Jenkins Pipeline CPS 方法不匹配问题

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 文档维护者。本文为中文翻译,代码及命令保留原文。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容