2

Google软件工程之过程篇

 2 years ago
source link: https://www.bmpi.dev/dev/software-engineering-at-google/process/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Google软件工程之过程篇 · 构建我的被动收入

上篇介绍了Google软件工程中的文化部分,本篇介绍软件工程中主要的过程部分,包括编码风格指南、代码评审、技术文档、自动化测试(单元测试、集成测试与较大型测试)与弃用。

以下是《Software Engineering at Google》一书第三部分过程篇的思维导图,由于此部分占全书近40%,所以本文不会详细的介绍其中的概念,想详细了解的读者建议阅读原书。本文会结合此书这部分内容分享作者的个人理解及相关经验。

测试模型的选择
金字塔模型
缓慢的测试
脆弱的测试
考虑被测试的行为
覆盖率目标是底线
仅测量单元测试覆盖率
测试原则:测试所有不想被破坏的东西
广范围测试
中范围测试
窄范围测试
Testing
Code Search
How/By who
alert fatigue
hope is not a strategy
功能无用、重复或被替代
维护成本太高
交互测试(Interaction Testing)
打桩(Stubbing)
伪造(Faking)
交互测试(Interaction Testing)
验证函数调用行为及参数
打桩(Stubbing)
赋予函数行为的过程
伪造(Faking)
API的轻量级实现
Mocking框架
缝(Seams)
DAMP原则
测试不应包含逻辑
测试名称应提现测试行为
测试行为而非方法
不清晰的测试
脆弱的测试
无法取代人工探索性测试
在厕所推广测试(TotT)
测试认证计划
针对新员工的专项课程
测试套件的陷阱
关于测试覆盖率
The Beyoncé Rule
自动化测试让持续交付变的更容易
好的测试反向提升代码设计
让代码评审更简单
测试代码是更好的文档
提升对代码变更的信心
更少的Debugging
Owner
谨慎启动新项目
演进而非弃用
被弃用的,和没有准备好的
系统启用,代码保留
系统用户越多,越难弃用
代码越少,功能越多
旧不意味着过时
过时的系统需要弃用
代码是负债而非资产
因为需要维护
为什么需要
测试替身技术
提高可维护性
自动化测试的局限
Google测试文化的历史
设计测试套件
测试代码的好处
好文档的特征
5W+1H
登陆页(Landing Page)
概念类(Conceptual)
教程类(Tutorial)
设计类(Design)
引用类(Reference)
文档流程化
文档即代码
Bug Fixes
金字塔模型
尽可能自动化
小规模评审
清晰的变更描述
友善且专业
塑造团队工程文化
促进团队知识共享
代码一致性
代码可读性
代码正确性
在设计时考虑弃用
较大型的测试
测试替身(Test Doubles)
单元测试(Unit Testing)
文档写作最佳实践
最佳实践(Best Practices)
Deprecation
Testing
Technical Documentation
Code Review
Style Guide
工具(Tool)
过程(Process)
文化(Culture)
Google软件工程

风格指南(Style Guide)

We value “simple to read" over "simple to write." (Software Engineering at Google - Style Guides and Rules)

代码可能只会被写一次,但会被读很多次。如果团队成员的代码风格都不统一,可读性会很差,所以保持团队代码风格统一很重要。

历史证明,能写的很飘逸的编程语言使用人数一般都不会很多,典型的如古老的Perl语言,可以达到“一人千面”的代码风格。而写起来中规中矩甚至没有啥高级技巧的语言如Java、Go等在工业上反而用的很多。

Google的代码风格指南不太适合一般规模的公司,所以此部分不做过多介绍。从我的个人经验来说,一般项目上会配置一套自动化的代码风格检查工具(如checkstyle),甚至会集成到流水线(Pipeline)中强制团队保持一致的代码风格。某些编程语言如Go在构建工具中也提供了gofmt的代码格式化工具。

