在一个 DDD 架构设计中,领域层的设计合理性会直接影响整个架构的代码结构以及应用层、基础设施层的设计。但是领域层设计又是有挑战的任务,特别是在一个业务逻辑相对复杂应用中,每一个业务规则是应该放在 Entity、Value Object 还是 DomainService 是值得用心思考的,既要避免未来的扩展性差,又要确保不会过度设计导致复杂性。今天我用一个相对轻松易懂的领域做一个案例演示,但在实际业务应用中,无论是交易、营销还是互动,都可以用类似的逻辑来实现。

初探龙与魔法的世界架构

背景和规则

平日里看了好多严肃的业务代码,今天找一个轻松的话题,如何用代码实现一个龙与魔法的游戏世界的(极简)规则?

基础配置如下:

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

攻击规则如下:

  • 兽人对物理攻击伤害减半
  • 精灵对魔法攻击伤害减半
  • 龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍

OOP 实现

对于熟悉 Object-Oriented Programming 的同学,一个比较简单的实现是通过类的继承关系

// 玩家
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 class Orc extends Monster {}
// 精灵
public class Elf extends Monster {}
// 龙
public class Dragon extends Monster {}

// 武器
public abstract class Weapon {
    // 伤害
    int damage;
    // 伤害类型
    int damageType; // 0 - physical, 1 - magic, etc.
}
// 剑
public class Sword extends Weapon {}
// 法杖
public class 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, 龙免疫力规则
    }
}

然后跑几个单测:

public class BattleTest {

    @Test
    @DisplayName("Dragon is immune to attacks")
    public void testDragonImmunity() {
        // Given
        Fighter fighter = new Fighter("Hero");
        Sword sword = new Sword("Excalibur", 10);
        fighter.setWeapon(sword);
        Dragon dragon = new Dragon("Dragon", 100L);

        // When
        fighter.attack(dragon);

        // Then
        assertThat(dragon.getHealth()).isEqualTo(100);
    }

    @Test
    @DisplayName("Dragoon attack dragon doubles damage")
    public void testDragoonSpecial() {
        // Given
        Dragoon dragoon = new Dragoon("Dragoon");
        Sword sword = new Sword("Excalibur", 10);
        dragoon.setWeapon(sword);
        Dragon dragon = new Dragon("Dragon", 100L);

        // When
        dragoon.attack(dragon);

        // Then
        assertThat(dragon.getHealth()).isEqualTo(100 - 10 * 2);
    }

    @Test
    @DisplayName("Orc should receive half damage from physical weapons")
    public void testFighterOrc() {
        // Given
        Fighter fighter = new Fighter("Hero");
        Sword sword = new Sword("Excalibur", 10);
        fighter.setWeapon(sword);
        Orc orc = new Orc("Orc", 100L);

        // When
        fighter.attack(orc);

        // Then
        assertThat(orc.getHealth()).isEqualTo(100 - 10 / 2);
    }

    @Test
    @DisplayName("Orc receive full damage from magic attacks")
    public void testMageOrc() {
        // Given
        Mage mage = new Mage("Mage");
        Staff staff = new Staff("Fire Staff", 10);
        mage.setWeapon(staff);
        Orc orc = new Orc("Orc", 100L);

        // When
        mage.attack(orc);

        // Then
        assertThat(orc.getHealth()).isEqualTo(100 - 10);
    }
}

以上代码和单测都比较简单,不做多余的解释了。

分析 OOP 代码的设计缺陷

编程语言的强类型无法承载业务规则,以上的 OOP 代码可以跑得通,直到我们加一些限制条件:

  • 战士只能装备剑
  • 法师只能装备法杖

