{T}

设计分析篇

← 01 设计认知篇 | 03 编程语言篇 →

一句话概述:理解一个软件的设计应按"先模型、再接口、最后实现"的三步顺序,逐层展开,在脑中构建一棵"设计树"。


知识图谱

图表渲染中…

图注:设计分析篇四部分内容——分析方法论、模型分析(Spring DI)、接口分析(Rails)、实现分析(Kafka)。


2.1 三步走:模型、接口、实现

经过前面几讲的铺垫,已经对软件设计的定义及需考虑的因素有了初步了解。接下来,进入正式的分析流程。

开发者在职业生涯中难免需要接手新项目并承担维护职责。当面对一个新项目时,应当如何开展研究工作?

多数开发者的第一反应是阅读源代码。然而直接深入代码细节,很快便会迷失其中,最初的探索热情也会逐渐被困惑所取代。回顾过往经验,有多少次满怀期望地打开一个开源项目,结果往往坚持不久便放弃。问题的根源何在?

产生困惑的原因在于缺乏对软件整体的认知,这如同未携带地图与指南针便闯入密林,迷路仅是时间问题。因此,尽管阅读源码是必经环节,但不应作为首要步骤。应当先从了解软件的设计入手。进一步而言,如何了解一个软件的设计呢?

模型、接口和实现

了解软件的设计可从三个维度着手:模型、接口和实现。三者的关系类似于代码阅读方式:首先查看有哪些类以及类之间的关系,此即模型层面;其次打开具体类,查看其提供的方法,此即接口层面;最后打开具体方法,查看代码实现细节,此即实现层面。

接下来,逐一分析各部分。

首先是模型,它是软件的核心组成部分。在其他文献中亦被称为抽象,为统一表述,本文统称为模型。前文已述,设计的关键在于构建合理的模型。理解设计中的模型有助于建立对该软件的整体认知。

例如,编写分布式计算程序时,需考虑如何在不同节点上调度计算任务;使用 MapReduce 时,仅需考虑如何将计算拆分(Map)再汇总(Reduce);而使用 Spark 时,关注点则集中于需执行的计算逻辑本身。三者解决相同问题,但抽象层次逐步提升,越来越接近待解决的问题本身,越来越少地涉及计算在不同机器上的执行细节,从而降低了理解门槛。

理解模型的重要性之后,视野甚至可不局限于单一软件。若将同一领域不同发展阶段的多个模型联系起来观察,还可洞察软件发展的演进趋势。

其次是接口,它决定了软件以何种方式将模型所提供的能力暴露给外部,是与软件交互的入口。具体而言:

  • 程序库的接口即其 API。对于同一模型,不同开发者会设计出不同的 API,不同 API 的表达能力也存在差异。例如:Google 的 Guava 对 JDK 的部分 API 进行了重新封装,其目的在于简化开发流程,其中诸多优秀实践后来又被 JDK 所采纳。
  • 工具软件通常情况下会提供命令行接口,命令行工具的使用是开发者必备的基本技能——Unix 命令行工具即为典型的命令行接口。
  • 业务系统的接口体现为对外暴露的各种服务接口,例如各类 REST API,或提供给其他系统调用的 RPC 接口。
  • ……

若希望深入源码了解某一软件,接口是一个有效的切入点。可通过某一接口进入软件内部,观察其如何完成各项基本功能。

最后是实现,指软件所提供的模型和接口在内部的具体技术实现方式,这是软件能力得以发挥的基础。具体而言:

  • 业务系统收到请求后,是将信息写入数据库,还是转发至其他系统;
  • 算法实现是调用已有的程序库,还是需要自行实现特定算法;
  • 系统中的哪些功能应采用分布式架构,哪些应由中央节点统一处理;
  • 业务处理逻辑应采用单线程还是多线程模式;
  • 当存在资源竞争时,由各节点自行处理,还是交由中间件统一协调;
  • 不同系统之间的连接应采用何种协议,是自己实现还是引入中间件;
  • ……

"实现"层面的内容较为丰富,每一项技术决策均应结合具体应用场景的特点进行考量,不存在通用的解决方案。在实际工作中,许多人所认为的"设计"实际上属于此处所述的实现范畴。

"实现"固然重要,但它必须建立在模型和接口的基础之上。在系统设计中,模型是最核心的部分。若模型发生改变,该软件的本质也随之改变;而接口通常反映的是模型的结构。因此,模型和接口的稳定性均高于实现,实现需要随着软件的发展不断调整。

举例而言,众多开发者熟知 Redis 作为键值存储具有卓越的性能表现,学习 Redis 时对其单线程模型印象深刻,因其简洁高效。随着使用场景的扩展,对 Redis 提出了更多需求。因此从 6.0 版本起,Redis 开始支持多线程版本以更好地满足需求。即便 Redis 改为多线程架构,它仍然是 Redis,其模型和接口保持不变,仅实现方式发生了变化。

