ASP.NET Core学习之路04

本文章是我听B站杨中科的所做的笔记

杨中科B站视频链接:.NET 6教程,.Net Core 2022视频教程,杨中科主讲_哔哩哔哩_bilibili

软件架构设计的坑

架构设计之怪现状

1、“迷信大公司”

2、“迷信流行技术”。坑老板指南

3、应该怎么做

架构是进化而来的

1、淘宝的进化故事

2、很多项目第一天就是奔着淘宝去的,然后。。。

3、“最小的可行性产品”MVP;“演进式结构”

4、软件退化以及如何预防

什么是微服务

单体结构项目

缺点:耦合;技术栈统一,软件包版本锁定;一蹦全崩;升级周期长;无法局部扩容

ASP.NET Core学习之路04_第1张图片

微服务结构项目

ASP.NET Core学习之路04_第2张图片

微服务结构优缺点:

优点:耦合性低,易于开发和维护;可以用不同技术栈;可以单独扩容;相互隔离,影响小;部署周期短; 缺点:对运维能力要求高;运行效率会降低;技术要求高,需要处理事务最终一致性等问题

微服务的误区

ASP.NET Core学习之路04_第3张图片

微服务架构应该是进化而来的;微服务的拆分进化

微服务第一定律:避免使用微服务,除非用充足的理由。---杨中科

什么是DDD,应该怎么学

什么是DDD

1、DDD(Domain-driven design,领域驱动设计)是一个很好的应用于微服务架构的方法论

2、在项目的全生命周期内,所有岗位的人员都基于对业务的相同的理解来开展工作。所有人员站在用户的角度、业务的角度去思考问题,而不是站在技术的角度去思考问题

3、诞生于2004年,兴起于2014(微服务元年)

DDD之难

1、DDD晦涩难懂,难以落地,因为DD是方法论,不是行动指南

2、“盐少许,油少许”,每个人对DDD的理解和落地都不同,而且没有绝对的对错

3、如果只学习DDD概念而没有了解如何应用的话,会感觉没有落地;而如果过早关注落地的话,会导致理解片面

DDD学习之道

1、正确姿势:“从理论到实践,从实践再到理论。。。。”讲课顺序:把概念讲解和技术落地分开。why?

2、不要一下子学DDD的整体。不同岗位、不同阶段的人先从自己的角度学习DDD的一部分

ASP.NET Core学习之路04_第4张图片

DDD之领域与领域模型

领域

1、“领域"(Domain):一个组织做的事情。子领域

2、领域的划分(以手机公司为例): 核心域:解决项目的核心问题,组织业务紧密相关 支撑域:解决项目的非核心问题,则具有组织特性,但不具有通用性 通用域:解决通用问题,没有组织特性

3、领域的不同分类决定了公司的研发重点

领域模型

1、对于领域内的对象进行建模,从而抽象出来模型。以银行为例。

2、我们的项目应用开始于创建领域模型,而不是考虑如何设计数据库和编写代码。使用领域模型,我们可以一直用业务语言去描述和构建系统,而不是使用技术人员的语言

事务脚本(×)

使用技术人员的语言去描述和实现业务事务。没有太多设计,没有考虑可扩展性,可维护性,流水账地编写代码

ASP.NET Core学习之路04_第5张图片

事务脚本的问题:代码的可维护性、可扩展性非常差。比如如何增加”取款金额大于5万元需要主管审批“、”通知短信“等功能

DDD之通用语言、界限上下文

通用语言

1、”我想要商品被删除“=》”我想要把删除的还原回来“=》”Windows回收站都能“

2、此”用户“非彼“用户”

3、通用语言:一个拥有确切含义,没有二义性的语言

界限上下文

通用语言离不开特定的语义环境,只有确定了通用语言所在的边界,才能没有歧义的描述一个业务对象

DDD之实体、值对象

实体

1、“标识符”用来唯一定位一个对象,在数据库中我们一般用表的主键来实现“标识符”。主键和标识符的思考角度不同

2、实体:拥有唯一的标识符,标识符的值不会改变,而对象的其他状态则会经历各种变化。标识符用来跟踪对象状态变化,一个实体的对象无论怎样变化,我们都能通过都能通过标识符定位这个对象

3、实体一般的表现形式就是EF Core中的实体类

值对象

1、值对象:没有标识符的对象,也有很多的属性,依附于某个实体对象而存在。比如“商家”的地址位置、衣服的RGB颜色

2、定义为值对象和普通属性的区别:体现整体关系

DDD之聚合、聚合根

聚合

1、目的:高内聚、低耦合。有关系的实体紧密协作,而关系很弱的实体被隔离

2、把关系紧密的实体放到一个聚合中,每个聚合中由一个实体作为聚合根(Aggregate root),所有对于聚合内对象的访问都通过聚合根来进行,外部对象只能持有对聚合根的引用

