领域驱动设计

领域驱动设计

领域驱动设计(DDD) 是一种通过将实现连接到持续进化的模型来满足复杂需求的软件开发方法. 领域驱动设计的前提是:

  • 把项目的主要重点放在核心领域和领域逻辑上
  • 把复杂的设计放在领域模型上
  • 发起技术专家和领域专家之间的创造性协作,以迭代方式完善解决特定领域问题的概念模型

分层

ABP框架遵循DDD原则和模式去实现分层应用程序模型,该模型由四个基本层组成:

  • 表示层: 为用户提供接口. 使用应用层实现与用户交互.
  • 应用层: 表示层与领域层的中介,编排业务对象执行特定的应用程序任务. 使用应用程序逻辑实现用例.
  • 领域层: 包含业务对象以及业务规则. 是应用程序的核心.
  • 基础设施层: 提供通用的技术功能,支持更高的层,主要使用第三方类库.

领域层

实体

实体通常映射到关系型数据库的表中.
实体都继承自Entity类,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
public class Book : Entity<Guid>
{
public string Name { get; set; }
public float Price { get; set; }
protected Book()
{
}
public Book(Guid id)
: base(id)
{
}
}

如果你不想继承基类Entity<TKey>,也可以直接实现IEntity<TKey>接口

Entity<TKey>类只是用给定的主 键类型定义了一个Id属性,在上面的示例中是Guid类型.可以是其他类型如string, int, long或其他你需要的类型.

Guid主键的实体

  • 创建一个构造函数,获取ID作为参数传递给基类.如果没有为GUID Id赋值,ABP框架会在保存时设置它,但是在将实体保存到数据库之前最好在实体上有一个有效的Id.
  • 如果使用带参数的构造函数创建实体,那么还要创建一个 privateprotected 构造函数. 当数据库提供程序从数据库读取你的实体时(反序列化时)将使用它.
  • 不要使用 Guid.NewGuid() 来设置Id! 在创建实体的代码中使用IGuidGenerator服务 传递Id参数. IGuidGenerator经过优化可以产生连续的GUID.这对于关系数据库中的聚集索引非常重要.

具有复合键的实体
有些实体可能需要 复合键 .在这种情况下,可以从非泛型Entity类派生实体.如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UserRole : Entity
{
public Guid UserId { get; set; }

public Guid RoleId { get; set; }

public DateTime CreationTime { get; set; }

public UserRole()
{

}

public override object[] GetKeys()
{
return new object[] { UserId, RoleId };
}
}

上面的例子中,复合键由UserId和RoleId组成.在关系数据库中,它是相关表的复合主键. 具有复合键的实体应当实现上面代码中所示的GetKeys()方法.

需要注意,复合主键实体不可以使用 IRepository<TEntity, TKey> 接口,因为它需要一个唯一的Id属性. 但你可以使用IRepository<TEntity>.

聚合根

AggregateRoot<TKey>类继承自Entity<TKey>类,所以默认有Id这个属性

值得注意的是 ABP 会默认为聚合根创建仓储,当然,ABP也可以为所有的实体创建仓储

聚合根例子
这是一个具有子实体集合的聚合根例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
public class Order : AggregateRoot<Guid>
{
public virtual string ReferenceNo { get; protected set; }

public virtual int TotalItemCount { get; protected set; }

public virtual DateTime CreationTime { get; protected set; }

public virtual List<OrderLine> OrderLines { get; protected set; }

protected Order()
{

}

public Order(Guid id, string referenceNo)
{
Check.NotNull(referenceNo, nameof(referenceNo));

Id = id;
ReferenceNo = referenceNo;

OrderLines = new List<OrderLine>();
}

public void AddProduct(Guid productId, int count)
{
if (count <= 0)
{
throw new ArgumentException(
"You can not add zero or negative count of products!",
nameof(count)
);
}

var existingLine = OrderLines.FirstOrDefault(ol => ol.ProductId == productId);

if (existingLine == null)
{
OrderLines.Add(new OrderLine(this.Id, productId, count));
}
else
{
existingLine.ChangeCount(existingLine.Count + count);
}

TotalItemCount += count;
}
}

