1

简介: 软件开发为什么需要职责驱动设计(RDD)?职责应该如何分配?如何结合架构模式在实际开发中实践落地?本文介绍一种通用的职责分配模式——GRASP,通过举例详解GRASP的几大原则,并分享两个实际运用的案例。

image.png
软件在本质上是复杂的,软件本身的复杂性在于除了要解决问题域,还要解决非功能性需求和软件域特有问题:安全性、可用性、可维护性、可扩展性、性能、一致性、容错性、稳定性、可重用性、幂等、兼容等等,软件开发者的任务就是制造“简单”的假象。如何组织复杂的系统?把复杂的事物分解到不同的层次中,层次代表了不同级别的抽象,一层构建于另一层之上,每一层都对上层屏蔽内部复杂度。

一 为什么使用RDD?

在RDD中,我们认为“软件对象具有职责”,这个定义很符合人在社会群体中分工协作的方式,软件也是人编写的,所以根据职责思考设计的软件系统符合人的行为习惯,同时更易于理解和管理。在微服务架构中不同系统由不同的组织和人负责,把系统当作对象(人),系统提供的接口就是对象(人)的职责。

职责驱动设计的核心是考虑怎样给对象分配职责,其适用于大到系统、小到对象等任何规模的软件。职责分配的本质是分工,劳动分工是劳动生产率提高的主要原因。

  • 熟练度的提高,专注于某个领域(降低复杂度)。
  • 时间的节约,同一个人在不同工作来回切换需要耗费大量时间。
  • 人工发明的机器和应用(特定领域的工具)。

二 如何给对象(元素)分配职责?

分配职责应当从清晰的描述职责开始,对于软件领域对象来说,领域模型描述了领域对象的属性和关联,对应类的属性和引用,用例模型包含一系列的行为活动,对应类的方法。领域模型创建方式可参考《UML和模式应用》、UDD、DDD。

使用GRASP(General Responsibility Assignment Software Patterns)模式分配职责,GRASP是通用职责分配模式,是对一些基本的职责分配原则进行了命名和描述,共9种模式(一些GRASP原则是对其他原则和设计模式的归纳,设计模式有上百种,只是记住GoF 23种设计模式就已经很困难了,更别提还要记住每种模式的细节,因此需要对设计模式进行有效的归类。GRASP中的原则描述了模式的本质,除了有助加速设计模式学习之外,对发现现有设计存在的问题也更有效,这就是归纳的价值)。

当谈论低耦合、高内聚时,我们具体是在谈什么?问题不在于耦合度高、内聚性低,而是在于其产生的负面影响,负面影响往往是在发生变化时体现出来的,这些负面影响会影响到我们开发的效率、稳定性、可维护性、可扩展性、可复用性等等,整个GRASP的核心是如何防止变异(变化)。

在学习过程中发现GRASP缺少结构化的展示归纳结果,通过我自己的理解把开发中常用的GoF设计模式、面向对象设计原则、架构设计原则和GRASP进行关联:

image.png

三 GRASP职责分配模式

1 防止变异

该模式基本等同于信息隐藏和开闭原则。如何做到在不修改原来功能的前提下对变化的部分进行扩展?识别不稳定因素是特别困难的,也决定了我们能否做出符合开闭原则的设计。

问题:如何设计对象、子系统和系统,使其内部的变化或不稳定性不会对其他元素产生不良影响。

解决方案:识别预计变化或不稳定之处,分配职责用以在这些变化之外创建稳定接口。

相关原则和模式:

  • GRASP:间接性、多态
  • GoF:大量模式
  • 其他:接口、数据封装

2 低耦合、高内聚

耦合是对某元素与其他元素之间的连接、感知和依赖程度的度量,内聚是对元素职责的相关性和集中度的度量(这里的元素指类、系统、子系统等等),耦合和内聚是从不同角度看待问题,他们互相依赖的互相影响的(以下两点也可以反过来说):

  • 内聚过低,相关功能分散在不同模块中,需要增加额外的耦合使这些功能聚合在一起,发生变更时影响多个模块。
  • 内聚过高,不相关的功能聚集在一个模块中,耦合度高,发生变更时会产生意想不到的影响。

