《Google 软件工程》阅读记录

Feb 21, 2023137 min read

我一直很好奇一家具有世界影响力的软件技术公司的工程师文化是怎样的,我想《Google 软件工程》这本书给了我一个了解和学习的机会。在翻阅这本书,解答了我工作以来一直对领导工作的误解和好奇,这本书也是一家大型软件公司的工程团队的运作经验的参考范本。书中虽然有些方案不是很完美但还是有许多值得学习借鉴的。

1. 什么是软件工程

  • 软件工程关注人的协作,更加关注代码的维护成本,主要还是体现在软件生命周期内对其维护的工作。
  • 在软件的生命周期内,我们需要具备响应依赖关系,技术或产品需求变化的能力。
  • 流程效率低下和软件迭代低下的问题会缓慢展开,需要时刻回顾,注意该问题。
  • 应该使用客观数据进行决策,而减少主观判断。

2. 如何更好地参与团队合作

  • 保持谦虚,尊重和信任。坦诚清晰,平等沟通,学会接受批评,太过于自我会影响做事的效率。
  • 早失败,快失败,常失败。
  • 软件工程是团队努力的结果,应该及早验证,小步快跑,提前暴露问题。
  • 习得无指责的回顾文化,从错误中吸取教训,通过根因分析还原整个失败过程,并记录 “回顾分析”。
  • 一份好的回顾分析文档应该包括以下内容:
    • 事故的简短摘要。
    • 事故的时间线,发现事故→ 调查事故→解决事故
    • 导致事故的主要原因。
    • 影响和破坏范围评估。
    • 可立即修复问题的行动事项。
    • 防止事故再次发生的行动事项。
    • 事故中习得的经验教训。
  • 学会了解自己和他人的个性和工作方式,少量的时间投入会有效提升协作效率。

3. 知识共享

  • 心理安全是知识共享的基础。
  • 可以考虑激励措施,鼓励分享专业知识的人和团队,提升知识共享的氛围。
  • 知识是软件工程组织最重要的无形资本,知识共享能使组织在面对变化更游刃有余。
  • 促进开放和诚实的知识共享文化可以让知识有效的在组织中传播,随着组织一起成长。
  • 将部落知识(部落知识指代团队成员脑袋中未文档化的知识)文档化可以更好的在团队甚至整个组织内部继承和流动。
  • 创建和维护一个安全的环境,可以确保新成员有信心提出问题,新专家能感到有能力帮助这些新成员,而不必担心他们的答案会受到既有专家的批评和攻击。
  • 团队互动可以营造安全的环境,推荐的模式:
    • 🙆 基本问题或低级别错误被正确引导,🙅 挑剔责备提问者
    • 🙆 解释是为了帮助提问者学习,保持谦虚态度,🙅 解释是为了炫耀自己的知识
    • 🙆 回应是亲切的,有耐心和有帮助的,🙅 回应居高临下,尖刻,无建设性的
    • 🙆 互动是为了寻找解决方案的共享讨论,🙅 互动是输家和赢家的争论
  • 持续学习,不断提问。接受不知道的事物是机会而不应是恐惧。克服冒名顶替综合症。
  • 知道的越多,就越是知道更多自己不知道的事情。作为领导者不应错误地把“年长”和“无所不知”等同起来。
  • 学会公开提问,可以是向团队,也可以是向社区请教。承认自己的知识缺失是种好行为。特别是领导者,领导者以身践行,可以让团队内成员认为提问是个正常行为。
  • 回答问题保持耐心和善意可以提升寻求帮助者的安全感,有针对性的帮助能使工程师们更快的提高工作效率,反过来也会让整个团队更有效率。
  • 主动分享你的知识,可以是讲座,文档,甚至代码也可以。

4. 平等工程

  • 人类的偏见是固有的。
  • 对于面向所有用户群体的产品设计,保持多样性是必要的。
  • 一名优秀的软件工程师,具有专业的计算机素养是基本要求,更应该理解多样性的必要性,才能实现包容且平等的工程。
  • 能够理解产品如何使不同群体的人获利或者受损也是杰出工程师的重要能力。
  • 工程师应该有技术才能,但是也应该有敏锐的洞察力。特别是应该能识别和拒绝可能导致不良结果的特性或产品的能力。
  • 在开发产品时,所有技术人员都应该考虑一切实际和直接的步骤,以避免对用户造成不变或无法充分代表用户。可以考虑更全面的用户体验调研。
  • 一个工程组织的扩大,就应该考虑多样性和包容性。

5. 团队领导的艺术

  • 工程经理:工程经理负责团队中每个人的绩效、生产力和幸福度,包括他们的技术主管,同时确保他们负责的产品满足业务需求。因为业务的需求和团队成员个人的需求并不总是一致的。
  • 技术主管:一个团队的技术主管常常向团队经理汇报,负责产品的技术方面,包括技术决策和选择、架构、优先级、速度和项目管理。TL 通常与工程经理携手合作,以确保团队为其产品配备足够的人员,确保工程师能完成符合其技能集和技能水平的任务。
  • 技术主管经理:同时兼顾团队管理和技术需求的人。工程经理和技术主管的结合。这一角色更常见。
  • 从个人贡献者到领导者,当一个产品需要有所进展,此时需要有人站出来把握方向,协调资源,帮助团队解决冲突,这是你会由个人贡献者转变成领导者角色,这有时是不可避免的,一名好的领导者应该以谦虚、尊重和信任的原则为团队服务。
  • 仆人式领导:作为一个领导者,你能做的最重要的事情就是为你的团队服务,管理团队的技术和社交健康。作为一个仆人式的领导者,你应该努力营造一种谦虚、尊重和信任的氛围。这有助于消除团队成员间的官僚障碍,帮助团队达成共识。仆人式领导者为团队清理障碍,铺平道路,并在必要的时刻提出建议,除此之外还不怕做些脏活和累活。
  • 在谦虚、尊重和信任的背景下,如果经理表现对员工明显的信任,员工会感到积极的压力,从而不辜负这种信任。一个好的经理会为团队开路,在确保他们的需求得到满足的同时,会关注他们的安全感和幸福感。
  • 传统经理担心如何完成任务,而伟大的经理关心的是要完成什么任务,并相信他们的团队能想出如何完成任务。
  • 激励团队另一种方法是让他们感到安全,这样他们就可以通过建立心理安全感来承担更大的风险,这意味着你的团队成员可以做自己,而不用担心你或其他团队成员的负面反馈。建立一个接受冒险的文化的好方法是让你的团队知道失败是可以的。
  • 扬善于公庭,规过于私室。面对失败应该将责任归咎于团队的失败,并用谦虚尊重和信任的态度来帮助团队从失败中进行吸取教训。将失败归咎于个人这会分裂团队并且使大家丧失冒险的信心。
  • 团队领导的反模式:
    • 雇佣平庸的人:对自己没信心,为了确保没人质疑自己的权威或威胁自己的工作,而雇佣可以随意摆布的人,雇佣哪些不如你聪明并不雄心勃勃的人,或者哪些比你更缺乏自信的人。这虽然能巩固你的领导地位,但你会因此做更多的工作。此时你组建了一个平庸的团队,没有你的牵引团队就不会前进,这会使团队的工作效率变得低下。雇佣比你更聪明的,可以取代你的人,这可能很困难,因为他们会挑战你,当你犯错他们可能会怼你。当然,这些人也会让你惊讶,干出出色的事情。他们有强大的自驱力,有些人可能渴望领导团队,但不应该对此产生担心,相反应该看成一个机会,这能让你有精力去领导一个更大的团队,寻找新的机会。这也是学习成长的机会,当周围都是比你聪明的人,你会更容易扩展自己的专业知识。
    • 忽视低绩效员工:忽视低绩效员工,不仅阻碍新的高绩效员工的加入,还会导致现有高绩效员工的理智。最终你会的带一支表现欠佳的团队,因为这些人根本不会自愿离开团队。有时低绩效员工在别的团队可能会表现不错。尽快与低绩效与昂沟通的好处是,你可以很好地帮助他们,刚开始你会发现他们可能只需要一些鼓励和指导便可进入生产力状态。但是当你等太久才进行沟通,很可能他们与团队的关系会变的更糟糕,你也会很沮丧,这时候已经无法帮助他们了。
    • 忽视”人“的问题:过度关注技术问题,花大量时间学习和解决技术问题。此时会忽视团队中人的因素。应该保持谦虚尊重、信任的态度对待团队中的人,了解他们的难处,给予团队成员同理心会是个不错的行为。
    • 做老好人:许多领导者不希望失去与团队建立起来的友谊,所有在成为团队领导者后,他们会格外努力保持友谊。应该注意不要把友谊与软弱的领导搞混了。在不必成为团队的亲密朋友,或是一个难对付的人的情况下,也可以领导团队并达成共识。也可以成为一个强硬的领导者,并不必放弃这一份友谊,只需要区分工作角色和友谊的界限。如果你管理的员工并不是自我管理,也不是努力工作的,那你们之间的关系是有压力的,此时应该特别注意你和那些朋友的关系。
    • 打破招聘门槛:与不得不和一个原本不应该招聘进来的员工进行打交道的成本相比,招聘成本是显得微不足道的。这种成本表现为团队生产力的丧失、团队压力,管理员工花费的时间,以及解雇员工所面临的压力。构建优秀团队所需要的原材料是竭尽全力争取更高质量的工程师。如果仍然得不到需要的工程师,这个团队注定失败。
    • 像对待孩子一样对待你的团队:像对待孩子一样对待你的团队成员,这样做会向你的团队表明你对他们的不信任。他们往往会以你对待他们的方式来对待你。如果你因为不信任你的团队,通过对他们进行微观管理,不尊重他们的能力,不给他们机会对自己的工作负责,那这是身为领导者你的责任,这是失败的。坚持雇佣优秀的人,保持对他们的信任,他们也会展示对你的信任,并应付自如。
  • 团队领导的积极模式:
    • 抛弃 ”自我”(Lost the Ego):信任你的团队,保持谦虚和尊重,努力培养一种强大的团队自我意识和认同感。在你没有对团队进行微观管理的前提下,你可以肯定一线工作的员工比你更了解工作细节。你可以推动团队达成共识并帮助确定方向,但如何实现目标的具体细节最好由负责产品生产的人来决定。这会使得他们有更强的主人翁精神和责任感。尝试欣赏询问:当有人质疑你的决定或声明时,记住这个人通常只是想更好地了解你。如果你鼓励询问,你就更有可能得到那种建设性的批评,使你成为一个更好的团队的领导者。找到会给你好的建设性批评的人是非常困难的,而从 “为你工作 “的人那里得到这种批评就更难了。想一想你作为一个团队所要完成的大局,坦然接受反馈和批评;避免有地域性的冲动。当你犯了错误时要真心实意地道歉。道歉并不会使你变得脆弱。事实上,当你道歉时,你通常会得到人们的尊重,因为道歉告诉人们,你头脑冷静,善于评估情况,并且谦逊。
    • 成为禅师:作为一名工程师,你可能已经养成了怀疑和愤世嫉俗的习惯,但当你担任领导者时,你需要减少口头上的怀疑,并且在调节你的反应和保持冷静方面变得更加重要。领导者在任何时候都会被观察,你对任何事情的明显态度都会在不知不觉中被注意到,并影响到团队。在任何时候保持冷静的能力,这让你在解决问题时会非常有效。领导者应该在团队成员向他们寻求建议时,尝试通过提炼和探索提问来帮助他们自己解决问题。这会引导员工找到答案,同时也能让他们感受到领导者的尊重和信任。
    • 成为催化剂:团队领导者最常做的事情就是建立共识。努力建立团队共识是种非正式领导者经常使用的领导技能,如果你的团队希望快速行动,并且自愿把权力和方向让出来给你领导,那这就是一种共识。
    • 移除障碍:有时候,团队已经对需要做的事达成共识,但却遇到了障碍,这可能是一个技术或组织上的障碍。这种障碍对你的团队可能是难以克服的,但对你来说很容易。帮助团队了解你很愿意并且能够帮助他们解决障碍也是非常有价值的。有时你并不知道所有答案,但是你通常可以帮助找到能消除障碍的人。在许多情况下,认识正确的人比知道正确的答案更有价值。
    • 成为老师和导师:一开始教人和给他们一个自学的机会可能非常困难,但是这是有效领导的重要组成部分。一个好的导师必须权衡学员学习时间和他们为产品贡献的时间,这是团队成长过程中有效扩展团队的一部分。
    • 设定清晰的目标:如果你想让团队朝一个方向快速前进,你需要确保团队的每个成员都理解并同意这个方向。确定明确的目标和明确的优先级,并在时机到来的时候帮助团队,令其知道应该如何做出权衡。设定一个明确的目标最简单的方法是为团队创建一个简洁的使命声明。在帮助团队确定方向和目标后,可以给团队更多的自主权,定期检查以确保每个人都在正确的轨道上。这可以使得你腾出时间来处理其他领导任务,还可以极大地提高团队效率。
    • 坦诚:有些事情你无法告诉你的团队,或者你要告诉他们更糟糕的事情。保持坦诚是重要的,你可以告诉他们你知道的,但不能随便说。你不知道就实话实说不知道,不能说就实话实说不能说,这并不丢人。当你要提出建设性批评时,要友善且有同理心,并不是一味地恭维对方。
    • 追踪幸福感:作为一个领导者,花时间衡量他们的幸福感,可以让你的团队长期保持高效以及团队稳定。关注团队成员的幸福感,确保他们的工作得到认可,并努力确保他们对自己的工作感到满意。可以通过了解团队成员工作时间、确保工作内容不太乏味、了解工程师们如何享受工作以及下一步的期望、需要什么样的帮助和支持来提升团队幸福感。
  • 保持团队成员工作生活平衡,不要假设员工在工作之外就没有生活,对员工投入工作的时间有不切实际的期望,这会让员工对你失去尊重和对你产生失望。不应该窥探员工隐私,但对员工所经历的个人情况保持敏感,可以让你深入了解他们在特定时间的工作效率或低或高的原因。跟踪团队幸福感的一个重要部分是追踪他们的职业发展。通常是升职、学习新东西、从事一些重要的工作、以及与聪明人一起工作。如果你想成为一个有效的领导者,应该考虑如何帮助实现所有这些事情,让团队知道你在考虑这个问题。最重要的一点是,将这些隐含的目标明确化,当你给出职业建议时,就有一套真实的衡量标准来评估形势和机会。追踪幸福感还要给团队成员自我提升的机会,让他们得到认可。
  • 领导者的提示和技巧:
    • 委派工作,也要亲自动手,这能获得团队成员的好感和信任。
    • 寻找替代者,培养能替代你的领导者,能使你更轻松,使你能负责更大范围的工作。
    • 知道什么时候该搞事情,需要做事情,立马就行动,拖延会使团队陷入泥潭。
    • 保护团队远离混乱,给团队提供空中掩护。让团队了解公司上面发生的事情很重要,同样重要的是要保护团队成员免受外部不确定性和无意义的需求的影响。这主要为了使团队成员注意力不被分散,更好的工作效率。
    • 让团队知道他们做的很好,给予团队成员正反馈。
    • 当团队成员有冒险精神,想要尝试新事物时,优秀的领导者通常会对易 “回退” 的事情说 “是”。对未来几年必须支持的产品,不太容易 “回退” 的事情应该认真考虑。
  • 内在激励和外在激励: 外在激励,来自外部的力量(金钱报酬)。内部激励,人的内部激情。
  • 努力增加内在动机,是让人们成为最快乐、最有成效的人的良药。你通常能通过给他们三样东西:自主、专精和目的。

