造轮子:一个 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
2
3
4
5
6
7
8
9
10
11
12
[Settings]
DbType=MySQL
[ConnStr]
server=localhost
; ...
; 其他连接串中需要的配置信息...
; ...
[NamedSql]
test.query=select * from account where id=?
; ...
; 其他命名SQL的名称和值...
; ...

可见,该配置文件分为三个节点。
Settings 节点是框架的基本配置,其中 DbType 设置所使用的数据库类型,其值可为 MySQLOracle (目前只支持这两个数据库,如果希望使用其他数据库,可联系我,或者自己在框架中添加代码。由于使用了面向接口编程,添加其他数据库的支持是一件很简单的事情)。
ConnStr 节点配置数据库的连接字符串,其具体的内容依你所使用的数据库而定。这个节点与连接串不同的地方在于,连接串的键值对是用分号分隔的不换行字符串,这里无需分号分隔且需换行。如你的连接串为 “Provider=MSDAORA.1;Data Source=ORCL;User ID=scott;Password=tiger”,则 ConnStr 节点如下:

1
2
3
4
5
[ConnStr]
Provider=MSDAORA.1
Data Source=ORCL
User ID=scott
Password=tiger

NamedSql 节点配置在代码中用到的 SQL 语句,把 SQL 写在配置文件中,可实现业务逻辑代码与 SQL 的解耦,也容易写出数据库无关的代码。举个栗子,你在配置文件中有如下设置:

1
2
[NamedSql]
test.deleteAccount=delete from account where id=?

在代码中可以使用名字获得具体的 SQL

1
session.CreateNamedQuery("test.deleteAccount", id).ExecuteNonQuery();

配置实体映射

要使用面向对象的方式操作数据库,在代码中直接对对象进行操作的话,就要把类和数据库中的表映射起来,把类中的属性和表中的字段映射起来。假如你的数据库中有一个 account 表,它的建表 SQL 如下:

1
2
3
4
5
6
7
8
CREATE TABLE account (
id varchar(255) NOT NULL,
user_id varchar(255) default NULL,
name varchar(255) default NULL,
password varchar(255) default NULL,
balance int(255) default NULL,
PRIMARY KEY (id)
)

则可以使用如下的代码进行映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using FrameDAL.Attributes;
namespace FrameTest
{
[Table("account")]
public class Account
{
[Id(GeneratorType.Uuid)]
[Column("id")]
public string Id { get; set; }
[Column("user_id")]
public string UserId { get; set; }
[Column("name")]
public string Name { get; set; }
[Column("password")]
public string Password { get; set; }
[Column("balance")]
public int? Balance { get; set; }
}
}

可以看到,这个类和普通的类的区别在于,它使用了各种特性来修饰,这些特性定义了从实体类到数据表之间的映射关系。要使用这些特性,需要 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
2
3
4
5
6
7
Account account = new Account();
account.Name = "test";
account.Password = "pwd";
AppContext context = AppContext.Instance;
ISession session = context.OpenSession();
session.Add(account);
session.Close();

这段代码先 new 了一个 Account 对象,为这个对象设置了属性值。为了将这个对象插入数据库,通过 AppContext.Instance 获得了一个 AppContext 的实例,然后通过该实例打开了一个session,使用 session.Add(account) 将account对象插入了数据库,最后关闭了这个session。AppContext 是表示程序上下文的对象,它主要保存了框架运行中产生的一些全局的缓存信息,当然,最主要的作用是使用它打开 session。ISession 是一个接口,它表示一次数据库会话,可使用它提供的方法将从数据库中存取实体。session 对象会为我们管理数据库连接,当需要连接时它会自动打开,当使用完毕时它会自动把连接关闭,并且一个 session 对象可以多次打开和关闭连接,然而这些都由 session 的实现类完成,不需要调用者关心。
打开的 session 必须关闭,否则可能会丢失操作。因为 ISession 接口继承了 IDisposable,因此可以使用 C♯ 的 using 代码块,免去关闭 session 的麻烦。如下

1
2
3
4
using (ISession session = AppContext.Instance.OpenSession())
{
session.Delete(account);
}

上面的代码在数据库中删除掉了 account 记录,并且使用了 using 代码块,不必手动调用 Close 方法。类似地,你也可以通过 session.Update(account); 更新数据库中的 account 记录,还可以通过 Get 方法获得数据库中的记录,不过 Get 是个泛型方法,需要给它指定类型参数,即要获取的实体的类型,如 Account account = session.Get<Account>(id);
可以看到,上面的代码操作的都是对象,框架在后台会自动生成SQL命令,自动打开和关闭数据库连接,你需要关注的只是你的业务逻辑,不再需要写那些重复而且繁琐的代码。

事务处理

事务是指访问数据库的一个操作序列,里面的操作要么全部完成,要么全部失败,不存在中间的状态。事务必须服从 ACID 原则,即原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。本框架也提供了对事务的支持,当然,这依赖于底层的数据库。
支持事务的方法也在 session 中,使用事务往数据库中插入50条记录的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using (ISession session = AppContext.Instance.OpenSession())
{
try
{
session.BeginTransaction();
for (int i = 0; i < 50; i++)
{
Account account = new Account();
account.Name = "Test" + i;
session.Add(account);
}
session.CommitTransaction();
}
catch
{
if(session.InTransaction()) session.RollbackTransaction();
}
}

