用DDD思想来编写Pipeline

作为我们使用最广泛的CI/CD工具Jenkins,它对于Pipeline as Code的支持却并不能算友好。在多个项目中使用之后,我发现它存在的主要问题有:

  • Groovy语言的学习成本
  • 调试低效
  • 灵活性差

Groovy语言相对而言还是比较小众的。其设计为一种动态类型的语言,这给编译器、IDE的类型推断带来了困难,从而导致较弱的自动代码提示。除此之外,由于Groovy可以在没有歧义的情况下省略括号和行尾分号,并且如果最后一个参数是一个闭包则可以将其写在函数调用之后,这就导致了相对比较怪异的语法出现。比如,刚接触Groovy的人可能不太能一下子理解下面这段代码的工作原理:

1
2
3
4
a(1) {
something(123)
someother id: 1, message: "test"
}

看起来似乎是函数定义,但其实这里是一个函数调用,其背后的函数定义为:def a(int i, Closure c); def someother(int id, String message);

注意,这里我并不是要说Groovy语言不是世界上最好的语言,而是想说明对于不常用这门语言的同学而言,由于其不够自然而存在一定的学习门槛。比如我就常常忘记Groovy的语法,而当我要重新开始一个项目时,我不得不又花一定的时间重新学习一下,可气的是这时还得不到多少IDE的辅助。大概也是由于Groovy对于新人入门不算友好,Gradle积极去支持了Kotlin语言。

至于调试低效,相信写过较复杂的Jenkins pipeline代码同学们已有同感。编写好代码之后,我们很难编写一个针对它的测试。想要测试我们的pipeline代码,我们不得不把pipeline运行起来看看效果,然后为了触发目标代码的执行,我们可能还要在网页上点来点去,花一定的时间操作好几个网页元素。

说到灵活性差,主要是指对于其他编译工具的集成而言。想要和Jenkins有好的集成,我们不得不编写一个Jenkins插件来实现。所以,我们就看到了大把大把的Jenkins插件。这些插件真的实现了所有原有编译工具提供的特性吗?这些插件质量怎么样,是否存在bug?某一个新的工具是否有插件支持?

说了这么多,可能有人觉得只是在吐槽而已。这里其实是想给大家分享一下另一种编写pipeline代码的方式,我个人长期使用下来发现更为高效。希望对于有相同困惑的小伙伴们有一定的参考价值。

适合实现日常编译运维工作的工具与语言

pipeline完成的工作其实是一些日常的编译运维工作。说到如何支持这些编译运维工作,相信大家最熟悉不过的就是命令行工具了,各种各样的工具几乎都会提供完善的命令行接口。如何组合这些命令行工具来完成这些工作呢?这种场景下,shell应当是比较适合的胶水了。然而shell脚本的功能性却比较弱,比如高级语言里面司空见惯的各种集合功能,在shell里面却没有提供直接的支持。

这里我经常选用的是一种虽然比较古老,但却能方便的实现大部分编译运维功能的工具:make。其实make工具在c/c++的世界里一直是非常流行的,编译过c/c++项目的同学对于make && make install命令一定不陌生。make工具支持的Makefile脚本功能是相当丰富的,但作为我们日常使用进行编译运维,通常只需要一个很小的子集就足够了。使用这样的一个功能很小的子集可以有效降低大家的学习成本,让新手亦能迅速上手。

对于后面要使用的make,我们只需要了解下面几点就够了:

  1. make将一组命令组织成一个task,一个Makefile里面可以存在多个task以支持不同种类的运维工作,task间可以相互依赖。比如:
1
2
3
4
5
6
7
8
9
// Makefile

build-java:
mvn clean package # 注意行首必须为一个或多个tab

build-nodejs:
npm run build

build: build-java build-nodejs
  1. 每一条命令均独立的通过一个子shell去执行,所以命令可以是任意一段合法的shell脚本。
1
2
3
4
5
6
7
8
9
10
// Makefile

modules=a b c
build-java:
for m in $modules; do \
cd $$m && mvn clean package; \
done # 按顺序编译多个模块

build-nodejs:
npm run build
  1. make中我们可以定义变量,这些变量可以初始化一个默认值,可以通过调用task时设置值进行覆盖。

    比如上面的Makefile,我们如果调用时使用命令make build-java modules="a b c d",那么这个build-java将会去a b c d四个目录下分别执行mvn clean package

了解了上面这几个make的特性,相信你已经能想象出很多很多的可能了。比如,一个简单的应用部署可以视为编译、打包、上传到目标机器、重启服务的过程。通过覆盖不同的变量,我们就能复用部署脚本,将应用部署到另一个环境。

我曾经在多个多语言、多工具项目中采用make来进行自动化日常运维工作。几乎在每个项目中,它都给我带来了巨大的效率提升。

即便我们工作在windows下,也无需担心,因为只要使用git,我们就有了一个随时可用的bashgit-bash),然后配以一个windows下编译的make工具,依赖的环境就搭建好了。这里我会给大家推荐用gow,它是一个相比Cygwin轻量得多的常用gnu工具包,只需要10MB左右的空间就够了。

当然如果我们有条件使用windows提供的linux子系统,那就更简单了。

用DDD的思路来编写pipeline

