谷歌有非常高的编码标准。编程风格指南规定了C++、Java、Python、JavaScript和公司内部使用的其他编程语言的规范。这些指南定义了诸如空格和变量命名之类的细节,以及谷歌代码库中允许使用哪些语言特性和编程惯用法1。在你提交任何代码变更之前,必须有另一位软件工程师对其进行审查,验证你所做的变更是否符合样式约定,代码是否具有足够的单元测试覆盖率,以及是否符合谷歌的编码标准。2
谷歌甚至要求软件工程师为他们在公司所使用的每一种编程语言正式通过代码可读性审查。软件工程师必须向一个内部委员会提交一份代码样本,并证明他们已经阅读并内化了所有编纂成文的风格指南。如果没有被委员会盖章批准,每一个代码变更就必须经过另一位已经通过可读性审查的软件工程师的审查及批准。
对代码质量设置的这种高标准,使谷歌这样一个拥有超过45,000名员工、分布在全球60多个国家/地区的组织能够以难以置信的效率扩展其规模。3,4 到2013年年底,谷歌的市值在全球所有上市公司中排名第四,进一步证明其工程实践方法足以支撑起一个庞大的成功企业。5谷歌的代码一直相对容易阅读和维护,尤其是与许多其他组织相比而言。代码质量也具有自我传播效应,新入职的软件工程师会以他们看到的优秀代码为榜样,来改进自己的代码,从而形成一个正反馈循环。我大学毕业加入谷歌的搜索质量团队后,学习编程和软件工程最佳实践的速度比在其他许多地方要快得多。
但这种优势也是有代价的。由于每一次的代码变更,无论是为100个用户还是为1000万个用户而做的,都要遵循相同的标准,因此实验性代码的成本非常高。如果一个实验失败——“实验”这个词的含义就决定了它们大多数都会失败——那么投入在这些高质量、高性能和可扩展的代码上的时间就都白费了。所以,在谷歌内敏捷地构建原型和验证新产品变得更加困难。许多急性子的软件工程师因为渴望更快地开发新产品,最终选择加入初创公司或小公司,这些公司为了达到更快的迭代速度而放弃了谷歌对代码和产品的严格要求。
对于初创公司或小公司来说,谷歌的那一套工程实践显得过于烦琐:要求新入职的软件工程师阅读并通过可读性评审,会给完成工作增加不必要的开销;对可能被抛弃的原型或实验强加严格的编码标准会扼杀新的想法;编写测试和彻底检查原型代码可能是有意义的,但笼统的要求则不然。谷歌的做法可能会造成对代码质量的过度投资,并导致所投入时间的收益递减。
归根到底,软件质量是一个权衡问题,并且不存在什么通用规则可以指导我们具体怎么做。Facebook前工程总监鲍比·约翰逊声称:“从对与错的角度思考……并不是一个非常准确或有用的观察世界的角度……与其说对错,我更愿意从有效或者无效的角度看待事情。这样能帮助我理清思路,更有效地做出决策。”6 一味地固守以“正确的方式”构建某些东西的想法,可能会使关于方案权衡以及其他可行选项的讨论陷入瘫痪。实用主义思考的是什么对实现目标有效、什么无效,这是一个更有效的视角,通过它可以更好地思考软件质量问题。
产出的软件的质量高,组织就能够扩大规模并加快软件工程师创造价值的速度,而在质量方面投资不足,则会阻碍我们快速行动。但另一方面,为了产出高质量的代码,组织对于代码审查、标准化和测试覆盖率的要求也有可能过于教条化,以至于这些流程带来的质量方面的收益越来越少,实际上降低了效率。“你必须迅速行动才能构建高质量的软件。如果不这样做,当事情或者你对事情的理解发生变化时,你就无法做出正确的反应……,”Facebook早期的软件工程师埃文·普里斯特利(Evan Priestley)写道,“而且你必须构建高质量的软件才能快速行动。如果不这样做,……你在处理代码时浪费的时间要比构建低质量的软件所节省的时间还多。”7时间最好花在什么地方?是用于增加单元测试覆盖率还是设计更多产品原型?是用于审查代码还是编写更多代码?鉴于高质量代码带来的收益,为自己和团队找到一种务实的平衡会有极高的杠杆率。
在本章中,我们将研究构建高质量代码库的几种策略,并考虑其中所涉及的权衡:优点、缺点以及实施这项策略的实用方法。我们将讨论代码审查的好处和成本,并提出一些方法,使团队可以审查代码同时又不过度影响迭代速度。我们将研究正确的抽象是怎样被用来管理复杂度和放大工程产出的,以及过早的代码泛型化如何减慢了我们的进度。我们将展示覆盖广的自动化测试如何使快速迭代成为可能,以及为什么某些测试比其他测试具有更高的杠杆率。最后,我们将探讨什么时候积累技术债是有意义的,以及应该什么时候偿还。
建立可持续的代码审查流程
工程团队对代码审查的态度各不相同。代码审查在某些团队的文化中根深蒂固,以至于软件工程师无法想象在没有代码审查的环境中工作。例如,谷歌设置了专门的软件检查流程,防止软件工程师将未经审查的代码提交到代码库中,而且每次提交时都需要至少经过另一个人的审查。
对这些软件工程师来说,代码审查的好处是显而易见的。这些好处包括:
尽早发现错误或设计上的缺陷。在开发过程的早期解决问题,所需的时间和精力更少;当软件部署到生产环境后,解决问题所需的成本就会明显增加。2008年,一项针对650家公司的12,500个项目的软件质量的研究发现,通过设计和代码审查平均可以消除85%的剩余bug。8
增加做代码变更时的责任心。如果知道团队中有人会对代码进行审查,你就不太可能在代码中添加一个快速但丑陋的临时解决方案,然后把这个烂摊子留给另一个人解决。
为如何写好代码进行积极的示范。代码审查提供了分享最佳实践的途径,软件工程师可以从自己审查的代码中学习,也可以从其他人审查的代码中学习。此外,软件工程师也会通过读到的代码潜移默化地学习。读到更好的代码就意味着他们会学着写出更好的代码。
分享代码库的知识。有人审查过你的代码,这确保了至少还有一个人熟悉你的工作,并且当你不在时可以替你解决高优先级bug或其他问题。
增加长期的敏捷性。代码的质量越高,越容易理解,修改起来越快,并且越不容易出现bug。这些都可以直接提升工程团队的迭代速度。
虽然不做代码审查的工程师也承认代码审查可以提升质量,但他们经常表示自己担心代码审查会影响迭代速度。他们认为,花在代码审查上的时间和精力可以更好地用在产品开发的其他方面。例如,Dropbox,一家成立于2007年的文件共享服务提供商,在其最初的四年里并没有正式要求进行代码审查。9尽管如此,该公司还是成功组建了一支强大的工程团队,并打造出拥有数千万用户的明星产品10,11, 在此之后他们才不得不进行代码审查以帮助提升代码质量。
从根本上讲,代码审查所能提升的代码质量与将代码审查的时间投入到其他工作上所获得的短期生产力之间存在着一种权衡。不做代码审查的团队随着其成长可能会遇到越来越大的压力,迫使其实行代码审查。新入职的软件工程师可能会错误地理解代码,从烂代码中学到坏习惯,或者开始重新造轮子——以不同的方式重新解决类似的问题,所有这些都是因为他们没有机会接触到高级软件工程师的体系化知识。
鉴于这些权衡,做代码审查究竟有没有意义?回答这个问题需要一个关键的见解:是否执行代码审查,这不应该是一个二元选择,即所有代码要么被审查,要么不被审查。相反,我们应该将代码审查视为一个连续的过程,可以采用不同的方式进行,这样既可以减少开销,又能够保持收益。
谷歌是一个极端的例子,它要求审查所有的代码变更。12而另一方面,较小的团队采用更灵活的代码审查流程。在Instagram的早期,软件工程师经常进行结对代码审查,其中一个人会在共享显示器上浏览另一个人的代码。13 Square和Twitter经常使用结对编程来代替代码审查。14,15 当我们在Ooyala引入代码审查时,会先通过电子邮件将代码审查的结果发送给团队,并且只审查核心功能中比较棘手的部分;为了提高开发速度,我们还会在代码已经被提交、推送到主分支之后再进行审查。
在Quora,我们只要求审查业务逻辑的模型和控制器代码,将网络界面呈现给用户的视图代码则无须审查。我们在大部分代码被推送到生产环境后才进行审查,因为不想减慢迭代速度。但同时,我们希望确保为未来投资高质量的代码库。比如,涉及底层细节的代码往往风险更大,因此我们经常在提交对这一类代码的变更之前进行审查;员工加入公司的时间越短,针对他们做的代码审查越有价值,因为这会使他们的代码质量和风格达到团队的标准,所以我们会尽早审查新员工的代码,并给予更多的关注。这些示例说明,我们可以通过调整代码审查流程来减少摩擦,同时享受代码审查带来的好处。
此外,代码审查工具在过去几年中有了显著改进,降低了代码审查的成本。我刚开始在谷歌工作时,软件工程师们通过邮件发送评审意见,在意见中手动引用代码的行号。其他公司在做代码审查时,团队成员要坐在会议室里通过投影仪阅读代码。而如今,GitHub和Phabricator等代码审查工具提供了轻量级的网页界面。当软件工程师在提交消息中提到某个队友的名字时,git hooks等工具可以自动向这个人发送代码审查请求。审查者可以直接在网页界面中添加行内评论,轻松查看自上一轮反馈以来代码发生的变化。代码静态检查器(Lint)可以自动检测代码与编程风格指南间的偏差,从而提高一致性。16,17 这些工具都有助于减少代码审查成本,将工程时间投入到重要的事情上:向代码提交者提供有价值的反馈。
通过实验,找到适合你和团队的代码审查的正确平衡点。在Ooyala的早期,我们的团队没有进行代码审查。但是由于低质量的代码干扰了产品开发,最终我们为提高代码质量而引入了代码审查。后来,一些团队成员甚至构建了一个名为Barkeep的开源代码审查工具,以进一步简化审查流程。18
利用抽象控制复杂性
在谷歌,我可以编写一个简单的C++ MapReduce程序来计算其搜索索引中数十亿网页中每个单词出现的频率——只需短短半小时。采用MapReduce编程框架,那些在分布式处理、网络或构建容错系统方面没有任何专业知识的软件工程师可以轻松定义大型分布式机器集群上的并行计算。我可以使用MapReduce来协调谷歌数据中心的数千台机器完成我的任务。其他软件工程师将其应用于Web索引、排名、机器学习、图形计算、数据分析、大型数据库连接和许多其他复杂的任务。19
相比之下,2005年我在麻省理工学院为硕士论文设计分布式数据库原型的经历要痛苦得多。我花了数周的时间写了数千行代码,定义分布式查询树,收集和组织计算输出,启动/停止服务器上的服务,定义自己的通信协议,设置数据序列化的格式以及优雅地从故障中恢复。所有这些工作的最终结果是:我可以在4台机器的分布式数据库上运行一个查询。20不可否认,这远远达不到谷歌的计算规模。
如果谷歌的每一位软件工程师都必须像我一样花费数周时间来组装分布式计算所需的所有组件,那么他们将需要更长的时间写更多的代码才能完成工作。而MapReduce将复杂性抽象出来,让软件工程师专注于他们真正关心的事情:应用程序逻辑。大多数使用MapReduce抽象的软件工程师不需要了解该抽象的内部细节,小型团队无须具备相关专业知识背景,就可以轻松对大量数据进行并行计算。MapReduce在谷歌内部发布的4年内,软件工程师们编写了10,000多个独特的MapReduce应用程序21,这证明正确的抽象能发挥巨大的作用。后来的抽象,比如Sawzall,甚至可以编写能够编译成MapReduce程序的简单脚本,与等效的C++程序相比,代码量仅是其十分之一。22 谷歌的MapReduce启发了流行的开源Hadoop MapReduce框架,使其他公司也能从中受益。
MapReduce的例子说明了正确的抽象为什么能大幅提高软件工程师的产出。麻省理工学院教授丹尼尔·杰克逊(Daniel Jackson)在《软件抽象》(Software Abstractions)一书中,阐述了选择正确的抽象的重要性。“选择正确的抽象,你的设计就能自然而然地转换为程序,模块的接口将会小而简单,新的功能也更易于适配而不需要进行大规模重组,”杰克逊写道,“如果选择了错误的抽象,编程时将出现一系列令人头疼的意外:接口会因为要被迫适应意料之外的交互而变得怪异和笨拙,甚至难以实现最简单的变更。”23
杰克逊的这段话谈到了正确的抽象是如何提高工程生产力的:
抽象将原始问题的复杂性简化为更易于理解的原语。使用MapReduce的软件工程师不必考虑可靠性和容错性,而是处理两个简单得多的概念:将输入从一种形式转换为另一种形式的Map函数,以及将中间数据合并并产生输出的Reduce函数。许多复杂的问题都可以使用一系列Map和Reduce转换来表达。
抽象降低了应用程序的维护成本,使未来的改进更容易应用。我用来统计单词的那个简单的MapReduce程序不超过20行自定义代码。而我在麻省理工学院编写的分布式数据库的数千行组装代码,在谷歌就没有必要写,因为这些代码所做的工作MapReduce已经都替我做了,换句话说,这数千行代码以后都不用再编写、维护或者调整了。
抽象一次性解决难题,且解决方案可以多次使用。这是“不要重复自己”(Don’t Repeat Yourself,DRY)原则的一个简单应用,24一个好的抽象会将所有共享的、通常很复杂的细节整合到一个地方。难题只需要处理和解决一次,其解决方案每使用一次就会得到一份额外的回报。
与我们在第4章中研究的节省时间的工具类似,正确的抽象可以将工程生产力提高一个数量级。强大的工程团队会在这些抽象上投入大量资金。除了MapReduce,谷歌还构建了Protocol Buffers25以可扩展的方式对结构化数据进行编码,构建了Sawzall22用于简化分布式日志处理,构建了BigTable26用于存储和管理PB级结构化数据,以及许多其他程序以提高生产力。Facebook构建了Thrift27来支持跨语言服务开发,构建了Hive28支持半结构化数据的关系式查询,构建了Tao29以简化MySQL数据库上的图形查询。在Quora,我们创建了像WebNode和LiveNode这样的抽象,从而可以很容易地实时更新网络框架中的功能30。在许多情况下,这些工具将构建新功能的时间从数周或数月缩短到数小时或数天。
与代码质量的许多其他方面一样,为问题构建抽象时也需要权衡。构建一个通用的解决方案往往比构建一个特定问题的解决方案花费的时间更多。为了达到收支平衡,抽象为未来的软件工程师节省的时间,需要超过构建抽象时所投入的时间。这种情况更可能发生在团队高度依赖的代码上(例如日志记录或用户身份验证库),而不是代码库的外围部分,因此应该将更多精力放在对核心抽象的改进上。
然而,即使是核心抽象,也有可能在前期过度投资。Asana是一家构建任务和项目管理工具的初创公司,在成立后的第一年里它几乎都在开发Luna——一种用于构建网络应用程序的新框架。该公司的团队甚至开发了自己的配套编程语言Lunascript31。Asana的工程经理杰克·哈特解释了团队早期的想法:“Asana认为,Lunascript所能赋予的抽象能力非常强大,以至于最终,编写Lunascript之后再开发Asana这样规模的网络应用,要比不用Lunascript直接开发同样规模的网络应用快得多。”32这项工程投资产生了巨大的机会成本:直到公司成立两年后,该团队才有了可以公开演示的产品。最后,团队不得不放弃他们为Lunascript编译器设定的雄心万丈的远大目标(尽管他们仍然能够重用Luna框架的某些部分),转而使用JavaScript。现在来看,在编译生成高性能代码方面有太多未解决的研究层面的问题,并且没有足够的工具支持该语言,这两种情况都分散了团队的时间和精力,无法真正开发产品。
正如在抽象上过度投资会产生高昂的成本一样,创建一个糟糕的抽象也是如此。当我们为工作寻找合适的工具时,如果发现从头开始构建一个新的抽象要比整合一个现成的、量身设计的抽象更容易,这就表明这个抽象可能设计不当。在对要解决的一般性问题有充分把握之前,如果过早地创建抽象,得到的设计往往会过度拟合那些当前已知的使用场景。其他软件工程师(甚至是你自己)可能会草率地进行修改,尽量回避抽象的缺点,或者完全避免使用这个抽象,因为它太难用了。糟糕的抽象不仅会浪费精力,也是阻碍未来发展的技术债。
那么,怎样才算是一个好的抽象呢?几年前,我参加了约书亚·布洛赫的一次讲座,他是许多Java核心库的架构师,当时是谷歌的首席软件工程师。他谈到了“如何设计一个好的API及其重要性”,论述了好的软件接口的特征,并展示了这些属性如何应用于好的抽象33。好的抽象应该:34
- 易于学习。
- 易于使用,甚至无须文档。
- 难以误用。
- 足够强大,能满足需求。
- 易于扩展。
- 适合于受众。
此外,好的抽象将复杂的概念分解为简单的概念。编程语言Clojure的作者里奇·希奇(Rich Hickey)在他的演讲“Simple Made Easy”(简单才可行)中解释说,简单的东西只扮演一个角色,完成一项任务,达成一个目标,或者处理一个概念35。简单的抽象避免了将多个概念交织在一起,这样我们就可以独立地对它们进行推理,而不是被迫把它们放在一起考虑。在构建软件时,有一些技巧可以降低附带的复杂性,如避免可变的状态,使用函数式而非命令式编程,优先使用组合而非继承,以声明方式而非命令方式表示数据操作等。
设计好的抽象需要花费大量精力。你可以通过研究别人设计的抽象来学习如何构造好的抽象。因为对一个抽象的采用会随着其易用性与收益的提高而规模化增加,所以抽象的使用率和受欢迎程度是衡量其质量的一个合理指标。可以从以下方法入手:
在工作中的代码库或GitHub上的存储库里查找流行的抽象。通读它们的文档,研究它们的源代码,并尝试进行扩展。
浏览谷歌、Facebook、LinkedIn和Twitter等科技公司的开源项目。了解为什么Protocol Buffers、Thrift、Hive和MapReduce等抽象对这些公司的发展是不可或缺的。
研究Parse、Stripe、Dropbox、Facebook和Amazon Web Services等公司开发的流行API的接口,了解开发人员可以轻松地在其平台上构建东西的原因,再反思一下你或社区其他成员不喜欢的API,并找出不喜欢它们的原因。
自动化测试
单元测试覆盖率和一定程度的集成测试覆盖率提供了一种可扩展的方式,既可以管理大型团队不断增长的代码库,也不会时常破坏构建的项目或产品。如果没有严格的自动化测试,进行全面的人工测试所需的时间可能会令人难以承受。许多bug会通过生产环境和外部的bug报告检测到,因此每一个主要功能的发布和每一次现有代码的重构都成为一种风险,导致错误率飙升,然后随着bug被报告和修复,错误率逐渐下降,如图8-1中的实线所示。36
一套全面的自动化测试可以验证新代码的质量,并保护旧代码,避免引入错误或导致其他错误,从而抚平错误率尖峰并降低整体错误率,即图8-1中改进后的虚线部分。实际上,在修改一段未经测试的代码之前,首先要添加缺失的测试,以确保所做的变更不会导致问题。类似地,在修复bug时,首先对含bug的代码进行自动化测试。这样,当代码通过测试时,你就会更有把握,这个bug是真的被解决了。
图8-1: 在有/无自动化测试的情况下错误率随时间变化的曲线
自动化测试不仅减少了bug,还带来了其他好处。最直接的好处就是减少了需要手动完成的重复性工作。我们不需要手动触发不同代码分支的变更,而可以通过编程方式快速运行大量分支以验证正确性。此外,测试越接近生产环境中的实际情况,软件工程师运行这些测试就越容易,他们也就越有可能将测试纳入开发流程并实现自动化检查。这反过来又促使软件工程师对自己的代码质量更加负责。
自动化测试还给了软件工程师更强大的信心去修改代码,尤其是重构大型项目时。在执行数千行代码的重构以提高质量或实现新的抽象时,我非常感谢单元测试所提供的保护。如果修改代码的人或团队不是代码的原始作者(这种情况很常见),并且不了解所有的边缘情况,这种保护就尤为重要。自动化测试减轻了人们因为担心可能会导致程序崩溃而对修改和改进代码怀有的恐惧心理。它使得将来对代码进行转换变得更容易。
当代码出现故障时,自动化测试有助于快速确定责任人。如果没有自动化测试,问题需要更长的时间才能被发现,而且经常会被错误地归咎于负责被损坏的功能的人,而不是提交了相关代码变更的人。Dropbox的工程经理亚历克斯·阿兰回忆,有一次,某些面向企业客户的流程神秘地停止了工作,包括他的团队在内的多个团队不得不争分夺秒地调查出了什么问题。最终,他们追溯到数据团队做出的一个看似无害的变更:一位软件工程师在数据库层调整了对象缓存的工作方式,无意中改变了内部数据库API的行为——而阿兰的团队一直依赖于旧API的行为。如果他的团队编写了自动化测试脚本来测试API的依赖关系(或者让数据工程师编写测试脚本来捕捉新旧API之间的差异),那么这个bug可能从一开始就会分配给应该负责的人,避免了浪费他的团队的时间。
最后,自动化测试提供的可执行文档说明了代码原作者考虑到的诸多情况以及如何调用代码。随着代码的增加和团队的成长,人们对代码库的平均熟悉度会降低,如果没有做足够的测试,就很难在未来进行修改。而且,就像文档一样,原始作者在对代码还记忆犹新的时候更容易编写测试脚本,而对于几个月或几年后再试图修改代码的人来说,这就没那么容易了。
然而,自动化测试虽然有益,也并不代表为所有代码进行自动化测试都是一个好主意。100%的测试覆盖率是很难实现的。有些代码会比其他代码更难进行自动化测试。此外,除非你正在开发的是任务关键型或安全关键型软件,否则教条式地要求对所有代码进行自动化测试可能会浪费时间。自动化测试的覆盖率设定为多少是一个需要权衡的问题。小的单元测试往往易于编写,虽然每一个单元测试可能只带来很小的益处,但是由它们所组成的大型单元测试库可以帮助我们快速建立起对代码正确性的信心。集成测试更难编写和维护,但创建几个关键的集成测试就是一项高杠杆率的投资。
尽管自动化测试有以上诸多好处,但培养关于自动化测试的文化还是很不容易的。可能是由于组织的惯性:人们认为编写单元测试会降低他们的迭代速度。也许还有历史的原因,由于测试脚本很难编写,部分代码没有经过测试。或者因为团队不清楚当前编写的代码是否会真正被投入生产环境——对于一个甚至可能无法交付的产品,人们是没有动力去编写自动化测试的。
这就是卡蒂克·艾亚尔(Kartik Ayyar)在领导Zynga公司的社交网络游戏《星佳城市》(Cityville)的开发时所面临的困境37。在《星佳城市》中,玩家通过建造房屋、铺设道路和经营企业,将一个虚拟城市从小型开发区发展为繁华的大都市。该游戏在发布后的50天内,月活跃用户飙升至6100多万,一度成为Facebook所有应用程序中月活跃用户最多的游戏。38艾亚尔以个人贡献者的身份加入《星佳城市》团队,当时团队中只有少数几个软件工程师,但他很快就成为这个50人团队的工程总监。
在《星佳城市》成为热门游戏之前,艾亚尔告诉我,该游戏的许多玩法并没有出现在正式发布的产品中,因此很难证明在测试方面投资的合理性。“如果真的舍弃了这么多游戏玩法,我们要在测试上投入多少成本?”他曾问过自己。此外,即使在游戏推出之后,也需要不断发布新内容来维持玩家的增长,这一需求的重要性远超过了其他需求。以增加新型建筑为形式的内容创作更是重中之重。由美术师、产品经理和软件工程师组成的团队通力合作,差不多每天发布三次新内容。他们几乎没有时间去构建自动化测试,而且自动化测试所能提供的价值也不明朗。
此外,实现高测试覆盖率是极其艰巨的任务。游戏中用于表示城市地图中某个道具的类的构造函数包含大约3000行代码,而单个城市建筑可能有50~100行配置文本,用于指定建筑的外观和依赖关系。经过排列组合后,需要做的测试总量是相当惊人的。
当一个简单的单元测试明显开始为他们节省时间时,拐点就出现了。由于建筑的依赖关系非常复杂,在部署时经常会遇到问题:软件工程师在合并将要发布的功能代码时会意外删除依赖关系。一位软件工程师最终为一座城市建筑编写了一个基本的自动化测试,以确保建筑配置所引用的图像文件确实在代码库中,不会在代码合并过程中被错误删除。这个简单的测试发现了《星佳城市》部署中的许多错误,帮助节省的时间数倍于编写测试所花费的时间。当节省的时间变得明显后,大家开始寻找其他测试策略来帮助他们更快地迭代。“嗯,我们既然检查了图像的配置,那么为什么不检查一下配置文件的其他部分呢?”卡蒂克解释道,“一旦真正开始运行这些单元测试,并将它们集成到构建中,大家就会真正看到测试节省了多少时间。”
编写第一个测试通常是最难的。有一种有效的方法帮助养成测试的习惯,尤其是在维护很少有自动化测试的大型代码库时,那就是专注于高杠杆率的测试——相对于编写它们所花的时间,这些测试可以节省大量时间,简直就是一本万利。一旦有了一些好的测试策略、测试模式和测试代码库,将来再编写测试时所需的工作量就会减少。这种投入与产出的差异有利于编写更多的测试,创建良性的反馈循环,节省更多的开发时间。让我们从最有价值的测试开始,然后逐步推进。
偿还技术债
有时,我们会以一种在短期内合理,但从长远来看可能代价高昂的方式来开发软件。我们想方设法绕开设计指南,因为这样做要比遵循它们更快、更容易;我们放弃为新功能编写测试用例,因为在截止日期之前有太多工作要完成;我们复制、粘贴和调整现有的代码块,而不是重构它们来支持我们的场景。无论是因为懒惰还是有意识地决定尽早发布产品,这些权宜之计都会增加代码库中的技术债。
技术债是指为了改善代码库的健康度和质量必须做的却被延迟的所有工作,如果不加以解决就会减慢迭代速度。维基的发明者沃德·坎宁安(Ward Cunningham)在1992年的一篇会议论文中创造了“技术债”这个词:“第一次交付代码就像负债一样。只要能迅速地用重写来偿还,一点点的债务是可以加速软件开发的,……当债务没有偿还时,危险就会发生。花在不完全正确的代码上的每一分钟都算作债务的利息。”39 就像金融债务一样,如果不能偿还技术债的本金,就意味着会有越来越多的时间和精力被用于偿还累积的利息,而不是创造价值。
过了某个阶段,过多的债务会阻碍我们取得进展。负债累累的代码难以理解,甚至更难以修改,降低了迭代速度;它更容易在无意中引入bug,这进一步增加了成功变更代码所需的时间。因此,软件工程师们主动回避这些债务缠身的代码,即便对它们进行修改可能是高杠杆率的工作。许多人决定编写迂回的解决方案,就是为了避开那些令人头疼的负债代码。
技术债不仅仅是在我们编写快速而肮脏的变通方案时累积起来的。只要是在没有完全理解问题的情况下编写软件,第一个版本最终就很可能不如我们所希望的那么整洁。随着时间的推移,我们会对更有效的工作方式产生新的见解。由于我们对问题最初的理解总是不完整的,因此不可避免会产生一点债务,这只是完成任务的一部分。
成为更高效的软件工程师的关键在于,当需要在截止日期内完成任务时,勇于承担技术债,但定期偿还它们。正如《重构》一书的作者马丁·福勒所指出的,“最常见的问题是开发组织失去对技术债的控制,并将未来大部分的开发工作花在支付技术债的巨额利息上”。40不同的组织使用不同的策略来管理技术债。Asana是一家构建在线生产力工具的初创公司,它会在每个季度末安排一次“焕然一新周”(Polish and Grease Week)活动,来偿还他们在用户界面和内部工具上积累的技术债。Quora在每次为期一周的“黑客马拉松”之后,都会投入一天的时间做技术债的清理工作。当缓慢的开发速度明显地影响团队的执行能力时,就证明技术债累积过多,一些公司会明确安排重写项目(以及应对随之而来的风险)。谷歌会举办“修复日”(Fixit)活动作为偿还技术债的轻量级机制,例如“文档修复日”(Docs Fixit)、“用户愉悦感修复日”(Customer Happiness Fixit)或“国际化修复日”(Internationalization Fixit)等,鼓励软件工程师解决特定主题的技术债问题。41 比如,LinkedIn在公司上市后将新功能的开发暂停了整整两个月。他们利用这段时间修复了一个损坏的流程——软件工程师花了一个月的时间部署新功能——然后以更快的速度恢复开发。42
然而,在许多其他公司,要由软件工程师自己来安排偿还技术债的工作,以及划定其与其他工作的优先级,甚至可能需要自己为偿还技术债所耗费的时间辩护,证明其合理性。不幸的是,技术债通常难以量化。我们对重写代码需要投入的时间和能够节省的时间越是缺乏信心,就越应该从小处着手,渐进式地解决问题。这样做可以降低风险,避免让清理技术债变得过于复杂,并让我们有机会向自己和他人证明技术债是值得偿还的。我曾经组织过一次“代码清理日”活动,我和一组队友删除了代码库中不再使用的代码。这是一项小而专注的工作,几乎没有失败的风险。此外,谁不喜欢那种扔掉不再使用的代码的畅快感觉呢?我们清除了大约3%的应用程序级代码。这个活动的合理性很容易证明,因为它帮助其他工程师节省了在代码库中陈旧且无关内容上浪费的时间。
与我们讨论过的其他权衡一样,并非所有技术债都值得偿还。我们的时间有限,时间花在偿还技术债上,就无法进行其他创造价值的工作。此外,有些技术债的利息要高于其他技术债。代码库的某个部分被读取、调用和修改的频率越高,这部分代码中技术债的利息就越高。而产品的外围代码、很少被读取或修改的代码,即使背负着大量技术债,也不会对整体开发速度产生太大的影响。
卓有成效的工程师不会盲目地偿还任何情况下的技术债,而是将有限的时间花在偿还杠杆率最高的技术债上——用最少的时间,修复代码库中被调用得最频繁的代码。这些改进会对我们的工作产生最深远的影响。