关于编码:聊聊领域驱动设计与编码思想

9次阅读

共计 4524 个字符,预计需要花费 12 分钟才能阅读完成。

在开始之前,让咱们回顾一下万恶之源:

把大象装进冰箱须要几步?

应该有很多计算机系的敌人对这个问题印象粗浅吧,它是大部分大学在传授面向对象这门课程时用来抛砖引玉的第一问。

而咱们通常会失去两个答案:

  • 须要三步,先关上冰箱门,而后把大象放进冰箱,而后关上冰箱门。
  • 须要三步,冰箱打开门,大象走进冰箱,冰箱关上门。

上述两种答案,实质上是思维的不同,第一种答复是站在第一人称的视角来扫视问题,这种思考形式咱们称其为 过程事务脚本

而第二种答复则是别离站在不同 事物 的视角上对待问题,这种思考形式咱们称其为 面向对象思维

过程事务脚本,其实就是对问题解决流程的列举,益处是有的,例如不须要额定的思考老本,写起来简略,入门门槛低等等等等。但从复杂度和事务倒退的客观规律来看,它不是最合适的。

注:‘事物倒退的客观规律’就是指事物往简单、熵增的方向倒退。

为什么这么说,让咱们来看一个理论的问题。

从问题登程

假如咱们要开发一个商城零碎,在设计初期,产品给出了上面需要:

用户提交订单后,后盾计算订单商品总金额,保留订单商品条目快照,锁定库存而后生成订单并回显。

于是研发部依据需要写出了第一版程序:

class OrderService is
    method createOrder(createOrderInputDto): Order is
        // 从订单创建对象中解构所须要的数据

        // 计算订单总金额
        // 保留订单商品条目快照
        // 锁定库存

        // 创立订单并返回

程序上线后,因为用户激增,单体式利用很快便满足不了宏大的用户量的需要,于是产品部要求研发部进行服务拆分,进一步晋升零碎并发申请量,而后第二版程序就进去了:

class OrderService is
    method createOrder(createOrderInputDto): Order is
        // 从订单创建对象中解构所须要的数据

        // + 调用近程服务获取商品数据
        // 计算订单总金额
        // 保留订单商品条目快照
        // - 锁定库存
        // + 调用仓储服务锁定库存

        // 创立订单并返回

忽然有一天,经营一拍大腿,决定搞一个优惠活动:

用户生产时,依据用户会员等级和单笔生产金额进行返利,返利间接补贴进单笔生产订单总金额中,并且容许用户能够应用优惠券叠加计算优惠金额。

繁忙的程序猿们再次扛起键盘筹备战斗,于是最新的程序又进去了:

class OrderService is
    method createOrder(createOrderInputDto): Order is
        // 从订单创建对象中解构所须要的数据

        // 调用近程服务获取商品数据
        // + 调用近程服务获取用户会员等级权利信息
        // 计算订单总金额
        // + 计算会员等级优惠及返利
        // + 再次计算订单总金额
        // + 计算叠加优惠券后的金额

        // 保留订单商品条目快照
        // 锁定库存
        // 调用仓储服务锁定库存

        // 创立订单并返回

在这之后,脑洞大开的经营时不时会想到一些离奇的创意,研发部门充斥了高兴的空气 …

那这么做有什么问题呢?

从整体来看,在 Service 层沉积的代码,不只是业务代码,还包含了应用服务调度,数据库操作,框架的权限认证等一系列跟业务不相干,而是跟技术层面强相干的逻辑。数据库操作跟应用服务的调度高度耦合于本服务,这样从久远来看显著是不好的。

接下来让咱们深刻到 createOrder 这个办法,来看看其中的局部代码:

此处假如咱们曾经开始创立订单 Model 了

Order order = new Order();
order.setUserId(xxx);
order.setItems(xxx);
order.setPrice(xxx);
...
...

下面代码是对业务需要的形容,在商城零碎中是由对应一个创立订单的语义化行为来表述的,但在此处转为了对 Model 的赋值操作,而且这些赋值操作并没有解决值的限定,那这就很可能产生一个谬误的后果,例如咱们将 Price 这个价值单位给予了一个负数值。

