聚合在.NET中的实现

上下文可以从数据库中查询出数据并且跟踪对象状态的改变,然后把对象状态的改变保存到数据库中,因此上下文就是一个天然的仓储的实现;上下文会跟踪多个对象状态的改变,让后在SaveChanges方法中把所有的改变一次性提交到数据库中,这是个“要么全部成功,要么全部失败”的操作,因此上下文也是一个天然的工作单元的实现。

有一些开发人员会再编写仓储和工作单元的接口以封装上下文的操作,这样可以把EF Core的操作封装起来,不仅可以让代码不依赖于EF Core,而且今后如果我们需要把EF Core替换为其他持久化机制,代码切换起来也会更容易。但是本书将直接用上下文作为仓储,而不是定义一个仓储的抽象层,微软也是这样建议的。因为EF Core是一个很好的仓储和工作单元的实现框架,很难找到另一款可以很好实现DDD的ORM框架,无论抽象层怎么定义,如果需要把EF Core替换为其他ORM框架,代码就不可能不做任何改变。我们直接用上下文做仓储,这样可以最大化地利用EF Core的特性,从而提供更高性能的仓储实现。

在EF Core中,我们可以不为每个实体类都声明对应的DbSet类型的属性,即使一个实体类没有声明对应的DbSet类型的属性,只要EF Core遇到实体类对象,EF Core仍然会像对待其他实体类对象一样对其进行处理。由于除了聚合根实体类之外,聚合中其他实体雷不应该被开发人员访问到,因此我们可以在上下文中只为聚合根实体类声明DbSet类型的属性。

  1. 聚合关系的基本实现
    聚合关系通过引用类型成员变量实现,子对象的生命周期不由父对象控制。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 子对象:可以独立存在
public class Employee
{
public string Name { get; set; }
public Employee(string name) => Name = name;
}

// 父对象:通过引用“聚合”子对象
public class Department
{
public string DepartmentName { get; set; }
private readonly Employee _manager; // 部门经理(聚合关系)

public Department(string name, Employee manager)
{
DepartmentName = name;
_manager = manager; // 注入已存在的 Employee 对象
}
}
  1. 集合聚合示例
    父对象可以聚合多个子对象,例如一个团队包含多个成员:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Team
{
public string TeamName { get; set; }
private readonly List<Employee> _members = new();

public Team(string name) => TeamName = name;

// 添加成员(子对象)
public void AddMember(Employee employee) => _members.Add(employee);

// 移除成员(子对象不会被销毁)
public void RemoveMember(Employee employee) => _members.Remove(employee);
}
  1. 与组合关系的对比
    组合(Composition)是更强的 “拥有” 关系,子对象的生命周期由父对象控制。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Car
{
private readonly Engine _engine; // 组合关系

public Car()
{
_engine = new Engine(); // 父对象创建并管理子对象生命周期
}

// 父对象销毁时,子对象也会被销毁
}

public class Engine { }
  1. 聚合的实际应用场景
    依赖注入(DI):通过构造函数注入依赖对象(聚合关系)。
1
2
3
4
5
6
7
8
9
public class UserService
{
private readonly IUserRepository _repository; // 聚合关系

public UserService(IUserRepository repository) // 注入已存在的仓储实例
{
_repository = repository;
}
}

数据模型聚合:例如订单聚合多个订单项。

1
2
3
4
5
6
7
public class Order
{
public int OrderId { get; set; }
public List<OrderItem> Items { get; set; } = new(); // 聚合多个订单项
}

public class OrderItem { }

总结
聚合:通过引用关联对象,子对象可独立存在,生命周期不由父对象管理。
组合:子对象不可独立存在,父对象负责创建和销毁子对象。

