软件设计第一步——分离关注点和单一职责原则

《软件设计第一步——分离关注点和单一职责原则》源站链接,阅读体验更佳~
《什么是代码质量》一文中我们介绍了什么是软件的内部质量和外部质量,对于我们程序员来说,我们需要对软件的内部质量负责,通俗地讲就是我们需要对自己编写的代码质量负责。同时我们也介绍了最常用的几个代码质量的评判标准。

想要让我们写出来的代码符合高质量代码的标准,其实是有一些设计原则进行指导的,设计原则是指导我们代码设计的一些经验总结,这也是我们写出高质量代码的内功心法

当然,作为内功心法的设计原则,很多听起来都是比较抽象的,定义描述也都比较模糊,不同的人解读会有不同的理解。

而有的设计原则从定义上看都非常容易理解,甚至当你看到它们的时候都会嗤之以鼻的说本来不就应该是这样的吗,但是当我们真正进行编码实践的时候又往往会把它们抛之脑后。

所以,对于设计原则如果我们只是单纯记忆定义,对于编程能力和代码设计能力的提高是微乎其微的,甚至有可能因为对设计原则的理解不够深刻,导致在应用设计原则的时候过于教条主义,把原则当成了真理,生搬硬套,适得其反。对于设计原则,我们需要掌握其设计初衷,能解决那些编程问题,有什么样的应用场景。我们也可以学习一些具体的招式,比如GoF设计模式,从这些招式中体会它们对设计原则的应用和取舍,只有这样,我们才能在编码中灵活运用这些原则。

一提到软件设计原则,你可能立马就能想到面向对象的五大设计原则:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖倒置原则等,这五个原则因为它们的首字母正好依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母,所以又被称为SOLID原则(英文Solid有稳定的含义)。其他的比较常用的设计原则还有DRY原则、KISS原则、YAGNI原则、LOD法则等。

这篇文章中,我们先从单一职责原则(SRP)开始我们对设计原则的学习,同时我也认为,SRP和软件设计的开始——分离关注点密不可分,从它开始介绍设计原则非常合适。

分离关注点——划分知识边界

现在的软件系统所承载的业务逻辑越来越多,相对应的,软件系统的代码量也就越来越大。在《什么是代码质量》一文中我们也提到过,软件设计是对抗需求规模和代码量规模的算法,而在算法中我们解决问题最常用的思路就是分治法。

软件设计也是一样的,当我们构建一个大型软件项目的时候,通常会被整体的复杂性所淹没。人类其实是不善于管理这种复杂性,我们所擅长的是在特定的问题范围内寻找解决方案,所以我们通常会将复杂的系统拆分成许多子系统,每个子系统负责实现部分特定的功能。

这种拆分对应的就是软件设计中一个常见的说法——“分离关注点(Separation of concerns)”。传统上,我们习惯的分解问题的方式是自顶向下的树形结构,比如,按功能分解,可分为:功能 1、功能 2、功能 3,等等,然后,每个功能再分成功能 1.1、功能 1.2、功能 2.1、功能 3.1 等等,以此类推,这样整个系统就有如下所示的一个树形结构构成:

img

比较顶层的模块划分是基于产品架构的,是对不同产品功能模块的划分,当我们把一个大的功能分解成多个小功能的时候,每一个小功能就是一个不同的关注点,就像我们上图中表示的那样;而随着模块的细化,技术架构就会慢慢参与进来,开始考虑产品的实现,这个时候各种非功能性的需求也会不断涌现,而对于技术来说,最细粒度的划分可能就是一个一个的类和函数了。而当我们落实到具体的实现的时候,就不仅需要考虑功能性的需求,还需要考虑非功能性的需求,比如数据如何存储,模块之间如何通信等等。功能性需求和非功能性需求本质上是不同维度的事情,所以它们也是不同的关注点

当我们把复杂系统拆分为一个个子系统,把每个子系统所负责的问题都解决好之后,再把这些子系统以恰当的方式组合起来。所谓组合,其实是建立在分解之上的,甚至我们可以认为组合其实是更高层级的一种分解,直到组合到软件系统的最顶层,这种分解才算完成。

然而,当我们在设计的时候,重点考虑的往往是如何组合的问题,相信你在编写代码的时候往往会为“这个方法需要放在哪个类里面”、“这个类放在哪个包里面”这样的问题发愁。其实我们思考的这些问题本质上还是在进行分解,只不过这个分解是在方法粒度之上,或者是类粒度之上的更大粒度的一种分解。

