IMLC.ME

JDK18 Simple Web Server

不知道是不是跟我一样,很羡慕 Python 和 Node.js 自带 Web Server。可以简单一行命令就跑个测试服务器,测试网页还是下载服务器的文件都很方便。

python -m SimpleHTTPServer # Python2
python3 -m http.server # Python3
npx static # Node.js

然而 Java 生态却一直没有合适的工具。

不过,终于,还是来了。

JDK18,JEP 408,引入了一个测试用的简单文件服务器命令行工具——jwebserver。

启动服务器

jwebserver

直接敲击命令,服务器默认启动在 127.0.0.1:8000 上。

要对外服务,需要通过-b参数指定 0.0.0.0 地址,监听所有 IPv4 的网卡地址。或者 :: 监听 IPv4 和 IPv6 双栈的地址。

jwebserver -b 0.0.0.0 -p 8080
jwebserver -b :: -p 8080

使用-h参数查看jwebserver 所有的参数:

jwebserver -h
Picked up JAVA_TOOL_OPTIONS: -Duser.language=en
Usage: jwebserver [-b bind address] [-p port] [-d directory]
                  [-o none|info|verbose] [-h to show options]
                  [-version to show version information]
Options:
-b, --bind-address    - Address to bind to. Default: 127.0.0.1 (loopback).
                        For all interfaces use "-b 0.0.0.0" or "-b ::".
-d, --directory       - Directory to serve. Default: current directory.
-o, --output          - Output format. none|info|verbose. Default: info.
-p, --port            - Port to listen on. Default: 8000.
-h, -?, --help        - Prints this help message and exits.
-version, --version   - Prints version information and exits.
To stop the server, press Ctrl + C.

可以看到,这个 Simple Web Server 是真的很 Simple。

-d, --directory       - 指定提供网页服务的目录,默认是命令执行的当前目录
-o, --output          - 日志级别, none|info|verbose
-p, --port            - 监听的端口,默认为8000
-h, -?, --help        - 打印帮助信息
-version, --version   - 打印版本信息

停止服务器

这只是一个测试用的服务器,直接 Ctrl+C 结束当前进程。没啥 graceful shutdown 之类的麻烦玩意。

在代码中使用 SimpleWebServer

一点都不一样,你可以在代码中使用 Simple Web Server。

该功能实现在com.sun.net.httpserver.*包下。
你可以参考 jwebserver 的源代码实现: JWebServer.java | openjdk | GitHub

var server = SimpleFileServer.createFileServer(
        new InetSocketAddress(8080),
        Path.of("/path/to/your/files"),
        OutputLevel.VERBOSE
    );
server.start();

就是这么简单。

com.sun.net.httpserver.HttpServer

不过,不知道大家知不知道,Java 其实很早就提供了 HTTP Server 工具——com.sun.net.httpserver.HttpServer。 JEP 408 只是提供了新的 FileServerHandler 类。

HttpServer 是个很原始,一点都不友好的 HTTP server 实现。连在 unit test 中用,都嫌麻烦。 但是,如果你能简单包装一下它的API,在某些迷你项目中,也是个还行的选择。

HttpServer 是个基于 BIO 的 HTTP 服务器,典型的 request-per-thread 的请求模型。 它支持 HTTPS,但是我不太确定它支不支持 HTTP/2 和 HTTP/3 (想来也不支持)。

但是,如果你可以引入虚拟线程,那么 HttpServer 的应用场景会宽广很多。后文有介绍*

public static void main(String[] args) throws IOException {
    var server = HttpServer.create(new InetSocketAddress(8080), 0);

    server.createContext("/hello", exchange -> {
      System.out.printf("%s %s %s\n", Thread.currentThread(), exchange.getRequestMethod(), exchange.getRequestURI());
      sleep(5_000);
      String resp = "Hello, world!";
      exchange.sendResponseHeaders(200, resp.length());
      OutputStream os = exchange.getResponseBody();
      os.write(resp.getBytes());
      os.close();
    });

    server.setExecutor(Executors.newSingleThreadExecutor());
    server.start();
}

HttpHandlers.of() 和 HttpHandlers.handlerOrElse()

HttpHandlers 也是很早就提供的工具来。

HttpHandlers.of() 方法直接创建一个 HttpHandler 实例,并制定 HTTP 响应的 status code、headers 和 body。显而易见,这么简单的 Handler,除了写 UT,应该也没别的使用场景。

HttpHandlers.handlerOrElse() 方法接受3个入参。第一个参数是 Predicate,用于条件判断。 如果条件满足,则调用第二个参数的 HttpHandler;否则就调用第三个参数的 HttpHandler。

Predicate<Request> IS_API = r -> r.getRequestURI().getPath().startsWith("/api/");
Predicate<Request> IS_FILE_SERVER = r -> r.getRequestURI().getPath().startsWith("/files");
...
HttpHandlers.handleOrElse(IS_API, apiHandler,
        HttpHandlers.handleOrElse(IS_FILE_SERVER, fileHandler, notFoundHandler)
    )

在上方的代码片段中,我创建了两个判断条件。
IS_API 判断,HTTP 请求路径是否 /api/
IS_FILE_SERVER 判断,HTTP 请求路径是否 /files

然后,第一层的 HttpHandlers.handleOrElse() ,实现了如果 HTTP 请求路径是 /api/,则调用 apiHandler。 其第三个参数嵌套了第二层的 HttpHandlers.handleOrElse()。

这个第二餐 HttpHandlers.handleOrElse() 判断如果 HTTP 请求路径是 /files,则调用 fileHandler,否则调用 notFoundHandler 返回 404 Not Found。

完整的代码如下:

public static void main(String[] args) throws IOException {
    var fileHandler = SimpleFileServer.createFileHandler(Path.of("/tmp"));


    Predicate<Request> IS_API = r -> r.getRequestURI().getPath().startsWith("/api/");
    Predicate<Request> IS_FILE_SERVER = r -> r.getRequestURI().getPath().startsWith("/files");

    HttpHandler apiHandler = HttpHandlers.of(201, Headers.of("Token", "123456"), """
        {
          "message": "request succeeded"
        }
        """);

    HttpHandler notFoundHandler = HttpHandlers.of(404, Headers.of(), "");

    var server = HttpServer.create(
        new InetSocketAddress(8080),
        0,
        "/",
        HttpHandlers.handleOrElse(IS_API, apiHandler,
            HttpHandlers.handleOrElse(IS_FILE_SERVER, fileHandler, notFoundHandler)
        )
    );

    server.setExecutor(Executors.newSingleThreadExecutor());
    server.start();
}

虽然 HttpHandlers.handlerOrElse() 被用于设计串联多个 HttpHandler。但再次吐槽,JDK 自带的 HttpServer 和 HttpClient API 设计都难用的很一致。

虚拟线程和HttpServer

JDK19 提供了虚拟线程(Virtual Thread)的 preview 实现。虽然还没能 production-ready。但是和 HttpServer 这样的测试工具类配合使用缺失一点问题都没有。

当你的代码使用虚拟线程后,你的所有 blocking 操作都会被自动替换成可中断的实现。开发者无需特意修改代码,即可无成本地享受虚拟线程带来的收益。

再次回到上面的第一个例子:

public static void main(String[] args) throws IOException {
var server = HttpServer.create(new InetSocketAddress(8080), 0);

server.createContext("/hello", exchange -> {
    System.out.printf("%s %s %s %s\n", LocalDateTime.now().toString(),Thread.currentThread(), exchange.getRequestMethod(), exchange.getRequestURI());
    sleep(5_000);
    String resp = "Hello, world!";
    exchange.sendResponseHeaders(200, resp.length());
    OutputStream os = exchange.getResponseBody();
    os.write(resp.getBytes());
    os.close();
});

server.setExecutor(Executors.newSingleThreadExecutor());
server.start();
}

这里我启动了一个 HttpServer,并且设置了线程池为 SingleThreadExecutor。也就是整个 HttpServer 只有一条工作线程。

当接收到请求,我先打印当前的时间戳和处理线程的信息。 然后休眠5秒钟(模拟业务逻辑中的Blocking IO操作)。再返回响应。

执行代码后,如果我们连续发送多条 HTTP 请求:

curl http://localhost:8080/hello &
curl http://localhost:8080/hello &
curl http://localhost:8080/hello &
curl http://localhost:8080/hello &

查看日志,不出意外,我们只有一条线程在工作。虽然我们同时发送了多个请求,但是只有当前一个请求处理完,才能开始下一个请求的处理。

2022-09-11T23:32:34.135493 Thread[#26,pool-1-thread-1,5,main] GET /hello
2022-09-11T23:32:48.592291 Thread[#26,pool-1-thread-1,5,main] GET /hello
2022-09-11T23:32:53.598546 Thread[#26,pool-1-thread-1,5,main] GET /hello
2022-09-11T23:32:58.605189 Thread[#26,pool-1-thread-1,5,main] GET /hello

嗯,BIO 和 thread-per-host 模型的毛病,也没啥好说的。

接下来才是关键。 要使用虚拟线程,我们只需要把线程池替换为VirtualThreadPerTaskExecutor。如下方的setExecutor()处所示。

public static void main(String[] args) throws IOException {
    var server = HttpServer.create(new InetSocketAddress(8080), 0);

    server.createContext("/hello", exchange -> {
      System.out.printf("%s %s %s %s\n", LocalDateTime.now().toString(),Thread.currentThread(), exchange.getRequestMethod(), exchange.getRequestURI());
      sleep(5_000);
      String resp = "Hello, world!";
      exchange.sendResponseHeaders(200, resp.length());
      OutputStream os = exchange.getResponseBody();
      os.write(resp.getBytes());
      os.close();
    });

    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.start();
}

再次发送多个 HTTP 请求。 从日志中我们可以看到,线程名字变成了 VirtualThreadXXXX。而且时间戳也显示了,多个请求被并发处理,而且9个请求只用到了2个线程。

2022-09-11T23:39:50.842384 VirtualThread[#26]/[email protected] GET /hello
2022-09-11T23:39:51.069154 VirtualThread[#32]/[email protected] GET /hello
2022-09-11T23:39:51.285328 VirtualThread[#33]/[email protected] GET /hello
2022-09-11T23:39:51.475797 VirtualThread[#34]/[email protected] GET /hello
2022-09-11T23:39:51.677515 VirtualThread[#36]/[email protected] GET /hello
2022-09-11T23:39:51.875060 VirtualThread[#37]/[email protected] GET /hello
2022-09-11T23:39:52.069483 VirtualThread[#38]/[email protected] GET /hello
2022-09-11T23:39:52.252484 VirtualThread[#39]/[email protected] GET /hello
2022-09-11T23:39:52.454167 VirtualThread[#40]/[email protected] GET /hello

有了虚拟线程的加持,HttpServer 和 Simple Web Server 可以更广泛地用于各种测试场景或者公司内部的小型工程中。而且使用方式上更加友好直观,显著减轻了智力负担。 哪怕是实习生同事开发,即使不懂得异步编程,也能实现一个性能相当可以的 HTTP 服务。

References

JEP 408: Simple Web Server

Java 18’s Simple Web Server: A tool for the command line and beyond

JWebServer.java | openjdk | GitHub