代码风格指南只能解决一些很基本的可读性问题,如代码缩进、函数命名风格、代码行数限制等。但代码的可读性可不只体现在这些表面,更深层次的可读性问题如API语义的可读性该怎么解决?一个可行的实践是代码评审。

代码评审(Code Review)

代码评审是如此重要,以至于其在Google是必须做的一个实践过程。它能提供以下的好处:

  • 代码正确性:评审人员可能发现评审代码中的逻辑问题,从而提前消除一些潜在的Bug;
  • 代码可读性:代码能否被其他人很容易的理解?API语义设计是否合理?是否包含测试?是否有必要的文档与注释?
  • 代码一致性:代码风格是否与团队和组织保持一致?
  • 促进团队知识共享:代码评审可以让团队其他成员了解你所做工作的上下文;
  • 塑造团队工程文化:团队保持代码评审的实践,本身也是团队工程文化的一部分,能让新的成员迅速适应团队工程文化;

代码评审的最佳实践有以下:

  • 友善且专业
  • 清晰的变更描述
  • 小规模评审
  • 尽可能自动化
  • 金字塔模型

代码评审金字塔模型如下图所示:

图片来源:《The Code Review Pyramid - Gunnar Morling》

代码评审的反模式是倒金字塔模型,也就是很多时间花费在了可以自动化执行的部分比如代码风格的统一、自动化测试等,但在金字塔模型里,代码评审应该把主要的精力放在API语义、实现语义及文档等部分。

Code Review v.s. Code Diff

Diff和Review的区别在于前者是一个团队集体行动,团队成员一块看某个开发者前一天写的代码,这样的好处在于每个人都能反馈,也能了解其他人做的工作,防止一些信息不同步的问题。代码评审一般是一两个人(可能甚至是团队外部的人)去审查对方要合入主干分支的代码,更适合外部人员提交代码到主干这种GitHub PR分支管理模式。

我所在项目的团队每天会做Code Diff ,这是个必须的实践。团队规模在几人以内可以让每个人都有时间讲解自己的代码,如果代码太多,那可以给每个人一个时间限制。如果团队太大那可以拆分成多个stream来管理,总之Diff的人员不能太多,但每天都应该花时间做,因为收益要高于成本,可以统一代码风格,保证可读性,提高成员技术水平。

技术文档(Technical Documentation)

好文档的特征
5W+1H
登陆页(Landing Page)
概念类(Conceptual)
教程类(Tutorial)
设计类(Design)
引用类(Reference)
文档流程化
文档即代码
文档写作最佳实践
Technical Documentation

技术文档与代码一样应该得到开发者同等的重视,但有太多文档与代码不同步的场景出现,导致文档的可用性大大降低。为什么会出现这种问题?一方面是因为开发者重视度不够的问题,另外一方面是因为写一份好的技术文档并不是一件简单的事情。

如何写一份好的技术文档?推荐阅读如下的文章:

开发人员不喜欢文档的另外一个原因在于,代码和文档的工作流程并不相同,一般文档都存放在与代码不同的位置,比如某个FTP目录以Word的格式存在。要是文档的编写可以和代码在同一套工作流里,就能极大的降低开发者的心智负担,这正是Docs-as-code的设计理念,具体的流程实践可以看这篇文章:

测试(Testing)

工程生产力
冰激凌甜筒模型
5%E2E测试
15%集成测试
80%单元测试
同步等待其他依赖
处理大数据集
滥用Mock
糟糕的测试代码
测试稳定可靠吗
对依赖的破坏性更新有信心
对系统的正常工作有信心
异常与错误
行为正确性
测试模型的选择
金字塔模型
类比E2E测试
验证系统间交互
类比单元测试
验证系统组件交互
类比单元测试
验证代码逻辑
清晰简单的测试
测试之间应隔离
类比E2E测试
在构建和发布时执行此类测试
主要验证环境配置
类比集成测试
可跨进程但不能跨机器
类比单元测试
无法执行阻塞线程的操作
(测试替身)
缓慢的测试
脆弱的测试
考虑被测试的行为
覆盖率目标是底线
仅测量单元测试覆盖率
测试原则:测试所有不想被破坏的东西
广范围测试
中范围测试
窄范围测试
无法取代人工探索性测试
在厕所推广测试(TotT)
测试认证计划
针对新员工的专项课程
测试套件的陷阱
关于测试覆盖率
The Beyoncé Rule
自动化测试让持续交付变的更容易
好的测试反向提升代码设计
让代码评审更简单
测试代码是更好的文档
提升对代码变更的信心
更少的Debugging
自动化测试的局限
Google测试文化的历史
设计测试套件
测试代码的好处

