DDD之代码架构

简介: 这是一篇迟到的文章。这其实是我写DDD的第四篇文章。去年11月份左右我在个人网站上写了三篇关于DDD的文章,都是比较偏战略部分的。那个时候我还在一个正在使用DDD的项目上,也是我第一次真正开始深入使用DDD。

荒腔走板


这是一篇迟到的文章。这其实是我写DDD的第四篇文章。去年11月份左右我在个人网站上写了三篇关于DDD的文章,都是比较偏战略部分的。那个时候我还在一个正在使用DDD的项目上,也是我第一次真正开始深入使用DDD。

网络异常,图片无法展示
|
DDD的前3篇文章

后来有收到读者催更的留言。其实那个时候也在陆续写文章,DDD第四篇想写战术模式方面的文章,尤其是代码架构。但一直觉得自己这方面还需要再学习修炼一下。且之前在项目上实践DDD,落地到代码上还是遇到了一些问题的,我也在思考怎样才能更好地让DDD在代码层面落地。

本文适合于对DDD战术模式有一定了解的读者。


从复杂性谈起

要聊架构问题,其实要先明白架构想要解决的是什么问题。我认为代码架构主要解决的是两个问题:程序的复杂性和编程的复杂性。

依赖混乱导致了程序复杂

程序的复杂性,指的是程序里分层或抽象不清晰,导致依赖关系比较乱。这里的“依赖”其实可以比较简单地理解为,A使用了B。如果删除了B,A就不能继续正常工作了。AB可以指的是类、模块、分层等概念。

经过了很长一段时间的发展,现在软件界已经能够总结出一些比较好的设计模式、分层架构、甚至是基于maven/gradle等构件工具的模块化、代码分包、甚至是微服务拆分等手段,来尽量减少彼此之间的依赖,架构的本质就是要尽量做到“高内聚,低耦合”。

然而架构很难完美。现在市面上绝大多数后端Web项目用的是“三层架构”(或者更多层,但本质上是差不多的),即controller -> service -> dao。其中controller层和dao层处于依赖关系的两端,且职责比较清晰,一般不会有什么太大的依赖复杂性。但service层就不一样了,它承载了所有的主要逻辑,彼此之间相互调用是很正常的一件事,所以service层的依赖关系很容易变得错综复杂。这就会导致软件到后面会变得越来越难以维护。

DDD结合整洁架构可以让依赖变得清晰,比三层架构有更好的“高内聚,低耦合”特性。

技术和业务耦合导致了编程复杂

编程这件事为什么复杂?为什么只有程序员能做,甚至初级程序员还做不好?不就是CRUD(增删改查)加if-else吗?

其实主要原因是编程这件事,有很多技术上的东西。可能要与数据库打交道,可能会发送消息,使用缓存,处理并发,处理失败、超时问题等等。这些事情其实都是与业务逻辑无关的,可以说是纯技术上的东西。

而业务逻辑,才是真正的CRUD加if-else。开发人员在写业务逻辑的时候,像个把业务语言翻译成代码语言的工具人,其他时候我们才是工程师。我们很多bug,可能并不是技术层面的bug,而是由于开发和测试没有足够了解业务造成的,遗漏了业务上的某些点,产生了业务上的bug。

在三层架构,往往技术和业务是耦合粘连在一起的,至少代码层面上很难把它们分开,很难单独把“业务”方面的代码抽离出来。

但DDD可以,DDD可以把业务全部内聚在领域层,且领域层不依赖其它任何东西,只有最纯粹的业务。那我们是不是甚至可以把这一层的user case和单元测试、甚至是实现代码都给业务同学(或者是领域专家)写?毕竟只跟业务相关,且都是简单的if-else。


整洁架构


来看一下DDD与各种架构提出的时间线。

网络异常,图片无法展示
|
软件架构编年史

2003年Eric Evans出了一本叫《领域驱动设计-应对软件复杂性之道》的书。2005年六边形架构(也叫端口与适配器架构)问世,2006年CQRS和Event Sourcing在一次Greg Young的演讲中提出,2008年洋葱架构。

2012年,Bob大叔发文,说:我总结了之前的各种架构,比如六边形架构、洋葱架构、尖叫架构(他自己2011年提出的)等等,总结出了它们好像说的其实是一个东西,我把它们抽象整合一下,提出这样一个“整洁架构”。

整洁架构链接:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

网络异常,图片无法展示
|
整洁架构图

向内依赖规则

整洁架构图是一个同心圆。它的依赖规则是依赖方向朝内,每个环可以依赖它本身这一层及其所有内部的层,但不能依赖它外部的层。就像图中黑色的水平箭头所示的那样。

虽然图中只有四层,但它可以不止四层,可以继续细分或者向外扩展,只要满足向内依赖规则就行。