image.png

低耦合

耦合是对某元素与其他元素之间的连接、感知和依赖程度的度量。这里的元素指类、系统、子系统等等。

问题:怎样降低依赖性,减少变化带来的影响,提高重用性?

解决方案:分配职责,使耦合尽可能低。利用这一原则评估可选方案。

相关模式或原则:

  • GRASP:防止变异

注意:耦合不能脱离专家、高内聚等其他原则独立考虑。

紧密耦合的系统在开发阶段有以下的缺点:

  • 一个模块的修改会产生涟漪效应,其他模块也需随之修改(通常是内聚低引起的)。
  • 由于模块之间的相依性,模块的组合会需要更多的精力及时间,可复用性低(通常是耦合高引起的)。

解读:耦合表示元素之间存在依赖,当谈论“耦合高”时,我们具体是在谈论什么呢?是依赖产生的负面影响,所以低耦合的核心是解决不良依赖。高低是度量并不是评判耦合结果好坏的标准,使用“不良耦合”、“松耦合”描述的更为准确。不良耦合产生的负面影响主要有两点:

  • 依赖关系本身错综复杂难以维护和理解,很容易产生遗漏和问题(这点针对人,人处理复杂性事物时能力是局限的)。
  • 与不稳定元素产生依赖时很容易受到变化的影响(通常无法避免不依赖)。

那么如何做呢?先对依赖关系的好坏进行评估:依赖方式、依赖方向、依赖链路。

image.png

方向:

  • 双向依赖(差)
    • 相互依赖的两个元素不能独立行动,在微服务系统架构的系统中类级别不会产生特别复杂的问题,但是在模块 or 系统级别就特别容易受到变化带来的影响。
    • 举例:A <-> B,A调用B的b接口,B的b接口依赖A的a接口,如果a b接口都要变更,两个系统如何发布?A依赖B先发布,B也依赖A先发布,相互依赖的两个元素不能独立行动。
  • 循环依赖(更差)
    • 循环依赖比双向依赖的的链路更长,影响的范围更大。
  • 单向依赖(好)

链路:

  • 深度
    • B调用A.getC().getD().getE().getF() 获取到F。
  • 广度
    • 在链路变宽的过程中不加以约束和管理很容易产生大杂烩的元素,也很容易产生双向和循环依赖。

方式:

  • 内容耦合(高)
    • 当一个模块直接使用另一个模块的内部数据,或通过非正常入口而转入另一个模块内部。
  • 共享耦合/公共耦合(高)
    • 指通过一个公共数据环境相互作用的那些模块间的耦合。
    • 公共耦合的复杂程度随耦合模块的个数增加而增加。
  • 控制耦合(中)
    • 指一个模块调用另一个模块时,传递的是控制变量(如开关、标志等),被调模块通过该控制变量的值有选择地执行块内某一功能;
  • 特征耦合/标记耦合(中)
    • 指几个模块共享一个复杂的数据结构,如高级语言中的数组名、记录名、文件名等这些名字即标记,其实传递的是这个数据结构的地址;
  • 数据耦合(低)
    • 指模块借由传入值共享数据,每一个数据都是最基本的数据,而且只分享这些数据(例如传递一个整数给计算平方根的函数)。
  • 非直接耦合(低)
    • 两个模块之间没有直接关系,它们之间的联系完全是通过主模块的控制和调用来实现的。耦合度最弱,模块独立性最强。
  • 无耦合(无)
    • 模块完全不和其他模块交换信息。

解决不良依赖:

  • 管理复杂的依赖关系
    • 依赖方向:使用单向依赖,去除或弱化双向依赖,不使用循环依赖。
    • 依赖链路:遵守最少认知原则。
    • 依赖方式:尽量使用数据耦合,少用控制和特征耦合,控制公共耦合的范围,不使用内容耦合,如果依赖的对象不稳定使用非直接耦合来弱化耦合紧密程度。
  • 分配正确的职责减少不必要的依赖:专家、创建者。
  • 通过其他原则和模式减少不稳定元素带来的影响:高内聚、纯虚构、控制器、多态、间接性、最少认知。

