Go-启动指南-全-

Go 启动指南(全)

原文:Shipping Go

译者:飞龙

协议:CC BY-NC-SA 4.0

前置材料

前言

我已经很久在考虑写这本书了。在我软件工程职业生涯的开始,我对完成工作的流程和程序根本不感兴趣。这一切似乎都太无聊了。但在我的管理者们的启发下,我开始深入研究 API 开发、敏捷流程、单元测试、持续交付和集成,很快我就发现自己淹没在资源、指南和会议演讲中。

直到阅读了 Gene Kim、George Spafford 和 Kevin Behr 合著的《凤凰项目》,我才恍然大悟。这是一个关于一家公司努力开发和交付高质量软件产品的故事。当我刚开始的时候,这本书在哪里?!我将《凤凰项目》与 Andy Hunt 和 Dave Thomas 合著的《实用程序员》结合起来,感觉自己对我的职业生涯有了全新的认识。

就像所有年轻的理想主义者一样,我用我新获得的知识和优越感让我的同事感到烦恼,直到其他人向我展示了我们已经在哪些地方实施了我在学习中了解到的一些概念。我采访了同事以及那些在行业内工作多年的人士,然后利用这些信息,结合 Martin Fowler 和 Kent Beck 的书籍,帮助我理解公司可以改进的领域。

很快,我将总结和文档发送给我的上司,并在会议中提出建议——但想法太多,时间太少。对于我在内部缺乏进展以及堆积如山的研究材料和示例代码感到沮丧,我决定在职业生涯的道路上继续前进。

我需要获得三个额外的职位,才能将许多这些想法付诸实践,并尝试其他想法。正如你将在本书中了解到的那样,我们开发者不仅需要实现想法,还需要反思如何使它们变得更好。我在阅读关于编写、测试和部署软件的所有书籍中,都发现了这个持续反馈循环的主题,但从未在一本书中用实例全部展示出来。当 Manning 出版社邀请我写这本书时,最初的主题完全不同,然后,在编辑、审稿人、早期购买者和行业专业人士的各种形式的反馈过程中,我们最终得到了你现在正在阅读的这本书。本书中描述的过程体现在了制作这本书的过程中(甚至使用了部署和持续集成)。甚至连书名都修改了好几次,以确保找到最能描述本书主题的那个!

你会发现,我按照随着增长而产生的复杂性来结构化这本书。初创公司和初步项目需要快速且轻量级以找到他们的市场,而在后期阶段,他们需要更广泛和大规模地考虑代码、架构和测试,因此我在一开始就专注于描述简单且经济的解决方案,并在最后介绍更高级和复杂的解决方案。我也希望你能看到,我这里呈现的材料是模块化的。语言、平台和部署模式并不重要。重要的是建立一个流程。为了强调这一点,我在整本书中使用了多种语言和部署模式。

我选择 Go 作为主要语言,因为它是我日常编写代码的语言。但我使用过许多语言,这本书中描述的许多概念都是语言无关的,所以我们还在附录中选择了其他一些流行的语言作为示例。此外,在书的结尾,我讨论了行业中的模式分裂,即使用基础设施即代码与基于容器的部署策略相比。

在《凤凰项目》(及其灵感来源,Eliyahu Goldratt 的《目标》)的精神下,这本书以半叙述的形式讲述。我的希望是,你,作为读者,能够借鉴你自己的经验和挑战,以便将它们与我所写的进行比较。你是否遇到过同样的问题?你是如何解决的?这个策略是否有所帮助?或者它是否可以适应以帮助未来?

这本书在你合上它的时候并不一定要结束。

致谢

当我开始写这本书的时候,我没有意识到会有这么多人参与进来。首先,我要感谢我的妻子,Chelsea,她在这次努力以及我参与的其它所有努力中支持我。在纸上,开始一份新工作并写一本书,同时还要管理两个孩子,这并不是最好的计划,但她帮助我坚持下去,并推动这本书完成最后阶段。

我还想感谢我的两个儿子,Eli 和 Abel,他们在所有最好的方式上激励和挑战我。他们的好奇心和兴趣迫使我思考那些看似不言自明的概念,并找到一种解释它们的方法,这在技术层面上,就是大多数编程书籍试图为人们做的事情!

没有 Manning 出版团队的巨大支持,这本书是无法写成的。感谢 Andy Waldron 与我一起为这本书寻找主题(以及正确的标题!)我真正为能写这本书而感到自豪。还要感谢 Aliénor Latour,她就内容的各个方面以及整本书的整体语气和方向给我提供了建议。

作为许多 Manning 书籍的审稿人,我特别感激所有在书评中提供反馈的人。感谢 Alain Lompo、Alex Harrington、Alex Lucas、Amit Lamba、Arun Saha、Bhagvan Kommadi、Borko Đurković、Camal Cakar、Clifford Thurber、Diego Stamigni、Eldon Alameda、Jorge Ezequiel Bo、Katia Patkin、Kent Spillner、Laud Bentil、Manoj Reddy、Marleny Núñez Alba、Mattia Di Gangi、Michele Di Pede、Mihaela Barbu、Muneeb Shaikh、Nathan B Crocker、Philippe Vialatte、Roman Zhuzha、Ryan Quinn、Sergio Britos Arévalo、Sudeep Batra、Tiklu Ganguly、Tymoteusz Wolodzko 和 Walter Alexander Mata López,你们的建议帮助使这本书变得更好。我还要感谢那些通过 MEAP 早期购买这本书并提供反馈和支持的人。

我非常感激贝基·惠特尼提供的所有帮助、指导、耐心和笑声。在整个写作过程中,她是一位了不起的向导,帮助我缓解了许多决策的焦虑。没有她的指导,写这本书对我来说将是令人压倒性的,而实际上,它是一次有组织的旅程。

致 Thoro.ai,它给了我写作这本书的自由和鼓励。

致弗兰克,他收养了我,并作为回报,我得到了谈话、论文和经验库。

致迈克·L,他每天早上与我讨论流程和改进,以提出这本书的想法。

致约翰·M 和维罗妮卡,他们给了我第一份工作,并鼓励我成长。

致我的父母,他们鼓励我成长并追求新的目标。

致我高中英语老师,他们鼓励我写作并帮助我确立自己的声音。

致奥托,每当我们散步时,他都引导我更接近这本书的结尾。

关于这本书

《Go 语言实践》旨在指导你构建产品。实验和黑客攻击需要流程和自动化来帮助将想法转化为其他人可以使用的东西。将这本书归入一个单一的类别是困难的,因为它故意引导你进入测试和基础设施领域,同时创建流程和自动化。在开发实验项目时,你将发现自己穿梭于开发、QA 和运维世界之间。将这些元素组合在一起是一个自动化管道,它提供了一个反馈循环,我们在整个过程中不断改进。

谁应该阅读这本书?

这本书旨在为任何对任何编程语言有扎实掌握的人而写,它被构思和撰写为你在学习 Go、JavaScript、Python 或你兴奋想要构建的任何其他有趣语言之后应该阅读的第一本书。有了这些知识,你将接受软件开发流程、持续集成和部署以及各种基础设施元素的快速课程。这本书使用特定语言和云基础设施的示例编写,这些示例可以转移到其他语言,如附录中所示。

经理和架构师可能会发现这些概念有助于帮助围绕新项目设计团队。这些概念可以逐渐引入现有的开发环境以及新的环境。考虑到语言和架构的进步,你可能会担心本书的内容会过时,但概念应向前推进,针对新的语言和基础设施元素。这里所写的内容只是可以做到的一小部分,但应为你和你的团队构建的坚实基础。

本书组织结构:路线图

本书分为三部分,每部分包含一个关于流程、测试和基础设施的章节,每部分的复杂性都在增加。这样,你可以跳到与你的专业领域(或缺乏专业领域)相关的相关章节或部分。每个概念都应可转移到其他语言和基础设施组件。在附录中,你可以找到其他语言中相同管道的示例。

关于代码

代码是基本级别的 Go 代码,使用 GitHub actions 作为 CI 引擎。这些操作使用 YAML 作为主要语言,这很容易转移到其他系统,尽管库将不同。我(没有特别的原因)选择了 Google Cloud 作为本书中的云主机;你可以用其他云服务中的类似产品替换它。此外,我选择了基于容器的部署路线,而不是作为首选建立单个服务器,因为许多绿色项目倾向于朝这个方向发展。然而,附录 D 提供了一些基本基础设施示例。

本书包含许多源代码示例,既有编号列表中的,也有与普通文本内联的。在两种情况下,源代码都以固定宽度字体如这样来格式化,以将其与普通文本区分开来。有时代码也会加粗以突出显示与章节中先前步骤相比已更改的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进,以适应书籍中可用的页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续续标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中删除。许多列表都附有代码注释,突出重要概念。

你可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为 livebook.manning.com/book/shipping-go。本书中示例的完整代码可在 Manning 网站 www.manning.com 和 GitHub github.com/holmes89/hello-api 上下载。

liveBook 讨论论坛

购买《Shipping Go》包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或特定章节或段落中附加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/shipping-go/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。

Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量承诺的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他们的兴趣转移!论坛和以前讨论的存档将可以从出版社的网站上获取,直到该书有售。

关于作者

Holmes4 作者

Joel Holmes 是一位专注于构建云原生应用的软件开发者。他在几家初创公司工作过,并帮助设计、开发和推出新产品和服务,以帮助这些公司发展和成长。在这个过程中,他能够帮助建立工具和流程,这些工具和流程有助于开发和提高质量。他与家人住在匹兹堡,目前在工作于 Thoro.ai,在日益增长的机器人行业中构建云应用。

本书的技术编辑是 Aliénor Latour,她是 Golang 技术负责人,专注于她团队软件的质量和简洁性,并且是开发角色多样性的倡导者。在工作时间之外,她游历欧洲参加苏格兰舞蹈活动,编织,缝制带口袋的裙子,并阅读关于语言学和社会学的书籍。

关于封面插图

《Shipping Go》封面上的插图标题为“Femme de Martavan en Sirie”,或“叙利亚的玛塔万女人”,取自 Jacques Grasset de Saint-Sauveur 的收藏,该收藏于 1797 年出版。每一幅插图都是手工精心绘制和着色的。

在那些日子里,人们通过他们的服饰很容易就能识别出他们住在哪里,以及他们的职业或社会地位。Manning 通过基于几个世纪前丰富多样的地区文化的封面设计,庆祝计算机行业的创新和主动性,这些设计通过像这一系列这样的图片重新焕发生机。

第一部分:启动

新项目的开始非常令人兴奋,有时也有些令人畏惧。你不受旧代码或错误的拖累,但你是从一个不确定是否可行的想法从头开始的。你不知道市场是否会喜欢它,或者它是否能承受高流量的考验。你肯定不希望很快把自己逼入死角,也不希望事情变得过于宽泛,以至于无法进行推理。这就是拥有狭窄、不稳定的基础和广阔、笨重的基础之间的区别。

在这个阶段的目标是要灵活。构建你的产品,使其能够舒适地改变和成长,对你和你的团队来说都是如此。在本节中,我们将在第二章讨论如何通过文档和计划启动一个项目。我们在第三章早期建立了一种简单灵活的测试编写方式,以帮助快速发现错误。在第四章中,我们发布了一个产品到生产环境中,直到它被大量使用之前,它将不会产生任何成本。在整个过程中,我们构建了工具来自动化将你的代码通过测试和交付的过程的大部分工作。

1 提供价值

本章涵盖

  • 使用小块工作来提高工作效率

  • 建立产品和流程改进的反馈循环

  • 规划产品增长和发展的阶段

  • 在各种反馈周期之间迭代

你将在本书中找到的内容是从敏捷软件开发、精益创业理念和 DevOps 文化中的过去实践中收集的。本书旨在帮助那些想要将他们学到的语言用于构建的人。你知道如何编写代码,并希望将其发布。这里教授的概念和流程应该与技术或语言无关,但我提供了使用 Go 和 GitHub Actions 的具体示例。通过使用它们的术语,你应该能够轻松地将我这里写的适应 Python 和 GitLab 或 JavaScript 和 CircleCI,但在这本书中,我们将发布 Go 代码。

本书采用半叙事格式,让我置身于一家希望快速开发增强型产品的公司的开发者角色。虽然这个项目很简单,但目的是让你了解产品开发的过程。许多这些元素部分来源于我的个人经验和事后之明。这种模式可能也不适合你公司的文化或流程,但希望你能从中找到一些有助于团队前进的元素。这里的重点是过程和心态,而不是技术。

最后,每个部分都被拆分,以便你在结束时交付一个产品。每一章都会基于现有章节进行构建,但如果你对过程满意,可以随时停止。每个部分以不同的方式将你的产品扩展到规模,例如通过扩大团队或提高资源利用率。我们探讨了与旧系统的集成和基于成本的不同的部署选项。

1.1 简单概念

本书汇集了来自各个行业的概念和流程,以帮助快速创建高质量的软件。其中一些概念在计算机和软件开发行业出现之前就已经存在。在过去几十年里,软件公司寻求其他行业的帮助,以更有效地构建产品来满足客户的需求。他们发现了一些能够从客户那里获得快速反馈的过程。基于这些反馈,他们能够调整他们的产品。调整他们的产品使他们能够成长为今天的谷歌、苹果和 Facebook,但他们根植于工业革命中的装配线和日本创造的精益制造技术。

假设你正在阅读这本书是为了构建一个产品。你有一些想法,你认为这会改变你的公司(或世界),你想要看看它是否可行。这是客户想要的吗?这有助于你的公司吗?很难知道。项目可能开始并最终失败。它们可能转型或改变,或者只是被遗弃在经验的垃圾堆中。如果一个项目几乎注定要改变或被丢弃,那么你应该投入多少努力?

想象一下尽可能少地投入精力去做某件事,这听起来可能很懒惰或缺乏灵感。相反,考虑一下有人告诉你需要构建一个设备,以尽可能快的方式将某人从一个地点带到另一个地点。在没有更多细节的情况下,你可能会花费数年时间设计和制造飞机,结果却发现客户只需要旅行 10 英里。比较图 1.1 中的两种开发过程。

在软件领域,这种情况经常发生。公司进行转型。他们从小规模开始,并不断发展。他们失败过。他们赚了数百万。他们是如何做到的?这来自于发展三个关键特性的理念:人员、流程和产品。人员推动组织和产品开发。一个流程帮助我们理解工作应该如何完成。最后,产品使我们能够从客户那里获得反馈。一旦你建立了你的流程,你就可以尽可能多地自动化它。这允许你的团队坐在一端,而产品则在另一端交付。

图 1.2

图 1.1 与客户交谈,以准确了解为他们构建哪种产品。

你的团队将开发客户想要的功能或进行更改。然后,这些更改将被交付给客户,客户反过来会就产品或功能进行讨论。这将触发设计步骤,从而重新开始整个过程。我喜欢将这个循环称为“四个 D”:开发、交付、讨论和设计。这是一个反馈循环,并成为我们价值流的关键部分,如图 1.2 所示。

图 1.1

图 1.2 开发、交付、讨论、设计循环

价值流正如其名,是指在公司内部创造价值的作业流程。这意味着与客户建立关系,并构建你认为对他们有价值的产品的过程。然而,直到你能了解客户的需求,你的投资应该尽可能小。如何降低投资成本?通过自动化。当你的源代码被提交时,它应该被视为一种原材料,而制造的产品应该在最后交付。

我们可以向另一个行业寻求答案。工业工程师长期以来一直在处理如何交付产品的问题。我们可以看看像流水线这样的创新,它展示了工作站之间自动化交接的好处。我们可以看看精益制造技术,以帮助我们理解减少在制品和即时交付以减少浪费的重要性。技术界已经观察并采纳了许多这些原则,以帮助设计和构建交付管道,这些管道自动将工作从单一想法到应用中的功能进行流动。这些想法和功能是在客户需要它们的时候才被创建出来的,而不是通过猜测客户的需求,提前花费时间和金钱开发可能不是他们想要的东西。这个管道可以在图 1.3 中看到,其中原始资源从一端进入,产品被发送出去,客户反馈被用来设计新的功能。这个周期对公司成功至关重要,这是我们将在整本书中探讨的概念。

图片

图 1.3 代码沿着一条流水线移动,在这个过程中它被分析、构建,然后作为库发送到设备或服务器。

我们可以看到,代码在产品构建并运送到最终客户之前,会经过一系列自动化步骤来验证质量。这可能是一个用于另一个项目的库包,一个用于现场更新的设备,或者一个在云中运行的云服务器。所有这些都在几乎没有人工交互的情况下移动,使得代码编写和客户可以使用产品之间的时间线变得可靠。

1.2 小块

你在这本书中会发现一个关键主题,那就是创建小而迭代的步骤,以便将反馈引入你的流程中。因此,当我们构建我们的产品时,我们会采取小而迭代的步骤,这样你就可以看到产品是如何成长的。你可能觉得第一部分中的步骤对你来说太简单了,可以选择跳过它们。或者你可能发现,你只需要到第二部分的结尾就能将你的产品推向市场。

想象一下,你花了三周时间开发一个没有人查看或测试的功能。你认为需要多长时间才能有人测试你功能的所有不同部分?你认为他们会发现多少个错误?你能在多快的时间内解决这些错误?在你开发这个功能的过程中,发生了多少变化?

创建小块工作可以让我们减少在制品(WIP)并加快交付速度。在 Eliyah Goldratt 的书籍《目标》(Routledge,2014)中,作者指出,在制品会占用收入。你投资时间和金钱在一件不会到达客户的东西上。这是直到交付之前的价值损失。创建更小的数量工作会占用你价值流中更少的收入,因此我们将专注于更小块的工作,以便尽早和经常交付价值。

虽然每一章对于构建完整的流程都很重要,但最终你会发现,由于每个产品和公司都不同,你的流程也会有所不同。保持不变的是过程。想法被提出,代码被编写,产品被交付。图 1.4 展示了这个循环。

图 1.4

图 1.4 产品开发经历一个生命周期,从原材料开始,最终产生客户可以提供反馈的产品,从而带来产品的改进和变化。

在这一步,协作变得至关重要,因为你要拆除不同团队之间的壁垒。过去,公司内部存在部落,它们之间经常处于战争状态。测试人员责怪开发者编写了低质量的代码。开发者责怪运维人员部署缓慢。运维人员责怪测试人员因为遗漏的虫子而发生的部署数量。这是不健康且对我们的客户有害的,所以我们不是建立壁垒,而是拆除它们,在他们之间建立沟通渠道,并协作构建一个接受想法并交付价值的工具。

你将获得产品开发的整体视角,以便将你的想法转化为产品。从远处看,这个视角是什么样的?我们需要哪些步骤来构建我们的流程?让我们来看看。

1.2.1 持续性

有许多持续的事情:持续集成、持续测试、持续交付、持续改进。它们有什么共同点?它们都是……持续的——一个循环,一个完整的旋转,一个圆圈。所有这些“持续”的事情都告诉我们,它们都需要在开始时连接起来。丰田公司采用了这个模型来构建其著名的丰田生产系统(TPS)。公司不断评估其开发过程的每个阶段,从装配线的运行方式,到人工装配,再到实验。每个阶段都有一个反馈循环,任何员工都可以寻求改进公司的方法。

从开发的角度来看,持续性的存在让你在编写代码时不必过多担心之后要做大量的手动工作。如果流程组装正确,提交代码应该会触发一系列自动化流程,为你提供关于代码的反馈。它可能会失败质量评估或无法编译,但开发者会收到通知并修复它,形成一个循环。如果部署成功,开发者可以继续进行下一项任务,继续改进过程。

这本书的编写方式试图遵循这个模式。TPS 有很多步骤,覆盖了非常广泛的市场,所以对于这本书来说会过于理论化。我们将会做的是将其分解为三个广泛的类别:过程、质量和交付。

每个阶段可以根据你的需求和你在产品开发中的位置简单或复杂。提供的内容不是处方,而是帮助你实施这些各种技术的指南。

1.2.2 流程

人类仍然是软件工程的一个基本要素。他们提出设计。他们编写代码。他们验证结果。但人类并不是每件事都需要。事实上,你能在更少的人力投入上投资,你就能从你的团队中获得更大的收益。

这并不是说你要自动化你的开发团队。相反,考虑一下:你更愿意花一个小时部署一个应用程序还是开发一个新功能?我们采用了一种在 TPS 中找到的方法:“自动化中的人性化。”这意味着我们尽可能地自动化,这增加了我们工作的效率。但这不是一个黑盒或一系列企业命令。相反,开发者创建并添加必要的工具来帮助他们进行开发。

这看起来是什么样子呢?嗯,它变成了一系列文档、脚本和工具,帮助快速进行开发。我的代码应该是什么格式?使用格式化工具。我如何创建一个新功能?使用代码生成器。我们如何改进我们的部署流程?使用管道。

这个过程一开始可能会很脆弱,但会逐渐发展成为你团队不可或缺的一部分。你会发现,你公司的工作流程将变得更加顺畅,你将能够快速高效地满足需求。

1.2.3 质量

质量是一个棘手的概念,也是一些哲学讨论的基础。罗伯特·皮尔西格在他的书《禅与摩托车维修艺术》(Mariner Books,2005 年)中这样描述:

“质量……你知道它是什么,但你不知道它是什么。但这自相矛盾。但有些东西比其他东西更好,也就是说,它们有更多的质量。但当你试图说质量是什么时,除了那些具有它的事物之外,一切都消失了!没有什么可说的。但如果你不能说质量是什么,你怎么知道它是什么,或者你怎么知道它甚至存在?如果没有人知道它是什么,那么从所有实际目的来看,它实际上根本不存在。但就所有实际目的而言,它确实存在。”

所以当人们说“交付高质量的产品”时,这意味着什么?就我们的目的而言,我们说首先,质量并不意味着完美。没有代码或产品会完美无缺。因此,质量因此成为完美的近似。

通过将额外的质量度量纳入你的开发流程,可以近似完美。你必须、你的团队以及你的公司必须确定你为客户定义的质量标准。你的代码可能很漂亮,但在大多数情况下,它会被编译,而客户永远不会看到。如果这段漂亮的代码有错误,它是高质量的代码吗?或者如果你有代码可以工作并且已经工作了多年,但难以阅读或调试,那是高质量的代码吗?

我们的质量检查将主要通过各种类型的测试代码进行。不同的模式和策略将被用来确保我们的产品按照开发者和客户的预期正常工作。这一步骤减少了系统中的浪费,这些浪费是通过返工(错误)和延误(未满足的需求)发生的。我们使用测试的多种方式来确保我们的产品在发货前能够正常工作。这不会解决我们需要的所有质量代码问题。诸如代码清晰度和可维护性等因素也有助于我们代码的质量,并将是我们添加的额外步骤。然而,最终,代码的编写者和维护者才是质量的守护者。

1.2.4 交付

交付是我们能够回到开始之前的最后一步。这是我们的管道中价值出现的地方。在代码编写并推送之后,我们通过客户对我们所写内容的反应来验证我们所写的内容。可能会要求更改,或者用户可能会满意或不满意。这个反馈循环只有在产品交付之后才会发生。

交付是指将工件发送出去的行为。一个 工件 可以是库的一个版本、一个可执行的二进制文件、一个容器镜像,或者任何可以被其他人使用的其他东西。工件可以私密或公开地交付。在某些情况下,公司会构建所谓的 发布候选版本,这是一种几乎准备好向公众发布的产品。这个候选版本可以通过另一组自动化测试来检查性能问题(负载测试)、可用性问题(UI 测试)或者是否真的能工作(冒烟测试)。可以通过手动测试来探索产品,获得批准的印章,然后向公众发布,如图 1.5 所示。

图 1.5 代码被构建成可执行文件或封装在通用运行时中,例如容器。

使工件运行的过程被称为 部署。在某些情况下,这可以包括在服务器上安装应用程序、在无服务器环境中设置新功能、在容器运行引擎上运行新容器,或者简单地通过空中更新客户的机器(例如,操作系统更新)。正是在这一点上,我们开始看到我们所构建的完整价值,如图 1.6 所示。

图 1.6 输出作为库被发送到客户,安装到嵌入式设备或服务器上。

在整个过程中,我们持续学习客户的需求和产品使用方式,这些信息反馈给我们的开发团队。如果一个应用无法启动,我们知道我们破坏了需要修复的东西。如果当太多人使用时它崩溃了,我们知道需要做出改变。如果只有一小部分用户觉得某个功能无帮助或不值,我们可能需要回到起点重新设计。

1.3 构建你的产品

产品开发周期中发生的事情与科学方法非常相似。你有一个假设,并通过实验来验证你的假设是否正确。有时你可能需要改变实验参数或探索不同的方向。产品也可以类似。你的想法(假设)可能不符合市场需求,所以你做出改变(实验)并最终找到是否成功。在两种情况下,你都会学到一些东西。

在构建你的产品过程中,将会有各种阶段。随着你的进展,每个部分都可能变得更加复杂,并将概述产品的成熟阶段。

1.3.1 初始设置

开始一个新项目需要什么?仅仅是一个好主意吗,还是更多?当你开始一个项目时,就像我们在第二章中做的那样,你需要收集关于系统应该做什么以及你期望它如何工作的信息。随着项目的进展,你需要指令和脚本为他人设置项目。在组织或大型项目中工作,你不会是唯一的工作者。在某个时候,有人会想要贡献,现在记录步骤比以后担心它更容易。

文档和脚本将帮助你扩展团队成员和贡献者。构建基本管道在开始时也很重要,因为后期改造可能会很棘手。在本节中,我们开始走向工业编程的道路,而不是黑客式编程。这两种类型的编程都有其时间和地点,但在这个例子中,我们关注的是构建产品而不是验证一个想法。一旦我们建立了一些基本的安装和流程,我们将在进行中添加更多。

1.3.2 基本验证

验证你的代码按预期工作是通往开发优秀产品的道路上的另一个步骤。团队经常将测试等事项推到优先列表的底部,因为他们觉得他们的产品在初期太不稳定,但测试不仅仅是开发者的安全毯。相反,它们告诉开发者他们正在编写的业务规则,并将产品引导到预期的目标。这些护栏可以帮助开发者长期发展,并在管道中将它们作为基本验证建立起来,有助于加速产品的增长,并通过代码记录业务期望,赋予开发者自主权。我们在第三章通过设置基本单元测试流程来探讨这个过程。

1.3.3 零成本部署

没有发布,你的产品就像放在架子上一样。部署是将你的产品放到服务器上,以便有人可以使用它。然而,当你查看所有选项时,有无数的事情需要考虑。其中最重要的是成本。这就是为什么对低成本技术的关注如此之大,以便将产品部署出去。

从第四章开始,我们将介绍各种免费选项,这些选项随着你的用户基础的扩大而扩展。我喜欢称之为“零成本”,因为早期,运行产品以获取市场验证不应该花费你任何费用。为此,我们将探索无服务器技术,如部署函数和托管平台。

1.3.4 代码信心

工作标准化是工业化生产的核心原则。同样,开发者们创造了一些技术来标准化软件的编写方式。随着团队的壮大,编码标准和格式变得重要。通过使用这些技术,我们可以更早地捕捉到错误,并在产品被测试之前自动检查产品的质量。

此外,在第五章中,我们将探讨代码审查流程,看看它如何有助于创建高质量的产品,以及它如何被用作团队成员的教学机制。我们还将使用文档来帮助我们的团队理解我们编写的代码,并努力创建易于理解的代码。

1.3.5 集成

系统很少在真空中工作。它们要么与数据库、文件系统或其他应用程序交互。这被称为集成,它是测试我们系统的一个关键部分。在第六章中,我们将探讨测试与其他系统集成时使用的技术。我们将与简单的存根系统以及更高级的模拟技术进行交互。为此,我们需要创建一个层,使我们能够反转依赖关系,从而针对抽象而不是具体系统进行开发。这样做将赋予我们更高的灵活性。

1.3.6 可移植性

“在我的机器上运行正常”是软件开发圈子中经常出现的一个陈词滥调。你花费了几个月时间创建一个系统,并且你知道它的所有细节。突然,有人想运行它,但它却无法运行。他们遵循你的设置,但你遗漏了一个依赖项。你是在 Linux 上开发的,但他们使用的是 Windows。

我们如何解决这个问题?在第七章中,我们将探讨抽象工具,这些工具帮助我们进行虚拟化和打包我们的产品,使其能够在通用运行时上运行。这将通过 Buildpacks 和容器来完成。最终,我们将将其集成到一个对每个人都是可移植的系统,包括我们的各种云部署选项。

1.3.7 适应性

当你发布你的产品时,你会发现自己在构建不完整的功能或关闭功能。通常,公司在将产品发布给客户之前会创建一个单独的产品进行测试,一旦“稳定”,但这种方法已被发现会降低团队的生产力,并可能导致发货延误。相反,行业已经转向通过使用配置来改变我们应用程序的工作方式。通过配置我们的应用程序,正如我们在第八章中所做的那样,我们可以改变功能而不改变代码本身。这意味着可以通过设置变量或通过更改标志来更改端点来测试实验性功能。配置意味着你可以调整你的应用程序,使它们可以像你一样快速移动。

1.3.8 用户验收

简单测试非常适合测试函数和方法在你的应用程序中的工作方式。它们帮助你专注于技术方面,但很少将你的工作与用户的需求联系起来。用户可能需要一个期望特定格式的 API 或一个具有特定期望的业务规则。在这种情况下,我们的测试从技术转向了稍微“柔软”一些的东西。在第九章中,我们将探讨其他技术。我们感兴趣的并不是它是如何完成的,而是我们是否满足了为我们设定的规格。“如果我的余额少于提款金额,那么我应该得到一个错误”是我们希望测试的规格示例。

1.3.9 比例产品

在我们的部署环境中使用各种抽象可以帮助我们建立客户基础。随着时间的推移,这些抽象可能会让你和你的公司付出金钱或性能的代价,因此你开始拆解这些抽象,这需要你对所构建的服务器和系统有更多的技术专长,以降低成本并能够根据需要扩展服务器。在第十章中,我们将探讨如何在可视化的服务器实例上创建和交付产品,以及如何通过代码维护这些产品。

1.3.10 端到端

任何产品在达到临界质量后的最后一步是测试其质量。到这时,我们将通过测试和代码检查创建出几种测试质量的方法。但随着产品进入生产阶段,我们希望确认客户将体验到的内容。通常这通过质量保证团队来完成,但我们希望尽可能自动化这一过程,以便我们的团队能够探索更细微的缺陷或寻找改进的领域。在第十一章中,我们将为我们的流程添加最后的基石,这将让我们从客户的角度了解整个系统是否按预期工作。我们专注于在整个系统中推动质量检查,但最终,我们应该有一个最终的检查来确认一切是否作为一个整体工作。由于这是一个成本较高的操作(从时间和维护的角度来看),我们将其留到最后,因为通常在产品成熟后,这是最后要实施的部分。在第十一章中,我们将展示一些测试,以便您的团队能够探索其他改进领域。

1.4 反馈循环

我猜很容易问出所有这些的意义所在。答案很简单,就是让您在整个产品、团队和公司成长过程中,能够快速且紧密地建立反馈循环。这些原则也容易转移到其他业务和项目中。

敏捷性 是软件开发中经常被提及的一个术语,它旨在捕捉灵活和快速改变方向的想法。然而,我觉得这个术语不够充分,因为它常常感觉像是在玩躲避球,试图躲避或回避反馈,而不是接受它。相反,我们的开发过程应该像驾驶赛车一样,你需要做出瞬间的决定,以保持向终点线的移动。随着我们通过这本书,我希望您能在您的项目和团队中找到一些前进和赢得比赛的指导。

摘要

  • 产品开发是一个不断变化的过程。

  • 专注于反馈循环将有助于指导改进领域。

  • 自动化是建立更快反馈循环的关键。

2 介绍持续集成

本章节涵盖

  • 在源代码中记录需求

  • 以中央代码仓库作为你流水线的起点

  • 通过使用持续集成系统来自动化构建你产品所需的步骤

  • 创建一个基本应用程序以开始开发

周一,你一边喝着早咖啡一边浏览着邮件,突然看到一封会议邀请,标题为“启动会议”。你看了看时间,意识到自己要迟到了。拿起笔记本电脑,你跑到会议室,只见那里坐着一个人,一个产品经理。当你关上门走向座位时,他说:“很高兴你能来;很抱歉临时邀请,但我们需要在本周内完成一些工作。我们公司希望探索创建一个比我们遗留系统更便宜、更快的新的 hello 翻译服务。在未来,我们希望将服务扩展到不仅仅是翻译‘hello’,但我们的系统将无法扩展。到目前为止,对话已经持续得太久,我想向他们证明我们可以快速完成工作,同时还能达到他们的目标。你认为我们能做到吗?”

一个新服务,在一周内以更优的性能和更低的成本复制旧服务的功能?当然,为什么不呢?你有什么可失去的?

“我希望你分步骤完成这项工作,并编写代码以便我们能快速获得反馈。此外,我希望明天能从实时服务器演示这个。它不需要完美;只需要展示我们一天内能做什么。我还希望其他人演示结束后能加入你。我们还需要确保它按预期工作,并且我们可以证明为什么它能。”

当他们在谈话时,你迅速打开一个终端并输入 mkdir hello-api && touch hello-api/README.md。你打开你的 README.md 文件,并按照以下列表编写代码。

列表 2.1 README.md

# Hello API

## Release Milestones

### V0 (1 day)
- [ ] Onboarding Documentation
- [ ] Simple API response (hello world!)
- [ ] Unit tests
- [ ] Running somewhere other than the dev machine

### V1 (7 days)
- [ ] Create translation endpoint
- [ ] Store translations in short-term storage
- [ ] Call existing service for translation
- [ ] Move towards long-term storage

“很好,一旦你完成了,告诉我,我们可以讨论下一步。我很感激你做这件事。我认为这将对我们展示公司如何快速进行产品开发并取得成功大有裨益。”

你带着笔记本电脑离开房间去喝更多的咖啡。当你回到办公桌前,你看了看时间:9:15。是时候开始编码了。

2.1 从哪里开始?

基于一个想法开始一个项目可能会有些令人不知所措。你用哪种语言编写它?应该如何构建?有哪些不同的用例?

有趣的是,这些都无关紧要。如果你被要求在一天内创建某物,你将使用你最熟悉的语言。你将以最简单的方式编写代码。你永远不会知道所有的用例,所以最好是尽快将产品交给客户。在这个项目中,你有一个好处,就是了解一些业务需求,因为有一个遗留系统,但在大多数情况下,你永远不会知道。

在您开始时,创建一个文档来记录您的设置过程、里程碑、测试过程等是一个很好的主意。您在开始README.md时已经开始了文档。大多数开发者会首先打开这个文件,这是一种异步沟通的好方法。README 文件不是一个新概念,并且几十年来一直是软件开发的一个固定元素。它的目的是向用户提供配置、安装、运行或使用软件的相关信息。您的 README 包含的内容取决于您的团队,但通常它将包含以下内容:

  • 运行软件的说明

  • 在您的环境中运行软件的配置

  • 已知依赖项

  • 故障排除信息

  • 常见的使用案例和软件使用示例

  • 软件里程碑

图 2.1 显示了文档如何作为新团队成员的地图。

图片

图 2.1 README 文档是指向您产品中所有其他文档的指针。这将有助于增强开发者对如何贡献、运行、构建或调试您产品的理解。

您已经从列表中添加了这些项目之一:软件里程碑。现在您想添加对软件功能的描述,它需要什么,以及如何开始工作。README 现在已经成为一个实验室笔记本,您在其中告诉他人您正在做什么以及如何复制实验。它还应包含一个论文或目的,告诉读者这个产品做什么。如果您发现很难写论文,那么您可能对您正在构建的东西没有很好的想法。让我们把它写出来:

# Hello API

这是当前我们在生产中使用的hello-api的改进版本。它将使用更少的内存,在生产中运行成本更低,并且可以扩展,扩展到更多的单词,并且更稳定:

## Dependencies

- Go version 1.18

## Setup

## Release Milestones
...

太好了!您已经用它做出了第一个决定。选择 Go 对您来说很自然,因为它是一种您在业余时间一直在使用的有趣、较新的语言。这将是一种向公司介绍它的好方法,并且它以使用少量内存、可扩展性和稳定性而闻名。请注意,您留下了设置为空白。这是故意的。这是一个活文档,应该在基础设施变更实施时更新。这将通过引导我们走向下一步的逻辑步骤来帮助我们贯穿整个章节。

2.2 绿色项目

对于这本书,我们使用基于 Unix 的开发环境。为什么?因为我们将要使用的许多部署服务都是基于 Linux 的。Windows 甚至有一个很酷的功能可以在 Windows 内部运行 Ubuntu Linux。在 Linux 和 Windows 之间,我们有相当大的用户份额。macOS 对于大多数事情都适用,所以我们需要指出它不适用的情况。这一点非常重要,所以我们可能需要将其添加到我们的README.md文件中:

...
## Setup

...

开发预期将在类 Unix 系统上运行。如果你正在运行 Windows,请考虑遵循以下指示(mng.bz/VpQr)。

现在我们需要安装 Go。简单地粘贴下载链接并告诉用户遵循指示会很容易。然而,你可能使用的是 1.7.2 版本,下一个人使用的是 1.7.3 版本,再下一个人使用的是不同的版本。很快,每个人都使用不同的版本,这看起来无害,但当你帮助同事解决问题时可能会成为问题,因为它在你的机器上工作,但在他们的机器上不工作。

在这种时候,我们希望使用标准化工具来帮助创建可重复的任务。为此,我们将创建一个 Makefile——在开发世界中是一种标准,它可以变得非常复杂。我们的 Makefile 将仅包含一些小命令,我们可以在文档中引用它们,并帮助我们进行开发。在您的代码编辑器中打开一个新的 Makefile,并添加以下列表中的代码。

列表 2.2 Makefile

GO_VERSION :=1.18

.PHONY: install-go init-go                                         ❶

setup: install-go init-go                                          ❷

#TODO add MacOS support
install-go:                                                        ❸
  wget "https://golang.org/dl/go$(GO_VERSION).linux-amd64.tar.gz"
  sudo tar -C /usr/local -xzf go$(GO_VERSION).linux-amd64.tar.gz
  rm go$(GO_VERSION).linux-amd64.tar.gz

init-go:                                                           ❹
  echo 'export PATH=$$PATH:/usr/local/go/bin' >> $${HOME}/.bashrc
  echo 'export PATH=$$PATH:$${HOME}/go/bin' >> $${HOME}/.bashrc

.PHONY 用于提前定义我们的一些方法,以便我们可以在设置阶段使用它们。

❷ 运行命令安装 Go 并设置环境

❸ 下载特定版本的 Go 并安装

❹ 将 Go 位置添加到您的本地环境

替代方案

以下代码使用 Make,因为它被 DevOps 社区和开发者相当频繁地使用:

- TaskFile[https://taskfile.dev/] - Modern Make alternative using YAML

注意 如果你不是使用 Bash 作为您的 shell,你将不得不修改这些步骤以将 Go 添加到系统路径。

注意这里的 TODO 注释。这是可以的。记住,我们试图快速行动但也要有帮助。重要的是要记录缺失的内容,这样其他人加入代码库时就会知道。TODO 项是人们开始贡献的绝佳方式!将以下列表中的代码添加到您的 README 中。

列表 2.3 README.md

...
## Setup

### Install Go          ❶
`sudo make setup`

### Upgrade Go          ❶
`sudo make install-go`
...

❶ 此 Makefile 还不支持 macOS。

通过标准化和文档化,你建立了一项关于如何在此产品上工作的指南。这几乎就像从宜家拿出一张桌子的一套组装说明。任何拿起它的人都应该能够遵循指示并运行应用程序。标准化我们的系统允许他人贡献。鉴于我们的工具和文档,产品开发进化的下一步是自动化。对于工厂来说,这表现为装配线。对于软件来说,这来自持续集成管道。

2.3 装配线

当生产者能够在家中将他们的工作标准化后,他们开始转向集中式工厂。这些工厂仍然让每个工人坐在他们的工作台上自己组装物品。一个工人的工作可能只是切割鞋子的皮革,然后将切割好的皮革堆叠交给另一个工人,由他将其固定到底部部件上,依此类推。今天,装配线大不相同。有些人站立并执行重复性任务,但随着更高级自动化的出现,这些工作站变得更加熟练和细致,需要特殊培训和知识,就像今天的软件开发者一样。

在软件中,我们可以想象这是一个开发者坐在他们的机器上,编写代码,编译它,然后部署它。虽然许多人以这种方式生产代码,但这并不容易扩展。工匠会制作定制家具,同样,许多程序员会坐在家里独自对一个项目进行黑客攻击。但这不是工业发展。这些都是独立项目。大多数公司不需要工匠软件;他们需要可预测性和可靠性。

需要的是一种自动化工厂中物品流动的方法,这样工人就不必自己完成所有工作或花费时间传递物品。在工厂中,这被称为装配线;在软件开发中,它被称为持续集成管道

持续集成管道,或 CI 系统,只是一个将代码沿着一系列预定义流程移动的应用程序。CI 系统可以像将文件复制到不同位置那样简单,也可以像处理多个部署和质量检查那样复杂。在这本书中,我们将使用 GitHub Actions 从前者过渡到后者。表 2.1 概述了一些最常见的 CI 系统。

表 2.1 持续集成服务器也随着时间而发展,通常拥有基于云的托管解决方案,因此您无需自行运行它们。

持续集成系统 年份 托管服务
Azure DevOps 2005
TeamCity 2006
Circle CI 2011
Jenkins 2011
Travis CI 2012
GitLab 2014
GitHub Actions 2020

GitHub Actions 是一种相对较新的技术,用于帮助开发者为源代码创建集成管道。它使用一个特殊的 YAML 文件来帮助我们定义我们希望代码通过的各个阶段,何时运行这些阶段,以及如果出现问题应该做什么。管道被分解为一组作业。每个作业可以包含一系列步骤,并且可以依赖于其他作业。每个步骤可以直接在底层系统上运行命令(bash 命令、脚本等)或使用库来帮助执行重复性任务(设置 Go、检出代码等)。当你查看一些定义时,你会看到像 actions/setup-go@v2 这样的东西,这意味着我们将使用 GithubAction 命令来使用正确版本设置我们的 Go 环境。

此外,我们还想了解我们正在构建的第一个管道。一开始,我们只想创建一个二进制文件并将其作为工件上传到我们的 GitHub 仓库。你应该看到以下步骤:

  1. 设置 Go 环境。

  2. 检出代码。

  3. 构建二进制文件。

  4. 将文件复制到上传目录。

  5. 将工件上传到 GitHub。

为了展示其简单性,我们将在编写任何代码之前创建我们的管道。在你的终端中,输入 mkdir -p .github/workflows && touch .github/workflows/pipeline.yml,然后打开文件。在其中,我们将添加以下列表中的代码。

列表 2.4 pipeline.yml

name: CI Checks

on:
  push:
    branches:                                                ❶
      - main
jobs:                                                        ❷
  build:
    name: Build App
    runs-on: ubuntu-latest                                   ❸
    steps:

    - name: Set up Go 1.x                                    ❹
      uses: actions/setup-go@v2
      with:
        go-version: ¹.18

    - name: Check out code into the Go module directory      ❺
      uses: actions/checkout@v2

    - name: Build
      run: make build                                        ❻

    - name: Copy Files                                       ❼
      run: |
           mkdir  artifacts
           cp api artifacts/.

    - name: Archive                                          ❽
      uses: actions/upload-artifact@v2
      with:
          name: api                                          ❾
          path: artifacts

❶ 我们只有在主分支有更改时才会运行这个 CI 流程。

❷ 这些是我们管道中运行的各个阶段。

在基于 Linux 的机器上运行此程序。

❹ 确保我们的镜像有 Go 1.18 或更高版本

❺ 从本地仓库拉取代码

❻ 告诉我们的构建命令构建一个二进制文件

❼ 将生成的二进制文件复制到上传目录

❽ 创建一个包含二进制文件的存档,并将其附加到工作流程中

❾ 将二进制目录命名为你可以识别的名称

希望你们中的一些人已经注意到我们列出了一个新的 make 目标。在这里,我们正在使用 make build 标准化我们的构建。为什么?好吧,Go 允许我们使用标志和各种其他功能来配置我们的构建,我们想确保它是标准化的。现在,我们将使用简单的 Go 构建,但在未来的章节中,我们将有其他配置,我们希望使用。让我们使用以下列表中的代码将我们的构建命令添加到 Makefile 中。

列表 2.5 \.gitignore

GO_VERSION := 1.18

setup:
    ...

build:
    go build -o api cmd/main.go      ❶

❶ 构建命令将编译主应用程序到一个名为 api 的二进制文件中。

你可能想知道代码是在哪里构建的。相信我,我们很快就会到达那里。现在,你可以看到我们如何将标准化的构建系统与我们的生产线结合起来。我们的开发者可以使用我们在生产线上使用的相同命令来确保它们在本地工作,但将在我们的管道上自动触发。如果我们需要更改流程,它就可以在本地和生产环境中得到反映。

注意:你更改了流程。你是否更新了文档?

装配线常被误认为是亨利·福特的一项发明。然而,装配线的概念在福特在著名的 T 型车中使用它之前就已经存在了几十年。服装、机器、自行车和船只都是将零件在劳动分工的不同部分之间移动的概念的应用,这一概念可以追溯到工业革命时期。自动化的材料轨道会在装配或单元生产中移动。你可以在图 2.2 中比较这两个。

图片

图 2.2 随着时间的推移,装配线在它们能做什么方面已经发生了演变。自动化增强了工人创造更高品质产品更有效率的能力。

亨利·福特甚至没有发明自动化生产线。他因应用生产线而闻名,通过创建核心原则,这些原则有效地从开始到结束高效地交付了他的产品。

他的原则很简单:

  • 按操作顺序放置工具和人员。

  • 优化每个工作站的工作流程。

  • 自动化生产线以移动正在组装的产品。

这些原则归结为将工具和工人分组:创建一个易于组装的系统,需要部件自动从每个阶段移动。今天,生产线更加复杂,自动化程度更高,但原则保持不变:使你的工具易于使用,高效地使用工具,并自动化流向下一步的流程。

我们刚刚创建了一条生产线,将我们的工匠开发转变为工业化开发。我们现在需要做的是找到一种将材料运入工厂和将产品运出的方法。

2.4 仓库

我住在匹兹堡,钢铁之城。它之所以得名,是因为在 19 世纪末在这里建立的大型钢铁工业。今天,你仍然可以在城市的郊区看到仍在运行的钢铁厂和废弃的钢铁厂。是什么原因使这里的钢铁工业如此庞大?是气候吗?人口吗?技术吗?不。成功在于其靠近制造钢铁所需的材料以及当时分销网络的邻近性。煤炭、铁和石灰石从城市周围的山脉和附近地区运下来进入工厂。然后工厂生产钢铁,通过铁路或通过河流上的驳船运出。

资源邻近性分销网络对现代生产同样至关重要。不再依赖于自然资源的地理位置,现在公司会建造仓库来储存他们的材料,直到他们可以处理它们。当产品完成时,它随后会进入另一个仓库,直到客户需要。这些仓库也有另一个更通用的名字:存储库。

存储库是存储物品的地方。在软件中,我们有代码存储库来存储我们的代码。与制造业一样,我们希望我们的源代码与我们的工厂保持接近,以使产品开发更高效。在我们的情况下,我们希望我们的软件代码在组装时接近我们的管道代码。代码存储库有多种形状和大小,但我们将使用 GitHub 上托管的 Git 作为我们的代码存储库。

我们将在整本书中使用一个项目。源代码可以在github.com/holmes89/hello-api找到。在每一章中,我们向我们的存储库添加新的任务和程序,以展示我们如何从一个存储库测试、构建和部署多个产品。

首先,如果你还没有注册 GitHub 账户,请访问github.com/join然后mng.bz/xdxq(SSH 访问你的账户)。

然后导航到右上角,点击加号,选择新建仓库。然后你将进入一个设置页面。选择一个名称,如果你愿意的话,还可以添加一个描述。按照图 2.3 中的设置进行操作。

图片

图 2.3 提供一个名称,并使用 Go 模板创建一个.gitignore文件。

然后点击创建仓库。恭喜!你已经创建了一个仓库。请确保你已经安装了 Git(mng.bz/AlXE)。然后我们将配置我们的本地目录以使用这个仓库。在你的目录中运行以下代码:

git init
git remote add origin git@github.com:holmes89/hello-api.git

现在我们可以将代码存储在中央位置,我们的操作将生成一个二进制文件,这个文件也将与其他人共享。这个仓库对我们产品的增长至关重要。一旦我们分享代码,其他人也可以开始贡献和共享。我们的代码是我们系统构建、测试和发货产品的材料。

这意味着我们的产品代码、测试代码和基础设施代码都存储在同一个仓库中,它们可以经过处理并用于发货产品。不仅我们的产品代码要经过质量检查,测试代码和基础设施代码也是如此。随着我们继续阅读本书,我们将看到这一点。如图 2.4 所示,用于产品的材料被运送到单个装配点,然后产品被发货。

图片

图 2.4 在制造业中,材料从原材料仓库中交付,然后组装成成品,交付给客户。

仓库需要存储制作我们产品所需的一切。这包括产品代码、部署代码、基础设施定义、测试计划、自动-

集成测试框架等等。这可能对一些人来说似乎很激进,但这是持续集成理念的核心。这是我们构建产品的唯一真相来源,并为所有参与其中的人创造了一种所有权感。你可能发现一个项目可能需要多个仓库或依赖于外部系统。这些属于不同的开发和部署模式。在这个例子中,我们专注于单一项目仓库,以保持我们的工作简单和有序。我们想要的代码进入,产品出去,如图 2.5 所示。

图片

图 2.5 与制造过程类似,我们可以通过集成层将源代码组装成产品,然后交付给客户。

使用单个仓库,你可以解决许多组织问题。例如,通过将测试代码与产品代码放在同一个仓库中,你可以在部署工件之前,在构建后轻松运行集成测试。否则,可能需要在测试仓库上触发以在构建完成后开始测试。或者,你可能会发现,在发布新功能或模式更改时,你面临的是一个鸡生蛋的问题。正如我之前提到的,这需要基于你和你团队想要的结构来决定。

一些项目可能不需要检查到你的仓库中。特别是,像编译后的二进制文件和外部库通常不会被检查,但它们是你开发过程中留下的碎片,你不想将它们添加到仓库中。为了处理这个问题,我们可以创建一个特殊的文件,称为.gitignore。创建一个,打开它,并添加以下内容:

# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# Dependency directories (remove the comment below to include it)
# vendor/

api       ❶

❶ 我们不希望将二进制文件保存到我们的源代码控制中。

这应该可以防止我们将这些文件添加到我们的仓库中,以保持其整洁。你可能不需要所有这些文件,但它们是标准文件,你不会希望将它们检查到你的仓库中。

现在我们有了 Makefile 的标准,README 中的文档,以管道形式的过程,以仓库形式存储,以及以发布形式交付。我们唯一缺少的是生产产品的材料。

2.5 材料内容

代码在哪里?为什么我们还没有写一行 Go 代码?为什么在我们甚至没有产品之前,我们要经历所有这些设置?

这些是很好的问题,说实话,似乎有点反常,要在没有其他人参与项目并且没有编写代码的情况下投入所有这些工作来构建管道并记录它。但这却是故意的,目的是证明一个观点。我们的流程应该与我们所编写的代码无关。我选择 Go 作为本书的原因有很多,但你可能是一名 JavaScript 或 Python 开发者,这些原则仍然适用。我们可以想象,我们的项目从hello-service变为good-bye-service,而我们所做的所有工作都不会改变。代码并不重要!

为了演示这个魔法,让我们编写我们的代码。输入mkdir cmd && touch cmd/main.go,打开文件,并添加以下列表中的代码。

列表 2.6 main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"
)

func main() {

    addr := ":8080"                                     ❶

    mux := http.NewServeMux()

    mux.HandleFunc("/hello",
      func(w http.ResponseWriter, r *http.Request) {    ❷
        enc := json.NewEncoder(w)
        w.Header().
        Set("Content-Type",
        "application/json; charset=utf-8")              ❸
        resp := Resp{
            Language:    "English",
            Translation: "Hello",
        }
        if err := enc.Encode(resp); err != nil {
            panic("unable to encode response", err)
        }
    })

    log.Printf("listening on %s\n", addr)               ❹

    log.Fatal(http.ListenAndServe(addr, mux))           ❺
}

type Resp struct {                                      ❻
    Language    string `json:"language"`
    Translation string `json:"translation"`
}

❶ 硬编码系统端口;目前我们将来能够配置它。

❷ 目前创建一个单独的处理程序,以满足我们系统的最低要求。

❸ 设置默认的头部类型,因为这将是一个 REST API

❹ 为服务器端口提供额外的日志信息;这些信息在运行单个机器上的多个服务器时通常很有帮助。

❺ 运行服务器

❻ 存储翻译信息的通用结构

在本书中,我们将使用内部 HTTP mux库和 Gorilla Mux,但还有其他选项:

  • Gin

  • kit

  • beego

让我们打包我们的代码,以便其他人知道它依赖于哪些库。为此,我们将初始化一个 Go 模块。有关 Go 模块的更多信息,请访问go.dev/blog/using-go-modules

输入make build然后./api,看看你的服务器是否运行。通过在另一个终端运行它来测试:

curl localhost:8080/hello
{"language":"English","translation":"Hello"}

就像魔法一样。现在,接下来我要展示的是,我们将让我们的管道生成一个二进制文件。在你的终端输入

git add .
git commit -am "Initial creation"
git push origin main

导航到 GitHub,点击“操作”标签,查看你的管道运行情况。希望一切都会变成绿色。点击运行,你会看到一个名为api的二进制文件。下载并运行它,你会看到它与本地实例的工作方式相同。你可以玩弄代码,更改输出以查看管道运行并交付新的二进制文件。

这段代码相当愚蠢,但这是故意的。想想投入的努力以及这为他人打开了什么。如果同事正在等待这个功能来构建用户界面,他们不需要完整的系统来开始集成。或者,如果我们还没有完全确定客户想要什么,我们可以先把这个东西拿出来进行演示。它并不完美,但它是有效的。这正是持续集成和部署的本质:小块的工作可以让你不断前进。现在我们已经写好了基本代码,我们需要将其放入传送带进行组装和交付。

现在,我们可以交付一个产品供他人使用,同时我们回头改进我们编写的代码。将任务分解成小块对于创建满足客户需求的高质量产品至关重要。这也有助于降低引入比我们需要的或能够支持更多的代码和功能的风险。

我们的项目经理希望明天能有一个演示,以证明这将有效,而你刚刚做到了这一点。它不是动态的,但这没关系。这是我们将在过程中添加的复杂性,但现在有了自动化的方式来做这件事,它将变得简单。

你看看你的钟表,意识到是吃午饭的时间了。你站起来,看到 QA 部门的负责人正朝你的桌子走来。你的目光相遇,他们挥手喊道:“嘿,你有几分钟时间吗?”你点头,又坐了下来。

摘要

  • 代码仓库是管道的起点,所有代码都存储在这里。

  • 使用脚本和工具使所有开发者和系统环境保持一致。

  • 在你的代码库中记录下所有内容,以便开发者从第一天开始就能提高生产力。

  • 自动化与你的代码相关的所有任务。

3 引入持续测试

本章涵盖

  • 在编写代码的同时创建编写测试的过程

  • 为代码部分建立测试边界

  • 使用我们的管道中的测试创建一个质量门

  • 使用代码覆盖率作为重构和测试的指南

QA 负责人走到你的办公桌旁,拿起你旁边的椅子。他们看起来有些沮丧,这是有道理的,因为 QA 团队一直在承受巨大的压力,要推出一个新版本。他们似乎总是被大量的错误、问题和误解的功能压得喘不过气来。开发和 QA 团队似乎总是在互相冲突,而不是一起解决问题。QA 团队感觉自己是质量项目的门户,而开发团队感觉 QA 阻碍了他们。开发者不断发布有错误的代码,并陷入了一种虐待狂式的 Whack-a-Mole 游戏,与错误作斗争。这种关系紧张到不健康。当你看到 QA 朝你的桌子走来时,你就知道这将不会是一次轻松的对话。

“看,我确信你知道我们现在处于周末发布即将到来时的水下状态。但我刚刚得到消息,你正在编写一个新项目来替换我们现有的翻译服务。我们已经对这个产品进行了多年的调试,到目前为止它已经稳定,所以我不确定我是否愿意替换它。然而,我们的项目经理坚持认为这是我们作为公司成长所必须做的事情。我知道你还在开发的早期阶段,但我希望有一种保证,这个产品能够工作,而且我的团队不需要花费数小时去寻找我们多年前遇到的相同错误。我们不应该浪费时间处理这些小错误。我们需要专注于我们的产品成为客户可能拥有的最佳产品。你明白吗?”

你点头并做了些笔记。没有人喜欢编写糟糕的软件。没有人愿意因为被指责为错误而感到良好。没有人愿意给其他人增加更多的工作。QA 代表质量保证,但这是一种误解。单个人或团队永远无法保证质量,所以让一个特殊的团队负责质量似乎有些可疑。质量应该是公司每个人的关注点,不同的团队应该以不同的方式测试产品,以确保它是您可以发布的最佳产品。你决定勾勒出这个计划,并向 QA 负责人解释你的计划。

“你提出的建议是将测试更靠近源代码,并使用它作为记录各种测试案例的方式,”QA 负责人评论道。“我知道我们在其他区域有一些单元测试,但它们似乎从未捕捉到我们需要的错误。我们的问题是这些测试似乎是在代码之后编写的,并且它们不符合我们的业务需求。它们也从未运行过,所以我们没有使用它们。如果这些测试在需要测试之前就能运行,那将很好。你认为你能做到这一点吗?”

当然,为什么不呢?你记下一些笔记,找到旧系统的 bug 和功能请求,然后开始工作。

3.1 要测试什么

从哪里开始?这是一个很好的问题,尤其是如果你对开发还不是很熟悉。编程书籍会向你展示语言,许多书籍甚至会展示现代测试框架或库,但它们不会向你展示如何编写测试。它们也不会向你展示要测试什么。确定要测试的内容以及如何测试它是一种随着时间的推移而发展的技能,并且你的团队应该为此设定标准。

例如,质量保证(QA)团队的负责人关心重复的 bug 和浪费的时间。开发者们也关心 bug,因为它们会浪费时间。QA 团队已经训练有素,能够考虑各种用例,而开发者们对系统的工作原理有更清晰的理解。当你被分配一个开发任务时,提前草拟一些用例和测试用例并不会有什么坏处。一旦这个列表被写出来,可以拿给其他人看看是否遗漏了什么。让我们看看以下列表中的代码。

列表 3.1 Main.go

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
)

func main() {

    addr := ":8080"

    mux := http.NewServeMux()

    mux.HandleFunc("/hello",
      func(w http.ResponseWriter, r *http.Request) {
        enc := json.NewEncoder(w)
        w.Header().
        Set("Content-Type",
        "application/json; charset=utf-8")
        resp := Resp{
            Language:    "English",
            Translation: "Hello",
        }
        if err := enc.Encode(resp); err != nil {
            panic("unable to encode response")
        }
    })

    log.Printf("listening on %s\n", addr)

    log.Fatal(http.ListenAndServe(addr, mux))
}

type Resp struct {
    Language    string `json:"language"`
    Translation string `json:"translation"`
}

我们的代码本应该做什么?将给定的单词翻译成另一种语言。我们的代码做到了吗?没有。记住,我们在上一章中只做了最小的工作来让我们的管道工作。现在我们将专注于业务或客户希望从我们的代码中获得什么。如果我们看看我们的当前实现,我们会注意到有三个部分:翻译服务、翻译处理程序和服务器。服务可能最不清晰,因为它是在Resp结构体中硬编码的值。但将来,这将是我们的产品的核心部分,而不会是硬编码的。处理程序将负责接收请求并将它们转换成可以传递给服务并返回结果的形式。然后服务器将运行处理程序,将整个系统串联起来。

开始测试的最好方式是将我们的工作分解成易于测试的单位。这些被称为测试系统(SUT)。SUT 有一个清晰的测试边界,你应该将其视为一个黑盒,这意味着你主要测试输入并断言输出是正确的。我们将我们的 SUTs 分为以下类别:服务、处理程序和服务器。

目前,我们的代码是一个巨大的主方法,这使得测试变得困难,所以让我们将其分解。首先,让我们处理包含所有业务逻辑的服务。记住,我们的服务是一个翻译服务,所以主要业务功能可以定义为接受一个单词和一个语言,并返回翻译后的单词。定义可能看起来像这样:

func Translate(word string, language string) string

让我们创建文件:

mkdir translation
touch translation/translator.go

很好;这给了我们一个起点。让我们创建一个名为translation的包。在其中,我们将创建一个名为translator.go的文件。打开translator.go,并添加以下列表中的代码。

列表 3.2 translator.go

package translation        ❶

func Translate(word string, language string) string {
    return ""              ❷
}

❶ 为翻译创建了一个新包。

❷ 定义测试的最小响应

现在我们已经建立了初始包,我们需要想出一个我们应该测试的想法。我们已经确定了编写测试所需的最少代码量。现在让我们花点时间思考一下我们需要测试什么。有时,遵循给定的“当...时,如果...,则...”格式可以成为编写测试的一个很好的入门。这个格式源于行为驱动开发,为我们提供了我们想要如何结构化测试的一般格式。例如,“给定一个单词,当它需要翻译成英语时,应该返回该单词。”

在这里,我们已经分解了业务需求。让我们再写一些来完善我们的单元测试列表:

  • 当一个单词需要翻译成英语时,应该返回该单词。

  • 当翻译时遇到大写单词或语言,应该返回与小写单词或语言相同的答案。

  • 当翻译时遇到包含额外空格的单词或语言,应该返回与没有空格的单词或语言相同的答案。

  • 当翻译时遇到不支持的语言或单词,应该返回一个空字符串。

这里你可以涉及你的测试人员或团队。将此列表发送给他们,以确保你没有遗漏任何内容,并且它符合业务需求。记住,这只是一个起点。你可能发现随着你编写更多的测试,这个列表会扩展。这个列表对于编写稳固和一致的测试至关重要,这些测试有助于增强对代码的信心。

3.2 编写单元测试

我们已经有了经过批准的测试列表;现在我们可以开始编写它们了。为此,我们将使用 Go 的内置测试库,并专注于编写尽可能少的代码来满足这些测试。让我们使用列表上的第一项来做这件事。打开你的测试文件,并添加以下列表中的代码。

列表 3.3 translator_test.go

package translation_test                                       ❶

import (
    "testing"
    "github.com/holmes89/hello-api/translation"
)

func TestTranslate(t *testing.T) {
    // Arrange                                                 ❷
    word := "hello"
    language := "english"

    // Act
    res := translation.Translate(word, language)               ❸

    // Assert
    if res != "hello" {                                        ❹
        t.Errorf(`expected "hello" but received "%s"`, res)    ❺
    }
}

❶ 使用单独的包提供黑盒测试

❷ 将所有要在测试中使用的变量添加到测试中,以便有更清晰的组织

❸ 调用要测试的函数并捕获结果

❹ 检查预期值

❺ 提供清晰的错误响应,以便更容易调试测试

这里是我们的第一个测试。你将注意到这个代码中的一些有趣之处。首先是“安排”、“行动”、“断言”模式,这是我们建立的。你注意到它们是如何从业务需求列表中的“给定”、“当”、“然后”句子中转换过来的吗?这是为了帮助我们关注我们正在测试的内容,并指向一个可测试项的列表。我们将在不久的将来将这个模式结合到测试中,以便我们覆盖的案例更加清晰。

你还会注意到使用黑盒测试方法。这指的是测试无法看到代码内部工作方式的代码包。这允许我们编写断言行为的测试,而不是实现。记住,应该测试系统在输入和输出上的表现,而不是其内部工作方式。这也要求你考虑一个合适的接口,或者你应用程序和代码的公开定义。你正在开发的单元是供他人使用的抽象。编写好的测试有助于推动良好的接口。拥有良好的接口很重要,因为一旦接口被公开,你将需要在将来支持它,而且它将变得难以更改。

通过输入 Go 测试命令go test ./...运行所有测试。你应该看到失败。现在我们需要修复它。同样,我们将尝试编写尽可能少的代码来满足这个测试。我们可以在代码中通过只返回word来处理这个问题,如下面的列表所示。

列表 3.4 translator.go

package translation

func Translate(word string, language string) string {
    return word       ❶
}

❶ 在修复测试中提供最小的努力

运行这个测试后,你会看到它通过了!这就是在测试驱动开发中被称为红、绿、重构的东西。首先,测试未能满足第一个预期,给你一个红色的错误;然后你修复测试,使其变为绿色;然后你添加到测试或更改底层代码以使其更简单,这就是重构。这限制了为给定功能所做的工作量。

测试驱动开发是一种许多开发者遵循的设计实践,由 Kent Beck 推广。在他的书《通过示例进行测试驱动开发》(Addison-Wesley Professional,2002 年)中,Beck 概述了一个编写失败测试、使其通过,然后通过从我们之前编写的测试列表中移除一项来使代码再次失败的模式。

为什么不一次性写完所有测试然后结束呢?这是一个很好的问题,我们应该在更广泛的背景下考虑。测试驱动开发是一种开发模式。它影响你做事的方式。通过遵循这个模式,你迫使开发者从需求的角度思考他们所写的代码。它通过迫使开发者首先考虑需求,证明需求得到满足,然后实际实现它们,将开发阶段移至次要操作。

以另一种方式思考,你可以将每个测试视为你希望进行的实验,以证明你的代码是否工作。在科学方法中,有三个主要步骤你需要完成:问题、测试和结果。通过你的测试代码,你可以根据预期的业务逻辑推测输入x的结果。测试将调用方法,结果将在你的代码中断言。

开发者 Ian Cooper 在提倡开发者成为“胶带程序员”时,用另一种方式表达了这一点。在定义了基本的测试和接口后,开发者应该继续前进并使其工作。然后他们可以使用测试来帮助他们完善实现,重新审视它,并确信它按预期工作。这种推动帮助开发者满足需求,而不需要过度设计解决方案。代码应该是动态的,而不是静态的,因此开发者应该定期回顾他们的代码,重构它以使其更好,并不断改进。

我们现在已经满足了这项测试。为了验证它,再次运行你的 go test ./... 命令,并查看是否通过。现在让我们添加更多语言。

3.3 重构,重构,重构

对于我们的演示,我们希望支持一些其他语言。让我们将德语和芬兰语添加到我们的需求中,同时,我们可以划掉我们的一个测试用例:

  • 当一个单词要翻译成英语时,应返回该单词。

  • 当翻译一个首字母大写的单词或语言时,应返回与未大写单词或语言相同的答案。

  • 当翻译一个带有额外空格的单词或语言时,应返回与没有空格的单词或语言相同的答案。

  • 当翻译一个不支持单词或语言时,应返回一个空字符串。

  • 当翻译单词 hello 时,应翻译成德语和芬兰语的“hallo”和“hei”。

我们划掉了第一个项目,因为我们有一个满足的测试用例。这并不意味着这个测试永远不会失败。它最终会失败,但我们已经实施了一个测试来保护我们不会错过业务案例。

注意:如果你发现自己是在更改测试而不是更改实现,你应该考虑更改的业务影响。测试与业务需求相一致。

让我们添加一些语言支持。我们将更新我们的测试,包括以下列表中的代码。

列表 3.5 translator_test.go

package translate_test

import (
    "testing"
    "github.com/holmes89/hello-api/translation"
)

func TestTranslate(t *testing.T) {
    res := translation.Translate("hello", "english")            ❶
    if res != "hello" {
        t.Errorf(`expected "hello" but recieved "%s"`, res)
    }

    res = translation.Translate("hello", "german")              ❷
    if res != "hallo" {
        t.Errorf(`expected "hallo" but recieved "%s"`, res)
    }

    res = translation.Translate("hello", "finnish")             ❸
    if res != "hei" {
        t.Errorf(`expected "hei" but received "%s"`, res)
    }

    res = translation.Translate("hello", "dutch")               ❹
    if res != "" {
        t.Errorf(`expected "" but received "%s"`, res)
    }

}

❶ 测试翻译是否适用于英语

❷ 测试翻译是否适用于德语

❸ 测试翻译是否适用于芬兰语

❹ 测试荷兰语返回空字符串

运行它,并查看测试是否失败。

这意味着我们需要为我们的服务添加不同的功能。切换回我们的 translator.go 文件,并修改它,以便我们可以处理这些新的测试用例。同样,我们的测试正在帮助我们推动代码的功能。在这里我们看到我们支持德语和芬兰语的翻译,但不支持荷兰语,并且如果找不到翻译,我们返回一个空字符串。

在这种情况下,我们不仅测试了“正常路径”,还测试了负面路径。断言积极结果的行为很重要,但更常见的是,错误或边缘情况会发生。“边缘情况”是系统上可能发生而你又没有预料到的罕见或极端条件。一个例子是输入奇怪的字符作为输入或非常大或非常小的数字。在这里,我们可以说我们需要处理我们没有翻译的语言的情况。下面的列表概述了代码可能的样子。

列表 3.6 translator.go

package translation

func Translate(word string, language string) string {
    switch language {        ❶
    case "english":
        return "hello"
    case "finnish":
        return "hei"
    case "german":
        return "hallo"
    default:
        return ""            ❷
    }
}

❶ 检查传入的语言并返回翻译后的单词

❷ 如果未知,则返回空字符串

你现在应该能够看到你的测试通过了。这个周期可以持续很长时间。在某些情况下,你可以立即解决一些小问题。记住,你不应该在开发中跳得太远,以免过度设计。尝试预测一些不同的用例,正如我们将在下一节中看到的那样。现在,我们已经为这个函数的工作建立了一个模式。

你能在我们的代码中看到其他边缘情况吗?我们是否在我们的列表中捕捉到了它们?

测试也需要重构以帮助使事情更清晰且易于扩展。我相信熟悉编写软件的你们中的一些人在看到我们测试中的重复代码时可能会皱一下眉头。我们可以稍微改变我们的结构,使其更容易通过使用表格测试添加测试。下面的列表提供了一个示例,说明我们如何重构我们的测试以使其简洁。

列表 3.7 translator_test.go

package translation_test

import (
    "testing"
    "github.com/holmes89/hello-api/translation"
)

func TestTranslate(t *testing.T) {
    // Arrange
    tt := []struct {                                             ❶
        Word        string
        Language    string
        Translation string
    }{
        {                                                        ❷
            Word:        "hello",
            Language:    "english",
            Translation: "hello",
        },
        {
            Word:        "hello",
            Language:    "german",
            Translation: "hallo",
        },
        {
            Word:        "hello",
            Language:    "finnish",
            Translation: "hei",
        },
        {
            Word:        "hello",
            Language:    "dutch",
            Translation: "",
        },
    }

    for _, test := range tt {                                    ❸
        // Act
        res := translation.Translate(test.Word, test.Language)   ❹

        // Assert
        if res != test.Translation {                             ❺
            t.Errorf(
                `expected "%s" to be "%s" from "%s" but received "%s"`,
                test.Word, test.Language, test.Translation, res)
        }
    }
}

❶ 创建一个包含所有测试用例的匿名结构体数组

❷ 每个案例包含测试的输入和输出结果。

❸ 遍历测试用例集

❹ 运行测试并捕获结果

❺ 检查结果并以适当的错误响应

这种测试模式在 Go 和其他语言中很常见,因为它将所有的测试场景放在一个地方,并且再次推动我们测试代码的更清晰接口。

现在我们的断言区域非常小,不再重复,我们的测试以这种方式组织,我们可以快速添加更多结果。运行你的测试以确保重构有效。一切都应该通过。现在让我们添加一个我们可能没有预料到的案例。记住,我们的系统目前应该只处理“hello”。如果我们输入的不是“hello”这个词会发生什么?让我们看看当我们添加以下列表中的代码时会发生什么。

列表 3.8 translator_test.go

package translate_test

import (
    "testing"
    "translation"
    "github.com/holmes89/hello-api/translation"
)

func TestTranslate(t *testing.T) {
    tt := []struct{
        Word string
        Language string
        Translation string
    }{
...
        {                  ❶
            Word: "bye",
            Language: "dutch",
            Translation: "",
        },
    }
...
}

❶ 无法翻译的单词和不支持的语言的负面案例

运行你的测试。它通过了。这是你预期的吗?你覆盖了所有其他情况吗?记得我之前问过其他边缘情况吗?这就是你需要戴上用户帽而不是开发者帽,开始看到你的代码可能出错的地方。我们经常依赖其他团队成员来找出这些情况(通常在 QA 中),但如果测试已经到了那个阶段,那么你正在浪费宝贵的周期,并且可能正在发送带有错误的代码。

注意:质量检查越接近实现,重做的机会就越小,这转化为更高水平的工作进度,进而转化为损失的钱。

著名的计算机科学家艾伦·佩利斯曾经说过:“如果测试与设计交织在一起,而不是在设计之后使用,那么软件系统将得到最佳设计。”这总结了为什么我们现在专注于单元测试。单元测试可以集成到我们的开发过程中,以创建更精简和更明确的代码。相应的测试将帮助我们防止做出影响系统的业务级逻辑更改。这并不意味着测试越多越好。我们需要的是质量测试,它们断言功能,而不是经常失败的脆弱测试。

现在我们能够从用户的角度进行更多思考,我们可以专注于向我们的单元测试中添加更多边缘情况,以确保一切按预期工作。让我们再添加一个测试(见下面的代码列表)。

列表 3.9 translator_test.go

package translate_test

import (
    "testing"
    "translation"
    "github.com/holmes89/hello-api/translation"
)

func TestTranslate(t *testing.T) {
    // Arrange
    tt := []struct{
        Word string
        Language string
        Translation string
    }{
...
        {                    ❶
            Word: "bye",
            Language: "german",
            Translation: "",
        },
    }
...
}

❶ 支持语言中不可翻译单词的负面案例

再次运行你的测试。现在你应该看到失败!当我们寻找空字符串时,我们得到了“hallo”,因为我们只翻译了“hello”。我们如何解决这个问题?再次访问我们的服务,并在下面的列表中添加代码。

列表 3.10 translator.go

package translation

func Translate(word string, language string) string {
    if word != "hello" {         ❶
        return ""
    }

    switch language {
    case "english":
        return "hello"
    case "finnish":
        return "hei"
    case "german":
        return "hallo"
    default:
        return ""
    }
}

❶ 添加对支持单词的检查

再次运行你的测试;现在它通过了!在这个时候,我们可能需要考虑一个最后的案例。开发者往往会忘记关于输入清理,或者使输入统一的过程。这可以包括空白字符的使用,到负数,无效参数,以及大写或小写字母,仅举几例。如果我们添加大写字母,我们的服务现在将有多强大?让我们通过在下面的列表中添加代码来找出答案。

列表 3.11 translator_test.go

package translate_test

import (
    "testing"
    "github.com/holmes89/hello-api/translation"
)

func TestTranslate(t *testing.T) {
    // Arrange
    tt := []struct{
        Word string
        Language string
        Translation string
    }{
...
        {
            Word: "hello",
            Language: "German",      ❶
            Translation: "hallo",
        },
        {
            Word: "Hello",           ❷
            Language: "german",
            Translation: "hallo",
        },
        {
            Word: "hello ",          ❸
            Language: "german",
            Translation: "hallo",
        },
    }
...
}

❶ 大写语言边缘情况

❷ 大写单词的边缘情况

❸ 单词中空格的边缘情况

我知道我说了一个最后的案例,但这里有三个不同的测试案例!你能猜出需要进行的修复是什么吗?

通常,服务的职责是实现“输入清理”以确保服务足够耐用和灵活,能够处理大多数传入的消息。这通常是一种许多 QA 成员在你创建服务或网页时就会尝试进行的 favorite 验证技术,并且应该在各个级别上处理,以防万一。在这种情况下,我们可以添加一个方法来清理语言和单词的输入,使用以下列表中的代码。

列表 3.12 translator.go

package translation

import "strings"

func Translate(word string, language string) string {
    word = sanitizeInput(word)                          ❶
    language = sanitizeInput(language)                  ❷

    if word != "hello" {
        return ""
    }

    switch language {
    case "english":
        return "hello"
    case "finnish":
        return "hei"
    case "german":
        return "hallo"
    default:
        return ""
    }

}

func sanitizeInput(w string) string {                   ❸
    w = strings.ToLower(w)
    return strings.TrimSpace(w)
}

❶ 清理传入的单词

❷ 清理传入的语言

❸ 创建一个清理输入的函数

测试现在应该通过,但这只是冰山一角。我们仍然有很多额外的测试要做。我们只处理了服务层,它仍然相当脆弱。现在我们需要检查值最初是如何到达服务的,这是通过我们的处理程序。

3.4 测试金字塔

之前我们确定了三个需要测试的系统部分:服务、处理程序和服务器。每个部分都可以用不同的方式进行测试。广义上,这些测试分为两大类:

  • 单元级测试—小型、独立的测试,在隔离状态下运行代码的部分。这些测试可以看作是测试正在建造桥梁的个别板和螺丝。如果一个已经腐烂或生锈,你不想使用它。在隔离状态下,这些测试变得更容易编写和管理,并且是任何自动化测试平台的基础。

  • 系统级测试—需要各种代码段或系统之间的交互。这个类别包括大量测试类型和实践,管理起来变得复杂,因此变得不太可靠或更昂贵(在时间和资源上)。

图 3.1 展示了这种差异。

图 3.1 将测试分解为单个单元,这些单元在隔离状态下进行测试,以及系统,这些系统测试在集成环境中的工作方式。

单元测试是运行最快的测试,应该包括我们系统中的所有构建块(或单元)。在图 3.2 中,我们可以看到测试被看作是一个金字塔,其中单元测试构成了基础,因为测试的数量。如果单元测试没有通过,我们就不应该向上移动到测试金字塔的更广泛的测试。这可以节省我们的时间,因为单元测试应该运行得快,易于理解,并且易于调试。当我们向上移动金字塔时,我们看到 集成 测试,这些测试验证了工作单元之间的功能,通常包括与外部依赖项(如数据库)的集成。最后,你有一个测试层,它验证整个系统或从端到端测试它,以查看系统是否完全按预期工作。还有其他类型的端到端测试可用,例如 负载测试,它测试系统在大量用户下的功能。图 3.2 中的倒金字塔是一个不稳定的模式,而常规金字塔则能够自我支撑。

图片

图 3.2 端到端测试在顶部较小,因为它们更昂贵且不可靠。它们应该由更大的集成和单元测试套件支持。每一层都应该独立运行。从单元测试开始,然后在不同阶段向上移动金字塔。

在左边的金字塔上向上移动,每一层都变得更小。这是因为当我们向上移动时,运行这些测试的能力变得更加昂贵,因为它们可能需要依赖项或更多资源。它们在运行方式上也可能不一致,因此结果可能不是确定性的,或不可预测的。如果我们把金字塔翻转成右边的“冰淇淋锥”,我们可以想象我们会处于一个怎样的世界。端到端测试不断变化,因为我们的应用程序的性质不断演变。如果我们花太多时间扩展这一级别的测试,我们将会有大量的返工,而无法验证底层模块是否正常工作。如果发生故障,你需要解开系统的所有内部工作来验证结果,而如果你有一个广泛的单元测试套件,你可以在模块级别验证错误或更改。

你将找到根据你团队的需求需要扩展或缩减以进行测试的地方。我们已经在服务级别建立了单元测试,建立了基础。现在我们希望将其扩展到包括一些其他自动化测试,以确保我们的系统按预期工作。

3.5 系统测试

现在我们已经为翻译建立了一个独立的服务,我们可以使用 REST 处理程序来调用这个服务。在 Go 和许多编程语言或框架中,HTTP 协议的实现与输出无关:HTML、纯文本、GraphQL 语法,几乎任何可以返回的内容。我们试图根据它们发送的响应类型来组织我们的处理程序文件。在这种情况下,我们发送 REST API 响应。

REST 代表表示性状态转移,这是一个通用的 API 编写风格的名称。尽管大多数人会将其与 JSON(JavaScript 对象表示法)联系起来,但它也可以与文件或 XML 格式一起使用。该设计使用基本的 HTTP 调用(POST、PUT、DELETE 等)以及使用 HTTP 头向用户发送信息以帮助解码信息,具有极高的灵活性。目前,我们将使用 JSON 作为我们的格式。

要做到这一点,我们创建一个新的包,名为handlers/rest。在其中,我们将创建一个名为translate.go的文件:

mkdir -p handlers/rest
touch handlers/rest/translate.go
touch handlers/rest/translate_test.go

目前,我们知道我们的服务只处理单个单词,“hello”,所以我们只支持那个请求;否则,我们将返回“未找到”,或 404 错误。默认情况下,翻译将是英语,除非用户传递?language=参数。让我们使用以下列表中的代码来构建一个空的处理程序以开始测试。

列表 3.13 translator.go

package rest                             ❶

import (
    "encoding/json"
    "net/http"
)

type Resp struct {                       ❷
    Language    string `json:"language"`
    Translation string `json:"translation"`
}

func TranslateHandler(w http.ResponseWriter, r *http.Request) {
    enc := json.NewEncoder(w)
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    resp := Resp{                        ❸
        Language:    "English",
        Translation: "Hello",
    }
    if err := enc.Encode(resp); err != nil {
        panic("unable to encode response")
    }
}

❶ 新的 API 工作休息包

❷ 构建一个结构体来存放响应结构

❸ 初始工作的硬编码响应

我相信你已经意识到这是来自我们的main函数的内容。然而,我们将很快用实际的业务逻辑来替换它。这个过程允许我们边迭代边测试。我们还把处理器从主函数中提取出来,以便它可以很容易地进行测试。像我们的单元测试一样,我们只想测试代码的一个部分,但与我们的单元测试不同,我们依赖于同一系统的外部部分进行测试。translate库的更改会影响这个测试,所以它不被视为单元测试,而是系统测试。在未来的章节中,我们将重构它以独立工作,但现在,我们将让它直接与服务集成(见以下列表)。

列表 3.14 translator_test.go

package rest_test                                             ❶

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/holmes89/hello-api/handlers/rest"             ❷
)

func TestTranslateAPI(t *testing.T) {

    // Arrange
    rr := httptest.NewRecorder()                              ❸
    req, _ := http.NewRequest("GET", "/hello", nil)           ❹

    handler := http.HandlerFunc(rest.TranslateHandler)        ❺

    // Act
    handler.ServeHTTP(rr, req)                                ❻

    // Assert
    if rr.Code != http.StatusOK {                             ❼
        t.Errorf(`expected status 200 but received %d`, rr.Code)
    }

    var resp rest.Resp
    json.Unmarshal(rr.Body.Bytes(), &resp)                    ❽

    if resp.Language != "english" {
        t.Errorf(`expected language "english" but received %s`,
            resp.Language)
    }

    if resp.Translation != "hello" {
        t.Errorf(`expected Translation "hello" but received %s`,
            resp.Translation)
    }
}

❶ 创建一个新的测试包以使用黑盒测试

❷ 导入 rest 包进行测试

❸ 创建一个用于断言的 HTTP 记录器

❹ 创建一个针对给定端点的新请求,没有正文内容

❺ 注册一个用于测试的处理程序

❻ 将内容传递给处理器以根据请求生成响应

❼ 检查响应的状态码

❽ 将响应体解码为要断言的结构

运行测试,你应该得到一个失败!这是因为我们没有使用服务来将消息转换为小写。让我们将我们的处理器更改为现在使用服务而不是我们编写的硬编码值。我们会发现处理器和服务的测试将是“紧密耦合”的,这意味着一个的变化会影响另一个,并且测试序列将看起来很相似。但请记住,我们在这里测试的不是服务的逻辑,而是请求和响应处理和转换。

你也会注意到,我们不仅断言响应消息的正文,还断言状态码。HTTP 状态码通过告诉用户系统层面发生了什么来帮助传达额外的信息。200 OK 是最常见的,告诉我们一切正常。表 3.1 列出了用于发送消息的常见代码。

表 3.1 常见 API 使用的 HTTP 消息

Code 消息 常见用途
200 OK 一切如预期进行。
201 已创建 新实体已添加到系统中。
401 未授权 缺少凭证。
403 禁止 不允许访问端点或资源。
404 未找到 无法找到资源或端点。
500 内部服务器错误 系统因某些未知原因失败。
503 服务不可用 系统不工作且已知。

通常,这些代码被分为几个更广泛的类别,如表 3.2 所示。

表 3.2 HTTP 消息的一般分组

Code 类型 常见用途
1xx 信息性 系统信息。
2xx 成功 一切如预期进行。
3xx 重定向 有所移动,需要更改请求。
4xx 客户端错误 客户端有错误。
5xx 服务器错误 服务器未能处理请求。

我们响应代码应该反映我们返回的消息类型。我们响应体中的适当消息应提供必要的信息。我们通过在以下列表中添加代码来完成此操作。

列表 3.15 translator.go

const defaultLanguage = "english"

func TranslateHandler(w http.ResponseWriter, r *http.Request) {
    enc := json.NewEncoder(w)
    w.Header().Set("Content-Type", "application/json; charset=utf-8")   ❶

    language := defaultLanguage                                         ❷
    word := strings.ReplaceAll(r.URL.Path, "/", "")                     ❸
    translation := translation.Translate(language, word)                ❹
    resp := Resp{
        Language:    language,
        Translation: translation,
    }
    if err := enc.Encode(resp); err != nil {
        panic("unable to encode response")
    }
}

❶ 设置内容类型的头部为 JSON 规范

❷ 目前默认语言为英语

❸ 从 URL 路径中获取单词

❹ 翻译单词

我们现在将添加一些额外的功能。但就像之前一样,让我们重新结构化这些测试以成为表格测试,这样我们可以快速重构我们正在编写的代码。我们将使用以下列表中的代码来重写它。

列表 3.16 translator_test.go

func TestTranslateAPI(t *testing.T) {
    tt := []struct {                                           ❶
        Endpoint            string
        StatusCode          int
        ExpectedLanguage    string
        ExpectedTranslation string
    }{
        {
            Endpoint:            "/hello",
            StatusCode:          http.StatusOK,
            ExpectedLanguage:    "english",
            ExpectedTranslation: "hello",
        },
        {
            Endpoint:            "/hello?language=german",
            StatusCode:          http.StatusOK,
            ExpectedLanguage:    "german",
            ExpectedTranslation: "hallo",
        },
    }

    handler := http.HandlerFunc(rest.TranslateHandler)         ❷

    for _, test := range tt {                                  ❸
        rr := httptest.NewRecorder()
        req, _ := http.NewRequest("GET", test.Endpoint, nil)

        handler.ServeHTTP(rr, req)

        if rr.Code != test.StatusCode {
            t.Errorf(`expected status %d but received %d`,
                test.StatusCode, rr.Code)
        }

        var resp rest.Resp
        json.Unmarshal(rr.Body.Bytes(), &resp)

        if resp.Language != test.ExpectedLanguage {
            t.Errorf(`expected language "%s" but received %s`,
                test.ExpectedLanguage, resp.Language)
        }

        if resp.Translation != test.ExpectedTranslation {
            t.Errorf(`expected Translation "%s" but received %s`,
                test.ExpectedTranslation, resp.Translation)
        }
    }
}

❶ 定义测试案例为一个端点、状态、翻译和语言

❷ 注册处理程序

❸ 遍历所有测试场景

运行你的测试,你会看到一个新的失败。让我们修复测试(见以下代码列表)。

列表 3.17 translator.go

func TranslateHandler(w http.ResponseWriter, r *http.Request) {
    enc := json.NewEncoder(w)
    w.Header().Set("Content-Type", "application/json; charset=utf-8")

    language := r.URL.Query().Get("language")      ❶
    if language == "" {
        language = defaultLanguage
    }
    word := strings.ReplaceAll(r.URL.Path, "/", "")
    translation := translation.Translate(word, language)
    resp := Resp{
        Language:    language,
        Translation: translation,
    }
    if err := enc.Encode(resp); err != nil {
        panic("unable to encode response")
    }
}

❶ 从查询参数中检索语言

运行你的测试,并添加一个额外的案例,如果翻译缺失,响应应该是没有值的404 Not Found。让我们添加它(见以下列表)。

列表 3.18 translator_test.go

func TestTranslateAPI(t *testing.T) {
    tt := []struct{
        Endpoint string
        StatusCode int
        ExpectedLanguage string
        ExpectedTranslation string
    }{
        {
            Endpoint: "/hello",
            StatusCode: 200,
            ExpectedLanguage: "english",
            ExpectedTranslation: "hello",
        },
        {
            Endpoint: "/hello?language=german",
            StatusCode: 200,
            ExpectedLanguage: "german",
            ExpectedTranslation: "hallo",
        },
        {
            Endpoint: "/hello?language=dutch",     ❶
            StatusCode: http.StatusNotFound,
            ExpectedLanguage: "",
            ExpectedTranslation: "",
        },
    }
    ...
}

❶ 在缺少语言或翻译的情况下,我们应该得到 404 错误代码。

查看失败,并修复代码(见以下列表)。

列表 3.19 translator.go

func TranslateHandler(w http.ResponseWriter, r *http.Request) {
    enc := json.NewEncoder(w)
    w.Header().Set("Content-Type", "application/json; charset=utf-8")

    language := r.URL.Query().Get("language")
    if language == "" {
        language = "english"
    }
    word := strings.ReplaceAll(r.URL.Path, "/", "")
    translation := translation.Translate(word, language)
    if translation == "" {
        language = ""
        w.WriteHeader(http.StatusNotFound)
        return
    }
    resp := Resp{
        Language:    language,
        Translation: translation,
    }
    if err := enc.Encode(resp); err != nil {
        panic("unable to encode response")
    }
}

我们应该会成功!

3.6 将其添加到管道中

现在我们已经重构了我们的服务,我们应该更新我们的函数并将测试添加到管道中。首先,让我们更新我们的main.go文件以使用我们新的处理程序和服务(见以下列表)。

列表 3.20 main.go

package main

import (
    "log"
    "net/http"
)

func main() {

    addr := ":8080"                                    ❶

    mux := http.NewServeMux()

    mux.HandleFunc("/hello", rest.TranslateHandler)    ❷

    log.Printf("listening on %s\n", addr)              ❸

    log.Fatal(http.ListenAndServe(addr, mux))          ❹
}

❶ 设置要监听的端口

❷ 注册翻译处理程序

❸ 记录监听端口

❹ 运行服务器并记录如果它失败

啊,看起来整洁多了!我们已经成功地将应用程序的大部分内容拆分成更小的部分,这些部分可以单独进行测试,这使得系统更容易推理。我们花费了所有这些时间编写这些测试,以便在检查代码时可以使用它们来帮助验证功能。一旦我们将这些添加到我们的管道中,它将就像构建步骤一样,它保护我们不会推送有缺陷的更改,而测试成为我们系统中的第一个“关卡”。

质量关卡是工业工程中的一个术语,其中在组装线各个阶段之前检查产品的质量。你希望保护每个阶段不浪费时间。在我们的小程序中,构建步骤运行得相当快,但在更大、更复杂的系统中,构建时间可能会更长,所以我们想确保我们不会浪费时间尝试构建有缺陷的东西或发送不按预期工作的代码。

假设你正在计划做一顿饭。你混合了一堆原料按照食谱进行,然后意识到主要原料已经变质。现在你可能需要快速去商店或者丢弃整个东西,浪费时间和金钱。但如果你提前检查了所有原料的质量,你就可以避免一些麻烦。

我们将构建我们的管道以执行相同的操作。通过在构建步骤之前添加测试步骤,我们可以确保在构建之前代码正在运行。大多数 CI 系统允许你在各个步骤之间创建依赖图,以便将这些步骤链接起来,从而在整个系统上节省时间和精力。我们将在未来扩展这一功能,以添加可以并行运行的额外保护、构建和部署。

现在,让我们将测试检查添加到以下列表中的代码管道中。

列表 3.21 pipeline.yml

name: CI Checks

on:
  push:
    branches:                                               ❶
      - main
jobs:
  test:
    name: Test Application
    runs-on: ubuntu-latest                                  ❷
    steps:
    - name: Set up Go 1.x                                   ❸
      uses: actions/setup-go@v2
      with:
        go-version: ¹.18
    - name: Check out code into the Go module directory     ❹
      uses: actions/checkout@v2
    - name: Downloads
      run: go get -t ./...
    - name: Run Test
      run: go test ./...                                    ❺
  build:
    name: Build  App
    runs-on: ubuntu-latest
    needs: test                                             ❻
    steps:

    - name: Set up Go 1.x
      uses: actions/setup-go@v2
      with:
        go-version: ¹.18

 ...

❶ 仅在主分支上运行

❷ 定义基本操作系统

❸ 设置 Go 环境

❹ 检出代码

❺ 运行测试

❻ 在进行构建步骤之前等待测试通过

提交你的更改,并推送你的分支!前往你的仓库,并观察测试运行。现在你可以看到结果。

3.7 代码覆盖率

编写测试以查看代码是否工作是有帮助的。我们在进行过程中添加了几个测试来检查系统的各个部分,并添加了功能。但我们是否都做到了?我们需要测试所有内容吗?

许多语言,包括 Go,提供了查看测试“代码覆盖率”的能力,这意味着它们会突出显示已测试代码的百分比,并突出显示可能被遗漏的区域。随着代码的增长,你将会有需要测试的逻辑分支或可能发生的额外错误条件,确保你可以扩展测试以覆盖所有需要测试的区域总是很有帮助。让我们看看我们已经测试了多少:

go test ./... -cover

图 3.3 显示了覆盖率。

图 3.3 输出反映了包中行的覆盖率

图 3.3 显示了覆盖率。

你应该会看到一个图表,显示所有已测试的文件和覆盖率数量,以及底部的总数。百分比告诉我们我们没有覆盖所有代码,也许我们应该考虑添加更多测试。你可能想知道需要多少覆盖率。随着时间的推移,你的代码覆盖率应该会随着更多测试的添加而增加。这确保了你在随着时间的推移改进你的系统。这可能意味着在缺乏测试的区域编写更多测试,甚至删除未使用的代码。

代码覆盖率在某些开发团队中可能是一个热门话题。有些人说你需要覆盖每一行代码,测试代码片段可以执行的每一种可能的方式,以确保最高的质量。虽然这是一个值得追求的目标,但这并不意味着你的代码按预期工作。试图达到总代码覆盖率可能导致编写糟糕的测试,这些测试随着时间的推移难以维护。通常,像这样的任意目标,虽然初衷良好,但可能导致阻碍公司整体目标:交付产品。

我们想要强制执行一定水平的测试,比如 80%,我们还希望为我们的开发者提供一个简单的方法来查看是否有任何分支或区域他们遗漏了,他们可以轻松添加测试。我们将添加一些工具到我们的 Makefile 中,以使这更容易。

Go 有一个内置的工具,允许你输出覆盖率配置文件,然后使用帮助你操作它的工具,这样你就可以看到覆盖率并生成报告。首先,让我们打开我们的 Makefile,并添加以下列表中的代码。

列表 3.22 Makefile

test:
    go test ./... -coverprofile=coverage.out              ❶

coverage:
    go tool cover -func coverage.out | grep "total:" | \
    awk '{print ((int($$3) > 80) != 1) }'                 ❷

❶ 从测试生成输出覆盖率

❷ 使用代码覆盖率工具查找总行数并检查该值以确保满足覆盖率预期

此脚本将确保创建覆盖率配置文件,而不仅仅是运行go test。记住,我们希望为我们的开发者提供与管道相同的工具,以帮助保持两者同步。第二行提供了一些“Unix 魔法”,它将覆盖率工具的结果通过管道传递到grep命令中,以查找总数,然后检查结果以确保它高于我们的最低测试阈值。如果条件不满足,此结果将返回一个错误代码,这意味着我们的管道将失败。

现在我们可以使用这个相同的覆盖率配置文件来生成一个覆盖率报告,我们将将其作为工件添加到我们的管道中。这将有助于指导我们当前的测试工作,并查看我们哪些地方做得不够。这也可以帮助团队领导决定是否应该进行一个测试日,让开发者花一天时间清理代码并添加测试。我们将在后面的章节中讨论更多类似的活动,但你现在应该明白,沟通是构建成功团队的关键。随着时间的推移,来自管道的报告在帮助引导整体开发者体验和产品开发方面非常出色。

注意:沟通是构建成功团队的关键。

要生成报告,我们将在 Makefile 中添加另一个工具:

report:
    go tool cover -html=coverage.out -o cover.xhtml

在图 3.4 的报告中的报告中,你会看到你已经测试过的行和遗漏的行。你看到我们可能测试过的区域了吗?我们能重构我们的代码,使其更容易到达这些遗漏的部分吗?试着做一下,看看你是否能提高到一个更高的水平,并计时看看这需要多长时间,以及这是否有助于解决可能的错误。

图片

图 3.4 如果测试代码覆盖了该代码,则行以绿色突出显示,而红色行则未覆盖。如果您无法区分颜色,请注意,错误部分是代码中唯一未测试的区域。

这些输出文件应存储在本地,并且不应提交到我们的源代码控制中。打开您的.gitignore文件,并添加以下内容:

coverage.out
cover.xhtml

现在我们可以更新我们的 CI 代码来运行覆盖率检查并上传报告。您的团队可以执行一些额外的后处理步骤,以便将这些结果发布到团队仪表板或 Slack 帖子,以便其他人可以轻松查看,但就目前而言,我们将允许它和二进制文件一起下载(见以下列表)。

列表 3.23 pipeline.yml

name: CI Checks

on:
  push:
    branches:
      - main
jobs:
  test:
    name: Test Application
    runs-on: ubuntu-latest
    steps:
    - name: Set up Go 1.x
      uses: actions/setup-go@v2
      with:
        go-version: ¹.18
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
    - name: Run Test
      run: make test                 ❶
    - name: Coverage Check
      run: make coverage             ❷
    - name: Generate Report
      run: make report               ❸
    - name: Copy Files
      run: |
           mkdir  reports
           cp cover.xhtml reports/.

    - name: Archive                  ❹
      uses: actions/upload-artifact@v2
      with:
          name: reports
          path: reports

❶ 使用测试 make 命令

❷ 检查测试覆盖率

❸ 根据覆盖率生成报告

❹ 将报告上传到存档

我们现在已成功在我们的系统中添加了第一个检查。对于一些人来说,测试可能一开始会显得繁琐,而其他人可能需要被说服其好处,但这是一个确保您以高质量代码前进的简单方法。在未来,您会发现一个可以帮您避免犯错的测试。即使在编写这本书的过程中,我也发现我所编写的测试修复了我示例代码中的错误。停下来思考并解决手头的问题可以帮助您成为一名更好的开发者。

测试对于某些开发团队来说是一个非常敏感的领域。一些成员可能比其他人对此有更高的热情。因此,您和您的团队建立您希望实现的测试模式和最佳实践,并在可能的情况下进行标准化是很重要的。测试不应成为教条,也不应阻碍您产品的开发。它是向您的公司和客户表明您正在满足他们期望的工具。

您抬头看到人们正在离开办公室。这是一天结束的时候,您已经提交并推送了您的代码。当您导航到仓库时,您会看到一个漂亮的绿色勾选标记,您笑了。在一天之内,您已经编写了一个带有测试的概念证明。明天您需要找到一种方法在中午的演示之前将其上线。幸运的是,您已经有了计划。

摘要

  • 自动化测试有助于验证系统按预期工作。

  • 单元测试是小型、独立运行的测试,专注于代码的一小部分。

  • 系统测试集成到多个模块中,这些模块断言系统的整体行为。

  • 专注于测试代码的接口,而不是代码本身。

  • 力求高覆盖率,但低于 100%也是可以的。

4 引入持续部署

本章涵盖

  • 区分交付和部署

  • 将应用程序交付到工件存储库

  • 将应用程序部署到托管服务

  • 使用健康检查来验证应用程序正在运行

你很早就开始工作。在你把东西放在桌子上后,你走到咖啡机那里。当你到达那里时,你看到一群运维人员正在交谈。你可能会说这是巧合,但事实并非如此。你知道运维团队很早就到岗,而且这是和他们交谈的最佳时机。

问候他们后,你随意提到了你一直在做的演示。有人叹了口气:“我实在看不出他们怎么能期望我们一直这样做。我们根本就没有资源来持续资助和运行这些小项目。我们有一堆工作要做:新项目、部署、系统升级和性能调整。更不用说,我们经常因为紧急情况而被抽调。我并不是在责怪你,但有时候这确实是一场噩梦。”

有时候这确实是一场噩梦。你还记得几个月前完成了一个功能,然后等待它被部署。计划发布花了超过一周的时间,部署失败是因为别人的配置更改破坏了系统。发生的事情是一整天的事件,人们都在检查所有的更改,试图找出什么出了问题。从创建代码到部署之间的差距太大,导致了太多问题。

“我希望开发者能更多地拥有部署的部分。我们设置了流程和批准了服务,他们自己负责部署。在他们专注于部署和周围的问题时,我们会帮助他们更新和维护机器。如果我们能使用一些不需要我们维护服务器的新的服务,那就太理想了。但说实话,我不知道这个模式会是什么样子,也不知道是否有人会接受它。从长远来看,这将节省我们的时间和金钱。”

这正是你希望有人会说的。赋予开发者权力。在没有服务器开销的情况下运行服务。快速迭代并经常交付。你提到你可以将其作为你的演示的一部分。

“真的吗?”运维人员说,“你会在你的项目中加入这个范围为我们做?那太好了,我很乐意听听你发现的情况以及我们可能有的选择。”

将此视为许可,你倒好咖啡,回到你的桌子旁。

4.1 交付

你首先意识到的是,你最终希望把这件事交给其他人。昨天是 QA 团队感兴趣,明天可能是整个开发团队。你需要把编译好的产品发布出去,让其他人容易消费。你需要交付。

注意:有关使用 Jenkins、GitLab 和 CircleCI 等工具的管道工具列表,请参阅第二章。

要做到这一点,我们可以将二进制文件附加到我们的管道中,就像我们在上一章中处理代码覆盖率报告时做的那样。让我们打开我们的pipeline.yml文件,并添加以下列表中的代码。

列表 4.1 pipeline.yml

name: CI Checks

on:
  push:
    branches:
      - main

jobs:
    test:
        ....
    deliver:                                                       ❶
        name: Release
        needs: build
        runs-on: ubuntu-latest
        steps:
        - name: Checkout code
            uses: actions/checkout@v2
        - name: Download binary                                    ❷
            uses: actions/download-artifact@v2
            with:
                name: api
        - name: Create Release
            id: create_release
            uses: actions/create-release@v1
            env:
                GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}          ❸
            with:
                tag_name: ${{ github.ref }}                        ❹
            release_name: Release ${{ github.ref }}                ❺
            body: |
                Still in experimentation phase
            draft: true                                            ❻
            prerelease: true                                       ❼
        - name: Upload Release Binary
            uses: actions/upload-release-asset@v1
            env:
                GITHUB_TOKEN:
                  ${{ secrets.GITHUB_TOKEN }}
            with:
                upload_url:
                  ${{ steps.create_release.outputs.upload_url }}   ❽
            asset_path: api
            asset_name: api
            asset_content_type: application/octet-stream           ❾

❶ 在我们的管道中创建一个新的步骤,称为交付

❷ 从我们的构建步骤下载二进制文件

❸ GitHub Actions 提供了一个内部令牌用于授权更新你的仓库。

❹ 使用触发构建的更改引用标记发布。现在它只是切换到稍后用于其他部署触发的主分支。这是一个从 GitHub Action 环境传递给我们的属性。

❺ 使用与上一步相同的机制命名发布

❻ 这不是一个最终发布版本,因此我们将其标记为草稿,以便客户无法查看。

❼ 类似地,我们还没有完成,所以我们将这个标记为预发布。

❽ 将二进制文件上传到之前步骤中创建的发布 URL

❾ 内容类型是二进制,因此我们需要将其定义为八位字节流,以便 GitHub 能够识别。

我们现在可以将我们的更改推送到我们的管道并观察其运行。一旦完成,你应该能在你的仓库的“发布”标签页上看到一个新的发布版本,如图 4.1 所示。

图 4.1 该页面包含我们现在需要的所有信息,以及一个可下载的二进制文件。

这就是了!你正在立即交付产品,但这并不是我们管道的终点。我们之所以只关注发布我们的产品,是因为它是交付的最简单形式——为某人提供一个可用的产品,但这并不一定意味着部署,即让产品运行。部署是在一个过程中运行的最终步骤,在这个过程中,你将你的产品作为服务运行和使用。并非所有产品都需要部署,但所有产品都应该交付。库是一个常见的未部署但已交付的产品。根据其运行位置,部署也可能变得复杂(正如我们将看到的)。构建和运行新的服务器或软件升级也是部署的额外形式。在本章中,我们将探讨这两种形式。

要知道一个产品在市场上是否可行,唯一的方法是将它推向市场。一旦人们开始使用你的产品,你将收到关于他们想要什么、喜欢什么以及不喜欢什么的反馈。这种反馈推动了你产品的开发。到目前为止,我们已经编写了所谓的最小可行产品,或 MVP。尽管我们的例子很简单,但你应该能够看出你最初写下的东西不需要完美。事实上,它永远不会完美。许多公司因为不够快地将自己推向市场,而等待太长时间才收到关于他们正在构建的产品的反馈。

反馈可以来自有远见的人、一组试点用户、投资者或公众。推出你的产品不是你需要担心唯一的事情。你还必须关注你能够多快推出你的产品。这正是我们一直在努力的方向。我们的流程将帮助我们转换代码成为产品并发布。正是这一最后一步我们还需要完成,但一旦我们完成了,我们就可以开始迭代我们的流程,以制造更好的产品。

在本书的这个阶段就发布内容可能看起来有些奇怪,但这正是我们试图做的事情的本质。就像我们的产品一样,我们的流程永远不会完美。你和你的团队需要像对待产品一样修订和增强你的流程。制造商不仅经历了一个创建和增强他们制造的产品的过程,而且还提高了他们生产这些产品的效率。

4.2 开发者作为操作者

对于一些人来说,这个过程可能看起来很奇怪。我为什么要在有整个运营团队来处理的情况下进行部署?这是一个好问题。许多公司会组织他们的团队专注于他们擅长的特定领域。虽然这允许个人专注于成为特定领域的专家,但它往往在团队之间设置障碍。这可能导致团队之间产生矛盾,阻碍积极的协作。协作破裂,因为指责游戏变得更容易,而不是花时间去理解问题和共同努力寻找解决方案。

以以下场景为例:一个测试人员发现报告上日期错误的一个 bug,并向分配给开发者的工单提交了问题。开发者查看 bug 后写道,“在我的机器上这没问题;服务器可能没有配置正确的时间区域”并附上了一张截图。然后工单被踢给了运营团队,他们只是重新分配了工单并评论道,“服务器已正确配置;不会完成。”工单就这样在那里待了数周,而每个人都认为这不是他们的问题。

受害者是谁?质量保证?开发者?运营?错误。是客户。

当我们不作为一个团队工作时,我们不理解彼此的角色。当我们不理解彼此的角色时,我们不会考虑可以帮助他们和最终客户的解决方案。

在 NASA 太空计划初期,人们意识到任务控制中心在故障排除和理解他们开发和使用的科技方面遇到了问题。对每个人来说这都是全新的,因为那时没有人进入过太空。Gene Kranz,当时是程序官员(他后来在阿波罗 13 号任务中成为了飞行指挥官),意识到存在脱节,工程师需要成为操作员。工程师构建了系统;他们在技术上理解它们,但从未真正将它们作为更大系统的一部分来使用。另一方面,操作员不需要理解设备的内部工作原理;他们只需要知道如何让它运行以及它可能造成的问题。

今天我们可以看看与要求我们的软件工程师成为操作员相同的过程。这被称为 DevOps,它结合了软件开发和 IT 运维。正如 Gene Kranz 在 NASA 早期发现的那样,系统可以迅速变得复杂,并且在每条通信线路之间都会出现知识流失。在太空任务中,如果有人不知道闪烁的红色灯光是由什么引起的,结果可能是灾难性的。

DevOps 以多种方式接管了行业,但往往它变成了一个误称,最终变成了运维。真正的 DevOps 发生在开发者有机会在生产环境中部署和管理产品,而运维团队能够对代码进行更改,无论是为了部署还是解决产品问题。这有一个原因。团队变得融合在一起。事实上,如果由我来决定,我会称之为 DevOpQas,并将测试作为所有这些的一部分。

我们在我们的项目中已经做了一些 DevOps 工作,但我发现简要地告诉你为什么这很重要是件好事。作为一名开发者,你可以了解你的代码是如何工作的;作为一名运维人员,你可以了解它应该如何运行。将会有一些运维人员对如何部署项目有更好的理解,他们可以指导你通过解决方案,或者为你提供示例或模块供你使用。将会有一些开发者能够帮助增强和构建部署和管道,使他们的产品运行更高效。

要构建有效的产品,你需要找到一种团队合作的方式。理解是成功的关键,而成功将使你的客户满意。

4.3 设置部署账户

你的电子邮件中出现了一条通知。内容是:“感谢你自行考虑部署你的项目。我已经提升了你在我们 Google Cloud 账户中的账户权限,以便你可以进行实验。为了你的演示,我建议查看他们的一些‘作为服务’产品。尝试几个,并告诉我你的想法。我们可以稍后再谈。”

太好了!你现在有了在生产环境中部署事物的能力。明智地使用这种力量!

这很令人兴奋,因为您一直在研究各种产品和部署它们的方式。然而,在您开始之前,您需要创建一个帐户并设置部署密钥。要创建帐户,我们需要导航到cloud.google.com/free。点击链接的“开始使用”。在您的帐户设置完成后,我们将通过使用服务帐户创建部署密钥。

注意:我们将在所有部署中使用谷歌的云平台(GCP)。GCP 提供了一个很好的免费层,为他们的产品分配了信用额度,并且许多产品在特定负载下运行免费。我们可以使用 GCP 来开发和部署我们的产品,而无需承担任何前期成本。我们在这里采用的原则很容易转移到其他平台,如亚马逊网络服务(AWS)和微软 Azure。

要这样做,请转到右侧的汉堡菜单,找到 API 与服务,然后选择凭据,如图 4.2 所示。

图 4.2 查找凭据页面。

一旦进入此页面,请点击顶部的创建凭据按钮,并选择服务帐户。

注意:在您在外部系统上构建产品时,拥有服务帐户非常重要。您的帐户包含诸如信用卡和其他个人识别信息等东西,并且通常具有对给定平台上所有产品的完全访问权限。它还锁定了特定的权限和特权,如果由于某种原因组织外的人获得了访问权限,它也不会损害您的帐户。服务帐户设置将类似于图 4.3。

图 4.3 创建新的服务帐户。

在这里,您将选择一个您认为合适的名称。您应该为正在构建的特定产品或正在使用的特定服务创建一个服务帐户。在这种情况下,我们将专注于产品本身,并将其命名为 hello-api。

接下来,我们将被提示为服务帐户选择特定的角色。我们将在未来添加额外的权限,但到目前为止,请使用出现的搜索提示来选择以下角色:

  • 应用引擎管理员—为应用引擎提供管理功能,例如删除和调用端点

  • 应用引擎部署者—允许部署应用引擎应用程序

  • 云构建编辑器—允许用户编辑云函数

  • 云函数管理员—允许用户创建和销毁云函数

  • 云函数开发者—允许创建和编辑云函数

  • 存储管理员—允许存储文件

从图 4.4 中选择产品。

图 4.4 每个产品将用于不同产品的不同权限。

一旦选择了所有这些,请点击继续,然后点击完成。点击您刚创建的用户,然后在上部选择密钥。

点击“添加密钥”→“创建新密钥”,选择 JSON,并下载文件。这是一个凭证文件,我们可以将其添加到我们的 GitHub 账户中作为部署的密钥。请将其保存在安全的地方。图 4.5 展示了示例密钥设置。

图片

图 4.5 为部署创建新密钥。

同时,打开一个标签页,导航到您的 GitHub 仓库,并选择设置→密钥。在那里,创建一个名为 GCP_CREDENTIALS 的新密钥。将 JSON 文件的内容添加到其中并保存。现在我们可以将我们的函数添加到管道中,如图 4.6 所示。

图片

图 4.6 将密钥内容添加到 GitHub 中的密钥。

现在我们已经准备好为任何我们想要运行应用程序的产品创建部署。但我们该选择哪一个呢?

4.4 如你所愿

那些我们大多数人为设置物理服务器而担忧的日子已经过去了。AWS 在 2006 年推出了其计算平台,并彻底改变了公司运行和维护服务器的方式。服务器随后被虚拟化,并由一组独特的 API 命令控制,这些命令允许轻松创建和销毁服务器实例,并为开发者提供了一个称为基础设施即服务(IaaS)的伟大抽象,使他们能够工作。第二年,即 2007 年,一家名为 Heroku 的公司使开发者部署产品的工作变得更加容易。他们创造了被称为平台即服务(PaaS)的平台。这个平台提供了允许开发者快速创建和迭代应用程序的抽象。在这两家公司之间,我们见证了无服务器应用程序和云计算的革命。

今天,其他产品被标记为“作为服务”。每个服务都为开发者提供了所需的不同的抽象层。抽象是有帮助的,因为它们隐藏了关于底层系统的某些细节。这种抽象有两个代价。首先是使用抽象的财务成本,因为通常抽象程度越高,整体成本就越高。随着时间的推移,如果产品开始流行,使用更便宜但更依赖开发者维护的其他服务可能变得更加经济。第二个代价是无法访问抽象隐藏的某些功能。例如,在函数即服务中,用户无法使用系统库进行图像处理或视频分割等操作。和所有事情一样,这也伴随着各种权衡,这是您和您的公司需要决定的事情。如图 4.7 所示,所有这些服务都在服务器上运行,但您需要担心的事情取决于抽象。从右向左移动,您的成本通常是时间,而从左向右移动,成本则是金钱。

图片

图 4.7 每种“即服务”产品都提供了不同层次抽象,您作为客户与之交互,并基于这种抽象提供可发布的项目。在抽象之下是服务器的一些元素,在过去这些是整个团队负责维护的。

当您向不同方向移动时,您的代码开发方式也会改变。向右移动提供了很多抽象,因此专注于可以运行的单一函数。向左移动允许您利用更多系统级功能,如存储和操作系统调用。表 4.1 概述了各种服务。

表 4.1 “即服务”应用

缩写 服务 产品
IaaS 基础设施即服务 AWS EC2, Google Compute
CaaS 容器即服务 AWS ECS, Google Cloud Run
PaaS 平台即服务 Heroku, Google App Engine, AWS Elastic Beanstalk
FaaS 函数即服务 AWS Lambda, Google Cloud functions

为了为您的公司制作一个有效的演示,您必须首先概述您正在进行的成本决策,并展示随着时间的推移,您的产品在每种环境中如何变得灵活。一位运营团队成员建议选择一个,但您认为,“为什么不为演示选择两个?”第一种部署类型将通过函数即服务 (FaaS) 展示低成本快速开发,第二种将通过平台即服务 (PaaS) 展示可扩展的应用服务。大多数公司将从图 4.7 的右侧向左侧移动,直到找到最适合他们的那个。我们将在这本书中一直使用这种方法,以便我们可以拉回各种抽象层。

首先,让我们创建一个无服务器应用程序,因为它具有低成本的使用。无服务器应用程序是函数即服务 (FaaS) 应用程序的另一个名称,因为它有一个单一的入口点,即函数,开发者不需要了解或理解任何关于平台或运行时的事情。这种抽象为您团队节省了时间,因为他们不需要专注于对系统或容器库进行安全更新或升级。他们也不需要为系统上的闲置时间付费。大多数云解决方案都会按小时收费以运行服务。相反,FaaS 关注的是函数遇到调用的次数。这允许您和您的团队在产品开发过程中进行实验,同时产生很少或没有成本。

4.5 函数即服务 (FaaS)

不幸的是,没有一种通用的方法可以在不同的平台上创建 FaaS 应用程序。您定义一个包和函数来运行命令,这就是在 GCP 上构建和部署的内容。Go 使用标准的 http.Handler,因此我们的产品几乎不需要更改。然而,GCP 只会查找指定的根文件夹,不会处理子包中的函数。

在项目的根目录下打开一个新的faas.go文件,并放入以下列表中的代码。

列表 4.2 添加代理处理程序以移动我们的调用

package faas

import (
  "net/http"

  "github.com/holmes89/hello-api/handlers/rest"
)

func Translate(w http.ResponseWriter, r *http.Request) {
  rest.TranslateHandler(w, r)
}

您始终可以使用 http.Mux 在这里重定向未来的多个调用到一个单一函数。

这就是我们使我们的函数工作所需做的全部。现在我们可以创建一个部署步骤到我们的管道中。打开您的pipeline.yml文件,并添加部署步骤(见以下列表)。

列表 4.3 pipeline.yml

jobs:
...
  deploy-function:
    name: Deploy FaaS
    runs-on: ubuntu-latest
    needs: test
    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
    steps:
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
      with:
        fetch-depth: 0
    - name: Deploy function
      id: deploy
      uses: google-github-actions/deploy-cloud-functions@main
      with:
        name: translate                                        ❶
        entry_point: Translate                                 ❷
        runtime: go116
        credentials: ${{ secrets.gcp_credentials }}            ❸
    - id: test
      run: curl "${{ steps.deploy.outputs.url }}/hello"        ❹

❶ 给函数一个可引用的名称

❷ 给出要调用的函数名称

❸ 使用为服务账户注册的密钥进行部署

❹ 测试调用是否工作

当测试通过时,部署步骤将会发生。从您的部署输出中,您应该看到一个端点。在您能够调用它之前,您需要更新权限以允许公共访问此端点。导航到您的 Google Cloud 控制台,搜索“functions”。您应该看到您新创建的函数,如图 4.8 所示。

图片

图 4.8 编辑函数的权限。

注意:您可能需要启用云功能:mng.bz/KlOZ

点击添加成员,输入“allUsers”,并赋予它云函数调用者角色。它会提示您,这将使您的函数公开,如图 4.9 所示。

图片

图 4.9 使您的函数公开。

在提示中按下确认,打开浏览器,并输入管道输出中找到的 URL,使用/translate/hello?language=german。您应该看到响应返回!更改语言。您看到了什么?尝试不同的输入,看看您能做什么来使其工作(或使其失败!)

当您的试用期结束时,您可能需要为运行这些各种应用程序承担一些费用。为了避免这种情况,当您完成这本书后,请删除应用程序。

为了强调我们刚刚所做的事情的力量,让我们通过支持新的语言来修改我们的代码。打开您的translate.go文件,并添加以下列表中的代码来为法语添加翻译。

列表 4.4 translate.go

func Translate(word string, language string) string {
  word = sanitizeInput(word)
  language = sanitizeInput(language)

  if word != "hello" {
    return ""
  }

  switch language {
  case "english":
    return "hello"
  case "finnish":
    return "hei"
  case "german":
    return "hallo"
  case "french":       ❶
    return "bonjour"
  default:
    return ""
  }

}

❶ 新行以检查您的 CI 是否工作

不要忘记添加测试!(见以下列表。)

列表 4.5 translator_test.go

func TestTranslate(t *testing.T) {
  // Arrange
  tt := []struct {
    Word        string
    Language    string
    Translation string
  }{
    ...
    {
      Word:        "hello",
      Language:    "french",
      Translation: "bonjour",
    },
  }
    ...
}

提交并推送您的更改。等待部署完成,然后再次尝试调用,但这次使用法语作为语言。这是一个快速迭代和快速交付以及快速反馈以满足客户需求的过程。现在您以最低的成本持续交付产品,并具有学习和成长的能力。在某个时候,您可能会发现需求在增长,您将需要扩展以满足该需求。或者您可能会发现您的产品没有达到预期,您需要转型。使用无服务器模式,您只需为使用付费,因此风险最小化。

4.6 平台即服务

FaaS 的好处是它们提供了足够的抽象,使得开发和应用部署变得快速且简单。这种抽象是以财务支出和控制为代价的。一般来说,你会发现,抽象越少,运行你的应用程序的成本就越低,直到一定程度。尝试为尚未经过测试的产品托管自己的服务器和基础设施会有昂贵的运营成本。或者,你可以为亚马逊或谷歌支付额外费用来处理这些事情。最终,如果你的产品变得流行,你将需要做出转变。现在我们将从 FaaS 迁移到 PaaS。

PaaS 允许你提交你的源代码,然后平台将为你识别、构建和运行应用程序。2007 年,Heroku 成为第一个可用的 PaaS 之一,它彻底改变了人们开发和部署程序的方式。他们的平台提供了一个建立在 AWS 云计算之上的抽象层,这为顾客提供了在无需担心配置或支付服务器费用的情况下开发应用程序的好处。这为亚马逊和谷歌提供了向客户提供类似抽象的机会铺平了道路。

对于许多独立服务,检查应用程序是否正在运行且健康变得很重要。通常,如果涉及依赖项,如数据库连接,这个服务可以发挥很大的作用。使用健康检查端点将告诉运行平台该服务正在运行且准备就绪。否则,平台可能会尝试重新启动应用程序或标记部署为失败。在我们开始将应用程序迁移到 PaaS 之前,让我们添加一个健康检查端点。我们没有外部依赖项,所以我们将使用以下列表中的代码在handlers/health.go文件中编写一个简单的处理程序。

列表 4.6 health.go

package handlers

import (
  "encoding/json"
  "net/http"
)

func HealthCheck(w http.ResponseWriter, r *http.Request) {
  enc := json.NewEncoder(w)
  w.Header().Set("Content-Type", "application/json; charset=utf-8")
  resp := map[string]string{"status": "up"}                             ❶
  if err := enc.Encode(resp); err != nil {
    panic("unable to encode response")
  }
}

❶ 我们现在直接硬编码响应,因为我们不需要检查任何服务的连接。将来,我们可以在这里添加更多关于特定依赖状态的信息。

现在我们有了这个端点,我们需要对我们的main.go文件做一些轻微的修改。

列表 4.7 main.go

func main() {
  addr := fmt.Sprintf(":%s", os.Getenv("PORT"))
  if addr == ":" {
    addr = ":8080"
  }

  mux := http.NewServeMux()

  mux.HandleFunc("/translate/hello", rest.TranslateHandler)
  mux.HandleFunc("/health", handlers.HealthCheck)             ❶

  log.Printf("listening on %s\n", addr)

  log.Fatal(http.ListenAndServe(addr, mux))
}

❶ 我们将健康检查添加到/health 端点以确保我们可以调用它。

现在我们的应用程序有了健康检查,让我们将其部署到 PaaS 上。

为什么我们没有将健康检查添加到 FaaS 中?作为一个函数,我们通常不期望它有一个长时间运行的状态。相反,它被调用并关闭。在一些平台上,这些函数会短暂运行以减少预热过程,预热是启动应用程序的过程。健康检查通常是长时间运行的服务需要知道是否应该关闭或重启时所需的东西。

在 Heroku 提供 PaaS 之后不久,谷歌就推出了 Google App Engine。最初专注于 Java 和 Python 应用程序,现在它支持包括 Go 在内的多种语言。App Engine 将使用您的源代码,并在一个沙盒或隔离的运行时中运行它,以防止您的应用程序影响其他应用程序。这种虚拟化和抽象形式确保了您的应用程序将安全且安全,同时提供了一个简单的方式来开发和部署可扩展的应用程序。谷歌担心平台是否在运行,以及升级服务器和安装库,所以您不必担心。这就是使用 PaaS 的力量;它让您控制整个应用程序,而无需担心底层运行时。部署到 App Engine 与部署云函数一样简单,但有一个附加条件:我们需要在根项目文件夹文件中提供一个 app.yaml 来描述部署。让我们使用以下列表中的代码在我们的项目根目录中创建一个。

列表 4.8 app.yaml

runtime: go116
main: ./cmd
liveness_check:
  path: "/health"
  check_interval_sec: 30
  timeout_sec: 4
  failure_threshold: 2
  success_threshold: 2
readiness_check:
  path: "/health"
  check_interval_sec: 5
  timeout_sec: 4
  failure_threshold: 2
  success_threshold: 2
  app_start_timeout_sec: 300

这就是谷歌启动我们的应用程序并确保一切正常运行所需的所有内容。为了部署这个,我们需要在我们的管道中添加一个步骤。而不是替换函数,我们部署两个。在函数步骤下方,将以下列表中的代码添加到我们的管道中。

列表 4.9 pipeline.yml

name: CI Checks

on:
  push:
    branches:
      - main

jobs:
...
  deploy-paas:
    name: Deploy PaaS
    runs-on: ubuntu-latest
    needs: test
    steps:
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
      with:
        fetch-depth: 0
    - name: Deploy App
      id: deploy
      uses: google-github-actions/deploy-appengine@main
      with:
        credentials: ${{ secrets.gcp_credentials }}
    - id: test
      run: curl "${{ steps.deploy.outputs.url }}/translate/hello"

就这样!现在我们可以推送我们的更改并调用部署输出的端点。从这里,我们可以扩展并获得客户的反馈。这些反馈将促进更多增长,并将我们的应用程序引导到有用的方向。我们在编写代码和看到它在生产中的无缝连接有助于提高开发者的生产力和参与度。不再需要花费数小时或数天才能看到工作成果;相反,它只需几分钟即可出现。向前看,我们将增强我们的管道以拥有更好的开发实践,减少错误,并实现更高级的部署,但就目前而言,我们可以反思构建管道是多么强大和简单。

您提交更改并将它们推送到您的仓库。就在那时,您抬头看到项目经理快速向您走来。“嘿,”他们说,“你准备好演示了吗?”

摘要

  • 以描述更改的内容交付产品,以帮助客户适应并使用您的产品。

  • 总是交付和部署以获得客户反馈。

  • 健康检查端点是传达已部署产品状态的一种简单方式。

  • 每种部署类型都有不同级别的抽象,以帮助开发者快速发布产品。

  • FaaS 有助于创建简单、易于管理的应用程序,但长期来看成本较高。

  • PaaS 有助于为您提供更完整的服务器来运行您的应用程序,但具有易于部署的选项。

第二部分. 规模化

在第一部分,我们建立了一个管道、测试流程和部署,因此在第二部分,我们可以专注于加固我们的流程以减少错误,并继续向我们的客户交付。我们应该获得一些关于产品如何被使用和采纳的反馈,并利用这些信息来专注于添加更多功能和改进我们的性能。

到这个时候,你的团队将会扩大,你们所有人都需要达成共识。标准化和代码质量检查将有助于避免开发者时间浪费,通过在代码运行前验证代码是否正常工作,这正是我们在第五章中将要工作的内容。在第六章中,我们将看到随着我们向更模块化的系统发展,测试变得越来越重要。最后,在第七章中,我们将通过通用方式打包我们的应用程序,使其对其他开发者便携和可访问。

5 代码质量执行

本章涵盖

  • 通过使用格式化工具和代码检查器标准化我们的代码格式

  • 通过引入静态代码分析工具减少代码中的错误和漏洞

  • 在将代码推送到仓库之前自动化质量检查

  • 组织我们的代码并为其编写文档,以便更清晰的使用和重用

  • 通过代码审查建立学习文化

“正如你所见,我们可以以一种赋予开发者快速、高效且高质量交付项目的能力的方式来构建我们的项目。我们只用了一天的时间就编写并部署了我们的翻译应用的新版本,同时提供了灵活的部署选项和自动化的质量检查。”

当你的项目经理这样说并坐下来时,他们满脸笑容。你的演示做得非常好。不深入细节,你能够展示你的新应用,甚至在演示期间推送了实时更改,以展示你可以实现的多快速度。你的 CTO 看起来很感兴趣,但并不确信。

“你所展示的看起来很有希望,但我还没有确信这可以扩展。我们有一群其他开发者,一个完整的 QA 团队,以及一个运营团队,他们都需要工作。我需要看到一份计划,展示我们如何在整个组织中推广它。我需要看到你如何与现有系统、团队和开发者集成,在我考虑在全局范围内实施之前。我们能否在周五见面,解答这些问题?”

你点头。这一切对你来说都很有意义,因为开发者很少单独工作。此外,这是一个迭代的过程,你刚刚得到了很多很好的反馈。

“太好了!”你的项目经理说,“我会为我们安排一些事情。请确保你有一个计划,文件,并且为周五准备另一个演示。你这次做得很好。让我们看看你是否能再次做到。”

5.1 代码审查

到目前为止,你一直是在真空状态下工作——只有你和你的代码。这并不是软件通常的制作方式。相反,大多数项目都是几个人之间的协作努力。随着时间的推移,这些人组成了一个团队。作为一个团队,他们制定了一系列规则来运作,相互学习,并帮助彼此改进。但我们如何做到这一点呢?

我们已经引入了这项工具:仓库。仓库不仅存储你的代码,我们还可以将其放置在待处理模式中,直到有人允许将其合并。再次想象我们的仓库是一个仓库。当货物到达时,有人需要签收。如果箱子损坏或丢失,就可以追溯到接收货物的人。有一种责任感。

同样,我们希望我们的团队能够对我们将要引入代码库中的任何内容进行签字确认。为此,我们使用一种称为“拉取请求”的机制,这是一种针对代码库的审查过程。这将保护源代码,并教育他人你正在进行的更改。现在让我们将这种保护添加到我们的代码库中。

要做到这一点,我们需要导航到我们的代码库。点击设置→分支。在这里,你会看到一个名为“分支保护规则”的部分,你将在其中看到一个表单,你可以输入你想要保护的分支的名称和与之相关的特定规则。按照图 5.1 中的方式填写:添加你想要保护的分支以及保护此分支需要执行的操作。这将保护主分支免受直接提交(除非你是管理员)的影响,并将在你有一个审查并且检查通过之前阻止合并。

图 5.1 在 GitHub 仓库中设置分支保护

图 5.1 设置 GitHub 仓库中的分支保护

你应该注意到,我们还在合并前选择了检查应该通过。我们已经在单元测试中设置了一些检查,并将把这些检查贯穿于整章(和整本书)中,以帮助我们的审查并保护开发者的时间。但就目前而言,让我们专注于我们刚刚引入的分支保护。在此阶段,除了管理员之外,没有人能够直接向你的主分支提交代码。相反,他们必须提交一个拉取请求供他人查看。这意味着任何更改都需要得到另一人的批准,作为在整个产品中跟踪责任的一种方式。如果引入了错误,它就不再是单个个人的错误,而是整个团队的责任,因为他们没有在审查中捕捉到它。

当你只是试图完成工作的时候,代码审查似乎是一个缓慢而繁琐的过程。但我可以向你保证,事实并非如此。它们提供了一种极好的方式来教导他人,并通知你的团队你正在进行的更改。即使是在独立进行项目工作时,我也会为自己创建拉取请求。这有助于我审查更改的内容,并帮助我识别错误和问题。这就像审查你写的论文,发现你在初稿中遗漏的错误和明显的问题。一个有效的审查应该包含哪些内容,你又在寻找什么?

5.1.1 保持简洁

将审查限制在 300 行代码(包括测试代码)以内。为什么?因为我们作为人类,注意力跨度有限。即使你整天都能读小说,或者在午餐休息时阅读技术期刊,你可能也无法处理大量的审查。审查更像是在阅读食谱,而不是一本书。长而复杂的食谱容易失败,因为你可能会错过一个步骤。在这里,你可能会错过一行代码或一个小错误。虽然我们稍后会介绍的一些工具可能会捕捉到这些,但它们仍然可能被忽略。较小的审查让我们能够专注于任务,快速合并,并且经常合并,正如我们在第一章中讨论的,使用基于主干的开发。

小型审查不是一成不变的规则,但这是你的团队必须学会做的事情。这首先来自于理解如何将任务分解成小块,以便你的代码审查可以更小。2000 行的更改可以分解成大约 200 行的 10 次审查。虽然这看起来可能有些过度,但你可能会发现你的团队能够更多地关注这些小的变化,并指出问题。此外,正如第一章中展示的,可能不是只有一个人在同时进行开发,API 和 UI 工作之间的任务分解也是如此。

5.1.2 保持开放的心态

代码审查是一种团队建设练习,应该这样对待。它们不是对你作为开发者的个人攻击或挑战,也不是用来羞辱其他开发者的方式。它们是你学习和教授的机会。

作者斯蒂芬·金说,成为一名优秀作家的第一步是成为一个积极的读者。我相信这一点同样适用于开发者。要成为一名更好的开发者,你需要阅读更多的代码。作为一个团队,这可以让资深开发者向年轻开发者展示不同的技术和编写代码、解决问题的方法。对于年轻开发者来说,这成为了一种向资深开发者展示新技术和问题解决方案的方式。我个人非常喜欢代码审查。我认为这是一种与团队建立和工作的绝佳方式。

将代码审查视为与朋友进行的哲学讨论,而不是政治讨论。在开发中,没有一成不变的规则,但总有可以从他人那里学习的地方,就像哲学讨论一样。一旦变得个人化,其他人学习就会变得更加困难,通常有人会开始竖起防御性的壁垒,不再学习而是看到另一面。时至今日,我仍然不知道像代码审查这样一件看似无辜的事情怎么会成为团队的一个痛点,但它们确实经常如此。以下是我认为你可以避免这种情况的一些技巧:

  1. 以你希望被对待的方式对待他人。

  2. 在门口放下你的自尊。

  3. 不要用糟糕的代码浪费他人的时间。

  4. 从反馈中学习,并尽量不再犯同样的错误。

  5. 将讨论移至线下,而不是在评论中来回回复。

  6. 对他人的行为保持开放的心态。

  7. 确保它能够正常工作。

这个列表来自我过去团队的经验;99%的问题可以通过沟通解决,其余的 1%可以通过流程解决。将代码审查作为一种建立联系和团队建设的方式,而不是作为一种让自己看起来更好或让别人看起来更差的方式。

5.1.3 保持前进

审查应该优先考虑,因为它们被视为工作正在进行中(WIP)。正如你在前面的章节中学到的,WIP 是管道中停滞的资金。让我们来做一些数学计算。如果一个开发者每年赚 10 万美元,那么每小时的开发工作相当于大约 50 美元的价值。当代码处于审查状态时,我们不会从这项工作中获得任何收入。我敢肯定你正在想,“如果我正在审查代码,我就没有在写代码,这是浪费时间和金钱。”如果你在代码处于审查状态时花时间写代码,你会增加 WIP,并且不会交付价值。很快,大量的审查都在进行中,但没有任何东西完成。

也可能看起来在编写代码和审查代码之间切换任务可能会很昂贵,因为你可能会忘记自己在哪里以及你在做什么。这个问题的答案是学会将审查融入你的日常工作中。将任务与某事联系起来。当你发现自己有一杯新鲜的咖啡时,就进行审查。我每天早上都会坐在咖啡旁,进行审查,然后开始新的一天。一旦到了喝第二杯咖啡的时间(或者饮料饮用的最终结论发生),我会进行更多的审查。我们总是有时间做我们想做的事情,但很少尝试做我们不想要做的事情。我们感到沮丧,因为没有人为我们的代码进行审查,但我们没有花时间去审查他们的代码。

注意:确保时间被安排在内,并确保其他人知道你正在等待审查(礼貌地)。

提醒其他人,停滞在审查中的时间是远离向客户交付的时间。

5.1.4 保持有趣

代码审查一次又一次可能会变得乏味,因此保持事物有趣是很重要的。与你的团队讨论如何改变和改进你的审查流程。进行你的审查,提出问题或发表评论。这是从他人那里获得反馈的好方法。设立挑战,看看谁能通过重构消除最多的代码,或者谁能找到编写单元测试的新方法。

这看起来很愚蠢,但它有助于团队士气。就像任何事物一样,它让人们对更多的事情保持兴趣。我曾经和一个团队一起工作,该团队要求在拉取请求提交过程中包含一个有趣的 GIF 作为部分要求,审查者不仅需要审查代码,还需要对 GIF 进行评分。再次强调,这有助于建立团队士气,虽然这看起来像是人为的或浪费时间,但团队的凝聚力增强,团队成员的生产力也提高了。

5.1.5 保持一致

虽然实验很有趣,能让人保持参与,但建立一些标准也很重要。GitHub 允许使用 pull request templates,这允许你为拉取请求创建一个标准格式,包括检查清单。检查清单是提醒他人提交请求前需要做什么的好方法。为此,打开你的源代码,创建一个名为 .github 的新目录,并添加一个名为 PULL_REQUEST_TEMPLATE.md 的文件。下一个列表显示了一个可以帮助拉取请求的示例模板。

列表 5.1 PULL_REQUEST_TEMPLATE.md

### Description
Please explain the changes you made here.

### Associated Task
Please list closed, fixed, or resolved issues here with a # and the number.

### Checklist
- [ ] Code compiles correctly
- [ ] Added tests that fail without the change (if possible)
- [ ] All tests passing
- [ ] Extended the documentation

在这里,你询问了已经做了什么,以及为这项工作提供的文档类型,以及提交前应该做的事情清单。

当作为团队在代码审查过程中工作时,你应该定期检查哪些工作得好,哪些工作得不好。通过这样做,你可以开始完善流程。你仍然可以做一些事情来自动化流程并教会他人。我们将看到如何让人类不必承担所有审查的负担,而是与机器一起协作,帮助指导和教学。

5.2 开发约束

在著名的电视剧《我爱露西》中有一个臭名昭著的场景,主角露西和她的朋友伊瑟尔在流水线上工作,结果出了差错。露西和伊瑟尔在一家巧克力工厂工作,将巧克力从流水线上放入包装纸中。一开始,两人还能跟上巧克力流动的速度,但不幸的是,发生了一个意外事件,导致她们落后了。慌乱中,两人想尽办法阻止巧克力的流动。对公众来说,这是不幸而又好笑的展示,说明了如果工人在流水线上落后会发生什么。观看这个片段的工业工程师只看到一件事:一个约束。约束也被称为瓶颈。它是流水线上决定工厂吞吐量的位置。

在他的小说《目标》中,艾利雅胡·高德拉特概述了所谓的“约束理论”,其中他声称,任何系统中不是约束的优化都是没有意义的。在我们的《我爱露西》例子中,如果露西和伊瑟尔不能及时包装,提高巧克力制作速度是没有意义的。这如图 5.2 所示。如果 A(露西)每分钟生产四个物品,而 B(伊瑟尔)每分钟只能处理一个物品,我们就会开始过度生产物品。很快,过剩的库存就会开始堆积,我们整个系统的总吞吐量将只有每分钟一个物品。

图片

图 5.2 步骤 B 只能从步骤 A 处理一个物品。工作最终会在 B 前面堆积,对 A 或 C 的任何改进都不会有助于吞吐量。

根据高德拉特(Goldratt)的观点,任何公司的重点都应该是尝试提高约束的吞吐量,并保护其时间不被浪费在那个阶段。这被称为提高约束。提高约束有几种方法。在我们的我爱露西(I Love Lucy)例子中,如果额外的工人帮助他们,或者如果他们有一个可以让他们包装十倍数量的巧克力的机器,露西和艾塞尔可能不会有问题。在另一个场景中,让我们想象露西和艾塞尔能够保持一个不错的速度,并且每小时能够包装 100 块巧克力,但其中 10%由于某种原因有缺陷。他们的吞吐量将下降到每小时 90 块巧克力。一旦我们确定了我们的约束,我们就可以找到新的方法使其变得高效,并保护它,以便我们有更高的吞吐量。

软件开发也有约束。几乎整个流程都是由计算机自动化的,这意味着我们管道中最慢的部分是开发者。实际思考和开发功能应该是决定我们管道吞吐量的因素。代码生成和审查需要时间,并且不是自动化任务;因此,时间应该得到保护。一个简单的解决方案可能是增加更多工人。增加更多团队成员可能意味着更多的人编写代码,但这会变得复杂。随着团队的增长,需要更多的沟通线路来维持关系和协作。通常,大多数公司遵循两个披萨规则,即一个团队不应该有超过两个披萨可以喂的人数。如果人数更多,你开始看到团队生产力的递减回报。

注意:弗雷德·布鲁克斯(Fred Brooks)曾著名地表示,给一个项目增加更多的人手并不会加快交付时间,就像“九个女人一个月也生不出一个孩子”一样。

如果增加人手不是解决方案,我们需要确保保护他们的时间。我们可以通过测量特性和计算缺陷作为返工或存在缺陷的工作来衡量一个公司的吞吐量。然后我们可以通过使代码易于开发、在发生之前捕捉到错误来提高我们的约束。这可以通过使我们的代码易于阅读、编写和修复,并提供一种机制来学习和教授其他开发者我们编写的内容来实现。在本章中,我们在合并代码之前检查代码的质量,并将这种质量检查作为学习和改进我们系统的一种方式。

5.3 通过格式和 lint 检查标准化我们的代码

在装配线上,各个工作站通常都是标准化的,这样工人就不必浪费精力或时间去确定哪些部件该放在哪里。如果我们回顾露西和艾塞尔的情况,我们可以想象如果他们需要确定哪种包装颜色应该放在各种巧克力的不同部分,他们可能会落后得多。相反,所有的包装和巧克力都被标准化了,以帮助流动,这样露西和艾塞尔就可以尽可能快地包装它们。

标准化随后成为帮助我们通过开发流程的重要元素。正如我们在第二章中概述的,标准化我们设置环境和工作站的方式对于整体开发者体验很重要,但这种标准化需要扩展。如果我们把包装和巧克力作为流经露西巧克力工厂的材料的一部分,我们需要考虑如何通过我们的管道标准化我们的材料。但如何标准化代码呢?

注意:在此处区分工业编程与个人项目很重要。工业编程意味着其他人将与你在某个产品上一起工作,该产品将被其他人消费。在这里,标准化变得很重要,以确保每个人都处于同一页面上。个人项目如果你只是对某事进行实验,则不需要这种程度的严谨性。如果个人项目成为工业产品,最好考虑使用这些原则重新编写它。

每一段代码的编写都必须服务于某种特殊或个人目的;否则,就不会被编写出来。你不会从每个开发者那里得到相同的独特代码块。可以标准化的是代码的外观和感觉、代码的文档、代码片段的既定模式以及测试。标准化随后变成了一个提出通用代码风格并决定如何执行这种风格的过程,以便当其他人查看你的代码时,它与其他团队成员编写的代码无法区分。这通常是通过一个风格指南来完成的,其中团队建立了一套关于其代码格式的规则。一般来说,这可以很简单(或具有争议性),比如使用制表符与空格、保持括号在同一行或下一行、函数之间的空格等。这将在不同的语言和团队之间有所不同。

除了格式规则之外,Go 还提供了一份编写惯用 Go 代码的指南,许多代码检查器都会尝试强制执行。我们将在后面讨论代码检查器,但以下文章还提供了在编写 Go 语言时做出各种格式决策的理由:golang.org/doc/effective_go

然而,Go 语言有其独特的风格,因此这并不是一个问题。流行的玩笑是“没有人喜欢 Go 格式,每个人都喜欢 Go 格式”,这意味着人们可能不喜欢 Go 格式化工具的一些方面,但每个人都喜欢存在一个标准格式化工具,而且没有人需要担心它。它使用简单且内置其中。进入你的项目目录,然后输入

go fmt ./...

你可能会看到一些变化,也可能不会。Go 会将括号移动到与函数声明相同的行,用制表符替换空格,合并函数之间的空格,等等。重点是,作为一个开发者,你应该担心的事情不仅仅是你的代码格式。对于其他语言,如 JavaScript 的 prettier 包和 Python 的 autopep8,还有其他工具。但是,有格式标准和使用它们是两回事。

我们需要做两件事来帮助我们的开发者:首先,自动化流程;其次,强制执行。让我们从第二部分开始,这样我们就可以看到它的实际效果,然后再进行自动化。

为了强制执行这些规则,我们已经在主分支上设置了一些限制。我们将设置我们的管道,在允许推送合并按钮之前强制执行检查。

目前,我们的持续集成系统仅在主分支的更改上运行,因此我们需要更新 pipeline.yml 文件以在拉取请求上运行。打开该文件,并添加以下列表中的代码。

列表 5.2 pipeline.yml

name: CI Checks

on:
  pull_request:                                                             ❶
    branches:
      - main
  push:
    branches:
      - main
...
  deploy-function:
    name: Deploy FaaS
    runs-on: ubuntu-latest
    needs: test
    if: ${{github.event_name=='push' && github.ref == 'refs/heads/main'}}   ❷
    steps:
...
  deploy-paas:
    name: Deploy PaaS
    runs-on: ubuntu-latest
    needs: test
    if: ${{github.event_name=='push' && github.ref == 'refs/heads/main'}}   ❷
    steps:
...

❶ 在主分支的拉取请求上运行

❷ 只在主分支的推送操作上运行部署,不运行拉取请求

此外,我们还想添加一个新检查,以查看合并前是否进行了格式化,因此我们将在我们的管道中添加一个步骤。我们将添加以下列表中的命令到我们的 Makefile 中(见下)。

列表 5.3 Makefile

...
report:
  go tool cover -html=coverage.out -o cover.xhtml

check-format:
  test -z $$(go fmt ./...)      ❶

❶ 这将检查运行格式化命令的结果,看是否有任何变化。如果有,它将返回一个失败值。

现在,我们可以更新我们的管道以运行此步骤,如下所示。

列表 5.4 pipeline.yml

jobs:
  test:
    needs:
      - format-check                   ❶
    name: Test Application
...
  format-check:
    name: Check formatting
    runs-on: ubuntu-latest
    steps:
    - name: Set up Go 1.x
      uses: actions/setup-go@v2
      with:
        go-version: ¹.18
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
    - name: Run Format Check
      run: make check-format           ❷

❶ 由于格式化比运行测试成本低,让我们先检查格式以节省时间。

❷ 从 Makefile 中调用我们的检查格式化命令以查看结果

现在创建一个名为 task/quality-check-enforcement-formatting 的分支,并提交你的更改。推送新分支,并创建一个拉取请求。观察 CI 管道运行以确保所有更改都正常工作。它失败了?如果是这样,调查失败原因。如果它通过了,你可以自由地尝试通过破坏格式化并再次推送来让它失败。一旦一切正常,就合并它。恭喜!你已经为主分支添加了一个保护措施。实际上,我们已经添加了两个:格式化和测试现在都需要通过才能合并到主分支。这也减轻了我们的团队需要要求人们格式化的负担。接下来,我们需要通过自动查找无法通过 linting 检测到的坏代码和安全漏洞来帮助我们的团队。

5.4 静态代码分析

软件用于自动化以前的手动任务。虽然人类对审查至关重要,但他们可能会犯错误。幸运的是,许多不良编码实践和反模式可以通过称为 静态代码分析 工具的软件自动识别。这些工具会遍历你的代码,寻找与错误或安全漏洞相关的已知模式。一些工具可以用来强制执行良好的编程实践,如文档和拼写。将静态代码分析工具添加到你的管道中可以帮助减少错误并保护审查者免于在“坏代码”上浪费时间。

要做到这一点,我们将使用两个工具。一个是 Go 内置的工具,另一个是社区支持的提供广泛库的工具,这些库支持额外的检查。让我们首先从 Go 的内部命令 go vet 开始,这是一个提供有关代码中错误快速结果的绝佳工具。现在让我们运行它,看看是否有任何问题。为此,请输入

go vet ./...

与测试中一样,三个点表示程序运行 vet 工具跨所有包。希望你在结果中看不到任何东西。打开 cmd/main.go,更改一行(见以下代码列表)。

列表 5.5 main.go

...
func main() {
  addr := fmt.Sprintf(":%s", os.Getenv("PORT"), "error")     ❶
  if addr == ":" {
    addr = ":8080"
  }
  ...
}

❶ 添加一个额外的变量可能导致这一行失败。

现在运行 go vet 命令,你应该会看到一个错误。vet 工具检查你的源代码,发现你有一个格式化命令,其变量多于预期。这很好,因为它将捕获一个潜在的错误。我们应该将此添加到我们的管道中,以便我们可以进行这些检查。再次强调,运行这个命令比运行测试(或将来会是这样)要快得多,因此我们应该在测试之前运行它,但我们也可以在格式化检查之后运行它。让我们更新我们的管道,使用以下列表中的代码运行这些检查。

列表 5.6 pipeline.yml

vet:
    name: Check formatting
    runs-on: ubuntu-latest
    steps:
    - name: Set up Go 1.x
      uses: actions/setup-go@v2
      with:
        go-version: ¹.18
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
    ...
    - name: Vet
      run: go vet ./...     ❶

❶ 运行内置的 Go 工具检查代码

这将在我们运行测试之前和检查格式化之后执行。这很好,因为它为我们提供了一个可以通过它来测试代码质量和在出现问题时向开发者提供具体反馈的管道。我们可以将其视为各种筛子,通过这些筛子我们筛选石头。较大的孔允许较大的石头通过,但随着我们逐渐减小孔径,石头变得越小,你越容易看到单个石头。最后,你将留下你想要的石头不同大小。

以同样的方式,我们的代码将通过,为我们提供易于消化的错误和改进,直到我们只剩下一个准备审查的产品。管道方法在针对和指出各种问题方面效果良好,但也可能导致更长的领先时间。领先时间指的是从问题或功能提出到交付给客户的过程中,从开始到停止的过程时间。这些步骤之间的时间被称为周期时间。如果一个周期时间是总领先时间的子集,我们可以专注于优化我们的周期时间,以减少对客户的总领先时间。在图 5.3 中,您可以看到客户请求到交付之间的总领先时间应该减少以满足他们的需求。为了做到这一点,我们可以考虑减少管道中每个步骤的周期时间。

图 5.3 Holmes4

图 5.3 总的领先时间是任务创建到交付给客户的时间。

我们可以想象我们的管道可以同时运行多个阶段,例如代码检查、审查和测试。这会变得计算成本高昂,因为您正在并行运行机器或进程,但您可以得到更快的结果,并可以看到所有发生的问题。这是您的团队需要确定如何运行管道的另一个地方。在这个例子中,我们将在管道中运行并行和顺序步骤的混合。

注意:GolangCI Lint 是一个聚合代码检查工具,允许开发者从众多代码检查器中选择。

go vet 是静态检查我们代码的一个很好的起点,但并不需要就此停止。可以在您的机器上安装一个名为 golangci-lint 的工具,并将其用作管道步骤。GolangCI-Lint 允许您从众多的代码检查和静态检查库中选择,以帮助扩展您的质量评估。完整的列表可在库页面找到,但我们将在这里使用几个以开始。默认情况下,它将运行几个检查,以查找未使用的代码、无效的变量赋值、缺失的错误检查等等。此外,我们还将添加一个安全检查。为此,我们需要创建一个名为 .golangci.yml 的新文件。在根目录中创建它,并添加以下列表中的代码。

列表 5.7 golangci.yml

linters:
  enable:
    - gosec           ❶

output:               ❷
  format: colored-line-number

  # print lines of code with issue, default is true
  print-issued-lines: false

  # print linter name in the end of issue text, default is true
  print-linter-name: true

❶ 将代码检查器添加到文件中

❷ 自定义代码检查器的输出格式

我们所使用的代码检查器在运行本项目时会自动查找此配置文件。

代码安全性是静态代码分析中经常被忽视的一步,但却是关键的一步。安全检查可以允许你选择使用哪些随机化库和函数,以及你可能需要的哈希类型。你的代码可能不会使用这些功能,但有一天你可能会发现(如果你启用了你的代码检查器),你可能需要它们。现在我们已经广泛了解了这个工具能做什么,让我们将其添加到我们的管道中(见下面的代码列表)。

列表 5.8 pipeline.yml

jobs:
  test:
    needs:
      - format-check
      - lint                                      ❶
    name: Test Application
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Lint
        uses: golangci/golangci-lint-action@v2    ❷

❶ 添加一个代码检查作为依赖项

❷ 将代码检查添加到管道中

创建一个名为 task/add-static-check 的分支,提交你的代码,并创建一个拉取请求。你看到了什么?它应该出错!为什么?好吧,看起来我们在一个函数上缺少错误检查。如果我们早点看到这个,我们就可以节省一些时间。让我们通过添加以下列表中的代码到我们的 Makefile 中来解决这个问题。

列表 5.9 pipeline.yml

setup: install-go init-go install-lint
...
install-lint:           ❶
  sudo curl -sSfL \
 https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh\
 | sh -s -- -b $$(go env GOPATH)/bin v1.41.1

static-check:
  golangci-lint run

❶ 从本地获取代码检查器的内容

太好了,现在我们可以在本地运行 make lint 并得到相同的错误。通过添加以下列表中的代码来修复这一行。

列表 5.10 Makefile

func TestTranslateAPI(t *testing.T) {
...

  for _, test := range tt {
    ...
    _ = json.Unmarshal(rr.Body.Bytes(), &resp)    ❶
  }
}

❶ 即使我们现在没有使用它,这也需要捕获错误信息。

提交你的更改并推送。现在一切应该都是绿色的,你可以合并。这个简单的步骤将帮助你避免在开发过程中遇到几个错误和问题。像缺少错误检查这样的情况可能会隐藏在系统运行期间发生的潜在问题。无效的赋值是另一个常见问题,其中变量被设置但从未使用,这可能导致错误。这些工具会增加轻微的开销,但长远来看会节省你时间。作为团队,花时间评估并按需使用它们,并让它们随着团队的发展而发展。

5.5 代码文档

编写代码应该像讲故事一样。你从一个想法开始,然后定义结构。大多数开发者不会先写注释就开始编写代码。他们很可能会只写一个函数一次,并希望永远不需要回到它。更常见的情况是,有人可能因为某种原因需要使用这个函数或包,开发者的工作是讲述这个函数的功能故事,这样其他人就不需要深入研究代码来弄清楚它。这归结于确保故事标题清晰,描述准确。

因为这是我第一次尝试写作,我可以说我发现为函数注释想出内容更具挑战性。你需要首先想出一个对人们有帮助的好名字的函数。这需要是某种有意义的名字,比如 TranslateTranslateFile 而不是 TTFile。或者,你不需要用像 TranslatesFileWithCaseInsensitiveAndUnixBasedHomeDir 这样的名字讲故事。相反,你会在注释中说明人们应该如何期望函数的工作方式。我们一直避免编写包级和函数级注释。

Go 内置了托管文档服务器的功能,其中库会解析你的源代码,寻找位于包声明和函数之上的注释以创建文档。然而,包含目录中的任何内容都会被跳过,因为它不能用于依赖项。

由于我们没有文档,我们应该解决这个问题。然而,我们还想确保在完成这个练习后,没有人会像我们一样不注释他们的代码。这被称为 侦察哲学,即你“让它比找到它时更好。”为了做到这一点,让我们添加一个新的 静态代码分析 工具来检查注释。我们将使用一个要求所有导出函数和包都有注释的检查器。此外,我们将添加拼写检查并确保所有注释都以句号结束。打开 .golangci-lint.yml,并添加以下列表中的代码。

列表 5.11 \golangci-lint.yml

linters:
  enable:
    - gosec
    - godot                       ❶
    - misspell
    - stylecheck

linters-settings:                 ❷
  stylecheck:
    # Select the Go version to target. The default is '1.13'.
    go: "1.18"
    checks: ["all","ST1*"]

issues:
  exclude-use-default: false      ❸

output:
  format: colored-line-number

  # print lines of code with issue, default is true
  print-issued-lines: false

  # print linter name in the end of issue text, default is true
  print-linter-name: true

❶ 添加代码检查器以检查注释和风格

❷ 在我们想要捕获风格问题的代码检查器设置

❸ 一些这些代码检查错误被 GolangCI-Lint 隐藏了,所以我们要禁用这个功能。

风格检查将确保我们的注释是必需的,并且我们遵循一些其他注释写作标准。godotmisspell 将确保我们的字符串和注释标点正确且拼写无误。一旦添加了此文件,运行 make static-check 并查看结果。通过添加你认为合适的注释来修复代码。推送你的更改并合并。记住,注释应该解释函数做什么,而不是它是如何做的。例如,Translate 函数的注释可以是:“Translate 函数将接受一个单词和语言,如果可用则返回翻译;否则,它将返回一个空字符串。”

5.6 Git 钩子

我们已经保护了主分支,增加了质量检查,然后在主分支上添加了各种要求。现在我们需要专注于本地化我们的开发环境中的更改。一般来说,本地化您的管道中的更改非常重要,这样您的开发者就可以轻松地在本地重现它们。如果您发现验证本地更改成为问题,您应该考虑更改您的管道。本地化函数允许开发者在将代码推送到拉取请求之前确保其代码能够正常工作。想象一下在创建拉取请求之前尝试猜测您的代码是否会通过。这会在过程中造成延误和问题。就大部分而言,我们已经自动化了我们管道所做的大部分功能,但我们未能确保开发者在本地使用它们。

政策只能带你走这么远。通常,开发者会认真编写一些代码,推送它,创建一个拉取请求,然后继续前进,几个小时后发现拉取请求因为这样或那样的原因失败了。同样,当看到一个新的拉取请求时,开发者会蜂拥而至进行审查和批准,却发现有错误阻止它合并。这浪费了开发者、审阅者和 CI 管道的时间。尽可能地将这些检查移到源代码附近将有助于推进这个过程。

Git 有一个美丽的特性叫做 hooks,它可以与 Git 支持的各种功能相结合。当执行特定功能时,hook 会运行,要么在之前,要么在之后。在我们的情况下,我们想要创建一个 pre-commit hook,它将在我们提交更改之前运行。这个 hook 将验证我们的代码是否格式正确,以及静态代码分析是否按预期运行。我们需要确保这些功能对每个人都是可用的,因此它们应该成为我们设置的一部分。首先,我们必须创建脚本,它只是一个简单的 shell 脚本。创建一个名为 scripts/hooks/pre-commit 的文件,并添加以下列表中的代码。

列表 5.12 pre-commit

#!/bin/sh

STAGED_GO_FILES=$(git diff --cached --name-only -- '*.go')     ❶
if [[ $STAGED_GO_FILES == "" ]]; then                          ❷
    echo "no go files updated"
else
    for file in $STAGED_GO_FILES; do
        go fmt $file                                           ❸
        git add $file
    done
fi

golang-ci run                                                  ❹

❶ 获取所有 Go 文件

❷ 如果没有,打印消息。

❸ 对所有文件运行格式化并将文件添加到提交中

❹ 运行 lint 检查

现在我们将创建一个脚本,将其作为初始化的一部分添加到我们的 .git/hooks 目录中(见以下列表)。一旦在那里,它将在任何分支推送之前运行,所以请注意错误信息!

列表 5.13 Makefile

setup: install-go init-go copy-hooks
...
copy-hooks:
  chmod +x scripts/hooks/*       ❶
  cp -r scripts/hooks .git/.

❶ 创建脚本并复制文件

现在应该在本地环境中捕捉到问题,因为团队试图使用这些钩子来确保基本任务正在完成,但要注意这些函数的时间成本。请注意,我们没有将测试阶段添加到预推送中。这是因为测试的运行时间比静态检查要长。尽量找到一个合适的平衡点。确保事情不会花费太多时间并打断开发流程。你和你的团队可能会发现这些钩子比有帮助还要侵扰,在这种情况下,你应该与你的团队讨论它们的用法和功能。

5.7 流动

心理学家 Mihaly Csikszentmihalyi 研究了人们的工作方式以及它与幸福和创造力的关系。他相信,如果人们能够建立一种流动感或对当前任务的专注和沉浸感,他们会享受他们所做的事情。这也被称为“进入状态”。如果你玩运动或乐器,我敢肯定你曾经进入过一种流动状态。你可以看到下一个动作,预测下一个音符,或者沉浸在你正在做的事情中。

Csikszentmihalyi 发现这种模式也可以在工作上反映出来,只要它能以某种方式吸引工人。开发者会在编写代码时找到这种流动。一些最具创新性和效率的代码可以来自一个投入的开发团队。然而,可能会引起问题的,是流动的中断,例如:

  • 会议超载

  • 破坏构建和测试

  • 本地开发问题

  • 海森堡虫,不一致可复现的虫子

  • 同事突然进来交谈或提问

这为什么重要?因为团队成员的流动很重要。使事情变得更容易将有助于为他们创造更好的流动。我们想要关注的是从想法到实施的过程。在这个阶段,我们遇到的干扰越少,越好。这种流动来自做这项工作的人,任何打断流动的事情都会打断他们成功的能力。

另一方面,宽松的质量控制可能导致中断,形式为错误和错误。没有适用于团队的最佳方案的公式。如果你是一个高级开发者的团队,你可能需要较少的约束。如果你是一个经验较少的开发者团队,你可能发现你需要更多。这里的关键是沟通。经常互相交谈。改进和增强。

总的来说,这一章是关于帮助你的团队一起工作。我意识到这是一个具有挑战性和独特性的领域,因为每个团队都会有所不同,所以你会注意到我一次又一次地重复了类似的句子:做对你团队最有利的事情。我们已经建立了一个管道,并且正在对其进行扩展。每次增加都会带来更多的复杂性,但这种复杂性往往可以帮助团队随着成长和演变。这些步骤和大多数保护措施都将有助于你的团队长期发展。这可能是通过捕捉错误或错误,但在某些情况下,它将使人们保持警惕。

父母通常使用计时器作为一种沟通方式,表明是时候离开了,而不是告诉孩子们该走了。这样做的好处是,父母和孩子已经同意,计时器是决定何时离开的东西,而不是父母看似随意的宣布。计时器是沟通工具。同样,团队成员会更乐意响应一个程序告诉他们评论代码,而不是团队成员,因为双方都同意使用一个单一的工具来约束他们。

“嘿,你有几分钟时间吗?”你惊讶地抬起头,看到你的项目经理和一个你从未见过的人。他说:“我想介绍你认识一下 Yvonne,她是一名实习生,将帮助你满足周五的演示。她没有很多经验,但我相信你能够很快帮助她熟悉工作。”任何帮助都是受欢迎的,尤其是在你已经标准化了你的工作之后。

摘要

  • 代码质量检查可以减少错误并标准化工作。

  • 格式化可以标准化工作空间,并使新成员更容易融入。

  • 静态代码分析将检查你的代码中已知的不良模式,并要求你修复它们。

  • 将质量检查移至管道前端可以减少等待时间,并在部署前捕获错误。

  • 不断审查和改进你的开发流程,以确保开发者的工作流程顺畅。

6 测试框架、模拟和依赖

本章涵盖

  • 通过使用接口将代码从外部依赖中隔离

  • 将依赖注入到服务中以创建可组合的代码部分

  • 构建测试套件以减少测试的样板设置和拆卸

  • 模拟和模拟依赖以创建可靠且隔离的测试

  • 模拟对外部服务的调用以测试客户端服务的逻辑

“所以你想坐在我旁边告诉我该写什么代码?那不会浪费你的时间吗?”你刚刚给了实习生访问仓库的权限,并坐在她的桌子旁边。你解释说她应该下载仓库并遵循 README 中的说明,你将坐在那里回答任何问题。在记下一些关于如何扩展文档的笔记后,你继续编码。实习生将向系统中添加一些新功能,并编写代码,而你将解释应该写什么。

这种“结对编程”的过程是一种很好的技术,可以让人加入并解释代码库。它也是一种很好的团队建设活动。一个人可以编写测试,另一个人实现解决方案。在这种情况下,你将编写测试并解释它做什么,然后实习生将负责在你在场的情况下实现它,你将指导她并提供指导和反馈。不过,在这样做之前,你需要定义你将要构建的内容,所以你打开一个编辑器,并在你的translate.go处理器中编写以下列表中的代码。

列表 6.1 translate.go

type Translator interface {
    Translate(word string, language string) string     ❶
}

❶ 将我们的方法提取到接口中使我们能够在实现上具有灵活性。

“你想要我在新服务中实现这个接口?为什么?现在一切似乎都运行得很好。接口能做什么来帮助?”你微笑着;教人总是很有趣。

6.1 依赖反转原则

“依赖抽象,而非具体。”这是依赖反转原则。这是一个在软件开发中发现的原理。这是一个相当简单的概念,有助于开发者创建干净和专注的代码。我们不是直接使用实现过的类或函数,而是依赖于抽象。

我能解释这个概念的最佳方式是想象一个没有电源插头的场景。如果你必须直接将你的灯或电视连接到家里的电力系统,那会多么困难?我相信在你重新布置家具之前,你一定会三思。相反,我们创造了插头和插座。我们不在乎家里的电线、断路器(除非有人拉闸)、通往房子的线路、变压器,或者电是从哪里来的。它对我们来说只是简单地工作。如果你的灯明天坏了,它可以被更换,你不需要担心安装一个新的插座。插头应该刚好合适。

这就是所谓的 接口,一种抽象,它允许某人轻松地使用背后更复杂的东西。我们在第四章讨论了抽象,以及它们如何在软件开发中有所帮助。开发者想要帮助抽象他们的一些代码供他人使用,这并不奇怪。同样,他们希望能够在不引起重大重构或问题的前提下改进和更改事物。这就是接口发挥作用的地方。在软件开发中,接口定义了给定结构体或类的函数。一旦结构体拥有了所有请求的函数,它就满足了接口,可以在满足接口的另一个服务的地方使用。就像灯具一样,我们可以更换帮助我们成长和改进的代码片段。

一些开发者会放弃创建接口,而是创建紧密耦合和交织的代码片段。这使得我们难以有效地测试,并在未来增强我们的代码。我们需要模块化我们的代码,以便我们可以更好地测试它,这可以通过使用接口来实现。

6.2 定义接口

接口也被称为 协议,它有助于定义系统之间的边界,并提供在这些边界之间通信的方式。协议和接口创建了一个定义,说明如何通过既定的结构或模式进行通信。就像电源插座上不同的插孔面可以告诉我们另一侧是什么类型的电力一样,接口将通过定义方法定义(如图 6.1 所示)告诉我们的用户如何使用服务。

图片

图 6.1 设计了各种接口来抽象电力的输送。

这允许我们的开发者面向接口定义编写代码,而不是具体的实现,这样我们就可以做出不会影响整个系统的更改。在我们开发过程中,这一点很重要,因为我们可能会发现我们需要缓慢地替换代码的一部分,同时仍然支持向后兼容性或测试尚未准备好广泛消费的功能。在本章的后面部分,我们将这样做,但让我们先从以下列表中的简单示例开始。

列表 6.2 example.go

package main

import "fmt"

type Greeter interface {                     ❶
    Greet() string
}

type spanishGreeter struct {}

func (g *spanishGreeter) Greet() string {    ❷
    return "hola"
}

type englishGreeter struct {}

func (g *englishGreeter) Greet() string {    ❸
    return "hello"
}

func printGreeting(greeter Greeter) {        ❹
    fmt.Println(greeter.Greet())
}

func main() {
    printGreeting(&spanishGreeter{})
    printGreeting(&englishGreeter{})
}

❶ 接口定义了一个结构体需要拥有的方法以满足接口。

spanishGreeter 结构体添加了该方法,因此满足了接口。

❸ 同样,englishGreeter 也满足了接口。

❹ 然后,可以编写一个泛型方法,该方法使用接口作为输入。

我们创建了两个满足相同接口的结构体。这允许我们将函数的参数抽象化,以接受接口并在两个结构体之间进行切换。这可以在图 6.2 中看到,我们有一个使用接口来隐藏底层实现的服务。虽然这是一个简单的例子,但我们将看到它在未来定制我们的应用程序时将变得更加强大。我们现在可以看到,我们可以实现任何数量的结构体来满足这个接口,而无需更改调用者。尽管我从未遇到过这种情况,但想象一下使用接口作为后端数据存储。有了接口,你可以使用 Postgres、RedisDB、MongoDB 或任何数量的技术来满足接口,而无需更改你的代码。这种情况很少发生,但可以防止你锁定到特定的实现。

图片

图 6.2 接口允许我们在不改变消费服务逻辑的情况下,在满足接口的不同服务之间进行切换。

这有什么关系呢?通过使用接口,我们还可以简化我们测试代码的方式。让我们以我们的处理器函数为例。目前,它依赖于服务结构体进行翻译。如果我们改变服务的底层实现,我们希望这不会影响我们的处理器。但当我们思考这个问题时,我们的处理器应该独立于底层服务工作。处理器所关心的只是输出翻译,没有其他,因此我们希望在处理器内添加一个抽象层,使其更容易测试(见以下代码列表)。

列表 6.3 translate.go

type Translator interface {
    Translate(word string, language string) string     ❶
}

❶ 将我们的方法提取到接口中使我们能够在实现上具有灵活性。

你可能会想知道为什么我们要在这里定义这个接口而不是其他地方。处理器是这个接口的消费者,因此根据我们之前讨论的依赖倒置原则,它定义了它需要的功能。我们定义我们想要的接口,然后创建满足这个接口类型的实现。Go 使用一种称为 鸭子类型 的方法来帮助将接口映射到其实现。鸭子类型 是一种类型系统,其中对象需要满足某些行为。它来自一个 鸭子测试:“如果它像鸭子走路,像鸭子嘎嘎叫,那么它一定是一只鸭子。”处理器可以通过使用接口来定义它对结构的期望,并忽略底层实现。这意味着我们的实现可以来自任何地方,一个单一的实现可以满足许多接口。作为开发者,我们希望将我们的接口分成小块,称为 接口隔离,以帮助它们更易于组合和重用。Go 的标准库有一个很好的例子(见以下列表)。

列表 6.4 io.go

type Reader interface {                   ❶
    Read(p []byte) (n int, err error)
}

type Writer interface {                   ❷
    Write(p []byte) (n int, err error)
}

type ReadWriter interface {               ❸
    Reader
    Writer
}

❶ 读取接口只有一个方法需要满足 . . .

❷ . . . 以及写接口。

❸ 从这些接口中创建一个复合接口。

一个服务可能只想实现 ReaderWriter 或两者兼具。你可以将接口视为乐高积木,这样你可以组装你需要的东西,而无需更多。这很好,但我们如何使用这种可组合性呢?我们创建一个满足接口的结构体,并将其注入到一个消费结构体中。这被称为 依赖注入

6.3 依赖注入

我一直想象依赖注入就像在不同的车上安装不同的引擎。许多车身使用相同的引擎,许多车身支持不同的引擎。例如,2022 款丰田凯美瑞允许你选择安装四缸、六缸或四缸混合动力引擎,所有这些都在同一类型的汽车上,就像混合动力引擎也可以用于其他车辆一样。这是公司以简化设计的同时,为用户提供他们所需多样性的流线型方式。

备注:我们将手动连接这些依赖项,但有一些工具可以为你完成这项工作。这些包括 Wire、Fx 和 Kit。

同样,我们可以构建我们的代码以使用不同的服务和元素,同时不影响我们的当前实现。代码会变化,想法会变化,功能会添加。重要的是,这些变化不应该需要整个系统的重新架构或重建。相反,我们应该能够以抽象术语定义服务应该如何工作,然后通过具体的实现来满足它们,这样它们就不是 紧密耦合 的,即一个服务的更改需要另一个服务的更改。相反,一个给定服务的更改不应该影响另一个服务的底层功能。在前一节中,我们讨论了编写接口的优点。你可以将你的接口视为一种标准化服务如何工作的方式。有了这个标准,你就可以根据需要做出更改。你可以添加更大的引擎、较小的引擎或混合引擎,而无需进行大规模的改造。让我们看看这是如何工作的。我们已经为翻译编写了接口,现在我们需要让处理器使用它,并让服务满足它。打开你的 translate.go 文件,并添加以下列表中的代码。

列表 6.5 translate.go

// TranslateHandler will translate calls for caller.
type TranslateHandler struct {
    service Translator                                               ❶
}

// NewTranslateHandler will create a new instance of the handler using a
// translation service.
func NewTranslateHandler(service Translator) *TranslateHandler{      ❷
    return &TranslateHandler{
        service: service,
    }
}

...

// TranslateHandler will take a given request with a path value of the
// word to be translated and a query parameter of the
// language to translate to.
func (t *TranslateHandler) \
    TranslateHandler(w http.ResponseWriter, r *http.Request) {       ❸
...
    translation := t.service.Translate(word, language)               ❹
...
}

❶ 我们的处理程序现在成为一个依赖于接口以满足翻译的 struct。

❷ 我们创建了一个便利方法来创建实例,这样你就不会错过任何依赖项。

❸ 将方法更改为附加到我们的结构体

❹ 用接口替换我们的翻译方法

我们创建了一个包含我们的接口并允许我们在处理器函数中调用它的结构体。现在让我们更新我们的服务以满足接口,这样我们仍然可以使用它,并让我们的系统重新构建。打开 translator.go 文件,并添加以下列表中的代码。

列表 6.6 translate.go

// StaticService has data that does not change.
type StaticService struct{}                                                ❶

// NewStaticService creates new instance of a service that uses static data.
func NewStaticService() *StaticService {                                   ❷
    return &StaticService{}
}

// Translate a given word to a the passed in language.
func (s *StaticService) Translate(word string, language string) string {   ❸
...
}

❶ 创建一个新的 struct 来附加我们现有的函数

❷ 创建一个实例化此结构体的方法

❸ 将函数附加到结构体并满足处理程序所需的接口

接下来是依赖注入。正如其名所示,我们将依赖的服务注入到处理程序结构体中。打开main.go,并添加以下列表中的代码。

列表 6.7 main.go

func main() {
...
    mux := http.NewServeMux()

    translationService := translation.NewStaticService()                    ❶
    translateHandler := rest.NewTranslateHandler(translationService)        ❷
    mux.HandleFunc("/translate/hello", translateHandler.TranslateHandler)   ❸
...
}

❶ 创建新的静态服务

❷ 创建一个新的处理程序,将服务作为依赖项

❸ 在路由器上注册函数

如你所见,我们创建了所需的服务并将其传递给处理程序,处理程序注册了翻译函数调用。我们现在可以控制处理程序可以使用哪种服务。我们将利用这一点,随着我们扩展服务。我们的 FaaS 现在也会因为我们对处理程序的更改而失败。看看你是否能自己修复它。

有趣的是,在这次练习中,你可以看到当你的服务变得紧密耦合时,进行小改动是多么困难。我们可以看到,我们对处理程序和服务的更改破坏了我们的代码的几个部分,包括我们的测试。目前,我们的代码库并不大,想象一下在一个更大的项目中会发生什么!

现在我们需要修复我们的测试。让我们先做最少的修复,然后我们将改进我们的测试以利用我们所做的更改。打开translate _test.go,并添加以下列表中的代码。

列表 6.8 translate_test.go

func TestTranslateAPI(t *testing.T) {

...
    underTest := rest.NewTranslateHandler(translation.NewStaticService())  ❶
    handler := http.HandlerFunc(underTest.TranslateHandler)                ❷
...
}

❶ 更新测试以创建新的处理程序

❷ 为测试注册处理程序

translator_test.go文件中实现更改(见以下列表)。

列表 6.9 translate_test.go

func TestTranslate(t *testing.T) {
...

    underTest := translation.NewStaticService()                   ❶
    for _, test := range tt {
        // Act
        res := underTest.Translate(test.Word, test.Language)      ❷
...
    }
}

❶ 创建一个新的静态服务进行测试

❷ 使用此方法获取不同测试用例的结果

你会注意到我在这两个文件中都使用了一个名为underTest的变量。这是一个很好的模式,因为它允许你明确地看到你在测试什么。现在我们应该能够看到所有测试都能无问题地运行。提交你的更改并推送你的分支。

为什么我们要费这么大的力气重构,却不想在功能上有所改变?

第一个原因是强调在开发早期创建接口的重要性,而不是在之后。你可以看到,在事后而不是在规划之前进行这些更改的困难和痛苦。我这样做是为了一个教训。我记得作为一个初级开发者,我被分配了一个为系统中所有服务创建接口的任务。这是乏味的。这是痛苦的。最糟糕的是,接口是草率的。

滥用接口是那些做得太多并且有太多参数的接口。它们太宽泛,难以推理。它们具有低内聚偶然内聚,因为它们是任意分组,并且很少考虑其功能。或者,如果我们一开始就考虑我们的服务,我们可以构建具有高内聚功能内聚的东西,并将它们分组在定义良好的任务集中。功能程序员经常吹嘘他们语言的优越性,因为大多数函数最终都会落入小型、高度内聚的函数中。但任何语言如果给予足够的时间和思考,也可以做到这一点。参见表 6.1。

表 6.1 使用内聚性来定义结构或类定义得有多好。

高内聚 明确的目的和方法定义
低内聚 广泛的责任,通常都在一个类或结构体中

这个例子中一个简单的例子就是名为 Validation 的类与名为 UserRegistrationValidation 的类之间的对比。在前一种情况下,你有一个包含系统中所有类型验证功能的类的集合,而后者则提供了一个针对业务流程中特定步骤的更专注的验证。让我们经历这一切的第二个原因是,我们可以解耦我们的测试,使它们更加原子化,或者说是独立的,这从长远来看,将给我们带来稳定性和加快我们的开发速度。让我们看看这将是什么样子。

6.4 测试存根

通过依赖注入,我们对自己之前没有控制权的服务有了更多的控制。在第三章中,我们介绍了黑盒测试的概念:我们看不到我们正在测试的方法或结构内部,必须从外部进行测试。随着我们的应用程序变得更加复杂,我们正在为其编写测试的服务可能变得更加难以推理。依赖注入允许我们约束和隔离我们试图测试的底层代码的各个部分。这在实验中被称为科学控制,以帮助最小化独立变量或你试图测试的东西的影响。在我们当前的处理程序实现中,我们无法控制底层翻译服务的工作方式,因此我们无法控制我们的测试。

这里有一个例子。目前,在我们的处理程序测试中,我们期望 /translate/hello?language=dutch 返回一个 404 消息。如果我们在我们底层服务中实现荷兰语翻译,我们的测试将会失败!这意味着我们的处理程序测试与底层服务耦合在一起,而这并不是我们想要测试的。相反,我们想要了解什么会触发处理程序本身预期的响应。如果结果返回为有效,我们想要返回一个 200,这是成功的 HTTP 代码,以及相应的值。如果未找到,将返回 404 错误代码。

但现在我们能够注入自己的依赖,我们可以创建专门用于测试的自定义服务。这被称为存根。存根是任何结构(服务、仓库、实用工具)的非常简单的实现,可以在测试以及开发中的系统中使用。存根通常缺乏复杂的逻辑,并返回硬编码的值。这使我们能够根据底层存根服务的已知期望来测试服务。

注意:存根在代码开发期间可以作为很好的占位符。在第二章中,我们讨论了将工作分配给团队成员以获取尽可能小的交付代码。这可以通过将你的代码视为层,并在前进过程中存根底层依赖来实现。在这个例子中,你会存根将代码传递给处理程序的服务,并仅关注处理程序。一旦处理程序交付,消费应用程序可以更早地开始通常痛苦的集成过程,同时你继续构建业务逻辑。

要查看这个功能是如何工作的,让我们更新我们的测试代码,如下所示。

列表 6.10 translate_test.go

type stubbedService struct{}                                               ❶

func (s *stubbedService) Translate(word string, language string) string {  ❷
    if word == "foo" {                                                     ❸
        return "bar"
    }
    return ""
}

func TestTranslateAPI(t *testing.T) {

    tt := []struct {
        Endpoint            string
        StatusCode          int
        ExpectedLanguage    string
        ExpectedTranslation string
    }{
        {
            Endpoint:            "/translate/foo",
            StatusCode:          200,
            ExpectedLanguage:    "english",
            ExpectedTranslation: "bar",
        },
        {
            Endpoint:            "/translate/foo?language=german",
            StatusCode:          200,
            ExpectedLanguage:    "german",
            ExpectedTranslation: "bar",
        },
        {
            Endpoint:            "/translate/baz",
            StatusCode:          404,
            ExpectedLanguage:    "",
            ExpectedTranslation: "",
        },
    }

    h := rest.NewTranslateHandler(&stubbedService{})                       ❹
    handler := http.HandlerFunc(h.TranslateHandler)

    ...
}

❶ 创建一个空的 struct 来满足你的接口

❷ 满足处理程序期望的接口

❸ 在接口内创建一个简单的方法来测试

❹ 注入存根服务进行测试

你会注意到这里有一些不同。主要的是,我们将测试更改为关注从我们的服务返回的结果,而不是试图将逻辑推送到服务中。我们想要测试的是:

  • 如果没有传递语言,则默认语言为英语

  • 如果传递了语言,则返回该语言

  • 如果传递了未翻译的单词,我们期望返回 404 和空值

我个人喜欢清楚地让开发者知道这些是测试值,因此使用了foobarbaz。这有助于人们意识到我们正在处理的是假数据而不是真实数据,并使他们的注意力集中在实际逻辑上。这将运行良好,但它缺乏一些精确度。具体来说,我们缺少传递给服务本身的实际值。目前,我们的代码中有一个故意留下的错误。在我们的测试章节中,我们讨论了构建健壮的服务,这些服务可以处理输入的标准化。虽然我们在服务上构建了支持这一点的工具,但我们没有在处理程序上注意到这一点。我们可以像以下列表中那样进行调用。

列表 6.11 translate_test.go

func TestTranslateAPI(t *testing.T) {

    tt := []struct {
        Endpoint            string
        StatusCode          int
        ExpectedLanguage    string
        ExpectedTranslation string
    }{
        ...
        {
            Endpoint:            "/translate/foo?language=GerMan",    ❶
            StatusCode:          200,
            ExpectedLanguage:    "german",                            ❷
            ExpectedTranslation: "bar",
        },
    }

    ....
}

❶ 此处的输入显示与我们在服务上期望的不一致的字母大小写。

❷ 预期结果是应该小写的。

我们的测试将会失败。我们希望确保返回的值时小写的。这样,结果总是标准的,我们的消费者可以正确地针对它进行开发。我们不仅想要验证返回的值是小写的,还要确保我们传递给服务的是小写版本。我们该如何做到这一点?

我们可以在我们的存根中添加逻辑来进行验证,但这会变得复杂。相反,我们可以通过使用模拟来专注于使用给我们更多测试逻辑控制的东西。

在我们进行模拟之前,让我们回顾一下我们试图实现的目标。我们不是在测试我们应用程序中的依赖项;相反,我们是在测试我们应用程序的某个部分是如何与依赖项一起工作的。这种区别必须清楚,因为我们希望每个部分都是可以独立测试和验证的。因此,当你发现你的代码的一部分依赖于外部库或服务时,你应该考虑它将如何融入你的测试策略。

6.5 模拟

在棒球中,击球手通常用投球机进行热身。这个机器代替人,帮助击球手练习挥棒。在实践中,击球手可能会面对一个真人投球手,这个人不是投球手,但可以给击球手提供足够的变数,使其更加逼真,从而提高逼真度。最后,在比赛中,击球手将遇到真正的投球手,希望他们已经准备好了。

在测试中,我们希望以相同的方式锻炼我们的代码。在前一节中,我们讨论了存根,它充当服务的一个占位符,但具有预期的结果。这些存根做得不多,你可能会发现你正在你的存根中添加奇怪的逻辑代码,以便你的测试按预期工作。在你走那条路之前,你应该考虑模拟。

模拟就像一个存根,但更详细。使用模拟,你可以创建一个类似的对象,但你可以附加方法,这样你就可以断言某些方法是否被调用以及调用时使用了什么值。它可以按测试更改功能,这样你就可以测试错误处理和奇怪值。总的来说,它为你提供了更深入的了解,了解你的函数是如何工作的,以及你如何测试每个边缘情况。

为了帮助演示这一点,我们首先将在我们的系统中添加一个功能,然后用模拟来测试它。如果我们无法在我们的数据库中找到结果,我们将使用客户端调用外部服务以从旧系统中获取结果。为此,我们首先创建一个接口,这样我们就可以在准备发布功能时进行交换。让我们看看这个服务是什么样子:

touch translation/remote_translator.go

接下来,添加以下列表中的代码。

列表 6.12 remote_translator.go

package translation

var _ rest.Translator = &RemoteService{}                                  ❶

// RemoteService will allow for external calls to existing service for 
➥translations.
type RemoteService struct {
    client HelloClient                                                    ❷
}

// HelloClient will call external service.
type HelloClient interface {                                              ❸
    Translate(word, language string) (string, error)
}

// NewRemoteService creates a new implementation of RemoteService.
func NewRemoteService(client HelloClient) *RemoteService {
    return &RemoteService{client: client}
}

// Translate will take a given word and try to find the result using the client.
func (s *RemoteService) Translate(word string, language string) string {
    resp, _ := s.client.Translate(word, language)                         ❹
    return resp
}

❶ 验证我们正在构建的 struct 是否满足接口。如果不满足,这将导致编译时错误。

❷ 使用新的接口调用外部 API

❸ 为调用和翻译的客户端创建一个接口

❹ 使用客户端进行外部调用

注意,我们添加了一个名为 HelloClient 的新接口。目前,我们让服务只调用客户端并返回结果。这将是我们的测试基础。通过模拟,你会发现有很多样板代码,因此更容易将我们的测试组织在 test suites 或具有相似设置和拆卸功能的测试分组中。这意味着我们可以以各种方式建立模拟并对其进行测试,而不会出现冲突设置或奇怪的外部效应。

6.5.1 设置我们的测试套件

幸运的是,有一个名为 testify 的优秀测试工具包,它可以处理套件和模拟。这个库提供了套件、断言辅助工具和模拟,帮助我们进行测试。为了使用它,我们将导入我们的第一个外部库:

go get -u github.com/stretchr/testify

注意:GoMock 是 Testify 模拟工具的一个流行替代品。它有一个为你的接口生成特定模拟的机制。

现在让我们创建我们的测试文件并设置我们的套件:

touch translation/remote_translator_test.go

接下来,添加以下列表中的代码。

列表 6.13 remote_translator_test.go

package translation_test

import (
    "context"
    "errors"
    "testing"

    "github.com/stretchr/testify/mock"
    "github.com/stretchr/testify/suite"
    "github.com/holmes89/hello-api/translation"
)

func TestRemoteServiceTestSuite(t *testing.T) {                              ❶
    suite.Run(t, new(RemoteServiceTestSuite))
}

type RemoteServiceTestSuite struct {                                         ❷
    suite.Suite                                                              ❸
    client *MockHelloClient
    underTest *translation.RemoteService
}

func (suite *RemoteServiceTestSuite) SetupTest() {                           ❹
    suite.client = new(MockHelloClient)
    suite.underTest = translation.NewRemoteService(suite.client)
}

type MockHelloClient struct {                                                ❺
    mock.Mock                                                                ❻
}

func (m *MockHelloClient) Translate(word, language string) (string, error) { ❼
    args := m.Called(word, language)                                         ❽
    return args.String(0), args.Error(1)                                     ❾
}

❶ Go 的测试框架期望测试以单词 Test 开头,并具有 (t *testing.T) 方法。这将用于触发我们的套件。

❷ 构建一个包含我们运行测试所需的任何依赖项的套件

❸ 扩展 Suite 结构体以使用默认方法

❹ SetupTest 在每个测试之前运行。在这里,我们初始化模拟客户端和要测试的服务。

❺ 创建一个 Mock 结构体以满足接口

❻ 扩展 Mock 以使用方法来跟踪调用

❼ 满足 Mock 接口

❽ 断言值以期望的值被调用

❾ 从模拟返回值

我们将测试库的传统测试机制包装在一个扩展 Suite 结构体的结构体中。使用这种结构,testify 可以使用 SetupTest 函数在我们运行任何测试之前运行。然后我们使用 Mock 结构体来扩展我们将需要验证和操作测试的功能,以便我们可以尝试各种边缘情况。Testify 允许你使用各种设置和拆卸命令来帮助减少代码重复,在模拟的情况下,重置它们的值。

我们将在下一节中探索一些额外的设置和拆卸,但到目前为止,你可以看到我们如何组织套件,以便我们可以专注于实际的测试。

6.5.2 在测试中使用我们的模拟

我们编写了一个服务,它调用远程端点获取值并将其返回给用户。这显然不是我们长期想要的,但我们将让测试驱动这个服务的编写。首先,让我们编写我们的测试(见以下列表),然后我们将着手改进我们的服务。

列表 6.14 remote_translator_test.go

package translation_test
...

func (suite *RemoteServiceTestSuite) TestTranslate() {
    // Arrange
    suite.client.On("Translate", "foo", "bar").Return("baz", nil)   ❶

    // Act
    res := suite.underTest.Translate("foo", "bar")

    // Assert
    suite.Equal(res, "baz")                                         ❷
    suite.client.AssertExpectations(suite.T())                      ❸
}

❶ 告诉模拟期望的输入和返回值

❷ 套件具有断言库,这些库在测试中充当便利方法。在这里,我们检查值是否相等。

❸ 断言在模拟上进行了调用

现在运行您的测试,看看它们是否通过。这使我们能够更好地控制依赖关系,以验证服务是否被调用,并通过断言服务调用的值。这是强大的,因为过于频繁地,错误可能发生,因为一个服务可能期望以某种格式接收值,而调用服务可能已经忘记或遗漏了。模拟提供了一种方法,使我们能够在设置过程中验证值(见以下列表)。

列表 6.15 remote_translator_test.go

package translation_test
...

func (suite *RemoteServiceTestSuite) TestTranslate_CaseSensitive() {
    // Arrange
    suite.client.On("Translate", "foo", "bar").Return("baz", nil)    ❶

    // Act
    res := suite.underTest.Translate("Foo", "bar")                   ❷

    // Assert
    suite.Equal(res, "baz")
    suite.client.AssertExpectations(suite.T())
}

❶ 这是我们之前从模拟中得到的相同期望。我们将尝试查看我们的方法是否将预期的输入传递给了它所调用的服务。

❷ 在这里,我们更改输入,以便测试将失败。

运行这个,你应该看到失败。它表示它期望输入为 foo,而不是 Foo。在这里,模拟验证了我们之前设定的期望。现在我们需要更改我们的函数以反映这一要求(见以下列表)。

列表 6.16 remote_translator.go

package translation

import (
    "strings"
)
...
// Translate will take a given word and try to find the result using the client.
func (s *RemoteService) Translate(word string, language string) string {
    word = strings.ToLower(word)
    language = strings.ToLower(language)              ❶
    resp, _ := s.client.Translate(word, language)
    return resp
}

❶ 将输入转换为小写,以便您的测试通过

现在您的测试应该通过。我们不仅可以使用模拟来验证输入,而且它还允许我们控制输出。在这里,我们可以添加一个简单的测试,只需进行最小更改,以查看如果发生错误会发生什么(见以下列表)。

列表 6.17 remote_translator_test.go

package translation_test
...

func (suite *RemoteServiceTestSuite) TestTranslate_Error() {
    // Arrange
    suite.client.On("Translate", "foo", "bar").Return("baz", 
    ➥ errors.New("failure"))                         ❶

    // Act
    res := suite.underTest.Translate("foo", "bar")

    // Assert
    suite.Equal(res, "")                              ❷
    suite.client.AssertExpectations(suite.T())
}

❶ 现在我们返回一个错误以查看我们如何处理它。

❷ 我们不应该得到任何回答。

哎,我们没有在我们的服务中处理错误!让我们使用以下列表中的代码来修复它。

列表 6.18 remote_translator.go

package translation

import (
    "strings"
    "log"
)
...
// Translate will take a given word and try to find the result using the client.
func (s *RemoteService) Translate(word string, language string) string {
    word = strings.ToLower(word)
    language = strings.ToLower(language)
    resp, err := s.client.Translate(word, language)
    if err != nil {                                    ❶
        log.Println(err)
        return ""
    }
    return resp
}

❶ 处理错误

现在我们可以验证我们如何处理这个错误。看看我们如何能够轻松地扩展测试而不改变我们创建模拟的方式?存根默认不提供这种级别的控制,并且需要特殊的编程来处理这些类型的案例。相反,我们的模拟为我们提供了注入错误和验证输入的能力,而无需更改底层实现。

备注:为什么还要测试错误?在这个例子中,它可能看起来毫无意义,但在大多数情况下它可能是有帮助的。错误在代码中不断发生,业务规则需要与每种类型的错误相关联。在这里,我们只是记录错误并返回一个空字符串。我们的测试验证了无论发生什么,在翻译失败的情况下,我们都应该返回一个空字符串。

我们想要添加到我们服务中的最后一个功能是一个缓存。大多数时候,在调用外部或远程服务时,保存值以减少调用次数是很重要的。这使得你的服务更快,因为它不需要等待服务器的响应;它也使服务更可靠,在某些情况下,它还能为你省钱。我们希望验证,如果我们用相同的值进行调用,它只会发生一次。我们的模拟可以为我们跟踪这一点(见以下列表)。

列表 6.19 remote_translator_test.go

package translation_test
...

func (suite *RemoteServiceTestSuite) TestTranslate_Cache() {
    // Arrange
    suite.client.On("Translate", "foo", "bar").Return("baz", nil).Times(1)  ❶

    // Act
    res1 := suite.underTest.Translate("foo", "bar")
    res2 := suite.underTest.Translate("Foo", "bar")                         ❷

    // Assert
    suite.Equal(res1, "baz")
    suite.Equal(res2, "baz")
    suite.client.AssertExpectations(suite.T())
}

❶ 断言此命令只运行了一次

❷ 对缓存进行两次调用以测试缓存。注意,我们使用大写字母,这样我们知道我们的业务逻辑在查找缓存之前应该将值转换为小写。

当你运行测试时,你应该看到失败。让我们通过在下一个列表中使用内存映射来修复这个问题。

列表 6.20 remote_translator.go

package translation

import (
    "fmt"
    "log"
    "strings"

    "github.com/holmes89/hello-api/handlers/rest"
)

// RemoteService will allow for external calls to existing service 
➥ for translations.
type RemoteService struct {
    client HelloClient
    cache map[string]string                                    ❶
}

...
// NewRemoteService creates a new implementation of RemoteService.
func NewRemoteService(client HelloClient) *RemoteService {
    return &RemoteService{
        client: client,
        cache: make(map[string]string),                        ❷
    }
}

// Translate will take a given word and try to find the result using the client.
func (s *RemoteService) Translate(word string, language string) string {
    word = strings.ToLower(word)
    language = strings.ToLower(language)

    key := fmt.Sprintf("%s:%s", word, language)                ❸

    tr, ok := s.cache[key]                                     ❹
    if ok {                                                    ❺
        return tr
    }

    resp, err := s.client.Translate(word, language)            ❻
    if err != nil {
        log.Println(err)
        return ""
    }
    s.cache[key] = resp                                        ❼
    return resp
}

❶ 使用内存映射作为缓存

❷ 在初始化过程中创建映射

❸ 为你的映射创建一个键来存储翻译

❹ 检查缓存中的键

❺ 如果找到了值,则返回它

❻ 进行翻译调用

❼ 将值存储在缓存中

完美!现在运行你的测试,看看它们是否都通过了。模拟可以是一个强大的测试工具,但警告,它们可能会变得复杂,你的测试可能会变得难以跟踪。这就是作为一个团队,你需要专注于服务的兼容性或易于组装以及它们之间关系的地方。如果一个测试因为一堆模拟而变得负担过重,服务可能需要被拆分。如果一个模拟因为接口的变化而需要不断更改,你可能需要重新思考你的抽象。模拟不是测试的万能药;它们只是帮助你测试代码的一个工具,以便在隔离的环境中测试你的代码并增强你的单元测试。

6.6 模拟

最后,我们将为调用外部 API 的客户端添加一组单元测试。在这里,我们遇到了不同类型的接口测试,这次是与我们不控制的 API。像我们的其他接口测试一样,我们可以建立一个契约或定义这个 API 应该是什么样子,并使用模拟来模拟它。

警告 我们无法控制其他 API,只是我们自己的,如果其他 API 发生变化,我们可能会遇到失败。这个模拟正在模拟一个外部依赖,或者是我们无法控制的外部系统,因此应该对其进行监控并大量记录,以防出现故障。应该使用高级系统模式,如断路器,但它们超出了本书的范围。我建议阅读 Cornelia Davis 的《Cloud Native Patterns》(Manning,2019)。

一个模拟是一个具有有限功能的对象、结构或服务。到目前为止,我们已经在测试的上下文中描述了存根和模拟。我们将使用模拟这个术语来定义代表外部服务的对象。模拟提供了这一类工具,帮助我们验证代码的基本单元,在我们开始与外部集成之前。

Go 提供了创建测试服务器的功能,这使得我们的测试更容易。我们将使用一个模拟 HTTP 服务器来构建我们的模拟以测试我们创建的客户端。在我们深入了解实现细节之前,让我们设置我们的套件。首先,使用以下命令创建文件:

touch translation/client.go
touch translation/client_test.go

然后在下面的列表中添加代码。

列表 6.21 client_test.go

package translation_test

import (
    "encoding/json"
    "io"
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/mock"
    "github.com/stretchr/testify/suite"
)

func TestHelloClientSuite(t *testing.T) {
    suite.Run(t, new(HelloClientSuite))
}

type HelloClientSuite struct {
    suite.Suite
    mockServerService *MockService
    server            *httptest.Server                                     ❶
    underTest         translation.HelloClient                              ❷
}

type MockService struct {
    mock.Mock
}

func (m *MockService) Translate(word, language string) (string, error) {   ❸
    args := m.Called(word, language)
    return args.String(0), args.Error(1)
}

❶ 使用测试服务器进行测试

❷ 我们将测试这里定义的接口。

❸ 与我们之前看到的类似的模拟,帮助我们向处理器注入值

为了设置我们的客户端测试,我们需要创建一个处理器来捕获消息以测试客户端传递的内容(见以下列表)。

列表 6.22 client_test.go

func (suite *HelloClientSuite) SetupSuite() {                             ❶
    suite.mockServerService = new(MockService)
    handler := func(w http.ResponseWriter, r *http.Request) {
        b, _ := ioutil.ReadAll(r.Body)
        defer r.Body.Close()

        var m map[string]interface{}
        _ = json.Unmarshal(b, &m)

        word := m["word"].(string)
        language := m["language"].(string)

        resp, err := suite.mockServerService.Translate(word, language)    ❷
        if err != nil {
            http.Error(w, "error", 500)
        }
        if resp == "" {
            http.Error(w, "missing", 404)
        }
        w.Header().Set("Content-Type", "application/json")
        _, _ = io.WriteString(w, resp)
    }
    mux := http.NewServeMux()
    mux.HandleFunc("/", handler)
    suite.server = httptest.NewServer(mux)                                ❸
}

func (suite *HelloClientSuite) TearDownSuite() {
    suite.server.Close()                                                  ❹
}

❶ 使用 SetupSuite 是因为我们不希望为每个测试创建一个新的服务器,只需为这个测试组创建即可。

❷ 使用模拟来获取信息,然后使用正确的错误代码处理响应

❸ 启动测试服务器。

❹ 在套件结束时关闭服务器。

注意:我们在测试的上下文中设置了一个数据库。或者,您可以使用如 WireMock 之类的工具,它是语言无关的。

我们正在设置一个带有 HTTP 处理器的假服务器,该处理器使用模拟,以便我们可以测试客户端如何处理各种消息类型。具体来说,我们想看看当发生错误、结果无法找到或找到良好结果时会发生什么。实际的测试用例将在我们构建客户端后编写出来,但您可以看到我们的套件设置如何从一开始就驱动该设计。让我们使用以下列表中的代码来编写我们的客户端,然后编写我们的测试。

列表 6.23 client.go

package translation

import(
    "errors"
    "log"
    "encoding/json"
    "io/ioutil"
    "net/http"
)

var _ HelloClient = &APIClient{}                                ❶

type APIClient struct {
    endpoint string                                             ❷
}

// NewHelloClient creates instance of client with a given endpoint
func NewHelloClient(endpoint string) *APIClient {
    return &APIClient{
        endpoint: endpoint,
    }
}

// Translate will call external client for translation.
func (c *APIClient) Translate(word, language string) (string, error) {
req := map[string]interface{}{
        "word":     word,
        "language": language,
    }
    b, err := json.Marshal(req)
    if err != nil {
        return "", errors.New("unable to encode msg")
    }

    resp, err := http.Post(c.endpoint, "application/json", 
    ➥ bytes.NewBuffer(b))                                      ❸
    if err != nil {
        log.Println(err)
        return "", errors.New("call to api failed")
    }
    if resp.StatusCode == http.StatusNotFound {                ❹
        return "", nil
    }
    if resp.StatusCode == http.StatusInternalServerError {
        return "", errors.New("error in api")
    }
    b, _ = ioutil.ReadAll(resp.Body)
    defer resp.Body.Close()
    var m map[string]interface{}                               ❺
    if err := json.Unmarshal(b, &m); err != nil {
        return "", errors.New("unable to decode message")
    }
    return m["translation"].(string), nil
}

❶ 类型检查以确保它满足接口

❷ 存储传入的端点以进行调用

❸ 向服务器发起外部调用

❹ 检查状态码以正确处理响应

❺ 使用通用结构体从 JSON 中获取值

现在客户端将根据提供的端点调用服务器并处理结果,给调用服务提供翻译后的文本。如您所见,我们有大量的路径可以用于测试,其中大多数是失败案例。我们将通过几个案例,但我会让您完成其他案例。由于我们几乎已经设置了一切,我们在编写测试之前需要做的最后一件事是将我们的新客户端添加到测试套件中(见以下列表)。

列表 6.24 client_test.go

func (suite *HelloClientSuite) SetupSuite() {
    ...
    suite.underTest = translation.NewHelloClient(suite.server.URL)    ❶
}

❶ 在套件中创建客户端并将其唯一的服务器 URL 传递给它

现在我们可以开始编写一些测试了。我们可以像在其他测试中一样使用模拟来操纵假服务器的输出。首先,我们将采取快乐路径而不是两个失败案例(见以下列表)。

列表 6.25 client_test.go

func (suite *HelloClientSuite) TestCall() {
    // Arrange
    suite.mockServerService.On("Translate", "foo", "bar").Return(`{
    ➥ "translation":"baz"}`, nil)                                    ❶

    // Act
    resp, err := suite.underTest.Translate("foo", "bar")

    // Assert
    suite.NoError(err)                                                ❷
    suite.Equal(resp, "baz")
}

func (suite *HelloClientSuite) TestCall_APIError() {
    // Arrange
    suite.mockServerService.On("Translate", "foo", "bar").Return("", 
➥ errors.New("this is a test"))                                      ❸

    // Act
    resp, err := suite.underTest.Translate("foo", "bar")

    // Assert
    suite.EqualError(err, "error in api")                             ❹
    suite.Equal(resp, "")
}

func (suite *HelloClientSuite) TestCall_InvalidJSON() {
    // Arrange
    suite.mockServerService.On("Translate", "foo", "bar").Return(`invalid 
    ➥ json`, nil)                                                    ❺

    // Act
    resp, err := suite.underTest.Translate("foo", "bar")

    // Assert
    suite.EqualError(err, "unable to decode message")
    suite.Equal(resp, "")
}

❶ 使系统返回有效的 JSON

❷ 检查是否没有错误

❸ 返回错误以测试错误状态

❹ 检查返回的错误值以确保传递了正确的错误

❺ 通过发送错误内容使系统失败

您可以为不良输入和未找到响应添加测试用例吗?

6.7 金字塔的基础

在第三章中,我们讨论了单元测试如何为我们提供其余测试所需的基础。在那个章节中,我们的测试相当简单,但正如你所看到的,一旦其他函数和系统介入,它们就会变得更加复杂。可以通过创建抽象来避免这种依赖,但这些抽象需要以现实的方式进行测试。这取决于你发现这一点并探索保持你的服务尽可能简单的方法。如果你的测试代码变得复杂,它可能会让你停下来,看看你的代码是否可以简化并重构。参见表 6.2。

表 6.2 比较存根、模拟和伪造

类型 优点 缺点
存根 创建和操作简单 验证可能变得复杂。
模拟 记录交互以供后续验证 设置和拆除更复杂。
伪造 与模拟系统的交互更高保真 编写和维护复杂。

我们对存根、模拟和伪造的介绍为你提供了编写更好测试的工具。请注意,这些工具不应取代良好的设计。在编写代码时,你有时会发现你周围有大量的模拟,或者你的伪造变得过于复杂。这些都是你的煤矿中的金丝雀,它们应该会引发一些关于你实现的问题。也许你的代码需要被拆分。也许你需要重新思考你的设计。无论如何,你需要关注并思考你如何测试和编写代码。测试为你提供了一个极好的反映你正在做的事情的镜子。复杂的结构很难编写测试,因此更容易出错。如图 6.3 所示,测试金字塔的基础上的裂缝将导致你对代码的信心下降,所以请花时间思考你正在写什么,以及你如何构建你的测试和代码结构。

图片

图 6.3 我们现在已经使用这些技术覆盖了金字塔的底部。

我们发现自己正缓缓地向上爬金字塔,仍然需要添加中间和顶层到我们的系统中,以帮助我们对我们正在构建的东西建立一些信心。但这些测试将变得更加复杂,难以管理,因此将需要我们管道的不同部分。我在这本书中试图确立的主题是从简单的地方开始,向复杂发展。如果可能的话,应尽量避免复杂性,但有时作为权衡,它不可避免。你将能够独自决定何时根据功能或你团队的大小转向复杂性。你们一起,你和你的团队将找出构建、测试和运行应用程序的最佳方式。

“哇,这真不错。我没想到第一天就能贡献这么重要的事情。”你微笑着说。看到有人学到新东西并帮助他们理解你的系统是如何工作的,真的很好。再进行几轮这样的结对编程,实习生就能准备好教下一位加入的人了。到目前为止,她已经能够贡献测试代码和功能代码。现在是时候教她基础设施了。

摘要

  • 接口可以用来定义服务之间的通信,并作为抽象来隔离你的代码。

  • 满足接口的服务可以被作为依赖项传递给服务,这样你就可以注入你想要支持服务的代码。

  • Suites 允许你以统一的方式设置和拆除测试以及测试组。

  • Stubs 是轻量级结构,可以帮助你以简单的方式测试服务,而无需外部依赖。

  • Mocks 通过允许你验证调用及其内容,为你的测试增加了更多维度。

  • Fakes 可以与 Mocks 和 Stubs 结合使用,完全替代另一个服务。

7 容器化部署

本章涵盖

  • 在多台机器上构建标准化的应用程序部署方式

  • 使用 Buildpacks 构建针对托管基础设施优化的容器

  • 使用 Dockerfile 自定义部署

  • 将容器部署到托管环境中

  • 为本地开发组织容器

“听着,我明白这个项目现在正在取得一些进展,但我们需要能够尽快与之集成。旧服务是我们所做的一切的中心,所以你需要给我们提供关于如何运行你已有的内容的文档,”卡罗尔说。你刚坐下吃午饭,她就坐在了同一张桌子上。卡罗尔现在已经在移动应用团队担任团队领导多年。她管理得非常严格,不喜欢惊喜——比如你的项目经理让你负责开发。

“如果它是你在生产中使用的确切系统并且可以在本地使用,那就太好了。”这很重要。让系统在你的机器上与在生产中一样工作是非常大的。当然,你可以把你的团队指向你在仓库上发布的二进制文件,但你的二进制文件是为 Linux 编译的,而不是为 macOS 或 Windows 编译的,这是移动应用团队使用的。你需要一个更通用的东西。

你能做什么?你如何以统一的方式在任何环境中交付产品?你如何确保它运行高效且安全?答案是容器。

如果你看看高速公路上行驶的一些卡车,你会在后面看到一个大型的金属货运集装箱。这些就是在港口的码头和火车后面的集装箱。它们都是相同的 ISO 标准集装箱,符合 ISO 标准 668:2020,尺寸为 8 英尺宽,20 或 40 英尺长,8 英尺 6 英寸高。这种标准化使得物品可以通过所有这些不同的车辆轻松运输,如图 7.1 所示。

图片

图 7.1 容器船有一个平坦的表面,可以同时移动许多集装箱。

在这些容器发明之前,船只、卡车和火车有散装货物或基本上是松散捆绑、非标准容器,如桶和箱子。试图为这些物品找到空间变成了一道复杂的谜题,并且需要额外的体力劳动来将物品从一个车辆移动到另一个车辆。转向容器使得运输产品在一种使货物运输变得容易和高效的方式上实现了标准化。

如果考虑到操作系统和计算机架构的状态,今天发布软件常常会感觉像散装货物。假设我们想要编写支持 Linux、macOS 和 Windows,并且能在 32 位或 64 位的 Intel 或 ARM 架构上运行的代码。我们需要发布多少个二进制文件?如果我们考虑三个操作系统和四种架构,我们将需要构建和运行 12 个不同的二进制文件,以适应特定的机器。这变成了航运业所面临的非标准桶和箱子的相同问题,并且常常成为“在我的机器上它工作”这个梗的受害者。如前所述,我们希望我们在构建的项目和发布的产品中保持一致性。

我们如何解决这个问题?与航运业一样:使用容器。

7.1 容器是什么?

为了更好地理解容器的工作原理,我们需要简要概述操作系统的工作方式。操作系统的工作是管理物理机器内的各种资源。这包括在内存或硬盘上存储信息或决定要执行哪些程序。容器是一个封装的操作系统,它是虚拟化的,这意味着它运行在不需要直接使用物理机器的机器上。这个虚拟机与底层操作系统协同工作,以运行各种应用程序。容器运行时是与主机操作系统协同工作的虚拟机,它们共享相同的内核,即选择哪些应用程序运行的服务。这些抽象层使我们能够隐藏底层实现层,如图 7.2 所示。

图片

图 7.2 应用程序在容器内与操作系统协同工作,并与容器运行时进行交互。运行时随后作为主机操作系统中的一个进程运行,就像任何其他应用程序一样。

注意:在这本书中,我们使用了一些更受欢迎的容器工具之一:Docker。或者,你也可以使用 Podman,它正在变得越来越受欢迎。

这对你意味着什么?当我们创建一个容器时,我们创建了一个在给定运行时上运行的标准化格式。容器是由镜像或系统应呈现的快照构建的。你可以将其视为一种模板或已保存的文件,该文件在系统上加载并运行。单个镜像可以用来创建许多容器。这些镜像是为特定运行时定义和构建的,就像我们的应用程序二进制文件一样,但运行时允许我们的容器在任何实现此规范的地方运行。目前,对于大多数开发者来说,最常用的运行时是 Docker,以至于 Docker 和容器经常被互换使用(就像称呼纸巾为 Kleenex 一样),但容器规范有许多不同的实现。

你可能会想,“这对你们来说意味着什么?”嗯,正如你可以想象的那样,随着你们公司的成长,你们可能不会所有人都使用 Go。可能会引入 Python 或 JavaScript。或者你们可能有数据库和队列系统这样的依赖。所有这些组件都需要配置和设置。如果你在 Go 中工作,你可能不想为了与另一个团队的产品集成而麻烦地安装 Python。你也不想你的开发者们浪费宝贵的时间去安装或更新到 Postgres 的最新版本。相反,你可以依赖 Docker 来帮助管理、维护和运行所有这些依赖。这让你可以腾出时间专注于你的开发。

因为 Docker 是容器的通用运行时,许多开发者会在他们的机器上安装它,以帮助他们统一运行他们的应用程序和应用程序依赖。但是当你离开你的主机机器,想要将你的容器发送到托管环境时,你可能会发现还有其他方法来优化和构建你的容器,以便它们在该环境中更有效地运行。有一个笑话,“云”只是别人的机器。这个笑话,尽管如此,是真实的。它只是某个地方的其他机器,由于许多不同的云提供商在构建和实施他们的硬件和自定义运行时,有时构建针对他们运行时的优化镜像会更好。这可以通过使用他们的基础镜像或让他们帮助你构建镜像来实现,这被称为Buildpacks

7.2 什么是 Buildpack?

在第四章中,我们使用 Google Cloud 中的 PaaS 框架部署了我们的应用程序。在这两次部署中,我们都不需要关注这些应用程序是如何运行的或在哪里运行的;它们被抽象化了。然而,我们可以想象这一切是如何工作的。在所有事情的最底层是运行代码的物理机器,但是有虚拟化和抽象的层。作为开发者,你不需要担心像安全补丁和内核升级这样的问题;只需专注于你的代码。有一些隐藏的魔法决定了你运行什么代码以及如何部署它,然后它突然就工作了。

云只是别人的电脑。

Buildpacks 以非常相似的方式工作,并且与许多 PaaS 在底层的工作方式密不可分。实际上,这项技术最初是在 2011 年由 Heroku 开发的,并被 Pivotal 和 Google 等公司用于帮助他们运行他们的 PaaS。概念很简单:你提供代码,他们会构建镜像。在底层,PaaS 根据他们的平台需要的库和依赖项构建自定义镜像,以便尽可能高效地运行代码,并在他们的托管环境中将它们作为容器部署。这为你提供了弹性和实质性的正常运行时间,并且他们可以通过运行隔离的、安全的和可维护的应用程序来最大限度地利用他们的硬件。

如果您正在构建应用程序,构建包将为您提供许多功能,这些功能将使您的应用程序更加健壮,例如高级缓存、多进程、语言检测等等。最近的构建包变革性概念是云原生构建包,它允许您作为开发者利用类似于 PaaS 的生态系统来构建应用程序,同时使用容器的便携性。

构建包内部发生了什么?当触发构建包时,它将经历两个阶段:检测和构建。当触发构建时,构建包会分析您的源代码,首先确定它是否可以识别源代码并构建容器;这被称为检测阶段。在我们的情况下,它将寻找 Go 文件或一个.mod文件。如果我们正在构建一个 JavaScript 应用程序,它将寻找一个package.json文件,或者如果是一个 Java 应用程序,它将寻找一个pom.xml文件。

当进入构建阶段时,构建包将确定运行时应该是什么,库应该如何构建,依赖项的安装,以及应用程序本身的编译和运行。它是通过使用一个构建器来做到这一点的,这是一个专门用于基于前一步检测创建应用程序的镜像。镜像的构建和运行是通过一个来完成的,它结合了构建和运行环境。

Docker 只是一个容器运行时。还有许多其他不那么受欢迎的容器运行时。

所有这些都可以让不同的团队为识别和构建特定于其运行时和环境的应用程序创建一个流程。这意味着 Google、Amazon、Heroku 和 Microsoft 可以构建自己的容器运行时,这些运行时针对其硬件进行了优化,您可以通过使用他们的构建包来利用这种性能。让我们用 Google 试试。

7.3 让我们构建一个容器

首先,我们将使用构建包在本地上构建和运行我们的容器。然后我们将使用相同的过程将容器部署到生产环境中。随后,我们将使用自己的定义构建自己的容器,并将其部署。这样,您将了解如何构建和维护自己的容器化部署和本地开发。首先,我们必须安装我们的容器运行时,在这种情况下,是 Docker。

根据您的操作系统,Docker 有三种不同的安装类型,因此最好遵循最适合您的说明。说明可以在docs.docker.com/get-docker/找到。这将为我们提供容器运行时。现在我们需要创建一个容器。为此,我们将使用构建包并安装pack,这是一个由 Cloud Native Buildpacks 构建和维护的工具。可以通过遵循buildpacks.io/docs/tools/pack/中的说明来安装。

pack将帮助我们使用定义的构建包选择和构建我们的应用程序到一个容器中。为了演示这一点,让我们看看pack建议我们使用什么来构建我们的应用程序。输入pack builder suggest并查看出现的选项:

Google:              gcr.io/buildpacks/builder:v1    Ubuntu 18 base image
    with buildpacks for .NET, Go, Java, Node.js, and Python
Heroku:              heroku/buildpacks:18            Base builder for 
    Heroku-18 stack, based on ubuntu:18.04 base image
Heroku:              heroku/buildpacks:20            Base builder for 
    Heroku-20 stack, based on ubuntu:20.04 base image
Paketo Buildpacks:   paketobuildpacks/builder:base   Ubuntu bionic base image
    with buildpacks for Java, .NET Core, NodeJS, Go, Python, Ruby, NGINX and
    Procfile
Paketo Buildpacks:   paketobuildpacks/builder:full   Ubuntu bionic base image
    with buildpacks for Java, .NET Core, NodeJS, Go, Python, PHP, Ruby, 
    Apache HTTPD, NGINX and Procfile
Paketo Buildpacks:   paketobuildpacks/builder:tiny   Tiny base image (bionic 
    build image, distroless-like run image) with buildpacks for Java Native 
    Image and Go

注意,这些 pack 并不是针对特定语言的,而是为多种语言提供了一个广泛的基础。你也可能注意到,这些语言是 Google 上支持 FaaS 和 PaaS 提供的语言。这是因为在我们底层,我们的 FaaS 和 PaaS 是在使用 Buildpack 的容器中运行的。现在,让我们通过输入以下内容来构建我们的应用程序:

pack build hello-api --builder gcr.io/buildpacks/builder:v1

让我们看看它做了什么。构建器识别出我们的应用程序是一个来自我们的模块文件的 Go 项目,并寻找一个main包来运行。如果你有多个main函数,可以进行一些配置。每个 Buildpack 都将有自己的配置。要查看我们的容器是如何运行的,输入

docker run hello-api

你应该看到你的服务器正在运行。调用你的翻译端点,看看你的应用程序正在一个整洁、便携的包中运行。现在,既然你可以构建一个容器,让我们发布它,以便其他人可以使用它。

7.4 将容器构建添加到您的管道中

现在我们希望将这个容器构建作为一个工件通过我们的发布版本提供,就像我们为我们的二进制文件所做的那样。如果我们已经有了二进制文件,为什么我们还需要容器呢?记住容器是什么:一个独立于底层操作系统的应用程序通用运行时。由于我们的其他开发团队希望使用我们的应用程序,我们可以简单地与他们共享一个容器,这样他们就不需要担心依赖项、库或运行时。这样,他们不需要对 Go 有任何底层知识,甚至不需要知道如何启动我们的应用程序,而是可以像运行其他任何应用程序一样运行容器。他们甚至不需要构建我们的容器;我们可以在一个注册表中为他们提供它。

容器注册表只是一个用于存储为容器创建的镜像的存储区域。Docker 上的默认注册表是hub .docker.com,在那里你可以找到各种可用的镜像。注册表中的每个项目都可以被拉取来运行或用作其他镜像构建的基础镜像。就像乐高积木一样,镜像可以堆叠在一起来构建产品。从该镜像启动容器的称为基础。图 7.3 显示了这是如何工作的。

图 7.3 Holmes4

图 7.3 容器层

在底部,你有一个像操作系统这样的镜像。这个层可以成为另一个层的基础,比如语言层。然后你可以使用这个层来构建你的应用程序。这些层可以随着时间的推移而累积并变得复杂,但它们都存储在这个注册库中。当你运行一个容器时,你通常更愿意从注册库中拉取它,而不是自己构建它。注册库可以作为你和其他人存储和运行容器的私人场所。在本章中,我们将把我们的应用程序发布到两个存储库:一个用于公共消费,一个用于我们在 Google Cloud 中运行。由于我们现在可以使用 Buildpack 构建容器,我们可以将容器发布到注册库。

为了发布我们的容器,我们希望将其添加到我们的管道中,以便它能够持续交付。打开你的pipeline.yml文件,并使用以下列表中的代码添加一个容器构建部分。

列表 7.1 pipeline.yml

containerize-buildpack:
    name: Build Container buildpack
    runs-on: ubuntu-latest
    needs: test                                                              ❶
    steps:
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
    - name: Install Pack
      run: (curl -sSL "https://github.com/buildpacks/pack/releases/download/
      v0.21.1/pack-v0.21.1-linux.tgz" | sudo tar -C /usr/local/bin/ 
      --no-same-owner -xzv pack)                                             ❷
    - name: Build
      run: pack build gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:latest 
      --builder gcr.io/buildpacks/builder:v1                                 ❸
    - name: Set up Cloud SDK
      uses: google-github-actions/setup-gcloud@master
      with:
        project_id: ${{ secrets.GCP_PROJECT_ID }}
        service_account_key: ${{ secrets.gcp_credentials }}
        export_default_credentials: true
    - name: Configure Docker
      run: gcloud auth configure-docker --quiet                              ❹
    - name: Push Docker image to GCP
      run: docker push gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:latest ❺
    - name: Log in to the GHCR
      uses: docker/login-action@master                                       ❻
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    - name: Tag for Github                                                   ❼
      run: docker image tag gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api
      :latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
    - name: Push Docker image to GHCR                                        ❽
      run: docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

❶ 仅在源代码通过单元测试后构建我们的容器

❷ 通过 curl 安装 Pack 到我们的构建阶段

❸ 使用 pack 命令构建针对 GCP 的容器

❹ 配置 Docker 使用 GCP 作为容器注册库

❺ 将容器推送到 GCP 注册库

❻ 登录到 GitHub 容器注册库

❼ 为 GitHub 重命名镜像

❽ 将新标签推送到 GitHub 容器注册库

如果你提交并推送了你的更改,你应该会在你的工件页面上看到一个新的容器。为了测试这一点,我们可以简单地执行以下操作:

docker run ghcr.io/holmes89/hello-api:latest

现在你已经自动构建并推送了你的容器,我们需要运行它们。为此,我们将使用容器运行时。

7.5 部署到容器运行时

我们使用 Buildpack 创建了一个容器。我们已经将容器发布到了 Google 容器注册库。现在让我们部署我们的容器。使用容器运行时有什么优势?我们为什么要经历所有这些?

在这本书中,我们第一次在云中运行应用程序和在我们机器上运行应用程序的方式之间有一个转折点。我们的容器现在运行在通用运行时中。这是我们能够达到的本地运行与云中运行最接近的抽象。这是一个强大的工具,因为它解决了“它在我的机器上运行”和“我在这个框架上挣扎”的问题。这就是为什么容器在今天的开发过程中如此受欢迎的解决方案。

一个著名的(或臭名昭著的)容器编排工具是 Kubernetes,它为开发者提供了在弹性、集群环境中部署基于容器的应用程序的工具。这是一个庞大、复杂且强大的工具。Kubernetes 超出了本书的范围,但我在这里提到它,因为它是我们将使用的某些其他容器运行时的基础。这些容器位于 Google Cloud Run 和 FaaS 等产品的底层;你无法看到容器,但它确实存在。Google 使用 Kubernetes 在隔离过程中为你运行容器,但你不需要担心维护集群、编写部署和设置传入请求。相反,你遵循一个模式并部署容器,Google 会处理其余部分。

在图 7.4 中,我们可以看到我们继续朝着更少的抽象和更多对我们部署过程的控制迈进。我们现在可以定义容器,并在通用运行时中运行它。我们将使用 Google Cloud Run,但同样可以轻松地将此产品部署到 AWS ECS 或 Kubernetes 集群。

图 7.4 我们现在使用我们的容器作为我们的可交付产品。

现在,我们可以像完成迄今为止的所有其他部署一样轻松地设置容器部署。我们只需要打开我们的pipeline.yml文件,并添加以下列表中的代码。

列表 7.2 pipeline.yml

deploy-container:
    name: Deploy Container buildpack
    runs-on: ubuntu-latest
    needs: containerize-buildpack
    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
    steps:
    - name: Deploy to CaaS
      id: deploy
      uses: google-github-actions/deploy-cloudrun@main
      with:
        service: translate                                              ❶
        image: gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:latest    ❷
        credentials: ${{ secrets.gcp_credentials }}
    - id: test                                                          ❸
      run: curl "${{ steps.deploy.outputs.url }}/hello"

❶ 这是你要部署的服务名称。

❷ 你要部署的镜像的路径

❸ 验证端点是否工作

7.6 编写自己的镜像

如果你不希望 Buildpack 容器中有所有额外的内容怎么办?记住,Buildpack 的结构是为了在容器化环境中运行良好,它包含底层库、配置和服务,所有这些都有助于你的产品在它们的运行时中良好运行。但是,随着这些库的出现,也会带来一些额外的开销,在这种情况下,是空间。对于已部署的环境,这可能不是什么大问题,但如果我们想让它更小或调试信息呢?

为什么更小?对于一个开发团队来说,拥有较小的镜像或为调试目的的特殊开发镜像可能是有意义的。在两种情况下,我们都不能依赖 Buildpack 抽象来为我们完成这项工作,因此我们需要定义我们的 Dockerfile 来构建它们。

Go 的美丽之处在于它编译成二进制文件,并且在大多数情况下不需要依赖外部库来运行。这意味着你可以创建一个二进制文件并将其放置在可能的最小基础镜像上。基础镜像是我们构建容器的起点。如果你查看不同的容器定义,你会找到 Ubuntu、Debian、Windows 等镜像。这些镜像是由安装安全补丁、升级、库以及在某些情况下应用程序的团队构建和维护的。这样,你可以运行像 Postgres 这样的东西,而无需在你的机器上安装它,或者使用 Go 的基础镜像,这样你就不需要安装 Go。让我们通过创建一个 Dockerfile 来了解一下这是如何工作的。在你的目录根目录下输入touch Dockerfile。以下列表显示了结果。

列表 7.3 Dockerfile

FROM golang:1.18 AS deps               ❶

WORKDIR /hello-api                     ❷
ADD *.mod *.sum ./
RUN go mod download                    ❸

FROM deps as dev                       ❹
ADD . .                                ❺
EXPOSE 8080
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-w -X main.docker=true" \
    -o api cmd/main.go                 ❻
CMD ["/hello-api/api"]

FROM scratch as prod                   ❼

WORKDIR /
EXPOSE 8080
COPY --from=dev /hello-api/api /       ❽
CMD ["/api"]

❶ 使用 Go 最新版本的基镜像

❷ 创建一个工作目录来存储源代码

❸ 只复制模块文件并下载依赖项。将此步骤放在其自己的步骤中允许缓存和加快未来的构建。

❹ 创建一个新的构建阶段以使用缓存能力

❺ 添加剩余的源代码

❻ 使用容器优化的标志构建二进制文件。我们不会使用这个构建标志,但编译器会使用。

❼ 使用最小的基础镜像

❽ 从开发阶段复制二进制文件

然后,我们可以通过输入docker build -t hello-api:min .来构建我们的镜像,对于我们的开发镜像docker build -t hello-api:dev --target dev .。现在两个镜像都构建好了,让我们比较一下它们的大小!我们可以简单地输入docker images,你会看到你的三个镜像定义、它们的标签、它们的大小以及它们构建的时间:

hello-api               dev            78b80879b282   4 minutes ago   962MB
hello-api               min            64d767be4d62   4 minutes ago   4.74MB
hello-api               latest         a6052d265459   41 years ago    129MB

哇!我们的开发镜像显然是最大的,但我们的最小镜像只有 Buildpack 镜像的 3%大!为什么是这样?我们之前讨论了容器是如何工作的。每个镜像都是基于另一个镜像的。每次构建一个新的容器时,都会在你的镜像中添加一个层。当你作为构建的一部分拉取镜像时,你可以看到这一点。在最底层是所有镜像都从中来的非常基础镜像,如图 7.5 所示。它被称为scratch,是完全空的,所以你的应用程序需要是自包含的——就像我们的 Go 二进制文件一样!这意味着我们可以将我们的二进制文件复制到scratch镜像中,并且它将与运行时交互来运行,就像任何其他容器一样,但没有冗余。这样,你不必担心过时的库或安全补丁。然而,你不能调试,因为没有命令行。这是一个权衡,但它可以非常有助于将应用程序分发到其他用户。

图 7.5 容器使用层来帮助构建镜像。层越多,你的镜像就越大,并且安全漏洞也越多。

现在,让我们将这些容器添加到我们的管道中,并使用以下列表中的代码将它们发布到我们的注册表中。

列表 7.4 pipeline.yml

containerize:
    name: Build Containers
    runs-on: ubuntu-latest
    needs: test
    steps:
    - name: Check out code into the Go module directory
      uses: actions/checkout@v2
    - name: Build Min
      run: docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:min .  ❶
    - name: Build Dev
      run: docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev 
      ➥ --target=dev .                                                     ❷
    - name: Log in to the GHCR
      uses: docker/login-action@master                                      ❸
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    - name: Push Docker min image to GHCR                                   ❹
      run: docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:min
    - name: Push Docker dev image to GHCR                                   ❺
      run: docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev

❶ 构建最小镜像并为其打标签

❷ 使用“dev”作为构建目标构建开发镜像

❸ 登录到 GitHub 容器注册表

❹ 将最小镜像推送到注册表

❺ 将开发镜像推送到注册表

当你的代码被推送到服务器后,你现在应该能看到你有三个不同的容器。当我们开始为稳定版本打标签时,这会变得非常有帮助。目前,这些可以被视为我们的最新构建版本,并用于测试可能不稳定的新功能。现在,我们正在分发容器,我们可以将它们集成到我们的开发过程中,其他人也可以这样做。

7.7 本地环境组织

你有一个容器化的服务。你正在为其他开发者使用它。但是你的开发也在依赖其他服务。容器能为你开发分发提供帮助吗?

当你开始在你开发环境中使用容器的道路时,你会发现诸如环境变量、端口号和运行时参数等问题可能会变得有些繁琐。你会发现你正在回到重启容器并忘记它的配置。或者你可能发现运行你应用程序的完整集成可能需要多个容器。这就是像 docker-compose 这样的工具可以提供帮助的地方。docker-compose 是一个用于运行和组织多个容器的工具。通过编写一个简单的 YAML 文件,你将能够在简单、通用的环境中构建和运行容器。正如本书中一直强调的那样,拥有简单易用的工具来帮助开发者是非常重要的。这个组合文件甚至可以通过组织我们的构建参数集成到我们的 CI 管道中。

首先,让我们安装 Docker Compose。如果你使用的是 Mac 或 Windows,那么你很幸运!它已经作为 Docker Desktop 安装的一部分被安装了。如果你使用的是 Linux,你需要按照以下简单的安装步骤进行:docs.docker.com/compose/install/

安装完成后,您可以通过输入 docker-compose 来找到要使用的命令列表。在这里,您将看到构建、创建和运行服务或命令的选项。Compose 需要存在一个 docker-compose.yml 文件才能工作。它将逐步检查父目录,直到找到一个合适的文件。这个 YAML 文件专门结构化,为每个服务提供一个独特的名称以供引用,包括镜像名称、参数以及许多其他选项,以帮助开发者配置他们的容器以启动。文件中使用的名称还可以作为 docker-compose 构建的内部运行网络的 DNS 条目,允许服务在需要时相互引用。我们将在后续章节中探讨这一点。现在,我们将创建一个基本的文件来创建我们的容器。在项目的根目录中创建并打开 docker-compose.yml,然后添加以下列表中的代码。

列表 7.5 docker-compose.yml

version: "3.8"
services:
  api-min:                                    ❶
    profiles: ['prod']                        ❷
    image: ghcr.io/holmes89/hello-api:min     ❸
    port: 8080:8080
    build: .
  api-dev:                                    ❹
    profiles: ['dev']                         ❺
    image: ghcr.io/holmes89/hello-api:dev     ❻
    port: 8080:8080
    build:
      context: .
      target: dev                             ❼

❶ 指定服务的名称,供内部使用

❷ 将服务标记为生产组的一部分,以进行定向部署和构建

❸ 你希望用于此服务的镜像

❹ 将开发服务目标分离,用于调试目的

❺ 将服务标记为开发组的一部分,以提供额外的测试功能

❻ 你希望用于此服务的镜像

❼ 如果您使用此服务构建 Docker 镜像,请指定目标

我们可以看到,我们在 docker-compose 中定义了我们的两个构建,这使得我们可以简单地输入 docker-compose build api-min 来构建我们的最小文件。试试看!此外,我们还添加了配置文件的概念,这有助于我们的应用程序增长。尝试使用 docker-compose --profile prod up 来查看最小文件启动。也可以用你的 dev 配置文件试试。

一旦我们添加了依赖项和高级测试,这将会变得很重要,但到目前为止,我们将使用它来发布。现在我们可以更新我们的管道,使其引用我们的 compose 文件,而不是直接将配置烘焙到构建命令中。这将随着我们开发工作的进行而节省我们时间和精力。

现在我们已经成功部署了一个帮助我们构建应用程序并运行的工具,我们准备扩展我们的能力。我们可以缓慢而高效地开始构建我们的应用程序及其依赖项,以便于本地开发。由于我们使用的是相同的工件,我们可以确保我们的服务在机器上与部署环境中的表现一致。我们的 compose 文件可以作为我们基础设施的松散定义,我们最终可以将其调整为满足我们的需求,就像我们的生产环境一样。这种服务之间的松散耦合使我们能够专注于我们的代码,而不是试图关注基础设施。

7.8 容器,到处都是容器

容器很受欢迎,我相信你可以看到原因。可移植性和简单性在开发世界中是颠覆性的。现在,整个操作系统可以与产品一起发货,用户端的设置最小。整个基础设施和系统都是使用容器构建的。它们作为云应用程序、构建系统和甚至机器人运行。这是软件开发的高峰,对吧?

并非如此。我们通过容器获得的收益当然对开发者和开发者体验有所帮助,但有时可能是多余的。正如我们在前面的章节中看到的,有时软件可以被编写成执行所有你需要做的简单函数。或者你可以有一个简单的应用程序,它托管在共享平台上。容器可能隐藏在这些技术的大多数表面之下,但开发者可能不需要使用它们。构建和维护容器意味着你需要负责诸如升级、安全补丁以及如何最佳构建你的应用程序等问题。这本身可能会产生复杂性。

技术中没有银弹,所以总是要警惕那些宣扬相反观点的人。在采用容器之前,考虑运行和构建容器的技术成本。产品的可移植性是最重要的方面。如果你发现自己正在与容器作斗争,你可能用错了它。记住,每件事都有权衡。在采用技术之前,你需要考虑它们。在这种情况下,由于你正在运行虚拟机,容器可能不是高性能应用程序实例的最佳解决方案。容器可以在干净的环境中运行测试,但测试不应该依赖于容器在本地运行。

但是,正如我们将在接下来的章节中看到的,容器在开发周期中也有其位置,并且可以帮助我们提高生产力。一如既往,与你的团队一起工作,找到最适合你的方法。

摘要

  • 容器提供了一个与本地操作系统的抽象层,以帮助创建适用于应用程序的通用运行时。

  • Buildpacks 专注于创建在托管平台上高效运行的容器。

  • 使用容器运行时,你可以在本地以及生产环境中使用你的容器。

第三部分. 公众化

如果你已经走到这一步,你可能会发现你的团队和客户基础已经增长到一定程度,以至于你被功能请求和更复杂的产品所淹没。这是一个好问题!这意味着你不仅为客户编写新功能,而且拥有足够大的团队来探索如何配置你的系统以实验客户的体验。

为了做到这一点,在第八章中,我们将探讨如何配置我们的应用程序在不改变其代码的情况下进行更改。我们将使用这种配置来帮助我们专注于编写基于功能请求的测试,这些测试是在整个系统上运行的,而不是在第九章中代码的模块化部分。然后,在第十章中,我们将使用容器将我们的应用程序迁移到更大的生产生态系统。第十一章是对你所学内容的总结。

8 配置管理和稳定发布

本章涵盖

  • 创建配置管理以更改应用程序功能

  • 探索配置管理的不同选项

  • 使用配置功能标志隐藏新或不完整的功能

  • 通过发布说明和版本控制来传达软件更改

“我实在看不出我们如何在测试的同时安全地推出这个版本,或者我们如何在感到舒适后轻松切换到新系统,”质量保证负责人在一次启动会议上说。“我的意思是,我们对自动化测试很满意,它已经捕捉到一些错误,但我们还不能批准将其发布到野外。”

“尽管如此,我们不能就这样放着。我们需要能够证明这次重写是值得的。我们已经证明我们可以快速做出改变并频繁发布,但我们需要一些真实流量来看到这将如何保持,而我们唯一能这样做的方式就是开始让我们的客户使用它。”此时,你的项目经理已经非常沮丧。你刚刚经历了一次整个发布计划会议,而且感觉质量保证又一次阻止了任何进展。

但当你坐下来时,你必须感觉到 QA(质量保证)有道理。我们不确定在不同负载下它会如何运行,我们也不确定它在我们整个生态系统中的运作方式。你提到了这一点,但当你的项目经理给你一个侧目时,你开始提出解决方案而不是指出问题。

你提到新系统依赖于旧系统进行翻译,而我们数据库中目前没有这些翻译。你还提到,你系统的当前存储设备只是一个内存中的键值存储——如果你想让它在生产中运行,这肯定是你需要改变的事情。

“正是我的观点:在我们甚至开始测试并在生产中签字批准之前,需要发生太多的变化,”质量保证部门的人插话道。

你耐心地纠正他们。“不,我们将继续发布,但一旦我们确定一切按预期工作,我们将慢慢过渡。同样,你将只发布一个二进制文件,但它将能够根据各种设置进行更改。”

“我们需要列出我们想要开始集成的项目,以便上线。”你的项目经理站起来,拿起一支记号笔,写下以下内容:

  • 更改 API 端口。

  • 为旧系统添加更改客户端端点的功能。

  • 关闭客户端调用。

  • 添加一个数据库用于长期存储。

“你认为你能在一周内完成吗?我们需要尽快开始,”你的项目经理说。你点头;使用配置管理,你可以构建一个系统,你可以用它来开启和关闭各种功能。

8.1 配置

所有程序都处理两件事:输入和输出。程序接收数据并输出数据。有些程序只会打印“Hello World!”(这是输出)。有些程序只会读取日志消息并将它们存储在数据库中(这是输入)。但你可以想象向应用程序提供输入以改变其功能。

以一个计数器应用程序为例。它可能看起来像以下列表中的代码。

列表 8.1 main.go

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    fmt.Println("Welcome to advanced counter. Press Enter to increment value.")
    reader := bufio.NewReader(os.Stdin)
    count := 0
    for {
        fmt.Printf("Count is: %d\n", count)
        _, _ = reader.ReadString('\n')
        count++
    }
}

这很简单,但是如果我们想让它每次增加的不是一,而是二,那么我们就需要将行 count++ 改为 count = count + 2。太棒了!但现在你想要每次增加 100。我希望你能看到我们在这里要走向何方。这既不可扩展,也无法转移到其他用例中。如果我们能只用一段代码来做这件事,而且每次都不需要重新编译,那会怎么样?我们可以在应用程序启动时提供输入,这将影响其输出。这种用于改变功能的输入被称为配置。为了配置此应用程序,我们将使用所谓的环境变量:存储在您的终端会话中的变量。在接下来的章节中,我们将探讨其他配置技术。

为了允许配置,我们将使用以下列表中的代码。

列表 8.2 main.go

package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
)

func main() {
    fmt.Println("Welcome to advanced counter. Press Enter to increment value.")
    reader := bufio.NewReader(os.Stdin)
    count := 0
    inc, err := strconv.Atoi(os.Getenv("INC"))
    if err != nil {
        fmt.Println("invalid incrementor, defaulting to 1")
        inc = 1
    }

    for {
        fmt.Printf("Count is: %d\n", count)
        _, _ = reader.ReadString('\n')
        count += inc
    }
}

如果你运行这段代码,你会看到警告信息,但如果你运行类似 INC=2 go run counter.go 这样的命令,你会看到值以二为增量增加。这看起来很简单,但非常有价值,特别是对于像

  • 数据库连接信息

  • 密码盐

  • 客户端端点

  • 日志级别

备注:我们将构建我们的配置管理工具。然而,你可以使用流行的 Viper 库以这种方式管理配置。

我们如何将配置添加到我们的 API 中?我们能否以不同的方式管理配置?

8.2 高级配置

对于我们的应用程序,我们将通过加载各种配置来调整应用程序的功能。首先,让我们确定我们想要修改的功能:

  • 自定义端口号

  • 存储类型(数据库、内存)

  • 存储连接信息(如果是数据库)

  • 外部客户端端点(如果没有,则不调用)

让我们定义一个结构体,我们可以在应用程序中传递。创建一个新的包名为 config,在其中创建一个名为 core.go 的文件。在那里,我们将定义一个结构体,如下所示。

列表 8.3 core.go

type Configuration struct {
    Port            string `json:"port"`                ❶
    DefaultLanguage string `json:"default_language"`
    LegacyEndpoint  string `json:"legacy_endpoint"`     ❷
    DatabaseType    string `json:"database_type"`       ❸
    DatabaseURL     string `json:"database_url"`        ❹
}

❶ 将端口号存储为字符串,但我们将稍后在适当格式中验证它。

❷ 这是我们的客户端调用的端点,如果它不是空字符串,则可以注入。

❸ 主动传递数据库类型以供未来增强

❹ 这与旧端点类似,如果它是空的,我们将使用内存数据库。

注意我们在结构体上使用了 JSON 文本装饰器。这是因为我们将以三种不同的方式加载我们的配置:

  • 使用环境变量

  • 使用文件

  • 使用标志

通常,这些是配置应用程序的三个最常见方法。还有其他方法,但我们主要关注这些。通过在我们的配置周围定义一个核心结构,我们可以创建一个通用函数,这将允许我们通过这些步骤来配置我们的系统。首先,我们通过环境加载我们可以加载的任何变量,然后从 JSON 文件中覆盖它们,最后依赖于标志将信息加载到我们的应用程序中。

让我们编写一个加载函数,使其以这种方式工作。首先,我们可以提出一组起始值,我们将称之为defaultConfiguration。我们的默认配置可以像以下列表那样。

列表 8.4 core.go

var defaultConfiguration = Configuration{
    Port:            ":8080",                ❶
    DefaultLanguage: "english",
}

❶ 创建一个仅包含端口号的基本结构作为默认值

给定这种默认状态,我们可以添加各种更改配置的方法。

8.2.1 环境变量

环境变量存储在系统的用户会话中。这些值可以是硬编码的,也可以通过在命令前添加变量来传递给应用程序。这是一种在系统启动前注入值到系统中的简单方法,如果系统反复调用环境变量,也可以动态使用。我们将在configuration结构体中添加一个方法来从环境中加载变量,然后使用以下列表中的代码将它们返回给调用方法。

列表 8.5 core.go

package config

import "os"
...

// LoadFromEnv will load configuration solely from the environment
func (c *Configuration) LoadFromEnv() {
    if lang := os.Getenv("DEFAULT_LANGUAGE"); lang != "" {      ❶
        c.DefaultLanguage = lang
    }
    if port := os.Getenv("PORT"); port != "" {
        c.Port = port
    }
}

❶ 内联检查语言是否在 ENV 中设置,然后更新结构体的变量。

我们正在检查两个变量,DEFAULT_LANGUAGEPORT。如果它们被设置,我们将覆盖配置设置;否则,我们将使用默认值。我们还引入了一个辅助方法,因为人们经常希望省略端口定义中的冒号,所以我们将创建一个方法来确保它存在并且是一个有效的数字(见以下列表)。

列表 8.6 core.go

package config

import "os"
...

// ParsePort will check to see if the port is in the proper format and a number
func (c *Configuration) ParsePort() {
    if c.Port[0] != ':' {                                         ❶
        c.Port = ":" + c.Port
    }
    if _, err := strconv.Atoi(string(c.Port[1:])); err != nil {   ❷
        fmt.Printf("invalid port %s", c.Port)
        c.Port = defaultConfiguration.Port                        ❸
    }
}

❶ 如果不存在,则添加一个冒号

❷ 验证字符串的值是否为整数

❸ 如果不存在,则恢复到默认端口

8.2.2 文件

虽然环境变量提供了一种加载配置的简单方法,但还有一种更便携的方法:通过使用文件。JSON 或 YAML 文件是存储和加载系统内配置的常见方式,并允许在不同环境之间实现便携性。常见的配置文件可以根据您的本地环境和生产环境进行修改,而无需更改底层代码。我们添加了一个新的标志,允许我们显式传递配置文件,并创建一个新的函数来解析文件并加载变量。让我们首先创建一个新的函数,通过以下列表中的代码使用 JSON 加载配置文件。

列表 8.7 core.go

import (
    "encoding/json"
    "errors"
    "io/ioutil"
    "log"
    "os"
    "strconv"
)

...

// LoadFromJSON will read a JSON file and update the configuration based 
➥ on the file.
func (c *Configuration) LoadFromJSON(path string) error {
    log.Printf("loading configuration from file: %s\n", path)
    b, err := ioutil.ReadFile(path)                              ❶
    if err != nil {
        log.Printf("unable to load file: %s\n", err.Error())
        return errors.New("unable to load configuration")
    }
    if err := json.Unmarshal(b, c); err != nil {                 ❷
        log.Printf("unable to parse file: %s\n", err.Error())
        return errors.New("unable to load configuration")
    }
    // Verify required fields
    if c.Port == "" {                                            ❸
        log.Printf("empty port, reverting to default")
        c.Port = defaultConfiguration.Port
    }
    if c.DefaultLanguage == "" {
        log.Printf("empty language, reverting to default")
        c.DefaultLanguage = defaultConfiguration.DefaultLanguage
    }
    return nil
}

❶ 从路径读取文件内容

❷ 将内容解析到结构体中

❸ 如果不存在,则不会覆盖现有值,但我们需要验证无效设置。

8.2.3 标志

有时候,用户可能希望更明确地注入变量。这在切换环境时很有用,因为环境变量可以使用export命令存储在会话中,例如DEFAULT_LANGUAGE=Finnish,并且不需要在命令前加上变量设置。使用flags是在运行时向服务器传递变量的常见方式。通常情况下,你在启动应用程序时在其他区域使用了标志。每次你执行./foo -h时,你都会向服务传递一个h标志,表示你需要该应用程序的帮助。我们将添加一个标志来设置端口号,这是大多数服务器提供的常见功能。

为了做到这一点,我们将创建一个LoadConfiguration函数,将所有配置部分结合起来(见以下列表)。我们将分层配置,请注意哪些变量可能会被覆盖。在我们的配置函数中,我们将按照以下操作顺序进行:

  1. 使用默认配置。

  2. 如果提供了文件,则加载文件。

  3. 使用环境变量。

  4. 使用标志。

列表 8.8 core.go

import (
    "encoding/json"
    "errors"
    "flag"
    "io/ioutil"
    "log"
    "os"
    "strconv"
)
...
//LoadConfiguration will provide cycle through flags, files, and finally 
➥ env variables to load configuration.
func LoadConfiguration() Configuration {
    cfgfileFlag := flag.String("config_file", "", "load configurations from 
    a file")                                                ❶
    portFlag := flag.String("port", "", "set port")

    flag.Parse()                                            ❷
    cfg := defaultConfiguration

    if cfgfileFlag != nil && *cfgfileFlag != "" {           ❸
        if err := cfg.LoadFromJSON(*cfgfileFlag); err != nil {
            log.Printf("unable to load configuration from json: %s, using 
            default values", *cfgfileFlag)
        }
    }

    cfg.LoadFromEnv()                                       ❹

    if portFlag != nil && *portFlag != "" {                 ❺
        cfg.Port = *portFlag
    }

    cfg.ParsePort()                                         ❻
    return cfg
}

❶ 添加标志及其描述

❷ 处理标志

❸ 检查是否传入了文件

❹ 加载环境变量

❺ 检查端口号是否已设置且不为空

❻ 解析端口号以确保其有效

我们已经构建了一个系统,允许你更改功能而不需要更改代码本身。你可以想象更大的文件,其中包含更多配置,可以用来隐藏开发中的功能或更改功能,而无需重新构建。这是一个强大的工具,可以由你的团队用来帮助构建一个健壮的产品。

8.3 隐藏功能

下次你坐在车里时,看看方向盘和仪表盘。你是否注意到任何看起来像按钮可以放置的塑料部件?这些被称为空白,用于不同类型的汽车包。这意味着同一款方向盘或控制台可以适用于所有类型的汽车,但只有特定车型会配备那些功能的按钮,如图 8.1 所示。例如,一个用于管理加热座椅的按钮。如果你购买了一款基础车型,这将是空白,但如果你购买了豪华包,它就会在那里。

图 8.1 不同车型中的塑料填充扩展槽。

这是一种功能标志的形式,你可以以相同的方式构建,但适应客户可用的功能。这种做法可以用来隐藏未付费用户(免费与付费层)的功能、仍在开发中的功能,或者你只想对少数客户进行测试的功能。

现在你已经构建了更改配置的能力,让我们更新我们的代码来使用它。在这里,我们将探索修改我们的应用程序以及修改我们的服务。在其中,你还将找到它是如何与我们依赖注入相关联并使用的。首先,我们将调整我们的端口号,然后我们将继续更新我们的客户端和存储代码。

8.3.1 更新端口

由于我们已经构建了我们的配置结构体,我们现在需要在主方法中加载它。为此,我们将简单地调用我们的 LoadConfiguration 方法。一旦我们有了配置,我们就可以开始在主二进制文件中的主函数中使用它,而不是我们的函数,以简化操作。也就是说,我们探索的所有配置更改也可以附加到我们的函数或我们正在编写的任何应用程序上。让我们看看在以下列表中更新我们的 cmd/main.go 文件中的端口编号看起来是什么样子。

列表 8.9 core.go

import (
    "log"
    "net/http"

    "github.com/holmes89/hello-api/config"
    "github.com/holmes89/hello-api/handlers"
    "github.com/holmes89/hello-api/handlers/rest"
    "github.com/holmes89/hello-api/translation"
)

func main() {

    cfg := config.LoadConfiguration()    ❶
    addr := cfg.Port                     ❷

....
    log.Printf("listening on %s\n", addr)

    log.Fatal(http.ListenAndServe(addr, mux))
}

❶ 加载我们的配置

❷ 用配置端口替换硬编码的字符串

现在让我们测试这些不同的配置更改。首先,创建一个名为 config.json 的配置 JSON 文件。它应该看起来像这样:

{
    "port": 8079
}

注意我们缺少一些字段。这是可以的,因为我们把默认值作为文件加载的一部分来处理。让我们运行一些不同的测试,看看我们的配置是如何起作用的:

go run cmd/main.go --config_file config.json
2022/03/31 14:19:44 loading configuration from file: config.json
2022/03/31 14:19:44 listening on :8079

太好了!现在让我们测试我们的环境变量(ENV var),它可以在类 Unix 系统的几种方式下设置。一种方式是使用 export 变量,然后它被存储在会话中。另一种方式是在命令之前设置变量。以下是一个示例:

PORT=8081 go run cmd/main.go --config_file config.json
2022/03/31 14:21:59 loading configuration from file: config.json
2022/03/31 14:21:59 listening on :8081

注意现在环境变量(ENV)正在优先于配置文件。最后,我们可以测试端口标志:

PORT=8081 go run cmd/main.go --config_file config.json --port 8082
2022/03/31 14:23:36 loading configuration from file: config.json
2022/03/31 14:23:36 listening on :8082

我们配置系统的三种方式都在正常工作。在这个时候,我们可以继续使用我们的配置来更改我们与外部服务的连接。

8.3.2 外部客户端

在第六章,我们探讨了依赖注入和接口。在第六章中,我们构建了一个静态客户端和远程客户端。在这里,我们将根据配置中是否设置了客户端 URL 来决定加载哪个客户端。为此,让我们再次打开我们的 cmd/main.go 文件,并添加以下列表中的代码。

列表 8.10 main.go

func main() {

    cfg := config.LoadConfiguration()

...

    var translationService rest.Translator                             ❶
    translationService = translation.NewStaticService()                ❷
    if cfg.LegacyEndpoint != "" {
        log.Printf("creating external translation 
        ➥ client: %s", cfg.LegacyEndpoint)
        client := translation.NewHelloClient(cfg.LegacyEndpoint)       ❸
        translationService = translation.NewRemoteService(client)      ❹
    }

    translateHandler := rest.NewTranslateHandler(translationService)   ❺

}

❶ 创建一个变量,其类型为接口,以便传递到处理器中

❷ 默认情况下创建静态服务

❸ 如果设置了旧端点,则创建一个新的客户端

❹ 将客户端插入到远程服务的创建中

❺ 将服务注入到处理器中

你应该看到我们正在使用我们的接口来帮助我们加载我们想要的客户端,并将其传递到我们的处理器中。同样,我们可以更改环境变量来影响客户端端点。设置它将允许你调用外部服务。在这种情况下,如果我们传递 URL http://hello-api.joelholmes.dev 并调用端点,我们希望看到它以有效的响应响应。

我们还有一些不同的功能需要构建,例如持久化存储后端(例如,数据库),但我们将它们留到下一章来处理。我们也没有将默认语言集成到我们的处理器中。我将把这个留给读者来处理。

现在我们有一些东西可以交给我们的质量保证团队进行测试,同时我们继续我们的开发工作。我们可以持续部署我们的应用程序,进行小的错误修复和变更,而不影响整体系统。现在测试可以与开发并行进行,只有在我们觉得一切按计划进行时,我们才能发布。但现在我们面临一个新的潜在问题。随着 bug 的修复和功能的开发,我们如何知道我们正在测试或发布的软件版本是什么?我们如何将这个信息传达给我们的用户和团队成员?

8.4 语义版本控制

为了与他人沟通他们使用的软件版本,我们将使用两个工具:版本控制和变更日志。每次更新软件时,你应该注意到有一个特殊的指示器来显示正在安装的软件版本。最常见的方法被称为语义版本控制。图 8.2 展示了示例。

图片

图 8.2 iPhone 软件版本

现在我们正在发布一个产品,向用户指明他们应该使用哪个版本非常重要。通常,开发者喜欢处于发布的最前沿,因为一些新功能和问题已经被修复。然而,这也意味着你处于 bug 的最前沿。软件版本控制解决了这个问题。

这些发布通常看起来像 v1.2.3、1.2.3-e5ad2 或 1.2.3-alpha。这是为了表明关于软件稳定性和变更兼容性的信息。带有部分哈希(如上方的 e5ad2)或希腊字母(alpha、beta 等)的发布通常被称为开发者构建,这表明它们尚未准备好供所有人使用。发布的软件版本通常具有如图 8.3 所示的架构。

图片

图 8.3 语义版本控制有助于区分大型可能破坏性的变更和较小的修复。这种使用的决定权在于团队,并且应该与产品的消费者沟通,因为他们可能依赖于某些功能。

正如你所见,像“主要”和“次要”这样的变化非常主观。最重要的是关注破坏性功能。如果一个端点被移除或方法调用被重新定义,那么这很可能是主要版本的变更,而错误修复或新功能可能只是小版本或补丁修复。

Git 标签是传达这类变化和将它们整合到我们的发布策略中的绝佳方式。我们希望不断整合,但可能并不总是准备好向公众发布,因此,当推送标签与推送小更改时,我们在构建过程中添加了特殊规则。在后面的章节中,我们将构建一个支持最新开发构建和向生产系统发布的部署流程。

一些 API 提供了所谓的 /info 端点,以帮助向开发人员和用户传达产品版本。当有人试图查看发布是否成功或可能引入了哪些错误时,/info 端点非常有用。让我们在我们的应用程序中添加一个。在 handlers 目录中,创建一个 info.go 文件(见以下列表)。

列表 8.11 info.go

package handlers

import (
    "encoding/json"
    "net/http"
)

var (                                            ❶
    tag  string
    hash string
    date string
)

func Info(w http.ResponseWriter, r *http.Request) {
    enc := json.NewEncoder(w)
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    resp := map[string]string{                   ❷
        "tag":  tag,
        "hash": hash,
        "date": date,
    }
    if err := enc.Encode(resp); err != nil {
        panic("unable to encode response")
    }
}

❶ 这些变量将通过编译过程设置。我们希望这些值与二进制文件链接,而不是通过环境变量读取,因为它们应该与二进制文件本身相关联。

❷ 将值映射到响应

让我们将此处理程序添加到 main.go 文件中(见以下列表)。

列表 8.12 main.go

func main() {

    ...

    mux.HandleFunc("/health", handlers.HealthCheck)
    mux.HandleFunc("/info", handlers.Info)             ❶

    log.Printf("listening on %s\n", addr)

    log.Fatal(http.ListenAndServe(addr, mux))
}

❶ 就像健康检查一样,我们需要将此处理程序添加到我们的服务中,但这次是在信息端点。

现在,我们需要通过我们的构建命令将这些值传递给这些变量。我们传递的信息是最新的标签信息、哈希值和构建日期。这可以帮助我们根据哈希值确定确切发生的变化,并根据构建日期确定自发布以来的一般时间。为了填充这些字段,我们需要更新我们的构建过程。打开你的 Makefile,并编辑 build 命令以添加一些额外的标志(见以下列表)。

列表 8.13 Makefile

GO_VERSION := 1.18.5
TAG := $(shell git describe --abbrev=0 --tags --always)               ❶
HASH := $(shell git rev-parse HEAD)                                   ❷

DATE := $(shell date +%Y-%m-%d.%H:%M:%S)                              ❸
LDFLAGS := -w -X github.com/holmes89/hello-api/handlers.hash=$(HASH) 
              -X github.com/holmes89/hello-api/handlers.tag=$(TAG) 
              -X github.com/holmes89/hello-api/handlers.date=$(DATE)  ❹
....

build:
    go build -ldflags "$(LDFLAGS)" -o api main.go                     ❺

❶ 我们使用 git 命令从我们的存储库获取最新的标签版本并将其存储为变量。

❷ 我们使用 git 命令从我们的存储库获取最新的哈希值并将其存储为变量。

❸ 我们使用 shell 获取构建的当前时间戳,以帮助我们确定自上次部署以来已经过去了多长时间。

❹ 我们将这些值组合成构建标志,这些标志针对我们定义的处理程序包中的变量,以便它们嵌入到二进制文件中。

❺ 添加 ldflags 将我们想要的构建标志添加到 go build 命令中。

输入 make build,使用 ./api 运行你的应用程序,并通过以下方式调用 /info 端点:

curl localhost:8080/info

你应该会看到带有填充信息的结果返回。由于我们在 Makefile 中的 build 命令进行了更改,我们不需要对我们的管道进行任何更改以支持此功能。我们真正想要做的是,只有当我们的存储库被标记时才创建发布。再次打开你的管道文件,并添加一个特殊规则,只在推送标签时进行发布,如下所示。

列表 8.14 pipeline.yml

name: CI Checks

on:
  push:
    branches:
      - main
    tags:                                                                  ❶
      - v*
jobs:
  ...
  deliver:
    name: Release
    needs: build
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')  ❷
    steps:

❶ 我们希望在我们的构建上运行标签以及向主分支的推送。

❷ 只有在标记时才运行此步骤。

8.5 更新日志

现在我们正在捕获各种版本,我们应该对发布之间的变化有一个更好的描述。我们可以通过使用一个工具来自动化此过程,该工具将查看我们做出的提交信息并将它们添加到发布体中。这很好,因为它迫使我们记住我们写的消息将被其他人阅读。我们将在未来的章节中增强这一点,但现在让我们先建立基础。我们需要编辑我们的管道(deliver部分)(见以下列表)。

列表 8.15 pipeline.yml

name: CI Checks

on:
  push:
    branches:
      - main
    tags:
      - v*

jobs:
  ...
  deliver:
    name: Release
    needs: build
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Download binary
        uses: actions/download-artifact@v2
        with:
          name: api
      - name: Changelog                                 ❶
        uses: scottbrenner/generate-changelog-action@master
        id: Changelog
      - name: Create Release
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.ref }}
          release_name: Release ${{ github.ref }}
          body: |                                       ❷
            ${{ steps.Changelog.outputs.changelog }}
          draft: false                                  ❸
          prerelease: false
      - name: Upload Release Binary
        uses: actions/upload-release-asset@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ steps.create_release.outputs.upload_url }}
          asset_path: api
          asset_name: api
          asset_content_type: application/octet-stream

❶ 使用此库自动创建一个更新日志以附加到发布

❷ 将库的输出添加到发布体的内容中

❸ 通过将草稿重置为 false 和发布为 false 来使这些发布正式化

提交您的更改并推送它们。然后我们将使用v0.0.1发布标记我们的发布:

git add .
git commit -m "created info endpoint"
git push
git tag "v0.0.1"
git push origin v0.0.1

现在检查您的发布,您可以看到v0.0.1已经发布!下载文件,并测试您的信息端点是否工作。现在转到您的部署,并调用信息端点。您看到了什么?希望您可以看到版本号以及识别构建所需的一些其他信息。

当您查看发布时,应该有一个描述反映了您所写的提交信息。这成为您和您的队友责任感的另一个位置。就像“更新文本”这样的评论对开发者来说并不很有帮助,它们对您的客户也没有帮助。相反,您应该考虑建立一个流程,以拥有有效的评论,概述您所做的更改。一个很好的例子是“在关于页面更正了拼写错误”或“为问题 #43 创建了新的 API 端点”,其中问题 #43 指的是某个内部票务系统。

通过自动化此过程,您有助于将责任感和周密性融入您的工作中。团队的主要目标是找到一种有效合作和沟通的方式。通过这个过程,您的团队将获得一定的弹性和独立性,以帮助他们感到有力量解决现有问题并处理出现的新问题。这不会一夜之间发生,但这是一个需要帮助创建的文化。

8.6 责任感和处理失败

错误是会发生的。每个人和每个团队都会遇到错误。公司通常会认为错误是流程或人员方面的失败,并会添加额外的约束并延长部署时间表以确保没有错误或问题。团队将被创建以确保产品在发货前的质量,这通常会导致越来越长的提前期(部署时间表)。

但让我们考虑一下替代方案。如果我们接受了一种容错文化,即无论我们做什么错误都会发生,我们的文化会如何改变?谁会发现问题,谁会解决它们?我们能够多快地修复它们,我们如何从错误中学习?

这种心态是肯特·贝克在他的各种编程书籍中经常提到的“勇气”。他描述了在自动化测试、代码标准化、结对编程等多个地方放置流程,以允许快速的开发实践,这些实践依赖于“勇敢”的开发者能够面对可能出现的问题。丰田有一个类似的过程,任何在生产线上的员工都有机会通过拉动被称为“andon 线”的东西来停止所有生产。这个简单的流程会停止生产线并触发一个灯来指示问题所在的位置。然后,每个人都会涌向那个地方,看看问题是什么,解决问题,并从中学习。

这种从问题中学习的过程使整个组织能够思考未来减轻问题的方法,并参与其文化内部建立的反馈循环。这被称为生成式文化,其中组织优先考虑提高能力、质量和创新。这与之前描述的风险规避文化相比如何?当然,每个人在延长时间表和添加特殊团队以确保质量时都是出于好意。如果我们把生成式文化与官僚主义文化进行比较,我们会看到一些小的差异。在生成式组织中,组织会调查失败,而官僚主义组织则会寻找导致问题的人。生成式文化为其员工提供自由,让他们改进公司和其流程,而官僚主义文化则限制责任和孤岛团队之间的相互改进。

朗·韦斯特鲁姆研究了各种组织并创建了这一类型模型。他的发现是,生成式文化可以减轻风险并提高组织的安全性。在开发世界中,这意味着在生产中发生的错误和重大故障会减少。如果出现问题,组织将从中学习并找到确保它不再发生的方法,而不会限制他人贡献的能力。在表 10.1 中,你可以看到两种不同公司文化的分解。

表 8.1 公司文化类型

官僚主义 生成式
信息可能会被忽视。 信息是被积极寻求的。
传信者是被容忍的。 传信者是被培训的。
责任被分割。 责任是被共享的。
团队间的桥梁是允许的,但被劝阻。 团队间的桥梁是被奖励的。
组织是公正而仁慈的。 失败会引起调查。
新想法会引发问题。 新想法是被鼓励的。

这对我们开发团队意味着什么?

作为一家组织,你需要为你的开发者创造一个生成性文化,让他们能够快速行动,同时从错误中学习。让组织中的每个人都能够表达担忧,并尝试在问题出现时解决它们。进行“无责后事”会议,以帮助找到根本原因并投资于改进你的流程。每个人都应该对技术问题以及团队动态和改进有发言权。

由于我们正在构建工具和管道,将我们的代码从实现转移到在生产中运行,我们可以在代码进入生产之前使用我们的管道添加更多的检查、守卫和分析。我们可以专注于使部署尽可能无缝,以便快速高效地修复发生的错误,或者提供通过回滚我们的代码和重新部署先前的版本来回滚更改的能力。

最后也是最重要的一步是问题的可见性。当构建或部署失败时,这成为团队中每个人的责任,不要指手画脚。这应该通过某种警报系统(电子邮件、Slack 消息、扔一个橡皮鸡)来完成。团队应该能够在任何额外工作开始之前解决问题并解决构建和部署流程。一旦问题得到解决,工作就可以继续。重要的是,就像在丰田一样,每个人都从经验中学习,并思考如何改进系统。

如果你团队有这个能力,让某个人监控和收集关于你的流程和系统的指标,可以创建一个很好的反馈机制,以找到需要改进的领域。像易出错的测试、长的构建时间、频繁的构建失败和依赖超时等情况可能是由于编写糟糕的测试、缓慢的构建服务器、糟糕的开发环境以及需要工件缓存造成的。但如果你不收集指标并与你的团队交谈,你就不会知道这些问题是否存在或应该如何优先处理。

当你在编辑器中键入时,你听到了你电子邮件的滴答声。你停下来,打开它,并阅读来自 QA 团队的以下内容:“我看到你刚刚发布了一个与我们的现有 API 集成的软件更新。文档说我可以本地测试这个,我假设它还没有在生产中运行。这真是太好了!有没有可能开始实施类似的功能与数据库?开始朝着这个方向推进产品会很好。谢谢!”

你微笑着。看起来你已经说服了他们你可以在不影响系统的情况下开发隐藏功能。是时候开始规划数据库了。

摘要

  • 配置允许你修改应用程序的功能,而无需更改其代码。

  • 配置与依赖注入相结合可以允许你隐藏不完整或未测试的功能。

  • 语义版本控制传达了系统变化的规模。

  • 变更日志提供了已完成工作的概述。

  • 生成性文化允许组织内部进行成长和变化。

9 集成测试

本章涵盖

  • 将用户需求转换为描述性测试

  • 编写遵循行为驱动设计模式的测试

  • 使用容器将外部依赖项集成到测试中

你和项目经理、质量保证负责人、运维负责人以及 CEO 坐在会议室里。项目经理站在房间前面开始演示。

“它被称为 strangler 应用程序。‘strangler 应用程序’这个名字来源于绞杀榕树,它围绕宿主树生长,直到宿主树死亡。虽然听起来很悲伤,但我们最终希望淘汰我们的旧应用程序。我们觉得我们的应用程序已经经过了足够的测试,可以开始向一小部分客户推出。新服务将像今天一样依赖于旧服务,但随着时间的推移,一旦我们确信没有遗漏任何东西,我们可以逐渐淘汰旧服务。”

你环顾四周,注意到每个人都点头表示同意。

“这似乎减轻了一些风险,因为我们总可以在有问题时切换回旧服务,”质量保证负责人补充道。

“确实如此。我们已经建立了一个用于编写灵活软件的系统,该系统能够满足我们的需求。我们缺少的最后一部分是什么?”你的项目经理环顾四周补充道。

运维负责人插话说:“如果我们想关闭旧系统,我们需要一个包含所有翻译的数据库。”

质量保证团队中有人补充说:“围绕这一点进行额外的测试会有所帮助。我们可以自动化吗?”

几周前,你绝对想不到这一点,但现在你在自动化测试方面建立了一些信誉。你的团队正在接受这个新的开发流程,并且效果显著。

你走到白板前写下

  • 将客户调用转换为数据库调用。

  • 迁移旧数据。

  • 建立满足功能要求的集成测试。

“看起来我们有一个计划。大家做得很好,”CTO 站起来走出房间时说。这是你开始工作的信号。

9.1 逐步淘汰旧系统

Strangler 应用程序擅长逐步将旧代码转换为新代码。当我们回到第六章创建外部客户端时,我们就已经开始创建旧系统与新系统之间的链接了。如果你还记得,如果没有缓存中的值,我们会调用外部系统。我们的接口看起来是这样的:

type Translator interface {
    Translate(word string, language string) string
}

到目前为止,我们已经建立了一个灵活性的框架。这使我们能够通过使用配置管理和依赖注入来改变新系统与旧系统交互的方式,逐步淘汰旧应用程序。首先,我们需要选择一个数据库来存储我们的数据。一旦我们确信一切按预期工作,我们将移除外部客户端,并希望关闭旧系统。

为了管理这一点,我们将重载配置以包含数据库连接。如果我们看到这个连接,我们将覆盖外部客户端。我们需要向配置中添加一些值(见以下列表)。

列表 9.1 core.go

type Configuration struct {
    Port            string `json:"port"`
    DefaultLanguage string `json:"default_language"`
    LegacyEndpoint  string `json:"legacy_endpoint"`
    DatabaseURL     string `json:"database_url"`      ❶
    DatabasePort    string `json:"database_port"`     ❷
}

❶ 添加数据库 URL 以进行连接

❷ 在标准端口未使用的情况下添加端口

为了创建所有这些,我们应该考虑如何验证我们的更改是否有效,因此我们将编写一些集成测试来测试整个系统。到目前为止,我们主要关注基本的单元测试并模拟了外部集成。除了模拟这些集成之外,我们还应该创建一系列测试来验证这些交互。最常见的集成点通常是在应用程序和数据库之间。现在我们希望将我们的系统迁移到连接数据库而不是调用外部客户端,但我们希望有灵活性来开启和关闭客户端调用。

首先,我们将专注于创建一个新的连接,该连接与我们的现有接口相匹配。然后,根据我们的配置,我们将进行外部服务调用、数据库调用或混合调用,仅在数据库中不存在该值时才进行外部调用。在我们做任何事情之前,我们应该编写一些测试来验证现有功能,然后集成数据库并验证其是否正常工作。这些测试将验证用户体验而不是代码模块的整体功能,因此我们将采取与之前略有不同的方法。

9.2 行为驱动设计

在第三章中,我们讨论了测试驱动开发(TDD),这有助于我们关注代码单元的预期功能。这意味着我们可以专注于提供适当的输入以获得预期的输出。我们花费时间通过模拟系统部分来验证它们是否被调用,这需要我们一点技术知识来理解一切应该如何工作。我们可以采用相同的格式并进一步抽象化。想象一下,如果你的项目经理、CEO 甚至你的客户编写了测试,而你编写了实现。这正是行为驱动设计应该做的事情。我们从宏观层面开始审视事物,然后观察产品或功能将如何被使用的更大图景。我们不再专注于安排测试、对函数进行操作和断言值(记住第三章中的三个 A),而是专注于一个 Given、When、Then 结构,这可以用更清晰的文本编写并对其进行测试。以下列表提供了一个我们可以用于我们应用程序的示例。

列表 9.2 app.feature

Feature: Translation Service             ❶
  Users should be able to submit a word to translate words within the application

  Scenario: Translation                  ❷
    Given the word "hello"               ❸
    When I translate it to "german"
    Then the response should be "Hallo"

❶ 功能是可交付的项目。

❷ 场景是功能的使用方式。

❸ Given、When、Then 描述了发生了什么。

这种领域特定语言(DSL)被称为 Gherkin,用于允许非技术人员编写可以自动转换为测试的要求。一旦进入你的测试框架,这些要求就变成了你的验证标准或 Arrange, Act, Assert 模式中的断言。关于这一点很棒的是,你正在验证的是其他人编写的要求,或者可以称为你开发过程的一部分。项目经理再也不能说你没有满足要求,如果他们编写了描述并且所有测试都通过了!

注意:我们专注于使用 Go 运行器运行我们的 BDD 测试;然而,你可以使用 Selenium 或 Cypress 编写更全面的集成测试套件。

Gherkin 语言的特别之处在于多个库都可以使用它。这些功能中的每一个都可以与特定的单元测试或用于测试用户界面相关联。关键是我们的项目经理可以用这种特殊语言为我们编写代码,然后我们可以通过多种方式使用它来验证一个功能是否完整。例如,假设我们正在使用我们的 API 创建一个 UI。我们可以使用这个相同的功能文件来编写我们的 Go 后端测试、JavaScript UI 测试和自动化的端到端 QA 测试。只要功能存在但测试未实现,我们的构建就会失败。这是故意的,因为我们的系统需求已经发生了变化。

针对这个功能请求,我们可以将其集成到我们的测试流程中;但是,我们将不会测试单个包,而是测试 main 包,它运行整个应用程序。从高层次来看,我们可以验证我们是否满足了用户的需求。我们将使用这个功能定义来驱动我们的测试。为此,我们将使用一个名为 Godog 的库,它属于 Cucumber 项目,这是 BDD 的顶级开源项目。Cucumber 为支持 Gherkin 的其他语言编写了其他库。

9.3 在 Go 中编写 BDD 测试

首先,我们将通过编写我们的功能定义和测试来设置我们的 BDD 测试。我们将设置测试并编写它们,但预期它们会失败。一旦编写完成,我们将通过附加我们的数据库来修复它们。到本章结束时,我们将能够验证我们的服务完全符合预期。

我们需要做的第一件事是安装一个名为 Godog 的新工具。为此,让我们首先在我们的 Makefile 中添加一个条目(见以下列表)。

列表 9.3 Makefile

setup: install-go init-go install-lint install-godog
...
install-godog:
    go install github.com/cucumber/godog/cmd/godog@latest    ❶

❶ 安装 Godog 二进制文件

接下来,运行安装程序以验证其是否正常工作。我们应该能够将我们的功能复制到目录中以进行测试。Godog 有许多不同的方式来运行测试,但到目前为止,我们将依赖于默认行为,即在一个名为 features 的本地目录中查找功能文件。由于我们将测试 API 二进制文件,我们将在 cmd 目录中创建该目录。

一旦你创建了cmd/features目录并复制了我们的app .feature文件,我们就可以导航到cmd目录并输入godog run。你应该会看到如下列表中所示的生成代码片段。

列表 9.4 console

func iTranslateItTo(arg1 string) error {                               ❶
        return godog.ErrPending
}

func theResponseShouldBe(arg1 string) error {
        return godog.ErrPending                                        ❷
}

func theWord(arg1 string) error {
        return godog.ErrPending
}

func InitializeScenario(ctx *godog.ScenarioContext) {                  ❸
        ctx.Step(`^I translate it to "([^"]*)"$`, iTranslateItTo)      ❹
        ctx.Step(`^the response should be "([^"]*)"$`, theResponseShouldBe)
        ctx.Step(`^the word "([^"]*)"$`, theWord)
}

❶ 我们将文本转换为具有相似名称的函数,以捕获特定的输入。

❷ 在实现之前,我们可以使用这个特殊的错误类型。

❸ 测试进入这里来设置和运行每个场景步骤。

❹ 每个步骤都有一个特殊的捕获组,它为适当的函数提供输入。

显然,这段代码是不完整的,但它为我们提供了一个开始的基础。复制文本,并在该目录中创建一个名为main_test.go的新文件。我们还将创建一个结构体来帮助我们捕获测试所需的某些输入。代码如下所示。

列表 9.5 main_test.go

package main                                                          ❶

import (
    "github.com/cucumber/godog"                                       ❷
)

type apiFeature struct {}                                             ❸

func (api *apiFeature) iTranslateItTo(arg1 string) error {
    return godog.ErrPending
}

func (api *apiFeature) theResponseShouldBe(arg1 string) error {
    return godog.ErrPending
}

func (api *apiFeature) theWord(arg1 string) error {
    return godog.ErrPending
}

func InitializeScenario(ctx *godog.ScenarioContext) {
    api := &apiFeature{}

    ctx.Step(`^I translate it to "([^"]*)"$`, api.iTranslateItTo)     ❹
    ctx.Step(`^the response should be "([^"]*)"$`, api.theResponseShouldBe)
    ctx.Step(`^the word "([^"]*)"$`, api.theWord)
}

❶ 我们使用主包,这样我们就可以引用其中的方法来启动应用程序。

❷ 使用 Godog 库来帮助设置测试。

❸ 这个结构体将帮助在整个测试过程中存储信息。

❹ 函数现在处于我们的功能结构体的上下文中。

我们的测试已经设置好了,但我们无法访问和运行主函数,因此我们想要创建与main()相同的方式来启动服务器。这通常是通过创建一个包含应用程序创建逻辑的函数,并在main函数中调用它来完成的。我们将重构main.go文件以匹配此模式(见以下列表)。

列表 9.6 main.go

package main

import (
    "log"
    "net/http"

    "github.com/holmes89/hello-api/config"
    "github.com/holmes89/hello-api/handlers"
    "github.com/holmes89/hello-api/handlers/rest"
    "github.com/holmes89/hello-api/translation"
)

func main() {

    cfg := config.LoadConfiguration()
    addr := cfg.Port

    mux := API(cfg)                                        ❶

    log.Printf("listening on %s\n", addr)

    log.Fatal(http.ListenAndServe(addr, mux))
}

func API(cfg config.Configuration) *http.ServeMux {        ❷

    mux := http.NewServeMux()

    var translationService rest.Translator
    translationService = translation.NewStaticService()
    if cfg.LegacyEndpoint != "" {
        log.Printf("creating external translation client: %s", cfg.LegacyEndpoint)
        client := translation.NewHelloClient(cfg.LegacyEndpoint)
        translationService = translation.NewRemoteService(client)
    }

    translateHandler := rest.NewTranslateHandler(translationService)

    mux.HandleFunc("/translate/hello", translateHandler.TranslateHandler)
    mux.HandleFunc("/health", handlers.HealthCheck)

    return mux                                            ❸
}

❶ 主函数现在只是运行服务器,而不是配置服务的 HTTP 和端点。

❷ 这个函数将组装服务及其 HTTP 端点,以便传递给服务器。

❸ 返回 mux 路由器以附加到 HTTP 服务器。

你应该能够启动你的应用程序,并且它仍然可以正常工作。现在我们可以连接我们的 Godog 测试。为了验证我们的结果,我们将调用我们的 API 并解析结果。虽然 Go 有调用 HTTP 端点的功能,但我们将使用库来帮助我们使代码更容易阅读。为此,安装go get github.com/go-resty/resty/v2。Resty 帮助使编写 API 调用更加清晰。例如,如果我们想使用 Resty 调用我们的 API,它将类似于以下列表。

列表 9.7 Resty 示例

resp, err := resty.New().R().                             ❶
        SetHeader("Content-Type", "application/json").    ❷
        SetQueryParams(map[string]string{                 ❸
            "language": "german",
        }).
        Get("http://localhost:8080/translate/hello")      ❹

❶ 创建一个新的请求

❷ 设置头部为 JSON

❸ 设置查询参数为德语

❹ 使用 GET 调用端点

我们需要这个调用的输入来验证我们的测试。记得我们之前查看 Godog 为我们生成的那些方法吗?它们有字符串输入,我们可以在功能测试结构体中设置这些输入。让我们将以下列表中的代码添加到我们的结构体中。

列表 9.8 main_test.go

type apiFeature struct {
    client   *resty.Client          ❶
    server   *httptest.Server       ❷
    word     string                 ❸
    language string                 ❹
}

❶ 测试的共享客户端

❷ 创建一个测试服务器以避免端口冲突

❸ 正在使用的单词

❹ 正在翻译到的语言

现在我们可以将值存储在各个步骤中(见以下列表)。

列表 9.9 main_test.go

func (api *apiFeature) iTranslateItTo(arg1 string) error {
    api.language = arg1     ❶
    return nil
}

func (api *apiFeature) theWord(arg1 string) error {
    api.word = arg1
    return nil
}

❶ 将值保存到结构体中

让我们使用下面的列表中的代码初始化我们的功能结构体,以便每个场景都有一个服务器启动和关闭。

列表 9.10 main_test.go

func InitializeScenario(ctx *godog.ScenarioContext) {

    client := resty.New()                    ❶
    api := &apiFeature{                      ❷
        client: client,
    }

    ctx.Before(func(ctx context.Context, sc *godog.Scenario) 
      (context.Context, error) {             ❸
        cfg := config.Configuration{}
        cfg.LoadFromEnv()                    ❹

        mux := API(cfg)                      ❺
        server := httptest.NewServer(mux)    ❻

        api.server = server
        return ctx, nil
    })

    ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) 
      (context.Context, error) {
        api.server.Close()                   ❼
        return ctx, nil
    })

    ctx.Step(`^I translate it to "([^"]*)"$`, api.iTranslateItTo)
    ctx.Step(`^the response should be "([^"]*)"$`, api.theResponseShouldBe)
    ctx.Step(`^the word "([^"]*)"$`, api.theWord)
}

❶ 创建共享客户端

❷ 创建一个新的用于共享的功能结构体

❸ 使用前后钩子来管理服务器

❹ 从环境变量中加载配置(也可以使用默认值)

❺ 创建与主函数相同的 mux

❻ 创建测试服务器

❼ 在场景结束后关闭服务器

最后,我们可以测试这个调用。我们将在theResponseShouldBe函数中这样做。在其中,我们组装 API 调用并验证结果,如下面的列表所示。

列表 9.11 main_test.go

func (api *apiFeature) theResponseShouldBe(arg1 string) error {
    url := fmt.Sprintf("%s/translate/%s", api.server.URL, api.word)    ❶

    resp, err := api.client.R().
        SetHeader("Content-Type", "application/json").
        SetQueryParams(map[string]string{
            "language": api.language,                                  ❷
        }).
        SetResult(&rest.Resp{}).                                       ❸
        Get(url)

    if err != nil {
        return err
    }

    res := resp.Result().(*rest.Resp)
    if res.Translation != arg1 {                                       ❹
        return fmt.Errorf("translation should be set to %s", arg1)
    }

    return nil
}

❶ 根据单词创建要调用的 URL

❷ 设置翻译的语言

❸ 将结果捕获到已知的结构体中

❹ 验证单词

我们就到这里吧!再次输入godog run,看看结果如何。现在如果你想要的话,可以创建另一个语言的场景!它工作了吗?接下来,让我们添加数据库的要求。

9.4 添加数据库

首先,我们将添加一个新的要求,这个要求需要我们离开我们的静态数据集,转而使用外部数据库。这组相同的测试原本也可以用于连接我们的外部服务。想象一下,如果 QA 团队最初是在旧系统上编写所有这些要求,然后你随着你的 strangler 应用开始,将他们移动到新项目中。当你所有的测试都通过时,你就知道你已经达到了平衡。一旦我们将配置翻转以使用数据库,我们就可以再次验证一切是否都已准备好部署(见下面的列表)。

列表 9.12 app.feature

Feature: Translation Service
  Users should be able to submit a word to translate words within the application

  Scenario: Translation
    Given the word "hello"
    When I translate it to "german"
    Then the response should be "Hallo"

  Scenario: Translation
    Given the word "hello"
    When I translate it to "bulgarian"
    Then the response should be "Здравейте"

如果我们现在运行我们的测试,我们应该看到失败。回想一下第三章,并记住我们的失败、通过、失败的模式。这意味着我们的项目经理或其他人可以在我们开发功能时监控我们的进度。可以生成报告来显示特定功能中所有场景的覆盖率和完成进度,并可以被称为功能完成

还有一点非常重要,这个应用功能集可以在后端 API 和前端屏幕上测试。一个功能的完整性可以通过一系列集成测试来验证,而不仅仅是单一的一个测试!

对于我们的功能,我们可以想象一个更大的翻译集合需要我们验证。我们当前在代码中的 switch 语句中保留所有翻译的解决方案既不可扩展,也不允许我们在不重新启动服务的情况下添加或删除语言,因此我们将向我们的系统添加数据库。数据库是专门的数据存储应用程序,在管理和处理我们的各种数据方面做得更好。然后我们将测试我们的服务与外部依赖项(在这种情况下是数据库)之间的集成。

有许多数据库选项,但我们将使用一个非常简单(但强大)的键值存储,称为 Redis,它非常轻量级,并且将非常类似于我们之前实现的缓存机制。我们将把这个工作分解为开发和测试。在我们设置测试之前,我们需要有一种建立连接的方法。记住,我们只需要一个实现我们的 Translator 接口的服务,然后我们就可以将其直接放入现有的处理器中。

首先,让我们将 Redis 添加到我们的基础设施中。记得在第七章中我们引入了 docker-compose 来帮助我们构建容器吗?我们将使用相同的技术来管理我们的依赖项。让我们将 Redis 作为依赖项添加到我们的 docker-compose.yml 文件中,如下所示。

列表 9.13 docker-compose.yml

version: "3.8"
services:
  ...
  database:                   ❶
    image: redis:latest       ❷
    ports:
     - '6379:6379'            ❸
    volumes:
     - "./data/:/data/"       ❹

❶ 创建一个新的名为 database 的服务

❷ 使用最新的 Redis 容器定义

❸ 将 Redis 端口暴露给 API 使用

❹ 将数据库备份挂载用于测试

如果你运行 docker-compose up -d database 然后输入 docker exec -it database redis-cli,你应该会看到一个提示出现。如果你输入 ping,你应该得到一个 pong 响应。恭喜!你刚刚启动了一个数据库!

让我们创建一个连接。我们已经更新了我们的配置来处理数据库的连接字符串。现在我们将在 translation 包中创建一个新的文件。我们将称之为 database.go。我们首先创建一个返回连接结构体的函数。我们将使用这个结构体来实现 Translator 接口。我们将创建足够的代码以便开始编写我们的测试。让我们在以下列表中编写代码。

列表 9.14 database.go

package translation

import (
    "context"
    "fmt"

    "github.com/go-redis/redis/v9"
    "github.com/holmes89/hello-api/config"
    "github.com/holmes89/hello-api/handlers/rest"
)

var _ rest.Translator = &Database{}                                    ❶

type Database struct {
    conn *redis.Client
}

func NewDatabaseService(cfg config.Configuration) *Database {          ❷
    rdb := redis.NewClient(&redis.Options{
        Addr:     fmt.Sprintf("%s:%s", cfg.DatabaseURL, cfg.DatabasePort),
        Password: "", // no password set
        DB:       0,  // use default DB
    })
    return &Database{
        conn: rdb,
    }
}

func (s *Database) Close() error {                                     ❸
    return s.conn.Close()
}

func (s *Database) Translate(word string, language string) string {
    return ""                                                          ❹
}

❶ 这是一个类型验证,这样我们知道我们的服务满足接口。

❷ 使用数据库配置返回一个新的连接结构体

❸ 需要一个关闭函数来清理连接。

❹ 只做最少的努力以开始。

现在我们可以创建我们的集成测试了。多亏了我们的运维团队,我们能够获取到生产数据库的备份,因此作为我们测试的一部分,我们将把备份的数据库加载到 Docker 容器中,并针对它运行我们的服务。这将尽可能地模拟生产环境进行测试。让我们为我们的测试套件创建设置(见以下列表)。

列表 9.15 main_test.go

var (
    pool     *dockertest.Pool
    database *dockertest.Resource
)

func InitializeTestSuite(sc *godog.TestSuiteContext) {                 ❶

    var err error

    sc.BeforeSuite(func() {
        pool, err = dockertest.NewPool("")                             ❷
        if err != nil {
            panic(fmt.Sprintf("unable to create connection pool %s", err))
        }

        wd, err := os.Getwd()
        if err != nil {
            panic(fmt.Sprintf("unable to get working directory %s", err))
        }

        mount := fmt.Sprintf("%s/data/:/data/", filepath.Dir(wd))      ❸

        redis, err := pool.RunWithOptions(&dockertest.RunOptions{      ❹
            Repository: "redis",
            Mounts:     []string{mount},
        })
        if err != nil {
            panic(fmt.Sprintf("unable to create container: %s", err))
        }
        if err := redis.Expire(600); err != nil {
            panic("unable to set expiration on container")
        } //Destroy container if it takes too long
        database = redis
    })

    sc.AfterSuite(func() {
        database.Close()                                               ❺
    })
}

❶ 这将在每个测试套件之前运行,这与在每个场景上运行的设置不同。

❷ 创建一个新的 Docker 连接池

❸ 将数据库备份挂载

❹ 运行 Docker 容器

❺ 完成后关闭容器

现在我们还需要更新我们的场景设置(见以下列表)。

列表 9.16 main_test.go

func InitializeScenario(ctx *godog.ScenarioContext) {

...
    ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
        cfg := config.Configuration{}
        cfg.LoadFromEnv()

                cfg.DatabaseURL = "localhost"               ❶
                cfg.DatabasePort = database.Port("6379")    ❷

        mux := API(cfg)
        server := httptest.NewServer(mux)

        api.server = server
        return ctx, nil
    })
...
}

❶ 将数据库设置为连接到您机器上的 Docker

❷ Docker 库随机创建一个端口进行连接。

最后,我们编辑我们的主文件以使用我们刚刚启动的数据库的新 URL(见以下代码列表)。

列表 9.17 main.go

if cfg.DatabaseURL != "" {                            ❶
        db := translation.NewDatabaseService(cfg)
        translationService = db
    }

❶ 如果数据库已设置,我们通过依赖注入使用这个服务。

现在我们可以运行我们的测试,并看到它们失败。太好了!让我们更新我们的实现代码来检索文件。根据文档,翻译的值存储在language:word的格式中,其中所有word变量都是英文,所以我们的逻辑变得相当简单,如下所示。

列表 9.18 database.go

func (s *Database) Translate(word string, language string) string {
    out := s.conn.Get(context.Background(), fmt.Sprintf("%s:%s", 
      word, language))         ❶
    return out.Val()           ❷
}

❶ 通过从单词和语言构建键来查询数据库

❷ 返回字符串值

再次运行我们的测试,你应该会看到它们通过!

备注:希望你们中的一些人在想为什么我们没有创建专门测试数据库的测试,而是依赖于集成测试。这是因为这超出了本章的范围,但它是一个很好的练习。你可以使用之前相同的数据库设置,或者查看一些其他内存数据库测试解决方案。

你能想到其他可以添加的测试吗?或者是我们可能没有涵盖的其他功能?

9.5 发布

让我们看看我们的测试金字塔(图 9.1)。

图 9.1 端到端测试在顶部较小,因为它们成本更高且可靠性不高。它们应该由更大的集成和单元测试套件支持。每一层都应该独立运行,从单元测试开始,然后在不同阶段向上推进金字塔。

我们已经涵盖了金字塔的所有层级,从第三章的单元测试开始,第六章的验收测试,以及现在本章的集成测试。这意味着我们完成了吗?不,还远远没有。这是你和你团队需要开始监控覆盖率以及测试所需时间的时候。理想情况下,你不想让你的测试套件运行超过 5-10 分钟,这样它们才是有效的。集成测试可以根据速度分成不同的组。例如,测试子集可以针对核心功能运行,而较长的套件可以用于所有回归(旧问题)。这个测试组通常被称为功能测试,或验证应用程序规格的测试。表 9.1 给出了这些不同类型的简要概述。

表 9.1 功能测试类型

类型 描述 回答问题
烟雾测试 检查基本功能的初步测试 它能启动吗?
精神测试 验证高级计算,如聚合或数学计算 项目数量正确吗?
回归测试 验证之前报告的缺陷是否已解决 这之前能工作吗?
可用性测试 评估客户与产品的互动 人们如何使用这个功能?

在我们的链中何时运行这些测试是合适的呢?在上一章中,我们讨论了发布:我们希望在代码稳定时才进行发布,因此我们希望使用这些测试作为一种方式来确认一切都已经稳定到可以发布。从理论上讲,我们所有的单元测试和验收测试都应该支持我们的集成测试,所以当我们打标签或发布时,不应该有任何意外。然而,我们还想增加一个最后的防护措施来防止发布有缺陷的代码,因此我们将在构建过程中添加一个集成测试阶段(见下面的代码列表),这个阶段将在发布完成但推送到生产之前发生。

列表 9.19 ci.yaml

smoke-test:
        name: Smoke Test Application
        needs:
        - test
        runs-on: ubuntu-latest
        steps:
        - name: Set up Go 1.x
        uses: actions/setup-go@v2
        with:
            go-version: ¹.18
        - name: Check out code into the Go module directory
        uses: actions/checkout@v2
        - name: Install Godog
        run: go install github.com/cucumber/godog/cmd/godog@latest    ❶
        - name: Run Smoke Tests
        run: |
            go get ./...
            godog run --tags=smoke-test
  build:
    name: Build App
    runs-on: ubuntu-latest #
    needs: smoke-test                                                 ❷
    steps:
    ...
  containerize-buildpack:
    name: Build Container buildpack
    runs-on: ubuntu-latest #
    needs: smoke-test                                                 ❸

❶ 安装 Godog 并运行你的烟雾测试

❷ 仅在烟雾测试成功后构建应用程序

❸ 仅在烟雾测试成功后构建容器

我们决定在单元测试之后放置功能测试。这允许我们在开始构建之前提升测试树。我们构建集成测试的方式意味着我们可以在系统启动时获得洞察。在手工焊接电路板的时代,这被称为烟雾测试,因为如果插入时冒烟,就存在问题。今天的烟雾测试验证系统是否启动并具有基本功能。因此,我们的集成测试也可以是我们的烟雾测试。

通常,标签会被赋予某些测试以表示它们是否是烟雾测试的一部分或更大的回归测试套件的一部分。这些可以对应于功能测试类型。我们可能在烟雾测试运行后运行额外的检查。烟雾测试失败将停止管道,但回归套件可能是一个标志,提示某人验证是否有所变化。这变成了一种自动化的质量保证系统,可以允许质量保证成员花大部分时间探索系统以找到额外的错误。在下一个列表中,让我们调整我们的场景以使用标签,并更新 CI 的最后一个标志以进行烟雾测试。

列表 9.20 api.feature

Feature: Translate API
  Users should be able to submit a word to translate

  @smoke-test
  Scenario: Translation
    Given the word "hello"
    When I translate it to "german"
    Then the response should be "Hallo"
  @smoke-test
  Scenario: Translation unknown
    Given the word "goodbye"
    When I translate it to "german"
    Then the response should be ""
  @smoke-test
  Scenario: Translation Bulgarian
    Given the word "hello"
    When I translate it to "bulgarian"
    Then the response should be "Здравейте"
  @regression-test                      ❶
  Scenario: Translation Czech
    Given the word "hello"
    When I translate it to "Czech"
    Then the response should be "Ahoj"

❶ 运行一个单独的测试类型

编辑我们的 CI 以首先运行烟雾测试,然后进行第二个步骤以测试回归(见下面的列表)。

列表 9.21 ci.yaml

smoke-test:
...
        run: |
            go get ./...
            godog run --tags=smoke-test
    regression-test:                              ❶
        name: Regression Test Application
        needs:
        - test
        runs-on: ubuntu-latest
        steps:
        - name: Set up Go 1.x
        uses: actions/setup-go@v2
        with:
            go-version: ¹.18
        - name: Check out code into the Go module directory
        uses: actions/checkout@v2
        - name: Install Godog
        run: go install github.com/cucumber/godog/cmd/godog@latest
        - name: Run Smoke Tests
        run: |
            go get ./...
            godog run --tags=regression-test      ❷

❶ 建立一个更长时间或更全面的测试套件,定期运行

❷ 指定回归套件

当你看到你的管道变绿时,质量保证经理走过。现在是展示你所能完成的事情的大好时机。你展示了不同的功能测试,并解释了我们如何将他们想要的全部要求放入各种测试套件中。当他们看到绿色的场景时,他们笑了,这是你第一次看到他们这样做。

摘要

  • 使用行为驱动开发有助于整个团队建立需求。

  • Gherkin 提供了一种通用的语言来编写行为驱动测试,这些测试可以被不同的团队实现。

  • 集成测试的外部依赖可以通过使用容器来复制现实世界的服务来提供。

  • 标签可用于帮助聚焦测试套件并缩短测试的整体运行时间。

10 高级部署

本章涵盖

  • 创建 Kubernetes 集群

  • 在 Kubernetes 中部署 API

  • 使用 Helm 部署数据库

  • 配置你的 API 以使用数据库

“如果你看看这些图表,你会发现我们的新服务实际上帮助我们的服务吸引了更多的流量。我们的移动应用团队能够快速地使用为翻译服务采用的一些相同技术制作了一个应用程序。这个应用程序得到了广泛的应用,并且在所有应用商店中都有趋势。然而,由于翻译服务仍然作为一个按需服务运行,我们发现它比运行专用服务器更昂贵,所以我们只剩下两个选择:使用像 Kubernetes 这样的专用容器编排器,或者构建专用虚拟机来运行服务。”

所有人都在看 DevOps 领导展示的图表。有一些表示同意的点头,但最终 CTO 发言了。

“我认为整个点是要从专用服务转向‘无服务器’方法。这不会减少我们的市场交付吗?有没有替代方案?”

DevOps 领导推进了幻灯片并说:“我们有一个更长期的目标,即向 Kubernetes 这样的容器编排框架迈进。这是因为我们可能无法从处理相同级别或资源的更多应用程序的专用虚拟机中获得 100% 的利用率。我们正在与各个团队合作,开始为他们的产品实施容器创建,这样我们就可以在 Kubernetes 或 K8S 集群上托管所有这些。然而,我们中没有人之前使用过 Kubernetes,所以可能会有一个学习曲线。另一种选择是创建自定义镜像并将它们部署到虚拟机上。我们称之为经典部署过程。目前它容易出错,因为我们在这方面几乎没有流程。然而,我们从这一过程中学到了尽可能多的东西可以帮助提高整体生产力,所以我们将对我们的一些较旧服务采用‘基础设施即代码’,以帮助我们更清晰地维护基础设施。不幸的是,我们没有人有这方面的经验,而且我们很忙,所以我们可能需要依赖开发团队来开始这项工作。这样可以吗?”

你微笑点头。这些倡议和理念现在开始传播到其他团队,这表明公司整体上有了巨大的改进。为整个公司工作一个更健壮的部署流程可能看起来有点令人畏惧,但它将非常值得。

“为了不显得太时髦,我认为对这两者都进行一次研究是很值得的。你认为你能给我一些关于 Kubernetes 集群的工程量估计吗?如果我们能将整个公司引向那个方向,我认为从财务上对我们来说是有意义的,但我们需要确保它不会垄断开发者的时间。我们可以在稍后日期尝试基础设施即代码。”

10.1 并非完全的 IaaS

我们在部署进展中来到了一个十字路口。记住,我们在部署中处理各种抽象,并将它们用作服务。在之前的基础设施章节中,我们探讨了使用函数即服务(FaaS),其中一个小型、轻量级的按需应用程序仅在请求时运行。然后我们转向平台即服务(PaaS),我们只需提交我们的二进制文件,就会神奇地创建一个服务器。我们最后的部署使用了容器即服务(CaaS),其中容器被构建和运行,使我们能够接触到底层虚拟化环境,以便进行更多系统级别的集成。

在这一点上,如果你发现我们需要更少的抽象和更多的控制,我们可以选择两条不同的路径之一。一条是走完全的基础设施即服务(IaaS)路线,通过构建和运行我们的物理基础设施,使用虚拟机和负载均衡器将流量导向我们的应用程序。另一种方式是设置、运行和管理一个容器编排工具,如 Kubernetes。在本章中,我们选择后者,因为它因其多样化的开发工具集和开发者友好的接口而流行。附录 D 简要概述了那些可能想要走真正的 IaaS 路线的替代方案。相反,我们将处于图 10.1 所示的 IaaS 和 CaaS 栈的中间。

图片

图 10.1 我们现在使用我们的容器作为我们的可运输产品。

Kubernetes 并非完全等同于基础设施即服务(IaaS)。它存在于容器即服务(CaaS)和 IaaS 领域之间。这是因为 Kubernetes 通过抽象处理了大部分底层基础设施。例如,节点扩展和负载均衡等功能都是由 Kubernetes 集群创建和维护的。作为开发者,你只需关注定义你想要的资源类型,并将它们提交给集群以运行。这种以抽象方式构建资源是 IaaS 的核心。像 Terraform 这样的工具用于维护和构建实际的基础设施,就像 Kubernetes 一样。

Kubernetes 与部署和服务一起工作,而不是服务器和负载均衡器。这些抽象允许 Kubernetes 根据服务器的负载在不同服务器实例之间移动工作负载。Kubernetes 减少了围绕你的应用程序的大量维护和管理工作,因为它处理诸如负载均衡、服务重启等任务。正因为如此,Kubernetes 已经成为许多从按需服务扩展到专用服务以实现最佳正常运行时间的团队的非常受欢迎的选择。

10.2 你的第一个集群

我们首先需要创建一个集群(见以下列表)。我们不会在本地安装 Kubernetes,而是将依靠 GCP 为我们创建一个。为此,我们将使用 GCP 工具。

列表 10.1 创建集群

gcloud container clusters create \
--zone=us-central1-a                                                        ❶
gcloud services enable \
 containerregistry.googleapis.com container.googleapis.com                  ❷
gcloud components install gke-gcloud-auth-plugin                            ❸
gcloud container clusters get-credentials hell-cluster --zone=us-central1-a ❹

❶ 在指定区域创建集群

❷ 为你的容器启用注册访问

❸ 安装身份验证插件

❹ 检索用于 kubectl 的集群凭据

注意:如果您不想在云中设置集群而感到麻烦,有许多本地工具可供选择,例如 Minikube 和 KinD。

您应该能够访问您的节点。就是这样。Google 使这变得非常简单。如果您希望使用其他云提供商,可能会有额外的步骤。现在您已经准备好部署。

要查看更靠近您居住地区的所有地区和区域列表,请访问 mng.bz/91Ro

10.3 构建块

您可以找到无数关于 Kubernetes 及其所有构建块的书、演讲和博客文章,所以在这里我不会深入讨论。我们需要关注两件事:部署和服务。部署 运行容器或容器组(Pod),这些容器可以扩展(副本集),这正是第七章中 GCP 的 Cloud Run 为我们做的事情。服务 创建一个端点,将调用定向到我们的部署。这本质上充当了一个负载均衡器,可以在多个服务器实例之间平均分配调用。

让我更详细地解释这两个核心元素。部署可以被视为 Kubernetes 两个较低实体定义的包装器。Pod 是容器组(Docker Whale 的游戏;一组鲸鱼是一个 Pod)。如果您想运行多个 Pod 实例,可以将它包装在一个 副本集 中,该副本集运行多个 Pod 实例。最后,部署将扩展、健康检查和调用 Pod 的定义包装起来。

服务充当您应用程序的路由器。它可以像将端口转发到底层应用程序一样简单,类似于浏览器在加载网站时进行的 DNS 查询,或者像具有特定路由规则的负载均衡器一样复杂,用于 A/B 测试或功能测试。

这两个定义缺少很多细节,但对于我们试图实现的目标来说已经足够了。然而,我鼓励您查看 Marko Luksa 的 Kubernetes in Action(Manning,2017)以获取更多详细信息。

让我们从创建我们的部署开始。首先,我们需要在您的根目录中创建一个名为 k8s 的新目录,并在其下创建一个名为 hello-api 的服务目录。在这里,我们将创建一个名为 deployment.yml 的新文件。在文件中,我们需要编写我们的部署定义。关键是让我们的容器运行一个实例。幸运的是,我们已经上传了一个容器镜像,我们可以使用它。以下列表中的代码显示了部署定义,它将是 /k8s/hello-api/deployment.yml

列表 10.2 deployment.yml

apiVersion: apps/v1
kind: Deployment                     ❶
metadata:
  name: hello-api                    ❷
spec:
  replicas:                          ❸
  selector:
    matchLabels:
        app: hello-api
  template:
    metadata:
      labels:
        app: hello-api
    spec:
      containers:
      - name: hello-api
        imagePullPolicy: Always
        image: gcr.io/PROJECT_NAME/hello-api:latest
        ports:
        - containerPort: 8080        ❹
          name: hello-api-svc        ❺

❶ 我们正在创建的 Kubernetes 对象类型

❷ 部署的名称

❸ 要运行的 Pod 数量

❹ 此端口与容器监听的端口相匹配。

❺ 如何访问此应用程序

现在我们使用kubectl应用我们的部署。如果它尚未安装,你可以按照kubernetes.io/docs/tasks/tools/上的说明进行安装。安装后,你只需运行kubectl apply -f k8s,该目录下的所有文件都将被应用。如果我们输入kubectl get pods,我们现在应该能看到我们的运行 API pod。

现在我们将在/k8s/hello-api/service.yml中设置服务。我们的服务非常简单,因为它只需要打开一个端口指向我们的部署,如下面的列表所示。

列表 10.3 service.yml

apiVersion: v1
kind: Service             ❶
metadata:
  name: hello-api
spec:
  selector:
    app: hello-api
  type: LoadBalancer      ❷
  ports:
  - port: 80
    protocol: TCP
    targetPort: 8080      ❸

❶ 服务类型将路由传入请求到部署。

❷ 负载均衡器将利用底层云基础设施将消息路由到你的部署。

❸ 映射到部署依赖的端口

现在我们可以调用apply来查看我们的服务是否出现。我们可以通过调用kubectl describe service hello-api提供的端点来测试它。

10.4 扩展和健康状态

任何系统中的关键服务都应该有一定的冗余。在软件中,你希望你的客户避免任何停机时间,并能够满足人们对你的系统提出的需求。这被称为扩展:系统可以通过在多个运行服务之间分配请求来增长以满足其需求。这样做可以减少系统耗尽内存或产生长时间响应的机会。有两种类型的扩展:垂直扩展和水平扩展。垂直扩展允许你向机器添加更多功率来处理增加的负载。水平扩展允许你创建额外的服务器实例来处理负载。在本节中,我们将关注对我们部署的水平扩展。

到目前为止,我们不必过多担心扩展问题,因为我们部署应用程序的系统已经处理了所有的扩展。如果你对我们的 FaaS、PaaS 或 CaaS 服务发起 100 万次请求,你会看到它们有多个运行实例来处理负载。同时,我们的 Kubernetes 部署在这个阶段无法扩展,因为我们还没有给它适当的设置来这样做。我们在这里将只关注手动扩展和健康检查,但像《Kubernetes in Action》这样的书籍可以展示其他方法。

同样,我们不希望出现中断时间,因此我们需要允许 Kubernetes 知道何时部署就绪,以便它可以关闭旧的部署。这被称为滚动部署。为此,我们将利用在第四章中添加的健康检查端点。在这里,我们将添加存活性(服务是否正在运行?)和就绪性(是否准备好接收请求?)检查。这两个检查都将让 Kubernetes 知道我们的 pod 已就绪。为此,我们需要通过添加以下列表中的代码来修改我们的部署文件。

列表 10.4 deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-api
spec:
  replicas: 1
  selector:
    matchLabels:
        app: hello-api
  template:
    metadata:
      labels:
        app: hello-api
    spec:
      containers:
      - name: hello-api
        imagePullPolicy: Always
        image: gcr.io/PROJECT_NAME/hello-api:latest
        ports:
        - containerPort: 8080
          name: hello-api-svc
        livenessProbe:
          httpGet:
            path: /health       ❶
            port: 8080
          initialDelaySeconds: 3
          periodSeconds: 3
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 3
          periodSeconds: 3

❶ 这个调用将每 3 秒检查一次是否返回 200 响应。

活性探测将检查容器是否已启动并运行,而就绪探测将开始将流量导向 Pod。在这种情况下,我们将使用健康端点。在这里,我们通过检查 HTTP 服务器是否响应来确定系统是否就绪。如果没有响应,Pod 将被关闭,并启动一个新的 Pod 来替代它。

在这种情况下,我们的活性探测和就绪探测是相同的。然而,这并不总是如此。比如说,如果你的 Pod 中运行了两个进程,一个 API 和一个缓存。缓存有时可以被预热或预加载数据。在这种情况下,活性探测将是健康的,但 Pod 只有在缓存预热后才能准备好接受消息。想象一下,这是启动你的车与将其挂入档位之间的区别。如果任何一个检查耗时超过预期,Pod 将被删除,并创建一个新的 Pod 来重新启动进程。

在设置了活性和就绪探测之后,我们现在可以通过添加副本来扩展服务。为此,我们只需编辑一行,如下所示。

列表 10.5 deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-api
spec:
  replicas: 3       ❶
  selector:
    matchLabels:
        app: hello-api
  template:
    metadata:
      labels:
        app: hello-api
    spec:
      containers:
      - name: hello-api
        imagePullPolicy: Always
        image: gcr.io/PROJECT_NAME/hello-api:latest
        ports:
...

❶ 将实例数量增加到 3

这将为该服务创建三个独立的 Pod。提交这些更改。我们将在创建自动部署后看到这一切是如何工作的。但到目前为止,Kubernetes 通过几行代码就给了我们添加和删除高级部署实践的控制权。在过去,这种配置难以维护和监控,因为你需要处理物理机器、负载均衡器和监控工具。相反,Kubernetes 为你提供了所有这些,以便你可以开始部署。因为一切都是代码,所以我们更新部署变得更加容易。

10.5 自动部署

在过去,我们在合并到主分支时部署我们的代码。这使我们的客户每次合并拉取请求时都能获得我们开发的最新成果。然而,在第八章中,我们介绍了标签的概念,它允许我们标记一个部署为稳定。有了这种稳定性,我们可以轻松跟踪已经部署的代码以及我们可以针对未来版本的目标修复和功能。一旦这种节奏建立起来,我们就可以轻松估计向客户交付新版本所需的时间。

所有这些都是为了说明标记你的产品和代码库非常重要。这也很好地与我们的容器化发布相结合,因为容器也使用发布标签。我们的部署代码有一个指向 latest 标签的引用,这可以松散地翻译为“我不在乎是什么版本;我想要最新的。”我们感觉我们已经超过了这一点(这可能是一个开发环境的好设置!)现在我们想要标记,因此我们应该创建一个容器构建过程,当我们的代码被标记时,它会推送一个新的标记版本。我们将使用在第八章中讨论的相同的标记策略,但这也将基于你团队的决定。让我们修改我们的代码来实现这一点,如下所示。

列表 10.6 pipeline

containerize-buildpack:
    name: Build Container buildpack
    runs-on: ubuntu-latest #
    needs: smoke-test 
    if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
    steps:
  ...

让我们试试看:

git tag v0.0.1
git push origin v0.0.1

你应该会看到一个新容器被标记并推送到 GCP。现在我们能够标记我们的容器,我们需要有一个更新部署的过程。在管理这类部署时,有两种思考规则:自动化或事后。在自动化世界中,你创建一个过程,每当 K8s 目录中的文件发生变化时,就会运行 apply。这意味着你更改了代码,而管道会跟踪集群凭据和状态。这是一个很好的地方,但直到那些过程被明确定义并且高效运行,许多人会事后将已应用的变化更新到仓库中。这通常是通过提交一个包含更改的 PR 并等待批准来完成的。一旦批准,你应用代码然后合并。

我们现在已经在 Kubernetes 上建立了一个 CD 流程。我们并没有运行我们的生产级系统。为了做到这一点,我们需要数据库和配置。

10.6 使用 Helm 部署 Redis

许多像 Kubernetes 这样的平台,使用基础设施即代码,允许额外的工具和抽象来扩展它或在其之上构建。在这种情况下,Kubernetes 与一个名为 Helm 的工具配合得很好。Helm 就像是一个包管理器,但用于你的 Kubernetes 集群。它将使用一个称为 Helm 图表的类似部署机制来部署应用程序。Helm 图表主要用于生产中的开箱即用功能,但可以根据你的需求进行调整。

在这个例子中,我们将使用 Helm 为我们的集群部署 Redis,但首先我们需要安装 Helm。为此,请遵循helm.sh/docs/intro/install/中的说明。

我希望你在想我们这个部分的 Makefile 在哪里。我们现在需要它来帮助我们管理部署。首先,我们将创建 Helm 部署,然后是部署我们的应用程序的步骤。Helm 允许我们在应用图表时通过传递特定的设置来配置我们的部署。这些设置通常是像扩展或安全值这样的东西。在我们的例子中,我们希望我们的 Redis 数据库通过使用密码来保证安全。为此,我们编辑我们的 Makefile,以便我们可以有一个带有一些配置的部署命令(见以下列表)。

列表 10.7 Makefile

install-redis:
  helm repo add bitnami https://charts.bitnami.com/bitnami       ❶
  helm install redis-cluster bitnami/redis --set password=$$(
  ➥ tr -dc A-Za-z0-9 </dev/urandom | head -c 13 ; echo '')      ❷

deploy:
  kubectl apply -f k8s

❶ 使用专门的 Kubernetes Redis 部署

❷ 生成一个随机密码使用

运行 make install-k8s-redis,我们应该能够看到新的 pod 上线。数据库现在正在运行,因此我们可以配置我们的系统以针对它运行。为此,我们需要创建一个配置映射。

10.7 更新部署配置

在第八章中,我们讨论了通过配置使我们的应用程序改变其功能的工作。现在我们可以使用 Kubernetes 的相同机制。由于 Kubernetes 集群不是由单个机器组成的,我们无法简单地在每个系统上设置环境变量,也无法将配置文件添加到单个服务器上。

相反,Kubernetes 将其视为一种资源,就像部署或服务一样。我们可以创建和引用一个 配置映射,它定义了一组类似使用的配置值,将我们的环境变量与消费容器解耦。这意味着我们将有一个配置映射供我们的服务消费。由于我们现在处于生产状态,我们还应该考虑通过使用称为 密钥 的特殊配置类型来更新我们的 Redis 服务器。首先,让我们制作我们的映射。

配置映射就像 Kubernetes 中的任何其他资源一样,我们可以通过文件来创建它们。让我们在 k8s/hello-api 目录下创建一个新的 config.yml 文件。在其中,我们将添加以下列表中的代码。

列表 10.8 config.yml

apiVersion: v1
kind: ConfigMap
metadata:
  name: hello-api
data:
  database_url: "redis-cluster"     ❶

❶ 在配置映射中设置环境变量

通过输入 kubectl apply -f k8s/hello-api/config.yml 来应用它,你应该会看到一个通知,表明已创建了一个新资源。

在我们将配置附加到我们的服务之前,我们还应该为我们的 Redis 服务器创建一个密钥。与配置映射不同,你不希望因为安全风险而将它们作为文件存储在我们的系统上,也不希望使它们在我们的集群中容易可见。

注意:虽然 Kubernetes 有一个称为“密钥”的特殊字段,但这并不意味着它是加密或安全的,只是意味着它对最终用户是隐藏的。一个健壮的生产系统应该考虑使用 Vault 这样的密钥管理器。

机密通常用于像用户名和密码这样的东西。它们不需要像您需要记住的电子邮件或银行登录信息一样。相反,正如我们之前看到的,我们可以提供一个随机字符串作为密码,Kubernetes 将为我们管理它。当我们创建我们的 Redis 部署时,提供了一个密码。我们不需要它;我们可以像配置映射一样引用它。为了将这些值放入我们的部署中,我们需要设置一些环境变量。让我们打开它,并在下面的列表中添加代码。

列表 10.9 deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-api
spec:
  replicas: 1
  selector:
    matchLabels:
        app: hello-api
  template:
    metadata:
      name: hello-api
      labels:
        app: hello-api
    spec:
      containers:
      - name: hello-api
        imagePullPolicy: Never
        image: holmes89/hello-api:v0.3
        ports:
        - containerPort: 8080
          name: hello-api-svc
        env:
        - name: DATABASE_URL         ❶
          valueFrom:
            configMapKeyRef:
              name: hello-api
              key: database_url
        - name: DATABASE_PASSWORD    ❷
          valueFrom:
            secretKeyRef:
              name: redis-cluster
              key: redis-password
              optional: false

❶ 从配置映射设置 DB URL

❷ 从 Helm 机密设置密码

这些值应该与我们本地运行应用程序配置时拥有的值匹配。配置值在容器启动时被加载。为了现在看到这个工作,我们可以简单地使用kubectl apply -f k8s重新应用我们的部署。一旦它运行起来,我们可以通过尝试用不同的语言进行查询来查询数据库以验证结果:

curl <url>:80/translate/hello?language=arabic

希望您看到的是正确的翻译!

现在是星期五,距离您开始这个整个项目已经两周,距离 Kubernetes 启动会议不到一天。您坐下来,给团队发了一封快速电子邮件,告诉他们您的状态,并在设计文档中草拟了您的发现,以帮助团队前进。微笑着,您反思了公司走了多远。您在保持标准的同时帮助创造了一种实验文化,并简化了开发过程。每个人都看起来很满意,但您知道这不会完美。事情需要改变,需要开发新的应用程序,并且希望您能够提供帮助。

摘要

  • Kubernetes 集群抽象化了由您的团队管理的多个服务器上的部署。

  • 部署创建了一组称为 pods 的容器组,可以根据需求或所需的可用性进行扩展。

  • 服务将调用路由到部署。

  • 机密和配置文件可以用来填充应用程序配置的环境变量。

11 循环

本章涵盖

  • 概述创业和项目的阶段

  • 通过扩展核心改进区域

“我真心没想到我们能完成这件事。我们过去几周所能够改变的事情真是令人惊叹,”项目经理面带微笑地说。“我们将把所有项目都转移到这种开发风格。如果我们幸运的话,我们可以开始尝试一些小项目和更快的发布周期。现在更新软件的成本要低得多。我们还通过将小型应用程序作为函数部署,然后迁移到我们的 Kubernetes 集群来降低了我们的服务器成本。”

你也不禁微笑起来。项目经理还没有看到这种模式的未来收益。当自动化进入纺织制造时,它将 100%的手动工作转变为 2%的手动工作。长期影响是纺织制造业的更多工作机会和更多日常消费者产品。通过自动化 98%的工作流程,您的公司现在能够生产更多产品和工具来帮助公司增长。

这个故事有趣的地方在于它不会有结局。你可能最终会再次开发一个新产品。你可能晋升为管理一个开发团队。你可能最终离开公司去另一家公司或自己创业。重要的是它重新开始。最后一章被称为“循环”,因为如果你记得第一章,你需要在交付后返回并设计。这种情况也适用于系统和流程。每次新项目开始时,你应该考虑上次有哪些可以改进的地方,或者你想要探索的其他想法。随着本书的结束,我们将回顾本书分解的每个阶段或部分(启动、加速、巡航)以及该阶段中的每个元素(流程、测试、交付),并决定未来要寻找什么。

11.1 启动

显然,任何项目的目标都是尽早交付并在预算内完成。这并不总是发生,但在创建新产品或概念验证时,任何事情都是可能的。我们想要建立一些流程和质量检查,而不必过于担心扩展。

正因如此,我们在本书的第一部分重点介绍了开发设置、单元测试和无服务器函数。让开发者参与进来将是最重要的任务。这就像组织一个工地。如果你对事物的位置和去向有一个清晰的概述,你将花费更少的时间给出指示,并将更多精力集中在开发本身上。至于事物的位置,我们建立了一个中央存储库和代码在我们组织中流转的机制。这自动化了我们团队需要做的一些工作,并让他们有更多时间专注于代码。在早期建立这一点是至关重要的,因为它让开发者有更多时间做其他工作。然而,你应该考虑在自动化之前,这项任务需要多久做一次。如果你发现自己一周内不止一次停下来做同样的任务,考虑自动化它。

你的软件会随着时间的推移而改变,但不会像开发初期那样变化。这就是为什么验证核心功能的基础测试是一项值得投资的工作,而不是一开始就进行端到端覆盖。虽然有些人可能会嘲笑在项目早期积累技术债务,但我认为如果项目转向或关闭,这将被视为浪费时间。关键是识别项目何时会持续进行,何时应该改进测试。一个好的规则是考虑你早期发现错误的地方。如果你发现错误或浪费了时间,也许你应该考虑添加测试。一旦你越过了这个界限,就应该考虑一个更大的代码清理计划。这可以是一次代码清理闪电战:每个人都负责一段代码,并添加测试和文档。

部署应该既便宜又简单,这就是为什么我们最初选择了 FaaS 和 PaaS。FaaS 完全能够运行你的整个初创企业。你可能发现,由于它们的扩展能力和你的组织结构,你永远不需要从 FaaS 切换。但迟早,你可能发现切换到另一种部署类型会更便宜。或者你可能发现函数的扩展方式并不符合你的期望。在两种情况下,你都需要密切关注这些函数的使用情况和指标。所有主要的云托管平台都提供这种洞察力,并允许你设置警报。考虑讨论你想要设置哪些警报,并在必要时制定迁移计划。你会在周六深夜进行消防演习之前,很高兴你考虑了这一点并提前制定了计划。

应用程序开发的启动阶段相当于开拓者。你可能会发现自己走错了方向。你可能会发现自己正在正确的方向上前进,但走得还不够远。你可能会发现自己甚至不应该在这个区域。在所有这些情况下,现在就打下根基是没有意义的。像帐篷这样的临时设施就足够了,所以这个阶段可以看作是帐篷阶段。你需要保持移动性和临时性,而不是固定下来。一旦你感受到缺乏耐久性的痛苦,就考虑进入下一个阶段。

11.2 加速

一旦你确定你的产品将长期存在,下一个开发阶段就开始了。这是加速阶段:你开始快速构建。你已经完成了开拓,找到了一个很好的基地。你不仅需要快速成长,而且要向正确的方向发展。这就是为什么我们专注于在启动阶段构建的基础上进行扩展。现在我们在帐篷上搭建墙壁和屋顶,并考虑城镇将如何发展。

在这个阶段,我们更注重标准化和文档化。在建造房屋或办公室时,会使用某些承包商标准。板材以英尺或米为单位测量。螺丝有特定的头部类型。混凝土需要特定的比例才能正确凝固。我们在我们的代码审查和审查程序中也做了同样的事情。我们还希望建立更好的文档,以概述我们的代码在哪里以及如何使用。这是我们系统的蓝图和图示。

我们还专注于将我们的应用程序构建成模块化。当你仍然需要在受限的空间内保持灵活性时,你最不需要的就是一个庞大且僵硬的项目来操作。拥有一个模块化系统可以让你更容易地适应空间。考虑一下在客厅里移动沙发和三把椅子之间的区别。哪个更容易?同样,我们通过使用接口注入某些依赖项并使用模拟和存根测试来测试我们的代码,从而构建了我们的代码。这样,每个模块化部分都是独立测试的,这意味着除非该模块的功能发生变化,否则我们不需要重写我们的测试。

然后,我们将这段新代码放入了一个便携式运行时环境中,使用了容器。首先,使用了 Buildpacks 来为 Google 的容器运行时构建优化的容器。然后,我们为开发用例创建了我们的容器。能够这样分发我们的代码,展示了团队可能需要如何在不同应用程序边界之间进行交互和共享。能够以这种方式运行和记录依赖项,可以在未来减轻集成问题。同时,考虑一下这可能会对你测试应用程序的能力产生什么影响。质量保证部门或团队可以创建临时部署来运行自动化测试脚本,而不会影响你的生产部署。

加速被用来描述这个阶段,因为重要的是不要停止。你已经找到了建设的地方,现在你需要扩展。物体需要达到一定的速度才能摆脱引力的拉扯。这只有在继续加速的情况下才能实现。这是一个累积的力量,而不仅仅是静态的。随着你加速,你获得越来越多的速度,但到达那个边缘变得越来越困难,直到最终,你摆脱了它。

11.3 巡航

一旦你摆脱了引力的拉扯,你就不再需要加速。相反,你可以巡航和探索。这并不意味着你已经完成。你已经成功了。你已经到达了一个可以自由改进和探索的阶段。需要校正课程,错误仍然会发生。

当我们到达这个阶段时,我们能够专注于使我们的应用程序灵活且稳定。我们可以根据我们放入的变量来改变应用程序的运行方式。这种功能可以扩展到超越添加数据库连接的默认值或端点。相反,你可以用它来隐藏尚未完成的功能。甚至有方法可以远程管理这些设置,这意味着你只需在数字仪表板上切换一个开关,你的应用程序就会突然以不同的方式运行。

管理这种灵活性是使你的公司成功的关键。在摆脱创业的引力之后,你现在可以探索你业务的广阔领域。这成为你成功的关键部分。在《精益创业》(Currency,2011)一书中,埃里克·雷泽谈到了在创业公司中保持灵活性的必要性,以找到客户想要的东西,并且只做那件事。这是我们贯穿整本书的指导原则:保持简单并不断变化。这有助于我们创建必要的底层工具,以便快速高效地交付,同时继续改进。一旦我们开始顺利航行,我们就可以开始构建围绕我们如何开发功能的过程。我们可以使用在第九章中学到的行为驱动开发技能来引导和聚焦我们的开发。然后我们可以开始考虑其他形式的测试,例如探索性测试,其中团队通过生成大量负载或将特殊字符放入输入字段来尝试破坏我们的系统。这可以通过用户模拟测试框架如 Selenium 或 Cypress 进行自动化。

希望这一切都能让你意识到你需要调整你的基础设施。当你有人来找你说你的云费用太高,并询问你能否做些什么时,你就知道你已经到了这个阶段。这很可能会在其他任何事情之前发生。这是因为你正在为 AWS 或 Google 为你运行应用程序的抽象付费。这是一个便利费。一旦到了这个阶段,你需要专注于基础设施;只有在这种情况下,查看像 Kubernetes 这样的酷工具才有意义。这不是一个铁的规则,但专注于简单而不是复杂。如果你没有支持像 Kubernetes 这样的基础设施的团队,你不必担心。专注于开发一个你可以展示的产品,然后担心如何优化它的运行方式和地点。

我从经验中说这些。我在创业公司工作了大约十年,看到了团队如何成长以及错误发生在哪里。过于频繁地,开发人员和经理们想要抓住一个新而闪亮的技术或工具来完成工作,而他们并不需要这样做。我的一个前经理曾经说过:“人们总是想要抓住一个新工具,但他们不知道如何使用它。那些旧而破烂的工具之所以旧而破烂,是因为它们有用。”这并不是说你不应该关注新而闪亮的东西。事实上,你需要将它们融入其中以保持增长和跟上步伐,以及保持你的开发人员参与其中。技能在于确定使用哪些工具以及如何使用它们。作为一个团队,在开始这条道路之前,你应该讨论、计划和进行研究,并考虑支持你为未来所做的一切。

这里有一个例子,展示了我在最近过去浪费在闪亮事物上的时间。我正在开发一个与我之前构建的应用程序非常相似的应用程序。一条消息传来,包含了一些指标数据,我需要将其标准化以便在各个仪表板上显示。我决定我想让这个系统完全基于事件,并且在整个服务中的所有通信都将异步进行,所以我围绕一个我已经关注了一年多的工具构建了一个复杂的系统。我对我所构建的东西感到兴奋和满意——直到我不满意了。很快我就意识到,我所构建的东西比所需的更复杂,虽然它可以扩展,但它不需要以我预期的那种方式扩展。然后我意识到,我使用的库在开发两周后就被弃用了,突然之间,重构我所写代码的时间表提前了。

教训是我为自己建造了一些闪亮的新东西,而不是为顾客。我过度设计和开发,然后不得不面对重建我所建造的东西的后果。这源于我自身的缺乏规划和纪律。重要的是要找到一种方法,确保未来不会发生这种情况。我为了防止这类问题再次发生,想出了什么缓解策略?我开始编写设计文档,并将它们放在我的代码库中的一个文件夹里,这样人们可以在工作完成之前审查设计,并在查看系统是如何构建的时参考它们。这些被称为 RFC,它们在允许团队在开发之前思考和讨论设计方面非常有帮助。

这些只是你在项目或产品通过各个阶段成长过程中可能会遇到的一些事情。现在让我们来看看每个阶段的各种元素,并讨论如何超越我们在流程、测试和交付中涵盖的内容。

11.4 开发元素

当我们开始写这本书时,我提到我所展示的不是新的——至少对行业来说不是新的。希望我能提供一本手册,帮助你和你的事业起步。软件开发直到我开始阅读软件开发与制造业之间的比较才变得有意义。

我可以想象我的初创公司里的各种同事都在扮演不同的角色。戴着安全帽和制服,我看到我的经理在叉车上指导一位开发者,同时我的项目经理在各个站点之间移动文件夹,处理各种订单。与此同时,一位质量保证团队成员正在他们的记事本上检查方框,记录下最微小的瑕疵。最后,我们的运维团队过来,把它装上卡车,然后把它从大楼里运出去。

那正是团队被划分的方式,工作也是这样通过我们的系统流动的。我们花了大量的工作来弄清楚如何一起工作,建立良好的关系,并建立信任。在我们被收购的前几个月,我制定了一个计划来展示如何简化这项工作。结果是创建了一个类似于本书的管道,通过几点击鼠标就能自动化将想法变成现实的过程。

当我研究这个想法时,我发现一些优秀的书籍描述了 CI/CD 管道、测试方法和代码标准化实践,但没有将它们与示例结合起来。当我查看这些书籍时,我将它们组织成了流程、测试和交付。

11.4.1 流程

在团队中工作很困难,无论你的团队成员是你的最好朋友还是完全陌生的人。每个人做事的方式都不同,你必须找出如何相处并一起完成任务。浪费可以导致团队中的问题。当你用不完整的功能、马虎的代码或明显的错误浪费某人的时间时,它可以从令人烦恼迅速升级到不尊重。一旦对同事的不尊重根深蒂固,团队就很难融合和协作。

流程可以节省时间。通过建立标准和实践,你可以通过节省人们的时间和互动来减少产生的浪费。如果一个程序能告诉你你的代码看起来很糟糕,这就节省了其他人告诉你这个信息的努力,你将获得快速、简洁的反馈。然而,这些简单但有效的工具往往被忽视。我们在一开始创建的简单管道建立了我们的流程,并在整本书中不断添加内容。建立验证、测试和自动部署的流程可以减少在交接和进入生产过程中浪费的时间。这个管道在图 11.1 中展示。

图片

图 11.1 我们将代码通过管道的流程

你还应该对你的流程有一个流程。虽然这看起来是循环的,但这是有帮助的。定期评估你是如何构建和交付你的产品的,这将帮助你改进。这被称为持续改进(与我们在早期所做的持续集成不同)。丰田意识到,当他们能够提高生产力时,他们的员工并没有达到最佳状态,这意味着他们可以完成额外的工作,因为他们的工作变得简单了,所以丰田要求他们的员工利用这些额外的时间来改进他们的流程:看看他们在哪里效率低下或不足。这不是通过选择一些你认为需要改进的任意领域来完成的。员工和管理者需要在改进成为流程的一部分之前获得指标、评估和设计。

这对你的团队意味着什么?确保你和你的团队成员都处于最佳状态,并且你给自己留出一些时间来寻找改进的领域。围绕交付时间、构建时间和响应时间建立指标都可以用来改进你的应用程序和流程。想想你希望如何使用测试以及你的管道是如何工作的。你的开发环境的人体工程学如何?它是否难以设置,或者是否需要其他人来让它工作?如果你不按照特定方式运行事情,你的电脑会崩溃吗?以另一种方式开发是否合理?

总是思考如何改进你的软件以及你开发软件的方式。实现这一目标的一种方式是通过有针对性的和适当的测试。

11.4.2 测试

在初创公司中进行测试是一件非常棘手的事情。你的代码变化如此频繁,以至于测试有时会阻碍你的开发工作。你把所有的时间都花在为一周内就会改变的代码片段编写测试上。当你知道这些代码可能瞬间就会消失时,你为什么还要花时间去测试呢?

让我问你这个问题:你怎么知道某个特定的代码片段会保留还是被移除?你不知道。虽然我经常不得不删除或重写我的测试,但我清楚地记得有些项目我们没有进行任何测试,最终为此付出了代价。我们很快在生产中遇到了如此多的错误,如果一开始就编写测试代码,这些时间会更好利用。

你应该怎么做?难道你真的要等到痛苦不堪的时候才去写测试吗?绝对不是!这样做会降低你的生产力,并增加你的整体开发周期。为了解决这个问题,我们需要在整个过程中考虑测试,而不仅仅是在某个特定的阶段。本书的整体趋势是从简单到复杂。同样,你的测试也应该通过以不同方式检查代码的不同部分而变得更加复杂和健壮。这为你和你的团队节省了时间。

但是你应该测试多少,测试什么?答案是,从基本的单元测试开始,围绕确定性代码部分进行。这些是你的典型的算法函数,它们计算余额或错误率。你给它一组数据,每次都会得到相同的答案。这是通过使用我们在第四章和第七章中讨论的基本单元测试和模拟技术来完成的。第十章探讨了针对更大服务的集成级别测试。我们没有达到全系统、端到端、UI 到 API 级别的测试。所有这些都应反映在我们的测试金字塔(图 11.2)中。

图片

图 11.2 测试金字塔侧重于建立单元测试的基础。

通过从较小的任务块开始并逐步构建,我们为测试建立了一个坚实的基础。如果你要做什么,那就做自动化单元测试。这些是评估你产品质量的基础。在所有情况下,尽可能将质量检查与代码保持接近,特别是对于单元测试。如果你发现你的单元测试正在中断或阻碍,你需要重新考虑你的测试流程。编写好的单元测试既是一门艺术,也是一门科学。你需要确定测试什么,不测试什么。正如第四章中提到的,测试驱动开发(TDD)在帮助我们精简测试到核心元素方面非常有帮助。

你可能会发现你的测试方法很好,但你总是在不断改变它们。这是在其他地方过度劳累的迹象。你是否掌握了你正在编写的代码?其他人是否在编写高质量的测试?你是否得到了正确的要求?项目是否定义得清楚?如果这些问题的任何一个答案是“否”,你应该召开一个团队会议。

测试不仅揭示了代码中的错误,还帮助你发现过程中的错误。例如,我为几家拥有非常复杂的测试设置的公司的测试工作提供过帮助。整个测试数据库需要加载,某些软件需要安装或运行,还需要运行脚本以设置测试环境。虽然这些本身并不可怕,但它们导致团队成员感到沮丧,因为他们挣扎于设置而不是编写测试。当你发现团队成员因为本地开发环境而不愿意编写测试或本地测试时,你就在过程中发现了错误。

所有这些自动化测试的最终目标,是任何自动化目标的:让其他人有更多的时间去做更有创造性的工作。如果你能在所有层面上构建自动化测试套件,你的团队将开始能够探索这些改进领域:

  • 不同功能或系统的性能

  • 产品的可用性

  • 罕见或极端的边缘情况(在客户识别它们之前)

  • 用户工作流程改进

还有许多其他改进领域。重点是测试是创建反馈循环的必要部分,这将只会帮助你的公司成长。唯一找出它是否按预期工作的方法是将它发布给公众。

11.4.3 交付

交付您产品的选项比沙丘移动得还要快。不久前,公司还安装自己的服务器和硬件。然后,随着所有这些虚拟化服务器的出现,一切都转向了云。当时,云计算的选项很少,但现在有很多,而且每个月都有更多出现。就像电力一样,计算正在变成任何拥有电脑和信用卡的人都可以使用的公用事业。由于云计算技术的广泛扩展,已经构建了工具来构建在这个庞大的计算公用事业系统之上。像 Kubernetes 和无服务器框架这样的平台都是在抽象计算资源的环境中运行的(见图 11.3)。

图片

图 11.3 我们专注于各种*aaS 产品,但随着时间的推移,可能会有其他抽象计算资源。

但我们无法期望变化会停止在这里。虽然其他流程和测试的概念随着时间的推移将有所小幅度改进和变化,但一般原则将保持不变。你交付产品的方式在未来将发生重大变化。我们已经在看到趋势,将计算资源转移到最初用于缓存网页的 CDN 网络上,现在它们可以在网络的“边缘”运行轻量级 API 和计算。

所有这些都是在说,在几年后,第四章、第七章和第十章可能会提到云世界中过时的技术。事实上,我保证它们会。但这并不意味着这本书会过时。像其他概念一样,一些核心部分是必不可少的。首先,你的管道完全属于交付领域。我们以讨论自动化交付及其重要性开始这本书。我们还讨论了交付根据技术可能意味着多件事情的事实。我们专注于云技术,因为它们是一个简单的开始方式。但交付可以通过在物联网设备上推送软件更新或仅提供供人们安装的可下载的二进制文件或包来实现。你的管道应该能够接收你编写的代码并交付一个工件,简单明了。

云产品的交付将发生变化,但那些规则也将保持不变:从最简单、最便宜的开始,直到你更多地了解你的产品是如何被使用的。在新闻业,他们告诉你遵循调查的五 W 原则来深入挖掘故事,这些原则在这里同样适用:

  • 会使用你的产品?

  • *他们在你的产品上做什么?

  • 在哪里人们在使用产品?

  • 何时人们最常使用它?

  • 为什么人们使用你的产品?

所有这些项目对于交付客户需要和想要的东西都是必不可少的。从那个角度来看,这些问题几乎显得重复,对吧?当你交付你的产品时,你需要找到调查这些领域的方法。例如,如果你发现大多数用户都在欧洲而不是美国,你应该考虑在欧洲的一个云区域部署,直到你可以扩展到多个部署区域。只有通过指标,你才能发现这些事情。

这是我无法在书中涵盖的核心部分,但它至关重要。指标收集帮助你确定如何发展你的产品。它回答了调查问题,并帮助你向雇主和团队证明未来工作的合理性。向前看,将取决于你如何随着时间的推移发展你的交付和产品。

11.5 OODA 循环

在 20 世纪 60 年代初,约翰·博伊德上校开发了一种名为 OODA 循环的军事战略:

  • 观察—收集数据。

  • 定位—分析数据。

  • 决策—根据对数据的分析确定行动方案。

  • 行动—根据决定采取行动。

这个过程会重复,把你带回观察阶段(图 11.4)。

图片

图 11.4 OODA 循环

我希望你能看到与我们在这本书中能够实现的事情之间的相似之处。项目经理观察到了旧系统的成本和维护。在用数据定位自己后,他们决定应该以更低的成本构建一个新的服务。然后他们通过启动这个项目采取了行动。

然后它又循环了!

观察了旧系统是如何构建的。在当前开发过程中定位后,你能够决定最低成本的方法是编写一个简单的 API,通过 FaaS 以低成本和维护。你行动通过创建一个 CI(持续集成)流程来交付这个简单的功能。

它又循环了!

你观察到你的 FaaS(函数即服务)是一个成功的概念验证。分析确定这个过程无法扩展到其他团队。你决定增加测试并创建一个可移植的应用程序供其他团队使用。

然后它又循环了!

由于你的容器化产品的成功,管理层观察到其他人也应该这样做。为了做到这一点,我们需要提高流程和测试的水平,并需要创建一个容器化部署环境。我们决定将配置管理和集成测试添加到我们的流程中,并创建一个 Kubernetes 集群。

并且循环将继续!

当你将产品开发视为一个循环而不是一条长线时,你会发现你的开发心态发生了变化。就像一个循环一样,产品不会结束。相反,它们会进化。是的,就像所有进化一样,你会有一些产品灭绝,但进化充满了胜利者和失败者;这就是它的方式。不能适应的人会死去。相反,专注于你的产品如何通过观察、定位、决策和行动来适应。

11.6 结论

我最近和我的前经理共进午餐。我们讨论了他的角色以及事情是如何变化的。“我现在不能再做很多开发了,而且没有工程师直接向我汇报,所以我看不到很多代码。但我似乎无法摆脱我们的管道。我一直在检查并试图改进它。我不确定为什么我无法放手。”

这对我来说是有意义的。他现在到了一个点,代码的细节不再重要。重要的是交付,然后将其运送给客户。他不需要知道测试是如何编写的,或者产品是用什么语言编写的。他只需要看到已经建立了一定的标准,并且正在使用一个流程。除此之外,他需要信任团队的其他成员完成他们的工作。

到目前为止的旅程与我的职业生涯以及许多其他人的职业生涯相并行。我在本书开头说过,这些页面中没有新内容,希望你能识别出这里列出的一些模式或流程。项目的成功超出了开发人员或公司的控制范围。你能做的唯一一件事就是确保你交付。交付一个能够快速适应市场和客户需求的高质量产品在你的控制范围内。你所需要做的就是构建交付的工具,围绕它们组建一个团队,并探索。

摘要

  • 产品是一个旅程,而不是一个目的地。

  • 管道有助于在团队内部管理流程,而不需要微观管理。

  • 变化是不可避免的,因此你需要找到一种方法来在变化中找到自己的方向。

附录 A. 使用 Kotlin

在整本书中,我提到软件开发和代码维护模式并不局限于单一的语言或技术,因此以下章节将快速展示如何使用三种流行的语言(Kotlin、JavaScript 和 Python)引入这些相同的流程,并使用 HashiCorp 的工具提供另一种部署选项。首先,我们将探讨 Kotlin,这是一种建立在 JVM 之上的语言,随着其成熟度的提高而越来越受欢迎。

Kotlin 的好处是它建立在现有的 Java 技术之上,这意味着工具和模式相当成熟。我们将使用 Kotlin 特定的工具构建一个新的 CI 流水线,但在我们的 Makefile 中步骤将保持基本相同。首先,我们将使用 Kotlin 和 Redis 再次构建我们的 hello-api。

A.1 框架

框架既帮助又阻碍开发团队。有时一个框架可以帮助你快速推出产品,但过了一段时间,你可能会发现你在与之抗争,失去了势头。花时间研究可用的框架并重新评估你正在使用的框架非常重要。寻找以下特性:

  • 易用性

  • 文档质量

  • 可持续性

所有这三点都非常重要。你不想使用一个晦涩且不再受支持的框架,因为可能会出现错误和漏洞,而你将无法修复它们。同时,你也不想采用不支持你想要使用的技术框架。在 Java 开发世界中,一个新兴的框架 Quarkus 符合所有这些标准。幸运的是,这个框架可以与 Kotlin 一起使用,并且是为了在基于容器的生态系统中运行而构建的。由 Red Hat 开发和支持的 Quarkus 提供了许多驱动程序和开箱即用的工具,以帮助快速构建和测试 API。要设置它,请按照以下步骤操作:

  • 安装 Java (mng.bz/e1mz)。

  • 前往 code.quarkus.io。

  • 将组命名为 com.manning,工件命名为 hello-api

  • 保持构建工具为 Maven,Java 版本 17。

  • 在过滤器部分,添加 Kotlin、RESTEasy Reactive Kotlin Serialization 和 Redis 客户端。

  • 点击生成您的应用程序,并打开您的项目。

注意:Spring 是 Java/Kotlin 框架中的大玩家,在 Java 和 Kotlin 中都有优秀的文档。

完成这些步骤后,我们可以解压我们的项目并开始工作。

A.2 编码

Quarkus 给我们很多,我们不需要做任何事情。像许多现代基于 JVM 的框架一样,Quarkus 广泛使用注解来处理大部分的连接。这可以非常有帮助,但在调试时也可能是一个谜。我们首先在我们的 com/manning/hello-api 包中创建四个文件。首先是主函数,我们将称之为 TranslationResource.kt。这将是我们的翻译入口点,如下所示。

列表 A.1 TranslationResource.kt

package com.manning.hello-api

import javax.inject.Inject
import javax.ws.rs.GET
import javax.ws.rs.Path
import javax.ws.rs.PathParam
import javax.ws.rs.Produces
import javax.ws.rs.QueryParam
import javax.ws.rs.core.MediaType

@Path("/translate")                                    ❶
class TranslationResource {

    @Inject                                            ❷
    private lateinit var service: ITranslationService

    @GET                                               ❸
    @Path("/{word}")                                   ❹
    @Produces(MediaType.APPLICATION_JSON)
    fun translate(
        @PathParam("word") word: String,
        @QueryParam("language") language: String?
    ) = service.translate(language, word)              ❺
}

❶ 请求的基本路径

❷ 依赖注入翻译服务

❸ REST GET 方法

❹ 请求的子路径

❺ 根据路径和查询参数调用服务

当我们的应用程序启动时,Quarkus 将查找我们所有的路径并将它们添加到主控制器中,就像我们在 Go 中手动做的那样。这段代码没有特别之处,除了我们将一个接口作为我们的服务来处理翻译。这个接口将作为实际 Redis 实现背后的屏障,就像我们在 Go 中做的那样。这个接口在 ITranslationService.kt 中定义,如下所示。

列表 A.2 ITranslationResource.kt

package com.manning.hello-api

interface ITranslationService {
    fun translate(language: String?, word: String): Translation?    ❶
}

❶ 定义返回可选翻译对象的接口方法

很简单,对吧?它几乎和我们的 Go 接口完全一样。我们可选地返回一个 Translation 类型。这个类型在单独的 Translation.kt 文件中定义,它将复制我们的 Go 中的 Translation 结构(见以下列表)。

列表 A.3 TranslationResource.kt

package com.manning.hello-api

data class Translation(      ❶
    val language: String?,
    val translation: String?
)

❶ 为消息创建数据类或 DTO

最后,我们可以进入 Redis 连接。在这里,我们有一些更多的代码(见下一条列表)以及从 Redis 本身实际检索的内容。

列表 A.4 RedisTranslationService.kt

package com.manning.hello-api

import io.quarkus.redis.datasource.RedisDataSource
import org.eclipse.microprofile.config.inject.ConfigProperty
import javax.enterprise.context.ApplicationScoped
import javax.inject.Inject

@ApplicationScoped
class RedisTranslationService : ITranslationService {

    @Inject
    private lateinit var redisAPI: RedisDataSource        ❶

    @ConfigProperty(name = "default.language")
    var defaultLanguage: String? = "english"              ❷

    override fun translate(language: String?, word: String): Translation? {
        val commands = redisAPI?.string(String::class.java)
        val lang = language?.lowercase() ?: defaultLanguage
        val key = "$word:$lang"
        val translation = commands?.get(key)              ❸
        return if (translation == null) {
            null
        } else {
            Translation(language = lang, translation = translation)
        }
    }
}

❶ 注入 Redis 客户端

❷ 从配置中设置默认语言

❸ 从 Redis 获取翻译

为了让 Redis 连接,我们需要提供一些它可以用来自连接的属性。这些值可以被覆盖,就像在 Go 中一样,通过传递环境变量。默认情况下,我们希望使用 localhost,所以我们将填写我们的 resources/application.properties 文件,如下所示。

列表 A.5 application.properties

quarkus.redis.hosts=redis://localhost:6379     ❶
quarkus.redis.client-type=standalone
quarkus.datasource.jdbc=false

default.language=english

❶ 连接到 Redis 的默认属性

现在我们有了代码,让我们介绍一下如何构建和运行它。然后我们将用它来进行测试。最后,我们将所有这些封装在一个管道中。

A.3 Maven

Maven 是 Apache 项目下的一个构建工具,它允许 Java 开发者管理他们的项目依赖(如我们的 go.mod 文件),以及整合不同的构建和测试脚本(如我们的 Makefile)。其他构建工具,如 Gradle,也可以用来完成类似任务。在本节中,我们将专注于使用 Maven。Maven 项目是通过使用一个 pom.xml 文件来管理的,你将在你下载的设置项目的根目录中找到它,还有一个 mvnw 脚本。这个脚本是一个围绕基本 Maven 工具的包装器,这样开发者就不需要担心环境特定的变量(例如,他们的操作系统和基本 Maven 文件)。

我们将使用 Maven 来构建、运行和测试我们的代码。首先,让我们运行我们编写的代码。确保你的 Redis 数据库正在运行,然后在终端窗口中输入 ./mvnw compile quarkus:dev。你会看到许多文件被下载和编译,最后你会看到一个消息说服务器正在监听。在这个时候,尝试你之前章节中的可靠 curl 命令来测试它。

现在我们已经了解了 Maven 的样子,我们将用它来运行我们的单元测试。

A.4 测试

对于我们的测试示例,我们将直接进入集成测试,因为它们让我们了解 Kotlin 中测试的编写方式,集成测试通常在设置和清理方面更为复杂。这将为我们提供一个很好的概述,同时提供一个起点来填充测试金字塔的其余部分。对于我们的测试,我们再次使用数据库容器和一个名为 RestAssured 的库,它将为我们提供一个用于验证的优秀的 REST 测试框架。要开始,在测试文件夹中创建一个名为 TranslationTest.kt 的文件(见以下列表)。

列表 A.6 TranslationTest.kt

package com.manning.hello-api

import io.quarkus.test.common.QuarkusTestResource
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured.given
import org.hamcrest.CoreMatchers.equalTo
import org.junit.jupiter.api.Test

@QuarkusTestResource(RedisTestContainer::class)     ❶
@QuarkusTest                                        ❷
class TranslationTest {

    @Test
    fun testHelloEndpoint() {
        given()
            .`when`().get("/translate/hello")
            .then()
            .statusCode(200)
            .body("translation", equalTo("Hello"))
            .body("language", equalTo("english"))
    }

    @Test
    fun testHelloEndpointGerman() {
        given()                                     ❸
            .`when`().get("/translate/hello?language=GERMAN")
            .then()
            .statusCode(200)
            .body("translation", equalTo("Hallo"))
            .body("language", equalTo("german"))
    }
}

❶ 依赖于一个容器

❷ 告诉构建工具这是一个测试

❸ 使用流畅断言测试请求

测试应该与书中早些时候的内容相似。现在我们需要创建另一个文件,告诉我们的测试插件这个特定的测试是一个集成测试。为此,我们只需创建一个包含以下列表内容的文件。

列表 A.7 TranslationTest.kt

package com.manning.hello-api

import io.quarkus.test.junit.QuarkusIntegrationTest

@QuarkusIntegrationTest                   ❶
class TranslationIT : TranslationTest()

❶ 将此测试套件定义为集成测试

就这些了!最后,我们需要我们的数据库容器来运行测试。为此,我们在 pom.xml 文件中添加一个依赖项。在那里,在依赖项下,你将添加以下列表中的行。

列表 A.8 pom.xml

<dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers</artifactId>
      <version>1.17.3</version>
      <scope>test</scope>
    </dependency>

然后我们创建一个名为 RedisTestContainer.kt 的文件,并添加以下列表中的代码。

列表 A.9 RedisTestContainer.kt

package com.manning.hello-api

import io.quarkus.test.common.QuarkusTestResourceLifecycleManager
import org.testcontainers.containers.BindMode
import org.testcontainers.containers.GenericContainer
import org.testcontainers.utility.DockerImageName

class RedisTestContainer : QuarkusTestResourceLifecycleManager {

    private val redisContainer = GenericContainer(DockerImageName.parse(
    ➥"redis:latest"))                                     ❶
        .withExposedPorts(6379)
        .withClasspathResourceMapping("data", "/data", BindMode.READ_ONLY)

    override fun start(): MutableMap<String, String> {     ❷
        println("STARTING redis ")
        redisContainer.start()
        println("redis://${redisContainer.getHost()}:${
        ➥redisContainer.getMappedPort(6379)}")
        return mutableMapOf(Pair("quarkus.redis.hosts", "redis://${
        ➥redisContainer.getHost()}:${
        ➥redisContainer.getMappedPort(6379)}"))
    }

    override fun stop() {
        println("STOPPING redis")
        redisContainer.stop()
    }
}

❶ 创建一个带有挂载数据目录的 Redis 容器

❷ 建立一个命令映射以检索消息

就这些了!要测试,运行 ./mvnw clean test,你将看到你的测试成功运行!

A.5 检查和初始管道

对于检查,我们使用一个名为 ktlint 的开源工具,它是由 Pinterest 作为开源项目编写和维护的。它支持可下载的独立应用程序以及 Maven 插件。在这里,我们将使用预构建的二进制文件,使安装更加简单直接。我们通常将检查作为管道的第一步,因为它是最简单且通常是运行最快的步骤。让我们创建我们的管道,直到构建和部署,我们将在下一节中完成。

.github/workflows/pipeline.yml 中创建一个新的工作流程文件,使用以下列表中的代码。

列表 A.10 pipeline.kt

name: Kotlin Checks

on:
  push:
    branches:
      - main
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  format-check:
    name: Check formatting
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-java@v3           ❶
      with:
        distribution: 'temurin'
        java-version: '17'
        cache: 'maven'
    - name: Download Ktlint                 ❷
      run: curl -sSLO https://github.com/pinterest/ktlint/releases/
      ➥download/0.47.1/ktlint && chmod a+x ktlint
    - name: Lint
      run: ./ktlint
  test:
    name: Test Application
    needs:
      - format-check
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-java@v3
      with:
        distribution: 'temurin'
        java-version: '17'
        cache: 'maven'
    - name: Run Test
      run: ./mvnw clean test                ❸

❶ 定义 Java 版本

❷ 安装并运行 ktlint

❸ 删除所有旧文件并运行测试

如果你提交了更改并推送,你应该能看到测试运行并通过检查!

A.6 容器化

最后一步是将这个应用程序放入容器中。为此,我们使用另一个巧妙的 Quarkus 技巧。近年来,已经开发出专门的编译器,允许 Java 代码编译成本地系统代码。这使得你的容器和包体积更小,运行速度更快。此外,你也不再需要过分关注 JVM 的安全补丁和更新;相反,你只需要担心操作系统的安全问题。Quarkus 已经将这项技术添加到 Quarkus 应用程序的构建系统中。Quarkus 甚至为你提供了 Maven 步骤和 Docker 容器来使用,因此我们可以立即将最后一个任务添加到我们的管道中,并验证它是否工作(见以下列表)。

列表 A.11 pipeline.kt

name: Kotlin Checks

jobs:
...
  containerize:
    name: Build and Push Container
    runs-on: ubuntu-latest #
    needs: test
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-java@v3
      with:
        distribution: 'temurin'
        java-version: '17'
        cache: 'maven'
    - name: Build
      run: ./mvnw package -Pnative -Dquarkus.native.container-build=true   ❶
    - name: Build Container
      run: docker build -f src/main/docker/Dockerfile.jvm -t
      ➥gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:kotlin-latest       ❷
    - name: Set up Cloud SDK
      uses: google-github-actions/setup-gcloud@main
      with:
        project_id: ${{ secrets.GCP_PROJECT_ID }}
        service_account_key: ${{ secrets.gcp_credentials }}
        export_default_credentials: true
    - name: Configure Docker
      run: gcloud auth configure-docker --quiet
    - name: Push Docker image
      run: docker push gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:
      ➥kotlin-latest
    - name: Log in to the GHCR
      uses: docker/login-action@master
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    - name: Tag for Github
      run: docker image tag gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:
      ➥kotlin-latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:
      ➥kotlin-latest
    - name: Push Docker image to GCP
      run: docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:
      ➥kotlin-latest                                                      ❸

❶ 构建本机 Java 二进制文件

❷ 使用内部 Dockerfile 构建容器

❸ 将容器推送到注册表

一旦你推送了最后一个更改,下载你的容器或将其部署到你的 Kubernetes 集群中,看看它是如何工作的!

Kotlin 和 Quarkus 在 Java 语言和框架的世界中都是相对较新的。这使得它们准备好应对当前软件开发中的挑战。虽然本章只是触及了表面,但我鼓励你深入挖掘并多做实验,因为 JVM 语言不会消失,你可能会在未来发现很多帮助迁移和改进 Java 应用程序的工作。

附录 B:使用 Python

在这个附录中,我们将使用 Python 3.8 和已安装的pip使用 Python 特定的工具构建一个 API 和管道。请确保你已经安装了它(www.python.org/downloads/)。

B.1 Poetry

在我们构建项目之前,我们需要创建一个可重复的工作环境。Python 遵循在多个项目之间共享库的常见做法。像 C、Java 甚至 Go 这样的语言使用一个中央库仓库,这些库被下载并存储在您的机器上。这个过程的问题在于,如果您没有跟踪版本,下一个设置开发环境的人可能会有不同版本的库,这可能会引入新的问题或破坏当前的功能。因此,这些语言中的大多数都存储库的版本,以便环境可以几乎完全相同地设置。

使用 Go,我们有一个go.mod文件,它将允许 Go 重新下载给定模块的所有依赖项。Python 为我们提供了一个非常基本的选项,称为虚拟环境,它允许开发者像在没有任何额外库的新安装的 Python 上运行一样操作。任何后续的库都是通过一个名为pip的工具安装的。安装完成后,你可以创建一个需求文档。这个文档是通过pipfreeze命令创建的,该命令输出所有已安装的库。如果我们想在应用程序的依赖项中更明确地指定某些内容,那么我们需要使用一个名为 Poetry 的单独工具来处理库、安装和构建。

首先,我们需要通过运行以下命令来安装 Poetry:

curl -sSL https://install.python-poetry.org | python3 -

然后我们通过输入以下命令来创建一个新的项目:

poetry new hello-api

简单到这个程度!现在让我们编写我们的 API。

B.2 编码

我们将使用一系列工具来托管我们的 API。创建处理器的框架称为 FastAPI,这是一个相对较新的路由库,运行速度快。它将在uvicorn服务器上运行。和之前一样,我们的后端需要一个 Redis 数据库,因此我们还需要这些依赖项。要将这些库添加到我们的项目中,我们只需输入以下命令:

poetry add fastapi \
uvicorn redis

库将被安装,并且你的pyproject.toml文件将更新以包含这些依赖项。现在我们可以创建我们的应用程序。在hello_api目录中创建一个名为app.py的新文件,并添加以下列表中的代码。

列表 B.1 app.py

from fastapi import FastAPI, Depends
import hello_api.deps as deps
from hello_api.repo import RepositoryInterface

app = FastAPI()                ❶

repo = deps.redis_client       ❷

@app.get("/translate/{word}")
def translation(
    word: str, language: str = "english", repo: RepositoryInterface =
    ➥Depends(repo)
):                             ❸
    resp = repo.translate(language, word)
    return {"language": language.lower(), "translation": resp}

❶ 创建一个基本处理器

❷ 加载 redis 客户端

❸ 将 Redis 客户端注入到处理器中

这个处理器应该看起来很熟悉。在这里,我们创建了一个应用程序、一组依赖项和一个翻译单词的路由。我们的翻译函数需要一个接口和一个依赖项来满足这个接口,所以让我们创建这些。首先,我们在一个名为repo.py的文件中创建接口,代码如下所示。

列表 B.2 repo.py

class RepositoryInterface:                                  ❶
    def translate(self, language: str, word: str) -> str:
        """translates word into given language"""
        pass

❶ 建立一个可以鸭式类型化的接口

虽然我们称这为一个接口,但从技术上讲它并不是,因为 Python 没有显式的接口类型。这是一个利用 Python 的鸭子类型系统的简单方法,就像我们在 Go 中做的那样。这个接口更像是抽象类,其中方法不应该被实现,而只是被定义。我们只需要在我们的redis.py中实现接口,如下面的列表所示。

列表 B.3 repo.py

import redis
import os
from hello_api.repo import RepositoryInterface

class RedisRepository(RepositoryInterface):                     ❶

    host: str = os.environ.get("DB_HOST", "localhost")
    port: str = os.environ.get("DB_PORT", "6379")
    default_language: str = os.environ.get("DEFAULT_LANGUAGE", "english")

    def __init__(self, client=None) -> None:                    ❷
        if client is None:
            self.client = redis.Redis(host=self.host, port=self.port)
        else:
            self.client = client

    def translate(self, language: str, word: str) -> str:       ❸
        """translates word into given language"""
        lang = language.lower() if language is not None else
        ➥self.default_language
        key = f"{word.lower()}:{lang}"
        return self.client.get(key)

❶ 实现接口

❷ 实例化客户端或设置可选的客户端变量

❸ 满足接口

在这里,您可以看到我们扩展了接口并实现了其方法。我们可以创建我们的连接并处理请求。最后一步是通过创建一个名为deps.py的文件来利用 FastApi 的依赖注入工具,该文件将包含运行服务所需的依赖项的函数,如下面的列表所示。

列表 B.4 deps.py

from hello_api.repo import RepositoryInterface
from hello_api.redis import RedisRepository

def redis_client() -> RepositoryInterface:    ❶
    return RedisRepository()

❶ 用于 FastAPI 依赖注入的必需项

要运行您的应用程序,请输入

uvicorn hello_api.app:app

现在您应该可以开始业务了!现在让我们添加我们的检查。

B.3 测试

我们将使用与在 Go 章节中不同的方法来测试我们的 Python 应用程序。我们不会使用 Redis 容器来模拟数据库连接,而是将使用一个内存中的 Redis 替代品,称为redislite。我们将使用依赖注入来替换实际连接,并且一切都应该相同。我们需要通过运行以下命令添加两个测试库,以便测试能够工作:

poetry add redislite \
requests

创建一个名为tests的目录,并添加下面的列表中的代码。

列表 B.5 test_hello_api.py

from hello_api.app import app, repo
from fastapi.testclient import TestClient
from redislite import Redis

from hello_api.repo import RepositoryInterface
from hello_api.redis import RedisRepository
import unittest

class AppIntegrationTest(unittest.TestCase):
    def redis_client(self) -> RepositoryInterface:            ❶
        self.fake_redis = Redis()
        self.fake_redis.set("hello:german", "Hallo")
        self.fake_redis.set("hello:english", "Hello")

        return RedisRepository(client=self.fake_redis)

    def setUp(self):                                          ❷
        self.repo = self.redis_client()
        self.client = TestClient(app)
        app.dependency_overrides[repo] = self.redis_client    ❸

    def test_english_translation(self):
        response = self.client.get("/translate/hello")
        assert response.status_code == 200
        assert response.json() == {"language": "english", "translation":
        ➥"Hello"}

    def test_german_translation(self):
        response = self.client.get("/translate/hello?language=GERMAN")
        assert response.status_code == 200
        assert response.json() == {"language": "german", "translation":
        ➥"Hallo"}

❶ 创建模拟 redis 客户端的内部函数

❷ 设置测试

❸ 使用内部函数覆盖依赖函数

现在我们应该能够验证我们的测试是否工作。为此,我们将使用一个工具来帮助我们保持测试和格式的标准化。

B.4 Nox

Nox 是一个开源工具,允许您组织和标准化您的测试和代码检查脚本。要使用此工具,您必须全局安装 Nox,因此打开一个新的终端窗口,并输入以下命令:

pip install --user --upgrade nox

接下来,我们在项目的根目录下创建一个noxfile.py文件。然后,我们将下面的列表中的选项添加到文件中。

列表 B.6 noxfile.py

import nox

nox.options.sessions = "lint", "tests"                   ❶
locations = "hello_api", "tests", "noxfile.py"           ❷

@nox.session
def tests(session):
    session.run("poetry", "install", external=True)      ❸
    session.run("pytest")

@nox.session
def lint(session):
    args = session.posargs or locations
    session.install("flake8", "flake8-black")
    session.run("flake8", *args)

❶ 可以完成的作业

❷ 可以完成作业的文件

❸ 执行命令

就这样!请注意,Nox 使用flake8作为我们的代码检查工具。我们想要对其进行一些配置,为此,我们需要创建一个.flake8文件,并在下面的列表中添加代码。

列表 B.7 \.flake8

[flake8]
select = E123,W456      ❶
max-line-length = 88    ❷

❶ 定义代码检查规则

❷ 设置最大行宽

Nox 将为我们处理其余的工作。要执行代码检查和测试,我们需要运行以下命令:

nox -rs lint
nox -rs tests

您将看到脚本将依次通过这些阶段,并输出正确的结果。我们已经将代码检查和测试合并,这是在管道定义它应该运行的容器之前的最后步骤。

B.5 定义容器

打包脚本语言与打包编译语言略有不同。在我们的 Go 和 Kotlin 示例中,我们构建了应用程序,并留下了一个可以分发和复制的文件。在 Python 和 JavaScript 等语言中,正确设置脚本运行的环境变得更为重要;否则,它们将在启动时或在请求过程中失败。这就是我们选择使用 Poetry 这样的打包和依赖管理器的原因。它将为我们管理所有这些。让我们定义我们的容器,如以下列表所示。

列表 B.8 Dockerfile

FROM python:3.10.7-slim-bullseye as base

ENV PYTHONFAULTHANDLER=1 \                                              ❶
    PYTHONHASHSEED=random \
    PYTHONUNBUFFERED=1

WORKDIR /app

FROM base as builder

ENV PIP_DEFAULT_TIMEOUT=100 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_NO_CACHE_DIR=1 \
    POETRY_VERSION=1.1.15

RUN pip install "poetry==$POETRY_VERSION"                               ❷
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false \
  && poetry install
COPY hello_api hello_api
ENV PORT 8080
EXPOSE 8080
CMD ["uvicorn","hello_api.app:app","--port","8080","--host","0.0.0.0"]  ❸

❶ 为容器设置生产级别的环境变量

❷ 安装 poetry

❸ 运行服务器

我们已经有了 Dockerfile,可以继续到我们的管道。

B.6 创建管道

此管道将进行代码检查、测试和构建容器。要开始,请在 .github/workflows/pipeline.yml 中创建一个新的工作流程文件,并添加以下代码。首先,我们将设置 Nox 来运行我们的代码检查器(见以下列表)。

列表 B.9 pipeline.yml

name: Python Checks

on:
  push:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  format-check:
    name: Check formatting
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-python@v4
      with:
        python-version: '3.10'
    - run: pip install --user --upgrade nox    ❶
    - name: Run Check Lint
      run: nox -rs lint                        ❷

❶ 安装 Nox

❷ 运行 Nox 代码检查

然后,在下一个列表中,我们将使用 Nox 来运行我们的测试。

列表 B.10 pipeline.yml

name: Python Checks
...
jobs:
...
  test:
    name: Test
    runs-on: ubuntu-latest
    needs: format-check
    steps:
        - uses: actions/checkout@v3
    - uses: actions/setup-python@v4
      with:
        python-version: '3.10'
    - run: pip install --user --upgrade nox
    - name: Run Tests
      run: nox -rs tests    ❶

❶ 使用 Nox 运行测试

最后,我们将在以下列表中构建和分发我们的容器。

列表 B.11 .pipeline.yml

name: Python Checks
...
jobs:
...
  containerize:
    name: Build and Push Container
    runs-on: ubuntu-latest #
    needs: test
    steps:
    - uses: actions/checkout@v3
    - name: Build Container
      run: docker build -t gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-
      ➥api:python-latest .                                              ❶
    - name: Set up Cloud SDK
      uses: google-github-actions/setup-gcloud@main
      with:
        project_id: ${{ secrets.GCP_PROJECT_ID }}
        service_account_key: ${{ secrets.gcp_credentials }}
        export_default_credentials: true
    - name: Configure Docker
      run: gcloud auth configure-docker --quiet
    - name: Push Docker image
      run: docker push gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-
      ➥api:python-latest
    - name: Log in to the GHCR
      uses: docker/login-action@master
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    - name: Tag for Github
      run: docker image tag gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-
      ➥api:python-latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:
      ➥python-latest
    - name: Push Docker image to GCP
      run: docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:python-
      ➥latest

❶ 构建 Docker 镜像

由于 Python 生态系统很大,此时可能有多种创建和部署 Python 应用程序的方法。关键是可以通过工具和流程来保护代码并减少错误。这对于像 Python 这样的语言尤为重要,因为它们以类型安全和编译时错误为代价换取灵活性,从而允许快速开发。

附录 C. 使用 JavaScript

Node.js 是一个 JavaScript 运行时环境,允许开发者编写在网页浏览器之外运行的应用程序。这导致了 JavaScript 库的增长,帮助开发者编写后端服务。我们可以使用工具连接到数据库并创建 API。让我们开始吧。

C.1 Node 包管理器

NPM,或 Node 包管理器,是我们用于 API 的核心构建和依赖管理工具。我们将使用 Express 库。首先,确保您已从 nodejs.org/en/download/ 安装了 Node;然后安装 Express 生成器应用程序:

npm install express-generator -g
express hello-api

代码生成完成后,使用以下列表中的代码编辑 package.json 文件。

列表 C.1 package.json

{
    "name": "hello-api",                          ❶
    "version": "0.0.1",
    "description": "Simple api",
    "private": true,
    "jest": {                                     ❷
        "testEnvironment": "node"
    },
    "scripts": {                                  ❸
        "test": "jest --config jest.config.js",
        "start": "node ./bin/www",
        "format": "prettier --single-quote --write --use-tabs .",
        "check-format": "prettier --single-quote --use-tabs --check .",
        "lint": "eslint \"**/*.js\" --max-warnings 0 --ignore-pattern
        ➥node_modules/"
    },
    "author": "Joel Holmes",
    "license": "MIT",
    "dependencies": {                              ❹
        "debug": "⁴.3.4",
        "express": "⁴.18.1",
        "morgan": "¹.10.0",
        "redis": "⁴.3.0"
    },
    "devDependencies": {                           ❺
        "eslint": "⁸.23.0",
        "eslint-plugin-jest": "²⁷.0.1",
        "jest": "²⁹.0.0",
        "prettier": "².7.1",
        "supertest": "⁶.2.4",
        "testcontainers": "⁸.13.1"
    }
}

❶ 包的名称

❷ Jest 配置设置

❸ NPM 内置了执行机制,可以根据本地安装的包运行脚本。

❹ 运行应用程序所需的库

❺ 用于测试或开发应用程序的库

package.json 文件包含了测试、构建和运行我们的应用程序所需的全部依赖和脚本。在做出这些更改后,输入 npm install 以将这些依赖项添加到我们的项目中。我们已经设置了基本结构,现在可以开始编码了。

C.2 编码

您会注意到 Express 为您生成了很多文件。主要的一个是 app.js。这是您进入 API 的入口路由。打开它,并用以下列表中的代码替换它。

列表 C.2 app.js

let express = require('express');                                 ❶
let logger = require('morgan');

let translateRouter = require('./routes/translation');            ❷
const { Repository } = require('./repository/translation');       ❸

class App {
    app = express();
    repo = undefined;
    constructor(host, port) {                                     ❹
        this.app.use(logger('dev'));
        this.app.use(express.json());
        this.app.use(express.urlencoded({ extended: false }));
        this.repo = new Repository(host, port);
        this.app.get('/translate/:word', translateRouter(this.repo));
    }

    async close() {
        return this.repo.close();
    }
}

module.exports = { App };                                         ❺

❶ 导入 Express 框架

❷ 导入翻译处理器

❸ 导入存储库

❹ 构建应用程序类

❺ 导出应用程序以供运行中的应用程序使用

希望您能注意到生成的代码与您所写的代码之间的差异。我们使用 JavaScript 的面向对象范式来帮助我们进行未来的单元测试。

我们需要定义一个路由和存储库。让我们首先定义路由。创建一个名为 routes 的目录。这个目录将存放我们的 translation.js 文件,也将是您想要定义的任何其他路由或路由组的存放位置。创建该文件,并添加以下列表中的代码。

列表 C.3 translation.js

const translation = (translationService) => {                            ❶
    return async (req, res) => {
        let language = req.query.language || 'english';                  ❷
        const resp = await translationService.translate(language,
        ➥ req.params.word);
        resp                                                             ❸
            ? res.json({ language: language.toLowerCase(), translation:
              ➥ resp })
            : res.status(404).send('Missing translation');
    };
};

module.exports = translation;

❶ 处理器的功能定义

❷ 如果未传递查询参数,则默认为英语。

❸ 如果提供了响应,则返回它;否则,返回 404。

如您所见,此函数需要将翻译服务传递给函数。这与我们在其他语言中使用依赖注入和更强的类型检查所做的方法不同。相反,我们传递函数以处理业务逻辑。或者,我们也可以创建一个类,就像我们在主应用中所做的那样。

注意:我们按照功能而不是按领域组织文件,这意味着我们将所有路由放在一个目录中,并将每个目录中的存储库方法放在每个目录中。另一种方法是有一个 translation 目录,其中包含 repo.jsroute.js 文件。

现在我们需要定义翻译仓库。创建一个名为repository的目录和一个名为translation.js的文件,并添加以下列表中的代码。

列表 C.4 translation.js

const redis = require('redis');

class Repository {
    constructor(host, port) {
        this.host = host ? host : process.env.DB_HOST || 'localhost';
        this.port = port ? port : process.env.DB_PORT || '6379';
        this.defaultLanguage = process.env.DEFAULT_LANGUAGE || 'english';
        const connectionURL = `redis://${this.host}:${this.port}`;
        console.log(`connecting to ${connectionURL}`);
        this.client = redis.createClient({ url: connectionURL });
        this.client.on('connect', () => {                             ❶
            console.log('connected to redis');
        });
        this.client.on('error', (err) => console.log('client error',
        ➥ err));                                                     ❷
        this.client.connect();
    }

    async translate(language, word) {
        const lang = language                                         ❸
            ? language.toLowerCase()
            : this.defaultLanguage.toLowerCase();
        const key = `${word.toLowerCase()}:${lang}`;
        const val = await this.client.get(key);
        return val;
    }
    async close() {
        this.client.quit();
    }
}

module.exports = { Repository };

❶ 当数据库连接时添加日志记录

❷ 添加错误日志记录

❸ 检查语言;如果没有指定,则使用默认值

在这里,我们建立与 Redis 数据库的连接和一个用于检索翻译的功能。为了测试这个,打开一个终端窗口并输入npm start。我们有一个正在运行的 API;让我们来测试它。

C.3 测试

我们将直接使用测试框架 Jest 和一个名为testcontainers的容器库进行集成测试,它将为我们提供 Redis 数据库。在我们编写测试之前,我们想要为我们的测试框架 Jest 创建一个配置文件。创建一个jest.config.js文件,并添加以下代码:

module.exports = {
    testTimeout: 30000,
};

这将给我们的容器提供启动时间,以便在测试之前。现在我们可以使用以下列表中的代码编写我们的集成测试。

列表 C.5 app.test.js

const { GenericContainer } = require('testcontainers');
const { App } = require('./app');
const request = require('supertest');

let container;
let app;
let api;

beforeAll(async () => {                                               ❶
    container = await new GenericContainer('redis')
        .withExposedPorts(6379)
        .withCopyFileToContainer('./data/dump.rdb', '/data/dump.rdb')
        .start();
    const port = container.getMappedPort(6379);
    const host = container.getHost();
    api = new App(host, port);                                        ❷
    app = api.app;
});

afterAll(async () => {
    await api.close();
    await container.stop();
});

describe('Translate', () => {
    test('hello translation in english to be hello', async () => {    ❸
        const response = await request(app).get('/translate/hello');
        expect(response.body).toEqual({
            translation: 'Hello',
            language: 'english',
        });
        expect(response.statusCode).toBe(200);
        return response;
    });
    test('hello translation in german to be hallo', async () => {
        const response = await request(app).get(
        ➥'/translate/hello?language=GERMAN');
        expect(response.body).toEqual({ translation: 'Hallo', language:
        ➥'german' });
        expect(response.statusCode).toBe(200);
        return response;
    });
});

❶ 通过启动容器来设置测试

❷ 将值传递给构建应用

❸ 编写一个测试来调用端点

现在运行npm test来查看测试是否通过。

C.4 代码风格检查

我们下一步是检查格式和静态代码分析。我们将使用 Prettier 和 ESLint 来帮助我们完成这些步骤。在 package.json 代码中,我们添加了运行这些库进行格式化、检查格式和代码风格检查的脚本。要运行这些脚本,只需输入以下命令:

npm run format
npm run check-format

对于代码风格检查,我们需要为我们的代码风格检查器创建一个配置文件(见以下列表)。

列表 C.6 \.eslintrc.json

{
    "env": {                             ❶
        "node": true,
        "commonjs": true,
        "es2021": true
    },
    "extends": "eslint:recommended",
    "overrides": [                       ❷
        {
            "files": ["**/*.test.js"],
            "env": {
                "jest": true
            },
            "plugins": ["jest"],
            "rules": {
                "jest/no-disabled-tests": "warn",
                "jest/no-focused-tests": "error",
                "jest/no-identical-title": "error",
                "jest/prefer-to-have-length": "warn",
                "jest/valid-expect": "error"
            }
        }
    ],
    "parserOptions": {
        "ecmaVersion": "latest"
    },
    "rules": {                           ❸
        "indent": ["error", "tab"],
        "linebreak-style": ["error", "unix"],
        "quotes": ["error", "single"],
        "semi": ["error", "always"]
    }
}

❶ 定义环境

❷ 传递 Jest 格式化规则

❸ 定义额外的格式化规则

现在你可以通过输入以下命令来运行代码风格检查步骤

npm run lint

将这些步骤直接集成到我们的脚本中非常简单。这展示了拥有一个能够满足我们管道多个领域的单一工具的强大之处。在我们组装管道之前,定义容器是最后一步。

C.5 定义容器

与附录 B 中我们创建的 Python 容器类似,JavaScript 需要设置一个环境以便脚本运行。NPM 作为基础镜像的一部分被安装,这样我们就可以运行依赖项的安装过程并运行主应用。以下列表显示了我们的容器定义。

列表 C.7 Dockerfile

FROM node:17                    ❶

# Create app directory
WORKDIR /usr/src/app

# Install app dependencies
COPY package*.json ./

RUN npm ci --only=production    ❷

# Bundle app source
COPY . ./
ENV PORT 8080
ENV NODE_ENV production
EXPOSE 8080
CMD [ "node", "./bin/www" ]     ❸

❶ 基础镜像

❷ 清理并安装生产所需的依赖包

❸ 使用 Express 提供的包装脚本运行

最后,我们可以构建我们的管道。

C.6 构建管道

这个管道与其他章节中的管道类似,它将包含代码风格检查、测试和容器步骤,如下所示。

列表 C.8 pipeline.yml

name: JavaScript Checks

on:
  push:
    branches:
      - main
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  format-check:
    name: Check formatting
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
       node-version: 17
    - run: npm ci                ❶
    - name: Run Check Format
      run: npm run check-format  ❷
    - name: Run Check Lint
      run: npm run lint          ❸

❶ 清理并安装依赖

❷ 运行格式检查

❸ 运行代码风格检查

首先,在我们进行集成测试之前(见以下列表),我们将先进行简单的清理、安装和格式检查。

列表 C.9 pipeline.yml

name: JavaScript Checks
...
jobs:
...
  test:
    name: Test
    runs-on: ubuntu-latest
    needs: format-check
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
       node-version: 17
    - run: npm ci
    - name: Test
      run: npm run test     ❶

❶ 使用 npm 脚本运行测试

当集成测试完成后,我们最终可以构建我们的容器以进行分发(参见下一列表)。

列表 C.10 pipeline.yml

name: JavaScript Checks
...
jobs:
...
  containerize:
    name: Build and Push Container
    runs-on: ubuntu-latest #
    needs: test
    steps:
    - uses: actions/checkout@v3
    - name: Build Container
      run: docker build -t gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:
           ➥javascript-latest .         ❶
    - name: Set up Cloud SDK
      uses: google-github-actions/setup-gcloud@main
      with:
        project_id: ${{ secrets.GCP_PROJECT_ID }}
        service_account_key: ${{ secrets.gcp_credentials }}
        export_default_credentials: true
    - name: Configure Docker
      run: gcloud auth configure-docker --quiet
    - name: Push Docker image
      run: docker push gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:
           ➥javascript-latest
    - name: Log in to the GHCR
      uses: docker/login-action@master
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    - name: Tag for Github
      run: docker image tag gcr.io/${{ secrets.GCP_PROJECT_ID }}/hello-api:
           ➥javascript-latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:
           ➥javascript-latest
    - name: Push Docker image to GCP
      run: docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:
           ➥javascript-latest

❶ 构建容器

附录 D. 使用 Terraform

基础设施即代码不是一个新概念,但多年来,工具已经从物理硬件上的脚本和镜像转变为在云中工作的工具。Kubernetes 和无服务器平台已经将其他工具的聚光灯从构建这些不同云环境中的基础设施的工具上移开。所有不同类型的部署都有其权衡和好处。在本附录中,提供了最后一个工具的示例,以给您提供一个关于部署景观的广泛概述。

D.1 构建镜像

我的第一份工作是在高中时的 IT 部门。在那里工作期间,我能够协助拆箱和布线计算机实验室。当所有计算机都拆箱并插上电源后,我会把它们全部打开,快速敲击键盘以启用网络模式。几分钟内,我会看到安装程序开始运行,一个小时后,我们就有了 20 台完全相同的机器。每周我都会带着惊奇重复这个魔法。

那种惊奇感还没有消失。今天我们需要在云中使用我们的应用程序执行同样的任务。我们不是拆箱物理服务器,而是将我们的镜像推送到临时服务器上。在云中创建服务器时,它是在更大的服务器集群上的一个虚拟机。它可以关闭,然后在建筑物的完全不同的部分,甚至是在完全不同的机器上重新启动。

要构建镜像,我们将使用 Packer([www.packer.io/](https://www.packer.io/)),这是 HashiCorp 的一个工具,它根据提供的规范构建服务器镜像。然后我们将使用以下列表中的代码创建一个 Packer 镜像定义。

列表 D.1 hello-api.pkr.hcl

variable "project_id" {                         ❶
  type    = string
}

variable "git_sha" {
  type    = string
  default = "UNKNOWN"
}

source "googlecompute" "hello-api" {            ❷
  project_id = ${var.project_id}
  source_image_family = "ubuntu-2204-lts"
  image_name = "hello-api-${var.git_sha}"       ❸
  ssh_username = "packer"
  zone = "us-central1-a"
}

build {                                         ❹
  sources = [sources.googlecompute.hello]

  provisioner "file" {
    destination = "/home/ubuntu/hello-api"
    source      = "api"
  }

  post-processor "manifest" {
    output = "manifest.json"
    strip_path = true
    custom_data = {
      sha = "${var.git_sha}"
    }
  }
}

❶ 构建所需输入变量

❷ 我们将构建的基础镜像

❸ 我们将要创建的镜像名称

❹ 特殊构建器,用于将我们的二进制文件复制到镜像中

这将构建一个以合并的提交名称命名的镜像。接下来,我们需要调整我们的账户以能够部署镜像。运行以下列表中的命令。

列表 D.2 hello-api.pkr.hcl

gcloud projects add-iam-policy-binding YOUR_GCP_PROJECT \
    --member=serviceAccount:GITHUB_SERVICE_ACCOUNT_NAME@YOUR_GCP_PROJECT.
    ➥iam.gserviceaccount.com \
    --role=roles/compute.instanceAdmin.v1

gcloud projects add-iam-policy-binding YOUR_GCP_PROJECT \
    --member=serviceAccount:GITHUB_SERVICE_ACCOUNT_NAME@YOUR_GCP_PROJECT.
    ➥iam.gserviceaccount.com \
    --role=roles/iam.serviceAccountUser

gcloud projects add-iam-policy-binding YOUR_GCP_PROJECT \
    --member=serviceAccount:GITHUB_SERVICE_ACCOUNT_NAME@YOUR_GCP_PROJECT.
    ➥iam.gserviceaccount.com \
    --role=roles/iap.tunnelResourceAccessor

这将允许我们的 GitHub 管道使用 Packer 构建镜像。接下来,我们将编写服务器以运行镜像。

D.2 部署镜像

为了定义我们的基础设施,我们使用 Terraform([https://www.terraform.io/](https://www.terraform.io/)),这是另一个 HashiCorp 的工具。Terraform 提供了一种定义服务器、负载均衡器、数据库等语言。它还跟踪您基础设施的状态,这在您需要更改或删除服务时将非常重要。我们需要在本地安装 Terraform,以便我们可以设置一些必要的组件。首先,创建一个名为infra的目录,然后创建一个名为global的子目录。在这些目录中,我们将以下列表中的代码添加到main.tf文件中。

列表 D.3 main.tf

resource "google_storage_bucket" "default" {    ❶
  name          = "hello-api-bucket-tfstate"
  force_destroy = false
  location      = "US"
  storage_class = "STANDARD"
  versioning {
    enabled = true
  }
}

❶ 定义一个状态桶来存储我们当前的基础设施值

然后运行 terraform apply 并确认。这个存储桶将保存我们整个系统的状态,以便我们可以进行更改。如果这个文件存储在笔记本电脑上,其他人就无法进行更改。如果文件丢失,你将丢失基础设施的状态,需要手动修改它。

接下来,我们将定义服务器和部署所需的变量。创建一个名为 infra/server 的目录,并添加以下列表中的代码。

列表 D.4 main.tf

terraform {                                                 ❶
 backend "gcs" {
   bucket  = "hello-api-bucket-tfstate
   prefix  = "terraform/state"
 }
}

resource "google_compute_instance" "hello_api" {            ❷
  name         = "hello-api"
  machine_type = "f1-micro"
  zone         = "us-east1-a"

  boot_disk {
    initialize_params {
      image = ${var.image_name}                             ❸
    }
  }
 lifecycle {
    create_before_destroy = true
  }
  metadata_startup_script = "sudo chmod +x /home/ubuntu/hello-api && sudo
  ➥/home/ubuntu/hello-api"                                 ❹
}

resource "google_compute_firewall" "hello_api" {            ❺
  name    = "hello-api-firewall"
  network = "default"

  allow {
    protocol = "tcp"
    ports    = ["8080"]
  }
  source_ranges = ["0.0.0.0/0"]
}

output "public_dns" {                                       ❻
  value = google_compute_instance.hello_api.public_dns
}

❶ 定义我们将使用的后端和状态文件

❷ 创建服务器实例

❸ 输入镜像名称

❹ 服务器启动时运行的脚本

❺ 在防火墙中创建一个洞以访问服务

❻ 输出端点以调用 API

现在我们可以创建我们的管道。

D.3 创建管道

我们的管道将编译我们的二进制文件,使用 Packer 打包,然后创建一个服务器。在任何提交后,服务器将重新部署(见下一列表)。

列表 D.5 pipeline.yml

name: Terraform Depoyment

on:
  push:
    branches:
      - main

jobs:
  build-image:
    name: Build image
    runs-on: ubuntu-latest
    steps:
    - name: Set up Go 1.x #
      uses: actions/setup-go@v2
      with:
        go-version: ¹.18
    - name: Check out code into the Go module directory #
      uses: actions/checkout@v2
    - name: Build
      run: make build #
    - name: Build Artifact
      uses: hashicorp/packer-github-actions@master
      with:
          command: build
          arguments: "-color=false -on-error=abort"
          target: packer.pkr.hcl
          working_directory: infrastructure/packer
      env:
          PACKER_LOG: 1
          PKR_VAR_git_sha: $(git rev-parse --short "$GITHUB_SHA")
          PKR_VAR_project_id: ${{ secrets.GCP_PROJECT_ID }}
  deploy-server:
    name: Deploy Server
    runs-on: ubuntu-latest
    steps:
    - uses: hashicorp/setup-terraform@v2
    - name: Init
      run: terraform init
    - run: export TF_VAR_image_name=hello-api-$(git rev-parse --short
      ➥"$GITHUB_SHA")
    - name: install
      run: terraform apply -auto-approve

基础设施可能会变得复杂,而这个管道只展示了创建持续部署的简单方法。它与第十章不同,因为这种基础设施代码是自动部署的,而不是手动集成。在管道的输出中,你可以找到需要调用服务的端点。

posted @ 2025-11-14 20:40  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报