造轮子:一个 ORM 持久层框架
这个想法其实已经在我心里很久了,自从对体检系统的框架伸出我的魔爪开始,我就一直想写一个属于自己的持久层框架。最近正好在学习 Hibernate,这个潜藏在心中的想法便越来越强烈。于是我迫不及待地开始设计、编码,只是无奈应了这句话:
读书太少而想太多。
经过几天夜以继日的编码,虽然终于做出了这个勉强能够使用的原型,但还是有许多问题未能解决。这个框架现今只有最基本的功能,如果遇到的问题有待解决,将会实现集合映射、关联映射以及与之配套的懒加载功能。代码已经托管在了我的GitHub(vincentlauvlwj/FrameDAL),要是有大神能进去指点指点就再好不过了。
现在,请允许我拿这个可能连半成品都算不上的东西来强行装个B
Features
- 支持对象-关系映射,以面向对象的方式操作数据库。
- 多种主键生成策略。支持 UUID,自增长,序列等。。
- 多数据库支持,无缝切换。在不同数据库之间切换只需更换配置文件即可,不用改动任何代码
- 扩展性强,面向接口编程,可随时增加对其他数据库的支持
- 支持一级 Session 缓存,减少连接数据库的次数,避免频繁的建立连接操作
- 支持命名查询,把 SQL 写在配置文件中,实现业务逻辑代码与SQL的解耦
- 支持事务处理。
- 支持多线程操作。
列了一大堆,然而这些都是类似“共产主义接班人”、“2006年时代杂志风云人物”这样子的东西,看起来很牛逼实际上一点也不难做到算了,还是看看这个框架的用法好了。
Usage
添加引用
首先,clone 我在 GitHub 上的 repo,把代码下载下来,编译之后会产生一个 FrameDAL.dll
的文件,把这个 dll 复制到你的工程中,添加对它的引用。
添加其他依赖
如果你的项目使用的数据库需要依赖其他 dll,请添加这些 dll 的引用。例如,使用 MySQ L数据库,需要添加 MySql.Data.dll
。使用微软自家的数据库或者其他支持 ODBC 或 OleDb 的数据库可能不需要这步。具体的依赖支持须因数据库类型而定。
添加配置文件
在你的程序启动目录添加 FrameDAL.ini
配置文件(在最新的版本里已经可以自定义配置文件的路径了,只需要在 AppContext 类第一次被调用之前设置 Configuration.DefaultPath
属性即可),配置文件文件的具体格式如下:
1 | [Settings] |
可见,该配置文件分为三个节点。Settings
节点是框架的基本配置,其中 DbType
设置所使用的数据库类型,其值可为 MySQL
和 Oracle
(目前只支持这两个数据库,如果希望使用其他数据库,可联系我,或者自己在框架中添加代码。由于使用了面向接口编程,添加其他数据库的支持是一件很简单的事情)。ConnStr
节点配置数据库的连接字符串,其具体的内容依你所使用的数据库而定。这个节点与连接串不同的地方在于,连接串的键值对是用分号分隔的不换行字符串,这里无需分号分隔且需换行。如你的连接串为 “Provider=MSDAORA.1;Data Source=ORCL;User ID=scott;Password=tiger”,则 ConnStr
节点如下:
1 | [ConnStr] |
NamedSql
节点配置在代码中用到的 SQL 语句,把 SQL 写在配置文件中,可实现业务逻辑代码与 SQL 的解耦,也容易写出数据库无关的代码。举个栗子,你在配置文件中有如下设置:
1 | [NamedSql] |
在代码中可以使用名字获得具体的 SQL
1 | session.CreateNamedQuery("test.deleteAccount", id).ExecuteNonQuery(); |
配置实体映射
要使用面向对象的方式操作数据库,在代码中直接对对象进行操作的话,就要把类和数据库中的表映射起来,把类中的属性和表中的字段映射起来。假如你的数据库中有一个 account 表,它的建表 SQL 如下:
1 | CREATE TABLE account ( |
则可以使用如下的代码进行映射:
1 | using System; |
可以看到,这个类和普通的类的区别在于,它使用了各种特性来修饰,这些特性定义了从实体类到数据表之间的映射关系。要使用这些特性,需要 using FrameDAL.Attributes
的命名空间声明。下面介绍一下这些特性。Table
是表示数据表的特性类,施加在实体类上,表示该实体类对应数据库中的一张表,如 Table("account")
表示表名为account的一张表。Column
是表示数据表中的字段的特性类,施加在实体类的属性上,表示该实体类对应的数据表中的一个字段,如 Column("name")
表示表中明为name的字段。Id
是表示主键的特性类,施加在实体类的属性上,表示该属性对应的数据库字段是主键。可用 Id 特性配置主键生成器,主键生成是指在保存实体时,框架会根据不同的配置自动生成主键的值,不需要我们手动指定。Uuid 表示由框架自动生成 UUID 作为主键,Identity 表示使用数据库的自增长机制生成主键,Assign 表示手动为主键赋值,Sequence 表示使用数据库的序列生成主键(主要针对Oracle)。当主键生成器使用 Sequence 时,还需要给定 SeqName 参数,此参数表示序列的名称。另外,建议不要使用具有实际意义的物理主键,应该为数据库增加一列,作为没有任何实际意义的逻辑主键,即主键仅用于表示数据记录的唯一性,不具有具体的含义。并且,每个实体类中都应该有且只有一个带有Id特性的属性。
使用示例
CURD
配置好实体映射以后,就可以通过操作对象来操作数据库了,往 account 表添加一条记录的代码如下:
1 | Account account = new Account(); |
这段代码先 new 了一个 Account 对象,为这个对象设置了属性值。为了将这个对象插入数据库,通过 AppContext.Instance
获得了一个 AppContext
的实例,然后通过该实例打开了一个session,使用 session.Add(account)
将account对象插入了数据库,最后关闭了这个session。AppContext
是表示程序上下文的对象,它主要保存了框架运行中产生的一些全局的缓存信息,当然,最主要的作用是使用它打开 session。ISession
是一个接口,它表示一次数据库会话,可使用它提供的方法将从数据库中存取实体。session 对象会为我们管理数据库连接,当需要连接时它会自动打开,当使用完毕时它会自动把连接关闭,并且一个 session 对象可以多次打开和关闭连接,然而这些都由 session 的实现类完成,不需要调用者关心。
打开的 session 必须关闭,否则可能会丢失操作。因为 ISession
接口继承了 IDisposable
,因此可以使用 C♯ 的 using 代码块,免去关闭 session 的麻烦。如下
1 | using (ISession session = AppContext.Instance.OpenSession()) |
上面的代码在数据库中删除掉了 account 记录,并且使用了 using 代码块,不必手动调用 Close 方法。类似地,你也可以通过 session.Update(account);
更新数据库中的 account 记录,还可以通过 Get
方法获得数据库中的记录,不过 Get
是个泛型方法,需要给它指定类型参数,即要获取的实体的类型,如 Account account = session.Get<Account>(id);
可以看到,上面的代码操作的都是对象,框架在后台会自动生成SQL命令,自动打开和关闭数据库连接,你需要关注的只是你的业务逻辑,不再需要写那些重复而且繁琐的代码。
事务处理
事务是指访问数据库的一个操作序列,里面的操作要么全部完成,要么全部失败,不存在中间的状态。事务必须服从 ACID 原则,即原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。本框架也提供了对事务的支持,当然,这依赖于底层的数据库。
支持事务的方法也在 session 中,使用事务往数据库中插入50条记录的代码如下:
1 | using (ISession session = AppContext.Instance.OpenSession()) |
上面的代码先使用 session.BeginTransaction()
开启了事务,然后执行了 50 次插入操作,操作完成后调用 session.CommitTransaction()
提交事务,若中间有异常发生,则会跳转到 catch 代码块,调用 session.RollbackTransaction()
回滚事务。回滚事务之前调用 InTransaction()
方法判断一下事务是否已成功开启,避免开启事务失败时又尝试回滚事务引发InvalidOperationException
。
查询
本框架还开放了直接执行 SQL 命令的方法以增加灵活性,以及实现某些面向对象的方式难以实现的功能。这是通过 IQuery
接口来实现的。可以通过 session 来获得 Query 对象,获得 Query 对象的方法有三个:
1 | // 创建Query对象 |
其中命名 Query 在前面已经介绍过了,在此不赘述。
查询 account 表中名字为 boc 的记录的代码如下:
1 | string sql = "select * from account where name=?"; |
为了防止 SQL 注入,我们使用参数化查询的方式,在需要填入具体参数的地方使用问号占位符代替,把带有占位符的 SQL 送到数据库中编译,等到执行的时候才将具体参数的值填入。在 C♯ 中,不同数据库使用的占位符格式是不一样的,有的使用问号占位符,也有使用 @param
形式的占位符,还有使用形如 :param
的占位符的。为了业务逻辑代码的数据库无关性,本框架一律使用问号占位符,由框架自动把问号占位符形式的 SQL 转换为不同数据库需要的占位符形式。
上面的 ExecuteGetList<T>()
是一个泛型方法,类型参数是查询的表对应的实体类的类型。实际上,该类型参数并不限于实体类的类型,也可以使用任何自定义的 VO 类。比如有这样一个 VO 类:
1 | public class AccountOwner |
这个类用来保存账户和账户的持有人的信息。在前面的章节中的提到的 account 表中有一个 user_id 字段,通过这个 user_id,我们可以在 user 表中找到账户持有人的名字。这是一个连接查询,代码如下:
1 | string sql = @" |
上面的代码把查询到的结果封装成 AccountOwner 对象的数组,以方便对结果进行对象化的操作。容易看出,框架之所以能正确封装查询结果,得益于 AccountOwner 类的属性上添加的 Column 特性。这个 VO 类和实体的区别在于,它只使用了 Column 特性,没有使用 Table 和 Id 特性(对于查询结果来说,这两个特性并没有什么卵用)。这就是 Column 特性的另一个使用方法,它不仅可以把属性映射到表中的字段,还可以把属性映射到查询结果中的数据列。
不同的数据库的 SQL 语法是有差异的,这些差异的其中一个表现在分页查询。例如 MySQL 的分页查询使用 limit
关键字,而 Oracle 的分页查询是使用 rownum
进行筛选。为了掩盖这些差异,写出数据库无关的业务逻辑代码,我们可以使用命名查询,也就是把 SQL 写在配置文件中,更换数据库的时候只需要更换配置文件,当然也可以用下面的这种方式:
1 | IQuery query = session.CreateQuery("select * from account"); |
使用 IQuery
接口中的分页查询 API,把数据库之间的差异交给框架去处理。
最后,IQuery
接口并不仅限于执行查询命令,你还可以使用它的 ExecuteNonQuery()
方法执行非查询操作,在此不作赘述。
The End
仅有这篇文章当然是不足以完全了解这个框架的,如果对它有兴趣的话,可以阅读源代码里面的文档注释。关于如何实现 Hibernate 中的懒加载,我正在看它的源码,等研究出来了,再往这个框架里加入这样的功能(没错,我这就是一个山寨版的 Hibernate_(:з」∠)_)。
花了一个多星期的时间分析、设计与编码,才装成了这样一个B,还可能分分钟被大神教做人。但是,这个B还是要装下去,不为别的,就为了心中的那一点执念。
对了,前面的代码示例中很多都是需要 using 命名空间的,在这里把代码的目录结构给出来,要 using 哪个命名空间就一目了然了。
PS
- 上图中为早期版本的目录结构,新版本有较大变化。
- 新版本新增了懒加载以及 LINQ 查询的特性,详情可直接查看 GitHub 中的源码,具体使用方法不在此赘述。