使用.NET 6开发TodoList应用之领域实体创建原理和思路

2022-04-16 09:41:17

需求

上一篇文章中我们完成了数据存储服务的接入,从这一篇开始将正式进入业务逻辑部分的开发。

首先要定义和解决的问题是,根据TodoList项目的需求,我们应该设计怎样的数据实体,如何去进行操作?

长文预警!包含大量代码

目标

在本文中,我们希望达到以下几个目标:

定义领域实体; 通过数据库操作领域实体;

原理和思路

虽然TodoList是一个很简单的应用,业务逻辑并不复杂,至少在这个系列文章中我并不想使其过度复杂。但是我还是打算借此简单地涉及领域驱动开发(DDD)的基础概念。

首先比较明确的是,我们的实体对象应该有两个:TodoListTodoItem,并且一个TodoList是由多个TodoItem的列表构成,除此以外在实际的开发中,我们可能还需要追踪实体的变更情况,比如需要知道创建时间/修改时间/创建者/修改者,这种需求一般作为审计要求出现,而对实体的审计又是一个比较通用的需求。所以我们会将实体分成两部分:和业务需求直接相关的属性,以及和实体审计需求相关的属性。

其次,对于实体的数据库配置,有两种方式:通过Attribute或者通过IEntityTypeConfiguration<T>以代码的方式进行。我推荐使用第二种方式,将所有的具体配置集中到Infrastructure层去管理,避免后续修改字段属性而去频繁修改位于Domain层的实体对象定义,我们希望实体定义本身是稳定的。

最后,对于DDD来说有一些核心概念诸如领域事件,值对象,聚合根等等,我们都会在定义领域实体的时候有所涉及,但是目前还不会过多地使用。关于这些基本概念的含义,请参考这篇文章:浅谈java开发架构之领域驱动设计DDD落地。在我们的开发过程中,会进行一些精简,有部分内容也会随着后续的文章逐步完善。

实现

基础的领域概念框架搭建

所有和领域相关的概念都会进入到Domain这个项目中,我们首先在Domain项目里新建文件夹Base用于存放所有的基础定义,下面将一个一个去实现。(另一种方式是把这些最基础的定义单独提出去新建一个SharedDefinition类库并让Domain引用这个项目。)

基础实体定义以及可审计实体定义

我这两个类都应该是抽象基类,他们的存在是为了让我们的业务实体继承使用的,并且为了允许不同的实体可以定义自己主键的类型,我们将基类定义成泛型的。

AuditableEntity.cs

namespace TodoList.Domain.Base;public abstract class AuditableEntity{    public DateTime Created { get; set; }    public string? CreatedBy { get; set; }    public DateTime? LastModified { get; set; }    public string? LastModifiedBy { get; set; }}

Base里增加Interface文件夹来保存接口定义。

IEntity.cs

namespace TodoList.Domain.Base.Interfaces;public interface IEntity<T>{    public T Id { get; set; }}

除了这两个对象之外,我们还需要增加关于领域事件框架的定义。

DomainEvent.cs

namespace TodoList.Domain.Base;public abstract class DomainEvent{    protected DomainEvent()    {        DateOccurred = DateTimeOffset.UtcNow;    }    public bool IsPublished { get; set; }    public DateTimeOffset DateOccurred { get; protected set; } = DateTime.UtcNow;}

我们还剩下Aggregate Root, ValueObjectDomain Service以及Domain Exception,其他的相关概念暂时就不涉及了。

IHasDomainEvent.cs

namespace TodoList.Domain.Base.Interfaces;public interface IHasDomainEvent{    public List<DomainEvent> DomainEvents { get; set; }}

ValueObject的实现有几乎固定的写法,请参考:https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/microservice-ddd-cqrs-patterns/implement-value-objects

IAggregateRoot.cs

namespace TodoList.Domain.Base.Interfaces;// 聚合根对象仅仅作为标记来使用public interface IAggregateRoot { }

ValueObject.cs

namespace TodoList.Domain.Base;public abstract class ValueObject{    protected static bool EqualOperator(ValueObject left, ValueObject right)    {        if (left is null ^ right is null)        {            return false;        }        return left?.Equals(right!) != false;    }    protected static bool NotEqualOperator(ValueObject left, ValueObject right)    {        return !(EqualOperator(left, right));    }    protected abstract IEnumerable<object> GetEqualityComponents();    public override bool Equals(object? obj)    {        if (obj == null || obj.GetType() != GetType())        {            return false;        }        var other = (ValueObject)obj;        return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());    }    public override int GetHashCode()    {        return GetEqualityComponents()            .Select(x => x != null ? x.GetHashCode() : 0)            .Aggregate((x, y) => x ^ y);    }}

