阿⾥技术专家详解DDD系列第四讲-领域层设计规范
填坑进⾏时。谢谢⼤家对DDD系列的期待,持续更新,欢迎关注此账号查收后续内容。
第⼀篇内容地址:
第⼆篇内容地址:
第三篇内容地址:
在⼀个DDD架构设计中,领域层的设计合理性会直接影响整个架构的代码结构以及应⽤层、基础设施层的设计。但是领域层设计⼜是有挑战的任务,特别是在⼀个业务逻辑相对复杂应⽤中,每⼀个业务规则是应该放在Entity、ValueObject 还是 DomainService是值得⽤⼼思考的,既要避免未来的扩展性差,⼜要确保不会过度设计导致复杂性。
今天我⽤⼀个相对轻松易懂的领域做⼀个案例演⽰,但在实际业务应⽤中,⽆论是交易、营销还是互动,都可以⽤类似的逻辑来实现。
初探龙与魔法的世界架构
▐ 背景和规则
平⽇⾥看了好多严肃的业务代码,今天⼀个轻松的话题,如何⽤代码实现⼀个龙与魔法的游戏世界的(极简)规则?
基础配置如下:
玩家(Player)可以是战⼠(Fighter)、法师(Mage)、龙骑(Dragoon)
怪物(Monster)可以是兽⼈(Orc)、精灵(Elf)、龙(Dragon),怪物有⾎量
武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击⼒
玩家可以装备⼀个武器,武器攻击可以是物理类型(0),⽕(1),冰(2)等,武器类型决定伤害类型
攻击规则如下:
1. 兽⼈对物理攻击伤害减半
2. 精灵对魔法攻击伤害减半
3. 龙对物理和魔法攻击免疫,除⾮玩家是龙骑,则伤害加倍
▐ OOP实现
对于熟悉Object-Oriented Programming的同学,⼀个⽐较简单的实现是通过类的继承关系(此处省略部分⾮核⼼代码):
public abstract class Player {
Weapon weapon
}
public class Fighter extends Player {}
public class Mage extends Player {}
public class Dragoon extends Player {}
public abstract class Monster {
Long health;
}
public Orc extends Monster {}
public Elf extends Monster {}
public Dragoon extends Monster {}
public abstract class Weapon {
int damage;
int damageType; // 0 - physical, 1 - fire, 2 - ice etc.
}
public Sword extends Weapon {}
public Staff extends Weapon {}
⽽实现规则代码如下:
public class Player {
public void attack(Monster monster) {
}
}
public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= Damage(); // 基础规则
}
}java技术专家
public class Orc extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (DamageType() == 0) {
this.Health() - Damage() / 2); // Orc的物理防御规则        } else {
}
}
}
public class Dragon extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (player instanceof Dragoon) {
this.Health() - Damage() * 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
Health()).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
Health()).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
Health()).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
Health()).isEqualTo(100 - 10);
}
}
以上代码和单测都⽐较简单,不做多余的解释了。
▐分析OOP代码的设计缺陷
编程语⾔的强类型⽆法承载业务规则
以上的OOP代码可以跑得通,直到我们加⼀个限制条件:
战⼠只能装备剑
法师只能装备法杖
这个规则在Java语⾔⾥⽆法通过强类型来实现,虽然Java有Variable Hiding(或者C#的new class variable),但实际上只是在⼦类上加了⼀个新变量,所以会导致以下的问题:
@Data
public class Fighter extends Player {
private Sword weapon;
}
@Test
public void testEquip() {
Fighter fighter = new Fighter("Hero");
Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);
Staff staff = new Staff("Staff", 10);
fighter.setWeapon(staff);
Weapon()).isInstanceOf(Staff.class); // 错误了
}
在最后,虽然代码感觉是setWeapon(Staff),但实际上只修改了⽗类的变量,并没有修改⼦类的变量,所以实际不⽣效,也不抛异常,但结果是错的。
当然,可以在⽗类限制setter为protected,但这样就限制了⽗类的API,极⼤的降低了灵活性,同时也违背了Liskov substitution principle,即⼀个⽗类必须要cast成⼦类才能使⽤:
@Data
public abstract class Player {
@Setter(AccessLevel.PROTECTED)
private Weapon weapon;
}
@Test
public void testCastEquip() {
Fighter fighter = new Fighter("Hero");
Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);
Player player = fighter;
Staff staff = new Staff("Staff", 10);
player.setWeapon(staff); // 编译不过,但从API层⾯上应该开放可⽤
}
最后,如果规则增加⼀条:
战⼠和法师都能装备⼔⾸(dagger)
BOOM,之前写的强类型代码都废了,需要重构。
对象继承导致代码强依赖⽗类逻辑,违反开闭原则Open-Closed Principle(OCP)
开闭原则(OCP)规定“对象应该对于扩展开放,对于修改封闭“,继承虽然可以通过⼦类扩展新的⾏为,但因为⼦类可能直接依赖⽗类的实现,导致⼀个变更可能会影响所有对象。在这个例⼦⾥,如果增加任意⼀种类型的玩家、怪物或武器,或增加⼀种规则,都有可能需要修改从⽗类到⼦类的所有⽅法。
⽐如,如果要增加⼀个武器类型:狙击,能够⽆视所有防御⼀击必杀,需要修改的代码包括:
Weapon
Player和所有的⼦类(是否能装备某个武器的判断)
Monster和所有的⼦类(伤害计算逻辑)
public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= Damage(); // ⽼的基础规则
if (Weapon instanceof Gun) { // 新的逻辑
this.setHealth(0);
}
}
}
public class Dragon extends Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
if (Weapon instanceof Gun) { // 新的逻辑
}
/
/ ⽼的逻辑省略
}
}
在⼀个复杂的软件中为什么会建议“尽量”不要违背OCP?最核⼼的原因就是⼀个现有逻辑的变更可能会影响⼀些原有的代码,导致⼀些⽆法预见的影响。这个风险只能通过完整的单元测试覆盖来保障,但在实际开发中很难保障单测的覆盖率。OCP的原则能尽可能的规避这种风险,当新的⾏为只能通过新的字段/⽅法来实现时,⽼代码的⾏为⾃然不会变。
继承虽然能Open for extension,但很难做到Closed for modification。所以今天解决OCP的主要⽅法是通过Composition-over-inheritance,即通过组合来做到扩展性,⽽不是通过继承。
Player.attack(monster) 还是 iveDamage(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
}
}
⼀个可能的解法是有个通⽤的⽗类:

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。