《Java 编程实战》8.3 异常映射:从领域错误到 HTTP 响应

领域层抛 LibraryException,持久层抛 SQLException,web 层只认识 Problem Details。本节把三层翻译串起来:SQLState 怎么变成错误码,错误码怎么决定 HTTP 状态,异常处理器为什么按最具体类型匹配,以及 404、405 和处理器自己抛异常时会发生什么。

本节目标:让每一个异常在正确的层被翻译成正确的错误码,最后由 web 层统一写成 HTTP 响应。
适用版本:JDK 25(Temurin 25.0.5)、Javalin 7.2.4、PostgreSQL JDBC 42.7.14。

承接的问题

到 8.2 节为止,错误响应的形状已经统一了。但「谁来产生这个响应」还没有完全说清。libraryd 里至少有三类异常:

  • 持久层:SQLException,SQLState 可能是 23505、23503、08006、57P01。
  • 领域层:LibraryException,携带 ErrorCode,例如 BOOK_NOT_FOUND、RENEWAL_NOT_ALLOWED。
  • web 层:ValidationFailure,携带字段错误 Map。

如果让每个路由方法自己 catch,会出现两个问题。第一,12 个 handler 要写 12 套 catch,漏一个就变成 500。第二,同一个错误在不同接口里可能被映射成不同状态码:借阅接口把「书已借出」返回 409,删除接口返回 400,客户端无法写统一策略。

这一节的原则是:每一层只翻译自己理解的异常,HTTP 状态只在错误码上定义一次。

核心机制一:错误码携带 HTTP 状态

ErrorCode 是领域模块里的枚举,每个值带三个属性:

package com.plume.library.domain;

public enum ErrorCode {
    BOOK_NOT_FOUND("E_BOOK_NOT_FOUND", 2, 404),
    MEMBER_NOT_FOUND("E_MEMBER_NOT_FOUND", 2, 404),
    BOOK_ALREADY_BORROWED("E_BOOK_ALREADY_BORROWED", 3, 409),
    DUPLICATE_ISBN("E_DUPLICATE_ISBN", 3, 400),
    LOAN_NOT_FOUND("E_LOAN_NOT_FOUND", 2, 404),
    LOAN_LIMIT_EXCEEDED("E_LOAN_LIMIT_EXCEEDED", 3, 409),
    RENEWAL_NOT_ALLOWED("E_RENEWAL_NOT_ALLOWED", 3, 409),
    INVALID_COMMAND("E_INVALID_COMMAND", 2, 400),
    VALIDATION_FAILED("E_VALIDATION_FAILED", 2, 400),
    DB_UNAVAILABLE("E_DB_UNAVAILABLE", 1, 503),
    INTERNAL("E_INTERNAL", 1, 500);

    private final String code;
    private final int severity;
    private final int httpStatus;

    ErrorCode(String code, int severity, int httpStatus) {
        this.code = code;
        this.severity = severity;
        this.httpStatus = httpStatus;
    }

    public String code() {
        return code;
    }

    public int severity() {
        return severity;
    }

    public int httpStatus() {
        return httpStatus;
    }
}

三个属性各有用途:

  • code 是 wire 值,进日志、进 Problem Details 的 code 字段,也用来推导 type URI。
  • severity 是运维分级:1 表示基础设施故障,2 表示请求或资源问题,3 表示业务冲突。它不直接等于日志级别,但异常处理器用它决定要不要打 WARN。
  • httpStatus 是 HTTP 映射。路由和处理器都从这里取,不再写 switch (errorCode)。

把 httpStatus 放在领域枚举里,是一个有争议的选择。纯化论者会说领域层不应该知道 HTTP。替代方案是维护一张 web 层的 Map<ErrorCode, HttpStatus>。libraryd 选择放在枚举里,理由是:错误码本身就是跨层的稳定契约,状态码是它的一部分;放在一处比放在两处更难漂移。代价是领域模块多了一个 HTTP 概念,但这个项目没有非 HTTP 的消费者,收益大于成本。

