《Java 编程实战》附录 D 与 Spring Boot 卷的衔接

把 libraryd 的手工装配逐项映射到 Spring Boot 的对应能力:模块与依赖、数据访问、事务、Web 路由、参数校验、错误契约、日志、指标、缓存、测试与打包,并给出从本书代码迁移到 Spring Boot 的顺序与最容易踩错的五个差异点。

这份附录解决什么问题

本书刻意不用 Spring,把事务、校验、错误映射、健康检查这些位置手工实现了一遍。读完之后再看 Spring Boot,常见的感觉是「知道它大概做了什么,但不确定哪一段对应哪一段」。

这份附录给一张对照表:左边是 libraryd 里的类或配置,右边是 Spring Boot 里的对应能力。它不是迁移教程,也不重复《Spring Boot》三部曲的内容,只帮你把两套知识接上。

先说明一点:映射不是等价替换。 Spring Boot 在每一层都加入了自动配置、条件装配和代理机制,行为边界比手工实现更宽,也更难从代码直接看出来。对照表的价值在于定位,不在于逐行翻译。

工程结构

librarydSpring Boot说明
父 POM + dependencyManagementspring-boot-starter-parent + BOM都由父级统一锁定版本
libraryd-domain独立 domain 模块或包领域层通常不依赖框架
libraryd-persistencespring-boot-starter-jdbc / -data-jpa数据访问能力
libraryd-webspring-boot-starter-webWeb 层
libraryd-app启动类所在模块装配与入口
手写 AppConfigapplication.yml + @ConfigurationProperties配置绑定
手写 AppMain 装配@SpringBootApplication + 组件扫描对象创建与注入

在 libraryd 里,谁依赖谁写在 pom.xml 里,对象怎么创建写在 AppMain 里。Spring Boot 把后者交给容器:类路径上有什么、哪个 Bean 被条件命中,决定了运行时装配结果。这也是排查 Spring 问题时最需要先确认的事:你以为装配进去的,和实际装配进去的是不是同一个。

数据访问与事务

librarydSpring Boot
HikariDataSource + AppConfigspring.datasource.* + 自动配置的 DataSource
JdbcBookRepositoryJdbcTemplate / NamedParameterJdbcTemplate
手写 RowMapperBeanPropertyRowMapper 或自定义 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();
}

两段代码看起来只差一个注解,实际差了四件事:

  1. 代理边界。 @Transactional 只在通过代理调用时生效。同一个类内部 this.borrow(...) 不会触发事务,这是最常见的「注解没生效」。
  2. 回滚规则。 默认只对 RuntimeException 和 Error 回滚。LibraryException 继承 RuntimeException,所以能回滚;如果改成受检异常,需要显式写 rollbackFor。
  3. 传播行为。 默认 REQUIRED,嵌套调用会加入当前事务。手工实现没有这层语义,需要自己决定。
  4. 连接绑定。 Spring 用 TransactionSynchronizationManager 把连接绑到线程;libraryd 用 ThreadLocal。虚拟线程下两者都要确认绑定方式,不能想当然。

Web 层

librarydSpring 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-IdOncePerRequestFilter + MDC

Javalin 的 before 和 Spring 的 Filter/Interceptor 都能做请求前处理,但执行位置不同:Filter 在 Servlet 容器层,Interceptor 在 Spring MVC 层,@ControllerAdvice 只处理控制器抛出的异常。排查「为什么错误响应没走统一格式」时,先确认异常是在哪一层抛出的。

参数校验

librarydSpring Boot
Validator.validate(request)@Valid / @Validated
jakarta.validation 注解同一套注解
手动收集 ConstraintViolationMethodArgumentNotValidException
在路由里转成 ApiError@ControllerAdvice 统一处理
expressly 提供 ELSpring 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 响应。

错误契约

librarydSpring Boot
ApiError recordProblemDetail(Spring 6+)
ErrorCode.httpStatus()ResponseEntity.status(...) 或 ErrorResponse
cfg.routes.exception(...) 统一映射@RestControllerAdvice + @ExceptionHandler
手写 type URLProblemDetail.setType(URI)
手写 errors Map自定义属性 setProperty("errors", ...)

Spring Boot 3 之后内置了 Problem Details 支持,但默认只覆盖框架自身异常。领域异常仍然要自己映射。本书第 8 章的做法可以直接搬过去:保留 ErrorCode 作为唯一状态码来源,在 @ExceptionHandler 里读取它。

日志、指标与追踪