了解设计三步走

将模型、接口和实现加以区分的原因在于三者的关注点各不相同,而许多人在讨论所谓的"设计"时常将三者混为一谈。

若在讨论过程中连"讨论的内容究竟属于哪个层面"都未能明确,则难以得出清晰的结论。许多讨论之所以混乱,根源在于将不同层面的内容混杂在一起。

正确的做法是:讨论设计时应遵循一定顺序,先模型,再接口,最后是实现;同理,了解一个设计也应遵循该顺序。

图表渲染中…

若尚未明确模型便贸然进入细节讨论,则难以区分哪些内容是核心且必须保留的,哪些内容是可以替换的。若清晰了解了模型,便可知道哪些内容在系统中具有广泛的适用性,哪些内容必须予以隔离。简言之,明确模型有助于界定实现的适用范围。

下图是一张简化后的架构图:订单模块完成处理后,通过 Kafka 队列将消息发送至支付模块,支付模块处理完成后,再通过 Kafka 队列将消息发送至物流模块。许多开发者在实际项目中见过类似但更为复杂的架构图。该图存在的问题是什么?

图表渲染中…

该架构图的问题在于将模型和实现混淆在一起。图中订单、支付和物流涉及的均为模型层面的概念,但 Kafka 的出现将实现层面的内容引入其中。Kafka 仅为实现该功能时的技术选型,这意味着若随着业务的发展 Kafka 无法有效承担其角色,即可将其替换,而整体设计无需变更。

因此,编写实现代码时,必须将与 Kafka 相关的代码进行封装,不应在系统各处随意调用,因为它属于实现层面,具备被替换的可能性。

还需强调一点:了解设计时应按照层次结构逐层展开,因为设计通常是分层的。每当展开某一层次以了解其内部时,仍需按照模型、接口和实现的顺序进行分析。

以操作系统为例,其内部包含内存管理、进程调度、文件系统等模块。可按照模型、接口和实现的顺序理解每个模块,以进程管理为例:

  • 进程管理的核心模型包括进程模型和调度算法;
  • 其接口包括进程创建、销毁以及调度算法触发等操作;
  • 不同调度算法即为具体的实现方式。

操作系统课程学习难度较大,很大程度上源于许多学习者未能理清各概念之间的相互关系。

即便逐层展开至最细粒度——具体到某个类乃至某个数据结构,仍可按照模型、接口和实现的结构进行理解。以 Java 面试常考查的 HashMap 为例:

  • 其模型对应数据结构课程中所学的 HashMap 概念;
  • 它定义了若干接口,如 get、put 等;
  • 其实现最初采用标准的 HashMap 实现,后续借鉴了红黑树进行优化。

实际上,当能够逐层地理解设计时,就如同知识树逐步展开一般,每个知识节点在展开时都会呈现下一层级更具体的内容。当脑海中形成了这样一棵设计树,也就掌握了整个系统的全局视图,此后再有新需求到来时,便不会盲目地修改代码。

图表渲染中…

图注:在每个层次都按"模型→接口→实现"展开,形成一棵完整的"设计树"。

核心要点:了解设计,先模型,再接口,最后是实现。


2.2 模型分析:Spring DI 容器

前文讨论了如何了解软件的设计,主要从三个维度入手:模型、接口和实现。接下来的三讲将结合几个典型的开源项目,说明如何具体理解软件的模型、接口和实现。

本文首先讨论了解设计的第一步:模型。当接手一个项目时,应当如何理解其模型?

首先需要了解项目提供了哪些模型,以及模型提供了怎样的能力。 这是基本要求,但仅了解设计的结果并不足以支撑后期对模型的维护工作。

在项目开发中,经常出现新人随意向模型添加内容或修改实现的情况,导致模型变得难以维护。造成这一现象的根本原因在于对模型的理解不够深入。

任何模型都是为了解决问题而存在的,因此理解一个模型需要了解在该模型出现之前问题是如何解决的,才能知道新模型究竟带来了怎样的提升。换言之,理解一个模型的关键在于了解其设计的来龙去脉,明确它是如何解决相应问题的。

下面以 Spring 的 DI 容器为例,说明如何理解软件的模型。

耦合的依赖

Spring 在 Java 领域具有重要影响力。当前从事 Java 开发而不使用 Spring,通常会被视为不合常规。

当前许多开发者将 Spring 视为一个成熟的框架,很少对其设计进行深入分析。但从 0.8 版本就开始接触 Spring 的开发者有幸见证了 Spring 从小规模框架发展为庞大生态的过程,得以体会 Spring 给行业带来的思维方式的转变。

如果说 Spring 这棵参天大树有一个稳健的根基,那便是 Spring 的 DI 容器。DI 是 Dependency Injection 的缩写,即"依赖注入"。Spring 的各个项目均是在这一根基之上发展而来的。

