护肤网站模版,wordpress 短信 插件,自己做的网站提示危险,本地信息发布平台简介#xff1a; 在一个DDD架构设计中#xff0c;领域层的设计合理性会直接影响整个架构的代码结构以及应用层、基础设施层的设计。但是领域层设计又是有挑战的任务#xff0c;特别是在一个业务逻辑相对复杂应用中#xff0c;每一个业务规则是应该放在Entity、ValueObject …简介 在一个DDD架构设计中领域层的设计合理性会直接影响整个架构的代码结构以及应用层、基础设施层的设计。但是领域层设计又是有挑战的任务特别是在一个业务逻辑相对复杂应用中每一个业务规则是应该放在Entity、ValueObject 还是 DomainService是值得用心思考的既要避免未来的扩展性差又要确保不会过度设计导致复杂性。今天我用一个相对轻松易懂的领域做一个案例演示但在实际业务应用中无论是交易、营销还是互动都可以用类似的逻辑来实现。 作者 | 殷浩 来源 | 阿里技术公众号
在一个DDD架构设计中领域层的设计合理性会直接影响整个架构的代码结构以及应用层、基础设施层的设计。但是领域层设计又是有挑战的任务特别是在一个业务逻辑相对复杂应用中每一个业务规则是应该放在Entity、ValueObject 还是 DomainService是值得用心思考的既要避免未来的扩展性差又要确保不会过度设计导致复杂性。今天我用一个相对轻松易懂的领域做一个案例演示但在实际业务应用中无论是交易、营销还是互动都可以用类似的逻辑来实现。
一 初探龙与魔法的世界架构
1 背景和规则
平日里看了好多严肃的业务代码今天找一个轻松的话题如何用代码实现一个龙与魔法的游戏世界的极简规则
基础配置如下
玩家Player可以是战士Fighter、法师Mage、龙骑Dragoon怪物Monster可以是兽人Orc、精灵Elf、龙Dragon怪物有血量武器Weapon可以是剑Sword、法杖Staff武器有攻击力
玩家可以装备一个武器武器攻击可以是物理类型0火1冰2等武器类型决定伤害类型。攻击规则如下
兽人对物理攻击伤害减半精灵对魔法攻击伤害减半龙对物理和魔法攻击免疫除非玩家是龙骑则伤害加倍
2 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 {Overridepublic 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 {Overridepublic void receiveDamageBy(Weapon weapon, Player player) {if (player instanceof Dragoon) {this.setHealth(this.getHealth() - weapon.getDamage() * 2); // 龙骑伤害规则}// else no damage, 龙免疫力规则}
}
然后跑几个单测
public class BattleTest {TestDisplayName(Dragon is immune to attacks)public void testDragonImmunity() {// GivenFighter fighter new Fighter(Hero);Sword sword new Sword(Excalibur, 10);fighter.setWeapon(sword);Dragon dragon new Dragon(Dragon, 100L);// Whenfighter.attack(dragon);// ThenassertThat(dragon.getHealth()).isEqualTo(100);}TestDisplayName(Dragoon attack dragon doubles damage)public void testDragoonSpecial() {// GivenDragoon dragoon new Dragoon(Dragoon);Sword sword new Sword(Excalibur, 10);dragoon.setWeapon(sword);Dragon dragon new Dragon(Dragon, 100L);// Whendragoon.attack(dragon);// ThenassertThat(dragon.getHealth()).isEqualTo(100 - 10 * 2);}TestDisplayName(Orc should receive half damage from physical weapons)public void testFighterOrc() {// GivenFighter fighter new Fighter(Hero);Sword sword new Sword(Excalibur, 10);fighter.setWeapon(sword);Orc orc new Orc(Orc, 100L);// Whenfighter.attack(orc);// ThenassertThat(orc.getHealth()).isEqualTo(100 - 10 / 2);}TestDisplayName(Orc receive full damage from magic attacks)public void testMageOrc() {// GivenMage mage new Mage(Mage);Staff staff new Staff(Fire Staff, 10);mage.setWeapon(staff);Orc orc new Orc(Orc, 100L);// Whenmage.attack(orc);// ThenassertThat(orc.getHealth()).isEqualTo(100 - 10);}
}
以上代码和单测都比较简单不做多余的解释了。
3 分析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 PrincipleOCP
开闭原则OCP规定“对象应该对于扩展开放对于修改封闭“继承虽然可以通过子类扩展新的行为但因为子类可能直接依赖父类的实现导致一个变更可能会影响所有对象。在这个例子里如果增加任意一种类型的玩家、怪物或武器或增加一种规则都有可能需要修改从父类到子类的所有方法。
比如如果要增加一个武器类型狙击枪能够无视所有防御一击必杀需要修改的代码包括
WeaponPlayer和所有的子类是否能装备某个武器的判断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和JumpMonster可以Move和Run怎么处理继承关系要知道Java以及绝大部分语言是不支持多父类继承的所以只能通过重复代码来实现。
问题总结
在这个案例里虽然从直觉来看OOP的逻辑很简单但如果你的业务比较复杂未来会有大量的业务规则变更时简单的OOP代码会在后期变成复杂的一团浆糊逻辑分散在各地缺少全局视角各种规则的叠加会触发bug。有没有感觉似曾相识对的电商体系里的优惠、交易等链路经常会碰到类似的坑。而这类问题的核心本质在于
业务规则的归属到底是对象的“行为”还是独立的”规则对象“业务规则之间的关系如何处理通用“行为”应该如何复用和维护
在讲DDD的解法前我们先去看看一套游戏里最近比较火的架构设计Entity-Component-SystemECS是如何实现的。
二 Entity-Component-SystemECS架构简介
1 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里包含多个ComponentComponent是真正的数据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文档等。
2 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万件不同类型、不同功能的物品如果这些不同功能的物品都去单独写代码可能永远都写不完但是通过数据驱动组件化架构所有物品的配置最终就是一张表修改也极其简单。这个也是组合胜于继承原则的一次体现。
3 ECS的缺陷
虽然ECS在游戏界已经开始崭露头角我发现ECS架构目前还没有在哪个大型商业应用中被使用过。原因可能很多包括ECS比较新大家还不了解、缺少商业成熟可用的框架、程序员们还不够能适应从写逻辑脚本到写组件的思维转变等但我认为其最大的一个问题是ECS为了提升性能强调了数据/状态State和行为Behaivor分离并且为了降低GC成本直接操作数据走到了一个极端。而在商业应用中数据的正确性、一致性和健壮性应该是最高的优先级而性能只是锦上添花的东西所以ECS很难在商业场景里带来特别大的好处。但这不代表我们不能借鉴一些ECS的突破性思维包括组件化、跨对象行为的抽离、以及数据驱动模式而这些在DDD里也能很好的用起来。
三 基于DDD架构的一种解法
1 领域对象
回到我们原来的问题域上面我们从领域层拆分一下各种对象
实体类
在DDD里实体类包含ID和内部状态在这个案例里实体类包含Player、Monster和Weapon。Weapon被设计成实体类是因为两把同名的Weapon应该可以同时存在所以必须要有ID来区分同时未来也可以预期Weapon会包含一些状态比如升级、临时的buff、耐久等。
public class Player implements Movable {private PlayerId id;private String name;private PlayerClass playerClass; // enumprivate WeaponId weaponId; // Note 1private Transform position Transform.ORIGIN;private Vector velocity Vector.ZERO;
}public class Monster implements Movable {private MonsterId id;private MonsterClass monsterClass; // enumprivate Health health;private Transform position Transform.ORIGIN;private Vector velocity Vector.ZERO;
}public class Weapon {private WeaponId id;private String name;private WeaponType weaponType; // enumprivate 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;}Overridepublic 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代码去处理避免了重复劳动。
2 装备行为
因为我们已经不会用Player的子类来决定什么样的Weapon可以装备所以这段逻辑应该被拆分到一个单独的类里。这种类在DDD里被叫做领域服务Domain Service。
public interface EquipmentService {boolean canEquip(Player player, Weapon weapon);
}
在DDD里一个Entity不应该直接参考另一个Entity或服务也就是说以下的代码是错误的
public class Player {AutowiredEquipmentService 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; Overridepublic 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 {Overridepublic boolean canApply(Player player, Weapon weapon) {return player.getPlayerClass() PlayerClass.Fighter;}/*** Fighter能装备Sword和Dagger*/Overridepublic boolean canEquip(Player player, Weapon weapon) {return weapon.getWeaponType() WeaponType.Sword|| weapon.getWeaponType() WeaponType.Dagger;}
}// 其他策略省略见源码
这样设计的最大好处是未来的规则增加只需要添加新的Policy类而不需要去改变原有的类。
3 攻击行为
在上文中曾经有提起过到底应该是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;Overridepublic 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;}Overridepublic boolean canApply(Player player, Weapon weapon, Monster monster) {return player.getPlayerClass() PlayerClass.Dragoon monster.getMonsterClass() MonsterClass.Dragon;}
}
特别需要注意的是这里的CombatService领域服务和3.2的EquipmentService领域服务虽然都是领域服务但实质上有很大的差异。上文的EquipmentService更多的是提供只读策略且只会影响单个对象所以可以在Player.equip方法上通过参数注入。但是CombatService有可能会影响多个对象所以不能直接通过参数注入的方式调用。
4 单元测试
Test
DisplayName(Dragoon attack dragon doubles damage)
public void testDragoonSpecial() {// GivenPlayer 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);// WhencombatService.performAttack(dragoon, dragon);// ThenassertThat(dragon.getHealth()).isEqualTo(Health.ZERO);assertThat(dragon.isAlive()).isFalse();
}Test
DisplayName(Orc should receive half damage from physical weapons)
public void testFighterOrc() {// GivenPlayer 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);// WhencombatService.performAttack(fighter, orc);// ThenassertThat(orc.getHealth()).isEqualTo(Health.of(100 - 10 / 2));
}
具体的代码比较简单解释省略。
5 移动系统
最后还有一种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() {// GivenPlayer 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);// WhenmovementSystem.update();// ThenassertThat(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领域层的设计成本。
1 实体类Entity
大多数DDD架构的核心都是实体类实体类包含了一个领域里的状态、以及对状态的直接操作。Entity最重要的设计原则是保证实体的不变性Invariants也就是说要确保无论外部怎么操作一个实体内部的属性都不能出现相互冲突状态不一致的情况。所以几个设计原则如下
创建即一致
在贫血模型里通常见到的代码是一个模型通过手动new出来之后由调用方一个参数一个参数的赋值这就很容易产生遗漏导致实体状态不一致。所以DDD里实体创建的方法有两种
1constructor参数要包含所有必要属性或者在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 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); // 确保对象的有效性
}
2使用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框架都有比较严重的冲突所以是一个在开发过程中需要特别注意的。这个原则的必要原因包括对外部对象的依赖性会直接导致实体无法被单测以及一个实体无法保证外部实体变更后不会影响本实体的一致性和正确性。
所以正确的对外部依赖的方法有两种
只保存外部实体的ID这里我再次强烈建议使用强类型的ID对象而不是Long型ID。强类型的ID对象不单单能自我包含验证代码保证ID值的正确性同时还能确保各种入参不会因为参数顺序变化而出bug。具体可以参考我的Domain Primitive文章。针对于“无副作用”的外部依赖通过方法入参的方式传入。比如上文中的equip(WeaponEquipmentService方法。
如果方法对外部依赖有副作用不能通过方法入参的方式只能通过Domain Service解决见下文。
任何实体的行为只能直接影响到本实体和其子实体
这个原则更多是一个确保代码可读性、可理解的原则即任何实体的行为不能有“直接”的”副作用“即直接修改其他的实体类。这么做的好处是代码读下来不会产生意外。
另一个遵守的原因是可以降低未知的变更的风险。在一个系统里一个实体对象的所有变更操作应该都是预期内的如果一个实体能随意被外部直接修改的话会增加代码bug的风险。
2 领域服务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);
}
这个原则也映射了“任何实体的行为只能直接影响到本实体和其子实体”的原则即Player.attack会直接影响到Monster但这个调用Monster又没有感知。
通用组件型
这种类型的领域服务更像ECS里的System提供了组件化的行为但本身又不直接绑死在一种实体类上。具体案例可以参考上文中的MovementSystem实现。
3 策略对象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。
1 领域事件介绍
领域事件是一个在领域里发生了某些事后希望领域里其他对象能够感知到的通知机制。在上面的案例里代码之所以会越来越复杂其根本的原因是反应代码比如升级直接和上面的事件触发条件比如收到经验直接耦合而且这种耦合性是隐性的。领域事件的好处就是将这种隐性的副作用“显性化”通过一个显性的事件将事件触发和事件处理解耦最终起到代码更清晰、扩展性更好的目的。
所以领域事件是在DDD里比较推荐使用的跨实体“副作用”传播机制。
2 领域事件实现
和消息队列中间件不同的是领域事件通常是立即执行的、在同一个进程内、可能是同步或异步。我们可以通过一个EventBus来实现进程内的通知机制简单实现如下
// 实现者瑜进 2019/11/28
public class EventBus {// 注册器Getterprivate 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);
}3 目前领域事件的缺陷和展望
从上面代码可以看出来领域事件的很好的实施依赖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规范可能会比较累所以最主要的是梳理一个对象行为的影响面然后作出设计决策即
是仅影响单一对象还是多个对象规则未来的拓展性、灵活性性能要求副作用的处理等等
当然很多时候一个好的设计是多种因素的取舍需要大家有一定的积累真正理解每个架构背后的逻辑和优缺点。一个好的架构师不是有一个正确答案而是能从多个方案中选出一个最平衡的方案。
原文链接 本文为阿里云原创内容未经允许不得转载。