还有一个问题需要讨论,如果一个微服务中有多个聚合根,那么我们是把每个聚合根实体类放到一个单独的上下文中,还是把所有实体类放到同一个上下文中?前者的优点是上下文的耦合度更低,聚合根之间的界限划分更清晰,缺点就是开发起来比较麻烦,而且实现跨聚合查询的时候也比较麻烦;后者的优点是开发难度低,跨聚合查询也简单,缺点就是聚合根在上下文里有一个定程度的耦合,我们无法很容易地看到聚合的划分。后者,也就是把同一个微服务中的所有实体类都放到同一个上下文中,因为虽然聚合之间的关系不紧密,但是它们毕竟属于同一个微服务,它们之间的关系仍然比它们和其他微服务之间的关系更紧密,而且我们还会在应用服务中进行跨聚合的组合操作,如果参与应用服务的组合操作的聚合都属于同一个上下文,我们再进行联合查询的时候可以获得更好的性能,再进行跨聚合的数据修改的保存的时候,也能更容易地实现强一致性的事务。当让,如果经过良好的微服务拆分设计之后,一个微服务中的部分聚合和其他聚合的关系仍然不紧密,我们也可以把它们放到不同的上下文中。

如果选择了把一个微服务中所有聚合中的实体类都放到同一个上下文中,为了区分聚合根实体类和其他实体类,我们可以定义一个不包含任何成员的标识接口,比如IAggregateRoot,然后要求所有的聚合根实体类都实现这个接口。

由于聚合之间是松耦合关系,它们只通过聚合根的Id进行关联,因此所有跨聚合的数据查询都应该是通过领域服务的协议来完成的,而不应该直接在数据库表之间进行jojn查询。当然,对于统计、汇总等报表类的应用,则不需要遵循聚合的规范,我们可以通过执行原生SQL语句进行跨表查询。

实体类不要面向数据库建模

  1. 建模的时候不要先考虑实体在数据库中如何保存。比如实体类和数据表具有直接的对应关系,实体类中属性和数据表中的列几乎完成一致。这样设计出来的类称不上“实体类”,只能被成为数据对象(Data Object)。更不要用DB First(反向工程)。

  2. 因该不考虑数据库实现的情况下进行领域模型建模,然后再使用Fluent API等对实体类和数据库之间做适配。在实现的时候,可能需要对建模进行妥协性修改,但是这不应该在最开始被考虑。

用MediatR实现领域事件

第一步,创建一个ASP.NET Core项目,然后通过NuGet安装MediatR。
第二步,在项目的Program.cs中调用AddMediatR方法把与MediatR相关的服务注册到依赖注入容器中,AddMediatR方法的参数中一般指定事件处理者所在的若干个程序集。

1
2
3
4
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});

第三步,定义一个在事件的发布者和处理者之间进行数据传递的类TestEvent,这个类需要实现INotification接口。

1
public record TestEvent(string UserName):INotification;

TestEvent中的UserName属性代表登录用户名。事件一般都是从发布者传递到处理者的,很少有在事件的处理者处直接反向通知事件发布者的需求,因此实现INotification的TestEvent类的属性一般都是不可变的,我们用record与发来声明这个类。

第四步,事件的处理者要实现NotificationHandler<TNotification>接口,其中的泛型参数TNotification代表此事件处理者要处理的消息类型。所有TNotification类型的事件都会被事件处理者处理。我们编写两个事件处理者,它们分别把收到的事件输出到控制台和写入文件。

1
2
3
4
5
6
7
8
public class TestEventHandler1 : INotificationHandler<TestEvent>
{
public Task Handle(TestEvent notification, CancellationToken cancellationToken)
{
Console.WriteLine($"我收到了{notification.UserName}");
return Task.CompletedTask;
}
}
1
2
3
4
5
6
7
public class TestEventHandler2 : INotificationHandler<TestEvent>
{
public async Task Handle(TestEvent notification, CancellationToken cancellationToken)
{
await File.WriteAllTextAsync("D:/SqlServer.txt", $"来了{notification.UserName}");
}
}

第五步,在需要发布事件的类中注入IMediator类型的服务,然后我们调用Publish方法来发布。注意不要错误地调用Send方法来发布事件,因此Send方法是用来发布一对一事件的,而Publish方法是用来发布一对多事件的。我们需要在控制器的登陆方法中发布事件,这里我们省略实际的登陆代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TestController : ControllerBase
{
private readonly IMediator mediator;
public TestController(IMediator mediator)
{
this.mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> Login(TestEvent req)
{
await mediator.Publish(new TestEvent(req.UserName));
return Ok("ok");
}
}