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オブジェクトが手に入ります。

この辺でちょっと休憩。続きは調べたらまた書こう...