深度剖析Byteart Retail案例:仓储(Repository)及其上下文(Repository Context)

原文来自深度剖析Byteart Retail案例:仓储(Repository)及其上下文(Repository Context)

在领域驱动设计(DDD)的案例中,仓储及其上下文都是开发人员学习和讨论的重点。对这两个内容的讨论,大致包含两个方面:第一个方面是有关仓储及其上下文在整个应用程序架构中的位置;第二个方面,则是仓储及其上下文的设计与具体技术实现。我将在本文中,结合Byteart Retail案例,对这两个内容进行讨论。

仓储及其上下文在整个应用程序架构中的位置

仓储是DDD中管理对象生命周期的一个重要组件。在面向对象的世界里,不仅仅是DDD,甚至是整个软件设计和开发过程,都离不开对象生命周期的管理:对象的创建、持久化(Persistence)、反持久化(Materialize)以及销毁,每种管理任务都对对象的状态造成影响。在传统的应用程序开发中,我们会使用类似DAO(Data Access Object)的类型来实现对象的持久化、反持久化操作,或者会使用Finder/Mediator来完成类似的任务。在DDD中,同样存在着两种与对象生命周期管理任务相关的组件,它们就是仓储和工厂。与DAO、Finder/Mediator所不同的是,仓储的实现更为限定在对整个聚合的操作上(事实上工厂也是如此),通过对聚合根的引用来完成整个聚合的持久化、反持久化操作;而DAO、Finder/Mediator则随意性更强:它们的设计可以是面向DTO(Data Transfer Object)的,也可以是直接面向数据库的。

刚刚提到,仓储的实现需要限定在对整个聚合的操作上(工厂也是如此),因此,不管是从持久化机制读取对象,还是将对象保存到持久化机制,都需要通过聚合根,以聚合为单位。根据DDD不难理解,聚合是领域模型的重要内容,而在整个应用程序的架构中,领域模型是属于领域层的,于是,仓储也是领域层的一个组成部分。

前不久,有网友向我询问这样的问题:如果说仓储是领域层的一个组成部分,但是仓储的实现往往需要涉及到很多技术层面上的东西,比如如果采用关系型数据库作为对象持久化机制,那么仓储的实现就需要封装类似ORM的功能,这样做岂不是使得领域层需要依赖这些技术的具体实现,从而使得两者之间紧密耦合?

对于这个问题的回答,我想应该从两个方面考虑。首先,领域层和领域模型是两个概念,前者是应用程序架构中的一种分层,而后者则是应用程序的业务核心组件。领域模型定义在领域层中,领域层中还能包含诸如仓储、工厂、服务等组件,一方面辅助领域模型完成完整的业务处理需求,另一方面为领域模型提供生命周期管理。因此,即使领域层耦合了其它基础结构组件,它也能通过合理的模式应用,将这些实现细节从领域模型中剥离开来,以保证领域模型的纯净度;其次,即使可以解除领域模型与基础结构组件的耦合关联,我们也不应该使领域层也直接依赖这些组件,否则,我们得到的后果是,当基础结构组件发生改变时,整个领域层组件将变得不再可用,我们不得不对领域层也进行重构,以适应新的接口需求。

综上所述,一方面,仓储的操作对象是领域模型中的聚合,无论是从DDD的实践思路上,还是从仓储与领域模型之间的关系上考虑,仓储都应该属于领域层,然而与领域模型不同,仓储需要通过基础结构组件的支持来提供服务,因此仓储又将依赖于这些组件。这就使得开发人员在设计应用程序架构的时候,对于仓储的部分具体应该如何设计产生了疑惑。

合理的做法是,将仓储的接口定义和具体实现分开处理,仓储接口定义在领域层,而仓储的具体实现则划分到领域层之外(注意,这里可以理解为将仓储的具体实现划分到基础结构层,也可以理解为架构的一种外部插件的实现)。具体到.NET应用程序架构,仓储接口定义在领域层的程序集中,仓储的具体实现则同时引用领域层程序集和基础结构组件程序集,以实现仓储接口。这里或许又会引来一个新的问题:既然仓储的具体实现引用了领域层的程序集,那领域层如何调用仓储呢?再去引用仓储的具体实现,岂不是造成了循环引用?我的答案是:领域层不需要,也不应该引用仓储的具体实现,仓储的具体实现应该以依赖注入(Dependency Injection)的方式,在应用层中获得,并由应用层通过仓储来完成领域对象管理和任务协调(比如:通过启用分布式事务来保证仓储和服务总线之间的事务性)。