3、聚合根不仅仅是实体,还是所在聚合的管理者

聚合的意义

1、为什么聚合可以实现“高内聚、低耦合”

2、聚合体现的是现实世界中整体和部分的关系,比如订单与订单明细。整体封装了对部分的操作,部分与整体有相同的生命周期。部分不会单独与外部系统单独交互,与外部系统的交互都由整体来负责

聚合的划分很难

1、系统中很多实体都存在着不同程度的关系,这些关系到底是设计为聚合之间的关系还是聚合之内的关系是很难的

2、聚合的判断标准:实体是否是整体和部分的关系,是否存在着相同的生命周期

3、订单与订单明细?用户与订单?

聚合的划分没有标准答案

1、不同的业务流程也就决定了不同的划分方式

2、新闻和新闻的评论?

聚合的划分的原则

1、尽量把聚合设计的小一点,一个聚合只包含一个聚合根实体和密不可分的实体,实体中包含最小数量的属性

2、小聚合有助于进行微服务的拆分

聚合宁愿设计的小一点也不要设计的太大

DDD之领域服务与应用服务

概念

1、聚合中的实体中没有业务逻辑代码,只有对象的创建、对象的初始化、状态管理等个体相关的代码

2、对于聚合内的业务逻辑,我们编写领域服务(Domain Service),而对于跨聚合协作以及聚合与外部系统协作的逻辑,我们编写应用服务(Application Service)

3、应用服务协调多个领域服务、外部系统来完成一个用例

DDD典型用例的处理流程

第一步:准备业务操作所需要的数据 第二步:执行由一个或者多个领域模型做出的业务操作,这些操作会修改实体的状态,或者生成一些操作结果 第三步:把实体的改变或者操作结果应用于外部系统

职责的划分

1、领域模型与外部系统不会发生直接交互,即领域模型不会涉及数据库操作

2、业务逻辑放入领域服务,而与外部系统的交互由应用服务负责

3、领域服务不是必须,在一些简单的业务处理中(比如增删改查)是没有领域知识(也就是业务逻辑)的,这种情况下应用服务可以完成所有操作,不需要引入领域服务。这样可以避免过度设计

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

1、仓储负责按照要求从数据中读取数据以及把领域服务修改的数据保存回数据库

2、聚合内的数据操作是关系非常紧密的,我们要保证事务的强一致性,而聚合间的协作式关系不紧密的,因此我们只要保证事务的最终一致性即可

3、聚合内的若干相关联的操作组成一个“工作单元”,这些工作单元要么全部成功,要么全部失败

DDD之领域事件、集成事件

事务脚本处理“事件”

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 Core的贫血模型与充血模型

概念

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

2、充血模型:一个类中既有属性、成员变量,也有方法 需求:定义一个类保存用户的用户名、密码、积分;用户必须具有用户名;为了保证安全,密码采用密码的散列值保存;用户的初始积分为10分;每次登录成功奖励5个积分,每次登录失败扣3个积分。

贫血模型

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("登录失败");
}

代码的问题是?

充血模型

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("登录失败");
}

代码的优点是什么?

EF Core对实体属性操作的密码

引言

1、Why?为EF Core实现充血模型做准备。

2、EF Core是通过实体对象的属性的get、set来进行属性的读写吗?

3、答案:基于性能和对特殊功能支持的考虑,EF Core在读写属性的时候,如果可能,它会直接逃过get、set,而直接操作真正存储属性值的成员变量

Dog类

class Dog
{
    public long Id { get; set; }
    private string name;
    public string Name 
    { 
        get
        {
            Console.WriteLine("get被调用");
            return name;
        }
        set 
        {
            Console.WriteLine("set被调用");
            this.name = value; 
        } 
    }
}
Dog d1 = new Dog { Name= "goofy" };
Console.WriteLine("Dog初始化完毕");
ctx.Dogs.Add(d1);
ctx.SaveChanges();
Console.WriteLine("SaveChanges完毕");
​
Console.WriteLine("准备读取数据");
Dog d2 = ctx.Dogs.First(d=>d.Name== "goofy");
Console.WriteLine("读取数据完毕");

结论

EF Core在读写实体对象的属性时,会查找属性对应的成员变量,如果能能找到,EF Core会直接读写这个成员变量的值,而不是通过set和get代码块来读写

改一下Dog类

class Dog
{
    public long Id { get; set; }
    private string xingming;
    public string Name 
    { 
        get
        {
            Console.WriteLine("get被调用");
            return xingming;
        }
        set 
        {
            Console.WriteLine("set被调用");
            this.xingming = value; 
        } 
    }
}

结论

1、EF Core会尝试按照命名规则去直接读写属性对应的成员变量,只有无法根据命名规则找到对应成员变量的时候,EF Core才会通过属性的get、set代码块来读写属性值

