C# Web开发教程(十四)DDD介绍

DDD(Domain-Driven Design)领域(模型)驱动设计

  • 是一套软件开发的方法论和思想
  • 核心思想: *软件的结构和代码应该反映出真实的业务领域,并且开发人员应该和业务专家(比如产品经理、领域专家)使用同一种“语言”来交流。

通俗理解(方言来打比方):

  • 讲闽南语的同事:可能代表前端开发,他们关心的是页面组件、用户交互。
  • 讲粤语的同事:可能代表后端开发,他们关心的是 API 接口、数据库性能。
  • 讲客家话的产品经理:代表业务专家,他们关心的是用户故事、业务流程、商业价值。
  • 运维、测试等成员:也都有自己领域的“方言”。

在项目初期,如果没有“统一语言”(普通话),就会出现这样的对话:

  • 产品经理(客家话):“用户点击‘立即购买’,就给他‘生成一个单子’。”
  • 后端开发(粤语):“哦,就是在 order 表里 insert 一条记录,把 cart 里的 itemsinsertorder_detail 里。”
  • 前端开发(闽南语):“明白,我这边点按钮就跳转到那个 confirm.html 页面,把 itemList 传过去。”

问题来了:

  1. 歧义:产品经理说的“单子”可能包含订单、支付单等多种含义,但开发人员直接理解为了数据库的 order 表。
  2. 知识错位:业务逻辑(如“下单前必须校验库存”)可能被分散在前端、后端、甚至数据库的触发器中,没有人对“下单”这个完整的业务概念负责。
  3. 沟通成本高:每次开会都像是在做翻译,还容易出错。

引入 DDD 和“普通话”之后

团队坐下来,共同定义一套统一语言

“在我们的系统里,用户从‘购物车’里点击‘立即购买’,这个行为叫做 提交订单
一个 订单 是一个重要的业务对象,它包含 订单号状态订单总额 以及多个 订单项
提交订单 这个动作必须确保:1. 所有 订单项 对应的 商品 库存充足;2. 从 购物车 中移出已下单的商品。”

现在,所有人都用这套“普通话”来交流:

  • 产品经理在提需求时,会说:“我们需要在‘提交订单’前,增加一个‘使用优惠券’的步骤。”
  • 后端开发在设计 API 时,会创建一个 OrdersController,里面有一个 SubmitOrder 的方法。
  • 前端开发在写代码时,会调用 submitOrder 这个 API。
  • 测试人员写的测试用例标题是:“测试用户‘提交订单’时,若库存不足,应提示‘库存不足’。”

带来的好处:

  • 沟通无障碍:所有人对核心业务概念的理解是完全一致的。
  • 代码即设计:代码中的类名(如 Order)、方法名(如 Submit)直接反映了业务语言,代码本身就成了最好的文档。
  • 边界清晰:大家明确了“订单”和“购物车”、“库存”是不同的上下文,为后续拆分成微服务打下了坚实基础。

总结一下:

DDD 的“统一语言”就像是在一个多方言的团队里推行 “业务普通话” 。它不是为了消灭方言(各角色的专业技能),而是为了确保在讨论业务核心时,所有人都在同一个频道上,说的是同一件事,从而极大地降低沟通成本,确保软件实现能够精准地匹配业务意图。

领域(即一个组织的业务范围)

  • 通俗理解: 就像手机公司的所有业务活动构成了它的"领域"。

  • 领域的划分(以手机公司为例)

    • 核心域(Core Domain): 公司的核心竞争力,最核心的价值所在,.例如研发中心
    • 支撑域(supporting Subdomain): 支持核心业务运行的必要功能.例如业务,销售,运维
    • 通用域(Generic Subdomain): 通用性很强,可以直接使用现成方案.例如保安,前台

软件资源投入策略:

  • 核心域:投入最优秀的开发人员,深度定制开发
  • 支撑域:投入适量资源,适度定制
  • 通用域:尽量使用开源方案或购买云服务
  • 电商系统为例
核心域:
  ✅ 商品推荐算法(自研)
  ✅ 交易风控系统(自研)
  ✅ 会员成长体系(自研)

支撑域:
  ⚡ 订单管理系统(基于开源框架定制)
  ⚡ 客服工单系统(基于开源框架定制)

通用域:
  🔧 用户登录认证(使用Auth0或Keycloak)
  🔧 文件存储服务(使用AWS S3或阿里云OSS)
  🔧 短信发送服务(使用云通信服务)

领域模型(Domain Model)

  • 对于领域内的对象进行建模,从而抽象出来模型
    • 项目的开始,应该始于创建领域模型,而不是考虑如何设计数据库并编写代码
    • 以银行为例

什么是领域模型?

领域模型是对业务领域中的概念、规则和流程的抽象表示,它关注的是"业务是什么"和"业务如何运作",而不是"技术如何实现"。


核心思想:业务优先,技术其次(技术是服务于业务的!)

传统错误做法:

需求 → 直接设计数据库表 → 开始写代码

DDD的正确做法:

深入理解业务 → 创建领域模型 → 根据模型设计代码和数据库

以银行为例详细说明

第一步:理解银行业务概念

通过与银行业务专家交流,我们识别出核心概念:

业务概念:

  • 账户 - 有账户号、余额、账户类型
  • 客户 - 银行客户,拥有账户
  • 交易 - 存款、取款、转账等操作
  • 余额 - 账户中的金额
  • 利息 - 存款产生的收益

第二步:识别业务规则

业务规则:

  • 取款金额不能超过账户余额
  • 转账需要双方账户都存在
  • 计算利息有特定公式
  • 大额交易需要风控审核

第三步:创建领域模型(抽象建模)

领域模型示例:

账户 (Account)
├── 账户号 (AccountNumber)
├── 余额 (Balance)
├── 账户类型 (AccountType)
└── 持有者 (Customer)

交易 (Transaction)
├── 交易类型 (TransactionType)
├── 金额 (Amount)
├── 时间戳 (Timestamp)
├── 源账户 (FromAccount)
└── 目标账户 (ToAccount)

业务规则:
- Account.Withdraw(amount): 必须 amount ≤ Balance
- Account.Transfer(toAccount, amount): 必须双方账户有效

第四步:对比两种开发思路

❌ 数据库优先的错误思路:

-- 一开始就设计表结构
CREATE TABLE Accounts (
    Id INT PRIMARY KEY,
    Balance DECIMAL,
    -- ... 其他字段
);

CREATE TABLE Transactions (
    Id INT PRIMARY KEY,
    AccountId INT,
    Amount DECIMAL,
    -- ... 其他字段
);

问题:数据库表结构限制了业务思维的展开

✅ 领域模型优先的正确思路:

// 先定义业务概念和规则
public class Account 
{
    public AccountNumber Number { get; }
    public Balance Balance { get; private set; }
    public Customer Owner { get; }
    
    // 业务方法
    public void Withdraw(Money amount) 
    {
        if (amount > Balance) 
            throw new InsufficientFundsException();
        Balance = Balance - amount;
        AddTransaction(TransactionType.Withdraw, amount);
    }
    
    public void Transfer(Account targetAccount, Money amount) 
    {
        // 业务规则验证
        if (targetAccount == null) 
            throw new InvalidAccountException();
        if (amount > Balance) 
            throw new InsufficientFundsException();
            
        // 执行转账业务逻辑
        this.Withdraw(amount);
        targetAccount.Deposit(amount);
    }
}

为什么领域模型如此重要?

1. 准确表达业务意图

// 好的领域模型让代码读起来像业务文档
account.Transfer(toAccount, amount);  // 清晰表达"转账"业务

// vs 技术实现的模糊表达
transactionService.ExecuteTransfer(accountId, targetId, amount);

2. 保护业务规则

// 业务规则内聚在领域模型中
public void Withdraw(Money amount)
{
    // 取款不能超过余额 ← 业务规则
    if (amount > Balance)
        throw new InsufficientFundsException();
        
    // 大额交易需要审核 ← 业务规则  
    if (amount > DailyLimit)
        RequireManagerApproval();
        
    Balance = Balance - amount;
}

3. 适应业务变化

当银行推出新业务时:

  • 数据库优先:需要修改表结构,影响很大
  • 领域模型优先:只需在模型中添加新的业务概念,技术影响最小化

实际开发中的实践

创建领域模型的步骤:

  1. 事件风暴工作坊:与业务专家一起梳理业务流程
  2. 识别核心概念:找出实体、值对象、聚合根
  3. 定义统一语言:确保团队对业务术语理解一致
  4. 绘制模型图:可视化业务关系和流程
  5. 编码实现:最后才将模型转化为代码

银行领域的模型示例:

聚合根:账户(Account)
   ├── 实体:交易记录(Transaction)
   ├── 值对象:余额(Balance)
   ├── 值对象:账户号(AccountNumber)
   └── 领域服务:利息计算器(InterestCalculator)

领域事件:
   ├── 账户开户成功(AccountOpenedEvent)
   ├── 取款成功(WithdrawalSucceededEvent) 
   └── 转账完成(TransferCompletedEvent)

总结

领域模型的核心价值在于它让我们的软件设计围绕业务概念展开,而不是围绕技术实现展开。就像建筑师的蓝图先考虑"这个建筑要满足什么功能",而不是先考虑"要用什么型号的钢筋水泥"。

在银行例子中,我们关注的是"账户如何管理资金"、"交易如何安全进行"这些业务本质,而不是"数据库表如何关联"、"API接口如何设计"这些技术细节。这样做出来的系统才能真正满足业务需求,并且易于维护和演化。

事务脚本的缺陷

  • 使用技术人员的语言去描述和实现业务事务。没有太多设计,没有考虑可扩展性、可维护性,流水账地编写代码。
  • 缺点很明显,如果后期有新增功能,就是一大堆的if...else,屎山一样的堆积代码...
public string Withdraw(string account, double amount)
{
    // 原有代码...
    if (!this.User.HasPermission(Withdraw)) return "当前柜员没有取钱权限";
    double? balance = Query($"select Balance from Accounts where Number={account}");
    if(balance == null) return "账号不存在";
    if(balance < amount) return "账号余额不足";
    
    // 🚨 新增需求开始堆积...
    double fee = 0;
    
    // 新增:手续费逻辑
    if (amount > 5000) {
        fee = 10; // 大额交易手续费
    }
    
    // 新增:VIP客户免手续费
    bool isVip = Query($"select IsVip from Customers where Account={account}");
    if (isVip) {
        fee = 0;
    }
    
    // 新增:夜间交易审核
    if (DateTime.Now.Hour > 22 || DateTime.Now.Hour < 6) {
        if (amount > 1000) {
            if (!this.User.HasPermission(NightTransaction)) {
                return "夜间大额交易需要特殊权限";
            }
        }
    }
    
    // 原有更新逻辑现在变得更复杂
    double totalDeduction = amount + fee;
    if (balance < totalDeduction) return "余额不足(含手续费)";
    
    Query($"Update Accounts set Balance=Balance-{totalDeduction} where Number={account}");
    
    // 新增:记录手续费
    if (fee > 0) {
        Query($"INSERT INTO Fees (Account, Amount, Type) VALUES ({account}, {fee}, 'Withdrawal')");
    }
    
    return "ok";
}
// 领域重构

