《Java 编程实战》7.2 中间件:请求 ID、访问日志与错误处理

libraryd 的每个 handler 都在重复读请求头、拼日志、记耗时。本节用 Javalin 的 before/after 把请求 ID、MDC、访问日志和指标收进中间件,并用真实执行顺序说明异常处理器与 after 的先后关系,最后给出请求头信任、MDC 清理与指标基数的边界。

本节目标:让 libraryd 的每个请求都有稳定的请求 ID、一条访问日志和一次耗时统计,并且异常发生时这些信息仍然完整。
适用版本:JDK 25(Temurin 25.0.5)、Javalin 7.2.4、SLF4J 2.0.20、Micrometer 1.17.2。

承接的问题

7.1 节把 12 条路由挂上了,但每个 handler 只关心自己的业务。真正上线后,还有四件事要在所有请求上发生:

  • 客户端传了 X-Request-Id 就沿用,没传就生成一个;响应头必须带回同一个值。
  • 每条日志都要能按请求串起来。只写一个 borrow failed,排查时无法知道是哪个请求、哪个用户、哪次重试。
  • 每个请求的耗时、状态码要进指标,否则无法回答「99 分位延迟是多少」。
  • 异常发生时,响应要变成统一错误格式,日志要保留请求 ID 和堆栈。

如果把这些逻辑复制进 12 个 handler,会出现三个问题:漏写一个就少一条日志;顺序不一致,有的先记耗时再解析请求;异常路径和成功路径走两套代码,错误响应反而没有请求 ID。

这一节把这些横切关注点收进 before 和 after,让 handler 只做业务。

核心机制一:before/after 是同一请求的两端

Javalin 的 before 在路由 handler 之前执行,after 在请求处理结束后执行。两者都注册在 config.routes 上:

Javalin app = Javalin.create(config -> {
    config.routes.before(this::before);
    config.routes.after(this::after);
    config.routes.get("/api/books", this::listBooks);
});

before 和 after 对所有请求生效,包括没有匹配到路由的请求。本机用一个只有 before/after 的小应用验证:

/ok   status=200 before=1 after=1
/nope status=404 before=1 after=1

/nope 没有注册路由,但 before 仍然先执行,after 仍然在 404 响应后执行。这意味着请求 ID 和访问日志可以覆盖 404,不会出现「查不到路由的请求没有日志」的盲区。

执行顺序比「先 before 后 after」更细。用一个抛异常的路由验证:

before path=/boom
exception-handler mdc=rid-order
after status=500 mdc=rid-order
client status=500 body=handled

顺序是:

  1. before 执行,写入 MDC。
  2. 路由 handler 抛出异常。
  3. 匹配到的 exception 处理器执行,此时 MDC 还在。
  4. after 执行,statusCode() 已经是异常处理器设置的 500。
  5. 响应发给客户端。

这个顺序决定了三件事:异常处理器里能拿到请求 ID;after 里记录的状态码是最终状态码;MDC 的清理必须放在 after,不能放在 handler 里。

核心机制二:请求 ID 的生成、回写与 MDC

请求 ID 的逻辑收在一个类里,before 只调用一次:

package com.plume.library.web;

import io.javalin.http.Context;
import java.util.UUID;
import org.slf4j.MDC;

public final class RequestContext {

    public static final String HEADER = "X-Request-Id";
    public static final String ATTRIBUTE = "libraryd.requestId";
    private static final String START_NANOS = "libraryd.startNanos";

    private RequestContext() {
    }

    public static String begin(Context context) {
        String requestId = context.header(HEADER);
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString();
        }
        context.attribute(ATTRIBUTE, requestId);
        context.attribute(START_NANOS, System.nanoTime());
        context.header(HEADER, requestId);
        MDC.put("requestId", requestId);
        return requestId;
    }

    public static String requestId(Context context) {
        return context.attribute(ATTRIBUTE);
    }

    public static long elapsedNanos(Context context) {
        Long start = context.attribute(START_NANOS);
        return start == null ? 0L : System.nanoTime() - start;
    }

    public static void clear(Context context) {
        MDC.remove("requestId");
        MDC.remove("traceId");
    }
}

四个动作各有用途:

  • context.header(HEADER) 读请求头。客户端重试时带同一个 ID,服务端日志就能把两次请求归到一组。
  • 缺失或空白时生成 UUID。空白也算缺失,避免响应头出现一个空字符串。
  • context.header(HEADER, requestId) 写响应头。Javalin 的 header(String, String) 是设置响应头,不是读请求头;同名方法靠参数个数区分。
  • MDC.put("requestId", requestId) 让同线程里的所有日志自动带上这个字段。Logback 的 pattern 里写 %X{requestId:-} 就能输出。

START_NANOS 用 System.nanoTime() 而不是 currentTimeMillis()。前者是单调时钟,不受系统时间校准影响;耗时统计不应该因为 NTP 调时而出现负数。

