领域驱动设计核心是围绕着领域模型, 其实是一种面向领域模型的设计方式. 我们通过大量与领域专家的沟通协作, 得到领域模型. 通过代码落地, 让领域模型可以落地并得到验证.
领域模型始终在我们的产品开发的生命周期里面承担了很重要的作用. 因此我们先看看领域模型是什么, 我们使用领域模型的收益是什么.
既然领域模型这么重要, 我们先有个直观的认识, 它是长什么样子的.
什么是模型?
领域驱动设计核心是围绕着领域模型的, 那什么是模型? 下面这幅图是一个城市的俯瞰图, 里面包含了非常多的信息, 包括河流,建筑, 公路等等, 但如果我想坐地铁从A点到B点, 那可以怎么做呢?
我可以找一个地铁线路图, 这个地铁线路图其实就是一个模型.
那模型有什么特点呢? 我们可以总结一下
- 抽象的, 经过精简的, 只为解决一部分问题. 例如他只能解决我坐地铁的问题, 解决不了我开车的问题.
- 具有业务概念或规则的元素. 例如地铁线路图里面有站点, 有线路等.
- 包含元素之间的关系. 例如一条线路上面有多少个站点, 站点的相对距离是如何的.
领域分析模型长什么样?
领域驱动设计包含了领域分析模型和领域设计模型, 我们先看看什么是领域分析模型.
上面就是一个简单的订单相关的领域分析模型, 他具有一些业务概念, 例如订单, 订单项等, 也包含了他们之间的关系, 这个关系可以是依赖, 关联, 聚合, 组合, 继承, 实现. 最重要的是他能满足我添加减少订单项, 并修改订单项上面的商品和商品数量的需求.
领域分析模型是个简单的类图吗? 例如Mybatis里面的Mapper对象能不能在领域模型上面进行表达?
答案是否定的, 因为领域模型关注的是业务领域的概念, 而非软件技术相关的概念. 关注的是业务而非具体的代码实现.
大家是不是发现领域模型和我们常说的架构有点类似. 架构其实也是一种模型, 但领域模型关注的是如何解决业务问题, 也就是功能需求.
而架构则是为了解决非功能性需求, 例如分层架构是为了解决可读性问题, 可维护性的问题. 微服务架构是为了解决划分弹性边界, 让资源得到最有效的利用. 微内核架构是为了解决可拓展的问题.
领域模型和代码实现区别是怎样子的?
那我们试着用个例子来说明用领域模型来设计的好处.
首先需求如下
用户登录成功会根据不同角色加积分
角色分为普通和VIP
普通加1分, VIP加2分
用事务脚本实现一下
这个需求并不复杂, 我们可以用一个方法就解决了
public void login(String username, String password) throws Exception {
User user = userMapper.selectByUsername(username);
if (Objects.equals(user.getPassword(), password)) {
switch (user.getRole()) {
case GENERAL:
user.setPoint(user.getPoint() + 1);
break;
case VIP:
user.setPoint(user.getPoint() + 2);
break;
default:
}
} else {
throw new Exception("密码不正确");
}
}
但如果需求有变怎么办?
password 需要加盐存储
增加 VVIP 角色, 积分加3
我们可能需要修改password 判等的方法, 需要再 switch 里面增加一个VVIP 角色. 我们已经隐隐闻到代码的坏味道了. 虽然新需求很容易实现, 但我们需要修改原有的代码, 改动的范围涉及到整个方法. 通过类似打补丁的方式去修改代码一时爽, 一直打补丁一直爽. 但当方法体变成上百行代码时大家都不愿意去理解原有的代码逻辑, 只能一直打补丁.
这种就是事务脚本的实现方式.
笔者的某个同事曾经维护一个迭代了3年的系统, 代码已经不堪入目, 有新的需求要修改, 大家都不约而同的在原有代码上面进行修修补补, 不愿意去重构, 生怕重构的改动范围太大, 风险太高.
用领域模型实现一下
我们试着用领域模型的方式实现一下这个需求.
我们可以先把领域模型分析出来
用户有分数, 密码, 和角色, 不同的角色会有不同的积分策略, 因此我考虑对积分策略进行抽象来提升可拓展性.
具体的代码实现如下
public class User {
public Password password;
public Role role;
public Point points;
public void addPoints(Point points) {
this.points.add(points);
}
public void login(String password) throws Exception {
if (this.password.isCorrect(password)) {
PointsStrategy pointsStrategy = role.getPointsStrategy();
addPoints(pointsStrategy.loginSuccessPoint());
}else{
throw new Exception("密码不正确");
}
}
}
使用了充血模型把登录的逻辑封装在了User对象里面, 登录作为User的一个行为暴露给外部使用.
login里面的密码判等的实现细节被封装在Password里面
使用了接口去获取登录成功的积分.
我们从可读性和可拓展性和可测试性角度去分析一下这段代码.
可读性: 这段代码是有层次感的, 逻辑清晰, 易于理解. 这因为对代码进行了合理的抽象和封装. 例如登录其实会抽象成一个登录的方法, 对于调用方来说封装了登录逻辑. 密码判断也是抽象成一个判等方法, 把具体判等的实现细节封装在了Password里面.
可拓展性: 使用了接口的方式便于拓展新的积分策略, 符合开闭原则.
可测试性: 新增一个积分策略或者修改密码判断的逻辑并不会对登录的方法有什么改动. 我们只需要单独对密码判等的逻辑进行测试, 只对新增的积分策略进行测试即可.
看上去领域模型的实现方式碾压了事务脚本, 但我们却不容易去使用领域模型呢? 这是因为模型的建立是困难的. 上面的例子是对拓展点提前进行了捕获, 业务逻辑也相对简单, 因此可以设计出比较合理的模型, 但实际的场景可能很难分辨对拓展点进行把控. 而模型的质量对领域模型的可维护性至关重要. 其实领域模型没有对错之分, 只有好坏之分, 好的领域模型可以很好的表达你的业务, 满足所有的需求(用例), 在这基础上还能具有一定的拓展性, 能支撑业务后续的发展.
其实DDD有很大部分就是解决建模的问题.
我们先对比事务脚本和领域模型的特点 回头再看看怎么建立好的领域模型
事务脚本 | 领域模型 | |
---|---|---|
需求分析 | 只考虑现在 | 面向未来 |
开发速度 | 快 | 慢 |
可读性 | 越简单越好 | 越复杂越好 |
拓展性 | 差 | 优 |
事务脚本是采用过程式代码, 基本上按流程写即可, 无需提前做过多的设计, 因此开发速度会很快. 当代码逻辑简单时, 可以一目了然就看到业务的实现细节, 可读性也是比较好的. 因为没有提前进行设计, 因此拓展性就会比较差.
而领域模型是基于业务后续的发展, 需要进行适当的封装和抽象的. 需要提前思考业务的拓展点是什么, 这个逻辑是哪部分的职责. 因此开发速度会比较慢, 当业务比较复杂时, 因为有了封装和抽象, 实现细节会被隐藏到下一个层次, 让代码看上去仍然非常清晰, 且易于拓展.
如何构造出好的领域模型?
DDD强调开发需要深入了解问题域, 问题域并不是说你功能的实现细节, 而是你要解决的是谁的什么问题, 你才可能设计出一个好的模型. 那如何更好的了解我们业务呢? 答案是领域专家. 领域专家并不是一个title, 而是对某个领域的业务比较了解的角色. 它可以是产品经理, 也可以是在这领域开发了很久的开发人员都是可以的.
我们常见的产品经理常常在问题域中进行探索, 他们会去发现用户的痛点, 痒点或爽点, 然后想出某个功能去解决用户这个问题. 而给到我们开发的通常就是一堆功能列表和交互稿.
而我们开发则会去思考如何使用UML去分析功能, 然后进行数据库设计, 最终通过代码的方式去实现. 这样存在的问题是我们所思考的问题是割裂的, 一个是在思考如何解决用户问题, 另一个则在思考如何实现. 看上去分工明确, 各自有各自擅长的领域. 但却存在着非常严重的问题.
我举个最近工作中的例子
我正在开发的产品老师可以创建多个课程, 一个课程里面可以创建多个班级.
有一个数据统计分析的需求, 是希望可以展示某个"班级"的一些活动的统计数据.
红色框框是"班级"筛选器, 事实上, 在原有的需求里面, 还包含一个 "全部", 其实就是还需要按照"课程"的维度进行统计.
分析: 增加"全部" 站在开发的角度无疑是需要增加一个维度的数据统计, 工作量, 复杂度都会有所增加, 为什么会有 "全部" 这个需求呢? 我就去问产品
我->产品: 这里加个"全部" 的目的是什么?
产品->我: 因为老师有评金课的需求, 而评金课需要到课程的维度.
我->产品: 这个统计数据是为了让老师知道某个班级的学习情况的, 便于对某个班级进行管理. 而金课是为了展示老师的教学质量的, 应该是更宏观一点的数据指标才对. 两者的目的不同, 数据指标也应该不一样.
产品->我: 确实是这样子的, 只是目前还没考虑清楚金课的指标是什么, 因此暂时加了一个"全部".
我->产品: 增加一个"全部"会增加一个维度的统计, 增加开发工作量和复杂度, 如果还没想好, 那可以考虑暂时先不做了.
而领域模型就是一个很好的沟通协作的工具和产出物. DDD强调领域模型不是靠开发一个人想出来的, 而是要和产品, 甚至测试, 交互等团队成员一起打磨, 最终达成共识的.
开发在沟通的过程中对业务有了更深的理解,而产品也对实现的逻辑有了更深的理解. 大家可以分别从技术视角和业务视角去讨论, 从而对某个解决方案是否能真正解决问题, 实现的影响范围, 拓展性等进行分析, 得到性价比更高的解决方案.
我们这么做并不是为了可以砍需求, 争取早点下班. 只是好刚放到刀刃上, 我们开发的价值并不在于实现了多少个功能, 而在于能为用户解决多少个问题.
领域模型只是用与分析吗? 可以落地的吗?
thoughtworks的徐昊说曾经有人问他: 怎样做才能更DDD一点
他回答说: 模型和实现关联了, 就够了.
他的含义按我的理解是: 领域模型需要和代码是同步的, 如果只是把领域模型作为分析的工具, 模型会慢慢和具体代码实现割裂, 后续维护领域模型就没什么意义了.
在DDD里面, 模型不是中间产物, 而是核心产出物, 甚至比代码还要重要.
如果把领域模型当成建筑的设计图
那代码就是屋子本身
我们从领域模型里面就可以了解到代码的结构, 而代码里面也能推导出领域模型来. 当领域模型随着业务的发展需要发生重构时, 也意味着代码也要进行重构了. (后续会有实现部分的文章来对领域模型的落地进行介绍)
总结下DDD的成本和价值和适用的场景
沟通和理解
沟通成本高:需要大量的沟通, 不仅业务流程要梳理清楚, 而且包括模型的设计, 是每个业务概念都需要达成共识. (明面上的业务概念是比较容易的, 难是难在隐含的业务概念, 需要共同挖掘, 例如用户订阅了极客事件. "订阅"看上去是个动词, 但实际上在模型里面是个名词, 代表用户和专栏之间的关系).
理解成本降低: 大量的沟通达成的共识让下次的沟通可以更顺畅, 开发对业务的理解也会加深, 大家慢慢形成了高效且准确的统一语言.
适用场景: 业务复杂, 开发很难自己可以清楚的把模型准确表示出来.
重构和拓展
重构成本高: 对比事务脚本就可以发现, 事务脚本只需要加几行补丁就解决的问题, 领域模型可能需要改动好几个类. (笔者正踩坑中, 当设计出一个糟糕的模型, 重构的模型成本很高).
拓展成本降低: 合理的模型会预留一些拓展点, 便于后续进行拓展, 从而降低拓展成本.
适用场景: 迭代速度比较快. 迭代速度快意味着开发的功能会越来越多, 开发的成本很大程度上会依赖代码的可维护性, 良好的模型可以实时表达当前的业务情况, 并预测未来的发展, 提升代码的可维护性.
难度与进步
学习成本高: 需要有面向对象的设计能力, 并且有比较好的抽象能力和结构化的能力才能设计出一个比较好的模型. 另外DDD和我们平时常见的分层架构, 设计方式不同, 目前没有太多标准的可参照的实现, 因此很考验大家对概念的理解. 错误的理解可能会导致五花八门的实现方式, 而且无异于代码的可维护性.
团队能力提升: 大家在和领域专家沟通的过程中加深了对业务的理解, 在和团队之间关于实现方式是否合理进行激烈碰撞的时候加深了对概念的理解. (这点笔者感触挺深的, 团队不再觉得写业务是件无聊的事了, 而是会有更多的思考)
适用场景: 团队氛围好. 大家都愿意进行沟通和新模式的探索, 愿意承受建模错误需要重构的风险.
领域驱动设计好不好用, 难在哪里?
我们团队现在已经在运用DDD来进行开发了. 但DDD涉及很多东西, 我们也在一步一步在探索. 上面提到成本和收益是我们目前最真实的感受. 总体从各个后台开发的反馈看来效果都是不错的, 大家都认可这种开发思想. 我觉得要实施目前有3个难点
- 需要和领域专家(产品经理, 测试等)沟通协作, 共同打磨模型. (未探索)
- 如何让模型更好的演进, 保证重构过程中的质量. (准备探索)
- 如何让规范达成共识, 落地过程中可能有什么未知的问题. (探索中)
我们团队第三点是做得不错的, 能落地是一个很好的开始, 后续也会慢慢把短板补齐. 希望能把经验分享给大家.
下一篇会介绍一下DDD里面的概念, 概念的理解对后续的落地有非常大的影响, 理解why是核心. 然后会开始介绍我们团队的落地情况, 中间遇到了什么问题, 思考过程是如何的, 最终如何解决, 大家再一起来探讨一下.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。