// 领域模型:账户
public class Account 
{
    public string Number { get; }
    public decimal Balance { get; private set; }
    public bool IsVip { get; }
    
    // 业务方法:取款
    public void Withdraw(Money amount, FeeCalculationStrategy feeStrategy)
    {
        // 计算手续费(业务规则内聚在领域对象中)
        Money fee = feeStrategy.CalculateFee(this, amount);
        Money totalAmount = amount + fee;
        
        // 余额检查(业务规则)
        if (this.Balance < totalAmount) 
            throw new InsufficientBalanceException();
            
        // 执行取款
        this.Balance -= totalAmount;
        
        // 发布领域事件,而不是直接写数据库
        this.AddDomainEvent(new WithdrawalCompletedEvent(this.Number, amount, fee));
    }
}

// 应用服务:协调领域对象完成业务用例
public class AccountApplicationService 
{
    public Result Withdraw(string accountNumber, decimal amount)
    {
        // 1. 获取领域对象
        var account = _accountRepository.Find(accountNumber);
        var user = _userRepository.Find(currentUserId);
        
        // 2. 业务规则验证
        if (!user.CanPerform(TransactionType.Withdraw))
            return Result.Fail("没有取款权限");
            
        // 3. 调用领域方法
        var feeStrategy = _feeStrategyFactory.Create(DateTime.Now);
        account.Withdraw(Money.FromDecimal(amount), feeStrategy);
        
        // 4. 保存变更
        _accountRepository.Save(account);
        
        return Result.Ok();
    }
}

误区说明

- 我可否把事务脚本理解为函数式编程,而领域模型其实就是一个类,当我需要增加新功能的时候,只需要新增方法即可,而不影响之前的逻辑,这样理解准确吗(部分准确,需要更精确的表述)

	- 事务脚本是过程式编程,不是函数式编程。
	- 领域模型是一组类的集合,而不仅仅是一个类。
	- 在领域模型中,我们通过设计来尽可能达到开闭原则,即通过扩展(而不是修改)来增加新功能。

实体(Entity)

  • 实体就是实体类(业务类)
    • 使用标识符来区分一个个实体,比如数据库中的ID字段
  • 值对象(value object): 没有标识符的对象,有多个属性,依附于某个实体对象而存在
    • 可以把值对象理解为实体对象的各种属性值
    • 例如商家的地理位置,衣服的尺寸,颜色等等

总结:

  • 实体:有身份(ID),可独立区分。
  • 值对象:无身份,描述实体的某个方面或属性组合。

Aggregate(聚合)

  • 概念:聚合是一组关系紧密的实体(和值对象)的集合,被当作一个整体来处理

  • 把关系紧密的实体放到一个聚合

    • 目的: 高内聚,低耦合,实现有关系的实体紧密协作,而关系很弱的实体被隔离
  • 聚合根(Aggregate Root): 每个聚合内都有一个实体作为聚合根

    • 唯一入口: 所有对聚合内部对象的操作(如查询、修改)都必须通过聚合根进行。外部对象不能直接访问聚合内的其他实体.
      • 举例:在一个“订单聚合”中,“订单”是聚合根,而“订单明细”是内部实体。你不能绕过“订单”直接去修改某个“订单明细”.
    • 管理者角色: 聚合根不仅仅是实体,也是所在聚合的管理者.
  • 聚合的判断标准: 实体是否是整体和部分的关系,是否存在相同的生命周期

    • 比如订单与订单明细: 属于同一聚合。明细随订单创建而创建,随订单删除而删除。
    • 用户与订单: 不属于同一聚合。订单可以独立于用户存在(例如用户删了,历史订单可能还要保留).
  • 聚合的划分原则: 尽量把聚合设计的小一点,只包含聚合根实体和密不可分的 实体

    • 实体只包含最小数量的属性
    • 好处: 小聚合有助于进行微服务的拆分(聚合宁愿设计的小一点,也不要太大)

总结:

  • 聚合强关联实体的集合,聚合根是其唯一访问入口
  • 判断聚合的关键是看实体之间是否是整体-部分关系生命周期一致
  • 设计时要倾向于小聚合,这有利于系统的可维护性、性能和微服务架构。

DDD之领域服务与应用服务

  • 引入场景: 聚合中的实体没有业务逻辑代码,只有对象的创建,初始化和状态管理等个体相关的代码

    • 对于聚合内的业务逻辑,我们编写领域服务(Domain Service)
      • 职责:处理单个聚合内部的复杂业务逻辑,包含核心业务规则,不直接与数据库等外部系统交互
    • 对于跨聚合协作以及聚合与外部系统协作,我们编写应用服务(Application Service)
      • 职责:协调者角色
      • 应用服务协调多个领域服务,和外部系统(数据库、消息队列、其他服务等)来完成一个用例.
  • DDD典型用例的处理流程

    • 准备业务操作所需要的数据
    • 执行由一个或者多个领域模型做出的业务操作,这些操作会修改实体的状态,或者生成一个操作结构
    • 把最终执行的结果,应用与外部系统
    1. 准备数据 → 2. 执行业务操作 → 3. 应用结果到外部系统
    

职责划分原则

重要规则

  • 领域模型外部系统不直接交互
    • 领域服务不涉及数据库操作
    • 领域服务不调用外部API

分工明确

  • 领域服务:包含业务逻辑
  • 应用服务:负责与外部系统交互

避免过度设计

  • 小项目(简单CRUD):可能不需要领域服务
  • 只有增删改查:应用服务直接完成所有操作即可

仓储(Repository)和工作单元(Unit Of Work)

  • 仓储: 负责数据库的读取和保存(把领悟服务修改的逻辑保存回数据库)
    • 将领域服务中的业务逻辑变更持久化到数据库
    • 特点: 提供类似集合的接口(Add,Remove,Get等)
  • 聚合内相关联的操作组成一个工作单元,要么一起成功,或者一起失败(事务的特性)
    • 例如银行的转账操作(扣款+存款)必须在一个事务中完成

.Net的贫血模型和充血模型

  • 贫血模型: 一个类中只有属性或者成员变量,没有方法

  • 充血模型: 一个类中,属性,成员变量和方法都有

  • 需求场景演示:

- 定义一个类保存用户的用户名、密码、积分;
	- 用户必须具有用户名;
	- 为了保证安全,密码采用密码的散列值保存;
	- 用户的初始积分为10分;
	- 每次登录成功奖励5个积分,每次登录失败扣3个积分。

  • 贫血模式(Anemic Model)
    • 类中只有属性/字段,没有业务方法
    • 业务逻辑分散在类外部
class User
{
	public string UserName { get; set; }//用户名
	public string PasswordHash { get; set; }//密码的散列值
	public int Credit { get; set; }//积分
}


User u1 = new User(); 
u1.UserName = "yzk"; 
u1.Credit = 10;
u1.PasswordHash = HashHelper.Hash("123456");//计算密码的散列值
string pwd = Console.ReadLine();
if(HashHelper.Hash(pwd)==u1.PasswordHash)
{
    u1.Credit += 5;//登录增加5个积分
    Console.WriteLine("登录成功");
}
Else
{
    if (u1.Credit < 3)
         Console.WriteLine("积分不足,无法扣减");
    else
    {
        u1.Credit -= 3;//登录失败,则扣3个积分
        Console.WriteLine("登录成功"); // 有逻辑错误
    }
    Console.WriteLine("登录失败");
}

❗ 贫血模型的问题

  1. 业务逻辑分散:登录验证、积分计算等业务规则散落在各处
  2. 容易出错:如示例中登录失败却显示"登录成功"
  3. 数据完整性无法保证:属性都是公开的,可被随意修改
  4. 违反封装原则:外部代码需要了解内部实现细节
  5. 重复代码:同样的业务逻辑可能在多个地方重复实现
  • 充血模型(Rich Model)
    • 类中包含属性+业务方法
    • 业务逻辑封装在类内部
    • 符合面向对象设计原则
class User
{
	public string UserName { get; init; }    // 只能在构造时设置
	public int Credit { get; private set; }  // 只能在类内部修改
	private string? passwordHash;	// 私有字段,外部无法直接访问
	
	public User(string userName)
	{
		this.UserName = userName;
		this.Credit =10; // 初始积分在构造时设置
	}
	
	// 业务方法:修改密码(包含验证逻辑)
	public void ChangePassword(string newValue)
	{
		if(newValue.Length<6)
		{
			throw new Exception("密码太短");
		}
		this.passwordHash =Hash(newValue);
	}
	
	 // 业务方法:验证密码
	public bool CheckPassword(string password)
	{
		string hash = HashHelper.Hash(password);
		return passwordHash== hash;
	}
	
	 // 业务方法:扣减积分(包含验证逻辑)
	public void DeductCredits(int delta)
	{
		if(delta<=0)
		{
			throw new Exception("额度不能为负值");
		}
		this.Credit -= delta;
	}
	
	 // 业务方法:增加积分
	public void AddCredits(int delta)
	{
		this.Credit += delta;
	}
}


User u1 = new User("yzk"); // 用户名在构造时就必须提供
u1.ChangePassword("123456"); // 密码修改包含业务规则验证
string pwd = Console.ReadLine();
if (u1.CheckPassword(pwd))
{
    u1.AddCredits(5);
    Console.WriteLine("登录成功");
}
else
{
    Console.WriteLine("登录失败");
}


✅ 充血模型的优点

  1. 高内聚:相关数据和操作封装在一起
  2. 强封装:内部状态受到保护,只能通过定义好的方法修改
  3. 业务规则集中:所有业务逻辑都在类内部,避免重复和错误
  4. 易于测试:可以单独测试每个业务方法
  5. 更好的可维护性:业务规则变化时只需修改一个地方
  6. 更符合面向对象设计:体现了"对象既有数据又有行为"的理念

🎯 核心区别总结

方面 贫血模型 充血模型
业务逻辑位置 分散在外部 封装在类内部
数据保护 属性公开,随意修改 属性受控,通过方法修改
代码复用 差,容易重复 好,逻辑集中
维护性 差,改动影响多处 好,改动局部化
面向对象 不符合 符合

推荐:在复杂的业务系统中,优先使用充血模型,它能更好地管理业务复杂度,保证数据一致性,并提高代码的可维护性。

