DDD

1、架构设计

1.1 架构设计的问题

和具体的代码编写不同,架构设计存在一定的主观因素,而且因行业、公司、团队的不同而不同,并没有哪个架构设计是绝对对的或错的,没有最好的架构,只有最适合的架构。但是不少技术人员在架构设计上容易犯“迷信大公司”“迷信流行技术”等错误。

有的人员在进行架构设计的时候,会说“某某大公司采用这样的架构”,言下之意就是“大公司都这样做,我们这样做肯定没错”,但是他并不了解那个大公司为什么采用这样的架构,自己所在的项目是否适合这样的架构

IT行业的发展瞬息万变,新技术层出不穷,很多技术人员出于个人兴趣、个人职业发展等考虑而选择一些流行的新技术,他们会把各种复杂的架构模式、高精尖的技术都加入架构中,这增加了项目的复杂度、延长了交付周期、增加了项目的研发成本。有些技术并不符合公司的情况,最后项目失败了,某些技术人员就拿着“精通某某流行技术”的简历去找下家了,给公司留下一地鸡毛。

因此,我们做架构设计的时候,一定要分析行业情况、公司情况、公司未来发展、项目情况、团队情况等来设计适合自己的架构,不能盲目跟风。

1.2 架构是进化而来的

罗马不是一天建成的,大公司的复杂架构也不是一蹴而就的,而是从简单到复杂演变、进化而来的。以淘宝网为例,它的第一个版本是几名开发人员用了一个月时间基于一个PHP(page hypertext preprocessor,页面超文本预处理器)版拍卖网站改造的,上线的时候淘宝网只有一台Web服务器和一台数据库服务器。在淘宝网近20年的发展中,随着网站访问量越来越大、功能越来越多,淘宝网才逐渐进化到现在这样复杂的架构。而现在很多网站在开发第一版的时候就以“上亿人访问,百万并发量”为架构设计目标,导致项目迟迟无法交付、研发成本高昂,好不容易网站开发完成了,但是由于项目交付延迟,公司已经错过了绝佳的市场机会,上线后才几千个注册用户,最后网站无疾而终。

按照“精益创业”的理念,我们应该用最低的成本、最短的时间开发出一个“最小的可行性产品”,然后把产品投入市场,根据市场的反馈再进行产品的升级。这里并不是让大家开发一个新产品的时候,也像淘宝网一样写普通的PHP代码、部署到普通服务器上。经过IT行业的发展,我们现在已经可以用非常低的成本、在很短的时间内构建一个可承担较大访问量的高可用系统。我们只要基于成熟的技术进行开发,并且对项目未来较短一段时间内的发展进行预测,在项目架构上做必要的准备就可以了,没必要“想得太长远”。架构设计在满足必要的可扩展性、隔离性的基础上,要尽可能简单。

一个优秀的架构不应该是初期版本简单、升级过程中经常需要推倒重来的,而是要从简单开始,并且可以顺滑地持续升级。也就是架构最开始的版本很简单,但是为后续的进化、升级做好了准备,以便后续可以完美地升级架构。这样可以持续升级的架构,叫作“演进式架构”。设计一个优秀的演进式架构比设计一个大而全的架构对架构设计人员的要求更高。

.NET是一个可以很好地支撑演进式架构的技术平台。在前期网站访问量低、没有专业运维人员的情况下,我们可以把用.NET开发的程序部署到单机Windows服务器上,随着网站规模的扩大,我们可以在不修改代码的情况下,把程序迁移到Linux+Docker的环境下;在网站访问量低的时候,我们可以用内存作为缓存,随着网站访问量的增大,我们可以切换为使用Redis作为缓存;.NET的依赖注入让我们可以替换服务的实现类,而不需要修改服务消费者的代码。

一个好的软件架构应该是可以防止软件退化的。软件退化指的是在软件升级的时候,随着功能的增加和系统复杂度的提升,代码的质量越来越差,系统的稳定性和可维护性等指标越来越差。一个退化中的软件的明显特征就是:软件的第一个版本是代码质量最高的版本,之后的版本中代码质量越来越差。软件的需求是不断变更的,软件的升级也是必然的,因此我们应该在进行架构设计的时候避免后续软件需求变更导致软件退化,并且在软件的升级过程中,我们要适时地进行架构的升级,以保持高质量的软件设计。如果我们在每次软件升级的时候没有及时地调整程序结构,而是在原有的程序结构上不断地加入代码,最终软件就会退化。

2、什么是微服务

随着IT行业的发展,传统的单体结构项目已经无法满足如今的软件项目的要求,越来越多的项目采用微服务架构进行开发

DDD是一个很好的应用于微服务架构的方法论.

从本节开始,将会对微服务和与DDD相关的概念进行讲解.

DDD相关的概念比较晦涩难懂,这也是DDD学习中比较高的门槛

DDD的学习中,我们一般会经历多次“从理论到实践,在实践中应用一段时间,再回到理论”这样的过程,才会对于DDD的概念及实践有螺旋式上升的认知。

什么是微服务呢?

2.1 单体架构优缺点

传统的软件项目大部分都是单体结构,也就是项目中的所有代码都放到同一个应用程序中,一般它们也都运行在同一个进程中

单体结构的项目有结构简单、部署简单等优点,但是有如下的缺点。

第一:代码之间耦合严重,代码的可维护性低。

虽然项目进行了分层,但是所有的模块都是在一个项目中,也就是在一个进程中,而且模块之间存在直接访问的情况,

例如:订单模块,直接访问物流模块,这样会导致这两个模块之间存在一定的耦合性。

第二:项目只能采用单一的语言和技术栈,甚至采用的开发包的版本都必须统一。

例如:订单模块使用c#开发,物流模块也是c#开发,并且模块开发的技术栈都是统一的,不可能会出现订单模块是.net6开发,而物流模块使用.net7开发的情况。

而且模块之间使用的一些第三包的版本是统一的,例如:不会出现订单模块使用某个第三包是2.1版本,而物流模块使用的相同的第三包是3.2版本,否则会出现版本混乱。

第三:一个模块的崩溃就会导致整个项目的崩溃。

由于整个项目是运行在同一个进程中的,很有可能会出现一个模块崩溃而导致整个项目崩溃的情况。

第四:升级周期长

一个模块升级了,其他模块也需要进行检测。从而导致升级周期变长。

当需要更新某一个功能时,我们需要把整个系统重新部署一遍,这会导致新功能的上线流程变长

第五:我们只能整体进行服务器扩容,无法对其中一个模块进行单独的服务器扩容

例如:只有订单模块,用户访问量比较大,只想对该模块进行服务器的扩容,但是由于是单体项目,只能进行整体的服务器扩容,增加了成本。

2.2 微服务架构优缺点

微服务项目结构

微服务架构把项目拆分为多个应用程序,每个应用程序单独构建和部署,也就是每个服务都是独立运行在单独的进程中的。

微服务架构有如下的优点

第一:每个微服务只负责一个特定的业务,业务逻辑清晰、代码简单,对于其他微服务的依赖非常低,因此易于开发和维护

第二:不同的微服务可以用不同的语言和技术栈开发

第三:一个微服务的运行不会影响其他微服务。

第四:可以对一个特定的微服务进行单独扩容

第五:当需要更新某一个功能的时候,我们只需要重新部署这个功能所在的微服务即可,不需要重新部署整个系统

当然,万事万物都不会只有优点没有缺点,微服务架构的缺点如下

微服务架构的缺点:

第一:在单体结构中,运维人员只需要保证一个应用的正常运行即可,而在微服务架构中,运维人员需要保证多个应用的正常运行,这给运维工作带来了更大的挑战。

第二:在单体结构中,各模块之间是进程内调用,数据交互的效率高,而在微服务架构中,各微服务之间要通过网络进行通信,数据交互的效率低。

第三:

在单体结构中,各模块之间的调用都是在进程内进行的,实现容错、事务一致性等比较容易,而在微服务架构中,各微服务之间通过网络通信,实现容错、事务一致性等非常困难

2.3 微服务误区

微服务的误区

在应用微服务架构的时候,我们可能会有微服务切分过细和微服务之间互相调用过于复杂这两个主要的误区。有的技术人员并没有深刻理解微服务的本质,迷信微服务,把一个很简单的项目拆分成了几十个甚至上百个微服务,这么多微服务的管理是非常麻烦的,运维人员苦不堪言。在设计不好的微服务架构中,微服务之间的调用关系非常复杂,一个来自客户端的请求甚至要经过七八层的微服务调用,这样糟糕的设计不仅导致系统间耦合严重,而且使得服务器端的处理效率非常低,如下图所示:

我们讲过,架构应该是进化而来的,同样微服务架构也应该是进化而来的。因此在进行系统架构设计的时候,我们应该认真思考“这个项目真的需要微服务架构吗”。如果经过思考后,我们仍然决定要采用微服务架构,那么也要再思考“能不能减少微服务的数量”。第一个版本的项目可以只有几个微服务,随着系统的发展,当我们发现一个微服务中某个功能已经发展到可以独立的程度时(比如某个功能被高频访问、某个功能经常被其他微服务访问),我们再把这个功能拆分为一个微服务。总之,是否采用微服务及如何采用微服务,应该是仔细思考后的结果,我们不能盲目跟风

3、什么是DDD

DDDDomain-driven design)中文:领域驱动设计,是一个很好的应用于微服务架构的方法论。

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

DDD是由埃里克·埃文斯(Eric Evans)在2004年提出来的,但是一直停留在理论层次,多年来的实际应用并不广泛,直到2014年,马丁·福勒与詹姆斯·刘易斯(James Lewis)共同提出了微服务的概念,人们才发现DDD是一种很好的指导微服务架构设计的模式。**DDD的诞生早于微服务的诞生,DDD并不是为微服务而生的,DDD也可以用于单体结构项目的设计,但是在微服务架构中DDD能发挥出更大的作用。**

DDD并不是一个技术,而是一种架构设计的指导原则,是一个方法论;DDD不是一种强制性的规范,各个项目可以根据自己的情况进行个性化的设计。DDD就像烹饪中餐时“盐少许、油少许”一样让人难以捉摸(没有明确的步骤),而且DDD中的概念非常多,表述非常晦涩,因此很多人都对DDD望而生畏。

不同项目的行业情况、公司情况、团队情况、业务情况等不同,因此DDD不能给我们一个拿来就能照着用的操作手册。每个人、每个团队对DDD的理解不同,如果说“一千个人心中就有一千个哈姆雷特”的话,那么也可以说“一千个人心中就有两千个DDD”,因为同一个人对DDD也可能在不同时期有着不同的理解。

很多开发人员把DDD当成一个技术,这是非常大的一个误区。DDD是一种设计思想

DDD思想的目的:在项目的整个生命周期内,所有岗位的人员(开发人员,产品经理,运维,测试等待)都应该是基于对业务的相同理解来开展工作,所有人员都应该站在用户的角度,业务的角度去思考问题,而不是站在技术的角度去思考问题。

不同的人对DDD的理解及对DDD概念落地的理解有所不同,并不存在绝对的错与对,在情况A下成功的DDD实战经验放到情况B下可能就会失败。正如古人所说“橘生淮南则为橘,生于淮北则为枳”,读者不要在众多的对DDD解读的文章中迷失,也不要执着于寻找根本就不存在的“DDD最佳实践”,而要认真聆听各方的解读,并且根据项目的自身情况来个性化地实现DDD的落地。只要读者能够用DDD很好地指导项目,那么该落地方案就是最优解。

说了那么多,到底什么是DDD呢?

DDD的英文全称是domain driven design,翻译成中文就是“领域驱动设计”。这里的主干词是“设计”,也就是说DDD是一种设计思想这里的形容词是“领域驱动”,那么什么是“领域”呢?领域其实指的就是业务,因此DDD其实就是一种用业务驱动的设计。传统的软件设计把业务和实现技术割裂,在系统的需求设计完成后,技术人员把业务人员描述的需求文档转换为代码去实现,业务人员和技术人员对系统的理解并不完全匹配

随着系统的升级,技术人员对代码进行修改,业务人员和技术人员对系统的理解偏差越来越大,从而造成系统的扩展性、可维护性越来越差。而DDD则是指在项目的全生命周期内,管理、产品、技术、测试、实施、运维等所有岗位的人员都基于对业务的相同理解来开展工作。技术人员在把业务落地为设计、代码的时候,也直接把业务映射到代码中。DDD的核心理念就是所有人员站在用户的角度、业务的角度去思考问题,而不是站在技术的角度去思考问题。

4、领域与领域模型

4.1 什么是领域

“领域”(domain)是一个比较宽泛的概念,主要指的是一个组织做的所有事情,比如一家银行做的所有事情就是银行的领域。为了缩小讨论问题的范围,我们通常会把领域细分为多个“子领域”(简称“子域”),比如银行的领域就可以划分为“对公业务子域”“对私业务子域”“内部管理子域”等,子域还可以继续划分为更细粒度的子域,比如“对私业务子域”可以划分为“柜台业务子域”“ATM(automated teller machine,自动柜员机)业务子域”“网银业务子域”等。划分出子域之后,我们就能专注于子域内部的领域相关业务的处理。

领域(包含子域)可以按照功能划分为核心域、支撑域、通用域

核心域 :指的是解决项目的核心问题的领域

支撑域:指的是解决项目的非核心问题的领域

通用域:指的是解决通用问题的领域

领域的划分可以不限于技术相关的问题,举个例子,对于一家手机公司来讲,手机的研发、制造、销售业务就属于核心域,售后业务、财务业务就属于支撑域,而保洁、保安则属于通用域。领域划分为不同类别后,我们就可以为不同的领域投入不同的资源:对于核心域我们要投入重点资源,对于通用域我们可以采购外部服务,比如很多公司的保洁人员都是外包的第三方服务公司提供的。一个公司对于领域的不同分类也决定了公司业务方向的不同。一家注重销售的手机公司,可能手机都是从第三方采购的,只是把手机贴上自己的商标而已,对于这样的公司来讲,研发、制造业务就是通用域。

