Domain-Driven Design
DDD
1、DDD(Domain-driven design,领域驱动设计)诞生于 2004 年,兴起于 2014 年(微服务元年)是一个很好的应用于微服务架构的方法论。
2、在项目的全生命周期内,所有岗位的人员都基于对业务的相同的理解来开展工作。所有人员站在用户的角度、业务的角度去思考问题,而不是站在技术的角度去思考问题。
3、DDD 是方法论,不是行动指南。
领域与领域模型
领域
1、“领域”(Domain):一个组织做的事情。子领域。
2、领域的划分(以手机公司为例):
核心域:解决项目的核心问题,和组织业务紧密相关。
支撑域:解决项目的非核心问题,则具有组织特性,但不具有通用性。
通用域:解决通用问题,没有组织特性。
3、领域的不同分类决定了公司的研发重点。
4、举例:
手机公司核心域:手机设计,手机制造
支撑域:App 设计,销售,市场,售后
通用域:后勤,人事(可外包)
软件公司核心域:业务系统的开发
支撑域:运维,
通用域:数据库,操作系统,保安,IDC 数据中心
领域模型(Domain Model)
1、对于领域内的对象进行建模,从而抽象出来模型。以银行为例。
2、我们的项目应该开始于创建领域模型,而不是考虑如何设计数据库和编写代码。使用领域模型,我们可以一直用业务语言去描述和构建系统,而不是使用技术人员的语言。
事务脚本(X)
使用技术人员的语言去描述和实现业务事务。没有太多设计,没有考虑可扩展性、可维护性,流水账地编写代码。
事务脚本的问题:代码的可维护性、可扩展性非常差。比如如何增加“取款金额大于 5 万元需要主管审批”、“通知短信”等功能。
通用语言与界限上下文
通用语言
1、“我想要商品可以被删除”→“我想要把删除的还原回来”→“Windows 回收站都能”
2、此“用户”非彼“用户”。
3、通用语言:一个拥有确切含义的、没有二义性的语言。
界限上下文
通用语言离不开特定的语义环境,只有确定了通用语言所在的边界,才能没有歧义的描述一个业务对象。
比如对于后台管理系统用户是管理员;商城购物车用户是买家
实体与值对象
实体(Entity)
1、“标识符”用来唯一定位一个对象,在数据库中我们一般用表的主键来实现“标识符”。主键和标识符的思考角度不同。
2、实体:拥有唯一的标识符,标识符的值不会改变,而对象的其他状态则会经历各种变化。标识符用来跟踪对象状态变化,一个实体的对象无论怎样变化,我们都能通过标识符定位这个对象。
3、实体一般的表现形式就是 EF Core 中的实体类。
值对象(Value Object)
1、值对象:没有标识符的对象,也有多个属性,依附于某个实体对象而存在。比如“商家”的地理位置、衣服的 RGB 颜色。
2、定义为值对象和普通属性的区别:体现整体关系。
聚合与聚合根
聚合(Aggregate)
1、目的:高内聚,低耦合。有关系的实体紧密协作,而关系很弱的实体被隔离。
2、把关系紧密的实体放到一个聚合中,每个聚合中有一个实体作为聚合根(Aggregate Root),所有对于聚合内对象的访问都通过聚合根来进行,外部对象只能持有对聚合根的引用。
3、聚合根不仅仅是实体,还是所在聚合的管理者。
聚合的意义
1、为什么聚合可以实现“高内聚,低耦合”。
2、聚合体现的是现实世界中整体和部分的关系,比如订单与订单明细。整体封装了对部分的操作,部分与整体有相同的生命周期。部分不会单独与外部系统单独交互,与外部系统的交互都由整体来负责。
聚合的划分很难:
1、系统中很多实体都存在着不同程度的关系,这些关系到底是设计为聚合之间的关系还是聚合之内的关系是很难的。
2、聚合的判断标准:实体是否是整体和部分的关系,是否存在着相同的生命周期。
聚合的划分没有标准答案,不同的业务流程也就决定了不同的划分方式。
聚合的划分的原则
1、尽量把聚合设计的小一点,一个聚合只包含一个聚合根实体和密不可分的实体,实体中只包含最小数量的属性。
2、小聚合有助于进行微服务的拆分。
聚合宁愿设计的小一点,也不要设计的太大
领域服务与应用服务
1、聚合中的实体中没有业务逻辑代码,只有对象的创建、对象的初始化、状态管理等个体相关的代码。
2、对于聚合内的业务逻辑,我们编写领域服务(Domain Service),而对于跨聚合协作的逻辑,我们编写应用服务(Application Service)。
3、应用服务协调多个领域服务来完成一个用例。
领域服务:聚合内的业务逻辑
应用服务:聚合间和外部系统的业务逻辑
订单系统微服务、采购系统微服务、库存系统微服务
订单聚合:订单(聚合根实体)、订单明细(普通实体)
举例:
class 订单{
string id;
Datetime createtime;
int totalamount;
list<订单明细> items;
}
public 订单{
this.id=guid.newguid().tostring();
this.createtime=datetime.now();
}
public AddDetail(string shangpinId,int count){
//商品是否存在于 items,若存在则更新对应这条订单明细的 Count;不存在则向 items 中增加一个对象
}
class 订单明细{
string id;
string parentid;
string shangpinId;
int count;
}
最多买 100 个商品属于聚合内的业务逻辑:领域服务
订单保存数据库中交互属于外部系统:应用服务
DDD 典型用例的处理流程
第一步,准备业务操作所需要的数据。
第二步,执行由一个或者多个领域模型做出的业务操作,这些操作会修改实体的状态,或者生成一些操作结果。
第三步,把对实体的改变或者操作结果应用于外部系统。
职责的划分
1、领域模型与外部系统不会发生直接交互,即领域服务不会涉及数据库操作。
2、业务逻辑放入领域服务,而与外部系统的交互由应用服务来负责。
3、领域服务不是必须的,在一些简单的业务处理中(比如增删改查)是没有领域知识(也就是业务逻辑)的,这种情况下应用服务可以完成所有操作,不需要引入领域服务。这样可以避免过度设计。
“仓储”(Repository)和“工作单元”(Unit Of Work)
1、仓储负责按照要求从数据库中读取数据以及把领域服务修改的数据保存回数据库。
2、聚合内的数据操作是关系非常紧密的,我们要保证事务的强一致性,而聚合间的协作是关系不紧密的,因此我们只要保证事务的最终一致性即可。
3、聚合内的若干相关联的操作组成一个“工作单元”,这些工作单元要么全部成功,要么全部失败。
DBContext 是仓储的实现,savechanges 是工作单元的实现
领域事件与集成事件
事务脚本处理“事件”
1、“当发生某事件的时候,执行某个动作”。
2、当有人回复了用户的提问的时候,系统就向提问者的邮箱发送通知邮件。事务脚本的实现:
void 保存答案(long id,string answer)
{
保存到数据库(id,answer);
string email = 获取提问者邮箱(id);
发送邮件(email,”你的问题被回答了”);
}
问题 1:
代码会随着需求的增加而持续膨胀。比如增加功能“如果用户回复的答案中有涉嫌违法的内容,则先把答案隐藏,并且通知审核人员进行审核”。怎么做?
问题 2:
代码可扩展性低。比如把“发送邮件”改成“发送短信”,怎么办?
问题 3:
容错性差。外部系统并不总是稳定的。
采用事件机制的伪代码:
void 保存答案(long id,string answer)
{
long aId = 保存到数据库(id,answer);
发布事件(“答案已保存”,aId,answer);
}
[绑定事件(“答案已保存”)]
void 审核答案(long aId,string answer)
{
if(检查是否疑似违规(answer))
{
隐藏答案(aId);
发布事件(“内容待审核”,aId);
}
}
[绑定事件(“答案已保存”)]
void 发邮件给提问者(long aId,string answer)
{
long qId = 获取问题 Id(aId);
string email = 获取提问者邮箱(qId);
发送邮件(email,”你的问题被回答了”);
}
优点:关注点分离;容易扩展;容错性好;
两种事件:
1、DDD 中的事件分为两种类型:领域事件(Domain Events)和集成事件(Integration Events)。
2、领域事件:在同一个微服务内的聚合之间的事件传递。使用进程内的通信机制完成。
3、集成事件:跨微服务的事件传递。使用事件总线(EventBus)实现。
.Net 贫血模型与充血模型
概念
1、贫血模型:一个类中只有属性或者成员变量,没有方法。
2、充血模型:一个类中既有属性、成员变量,也有方法。
需求:定义一个类保存用户的用户名、密码、积分;用户必须具有用户名;为了保证安全,密码采用密码的散列值保存;用户的初始积分为 10 分;每次登录成功奖励 5 个积分,每次登录失败扣 3 个积分。
贫血模型
class User |
充血模型
class User |
username 只读;credit 外部无法 set
DBFirst 逆向工程对已有的数据库生成实体属于贫血模型
EFCore 对实体属性操作的秘密
1、EF Core 是通过实体对象的属性的 get、set 来进行属性的读写吗?
答案:基于性能和对特殊功能支持的考虑,EF Core 在读写属性的时候,如果可能,它会直接跳过 get、set,而直接操作真正存储属性值的成员变量。
Dog 类:
class Dog |


