{T}

设计实战篇

← 07 领域驱动设计篇 | 09 扩展补充篇 →

一句话概述:设计实战从程序库设计(Moco)、应用设计(数据采集平台)到设计改进三个维度,展示了如何将设计原则与范式运用到真实项目中,最后回归到代码质量与测试的终极话题。


知识图谱

图表渲染中…

图注:设计实战篇完整知识图谱——从程序库设计到应用设计,从设计改进到代码质量,形成完整的实战闭环。


8.1 程序库设计:Moco 如何解决集成问题

章节概述

程序库设计是从一个真实的问题出发,构建出可复用的解决方案。Moco 是一个模拟服务器程序库,它曾经获得 2013 年的 Oracle Duke 选择奖。本节通过 Moco 的设计过程,展示从问题发现到模型构建到接口设计的完整路径,揭示一个好的程序库是如何诞生与成长的。

集成的问题

阻碍一个程序员写出程序库的第一步,往往是不知道要实现一个什么样的程序库。对于很多人来说,能想到的程序库,别人都写了,再造一个轮子意义并不大。然而,这种思路往往是站在理解结果的角度。实际上,程序库和所有的应用一样,都是从一个要解决的问题出发。 因此,在日常的繁忙工作中,需要偶尔抬头,想想哪些问题正困扰着我们,也许这就是一个程序库或者一个工具的出发点。

曾经有一个问题困扰了作者很久,就是集成。在初入职场时,开发的系统需要与第三方厂商的系统进行集成,而验证集成效果的方法就是模拟一个第三方服务。当时作为新人,承担起编写模拟服务的任务,甚至自己写了一个 HTTP 服务器,然后继续在上面编写应用协议。那时候完全没有编写程序库的意识,只是有人要求返回什么样的应答,就改代码返回一个什么应答。

在职业生涯中,集成并不少见,只是后来经验多了,这种编写模拟服务的事就交到了别人手上。2012 年,在一个海外合作项目中,项目也有一个模拟的 HTTP 服务——开发人员根据自己的需要去改动代码,让模拟服务返回不同的应答,然后打出一个包,部署到 Web 服务器上。这比当年一个人维护模拟服务器进步了不少,至少不用考虑 HTTP 协议层面的问题了。不过,依旧要自己部署模拟服务这一点,让人想起当年开发模拟服务时的景象。这么多年过去了,模拟服务却依然如此麻烦,没有得到任何好转——也许可以做点什么。

从问题到需求,再到解决方案

问题有了,如何解决?需要先把问题变成一个可以下手解决的需求。首先,这个模拟服务应该做成什么样子:

  • 它可以支持配置,这样就不用每次都调整代码了;
  • 它可以独立部署,因为部署到应用服务器上的方式实在不够轻量级;
  • 它可以是一个通用的解决方案,因为已经在多个不同的场景下遇到类似的问题。

除了这些正常的需求之外,还有一个额外的小需求,就是希望它有一个有表达性的 DSL。因为当时刚刚翻译完《领域特定语言》,希望实践应用相关技术。

以当时的技术水平来看,配置肯定不是问题,这是任何一个程序员都可以做到的。独立部署,应该也可行,虽然当时还不流行嵌入式的 Web 服务器,但还有 Netty 这样的网络编程框架,稍微做一点调研就发现,用它实现一个简单的 Web 服务器并不难。

问题在于,如何把它做成一个通用的方案?在设计中,最难的就在这里。一个特定的问题总有一个快速的解决方案,而要想做成一个通用方案,它就必须是一个通用的模式。这就需要把问题抽丝剥茧,把无关的信息都拿掉,才可能看到最核心的部分。 而进行这种分析的根基,同样是分离关注点。

找到的核心问题就是:模拟服务到底是做什么的?实际上,其核心功能是根据预期返回相应应答。一方面,要表达出预期;另一方面,它要给出返回的结果。

当想明白这一点之后,一段代码浮现在脑海中:

java
server.request("foo").response("bar");

这就是这个模拟服务器最简单的形式。当请求是"foo"的时候,它就给出对应的应答"bar",这个结构非常适用于 HTTP 这种请求-应答的结构。这段代码还是一段内部 DSL,声明出模拟服务器的行为,额外需求也得到了满足。

如果代码真的可以做成这个样子,那它应该就可以写在单元测试里了。和动辄需要启动整个应用做人工集成测试相比,这标志着质的提升,从开发效率上看,这是数量级的提升。

不过,上面只是给出了设置服务器的形式,如果要把它写到单元测试里,还要考虑如何启动和关闭服务器。于是,一段单元测试的代码就浮现了出来:

java
public void should_return_expected_response() {
  // 设置模拟服务器的信息
  // 设置服务器访问的端口
  HttpServer server = httpServer(12306);
  // 访问/foo 这个 URI 时,返回 bar
  server.request("foo").response("bar");
 
  // 开始执行测试
  running(server, new Runnable() {
    // 这里用了 Apache HTTP 库访问模拟服务器,实际上,可以使用你的真实项目
    Content content = Request.Post("http://localhost:12306")
      .bodyString("foo", ContentType.TEXT_PLAIN)
      .execute()
      .returnContent();
    assertThat(content.asString(), is("foo"));
  });
}

这就是 Moco 的第一个测试。有了测试,就该考虑如何让测试通过了。同时,测试帮人锁定了具体的目标,也知道了可用的技术,剩下的就是把它实现出来。对于程序员而言,实现相对直接。采用这种方式,花了一个周末的时间,翻着各种文档,让第一个测试通过了。Moco 在实现上的技术难度就此被突破。

基础设计的诞生

