本节目标:给 libraryd 装上第一个可用的 HTTP 入口,让领域服务能被真实请求调用。
适用版本:JDK 25(Temurin 25.0.5)、Javalin 7.2.4、Jackson 2.22.4。
承接的问题
前面六章把 libraryd 从内存 CLI 长成了一个有数据库、有事务、有 DTO 的服务:LibraryService 能分页查书、借书、还书、续借,JdbcBookRepository 和 JdbcLoanRepository 能把状态写进 PostgreSQL,JsonSupport 也把日期格式定成了 ISO-8601。
这里顺带收口第 5 章留下的结构差异:第 5 章为了单独讲事务,用 BorrowService 和 TransactionRunner 做了最小闭环;进入 HTTP 层后,这些用例合并进 LibraryService 门面,事务实现由 ThreadLocalTransactionManager 承担——它把连接绑定到当前线程,仓储通过 JdbcSupport 复用同一条连接。职责划分与第 5 章一致,只是最终类名以本章为准。
但这些能力目前只存在于 Java 方法调用里。外部客户端要借一本书,仍然得写一个 main 方法,拿到 LibraryService 的引用,再自己调 borrow。这不是服务,只是一个库。
这一章要补上 HTTP 这一层。落地后,libraryd 需要对外提供这些端点:
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /api/books | 分页查询,支持 q 关键字 |
| GET | /api/books/{isbn} | 按 ISBN 取一本书 |
| POST | /api/books | 新增图书,返回 201 |
| PUT | /api/books/{isbn} | 更新图书 |
| DELETE | /api/books/{isbn} | 删除图书,返回 204 |
| POST | /api/loans | 借书,返回 201 |
| GET | /api/loans/{id} | 按 id 取借阅 |
| GET | /api/members/{id}/loans | 查会员的活动借阅 |
| POST | /api/returns | 还书 |
| POST | /api/renews | 续借 |
| GET | /health | 健康检查 |
| GET | /metrics | Prometheus 指标 |
第一个问题是选型。libraryd 不需要 Spring 的容器、AOP 和自动配置,只需要一个能把请求映射到方法、能读写 JSON、能挂生命周期钩子的 HTTP 框架。Javalin 7 的体量合适,但它的路由 API 和 6.x 有一个会直接导致编译失败的变化,必须先把这件事说清楚。
核心机制一:Javalin 7 把路由注册在配置对象上
Javalin 6 的写法是先创建实例,再在实例上挂路由。Javalin 7 把路由表移进了 JavalinConfig,所有注册动作必须发生在 Javalin.create(...) 的回调里:
import io.javalin.Javalin;
Javalin app = Javalin.create(config -> {
config.routes.get("/health", ctx -> ctx.json(java.util.Map.of("status", "UP")));
config.routes.get("/api/books", ctx -> ctx.result("[]"));
});
app.start("0.0.0.0", 8080);
config.routes 是一个 RoutesConfig,提供 get、post、put、delete、patch、before、after、exception 等方法。app 实例上已经没有 get、post、before、exception 这些方法了。如果照着旧教程写,编译器会直接拦住:
错误: 找不到符号
符号: 方法 get(String,Handler)
位置: 类型为 Javalin 的变量
这段关键报错来自本机 JDK 25 + Javalin 7.2.4 的编译输出,这里省略了触发报错的源码行。它不是配置问题,而是 API 被移除了。看到它时不要去加依赖或换版本,应该把路由搬进 create 的回调。
路由注册和服务器启动是分开的两个阶段。Javalin.create(...) 只构建配置,app.start(host, port) 才真正绑定端口。这样做的好处是测试可以先创建应用、再决定端口:app.start("127.0.0.1", 0) 会随机分配一个可用端口,app.port() 能读回来。7.3 节讲停机时还会用到这个生命周期。
核心机制二:路径参数、查询参数与状态码
路径参数用 context.pathParam("name") 读取,值的类型永远是 String。查询参数用 context.queryParam("name"),缺失时返回 null。两者都不做类型转换,转换和默认值由 handler 自己负责。
先看三个有代表性的 handler:
private void listBooks(Context context) {
int page = pageParam(context);
int size = sizeParam(context);
String query = context.queryParam("q");
PageResult<Book> result = service.listBooks(page, size, query);
context.json(PageResponse.from(result, BookDto::from));
}
private void createBook(Context context) {
CreateBookRequest request = validator.validate(parseBody(context, CreateBookRequest.class));
Book saved = service.addBook(new Book(request.isbn(), request.title(),
request.author(), request.year()));
context.status(201)
.header("Location", "/api/books/" + saved.isbn())
.json(BookDto.from(saved));
}
private void deleteBook(Context context) {
service.deleteBook(context.pathParam("isbn"));
context.status(204);
}
三件事值得展开:
- 新增资源返回
201 Created,并在Location头里给出新资源的地址。客户端不需要自己拼 URL,也不需要再猜 ISBN 有没有被规范化。 - 删除成功返回
204 No Content,不写响应体。204 的语义是「成功,但没有内容可返回」,不是「返回一个空 JSON」。 - 查询参数有默认值和上限。
page从 0 开始,size默认 20、最大 100。越界或不是数字时不让请求失败,而是回落到默认值并记一条 WARN。
分页参数的处理放在两个小方法里,避免每个列表接口各写一遍:
private static int pageParam(Context context) {
String raw = context.queryParam("page");
if (raw == null || raw.isBlank()) {
return 0;
}
try {
int value = Integer.parseInt(raw);
if (value < 0) {
log.warn("page out of range, using 0: {}", raw);
return 0;
}
return value;
} catch (NumberFormatException exception) {
log.warn("page is not a number, using 0: {}", raw);
return 0;
}
}
sizeParam 的结构相同,只是判断条件换成 value < 1 || value > LibraryService.MAX_PAGE_SIZE,回落值是 LibraryService.DEFAULT_PAGE_SIZE。这里选择「容错」而不是「报 400」,是因为分页参数是展示层参数,客户端传错不应该让整个列表不可用。对比之下,/api/loans/{id} 的 id 是资源标识,传 abc 必须报错:
private static long loanId(Context context) {
String raw = context.pathParam("id");
try {
long id = Long.parseLong(raw);
if (id < 1) {
throw new NumberFormatException("non-positive");
}
return id;
} catch (NumberFormatException exception) {
throw new LibraryException(ErrorCode.INVALID_COMMAND,
"loan id must be a positive number");
}
}
LibraryException 是领域异常,会由 7.2 节注册的异常处理器翻译成 HTTP 响应。路由方法不写 try/catch,也不自己拼错误 JSON。
核心机制三:JSON 必须显式注册 mapper
Javalin 7.2.4 在 classpath 上有 Jackson 时会提供一个默认 mapper,但它没有注册 JavaTimeModule。LocalDate 会被当成普通对象,序列化成数组:
{"date":[2026,10,20]}
这不是 libraryd 想要的契约。LoanDto 的 borrowedOn、dueOn、returnedOn 必须是 ISO-8601 的 yyyy-MM-dd,否则前端和移动端都要自己处理数组。因此要显式注册一个配置好的 mapper:
package com.plume.library.web;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
public final class JsonSupport {
private JsonSupport() {
}
public static ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
mapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
return mapper;
}
}
注册发生在 Javalin.create 的回调里:
config.jsonMapper(new JavalinJackson(JsonSupport.objectMapper(), false));
JavalinJackson 的第二个参数是 prettyPrint,生产环境用 false 减少 payload。注册之后,ctx.json(...) 和 ctx.bodyAsClass(...) 都走同一个 mapper,日期格式和未知字段策略不会再出现两个版本。
FAIL_ON_UNKNOWN_PROPERTIES 在 Jackson 里默认就是 true,这里显式写出来是为了把契约写进代码。客户端多传一个 "extra":1,反序列化会失败,parseBody 会把它翻译成 E_INVALID_COMMAND。如果为了「兼容」把它关掉,拼错的 memberID 会被静默丢弃,服务端拿着 null 继续执行,最后报一个和根因无关的空指针。
请求体读取还有一个前置条件:Content-Type 必须是 application/json。parseBody 先检查再解析:
private static <T> T parseBody(Context context, Class<T> type) {
String contentType = context.contentType();
if (contentType == null
|| !contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) {
throw new LibraryException(ErrorCode.INVALID_COMMAND,
"Content-Type must be application/json");
}
try {
return context.bodyAsClass(type);
} catch (LibraryException exception) {
throw exception;
} catch (Exception exception) {
throw new LibraryException(ErrorCode.INVALID_COMMAND,
"malformed JSON request body", exception);
}
}
这段代码不区分「JSON 语法错」和「字段类型不匹配」,都归到 INVALID_COMMAND(400)。更细的字段级错误留给第 8 章的校验,本节先把请求能不能读进来这件事收口。
在 libraryd 里落地:LibraryApi.create
把上面的机制拼起来,LibraryApi 的职责只有一个:把 HTTP 请求翻译成服务调用,再把结果翻译成 DTO。它不判断业务规则,也不碰 JDBC。
LibraryApi 的依赖通过构造函数注入,这里只列出 create() 里的路由注册部分:
public Javalin create() {
return Javalin.create(config -> {
config.jsonMapper(new JavalinJackson(JsonSupport.objectMapper(), false));
config.http.defaultContentType = "application/json";
config.router.ignoreTrailingSlashes = true;
config.routes.get("/api/books", this::listBooks);
config.routes.get("/api/books/{isbn}", this::getBook);
config.routes.post("/api/books", this::createBook);
config.routes.put("/api/books/{isbn}", this::updateBook);
config.routes.delete("/api/books/{isbn}", this::deleteBook);
config.routes.post("/api/loans", this::borrowBook);
config.routes.get("/api/loans/{id}", this::getLoan);
config.routes.get("/api/members/{id}/loans", this::memberLoans);
config.routes.post("/api/returns", this::returnBook);
config.routes.post("/api/renews", this::renewLoan);
config.routes.get("/health", this::health);
config.routes.get("/metrics", this::metrics);
});
}
实际工程里的构造函数还接收 MeterRegistry、指标抓取函数、HealthProbe、Tracer 和 Clock,这些依赖分别服务于 7.2、第 13 章和可测试的「今天」。中间件和异常处理器在 7.2 节接入,这里先聚焦路由。
几个 handler 的完整形态如下:
private void getBook(Context context) {
Book book = service.getBook(context.pathParam("isbn"));
context.json(BookDto.from(book));
}
private void updateBook(Context context) {
UpdateBookRequest request = validator.validate(parseBody(context, UpdateBookRequest.class));
Book updated = service.updateBook(context.pathParam("isbn"),
request.title(), request.author(), request.year());
context.json(BookDto.from(updated));
}
private void borrowBook(Context context) {
BorrowRequest request = validator.validate(parseBody(context, BorrowRequest.class));
Loan loan = service.borrow(request.isbn(), request.memberId(), today());
context.status(201)
.header("Location", "/api/loans/" + loan.id())
.json(LoanDto.from(loan, today()));
}
private void memberLoans(Context context) {
List<LoanDto> loans = service.loansByMember(context.pathParam("id")).stream()
.map(loan -> LoanDto.from(loan, today()))
.toList();
context.json(loans);
}
private LocalDate today() {
return LocalDate.now(clock);
}
today() 用注入的 Clock 而不是 LocalDate.now(),测试才能固定「今天」。LoanDto.from(loan, today) 里的 status 和 overdueDays 依赖当天日期,同一个借阅在不同日期查会得到不同状态。
health 和 metrics 也走同一套路由:
private void health(Context context) {
boolean up;
try {
up = healthProbe.databaseUp();
} catch (RuntimeException failure) {
log.warn("health probe failed", failure);
up = false;
}
HealthResponse response = new HealthResponse(up ? "UP" : "DOWN",
Map.of("db", up ? "UP" : "DOWN"));
context.status(up ? 200 : 503).json(response);
}
private void metrics(Context context) {
context.contentType("text/plain; version=0.0.4; charset=utf-8")
.result(metricsScrape.get());
}
健康检查不是「进程还活着就返回 200」。它要真的拿一条数据库连接执行 connection.isValid(2),数据库不可用时返回 503,让负载均衡器把实例摘掉。指标端点返回 Prometheus 文本格式,Content-Type 必须带版本号和字符集,抓取端才会按 exposition format 解析。
真实请求轨迹
把上面的应用跑在随机端口上,用 JDK 自带的 HttpClient 依次请求,本机输出如下:
[POST /api/books] status=201 ct=application/json loc=/api/books/9787111213826 rid=dd197110-...
{"isbn":"9787111213826","title":"Effective Java","author":"Joshua Bloch","year":2018}
[GET /api/books/9787111213826] status=200 ct=application/json
{"isbn":"9787111213826","title":"Effective Java","author":"Joshua Bloch","year":2018}
[GET /api/books?page=-1&size=1000] status=200 ct=application/json
{"items":[...],"total":1,"page":0,"size":20}
[POST /api/loans] status=201 ct=application/json loc=/api/loans/1
{"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-20","dueOn":"2026-11-03","returnedOn":null,"renewCount":0,"status":"ACTIVE","overdueDays":0}
[POST /api/renews] status=200 ct=application/json
{"id":1,...,"dueOn":"2026-11-17","renewCount":1,"status":"ACTIVE","overdueDays":0}
[POST /api/returns] status=200 ct=application/json
{"isbn":"9787111213826","memberId":"m-001","returnedOn":"2026-10-20","fineCents":0}
[GET /health] status=200 ct=application/json
{"status":"UP","checks":{"db":"UP"}}
[GET /metrics] status=200 ct=text/plain; version=0.0.4; charset=utf-8
# HELP libraryd_http_server_requests_seconds ...
这段输出里能直接看到几个契约:
- 创建成功是 201,并带
Location;读取和更新是 200。 - 借阅和续借的日期都是
yyyy-MM-dd,不是数组,也不是时间戳。 - 分页参数
page=-1&size=1000没有让请求失败,响应回落到page=0、size=20。 - 每个响应都带
X-Request-Id,客户端没传时由服务端生成 UUID。 - 健康检查和指标端点的内容类型是各自独立的,不跟随全局的
application/json。
X-Request-Id 的生成和传递属于横切逻辑,不应该在每个 handler 里重复。7.2 节会把它收进 RequestContext 与 before/after。
常见错误与边界
错误一:照抄 Javalin 6 在实例上挂路由的写法。 Javalin 7 把路由注册移到了 config.routes,实例方法 get(...) 会直接编译失败。所有路由、before、after、exception 都要写进 Javalin.create 的回调。
错误二:依赖默认 mapper 的日期格式。 不注册 JavaTimeModule 时,LocalDate 会输出 [2026,10,20]。这个格式不是 ISO-8601 日期,客户端按 yyyy-MM-dd 解析会失败。日期契约要在 JsonSupport 里一次定好。
错误三:把路径参数当数字直接用。 pathParam 返回 String,Long.parseLong 会抛 NumberFormatException。要么在路由层转换成 long 并映射成 400,要么让异常处理器接住;不能把原始异常直接漏成 500。
错误四:新增资源返回 200。 201 和 Location 是资源创建的语义,客户端和网关会据此判断是否需要跳转。统一返回 200 会让「创建」和「幂等更新」在协议层无法区分。
错误五:在 handler 里做阻塞的重活。 Javalin 默认在 Jetty 的请求线程上执行 handler。一个慢查询会占住线程,并发上来后请求排队。线程池和虚拟线程是第 9 章的主题,本节只需要知道:handler 应该尽快返回,重活要么交给数据库,要么交给专门的工作池。
边界:尾部斜杠。 config.router.ignoreTrailingSlashes = true 让 /api/books 和 /api/books/ 命中同一条路由。如果不显式设置,两个 URL 可能一个 200、一个 404,客户端的重试逻辑会变得不可预测。
边界:未注册的路径。 Javalin 对没有匹配到路由的请求返回自己的 404 JSON,形状不是 libraryd 的 ApiError。如果 API 要求所有错误都遵循同一契约,需要注册 config.routes.error(404, ...),这会在 8.3 节展开。
小结
- Javalin 7 的路由必须注册在
Javalin.create(config -> ...)的config.routes上,实例方法get已被移除。 - 路径参数和查询参数都是字符串,类型转换、默认值和越界回落由 handler 负责。
- 创建资源返回 201 +
Location,删除成功返回 204,分页参数越界时回落到默认值并记 WARN。 - JSON 要显式注册
JavalinJackson(JsonSupport.objectMapper(), false),否则LocalDate会变成数组。 parseBody先检查Content-Type,再把解析失败统一映射成INVALID_COMMAND。- 未注册路由的 404 不走业务异常处理器,需要单独的错误页处理器才能保持错误契约。
HTTP 入口打通后,请求 ID、访问日志、耗时统计和错误处理还散落在各个 handler 里。下一节把它们收进 before 和 after,让每个请求都有同一条可追踪的轨迹。
阅读导航:上一节:6.3 序列化边界:多态、循环引用与 JSONB · 下一节:7.2 中间件:请求 ID、访问日志与错误处理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。