高内聚

内聚是对元素职责的相关性和集中度的度量。

问题:怎么样保持对象是有重点的、可理解的、可维护的,并且能够支持低耦合?

解决方案:按照相关性分配职责,可保持较高的内聚。

优点:

  • 分解后的元素更加简单易于理解和维护。
  • 按照相关性拆分可以提高重用性。

相关原则和模式:单一职责原则、关注点分离、模块化。

低内聚的缺点:内聚性较低的类要做许多不相关的工作,或需要完成大量的工作,这样的类会导致以下问题:

  • 难以理解
  • 难以复用
  • 难以维护
  • 经常会受到变化影响

image.png

例子:A的变更影响从3个模块变为1个。

image.png

小结

通过结构化管理来保持低耦合、高内聚。

image.png

3 创建者

创建者指导我们分配那些与创建对象有关的职责。如此选择是为了保持低耦合。

问题:谁应该负责创建某类的新实例?

解决方案:满足以下条件之一时,将创建类A的职责分配给类B(当满足1条以上时,通常首选包含或聚合)。

  • B“包含”或聚合A。
  • B记录A。
  • B频繁使用A。
  • B具有A的初始化数据,该数据将在创建时传递给A。

优点:支持低耦合,因为创建者和被创建者已经存在关联,所以这种方式不会增加耦合性。

相关模式或原则:

  • GRASP:低耦合
  • GoF:具体工厂、抽象工厂
  • 其他:整体-部分

注:包含(作者在这里标注了“”,因为包含在uml是表达用例关系的,用来说明对象关系也可以)、聚合、整体-部分 看UML定义;包含强调了强依赖(A是B的子集,A属于B,缺少了A时B不是整体),聚合是弱依赖(B由A组成,A不属于B)。

例子:
image.png

  • Order包含Goods(Order脱离Goods就失去了完整性,没有存在的意义)。
  • Order记录相关的Goods。
  • Goods初始化数据:
    • 情况一:只需要订单上的Goods数据,这种情况Order具有Goods的初始化数据。
    • 情况二:订单上的Goods数据不完整,这种情况Order只有Goods初始化数据的一小部分,Order不能做为创建者。

4 信息专家(or 专家)

“信息”不单指数据。

问题:给对象分配职责的基本原则是什么?

解决方案:把职责分配给信息专家,它具有实现这个职责所必需的信息

优点:

  • 对象使用自身信息来完成任务,所以信息的封装性得以维持,因此支持了低耦合(至少不会增加耦合性)。
  • 行为分布在那些具有所需信息的类之间,这样功能更集中,因此支持了高内聚。

相关模式或原则:

  • GRASP:低耦合、高内聚

注意:和“关注点分离”一起使用使得对象进一步内聚,从而达到高内聚,也能降低耦合。

举例:获取所有买的商品总金额,Order和Goods是一对多的关系。

image.png

分析:Order本身关联了Goods,并且理解Goods的结构。在图例中Client通过Order获取了Goods并做了逻辑运算得出商品总金额,这种做法产生了不必要的依赖增加了耦合数量,商品总金额计算的职责由Order承担最合适。

image.png

延伸:在某些情况下,该方案并不合适,通常是由于耦合与内聚问题产生的,如:谁应该把对象A存入数据库?按照原则每个类都应该具有把自己持久化的能力。

5 纯虚构

为了保持良好的耦合和内聚,捏造业务上不存在的对象来承担职责。

问题:当你并不想违背高内聚和低耦合或者其他目标,但是基于专家模式所提供的方案又不合适时,哪些对象应该承担这一职责?

解决方案:对人为制造的类分配一组高内聚的职责,该类并不代表问题领域的概念--虚构的事物,用以支持高内聚、低耦合和复用。

优点:

  • 支持高内聚,因为职责被解析为细粒度的类,这种类只着重于极为特定的一组相关任务。
  • 增加了潜在的复用性。

相关原则和模式:

  • GRASP:低耦合、高内聚。
  • 通常接纳本来是基于专家模式所分配给领域类的职责。
  • 所有GoF设计模式都是纯虚构,事实上所有其他设计模式也都是纯虚构。