接下来,需要考虑 Moco 可以提供怎样的功能。Moco 首先是一个 HTTP 的模拟服务器,所以需要对各种 HTTP 的元素进行支持。HTTP 的元素有哪些?无非就是 HTTP 协议中可以看到的 HTTP 协议版本、URI、HTTP 方法、HTTP 头和 HTTP 内容等等。

问题在于,如果要把 Moco 实现成一个通用的解决方案,就需要任意地组合这些元素,该如何设计?

在讲函数式编程的组合性时,已经提到了要设计可以组合的接口。Moco 采用了这一设计理念。下面是一个例子,如果请求 /foo 这个 URI,请求的内容是 foo,那就返回一个 bar,还要把应答的状态码设置成 200:

java
server
  .request(and(by("foo"), by(uri("/foo"))))
  .response(and(with(text("bar")), status(200)));

在这里,传给 requestresponse 的就不再是一个简单的文本,而是一个元素的组合。传给 request 的,称之为 RequestMatcher,也就是对请求进行匹配,匹配成功则返回 true,反之返回 false。而传给 response 的,称之为 ResponseHandler,也就是对应答进行处理,在里面设置应答中的各种元素。

这就是 Moco 最核心的两个模型。从 Moco 的第一个版本形成开始,一直没有变过:

java
interface RequestMatcher {
  boolean match(Request request);
}
 
interface ResponseHandler {
  void writeToResponse(Response response);
}

从这段代码上,还可以看到用来组合各个元素的 and。除了 and,还提供了 ornot 这样的元素,方便更好地进行表达。

扩展设计

有了基础设计之后,Moco 已经是一个可用的程序库了。从理论上来说,它已经能够完成 HTTP 模拟服务器所有的需求。事实上,当拿出了 Moco 的第一个版本,就有同事在实际的项目中用了起来。

如同所有开源项目一样,只要有人用,就会有人给出反馈,就需要去解决它。Moco 就这样不经意间开启了自己的生命周期。

软件设计是一门关注长期变化的学问。长期意味着会有需求源源不断地扑面而来。每当有新问题的到来,软件就要去应对这个新的变化,这也是考验软件设计的时候。

第一个变化是有人提出要有一个外部的配置文件。Moco 所要做的调整,就是增加一个配置文件,然后要在配置文件和核心模型之间做一个映射。这个变化在核心模型上没有任何改变。这就相当于给 Moco 增加了一种外部 DSL,只不过这个 DSL 的语法采用了 JSON。

正是因为 JSON 配置文件的出现,Moco 有了一个全新的用法——把 Moco 当作一个独立的模拟服务器。后续使用者更熟悉这种用法,而把 Moco 用在单元测试的场景比例就要低一些。也是因为这个独立模拟服务器的用法,Moco 也不再局限于 Java,不同的程序设计语言编写的应用都可以与之进行交互,Moco 的使用范围得到了扩展。

随后,还有人提出了更多功能性上的需求,让 Moco 的能力也得到了极大的提升:

  • 有些被模拟的服务不稳定,Moco 支持了 proxy 功能,将请求转发给被模拟服务,如果这个服务失效了,就使用本地缓存的信息;
  • 有些应答里的字段是根据请求的内容来的,Moco 支持了 template 功能,让使用者自己决定怎样使用哪个信息;
  • 有时还要对请求的内容进行各种匹配,例如 URI 在同一个根目录下就进行一样的处理,Moco 支持了 match 功能,让使用者自己可以写正则表达式对请求进行匹配;
  • 有人为了方便管理,希望把所有的应答内容放到一个目录下,Moco 支持了 mount 功能,把一个目录挂载在一个 URI;
  • 现在的 REST 开发是主流,Moco 支持了 REST 能力,能够定义资源,更方便地将同一资源的内容定义在一起;
  • ……

所有这些内容都是在基础的模型上扩展出来的,基本上都不需要去改动基础模型。不过,有一个功能的拓展影响了基础模型,就是 template。因为它需要根据请求的内容来决定应答的内容,这让原本各自独立的 requestresponse 开始有了关联。

为了适应 template 的需求,在 ResponseHandler 的接口上增加了 Request,把请求信息带了进来:

java
class SessionContext {
    private final Request request;
    private final Response response;
    ...
}
 
interface ResponseHandler {
  void writeToResponse(SessionContext context);
}

也是由于这个调整,让 Moco 后来有了可以支持录制回放的能力:

java
server
  .request(by(uri("/record")))
  .response(record(group("foo")));
 
server
  .request(by(uri("/replay")))
  .response(replay(group("foo")));

在这个设置中,发给 /record 这个地址的内容就可以记录下来,然后访问 /replay 这个地址的时候,就可以得到刚才记录的内容。由此,Moco 由原来只提供静态设置的模拟服务器,变成了一个能够动态配置的模拟服务器,能力得到了进一步提升。

至此,Moco 一点一点地长大了。与 2012 年刚刚起步时相比,今天的 Moco 的能力已经强大了许多,但它的内核依然很小,代码量也不大。Moco 是根据请求给出应答,只要理解了这一简单的逻辑,就完全可以理解 Moco 在做的事情,其他的东西都是在这个基础上生长出来的。

Moco 的设计演进

图表渲染中…

图注:Moco 从问题发现到模型提炼到接口设计的演进历程——好的程序库不是一蹴而就的。

程序库设计的关键原则

图表渲染中…

图注:程序库设计的关键原则——问题驱动、模型优先、声明式接口、隐藏细节、稳定模型。

核心要点

  • 程序库和所有应用一样,都是从一个要解决的问题出发;阻碍人写出程序库的第一步,往往是没找到一个好问题去解决
  • 程序员不能只当问题的解决者,还应该经常抬头看路,做问题的发现者
  • 有了问题之后,需要把问题拆解成可以下手解决的需求,让目标更明确
  • 一个通用的解决方案需要不断地抽丝剥茧,抛开无关的部分,找到核心的部分——这同样根植于分离关注点
  • 用测试把程序库要表达的内容写出来是最直接的——有了测试,就锁定了目标,剩下的就是让测试通过
  • 一个好的设计,应该找到一个最小的核心模型,所有其他的内容都是在这个核心模型上生长出来的;越小的模型越容易理解,也越容易保持稳定
  • 注意发现身边的小问题,用一个程序库或工具解决它

