《Java 编程入门》18.3 用 com.sun.net.httpserver 暴露 REST 接口

不引入任何 Web 框架,用 JDK 自带的 com.sun.net.httpserver 把 LibraryService 暴露成 REST:手写扁平 JSON、实现 GET /books、POST /books、POST /loans、POST /returns,并用 curl 验证 201、409 等真实响应。

本节目标:把 CLI 背后的 LibraryService 原样暴露成四个 HTTP 接口,理解 JDK 内置 HTTP 服务器的能力边界,并能用 curl 验证成功与失败路径。
适用版本:JDK 25 LTS(Temurin 25.0.5);HTTP 服务器来自 JDK 模块 jdk.httpserver,不引入第三方库。

为什么用 JDK 自带的 HTTP 服务器

卷二会把 library 长成 libraryd,最终大概率用 Spring Boot 或类似的框架。但在卷一,先不引入框架有两个好处:

  • 看清 HTTP 的最小模型:方法、路径、请求头、请求体、状态码、响应体。框架会把这些藏起来,初学时容易只记住注解。
  • 验证分层是否真的解耦:如果 LibraryService 写得好,换一个入口不应该改业务代码。这一节就是检验。

com.sun.net.httpserver 在 JDK 的 jdk.httpserver 模块里,不在 java.base。它提供 HttpServer、HttpExchange、HttpHandler 三个核心类型,足够实现路由、读取请求、写响应。它不是生产级框架:没有注解路由、没有参数绑定、没有内置的 JSON、没有连接池治理。用它讲清楚原理,正好。

模块描述符要声明依赖:

module com.plume.library {
    requires jdk.httpserver;
    exports com.plume.library.domain;
    exports com.plume.library.service;
}

没有 requires jdk.httpserver,import com.sun.net.httpserver.HttpServer 会直接编译失败。这是模块系统比 classpath 严格的地方:用哪个 JDK 模块,要明说。

接口契约

四个接口覆盖借阅主链路:

方法与路径请求体成功响应主要失败
GET /books无200,图书数组无
POST /books{"isbn","title","author","year"}201,图书对象400 参数错误或 ISBN 重复
POST /loans{"isbn","memberId"}201,借阅回执404 书不存在、409 已借出
POST /returns{"isbn"}200,归还回执404 无在借记录

错误响应用统一形状:

{"error":"图书已借出: 9787111407010"}

状态码和业务错误码的映射放在 HTTP 层,不放进 LibraryService。服务层只抛 LibraryException,由入口决定「这个错误对 HTTP 是 404 还是 409」,对 CLI 则只是打印一行文字。

极简 JSON:只处理扁平对象

请求体只有四个字段,响应也是扁平对象或对象数组。手写一个只支持扁平对象的解析器,比引入 Jackson 更能看清边界。先看序列化:

public static String quote(String value) {
    if (value == null) return "null";
    StringBuilder out = new StringBuilder("\"");
    for (int i = 0; i < value.length(); i++) {
        char c = value.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("\\u%04x".formatted((int) c));
                else out.append(c);
            }
        }
    }
    return out.append('"').toString();
}

字符串必须转义引号、反斜杠和控制字符。不转义双引号,书名里的 " 会提前结束 JSON 字符串,客户端解析失败;不转义换行,响应会变成两行,严格解析器直接报错。第 15 章的 String 与文本块知识在这里变成了协议安全。

解析入口只接受一个 JSON 对象:

public static Map<String, String> parseFlatObject(String json) {
    if (json == null) throw new IllegalArgumentException("请求体不能为空");
    Cursor cursor = new Cursor(json);
    Map<String, String> values = new LinkedHashMap<>();
    cursor.expect('{');
    if (cursor.peek() == '}') {
        cursor.next();
        cursor.expectEnd();
        return values;
    }
    while (true) {
        String key = cursor.readString();
        cursor.expect(':');
        values.put(key, cursor.readValue());
        char separator = cursor.next();
        if (separator == '}') break;
        if (separator != ',') throw new IllegalArgumentException("JSON 对象缺少逗号或右花括号");
    }
    cursor.expectEnd();
    return values;
}

Cursor 是一个内部类,负责四件事:跳过空白、读带转义的字符串、读数字/布尔这样的裸值、确认末尾没有多余内容。关键实现如下(省略 next、expect 等短方法):

char peek() {
    while (index < text.length() && Character.isWhitespace(text.charAt(index))) index++;
    if (index >= text.length()) throw new IllegalArgumentException("JSON 提前结束");
    return text.charAt(index);
}

String readString() {
    expect('"');
    StringBuilder value = new StringBuilder();
    while (true) {
        if (index >= text.length()) throw new IllegalArgumentException("JSON 字符串未闭合");
        char c = text.charAt(index++);
        if (c == '"') return value.toString();
        if (c != '\\') { value.append(c); continue; }
        char escaped = next();
        switch (escaped) {
            case '"' -> value.append('"');
            case '\\' -> value.append('\\');
            case 'n' -> value.append('\n');
            case 'r' -> value.append('\r');
            case 't' -> value.append('\t');
            case 'u' -> value.append((char) Integer.parseInt(readHex4(), 16));
            default -> throw new IllegalArgumentException("非法转义: \\" + escaped);
        }
    }
}

