使用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进行查询的方法定义如下:
1: /// <summary>
2: /// Gets a list of ordered objects from storage by given selection criteria and order.
3: /// </summary>
4: /// <typeparam name="T">The type of the object to get.</typeparam>
5: /// <param name="criteria">The <c>PropertyBag</c> instance which contains the criteria.</param>
6: /// <param name="orders">The <c>PropertyBag</c> instance which contains the ordering fields.</param>
7: /// <param name="sortOrder">The sort order.</param>
8: /// <returns>A list of ordered objects.</returns>
9: IEnumerable<T> Select<T>(PropertyBag criteria, PropertyBag orders, SortOrder sortOrder)
10: 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子句生成的几个细节问题。
- 支持哪些运算?
LINQ Expression的类型有85种,但并不是SQL中会支持到所有的这85种类型。目前Apworks打算支持常用的条件运算,比如:大于、大于等于、小于、小于等于、不等于、等于这几种,打算支持常用的逻辑运算:AND、OR、NOT - 支持哪些方法(函数)?
目前Apworks支持的方法仅有三种:object.Equals、string.StartsWith和string.EndsWith。object.Equals将被翻译成“object = value”,string.StartsWith和string.EndsWith将被翻译成“LIKE”子句 - 支持内联函数和变量?
目前仅支持变量,不支持内联函数。
比如:可以用下面的方式来指定Expression:
1: int a = GetAge();
2: Expression<Func<Employee, bool>> expr = p => p.Age.Equals(a);
而不能使用下面的方式来指定Expression:
1: Expression<Func<Employee, bool>> expr = p => p.Age.Equals(GetAge());
- 支持扩展?
当然,只需要继承已有的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来获得结果:
1: public interface ISpecification<T>
2: {
3: bool IsSatisfiedBy(T obj);
4: Expression<Func<T, bool>> Expression { get; }
5: }
6:
7: public abstract class Specification<T> : ISpecification<T>
8: {
9:
10: #region ISpecification Members
11:
12: public virtual bool IsSatisfiedBy(T obj)
13: {
14: return this.Expression.Compile()(obj);
15: }
16:
17: public abstract Expression<Func<T, bool>> Expression { get; }
18:
19: #endregion
20: }
回过头来考察Select方法,原本第一个参数是用Expression<Func<T, bool>>类型代替PropertyBag的,现在则可以直接使用ISpecification接口了,于是,我们的Query Object可以使用规约模式来支持数据查询了。
执行过程与客户端调用示例
基于上面的讨论,Select方法的定义,已经从使用PropertyBag作为查询条件,转变为使用ISpecification接口。注意:orders参数仍然使用PropertyBag,因为目前不打算支持基于表达式的排序条件:
1: IEnumerable<T> Select<T>(ISpecification<T> specification, PropertyBag orders, SortOrder sortOrder)
2: where T : class, new();
在Apworks.Storage.RdbmsStorage中,使用WhereClauseBuilder.BuildWhereClause方法,根据LINQ Expression生成WHERE子句,进而产生SQL语句并使用ADO.NET访问关系型数据库:
1: public IEnumerable<T> Select<T>(ISpecification<T> specification, PropertyBag orders, Storage.SortOrder sortOrder)
2: where T : class, new()
3: {
4: try
5: {
6: Expression<Func<T, bool>> expression = null;
7: WhereClauseBuildResult whereBuildResult = null;
8: string sql = string.Format("SELECT {0} FROM {1}",
9: GetFieldNameList<T>(), GetTableName<T>());
10: if (specification != null)
11: {
12: expression = specification.GetExpression();
13: whereBuildResult = GetWhereClauseBuilder<T>().BuildWhereClause(expression);
14: sql += " WHERE " + whereBuildResult.WhereClause;
15: }
16: if (orders != null && sortOrder != Storage.SortOrder.Unspecified)
17: {
18: sql += " ORDER BY " + GetOrderByFieldList<T>(orders);
19: switch (sortOrder)
20: {
21: case Storage.SortOrder.Ascending:
22: sql += " ASC";
23: break;
24: case Storage.SortOrder.Descending:
25: sql += " DESC";
26: break;
27: default: break;
28: }
29: }
30: using (DbCommand command = CreateCommand(sql))
31: {
32: if (command.Connection == null)
33: command.Connection = Connection;
34: if (Transaction != null)
35: command.Transaction = Transaction;
36: if (specification != null)
37: {
38: command.Parameters.Clear();
39: var parameters = GetSelectCriteriaDbParameterList<T>(whereBuildResult.ParameterValues);
40: foreach (var parameter in parameters)
41: {
42: command.Parameters.Add(parameter);
43: }
44: }
45: DbDataReader reader = command.ExecuteReader();
46: List<T> ret = new List<T>();
47: while (reader.Read())
48: {
49: ret.Add(CreateFromReader<T>(reader));
50: }
51: reader.Close(); // Very important: reader MUST be closed !!!
52: return ret;
53: }
54: }
55: catch (ExpressionParseException)
56: {
57: throw;
58: }
59: catch (InfrastructureException)
60: {
61: throw;
62: }
63: catch (Exception ex)
64: {
65: throw ExceptionManager.HandleExceptionAndRethrow<StorageException>(ex,
66: Resources.EX_SELECT_FROM_STORAGE_FAIL,
67: typeof(T).AssemblyQualifiedName,
68: specification != null ? specification.ToString() : "NULL",
69: orders != null ? orders.ToString() : "NULL",
70: sortOrder);
71: }
72: }
73:
下面这个方法将根据Aggregate Root的类型与ID,返回与之相关的所有Domain Events:
1: public virtual IEnumerable<IDomainEvent> LoadEvents(Type aggregateRootType, long id)
2: {
3: try
4: {
5:
6: PropertyBag sort = new PropertyBag();
7: sort.AddSort<long>("Version");
8: var aggregateRootTypeName = aggregateRootType.AssemblyQualifiedName;
9: ISpecification<DomainEventDataObject> specification = Specification<DomainEventDataObject>
10: .Eval(p => p.AggregateRootId == id && p.AggregateRootType == aggregateRootTypeName);
11:
12: return Select<DomainEventDataObject>(specification, sort, Apworks.Storage.SortOrder.Ascending)
13: .Select(p => p.ToEntity());
14: }
15: catch { throw; }
16: }