Entities

对应DDD的话,这一层是用来放实体、值对象、聚合等领域模型的。业务逻辑都应该尽量内聚在这一层,这一层是最纯净的,不需要依赖任何其它东西。

Use Cases

翻译过来是“用例层”,用于协调进出Entities层的数据流,通过调用和编排领域模型来实现用例。在DDD中,这一层通常是Application Service层,是很薄的一层,只用来做一些比较简单的事情。

Interface Adapters

这一层叫“接口适配层”,它其实是主要用来与外部进行适配的。比如Web请求进来的Controllers(写)和Presenters(读)。这一层会将User Cases或Entities层需要的数据结构与外层的数据结构做一个转换。比如操作数据库、调用第三方接口等。

Frameworks and Drivers

这一层主要是框架和驱动层,比如数据库驱动、WEB框架、UI等,日常编码中很少会在这一层编写代码。

跨越边界和依赖反转

就如同图中的右下角所示。我们的业务流程通常是先从controller进来,调用Use Cases层。但有时候Use Cases层需要调用presenter。如果直接调用,就破坏了“向内依赖规则”,这个时候可以用“依赖反转”来做。

网络异常,图片无法展示
|
依赖反转

简单来说,我们在Use Cases层定义两种抽象类或者接口:Use Case Input Port, 或者Use Case Output Port。Use Case Interactor去实现了Use Case Input Port,而Presenter去实现了Use Case Output Port。

这样Use Cases层就不用依赖外部的层了。同样的道理,也可用于对数据库、第三方接口等的交互场景。


代码

No Code, No BB, Show me the code!

我给大家找了一个Go语言版本的整洁架构代码实现,来一起分析一下它的结构。

Github地址:https://github.com/manakuro/golang-clean-architecture

文章地址:https://medium.com/@manakuro/clean-architecture-with-go-bce409427d31

首先看一下整体的结构:

.
├── domain(对应Entities层)
│   └── model
│       └── user.go
├── infrastructure(对应FrameWorks & Divers层)
│   ├── datastore
│   │   └── db.go
│   └── router
│       └── router.go
├── interface(对应Interface Adapters层)
│   ├── controller
│   │   ├── app_controller.go
│   │   ├── context.go
│   │   └── user_controller.go
│   ├── presenter
│   │   └── user_presenter.go
│   └── repository
│       └── user_repository.go
├── main.go
├── registry
│   ├── registry.go
│   └── user_registry.go
├── usecase(对应Use Cases层)
│   ├── presenter
│   │   └── user_presenter.go
│   ├── repository
│   │   └── user_repository.go
│   └── interactor
│       └── user_interactor.go

Entities层没什么好说的,就是放的领域模型。Use Cases层定义了presenter和repository的接口。并且在interactor中,使用了这两个接口。

// user_interactor.go中的Get方法
func (us *userInteractor) Get(u []*model.User) ([]*model.User, error) {
 u, err := us.UserRepository.FindAll(u)
 if err != nil {
  return nil, err
 }
 return us.UserPresenter.ResponseUsers(u), nil
}

然后在Interface Adapters层,实现了上面定义的两个接口。在user_controller.go里面,调用user_interactor。


DDD与整洁架构

看了上面的整洁架构,发现是不是还是不能跟DDD的战术模式完全match上?

是的,DDD是用来解决软件的复杂性的,而真实的软件远比上面的demo代码复杂得多。其中两个很重要的架构上的概念CQRS、Event Sourcing、Domain Service等并没有在上面整洁架构的图中体现出来。

我也是疑惑了许久,最终在hgraca博客里找到了答案。全部都在这张图上了:

网络异常,图片无法展示
|
DDD与整洁架构

博客原文:https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/

这不只有整洁架构,上面还有一些明显的六边形架构的样子。不过内部是整洁架构的模式,并且和DDD很好的结合了起来。我把它中间部分放大一些。

网络异常,图片无法展示
|
放大版

可以看到,处在最中间的是领域模型,然后是领域服务。这两环共同构成了领域层。更外面一层是应用层,应用层也包含了两个环,里面是App Services,外面是C/Q处理器、事件监听器等等。

然后再往外,是大红色轮廓包起来的Application Core。这一层定义了很多接口(也可以说是端口),比如持久化、第三方服务、搜索、CQ总线、事件总线等等。当然,也接收处理命令和查询。


思考,我曾遇到的问题

在之前的DDD项目上,我曾经遇到过一些战术模式上的问题,现在回过头去思考一下。

究竟什么时候需要领域服务?

可以一句话总结:当只使用领域模型做不到的时候。那两者都是在领域层,大家都不依赖外面的东西,什么情况下领域模型做不到,领域服务就做得到?