8.2 应用设计:如何设计一个数据采集平台

章节概述

应用设计与程序库设计的核心差异在于:程序库面向开发者(接口表达性至关重要),应用面向最终用户(业务逻辑的准确性至关重要)。本节以金融指数系统为例,展示如何运用 DDD 和关注点分离来构建应用,并揭示如何通过不断发现潜在问题,将设计推到更高水平。

一个指数系统

在金融系统中,有一个概念叫指数,用来表示金融市场的活动,例如有股票指数、期货指数等等。比较著名的指数有道琼斯指数、标准普尔指数。这个世界上的指数多得数不胜数,每个金融机构都会有自己的指数,而且还会不断推出新的指数。

指数是怎么算出来的呢?如果以股票为例,就是获取一堆股票的价格,然后根据一个公式算出一个结果。例如,有一个公式 A * 0.2 + B * 0.3 + C * 0.5,把公式里的数据部分称为指标,也就是公式中的 A、B、C,这个公式表示这三种指标分别占比 20%、30% 和 50%。

假设 A 指标的价格是 5 元、B 指标是 2 元、C 指标是 1 元,按照公式可以算出 5 * 20% + 2 * 30% + 1 * 50% = 2.1,这个算出来的 2.1 就是指数的值。价格是实时变化的,而公式是固定的。指数在问世之初,需要不断调整这个公式里面各个指标的参数,以便能更好地反映市场的变化。

一个不假思索的设计就是,针对一个具体的指数进行开发——把指数计算中涉及的各种数据实时取过来,然后根据设置的公式去做计算。如果只有一个指数,这么做也许是可以接受的。但要开发的是一个指数系统,意味着会有很多个指数。两个不同的指数可能会用到同样的指标,如果按照开发一个指数的方法,不同的指标数据要获取好多遍,这就是一种重复。

因此,一个好的做法就是,先做职责划分,把不同职责的部分划分出来。不能把各种不同的关注点混在一起,这是很多系统出问题的根源所在。

从需求描述中,可以把指数的计算过程分成两个部分:

  • 一部分是需要实时获取的数据,例如前面说到的各种价格;
  • 一部分是根据公式进行计算出最终的结果,也就是指数最终的值。
图表渲染中…

图注:指数系统的关注点分离——将数据获取与公式计算拆分为两个独立部分,消除了数据重复获取的问题。

这种拆分解决了前面设计中存在的问题,使得指标数据获取和公式计算分开了,同样的数据就可以用在多个公式中,数据的获取和公式的计算就不用同步进行了。

而且,把计算过程拆成了两个部分之后,就可以针对这两个部分分别进行细化了:

  • 对于指标数据获取的部分,要解决数据获取可能出现的问题:不同的数据来源如何管理、不同数据源的数据格式是怎样的、如果数据源不可用该怎么办等等;
  • 对于公式计算的部分,关心的问题则是计算要用到哪些指标、每个指标当前可用的值是多少、如果公式中有不可用的指标数据时系统该怎么处理等等。

既然把系统拆分成了两个部分,还有一个问题就是如何把这两个部分连接起来。指标数据获取部分的输出,就是公式计算部分的输入。指标数据获取要实时获取,无论采用轮询的方式还是数据上报的方式,这种数据的特点就是:有一个值,还有一个时间。正是因为这种特点,数据会形成一个序列,所以将这种数据称为时序数据

指标数据获取部分的输出就是这种时序数据,只不过针对每一种指标都会产生一个时序数据序列,而这些不同的时序数据也正是公式计算部分的输入。既然是时序数据,也就有了时间的信息,公式计算部分就可以根据时序数据的时间做一些处理了。例如,如何判定一个指标不可用?如果判断一个指标最新的数据与当前时间的差值过大,就可以判断在这次计算中该指标的数据不可用。

有了对于时序数据的认识,结合数据获取和公式计算不再是同步进行的这一点,指标数据获取和公式计算两个部分就完全解耦了,二者之间可以只通过时序数据进行交互。

更上一层楼

现在已经把数据获取和公式计算分成了两个部分,这是常规设计中可以想到的。很多设计者做设计也可能就此打住,开始编码实现了。但是,有时候还可以更进一步。

公式计算该怎么做?最直觉的想法是,业务人员给什么样的公式,就用写代码的方式把它实现出来。这么做肯定是可以把公式实现出来的。但是,指数往往要经过一个调整的过程,因为业务人员自己也常常不确定设置的参数是否合理。

用写代码的方式实现公式,意味着每次业务人员要调整一个参数,都需要去改代码。在可以预见的未来,工作基本上都会与调整参数相关,而这毫无技术含量。

一项工作是否具有技术含量往往不取决于工作本身,而取决于如何完成它。 换言之,问题是一样的,但不同的解决方案却会带来不同的效果。业务人员提出的是问题,解决方案是由技术人员给出的,切勿混淆问题和解决方案。

当预见到某项工作将来会较为繁琐、会不断重复,而且会持续相当长的时间,就需要重新审视解决方案了。

图表渲染中…

图注:应用设计的自动化程度阶梯——从无自动化到业务员自主配置,设计要求依次提高。

最原始的解决方案是没有自动化的方案。在自动化方案中,最原始的做法是开发人员自己修改代码的方案,这种做法会导致开发人员大量的时间投入,属于严重消耗时间的做法。