充血模型实现的要求

  • 属性只读的,或者只能被类内部的代码修改

    • 实现: 把属性set定位为prvate或者init
      • 使用init访问器(C# 9.0引入)使属性只能在对象初始化时赋值。
      • 通过构造方法为这些属性赋予初始值
      • 目的:封装业务逻辑,防止外部随意修改对象状态
    ......
    
    namespace ConsoleApp1
    {
        public class Person
        {
       
            public long Id { get; init; } // 只读,只能在构造时赋值
            public string Name { get; private set; } // 通过类的内部方法来修改值(不允许直接修改)
    
            public void ChangeName(string name)
            {
                this.Name = name;
            }
    
           
        }
    }
    
    - 主程序使用:
    
    var p1 = new Person();
    // p1.Name = "Kate Green"; // 这句会报错: set不可访问
    p1.ChangeName("Jim Green");
    
  • 定义有参数的构造方法

    • 在EF Core中,若实体类没有无参的构造方法,则有参的构造方法中,参数的名字必须和属性的名字一致

      • 实现方式1: 无参数构造方法定义为private(EF Core通过反射可以调用私有构造方法)
      • 实现方式2: 实体类中不定位无参的构造方法,只定义有参的构造方法
        • 注意点: 要求构造方法中的参数名字,必须和属性的名字一致
      public class User
      {
          public string Username { get; init; }
          public int Credit { get; private set; }
          private string? passwordHash;
      
          public User(string yhm) // EF Core 会无法识别,应当写成Username
          {
              this.Username = yhm; // this.Username = Username
              this.Credit = 10;
          }
      }
      
  • 有的成员变量没有对应的属性,但是这些成员变量需要映射为数据表中的

    • 换句话说,我们需要把私有成员变量映射为数据表中的
    buidler.Property("成员变量名")
    
  • 有的属性只读

    • 属性值从数据库中取出来,但不能修改
      • EF Core提供backing field来支持这种功能
        • 在配置实体类的代码中,使用HasField(成员变量名)来配置属性
  • 有的属性不需要映射到数据列,仅在运行时被使用

    • 使用Ignore()来忽略这个属性

实例演示

// User.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Zack.Commons;

namespace ConsoleApp1
{
    public class User
    {
        public int Id { get; init; } // 只能在构造时赋值

        public DateTime CreatedDateTime { get; init; } // 只能在构造时赋值
        public string UserName { get; private set; }
        public int Credit { get; set; }
        private string? PasswordHash;
        public string? remark; // remark通过公有只读属性Remark暴露
        public string? Remark {
            get { return this.remark; }
        }
     
        public string? Tag { get; set; }

        private User() // 私有无参构造方法是为了满足EF Core的要求(EF Core在查询时会调用无参构造方法创建实例,然后设置属性)。
        {

        }

        public User(string yhm)
        {
            this.UserName = yhm;
            this.Credit = 10;
            this.CreatedDateTime = DateTime.Now;
        }

        public void ChangeUserName(string newValue)
        {
            this.UserName = newValue;
        }

        public void ChangePassword(string newValue)
        {
            if (newValue.Length < 6)
            {
                throw new ArgumentException("密码太短");
            }
            this.PasswordHash = HashHelper.ComputeMd5Hash(newValue);
        }


    }
}

/*
User 类特点:
- Id, CreatedDateTime:初始化后不可修改
- UserName:只能通过 ChangeUserName 方法修改
- PasswordHash:私有字段,通过 ChangePassword 方法修改(包含业务逻辑验证)
- Remark:只读属性,映射到私有字段 remark
- Tag:不持久化到数据库
*/


// UserConfig.cs(配置实体映射)

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    internal class UserConfig:IEntityTypeConfiguration<User>
    {
        public void Configure(EntityTypeBuilder<User> builder)
        {
            builder.Property("PasswordHash"); // 将私有字段PasswordHash映射到数据库列
            builder.Property(e => e.Remark).HasField("remark"); // 将Remark属性映射到私有字段remark
            builder.Ignore(e=>e.Tag); // Tag属性被标记为不需要映射到数据库。
        }
    }
}

// MyDbContext.cs

// MyDbContext.cs
using Microsoft.EntityFrameworkCore;
namespace ConsoleApp1
{
    public class MyDbContext : DbContext
    {
        public DbSet<Person> Persons { get; set; }
        public DbSet<User> Users { get; set; }
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
            // 添加 TrustServerCertificate=true 解决证书问题
            optionsBuilder.UseSqlServer("Server=.;Database=ddd1;Trusted_Connection=True;TrustServerCertificate=true;");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
        }
    }
}

// Program.cs

using ConsoleApp1;


using var ctx = new MyDbContext();

User user1 = new User("yzk");
user1.ChangePassword("123456");
ctx.Users.Add(user1);
ctx.SaveChanges();
Console.WriteLine("数据处理完成!");

  • 把主程序修改成这样,debug查看属性的赋值过程
var user1 = ctx.Users.First();
Console.WriteLine(user1.Remark);
Console.WriteLine("数据处理完成!");

重点关注private User(){} ,这个私有无参构造方法在 EF Core 中有很重要的作用:

主要作用

1. EF Core 实例化要求

EF Core 在从数据库查询数据时,需要通过反射创建实体类的实例。私有无参构造方法提供了这个入口:

// EF Core 内部会这样使用:
var user = (User)Activator.CreateInstance(typeof(User), nonPublic: true);

2. 保护领域完整性

public class User
{
    public string UserName { get; private set; }
    public int Credit { get; private set; }
    
    // 外部无法调用无参构造方法
    private User() { }
    
    // 必须通过这个有参构造方法创建,确保业务规则
    public User(string userName)
    {
        if (string.IsNullOrEmpty(userName))
            throw new ArgumentException("用户名不能为空");
            
        this.UserName = userName;
        this.Credit = 10; // 默认信用分
    }
}

具体场景分析

没有私有无参构造方法时:

// 如果只有有参构造方法
public User(string userName)
{
    this.UserName = userName;
}

// EF Core 查询时会报错:
// 无法创建 User 类型的实例,因为没有合适的构造方法

有私有无参构造方法时:

private User() { } // EF Core 可以用这个

public User(string userName) // 业务代码用这个
{
    this.UserName = userName;
}

实际工作流程

  1. EF Core 查询数据 → 使用私有无参构造方法创建实例
  2. EF Core 设置属性 → 通过反射直接设置属性值
  3. 业务代码创建对象 → 使用有参构造方法,执行业务验证
// EF Core 从数据库还原对象(绕过业务逻辑)
var userFromDb = context.Users.First(); // 使用 private User()

// 业务代码创建新对象(执行完整业务逻辑)
var newUser = new User("valid_username"); // 使用 public User(string)

设计考虑

为什么不用 public 无参构造方法?

// 这样不好:
public User() { } // 外部可以创建无效状态的对象

// 外部可能这样创建无效对象:
var invalidUser = new User(); // UserName 为 null,Credit 为 0

最佳实践

public class User
{
    // 必须的属性
    public string UserName { get; private set; }
    public int Credit { get; private set; }
    
    // 1. 为 EF Core 提供入口
    private User() { }
    
    // 2. 为业务代码提供受控的创建方式
    public User(string userName)
    {
        // 业务验证
        if (string.IsNullOrWhiteSpace(userName))
            throw new ArgumentException("用户名不能为空");
            
        this.UserName = userName.Trim();
        this.Credit = 10; // 设置合理的默认值
    }
}

总结

private User(){} 是一个技术妥协

  • 对 EF Core:提供实例化入口
  • 对业务代码:强制使用有业务验证的构造方法
  • 对领域模型:保护对象完整性,防止创建无效状态的对象

这是在使用 ORM 框架时实现充血模型的常见模式。

值类型需求

  • 问题背景
// 传统方式存在的问题
class Product
{
    double Weight; // 单位是什么?kg, g, 磅?
    double Price;  // 货币是什么?CNY, USD, EUR?
}
  • 隐式知识:数值类型常常隐含了单位信息

  • 维护困难:文档与代码容易不同步

  • 理解困难:新开发人员需要学习"隐含规则"

  • 解决方案:显式值类型(枚举类型的改进)

class Product
{
    Weight Weight;   // 明确的值类型
    Money Price;     // 明确的值类型
}

// 值类型定义
class Weight
{
    double Value;
    WeightUnit Unit; // 显式指定单位
}

enum WeightUnit { G, KG, Pound, Jin };

值类型的视线

  • “从属实体类型(owned entities)”:使用Fluent API中的OwnsOne等方法来配置。

  • 在EF Core中,实体的属性可以定义为枚举类型,枚举类型的属性在数据库中默认是以整数类型来保存的。

    • 对于直接操作数据库的人员来讲,0、1、2这样的值没有“CNY”(人民币)、“USD”(美元)、“NZD”(新西兰元)等这样的字符串类型值可读性更强

      - EF Core中可以在Fluent API中用HasConversion<string>()把枚举类型的值配置成保存为字符串
      
  • 实例演示

// Entity1.cs
......
namespace ConsoleApp1
{
    public class Entity1
    {
        public int Id { get; set; }
        public string Name { get; set; }

        public CurrencyName Currency { get; set; }
        //public string Currency { get; set; } // CNY,USD
        //public CurrencyName Currency { get; set; } // 0,1
    }
	
	// 默认:数据库存储为 0,1,2...
    public enum CurrencyName
    {
        CNY,USD,NZD
    }
}

// MyDbContext.cs(作数据库迁移并更新)
using Microsoft.EntityFrameworkCore;
namespace ConsoleApp1
{
    public class MyDbContext : DbContext
    {
        ......
        // 新增
        public DbSet<Entity1> Entity1s { get; set; }
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
            // 添加 TrustServerCertificate=true 解决证书问题
            optionsBuilder.UseSqlServer("Server=.;Database=ddd1;Trusted_Connection=True;TrustServerCertificate=true;");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
        }
    }
}

// Program.cs
using ConsoleApp1;
......
using var ctx = new MyDbContext();
var e1 = new Entity1() { Name = "Pen", Currency = CurrencyName.CNY };
var e2 = new Entity1() { Name = "Pencil", Currency = CurrencyName.USD };
ctx.Entity1s.Add(e1);
ctx.Entity1s.Add(e2);
ctx.SaveChanges(); // 数据库存放为int类型
Console.WriteLine("数据处理完成!");

foreach(var e in ctx.Entity1s)
{
    Console.WriteLine(e.Currency); // 返回CNY,USD
}
  • 这里若想修改Currency在数据库中的类型,可以这么做
// Entity1Config.cs
......
namespace ConsoleApp1
{
    internal class Entity1Config: IEntityTypeConfiguration<Entity1>
    {
        public void Configure(EntityTypeBuilder<Entity1> builder)
        {	
        	// 修改为string类型存储并设置最大长度
            builder.Property(e=>e.Currency).HasConversion<string>().HasMaxLength(5);
            
        }
    }
}

优势

  • 数据库可读性更强
  • 避免枚举值变更导致的数据混乱
  • 支持非开发人员直接查看数据库

复杂值类型:Owned Entity

// Geo.cs
......
namespace ConsoleApp1
{
    public record Geo
    {

        public double Latitude { get; init; }
        public double Longitude { get; init; }



        public Geo(long longitude,long latitude)
        {
            this.Longitude = longitude;
            this.Latitude = latitude;
        }

        public Geo() // 给EF Core调用的,必须要加,否则迁移数据库会报错
        {
        }
    }
}