关于Domain Exception的定义,是根据业务内容来确定的,暂时不在Base里实现,而是放到各个聚合根的层级里。

定义TodoLIst/TodoItem实体

TodoList对象从领域建模上来说属于聚合根,并且下一节将要实现的TodoItem是构成该聚合根的一部分,业务意思上不能单独存在。有一种实现方式是按照聚合根的关联性进行代码组织:即在Domain项目里新建文件夹AggregateRoots/TodoList来保存和这个聚合根相关的所有业务定义:即:Events/ExceptionsEnums三个文件夹用于存放对应的内容。但是这样可能会导致项目的目录层级太多,实际上在这里,我更倾向于在Domain项目的根目录下创建Entities/Events/Enums/Exceptions/ValueObjects文件夹来扁平化领域模型,对于世纪的开发查找起来也并不麻烦。所以才去后一种方式,然后在Entities中创建TodoItemTodoList实体:

TodoItem.cs

using TodoList.Domain.Base;using TodoList.Domain.Base.Interfaces;using TodoList.Domain.Enums;using TodoList.Domain.Events;namespace TodoList.Domain.Entities;public class TodoItem : AuditableEntity, IEntity<Guid>, IHasDomainEvent{    public Guid Id { get; set; }    public string? Title { get; set; }    public PriorityLevel Priority { get; set; }    private bool _done;    public bool Done    {        get => _done;        set        {            if (value && _done == false)            {                DomainEvents.Add(new TodoItemCompletedEvent(this));            }            _done = value;        }    }    public TodoList List { get; set; } = null!;    public List<DomainEvent> DomainEvents { get; set; } = new List<DomainEvent>();}

PriorityLevel.cs

namespace TodoList.Domain.Enums;public enum PriorityLevel{    None = 0,    Low = 1,    Medium = 2,    High = 3}

TodoItemCompletedEvent.cs

using TodoList.Domain.Base;using TodoList.Domain.Entities;namespace TodoList.Domain.Events;public class TodoItemCompletedEvent : DomainEvent{    public TodoItemCompletedEvent(TodoItem item) => Item = item;    public TodoItem Item { get; }}

TodoList.cs

using TodoList.Domain.Base;using TodoList.Domain.Base.Interfaces;using TodoList.Domain.ValueObjects;namespace TodoList.Domain.Entities;public class TodoList : AuditableEntity, IEntity<Guid>, IHasDomainEvent, IAggregateRoot{    public Guid Id { get; set; }    public string? Title { get; set; }    public Colour Colour { get; set; } = Colour.White;    public IList<TodoItem> Items { get; private set; } = new List<TodoItem>();    public List<DomainEvent> DomainEvents { get; set; } = new List<DomainEvent>();}

为了演示ValueObject,添加了一个Colour对象,同时添加了一个领域异常对象UnsupportedColourException

Colour.cs

using TodoList.Domain.Base;namespace TodoList.Domain.ValueObjects;public class Colour : ValueObject{    static Colour() { }    private Colour() { }    private Colour(string code) => Code = code;    public static Colour From(string code)    {        var colour = new Colour { Code = code };        if (!SupportedColours.Contains(colour))        {            throw new UnsupportedColourException(code);        }        return colour;    }    public static Colour White => new("#FFFFFF");    public static Colour Red => new("#FF5733");    public static Colour Orange => new("#FFC300");    public static Colour Yellow => new("#FFFF66");    public static Colour Green => new("#CCFF99 ");    public static Colour Blue => new("#6666FF");    public static Colour Purple => new("#9966CC");    public static Colour Grey => new("#999999");    public string Code { get; private set; } = "#000000";    public static implicit operator string(Colour colour) => colour.ToString();    public static explicit operator Colour(string code) => From(code);    public override string ToString() => Code;    protected static IEnumerable<Colour> SupportedColours    {        get        {            yield return White;            yield return Red;            yield return Orange;            yield return Yellow;            yield return Green;            yield return Blue;            yield return Purple;            yield return Grey;        }    }    protected override IEnumerable<object> GetEqualityComponents()    {        yield return Code;    }}

UnsupportedColourException.cs

namespace TodoList.Domain.Exceptions;public class UnsupportedColourException : Exception{    public UnsupportedColourException(string code)        : base($"Colour "[code]" is unsupported.")    {    }}

关于领域服务的内容我们暂时不去管,继续看看如何向数据库配置实体对象。

