Jenkins Job DSL の処理の流れについて
2022年にもなって今更? という感じもあるが、ふとしたきっかけで調べたので備忘録的にまとめておく。 Jenkins, Groovy のどちらも詳しくなく、実装を斜め読みして把握しただけなので、記述内容の信頼性は低い。
また、ある程度 Groovy 自体の言語機能を把握していないと読みづらい部分があるので、雰囲気でジョブを記述している自分のような人向けに、そのあたりも一緒にまとめておく。
前提知識
Jenkins: ジョブ設定
Jenkins のジョブ設定は、 {{ JENKINS_HOME }}/jobs/{{ JOB_NAME }}/config.xml
に配置されている。また、このジョブ設定ファイルは http://{{ JENKINS_WEB_SERVER }}/job/{{ JOB_NAME }}/config.xml
のように HTTP エンドポイント経由でも取得できる。
補足: JENKINS_HOME の値
日本語 : Administering Jenkins によるとデフォルトは ~/.jenkins
らしいが、 /var/lib/jenkins/jobs/{{ JOB_NAME }}/config.xml
になっていることも多いと思う。
Web UI 上で JENKINS_HOME
を確認する場合は、システムの設定ページ (http://{{ JENKINS_WEB_SERVER }}/configure
のような URL) の「ホームディレクトリ」の項目に記載されているので、そこを確認する。
Jenkins: ビルドステップに対応するクラス
Jenkins のビルドステップは BuildStep
インタフェースを実装する形で実現されている。実際には BuildStep
インタフェースを継承した Builder
クラスを継承することで実装している様子。これは Jenkins にビルトインされているか否か(プラグインか)に関わらず、 Builder クラスを実装している様で、 Builder - Extension Points defined in Jenkins Core には実装されたビルドステップに対応するクラスが一覧になっている。
また、ビルドステップのメインの処理に対応するのは perform
メソッドなので、どの様な処理をしているか把握する場合は、このメソッドを確認することになる。
Groovy: 演算子オーバーロード
他の言語と同様に演算子のオーバーロードは存在する。少し意識すべき点として、 plus
や div
, call
など、一見普通のメソッドに見える名前をしている。
Groovy: クラスのプロパティ
Groovy では、 setName
, getName
のように JavaBeans の仕様 に沿ったメソッドを用意することで、 obj.name = "Tanaka"
のように利用することが出来る。
methodMissing
メソッドと invokeMethod
メソッド
Groovy: methodMissing
は Ruby の method_missing
メソッドのようなメソッド。メソッドが見つからなかった際に呼び出されるメソッド。
一方、 invokeMethod
が定義されている場合は任意のメソッド呼び出し時に呼び出される。
参考資料
Groovy: クロージャの記法
クロージャは { [closureParameters -> ] statements }
のような形で記述できる。
関数の末尾の引数としてクロージャを渡す場合、以下のように丸括弧の外にクロージャを記述できる
f(arg1, arg2) { println "hello" }
補足
公式ドキュメント中にこの構文に関する記述がパッと見つからなかったが、「Programming Groovy 2」の「4.1 The Convenience of Closures」には記載を見つけた。また、 groovy/GroovyParser.g4 at GROOVY_4_0_X · apache/groovy あたりを真面目に読めば、正確な構文も把握出来ると思われる)。
delegate
まわりの挙動
Groovy: クロージャの クロージャ内部では this
, own
, delegate
という外部のオブジェクトに紐付けられた変数が利用できる。また、クロージャ内でオブジェクトを明示的に指定せずにプロパティにアクセスしようとした場合、 this
, own
, delegate
に紐付けられたオブジェクトのプロパティとして解決しようとする。この際、どの順番で解決しようと試みるかは、クロージャの resolveStrategy
プロパティの値によって制御される。
さらに、 delegate
はクロージャの delegate
プロパティの値を変更することで、クロージャ内部で delegate
に紐付けられるオブジェクトを変更することが出来る。
The Apache Groovy programming language - Closures - 3.2.4. Delegation strategy にある例(以下引用)を見ると基本的な挙動が把握しやすい。
class Person {
String name
}
def p = new Person(name:'Igor')
def cl = { name.toUpperCase() }
cl.delegate = p
assert cl() == 'IGOR'
use
メソッド
Groovy: Categories と 以下のように、 Integer
型には minute
というプロパティは存在しないが、 use
メソッドとカテゴリクラスを利用することで、そのスコープの中でのみ利用可能なメソッドを追加することが出来る。
use(TimeCategory) {
println 1.minute.ago
}
カテゴリクラスは、拡張メソッドがすべて static メソッドとして実装されており、実装するメソッドの第1引数の型が拡張したいクラス(上記の例では Integer 型)であるように実装されていれば良い。クラスに @Category
アノテーションをつけることで、カテゴリクラスに変換する方法もある。
参考資料
NodeBuilder
クラス
Groovy: NodeBuilder
は、木構造を内部 DSL で記述できるようにするクラス。 Groovy では他にも DOMBuilder
や JsonBuilder
のような、内部 DSL で各種データ形式が記述できるクラスが存在する。これらは BuilderSupport
クラスを継承することで実装されている。
BuilderSupport
クラス
Groovy: BuilderSupport
クラスは invokeMethod
メソッドを利用して実装されている。メソッド末尾にクロージャを渡し、さらにそのクロージャの delegate
プロパティに BuilderSupport
のインスタンスを渡すことで、木構造を内部 DSL で表現できるようにしている。
以下は JsonBuilder
を利用するサンプル。 Groovy としては item
, id
, tags
はすべて BuilderSupport
のメソッドとして解決される。
JsonBuilder builder = new JsonBuilder()
builder.item {
id "12345ab"
tags "a", "b", "c"
}
String json = JsonOutput.prettyPrint(builder.toString())
得られる Json 文字列
{
"item": {
"id": "12345ab",
"tags": [
"a",
"b",
"c"
]
}
}
参考資料
Script
クラスと GroovyShell
クラス
Groovy: Groovy のスクリプトは Script
クラスを継承したクラスにコンパイルされる。また、 GroovyShell
クラスを利用することで文字列から Script
へコンパイルし、実行する事ができる。
GroovyShell
に CompilerConfiguration
クラスのインスタンスを渡すことで文字列から クラスへコンパイルする際の挙動を変更することができ、 scriptBaseClass
プロパティに Script クラスを継承したクラス名を指定することで、そのクラスをベースとしたクラスにコンパイルされるようになる。つまり、スクリプト上でそのクラスのメソッドが、インスタンスを指定せずに利用できるようになる。
以下は The Apache Groovy programming language - Domain-Specific Languages - 3.1. The Script class に記載されているサンプルコードを引用したもの。スクリプト中の setName
や greet
は MyBaseClass
のメンバ変数やメソッドとして解釈される。
abstract class MyBaseClass extends Script {
String name
public void greet() { println "Hello, $name!" }
}
def config = new CompilerConfiguration()
config.scriptBaseClass = 'MyBaseClass'
def shell = new GroovyShell(this.class.classLoader, config)
shell.evaluate """
setName 'Judith'
greet()
"""
Jenkins Job DSL コードリーディング
job(...){...}
の評価部分(前半)
Job DSL スクリプト上での Jenkins Job DSL Architecture · jenkinsci/job-dsl-plugin Wiki によると、 DSL Job スクリプトは JobParent
クラス のコンテキストで実行されるとのことなので、ここを出発点に読み進めていく。
DSL Job スクリプトでの job(...){...}
は Groovy としては JobParent
のメソッドとして実装されている。
- https://github.com/jenkinsci/job-dsl-plugin/blob/job-dsl-1.81/job-dsl-core/src/main/groovy/javaposse/jobdsl/dsl/JobParent.groovy#L33-L39
- https://github.com/jenkinsci/job-dsl-plugin/blob/job-dsl-1.81/job-dsl-core/src/main/groovy/javaposse/jobdsl/dsl/JobParent.groovy#L81-L87
これらは内部で processItem
メソッドを呼び出しており、内部では各種ジョブに対応するクラス(たとえば job
は FreeStyleJob
クラス)のインスタンスに with
メソッド を利用してクロージャの内容を反映(詳細は後述)した上で、 referencedJobs
メンバ変数に追加している。
この with
メソッドは、自身を引数に渡されたクロージャの delegate
に紐付けることで、クロージャ内でインスタンスを明示せずに扱えるようにしている。なので、以下のような DSL Job スクリプトでは、 steps
は FreeStyleJob
クラスのメソッドとして解決される。
job ("test-job") {
steps {
...
}
}
以降、簡単のために FreeStyleJob
クラスのケースに絞って追っていく。
Job DSL スクリプト実行〜ジョブ設定の更新処理
FreeStyleJob
クラス内部の処理をさらに追う前に、 FreeStyleJob
クラスのインスタンスがどのように利用され、最終的にジョブ設定の更新が行われるのかを見ていく。
Jenkins Job DSL Architecture · jenkinsci/job-dsl-plugin Wiki によると、 ExecuteDslScripts
クラスは Jenkins のプラグインとしての本体で、そこから始まってジョブ設定の更新処理を行うとのことなので、 ExecuteDslScripts
クラスの perform
メソッドの実装を読んでいく。
読んでいくと、途中で Job DSL スクリプトが ScriptRequest クラスのインスタンスに変換されている事がわかる。また、その ScriptRequest
は JenkinsDslScriptLoader
クラスの runScripts
メソッドで実行されていることが分かる。
- https://github.com/jenkinsci/job-dsl-plugin/blob/job-dsl-1.81/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/ExecuteDslScripts.java#L336-L338
- https://github.com/jenkinsci/job-dsl-plugin/blob/job-dsl-1.81/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/ExecuteDslScripts.java#L351
補足
正確には JenkinsDslScriptLoader
クラスだけでなく、 ScriptApprovalDslScriptLoader
クラスなどが利用される可能性もあるが、ここでは簡単のため1つに絞る。
runScripts
メソッドは JenkinsDslScriptLoader
クラスが継承している AbstractDslScriptLoader
クラスで実装されている。内部では customizeCompilerConfiguration
メソッドが呼び出されており、ここで scriptBaseClass
に JenkinsJobParent
クラスを指定している。その後 runScriptEngine
メソッドが呼び出され、 ScriptRequest
を Script
クラスに変換して実行している。
Script
クラスに変換&実行後は、 FreeStyleJob
クラスなどのインスタンスが JenkinsJobParent
クラスの referencedJobs
メンバ変数に追加されているので、 extractGeneratedItems
メソッド → JenkinsJobManagement
クラスの createOrUpdateConfig
メソッド → JenkinsJobManagement
クラスの createNewItem
メソッド の流れでたらい回しにされた後、 FreeStyleJob
クラスが継承している Item
クラスの getXml
メソッドで XML 文字列に変換後、ジョブ設定の更新が行われている。
job(...){...}
の評価部分(後半)
Job DSL スクリプト上での ふたたび FreeStyleJob
クラス内部の処理を追っていく。
FreeStyleJob
クラスは FreeStyleJob -> Project -> Job -> Item -> AbstractContext -> Context
のようなクラスの継承関係があり、例えば steps
メソッドは Project
クラス に実装されている。
さらに steps
メソッド内部での処理を追っていくと、まずはじめに StepContext
クラスのインスタンスを作成し、 ContextHelper
クラスを利用して StepContext
をクロージャの delegate
に紐付けた上でクロージャを実行している。なので、以下のような DSL Job スクリプトでは、 shell
は StepContext
クラスのメソッドとして解決される事がわかる。
job ("test-job") {
steps {
shell "echo test"
}
}
shell
メソッド の内部を確認すると、 NodeBuilder
で Node
クラスのインスタンスを作成して、それを stepNodes
メンバ変数に追加していることが分かる。また、他の StepContext
クラスのメソッドも同様に stepNodes
に追加している。この stepNodes
は Project
クラスの steps
メソッド内で利用されている。
ここで現れる configure
メソッドは Item
クラスのメソッドで、 configureBlocks
メンバ変数へのクロージャの追加だけを行っている。
他の Project
クラスのメソッドでも内部で configure
メソッドを利用していることから、 DSL Job スクリプト上で steps
と同じ階層に記載されている設定値は、 configureBlocks
に追加されるクロージャに対応付くことが分かる。
getXml
メソッド内部の処理
最後に Item
クラスの getXml
メソッドでどのように XML 文字列が生成されているかを把握する。
getXml
メソッドは getNode
メソッド を利用しており、ここで configureBlocks
に追加されたクロージャの評価が行われている。 getNode
メソッドでは、 ContextHelper
クラスの executeConfigureBlocks
メソッド を利用して、ジョブ設定の雛形となる Node
クラスのインスタンス(FreeStyleJob
の場合は FreeStyleJob-template.xml
を変換したもの)を各クロージャの処理で変更を加えることで、最終的に欲しいジョブ設定に対応する Node
クラスのインスタンスを生成している。
executeConfigureBlocks
メソッド内部の処理をもう少し追うと、 executeConfigureBlock
メソッドで各クロージャを処理しており、ここで NodeEnhancement
クラスをカテゴリクラスとして扱っており、それによってクロージャ内で Node クラスに子ノードを作成する際に /
や <<
を利用できるようになっている。
なので、例えば steps メソッド で追加されるクロージャでは、ジョブ設定に対応する Node
クラスのインスタンスに builders
という子ノードを追加し、さらにその子ノードとして stepNodes
の各要素を追加するという処理を行うことが分かる。
Discussion