// Shop.cs
......
namespace ConsoleApp1
{
    public class Shop
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public Geo Location { get; set; } // 值类型属性
    }
}

// ShopConfig.cs
......
namespace ConsoleApp1
{
    internal class ShopConfig: IEntityTypeConfiguration<Shop>
    {
        public void Configure(EntityTypeBuilder<Shop> builder)
        {
        	// 配置:告诉 EF Core 这是一个值类型
            builder.OwnsOne(x => x.Location);

        }
    }
}

// Program.cs
......
using var ctx = new MyDbContext();
var shopObj1 = new Shop() { Name = "yzk", Location = new Geo(3,7)};
ctx.Add(shopObj1);
ctx.SaveChanges();
Console.WriteLine("数据处理完成!");

- 数据库效果:
	- Id (int)
	- Name (nvarchar)
	- Location_Latitude (float) ← 自动生成的列
	- Location_Longitude (float) ← 自动生成的列

更复杂的值类型:多语言文本

// MultiLangString.cs
......

namespace ConsoleApp1
{
	// 中文和英文
    public record MultiLangString(string? Chinese,string? English);
}

// Blog.cs
......
namespace ConsoleApp1
{
    public class Blog
    {
        public int Id { get; set; }
        public MultiLangString Title { get; set; } // 多语言标题
        public MultiLangString Body { get; set; } // 多语言内容
    }
}

// BlogConfig.cs
......

namespace ConsoleApp1
{
    public class BlogConfig: IEntityTypeConfiguration<Blog>
    {
        public void Configure(EntityTypeBuilder<Blog> builder)
        {
            //builder.OwnsOne(e => e.Title);
            //builder.OwnsOne(e => e.Body);
			// 详细配置
            builder.OwnsOne(e => e.Title, nb =>
            {
                nb.Property(e => e.Chinese).HasMaxLength(255);
                nb.Property(e => e.English).HasColumnType("varchar(255)");
            });

            builder.OwnsOne(e => e.Body, nb =>
            {
                nb.Property(e => e.English).HasColumnType("varchar(MAX)");

            });

            builder.OwnsOne(e=>e.Body);

        }
    }
}

- 迁移数据库并更新,查看效果
  • 现在,插入几条记录,然后测试查询
// MyDbContext.cs
......
public DbSet<Blog> Blogs { get; set; }

// Program.cs
......
using var ctx = new MyDbContext();

var b1 = new Blog() { Title = new MultiLangString("你好1", "Hello1"),Body = new MultiLangString("正文1","body1")};
var b2 = new Blog() { Title = new MultiLangString("你好2", "Hello2"), Body = new MultiLangString("正文2", "body2") };
ctx.Add(b1);
ctx.Add(b2);
ctx.SaveChanges();
Console.WriteLine("数据处理完成!");
......


using var ctx = new MyDbContext();
//var b1 = ctx.Blogs.First(b => b.Title.Chinese == "你好1" && b.Body.Chinese=="正文1");
//var b1 = ctx.Blogs.First(b => b.Title.Chinese == "你好1");
// 这么传查询报异常
//var b1 = ctx.Blogs.First(b => b.Title == new MultiLangString("你好1","Hello1"));
// 这么传查询报异常
var b1 = ctx.Blogs.First(b => b.Title.Equals(new MultiLangString("你好1", "Hello1")));
Console.WriteLine(b1.Id);

  • 这里如果想实现优雅查询,可以使用第三方库Zack.Infrastructure
......
using var ctx = new MyDbContext();
var b1 = ctx.Blogs.First(ExpressionHelper.MakeEqual ((Blog b) => b.Title, new MultiLangString("你好1","Hello1")));
Console.WriteLine(b1.Id);

值类型特点

  • 不可变性(推荐)

public record Geo  // record 默认不可变
{
    public double Latitude { get; init; }  // init-only
    public double Longitude { get; init; }
}

总结

值类型是领域驱动设计中重要的概念,它帮助我们将隐式的领域知识显式化,提高代码的表达力和安全性。通过EF Core Owned Entity 特性,我们可以方便地将值类型映射到数据库,同时保持领域模型的纯净性。

聚合与聚合根的实现

  • 我们可以在context中只为聚合根实体声明DbSet类型的属性.
    • 对于非聚合根实体值对象的操作,都通过根实体进行
    • 如之前的Blog实体,就是根实体,我们通过操作Blog关联其他实体对象
  • 实例演示:商品--订单--订单详情
// Merchant.cs
......
namespace ConsoleApp1
{
    public class Merchant
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public double Price { get; set; }
    }
}

// Order.cs
......

namespace ConsoleApp1
{
    public class Order // 聚合根
    {
        public int Id { get; set; }
        public DateTime CreateDateTime { get; set; }
        public double TotalAmount { get; set; }
         // 聚合内部的实体,外部不能直接操作,只能通过Order访问
        public List<OrderDetail> Details { get; set; } = new List<OrderDetail>();
		  // 业务逻辑封装在聚合根中
        public void AddDetail(Merchant merchant,int count)
        {
            var detail = Details.SingleOrDefault(x => x.Merchant == merchant);
            if(detail == null)
            {
                detail = new OrderDetail() { Merchant=merchant,Count = count };
                Details.Add(detail);
            }
            else
            {
                detail.Count += count;
            }
        }
    }
}

// OrderDetail.cs
......
namespace ConsoleApp1
{
    public class OrderDetail // 聚合内部的实体
    {
        public Order Order { get; set; }
        public string Name { get; set; }
        public int OrderId { get; set; }
        public int Count { get; set; }
        public Merchant Merchant { get; set; }
    }
}


public class MyDbContext : DbContext
{
    // 只声明聚合根的DbSet
    public DbSet<Order> Orders { get; set; }
    public DbSet<Merchant> Merchants { get; set; }
    
    // 不声明OrderDetail的DbSet,只能通过Order访问
    // public DbSet<OrderDetail> OrderDetails { get; set; } // ❌ 不要这样做
    // 强制通过聚合根操作,保证聚合完整性,简化数据访问
}


// Program.cs 伪代码演示
......
// 正确:通过聚合根操作
var order = context.Orders.Find(orderId);
order.AddDetail(merchant, 2);  // 通过聚合根的方法
context.SaveChanges();

// 错误:直接操作内部实体
// var detail = new OrderDetail();  // ❌ 不应该这样做
// context.OrderDetails.Add(detail); // ❌ 没有这个DbSet
  • 架构争议性话题,DbContext是否有必要封装
    • 要封装: 可能以后有切换到其他ORM的可能
    • 不要封装:
      • 减少性能开销
      • ORM切换的概率很小
      • 即使移植到其他ORM也不一定能直接用(需要重写,所以封装的意义不大)

聚合与DbContext的关系

  • 如果一个微服务中有多个聚合根,那么是每个聚合根的实体放到一个单独的上下文中,还是把所有实体放到同一个上下文中?各自的优缺点是什么?
// 方案1:直接使用(文档推荐)
public class OrderService
{
    private readonly MyDbContext _context;  // 直接依赖DbContext
    
    public void AddOrderItem(int orderId, int merchantId, int count)
    {
        var order = _context.Orders.Find(orderId);
        var merchant = _context.Merchants.Find(merchantId);
        order.AddDetail(merchant, count);
        _context.SaveChanges();
    }
}

// 方案2:封装仓储层(可选)
public interface IOrderRepository
{
    Order GetById(int id);
    void Save(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    // 解耦了具体的数据访问技术
}
  • 我倾向于所有实体放到同一个上下文中
    • 它们之间的关系仍然比它们和其他微服务中的实体关系更紧密,而且我们还会在应用服务中进行跨聚合的组合操作。进行联合查询的时候可以获得更好的性能,也能更容易实现强一致性的事务。

区分聚合根实体和其他实体的方法

  • 使用标记接口,即定义一个不包含任何成员的标记接口,比如IAggregateRoot,然后要求所有的聚合根实体类都实现这个接口。
  • 实例演示
// IJuhegen.cs
......
namespace ConsoleApp1
{
	// 自定义一个空接口
    internal interface IJuhegen
    {
    }
}

// Order.cs
......
namespace ConsoleApp1
{
    public class Order:IJuhegen // 约定都继承该接口
    {
        ......
    }
}

// Merchant.cs
......

namespace ConsoleApp1
{
    public class Merchant:IJuhegen // 约定都继承该接口
    {
        ......
    }
}

// 内部实体不实现接口
public class OrderDetail
{
    // 不是聚合根
}

聚合设计原则

  • 边界设计
// 判断聚合边界的思考:
// 1. Order和OrderDetail属于同一个聚合
//    - OrderDetail不能离开Order独立存在
//    - 需要一起更新(库存、价格等)
// 2. Merchant是单独的聚合
//    - 可以被多个Order引用
//    - 有独立的生命周期
  • 引用规则
public class OrderDetail
{
    // 正确:通过ID引用其他聚合根
    public int MerchantId { get; set; }  // ✅ 推荐
    
    // 也可以:直接引用对象
    public Merchant Merchant { get; set; } // ✅ EF Core支持
    
    // 但是:Merchant不应该反向引用OrderDetail
    // 因为OrderDetail是Order的内部实体
}
  • 事务的一致性
// 聚合内的强一致性
public class Order
{
    public void AddDetail(Merchant merchant, int count)
    {
        // 在这里执行所有验证
        if (merchant.Stock < count)
            throw new Exception("库存不足");
            
        // 更新聚合内部状态
        var detail = new OrderDetail { MerchantId = merchant.Id, Count = count };
        Details.Add(detail);
        
        // 可以调用领域事件,但不在同一个事务中
        // DomainEvents.Raise(new OrderDetailAdded(this, detail));
    }
}

小结

关键要点:

  1. 聚合根是唯一入口:外部只能通过聚合根访问聚合内部
  2. DbContext设计:只暴露聚合根的DbSet,不暴露内部实体
  3. 标识聚合根:使用标记接口或基类明确标识
  4. 聚合边界:根据业务一致性要求划分聚合
  5. 引用方式:聚合间通过ID引用,聚合内可以直接引用对象
  • 修正OrderDetail的数据模型
    • 跨聚合进行实体引用,只能引用根实体,并且只能引用根实体Id,而不是引用整个根实体对象
......
namespace ConsoleApp1
{
    public class OrderDetail
    {
        ......
        //public Merchant Merchant { get; set; }
        public long MerchantId { get; set; }
    }
}

