jenkins源码阅读:启动和请求处理

jenkins 编译后,会生成一个 jenkins.war 文件,阅读 jenkins 源码,我们需要研究的一切,都在这个文件里面了。按照习惯,先寻找入口点,然后顺藤摸瓜,才能进入源码树中。

如果 jenkins 独立运行,启动命令是:

$ java -jar jenkins.war

所以 jenkins.war 其实是一个普通的 jar 包。java 命令运行一个 jar 文件,会在 jenkens.war/META-INF/ 目录下,找 MANIFEST.MF 文件。这个文件里面有入口类,在这里,是 Main,即是 jenkins.war/Main.class 文件。

jenkins.war/META-INF/MANIFEST.MF

$ cat MANIFEST.MF
Manifest-Version: 1.0
Jenkins-Version: 2.13-SNAPSHOT
Implementation-Version: 2.13-SNAPSHOT
Built-By: myzhan
Build-Jdk: 1.7.0_79
Hudson-Version: 1.395
Created-By: Apache Maven 3.3.3
Main-Class: Main
Archiver-Version: Plexus Archiver

反编译一下 Main.class 文件,一个标准的 Main 文件,属于 default 包。但是这个 Main 文件,并不在 jenkins 的源码里面,即不在 jenkins core 里面。过了一遍反编译的源码,发现它先将 jenkins.war 解压到 $JENKINS_HOME/war 目录下,再启动一个 winstone 容器。鉴于jenkins 本身是一个 Servlet 程序,我猜测,在这个入口,是先启动一个 Servlet 容器,再加载 jenkins 本身。

通过查看 $JENKINS_SRC/war/pom.xml 文件,发现这个 Main 文件,在 org.jenkins-ci:executable-war 这个依赖中,验证了我的猜测。

既然是一个 Servlet 程序,那我们再去 $JENKINS_HOME/war/WEB-INF/web.xml 找入口点。与 Servlet 有关的配置,在这里。

$JENKINS_HOME/war/WEB-INF/web.xml

<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>

在这里,jenkins 将所有 URL 请求的处理,都交给了 Stapler。根据 Stapler 的文档,了解到它提供了 URL 到对象的映射。即所有请求,都先交给 Stapler 处理,然后 Stapler 通过反射的方式,找到对应的处理类及方法。

顺着 web.xml 往下看,终于找到了与 jenkins core 有关的配置。

$JENKINS_HOME/war/WEB-INF/web.xml

<listener>
    <listener-class>hudson.WebAppMain</listener-class>
</listener>

WebAppMain.java 文件,在 $JENKINS_SRC/core/src/main/java/hudson 目录下面,文件开头的注释,解释了它的身份。

$JENKINS_SRC/core/src/main/java/hudson/WebAppMain.java

/**
* Entry point when Hudson is used as a webapp.
*
* @author Kohsuke Kawaguchi
*/

这个类的代码大约 400 行,大多是一些初始化工作,下面这段代码引起了我的注意。

$JENKINS_SRC/core/src/main/java/hudson/WebAppMain.java

context.setAttribute(APP, new HudsonIsLoading());
initThread = new Thread("Jenkins initialization thread") {
    @Override
    public void run() {
        Jenkins instance = new Hudson(_home, context);
        context.setAttribute(APP, instance);
    }
};
initThread.start();

熟悉 jenkins 的同学,到了这里有没有一点似曾相识的感觉? jenkins 在刚启动的时候,会显示一个 loading 页面,启动完成后,才切到主页,就是靠这段代码实现的,HudsonIsLoading 就是 loading 页面,Hudson 就是主页。

现在看来 context.setAttribute(APP, xxxx) 这个方法调用,能把 xxxx 对象挂到 “/” 这个 URL 下面。不过 context.setAttribute 方法,在 Servlet 编程里面,只是保存了一个变量而已,为什么能挂到 “/” 下面呢?答案一定在 Stapler 的源码里!从 web.xml 配置我们可以知道,入口在 org.kohsuke.stapler.Stapler。继续查看 Stapler 的源码,我找到了答案。

/org/kohsuke/stapler/Stapler.java

protected @Override void service(HttpServletRequest req, HttpServletResponse rsp) {
    Object root = webApp.getApp();
    if(root==null)
        throw new ServletException("there's no \"app\" attribute in the application context.");
    invoke( req, rsp, root, servletPath);
}

/org/kohsuke/stapler/WebApp.java

/**
* Returns the 'app' object, which is the user-specified object that
* sits at the root of the URL hierarchy and handles the request to '/'.
*/
public Object getApp() {
    return context.getAttribute("app");
}

跟踪到这里,jenkins 的启动,和处理请求的过程,已经清楚。总结一下,主要是下面这几步。

  1. 启动 winstone 容器,加载 jenkins。
  2. Stapler 负责处理请求。
  3. WebAppMain 负责初始化。

最后,写一个 Hello World 庆祝一下,在 Jenkins.java 中,加入以下代码。

$JENKINS_SRC/core/src/main/java/jenkins/model/Jenkins.java

public void doHelloWorld( StaplerRequest req, StaplerResponse rsp ) throws IOException, ServletException {
    rsp.getWriter().println("Hello World");
}

重新编译生成 jenkins.war,运行后用浏览器打开 http://localhost:8080/helloWorld 即可看到 Hello World。

注意这里的 URL 是大小写敏感的。

如果想了解 Stapler 的基本用法,可以查看下面这几篇文档。

编译和运行jenkins

在工作中使用 jenkins 做持续集成,已经将近3年了。按照我的性格,用一样东西,就想了解它的源码实现。期间看过不少 jenkins 插件的代码,却一直没有看过 jenkins core 的源码。主要原因,是第一眼看代码的时候,感觉 jenkins 当初从 hudson 分出来,背负了不少历史包袱,代码结构有点混乱。

