不用任何库,JDK 就能发起 HTTP 请求,也能提供 HTTP 服务。学会 HttpClient 的同步和异步用法,再用内置的 HttpServer 和虚拟线程搭一个小型 JSON 任务服务,状态码正确,关闭干净。
JDK 自带 HTTP 的两端。java.net.http.HttpClient 发送请求,com.sun.net.httpserver.HttpServer 回应请求。两者都不需要库,合起来足够做一个小型内部服务、测试里的假服务器,或者一个调用 API 的工具。
本文先讲客户端、异步请求、超时、重定向,以及服务器发送响应时的规则,然后把一个小型任务服务搭成真正的模块。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java。检查这些程序的机器不能上网,所以每个程序都在端口 0 上启动自己的服务器,再去调用它。
端口 0 上的服务器,和一个调用它的客户端
HttpServer 监听一个端口,把每个请求交给为某个路径注册的处理器。端口 0 让操作系统随便给一个空闲端口,示例和测试要的正是这个。下面这个程序启动一个带一个处理器的服务器,调用它四次,然后停掉它:
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
// Port 0 asks the operating system for any free port.
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/hello", exchange -> {
String text = "hello, you asked for " + exchange.getRequestURI().getPath() + "\n";
byte[] body = text.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
int port = server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String path : List.of("/hello", "/hello/world", "/helloworld", "/")) {
var uri = URI.create("http://localhost:" + port + path);
var response = client.send(HttpRequest.newBuilder(uri).build(),
HttpResponse.BodyHandlers.ofString());
IO.println(response.statusCode() + " " + path + ": " + response.body().strip());
}
}
server.stop(0);
}
输出:
200 /hello: hello, you asked for /hello
200 /hello/world: hello, you asked for /hello/world
200 /helloworld: hello, you asked for /helloworld
404 /: <h1>404 Not Found</h1>No context found for request
处理器收到一个 HttpExchange,里面装着请求和响应。它先设置一个响应头,再用状态码和响应体的字节长度调用 sendResponseHeaders,最后写出响应体。getAddress().getPort() 告诉你服务器拿到了哪个端口,stop(0) 不等待,直接停掉服务器。
看看 /helloworld。上下文会匹配所有以它的字符串开头的路径,所以 /hello 匹配上了它。没有上下文的路径会得到 JDK 自己的 HTML 404 页面。处理器如果在意精确的路径,就得自己检查。
这些导入省不掉。紧凑源文件会自动替你导入 java.base,但 java.net.http 和 jdk.httpserver 是单独的模块。
HttpClient:构建请求、发送、读取响应
HttpClient 发送 HttpRequest 对象,返回 HttpResponse 对象。建一个就反复使用,因为它维护着一个打开的连接池。HttpClient 在 Java 11 成为标准 API,javac --release 10 找不到 java.net.http 包。
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/echo", exchange -> {
String sent = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
String text = exchange.getRequestMethod() + " with "
+ exchange.getRequestHeaders().getFirst("Content-Type") + ": " + sent;
byte[] body = text.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("X-Echo-Length", String.valueOf(sent.length()));
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
URI echo = URI.create("http://localhost:" + server.getAddress().getPort() + "/echo");
try (HttpClient client = HttpClient.newHttpClient()) {
HttpRequest request = HttpRequest.newBuilder(echo)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"title\":\"Buy milk\"}"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
IO.println("status: " + response.statusCode());
IO.println("body: " + response.body());
IO.println("header: " + response.headers().firstValue("X-Echo-Length").orElse("none"));
IO.println("asked: " + client.version());
IO.println("got: " + response.version());
}
server.stop(0);
}
输出:
status: 200
body: POST with application/json: {"title":"Buy milk"}
header: 20
asked: HTTP_2
got: HTTP_1_1
send 会一直阻塞,直到响应到达。响应体处理器决定响应体变成什么:这里是 ofString(),也可以是 ofByteArray()、ofFile(path) 和 discarding()。headers().firstValue 返回 Optional<String>,并且不区分名字的大小写。
最后两行后面会用到。客户端优先用 HTTP/2,但 JDK 的服务器用 HTTP/1.1 回应,客户端就默默改用了它。try 块会关闭客户端,HttpClient 从 Java 21 起支持这样做。
sendAsync 返回 CompletableFuture
sendAsync 发起请求后立即返回,带回一个 CompletableFuture,它在响应到达时完成。CompletableFuture 本身由讲 java.util.concurrent 的那一部分介绍。
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/square", exchange -> {
int n = Integer.parseInt(exchange.getRequestURI().getQuery().substring("n=".length()));
byte[] body = String.valueOf(n * n).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int n = 1; n <= 5; n++) {
var request = HttpRequest.newBuilder(URI.create(base + "/square?n=" + n)).build();
CompletableFuture<String> future = client
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body);
futures.add(future);
}
IO.println("sent " + futures.size() + " requests, none of them waited for another");
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
IO.println(futures.stream().map(CompletableFuture::join).toList());
}
server.stop(0);
}
输出:
sent 5 requests, none of them waited for another
[1, 4, 9, 16, 25]
循环发起了五个请求,一个都没等。thenApply(HttpResponse::body) 把每个“响应的 future”变成“响应体的 future”。CompletableFuture.allOf 等全部五个完成,之后每次 join 都立即返回。结果按 future 放进列表的顺序排列,而不是按响应到达的顺序。
服务器用 setExecutor 让每个请求跑在一个虚拟线程上。原因在下面的服务里解释。
超时和重定向
没有超时的客户端,可能会永远等一个始终不回应的服务器。HttpClient 有两种超时:客户端上的 connectTimeout 管建立连接,每个请求上的 timeout 管等待响应。跟随重定向也只是同一个构建器上的一行:
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
void reply(HttpExchange exchange, int status, String text) throws IOException {
byte[] body = text.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
}
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/old", exchange -> {
exchange.getResponseHeaders().set("Location", "/new");
reply(exchange, 302, "moved");
});
server.createContext("/new", exchange -> reply(exchange, 200, "the new page"));
server.createContext("/slow", exchange -> {
try {
Thread.sleep(Duration.ofSeconds(2));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
reply(exchange, 200, "finally");
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort();
try (HttpClient plain = HttpClient.newHttpClient();
HttpClient following = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.followRedirects(HttpClient.Redirect.NORMAL)
.build()) {
var old = HttpRequest.newBuilder(URI.create(base + "/old")).build();
var r1 = plain.send(old, HttpResponse.BodyHandlers.ofString());
IO.println("plain: " + r1.statusCode() + " " + r1.body()
+ ", Location " + r1.headers().firstValue("Location").orElse("none"));
var r2 = following.send(old, HttpResponse.BodyHandlers.ofString());
IO.println("following: " + r2.statusCode() + " " + r2.body()
+ ", from " + r2.uri().getPath());
var slow = HttpRequest.newBuilder(URI.create(base + "/slow"))
.timeout(Duration.ofMillis(200))
.build();
try {
following.send(slow, HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
IO.println("slow: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
server.stop(0);
}
输出:
plain: 302 moved, Location /new
following: 200 the new page, from /new
slow: HttpTimeoutException: request timed out
默认的重定向策略是 Redirect.NEVER,所以普通客户端返回了 302 和它的 Location 响应头。Redirect.NORMAL 会跟随重定向,只是不会从 HTTPS 地址跳到 HTTP 地址。response.uri() 显示最终响应来自哪里。
慢请求在 200 毫秒后抛出 HttpTimeoutException,这时服务器上的处理器还在睡。它是 IOException 的子类。
项目:一个模块里的任务服务
本文剩下的部分搭一个小服务:它在内存里保存任务列表,并以 JSON 形式提供出去。它是一个具名模块 com.example.tasks,不用构建工具,也不用库。它回应四种请求:
| 请求 | 作用 | 成功 | 客户端错误 |
|---|---|---|---|
GET /tasks |
列出所有任务,按 id 排序 | 200 | |
POST /tasks |
创建一个任务 | 201,带 Location |
400、413、415、422 |
GET /tasks/{id} |
获取一个任务 | 200 | 404 |
DELETE /tasks/{id} |
删除一个任务 | 204 | 404 |
路径已知但方法不对,返回 405 和 Allow 响应头。其他路径返回 404,代码里的 bug 返回 500。每个错误的响应体形状都一样:{"error":"..."}。下面是整个项目,只省略了 .gitignore:
19-tasks-service/
├── run-checks.sh
├── checks/
│ └── SmokeCheck.java
└── src/
└── com.example.tasks/
├── module-info.java
└── com/example/tasks/
├── Main.java
├── http/
│ ├── Json.java
│ ├── TaskHandler.java
│ └── TaskServer.java
└── store/
├── InMemoryTaskStore.java
├── Task.java
└── TaskStore.java
这个模块依赖 JDK 的 HTTP 服务器模块,并导出两个包:
/** A small task list over HTTP, built on the JDK's own HTTP server. */
module com.example.tasks {
requires jdk.httpserver;
exports com.example.tasks.http;
exports com.example.tasks.store;
}
因为导出了 http 和 store,其他代码(比如测试)可以用任意存储启动服务器。讲测试和发布的那一部分会用 jlink 从这个模块构建一个小型运行时。
存储:挡在 map 前面的接口
任务是 record Task(long id, String title, boolean done),HTTP 代码只通过一个接口访问任务。这样测试或者以后的数据库版本就能传入别的存储:
package com.example.tasks.store;
import java.util.List;
import java.util.Optional;
/** Everything the HTTP layer needs from storage. Implementations must be thread-safe. */
public interface TaskStore {
/** Returns every task, sorted by id. */
List<Task> list();
/** Returns the task with this id, or an empty Optional. */
Optional<Task> get(long id);
/** Stores a new task under the next id and returns it. */
Task create(String title, boolean done);
/** Deletes the task with this id. Returns false if there was none. */
boolean delete(long id);
}
内存版本用 ConcurrentHashMap 存任务,用 AtomicLong 生成 id:
/** Keeps tasks in memory. Safe for many requests at once; lost on restart. */
public final class InMemoryTaskStore implements TaskStore {
private final ConcurrentHashMap<Long, Task> tasks = new ConcurrentHashMap<>();
private final AtomicLong lastId = new AtomicLong();
/** Creates an empty store. The first task gets id 1. */
public InMemoryTaskStore() {
}
@Override
public List<Task> list() {
return tasks.values().stream()
.sorted(Comparator.comparingLong(Task::id))
.toList();
}
@Override
public Optional<Task> get(long id) {
return Optional.ofNullable(tasks.get(id));
}
@Override
public Task create(String title, boolean done) {
var task = new Task(lastId.incrementAndGet(), title, done);
tasks.put(task.id(), task);
return task;
}
@Override
public boolean delete(long id) {
return tasks.remove(id) != null;
}
}
请求是同时运行的,所以两个 POST 请求可能同时进入 create。incrementAndGet 照样给每个请求不同的 id,而且不用锁。list 按 id 排序,因为哈希表没有有用的顺序。在 create 期间运行的 list 可能包含新任务,也可能不包含,因为遍历 ConcurrentHashMap 并不会冻结它。
每个请求一个虚拟线程
TaskServer 负责构建服务器。它把依赖(一个存储和一个端口)作为参数接收,这样测试就能用新的存储在端口 0 上启动一个:
/**
* Starts serving the store on the given port, on every local address.
* Port 0 asks the operating system for a free port; port() says which one it got.
*/
public static TaskServer start(TaskStore store, int port) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
server.setExecutor(executor);
server.createContext("/", new TaskHandler(store));
server.start();
return new TaskServer(server, executor);
}
/ 上的一个上下文把所有请求都交给同一个处理器。setExecutor 这一行决定每个请求由哪个线程运行。没有它,JDK 会在服务器唯一的分发线程上运行所有处理器,一次处理一个请求。下面这个程序向一个会等待 300 毫秒的处理器同时发送五个请求,先不用执行器,再用虚拟线程:
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.atomic.AtomicInteger;
/** Sends 5 requests at once. Each handler waits 300 ms, like a slow database call. */
void run(String label, Executor executor) throws Exception {
var inFlight = new AtomicInteger();
var mostAtOnce = new AtomicInteger();
var virtual = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.setExecutor(executor);
server.createContext("/work", exchange -> {
mostAtOnce.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
if (Thread.currentThread().isVirtual()) {
virtual.incrementAndGet();
}
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
inFlight.decrementAndGet();
exchange.sendResponseHeaders(204, -1);
exchange.close();
});
server.start();
var uri = URI.create("http://localhost:" + server.getAddress().getPort() + "/work");
try (HttpClient client = HttpClient.newHttpClient()) {
var futures = new ArrayList<CompletableFuture<HttpResponse<Void>>>();
for (int i = 0; i < 5; i++) {
futures.add(client.sendAsync(HttpRequest.newBuilder(uri).build(),
HttpResponse.BodyHandlers.discarding()));
}
futures.forEach(CompletableFuture::join);
}
server.stop(0);
IO.println(label + ": at most " + mostAtOnce.get() + " at once, "
+ virtual.get() + " of 5 on virtual threads");
}
void main() throws Exception {
run("no executor ", null);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
run("virtual threads", executor);
}
}
输出:
no executor : at most 1 at once, 0 of 5 on virtual threads
virtual threads: at most 5 at once, 5 of 5 on virtual threads
没有执行器时,请求一个接一个排队。用了 Executors.newVirtualThreadPerTaskExecutor() 之后,五个处理器在同一时刻都在等待,各自跑在自己的虚拟线程上。虚拟线程在 Java 21 成为正式特性,讲虚拟线程的那一部分会介绍它的原理。
用十岁孩子能懂的话说
把服务器想成一家只有一个柜台的商店。门口站着一个人,留意每一位进门的顾客。
没有执行器时,还是这个人来接待每一位顾客。如果某位顾客要的东西得去仓库取,后面的人都得等到东西拿回来。
有了虚拟线程,每位顾客一进门就有自己的帮手。帮手几乎不花钱,所以商店可以有成千上万个。帮手等仓库的时候会离开柜台,让别的帮手去用。
准确的说法
HttpServer 有一个分发线程,负责盯着所有连接。某个请求准备好后,分发线程把一个任务交给执行器,这个任务读取请求并调用你的处理器。newVirtualThreadPerTaskExecutor() 为每个任务启动一个新的虚拟线程。处理器阻塞时,比如在 Thread.sleep 里或者读 socket 时,它的虚拟线程会让出正在使用的平台线程,这个平台线程叫作载体线程。载体线程只有几个,默认大约每个 CPU 核心一个,它们可以腾出来运行别的虚拟线程。
TaskServer.stop 在 server.stop 之后关闭执行器,因为 HttpServer 从不关闭你交给它的执行器。
这个比喻的局限:帮手不会让仓库变大。如果一万个请求都在等一个只允许十个连接的数据库,就有 9,990 个虚拟线程在那里排队。对于一直占用 CPU 的工作,虚拟线程也不会让它变快,因为这种工作运行多久,就要占用载体线程多久。只有当请求大部分时间都在等待时,虚拟线程才有帮助。
在处理器里按方法和路径路由
处理器把方法和路径变成对存储的调用。JDK 没有路由器,所以就是普通的 if 和 switch:
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
try {
route(exchange);
} catch (RequestException e) {
sendError(exchange, e.status, e.getMessage());
} catch (RuntimeException e) {
LOG.log(Level.ERROR, "request failed: " + exchange.getRequestURI(), e);
if (exchange.getResponseCode() == -1) {
sendError(exchange, 500, "internal server error");
}
}
}
}
private void route(HttpExchange exchange) throws IOException, RequestException {
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
if (path.equals("/tasks")) {
switch (method) {
case "GET" -> sendJson(exchange, 200, Json.tasks(store.list()));
case "POST" -> createTask(exchange);
default -> throw methodNotAllowed(exchange, "GET, POST");
}
} else if (path.startsWith("/tasks/")) {
String rawId = path.substring("/tasks/".length());
switch (method) {
case "GET" -> getTask(exchange, parseId(rawId));
case "DELETE" -> deleteTask(exchange, parseId(rawId));
default -> throw methodNotAllowed(exchange, "GET, DELETE");
}
} else {
throw new RequestException(404, "not found");
}
}
HttpExchange 实现了 AutoCloseable,所以外层的 try 总会结束这次交换。内层的 try 把 RequestException 变成错误响应。其他任何 RuntimeException 都是 bug:先记录日志,如果还没有发出状态码,就给客户端返回 500,getResponseCode() == -1 表示的就是这个意思。没有这个 catch,JDK 只会关闭连接,客户端连状态码都拿不到。
route 比较的是整个路径,所以 /tasksfoo 是 404。方法不对时返回 405,并带上 HTTP 要求它必须带的 Allow 响应头:
private static RequestException methodNotAllowed(HttpExchange exchange, String allow) {
exchange.getResponseHeaders().set("Allow", allow);
return new RequestException(405, "method not allowed");
}
/** Only a positive decimal number names a task: "7" does; "+7", "07x" and "-3" don't. */
private static long parseId(String raw) throws RequestException {
boolean digits = !raw.isEmpty() && raw.length() <= 18
&& raw.chars().allMatch(c -> c >= '0' && c <= '9');
long id = digits ? Long.parseLong(raw) : 0;
if (id <= 0) {
throw new RequestException(404, "task not found");
}
return id;
}
数字检查放在 Long.parseLong 之前,因为 parseLong 接受 "+7"。/tasks/abc、/tasks/0 和 /tasks/+7 都返回 404。RequestException 是一个带状态码的受检异常:
/** A problem with the request that the client can fix. */
static final class RequestException extends Exception {
@Serial
private static final long serialVersionUID = 1L;
final int status;
RequestException(int status, String message) {
super(message);
this.status = status;
}
}
读取有大小限制的请求体
请求体来自发请求的任何人,所以服务在解析之前先检查它的类型,并限制它的大小:
private void createTask(HttpExchange exchange) throws IOException, RequestException {
requireJson(exchange);
NewTask input = parseNewTask(readBody(exchange));
Task task = store.create(input.title(), input.done());
exchange.getResponseHeaders().set("Location", "/tasks/" + task.id());
sendJson(exchange, 201, Json.task(task));
}
private static void requireJson(HttpExchange exchange) throws RequestException {
String type = exchange.getRequestHeaders().getFirst("Content-Type");
String mediaType = type == null ? "" : type.split(";", 2)[0].strip();
if (!mediaType.equalsIgnoreCase("application/json")) {
throw new RequestException(415, "Content-Type must be application/json");
}
}
/** Reads the whole body as UTF-8, but never more than MAX_BODY_BYTES + 1 bytes of it. */
private static String readBody(HttpExchange exchange) throws IOException, RequestException {
byte[] bytes;
try (InputStream in = exchange.getRequestBody()) {
bytes = in.readNBytes(MAX_BODY_BYTES + 1);
}
if (bytes.length > MAX_BODY_BYTES) {
throw new RequestException(413,
"request body must not be larger than " + MAX_BODY_BYTES + " bytes");
}
try {
return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
throw new RequestException(400, "request body must be UTF-8");
}
}
当 Content-Type 不是 application/json 时,requireJson 返回 415 Unsupported Media Type。请求体本身也许没问题,这个状态码说的是标签不对。
readNBytes(MAX_BODY_BYTES + 1) 最多读 16,385 个字节,多出的这一个字节用来区分“太大”和“正好到上限”。超过上限就返回 413 Content Too Large。代码自己数字节,而不是相信 Content-Length,因为客户端可能把它写错,也可能根本不发。
newDecoder() 得到的解码器会把错误的 UTF-8 报告为错误。换成 new String(bytes, UTF_8),它会悄悄把坏字节替换成 �。
不用库的 JSON
JDK 没有 JSON API,Java 25 里也没有相关的模块:
$ java --list-modules | grep -c .
69
$ java --list-modules | grep -ci json
0
真实的服务会用 Jackson 或 Gson 这样的库。这个项目只用 JDK,所以手写 JSON 输出,并且只读取一个小而严格的子集。写出是简单的那一半:
static String task(Task task) {
return "{\"id\":" + task.id()
+ ",\"title\":" + string(task.title())
+ ",\"done\":" + task.done() + "}";
}
static String tasks(List<Task> tasks) {
return tasks.stream().map(Json::task).collect(Collectors.joining(",", "[", "]"));
}
static String error(String message) {
return "{\"error\":" + string(message) + "}";
}
/** Quotes a string, escaping what JSON requires: quote, backslash, control characters. */
static String string(String s) {
var out = new StringBuilder(s.length() + 2).append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> {
if (c < 0x20) {
out.append(String.format("\\u%04x", (int) c));
} else {
out.append(c);
}
}
}
}
return out.append('"').toString();
}
JSON 要求对引号、反斜杠和控制字符转义。其他字符都可以原样输出。
读取才是更难做的决定。一个老实的办法是接受 text/plain 格式的标题,完全不解析 JSON。但 JSON API 的客户端发来的就是 JSON,所以服务接受 JSON,并且把接受到什么程度说清楚:
/**
* Parses one flat JSON object whose values are strings, true or false.
* Numbers, null, arrays, nested objects and repeated names are rejected
* on purpose: this service never needs them.
*/
static Map<String, Object> parseObject(String text) {
return new Parser(text).object();
}
解析器只读一个扁平对象,值只能是字符串、true 或 false。其他任何内容都返回 400 和一条消息,绝不猜测。这已经覆盖了任务需要的一切。等服务真需要更多的那天,该做的是引入一个库,而不是把这个解析器越写越大。
接着由 parseNewTask 检查字段:
private static NewTask parseNewTask(String body) throws RequestException {
Map<String, Object> fields;
try {
fields = Json.parseObject(body);
} catch (Json.BadJson e) {
throw new RequestException(400, e.getMessage());
}
for (String name : fields.keySet()) {
if (!name.equals("title") && !name.equals("done")) {
throw new RequestException(400, "unknown field " + Json.string(name));
}
}
if (!(fields.getOrDefault("title", "") instanceof String rawTitle)) {
throw new RequestException(400, "field \"title\" must be a string");
}
if (!(fields.getOrDefault("done", false) instanceof Boolean done)) {
throw new RequestException(400, "field \"done\" must be true or false");
}
String title = rawTitle.strip();
if (title.isEmpty()) {
throw new RequestException(422, "title must not be empty");
}
if (title.codePointCount(0, title.length()) > MAX_TITLE_LENGTH) {
throw new RequestException(422,
"title must be at most " + MAX_TITLE_LENGTH + " characters");
}
return new NewTask(title, done);
}
像 "id" 这样的未知字段返回 400,所以客户端没法自己挑 id,"titel" 这样的拼写错误也不会被悄悄忽略。空白标题返回 422 Unprocessable Content:服务器能正常读懂请求,但某个值违反了规则。长度按码点计算,所以中文标题同样可以有 200 个字符。
写出响应:sendResponseHeaders 的三种长度
sendResponseHeaders(status, length) 发出状态行和响应头,第二个参数说明后面跟着什么样的响应体。服务里的每个 JSON 响应都经过同一个方法:
private static void sendJson(HttpExchange exchange, int status, String json)
throws IOException {
byte[] body = (json + "\n").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
}
private static void sendError(HttpExchange exchange, int status, String message)
throws IOException {
sendJson(exchange, status, Json.error(message));
}
响应头要先设置,因为它们和状态码一起发出。长度按 UTF-8 字节计算,而不是字符:"Zoë" 是 3 个字符,却有 4 个字节。删除操作不发送响应体:
private void deleteTask(HttpExchange exchange, long id) throws IOException, RequestException {
if (!store.delete(id)) {
throw new RequestException(404, "task not found");
}
exchange.sendResponseHeaders(204, -1);
}
这个 -1 很重要,它和 0 的区别也很重要。下面这个程序把每种长度都试一遍,外加一个说谎的长度:
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
byte[] hello = "hello".getBytes(StandardCharsets.UTF_8);
server.createContext("/length", exchange -> {
exchange.sendResponseHeaders(200, hello.length); // exactly 5 bytes follow
try (var out = exchange.getResponseBody()) {
out.write(hello);
}
});
server.createContext("/chunked", exchange -> {
exchange.sendResponseHeaders(200, 0); // 0: any amount, sent in chunks
try (var out = exchange.getResponseBody()) {
out.write(hello);
out.write(hello);
}
});
server.createContext("/nobody", exchange -> {
exchange.sendResponseHeaders(204, -1); // -1: no body at all
exchange.close();
});
server.createContext("/toomany", exchange -> {
exchange.sendResponseHeaders(200, 2); // promises 2 bytes, writes 5
try (var out = exchange.getResponseBody()) {
out.write(hello);
} catch (IOException e) {
IO.println(" server: " + e.getMessage());
throw e;
}
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String path : List.of("/length", "/chunked", "/nobody", "/toomany")) {
IO.println(path);
var request = HttpRequest.newBuilder(URI.create(base + path))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
try {
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var headers = response.headers();
IO.println(" " + response.statusCode() + " [" + response.body() + "]"
+ " content-length=" + headers.firstValue("content-length").orElse("-")
+ " transfer-encoding="
+ headers.firstValue("transfer-encoding").orElse("-"));
} catch (IOException e) {
IO.println(" client: " + e.getMessage());
}
}
}
server.stop(0);
}
输出:
/length
200 [hello] content-length=5 transfer-encoding=-
/chunked
200 [hellohello] content-length=- transfer-encoding=chunked
/nobody
204 [] content-length=- transfer-encoding=-
/toomany
server: too many bytes to write to stream
client: HTTP/1.1 header parser received no bytes
正数长度会发出 Content-Length 响应头,响应体必须正好这么长。0 不表示“空”,而是表示“长度未知”,所以服务器把响应体分成任意大小的块发送。-1 表示没有响应体。204 绝对不能带响应体。
我们给 204 传 0 时,JDK 往标准错误输出记了一条 WARNING: sendResponseHeaders: rCode = 204: forcing contentLen = -1。写出的字节比承诺的多,会抛出 IOException。处理器让它逃出去,服务器就关闭连接。我们的第一个版本捕获了这个异常然后继续执行,结果客户端一直挂着,直到我们把它杀掉。
状态码和统一的错误形状
客户端首先看状态码,所以服务里的每个状态码都是有意挑选的,每个错误响应体的形状也都一样:{"error":"..."}。
- 400: 请求体读不懂:JSON 有误、字段未知、类型不对或 UTF-8 有误。
- 404: 没有这个任务,或者没有这个路径。
- 405: 路径存在,但不支持这个方法。
Allow列出可用的方法。 - 413: 请求体超过 16 KiB。
- 415:
Content-Type不是application/json。 - 422: 标题为空白或太长。
- 500: bug。细节写进日志,不发给客户端。
Main:在端口上启动,用关闭钩子停止
Main 从参数里读取一个可选的端口,用内存存储启动服务,并注册一个关闭钩子:
package com.example.tasks;
import java.io.IOException;
import com.example.tasks.http.TaskServer;
import com.example.tasks.store.InMemoryTaskStore;
/** Starts the tasks service. Usage: Main [port], where the port defaults to 8080. */
public final class Main {
private Main() {
}
public static void main(String[] args) throws IOException {
int port = args.length == 0 ? 8080 : parsePort(args);
TaskServer server = TaskServer.start(new InMemoryTaskStore(), port);
IO.println("listening on port " + server.port());
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
IO.println("stopping");
server.stop(2);
IO.println("stopped");
}));
}
private static int parsePort(String[] args) {
try {
int port = Integer.parseInt(args[0]);
if (args.length == 1 && port >= 0 && port <= 65535) {
return port;
}
} catch (NumberFormatException e) {
// Fall through to the usage message.
}
System.err.println("usage: tasks [port] (0 to 65535, default 8080)");
System.exit(2);
return -1;
}
}
main 马上就返回了,JVM 却继续运行,因为服务器的分发线程不是守护线程。
关闭钩子是 JVM 被要求退出时启动的一个线程,按 Ctrl+C 和收到 SIGTERM 都算。这个钩子调用 stop(2),它停止接受新连接,并给正在处理的请求最多两秒时间完成。kill -9 时钩子不会运行。
运行服务,用 curl 调用它
和任何模块化程序一样,服务从模块路径运行。在项目目录里,在一个终端中编译并启动它:
$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.tasks
$ java -p out -m com.example.tasks/com.example.tasks.Main
listening on port 8080
然后在第二个终端里调用它。-d 让 curl 发送 POST,-w '%{http_code}\n' 在响应体后面打印状态码:
$ curl -s localhost:8080/tasks
[]
$ curl -s -i localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Buy milk"}' | grep -v '^Date:'
HTTP/1.1 201 Created
Content-type: application/json
Content-length: 41
Location: /tasks/1
{"id":1,"title":"Buy milk","done":false}
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Walk the dog", "done": true}'
{"id":2,"title":"Walk the dog","done":true}
$ curl -s localhost:8080/tasks
[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Walk the dog","done":true}]
$ curl -s -X DELETE localhost:8080/tasks/1 -w '%{http_code}\n'
204
$ curl -s localhost:8080/tasks/1 -w '%{http_code}\n'
{"error":"task not found"}
404
$ curl -s -i -X PATCH localhost:8080/tasks/2 | grep -v '^Date:'
HTTP/1.1 405 Method Not Allowed
Allow: GET, DELETE
Content-type: application/json
Content-length: 31
{"error":"method not allowed"}
$ curl -s localhost:8080/tasks -d 'title=Walk the dog' -w '%{http_code}\n'
{"error":"Content-Type must be application/json"}
415
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": " "}' -w '%{http_code}\n'
{"error":"title must not be empty"}
422
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": 42}' -w '%{http_code}\n'
{"error":"only strings, true and false are accepted as values"}
400
$ printf '{"title": "%s"}' "$(printf 'a%.0s' {1..20000})" > big.json
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d @big.json -w '%{http_code}\n'
{"error":"request body must not be larger than 16384 bytes"}
413
$ curl -s --http2 localhost:8080/tasks -o /dev/null -w '%{http_version}\n'
1.1
grep 去掉了每秒都在变的 Date 响应头。JDK 发送的是 Content-type,不是 Content-Type,因为它只把响应头名字的首字母大写。响应头名字不区分大小写,所以客户端并不在意。最后一条命令请求升级到 HTTP/2,服务器仍然停留在 1.1。
在第一个终端按下 Ctrl+C,打印出 stopping 和 stopped,JVM 以状态码 130 退出。
只用 JDK 工具检查项目
项目的 run-checks.sh 只用 javac 和 java。它先把警告当错误编译模块,再编译并运行 checks/SmokeCheck.java,后者在自己的 JVM 里于端口 0 上启动服务:
public static void main(String[] args) throws Exception {
try (TaskServer server = TaskServer.start(new InMemoryTaskStore(), 0);
HttpClient c = HttpClient.newHttpClient()) {
client = c;
base = "http://localhost:" + server.port();
checkEndpoints();
checkBadRequests();
checkConcurrentCreates();
}
if (failures > 0) {
IO.println(failures + " check(s) failed");
System.exit(1);
}
}
它用 HttpClient 调用每个端点,比较状态码、响应头和响应体,其中包括同时发出的 100 个 POST 请求,它们必须拿到 100 个不同的 id。然后脚本在端口 0 上启动真正的 Main,给它发送 SIGTERM,检查钩子是否运行了:
$ ./run-checks.sh
ok compiled com.example.tasks
ok smoke check: 45 checks passed
ok Main starts on port 0 and stops cleanly on SIGTERM
ok a bad port prints usage and exits 2
ok tests: 18 passed, 0 failed
ok java -p tasks.jar -m com.example.tasks starts and stops
ok jlink image with com.example.tasks,java.base,jdk.httpserver starts and stops
all checks passed
前四行是这一部分的检查。这是冒烟检查,不是测试套件。后三行来自下一部分,它为这个项目搭一套测试工具,并用 jlink 和 jpackage 打包。
com.sun.net.httpserver 做不到的事
别看名字,com.sun.net.httpserver 是受支持的 JDK API,由 jdk.httpserver 模块导出。它适合小型内部服务、工具和测试里的假服务器。要做面向公众的生产服务,它留给你自己写的东西很多:
- 不支持 HTTP/2。
HttpClient和curl --http2拿到的都是 HTTP/1.1,curl --http2-prior-knowledge得不到任何响应,模块里也没有 HTTP/2 相关的类。 - 没有路由器。 上下文按字符串前缀匹配,没有路径参数,也不能按方法路由。
- 没有 JSON,也没有校验。
Json类得你自己维护。 - 限制是系统属性,不是 API。 时间和响应头的限制是
-D参数,比如sun.net.httpserver.maxReqTime。 - 没有 WebSocket 服务器,虽然
java.net.http有 WebSocket 客户端。 - HTTPS 要手动配置。
HttpsServer是有的,但SSLContext得你自己配。
做生产服务,常见的选择有 Jetty、基于 Netty 的框架(比如 Vert.x 或 Micronaut),以及 Spring Boot。它们提供路由、HTTP/2、JSON 绑定、TLS 配置和指标。本文里的存储接口和状态码可以原样搬到其中任何一个上。
要点
HttpClient.newHttpClient()构建一个可复用的客户端。send会阻塞,sendAsync返回CompletableFuture,BodyHandlers.ofString()把响应体读成文本。- 在客户端上设置
connectTimeout,在每个请求上设置timeout。除非设置Redirect.NORMAL,否则不会跟随重定向。 HttpServer的上下文按字符串前缀匹配,所以要在处理器里检查精确的路径和方法。端口 0 会挑一个空闲端口,getAddress().getPort()告诉你是哪个。sendResponseHeaders接收字节数:正数是精确长度,0表示分块传输,-1表示没有响应体。调用它之前先设置响应头。- 没有执行器时,一个线程处理所有请求。
setExecutor(Executors.newVirtualThreadPerTaskExecutor())让每个请求有自己的虚拟线程。 - JDK 没有 JSON API。要么手写一个小而严格的子集,要么用 Jackson 或 Gson。
- 限制请求体大小,所有错误都用同一种 JSON 形状回应,并用关闭钩子停止服务器。
JDK 的 HTTP 服务器足够撑起一个小服务,前提是你清楚哪些部分现在得自己写。