可能有的人会说 setPrice 是对类中变量的封装,咱们能够在这个函数中做赋值的校验解决。然而社区给出的大多数实际,还是在 service 层面对业务做校验的比拟多。

从另外一个层面来思考,这样的代码依赖于开发人员对业务的了解,但咱们不能保障每个开发人员对业务的了解都是正确的,因而很容易呈现开发人员不晓得是否应该对某个字段赋值,或赋错值的状况。

这实质上是因为 对业务的表述在转化为 <u> 过程事务脚本 </u> 的这个过程中失落了,咱们在业务代码编写的过程中,自然而然的将业务中带有语义化的行为形容,转化成了对 Model 中某些变量的赋值,这导致了语义的失落。

过程事务脚本与面向对象思维

正如前文所说,过程事务脚本容易失落语义,那么有没有什么办法能解决这个问题呢?答案是有,就是面向对象思维。

传统面向对象思维认为:

程序的实质是对事实的建模与形象

让咱们带入古代社区的惯例做法来看看当下是否合乎 面向对象 这个概念。

在传统软件开发畛域中,面对业务时惯例的做法是 依据产品经理给出的业务需要以及交互原型,划分出零碎功能模块并设计出各功能模块对应的数据库表。在这个设计流程中咱们次要思考的是隐含在业务中的属性,具体的业务交互流程和其中对象的行为则被拆散到了 MVC 三层中的控制器中。

而在游戏开发的畛域,惯例的做法是基于游戏中被设计的主体对象进行建模,最终造成的是带有属性以及行为的 对象模型,一个非常活泼的例子如下:

在这个例子中,咱们筹备设计一个仿照英雄联盟的游戏,其中针对英雄的需要形容如下:

  • 英雄有血量以及魔法值
  • 英雄的血量和魔法值会随工夫缓缓复原
  • 英雄每开释一次技能就会损失肯定水平的魔法值,魔法值为 0 则不能开释技能
  • 英雄能够应用普通攻击来重创对手
  • 当血量降落为 0 时,英雄死亡

咱们能够针对下面的需要来实现对于“英雄”这个对象的建模:

class Hero is
    // 英雄的血量
    property Blood: Float
    // 英雄的魔法值
    property Magic: Float
    
    // timer 用于管制英雄血量以及魔法值的复原
    var _timer: Timer
    
    /**
     * 英雄被攻打事件
     */
    method UNDER_ATTACK(who, how) is
        // 计算新血量
    
    /**
     * 英雄死亡事件
     */
    method HERO_DIED() is
        // 调用析构函数,销毁某个英雄对象
    
    /**
     * 结构一个新英雄
     */
    method constructor is
        // 初始化主动回复 timer
        
    /**
     * 英雄的攻打办法
     */
    method attack(target) is
        // 公布攻打英雄的事件
        
    /**
     * 英雄开释技能的办法
     */
    method release(skill) is
        // 产生技能开释成果
        // 碰撞检测
        // 触发攻打成果
        
    /**
     * 初始化主动回复 timer
     */
    private method initialTimer()

从下面代码咱们不难发现,英雄 这个模型外部不止有属性,还有动作行为以及事件,这种将对象的业务行为一并封装进模型的做法显著更为天然,后续业务的迭代与变迁显然也更好保护。

那么如果咱们应用解决传统软件的设计方法来设计下面那个游戏,会怎么样呢?

首先咱们须要遍历整个地图中的所有英雄,而后挨个解决它们的血量跟魔法值复原的逻辑。在某个英雄产生攻打状态时,咱们须要再次遍历整个地图中的所有英雄,挨个进行碰撞检测,而后解决扣血以及死亡断定的逻辑。剩下的不必我多说我想你们也能设想的进去吧 …

并且,当咱们将动作行为应用过程事务脚本构建当前,业务显著变的简单了,也产生了很多说不通,不好保护的点。

所以,当咱们回过头来反思,传统软件行业是否做到了 传统面向对象 这一概念,咱们也曾经有答案了。

贫血模型与充血模型

说了这么多,实际上导致设计向着过程事务脚本或面向对象思维倒退的根本原因只有一点,那就是 模型

