简介: 在一个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标准可能会比拟累,所以最次要的是梳理一个对象行为的影响面,而后作出设计决策,即:
- 是仅影响繁多对象还是多个对象
- 规定将来的拓展性、灵活性
- 性能要求
- 副作用的解决,等等
当然,很多时候一个好的设计是多种因素的取舍,须要大家有肯定的积攒,真正了解每个架构背地的逻辑和优缺点。一个好的架构师不是有一个正确答案,而是能从多个计划中选出一个最均衡的计划。
原文链接
本文为阿里云原创内容,未经容许不得转载。
发表回复