关键要点:
- Dapper 这类微 ORM(Micro-ORM)虽然提供了最好的性能,但也需要去做最多的工作。
- 在无需复杂对象图时,Chain 这类 Fluent ORM 更易于使用。
- 对实体框架(Entity Framework)做大量的工作后,其性能可显著提高。
- 为获得数据库的最大性能,需要采用可能会有些繁琐的投影(Projection)操作。
- ORM 整体上的局部更新可能会存在问题。
在现代企业开发中,可采用多种方法构建数据存取层(data access layer ,DAL)。使用 C#做开发时,DAL 的最底层几乎总是使用 ADO.NET。但这时常会形成一个笨重的库,所以通常会在 DAL 的底层之上再部署一个 ORM 层。为允许模拟和隐藏 ORM 的细节,整个 DAL 包装在存储内。
在这一系列的文章中,我们将审视三种使用不同类型 ORM 构建仓储模式的方法,分别是:
- 实体框架:一种传统的“全特性”或“OOP”类型的 ORM。
- Dapper :一种主要专注结果集映射的轻量级微 ORM。
- Tortuga Chain :一种基于函数式编程理念的 Fluent ORM。
本文将侧重于开发人员可在典型仓储中用到的那些基本功能。在本系列文章的第二部分,我们将着眼于那些开发人员基于实际情况而实现的高级技术。
插入(Insert)操作
对于任何 CRUD 操作集,通常会首先实现基本的插入操作,进而可用插入操作对其它的操作进行测试。
Chain
Chain 使用列名和属性名间的运行时匹配。对于在数据库中并不存在的对象,除非启用了严格模式(strict model),否则将忽略该对象上的属性。类似地,没有匹配属性的列不能成为生成 SQL 的组成部分。
public int Insert(Employee employee) { return m_DataSource.Insert("HR.Employee", employee).ToInt32().Execute(); }
Dapper
没有第三方扩展时,Dapper 需要编程人员手工指定所需的 SQL,其中包括了特定于数据库的逻辑,用于返回新创建的主键。
public int Insert(Employee employee) { const string sql = @"INSERT INTO HR.Employee (FirstName, MiddleName, LastName, Title, ManagerKey, OfficePhone, CellPhone ) VALUES (@FirstName, @MiddleName, @LastName, @Title, @ManagerKey, @OfficePhone, @CellPhone ); {1} SELECT SCOPE_IDENTITY() "; using (var con = new SqlConnection(m_ConnectionString)) { con.Open(); return con.ExecuteScalar<int>(sql, employee); } }
实体框架
实体框架使用编译阶段映射在运行时生成 SQL。需将任何没有匹配列的属性标记为 NotMapped,否则将会产生错误。
public int Insert(Employee employee) { using (var context = new CodeFirstModels()) { context.Employees.Add(employee); context.SaveChanges(); return employee.EmployeeKey; } }
更新(Update)操作
Chain
Chain 缺省使用数据库中所定义的主键。但是在设置了适当的插入选项后,它将在模型中使用 Key 属性。
public void Update(Employee employee) { m_DataSource.Update("HR.Employee", employee).Execute(); }
Dapper
与插入操作一样,纯 Dapper 需用户手工编写必要的 SQL 语句。
public void Update(Employee employee) { const string sql = @"UPDATE HR.Employee SET FirstName = @FirstName, MiddleName = @MiddleName, LastName = @LastName, Title = @Title, ManagerKey = @ManagerKey, OfficePhone = @OfficePhone, CellPhone = @CellPhone WHERE EmployeeKey = @EmployeeKey "; using (var con = new SqlConnection(m_ConnectionString)) { con.Open(); con.Execute(sql, employee); } }
实体框架(初学者)
实体框架为 UPDATE 语句查找 Key 属性,以生成 WHERE 语句。
public void Update(Employee employee) { using (var context = new CodeFirstModels()) { var entity = context.Employees.Where(e => e.EmployeeKey == employee.EmployeeKey).First(); entity.CellPhone = employee.CellPhone; entity.FirstName = employee.FirstName; entity.LastName = employee.LastName; entity.ManagerKey = employee.ManagerKey; entity.MiddleName = employee.MiddleName; entity.OfficePhone = employee.OfficePhone; entity.Title = employee.Title; context.SaveChanges(); } }
实体框架(中级用户)
使用实体框架时,初学者常会在执行更新操作上犯错误。将实体添加到上下文中很容易就能实现它,而这种模式应成为中级使用者的常识。这里给出使用实体状态“Modified”修正后的例子。
public void Update(Employee employee) { using (var context = new CodeFirstModels()) { context.Entry(employee).State = EntityState.Modified; context.SaveChanges(); } }
读取全部(Read All)操作
读取全部操作在实体框架和 Chain 中是十分相似的,不同之处在于在实体框架中实现需要编写更多行的代码,而在 Chain 中实现需要编写更长的代码行。
Dapper 当然是最为繁琐的,因为它需要未经加工的 SQL 语句。即使如此,仍可以通过使用 SELECT * 语句替代手工地指定列名而在一定程度上降低 Dapper 的开销。这在存在返回额外数据的风险的情况下,降低了出现类与 SQL 语句不匹配的可能性。
Chain
在 Chain 中,ToObject 连接生成一系列所需的列。通过匹配所需列表与可用列的列表,From 连接生成 SQL 语句。
public IList<Employee> GetAll() { return m_DataSource.From("HR.Employee").ToCollection<Employee>().Execute(); }
Dapper
Dapper 是最为繁琐的,因为它需要原始未经加工的 SQL 语句。虽然这令人皱眉头,但仍可以通过使用 SELECT * 语句替代手工地指定列名而在一定程度上降低 Dapper 的开销,这样是不太可能漏掉列的,虽然存在返回额外数据的风险。
public IList<Employee> GetAll() { using (var con = new SqlConnection(m_ConnectionString)) { con.Open(); return con.Query<Employee>("SELECT e.EmployeeKey, e.FirstName, e.MiddleName, e.LastName, e.Title, e.ManagerKey, e.OfficePhone, e.CellPhone, e.CreatedDate FROM HR.Employee e").AsList(); } } {1}
实体框架
像以前一样,实体框架使用编译期信息确定如何生成 SQL 语句。
public IList<Employee> GetAll() { using (var context = new CodeFirstModels()) { return context.Employees.ToList(); } }
按标识符获取(Get by Id)操作
需要注意的是,随每个例子的语法稍作修改就可表明只返回一个对象。同样的基本过滤技术可用于返回多个对象。
Chain
Chain 严重依赖于“过滤对象”。这些对象直接被转义成参数化的 WHERE 语句,语句中的每个属性间具有“AND”操作符。
public Employee Get(int employeeKey) { return m_DataSource.From("HR.Employee", new { @EmployeeKey = employeeKey }).ToObject<Employee>().Execute(); }
Chain 也允许用参数化的字符串表示 WHERE 语句,虽然这个功能很少被用到。
如果主键是标量,即主键中只有一列,那么可使用简化的语法。
public Employee Get(int employeeKey) { return m_DataSource.GetByKey("HR.Employee", employeeKey).ToObject<Employee>().Execute(); }
Dapper
下例中,可以看到 Dapper 手工指定了 SQL 语句。该语句与 Chain 和实体框架所生成的 SQL 语句在本质上是一致的。
using (var con = new SqlConnection(m_ConnectionString)) { con.Open(); return con.Query<Employee>("SELECT e.EmployeeKey, e.FirstName, e.MiddleName, e.LastName, e.Title, e.ManagerKey, e.OfficePhone, e.CellPhone, e.CreatedDate FROM HR.Employee e WHERE e.EmployeeKey = @EmployeeKey", new { @EmployeeKey = employeeKey }).First(); } {1}
实体框架
实体框架将表名和首个 ToList 或 First 操作间的所有内容看作为一个表达式树。在运行时评估该树以生成 SQL 语句。
public Employee Get(int employeeKey) { using (var context = new CodeFirstModels()) { return context.Employees.Where(e => e.EmployeeKey == employeeKey).First(); } }
删除(Delete)操作
Chain
Chain 期待包括主键的参数对象。而参数对象中的其它特性将被忽略(该语法不支持批量删除)。
public void Delete(int employeeKey) { m_DataSource.Delete("HR.Employee", new { @EmployeeKey = employeeKey }).Execute(); }
如果有标量主键,可使用简化的语法。
public void Delete(int employeeKey) { m_DataSource.DeleteByKey("HR.Employee", employeeKey).Execute(); }
Dapper
public void Delete(int employeeKey) { using (var con = new SqlConnection(m_ConnectionString)) { con.Open(); con.Execute("DELETE FROM HR.Employee WHERE EmployeeKey = @EmployeeKey", new { @EmployeeKey = employeeKey }); } }
实体框架(初学者)
初学者一般会取回一个记录然后迅速删除,丢弃所有返回的信息。
public void Delete(int employeeKey) { using (var context = new CodeFirstModels()) { var employee = context.Employees.Where(e => e.EmployeeKey == employeeKey).First(); context.Employees.Remove(employee); context.SaveChanges(); } }
实体框架(中级用户)
可使用内嵌 SQL 避免数据库的往返交互操作。
public void Delete(int employeeKey) { using (var context = new CodeFirstModels()) { context.Database.ExecuteSqlCommand("DELETE FROM HR.Employee WHERE EmployeeKey = @p0", employeeKey); } }
投影(Projection)操作
投影是中间层开发中的一个重要部分。在取回了比实际所需更多的数据时,数据库常会完全失去使用覆盖索引或索引的能力,这将导致严重的性能影响。
Chain
同上,Chain 将仅选取指定对象类型所需的所有列。
public IList<EmployeeOfficePhone> GetOfficePhoneNumbers() { return m_DataSource.From("HR.Employee").ToCollection<EmployeeOfficePhone>().Execute(); }
Dapper
鉴于 Dapper 是显式的,所以是由开发人员确保只选取必需的列。
public IList<EmployeeOfficePhone> GetOfficePhoneNumbers() { using (var con = new SqlConnection(m_ConnectionString)) { con.Open(); return con.Query<EmployeeOfficePhone>("SELECT e.EmployeeKey, e.FirstName, e.LastName, e.OfficePhone FROM HR.Employee e").AsList(); } }
实体框架
实体框架需要额外的操作步骤,这些步骤常因为有些繁琐而被忽视。
通过在调用 ToList 前就包括了额外的选择语句,实体架构可生成正确的 SQL 语句,并避免从数据库返回过多的信息。
public IList<EmployeeOfficePhone> GetOfficePhoneNumbers() { using (var context = new CodeFirstModels()) { return context.Employees.Select(e => new EmployeeOfficePhone() { EmployeeKey = e.EmployeeKey, FirstName = e.FirstName, LastName = e.LastName, OfficePhone = e.OfficePhone }).ToList(); } }
使用投影做更新操作
固然,在存在投影对象时直接从投影对象更新数据库是一种好的方法。该方法在 Chain 和 Dapper 的基本模式中是天然存在的。而在实体框架中,则必须要在手工拷贝属性和编写 Dapper 风格的内嵌 SQL 这两种方法间做出选择。
Chain
注意,任何未在投影类上具有匹配属性的列将不受到影响。
public void Update(EmployeeOfficePhone employee) { return m_DataSource.Update("HR.Employee", employee).Execute(); }
Dapper
public void Update(EmployeeOfficePhone employee) { const string sql = @"UPDATE HR.Employee SET FirstName = @FirstName, LastName = @LastName, OfficePhone = @OfficePhone WHERE EmployeeKey = @EmployeeKey "; using (var con = new SqlConnection(m_ConnectionString)) { con.Open(); con.Execute(sql, employee); } }
实体框架
public void Update(EmployeeOfficePhone employee) { using (var context = new CodeFirstModels()) { var entity = context.Employees.Where(e => e.EmployeeKey == employee.EmployeeKey).First(); entity.FirstName = employee.FirstName; entity.LastName = employee.LastName; entity.OfficePhone = employee.OfficePhone; context.SaveChanges(); } }
反射插入(Reflexive Insert)
现在我们来看一些更有意思的用例。反射插入意味着返回被插入的对象。做反射插入通常是为了获得默认的和计算的域。
模型
注意,实体框架和 Chain 需要对属性进行注释,这样库才会知道该域将由数据库予以设置。
[DatabaseGenerated(DatabaseGeneratedOption.Computed)] //Needed by EF [IgnoreOnInsert, IgnoreOnUpdate] //Needed by Chain public DateTime? CreatedDate { get; set; }
Chain
Chain 允许将 ToObject 附加到任何插入或更新操作上。
public Employee InsertAndReturn(Employee employee) { return m_DataSource.Insert("HR.Employee", employee).ToObject<Employee>().Execute(); }
Dapper
使用 Dapper 的反射插入,可以使用特定于数据库的功能实现,例如 OUTPUT 语句。
public Employee InsertAndReturn(Employee employee) { const string sql = @"INSERT INTO HR.Employee (FirstName, MiddleName, LastName, Title, ManagerKey, OfficePhone, CellPhone ) OUTPUT Inserted.EmployeeKey, Inserted.FirstName, Inserted.MiddleName, Inserted.LastName, Inserted.Title, Inserted.ManagerKey, Inserted.OfficePhone, Inserted.CellPhone, Inserted.CreatedDate VALUES (@FirstName, @MiddleName, @LastName, @Title, @ManagerKey, @OfficePhone, @CellPhone );"; using (var con = new SqlConnection(m_ConnectionString)) { con.Open(); return con.Query<Employee>(sql, employee).First(); } }
如果一并考虑初学者级别模式,更典型的做法是仅在 Get 方法之后调用 Insert 方法。
public Employee InsertAndReturn_Novice(Employee employee) { return Get(Insert(employee)); }
实体框架
使用前面提及的 DatabaseGenerated 属性,你可以插入一个新的实体并读回它的计算的和 / 或默认的列。
public Employee InsertAndReturn(Employee employee) { using (var context = new CodeFirstModels()) { context.Employees.Add(employee); context.SaveChanges(); return employee; } }
受限更新 / 局部更新
有时应用并没有打算对每个列做更新,尤其是当模型是直接源自于 UI 并可能混合了可更新域和不可更新域时。
Chain
在 Chain 中,使用 IgnoreOnInsert 和 IgnoreOnUpdate 属性去限制插入和更新操作。为允许用数据库作为默认取值,典型的做法是将这两个属性都置于 CreatedDate 类型的列中。为避免更新操作过程中的意外改变,通常将 IgnoreOnUpdate 属性置于 CreatedBy 之类的列上。
Dapper
就显式编写的插入和更新语句而言,Dapper 最具灵活性。
实体框架
除了计算列(列值为表达式),实体框架并未给出一种简单的方法可声明某一列不参与插入或删除操作,但可使用更新操作的“读 - 拷贝 - 写”(read-copy-write)模式模拟该行为。
更新或插入(Upsert)操作
经常需要作为一个单一操作完成记录的插入或者更新,尤其是在使用自然主键(natural key)时。
Chain
在 Chain 中,Upsert 操作的实现使用了与插入和删除相同的设计。所生成的 SQL 随数据库引擎不同而各异(例如:SQL Server 使用了 MERGE,SQLit 使用了一系列语句)。
public int Upsert(Employee employee) { return m_DataSource.Upsert("HR.Employee", employee).ToInt32().Execute(); }
Dapper
在 Dapper 中,Upsert 操作的实现需要多轮的来回交互,或是需要比较复杂的特定于数据库的 SQL 语句。本文对此不作阐述。
实体框架
在实体框架中,这(过程?函数?都可以用“这”指代)仅作为被改进的更新操作的一个变体。
public int Upsert(Employee employee) { using (var context = new CodeFirstModels()) { if(employee.EmployeeKey == 0) context.Entry(employee).State = EntityState.Added; else context.Entry(employee).State = EntityState.Modified; context.SaveChanges(); return employee.EmployeeKey; } }
性能
虽然本文所采用的主要基准测试是代码量和易用性,但是对实际性能的考虑也是非常有用的。
所有的性能基准测试中都包括了预热过程,其后是对主循环做 1000 次迭代操作。每次测试中都使用了同样的模型,模型使用实体框架的代码优先(Code First)技术从数据库代码生成器产生。所有迭代都相当于共计 13 个基本 CRUD 操作,其中包括创建、读取、更新和删除操作。
我要澄清的是,这里所做的仅是一些粗略的测试,使用了任何人在刚开始接触这些库时通常就会看到的代码类型。当然一些高级技术可以改进每个测试的性能,有时甚至是极大地改进。
BenchmarkDotNet 计时
- Chain:平均 3.4160 毫秒,标准偏差为 0.2764 毫秒;
- 未使用经编译的物化器(Compiled Materializers)的 Chain:平均 3.0955 毫秒,标准偏差 0.1391 毫秒;
- Dapper:平均 2.7250 毫秒,标准偏差 0.1840 毫秒;
- 实体框架(初学者):平均 13.1078 毫秒,标准偏差 0.4649 毫秒;
- 实体框架(中级用户):平均 10.11498 毫秒,标准偏差 0.1952 毫秒;
- 实体框架(未使用 AsNoTracking 的中级用户):平均 9.7290 毫秒,标准偏差 0.3281 毫秒。
结论
虽然可使用任何 ORM 框架去实现基本的仓储模式,但是各种实现的性能和所需的代码量具有显著的差异。选取实现方式时需要对这些因素进行平衡,此外还需考虑数据库可移植性、跨平台支持和开发人员经验等。
在该系列文章的第二部分,我们将着眼于那些不仅将仓储模式作为瘦抽象层的高级用例。
你可以在 GitHub 上获取本文的代码。
关于作者
Jonathan Allen的首份工作是在上世纪九十年代末做诊所的 MIS 项目,Allen 将项目逐步由 Access 和 Excel 升级到企业级的解决方法。在从事为财政部门编写自动交易系统代码的工作五年之后,他成为项目顾问,参与了包括机器人仓库 UI、癌症研究软件中间层、主要房地产保险企业的大数据需求等在内的各种行业项目。在闲暇时间,他喜欢研究源于 16 世纪的武术,并为其撰写文章。
查看英文原文: Implementation Strategies for the Repository Pattern with Entity Framework, Dapper, and Chain
感谢冬雨对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论