65.9K
CodeProject 正在变化。 阅读更多。
Home

轻量级领域服务库

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (55投票s)

2014年2月19日

CPOL

9分钟阅读

viewsIcon

87031

downloadIcon

1528

本文提供了一个非常简单、轻量但有用的领域服务库的具体示例。

引言

如果您在领域驱动设计(DDD)方面有几年以上的经验,那么您很可能已经认识到,无论您正在处理何种类型的应用程序,您都必须解决某种整体模式的问题。我个人确实认识到了。

无论您是开发桌面应用程序、Web 应用程序还是 Web API,您几乎总会发现自己处于一种需要建立一种机制来创建、持久化和维护应用程序领域模型中各种实体状态的情况。因此,每次启动一个新项目时,您都必须进行大量的“剃牦牛毛”工作来建立这个持久化机制,而您真正想做的是建立领域模型——应用程序的实际业务功能。

经过多个项目的迭代,我已经建立了一种几乎在任何情况下都对我有效的方法。这种方法允许您抽象实体持久化(“剃牦牛毛”...),这样您就可以轻松地隔离其细节实现,并专注于开发您的真正业务功能。当然,最终您必须处理持久层的实现,但能够在不关心持久化细节的情况下独立开发——尤其是不进行测试——您的领域模型,其价值是巨大的。然后,您可以开始使用伪存储库开发和测试您的领域模型。至于您最终是选择简单的基于文件的存储库还是决定采用功能齐全的 RDBMS,在这一点上并不重要。

为了本文,我将我的这种实践消化成我称之为“领域服务库”的东西。这个库非常轻量,只包含几个普通的 C# 类。库本身没有任何第三方依赖。不涉及任何 ORM——存储库可以是内存对象,也可以是 RDBMS。

背景

这种实践(或者说库,如果您愿意的话)的技术要素无非是一些通用的面向对象软件开发原则,即SOLID存储库模式,特别是依赖注入

有无数的资源描述了这些原则和模式。我自己在系列博客文章中做了一些尝试,可以在这里找到。

领域服务库代码

让我们深入探讨。总体的想法是创建定义良好的交互点——即所谓的“缝隙”——以存储库接口的形式,在领域模型服务和持久层存储库之间。这里以产品服务及其相应存储库的依赖图为例。

存储库的抽象在 IRepositoryIReadOnlyRepository 接口中定义。

public interface IRepository<TEntity, in TId> : 
    IReadOnlyRepository<TEntity, TId> where TEntity : IEntity<TId>
{
    void Add(TEntity entity);

    void Remove(TId id);

    void Update(TEntity entity);
} 

请注意,IRepository 继承了 IReadOnlyRepository 的签名。换句话说,IRepositoryIReadOnlyRepository 的扩展。

public interface IReadOnlyRepository<TEntity, in TId> where TEntity : IEntity<TId>
{
    int Count { get; }
 
    bool Contains(TId id);
 
    E Get(T id);
 
    IQueryable<TEntity> Get(Expression<Func<TEntity, bool>> predicate);
 
    IEnumerable<TEntity> GetAll();
}

显然,存储库抽象可以保持在一个单一接口中,但由于接口隔离原则,将其拆分为(至少)两个单独的接口是有意义的。您可能最终需要实现一个简单的只读存储库,而且消费者不应该被迫为不支持的功能抛出 NotImplementedException。在真实的生产代码中,您可能需要考虑将存储库接口拆分为更多子接口。

领域模型中实体的抽象由泛型 IEntity 接口定义。

public interface IEntity<TId> : INotifyPropertyChanged, INotifyPropertyChanging
{
    TId Id { get; set; }
 
    string Name { get; set; }
} 

泛型类型参数 TId 代表实体 ID 的类型,通常根据实体类型而异。您可能更喜欢某些实体的字符串 ID,而其他类型的实体可能需要例如整数或 GUID ID。另请注意,IEntity 继承了 INotifyPropertyChangedINotifyPropertyChanging 接口。这是为了支持可能的数据绑定,这在 UI 开发中非常方便。

通过泛型 abstract BaseEntity 类将实体抽象更进一步,该类提供了 IEntity 接口的基本实现。

public abstract class BaseEntity<TId> : IEntity<TId>
{
    private TId _id;
    private string _name;

    protected BaseEntity(TId id, string name)
    {
        _id = id;
        _name = name;
    }

    ...

} 