这个规则在 Java 语言里无法通过强类型来实现,虽然 Java 有 Variable Hiding(或者 C# 的 new class variable),但实际上只是在子类上加了一个新变量,所以会导致以下的问题:

@Data
public class Fighter extends Player {
    private Sword weapon;
}

@Test
public void testEquip() {
    Fighter fighter = new Fighter("Hero");

    Sword sword = new Sword("Sword", 10);
    fighter.setWeapon(sword);

    Staff staff = new Staff("Staff", 10);
    fighter.setWeapon(staff);

    assertThat(fighter.getWeapon()).isInstanceOf(Staff.class); // 错误了
}

在最后,虽然代码感觉是 setWeapon(Staff),但实际上只修改了父类的变量,并没有修改子类的变量,所以实际不生效,也不抛异常,但结果是错的。

当然,可以在父类限制 setter 为 protected,但这样就限制了父类的 API,极大的降低了灵活性,同时也违背了 Liskov substitution principle,即一个父类必须要 cast 成子类才能使用:

@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)

BOOM,之前写的强类型代码都废了,需要重构。

对象继承导致代码强依赖父类逻辑,违反开闭原则 Open-Closed Principle(OCP)

开闭原则(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 的原则能尽可能的规避这种风险,而当新的行为只能通过新的字段/方法来实现时,老代码的行为自然不会变。

继承虽然能 Open for extension,但很难做到 Closed for modification。所以今天解决 OCP 的主要方法是通过 Composition-over-inheritance,即通过组合来做到扩展性,而不是通过继承。

Player.attack(monster) 还是 Monster.receiveDamage(Weapon, Player)?

在这个例子里,其实业务规则的逻辑到底应该写在哪里是有异议的:当我们去看一个对象和另一个对象之间的交互时,到底是 Player 去攻击 Monster,还是 Monster 被 Player 攻击,受到伤害?目前的代码主要将逻辑写在 Monster 的类中,主要考虑是 Monster 会受伤降低 Health,但如果是 Player 拿着一把双刃剑会同时伤害自己呢?是不是发现写在 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 的逻辑很简单,但如果你的业务比较复杂,未来会有大量的业务规则变更时,简单的 OOP 代码会在后期变成复杂的一团浆糊,逻辑分散在各地,缺少全局视角,各种规则的叠加会触发 bug。有没有感觉似曾相识?对的,电商体系里的优惠、交易等链路经常会碰到类似的坑。而这类问题的核心本质在于:

  • 业务规则的归属到底是对象的“行为”还是独立的“规则对象”?
  • 业务规则之间的关系如何处理?
  • 通用“行为”应该如何复用和维护?

在讲 DDD 的解法前,我们先去看一套游戏里最近比较火的架构设计,Entity-Component-System(ECS)是如何实现的。

Entity-Component-System(ECS)架构简介

ECS 介绍

ECS 架构模式其实是一个很老的游戏架构设计,最早应该能追溯到《地牢围攻》的组件化设计,但最近因为 Unity 的加入而开始变得流行(比如《守望先锋》就是用的 ECS)。要很快的理解 ECS 架构的价值,我们需要理解一个游戏代码的核心问题:

  • 性能:游戏必须要实现一个高的渲染率(60 FPS),也就是说整个游戏世界需要在 1/60s(大概 16ms)内完整更新一次(包括物理引擎、游戏状态、渲染、AI 等)。而在一个游戏中,通常有大量的(万级、十万级)游戏对象需要更新状态,除了渲染可以依赖 GPU 之外,其他的逻辑都需要由 CPU 完成,甚至绝大部分只能由单线程完成,导致绝大部分时间复杂场景下 CPU(主要是内存到 CPU 的带宽)会成为瓶颈。在 CPU 单核速度几乎不再增加的时代,如何能让 CPU 处理的效率提升,是提升游戏性能的核心。
  • 代码组织:如同第一章讲的案例一样,当我们用传统 OOP 的模式进行游戏开发时,很容易就会陷入代码组织上的问题,最终导致代码难以阅读,维护和优化。
  • 可扩展性:这个跟上一条类似,但更多的是游戏的特性导致:需要快速更新,加入新的元素。一个游戏的架构需要能通过低代码、甚至 0 代码的方式增加游戏元素,从而通过快速更新而留住用户。如果每次变更都需要开发新的代码,测试,然后让用户重新下载客户端,可想而知这种游戏很难在现在的竞争环境下活下来。

而 ECS 架构能很好的解决上面的几个问题,ECS 架构主要分为:

  • Entity:用来代表任何一个游戏对象,但是在 ECS 里一个 Entity 最重要的仅仅是他的 EntityID,一个 Entity 里包含多个 Component
  • Component:是真正的数据,ECS 架构把一个个的实体对象拆分为更加细化的组件,比如位置、素材、状态等,也就是说一个 Entity 实际上只是一个 Bag of Components。
  • System(或者 ComponentSystem,组件系统):是真正的行为,一个游戏里可以有很多个不同的组件系统,每个组件系统都只负责一件事,可以依次处理大量的相同组件,而不需要去理解具体的 Entity。所以一个 ComponentSystem 理论上可以有更加高效的组件处理效率,甚至可以实现并行处理,从而提升 CPU 利用率。

ECS 的一些核心性能优化包括将同类型组件放在同一个 Array 中,然后 Entity 仅保留到各自组件的 pointer,这样能更好的利用 CPU 的缓存,减少数据的加载成本,以及 SIMD 的优化等。

一个 ECS 案例的伪代码如下:

public class Entity {
  public Vector position; // 此处 Vector 是一个 Component, 指向的是 MovementSystem.list 里的一个
}

public class MovementSystem {
    
  List<Vector> list;

  // System 的行为
  public void update(float delta) {
    for(Vector pos : list) { // 这个 loop 直接走了 CPU 缓存,性能很高,同时可以用 SIMD 优化
      pos.x = pos.x + delta;
      pos.y = pos.y + delta;
    }
  }
}

@Test
public void test() {
  MovementSystem system = new MovementSystem();
  system.list = new List<>() { new Vector(0, 0) };
  Entity entity = new Entity(list.get(0));
  system.update(0.1);
  assertTrue(entity.position.x == 0.1);
}

由于本文不是讲解 ECS 架构的,感兴趣的同学可以搜索 Entity-Component-System 或者看看 Unity 的 ECS 文档等。

ECS 架构分析

重新回来分析 ECS,其实它的本源还是几个很老的概念:

组件化

在软件系统里,我们通常将复杂的大系统拆分为独立的组件,来降低复杂度。比如网页里通过前端组件化降低重复开发成本,微服务架构通过服务和数据库的拆分降低服务复杂度和系统影响面等。但是 ECS 架构把这个走到了极致,即每个对象内部都实现了组件化。通过将一个游戏对象的数据和行为拆分为多个组件和组件系统,能实现组件的高度复用性,降低重复开发成本。

行为抽离

这个在游戏系统里有个比较明显的优势。如果按照 OOP 的方式,一个游戏对象里可能会包括移动代码、战斗代码、渲染代码、AI 代码等,如果都放在一个类里会很长,且很难去维护。通过将通用逻辑抽离出来为单独的 System 类,可以明显提升代码的可读性。另一个好处则是抽离了一些和对象代码无关的依赖,比如上文的 delta,这个 delta 如果是放在 Entity 的 update 方法,则需要作为入参注入,而放在 System 里则可以统一管理。在第一章的有个问题,到底是应该 Player.attack(monster) 还是 Monster.receiveDamage(Weapon, Player)。在 ECS 里这个问题就变的很简单,放在 ComponentSystem 里就可以了。

数据驱动

即一个对象的行为不是写死的而是通过其参数决定,通过参数的动态修改,就可以快速改变一个对象的具体行为。在 ECS 的游戏架构里,通过给 Entity 注册相应的 Component,以及改变 Component 的具体参数的组合,就可以改变一个对象的行为和玩法,比如创建一个水壶 + 爆炸属性就变成了“爆炸水壶”、给一个自行车加上风魔法就变成了飞车等。在有些 Rougelike 游戏中,可能有超过 1 万件不同类型、不同功能的物品,如果这些不同功能的物品都去单独写代码,可能永远都写不完,但是通过数据驱动 + 组件化架构,所有物品的配置最终就是一张表,修改也极其简单。这个也是组合胜于继承原则的一次体现。

ECS 的缺陷

虽然 ECS 在游戏界已经开始崭露头角,我发现 ECS 架构目前还没有在哪个大型商业应用中被使用过。原因可能很多,包括 ECS 比较新大家还不了解、缺少商业成熟可用的框架、程序员们还不够能适应从写逻辑脚本到写组件的思维转变等,但我认为其最大的一个问题是 ECS 为了提升性能,强调了数据/状态(State)和行为(Behaivor)分离,并且为了降低 GC 成本,直接操作数据,走到了一个极端。而在商业应用中,数据的正确性、一致性和健壮性应该是最高的优先级,而性能只是锦上添花的东西,所以 ECS 很难在商业场景里带来特别大的好处。但这不代表我们不能借鉴一些 ECS 的突破性思维,包括组件化、跨对象行为的抽离、以及数据驱动模式,而这些在 DDD 里也能很好的用起来。

基于DDD架构的一种解法

领域对象

回到我们原来的问题域上面,我们从领域层拆分一下各种对象:

实体类

在 DDD 里,实体类包含 ID 和内部状态,在这个案例里实体类包含 Player、Monster 和 Weapon。Weapon 被设计成实体类是因为两把同名的 Weapon 应该可以同时存在,所以必须要有 ID 来区分,同时未来也可以预期 Weapon 会包含一些状态,比如升级、临时的 buff、耐久等。

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
}

