简介
Jenkins中的流水线定义开始时很小,而且很容易维护。你写一个Jenkinsfile ,声明几个阶段。没有什么了不起的,简单的,可以理解的代码。随着你对Jenkins和 "作为代码的管道 "的采用在组织内的增长,你会发现其他团队正在复制粘贴管道代码,到处都是。对一个项目有用的东西应该对其他项目有用,对吗?很快,需求变得更加复杂,你的组织将进入一个不可维护的、重复的代码的痛苦世界。
Jenkins通过共享库提供了可重复使用的管道功能的概念。在共享库的帮助下,你可以实现更复杂的逻辑,可以在多个管道中共享。共享库有点类似于其他语言的库,例如JVM世界中的JARs或Go包。
Jenkins用户指南解释了共享库的机制,但对最佳实践的指导很少。在这篇博文中,我将解释我认为是最佳实践的内容。这里描述的许多配方其实并不是专门针对Jenkins共享库的,而是适用于一般的软件开发。
设计共享库
全局变量与类的实现
共享库提供了两种方法来实现可重用的逻辑。
-
全局变量代表一个定义松散的脚本,没有什么结构。这些脚本通常包含一个或多个方法和/或变量。从本质上讲,全局变量只是外部化的脚本,可以被导入到
Jenkinsfile,以分解逻辑。命名并不能完全表达其目的,在谈论共享库术语时,会导致团队成员之间的问题。 -
类的实现代表了脚本的替代品。它们支持一种更加结构化的方法,将功能分解成包和类,如果你在编写应用程序源代码,你可能已经熟悉这种编码方法。类实现的主要好处之一是可以通过Groovy Grape声明和下载外部库。
就个人而言,我不喜欢使用全局变量。暴露出具有全局范围的变量的能力常常导致在追踪其定义和代码中分配新值的位置时的混乱。此外,脚本并不适合于实现更复杂的逻辑,因为它很容易成为意大利面条式的代码。
在大多数情况下,我马上就开始把Jenkins共享库作为类来实现。这种方法对JVM程序员来说感觉更自然,有助于代码的结构化和随着时间的推移不断发展,并使你在实际编写代码测试时处于有利地位。你可以在下面的声明式部分阅读更多关于测试方面的内容。
声明式与脚本式
共享库甚至可以定义一个模板化的管道定义,目的是使典型项目类型标准化。例如,你可能会决定,你的组织中的一个Java项目应该要求改变通过编译、单元测试、集成测试和发布等阶段。
声明式管道和脚本式管道的语法有一些错综复杂的区别,例如,脚本式管道中的stage ,不需要指定嵌套的steps 块。语法差异(尤其是从共享库中导入时)会导致消费者的很多困惑,并导致意外的运行时错误。尽量以声明式语法作为首选来实现共享库。声明式语法可能会在未来看到CloudBees的更多支持和新功能。最重要的是,为你的任何消费者记录这一决定。
API设计
无论你选择使用全局变量还是类的实现,你都必须考虑你想暴露给消费者的方法签名。试着把自己放在其他开发者的位置上,从他们的管道中调用这些功能。作为一般准则,我建议在设计共享库的API时问自己以下几个问题。
-
命名是否表达了它所提供的功能?
-
功能的签名是否有足够的表达力?
-
我是否可能要求最终用户提供一长串的参数?我可以尽量减少参数的数量吗?我是否有可能引入一个数据对象来提供输入值?
-
该功能是否在Groovydoc的帮助下被记录下来?
Groovy作为一种语言,并不强制对变量和方法进行静态类型化。你可以很高兴地用def 来标记所有的东西,或者完全省略类型。我强烈建议不要这样做,因为类型化对消费者来说是一个微妙的文档。只要你能做到,就尽量提供一个类型。这将给消费者一个提示,告诉他们你期待的是什么样的值。
限制
最初,Jenkins共享库可能给你的印象是你在编写普通的Groovy代码。这只是在某种程度上是真的。虽然管道使用Groovy编译器和解析器,但它用一个特殊的解释器运行管道和共享库。这个解释器引起了某些限制,影响了你需要的代码结构方式。
-
它不能很好地处理继承或方法覆盖,这可能导致运行时问题。由此产生的错误信息很难分析和调试,也被称为臭名昭著的CPS不匹配错误。在大多数情况下,我用委托代替继承来解决这个限制。
-
鉴于我上面关于静态类型的建议,你可能会觉得很想使用Groovy的@CompileStatic注解来强制执行编码风格。不幸的是,Jenkins并没有很好地处理这个注解,而是产生了一个运行时错误。
-
与其说这是一种限制,不如说是一种要求。不要忘记通过你的任何一个类来实现
java.io.Serializable,以避免在Jenkins服务器需要重启的情况下管道出现问题。
构建共享库
如果你在IDE中工作,构建Jenkins共享库会变得非常容易。特别是在编写Groovy类时,你会希望有自动补全、类之间的简单导航和编译支持等功能。IntelliJ在从构建定义中导出项目设置方面做得很好。
清单1显示了一个Maven构建脚本样本。打开项目时,将IntelliJ指向该构建脚本,就能自动导出源目录,设置合适的JDK版本,并配置Groovy编译器。请注意,共享库的源目录约定并不遵循标准的Maven约定,因此必须重新配置。
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.bmuschko.jenkins</groupId>
<artifactId>jenkins-shared-lib</artifactId>
<name>jenkins-shared-lib</name>
<version>1.0.0</version>
<properties>
<jdk.version>8</jdk.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<groovy.cps.version>1.30</groovy.cps.version>
<groovy.version>2.4.12</groovy.version>
</properties>
<dependencies>
<dependency>
<groupId>com.cloudbees</groupId>
<artifactId>groovy-cps</artifactId>
<version>${groovy.cps.version}</version>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<version>${groovy.version}</version>
</dependency>
</dependencies>
<build>
<sourceDirectory>src</sourceDirectory>
<resources>
<resource>
<directory>resources</directory>
</resource>
</resources>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<compilerId>groovy-eclipse-compiler</compilerId>
<source>${jdk.version}</source>
<target>${jdk.version}</target>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-eclipse-compiler</artifactId>
<version>3.5.0-01</version>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-eclipse-batch</artifactId>
<version>2.5.8-02</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>
清单1.用Maven构建一个共享库
我试图找到用于编译和解析Jenkins管道的兼容Jenkins版本。我唯一能找到的提示是在Manage Jenkins > About Jenkins下。对于我的Jenkins版本,Maven的GAV是org.codehaus.groovy:groovy-all:2.4.12 。在共享库的构建脚本中,你应该依靠这个确切的版本,以确保最佳的版本兼容性。通过查看依赖性的父POM,你也会得到关于兼容的Groovy版本的提示com.cloudbees:groovy-cps 。
没有人反对用Gradle设置类似的构建。虽然语法完全不同,但配置的本质是一样的。简而言之,应用Groovy插件,声明相关的依赖关系并重新配置源目录。没有必要明确配置Groovy编译器。Groovy插件已经解决了这个问题。
测试共享库
在生产中使用的任何代码都应该被测试。而我所说的不一定是指手工测试。Jenkins的文档没有提供任何关于如何处理这个问题的提示。下面是解决共享库测试的可能方法。
-
设置管道作业的唯一目的是消耗共享库的代码,看看事情的结果如何。迟早,你将不得不经历这种类型的测试,因为没有办法模拟Jenkins的运行时行为。
-
编写单元测试并模拟出调用Jenkins API的每一部分代码。只有当你把共享库写成类的实现时,这种方法才是真正可行的,这样你就可以把适当的抽象放到位。
第2点需要你做一点额外的工作。我将在下面描述这个设置。还有一个项目叫"Jenkins管道单元测试框架",然而,我甚至没有设法用它成功执行一个工作测试案例。
设置构建
为了编写单元测试,你必须决定一个测试框架。最突出的选择是JUnit和Spock。此外,如果你决定使用JUnit,你还需要加入一个模拟框架。清单2中的Maven构建使用了JUnit 5和Mockito。你还可以看到,我将构建配置为查看一个非标准的测试源目录。
pom.xml
<project>
...
<properties>
...
<junit.jupiter.version>5.5.2</junit.jupiter.version>
</properties>
<dependencies>
...
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.0.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
...
<testSourceDirectory>test</testSourceDirectory>
<plugins>
...
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
</plugins>
</build>
</project>
清单2.用Maven测试一个共享库
嘲弄Jenkins的API
你要把自己放在一个良好的位置上,来模拟对Jenkins API的调用。我建议引入一个可以隐藏所有这些调用的接口。你可以在清单3中找到一个例子。你可能只需要添加几个方法,而不是完整的Jenkins API。
JenkinsExecutor.groovy
package com.bmuschko.jenkins
interface JenkinsExecutor extends Serializable {
void stage(String name, Closure config)
String sh(String command)
void echo(String message)
...
}
清单3.将Jenkins的API隐藏在一个接口后面
接口的实现看起来很简单,如清单4所示。首先,你必须注入对Jenkinsscript 的引用。这是在类的构造函数中完成的。该方法只是使用script 引用来调用相关的Jenkins APIs。
DefaultJenkinsExecutor.groovy
package com.bmuschko.jenkins
class DefaultJenkinsExecutor implements JenkinsExecutor {
private final script
DefaultJenkinsExecutor(script) {
this.script = script
}
@Override
String sh(String command) {
script.sh(script: command, returnStdout: true)
}
@Override
void echo(String message) {
script.echo(message)
}
@Override
void stage(String name, Closure config) {
script.stage(name, config)
}
...
}
清单4.通过脚本引用调用Jenkins API
你的共享库中任何需要调用Jenkins API的代码都需要对接口JenkinsExecutor 的引用。例如,下面的类使用了sh 和echo 方法。
MyCustomSteps.groovy
package com.bmuschko.jenkins
class MyCustomSteps implements Serializable {
private final JenkinsExecutor jenkinsExecutor
MyCustomSteps(JenkinsExecutor jenkinsExecutor) {
this.jenkinsExecutor = jenkinsExecutor
}
void execute() {
jenkinsExecutor.sh('ls -l')
jenkinsExecutor.echo('Done!')
}
}
清单5.使用Jenkins的API门面
现在我们把Jenkins的实现细节隐藏在一个接口后面,我们可以简单地为它创建一个模拟对象。清单6中的测试案例用Mockito创建了一个模拟对象JenkinsExecutor ,将该实例注入被测类,并根据需要模拟其行为。
DefaultJenkinsExecutor.groovy
package com.bmuschko.jenkins
import com.bmuschko.jenkins.JenkinsExecutor
import org.junit.jupiter.api.Test
import static org.mockito.Mockito.*
class Test {
JenkinsExecutor jenkinsExecutor = mock(JenkinsExecutor)
MyCustomSteps myCustomSteps = new MyCustomSteps(jenkinsExecutor)
@Test
void "can execute custom steps"() {
when(jenkinsExecutor.sh('ls -l')).thenReturn("""total 1
-rw-r--r--@ 1 bmuschko staff 889 Jun 13 2018 README.adoc""")
myCustomSteps.execute()
verify(jenkinsExecutor).sh('ls -l')
verify(jenkinsExecutor).echo('Done!')
}
}
清单6.在测试中模拟Jenkins的API调用
共享库的版本管理
Jenkins共享库不需要像JVM生态系统中的典型库那样被捆绑或发布。在Jenkins管理部分,你创建了一个对托管代码的SCM仓库的引用。一开始,指向库的master 分支可能听起来非常诱人,然而,其结果是可能出现不可靠的构建。对该分支所做的任何修改都会被消耗管道自动拉下来。虽然这对于推出新功能似乎很方便,但同样的概念也适用于bug。
我强烈建议在版本控制中对你的提交进行标记,并从你的管道中固定到这些标记。有效地,标签充当了共享库的版本。我在过去使用语义版本管理,取得了巨大的成功。
Jenkinsfile
@Library('deployment@4.2.6')
import com.bmuschko.jenkins.Deployment
...
清单7.通过引用一个具体的标签来使用共享库
不言而喻,每个 "发布"(release)(又称标签)都应该在发布说明的帮助下加以记录。发布说明可以看起来像共享库根目录下的一个Markdown或Asciidoc文件一样简单。如果你要向更多的人推出新的版本,这些发布说明将帮助消费者决定他们真正想要采用的功能集。
共享库的文档化
对于大多数消费者来说,Jenkins共享库看起来像一个黑盒子。你可能明白共享库的目的,但如果没有任何文档,你就不知道该怎么调用。我发现有多个层次的文档是有用的。
-
回答 "共享库解决了什么问题 "这一问题的高层次文档。
-
Groovydoc记录共享库的API,回答 "我如何使用它?"的问题。
-
使用实例,展示共享库的代码片段,作为管道的一部分。
1和3的文档可以很容易地作为Markdown和Asciidoc文件添加到同一个资源库中,也可以放在Wiki页面中。Groovydoc需要被生成和发布,以便日后参考。在Maven构建中添加Groovydoc支持并不难。你可以添加一个可用的插件,如清单8所示。
pom.xml
<project>
...
<build>
<plugins>
...
<plugin>
<groupId>com.bluetrainsoftware.maven</groupId>
<artifactId>groovydoc-maven-plugin</artifactId>
<version>2.1</version>
</plugin>
</plugins>
</build>
</project>
清单8.在Maven构建中生成Groovydoc
发布Groovydoc文件就比较复杂了。在Jenkins上托管API文档可能是个不错的开始。假设你已经有了共享库的构建管线,再增加一个生成和发布API文档的步骤就很容易了。下面的列表展示了一个管道实例中的这样一个阶段。
Jenkinsfile.groovy
pipeline {
...
stage('Publish API Docs') {
when {
branch 'master'
}
steps {
sh './mvnw groovydoc:generate'
}
post {
success {
publishHTML(target: [
allowMissing: false,
alwaysLinkToLastBuild: false,
keepAll: true,
reportDir: 'target/groovydoc',
reportFiles: 'index.html',
reportName: 'API Docs'])
}
}
}
}
清单9.在Jenkins流水线中生成和发布Groovydoc
总结
对于有意编写可重复使用的管道逻辑或甚至标准化整个管道定义的组织来说,共享库可以是一个强大的工具。Jenkins对最佳实践没有采取强有力的立场。这篇文章指出了对我来说很有效的配方。我们涵盖了设计、构建、测试、版本和文档方面。我希望你能将这些配方应用于你自己的项目,以避免常见的陷阱。