使用LINQ Expression構建Query Object
這個問題來源于Apworks應用開發框架的設計。由于命令與查詢職責的分離,使得基于CQRS體系結構風格的應用系統的外部存儲系統的結構變得簡單起來:在“命令”部分,簡單地說,只需要 Event Store和Snapshot Store來保存Domain Model;而“查詢”部分,則又是基于事件派送與偵聽的系統集成。之前我也提到過,“查詢”部分由于不牽涉到Domain Model,于是,它的設計應該隨意性很大:不需要受到Domain Model的牽制,例如,我們可以根據UI所需的數據源結構進行“查詢”庫的設計。Greg Young在他的“CQRS Documents”一文中也提到了這樣一些相關話題:就“查詢”部分而言,雖然也存在“阻抗失衡”效應,但是事件模型與關系模型之間的這種效應,要遠遠小于對象模型與關系模型之間的“阻抗失衡”效應。這是因為,事件模型本身沒有結構,它僅僅表述“該對關系模型做哪些操作”這樣的概念。在設計上,Greg Young建議,采用一種非正規的方式設計“查詢”數據庫,以便盡量減少讀取數據時所需的JOIN操作,比如可以選用基于第一范式(1NF)的關系模型。這是一種反范式模型,雖然Greg Young并沒有建議根據UI所需的數據源結構進行設計,但思想是相同的:基于事件而不拘泥于對象模型本身。由此引申出來的另一個好處就是外部存儲系統架構的隨意性:你可以選用任何存儲技術和存儲媒介,這又給基于系統架構的性能優化提供了便利。因為并非所有的存儲架構都支持“表”、“字段”、“存儲過程”、“JOIN”這些概念。
根據上面的簡單分析,我們得到一個結論:通常情況下,或許基于CQRS體系結構風格的應用系統更多的是采用的“平整”的外部存儲結構,簡而言之,就是一個數據訪問對象(DAO)對應一張數據表。這也是我所設計的Apworks應用開發框架中默認支持的一種存儲結構。有讀過Apworks源代碼的朋友會發現,在Apworks.Events.Storage命名空間下,有兩個定制的DAO:DomainEventDataObject,用于表述領域事件的數據結構,以及SnapshotDataObject,用于表述快照的數據結構,與之相對應的就是數據庫中的兩張表:DomainEvents和 Snapshots。雖然結構變得這么簡單,但是映射關系總還是需要維護的:最簡單的就是需要在對象類型名稱與數據表名之間,以及對象屬性與數據表字段之間建立起映射關系。在Apworks中,這種映射關系是由Apworks.Storage.IStorageMappingResolver接口完成的。有關這個接口的內容不是本文討論的重點,暫且不深入分析了。
至此,也許你不會接受我上面的討論,認為“基于UI設計數據庫結構”或者“采用1NF、反范式設計數據庫結構”是無法接受的,那么,接下來的討論可能對你來說意義也不大了。因為下面的問題是以上面的描述為基礎的:一個數據訪問對象對應一張數據表。不過即使你不認同我的觀點,我也建議你繼續看完本文。
Query Object模式
雖然只是簡單的映射,但畢竟不能忽略這樣的映射關系。Apworks作為一個應用開發框架,需要提供方便的整合接口,以便今后能夠根據不同的客戶需求進行擴展。例如在存儲部分,數據的增刪改查(CRUD)是基于數據訪問對象(DAO)的,這樣做的一個好處是能夠對外部存儲系統進行抽象,使得訪問存儲系統的部分能夠無需關系存儲系統的細節問題。客戶有可能選擇SQL Server、Oracle、MySQL等關系型數據庫作為存儲系統,也可以選擇其它的非關系型數據庫作為存儲系統,因此,我們的設計不能僅僅局限于關系型數據庫,我們需要同時考慮其它形式的數據存儲產品以便將來能夠方便地集成新的存儲方案。假設我們要設計一個針對 DomainEventDataObject的“查詢”功能,我們需要考慮的問題可能會有(但不一定僅限于):
* 需要查詢對象的哪些屬性(或者說與DomainEventDataObject相對應的數據表的哪些字段)
* 需要根據什么樣的條件進行查詢
* 查詢是否需要排序
* 是否只查結果集中的任意一條記錄,還是要返回所有的記錄
在Apworks框架的Alpha版本中,查詢的方法定義在Apworks.Storage.IStorage接口中。比如,根據給定的查詢條件和排序方式,對指定DAO進行查詢的方法定義如下:
/// Gets a list of ordered objects from storage by given selection criteria and order.
/// </summary>
/// <typeparam name="T">The type of the object to get.</typeparam>
/// <param name="criteria">The <c>PropertyBag</c> instance which contains the criteria.</param>
/// <param name="orders">The <c>PropertyBag</c> instance which contains the ordering fields.</param>
/// <param name="sortOrder">The sort order.</param>
/// <returns>A list of ordered objects.</returns>
IEnumerable<T> Select<T>(PropertyBag criteria, PropertyBag orders, SortOrder sortOrder)
where T : class, new();
這個方法是個泛型方法,泛型類型就是DAO的類型。它接受三個參數:前兩個是用于指定查詢條件和排序字段的PropertyBag,最后一個是指定排序方式的SortOrder。之所以采用PropertyBag,而不是接受SQL字符串,原因不僅是因為框架本身需要在今后能夠方便地支持非關系型數據庫,而且更重要的是,雖然SQL已經成為一種業界標準,但實際上不同的關系型數據庫產品對SQL的支持和實現方式也有所不同,有些關系型數據庫產品或許只支持SQL的一些子集,如果單純地把SQL字符串作為Select方法的參數,明顯是不合理的。事實上,Apworks.Storage.IStorage實現了Query Object模式[MF, PoEAA],Martin Fowler在他的PoEAA(《企業應用架構模式》)中有以下幾點可以供讀者參考:
* “編程語言是可以支持SQL語句的,但大多數開發人員對此不太熟悉。而且,你需要了解數據庫設計方案以便構造出查詢。可以通過創建特殊的、隱藏了 SQL內部參數化方法的查詢器方法避免這一點。但這樣難以構造更多的特別查詢。而且,如果數據庫設計方案改變,就會需要復制到SQL語句中”
* “查詢對象的一個共同特征是,它能夠利用內存對象的語言而不是用數據庫方案來描述查詢。這意味著我可以使用對象和域名,而不是表和列名。如果對象和數據庫具有相同的結構,這一點就不重要,如果兩者有差異,查詢對象就很有用。為了實現這樣的視角變化,查詢對象需要知道數據庫結構怎樣映射到對象結構,這一功能實際上要用到元數據映射”【daxnet注:上面提到過,在Apworks框架中,這個元數據映射的實現,就是 IStorageMappingResolver】
* “為了描述任意的查詢,你需要一個靈活的查詢對象。然而,應用程序經常能用遠少于SQL全部功能的操作來完成這一任務,在此情況下,你的查詢對象就會比較簡單。它不能代表任何東西,但它可以滿足特定的需要。此外,當需要更多功能而進行功能擴充時,通常不會比從零開始創建一個全能的查詢對象更麻煩。因此,應該為當前需求創建一個功能最小化的查詢對象,并隨著需求的增加改進這個查詢對象”
以上三點讓我很有感觸,特別是第三點。目前基于PropertyBag的設計,只能夠支持以AND連接的查詢條件,比如,類似“WHERE a=va AND b=vb AND c=vc…”這樣的查詢,雖然在Apworks Alpha版本中,這樣的查詢已經夠用了,但它不具備擴展性,基于關系型數據庫的存儲設計Apworks.Storage.RdbmsStorage已經將這種邏輯寫死了,倘若我們需要一個復雜的查詢,這種方式不僅沒法勝任,而且沒法擴展。PropertyBag應該要退休了。
在下一個版本的Apworks中,我使用.NET中的LINQ Expression代替了PropertyBag,并引入了一個WhereClauseBuilder的對象,專門根據LINQ Expression,針對關系型數據庫產生WHERE子句。使用LINQ Expression的好處有:
* LINQ Expression是.NET下的一種查詢標準,多數存儲系統產品能夠提供針對LINQ Expression的查詢解決方案,即使不提供,也可以自己定制Provider,雖然麻煩一點,但總歸是可以實現的
* LINQ Expression能夠完美地“利用內存對象的語言而不是用數據庫方案”來描述查詢,語言集成的特性,為開發人員帶來了更多的便捷
* Apworks中,Specification是基于LINQ Expression的,于是,Apworks.Storage.IStorage就能夠實現基于Specification的查詢,實現接口統一
于是技術問題來了:如何將LINQ Expression轉換成WHERE子句,以便Apworks.Storage.IStorage的類(Query Objects)能夠使用這個WHERE子句構造出SQL語句,進而通過ADO.NET直接訪問數據庫?Apworks選用的是Expression Visitor的方案:使用Expression Visitor遍歷表達式樹(Expression Tree)然后產生WHERE子句。在討論Expression Visitor之前,讓我們回顧一下對象結構以及Visitor模式。
Visitor模式
網上面有關Visitor模式的文章太多了,還有相當一部分討論的比較深入透徹,我也就不多說了。總之,Visitor模式在處理較復雜的對象結構時會顯得十分自然:它能夠遍歷結構中的每一個對象,然后針對不同的對象類型作不同的處理。這就看上去像是為這些對象擴展了一些方法一樣。之前,我有用過 Visitor模式來驗證程序配置節點的合理性,當節點的類型增加后,只需要擴展Visitor即可實現新的驗證邏輯,非常方便。模式歸模式,不同的應用場景,實現方式還是有所不同的。經典的Visitor例子,通常都是利用了函數的重載(多態性),并結合了Composite模式來說明問題,但實際上 Visitor并非一定需要使用函數重載,也不是僅能用在Composite上。Expression Visitor的實現方式,就與這經典的Visitor案例有所不同。
Expression Visitor
在System.Linq.Expressions命名空間下,有一個ExpressionVisitor的抽象類,我們只需要繼承這個抽象類,并重寫其中的某些Visit方法,即可實現WHERE子句的生成。在這里我不打算繼續去細究ExpressionVisitor是如何遍歷表達式樹的,我還是描述一下實現WHERE子句生成的幾個細節問題。
1. 支持哪些運算?
LINQ Expression的類型有85種,但并不是SQL中會支持到所有的這85種類型。目前Apworks打算支持常用的條件運算,比如:大于、大于等于、小于、小于等于、不等于、等于這幾種,打算支持常用的邏輯運算:AND、OR、NOT
2. 支持哪些方法(函數)?
目前Apworks支持的方法僅有三種:object.Equals、string.StartsWith和 string.EndsWith。object.Equals將被翻譯成“object = value”,string.StartsWith和string.EndsWith將被翻譯成“LIKE”子句
3. 支持內聯函數和變量?
目前僅支持變量,不支持內聯函數。
比如:可以用下面的方式來指定Expression:
Expression<Func<Employee, bool>> expr = p => p.Age.Equals(a);
而不能使用下面的方式來指定Expression:
4. 支持擴展?
當然,只需要繼承已有的ExpressionVisitor類,并重寫其中某些方法即可
在當前的Apworks版本中,Apworks.Storage.Builders命名空間下定義了針對關系型數據庫的 IWhereClauseBuilder接口,以及一個抽象實現:Apworks.Storage.Builders.WhereClauseBuilder類,它不僅實現了IWhereClauseBuilder 接口,同時繼承于System.Linq.Expressions.ExpressionVisitor抽象類,因此,WHERE子句生成的主體邏輯都在這個類中。SqlWhereClauseBuilder類繼承WhereClauseBuilder類,以便實現特定于SQL Server語法的WHERE子句生成器。
由于Apworks.Storage.Builders.WhereClauseBuilder類的源代碼比較長,我就不貼在這里了,讀者朋友請【點擊此處】查看該類的全部源代碼。
與規約(Specification)整合
在《EntityFramework之領域驅動設計實踐(十):規約模式》一文中,我提出了基于.NET的規約模式的實現方式,為了迎合.NET對LINQ Expression的支持,規約模式的實現也采用了LINQ Expression,而原來的IsSatisfiedfiedBy方法則改為直接使用LINQ Expression來獲得結果:
{
bool IsSatisfiedBy(T obj);
Expression<Func<T, bool>> Expression { get; }
}
public abstract class Specification<T> : ISpecification<T>
{
#region ISpecification Members
public virtual bool IsSatisfiedBy(T obj)
{
return this.Expression.Compile()(obj);
}
public abstract Expression<Func<T, bool>> Expression { get; }
#endregion
}
回過頭來考察Select方法,原本第一個參數是用Expression<Func<T, bool>>類型代替PropertyBag的,現在則可以直接使用ISpecification接口了,于是,我們的Query Object可以使用規約模式來支持數據查詢了。
where T : class, new();
執行過程與客戶端調用示例
基于上面的討論,Select方法的定義,已經從使用PropertyBag作為查詢條件,轉變為使用ISpecification接口。注意:orders參數仍然使用PropertyBag,因為目前不打算支持基于表達式的排序條件:
在Apworks.Storage.RdbmsStorage中,使用WhereClauseBuilder.BuildWhereClause方法,根據LINQ Expression生成WHERE子句,進而產生SQL語句并使用ADO.NET訪問關系型數據庫:
where T : class, new()
{
try
{
Expression<Func<T, bool>> expression = null;
WhereClauseBuildResult whereBuildResult = null;
string sql = string.Format("SELECT {0} FROM {1}",
GetFieldNameList<T>(), GetTableName<T>());
if (specification != null)
{
expression = specification.GetExpression();
whereBuildResult = GetWhereClauseBuilder<T>().BuildWhereClause(expression);
sql += " WHERE " + whereBuildResult.WhereClause;
}
if (orders != null && sortOrder != Storage.SortOrder.Unspecified)
{
sql += " ORDER BY " + GetOrderByFieldList<T>(orders);
switch (sortOrder)
{
case Storage.SortOrder.Ascending:
sql += " ASC";
break;
case Storage.SortOrder.Descending:
sql += " DESC";
break;
default: break;
}
}
using (DbCommand command = CreateCommand(sql))
{
if (command.Connection == null)
command.Connection = Connection;
if (Transaction != null)
command.Transaction = Transaction;
if (specification != null)
{
command.Parameters.Clear();
var parameters = GetSelectCriteriaDbParameterList<T>(whereBuildResult.ParameterValues);
foreach (var parameter in parameters)
{
command.Parameters.Add(parameter);
}
}
DbDataReader reader = command.ExecuteReader();
List<T> ret = new List<T>();
while (reader.Read())
{
ret.Add(CreateFromReader<T>(reader));
}
reader.Close(); // Very important: reader MUST be closed !!!
return ret;
}
}
catch (ExpressionParseException)
{
throw;
}
catch (InfrastructureException)
{
throw;
}
catch (Exception ex)
{
throw ExceptionManager.HandleExceptionAndRethrow<StorageException>(ex,
Resources.EX_SELECT_FROM_STORAGE_FAIL,
typeof(T).AssemblyQualifiedName,
specification != null ? specification.ToString() : "NULL",
orders != null ? orders.ToString() : "NULL",
sortOrder);
}
}
下面這個方法將根據Aggregate Root的類型與ID,返回與之相關的所有Domain Events:
{
try
{
PropertyBag sort = new PropertyBag();
sort.AddSort<long>("Version");
var aggregateRootTypeName = aggregateRootType.AssemblyQualifiedName;
ISpecification<DomainEventDataObject> specification = Specification<DomainEventDataObject>
.Eval(p => p.AggregateRootId == id && p.AggregateRootType == aggregateRootTypeName);
return Select<DomainEventDataObject>(specification, sort, Apworks.Storage.SortOrder.Ascending)
.Select(p => p.ToEntity());
}
catch { throw; }
}