后面说过,程序是对事实世界的形象与建模,计算机最后也是为了解决生存中的根本需要而被发明进去。传统开发模式下,咱们始终保持着数据库为主的准则,所有的编码思路都围绕既定的数据库表进行实现,也因而咱们将数据与解决逻辑进行拆散,数据被管制在 Model 层内,逻辑则被拆散到了 Controller 层中,这样的 Model 咱们称其为 贫血模型 ,因为它只有属性而没有行为。

基于面向对象思维思考而失去的模型,其外部既蕴含属性,又蕴含指标对象的行为和事件,因而也被称为 充血模型 ,充血模型具备以下长处:

  • 独立,可移植
  • 残缺
  • 自蕴含

所以充血模型在研发时能够独自进行单元测试,以此确定畛域业务逻辑解决的正确性,同传统的甩锅准则一样。

传统的甩锅准则:前端基于 Mock 数据实现页面需要,在没有跨域问题的前提下,如果后端接入之后产生了问题,锅肯定是后端的。

这也就是说,在单元测试可能保障畛域对象的充血模型没有问题的前提下,如果最终接口实现有问题,问题肯定出在除 Model 层以外的其余层面上,这很好的隔离了畛域业务和技术逻辑的关注点。

古代软件架构之父 Martin Fowler 认为贫血模型是一种反模式,因为软件开发流程中的建模须要对应于特定畛域的业务,而这种拆散畛域逻辑与数据表白的做法,有悖于天然衍生的设计法令。

对于继续演进,频繁迭代的业务来说,充血模型是比拟好的抉择,但它也有以下毛病:

  • 设计难度高
  • 须要设计人员对业务高度相熟
  • 不稳固的充血模型会带来其余层面代码的频繁变动

绝对的,如果业务需要比较简单,显然贫血模型是更好的抉择。

畛域驱动设计

畛域驱动设计【Domain Driven Design】(下文简称 DDD),是:

  • 一套残缺的模型驱动软件设计工具,用于简化大型软件我的项目的复杂度。
  • 一种设计思路,能够利用在简单业务的软件设计中,放慢交付进度。
  • 一组提炼进去的准则和模式,有助于进步团队成员的软件外围业务设计能力。

为什么咱们要学习 DDD

有助于划分微服务

DDD 通过划分畛域,并将划分后的畛域限定在上下文中,以此来达到隔离关注点的目标。这里的上下文,在 DDD 中就被称为 限界上下文

就好比生物学中的细胞,细胞之所以能存在,是因为细胞膜定义了什么在细胞内,什么在细胞外,并且确认了什么物质能够通过细胞膜。

子域内的每个畛域模型都有它对应的限界上下文,畛域内所有限界上下文(蕴含外部的子·畛域模型)独特形成了整个畛域的畛域模型。咱们将限界上下文对应微服务进行映射,就实现了整个微服务的划分。

升高简单零碎迭代的难度

简单零碎之所以难以迭代,是因为传统基于数据库进行设计的形式无奈限定子系统中的“变数”,这些变数在零碎迭代的任何一个阶段都可能成为畛域崩塌的要害。

软件存在的意义就是它可能解决事实中存在的问题,DDD 中一个次要的步骤就是对业务表述的问题进行梳理与划分,将大的问题划分成若干个小问题,而后逐个解决。

这样的形式能够最大水平的限度子问题中的变数,从而达到升高迭代复杂度的目标。

进步研发团队合作的效率

传统设计思维跟 DDD 相比最大的差异在于:DDD 器重业务语义,提倡针对业务建设对立的描述语言,零碎的设计建设在团队成员对业务的统一认知上,这有利于团队的沟通和交换。

书籍 & 文章举荐

书籍

  • 《畛域驱动设计:软件外围复杂性应答之道》- Eric Evans
  • 《实现畛域驱动设计》- Vaughn Vernon
  • 《解构畛域驱动设计》- 张逸

文章

  • 从 CQS 到 CQRS (译)
  • DDD 中的那些模式 — CQRS
  • [ABP 框架官网指南](
正文完
 0