首页 要闻娱乐军事情感奇闻搞笑社会体育游戏百科

weaponx 阿里技术专家详解DDD系列第四讲:领域层设计规范

2022-01-13 13:58

在DDD架构设计中,领域层的设计合理性将直接影响整个架构的代码结构以及应用层和基础设施层的设计。然而,域级设计是一项具有挑战性的任务,尤其是在业务逻辑相对复杂的应用程序中。每个业务规则应该放在Entity、ValueObject还是DomainService中值得仔细考虑。有必要避免未来较差的可扩展性,并确保过度设计不会导致复杂性。今天我用一个相对容易理解的领域来做一个案例演示,但在实际的商业应用中,无论是交易、营销还是互动,都可以用类似的逻辑来实现。

论龙与魔的世界结构

背景和规则

我在工作日读了很多严肃的商业守则。今天,我在寻找一个轻松的话题。如何用代码实现龙与魔的游戏世界的规则?

基本配置如下:

玩家可以是战士、法师、龙骑怪物可以是兽人、精灵、龙,怪物有血量武器可以是剑、法杖,武器有攻击力玩家可以装备一个武器,武器攻击可以是物理类型,火,冰等,武器类型决定伤害类型

攻击规则如下:

兽人对物理攻击伤害减半精灵对魔法攻击伤害减半龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍▐OOP实现

对于熟悉面向对象编程的同学来说,一个简单的实现就是通过类的继承关系:

publicabstractclassPlayer{Weapon weapon } publicclassFighterextendsPlayer{}publicclassMageextendsPlayer{}publicclassDragoonextendsPlayer{}

publicatabstractclass monster { Long health;}publicOrc扩展Monster {} publicElf扩展Monster {} publicDragoon扩展Monster { }

