1. 程式人生 > 程式設計 >使用.NET 6開發TodoList應用之領域實體建立原理和思路

使用.NET 6開發TodoList應用之領域實體建立原理和思路

需求

上一篇文章中我們完成了資料儲存服務的接入,從這一篇開始將正式進入業務邏輯部分的開發。

首先要定義和解決的問題是,根據TodoList專案的需求,我們應該設計怎樣的資料實體,如何去進行操作?

長文預警!包含大量程式碼

目標

在本文中,我們希望達到以下幾個目標:

  • 定義領域實體;
  • 通過操作領域實體;

原理和思路

雖然www.cppcns.comTodoList是一個很簡單的應用,業務邏輯並不複雜,至少在這個系列文章中我並不想使其過度複雜。但是我還是打算藉此簡單地涉及領域驅動開發(DDD)的基礎概念。

首先比較明確的是,我們的實體物件應該有兩個:TodoListTodoItem

,並且一個TodoList是由多個TodoItem的列表構成,除此以外在實際的開發中,我們可能還需要追蹤實體的變更情況,比如需要知道建立時間/修改時間/建立者/修改者,這種需求一般作為審計要求出現,而對實體的審計又是一個比較通用的需求。所以我們會將實體分成兩部分:和業務需求直接相關的屬性,以及和實體審計需求相關的屬性。

其次,對於實體的資料庫配置,有兩種方式:通過Attribute或者通過IEntityTypeConfiguration<T>以程式碼的方式進行。我推薦使用第二種方式,將所有的具體配置集中到Infrastructure層去管理,避免後續修改欄位屬性而去頻繁修改位於Domain

層的實體物件定義,我們希望實體定義本身是穩定的。

最後,對於DDD來說有一些核心概念諸如領域事件,值物件,聚合根等等,我們都會在定義領域實體的時候有所涉及,但是目前還不會過多地使用。關於這些基本概念的含義,請參考這篇文章:淺談開發架構之領域驅動設計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,gPIYXzValueObject 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,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();

        buildgPIYXzer.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.csproj
Build 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 - TodoItemCompletedEvent

# ...省略

[14:06:25 INF] Now listening on: https://localhost:7039

[14:06:25 INF] Now listening on: http://localhost:5050

[14:06:25 INF] Application started. Press Ctrl+C to shut down.

[14:06:25 INF] Hosting environment: Development

# ...省略

我們再去看看資料庫中的資料:

TodoLists資料表:

使用.NET6開發TodoList應用之領域實體建立原理和思路

TodoItems資料表

使用.NET6開發TodoList應用之領域實體建立原理和思路

__EFMigrationsHistory遷移表:

使用.NET6開發TodoList應用之領域實體建立原理和思路

總結

在本文中,我們著手搭建了基本的領域驅動設計對應的Domain層實現,包括兩個領域實體物件及其關聯的其他知識。最後通過種子資料的方式進行資料庫資料操作的驗證,下一篇我們將繼續實現一個通用的Repository模式。

參考資料

Domain Driven DesignDDD領域驅動設計基本理論知識總結

到此這篇關於使用.NET6開發TodoList應用之領域實體建立原理和思路的文章就介紹到這了,更多相關.NET6開發TodoList應用內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!