其次是开发人员修改配置,虽然这种做法只修改配置,但通常还会涉及到重新打包发布的过程,只能说它比修改代码要强一点。

比较好的做法是业务人员修改配置,开发人员完全不参与其中。一方面,业务人员自己最知道自己想要什么;另一方面,没有开发人员的参与,反馈周期就缩短了。

虽然这几种方法在业务的角度是越来越好的,但在设计上,却是要求越来越高的。比起没有自动化的方案,自动化的方案需要投入一些力量去做设计。相比于修改代码,修改配置就意味着要留下扩展的接口。而能够做到让业务人员而不是开发人员修改配置,配置的接口就应该是一个业务的接口,例如要有一个配置界面。

如果用这几个标准评估现在的方案,显然还处于开发人员修改代码的阶段,这说明还有向上努力的空间。不过,给出的只是一个衡量标准,并不意味着这个台阶要一步一步上,因为可以一步就提升到最高标准——一步到位给业务人员提供一个配置的接口。

那么,给业务人员提供的配置接口应该是什么样子?指数设计的关键就是这个指数的公式。在前面的例子里面,公式是 A * 0.2 + B * 0.3 + C * 0.5。如果能够让业务人员在配置接口上这样配置,问题就解决了。A、B、C 分别代表一个指标,换言之,只要能够让业务人员指定指标以及指定计算公式,剩下的问题就是根据公式计算出相应的结果。

A * 0.2 + B * 0.3 + C * 0.5 变成一个可执行的公式,需要一点编译原理的知识。实际上,公式的解析是编译原理入门的知识,难度系数比设计一门程序设计语言要小多了。而且,现在有编译器前端的工具,例如 Java 世界的 Antlr,它可以直接生成对应的语法树结构,只要负责编写对应的执行部分就好了。

这里实际上已经构建出了一门 DSL,一门属于指数计算这个特定领域的外部 DSL。把设计做到极致就可以构建出一门 DSL,了解 DSL,实际上也增添了一个可以前进的方向。

把公式构建出来之后,仔细分析,还会有一个有趣的发现:公式计算的结果是什么?因为是在利用多个指标的时序数据做计算,所以得到的结果实际上也是一个时序数据。这样,公式计算得到的结果实际上也是一个指标。如此一来,公式计算的结果也可以作为另外一个公式的输入,形成更为复杂的复合公式。由于复合公式的出现,系统的处理能力又上了一个台阶。

这与设计模式中的组合模式如出一辙——基础知识在这里都用上了。

图表渲染中…

图注:复合公式的组合模式——公式计算的结果本身也是一个指标(时序数据),可以作为另一个公式的输入,形成递归组合。

虽然这里讨论的是一个金融中会用到的指数系统,但当模型经过一番整理之后,它不仅仅局限于指数系统中。例如,如果开发的是一个物联网系统,上报上来的数据往往也要经过一些计算和聚合,这个模型显然也是适用的。再例如,开发了一个 APM(Application Performance Management,应用性能管理)类的应用,采集上来的数据往往也要经过一番计算再展示出来,这个模型同样适用。当可以构建出一个好的模型时,它本身就有着更大的适用范围。

数据采集平台的关注点分离

图表渲染中…

图注:数据采集平台的三大关注点——采集层和展示层变化频繁,处理层(核心业务逻辑)相对稳定。核心业务逻辑应该是设计最坚固的部分。

应用设计的分层架构

图表渲染中…

图注:应用的分层架构——领域层是核心(依赖方向:外层→内层),基础设施层实现领域层定义的接口(DIP)。

核心要点

  • 应用设计的第一步依然是分离关注点——不能把各种不同的关注点混在一起,这是很多系统出问题的根源
  • 指数系统的关注点分离:数据采集 vs 公式计算,二者通过时序数据解耦
  • 一项工作是否具有技术含量往往不取决于工作本身,而取决于如何完成它——业务人员提出的是问题,解决方案是由技术人员给出的
  • 应用设计水平的衡量标准:无自动化 → 开发员修改代码 → 开发员修改配置 → 业务员修改配置
  • 把公式构建成外部 DSL,让业务人员自主配置——把设计做到极致就可以构建出一门 DSL
  • 公式计算的结果也是时序数据,也是指标——组合模式使得系统支持复合公式
  • 当可以构建出一个好的模型时,它本身就有着更大的适用范围——同样的模型适用于物联网、APM 等场景
  • 一个更好的设计从拒绝低水平重复开始,把工作做成有技术含量的事情

8.3 应用改进:如何改进我们的软件设计

章节概述

设计改进不是从零开始,而是在既有代码的基础上逐步优化。本节讨论如何确定改进目标、如何理解既有系统、如何避免回到老路上,以及如何在小步前行中让设计不断焕发新的活力。

从目标开始

在实际工作中,很多时候的工作并不是从头设计一个应用,而是改进一个既有项目的代码。既有项目的代码意味着各种问题。

软件设计是一门关注长期变化的学问。越是在商业上成功的软件,存续的时间往往越长。存续的时间越长,往往就会有更多的麻烦。先不说有些项目一开始就没有设计,一路混乱向前。即便是一个最初有着还算不错设计的项目,随着时间的积累、人员的更替、把前人的做法当作惯例等等事情的发生,项目的设计就会逐渐变得不堪重负。每个人只改了一点点,最后却是积重难返——这就是一个项目缺乏设计守护的结果。

除此之外,新的技术和框架会不断涌现,旧代码往往不能有效使用这些新东西。例如,Java 世界今天开发的主流是 Spring Boot,然而十年前它还不存在。虽然那时候已经有了 Spring,但主流开发方式还是打出一个 WAR 包再部署到 Tomcat 上。新出现的很多技术会提供更简单的做法,替换掉旧代码中笨拙的部分。