publicatabstractclasswarehouse { int damage;intdamageType// 0 -物理,1 -火,2 -冰等。}publicSword扩展了武器{} publicStaff扩展了武器{}

实现规则代码如下:

publicclassPlayer{ publicvoidattack{ monster.receiveDamageBy; }}

public class monster { public void receivedamageby{ this.health -=凶器. getdamage;//基本规则}}

public class force extends monster { @ overridedpublicationreceivedamageby{ if{ this . set health;//兽人的物理防御规则} else {super。receiveddamageby;}}}

public class dragonextendsmnster { @ overridedpublicavitreceivedamageby{ if{ this . set health;//骑龙伤害规则}//否则无伤害,龙免疫规则}}

然后运行一些单独的测试:

publicclassBattleTest{

@Test@DisplayNamepublic votitestdragonimmunity {//given fifter = new fifter;剑剑= newSword;fighter.setWeapon;Dragon dragon = newDragon;

// Whenfighter.attack;

//NansertHat。isEqualTo;}

@Test@DisplayNamepublic votitestdragonspecial {//GivenDragoon dragon = new dragon;剑剑= newSword;dragoon.setWeapon;Dragon dragon = newDragon;

//when dragon . attack;

//NansertHat。isEqualTo;}

@Test@DisplayNamepublicavittestfighterrorc {//given fighter fighter = new fighter;剑剑= newSword;fighter.setWeapon;兽人兽人=新兽人;

// Whenfighter.attack;

//NansertHat。isEqualTo;}

@Test@DisplayNamepublicavittestmagorc {//GivenMage Mage = new Mage;工作人员= newStaff;mage . setWireLess;兽人兽人=新兽人;

// Whenmage.attack;

//NansertHat。isEqualTo;}}

以上代码和单项测试都比较简单,没有多余的解释。

分析面向对象代码的设计缺陷

强类型的编程语言不能携带业务规则

上面的OOP代码可以工作,直到我们添加一个约束:

战士只能装备剑 法师只能装备法杖

这个规则是Java语言中强类型化无法实现的。虽然Java有变量隐藏,但它实际上只是给子类增加了一个新的变量,这将导致以下问题:

@DatapublicclassFighterextendsPlayer{ privateSword weapon; }

@ TestpublicvoidtestEquip {战斗机=新战斗机;

剑剑= newSword;fighter.setWeapon;

Staff staff = newStaff;战斗机;

assert ThAT。is instance of;//错误}

最后,虽然代码感觉是set兵器,但实际上只是修改了父类的变量,并没有修改子类的变量,所以实际上并没有生效,也没有抛出异常,但结果是错误的。

当然,setter可以限制在父类中受保护,但这限制了父类的API,大大降低了灵活性,同时也违反了Liskov替换原则,即父类必须先被转换为子类才能使用:

@DatapublicabstractclassPlayer{ @Setter privateWeapon weapon; }

@ TestpublicvoidtestCastEquip {战斗机=新战斗机;

剑剑= newSword;fighter.setWeapon;

玩家=战士;Staff staff = newStaff;player.setWeapon;//编译,但它应该是开放的,并且可以从API级别获得}

最后,如果添加了一条规则:

战士和法师都能装备匕首

BOOM,之前写的强类型代码已经过时,需要重新构建。

对象继承导致对父类逻辑的强烈依赖,这违反了开-闭原则

开放封闭原则规定“对象应该对扩展开放,对修改封闭”。虽然继承可以通过子类扩展新的行为,但是子类可能直接依赖于父类的实现,所以一个改变可能会影响所有的对象。在这个例子中,如果你添加任何类型的玩家、怪物或武器,或者添加一个规则,你可能需要修改从父类到子类的所有方法。

比如想增加一种武器类型:狙击枪,可以无视一切防御,进行杀伤,需要修改的代码包括:

Weapon Player和所有的子类 Monster和所有的子类 publicclassMonster{ publicvoidreceiveDamageBy{ this.health -= weapon.getDamage; // 老的基础规则if { // 新的逻辑this.setHealth; }}}

公众级dragonextendsmoster {公众语音接收伤害由{if{//新逻辑超级。接收图像;}//省略旧逻辑}}

为什么建议在一个复杂的软件中尽量不要违反OCP?核心原因是现有逻辑的变化可能会影响一些原始代码,从而产生一些不可预见的影响。这种风险只能通过完整的单元测试覆盖来保证,但在实际开发中很难保证单个测试的覆盖。OCP原则可以尽可能避免这种风险。当新的行为只能通过新的字段/方法实现时,旧代码的行为自然不会改变。

继承可以打开进行扩展,但很难关闭进行修改。因此,今天解决OCP的主要方法是组合-超继承,即扩展性是通过组合实现的,而不是继承。

是玩家.攻击还是怪物.接收图像?

在这个例子中,其实商业规则的逻辑应该写在哪里是有争议的:当我们看一个对象和另一个对象的交互时,是Player要攻击Monster还是Monster被Player攻击?目前代码主要在Monster的类中写逻辑。主要考虑怪物会伤害和降低生命值,但是如果玩家拿着一把双刃剑,他会同时伤害自己。你有没有发现在Monster写作也有问题?代码写在哪里的原理是什么?

多个对象行为相似,导致代码重复

当我们有不同的对象,但有相同或相似的行为时,OOP将不可避免地导致代码重复。在这个例子中,如果我们添加一个“可移动”行为,我们需要向玩家和怪物类添加类似的逻辑:

publicabstractclassPlayer{ intx; inty; voidmove { // logic}}

publicatabstractclassmonster { intx;intyvoidmove { // logic}}

一个可能的解决方案是拥有一个公共的父类:

publicabstractclassMovable{ intx; inty; voidmove { // logic}}

publicatabstractclassplayereextendsmobile;publicatabstractclassmonsterextendsmobile;

但是如果我们再增加一个跳跃能力呢?一个能跑的跑步者怎么样?如果玩家能动能跳,怪物能动能跑,如何处理继承关系?重要的是要知道Java不支持多父类继承,所以只能通过重复代码来实现。

问题总结

在这种情况下,虽然直观上OOP的逻辑非常简单,但是如果你的业务比较复杂,未来会有大量的业务规则变化,那么简单的OOP代码在后期就会变成复杂的浆糊,逻辑散在各处,缺乏全局的视角。各种规则的叠加会触发bug。你觉得熟悉吗?没错,电子商务系统中的优惠、交易等环节经常会遇到类似的坑。这类问题的核心本质在于:

业务规则的归属到底是对象的“行为”还是独立的”规则对象“?业务规则之间的关系如何处理?通用“行为”应该如何复用和维护?

在谈论DDD的解决方案之前,我们先来看一套最近在游戏中流行的架构设计,以及如何实现实体-组件-系统。

实体-组件-系统体系结构介绍

ECS架构模式其实是一个非常古老的游戏架构设计,最早可以追溯到《地下城围攻》的组件设计,但最近随着Unity的加入而流行起来。为了快速理解ECS架构的价值,我们需要了解游戏代码的一个核心问题:

性能:游戏必须要实现一个高的渲染率,也就是说整个游戏世界需要在1/60s内完整更新一次。而在一个游戏中,通常有大量的游戏对象需要更新状态,除了渲染可以依赖GPU之外,其他的逻辑都需要由CPU完成,甚至绝大部分只能由单线程完成,导致绝大部分时间复杂场景下CPU会成为瓶颈。在CPU单核速度几乎不再增加的时代,如何能让CPU处理的效率提升,是提升游戏性能的核心。代码组织:如同第一章讲的案例一样,当我们用传统OOP的模式进行游戏开发时,很容易就会陷入代码组织上的问题,最终导致代码难以阅读,维护和优化。可扩展性:这个跟上一条类似,但更多的是游戏的特性导致:需要快速更新,加入新的元素。一个游戏的架构需要能通过低代码、甚至0代码的方式增加游戏元素,从而通过快速更新而留住用户。如果每次变更都需要开发新的代码,测试,然后让用户重新下载客户端,可想而知这种游戏很难在现在的竞争环境下活下来。

ECS架构可以很好地解决上述问题。ECS架构主要分为:

Entity:用来代表任何一个游戏对象,但是在ECS里一个Entity最重要的仅仅是他的EntityID,一个Entity里包含多个ComponentComponent:是真正的数据,ECS架构把一个个的实体对象拆分为更加细化的组件,比如位置、素材、状态等,也就是说一个Entity实际上只是一个Bag of Components。System:是真正的行为,一个游戏里可以有很多个不同的组件系统,每个组件系统都只负责一件事,可以依次处理大量的相同组件,而不需要去理解具体的Entity。所以一个ComponentSystem理论上可以有更加高效的组件处理效率,甚至可以实现并行处理,从而提升CPU利用率。

ECS的一些核心性能优化包括将相同类型的组件放在同一个Array中,然后保持Entity只指向各自组件的指针,这样可以更好地利用CPU缓存,降低数据加载成本,优化SIMD。

ECS案例的伪代码如下:

publicclassEntity{ publicVector position; // 此处Vector是一个Component, 指向的是MovementSystem.list里的一个}

public class movementsystem { List & lt;Vector>。列表;

//系统的行为是public void update{ for{//这个循环直接远离CPU缓存,性能很高。同时,SIMD可以用来优化pos . x = pos . x+delta;pos . y = pos . y+delta;}}}

@ testpublicationtest { MovementSystem system = new MovementSystem;系统。list = newList & lt& gt{ newVector};entity entity = new entity);system . update;assert TRUe;}

由于本文不是关于ECS架构的,感兴趣的学生可以搜索实体-组件-系统或查看Unity的ECS文档。

▐ECS建筑分析

回过头来分析ECS,其实它的起源还是几个很古老的概念:

模块化

在软件系统中,我们通常将复杂的大系统分成独立的组件来降低复杂性。例如,在网页中,通过前端组件化降低了重复开发的成本,通过拆分服务和数据库降低了服务复杂性和系统影响。然而,ECS架构将这一点发挥到了极致,即每个对象都在内部组件化。通过将一个游戏对象的数据和行为拆分成多个组件和组件系统,可以实现组件的高重用性,降低重复开发的成本。

行为退缩

这在游戏系统中有明显的优势。根据OOP,游戏对象可以包括移动代码、战斗代码、渲染代码、AI代码等。,如果将它们都放在一个类中,将会非常长并且难以维护。通过将通用逻辑分离到单独的System类中,可以明显提高代码的可读性。另一个优点是删除了一些与目标代码无关的依赖关系,例如上面的增量。如果这个增量放在Entity的更新方法中,需要作为输入注入,而放在System中可以统一管理。第一章有个问题,应该是Player.attack还是怪物。接收图像。在ECS中,这个问题变得非常简单。把它放进战斗系统。

数据驱动

也就是说,对象的行为是由其参数决定的,通过动态修改参数可以快速改变对象的具体行为。在ECS的游戏架构中,通过向Entity注册对应的Component,改变Component的具体参数组合,就可以改变一个对象的行为和玩法。比如创造一个水壶+爆炸属性就变成了“爆炸水壶”,给自行车加风魔法就变成了飞驰的汽车。在一些Rougelike游戏中,可能有10000多个不同类型和功能的项目。如果把这些功能不同的项目分开写,可能永远写不完。但是,通过数据驱动和基于组件的架构,所有项目的配置最终都是一个表,修改极其简单。这也是组合优于继承原则的体现。

▐ECS的缺陷

虽然ECS已经开始在游戏行业崭露头角,但我发现ECS架构还没有在任何大型商业应用中使用。原因可能有很多,包括ECS比较新,人们还不知道,缺乏商业上成熟可用的框架,程序员无法适应从编写逻辑脚本到编写组件的思维转变等。,但我认为最大的问题是ECS为了提高性能,强调数据/State和Behaivor的分离,为了降低GC成本,直接将数据操作到极致。在商业应用中,数据的正确性、一致性和健壮性应该是重中之重,而性能只是锦上添花,因此ECS很难在商业场景中带来巨大的收益。但这并不意味着我们不能借鉴ECS的一些突破性思维,包括组件化、跨对象行为分离、数据驱动模式等,在DDD中也能很好的运用。

基于DDD架构的解决方案

域对象

回到我们最初的问题域,我们从域层分离出各种对象:

实体类

在DDD,实体类包含标识和内部状态。在这种情况下,实体类包含玩家、怪物和武器。武器被设计成一个实体类,因为两个同名的武器应该同时存在,所以必须用ID来区分。同时,可以预计武器在未来会包含一些状态,比如升级、临时缓冲、耐久等等。

publicclassPlayerimplementsMovable{ privatePlayerId id; privateString name; privatePlayerClass playerClass; // enumprivateWeaponId weaponId; // privateTransform position = Transform.ORIGIN; privateVector velocity = Vector.ZERO; }

public class monster implements mobile { privateonsterid id;privateMonsterClass monster class;//enumprivathealth health;私有转换位置=转换。ORIGIN私人矢量速度=矢量。零;}

public class WireLess { privateWireLess id;privateString名称;privateWeaponType weaponType//enumprivateintdamage;privateintdamageType// 0 -物理,1 -火,2 -冰}

在这个简单的案例中,我们可以使用枚举的PlayerClass和MonsterClass来代替继承关系,然后我们可以使用Type Object设计模式来实现数据驱动。

注1:因为武器是实体类,但武器可以独立存在,玩家不是聚合根,所以玩家只能保存武器,不能直接指向武器。

价值对象的组件化

在之前的ECS架构中,有一个可以重用的MovementSystem的概念。虽然您不应该直接操作组件或继承公共父类,但是您可以通过接口对域对象进行组件化:

publicinterfaceMovable{ // 相当于组件Transform getPosition; Vector getVelocity;

//行为voidmoveTo;voidstartMove;无效停止移动;booleanisMoving}

//实现public blassplayrimplementchangered { public void move to {this。position = newtransform ;}

public void startmove{ this . velocity = new vector;}

public void stop move { this . velocity = Vector。零;}

@ overrideepublibooleanisoving { returnthis . velocity . getx!= 0|| this.velocity.getY!= 0;}}

@ valuepublicclass transform { publicationstatifindtransform ORIGIN = new transform;longx隆伊;}

@ ValuePublicClassVector { PublistatiFilteVector ZERO = NewVector;longx隆伊;}

注意两点:

Moveable的接口没有Setter。一个Entity的规则是不能直接变更其属性,必须通过Entity的方法去对内部状态做变更。这样能保证数据的一致性。 抽象Movable的好处是如同ECS一样,一些特别通用的行为可以通过统一的System代码去处理,避免了重复劳动。▐装备行为

因为我们不能用Player的子类来决定可以装备什么样的武器,所以这个逻辑应该拆分成一个单独的类。这个类在分布式拒绝服务中被称为域服务。

publicinterfaceEquipmentService{ booleancanEquip; }

在DDD,一个实体不应直接引用另一个实体或服务,这意味着以下代码是错误的:

publicclassPlayer{ @AutowiredEquipmentService equipmentService; // BAD: 不可以直接依赖

公共武器装备{ //...}}

这里的问题是实体只能保持它自己的状态。其他任何对象,不管是不是依赖注入带来的,都会破坏实体的不变性,很难单独测试。

正确的参考方式是通过方法参数介绍:

publicclassPlayer{

public void equipment{ if){ this . wear ponid = wear . getid;} else { thrownewIllegalArgumentException;}}}

在这种情况下,武器和装备服务都是通过方法参数传入的,以确保玩家自己的状态不会被污染。

双重分派是使用域服务时经常使用的一种方法,类似于调用反转。

然后,在设备服务中实现相关的逻辑判断。这里,我们使用另一种常用的策略设计模式:

publicclassEquipmentServiceImplimplementsEquipmentService{ privateEquipmentManager equipmentManager;

@ OverridepublicbooleancanEquip{ returnequipmentManager.canEquip;}}

//策略优先级管理public class requirements Manager { privatesticfinallist

publicbooleancanEquip{ for{ if){ continue}returnpolicy.canEquip;} returnfalse}}

//策略案例public class fighterererequirements Policy implements requirements Policy {

@ OverridepublicbooleancanApply{ return Player . getplayer class = = Player class。战斗机;}

/***战士可以装备剑与匕首*/@ Override Public BooleanneQuip{ Return Weapontype = = Weapontype . Sword | | Weapontype = = Weapontype . dager;}}

//其他策略省略,参见源代码

这种设计最大的优点是,以后只需要增加新的Policy类,不需要改变原有的类。

攻击行为

如上所述,应该是玩家攻击还是怪物。受到伤害?在DDD,因为这种行为可能会影响到Player、Monster和兵器,属于跨实体的商业逻辑。在这种情况下,它需要由第三方域服务完成。

publicinterfaceCombatService{ voidperformAttack; }

public class ComPaneserviceimplemplements ComPaneservice { privateWeaPontrepository;privateDamageManager damageManager;

@ overridedpublicavitperformattack{武器武器= weaponRepository.find;伤害=伤害管理器.计算伤害;如果 { monster.takeDamage;//换域服怪物}//省略玩家和武器的可能影响}}

同样,在这种情况下,损伤的计算问题可以通过策略设计模式来解决:

// 策略优先级管理publicclassDamageManager{ privatestaticfinal List POLICIES = newArrayList<>; static{ POLICIES. add; POLICIES. add; POLICIES. add; POLICIES. add; POLICIES. add; POLICIES. add; }

publicintcalculateDamage{ for{ if){ continue} return policy . calculatedArability;} return0}}

//策略案例public class dragonpolicy implements image Policy { public computed image{ Return armor。getDeaP * 2;} @ Overridepublicboolean{ return Player . getplayer class = = Player class。龙骑兵。和。monster . getmonster class = = monster class。龙;}}

需要特别注意的是,这里的CombatService域服务和3.2的EquipmentService域服务都是域服务,但本质上有很大的不同。上面的EquipmentService提供了只读策略,只影响单个对象,所以可以通过播放器上的参数注入。设备方法。但是,CombatService可能会影响多个对象,因此不能通过参数注入直接调用。

单元测试

@Test@DisplayName publicvoidtestDragoonSpecial{ // GivenPlayer dragoon = playerFactory.createPlayer; Weapon sword = weaponFactory.createWeaponFromPrototype; weaponRepository).cache;dragoon.equip;Monster dragon = monsterFactory.createMonster;

//whencombateservice . performattack;

//NansertHat。健康。ZERO);assertThat。isFalse}

@Test@DisplayNamepublicationtestfighterrorc {//GivenPlayer fighter = player factory . create player;武器剑=武器工厂. weaponFactory.createWeaponFromPrototype;WeaPonrepository)。缓存;战斗机.装备;怪物兽人= monster factory . create monster;

//whencombatervice . performattack;

//NansertHat。健康;}

具体代码简单,省略说明

移动系统

最后,还有一个域服务。通过组件化,我们实际上可以实现与ECS相同的系统,以减少一些重复的代码:

publicclassMovementSystem{

privatedstaticnlongx _ FENCE _ MIN =-100;privateStaticFillLongx _ FENCE _ MAX = 100;privatedstatifnloy _ FENCE _ MIN =-100;privatedstatifnloy _ FENCE _ MAX = 100;

privateList & lt可移动的。实体=新数组列表& lt& gt;

publicvoidregister{ entities.add;}

publicvoidupdate{ for{ if{ continue;}

transform old = entity . getposition;Vector vel = entity.getVelocitylongnewX = math . MAX,X _ FENCE _ MIN);longnewY = math . MAX,Y _ FENCE _ MIN);entity.moveTo;}}}

单一测试:

@Test@DisplayName publicvoidtestMovement{ // GivenPlayer fighter = playerFactory.createPlayer; fighter.moveTo; fighter.startMove;

怪物兽人= monster factory . create monster;orc.moveTo;orc.startMove;

移动系统。注册;移动系统。注册;

//when movementsystem . update;

//NansertHat。isEqualTo;assert ThAT。isEqualTo;}

这里,移动系统是一个相对独立的域服务。通过对Movable进行组件化,实现了相似代码的集中化和一些常见依赖/配置的集中化。).

DDD域层的一些设计规范

以上,我主要针对同一个例子比较了OOP、ECS和DDD的三种实现,比较如下:

基于继承关系的OOP代码:OOP的代码最好写,也最容易理解,所有的规则代码都写在对象里,但是当领域规则变得越来越复杂时,其结构会限制它的发展。新的规则有可能会导致代码的整体重构。 基于组件化的ECS代码:ECS代码有最高的灵活性、可复用性、及性能,但极具弱化了实体类的内聚,所有的业务逻辑都写在了服务里,会导致业务的一致性无法保障,对商业系统会有较大的影响。 基于领域对象 + 领域服务的DDD架构:DDD的规则其实最复杂,同时要考虑到实体类的内聚和保证不变性,也要考虑跨对象规则代码的归属,甚至要考虑到具体领域服务的调用方式,理解成本比较高。

因此,我会通过一些设计规范,尽量降低DDD域层的设计成本。关于领域层中值对象的设计规范,请参考我之前的文章。

实体类

大多数DDD体系结构的核心是实体类,它包含域中的状态和对状态的直接操作。Entity最重要的设计原则是保证实体的不变量,也就是说保证无论外界如何操作,实体的内部属性不能相互冲突,状态不一致。因此,几个设计原则如下:

创建是一致的

在贫血模型中,常见的代码是调用者手动新建模型后分配一个参数和一个参数,容易导致遗漏和实体状态不一致。因此,在DDD创建实体有两种方式:

构造函数参数应该包含所有必要的属性,或者在构造函数中有合理的默认值。

例如,帐户创建:

publicclassAccount{ privateString accountNumber; privateLong amount; }

@ testpublicationtest { Account Account = new Account;account . setamount;TransferService.transfer;//报告了一个错误,因为帐户缺少必要的帐户号码}

没有强检查构造函数,创建的实体的一致性就无法得到保证。因此,有必要添加一个强检查构造函数:

publicclassAccount{ publicAccount{ assertStringUtils.isNotBlank; assertamount >= 0; this.accountNumber = accountNumber; this.amount = amount; }}

@ testpublicationtest { Account Account = new Account;//确保对象的有效性}

使用工厂模式降低调用者的复杂性

另一种方法是通过工厂模式创建对象,以减少一些重复的参数。例如:

publicclassWeaponFactory{ publicWeapon createWeaponFromPrototype { Weapon weapon = newWeapon; returnweapon; }}

通过导入现有原型,您可以快速创建新实体。还有一些其他的设计模式,比如Builder,就不一一指出了。

尽量避免公共设置

不一致最可能的原因之一是实体公开了public的setter方法,尤其是set的单个参数会导致状态不一致的时候。例如,订单可能包含子实体,如订单状态、付款单据、物流单据等。如果调用者可以随意设置订单状态,订单状态和子实体可能不匹配,可能导致业务流程失败。因此,在实体中,有必要通过行为方法来修改内部状态:

@Data@Setter // 确保不生成public setterpublicclassOrder{ privateintstatus; // 0 - 创建,1 - 支付,2 - 发货,3 - 收货privatePayment payment; // 支付单privateShipping shipping; // 物流单

publicvoidpay{ if{ thrownewillegalsteexception;} this . status = 1;this.payment = newPayment;}

public void ship{ if{ thrownewillegalsteexception;} this . status = 2;this . shipping = newShipping;}}

在一些简单的场景中,有时可以随意设置一个值,而不会导致不一致。也建议将方法名改写为更“行为”的名称,这样会增强其语义。SetPosition可以叫做moveTo,setAddress可以叫做assignAddress等等。

通过聚合根确保主实体和辅助实体的一致性

在稍微复杂一点的领域,主实体通常包含子实体,所以主实体需要扮演聚合根的角色,即:

子实体不能单独存在,只能通过聚合根的方法获取到。任何外部的对象都不能直接保留子实体的引用子实体没有独立的Repository,不可以单独保存和取出,必须要通过聚合根的Repository实例化子实体可以单独修改自身状态,但是多个子实体之间的状态一致性需要聚合根来保障

电子商务领域常见的聚合案例,如主子订单模型、商品/SKU模型、跨子订单折扣、跨店折扣模型等。聚合根和Repository的很多设计规范在我之前关于Repository的文章中已经详细解释过了,可以参考。

您不能严重依赖其他聚合根实体或域服务

实体的原理是高内聚低耦合,即实体类不能直接依赖外部实体或内部服务。这一原则与大多数ORM框架存在严重冲突,因此在开发过程中需要特别注意。这一原则产生的必要原因包括:对外部对象的依赖会直接导致实体无法被测试;并且一个实体无法保证外部实体发生变化后,实体的一致性和正确性不会受到影响。

因此,有两种正确的方法来依赖外部因素:

只保存外部实体的ID:这里我再次强烈建议使用强类型的ID对象,而不是Long型ID。强类型的ID对象不单单能自我包含验证代码,保证ID值的正确性,同时还能确保各种入参不会因为参数顺序变化而出bug。具体可以参考我的Domain Primitive文章。针对于“无副作用”的外部依赖,通过方法入参的方式传入。比如上文中的equip

这个原则更多的是一个保证代码可读性和可理解性的原则,即任何实体的行为都不能有“直接”“副作用”,即直接修改其他实体类。这样做的好处是读取代码时不会发生意外。

合规的另一个原因是降低未知变更的风险。在系统中,对实体对象的所有更改都应该是预期的。如果一个实体可以直接从外部随意修改,就会增加代码bug的风险。

域服务

如上所述,实际上有很多种域服务。根据以上所述,这里总结了三种常见的方法:

单对象策略类型

这类领域对象主要面向单个实体对象的变化,但涉及到多个领域对象或一些外部依赖规则。在上面,设备服务是这样的:

变更的对象是Player的参数读取的是Player和Weapon的数据,可能还包括从外部读取一些数据

在这种类型下,实体应该通过方法输入传入域服务,然后通过Double Dispatch反转调用域服务的方法,例如:

Player.equip {EquipmentService.canEquip; }

为什么不能先调用域服务,再调用实体对象的方法,从而减少实体对域服务的依赖?例如,以下方法是错误的:

booleancanEquip = EquipmentService.canEquip; if { Player.equip; // ❌,这种方法不可行,因为这个方法有不一致的可能性}

错误的主要原因是缺少域服务导致方法不一致的可能性。

跨对象交易类型

当一个动作直接修改多个实体时,就不能再用单个实体的方法来处理,必须直接用域服务的方法来操作。在这里,域服务在跨对象事务中扮演了更重要的角色,确保了多个实体的变更之间的一致性。

在上面,虽然下面的代码可以运行,但不建议这样做:

publicclassPlayer{ voidattack { CombatService.performAttack; // ❌,不要这么写,会导致副作用}}

我们实际上调用了应该直接调用CombatService的方法:

publicvoidtest { //...combatService.performAttack;}

这个原理也体现了4.1.5的原理,就是Player.attack会直接影响Monster,但是这个对Monster的调用是没有感知的。

通用组件类型

这种类型的域服务更像ECS中的System,它提供了组件化的行为,但它并不直接绑定到实体类。具体情况请参考上面MovementSystem的实现。

策略对象

策略设计模式是一种通用的设计模式,但它经常出现在DDD体系结构中,其核心是封装领域规则。

策略是一个无状态的单例对象,通常至少需要两种方法:canApply和business方法。其中,canApply方法用于判断一个Policy是否适用于当前上下文,如果适用,调用者将触发业务方法。通常,为了降低策略的可测试性和复杂性,策略不应该直接操作对象,而是通过返回计算值来操作域服务中的对象。

在上述情况下,DamagePolicy只负责计算应该受到的伤害,而不是直接对Monster造成伤害。这不仅是可测试的,也为未来的多策略叠加计算做好了准备。

本文除了静态注入多个策略和手动进行Priority排序外,在日常开发中,常见的是通过Java SPI机制或类SPI机制注册策略,通过不同的优先级方案对策略进行排序,这里就不多做介绍了。

膳食-副作用的治疗-领域事件

在上面,有一种我故意忽略的域规则,那就是“副作用”。当核心域模型的状态改变时,以及同步或异步对另一个对象的影响或行为时,会出现常见的副作用。在这种情况下,我们可以添加一个副作用规则:

当Monster的生命值降为0后,给Player奖励经验值

这个问题有很多解决方案,比如直接在CombatService中编写副作用:

publicclassCombatService{ publicvoidperformAttack { // ...monster.takeDamage;if { player.receiveExp; // 收到经验}}}

但是这样写的问题是,CombatService的代码很快就会变得非常复杂,例如,我们添加了另一个副作用:

当Player的exp达到100时,升一级

那么我们的代码将变成:

publicclassCombatService{ publicvoidperformAttack { // ...monster.takeDamage;if { player.receiveExp; // 收到经验if { player.levelUp; // 升级}}}}

如果加上“升级后奖励XXX”呢?“更新XXX排名”?以此类推,后续的代码将不可维护。所以我们需要引入领域层的最后一个概念:领域事件。

领域事件介绍

域事件是一种通知机制,我希望域中的其他对象在域中发生一些事情后能够感知到。在上述情况下,代码变得越来越复杂的根本原因是反应代码与上述事件触发条件直接耦合,这种耦合是隐式的。领域事件的优势在于将这种隐藏的副作用“显式化”,通过一个显式的事件,将事件触发器与事件发生地理解耦合起来,最终达到代码更清晰、扩展性更好的目的。

因此,领域事件是DDD中推荐的跨实体“副作用”传播机制。

域事件实现

与消息队列中间件不同,域事件通常在同一个进程中立即执行,可能是同步的,也可能是异步的。我们可以通过一个事件总线来实现过程中的通知机制,简单实现如下:

// 实现者:瑜进 2019/11/28publicclassEventBus{

//registrar @ getterprivate最终事件注册表invoker registry = new event registry;

//事件调度程序私有最终事件调度程序= new event dispatcher;

//异步事件调度程序私有最终事件调度程序异步调度程序= new event dispatcher;

//事件分发公共布尔调度{返回调度;}

//异步事件分发公共布尔调度async{ return dispatch;}

//内部事件分发私有布尔调度{ check event;// 1.获取事件数组集

//事件总线寄存器公共void寄存器{if{ thrownewillegalargumentexception;}invokerRegistry.register;}

privatedinvitcheckevent{ if{ thrownewIllegalArgumentException;}if){ thrownewIllegalArgumentException;}}}

调用方法:

publicclassLevelUpEventimplementsEvent{ privatePlayer player; }

public class level uphandler { public void handle;}

public class player { public void receiveexp{ this . exp+= value;if { LevelUpEvent事件= new level upevent;EventBus.dispatch;this . exp = 0;} } } @ testpublicationtest{ event bus . register;player . setLevel;player . receiveexp;assertThat。等于;}

当前田赛项目的缺陷与展望

从上面的代码可以看出,领域事件的良好实现依赖于EventBus、Dispatcher和Invoker的框架级支持。同时,另一个问题是Entity不能直接依赖外部对象,所以EventBus目前只能是全局Singleton,大家应该都知道全局Singleton对象很难被测试。这很容易导致实体对象不容易被完整的单一测试覆盖。

另一种解决方案是入侵实体,并为每个实体添加一个列表:

publicclassPlayer{ List events;

public void receiveexp{ this . exp+= value;if { LevelUpEvent事件= new level upevent;事件。add;//将事件添加到this . exp = 0;}}}

@ testpublicationtest{ event bus . register;player . setLevel;player . receiveexp;

For {//这里是显式调度事件event bus . dispatch;}

assertThat。等于;}

但是可以看出,这种解决方案不仅会入侵实体本身,还需要比较调用者身上重复的、显式的调度事件,不是一个好的解决方案。

也许未来会有一个框架可以让我们既不依赖全局Singleton,也不显式处理事件,但是目前的方案基本都有或多或少的缺陷,在使用中可以注意。

摘要

在真实的业务逻辑中,我们的领域模型或多或少是“特殊”的,如果100%符合DDD规范,可能会很累,所以最重要的是梳理出一个对象行为的影响,然后做出设计决策,即:

是仅影响单一对象还是多个对象, 规则未来的拓展性、灵活性, 性能要求, 副作用的处理,等等

当然,在很多情况下,一个好的设计是多种因素的权衡,需要人们有一定的积累,真正了解每一个架构背后的逻辑、优缺点。一个好的架构师没有正确的答案,但是可以从多个解决方案中选择最平衡的解决方案。

相关阅读
陈德容吻戏 王挺断箭激吻琼女郎陈德容 吻戏长达5分钟
本报讯年代黑帮大戏《断箭》于昨天19:05起在UTV杭州影视频道播出。这部男人戏因为“琼女郎”陈德容的加盟,融入了一丝浪漫气息,而久违荧屏的陈德容在剧中将与王挺上演一出战火纷飞中的凄美爱情。陈德容以《一帘幽梦》、《梅花三弄》等琼瑶剧走红,柔弱善良的苦情角色已成为她的代表性符号,但随着年龄的增长和演技的成熟,清纯的外形反而成为了限制她戏路的障碍。因此坚定直言“不再演琼瑶剧”的陈德容为了寻求突破,在久36在看 07-07
aderma艾芙美官网地址 艾芙美燕麦修复再生霜怎么样
aderma艾芙美是法国的一家皮肤保养产品公司,aderma艾芙美产品最大的特点就是可以供各类问题皮肤使用,在抗刺性、抗发炎、舒缓、保湿、修护等方面拥有着较大的优势,即使是婴幼儿稚嫩的皮肤也可以正常使用,如果你觉得自己的宝宝也需要使用保养皮肤,那aderma艾芙美将是不错的选择,下面我们一起看看aderma艾芙美官网及知名产品艾芙美燕麦修复再生霜效果怎么样是否好用?艾芙美官网地址艾芙美官网地址:516在看 07-07
高楼坠亡洒落现金 市民震惊有好几十万元
【高楼坠亡洒落现金 市民震惊有好几十万元】12月2日早上,广州泽德花苑的居民突然听到砰一声,明显有物体坠楼。跑到外面一看,竟然发现了一名坠楼男子。更奇怪的是,他的身旁散落了一地现金,全部是100元,加起来近几十万元。究竟发生什么事?高楼坠亡洒落现金 市民震惊有好几十万元街坊拍摄的照片可以看到,在楼梯间的石围栏以及楼梯上都散落了不少红色的纸张。照片放大清晰可见,全部是红色的 100 元人民币。而在楼19在看 07-07
王思聪约郭碧婷 两人疑似斟谈大事情(图)
王思聪约郭碧婷吃饭,两人疑似谈工作!近日,网曝国民老公王思聪与同样为国民老公的女神郭碧婷一同约饭,两人在某餐厅火热吃火锅,对于两国民老公同框的画面引众网友热议,甚至有网友表示这个组合简直活久见,也有怀疑王思聪正在追郭碧婷。王思聪和郭碧婷一起吃饭被曝出的画面中郭碧婷扎着一利落马尾,装扮十分清新,一脸素颜但颜值仍高,而王思聪则翘着二郎腿坐在其旁边。大美女郭碧婷超级富二代王思聪其实在早前,郭碧婷因被曝有22在看 07-07
张定涵寇世勋 寇世勋张定涵吻戏 张定涵跟了寇世勋吗
寇世勋可是国内数一数二的老戏骨,他曾经参与拍摄的多部影视作品均深受观众们的喜爱,至于张定涵,有着内地女演员身份的她同样参演了不少影视作品,按说这两个人应该是没什么关系的,但由于大家对寇世勋张定涵吻戏的好奇,使得张定涵跟了寇世勋吗这一问题同样也得到了关注。张定涵寇世勋 寇世勋张定涵吻戏 张定涵跟了寇世勋吗寇世勋张定涵寇世勋是1954年出生的台湾男演员,而张定涵则是1980年出生的内地女星,如果说他们13在看 07-07

热文排行