简介:在一个 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 {
@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);
}
}
以上代码和单测都比较简单,不做多余的解释了。
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 Principle(OCP)
开闭准则(OCP)规定“对象应该对于扩大凋谢,对于批改关闭“,继承尽管能够通过子类扩大新的行为,但因为子类可能间接依赖父类的实现,导致一个变更可能会影响所有对象。在这个例子里,如果减少任意一种类型的玩家、怪物或武器,或减少一种规定,都有可能须要批改从父类到子类的所有办法。
比方,如果要减少一个武器类型:狙击枪,可能忽视所有进攻一击必杀,须要批改的代码包含:
Weapon
Player 和所有的子类(是否能配备某个武器的判断)
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)架构简介
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 里蕴含多个 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 文档等。
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; // 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 代码去解决,防止了重复劳动。
2 配备行为
因为咱们曾经不会用 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 类,而不须要去扭转原有的类。
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;
@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 畛域服务和 3.2 的 EquipmentService 畛域服务,尽管都是畛域服务,但本质上有很大的差别。上文的 EquipmentService 更多的是提供只读策略,且只会影响单个对象,所以能够在 Player.equip 办法上通过参数注入。然而 CombatService 有可能会影响多个对象,所以不能间接通过参数注入的形式调用。
4 单元测试
@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));
}
具体的代码比较简单,解释省略。
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() {
// 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 畛域层的设计老本。
1 实体类(Entity)
大多数 DDD 架构的外围都是实体类,实体类蕴含了一个畛域里的状态、以及对状态的间接操作。Entity 最重要的设计准则是保障实体的不变性(Invariants),也就是说要确保无论内部怎么操作,一个实体外部的属性都不能呈现互相抵触,状态不统一的状况。所以几个设计准则如下:
创立即统一
在贫血模型里,通常见到的代码是一个模型通过手动 new 进去之后,由调用方一个参数一个参数的赋值,这就很容易产生脱漏,导致实体状态不统一。所以 DDD 里实体创立的办法有两种:
1)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 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(Weapon,EquipmentService)办法。
如果办法对外部依赖有副作用,不能通过办法入参的形式,只能通过 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 {
// 注册器
@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);
}
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 标准可能会比拟累,所以最次要的是梳理一个对象行为的影响面,而后作出设计决策,即:
- 是仅影响繁多对象还是多个对象
- 规定将来的拓展性、灵活性
- 性能要求
- 副作用的解决,等等
当然,很多时候一个好的设计是多种因素的取舍,须要大家有肯定的积攒,真正了解每个架构背地的逻辑和优缺点。一个好的架构师不是有一个正确答案,而是能从多个计划中选出一个最均衡的计划。
原文链接
本文为阿里云原创内容,未经容许不得转载。