{T}

设计认知篇

02 设计分析篇 →

一句话概述:软件设计是应对需求规模的"算法"——它包括模型规范两部分,其核心起点是关注点分离,而可测试性是衡量设计优劣的关键标尺。


知识图谱

图表渲染中…

图注:设计认知篇三大核心主题——软件设计的本质、关注点分离、可测试性。


开篇:软件设计,应对需求规模的"算法"

"设计是为了让软件在长期更容易适应变化。" —— Kent Beck

许多开发者在实现基本功能后,常常会产生这样的疑问:"已编写的代码是否存在更优的实现方案?若需重构,应当如何进行?"

这种困惑往往源于以下经历:

  • 厌倦了将各类代码堆砌在一起,而在出现 Bug 时,犹如"排查故障"一般在其中定位问题;
  • 厌倦了仅仅为了一个微小需求,需要在众多位置谨慎地进行各种微调,还被产品经理指责响应速度缓慢;
  • 厌倦了自己精心编写的代码,被他人在其他位置不经意地修改,导致系统崩溃;
  • ……

软件设计,是一门关注长期变化的学问。它并非程序员的入门第一课,因为初学编程的程序员首先追求的是实现具体功能,难以预见软件的长期演变。

以排序算法为例,快速排序的平均时间复杂度为 O(nlogn),而插入排序为 O(n²)。当数据规模较小时,二者差异并不显著;但当数据规模增大时,快速排序的优势则极为明显。对比两个算法的优劣,关键在于数据规模。

算法和软件设计在本质上是相同的,二者所应对的都是规模问题,区别在于:算法应对的是数据规模,而软件设计应对的是需求规模换言之,软件设计即为应对需求变化的"算法"

软件设计的学习难点,不在于掌握单一的技术点,而在于融会贯通。多态,表面上看仅是一个普通的语法规则,但只有理解其将变化部分与不变部分隔离的本质,才能深刻认识开放封闭原则、接口隔离原则和依赖倒置原则等设计原则的价值所在,进而理解某些设计模式仅为这些原则在特定场景下的应用。


1.1 软件设计到底是什么?

关于软件设计的定义,不同的开发者存在不同的理解:

  • 有观点认为,设计即讨论采用何种技术实现功能;
  • 有观点认为,设计即考虑选择哪些框架和中间件;
  • 有观点认为,设计就是设计模式;
  • 有观点认为,设计即 Controller、Service 加 Model 的架构模式;
  • ……

按照上述方式理解"软件设计",不仅知识体系会较为零散,而且缺乏根基:

  • 今日刚学会使用 Java,明日 JavaScript 便成为新宠,尚未决定转型之际,Rust 又成为众多大型企业推崇的技术方向;
  • 终于理解消息队列所解决的问题,准备学习功能强大的 Kafka 时,又获悉 Pulsar 在某些场景下表现更为优异;
  • 总算掌握了 Observer 模式,却得知 JDK 早已提供原生支持,而更优的方案应采用 Guava 的 EventBus;
  • 好不容易理清 MVC 架构的原理,却发现后端开发的主要工作已转向编写 RESTful 服务,Controller 尚未应用便需更名为 Resource;
  • ……

软件设计需要关注长期变化,需要应对需求规模的持续膨胀。上述不断流变的技术往往难以覆盖完整的软件生命周期,无法支撑系统的长期演进。

进一步探讨,软件设计究竟是什么?

核心的模型

在回答这一问题之前,首先需要思考:软件开发的目的是什么?

一个直接的答案是,软件开发旨在解决由需求引发的各种问题,其产出为一个可运行的交付物。例如,在线购物的需求,是通过电商平台这一解决方案实现的。

软件设计在此过程中的作用,即在需求和解决方案之间构建桥梁。

图表渲染中…

区别于解决简单问题,软件开发往往是一项长期的工作,会有众多人员参与其中。在这种情况下,需要建立统一的结构,以便所有参与者形成共同的理解。这就如同建筑设计中的图纸,专业人员阅后即可产生一致的认识。

在软件开发过程中,这种统一的结构即为模型,而软件设计的核心任务即是构建一套完整的模型

此处所指的模型,不仅包括用于描述业务的各种实体,也包括完成业务功能的各类组件。服务(Service)、调度器(Scheduler)等概念均为模型的体现。

