《Java 编程实战》7.1 Javalin 起步:路由、路径参数与 JSON

libraryd 的服务层已经能借书还书,却还没有 HTTP 入口。本节用 Javalin 7.2.4 把路由挂在配置对象上,接通路径参数、查询参数、分页默认值与 Jackson,并用真实请求验证 201、204、Location、日期序列化和内容类型检查,最后给出完整路由清单。

本节目标:给 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/metricsPrometheus 指标

第一个问题是选型。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、访问日志与错误处理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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