在面向对象编程中,你是不是经常听到“要实现代码间的职责分离”,但是具体什么样的代码才算得上是清晰的职责分离,似乎却又总是模糊不清。比如:

  • 代码模块越多职责越清晰?

  • 按照需求来分配职责就是职责分离?

  • 模块化就是职责分离?

实际上,要想写出“职责分离”的代码,单从字面含义是很难下手的,因为业界并没有统一的通用标准。比如,什么是职责分离?为什么职责分离很重要?具体如何实现职责分离?

所以,今天我们就一起带着这些问题来学习与理解职责分离。

一、高内聚、低耦合:职责分离的目标

什么是职责?在《敏捷软件开发:原则、模式与实践》这本书中,把“职责”定义为“变化的原因”。如果你能够想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责。

那什么是职责分离?为了更好地理解职责分离,我们先从一个熟悉而又陌生的概念讲起,那就是内聚。

这里我们结合一个例子来说明一下,如下图所示:

如何在代码设计中实现职责分离?_字符串
在该图中,模块按照相对小的功能进行划分(数字表示,比如模块 1),这里我们假设业务领域已经被分析为有三个不同的功能,并放在了一个模块内(叫“我的模块”),其中,模块 A、B、C 之间没有什么共同的职责,分别在独立的数据上运行。

你有没有一种有种曾相似的感觉?没错,常见的 Controller+Service+Dao 里的各种功能多是这样的组织形式,看上去很漂亮的结构,但实际上却是最混乱的,俗称大泥球结构,这也是内聚度很低的一种模式。

观察上面的关系图你会发现,八个模块都依赖着“我的模块”。在这种情况下,如果想要在系统中的其他模块使用功能 A、B 或 C,那么调用就会依赖整个“我的模块”,这显然导致了太多的依赖,大大降低了可维护性。

那么,为了提高内聚性,我们就应该对功能进行分离,如下图所示:

如何在代码设计中实现职责分离?_System_02

很明显,现在每个模块的依赖比原来少了很多,模块 A、B、C 之间没有直接的关系,并且模块 3 是唯一一个依赖模块 A 和模块 C 的模块。这样带来的好处是,当我们依赖 A 或 B 或 C 时,能够清晰地知道它们依赖了哪些模块,也就是下次修改代码时影响的模块有哪些,将变更风险控制在有限范围内。这样才算是做到了真正的高内聚,也就是各个模块专注于自己最重要的某个职责,并建立起与其他模块之间清晰的界限。

所以说,内聚本质上表示的是系统内部的各个部分对同一个问题的专注程度,以及这些部分彼此之间联系的紧密性

你可能也注意到,对同一个问题的专注程度才是判断内聚高低的标准,而职责分离只是实现高内聚的一种方法而已。

那么,现在我们就可以来回答“什么是职责分离”这个问题了。简单来说,职责分离就是将不同变化原因引起的类或方法修改行为拆分到不同类或方法里去

二、职责分离的重要性

那为什么职责分离很重要呢?主要有三点原因。

第一点,直接对问题进行对象建模,方便厘清构建逻辑。在面向对象编程中,通常都会建议你将现实中的事物或问题翻译成对象,这样更“拟人化”,也能更好地进行编码实现,就好比让每一个代码模块能够像人一样具备自己的属性和行为,只需要指定特定的职责就能让各自模块运行良好,而不是像面向过程编程那样把所有的功能都放在一起。比如,针对商品属性相关的问题,我们可以建立商品基本信息对象、赠品信息对象、活动商品对象等各类对象,然后通过不同的职责关联统一起来,这样在修改时就能通过清晰的职责边界来理解代码逻辑关系了。

第二点,将问题分解为各种职责,更有利于系统的测试、调试和维护。比如,开发一个电商系统,你一定不会把所有的系统(订单、物流、商品、支付)都放在一起,因为这样不仅不利于理解系统,而且其中任何一个子系统的代码修改都会影响到别的系统。除此之外,职责分离不够的系统,测试起来也会非常痛苦,因为每一次的修改不敢保证不会影响别的系统,那么就需要测试相关联的系统,这样大大降低了交付效率,同时还会因为测试不充分而出现线上问题。

第三点,提高系统的可扩展性。虽然可扩展性是现代软件设计的必选项之一,但是很多系统在前期时间紧、任务重的情况下几乎都会放弃一部分扩展性,于是矛盾就出现了,系统已经上线,但用户需求却不断变化,这时如果需要添加一些新东西,那么你就需要改动所有没有清晰划分职责的地方,这样势必影响系统的运行。但如果做好了职责划分,那么你就只需要改动具有相应职责的类,而不会影响到系统的其他部分,这样不仅能提高系统的可扩展性,还降低了代码修改引入风险的概率。

三、职责分离的时机

在编码实现中,职责分离的时机大致有三个:

  • 命名太过于笼统;

  • 改动代码后的测试规模很大;

  • 类和方法过大。

