C# Web开发教程(十四)DDD介绍
DDD(Domain-Driven Design)领域(模型)驱动设计
- 是一套软件开发的方法论和思想
- 核心思想: *软件的结构和代码应该反映出真实的业务领域,并且开发人员应该和业务专家(比如产品经理、领域专家)使用同一种“语言”来交流。
通俗理解(方言来打比方):
- 讲闽南语的同事:可能代表前端开发,他们关心的是页面组件、用户交互。
- 讲粤语的同事:可能代表后端开发,他们关心的是 API 接口、数据库性能。
- 讲客家话的产品经理:代表业务专家,他们关心的是用户故事、业务流程、商业价值。
- 运维、测试等成员:也都有自己领域的“方言”。
在项目初期,如果没有“统一语言”(普通话),就会出现这样的对话:
- 产品经理(客家话):“用户点击‘立即购买’,就给他‘生成一个单子’。”
- 后端开发(粤语):“哦,就是在
order表里insert一条记录,把cart里的items也insert到order_detail里。” - 前端开发(闽南语):“明白,我这边点按钮就跳转到那个
confirm.html页面,把itemList传过去。”
问题来了:
- 歧义:产品经理说的“单子”可能包含订单、支付单等多种含义,但开发人员直接理解为了数据库的
order表。 - 知识错位:业务逻辑(如“下单前必须校验库存”)可能被分散在前端、后端、甚至数据库的触发器中,没有人对“下单”这个完整的业务概念负责。
- 沟通成本高:每次开会都像是在做翻译,还容易出错。
引入 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. 适应业务变化
当银行推出新业务时:
- 数据库优先:需要修改表结构,影响很大
- 领域模型优先:只需在模型中添加新的业务概念,技术影响最小化
实际开发中的实践
创建领域模型的步骤:
- 事件风暴工作坊:与业务专家一起梳理业务流程
- 识别核心概念:找出实体、值对象、聚合根
- 定义统一语言:确保团队对业务术语理解一致
- 绘制模型图:可视化业务关系和流程
- 编码实现:最后才将模型转化为代码
银行领域的模型示例:
聚合根:账户(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("登录失败");
}
❗ 贫血模型的问题
- 业务逻辑分散:登录验证、积分计算等业务规则散落在各处
- 容易出错:如示例中登录失败却显示"登录成功"
- 数据完整性无法保证:属性都是公开的,可被随意修改
- 违反封装原则:外部代码需要了解内部实现细节
- 重复代码:同样的业务逻辑可能在多个地方重复实现
- 充血模型(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("登录失败");
}
✅ 充血模型的优点
- 高内聚:相关数据和操作封装在一起
- 强封装:内部状态受到保护,只能通过定义好的方法修改
- 业务规则集中:所有业务逻辑都在类内部,避免重复和错误
- 易于测试:可以单独测试每个业务方法
- 更好的可维护性:业务规则变化时只需修改一个地方
- 更符合面向对象设计:体现了"对象既有数据又有行为"的理念
🎯 核心区别总结
| 方面 | 贫血模型 | 充血模型 |
|---|---|---|
| 业务逻辑位置 | 分散在外部 | 封装在类内部 |
| 数据保护 | 属性公开,随意修改 | 属性受控,通过方法修改 |
| 代码复用 | 差,容易重复 | 好,逻辑集中 |
| 维护性 | 差,改动影响多处 | 好,改动局部化 |
| 面向对象 | 不符合 | 符合 |
推荐:在复杂的业务系统中,优先使用充血模型,它能更好地管理业务复杂度,保证数据一致性,并提高代码的可维护性。
充血模型实现的要求
-
属性是只读的,或者只能被类内部的代码修改- 实现: 把
属性的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(成员变量名)来配置属性
- 在配置实体类的代码中,使用
- EF Core提供
-
有的
属性不需要映射到数据列,仅在运行时被使用- 使用
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;
}
实际工作流程
- EF Core 查询数据 → 使用私有无参构造方法创建实例
- EF Core 设置属性 → 通过反射直接设置属性值
- 业务代码创建对象 → 使用有参构造方法,执行业务验证
// 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));
}
}
小结
关键要点:
- 聚合根是唯一入口:外部只能通过聚合根访问聚合内部
- DbContext设计:只暴露聚合根的DbSet,不暴露内部实体
- 标识聚合根:使用标记接口或基类明确标识
- 聚合边界:根据业务一致性要求划分聚合
- 引用方式:聚合间通过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}";
}
}
}
}
关键要点:
- 分离关注点:
聚合根负责注册事件,基础设施负责发布事件 - 保证一致性:
事件发布与数据库事务同步 - 灵活扩展:可以轻松添加新的事件处理器而不影响业务逻辑
- 便于测试:可以独立测试事件逻辑和业务逻辑
集成事件
-
应用范围: 微服务之间
- 比如订单微服务,物流微服务
领域事件是基于微服务内部之间的通讯,所以集成事件就是基于微服务之间的通讯- 通俗理解: 就是外部和外部之间的通知(服务器与服务器之间的通知)
领域事件 vs 集成事件
| 特性 | 领域事件 | 集成事件 |
|---|---|---|
| 作用范围 | 单个微服务内部 | 多个微服务之间 |
| 通信对象 | 同一服务内的不同组件 | 不同服务之间 |
| 实现方式 | 进程内事件总线(如 MediatR) | 进程间消息中间件 |
| 示例场景 | 用户注册后发送欢迎邮件 | 订单创建后通知物流服务 |
-
由于
集成事件是基于服务器之间的通信,所以必须借助于第三方服务器作为事件总线常见的消息中间件有
Redis,'RabbitMQ',Kafka,ActiveMQ等. -
RabbitMQ: 优秀的消息中间件库
- 信道(Channel): 它是
消息生产者,消费者和服务器进行通信的虚拟连接.- 特点:
- 建立在 TCP 连接之上
- TCP 连接资源消耗大,应尽量复用
- 信道可以快速创建和关闭
- 类比:TCP 连接是高速公路,信道是车道
- 特点:
- 队列(Queue): 消息排队的地方
消费者从队列中获取数据- 即使
消费者离线,消息也会保存在队列中 - 支持持久化存储
- 交换机(exchange): 把消息路由到一个或者多个队列中
- 工作方式: 消息 → 交换机 → 根据规则 → 队列
- 信道(Channel): 它是
RabbitMQ的routing模式(有好几种模式,这种常用)
routing模式的过程如下
- 生产者把消息发布到"交换机"中,并且该消息会携带一个"routingKey"属性
- 交换机根据"routingKey"的值把消息发送到一个或者多个队列
- 消费者从队列中获取消息
- 交换机和队列都位于RabbitMQ服务器内部
- 优点: 即使消费者不在线,和消费者相关的消息也会被保存到队列中
当消费者上线后,就可以获得离线期间错过的消息.
关键特性
- 消息持久化:消费者离线时消息不丢失
- 精准路由:通过 routingKey 指定目标队列
- 异步通信:生产者和消费者解耦
- 基本用法演示
- 安装 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);
以电商系统为例
集成事件的优势
- 解耦服务:服务间不直接调用
- 提高可用性:单个服务故障不影响整体
- 支持异步处理:提高系统吞吐量
- 便于扩展:新增消费者无需修改生产者
小结
- 集成事件是微服务架构中实现服务间异步通信的关键技术
- RabbitMQ 的 Routing 模式提供了灵活可靠的消息路由机制。
- 通过手动消息确认机制,可以确保消息被正确处理,避免数据丢失。
- 在实际应用中,需要根据业务需求合理设计交换机和队列的绑定关系,并考虑消息的持久化、错误处理和性能优化。
Zack.EventBus
- 简化版的
RabbitMQRabbitMQ的调用过程还是比较繁琐,所以大神写一个简便版的框架---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");
}
}
洋葱架构的优势在此示例中体现:
- 依赖方向:内层(领域模型、接口)不依赖外层,外层依赖内层接口
- 可测试性:可以轻松为
EmailService编写单元测试,使用模拟的IFileReader和IEmailSender - 可替换性:更换邮件发送方式(控制台→SMTP→第三方API)只需更换外层实现,不修改核心业务逻辑
- 关注点分离:
- 领域层:定义业务实体和规则
- 应用层:编排业务流程
- 基础设施层:处理技术细节
防腐层示例
如果邮件服务经常变化(如切换服务商),可以添加防腐层:
// 防腐层:统一邮件发送接口
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);
}
}
这样,当需要更换邮件服务商时,只需创建新的适配器并修改依赖注入配置,核心业务代码完全不受影响。
流程图如下
- 详细组件关系图
- 依赖注入流程图
DDD项目演示
- 需求
- 一个包含用户管理、用户登录功能的微服务,系统的后台允许添加用户、解锁用户、修改用户密码等;
- 系统的前台允许用户使用手机号加密码进行登录,也允许用户使用手机号加短信验证码进行登录;
- 如果多次尝试登录失败,则账户会被锁定一段时间;为了便于审计,无论是登录成功的操作还是登录失败的操作,我们都要记录操作日志。
- 为了简化问题,这个案例中没有对于接口调用进行鉴权,也没有防暴力破解等安全设置。
- 把整个项目拆分成三个项目
- User.Domain // 内层,编写模型和服务
- 实体类
- 事件
- 防腐层接口
- 仓储接口
- 领域服务
- User.Infrastructure // 中层,基础设施
- 实体类的配置(DbContext)
- 防腐层接口实现
- 仓储接口实现
- Users.WebAPI // 外层,用户界面
- Controller
- 事件(领域事件 && 集成事件响应类)


浙公网安备 33010602011771号