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

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

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

而我们通常会得到两个答案:

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

上述两种答案,本质上是思想的不同,第一种回答是站在第一人称的视角来审视问题,这种思考方式我们称其为 过程事务脚本

而第二种回答则是分别站在不同 事物 的视角上看待问题,这种思考方式我们称其为 面向对象思想

过程事务脚本,其实就是对问题解决流程的罗列,好处是有的,例如不需要额外的思考成本,写起来简单,入门门槛低等等等等。但从复杂度和事务发展的客观规律来看,它不是最合适的。

注:‘事物发展的客观规律’ 就是指事物往复杂、熵增的方向发展。

为什么这么说,让我们来看一个实际的问题。

从问题出发

假设我们要开发一个商城系统,在设计初期,产品给出了下面需求:

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

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

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重视业务语义,提倡针对业务建立统一的描述语言,系统的设计建立在团队成员对业务的一致认知上,这有利于团队的沟通和交流。

书籍&文章推荐

书籍

文章


zyz123456
1 声望2 粉丝