流水线 CPS 方法不匹配

Pipeline CPS Method Mismatches

Jenkins 流水线使用了一个名为 Groovy CPS 的库,来运行流水线脚本。虽然流水线用到 Groovy 解析器和编译器,但与普通的 Groovy 环境不同,他是在一个特殊的解释器中,运行大部分程序的。他用到一种延续传递式(CPS,continuation-passing style)转换,将咱们的代码,转换成一个可以将其当前状态保存到磁盘(在咱们构建目录下,一个名为 program.dat 文件)的版本,并在 Jenkins 重新启动后继续运行。(在 Pipeline: Groovy 插件 页面和 库页面,就可以获取到一些技术背景信息)。

虽然 CPS 转换对用户来说通常是透明的,但对可被支持的 Groovy 语言结构,则有一定的限制,在某些情况下,CPS 转换可能会造成一些违反直觉的行为。JENKINS-31314 就提出了,运行时检测一些最常见错误的尝试:自非 CPS 转换代码,调用 CPS 转换后的代码。以下几种情况,就属于 CPS 转换的情形:

  • 咱们所编写的几乎所有 Pipeline 脚本(包括库中的脚本);

  • 绝大多数的流水线步骤,包括那些占用代码块的全部步骤;

而以下几种情况,则不属于 CPS 转换:

  • 编译后的 Java 字节码,包括:
    • Java 平台自身;

    • Jenkins 的内核与插件;

    • Groovy 语言的运行时。

  • Pipeline脚本中的构造函数主体;

  • 流水线脚本中,任何标有 @NonCPS 注解的方法;

  • 少数几个不需要代码块,且立即执行的 Pipeline 步骤,例如 echoproperties

CPS 转换了的代码可调用非 CPS 转换代码,或其他 CPS 转换代码,非 CPS 转换代码可调用其他非 CPS 转换代码,但非 CPS 转换代码 不得 调用 CPS 转换代码。如果咱们尝试从非 CPS 转换代码调用 CPS 转换代码,CPS 解释器将无法正确运行,从而造成不正确及经常混乱的结果。

常见问题及解决方案

Common problems and solutions

@NonCPS 中使用 Pipeline 步骤

有时,用户会对方法定义应用 @NonCPS 注解,以绕过该方法内部的 CPS 转换。这样做的目的,可能是绕过 Groovy 语言覆盖范围的限制(因为方法的主体将用到原生 Groovy 语义执行),或者是为了获得更好的性能(解释器会带来很大的开销)。不过,此类方法就不得调用 CPS 转换的代码了,比如如 Pipeline 步骤。例如,以下代码将无法运行:

@NonCPS
def compileOnPlatforms() {
  ['linux', 'windows'].each { arch ->
    node(arch) {
      sh 'make'
    }
  }
}
compileOnPlatforms()

在这个方法中,使用 nodesh 步骤是非法的,会导致行为异常。运行此脚本时,日志中的警告如下:

expected to call WorkflowScript.compileOnPlatforms but wound up catching node

要修复这种情况,只需删除那个注解即可,因为不需要他。(在修正 JENKINS-26481 之前,流水线的长期用户,可能认为其是必要的)。

以 CPS 转换了的参数调用非 CPS 转换方法

Calling non-CPS-transformed methods with CPS-transformed arguments

一些 Groovy 和 Java 方法,会将复杂类型作为参数,以支持动态的行为。一个常见的例子,便是是排序方法,其允许调用者指定用于比较对象的方法 ( JENKINS-44924 )。在修正 JENKINS-26481 之后,Groovy 标准库中的许多类似方法就都能正确工作了,但仍有些方法未被修正。例如,以下方法将无法工作:

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 本身在内部却并没有经过 CPS 转换,因此这不会按预期的方式工作。当前的行为是,调用 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 注解该类中的全部方法。

构造器

Constructors

有时,用户可能会尝试在 Pipeline 脚本的构造函数中,使用 CPS 转换了的代码,如 Pipeline 步骤。遗憾的是,通过 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. but wound up catching Test.setX

要解决这种情况,就要确保在 Pipeline 脚本中定义的、从构造器内部调用到的任何方法,都以 @NonCPS 进行了注解,并且构造器没有调用任何 Pipeline 步骤。如果必须在构造函数中调用 CPS 转换代码(如流水线步骤),则需要将与那些 CPS 转换方法相关的逻辑,移出构造函数,例如移入调用了 CPS 转换代码的静态工程方法中,然后将结果传递给构造函数。

非 CPS 转换方法的重写

Overrides of non-CPS-transformed methods

用户可在 Pipeline 脚本中,创建出某个对该 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 转换代码中,调用 toString 的 CPS 转换重写,是不允许的,而且在大多数情况下,都不会按预期运行。运行此脚本时日志中的警告如下:

expected to call java.lang.StringBuilder.append but wound up catching Test.toString

要解决这种情况,可在覆盖方法中添加 @NonCPS 注解,并从方法中移除任何 CPS 转换代码的使用,如 Pipeline 步骤。

GString 中的闭包

Closures inside GString

在 Groovy 里, 使用位处 GString 中的某个闭包是可行的,这样每次将 GString 作为 String 使用时,都会对这个闭包进行计算。然而,在 Pipeline 脚本中,这不会像预期的那样起作用,因为 GString 中的闭包将被 CPS 转换。下面是一个示例:

def x = 1
def s = "x = ${-> x}"
x = 2
echo(s)

在本例中,使用 GString 内的闭包将不起作用。运行此脚本时日志中的警告如下:

expected to call WorkflowScript.echo but wound up catching org.jenkinsci.plugins.workflow.cps.CpsClosure2.call

要解决这种情况,可以一个用到普通表达式,而不是闭包,返回 GString 的闭包,替换原来的 GString,然后在用到原来的 GString 的地方,调用这个闭包,如下所示:

def x = 1
def s = { -> x = "${x}" }
x = 2
echo(s())

误报问题(假阳性)

False Positives

不幸的是,某些表达式即使执行正确,也可能错误地触发此类警告。如果遇到这种情况,请为 workflow-cps-plugin 提交一个新问题(请首先检查是否有重复)。