一个很常见的场景是创建模型的时候有业务逻辑。虽然创建模型通常是放在Factory里面,但Factory里面并不适合放业务逻辑。而这个时候领域模型还没有创建,自然就只能放在领域服务里面了。

当然了,DDD最佳实践是希望能够尽量消灭领域服务层,全部内聚在模型层,只是很难达到这种完美的情况。

在应用层需要查询其它数据怎么办?

有时候我们可能不止是需要当前这个聚合根的数据,可能还需要其它的数据。这个读取操作当然不可能放到领域层去做,通常把它放在应用层。但应用层通常是一个聚合根对应一个ApplicationService,正常的流程是调用Repository接口获取一个领域模型对象,然后对它进行操作,再保存回数据库。

那如果需要获取其它数据怎么办呢?尤其是可能与当前领域模型无关的数据,比如“最近评论时间”。

我们团队之前的解决方案是用了一个Query,Query的职责是去数据库查询,它可以查询部分字段。但为了防止它被滥用,团队规定它只能用于repsenter或者applicationService中“为了写的读”。

回过头去看整洁架构,这里的Query其实就是User Case Output Port。不过当时我们团队的Repository和Query都没有做抽象处理,也就是说并没有依赖反转,所以ApplicationService层依赖了它们,这与整洁架构的“单向依赖”不太相符,这里其实用上依赖反转会好一点。

领域事件究竟该在什么时候发送和接收?

这个也是一个比较有争议的点。首先看领域事件在什么时候创建,有人认为应该在领域层创建,有人认为应该在应用层创建。个人认为在领域层创建比较好,因为创建领域事件其实也算是一种业务逻辑,并且只是创建一个领域事件的话,不会依赖任何外部的东西,放在领域层没有什么问题。

那什么时候发送领域事件呢?按照整洁架构的规则,不应该在领域层发送,因为事件总线(或者事件发送器的实现)在最外层,如果在领域层发送,虽然有依赖倒置,但感觉也跨越太多层次了,不是一个好的实践。

那在应用层发送事件是一个比较好的方案。我们团队之前的方案比较奇葩,是在Repository的实现里面发送的。有点忘记当时为啥这样做了,不过现在看来,应该在应用层去做更合适一些。

事件接收的话,就像上面的图中所示的一样,放在应用层比较好(但在App Services外)。然后可以通过ApplicationService去完成业务逻辑。

跨聚合根的事务问题?

这个其实很难保证强一致的事务了,因为跨聚合根应该使用事件通信,但事件的实现方式有多种,如果是异步,那就保证不了强一致的事务。只能用一些技术手段去尽量保证最终一致。

怎样保证架构不被腐化?

根据之前的实践经验来看,代码架构是有可能随着时间腐化的。比如我前面提到的Query就是一个例子,得通过一些团队间的“共识”来保证它不被滥用。我们不能保证代码完全不被腐化,但是可以通过一些手段去保证依赖的层次不被腐化。

比较推荐的是使用maven/gradle的模块化,因为模块之间是有依赖关系的,只要我们不去改依赖的配置,就永远是单向依赖的。具体来说,我们可以把整洁架构上面的层级分成一个个模块,然后在配置文件里面定义它们的依赖关系。比如应用层模块,依赖领域层模块;接口和适配层模块依赖应用层模块和领域层模块。

永远的难题:定义合适的聚合根

最后谈一个比较难的问题,就是找聚合根。其实大家可能觉得,定义一个聚合根应该很简单,根据业务来就是了,比如用户、订单、商品、库存等等。但有时候我们很容易把聚合根定义得很大,因为无论聚合根多大,它都能够很好地解释。有个建模界的笑话是:我定义了一个“宇宙类”,它可以包含所有模型。

聚合根太大可能会有问题,比如代码过多、测试用例过多、性能不好等等。很有可能做着做着一个聚合根就膨胀了,这个时候你们尝试去拆分它,会发现并非像最开始现象的那样不能拆分。只是要找到合适的“借口”,要拆得有理有据。但总而言之,这是一件很难的事情。