举例:计算商品总数量。根据专家模式计算商品总数量的职责也应该是分配给Order,照这样分配下去商品相关的还会有:总重量、总体积、总XX,这时Order的职责就会越来越多也可能会产生额外的耦合,通过纯虚构对象把这些职责分配出去能够得到更好的设计。

image.png

通过虚构对象GoodsItems承担和商品聚合计算相关的职责。

延伸:经常发现代码中会使用Util、Handler、Service这样的虚构类,缺点是这些类通常是单例并共用的,这些虚构类的职责会越来越多(一个Util类2000行代码),创建和业务更相近的虚构对象才能便于理解和管理耦合关系。

6 控制器

解决方案:把职责分配给能代表以下选择之一的类:

  • 代表整个“系统”、“根对象”、运行软件的设备或主要子系统,这些是外观控制器的所有变体。
  • 代表用例场景,在该场景中发生系统事件。

相关模式:

  • GRASP:纯虚构
  • GoF:命令、外观
  • 其他:层

控制器的核心是提供一个统一入口,避免客户对元素内部进行耦合,很好的维护了边界:

  • api层
  • 根对象
  • 接口

7 多态

问题:如何处理给予类型的选择?如何创建可插拔的软件构件?

解决方案:当相关选择或行为随类型有所不同时,使用多态操作为变化的行为类型分配职责。

优点:可扩展性强,同时不影响客户。

相关原则和模式:

  • GRASP:防止变异
  • GoF:大量模式

订单退款时需要计算出用户退款金额和商户扣款金额,在没有新零售业务进来之前直接使用计算服务返回的数据结构,新零售进来后数据结构未统一,需要进行适配,实现多态后的代码扩展性很强。

image.png

在微服务架构中,比较复杂的多态问题通常会选择增加一层去解决,如:支付网关、交付网关。

8 间接性

计算机学科中的大多数问题都可以通过增加一层解决,如果不行再加一层。反过来大多数性能问题都可以通过去掉一层来解决。

问题:为了避免两个或多个事物之间直接耦合,应该如何分配职责?

解决方案:将职责分配给中介对象,使其作为其他构建或服务之间的媒介,以避免他们之间的直接耦合。

优点:实现了构件之间的低耦合。

相关原则和模式:

  • GRASP:防止变异、低耦合、大量间接性中介都是纯虚构
  • GoF:大量模式

注意:间接性通常用来支持防止变异。

四 架构模式

除了职责分配原则,还需要一些架构模式帮助我们更好的落地。

1 分层架构

在分布式系统中系统是独立存在的,可以单独变更而不对其他系统产生影响,但是随着业务整体复杂度的提升也带来了一些负面影响:由于整体被分解成大量独立的系统,随着复杂度提升系统之间的依赖关系会变的错综复杂,某个系统的变更会影响其他系统,同时也会产生意想不到的问题,效率也随之下降。这时就需要重新对分布式系统的逻辑架构做设计,以解决系统间的不良耦合和内聚,从而提效。

分层架构是非常实用和常见的方式,TCP/IP、HTTP、操作系统等等都运用了分层,分层的本质很简单:通过分离关注点,达到高内聚;通过向下依赖、拒绝循环依赖、使用接口,达到低耦合。

image.png

分层架构也是存在缺点的:按照分层架构定义消息消费应该在基础设施层,但是消息消费是为了执行某个业务逻辑,这样就需要依赖应用层 或 领域层,如果真的这样写就会出现循环依赖问题。通过依赖倒置可以解决依赖问题。

2 六(多)边形架构(洋葱圈架构)

六边形架构(Hexagonal Architecture),又称为端口和适配器架构风格,其中的“六”具体数字没有特殊的含义,仅仅表示一个“量级”的意思,六边形的定义只是方便更加形象的理解。

image.png

六边形架构提倡用一种新的视角来看待整个系统,该架构中存在两个区域:“外部区域”和“内部区域”。在外部区域中不同的客户均可以提交输入(网络请求、定时脚本、消息消费等),而内部区域则是处理具体逻辑的地方。

