复杂性
计算机科学的根本是问题分解:降低程序的复杂性。
在项目开始阶段我们进行系统设计,通常称之为瀑布模型:需求定义、系统设计、编码、测试和维护。这些阶段通常是离散的。起初不太可能足够清晰地可视化设计一个大型软件系统,有很多问题直到实施阶段才变得明显。当产生设计变更时,最初的设计者可能已经去做其他项目了。开发者尝试在不改变整体设计的情况下兼容问题,通常就会导致复杂度上升。
当今大多数软件项目采用增量敏捷开发的方式,每次迭代会暴露当前系统设计的问题,在下批功能发布前修复。这样不仅问题会变少,后续功能也能从早期的优化、实现过程中受益。
应对复杂性的常用方法有两种:
- 长期CR防劣化,如消除特殊分支、惯用法规范
- 模块化设计
下面主要讲模块化。
复杂性顽疾
复杂性有多种形式,庞大的系统不容易理解全部代码的工作原理,实现一个小改进可能需要大量努力,修复一个错误又会引入了另一个错误。它的症状表现通常有:
- 变更放大:如变量没有统一管理,导致修改网站颜色值需要改动多处
- 认知负荷:开发者完成一个需求需要知道更多信息,可能由多种历史问题导致:API 包含过多类似方法、全局变量不一致、模块之间依赖不清晰
- 未知的未知数:做之前不知道掌握哪些信息来成功执行任务,比如修改一处背景色,导致了非预期以来的变动
- 晦涩和依赖的设计导致认知负荷变大
在一个简单系统中,可以用较少的努力实现更大的改进;在一个复杂的系统中,即使是小的改进也需要大量工作。
总体复杂度 = 模块复杂度 * 开发者处理时间
战术性 vs 战略性
Facebook在创立之初以战术式的快速前进闻名,后期逐渐建设起了稳健的基础设施。Google和 VMWare就很重视高质量的代码和良好设计,用可靠的软件系统来解决复杂问题。
战术编程的主要焦点是让东西运作起来,就是俗话说的能跑就行、又不是不能用,这几乎不可能产出好的系统设计,每次需求完成都会增加一些复杂度。战术编程不会加快你的首个产品发布,一旦代码库变成了意大利面,将为生命周期支付高昂的开发成本。 战略变成从长远上看更经济。完成需求的主要目标是产出伟大的设计,而代码正好可以跑起来运行。这需要投资心态,花费额外的时间为每个类寻找一个简单的设计,并编写良好的文档。
如何对战略性编程进行投入? 在总开发时间中,投资大约 10%~20%。这个比例足够小,不会显著影响你的进度,随着时间又能带来好处。几个月后,这种方法至少比战术性编程快 10%~20%,过去投资的好处将节省出时间支付未来投资的成本。
模块化
模块必须通过函数调用或方法协同来工作,它有多种形式,如类、子系统或服务。每个模块分为两部分:接口、实现。 接口描述模块做什么,实现表达的是如何做,完成接口承诺的功能代码。 更高级别的子系统和服务也是模块,它的接口形式不同,像内核调用或HTTP请求。
接口抽象
如果接口包含不重要的细节,会增加开发者的认知负担;如果省略了真正重要的细节,会导致不清晰。 文件系统的接口设计中,存储设备上哪些块用于存储数据不重要,将数据刷新到辅助存储的规则必须在接口中可见。 微波炉背后将交流电转换成微波,用户看到的抽象只有时间和强度。 汽车的驾驶接口,并不包含电动机械、电池电力管理、防抱死刹车、巡航控制等功能细节。
Unix的文件 I/O机制接口
open(path, flags, permissions)
- path: 传入层次化文件名
- flags:标识文件是读取还是写入
- permissions: 标识访问权限
这是好的简单的接口设计,其背后的实现需要数十万行代码,有以下主要功能:
- 目录存储及文件查找方式
- 权限控制
- 文件访问方式
- 调度多并发访问
- 缓存文件,减少磁盘访问次数
- 将磁盘、闪存驱动等不同辅助存储整合到单一文件系统
这个深层模块通过三个接口参数,隐藏了重要的实现复杂性。
信息隐藏
每个模块都会封装一些知识,这些知识嵌入在实现中,对接口不可见。它包括与机制相关的数据结构和算法,及更低级别的实现细节。
- 在 B 树种存储和访问信息
- 识别逻辑块对应的物理磁盘
- 实现 TCP 网络协议
- 在多核处理器上调度线程
- 解析 JSON 文档
比如在上面 TCP 网络协议的模块里,如果后续协议变化,拥塞控制机制会相应变化,但是依赖该协议发送和接收数据的代码就无需改动。在信息隐藏的过程中,我们要额外考虑设计一些配置参数,以便外部模块调参优化性能等工作需要。
信息泄露
当一个设计决策反映在多个模块中,我们就叫它信息泄露。任何对改格式的更新都需要调整所有涉及的模块。比如两个类都处理某种特定格式的文件,当文件格式发生变化,两个类都需要修改,这种设计就叫信息泄露。此时我们要考虑重组类以便只影响单一类,重组后避免新类通过接口暴露太多知识,否则工作意义不大。
信息泄露的一个常见原因是时间分解,我们设计系统时通常遵循时间顺序。比如读写不是同时进行的,因为处于不同阶段,所以写到了两个类里。这种情况下时序是不应该反映在模块设计结构中的,要将知识编码到一处,关注执行任务需要的知识而不是时序。
例:HTTP 服务器 按照时间分解的顺序,新手在设计服务器时通常会做两个类,一个读取请求数据,一个解析它。这两个类都需要理解请求的大部分参数结构,时序也为调用者创造了额外复杂度。可以用以下思路优化:
- 增加类的大小,提供执行整个流程的单一方法
- 避免暴露 HTTP 接口参数数据,要提供更详实的深层模块
- HTTP 响应版本和默认值不要暴露在请求参数里,他们不关心
通用模块
在设计通用模块时,如果需要大量额外代码来满足当前目的,则当前的模块并没有提供正确的功能。 你的伙伴会读你的代码,软件设计的要点之一是,确定谁要知道什么,以及何时知道。 比如下面的文本编辑器例子:
我们的任务是在 GUI 编辑器里,支持不同窗口同一文件的多视图、文件修改的多级撤销重做。有人设计了如下两个方法:
void backspace(Cursor cursor)
void delete(Cursor cursor)
这种方法在用户界面和文本类之间造成了信息泄露,使用者需要在文本类上定义新方法实现后端和删除。更好的办法是设计通用的文本 API
void insert(Position position, String newText) // 在任意位置插入字符串
void delete(Position start, Position end) // 删除从start到end间的字符
Position changePosition(Position position, int numChars) // 给出numChars和position计算之后的位置
我们的删除和退格就可以这样实现
text.delete(cursor, text.changePosition(cursor, 1))
text.delete(text.changePosition(cursor, -1), cursor)
降低使用者的复杂度
尽量不要只是简单的抛出异常或参数,将难题推给 API 使用者。 系统命令的配置参数一般有很多,某些情况下底层基础设施代码很难知道哪种策略最佳,因此是合理的。但是像在网络通信场景中处理丢包的情况,当然可以让用户设置重试时间,但传输协议也可以收集成功的响应时间,用倍数作为重试间隔,从而将复杂度向下转移。
层次抽象
软件系统由多层组成,高层使用低层提供的设施,每一层都提供与上下层的抽象,操作通过调用方法在各层间上下移动。像下面两个是设计良好的系统层级关系:
- 文件系统:文件由可变长度字节组成,通过读写字节范围来更新;下一层实现了固定大小磁盘块的内存缓存;底层由设备驱动组成,在二级存储设备和内存之间移动块
- TCP:顶层抽象是机器可靠地传输到另一台的字节流,下面是机器之间尽力而为传输有界大小的数据包。
而一般遇到相邻层之间的相似抽象,很可能是类分解存在问题,我们要进行重构。下面是透传方法的重构步骤:
- 将低层类暴露给高层调用者
- 类之间重新分配责任
- 如果无法解耦,将其合并
可接受的接口重复
当方法签名相同,但作为分发器职能时,它是有用的。比如以下两种情况:
- 路由匹配:当Web服务器接收一个传入的HTTP请求时,会调用分发器检查URL规则,选择特定方法处理请求。
- 多个方法对同一接口进行不同实现:这样的方法通常在同一层中,并且不会相互调用。比如操作系统中的磁盘驱动,支持不同类型的磁盘。