修改 Dog:
class Dog |

结论:
1、EF Core 会尝试按照命名规则去直接读写属性对应的成员变量,只有无法根据命名规则找到对应成员变量的时候,EF Core 才会通过属性的 get、set 代码块来读写属性值。
2(*)、可以在 FluentAPI 中通过 UsePropertyAccessMode()方法来修改默认的这个行为。
EFCore 充血模型的需求
充血模型实现的要求
一:属性是只读的或者是只能被类内部的代码修改。
二:定义有参数的构造方法。
三:有的成员变量没有对应属性,但是这些成员变量需要映射为数据表中的列,也就是我们需要把私有成员变量映射到数据表中的列。
四:有的属性是只读的,也就是它的值是从数据库中读取出来的,但是我们不能修改属性值。
五:有的属性不需要映射到数据列,仅在运行时被使用。
实现“一”:
属性是只读的或者是只能被类内部的代码修改。
实现:把属性的 set 定义为 private 或者 init,然后通过构造方法为这些属性赋予初始值。
实现“二”:
定义有参数的构造方法。
原理: EF Core 中的实体类如果没有无参的构造方法,则有参的构造方法中的参数的名字必须和属性的名字一致。为什么?
实现方式 1:无参构造方法定义为 private。
实现方式 2:实体类中不定义无参构造方法,只定义有意义的有参构造方法,但是要求构造方法中的参数的名字和属性的名字一致。
实现“二”:
定义有参数的构造方法。
原理: EF Core 中的实体类如果没有无参的构造方法,则有参的构造方法中的参数的名字必须和属性的名字一致。为什么?
实现方式 1:无参构造方法定义为 private。
实现方式 2:实体类中不定义无参构造方法,只定义有意义的有参构造方法,但是要求构造方法中的参数的名字和属性的名字一致。
实现“三”:
不属于属性的成员变量映射为数据列。
实现:
builder.Property(“成员变量名”)
实现“四”:
从数据列中读取值的只读属性。
EF Core 中提供了“支持字段”(backing field)来支持这种写法:在配置实体类的代码中,使用 HasField(“成员变量名”)来配置属性。
实现“五”:
有的属性不需要映射到数据列,仅在运行时被使用。
实现:使用 Ignore()来配置忽略这个属性。
EF Core 中实现充血模型
public record User |
id 自增只读,CreatedDateTime 由构造函数中初始化;
UserNameprivate set 通过 changeUserName 修改;
user 无参构造方法给 efcore 从数据库中加载数据生成 user;tag 不映射数据库
class UserConfig : IEntityTypeConfiguration<User> |
使用:
User u1 = new User("Zack"); |