到底怎么才能让自己的项目在设计上不断地演进,跟上时代发展的步伐,不断焕发新的活力?对于任何一个开发团队而言,这都是一个值得考虑的问题。

大多数团队一说起改进,一般想的都是功能性方面的目标。例如,原来的系统能支持 100 万的用户,现在要支持 1000 万的用户。这种改进固然是需要考虑的,甚至是迫不得已的。但这种改进解决的是实现,因为不同量级的系统根本就不是一个系统,承载的用户量发生了变化,实际上是一种需求的变化。这种改变并不会让设计变好。

既然已经决定要改进了,就应该好好地把设计改进一下,而不只是把功能重新实现一遍。因为功能实现是无论如何都必须做的,都是为别人做的,而设计的改进才是为自己做的——因为在未来的一段日子里维护这些代码的人是自己。如果要做设计的改进,设定好改进设计的目标就显得尤为重要。

那设计改进的目标应该是什么呢?可以先问自己这样一个问题:如果有机会从头设计这个系统,它应该是什么样子呢?

这个问题可能会让很多程序员一下子愣住,因为他们每天都陷于忙碌的工作中,做的工作都是各种微调、各种打补丁,眼中只有一个具体微观的世界,却不曾有一个整体的思考。

从头来过,它应该是什么样子。这是一个简单的问题,也是一个困难的问题。简单在于,它的字面意思很好理解。困难在于,很多人一听到这个问题,直觉就要回避:

  • 系统已经这么沉重了,怎么可能重来?
  • 有那么多的需求要做,哪有时间重做一遍?
  • 系统那么复杂,重做一遍,出了问题谁来负责?

这些都是很现实的问题。但是,这里的重点并不是真的一上来就动手从零开始把系统重写一遍,而是要找到改进的目标,也就是一个系统本来应有的面貌

这就是为什么前面要学习那么多设计一个系统的知识,否则没有设计知识的沉淀,所谓的"重新设计",弄不好就会回到原来的老路上去。

这时候,或许会想到一个严重的问题:开启一次系统改进,如何处理人们的共识也是一件困难的事情,但这根本不是一个设计问题。想要真正地开启一次改进,就要让人们意识到,设计一个系统和实施一次系统改进是两个完全不同的问题,可以分阶段地进行

只有把系统设计成它应有的样子,才算是确定了目标。有了目标之后,接下来才能制定改进路径,而把现有的系统一点一点从旧有的样子改动成新的样子,这是实施的过程。

改进的过程

现在要重新设计这个系统了。在大部分真实的项目中,一个既有系统的情况是,没有人能够说出它到底承载了哪些需求。主干部分是人人都知道的,但主干常常是九牛一毛,而更多的细节隐藏在代码中。一个长期存在的系统,开发者可能已经换了好几拨,了解当年那些需求的人可能早已不知所踪,导致的结果就是,每一个工作在这个项目上的人都是只见树木不见森林。

在这种情况下该怎么办?一个入手的起点,就是接口。

在学习怎样理解一个系统的设计时,曾经说过,想要理解一个系统的设计,可以按照模型、接口和实现的框架去理解,其中接口是模型能力的体现。对于一个系统而言,接口也是使系统内部状态发生改变的原因,系统中的所有变化必然都是从某个接口开始的。既然没有人能够清楚地说明系统的现状,那么从接口入手了解系统的现状是一个非常现实的做法——接口是不会骗人的。

不过,这里的接口不仅包括传统意义上的接口,也包括各种后台服务。有了构建模型的基础,再看后台服务,就会发现后台服务只不过是按照某种规则触发模型的接口。例如定时服务,就是定时地去调用模型的接口。所以,也要把这种接口梳理出来。

有了对这些接口的了解,就对这个系统呈现哪些能力有了一个认识,相当于获得了一份需求描述。基于这个认识,来构建新的设计。

接下来就要重新设计了,改进设计的难点就是不要回到老路上。需要按照一个正常设计的思路去走,该分离关注点的分离关注点,该重新组合的要重新组合。

之所以要提示这一点,就是因为思维的惯性实在是太大了。例如,在原有的系统内有一个叫"订单"的概念,就会习惯性地使用"订单",而不是把商品订单、支付订单等概念分开。一般而言,既有项目的设计有一个很大的问题就是各种信息混在一起,而能够把不同的信息拆分开来,对于设计而言就是一个巨大的进步。

做好了新的设计,也就为后续的行动找到了新的方向。接下来要做的是,对比新旧设计,找到一条改进路径。

永远不要指望一个真实的项目停下来,一步到位地进行改进。 能够做的,唯有小心翼翼,一步一步向着目标前进。

对于不同的项目,选择的路径可能是不同的,有人会选择关键路径上的关键模块进行改进,也有人会选择影响较小的模块先进行探索,无论是哪种方案都是可以的。一个关键点就在于,动作要小

任何一个大动作,往往都意味着很长时间无法完成。在这个过程中,所有人都会提心吊胆。如果不能看到成果,很多人的信心都会随时间流失。所以,在软件设计的改进过程中,积小胜为大胜才是一个合理的选项

还有一个关键点,要让所有相关利益人有一个共识。软件开发虽然是一个技术活,但归根结底还是一项团队活动,是一项人的活动。既然涉及到诸多参与者,就一定要让大家形成一个共识。系统改进,尤其是一个规模比较大的系统改进,一定要让所有人有共识。无论是开会也好,宣讲也罢,让大家对于改进的原因和改进的计划有个共同的预期是至关重要的。

虽然这里讲的是一个系统的改进过程,但同样的思路也可以运用在更小的模块中。只不过,更小模块意味着更少的接口、更低的复杂度以及更少的相关利益人。事实上,反而鼓励从小模块入手——一步到位去改进整个系统难度系数更大,而小模块可以帮助积累更多改进的经验,无论是设计方面,还是与人打交道方面。

