9

造轮子:一个 ORM 持久层框架

 2 years ago
source link: https://www.liuwj.me/posts/FrameDAL/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

造轮子:一个 ORM 持久层框架

2015-11-14 | 刘文俊

这个想法其实已经在我心里很久了,自从对体检系统的框架伸出我的魔爪开始,我就一直想写一个属于自己的持久层框架。最近正好在学习 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 属性即可),配置文件文件的具体格式如下:

[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 节点如下:

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

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

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

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

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

配置实体映射

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

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)
)

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

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特性的属性。

配置好实体映射以后,就可以通过操作对象来操作数据库了,往 account 表添加一条记录的代码如下:

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 的麻烦。如下

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条记录的代码如下:

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 对象的方法有三个:

// 创建Query对象
IQuery CreateQuery();

// 创建Query对象,同时使用给定参数对其进行初始化
IQuery CreateQuery(string sqlText, params object[] parameters);

// 创建命名Query对象,从配置文件中读取给定名字的SQL对其进行初始化
IQuery CreateNamedQuery(string name, params object[] parameters);

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

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 类:

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 表中找到账户持有人的名字。这是一个连接查询,代码如下:

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 写在配置文件中,更换数据库的时候只需要更换配置文件,当然也可以用下面的这种方式:

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 哪个命名空间就一目了然了。

FrameDAL-01.png

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK