建模没必要

Eric在DDD第一章节就介绍了模型,可见模型的作用不言而喻,说DDD是一种模型驱动设计方法,绝对没有问题

那是不是我们在拿到业务需求时,就急呼呼的跟业务方来一起构造模型呢?毕竟模型是万事之首嘛

《DDD开篇》提过DDD是一种基于面向对象的设计方法,我们既然已经有了面向对象,而且OOAD也很强大,为什么还需要DDD呢?

要想弄清楚这两个问题,首先我们需要拿个示例来仔细比对一下

OOP小示例

《面向对象是什么》一文中提到的游戏小示例

有个游戏,基本规则就是玩家装备武器去攻击怪物

  • 玩家(Player)可以是战士(Fighter)、法师(Mage)、龙骑(Dragoon)
  • 怪物(Monster)可以是兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量
  • 武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击力
  • 玩家可以装备一个武器,武器攻击可以是物理类型(0),火(1),冰(2)等,武器类型决定伤害类型

作为一名受过OO熏陶的程序员,借助OO的继承特性把类结构设计成:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 {}

攻击规则如下:

  • 兽人对物理攻击伤害减半
  • 精灵对魔法攻击伤害减半
  • 龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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,扩展狙击枪Gun
  2. Player和所有子类(是否能装备某个武器)
  3. Monster和所有子类(伤害计算逻辑)

除了伤害逻辑有各种规则,还有装备武器也会有各种规则

比如,战士只能装备剑,法师只能装备法杖,但他们都可以装备匕首

再比如,当我们有不同的对象,但又有相同或类似的行为时,OOP会不可避免的导致代码的重复

在这个例子里,如果我们去增加一个“可移动”的行为,需要在Player和Monster类中都增加类似的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
}
}

一个可能的解法是有个通用的父类:

1
2
3
4
5
6
7
8
9
10
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,至少没有达到OCP目标

尤其开始就掉进OOP的陷阱,使用继承来实现看似是继承关系的逻辑,没有遵循组合优先于继承的原则

尤其没有提取出业务规则,并理清业务规则的归属,不应该与实体对象混合

建模

示例本身很简单,如果我们建模,大概是这样:

但很怪,模型则偏重于数据角度,描述了在不同业务维度下,数据将会如何改变,以及如何支撑对应的计算与统计,也就是说模型上看,会有实体以及实体间的关系,隐藏了业务维度,可以我们这个模型上却包含了动词,来描述业务行为

当然这个模型可以再充实一下,比如把业务规则标识上去,这也说明了传统模型的缺点,如果你对其他模型感兴趣,请关注我,后期会详情介绍模型系列文章

我们回到有问题的本质原点,为什么要建模呢,为了抽象复杂业务逻辑,降低理解业务的成本,挖掘更多的业务隐藏知识

可上面的示例太清楚了,一览无余。一句话可以概述出整个业务需求:

玩家使用武器攻击怪物,对怪物造成伤害,直至怪物死亡

把规则加进去:

玩家按规则使用武器按规则攻击怪物,对怪物、玩家、武器造成一定规则的影响(怪物受到伤害,玩家可能会有反弹伤害,武器持久属性会下降直到武器消失),直至怪物死亡

这其实是任何一款ARGP游戏的核心业务

软件开发的核心难度在于处理隐藏在业务知识中的复杂度,模型就是对这种复杂度的简化与精练,DDD改进版还使用事件风暴方式挖掘业务知识,而像这种业务知识没有隐藏的简明型业务系统,我们已经把核心问题描述得很清楚,无需再去知识消化,事件风暴,为了DDD而DDD,所以建模价值不高,甚至毫无必要

DDD应对

在上面的申辩中,我们已经发现了并不是OO不行,而是使用OO方式不对,虽说要把OO原则深入骨髓,可有没有一种方法能直接上升一层次,就像我们在使用面向过程语言时,也要有面向对象思维,实践没那么容易,直接使用面向对象语言,会让我们更容易使用面向对象思维,领略OO精髓

DDD正好就是这样一种方法,基于OO的升华,主要看看领域层的规范

实体,充血的实体

这一点与原生OO一样,数据与行为相结合

1
2
3
4
5
6
7
8
9
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
1
2
3
4
5
6
7
8
9
10
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

1
2
3
4
5
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呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Player implements Moveable,Jumpable,Fireable {

void move(int targetX, int targetY) {
// logic
}

void jump() {
// logic
}

void fire() {
// logic
}

}

可以想象,随着能力越来越强大,player类会越来越臃肿,发展成超大类,充满坏味道,可我们这次也没有违反什么原则?难道达到了原生面向对象的能力极限?

如果你有好的想法,欢迎留言交流。如果你觉得文章有帮助,多转发,点击右下角『看一看』

公众号:码农戏码
欢迎关注微信公众号『码农戏码』