DDD优秀实践及总结 Part Ⅳ——领域层设计

案例分析:

如何用代码实现一个龙与魔法的游戏世界的(极简)规则?

基础配置如下:

  • 玩家(Player)可以是战士(Fighter)、法师(Mage)、龙骑(Dragoon)

  • 怪物(Monster)可以是兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量

  • 武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击力

  • 玩家可以装备一个武器,武器攻击可以是物理类型(0),火(1),冰(2)等,武器类型决定伤害类型

攻击规则如下:

  1. 兽人对物理攻击伤害减半

  2. 精灵对魔法攻击伤害减半

  3. 龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍

 

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架构中会经常出现,核心就是封装领域规则。

 

  • 领域事件

领域事件是一个领域里发生了某些事后,希望领域里其他对象能够感知的通知机制。领域事件的好处就是将隐形事件显性化。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值