2(*)、可以在FluentAPI中通过UsePropertyAccessMode()方法来修改默认的这个行为

EF Core中充血模型的需求

充血模型实现的要求

一:属性是只读的或者是只能被类内部的代码修改

二:定义有参的构造方法

三:有的成员变量没有对应属性,但是这些成员变量需要映射为数据表中的列,也就是我们需要把私有成员变量映射到数据表中的列

四:有的属性是只读的,也就是它的值是从数据库中读取出来,但是我们不能修改属性值

五:有的属性不需要映射到数据列,仅在运行时使用

实现“一”

属性是只读的或者只能被类内部的代码修改。 实现:把属性的set定义为private或者init,然后通过构造方法为这些属性赋值初始值

实现“二”

定义有参数的构造方法 原理:EF Core中的实体类如果没有无参的构造方法,则有参的构造方法中的参数的名字必须和属性的名字一致。为什么?

实现方式1:无参构造方法定义为private

实现方式2:实体类中不定义无参构造方法,只定义有意义的有参构造方法,但是要求构造方法中的参数的名字和属性的名字一致

实现“三”

不属于属性的成员变量映射为数据列。 实现:builder.Property("成员变量名")

实现“四”

从数据列中读取值的只读属性 EF Core中提供了“支持字段”(backing field)来支持这种写法:在配置实体类的代码中,使用HasField("成员变量名")类配置属性

实现“五”

有的属性不需要映射到数据列,仅在运行时被使用。 实现:使用Ignore()来配置忽略这个属性

EF Core中实现充血模型

public record User
{
    public int Id { get; init; }//特征一
    public DateTime CreatedDateTime { get; init; }//特征一
    public string UserName { get; private set; }//特征一
    public int Credit { get; private set; }
    private string? passwordHash;//特征三
    private string? remark;
    public string? Remark //特征四
    {
        get { return remark; } 
    }
    public string? Tag { get; set; }//特征五
    private User()//特征二
    {
    }
    public User(string yhm)//特征二
    {
        this.UserName = yhm;
        this.CreatedDateTime = DateTime.Now;
        this.Credit = 10;
    }
    public void ChangeUserName(string newValue)
    {
        this.UserName = newValue;
    }
    public void ChangePassword(string newValue)
    {
        if (newValue.Length < 6)
        {
            throw new ArgumentException("密码太短");
        }
        this.passwordHash = HashHelper.Hash(newValue);
    }
}
class UserConfig : IEntityTypeConfiguration
{
    public void Configure(EntityTypeBuilder builder)
    {
        builder.Property("passwordHash");//特征三
        builder.Property(u => u.Remark).HasField("remark");//特征四
        builder.Ignore(u => u.Tag);//特征五
    }
}
User u1 = new User("Zack");
u1.Tag = "MyTag";
u1.ChangePassword("123456");
ctx.Users.Add(u1);
ctx.SaveChanges();
​
User u1 = ctx.Users.First(u=>u.UserName=="Zack");
Console.WriteLine(u1);

EF Core中实现值对象

值类型的需求

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中可以在FluentAPI中用HasConversion()把枚举类型的值配置成保存为字符串

值类型GEO的实现

record Geo{
    public double Longitude { get; init; }
    public double Latitude { get; init; }
    public Geo(double longitude, double latitude)
    {
        if(longitude<-180||longitude>180)
            throw new ArgumentException("longitude invalid");
        if (latitude < -90 || latitude > 90)
            throw new ArgumentException("longitude invalid");
        this.Longitude = longitude;
        this.Latitude = latitude;
    }
}
builder.OwnsOne(c=>c.Location);

值类型MultilinguaString的实现

record MultilingualString(string Chinese, string? English);
​
builder.OwnsOne(c=>c.Name, nb => {
    nb.Property(e=>e.English).HasMaxLength(20).IsUnicode(false);
    nb.Property(e=>e.Chinese).HasMaxLength(20).IsUnicode(true);
});

构建表达式树简化对象比较

比较的麻烦

ctx.Cities.Where(c=>c.Name==new MultilingualString(“北京”,“BeiJing”)) //不行。
//怎么办?
ctx.Cities.Where(c=>c.Name.Chinese== "北京"&&c.Name.English="BeiJing")

代码

Zack.Infrastructure/EFCore/ExpressionHelper.cs

DDD聚合在.NET中的实现

工作单元的实现

1、复习:什么是UnitOfWork(工作单元)

2、EF Core的DbContext:跟踪对象状态的改变;SaveChanges把所有的改变一次性地提交到数据库中,是一个事务。因此DbContext是天然的UoW实现

聚合与聚合根的实现