  • 原因1:避免聚合边界模糊

// 错误:Merchant对象被加载到Order聚合中
var orderDetail = order.Details.First();
var merchantName = orderDetail.Merchant.Name;  // Merchant被加载

// 这意味着:
// 1. Order聚合包含了Merchant的部分数据
// 2. Merchant的变更会影响Order的加载
// 3. 聚合边界变得模糊

原因2:微服务拆分准备

// 当前:同一个数据库
Order (订单服务) --引用--> Merchant (商品服务)

// 未来:微服务拆分
// 订单服务数据库:Order, OrderDetail (只有MerchantId)
// 商品服务数据库:Merchant
// 通过MerchantId进行服务间通信
// 未划分聚合的情况
public class BigAggregate  // 一个大聚合
{
    public List<Customer> Customers { get; set; }      // 客户
    public List<Order> Orders { get; set; }           // 订单
    public List<Product> Products { get; set; }       // 产品
    public List<Inventory> Inventory { get; set; }    // 库存
}

// 划分聚合后
public class CustomerAggregate { /* 客户相关 */ }
public class OrderAggregate { /* 订单相关 */ }
public class ProductAggregate { /* 产品相关 */ }
public class InventoryAggregate { /* 库存相关 */ }

// 优势: 更容易拆分为微服务
微服务1:CustomerService     // 管理CustomerAggregate
微服务2:OrderService        // 管理OrderAggregate  
微服务3:ProductService      // 管理ProductAggregate
微服务4:InventoryService    // 管理InventoryAggregate

实体建模的建议

  • 优先考虑进行领域模型建模,而不是考虑面向数据库建模

    • 应该不考虑数据库实现的情况下,进行领域模型建模,然后再使用Fluent API

      对实体类和数据库之间做适配,在实现的时候,可能需要对建模进行妥协性修改,但这不应纳在最开始被考虑

错误做法演示:数据库驱动设计

-- 先设计数据库表
CREATE TABLE Users (
    Id INT PRIMARY KEY,
    Name NVARCHAR(100),
    Email NVARCHAR(100)
    -- 更多字段...
)

// 然后生成实体类(变成贫血的数据对象)
public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    // 只有getter/setter,没有业务逻辑
}

正确做法:领域驱动设计

// 1. 先分析业务领域
public class User  // 领域实体
{
    public int Id { get; init; }
    public string Name { get; private set; }
    public Email Email { get; private set; }  // 值类型
    
    // 业务方法
    public void ChangeName(string newName)
    {
        if (string.IsNullOrWhiteSpace(newName))
            throw new ArgumentException("姓名不能为空");
        Name = newName.Trim();
    }
    
    public void ChangeEmail(string newEmail)
    {
        Email = new Email(newEmail);  // 包含验证逻辑
    }
}

// 2. 再使用Fluent API适配数据库
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>(entity =>
    {
        entity.Property(u => u.Email)
              .HasConversion(
                  v => v.Value,           // 存储到数据库
                  v => new Email(v));     // 从数据库读取
    });
}

领域事件的实现

什么是领域事件?(通俗理解,就是"通知")

// 领域事件:当领域发生某些事情时发出的事件
public record OrderCreatedEvent : INotification
{
    public int OrderId { get; init; }
    public DateTime CreatedAt { get; init; }
    public decimal TotalAmount { get; init; }
}

// 使用场景:订单创建后需要
// 1. 发送邮件通知
// 2. 更新库存
// 3. 记录日志
// 4. 发送短信

传统方式:紧耦合

public class OrderService
{
    private readonly IEmailService _emailService;
    private readonly IInventoryService _inventoryService;
    private readonly ILogService _logService;
    private readonly ISmsService _smsService;
    
    public void CreateOrder(Order order)
    {
        // 保存订单
        _orderRepository.Save(order);
        
        // 调用各种服务(紧耦合)
        _emailService.SendOrderConfirmation(order);
        _inventoryService.UpdateStock(order);
        _logService.LogOrder(order);
        _smsService.SendNotification(order);
        // 每添加一个新功能,就要修改这里
    }
}

使用MediatR:松耦合

public class OrderService
{
    private readonly IMediator _mediator;
    
    public async Task CreateOrder(Order order)
    {
        // 保存订单
        await _orderRepository.SaveAsync(order);
        
        // 发布事件(解耦)
        await _mediator.Publish(new OrderCreatedEvent
        {
            OrderId = order.Id,
            CreatedAt = order.CreatedAt,
            TotalAmount = order.TotalAmount
        });
        // 添加新功能:只需添加新的Handler,不修改这里
    }
}

// 多个独立的处理者
public class EmailHandler : INotificationHandler<OrderCreatedEvent>
{
    public Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken)
    {
        // 发送邮件
        return Task.CompletedTask;
    }
}

public class InventoryHandler : INotificationHandler<OrderCreatedEvent>
{
    public Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken)
    {
        // 更新库存
        return Task.CompletedTask;
    }
}
  • 比较好的解耦方式: 进程内消息传递的开源库MediatR
    • 它可以在事件的发布和事件的处理之间解耦
    • 支持"一个发布者对应一个处理者"(较少见)
    • 支持"一个发布者对应多个处理者"(常见)

MediatR的用法

  • 新建Web API项目并安装库
- Install-Package MediatR.Extensions.Microsoft.DependencyInjection
  • 注册服务
// Program.cs
......
builder.Services.AddSwaggerGen();
builder.Services.AddMediatR(Assembly.GetExecutingAssembly());
......

  • 定义事件(最好是记录)需继承INotification接口
using MediatR;

namespace WebApplicationAboutMediatRDemo
{
	// 定义一个Body字段
    public record PostNotification(string Body): INotification;
    
}

  • 接口中发布事件
using MediatR;
using Microsoft.AspNetCore.Mvc;

namespace WebApplicationAboutMediatRDemo.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {	
        // 声明
        private IMediator mediator;

        ......

        public WeatherForecastController(ILogger<WeatherForecastController> logger, IMediator mediator)
        {	
        	// 注入
            _logger = logger;
            this.mediator = mediator;
        }

        ......
        public IEnumerable<WeatherForecast> Get()
        {	
        	// 发布事件
            mediator.Publish(new PostNotification("Hello" + DateTime.Now));
            ......
        }
    }
}

  • 处理事件: 继承 INotificationHandler<PostNotification>来处理事件,可以定义多个事件处理者来接收
using MediatR;

namespace WebApplicationAboutMediatRDemo
{
    public class PostNotificationHandler1 : INotificationHandler<PostNotification>
    {
        public Task Handle(PostNotification notification, CancellationToken cancellationToken)
        {
            Console.WriteLine("111" + notification.Body);
            // throw new NotImplementedException();
            return Task.CompletedTask;
        }

        
    }
}


using MediatR;

namespace WebApplicationAboutMediatRDemo
{
    public class PostNotificationHandler2 : INotificationHandler<PostNotification>
    {
        public Task Handle(PostNotification notification, CancellationToken cancellationToken)
        {
            Console.WriteLine("222" + notification.Body);
            //throw new NotImplementedException();
            return Task.CompletedTask;
        }

        
    }
}



  • 测试,访问API接口,触发事件的发布,控制台会输出111222...

设计演进:

// 阶段1:简单应用
// 直接调用各种服务

// 阶段2:使用接口抽象
// 依赖注入解耦

// 阶段3:领域事件
// 完全解耦,易于扩展

// 阶段4:微服务架构
// 基于聚合拆分服务,事件跨服务通信

这种设计模式让系统更加灵活,新功能的添加只需要添加新的Handler,而不需要修改现有的代码,符合开闭原则。

领域事件的时机1(在什么地方发布事件)

  • 聚合根实体对象的构造方法或者类似ChangeName,ChangePassword等方法中,把事件发布的逻辑写在里面

    • 好处: 无论是应用服务还是领域服务,最终要调用聚合根的方法来操作聚合,可以确保领域事件不会被漏掉

    • 缺点1: 存在重复发布领域事件,造成浪费的情况

      • 比如只是查询一下用户名,先new一个实例,而此时的构造方法包含要发布的领域事件,此时就会触发

        而实际的需求中,无需发布这个领域事件,造成浪费的情况

    • 缺点2: 领域事件发布的太早:

      • 比如在实体类构造方法中发布领域事件,但有可能是数据验证没通过,最终没有把该实体存入数据库

        此时,领域事件的发布就过早,误报了.

  • demo演示

// User.cs

namespace WebApplicationDomainEventOpportunity
{
    public class User
    {
    
        public int Id { get; set; }
        public string Name { get; set; }
        public string? Password { get; set; } 

        private User()
        {

        }
        public User(string name)
        {
            this.Name = name;
            // publish... // 这里编写发布事件的逻辑
            mediator.Publish(new NewUserNotification(name));
        }

        
    }
}



// 某个api接口
public IEnumerable<WeatherForecast> Get()
{
    User user1 = new User("yzk");
	// publish... // 这里编写发布事件的逻辑
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        ......
    })
    .ToArray();
}

领域事件的时机2

  • 推荐做法: 把领域事件的发布延迟Context保存修改的时候(ctx.SaveChanges())
// 只在聚合中注册事件
public User(string name)
{
    this.Name = name;
    // ✅ 只注册事件,不发布
    this.AddDomainEvent(new NewUserNotification(name));
}

// 在DbContext中统一发布
public override async Task<int> SaveChangesAsync()
{
    // 获取所有事件并发布
    var events = GetDomainEvents();
    foreach(var e in events)
    {
        await mediator.Publish(e);
    }
    return await base.SaveChangesAsync();
}
  • 在实体中,只是注册要发布的领域事件,等到ctx.SaveChanges()时,我们再发布事件
  • Demo实例演示
// IDomainEvents.cs

using MediatR;
namespace WebApplicationDomainEventOpportunity
{
    public interface IDomainEvents // 定义事件管理的基本契约(事件管理接口)
    {
        IEnumerable<INotification> GetDomainEvents();
        void AddDomainEvent(INotification notification);
        void ClearDomainEvents();
    }
}


// BaseEntity.cs

using MediatR;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebApplicationDomainEventOpportunity
{
    public abstract class BaseEntity:IDomainEvents // 所有实体的基类,实现事件crud功能
    {
        [NotMapped] // 安装第三方库: Zack.DomainCommons 告诉EF Core这不是数据库字段
        private  IList<INotification> events = new List<INotification>();
        // 添加事件到待发布列表
        public void AddDomainEvent(INotification notification)
        {
            events.Add(notification);
        }

        public void ClearDomainEvents()
        {
            events.Clear();
        }

        public IEnumerable<INotification> GetDomainEvents()
        {
            return events;
        }
    }
}


// User.cs
namespace WebApplicationDomainEventOpportunity
{
    public class User:BaseEntity // 集成事件管理类,并在业务方法中注册事件,但不发布
    {

        public int Id { get; set; }
        public string Name { get; set; }
        public string? Password { get; set; }

        private User()
        {

        }
        public User(string name)
        {
            this.Name = name;
            // 注册事件,但不发布
            this.AddDomainEvent(new NewUserNotification(name, DateTime.Now));
            
        }

        public void ChangeUserName(string userName) {
            if (userName.Length > 20) {
                Console.WriteLine("用户名长度不能大于5");
                return;
            }
            string oldUserName = this.Name;
            this.Name = userName;
            // 注册事件,但不发布
            this.AddDomainEvent(new UserNameChangeNotification(oldUserName, userName));
        }

       
    }
}





// NewUserNotification.cs

using MediatR;