领域实体的数据库配置

这部分内容相对会熟悉一些,我们在Infrastructure/Persistence中新建文件夹Configurations用于存放实体配置:

TodoItemConfiguration.cs

using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Metadata.Builders;using TodoList.Domain.Entities;namespace TodoList.Infrastructure.Persistence.Configurations;public class TodoItemConfiguration : IEntityTypeConfiguration<TodoItem>{    public void Configure(EntityTypeBuilder<TodoItem> builder)    {        builder.Ignore(e => e.DomainEvents);        builder.Property(t => t.Title)            .HasMaxLength(200)            .IsRequired();    }}

TodoListConfiguration.cs

using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Metadata.Builders;namespace TodoList.Infrastructure.Persistence.Configurations;public class TodoListConfiguration : IEntityTypeConfiguration<Domain.Entities.TodoList>{    public void Configure(EntityTypeBuilder<Domain.Entities.TodoList> builder)    {        builder.Ignore(e => e.DomainEvents);        builder.Property(t => t.Title)            .HasMaxLength(200)            .IsRequired();        builder.OwnsOne(b => b.Colour);    }}

修改DbContext

因为下一篇里我们将要使用Repository模式,所以我们可以不需要让TodoListDbContext继续继承IApplicationDbContext了。关于直接在Application里使用Context比较简单,就不继续演示了。

在这一步里面,我们需要完成以下几件事:

添加数据表; 重写SaveChangesAsync方法,自动补充审计相关字段值,并且在此发送领域事件;

对于第一件事,很简单。向TodoListDbContext.cs类定义中加入:

// TodoLIst实体与命名空间名称有冲突,所以需要显示引用其他命名空间里的对象public DbSet<Domain.Entities.TodoList> TodoLists => Set<Domain.Entities.TodoList>();public DbSet<TodoItem> TodoItems => Set<TodoItem>();

对于第二件事,我们需要先向Application/Common/Interfaces中添加一个接口用于管理领域事件的分发,但是在讲到CQRS之前,我们暂时以Dummy的方式实现这个接口,因为将要使用第三方框架实现具体逻辑,所以我们把实现类放到Infrastrcucture/Services目录下,并在TodoListDbContext中注入使用。

IDomainEventService.cs

using TodoList.Domain.Base;namespace TodoList.Application.Common.Interfaces;public interface IDomainEventService{    Task Publish(DomainEvent domainEvent);}

DomainEventService.cs

using Microsoft.Extensions.Logging;using TodoList.Application.Common.Interfaces;using TodoList.Domain.Base;namespace TodoList.Infrastructure.Services;public class DomainEventService : IDomainEventService{    private readonly ILogger<DomainEventService> _logger;    public DomainEventService(ILogger<DomainEventService> logger)    {        _logger = logger;    }    public async Task Publish(DomainEvent domainEvent)    {        // 在这里暂时什么都不做,到CQRS那一篇的时候再回来补充这里的逻辑        _logger.LogInformation("Publishing domain event. Event - {event}", domainEvent.GetType().Name);    }}

DependencyInjection中注入:

// 省略以上...并且这一句可以不需要了// services.AddScoped<IApplicationDbContext>(provider => provider.GetRequiredService<TodoListDbContext>());// 增加依赖注入services.AddScoped<IDomainEventService, DomainEventService>();return services;

最终的TodoListDbContext实现如下:

TodoListDbContext.cs

using System.Reflection;using Microsoft.EntityFrameworkCore;using TodoList.Application.Common.Interfaces;using TodoList.Domain.Base;using TodoList.Domain.Base.Interfaces;using TodoList.Domain.Entities;namespace TodoList.Infrastructure.Persistence;public class TodoListDbContext : DbContext{    private readonly IDomainEventService _domainEventService;    public TodoListDbContext(        DbContextOptions<TodoListDbContext> options,        IDomainEventService domainEventService) : base(options)    {        _domainEventService = domainEventService;    }    public DbSet<Domain.Entities.TodoList> TodoLists => Set<Domain.Entities.TodoList>();    public DbSet<TodoItem> TodoItems => Set<TodoItem>();    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())    {        // 在我们重写的SaveChangesAsync方法中,去设置审计相关的字段,目前对于修改人这个字段暂时先给个定值,等到后面讲到认证鉴权的时候再回过头来看这里        foreach (var entry in ChangeTracker.Entries<AuditableEntity>())        {            switch (entry.State)            {                case EntityState.Added:                    entry.Entity.CreatedBy = "Anonymous";                    entry.Entity.Created = DateTime.UtcNow;                    break;                case EntityState.Modified:                    entry.Entity.LastModifiedBy = "Anonymous";                    entry.Entity.LastModified = DateTime.UtcNow;                    break;            }        }        // 在写数据库的时候同时发送领域事件,这里要注意一定要保证写入数据库成功后再发送领域事件,否则会导致领域对象状态的不一致问题。        var events = ChangeTracker.Entries<IHasDomainEvent>()                .Select(x => x.Entity.DomainEvents)                .SelectMany(x => x)                .Where(domainEvent => !domainEvent.IsPublished)                .ToArray();        var result = await base.SaveChangesAsync(cancellationToken);        await DispatchEvents(events);        return result;    }    protected override void OnModelCreating(ModelBuilder builder)    {        // 应用当前Assembly中定义的所有的Configurations,就不需要一个一个去写了。        builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());        base.OnModelCreating(builder);    }    private async Task DispatchEvents(DomainEvent[] events)    {        foreach (var @event in events)        {            @event.IsPublished = true;            await _domainEventService.Publish(@event);        }    }}

验证

生成Migrations

老办法,先生成Migrations。

$ dotnet ef migrations add AddEntities -p src/TodoList.Infrastructure/TodoList.Infrastructure.csproj -s src/TodoList.Api/TodoList.Api.csprojBuild started...Build succeeded.[14:06:15 INF] Entity Framework Core 6.0.1 initialized 'TodoListDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:6.0.1' with options: MigrationsAssembly=TodoList.Infrastructure, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null Done. To undo this action, use 'ef migrations remove'

使用种子数据更新数据库

为了演示效果,在Infrastructure/Persistence/下创建TodoListDbContextSeed.cs文件并初始化种子数据:

TodoListDbContextSeed.cs

using Microsoft.EntityFrameworkCore;using TodoList.Domain.Entities;using TodoList.Domain.Enums;using TodoList.Domain.ValueObjects;namespace TodoList.Infrastructure.Persistence;public static class TodoListDbContextSeed{    public static async Task SeedSampleDataAsync(TodoListDbContext context)    {        if (!context.TodoLists.Any())        {            var list = new Domain.Entities.TodoList            {                Title = "Shopping",                Colour = Colour.Blue            };            list.Items.Add(new TodoItem { Title = "Apples", Done = true, Priority = PriorityLevel.High});            list.Items.Add(new TodoItem { Title = "Milk", Done = true });            list.Items.Add(new TodoItem { Title = "Bread", Done = true });            list.Items.Add(new TodoItem { Title = "Toilet paper" });            list.Items.Add(new TodoItem { Title = "Pasta" });            list.Items.Add(new TodoItem { Title = "Tissues" });            list.Items.Add(new TodoItem { Title = "Tuna" });            list.Items.Add(new TodoItem { Title = "Water" });            context.TodoLists.Add(list);            await context.SaveChangesAsync();        }    }    public static async Task UpdateSampleDataAsync(TodoListDbContext context)    {        var sampleTodoList = await context.TodoLists.FirstOrDefaultAsync();        if (sampleTodoList == null)        {            return;        }        sampleTodoList.Title = "Shopping - modified";        // 演示更新时审计字段的变化        context.Update(sampleTodoList);        await context.SaveChangesAsync();    }}

在应用程序初始化的扩展中进行初始化和更新:

ApplicationStartupExtensions.cs

// 省略以上...try{    var context = services.GetRequiredService<TodoListDbContext>();    context.Database.Migrate();    // 生成种子数据    TodoListDbContextSeed.SeedSampleDataAsync(context).Wait();    // 更新部分种子数据以便查看审计字段    TodoListDbContextSeed.UpdateSampleDataAsync(context).Wait();}catch (Exception ex)// 省略以下...

运行Api项目,得到下面的输出,中间我省略了一些SQL语句的输出:

$ dotnet run --project src/TodoList.Api

Building...

# ...省略

[14:06:24 INF] Applying migration '20211222060615_AddEntities'.

# ...省略

INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])

VALUES (N'20211222060615_AddEntities', N'6.0.1');

# ...省略,注意下面的三个domain event,因为我们在造种子数据的时候有设置三个TodoItem标记为已完成,将会触发event。

[14:06:25 INF] Publishing domain event. Event - TodoItemCompletedEvent

[14:06:25 INF] Publishing domain event. Event - TodoItemCompletedEvent

[14:06:25 INF] Publishing domain event. Event - TodoItemCom一个通用的Repository模式。

参考资料

Domain Driven DesignDDD领域驱动设计基本理论知识总结