下图来自于微软的DDD分层架构案例:Microsoft NLayerApp,从图中的彩色高亮部分可以看到,仓储接口和仓储实现分别位于领域层和基础结构层:

image

根据以上总结,我大致描绘了一下.NET解决方案中各层的程序集(Assembly)之间的引用关系,以供参考。

image

在Byteart Retail案例中,仓储接口定义在ByteartRetail.Domain程序集中,而仓储的实现部分则写在了ByteartRetail.Domain.Repositories程序集中,以下是Visual Studio 2012中解决方案资源管理器下的项目结构,我用数字对四个主要部分做了标注:1、领域层的所有内容都定义在ByteartRetail.Domain程序集中;2、在该程序集的Repositories目录(命名空间)下,定义了仓储的接口(事实上还包含了仓储上下文的接口定义);3、仓储的具体实现部分写在了ByteartRetail.Domain.Repositories程序集中,该程序集引用了ByteartRetail.Domain程序集;4、在ByteartRetail.Domain.Repositories程序集中提供了针对Entity Framework的仓储实现。

image

接下来,再让我们一起了解一下,Byteart Retail案例中,基于Entity Framework的仓储实现。

仓储及其上下文的设计与实现

仓储的实现其实网上有很多相关的资料,有基于NHibernate的仓储实现,也有基于Entity Framework Code First的,在我自己开发的面向领域驱动的应用程序开发框架Apworks中,就提供了基于三种技术的仓储实现:NHibernate、Entity Framework Code First以及MongoDB。相对而言,网文中所提供的一些解决方案虽然简单有效,但与实际项目应用之间还是有一定的差距。比如,对于EF Code First的实现,在很多文章中,都是直接在仓储的泛型基类中封装了DbContext对象,这样做可以完成一般性的事务处理需求,但需要注意的是,由于DbContext对象被封装在泛型类中,因此,这种事务性只能应用在对某个特定聚合的仓储操作上,例如:Repository<Customer>可以保证所有针对Customer聚合的仓储操作都在同一个事务处理范围内,而Repository<SalesOrder>则可以保证所有针对SalesOrder聚合的仓储操作都在另一个事务处理范围内。从DDD的应用层角度看,由于应用层服务负责任务协调,而多个任务很有可能需要在同一事务下完成,如果某个任务需要同时更新Customer及其相关的SalesOrder,那么,将DbContext限定在仓储的泛型类中,显然无法完成这样的设计需求。

为了解决这个问题,Byteart Retail案例和Apworks框架都引入了仓储上下文(Repository Context)的概念,Repository Context负责事务处理,每一个Repository的实现都会被关联到一个Repository Context上,以便来自不同仓储的操作能够被限定在同一个事务中。具体地说,在这种设计下,应用层服务只需要通过服务定位器来获得一个Repository Context的实例,就能够保证后续的仓储操作都是在该Repository Context所管理的同一个事务之中:由于服务定位器的使用,应用层服务在获得Repository实例的同时,会通过服务定位器来解析获得Repository Context,因此,只要在IoC容器中注册Repository Context类型时,使用了合理的生命周期管理器(Lifetime Manager),就能确保所有Repository<T>类型中所使用的Repository Context是同一个实例,于是,当应用层服务完成任务处理之后,直接使用Repository Context的Commit方法,即可将事务一次提交。

从实现上看,仓储上下文应用了Unit Of Work模式[PoEAA],鉴于主流ORM框架都具有对象状态托管功能,因此,仓储上下文的实现基本上也都是对ORM会话组件(比如NHibernate Session或者EF DbContext)的封装。当然这样的封装会有一定的风险性,以NHibernate为例,由于Session对象并不是线程安全的,因此尽量不要跨线程使用Session;更进一步,由于仓储上下文是对这些会话组件的封装,所以,在使用仓储上下文时也应该遵循一些最佳操作条款,比如尽量不要使用单件(Singleton)模式来创建和使用Repository Context,除非你对你的设计有着十足的把握。下面的UML类图体现了在Byteart Retail中,Repository和Repository Context相关的类型定义以及这些类型之间的关系,到目前为止,我们的讨论还处于抽象层面,并没有引入与NHibernate或者Entity Framework相关的类型定义。(注意:图中仅展示了所涉及的类型及其关系,为了简化图形,类型中方法和属性的定义并不一定与Byteart Retail案例的源代码完全一致,如有出入,以源代码为准)

