您的位置:  首页 > 技术杂谈 > 正文

DDD实战之二:看看代码结构长啥样

2023-11-15 11:00 https://my.oschina.net/u/5587102/blog/10143202 不败顽童1号 次阅读 条评论

DDD实战之二:看看代码结构长啥样

真正开始 DDD 旅程前,我想让您看到经过 DDD 设计之后的代码长啥样。我想,这是所有本着“talking is easy, show me your code”理念的程序员都比较在乎的观念。

为此,我特别将“群买菜”生鲜电商系统服务端代码新旧代码结构都显示出来,让您看看原来的旧代码——也就是“事务脚本式”代码长啥样(应该是目前大部分 java 程序员写代码的样子),再让您看看 DDD 改造设计后的新代码长什么样子。然后再通过分析,说清楚为什么传统的“事务脚本”代码不是对真实世界的“同构映射”,而 DDD 代码的“同构映射”在哪。

需要提醒您的是:从今天这个专题开始,可能需要你多花点时间、深入地阅读我写的代码、和文字的每一句话,反复对照着看,甚至来回反复多看几遍,才能真的去理解这些文字了。

我们先来看旧代码的目录结构截图。注意看下面的 1、2、3、4 标注位置(解释下,我这里用的是 spring-boot 开发框架,MyBatisPlus 数据持久框架、MySql5.6 数据库):

image.png

image.png

image.png

您注意到这里标注的 1、2、3、4 代码位置了吗?是不是代码结构很像大部分 spring-boot 应用框架下代码结构?为了避免您可能不太了解这种代码结构,我还是简单解释下。

标号 1 位置:这里放的是 Controller(控制器)层代码,也就是所有前端访问的接口都在这里实现。按照 MVC 的分层原则,一般来说,这里只会放一些客户端输入参数的解析、以及对 service 层(见下文)的业务方法调用。一般来说,这里的代码都长成下面这样:

image.png

标号 2 位置:这里放的是 entity(数据 bean)层代码,其实都是 POJO 代码,所有类都一一对应到数据库表。一般来说,这里的代码都长成这样:

image.png

标号 3 位置:mapper 层,对于 mybatis 持久层框架来说,mapper 和 entity 共同实现了 ORM(对象模型到关系模型的映射)。一般来说,这里的代码长成这样(这里 CustomerMapper 类只是定义了 entity 类 Customer 的映射关系,以及自定义的数据操作方法):

image.png

以及这样(在 MP 中,只有需要实现自定义的 SQL 操作方法,才需要这个 CustomerMapper.xml 文件):

image.png

标号 4 位置:Service(服务)层,这里是所有业务逻辑实现的核心代码处,几乎所有的业务逻辑都是在这里实现的。一般来说,这里会有 interface+implementation 组合的实现方式。比如:OrderService 和 OrderServiceImpl,分别长下面这样:

OrderService 接口类

image.png

OrderServiceImpl 实现类

image.pngimage.png

image.png

从上面的代码中 ,我们可以很明显地看出如下几点:

  • Controller/entity/mapper 基本上都是利用框架的 annonation(注解)和公共工具类代码(如 json 解析等)实现的很少的代码;

  • 显然,大部分业务逻辑都是在 Service 层的实现类里面实现的;

  • Service 层实现类代码的逻辑写的很长,且完全是“平铺直述”的。我这里展示的 OrderSeriveImple 的 create 方法——创建订单,就写了 135 行。从我的代码截图中的注释可以看出来,我是想好了一步一步要怎么对数据库进行 CRUD,先填写好注释,然后写代码的。这种代码,说白了就是“CRUD+计算逻辑”组合的代码;

  • 事实上,这种“平铺直述”式的代码,是很容易被程序理解的,写起来也很容易,基本上不用“杀死”太多脑细胞,所以团队很容易就开始实施项目工程,随便找一个具有基本 java 编程经验(一般一年以上经验即可)就能够开始着手业务代码的开发;

  • 这种代码,我们就叫做”事务脚本式”代码,或者说叫“贫血模型”代码。

  • 之所以叫“事务脚本”,我个人的理解:本质上跟 20 年前写数据库存储过程代码没有本质区别(只是换了个语言书写、运行代码的位置从数据库服务器内部提到了应用服务器);

  • 又之所以叫“贫血模型”代码,是因为 entity 层的那些 POJO 对象如 Order 等,没有任何业务行为的封装(比如:Order 类应该自己生成自己的订单号、提货号等),只有属性而没有行为的对象,就是“贫血”对象,基于“贫血”对象实现的业务逻辑代码,就叫“贫血模型”代码。