在这个简单的案例里,我们可以利用 enum 的 PlayerClass、MonsterClass 来代替继承关系,后续也可以利用 Type Object 设计模式来做到数据驱动。

Note 1:因为 Weapon 是实体类,所以 Weapon 能独立存在,而 Player 不是聚合根,所以 Player 只能保存WeaponId,而不能直接指向 Weapon。

值对象的组件化

在前面的 ECS 架构里,有个 MovementSystem 的概念是可以复用的,虽然不应该直接去操作 Component 或者继承通用的父类,但是可以通过接口的方式对领域对象做组件化处理:

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;
}

注意两点:

  • Moveable 的接口没有 Setter。一个 Entity 的规则是不能直接变更其属性,必须通过 Entity 的方法去对内部状态做变更。这样能保证数据的一致性。
  • 抽象 Movable 的好处是如同 ECS 一样,一些特别通用的行为(如在大地图里移动)可以通过统一的System 代码去处理,避免了重复劳动。

装备行为

因为我们已经不会用 Player 的子类来决定什么样的 Weapon 可以装备,所以这段逻辑应该被拆分到一个单独的类里。这种类在 DDD 里被叫做领域服务(Domain Service)。

public interface EquipmentService {
    boolean canEquip(Player player, Weapon weapon);
}

