Old-School Backend / 2026 ・ No.06
古法后端入门
第6讲 输入检查 ── 检查什么、在哪里检查、检查到什么程度
发布
上一讲,Jackson 默默丢掉了不认识的项目。那么,空的项目和负数金额会在哪里被挡住呢?前端篇第 9 讲在画面上做了输入检查,但服务器也会收到不经过画面的申请。
这次把申请存进数据库之前的关卡逐一试一遍:@Valid 与 Bean Validation、只靠标记不够的地方、JSON 阶段、“今天”是哪里的今天,以及最后防线 数据库约束。
在入口检查
新井输入检查的话,前端篇已经在画面上做了。金额为空就变红的那个。
古井那是对用户的体贴。用 curl 发过来,就不经过画面了。
新井会有那种人吗?
古井有。别的系统、旧的应用、恶作剧。服务器不管是谁发来的,都要检查。
全 15 讲路线图(当前:第 6 讲)
- @Valid ── 忘了加的窗口,空的项目和负数金额都进了数据库(实测)
- 标记不够 ── 1280.125 日元被默默变成 1280.13 日元,101 个字符的项目变成 500(实测)
- JSON 阶段 ── 字符串 "1280" 能通过,全角 "1280" 是 400。不认识的项目一个设置就能拒绝(实测)
- 今天 ── 东京今天的申请,被 UTC 的应用当作“未来”拒绝(实测)
- 数据库约束 ── 加上 CHECK,连不经过应用的写入也挡住了(实测)
舞台和之前一样是三台机器。应用是在上一讲的 Spring Boot 4.1.2(Hibernate Validator 9、Jackson 3)上,加了三个检查强度不同的窗口:/api/novalid(没有 @Valid)、/api/v1(第 1 讲的标记)、/api/v2(严格的标记)。申请从 PC 发送,受理的申请连存进数据库的内容都确认了。
忘了加 @Valid 的话
第 1 讲的申请形式(ExpenseRequest)上已经有标记了。可是,标记只有在窗口的参数上加了 @Valid 时才起作用。
@PostMapping("/api/novalid/expenses") // 忘了加 @Valid 的窗口
public Map<String, String> noValid(@RequestBody ExpenseRequest req) {
return Map.of("id", service.submit(req));
}
@PostMapping("/api/v1/expenses") // 第 1 讲的标记(@NotNull、@NotBlank、@Positive)
public Map<String, String> v1(@Valid @RequestBody ExpenseRequest req) {
return Map.of("id", service.submit(req));
}
# 忘了加 @Valid 的窗口(/api/novalid/expenses) a 项目为空 200 存进数据库: 2026-10-28||1280.00 a2 项目只有全角空格 200 存进数据库: 2026-10-28| |1280.00 b 金额为负数 200 存进数据库: 2026-10-28|交通費|-1280.00 c 没有日期 500 ERROR: null value in column "applied_on" of relation "expense" violates not-null constraint d 金额为 0 200 存进数据库: 2026-10-28|交通費|0.00 # @Valid 和第 1 讲的标记(/api/v1/expenses) a 项目为空 400 item: must not be blank a2 项目只有全角空格 400 item: must not be blank b 金额为负数 400 amount: must be greater than 0 c 没有日期 400 date: must not be null d 金额为 0 400 amount: must be greater than 0
没有 @Valid 的窗口,把空的项目、只有全角空格的项目、负数金额、0 日元都原样存进了数据库。只有没有日期的申请变成了 500,但拒绝它的不是应用,而是数据库的 NOT NULL。加了 @Valid 的窗口,5 件都以 400 拒绝,日志里留下了哪个项目、为什么不行。只忘了一个词,标记就全都成了摆设。
Bean Validation 的标记
public record ExpenseRequest(
@NotNull LocalDate date,
@NotBlank String item,
@NotNull @Positive BigDecimal amount) {
}
加在项目上的 @NotNull(不是 null)、@NotBlank(不只是空白的文字)、@Positive(大于 0),是 Bean Validation(Jakarta Validation)的标记。在 Spring Boot 中由 Hibernate Validator 读取,在交给窗口之前检查带 @Valid 的参数。只有全角空格的项目,也被 @NotBlank 拒绝了。
标记的好处是,“金额是正数”这样的规则就写在申请形式旁边。不过 400 的回复和上一讲一样只有“Bad Request”,哪个项目不对,传不到用户那里。把回复做成能传达这些,是下一讲的工作。
能通过标记、却很麻烦的申请
第 1 讲的标记只是最低限度。发送了 6 件能绕过标记、却会带来麻烦的申请。
# 能通过第 1 讲的标记、却很麻烦的申请(/api/v1/expenses) e 项目前后有空格 200 存进数据库: 2026-10-28| 交通費 |1280.00 f 金额 1280.125 200 存进数据库: 2026-10-28|交通費|1280.13 g 项目 101 个字符 500 ERROR: value too long for type character varying(100) h 金额 1 万亿日元 500 ERROR: numeric field overflow i 未来的日期 200 存进数据库: 2027-04-01|交通費|1280.00 j 6 万日元、没有理由 200 存进数据库: 2026-10-28|接待費|60000.00
- 前后有空格 ── “ 交通費 ”连同空格一起保存了。统计时会和“交通費”成为不同的项目
- 1280.125 日元 ── 数据库的 numeric(12,2) 默默舍入成了 1280.13 日元。谁也没有决定舍入方式(第 2 讲)
- 101 个字符的项目、1 万亿日元 ── 应用放行了,数据库的类型(varchar(100)、numeric(12,2))拒绝,变成 500。在用户看来是“服务器故障”
- 未来的日期、没有理由的 6 万日元 ── 业务上想拒绝,但没有标记,所以被保存了
严格的标记,以及检查前的清理
做了一个加了标记的申请形式(StrictExpenseRequest)。
public record StrictExpenseRequest(
@NotNull @PastOrPresent LocalDate date, // 不允许未来的日期
@NotBlank @Size(max = 100) String item, // 与数据库的 varchar(100) 一致
@NotNull @Positive @Digits(integer = 9, fraction = 0) BigDecimal amount, // 日元。没有小数,最多 9 位
@Size(max = 200) String reason) {
public StrictExpenseRequest {
if (item != null) item = item.strip(); // 去掉前后空格(含全角)后再检查
if (reason != null) reason = reason.strip();
}
@AssertTrue(message = "5万円を超える申請には理由が必要です")
public boolean isReasonGivenForLargeAmount() { // 跨项目的规则
return amount == null || amount.compareTo(new BigDecimal("50000")) <= 0 || (reason != null && !reason.isEmpty());
}
# 同样的申请,发给严格标记的窗口(/api/v2/expenses) e 项目前后有空格 200 存进数据库: 2026-10-28|交通費|1280.00 f 金额 1280.125 400 amount: numeric value out of bounds (<9 digits>.<0 digits> expected) g 项目 101 个字符 400 item: size must be between 0 and 100 h 金额 1 万亿日元 400 amount: numeric value out of bounds (<9 digits>.<0 digits> expected); reasonGivenForLargeAmount: 5万円を超える申請には理由が必要です i 未来的日期 400 date: must be a date in the past or in the present j 6 万日元、没有理由 400 reasonGivenForLargeAmount: 5万円を超える申請には理由が必要です k 6 万日元、有理由 200 存进数据库: 2026-10-28|接待費|60000.00
第 1 讲的标记与严格的标记(实测)
- 与数据库的类型一致 ── @Size(max = 100) 和 @Digits(integer = 9, fraction = 0)。原本在数据库变成 500 的,变成了 400
- 检查之前先清理 ── 在 record 的构造函数里 strip()(全角空格也去掉)。检查之后再改,改过的值就没被检查过
- 跨项目的规则 ── “超过 5 万日元需要理由”要同时看金额和理由。用加了 @AssertTrue 的方法就能写
不过,标记能写的,只到“只看这一件申请就能决定的事”。像“同一天已经有同样金额的交通费”“上级已批准的申请不能修改”这样不查数据库就不知道的规则,是负责人(Service)的工作(第 10 讲)。
在 JSON 阶段发生的事
在 Bean Validation 之前,Jackson 先把 JSON 变成 Java 的值。这里也有被拒绝的,和意外通过的。
# 在 JSON 阶段发生的事(/api/v2/expenses) l 金额是字符串 "1280" 200 存进数据库: 2026-10-28|交通費|1280.00 m 金额是全角 "1280" 400 Cannot deserialize value of type `java.math.BigDecimal` from String "1280": not a valid representation n 金额是 "1,280" 400 Cannot deserialize value of type `java.math.BigDecimal` from String "1,280": not a valid representation o 项目是数字 123 200 存进数据库: 2026-10-28|123|1280.00 p 金额是 true 400 Cannot deserialize value of type `java.math.BigDecimal` from Boolean value (token `JsonToken.VALUE_TRUE`) q 2 月 30 日 400 Cannot deserialize value of type `java.time.LocalDate` from String "2026-02-30": Failed to deserialize `java.time.LocalDate` r 不认识的项目 receipts 200 存进数据库: 2026-10-28|交通費|1280.00 # 用 spring.jackson.deserialization.fail-on-unknown-properties=true 启动时 r 不认识的项目 receipts 400 Unrecognized property "receipts" (class example.expenses.StrictExpenseRequest), not marked as ignorable
字符串 "1280" 被变成数字,数字 123 被变成项目文字“123”,这是 Jackson 的贴心。另一方面,全角的 "1280" 是 400。第 2 讲里 new BigDecimal("1280") 能读成 1280,所以不同的工具答案不同。"1,280"、true、2 月 30 日也都是 400。
上一讲被默默丢掉的 receipts,加上 spring.jackson.deserialization.fail-on-unknown-properties=true 启动后,就能以 400 拒绝。画面和 API 由同一个团队做的话,设成拒绝、尽早发现发错,比较安全。反过来,对外公开、不断增加项目的 API,放过不认识的项目,才不会弄坏老用户。选哪种,是要决定并写下来的规则。
“今天”是哪里的今天
在东京的 10 月 31 日 0 点 55 分,用当天的日期试了严格标记里的 @PastOrPresent(今天或过去的日期)。
# 东京是 10 月 31 日 0 点多,UTC 是 10 月 30 日 15 点多。JVM 没有时区设置(UTC) s 今天(东京)2026-10-31 400 date: must be a date in the past or in the present # 同样的申请,发给用 -Duser.timezone=Asia/Tokyo 启动的应用 s 今天(东京)2026-10-31 200 存进数据库: 2026-10-31|交通費|1280.00
“今天”是 JVM 时钟的今天(实测)
应用的 JVM 没有时区设置,按 UTC 运行。UTC 还是 10 月 30 日,所以东京今天的申请被当作“未来的日期”拒绝了。每天 0 点到 9 点,东京的用户都没法提交今天的申请。和第 2 讲的作业是同一个根源。用 -Duser.timezone=Asia/Tokyo 启动后,同样的申请以 201 通过了。用到“今天”的规则,要先决定是哪里的今天。
新井半夜提交申请的人……
古井有人出差坐末班车回来,在家里提交。越是这种人,被拒绝了越生气。
最后防线:数据库约束
应用的标记,只对经过应用的申请有效。迁移脚本、别的系统、用 psql 手工操作,都会绕过应用直接写数据库。所以,让数据库自己也带上规则。
# 给数据库加上 CHECK 约束 alter table expense add constraint amount_positive check (amount > 0); # 从没有 @Valid 的窗口发送负数金额 b 金额为负数 500 ERROR: new row for relation "expense" violates check constraint "amount_positive" # 即使用 psql 直接写入 ERROR: new row for relation "expense" violates check constraint "amount_positive" DETAIL: Failing row contains (EXP-2026-9999, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 00:53:08.807753+09, 新井).
加上 CHECK (amount > 0) 之后,无论从没有 @Valid 的窗口,还是用 psql 直接写,负数金额都进不去了。第二章挡住没有日期的申请的,也是数据库的 NOT NULL。不过数据库拒绝时,应用会抛出异常,给用户的回复是 500。数据库约束是“最后防线”,给用户的细致说明是前面关卡的工作。
检查什么、在哪里检查
申请存进数据库之前的关卡
画面的检查是为了用户,服务器的检查是为了数据。同样的规则两边都写,并不是浪费。服务器上有:JSON 的类型(Jackson)、项目的形式(Bean Validation 与 @Valid)、查数据库决定的业务规则(负责人),以及数据库约束。越靠前越能早而清楚地拒绝,越靠后越可靠。
在自己的电脑上验证
| 想试的事 | 写法 |
|---|---|
| 让标记在窗口生效 | @Valid @RequestBody StrictExpenseRequest req |
| 与数据库的长度和位数一致 | @Size(max = 100)、@Digits(integer = 9, fraction = 0) |
| 跨项目的规则 | @AssertTrue public boolean isReasonGiven…() |
| 拒绝不认识的项目 | spring.jackson.deserialization.fail-on-unknown-properties=true |
| 让 JVM 的今天是东京的今天 | java -Duser.timezone=Asia/Tokyo -jar app.jar |
| 让数据库也带上规则 | alter table expense add check (amount > 0); |
标记的提示信息会随 JVM 的语言设置变化。这次实验是 LANG=C.UTF-8,所以是英文。不依赖标记默认的提示、自己决定给用户看的文字,也放在下一讲的回复写法里讨论。
结 — 一页总结与下一讲
- 画面的检查是为了用户。服务器不管是谁发来的都要检查。
- Bean Validation 的标记只在加了 @Valid 的窗口起作用。忘了加,空的和负数都进了数据库。
- 标记要与数据库的类型一致。不够的话,要么被默默舍入,要么一路到数据库变成 500。
- 检查之前先清理(strip)。跨项目的规则用 @AssertTrue,查数据库的规则交给负责人。
- JSON 阶段:字符串 "1280" 能通过,全角是 400。拒不拒绝不认识的项目,要决定并写下来。
- “今天”是 JVM 的今天。UTC 的应用,每天 0~9 点都拒绝东京今天的申请。
- 数据库约束是最后防线,连不经过应用的写入也能挡住。但回复是 500。
新井能拒绝了,可回复只有“Bad Request”和“500”,总觉得不够体贴。
古井下一讲就讲这个。在哪里接住异常,用什么状态码,写什么内容返回。从古老的“系统发生错误”毕业吧。
参考
- Jakarta Validation 3.1
- Hibernate Validator — Documentation
- Spring Framework — Java Bean Validation
- Spring Boot — Validation
- PostgreSQL 16 — Constraints(CHECK、NOT NULL)
- OWASP — Input Validation Cheat Sheet
记录是笔者于 2026 年 10 月 31 日(东京)在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 Spring Boot 4.1.2(Hibernate Validator 9、Jackson 3)、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。PC・申请服务器・数据库服务器,是在一台机器里建立的不同网络空间(network namespace)。“今天”的实验在东京 0 点多(UTC 15 点多)进行。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构。
古法后端入门 第6讲 — 输入检查 / 2026.10