MDC 是线程本地变量。Jetty 的请求线程会被复用,如果只 put 不 remove,下一个请求可能继承上一个请求的 ID。清理放在 after 的 finally 里,保证异常路径也会执行:

private void after(Context context) {
    try {
        httpMetrics.record(context, RequestContext.elapsedNanos(context));
    } finally {
        tracing.finish(context);
        RequestContext.clear(context);
    }
}

这里先记录指标,再结束 trace,最后清理 MDC。顺序不能反:HttpMetrics.record 的日志要用 requestId,tracing.finish 要把 traceId 注入响应头,清理必须最后做。

核心机制三:访问日志与指标只记录一次

访问日志和指标都放在 HttpMetrics,after 只调用一次:

public final class HttpMetrics {

    private static final Logger log = LoggerFactory.getLogger(HttpMetrics.class);
    private final MeterRegistry registry;

    public HttpMetrics(MeterRegistry registry) {
        this.registry = Objects.requireNonNull(registry, "registry");
    }

    public void record(Context context, long elapsedNanos) {
        Duration duration = Duration.ofNanos(elapsedNanos);
        Timer.builder("libraryd.http.server.requests")
                .description("HTTP requests handled by libraryd")
                .tag("method", context.method().name())
                .tag("status", String.valueOf(context.statusCode()))
                .register(registry)
                .record(duration);
        log.info("http_request method={} path={} status={} duration_ms={} requestId={}",
                context.method().name(),
                context.path(),
                context.statusCode(),
                duration.toMillis(),
                RequestContext.requestId(context));
    }
}

指标只打两个标签:method 和 status。日志里可以写 path,因为日志是逐条文本,基数再大也只是多几行;指标标签不能写原始路径,否则 /api/books/9787111213826、/api/books/9787115428028 会各生成一条时间序列,几千本书就能把 Prometheus 的基数打爆。

本机开启 INFO 级别后,一条真实的访问日志是:

18:13:36.564 INFO  com.plume.library.web.HttpMetrics requestId=75457309-0763-4c78-9f57-bee951e30cc1 traceId= - http_request method=POST path=/api/books status=201 duration_ms=17 requestId=75457309-0763-4c78-9f57-bee951e30cc1

requestId 出现了两次:一次来自 MDC 的 pattern,一次来自消息体。这不是冗余,而是为了让日志既能被结构化采集(MDC 字段),又能在纯文本 grep 时直接看到(消息体)。如果日志已经用 JSON encoder,消息体里的 requestId 可以去掉,避免重复字段。

耗时从 before 开始算,包含路由匹配、handler、异常处理和响应序列化。它不包含 TCP 建连和 TLS 握手,那些属于接入层指标。一个请求如果在 before 之前就被 Jetty 拒绝(例如请求头过大),不会进入这个统计,这是已知边界。

核心机制四:异常处理器是中间件的最后一段

异常处理器也注册在 config.routes 上,按异常类型匹配:

config.routes.exception(LibraryException.class, this::handleLibraryException);
config.routes.exception(ValidationFailure.class, this::handleValidationFailure);
config.routes.exception(Exception.class, this::handleUnexpected);

Javalin 选择最具体的处理器。本机用三个处理器验证:同时注册 RuntimeException、IllegalArgumentException 和 Exception,抛 IllegalArgumentException,返回的是 IllegalArgumentException 对应的 400,不是 RuntimeException 的 500,也不是兜底的 Exception。

status=400 body=illegal-argument

Exception.class 的处理器是兜底,必须存在。没有它,未捕获异常会走 Javalin 自己的 500 页面,响应格式和 libraryd 的错误契约不一致,堆栈也可能被写进响应。

三个处理器的分工:

private void handleLibraryException(LibraryException exception, Context context) {
    if (exception.errorCode().severity() >= 3) {
        log.warn("request rejected requestId={} code={} message={}",
                RequestContext.requestId(context), exception.errorCode().code(), exception.getMessage());
    } else if (exception.errorCode() == ErrorCode.DB_UNAVAILABLE
            || exception.errorCode() == ErrorCode.INTERNAL) {
        log.error("request failed requestId={} code={}",
                RequestContext.requestId(context), exception.errorCode().code(), exception);
    }
    ProblemDetails.write(context, exception.errorCode(), exception.getMessage(), Map.of());
}

private void handleValidationFailure(ValidationFailure exception, Context context) {
    log.warn("validation failed requestId={} errors={}",
            RequestContext.requestId(context), exception.errors());
    ProblemDetails.write(context, ErrorCode.VALIDATION_FAILED,
            "request validation failed", exception.errors());
}

private void handleUnexpected(Exception exception, Context context) {
    log.error("unhandled request failure requestId={}",
            RequestContext.requestId(context), exception);
    ProblemDetails.write(context, ErrorCode.INTERNAL, "internal server error", Map.of());
}

日志级别跟着错误性质走:客户端犯错(校验失败、资源冲突)是 WARN,服务端故障(数据库不可用、未捕获异常)是 ERROR。把所有异常都打成 ERROR,告警会被 404 淹没;都打成 WARN,真正的 500 又会被忽略。

处理器本身不能再抛异常。ProblemDetails.write 如果序列化失败,外层没有第二个兜底,Jetty 会返回一个空响应。8.2 节会给它加一个显式的序列化失败分支。

在 libraryd 里落地:装配顺序

把上面的类拼进 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.before(this::before);
        config.routes.after(this::after);

        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);

        config.routes.exception(LibraryException.class, this::handleLibraryException);
        config.routes.exception(ValidationFailure.class, this::handleValidationFailure);
        config.routes.exception(Exception.class, this::handleUnexpected);
    });
}

private void before(Context context) {
    RequestContext.begin(context);
    tracing.start(context);
}

private void after(Context context) {
    try {
        httpMetrics.record(context, RequestContext.elapsedNanos(context));
    } finally {
        tracing.finish(context);
        RequestContext.clear(context);
    }
}

before 里只做两件轻量的事:生成请求 ID、开启 trace。不要在这里查数据库、读 Redis 或做权限校验的重活,否则每个请求都要多付一次延迟,而且 before 抛异常时 after 仍然会执行,指标里会出现一个没有业务处理却带着耗时的请求。

tracing 是 TracingMiddleware,负责从 traceparent 请求头恢复上下文、开 server span、把 trace id 写进 MDC。它的细节在第 13 章展开;本节只需要保证 tracing.start 在 RequestContext.begin 之后,因为 span 上要挂 request.id 属性,tracing.finish 在 RequestContext.clear 之前,因为 trace id 还要写响应头。

/health 和 /metrics 也走同一套 before/after。健康检查被负载均衡器高频调用,访问日志会很多;如果觉得吵,可以在 HttpMetrics.record 里对这两个路径降级为 debug,但不要跳过请求 ID,否则健康检查失败时无法和网关日志对齐。

常见错误与边界

错误一:MDC 只 put 不 remove。 请求线程会被复用,上一个请求的 requestId 会出现在下一个请求的日志里。clear 必须放在 after 的 finally 块,不能放在 handler 末尾。

错误二:在每个 handler 里手动记日志和耗时。 12 个 handler 就有 12 份重复逻辑,异常路径最容易漏。中间件只写一次,成功和失败共享同一条记录路径。

错误三:把原始路径当指标标签。 /api/books/{isbn} 里的 ISBN 是无限集合,每个值都会生成一条时间序列。指标标签只放 method、status 这类低基数字段,路径模板要在路由层归一化后再用。

错误四:无条件信任客户端传来的 X-Request-Id。 攻击者可以传一个超长字符串或带换行的值,污染日志甚至响应头。工程上要限制长度(例如 128 字符)、只允许可见 ASCII,并且在网关层覆盖客户端值。请求 ID 只用于追踪,不能当身份凭证。

错误五:异常处理器里再抛异常。 处理器是最后一道网,抛出去就没人接了。序列化错误响应时要么用不会失败的字符串拼接,要么在 write 内部捕获并退化成最小响应。

边界:after 里响应可能已经提交。 如果 handler 已经写了响应体,after 再改状态码或响应头不会生效,Jetty 会忽略或抛 IllegalStateException。after 只做记录,不要试图修改业务响应。

边界:before 对静态资源和未匹配路由也执行。 如果将来挂静态文件,before 里的数据库健康检查会对每个 CSS 请求都跑一次。横切逻辑要么足够轻,要么按路径前缀跳过。

小结

  • before/after 对所有请求生效,包括未匹配路由;顺序是 before → handler → exception → after。
  • 请求 ID 优先沿用客户端 X-Request-Id,缺失时生成 UUID,同时写响应头和 MDC。
  • MDC 是线程本地变量,必须在 after 的 finally 里清理,否则线程复用会串请求。
  • 访问日志和指标只在 HttpMetrics 记录一次;指标标签用低基数的 method、status,不要用原始路径。
  • 异常处理器按最具体类型匹配,Exception.class 兜底必须存在,处理器本身不能再抛异常。
  • before 只做轻量工作,after 只做记录,不要在响应提交后修改业务响应。

请求 ID、日志和错误响应都接上后,服务已经能被观测。但进程收到 SIGTERM 时,正在处理的请求会不会被切断,数据库连接会不会来不及归还,还没有答案。下一节看优雅停机与连接管理。

阅读导航:上一节:7.1 Javalin 起步:路由、路径参数与 JSON · 下一节:7.3 优雅停机与连接管理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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