在 DDD 里,一个 Entity 不应该直接参考另一个 Entity 或服务,也就是说以下的代码是错误的:

public class Player {
    
    @Autowired
    EquipmentService equipmentService; // BAD: 不可以直接依赖

    public void equip(Weapon weapon) {
       // ...
    }
}

这里的问题是 Entity 只能保留自己的状态(或非聚合根的对象)。任何其他的对象,无论是否通过依赖注入的方式弄进来,都会破坏 Entity 的 Invariance,并且还难以单测。

正确的引用方式是通过方法参数引入(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);
        }
    }
}

在这里,无论是 Weapon 还是 EquipmentService 都是通过方法参数传入,确保不会污染 Player 的自有状态。

Double Dispatch 是一个使用 Domain Service 经常会用到的方法,类似于调用反转。

然后在 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;
    }
}

// 其他策略省略,见源码

这样设计的最大好处是未来的规则增加只需要添加新的 Policy 类,而不需要去改变原有的类。

攻击行为

在上文中曾经有提起过,到底应该是 Player.attack(Monster) 还是 Monster.receiveDamage(Weapon, Player)?在 DDD 里,因为这个行为可能会影响到 Player、Monster 和 Weapon,所以属于跨实体的业务逻辑。在这种情况下需要通过一个第三方的领域服务(Domain Service)来完成。

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 有可能会影响多个对象,所以不能直接通过参数注入的方式调用。