领域服务抽象由两个泛型 abstract 基类提供,分别称为 BaseServiceBaseReadOnlyService,它们分别使用存储库和只读存储库。不足为奇,BaseService 扩展了 BaseReadOnlyService。存储库依赖通过构造函数注入存储库实例来处理。服务不需要了解存储库的具体实现,除了它满足 IRepository 接口定义的契约。这就是依赖注入的实际应用。

public abstract class BaseService<TEntity, TId> : BaseReadOnlyService<TEntity, TId> 
                                                           where TEntity : IEntity<TId>
{
    private readonly IRepository<TEntity, TId> _repository;
 
    protected BaseService(IRepository<TEntity, TId> repository)
        : base(repository)
    {
        _repository = repository;
    }

    ...

} 

当实体被添加时,BaseService 会引发 AddingAdded 事件。Adding 事件支持取消。当实体更新或移除时,也会引发类似的事件。

所有 abstract 基类都提供了部分接口成员的默认实现。这有一个优点,即您可以通过仅实现强制性的 abstract 成员,非常快速地建立这些抽象的功能性实现。基类中的默认实现通常会被标记为 virtual,因此如果您能提出更好的实现,您可以在自己的实现中 override 它们。

为了支持单元测试,提供了一个泛型 FakeRepository。在这里,“持久化”是在一个内存对象——一个 Dictionary 对象中完成的。这在实际应用程序中显然是无用的,但作为单元测试的存储库来说是完美的。

public class FakeRepository<TEntity, TId> : IRepository<TEntity, TId> where TEntity : IEntity<TId>
{
    public FakeRepository()
    {
        Entities = new Dictionary<TId, TEntity>();
    }

    protected Dictionary<TId, TEntity> Entities { get; }

    ...

} 

使用领域服务库

现在,让我们看一个领域服务库的用法示例。让我们添加产品管理支持。首先,您将通过从泛型 abstract BaseEntity 类派生来创建一个 Product 类。定义 Id 属性类型的泛型类型参数设置为 Guid。除了 IEntity 中定义的 IdName 属性之外,似乎有理由至少向 Product 类添加一个 Price 属性。为简单起见,Price 属性的数据绑定支持被忽略。

public class Product : BaseEntity<Guid>
{
    public Product(string name)
        : base(Guid.NewGuid(), name)
    {
    }

    public decimal Price { get; set; }
}

现在,假设您想扩展产品存储库,添加一个方法来检测存储库中是否已包含同名产品。那么,您只需通过扩展 IRepository 接口来创建一个 IProductRepository 接口。

public interface IProductRepository : IRepository<Product, Guid>
{
    bool ContainsName(string name);
}

最后,是时候创建产品服务本身了。这基本上是通过从泛型 abstract BaseService 类派生来完成的。由于 BaseService 类中的所有方法都声明为 virtual,您可以选择重写它们——例如,在添加产品时添加进一步的约束。如果您尝试添加一个已存在 ID 的实体,BaseService 已经会抛出异常,但在以下实现中,如果具有给定名称的产品已存在或产品名称为空或未定义,也会抛出异常。

public class Products : BaseService<Product, Guid>
{
    private readonly IProductRepository _repository;
 
    public Products(IProductRepository _repository)
        : base(repository)
    {
        _repository = repository;
    }
 
    public override void Add(Product product)
    {
        if (_repository.ContainsName(product.Name))
        {
            throw new ArgumentException
              ($"There is already a product with the name '{product.Name}'.", nameof(product));
        }
 
        if (string.IsNullOrEmpty(product.Name))
        {
            throw new ArgumentException
              ("Product name cannot be null or empty string.", nameof(product));
        }
 
        base.Add(product);
    }
}

在实际的生产代码中,您现在可能会扩展产品服务,增加额外的功能——例如计算折扣价、货币管理等。

毋庸置疑,为用户、客户、活动等其他实体建立类似服务的模式是完全相同的。

现在,我们来编写一些测试代码。因为您将 IProductRepository 作为 IRepository 接口的扩展,所以首先需要创建一个 FakeProductRepository 类作为 FakeRepository 类的扩展。ContainsName() 方法必须实现——至少如果您想测试依赖于它的功能的话。

internal class FakeProductRepository : FakeRepository<Product, Guid>, IProductRepository
{
    public bool ContainsName(string name)
    {
        return Entities.Values.Any(p => p.Name.Equals(name));
    }
}

现在,您可以测试例如尝试添加具有相同名称的产品时是否会抛出预期的异常。请注意 FakeProductRepository 如何通过构造函数注入到 Products 服务中。

