不知道是不是跟我一样,很羡慕 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 之类的麻烦玩意。
一点都不一样,你可以在代码中使用 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();
就是这么简单。
不过,不知道大家知不知道,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 也是很早就提供的工具来。
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 设计都难用的很一致。
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]/runnable@ForkJoinPool-1-worker-1 GET /hello
2022-09-11T23:39:51.069154 VirtualThread[#32]/runnable@ForkJoinPool-1-worker-1 GET /hello
2022-09-11T23:39:51.285328 VirtualThread[#33]/runnable@ForkJoinPool-1-worker-1 GET /hello
2022-09-11T23:39:51.475797 VirtualThread[#34]/runnable@ForkJoinPool-1-worker-1 GET /hello
2022-09-11T23:39:51.677515 VirtualThread[#36]/runnable@ForkJoinPool-1-worker-5 GET /hello
2022-09-11T23:39:51.875060 VirtualThread[#37]/runnable@ForkJoinPool-1-worker-5 GET /hello
2022-09-11T23:39:52.069483 VirtualThread[#38]/runnable@ForkJoinPool-1-worker-5 GET /hello
2022-09-11T23:39:52.252484 VirtualThread[#39]/runnable@ForkJoinPool-1-worker-5 GET /hello
2022-09-11T23:39:52.454167 VirtualThread[#40]/runnable@ForkJoinPool-1-worker-5 GET /hello
有了虚拟线程的加持,HttpServer 和 Simple Web Server 可以更广泛地用于各种测试场景或者公司内部的小型工程中。而且使用方式上更加友好直观,显著减轻了智力负担。
哪怕是实习生同事开发,即使不懂得异步编程,也能实现一个性能相当可以的 HTTP 服务。
Java 18’s Simple Web Server: A tool for the command line and beyond