为什么这么说呢?**因为分离关注点最本质上是对各个子系统和模块职责的划分,也就是对知识边界的规定。**如果我们已经把每个模块的职责都划分清晰了,那么诸如“这个方法需要放在哪个类里面”、“这个类放在哪个包里面”这样的问题其实就迎刃而解了。在进行分解的时候,每一个关注点都有一个自己的知识集合,我们只要把握好了关注点,分解的过程就会变得非常自然,很容易就可以把知识描述出来。所以,只有当我们做好分解的工作之后,我们才能更好地进行组合,一步一步地搭建起整个复杂的软件系统。

你可能会想:“分解?很简单呀,不就是把一个大系统拆分成若干个子系统,再把子系统拆分成若干个模块,一层一层拆下去吗”。但是事实上,分解远没有表面上那么简单,它深刻地影响着软件设计的方方面面,从最根本上影响着我们的软件架构。分解其实是非常考验设计人员对技术和业务的整体理解的,同时也非常考验设计人员的经验积累。因为要把握好知识边界的规划,需要掌握好适当的粒度,如果分解的粒度太大,必然会导致我们把不同的东西混淆在一起,知识的边界就会变得模糊。

那在做设计的时候,应该如何考虑分解呢?**上文中我们提到关注点其实就分为两类,一类是由产品架构和业务背景所决定的不同功能,另一种就是由技术架构所主导的功能性需求和非功能性需求。**对于作为程序员的我们来讲,尤其需要注意的是功能性的需求和非功能性需求的关注定分离。程序员最常犯的错误就是认为所有问题都是技术问题,总是试图用技术解决所有问题。任何试图用技术去解决其他关注点的问题,只能是陷入焦油坑之中,越挣扎,陷得越深。

下面,我们就基于这两种关注点分别给出一个反例,来帮助大家进行理解。

一个业务分解的失败案例

我所在的公司有两个平台系统,用户使用的时候总是抱怨,两个平台都是你们的,但是功能却有所区分,我在登陆一个系统寻找一个功能半天都没有找到,才发现这个功能是另一个平台的。在接到的抱怨多了之后,领导决定对两个平台进行融合,融合的方案就是两个系统互相把自己的功能节点同步到对方的系统中。由于种种原因,两个平台之间不能直接进行通信,必须借助一个中间系统进行通信。

考虑到数据量并不是很大,也就几千条的样子,最终的实现方案是两个平台各提供两个接口,一个查询接口,负责查询出本系统中所有的功能节点并转换成约定好的数据结构;一个保存接口,保存完成之后需要告知中间系统有哪些数据已经包保存了(每一条数据都有一个唯一标识),一个保存了多少数据等信息。中间系统先调用A系统的查询节点查询出A系统的数据,然后调用B系统的保存接口把A系统的数据同步到B系统中,这个过程中中间系统会记录有哪些数据已经同步过了,在查询出A系统的数据之后会把B系统已经保存过的数据过滤掉,以实现数据的增量同步。而且把同步任务做成了一个定时任务,每天的凌晨运行一次。

这个方案乍一看好像没有什么问题,其实在后续的数据同步过程中问题频出。首先中间系统需要负责记录A系统和B系统已经保存了的对方的数据,增量数据的判断逻辑被划分到了中间系统中。这就使得在初次同步之后的后续的同步过程中,只有新增的数据可以被另一个系统感知到,而对于被修改的数据,被删除的数据,则永远无法通知到对方的系统中。

其实在这个划分方案中存在明显的知识边界不清晰的情况,对于A系统来说,它保存过了那些B系统的数据,应该是A系统内部的知识,对于B系统也同样如此。在上面的划分方案中,却把这部份知识强加给了中间系统,让中间系统负责增量逻辑。

由于让中间系统负责增量逻辑存在的一系列问题,而且考虑到需要同步的数据量并不是很大,所以我们最后重新划分了职责,中间系统只负责A系统和B系统的数据沟通,它只负责从一个系统查询出全部的数据,然后把全部的数据传递给另一个系统,由另一个系统内部进行数据的合并操作。

经过职责的重新划分之后,知识边界变得更加清晰了,中间系统也变得更加纯粹了。将增量逻辑交还给系统本身负责之后,我们不仅可以实现增量的更新,同时数据的删除和数据的更新也得以实现。

一个功能性需求和非功能性需求分解的失败案例

我们有一个故障频出的订单清结算系统,一开始我们认为清结算系统本身就是一个业务规则比较多的系统,偶尔出现故障也是在所难免的。

