• DDD 实战记录——实现「借鉴学习计划」


    「借鉴学习计划」的核心是:复制一份别人的学习计划到自己的计划中,并同步推送学习任务给自己,并且每个操作都要发送通知给对方。

    它们的类图如下:

    它们的关系是一对多:

    // Schedule
    entity.HasOne(x => x.Parent).WithMany(x => x.Children).HasForeignKey(x => x.ParentId).OnDelete(DeleteBehavior.Restrict);
    entity.HasIndex(nameof(Schedule.UserId), nameof(Schedule.ParentId)).IsUnique().HasFilter($"[{nameof(Schedule.Deleted)}]=0 and [{nameof(Schedule.ParentId)}] is not null");
    
    // ScheduleItem
    entity.HasOne(i => i.Schedule).WithMany(s => s.Items).HasForeignKey(i => i.ScheduleId);
    entity.HasOne(i => i.Html).WithOne(h => h.Item).HasForeignKey<ScheduleItemHtml>(h => h.ScheduleItemId);
    entity.HasOne(x => x.Parent).WithMany(x => x.Children).HasForeignKey(x => x.ParentId).OnDelete(DeleteBehavior.Restrict);
    entity.HasIndex(nameof(ScheduleItem.UserId), nameof(ScheduleItem.ParentId)).IsUnique().HasFilter($"[{nameof(ScheduleItem.Deleted)}]=0 and [{nameof(ScheduleItem.ParentId)}] is not null");
    

    按照 DDD 的思路,业务应该发生在领域层中,事件也是从领域中触发的,整个流程的可读性比较强,下面以借鉴功能为例:

        // Domain.Schedule.cs
        /* 借鉴 */
        public class Schedule : Entity, IAggregateRoot
        {
            private Schedule()
            {
                Items = new List<ScheduleItem>();
                Children = new List<Schedule>();
            }
    
            public Schedule(string title, string description, Guid userId, bool isPrivate = false, long? parentId = null) : this()
            {
                Title = title;
                Description = description;
                UserId = userId;
                IsPrivate = isPrivate;
                if (parentId.HasValue)
                {
                    ParentId = parentId;
                }
                AddDomainEvent(new ScheduleCreatedEvent(UUID));
            }
            public Schedule Subscribe(Guid userId)
            {
                if (userId == UserId)
                {
                    throw new ValidationException("不能借鉴自己的计划");
                }
    
                if (ParentId > 0)
                {
                    throw new ValidationException("很抱歉,暂时不支持借鉴来的学习计划");
                }
                var child = Deliver(userId);
                Children.Add(child);
                FollowingCount += 1;
    
                AddDomainEvent(new NewSubscriberEvent(this.UUID, child.UUID));
    
                return child;
            }
            public Schedule Deliver(Guid userId)
            {
                var schedule = new Schedule(Title, Description, userId, isPrivate: false, Id);
                return schedule;
            }
        }
    

    阅读Subscribe():首先不能借鉴自己的计划,其次不能借鉴借鉴来的计划,Deliver()生产或者说克隆一个Schedule出来,作为当前计划的孩子,然后把借鉴数+1,触发有新的借鉴者事件NewSubscriberEvent

    Application作为领域的消费者,就可以直接消费这个领域了。

            // Application.ScheduleAppService.cs
            public async Task<long> SubscribeAsync(long id, Guid userId)
            {
                var schedule = await _repository.Schedules.FirstOrDefaultAsync(s => s.Id == id);
                if (schedule != null)
                {
                    try
                    {
                        schedule.Subscribe(userId);
                        await _repository.UnitOfWork.SaveEntitiesAsync();
                    }
                    catch (Exception ex) when (ex.InnerException is SqlException sqlerror)
                    {
                        if (sqlerror.Number == 2601)
                        {
                            throw new ValidationException("已经借鉴过了");
                        }
                    }
                }
                return 0;
            }
    

    最后使用 UnitOfWork 工作单元持久化到数据库,并分发领域中产生的事件。

    // Infrastructure.DbContext.cs
            public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default(CancellationToken))
            {
                //https://stackoverflow.com/questions/45804470/the-dbcontext-of-type-cannot-be-pooled-because-it-does-not-have-a-single-public
                var bus = this.GetService<ICapPublisher>();
                using (var trans = Database.BeginTransaction())
                {
                    if (await SaveChangesAsync(cancellationToken) > 0)
                    {
                        await bus.DispatchDomianEventsAsync(this);
                        trans.Commit();
                    }
                    else
                    {
                        trans.Rollback();
                        return false;
                    }
                }
                return true;
            }
    

    通过 EF Core 的上下文实现了 IUnitOfWork 接口,通过事务保证一致性。这里使用 DotNetCore.CAP 这个优秀的开源产品帮助我们分发事件消息,处理最终一致性。

        public static class CapPublisherExtensions
        {
            public static async Task<int> DispatchDomianEventsAsync(this ICapPublisher bus, AcademyContext ctx)
            {
                var domainEntities = ctx.ChangeTracker
                               .Entries<BaseEntity>()
                               .Where(x => x.Entity.DomainEvents != null && x.Entity.DomainEvents.Any());
    
                if (domainEntities == null || domainEntities.Count() < 1)
                {
                    return 0;
                }
    
                var domainEvents = domainEntities
                    .SelectMany(x => x.Entity.DomainEvents)
                    .ToList();
    
                domainEntities.ToList()
                    .ForEach(entity => entity.Entity.ClearDomainEvents());
    
                var tasks = domainEvents
                    .Select(domainEvent => bus.PublishAsync(domainEvent.GetEventName(), domainEvent));
    
                await Task.WhenAll(tasks);
                return domainEvents.Count;
            }
        }
    

    这里参考了eShopContainer的实现。在触发事件的时候一直都有一个疑问,我们的实体的主键是自增长类型的,只有持久化到数据库之后才知道 Id 的值是多少,但是我们在领域事件中却经常需要这个 Id作为消息的一部分。我解决这个问题的方案,给实体增加一个 GUID 类型的字段UUID,作为唯一身份标识,这样我们就不需要关心最终的Id是多少了,用UUID就可以定位到这个实体了。

    事件消息分发出去后,关心这个事件消息的领域就能通过订阅去消费这个事件消息了。

    当有新的借鉴者的时候,“消息中心”这个领域关心这个事件,它的MsgService通过DotNetCore.CAP订阅事件消息:

    // Msg.AppService.cs
    [CapSubscribe(EventConst.NewSubscriber, Group = MsgAppConst.MessageGroup)]
    public async Task HandleNewSubscriberEvent(NewSubscriberEvent e)
    {
        // Notify schedule author
        var child = await _repository.FindByUUID<Schedule>(e.ChildScheduleUuid).Include(x => x.Parent).FirstOrDefaultAsync();
        if (child == null) return;
        var auth = await _uCenter.GetUser(x => x.UserId, child.Parent.UserId);
        if (auth == null) return;
        var subscriber = await _uCenter.GetUser(x => x.UserId, child.UserId);
        if (subscriber == null) return;
        var msg = new Notification
        {
            RecipientId = auth.SpaceUserId,
            Title = $"有用户借鉴了您的「{child.Parent.Title}」",
            Content = $@"<p>亲爱的 {auth.DisplayName} 同学:</p>
                    <p>
                        <b>
                        <a href='{AppConst.DomainAddress}/schedules/u/{subscriber.Alias}/{child.Id}'>
                            {subscriber.DisplayName}</a>
                        </b>
                        借鉴了您的学习计划
                        <a href='{AppConst.DomainAddress}/schedules/u/{auth.Alias}/{child.ParentId}'>
                        「{child.Parent.Title}」
                        </a>
                    </p>"
        };
        await _msgSvc.NotifyAsync(msg);
    }
    

    “消息中心”的业务是要给作者发送通知,它负责生产出通知Notification,因为我们团队已经有了基础服务——MsgService,已经实现发送通知的功能,所以只需要调用即可,如果没有的话我们就要自己来实现通过邮件或者短信进行通知。

    源代码已托管在 github 上了

  • 相关阅读:
    Java equals compareTo()的区别
    Java getClass() VS instanceof VS ==
    HashMap与LinkedHashMap
    位运算的一些用例
    常见字符集和编码方式
    spring 打印所有创建的beans
    ApplicationContext之getBean方法详解
    多线程时Autowired自动注入问题
    使用Nexus创建Maven私服
    MYSQL timestamp用法
  • 原文地址:https://www.cnblogs.com/kexxxfeng/p/11414218.html
Copyright © 2020-2023  润新知