进一步而言,DI 容器要解决的是什么问题?它解决的是组件创建和组装的问题。为何这是一个需要解决的问题?这就需要先了解组件的创建和组装过程。

前文已述,软件设计必然包含分解的过程,因而也必然面临组装的过程——即将分解出的各个组件组合起来,完成所需功能。

为便于叙述,后续采用 Java 语言进行描述。

从一个最常见的查询场景开始。假设存在文章服务(ArticleService)提供根据标题查询文章的功能。数据需要进行持久化存储,因此还存在 ArticleRepository 用于与持久化数据进行交互。

java
class ArticleService {
  //提供根据标题查询文章的服务
  Article findByTitle(final String title) {
    ...
  }
}
 
interface ArticleRepository {
  //在持久化存储中,根据标题查询文章
  Article findByTitle(final String title);
}

在 ArticleService 处理业务逻辑的过程中,需要借助 ArticleRepository 完成相关功能,即 ArticleService 依赖于 ArticleRepository。此时应当如何处理?一种直接的做法是在 ArticleService 中增加字段表示 ArticleRepository。

java
class ArticleService {
  private ArticleRepository repository;
 
  public Article findByTitle(final String title) {
    // 做参数校验
    return this.repository.findByTitle(title);
  }
}

目前看来一切正常,但接下来问题出现了:该字段如何初始化?开发者通常情况下最直接的反应是直接创建对象实例。此处选用数据库版本的实现(DBArticleRepository)。

java
class ArticleService {
  private ArticleRepository repository = new DBArticleRepository();
 
  public Article findByTitle(final String title) {
    // 做参数校验
    return this.repository.findByTitle(title);
  }
}

表面上看没有问题,但实际上 DBArticleRepository 并不能如此初始化。正如该实现类的名称所示,此处需要使用数据库。在实际项目中,受资源限制,通常情况下不会在应用中随意建立数据库连接,而是选择共享数据库连接。因此 DBArticleRepository 需要接收数据库连接(Connection)参数。此处决定通过构造函数传入该参数。

java
class ArticlService {
  private ArticleRepository repository;
 
  public ArticlService(final Connection connection) {
    this.repository = new DBArticleRepository(connection);
  }
 
  public Article findByTitle(final String title) {
    // 做参数校验
    return this.repository.findByTitle(title);
  }
}

代码编写完成,表面上一切正常。但当进入测试阶段就会发现,要让 ArticleService 运行起来,就必须让 ArticleRepository 也运行起来;要让 ArticleRepository 运行起来,就必须准备数据库连接。

准备数据库连接之后,真正开始编写测试时才发现,要进行测试还必须在数据库中准备各种数据。例如,测试查询功能时需要插入一些数据,验证查询结果与插入的数据是否一致;测试更新功能时需要先插入数据,运行测试后再验证数据更新是否正确。

准备了一堆数据之后,困惑随之而来:要测试的是服务层,数据准备工作难道不应该是测试仓库层的职责吗?

问题的根源在哪里?实际上从创建对象的那一刻起,问题就已经产生了。

分离的依赖

为什么说从创建对象开始就出现问题了呢?

因为在创建对象时必须指定一个具体的实现类,在此处即 DBArticleRepository。尽管 ArticleService 本身编写得较为规范,其他部分并不依赖于 DBArticleRepository,仅在构造函数中存在依赖,但依赖关系本身依然存在。

与此同时,由于构造 DBArticleRepository 的缘故,还引入了 Connection 类,该类仅与 DBArticleRepository 的构造有关,与 ArticleService 的业务逻辑毫无关联。

仅仅因为引入了一个具体的实现类,就需要将其周边的所有配套组件全部引入进来,而这些内容与该类本身的业务逻辑没有任何关系。

这好比原本计划购置一套家具,却必须了解树木种植、砍伐加工、家具设计与组装的全套流程,而目标仅仅是获得一套可用的家具而已。

上述情况还只是最简单的场景,在实际项目中,构建一个对象可能还会涉及更多的复杂因素:

  • 根据不同的参数创建不同的实现类对象,可能需要用到工厂模式;
  • 为了监控方法的执行时间,需要为被依赖的对象添加监控机制;
  • 被依赖的对象来自某个框架,自身并不清楚具体的实现类是什么;
  • ……

即便是看似简单的对象创建和组装过程,实际上也并非如表面看起来那样简单。

既然直接构造存在诸多问题,那么最简单的解决方案就是将对象的创建过程分离出去,仅保留字段关联的过程:

java
class ArticleService {
  private ArticleRepository repository;
 
  public ArticleService(final ArticleRepository repository) {
    this.repository = repository;
  }
 
  public Article findByTitle(final String title) {
    // 做参数校验
    return this.repository.findByTitle(title);
  }
}