从DDD的角度来看,这里我们所要真正解决的问题是搭建一个CI/CD的pipeline,而Jenkins在这里扮演的是CI/CD的某种具体实现工具。如果条件允许,我们完全可以换用其他的具体实现工具来搭建这个pipeline

对于Jenkins这个第三方工具,使用DDD的思想,我们要如何处理呢?这里我们可以将其视为一个独立的领域,仔细思考就会发现,这种场景与我们在项目中去使用某个第三方服务是没什么两样的。

为了确保我们的核心领域pipeline可以独立健康的发展,不为Jenkins这个第三方工具提供的功能所支配,我们首先需要从核心领域出发来定义业务流程。

对于一个典型的pipeline,我们可以定义这样几个顺序执行的步骤:构建(编译、单元测试、打包) -> 部署到开发环境 -> 运行集成测试 -> 部署到测试环境 -> 部署到高级测试环境 -> 部署到生产环境。

这里的顺序其实是一个潜在的变更点,因为我们完全可以定义另一条pipeline以支持直接部署到生产环境:构建(编译、单元测试、打包) -> 部署到生产环境。

于是这里我们可以抽象这样几个通用的领域能力:构建、运行集成测试、部署到某一环境。

有了这些分析,我们就可以着手编写实现了。用我们上文提到的Makefile脚本来实现这里的领域能力,可以得到类似下面这样的脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
build:
mvn clean verify package

TARGET_HOST=1.1.1.1
deploy:
scp target/xxx.jar ${TARGET_HOST}:/app/
ssh ${TARGET_HOST} "cd /app/ && sh restart.sh"

deploy-dev:
make deploy TARGET_HOST=1.1.1.2

deploy-qa:
make deploy TARGET_HOST=1.1.1.3

integration-test:
cd src/test/integration && npm run test

上述只是一个简单的示例,实际情况可能比这里复杂很多。比如我们可能需要将构建出来的文件发布到一个制品库里面,我们可能需要管理上传制品库的密码。比如我们可能是一个多模块的项目,需要支持多个模块的构建。还比如,我们可能要构建出来的是一个容器镜像。

但是,无论怎样,通过从核心领域出发来考虑问题,我们定义了几个清晰且相对稳定的接口。

有了上面实现的领域能力,下一步就是利用Jenkins制作一个的图形化流水线了。到这里再来看需要编写的pipeline代码,我们会发现需要写的pipeline代码已经变得很少了。主要的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pipeline {
stages {
stage("build") {
steps {
sh "make build"
}
}

stage("deploy dev") {
steps {
sh "make deploy-dev"
}
}

stage("integration test") {
steps {
sh "make integration-test"
}
}

...
}
}

pipeline代码的变少说明我们对第三方工具的依赖变弱了,这正是想要的效果。我们将主要的投资放在了核心域,而不是与第三方的集成。

到这里我们的pipeline代码就基本编写完成了。回顾一下编写好的代码,我们可以发现这样一些好处:

  • 减少了大家不熟悉的Groovy代码的编写
  • 使用大家更为熟悉的shell命令来组织运维脚本,可维护性变高
  • 测试这些自动运维脚本的成本变低了,比如,我们只需要本地运行一下make build就可以测试build相关脚本是否正确的编写
  • 核心领域内的功能可以独立的发展,而不会对实现pipeline的Groovy代码有影响(相对稳定的接口)
  • 设计变得足够灵活,后续如果需要更换Jenkins为另一种工具,这将是很简单的一件事
  • 使用变得足够灵活,某一天即使Jenkins服务出故障了,我们也可以很轻松的在一台开发机器上面手动去完成必要的运维工作

以上这些都是DDD的设计思想带给我们的好处。

回顾整个应用DDD思想的过程,我们按顺序经历了这样几个步骤:识别核心域 -> 从核心领域出发定义领域能力 -> 和其他周边工具进行集成。这是我在应用DDD时的几个主要步骤,虽然本文只是用于解决pipeline这一特定场景下的问题,但是其实这几个步骤在几乎所有场景下都适用的。

可能很多人觉得DDD的思想是一种必须在很复杂的业务场景下才有用的思想,进而怀疑在这个简单的场景下谈DDD是否会显得大材小用。这样的质疑是可以理解的,我了解过的很多DDD的资料也都是针对复杂业务场景来讨论问题。

但是,在我看来,DDD思想中以领域为核心的一些观点,其实是无所谓问题的复杂程度的。因为任何一个简单的业务问题,越往后发展越复杂,最终都可能成为一个复杂的问题。比如上传文件的功能,一开始我们可能可以用几行代码就实现了,但是随着系统的发展,我们可能要进一步跟踪进度、支持多文件、做文件类型限制等等,这个功能很容易就变成需要上千行代码才能实现的功能。

如果DDD仅限于解决复杂问题,那么它就失去了在问题还比较简单时就去解决它的先机。事实恰恰相反,最好的实践DDD的方式就是从一开始就应用这样的设计思想。

所以,对于任意复杂程度的问题,只要我们应用DDD的思想来进行思考,我们几乎都能从中获益。

最后,希望这里的分享的一些经验对大家有所帮助。有任何问题,欢迎留言交流。

相关文章:代码中的领域