模型是软件的骨架,是一个软件之所以成为该软件的核心要素。以电商平台为例,其可不采用关系型数据库而改用 NoSQL,但若缺少产品信息、订单等核心要素,便不再构成电商平台。

不少开发者对"模型"一词望而生畏,认为其内容过于高深。事实上,无需过度担心,模型的粒度具有灵活性。若将模型理解为一个个类,此为细粒度模型;若将整个系统作为一个整体来理解,则为粗粒度模型。

关于设计,有一个广为人知的准则——"高内聚、低耦合"(模块的内聚程度越高越好,模块间的耦合程度越低越好),这实际上是对模型的核心要求。一个符合"高内聚、低耦合"原则的模型能够有效地隐藏实现细节,降低理解难度,并支持后续扩展。后续课程将讨论的程序设计语言,正是提供了一个又一个的编程模型,使开发者无需面对各种硬件差异,并能够在此基础上继续构建新功能。

日常工作中使用的各类框架和技术,同样提供了丰富的模型,它们大幅降低了开发门槛。整个计算机世界正是在这样一个又一个模型的叠加中逐步构建而成。用程序员熟悉的表述即为:模型是分层的。这类似于乐高积木,由一个个基础模块构建出较大的组件,再由这些组件组成最终的成品。

这与一些开发者常规理解的 Controller、Service 分层架构略有差异。实际上,上述分层方式才是计算机行业中普遍存在的分层模式。网络模型即是一个典型的分层模型。按照 TCP/IP 协议的分层方法,网络层构建在网络接口层之上,应用层依赖传输层,而日常使用的大多数协议均属于应用层。

图表渲染中…

即便是在单个软件内部,模型同样可以分层。可以从最核心的模型开始构建,在建立核心模型之后,通过组合这些基础模型,构建出上一层的模型

以交易系统设计为例。在分析了主要的交易动作后,提出了"交易原语"的概念,包括资产冻结、解冻、出金、入金等少数几个基本操作。随后,将原先的交易动作转化为原语的组合。例如,下单操作对应资产冻结,成交操作涉及不同账户的出金和入金,撤单操作则是资产解冻。

图表渲染中…

在该结构下,由交易原语保证每个业务操作的准确性,由交易动作保证整个事务的一致性。这即是一种分层,一种基于模型的分层。

对软件设计中模型的初步认知可总结为:模型是软件的核心;模型粒度灵活可调;优秀的模型应符合"高内聚、低耦合"原则;模型支持分层,通过底层模型提供的接口构建上层模型。

后续课程的大部分内容将围绕模型展开:包括如何理解模型、建立模型、评判模型优劣等。掌握这些知识后,能够在多大粒度上应用它们,便能掌控多大范围的模块。然而,仅将软件设计理解为构建模型是不够的。模型设计也不能随意进行,需要遵循一定的约束,而这个约束,即为软件设计要构建的另一组成部分:规范。

约束的规范

如果说构建模型相对直观且易于理解(因为模型通常可以直接体现在代码中),那么软件设计的另一组成部分——规范,则常常被忽略。

规范的作用在于限定特定需求应以何种方式完成。 例如:

  • 与业务处理相关的代码,应体现在领域模型中;
  • 与网络连接相关的代码,应编写在网关组件中;
  • 与外部系统集成的代码,需要设置防腐层(Anti-corruption Layer);
  • ……

每个项目都会制定各自的规范。例如,项目中的资深成员会指导新成员某段代码应编写于何处而不应编写于他处,这即是在某种意义上的规范。虽然规范普遍存在,但问题也常常随之出现。

一种常见的问题即缺乏显式的、统一的规范。

规范的一个重要功能是维系软件的长期演化。若无显式的规范,项目的维护只能依赖于团队成员个人的判断,资深成员稍有疏忽,新成员就可能引入不规范的写法,项目便向失控的方向又迈进了一步。

许多项目中存在多种不同做法并存的现象:

  • 数据库访问方面,有的采用 MyBatis,有的采用 JDBC,也有的采用 Hibernate;
  • 外部接口设计方面,有的采用 REST 风格,有的采用 URL 表示各种操作;
  • 文件组织结构方面,有的按照业务功能划分(如产品、订单等),有的按照代码结构划分(如 Resource、Service 等);
  • ……

