DDD的技术落地 — EF Core中发布领域事件的合适时机

领域事件大部分发生在领域模型的业务逻辑方法上或者领域服务上,我们可以在一个领域事件发生的时候立即调用IMediator的Publish方法来发布领域事件。

我们一般在聚合根的实体类对象的ChangeName、构造方法等方法中发布领域事件。

因为无论是应用服务还是领域服务,最终都要调用聚合根中的方法来操作聚合,我们这样做可以确保领域事件不会被漏掉。

但是在实体类的业务方法中立即进行领域事件的发布可能会有如下的问题:

  • 可能存在重复发送领域事件的情况。比如,在”修改用户信息”这个应用服务操作中,我们分别调用实体类的ChangeName、ChangeAge、ChangeEmail方法修改用户的姓名、年龄和邮箱。因为每个ChangeXXX方法中都会发布”实体类被修改”的领域事件,所以领域事件的处理者就会被多次调用,这是没有必要的,其实只要发布一次”实体类被修改”的领域事件即可。
  • 领域事件发布太早。为了确保新增加的实体类能够发布”新增实体类”的领域事件,我们需要在实体类的构造方法中发布领域事件,但是有可能因为数据验证没有通过等原因,我们最终没有把这个新增的实体类保存到数据库中,这样在构造方法中过早地发布领域事件就可能导致”误报”的问题。

参考微软开源的eShopOnContainers项目中的做法,本书作者把领域事件的发布延迟到上下文保存修改时。

也就是实体类中只注册要发布的领域事件,然后在上下文的SaveChange方法被调用时,我们再发布领域事件。

领域事件是由聚合根进行管理的,因此我们定义了供聚合根进行事件注册的接口IDomainEvents,如以下代码所示:

C#
 public interface IDomainEvents
 {
     /// <summary>
     /// 获取注册的领域事件
     /// </summary>
     /// <returns></returns>
     IEnumerable<INotification> GetDomainEvents(); //获取注册的领域事件

     /// <summary>
     /// 注册领域事件
     /// </summary>
     /// <param name="eventItem"></param>
     void AddDomainEvent(INotification eventItem);

     /// <summary>
     /// 如果领域事件不存在,则注册领域事件
     /// </summary>
     /// <param name="eventItem"></param>
     void AddDomainEventIfAbsent(INotification eventItem);

     /// <summary>
     /// 清除注册的领域事件
     /// </summary>
     void ClearDomainEvents();
 }

为了简化实体类的代码编写,我们编写实现了IDomainEvents接口的抽象实体类BaseEntity,如以下代码所示:

C#
 public abstract class BaseEntity : IDomainEvents
 {
     private List<INotification> DomainEvents = new();

     public void AddDomainEvent(INotification eventItem)
     {
         DomainEvents.Add(eventItem);
     }

     public void AddDomainEventIfAbsent(INotification eventItem)
     {
         if (!DomainEvents.Contains(eventItem))
         {
             DomainEvents.Add(eventItem);
         }
     }

     public void ClearDomainEvents()
     {
         DomainEvents.Clear();
     }

     public IEnumerable<INotification> GetDomainEvents()
     {
         return DomainEvents;
     }
 }

我们需要在上下文中保存数据的时候发布注册的领域事件。

在DDD中,每个聚合都对应一个上下文,因此项目中的上下文类非常多。

为了简化上下文代码的编写,我们编写BaseDbContext类,将在SaveChanges中发布领域事件的代码封装到这个类中,如以下代码所示:

C#
public abstract class BaseDbContext : DbContext
{
    private IMediator mediator;

    public BaseDbContext(DbContextOptions options, IMediator mediator) : base(options)
    {
        this.mediator = mediator;
    }

    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        throw new NotImplementedException("Don not call SaveChanges, please call SaveChangesAsync instead.");
    }

    public async override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {

        //ChangeTracker是上下文中用来对实体类的变化进行追踪的对象,Entries<IDomainEvents>获得的是所有实现了IDomainEvents接口的实体类
        var domainEntities = this.ChangeTracker.Entries<IDomainEvents>()
                        .Where(x => x.Entity.GetDomainEvents().Any());

        var domainEvents = domainEntities.SelectMany(x => x.Entity.GetDomainEvents()).ToList();


        domainEntities.ToList().ForEach(entity => entity.Entity.ClearDomainEvents());


        foreach (var domainEvent in domainEvents)
        {
            await mediator.Publish(domainEvent);
        }

        return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);

    }
}

因为我们需要发布注册的领域事件,所以我们通过构造方法注入IMediator服务;

我们重写了父类的SaveChangeAsync方法,在调用父类的SaveChangesAsync方法保存修改之前,我们把所有实体类中注册的领域事件发布出去;

至此,我们完成了EF Core中简化领域事件发布的几个接口和抽象类的开发。

接下来,我们编写用来测试的实体类和上下文。

首先我们编写代表用户的实体类User,其主干内容如以下代码所示:

C#
    public class User: BaseEntity
    {
        public Guid Id { get; init; }
        public string UserName { get; init; }
        public string Email { get; private set; }
        public string? NickName { get; private set; }
        public int? Age { get; private set; }
        public bool IsDeleted { get; private set; }
        private User()
        {
            //提供无参构造方法。避免EF Core加载数据的时候调用有参的构造方法触发领域事件
        }
        //发布了UserAddedEvent领域事件,这样当我们创建新的实体类并且保存修改的时候,这个领域事件就会被发布
        public User(string userName,string email)
        {
            this.Id = Guid.NewGuid();
            this.UserName = userName;
            this.Email = email;
            this.IsDeleted = false;
            AddDomainEvent(new UserAddedEvent(this));
        }

        //因为可能连续调用ChangeNickName、ChangeAge等方法,所以通过AddDomainEventIfAbsent方法来避免重复发布领域事件
        public void ChangeEmail(string value)
        {
            this.Email = value;
            AddDomainEventIfAbsent(new UserUpdatedEvent(Id));
        }
        public void ChangeNickName(string? value)
        {
            this.NickName = value;
            AddDomainEventIfAbsent(new UserUpdatedEvent(Id));
        }
        public void ChangeAge(int value)
        {
            this.Age = value;
            AddDomainEventIfAbsent(new UserUpdatedEvent(Id));
        }
        public void SoftDelete()
        {
            this.IsDeleted = true;
            AddDomainEvent(new UserSoftDeletedEvent(Id));
        }
    }

UserUpdateEvent等类是自定义的传递领域事件的类,如以下代码所示:

C#
public record UserAddedEvent(User Item):INotification;
public record UserUpdatedEvent(Guid Id):INotification;
public record UserSoftDeletedEvent(Guid Id):INotification;

接下来,我们编写事件处理类来对这些领域事件进行处理。

首先,我们编写响应UserAddedEvent领域事件,然后向用户发送注册邮件的NewUserSendEmailHandler类,如以下代码所示:

C#
  public class NewUserSendEmailHandler : INotificationHandler<UserAddedEvent>
  {
      private readonly ILogger<NewUserSendEmailHandler> logger;

      public NewUserSendEmailHandler(ILogger<NewUserSendEmailHandler> logger)
      {
          this.logger = logger;
      }

      public Task Handle(UserAddedEvent notification, CancellationToken cancellationToken)
      {
          var user = notification.Item;
          logger.LogInformation($"{user.Email}发送欢迎邮件");
          return Task.CompletedTask;
      }
  }

这里使用日志输出代替真正的日志发送,在实际项目中是将邮件发送的代码放到后台线程执行的。

下面我们再实现一个”当用户的个人信息被修改后,发邮件通知用户的事件处理者”的功能,如以下代码所示:

C#
    public class ModifyUserLogHandler : INotificationHandler<UserUpdatedEvent>
    {
        private readonly UserDbContext context;
        private readonly ILogger<ModifyUserLogHandler> logger;

        public ModifyUserLogHandler(UserDbContext context, ILogger<ModifyUserLogHandler> logger)
        {
            this.context = context;
            this.logger = logger;
        }

        public async Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken)
        {
            //var user = await context.Users.SingleAsync(u=>u.Id== notification.Id);
            //FindAsync首先从上下文的缓存中获取对象,而修改操作之前被修改的对象已经存在于缓存中了
            var user = await context.Users.FindAsync(notification.Id);
            logger.LogInformation($"通知用户{user.Email}的信息被修改");
        }
    }

最好我们编写一个Update方法和控制器类UsersController来执行用户新增、用户修改等操作,如以下代码所示:

C#
 [Route("api/[controller]/[action]")]
 [ApiController]
 public class UsersController : ControllerBase
 {
     private UserDbContext context;

     public UsersController(UserDbContext context)
     {
         this.context = context;
     }

     [HttpPost]
     public async Task<IActionResult> Add(AddUserRequest req)
     {
         var user = new User(req.UserName, req.Email);
         context.Users.Add(user);
         await context.SaveChangesAsync();
         return Ok();
     }

     [HttpPut]
     [Route("{id}")]
     public async Task<IActionResult> Update(Guid id,UpdateUserRequest req)
     {
         User? user = context.Users.Find(id);
         if (user==null)
         {
             return NotFound($"id={id}的User不存在");
         }
         user.ChangeAge(req.Age);
         user.ChangeEmail(req.Email);
         user.ChangeNickName(req.NickName);
         await context.SaveChangesAsync();
         return Ok();
     }

     [HttpDelete]
     [Route("id")]
     public async Task<IActionResult> Delete(Guid id)
     {
         User? user = context.Users.Find(id);
         if (user == null)
         {
             return NotFound($"id={id}的User不存在");
         }
         user.SoftDelete();
         await context.SaveChangesAsync();
         return Ok();
     }
 }

运行上面的程序,执行控制器中的新增用户、修改用户的操作后,可以看到日志输出结果如图所示:

可以看到,UserAddedEvent和UserUpdateEvent两个领域事件的事件处理者的代码都执行了。

在UsersController中,我们调用了多个ChangeXXX方法,这些方法都通过AddDomainEventIfAbsent方法向聚合根中注册领域事件,只有一个领域事件注册成功了,因此在修改用户的时候,UserUpdatedEvent事件只被发布了一次。

由于我们在SaveChangesAsync方法中把发布领域事件的代码放到了调用父类上下文的SaveChangesAsync方法之前,而且领域事件的处理者的代码也是同步运行的,因此领域事件的处理者的代码也会在把上下文中模型的修改保存到数据库之前执行。

这样所以的代码都在同一个数据库事务中执行,就构成了一个强一致性的事务。

订阅评论
提醒
0 评论
最旧
最新 最多投票
内联反馈
查看所有评论
滚动至顶部