注意 readString 里用 text.charAt(index++),不是 next()。next() 会先跳过空白,而字符串内部的空格是内容的一部分;用错方法会把 "Effective Java" 读成 "EffectiveJava"。

这个解析器只支持扁平对象,不支持嵌套、数组、null 字面量和数字类型区分。请求体刚好是扁平对象,所以够用;超出这个范围,应该换成熟库,而不是继续给手写解析器打补丁。

路由与处理器

服务器的启动逻辑很短:

public void start() throws IOException {
    server = HttpServer.create(new InetSocketAddress(port), 0);
    server.createContext("/books", this::handleBooks);
    server.createContext("/loans", this::handleLoans);
    server.createContext("/returns", this::handleReturns);
    server.setExecutor(executor);
    server.start();
}

createContext 按路径前缀匹配,不是精确匹配;/books 也会匹配 /books/123。本节的四个路径没有子路径,前缀匹配足够。setExecutor 传入固定大小线程池,避免所有请求挤在服务器的分发线程上。

/books 同时处理查询和新增:

private void handleBooks(HttpExchange exchange) throws IOException {
    try (exchange) {
        try {
            String method = exchange.getRequestMethod();
            if ("GET".equals(method)) {
                sendJson(exchange, 200, booksJson(service.listBooks()));
                return;
            }
            if ("POST".equals(method)) {
                Map<String, String> body = readJsonObject(exchange);
                Book book = new Book(
                        required(body, "isbn"),
                        required(body, "title"),
                        required(body, "author"),
                        intField(body, "year"));
                service.addBook(book);
                exchange.getResponseHeaders().set("Location", "/books/" + book.isbn());
                sendJson(exchange, 201, bookJson(new LibraryService.BookView(book, null)));
                return;
            }
            sendError(exchange, 405, "只支持 GET 和 POST");
        } catch (LibraryException e) {
            sendError(exchange, statusOf(e.errorCode()), e.getMessage());
        } catch (IllegalArgumentException e) {
            sendError(exchange, 400, e.getMessage());
        }
    }
}

try (exchange) 放在最外层,保证响应写完后连接被关闭。try/catch 必须在 try (exchange) 的内部:如果把 catch 写在资源块外面,异常触发时 exchange 已经关闭,sendError 再写响应就会得到空响应。这个问题在本机第一次联调时真实出现过,症状是 curl 收到 409 但没有响应体。

/loans 和 /returns 只接受 POST:

private void handleLoans(HttpExchange exchange) throws IOException {
    try (exchange) {
        try {
            if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "只支持 POST"); return; }
            Map<String, String> body = readJsonObject(exchange);
            sendJson(exchange, 201, loanJson(service.borrow(
                    required(body, "isbn"), required(body, "memberId"), LocalDate.now())));
        } catch (LibraryException e) {
            sendError(exchange, statusOf(e.errorCode()), e.getMessage());
        } catch (IllegalArgumentException e) {
            sendError(exchange, 400, e.getMessage());
        }
    }
}

private void handleReturns(HttpExchange exchange) throws IOException {
    try (exchange) {
        try {
            if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "只支持 POST"); return; }
            Map<String, String> body = readJsonObject(exchange);
            sendJson(exchange, 200, returnJson(service.returnBook(
                    required(body, "isbn"), LocalDate.now())));
        } catch (LibraryException e) {
            sendError(exchange, statusOf(e.errorCode()), e.getMessage());
        } catch (IllegalArgumentException e) {
            sendError(exchange, 400, e.getMessage());
        }
    }
}

readJsonObject 先检查 Content-Type 是否以 application/json 开头,不匹配就抛 INVALID_COMMAND;再用 readAllBytes() 读请求体,按 UTF-8 解码后交给 Json.parseFlatObject。头里的 charset 参数这里没有解析,因为客户端固定发 UTF-8;生产实现应该按头解析,或者明确拒绝非 UTF-8。

错误映射

ErrorCode 到状态码的映射只有一行 switch:INVALID_COMMAND 和 DUPLICATE_ISBN 到 400,BOOK_NOT_FOUND 和 LOAN_NOT_FOUND 到 404,BOOK_ALREADY_BORROWED 到 409。400 表示请求本身有问题,404 表示资源不存在,409 表示当前状态不允许这个操作(书已经借出)。把 BOOK_ALREADY_BORROWED 映射成 409 而不是 400,是因为请求格式完全正确,冲突来自服务端状态。

响应统一用 sendJson 写出:先把 body 按 UTF-8 转成字节数组,设置 Content-Type: application/json; charset=utf-8,再用 sendResponseHeaders(status, bytes.length) 写状态和长度,最后通过 getResponseBody() 输出。第二个参数是响应体长度,传 0 表示 chunked 或未知长度,传 -1 表示没有响应体;这里传准确值,客户端不需要猜。