缺乏统一的规范时,每位新成员都会批评前人的不负责任,然后准备另起炉灶,增加新的实现方式。混乱通常由此开始。

若存在显式的、统一的规范,项目将沿着统一的方向推进。即使未来设计需要演化、规范需要调整,拥有统一的规范也比无序调整要可控得多。

关于规范,还存在另一种常见问题,即规范不符合软件设计原则

一个典型案例:某网关出现了 OOM(Out of Memory,内存溢出)故障。该网关日常内存消耗高达 150G,一次流量激增便无法承受。经过优化后,内存消耗降至 8G。

从数值上看,这是一个接近 20 倍的性能优化。实际上,此次优化最核心的内容即构建了一个防腐层,将接收到的 JSON 请求转换为普通的内存对象。而原有的做法是将 JSON 解析器解析出的对象在各处使用,由于这些对象附加了大量额外信息,导致占用了过多内存空间。

这并非技术专家攻坚克难的故事,仅仅是因原有规范不符合软件设计原则而导致的错误:外部请求的对象应在防腐层转换为内部对象。

模型与规范

建立了模型和规范之后,二者相辅相成。一个项目最初建立的模型往往需要符合特定的规范,而规范的制定也有赖于模型的支持。这就如同讨论户型设计时,可以按照多种方式组合不同的空间(模型),却不会将厨房与卫生间相邻布置(规范)。

至此,软件设计既包含构建一套完整的模型,也包括制定相应的规范。回顾开篇提出的问题:特定技术、框架和中间件仅是支撑模型实现的工具,而设计模式、Controller、Service、Model 等仅为特定的实现结果,是某些特定场景下的模型实例。

图表渲染中…

图注:软件设计 = 模型 + 规范。模型构建骨架,规范约束方向,二者相辅相成。

核心要点:软件设计应包含模型和规范两个维度。


1.2 分离关注点:软件设计至关重要的第一步

前文阐述了软件开发即解决问题的过程。最常见的解题思路是分而治之,即将问题拆分为若干子问题,在每个子问题得到解决后,再将各子问题的解决方案以恰当的方式组合起来。如何分解与组合,是软件设计中需要重点考虑的问题。

然而,在软件设计环节中,大多数开发者将焦点放在如何组合上,却忽略了至关重要的第一步:分解。或许有人认为分解非常简单——"不就将一个大系统拆分为若干子系统,再将子系统拆分为若干模块,逐层拆分而已"。然而,这种程度的分解远远不够,因为分解的粒度过大,会导致将不同性质的内容混淆在一起,从而为后续埋下诸多隐患。

一个失败的分解案例

某故障频发的清结算系统,主要职责是执行清结算操作。清结算系统属于业务规则较为复杂的系统,偶尔出现故障似乎情有可原。但经分析故障报告后发现,该系统的设计极为复杂。

其中一处问题:上游系统以推送方式向该系统发送消息。在原始实现中,开发人员发现该过程可能丢失消息,于是设计了补偿机制。

由于推送的数据此前由该系统发出,其本身具备这些数据的初始信息,于是开发人员在数据库中增加了一个状态字段,记录消息返回的情况。一旦发现消息丢失,系统便会调用上游系统接口,请求获取丢失的数据。

正是这个补偿机制,带来了一系列后续问题。当系统业务量增加时,数据库访问压力本身就很大,而在该场景下,数据丢失的概率也随之增加,用于补偿的线程频繁访问数据库——既要查找丢失的数据,又要将请求回的数据写回数据库。

换言之,一旦业务量上升,原本已不堪重负的系统负担进一步加重,系统出现性能瓶颈便在所难免。

该补偿机制的设计问题在于:上游系统向下游推送消息,本应是通信层面的问题。而在原有设计中,由于状态字段的添加,该问题被引入到业务层面。

这是一个典型的分解不当的示例,由分解粒度过大所致。开发人员仅考虑了业务功能,忽视了其他维度。技术与业务被混合在一起,随之而来的便是无尽的隐患。

理解了这一点,解决方案便清晰可见:既然消息丢失属于通信层面的问题,便应在通信层面予以解决。当时的解决方案是选择了一个吞吐量更大的消息队列,在未来可预见的业务量下,消息均不会丢失。通信层面的问题在通信层面得以解决,业务层面便不会受到影响。改造完成后,系统的稳定性得到了大幅提升。

