本系列专栏基于杨中科老师的《ASP.NET Core技术内幕与项目实战》,本人记录梳理的学习笔记,有部分的增补和省略。更全面系统的讲解,请看杨老师的视频课:【.NET教程,.Net Core视频教程,杨中科主讲】。
一、实现充血模型
充血模型 = 属性 + 业务方法 + 数据校验 + 私有状态
拒绝只有 getter/setter 的贫血类,把数据和行为绑定,这是 DDD 最基础的落地。
1.充血模型 User 实体
cs
// 辅助类:密码哈希(实际项目用 BCrypt/PBKDF2)
public static class HashHelper
{
public static string Hash(string password)
=> Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(password));
}
// 充血模型实体
public record User
{
// 特征:标识符不可变(init)
public int Id { get; init; }
public DateTime CreatedDateTime { get; init; }
// 特征:公开属性私有 set,外部只能通过方法修改
public string UserName { get; private set; }
public int Credit { get; private set; }
// 特征:私有字段(不对外暴露,仅内部操作)
private string? passwordHash;
private string? remark;
// 特征:只读属性(只提供get,不允许外部修改)
public string? Remark
{
get { return remark; }
}
// 特征:临时属性(不存数据库,仅内存使用)
public string? Tag { get; set; }
// 特征:私有无参构造(仅EF Core使用,禁止外部直接new)
private User() { }
// 特征:公共构造方法(强制业务规则,创建必须传用户名)
public User(string yhm)
{
this.UserName = yhm;
this.CreatedDateTime = DateTime.Now;
this.Credit = 10; // 初始积分
}
// 业务方法:修改用户名
public void ChangeUserName(string newValue)
{
this.UserName = newValue;
}
// 业务方法:修改密码(自带校验!充血模型核心)
public void ChangePassword(string newValue)
{
// 业务规则:密码长度不能小于6
if (newValue.Length < 6)
{
throw new ArgumentException("密码长度不能小于6位");
}
this.passwordHash = HashHelper.Hash(newValue);
}
}
2.EF Core 配置
映射私有字段、忽略临时属性
cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class UserConfig : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
// 映射私有字段 passwordHash
builder.Property("passwordHash");
// 映射私有字段 remark 到 Remark 属性
builder.Property(u => u.Remark).HasField("remark");
// 忽略临时属性 Tag(不存入数据库)
builder.Ignore(u => u.Tag);
// 常规配置
builder.Property(u => u.UserName).IsRequired().HasMaxLength(50);
}
}
3.使用方式
强制走业务方法,保证数据安全
cs
// 1. 创建用户
User u1 = new User("Zack");
u1.Tag = "MyTag"; // 临时属性,不入库
u1.ChangePassword("123456"); // 必须调用方法修改
ctx.Users.Add(u1);
ctx.SaveChanges();
// 2. 查询用户
User u1 = ctx.Users.First(u => u.UserName == "Zack");
Console.WriteLine(u1);
二、实现值对象
值对象 = 无 ID + 不可变 + 数据校验 + 依附实体存在
在 EF Core 中使用 Owned Entity Types(从属实体) 实现。
1.地理坐标值对象
cs
// 不可变值对象(record 天然不可变)
public record Geo
{
public double Longitude { get; init; }
public double Latitude { get; init; }
// 构造时强制校验业务规则
public Geo(double longitude, double latitude)
{
if (longitude < -180 || longitude > 180)
throw new ArgumentException("经度格式错误");
if (latitude < -90 || latitude > 90)
throw new ArgumentException("纬度格式错误");
this.Longitude = longitude;
this.Latitude = latitude;
}
}
2.多语言字符串值对象
cs
public record MultilingualString(string Chinese, string? English);
3.EF Core 配置
OwnsOne
cs
// 假设 Shop 实体使用 Name 值对象
public class Shop
{
public int Id { get; set; }
public MultilingualString Name { get; set; } = null!;
public Geo Location { get; set; } = null!;
}
// 配置
public class ShopConfig : IEntityTypeConfiguration<Shop>
{
public void Configure(EntityTypeBuilder<Shop> builder)
{
// 配置值对象
builder.OwnsOne(c => c.Name, nb =>
{
nb.Property(e => e.English).HasMaxLength(20).IsUnicode(false);
nb.Property(e => e.Chinese).HasMaxLength(20).IsUnicode(true);
});
builder.OwnsOne(s => s.Location);
}
}
4.枚举转字符串存储
提升可读性
cs
public enum Currency
{
CNY, USD, NZD
}
// 配置:枚举存字符串,而不是数字
builder.Property(e => e.Currency)
.HasConversion<string>() // 核心
.HasMaxLength(10);
三、聚合与聚合根实现
聚合 = 一组紧密关联的实体 + 值对象 聚合根 = 聚合唯一入口,外部只能访问根
1.定义聚合根标识接口
cs
/// <summary>
/// 聚合根标记接口
/// </summary>
public interface IAggregateRoot
{
}
2.实现聚合根
cs
// Order 是聚合根,实现 IAggregateRoot
public class Order : IAggregateRoot
{
public int Id { get; set; }
public DateTime CreateTime { get; set; }
// 包含值对象
public Address ShippingAddress { get; set; } = null!;
// 包含子实体(非聚合根,外部禁止直接访问)
private List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
// 只能通过根添加明细(高内聚)
public void AddItem(int productId, int count)
{
_items.Add(new OrderItem(productId, count));
}
}
// 子实体:非聚合根,外部不能直接操作
public class OrderItem
{
public int Id { get; set; }
public int ProductId { get; set; }
public int Count { get; set; }
// 私有构造,只能由 Order 调用
internal OrderItem(int productId, int count)
{
ProductId = productId;
Count = count;
}
}
3.EF Core 配置规范
- 只给聚合根声明 DbSet
- 子实体 / 值对象通过根访问
- 跨聚合只引用 ID,不引用对象
cs
public class AppDbContext : DbContext
{
// 只暴露聚合根
public DbSet<User> Users => Set<User>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
}
拓展:多个聚合根放一个上下文还是多个?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 一个上下文 | 简单、事务方便、配置少 | 庞大、职责不清 |
| 多个上下文(边界上下文) | 职责清晰、微服务友好 | 跨上下文事务复杂 |
建议 :一个微服务 = 一个界限上下文 = 一个 DbContext ,里面可以包含多个聚合根。
四、领域事件实现
领域事件:同一服务内的业务触发通知 最佳实践:延迟发布(SaveChanges 时发布),避免 "未入库就发事件" 的误报。
1.核心思路
- 实体只注册事件,不立即发布
- 在
SaveChangesAsync中统一发布 - 发布后清空事件列表
2.实现步骤
第一步:定义领域事件基础接口
cs
public interface IDomainEvents
{
IEnumerable<INotification> GetDomainEvents();
void AddDomainEvent(INotification eventItem);
void ClearDomainEvents();
}
第二步:实体基类
cs
public abstract class BaseEntity : IDomainEvents
{
private List<INotification> _domainEvents = new();
public IEnumerable<INotification> GetDomainEvents() => _domainEvents.AsReadOnly();
public void AddDomainEvent(INotification eventItem)
{
_domainEvents.Add(eventItem);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
第三步:重写 DbContext 实现自动发布
cs
public class AppDbContext : DbContext
{
private readonly IMediator _mediator;
// 注入 MediatR
public AppDbContext(DbContextOptions<AppDbContext> options, IMediator mediator)
: base(options)
{
_mediator = mediator;
}
// 重写 SaveChanges 发布事件
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
// 1. 找到所有注册了领域事件的实体
var domainEntities = ChangeTracker.Entries<IDomainEvents>()
.Where(e => e.Entity.GetDomainEvents().Any()).ToList();
// 2. 取出所有事件
var domainEvents = domainEntities.SelectMany(e => e.Entity.GetDomainEvents()).ToList();
// 3. 清空实体事件
domainEntities.ForEach(e => e.Entity.ClearDomainEvents());
// 4. 保存数据
int result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
// 5. 发布事件(确保数据入库后再发,避免误报)
foreach (var evt in domainEvents)
{
await _mediator.Publish(evt);
}
return result;
}
}
第四步:定义领域事件
cs
// 用户创建事件
public record UserCreatedDomainEvent(User User) : INotification;
第五步:在实体中注册事件
cs
public class User : BaseEntity // 继承基类
{
public User(string userName)
{
UserName = userName;
// 注册事件,不立即发布
AddDomainEvent(new UserCreatedDomainEvent(this));
}
}
第六步:编写事件处理器
cs
public class UserCreatedEventHandler : INotificationHandler<UserCreatedDomainEvent>
{
public async Task Handle(UserCreatedDomainEvent notification, CancellationToken cancellationToken)
{
// 执行业务:发送欢迎短信、增加积分、日志记录
Console.WriteLine($"新用户创建:{notification.User.UserName}");
await Task.CompletedTask;
}
}
注册 MediatR(Program.cs)
cs
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});
总结
- **充血模型:**私有 set、私有字段、业务方法、数据校验;EF 映射私有字段、忽略临时属性。
- 值对象: 使用
record保证不可变;构造函数校验;EFOwnsOne配置。 - 聚合与聚合根: 标记接口
IAggregateRoot;只暴露聚合根DbSet;跨聚合只引用 ID。 - 领域事件: 实体注册事件;
SaveChanges统一发布;MediatR 处理;杜绝 "未入库就发事件" BUG。