单元测试

@Test
@DisplayName("Dragoon attack dragon doubles damage")
public void testDragoonSpecial() {
    // Given
    Player dragoon = playerFactory.createPlayer(PlayerClass.Dragoon, "Dart");
    Weapon sword = weaponFactory.createWeaponFromPrototype(swordProto, "Soul Eater", 60);
    ((WeaponRepositoryMock)weaponRepository).cache(sword);
    dragoon.equip(sword, equipmentService);
    Monster dragon = monsterFactory.createMonster(MonsterClass.Dragon, 100);

    // When
    combatService.performAttack(dragoon, dragon);

    // Then
    assertThat(dragon.getHealth()).isEqualTo(Health.ZERO);
    assertThat(dragon.isAlive()).isFalse();
}

@Test
@DisplayName("Orc should receive half damage from physical weapons")
public void testFighterOrc() {
    // Given
    Player fighter = playerFactory.createPlayer(PlayerClass.Fighter, "MyFighter");
    Weapon sword = weaponFactory.createWeaponFromPrototype(swordProto, "My Sword");
    ((WeaponRepositoryMock) weaponRepository).cache(sword);
    fighter.equip(sword, equipmentService);
    Monster orc = monsterFactory.createMonster(MonsterClass.Orc, 100);

    // When
    combatService.performAttack(fighter, orc);

    // Then
    assertThat(orc.getHealth()).isEqualTo(Health.of(100 - 10 / 2));
}

具体的代码比较简单,解释省略

移动系统

最后还有一种 Domain Service,通过组件化,我们其实可以实现 ECS 一样的 System,来降低一些重复性的代码:

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);
}

在这里 MovementSystem 就是一个相对独立的 Domain Service,通过对 Movable 的组件化,实现了类似代码的集中化、以及一些通用依赖/配置的中心化(如 X、Y 边界等)。

DDD 领域层的一些设计规范

上面我主要针对同一个例子对比了 OOP、ECS 和 DDD 的 3 种实现,比较如下:

  • 基于继承关系的 OOP 代码:OOP 的代码最好写,也最容易理解,所有的规则代码都写在对象里,但是当领域规则变得越来越复杂时,其结构会限制它的发展。新的规则有可能会导致代码的整体重构。
  • 基于组件化的 ECS 代码:ECS 代码有最高的灵活性、可复用性、及性能,但极具弱化了实体类的内聚,所有的业务逻辑都写在了服务里,会导致业务的一致性无法保障,对商业系统会有较大的影响。
  • 基于领域对象 + 领域服务的 DDD 架构:DDD 的规则其实最复杂,同时要考虑到实体类的内聚和保证不变性(Invariants),也要考虑跨对象规则代码的归属,甚至要考虑到具体领域服务的调用方式,理解成本比较高。

所以下面,我会尽量通过一些设计规范,来降低 DDD 领域层的设计成本。领域层里的 Value Object(Domain Primitive)设计规范请参考我之前的文章。

实体类(Entity)

大多数 DDD 架构的核心都是实体类,实体类包含了一个领域里的状态、以及对状态的直接操作。Entity 最重要的设计原则是保证实体的不变性(Invariants),也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。所以几个设计原则如下:

创建即一致

在贫血模型里,通常见到的代码是一个模型通过手动 new 出来之后,由调用方一个参数一个参数的赋值,这就很容易产生遗漏,导致实体状态不一致。所以 DDD 里实体创建的方法有两种:

constructor 参数要包含所有必要属性,或者在 constructor 里有合理的默认值。

比如,账号的创建:

public class Account {
    private String accountNumber;
    private Long amount;
}

