目录
文章目录
- 目录
- 优秀软件的指标
- 1. KISS(Keep it simple and stupid)原则
- 如何定义复杂性
- 认知负荷的产生
- 影响协同成本的因素
- 解决复杂性的一般性原则
- 横向分层设计
- 层次和抽象
- 复杂性下沉
- 纵向分模块设计
- 深模块和浅模块
- 通用和专用
- 信息隐藏
- 拆分和合并
- 良好的注释和文档
- 2. Upstream Fixed 原则
- 3. DRY(Don't Repeat Yourself)原则
- 4. 防御性编程原则
- 5. 正确的异常处理原则
- 6. 高单元测试覆盖率原则
- 7. Code Review 原则
优秀软件的指标
- 正确性
- 可读性
- 鲁棒性
- 可测试性
- 可扩展性
- 可移植性
- 性能
1. KISS(Keep it simple and stupid)原则
注:该原则面向持续演进的项目,如果所写的业务代码生命周期只有几个月则不用过于关注。
软件架构的核心挑战是快速增长的复杂性,越是大型系统,越需要简单性。那么,软件的复杂度为什么会快速增长?因为 “软件是长出来的,不是建造出来的”,即:软件是持续演进的,而不是设计之初就已经构建完成的。
如何定义复杂性
复杂度指的是软件中那些让人理解和修改维护的困难程度。相应的,简单性,就是让理解和维护代码更容易的要素。
John Ousterhout 将软件的复杂度分解为 3 个维度,都遵循着 “以人为本” 的铁则:
- 认知负荷(Cognitive load):理解软件的接口、设计或者实现所需要的心智负担。
- 协同成本(Collaboration cost):团队维护软件时需要在协同上额外付出的成本。
- 不可知性(Unknown Unknowns),开发人员在接到任务时,不知道从哪里入手。
认知负荷的产生
认知负荷产生主要源自下述两个方面:
- 定义新的概念带来认知负荷:而这种认知负荷与概念和物理世界的关联程度相关。
- 逻辑符合思维习惯程度:正反逻辑差异,逻辑嵌套和独立原子化组合。继承和组装差异。
大型软件设计和实现的本质是大量的工程师相互通过 “写作” 来交流一些包含丰富细节的抽象概念并且相互不断迭代的过程。—— 代码是写给人看的,而不是写给机器,代码的可读性永远是第一位。
所以,我们应该避免出现以下情况:
- 不恰当的逻辑带来的认知成本。
- 模型失配:和现实世界不完全符合的模型带来高认知负荷。
- API 设计不当。
- 耦合度高,内聚度低:一个简单的修改需要在多处更新。
- 命名不当:软件中的 API、方法、变量的命名,对于理解代码的逻辑、范围非常重要,也是设计者清晰传达意图的关键。我们应该使用面向 “意图” 的命名设计,而不使用 “是什么” 的命名设计。
- 不知道一个简单特性需要在哪些做修改,或者一个简单的改动会带来什么影响。即:unknown unknowns。
影响协同成本的因素
协同成本即是增长这块模块所需要付出的协同成本:
- 增加一个新的特性往往需要多个工程师协同配合,甚至多个团队协同配合;
- 测试以及上线需要协调同步。
为此,我们需要:
- 清晰的系统模块拆分与团队边界。
- 清晰服务之间的依赖。
软件之间的依赖模式,常见的有 Composition(组合)和 Inheritance(继承/扩展)模式,对于 Local 模块/类之间的依赖还是远程调用,都存在类似模式。
举例说明:
- 上图左侧是 Inheritance 模式,有四个团队,其中一个是 Framework 团队负责框架实现,框架具有三个扩展点,这三个扩展点有三个不同的团队实现插件扩展,这些插件被 Framework 调用,从架构上,这是一种类似于继承的模式。
- 右侧是 Composition 模式,底层的系统以 API 服务的方式提供接口,而上层应用或者服务通过调用这些接口来实现业务功能。
这两种模式适用于不同的系统模型:
- 当 Framework 偏向于底层、不涉及业务逻辑且相对非常稳定时,可以采用 Inheritance 模式,也即 Framework 被集成到团队 1,2,3 的业务实现当中。例如:RPC Framework 就是这样的模型。
- Composition 是更常用的模型,服务与服务之间通过 API 交互,相互解耦,业务逻辑的完整性不被破坏。
- 可测试性不足带来的协同成本。
交付给其他团队(包括 QA 团队)的代码应该包含充分的单元测试,具备良好的封装和接口描述,易于被集成测试的。然而因为 UT/FT 的不足,带来的集成阶段的复杂度升高、失败率和返工率的升高,都极大的增加了协同的成本。因此做好代码的充分单元测试,并提供良好的集成测试支持,是降低协同成本提升迭代效率的关键。
- 文档。
降低协同成本需要对 API 提供清晰的、不断保持更新一致的文档,针对 API 的场景、使用方式等给出清晰描述。最好的方式:
- 代码都公开。
- 文档和代码写在一起(README.md, *.md),随着代码一起提交和更新。
解决复杂性的一般性原则
软件架构师最重要的工作不是设计软件的结构,而是通过 API,团队设计准则和对细节的关注,来控制软件复杂度的增长。
执行原则:
- 拒绝战术编程,推崇战略编程:战略编程指重视设计并愿意投入时间,短时间内可能会降低工作效率,但是长期看,会增加系统的可维护性和迭代效率。
- 对复杂度增长零容忍:破窗效应(Broken window):一个建筑,当有了一个破窗而不及时修补,这个建筑就会被侵入住认为是无人居住的、风雨更容易进来,更多的窗户被人有意打破,很快整个建筑会加速破败。这就是破窗效应,在软件的质量控制上这个效应非常恰当。
- 设计两次:为一个类、模块或者系统的设计提供两套或更多方案,有利于我们找到最佳设计。
横向分层设计
横向分层设计,通过将系统分成若干个水平层、明确每一层的角色和分工,来降低单个层次的复杂性。
层次和抽象
软件系统由不同的层次组成,层次之间通过接口来交互。在严格分层的系统里,内部的层只对相邻的层次可见,这样就可以将一个复杂问题分解成增量步骤序列。由于每一层最多影响两层,也给维护带来了很大的便利。分层系统最有名的实例是 TCP/IP 网络模型。
在分层系统里,每一层应该具有不同的抽象。TCP/IP模型中,应用层的抽象是用户接口和交互;传输层的抽象是端口和应用之间的数据传输;网络层的抽象是基于IP的寻址和数据传输;链路层的抽象是适配和虚拟硬件设备。如果不同的层具有相同的抽象,可能存在层次边界不清晰的问题。
复杂性下沉
不应该让用户直面系统的复杂性,即便有额外的工作量,开发人员也应当尽量让用户使用起来更简单。如果一定要在某个层次处理复杂性,这个层次越低越好。
复杂性下沉,并不是说把所有功能下移到一个层次,过犹不及。如果复杂性跟下层的功能相关,或者下移后,能大大下降其他层次或整体的复杂性,则下移。
纵向分模块设计
纵向分模块设计,降低了单模块的复杂性。但是也会引入新的复杂性,例如模块与模块的交互。
深模块和浅模块
理想情况下,模块之间应该是相互隔离的,开发人员面对具体的任务,只需要接触和了解整个系统的一小部分,而无需了解或改动其他模块。深模块(Deep Module)指的是拥有强大功能和简单接口的模块。深模块是抽象的最佳实践,通过排除模块内部不重要的信息,让用户更容易理解和使用。
Unix 操作系统文件 I/O 是典型的深模块,以 Open 函数为例,接口接受文件名为参数,返回文件描述符。但是这个接口的背后,是几百行的实现代码,用来处理文件存储、权限控制、并发控制、存储介质等等,这些对用户是不可见的。
与深模块相对的是浅模块(Shallow Module),功能简单,接口复杂。通常情况下,浅模块无助于解决复杂性。因为他们提供的收益(功能)被学习和使用成本抵消了。
以 Java I/O 为例,从 I/O 中读取对象时,需要同时创建三个对象 FileInputStream、BufferedInputStream、ObjectInputStream,其中前两个创建后不会被直接使用,这就给开发人员造成了额外的负担。
通用和专用
设计新模块时,应该设计成通用模块还是专用模块?
- 一种观点认为通用模块满足多种场景,在未来遇到预期外的需求时,可以节省时间。
- 另外一种观点则认为,未来的需求很难预测,没必要引入用不到的特性,专用模块可以快速满足当前的需求,等有后续需求时再重构成通用的模块也不迟。
以上两种思路都有道理,实际操作的时候可以采用两种方式各自的优点,即:
- 在功能实现上满足当前的需求,便于快速实现;
- 接口设计通用化,为未来留下余量。
设计通用性接口需要权衡,既要满足当前的需求,同时在通用性方面不要过度设计。一些可供参考的标准:
- 满足当前需求最简单的接口是什么?在不减少功能的前提下,减少方法的数量,意味着接口的通用性提升了。
- 接口使用的场景有多少?如果接口只有一个特定的场景,可以将多个这样的接口合并成通用接口。
- 满足当前需求情况下,接口的易用性如何?如果接口很难使用,意味着我们可能过度设计了,需要拆分。
信息隐藏
信息隐藏是指,程序的设计思路以及内部逻辑应当包含在模块内部,对其他模块不可见。如果一个模块隐藏了很多信息,说明这个模块在提供很多功能的同时又简化了接口,符合前面提到的深模块理念。软件设计领域有个技巧,定义一个“大”类有助于实现信息隐藏。这里的“大”类指的是,如果要实现某功能,将该功能相关的信息都封装进一个类里面。
信息隐藏在降低复杂性方面主要有两个作用:
- 一是简化模块接口,将模块功能以更简单、更抽象的方式表现出来,降低开发人员的认知负担;
- 二是减少模块间的依赖,使得系统迭代更轻量。
举个例子,如何从 B+ 树中存取信息是一些数据库索引的核心功能,但是数据库开发人员将这些信息隐藏了起来,同时提供简单的对外交互接口,也就是 SQL 脚本,使得产品和运营同学也能很快地上手。并且,因为有足够的抽象,数据库可以在保持外部兼容的情况下,将索引切换到散列或其他数据结构。
与信息隐藏相对的是信息暴露,表现为:设计决策体现在多个模块,造成不同模块间的依赖。举个例子,两个类能处理同类型的文件。这种情况下,可以合并这两个类,或者提炼出一个新类。工程师应当尽量减少外部模块需要的信息量。
拆分和合并
两个功能,应该放在一起还是分开?本质上能降低复杂性就好。这里有一些可以借鉴的设计思路:
- 共享信息的模块应当合并,比如两个模块都依赖某个配置项。
- 可以简化接口时合并,这样可以避免客户同时调用多个模块来完成某个功能。
- 可以消除重复时合并,比如抽离重复的代码到一个单独的方法中。
- 通用代码和专用代码分离,如果模块的部分功能可以通用,建议和专用部分分离。举个例子,在实际的系统设计中,我们会将专用模块放在上层,通用模块放在下层以供复用。
良好的注释和文档
注释可以记录开发人员的设计思路和程序功能,降低开发人员的认知负担和解决不可知(Unkown Unkowns)问题,让代码更容易维护。通常情况下,在程序的整个生命周期里,编码只占了少部分,大量时间花在了后续的维护上。有经验的工程师懂得这个道理,通常也会产出更高质量的注释和文档。
注释也可以作为系统设计的工具,如果只需要简单的注释就可以描述模块的设计思路和功能,说明这个模块的设计是良好的。另一方面,如果模块很难注释,说明模块没有好的抽象。
注释应当能提供代码之外额外的信息,重视 What 和 Why,而不是代码是如何实现的(How),最好不要简单地使用代码中出现过的单词。
根据抽象程度,注释可以分为低层注释和高层注释,低层次的注释用来增加精确度,补充完善程序的信息,比如变量的单位、控制条件的边界、值是否允许为空、是否需要释放资源等。高层次注释抛弃细节,只从整体上帮助读者理解代码的功能和结构。这种类型的注释更好维护,如果代码修改不影响整体的功能,注释就无需更新。在实际工作中,需要兼顾细节和抽象。低层注释拆散与对应的实现代码放在一起,高层注释一般用于描述接口。
- 注释先行,注释应该作为设计过程的一部分,写注释最好的时机是在开发的开始环节,这不仅会产生更好的文档,也会帮助产生好的设计,同时减少写文档带来的痛苦。
- 避免重复的注释。如果有重复注释,开发人员很难找到所有的注释去更新。解决方法是,可以找到醒目的地方存放注释文档,然后在代码处注明去查阅对应文档的地址。如果程序已经在外部文档中注释过了,不要在程序内部再注释了,添加注释的引用就可以了。
- 注释属于代码,而不是提交记录。一种错误的做法是将功能注释放在提交记录里,而不是放在对应代码文件里。因为开发人员通常不会去代码提交记录里去查看程序的功能描述,很不方便。
良好的设计基础是提供好的抽象。在开始编码前编写注释,可以帮助我们提炼模块的核心要素:模块或对象中最重要的功能和属性。这个过程促进我们去思考,而不是简单地堆砌代码。另一方面,注释也能够帮助我们检查自己的模块设计是否合理,正如前文中提到,深模块提供简单的接口和强大的功能,如果接口注释冗长复杂,通常意味着接口也很复杂;注释简单,意味着接口也很简单。在设计的早期注意和解决这些问题,会为我们带来长期的收益。
2. Upstream Fixed 原则
宁可在 Upstream (上游,接近问题的根源层面) 推送补丁,也不要在 Downstream (下游,远离问题根源的层面) 解决问题。即:从根本上解决问题,而不是 Workaround。
这一原则很好理解,但也难实现,困难在于:
- 如何判定问题的根源。
- 如何坚定的准守这一法则。
3. DRY(Don’t Repeat Yourself)原则
DRY 原则是 “系统中的每一部分,都必须有一个单一的、明确的、权威的代表”,即:代码和测试所构成的系统,必须能够表达所应表达的内容,但是不能含有任何重复代码。当 DRY 原则被成功应用时,一个系统中任何单个元素的修改都不需要与其逻辑无关的其他元素发生改变。此外,与之逻辑上相关的其他元素的变化均为可预见的、均匀的,并如此保持同步。
为了快速地实现一个功能,把代码 Copy 过来修改一下就用,可能是最快的方法。但是 Copy 代码往往是问题和 Bug 的根源。相同的逻辑要尽量只出现在一个地方,这样有问题的时候也就可以一次性地修复。这也是一种抽象,对于相同的逻辑,抽象到一个类或者一个函数中去,这样也有利于代码的可读性。
4. 防御性编程原则
防御式编程的主要思想是:程序、函数、或方法不应该因传入错误数据而被破坏,哪怕是其他由自己编写方法和程序产生的错误数据。这种思想是将可能出现的错误造成的影响控制在有限的范围内。即:自己提供的接口,就应该自己检查输入是否准确。
好的代码,在非法输入的情况下,要么什么都不输出,要么输出错误信息。我们往往会检查每一个外部的输入(一切外部数据输入,包括但不仅限于数据库和配置中心),我们往往也会检查每一个方法的入参。我们一旦发现非法输入,根据防御式编程的思想一开始就不引入错误。
防御式编程会预设错误处理。在错误发生后的后续流程上通常会有两种选择,终止程序和继续运行。
- 终止程序,如果出现了非常严重的错误,那最好终止程序或让用户重启程序。
- 继续运行,通常也是有两种选择,本地处理和抛出错误。
- 本地处理:通常会使用默认值的方式处理。
- 抛出错误:会以异常或者错误码的形式返回。
在处理错误的时候我们还面临着另外一种选择,正确性和健壮性的选择。
- 正确性,选择正确性意味着结果永远是正确的,如果出错,宁愿不给出结果也不要给定一个不准确的值。
- 健壮性,健壮性意味着通过一些措施,保证软件能够正常运行下去,即使有时候会有一些不准确的值出现。
5. 正确的异常处理原则
检查每一个可能的错误是一种良好的实践,特别是那些意料之外的错误。良好的日志和异常机制,是不应该出现调试的。打日志和抛异常,一定要把上下文给出来,否则,等于在毁灭案发现场。
对于异常处理,一方面:需要调试来查找错误时,往往是一种对异常处理机制的侮辱;而另一方面:异常和错误处理是造成软件复杂的罪魁祸首之一。有些开发人员错误的认为处理和上报的错误越多越好,这会导致过度防御性的编程。如果开发人员捕获了异常并不知道如何处理,直接往上层扔,这就违背了封装原则。
降低复杂度的一个原则就是尽可能减少需要处理异常的可能性。而最佳实践就是确保错误终结,例如删除一个并不存在的文件,与其上报文件不存在的异常,不如什么都不做。确保文件不存在就好了,上层逻辑不但不会被影响,还会因为不需要处理额外的异常而变得简单。
6. 高单元测试覆盖率原则
单元测试是为了保证我们写出的代码确实是我们想要表达的逻辑。当我们的代码被集成到大项目中的时候,之后的集成测试、功能测试甚至 e2e 的测试,都不可能覆盖到每一行的代码了。如果单元测试做的不够,其实就是在代码里面留下一些自己都不知道的黑洞,哪天调用方改了一些东西,走到了一个不常用的分支可能就挂掉了。
单元测试就是要保证我们自己写的代码是按照我们希望的逻辑实现的,需要尽量的做到比较高的覆盖,确保我们自己的代码里面没有留下什么黑洞。
7. Code Review 原则
代码评审的主要目的是确保代码库的整体代码运行状况随着时间的推移而不断改善。我们应该把代码评审作为开发流程的必选项而不是可选项。
不少人认为代码评审就是用来查错的,甚至希·望用代码的缺陷数量来检验代码评审的效果。这低估了代码评审的价值。代码评审最本质的作用不是问题发现。除了代码评审,我们有更多更好的手段来发现问题。代码评审的作用更多是关于社会学的,是一种长期行为和组织文化。
代码评审具有重要的功能,可以传授开发人员关于编程语言,框架或通用软件设计原理的新知识。随着时间的推移共享知识会成为改善系统代码运行质量的一部分。但要注意,如果你的建议纯粹是带有教育性质的,并且对于满足本文所描述的标准来说并不是那么重要,那么请在前面加上“Nit:”,以使开发人员知道这只是一个改进建议,他们可以选择忽略。
- 编码者视角:良性的社交压力。
- 维护者视角:代码可读性的保证。
代码评审往往是从 CL(变更列表)或 Issue 开始的,所以评审人第一件事情就是要检查 CL 的描述,确保全面了解代码的变更。然后,代码评审应该关注以下几个方面:
- 从设计文档出发,先设计再编码。
- 从用户角度出发的 UI/UE 变更审视,即:用户视角的成功与失败,而不仅仅是代码角度的合理性。
- 查看每一行代码,每一行代码的存在是有意义的。
- 查看代码的上下文。
- 是否符合 DRY 原则。
- 是否引入复杂度。
- 是否具有单元测试。
- 命名是否符合 “意图” 原则。
- 注释是否符合 “补充” 原则,而非单纯的代码说明。
- 代码风格。
- 文档是否同步更新,没有文档比错误的文档更好。
最终评审人员应该确保:
- 代码经过精心设计。
- UI 的变更合理。
- 该代码符合我们的风格指南。
- 代码复杂性不要超过应有的程度。
- 具有适当的单元测试。
- 开发人员对所有内容都清晰的命名。
- 清晰而有用的代码注释,要解释“为什么”,而不是“什么”。
- 文档和代码都是最新的。
实际操作建议:
- 一个提交只完成一件事情(一个工作单元),可以是一个特性、一个错误修复、一个依赖项升级、一个 API 变更等等。
- 小批量,每次 Review 的代码量要少。
- 多批次,Review 要频繁发生。
- 找对人,找到合适的评审人。
- 评审人要快速响应。
原则上是要将 PR 控制在 200-300 行代码。如果超过这个阈值,我们一般会将它拆分成更小的块。评审人心理上更容易接受开始和完成一小块代码的评审工作。更大的 PR 自然会让评审人推迟和拖延评审,并且在评审过程中被打断的可能性更大。作为一名评审人,如果 PR 太长,就很难深入进去。要检查的代码越多,我们越需要耗费更多脑力来理解整个代码块。
将 PR 拆分为更小的代码段,让你有更多机会在更短时间内得到更深入的评审。