从软件开发技术这个层面来讲,领域的不同分类也决定了公司的研发重点。对于一家普通软件公司来讲,业务逻辑代码属于核心域,权限管理、日志模块等属于支撑域,而报表工具、工作流引擎等属于可以从外部采购的通用域。但是对于一家提供云计算基础服务的公司来讲,服务器资源管理、安全监控等属于核心域,云服务器SDK、技术文档、沙箱环境、计费模块等则属于支撑域,而操作系统、数据库等属于通用域。对于一家想要通过研发自己的操作系统、数据库系统从而最大化地利用服务器资源的云计算公司来讲,操作系统、数据库等就属于支撑域甚至核心域了。

4.2 什么是领域模型

确定一个领域之后,我们就要对领域内的对象进行建模,从而抽象出模型的概念,这些领域中的模型就叫作领域模型(domain model)。比如银行的柜台业务领域中,就有储户、柜员、账户等领域模型。建模是DDD中非常核心的事情,一旦定义出了领域模型,我们就可以用领域模型驱动项目的开发。使用DDD,我们在分析完产品需求后,就应该创建领域模型,而不是考虑如何设计数据库和编写代码。使用领域模型,我们可以一直用业务语言去描述和构建系统,而不是使用技术人员的语言。

与领域模型对应的概念是“事务脚本”(transaction script),事务脚本是指使用技术人员的语言去描述和实现业务,说通俗一点儿就是没有太多设计,没有考虑可扩展性、可维护性,通过使用if、for等语句用流水账的形式编写代码。如下代码所示:,“柜员取款”业务的伪代码就是一个典型的事务脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
 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 "账号余额不足";

Query($"Update Accounts set Balance=Balance-{amount} where Number={account}");
return "ok";
}

在这段代码中,我们检查当前柜员是否拥有操作取款业务的权限,然后检查账户的余额,最后完成扣款。包括作者在内的很多开发人员的职业生涯中都写过这样流水账式的代码。这样的代码可以满足业务需求,而且编写简单、自然,非常符合开发人员的思维方式。事务脚本代码的问题在于,本应该属于支撑域中的权限的概念出现在了核心域的代码中,我们应该通过AOP(aspect-oriented programming,面向切面编程)等方式把权限校验的代码放到单独的权限校验支撑域中。这段代码的另外一个问题是,它对于需求变更的响应是非常糟糕的,比如系统需要增加一个“取款金额大于5万元需要主管审批”的功能,我们就要在第5行代码之前加上一些if判断语句;再比如系统需要增加一个取款成功后发送通知短信的功能,我们就要在,return ok这行代码之前添加上发送短信的代码…….

随着系统需求的膨胀,以上方法可能会膨胀出上千行的代码,代码的可维护性,可扩展性非常差。

而根据领域模型、DDD开发完成的系统,代码的可维护性、可扩展性会非常高.

大家学习完这次课程以后,可以尝试重构上面的代码。

5、通用语言与界限上下文

在进行系统开发的时候,非常容易导致歧义的是不同人员对于同一个概念的不同描述。比如用户说“我想要商品可以被删除”,开发人员就开发了一个使用Delete语句把商品从数据库中删除的功能;后来用户又说“我想把之前删除的商品恢复回来”,开发人员就会说“数据已经被删除了,恢复不了”,用户就会生气地说“Windows里的文件删除后都能从回收站里恢复,你们删除的怎么就恢复不了呢”。这其实就是开发人员和用户对于“删除”这个词语的理解不同造成的。再如,电商系统的支付模块的开发人员和后台管理模块的开发人员聊了许久关于“用户管理”的功能,最后才发现支付模块开发人员说的“用户”指的是购买商品的“客户”,而后台管理模块开发人员说的“用户”指的是“网站管理员”。

从上面两个例子我们可以看出,在描述业务对象的时候,拥有确切含义的、没有二义性的语言是非常重要的,这样的语言就是“通用语言”。在应用DDD的时候,团队成员必须对于系统内的每一个业务对象有确定的、无二义性的、公认的定义。通用语言离不开特定的语义环境,只有确定了通用语言所在的边界,才能没有歧义地描述一个业务对象。比如,后台管理模块中的“用户”和支付模块的“用户”就处于不同的边界中,它们在各自的边界内有着各自的含义。界限上下文就是用来确定通用语言的边界的,在一个特定的界限上下文中,通用语言有着唯一的含义。

6、实体与值对象

6.1 实体对象

DDD中,“标识符”用来唯一定位一个对象,在数据库中我们一般用表的主键来实现标识符。当谈到标识符的时候,我们是站在业务的角度思考问题,而谈到主键的时候,我们是站在技术的角度思考问题。

在具体实现DDD的时候,实体类一般的表现形式就是EF Core中的实体类,实体类的Id属性一般就是标识符,Id属性的值不会变化,它标识着唯一的对象,实体类的其他属性则可能在运行时被修改(例如:商品名称,商品价格,数量等),但是只要Id不变,我们就知道前后两个对象指的是同一个对象。我们可以把实体类的对象保存到数据库中,也可以把它从数据库中读取出来。(实体是一个概念,在EFCORE中的实体类就是它的一般的表现形式)

6.2 值对象

DDD中还存在着一些没有标识符的对象,它们也有多个属性,它们依附于某个实体类对象而存在,这些没有标识符的对象叫作值对象

比如,在电子地图系统中,“商家”就是一个实体类,该实体类包含营业执照编号、名称、经纬度位置、电话等属性。一个商家的营业执照编号是不可以修改的,而商家的名称、经纬度位置、电话都是可以修改的,只要两个商家的营业执照编号一样,我们就认定两个商家是同一家,因此营业执照编号就可以看作标识符。而经纬度位置就是一个值对象,经纬度位置这个值对象包含“经度”和“纬度”两个属性,经纬度位置没有标识符,而且经纬度位置的经度和纬度两个属性也不会被修改,如果商家搬家了,我们只要重新创建一个新的经纬度位置的对象,然后重新赋值商家的经度和纬度属性就可以了。当然,我们也可以取消经纬度位置这个值对象属性,直接改为经度、纬度两个属性,也就是商家实体类包含营业执照编号、名称、经度、纬度、电话等属性,但是把经度和纬度作为一个值对象更能够体现它们的整体关系。

定义为值对象还是实体对象的区别:整体与部分的关系

例如:上面所说的“商家实体类”中的经纬度是商家实体类的一部分,它是不能单独存在的。

问题:员工是实体对象还是值对象,是实体对象,因为员工不是商家实体类中的一部分,它们两者之间是合作的关系。

员工是可以独立商家而存在的。

所以说到底是定义实体对象还是值对象,就看,是可以单独存在,还是作为某个对象的一部分存在。可以单独存在就定义成实体对象,如果是某个实体对象的一部分,不能单独的存在就是值对象。

7、聚合与聚合根

一个系统中会有很多的实体类(包含值对象),这些实体类之间有的关系紧密,有的关系很弱,有的没有关系。面向对象设计的一个重要原则就是“高内聚,低耦合”(电脑与鼠标就是高内聚,低耦合,电脑中的各个元器件是高内聚,缺少了哪个都不能工作,而电脑与鼠标的关系是低耦合,也就是说电脑与鼠标只是通过接口能够链接,鼠标坏了,也不能影响接口。而且鼠标可以插入到任何电脑中,只要共同的接口就可以了),我们同样希望有关系的实体类紧密协作,而关系很弱或者没有关系的实体类可以很好地被隔离。因此,我们可以把关系紧密的实体类放到一个聚合(aggregate)中,每个聚合中有一个实体类作为聚合根(aggregate root),所有对聚合内实体类的访问都通过聚合根进行(鼠标只是通过接口与电脑进行沟通,而不会直接访问电脑中的元器件,所以接口可以理解成聚合根),外部系统只能持有对聚合根的引用,聚合根不仅仅是实体类,还是所在聚合的管理者。

聚合并不是简单地把实体类组合在一起,而要协调聚合内若干实体类的工作,让它们按照统一的业务规则运行,从而实现实体类数据访问的一致性,这样我们就能够实现聚合内的“高内聚”;聚合之间的关系很弱,一个聚合只能引用另外一个聚合的聚合根,这样我们就能够实现聚合间的“低耦合”。

聚合体现的是现实世界中整体和部分的关系,比如订单与订单明细(订单是一个实体,而订单明细也是一个实体,并且订单明细是在订单中的,外部访问的是订单,而不是订单明细)。整体封装了对部分的操作,部分与整体有相同的生命周期。部分不会单独与外部系统交互,与外部系统的交互都由整体来负责

聚合的设计是DDD中比较难的工作,因为系统中很多实体类都存在着关系,这些关系到底是设计为聚合之间的关系还是聚合之内的关系是非常容易让人困惑的。判断的标准就是看它们是否是整体和部分的关系,是否存在着相同的生命周期,如果是的话,它们就是聚合内的关系,反之,则不是。

比如,订单与订单明细之间显然是整体和部分的关系,因为删除了订单,订单明细也就消失了,而且外部系统不会直接引用订单明细,只会引用订单。因此我们把订单和订单明细设计为一个聚合,并且把订单作为聚合根,外部系统只能引用订单,对订单明细的操作都通过订单来进行。

而用户和订单之间的关系就不是整体与部分的关系,因为删除了订单,用户还是可以存在的。有人可能会认为,删除了用户,这个用户的订单也就消失了,因此用户和订单是整体和部分的关系。但是聚合关系还有一个判断标准就是“实体类能否单独和外部系统交互”,很显然,在系统中订单是可以单独和外部系统交互的,比如支付系统中就可以直接引用订单,因此用户和订单之间不是聚合关系。

有的情况下,聚合关系的划分也不是一成不变的,不同的业务流程决定了不同的划分方式。比如新闻和新闻的评论就既可以设计成同一个聚合,也可以放到不同的聚合中。如果在网站中,新闻和评论都是一起出现的,评论不会单独出现,我们就可以把它们设计成同一个聚合,把新闻设置为聚合根。但是如果在网站中,有“全站热门评论榜”“分享评论到朋友圈”等把评论作为一个独立的实体类看待的情况,我们就可以把新闻和评论设置为两个聚合。

在设计聚合的时候,要尽量把聚合设计得小一点儿,一个聚合只包含一个聚合根实体类和密不可分的实体类,实体类中只包含最小数量的属性。小聚合有助于进行微服务的拆分,也有助于减少数据修改冲突。设计聚合的一个原则就是:聚合宁愿设计得小一点儿,也不要设计得太大。(例如:新闻与评论,如果放到一个聚合中,后期由于评论访问比较大的时候,进行拆分比较麻烦,所以可以设计成两个聚合)

如果不知道怎样划分,就是一个实体就是一个聚合,这样有利于以后的拆分。

8、领域服务与应用服务

聚合根的实体类中没有业务逻辑代码,只有对象的创建、对象的初始化、状态管理等与个体相关的代码。对于聚合内的业务逻辑,我们编写领域服务(domain service),而对于跨聚合协作的逻辑,我们编写应用服务(application service)。应用服务协调多个领域服务来完成一个用例。

其实就是本质上就是说,我们的代码写到什么地方。

下面我们通过一段伪代码,来体会以上所提到的领域服务,应用服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

class Order{
int Id{get;set;};
DateTime CreateTime,
double TotalAmount; // 总金额
List<OrderItem>Items ;// 订单明细
public Order(){
this.CreateTime = DateTime.Now();
}
public AddDetail(string productId,int count){
OrderItem item = Items.FirstOrDefault(i=>i.ProductId==productId);
if(item !=null){
item.Count+=count;
}else{
item = new OrderItem();
item.ParentId = this.Id;
item.ProductId = productId;
item.Count = count;
Items.add(item)
}
}

}

class OrderItem{
int Id;
int ParentId;
string ProductId;
int Count;
}

在这里我们可以看到在Order这个实体类中的构造函数中,初始化了CreateTime这个属性,同时在该实体类中添加了一个AddDetail方法,该方法中,根据传递过来的商品编号,查询订单项,如果找到了,更新数量,如果没有找到,添加一个具体的订单项。

这里我们可以看到这些代码,仅仅是为了完成对象的创建,对象的初始化,状态管理等工作,不是具体的业务代码,所以在聚合根Order这个实体类中,添加了如上的代码。

假如,这里有一个场景,就是用户每次最多购买99件商品,这就是一个具体的业务,而且是属于聚合内的业务,因为商品的数量与当前订单聚合有关,与其他的聚合没有关系,这个业务代码就需要领域服务中。

例如:有一个场景:如果订单需要与采购或者是库存,等聚合之间有关系,或者与外部系统之间有关系,例如把创建好的订单实体保存到数据库,这样的业务代码需要写到应用服务中。

以上其实就是体现了DDD的职责的划分;

这里也可以在对前面所讲的内容做一个总结:

第一:领域模型与外部系统不会发生直接的交互,领域服务也不会涉及到数据库的操作。

第二:业务逻辑放入领域服务中(所以说,在一些比较大的系统中,领域服务会比较复杂),而与外部系统的交互由应用服务来负责。

第三:领域服务不是必须的,在一些简单的业务处理中,比如简单的增删改查中,是可以没有领域服务的。像这种情况,可以在应用服务中完成所有的操作,就不需要引入领域服务了,这样可以避免过度设计。

这里还有两个前面已经提到的概念:

仓储(Repository)和 工作单元(Unit of Work)

仓储:负责按照要求从数据库中读取数据以及把领域服务修改的数据保存回数据库。一个聚合对应一个用来实现数据持久化的仓储。

工作单元:聚合内数据操作的关系是非常紧密的,我们要保证事务的强一致性,聚合内的若干相关联的操作组成一个“工作单元”,这些工作单元要么全部成功,要么全部失败。

9、领域事件与集成事件

这一小节是比较重要的

我们在进行系统开发的时候,经常会遇到“当发生某事件的时候,执行某个动作(什么是事件)”。比如,在一个问答系统中,当有人回复了提问者的提问的时候,系统就向提问者的邮箱发送通知邮件,如果我们使用事务脚本的方式来实现这个功能,如下代码所示:

1
2
3
4
5
6
void SaveAnswer(long id,string answer)
{
保存到数据库(id,answer);
string email = 获取提问者邮箱(id);
发送邮件(email,"你的问题被回答了");
}

这样编写的代码有如下几个问题。

第一个问题:代码会随着需求的增加而持续膨胀。比如网站又增加了一个功能“如果用户回复的答案中有疑似违规的内容,则先把答案隐藏,并且通知管理员进行审核”,那么我们就要把“保存答案”方法修改成,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  void SaveAnswer(long id,string answer)
{
long aId = 保存到数据库(id,answer);
if(检查是否疑似违规(answer))
{
隐藏答案(aId);
通知管理员审核();
}

else
{
string email = 获取提问者邮箱(id);
发送邮件(email,"你的问题被回答了");
}
}