即使一个实体类型没有声明对应的DbSet类型的属性,只要EF Core遇到实体对象,EF Core仍然会像对待其他实体对象一样处理 因此我们可以在上下文中只为聚合根实体声明DbSet类型的属性。对非聚合根实体、值对象的操作都通过根实体进行 跨聚合只能引用根实体的Id,而不是跟实体对象

聚合与DbContext的关系

1、如果一个微服务中又多个聚合根,那么是每个聚合根的实体放一个单独的上下文中,还是把所有实体放到同一个上下文中?各自的优缺点是什么?

2、我为什么倾向于后者?它们之间的关系仍然比它们和其他微服务中的实体关系更紧密,而且我们还会在应用服务中进行跨聚合的组合操作。进行联合查询的时候可以获得更好的性能,也能更容易实现强一致性的事务

区分聚合根实体和其他实体

定义一个不包含任何成员的标识接口,比如IAggregateRoot,然后要求所有的聚合根实体类都实现这个接口

跨表查询

1、所有跨聚合的数据查询都应该是通过领域服务的协作来完成的,而不应该是直接在数据库表之间进行join查询。会有性能损失,需要做权衡,不是死规矩

2、对于统计、汇总等报表类额应用,则不需要遵循集合的约束,可以通过执行原生SQL等方式进行跨表的查询

实现实体不要面向数据库建模

1、建模的时候不要先考虑实体在数据库中如何保存。比如实体类和数据表具有直接的对应关系,实体类中属性和数据表中的列几乎完全一致。这样设计出来的类称不上“实体类”,只能成为数据对象(Data Object)。更不要用DBFirst(反向工程)

2、应该不考虑数据库实现的情况下进行领域模型建模,然后再使用Fluent API等对实体类和数据库之间做适配。在实现的时候,可能需要对建模进行妥协性修改,但是这不应该在最开始被考虑

案列

后面有一个专门的小案例综合演示DDD的实现,包括聚合、聚合根等

用MediatR实现领域事件

领域事件的实现选型

1、复习:什么是领域事件?进程内。。。。

2、实现方式1:C#的事件机制。

var bl = new ProcessBusinessLogic();
bl.ProcessCompleted += bl_ProcessCompleted;
bl.StartProcess();

缺点:需要显示地注册

3、实现方式2:进程内消息传递的开源库MediatR。事件的发布和事件的处理之间解耦。MediatR中支持”一个发布者对应一个处理者“和”一个发布者对应多个处理者“这两种模式

MediatR用法

1、创建一个ASP.NET Core项目,NuGet安装:MediatR.Extensions.Microsoft.DependencyInjection

2、Program.cs中调用AddMediatR()

3、定义一个在消息的发布者和处理者之间进行数据传递的类,这个类需要实现INotification接口。一般用record类型

4、消息的处理者要实现NotificationHandler接口,其中的泛型参数TNotification代表此信息处理者要处理的消息类型

5、在需要发布消息的类中注入IMediator类型服务,然后我们调用Publish方法来发布消息。Send()方法是用来发布一对一消息的,而Publish()方法是用来发布一对多消息的

EF Core中发布领域事件的时机

领域事件的时机1

1、在聚合根的实体对象的ChangName()、构造方法等方法中立即发布领域事件,因为无论是应用服务还是领域服务,最终要调用聚合根中的方法来操作聚合,我们这样做可以确保领域事件不会被漏掉

2、缺点:

1)存在重复发送领域事件的情况;

2)领域事件发布的太早:在实体类的构造方法中发布领域事件,但是有可能因为数据验证没通过等原因,我们最终没有这个新增的实体保存到数据库中,我们这样在构造方法中过早地发布领域事件就会导致”误报“。

领域事件的时机2

1、微软开源的eShopOnContainers项目中的做法:把领域事件的不发延迟到上下文保存修改时。实体中只是注册要发布的领域事件,然后再上下文的SaveChanges方法被调用时,我们再发布事件。

2、供聚合根进行事件注册的接口IDomainEvents Zack.DomainCommons/Models/IDomainEvents.cs

public interface IDomainEvents
{
    IEnumerable GetDomainEvents();
    void AddDomainEvent(INotification eventItem);
    void AddDomainEventIfAbsent(INotification eventItem);
    void ClearDomainEvents();
}

3、简化IDomainEvents实现的父类BaseEntity.cs

EF Core实现

Zack.Infrastructure/EFCore/BaseDbContext.cs

public async override Task SaveChangesAsync(...)
{
    var domainEntities = this.ChangeTracker.Entries()
                    .Where(x => x.Entity.GetDomainEvents().Any());
    var domainEvents = domainEntities.SelectMany(x => x.Entity.GetDomainEvents()).ToList();
    domainEntities.ToList().ForEach(entity => entity.Entity.ClearDomainEvents());
    foreach (var domainEvent in domainEvents)
    {
        await mediator.Publish(domainEvent);
    }
    return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}