此时,ArticleService 仅依赖于 ArticleRepository。测试 ArticleService 也变得简单,只需用一个模拟对象替代 ArticleRepository 的行为即可。通常情况下此类模拟工作可采用现成的程序库来完成,这就是 Mock 框架所能提供的功能。

或许有人会问:在之前的代码中,如果用 Mock 框架模拟 Connection 类是否可行?理论上确实可行。但若要让 ArticleService 的测试通过,就必须打开 DBArticleRepository 的实现,配合其中的实现细节才可能让 ArticleService 运行起来。这显然偏离了测试的正确方向。

此时,对象的创建已经分离出去,但仍需有某处完成这项工作,最简单的解决方案自然是将所有对象的创建和组装集中在一处完成:

java
...
ArticleRepository repository = new DBArticleRepository(connection);
AriticleService service = new ArticleService(repository);
...

相较于业务逻辑,组装过程并无复杂的部分,纯粹是一个又一个对象的创建与传参过程,这部分代码显得较为机械。虽然单调,但这部分代码至关重要,最佳的解决方案是由框架来完成此项工作。在 Java 领域中,这种负责组装一组对象的组件通常被称为"容器"。

java
Container container = new Container();
container.bind(Connection.class).to(connection);
container.bind(ArticleReposistory.class).to(DBArticleRepository.class);
container.bind(ArticleService.class).to(ArticleService.class)
 
ArticleService service = container.getInstance(ArticleService.class)

至此,一个容器就此诞生。由于它解决的是依赖管理的问题,将被依赖的对象注入到目标对象中,因此得名"依赖注入"(Dependency Injection,简称 DI),该容器也因此被称为 DI 容器。

上述代码与 Spring DI 容器的形式并不完全一致,但其原理是一致的,仅在接口表现形式上存在差异。

事实上,这种创建和组装对象的方式在当时引发了广泛讨论,直到 Martin Fowler 发表《反转控制容器和依赖注入模式》一文,才对业界的讨论进行了总结,行业自此达成了共识。

在那段时间里,DI 容器得到了快速发展,众多开源项目纷纷推出了各自的 DI 容器实现,Spring 是其中最具影响力的一个。不过 Spring 并未止步于此,而是在这个小内核的基础上发展出了更多的功能,从而形成了今天庞大的 Spring 生态系统。

进一步而言,这与"模型"有何关联?

正如前面所述,许多开发者习惯于将对象的创建和组装写在同一个类中,导致的结果是代码中出现了大量的耦合。时至今日,许多项目仍在犯同样的错误。许多项目测试困难,原因正在于此。这也从另一个侧面印证了可测试性的作用——在第 3 讲中曾提到:可测试性是衡量设计优劣的重要标准之一。

在 DI 容器出现之前,开发处于相对原始的阶段。有了 DI 容器之后,代码中仅保留关联逻辑,对象的创建和组装全部由 DI 容器完成。甚至在不知不觉间,形成了一个尚可接受的设计:至少做到了面向接口编程,实现是可以替换的,同时也是可测试的。与前一种方式相比,这是一种截然不同的思维方式,而这正是 DI 容器这一模型所带来的转变。

此外,一旦建立了容器的概念,它还可以不断增强。例如,若希望为所有与数据库相关的代码添加耗时监控,只需在容器构造对象时添加相应的处理逻辑即可。这就是 AOP(Aspect Oriented Programming,面向切面编程)的处理方式,而业务代码对此并无感知。

Spring 的流行对于提升 Java 领域整体编程质量具有显著的促进作用。因为它引导的设计方向是良性的方向,普通的 Java 开发者只要遵循 Spring 引导的方向,编写的程序基本质量便能得到保障,远超以往随意编码的时代。

不过,如果不能认识到 DI 容器所引导的方向,就无法充分利用其优势。更值得警惕的是,不能低估部分开发者的破坏力——许多开发者即便在使用 Spring 之后,仍然自行构造对象、滥用静态方法,将原本合理的设计破坏得支离破碎。

综上所述,只有理解了模型设计的来龙去脉,清楚认识到其所解决的问题,才能更好地运用该模型解决后续遇到的问题。作为项目的维护者,才能更好地扩展该模型以适应未来的需求变化。

图表渲染中…

图注:DI 容器将对象创建从业务代码中分离——从"自己创建"变为"声明依赖,容器注入"。

核心要点:理解模型,要了解模型设计的来龙去脉。


2.3 接口分析:Ruby on Rails

前文以 Spring 的 DI 容器为例,讲解了如何理解项目的模型。在模型之后,下一步应当分析接口。

在任何项目中,接口的数量都相当可观。即使是一个普通的程序库,其中的接口少则几十个,多则成百上千。显然,逐一阅读这些接口并非理解接口的有效途径——拥有近 20 年 Java 开发经验的开发者,对于 JDK 中的许多类仍然不了解,甚至有些类还未来得及了解就已经过时。