但是在进行仔细分析之后我们发现,这个系统设计的及其复杂,清结算系统和其上游系统存在双向依赖关系:首先,原始的订单是由上游系统请求清结算系统产生的,在上游系统处理完自己的业务逻辑之后,会定时把一段时间内产生的订单消息的处理结果推送给清结算系统。在最初的实现中,开发人员发现上游系统向清结算系统中推送消息的时候,消息有可能会丢失(有可能是时间间隔处理的不太合适,所以造成了漏数据的现象),于是他们设计了一个补偿机制。

因为推送过来的数据是由清结算系统产生的,所以清结算系统中保存这这些数据的原始信息,于是他们要求上游系统提供一个查询接口。当清结算系统接收到一个新的通知消息之后,如果发现最晚的订单之前有订单没有接收到处理结果,那么它就会去上游系统请求这些没有处理过的订单的状态,来把这些状态补齐。

正是因为这个补偿机制的产生,导致了后来的一系列问题,首先,非常明显的,上游系统和清结算系统之间存在直接的双向依赖,在完成一个业务的时候,双方需要进行双向的通信;再者,当系统业务量增加的时候,数据库访问的压力本身就很大,但在这种场景下,丢数据的概率也增加了,用于补偿的线程也会频繁访问数据库,因为它要找出丢失的数据,还要把请求回来的数据写回到数据库里。也就是说,一旦业务量上升,本来就已经很吃力的系统,它的负担就更重了,系统出现卡顿也就在所难免了。

这个补偿机制的设计是有问题的,问题的点在于,上游系统向下游推送消息,这应该是一个通信层面的问题。而在原有的设计中,这个问题被带到了业务层面。这是一个典型的功能性需求和非功能性需求的关注点没有分离的例子。开发人员只考虑了业务功能,忽视其他维度。技术和业务被混在了一起,随之而来的就是无尽的后患。

一旦理解了这一点,我们就可以想办法解决了。既然是否丢消息是通信层面的事,我们就争取在通信层面解决它。比如我们可以选择一个吞吐量合适的消息队列,在未来可见的业务量下,消息都不会丢。通信层面的问题在通信层面解决了,业务层面也就不会受到影响了。

上面我只讲了这个故事的主线,其实,相关的事情还有一些。比如,上游系统专门为补偿而开发的接口,现在也不需要了,于是上游系统得到了简化;同时,清结算系统和上游系统之间的直接双向依赖被破除了,系统的耦合度得到了降低。

单一职责原则

其实,通过上面的描述我们也能发现,在我们对复杂系统进行分解,分离关注点的时候,面临的大多数问题其实都是分解粒度太大造成的。其实在分离关注点的过程中,有一个软件设计原则可以比较好地指导我们避免分解粒度过大的问题,它就是单一职责原则。

单一职责原则的英文是Single Responsibility Principle,缩写为SRP。这个原则的描述是这样的:一个模块只负责完成一个职责(或者功能)。

这里的模块是一个非常抽象的概念,它可以应用于任何粒度,比如我们可以认为一个类是一个模块、一个方法是一个模块,在一个比较大的系统被划分为多个子系统的时候,每一个子系统也可以认为是一个模块。

不管是什么粒度下的模块,单一职责的道理都是一样的,我们上文也提到过,对于编码来说,最细粒度可能就是一个一个的类和方法了,所以这里我们就以类这个粒度对单一职责原则进行说明。

单一职责原则的定义描述非常简单,也不难理解,一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小,功能单一的类。换句话说,一个类包含了两个或者两个以上的关注点,那么我们就说它的职责不够单一,应该对其进行拆分。

举一个简单的例子,比如一个类中即包含订单的一些操作,又包含用户的一些操作。但是其实订单和用户是两个独立的业务领域模型,我们将不相干的两个功能放到同一个类中,那么就违反了单一职责原则,这个时候我们需要将这两个类进行细化,将它拆分为单独的订单类和用户类。

如何判断模块的职责是否足够单一

通过上面的介绍,单一职责原则似乎并不难应用,但是事实往往没有那么简单。还是那句话,我们编程是基于特定的业务场景的,不同的场景下,对粒度的要求可能也是不一样的。我们用一个更加贴近实际的例子来解释一下。

在一个社交产品中,我们用下面的UserInfo类来记录用户的信息,其代码大致如下:

public class UserInfo {
  private long userId;
  private String username;
  private String email;
  private String telephone;
  private long createTime;
  private long lastLoginTime;
  private String avatarUrl;
  private String provinceOfAddress; // 省
  private String cityOfAddress; // 市
  private String regionOfAddress; // 区 
  private String detailedAddress; // 详细地址
  // ...省略其他属性和方法...
}

你觉得,对于UserInfo类,它是否满足单一职责原则呢?

其实对于社交软件这个场景来说,UserInfo类是基本满足单一职责原则的。在这里争议比较大的可能是用户的地址信息是否需要单独的类来进行表示,对于社交软件这种应用来说,用户的地址信息就类似于现实中我们的籍贯信息,每个用户的地址信息都是唯一的,可能只会用来进行展示,并不会涉及其他的业务逻辑,这个时候我们就没有必要对用户的地址信息进行单独的封装。

但是,如果我们把社交软件换成电商软件,或者是我们的社交软件发展的比较好,现在我们为其增加了电商模块,这个时候用户的地址信息就需要单独进行封装了,因为用户的地址可能会涉及发货等业务逻辑,同时用户可以为自己保存不止一个地址信息,比如我们可以把公司的地址和家的地址同时保存起来,在购买商品的时候选择商品的邮寄地址。

我们在进行一步延伸,如果这个社交产品发展的越来越好,同时公司内部还有其他的很多产品,这个时候,我们希望以这款社交产品为中心构建一个软件生态,公司的任何产品都可以使用我们的社交软件进行单点登录,就像现在的QQ和微信一样,这个时候我们可能就需要对UserInfo再一次进行拆分了,将跟身份认证相关的信息(比如,email、telephone 等)抽取成独立的类。

由此可见,**在不同的场景下、不同的业务背景下,对同一个类是否满足单一职责原则的判定,可能都是不一样的。这是因为在不同的场景下,关注点必然是不同的,即使是类似的关注点,其中所包含的知识点也是不尽相同的。**在某种应用场景或者是当前的业务背景下,一个类的设计可能已经满足单一职责原则了,但是如果换一个业务场景,或者在未来的某个业务背景下,它可能就不满足单一职责原则了,这个时候我们需要根据实际的需求将其拆分成更细粒度的类。

从上面的分析来看,要判断一个类是否符合单一职责原则并没有那么简单,它没有一个非常明确的可以量化执行的标准,有时候它是一个非常主观的事情。实际上,我们在进行设计的时候没有必要过于未雨绸缪(这就是我们后面会介绍的YAGNI原则),我们可以先写一个粗粒度的类,在遇到合适的时机的时候再对其进行拆分和细化。

那么我们有什么可以量化的标准来判断拆分的时机吗?我这里有几条小的建议:

  • 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
  • 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
  • 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
  • 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
  • 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。

不过,你可能还会有这样的疑问:在上面的判定原则中,我提到类中的代码行数、函数或者属性过多,就有可能不满足单一职责原则。那多少行代码才算是行数过多呢?多少个函数、属性才称得上过多呢?实际上,这个问题并不好定量地回答,就像你问大厨“放盐少许”中的“少许”是多少,大厨也很难告诉你一个特别具体的量值。

其实,软件设计原则都是提高代码质量的经验,都是为了代码的可读性,可维护性、可复用性和扩展性等负责的;个人认为代码的可读性是我们首先需要兼顾的,应为可读性是高质量代码最基本的特点,如果一段代码我们读起来都费劲,那么它大概率就是不好维护、不好扩展、不好复用的

所以,当一个类的代码,读起来让你头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数、函数、属性过多了。实际上,等你做多项目了,代码写多了,在开发中慢慢“品尝”,自然就知道什么是“放盐少许”了,这就是所谓的“专业第六感”。

类的职责是否越单一越好?

提到类的职责,我们很容易想到类的功能,也就是类对外暴露的方法。那么类的单一职责是否就意味着一个类只应该对外暴露一个方法呢?这明显是错误的。换言之,为了满足单一职责原则,是不是把类拆分得越细越好呢?答案也是否定的。我们通过一个序列化和反序列化的例子来说明一下:

/**
 * Protocol format: identifier-string;{gson string}
 * For example: UEUEUE;{"a":"A","b":"B"}
 */
public class Serialization {
  private static final String IDENTIFIER_STRING = "UEUEUE;";
  private Gson gson;
  
  public Serialization() {
    this.gson = new Gson();
  }
  
  public String serialize(Map<String, String> object) {
    StringBuilder textBuilder = new StringBuilder();
    textBuilder.append(IDENTIFIER_STRING);
    textBuilder.append(gson.toJson(object));
    return textBuilder.toString();
  }
  
