以下文章摘錄來自InfoQ,是一篇不錯的軟問,大家細細的品味
關鍵要點:
- 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
);
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預設使用資料庫中所定義的主鍵。但是在設定了适當的插入選項後,它将在模型中使用Key屬性。
public void Update(Employee employee)
{
m_DataSource.Update("HR.Employee", employee).Execute();
}
與插入操作一樣,純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中,ToObject連接配接生成一系列所需的列。通過比對所需清單與可用列的清單,From連接配接生成SQL語句。
public IList<Employee> GetAll()
{
return m_DataSource.From("HR.Employee").ToCollection<Employee>().Execute();
}
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();
}
}
像以前一樣,實體架構使用編譯期資訊确定如何生成SQL語句。
public IList<Employee> GetAll()
{
using (var context = new CodeFirstModels())
{
return context.Employees.ToList();
}
}
按辨別符擷取(Get by Id)操作
需要注意的是,随每個例子的文法稍作修改就可表明隻傳回一個對象。同樣的基本過濾技術可用于傳回多個對象。
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手工指定了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();
}
實體架構将表名和首個ToList或First操作間的所有内容看作為一個表達式樹。在運作時評估該樹以生成SQL語句。
public Employee Get(int employeeKey)
{
using (var context = new CodeFirstModels())
{
return context.Employees.Where(e => e.EmployeeKey == employeeKey).First();
}
}
删除(Delete)操作
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();
}
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将僅選取指定對象類型所需的所有列。
public IList<EmployeeOfficePhone> GetOfficePhoneNumbers()
{
return m_DataSource.From("HR.Employee").ToCollection<EmployeeOfficePhone>().Execute();
}
鑒于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這兩種方法間做出選擇。
注意,任何未在投影類上具有比對屬性的列将不受到影響。
public void Update(EmployeeOfficePhone employee)
{
return m_DataSource.Update("HR.Employee", employee).Execute();
}
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允許将ToObject附加到任何插入或更新操作上。
public Employee InsertAndReturn(Employee employee)
{
return m_DataSource.Insert("HR.Employee", employee).ToObject<Employee>().Execute();
}
使用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中,使用IgnoreOnInsert和IgnoreOnUpdate屬性去限制插入和更新操作。為允許用資料庫作為預設取值,典型的做法是将這兩個屬性都置于CreatedDate類型的列中。為避免更新操作過程中的意外改變,通常将IgnoreOnUpdate屬性置于CreatedBy之類的列上。
就顯式編寫的插入和更新語句而言,Dapper最具靈活性。
除了計算列(列值為表達式),實體架構并未給出一種簡單的方法可聲明某一列不參與插入或删除操作,但可使用更新操作的“讀-拷貝-寫”(read-copy-write)模式模拟該行為。
更新或插入(Upsert)操作
經常需要作為一個單一操作完成記錄的插入或者更新,尤其是在使用自然主鍵(natural key)時。
在Chain中,Upsert操作的實作使用了與插入和删除相同的設計。所生成的SQL随資料庫引擎不同而各異(例如:SQL Server使用了MERGE,SQLit使用了一系列語句)。
public int Upsert(Employee employee)
{
return m_DataSource.Upsert("HR.Employee", employee).ToInt32().Execute();
}
在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