如果将来领域模块要被 CLI 或消息消费者复用,可以把 httpStatus 拆到一个 web 层的映射表,领域枚举只保留 code 和 severity。这不影响 wire 契约,因为 code 和 type 不变。

核心机制二:异常处理器按具体类型匹配

Javalin 的 config.routes.exception(Class, Handler) 按异常类型匹配,选择最具体的那一个,和注册顺序无关。本机用两个相反顺序验证:

先注册 RuntimeException,再注册 IllegalArgumentException,最后 Exception
抛 IllegalArgumentException -> status=400 body=illegal-argument

先注册 Exception,再注册 IllegalArgumentException,最后 RuntimeException
抛 IllegalArgumentException -> status=400 body=illegal-argument

两次都命中 IllegalArgumentException 的处理器。Javalin 找的是类型距离最近的处理器,不是「第一个能接住的」。因此注册顺序可以按可读性排列,先具体、后兜底:

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

Exception.class 的兜底必须有。没有它,未捕获异常会走 Javalin 的默认错误页。处理器本身也不能再抛异常:本机让 IllegalStateException 的处理器抛 RuntimeException,外层虽然注册了 Exception.class,响应仍然是:

status=500 ct=text/plain body=Server Error

外层的 Exception 处理器没有接住处理器内部抛出的异常。Javalin 已经把请求交给「处理这个异常的处理器」,处理器再失败就退到框架的默认 500。这也是为什么 ProblemDetails.write 要自己捕获序列化失败,而不是把异常继续往外扔。

核心机制三:三层翻译的分工

异常映射不是一层的事。每一层把自己能理解的异常翻译成上一层能理解的类型:

SQLException
   │  JdbcErrors.translate:看 SQLState 和约束名
   ▼
LibraryException(ErrorCode)
   │  handleLibraryException:看 severity 和 errorCode
   ▼
ApiError + HTTP status

持久层:SQLState 到错误码

持久层最了解数据库,也最了解约束名。JdbcErrors.translate 把 SQLException 翻译成领域异常:

package com.plume.library.persistence;

import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.LibraryException;
import java.sql.SQLException;

final class JdbcErrors {

    private JdbcErrors() {
    }

    static LibraryException translate(SQLException exception) {
        String state = exception.getSQLState();
        String message = exception.getMessage() == null ? "" : exception.getMessage();
        if ("23505".equals(state)) {
            if (message.contains("ux_loans_active_isbn")) {
                return new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED,
                        "book already has an active loan", exception);
            }
            return new LibraryException(ErrorCode.DUPLICATE_ISBN,
                    "isbn already exists", exception);
        }
        if ("23503".equals(state)) {
            return new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED,
                    "row is still referenced by loans", exception);
        }
        if (state != null && (state.startsWith("08") || state.startsWith("57P"))) {
            return new LibraryException(ErrorCode.DB_UNAVAILABLE,
                    "database is unavailable", exception);
        }
        return new LibraryException(ErrorCode.INTERNAL,
                "database operation failed", exception);
    }
}

本机用构造的 SQLException 跑这套规则,输出是:

23505 (ux_loans_active_isbn) -> E_BOOK_ALREADY_BORROWED 409
23505 (books_pkey)           -> E_DUPLICATE_ISBN 400
23503                        -> E_BOOK_ALREADY_BORROWED 409
08006                        -> E_DB_UNAVAILABLE 503
57P01                        -> E_DB_UNAVAILABLE 503
22P02                        -> E_INTERNAL 500

几个判断的依据:

  • 23505 是唯一约束冲突。同样是 23505,撞到 ux_loans_active_isbn 说明这本书已有活动借阅,映射成 409;撞到主键则是 ISBN 重复,映射成 400。约束名是持久层的知识,所以判断放在这里。
  • 23503 是外键冲突。删除一本书时,如果 loans 还引用它,数据库会拒绝。语义上是「资源仍被引用」,映射成 409 比 500 更准确。
  • 08 开头是连接异常,57P 是管理员终止连接(例如数据库重启、连接被 kill)。这两类都应该返回 503,让客户端知道可以重试。
  • 其他 SQLState 映射成 INTERNAL 500。不要把原始 SQLException.getMessage() 放进 detail,它可能包含表名和 SQL 片段。

领域层:明确抛出业务错误

领域层不做 SQLState 判断,它只抛语义明确的错误:

if (loans.findActiveByIsbn(normalizedIsbn).isPresent()) {
    throw new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED,
            "book is already borrowed: " + normalizedIsbn);
}
if (loans.countActiveByMember(normalizedMemberId) >= MAX_ACTIVE_LOANS) {
    throw new LibraryException(ErrorCode.LOAN_LIMIT_EXCEEDED,
            "member " + normalizedMemberId + " reached the active loan limit");
}

LibraryException 只是一个带错误码的运行时异常:

public class LibraryException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    private final ErrorCode errorCode;

    public LibraryException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public LibraryException(ErrorCode errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public ErrorCode errorCode() {
        return errorCode;
    }
}

它不携带 HTTP 状态,也不携带响应体。领域层不知道 Problem Details 的存在。

web 层:错误码到响应

web 层只做最后一跳:把 LibraryException 的 ErrorCode 和 message 交给 ProblemDetails.write。status、type、title 都从错误码推导,路由和处理器里没有第二个 switch。

在 libraryd 里落地:三个处理器

完整的处理器实现如下。它和 7.2 节的结构一致,这里重点看日志分级:

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

分级规则可以写成一张表:

条件日志级别是否带堆栈例子
severity >= 3WARN否BOOK_ALREADY_BORROWED、RENEWAL_NOT_ALLOWED
DB_UNAVAILABLEERROR是连接被数据库终止
INTERNALERROR是持久层未知异常
ValidationFailureWARN否字段为空、超长
未捕获 ExceptionERROR是空指针、序列化失败

severity 为 2 的 BOOK_NOT_FOUND、LOAN_NOT_FOUND、INVALID_COMMAND 在这个处理器里不打日志。这不是遗漏:它们是正常的 4xx,访问日志已经记录了 method、path、status 和 requestId;再打一条业务日志只会增加噪音。如果合规要求审计「谁查了不存在的书」,应该单独加 INFO 日志,而不是把它们升级成 WARN。

本机实际产生的两条 WARN 是:

WARN com.plume.library.web.LibraryApi - validation failed requestId=... errors={title=不能为空}
WARN com.plume.library.web.LibraryApi - request rejected requestId=... code=E_BOOK_ALREADY_BORROWED message=book is already borrowed: isbn-1

两条都带 requestId,可以和访问日志、客户端报障对上。

核心机制四:404、405 与框架默认响应

有一类错误不经过业务异常处理器:请求根本没有匹配到路由。7.1 节提到过,Javalin 会返回自己的 404 JSON:

status=404 ct=application/json
{"title":"Endpoint GET /no-such-route not found","status":404,
 "type":"https://javalin.io/documentation#endpointnotfound","details":{}}

这个形状和 ApiError 不一致,details 还是复数。要让它也遵守契约,需要注册错误页处理器:

config.http.prefer405over404 = true;
config.routes.error(404, context ->
        ProblemDetails.write(context, ErrorCode.BOOK_NOT_FOUND,
                "endpoint not found: " + context.path(), Map.of()));
config.routes.error(405, context ->
        ProblemDetails.write(context, ErrorCode.INVALID_COMMAND,
                "method not allowed: " + context.method(), Map.of()));

本机验证的结果:

GET /nope          -> 404 ct=application/problem+json body={"type":"not-found"}
DELETE /api/books  -> 405 ct=application/problem+json allow=GET body={"type":"method-not-allowed"}