首先,命名太过于笼统时是职责分离的好时机。 你一定希望类、方法和其他对象(包、服务等)的命名能够直接反映出它们的作用,同时还要足够简短,方便记忆。如果命名过长或表述模糊,通常可能是因为类或方法包含的职责过多而无法筛选出职责的优先级,这样的类随着修改越多,问题出现的概率也越高。这时就是进行职责分离的好时机,通过将不同职责拆分出来,就能很好地限定问题范围,即便出现问题需要修改也只是在限定范围内修改,不会影响到其他模块。

其次,如果每次修改代码都要重新进行一次全量测试,那么这也是进行职责分离的好时机。“修改一处影响全部”可以说是开发和测试都不愿意面对的情况之一,这说明代码耦合性高、内聚度低。换句话说就是,代码中的职责过多,彼此之间相互影响。这时可以通过修改代码处的职责来进行代码重构,找出合适的职责进行分离,逐步减小全量测试的范围,这样就能减少职责之间的相互干扰。

最后,遇见超大类或方法也是一个时机。 绝大多数情况下,超大的类或方法都是职责划分不清导致的代码过度耦合(当然,有的算法实现本身就很复杂,进而导致出现超长的方法,这种情况不在这次的讨论范围内)。比如,当一个类包含了太多的其他类时,可以用一个简单的原则来判断职责是否过多,那就是:能否拆分出来更多的子类?如果不能,那么这个类很可能就是高内聚的,职责比较单一;如果能,那么这个类还不够内聚,职责还有多余的。

四、如何通过职责分离实现高内聚

职责分离更多的是一种设计思想和编程技巧,主要的理念就是将模糊笼统的问题拆分为多个清晰单一的问题。而实现职责分离的核心技巧就在于寻找互相不重合的的职责

这里我们演示一个通过分离职责实现真正内聚的例子,如下面的反转字符串的处理程序代码所示:

public class Application {
    private static void process(String[] words) {
        for (int i = 0; i < words.length; i++) {
            String arg = "";
            for (int j = words[i].length(); j > 0; j--) {
                arg+=words[i].substring(j-1,j);
            }
            System.out.println(arg);
        }
        if (words.length == 2){
            if (words[0].toLowerCase().equals("hello")
                    && words[1].toLowerCase().equals("world")){
                System.out.println("...bingo");
            }
        }
    }
    public static void main(String[] args) {
        process(new String[]{"test","is","a","mighty,hahaah,world"});
        process(new String[]{"hello","world"});
    }
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.

这是一段非常简单的代码,代码的功能是:接收命令行的任意字符串参数,然后反转每个字符,并检查反转后的字符是否为“hello world”,如果是,则打印一条信息。

虽然这段代码很短,但是它的内聚性非常差。为什么呢?你可以从 process () 方法看出问题所在,process() 这个名称并没有告诉你方法实现了什么操作,而如果要以正规方式命名这个方法,通常会出现类似于这样的名称:reverseCharactersAndTestHelloWorld(),前面我们也说了,命名太过于笼统的通常就是内聚性较差的信号。

继续阅读代码,你也会发现,process() 方法要做的职责太多了,并且这些事情彼此又并不相关。

那么,我们该如何通过职责分离来优化代码呢?直接看代码,如下所示:

public class ApplicationOpt {
    public void process(String[] words) {
        for (int i = 0; i < words.length; i++) {
            reversecharacters(words[i]);
            System.out.println(words[i]);
        }
        if (isHelloWorld(words)) {
            System.out.println("...bingo");
        }
    }
    private String reversecharacters(String forward) {
        String reverse = "";
        for (int j = forward.length(); j > 0; j--) {
            reverse += forward.substring(j - 1, j);
        }
        return reverse;
    }
    private boolean isHelloWorld(String[] names) {
        if (names.length == 2){
            if (names[0].toLowerCase().equals("hello")
                    && names[1].toLowerCase().equals("world")){
                return true;
            }
        }
        return false;
    }
    public static void main(String[] args) {
        ApplicationOpt myApp = new ApplicationOpt();
        myApp.process(new String[]{"test","is","a","mighty,hahaah,world"});
        myApp.process(new String[]{"hello","world"});
    }
}

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.

这时,当你再读 process() 方法时,读到的是一系列步骤,每个步骤都由一个方法来实现,你也能很快区分每个方法具体负责的职责是什么。如果字符的反转不正确,你会知道去 reversecharacters() 方法里查找 Bug,因为反转字符的职责已明确分配给了合适的方法,而且只有这个方法执行这个操作。

虽然这个例子很简单,但是它给我们一个很重要的启示:按照职责进行分离能帮助我们转移对问题的关注点。关注点其实就是用户关注的核心点,比如,例子中如果不分配职责,关注点就是处理字符串,但是处理字符串有很多方法和步骤,关注点太笼统了。而一旦开始尝试分离职责,你就会发现关注点发生了转移,也就是问题范围变小了。不过,在很多编码实现过程中,又很容易省略这个“转移”。因为哪怕是抽取一个方法,都会多耗费写代码的时间,而很多人实际上不愿意花这个时间,想等到有时间时再来优化。可等真的有时间时却发现代码逻辑千丝万缕,职责多到超出想象,根本优化不动。所以说,分离职责是一种将复杂问题拆分成可解决问题的有效方法

文章(专栏)将持续更新,欢迎关注公众号:服务端技术精选。欢迎点赞、关注、转发