目录
文章目录
优秀软件的指标
- 正确性
- 可读性
- 鲁棒性
- 可测试性
- 可扩展性
- 可移植性
- 性能
1. Upstream Fixed 原则
宁可在 Upstream (上游,接近问题的根源层面) 推送补丁,也不要在 Downstream (下游,远离问题根源的层面) 解决问题。即:从根本上解决问题,而不是 Workaround。
这一原则很好理解,但也难实现,困难在于:
- 如何判定问题的根源。
- 如何坚定的准守这一法则。
2. KISS(Keep it simple and stupid)原则
注:该原则面向持续演进的项目,如果所写的业务代码生命周期只有几个月则不用过于关注。
软件架构的核心挑战是快速增长的复杂性,越是大型系统,越需要简单性。大型软件设计和实现的本质是大量的工程师相互通过 “写作” 来交流一些包含丰富细节的抽象概念并且相互不断迭代的过程。—— 代码是写给人看的,而不是写给机器,代码的可读性永远是第一位。
那么:
-
软件的复杂度为什么会快速增长?因为 “软件是长出来的,不是建造出来的”,即:软件是持续演进的,而不是设计之初就已经构建完成的。
-
什么是软件的复杂度?复杂度指的是软件中那些让人理解和修改维护的困难程度。相应的,简单性,就是让理解和维护代码更容易的要素。
为此,我们将软件的复杂度分解为两个维度,都和人理解与维护软件的成本相关:
- 认知负荷(Cognitive load):理解软件的接口、设计或者实现所需要的心智负担。
- 协同成本(Collaboration cost):团队维护软件时需要在协同上额外付出的成本。
认知负荷的产生
- 定义新的概念带来认知负荷,而这种认知负荷与概念和物理世界的关联程度相关。
- 逻辑符合思维习惯程度:正反逻辑差异,逻辑嵌套和独立原子化组合。继承和组装差异。
所以,我们应该避免以下细节的出现:
- 不恰当的逻辑带来的认知成本。
- 模型失配:和现实世界不完全符合的模型带来高认知负荷。
- 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,团队设计准则和对细节的关注,来控制软件复杂度的增长。多数情况下,我们要对复杂度增长采用接近于 “零容忍” 的态度,避免 “能用就行”,原因在于:
-
复杂度增长带来的风险(unknown unknowns、不可控的失败等)往往是后知后觉的,等到问题出现时,往往已经形成一段时间,或者坑往往是很久以前埋的。
-
当我们在代码评审、设计评审时面临一个个选择时,每一个带来额外成本和复杂度的设计似乎都显得没那么有危害:就是增加了一点点复杂度而已,就是一点点风险而已。但是每一个失败的系统的问题都是这样一点点积累起来的。
-
破窗效应(Broken window):一个建筑,当有了一个破窗而不及时修补,这个建筑就会被侵入住认为是无人居住的、风雨更容易进来,更多的窗户被人有意打破,很快整个建筑会加速破败。这就是破窗效应,在软件的质量控制上这个效应非常恰当。
零容忍,并不是不让复杂度增长:我们都知道这是不可能的。我们需要的是尽力控制。因为进度而临时打破窗户也能接受,但是要尽快补上。
3. DRY(Don’t Repeat Yourself)原则
DRY 原则是 “系统中的每一部分,都必须有一个单一的、明确的、权威的代表”,即:代码和测试所构成的系统,必须能够表达所应表达的内容,但是不能含有任何重复代码。当 DRY 原则被成功应用时,一个系统中任何单个元素的修改都不需要与其逻辑无关的其他元素发生改变。此外,与之逻辑上相关的其他元素的变化均为可预见的、均匀的,并如此保持同步。
为了快速地实现一个功能,把代码 Copy 过来修改一下就用,可能是最快的方法。但是 Copy 代码往往是问题和 Bug 的根源。相同的逻辑要尽量只出现在一个地方,这样有问题的时候也就可以一次性地修复。这也是一种抽象,对于相同的逻辑,抽象到一个类或者一个函数中去,这样也有利于代码的可读性。
4. Code Review 原则
代码评审的主要目的是确保代码库的整体代码运行状况随着时间的推移而不断改善。我们应该把代码评审作为开发流程的必选项而不是可选项。
不少人认为代码评审就是用来查错的,甚至希·望用代码的缺陷数量来检验代码评审的效果。这低估了代码评审的价值。代码评审最本质的作用不是问题发现。除了代码评审,我们有更多更好的手段来发现问题。代码评审的作用更多是关于社会学的,是一种长期行为和组织文化。
代码评审具有重要的功能,可以传授开发人员关于编程语言,框架或通用软件设计原理的新知识。随着时间的推移共享知识会成为改善系统代码运行质量的一部分。但要注意,如果你的建议纯粹是带有教育性质的,并且对于满足本文所描述的标准来说并不是那么重要,那么请在前面加上“Nit:”,以使开发人员知道这只是一个改进建议,他们可以选择忽略。
- 编码者视角:良性的社交压力。
- 维护者视角:代码可读性的保证。
代码评审往往是从 CL(变更列表)或 Issue 开始的,所以评审人第一件事情就是要检查 CL 的描述,确保全面了解代码的变更。然后,代码评审应该关注以下几个方面:
- 从设计文档出发,先设计再编码。
- 从用户角度出发的 UI/UE 变更审视,即:用户视角的成功与失败,而不仅仅是代码角度的合理性。
- 查看每一行代码,每一行代码的存在是有意义的。
- 查看代码的上下文。
- 是否符合 DRY 原则。
- 是否引入复杂度。
- 是否具有单元测试。
- 命名是否符合 “意图” 原则。
- 注释是否符合 “补充” 原则,而非单纯的代码说明。
- 代码风格。
- 文档是否同步更新,没有文档比错误的文档更好。
最终评审人员应该确保:
- 代码经过精心设计。
- UI 的变更合理。
- 该代码符合我们的风格指南。
- 代码复杂性不要超过应有的程度。
- 具有适当的单元测试。
- 开发人员对所有内容都清晰的命名。
- 清晰而有用的代码注释,要解释“为什么”,而不是“什么”。
- 文档和代码都是最新的。
实际操作建议:
- 小批量,每次 Review 的代码量要少。
- 多批次,Review 要频繁发生。
- 找对人,找到合适的评审人。
- 评审人要快速响应。
5. 高单元测试覆盖率原则
单元测试是为了保证我们写出的代码确实是我们想要表达的逻辑。当我们的代码被集成到大项目中的时候,之后的集成测试、功能测试甚至 e2e 的测试,都不可能覆盖到每一行的代码了。如果单元测试做的不够,其实就是在代码里面留下一些自己都不知道的黑洞,哪天调用方改了一些东西,走到了一个不常用的分支可能就挂掉了。
单元测试就是要保证我们自己写的代码是按照我们希望的逻辑实现的,需要尽量的做到比较高的覆盖,确保我们自己的代码里面没有留下什么黑洞。
防御性编程原则
防御式编程的主要思想是:程序、函数、或方法不应该因传入错误数据而被破坏,哪怕是其他由自己编写方法和程序产生的错误数据。这种思想是将可能出现的错误造成的影响控制在有限的范围内。即:自己提供的接口,就应该自己检查输入是否准确。
好的代码,在非法输入的情况下,要么什么都不输出,要么输出错误信息。我们往往会检查每一个外部的输入(一切外部数据输入,包括但不仅限于数据库和配置中心),我们往往也会检查每一个方法的入参。我们一旦发现非法输入,根据防御式编程的思想一开始就不引入错误。
防御式编程会预设错误处理。在错误发生后的后续流程上通常会有两种选择,终止程序和继续运行。
- 终止程序,如果出现了非常严重的错误,那最好终止程序或让用户重启程序。
- 继续运行,通常也是有两种选择,本地处理和抛出错误。
- 本地处理:通常会使用默认值的方式处理。
- 抛出错误:会以异常或者错误码的形式返回。
在处理错误的时候我们还面临着另外一种选择,正确性和健壮性的选择。
- 正确性,选择正确性意味着结果永远是正确的,如果出错,宁愿不给出结果也不要给定一个不准确的值。
- 健壮性,健壮性意味着通过一些措施,保证软件能够正常运行下去,即使有时候会有一些不准确的值出现。