public class OrderLine : Entity
{
public virtual Guid OrderId { get; protected set; }

public virtual Guid ProductId { get; protected set; }

public virtual int Count { get; protected set; }

protected OrderLine()
{

}

internal OrderLine(Guid orderId, Guid productId, int count)
{
OrderId = orderId;
ProductId = productId;
Count = count;
}

internal void ChangeCount(int newCount)
{
Count = newCount;
}

public override object[] GetKeys()
{
return new Object[] {OrderId, ProductId};
}
}

如果你不想你的聚合根继承AggregateRoot<TKey>类,你可以直接实现IAggregateRoot<TKey>接口

Order是一个具有Guid类型Id属性的 聚合根.它有一个OrderLine实体集合.OrderLine是一个具有组合键(OrderId和 ProductId)的实体.

虽然这个示例可能无法实现聚合根的所有最佳实践,但它仍然遵循良好的实践:

  • Order有一个公共的构造函数,它需要 minimal requirements 来构造一个”订单”实例.因此,在没有IdreferenceNo的时候是无法创建订单的.protected/private的构造函数只有从数据库读取对象时 反序列化 才需要.
  • OrderLine的构造函数是internal的,所以它只能由领域层来创建.在Order.AddProduct这个方法的内部被使用.
  • Order.AddProduct实现了业务规则将商品添加到订单中
    所有属性都有protected的set.这是为了防止实体在实体外部任意改变.因此,在没有向订单中添加新产品的情况下设置 TotalItemCount将是危险的.它的值由AddProduct方法维护.

带有组合键的聚合根
虽然这种聚合根并不常见(也不建议使用),但实际上可以按照与上面提到的跟实体相同的方式定义复合键.在这种情况下,要使用非泛型的AggregateRoot基类.

BasicAggregateRoot类
AggregateRoot 类实现了 IHasExtraProperties 和 IHasConcurrencyStamp 接口,这为派生类带来了两个属性. IHasExtraProperties 使实体可扩展(请参见下面的 额外的属性部分) 和 IHasConcurrencyStamp 添加了由ABP框架管理的 ConcurrencyStamp 属性实现乐观并发. 在大多数情况下,这些是聚合根需要的功能.

但是,如果你不需要这些功能,你的聚合根可以继承 BasicAggregateRoot<TKey>(或BasicAggregateRoot).

值对象

值对象类必须实现 GetAtomicValues()方法来返回原始值

仓储

仓储用于领域对象在数据库中的操作, 通常每个 聚合根 或不同的实体创建对应的仓储.

通用(泛型)仓储
ABP为每个聚合根或实体提供了 默认的通用(泛型)仓储 . 你可以在服务中注入 IRepository<TEntity, TKey> 使用标准的CRUD操作.

默认通用仓储用法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class PersonAppService : ApplicationService
{
private readonly IRepository<Person, Guid> _personRepository;

public PersonAppService(IRepository<Person, Guid> personRepository)
{
_personRepository = personRepository;
}

public async Task Create(CreatePersonDto input)
{
var person = new Person { Name = input.Name, Age = input.Age };

await _personRepository.InsertAsync(person);
}

public List<PersonDto> GetList(string nameFilter)
{
var people = _personRepository
.Where(p => p.Name.Contains(nameFilter))
.ToList();

return people
.Select(p => new PersonDto {Id = p.Id, Name = p.Name, Age = p.Age})
.ToList();
}
}

在这个例子中

  • PersonAppService 在它的构造函数中注入了 IRepository<Person, Guid> .
  • Create 方法使用了 InsertAsync 创建并保存新的实体.
  • GetList 方法使用标准LINQ Where 和 ToList 方法在数据源中过滤并获取People集合.

通用仓储提供了一些开箱即用的标准CRUD功能:

  • 提供 Insert 方法用于保存新实体.
  • 提供 Update 和 Delete 方法通过实体或实体id更新或删除实体.
  • 提供 Delete 方法使用条件表达式过滤删除多个实体.
  • 实现了 IQueryable<TEntity>, 所以你可以使用LINQ和扩展方法 FirstOrDefault, Where, OrderBy, ToList 等…
  • 所有方法都具有 sync(同步) 和 async(异步) 版本