案例实现

1、注册用户的时候给用户发送欢迎邮件

2、修改用户信息的时候通知用户

RabbitMQ简介

RabbitMQ基本概念

1、集成事件是服务器间的通信,所以必须借助于第三方服务器为事件总线。常用的消息中间件有Redis、RebbitMQ、Kafka、ActiveMQ等

2、RabbitMQ的基本概念:

1)信道(Channel):信道是信息的生产者、消费者和服务器进行通信的虚拟连接。TCP连接的建立是非常消耗资源的,所以RabbitMQ在TCP连接的基础上构建了虚拟的信道。我们尽量重复使用TCP连接,而信道则是可以用完了就关闭

2)队列(Queue):用来进行消息收发的地方,生产者把消息放到队列中,消费者从队列中获取数据

3)交换机(exchange):把消息路由到一个或者多个队列中

RabbitMQ的routing模式

ASP.NET Core学习之路04_第6张图片

生产者把消息发布到交换机中,消息携带一个routingKey属性,交换机根据routingKey的值把消息发送到一个或者多个队列;消费者会从队列中获取消息;交换机和队列都位于RabbitMQ服务器内部。优点:即使消费者不在线,消费者相关消息也会保存到队列中,当消费者上线之后,消费者就可以获取到离线期间错过的消息。

.NET中RabbitMQ的基本使用

基本使用

1、安装 RabbitMQ服务器

2、分别创建发送消息的项目和接受消息的控制台项目,这两个项目都安装NuGet包:RebbitMQ.Client。

var factory = new ConnectionFactory();
factory.HostName = "127.0.0.1";//RabbitMQ服务器地址
factory.DispatchConsumersAsync = true;
string exchangeName = "exchange1";//交换机的名字
string eventName = "myEvent";// routingKey的值
using var conn = factory.CreateConnection();
while(true)
{
    string msg = DateTime.Now.TimeOfDay.ToString();//待发送消息
    using (var channel = conn.CreateModel())//创建信道
    {
        var properties = channel.CreateBasicProperties();
        properties.DeliveryMode = 2; 
        channel.ExchangeDeclare(exchange: exchangeName, type: "direct");//声明交换机
        byte[] body = Encoding.UTF8.GetBytes(msg);
        channel.BasicPublish(exchange: exchangeName,routingKey: eventName,
            mandatory: true,basicProperties: properties,body: body);//发布消息        
    }
    Console.WriteLine("发布了消息:" + msg);
    Thread.Sleep(1000);
}

接收端:

var factory = new ConnectionFactory();
factory.HostName = "127.0.0.1";
factory.DispatchConsumersAsync = true;
string exchangeName = "exchange1";
string eventName = "myEvent";
using var conn = factory.CreateConnection();
using var channel = conn.CreateModel();
string queueName = "queue1";
channel.ExchangeDeclare(exchange: exchangeName,type: "direct");
channel.QueueDeclare(queue: queueName,durable: true,
        exclusive: false,autoDelete: false,arguments: null);
channel.QueueBind(queue: queueName,
    exchange: exchangeName,routingKey: eventName);
​
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += Consumer_Received;
channel.BasicConsume(queue: queueName, autoAck: false,consumer: consumer);
Console.ReadLine();
async Task Consumer_Received(object sender, BasicDeliverEventArgs args)
{
    try
    {
        var bytes = args.Body.ToArray();
        string msg = Encoding.UTF8.GetString(bytes);
        Console.WriteLine(DateTime.Now + "收到了消息" + msg);
        channel.BasicAck(args.DeliveryTag, multiple: false);
        await Task.Delay(800);
    }
    catch (Exception ex)
    {
        channel.BasicReject(args.DeliveryTag, true);//失败重发
        Console.WriteLine("处理收到的消息出错"+ex);
    }
}

.NET中简化集成事件的框架

Zack.EventBus使用

1、每次都使用RabbitMQ原始代码太麻烦。参考并改进了微软开源的eShopOnContainers,开发了简化领域事件编程的开发包Zack.EventBus,并且简化了以后迁移到其他MQ服务器的工作量

2、使用步骤:

1)创建两个ASP.NET Core Web API项目,它们分别是发布集成事件的项目和消费集成事件的项目,然后我们为这两个项目都安装NuGet包Zack.EventBus

2)这两个项目中的Program.cs文件中的builder.Buildl()上面增加对IntegrationEventRabbitMQOptions进行配置的代码以及对AddEventBus的调用,然后还要在builder.Build()下面用UseEventBus()。

3)在需要发布领域事件的类注入IEventBus服务,然后调用IEventBus的Publish方法发布消息

4)创造一个实现了IIntegrationEventHandler接口的类,这个类用来处理收到的事件。通过[EventName("UserAdded")]设定类监听的事件