进一步而言,如何从纷繁复杂的接口中梳理出清晰的脉络?方法是:找主线,看风格

找主线的含义是:找到一条功能主线,建立对项目结构的整体性认知,而非一开始就将精力投入到每一个接口的细节之中。对细节的了解会随着对项目理解的深入而逐渐增加,而有了主线之后便有了着力点,可以不断地深入探索。

但所要学习的不仅仅是这些接口的使用方法,若希望从项目的接口设计中学到更多内容,就需要关注它所引导的风格——它期望使用者如何使用它,或者如何在它的基础上继续进行开发。

从项目的接口风格中不难看出设计者的专业水准。编程常被视为一门艺术,在接口设计中便能窥见一斑。这些内容是学习软件设计时应当细细品味的。

关注风格的另一个重要原因是保持项目的一致性,必须建立统一的风格。不少项目中并存着多种不同风格的接口,这是由于每位开发者都在按照自己的习惯设计接口,势必导致混乱。

Ruby on Rails 模型

对于较年轻的开发者而言,Ruby on Rails 这个名称可能较为陌生。但在十多年前,它初出茅庐之时,给行业带来了巨大的冲击。只是后来由于行业环境的变化,编程模型发生了重大转变,使其失去了行业领导地位。

从模型角度而言,Rails 是标准的基于 MVC 模型进行开发的 Web 框架。在这方面并无特殊之处,给行业带来巨大冲击的是其接口设计。

Rails 一个重要的设计理念是约定优于配置——无需额外配置,按照默认约定即可完成基本功能,这一理念贯穿于 Rails 各个接口的设计之中。

接下来分析 Rails 的接口。前文提到理解接口时应先找主线,找到项目主线的有效方法是从快速入门文档开始,因为它会将项目最基本的用法展示出来,便于快速定位主线

Rails 的快速入门文档做得非常出色,主线可以说是一目了然。它通过一个 Web 项目介绍了 Rails 开发的基本流程,通过该流程便可对 Rails 形成初步的认知。

确立了主线之后,便可以从中了解接口的风格。Rails 提供的三种接口分别是:

  • Web 应用对外暴露的接口:REST API;
  • 开发者编写程序时所使用的接口:API;
  • 开发过程中使用的接口:命令行工具。

REST 接口

首先分析应用对外暴露的接口:REST API。REST 如今已成为广为人知的概念,它将 Web 上的各类信息视为资源。既然是资源,便可对这些 Web 信息执行各种操作,这些操作对应 HTTP 协议的各种动词(GET、POST、PUT、DELETE 等)。

REST 当年的提出是 Roy Fielding 博士为了纠正业界对 HTTP 的误用。REST 刚问世时,开发者普遍认为这是一个优秀的理念,但如何将其付诸实践,当时能够想清楚的人并不多。

Rails 在恰当的时机出现。Rails 对 REST 的使用方式制定了一套约定,只要遵循 Rails 的惯用写法,编写出来的代码基本上就符合 REST 结构,换言之,Rails 将 REST 这一模型以一种更具实用性的方式落地实现。

ruby
Rails.application.routes.draw do
  ...
  resources :articles
  ...
end

在使用 Rails 编写程序时,只要添加一个 resource,它就会自动规划好该资源的编写方式、URL 设计方案、HTTP 动词选择以及对应的控制器方法。

plaintext
$ bin/rails routes
      Prefix Verb   URI Pattern                  Controller#Action
    articles GET    /articles(.:format)          articles#index
             POST   /articles(.:format)          articles#create
 new_article GET    /articles/new(.format)      articles#new
edit_article GET    /articles/:id/edit(.format) articles#edit
     article GET    /articles/:id(.format)      articles#show
             PATCH  /articles/:id(.format)      articles#update
             PUT    /articles/:id(.format)      articles#update
             DELETE /articles/:id(.format)      articles#destroy
        root GET    /                            welcome#index

Rails 提供的这一映射关系本质上是在指导开发者如何编写代码。这是一种约定,无需耗费精力去思考,因为这些是经过总结的行业最佳实践。只要按照该规范编写,生成的代码便符合 REST 规范,这就是 Rails 所引导的外部接口风格。

API 接口

再来分析 API 接口。当年接触 Rails 时,最令人印象深刻的是其数据库查询方式,与传统开发的风格截然不同——仅用一句简单的代码即可完成:

ruby
Article.find_by_title("foo")

要知道,在那个年代使用 Java 编写程序时,即使是最简单的查询也需要编写大量代码。不仅要创建对象,还要编写对应的 SQL 语句,还要将查询结果按照一定的规则进行组装。

而 Rails 用一句简洁的 find_by 就解决了所有问题,而且 find_by_title 方法并非由开发者手动实现,Rails 会自动生成该方法。当需要添加更多查询条件时,只需依次附加即可。

ruby
Article.find_by_title_and_author("foo", "bar")

