Jenkinsソースコードリーディングをやってみたかった
Jenkinsプラグイン開発をしてて、Jenkinsってどんな風に動作してるのか気になったので調べてみました。(未完成)
手始めに
Servletなんだからweb.xmlから始めればいいじゃない。
jenkins/web.xml at jenkins-2.82 · jenkinsci/jenkins · GitHub
<servlet> <servlet-name>Stapler</servlet-name> <servlet-class>org.kohsuke.stapler.Stapler</servlet-class> ... </servlet> <servlet-mapping> <servlet-name>Stapler</servlet-name> <url-pattern>/*</url-pattern> </servlet-mapping>
Staplerに丸投げです。
Staplerって?
Staplerの概要は「What is Stapler?」を見ていただくとして、ざっくりいうとURL→Javaオブジェクトのメソッド呼び出しに便利に変換してくれるライブラリって感じです。どんな感じに変換するかはJenkinsのドキュメントがわかりやすいでしょう。このページをGoogle翻訳さんを駆使して意訳するとこんな感じのことが書いてあります。
Hudson
インスタンス(訳註:上記でServletContextに紐づけたインスタンス)はコンテキストルートURL(例: "/")にバインドされ、残りのオブジェクトはこのルートオブジェクトからの到達可能性によりバインドされます。 シングルトンなHudsonインスタンス(訳註:上記でServletContextに紐づけた インスタンス)はコンテキストルート(例えば "/")URLにバインドされ、 残りのオブジェクトはこのルートオブジェクトからの到達可能性に従って バインドされます。Staplerはリフレクションを使用して、任意のURLをどのように処理するかを 再帰的に判断します。URLの/foo/bar処理方法の例をいくつか示します。
getFoo(String)
がHudson
オブジェクト(訳註:原文はJenkinsオブジェクトだが 間違いと思われる)に定義されている場合、Staplerがbar
をgetFooのパラメータ として渡します。getFooから返されるオブジェクトには、doIndex(…)メソッドがあり、 このメソッドの戻り値が応答としてレンダリングされます。getFoo()またはdoFoo()がHudsonオブジェクトに定義されており、 その戻り値であるオブジェクトがgetBarまたはdoBarメソッドを持つとします。 さらに、その返されたオブジェクトにindex.jellyや index.groovyビューが関連付けられている場合、それが応答として利用されます。
getFoo()またはdoFoo()定義されていて、その戻り値であるオブジェクトには、名前付きのJellyテンプレートである bar.jellyまたはbar.groovyがビューとして定義されているとします。この場合、応答にbar.jellyやbar.groovyが利用されます。
ほかにもいろいろな変換方法がありますが、代表的なのは上記のパターンです。
なんとなくこの説明で概要はわかりますが、上記の例に載ってないパターンとして、POSTリクエストでFormデータが送られてきたらどうやって処理すんの?というパターンがあります。この辺はこちらのJenkinsのドキュメントがわかりやすいです。 一言でいうと、アノテーションをつけてあげるとFormデータとかが取得できます。
// formやクエリパラメータの"param1"を引数に取りたいなら public HttpResponse doStart(@QueryParameter("param1") String paramA) { ... } // 引数名がパラメータ名と同じならアノテーションのvalueは省略できるみたいです public HttpResponse doEnd(@QueryParameter String param1) { ... }
戻ってweb.xmlのほかの部分
メインのマッピングはStaplerがやってくれることはわかりましたが、前記ルートオブジェクトはどこで定義するのでしょう? web.xmlを読み進めると明らかに怪しげな設定がlistenerにあります。 jenkins/web.xml at jenkins-2.82 · jenkinsci/jenkins · GitHub
<listener> <listener-class>hudson.WebAppMain</listener-class> </listener>
なるほど、じゃあそのクラス見てみますか。
hudson.WebAppMainの初期化処理を覗いてみる
jenkins/WebAppMain.java at jenkins-2.82 · jenkinsci/jenkins · GitHub
... private static final String APP = "app"; ... public void contextInitialized(ServletContextEvent event) { ... final ServletContext context = event.getServletContext(); ... try { ... home = describedHomeDir.file.getAbsoluteFile(); ... context.setAttribute(APP,new HudsonIsLoading()); final File _home = home; initThread = new Thread("Jenkins initialization thread") { @Override public void run() { boolean success = false; try { Jenkins instance = new Hudson(_home, context); ... context.setAttribute(APP, instance); ... } }; initThread.start(); } }
着目すべきはcontext.setAttribute("app", ...)
。ここで、Staplerにルートオブジェクトを教えています。以下はStaplerのGetting Startedから抜粋。
Registering the root object
Stapler needs to know the root object of your application. It does that by ServletContext.getAttribute("app"), so your application needs to set the root object into a ServletContext. The easiest to do that is to write a ServletContextListener and use the helper method from Stapler.
初期化完了までは「Jenkinsを再起動しますので、しばらくお待ちください。」のあの画面を表すHudsonIsLoading()
がルートオブジェクトになってますね。
さらにHudsonのコンストラクタから初期化の流れを追う
jenkins/Hudson.java at jenkins-2.82 · jenkinsci/jenkins · GitHub
public class Hudson extends Jenkins { ... public Hudson(File root, ServletContext context) throws IOException, InterruptedException, ReactorException { this(root,context,null); } public Hudson(File root, ServletContext context, PluginManager pluginManager) throws IOException, InterruptedException, ReactorException { super(root, context, pluginManager); } ... }
特に見どころなく、親クラスのJenkins
に移ります。
ここからが本番だよ!
jenkins/Jenkins.java at jenkins-2.82 · jenkinsci/jenkins · GitHub
protected Jenkins(File root, ServletContext context, PluginManager pluginManager) throws IOException, InterruptedException, ReactorException { ... // As Jenkins is starting, grant this process full control ACL.impersonate(ACL.SYSTEM); try { ... // doing this early allows InitStrategy to set environment upfront final InitStrategy is = InitStrategy.get(Thread.currentThread().getContextClassLoader()); ... Trigger.timer = new java.util.Timer("Jenkins cron thread"); queue = new Queue(LoadBalancer.CONSISTENT_HASH); try { dependencyGraph = DependencyGraph.EMPTY; } ... if (pluginManager==null) pluginManager = PluginManager.createDefault(this); this.pluginManager = pluginManager; ... // initialization consists of ... executeReactor( is, pluginManager.initTasks(is), // loading and preparing plugins loadTasks(), // load jobs InitMilestone.ordering() // forced ordering among key milestones ); ... } }
長いです。(114行)コメントから察するにexecuteReactor
が初期化処理のメインのようなので、とりあえずここを掘り下げてみましょう。
プラグイン周りはとても長くなりそう。まずは引数のloadTasks()を読んでみる。
private synchronized TaskBuilder loadTasks() throws IOException { File projectsDir = new File(root,"jobs"); ... File[] subdirs = projectsDir.listFiles(); final Set<String> loadedNames = Collections.synchronizedSet(new HashSet<String>()); TaskGraphBuilder g = new TaskGraphBuilder(); Handle loadJenkins = g.requires(EXTENSIONS_AUGMENTED).attains(JOB_LOADED).add("Loading global config", new Executable() { public void run(Reactor session) throws Exception { loadConfig(); // if we are loading old data that doesn't have this field if (slaves != null && !slaves.isEmpty() && nodes.isLegacy()) { nodes.setNodes(slaves); slaves = null; } else { nodes.load(); } clouds.setOwner(Jenkins.this); } }); for (final File subdir : subdirs) { g.requires(loadJenkins).attains(JOB_LOADED).notFatal().add("Loading item " + subdir.getName(), new Executable() { public void run(Reactor session) throws Exception { ... TopLevelItem item = (TopLevelItem) Items.load(Jenkins.this, subdir); items.put(item.getName(), item); loadedNames.add(item.getName()); } }); } g.requires(JOB_LOADED).add("Cleaning up obsolete items deleted from the disk", new Executable() { public void run(Reactor reactor) throws Exception { for (String name : items.keySet()) { if (!loadedNames.contains(name)) items.remove(name); } } }); g.requires(JOB_LOADED).add("Finalizing set up",new Executable() { public void run(Reactor session) throws Exception { rebuildDependencyGraph(); {// recompute label objects - populates the labels mapping. for (Node slave : nodes.getNodes()) // Note that not all labels are visible until the agents have connected. slave.getAssignedLabels(); getAssignedLabels(); } // initialize views by inserting the default view if necessary // this is both for clean Jenkins and for backward compatibility. if(views.size()==0 || primaryView==null) { View v = new AllView(AllView.DEFAULT_VIEW_NAME); setViewOwner(v); views.add(0,v); primaryView = v.getViewName(); } primaryView = AllView.migrateLegacyPrimaryAllViewLocalizedName(views, primaryView); if (useSecurity!=null && !useSecurity) { // forced reset to the unsecure mode. // this works as an escape hatch for people who locked themselves out. authorizationStrategy = AuthorizationStrategy.UNSECURED; setSecurityRealm(SecurityRealm.NO_AUTHENTICATION); } else { // read in old data that doesn't have the security field set if(authorizationStrategy==null) { if(useSecurity==null) authorizationStrategy = AuthorizationStrategy.UNSECURED; else authorizationStrategy = new LegacyAuthorizationStrategy(); } if(securityRealm==null) { if(useSecurity==null) setSecurityRealm(SecurityRealm.NO_AUTHENTICATION); else setSecurityRealm(new LegacySecurityRealm()); } else { // force the set to proxy setSecurityRealm(securityRealm); } } // Initialize the filter with the crumb issuer setCrumbIssuer(crumbIssuer); // auto register root actions for (Action a : getExtensionList(RootAction.class)) if (!actions.contains(a)) actions.add(a); } }); return g; }
ここもヘビーでした。どう見ても重要そうなItems.load()
を見てみましょう
jenkins/Items.java at 303a9f7df70b702dfa3df1fa5bdf0a2afe9b1445 · jenkinsci/jenkins · GitHub
public static Item load(ItemGroup parent, File dir) throws IOException { Item item = (Item)getConfigFile(dir).read(); // XStreamライブラリを使ってXMLからデシリアライズしてる. item.onLoad(parent,dir.getName()); return item; }
何となく想像のつく処理にたどり着きました。ここは各ジョブのディレクトリからconfig.xml
を読み込んでItem
インスタンスを生成するようですね。
jenkins/PluginManager.java at jenkins-2.82 · jenkinsci/jenkins · GitHub
public abstract class PluginManager extends AbstractModelObject implements OnMaster, StaplerOverridable { ... public static @NonNull PluginManager createDefault(@NonNull Jenkins jenkins) { ... return new LocalPluginManager(jenkins); } ... }
public class LocalPluginManager extends PluginManager { public LocalPluginManager(@CheckForNull ServletContext context, @NonNull File rootDir) { super(context, new File(rootDir,"plugins")); } public LocalPluginManager(@NonNull Jenkins jenkins) { this(jenkins.servletContext, jenkins.getRootDir()); } ... }
jenkins/PluginManager.java at jenkins-2.82 · jenkinsci/jenkins · GitHub
private final Transformer compatibilityTransformer = new Transformer(); ... public PluginManager(ServletContext context, File rootDir) { this.context = context; this.rootDir = rootDir; ... this.workDir = StringUtils.isBlank(workDir) ? null : new File(workDir); strategy = createPluginStrategy(); // load up rules for the core first try { compatibilityTransformer.loadRules(getClass().getClassLoader()); } ... } ... protected PluginStrategy createPluginStrategy() { ... // default and fallback return new ClassicPluginStrategy(this); }
リクエスト処理の具体例を見てみよう: ジョブ実行編
JenkinsでジョブをビルドするときのURLを観察すると、次のような感じになってます。
http://jenkins-host/job/ジョブ名/build
上記Staplerの変換ルールから察するに、Hudson#getJob("ジョブ名").doBuild()
てな感じのJavaオブジェクトの呼び出しとして解釈されそうです。実際に見てみましょう。
jenkins/Hudson.java at jenkins-2.82 · jenkinsci/jenkins · GitHub
public class Hudson extends Jenkins { ... public TopLevelItem getJob(String name) { return getItem(name); } ... }
さらにJenkins#getItem(String)を参照すると
@Override public TopLevelItem getItem(String name) throws AccessDeniedException { ... TopLevelItem item = items.get(name); ... return item; }
このitems
は前記Hudsonインスタンスの初期化で見ましたね。これでジョブのconfig.xmlをデシリアライズして得られたItemオブジェクトが手に入ります。
この辺でちょっと休憩。続きは調べたらまた書こう...
PlayFramework 小ネタまとめ
プラグインとかビルドの設定すぐ忘れちゃうので覚書。 無記載の場合バージョン2.4.xの話だけど、たぶんそんなに変わらない。
Eclipseプロジェクトを生成
build.sbtに次を追加
// Compile the project before generating Eclipse files, so that generated .scala or .class files for views and routes are present EclipseKeys.preTasks := Seq(compile in Compile) // Java project. Don't expect Scala IDE EclipseKeys.createSrc := EclipseCreateSrc.ValueSet(EclipseCreateSrc.ManagedClasses, EclipseCreateSrc.ManagedResources) // Use .class files instead of generated .scala files for views and routes EclipseKeys.projectFlavor := EclipseProjectFlavor.Java
sbt eclipse with-source=true
で生成.
Eclipseでプロジェクトを開いたらビルドパスにtarget/scala-2.11/classes_managed
を追加. (LibrariesにAdd Class Folder)
JUnitテストの並列実行を切る
機能テストとかで困る場合用。build.sbtに次を追加。
parallelExecution in Test := false
Jacocoでテストカバレッジをとる
https://github.com/sbt/jacoco4sbt
project/plugins.sbt
に次を追加
addSbtPlugin("de.johoop" % "jacoco4sbt" % "2.2.0")
build.sbt
に次を追加
// 念のため並列実行も切っておく parallelExecution in jacoco.Config := false jacoco.settings
jacoco:cover
でカバレッジ生成。出力はtarget/scala-2.11/jacoco
JPAを使う場合の注意
persistence.xml
は対象のエンティティクラスが同じJarに入ってないとだめらしい。
何も設定せずにsbt dist
するとconf
ディレクトリが配布パッケージにできて、プロジェクトのconf
ディレクトリの中身がコピーされるが、
ここにpersisitence.xml
を入れとくと上記問題に引っかかる。
そこで、build.sbt
に次を追加してconf
ディレクトリを配布パッケージに作らない。(Jarにはちゃんと入るので動く。)
PlayKeys.externalizeResources := false
よく読んだから公式のDocumentation[2.4.x 日本語], [2.5.x 英語]にちゃんと書いてある。 気づかなくて小一時間ハマった。
PlayFrameworkにDIしてもらう
GuiceのAbstractModule
を継承したクラスのFQCNをapplication.conf
に書いておけばPlayFrameworkでDIしてくれる。
演算子の雰囲気からわかる通り、複数のモジュールを追加できる。
play.modules.enabled += "modules.HelloModule"
(2.5.x以降限定?)
デフォルト(ルート)パッケージにクラス名Module
でモジュールを作っておけば上記設定さえ書かなくても動く。
アノテーションのつけ方とかはGuiceそのものなので割愛。 nodchipさんの記事心地良すぎるDependency Injectionライブラリ Guiceが参考になる。 英語が苦にならない人はGuice Wiki見よう。
Javadocを生成してもらう
何も設定しないとsbt doc
でscaladocになる。build.sbt
に次を追加。
sources in (Compile, doc) ~= (_ filter (_.getName endsWith ".java"))
Eclipseに入れておくといいプラグイン
随時更新。ほぼJava開発者向け。
全般
TM Terminal
リンク: TM Terminal | Eclipse Plugins, Bundles and Products - Eclipse Marketplace
ビューにターミナル(Windowsならコマンドプロンプト)を追加してくれる。特にsbtなんかを使うときにはあると便利。コマンド直接打ちたいことも結構あるよね。Eclipse Foundation製で何となく安心?Ctrl +Alt + Tでさっと起動できるのもいいところ。Project ExplorerやPackage Explorerビューの右クリックメニューの`Show in Local Terminal`も便利。(右クリックしたフォルダでターミナルが開く)
Java関連
Quick JUnit
リンク: Quick JUnit Plugin for Eclipse
開いてるJavaコード <=> JUnitテストコードをCtrl+9で一発移動できる。さらにテストコードがない場合作ってくれる。JUnitテストコード上でCtrl+0でテスト実行。カーソル位置に応じてテスト範囲を変えてくれる。(クラスのスコープにカーソルがあればクラス全体テスト、関数のスコープにカーソルがあればその関数だけテスト)これがないとテストやってられないくらい便利。