3、JsonIntegrationEventHandler和DynamicIntegrationEventHandler。

4、RabbitMQ等消息中间件的消息发布和消费的过程是异步的,也就是消息发布者讲消息放入消息中间件就返回了,并不会等待消息的消费过程,因此集成事件不仅能够降低微服务之间的耦合度,也还能起到削峰填谷的作用,避免一个微服务中的突发请求导致其他微服务雪崩的情况出现,而且消息中间件的失败重发机制可以提高消息处理的成功率。从而保证事务的最终一致性

5、最终一致性的事务:需要开发人员对流程进行精细的设计,甚至有时候需要引入人工补偿操作。不像强一致性事务那样是纯技术方案。

6、其他类似开源项目:CAP

Zack.EventBus源代码讲解

源代码结构

1、YouZack-Vnext/Zack.EventBus

2、RabbitMQConnection类提供的是RabbiMQ连接的失败重连机制

3、SubscriptionsManager类提供的是事件处理的注册和事件的分发机制,从而使得同样一个领域事件可以被微服务内多个事件处理者收到SubsctiptionsManager使用Dictionary来记录注册的事件处理者,其中的AddSubsctiption(string eventName,Type eventHandlerType)方法用来供把eventHandlerType指定的事件处理类注册为eventName事件的处理类

4、ServiceCollectionExtentsions类中的AddEventBus方法用来把集成事件处理类注册到SubscriptionManager中,它会扫描指定程序集中所有实现了IIntegrationEventHandler接口的类,然后读取类上标注的所有[EventName],把指定监听的事件注册到SubscriptionsManager中;

5、RabbitMQEventBus类用来进行事件的注册和分发

洋葱架构(整体架构)

分层结构和传统三层结构

1、分层结构:把各个组件按照“高内聚,低耦合”的原则组织到不同的项目中

2、传统的经典三层结构

ASP.NET Core学习之路04_第7张图片

三层结构的缺点:尽管由DAL,但是仍然是面向数据库的思维方式;对于一些简单的,不包含业务逻辑的增删改查类的操作,仍然需要BLL进行转发;依赖关系是单向的,所以下一层中的代码不能使用上一层中的逻辑

整体架构(洋葱结构)

1、内层的部分比外层的部分更加的抽象--->内层表达抽象,外层表达实现

2、外层的代码只能调用内层的代码,内层的代码可以通过依赖注入的形式来间接调用外层的代码。举一个简单的例子:读取文件然后发送邮件 对比三层架构谈洋葱结构的优点

ASP.NET Core学习之路04_第8张图片

防腐层

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

DDD实战-项目分层

需求

1、一个包含用户管理、用户登录功能的微服务,系统的后台允许添加用户、解锁用户、修改用户密码等;系统的前台允许用户使用手机号加密码进行登录,也允许用户使用手机号加短信验证码进行登录;如果多次尝试登录失败,则账户会被锁定一段时间;为了便于审计,无论是登录成功的操作还是登录失败的操作,我们都要记录操作日志

2、为了简化问题,这个案例中没有对于接口调用进行鉴权,也没有防暴力破解等安全设置

ASP.NET Core学习之路04_第9张图片

 ASP.NET Core学习之路04_第10张图片

技术选型

对于ASP.NET Core Web API项目来讲,是否需要拆分出应用服务和用户界面层?

1)有的人认为前端代码是用户界面,而Web API的控制器的代码就是应用服务

2)有的人认为控制器也是一种用户界面,因此需要再拆分出来一个应用服务层,由控制器再调用应用服务层 建议前者

DDD实战-领域模型的实现

实体

1、“用户”(User)实体类。没有基于Identity框架,因为。。。。

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 Guid Id { get; init; }
    public PhoneNumber PhoneNumber { get; private set; }//手机号
    private string? passwordHash; //密码的散列值
    public UserAccessFail AccessFail { get; private set; }
    private User(){}//供EF Core加载数据使用
    public User(PhoneNumber phoneNumber)
    {
        Id = Guid.NewGuid();
        PhoneNumber = phoneNumber;
        this.AccessFail = new UserAccessFail(this);
    }
    public bool HasPassword()//是否设置了密码
    {
        return !string.IsNullOrEmpty(passwordHash);
    }
    public void ChangePassword(string value)//修改密码
    {
        if (value.Length <= 3)
            throw new ArgumentException("密码长度不能小于3");
        passwordHash = HashHelper.ComputeMd5Hash(value);
    }
    public bool CheckPassword(string password)//检查密码是否正确
    {
        return passwordHash == HashHelper.ComputeMd5Hash(password);
    }
    public void ChangePhoneNumber(PhoneNumber phoneNumber)//修改手机号
    {
        PhoneNumber = phoneNumber;
    }
}
public record UserAccessFail
{
    public Guid Id { get; init; }
    public Guid UserId { get; init; }//用户Id
    public User User { get; init; }//用户
    private bool lockOut;//是否锁定
    public DateTime? LockoutEnd { get; private set; }
    public int AccessFailedCount { get; private set; }
    private UserAccessFail(){}
    public UserAccessFail(User user)
    {
        Id = Guid.NewGuid();
        User = user;
    }
    public void Reset()
    {
        lockOut = false;
        LockoutEnd = null;
        AccessFailedCount = 0;
    }
    public void Fail()//处理一次“登录失败”
    {
        AccessFailedCount++;
        if (AccessFailedCount >= 3)
        {
            lockOut = true;
            LockoutEnd = DateTime.Now.AddMinutes(5);
        }
    }
     public bool IsLockOut()//是否已经锁定
    {
        if (lockOut)
        {
            if (LockoutEnd >= DateTime.Now)
            {
                return true;
            }
            else//锁定已经到期
            {                
                AccessFailedCount = 0;
                LockoutEnd = null;
                return false;
            }
        }
        else
        {
            return false;
        }
    }
    }