EFCore 实现值对象
值类型的需求
1、“商品”实体中的重量属性。我们如果把重量定义为 double 类型,那么其实是隐含了一个“重量单位”的领域知识,使用这个实体类的开发人员就需要知道这个领域知识,而且我们还要通过文档等形式把这个领域知识记录下来,这又面临一个文档和代码修改同步的问题。
2、实现:定义一个包含 Value(数值)、Unit(单位)的 Weight 类型,然后把“商品”的重量属性设置为 Weight 类型。
3、很多数值类型的属性其实都是隐含了单位的,比如金额隐含了币种信息。
值类型的实现
1、“从属实体类型(owned entities)”:使用 Fluent API 中的 OwnsOne 等方法来配置。
2、在 EF Core 中,实体的属性可以定义为枚举类型,枚举类型的属性在数据库中默认是以整数类型来保存的。对于直接操作数据库的人员来讲,0、1、2 这样的值没有“CNY”(人民币)、“USD”(美元)、“NZD”(新西兰元)等这样的字符串类型值可读性更强。EF Core 中可以在 Fluent API 中用 HasConversion
比如 shop.cs 中地理位置:
internal class Shop{ |
配置 builder.OwnsOne(x=>x.location);
值类型 Geo 的实现:
record Geo{ |

比如 blog 标题文章多语言:
internal class Blog{ |
值类型 MultilingualString 的实现:
record MultilingualString(string Chinese, string? English); |
构建表达式树简化值对象比较
问题
ctx.Cities.Where(c=>c.Name==new MultilingualString(“北京”,“BeiJing”)) 不行。
改成 ctx.Cities.Where(c=>c.Name.Chinese== “北京”&&c.Name.English=”BeiJing”)
ExpressionHelper:
使用:Users.SingleOrDefaultAsync(MakeEqual((User u) => u.PhoneNumber, phoneNumber))
public class ExpressionHelper |
DDD 聚合在.NET 中的实现
工作单元的实现
1、复习:什么是 UnitOfWork(工作单元)。
2、EFCore 的 DbContext:跟踪对象状态的改变;SaveChanges 把所有的改变一次性地提交到数据库中,是一个事务。因此 DbContext 是天然的 UoW 实现。
3、需要把 DbContext 再封装一次 UoW 吗?两种观点分析:
观点 1:可能以后有切换其他 ORM 可能,所以封装 DbContext
观点 2:不需要封装,直接使用 DbContext
聚合与聚合根的实现
即使一个实体类型没有声明对应的 DbSet 类型的属性,只要 EF Core 遇到实体对象,EF Core 仍然会像对待其他实体对象一样处理。
因此我们可以在上下文中只为聚合根实体声明 DbSet 类型的属性。对非聚合根实体、值对象的操作都通过根实体进行。比如:
商品:
internal class Merchan{ |
订单:
internal class Order{ |
订单明细:
internal class OrderDetail{ |
Order 和 OrderDetail 组成聚合,聚合根实体 Orders 添加操作其他实体的方法。
聚合与 DbContext 的关系
1、如果一个微服务中有多个聚合根,那么是每个聚合根的实体放到一个单独的上下文中,还是把所有实体放到同一个上下文中?各自的优缺点是什么?
2、为什么倾向于后者?它们之间的关系仍然比它们和其他微服务中的实体关系更紧密,而且我们还会在应用服务中进行跨聚合的组合操作。进行联合查询的时候可以获得更好的性能,也能更容易实现强一致性的事务。
区分聚合根实体和其他实体
定义一个不包含任何成员的标识接口,比如 IAggregateRoot,然后要求所有的聚合根实体类都实现这个接口。
跨表查询
1、所有跨聚合的数据查询都应该是通过领域服务的协作来完成的,而不应该是直接在数据库表之间进行 join 查询。会有性能损失,需要做权衡,不是死规矩。
2、对于统计、汇总等报表类的应用,则不需要遵循聚合的约束,可以通过执行原生 SQL 等方式进行跨表的查询。
为什么划分聚合?便于以后进行微服务的拆分
每个微服务管理自己的数据库,有可能两个微服务用的是两个数据库:

性能损失可以添加冗余字段:
实现实体不要面向数据库建模
1、建模的时候不要先考虑实体在数据库中如何保存。比如实体类和数据表具有直接的对应关系,实体类中属性和数据表中的列几乎完全一致。这样设计出来的类称不上“实体类”,只能被成为数据对象(Data Object)。更不要用 DB First(反向工程)。
2、应该不考虑数据库实现的情况下进行领域模型建模,然后再使用 Fluent API 等对实体类和数据库之间做适配。在实现的时候,可能需要对建模进行妥协性修改,但是这不应该在最开始被考虑。
用 MediatR 实现领域事件
领域事件的实现选型
实现方式 1:C#的事件机制。
var bl = new ProcessBusinessLogic();
bl.ProcessCompleted += bl_ProcessCompleted;
bl.StartProcess();
缺点:需要显式地注册。
实现方式 2:
进程内消息传递的开源库 MediatR。事件的发布和事件的处理之间解耦。MediatR 中支持“一个发布者对应一个处理者”和“一个发布者对应多个处理者”这两种模式
MediatR 用法
1、创建一个 ASP.NET Core 项目,NuGet 安装 MediatR.Extensions.Microsoft.DependencyInjection
2、Program.cs 中调用 AddMediatR()
3、定义一个在消息的发布者和处理者之间进行数据传递的类,这个类需要实现 INotification 接口。一般用 record 类型。
4、消息的处理者要实现 NotificationHandler
5、在需要发布消息的的类中注入 IMediator 类型的服务,然后我们调用 Publish 方法来发布消息。Send()方法是用来发布一对一消息的,而 Publish()方法是用来发布一对多消息的。
Publish 异步与否
1、如果我们使用 await 的方式来调用 Publish 方法,那么程序会等待所有的消息处理者的 Handle 方法执行完成后才继续向后执行,因此消息的发布者和消息的处理者的代码是运行在相同的调用堆栈中的,这样我们可以轻松地实现强一致性的事务。
2、如果消息的发布者不需要等待消息处理者的执行,那么我们可以不用 await 方法来调用 Publish 方法。
EF Core 中发布领域事件的时机
领域事件的时机 1
1、在聚合根的实体对象的 ChangeName()、构造方法等方法中立即发布领域事件,因为无论是应用服务还是领域服务,最终要调用聚合根中的方法来操作聚合,我们这样做可以确保领域事件不会被漏掉。
2、缺点:
1)存在重复发送领域事件的情况;
2)领域事件发布的太早:在实体类的构造方法中发布领域事件,但是有可能因为数据验证没通过等原因,我们最终没有把这个新增的实体保存到数据库中,我们这样在构造方法中过早地发布领域事件就会导致“误报” 。
领域事件的时机 2
1、微软开源的 eShopOnContainers 项目中的做法:把领域事件的发布延迟到上下文保存修改时。实体中只是注册要发布的领域事件,然后在上下文 的 SaveChanges 方法被调用时,我们再发布事件。
2、供聚合根进行事件注册的接口 IDomainEvents
public interface IDomainEvents{ |
3、简化 IDomainEvents 实现的父类 BaseEntity.cs
EF Core 实现
public async override Task<int> SaveChangesAsync(...) |
案例实现
1、注册用户的时候给用户发送欢迎邮件。
2、修改用户信息的时候通知用户。
RabbitMQ 的基本使用
RabbitMQ 的基本概念
1、集成事件是服务器间的通信,所以必须借助于第三方服务器作为事件总线。常用的消息中间件有 Redis、RabbitMQ、Kafka、ActiveMQ 等。
2、RabbitMQ 的基本概念:
1)信道(Channel):信道是消息的生产者、消费者和服务器进行通信的虚拟连接。TCP 连接的建立是非常消耗资源的,所以 RabbitMQ 在 TCP 连接的基础上构建了虚拟的信道。我们尽量重复使用 TCP 连接,而信道则是可以用完了就关闭。
2)队列(Queue):用来进行消息收发的地方,生产者把消息放到队列中,消费者从队列中获取数据。
3)交换机(exchange):把消息路由到一个或者多个队列中。
RabbitMQ 的 routing 模式

生产者把消息发布到交换机中,消息携带一个 routingKey 属性,交换机会根据 routingKey 的值把消息发送到一个或者多个队列;消费者会从队列中获取消息;交换机和队列都位于 RabbitMQ 服务器内部。优点:即使消费者不在线,消费者相关的消息也会被保存到队列中,当消费者上线之后,消费者就可以获取到离线期间错过的消息。
基本用法
1、安装 RabbitMQ 服务器。
2、分别创建发送消息的项目和接收消息的控制台项目,这两个项目都安装 NuGet 包 RabbitMQ.Client。
集成事件框架
EventBus 使用
1、每次都使用 RabbitMQ 原始代码太麻烦。参考并改进了微软开源的 eShopOnContainers,开发了简化领域事件编程的开发包 EventBus,并且简化了以后迁移到其他 MQ 服务器的工作量。
2、使用步骤:
1)在 Program.cs 文件中的 builder.Build()上面增加对 IntegrationEventRabbitMQOptions 进行配置的代码以及对 AddEventBus 的调用,然后还要在 builder.Build()下面调用 UseEventBus()。
3)在需要发布领域事件的类中注入 IEventBus 服务,然后调用 IEventBus 的 Publish 方法发布消息。
4)创造一个实现了 IIntegrationEventHandler 接口的类,这个类用来处理收到的事件。通过[EventName(“UserAdded”)]设定类监听的事件。
3、JsonIntegrationEventHandler 和 DynamicIntegrationEventHandler。
4、RabbitMQ 等消息中间件的消息发布和消费的过程是异步的,也就是消息发布者将消息放入消息中间件就返回了,并不会等待消息的消费过程,因此集成事件不仅能够降低微服务之间的耦合度,也还能够起到削峰填谷的作用,避免一个微服务中的突发请求导致其他微服务雪崩的情况出现,而且消息中间件的失败重发机制可以提高消息处理的成功率,从而保证事务的最终一致性。
5、最终一致性的事务:需要开发人员对流程进行精细的设计,甚至有时候需要引入人工补偿操作。不像强一致性事务那样是纯技术方案。
6、其他类似开源项目:CAP。
分层架构和传统三层架构
1、分层架构:把各个组件按照“高内聚、低耦合”的原则组织到不同的项目中。
2、传统的经典三层架构:
三层架构的缺点:尽管由 DAL,但仍然是面向数据库的思维方式;对于一些简单的、不包含业务逻辑的增删改查类操作,仍然需要 BLL 进行转发;依赖关系是单向的,所以下一层中的代码不能使用上一层中的逻辑。
整洁架构(洋葱架构)
架构图