随着系统的升级,这个方法的代码也越来越长、越来越复杂,充斥着大量一层嵌套一层的判断语句。这样的代码经过几任开发人员的接手,可能没有任何一个开发人员能够完全理解这个方法。当需要为这个方法增加新的功能的时候,开发人员不敢修改前任的代码,他只能“胆战心惊”地找到一个位置插入自己编写的代码,如果代码恰好能够运行,又没有导致原有功能出现bug,就是一件“天大的喜事”。这样新版本的代码又成为了继任的开发人员不敢动的“祖传代码”。总之,这样的事务脚本的可读性和可维护性非常差。

第二个问题:

代码可扩展性低。在后续版本中,我们可能要把“发送邮件”改成“发送短信”,那么我们就要把发送邮件行代码改成与发送短信相关的代码;如果后续我们又要把逻辑改成“向普通会员发邮件,向VIP会员发短信”,那我们就要把代码改成由多个判断语句组成的代码块

面向对象设计中有一个原则是“开闭原则”,即“对扩展开放,对修改关闭”,通俗来讲就是“当需要增加新的功能的时候,我们可以通过增加扩展代码来完成,而不需要修改现有的代码”。很显然,我们这种事务脚本的写法是很难满足开闭原则的。

第三个问题:

用户体验很差。这段代码中除了“保存答案”这个核心的业务逻辑之外,掺杂了“检查是否疑似违规”“发送邮件”等业务逻辑,这些业务逻辑的执行一般都比较耗时,会拖慢“保存答案”方法的执行速度,造成每次用户单击【保存答案】按钮的时候都要等待很长时间。

第四个问题:

容错性差。“检查是否疑似违规”可能需要调用第三方的鉴黄服务,“发送邮件”需要访问邮件服务,这些都需要访问外部系统,这些外部系统并不总是稳定的。比如,在发送邮件时,邮件服务器的暂时故障可能会造成用户单击【保存答案】按钮后,系统提示“操作失败”,因此用户体验是极差的。

为了解决以上的问题,可以通过事件机制来进行解决。

为了解决这些问题,我们可以在保存答案后,发出一个“答案已保存”的通知事件,内容审核模块和邮件发送模块监听这个事件来分别进行各自的处理。采用事件机制的伪代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  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
2
3
4
1、关注点分离。3个方法各司其职,各自的业务逻辑没有混杂到一起,代码的可读性、可维护性都非常高。
2、扩展容易。如果我们需要实现“保存答案后,刷新缓存”,只要再增加一个新的方法并且将其绑定到"答案已保存"事件即可,现有的代码不用做任何修改,符合“开闭原则”。
3、用户体验好。我们可以把“审核答案”“发邮件给提问者”等这些对事件的处理异步运行,这样这些处理就不会影响用户体验。
4、容错性更好。如果外部系统调用失败,我们可以进行失败重试(主要是保存答案这个方法,修改成异步的以后,用户不用等待第三方的处理完成以后才会受到反馈,这样第三方处理失败了,例如发送短信失败了,可以在发送短信或者是发送邮件的方法中进行重试,这时候修改的是发送短信或者是发送邮件的方法,其他方法中的代码不需要修改)

DDD中的事件分为两种类型:领域事件(domain events)和集成事件(integration events)

领域事件:主要用于在同一个微服务内(同一个进程内)的聚合之间的事件传递。

比如在问答微服务中,当用户保存答案的时候,审核答案的逻辑我们一般通过领域事件实现,因为:保存答案与审核答案我们一般都是将其放到同一个微服务中。

集成事件:用于跨微服务的事件传递,例如:项目中有专门的邮件发送微服务,则当用户保存答案的时候,发送邮件给提问者的操作就要通过集成事件来实现。还有就是我们前面提到的订单与仓库,这是两个不同的微服务,当用户下完订单以后,可以通知仓库发货。

这时候涉及到了不同的微服务直接的事件传递,所以需要使用的就是集成事件来完成。集成事件的实现,主要是通过事件总线来实现,一般也是使用第三方的,例如redisRabbitMQ(我们一般使用消息队列服务器中的“发布/订阅”模式来实现事件总线。)

10、贫血模型与充血模型

在前面的小节中,我们主要讲解了关于DDD中常见的概念,从这一小节开发,我们逐步的来看一下关于DDD中这些概念应该怎样进行落地。

在面向对象的设计中有贫血模型和充血模型两种风格。所谓的贫血模型指的是一个类中只有属性或者成员变量,没有方法(有意义的方法,虽然getset也是方法,但是没有具体的其他方面的意义),而充血模型指的是一个类中既有属性、成员变量,也有方法。下面用一个用户的例子来说明它们的区别。

下面通过一段需求结合伪代码来体会一下贫血模型与充血模型

需求:

1
假设我们需要定义一个类,这个类中可以保存用户的用户名、密码、积分;用户必须具有用户名;为了保证安全,密码采用密码的哈希值保存;用户的初始积分为10;每次登录成功奖励5个积分,每次登录失败扣3个积分(这样的需求肯定是不合理的,这里只是为了方便演示而已)。

如果采用贫血模型,我们就会如下定义User类。

1
2
3
4
5
6
class User
{
public string UserName { get; set; } //用户名
public string PasswordHash { get; set; }//密码的哈希值
public int Credit { get; set; } //积分
}

这是一个典型的只包含属性、不包含逻辑方法的类,这样的类通常被叫作POCO类,这就是典型的“贫血模型”。使用这样的类,我们编写代码来进行用户创建、登录、积分变动操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 User u1 = new User();
u1.UserName = "zhangsan";
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("登录失败");
}

上面的代码可以正常地实现需求,但有问题:

这里是将业务逻辑与实体的状态初始化,实体状态的管理等一系列的代码,都写在一起了,导致代码非常的臃肿。

如果我们按照面向对象的原则来重新设计User类,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
 class User
{
public string UserName { get; init; } // 这里将UserName 设置为只读
public int Credit { get; private set; }
private string? passwordHash; // 这里是私有的成员变量,因为密码的哈希值是不应该被外部系统访问的。

public User(string userName)
{
this.UserName = userName; // 在构造函数中给UserName赋值
this.Credit =10;
}
public void ChangePassword(string newValue)
{
if(newValue.Length<6)
{
throw new ArgumentException("密码太短");
}
this.passwordHash = HashHelper.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 ArgumentException("额度不能为负值");
}
this.Credit -= delta;
}
public void AddCredits(int delta)
{
this.Credit += delta;
}
}

Credit属性设置为只读并且只能在User类内部被修改.

passwordHash:是私有的成员变量,因为密码的哈希值是不应该被外部系统访问的。当然为了能够让外部系统发送修改密码的请求以及检查密码是否正确,这里提供了ChangePassword,CheckPassword方法,把保存密码和校验密码的工作封装起来。

通过合理设置User类的属性的访问修饰符,我们有效地避免了外部访问者对类内部数据的随意修改

以上实现的User这个实体类就是充血模型,也就是将针对实体的初始化,状态更改等操作放在了实体类中完成,这就是所谓的充血模型。

具体的业务操作就比较简单了,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
  User u1 = new User("zhangsan");
u1.ChangePassword("123456");
string pwd = Console.ReadLine();
if (u1.CheckPassword(pwd))
{
u1.AddCredits(5);
Console.WriteLine("登录成功");
}
else
{
u1.DeductCredits(3);
Console.WriteLine("登录失败");
}

大家可能会认为,无论是贫血模型还是充血模型,只不过是逻辑代码放置的位置不同而已,本质上没什么区别。这样的观点是错误的。首先,从代码的角度来讲,把本应该属于User类的行为封装到User类中,这是符合“单一职责原则”的,当系统中其他地方需要调用User类的时候就可以复用User中的方法。其次,贫血模型是站在开发人员的角度思考问题的,而充血模型是站在业务的角度思考问题的。领域专家不明白什么是“把用户输入的密码进行哈希运算,然后把哈希值保存起来”,但是他们明白“修改密码、检查密码成功”等充血模型反映出来的概念,因此领域模型中的所有行为应该有业务价值,而不应该只是反映数据属性。

当然,充血模型设计起来比较复杂一些(EFCore中还需要进行配置,使用难度增加),需要仔细分析模型中需要完成的操作。

尽管充血模型带来的好处更明显,但是贫血模型依然很流行,其根本原因就在于早期的很多持久性框架(比如ORM等)要求实体类的所有属性必须是可读可写的,而且我们可以很简单地把数据库中的表按照字段逐个映射为一个贫血模型的POCO类,这样“数据库驱动”的思维方法更简单直接,因此我们就见到“到处都是贫血模型”的情况了。值得欣慰的是,目前大部分主流的持久性框架都已经支持充血模型的写法了,比如EF Core对充血模型的支持就非常好(当然还是需要进行配置),因此我们就没有再继续编写贫血模型的理由了。采用充血模型编写代码,我们能更好地实现DDD和模型驱动编程。

11、EF Core 对实体类属性操作

EF Core对实体类属性的读写操作有一个非常不容易被发现的秘密,了解这个秘密之后,我们能更好地在EF Core中实现充血模型,因此本小节将会为大家揭示这个秘密。

我们知道,对于属性的读写操作都是通过get,set代码块来进行的,因此,当我们通过EF Core 把实体类对象写入数据库或者把数据从数据库中加载到实体对象的时候,EF Core也应该通过实体类对象的属性的get,set进行属性的读写。但是基于性能和对特殊功能支持的考虑,EF Core在读写属性的时候,如果可能,它会直接跳过get,set,而直接操作真正存储属性值的成员变量。

下面来演示一下:

新创建一个控制台的项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<!---安装EFCore需要的包--->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

</Project>

然后创建Person.cs实体类,该类中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace ConsoleApp1
{
public class Person
{
public long Id { get; set; }
private string name;
public string Name {
get {
Console.WriteLine("get 被调用");
return name;
}
set {

name = value;
}
}
}
}

在上面的代码中,我们为了方便观察代码对Name属性的get,set代码块的调用情况,我们编写Name属性的代码的时候没有使用{get;set;}这样简化的语法,而是使用完全的get,set代码块,把属性的值显式地保存到名字为name的成员变量中,并且在get和set代码块中都加入了调试输出的信息。

下面搭建EF Core的环境

创建PersonConfig.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
namespace ConsoleApp1
{
public class PersonConfig : IEntityTypeConfiguration<Person>
{
public void Configure(EntityTypeBuilder<Person> builder)
{
builder.ToTable("T_Persons");
}
}
}

下面再创建TestDbContext.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace ConsoleApp1
{
public class TestDbContext: DbContext
{
public DbSet<Person> Persons { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connStr = "server=.;database=D1;uid=sa;password=123456;TrustServerCertificate=true";
optionsBuilder.UseSqlServer(connStr);

}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
}
}

下面进行数据的迁移操作:

1
2
Add-Migration InitialCreate
Update-database

整个EFCore的环境搭建好以后,下面我们需要创建Person类的对象,并且把对应的数据插入到数据库中,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
using ConsoleApp1;

Person person = new Person();
person.Name = "zhangsan";
Console.WriteLine("完成Person初始化");
using (TestDbContext ctx= new TestDbContext())
{
ctx.Persons.Add(person);
ctx.SaveChanges();
Console.WriteLine("SaveChanges执行完毕");
}

运行上面的程序,然后查看运行结果,发现Name属性的get代码块没有被调用执行。按照常理来讲,SaveChanges方法在将对象插入数据库的时候,需要读取Name属性的值,这时候应该会调用get代码块,但是实验的结果表明get代码块没有被调用,那么EFCore是怎样得到Name属性的值的呢?

下面我们再来读取一下数据,看一下执行情况,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using ConsoleApp1;

/*Person person = new Person();
person.Name = "zhangsan";
Console.WriteLine("完成Person初始化")*/;
using (TestDbContext ctx= new TestDbContext())
{
/* ctx.Persons.Add(person);
ctx.SaveChanges();
Console.WriteLine("SaveChanges执行完毕");*/
Console.WriteLine("准备读取数据");
var p = ctx.Persons.First(p=>p.Name=="zhangsan");
Console.WriteLine("数据读取完毕");
}

我们发现,Name属性的set代码块竟然也没有被调用。按照常理来讲,EF Core在从数据库中读取数据的时候,应该会调用set代码块为对象的Name属性赋值,但是实验的结果表明set代码块没有被调用,那EF Core是怎么设置Name属性的值的呢?

答案其实很简单,**EF Core在读写实体类对象的属性时,会查找类中是否有与属性的名字一样(忽略大小写)的成员变量,如果有这样的成员变量的话,EF Core会直接读写这个成员变量的值,而不是通过set和get代码块来读写**。如果我们采用stirng Name{get;set;}这种简化的语法来声明属性,编译器会为我们生成名字为<Name>k__BackingField的成员变量来保存属性的值,因此EF Core除了查找与属性同名的成员变量之外,也会查找符合<Name>k__BackingField规则的成员变量,还会查找“_name”“m_name”等常见写法的成员变量。

由于EF Core直接读写属性背后的成员变量,而不是通过执行get、set代码块来读写属性的值,因此我们编写的get、set代码块就不会被EF Core执行了

12、 EF Core中实现充血模型

12.1 充血模型实体类特征说明

EF Core中对充血模型提供了比较好的支持,本小节我们来学习如何在EF Core中把充血模型风格的实体类映射到数据库表中

这里我们看一个充血模型,然后总结一下充血模型中实体类的特征:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
 public class User
{
// 因为Id是由数据库生成的自增字段,我们无法修改它的值
public int Id { get } //特征一
// CreatedDateTime(创建日期)在对象创建的时候初始化,之后我们就不能修改这个属性的值,因此我们同样把CreatedDateTime属性修饰为init
public DateTime CreatedDateTime { get; init; } //特征一
// UserName(用户名)、Credit(积分)这两个属性可以由类内部的代码修改,因此我们把它们的set操作修饰为私有的
public string UserName { get; private set; } //特征一
public int Credit { get; private set; }
private string? passwordHash; //特征三
// Remark(备注)是一个只读属性,它的值只能从数据库中读取
private string? remark;
public string? Remark //特征四
{
get { return remark; }
}
// 这里要求Tag属性不被映射到数据库表中
public string? Tag { get; set; } //特征五
private User() //特征二
{
}
public User(string yhm) //特征二
{
this.UserName = yhm;
this.CreatedDateTime = DateTime.Now;
this.Credit = 10;
this.remark="......"
}
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);
}
}