​Fail()、Reset()等方法都只是修改实体的属性,并没有写入数据库的操作
public record UserLoginHistory : IAggregateRoot
{
    public long Id { get; init; }
    public Guid? UserId { get; init; }//用户Id
    public PhoneNumber PhoneNumber { get; init; }//手机号
    public DateTime CreatedDateTime { get; init; }//时间
    public string Messsage { get; init; }//消息
    private UserLoginHistory(){}
    public UserLoginHistory(Guid? userId, PhoneNumber phoneNumber, string message)
    {
        this.UserId = userId;
        this.PhoneNumber = phoneNumber;
        this.CreatedDateTime = DateTime.Now;
        this.Messsage = message;
    }
}

UserLoginHistory的UserId属性是一个指向User实体的外键,但是再物理上,我们并没有创建它们的外键关系

仓储接口

1、仓储接口的定义放在领域层中

2、

public interface IUserDomainRepository
{
    Task FindOneAsync(PhoneNumber phoneNumber);
    Task FindOneAsync(Guid userId);
    Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string msg);
    Task PublishEventAsync(UserAccessResultEvent eventData);
    Task SavePhoneCodeAsync(PhoneNumber phoneNumber, string code);
    Task RetrievePhoneCodeAsync(PhoneNumber phoneNumber);
}

3、不建议用通用CRUDRepository,避免陷入“伪DDD”

防腐层接口

//发送验证码
public interface ISmsCodeSender
{
    Task SendCodeAsync(PhoneNumber phoneNumber,string code);
}

领域层服务

ASP.NET Core学习之路04_第11张图片

public async Task CheckLoginAsync(PhoneNumber phoneNum,string password)
{
    User? user = await repository.FindOneAsync(phoneNum);
    UserAccessResult result;
    if (user == null)//找不到用户
        result = UserAccessResult.PhoneNumberNotFound;
    else if (IsLockOut(user))//用户被锁定
        result = UserAccessResult.Lockout;
    else if(user.HasPassword()==false)//没设密码
        result = UserAccessResult.NoPassword;
    else if(user.CheckPassword(password))//密码正确
        result = UserAccessResult.OK;
    else//密码错误
        result = UserAccessResult.PasswordError;
    if(user!=null)
    {
        if (result == UserAccessResult.OK)
            this.ResetAccessFail(user);//重置
        else
            this.AccessFail(user);//处理登录失败
    }            
    UserAccessResultEvent eventItem = new(phoneNum, result);
    await repository.PublishEventAsync(eventItem);
    return result;
}
public async Task CheckCodeAsync(PhoneNumber phoneNum,string code)
{
    var user = await repository.FindOneAsync(phoneNum);
    if (user == null)
        return CheckCodeResult.PhoneNumberNotFound;
    if (IsLockOut(user))
        return CheckCodeResult.Lockout;
    string? codeInServer = await repository.RetrievePhoneCodeAsync(phoneNum);
    if (string.IsNullOrEmpty(codeInServer))
        return CheckCodeResult.CodeError;
    if (code == codeInServer)
    {
        return CheckCodeResult.OK;
    }
    else
    {
        AccessFail(user);
        return CheckCodeResult.CodeError;
    }
}
public void ResetAccessFail(User user)
{
    user.AccessFail.Reset();
}
​
public bool IsLockOut(User user)
{
    return user.AccessFail.IsLockOut();
}
​
public void AccessFail(User user)
{
    user.AccessFail.Fail();
}

DDD实战-基础设施的实现

原则

1、领域模型、领域服务中只是定义了抽象的实体、防腐层和仓储,我们需要再基础设施中对它们进行落地和实现