1、内层的部分比外层的部分更加的抽象 → 内层表达抽象,外层表达实现。
2、外层的代码只能调用内层的代码,内层的代码可以通过依赖注入的形式来间接调用外层的代码。
举例读取文件然后发送邮件:

外层 console app 依赖 内层 Intf1,内层 Intf1 运行时 DI,定义接口抽象。
防腐层(ACL:Anti Corruption Layer)
外部服务(短信服务、邮件服务、存储服务等)的变化会比较频繁。把这些服务定义为接口,在内层代码中我们只定义和使用接口,在外层代码中定义接口的实现,体现的仍然是洋葱架构的理念。
DDD 项目分层
需求
1、一个包含用户管理、用户登录功能的微服务,系统的后台允许添加用户、解锁用户、修改用户密码等;系统的前台允许用户使用手机号加密码进行登录,也允许用户使用手机号加短信验证码进行登录;如果多次尝试登录失败,则账户会被锁定一段时间;为了便于审计,无论是登录成功的操作还是登录失败的操作,我们都要记录业务操作日志。
2、为了简化问题,这个案例中没有对于接口调用进行鉴权,也没有防暴力破解等安全设置。

参考洋葱架构:


Domain(领域):
IAggregateRoot 声明聚合根;phoneNumber 值对象包含国家代码,手机号;
UserAccessResultEvent 领域事件;
ISmsCodeSender 短信防腐层接口;
IUserDomainReposity 仓储接口;
UserDomainService 领域服务体现业务逻辑。
Infrastructure(基础设施):
Configs 实体类配置;DbContext;工具类;防腐层接口实现;仓储接口实现
WebApi
Controller、事件(领域事件、集成事件)响应类
技术选型
对于 ASP.NET Core Web API 项目来讲,是否需要拆分出应用服务和用户界面层?
1)有的人认为前端代码是用户界面,而 Web API 的控制器的代码就是应用服务;(本文选择)
2)有的人认为控制器也是一种用户界面,因此需要再拆分出来一个应用服务层,由控制器再调用应用服务层。
领域模型的实现
实体
1、“用户”(User)实体类;
2、“用户登录失败次数过多则锁定”这个需求并不属于“用户”这个实体中一个常用的特征,因此把它拆分到一个单独的实体中,识别出来一个单独的“用户登录失败”(UserAccessFail)实体;
3、“用户登录记录”(UserLoginHistory)也应该识别为一个单独的实体。
4、把 User 和 UserAccessFail 设计为同一个聚合,并且把 User 设置为聚合根;
5、有单独查询一段时间内的登录记录等这样独立于某个用户的需求,因此把 UserLoginHistory 设计为一个单独的聚合。
6、DbContext 定义到基础设施层
手机号值对象
考虑到系统可能被海外用户访问,而海外用户的手机号还需要包含“国家/地区码”,因此设计了用来表示手机号的值对象 PhoneNumber。
public record PhoneNumber(int RegionCode,string Number);
用户:
public record User : IAggregateRoot |
用户登录失败:
public record UserAccessFail |
Fail()、Reset()等方法都只是修改实体的属性,并没有写入数据库的操作。
public record UserLoginHistory : IAggregateRoot |
UserLoginHistory 的 UserId 属性是一个指向 User 实体的外键,但是在物理上,我们并没有创建它们的外键关系
领域服务的实现
仓储接口
1、仓储接口的定义放在领域层中。不建议用通用 CRUDRepository,避免陷入“伪 DDD”。
public interface IUserDomainRepository |
防腐层接口
public interface ISmsCodeSender |
领域层服务
public enum UserAccessResult |
基础设施的实现
原则
1、领域模型、领域服务中只是定义了抽象的实体、防腐层和仓储,我们需要在基础设施中对它们进行落地和实现。
2、实体类、值对象的定义是和持久机制无关的,而它们需要通过 EF Core 的配置、上下文等建立和数据库的关系。
3、上下文等也是和持久层相关的,也放到基础设施。
Infrastracture 引用 Domain
UserConfig
class UserAccessFailConfig : IEntityTypeConfiguration<UserAccessFail> |
EFCore 对于实体之间的关系外键是强制生成的。
1)写 SQL 脚本删除外键
2)DDD 聚合内的外键建议保留,聚合之间没有外键
实现 IUserDomainRepository
public class UserDomainRepository : IUserDomainRepository |
工作单元的实现
原则
1、工作单元是由应用服务层来确定,其他层不应该调用 SaveChangesAsync 方法保存对数据的修改。
2、可以开发一个在控制器的方法调用结束后自动调用 SaveChangesAsync 的 Filter:UnitOfWorkAttribute、UnitOfWorkFilter。
[] |
params 可选参数,使用[UnitOfWork(typeof(UserDbContext))]
UnitOfWorkFilter.cs:
private static UnitOfWorkAttribute? GetUoWAttr(ActionDescriptor actionDesc) { |
Program.cs 注册:
builder.Services.Configure
opt.Filters.Add
});
应用层的实现
原则
1、应用层主要进行的是数据的校验、请求数据的获取、领域服务返回值的显示等处理,并没有复杂的业务逻辑,因为主要的业务逻辑都被封装在领域层。
2、应用层是非常薄的一层,应用层主要进行安全认证、权限校验、数据校验、事务控制、工作单元控制、领域服务的调用等。从理论上来讲,应用层中不应该有业务规则或者业务逻辑。
3、监听登录失败或者成功的领域事件 UserAccessResultEvent,记录到 LoginHistory:
repository.AddNewLoginHistoryAsync(phoneNum,msg);
控制器
控制器实现根据手机号和密码进行登录:
public async Task<IActionResult> LoginByPhoneAndPwd(LoginByPhoneAndPwdRequest req) |
新增用户:
public async Task<IActionResult> AddNew(PhoneNumber req) |
对于增删改查等这种简单的业务场景没必要拘泥于 DDD 的原则。这也是洋葱架构的优点,仅供参考。
参考链接
https://github.com/yangzhongke
https://github.com/dotnet-architecture/eShopOnContainers
.Net Microservices 实践
微服务
单体结构项目缺点:
耦合;技术栈统一,软件包版本锁定;一崩全崩;升级周期长;无法局部扩容;
微服务结构项目:
微服务结构优缺点:
优点:耦合性低,易于开发和维护;可以用不同技术栈;可以单独扩容;互相隔离,影响小;部署周期短;
缺点:对运维能力要求高;运行效率会降低;技术要求高,需要处理事务最终一致性等问题。
微服务的误区:
微服务架构应该是进化而来的;微服务的拆分进化。避免使用微服务,除非有充足的理由。——杨中科
架构图