相关的改进还包括:上游系统专门为补偿开发的接口不再需要,上游系统得以简化;系统中表示状态的字段原本还被用于业务处理中,也曾引发过其他问题,现仅用于业务处理中,职责单一化了,与此相关的问题也随之减少。

分离关注点

至此,对分解粒度过大所造成的影响已有初步了解。进一步探讨,在进行设计时应如何考虑分解?

传统上,习惯采用的分解方式是树型的。例如,按功能分解可分为:功能1、功能2、功能3等,每个功能再细分为功能1.1、功能1.2、功能2.1、功能3.1等,以此类推。

图表渲染中…

若仅从业务角度看,这似乎没有问题。但要实现一个真实的系统,不仅要考虑功能性需求,还要考虑非功能性需求。例如,前述提到的数据不可丢失、部分系统要求处理速度快等等。

这些并非与业务处于同一维度,在设计时需要能够识别这些非功能性需求。换言之,在分解问题时,存在多个维度,每个维度代表一个关注点,这就是设计中常见的概念——"分离关注点(Separation of concerns)"。

可分离的关注点非常多,稍加注意即可识别。但仍有一些可能未被注意到,从而导致混淆。最常见的一类问题是将业务处理和技术实现两个关注点混合在一起,前述清结算系统的例子即为典型代表。

对于"将业务处理和技术实现混合在一起"的问题,再举一例:若业务的处理性能跟不上,应如何解决?大多数程序员的第一反应是多线程。

多线程确实是一种解决方案。但若不加限制地将代码改为多线程实现,一系列多线程相关的问题也会随之而来——资源竞争、数据同步等。

编写正确的业务规则和处理好多线程逻辑,这是两个不同的关注点。若将二者放在同一段代码中编写,彼此影响便在所难免。解决方案十分明确:将业务处理和多线程处理的代码分离。

大部分程序员不应直接编写多线程程序。应由专门的程序员将并发处理的部分封装为框架,其他开发者在框架内编写业务代码即可。

将业务处理和技术实现混合在一起,类似的问题还有很多。例如经常被问及的如何处理分布式事务、如何实施分库分表等。事实上更应追问的是:业务是否真的需要分布式事务?是否因业务划分不清才导致了数据库的压力?

在实际项目中,程序员最容易犯的错误即认为所有问题都是技术问题,总是试图用技术手段解决所有问题。任何试图用技术去解决其他关注点问题的做法,只会陷入焦油坑之中,越挣扎,陷得越深。

另一个容易产生混淆的常见关注点是不同的数据变动方向

曾有这样一个问题:在 Java 应用中,进行数据库访问应选用 Spring Data JPA 还是 MyBatis?Spring Data JPA 简化了数据库访问流程,自动生成对应的 SQL 语句,而 MyBatis 则需要手动编写 SQL。对于普通的增删改查操作,使用 Spring Data JPA 非常便捷,但对于复杂场景,担心自动生成 SQL 的性能问题,手动编写 SQL 进行优化似乎更为直接。

继续追问:为何需要复杂查询?答案是存在一些统计报表的需求。

此处混淆关注点的根源在于:普通的增删改查操作需要频繁地修改数据库,而复杂查询的使用频率实际上很低。出现工具选择困难的原因,是将两种数据使用频率不同的场景混合在一起造成的。若将前台访问(处理增删改查)和后台访问(统计报表)分离,选择的困境便不复存在了。

不同的数据变动方向还包括:

  • 动静分离,即将变化的内容和不变的内容分开;
  • 读写分离,即将读操作和写操作分开;
  • 高频和低频访问,也可以进行分离;
  • ……

不同的数据变动方向,即是一个潜在的、可分离的关注点。

在实际项目中,可分离的关注点远不止上述几种。进行设计时,需要始终保持敏锐度以发现不同的关注点。分离关注点不仅适用于宏观层面,在微观的代码层面同样适用。例如,很多程序员喜欢编写 setter 方法,但是否真的有那么多需要改变的属性?实际上可能只是封装工作未做好而已。

