

使用.NET 6开发TodoList应用(5)——领域实体创建
source link: https://www.cnblogs.com/code4nothing/p/build-todolist-5.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

需求#
上一篇文章中我们完成了数据存储服务的接入,从这一篇开始将正式进入业务逻辑部分的开发。
首先要定义和解决的问题是,根据TodoList
项目的需求,我们应该设计怎样的数据实体,如何去进行操作?
长文预警!包含大量代码
目标#
在本文中,我们希望达到以下几个目标:
- 定义领域实体;
- 通过数据库操作领域实体;
原理和思路#
虽然TodoList
是一个很简单的应用,业务逻辑并不复杂,至少在这个系列文章中我并不想使其过度复杂。但是我还是打算借此简单地涉及领域驱动开发(DDD)的基础概念。
首先比较明确的是,我们的实体对象应该有两个:TodoList
和TodoItem
,并且一个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;
}
IHasDomainEvent.cs
namespace TodoList.Domain.Base.Interfaces;
public interface IHasDomainEvent
{
public List<DomainEvent> DomainEvents { get; set; }
}
我们还剩下Aggregate Root
, ValueObject
和Domain Service
以及Domain Exception
,其他的相关概念暂时就不涉及了。
IAggregateRoot.cs
namespace TodoList.Domain.Base.Interfaces;
// 聚合根对象仅仅作为标记来使用
public interface IAggregateRoot { }
ValueObject
的实现有几乎固定的写法,请参考:https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/microservice-ddd-cqrs-patterns/implement-value-objects
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
/Exceptions
和Enums
三个文件夹用于存放对应的内容。但是这样可能会导致项目的目录层级太多,实际上在这里,我更倾向于在Domain
项目的根目录下创建Entities
/Events
/Enums
/Exceptions
/ValueObjects
文件夹来扁平化领域模型,对于世纪的开发查找起来也并不麻烦。所以才去后一种方式,然后在Entities
中创建TodoItem
和TodoList
实体:
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.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
数据表:
TodoItems
数据表
__EFMigrationsHistory
迁移表:
总结#
在本文中,我们着手搭建了基本的领域驱动设计对应的Domain
层实现,包括两个领域实体对象及其关联的其他知识。最后通过种子数据的方式进行数据库数据操作的验证,下一篇我们将继续实现一个通用的Repository
模式。
参考资料#
系列导航#
- 使用.NET 6开发TodoList应用(1)——系列背景
- 使用.NET 6开发TodoList应用(2)——项目结构搭建
- 使用.NET 6开发TodoList应用(3)——引入第三方日志
- 使用.NET 6开发TodoList应用(4)——引入数据存储
- 使用.NET 6开发TodoList应用(5)——领域实体创建
- 使用.NET 6开发TodoList应用(5.1)——实现Repository模式
- 使用.NET 6开发TodoList应用(6)——实现POST请求
- 使用.NET 6开发TodoList应用(6.1)——实现CQRS模式
- 使用.NET 6开发TodoList应用(6.2)——实现AutoMapper
- 使用.NET 6开发TodoList应用(7)——实现GET请求
- 使用.NET 6开发TodoList应用(8)——实现全局异常处理
- 使用.NET 6开发TodoList应用(9)——实现PUT请求
- 使用.NET 6开发TodoList应用(10)——实现PATCH请求
- 使用.NET 6开发TodoList应用(11)——HTTP请求幂等性的考虑
- 使用.NET 6开发TodoList应用(12)——实现接口请求验证
- 使用.NET 6开发TodoList应用(13)——实现ActionFilter
- 使用.NET 6开发TodoList应用(14)——实现查询分页
- 使用.NET 6开发TodoList应用(15)——实现查询过滤
- 使用.NET 6开发TodoList应用(16)——实现查询搜索
- 使用.NET 6开发TodoList应用(17)——实现查询排序
- 使用.NET 6开发TodoList应用(18)——实现数据塑形
- 使用.NET 6开发TodoList应用(19)——实现HATEAOS支持
- 使用.NET 6开发TodoList应用(20)——处理OPTION和HEAD请求
- 使用.NET 6开发TodoList应用(21)——实现Root Document
- 使用.NET 6开发TodoList应用(22)——实现API版本控制
- 使用.NET 6开发TodoList应用(23)——实现缓存
- 使用.NET 6开发TodoList应用(24)——实现请求限流和阈值控制
- 使用.NET 6开发TodoList应用(25)——实现基于JWT的Identity功能
- 使用.NET 6开发TodoList应用(26)——实现RefreshToken
- 使用.NET 6开发TodoList应用(27)——实现Configuration和Option的强类型绑定
- 使用.NET 6开发TodoList应用(28)——实现API的Swagger文档化
- 使用.NET 6开发TodoList应用(29)——实现应用程序健康检查
- 使用.NET 6开发TodoList应用(30)——实现本地化功能
- 使用.NET 6开发TodoList应用(31)——实现Docker打包和部署
- 使用.NET 6开发TodoList应用(32)——实现基于Github Actions和ACI的CI/CD
Recommend
-
61
首先在这里给大家拜个晚年,祝大家新年愉快,编程功力节节高升~~ 我自己有制定年度规划的习惯,之前也一直在用OneNote在做。OneNote虽然好用,可是没有相关统计功能。这样就不容易把握计划制定的是否合理。于是就趁着放假,顺手做一个简单的ToDoList小程
-
35
开始 安装react脚手架并初始化项目 {代码...} 此时项目已经运行在 :localhost:3000 安装 electron electron 7.0.0 实在太坑爹了 使用6.1.2没有问题。 {代码...} 新建main.js {代码...} 启动项目 在package.json文件中添加: {代码...} 然后执行: {代码...} 看到...
-
4
使用Spring Data JPA在更改实体时发布DDD领域事件从 Spring Data JPA 1.11(Ingalls 版本)开始,您可以在保存实体对象时自动发布域事件。您只需要向实体类添加一个方法,该方法返回要发布的事件对象的 集合 ,并使用@DomainEvents注释该方法 。Spring Data JPA...
-
4
需求# 在我们项目开发的过程中,使用.NET 6自带的日志系统有时是不能满足实际需求的,比如有的时候我们需要将日志输出到第三方平台上,最典型的应用...
-
6
系列导航及源代码#
-
2
系列导航及源代码#
-
7
系列导航及源代码#
-
7
文章正文: 前面的四个章节我们主要讲解了MongoDB的相关基础知识,接下来我们就开始进入使用.NET7操作MongoDB开发一个ToDoList系统实战教程。本章节主要介绍的是如何快熟搭建一个简单明了的后端项目框架。 MongoDB从入门到实战的相关教程...
-
4
文章正文: Swagger是什么? Swagger是一个规范且完整API文档管理框架,可以用于生成、描述和调用可视化的RESTful风格的 Web 服务。Swagger 的目标是对 REST API 定义一个标准且和语言无关的接口,可以让人和计算机拥有无须访问源码、文...
-
3
文章正文: 前几章教程我们把ToDoList系统的基本框架搭建好了,现在我们需要根据我们的需求把ToDoList系统所需要的系统集合(相当于关系型数据库中的数据库表)。接下来我们先简单概述一下这个系统主要需要实现的功能以及实现这些功能我们需要设...
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK