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してもらう

GuiceAbstractModuleを継承したクラスのFQCNapplication.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でテスト実行。カーソル位置に応じてテスト範囲を変えてくれる。(クラスのスコープにカーソルがあればクラス全体テスト、関数のスコープにカーソルがあればその関数だけテスト)これがないとテストやってられないくらい便利。

Play Framework 2.5.x をEclipseで快適に使えるようにするまで (1)

現時点で最新のPlay Framework 2.5.12を対象に環境構築のメモ。Windows向け&Javaで開発です。

Part.1はとりあえずサンプルを動かしてみるところまで。

続きを読む