分离关注点的重要性体现在两方面:一方面,不同的关注点混合在一起会带来一系列问题;另一方面,当分解得足够细致时,就会发现不同模块的共性,才有机会将相同的信息聚合在一起,为软件设计的后续步骤——组合,做好准备。

图表渲染中…

图注:两种最常见的关注点混淆及其正确的分离方式。

核心要点:分离关注点,发现的关注点越多越好,粒度越小越好。


1.3 可测试性:影响软件设计的重要因素

前文阐述了软件设计的第一步:分离关注点。本文探讨另一个经常被忽视的因素:可测试性。

在讨论可测试性之前,先思考一个问题:软件开发中最耗时的环节是什么?答案肯定不是编写代码,因为编码是一个建设性的过程。在众多项目中,集成测试可以说是浪费时间的主要环节。

典型的集成测试场景:首先花费时间打包部署一个服务端应用,然后开始测试。测试过程中发现一个 Bug,经过长时间调查,最后发现是一个简单的错误。

更为复杂的场景:多个不同项目组的人员一起进行联合测试。测出一个 Bug,经过艰苦调查,发现是另外一个模块出现问题,唯一能做的就是等待该组的同事修复 Bug 后,测试才能继续进行。而他们调查许久,结果同样只是一个简单的错误。

这种状态并不正常。尽管令人困扰,许多团队却不得不忍受。而导致此类问题的主要原因在于前期设计时便埋下了隐患——根本没有考虑"可测试性"因素

软件设计应考虑"可测试性"

软件开发所要解决的问题源于需求。需求包括两大类:第一类是功能性需求,即需要完成的业务功能;第二类是非功能性需求,即业务功能之外的其他需求。

非功能性需求又分为两大类:一类称为执行质量(Execution qualities),吞吐量、延迟、安全性等属于此类,它们均可在运行时通过运维手段观察得到;另一类称为演化质量(Evolution qualities),它们内嵌于软件的结构之中,包括可测试性、可维护性、可扩展性等。

图表渲染中…

进行设计时,功能性需求必然会纳入考量。在非功能性需求中,执行质量是许多程序员关注的重点,一般也不会被忽略。但演化质量的地位却较低,常常被忽视,尤其是其中的"可测试性"。

开发过程中积累的许多技术债务,本质上都是因为忽略了"可测试性"这一需求。

可测试性为何如此重要?因为设计的过程即是将软件拆分为一个个小模块。若不能尽可能地保证每个小模块的正确性,而仅从最外围的系统角度验证系统的正确性,这将是一个极为困难的过程。就像建造大楼,不保证钢筋、水泥、砖块的质量合格,却想要建成合格的大楼,这是荒谬的。然而,许多团队的软件开发正是这样做的。

要保证每个小模块的正确性,就要确保每个模块在开发阶段能够被测试,而要使每个模块可测试,在设计过程中就必须保证每个模块是可以被测试的——这就是可测试性。

一旦对可测试性考虑不足,就会引发一系列后续问题。复杂的系统不仅在测试方面存在困难,在集成、部署等各个环节均有其复杂性,完成一次部署往往也需要很长时间。即便是一个简单的验证工作,部署的时间成本也非常高昂,还不包括在出现问题时,在一个复杂系统中定位问题的成本。

只有将每个小模块尽可能做好,才能尽量降低对集成环境的依赖程度,从而节省后期成本。这相当于前期多投入一份资源,却节省了后期十倍的代价。

回顾思考,为何在集成测试场景中会浪费大量时间?因为该系统只能在集成测试环境中进行测试,即使是一些非常简单的问题,也只能在该阶段暴露。这些问题原本可以在更早的阶段解决,例如单元测试阶段。

那么为何这些问题会遗留到集成测试环境呢?许多程序员的回答是"难以测试"。而这"难以测试"的背后,往往是因为在设计中没有考虑"可测试性"这一因素。

进一步探讨如何在设计中考虑可测试性?实际上就是在设计时思考:该函数/模块/系统应如何进行测试。

当以此标准衡量一些系统时,可能会发现一种典型的缺陷——设计根本未考虑过测试。这类系统通常只有最外层的接口可以测试,换言之,整个系统必须集成后才能进行测试。

在实际工作中,许多公司为了进行集成测试,需要搭建所有的子系统,即一套完整的环境。这类环境需要占用大量资源,公司通常不会准备多套,造成的结果是各个团队对环境的竞争,再叠加各系统配合的问题,测试效率还会进一步降低。

因此,在设计函数/模块/系统时,必须将可测试性纳入考量,以便能够完成不同层次的测试,减少对集成环境的依赖。

具体措施包括:一方面,尽可能为每个模块提供充分的测试,使构成系统的每个模块尽可能稳定,将集成测试环境更多地作为公共验收资源;另一方面,尽可能搭建本地集成测试环境,周边的系统可采用模拟服务的方案。

在软件开发过程中考虑测试,实际上是思考软件的质量问题,而将质量控制的思考前置到开发甚至设计阶段,是软件开发从传统模式进入现代模式的重要一步。

具备可测试性的视角

在了解可测试性之后,还可以将其作为衡量标准来评估已有的设计。

例如,Singleton 模式的常规做法是将构造函数设为私有。若该 Singleton 类与其他组件协作,由于私有构造函数的存在,该类无法被继承,也就无法用子类对象进行模拟。因此,从可测试性的角度来看,Singleton 并非一个优秀的设计模式。

再如,TDD(Test-Driven Development,测试驱动开发)对于许多人而言都较为困难,主要有两方面原因:一方面是不习惯先编写测试的工作方式;但更重要的原因,是不知道如何进行测试。

因为许多模块的设计根本没有考虑过如何进行测试,要将它们单独提取出来进行测试,必然会遇到诸多问题。

举例说明,在常规架构中,服务层会调用数据库访问层的代码。若不考虑测试,代码可能编写如下:

java
class ProductService {
  // 访问数据库的对象
  private ProduceRepository repository = new ProductRepository();

  public Product find(final long id) {
    return this.repository.find(id);
  }
}

在此处,需要直接创建数据库访问对象,而要创建数据库访问对象,就需要同时连接数据库,需要准备大量的相关配置,导致测试的复杂度极高。

然而,测试该服务的目的是验证业务逻辑的正确性,这与是否使用数据库无关。因此,若考虑了可测试性,服务的依赖应变为数据访问接口:

java
class ProductService {
  // 访问数据库的对象
  private ProduceRepository repository;

  public ProductService(final ProduceRepository repository) {
    this.repository = repository;
  }

  public Product find(final long id) {
    return this.repository.find(id);
  }
}

在这种代码结构中,只需模拟数据访问接口,而用于模拟接口的 Mock 框架在各种编程语言中几乎均可找到。唯一需要保证的,是模拟对象的行为与接口定义保持一致,这比准备数据库环境的难度系数要低得多。

真正理解可测试性,还有助于把握软件开发的趋势。有 Java 开发经验的读者可能听说过 EJB(Enterprise Java Beans),它是 2000 年左右的开发主流。但如今很难听说有谁还在使用 EJB 开发新系统。

每次进行测试时,EJB 都需要部署到专门的应用服务器上。从可测试性角度分析,其测试成本极高,相应的开发成本也很高。

当年与 EJB 竞争的正是当今广泛使用的 Spring 框架,Spring 胜出的一个重要原因即它简化了开发流程。它当年的口号正是"without EJB"。这是一种重要的开发趋势:轻量级开发。而这背后重要的思维基础,正是可测试性。后续第五讲中将阐述 Spring DI 容器的设计,届时将进一步看到可测试性在其中发挥的作用。

事实上,Spring 在简化开发的道路上从未停止前进。当今的 Java 程序员在使用 Spring Boot 时,启动它就像启动一个普通的 Java 应用,可在 IDE 中进行各种调试,甚至未曾注意到其启动时底层运行着一个 Tomcat 容器。要知道,当年需要打包成 WAR 文件,再部署到 Tomcat 服务器上。曾几何时,能够连接远程 Web 服务器曾是 IDE 的一项重要功能,而如今这项功能已显得多余。

图表渲染中…

图注:软件需求分类——可测试性属于演化质量,是最容易被忽略的关键设计要素。

核心要点:进行软件设计时,请务必考虑可测试性。


本篇核心概念关系

图表渲染中…

图注:设计认知篇核心概念关系——模型与规范通过关注点分离落地,可测试性验证设计质量。


与其他知识点的关联