同样的功能如果用 Java 来实现,还需要重复上述所有步骤,唯一的区别仅在于查询语句不同。

虽然描述的是当年的场景,但时至今日,在这些简单问题上,许多使用 Java 的团队所投入的工作量并未比当年减少多少。

从功能角度来看,上述查询在功能上是完全相同的,但 Rails 开发者与 Java 开发者的工作量差异悬殊。这种差异正是由不同的编程接口所导致的。

优秀的接口设计能够显著减少工作量、降低出错概率,因为它会在背后自动处理那些细节。而设计不佳的接口则会将内部细节暴露出来,迫使使用者参与其中。虽然编写程序库和编写应用程序都是编写代码,但二者对质量的要求确实相差甚大。将细节暴露给所有人,显然会增加犯错的可能性。

Rails 的 API 接口给行业带来的另一项影响是使人们开始关注 API 的表达性。例如,每篇文章可以有多个评论,用 Rails 的方式表达如下:

ruby
class Article < ApplicationRecord
  has_many :comments
  ...
end

而如果采用传统 Java 风格,代码可能是这样的:

java
class Article {
  private List<Comment> comments;
  ...
}

"有多个"这种表示关系的语义用 has_many 表达更为直观明确,若使用 List 则无法区分其究竟是属性还是关系。

Rails 中类似的代码还有很多,包括前面提到的 find_by。因此,阅读基于 Rails 编写的应用程序时会感觉代码的可读性更好。

随着 Rails 的蓬勃发展,人们也开始重视良好接口的重要性。Java 后期的一些开源项目也开始借鉴 Rails 的经验。例如,使用 Spring Data JPA 的项目也可以写出类似 Rails 风格的代码。声明一对多关系可以这样写:

java
class Article {
  @OneToMany
  private List<Comment> comments;
  ...
}

而查询操作需要定义一个接口,代码如下:

java
interface ArticleRepository extends JpaRepository<Article, Long> {
  Article findByTitle(String title);
  Article findByTitleAndAuthor(String title, String author);
}

当需要使用时,只需在服务中调用相应接口即可。

java
class ArticleService {
  private ArticleRepository repository;
  ...
  public Article findByTitle(final String title) {
    return repository.findByTitile(title);
  }
}

Java 无法像 Rails 那样在不声明方法的情况下直接调用,这是由 Ruby 的动态语言特性所支持的,而 Java 作为编译型语言无法做到这一点。不过相比于从前手写 SQL、手工进行对象映射,工作量已经大幅减少。

简洁、表达性强,这就是 Rails API 的风格特征。

命令行接口

作为开发者,自动化的重要性不言而喻,但 Rails 在"将命令行接口与整个工程无缝整合"方面达到了极致。Rails 的自动化不仅能够辅助完成各项工作,更重要的是它融合了当前软件工程领域的最佳实践,这就是 Rails 的命令行接口风格。

如果要创建一个新项目,使用 Rails 只需一条命令:

plaintext
$ rails new article-app

该命令执行后生成的不仅仅是源代码,还包含了鼓励采用的最佳实践配置,例如:

  • 选择 Rake 作为自动化管理工具,生成了对应的 Rakefile;
  • 选择 RubyGem 作为包管理工具,生成了对应的 Gemfile;
  • 为防止不同开发者在不同时间执行命令导致软件包版本不一致,生成了 Gemfile.lock 以锁定软件包版本;
  • 将对数据库的变更加入了版本化管理;
  • ……

而这仅仅是一个刚生成的工程,一行业务代码都未编写,但它已经可以正常运行了。

plaintext
$ bin/rails server

该命令启动了一个服务器,访问 http://localhost:3000/ 即可访问到一个页面。

如果打算开始编写代码,也可以利用它生成代码骨架。执行以下命令,它会帮助生成 controller 类、对应的页面,甚至包括对应的测试代码,这同样是鼓励测试的最佳实践。

plaintext
$ bin/rails generate controller Welcome index

在 Rails 快速发展的时期,人们积极探索着 Web 开发中的各种优秀实践,而在该领域走在最前沿的就是 Rails。因此在那个时期,人们经常关注 Rails 的版本更新,看看又有哪些优秀做法被融入其中。

Rails 中的优秀实践逐渐被各种语言的框架所学习和借鉴。语言设计者在设计各类框架时,也逐步参考了 Rails 中的优秀实践。例如,当今进行 Java 开发时也会用到数据库迁移工具,如 Flyway。

另一方面,即便到了今天,大部分项目的自动化整合程度仍远远达不到 Rails 的高度,虽然各方面工具都已具备,但这种浑然一体的开发体验依然是 Rails 做得最好的。

最后,Rails 如此优秀,为何如今却不再处于行业领先地位了呢?