构建与运行

pom.xml 的关键配置仍然是可执行 jar:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-jar-plugin</artifactId>
  <version>3.4.2</version>
  <configuration>
    <archive>
      <manifest>
        <mainClass>com.plume.library.LibraryApp</mainClass>
      </manifest>
    </archive>
  </configuration>
</plugin>

构建并以 serve 模式启动:

mvn -q clean package -DskipTests
java -jar target/library.jar serve 18080

本机输出:

libraryd listening on http://localhost:18080

另开一个终端,先新增一本书:

curl -s -i -X POST http://localhost:18080/books \
  -H 'Content-Type: application/json' \
  -d '{"isbn":"9787111407010","title":"Effective Java","author":"Joshua Bloch","year":2018}'

本机响应(节选):

HTTP/1.1 201 Created
Content-type: application/json; charset=utf-8
Location: /books/9787111407010

{"isbn":"9787111407010","title":"Effective Java","author":"Joshua Bloch","year":2018,"available":true}

查询:

curl -s http://localhost:18080/books
[{"isbn":"9787111407010","title":"Effective Java","author":"Joshua Bloch","year":2018,"available":true}]

借出:

curl -s -X POST http://localhost:18080/loans \
  -H 'Content-Type: application/json' \
  -d '{"isbn":"9787111407010","memberId":"m-001"}'
{"isbn":"9787111407010","memberId":"m-001","borrowedOn":"2026-10-27","dueOn":"2026-11-10"}

再借一次,验证冲突:

HTTP/1.1 409 Conflict
Content-type: application/json; charset=utf-8

{"error":"图书已借出: 9787111407010"}

归还:

curl -s -X POST http://localhost:18080/returns \
  -H 'Content-Type: application/json' \
  -d '{"isbn":"9787111407010"}'
{"isbn":"9787111407010","memberId":"m-001","returnedOn":"2026-10-27","overdueDays":0,"fineCents":0}

再查一次 /books,available 回到 true,说明借阅状态和仓储同步更新了。

生产边界

这套服务能跑,但离生产还有明显距离:

  • 数据只在内存里。 服务重启后全部丢失;多实例部署时各自持有一份不同的数据。
  • 没有鉴权。 任何人都能新增图书、替别人还书。
  • 没有分页和过滤。 GET /books 返回全部,书多了以后响应会越来越大。
  • 没有请求体大小限制,也没有优雅停机。 readAllBytes() 会把任意大的请求读进内存;stop(0) 立即停止,正在处理的请求可能被中断。
  • JSON 只支持扁平对象。 嵌套结构、数组、类型区分都不支持。

这些不是本节的失败,而是卷二、卷三的入口:数据库、连接池、鉴权、分页、限流、可观测性,都会在 libraryd 里逐项补上。卷一的目标是让读者看清「一个 HTTP 接口从请求到领域规则再回到响应」的完整链路,而不是交付一个生产服务。

常见错误与边界

「catch 写在 try (exchange) 外面。」 资源块先关闭 exchange,catch 里的 sendError 再写响应,客户端收到空 body。第一次联调时 409 没有响应体,原因就是这里。正确写法是把 catch 放进资源块内部。

「Content-Type 不检查。」 客户端发 application/x-www-form-urlencoded,服务端仍按 JSON 解析,报错信息会变成「JSON 期望 {」,排查方向被带偏。先检查头,再解析体。

「sendResponseHeaders 的长度传错。」 传 0 会启用 chunked 或未知长度,传 -1 表示无 body;传字节数组长度才是固定长度响应。长度和实际写入不一致,客户端会截断或挂起。

「把业务异常映射成 500。」 BOOK_ALREADY_BORROWED 是正常的业务冲突,不是服务器 bug。映射成 500 会让监控告警失真,也会让客户端误以为可以重试。

小结

  • com.sun.net.httpserver 在 jdk.httpserver 模块,module-info.java 必须 requires jdk.httpserver。
  • 四个接口:GET /books、POST /books、POST /loans、POST /returns,状态码按 201/200/400/404/409 区分。
  • JSON 手写解析只支持扁平对象,字符串必须转义引号、反斜杠和控制字符。
  • createContext 是前缀匹配;四个路径没有子路径,够用。
  • try (exchange) 与 catch 的嵌套顺序会直接影响错误响应是否能写出去。
  • 服务层不感知 HTTP,ErrorCode 到状态码的映射只在入口层。
  • 内存存储、无鉴权、无分页、无请求体上限,是卷一明确的生产边界。

到这里,卷一的主线走完了:从 JDK 与字节码,到语法、集合、泛型、异常、IO、测试,再到依赖、打包、可复现构建,最后汇成一个能借书、能还书、能通过 CLI 和 HTTP 访问的完整程序。下一步可以回到目录,挑一个方向继续:卷二的 libraryd、卷三的并发与机制、卷四的 JVM 观测。

阅读导航:上一节:18.2 内存仓储与 CLI 交互 · 下一节:《Java 编程入门》目录 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发