namespace WebApplicationDomainEventOpportunity
{
    // 生成通知类
    public record NewUserNotification(string Name,DateTime Time):INotification;
}


// UserNameChangeNotification.cs
using MediatR;

namespace WebApplicationDomainEventOpportunity
{
    // 生成通知类
    public record UserNameChangeNotification(string OldName,string NewName):INotification;
  
}


// MyDbContext.cs

using MediatR;
using Microsoft.EntityFrameworkCore;

namespace WebApplicationDomainEventOpportunity
{
    public class MyDbContext:DbContext // 在SaveChangesAsync中统一发布事件
    {
        public DbSet<User> Users { get; set; }

        private IMediator mediator;

        public MyDbContext(IMediator mediator)
        {
            this.mediator = mediator;
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            string connStr = "Server=.;Database=ddd1;Trusted_Connection=True;TrustServerCertificate=true;";
            optionsBuilder.UseSqlServer(connStr);
            
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
        }

      
        public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
        {
        	// 获取所有有未发布事件的实体
            var domainEntities = this.ChangeTracker.Entries<IDomainEvents>()
                .Where(e => e.Entity.GetDomainEvents().Any());
			
			// 提取所有事件
            var domainEvents = domainEntities.SelectMany(e => e.Entity.GetDomainEvents()).ToList();

            Console.WriteLine($"找到 {domainEvents.Count} 个待发布的事件"); // 调试输出
			// 清空实体中的事件列表
            domainEntities.ToList().ForEach(e => e.Entity.ClearDomainEvents());
			
			// 发布所有事件
            foreach (var e in domainEvents)
            {
                Console.WriteLine($"发布事件:{e.GetType().Name}"); // 调试输出
                await mediator.Publish(e);
            }
			
			// 返回基类的逻辑
            return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
        }

    }
}




// NewUserHandler.cs

using MediatR;

namespace WebApplicationDomainEventOpportunity
{
    // 处理者
    public class NewUserHandler:NotificationHandler<NewUserNotification>
    {
        protected override void Handle(NewUserNotification notification)
        {
            Console.WriteLine("新建了用户"+notification.Name);
        }
    }
}



// UserNameChangedHandler.cs
using MediatR;

namespace WebApplicationDomainEventOpportunity
{
    // 处理者
    public class UserNameChangedHandler: NotificationHandler<UserNameChangeNotification>
    {
        protected override void Handle(UserNameChangeNotification notification)
        {
            Console.WriteLine($"用户名从{notification.OldName}变成了{notification.NewName}");
        }
    }
}




// Program.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
using WebApplicationDomainEventOpportunity;

var builder = WebApplication.CreateBuilder(args);using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
using WebApplicationDomainEventOpportunity;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddScoped<MyDbContext>();
builder.Services.AddMediatR(Assembly.GetExecutingAssembly());

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();


  • 最后写一个api接口来测试效果
// TestUserController.cs
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace WebApplicationDomainEventOpportunity.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TestUserController : ControllerBase
    {

        private IMediator mediator;
        private MyDbContext dbContext;

        public TestUserController(IMediator mediator, MyDbContext dbContext)
        {
            this.mediator = mediator;
            this.dbContext = dbContext;
        }

       
        [HttpGet]
        public async Task<string> GetUserInfoString()
        {
            try
            {
                var user1 = new User("yzk");
                user1.ChangeUserName("Jim Green");
                dbContext.Users.Add(user1);
                var result = await dbContext.SaveChangesAsync();
                Console.WriteLine($"保存结果:{result} 行受影响"); // 添加日志
                return "用户信息字符串";
            }
            catch (Exception ex)
            {
                Console.WriteLine($"错误:{ex.Message}");
                return $"错误:{ex.Message}";
            }
        }
    }
}

关键要点:

  1. 分离关注点聚合根负责注册事件基础设施负责发布事件
  2. 保证一致性事件发布数据库事务同步
  3. 灵活扩展:可以轻松添加新的事件处理器而不影响业务逻辑
  4. 便于测试:可以独立测试事件逻辑和业务逻辑
flowchart TD A["开始: 应用服务调用聚合方法"] --> B["聚合根内部业务逻辑执行"] B --> C["注册领域事件<br>调用 AddDomainEvent(notification)"] C --> D{"事件已添加到<br>聚合的事件列表中"} D --> E["聚合操作完成<br>返回应用服务"] E --> F["DbContext.SaveChangesAsync 调用"] F --> G["遍历 ChangeTracker<br>获取所有注册事件的实体"] G --> H["提取所有待发布事件<br>domainEvents.ToList()"] H --> I["清空聚合中的事件列表<br>ClearDomainEvents()"] I --> J["通过 MediatR 发布事件<br>mediator.Publish(e)"] J --> K{"事件分发到处理器"} K --> L["事件处理器1执行<br>NotificationHandler.Handle()"] K --> M["事件处理器2执行<br>NotificationHandler.Handle()"] K --> N["...其他事件处理器"] L --> O["执行数据库操作<br>base.SaveChangesAsync"] M --> O N --> O O --> P["事务完成提交<br>返回结果"] P --> Q["结束: 业务操作完成"] style A fill:#e1f5fe,stroke:#01579b style B fill:#f3e5f5,stroke:#4a148c style C fill:#e8f5e8,stroke:#1b5e20 style F fill:#fff3e0,stroke:#e65100 style J fill:#fff3e0,stroke:#e65100 style L fill:#fce4ec,stroke:#880e4f style M fill:#fce4ec,stroke:#880e4f style O fill:#e0f7fa,stroke:#006064 style Q fill:#e1f5fe,stroke:#01579b

集成事件

  • 应用范围: 微服务之间

    • 比如订单微服务,物流微服务
    • 领域事件是基于微服务内部之间的通讯,所以集成事件就是基于微服务之间的通讯
    • 通俗理解: 就是外部和外部之间的通知(服务器与服务器之间的通知)

领域事件 vs 集成事件

特性 领域事件 集成事件
作用范围 单个微服务内部 多个微服务之间
通信对象 同一服务内的不同组件 不同服务之间
实现方式 进程内事件总线(如 MediatR) 进程间消息中间件
示例场景 用户注册后发送欢迎邮件 订单创建后通知物流服务
  • 由于集成事件是基于服务器之间的通信,所以必须借助于第三方服务器作为事件总线

    常见的消息中间件有Redis,'RabbitMQ',Kafka,ActiveMQ等.

  • RabbitMQ: 优秀的消息中间件库

    • 信道(Channel): 它是消息生产者,消费者服务器进行通信的虚拟连接.
      • 特点
        • 建立在 TCP 连接之上
        • TCP 连接资源消耗大,应尽量复用
        • 信道可以快速创建和关闭
      • 类比:TCP 连接是高速公路,信道是车道
    • 队列(Queue): 消息排队的地方
      • 消费者队列中获取数据
      • 即使消费者离线,消息也会保存在队列中
      • 支持持久化存储
    • 交换机(exchange): 把消息路由到一个或者多个队列中
      • 工作方式: 消息 → 交换机 → 根据规则 → 队列

RabbitMQrouting模式(有好几种模式,这种常用)

  • routing模式的过程如下
- 生产者把消息发布到"交换机"中,并且该消息会携带一个"routingKey"属性
- 交换机根据"routingKey"的值把消息发送到一个或者多个队列
- 消费者从队列中获取消息
- 交换机和队列都位于RabbitMQ服务器内部
	- 优点: 即使消费者不在线,和消费者相关的消息也会被保存到队列中
	  当消费者上线后,就可以获得离线期间错过的消息.
sequenceDiagram participant Producer as 生产者 participant Exchange as 交换机 participant Queue as 队列 participant Consumer as 消费者 Producer->>Exchange: 发布消息 + routingKey Exchange->>Queue: 根据 routingKey 路由 Consumer->>Queue: 订阅消息 Queue->>Consumer: 传递消息

关键特性

  1. 消息持久化:消费者离线时消息不丢失
  2. 精准路由:通过 routingKey 指定目标队列
  3. 异步通信:生产者和消费者解耦
  • 基本用法演示
- 安装 Erlang:RabbitMQ 运行的依赖前提
- 安装RabbitMQ.exe
	- 按照安装文档的方法安装以后,启动服务器,如果web地址无法访问,重启一下电脑即可
	
	
- 创建两个控制台项目(基于".net6.0"老版本),分别负责"发送消息"和"接收消息"
	- 安装RabbitMQ.Client库)(Version:6.0.0)
  • 生产者代码如下
using RabbitMQ.Client;
using System.Text;


Console.WriteLine("Hello, World!");

var connFactory = new ConnectionFactory();
connFactory.HostName = "localhost";
connFactory.DispatchConsumersAsync = true;
var connection = connFactory.CreateConnection();
string exchangeName = "exchange1";
while (true)
{
    using var channel = connection.CreateModel();
    var prop = channel.CreateBasicProperties();
    prop.DeliveryMode = 2; // 消息持久化
    channel.ExchangeDeclare(exchangeName, "direct"); // 声明交换机
    byte[] bytes = Encoding.UTF8.GetBytes(DateTime.Now.ToString());
    channel.BasicPublish(exchangeName,routingKey:"key1",mandatory:true,  // 发布消息(包含routingKey)
        basicProperties:prop,body:bytes);
    Console.WriteLine("OK! "+"Now it is "+DateTime.Now);
    Thread.Sleep(10000); // 每10秒发送一次
}

  • 消费者代码如下: 消息确认---"Ack",对于没有确认的消息,可以进行消息的"重发"
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;

Console.WriteLine("I'm in comsumer!");
// 创建连接
var connFactory = new ConnectionFactory();
connFactory.HostName = "localhost";
connFactory.DispatchConsumersAsync = true;
var connection =  connFactory.CreateConnection();
string exchangeName = "exchange1";
string queueName = "queue1";
string routingKey = "key1";
// 创建信道
using var channel = connection.CreateModel();
// 声明交换机和队列
channel.ExchangeDeclare(exchangeName, "direct");
channel.QueueDeclare(queueName,durable: true,exclusive: false,autoDelete: false,arguments:null);
// 绑定队列到交换机
channel.QueueBind(queueName, exchangeName, routingKey);

// 创建异步消费者
AsyncEventingBasicConsumer consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += Consumer_Received;
// 开始消费,不自动确认消息
channel.BasicConsume(queueName, autoAck: false, consumer: consumer);
Console.WriteLine("按回车退出!");
Console.ReadLine();

// 消息的处理与确认机制
async Task Consumer_Received(object sender, BasicDeliverEventArgs _event)
{
    try
    {
        byte[] bytes = _event.Body.ToArray();
        // 解析消息
        string text = Encoding.UTF8.GetString(bytes);
        Console.WriteLine(DateTime.Now + " 收到消息 " + text);
        // 确认消息已处理(手动ACK),DeliveryTag为消息的唯一标识
        channel.BasicAck(_event.DeliveryTag, multiple: false);
        // 处理业务逻辑
        await Task.Delay(800);
    }
    catch(Exception ex)
    {
        Console.WriteLine("报错异常啦: "+ex);
        // 拒绝消息 并重新入队, 参数2:requeue = true 表示消息重新放回队列
        channel.BasicReject(_event.DeliveryTag, true);
    }
}
  • 运行两个控制台代码(生产者和消费者),效果如下
