Old-School Backend / 2026 ・ No.05
古法后端入门
第5讲 回到 Spring Boot ── DI、三层,以及“Bean 只有一个”
发布
第 3 讲徒手处理了 HTTP,第 4 讲徒手处理了数据库。这次回到 Spring Boot。徒手做错的事,Spring Boot 真的会接手吗?使用 Spring Boot 时,又有什么新的要注意的地方?
在第 1 讲的应用里加上实验用的窗口和负责人,实测 DI(依赖注入)、窗口・负责人・仓库管理员三层、Bean 只有一个、代理人与 @Transactional。
从徒手回来
新井终于是 Spring Boot 了。这下全都交给它了吧。
古井一半是。另一半,恰恰是因为用了 Spring Boot 才会出的事故。
新井变方便了,事故反而变多?
古井因为看不见的地方运行的东西变多了。今天就从数这些看不见的东西开始。
全 15 讲路线图(当前:第 5 讲)
- 名册 ── 启动后有 196 个 Bean,自己写的是 8 个(实测)
- DI ── 零件不自己造,用构造函数接收。不用 Spring 也能在 0.23 秒内运行(实测)
- Bean 只有一个 ── 把申请人放在负责人的字段里,200 件中有 87~102 件申请人被调换(实测)
- Jackson 与 Tomcat ── 第 3・4 讲的失败一个都没有发生(实测)
- 代理人 ── 在同一个类里调用,@Transactional 不生效,留下了只有一半的申请(实测)
舞台和第 1 讲一样是三台机器。应用是在第 1 讲的 Spring Boot 4.1.2(Tomcat 11、Jackson 3、JDK 25)上,加了实验用的窗口(DemoController)和三个负责人。数据库是第 4 讲加了申请人列的 PostgreSQL 16。
Spring Boot 里的名册
Spring Boot 应用启动时,Spring 会造出需要的零件、把它们连起来,放进一个容器(ApplicationContext)里。这些零件叫作 Bean。启动后,让应用自己数一数 Bean。
# 启动之后,Spring 创建的东西(Bean)的名册(← 是交给这个 Bean 的东西) Started ExpensesApplication in 4.12 seconds (process running for 4.83) Bean の数 196(うち自分で書いたもの 8) expenseController = ExpenseController ← [expenseService] demoController = DemoController ← [expenseService, badExpenseService, tripService] expenseService = ExpenseService$$SpringCGLIB$$0 ← [expenseRepository] badExpenseService = BadExpenseService ← [expenseRepository] tripService = TripService$$SpringCGLIB$$0 ← [expenseRepository] expenseRepository = ExpenseRepository$$SpringCGLIB$$0 ← [jdbcClient] DataSource : dataSource = com.zaxxer.hikari.HikariDataSource JdbcClient : jdbcClient = org.springframework.jdbc.core.simple.DefaultJdbcClient PlatformTransactionManager : transactionManager = org.springframework.jdbc.support.JdbcTransactionManager JsonMapper : jacksonJsonMapper = tools.jackson.databind.json.JsonMapper DispatcherServlet : dispatcherServlet = org.springframework.web.servlet.DispatcherServlet ServletWebServerFactory : tomcatServletWebServerFactory = org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory
一件申请经过的 Bean,以及配角们
Bean 有 196 个。自己写的是 8 个(应用本体、2 个窗口、3 个负责人、仓库管理员、名册员),其余 188 个由 Spring Boot 准备。第 3 讲徒手做的接收 HTTP 由 Tomcat 负责,选择窗口是 DispatcherServlet,JSON 是 Jackson 3 的 JsonMapper。第 4 讲的连接池是 HikariCP,事务是 JdbcTransactionManager。还有,负责人和仓库管理员的名字后面带着一个陌生的记号 $$SpringCGLIB$$0。谜底在第八章揭晓。
DI:不自己造,而是让别人交给你
再看一次负责人的代码。仓库管理员(ExpenseRepository)不是用 new 造的,而是通过构造函数的参数接收。
@Service
public class ExpenseService {
private static final Logger log = LoggerFactory.getLogger(ExpenseService.class);
private final ExpenseRepository repo;
public ExpenseService(ExpenseRepository repo) { // 仓库管理员不自己造,而是让别人交给我
this.repo = repo;
}
@Transactional
public String submit(ExpenseRequest req) {
return submitAs("新井", req);
}
@Transactional
public String submitAs(String applicant, ExpenseRequest req) { // 申请人用参数接收
String id = "EXP-2026-%04d".formatted(repo.nextNumber());
log.info("担当者:受付番号を決めた {} {}", id, applicant);
repo.insert(id, applicant, req);
return id;
}
“要用的零件不自己造,而是从外面交进来”。这就是 DI(Dependency Injection,依赖注入)。名册里的箭头(←),就是 Spring 把什么交给了谁。把 Spring 启动时做的事用手写出来,是这样的。
# 把 Spring 启动时做的事,用手写出来 JdbcClient jdbc = …; // 来自 DataSource(HikariCP) ExpenseRepository repo = new ExpenseRepository(jdbc); ExpenseService service = new ExpenseService(repo); TripService trips = new TripService(repo); // 仓库管理员共用同一个 ExpenseController controller = new ExpenseController(service);
普通得让人泄气的 Java。写成“让别人交进来”的形式,就能替换交进来的东西。不用 Spring 也不用数据库,交一个假的仓库管理员,只让负责人运行。
# 不用 Spring 也不用数据库,只让负责人运行(FakeRun.java 的要点)
List<String> written = new ArrayList<>();
ExpenseRepository fake = new ExpenseRepository(null) { // 假的仓库管理员(不去数据库)
long n = 12;
@Override long nextNumber() { return n++; }
@Override void insert(String id, String applicant, ExpenseRequest r) { written.add(id + " " + applicant + " " + r.item()); }
};
ExpenseService service = new ExpenseService(fake); // 手工做 DI=只是交给构造函数
String id = service.submitAs("古井", new ExpenseRequest(LocalDate.of(2026, 10, 31), "交通費", new BigDecimal("1280")));
# 运行(3 次)
返ってきた受付番号 EXP-2026-0012、偽の倉庫番に書かれたもの [EXP-2026-0012 古井 交通費](163.5 ms) JVM の起動から終わりまで 0.23 s
返ってきた受付番号 EXP-2026-0012、偽の倉庫番に書かれたもの [EXP-2026-0012 古井 交通費](201.7 ms) JVM の起動から終わりまで 0.25 s
返ってきた受付番号 EXP-2026-0012、偽の倉庫番に書かれたもの [EXP-2026-0012 古井 交通費](184.4 ms) JVM の起動から終わりまで 0.23 s
从 JVM 启动到结束 0.23~0.25 秒。启动 Spring Boot,光是这一步就要 3.6~4.1 秒。如果负责人自己 new 仓库管理员,没有数据库就无法测试负责人。DI 也是“容易测试的形式”,第 14 讲的测试以这种形式为前提。
零件用构造函数接收,放进 final 字段。构造函数只有一个时,连 @Autowired 都不需要。古老代码里常见的直接在字段上加 @Autowired 的写法,没有 Spring 时就没有交零件的入口,测试时会很麻烦。
窗口・负责人・仓库管理员
从第 1 讲用到现在的三个角色,在 Spring 的世界里被称为“三层”。
| 角色 | Spring 的标记 | 做什么 | 不做什么 |
|---|---|---|---|
| 窗口 | @RestController | 在 HTTP 的形式(URL、方法、JSON、状态码)与 Java 的值之间转换 | 业务规则、SQL |
| 负责人 | @Service | 业务规则(受理编号、金额计算、审批顺序)。事务的范围 | HTTP 的事、SQL 语句 |
| 仓库管理员 | @Repository | 写 SQL,和数据库对话。把数据库的异常翻译成 Spring 的异常 | 业务判断 |
之所以分开,是因为变化的理由不同。画面需要改 JSON 的形式,负责人不变;数据库的表拆开,窗口不变。能各自单独测试,也多亏了 DI 和三层的组合。想在窗口里写 SQL 的时候,就是负责人或仓库管理员的工作迷路了的信号。
Bean 只有一个:别放在桌上
Spring 的 Bean,不特别指定的话整个应用只有一个(单例)。名册里的 expenseRepository,交给三个负责人的都是同一个。负责人也只有一个,同时到来的请求,由不同的线程同时使用同一个负责人。于是我们做了一个把申请人放在字段(桌上)的负责人。用 X-User 首部代替登录来发送申请人(真正的登录在第 11 讲)。
# 不好的负责人:把申请人放在字段里
@Service
public class BadExpenseService {
private final ExpenseRepository repo;
private String applicant; // ← Bean 只有一个。所有人用同一张桌子
public BadExpenseService(ExpenseRepository repo) {
this.repo = repo;
}
public String submit(String who, ExpenseRequest req) {
this.applicant = who;
String id = "EXP-2026-%04d".formatted(repo.nextNumber()); // 在和数据库往返一次的时候……
repo.insert(id, this.applicant, req);
return id;
}
}
# 同时 200 件(每次 50 个),arai 和 furui 交替申请。与数据库里的申请人比较(3 次) 放在字段里的版本:200 件中、申請者が入れ替わったもの 87 件(例:EXP-2026-1002 は furui が出したのに arai) 放在字段里的版本:200 件中、申請者が入れ替わったもの 102 件(例:EXP-2026-1209 は arai が出したのに furui) 放在字段里的版本:200 件中、申請者が入れ替わったもの 94 件(例:EXP-2026-1402 は furui が出したのに arai) 用参数接收的版本:200 件中、申請者が入れ替わったもの 0 件 用参数接收的版本:200 件中、申請者が入れ替わったもの 0 件 用参数接收的版本:200 件中、申請者が入れ替わったもの 0 件
Bean 只有一个。同时到来的两个人用同一张桌子(实测)
200 件中有 87~102 件,几乎一半的申请人被调换了。因为 arai 在桌上写下名字、去数据库要受理编号的时候,furui 改写了同一张桌子。用测试一件一件试的时候,绝对不会发生。用参数接收申请人的负责人,3 次都是 0 件。
新井我的申请,变成古井前辈的名字了……
古井还好不是反过来。差点寿司费就成了你的名字。
Bean 的字段里只能放交进来的零件(final)和不变的设置。每个请求都不同的值(谁、什么、多少钱),用参数和局部变量来传。和第 2 讲的 SimpleDateFormat 一样,“一个东西大家一起用”的,就不要让它带状态。
JSON 交给 Jackson
把第 3 讲被正则表达式弄错的申请,原样发给 Spring Boot。
# 把第 3 讲那些有点刁钻的申请发给 Spring Boot
## a ふつう
{"status":"submitted","id":"EXP-2026-2201"}
-> 201
## b 品目に引用符
{"status":"submitted","id":"EXP-2026-2202"}
-> 201
## c json.dumps(\u)
{"status":"submitted","id":"EXP-2026-2203"}
-> 201
## d 内訳つき(入れ子の amount)
{"status":"submitted","id":"EXP-2026-2204"}
-> 201
## e 閉じかっこがない
{"timestamp":"2026-10-30T15:25:47.433Z","status":400,"error":"Bad Request","path":"/api/expenses"}
-> 400
## f 金額が文字
{"timestamp":"2026-10-30T15:25:47.462Z","status":400,"error":"Bad Request","path":"/api/expenses"}
-> 400
# 存进数据库的内容
EXP-2026-2201|交通費|1280.00
EXP-2026-2202|会議費 "A社"|3300.00
EXP-2026-2203|交通費|1280.00
EXP-2026-2204|交通費|1280.00
# 列表的回复(GET)里,带引号的项目是这样写的
{"id":"EXP-2026-2202","date":"2026-10-31","item":"会議費 \"A社\"","amount":3300.00,"status":"submitted"}
带引号的项目按 会議費 "A社" 原样存入,回复里正确地写成了 \"。交通費 变回了“交通費”,没有被明细里的 amount 迷惑,金额是 1280 日元。缺右括号的 JSON,以及金额写成“千二百八十円”的申请,都以 400 被拒绝。
不过有两点让人在意。一是记录(ExpenseRequest)里没有的 receipts,被 Jackson 默默丢掉了。画面那边以为发了明细,却哪里都没留下。二是 400 的回复只有“Bad Request”,看不出哪里不对。前者在第 6 讲的输入检查里,后者在第 7 讲的“异常与回复”里讨论。
Tomcat 接手的工作
# 坏掉的请求、不存在的方法、不存在的地方
## HELLO
HTTP/1.1 400
Content-Type: text/html;charset=utf-8
Content-Language: en
Content-Length: 435
Date: Fri, 30 Oct 2026 15:25:47 GMT
Connection: close
<!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style>…</style></head><body><h1>HTTP Status 400 – Bad Request</h1></body></html>
## PUT
{"timestamp":"2026-10-30T15:25:47.802Z","status":405,"error":"Method Not Allowed","path":"/api/expenses"}
-> 405
## ない場所
{"timestamp":"2026-10-30T15:25:47.828Z","status":404,"error":"Not Found","path":"/api/nothing"}
-> 404
# 列表回复的首部(不事先数长度,分块发送)
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 30 Oct 2026 15:25:47 GMT
毫无意义的 HELLO 由 Tomcat 以 400(HTML 页面)拒绝,PUT 和不存在的地方,则由 Spring MVC 用 JSON 分别返回 405 和 404。列表的回复里没有 Content-Length,取而代之的是 Transfer-Encoding: chunked。这是一种不事先数长度、每一块都写上“从这里开始几个字节”再发送的方法,第 3 讲那种“用字符数写长度”的 bug 根本无从发生。
# 沉默的客人 黙っている客:60.1 秒後にサーバーが切った(受け取ったバイト 0) 黙っている客がいる間の申請 -> 201 (0.033746 s)
沉默的客人在 60.1 秒时被服务器切断。Tomcat 默认的等待时间(connectionTimeout)是 60 秒,在 Spring Boot 中可以用 server.tomcat.connection-timeout 修改。这段时间里,其他申请 0.034 秒就拿到了 201。Tomcat 最多准备 200 个工人(线程),把来的客人分配下去。
代理人与 @Transactional
揭晓名册里 ExpenseService$$SpringCGLIB$$0 的谜底。对于带有 @Transactional 方法的 Bean,Spring 会造一个包着本体的代理人(代理,proxy)交出去,代替本体。代理人被调用时先开始事务,再调用本体,成功就 commit,出异常就 rollback。第 4 讲手写的 setAutoCommit(false)、commit、rollback,由代理人代劳了。仓库管理员的代理人,负责把数据库的异常翻译成 Spring 的异常(不是第 4 讲的 SQLState,而是 DataIntegrityViolationException 等)。
把第 4 讲出差的申请(酒店费 1,000,000 日元放不进明细列),用三种调用方式发送。
# 出差负责人(TripService 的要点)
@Transactional
public String submit(TripRequest t) {
log.info("出張:トランザクションの中? {}", TransactionSynchronizationManager.isActualTransactionActive());
String id = "EXP-2026-%04d".formatted(repo.nextNumber());
repo.insert(id, "新井", new ExpenseRequest(t.date(), t.item(), t.total()));
int no = 1;
for (TripRequest.Line l : t.lines()) repo.insertLine(id, no++, l.item(), l.amount());
return id;
}
public String submitFromInside(TripRequest t) { // 在同一个类里调用 submit
return submit(t);
}
@Transactional
public String submitChecked(TripRequest t) throws ReceiptMissingException {
String id = submit(t);
throw new ReceiptMissingException("領収書の画像がない " + id); // 受检异常
}
# 新干线 12,860 日元+酒店 1,000,000 日元(放不进明细列)。checked 的明细正确,最后抛出受检异常 ## mode=tx -> 500 残ったもの:expense 0 / expense_line 0 ## mode=inside -> 500 残ったもの:expense 1 / expense_line 1 ## mode=checked(明細は正しい。最後にチェック例外) -> 500 残ったもの:expense 1 / expense_line 2 # 应用日志(节选) 15:26:47.982 Creating new transaction with name [example.expenses.TripService.submit]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT 15:26:47.983 出張:トランザクションの中? true 15:26:47.987 倉庫番:1行書いた EXP-2026-2206 15:26:47.992 倉庫番:明細を書いた EXP-2026-2206 1 15:26:48.004 Rolling back JDBC transaction on Connection [HikariProxyConnection@1350144235 wrapping org.postgresql.jdbc.PgConnection@59b65dce] 15:26:48.005 Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springf org.postgresql.util.PSQLException: ERROR: numeric field overflow 15:26:48.170 出張:トランザクションの中? false 15:26:48.173 倉庫番:1行書いた EXP-2026-2207 15:26:48.174 倉庫番:明細を書いた EXP-2026-2207 1 15:26:48.177 Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springf org.postgresql.util.PSQLException: ERROR: numeric field overflow 15:26:48.308 Creating new transaction with name [example.expenses.TripService.submitChecked]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT 15:26:48.309 出張:トランザクションの中? true 15:26:48.310 倉庫番:1行書いた EXP-2026-2208 15:26:48.311 倉庫番:明細を書いた EXP-2026-2208 1 15:26:48.312 倉庫番:明細を書いた EXP-2026-2208 2 15:26:48.312 Committing JDBC transaction on Connection [HikariProxyConnection@1851115318 wrapping org.postgresql.jdbc.PgConnection@59b65dce] 15:26:48.315 Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: example.exp example.expenses.ReceiptMissingException: 領収書の画像がない EXP-2026-2208
经过代理人就会回滚。不经过,就只是普通的方法(实测)
- tx ── 窗口经过代理人调用 submit。在事务里运行,失败后全部回滚,0 行
- inside ── 窗口调用 submitFromInside,在里面调用 this.submit()。this 是本体自己,所以不经过代理人。日志是“トランザクションの中? false”,留下了和第 4 讲一样只有一半的申请
- checked ── 全部写完后,抛出“没有收据”的受检异常(Exception 的子类)。Spring 默认对受检异常不回滚,而是 commit。3 行全都留下了
@Transactional 不是加上就生效的魔法,而是只有经过代理人调用时才生效的约定。想放进事务范围的处理,拆到别的 Bean 里,从外面调用。想在受检异常时也回滚,就写 rollbackFor,或者改成运行时异常。两个人同时修改同一件申请的话题,第 9 讲会再回到事务。
徒手的工作去了哪里
第 3・4 讲徒手做错的事,在 Spring Boot 上(实测)
第 3 讲和第 4 讲的失败,在 Spring Boot 上一个都没有发生。HTTP 是 Tomcat,选窗口是 Spring MVC,JSON 是 Jackson,连接是 HikariCP,回滚是代理人。正因为徒手做过,听到这些名字时才知道“是那份工作”。作为交换,多了 Bean 只有一个和代理人这两条 Spring 特有的约定。
在自己的电脑上验证
| 想试的事 | 命令・写法 |
|---|---|
| 生成项目骨架(也可以用浏览器) | https://start.spring.io |
| 不打包直接运行 | ./mvnw spring-boot:run |
| 自动配置造了什么、没造什么 | java -jar app.jar --debug |
| 交进来的是不是代理人 | log.info("{}", service.getClass()); |
| 现在是否在事务里 | TransactionSynchronizationManager.isActualTransactionActive() |
| 切断沉默客人的时间 | server.tomcat.connection-timeout=20s |
加上 --debug,启动时会输出“CONDITIONS EVALUATION REPORT”。像“因为有 JDBC 驱动所以造了 DataSource”这样的 Spring Boot 判断会列成清单,是查“为什么会有这个 Bean”的入口。
结 — 一页总结与下一讲
- Spring Boot 启动时造出 Bean 并连起来。这一讲的应用有 196 个,自己写的是 8 个。
- DI:零件用构造函数接收,放进 final。交一个假的,不用 Spring 也能测试。
- 窗口(HTTP 的形式)・负责人(业务规则与事务)・仓库管理员(SQL)。按变化的理由来分。
- Bean 只有一个。把每个请求的值放在字段里,申请人就被调换了(200 件中 87~102 件)。
- 第 3・4 讲的失败,由 Tomcat・Spring MVC・Jackson・HikariCP 接手。不过不认识的项目会被默默丢掉。
- @Transactional 只在经过代理人时生效。用 this 调用不生效,受检异常时会 commit。
新井明细被默默丢掉这件事,不起眼但让人在意。
古井眼光不错。下一讲是输入检查。空的项目、负数金额、全角数字、不认识的项目。在窗口之前,决定检查什么、检查到哪里。
参考
- Spring Framework — Dependency Injection
- Spring Framework — Bean Scopes(singleton)
- Spring Framework — Using @Transactional(proxy と self-invocation)
- Spring Framework 7.1 — Rolling Back a Declarative Transaction
- Spring Boot Reference Documentation
- Apache Tomcat 11 — The HTTP Connector(connectionTimeout、maxThreads)
- Jackson Project Home(FasterXML)
记录是笔者于 2026 年 10 月 30 日~31 日在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 Spring Boot 4.1.2(Tomcat 11、Jackson 3)、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。PC・申请服务器・数据库服务器,是在一台机器里建立的不同网络空间(network namespace)。申请人被调换的件数和启动时间,每次运行都会不同。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构。
古法后端入门 第5讲 — 回到 Spring Boot / 2026.10