Blog

只用 JDK 做 Java HTTP:HttpClient 与一个小型 HttpServer

不用任何库,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.httpjdk.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;
}

因为导出了 httpstore,其他代码(比如测试)可以用任意存储启动服务器。讲测试和发布的那一部分会用 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 请求可能同时进入 createincrementAndGet 照样给每个请求不同的 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.stopserver.stop 之后关闭执行器,因为 HttpServer 从不关闭你交给它的执行器。

这个比喻的局限:帮手不会让仓库变大。如果一万个请求都在等一个只允许十个连接的数据库,就有 9,990 个虚拟线程在那里排队。对于一直占用 CPU 的工作,虚拟线程也不会让它变快,因为这种工作运行多久,就要占用载体线程多久。只有当请求大部分时间都在等待时,虚拟线程才有帮助。

在处理器里按方法和路径路由

处理器把方法和路径变成对存储的调用。JDK 没有路由器,所以就是普通的 ifswitch

    @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 总会结束这次交换。内层的 tryRequestException 变成错误响应。其他任何 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();
    }

解析器只读一个扁平对象,值只能是字符串、truefalse。其他任何内容都返回 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

然后在第二个终端里调用它。-dcurl 发送 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,打印出 stoppingstopped,JVM 以状态码 130 退出。

只用 JDK 工具检查项目

项目的 run-checks.sh 只用 javacjava。它先把警告当错误编译模块,再编译并运行 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

前四行是这一部分的检查。这是冒烟检查,不是测试套件。后三行来自下一部分,它为这个项目搭一套测试工具,并用 jlinkjpackage 打包。

com.sun.net.httpserver 做不到的事

别看名字,com.sun.net.httpserver 是受支持的 JDK API,由 jdk.httpserver 模块导出。它适合小型内部服务、工具和测试里的假服务器。要做面向公众的生产服务,它留给你自己写的东西很多:

  • 不支持 HTTP/2。 HttpClientcurl --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 返回 CompletableFutureBodyHandlers.ofString() 把响应体读成文本。
  • 在客户端上设置 connectTimeout,在每个请求上设置 timeout。除非设置 Redirect.NORMAL,否则不会跟随重定向。
  • HttpServer 的上下文按字符串前缀匹配,所以要在处理器里检查精确的路径和方法。端口 0 会挑一个空闲端口,getAddress().getPort() 告诉你是哪个。
  • sendResponseHeaders 接收字节数:正数是精确长度,0 表示分块传输,-1 表示没有响应体。调用它之前先设置响应头。
  • 没有执行器时,一个线程处理所有请求。setExecutor(Executors.newVirtualThreadPerTaskExecutor()) 让每个请求有自己的虚拟线程。
  • JDK 没有 JSON API。要么手写一个小而严格的子集,要么用 Jackson 或 Gson。
  • 限制请求体大小,所有错误都用同一种 JSON 形状回应,并用关闭钩子停止服务器。

JDK 的 HTTP 服务器足够撑起一个小服务,前提是你清楚哪些部分现在得自己写。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。