充血模型中的实体类与前面我们所讲的贫血模型中实体类相比,有如下的特征:

第一个特征:有的属性是只读的或者只能被类内部的代码修改

1
public string UserName { get; init; } // 这里将UserName 设置为只读

这是我们前面定义的充血模型案例中的UserName属性,我们可以看到它是只读的,只能在当前类的构造函数中进行初始化。

后面是不能对该属性的值进行修改的。

1
public int Credit { get; private set; }

该属性也是只读的,并且只能在当前类中修改Credit属性的值,外部类是不能修改的。

也就是说,充血模型要能够对以上提到的情况进行支持。

第二个特征:可以定义有参构造函数

1
2
3
4
5
public User(string userName)
{
this.UserName = userName; // 在构造函数中给UserName赋值
this.Credit =10;
}

这里我们定义了一个构造函数User,完成了对UserName属性和Credit属性的赋值。

关于特征二,也就是上面我们提到的实体类中可能包含有参数的构造方法。这里需要注意一个点:

如果定义的充血模型中的实体类只有有参构造方法,而没有无参构造方法,这时候要求有参构造方法中的参数名字必须和属性的名字一致(如上代码),因为在EFCore中从数据库加载数据的时候,它会利用反射的机制来调用有参构造方法来初始化实体对象。只有构造方法的参数名字和属性的名字一致,EFCore才知道构造方法中参数和数据库表的对应关系。

当然,如果在定义的充血模型的实体类中,**有参数构造方法,也有无参构造方法,这就要求无参构造方法定义为private.**这时候EFCore可以调用私有的构造方法,因此EFCore在从数据库中加载数据到实体类对象的时候,会调用这个私有构造方法创建实体对象,然后对各个属性进行赋值,而有参构造可以给开发人员使用。当然,有参构造的参数名字没有必要和属性的名字保持一致,如下代码

1
2
3
4
5
6
7
8
9
private User()                                  //特征二
{
}
public User(string yhm) //特征二
{
this.UserName = yhm;
this.CreatedDateTime = DateTime.Now;
this.Credit = 10;
}

第三个特征:有的成员变量没有对应的属性,但是这些成员变量需要映射为数据库表中的字段,也就是需要我们将私有成员变量映射到数据库表中的列。

如下所以:

1
private string? passwordHash;       

这里的passwordHash,是一个私有的成员变量,没有对应的属性。将其定义成私有成员变量的原因是:关于密码的操作,例如赋值,判断都是在当前实体类中,通过不同方法完成的,也就是说,只在当前类中使用passwordHash,不允许在外部其它类中使用。

所以这里将其定义为私有成员变量。

但是,问题是,我们需要将其映射到数据表中的列。

这应该怎样处理呢?

EFCore中我们只需要在配置实体类的代码中,使用builder.Property("成员变量名")来配置即可。

第四个特征:有的属性是只读的,也就是它的值是从数据库中读取出来的,但是我们不能修改属性的值,如下所示:

1
2
3
4
public string? Remark                           //特征四
{
get { return remark; }
}

例如:上面我们定义的Remark属性,假设它是只读的,这里我们只为其添加get,没有添加set

同时,在EFCore中我们还需要在配置实体类的代码中,使用HasField("成员变量名")来配置属性。

第五个特征:有的属性不需要映射到数据列,仅仅在运行的时候被使用。

针对这种情况,在EFCORE中我们只要在配置实体类的代码中,使用Ignore来配置忽略相关属性即可。

12.2 实现充血模型

在控制台项目中创建User.cs这个充血模型实体类

其中的代码,就是上一小节中的所展示的代码

HashHelper.cs中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class HashHelper
{
public static string Hash(string input)
{
using (MD5 md5Hash = MD5.Create())
{
byte[] data = md5Hash.ComputeHash(Encoding.UTF8.GetBytes(input));
StringBuilder sBuilder = new StringBuilder();
for (int i = 0; i < data.Length; i++)
{
sBuilder.Append(data[i].ToString("x2"));
}
return sBuilder.ToString();
}
}
}

下面在TestDbContext.cs中添加Users这个DbSet这个属性。

1
2
3
4
public class TestDbContext: DbContext
{
public DbSet<Person> Persons { get; set; }
public DbSet<User> Users { get; set; } // 添加Users这个DbSet这个属性

下面需要对User这个实体类进行配置,创建UserConfig.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
public class UserConfig : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("T_Users");
builder.Property("passwordHash");// 针对特征三的配置
builder.Property(u => u.Remark).HasField("remark");// 针对特征四的配置
builder.Ignore(u=>u.Tag); // 针对特征五的配置
// 以上特征四和特征五的应用很少。
}
}

下面进行数据的迁移操作

1
2
Add-Migration CreateUser
Update-database

打开数据库中对应的表,发现passwordHash这个字段是有的,但是没有Tag这个字段

下面开始编写测试的代码:

1
2
3
4
5
6
7
using (TestDbContext ctx = new TestDbContext())
{
User user = new User("wangwu");
user.ChangePassword("1234567");
ctx.Users.Add(user);
ctx.SaveChanges();
}

启动程序进行测试

进行查询:

1
2
3
4
5
6
7
8
9
10
using (TestDbContext ctx = new TestDbContext())
{
/* User user = new User("wangwu");
user.ChangePassword("1234567");
ctx.Users.Add(user);
ctx.SaveChanges();*/
User user = ctx.Users.First(u=>u.UserName == "wangwu");
Console.WriteLine(user.UserName);
}

这里也可以给构造方法打上断点,查看执行的效果。

13、EF Core中实现值对象

13.1 枚举类型的存储

DDD中还存在着一些没有标识符的对象,它们也有多个属性,它们依附于某个实体类对象而存在,这些没有标识符的对象叫作值对象

在定义实体类的时候,实体类中的一些属性之间有着紧密的联系,比如我们要在表示城市的实体类City中定义表示地理位置的属性,因为地理位置包含“经度”(longitude)和“纬度”(latitude)两个值,所以我们可以为City类增加LongitudeLatitude两个属性。这也是大部分人的做法,这样做没什么太大的问题。不过,从逻辑上来讲,这样定义的经纬度和主键、名字等属性之间是平等的关系,体现不出来经度和纬度的紧密关系。如果我们能定义一个包含Longitude、Latitude两个属性的Geo类型,然后把City的“地理位置”属性定义为Geo类型,这样经度、纬度的关系就更紧密了。Geo类型的Longitude、Latitude两个属性通常不会被单独修改,因此Geo被定义成不可变类,也就是值对象。

在定义实体类的时候,实体类中有的属性为数值类型,比如“商品”实体类中的质量属性。我们如果把质量定义为double类型,那么其实隐含了一个“质量单位”的领域知识,使用这个实体类的开发人员就需要知道这个领域知识,而且我们还要通过文档等形式把这个领域知识记录下来,这又面临一个文档和代码修改同步的问题。在DDD中,我们要尽量减少文档中不必要的领域知识。如果我们定义一个包含Value(数值)、Unit(质量单位)的Weight类型,然后把“商品”的质量属性设置为Weight类型,这样的代码中天然包含了数值和质量单位信息。在定义实体类的时候,很多数值类型的属性其实都是隐含了单位的,比如金额隐含了币种信息。理想情况下,这些数值类型的属性都应该定义为包含了计量单位信息的类型。这些包含数值和计量单位的类也一般被定义为不可变的值对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Product{
long Id;
string Name;
Weight Weight;
}
class Weight{
double value;
WeightUnit Unit;

}
// 关于WeightUnit可以定义成一个枚举类型
enum WeightUnit{G,KG}
Proudct p = new Product();
p.Weight = new Weight(5,KG) // 表示是5公斤

我们在编写实体类的时候,有一些属性的可选值范围是固定的,比如“员工”中用来定义职位级别的属性为int类型,可选范围为1~3,它们分别表示“初级”“中级”“高级”。我们用int类型表示级别,因此我们同样需要在文档中说明不同数值的含义。如果我们用C#中的枚举类型来表示这些固定可选值范围的属性,就可以让代码的可读性更强,也就更加符合DDD的思想。

下面,我们看一下怎样对上面提到的枚举类型存储到数据库中。在控制台的项目中,添加一个Book.cs这个实体类,该实体类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace ConsoleApp1
{
public class Book
{
public long Id { get; set; }
public string Name { get; set; }
public CurrencyName Currency { get; set; }

}
public enum CurrencyName {
CNY,USD
}
}

我们知道书的价格是有单位的,针对单位,这里我们定义了Currency这个属性,该属性的类型是一个枚举类型CurrencyName.在该枚举类型中定义了单位成员。

当然这里的属性Currency,可以定义成string类型,但是定义成字符串类型,我们无法直观的看出它的具体单位是什么,还需要查看文档。但是定义成枚举类型以后,就比较直观了。

1
2
3
4
5
6

public class TestDbContext: DbContext
{
public DbSet<Person> Persons { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Book> Books { get; set; } // 添加了Books这个DbSet的类型属性

下面进行数据库的迁移操作

1
2
Add-Migration AddBook
Update-database

下面在Program.cs文件中进测试:

1
2
3
4
5
6
7
8
using (TestDbContext ctx = new TestDbContext())
{
Book book = new Book() { Name="C#", Currency= CurrencyName.CNY };
Book book1 = new Book() { Name="Vue", Currency = CurrencyName.USD };
ctx.Books.Add(book);
ctx.Books.Add(book1);
ctx.SaveChanges();
}

执行完上面的测试代码以后,我们可以查看数据库中对应的Books表(注意:这里我们没有写BookConfig.cs,这个类,也就是没有针对Book这个实体类进行配置,这样全部采用默认的配置),

我们发现,枚举类型的属性在数据库中默认是以int类型来进行保存的。

下面看一下查询的代码:

1
2
3
4
5
using (TestDbContext ctx = new TestDbContext())
{
Book book = ctx.Books.First();
Console.WriteLine(book.Currency);
}

执行上面的查询代码,打印的结果是枚举的内容。(底层做了处理,我们在代码中使用的就是枚举)

当然,这里如果感觉,数据库中存储的枚举用int来进行表示,并不直观,可读性不强,在EFCore中可以使用HasConversion<string>把枚举类型的值配置成字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace ConsoleApp1
{
public class BookConfig : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
// 将枚举转换成字符串
builder.Property(e=>e.Currency).HasConversion<string>();

}
}
}

进行数据库的迁移操作

1
Add-Migration AddBook2

执行完以上的命令以后,会出现如下的警告:

1
2

An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy.

说明:更改后的类型与数据表中,Currency字段的原有类型是不一致,因此有可能会造成数据的丢失,这里我们不需要关心。

下面直接执行

1
Update-database

这时候,查看数据表BooksCurrency字段的类型就是字符串类型,当然这里你也可以限制字符串类型的长度。

下面我们再执行如下的测试代码:

1
2
3
4
5
6
7
8
9
10
11
using (TestDbContext ctx = new TestDbContext())
{
/* Book book = ctx.Books.First();
Console.WriteLine(book.Currency); */

Book book = new Book() { Name = ".Net core", Currency = CurrencyName.CNY };
Book book1 = new Book() { Name = "React", Currency = CurrencyName.USD };
ctx.Books.Add(book);
ctx.Books.Add(book1);
ctx.SaveChanges();
}

执行完上面的测试代码以后,查看Books表中的Currency这个字段的值就变成了字符串的CNY,USD

当然,一般情况下不需要进行该项的配置,除非就是让数据在数据库中展示的更加的清晰。

13.2 值对象类型存储

在这一小节中,我们看一下值对象类型的存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace ConsoleApp1
{
public class Geo
{
public double Latitude { get; init;}
public double Longitude { get; init;}
private Geo() { } // -----这里需要添加无参的构造方法。
public Geo(double longitude,double latitude)
{
if(longitude<-180|| longitude > 180)
{
throw new ArgumentException("longitude");
}
if (latitude < -90 || latitude > 90)
{
throw new ArgumentException("latitude")
}
this.Latitude = latitude;
this.Longitude = longitude;
}
}
}

在上面的代码中,定义了Geo这个值对象类型,并且有经度与维度,同时在构造方法中对传递过来的数据进行了合法性的校验。

然后完成了属性的赋值操作。

这里我们再创建一个Shop.cs商品实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace ConsoleApp1
{
public Shop
{
public int Id { get; set; }
public string? Name { get; set; }
public Geo? Location { get;set; }



}
}

注意:这里商家的编号与商家的名称,我们没有按照充血模型中实体类属性的特性进行配置,主要为了简单。

这里的Location属性就是商家的地址坐标位置,对应的类型Geo这个值对象类型。

如果需要将该值对象存储到数据库中,需要对当前的Shop这个实体类进行配置。

创建ShopConfig.cs这个配置类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
namespace ConsoleApp1
{
public class ShopConfig : IEntityTypeConfiguration<Shop>
{
public void Configure(EntityTypeBuilder<Shop> builder)
{
builder.OwnsOne(x=>x.Location);
}
}
}

这里通过OwnsOne方法指定了Location这个属性是当前Shop的一部分。

注意:这里也是为了操作简单,没有配置其他的内容。

为了进行测试,一定要注意在TestDbContext中添加一个 Shops这个DbSet类型的属性

1
2
3
4
5
6
7