在 Web 开发领域,Rails 可谓成也 MVC,败也 MVC。MVC 是那个时代 Web 开发的主流范式,页面主要由服务端渲染完成。然而后来形势发生了重大变化,得益于 JavaScript 虚拟机 V8 的性能提升,JavaScript 的能力日益增强,Node.js 应运而生,人们重新认识了 JavaScript。JavaScript 从边缘走向舞台中心,各类前端组件层出不穷,前端页面的表现力得到了大幅增强。

Web 开发的模式由原来的 MVC 架构逐渐演变为前端负责页面呈现、后端提供接口的模式。Java 的某些框架和服务也逐渐发展壮大,Spring 系列变得越来越强大,重新夺回了 Web 后端开发领域的关注度。

核心要点:理解一个项目的接口,先找主线,再看风格。


2.4 实现分析:Kafka

前文学习了如何分析接口,本文进入第三个部分——分析实现。在一个系统中,模型和接口是相对稳定的组成部分。但是,相同的模型和接口若采用不同的实现方式,在稳定性、可扩展性和性能等诸多方面的表现差异极大。此外,只有充分了解实现,才具备修改代码的基础。

不过,"分析实现"是一项颇具挑战性的工作,因为有无数的细节等待着去了解。在许多团队中,新人甚至需要花费数月的时间来熟悉代码中的这些细节。

面对这种情况,应当如何应对?

首先需要明确一点:不可能记住真实项目的所有细节,甚至在离开项目的那一天,仍然会有大量细节未被了解,但这并不会妨碍正常的工作。然而,如果脑海中没有一份关于项目实现的地图,就必然会迷失方向。

如前文所述的新人花费数月时间熟悉代码,正是通过代码逐步展开地图的过程,这不仅极其浪费时间,也很难形成整体性认知。因此建议直接展开地图,如何展开?需要找到两个关键点:软件的结构和关键技术。

消息队列的模型与接口

Kafka 对自身的定位是:分布式流平台。这是其当前的发展方向,但在更多人的认知中,Kafka 的角色是消息队列。可以说消息队列是 Kafka 这一软件的核心模型,而流平台显然是该核心模型确立之后的扩展。因此先将焦点放在 Kafka 的核心模型——消息队列上。

简单而言,消息队列(Messaging Queue)是一种进程间的通信方式,消息发送方(生产者)将消息发送至消息队列,消息接收方(消费者)从队列中取出消息并进行处理。

从模型的角度来看,消息队列的概念十分简明,无非是生产者发送消息、消费者消费消息。此外消息队列通常还具有 topic 的概念,用于区分发送给不同目标的消息。

消息队列的基本接口也十分简单。以 Kafka 为例,生产者发送消息的方式如下:

java
producer.send(new KafkaRecord<>("topic", new Message()));

而消费者接收消息的方式如下:

java
ConsumerRecords<String, Message> records = consumer.poll(1000);

在对模型和接口有了基本了解之后,会发现消息队列本身并不复杂。

但消息队列的实现有多种,Kafka 只是其中一种,还包括 ActiveMQ、RabbitMQ 等其他实现。为何会有这么多不同的消息队列实现?这是因为每种消息队列的实现各有侧重,不同的消息队列适用于不同的场景。

消息队列还有一个常见的特性是提供一定的消息存储能力。当生产者发送消息的速度快于消费者处理消息的速度时,消息队列可以起到缓冲作用。因此一些系统利用该特性实现"削峰填谷"——即在消息量特别大时先将消息接收下来再缓慢处理,以减轻系统的压力。

Kafka 能够从众多消息队列实现中脱颖而出的一个重要原因是它针对消息写入进行了优化,生产者的写入速度非常快。从整体表现来看,其吞吐能力特别强。

软件的结构

前文提到,当需要分析软件的实现时,有两点尤为重要:软件的结构和关键技术。

首先分析软件的结构。软件的结构实质上也是软件的模型,只不过不是整体的模型,而是展开实现细节后的模型。在第 1 讲中也提到过,模型是分层的。

对于每个软件而言,当从整体角度对其进行了解时,它表现为一个完整的整体。但当深入其内部时,它便展现为多个模块的组合,这正是所谓"分层"的意义所在。上一层只需使用下一层提供的接口即可。

因此当展开某一层次以了解其实现时,也应先从宏观入手。最佳的方法是能够找到一张结构图,准确地了解其结构。

若能找到这样的结构图则是比较幸运的。因为在实际项目中可能会遇到多种情况:

  • 结构图混乱:找到一张图,其中混合了各种内容,有的是模块设计,有的是具体实现,有的甚至包括了流程图;
  • 结构图复杂:对于一个较为成熟的项目,图中绘制了过多的内容;
  • 无结构图:这是最糟糕的情况,最好先尝试绘制一张结构图。

无论遇到上述哪种情况,了解项目都不会十分顺利。因此还是要先了解模型和接口,因为它们永远是主线,可以帮助从混乱的局面中理清头绪。