I'm in Factory!
OK! Now it is 2025/12/5 14:24:37
OK! Now it is 2025/12/5 14:24:47
OK! Now it is 2025/12/5 14:24:57
OK! Now it is 2025/12/5 14:25:07
OK! Now it is 2025/12/5 14:25:17
...........


I'm in comsumer!
按回车退出!
2025/12/5 14:24:51 收到消息 2025/12/5 14:24:37
2025/12/5 14:24:51 收到消息 2025/12/5 14:24:47
2025/12/5 14:24:57 收到消息 2025/12/5 14:24:57
2025/12/5 14:25:07 收到消息 2025/12/5 14:25:07
2025/12/5 14:25:17 收到消息 2025/12/5 14:25:17
...............

消息确认机制详解

1. 手动确认 vs 自动确认

  • 自动确认:消息一发送给消费者就确认

    • 风险:消费者处理失败时消息丢失
  • 手动确认:消费者处理完成后确认

    • 安全:确保消息被正确处理
  • multiple

    • false:仅确认单条消息
    • true:确认所有 deliveryTag <= 当前tag 的消息

BasicReject 参数说明

channel.BasicReject(deliveryTag: 123, requeue: true);

以电商系统为例

graph LR A[订单服务] -->|创建订单事件| B[RabbitMQ] B --> C[库存服务] B --> D[物流服务] B --> E[支付服务] B --> F[通知服务]

集成事件的优势

  1. 解耦服务:服务间不直接调用
  2. 提高可用性:单个服务故障不影响整体
  3. 支持异步处理:提高系统吞吐量
  4. 便于扩展:新增消费者无需修改生产者

小结

- 集成事件是微服务架构中实现服务间异步通信的关键技术
- RabbitMQ 的 Routing 模式提供了灵活可靠的消息路由机制。
- 通过手动消息确认机制,可以确保消息被正确处理,避免数据丢失。
- 在实际应用中,需要根据业务需求合理设计交换机和队列的绑定关系,并考虑消息的持久化、错误处理和性能优化。

Zack.EventBus

  • 简化版的RabbitMQ
    • RabbitMQ的调用过程还是比较繁琐,所以大神写一个简便版的框架---Zack.EventBus,使用起来较简单
    • 旨在简化消息发布与订阅的流程,适用于微服务架构中的集成事件处理
  • Demo以两个Web API项目充当生产者消费者为例来演示
- 安装: Zack.EventBus
  • 两个项目均先设置配置文件
// Program.cs
......
using System.Reflection;
using Zack.EventBus;
......
builder.Services.AddSwaggerGen();
// 设置 RabbitMQ 连接信息(主机名、交换器名称)
builder.Services.Configure<IntegrationEventRabbitMQOptions>(o =>
{
    o.HostName = "localhost";
    o.ExchangeName = "exchangeEventBus1";
});
// 注册事件总线,指定队列名称和扫描的程序集
builder.Services.AddEventBus("queue1", Assembly.GetExecutingAssembly());

var app = builder.Build();
app.UseEventBus(); // 最后使用
......

  • 基本的使用流程
- 发布事件
	- 在需要发布领域事件的类(在控制器或服务)中注入"IEventBus"服务,然后调用Publish方法发布消息.
	- eventBus.Publish("OrderCreated", orderDataObj);
	
- 订阅与处理事件	
    - 创造一个实现了IintegrationEventHandler接口的类
        - 使用 [EventName("事件名")] 属性标注该类,指定要监听的事件类型
        -  Handle 方法中编写事件处理逻辑
        
        [EventName("OrderCreated")]
        public class EventHandler1 : IIntegrationEventHandler
        {
            public Task Handle(string eventName, string eventData)
            {
                // 处理逻辑
                return Task.CompletedTask;
            }
        }
  • 单项目的自定义事件的通知和发布,代码如下
// api接口(发布通知)
......
using Microsoft.AspNetCore.Mvc;
using Zack.EventBus;

namespace WebApplicationZackEventBus1.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private readonly IEventBus eventBus;
        ......
        public WeatherForecastController(ILogger<WeatherForecastController> logger, IEventBus eventBus)
        {
            ......
            this.eventBus = eventBus;
        }

        [HttpGet(Name = "GetWeatherForecast")]
        public IEnumerable<WeatherForecast> Get()
        {
        	// 发布简单的数据
            eventBus.Publish("OrderCreated", 888);
            Console.WriteLine("已发布通知OrderCreated,发出去的数据是: "+888);
            return ......
        }
    }
}


// EventHandler1(接收通知并自定义处理)

using Zack.EventBus;

namespace WebApplicationZackEventBus1
{
    [EventName("OrderCreated")]
    public class EventHandler1: IIntegrationEventHandler
    {
        public Task Handle(string eventName,string eventData)
        {
            if(eventName == "OrderCreated")
            {
                Console.WriteLine("收到订单创建的通知了,收到的数据是: " + eventData);
            }
            return Task.CompletedTask;
        }
    }
}

  • 访问api接口测试,效果如下
......
已发布通知OrderCreated,发出去的数据是: 888
收到订单创建的通知了,收到的数据是: 888
已发布通知OrderCreated,发出去的数据是: 888
收到订单创建的通知了,收到的数据是: 888
......
  • 把上述示例小改一下,发送对象(自动序列化为 JSON)
// api接口

using Microsoft.AspNetCore.Mvc;
using Zack.EventBus;

namespace WebApplicationZackEventBus1.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
       ......

        [HttpGet(Name = "GetWeatherForecast")]
        public IEnumerable<WeatherForecast> Get()
        {
            //eventBus.Publish("OrderCreated", 888);
            var orderDataObj = new OrderData(1, "oppo手机订单", DateTime.Now);
            eventBus.Publish("OrderCreated", orderDataObj);
            Console.WriteLine("已发布通知OrderCreated,发出去的数据是: ");
            Console.WriteLine(orderDataObj);
            return ......
        }
    }

	// 新建记录对象
    public record OrderData(long Id,string Name,DateTime Date);
}

- 效果如下:
......
已发布通知OrderCreated,发出去的数据是:
OrderData { Id = 1, Name = oppo手机订单, Date = 2025/12/9 9:55:55 }
......
收到订单创建的通知了,收到的数据是: {
  "Id": 1,
  "Name": "oppo\u624B\u673A\u8BA2\u5355",
  "Date": "2025-12-09T09:55:55.0509216+08:00"
}

跨项目事件订阅

  • 支持在多个项目中订阅同一事件,每个项目需独立配置事件总线

  • 现在,新建另外一个api项目(同一个程序集),搭建好配置,并写EventHandler2,也能顺利接收到通知

// Program.cs 一样的配置
......
// EventHandler2.cs 逻辑和上面一样

using Zack.EventBus;

namespace WebApplicationZackEventBus2
{
    [EventName("OrderCreated")]
    public class EventHandler2: IIntegrationEventHandler
    {
        public Task Handle(string eventName,string eventData)
        {
            if(eventName == "OrderCreated")
            {
                Console.WriteLine("收到订单创建的通知了,收到的数据是: " + eventData);
            }
            return Task.CompletedTask;
        }
    }
}

  • 同时跑起这两个项目的效果,有一个神奇的现象
- 每发布一次事件,EventHandler1和EventHandler2轮流收到通知
	- 按照通常的思维,应该可以同时收到通知才对,而不是轮流收到通知,原因是啥?待研究...
	- 这可能与 RabbitMQ 的队列竞争模式(竞争消费者模式)有关,需进一步研究其配置机制
	

已发布通知OrderCreated,发出去的数据是:
OrderData { Id = 1, Name = oppo手机订单, Date = 2025/12/9 10:08:54 }
已发布通知OrderCreated,发出去的数据是:
OrderData { Id = 1, Name = oppo手机订单, Date = 2025/12/9 10:10:04 }
收到订单创建的通知了,收到的数据是: {
  "Id": 1,
  "Name": "oppo\u624B\u673A\u8BA2\u5355",
  "Date": "2025-12-09T10:10:04.2438809+08:00"
}
已发布通知OrderCreated,发出去的数据是:
OrderData { Id = 1, Name = oppo手机订单, Date = 2025/12/9 10:10:28 }
......

收到订单创建的通知了,收到的数据是: {
  "Id": 1,
  "Name": "oppo\u624B\u673A\u8BA2\u5355",
  "Date": "2025-12-09T10:08:54.9337418+08:00"
}
收到订单创建的通知了,收到的数据是: {
  "Id": 1,
  "Name": "oppo\u624B\u673A\u8BA2\u5355",
  "Date": "2025-12-09T10:10:28.5907853+08:00"
}

高级特性:JsonIntegrationEventHandler

  • 继承 JsonIntegrationEventHandler<T> 可直接反序列化事件数据为指定类型 T
    • 好处: 无需手动解析 JSON 字符串
using WebApplicationZackEventBus1.Controllers;
using Zack.EventBus;

namespace WebApplicationZackEventBus1
{
    [EventName("OrderCreated")]
    public class EventHandler3 : JsonIntegrationEventHandler<OrderData>
    {
        public override Task HandleJson(string eventName, OrderData? eventData)
        {
            Console.WriteLine("我是EventHandler3,收到了订单: " + eventData.Name + eventData.Id);
            return Task.CompletedTask;
        }
    }
}

  • 运行效果如下: 两个handler均收到事件通知
已发布通知OrderCreated,发出去的数据是:
OrderData { Id = 1, Name = oppo手机订单, Date = 2025/12/9 10:30:24 }
收到订单创建的通知了,收到的数据是: {
  "Id": 1,
  "Name": "oppo\u624B\u673A\u8BA2\u5355",
  "Date": "2025-12-09T10:30:24.8461578+08:00"
}
我是EventHandler3,收到了订单: oppo手机订单1

架构优势与应用场景

  • 异步处理:发布者无需等待消费者处理,提升系统响应能力。
  • 解耦与削峰填谷:服务间通过事件通信,降低耦合度,缓解突发流量压力。
  • 最终一致性支持:适用于分布式事务场景,通过事件驱动实现数据最终一致性。
  • 失败重试机制:借助消息队列的重试机制,提高事件处理的可靠性。

类似框架对比

  • CAP:另一个流行的 .NET 事件总线与消息队列框架,功能更为丰富,支持多种存储和消息队列。
  • Zack.EventBus 更轻量、易上手,适合快速集成事件驱动架构。

总结

  • Zack.EventBus 是一个简化 RabbitMQ 使用的 .NET 事件总线框架。
  • 适用于微服务架构中的事件通信,支持跨服务、跨项目的事件发布与订阅。
  • 提供简单易用的 API,支持对象序列化、JSON 处理等高级特性。
  • 适合需要快速实现事件驱动架构的中小型项目。