[Fact]
public void AddWithExistingNameThrows()
{
    // Setup fixture
    var products = new Products(new FakeProductRepository());
    var product = new Product("MyProduct name");
    products.Add(product);
    var productWithSameName = new Product(product.Name);
 
    // Exercise system and verify outcome
    Assert.Throws<ArgumentException>(() => products.Add(productWithSameName));
}  

这是一个测试,验证在删除产品时是否正确触发了 DeletingDeleted 事件。

[Fact]
public void EventsAreRaisedOnRemove()
{
    // Setup fixture
    var raisedEvents = new List<string>();
    var products = new Products(new FakeProductRepository());
    products.Deleting += (s, e) => { raisedEvents.Add("Deleting"); };
    products.Deleted += (s, e) => { raisedEvents.Add("Deleted"); };
    var product = new Product("MyProduct name");
    products.Add(product);
 
    // Exercise system
    products.Remove(product.Id);
 
    // Verify outcome
    Assert.Equal("Deleting", raisedEvents[0]);
    Assert.Equal("Deleted", raisedEvents[1]);
} 

最后,这是一个测试,证明使用 lambda 表达式的查询机制按预期工作。

[Fact]
public void GetQueryableIsOk()
{
    // Setup fixture
    var products = new Products(new FakeProductRepository());
    var coke = new Product("Coke") {Price = 9.95M};
    var cokeLight = new Product("Coke Light") {Price = 10.95M};
    var fanta = new Product("Fanta") {Price = 8.95M};
    products.Add(coke);
    products.Add(cokeLight);
    products.Add(fanta);
 
    // Exercise system
    var cheapest = products.Get(p => p.Price < 10M).ToList();
    var cokes = products.Get(p => p.Name.Contains("Coke")).ToList();
 
    // Verify outcome
    Assert.Equal(2, cheapest.Count());
    Assert.Equal(2, cokes.Count());
} 

示例代码中提供了更多测试。

组件

源代码分为以下 DLL(项目)

DomainServices 项目包含以泛型接口和 abstract 类形式表示的基本抽象——例如,一个 abstract BaseService 类和一个泛型 IRepository 接口。这就是领域服务库本身。

MyServices 项目包含 DomainServices 抽象的一些具体实现和扩展——例如,一个 Products 类和一个 IProductRepository 接口。

MyServices.Test 项目包含 MyServices 类型的单元测试类。

MyServices.Data 项目包含 MyServices 中定义的存储库接口的具体实现——例如,一个 JsonProductRepository,它是 IProductRepository 的实现,用于将产品序列化存储在 JSON 文件中。

摘要

本文提供了一个非常简单、轻量但有用的领域服务库的具体示例。其要素是一些通用的面向对象软件开发原则,以及存储库模式和依赖注入。该库仅由普通的 C# 类组成。

通过使用依赖注入实现松耦合,使得使用伪存储库对象进行领域功能的单元测试变得非常容易。为此提供了一个泛型 FakeRepository 类。

整体设计原则在我的另一篇 CodeProject 文章中进行了更详细的描述。

实用信息

这些测试是使用我个人偏爱的 xUnit.NET 编写的。xUnit.NET 可以通过 Visual Studio 中的 NuGet 包管理器获取。在生产代码中,您应该认真考虑使用辅助单元测试框架,例如 MoqAutofixture 来帮助您简化模拟和夹具设置。这两个库都可以通过 NuGet 获取。我写过一篇关于此的 CodeProject 文章

生产代码显然还需要各种存储库的具体实现。在示例代码中,我添加了一个简单的 JsonProductRepository,用于将产品持久化到 JSON 文件中。这个存储库使用了 Json.NET 库,该库可以通过 NuGet 获取。我没有为这个存储库提供单元测试。

由于领域服务库利用了依赖注入,因此它非常适合与依赖注入容器(例如 NInjectUnity)一起使用——两者都可以通过 NuGet 获取。

示例代码使用 Visual Studio 2017 (C# 7) 和 .NET Framework 4.6.1 制作。

历史

  • 2019年2月23日
    • 重构代码以使用
      • 自动属性
      • 内联 out 变量
      • 表达式体成员
      • nameof 运算符
      • null 传播运算符
      • string 插值
    • 更新至 VS 2017 (C# 7) 和 .NET Framework 4.6.1
    • 更新至 xUnit.NET 2.4.1 和 Json.NET 12.0.1
    • 从 NuGet packages.config 迁移到 PackageReference
    • 新增了组件章节
  • 2015年7月8日
    • 引入了更标准化的编码风格——包括更具描述性的泛型类型参数
    • 单元测试更新至最新的 xUnit.NET 版本 (2.0.0)
© . All rights reserved.