Eric在DDD第一章节就介绍了模型,可见模型的作用不言而喻,说DDD是一种模型驱动设计方法,绝对没有问题
那是不是我们在拿到业务需求时,就急呼呼的跟业务方来一起构造模型呢?毕竟模型是万事之首嘛
在《DDD开篇》[1]提过DDD是一种基于面向对象的设计方法,我们既然已经有了面向对象,而且OOAD也很强大,为什么还需要DDD呢?
要想弄清楚这两个问题,首先我们需要拿个示例来仔细比对一下
OOP小示例
在《面向对象是什么》[2]一文中提到的游戏小示例
有个游戏,基本规则就是玩家装备武器去攻击怪物
•玩家(Player)可以是战士(Fighter)、法师(Mage)、龙骑(Dragoon)•怪物(Monster)可以是兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量•武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击力•玩家可以装备一个武器,武器攻击可以是物理类型(0),火(1),冰(2)等,武器类型决定伤害类型
作为一名受过OO熏陶的程序员,借助OO的继承特性把类结构设计成:
public abstract class Player { Weapon weapon } public class Fighter extends Player {} public class Mage extends Player {} public class Dragoon extends Player {} public abstract class Weapon { int damage; int damageType; // 0 - physical, 1 - fire, 2 - ice etc. } public Sword extends Weapon {} public Staff extends Weapon {}
攻击规则如下:
•兽人对物理攻击伤害减半•精灵对魔法攻击伤害减半•龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍
public class Player { public void attack(Monster monster) { monster.receiveDamageBy(weapon, this); } } public class Monster { public void receiveDamageBy(Weapon weapon, Player player) { this.health -= weapon.getDamage(); // 基础规则 } } public class Orc extends Monster { @Override public void receiveDamageBy(Weapon weapon, Player player) { if (weapon.getDamageType() == 0) { this.setHealth(this.getHealth() - weapon.getDamage() / 2); // Orc的物理防御规则 } else { super.receiveDamageBy(weapon, player); } } } public class Dragon extends Monster { @Override public void receiveDamageBy(Weapon weapon, Player player) { if (player instanceof Dragoon) { this.setHealth(this.getHealth() - weapon.getDamage() * 2); // 龙骑伤害规则 } // else no damage, 龙免疫力规则 } }
如果此时,要增加一个武器类型:狙击枪,能够无视一切防御,此时需要修改
1.Weapon,扩展狙击枪Gun2.Player和所有子类(是否能装备某个武器)3.Monster和所有子类(伤害计算逻辑)
除了伤害逻辑有各种规则,还有装备武器也会有各种规则
比如,战士只能装备剑,法师只能装备法杖,但他们都可以装备匕首
再比如,当我们有不同的对象,但又有相同或类似的行为时,OOP会不可避免的导致代码的重复
在这个例子里,如果我们去增加一个“可移动”的行为,需要在Player和Monster类中都增加类似的逻辑:
public abstract class Player { int x; int y; void move(int targetX, int targetY) { // logic } } public abstract class Monster { int x; int y; void move(int targetX, int targetY) { // logic } }
一个可能的解法是有个通用的父类:
public abstract class Movable { int x; int y; void move(int targetX, int targetY) { // logic } } public abstract class Player extends Movable; public abstract class Monster extends Movable;
但如果再增加一个跳跃能力Jumpable呢?一个跑步能力Runnable呢?如果Player可以Move和Jump,Monster可以Move和Run,怎么处理继承关系?要知道Java(以及绝大部分语言)是不支持多父类继承的,所以只能通过重复代码来实现
原生OOP力不从心
从OO角度看待,逻辑简单,代码也算过得去,也基本符合充血模型需要的数据与行为结合性要求
但如果业务比较复杂,未来会有大量的业务规则变更时,简单的OOP代码会在后期变成复杂的一团浆糊,逻辑分散在各地,缺少全局视角,各种规则的叠加会触发bug。
在这个小示例中,可以看到新增加一次规则几乎重写很多类,改造成本相当高,这还写得不够OO吗?
总体而言,上面的代码没有处理好这三个问题:
•业务规则的归属到底是对象的“行为”还是独立的”规则对象“?•业务规则之间的关系如何处理?•通用“行为”应该如何复用和维护?
DDD应对
示例和单纯使用面向对象的问题已经很明晰了,DDD如何应对呢?
当然,可以申辩
虽然示例代码已经很OO,但却没有遵守OO原则SOLID[3],至少没有达到OCP目标
尤其开始就掉进OOP的陷阱,使用继承来实现看似是继承关系的逻辑,没有遵循组合优先于继承的原则
尤其没有提取出业务规则,并理清业务规则的归属,不应该与实体对象混合
建模
示例本身很简单,如果我们建模,大概是这样:
但很怪,模型则偏重于数据角度,描述了在不同业务维度下,数据将会如何改变,以及如何支撑对应的计算与统计,也就是说模型上看,会有实体以及实体间的关系,隐藏了业务维度,可以我们这个模型上却包含了动词,来描述业务行为
当然这个模型可以再充实一下,比如把业务规则标识上去,这也说明了传统模型的缺点,如果你对其他模型感兴趣,请关注我,后期会详情介绍模型系列文章
我们回到有问题的本质原点,为什么要建模呢,为了抽象复杂业务逻辑,降低理解业务的成本,挖掘更多的业务隐藏知识
可上面的示例太清楚了,一览无余。一句话可以概述出整个业务需求:
玩家使用武器攻击怪物,对怪物造成伤害,直至怪物死亡
把规则加进去:
玩家按规则使用武器按规则攻击怪物,对怪物、玩家、武器造成一定规则的影响(怪物受到伤害,玩家可能会有反弹伤害,武器持久属性会下降直到武器消失),直至怪物死亡
这其实是任何一款ARGP游戏的核心业务
软件开发的核心难度在于处理隐藏在业务知识中的复杂度,模型就是对这种复杂度的简化与精练,DDD改进版还使用事件风暴方式挖掘业务知识,而像这种业务知识没有隐藏的简明型业务系统,我们已经把核心问题描述得很清楚,无需再去知识消化,事件风暴,为了DDD而DDD,所以建模价值不高,甚至毫无必要
DDD应对
在上面的申辩中,我们已经发现了并不是OO不行,而是使用OO方式不对,虽说要把OO原则深入骨髓,可有没有一种方法能直接上升一层次,就像我们在使用面向过程语言时,也要有面向对象思维,实践没那么容易,直接使用面向对象语言,会让我们更容易使用面向对象思维,领略OO精髓
DDD正好就是这样一种方法,基于OO的升华,主要看看领域层的规范
实体,充血的实体
这一点与原生OO一样,数据与行为相结合
public class Player { private String name; private long health; private WeaponId weaponId; public void equip(Weapon weapon) { // ... } }
•任何实体的行为只能直接影响到本实体(和其子实体)•因为 Weapon 是实体类,但是Weapon能独立存在,Player不是聚合根,所以Player只能保存WeaponId,而不能直接指向Weapon•实体需要依赖其他服务时,也不能直接依赖,使用Double Dispatch
public class Player { public void equip(Weapon weapon, EquipmentService equipmentService) { if (equipmentService.canEquip(this, weapon)) { this.weaponId = weapon.getId(); } else { throw new IllegalArgumentException("Cannot Equip: " + weapon); } } }
领域服务(Domain Service)
单对象
这种领域对象主要面向的是单个实体对象的变更,但涉及到多个领域对象或外部依赖的一些规则
跨对象领域服务
当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。
在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的
不能学习实体的Double Dispatch
public class Player { void attack(Monster, CombatService) { CombatService.performAttack(this, Monster); // ❌,不要这么写,会导致副作用 } }
这个原则也映射了“任何实体的行为只能直接影响到本实体(和其子实体)”的原则,即Player.attack会直接影响到Monster,但这个调用Monster又没有感知
通用组件型
像Movalbe、Runnable通用能力,提供组件化的行为,但本身又不直接绑死在一种实体类上
策略对象(Domain Policy)
Policy或者Strategy设计模式是一个通用的设计模式,但是在DDD架构中会经常出现,其核心就是封装领域规则。
一个Policy是一个无状态的单例对象,通常需要至少2个方法:canApply 和 一个业务方法。其中,canApply方法用来判断一个Policy是否适用于当前的上下文,如果适用则调用方会去触发业务方法。通常,为了降低一个Policy的可测试性和复杂度,Policy不应该直接操作对象,而是通过返回计算后的值,在Domain Service里对对象进行操作。
总结
DDD是一种模型驱动设计方法,但使用DDD也并不是一定要按固定方式方法一步步执行,建模是为了对复杂问题的简化和精炼,挖掘隐藏的业务知识。如果能通过简明方式就能把业务核心问题描述清楚,比其他一切手段都有用,也都重要。那我们就没必要再去为了DDD而DDD,去进行事件风暴,知识消化慢迭代方式
本文中虽然提取了一些DDD领域层规范直接升华OO,但你有没有注意到一个问题,Player如果拥有很多能力,比如Moveable,Runnable,Jumpable,Fireable,那这个实体如何实现?
首先我们肯定会面向接口编程,提取出interface Moveable,interface Runnable,interface Jumpable,interface Fireable,可Player呢?
public class Player implements Moveable,Jumpable,Fireable { void move(int targetX, int targetY) { // logic } void jump() { // logic } void fire() { // logic } }
可以想象,随着能力越来越强大,player类会越来越臃肿,发展成超大类,充满坏味道,可我们这次也没有违反什么原则?难道达到了原生面向对象的能力极限?
如果你有好的想法,欢迎留言交流。如果你觉得文章有帮助,多转发,点击右下角『看一看』
References
[1]
《DDD开篇》: http://www.zhuxingsheng.com/blog/ddd-opening.html
[2]
《面向对象是什么》: http://www.zhuxingsheng.com/blog/what-is-objectoriented.html
[3]
SOLID: http://www.zhuxingsheng.com/tags/SOLID/