6. 大规模团队领导力

  • 总是要做决定。模糊的问题没有神奇的答案;它们都是需要找到当下正确的权衡并进行迭代。

  • 总是不在场。作为一个领导者,你的工作是建立一个组织,随着时间的推移,组织能在不需要你在场的情况下自动解决一类模糊不清的问题。

  • 总是在扩展。随着时间的推移,成功会产生更多的责任,你必须积极主动地管理这项工作的扩展,以保护你稀缺的个人时间、注意力和精力资源。

    The_spiral_of_success

7. 度量工程生产力

  • 行动导向,度量结果辅助决策。
  • 可采用 Goals-Signals-Metrics 框架指导指标的选择。
  • 工程生产力的五个主要组成元素:
    • Quality of The Code (代码质量)
    • Attention from engineers(注意力)
    • Intellectual complexity(知识复杂性)
    • Tempo and velocity(节奏和速度)
    • Satisfaction(满意度)
  • 目标提供建议,将最佳实践固化到工作流和工具中,可使目标能更有效快速发生。

8. 风格指南与规则

  • 谷歌代码风格指南
  • 风格指南的目的是为了管理好开发环境的复杂性,保持代码库的可管理性,同时保证工程师高效地工作。
  • 指定规则的指导原则:
    • 发挥规则的作用。
    • 提升可读性。
    • 保持一致性。
    • 避免容易出错的特性构造。
    • 必要时向实际情况让步,一致性很重要,但适应性更关键(如性能考量,互操作性的格式规范)。
  • 尽可能让规则自动化强制执行,集成到开发流程中。

9. 代码评审

  • 设计良好的代码评审流程和认真对待代码评审的工程师文化能提供以下好处:
    • 检查代码的正确性。
    • 确保代码变更能够被被其他工程师理解,提升可读性。
    • 增强整个代码库的一致性。
    • 有助于提升团队成员的责任感。
    • 做到知识共享。
    • 提供代码评审本身的历史记录。
  • 轻量化的代码评审能节省原本用于测试、调试和执行回归测试的时间。
  • 评审者不应因为个人意见而提出替代方案。应该在提高理解性和功能性的前提下提出替代方案。确保客观的评审。
  • 代码评审是”左移策略“重要的组成部分,提前发现缺陷并修复,能避免后期增加投入修复的资源和成本。
  • 代码评审不止为了确保代码的正确性,更重要的是确保代码变更是可理解的。
  • 代码评审还应该考虑可维护性,保持代码遵循项目的一致性标准和最佳实践,避免代码过于复杂,以便理解和维护。
  • 好的代码评审状态,它是对工程师的假设以一种规范、中立的方式提出的挑战,有助于缓和任何批评,减轻可能带来的情绪化影响。
  • 代码评审中反馈与确认的流程中产生的双向信息交换能促进知识的共享。
  • 每一次变更都成为代码库的一部分,因此代码评审产生的历史记录可以为更多的工程师提供洞见,了解变更的背景信息。
  • 代码评审的最佳实践:
    • 评审者应尊重作者对特定方案的意见,只有在作者所用的实现方案有缺陷时,才提出替代方案。
    • 严格保持评论的专业性,勿提前下结论,应考虑先询问作者为什么会采用该方案,了解作者的想法。
    • 评审者应该及时反馈意见。如果来不及评审,也应该回复看到了变更,会尽快完成评审。
    • 作者应该保持专业性,将代码评审者的评论视为待处理项是很重要的,对每个代码评审的评论都应该有回应,再没有达成共识之前,请不要将评论标记为已解决。
    • 请记住,代码评审对评审者和作者来说都是一次学习交流的机会。
    • 除非是新增功能特性,请保持每次拉起代码评审的变更是可接受的小的变更,每次变更只关注单个问题。小规模的变更的好处不仅可以让评审者更快速的严格评估变更,还能防止作者在不正确的方法上浪费更多的昂贵精力。
    • 保持清晰的变更描述,为评审提供良好的上下文信息,也为后来维护者提供变更记录。
    • 保持评审数量最少化,能减少信息噪音造成的评审收益递减。
    • 尽可能在评审前使用自动化方案来提升评审效率,如:代码静态分析,代码合并冲突检测,集成测试等自动化工具

10. 文档

  • 对工程师作者而言,文档具有以下优点:
    • 帮助制定 API。
    • 提供了维护路线图和历史记录。
    • 减少用户提出的问题,节约作者时间。
  • 像写代码一样维护文档:
    • 有遵循的政策和规则。
    • 置于源代码版本控制下来管理,保持和代码迭代相同步。
    • 明确的责任主体负责维护。
    • 进行变更评审,随着代码变更而更新文档。
    • 跟踪问题,就像在代码中跟进缺陷一样。
    • 定期评估文档质量。
    • 如果可能,可以对准确性和有效性等方面进行度量。
  • 文档类型:
    • 参考文档(清晰性:+++,准确性:+,完整性:+++)
      • 通过代码库本身的注释生成参考文档是个很好的方式,可以保证单一来源。
      • 保持代码库接口注释的一致性。
      • 文件头部能提供文件注释,以代码内容概览目的为开始,标识代码主要用例和目标读者。能在第一第二段简洁描述 API。
      • 为公共类(结构体)提供类注释,描述类、该类的重要方法和设计目的。
      • 为函数和公共方法提供描述函数功能的函数注释。函数注释可以使用主动性的语气强调其函数功能,以一个指示性动词(例如合并,删除等动作)开始,描述函数做什么和返回什么。不建议使用样板格式(例如:arguments:[],return:[])来描述,它不够清晰。
    • 设计文档(清晰性:+++,准确性:+++,完整性:+++)
      • 设计文档是是工程师开发部署新服务之前需要进行的重要工作之一,好的设计文档能够涵盖需要的关注点,包括设计目标,实现方案,以及优缺点。
    • 新手文档 (清晰性:+,准确性:+++,完整性:+)
      • 尽量不要假定任何特定设置,领域知识,若有必要,可在教程的开头将它们明确的列为先决条件说明。
      • 保持简洁,有步骤,确保每条步骤都需要用户干预。
    • 概念文档 (清晰性:+++,准确性:+,完整性:+)
      • 为扩充参考文档而提供(如解释服务架构,生命周期),强调清晰性,会牺牲部分完整性(详细使用方式和副作用等应该留在参考文档中)和准确性,主要为了概述系统和 API,提供常见用法。
    • 着陆页面 (清晰性:+,准确性:+++,完整性:+)
      • 简单概述,展示服务特色,让用户知道如何使用服务或 API。主要关注页面结构,减少详细内容。
  • 文档评审:
    • 技术评审, 保证文档的准确性。
    • 读者评审,保证文档的清晰度。
    • 写作评审,保证文档的一致性。
  • 技术写作最佳实践:
    • who,what,when,where 和 why
      • who,文档的受众。在文档中针对受众给出明显、直白的说明。
      • what,文档的写作目的。
      • when,文档的创建时间,评审时间,以及更新时间。
      • where,文档存放的地方,需要指明文档的仓库地址,所有权,版本控制以及责任,在哪里贡献文档变更。
      • why,设置该文档的原因。用户读完文档可以获得的信息,是否达到最初的期望,常写作文档介绍的时候带上该信息。
    • 文档格式:
      • 所有文档至少应该含有开头,中间,结尾。
      • 对文档进行章节划分,并向读者呈现文档涵盖内容的路线图。
      • 一个章节描述一件事。
      • 在章节的介绍性段落描述和总结要点,然后使用本节的其余部分更详细的概述要点。有助于让用户抓住重点。
    • 优秀文档的要素:
      • 完整性,准确性,清晰性是文档的三个重要要素。但是一篇优秀的文档不应该都涵盖所有要素,只要履行了其预期目标的文档就是一篇优秀文档。
      • 追求完整性,会失去清晰性,导致没有重点能关注。追求清晰性,会丢失文档的准确性,陷入复杂的漩涡(如在使用 API 文档中阐述 API 的内部实现和副作用,这会让读者失去焦点)。

