DDD(一)
DDD领域驱动模型
领域划分
核心域:解决项目的核心问题,和组织业务紧密相关。
支撑域:解决项目的非核心问题,具有组织特性,但不具有通用性。
通用域:解决通用问题,没有组织特性。
领域模型
事务脚本
界限上下文
实 体:
- ”标识符“用来唯一定位一个对象,在数据库中我们一般用表的主键来实现“标识符”。主键和标识符的思考
角度不同。 - 实体:拥有唯一的标识符,标识符的值不会改变,而对象的其他状态则会经历各种变化。标识符用来跟踪对象状态变化,一个实体的对象无论怎样变化,我们都能通过标识符定位这个对象。
- 实体一般的表现形式就是EF Core中的实体类。
值对象(Value Object)
- 值对象:没有标识符的对象,也有多个属性,依附于某个实体对象而存在。比如“商家”的地理位置、衣服的RGB颜色。
- 定义为值对象和普通属性的区别:体现整体关系。
聚合(Aggregate)
- 目的:高内聚,低耦合。有关系的实体紧密协作,而关系很弱的实体被隔离。
- 把关系紧密的实体放到一个聚合中,每个聚合中有一个实体作为聚合根(Aggregate Root),所有对于聚合内对象的访问都通过聚合根来进行,外部对象只能持有对聚合根的引用。
- 聚合根不仅仅是实体,还是所在聚合的管理者。
- 聚合的判断标准:实体是否是整体和部分的关系,是否存在相同的生命周期。
聚合延伸到服务
- 聚合中的实体中没有业务逻辑代码,只有对象的创建、对象的初始化、状态管理等个体相关的代码。
- 对于聚合内的业务逻辑,我们编写领域服务(Domain Service),而对于跨聚合协作以及聚合与外部系统协作的逻辑,我们编写应用服(ApplicationService) .
- 应用服务协调多个领域服务、外部系统来完成一个用例。
实体的逻辑代码
管理实体的创建,状态管理等非业务逻辑。
领域服务
聚合内的业务逻辑。
应用服务
聚合间的业务逻辑,和外部系统的业务逻辑。
仓储
按照要求从数据库中读取数据以及把领域服务修改的数据保存回数据库。
工作单元
工作单元内的代码要么全部成功执行,要么全部执行失败。
领域事件
继承事件
开闭原则
对扩展开放,对修改关闭。
领域事件
在同一个微服务内的聚合之间的事件传递。使进程内的通讯机制完成。
集成事件
跨微服务的事件传递。使用事件总线(EventBus)实现。
充血模型与贫血模型
贫血模型
一个类中只有属性或者成员变量,没有方法
充血模型
一个类中既有属性、呈椭圆变量、也有方法。
EF Core对实体属性的操作
EF Core在读写实体对象的属性时,会查找属性对应的成员变量,如果能找到,EF Core会直接读写这个成员变量的值,而不是通过set和get代码块来读写。
充血模型实现的要求
- 属性是只读的或者是只能被类内部的代码修改。
- 定义有参数的构造方法。
- 有的成员变量没有对应属性,但是这些成员变量需要映射为数据表中的列,也就是我们需要把私有成员变量映射到数据表中的列。
- 有的属性是只读的,也就是它的值是从数据库中读取出来的,但是我们不能修改属性值。
- 有的属性不需要映射到数据列,仅在运行时被使用。
实体在EFCore中的实现
internal class User
{
public int id {get;init}
public DateTime CreateDateTime{get;init }//初始化属性
public string UserName {get;private set;}
public int Credits{get;set;}
public string? passwordHash;//只在类中使用
public string? remark {get;}
public string? Remark{
get
{
return this.remark;
}
}
public string Tag {get;set;}
//无参的构造方法
//给EFCOre从数据库中加载数据然后生成User对象返回用的
private User()
{
}
//构造方法
public User(string UName)
{
this.UserName=UName;
this.CreatDateTime=DateTime.Now;
this.Credits=10;
}
public void ChangeUserName(string un)
{
//起到数据校验的作用
if(un.Length>5)
{
Console.WriteLine(".......");
return;
}
this.UserName=UName;
}
public void ChangePassword(string pwd)
{
this.passwordHash=pwd;
}
}
internal class UserConfig:IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.Property("passwordHash");
builder.Property(e=>e.Remark).HasField("remark");//让一个属性只从数据库中读出来
builder.Ignore(e=>e.Tag);//忽略属性
}
}
internal class MyDbContext:DbContext
{
public DbSet<User> Users{get;set;}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlServer
("Server=.;Database=dddl;Trusted_Connection=True;");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modeBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
}
EFCore中实现值对象
实体和值对象的区别是是否包含标识符
值对象是属于实体的一部分,并且不拥有标识符
internal class Entity
{
public int Id {get;set;}
public string Name {get;set;}
public CurrencyName Currency{get;set;}
}
enum CurrencyName
{
CNY,USD,NZD
}
internal class EntityConfig:IEntityTypeConfiguration<Entity>
{
public void Configurs(EntityTypeBuilder<Entity> builder)
{
//将写入数据库的枚举值从int转换为string
builder.Property(e=>e.Currency).HasConversion<string>();
}
}
从属实体类型的值对象的配置方法
(这里有个大坑,EF 7 下,值对象不能在DbContext里有DbSet
(EF7.0 location类需要加上Owned特性,不然会报没主键的错)
internal class Geo
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public Geo(double latitude, double longitude)
{
Latitude = latitude;
Longitude = longitude;
}
}
internal class Shop
{
public int Id { get; set; }
public string Name { get; set; }
public Geo Location { get; set; }
}
internal class ShopConfig : IEntityTypeConfiguration<Shop>
{
public void Configure(EntityTypeBuilder<Shop> builder)
{
//将Location属性Geo设置为从属于shop
builder.OwnsOne(c => c.Location);
}
}
聚合在.net中的实现
把关系强的实体,放到同一个聚合中,把其中一个实体作为“聚合根”,对于同一个聚合内的其他实体,都通过聚合根来调用。
工作单元(UnitOfWork)
我们在上下文中只为聚合根实体生命DbSet类型的属性。对非聚合根实体,值对象的操作都通过根实体进行。
跨表查询
所有跨聚合的数据查询都应该是通过领域服务的协作来完成的,而不应该是在数据库表之间进行join查询。会有性能损失,需要做权衡。
意思是不是各自聚合自己取各自内部的数据,不要跨表查询
对于统计。汇总等报表类的应用,则不需要遵循聚合的约束,可以通过执行原生SQL等方式进行跨表的查询。
领域事件的实现方式
中介者模式
使用进程内消息传递的开源库MediatR
mediatR支持 一个发布者对应一个处理者 和 一个发布者对应多个处理者 两种模式
MediatR用法
- 创建一个ASP.NET Core项目,NuGet安装MediatR.Extensions.Microsoft.DependencyInjection(已弃用)
- 我调用了MediatR
- Program.cs中调用AddMediatR()
- 定义一个在消息的发布者和处理者之间进行数据传递的类,这个类需要
实现lNotification接口。一般用record类型。 - 消息的处理者要继承NotificationHandler
接口,其中的泛型
参数TNotification代表此消息处理者要处理的消息类型。 - 在需要发布消息的的类中注入IMediator类型的服务,然后我们调用
Publish方法来发布消息。Send()方法是用来发布一对一消息的,而Publish()方
法是用来发布一对多消息的。
builder.Services.AddMediatR(cfg=>cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
namespace WebAppMediatRDemo
{
/// <summary>
/// 负责进行数据传递的类
/// 相当于快递员,在接收消息和传递消息之间来回传递消息
/// </summary>
public record PostNotification(string Body) : INotification;
}
//接收消息方,等待信号
public class PostNotifHandler1 : NotificationHandler<PostNotification>
{
protected override void Handle(PostNotification notification)
{
Console.WriteLine("11111"+notification.Body);
}
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
//在此处,等待触发,相当于消息的发送者,这里的Mediator是被注入进来的
Mediator.Publish(new PostNotification("Hello" + DateTime.Now));
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
知识补充
Singler
SignalR:当所连接的客户端变得可用时服务器代码可以立即向其推送内容,而不是让服务器等待客户端请求新的数据。实现实时服务器与客户端通信。是一个开源.NET 库生成需要实时用户交互或实时数据更新的 web 应用程序。
SignalR的出现,让页面通过javascript可以很简单的调用后端服务的方法,而在后端也可以很简单的直接调用javascript所实现的方法,前后端可以进行实时通信。实现了服务器主动推送(Push)消息到客户端页面,这样客户端就不必重新发送请求或使用轮询技术来获取消息。
注意:SignalR 会自动管理连接。客户端和服务器之间的连接是持久性的,不像传统的 HTTP 连接。
EFCore中发布领域事件的时机
public abstract class BaseEntity : IDomainEvent
{
[NotMapped]//使EF Core忽略此属性
private IList<INotification> events = new List<INotification>();
public void AdddomainEvent(INotification notif)
{
events.Add(notif);
}
public void ClearDomainEvents()
{
events.Clear();
}
public IEnumerable<INotification> GetDomainEvents()
{
return events;
}
}
public interface IDomainEvent
{
IEnumerable<INotification> GetDomainEvents();
void AdddomainEvent(INotification notif);
void ClearDomainEvents();
}
public class NewUserHandlercs : NotificationHandler<NewUserNotification>
{
protected override void Handle(NewUserNotification notification)
{
}
}
public class User : BaseEntity
{
public int Id { get; init; }
public DateTime CreatDateTime { get; init; }
public string UserName { get; private set; }
public int Credits { get; set; }
public User()
{
}
public User(string UN)
{
UserName = UN;
AdddomainEvent(new NewUserNotification(UN, this.CreatDateTime));//事件
}
public void ChangeUserName()
{
}
}
internal class MyDbContext : DbContext
{
private readonly IMediator? mediator;
public MyDbContext(IMediator? mediator)
{
this.mediator = mediator;
}
public DbSet<User> Users { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlServer
("Server=.;Database=EFCoreDemo;Trusted_Connection=True;");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
// 知道可以这样用就行了, 具体到自己的项目中,根据实际情况处理
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
//获取所有含有未发布事件的实体对象
var domainentities = this.ChangeTracker.Entries<IDomainEvent>().Where(e => e.Entity.GetDomainEvents().Any());
//获取所有待发布消息
var domainEvents=domainentities.SelectMany(e => e.Entity.GetDomainEvents()).ToList();
domainentities.ToList().ForEach(e => e.Entity.ClearDomainEvents());
foreach(var e in domainentities)
{
await mediator.Publish(e);
}
return await base.SaveChangesAsync(cancellationToken);
//可以加一个TransactionScope,在savechanges()之后进行发送事件,这样发送事件失败savaechanges也会回滚
}
}
集成事件的发布
微服务之间的通信
RabbitMQ的基本概念
1、集成事件是服务器间的通信,所以必须借助于第三方服务器作为事件总
线。常用的消息中间件有Redis、RabbitMQ、Kafka、ActiveMQ等。
2、RabbitMQ的基本概念:
1)信道(Channel):信道是消息的生产者、消费者和服务器进行通信的虚
拟连接。TCP连接的建立是非常消耗资源的,所以RabbitMQ在TCP连接的基础
上构建了虚拟的信道。我们尽量重复使用TCP连接,而信道则是可以用完了
就关闭。
2)队列(Queue):用来进行消息收发的地方,生产者把消息放到队列中,
消费者从队列中获取数据。
3)交换机(exchange):把消息路由到一个或者多个队列中。
RabbitMQ的routing模式
生产者把消息发布到交换机中,消息携带一个routingKey属性,交换机会根据routingKey的值
把消息发送到一个或者多个队列;消费者会从队列中获取消息;交换机和队列都位于RabbitMQ服务器内部。优点:即使消费者不在线,消费者相关的消息也会被保存到队列中,当消费者上线之后,消费者就可以获取到离线期间错过的消息。
洋葱架构(整洁架构)
本质上是面向接口编程
外层调动内层,内层反向隐式(通过依赖注入的形式)依赖内层
防腐层
外部服务(短信服务、邮件服务、存储服务等)的变化会比较频繁。把这些服务定
义为接口,在内层代码中我们只定义和使用接口,在外层代码中定义接口的实现。
体现的仍然是洋葱架构的理念。
模拟项目
需求
1、一个包含用户管理、用户登录功能的微服务,系统的后台允许添加用户、解锁用户、修改用户密码等;系统的前台允许用户使用手机号加密码进行登录,也允许用户使用手机号加短信验证码进行登录;如果多次尝试登录失败,则账户会被锁定一段时间;为了便于审计,无论是登录成功的操作还是登录失败的操作,我们都要记录操作日志。
2、为了简化问题,这个案例中没有对于接口调用进行鉴权,也没有防暴力破解等安全设置。
Users.Domain(领域模型)
实体类,抽象的实体,实体类事件的定义,防腐层,仓储接口,领域服务
Users.Infrastructure(基础设施)
模型具体的落实,实体类的配置,DbContext和数据库相关的代码,防腐层接口的实现,工具类,仓储接口的实现
Users.WebAPI(应用服务)
Controller,事件(领域事件和集成事件的响应类),
领域服务:聚合内的业务处理; 应用服务:聚合间的业务处理
原则
- 领域模型、领域服务中只是定义了抽象的实体、防腐层和仓储,我们需要在基础设施中对它们进行落地和实现。
- 实体类、值对象的定义是和持久机制无关的,而它们需要通过EFCore的配置、上下文等建立和数据库的关系。
- 上下文等也是和持久层相关的,也放到基础设施。
实体
- “用户”(User)实体类。没有基于Identity框架,正常应该使用,但是为了增加理解,没有使用。
- “用户登录失败次数过多则锁定”这个需求并不属于“用户”这个实体中一个常用的特征,因此我们应当把它拆分到一个单独的实体中,因此我们识别出来一个单独的“用户登录失败”(UserAccessFail)实体;
- “用户登录记录”(UserLoginHistory)也应该识别为一个单独的实体。
- 把User和UserAccessFail设计为同一个聚合,并且把User设置为聚合根;
- 有单独查询一段时间内的登录记录等这样独立于某个用户的需求,因此我们把UserLoginHistory设计为一个单独的聚合。
- DbContext要定义到基础设施层。
实现
- 应用层主要进行的是数据的校验、请求数据的获取、领域服务返回值的显示等处理,并没有复杂的业务逻辑,因为主要的业务逻辑都被封装在领域层。
- 应用层是非常薄的一层,应用层主要进行安全认证、权限校验、数据校验、事务控制、工作单元控制、领域服务的调用等。从理论上来讲,应用层中不应该有业务规则或者业务逻辑。
- 监听登录失败或者成功的领域事件UserAccessResultEvent,记录到
LoginHistory:repository.AddNewLoginHistoryAsync(phoneNum,msg);
手机号值对象
考虑到我们的系统可能被海外用户访问,而海外用户的手机号还需
要包含“国家/地区码”,因此我们设计了用来表示手机号的值对
象PhoneNumber。
public record PhoneNumber(int RegionCode,string Number);
强类型
yangzhongke
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理