洋葱架构

  • 分层架构: 各个组件按照高内聚-低耦合的原则组织到不同的项目中
  • 传统的经典三层架构
用户界面层-->业务逻辑层-->数据访问层
  • 三层架构的缺点
- 尽管由DAL,但仍然是面向数据库的思维方式;对于一些简单的、不包含业务逻辑的增删改查类操作,仍然需要BLL进行转发;依赖关系是单向的,所以下一层中的代码不能使用上一层中的逻辑
  • 洋葱架构(整洁架构)
- 架构由内向外
	- 领域模型-领域服务-应用服务-用户界面(基础设施,数据库,外部服务)
- 内层的部分比外层的部分更加的抽象→内层表达抽象,外层表达实现
- 外层的代码只能调用内层的代码,内层的代码可以通过依赖注入的形式来间接调用外层的代码
	- 举一个简单的例子:读取文件然后发送邮件

洋葱架构

  • 防腐层(外层服务)
- 通常指变化比较频繁的外层服务(短信服务,邮件服务,存储服务)
	 -把这些频繁变动服务定义为接口,在内层代码中我们只定义和使用接口,在外层代码中定义接口的实现。
	  体现的仍然是洋葱架构的理念

实例演示-发送邮件

  • 通常写法,写在一起

string[] lines = File.ReadAllLines("d:/text.txt");
foreach (var line in lines)
{
    string[] parts = line.Split('|');
    string email = parts[0];
    string title = parts[1];
    string body = parts[2];

    Console.WriteLine($"发送邮件:{email}---{title}---{body}");
}

- 运行结果:
发送邮件:111@qq.com---标题1---正文1的内容
发送邮件:222@qq.com---标题2---正文2的内容

现在,使用洋葱架构来实现它

1. 领域模型层 (核心)

// EmailMessage.cs - 领域实体
public class EmailMessage
{
    public string Email { get; set; }
    public string Title { get; set; }
    public string Body { get; set; }
    
    public EmailMessage(string email, string title, string body)
    {
        // 验证逻辑可以放在这里
        Email = email;
        Title = title;
        Body = body;
    }
}

2. 应用服务层 (用例/业务逻辑)

// IFileReader.cs - 接口定义在内层
public interface IFileReader
{
    string[] ReadAllLines(string path);
}

// IEmailSender.cs - 接口定义在内层
public interface IEmailSender
{
    void SendEmail(EmailMessage message);
}

// EmailService.cs - 应用服务
public class EmailService
{
    private readonly IFileReader _fileReader;
    private readonly IEmailSender _emailSender;
    
    public EmailService(IFileReader fileReader, IEmailSender emailSender)
    {
        _fileReader = fileReader;
        _emailSender = emailSender;
    }
    
    public void SendEmailsFromFile(string filePath)
    {
        var lines = _fileReader.ReadAllLines(filePath);
        
        foreach (var line in lines)
        {
            var parts = line.Split('|');
            if (parts.Length >= 3)
            {
                var emailMessage = new EmailMessage(
                    parts[0], 
                    parts[1], 
                    parts[2]
                );
                
                _emailSender.SendEmail(emailMessage);
            }
        }
    }
}

3. 基础设施层 (外层实现)

// FileReader.cs - 文件读取的具体实现
public class FileReader : IFileReader
{
    public string[] ReadAllLines(string path)
    {
        return File.ReadAllLines(path);
    }
}

// ConsoleEmailSender.cs - 邮件发送的具体实现(控制台模拟)
public class ConsoleEmailSender : IEmailSender
{
    public void SendEmail(EmailMessage message)
    {
        Console.WriteLine($"发送邮件:{message.Email}---{message.Title}---{message.Body}");
    }
}

// 实际的邮件发送实现(如使用SMTP)
public class SmtpEmailSender : IEmailSender
{
    private readonly string _smtpServer;
    private readonly int _port;
    
    public SmtpEmailSender(string smtpServer, int port)
    {
        _smtpServer = smtpServer;
        _port = port;
    }
    
    public void SendEmail(EmailMessage message)
    {
        // 实际的SMTP发送逻辑
        // 使用_smtpServer和_port配置
        Console.WriteLine($"通过SMTP发送邮件到:{message.Email}");
    }
}

4. 用户界面/入口点

// Program.cs - 组合根
class Program
{
    static void Main(string[] args)
    {
        // 依赖注入(这里手动注入,实际项目可用DI容器)
        IFileReader fileReader = new FileReader();
        IEmailSender emailSender = new ConsoleEmailSender(); // 可轻松替换为SmtpEmailSender
        
        var emailService = new EmailService(fileReader, emailSender);
        
        // 使用应用服务
        emailService.SendEmailsFromFile("d:/text.txt");
    }
}

洋葱架构的优势在此示例中体现:

  1. 依赖方向:内层(领域模型、接口)不依赖外层,外层依赖内层接口
  2. 可测试性:可以轻松为EmailService编写单元测试,使用模拟的IFileReaderIEmailSender
  3. 可替换性:更换邮件发送方式(控制台→SMTP→第三方API)只需更换外层实现,不修改核心业务逻辑
  4. 关注点分离
    • 领域层:定义业务实体和规则
    • 应用层:编排业务流程
    • 基础设施层:处理技术细节

防腐层示例

如果邮件服务经常变化(如切换服务商),可以添加防腐层:

// 防腐层:统一邮件发送接口
public interface IExternalEmailService
{
    Task SendAsync(string to, string subject, string body);
}

// 适配不同邮件服务商
public class MailgunAdapter : IExternalEmailService { /* 实现Mailgun API */ }
public class SendGridAdapter : IExternalEmailService { /* 实现SendGrid API */ }
public class SmtpAdapter : IExternalEmailService { /* 实现SMTP */ }

// 在基础设施层使用
public class EmailSenderWithAdapter : IEmailSender
{
    private readonly IExternalEmailService _externalService;
    
    public EmailSenderWithAdapter(IExternalEmailService externalService)
    {
        _externalService = externalService;
    }
    
    public void SendEmail(EmailMessage message)
    {
        _externalService.SendAsync(message.Email, message.Title, message.Body);
    }
}

这样,当需要更换邮件服务商时,只需创建新的适配器并修改依赖注入配置,核心业务代码完全不受影响。

流程图如下

graph TB subgraph "基础设施层 (Infrastructure)" I1[文件系统<br>File.ReadAllLines] I2[控制台输出<br>Console.WriteLine] I3[SMTP服务<br>邮件发送] end subgraph "用户界面层 (Presentation)" UI[Program<br>组合根] end subgraph "应用服务层 (Application)" A1[EmailService<br>发送邮件用例] A2[IFileReader<br>接口] A3[IEmailSender<br>接口] end subgraph "领域层 (Domain)" D1[EmailMessage<br>领域实体] D2[业务规则<br>验证逻辑] end %% 依赖关系 UI --> A1 UI -.-> I1 UI -.-> I2 UI -.-> I3 A1 --> A2 A1 --> A3 A1 --> D1 I1 -.-> A2 I2 -.-> A3 I3 -.-> A3 D1 --> D2 %% 样式 style D1 fill:#e1f5fe style D2 fill:#e1f5fe style A1 fill:#f3e5f5 style A2 fill:#f3e5f5 style A3 fill:#f3e5f5 style UI fill:#e8f5e8 style I1 fill:#fff3e0 style I2 fill:#fff3e0 style I3 fill:#fff3e0
  • 详细组件关系图
classDiagram direction LR %% 领域层 class EmailMessage { +string Email +string Title +string Body +EmailMessage(email, title, body) } %% 应用服务层(接口) class IFileReader { <<interface>> +string[] ReadAllLines(string path) } class IEmailSender { <<interface>> +void SendEmail(EmailMessage message) } class EmailService { -IFileReader _fileReader -IEmailSender _emailSender +EmailService(IFileReader, IEmailSender) +void SendEmailsFromFile(string filePath) } %% 基础设施层(实现) class FileReader { +string[] ReadAllLines(string path) } class ConsoleEmailSender { +void SendEmail(EmailMessage message) } class SmtpEmailSender { -string _smtpServer -int _port +SmtpEmailSender(string, int) +void SendEmail(EmailMessage message) } %% 用户界面层 class Program { +void Main(string[] args) } %% 依赖关系 EmailService --> IFileReader : 依赖 EmailService --> IEmailSender : 依赖 EmailService --> EmailMessage : 使用 FileReader ..|> IFileReader : 实现 ConsoleEmailSender ..|> IEmailSender : 实现 SmtpEmailSender ..|> IEmailSender : 实现 Program --> EmailService : 使用 Program --> FileReader : 创建 Program --> ConsoleEmailSender : 创建 %% 层标记 note for EmailMessage "领域层<br>(Domain)" note for IFileReader "应用服务层<br>(Application Services)" note for FileReader "基础设施层<br>(Infrastructure)" note for Program "用户界面层<br>(Presentation)"
  • 依赖注入流程图
sequenceDiagram participant UI as Program<br>(组合根) participant AS as EmailService<br>(应用服务) participant FR as FileReader participant ES as ConsoleEmailSender participant DM as EmailMessage Note over UI: 1. 依赖配置 UI->>FR: 创建 FileReader UI->>ES: 创建 ConsoleEmailSender UI->>AS: 注入依赖<br>new EmailService(FR, ES) Note over UI: 2. 执行用例 UI->>AS: 调用 SendEmailsFromFile("d:/text.txt") Note over AS: 3. 协调业务流程 AS->>FR: 调用 ReadAllLines() FR-->>AS: 返回文件内容 loop 每行数据 AS->>AS: 解析行数据 AS->>DM: 创建 EmailMessage 对象 AS->>ES: 调用 SendEmail(EmailMessage) ES->>ES: 执行发送逻辑 Note right of ES: Console.WriteLine(...) end Note over AS: 4. 返回结果 AS-->>UI: 邮件发送完成

DDD项目演示

  • 需求
- 一个包含用户管理、用户登录功能的微服务,系统的后台允许添加用户、解锁用户、修改用户密码等;
- 系统的前台允许用户使用手机号加密码进行登录,也允许用户使用手机号加短信验证码进行登录;
- 如果多次尝试登录失败,则账户会被锁定一段时间;为了便于审计,无论是登录成功的操作还是登录失败的操作,我们都要记录操作日志。
- 为了简化问题,这个案例中没有对于接口调用进行鉴权,也没有防暴力破解等安全设置。
  • 把整个项目拆分成三个项目
- User.Domain // 内层,编写模型和服务
	- 实体类
	- 事件
	- 防腐层接口
	- 仓储接口
	- 领域服务
	
- User.Infrastructure // 中层,基础设施
	- 实体类的配置(DbContext)
	- 防腐层接口实现
	- 仓储接口实现
	
- Users.WebAPI // 外层,用户界面
	- Controller
	- 事件(领域事件 && 集成事件响应类)
	

项目分层图

posted @ 2025-11-21 11:40  清安宁  阅读(22)  评论(0)    收藏  举报