11. 测试概述

  • 编写测试的目的:

    • 提前发现问题。
    • 提升变更系统的信心。
    • 重新审视系统设计。
  • 测试代码的好处:

    • 更少的调试工作,通过自动化测试快速检测代码缺陷,减少人工调试的介入。
    • 提供正反馈,增强变更的信心。在有测试用例的情况下,能更放心的进行重构和变更。
    • 改进的文档,对软件文档的补充,好的测试用例做到了可执行文档的作用,能提供边界情况下的执行情况。注意,只有在测试保持清晰简洁的情况下,测试才能发挥最好的文档作用。
    • 更简洁的评审工作,通过测试用例的自执行,能加快评审的速度,更方便的展示代码预期执行情况。
    • 深思熟虑的设计,为新编写的代码写测试代码是验证接口设计的实用方法。如果新代码很难进行测试,则可能是新代码承担过多职责或者有很难管理的依赖关系,易于测试的代码应该是模块化,松耦合,专注单一职责。
    • 快速高质量的发布,有完善的测试用例,能为项目发布提供质量保障。
  • 每个测试用例都有两个不同的维度:粒度和范围。

    • 粒度:指运行测试用例需要的资源,例如:内存,进程和时间。
    • 范围:指测试用例要验证的目标代码的路径范围(所需测试的代码量)。
  • 执行一段代码和验证其是否按预期工作是不同的。

  • 按测试粒度和测试范围进行测试类型划分:

    测试类型按粒度进行划分

  • 小型测试(单元测试):

    • 单元测试:在代码库中一个小的,集中的部分(如单个类或方法)中验证逻辑。
    • 小型测试追求速度,确定性。
    • 小型测试必须在单个进程或单个线程中运行。测试代码和被测试代码共同运行在一个进程或线程中,不能在测试中运行三方程序。
    • 小测试不能允许它们休眠(sleep),执行 I/O 操作或进行任何阻塞调用(例如:网络调用和访问磁盘)。可以使用轻量级的进程内调用来替代这些重型操作(测试替身方案)。
  • 中型测试(集成测试):

    • 集成测试:指验证少量组件之间的交互。
    • 中等测试有追求灵活性。
    • 中型测试可以跨越多个进程或多个线程,并且可以在本地机器进行阻塞调用(例如:网络调用,I/O 调用),注意只能对本地机器进行系统调用,即要求所有相关测试软件都跑在一台机器上。
  • 大型测试(系统测试):

    • 功能测试(或端到端测试,系统测试):指在验证系统中几个不同部分的交互。或测试不在单个类或方法中出现的行为。
    • 大型测试主要用在复杂和困难的测试场景。
    • 大型测试允许跨系统跨机器进行阻塞调用。被测试和测试进程可以跑在不同的机器上。
    • 大型测试一般为了验证配置和软件运行是否符合预期,而不是验证代码片段。
    • 大型测试一般考虑在构建和发布过程中运行,以免阻塞开发工作流。
  • 所有测试应该遵循的原则:

    • 封闭性,一个测试应该包含所有必要的信息来设置、执行和关闭其环境。 应努力确保隔离,尽量不对外部环境进行依赖。
    • 保持清晰简单,不应该使用条件判断和循环语句。
    • 精确定义测试粒度,能方便未来扩展测试套件,并在速度,资源,稳定性方面提供保证。
  • 测试的范围百分比划分:

    单元测试、集成测试、端对端测试的建议占比

  • 测试分布反模式:

    unknown

    • 冰淇淋模式, 手动 E2E 测试 > 集成测试 > 单元测试,不可靠,速度慢。
    • 沙漏模式, E2E 测试 > 集成测试 < 单元测试,无法提前在集成测试中发现问题。
  • 所有不想被破坏的东西都可以加上测试,如性能、行为正确性、可访问性和安全性。

  • 代码覆盖率是简单衡量代码测试质量的一个标准,建议只在单元测试进行代码覆盖率的度量,防止覆盖率膨胀(即无效的覆盖率度量),测试的质量好坏应该考虑被测试行为。

  • 失效测试:失效是不可避免的,可以考虑在单元测试中模拟异常或错误,在集成测试和系统测试中注入远程调用错误或延迟,或者加入更大的干扰因素(混沌工程技术)来影响生产网络,对不利条件的可预测和可控的响应是可靠系统的标志。

  • 提升测试套件的运行快速性和确定性。

    • 把测试代码当作生产代码,避免脆弱测试,让测试代码更健壮和有质量。
    • 使用接近微秒的轮询来获取状态转移,避免使用定时器。
    • 保持测试框架和工具尽可能简单和少,以提高工作效率,减少学习成本。使测试更易于管理。

12. 单元测试(小型测试)

  • 单元测试的特性:

    • 小型测试粒度,具有快速和确定性。
    • 编写生产代码和测试代码能同时进行,工程师只需要关注当前代码块来实现测试代码,无需关心系统设计。
    • 能达到较高水平的测试覆盖率。
    • 由于最小粒度,能更好的定位测试执行失败的代码块。
    • 每个单元测试用例能作为文档和示例。
  • 防止脆弱测试。

    • 脆弱测试指在对生产代码进行不相关变更,而不会引入任何真正的缺陷时,缺执行失败的测试。

    • 调用公共 API 来写测试用例,不应该调用它的实现细节。

      目的是为了确保纯粹重构时不修改测试。测试的作用是确保重构不会改变系统的行为。若重构需要相应修改测试,则就不应该认为是纯粹的重构工作,或者该测试可能不是以抽象接口粒度来编写的。

      以公共接口为粒度编写的测试可以作为面向用户的示例和文档。

      选择公共 API 作为测试单元的经验法则:

      • 如果一个方法或类(工具类或方法)的存在只是为了支持小部分类,则可以不把它视为一个独立单元,它的功能应该由所支持的类或方法进行测试来覆盖。
      • 如果一个包或类被设计成任何人都可访问,那么它应该构成一个被直接测试的单元。它的测试应该以用户相同的使用方式访问该单元。
      • 如果一个包或类只能被拥有它的开发者访问,被设计在一定范围内提供有用的通用功能,则它也应该被视为一个独立单元并进行测试,防止用户接口废弃暴露出测试缺口。
    • 测试状态优先,如果有必要再考虑测试交互。

      • 测试状态: 在测试代码中,通过验证被测试代码是否正确返回预期的结果。
      • 测试交互:在测试代码中,通过验证被测试代码是否正确调用预期的接口。(例如:UI 测试、接口性能测试)
      • 一般都首先考虑测试状态,而不是测试交互。
  • 编写清晰的测试代码。

    • 使测试完整整洁。

      一个测试主体应该包含理解它所需的所有信息,而不包含任何无关的或分散注意力的信息。

    • 基于行为进行测试。

      因为一个方法可能包含多个行为,依据行为进行测试,可以保证当方法改动的时候,不会对原有测试进行修改,只需要扩展新的测试来测试新的行为即可,保持单元测试简单整洁。

      将测试结构化以便强调行为,以行为三断式进行组织:

      • Given,定义系统的预设条件。
      • When,定义要在系统上执行的操作。
      • Then,验证结果。
    • 以行为为测试命名,以方法对测试集合描述

      以方法对一个测试集合进行描述,在该测试集合中包含该方法相关的所有行为的测试,每个对行为的单元测试都以该行为进行命名。

    • 保持测试代码的直白,不要把逻辑放在测试代码中(常见逻辑:条件判断,循环,运算)。

      适当增加测试代码的冗余,适当重复代码是可接受的。

    • 编写清晰的失败信息。

  • 测试代码应该保持 “描述性和有意义的片段”(Descriptive And Meaningful Phrases)

    保持代码的适当性的重复,着眼于使测试更具描述性和意义,能使测试更清晰。

  • 测试代码共享

    • 共享值:
      • 使用更具描述性的常量命名共享值。
    • 共享设置:
      • 共享设置方法(setup)或初始化方法(initialization)。
      • setup 方法的最佳场景是构造被测试对象及其协作者。
      • 在需要使用共享设置的测试代码中,如果测试代码需要验证共享设置里的值,最佳实践是在测试代码片段开头重新声明设置共享设置,再进行验证操作。
    • 共享工具函数和验证函数:
      • 针对复杂的验证函数可以抽离出来作为共享函数,提升测试的清晰度。
      • 使用或建设测试基础设施(如常见的测试框架:Jest、PyTest、JUnit)等提升测试清晰度。使单测更易于编写。