@Test
public void test() {
    Account account = new Account();
    account.setAmount(100L);
    TransferService.transfer(account); // 报错了,因为Account缺少必要的AccountNumber
}

如果缺少一个强校验的 constructor,就无法保障创建的实体的一致性。所以需要增加一个强校验的constructor:

public class Account {
    public Account(String accountNumber, Long amount) {
        assert StringUtils.isNotBlank(accountNumber);
        assert amount >= 0;
        this.accountNumber = accountNumber;
        this.amount = amount;
    }
}

@Test
public void test() {
    Account account = new Account("123", 100L); // 确保对象的有效性
}

使用 Factory 模式来降低调用方复杂度

另一种方法是通过 Factory 模式来创建对象,降低一些重复性的入参。比如:

public class WeaponFactory {
    public Weapon createWeaponFromPrototype(WeaponPrototype proto, String newName) {
        Weapon weapon = new Weapon(null, newName, proto.getWeaponType(), proto.getDamage(), proto.getDamageType());
        return weapon;
    }
}

通过传入一个已经存在的 Prototype,可以快速的创建新的实体。还有一些其他的如 Builder 等设计模式就不一一指出了。

尽量避免 public setter

一个最容易导致不一致性的原因是实体暴露了 public 的 setter 方法,特别是 set 单一参数会导致状态不一致的情况。比如,一个订单可能包含订单状态(下单、已支付、已发货、已收货)、支付单、物流单等子实体,如果一个调用方能随意去 set 订单状态,就有可能导致订单状态和子实体匹配不上,导致业务流程走不通的情况。所以在实体里,需要通过行为方法来修改内部状态:

@Data @Setter(AccessLevel.PRIVATE) // 确保不生成 public setter
public class Order {
    
    private int status; // 0 - 创建,1 - 支付,2 - 发货,3 - 收货
    private Payment payment; // 支付单
    private Shipping shipping; // 物流单

    public void pay(Long userId, Long amount) {
        if (status != 0) {
            throw new IllegalStateException();
        }
        this.status = 1;
        this.payment = new Payment(userId, amount);
    }

    public void ship(String trackingNumber) {
        if (status != 1) {
            throw new IllegalStateException();
        }
        this.status = 2;
        this.shipping = new Shipping(trackingNumber);
    }
}

【建议】在有些简单场景里,有时候确实可以比较随意的设置一个值而不会导致不一致性,也建议将方法名重新写为比较“行为化”的命名,会增强其语意。比如 setPosition(x, y) 可以叫做 moveTo(x, y),setAddress 可以叫做 assignAddress 等。

通过聚合根保证主子实体的一致性

在稍微复杂一点的领域里,通常主实体会包含子实体,这时候主实体就需要起到聚合根的作用,即:

  • 子实体不能单独存在,只能通过聚合根的方法获取到。任何外部的对象都不能直接保留子实体的引用
  • 子实体没有独立的 Repository,不可以单独保存和取出,必须要通过聚合根的 Repository 实例化
  • 子实体可以单独修改自身状态,但是多个子实体之间的状态一致性需要聚合根来保障

常见的电商域中聚合的案例如主子订单模型、商品/SKU 模型、跨子订单优惠、跨店优惠模型等。很多聚合根和 Repository 的设计规范在我前面一篇关于 Repository 的文章中已经详细解释过,可以拿来参考。

不可以强依赖其他聚合根实体或领域服务

一个实体的原则是高内聚、低耦合,即一个实体类不能直接在内部直接依赖一个外部的实体或服务。这个原则和绝大多数 ORM 框架都有比较严重的冲突,所以是一个在开发过程中需要特别注意的。这个原则的必要原因包括:对外部对象的依赖性会直接导致实体无法被单测;以及一个实体无法保证外部实体变更后不会影响本实体的一致性和正确性。

