案例分析:
如何用代码实现一个龙与魔法的游戏世界的(极简)规则?
基础配置如下:
-
玩家(Player)可以是战士(Fighter)、法师(Mage)、龙骑(Dragoon)
-
怪物(Monster)可以是兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量
-
武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击力
-
玩家可以装备一个武器,武器攻击可以是物理类型(0),火(1),冰(2)等,武器类型决定伤害类型
攻击规则如下:
-
兽人对物理攻击伤害减半
-
精灵对魔法攻击伤害减半
-
龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍
OOP实现
public abstract class Player {
Weapon weapon
}
public class Fighter extends Player {}
public class Mage extends Player {}
public class Dragoon extends Player {}
public abstract class Monster {
Long health;
}
public Orc extends Monster {}
public Elf extends Monster {}
public Dragoon extends Monster {}
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, 龙免疫力规则
}
}
oop代码的设计缺陷
1)编程语言的强类型无法承载业务规则:
需求一:战士只能装备剑、法师只能装备法杖
@Data
public class Fighter extends Player {
private Sword weapon;
}
@Data
public abstract class Player {
@Setter(AccessLevel.PROTECTED)
private Weapon weapon;
}
@Test
public void testCastEquip() {
Fighter fighter = new Fighter("Hero");
Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);
Player player = fighter;
Staff staff = new Staff("Staff", 10);
player.setWeapon(staff); // 编译不过,但从API层面上应该开放可用
}
需求二:战士和法师都能装备匕首(dagger)
需求一的逻辑被推翻,需要重构
2)对象继承导致代码强依赖父类逻辑,违反开闭原则OCP:
需求三:要增加一个武器类型:狙击枪,能够无视所有防御一击必杀
要修改:
-
Weapon
-
Player和所有的子类(是否能装备某个武器的判断)
-
Monster和所有的子类(伤害计算逻辑
public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 老的基础规则
if (Weapon instanceof Gun) { // 新的逻辑
this.setHealth(0);
}
}
}
public class Dragon extends Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
if (Weapon instanceof Gun) { // 新的逻辑
super.receiveDamageBy(weapon, player);
}
// 老的逻辑省略
}
}
继承虽然可以通过子类扩展新的行为,单因为子类可能直接依赖父类的实现,导致一个变更可能会影响所有的对象。在复杂的软件中会建议“尽量”不要违背OCP,核心的原因就是一个现有逻辑的变更可能会影响一些原油代码,导致一些无法预见的风险。
解决OCP的主要方法是通过组合来做到扩展性,而不是通过继承。
另外在这个例子里,业务规则的逻辑写在哪里?
3)多对象行为类似,导致代码重复:
需求四:Player和Monster都具有可移动行为。
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。
无法多继承,只能写重复代码。
问题总结:
1)业务规则的归属到底是对象的“行为”还是独立的“规则对象”;
2)业务规则之间的关系如何处理;
3)通用“行为”应该如何复用和维护。
基于DDD架构的一种解法
- 领域对象
实体类:Player、Monster和Weapon
public class Player implements Movable {
private PlayerId id;
private String name;
private PlayerClass playerClass; // enum
private WeaponId weaponId; // (Note 1)
private Transform position = Transform.ORIGIN;
private Vector velocity = Vector.ZERO;
}
public class Monster implements Movable {
private MonsterId id;
private MonsterClass monsterClass; // enum
private Health health;
private Transform position = Transform.ORIGIN;
private Vector velocity = Vector.ZERO;
}
public class Weapon {
private WeaponId id;
private String name;
private WeaponType weaponType; // enum
private int damage;
private int damageType; // 0 - physical, 1 - fire, 2 - ice
}
值对象:MovementSystem
public interface Movable {
// 相当于组件
Transform getPosition();
Vector getVelocity();
// 行为
void moveTo(long x, long y);
void startMove(long velX, long velY);
void stopMove();
boolean isMoving();
}
// 具体实现
public class Player implements Movable {
public void moveTo(long x, long y) {
this.position = new Transform(x, y);
}
public void startMove(long velocityX, long velocityY) {
this.velocity = new Vector(velocityX, velocityY);
}
public void stopMove() {
this.velocity = Vector.ZERO;
}
@Override
public boolean isMoving() {
return this.velocity.getX() != 0 || this.velocity.getY() != 0;
}
}
@Value
public class Transform {
public static final Transform ORIGIN = new Transform(0, 0);
long x;
long y;
}
@Value
public class Vector {
public static final Vector ZERO = new Vector(0, 0);
long x;
long y;
}
注意点:
1)一个Entity的规则不能直接变更其属性,必须通过Entity的方法取对内部状态做变更,来保证数据的一致性;
2)抽象Movable可以通过统一的System代码去处理,避免重复劳动。
- 领域服务
1)装备行为:
public interface EquipmentService {
boolean canEquip(Player player, Weapon weapon);
}
错误的使用:
一个Entity不应该直接参考另一个Entity或服务,Entity(非聚合对象)只能保留自己的状态。
public class Player {
@Autowired
EquipmentService equipmentService; // BAD: 不可以直接依赖
public void equip(Weapon weapon) {
// ...
}
}
正确的使用:
(Double Dispatch)在这里,无论是Weapon还是EquipmentService都是通过方法参数传入,确保不会污染Player的自有状态。
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);
}
}
}
Tip:单分派 vs 多分派
单分派(single dispatch)的含义比较好理解,单分派(single dispatch)就是说我们在选择一个方法的时候仅仅需要根据消息接收者(receiver)的运行时型别(Run time type)。实际上这也就是我们经常提到的多态的概念(当然C++中的函数重载也是Sigle dispatch的一种实现方式)。举一个简单的例子,我们有一个基类B,B有一个虚方法f(可被子类override),D1和D2是B的两个子类,在D1和D2中我们覆写(override)了方法f。这样我们对消息f的调用,需要根据接收者A或者A的子类D1/D2的具体型别才可以确定具体是调用A的还是D1/D2的f方法。
double dispatch(双分派)则在选择一个方法的时候,不仅仅要根据消息接收者(receiver)的运行时型别(Run time type),还要根据参数的运行时型别(Run time type)。当然如果所有参数都考虑的话就是multi-dispatch(多分派)。也举一个简单的例子,同于上面单分派中例子,A的虚方法f带了一个C型别的参数,C也是一个基类,C有也有两个具体子类E1和E2。这样,当我们在调用消息f的时候,我们不但要根据接收者的具体型别(A、D1、D2),还要根据参数的具体型别(C、E1、E2),才可以最后确定调用的具体是哪一个方法f。
然后在EquipmentService里实现相关的逻辑判断,这里我们用了另一个常用的Strategy(或者叫Policy)设计模式:
public class EquipmentServiceImpl implements EquipmentService {
private EquipmentManager equipmentManager;
@Override
public boolean canEquip(Player player, Weapon weapon) {
return equipmentManager.canEquip(player, weapon);
}
}
// 策略优先级管理
public class EquipmentManager {
private static final List<EquipmentPolicy> POLICIES = new ArrayList<>();
static {
POLICIES.add(new FighterEquipmentPolicy());
POLICIES.add(new MageEquipmentPolicy());
POLICIES.add(new DragoonEquipmentPolicy());
POLICIES.add(new DefaultEquipmentPolicy());
}
public boolean canEquip(Player player, Weapon weapon) {
for (EquipmentPolicy policy : POLICIES) {
if (!policy.canApply(player, weapon)) {
continue;
}
return policy.canEquip(player, weapon);
}
return false;
}
}
// 策略案例
public class FighterEquipmentPolicy implements EquipmentPolicy {
@Override
public boolean canApply(Player player, Weapon weapon) {
return player.getPlayerClass() == PlayerClass.Fighter;
}
/**
* Fighter能装备Sword和Dagger
*/
@Override
public boolean canEquip(Player player, Weapon weapon) {
return weapon.getWeaponType() == WeaponType.Sword
|| weapon.getWeaponType() == WeaponType.Dagger;
}
}
// 其他策略省略,见源码
2)攻击行为
public interface CombatService {
void performAttack(Player player, Monster monster);
}
public class CombatServiceImpl implements CombatService {
private WeaponRepository weaponRepository;
private DamageManager damageManager;
@Override
public void performAttack(Player player, Monster monster) {
Weapon weapon = weaponRepository.find(player.getWeaponId());
int damage = damageManager.calculateDamage(player, weapon, monster);
if (damage > 0) {
monster.takeDamage(damage); // (Note 1)在领域服务里变更Monster
}
// 省略掉Player和Weapon可能受到的影响
}
}
同样的在这个案例里,可以通过Strategy设计模式来解决damage的计算问题:
// 策略优先级管理
public class DamageManager {
private static final List<DamagePolicy> POLICIES = new ArrayList<>();
static {
POLICIES.add(new DragoonPolicy());
POLICIES.add(new DragonImmunityPolicy());
POLICIES.add(new OrcResistancePolicy());
POLICIES.add(new ElfResistancePolicy());
POLICIES.add(new PhysicalDamagePolicy());
POLICIES.add(new DefaultDamagePolicy());
}
public int calculateDamage(Player player, Weapon weapon, Monster monster) {
for (DamagePolicy policy : POLICIES) {
if (!policy.canApply(player, weapon, monster)) {
continue;
}
return policy.calculateDamage(player, weapon, monster);
}
return 0;
}
}
// 策略案例
public class DragoonPolicy implements DamagePolicy {
public int calculateDamage(Player player, Weapon weapon, Monster monster) {
return weapon.getDamage() * 2;
}
@Override
public boolean canApply(Player player, Weapon weapon, Monster monster) {
return player.getPlayerClass() == PlayerClass.Dragoon &&
monster.getMonsterClass() == MonsterClass.Dragon;
}
}
特别需要注意的是这里的CombatService领域服务和EquipmentService领域服务,虽然都是领域服务,但实质上有很大的差异。上文的EquipmentService更多的是提供只读策略,且只会影响单个对象,所以可以在Player.equip方法上通过参数注入。但是CombatService有可能会影响多个对象,所以不能直接通过参数注入的方式调用。
3)移动系统
移动行为的统一处理,对象中只保留了位置和速度参数
public class MovementSystem {
private static final long X_FENCE_MIN = -100;
private static final long X_FENCE_MAX = 100;
private static final long Y_FENCE_MIN = -100;
private static final long Y_FENCE_MAX = 100;
private List<Movable> entities = new ArrayList<>();
public void register(Movable movable) {
entities.add(movable);
}
public void update() {
for (Movable entity : entities) {
if (!entity.isMoving()) {
continue;
}
Transform old = entity.getPosition();
Vector vel = entity.getVelocity();
long newX = Math.max(Math.min(old.getX() + vel.getX(), X_FENCE_MAX), X_FENCE_MIN);
long newY = Math.max(Math.min(old.getY() + vel.getY(), Y_FENCE_MAX), Y_FENCE_MIN);
entity.moveTo(newX, newY);
}
}
}
单测:
@Test
@DisplayName("Moving player and monster at the same time")
public void testMovement() {
// Given
Player fighter = playerFactory.createPlayer(PlayerClass.Fighter, "MyFighter");
fighter.moveTo(2, 5);
fighter.startMove(1, 0);
Monster orc = monsterFactory.createMonster(MonsterClass.Orc, 100);
orc.moveTo(10, 5);
orc.startMove(-1, 0);
movementSystem.register(fighter);
movementSystem.register(orc);
// When
movementSystem.update();
// Then
assertThat(fighter.getPosition().getX()).isEqualTo(2 + 1);
assertThat(orc.getPosition().getX()).isEqualTo(10 - 1);
}
- 领域事件
需求六:当Monster的生命值降为0后,给Player奖励经验值
需求七:当Player的exp达到100时,升一级
错误示范:
public class CombatService {
public void performAttack(Player player, Monster monster) {
// ...
monster.takeDamage(damage);
if (!monster.isAlive()) {
player.receiveExp(10); // 收到经验
if (player.canLevelUp()) {
player.levelUp(); // 升级
}
}
}
}
这两个需求被称为“副作用”,核心领域模型状态变更后,通过或者异步对另一个对象的影响或行为。这种“副作用”会后续代码无法维护。
因此需要使用领域事件进行解耦。
领域事件的实现:
public class EventBus {
// 注册器
@Getter
private final EventRegistry invokerRegistry = new EventRegistry(this);
// 事件分发器
private final EventDispatcher dispatcher = new EventDispatcher(ExecutorFactory.getDirectExecutor());
// 异步事件分发器
private final EventDispatcher asyncDispatcher = new EventDispatcher(ExecutorFactory.getThreadPoolExecutor());
// 事件分发
public boolean dispatch(Event event) {
return dispatch(event, dispatcher);
}
// 异步事件分发
public boolean dispatchAsync(Event event) {
return dispatch(event, asyncDispatcher);
}
// 内部事件分发
private boolean dispatch(Event event, EventDispatcher dispatcher) {
checkEvent(event);
// 1.获取事件数组
Set<Invoker> invokers = invokerRegistry.getInvokers(event);
// 2.一个事件可以被监听N次,不关心调用结果
dispatcher.dispatch(event, invokers);
return true;
}
// 事件总线注册
public void register(Object listener) {
if (listener == null) {
throw new IllegalArgumentException("listener can not be null!");
}
invokerRegistry.register(listener);
}
private void checkEvent(Event event) {
if (event == null) {
throw new IllegalArgumentException("event");
}
if (!(event instanceof Event)) {
throw new IllegalArgumentException("Event type must by " + Event.class);
}
}
}
使用:
public class LevelUpEvent implements Event {
private Player player;
}
public class LevelUpHandler {
public void handle(Player player);
}
public class Player {
public void receiveExp(int value) {
this.exp += value;
if (this.exp >= 100) {
LevelUpEvent event = new LevelUpEvent(this);
EventBus.dispatch(event);
this.exp = 0;
}
}
}
@Test
public void test() {
EventBus.register(new LevelUpHandler());
player.setLevel(1);
player.receiveExp(100);
assertThat(player.getLevel()).equals(2);
}
目前领域事件的缺陷和展望
从上面代码可以看出来,领域事件的很好的实施依赖EventBus、Dispatcher、Invoker这些属于框架级别的支持。同时另一个问题是因为Entity不能直接依赖外部对象,所以EventBus目前只能是一个全局的Singleton,而大家都应该知道全局Singleton对象很难被单测。这就容易导致Entity对象无法被很容易的被完整单测覆盖全。
另一种解法是侵入Entity,对每个Entity增加一个List:
public class Player {
List<Event> events;
public void receiveExp(int value) {
this.exp += value;
if (this.exp >= 100) {
LevelUpEvent event = new LevelUpEvent(this);
events.add(event); // 把event加进去
this.exp = 0;
}
}
}
@Test
public void test() {
EventBus.register(new LevelUpHandler());
player.setLevel(1);
player.receiveExp(100);
for(Event event: player.getEvents()) { // 在这里显性的dispatch事件
EventBus.dispatch(event);
}
assertThat(player.getLevel()).equals(2);
}
但是能看出来这种解法不但会侵入实体本身,同时也需要比较啰嗦的显性在调用方dispatch事件,也不是一个好的解决方案。
DDD领域层的一些设计规范
- 实体类
1)创建即一致:手动new出对象逐个赋值,容易产生遗漏。DDD中实体创建的方法有两种:
constructor参数要包含所有必要属性,或者在constructor里有合理的默认值,并在创建时做强校验。
2)尽量避免public setter:“尽量”通过行为方法来修改内部状态;
3)通过聚合根保证主子实体的一致性:
子实体不能单独存在,只能通过聚合根的方法获取到;
子实体没有独立的Repository,不可以单独保存和取出,必须要通过聚合根的Repository实例化;
子实体可以单独修改自身状态,但是多个子实体之间的一致性需要聚合根来保障。
4)不可以强依赖其他聚合根实体或领域服务
只保存外部实体的ID,建议使用强类型的ID对象,而不是Long型ID;
针对“无副作用”的外部依赖,通过方法入参的方式传入。比如上文中的equip(Weapon,EquipmentService)方法。
如果方法对外部有副作用依赖,只能通过Domain Service解决。
5)任何实体的行为只能直接影响到本实体(和其子实体)。
- 领域服务
1)单对象策略型:主要面向的是单个实体对象的变更,单涉及多个领域对象或外部依赖的一些规则。
这种类型下,实体应该通过方法入参的方式传入这种领域服务,然后通过Double Dispatch来反转调用领域服务的方法;
2)跨对象事务型:跨对象事务,确保多个实体的变更之间是有一致性的;
3)通用组件性:提供了组件化行为,参考MovementSystem实现。
- 策略对象
在DDD架构中会经常出现,核心就是封装领域规则。
- 领域事件
领域事件是一个领域里发生了某些事后,希望领域里其他对象能够感知的通知机制。领域事件的好处就是将隐形事件显性化。