13. 测试替身

  • 测试替身是一个对象或者函数,可以在测试中代表真实的实现,轻量化的测试替身保证测试的确定性和快速性。
  • 要使用测试替身,需要将代码库设计成可测试的(常用依赖注入方式来分离依赖性,方便使用测试替身),测试应该可以用测试替身替换实际实现。
  • 常用测试替身技术:
    • 伪造,Faking
      • 对 API 进行伪造实现,使其行为与实际实现类似,但不适合生产环境。
      • 伪实现的挑战,是需要确保它现在的实现和将来都具有与实际实现相似的行为。
      • 伪实现的替身能更接近实际行为,使用伪实现的替身替代真实实现,辅助测试。
      • 如果可能,建议伪实现的维护和实际实现的维护都应该交由同一个团队维护,可以保证两者的同步。
      • 在需要进行性能测试等需要获取真实数据的情况下,就应该考虑真实实现而不是伪实现。
      • 伪实现也应该需要进行测试。可以使用真实实现的测试集来进行测试,确保伪实现能符合实际实现的行为。当实际实现在演进中更新了测试集,也能快速反应在伪实现上,提醒对伪实现进行更新。
      • 如果伪实现在对测试体验提升方面产生的回报低于维护它付出的成本,那么还是考虑真实实现吧。
    • 打桩,Stubbing
      • 打桩是指将特定行为以强制硬编码的形式赋给函数方法的过程,强制指定函数要返回的值。
      • 当期望强制测试替身返回某个确定的值,使被测系统进入特定状态时,可以使用打桩技术。
      • 打桩一般用在无法容易使用实际实现,追求快速简单的情况下,就可以使用打桩的技术。
      • 过度使用打桩的负影响:
        • 使测试意图变得不清晰,后来的开发者可能需要去理解系统才能理解某个函数为啥需要打桩。
        • 使测试变得脆弱。每当依赖的函数方法真实实现发生变更时,都可能需要相对应的更新其相关的打桩的测试函数,这对测试产生了影响(理应只有公共 API 改动才可能影响测试),使得测试变得更脆弱。
        • 使测试变得低效。打桩需要了解依赖函数方法的实现细节,无法保证打桩的值符合真实实现的行为。打桩值无法改变依赖函数的内部状态,只改变了返回值,这在某些情况下会使得测试变得困难,例如验证测试状态的时候需要获取依赖函数的内部状态变化的时候,打桩是不合适的。
    • 交互测试
      • 交互测试是指在不实际调用某种函数具体实现的前提下验证函数调用情况。
      • 通过对测试替身的某个方法调用进行验证,判断替身方法是否调用来验证测试。
      • 交互测试的缺点:
        • 交互测试无法让你知道被测试系统是否正常工作,它只能验证某些函数方法是否按预期被调用过。
        • 交互测试利用了被测试系统的实现细节,为验证函数是否被调用,需要将函数暴露在测试中进行验证。这使得测试变的不清晰和脆弱。
      • 交互测试的适用场景:
        • 对无法使用实际实现和伪实现进行状态测试的时候,可以考虑使用交互测试进行替代。虽然不理想,但有比没有强些。
        • 调用函数的次数或顺序的差异会导致非预期的行为,这种很适合交互测试。
      • 遵循以下交互测试的最佳实践,能是测试更清晰:
        • 建议仅对状态变更函数(对被测系统以外的外部系统产生副作用,并没有返回值的函数)执行交互测试。
        • 避免过度指定哪些函数和参数要被验证,保证测试方法专注验证一个行为,避免在一个测试方法中验证多个行为。
  • 尽管测试替身是很好用的测试工具,但是有时还是需要使用实际实现搭建测试环境进行测试。
    • 原因是测试替身如果滞后于实际实现,则会污染测试,降低测试的置信度。

    • 使用实际实现能让测试系统更加真实。

    • 建议在中大型测试中使用实际实现更合适,测试替身最好放在单元测试中使用。

      这样做的好处是保证单测的确定性,也能在集成测试中发现真实系统变更,而测试替身没有跟进,从而导致的错误。

    • 如果实际实现是快速的,确定性的,简单的依赖关系,则实际实现在单元测试中是首选项,它优于测试替身。

14. 较大型测试(集成测试&端到端测试&系统测试)

  • 较大型测试包括集成测试和端到端测试或者系统测试。较大型测试的特点是可能非封闭,具有不确定性的特点。

  • 较大型测试可以使我们对于整个系统是否按预期运行有更大的信心。较大型测试存在的主要原因是为了解决保真度的问题。不过提高保真度意味着成本的增加。当在生产环境下灰度时故障风险也就相应增加。

  • 较大型测试的出现是为了覆盖单元测试无法顾及的场景:

    • 单元测试的测试替身保真度不够高。
    • 性能测试、负载测试或者压力测试。
    • 覆盖单元测试无法测试到的没有被指出的可见行为,例如意外输入或者副作用,依赖服务崩溃。
  • 较大型测试面临的两个挑战:

    • 所有权,测试的归属权问题,测试需要有人维护,否则测试会腐化。
    • 标准化,测试跨多个系统和部门,不同的系统的测试方案不同,需要有统一的标准演进,才能提供一致性的测试。
  • 在大型系统的集成测试中,一般采用链式的测试方式,即将大型系统切分创建成多个小的成对的集成测试,单独对每个集成测试进行测试,将一个集成测试的输出作为另一个集成测试的输入。

    unknown

  • 被测系统(System Under Test)

    • 封闭式的 SUT 具有隔离生产环境的好处,减少不可靠的影响面。一般我们都会建设一套离线共享环境供 SUT 进行大型测试。

    • 在问题边界内缩小被测系统,例如对前后端测试,通常将测试拆分成 UI 和 API 的边界连通测试。再者,对依赖三方服务的系统,需要将三方服务替换成测试替身,避免对三方服务的影响。

      An_example_system_under_test__SUT_

      A_reduced-size_SUT

  • 测试数据

    • 种子数据,初始化系统的测试数据,反应 SUT 的初始状态。例如:
      • 领域数据,领域服务启动需要的相关配置。
      • 现实的基线:例如系统接近现实的数据规模和复杂度。
    • 测试流量,在执行过程中测试发送到 SUT 的数据。例如:线上录制的流量或者自动生成的采样数据。
  • 验证

    • 手动回归验证。
    • 自动化断言。
    • A/B 测试,进行行为差异比较。
  • 运行大型测试的一些实践

    • 可以加速测试,缩小测试的范围,将大型测试拆分成两个可以并行较小型的测试;降低内部系统超时,反复轮询或者实现事件订阅通知,这比定时器更有效;优化测试构建时间也是一种加速方式。
    • 摆脱不可靠,缩小测试范围和降低内部系统超时。
    • 让测试结果可以被理解,可以提供支持信息,例如 trace 日志,明确的报错信息。
    • 明确大型测试的所有权,能防止测试的腐化。
  • 较大型测试的类型

    • 多个二进制文件的功能测试。
    • 浏览器或设备测试。
    • 性能负载或压力测试。
    • 部署配置测试。
    • 探索性测试。
    • A/B 测试。
    • 探针和金丝雀测试。
    • 容灾与混沌工程。
    • 用户评估。

15. 弃用

  • 代码是负债,而不是资产。代码的创建和维护是需要消耗成本的。
  • 代码本身并没有带来价值,是它提供的功能可以带来价值。一个能满足用户需求的功能就是一种资产。代码只是实现功能的一种手段。
  • 将弃用融入设计,在构建软件系统时就将未来的弃用考虑进去。
  • 弃用类型:
    • 建议性弃用。
    • 强制性弃用。
  • 弃用工作可以帮助减少维护的成本以及工程师的认知负担,从而改三整个软件生态系统。随着时间推移,可伸缩性地维护复杂软件系统不仅仅是构建和运行软件,还需要能够弃用过时或未使用的系统。

16. 版本控制与分支管理

  • 集中式的 CVCS (Centralized Version Control System)将 “真实来源” 的概念引入到系统设计中,主干上保持最新的版本,永远只有一个主版本分支。

  • 在 DVCS( Distributed Version Control System )中,开发者可以随意 fork 存储库和 checkout 新的分支,这会使得项目分化出许多的版本,不利于维护,故我们应该采取在 DVCS 中保持基于主干分支迭代或者主仓库迭代的策略来做到 ”真实来源”。

  • 不同的项目或组织对 “真实来源” 有不同的理解, 但原则是只要对于应该把变更推送到哪儿不存在模糊性,我们就可以避免 DVCS 模型中大量混乱的可扩展性问题。

  • 主干分支:

    基于主干开发,重度依赖测试和 CI,保持构建成功良好,并在运行时禁用未完成或测试的特性。

  • 开发分支:

    保持开发新特性基于主分支切出新的分支迭代,最终合并回主分支。

  • 发布分支:

    • 每次版本发布可以创建一个发布分支,让这个分支只包含产品发布而构建的代码,这么做的好处,可以在版本的生命周期内易于对缺陷进行修复,可以使用 cherry-pick 出修复改动合并到发布分支用于修复缺陷。这一般适合需要向客户推送有形产品的服务(如:客户端软件,依赖库)。
    • 对于只维护主分支版本的项目,且实现了持续部署(CD)的项目组织,一般不需要发布分支,只需要在主分支上迭代修复,重新部署即可。这一般适合数字化部署服务(如:Web 服务系统)。
    • 如果保持发布频率,并定期发布版本,且连续为下一个版本迭代,那么创建一个发布分支是合理的。可以及时修复线上版本缺陷,也不会阻塞新版本迭代的工作。
  • 单一代码仓库 (monorepos)

    • 单一代码仓库的好处是让遵循 “单一版本” 变的简单。
    • 主项目和依赖项都是同一团队维护时,可以考虑使用 monorepos,这样做可以保持整体的一致性,不需要考虑依赖版本,有助于扩的引入新工具和优化的影响。
    • 我们选择 monorepos 的目的是为了尽可能坚持“单一版本”的原则,“单一版本”规则有助于避免合并策略的讨论,以及避免多版本依赖使用和维护,减少精力的浪费。
    • 常见的单一代码仓库工具: Git SubModule(跨仓库整合成虚拟的单一代码仓库,主仓库只做子仓库上游特定 commit 的索引,对子仓库的变更还得在上游进行变更,主仓库只能同步索引)、Git SubTree(跨仓库整合成真实的单一代码仓库,会对子仓库进行拷贝,可自由编辑子仓库,同步子仓库的上游仓库可能需要处理合并)和代码构建工具相关的 WorkSpace 特性。
    • Federated / Virtual Monorepos (VMR) 兼细粒度存储库的特性(单独的法律,合规,策略要求),同时仍然遵循 “单一版本” 原则。例如: Git SubModule 特性就是一种 VMR, 主仓库可以理解为虚拟的主干(head / trunk)分支,SubModule 可以理解为特性分支,主仓库更新 SubModule 的索引可以理解为将特性分支合并入主干分支。
    • 达成共识:版本号就是时间戳。应该避免版本发生偏移,可以减少时间维度上的复杂性。

17. 代码搜索

  • 该章节介绍了谷歌如何通过 Code Search 提升开发人员的工程生产力。