  public Map<String, String> deserialize(String text) {
    if (!text.startsWith(IDENTIFIER_STRING)) {
        return Collections.emptyMap();
    }
    String gsonStr = text.substring(IDENTIFIER_STRING.length());
    return gson.fromJson(gsonStr, Map.class);
  }
}

上面的Serialization 类实现了一个简单协议的序列化和反序列功能,如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。拆分后的具体代码如下所示:

public class Serializer {
  private static final String IDENTIFIER_STRING = "UEUEUE;";
  private Gson gson;
  
  public Serializer() {
    this.gson = new Gson();
  }
  
  public String serialize(Map<String, String> object) {
    StringBuilder textBuilder = new StringBuilder();
    textBuilder.append(IDENTIFIER_STRING);
    textBuilder.append(gson.toJson(object));
    return textBuilder.toString();
  }
}

public class Deserializer {
  private static final String IDENTIFIER_STRING = "UEUEUE;";
  private Gson gson;
  
  public Deserializer() {
    this.gson = new Gson();
  }
  
  public Map<String, String> deserialize(String text) {
    if (!text.startsWith(IDENTIFIER_STRING)) {
        return Collections.emptyMap();
    }
    String gsonStr = text.substring(IDENTIFIER_STRING.length());
    return gson.fromJson(gsonStr, Map.class);
  }
}

虽然经过拆分之后,Serializer 类和 Deserializer 类的职责更加单一了,但也随之带来了新的问题。如果我们修改了协议的格式,数据标识从“UEUEUE”改为“DFDFDF”,或者序列化方式从 JSON 改为了 XML,那 Serializer 类和 Deserializer 类都需要做相应的修改,代码的内聚性显然没有原来 Serialization 高了。而且,如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。

**其实,一个关注点并不是只有一个知识点,它其实是一系列相关性非常强的知识点,这个时候我们在圈定知识的边界的时候,应该把它们圈定在一起。**判断几个知识点是否需要被划分为同一个关注点其实是比较容易的,就像上面的序列化和反序列化一样,序列化和反序列化都需要依赖IDENTIFIER_STRING和Gson,这个时候我们如果强行把序列化和反序列化的职责拆分开来,就会造成知识的重复,这就违反了我们接下来将要介绍的一个非常重要的软件设计原则——DRY原则。

总结

这篇文章中,我们介绍了软件设计中至关重要的第一步——分离关注点。在我们进行关注点分离的过程中,单一职责原则可以指导我们避免分解粒度过大的问题。但是,在我们分解的过程中也不能一味追求细粒度,我们所应该追求的应该是合适的粒度,因为一个关注点并不是一个单一的知识点,它可能是一系列相关的知识点,甚至有可能一个关注点内的多个知识点的变化必须是保持同步的,一个知识点的变化可能会引起其他的一个或者多个知识点的变化,所以对关注点的识别是至关重要的。从这个角度上来说,单一职责原则可能被称为单一关注点原则会更加恰当。

同时,一个关注点其本身所包含的知识点可能会比较复杂,这会导致我们的代码量很难控制的住,这个时候我们可以基于代码的可读性、可维护性、可复用性等代码质量的考虑上,对我们的代码进行一个模块的划分,然后通过组合的方式重新把这些相关的只是点组合为一个完整的关注点。

在我们分离关注点的过程中,一个复杂的系统会被分解成多个子系统,如果子系统还是太过复杂我们可能还会进一步进行拆分。就这样不断将复杂的系统拆分成更小粒度的子系统和组件,我们最终会切分到一个层次,在这个层次上原本那些复杂的单元被精简为一个具有单一职责的单元,我们称这样的单元为最小粒度单元

当我们把系统拆解为一个一个最小粒度单元的时候,这些最小单元之间可能会不可避免的出现重复或者高度相似的情况,对于这种重复的情况,我们也需要多加注意,因为重复或者高度相似可能本身就意味着他们是同一个知识点,这种情况下这个知识点如果发生变化,就会产生大麻烦,对于这个问题,DRY原则可以给出比较好的解决方案,DRY原则也是接下来我们将要介绍的一个非常重要的软件设计原则。

以上就是我分离关注点和单一职责原则的基本理解,本人深知自己技术水平和表达能力有限,文章中一定存在不足和错误,欢迎与我进行交流(laomst@163.com),跟我一起讨论,修改文中的不足和错误,感谢您的阅读。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

劳码识途

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值