测试是软件工程过程中很重要的一个组成部分,而这里的测试主要指自动化测试过程,人工测试占比很少。测试也有一个金字塔模型,如下图所示:

关于测试金字塔的细节,推荐阅读这篇文章:

开发人员写自动化测试有如下好处:

  • 更少的Debugging:有了自动化测试后,系统的很多行为可以通过测试代码观察到。当然Bug一旦产生说明测试代码覆盖不全面,需要补上相关的测试,久而久之,测试代码就形成了非常全面的防护网。
  • 提升对代码变更的信心:当有了测试防护网后,对代码一旦产生破坏性的更新,测试代码会失败,这就给开发人员机会在部署前去修复此问题。
  • 测试代码是更好的文档:当面对一个完全陌生的代码库时,除了有限的文档,另外一个了解系统行为的方式就是看测试代码。测试代码相比文档,有着更全面清晰的业务细节,能给予开发人员更多的信息去了解此业务系统。
  • 让代码评审更简单:测试代码相比生产代码更接近业务视角,能让评审人员从业务系统对外行为的视角去了解生产代码的意图。这样也能让评审人员做出更有效的反馈意见。
  • 好的测试反向提升代码设计:要让系统模块具备一定的测试性才能写出测试代码,所以有测试的代码从设计的角度看,其可读性与解耦度相比没有测试代码的要更高。敏捷实践中推崇的TDD(Test Driven Development)就是一种通过测试驱动出好的实现代码的实践。
  • 自动化测试让持续交付变的更容易:如果没有自动化测试的帮助,代码部署上去后出Bug的概率要更高,这会提高系统交付的时间。

没有测试代码的系统是遗留系统。

单元测试(Unit Testing)

通用支持类需有独立测试
只测试公开的接口
局部支持类无需独立测试
不包含无关的信息
通过冗余提供完整的信息
合适的测试范围
避免测试具体实现
业务行为改变
(只有在此情况下才修改测试)
Bug Fixes
(不应修改测试)
新特性
(不应修改测试)
重构
(不应修改测试)
适当冗余而非精简
测试代码简单直接
given
编写完整简洁的测试
测试状态而非交互
测试公开接口
不变的测试
DAMP原则
Descriptive And Meaningful Phrases
当在测试代码共享代码和数据时采用此规则
测试不应包含逻辑
测试名称应提现测试行为
测试行为而非方法
不清晰的测试
脆弱的测试
能作为技术文档
失败时能快速定位问题
能提高测试覆盖率
小且快,能立即获得反馈
提高可维护性
单元测试(Unit Testing)

单元测试作为占比测试金字塔最大部分的底座,重要性不言而喻。它的优势很多,但Google在多年的实践中发现,提高单元测试的可维护性非常重要。而难以维护的测试代码主要有两方面造成:

  • 测试脆弱:当在代码重构、添加新特性及修复Bug时,会出现一些测试无法跑通,只能通过修改测试的方式来解决,这说明已有的测试很脆弱。好的测试应该只有在系统的业务行为发生改变时,才需要修改生产代码和测试代码。造成测试脆弱的原因有很多种,可能的原因包括测试隔离没做好,比如依赖了很多共享的全局性状态,或者测试了非公开的函数或方法,又或者测试的粒度过细,把很多实现细节给测试了。
  • 测试不清晰:不清晰的原因也有很多方面,比如测试的名称并没有体现其测试意图,在单个测试中测试了一些不必要的行为,又或包含了很多无关的信息。