2、实体类、值对象的定义是和持久机制无关的,而它们需要通过EF Core的配置,上下文等建立和数据库的关系

3、上下文等也是和持久层相关的,也放到基础设施

class UserConfig : IEntityTypeConfiguration
{
    public void Configure(EntityTypeBuilder builder)
    {
        builder.ToTable("T_Users");
        builder.OwnsOne(x => x.PhoneNumber, nb => {
            nb.Property(x => x.RegionCode).HasMaxLength(5).IsUnicode(false);
            nb.Property(x => x.Number).HasMaxLength(20).IsUnicode(false);     
        });
        builder.Property("passwordHash").HasMaxLength(100).IsUnicode(false);
        builder.HasOne(x => x.AccessFail).WithOne(x => x.User)
            .HasForeignKey(x=>x.UserId);
    }
}
//实现IUserDomainRepository
public async Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string msg)
{
    var user = await FindOneAsync(phoneNumber);
    UserLoginHistory history = new UserLoginHistory(user?.Id,phoneNumber, msg);
    dbCtx.LoginHistories.Add(history);//没有SaveChangesAsync()
}
public Task RetrievePhoneCodeAsync(PhoneNumber phoneNumber)
{
    string fullNumber = phoneNumber.RegionCode + phoneNumber.Number;
    string cacheKey = $"LoginByPhoneAndCode_Code_{fullNumber}";
    string? code = distCache.GetString(cacheKey);
    distCache.Remove(cacheKey);//验证码保存到IDistributedCache 
    return Task.FromResult(code);
}

其他

DBContext、防腐层等实现

DDD-实战-工作单元的实现

原则

1、工作单元是由应用服务层来确定,其他层不应该调用SaveChangesAsync方法保存对数据的修改

2、可以开发一个在控制器的方法调用结束后自动调用SaveChangesAsync的Filter:UnitOfWorkAttribute、UnitOfWorkFilter

public class UnitOfWorkAttribute:Attribute
{
    public Type[] DbContextTypes { get; init; }
    public UnitOfWorkAttribute(params Type[] dbContextTypes)
    {
        this.DbContextTypes = dbContextTypes;
    }
}
public async Task OnActionExecutionAsync(ActionExecutingContext context,  ActionExecutionDelegate next)
{            
    var uowAttr = GetUoWAttr(context.ActionDescriptor);
    if (uowAttr == null)
    {
        await next();
        return;
    }
    List dbCtxs = new List();
    foreach (var dbCtxType in uowAttr.DbContextTypes)
    {
        var sp = context.HttpContext.RequestServices;
        DbContext dbCtx = (DbContext)sp.GetRequiredService(dbCtxType);
        dbCtxs.Add(dbCtx);
    }
    var result = await next();            
    if (result.Exception == null)
    {
        foreach (var dbCtx in dbCtxs)
        {
            await dbCtx.SaveChangesAsync();
        }
    }
}

DDD实战-应用层的实现

实现

1、应用层主要进行的是数据的校验、请求数据的获取、领域服务返回值的显示等处理,并没有复杂的业务逻辑,因为主要的业务逻辑都被封装在领域层

2、应用层是非常薄的一层,应用层主要进行安全认证、权限校验、数据校验、事务控制、工作单元控制、领域服务的调用等。从理论上来讲,应用层中不应该由业务规则或者业务逻辑

3、监听登录失败或者成功的领域事件UserAccessResultEvent,记录到LoginHistory:repository.AddNewLoginHistoryAsync(phoneNum,msg);

控制器

控制器实现根据手机号和密码进行登录

public async Task LoginByPhoneAndPwd(LoginByPhoneAndPwdRequest req)
{
    if(req.Password.Length<3) return BadRequest("密码的长度不能小于3");
    var phoneNum = req.PhoneNumber;
    var result = await domainService.CheckLoginAsync(phoneNum, req.Password);
    switch(result)
    {
        case UserAccessResult.OK:
            return Ok("登录成功");
        case UserAccessResult.PhoneNumberNotFound:
            return BadRequest("手机号或者密码错误");//避免泄密,不能404
        case UserAccessResult.Lockout:
            return BadRequest("用户被锁定,请稍后再试");
        case UserAccessResult.NoPassword:
        case UserAccessResult.PasswordError:
            return BadRequest("手机号或者密码错误");
        default:
            throw new NotImplementedException();
    }
}

新增用户

public async Task AddNew(PhoneNumber req)
{
    if ((await repository.FindOneAsync(req))!=null)
        return BadRequest("手机号已经存在");
    User user = new User(req);
    dbCtx.Users.Add(user);
    return Ok("成功");
}

对于增删改查等这种简单的业务场景,我们没必要拘泥于DDD的原则。这样也是洋葱架构的优点

你可能感兴趣的:(学习,.netcore,微服务)