所以,正确的对外部依赖的方法有两种:

  1. 只保存外部实体的 ID:这里我再次强烈建议使用强类型的 ID 对象,而不是 Long 型 ID。强类型的 ID 对象不单单能自我包含验证代码,保证 ID 值的正确性,同时还能确保各种入参不会因为参数顺序变化而出 bug。具体可以参考我的 Domain Primitive 文章。
  2. 针对于“无副作用”的外部依赖,通过方法入参的方式传入。比如上文中的 equip(Weapon,EquipmentService) 方法。如果方法对外部依赖有副作用,不能通过方法入参的方式,只能通过 Domain Service 解决,见下文。

无副作用的外部依赖:

// 方法只需要读取武器数据,不会改变任何外部状态
public void equip(Weapon weapon, AttributeCalculator calculator) {
    int attack = calculator.calculateAttack(weapon);
    this.attack += attack;
}
  • 特点:就像借用计算器算数,用完归还,不会留下痕迹。
  • 通过参数传入,不影响系统状态。
  • 测试时只需要准备虚拟的 calculator 即可。

有副作用的外部依赖:

// 方法调用会改变外部系统状态(库存数量)
public void equip(Weapon weapon, InventoryService service) {
    service.deductStock(weapon.getId()); // 这里修改了外部库存
    this.equippedWeapon = weapon;
}
  • 特点:就像在墙上涂鸦,会留下永久性的改变。
  • 必须通过 DomainService 进行协调,因为:
    • 可能涉及事务操作(装备 + 扣库存要原子化)
    • 需要协调多个聚合根(玩家、库存、日志等)
    • 测试时需要真实 mock 外部服务状态。
    • 可能产生级联影响(库存不足时的补偿操作)

简单判断标准:方法执行后,外部系统会留下痕迹(数据变更、日志记录、消息发送等),就是有副作用的外部依赖,如果只是读取数据或使用工具类计算,就是无副作用的外部依赖。

任何实体的行为只能直接影响到本实体(和其子实体)

这个原则更多是一个确保代码可读性、可理解的原则,即任何实体的行为不能有“直接”的“副作用”,即直接修改其他的实体类。这么做的好处是代码读下来不会产生意外。

另一个遵守的原因是可以降低未知的变更的风险。在一个系统里一个实体对象的所有变更操作应该都是预期内的,如果一个实体能随意被外部直接修改的话,会增加代码 bug 的风险。

领域服务(Domain Service)

在上文讲到,领域服务其实也分很多种,在这里根据上文总结出来三种常见的:

单对象策略型

这种领域对象主要面向的是单个实体对象的变更,但涉及到多个领域对象或外部依赖的一些规则。在上文中,EquipmentService 即为此类:

  • 变更的对象是 Player 的参数
  • 读取的是 Player 和 Weapon 的数据,可能还包括从外部读取一些数据

在这种类型下,实体应该通过方法入参的方式传入这种领域服务,然后通过 Double Dispatch 来反转调用领域服务的方法,比如:

Player.equip(Weapon, EquipmentService) {
    EquipmentService.canEquip(this, Weapon);
}

为什么这种情况下不能先调用领域服务,再调用实体对象的方法,从而减少实体对领域服务的入参型依赖呢?比如,下面这个方法是错误的:

boolean canEquip = EquipmentService.canEquip(Player, Weapon);
if (canEquip) {
    Player.equip(Weapon); // ❌,这种方法不可行,因为这个方法有不一致的可能性
}

其错误的主要原因是缺少了领域服务入参会导致方法有可能产生不一致的情况。

跨对象事务型

当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的。

在上文里,虽然以下的代码虽然可以跑到通,但是是不建议的:

public class Player {
    void attack(Monster, CombatService) {
        CombatService.performAttack(this, Monster); // ❌,不要这么写,会导致副作用
    }
}

而我们真实调用应该直接调用 CombatService 的方法:

public void test() {
    //...
    combatService.performAttack(mage, orc);
}

这个原则也映射了 4.1.5 的原则,即 Player.attack 会直接影响到 Monster,但这个调用 Monster 又没有感知。