18. 构建工具与构建哲学

  • 构建系统的目的:将工程师编写的代码转换成可由机器读取的可执行二进制文件。

  • 构建系统两个重要属性,快和正确。

  • 现代构建系统 —— 基于任务的构建系统

    • 基于任务的构建系统允许工程师以模块化的方式编写构建脚本作为任务,并提供执行这些任务和管理它们之间依赖关系的工具。
    • 基于任务的构建具有更好的灵活性。可以提高项目的扩展能力,能够自动化复杂的构建,相对于原始的 shell 脚本构建来说,更容易地跨机器复杂这些文件。
    • 缺陷:
      • 难以通过构建系统进行并行构建,因为基于任务的构建无法有效确定并行构建是否有冲突。
      • 难以通过构建系统执行增量构建,基于任务的构建无法很好了解到任务内部的动作,因此为了正确性,系统每次构建期间都会重新运行任务。
      • 难以维护和调试脚本,任务执行先后有依赖关系,而构建脚本可能会因为前置任务的输出不符合预期(例如路径输出错误)从而导致构建失败。还有任务可能因为构建环境的差异导致失败。
  • 现代构建系统 —— 基于制品的构建系统

    • 基于制品的构建系统的主要任务应该是构建代码,工程师仍需要告知构建系统需要构建什么,但是如何构建将由系统决定。
    • 基于制品的构建系统限制了灵活性,不允许使用编程语言对构建流程编写任务,而是使用配置清单来构建制品。这样子具有更好的扩展性和可靠性,构建过程是可控的。
    • 基于制品的构建文件应该是一份声明性的清单文件,例如 yaml 配置,描述要构建的一组制品,它们的依赖项和影响它们构建方式的有限配置集。
    • 指定构建目标,构建系统可以负责配置,运行调度编译步骤,可以保证正确性的同时,变得更高效。
    • 基于制品的构建,我们可以将针对不同语言的构建优化,并行构建,增量构建等能力做进构建系统,通过工程师配置的清单文件解析出依赖等信息以及构建系统的输入(项目代码),实现构建优化。
    • 构建策略:
      • 把工具作为依赖。保证构建产物的正确性,以及保持平台独立性。如果可能,可以考虑依赖工具链而不是工具,工具链包含一组用于目标平台的工具和其他属性。
      • 扩展构建系统。可以通过自定义规则来扩展支持更多的目标类型。
      • 规则需要输入和输出,以及需要执行的动作(action)。每个动作需要声明其输入输出,输出可以链接到其他动作上,action 是构建系统最小的可组合单元。构建系统可以对这些 action 进行调度和输出缓存。
      • 隔离环境。对每个 action 都置于隔离文件系统沙盒(如 Linux 的 LXC)中与其他 action 隔离,目的是隔离 action 的副作用,防止非预期的输入。对未声明的输出都会被构建系统丢弃。
      • 使外部依赖具有确定性。构建系统通过定义一个工作区范围的清单文件来解决 “供应链攻击” 和依赖项不稳定的问题。该文件列出了工作空间中每个外部依赖的加密 Hash,该 Hash 可以唯一表示文件。当依赖项配置更新时,才自动添加到该清单。每次构建系统构建都会对依赖项进行 Hash 校验。一般都会将依赖 Hash 清单和源代码控制一起维护。为了防止三方依赖分发服务崩溃,可以自举一个三方依赖分发服务的镜像。
  • 基于制品构建系统类比为函数式编程,基于任务的构建系统类比于命令式编程。

  • 分布式构建

    • 远程缓存

      A_distributed_build_showing_remote_caching

      • 将每一个制品的每个版本的构建产物进行缓存,在开发者进行构建的时候,无论是直接构建还是作为依赖进行构建,系统都会通过检查该制品是否被其他用户构建过,如果构建过则下载远程缓存,而不对它进行构建,这样做实现了制品的多用户共享,不必每次构建都从头进行重新构建,极大的降低运行构建系统的成本。
      • 支持构建缓存的前提是,保证相同输入能产生相同的输出。还应该考虑网络传输对构建效率的影响。
    • 远程执行

      A_remote_execution_system

      • 远程执行,将构建工作分散到多个 worker 中进行构建。自动构建系统将请求发生给构建主服务器,主服务器将请求分解为多个子 action,并发送给 worker 池调度这些 action 执行,每个 work 根据每个 action 指定的输入执行产生输出,这些输出可以在多个 worker 中共享,直到生产出最终的制品。
      • 值得注意的一点,还应该提供单独的方法能导出对源代码改动后的变更,以便 worker 可以在构建之前应用这些变更,在进行构建工作。要实现这一点,需要构建系统的环境是完全自描述的( self describing),这样做才能在无人为干预的情况下启动 worker。构建过程是完全自包含的(self contained),使得每次输出都是确定的。
  • 谷歌分布式构建系统

    unknown

    • ObjFS 远程缓存服务
      • 提供按需下载和预览功能。
    • Forge 远程构建系统
      • Forge Distributor 客户端远程执行,将构建 action 发送到 Forge Scheduler 作业上。
      • Forge Scheduler 维护 action 结果的缓存,如果已经有该动作存在则返回响应,如果没有则创建 action 加入 queue。一个 Executor 读取这个 action queue 的 action,并执行它们,并将结果缓存在 ObjFS BigTables 中,供其他构建使用或者下载产物。
  • 基于制品的构建系统的模块

    • “模块“ 在基于制品的构建系统中由一个指定可构建单元的目标表示,如一个 bin_binary。
    • 尽量保证目标和 BUILD 文件为 1:1 的原则,一个构建目标对应一个构建文件,以此为一个模块。
    • 细粒度的模块能更好的利用并行和增量构建。
    • 最小化模块的可见性,构建系统需要支持允许每个目标指定可见性。
      • public:目标对工作区中任何其他目标引用都可见
      • private:目标只能在同一个 BUILD 文件中引用,或仅对特定的目标引用可见
  • 基于制品的构建系统的依赖管理

    • 内部依赖

      • 内部依赖从源代码构建的,一般内部依赖的目标定义和构建都随主项目一起存储,一起维护。内部依赖不存在版本概念,都是随主项目一起更新和维护。

      • 传递依赖:

        Transitive_dependencies

        不允许在未声明传递依赖的情况下使用传递依赖的符号方法。如 A 不能使用 C 的方法。应该显式地声明依赖关系。隐形传递依赖会引起依赖关系的不清晰,不利于维护和构建优化。

    • 外部依赖

      • 外部依赖是那些在构建系统之外构建和存储的制品。依赖项目直接从制品存储库导入构建中使用。

      • 应该在构建文件显式地列出需要从制品存储库下载的版本。

        • 手动管理:指定确切的版本,系统只会去当前指定的确切版本的依赖。建议使用手动管理依赖,可以为项目提供稳定性和减少不必要的破坏性更新引起的破坏。
        • 自动管理:指定一个版本范围,构建系统根据版本范围的指定,拉取当前版本范围中的最新版本依赖。
      • 单一版本原则

        • 对整个项目(特别是 monorepos)的所有的第三方依赖项执行严格的单一版本规则。允许多个版本会由于 “菱形依赖” 产生依赖冲突的问题,由于我们无法决定三方依赖的单版本规则,三方依赖一般都是多版本的,使用多版本的三方依赖我们最好采取的策略是统一他们的版本。

        • 菱形依赖:A → B → C,A→ C

          面对这种菱形依赖,很可能会出现 A 和 B 依赖不同版本的 C,这时候很可能会产生依赖冲突或者多版本并存的问题。建议可以统一 C 的版本,保持项目中依赖 C 为单一版本。

          菱形依赖是不可避免的,但是我们可以使用单一版本原则去避免菱形依赖产生的版本不一致的问题。

      • 传递的外部依赖

        外部依赖一般都会有自己的三方依赖,这些三方依赖也会有自己的三方依赖,这种传递的外部依赖是有风险的,很可能会引起菱形依赖的问题。面对这种菱形依赖一般建议将每个传递依赖都进行显示声明在 一个 hash 过的清单文件(例如 package-lock.json, cargo.lock)。这种显示声明是很难手动做到的,一般都使用自动化手段生成,常见的构建和包管理工具都会帮我们做掉这个功能,开箱即用。

      • 使用外部依赖缓存构建结果。

        如果要对外部依赖进行构建,可以使用构建缓存策略,使用共享的已经构建好的制品来跳过对三方依赖的构建工作,加速构建工作。

      • 外部依赖的安全性和可靠性

        • 策略一,使用自托管的依赖源,限制构建系统对不可信依赖源的访问。并将依赖源写进依赖 hash 清单中,当依赖源更换,会导致 hash 不一致,从而导致构建失败。
        • 策略二,对外部依赖进行本地化。

19. 代码评审工具

该章节介绍 Google 内部的代码评审工具 Critique。

20. 静态分析

  • 有效静态分析的特点
    • 可扩展性:分析工具应该是可分片和增量的。分析工具对分析器的支持需要可扩展,分析的过程也要求需要可扩展,可以以结构化的数据结构返回分析结果,方便扩展更多的场景。
    • 易用性:专注于新引入的警告。只有当已存的历史代码出现特别严重的警告(安全问题,总大缺陷)的时候才对其投入关注(修复)。支持自动修复可以自动修复的错误。
  • 有效静态分析的最佳实践
    • 关注开发者的体验。降低误报率。
      • 误报(False Positive):一个工具错误地将代码标记为存在问题。
      • 漏报(False Negative):一段代码包含分析工具应该发现的问题,但工具没有发现。
    • 让静态分析成为工作流的一部分。在代码评审前,运行静态分析工具(可以是 IDE 集成、集成在项目的预提交阶段,评审前的前置 Merge Action 等等),可以快速发现和修复常见问题,这可以优化评审的时间。
    • 赋予用户贡献的权力
      • 让领域专家编写静态分析工具,可以将最佳实践的知识投入到大规模应用。
      • 让发现问题的开发者贡献,通过新增的规则可以避免他们不希望出现的缺陷出现在别的项目中。
    • 好的分析器的特点
      • 分析结果应该易于理解。
      • 分析结果最好包含建议的修复方式,能提示开发者如何修复缺陷。
      • 产生少于 10% 的有效误报(性能和可读性相关的警告)。
      • 分析警告应该包含可能对代码质量产生重大影响的警告(如可读性和性能考虑),提醒开发者修复。
      • 自动修复格式化相关的警告。
      • 可根据项目进行配置分析规则。
    • 跟编译器相集成的分析工具的标准:
      • 分析结果应该易于理解,分析结果最好包含建议的修复方式,能提示开发者如何修复缺陷。
      • 不产生有效误报,不应该对正确代码产生误报。
      • 报告只影响正确性而非风格或最佳做法的问题。
      • 可以将编译器检查作为错误启用来中断编译。对无法破坏构建的检查应该采取屏蔽,或者前置到代码评审阶段去检查。

