注:该原则面向持续演进的项目,如果所写的业务代码生命周期只有几个月则不用过于关注。
软件架构的核心挑战是快速增长的复杂性,越是大型系统,越需要简单性。那么,软件的复杂度为什么会快速增长?因为 “软件是长出来的,不是建造出来的”,即:软件是持续演进的,而不是设计之初就已经构建完成的。
复杂度指的是软件中那些让人理解和修改维护的困难程度。相应的,简单性,就是让理解和维护代码更容易的要素。
John Ousterhout 将软件的复杂度分解为 3 个维度,都遵循着 “以人为本” 的铁则:
认知负荷产生主要源自下述两个方面:
大型软件设计和实现的本质是大量的工程师相互通过 “写作” 来交流一些包含丰富细节的抽象概念并且相互不断迭代的过程。—— 代码是写给人看的,而不是写给机器,代码的可读性永远是第一位。
所以,我们应该避免出现以下情况:
协同成本即是增长这块模块所需要付出的协同成本:
为此,我们需要:
软件之间的依赖模式,常见的有 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,团队设计准则和对细节的关注,来控制软件复杂度的增长。
执行原则:
横向分层设计,通过将系统分成若干个水平层、明确每一层的角色和分工,来降低单个层次的复杂性。
软件系统由不同的层次组成,层次之间通过接口来交互。在严格分层的系统里,内部的层只对相邻的层次可见,这样就可以将一个复杂问题分解成增量步骤序列。由于每一层最多影响两层,也给维护带来了很大的便利。分层系统最有名的实例是 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),最好不要简单地使用代码中出现过的单词。
根据抽象程度,注释可以分为低层注释和高层注释,低层次的注释用来增加精确度,补充完善程序的信息,比如变量的单位、控制条件的边界、值是否允许为空、是否需要释放资源等。高层次注释抛弃细节,只从整体上帮助读者理解代码的功能和结构。这种类型的注释更好维护,如果代码修改不影响整体的功能,注释就无需更新。在实际工作中,需要兼顾细节和抽象。低层注释拆散与对应的实现代码放在一起,高层注释一般用于描述接口。
良好的设计基础是提供好的抽象。在开始编码前编写注释,可以帮助我们提炼模块的核心要素:模块或对象中最重要的功能和属性。这个过程促进我们去思考,而不是简单地堆砌代码。另一方面,注释也能够帮助我们检查自己的模块设计是否合理,正如前文中提到,深模块提供简单的接口和强大的功能,如果接口注释冗长复杂,通常意味着接口也很复杂;注释简单,意味着接口也很简单。在设计的早期注意和解决这些问题,会为我们带来长期的收益。
宁可在 Upstream (上游,接近问题的根源层面) 推送补丁,也不要在 Downstream (下游,远离问题根源的层面) 解决问题。即:从根本上解决问题,而不是 Workaround。
这一原则很好理解,但也难实现,困难在于:
DRY 原则是 “系统中的每一部分,都必须有一个单一的、明确的、权威的代表”,即:代码和测试所构成的系统,必须能够表达所应表达的内容,但是不能含有任何重复代码。当 DRY 原则被成功应用时,一个系统中任何单个元素的修改都不需要与其逻辑无关的其他元素发生改变。此外,与之逻辑上相关的其他元素的变化均为可预见的、均匀的,并如此保持同步。
为了快速地实现一个功能,把代码 Copy 过来修改一下就用,可能是最快的方法。但是 Copy 代码往往是问题和 Bug 的根源。相同的逻辑要尽量只出现在一个地方,这样有问题的时候也就可以一次性地修复。这也是一种抽象,对于相同的逻辑,抽象到一个类或者一个函数中去,这样也有利于代码的可读性。
防御式编程的主要思想是:程序、函数、或方法不应该因传入错误数据而被破坏,哪怕是其他由自己编写方法和程序产生的错误数据。这种思想是将可能出现的错误造成的影响控制在有限的范围内。即:自己提供的接口,就应该自己检查输入是否准确。
好的代码,在非法输入的情况下,要么什么都不输出,要么输出错误信息。我们往往会检查每一个外部的输入(一切外部数据输入,包括但不仅限于数据库和配置中心),我们往往也会检查每一个方法的入参。我们一旦发现非法输入,根据防御式编程的思想一开始就不引入错误。
防御式编程会预设错误处理。在错误发生后的后续流程上通常会有两种选择,终止程序和继续运行。
在处理错误的时候我们还面临着另外一种选择,正确性和健壮性的选择。
检查每一个可能的错误是一种良好的实践,特别是那些意料之外的错误。良好的日志和异常机制,是不应该出现调试的。打日志和抛异常,一定要把上下文给出来,否则,等于在毁灭案发现场。
对于异常处理,一方面:需要调试来查找错误时,往往是一种对异常处理机制的侮辱;而另一方面:异常和错误处理是造成软件复杂的罪魁祸首之一。有些开发人员错误的认为处理和上报的错误越多越好,这会导致过度防御性的编程。如果开发人员捕获了异常并不知道如何处理,直接往上层扔,这就违背了封装原则。
降低复杂度的一个原则就是尽可能减少需要处理异常的可能性。而最佳实践就是确保错误终结,例如删除一个并不存在的文件,与其上报文件不存在的异常,不如什么都不做。确保文件不存在就好了,上层逻辑不但不会被影响,还会因为不需要处理额外的异常而变得简单。
单元测试是为了保证我们写出的代码确实是我们想要表达的逻辑。当我们的代码被集成到大项目中的时候,之后的集成测试、功能测试甚至 e2e 的测试,都不可能覆盖到每一行的代码了。如果单元测试做的不够,其实就是在代码里面留下一些自己都不知道的黑洞,哪天调用方改了一些东西,走到了一个不常用的分支可能就挂掉了。
单元测试就是要保证我们自己写的代码是按照我们希望的逻辑实现的,需要尽量的做到比较高的覆盖,确保我们自己的代码里面没有留下什么黑洞。
代码评审的主要目的是确保代码库的整体代码运行状况随着时间的推移而不断改善。我们应该把代码评审作为开发流程的必选项而不是可选项。
不少人认为代码评审就是用来查错的,甚至希·望用代码的缺陷数量来检验代码评审的效果。这低估了代码评审的价值。代码评审最本质的作用不是问题发现。除了代码评审,我们有更多更好的手段来发现问题。代码评审的作用更多是关于社会学的,是一种长期行为和组织文化。
代码评审具有重要的功能,可以传授开发人员关于编程语言,框架或通用软件设计原理的新知识。随着时间的推移共享知识会成为改善系统代码运行质量的一部分。但要注意,如果你的建议纯粹是带有教育性质的,并且对于满足本文所描述的标准来说并不是那么重要,那么请在前面加上“Nit:”,以使开发人员知道这只是一个改进建议,他们可以选择忽略。
代码评审往往是从 CL(变更列表)或 Issue 开始的,所以评审人第一件事情就是要检查 CL 的描述,确保全面了解代码的变更。然后,代码评审应该关注以下几个方面:
最终评审人员应该确保:
实际操作建议:
原则上是要将 PR 控制在 200-300 行代码。如果超过这个阈值,我们一般会将它拆分成更小的块。评审人心理上更容易接受开始和完成一小块代码的评审工作。更大的 PR 自然会让评审人推迟和拖延评审,并且在评审过程中被打断的可能性更大。作为一名评审人,如果 PR 太长,就很难深入进去。要检查的代码越多,我们越需要耗费更多脑力来理解整个代码块。
将 PR 拆分为更小的代码段,让你有更多机会在更短时间内得到更深入的评审。
免责声明:本文系网络转载或改编,未找到原创作者,版权归原作者所有。如涉及版权,请联系删