假设现在已经有了一张结构图,接下来应当做什么?不仅要了解一个设计的结果,最好还能推断出设计的动因

一种更好的方法是带着问题进行分析。假设自己是该软件的设计者,思考自己会如何去做,然后再与他人的设计进行对比,便会发现自己的想法与他人想法的异同之处。对于理解 Kafka 而言,第一个问题是如果让你来设计一个消息队列,你会怎么做?

如果在网络上搜索 Kafka 的架构图,会搜索到各种各样的图片,其中包含不同的信息。有的介绍了分区(Partition)的概念,有的介绍了 Zookeeper。根据前面对模型的介绍,特意挑选了一张看上去最为简单的架构图,因为它最贴近消息队列的基础模型:

图表渲染中…

从该图中能看到什么?Kafka 的生产者端将消息发送给 Kafka 集群,然后消费者端从集群中取出消息进行处理。这样的结构与预想的是否一致?如果让你负责进一步设计,你会怎么做?

  • 生产者端封装出一个 SDK,负责消息的发送;
  • 消费者端封装出一个 SDK,负责消息的接收;
  • 设计一个集群系统,作为生产者和消费者之间的连接桥梁。

接着,还可以提出更多的问题:

  • 生产者端若出现网络抖动导致消息未能成功发送,应当如何重试?
  • 消费者端处理完的消息,如何保证集群不会重复发送?
  • 为什么要设计集群?为了防止单点故障,而一旦有了集群,就会引发下一个问题:集群内的节点如何保证消息的同步?
  • 消息在集群中是如何存储的?
  • 无论是生产者端还是消费者端,若某个节点彻底掉线,集群该如何处理?
  • ……

带着更多的问题,便可以在代码中进行更深入的探索。可根据需要打开相应的模块,进一步了解其中的实现细节。例如关于消息重发的问题,可以研究生产者端是如何解决这些问题的。当问题细化到具体实现时,可以打开对应的源代码在其中寻找答案。

从结构上来看,Kafka 并不是一个特别复杂的系统。因此如果项目更为复杂、层次更多,建议将各个层次逐一展开,先把整体结构铭记于心,再进行细节层面的探索。

关键的技术

接下来分析理解实现的另一个重要方面:关键技术。

什么是关键技术?即是能够让该软件的"实现"与众不同的地方。了解关键技术可以确保一点:对代码的调整不会导致项目出现明显的劣化。幸运的是,大多数项目都愿意公开其关键技术,因此获取这些信息并不困难。

以 Kafka 为例,前文提到它针对写入进行了优化,使得整体吞吐能力特别强。它是如何做到的呢?

消息队列实现消息存储的方式通常是将消息写入磁盘,而 Kafka 的不同之处在于它充分利用了磁盘顺序读写的特性。对于普通的机械硬盘而言,如果是随机写入,需要按照机械硬盘的方式进行寻址,然后磁头做机械运动,写入速度就会慢得多。而顺序写入可以大幅度减少磁头的运动,效率自然得到大幅度的提高。

之所以可以实现这种方式,是因为充分利用了消息队列本身的特性:有序性。这是技术实现与需求的完美结合产物。在此基础上还可以做进一步的优化,例如利用内存映射文件减少用户空间到内核空间复制的开销。

从了解实现的角度来看,会觉得非常自然。但如果希望能从设计的角度学到更多内容,还是应当带着问题思考,再多问一个问题:为什么其他的消息队列之前不这样做呢?这是一个值得深思的问题。Kafka 这一实现的难点究竟在哪里?答案是软硬结合。

之前的消息队列实现也会将消息写入文件,但对它们而言文件只是一个通用的接口。开发者并未想过利用硬件的特性进行开发。而 Kafka 的开发者突破了这一限制,充分利用了硬件特性,从而取得了更好的效果。

一旦理解了这一点,再来看其他的一些设计,就能学到更多的东西。例如有一个著名的开源项目 LMAX Disruptor,它号称是最强劲的线程通信库。其中有一段非常特殊的代码,类似这样:

c
protected long p1, p2, p3, p4, p5, p6, p7;

以正常程序员的标准来看,这段代码简直是无厘头的低劣代码。而想要理解这段代码,必须理解 CPU 缓存行的机制,这也是一种软硬结合的思路。

对于习惯编写"软"件的开发者而言,当在软件层面投入的努力达到极限时,软硬结合是一种思路上的突破。当然这种突破的前提是对硬件的机制有所了解,这往往是许多开发者在基本功方面欠缺的。如果有时间学习,《深入理解计算机系统》一书值得一读。

核心要点:理解实现,带着自己的问题,了解软件的结构和关键的技术。


本篇核心概念关系

图表渲染中…

图注:设计分析三步法总结——模型、接口、实现三者逐层深入,最终形成完整的"设计树"。


与其他知识点的关联