本节目标:把 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 编程入门》目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。