通用组件型

这种类型的领域服务更像 ECS 里的 System,提供了组件化的行为,但本身又不直接绑死在一种实体类上。具体案例可以参考上文中的 MovementSystem 实现。

策略对象(Domain Policy)

Policy 或者 Strategy 设计模式是一个通用的设计模式,但是在 DDD 架构中会经常出现,其核心就是封装领域规则。

一个 Policy 是一个无状态的单例对象,通常需要至少 2 个方法:canApply 和一个业务方法。其中, canApply 方法用来判断一个 Policy 是否适用于当前的上下文,如果适用则调用方会去触发业务方法。通常,为了降低一个 Policy 的可测试性和复杂度,Policy 不应该直接操作对象,而是通过返回计算后的值,在Domain Service 里对对象进行操作。

在上文案例里,DamagePolicy 只负责计算应该受到的伤害,而不是直接对 Monster 造成伤害。这样除了可测试外,还为未来的多 Policy 叠加计算做了准备。

除了本文里静态注入多个 Policy 以及手动排优先级之外,在日常开发中经常能见到通过 Java 的 SPI 机制或类 SPI 机制注册 Policy,以及通过不同的 Priority 方案对 Policy 进行排序,在这里就不作太多的展开了。

加餐 - 副作用的处理方法 - 领域事件

在上文中,有一种类型的领域规则被我刻意忽略了,那就是“副作用”。一般的副作用发生在核心领域模型状态变更后,同步或者异步对另一个对象的影响或行为。在这个案例里,我们可以增加一个副作用规则:

当 Monster 的生命值降为 0 后,给 Player 奖励经验值

这种问题有很多种解法,比如直接把副作用写在 CombatService 里:

public class CombatService {
    public void performAttack(Player player, Monster monster) {
        // ...
        monster.takeDamage(damage);
        if (!monster.isAlive()) {
            player.receiveExp(10); // 收到经验
        }
    }
}

但是这样写的问题是:很快 CombatService 的代码就会变得很复杂,比如我们再加一个副作用:

  • 当 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(); // 升级
            }
        }
    }
}

如果再加上“升级后奖励XXX”呢?“更新XXX排行”呢?依此类推,后续这种代码将无法维护。所以我们需要介绍一下领域层最后一个概念:领域事件(Domain Event)。

领域事件介绍

领域事件是一个在领域里发生了某些事后,希望领域里其他对象能够感知到的通知机制。在上面的案例里,代码之所以会越来越复杂,其根本的原因是反应代码(比如升级)直接和上面的事件触发条件(比如收到经验)直接耦合,而且这种耦合性是隐性的。领域事件的好处就是将这种隐性的副作用“显性化”,通过一个显性的事件,将事件触发和事件处理解耦,最终起到代码更清晰、扩展性更好的目的。

所以,领域事件是在 DDD 里,比较推荐使用的跨实体“副作用”传播机制。

领域事件实现

和消息队列中间件不同的是,领域事件通常是立即执行的、在同一个进程内、可能是同步或异步。我们可以通过一个 EventBus 来实现进程内的通知机制,简单实现如下:

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 事件,也不是一个好的解决方案。

也许未来会有一个框架能让我们既不依赖全局 Singleton,也不需要显性去处理事件,但目前的方案基本都有或多或少的缺陷,大家在使用中可以注意。

总结

在真实的业务逻辑里,我们的领域模型或多或少的都有一定的“特殊性”,如果 100% 的要符合 DDD 规范可能会比较累,所以最主要的是梳理一个对象行为的影响面,然后作出设计决策,即:

  • 是仅影响单一对象还是多个对象,
  • 规则未来的拓展性、灵活性,
  • 性能要求,
  • 副作用的处理,等等

当然,很多时候一个好的设计是多种因素的取舍,需要大家有一定的积累,真正理解每个架构背后的逻辑和优缺点。一个好的架构师不是有一个正确答案,而是能从多个方案中选出一个最平衡的方案。