只读仓储

对于想要使用只读仓储的开发者,我们提供了IReadOnlyRepository<TEntity, TKey> 与 IReadOnlyBasicRepository<Tentity, TKey>接口.

无主键的通用(泛型)仓储

如果你的实体没有id主键 (例如, 它可能具有复合主键) 那么你不能使用上面定义的 IRepository<TEntity, TKey>, 在这种情况下你可以仅使用实体(类型)注入 IRepository<TEntity>.

IRepository<TEntity> 有一些缺失的方法, 通常与实体的 Id 属性一起使用. 由于实体在这种情况下没有 Id 属性, 因此这些方法不可用. 比如 Get 方法通过id获取具有指定id的实体. 不过, 你仍然可以使用IQueryable<TEntity>的功能通过标准LINQ方法查询实体.

自定义仓储

对于大多数情况, 默认通用仓储就足够了. 但是, 你可能会需要为实体创建自定义仓储类.

自定义仓储接口,首先在领域层定义一个仓储接口:

1
2
3
4
public interface IPersonRepository : IRepository<Person, Guid>
{
Task<Person> FindByNameAsync(string name);
}

此接口扩展了 IRepository<Person, Guid> 以使用已有的通用仓储功能.

自定义存储库依赖于你使用的数据访问工具. 在此示例中, 我们将使用Entity Framework Core:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class PersonRepository : EfCoreRepository<MyDbContext, Person, Guid>, IPersonRepository
{
public PersonRepository(IDbContextProvider<TestAppDbContext> dbContextProvider)
: base(dbContextProvider)
{

}

public async Task<Person> FindByNameAsync(string name)
{
var dbContext = await GetDbContextAsync();
return await dbContext.Set<Person>()
.Where(p => p.Name == name)
.FirstOrDefaultAsync();
}
}

你可以直接使用数据库访问提供程序 (本例中是 DbContext ) 来执行操作.

IAsyncQueryableExecuter
IAsyncQueryableExecuter 是一个用于异步执行 IQueryable<T>对象的服务,不依赖于实际的数据库提供程序.

示例:
注入并使用IAsyncQueryableExecuter.ToListAsync()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Linq;

namespace AbpDemo
{
public class ProductAppService : ApplicationService, IProductAppService
{
private readonly IRepository<Product, Guid> _productRepository;
private readonly IAsyncQueryableExecuter _asyncExecuter;

public ProductAppService(
IRepository<Product, Guid> productRepository,
IAsyncQueryableExecuter asyncExecuter)
{
_productRepository = productRepository;
_asyncExecuter = asyncExecuter;
}

public async Task<ListResultDto<ProductDto>> GetListAsync(string name)
{
//Create the query
var query = _productRepository
.Where(p => p.Name.Contains(name))
.OrderBy(p => p.Name);

//Run the query asynchronously
List<Product> products = await _asyncExecuter.ToListAsync(query);

//...
}
}
}

ABP框架使用实际数据库提供程序的API异步执行查询.虽然这不是执行查询的常见方式,但它是使用异步API而不依赖于数据库提供者的最佳方式.

领域服务

领域驱动设计(DDD) 解决方案中,核心业务逻辑通常在聚合(实体)和领域服务中实现.

在以下情况下特别需要创建领域服务

  • 你实现了依赖于某些服务(如存储库或其他外部服务)的核心域逻辑.
  • 你需要实现的逻辑与多个聚合/实体相关,因此它不适合任何聚合.

领域服务是简单的无状态类. 虽然你不必从任何服务或接口派生,但ABP框架提供了一些有用的基类和约定.

DomainServiceIDomainService
DomainService基类派生领域服务或直接实现 IDomainService接口.

示例:
创建从DomainService基类派生的领域服务.

1
2
3
4
5
6
7
8
using Volo.Abp.Domain.Services;
namespace MyProject.Issues
{
public class IssueManager : DomainService
{

}
}