改进既有代码的步骤

图表渲染中…

图注:改进既有代码的安全路径——从确定目标开始,通过接口理解系统,重新设计不回到老路,小步前行并达成共识。

核心要点

  • 改进的第一步是确定改进目标——如果有机会从头设计这个系统,它应该是什么样子
  • 既有系统的入手起点是接口——接口是不会骗人的,系统中的所有变化必然都是从某个接口开始的
  • 改进设计的难点在于不要回到老路上——思维的惯性太大,需要按照正常设计的思路走
  • 永远不要指望一步到位地进行改进——积小胜为大胜才是一个合理的选项
  • 改进的两个关键点:动作要小要让相关利益人达成共识
  • 鼓励从小模块入手,积累改进经验
  • 改进既有设计,从做一个正常的设计开始,小步向前

8.4 结束语:那些没讲的事儿

章节概述

虽然专栏把软件设计相关的核心知识都讲了一遍,但还有一些内容没有在专栏中呈现。本节讨论软件设计中两个无法通过课程直接传授却至关重要的方面:沟通经验的积累

设计需要沟通

专栏中讲的大部分内容都是技术性的,然而在真实的软件开发过程中,软件设计工作有很大一部分内容却是非技术性的,例如沟通。

软件设计是要构建模型、打造规范。但模型的理解需要沟通,规范的执行同样也需要沟通。因为无论是模型还是规范,软件设计最终是要落实到代码上的。具体落实成什么样,依赖于人的理解和人的执行。如何才能让人的理解达成一致?唯有不断反复地沟通。

为什么有些人并不觉得沟通很重要?或许只是因为他们所做的工作是局部代码的调整,涉及到的人比较少,沟通的重要性没有那么凸显。但只要在成长,负责的模块规模就会变大,牵扯到的人就会增多。如果要让其他人能够理解设计,就需要靠沟通了。

好的设计一定是易于理解的,而这个理解指的是别人如何理解。所以,一个好的设计只做到自己心知肚明是不够的,酒香也怕巷子深。一个好的设计是需要讲给别人、取得别人认同的。达到同一个目标的路径有很多,设计没有一条标准的路径。可是当人数多起来,思想不一致几乎是一种必然。只有通过沟通,才有可能让大家对某一种路径达成一致。

这样设计才能保持一致,否则你写结构化编程的 for 循环,我写函数式编程的 map、reduce;你留扩展点,我来硬编码,代码就注定是无法维护的。

在软件设计中,确实有一个工具是关于沟通的,那就是 UML,叫作统一建模语言。不过,现在很多程序员更习惯随手画个图,因为这种表达方式更简单,这让 UML 的用武之地就少了许多。所以,有空的时候还是建议去了解一下 UML,至少要知道有几种类型的图。这样以后在随手画图时,不至于把静态结构和动态交互画在一起。

很多程序员会习惯性地把自己的职业只当作一个技术工种,认为只要技术足够深厚,便能够通行天下。但实际上,只要是在一个组织中工作,沟通能力就是非常重要的,而且随着职位的上升,沟通能力的重要性会越发显现。

如果在实际工作中发现别人很难理解你的美妙设计,一种可能是你的设计没有你以为的那么好;而另一种可能就是,你的沟通还不够好,其他人并没有理解你。

图表渲染中…

图注:设计沟通的闭环——设计意图需要通过沟通表达让团队理解,落实到代码后通过反馈验证,形成持续改进的闭环。

经验的积累

关于软件设计,还有一件事是没法教给你的,那就是经验的积累。

如果有机会和有经验的人一起写代码,他们可能会在一些地方要求增加一些类、把类写小;在另外一些地方要求把一些类合并、减少类的数量;有时候会告诉这里要留一个扩展点,把变化隔离开来;还有的时候会说不必考虑扩展,先把功能实现了。这些成对的操作完全是相反的,到底哪个才是软件设计该做的?但如果有相应的上下文,就会理解这些要求的合理之处。

所以,即便掌握了相同的软件设计知识,但在一个具体的场景下,该做怎样的判断、如何作出判断,都是需要经验积累的。

软件设计的基础,无论是设计模式还是设计微调的技巧,都可以通过课程去学习,甚至可以通过短期的训练营去锻炼。但是,如果想把这些内容熟练地运用到实际的工作中,就需要有大量经验的积累,需要经历或者见过许多不同的使用场景。

一切经验积累的前提条件是,先有软件设计的意识。 对于大多数人而言,软件设计是知与不知的差别。知道的人就会有意识地积累经验,而不知道的人即使做过再多的项目,也无非是不断地在重复增删改查。《软件设计之美》这些内容首先帮助解决了"知"的问题,只有知道了,才能开启积累的道路,踏上个体成长的阶梯。

专栏中反复说到,软件设计关注的是长期变化。可是,实际上没有任何一个专栏或是一个训练营可以让人真正感受到一个软件的长期变化,唯有真实的项目可以。每当来了一个新的需求,就会有一个对应的解决方案。但最好先问自己一个问题:这种实现方案是不是一个好的设计呢?这就可以给自己的直觉思维加上一个缓冲。

普通程序员和高手之间的差别就在于此,普通程序员凭直觉做事,高手却是把专业的做法训练成直觉。 所以能看到很多人在不经意间写出的代码就非常漂亮,而漂亮的背后,实际上是一次又一次的思考和训练。

作者有幸在职业生涯之初就接触了软件设计,当时所在的部门正面临着对系统的调整,技术负责人每天研读《设计模式》,然后在部门里做分享。虽然当时还不能完全理解,但本着对技术负责人的信任,整个过程都听得很认真,竟然从中听出了一些美感——软件设计是个好东西,这种印象深深地印在了脑海中。