image.png

五 案例

案例1:Jpa替换为Mybatis

@Component
public class CloseOrderService {
    @Autowired(required = false)
    @Qualifier("rstOrderTransactionManager")
    JpaTransactionManager tm;
    
    public void invalid_order(Long orderId, Long userId, Short processGroup)
        throws UserException, SystemException, UnknownException {
        //其他逻辑。。。省略
        
        // 开启事务
        DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        TransactionStatus ts = tm.getTransaction(def);

        try {
            order = orderDAO.get(orderId);
            order.setStatusCode(toStatus);
            order.setUpdatedAt(new Timestamp(System.currentTimeMillis()));
            orderDAO.save(order);
            //提交事务
            tm.commit(ts);
        } catch (Exception e) {
            if (!ts.isCompleted()) {
                //回滚
                tm.rollback(ts);
            }
            if (e instanceof SatisfiedStateException) {
                return;
            }
            throw e;
        }
    }
    @Transactional(transactionManager = "rstOrderTransactionManager", rollbackFor = Exception.class)
    public void invalidOrder(){
    }
}

@Component
public interface OrderDAO extends JpaRepository<OrderPO, Long> {
    @Query(value = "sql语句", nativeQuery = true)
    Long generateGlobalOrderId(@Param("userId") Long userId, 
                               @Param("restaurantId") Long restaurantId, 
                               @Param("seqName") String seqName);
} 

变化带来的影响:如果不出意外对Jpa的使用方式不会产生变更,意味着其相对稳定,所以在当前阶段来看以上耦合是正常的也不会产生负面影响。但是在以下场景会让我们对高耦合有很明显的体感:大家觉得Jpa不好用,想替换为Mybatis该怎么做?代码中直接使用了继承JpaRepository的OrderDAO做数据操作,由于Jpa和Mybatis的写法不同,所以需要把使用到OrderDAO的地方都做替换:

  • 调用OrderDAO的类(70多个类)都需要替换为新的dao。
  • 使用JpaTransactionManager.getTransaction()的位置需要替换为MyBatis的TransactionManager。
  • 使用@Transactional(transactionManager = "rstOrderTransactionManager")的位置需要改为编写事务提交和回滚的代码块儿,便于做灰度。
  • 以上改动的位置需要增加开关做灰度。

结论:由于变更涉及到70多个类,同时事务管理器获取方式也需要修改,其带来的影响还是挺大的,不满足“低耦合”原则,可以使用“多态”原则重新设计。

案例2:订单对应的支付单应该由谁来创建?

拿饿了么交易系统举例,当前创建支付单的职责是由bos服务承担(面向app的一个后端服务)的,接下我们进行分析。

image.png

支付单创建分为两种场景:

  • 创建订单和支付单是在一次操作中完成。
  • 用户回到订单列表页点击“去支付”时创建支付单。

支付单创建依赖:

  • 订单号
  • 支付金额
  • 支付类型
  • 一堆支付系统分配的用于识别业务的参数

image.png

注1:如果饿了么只会有外卖一种交易业务,当前的设计还是很稳定的,不会出现太大变化。所以识别变化点才能更好的评判当前系统设计是否合理,如:饿了么将升级为本地生活服务公司,根据公司战略多少能看出我们将来不只外卖业务存在,还会有很多和本地生活相关的交易业务,这些业务会有自己的展示层(app、h5、web)同时对应会有类似bos的服务,如果有10个业务方,在支付场景就需要去对接10次,而由order做就只需要一次(支付作为工具已经比较稳定,不会有太大变化)。

  • bos比order多出识别订单结构的成本。
  • bos比order多出认知交易域业务知识的成本。需要深入了解交易状态,这样才知道什么状态才能去支付(一般是去问order服务的开发),打破了边界。

结论:bos服务不应该承担创建支付单的职责,由order承担最合适。


阿里云开发者
3.2k 声望6.3k 粉丝

阿里巴巴官方技术号,关于阿里巴巴经济体的技术创新、实战经验、技术人的成长心得均呈现于此。