public class TestDbContext: DbContext
{
public DbSet<Person> Persons { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Book> Books { get; set; }
public DbSet<Shop> Shops { get; set; } // 添加Shops这个DbSet类型的属性

下面进行数据库的迁移

1
2
Add-Migration AddShop
Update-database

查看数据库中对应的Shops表,发现Geo中定义的两个属性已经映射成了数据表中的字段了。

下面编写如下的测试代码:

1
2
3
4
5
6
using (TestDbContext ctx = new TestDbContext())
{
Shop shop = new Shop() { Name="ABC", Location=new Geo(11,12) };
ctx.Shops.Add(shop);
ctx.SaveChanges();
}

读取数据:

1
2
3
4
5
6
7
8
9
using (TestDbContext ctx = new TestDbContext())
{
/* Shop shop = new Shop() { Name="ABC", Location=new Geo(11,12) };
ctx.Shops.Add(shop);
ctx.SaveChanges();*/
// 读取商家的名称以及坐标位置
Shop shop = ctx.Shops.First();
Console.WriteLine(shop.Name+":"+shop.Location.Latitude+":"+shop.Location.Longitude);
}

最后,还需要补充一点,如果需要对值对象中的属性进行单独的配置,可以采用如下的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ShopConfig : IEntityTypeConfiguration<Shop>
{
public void Configure(EntityTypeBuilder<Shop> builder)
{
/* builder.OwnsOne(x=>x.Location);*/
// 这里使用了OwnsOne中的第二个参数,是一个回调方法,在这回调方法中对属性进行了进一步的配置。当然,这里的double类型不需要指定最大长度,这里只是为了做一个说明
builder.OwnsOne(x => x.Location, b =>
{
b.Property(e => e.Latitude).HasMaxLength(50);
b.Property(e=>e.Longitude).HasMaxLength(50).HasColumnType("decimal"); // 数据表中的字段采用的是decimal类型
});
}
}

14、 聚合在.Net中的实现

聚合:高内聚,低耦合

把关系强的实体放到同一个聚合中,把其中一个实体作为聚合根

聚合的实现也非常的简单,在EFCore上下文中只为聚合根实体声明DbSet类型的属性,对于非聚合根实体,值对象的操作都通过根实体来进行。

这里我们再创建一个新的控制台项目进行演示。

创建一个商品实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace ConsoleApp2
{
/// <summary>
/// 商品
/// </summary>
public class Proudct
{
public long Id { get; set; }
public string? Name { get; set; }
/// <summary>
/// 商品价格
/// </summary>
public double Price { get; set; }

}
}

创建一个订单的实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace ConsoleApp2
{
/// <summary>
/// 订单
/// </summary>
public class Order
{
public int Id { get; set; }
public DateTime CreateDateTime { get; set; }
/// <summary>
/// 订单总价
/// </summary>
public double TotalAmount { get; set; }
public List<OrderDetail> Details { get; set; } = new List<OrderDetail>();

}
}

再创建一个订单详情的实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace ConsoleApp2
{
/// <summary>
/// 订单明细
/// </summary>
public class OrderDetail
{
public long Id { get; set; }
public Order? Order { get; set; }
public string? Name { get;set; }

public Proudct? Proudct { get; set; }

/// <summary>
/// 买了多少个
/// </summary>
public int Count { get; set; }
}
}

这里的Order与OrderDetail是一对多的关系,同时这两者之间也是聚合的关系,Order是聚合根。

同时在新创建的控制台程序中,安装EFCore响应的包。

1
2
3
4
5
6
7
8
9
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

然后创建MyDbContext.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace ConsoleApp2
{
public class MyDbContext:DbContext
{
public DbSet<Order>Orders { get; set; }
public DbSet<Proudct> Proudcts { get; set;}

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connStr = "server=.;database=D1;uid=sa;password=123456;TrustServerCertificate=true";
optionsBuilder.UseSqlServer(connStr);

}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
}
}

在这里我们可以看到,在MyDbContext中定义的DbSet属性是Orders,Products,都是聚合根实体。

非聚合根OrderDetail,就不需要创建对应的DbSet属性了。

如果,现在要下订单,需要创建订单明细,应该怎样处理呢?

修改Order.cs 这个实体类中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Order
{
public int Id { get; set; }
public DateTime CreateDateTime { get; set; }
/// <summary>
/// 订单总价
/// </summary>
public double TotalAmount { get; set; }
//

public List<OrderDetail> Details { get; set; } = new List<OrderDetail>();
// -------------------------- 添加了一个AddDetail方法
public void AddDetail(Proudct proudct,int count)
{
// 判断订单明细中是否有该商品
var detail = Details.FirstOrDefault(d=>d.Proudct==proudct);
if(detail == null)
{
// 没有进行添加
detail = new OrderDetail() { Proudct=proudct,Count=count };
Details.Add(detail);
}
else
{
// 有进行累加
detail.Count += count;

}


}

}

怎样调用呢?

返回到Program.cs文件中,进行代码的修改:

1
2
3
4
5
6
7
8
9
10
11
12
using ConsoleApp2;
using (MyDbContext ctx=new MyDbContext())
{
Order order = new Order();
order.AddDetail(new Proudct() { Name = "苹果", Price = 100 }, 5);
ctx.Orders.Add(order);
ctx.SaveChanges();


}


通过上面的测试,我们可以看到这里我们只是使用了聚合根

同时在聚合根Order中完成了对其他子实体的操作。

以上都是伪代码。

总结:关于.Net中实现聚合:

第一:在聚合根实体中定义对聚合内的所有实体进行操作的方法,例如上面提到的的AddDetail方法。

第二:只在DbContext中为聚合根定义DbSet属性。

问题:怎样区分聚合根实体和其他实体呢?

例如:上面定义的Order和OrderDetail,怎样能够很清晰的看出哪个实体就是聚合根实体呢?

这里我们可以定义一个不包含任何成员的标识接口(接口的名字随意命名),这个随意命名的接口,只是起到一个标识的作用。

然后要求所有的聚合根实体都要实现这个接口。

1
2
3
4
5
6
7
namespace ConsoleApp2
{
public interface IAgateRoot
{
}
}

上面我们创建了一个IAgateRoot的接口,该接口中没有任何的成员。

下面要求所有的聚合根实现该接口,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// 商品
/// </summary>
public class Proudct:IAgateRoot // ---------------实现了IAgateRoot这个标识的接口
{
public long Id { get; set; }
public string? Name { get; set; }
/// <summary>
/// 商品价格
/// </summary>
public double Price { get; set; }
}
1
2
3
4
5
6

/// <summary>
/// 订单
/// </summary>
public class Order:IAgateRoot // 这里的Order这个聚合根实体也实现了IAgateRoot这个标识接口
{

聚合根虽然实现了IAgateRoot这个标识,但是不会影响到具体的实现。

如果后期由于业务的需求,我们实现了一个方法,只要求传递聚合根,可以定义成如下的泛型方式:

1
AddMethod<T>(T t) where T:IAgateRoot

注意: 跨聚合进行实体引用,只能引用根实体,并且只能引用根实体的Id,而不是根实体对象。

在前面我们所定义的订单明细实体OrderDetail.cs中,引用了聚合实体Proudct,但是这里需要注意的一点,就是最好不要直接使用Proudct这个实体类型。而是定义具体的Id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class OrderDetail
{
public long Id { get; set; }
public Order? Order { get; set; }
public string? Name { get;set; }

/* public Proudct? Proudct { get; set; }*/
// -------------------------引用根实体的id
public long ProudctId { get; set; }
/// <summary>
/// 买了多少个
/// </summary>
public int Count { get; set; }
}

以上只是通过伪代码,说明了概念,后面有具体的案例

15、使用MediatR实现领域事件

领域事件就是进程内事件,也就是一个微服务内的事件。

针对领域事件一般有两种实现方式:

第一种实现方式:传统的C#事件机制

1
2
var event  =  new XXXEvent();
event.Completed+= event_Completed

这种实现方式要求,事件的处理者被显式地注册到事件的发布者对象中,这种显式注册的方式不是很灵活。事件的发布和事件的处理之间的耦合性比较强

第二种方式:针对进程内的事件传递,可以使用MediatR这个开源库来完成。它可以实现事件的发布和事件的处理之间的解耦。

MediatR中支持一个发布者对应一个处理者一个发布者对应多个处理者两种模式。

后面这种模式使用的更加的广泛,所以这里我们就重点来看一下这种处理模式。

下面我们来看一下MediatR的使用。

第一步:创建一个Asp.net Core项目(这里我们创建一个WebApi的项目来演示)。创建好项目以后,在该项目中通过NuGet安装对应的包(注意:在安装的时候,一定要选择当前的WebApi项目)

1
Install-Package MediatR.Extensions.Microsoft.DependencyInjection

第二步:在项目的Program.cs中调用调用AddMediatR方法,把MediatR相关的服务注册到容器中。

AddMediatR方法的参数中一般指定事件处理者所在的若干个程序集。

1
builder.Services.AddMediatR(Assembly.Load("程序集"));

在当前的案例中,事件处理者所在的程序集就是当前项目,所以这里的具体注册如下所示:

1
2
builder.Services.AddSwaggerGen();
builder.Services.AddMediatR(Assembly.GetExecutingAssembly()); // 注册MediatR,指定的事件处理者是当前的程序集,如果是分层项目,需要指定的就是事件处理者所在的类库项目的程序集

第三步:

定义一个在事件的发布者和处理者之间进行数据传递的类,这个类我们假设就叫做TestEvent,这个类必须要实现INotification接口。

如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12

namespace WebApplication1
{
public class TestEvent:INotification
{
public string UserName { get; set; }
public TestEvent(string UserName) {
this.UserName = UserName;
}
}
}

TestEvent这个构造方法中的UserName参数表示的是登录用户的用户名。

第四步:发布事件

在需要发布事件的类中注入IMediator类型的服务,然后我们可以调用Publish方法来发布事件。

注意:这里调用的是Publish方法来发布事件,不要调用Send方法。Send方法也可以实现发布事件,但是Send方法是用来发布一对一事件的,而Publish方法是用来发布一对多事件的。

创建一个TestController控制器,该控制器中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[Route("api/[controller]")]
[ApiController]
public class TestsController : ControllerBase
{
private readonly IMediator mediator; // 注入IMediator
public TestsController(IMediator mediator)
{
this.mediator = mediator;

}
[HttpGet]
public async Task<IActionResult> Test()
{
// 完成了用户的注册

// 发布事件(发送邮件)
await mediator.Publish(new TestEvent("zhangsan"));
return Ok("ok");
}
}

第五步:创建事件的处理者

事件的处理者要求实现NotificationHandler<TNotification>

其中的泛型参数TNotification代表此事件处理者要处理的消息类型。所有TNotification类型的事件都会被事件处理者处理

我们编写两个事件处理者,来对发出的事件消息进行处理。

先创建一个TestEventHanderl.cs,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace WebApplication1
{
public class TestEventHanderl : NotificationHandler<TestEvent>
{
protected override void Handle(TestEvent notification)
{
Console.WriteLine("123" +notification.UserName);


}
}
}

再创建一个事件处理者TestEventHandler2.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
namespace WebApplication1
{
public class TestEventHandler2 : NotificationHandler<TestEvent>
{
protected override void Handle(TestEvent notification)
{
Console.WriteLine("555566" + notification.UserName);
}
}
}

启动项目,进行测试,查看控制台中打印的结果。

如果我们使用await的方式来调用Publish方法,那么程序会等待所有的事件处理者的Handle方法执行完成后才继续向后执行,因此事件发布者和事件处理者的代码是运行在相同的调用堆栈中的,这样我们可以轻松地实现强一致性的事务。如果事件发布者不需要等待事件处理者的执行,那么我们可以不用await方法来调用Publish方法;即使我们需要使用await方法来调用Publish方法发布事件,如果某个事件处理者的代码执行太耗时,为了避免影响用户体验,我们也可以在事件处理者的Handle方法中异步执行事件的处理逻辑。如果我们选择不等待事件处理者,就要处理事务的最终一致性。

16、EF Core中发布领域事件的合适时机

在上一小节中,我们简单了解了MediatR的使用,问题是:我们什么时候发布领域事件呢?

例如:用户注册成功,我们就可以发布一个发送邮件的事件了。

为了进行说明,这里我们有重新创建了一个WebApi项目。

让后在项目中,把前面创建的User领域实体拷贝过来。对应的DbContext中的内容也拷贝过来进行说明

User.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
namespace WebApplication2
{
public class User
{
// 因为Id是由数据库生成的自增字段,我们无法修改它的值,所以我们把Id属性修饰为init;
public int Id { get; init; } //特征一
// CreatedDateTime(创建日期)在对象创建的时候初始化,之后我们就不能修改这个属性的值,因此我们同样把CreatedDateTime属性修饰为init
public DateTime CreatedDateTime { get; init; } //特征一
// UserName(用户名)、Credit(积分)这两个属性可以由类内部的代码修改,因此我们把它们的set操作修饰为私有的
public string UserName { get; private set; } //特征一
public int Credit { get; private set; }
private string? passwordHash; //特征三
// Remark(备注)是一个只读属性,它的值只能从数据库中读取
private string? remark;
public string? Remark //特征四
{
get { return remark; }
}
// 这里要求Tag属性不被映射到数据库表中
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);
}
}
}

UserConfig.cs中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 WebApplication2
{
public class UserConfig : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("T_Users");
builder.Property("passwordHash");// 针对特征三的配置
builder.Property(u => u.Remark).HasField("remark");// 针对特征四的配置
builder.Ignore(u=>u.Tag); // 针对特征五的配置
}
}
}

HashHelper.cs中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

namespace WebApplication2
{
public class HashHelper
{
public static string Hash(string input)
{
using (MD5 md5Hash = MD5.Create())
{
byte[] data = md5Hash.ComputeHash(Encoding.UTF8.GetBytes(input));
StringBuilder sBuilder = new StringBuilder();
for (int i = 0; i < data.Length; i++)
{
sBuilder.Append(data[i].ToString("x2"));
}
return sBuilder.ToString();
}
}
}
}

这里还需要将EFCore的包安装一下(直接拷贝):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<ItemGroup>
<!--------MediatR包------------>
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>
<!-----------EFCore的包---------->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

TestDbContext.cs中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace WebApplication2
{
public class TestDbContext: DbContext
{
public DbSet<User> Users { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// 这里修改了数据库的名字,为`D2`
string connStr = "server=.;database=D2;uid=sa;password=123456;TrustServerCertificate=true";
optionsBuilder.UseSqlServer(connStr);

}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
}
}

下面进行数据库的迁移操作(一定要注意切换到新创建的项目后,进行数据的迁移操作)

1
2
Add-Migration CreateUser
Update-database

现在创建一个UsersController.cs控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

[Route("api/[controller]")]
[ApiController]
public class UsersController : ControllerBase
{
public async Task<IActionResult> Test()
{
User user = new User("zhangsan");
using (TestDbContext ctx = new TestDbContext())
{
ctx.Users.Add(user);
await ctx.SaveChangesAsync();

// 这里可以发送事件,表示用户添加成功了,
}
return Ok("ok");
}
}