从那之后,就会有意识地去找设计的书去读,会有意识地去反复思考设计的优劣。经常问自己的一个问题就是,如果我把这段代码重写一遍,我该怎么做。久而久之,几乎每次都能发现代码质量欠佳的地方,找到那些值得改进的地方。也正是因为这样,代码风格每隔一段时间就会发生一些变化。尽管在外人眼中实现的功能都是差不多的,但设计已经变得更好了——更容易测试了,也更容易扩展了。

对于一个好程序员来说,品味是尤为重要的。要想有一个好的品味,就一定要见过好东西。遗憾的是,大多数人在日常工作见到的代码都很难称得上有品味,唯一的优点就是可以运行。所以,要多向好的开源项目学习,这是一种帮助打破限制的好方法。

开源项目有很多,但是很多人的关注点一般都是这些项目如何实现了一个功能,却少有人关注它的设计,这会让人错过很多风景。可以先从一些不那么复杂的项目入手,关注它的设计。推荐几个能从中感受到美感的项目:

  • Moco
  • Google Guava
  • Spring 系列的项目

编写代码也是一门手艺,手艺是要不断打磨锤炼的。鸟巢的每个焊接处都镌刻着焊工的名字,因为主事者希望记录下来他们对这件历史工程做出的贡献。程序员的工作天生也会被源码控制工具记录下来。所以,作为一个程序员,希望自己写下的代码能成为自己的骄傲,而不是别人的槽点。唯有不断精进的手艺才能成为努力过的证明。

结束了吗?

丘吉尔在阿拉曼战役庆功宴上发表的演讲中说过这样一段话:这不是结束,甚至不是结束的开始,而可能是开始的结束。(Now this is not the end. It is not even the beginning of the end. But it is perhaps the end of the beginning.)

《软件设计之美》这些内容只是帮助开启了软件设计的大门,但能真正让软件设计成为自己的一部分,对每个人来说,都有很长的路要走。即便写程序已经二十多年了,依然不敢说自己的程序已经写到无懈可击的地步,偶尔的新需求依然会让人陷入思索。

对这些内容的预期,就是把体验到的思考乐趣告诉读者,让人产生对于软件设计的兴趣。如果希望学了这些内容之后还能更进一步地学习,不妨找一找专栏中推荐的书,几乎每一本都值得深入学习,而这些内容已经给了一张地图,保证不会在茂盛的软件设计丛林中迷失。

核心要点

  • 设计需要沟通——模型的理解需要沟通,规范的执行同样也需要沟通;好的设计只做到自己心知肚明是不够的
  • 如果别人很难理解你的设计,一种可能是设计没有你以为的那么好,另一种可能就是沟通还不够好
  • 经验的积累无法通过课程传授——同样的知识在不同场景下的判断,需要大量经验的积累
  • 一切经验积累的前提条件是,先有软件设计的意识——知与不知是最根本的差别
  • 普通程序员凭直觉做事,高手把专业的做法训练成直觉
  • 经常问自己:如果我把这段代码重写一遍,我该怎么做
  • 对于一个好程序员来说,品味是尤为重要的——要多向好的开源项目学习
  • 这些内容只是"开始的结束"——软件设计之路漫漫,唯有不断精进

8.5 第三季回归:写好代码

章节概述

在《10x 程序员工作法》中讲了工作原则,在《软件设计之美》中讲了设计原则。这两个专栏让许多人受益匪浅,但也有人觉得还不过瘾。这些原则虽然很好,但怎么应用到自己的实际工作中,完全取决于个人的理解。本节讨论从原则到实践之间的落差,以及如何通过识别代码的坏味道来弥补这一落差。

原则到实践的落差

经验丰富的人或许可以直接改变自己的行为,而经验少的人从中的获得就完全取决于个人的悟性了。

例如,在两个专栏中都讲到了单一职责原则,最终得出的结论都是要把代码写短小。但什么叫写短小,不同的人理解起来就是有差异的。

有一次,在一些人面前演示了如何将一段代码重构成小函数,然后问听众:可以接受一个函数代码行数的上限是多少?一个听众很认真地说,100 行。默默看了看被重构掉的那个"不好"的函数,好像也没有 100 行,按照他的标准,那个函数根本不需要改。

还有一次,一个颇有经验的前辈说自己写代码的要求很高,函数要求写得很短。当问及一个函数不得超过多少行时,他说 50 行。

50 行也好,100 行也罢,这是一个天文数字。通常对自己的要求是:像 Java 语言这种表达能力一般的语言尽可能 10 行之内完成,而像 Python、Ruby 这类动态语言,5 行代码就可以解决大多数问题,而且很多代码一行就够了。在自己实际的项目中,考虑到团队的协作,在静态检查中配置的参数是 20 行——换言之,一个函数超过 20 行,连构建都是无法通过的。

从这些例子中可以看到,虽然大家都遵循了同样的原则,但具体体现在代码上,却是千差万别的。也正是因为理解的差异,造成的结果是:许多人懂得了很多道理,依然不能很好地完成自己的本职工作。许多人日夜辛苦地调试的代码,实际上在写出来的那一刻就已经漏洞百出了。

如果能够知道这些代码是有问题的,在写代码之初就把这些问题消灭在萌芽中,日后的辛苦就可以节省出不少。

代码的坏味道

Martin Fowler 在《重构》这本书里给这种有问题的代码起了一个很有特点的名字:代码的坏味道

有追求的程序员都希望自己能够写出整洁的代码,而这一切的出发点就是坏味道。只有拥有对于坏味道的嗅觉,才有机会对代码进行重构,也才有机会写出整洁的代码。

