DDD 领域层,该如何设计?
领域层设计规范
在一个DDD架构设计中,领域层的设计 非常重要,领域层 不仅仅 会直接影响整个架构的代码结构,而且会影响 应用层、基础设施层的设计。
领域层设计又是有挑战的任务,特别是在一个业务逻辑相对复杂应用中,每一个业务规则是应该放在Entity、ValueObject 还是 DomainService是值得用心思考的,既要避免未来的扩展性差,又要避免过度设计导致复杂性。
今天我用一个相对轻松易懂的领域做一个案例演示,但在实际业务应用中,无论是交易、营销还是互动,都可以用类似的逻辑来实现。
1. 前置知识:领域模型 Model定义、分类、生命周期
Model(模型):承载着业务的属性和具体的行为,是业务表达的方式,是DDD的内核。
-
Model(模型)是一个类中有属性、属性有Get/Set方法
-
并且业务的行为(Action)操作也是在模型类中(充血模型)
-
模型分为Entity、Value Object、Service这三种类型
1.1 Model分类
-
Entity (实体)
-
有特定的标识,标识着这个Model在系统中全局唯一
-
内部值可以是变化的,可能存在生命周期 (比如订单对象,状态值是连续变化的)
-
有状态的Value Object
-
-
Value Object (值对象)
-
内部值是不变的,不存在生命周期 (比如地址对象不存在生命周期)
-
无状态对象
-
-
Service (服务)
-
无状态对象
-
当一个属性或行为放在Entity、Value Object中模棱两可或不合适的时候就需要以Service的形式来呈现
-
三种模型复杂度:Service > Entity > ValueObject,优先选择简单模型
1.2 Model的生命周期
-
Factory (工厂):用来创建Model,以及帮助Repository (数据源)注入到Model中
-
Aggreagte (聚合根):封装Model,一个Mode中l可能包含其他Model(类似一个对象中包含其他对象的引用,实际概念更复杂)
-
聚合是用来封装真正的不变性,而不是简单的将对象组合在一起;
-
聚合应尽量设计的小;
-
聚合之间的关联通过ID,而不是对象引用;
-
聚合内强一致性,聚合之间最终一致性。
-
-
Repository (数据源):
-
数据源的访问网关层
-
Model通过Repository来对接不同的数据源
-
了解了领域模型 Model定义、分类、生命周期之后,咱们来进行 DDD的 领域层设计规范介绍。
2. 业务场景:龙与魔法的世界
这里 找一个轻松的案例 ,一个龙与魔法的游戏世界的(极简)规则,看看 如何用代码实现
2.1 背景和规则
基础配置如下:
-
玩家(Player)
战士(Fighter)、法师(Mage)、龙骑(Dragoon)
-
怪物(Monster)
兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量
-
武器(Weapon)
是剑(Sword)、法杖(Staff),武器有攻击力
玩家可以装备一个武器,武器有攻击的类型, 攻击类型可以是物理(0),魔法(1),冰(2)等,攻击类型决定伤害类型。‘
攻击规则如下:
-
兽人对物理攻击伤害减半
-
精灵对魔法攻击伤害减半
-
龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍
2.2 OOP实现
通过 Object-Oriented Programming 面向对象的方式进行设计,一通过类的继承关系(此处省略部分非核心代码) 实现上面的case。
玩家的接口和类如下:
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(); // 基础规则
}
}
//兽人(Orc) 对物理攻击伤害减半
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);
}
}
}
//龙(Dragon)对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍
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() {
// 玩家
Fighter fighter = new Fighter("Hero");
//武器, 剑(Sword)
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);
}
}
以上代码和单测都比较简单,不做多余的解释了。
2.3 OOP代码的设计缺陷
缺陷一:编程语言的强类型无法承载业务规则
以上的OOP代码可以跑得通,但是,现在要进行扩展,我们加一个限制条件:
-
战士只能装备剑
-
法师只能装备法杖
这个规则在Java语言里无法通过强类型来实现,虽然Java有Variable Hiding(或者C#的new class variable),但实际上只是在子类上加了一个新变量,所以会导致以下的问题:
public abstract class Player {
Weapon weapon
}
// 战士(Fighter) 只能装备剑
@Data
public class Fighter extends Player {
private Sword weapon;
}
@Test
public void testEquip() {
Fighter fighter = new Fighter("Hero");
// 剑(Sword)
Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);
// 法杖(Staff)
Staff staff = new Staff("Staff", 10);
fighter.setWeapon(staff);
assertThat(fighter.getWeapon()).isInstanceOf(Staff.class); // 错误了
}
在最后,虽然代码感觉是setWeapon(Staff),但实际上只修改了父类的变量,并没有修改子类的变量,所以实际不生效,也不抛异常,但结果是错的。
当然,可以在父类限制setter为protected:
@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层面上应该开放可用
}
在父类限制setter为protected, 有一个很大的坏处:限制了父类的API,极大的降低了灵活性,同时也违背了Liskov substitution principle,即一个父类必须要cast成子类才能使用
现在还要进行扩展,我们又加一个限制条件:
-
战士和法师都能装备匕首(dagger)
这下问题更大,之前写的强类型代码都废了,需要重构。
缺陷二:对象继承导致代码强依赖父类逻辑,违反开闭原则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去攻击Monster,还是Monster被Player攻击?
Player.attack(monster) 还是 Monster.receiveDamage(Weapon, 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)是如何实现的。
3. Entity-Component-System(ECS)架构简介
3.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文档等。
3.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.3 ECS的缺陷
虽然ECS在游戏界已经开始崭露头角,我发现ECS架构目前还没有在哪个大型商业应用中被使用过。
原因可能很多,包括ECS比较新大家还不了解、缺少商业成熟可用的框架、程序员们还不够能适应从写逻辑脚本到写组件的思维转变等,但我认为其最大的一个问题是ECS为了提升性能,强调了数据/状态(State)和行为(Behaivor)分离,并且为了降低GC成本,直接操作数据,走到了一个极端。
而在商业应用中,数据的正确性、一致性和健壮性应该是最高的优先级,而性能只是锦上添花的东西,所以ECS很难在商业场景里带来特别大的好处。
但这不代表我们不能借鉴一些ECS的突破性思维,包括组件化、跨对象行为的抽离、以及数据驱动模式,而这些在DDD里也能很好的用起来。
4. 基于DDD架构的龙与魔法的世界
4.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代码去处理,避免了重复劳动。
4.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类,而不需要去改变原有的类。
4.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.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));
}
具体的代码比较简单,解释省略。
4.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边界等)。
5. DDD领域层的一些设计规范
上面我主要针对同一个例子对比了OOP、ECS和DDD的3种实现,比较如下:
-
基于继承关系的OOP代码:
OOP的代码最好写,也最容易理解,所有的规则代码都写在对象里,但是当领域规则变得越来越复杂时,其结构会限制它的发展。
新的规则有可能会导致代码的整体重构。
-
基于组件化的ECS代码:
ECS代码有最高的灵活性、可复用性、及性能,但极具弱化了实体类的内聚,所有的业务逻辑都写在了服务里,会导致业务的一致性无法保障,对商业系统会有较大的影响。
-
基于领域对象 + 领域服务的DDD架构:
DDD的规则其实最复杂,同时要考虑到实体类的内聚和保证不变性(Invariants),也要考虑跨对象规则代码的归属,甚至要考虑到具体领域服务的调用方式,理解成本比较高。
所以,尽量通过一些设计规范,来降低DDD领域层的设计成本。
5.1 实体类(Entity)
大多数DDD架构的核心都是实体类,实体类包含了一两块大的内容
-
领域里的状态
-
以及对状态操作
Entity最重要的设计原则是保证实体的一致性,也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。
数据库系统中的数据一致性是指数据库中的数据始终保持有效、准确、完整、可靠和可用的特性。
实体的数据一致性,可以套用这个概念,是指实体的数据始终保持有效、准确、完整、可靠和可用的特性。
所以几个设计原则如下:
-
创建即一致
-
尽量避免public setter
-
原则3:通过聚合根保证父子实体的一致性
-
原则4:不可以强依赖其他聚合根实体或领域服务
-
原则5:任何实体的行为只能直接影响到本实体(和其子实体)
原则1:创建即一致
原则2:尽量避免public setter
原则3:通过聚合根保证父子实体的一致性
原则4:不可以强依赖其他聚合根实体或领域服务
原则5:任何实体的行为只能直接影响到本实体(和其子实体)
5.2 领域服务(Domain Service)
类型1:单对象策略型
类型3:通用组件型
这种类型的领域服务更像ECS里的System,提供了组件化的行为,但本身又不直接绑死在一种实体类上。
具体案例可以参考上文中的MovementSystem实现。
5.3 策略对象(Domain Policy)
6. 使用事件处理领域模型副作用
在上文中,有一种类型的领域规则被刻意忽略了,那就是”领域模型副作用“。
一般的领域模型副作用发生在核心领域模型状态变更后,同步或者异步对另一个对象的影响或行为。
在这个案例里,我们可以增加一个副作用规则:
当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)。
6.1 领域事件介绍
领域事件是一个通知机制。领域事件是一个在领域里发生了某些事后,希望领域里其他对象能够感知到的通知机制。
在上面的案例里,代码之所以会越来越复杂,其根本的原因是反应代码(比如升级)直接和上面的事件触发条件(比如收到经验)直接耦合,而且这种耦合性是隐性的。
领域事件的好处就是将这种隐性的副作用“显性化”,通过一个显性的事件,将事件触发和事件处理解耦,最终起到代码更清晰、扩展性更好的目的。
所以,领域事件是在DDD里,比较推荐使用的跨实体“副作用”传播机制。
6.2 领域事件实现
6.3 目前领域事件的缺陷和展望
6.4 领域事件总结
领域事件 是 领域模型副作用的处理方法。一般的 副作用发生在核心领域模型状态变更后,同步或者异步对另一个对象的影响或行为。
领域事件介绍
-
领域事件是一个在领域里发生了某些事后,希望领域里其他对象能够感知到的通知机制。
-
领域事件将隐性的副作用“显性化”,通过一个显性的事件,将事件触发和事件处理解耦,最终起到代码更清晰、扩展性更好的目的。
领域事件实现
-
领域事件通常是立即执行的、在同一个进程内、可能是同步或异步。可通过一个EventBus来实现进程内的通知机制。
-
缺陷:领域事件的很好的实施依赖EventBus、Dispatcher、Invoker这些属于框架级别的支持。但因为Entity不能直接依赖外部对象,所以EventBus目前只能是一个全局的Singleton,导致Entity对象无法被完整单测覆盖全。
7. 领域层设计规范总结
在真实的业务逻辑里,我们的领域模型或多或少的都有一定的“特殊性”,如果100%的要符合DDD规范可能会比较累,所以最主要的是梳理一个对象行为的影响面,然后作出设计决策,即:
-
是仅影响单一对象还是多个对象
-
规则未来的拓展性、灵活性
-
性能要求
-
副作用的处理,等等
当然,很多时候一个好的设计是多种因素的取舍,需要大家有一定的积累,真正理解每个架构背后的逻辑和优缺点。一个好的架构师不是有一个正确答案,而是能从多个方案中选出一个最平衡的方案。
7.1 Entity设计规范总结
-
Entity最重要的设计原则是保证实体的一致性,也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。
-
原则1:创建即一致
-
constructor参数要包含所有必要属性,或者在constructor里有合理的默认值。
-
使用Factory模式来降低调用方复杂度
-
-
原则2:尽量避免public setter
-
因为set单一参数会导致状态不一致的情况;
-
@ Setter(AccessLevel.PRIVATE) // 确保不生成public setter
-
-
原则3:通过聚合根保证主子实体的一致性,主实体会包含子实体,主实体就起到聚合根的作用,即:
-
子实体不能单独存在,只能通过聚合根的方法获取到。任何外部的对象都不能直接保留子实体的引用
-
子实体没有独立的Repository,不可以单独保存和取出,必须要通过聚合根的Repository实例化
-
子实体可以单独修改自身状态,但是多个子实体之间的状态一致性需要聚合根来保障
-
-
原则4:不可以强依赖其他聚合根实体或领域服务。对外部对象的依赖性会直接导致实体无法被单测;以及一个实体无法保证外部实体变更后不会影响本实体的一致性和正确性。正确的对外部依赖的方法有两种:
-
只保存外部实体的ID:强烈建议使用强类型的ID对象,而不是Long型ID。强类型的ID对象不单单能自我包含验证代码,保证ID值的正确性,同时还能确保各种入参不会因为参数顺序变化而出bug。
-
针对于“无副作用”的外部依赖,通过方法入参的方式传入。
-
-
原则5:任何实体的行为只能直接影响到本实体(和其子实体)
-
原则6:实体的充血模型不包含持久化逻辑
7.2 Domain Service 设计规范总结
-
领域服务一般分为单对象策略型、跨对象事务型、通用组件型三种。
-
单对象策略型:主要面向的是单个实体对象的变更,但涉及到多个领域对象或外部依赖的一些规则。实体应该通过方法入参的方式传入这种领域服务,然后通过Double Dispatch来反转调用领域服务的方法。
-
跨对象事务型:当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。
-
通用组件型:与ECS里的System类似,提供了组件化的行为,但本身又不直接绑死在一种实体类上。
-
让Domain Service与Repository打交道,而不是让领域模型Entity与Repository打交道,因为我们想保持领域模型的独立性,不与任何其他层的代码(Repository)或开发框架(比如Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从DB中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用。
-
Domain Service类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的RPC接口等,都可以放到Domain Service类中。
7.3 Application Service 设计规范总结
-
Application Service 是业务流程的封装,不处理业务逻辑,即不要有if/else分支逻辑、不要有任何计算、一些数据的转化可以交给其他对象来做。
-
常用的ApplicationService“套路”:
-
准备数据:包括从外部服务或持久化源取出相对应的Entity、VO以及外部服务返回的DTO。
-
执行操作:包括新对象的创建、赋值,以及调用领域对象的方法对其进行操作。需要注意的是这个时候通常都是纯内存操作,非持久化。
-
持久化:将操作结果持久化,或操作外部系统产生相应的影响,包括发消息等异步操作。
-
7.4 其它规范总结
-
Interface层:
-
职责:主要负责承接网络协议的转化、Session管理等
-
接口数量:避免所谓的统一API,不必人为限制接口类的数量,每个/每类业务对应一套接口即可,接口参数应该符合业务需求,避免大而全的入参
-
接口出参:统一返回Result
-
异常处理:应该捕捉所有异常,避免异常信息的泄漏。可以通过AOP统一处理,避免代码里有大量重复代码。
-
-
Application层:
-
入参:具像化Command、Query、Event对象作为ApplicationService的入参,唯一可以的例外是单ID查询的场景。
-
CQE的语意化:CQE对象有语意,不同用例之间语意不同,即使参数一样也要避免复用。
-
入参校验:基础校验通过Bean Validation api解决。Spring Validation自带Validation的AOP,也可以自己写AOP。
-
出参:统一返回DTO,而不是Entity或DO。
-
DTO转化:用DTO Assembler负责Entity/VO到DTO的转化。
-
异常处理:不统一捕捉异常,可以随意抛异常。
-
-
部分Infra层:
-
用ACL防腐层将外部依赖转化为内部代码,隔离外部的影响
-
DDD中Interface层、Application层的设计规范
在日常工作中我观察到,面对老系统重构和迁移场景,有大量代码属于流水账代码,通常能看到开发在对外的API接口里直接写业务逻辑代码,或者在一个服务里大量的堆接口,导致业务逻辑实际无法收敛,接口复用性比较差。
本文主要想系统性的解释一下如何通过DDD的重构,将原有的流水账代码,改造为逻辑清晰、职责分明的模块。
领域驱动设计没有特定的架构风格,它的核心是域模型驱动业务的思想,常见的领域驱动设计架构有传统的四层架构模式、事件驱动架构、CQRS架构、六边形架构等。
1. 传统的四层架构模式
-
User Interface为用户界面层(对外访问层API),负责向用户显示信息和解释用户命令。
-
Application为应用层,定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。
-
Domain为领域层(或模型层),负责表达业务概念,业务状态信息以及业务规则。
-
Infrastructure层为基础实施层,向其他层提供通用的技术能力。
DDD四层架构图
-
基础设施层:基本上都是需要固化的代码,一次写成,需要变动的次数很少,一旦变动,就需要大量谨慎的回归测试。将所有的存储调用、中间件调用都沉淀在这一层中。
2. 下单链路案例简介
这里举一个简单的常见案例:下单链路。
假设我们在做一个checkout接口,需要做各种校验、查询商品信息、调用库存服务扣库存、然后生成订单:
一个比较典型的代码如下:
@RestController
@RequestMapping("/")
public class CheckoutController {
@Resource
private ItemService itemService;
@Resource
private InventoryService inventoryService;
@Resource
private OrderRepository orderRepository;
@PostMapping("checkout")
public Result<OrderDO> checkout(Long itemId, Integer quantity) {
// 1) Session管理
Long userId = SessionUtils.getLoggedInUserId();
if (userId <= 0) {
return Result.fail("Not Logged In");
}
// 2)参数校验
if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
return Result.fail("Invalid Args");
}
// 3)外部数据补全
ItemDO item = itemService.getItem(itemId);
if (item == null) {
return Result.fail("Item Not Found");
}
// 4)调用外部服务
boolean withholdSuccess = inventoryService.withhold(itemId, quantity);
if (!withholdSuccess) {
return Result.fail("Inventory not enough");
}
// 5)领域计算
Long cost = item.getPriceInCents() * quantity;
// 6)领域对象操作
OrderDO order = new OrderDO();
order.setItemId(itemId);
order.setBuyerId(userId);
order.setSellerId(item.getSellerId());
order.setCount(quantity);
order.setTotalCost(cost);
// 7)数据持久化
orderRepository.createOrder(order);
// 8)返回
return Result.success(order);
}
}
为什么这种典型的流水账代码在实际应用中会有问题呢?
其本质问题是违背了SRP(Single Responsbility Principle)单一职责原则。
这段代码里混杂了业务计算、校验逻辑、基础设施、和通信协议等,在未来无论哪一部分的逻辑变更都会直接影响到这段代码,当后人不断地在上面叠加新的逻辑时,会使代码复杂度增加、逻辑分支越来越多,最终造成bug或者没人敢重构的历史包袱。
所以我们才需要用DDD的分层思想去重构一下以上的代码,通过不同的代码分层和规范,拆分出逻辑清晰,职责明确的分层和模块,也便于一些通用能力的沉淀。
主要的几个步骤分为:
-
分离出独立的Interface接口层,负责处理网络协议相关的逻辑。
-
从真实业务场景中,找出具体用例(Use Cases),然后将具体用例通过专用的Command指令、Query查询、和Event事件对象来承接。
-
分离出独立的Application应用层,负责业务流程的编排,响应Command、Query和Event。每个应用层的方法应该代表整个业务流程中的一个节点。
-
处理一些跨层的横切关注点,如鉴权、异常处理、校验、缓存、日志等。
下面会针对每个点做详细的解释。
3. Interface接口层
随着REST和MVC架构的普及,经常能看到开发同学直接在Controller中写业务逻辑,如上面的典型案例,但实际上MVC Controller不是唯一的重灾区。
以下的几种常见的代码写法通常都可能包含了同样的问题:
-
HTTP 框架:如Spring MVC框架,Spring Cloud等。
-
RPC 框架:如Dubbo、HSF、gRPC等。
-
消息队列MQ的“消费者”:比如JMS的 onMessage,RocketMQ的MessageListener等。
-
Socket通信:Socket通信的receive、WebSocket的onMessage等。
-
文件系统:WatcherService等。
-
分布式任务调度:SchedulerX等。
这些的方法都有一个共同的点就是都有自己的网络协议,而如果我们的业务代码和网络协议混杂在一起,则会直接导致代码跟网络协议绑定,无法被复用。
所以,在DDD的分层架构中,我们单独会抽取出来Interface接口层,作为所有对外的门户,将网络协议和业务逻辑解耦。
3.1 接口层的组成
接口层主要由以下几个功能组成:
-
网络协议的转化:通常这个已经由各种框架给封装掉了,我们需要构建的类要么是被注解的bean,要么是继承了某个接口的bean。
-
统一鉴权:比如在一些需要AppKey+Secret的场景,需要针对某个租户做鉴权的,包括一些加密串的校验
-
Session管理:一般在面向用户的接口或者有登陆态的,通过Session或者RPC上下文可以拿到当前调用的用户,以便传递给下游服务。
-
限流配置:对接口做限流避免大流量打到下游服务
-
前置缓存:针对变更不是很频繁的只读场景,可以前置结果缓存到接口层
-
异常处理:通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式
-
日志:在接口层打调用日志,用来做统计和debug等。一般微服务框架可能都直接包含了这些功能。
当然,如果有一个独立的网关设施/应用,则可以抽离出鉴权、Session、限流、日志等逻辑,但是目前来看API网关也只能解决一部分的功能,即使在有API网关的场景下,应用里独立的接口层还是有必要的。
在Interface层,鉴权、Session、限流、缓存、日志等都比较直接,只有一个异常处理的点需要重点说下。
3.2 返回值和异常处理规范,Result vs Exception
注:这部分主要还是面向REST和RPC接口,其他的协议需要根据协议的规范产生返回值。
在我见过的一些代码里,接口的返回值比较多样化,有些直接返回DTO甚至DO,另一些返回Result。
接口层的核心价值是对外,所以如果只是返回DTO或DO会不可避免的面临异常和错误栈泄漏到使用方的情况,包括错误栈被序列化反序列化的消耗。
所以,这里提出一个规范:
-
Interface层的HTTP和RPC接口,返回值为Result,捕捉所有异常
-
Application层的所有接口返回值为DTO,不负责处理异常
Application层的具体规范等下再讲,在这里先展示Interface层的逻辑。
举个例子:
@PostMapping("checkout")
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
try {
CheckoutCommand cmd = new CheckoutCommand();
OrderDTO orderDTO = checkoutService.checkout(cmd);
return Result.success(orderDTO);
} catch (ConstraintViolationException cve) {
// 捕捉一些特殊异常,比如Validation异常
return Result.fail(cve.getMessage());
} catch (Exception e) {
// 兜底异常捕获
return Result.fail(e.getMessage());
}
}
当然,每个接口都要写异常处理逻辑会比较烦,所以可以用AOP做个注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResultHandler {
}
@Aspect
@Component
public class ResultAspect {
@Around("@annotation(ResultHandler)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
Object proceed = null;
try {
proceed = joinPoint.proceed();
} catch (ConstraintViolationException cve) {
return Result.fail(cve.getMessage());
} catch (Exception e) {
return Result.fail(e.getMessage());
}
return proceed;
}
}
然后最终代码则简化为:
@PostMapping("checkout")
@ResultHandler
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
CheckoutCommand cmd = new CheckoutCommand();
OrderDTO orderDTO = checkoutService.checkout(cmd);
return Result.success(orderDTO);
}
3.3 接口层的接口的数量和业务间的隔离
在传统REST和RPC的接口规范中,同一个领域的方法放在一个领域的服务或Controller中。
具体来说:通常一个领域的接口,无论是REST的Resource资源的GET/POST/DELETE,还是RPC的方法,是追求相对固定的,统一的,而且会追求同一个领域的方法放在一个领域的服务或Controller中。
但是我发现在实际做业务的过程中,特别是当支撑的上游业务比较多时,刻意去追求接口的统一会有一个严重后果,通常会导致方法中的参数膨胀,或者导致方法的膨胀。
举个例子:假设有一个宠物卡和一个亲子卡的业务公用一个开卡服务,但是宠物需要传入宠物类型,亲子的需要传入宝宝年龄。
// 可以是RPC Provider 或者 Controller
public interface CardService {
// 1)统一接口,参数膨胀
Result openCard(int petType, int babyAge);
// 2)统一泛化接口,参数语意丢失
Result openCardV2(Map<String, Object> params);
// 3)不泛化,同一个类里的接口膨胀
Result openPetCard(int petType);
Result openBabyCard(int babyAge);
}
可以看出来,无论怎么操作,都有可能导致CardService这个服务未来越来越难以维护,方法越来越多,一个业务的变更有可能会导致整个服务/Controller的变更,最终变得无法维护。
我曾经参与过的一个服务,提供了几十个方法,上万行代码,可想而知,无论是使用方对接口的理解成本还是对代码的维护成本都是极高的。
所以,这里提出另一个规范:
-
一个Interface层的类应该是“小而美”的,
-
应该是面向“一个单一的业务”或“一类同样需求的业务”,需要尽量避免用同一个类承接不同类型业务的需求。
基于上面的这个规范,可以发现宠物卡和亲子卡虽然看起来像是类似的需求,但并非是“同样需求”的,可以预见到在未来的某个时刻,这两个业务的需求和需要提供的接口会越走越远,所以需要将这两个接口类拆分开:
public interface PetCardService {
Result openPetCard(int petType);
}
public interface BabyCardService {
Result openBabyCard(int babyAge);
}
这个的好处是符合了Single Responsibility Principle单一职责原则,也就是说一个接口类仅仅会因为一个(或一类)业务的变化而变化。一个建议是当一个现有的接口类过度膨胀时,可以考虑对接口类做拆分,拆分原则和SRP(Single Responsibility Principle,简称 SRP)一致。
也许会有人问,如果按照这种做法,会不会产生大量的接口类,导致代码逻辑重复?
答案是不会,因为在DDD分层架构里,接口类的核心作用仅仅是协议层,每类业务的协议可以是不同的,而真实的业务逻辑会沉淀到应用层。
也就是说Interface和Application的关系是多对多的:
因为业务需求是快速变化的,所以接口层也要跟着快速变化,通过独立的接口层可以避免业务间相互影响,但我们希望相对稳定的是Application层的逻辑。所以我们接下来看一下Application层的一些规范。
4. Application层
4.1 Application层的组成部分
Application层的几个核心类:
-
ApplicationService应用服务:最核心的类,负责业务流程的编排,但本身不负责任何业务逻辑。
-
DTO Assembler:负责将内部领域模型转化为可对外的DTO。
-
Command、Query、Event对象:作为ApplicationService的入参。
-
返回的DTO:作为ApplicationService的出参。
Application层最核心的对象是ApplicationService,它的核心功能是承接“业务流程“。
但是在讲ApplicationService的规范之前,必须要先重点的讲几个特殊类型的对象,即Command、Query和Event。
4.2 使用Command、Query、Event对象语意化我们的参数
首先,回顾一下基础 的 CQRS 模式
-
CQRS(Command and Query Responsibility Segregation)命令查询职责分离模式,分别对读和写建模。
-
CQRS从定义上要求:
-
一个方法修改了对象的状态,该方法便是一个Command,它不应该返回数据。
-
一个方法返回了数据,该方法便是一个Query,此时它不应该通过直接的或间接的手段修改对象的状态。
-
CQRS架构图
从本质上来看,Command、Query、Event 对象都是Value Object,但是从语义上来看有比较大的差异:
-
Command指令:指调用方明确想让系统操作的指令,其预期是对一个系统有影响,也就是写操作。
通常来讲指令需要有一个明确的返回值(如同步的操作结果,或异步的指令已经被接受)。
-
Query查询:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作。
-
Event事件:指一件已经发生过的既有事实,需要系统根据这个事实作出改变或者响应的,通常事件处理都会有一定的写操作。
事件处理器不会有返回值。这里需要注意一下的是,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。
简单总结下:
为什么要用CQE对象?
通常在很多代码里,能看到接口上有多个参数,比如上文中的案例:
如果需要在接口上增加参数,考虑到向前兼容,则需要增加一个方法:
或者常见的查询方法,由于条件的不同导致多个方法:
List < OrderDO> queryByItemId(Long itemId);
List < OrderDO> queryBySellerId(Long sellerId);
List < OrderDO> queryBySellerIdWithPage(Long sellerId, int currentPage, int pageSize);
可以看出来,传统的接口写法有几个问题:
-
接口膨胀:一个查询条件一个方法。
-
难以扩展:每新增一个参数都有可能需要调用方升级。
-
难以测试:接口一多,职责随之变得繁杂,业务场景各异,测试用例难以维护。
但是另外一个最重要的问题是:这种类型的参数罗列,本身没有任何业务上的”语意“,只是一堆参数而已,无法明确的表达出来意图。
CQE的规范
所以在Application层的接口里,强力建议的一个规范是:
-
ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。
-
唯一可以的例外是根据单一ID查询的情况,可以省略掉一个Query对象的创建。
按照上面的规范,实现案例是:
public interface CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd);
List<OrderDTO> query(OrderQuery query);
OrderDTO getOrder(Long orderId); // 注意单一ID查询可以不用Query
}
@Data
public class CheckoutCommand {
private Long userId;
private Long itemId;
private Integer quantity;
}
@Data
public class OrderQuery {
private Long sellerId;
private Long itemId;
private int currentPage;
private int pageSize;
}
这个规范的好处是:
-
提升了接口的稳定性、降低低级的重复,
-
并且让接口入参更加语意化。
CQE vs DTO
从上面的代码能看出来,ApplicationService的入参是CQE对象,但是出参却是一个DTO,从代码格式上来看都是简单的POJO对象,那么他们之间有什么区别呢?
-
CQE:CQE对象是ApplicationService的输入,是有明确的”意图“的,所以这个对象必须保证其”正确性“。
-
DTO:DTO对象只是数据容器,只是为了和外部交互,所以本身不包含任何逻辑,只是贫血对象。
但可能最重要的一点:
-
因为CQE是”意图“,所以CQE对象在理论上可以有”无限“个,每个代表不同的意图;
-
但是DTO作为模型数据容器,和模型一一对应,所以是有限的。
CQE的校验
CQE作为ApplicationService的输入,必须保证其正确性,那么这个校验是放在哪里呢?
在最早的代码里,曾经有这样的校验逻辑,当时写在了服务里:
if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
return Result.fail("Invalid Args");
}
这种代码在日常非常常见,但其最大的问题就是,大量的非业务代码混杂在业务代码中。
很明显的违背了单一职责原则。
但,因为当时入参仅仅是简单的int,所以这个逻辑只能出现在服务里。
现在当入参改为了CQE之后,我们可以利用java标准JSR303或JSR380的Bean Validation来前置这个校验逻辑。
CQE对象的校验应该前置,避免在ApplicationService里做参数的校验。可以通过JSR303/380和Spring Validation来实现。
前面的例子可以改造为:
@Validated // Spring的注解
public class CheckoutServiceImpl implements CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd) { // 这里@Valid是JSR-303/380的注解
// 如果校验失败会抛异常,在interface层被捕捉
}
}
@Data
public class CheckoutCommand {
@NotNull(message = "用户未登陆")
private Long userId;
@NotNull
@Positive(message = "需要是合法的itemId")
private Long itemId;
@NotNull
@Min(value = 1, message = "最少1件")
@Max(value = 1000, message = "最多不能超过1000件")
private Integer quantity;
}
这种做法的好处是,让ApplicationService更加清爽,同时各种错误信息可以通过Bean Validation的API做各种个性化定制。
避免复用CQE
因为CQE是有“意图”和“语意”的,我们需要尽量避免CQE对象的复用,哪怕所有的参数都一样,只要他们的语意不同,尽量还是要用不同的对象。
规范:针对于不同语意的指令,要避免CQE对象的复用。
反例:一个常见的场景是“Create创建”和“Update更新”,一般来说这两种类型的对象唯一的区别是一个ID,创建没有ID,而更新则有。
所以经常能看见有的同学用同一个对象来作为两个方法的入参,唯一区别是ID是否赋值。
这个是错误的用法,因为这两个操作的语意完全不一样,他们的校验条件可能也完全不一样,所以不应该复用同一个对象。
正确的做法是:产出两个对象:
public interface CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd);
OrderDTO updateOrder(@Valid UpdateOrderCommand cmd);
}
@Data
public class UpdateOrderCommand {
@NotNull(message = "用户未登陆")
private Long userId;
@NotNull(message = "必须要有OrderID")
private Long orderId;
@NotNull
@Positive(message = "需要是合法的itemId")
private Long itemId;
@NotNull
@Min(value = 1, message = "最少1件")
@Max(value = 1000, message = "最多不能超过1000件")
private Integer quantity;
}
4.3 ApplicationService 流程编排
要点1:ApplicationService 负责了业务流程的编排,
具体来说 ,ApplicationService 是将原有业务流水账代码剥离了校验逻辑、领域计算、持久化等逻辑之后剩余的流程,是“胶水层”代码。
参考一个简易的交易流程:
在这个案例里可以看出来,交易这个领域一共有5个用例:下单、支付成功、支付失败关单、物流信息更新、关闭订单。
这5个用例可以用5个Command/Event对象代替,也就是对应了5个方法。
我见过3种ApplicationService的组织形态:
(1)一个ApplicationService类是一个完整的业务流程,其中每个方法负责处理一个Use Case。
-
这种的好处是可以完整的收敛整个业务逻辑,从接口类即可对业务逻辑有一定的掌握,适合相对简单的业务流程。
-
坏处就是对于复杂的业务流程会导致一个类的方法过多,有可能代码量过大。
这种类型的具体案例如:
public interface CheckoutService {
// 下单
OrderDTO checkout(@Valid CheckoutCommand cmd);
// 支付成功
OrderDTO payReceived(@Valid PaymentReceivedEvent event);
// 支付取消
OrderDTO payCanceled(@Valid PaymentCanceledEvent event);
// 发货
OrderDTO packageSent(@Valid PackageSentEvent event);
// 收货
OrderDTO delivered(@Valid DeliveredEvent event);
// 批量查询
List<OrderDTO> query(OrderQuery query);
// 单个查询
OrderDTO getOrder(Long orderId);
}
(2)针对于比较复杂的业务流程,可以通过增加独立的CommandHandler、EventHandler来降低一个类中的代码量:
@Component
public class CheckoutCommandHandler implements CommandHandler<CheckoutCommand, OrderDTO> {
@Override
public OrderDTO handle(CheckoutCommand cmd) {
//
}
}
public class CheckoutServiceImpl implements CheckoutService {
@Resource
private CheckoutCommandHandler checkoutCommandHandler;
@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
return checkoutCommandHandler.handle(cmd);
}
}
(3)比较激进一点,通过CommandBus、EventBus,直接将指令或事件抛给对应的Handler,EventBus比较常见。
具体案例代码如下,通过消息队列收到MQ消息后,生成Event,然后由EventBus做路由到对应的Handler:
// 在这里框架通常可以根据接口识别到这个负责处理PaymentReceivedEvent
// 也可以通过增加注解识别
@Component
public class PaymentReceivedHandler implements EventHandler<PaymentReceivedEvent> {
@Override
public void process(PaymentReceivedEvent event) {
//
}
}
// Interface层,这个是RocketMQ的Listener
public class OrderMessageListener implements MessageListenerOrderly {
@Resource
private EventBus eventBus;
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
PaymentReceivedEvent event = new PaymentReceivedEvent();
eventBus.dispatch(event); // 不需要指定消费者
return ConsumeOrderlyStatus.SUCCESS;
}
}
不建议:这种做法可以实现Interface层和某个具体的ApplicationService或Handler的完全静态解藕,在运行时动态dispatch,做的比较好的框架如AxonFramework。
虽然看起来很便利,但是根据我们自己业务的实践和踩坑发现,当代码中的CQE对象越来越多,handler越来越复杂时,运行时的dispatch缺乏了静态代码间的关联关系,导致代码很难读懂,特别是当你需要trace一个复杂调用链路时,因为dispatch是运行时的,很难摸清楚具体调用到的对象。
所以我们虽然曾经有过这种尝试,但现在已经不建议这么做了。
要点2:Application Service 是业务流程的封装,不处理业务逻辑
虽然之前曾经无数次重复ApplicationService只负责业务流程串联,不负责业务逻辑,但如何判断一段代码到底是业务流程还是逻辑呢?
举个之前的例子,最初的代码重构后:
@Service
@Validated
public class CheckoutServiceImpl implements CheckoutService {
private final OrderDtoAssembler orderDtoAssembler = OrderDtoAssembler.INSTANCE;
@Resource
private ItemService itemService;
@Resource
private InventoryService inventoryService;
@Resource
private OrderRepository orderRepository;
@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
ItemDO item = itemService.getItem(cmd.getItemId());
if (item == null) {
throw new IllegalArgumentException("Item not found");
}
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}
Order order = new Order();
order.setBuyerId(cmd.getUserId());
order.setSellerId(item.getSellerId());
order.setItemId(item.getItemId());
order.setItemTitle(item.getTitle());
order.setItemUnitPrice(item.getPriceInCents());
order.setCount(cmd.getQuantity());
Order savedOrder = orderRepository.save(order);
return orderDtoAssembler.orderToDTO(savedOrder);
}
}
判断是否业务流程的几个点
(1)不要有if/else分支逻辑
也就是说代码的Cyclomatic Complexity(循环复杂度)应该尽量等于1。
通常情况下,如果有分支逻辑的,都代表一些业务判断,那么,应该将逻辑封装到DomainService或者Entity里。
但,这不代表Application Service 完全不能有if逻辑,比如,在这段代码里:
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}
虽然CC > 1,但是仅仅代表了中断条件,具体的业务逻辑处理并没有受影响。可以把它看作为Precondition。
(2)不要有任何计算
在最早的代码里有这个计算:
// 5)领域计算
Long cost = item.getPriceInCents() * quantity;
order.setTotalCost(cost);
通过将这个计算逻辑封装到实体里,避免在ApplicationService里做计算:
@Data
public class Order {
private Long itemUnitPrice;
private Integer count;
// 把原来一个在ApplicationService的计算迁移到Entity里
public Long getTotalCost() {
return itemUnitPrice * count;
}
}
order.setItemUnitPrice(item.getPriceInCents());
order.setCount(cmd.getQuantity());
(3)一些数据的转化可以交给其他对象来做
比如DTO Assembler,将对象间转化的逻辑抽取和剥离在单独的类中,降低ApplicationService的复杂度。
OrderDTO dto = orderDtoAssembler.orderToDTO(savedOrder);
常用的ApplicationService“套路”
我们可以看出来,ApplicationService的代码通常有类似的结构:
-
AppService通常不做任何决策(Precondition除外),
-
仅仅是把所有决策交给DomainService或Entity,
-
把跟外部交互的交给Infrastructure接口,如Repository或防腐层。
一般ApplicationService的“套路”如下:
-
准备数据:包括从外部服务或持久化源取出相对应的Entity、VO以及外部服务返回的DTO。
-
执行操作:包括新对象的创建、赋值,以及调用领域对象的方法对其进行操作。需要注意的是这个时候通常都是纯内存操作,非持久化。
-
持久化:将操作结果持久化,或操作外部系统产生相应的影响,包括发消息等异步操作。
如果涉及到对多个外部系统(包括自身的DB)都有变更的情况,这个时候通常处在“分布式事务”的场景里,无论是用分布式TX、TCC、还是Saga模式,取决于具体场景的设计,在此处暂时略过。
4.4 DTO Assembler
4.5 Result vs Exception
4.6 Anti-Corruption Layer防腐层
5. Orchestration编排 vs Choreography协作
在复杂的业务流程里,我们通常面临两种模式:Orchestration 和 Choreography。
很无奈,这两个英文单词的百度翻译/谷歌翻译,都是“编排”,但实际上这两种模式是完全不一样的设计模式。
-
Orchestration的编排(比如SOA/微服务的服务编排Service Orchestration)是我们通常熟悉的用法
-
Choreography是最近出现了事件驱动架构EDA才慢慢流行起来。
5.1 模式简介
-
Orchestration:通常出现在脑海里的是一个交响乐团(Orchestra,注意这两个词的相似性)。交响乐团的核心是一个唯一的指挥家Conductor,在一个交响乐中,所有的音乐家必须听从Conductor的指挥做操作,不可以独自发挥。所以在Orchestration模式中,所有的流程都是由一个节点或服务触发的。我们常见的业务流程代码,包括调用外部服务,就是Orchestration,由我们的服务统一触发。
-
Choreography:通常会出现在脑海的场景是一个舞剧(来自于希腊文的舞蹈,Choros)。其中每个不同的舞蹈家都在做自己的事,但是没有一个中心化的指挥。通过协作配合,每个人做好自己的事,整个舞蹈可以展现出一个完整的、和谐的画面。所以在Choreography模式中,每个服务都是独立的个体,可能会响应外部的一些事件,但整个系统是一个整体。
5.2 案例
用一个常见的例子:下单后支付并发货。
如果这个案例是Orchestration编排,则业务逻辑为:下单时从一个预存的账户里扣取资金,并且生成物流单发货,从图上看是这样的:
如果这个案例是Choreography协作,则业务逻辑为:下单,然后等支付成功事件,然后再发货,类似这样:
5.3 模式的区别和选择
5.4 跟DDD分层架构的关系
最后,讲了这么多O vs C,跟DDD有啥关系?很简单:
-
O&C其实是Interface层的关注点,Orchestration = 对外的API,而Choreography = 消息或事件。当你决策了O还是C之后,需要在Interface层承接这些“驱动力”。
-
无论O&C如何设计,Application层都“无感知”,因为ApplicationService天生就可以处理Command、Query和Event,至于这些对象怎么来,是Interface层的决策。
所以,虽然Orchestration 和 Choreography是两种完全不同的业务设计模式,但最终落到Application层的代码应该是一致的,这也是为什么Application层是“用例”而不是“接口”,是相对稳定的存在。
总结
只要是做业务的,一定会需要写业务流程和服务编排,但不代表这种代码一定质量差。
通过DDD的分层架构里的Interface层和Application层的合理拆分,代码可以变得优雅、灵活,能更快的响应业务但同时又能更好的沉淀。
本文主要介绍了一些代码的设计规范,帮助大家掌握一定的技巧。
Interface层:
-
职责:主要负责承接网络协议的转化、Session管理等。
-
接口数量:避免所谓的统一API,不必人为限制接口类的数量,每个/每类业务对应一套接口即可,接口参数应该符合业务需求,避免大而全的入参。
-
接口出参:统一返回Result。
-
异常处理:应该捕捉所有异常,避免异常信息的泄漏。可以通过AOP统一处理,避免代码里有大量重复代码。
Application层:
-
入参:具像化Command、Query、Event对象作为ApplicationService的入参,唯一可以的例外是单ID查询的场景。
-
CQE的语意化:CQE对象有语意,不同用例之间语意不同,即使参数一样也要避免复用。
-
入参校验:基础校验通过Bean Validation api解决。Spring Validation自带Validation的AOP,也可以自己写AOP。
-
出参:统一返回DTO,而不是Entity或DO。
-
DTO转化:用DTO Assembler负责Entity/VO到DTO的转化。
-
异常处理:不统一捕捉异常,可以随意抛异常。
部分Infra层:
-
用ACL防腐层将外部依赖转化为内部代码,隔离外部的影响。
业务流程设计模式:
-
没有最好的模式,取决于业务场景、依赖关系、以及是否有业务“负责人”。避免拿着锤子找钉子。