目录
相关文章
|
1月前
|
数据采集 机器学习/深度学习 大数据
行为检测代码(一):超详细介绍C3D架构训练+测试步骤
这篇文章详细介绍了C3D架构在行为检测领域的应用,包括训练和测试步骤,使用UCF101数据集进行演示。
43 1
行为检测代码(一):超详细介绍C3D架构训练+测试步骤
|
15天前
|
前端开发 测试技术 数据库
DDD架构中assembler和converter的区别
在 DDD 四层架构模式中,assembler 和 converter 常用于对象转换,但两者在实际项目中的使用较为随意。本文从英文释义、语义区分和模型层区分三个方面探讨了两者的区别,建议按模型层区分,即 Interface 和 Application 层使用 assembler,Infrastructure 层使用 converter,以避免混淆和随意使用。此外,将转换代码抽离为独立方法有助于保持代码整洁和可测试性。
49 1
|
22天前
|
存储 安全 Java
系统安全架构的深度解析与实践:Java代码实现
【11月更文挑战第1天】系统安全架构是保护信息系统免受各种威胁和攻击的关键。作为系统架构师,设计一套完善的系统安全架构不仅需要对各种安全威胁有深入理解,还需要熟练掌握各种安全技术和工具。
64 10
|
1月前
|
机器学习/深度学习 网络架构 计算机视觉
目标检测笔记(一):不同模型的网络架构介绍和代码
这篇文章介绍了ShuffleNetV2网络架构及其代码实现,包括模型结构、代码细节和不同版本的模型。ShuffleNetV2是一个高效的卷积神经网络,适用于深度学习中的目标检测任务。
71 1
目标检测笔记(一):不同模型的网络架构介绍和代码
|
1月前
|
存储 前端开发 API
DDD领域驱动设计实战-分层架构
DDD分层架构通过明确各层职责及交互规则,有效降低了层间依赖。其基本原则是每层仅与下方层耦合,分为严格和松散两种形式。架构演进包括传统四层架构与改良版四层架构,后者采用依赖反转设计原则优化基础设施层位置。各层职责分明:用户接口层处理显示与请求;应用层负责服务编排与组合;领域层实现业务逻辑;基础层提供技术基础服务。通过合理设计聚合与依赖关系,DDD支持微服务架构灵活演进,提升系统适应性和可维护性。
|
1月前
|
设计模式 人工智能 算法
编程之旅:从代码到架构的感悟
【9月更文挑战第33天】在编程的世界里,代码不仅是实现功能的工具,更是连接思想与现实的桥梁。本文将通过个人的编程经历,分享从编写第一行代码到设计系统架构的旅程,探索编程背后的哲学和技术演变。我们将一起思考,如何在代码的海洋中找到自己的航向,以及在这个过程中如何不断成长和适应变化。
|
1月前
|
机器学习/深度学习 大数据 PyTorch
行为检测(一):openpose、LSTM、TSN、C3D等架构实现或者开源代码总结
这篇文章总结了包括openpose、LSTM、TSN和C3D在内的几种行为检测架构的实现方法和开源代码资源。
42 0
|
2月前
|
机器学习/深度学习 测试技术 数据处理
KAN专家混合模型在高性能时间序列预测中的应用:RMoK模型架构探析与Python代码实验
Kolmogorov-Arnold网络(KAN)作为一种多层感知器(MLP)的替代方案,为深度学习领域带来新可能。尽管初期测试显示KAN在时间序列预测中的表现不佳,近期提出的可逆KAN混合模型(RMoK)显著提升了其性能。RMoK结合了Wav-KAN、JacobiKAN和TaylorKAN等多种专家层,通过门控网络动态选择最适合的专家层,从而灵活应对各种时间序列模式。实验结果显示,RMoK在多个数据集上表现出色,尤其是在长期预测任务中。未来研究将进一步探索RMoK在不同领域的应用潜力及其与其他先进技术的结合。
95 4
|
3月前
|
XML 开发框架 .NET
.NET框架:软件开发领域的瑞士军刀,如何让初学者变身代码艺术家——从基础架构到独特优势,一篇不可错过的深度解读。
【8月更文挑战第28天】.NET框架是由微软推出的统一开发平台,支持多种编程语言,简化应用程序的开发与部署。其核心组件包括公共语言运行库(CLR)和类库(FCL)。CLR负责内存管理、线程管理和异常处理等任务,确保代码稳定运行;FCL则提供了丰富的类和接口,涵盖网络、数据访问、安全性等多个领域,提高开发效率。此外,.NET框架还支持跨语言互操作,允许开发者使用C#、VB.NET等语言编写代码并无缝集成。这一框架凭借其强大的功能和广泛的社区支持,已成为软件开发领域的重要工具,适合初学者深入学习以奠定职业生涯基础。
102 1
|
3月前
|
前端开发 开发者 C#
WPF开发者必读:MVVM模式实战,轻松实现现代桌面应用架构,让你的代码更上一层楼!
【8月更文挑战第31天】在WPF应用程序开发中,MVVM(Model-View-ViewModel)模式通过分离应用程序的逻辑和界面,提高了代码的可维护性和可扩展性。本文介绍了MVVM模式的三个核心组件:Model(数据模型)、View(用户界面)和ViewModel(处理数据绑定和逻辑),并通过示例代码展示了如何在WPF项目中实现MVVM模式。通过这种方式,开发者可以构建更加高效和可扩展的桌面应用程序。
160 0

热门文章

最新文章