这份附录解决什么问题
本书刻意不用 Spring,把事务、校验、错误映射、健康检查这些位置手工实现了一遍。读完之后再看 Spring Boot,常见的感觉是「知道它大概做了什么,但不确定哪一段对应哪一段」。
这份附录给一张对照表:左边是 libraryd 里的类或配置,右边是 Spring Boot 里的对应能力。它不是迁移教程,也不重复《Spring Boot》三部曲的内容,只帮你把两套知识接上。
先说明一点:映射不是等价替换。 Spring Boot 在每一层都加入了自动配置、条件装配和代理机制,行为边界比手工实现更宽,也更难从代码直接看出来。对照表的价值在于定位,不在于逐行翻译。
工程结构
libraryd | Spring Boot | 说明 |
|---|---|---|
父 POM + dependencyManagement | spring-boot-starter-parent + BOM | 都由父级统一锁定版本 |
libraryd-domain | 独立 domain 模块或包 | 领域层通常不依赖框架 |
libraryd-persistence | spring-boot-starter-jdbc / -data-jpa | 数据访问能力 |
libraryd-web | spring-boot-starter-web | Web 层 |
libraryd-app | 启动类所在模块 | 装配与入口 |
手写 AppConfig | application.yml + @ConfigurationProperties | 配置绑定 |
手写 AppMain 装配 | @SpringBootApplication + 组件扫描 | 对象创建与注入 |
在 libraryd 里,谁依赖谁写在 pom.xml 里,对象怎么创建写在 AppMain 里。Spring Boot 把后者交给容器:类路径上有什么、哪个 Bean 被条件命中,决定了运行时装配结果。这也是排查 Spring 问题时最需要先确认的事:你以为装配进去的,和实际装配进去的是不是同一个。
数据访问与事务
libraryd | Spring Boot |
|---|---|
HikariDataSource + AppConfig | spring.datasource.* + 自动配置的 DataSource |
JdbcBookRepository | JdbcTemplate / NamedParameterJdbcTemplate |
手写 RowMapper | BeanPropertyRowMapper 或自定义 RowMapper |
JdbcTransactionManager.inTransaction(...) | @Transactional + PlatformTransactionManager |
Connection.setAutoCommit(false) | 由事务管理器代理完成 |
Flyway.configure().dataSource(...) | spring-boot-starter-flyway 自动执行迁移 |
最值得对照的是事务。libraryd 的事务边界是显式的:
public Loan borrow(String isbn, String memberId, LocalDate today) {
return transactions.inTransaction(() -> {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LibraryException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (loans.findActiveByIsbn(isbn).isPresent()) {
throw new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED, isbn);
}
members.upsert(new Member(memberId, memberId));
long id = loans.insert(Loan.open(isbn, memberId, today));
return loans.findById(id).orElseThrow();
});
}
Spring Boot 里通常写成:
@Transactional
public Loan borrow(String isbn, String memberId, LocalDate today) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LibraryException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (loans.findActiveByIsbn(isbn).isPresent()) {
throw new LibraryException(ErrorCode.BOOK_ALREADY_BORROWED, isbn);
}
members.upsert(new Member(memberId, memberId));
long id = loans.insert(Loan.open(isbn, memberId, today));
return loans.findById(id).orElseThrow();
}
两段代码看起来只差一个注解,实际差了四件事:
- 代理边界。
@Transactional只在通过代理调用时生效。同一个类内部this.borrow(...)不会触发事务,这是最常见的「注解没生效」。 - 回滚规则。 默认只对
RuntimeException和Error回滚。LibraryException继承RuntimeException,所以能回滚;如果改成受检异常,需要显式写rollbackFor。 - 传播行为。 默认
REQUIRED,嵌套调用会加入当前事务。手工实现没有这层语义,需要自己决定。 - 连接绑定。 Spring 用
TransactionSynchronizationManager把连接绑到线程;libraryd用ThreadLocal。虚拟线程下两者都要确认绑定方式,不能想当然。
Web 层
libraryd | Spring Boot |
|---|---|
cfg.routes.post("/api/books", handler)(在 Javalin.create 内) | @PostMapping + @RestController |
ctx.pathParam("isbn") | @PathVariable |
ctx.queryParam("page") | @RequestParam |
ctx.bodyAsClass(CreateBookRequest.class) | @RequestBody |
ctx.json(dto) + ctx.status(201) | 返回 ResponseEntity 或 @ResponseStatus |
cfg.routes.before(...) 中间件 | Filter / HandlerInterceptor |
cfg.routes.exception(LibraryException.class, ...) | @ExceptionHandler / @ControllerAdvice |
手写 X-Request-Id | OncePerRequestFilter + MDC |
Javalin 的 before 和 Spring 的 Filter/Interceptor 都能做请求前处理,但执行位置不同:Filter 在 Servlet 容器层,Interceptor 在 Spring MVC 层,@ControllerAdvice 只处理控制器抛出的异常。排查「为什么错误响应没走统一格式」时,先确认异常是在哪一层抛出的。
参数校验
libraryd | Spring Boot |
|---|---|
Validator.validate(request) | @Valid / @Validated |
jakarta.validation 注解 | 同一套注解 |
手动收集 ConstraintViolation | MethodArgumentNotValidException |
在路由里转成 ApiError | @ControllerAdvice 统一处理 |
expressly 提供 EL | Spring Boot 自动配置 |
libraryd 里校验是显式的一步:
CreateBookRequest request = validator.validate(
context.bodyAsClass(CreateBookRequest.class));
RequestValidator 内部调用 validator.validate(value),把 ConstraintViolation 收成 Map<String, String>,再抛 ValidationFailure;异常处理器统一把它写成 E_VALIDATION_FAILED。
Spring Boot 把这一步藏在参数解析里:@Valid @RequestBody 不通过时,请求根本进不了方法体,直接抛 MethodArgumentNotValidException。好处是少写代码,坏处是如果你不知道这个异常,就只能看到默认的 400 响应。
错误契约
libraryd | Spring Boot |
|---|---|
ApiError record | ProblemDetail(Spring 6+) |
ErrorCode.httpStatus() | ResponseEntity.status(...) 或 ErrorResponse |
cfg.routes.exception(...) 统一映射 | @RestControllerAdvice + @ExceptionHandler |
手写 type URL | ProblemDetail.setType(URI) |
手写 errors Map | 自定义属性 setProperty("errors", ...) |
Spring Boot 3 之后内置了 Problem Details 支持,但默认只覆盖框架自身异常。领域异常仍然要自己映射。本书第 8 章的做法可以直接搬过去:保留 ErrorCode 作为唯一状态码来源,在 @ExceptionHandler 里读取它。
日志、指标与追踪
libraryd | Spring Boot |
|---|---|
logback.xml | logback-spring.xml |
MDC.put("requestId", ...) | 同上;可配合 TaskDecorator 传递 |
PrometheusMeterRegistry | micrometer-registry-prometheus + Actuator |
手写 /metrics | /actuator/prometheus |
SdkTracerProvider + OTel SDK | Micrometer Tracing + OTel bridge |
手写 /health | /actuator/health + HealthIndicator |
手写 ScheduledExecutorService | @Scheduled + TaskScheduler |
指标和追踪是差异最大的部分。libraryd 里注册一个计数器需要显式拿到 MeterRegistry:
Counter borrows = registry.counter("libraryd.loans.borrowed");
borrows.increment();
Spring Boot 会自动配置 MeterRegistry,并给 HTTP 请求、数据源、JVM 加上默认指标。你要做的是确认哪些指标真的存在、标签基数是否可控,而不是从零注册。默认指标越多,越需要检查有没有高基数标签把 Prometheus 拖垮。
缓存
libraryd | Spring Boot |
|---|---|
Caffeine.newBuilder() | spring-boot-starter-cache + Caffeine |
手写 get(key, loader) | @Cacheable |
手写 invalidate(key) | @CacheEvict |
Jedis 直接操作 Redis | spring-boot-starter-data-redis |
| 手写序列化 | RedisTemplate / StringRedisTemplate |
@Cacheable 的坑在键和失效:默认用方法参数生成键,参数是对象时依赖 equals/hashCode;更新方法忘记 @CacheEvict 时,缓存会一直返回旧值。本书第 14 章手写的 Cache-Aside 逻辑,正是为了让你看清这两步分别在做什么。
测试与打包
libraryd | Spring Boot |
|---|---|
| JUnit 5 + Testcontainers | spring-boot-starter-test + Testcontainers |
@Testcontainers + PostgreSQLContainer | @ServiceConnection 自动注入连接信息 |
Javalin app.start(0) | @SpringBootTest(webEnvironment = RANDOM_PORT) |
HttpClient 发真实请求 | TestRestTemplate / WebTestClient |
| Shade 生成 fat jar | spring-boot-maven-plugin 重新打包 |
| 多阶段 Dockerfile / Jib | Buildpacks 或 Jib |
Spring Boot 的 @SpringBootTest 会启动完整上下文,测试更接近生产,但更慢。本书第 15 章的分层策略可以照搬:领域规则用纯单元测试,仓储用 Testcontainers,HTTP 契约用轻量端到端测试,不要把所有测试都写成 @SpringBootTest。
从本书迁移到 Spring Boot 的顺序
如果你已经有一个 libraryd,想换成 Spring Boot,建议按这个顺序,不要一次性重写:
- 先换装配。 保留 domain 和 persistence 的实现,用
@Configuration把现有对象注册成 Bean。此时 Web 层不动,服务能启动。 - 再换 Web 层。 把 Javalin 路由逐个改成
@RestController,保持 URL、状态码和 DTO 不变。用第 15 章的端到端测试做回归。 - 再接事务。 把
TransactionManager.inTransaction(...)换成@Transactional,逐个方法确认传播行为和回滚规则。 - 再换可观测性。 日志配置改成
logback-spring.xml,指标切到 Actuator,追踪接 Micrometer Tracing。 - 最后换构建与部署。 用
spring-boot-maven-plugin替换 Shade,镜像构建方式按团队规范选择。
每换一层都跑一遍集成测试。不要同时改 Web 框架和事务模型,否则出问题时无法判断是哪一层引起的。
五个最容易踩错的差异
@Transactional自调用不生效。 同类内部调用不走代理,事务注解被忽略。- 默认只回滚运行时异常。 受检异常需要
rollbackFor。 @Valid只校验进入方法体的参数。 路径变量、查询参数需要@Validated在类上配合。- Actuator 端点默认只暴露
health。 指标端点要显式management.endpoints.web.exposure.include。 - 自动配置是条件装配。 类路径上多一个依赖,可能换掉默认实现;排查时先看
/actuator/conditions或启动日志里的条件报告。
什么时候不该用 Spring Boot
这本书不主张「所有项目都该手写」。以下场景,Spring Boot 通常更合适:
- 团队已经在用 Spring 生态,运维、监控、安全规范都围绕它建立。
- 需要快速接入认证授权、消息、批处理、定时任务等成熟组件。
- 项目周期紧,愿意用约定换开发速度。
反过来说,如果你在做教学项目、嵌入式服务、启动时间和包体积敏感的工具,或者需要精确控制每一个装配步骤,本书的手工方式更直接。选型看约束,不看阵营。
小结
libraryd与 Spring Boot 的差别主要在装配方式和代理边界,不在业务代码。- 事务、校验、错误映射、指标都能一一对应,但每一层都多了条件装配和隐式行为。
- 迁移时按「装配 → Web → 事务 → 可观测性 → 构建」分层推进,每层用集成测试兜底。
- 最常见的五个坑:自调用事务、回滚规则、校验位置、端点暴露、条件装配。
- 框架替你做的事越多,排查时越要先确认「实际装配了什么」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。