image

接下来,我将讨论在Byteart Retail中基于Entity Framework Code First的仓储设计和实现细节。在这部分讨论中,我不会过多地涉及EF Code First的用法,需要了解如何在应用程序开发中使用EF Code First的读者,请直接参考Byteart Retail的源程序代码。更多地,我会把重点放在架构设计部分,让读者充分了解到选择这样一种架构的好处。

基于Entity Framework Code First的仓储设计和实现

仓储的实现是多样化的,总体上讲,还是根据项目本身的实际情况而定。比如基于NoSQL的仓储实现所采用的技术,就与基于关系型数据库的仓储实现所采用的技术不同;即使是关系型数据库,使用不同的ORM,也会造成仓储实现上的差异,不难理解,基于NHibernate的仓储实现和基于EF Code First的仓储实现之间就有着一定的区别。不过无论如何,如果采用上文给出的仓储及其上下文的设计能够满足项目需求的话,我们总是可以在这个框架的基础上进行扩展,以实现面向特定技术的仓储及其上下文组件。

在Byteart Retail中,我选用了EF Code First作为ORM,实现了仓储(Repository)和仓储上下文(Repository Context),先来看看Repository Context。从技术实现角度分析,基于EF Code First的Repository Context封装了DbContext,这跟上文中的分析是一致的,从设计和框架应用的角度分析,基于EF Code First的Repository Context需要实现IRepositoryContext的接口,以便当服务定位器在解析并提供IRepositoryContext类型实例的时候,能够返回我们的EF Repository Context。为了提供一定的扩展性,我在Byteart Retail的ByteartRetail.Domain.Repositories程序集中引入了一个新的接口:IEntityFrameworkRepositoryContext,在这个接口中,向外界公开了访问DbContext的属性:

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// 表示继承于该接口的类型,是由Microsoft Entity Framework支持的一种仓储上下文的实现。
/// </summary>
public interface IEntityFrameworkRepositoryContext : IRepositoryContext
{
    #region Properties
    /// <summary>
    /// 获取当前仓储上下文所使用的Entity Framework的<see cref="DbContext"/>实例。
    /// </summary>
    DbContext Context { get; }
    #endregion
}

由于Repository类本身引用了Repository Context,因此,对于EF Repository而言,它能够很方便地通过这个DbContext属性来实现基于EF的仓储操作(CRUD相关的操作)。至于IEntityFrameworkRepositoryContext接口的具体实现,我就不多探讨了,读者朋友请直接参考ByteartRetail.Domain.Repositories.EntityFramework命名空间下的EntityFrameworkRepositoryContext类的源代码。

接下来是基于EF Code First的仓储设计。仓储设计相对简单,不需要引入新的接口,只需要继承上文所设计的Repository抽象类即可,当然,为了能够在仓储中使用EF的DbContext,在EF Repository的构造函数中,需要将注入的IRepositoryContext实例转换为IEntityFrameworkRepositoryContext实例,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class EntityFrameworkRepository<TAggregateRoot> : Repository<TAggregateRoot>
    where TAggregateRoot : class, IAggregateRoot
{
    private readonly IEntityFrameworkRepositoryContext efContext;
 
    public EntityFrameworkRepository(IRepositoryContext context)
        : base(context)
    {
        if (context is IEntityFrameworkRepositoryContext)
            this.efContext = context as IEntityFrameworkRepositoryContext;
    }
    // 暂时忽略其它方法和属性
}

在引入了基于Entity Framework Code First的仓储实现以后,与仓储相关的类型及其关系可以用下图表示(同样,省略了不少方法和属性的定义):

image

现在再让我们对仓储部分的实践和应用中的几个问题进行更进一步的思考。

设计更为专注的仓储接口