所以第三个专栏——《代码之丑》——就从代码的坏味道出发。提供一些非常直观的坏味道,让人看一眼就知道代码有问题。在这些坏味道中,有一些是已经深恶痛绝的,比如长函数和大类;有一些则是在挑战编程习惯,比如 else 语句和循环语句。这些坏味道的知识即学即用,对照代码,立刻就能发现很多问题。

按照专栏一贯的风格,不仅仅告诉一段代码是坏味道,也会告诉这些坏味道之所以为坏味道背后的道理,还会讨论如何去重构这段代码。

有了《10x 程序员工作法》或《软件设计之美》这两个专栏的积淀,当再去学习新专栏的时候,之前学习的这些原则就实打实地体现在对于代码的改进上,让修炼的内功有了更好的用武之地。

图表渲染中…

图注:从坏味道到整洁代码的改进路径——以设计原则和工作原则为根基,通过识别坏味道、理解原因、进行重构,最终达到整洁代码。

核心要点

  • 虽然大家都遵循了同样的原则,但具体体现在代码上却是千差万别的——理解的程度不同,实践的效果也大不相同
  • 代码的坏味道是写好代码的出发点——只有拥有对坏味道的嗅觉,才有机会重构和写出整洁代码
  • 常见的坏味道包括:长函数、大类、else 语句、循环语句等
  • 坏味道的知识即学即用,对照代码立刻就能发现很多问题
  • 有了设计原则和工作原则的积淀,学习坏味道和重构就实打实地体现在代码改进上
  • 一项工作是否具有技术含量往往不取决于工作本身,而取决于如何完成它——拒绝低水平重复

8.6 第四季回归:通向高质量代码之路

章节概述

前面三个专栏 100 讲的篇幅讲了如何做一个不断精进的程序员,通过各种方式把代码写好。但在这些专栏中,有一条隐隐的线索一直贯穿始终:如何验证自己写的程序是对的?能够用来保证程序正确性的,唯有测试。本节揭示四个专栏之间的内在联系,展示如何通过测试打通高质量代码的最后一公里。

四个专栏的体系

前面三个专栏分别从不同角度提升代码质量:《10x 程序员工作法》让程序员找准正确的目标,使程序不偏航;《软件设计之美》让程序更加灵活,适应未来的变化;《代码之丑》让程序员不犯低级错误,使代码更容易理解。

在这三个专栏里,有一条隐隐的线索一直贯穿始终:怎么验证自己写的程序是对的?能够用来保证程序正确性,唯有测试。

  • 在《10x 程序员工作法》里,讲了一个测试的小专题;
  • 在《软件设计之美》中,讲了用可测试性衡量软件设计的正确性;
  • 而在《代码之丑》中,修正的很多坏味道,就是为了让代码更容易测试。

保证代码的正确性是每个程序员口中的目标,但它是多少程序员行动中的目标呢?这件事在行业中的真实情况是,从思想到行动之间有巨大的落差。不能质疑程序员的专业精神,但大部分人对代码正确性的要求都停留在个人努力上。

很多团队并没有对编写测试硬性的要求,即便有,也是很低的要求,比如测试覆盖率达到 50%。为什么团队不要求?一个可悲的答案是大多数程序员不会写测试。对于不会做的事情,人们自然的反应就是少做或者不做。

因为测试并不是光知道 xUnit 框架就能够很好完成的。很多人只会用系统测试这种大粒度的测试,结果必然是整个测试又慢覆盖度又不高,还会有很多测不到的地方。反过来,这种笨拙的测试方式也会进一步劣化测试在程序员心目中的形象,导致更多的人不愿意写测试。

程序员写测试的目的

程序员写测试就是为了编写高质量的代码。 这里所说的高质量代码分成两个部分:一方面自然是常规理解的——经过测试的代码,质量会更高;另一方面,要想写好测试,代码本身的质量也要高。

不过,想真正做好测试,需要有很多基础的铺垫。在不少人看来,一些东西看上去与测试并没有直接的关联,例如任务分解,再比如要有高质量的代码等等。实际上只有把这些知识连接起来,才能知道如何做好测试。好在测试基础的部分,在前面的几个专栏中已经讲过了:

  • 要懂得任务分解,这是《10x 程序员工作法》中讲过的;
  • 要懂软件设计,这是《软件设计之美》中讲过的;
  • 每个函数要整洁小巧,这是《代码之丑》中讲过的。

已经掌握了打开通向编写高质量代码大门的通行证,接下来就是怎样把这些知识运用到编写测试的过程中。所以《程序员的测试课》把"测试"这条隐藏在专栏中许久的线索拿出来做一次完整的呈现。

图表渲染中…

图注:四个专栏的知识体系——前三个专栏为测试课提供了任务分解、软件设计和代码整洁的基础,测试课则将这些知识串联起来,通往高质量代码。

核心要点

  • 四个专栏有一条贯穿始终的线索:如何验证自己写的程序是对的?唯有测试
  • 保证代码正确性是程序员口中的目标,但从思想到行动之间有巨大的落差
  • 大多数程序员不会写测试——只会用系统测试这种大粒度方式,结果又慢覆盖度又不高
  • 程序员写测试就是为了编写高质量的代码——经过测试的代码质量更高,而且要想写好测试,代码本身的质量也要高
  • 做好测试需要的基础:任务分解(10x 程序员)、软件设计(软件设计之美)、代码整洁(代码之丑)
  • 测试课的内容包括:程序员测试与测试人员测试的区别、如何编写单元测试、如何做到 100% 覆盖、如何在遗留系统上写测试等
  • 实战案例展示了从项目准备到设计到需求分解到编码的完整过程——原则变成了一行行具体的代码

本篇核心概念关系

图表渲染中…

图注:设计实战篇的核心概念关系——从程序库设计到应用设计到设计改进到代码质量,形成完整的实战闭环。设计原则贯穿始终,沟通与经验积累是隐性但关键的纽带。


与其他知识点的关联