要提高可维护性,一些好的实践包括以下方面:

  • 测试行为而非方法:很多测试框架如Junit都倡导Given/When/Then三段式测试编写方式,这样可以从验收标准(Acceptance Criteria)的业务视角去编写测试,而非针对单个函数或方法去编写测试(这很容易写出脆弱的测试)。
  • 测试名称应提现测试行为:当单元测试失败时,最先看到的就是测试失败单元的名称,好的测试名称能以最直接的方式体现该测试意图,所以测试名称长一些也可以。
  • 测试不应包含逻辑:因为测试单元本身并没有额外的测试,如果测试包含了比较复杂的逻辑,可能会导致测试代码的Bug。所以测试代码中尽可能不包含逻辑计算的过程。
  • DAMP(Descriptive And Meaningful Phrases)原则:在生产代码上业界倡导DRY(Don’t repeat yourself)的基本原则。而在测试代码中,正如上面几条实践表明,一定程度上的代码冗余是有必要的,这能帮助我们编写出简单而清晰的测试代码。

单元测试的代码执行速度一定要快,但在要测试的生产代码中,可能包含了执行速度很慢的代码,比如网络或文件等I/O操作,又或者对数据库的请求,甚至需要整个应用启动来获得完整的执行环境。如何将这类慢的代码与真正要测试业务逻辑的代码隔离开来?那就是接下来要介绍的测试替身技术。

测试替身(Test Doubles)

可能让测试变得低效
难以测试有副作用的状态
可能让测试变得脆弱
测试代码包含了实现细节
可能会让测试意图不清晰
不要过度测试
仅测试必须测试的信息
需测试函数调用次序时使用
需测试函数副作用时使用
容易让测试变得脆弱
测试代码包含了实现细节
尽可能测试状态而非交互
少量函数仅依赖返回值时
在真实实现和伪造不可用时
谨慎使用打桩
需要写测试
考虑伪造的保真度
当真实实现不可用时
权衡维护成本与收益
实例构造简单
执行时间快
依赖注入(Dependency Injection)
交互测试(Interaction Testing)
打桩(Stubbing)
伪造(Faking)
交互测试(Interaction Testing)
验证函数调用行为及参数
打桩(Stubbing)
赋予函数行为的过程
伪造(Faking)
API的轻量级实现
Mocking框架
缝(Seams)
通过使用测试替身
实现可测试性的技术
测试替身技术
测试替身(Test Doubles)

测试替身能通过一些模拟或伪造的技术来控制被测试代码的执行路径,比如在OOP中我们可以通过接口的多个实现,来完成生产代码与测试代码的不同实现。

由于测试替身技术本身非常成熟,所以本文不做基本的介绍,推荐阅读这篇文章进一步了解:

在Google的多年实践中发现,测试替身很容易被滥用,造成很多脆弱的测试,而被滥用最多的就是打桩(Stubbing)技术。不同替身技术都有其适用场景,推荐的一个决策流程是:

  • 如果生产代码的执行时间足够快,那就不需要替身技术,直接测试生产代码;
  • 如果伪造(Faking)的实现成本很低,且伪造的保真度够高(能尽可能模拟真实的使用场景),则推荐使用伪造替身技术;
  • 如果在前两者都不可用的情况下,仅被测试代码只依赖少量函数或方法的返回值时,可以使用打桩(Stubbing)替身技术;
  • 交互测试(Interaction Testing)替身技术谨慎使用,如果要用也仅在需测试函数副作用或调用次序时使用,并且不要过度测试不必要的数据;

较大型的测试(Larger Testing)

