Jenkins Pipeline 使用 Groovy CPS 库执行 Pipeline 脚本。虽然采用 Groovy 解析器和编译器,但与普通 Groovy 环境不同,大部分程序在特殊解释器中运行。
解释器使用 continuation-passing style(延续传递风格,CPS)转换,将代码改写为能够把当前状态保存到磁盘的形式。状态保存在构建目录的 program.dat 文件中,即使 Jenkins 重启,也能继续运行。更多技术背景见 Pipeline: Groovy 插件与 Groovy CPS 库文档。
CPS 转换通常对用户透明,但支持的 Groovy 语言结构存在限制,某些情况下会产生违反直觉的行为。JENKINS-31314 让运行时尝试检测最常见的错误:从未经过 CPS 转换的代码调用经过 CPS 转换的代码。
以下内容会进行 CPS 转换:
- 几乎全部用户编写的 Pipeline 脚本,包括库中的代码。
- 大多数 Pipeline 步骤,包括全部接受代码块的步骤。
以下内容不会进行 CPS 转换:
- 编译后的 Java 字节码,包括 Java 平台、Jenkins 核心和插件、Groovy 语言运行时。
- Pipeline 脚本中的构造函数体。
- 带
@NonCPS注解的 Pipeline 方法。 - 少数不接受代码块、立即执行的步骤,例如
echo和properties。
CPS 代码可以调用非 CPS 代码或其他 CPS 代码;非 CPS 代码可以调用其他非 CPS 代码,但不能调用 CPS 代码。违反此规则时,CPS 解释器无法正确运行,结果往往错误且令人困惑。
常见问题与解决办法
在 @NonCPS 中使用 Pipeline 步骤
有时用户给方法添加 @NonCPS,绕过方法内部的 CPS 转换。这样可以避开 Groovy 语言支持范围的限制,让方法体使用原生 Groovy 语义,也可以提高性能,因为解释器开销较大。
但这类方法不能调用 Pipeline 步骤等 CPS 代码。以下示例无法正常工作:
@NonCPS
def compileOnPlatforms() {
['linux', 'windows'].each { arch ->
node(arch) {
sh 'make'
}
}
}
compileOnPlatforms()
从此方法调用 node 或 sh 不合法,会出现异常行为。日志警告类似:
expected to call WorkflowScript.compileOnPlatforms but wound up catching node
解决方法是去掉并不需要的注解。长期使用 Pipeline 的用户可能受 JENKINS-26481 修复之前的经验影响,以为这里需要注解。
向非 CPS 方法传入 CPS 参数
有些 Groovy 或 Java 方法接收复杂参数,以支持动态行为。常见例子是允许调用方提供对象比较方法的排序函数,见 JENKINS-44924。JENKINS-26481 修复后,许多类似标准库方法已经正常,但仍有部分尚未修复。
以下示例不能正常工作:
def sortByLength(List<String> list) {
list.toSorted { a, b -> Integer.valueOf(a.length()).compareTo(b.length()) }
}
def sorted = sortByLength(['333', '1', '4444', '22'])
echo(sorted.toString())
传给 Iterable.toSorted 的闭包经过 CPS 转换,而 Iterable.toSorted 内部没有,因此结果不符合预期。目前的行为是,toSorted 返回第一次调用闭包时的返回值。本例中 sorted 被设为 -1,日志警告为:
expected to call java.util.ArrayList.toSorted but wound up catching org.jenkinsci.plugins.workflow.cps.CpsClosure2.call
解决方法是确保传入这些方法的参数不经过 CPS 转换。可以把出问题的方法,例如 Iterable.toSorted,封装在另一个方法中,并给外层方法加 @NonCPS;也可以为闭包定义显式类,并为该类全部方法加 @NonCPS。
构造函数
用户有时会在 Pipeline 构造函数中调用 Pipeline 步骤等 CPS 代码。但 Groovy 的 new 对象构造无法进行 CPS 转换,见 JENKINS-26313,因此不能这样做。
下面的构造函数调用了 CPS 方法:
class Test {
def x
public Test() {
setX()
}
private void setX() {
this.x = 1;
}
}
def x = new Test().x
echo "${x}"
构造 Test 时,调用 Test.setX 就会失败,因为 setX 经过 CPS 转换。日志警告为:
expected to call Test.<init> but wound up catching Test.setX
解决方法是:构造函数调用的 Pipeline 方法必须带 @NonCPS,且构造函数不能调用 Pipeline 步骤。
如果确实需要这类 CPS 逻辑,应将其移出构造函数,例如放入静态工厂方法。先调用 CPS 代码,再把结果传给构造函数。
覆盖非 CPS 方法
可以在 Pipeline 中创建类,继承脚本外定义的类,例如 Java 或 Groovy 标准库中的类。但子类覆盖的方法必须带 @NonCPS,且内部不能使用 CPS 代码,否则从非 CPS 上下文调用时就会失败。
以下示例不能正常工作:
class Test {
@Override
public String toString() {
return "Test"
}
}
def builder = new StringBuilder()
builder.append(new Test())
echo(builder.toString())
StringBuilder.append 等非 CPS 代码,不允许调用经过 CPS 转换的 toString 覆盖实现,通常不会得到预期结果。日志警告为:
expected to call java.lang.StringBuilder.append but wound up catching Test.toString
给覆盖方法添加 @NonCPS,并移除其中的 Pipeline 步骤等 CPS 代码,即可解决。
GString 中的闭包
Groovy 允许在 GString 内使用闭包,每次将 GString 用作字符串时重新求值。但 Pipeline 中闭包会经过 CPS 转换,因此这种用法不符合预期:
def x = 1
def s = "x = ${-> x}"
x = 2
echo(s)
日志警告为:
expected to call WorkflowScript.echo but wound up catching org.jenkinsci.plugins.workflow.cps.CpsClosure2.call
解决方法是将原 GString 改为返回 GString 的闭包;新 GString 内使用普通表达式,而不是闭包。在原来使用 GString 的地方调用外层闭包:
def x = 1
def s = { -> "x = ${x}" }
x = 2
echo(s())
误报
有些表达式即使执行正确,也可能错误触发这条警告。遇到此情况,请先检查是否已有重复问题,再为 workflow-cps-plugin 提交新问题。
原文:Pipeline CPS Method Mismatches。作者/维护方:Jenkins 文档维护者。本文为中文翻译,代码及命令保留原文。











暂无评论内容