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()));
//接收消息放,等待信号
public class PostNotifHandler1 : NotificationHandler<PostNotification>
{
protected override void Handle(PostNotification notification)
{
Console.WriteLine("11111"+notification.Body);
}
}
//接收消息放,等待信号
public class PostNotifHandler1 : NotificationHandler<PostNotification>
{
protected override void Handle(PostNotification notification)
{
Console.WriteLine("11111"+notification.Body);
}
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
//在此处,等待触发,相当于消息的发送者
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服务器内部。优点:即使消费者不在线,消费者相关的消息也会被保存到队列中,当消费者上线之后,消费者就可以获取到离线期间错过的消息。