这个标题听起来似乎不太好理解。在上面的设计中,仓储类型都是以泛型的方式定义的,于是,无论在向IoC容器注册的时候,还是在使用的时候,都需要以泛型的方式进行定义和调用,这样虽然没什么不好,但始终会让代码看起来别扭。或许,在我们的设计中再加上一种更为专注的仓储接口会显得更好一些。例如,对于User的仓储,我们可以定义这样的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface IUserRepository : IRepository<User>
{
    #region Methods
    /// <summary>
    /// 根据指定的用户名,获取用户实体。
    /// </summary>
    /// <param name="userName">需要获取的用户的用户名。</param>
    /// <returns>用户实体。</returns>
    User GetUserByName(string userName);
    /// <summary>
    /// 根据指定的电子邮件地址,获取用户实体。
    /// </summary>
    /// <param name="email">需要获取的用户的电子邮件地址。</param>
    /// <returns>用户实体。</returns>
    User GetUserByEmail(string email);
    #endregion
}

在这个接口中,我们可以看到两个可读性更好的方法:GetUserByName和GetUserByEmail,从方法名就能很快得知其含义,当然,这些方法本身也是使用某些规约(Specification)来调用已有的仓储方法来获取结果,不过增加了代码的可读性,而且在IoC注册仓储实例的时候,也可以直接使用这些接口,这对于仓储部分的纵向扩展是有好处的。这我将在下面介绍这部分内容。

IUserRepository接口的实现比较简单,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserRepository : EntityFrameworkRepository<User>, IUserRepository
{
    public UserRepository(IRepositoryContext context)
        : base(context)
    { }
     
    public User GetUserByName(string userName)
    {
        return Find(new UserNameEqualsSpecification(userName));
    }
     
    public User GetUserByEmail(string email)
    {
        return Find(new UserEmailEqualsSpecification(email));
    }
     
}

仓储的横向扩展

在Byteart Retail中,我采用的是基于Entity Framework Code First的仓储实现,假如我们希望Byteart Retail能够使用基于NHibernate或者MongoDB等其它技术的仓储,应该怎么办呢?

其实很简单,只要定义两个分别继承于RepositoryContext和Repository抽象类的类型,并在这两个类型中使用这些技术来完成仓储及其上下文的操作,最后在ByteartRetail.Services项目的web.config中配置使用新的仓储实现即可。这并不是一个很难的问题,关键是要能够管理好仓储所使用的技术资源。

仓储的纵向扩展

以上的仓储及其上下文的设计,作为一种框架而言,无法涵盖所有的对象持久化/反持久化操作需求。比如以前有很多朋友问过我,假如我希望仓储能够根据多个字段进行排序,然后以分页的方式给出某页中的对象集合,应该怎么办?不错,目前的这个设计无法满足这样的需求,因为仓储的接口中没有一个方法能够接受多个排序字段的参数,但是,我们可以借用上面“更为专注的接口”对这个设计进行扩展。

首先,另外定义一个接口,比如:ICustomUserRepository,使得这个接口继承于IUserRepository接口,然后在这个接口中定义支持多字段排序、分页获取对象的方法;然后,另外定义一个类,比如:CustomUserRepository类,使其继承于UserRepository类,并实现ICustomUserRepository接口,如此一来,就无需修改任何现有的仓储代码,即可完成新功能的添加。最后,我们会发现:我们新引入了一个接口和一个类(你可以将它们定义在另外一个单独的Assembly中),同时我们还修改了ByteartRetail.Services项目的web.config,将IUserRepository接口的注册替换为了ICustomUserRepository(或者也可以增加了ICustomUserRepository的注册),于是,整个仓储框架无需修改,更无需二次编译。恭喜你,你已经可以将这个仓储框架作为一个通用组件发布了!

你或许还有疑问,这样一来,岂不是我还需要修改仓储的调用部分?这就要看你的整个应用程序的设计是否能够满足这样的修改了。对于类似Byteart Retail这样的面向DDD分层架构的应用程序来说,仓储的调用部分都位于应用(Application)层,而Byteart Retail的应用层也是面向接口定义的,因此,使用面向对象的手段来替换应用层的实现并非难事。

总结

本文详细介绍了仓储及其上下文在整个应用程序架构中的位置,并结合Byteart Retail案例讲解了基于EF Code First的仓储设计和实现方式。在本文的结尾部分,对这样的仓储设计进行了更深层次的分析和讨论,尤其是在仓储扩展的相关问题上。希望本文能够解答读者朋友心中大多数与领域驱动设计“仓储”相关的疑问。也欢迎大家积极参与讨论,提出宝贵意见。

Leave a Reply