Google软件工程之过程篇

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

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

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

风格指南(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》

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

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

Code Review v.s. Code Diff

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

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

技术文档(Technical Documentation)

- Technical Documentation
  - 一些实践
    - 文档即代码
    - 文档流程化
    - 了解受众
    - 文档评审
  - 文档类型
    - 引用类(Reference)
    - 设计类(Design)
    - 教程类(Tutorial)
    - 概念类(Conceptual)
    - 登陆页(Landing Page)
  - 文档写作最佳实践
    - 5W+1H
    - 三段式
    - 好文档的特征
      - 完整
      - 准确
      - 清晰
    - 弃用文档

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

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

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

测试(Testing)

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

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

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

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

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

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

单元测试(Unit Testing)

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

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

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

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

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

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

测试替身(Test Doubles)

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

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

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

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

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

较大型的测试(Larger Testing)

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

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

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

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

推荐进一步阅读的文章:

弃用(Deprecation)

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

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

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

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

总结

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

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

更新时间: 20个月前 版本: 7c48ad151