DDD系列第四讲:领域层设计规范
殷浩2020-12-31

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




初探龙与魔法的世界架构


▐  背景和规则


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


基础配置如下:

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


攻击规则如下:

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


▐  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 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, 龙免疫力规则
    }
}


然后跑几个单测:

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架构的价值,我们需要理解一个游戏代码的核心问题:


  • 性能:游戏必须要实现一个高的渲染率(60FPS),也就是说整个游戏世界需要在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里这个问题就变的很简单,放在CombatSystem里就可以了。


数据驱动


即一个对象的行为不是写死的而是通过其参数决定,通过参数的动态修改,就可以快速改变一个对象的具体行为。在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);
    }
}