librarydSpring Boot
logback.xmllogback-spring.xml
MDC.put("requestId", ...)同上;可配合 TaskDecorator 传递
PrometheusMeterRegistrymicrometer-registry-prometheus + Actuator
手写 /metrics/actuator/prometheus
SdkTracerProvider + OTel SDKMicrometer 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 拖垮。

缓存

librarydSpring Boot
Caffeine.newBuilder()spring-boot-starter-cache + Caffeine
手写 get(key, loader)@Cacheable
手写 invalidate(key)@CacheEvict
Jedis 直接操作 Redisspring-boot-starter-data-redis
手写序列化RedisTemplate / StringRedisTemplate

@Cacheable 的坑在键和失效:默认用方法参数生成键,参数是对象时依赖 equals/hashCode;更新方法忘记 @CacheEvict 时,缓存会一直返回旧值。本书第 14 章手写的 Cache-Aside 逻辑,正是为了让你看清这两步分别在做什么。

测试与打包

librarydSpring Boot
JUnit 5 + Testcontainersspring-boot-starter-test + Testcontainers
@Testcontainers + PostgreSQLContainer@ServiceConnection 自动注入连接信息
Javalin app.start(0)@SpringBootTest(webEnvironment = RANDOM_PORT)
HttpClient 发真实请求TestRestTemplate / WebTestClient
Shade 生成 fat jarspring-boot-maven-plugin 重新打包
多阶段 Dockerfile / JibBuildpacks 或 Jib

Spring Boot 的 @SpringBootTest 会启动完整上下文,测试更接近生产,但更慢。本书第 15 章的分层策略可以照搬:领域规则用纯单元测试,仓储用 Testcontainers,HTTP 契约用轻量端到端测试,不要把所有测试都写成 @SpringBootTest。

从本书迁移到 Spring Boot 的顺序

如果你已经有一个 libraryd,想换成 Spring Boot,建议按这个顺序,不要一次性重写:

  1. 先换装配。 保留 domain 和 persistence 的实现,用 @Configuration 把现有对象注册成 Bean。此时 Web 层不动,服务能启动。
  2. 再换 Web 层。 把 Javalin 路由逐个改成 @RestController,保持 URL、状态码和 DTO 不变。用第 15 章的端到端测试做回归。
  3. 再接事务。 把 TransactionManager.inTransaction(...) 换成 @Transactional,逐个方法确认传播行为和回滚规则。
  4. 再换可观测性。 日志配置改成 logback-spring.xml,指标切到 Actuator,追踪接 Micrometer Tracing。
  5. 最后换构建与部署。 用 spring-boot-maven-plugin 替换 Shade,镜像构建方式按团队规范选择。

每换一层都跑一遍集成测试。不要同时改 Web 框架和事务模型,否则出问题时无法判断是哪一层引起的。

五个最容易踩错的差异

  1. @Transactional 自调用不生效。 同类内部调用不走代理,事务注解被忽略。
  2. 默认只回滚运行时异常。 受检异常需要 rollbackFor。
  3. @Valid 只校验进入方法体的参数。 路径变量、查询参数需要 @Validated 在类上配合。
  4. Actuator 端点默认只暴露 health。 指标端点要显式 management.endpoints.web.exposure.include。
  5. 自动配置是条件装配。 类路径上多一个依赖,可能换掉默认实现;排查时先看 /actuator/conditions 或启动日志里的条件报告。

什么时候不该用 Spring Boot

这本书不主张「所有项目都该手写」。以下场景,Spring Boot 通常更合适:

  • 团队已经在用 Spring 生态,运维、监控、安全规范都围绕它建立。
  • 需要快速接入认证授权、消息、批处理、定时任务等成熟组件。
  • 项目周期紧,愿意用约定换开发速度。

反过来说,如果你在做教学项目、嵌入式服务、启动时间和包体积敏感的工具,或者需要精确控制每一个装配步骤,本书的手工方式更直接。选型看约束,不看阵营。

小结

  • libraryd 与 Spring Boot 的差别主要在装配方式和代理边界,不在业务代码。
  • 事务、校验、错误映射、指标都能一一对应,但每一层都多了条件装配和隐式行为。
  • 迁移时按「装配 → Web → 事务 → 可观测性 → 构建」分层推进,每层用集成测试兜底。
  • 最常见的五个坑:自调用事务、回滚规则、校验位置、端点暴露、条件装配。
  • 框架替你做的事越多,排查时越要先确认「实际装配了什么」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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