两个细节:

  • prefer405over404 默认是 false。不开它,DELETE /api/books 会返回 404,因为路径存在但没有 DELETE 路由;开了之后才返回 405,并带 Allow: GET。
  • 404 的 ErrorCode 用 BOOK_NOT_FOUND 并不完全准确:路径不存在不等于书不存在。更干净的做法是给 ErrorCode 加一个 ENDPOINT_NOT_FOUND,映射成 404。这里为了不扩展错误码表,先用现有码;如果要严格,应该新增。

405 用 INVALID_COMMAND(400)也不准确,405 应该是独立状态。生产项目里建议新增 METHOD_NOT_ALLOWED,httpStatus=405。错误码表是公开契约,新增比复用更安全。

常见错误与边界

错误一:在每个路由里 catch (Exception)。 12 个 handler 就有 12 套映射,漏掉的异常变成默认 500,而且每个接口的状态码可能不同。统一交给 config.routes.exception。

错误二:把所有 23505 都映射成 DUPLICATE_ISBN。 借阅表的 ux_loans_active_isbn 也是 23505,映射错会让「书已借出」返回 400,客户端以为请求格式错了。要看约束名。

错误三:把 23503 映射成 INTERNAL。 外键冲突是资源状态问题,不是服务端 bug。删除有借阅历史的书返回 409 比 500 更准确,也不会触发无意义的告警。

错误四:500 的 detail 用异常消息。 SQLException.getMessage() 可能包含表名、列名、SQL 片段。500 的 detail 固定为 internal server error,堆栈只进日志。

错误五:异常处理器里再抛异常。 本机验证过,外层 Exception 处理器接不住处理器内部抛出的异常,最终是 text/plain 的 Server Error。处理器要么自己捕获,要么保证不失败。

错误六:依赖注册顺序。 Javalin 按类型距离选处理器,不按注册顺序。但不要因此把 Exception.class 注册在最前面就以为能兜住具体异常;具体类型仍然优先。

错误七:忘记注册 Exception.class。 没有兜底时,未捕获异常走框架默认响应,格式和契约不一致。兜底处理器是最后一道网,不能省。

边界:ErrorOverflow 与 StackOverflowError。 config.routes.exception(Exception.class, ...) 只接 Exception,不接 Error。OutOfMemoryError、StackOverflowError 不会进入这个处理器,JVM 可能已经无法安全地写响应。这类错误应该靠进程重启和健康检查处理,不要试图在应用层恢复。

边界:异步任务的异常。 CompletableFuture 或线程池里的异常不会经过 Javalin 的异常处理器,因为它们不在请求线程上。异步边界要么把异常包装成结果,要么在任务内部记录并转换成领域错误;第 10 章会展开。

边界:错误码的兼容性。 code 和 type 一旦发布就当成公开 API。重命名枚举常量会改变 wire 值,客户端的分支会失效。要改语义就新增错误码,不要改旧的。

小结

  • 每一层只翻译自己理解的异常:持久层看 SQLState,领域层抛 LibraryException,web 层写 Problem Details。
  • ErrorCode 同时携带 wire 值、severity 和 HTTP 状态,状态码只定义一次。
  • Javalin 按最具体的异常类型匹配处理器,和注册顺序无关;Exception.class 兜底必须存在。
  • 异常处理器本身不能再抛异常,否则会退化成框架默认的 text/plain 500。
  • 23505 要结合约束名区分「ISBN 重复」和「书已借出」,23503 是资源仍被引用,08/57P 是数据库不可用。
  • 404 和 405 不走业务异常处理器,需要 config.routes.error(...) 才能保持同一错误契约。

到这里,HTTP 层的入口、中间件、停机和错误契约都齐了。下一章换一个方向:请求线程本身是有限资源,阻塞查询不能无限占用 Jetty 线程。9.1 节从 ExecutorService 的参数和队列开始,把工作线程池的边界画清楚。

阅读导航:上一节:8.2 错误契约:RFC 9457 Problem Details · 下一节:9.1 ExecutorService:线程池参数与队列 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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