To be continued
参考链接
https://github.com/yangzhongke
https://github.com/binarythistle
JCB VNext - DDD 实践
需求说明
1、功能:听力练习。
2、业务概念:类别(Category)、专辑(Album)、片段(Episode)。
3、听力原文字幕文件查看。
4、网站后台允许进行资源的 CRUD。
5、其他格式的音频的追踪在部分浏览器上有问题,统一用 M4A。
6、音频文件放到单独的文件服务器上。
7、原文的搜索。
网站结构分析

项目整体结构
1、不同服务的项目放到不同的解决方案文件夹下
2、解决方案文件夹 Commons 下的项目是一些公用的类库,大家可以通过 Nuget 包使用这些项目。
3、各服务的解决方案文件夹下都包含 Domain、Infrastructure、WebAPI 这 3 个项目,它们分别对应领域层、基础设施、应用层。
4、因为所有的项目都用到了领域事件、集成事件、中心配置服务器、JWT、工作单元、CORS、FluentValidation 等,因此作者开发了 CommonInitializer 项目用来复用这些组件的初始化代码。
5、注意:在类库项目中不能直接使用 WebApplicationBuilder、IApplicationBuilder、IWebHostEnvironment 等类,不能通过 Nuget 安装。
请在 csproj 中添加
项目 类 说明
Jcb.ASPNETCore DistributedCacheHelper 分布式缓存帮助类
MemoryCacheHelper 内存缓存帮助类
UnitOfWorkFilter 工作单元筛选器
Jcb.Commons
Validators 文件夹 FluentValidation 的扩展类
LoggerExtensions 使用 FormattableString 简化日志的代码
ModuleInitializerExtensions 把服务注册代码放到各自的项目中
Jcb.DomainCommons IAggregateRoot 聚合根标识接口
BaseEntity、AggregateRootEntity 领域事件的发布
MultilingualString 多语言值对象
Jcb.EventBus 集成事件总线
Jcb.Infrastructure BaseDbContext 领域事件的发布
EFCoreInitializerHelper 上下文的自动化注册
ExpressionHelper 简化值对象的相等性比较
MediatorExtensions 领域事件的注册
MultilingualStringEFCoreExtensions 多语言值对象的配置
Jcb.JWT 使用 JWT 实现登录令牌
运行环境搭建
1、项目使用 Nginx 做网关、用 Redis 做分布式缓存、用 RabbitMQ 实现领域事件、用 Elastic Search 做搜索引擎服务器。前端项目访问 nginx 使用 https,网关访问微服务用 http 即可。
2、在生产环境下一般会把不同的服务放到不同的服务器上,因此不会有多个 ASP.NET Core 网站同时运行造成的端口冲突问题,但是如果需要在 Visual Studio 中同时运行多个 ASP.NET Core 项目就可能会遇到这些项目的端口冲突的问题。修改 ASP.NET Core 项目的 Properties 文件夹下的 launchSettings.json 文件,在 iisExpress 节点下的配置指定项目运行的端口。
3、分别让 FileService.WebAPI、IdentityService.WebAPI、Listening.Admin.WebAPI、Listening.Main.WebAPI、MediaEncoder.WebAPI、SearchService.WebAPI 运行在 44339、44392、44352、44375、44353、44310 端口下。
4、目标:访问 https://localhost/IdentityService/来访问 IdentityService 的接口,其他服务是一样的,前端就可以通过统一的端口来访问后端服务。
配置 Nginx 来反向代理后端的接口,以 IdentityService 服务为例,在 Nginx 的 nginx.conf 文件中的 server 节点下增加:
location /IdentityService/ {
proxy_pass https://localhost:44392/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
proxy_set_header 是用来方便在 ASP.NET Core 中获取客户端 IP 地址等使用的,需要配合 ForwardedHeaders 中间件使用。