当你这样做时:ABP框架自动将类注册为瞬态生命周期到依赖注入系统.
你可以直接使用一些常用服务作为基础属性,而无需手动注入 (例如ILogger and IGuidGenerator).

建议使用ManagerService 后缀命名领域服务. 我们通常使用如上面示例中的 Manager 后缀.

示例:
实现将问题分配给用户的领域逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class IssueManager : DomainService
{
private readonly IRepository<Issue, Guid> _issueRepository;
public IssueManager(IRepository<Issue, Guid> issueRepository)
{
_issueRepository = issueRepository;
}

public async Task AssignAsync(Issue issue, AppUser user)
{
var currentIssueCount = await _issueRepository
.CountAsync(i => i.AssignedUserId == user.Id);

//Implementing a core business validation
if (currentIssueCount >= 3)
{
throw new IssueAssignmentException(user.UserName);
}
issue.AssignedUserId = user.Id;
}
}

问题是定义如下所示的 聚合根:

1
2
3
4
5
6
public class Issue : AggregateRoot<Guid>
{
public Guid? AssignedUserId { get; internal set; }

//...
}

使用internalset确保外层调用者不能直接在调用 set,并强制始终使用IssueManagerUser分配 Issue.

应用程序服务与领域服务
虽然应用服务领域服务都实现了业务规则,但存在根本的逻辑和形式差异:

  • 应用程序服务实现应用程序的用例(典型Web应用程序中的用户交互),而领域服务实现核心的、用例独立的领域逻辑.
  • 应用程序服务获取/返回 数据传输对象,领域服务方法通常获取和返回领域对象(实体,值对象).
  • 领域服务通常由应用程序服务或其他领域服务使用,而应用程序服务由表示层或客户端应用程序使用.

生命周期
领域服务的生命周期是瞬态的,它们会自动注册到依赖注入服务.

规约

规约模式用于为实体和其他业务对象定义 命名、可复用、可组合和可测试的过滤器 .
你可以创建一个由Specification<Customer>派生的新规约类.

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using System.Linq.Expressions;
using Volo.Abp.Specifications;
namespace MyProject
{
public class Age18PlusCustomerSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return c => c.Age >= 18;
}
}
}

你也可以直接实现ISpecification<T>接口,但是基类Specification<T>做了大量简化.

虽然规约模式通常与C#lambda表达式相比较,算是一种更老的方式.一些开发人员可能认为不再需要它,我们可以直接将表达式传入到仓储或领域服务中,如下所示:

1
var count = await _customerRepository.CountAsync(c => c.Balance > 100000 && c.Age => 18);

自从ABP的仓储支持表达式,这是一个完全有效的用法.你不必在应用程序中定义或使用任何规约,可以直接使用表达式.

所以,规约的意义是什么?为什么或者应该在什么时候考虑去使用它?

何时使用?
使用规约的一些好处:

  • 可复用:假设你在代码库的许多地方都需要用到优质顾客过滤器.如果使用表达式而不创建规约,那么如果以后更改“优质顾客”的定义会发生什么?假设你想将最低余额从100000美元更改为250000美元,并添加另一个条件,成为顾客超过3年.如果使用了规约,只需修改一个类.如果在任何其他地方重复(复制/粘贴)相同的表达式,则需要更改所有的表达式.
  • 可组合:可以组合多个规约来创建新规约.这是另一种可复用性.
    命名:PremiumCustomerSpecification更好地解释了为什么使用规约,而不是复杂的表达式.因此,如果在你的业务中使用了一个有意义的表达式,请考虑使用规约.
  • 可测试:规约是一个单独(且易于)测试的对象.

什么时侯不要使用?

  • 没有业务含义的表达式:不要对与业务无关的表达式和操作使用规约.
  • 报表:如果只是创建报表,不要创建规约,而是直接使用IQueryable 和LINQ表达式.你甚至可以使用普通SQL、视图或其他工具生成报表.DDD不关心报表,因此从性能角度来看,查询底层数据存储的方式可能很重要.

应用服务层

应用服务

应用服务实现应用程序的用例, 将领域层逻辑公开给表示层.从表示层调用应用服务,DTO作为参数. 返回DTO给表示层.