前言
本文主要讲解在编写Jenkins Pipeline(后续统称Pipeline)时会使用到的一些技巧,这些技巧能够帮助我们编写出结构清晰,易读以及复用度高的Pipeline。本文适合的阅读人群为想优化Pipeline内容和扩展Pipeline适用范围的工程师。
大多数人在编写Pipeline时,往往会由于需求不明确,流程不严谨而导致实现出的Pipeline适用性差的问题。即每次有新需求时,都会拷贝现有Pipeline并进行部分改动。这样的做法随着时间的推移,Pipeline项目的维护难度也会随之增高。并且通常大多数人都会使用Jenkins Web页面去维护Pipeline,每一个Pipeline都是分散在不同位置,这同样也会导致其维护难度的增加。在编写上,若没有在事前统一编写规范,则会导致不同人实现出的Pipeline内容结构也会大不相同,这会给统一管理Pipeline带来难度。笔者希望本文中所介绍的编写技巧以及相关管理方式,能够帮助大家更好编写、管理和使用Pipeline
。
笔者也会将自己的理解在文中进行阐述,这也算是在和大家交流心得的一个过程。若文中有错误的理解和概念,请大家及时纠正;吸纳大家的建议,对于我来说也是很重要的学习过程之一。
(目录)
1. 编写风格
Jenkins官方提供了两种Pipeline的编写风格:一种是声明式(Declarative Pipeline)风格;一种是脚本式(Scripted Pipeline)风格。
其中,声明式Pipeline因其编写格式的有着强规范性,所以这种风格的优势在于可读性强
。但同时也因为它的强规范性,导致在编写声明式Pipeline时会体现出不灵活以及代码内容偏冗长。
而脚本式Pipeline的特点与之相反,在脚本式Pipeline中可以直接使用Groovy语言进行编写,就如同编写Java代码一样(因为Groovy为类Java语言)。在脚本式Pipeline中实现自定义逻辑会更方便,同时还可以对功能代码段封装为方法(函数)或类
。同时,也因为其有灵活性过高,通常也会导致脚本式Pipeline的可读性会差一些。
大家可以根据自己团队的编码风格和代码编写阅读能力来考虑选择使用哪种风格。
Tips: 在这个抉择上,
笔者更倾向于选择使用脚本式风格编写Pipeline。因为这个风格更接近于编写代码,便于研发人员能够更好的上手
。虽然其编写的灵活性可能会使脚本的可读性降低,但该问题可以通过编码规范来解决,就如同我们在编写工程代码是一样的。其次,Groovy是支持面向对象设计的。因此我们还对常用的功能操作进行抽象和封装,增加其复用性。脚本式风格编写还可使我们更方便的调用Jenkins内部方法和插件,因为其核心以及插件也是使用Groovy编写的。在脚本式Pipeline调用它们就如同编写代码是直接引入相应的库后直接调用指定方法(函数)或类对象实例方法即可。
2. Shared Library
Jenkins的Shared Library主要是为了解决Pipeline功能块复用的问题
。Jenkins建议我们将常用的功能块进行抽象和封装,并存放在Shared Library中。当Pipeline中需要使用这些功能时,无需再重复编码,直接引入Shared Library中的已封装好的方法(函数)或类即可使用。
Shared Library通常会选择一种SCM来作为实现载体,例如Gitlab等。通过引入SCM,我们还可以更好对Shared Library中的源码进行版本控制。例如针对不同的环境建立不同的分支,开发环境引用dev分支、测试环境引用test分支以及生产环境引用prod分支中的功能代码。
Shared Library具体的配置方法本文中不再详细阐述,详情可查看官方文档。接下来会介绍笔者认为在使用Shared Library时需要留意的几点。
2.1 Shared Library目录结构设计
官方推荐的目录结构如下:
(root)
+- src # Groovy source files
| +- org
| +- foo
| +- Bar.groovy # for org.foo.Bar class
+- vars
| +- foo.groovy # for global 'foo' variable
| +- foo.txt # help for 'foo' variable
+- resources # resource files (external libraries only)
| +- org
| +- foo
| +- bar.json # static helper data for org.foo.Bar
其中:
- src: 用于存放相对复杂的功能模块
一般将
功能抽象封装为功能类后会选择将其源码放入到src目录中
。在Pipeline中使用时只需要将其作为第三方库引用,并在创建其对象实例后调用相应的实例方法即可。使用方法如下:@Library('zhumuLib@master') // 引入Shared Library import com.zhumu.git.Git; // 引入Shared Library中的封装类 node('internal-build') { stage('Get code by Gitlab') { try { // 按需使用封装类 def gitObject = new Git(this,gitlabCredentialsId); gitObject.git_url = gitLabUrl; gitObject.setGitExtensions([ $class: 'CleanBeforeCheckout' ]); gitObject.pull(gitBranch); log.printInfo('Download Code successful.'); } catch(err) { log.printInfo('Download Code failed.You can find errors in the jenkins ouput.'); error(err.message); } } }
- vars: 用于存放复用度高的简单功能
常常会将
封装为方法(函数)的常用小功能放入到vars目录中
。放到该目录中的方法(函数),无需声明引入,直接在全局任意位置使用。 定义全局方法(/vars/log.groovy)如下:
在Pipeline中调用时,只需使用<文件名称>.<方法(函数)名>的方式引用即可,例如:def printInfo(message) { //format and print message echo """ #################################################################################### ${message} #################################################################################### """ }
@Library('zhumuLib@master') node('internal-build') { stage('Test log print') { // call by use <file name>.<function name> log.printInfo('Download Code successful.'); } }
- resources: 用于存放静态文件。即配置文件,相关的静态文件以及压缩包等等
2.2 引入Shared Library
在Pipeline中引用Shared Library可以按使用需求分为两种形式:
- 引用整个Library
- 引入默认分支(版本)的Shared Library:
在Pipeline顶部添加如下代码:
其中,默认分支(版本)需要提前在Jenkins全局配置里的Shared Library相关配置中设置。可以将经常使用的分支(版本)设定为Default version。@Library('my-shared-library')
- 引入指定分支(版本)的Shared Library:
在Pipeline顶部添加如下代码:
指定分支(版本)直接使用分支名称描述即可。@Library('my-shared-library@master')
- 引入多个Shared Library:
在Pipeline顶部添加如下代码:
@Library(['my-shared-library', 'otherlib@abc1234'])
- 引入默认分支(版本)的Shared Library:
在Pipeline顶部添加如下代码:
- 只引入全局方法
若只需要引入 vars目录下的全局方法,则可在Pipeline中的任意位置添加如下代码:
library('my-shared-library')
之后就可以在其后续代码段中调用封装的全局方法了。
Tips: 通过上述的介绍,笔者认为Shared Library的实现和引用,实际上与其他编程语言中的引入第三方库基本相同。也就是说我们完全可以利用团队内成熟的管理第三方库经验以及引入方式的编码规范来约束Shared Library的扩展和使用。同时,还可以延续针对项目代码的版本控制方式,对Shared Library进行版本控制。
3. 参数定义
通常,大多数人都会在Jenkins Web控制台中为Job配置相关执行参数,以让其能够更加灵活的应对需求。但以这种方式定义和配置参数操作步骤繁杂且重复性高,此时就可以考虑通过编码的方式在Pipeline中定义这些参数。通过编码的方式定义参数,可以节省大量的页面操作步骤;并且还能使这些关键参数与步骤流程都出现在同一个Pipeline源码文件中,提高Pipeline的可读性和增加集中配置维护的特性。 定义参数的语法如下:
properties([
parameters([
// 在此定义参数
])
])
如果有多个参数需要定义,将这些参数作为元素添加到最内层的array中即可。该代码段不需要出现在node{}内部,一般在Pipeline的首部进行参数定义。
以下提供一些常用类型的参数定义方式供参考:
- 字符串类型
properties([ parameters([ string(name: 'gitLabUrl', defaultValue: '', description: 'gitlab url'), ]) ])
- 布尔类型
properties([ parameters([ booleanParam(name: 'backupProject', defaultValue: false, description: '是否需要备份工程'), ]) ])
- Jenkins Credentials类型
properties([ parameters([ credentials( name: 'gitlabCredentialsId', credentialType: 'sshUserPrivateKey', required: true, defaultValue: '', description: '访问工程git所需密钥对应的jenkins密钥id' ), ]) ])
- 选择类型(下拉列表)
properties([ parameters([ choice(name: 'action',choices: ['continue', 'init' ], description: '发版策略:初始化(包含运行时环境安装init) or 正常发版(continue)'), ]) ])
- 罗列git仓库分支类型
properties([ parameters([ listGitBranches( name: 'gitBranch', sortMode: 'DESCENDING_SMART', type: 'PT_BRANCH_TAG', quickFilterEnabled: true, defaultValue: '' ), ]) ])
其他类型的参数定义语法,详见官方文档中properties章节parameters属性的配置说明。
这些参数在Pipeline中的使用方式如下:
node {
if (!(params.projectName.trim() && params.gitlabCredentialsId.trim() && params.hosts.trim())) {
log.printInfo('Please make sure those params are already has value.Some param maybe not get value.');
}
}
即通过params.<参数名称>的形式进行调用即可。
Tips: 对于参数定义,笔者有几点实践经验想分享给大家:
- 关于页面生成参数 在使用编码的形式来定义参数时,如果需要在Jenkins的Job页面上体现出这些参数,则需要
先运行一次Pipeline后Job页面上才会出现参数定义
。
- 参数默认值 在之前的介绍中,大家也能够看到。每一类的参数在定义时都可以指定其默认值。
笔者强烈介意大家在定义参数时为每一个参数都配置默认值
。这样能够增强Pipeline的健壮性和稳定性,使其运行状态是可控的,有预期值的。这个概念在其他领域也是通用的,默认值的设置有助于程序更加稳定的运行。
- API调用 这里所说的API调用指的是:通过调用Jenkins API来触发Pipeline并运行。基于这种方式来运行的Pipeline,若想使用参数来定制化Pipeline,则只能通过在Pipeline中以编码形式提前好定义参数才可实现。因此,这也是笔者为什么建议大家在Pipeline中定义相关参数的一大原因。
在Pipeline中定义参数,一个是使得Pipeline相关的内容都汇聚在一起,二是更加方便与动态调用Pipeline
。
- 参数调用
保存所有参数的变量params实际为Groovy的Map类型数据
。因此一切可用于Map类型数据的方法都可以用于变量params。这一点会有助于我们去获取和使用这些参数。
4. 功能封装
因为脚本式Pipeline是支持在任何位置嵌入Groovy代码段,所以就可以使用Groovy语法来对相关功能进行封装,提高Pipeline的复用性和可读性。 方法(函数)封装的示例如下:
// 方法(函数)定义
void selectMode() {
if (params.containsKey('gitUrl') && params.containsKey('branch') && params.containsKey('pushUserEmail')) {
gitRepo = params.gitUrl.trim();
gitbranch = params.branch.trim();
emailList = params.pushUserEmail.trim() + ',' + params.defaultRecipient.trim();
log.printProgress('Auto mode');
} else {
gitRepo = params.gitLabUrl.trim();
gitbranch = params.gitBranch.trim();
emailList = params.defaultRecipient.trim();
log.printProgress('Manual mode');
}
}
node {
stage('Select Mode') {
selectMode(); // 方法(函数)调用
}
}
由于Groovy支持面向对象,因此还可以将相应的功能块抽象封装为类。类封装的示例如下:
// 类封装
class Git {
def custum_extensions = [];
private String git_url = '';
private String jenkins_credentials_id = '';
Git(jenkins_credentials_id) {
this.jenkins_credentials_id = jenkins_credentials_id;
if (!(this.jenkins_credentials_id)) {
throw new Exception("git url and jenkins user id must be take.");
}
}
public void setGitExtensions(ex_list) {
this.custum_extensions.add(ex_list)
}
public void pull(branch_name='') {
if (!(branch_name && this.git_url)) {
throw new Exception("git branch or git url name must be take.");
}
checkout(
scm: [
$class: 'GitSCM',
userRemoteConfigs: [[ url: this.git_url,credentialsId: this.jenkins_credentials_id ]],
branches: [[ name: branch_name ]],
doGenerateSubmoduleConfigurations: false,
gitTool: 'Default',
submoduleCfg: [],
extensions: this.custum_extensions
],
poll: false
)
}
}
node {
stage('Get code from Gitlab') {
// 类的使用
def gitObject = new Git(this,gitlabCredentialsId);
gitObject.git_url = gitLabUrl;
gitObject.setGitExtensions([ $class: 'CleanBeforeCheckout' ]);
gitObject.pull(gitBranch);
}
}
5. 流程控制
由于能够在脚本式Pipeline的任意位置嵌入Groovy代码,那么就可以利用Groovy的条件语句和循环语句来灵活控制Pipeline的执行流程
。本章节将会介绍两种流程控制思路,供大家参考。
5.1 动态调整流程步骤
Jenkins Pipeline通常会被拆分为多个stage或step。但有Pipeline面对的场景中的大多数步骤都相同,只有其中一些步骤是每一个场景独有的。为了让Pipeline能够支持更多的场景,实现这个需求有两种思路:一是为每一个场景都编写一个Pipeline;二是只实现一个Pipeline,其中包含所有场景的步骤,其在运行时能够自动根据场景来判断应该执行哪些步骤
。
上述方法二的实现思路,就可以通过使用Groovy的条件语句和循环语句来实现。具体实现如下所示:
// 伪代码
envTag = 'Test'
node {
if (envTag == 'Prod') {
stage('Double Check') {
doubleCheck();
}
}
stage('Deploy') { ... }
stage('Notify') { ... }
}
即可通过在流程中控制某些变量的值来达到在流程中灵活控制步骤的执行策略
。
5.2 强制结束
通常流程中不是所有的步骤都是无状态或可逆的,即某些步骤的失败可能会导致后续步骤无法执行或影响下次执行。因此,对于这类步骤的失败处理就变得很重要了。可以通过对这些步骤的失败状态进行异常捕获,并在记录异常状态后强制结束Pipeline
。这样的做法也能够提升Pipeline流程的稳定性和可靠性。
具体实现如下所示:
node {
if (!(params.projectName.trim() && params.gitlabCredentialsId.trim() && params.hosts.trim())) {
// 异常状态记录
log.printInfo('Please make sure those params are already has value.Some param maybe not get value.');
}
error("Some param didn't get value."); // 强制结束Pipeline
}
即通过调用Jenkins的内置方法error()来强制终止Pipeline的运行
。
Tips: 通过在脚本式Pipeline使用Groovy的条件语句来控制Pipeline流程逻辑,从而就可以实现对Pipeline流程的自定义控制。这也是笔者推荐大家使用脚本式风格编写Pipeline的一大原因。这样的做法可以使Pipeline能够更好的支持和兼容多种需求环境。 但是,注意不要过度使用上述方法进行流程控制。过多的流程逻辑判断会降低Pipeline整体的可读性以及复杂性,这会为日后维护Pipeline带来难度。凡事都是物极必反,对于这个"度"的把握,
笔者建议可以在实现后让其他人来阅读Pipeline(或自己重新阅读),若普遍反映流程过长或需要原作者进行讲解才能明白流程,则这时就可以考虑是否应将该Pipeline进行拆分,要考虑当前这些逻辑是否可以在不同的Pipeline中实现
。
6. 状态传递
在大多数的情况下,Pipeline的每一个步骤(stage)都会有些许的关联和联系。这些所谓的“联系”可能会为:已构建的工程包路径、需要压缩的工程目录路径以及本次构建的版本分支名等等。在处理这些需要从一个步骤(stage)传递到其他步骤(stage)中的状态数据时,通常会有两种解决思路: 1. 通过制定规范,将这些状态数据的值固定下来。随后即可将这些状态数据的值硬编码在Pipeline内(因为是一成不变的)。 2. 将步骤(stage)产出的这些状态数据动态的传递给其他步骤(stage)。即在运行时完成这个操作,而这些状态数据的值也是不固定的。
笔者推荐大家使用第二种思路来实现
。因为在有些情况下,需求以及和环境因素是不好控制的。即在这种情况下想通过规范化来约束相关操作是困难的。这时候就可以通过在Pipeline中添加相应的逻辑,使得Pipeline可以自行根据需求和环境来定义状态数据的值并进行传递。
而实现这个思路的方式就有很多种了,这里介绍一种笔者实践过的方法:可以通过利用Groovy的全局变量作为载体来传递状态数据。例如:
.......
// 定义全局变量
@Field String branchName = '';
@Field String projectVersion = '';
.......
node {
stage('Get project version') {
try {
branchName = getGitBranchName(); // 将状态数据存入全局变量
projectVersion = getProjectVersion(); // 将状态数据存入全局变量
log.printInfo('Get Project Version successful.');
} catch(err) {
log.printInfo('Get Project Version failed.You can find errors in the jenkins output.');
error(err.message);
}
}
.......
stage('Upload project package to OSS') {
// 使用全局变量获取状态数据
currentPackage = "${params.projectName.trim()}/${projectVersion}/${branchName}/${params.projectName.trim()}-${projectVersion}-${currentTime}.tar.gz";
uploadToOSS();
}
.......
}
除此之外,还可通过缓存到文件、中间件或使用Jenkins的相关插件等其他方式来实现步骤(stage)之间的状态数据传递。
7. 人工确认
虽然Pipeline能够自动化很多步骤,为研发人员、测试人员以及运维人员带来很多便利。但在一些重要的环境中使用时,如果团队对Jenkins了解不全面或是引入Jenkins的时长还不多的情况下,笔者建议还是需要在一些关键步骤中进行人为介入逻辑较好
。尤其像是在例如准生产和生产环境中使用Jenkins Pipeline时,加入一些需要人为介入的确认和判断步骤可以很好的控制Jenkins Pipeline对这些环境的产生影响的风险。
在Pipeline中,可以使用input插件来实现中断流程、等待人工介入处理的逻辑
。实际体现为需要人工在Jenkins Pipeline Job页面中点击相关的确认按钮或取消按钮来控制Pipeline继续执行与否。相关代码如下:
node {
stage('Manual') {
def deployInfo = """
1.是否已与相关研发人员核对该工程的生产配置?
配置文件(): ${params.projectConfigFileID.trim()}
2.确认下列部署相关信息:
工程名: ${params.projectName.trim()}
工程包(包含分支名与版本号): ${currentPackage}
部署类型: ${params.action.trim()}
是否备份旧工程: ${params.backupProject}
目标机器(): ${params.hosts.trim()}
""".toString();
// 需要人工介入判断确认并进行操作
input(message: deployInfo, id: "${java.util.UUID.randomUUID()}", ok: '开始部署');
}
}
8. 跨节点操作
在搭建Jenkins时,为了满足各类的CI/CD需求以及提高效率,大多数人会选择将Jenkins搭建为集群模式。通过其分布式搭建的方式,可以将不同类型的步骤分配到指定的从节点上运行,即不同的从节点提供类型的服务。例如增加一个专用于构建安卓项目的从节点、增加专用于构建IOS项目的从节点等等。
对于Jenkins的分布式集群,笔者这里先提出一个经过实践总结出的经验:Pipeline运行时的主进程只存在于主节点上
。之所以要强调这个概念,是为了保证在Pipeline中编写分配某些步骤在指定从节点上运行时不会出现逻辑错误。这里笔者举一些例子来说明该概念的重要性:
- 当需求从一个节点到另一个节点上发送(拷贝)文件的需求时,此时如果不考虑使用Jenkins的相关插件来完成,而是选择通过编写Groovy来解决该需求的话,多数人会选择直接调用File库来实现相关逻辑。
- 某一个步骤在指定的从节点上完成后会产出一个文件,此时需要Jenkins去读取这个文件。如果不考虑使用Jenkins的相关插件来完成,而是选择通过编写Groovy来解决该需求的话,多数人会选择直接调用File库来实现相关逻辑。
上述两个案例,如果按照其中所描述的思路去实现,则Jenkins会报“找不到相关文件”的错误。因为Pipeline运行时的主进程只存在于主节点上,因此Pipeline在调用File库时,只会在主节点上尝试打开文件。而又因为这些步骤实际上都是在从节点上执行的,所以这些文件是在从节点上生成和存在的。
对于跨节点操作,可以通过调用Jenkins官方库中的hudson.FilePath类来解决
。该类是专门用于节点之间互访文件或传递文件而设计的。具体的使用方法请参考官方文档,这里给出一个示例供大家参考:
import hudson.FilePath;
import jenkins.model.Jenkins;
def copyFileFromMasterToSlave(String sourceFileBaseDir, String targetFileBaseDir, String fileName, String node = "${NODE_NAME}".toString()) {
// 读取主节点本地文件
def fileOnMaster = new FilePath(new File(sourceFileBaseDir + File.separator + fileName));
if (!fileOnMaster.exists()) {
throw new Exception("File:${sourceFileBaseDir + File.separator + fileName} don't exist on the master node.Please create this file first.");
}
// 创建从节点对应的文件存放目录
def dirOnSlave = new FilePath(Jenkins.getInstance().getComputer(node).getChannel(),targetFileBaseDir);
if (dirOnSlave.exists() && !dirOnSlave.isDirectory()) {
throw new Exception("Dir:${targetFileBaseDir} which maybe is a file exist on the agent node:${node}.".toString());
}
if (!dirOnSlave.exists()) {
dirOnSlave.mkdirs();
}
// 拷贝文件到从节点
def fileOnSlave = new FilePath(dirOnSlave, fileName);
if (fileOnSlave.exists()) {
throw new Exception("File:${targetFileBaseDir + File.separator + fileName} is already exist on the agent node${node}.");
}
fileOnMaster.copyTo(fileOnSlave);
// 检查是否传输成功
if (!fileOnSlave.exists()) {
throw new Exception("File:${targetFileBaseDir + File.separator + fileName} copy failed.");
}
}
Tips: 笔者之所以将这个概念单拿出一个章节来阐述,是想让大家理解在Jenkins分布式集群的Pipeline是如何运行的,以此来帮助大家更好的编写适用于Jenkins分布式集群的Pipeline。 Jenkins的很多插件都能够实现跨集群节点的相关操作,实际上这些插件已经在其内部实现了对Jenkins分布式集群操作所需的相关逻辑。如果正好有插件可以满足你的需求,那这将是最好的情况。 而如果目前没有一款插件可以满足你的需求,那此时可能就需要你亲自编码来解问题了。此时,如果你知道上述所讲的相关概念后,应该可以为你自行编码提供一些帮助,至少能够少走一些弯路。
9. Job联动
在之前的章节有提到过,当一个Pipeline中的逻辑过于复杂时,则可以考虑将其拆分为多个Pipeline,这些Pipeline配合运行来完成相关需求。在Jenkins中,Pipeline运行后会被Jenkins看作为一个Job。那么上述思路就可以理解为多个Job之间进行联动配合完成相关需求
的一个过程了。
在Pipeline中,使用build插件(方法)即可实现在当前Job(Pipeline)中调用触发另一个Job(Pipeline)
。例如:
node {
stage('Call Job') {
// 调用其他Job
build(job: jobName, parameters: [string(name: 'branch', value: branch),string(name: 'pushUserEmail', value: emails),
string(name: 'gitUrl', value: gitUrl)], quietPeriod:0 ,wait: false)
}
}
其中,通过指定Job(Pipeline)名称来告知Job(Pipeline)是要调用哪个Job(Pipeline),其次按需构建被调用Job(Pipeline)所需的参数即可。最后可通过wait参数来告知当前Job(Pipeline)是否有必要等待被调用Job(Pipeline)执行完毕。build插件的具体使用方法可查看官方文档了解。
Tips: 对于这一章节所介绍的内容,将会在下一章节中实际运用到。
10.任务调度器
通过第9章节的介绍,我们得知了Job(Pipeline)之间如何互相联动。借助这个利器,就可以在Jenkins中实现任务调度的概念了。在这里提供一个笔者亲自实践过的案例,希望这个案例能够给予大家一些对于在Jenkins中实现任务调度的思路。
- 需求: 当研发人员将代码提交到Gitlab后,Gitlab会通知Jenkins调用指定的Job(Pipeline)来执行相应工程的CI/CD流程。我们希望当Gitlab通知Jenkins之后,Jenkins能够自动判断应该调用哪些Job(Pipeline),并构建相关参数调用这些Job(Pipeline)。
- 实现思路: 事前,相关人员会将每一个工程多对应的Job(Pipeline)提前建立在Jenkins上。这些Job(Pipeline)的名称有着同一的命名格式,Job(Pipeline)的名称中会包含对应工程名的字段。而Gitlab通知Jenkins的请求中,会带有工程名、版本号和提交者等相关信息。通过在Pipeline中编写相关逻辑来获取Gitlab请求中的这些数据,同时Pipeline利用工程名信息来动态拼接Job名称。最后将解析出的数据构建为Job参数,并配合Job名称来调用指定Job即可。
示例的伪代码如下:
@Library('testLib@master')
import test.Git
properties([
parameters([
string(name: 'jobPrefix',defaultValue: ''),
string(name: 'jobSuffix',defaultValue: ''),
string(name: 'delimiter',defaultValue: ''),
booleanParam(name: 'printErrorMsg', defaultValue: false),
]),
pipelineTriggers([
[
$class: 'GenericTrigger',
token: '',
printPostContent: true,
printContributedVariables: true,
regexpFilterText: '$object_kind $project',
regexpFilterExpression: '',
genericVariables: [
[key: 'object_kind',value: '$.object_kind',expressionType: 'JSONPath'],
[key: 'project',value: '$.project.id',expressionType: 'JSONPath'],
[key: 'branch',value: '$.ref',expressionType: 'JSONPath', defaultValue: ''],
[key: 'gitUrl',value: '$.project.git_ssh_url',expressionType: 'JSONPath', defaultValue: ''],
[key: 'pushUserEmail',value: '$.user_email',expressionType: 'JSONPath', defaultValue: ''],
[key: 'gitlabRequest', value: '$']
],
],
]),
[$class: 'BuildDiscarderProperty', strategy: [$class: 'LogRotator', numToKeepStr: '10']]
])
node('master') {
def emailList = getEmailAddrsByPush(gitlabRequest, [pushUserEmail,]) // 获取所有开发者的邮箱地址
def projectNames = getProjectsByPush(gitlabRequest) // 获取所有涉及build的工程名
if (projectNames) {
log.printProgress("projects:${projectNames}")
log.printProgress("Emails:${emailList}")
} else {
log.printInfo("Don't get project name which need to build")
}
for (pn in projectNames) { // 调度工程
if (pn) {
buildProject(pn, params.jobPrefix.trim(), params.jobSuffix.trim(), params.delimiter.trim(), emailList)
}
}
}
void buildProject(String projectName, String jobPrefix='', String jobSuffix='', String delimiter='', def emailList=[]) {
String emails = emailList.join(',')
String jobName = jobPrefix + delimiter + projectName + delimiter + jobSuffix
try {
build(job: jobName, parameters: [string(name: 'branch', value: branch),string(name: 'pushUserEmail', value: emails),
string(name: 'gitUrl', value: gitUrl)], quietPeriod:0 ,wait: false)
log.printProgress("Dispatch job: ${ jobName }")
} catch(error) {
log.printProgress("Can't build job:${ jobName }.Maybe the job does not exist.")
if (printErrorMsg) {
log.printInfo("Error:${ error.message }")
}
}
}
def getProjectsByPush(String gitlabRequestJsonStr) {
/**
* 获取本次push操作中涉及需要构建的工程名
* 返回存有所有需要构建工程名的数组
*/
def temp = []
def gitlabRequest = readJSON(text: gitlabRequestJsonStr)
for (commit in gitlabRequest['commits']) {
temp.addAll(searchProject(commit['message']))
}
def projects = temp.unique()
return projects
}
def getEmailAddrsByPush(String gitlabRequestJsonStr, def defaultEmailList) {
/**
* 获取本次push操作中涉及到的所有开发者邮箱
* 返回存有所有开发者邮箱的数组
*/
def temp = []
def gitlabRequest = readJSON(text: gitlabRequestJsonStr)
for (commit in gitlabRequest['commits']) {
temp.add(commit['author']['email'])
}
defaultEmailList.addAll(temp.unique())
def emailList = defaultEmailList
return emailList
}
def searchProject(msg) {
/**
* 在commit messages中搜集在本次push commit中涉及到的工程名
* 返回包含本次push commit中涉及到工程名的数组
* 不符合规范的messages返回空
*/
try {
if (msg.indexOf('|') < 0) {
return []
}
temp = msg.split(/\|/)[0].replaceAll("\\s","") // 去空白
if (temp.indexOf(',') < 0) {
projectNames = [temp]
} else {
projectNames = temp.split(',')
}
} catch(error) {
log.printProgress("Search project name failed.ERROR:${ error.message }.")
projectNames = []
}
return projectNames
}
Tips: 通过上述案例,笔者想像大家表明一点: 通过使用Groovy语言以及结合Jenkins的相关插件,可以使Pipeline做一些很多意想不到的事情。实际上很多Jenkins插件的诞生,也是基于这种实际需求而产生的。上述这种思路也可以理解为在编写一个Jenkins的插件。因此,思路很重要,有时思路比技巧更有价值。
11.并行处理
Pipeline中的步骤,一般都是以串型方式执行。此时,如果其中有一个步骤的执行时间过长,则就会导致整个Pipeline的执行时间过长。
通常,一个Pipeline的步骤中总有一些是可以同时进行的。这些步骤的特点在于,它们之间的执行并不会互相影响。对于这些步骤,就可以采用并行执行的方式来执行
。从而可降低整个Pipeline的执行时长,并且还可充分利用相关计算资源。
在Pipeline中通过使用parallel方法(插件)来实现stage(step)的并行执行
。该方法需要接收一个Map类型的变量,变量中每一个key表示一个并行任务名,其对应的value中保存的是该stage(step)中所需要执行的逻辑(即为一个方法,函数式编程);其次,名为failFast的key所对应的value(布尔型)表示是否当其中一个任务执行失败时就结束并行任务的含义。示例如下:
def bulidNodejs(String npmRunCMD, String buildPath, String cacheDir = '', def npmTool = null) {
if (!npmRunCMD) {
throw new Exception("Can't get npm build command.Please set the command first.");
}
String bulidCMD = ''
bulidCMD = "cd ${buildPath} && ${npmRunCMD}"
// 需要返回一个方法体
return {
sh(script: bulidCMD)
if (cacheDir && npmTool) {
npmTool.useNodeModulesCache(cacheDir)
}
}
}
node {
stage('Build') {
// 构造parallel的参数
buildSteps = [
'app01': bulidNodejs(params.appBuildcmd.trim(), "${WORKSPACE}/app"),
'app02': bulidNodejs(params.appBuildcmd.trim(), "${WORKSPACE}"),
'failFast': true
]
parallel(buildSteps) // 并行执行任务
}
}
总结
细心的读者应该已经发现了,笔者是比较倾向于使用脚本式风格来编写Pipeline的。这种风格下的Pipeline有着更为灵活的特性,能够实现、支持更多的需求和场景。
但这种风格的Pipeline的可读性并不是太友好,需要有一定编程基础去阅读。因此笔者在这里给大家一个重要的建议:了解和学习Groovy的基础知识
。其实在声明式的Pipeline中,也可以通过使用script{}来嵌入Groovy脚本的。如果不需要使用Pipeline去完成一些很复杂的需求,则只学需要学习Groovy的基础知识即可。反之,可能就需要学习Groovy的一些进阶、高级用法了。这些高级用法可能会帮助你去更好的编写Shared Library、使用Jenkins插件以及调用Jenkins核心库方法等。
其次,Jenkins的一大核心优势就在于其有着丰富的插件资源。这些插件给给予了Jenkins更多的可能性。一般,大多数人都是通过Jenkins的Web页面来添加和配置插件;而在Pipeline中使用和配置插件是通过调用其相应的方法(函数)来实现的。在之前的章节中也提到过,Jenkins的插件实际上也是使用Groovy来编写的。因此就可以通过阅读和查看相应插件的Doc或源码来学习如何通过编码形式来调用该插件了。Jenkins官方提供了一个包含当前版本支持的所有插件文档索引,笔者强烈建议大家通过这个索引去查找和学习相关插件的使用方法
。
最后,希望本文中所介绍的这些思路能够帮助大家更好的实现Pipeline。