Old-School Backend / 2026 ・ No.07
古法后端入门
第7讲 异常与回复 ── 从“系统发生错误”毕业
发布
上一讲,服务器已经能拒绝申请了。可是回复只有“Bad Request”或“500”。画面不知道该对用户说什么。
这次决定在哪里接住异常、用哪个状态码、写什么返回。实测什么都不加的 Spring Boot、ProblemDetail(RFC 9457)、异常处理员(@RestControllerAdvice)、吞掉异常、查询编号,以及不该返回的东西。
“系统发生错误”
新井出错的时候显示“系统发生错误,请联系管理员”就行了吧。
古井看到那个画面的用户,该改什么?
新井……去问管理员?
古井那作为管理员的我,该看哪条日志?古时候的现场,就因为那一句话,收到了几百封询问。
全 15 讲路线图(当前:第 7 讲)
- 什么都不加 ── “不存在的受理编号”和“连点两下”都是 500。明明是用户的原因,却像故障(实测)
- ProblemDetail ── 一个设置就能统一形式,但内容是“Invalid request content.”(实测)
- 异常处理员 ── 400 带逐项理由,404,409,500 带查询编号(实测)
- 吞掉异常 ── 明明没写进去,却返回了 201 和受理编号(实测)
- 不该返回的东西 ── 一个设置,SQL 和行的内容都上了回复(实测)
应用是在上一讲的 Spring Boot 4.1.2 上,加了实验用的窗口(/api/v3)、吞掉异常的负责人,以及异常处理员(ApiErrors)。数据库里加了唯一索引,同一申请人・日期・项目・金额的申请只能有一件(用于连点两下的实验)。
什么都不加的回复
先给什么都不加的 Spring Boot 发送 5 种麻烦的请求。
# 什么都不加的 Spring Boot 的回复
## 1 不符合标记(项目为空、金额为负数)
HTTP/1.1 400 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.420Z","status":400,"error":"Bad Request","path":"/api/v3/expenses"}
## 2 JSON 坏了
HTTP/1.1 400 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.519Z","status":400,"error":"Bad Request","path":"/api/v3/expenses"}
## 3 不存在的受理编号
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.609Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses/EXP-2026-9999"}
## 4 同样的申请发 2 次(连点两下)
HTTP/1.1 201 Content-Type: application/json
{"id":"EXP-2026-2894"}
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.763Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses"}
## 5 数据库拒绝(从没有 @Valid 的窗口发送负数金额)
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:06.824Z","status":500,"error":"Internal Server Error","path":"/api/novalid/expenses"}
不符合标记的申请和坏掉的 JSON 是 400。到这里还好,但都只写着“Bad Request”。而“不存在的受理编号”和“同样的申请发 2 次”是 500。负责人抛出的“不存在”异常,以及数据库唯一索引拒绝的连点两下,都没人接住,于是被当作“服务器故障”返回了。用户造成的失败,看起来像服务器故障。监控系统会数 500 并报警,运维的人也会半夜被叫醒(第 13 讲)。
启用 ProblemDetail
HTTP API 返回失败时的形式,有一个叫 ProblemDetail(RFC 9457,以前是 RFC 7807)的规范。在 Spring Boot 中一个设置就能启用。
# 用 spring.mvc.problemdetails.enabled=true 启动时
## 1 不符合标记(项目为空、金额为负数)
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"Invalid request content.","instance":"/api/v3/expenses","status":400,"title":"Bad Request"}
## 2 JSON 坏了
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"Failed to read request","instance":"/api/v3/expenses","status":400,"title":"Bad Request"}
## 3 不存在的受理编号
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:13.943Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses/EXP-2026-9999"}
## 4 同样的申请发 2 次(连点两下)
HTTP/1.1 201 Content-Type: application/json
{"id":"EXP-2026-2897"}
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:14.102Z","status":500,"error":"Internal Server Error","path":"/api/v3/expenses"}
## 5 数据库拒绝(从没有 @Valid 的窗口发送负数金额)
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:14.161Z","status":500,"error":"Internal Server Error","path":"/api/novalid/expenses"}
400 变成了 application/problem+json,title・detail・instance 都有了。但内容是“Invalid request content.”,看不出哪个项目不对。而本该是 404、409 的,依然是 500。统一形式的机制是有的,但哪个异常对应什么只能由应用决定。
设一个异常处理员
负责人只是把申请“不存在”作为业务异常抛出。它不知道 HTTP。
@Transactional(readOnly = true)
public Expense find(String id) {
return repo.findById(id).orElseThrow(() -> new ExpenseNotFoundException(id)); // 不存在 → 业务异常
}
然后,在整个应用里设一个接住异常、做成回复的角色。加了 @RestControllerAdvice 的类里的 @ExceptionHandler,按异常种类决定状态码和文字。
@RestControllerAdvice
@ConditionalOnProperty("demo.advice")
public class ApiErrors {
private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);
@ExceptionHandler(MethodArgumentNotValidException.class) // 不符合标记 → 400,逐项给出理由
ProblemDetail invalid(MethodArgumentNotValidException e) {
ProblemDetail p = problem(HttpStatus.BAD_REQUEST, "invalid-request", "入力に誤りがあります", "直してから、もう一度送ってください");
List<Map<String, String>> errors = e.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", String.valueOf(f.getDefaultMessage()))).toList();
p.setProperty("errors", errors);
return p;
}
@ExceptionHandler(HttpMessageNotReadableException.class) // 无法作为 JSON 读取 → 400
ProblemDetail unreadable(HttpMessageNotReadableException e) {
return problem(HttpStatus.BAD_REQUEST, "unreadable", "申請の形が読めません", "JSON の書き方か、項目の型が違います");
}
@ExceptionHandler(ExpenseNotFoundException.class) // 不存在 → 404
ProblemDetail notFound(ExpenseNotFoundException e) {
return problem(HttpStatus.NOT_FOUND, "expense-not-found", "申請が見つかりません", "受付番号 " + e.id() + " の申請はありません");
}
@ExceptionHandler(DuplicateKeyException.class) // 已经有同样的申请 → 409
ProblemDetail duplicate(DuplicateKeyException e) {
return problem(HttpStatus.CONFLICT, "duplicate", "同じ申請がすでにあります", "同じ日・品目・金額の申請は、1回だけ出せます");
}
@ExceptionHandler(Exception.class) // 其他 → 500。不显示内容,用编号关联
ProblemDetail unexpected(Exception e) {
String no = "ERR-" + HexFormat.of().toHexDigits(ThreadLocalRandom.current().nextInt());
log.error("問い合わせ番号 {}", no, e);
ProblemDetail p = problem(HttpStatus.INTERNAL_SERVER_ERROR, "unexpected", "処理できませんでした", "時間をおいてもう一度お試しください。続くときは問い合わせ番号をお知らせください");
p.setProperty("errorId", no);
return p;
}
# 加上接住异常的角色(ApiErrors)后
## 1 不符合标记(项目为空、金额为负数)
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"item","message":"must not be blank"},{"field":"amount","message":"must be greater than 0"}]}
## 2 JSON 坏了
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"JSON の書き方か、項目の型が違います","instance":"/api/v3/expenses","status":400,"title":"申請の形が読めません","type":"https://expenses.example/problems/unreadable"}
## 3 不存在的受理编号
HTTP/1.1 404 Content-Type: application/problem+json
{"detail":"受付番号 EXP-2026-9999 の申請はありません","instance":"/api/v3/expenses/EXP-2026-9999","status":404,"title":"申請が見つかりません","type":"https://expenses.example/problems/expense-not-found"}
## 4 同样的申请发 2 次(连点两下)
HTTP/1.1 201 Content-Type: application/json
{"id":"EXP-2026-2900"}
HTTP/1.1 409 Content-Type: application/problem+json
{"detail":"同じ日・品目・金額の申請は、1回だけ出せます","instance":"/api/v3/expenses","status":409,"title":"同じ申請がすでにあります","type":"https://expenses.example/problems/duplicate"}
## 5 数据库拒绝(从没有 @Valid 的窗口发送负数金额)
HTTP/1.1 500 Content-Type: application/problem+json
{"detail":"時間をおいてもう一度お試しください。続くときは問い合わせ番号をお知らせください","instance":"/api/novalid/expenses","status":500,"title":"処理できませんでした","type":"https://expenses.example/problems/unexpected","errorId":"ERR-6ec9cea0"}
什么都不加的 Spring Boot,与加上处理员之后(实测)
400 带上了逐项理由,不存在的受理编号变成 404,连点两下变成 409(Conflict,与当前状态冲突)。只有数据库拒绝的是 500,并且附上了查询编号。窗口的代码里一个 try-catch 都没有。
ProblemDetail 的项目与读者
用用户的语言说明理由
刚加上处理员时,400 的理由是英文的“must not be blank”。换一个语言偏好(Accept-Language)发送同样的申请。
# 同样的申请,换一个 Accept-Language
## 1 不符合标记(Accept-Language: ja)
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"item","message":"空白は許可されていません"},{"field":"amount","message":"0 より大きな値にしてください"}]}
## 1 不符合标记(Accept-Language: en)
HTTP/1.1 400 Content-Type: application/problem+json
{"detail":"直してから、もう一度送ってください","instance":"/api/v3/expenses","status":400,"title":"入力に誤りがあります","type":"https://expenses.example/problems/invalid-request","errors":[{"field":"amount","message":"must be greater than 0"},{"field":"item","message":"must not be blank"}]}
Accept-Language: ja 时是“空白は許可されていません”。Spring 会配合请求的语言选择标记的文字。浏览器会自动发送用户的语言,所以从画面来的申请会得到对应语言的理由。不过这句话有点生硬,生产环境里要在标记上写自己的文字(message = "品目を入れてください"),或者准备文字清单(ValidationMessages.properties)。
不要吞掉异常
这是设处理员之前,古老代码里常见的写法:捕获异常,只打印出来,然后继续往下走。
# 古老的负责人(SwallowService 的要点)
public String submit(ExpenseRequest req) {
String id = "EXP-2026-%04d".formatted(repo.nextNumber());
try {
repo.insert(id, "新井", req);
} catch (Exception e) {
e.printStackTrace(); // 画面上什么都不知道
}
return id; // 即使没写进去,也返回受理编号
}
## 6 给吞掉异常的负责人发送负数金额
HTTP/1.1 201 Content-Type: application/json
{"id":"EXP-2026-2903"}
# 去查看这个受理编号
HTTP/1.1 404 Content-Type: application/problem+json
{"detail":"受付番号 EXP-2026-2903 の申請はありません","instance":"/api/v3/expenses/EXP-2026-2903","status":404,"title":"申請が見つかりません","type":"https://expenses.example/problems/expense-not-found"}
# 应用日志(输出到标准错误的堆栈的一部分)
at java.base/java.lang.Thread.run(Thread.java:1474)
Caused by: org.postgresql.util.PSQLException: ERROR: new row for relation "expense" violates check constraint "amount_positive"
Detail: Failing row contains (EXP-2026-2903, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:22.039223+09, 新井).
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2993)
负数金额被数据库拒绝了,画面却收到了 201 和受理编号。用户以为“提交成功”就回去了,月底被财务说“没有这件申请”。日志里虽然有异常,却没有任何东西把它和哪个请求联系起来。与其捕获了什么都不做,不如不捕获。把异常往上交给处理员,事务会回滚(第 5 讲),回复也会正确。
新井连受理编号都出来了,谁都会相信吧……
古井比错误更糟的,是虚假的成功。错误用户自己会发现,虚假的成功要一个月后财务才发现。
500 用查询编号关联
不属于任何一种异常的(数据库拒绝、对方系统宕机、程序错误),确实是服务器这边的问题,所以是 500。处理员不把内容写进回复,只返回编号,再用同一个编号把全部内容写进日志。
# 此时的应用日志(同一个查询编号,内容完整) 01:08:21.825 http-nio-8080-exec-7 ERROR ApiErrors 問い合わせ番号 ERR-6ec9cea0 org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) values (?, ?, ?, ?, ?, 'submitted')]; ERR Detail: Failing row contains (EXP-2026-2902, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:21.818124+09, 新井). at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:140)
只要用户告诉我们“出现了 ERR-6ec9cea0”,就能找到日志的那一行。反过来,也有把内容写进回复的设置。
# 用 spring.web.error.include-message=always 和 include-stacktrace=always 启动时
## 5 数据库拒绝(include-message=always、include-stacktrace=always)
HTTP/1.1 500 Content-Type: application/json
{"timestamp":"2026-10-30T16:08:28.782Z","status":500,"error":"Internal Server Error","trace":"org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) v…(91 行、8842 文字)","message":"PreparedStatementCallback; SQL [insert into expense (id, applicant, applied_on, item, amount, status) values (?, ?, ?, ?, ?, 'submitted')]; ERROR: new row for relation \"expense\" violates check constraint \"amount_positive\"\n Detail: Failing row contains (EXP-2026-2904, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 01:08:28.733548+09, 新井).","path":"/api/novalid/expenses"}
回复里出现了 91 行、8,842 个字符的堆栈,还有 SQL、表名和列名、约束名、失败行的内容。所用部件的名字和行号,对攻击者来说是“该试哪个弱点”的线索(第 11 讲)。include-stacktrace 和 include-message,除了开发中的自己电脑,都不要用。
状态码的选法
| 状态码 | 含义 | 报销系统里的例子 | 谁来改 |
|---|---|---|---|
| 400 | 请求的形式不对 | 项目为空、JSON 坏了 | 发送方(改了就能通过) |
| 401 / 403 | 不知道是谁/没有权限 | 没登录、别人的申请(第 11 讲) | 发送方 |
| 404 | 不存在 | 不存在的受理编号 | 发送方 |
| 409 | 与当前状态冲突 | 连点两下、修改已批准的申请(第 10 讲) | 发送方(重新确认状态) |
| 500 | 服务器这边的问题 | 数据库拒绝、程序错误 | 服务器这边(查询编号) |
| 503 | 现在无法受理 | 停机替换中、太拥挤(第 15 讲) | 等一等就好 |
异常往上抛,在一个地方变成回复
4 开头的是“发送方能改”,5 开头的是“服务器这边的问题”。守住这个区分,画面就能选择是显示改法还是道歉,监控也只数真正的故障。拿不准时,就问“用户改点什么就能通过吗”。
画面怎么接收
前端篇第 9 讲做的“项目旁边的红字”,也能显示服务器给的理由。比如这样接收。
const res = await fetch("/api/v3/expenses", {
method: "POST",
headers: { "Content-Type": "application/json" }, // Accept-Language 由浏览器加上
body: JSON.stringify(form),
});
if (res.ok) { /* 显示受理编号 */ }
else if (res.headers.get("Content-Type")?.startsWith("application/problem+json")) {
const p = await res.json();
(p.errors ?? []).forEach(e => showFieldError(e.field, e.message)); // 显示在项目旁
if (p.status === 409) showMessage(p.title); // 连点两下
if (p.errorId) showMessage(`${p.title}(查询编号 ${p.errorId})`);
}
画面按 status 和 type 选择显示方式,title 和 detail 可以直接给用户看。连点两下本身,画面这边也要让按钮不能再按(前端篇)。服务器的 409,是绕过它时的最后一道刹车。
在自己的电脑上验证
| 想试的事 | 写法 |
|---|---|
| 做成 ProblemDetail 的形式 | spring.mvc.problemdetails.enabled=true |
| 设异常处理员 | @RestControllerAdvice + @ExceptionHandler |
| 增加项目 | problem.setProperty("errors", …) |
| 换语言确认 | curl -H 'Accept-Language: ja' … |
| 看回复的形式和状态码 | curl -si … | head |
| 不能用(生产环境) | spring.web.error.include-stacktrace=always |
在 Spring Boot 4 中,在回复里包含堆栈等内容的设置名,从 server.error.* 移到了 spring.web.error.*。照抄旧文章里的设置名,有时会不起作用。这次实验里,用旧名字也什么都没变。
结 — 一页总结与下一讲
- 什么都不加,“不存在”和“连点两下”都是 500。用户造成的失败,看起来像故障。
- 用 ProblemDetail(RFC 9457)统一形式。但哪个异常对应什么由应用决定。
- 异常由负责人抛出,@RestControllerAdvice 的处理员在一处变成状态码和文字。窗口里不需要 try-catch。
- 400 用用户的语言给出逐项理由。
- 不要吞掉异常。没写进去却返回 201 的虚假成功最糟。
- 500 不显示内容,用查询编号和日志关联。不要返回堆栈。
新井这样就从“系统发生错误”毕业了。
古井毕业了。下一讲回到仓库管理员。手写 SQL 的 JdbcClient、JPA、MyBatis,把同一个申请列表用三种方式写,比较会发生什么。
参考
- RFC 9457 — Problem Details for HTTP APIs
- RFC 9110 — HTTP Semantics(Status Codes)
- Spring Framework — Error Responses(ProblemDetail)
- Spring Framework — Controller Advice
- Spring Boot — Servlet Web Applications(Error Handling)
- OWASP — Error Handling Cheat Sheet
记录是笔者于 2026 年 10 月 31 日在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 Spring Boot 4.1.2(Spring Framework 7、Tomcat 11、Hibernate Validator 9)、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。PC・申请服务器・数据库服务器,是在一台机器里建立的不同网络空间(network namespace)。查询编号每次运行都会变。画面的代码是用于说明的例子。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构。
古法后端入门 第7讲 — 异常与回复 / 2026.10