我们可以在SaveChangesAsync方法执行完毕以后手动写发送事件的代码,但是问题是:如果新增用户的模块代码比较多,有可能漏掉发送事件的情况。

如果我们在User.cs这个实体类的构造方法中,发送事件的话,就可以解决这个问题。

假如:修改密码完成以后,也需要发送事件,这时候我们可以在User.cs这个实体类的ChangePassword这个方法中发布事件。

但是这种方式也有问题:

例如:如果在构造函数中发布事件,用户信息还没有保存到数据库中,也就是说事件发布的有点早(实际上就是误报)。

或者说,更改密码,更改用户信息的时候,发布事件,有可能会出现重复发布事件,因为这里我们说的是更改用户发布事件,也就是说,更改了用户的年龄,手机号等属性都会发送事件,而我们真正的需求可能仅仅是需要更改用户名的时候才会发送事件,

这里所采用的解决方案是:

我们可以把领域事件的发布延迟到上下文保存修改的时候,也就是实体类中只是注册要发布的领域事件,然后在上下文的SaveChanges方法被调用的时候,我们再发布领域事件。

领域事件是有聚合根进行管理的,因此我们定义了供聚合根进行事件注册的接口IDomainEvents.(这个接口不仅仅是标识了,还要在其中定义方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace WebApplication2
{
public interface IDomainEvents
{
/// <summary>
/// 获取注册的领域事件
/// </summary>
/// <returns></returns>
IEnumerable<INotification> GetDomainEvents();
/// <summary>
/// 注册领域事件
/// </summary>
/// <param name="eventItem"></param>
void AddDomainEvent(INotification eventItem);

/// <summary>
/// 清除注册的领域事件
/// </summary>
void ClearDomainEvents();
}
}

为了简化实体类的代码编写,我们编写了实现IDomainEvents接口的抽象实体类BaseEntity.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace WebApplication2
{
public class BaseEntity : IDomainEvents
{
private List<INotification> DomainEvents = new List<INotification>();
public void AddDomainEvent(INotification eventItem)
{
DomainEvents.Add(eventItem);
}

public void ClearDomainEvents()
{
DomainEvents.Clear();
}

public IEnumerable<INotification> GetDomainEvents()
{
return DomainEvents;
}
}
}

下面,就可以让User.cs这个实体类,继承上面所定义的BaseEntity.cs这个类。

1
2
3
4
namespace WebApplication2
{
public class User:BaseEntity
{

下面创建一个新增用户后进行事件发布于事件处理之间进行数据传递的类NewUserNotification.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace WebApplication2
{
public class NewUserNotification:INotification
{
public string? UserName { get; set; }
public DateTime? CreatedAt { get; set; }
public NewUserNotification(string userName,DateTime dateTime)
{
this.UserName = userName;
this.CreatedAt = dateTime;
}
}
}

再创建一个更改用户后进行事件发布于事件处理之间进行数据传递的类UserNameChangeNotification.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace WebApplication2
{
public class UserNameChangeNotification:INotification
{
public string? OldUserName { get; set; }
public string? NewUserName { get; set; }
public UserNameChangeNotification(string oldUserName,string newUserName) {
this.OldUserName = oldUserName;
this.NewUserName = newUserName;
}
}
}

怎样进行事件的注册呢?

这里需要修改User.cs这个实体类中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public User(string yhm)                         //特征二
{
this.UserName = yhm;
this.CreatedDateTime = DateTime.Now;
this.Credit = 10;
// 注册事件(这里仅仅是注册了事件,并没有发布事件,所以不会存在`误报`的情况)
AddDomainEvent(new NewUserNotification(yhm,this.CreatedDateTime));
}
public void ChangeUserName(string newValue)
{
// 旧的用户名
string oldUserName = this.UserName;
// 新用户名
this.UserName = newValue;
// 注册事件
AddDomainEvent(new UserNameChangeNotification(oldUserName,newValue));

}

在上面的代码中,在User构造函数和ChangeUserName 方法中完成了相应的事件的注册。

下面我们需要修改TestDbContext.cs这个上下文类中的代码,这里重写SaveChangesAsync方法,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// ChangeTracker:是上下文用来对实体类的变化进行追踪的对象
// Entries<IDomainEvents>():获取所有实现了IDomainEvents接口的追踪实体类
var domainEntities = this.ChangeTracker.Entries<IDomainEvents>().Where(e => e.Entity.GetDomainEvents().Any());// 获取所有的没有发布的事件,Any():用来确定序列中是否包含元素(是否有数据)

// 获取所有待发布的消息(通过循环嵌套的方式)
var dominEvents = new List<INotification>();
foreach (var domainEntity in domainEntities)
{
foreach(var entity in domainEntity.Entity.GetDomainEvents())
{
dominEvents.Add(entity);
}
}

domainEntities.ToList().ForEach(e=>e.Entity.ClearDomainEvents());
foreach(var entity in dominEvents)
{
await mediator.Publish(entity);
}

// 把消息的发布放到base.SaveChangesAsync方法之前,可以保证领域事件响应代码中的事务操作(有可能操作数据库)和 base.SaveChangesAsync中的代码在同一个事务中。
return await base.SaveChangesAsync(cancellationToken); // 注意await
}

在上面的代码中,我们重写了DbContext中的SaveChangesAsync方法。在调用父类的SaveChangesAsync方法保存数据修改之前,我们把所有实体类注册的领域事件发布出去。

1
2
3
4
5
6
public DbSet<User> Users { get; set; }
private readonly IMediator mediator; // 注入IMediator
public TestDbContext(IMediator mediator)
{
this.mediator = mediator;
}

下面我们创建对应的事件处理程序

创建一个NewUserHandler.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
namespace WebApplication2
{
public class NewUserHandler : NotificationHandler<NewUserNotification>
{
protected override void Handle(NewUserNotification notification)
{
Console.WriteLine("创建了用户" + notification.UserName);
}
}
}

创建一个UserNameChangeHandler.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
namespace WebApplication2
{
public class UserNameChangeHandler : NotificationHandler<UserNameChangeNotification>
{
protected override void Handle(UserNameChangeNotification notification)
{
Console.WriteLine($"用户名从{notification.OldUserName}变成了{notification.NewUserName}");
}
}
}

注意:不要忘记需要在Program.cs文件中完成注入

1
2
3
builder.Services.AddSwaggerGen();
builder.Services.AddMediatR(Assembly.GetExecutingAssembly()); // 将MediatR添加到容器中
var app = builder.Build();

下面修改UsersController.cs控制器中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class UsersController : ControllerBase
{
// 这里我们注入了`TestDbContext`
private TestDbContext context;
public UsersController(TestDbContext testDbContext)
{
this.context = testDbContext;
}
[HttpGet] // ----添加了HttpGet
public async Task<IActionResult> Test()
{
User user = new User("zhangsan"); // 会添加一个添加用户的事件
user.ChangeUserName("wangwu"); // 会添加一个更新用户名的事件
context.Users.Add(user);
await context.SaveChangesAsync();

return Ok("ok");
}
}

这里还需要再次修改Program.cs文件中的代码,将TestDbContext添加到容器中

1
2
3
4
5
6
7
8
builder.Services.AddMediatR(Assembly.GetExecutingAssembly());
// 添加TestDbContext
builder.Services.AddDbContext<TestDbContext>(opt =>
{
string connStr = "server=.;database=D2;uid=sa;password=123456;TrustServerCertificate=true";
opt.UseSqlServer(connStr);

});

下面还需要修改一下TestDbContext.cs文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TestDbContext: DbContext
{
public DbSet<User> Users { get; set; }
private readonly IMediator mediator; // 注入IMediator
// ------------由于是注入了TestDbContext,这里的构造方法中必须添加DbContextOptions,同时调用父类的构造方法,传递对应的参数options
public TestDbContext(DbContextOptions options, IMediator mediator):base(options)
{
this.mediator = mediator;
}
//--------------------这段代码需要注释掉,因为数据库链接字符串已经写到了AddDbContext方法中
/* protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connStr = "server=.;database=D2;uid=sa;password=123456;TrustServerCertificate=true";
optionsBuilder.UseSqlServer(connStr);

}*/

启动项目进行测试,查看数据库中已经保存了对应的用户信息。

同时控制台中展示了触发的事件内容。

17、DDD实战案例

前面我们已经介绍了关于DDD中的常见的一些概念,但是没有演示它们在代码中的具体实现,也没有演示它们与充血模型、领域事件等的综合应用。本节中,我们将会通过实现一个项目案例来演示这些DDD概念的综合性技术落地。

这个案例是一个包含用户管理、用户登录功能等待的微服务。

为了简化问题,这个案例中没有对接口调用进行鉴权

17.1 项目分层

这个案例分为Users.DomainUsers.InfrastructureUsers.WebAPI这3个项目

Users.Domain是领域层项目,主要包含实体类、值对象、领域事件数据类、领域服务、仓储接口

Users.Infrastructure是基础设施项目,主要包含实体类的配置、上下文类的定义、仓储服务的实现,基础工具类等

Users.WebAPI是ASP.NET Web API项目,主要包含应用服务(这里的webapi就是应用服务)、Controller类、领域事件处理者、数据校验、权限校验、工作单元、事务处理等代码

这里的项目拆分并不是必须遵守的规范,不同的项目有不同的拆分方法

领域模型不应该和具体的数据库耦合,因此Users.Domain项目中只定义了实体类、值对象,而上下文类、实体类的配置等和具体的数据库相关的代码被定义在Users.Infrastructure项目中。领域服务是和领域模型相关的,因此UserDomainService被定义在Users.Domain项目中。UserDomainService需要调用仓储功能来获取数据、保存数据,因此体现仓储功能的接口IUserDomainRepository也被定义在Users.Domain项目中。由于仓储的具体实现是和具体数据库相关的,因此仓储接口IUserDomainRepository的实现类UserDomainRepository被定义在Users.Infrastructure项目中。

17.2 领域模型的实现

先创建一个空白的解决方案UserMgr

然后在创建一个类库项目UserMgr.Domain,在该类库项目中创建领域模型。

本小节中,我们将实现实体类、值对象等基础的领域模型,并且识别和定义出聚合及聚合根,这是DDD的战术起点。这些代码都位于Users.Domain项目中。

1、作为一个用户管理系统,“用户”(User)是我们识别出的第一个实体类;“用户登录失败次数过多则锁定”这个需求并不属于“用户”这个实体类中一个常用的特征,我们应当把它拆分到一个单独的实体类中,因此我们识别出一个单独的“用户登录失败”(UserAccessFail)实体类(失败的信息需要保存到数据库中);为什么说用户登录失败次数过多被锁定并不是用户这个实体类中一个常用的特征呢?因为,这个需求仅仅是在用户登录的时候使用,当后面,其他业务场景是使用不到的,例如,获取用户下的订单等。

2、“用户登录记录”,什么时候登录的,也需要保存到数据库中,是一种业务日志的行为,这里也将其单独的设计为一个实体(**UserLoginHistory**)。

当实体分析完以后,下面分析的就是聚合根

3、这里将UserUserAccessFail设计为同一个聚合,因为User和UserAccessFail的关系是非常紧密的,UserAccessFail不会独立于User存在,而且我们只有访问到User的时候才会访问UserAccessFail,因此将UserUserAccessFail设计为同一个聚合,并且把User设置为聚合根。

由于我们有单独查询一段时间内的登录记录等独立于某个用户的需求,因为我们将UserLoginHistory设计为一个单独的聚合。

当然,这里如果你的需求是查询某个用户登录的信息,可以将UserLoginHistoryUser设计为同一个聚合。

并没有固定的答案,一切以需求为主。

4、关于DbContext不会定义在领域层项目中,而是定义在基础设施项目中。

5、我们的系统中需要保存手机号,由于该系统可能被海外用户访问,而海外用户的手机号还需要包含“国家/地区码”,因此我们设计了用来表示手机号的值对象PhoneNumber

6、为了区分聚合根实体类和普通实体类,我们定义了不包含任何成员的标识接口IAggregateRoot,并且让所有的聚合根实体类实现这个接口。

UserMgr.Domain这个项目中,创建Entities文件夹,在该文件夹中存放对应的实体类

首先创建标识聚合根实体类的标识接口IAggregateRoot.cs

1
2
3
4
5
6
7
namespace UserMgr.Domain.Entities
{
public interface IAggregateRoot
{
}
}

创建User.cs这个实体类(只是完成了实体状态的更改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
namespace UserMgr.Domain.Entities
{
public class User:IAggregateRoot // 实现了IAggregateRoot接口
{
/// <summary>
/// 标识--只读
/// </summary>
public long Id { get; init; }
/// <summary>
/// 手机号可以修改,但是只能在本类中修改
/// </summary>
public PhoneNumber PhoneNumber { get; private set; }

private string? passwordHash; // 密码散列值

public UserAccessFail UserAccessFail { get; private set; }
private User() { }
public User(PhoneNumber phoneNumber)
{
this.PhoneNumber = phoneNumber;
// 表示当前用户登录失败
this.UserAccessFail = new UserAccessFail(this);
}
/// <summary>
/// 判断是否设置了密码
/// </summary>
/// <returns></returns>
public bool HasPassword() {
return !string.IsNullOrWhiteSpace(this.passwordHash);
}
/// <summary>
/// 修改密码
/// </summary>
/// <param name="password">传递过来的新密码</param>
public void ChangePassword(string password)
{
if (password.Length <=3)
{
throw new ArgumentOutOfRangeException("密码长度必须大于3");
}
this.passwordHash =HashHelper.Hash(password);
}
/// <summary>
/// 判断密码是否输入正确
/// </summary>
/// <param name="password"></param>
/// <returns></returns>
public bool CheckPassword(string password)
{
return this.passwordHash == HashHelper.Hash(password);
}
/// <summary>
/// 修改手机号码
/// </summary>
/// <param name="phoneNumber"></param>
public void ChangePhoneNumber(PhoneNumber phoneNumber)
{
this.PhoneNumber = phoneNumber;
}
}
}

创建UserAccessFail.cs实体类,表示登录失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
namespace UserMgr.Domain.Entities
{
public class UserAccessFail
{
public long Id { get; init; }

public User User { get; init; }
/// <summary>
/// 用户编号,也可以不用添加,添加上更加清晰
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 是否被锁定,由于只在本类中使用,直接定义成私有的成员变量
/// </summary>
private bool isLockOut { get; set; }
/// <summary>
/// 被锁定结束日期
/// </summary>
public DateTime? LockEnd { get;private set; }
/// <summary>
/// 登录失败次数
/// </summary>
public int AccessFailCount { get; private set; }
/// <summary>
/// 为EFCore使用
/// </summary>
private UserAccessFail() { }
/// <summary>
/// 为程序员使用
/// </summary>
/// <param name="user">表示哪个用户登录失败</param>
public UserAccessFail(User user)
{
this.User = user;
}
/// <summary>
/// 重置方法
/// </summary>
public void Rest()
{
this.AccessFailCount = 0;
this.LockEnd = null;
this.isLockOut = false;
}
/// <summary>
/// 登录失败的处理
/// </summary>
public void Fail()
{
this.AccessFailCount++;
if (this.AccessFailCount >= 3)
{
this.isLockOut = true;
// 锁定5分钟
this.LockEnd= DateTime.Now.AddMinutes(5);
}
}
/// <summary>
/// 是否已经锁定
/// </summary>
/// <returns></returns>
public bool IsLockOut()
{
if (isLockOut)
{
if (LockEnd >= DateTime.Now)
{
return true;
}
else
{
Rest();
return false;


}
}
else
{
return false;
}
}
}
}

HashHelper.cs这个负责加密密码的类,也放在了Entities文件夹中,当然,该类实际上应该放在基础项目中,这里为了简单,所以放在了Entities文件夹中。

创建UserLoginHistory.cs类,表示用户登录的记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace UserMgr.Domain.Entities
{
public class UserLoginHistory:IAggregateRoot // 实现IAggregateRoot接口
{
public long Id { get; init; }
public long? UserId { get; init; } // 用户编号 ,这里设置为可为空的原因是,用户输入的手机号是错误的,就没有对应的用户编号

public PhoneNumber PhoneNumber { get; init; } // 手机号

public DateTime CreateDateTime { get; init; } // 时间

public string? Message { get; init; } // 消息

private UserLoginHistory() { }

public UserLoginHistory(long userId,PhoneNumber phoneNumber,string message)
{
this.UserId = userId;
this.PhoneNumber = phoneNumber;
this.Message = message;
}
}
}

注意:这里有一个属性UserId,表示用户的编号,是外键。

这里没有添加User类型的属性,原因是:当前的UserLoginHistory类,也是一个聚合根实体,为了以后拆分微服务的方便,只是添加了UserId属性。

当然前面我们所创建的UserAccessFail.cs类中确有User类型的属性,原有是UserAccessFail类与User类是同一个聚合,即使以后进行微服务的拆分,两者也不会进行拆分,就是因为这两个类是同一个聚合。

最后在UserMgr.Domain项目中,创建一个ValueObjects文件夹,存放值对象PhoneNumber.cs

代码:

1
2
3
4
5
6
7
8
9
namespace UserMgr.Domain.ValueObjects
{
public class PhoneNumber
{
public int RegionNumber { get; set; } // 地区号
public string? PnoneNumber { get; set; }
}
}

关于领域模型在这里创建完毕。

17.3 领域服务的实现

领域服务需要使用仓储接口来通过持久层读写数据,因此我们需要在Users.Domain项目中编写仓储接口IUserRepository

具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace UserMgr.Domain
{
public interface IUserRepository
{

/// 根据手机号码查询用户信息
public Task<User?> FindOneAsync(PhoneNumber phoneNumber);
// 根据用户编号查询用户信息
public Task<User?> FindOneAsync(long userId);
// 添加用户登录的日志信息
public Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string message);
// 保存手机验证码
public Task SavePhoneNumberCodeAsync(PhoneNumber phoneNumber, string code);

//用于获取保存的短信验证码。
public Task<string?> RetrievePhoneCodeAsync(PhoneNumber phoneNumber);
}
}

同时,在Users.Domain项目中创建一个发送短信的接口

1
2
3
4
5
6
7
8
namespace UserMgr.Domain
{
public interface ISmsCodeSender
{
Task SendCodeAsync(PhoneNumber phoneNumber, string code);
}
}

实体类中定义的方法只是和特定实体类相关的业务逻辑代码,而跨实体类、跨聚合的代码需要定义在领域服务或者应用服务中。因此我们编写领域服务UserDomainService.cs

UserMgr.Domain项目中创建UserDomainServer.cs文件

代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UserMgr.Domain.Entities;
using UserMgr.Domain.ValueObjects;

namespace UserMgr.Domain
{
public class UserDomainService
{
private readonly IUserRepository _userRepository;
private readonly ISmsCodeSender _smsSender;
public UserDomainService(IUserRepository userRepository, ISmsCodeSender smsSender)
{
this. _userRepository = userRepository;
this. _smsSender = smsSender;
}
// 检测用户登录
public async Task<UserAccessResult> CheckLoginAsync(PhoneNumber phoneNumber,string password)
{
User? user = await _userRepository.FindOneAsync(phoneNumber);
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);//处理登录失败
}
}
return result;
}
// 发送验证码
public async Task<UserAccessResult> SendCodeAsync(PhoneNumber phoneNum)
{
var user = await _userRepository.FindOneAsync(phoneNum);
if (user == null)
{
return UserAccessResult.PhoneNumberNotFound;
}
if (IsLockOut(user))
{
return UserAccessResult.Lockout;
}
string code = Random.Shared.Next(1000, 9999).ToString();
await _userRepository.SavePhoneNumberCodeAsync(phoneNum, code);
await _smsSender.SendCodeAsync(phoneNum, code);
return UserAccessResult.OK;
}
/// <summary>
/// 检测验证码
/// </summary>
/// <param name="phoneNum">手机号码</param>
/// <param name="code">验证码</param>
/// <returns></returns>
public async Task<CheckCodeResult> CheckCodeAsync(PhoneNumber phoneNum, string code)
{
var user = await _userRepository.FindOneAsync(phoneNum);
if (user == null)
{
return CheckCodeResult.PhoneNumberNotFound;
}
if (IsLockOut(user))
{
return CheckCodeResult.Lockout;
}
// 根据手机号查询已经保存的验证码
string? codeInServer = await _userRepository.RetrievePhoneCodeAsync(phoneNum);
if (string.IsNullOrWhiteSpace(codeInServer))
{
return CheckCodeResult.CodeError;
}
if (code == codeInServer)
{
return CheckCodeResult.OK;
}
else
{
AccessFail(user);
return CheckCodeResult.CodeError;
}
}

public bool IsLockOut(User user)
{
return user.UserAccessFail.IsLockOut();
}

public void AccessFail(User user)
{
user.UserAccessFail.Fail();
}

public void ResetAccessFail(User user)
{
user.UserAccessFail.Rest();
}

}
}