根据这里的代码分析,我们是不是能够发现一个关键问题:这里的 Controller/entity/mapper/service,事实上和真实世界的业务之间关系,是没有任何映射的——也就是说:“代码世界”和“真实世界”是异构的。具体来说,我们可以分以下几点来看。

首先,从业务模块划分这个“最粗”的粒度来说,我们其实是可以简单的、凭直觉进行模块划分的,不用全部业务模块放在一个工程项目中,是可以按照业务模块(比如:店铺管理、订单管理、商品管理等)进行项目目录划分、也就是项目团队分组的。

事实上,目前市面上的大多数软件公司,就是根据业务经验或直觉简单粗暴的将项目划分了多个团队在进行开发。但这种划分方式,虽然也可以七七八八准确——但我们需要意识到的是,这样简单粗暴的凭经验直觉的划分,跟 DDD 方法论做的设计划分相比(划分到限界上下文这个粒度的设计,在 DDD 中叫做“战略设计”),至少有 3 个不足:

  • 软件代码如何划分是严格的“工程性问题”,而所有工程性问题,往往会“差之毫厘谬以千里”!这种经验直觉的划分,很可能会遗漏掉一些很重要的“限界上下文”识别。而正因为这些重要的“限界上下文”的遗漏,导致了一些模糊地带,发现要么是没必要的模块间耦合、要么是没必要的重复。
  • DDD“限界上下文”的识别,不但要区分出到底要划分为几个模块(其实“模块”是个很模糊的词,可以用来划分微服务、也可以用来划分代码目录结构,视需要而定),还需要识别这些“限界上下文”之间的协作关系和边界。而这些协作关系,才真正“清晰准确、代码行级”定义了哪些代码归属模块 A、哪些代码归属模块 B——也就是边界,以及这些模块是通过 RPC 或本地调用关系在协作、还是异步消息事件在协作、甚至直接就没有协作。
  • 一般来说,DDD 的“限界上下文”需要对应到业务子领域,而业务子领域的重要程度将决定限界上下文的重要程度。业务子领域针对某个具体的软件系统来说,是可以从业务角度判断出哪些必须建设为软件的核心竞争力、哪些则可以作为次要模块甚至通过外包来实现。这些对“限界上下文”模块的不同“重要程度”定义,将会促使项目管理层从效率的角度采用不同的技术栈。比如:目前市面上不同的程序员薪资水平是不同的、招聘难度是不同的;不同技术栈的成熟程度、可适用的编程特性是不同的(比如:java 比较成熟适合企业级应用开发,而 python 适合数据处理类开发,node.js 适合跟第三方互联网系统连接等)。

其次,到模块内部,其代码的层次结构划分,如果按照 mvc 思想,最后还是又回到了类似 controller/entity/mapper/service 这样的划分方式。而这种划分方式,又和“真实世界”有什么同构映射关系呢?可以说,没有!

所以,最终我们还是可以得出结论:这种传统的代码架构,是没有考虑和真实世界的“同构映射”的。而这种对“同构映射”的缺失,才是导致我们出现“真实业务其实没多大变化、但某个需求却为什么引起软件代码翻天覆地的变化呢?”这样疑惑的根本原因——DDD 方法论,就是用来解决这个问题的!

我们再来看看使用 DDD 设计后,新的代码结构长什么样。下面是新代码的结构截图(同样注意下面的 1~8 标号):

image.png

对上