不过最近心血来潮,又开始想研究 jenkins core 的源码,在公司里面使用 jenkins,大多是用来实现自动打包部署,和自动化测试。这些 job 的实现,查看文档,使用几个插件就能完成。不了解源码,已经不能满足我的好奇心了。

学习源码,第一步要做的,就是能够编译运行,并且debug。jenkins 使用 maven 作为构建系统,编译和运行,都非常简单。不过依赖非常多,在 maven 下载依赖的时候,在这里写博客。算是挖个坑,督促自己把坑填了。:D

源码

jenkins 的源码,目前放在 github 上面,clone 下来即可。

$ git clone https://github.com/jenkinsci/jenkins.git

编译

编译 jenkins,需要 JDK 和 maven,按照 官方文档 操作即可。不过默认的构建命令,会跑单元测试,由于环境原因,测试用例或许会失败。为了先简单体验一下,我们可以跳过部分测试。这里我用了源码目录里,BUILDING.TXT 文件介绍的命令。

$ mvn clean install -pl war -am -DskipTests

这一步执行后,maven 需要在源里下载很多依赖包,由于国内的网络环境,需要花不少时间。如果等不及,可以使用国内的 maven 源。不过我在使用 oschina 提供的源时,提示缺少依赖,可能是还没同步过来,乖乖换回了官方的源。

如果一切顺利,就能在 war/target/ 目录下面,找到 jenkins.war 文件。在我这边就不顺利,由于网络不好,光是下载依赖就花了几个小时,哈哈。

运行

在使用方面,jenkins非常友好。所有代码、依赖,都在 jenkins.war 文件里面,所有数据,默认都在 $HOME/.jenkins 目录下面。在升级版本,或者备份、迁移数据的时候,都很舒服。

上面我们已经得到 jenkins.war 文件,运行之。

$ java -jar jenkins.war

用浏览器访问 http://localhost:8080 ,即可以访问我们从源码编译出来的 jenkins 版本了。

Debug

成功编译运行后,最简单、原始的方法,就是在自己不懂的地方,打日志,重新编译运行啦。IDE 我用的是 Intellij IDEA,因为可以通过直接打开 maven 的 pom 文件,来导入一个 Project。

如果更习惯使用 debugger 来单步调试,也是支持的。根据文档,在源码目录,执行以下命令即可。

$ cd war
$ mvnDebug jenkins-dev:run

这样子跑起来的 jenkins 实例,会额外监听 8000 端口(jdwp端口),用 IDE 提供的 remote debug 功能连上去即可。

告别robotframework

刚开始工作的两年,在做 API 型服务端的自动化测试。当时老大安利了 robotframework,一直在用。由于可以编写自定义的 library,robot 的扩展能力非常强,基本上 python 能做的事情,都可以在 robot 里面实现。通过编写 library,我实现了很多好玩的功能。例如,检测数据库,mc,日志,和运行时更换 mock 服务。

去年想尝试新事物,便换了项目。新项目的自动化从零开始,我也不想继续再用 robot。不用 robot,用什么呢?当时我也没有答案。不过当时我在做一件事情,就是把新项目的接口调用,都封装到一个 python 写的库里面。既然像模像样地写一个库,自然要有逼格,src,test,doc 这些目录都要有,不然会被程序员 BS 。写了好几个接口后,突然来了灵感,我写在 test 下面的那些 unittest,不就可以用来做服务端的自动化测试吗?事实证明,完全可以! 被测对象,从 python 库,变成了服务端 API。

举个例子,服务端有个 login 接口,接收 name 和 password 参数。我把这个接口封装成了 python 调用,类似于 RPC。

resp = User.login(name, password)

这样,如果有人需要访问 login 接口,只要像上面这样调用就可以了。

我这样写 unittest。

def test_login():
    resp = User.login("xxx", "123")
    assert resp.nick_name == "xxx"

在 robot 里面淫浸多年,我非常喜欢数据驱动方式的自动化测试,于是通过 pytest 来解决这个问题。

import pytest
@pytest.mark.parametrize("name, password, expected", [
    ("xxx", "123", "xxx"),
    ("yyy", "123", "yyy"),
])
def test_login(name, password, expected):
    resp = User.login(name, password)
    assert resp.nick_name == expected

Bingo!现在完全可以通过不同的 name 和 password,来测试服务端的 login 接口了。接着我又把我在 robot 里面已经实现的一些功能,搬了过来。

测试缓存不存在的情况。

def test_login():
    with mc_delete("userinfo_xxx"):
        resp = User.login("xxx", "123")
        assert resp.nick_name == "xxx"

熟悉的 with !不过这还不能完全体现 with 的优势,来看看另外一个例子。

假设 login 接口,需要再调用内部系统去完成,那么我们可以测试内部系统响应异常的情况。

def test_login():
    with mock("microservice", "error"):
        resp = User.login("xxx", "123")
        assert resp.not_ok
        assert resp.nick_name == ""

mock 函数把内部系统的响应,mock 为 error,导致我们的系统,不能正常登录。很妙的是,mock 还是一个 context manager,离开 with 块之后,自动取消 mock 状态。测试用例还可以写成这样。

def test_login():
    with mock("microservice", "error"):
        resp = User.login("xxx", "123")
        assert resp.not_ok
        assert resp.nick_name == ""
    resp = User.login("xxx", "123")
    assert resp.ok
    assert resp.nick_name == "xxx"

很快,这样子写出来的用例,就有了 600 多个,每天都在 jenkins 上面做自动化回归测试。于是我成功地告别了 robot。

撸完这些后,忽然觉得心里空荡荡的。这大概就是我所理解的,API 接口自动化的最好状态了。

Simple is better than complex!