以上就是在领域服务中实现的业务逻辑,具体的业务逻辑都是在领域服务中实现

同时在UserMgr.Domain类库项目中UserAccessResultCheckCodeResult枚举。

CheckCodeResult枚举中的代码

1
2
3
4
5
6
7
8
namespace UserMgr.Domain
{
public enum CheckCodeResult
{
OK, PhoneNumberNotFound, Lockout,CodeError
}
}

UserAccessResult枚举中的代码,如下所示:

1
2
3
4
5
6
7
namespace UserMgr.Domain
{
public enum UserAccessResult
{
OK, PhoneNumberNotFound, Lockout, NoPassword, PasswordError
}
}

以上就是领域服务中的代码,完整的封装了对应的业务逻辑。

最后一个问题就是领域事件的实现,为了简单,这里我们也是定义了领域层项目中。

当然,这里也是在接口中定义一个对应的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface IUserRepository
{
public Task<User?> FindOneAsync(PhoneNumber phoneNumber);
public Task<User?> FindOneAsync(long userId);

public Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string message);

public Task SavePhoneNumberCodeAsync(PhoneNumber phoneNumber, string code);


//方法用于获取保存的短信验证码。
public Task<string?> RetrievePhoneCodeAsync(PhoneNumber phoneNumber);
// --------------------发布领域事件
public Task PublishEventAsync(UserAccessResultEvent eventData);
}

UserAccessResultEvent类中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace UserMgr.Domain.Events
{
public class UserAccessResultEvent: INotification
{
public PhoneNumber PhoneNumber { get; set; }
public UserAccessResult UserAccessResult { get; set; }
public UserAccessResultEvent(PhoneNumber phoneNumber, UserAccessResult userAccessResult)
{
this.PhoneNumber = phoneNumber;
this.UserAccessResult = userAccessResult;
}
}
}

当校验完登录用户的信息以后,发布领域事件,修改UserDomainService.cs类中的CheckLoginAsync方法中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (user != null)
{
if (result == UserAccessResult.OK)
{
this.ResetAccessFail(user);//重置
}
else
{
this.AccessFail(user);//处理登录失败
}
}
// -----------------发布领域事件
UserAccessResultEvent eventItem = new(phoneNumber, result);
await _userRepository.PublishEventAsync(eventItem);
return result;

在类库项目中安装MediatR

1
Install-Package MediatR.Extensions.Microsoft.DependencyInjection

17.4 基础设施的实现

17.4.1、实体类配置

在领域层项目中,实现了领域模型,领域服务还有仓储的接口。而在基础设施项目中,要完成实体类的配置、上下文类的定义、仓储服务的实现等操作。

在解决方法方案中创建一个新的类库项目UserMgr.Infrastructure

该类库项目要引用一下UserMgr.Domain项目

同时在UserMgr.Infrastructure这个基础设施的项目中安装EFCore 需要的包。

1
2
3
4
5
6
7
8
<!---安装EFCore对应的包--->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

在项目中创建Configs文件夹,然后在该文件夹中 创建UserConfig.cs类,完成对User.cs实体类的配置操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace UserMgr.Infrastructure.Configs
{
public class UserConfig : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("T_Users");
// 配置值对象,将RegionNumber和PnoneNumber作为两个字段
builder.OwnsOne(x => x.PhoneNumber, nb => {
nb.Property(x => x.RegionNumber).HasMaxLength(5);
nb.Property(x => x.PnoneNumber).HasMaxLength(20);
});
builder.Property("passwordHash").HasMaxLength(100);
// 配置一对多的关系(这里的User与UserAccessFail是一对多关系,但是这里并没有配置User与UserLoginHistory一对多关系,而在UserLoginHistory.cs中是有外键UserId的,没有配置强制性外键关系的原因是,考虑到UserLoginHistory有可能拆分到不同的微服务中,为了拆分方便,没有强制性的建立外键关系,不同的微服务有可能对应不同的数据库)
builder.HasOne(x => x.UserAccessFail).WithOne(x => x.User)
.HasForeignKey<UserAccessFail>(x => x.UserId);
}
}

}

注意:聚合内的外键可以强制性建立,但是聚合之间的不建议强制性的建立外键关系。

下面在Configs文件夹下面创建UserLoginHistoryConfig.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace UserMgr.Infrastructure.Configs
{
public class UserLoginHistoryConfig : IEntityTypeConfiguration<UserLoginHistory>
{
public void Configure(EntityTypeBuilder<UserLoginHistory> builder)
{
builder.ToTable("T_UserLoginHistories");
// 针对值对象的配置
builder.OwnsOne(x => x.PhoneNumber, nb => {
nb.Property(x => x.RegionNumber).HasMaxLength(5);
nb.Property(x => x.PnoneNumber).HasMaxLength(20);
});

}
}
}

Configs文件夹下面创建UserAccessFailConfig.cs代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
namespace UserMgr.Infrastructure.Configs
{
public class UserAccessFailConfig : IEntityTypeConfiguration<UserAccessFail>
{
public void Configure(EntityTypeBuilder<UserAccessFail> builder)
{
builder.ToTable("T_UserAccessFails");
builder.Property("isLockOut");
}
}
}

17.4.2 实现具体的仓储

在实现具体的仓储的时候,会使用到DbContext,所以在UserMgr.Infrastructure这个项目中创建UserDbContext.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace UserMgr.Infrastructure
{
public class UserDbContext:DbContext
{
public DbSet<User> Users { get; private set; }
public DbSet<UserLoginHistory> LoginHistories { get; private set; }

public UserDbContext(DbContextOptions<UserDbContext> options)
: base(options)
{
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
}
}

注意:这里我们在创建DbSet属性的时候,只是创建了User,UserLoginHistory两个聚合的DbSet属性,没有创建UserAccessFailDbSet属性,因为这里可以通过User这个聚合根来访问UserAccessFail.

下面实现具体的数据仓储UserRepository.cs中的代码(在UserMgr.Infrastructure项目中创建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
namespace UserMgr.Infrastructure
{
public class UserRepository : IUserRepository
{
private readonly UserDbContext dbCtx;
private readonly IDistributedCache distCache;
private readonly IMediator mediator;
public UserRepository(UserDbContext userDbContext, IDistributedCache distCache,IMediator mediator)
{
this.dbCtx = userDbContext;
this.distCache = distCache;
this.mediator = mediator;
}
/// <summary>
/// 添加用户登录的记录
/// </summary>
/// <param name="phoneNumber"></param>
/// <param name="message"></param>
/// <returns></returns>
public async Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string message)
{
User? user = await FindOneAsync(phoneNumber); // 调用下面定义的FindOneAsync方法,根据手机号查询用户信息
long userId = 0;
if (user != null)
{
userId= user.Id;
}
dbCtx.LoginHistories.Add(new UserLoginHistory(userId,phoneNumber,message));
}
/// <summary>
/// 根据手机号查询用户信息
/// </summary>
/// <param name="phoneNumber"></param>
/// <returns></returns>

public async Task<User?> FindOneAsync(PhoneNumber phoneNumber)
{
User? user = await dbCtx.Users.FirstOrDefaultAsync(u => u.PhoneNumber.RegionNumber == phoneNumber.RegionNumber && u.PhoneNumber.PnoneNumber == phoneNumber.PnoneNumber);
return user;
}
/// <summary>
/// 根据用户的编号查询用户
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task<User?> FindOneAsync(long userId)
{
User? user = await dbCtx.Users.FirstOrDefaultAsync(u=>u.Id==userId);
return user;
}
/// <summary>
/// 发布事件
/// </summary>
/// <param name="eventData"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>

public Task PublishEventAsync(UserAccessResultEvent eventData)
{
return mediator.Publish(eventData);
}
/// <summary>
/// 根据手机号,查询对应的验证码
/// </summary>
/// <param name="phoneNumber"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task<string?> RetrievePhoneCodeAsync(PhoneNumber phoneNumber)
{
string fullNumber = phoneNumber.RegionNumber + phoneNumber.PnoneNumber;
string cacheKey = $"LoginByPhoneAndCode_Code_{fullNumber}";
string? code =await distCache.GetStringAsync(cacheKey);
distCache.Remove(cacheKey); // 从缓存中获取对应的验证码以后,就将其从缓存中移除。
return code;
}
/// <summary>
/// 保存手机验证码
/// </summary>
/// <param name="phoneNumber"></param>
/// <param name="code"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public Task SavePhoneNumberCodeAsync(PhoneNumber phoneNumber, string code)
{
string fullNumber = phoneNumber.RegionNumber + phoneNumber.PnoneNumber;
var options = new DistributedCacheEntryOptions();
options.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60);
return distCache.SetStringAsync($"LoginByPhoneAndCode_Code_{fullNumber}", code, options);

}
}
}

注意:SavePhoneNumberCodeAsync方法的作用保存手机号对应的验证码。

这里我们是将其存储到了分布式缓存中,当然,也可以存储到数据库中。

这里存储到分布式缓存中的目的:主要考虑的是缓存中可以设置过期时间,当时间到了以后,验证码就无效了。

如果将验证码存储到数据库中,还需要自己进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
什么是分布式的缓存
分布式的缓存由多个应用程序服务器共享,缓存中的信息不存储在单独的 Web 服务器的内存中,并且缓存的数据可用于所有应用服务器。这具有几个优点:

  1、所有 Web 服务器上的缓存数据都是一致的。(用户不会因处理其请求的 Web 服务器的不同而看到不同的结果。)

  2、缓存的数据在 Web 服务器重新启动后和部署后仍然存在。 (删除或添加单独的 Web 服务器不会影响缓存。)

  3、对数据库的请求变的更少 。

像其它缓存一样,分布式缓存可以显著提高应用的响应速度,因为通常情况下,数据从缓存中检索比从关系数据库(或 Web 服务)中检索快得多。

缓存配置是特定于实现的。 本文介绍如何配置 Redis、 mongoDB 和 SQL Server 分布式缓存。 无论选择哪一种实现,应用都使用通用的 IDistributedCache 接口与缓存交互。



最后在UserMgr.Infrastructure项目中创建一个MockSmsCodeSender.cs来实现ISmsCodeSender这个接口,用来发送短信验证码,这里只是模拟实现。很多云服务提供厂商,会提供发送短信的接口。

1
2
3
4
5
6
7
8
9
10
11
12
namespace UserMgr.Infrastructure
{
public class MockSmsCodeSender : ISmsCodeSender
{
public Task SendCodeAsync(PhoneNumber phoneNumber, string code)
{
Console.WriteLine($"向{phoneNumber.PnoneNumber}发送验证码{code}");
return Task.CompletedTask;
}
}
}

18、 工作单元

关于工作单元,我们前面也给大家做过介绍。

这里的工作单元和以前是一样的。(保证所有的操作要么都成功,要么都失败。)

DDD的设计思想中,工作单元是由应用服务层来完成的,其他层不应该调用SaveChangesAsync方法来保存对数据的修改操作。

原因:就是因为应用层可以保证一次请求的完整实现。

我们把Web API的控制器当成应用服务,而且对于大部分应用场景来讲,一次对控制器中方法的调用就对应一个工作单元,因此我们可以开发一个在控制器的方法调用结束后自动调用SaveChangesAsync的机制。这样就能大大简化应用服务层代码的编写,从而避免对SaveChangesAsync方法的重复调用。当然,对于特殊的应用服务层代码,我们可能仍然需要手动决定调用SaveChangesAsync方法的时机。

创建一个WebApi项目,叫做UserMgr.WebApi,并且把该项目设置为启动的项目。

同时让该项目引用其他两个项目

在该UserMgr.WebApi项目中创建一个目录Attributes,在该目录下面创建一个UnitOfWorkAttribute.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
namespace UserMgr.WebApi.Attributes
{
[AttributeUsage(AttributeTargets.Method)]
public class UnitOfWorkAttribute:Attribute
{
public Type[] DbContextTypes { get; set; }
public UnitOfWorkAttribute(Type[] dbContextTypes) {
this.DbContextTypes = dbContextTypes;
}
}
}

同时在该WebApi项目中再创建一个文件夹,叫做Filters,在该文件夹中创建一个类叫做UnitOfWorkFilter.cs,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
using UserMgr.WebApi.Attributes;

namespace UserMgr.WebApi.Filters
{
public class UnitOfWorkFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var result = await next();// 执行完控制器中的方法了
if (result.Exception != null)
{
return;// 如果该条件成立,表示控制器中方法执行出现了错误,所以就没有必要执行SaveChanges方法了
}
// 下面要获取添加在控制器方法上的Attribute
var actionDesc = context.ActionDescriptor as ControllerActionDescriptor;
if (actionDesc == null)
{
return;// 表示无法转换,也会终止
}
var unAttr = actionDesc.MethodInfo.GetCustomAttribute<UnitOfWorkAttribute>();
if (unAttr == null)
{
return;// 如果该条件成立,表示所执行的控制器的方法上没有添加UnitOfWorkAttribute,这里就会终止,没有必要在执行下面的SaveChanges方法了
}
// 从UnitOfWorkAttribute中的DbContextTypes数组中获取所保存的DbContext
foreach (var dbCxtType in unAttr.DbContextTypes)
{
// -----------我们知道DbContext都是注入到容器中,所以这里需要从容器中获取,需要再WebApi项目中安装EFCore的包
var dbCtx = context.HttpContext.RequestServices.GetService(dbCxtType) as DbContext;
if (dbCtx != null)
{
await dbCtx.SaveChangesAsync();
}
}
}
}
}

下面修改Program.cs文件进行配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//-----------------注入DbContext,同时获取数据库链接字符串
builder.Services.AddDbContext<UserDbContext>(opt =>
{
string connStr = builder.Configuration.GetConnectionString("StrConn")!;
opt.UseSqlServer(connStr);
});
// --------------------完成Filter的注册
builder.Services.Configure<MvcOptions>(c =>
{
c.Filters.Add<UnitOfWorkFilter>();
});


var app = builder.Build();

下面,修改appsettings.json文件,添加数据库链接字符串

1
2
3
4
"AllowedHosts": "*",
"ConnectionStrings": {
"StrConn": "server=localhost;database=DDDApp;uid=sa;pwd=123456;TrustServerCertificate=true"
}

下面要做的就是数据的迁移操作

在【程序包管理器控制台中】指定的是[UserMgr.Infrastructure]项目,

1
2
Add-Migration Init
Update-database

最后在控制器的对应方法上添加特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace UserMgr.WebApi.Controllers
{
[Route("api/[controller]")]
[ApiController]

public class UsersController : ControllerBase
{
[UnitOfWork(new Type[] { typeof(UserDbContext)})]
public IActionResult GetUsers()
{
return Ok("ok");
}
}
}

这里创建一个UsersController控制器

19、应用层的实现

19.1、完成各项服务注入

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

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

3、可以在应用层中监听领域事件(注意:这里是进行监听,事件的处理是在领域层完成的)

下面现在应用层,也就是WebApi项目中安装:

MediatR

1
Install-Package MediatR.Extensions.Microsoft.DependencyInjection

并且在Program.cs文件中将其添加到容器中。

1
2
3
4
// 完成MeditatR的注入
builder.Services.AddMediatR(Assembly.GetExecutingAssembly());

var app = builder.Build();

下面好需要将其他服务进行注册

1
2
3
4
5
6
// 完成MeditatR的注入
builder.Services.AddMediatR(Assembly.GetExecutingAssembly());
builder.Services.AddScoped<UserDomainService>(); // 注入 UserDomainService
builder.Services.AddScoped<IUserRepository, UserRepository>(); // 注入
builder.Services.AddScoped<ISmsCodeSender, MockSmsCodeSender>(); // 注入
builder.Services.AddDistributedMemoryCache();// 注入缓存,这里后面需要使用Redis

这里我们先手动注入,当然,大家也可以使用前面我们所讲解的自动注入的方式。

19.2 添加用户

UsersController.cs控制器中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[Route("api/[controller]")]
[ApiController]

public class UsersController : ControllerBase
{

// ----注入IUserRepository,UserDbContext
private readonly IUserRepository _userRepository;
private readonly UserDbContext _userDbContext;
public UsersController(IUserRepository userRepository,UserDbContext userDbContext) {
this._userDbContext = userDbContext;
_userRepository = userRepository;
}

[HttpGet]
public IActionResult GetUsers()
{
return Ok("ok");
}
//------------------------- 添加用户
[UnitOfWork(new Type[] { typeof(UserDbContext) })] // 保存用户信息
[HttpPost]
public async Task<IActionResult> AddNewUser(AddUserRequest req) // 需要定义AddUserRequest
{
// 判断手机号码是否被占用
if(await _userRepository.FindOneAsync(req.PhoneNumber) != null)
{
return BadRequest("手机号已经存在");
}
// 创建User对象,需要传递手机号码
var user = new User(req.PhoneNumber);
// 一定要调用ChangePassword方法,对密码进行加密
user.ChangePassword(req.Password);
// 将用户信息添加到上下文中
_userDbContext.Users.Add(user);
return Ok("添加用户完成");
}
}

这里我们可以看到,在控制器(应用层)中直接使用了数据仓储和DbContext上下文,这里这样做的原因是:考虑到新增用户是简单的业务逻辑,所以这里就没有必要拘泥于DDD的设计原则,当然,也可以按照我们以前的方式进行设计。

同时在WebApi项目中创建Dto目录,在该目录中创建AddUserRequest.cs类,该类中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
using UserMgr.Domain.ValueObjects;

namespace UserMgr.WebApi.Dto
{
public class AddUserRequest
{
public PhoneNumber PhoneNumber { get; set; }
public string Password { get;set; }
}
}

19.3 用户登录

这里创建一个新的控制器LoginController.cs,该控制器中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using UserMgr.Domain;
using UserMgr.Infrastructure;
using UserMgr.WebApi.Attributes;
using UserMgr.WebApi.Dto;

namespace UserMgr.WebApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class LoginController : ControllerBase
{
// 注意-------UserDomainService
private readonly UserDomainService domainService;

public LoginController(UserDomainService domainService)
{
this.domainService = domainService;
}
//-------------完成用户登录
[HttpPost]
[UnitOfWork(new Type[] { typeof(UserDbContext) })]
public async Task<IActionResult> LoginByPhoneAndPwd(LoginByPhoneAndPwdRequest req) // 需要定义LoginByPhoneAndPwdRequest
{
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("手机号或者密码错误");//避免泄密
case UserAccessResult.Lockout:
return BadRequest("用户被锁定,请稍后再试");
case UserAccessResult.NoPassword:
case UserAccessResult.PasswordError:
return BadRequest("手机号或者密码错误");
default:
throw new NotImplementedException();
}
}
}
}

我们可以看到在应用服务中,只有信息的校验,对领域服务的调用,和领域服务返回结果的处理,没有其他的业务。

Dto目录中创建LoginByPhoneAndPwdRequest.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
using UserMgr.Domain.ValueObjects;

namespace UserMgr.WebApi.Dto
{
public class LoginByPhoneAndPwdRequest
{
public PhoneNumber? PhoneNumber { get; set; }
public string? Password { get; set; }
}
}

还要有事件处理程序。

WebApi项目中创建Events文件夹,在该文件夹中添加UserAccessResultEventHandler.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
using MediatR;
using UserMgr.Domain;
using UserMgr.Domain.Events;

namespace UserMgr.WebApi.Events
{
public class UserAccessResultEventHandler : INotificationHandler<UserAccessResultEvent>
{
// 这里需要注入IUserRepository,用来保存用户登录的时候的信息。
private readonly IUserRepository repository;

public UserAccessResultEventHandler(IUserRepository repository)
{
this.repository = repository;
}
public Task Handle(UserAccessResultEvent notification, CancellationToken cancellationToken)
{
var result = notification.UserAccessResult;
var phoneNum = notification.PhoneNumber;
string msg;
switch (result)
{
case Domain.UserAccessResult.OK:
msg = $"{phoneNum}登陆成功";
break;
case Domain.UserAccessResult.PhoneNumberNotFound:
msg = $"{phoneNum}登陆失败,因为用户不存在";
break;
case Domain.UserAccessResult.PasswordError:
msg = $"{phoneNum}登陆失败,密码错误";
break;
case Domain.UserAccessResult.NoPassword:
msg = $"{phoneNum}登陆失败,没有设置密码";
break;
case Domain.UserAccessResult.Lockout:
msg = $"{phoneNum}登陆失败,被锁定";
break;
default:
throw new NotImplementedException();
}
return repository.AddNewLoginHistoryAsync(phoneNum, msg);
}
}
}

注意:由于这里是一个类,而不是具体的控制器,所以想保存数据,就需要注入IUserRepository,用来保存用户登录的时候的信息。

而不能使用Filter过滤器

最后还有一个问题需要注意:

UserMgr.Infrastructure项目中的UserRepository.cs仓储中的FindOneAsync方法中,要通过include关联UserAccessFail,才能够查询到用户登录失败的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

public async Task<User?> FindOneAsync(PhoneNumber phoneNumber)
{
// 添加了include
User? user = await dbCtx.Users.Include(u=>u.UserAccessFail).FirstOrDefaultAsync(u => u.PhoneNumber.RegionNumber == phoneNumber.RegionNumber && u.PhoneNumber.PnoneNumber == phoneNumber.PnoneNumber);
return user;
}
/// <summary>
/// 根据用户的编号查询用户
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task<User?> FindOneAsync(long userId)
{
// 添加了include
User? user = await dbCtx.Users.Include(u=>u.UserAccessFail).FirstOrDefaultAsync(u=>u.Id==userId);
return user;
}

启动项目进行测试。

先添加用户信息

然后再进行用户的登录。

其他的功能大家可以自行实现