自从核心工作转到后台以来,我就一直在摸索如何去构建更优雅,更容易维护的代码。在这几年的摸索中也渐渐沉淀了一些心得,进入现在的团队后又结合我厂的大环境和持续的思考,逐渐形成了现在较为成熟的一套理论,特此与各位同学分享,也作为一种知识记录。 本文会涉及到多方面的知识,所以我将其拆分了多个子文章,方便以后专项回顾和更,另外文章内容更偏向 Dev 的方向,Ops 内容较少,这是因为很多 Ops 的实践依赖于具体的部署平台,所以这里没有展开讨论

简述 在我们正式开始之前,我想先向大家展示我们已经做到了怎样的地步,又是如何帮助我们保证项目的质量的。

首先每个项目都配置了流水线,流水线触发条件有两个:commit push 和 tag push

两个动作都会触发流水线工作,但是会导致不同的行为,具体来说如下:

commit push:

验证代码是否能够编译通过 执行单元测试,验证单元测试率是否达标然后归档报告 执行 CodeCC 代码检查,验证代码是否符合规范( 安全性、性能、可维护性等 ),归档报告 输出消息到群机器人 tag push:

验证代码是否能够编译通过 执行单元测试,验证单元测试率是否达标然后归档报告 执行 CodeCC 代码检查,验证代码是否符合规范( 安全性、性能、可维护性等 ),归档报告 输出消息到群机器人 构建镜像且推送到镜像仓库 根据配置判断是否自动部署到 k8s 集群 (如果需要部署) 部署完成后输出消息到群机器人 对比下两者,可以发现 tag push 其实就是在 commit push 的基础上多出了 构建并推送镜像 和 自动部署 的步骤,其实它们之间的区别,正是 DevOps 中 CI 和 CD的区别,对 DevOps 不太了解的同学可能不知道什么是 CI 和 CD,不过没关系,正文里我会一一解释。

接下来我要解释以上这些步骤是如何帮助我们提高项目质量的。

验证代码是否能够编译通过

这是第一步,我们必须要确保推送到仓库的代码都是能够编译通过的,也是最基本的,相信谁都不愿意因为拉取了最新代码结果导致项目编译不了,而且还是由于别人的代码引起的,如果你要修复,那么你不得不去了解其他人工作的上下文。如果编译失败,那么会通知代码的迁入者。如下图:

执行单元测试,验证单元测试率是否达标然后归档报告

单元测试的重要性这里不过多解释,可以参考我在码客上的回答: 单元测试的必要性?由谁来写。 流水线会自动运行项目中的单元测试且统计覆盖率,如果覆盖率没有达到项目要求或者单元测试执行失败,都会在群里面通知代码迁入者进行检查,如下图:

执行 CodeCC 代码检查,验证代码是否符合规范( 安全性、性能、可维护性等 ),归档报告

单元测试可以保证业务逻辑的正确性,以及模块的松耦合。但我们还需要检查代码是否符合规范,这里可以使用 CodeCC 来进行。执行完毕后会自动归档报告到产出物中,如下图:

如果需要更细致的代码检查,可以人工做 CodeReview。

输出消息到群机器人

我们执行了以上步骤,产生的结果肯定要告知到相关人( 尤其是关乎业务正确率的测试覆盖率 ),这里我们选择了通知到群机器人而不仅仅是触发人,目的是在于激励同组的同学都赶紧为自己的项目补充单元测试。

点击 点击查看 还可以在线逐行查看代码覆盖的细节,红色代表未覆盖,绿色为已覆盖。如下图:

这样以来可以方便维护人清楚地知道自己需要补充哪些 case 。

构建镜像且推送到镜像仓库

我们的微服务都是部署在 k8s 集群中,所以镜像对我们是必要的。k8s、容器化、镜像、云原生等等概念可能对某些同学来说非常陌生,但是由于文章篇幅有限,也不属于本次的知识范畴内,所以这里不做详细介绍。感兴趣的同学在下面留言,下次我可以写一些关于云原生的东西,比如它是什么,有什么有点,微服务该如何转型等。

所以这里我只简单说下,流水线会自动根据 tag 产生相应的镜像,比如我们的 tag 是 v1.3.3 那么流水线会自动产生一个名为 xxxx:release-v1.3.3 的镜像且推送到镜像仓库中,如下图:

同时可以根据项目下的 CHANGELOG.md 文件来发布公告,告知本版本内容,如下:

根据配置判断是否自动部署到 k8s 集群

通常我们都需要产生版本文件且打包后,再自行到发布平台去发布,我们这里要尽量避免人工操作,人工操作就意味可能出现误操作。我们可以在流水线指定项目是否需要自动部署,如下:

部署完成后输出消息到群机器人

当部署完毕后,会通知到群机器人,如下图:

小结 以上就是我们组目前的流水线,它帮助我们在一周一个甚至一天一个迭代、没有测试人员的情况下保证我们已经发布的功能不受影响,且在一定程度上保证了代码质量。

接下来,让我们正式开始本文:如何去一步一步落地这些东西,以及它们背后更深层的意义。

注意事项 这里有一些注意事项,在开始阅读之前你可能需要注意:

尽管我尽力去做语言无关的描述,但还是需要读者了解,以上的实践都是基于 golang 的项目。 虽然这次文章中的内容是用 golang 实现,但是我在之前的团队和我参与的开源框架中也曾使用 c# 落地过。 希望读者能从本次实践中获取一些模式的启发而不是单纯的拷贝。 什么是DevOps 它的字面很简单,就是 Development 与 Operations。引用一下维基百科的一段话:

DevOps(Development和Operations的组合词)是一种重视“软件开发人员(Dev)”和“IT运维技术人员(Ops)”之间沟通合作的文化、运动或惯例。透过自动化“软件交付”和“架构变更”的流程,来使得构建、测试、发布软件能够更加地快捷、频繁和可靠。

由于没有它没有权威的定义,这里只从我的角度来解释下我理解的 DevOps:我觉得它是一种指导思想,代表工程师,无论开发、测试或者运维,都应该了解彼此的工作内容这样才促进整个项目的各个环节的无缝结合和紧密协作。 虽然我知道在以前的模式当中,由于职责和工作内容的差异化,所以导致整个软件工程当中的参与者都有或大或小的认知隔阂:开发不太了解如何进行更完善的测试和部署环境与开发环境的差异,测试和运维也并不了解软件的设计细节,导致合作过程中需要大量的沟通来达成共识,当然这些沟通能在项目初期大家就做到那是最好不过,但我相信大家都经历过等到开发完成,测试或者部署过程中才发现有矛盾,这时再去解决问题十有八九都会赶工加班,甚至还会造成延期等。

DevOps 通过拉近软件工程中各个过程的沟通和合作,来为项目打造更为可靠的交付模式。

顺带一提,我所在的团队,没有测试和运维……所以我们就是三位一体的,无比紧密(笑)

DevOps相关的一些模式 CI(Continuous integration) CI 翻译为 持续集成,持续 很容易理解,就是指不间断的、连续的,那么 集成 呢。相信了解过软件测试的同学都知道一个名词叫做 集成测试,它意味着将业务相关的所有子模块(包括存储等等,通常都是针对后台系统)都组合起来进行测试。持续集成 中的 集成 也类似,它指我们应当尝试将项目各个子模块进行组装,并保证其正确,我们通常可以通过以下几个指标来确定其正确性:

项目代码是可编译通过,且满足基本规范 测试( 包括单元测试与集成测试 )的结果符合预期( 用例通过且覆盖率满足要求 ) 如果我们能保证以上的指标,基本也能保证已交付的功能不会受到影响。 所以 CI 指的就是在项目主干代码变化之后立即自动进行构建与测试,这样做除了保证了代码质量,更重要的一点是可以让问题提前暴露,不需要到转测甚至交付后才发现,可以有效地减少维护成本( 这也是开源项目为什么都需要 CI, 因为开源项目几乎都没有测试人员, 项目质量必须要通过 CI 来保证 )。

CD(Continuous Delivery) CD 翻译为 持续交付,它是基于 CI 的基础上将已通过测试的成果物都保存起来,方便直接用于部署。通常我们会使用镜像( Image )来存储它们再配合 Docker 或 k8s 来快速交付到任意环境,在一些尚未容器化的团队,则将其打包为版本文件再配合特定的发布系统来完成交付。

CD(Continuous Deployment) 它的英文缩写也是 CD,翻译为 持续部署,它与 持续交付 不同点在于它比后者更进一步:每一次构建成功且打包完毕后都会自动部署到指定环境而不用手动触发。这意味主干代码上每一个经过测试的变动都会反映到特定环境上,完全的自动化。 这确实是一个很棒的模式,但我不建议将用于生产环境,因为可能会因此发布一些预料外的变动。比较好的实践是: 自动部署到 测试环境, 经过充分的测试之后再 手动 部署到生产环境。

模式实践小结 推荐配合版本管理工具的动作来落地这些模式:

如果是一个 commit push,推荐执行 CI,因为开发者这个时候只是想提交一下代码,那么我们只需要保证代码的质量即可 如果是一个 tag push,那么有两种情况: 不需要产生特定包:如 golang 的框架和组件库都使用 git 来控制版本,它既不需要部署也不需要上传版本包,所以推荐执行 CI 或 什么都不做(因为通常在 tag push 之前都已经提交过 commit 了) 如果需要产生特定包并上传到版本管理服务端:如果项目是一个公共库( 如框架 ),它构建完之后还需要发布一下版本包,如 c# 的 nuget, jave 的 maven, js 的 npm等等,那么推荐 CD( 持续交付 ) 如果需要产生特定包并部署:这是应该是最常见的场景,推荐执行 CD( 持续部署 ) 自动部署到测试环境,经过测试后再行部署到生产环境 我给项目组准备了两条流水线模版:

一条 CI 流水线,只执行构建与测试 一条 CD 流水线,默认实现 持续交付,可以通过参数开关配置为 持续部署,也就是之前展示中看到的 autoPublish 参数

没有特殊注明的情况下,本文此后提到的 CD 都指代 “持续部署” 而不是 “持续交付”

实践细节 聊完了 DevOps 的一些基本概念和模式,这里再分享下一些实践细节。

选择CI/CD工具 你可以在 这里 找到目前 Github 最流行的 CI/CD 工具。

我厂就直接使用 蓝盾流水线 吧,毕竟已经是官方钦定工具了 : )

自动构建 这个是最基本的一步,通常来说每个语言都提供了命令行构建的指令的指令,我们只要准备构建环境即可。如果你使用容器来作为构建环境,并且项目使用 golang ,那么推荐你阅读我的另一篇文章 为golang项目打造一个快速构建镜像。 建议使用 Bash 或者 Makefile 来承载构建相关的指令,这样做有几个好处:

自己在本地环境也能方便地使用它们 当构建命令发生变化时,我们不用去修改 CI/CD 的构建命令 比如我们项目就使用 Makefile 来构建项目,构建一个二进制文件只需要运行:

make build 执行测试并输出报告 个人认为测试(包含单元测试和集成测试)是整个流程 最重要的环节,因为它保障了两个指标:

已交付的功能正确性 代码的可测试性:这意味着你的代码保障了最基本程度的松耦合和细粒度,因为紧耦合和粗粒度的模块会让你几乎无法进行单元测试,在 XP(极限编程) 中甚至使用单元测试来驱动设计( TDD ) 测试理论有很多种,我们这里以常见的测试金字塔来讨论,它把测试用例分为三种类型:

e2e 测试( UI 测试 ): 集成测试( 服务测试 ) 单元测试

不要太在意每个层次的名称,很多测试理论都会有不同的命名和划分方法。在我过往的经验中,我喜欢按下面的定义来区分它们:

e2e 测试: 包含业务所有的组件:从用户可操作的入口到背后的存储,都应该覆盖到。 集成测试:通常针对后台服务,它应该覆盖后台所涉及的所有组件,包括关联服务与基础设施(消息中间件与存储等) 单元测试:针对代码中的模块,它应该覆盖各个模块的各种边界条件,以保证单个模块的健壮性。 从上面的介绍来看,我们可以明显地看出,金字塔越高层次的代码越具备价值,因为它是直接测试了整个系统,但是成本也是最高的,你需要业务相关的所有服务都就绪才能开始。低层次的测试虽然并没有测试整个系统,但它成本是最低的。 所以金字塔理论指示一个测试的最佳实践:使用低层次的用例来保障更多边界场景,而高层次的用例来保障基本的系统可用性。

关于测试,有太多细节可以说,有兴趣的同学可以阅读我的另一篇文章 如何优雅地在 go 项目中落地测试,这里就不展开了,只说下几个实践方向:

每个语言都有自己的单元测试框架和命令行执行工具以及测试覆盖率工具,这里只以 golang 为例,使用以下的命令可以轻松执行单测并生成单侧覆盖率报告

执行测试,并生成测试数据

go test -coverpkg=./… -coverprofile=./test_results/coverage.data -timeout=10s ./…

生成测试报告html

go tool cover -html=./test_results/coverage.data -o ./test_results/coverage.html

生成易于阅读的数据测试报告

go tool cover -func=./test_results/coverage.data -o ./test_results/coverage.txt

提取测试覆盖率

grep -o -E ‘[0-9]+.[0-9]+%$’ ./test_results/coverage.txt | tail -n 1 如果你想在 CI 过程中执行 集成测试,可以使用 Postman,它里面自带齐全的用例设置,并且附带命令行工具可以集成到 CI/CD 工具中。如果用 golang 的话可以直接使用它自带的测试框架来执行 集成测试,具体实现参考 [如何优雅地测试你的go代码]。 计算出覆盖率了之后要对覆盖率进行达标判断,并执行相应反馈(比如群消息通知)和 测试报告归档,每个工具和语言使用方法都不尽相同,你可以在 Bash 中完成,如果 CI工具 支持条件判断也可以放在 CI工具 中判断。下面以 蓝盾流水线 为例:

可以看到这里达标判断是放入了 CI工具 当中,根据覆盖率判断是否执行相应插件,考虑到很多同学此前都没有做过单元测试,所以我们组制定的目标很简单:不等于 0%,意味着你的项目中至少应该存在测试用例。 归档报告 得益于蓝盾强大的插件体系,归档报告只需要一个插件即可。这个功能我印象中还没有发现过其他CI工具能够方便做到的,虽然自己写一个文件服务上传下载的复杂度也不算太高。 (如果阅读的同学有使用其他CI工具的还请自己搜索下解决方案了,抱歉orz )

打包产出文件 如果本次执行过程是 CD(由 tag push 触发),那么我们还需要将生成的相关文件打包。主要涉及场景有以下几种可能:

如果是公共库,那么需要执行相应的打包动作(如 nuget, npm, maven 等,用法请自行查阅),go 项目可以不用操作,因为它直接使用 git 的版本管理体系。 如果是可执行的项目,那么需要将产出的可执行问题根据 tag 的内容进行封包,此时又分两种情况: 项目没有容器化:可以直接使用 tar 命令打包后存储到指定的版本服务器上。在蓝盾上可以使用 归档构件插件完成。

项目已容器化:利用 Docker 打包镜像。在蓝盾可以使用 构建并推送Docker镜像 来完成。可以参考 利用蓝盾流水线构建项目镜像(文章尚未完成) 这里还是要强调下:强烈建议使用 tag 来管理版本,这样可以保持代码版本与项目版本保持一致 以前的团队曾经为了避免小改动也要打一个 tag,而使用自动生成的流水号作为包的版本号,结果后来线上出现问题,想要修复却不知道该基于哪个版本来快速进行 hotfix,只能逐条查看提交日志……

部署到目标环境 如前所述,这一步根据不同需求可以分为 持续交付( 手动部署 ) 与 持续部署( 自动部署 )两种模式,不过无论哪一种,最终我们最终要将已存入版本库的文件部署到目标环境。这里可分为两种情况:

项目没有容器化:那么需要在目标服务器分发对应的版本包,同时解压部署。在我的实践过程中有参与过一些传统项目,可以使用 夸克平台 部署,平台也提供对应插件上传版本,然后手动到平台上发布。

项目已容器化:那可以直接部署到任意支持镜像的运行时,比如 Docker 和 k8s。如果你的服务托管于我们组的Serving 你可以直接在蓝盾上使用我们提供的 serving发布 插件完成 全自动 部署,如下图:

消息通知 由于各个 CI 工具支持方式不同,这里不做发散。蓝盾流水线上可以使用 企业微信机器人推送 插件进行消息推送,详细配置可以参考插件使用文档。

最后的一些话 本文的核心目的在于分享我在团队中摸索出来的 一套具体的 DevOps 实践模式,其实背后是通过何种工具实现这个模式中某个环节都不太重要,只希望阅读的同学能够在这些实践经验中找到能对自己团队代码质量提升有的步骤。

由于文章重在讲解 DevOps 相关的自动化实践,所以将 篇幅最长、也最重要的一个环节:该如何落地测试用例,单独写了一篇文章,链接上面有,这里再贴一次:如何优雅地在 go 项目落地测试。(这是我第二次提到测试用例,因为我觉得它真的非常重要)

我一直认为项目质量除了靠开发者自己的能力把关之外,更重要的是要有 一套稳定的流程标准 来保证它 功能正确性 和 可维护性。我接触过的很多团队,项目没有一个单元测试,都是测试工程师在为项目的稳定兜底,而代码质量全靠工程师个人编码风格决定:都是工程师们在把关。

但是人的状态并不是稳定的,当他们陷入赶工的时候、当他们松懈的时候、当他们分心的时候,再厉害的工程师也会出现纰漏。 而 DevOps 正是为了解决这样的困境,是一种将迭代中的工作流程化、自动化的思想,为了完成这样的目标,我们需要跟测试工程师学习如何覆盖更多的边界情况,跟运维工程师学习如何快部署、扩容服务,并把这些东西融入到项目来。

其实经常参与开源项目的同学应该早就发现了:他们没有测试人员,功能的正确性都依赖那些自动化的测试用例。这样,他们才能在来自众多贡献者的变化中保证项目稳步发展。

希望大家都能为自己的团队建立稳定的 DevOps 模式,从难以控制的代码缺陷中走出来 : )