上面的代码先使用 session.BeginTransaction() 开启了事务,然后执行了 50 次插入操作,操作完成后调用 session.CommitTransaction() 提交事务,若中间有异常发生,则会跳转到 catch 代码块,调用 session.RollbackTransaction() 回滚事务。回滚事务之前调用 InTransaction() 方法判断一下事务是否已成功开启,避免开启事务失败时又尝试回滚事务引发InvalidOperationException

查询

本框架还开放了直接执行 SQL 命令的方法以增加灵活性,以及实现某些面向对象的方式难以实现的功能。这是通过 IQuery 接口来实现的。可以通过 session 来获得 Query 对象,获得 Query 对象的方法有三个:

1
2
3
4
5
6
7
8
// 创建Query对象
IQuery CreateQuery();
// 创建Query对象,同时使用给定参数对其进行初始化
IQuery CreateQuery(string sqlText, params object[] parameters);
// 创建命名Query对象,从配置文件中读取给定名字的SQL对其进行初始化
IQuery CreateNamedQuery(string name, params object[] parameters);

其中命名 Query 在前面已经介绍过了,在此不赘述。
查询 account 表中名字为 boc 的记录的代码如下:

1
2
string sql = "select * from account where name=?";
List<Account> result = session.CreateQuery(sql, "boc").ExecuteGetList<Account>();

为了防止 SQL 注入,我们使用参数化查询的方式,在需要填入具体参数的地方使用问号占位符代替,把带有占位符的 SQL 送到数据库中编译,等到执行的时候才将具体参数的值填入。在 C♯ 中,不同数据库使用的占位符格式是不一样的,有的使用问号占位符,也有使用 @param 形式的占位符,还有使用形如 :param 的占位符的。为了业务逻辑代码的数据库无关性,本框架一律使用问号占位符,由框架自动把问号占位符形式的 SQL 转换为不同数据库需要的占位符形式。
上面的 ExecuteGetList<T>() 是一个泛型方法,类型参数是查询的表对应的实体类的类型。实际上,该类型参数并不限于实体类的类型,也可以使用任何自定义的 VO 类。比如有这样一个 VO 类:

1
2
3
4
5
6
7
8
public class AccountOwner
{
[Column("account_name")]
public string AccountName { get; set; }
[Column("user_name")]
public string Owner { get; set; }
}

这个类用来保存账户和账户的持有人的信息。在前面的章节中的提到的 account 表中有一个 user_id 字段,通过这个 user_id,我们可以在 user 表中找到账户持有人的名字。这是一个连接查询,代码如下:

1
2
3
4
string sql = @"
select account.name as account_name, user.name as user_name
from account left outer join user on (account.user_id=user.id)";
List<AccountOwner> result = session.CreateQuery(sql).ExecuteGetList<AccountOwner>();

上面的代码把查询到的结果封装成 AccountOwner 对象的数组,以方便对结果进行对象化的操作。容易看出,框架之所以能正确封装查询结果,得益于 AccountOwner 类的属性上添加的 Column 特性。这个 VO 类和实体的区别在于,它只使用了 Column 特性,没有使用 Table 和 Id 特性(对于查询结果来说,这两个特性并没有什么卵用)。这就是 Column 特性的另一个使用方法,它不仅可以把属性映射到表中的字段,还可以把属性映射到查询结果中的数据列。
不同的数据库的 SQL 语法是有差异的,这些差异的其中一个表现在分页查询。例如 MySQL 的分页查询使用 limit 关键字,而 Oracle 的分页查询是使用 rownum 进行筛选。为了掩盖这些差异,写出数据库无关的业务逻辑代码,我们可以使用命名查询,也就是把 SQL 写在配置文件中,更换数据库的时候只需要更换配置文件,当然也可以用下面的这种方式:

1
2
3
4
IQuery query = session.CreateQuery("select * from account");
query.FirstResult = 0; // 设置返回的第一条结果的索引,该索引从0开始
query.PageSize = 10; // 设置返回的结果的数量,设置为0表示返回全部结果,默认为0
List<Account> result = query.ExecuteGetList<Account>();

使用 IQuery 接口中的分页查询 API,把数据库之间的差异交给框架去处理。
最后,IQuery接口并不仅限于执行查询命令,你还可以使用它的 ExecuteNonQuery() 方法执行非查询操作,在此不作赘述。

The End

仅有这篇文章当然是不足以完全了解这个框架的,如果对它有兴趣的话,可以阅读源代码里面的文档注释。关于如何实现 Hibernate 中的懒加载,我正在看它的源码,等研究出来了,再往这个框架里加入这样的功能(没错,我这就是一个山寨版的 Hibernate_(:з」∠)_)。
花了一个多星期的时间分析、设计与编码,才装成了这样一个B,还可能分分钟被大神教做人。但是,这个B还是要装下去,不为别的,就为了心中的那一点执念。
对了,前面的代码示例中很多都是需要 using 命名空间的,在这里把代码的目录结构给出来,要 using 哪个命名空间就一目了然了。

PS

  1. 上图中为早期版本的目录结构,新版本有较大变化。
  2. 新版本新增了懒加载以及 LINQ 查询的特性,详情可直接查看 GitHub 中的源码,具体使用方法不在此赘述。
0条评论
游客评论 游客