21. 依赖管理

  • 依赖管理:是管理一个依赖网络及其随时间的变化。

  • 依赖冲突和菱形依赖

    The_diamond_dependency_problem

    • 当 liba 和 libb 依赖的 libbase 版本不同时,出现两个不同版本且不兼容的 libbase,这时会出现菱形依赖问题,引起依赖冲突。
    • 不同语言处理菱形依赖方式不同:
      • 调整函数命名,嵌入多个版本。如果依赖项之间传递类型,那么会导致冲突。(Java)
      • 使用兼容的版本,向前或向后寻找共同的兼容版本,一般对高版本降级。
  • 依赖的兼容性承诺

    • API 兼容性承诺。不同版本的公共接口向后兼容,使用方式一致。
    • ABI 兼容性承诺。不同版本的构建工具构建出来的二进制制品向后兼容,可以跨版本引用。

    不同语言的标准库和编译器的兼容性支持不同, 例如:C++ 的 STD 库和 JAVA 编译器支持 API 兼容性和 ABI 兼容性。根据标准库的旧版本构建的二进制文件有望与较新的标准构建和链接:标准不仅提供了 API 兼容性,还为二进制制品提供了持续的向后兼容性,即 ABI 兼容性。Go 编译器支持 API 兼容性,不支持 ABI 兼容性,即不允许跨版本引用库。

  • 引入依赖的注意事项

    • 关注依赖的设计。了解它的 API 设计是否合理,最好有文档解释依赖的架构设计。可以查看源码是否清晰,以及使用 API 的良好示例,希望用户如何使用 API。
    • 关注代码质量。代码是否良好可读。从代码可以看出作者的意图实现是否是自己想要的。开发简单的 demo,使用编译器重要的编译警告,检查依赖是否有大量不安全的代码。注意代码语义比格式更重要。
    • 关注测试。代码有测试吗?能运行它吗?测试都通过了吗?测试确定了代码的基本功能是正确的,它们表明开发者是认真地保持代码的正确性。假设测试存在、运行并通过,你可以通过运行时工具来收集更多信息,如代码覆盖率分析、性能检测、内存分配检查和内存泄漏检测。
    • 关注 issue 记录。找到这个库的 ISSUE Tracker,是否活跃,是否及时跟踪错误并修复关闭 ISSUE。
    • 关注提交历史。看看软件包的提交历史记录。有没有保持良好的更新节奏,是否有最近提交。是否有多人维护,多人维护的包更可能有及时的修复缺陷和稳定性改进。
    • 关注使用量。大量的使用者,能反应用户更好的使用这些代码,同时能更快地发现新的错误。广泛的使用也能在没人维护情况下,能更快出现新的愿意维护的开发者。
    • 关注安全。会使用这个包处理不可信输入吗?这个包有过安全漏洞吗?可以了解一下,这可以减少安全风险。
    • 关注许可协议。该代码是否有适当的许可证?它到底有没有许可证?许可证对你的项目或公司来说是否可以接受?应该了解一下,这可以避免合规问题。
    • 关注包的依赖项。代码是否有自己的依赖关系?间接依赖中的缺陷与直接依赖中的缺陷一样,对你的程序有害。依赖管理器可以列出一个给定包的所有横向依赖关系,最好是按照本节的描述对每个依赖关系进行检查。一个有许多依赖关系的包会产生额外的检查工作,因为这些相同的依赖关系会产生额外的风险,需要进行评估。
  • 检查并测试依赖,对依赖编写集成测试。检查过程应该包括运行包自身的测试。如果该包通过了检查,并且你决定让你的项目依赖于它,下一步应该是编写集成测试,重点是你的应用程序所需要的功能。以确保你能理解包的 API,并确保它能做你认为该做的事。如果不能,那这个依赖不是合适的依赖,放弃它。

  • 对依赖进行抽象(例如 依赖注入模式和适配器模式)。对依赖的抽象能方便你将来更换依赖。定义你自己的依赖接口(只需要定义你需要的功能接口即可)和适配器来调用依赖。这样做可以保证项目和外部依赖的解耦,能更容易切换依赖和更新依赖。

  • 在运行时隔离依赖。在运行时隔离一个依赖关系也可能是合适的,以限制它的错误可能造成的损害。一般将依赖置入沙箱运行。但有时这是不现实的,故我们能做的可能的防御措施之一是更好地限制依赖关系可以访问的内容。

  • 放弃依赖。如果你只需要某个依赖的一小部分,最简单的办法就是复制你需要的东西(当然要保留适当的版权和其他法律声明)。你承担了修复错误、维护等方面的责任,但你也完全与更大的风险隔离开来。

  • 更新依赖。对直接依赖和间接依赖都应该定期更新,最好采用手动更新,可以使用依赖更新检查自动提醒更新。更新完依赖还应该运行依赖的集成测试,防止更新的依赖产生不可预期的错误。

  • 定期检查依赖。要持续监测它们(可能是依赖更新,或者依赖风险报告,或者是安装的依赖 hash 不一致),也许甚至要重新评估你使用它们的决定。

  • 源代码管理相对于依赖管理更加容易,当提供者和消费者属于同一组织并切具有适当的可见性和持续集成时,依赖管理可能不再是大问题,因为我们可以确切知道代码如何被使用,并且知道变更的影响,此时的源代码管理比真正的依赖管理更加容易。

  • 四种常见的依赖管理解决方案,他们并不完美只是解决了部分问题

    • 静态依赖模型
      • 对依赖尽量不改变它们,不改变 API,不改变行为,什么都不要改变。只有在不破坏用户代码的情况下,才进行缺陷修复。将兼容性和稳定性置于首要位置。
      • 缺陷:
        • 存在不确定的稳定性假设,这是不理想的,如果项目生命周期够长,则它是个错误的选择。
        • 这一静态依赖的决策,在跳过多个小版本更新后,很可能会难以进行更新,强制更新可能会付出更大的成本。
    • 语义化版本(SemVer)
      • 通过语义化版本号(SemVer)标准来标识依赖版本

        格式: major . minor . patch

        当你做出不兼容的 API 改变时,增加 major 版本 当你以向后兼容的方式增加功能时,增加 minor 版本。 当你进行向后兼容的错误修复时,增加 patch 版本。 预发布和构建元数据的额外标签可作为 major . minor . patch 格式的后缀。

      • SemVer 版本管理方式通常基于 SAT-solver 求解器运行算法来寻找整个依赖网络中满足所有版本约束的依赖项版本分配。当出现无法满足所有约束时,会出现依赖冲突。

      • SemVer 局限性:

        • SemVer 的版本定义并非承诺,只是一个估计值。可能使用者不合理的 API 使用方式导致非预期的构建失败或行为改变。SemVer 是个有损估计,只代表了可能变化范围的子集。

        • 我们使用 SAT-solver 求解兼容版本,也是基于假设 SemVer 的版本定义时准确的,这总的来说并不能有足够的精确度来支持一个健康的生态系统。

        • SemVer 可能过度约束。

          假设依赖库有 A 和 B 两个 API,可能我们只使用了依赖库的 A,但是依赖库的 B 进行了破化性更新,此时会更新其 SemVer 的 major 版本号,但是更新后的版本和旧版本对我们的当前系统来说是没差别的。此时这种过度约束的行为是有问题的。

        • SemVer 可能过度承诺。

          根据 SemVer 的版本定义,我们对 API 的提供者对兼容性的评估是可预测的。如果 API 提供者对依赖的改动不是相关 API 契约的一部分(例如:打印日志的格式变更,时间敏感 API 的时延调整,或者输出结果的顺序改动)。这些变更一定是安全的吗,这是很难确定的。这其实反应 SemVer 可能会过度承诺。因为 “破坏性变更”这一描述是有歧义的,不同的开发者在不同的立场理解不同,一个变更是否有破坏性应该在使用它的上下文去评估。

      • 在变更 API 时,可以通过试验驱动的结论来辅助对 SemVer 版本的兼容性评估,例如:运行受影响的下游依赖包的测试,来评估新版本的改动是否对其产生影响,依据用户使用情况进行测试,从而更合理的评估 SemVer 的版本。

      • 在 SemVer 包管理中,对下游依赖方来说,保持 “最小版本更新策略” 可以提供更高的保真度。

    • 绑定分发模式
      • 对一组依赖进行打包成一个依赖集,并将其发行出去,发行商负责对依赖集进行版本查找,修复和测试兼容性相关的工作。发行商可以是个工程师。
      • 这种方式通常更高效,一般将依赖网络整组打包成一个聚合依赖并给出版本号,专人维护。
    • Live at Head
      • 基于主干开发的源代码管理的方案扩展到依赖管理关系上,丢弃依赖的版本号,保持依赖的最新变更,并进行充分的变更测试。这种方案需要整个系统有完善的单元测试和自动化 CI,能在依赖更新时发现缺陷。且还需要受到依赖方的支持。在出现破坏性改动的情况,需要提供自动化变更工具协助下游依赖方进行变更更新。
      • 这一方案将所有下游依赖方的测试和变更的负担都转移到 API 提供者身上,增加了 API 提供者的责任负担。
      • 这一方案的好处是能保持依赖最新,使得缺陷问题能及时修复。
  • 依赖开源

    • 依赖开源本心上是有益软件生态的发展,但是不当的维护可能会拖累组织声誉,甚至会因为无法保持内外同步,成为工程效率的负担。最后会分叉出多个版本,增加了维护成本。
    • 没有长期的支持计划和授权,就不要发布开源项目。这是 google 得到的教训。

22. 大规模变更(Large Scale Change,LCS)

  • LSC:指逻辑上相关但实际上不能作为单个原子单元提交的任何一组变更。

  • 常见大规模变更:

    • 使用分析工具清理全代码库中常见的反模式。
    • 替换已经废弃的库功能。
    • 支持底层基础设施改进,如编译器升级。
    • 将用户从旧系统迁移到新系统。
  • 使用自动化工具来完成大规模变更。

  • 原子变更的障碍

    • 技术限制

      目前的大多数版本控制系统都会随着变更的大小而线性变化,随着变更文件的增多,会产生性能瓶颈,这是困难的。这种大量文件改动的原子变更是不实际的,无法快速验证,很难确保万无一失。

      但可以考虑使用多次小型变更绕开限制,尽管变更执行会变的复杂。

    • 合并冲突

      随着变更规模的增大,合并冲突的可能性会增大。随着参与当前项目的工程师增多,问题也会变得更复杂。

    • 闹鬼的墓地

      面对无人维护的软件系统,犹如闹鬼的墓地。大家都不想对它变更,但是它又很重要,存在整个链路的关键路径上,我们在进行变更时,应该保证有完善的测试,能在变更后进行验证,不会破坏系统。

    • 异构性

      只有大部分工作可以由计算机而不是人来完成时, LSC 才能真正发挥作用。简化整个组织工作流,保持一致性,将有助于使用自动化方案进行大规模变更。如特殊项目有自己的独立 CI,或者预提交测试卡点,则需要采取人工介入,不过尽量自动化处理更合适。

    • 测试

      每个变更都应该被测试,但变更越大,实际测试可能会越困难。采取多大的测试范围需要衡量。面对大规模变更产生的大量测试工作,可以采用火车策略,批量对变更进行分批测试。

    • 代码评审

      所有变更都应该在提交之前进行代码评审。将大规模变更分解成多个独立的碎片变更会使评审变得更容易。

  • LCS 的基础设施

    变更创建,变更管理,变更评审和测试工具。围绕大规模变更的政策和文化规范。

    • 政策与文化

      大规模变更需要组织团队的支持。

      组织建设一个轻量化的批准流程,由一组熟悉各种语言细微差别的经验丰富的工程师,以及针对特定变更邀请的领域专家进行监督。帮助变更者更好的做出变更修改。

      培养起产品团队相信领域专业知识时迈向大规模变更的重要一步,这一改变能建立起产品团队对 LSC 作者做出大规模变更产生信任。

      针对每一个变更提供一个好的 F&Q 和可靠的历史改进记录,能使得 LSC 受到更广泛的认可。

    • 代码库分析

      在进行大规模变更前,对代码库进行分析是有价值的。这样可以确保大规模变更能覆盖整个代码库,变更能覆盖其所试图修复的所有情况。

    • 变更管理

      变更管理应该能将主变更分解成更小的部分,并独立地管理测试,消息触达,评审和提交他们的流程。

    • 测试

      应用大规模变更应该有配套的测试方案,健壮的测试文化和测试基础设施意味可以信任变更不会产生破坏性影响。

    • 语言支持

      大规模变更通常是基于每一种语言完成的。google 发现静态类型语言比动态类型语言更容易进行大型自动化变更。静态类型语言可以通过编译器和静态分析提供更多的信息。自动代码格式化也应该是自动化变更应该支持的功能,可读性也很重要,在代码评审时能方便评审和后期维护。

  • LCS 流程

    • 授权

      变更者应该提供变更文档,解释提议的变更原因,估计影响范围,将产生多少的碎片变更,以及潜在评审者们提出的任何问题的答案。这一变更会通知到专家评审团队进行监督,提出反馈意见。变更被批准后,就可以进行变更操作。

    • 变更创建

      在获得批准后,LSC 作者将开始生产实际的变更。变更生成应该自动化,已便用户进行回退。自动化变更生成的代码应该保持可读性。

    • 切片和提交

      在生成全局变更后,许使用自动化工具将大型变更分解为可以原子化提交的变更。每一个独立切片变更应该放入单独的测试-通知-提交流程。切片成小型变更提交的好处是如果出现问题可以快速发现和回滚。

    • 测试

      每个独立变更都应该进行 CI 测试。

    • 通知

      通过测试的变更,应该发送通知给评审者。项目拥有者应该参与评审。项目拥有者在接受变更时,有责任确保他们的代码库没有预先存在错误,这是他们和变更者之间的契约的一部分。

    • 提交

      对变更进行提交,完成变更,我们应该确保变更通过了项目的预提交检查。

    • 清理

      进行变更后的清理工作。

23. 持续集成(Continuous Integration,CI)

  • 持续集成 1.0 :持续集成是一种软件开发实践,指项目团队成员频繁集成他们的工作,每个集成都由一个自动构建(包括测试)来验证,以尽快发现集成错误。许多团队发现,这种方法可以大大减少集成问题,使团队更迅速地开发出高质量的软件。

  • 持续集成 2.0 :对我们整个复杂而快速演进的生态系统的持续组装和测试。

  • 我们应该在 “正确的时间” 使用 CI 测试 “正确的事情”。从构建到发布的各个节点上,我们都应该保证可验证的且能及时证明程序运行良好,能顺利进入下一个阶段。

  • 快速反馈循环

    • 软件生命周期

      Life_of_a_code_change

    • 随着时间推移,问题的修复的成本会越高,原因如下:

      • 代码可能由不熟悉问题的工程师进行定位。
      • 代码变更的作者需要做更多的工作来回顾和研究变更。
      • 会对他人产生影响,可能是使用代码的工程师和用户。
    • CI 鼓励使用快速反馈循环,提前测试,快速获得反馈:

      • 开发-编译-调试循环。
      • 预提交时将自动测试结果反馈给开发者。
      • 多个项目变更的集成测试。
      • 项目与上游服务的不兼容,上游服务变更后,被下游服务的测试捕获到。
      • 内测用户缺陷报告。
      • 外部用户的缺陷报告。
    • 金丝雀部署,可以帮助在进入生产环境时将问题最小化,在全量部署之前在生产环境的子集形成初始反馈循环。

    • 金丝雀部署可能会导致问题,特别是版本偏移问题(version skew)—— 当同时部署多个版本时,部署之间的兼容性问题,它指一种分布式系统的状态,其中包含多个不兼容版本的代码数据和配置。

    • 实验和特性开关是非常强大的反馈循环。它们通过隔离模块化组件中的变更来降低部署风险,这些组件可以在生产环境中动态切换。重度依赖特性开关保护是持续交付的常见范式。

    • CI 的反馈应该是可访问和可操作的

      • 可访问,包括代码、测试报告、构建执行状态、构建日志、测试记录的可访问性。对测试历史的可访问性使工程师能够在反馈上共享和协作,这是可供工程师们学习集成经验和诊断集成失败的原因。还应该有开发的缺陷系统(issues),供大家反馈缺陷和查看历史记录进行学习。
      • 可操作,可操作的 CI 测试能方便开发者快速发现和修复问题(例如:进入构建环境进行排查,基于配置的测试套件,更友好的测试失败日志)。
  • 自动化

    • 将流程自动化能减少工程资源的投入,在迁入变更时进行同行评审,将降低出错的概率。自动化流程会比人工操作更高效可靠。
    • CI 通过持续构建和持续交付来自动化构建和发布流程。持续测试贯穿始终。
  • 持续构建(Continuous Build,CB)

    • 持续构建:持续构建在主干(Head)上集成了最新的代码变更,并运行一个自动化的构建和测试。持续构建包含构建和测试两个步骤,持续构建失败可能是编译失败,也可能是测试失败。

    • CB 会有效的引入两个不同版本的 head:

      • green head,CB 已经验证通过的最新变更
      • true head,已提交的最新变更

      在本地开发中,开发者会同步 green head 到本地进行本地开发,目的是为了更稳定的环境。在提交之前必须将变更同步到 true head。

  • 持续交付(Continuous Delivery,CD)

    • 持续交付:持续的装配发布候选版本,然后这些发布候选版本在一系列环境中推进和测试 ——— 有时能达到生产标准,有时不能。
    • 持续交付第一步是发布自动化,不断地将最新的代码和配置从 green head 组装成候选版本。
    • 发布候选(Release candidate,RC):由自动化过程创建的一个内聚的、可部署的单元,由经过持续构建的代码、配置(特指静态配置,动态配置建议动态下发)和其他依赖项组合而成。
    • 随着 RC 在环境中不断往前推进,其制品(例如二进制文件、容器镜像)理想情况下不应该重新编译或重建。本地开发开始使用容器(Docker)部署有助于增强环境之间 RC 的一致性。使用编排工具(Kubernetes)有助于加强部署之间的一致性。加强环境间的发布和部署的一致性,使得早期测试环境的保真度越高,在生产环境的意外则越少。
  • 持续测试

    Life_of_a_code_change_with_CB_and_CD

    • 预提交和提交后
      • 不在预提交上运行自动化测试的原因:
        • 主要原因是成本过高,在代码提交过程中等待测试完成,这是浪费时间的,对生产力是严重的浪费。不过如果需要可以进行特定范围的预提交的测试,不过还是尽量减少预提交的测试工作,减少失败的概率,能保证较高效的生产力。
        • 预提交测试有比较大概率的情况下出现多个改动同时提交,产生不兼容的冲突,引起测试失败,这会影响开发者的生产力。
      • 快速且可靠的测试允许在预提交前进行测试,接受一定覆盖范围的损失,在提交后承担回滚的风险,以及不稳定性,还有长时间的全面的测试等待。
      • 建议预提交前的测试只能是可靠快速的测试,不能是慢且不稳定的测试。常见的预提交测试就是单元测试。
    • 发布候选版本测试
      • 代码变更 → CB → CD 构建 RC → 针对整个 RC 版本运行更大的测试。包括沙盒、临时环境、共享测试环境的组合。
      • 对整个 RC 版本运行更大的自动化测试套件是重要的,即使测试套件已经在 CB 进行测试过也应该运行。原因如下:
        • 做完整性检查:再次检查构建出来的 RC 版本,确保无问题。
        • 为了审计目的:检查 RC 的测试结果,方便审计。
        • cherry-pick 的情况:可能在 RC 的时候进行了 cherry-pick 修复,此时需要再次进行测试。
        • 紧急更新情况:CD 可以直接从 green head 中拉出分支并运行必要的测试,供快速修复。
    • 生产测试
      • 生产测试:针对生产环境运行与之前候选发布版本所做的相同的测试套件。
      • 目的:
        • 根据我们的测试确认生产环境的工作状态。
        • 根据生产环境来验证测试的相关性。
    • CI 是一种告警,CI 揭示了我们的软件如何响应其环境中的变化。CI 在部署软件之间使用单元测试和集成测试来检测软件的变化。
    • CI 的挑战
      • 预提交优化,在预提交阶段应该运行哪些测试,应该如何运行这些测试。
      • 问题定位和故障隔离:哪段代码或是其他变更导致的问题,问题发生在哪个系统,这需要定位(获取变更,运行测试)。在分布式架构中,“整合上游微服务” 故障隔离的一种方法,当你想弄清楚问题是源于你自己的服务还是上游服务。在这种方法中,你将你的稳定服务与上游微服务的新服务组合在一起进行测试。由于版本差异,可能会有不兼容性误报,这是一种挑战。
      • 资源约束:测试需要使用资源来运行,在大型自动化测试中资源成本是很高的,需要接受资源的限制。
      • 失败管理:测试失败时的失败管理。大型测试的时候,常常会出现不稳定的,难以调试的失败,此时需要对其禁用并跟踪,以便发布继续进行。常见的技术手段是对缺陷进行自动归档成 “重要清单”,交由适当的团队进行修复,并进行回滚。这些重要的失败清单需要进行管理,以确保任何阻塞发布的的缺陷都能及时修复,非发布缺陷与应该高优先级修复,以便测试套件能发挥作用。
      • 测试不稳定性:测试不稳定性时有发生,此时在不同的特定点进行重试是必要的或者考虑使用封闭性测试。
    • 封闭测试
      • 封闭测试:测试运行在完全自包含(即没有外部依赖性)的测试环境中。
      • 封闭测试的确定性和隔离性
        • 封闭系统具有更好的确定性,进入测试的内容不会因外部依赖关系而改变,因此当使用相同的测试套件运行出来的结果应该相同。
        • 封闭系统具有更好的隔离性,这意味着生产环境中的问题不应该影响到系统中的测试。封闭测试引起的问题不应该影响生产环境。

24. 持续交付(Continuous Delivery,CD)

  • 任何一个组织长期成功的关键始终在于它能够将想法尽快付诸实施并交到用户手中,并对用户的反馈做出快速响应。
  • 任何软件工作最大的风险是最终构建的东西是无用的。越早、越频繁地将可工作的软件呈现在实际用户面前,就会越快地得到反馈,从而发现他的真正价值。
  • 代码的价值不是在提交时实现,而是在特性呈现在用户面前实现的。缩短 “代码完成” 和用户反馈之间的时间可以最大限度地降低正在进行的工作的成本。
  • 持续交付的一个核心原则是,随着时间的推移,小批量的变更会带来更高的质量,更快更安全。
  • 以下方面能够在实现最终目标的过程中体现各自的价值:
    • 敏捷,频繁小批量发布。
    • 自动化,减少或消除频繁发布的重复开销。
    • 隔离,争取模块化架构,以隔离变更并使故障排除更容易。
    • 可靠,度量关键的健康指标,如崩溃时延,并不断改进它们。
    • 数据驱动决策,对健康指标进行 A / B 测试以确保质量。
    • 分阶段发布,在全量发布前,进行灰度发布测试。
  • 如果发布成本高且时常有风险。降低成本,增加纪律性并让风险变成递进式风险(采用 CI 进行阶段性测试),重要的是抵制明显的人工操作,并投资于长期的架构变更。
  • 可靠的持续发布的一个关键是确保工程师们对所有的变更都有一个特性开关。针对不同的开关控制制品的各个特性代码的包含或表现。这样做可以保证更好的控制新特性,为特性设置开关能尽早隔离问题。配置变更也应该谨慎进行。管理安全配置发布的配置服务是一项很好的投资。将特定特性的命运和整个产品发布分离的能力和控制水平,是实现应用程序长期可持续性的强大杠杆。
  • 建立发布火车,定期发车,持续集成 → 持续发布。没有一个二进制制品是完美的,可以持续迭代来修缮它。每次发车时间是固定的,在检票时间结束后,应该坚定地拒绝新特性功能的加入。一个迭代周期内,发布火车可以多班次,这样做可以缓解开发人员无法赶上火车而恐慌,也改善发布工程师的工作和生活平衡。
  • 有意识地依据用户的具体需求以及更大的组织目标来选择发布流程,并决定最能支持产品长期可持续性人员配置和工具模型。
  • 保持代码模块化,可以实现动态可配置的部署,这允许应用程序保持较小的规模,同时只将那写能为用户带来价值的代码发送到其设备上,而 A / B 测试实验允许在功能和成本与它对用户和业务的价值之间进行有意地权衡。监控任何功能的成本和价值,以了解它是否仍然有意义,是否提供了足够的用户价值。
  • 通过持续集成和持续部署,在所有变更之前实现更快,数据驱动的决策。
  • 面对客户市场的多样性时,可以通过一下方式切换发布质量评估模型:
    • 如果全面测试不可行,则以代表性测试为目标。
    • 灰度发布,缓慢增加用户群的百分比,从而实现快速修复。
    • 自动化的 A / B 发布实现统计上显著的结果证明版本质量。
  • 随着规模的扩大,复杂性的增加通常表现为发布延期的增多。“总是保持可部署状态” 可以让项目开发回归正轨,频繁发布火车使得每次变更的内容少,从而更容易定位和解决问题。发布的责任之一是保护产品不受开发人员的影响。现有产品的用户体验应该置于首位。
  • 速度是一项团队运动,对于协作开发代码的大型团队来说,最佳工作流程需要模块化的架构和持续集成。

25. 计算即服务(Compute as a Service, CaaS)

  • 驯服计算环境
    • 部署自动化:自动部署服务到机器上
    • 监控自动化:监控服务的可用性和机器的稳定性
    • 调度自动化:自动调度机器负载,杀死服务并迁移服务
    • 容器化与多租户:隔离服务间的运行环境,避免互相影响。使用 Linux 的 cgroups 特性和 chroot jails 特性,以及文件系统隔离的 bind mounts、union/overlay filesystems 特性进行进程隔离
    • 规模优化与弹性伸缩:自动调整资源需求和副本数量,来调度工作
  • 为托管计算编写软件
    • 批处理 (batch jobs)VS 服务(serving jobs)
      • 批处理作业主要关心的是处理的吞吐量,服务作业则关心的是服务单个请求的延迟。
      • 批处理作业的生命周期很短(几分钟或者几个小时),服务作业通常是长期存在的。
    • 负载均衡:服务负载分流程序,让多个服务实例提供服务。
    • 管理状态:保持服务的无状态,使用外部存储进行状态管理。
    • 服务发现:其他服务通过标识符引用你的服务,该标识符在特定 ”后端“ 实例重启前是持久的,当调度程序将服务放在特定计算机上,该标识符可以由另一个系统解析。
    • 针对低优先级的批处理任务,应该提供配额限制或者使用多租户限制,避免影响其他服务。
  • CaaS 随时间和规模的演化
    • 抽象容器:容器的主要目的是隔离,最小化不同任务之间的干扰,这些任务共享同一台机器。容器在已部署的软件和运行它的实际机器之间提供了抽象边界。这也意味着随着机器的变化,只需要调整容器软件,而不需要关注应用程序软件。

      例如:文件系统抽象提供了一种方法来合并那些部署公司编写的软件,而不需要管理定制的机器配置。使用 Linux namespace 为容器提供虚拟私有的 NIC,做到端口资源的抽象。

    • 容器与隐式依赖:容器也具有隐式依赖,例如底层系统的进程标识 pid 使用。

    • Kubernetes 维护资源池统一管理所有服务。将服务的机器池化,统一管理。大多数时间都运行者服务作业,机器空闲资源如果可能则可以用来部署批处理作业(混合部署模式)。服务作业优先级高于批处理作业,服务作业的调度应该频率更低,因为通常服务作业的冷启动时间更长,要求更低的延迟和更高的可用性。如果无法做到混合部署,应该对批处理和服务作业创建不同的机器资源池,分开管理。

    • 对部署在 Kubernetes 上的服务我们应该使用标准化的配置语言提供标准配置。这能提高自动化水平和部署操作的可复制性,提升运维效率。

  • 选择计算服务
    • 现代计产品可以有多种选择:
      • 有开源的解决方案(Kubernetes、Mesos、OpenWhisk、Knative)
      • 公有云解决方案
        • 弹性伸缩服务计算:AWS 的 EC2、 GCP 的 MIG
        • 托管容器:MicroSoft 的 AKS,Google 的 GKE,
        • Serverless 产品:Google 的 Cloud Functions
    • 计算服务选择很难随着时间的推移而改变的一个原因:任何计算服务选择最终都会被一个大型的服务生态系统所包围,这些工具用于日志记录、监控、调试、告警、可视化、动态分析、配置语言和元语言等等。这些为计算服务的工具作为计算服务变更的一部分,可能需要被重写,这些工作量可能是个挑战。
    • 计算架构的选择非常重要,这需要权衡。
      • 集中化:从资源效率的角度来看,一个组织采用单一的 CaaS 解决方案来管理整个机群。这确保了随着组织的发展,管理机群的成本保持可控。
      • 定制化:随着组织的发展,会有越来越多的定制化需求。
  • 抽象层次和 Serverless
    • 抽象层次: 裸机上运行 ” pet(服务)“ → 裸机上运行 ” cow(容器,虚拟机)“, ”cow“ 上运行服务
  • Serverless
    • 假设一个组织使用公共服务器框架来处理请求和服务响应,服务框架的关键特性是控制反转,因此,用户只负责编写某种类型的 ”Action“ 或 ”Handler“(即所选语言中的一个函数),接受请求参数并返回响应。这是 Serverless 的表现定义。
    • 持久集群模型:在 Kubernetes 中运行这些代码的方式是建立一个容器副本,每个副本包含由框架代码和函数组成的服务器,可以动态扩缩容,最小流量保障是必须的(必须有最小的副本实例)。
    • Serverless 管理模型:面对多团队使用同一个服务框架,应该考虑不止让机器具有多租户特性,还应该让服务框架具有多租户特性。这种方式中,运行大量的框架服务器,并根据需要在不同的服务器上动态地加载/卸载操作代码,并将请求动态地定向到那些加载了相关操作代码的服务器上。单个团队不再运行服务器。
    • Serverless 框架一般都是建立在其他计算层上的。例如: Knative 运行在 Kubernetes,Lambda 运行在 Amazon EC2 上。
    • Serverless 管理模型在持久容器模型上进行了高度抽象,具有更快速的反应和细粒度的控制。不过采用 Serverless 方案意味着在一定程度上失去了对环境的控制权。
    • Serverless 架构要求代码必须真正的无状态,无法使用任何存储。在 Serverless 模型中,不存在真正跨请求持久化的本地状态,你应该在请求范围内设置你想要使用的所有东西。如果需要可以使用三方存储解决方案。
    • Serverless 管理模型对于资源成本的可适应伸缩很有吸引力,特别是在低流量端。持久集群模型中要求至少有最小可用实例,这是最低要求。Serverless 管理模型可用缩小到无,因此拥有它的成本和流量成正比。这对于小型组织是有吸引力的。
    • 在实际应用中,应权衡采用持久容器化解决方案还是 Serverless 解决方案,常常考虑的点是应用程序在 ”真正的无状态“ 是否可以工作。
    • Serverless 模型虽然限制性更强,但是允许基础设施提供商在总体管理开销中承担更大的份额,从而降低用户的管理开销。当组织逐渐增大,持久容器化方案可能是更好的选择,此时共享集群的管理成本能在组织中更好的分摊,也能做到统一计算架构。
    • Knative → Kubernetes 发展路径是很好的 Serverless 解决方案转向容器化解决方案的路径。
  • 公有云 VS 私有云
    • 使用公有云的优势应该是:将管理开销部分外包给了公有云服务提供商,这对组织来说是降低了管理成本。并且公有云能更容易地扩展基础设施,无需为预测需求预先提供资源,这一优势是非常显著的。
    • 公有云的成本其实是高于私有云的运行成本的,这成本差价中间包含了管理费用。
    • 在使用公有云时,还应该防止服务商锁定,常见的策略可以是:
      • 使用较低抽象的服务,如使用云服务提供商的 Kubernetes 解决方案,并在其上运行更高级别的开源的 Knative 解决方案。
      • 使用多家公有云服务提供商提供的相同开源解决方案的托管服务,避免对云服务商特定实现的依赖,导致迁移困难。
      • 采用私有云和公有云混合运行方式,将总的工作负载一部分放在私有基础设施,一部分放在公有云上运行。这种方式可以兼顾公有云作为处理溢出的优势。这种混合云的方式要求必须采用相同的开源基础设施解决方案。
    • 多云和混合云策略都要求,通过不同环境的机器之间的直接网络连接,以及两者都可用的公共 API 将多个环境良好地连接起来。