提供支持和联系信息
尽可能减少定位问题的成本
如日志打印分布式追踪ID而非堆栈信息
清晰的故障定位信息
密封的SUT环境
优化测试构建时
降低内部系统超时和延迟
使测试易于理解
驱除松散性
Bug Bash
契约测试(Contract test)
单元测试覆盖不到的位置
意外的修改
混沌测试(Chaos)
用户验收测试(UAT)
A/B测试
探索性测试
部署配置测试
浏览器和设备测试
功能性测试
缺乏开发标准
维护与归属的问题
紧急行为和真空效应
预期外的行为与副作用
负载下的问题
环境配置的问题
单元测试保真度的问题
为什么需要
较大型的测试

在测试金字塔的顶端是占比只有20%的集成测试与E2E测试,虽然占比少,但其却可解决单元测试的以下问题:

  • 保真度的问题:单元测试因使用测试替身来加速执行时间,但替身与实现本身就存在保真度的问题,一旦被替身的实现发生改变,单元测试因模拟行为未变,可能造成一些意想不到的Bugs。
  • 环境配置的问题:环境的问题只能在接近生产环境的测试环境(如UAT)环境中去测试与发现问题,这是单元测试无法覆盖的测试范围。如Google的一些重大全球性的Bug都和环境配置问题有关系。
  • 负载下的问题:在压力测试下,系统的行为表现如何?性能是否能达到业务要求?这类非功能性的需求测试只能在E2E测试中完成。
  • 预期外的行为与副作用:单元测试是在开发者预期的视角下完成的,所以存在一定的视角盲区。在一个接近生产环境的测试环境测试是发现这类问题最好的办法。
  • 紧急行为和真空效应:如果系统的运行时环境发生一些意外的修改,如集群网络配置或部署配置发生变更,这类问题也只能在集成环境中发现。

较大型测试的编写与维护都是成本高昂的,在我们项目实践中,一般和业务系统强相关的集成测试和部分E2E测试都是业务开发团队完成的。但一些公共的E2E测试,比如某个全局性的功能性测试,可能由一个独立的小组完成,也可能只完成一个MVP的版本,之后由业务系统维护团队开发完成。

推荐进一步阅读的文章:

弃用(Deprecation)

Testing
Code Search
How/By who
alert fatigue
hope is not a strategy
功能无用、重复或被替代
维护成本太高
Owner
谨慎启动新项目
演进而非弃用
被弃用的,和没有准备好的
系统启用,代码保留
系统用户越多,越难弃用
代码越少,功能越多
旧不意味着过时
过时的系统需要弃用
代码是负债而非资产
因为需要维护
在设计时考虑弃用
Deprecation

代码是资产还是负债?Google的答案是负债,因为代码需要不断的维护才能正常工作。负债是有高昂的利息,降低负债最好的办法就是在不需要的时候砍掉它。而这就是弃用过程的价值。

对开发人员来说,弃用是个难以接受的过程,因为幸苦写的代码,很难下定决心去销毁它。所以一个中庸之道是在代码将要被弃用前,想办法通过演进的方式给予其二次生命。如果非要弃用,也只是停止维护和运行,旧的代码依旧会在代码仓库中可被搜索到,历史记录也会被保留。

我的个人项目实践是,下线一个系统是一件需要重视的过程。一个系统一旦被发布,它被使用的场景就很难以想象,API的用户可能会以意想不到的方式去使用它。所以尽可能通过代码搜索去找到其被使用的场景,之后再给充足的Deadline广而告之,甚至可以主动与用户沟通,确保不会让其出现大的损失。

代码可能只会被写一次,但会被读很多次。所以软件工程中的过程部分主要致力于解决代码可读性的问题。无论是风格指南、代码评审、文档甚至自动化测试,很大程度上都在为提高代码可读性。

写代码很容易,能写出易懂的代码却有难度。所以从这个角度看,写代码是个入门简单精通却难的技能,需要我们不断的精进,通过多种实践去提高这个技能。希望这篇文章能让你对写代码这件事有更多的理解。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK