在单体应用的一些DDD实践经验

时间:2022-12-07 17:02:02

阅读此文需要一定的DDD基础,如果你是第一次接触DDD读者,建议先去阅读一些DDD相关的书籍或者文章之后再来阅读本文。

背景


自从我在团队中推行DDD以来,我们团队经历了一系列的磨难——先是把核心项目重构,接着又在一些衍生项目中尝试全面落地DDD, 最终探索了一些经验出来,特此记录一下。

本文采用语言无关的角度陈述,无论你是Java或者c#的开发同学相信都可以无障碍阅读。

请注意本文并不是介绍如何实现DDD,因为这个话题实在太大了。

这次的主题是分享一些我们团队在实践DDD过程中碰到问题和如何克服它们,以及介绍一下我们所使用的架构体系。

先说说为什么标题限定在“单体应用”这个范围内,

  1. 我们团队这次实践的应用全是单体应用
  2. 如果是分布式的应用,那么拆分限界上下文(BoundedContext)的最佳实践是什么?当然是微服务!

    我相信现在讨论微服务的文章肯定不在少数,微软也专门出过容器化微服务架构的电子书。传送门点我

    资源如此丰富,当然就不需要我画蛇添足了。

领域模型


领域模型的分析可以说是DDD当中最为核心的部分,因为你整个系统的业务逻辑代码都是基于领域模型而构成的。

而要将业务逻辑转换成领域模型除了对业务的熟悉外还需要极高的抽象能力,所以一般需要业务专家和建模专家共同完成。

怎样提炼一个好的领域模型是一个非常大的话题,推荐你阅读以下书籍:

  • 《领域驱动设计:软件核心复杂性应对之道》Eric Evans
  • 《实现领域驱动设计》Vaughn Vernon
  • 《领域驱动设计与模式实战》Jimmy Nilsson

另外微软架构电子书上还有推荐其他几本DDD的书籍,遗憾的是,JD和TB都没搜到。

在团队刚开始分析领域模型时,对所有相关者都是一个极大的挑战,我这里分享几点经验帮助团队更好地度过这段时期:

  1. 不要想着能够一次提炼出完美的领域模型(除非团队中有着经验丰富的DDD实践者),通常来说,我们会在会议上决定一个粗略的模型,然后在开发过程中你会发现有一些不自然的地方,比如某些上下文频繁地与其他上文通信,或者某个实体的行为不是很恰当,这个时候再去修正领域模型,这样演进式的过程可以大大降低你们在初期的压力。
  2. 如果你的团队整体能力不足以支撑领域模型的推行,或者他们在初期的配合度不高时,你可以选择把你的项目中业务逻辑最为复杂的部分使用弱化的领域模型拆解,比如仅使用充血模型和领域服务,这样至少你可以对最为复杂的部分引入一些DDD战术模式或设计模式。
  3. 就算你的团队能力够了,但大部分人都没有DDD的经验的话,我也建议先只引入部分模式(比如只引入实体,值对象和仓储这类比较容易理解的模式)来提高团队的敏感度之后再采用完整的领域模型。
  4. 领域模型会对查询带来一定的复杂性,这种时候你可以采用CQRS来分离Query和Command,只有在Cammand的时候你才需要发挥领域模型的威力,至于Query,SQL语句显然是更好选择。

基础架构


了解DDD的同学都应该知道,DDD当中最为重要的部分就是限界上下文(BoundedContext),在领域模型中我们区分好了上下文之后,下一步就是选择一种技术手段来确保每个上下都是低耦合高内聚且自治的。

在分布式应用中,多数设计者和包括微软架构的电子书都会推荐使用一个上下文对应一个微服务的方式来实现(确实微服务和上下文的设计需求不谋而合)。

但单体应用该怎么办呢?

有同学说,我们可以通过命名空间来隔离它们啊。

不错,我们可以这样做,但是有以下几个缺点

  1. 在使用IDE的智能引用时,你得确认你引用的实体究竟是位于当前上下文之内还是之外。
  2. 会导致你的项目结构层次过深,不便于查看。(至于过深的标准是多少,看个人了,对于我来说,5层是可以接受的上限,理想是控制在4层以内)
  3. 不便于向微服务架构迁移

所以我们选择了使用程序集(java是使用jar包)的方式来隔离每个上下文,这样做克服了以上的缺点,但却带来了新的问题:动态加载这些上下文。

不过这种程度的问题比起带来的收益几乎可以忽视。

我们团队使用一个基础平台来动态加载这些上下文,

我们采用了 Abp 框架提供的插件功能来实现,如果你也是.net 的使用者,也可以采用 Abp 来构建这个应用。

当然自己写一个动态加载功能也并不困难。

基础架构如下图所示:

在单体应用的一些DDD实践经验

可是我们的平台要承担很多功能,比如开放RESTful的API与Webservice(为了兼容老的接口), 同时还要提供授权(使用了基于Oauth2.0协议的三种模式)、数据库初始化、处理请求上下文等等,我就不一一列出来了。

我们希望BC(BoundedContext,后文都会简写为BC)里不需要关注网络层面的东西而只聚焦于应用,所以很多通用的事情都由平台来承担, 而且有时还会有一些交互,比如在验证权限时你得跟用户权限上下文通信。

在这种前提下,我们抽出了一个用于连接平台和这些BC的交互层,我们把它称作——桥接组件(BrigeComponent),它负责联系起平台和这些BC,外加上一些共用的基础设施,我们的架构图变成了这样:

在单体应用的一些DDD实践经验

这样一来,你可以把每个BC都当作微服务来处理,每一个BC内的分层结构你可以按你的喜欢的来,如果你喜欢标准的三层架构(UI + BLL + DAL),你可以将BC设计那样。

你甚至可以每个BC都采用不同的风格,比如一个采用N层架构,而另一个采用事件驱动架构(EDA)。

这里我们的BC都用了相同的DDD推荐分层架构(这里省去了 表现层, 因为现代应用大多都是前后端分离了的),如下图所示:

在单体应用的一些DDD实践经验

好了,现在整体架构和领域模型都已经确定下来后,我们开始编码了,但很快我们就遇到了阻碍。

“结算上下文需要访问用户权限上下文,它需要知道这个用户的机构信息,我可以直接引用吗?”

“帐户上下文这里输出的数据需要通用上下文提供一些有效性校验,我可以直接引用吗?”

“我这里也需要访问通用上下文!”

……

好吧,如果我们直接提供引用,会有以下问题:

  1. 由于我们采用了程序集分割上下文,所以相互引用是不被允许的。
  2. 就算克服了相互引用的问题,最终也会导致引用拓扑图混乱不堪。
  3. 强耦合,这会直接影响到以后的拓展性。

在微服务中,为了克服服务间的互相通信问题,目前我了解的有两类解决方案,

一是类似于ESB(企业服务总线)的中心化通信模式,比如大名鼎鼎的SprinCloud。

二是现在微服务界炒得沸沸腾腾的ServiceMesh(服务网格),比如 Linkerd 和 Istio。

我们项目选择了前者,使用了类似于ESB中心化通信方式来解决,简单来说,你需要一个通信中介者(Mediator)来负责BC之间的交互,结构图如下:

在单体应用的一些DDD实践经验

如果你是 .Net 的开发者,请容许我给你安利一下我们在项目中使用的,自己开发的组件——ServiceAnt,它目前只支持进程内的通信,但不久后会开发分布式的。

详细情况你可以点击上面的连接进去查看,也可以查看我写的  另一篇博客  了解ServiceAnt是做什么的,当然你也可以选择 Mediator 来实现这个通信中间件。

Java的话,由于经验较少,没有发现类似的项目,Mule ESB什么的就跟 NServiceBus 一样是重量级的组件,不适用我们这样的场景。

以上就是我们用于实现DDD的基础架构,基于这样的架构我们可以很轻松地将现有应用向微服务拆分。

当然,上面的架构隐藏了很多细节,比如大量的基础设施(Ioc,Aop, Logger, cache等等),

原因之一是因为这些东西的设计都很常见,网上你随便就可以搜到相关设计的文章,

原因之二是因为我不想这些细节影响到了读者的关注点,我希望我们可以聚焦于如何实现DDD而不是系统的其他部分。

其他的一些话


在推行DDD过程中,总会有一些成员会问我,DDD给我们带来的好处是什么。

我总会不厌其烦地告诉他们,为了降低系统的维护成本和更合理地去解决系统业务的复杂性。

但后来我渐渐发现,实现DDD本身就不是一件容易的事情,它会对项目引入新的复杂性,有时候你会发现你团队花上大量时间去建模之后,在开发过程中却依然需要不断修正模型。

这很容易让整个团队士气变低,并且让开发人员有挫败感,这种时候我经常会怀疑DDD对我们而言是否真的有价值。

不过坚持下去,在你使用DDD完成一到两个项目之后,你会发现建模是一件非常有意思的事情——提炼业务并将其转换为一个无关技术的模型,这就跟搭积木一样。

最后给所有希望通过DDD来改善项目,并且提升自己的同学说以下两点:

1,不要奢望光通过阅读就能充分地理解DDD,你需要真正去实践(当然,框架和架构设计也是一样的,不要做象牙塔里的架构师)

2,实践的过程你总会碰见疑惑和挫折,比如完全不知道如何拆分上下文,也不知道该如何使用那些战术模式,这个时候再把那几本书拿出来翻翻,你就会发出“啊,原来这种场景还可以这样处理”的感概。

那句话怎么说来着,

The one trying to wear the crown must withstand the weight.