本节目标:说清实体为什么不能直接进接口层,掌握接口投影与 DTO 投影的选型、转换代码该放哪一层,以及 readOnly 事务与转换时机的配合。
适用版本:Spring Boot 4.1.x(Java 21)
6.1 Repository 分层与 DTO 转换
入门卷第 5 章用「图书借阅管理服务」把 Book、Member、Loan 三个实体的增删改查跑通了,Controller 直接返回实体也能拿到 JSON。本节要回答的是:上了生产,实体还能不能继续出现在接口层? 答案是不能。下面先把「为什么不能」拆成四类会真实发生的事故,再给出投影、转换、事务边界三条落地规则。
6.1.1 实体直接返回会出四类事故
不是「不优雅」,是会挂、会泄露。逐条看。
事故一:懒加载代理在序列化时炸掉。 Book.loans 声明为 @OneToMany(fetch = FetchType.LAZY),它是一段没有初始化的代理。Jackson 序列化 Book 时会遍历所有 getter,包括 getLoans(),从而触发代理初始化。如果此刻 Hibernate Session 已经关闭(比如 Service 方法没有事务、或转换发生在异步线程里),就抛 LazyInitializationException。如果 Session 还开着(Open Session In View 打开),它不会报错,而是把整张 loan 表拉进来——一次列表查询变成全表扫描。
事故二:字段泄露。 实体是持久化模型,字段跟着表结构走。Member 上迟早会有 passwordHash、statusReason、internalNote 这类字段。它们一旦随实体序列化出去,就变成了对外 API 的一部分。指望靠 @JsonIgnore 逐个打补丁,是维护不动的。
事故三:循环引用。 Loan.book 指向 Book,Book.loans 又指回 Loan。Jackson 序列化双向关联会直接 StackOverflowError。给一端加 @JsonIgnore 能压住,但序列化行为就变得依赖注解的分布,非常脆弱。
事故四:数据库契约与接口契约被绑死。 实体加一个字段、改一次关联方向,API 响应就跟着变,前端被动升级。数据库的演进节奏和 API 的演进节奏本就不该同步。
四类事故指向同一个结论:实体只在 Repository 与 Service 之间流动,出 Service 之前一律转成 DTO。 这条边界不是风格偏好,是隔离风险的必要手段。
6.1.2 投影:只要几列就别查整行
上一节说实体不能出 Service,但很多查询根本不需要实体——列表页只要标题和 ISBN。这时用**投影(projection)**直接查需要的列,既省带宽也省内存。
Spring Data JPA 提供两种投影,先看接口投影:
public interface BookRepository extends JpaRepository<Book, Long> {
// 接口投影:Spring Data 为它生成运行时代理,只 select 这几个属性
interface BookSummary {
Long getId();
String getTitle();
String getIsbn();
}
List<BookSummary> findByCategory(String category);
List<BookSummary> findByAvailableCopiesGreaterThan(int threshold);
}
再看 DTO 投影(构造函数表达式),它需要先有一个 DTO 类型:
// 放在 book-loan-api 模块,作为对外契约的一部分
public record BookDto(Long id, String title, String author) {
}
public interface BookRepository extends JpaRepository<Book, Long> {
@Query("""
select new com.example.bookloan.api.BookDto(b.id, b.title, b.author)
from Book b
where b.availableCopies > 0
order by b.title
""")
List<BookDto> findAvailableBooks();
}
两者对比:
| 维度 | 接口投影 | DTO 投影 |
|---|---|---|
| 声明成本 | 低,只写接口 | 要先定义 DTO 类/record |
| 类型安全 | 属性名写错编译期不报,运行期才炸 | 构造函数参数编译期校验 |
| 可否跨模块 | 投影接口要在 Repository 所在模块 | DTO 可在共享 api 模块 |
| 嵌套/聚合 | 受限 | 可用 count()、group by 等表达式 |
| 序列化 | 由 Spring 生成代理,JSON 正常 | record 直接序列化 |
选型规则很直接:列表、下拉、统计这类「只读几列」的查询用投影;需要参与业务计算、要被别的方法复用的,用 DTO 投影。 接口投影胜在快,但它的属性名与实体字段名靠字符串约定,重构时容易漏改,所以更适合稳定的简单场景。
6.1.3 转换代码放哪:手写还是 MapStruct
当查询确实需要完整实体(要参与业务判断),就得在 Service 里把它转成 DTO。转换代码的写法有两派。
手写转换。 一个 @Component 里的普通方法:
@Component
public class LoanMapper {
public LoanDto toDto(Loan loan) {
return new LoanDto(
loan.getId(),
loan.getBook().getTitle(),
loan.getMember().getName(),
loan.getBorrowedAt(),
loan.getDueAt(),
loan.getStatus().name());
}
}
MapStruct 生成。 声明一个接口,编译期生成实现:
@Mapper(componentModel = "spring")
public interface LoanMapper {
@Mapping(target = "bookTitle", source = "book.title")
@Mapping(target = "memberName", source = "member.name")
@Mapping(target = "status", source = "status")
LoanDto toDto(Loan loan);
}
MapStruct 1.5 起支持 record 作为目标类型,与上面的 DTO 定义兼容。生成实现是编译期的,没有运行期反射开销。
两者取舍:
| 维度 | 手写 | MapStruct |
|---|---|---|
| 依赖 | 无 | 需引入 MapStruct + 注解处理器 |
| 字段多时的成本 | 线性增长,容易漏字段 | 一行 @Mapping 一个字段,漏了有编译告警 |
| 嵌套对象 | 手写清晰 | 需 @Mapping(source = "a.b") |
| 调试 | 直接可读 | 要看生成代码 |
| 构建影响 | 无 | 多一个注解处理阶段,增量编译变慢 |
经验判断:字段少于 6 个、嵌套一两层,手写更好维护;DTO 字段多、映射规则重复(十几处都在转 Loan),上 MapStruct 才划算。 不要为了「避免手写」引入注解处理器——它会让每次增量构建多一个 APT 阶段,字段少时收益为负。
6.1.4 Repository 与 Service 的职责边界
有了投影和转换,接下来划清 Repository 与 Service 的分工。边界模糊时最常见的两种写法都要避免。
| 该由 Repository 负责 | 该由 Service 负责 |
|---|---|
| 数据访问:查询、保存、删除 | 业务规则:借阅上限、逾期判定 |
| 查询条件拼装(含 Specification) | 事务边界与回滚策略 |
| 投影/聚合查询的定义 | 调用多个 Repository 组合 |
| 分页与排序的透传 | 实体到 DTO 的转换 |
| 批量写入 | 领域事件发布、缓存失效 |
两条红线:
红线一:Repository 里不写业务判断。 像「一个会员最多借 5 本」这种规则,不要塞进 @Query 的 where 里靠 SQL 表达式表达——它散在 SQL 里就无法单测、无法复用。规则写在 Service,Repository 只提供「查该会员当前未还数量」这个原语。
红线二:Service 不直接暴露 Page<Book> 这种实体容器。 Page<Book> 一旦返回给 Controller,就等于把实体带出了 Service 边界,前面四类事故原样重现。要么在 Service 里转成 Page<BookDto>(用 page.map(mapper::toDto)),要么直接查投影。
@Service
public class LoanQueryService {
private final LoanRepository loanRepository;
private final LoanMapper loanMapper;
public LoanQueryService(LoanRepository loanRepository, LoanMapper loanMapper) {
this.loanRepository = loanRepository;
this.loanMapper = loanMapper;
}
@Transactional(readOnly = true)
public Page<LoanDto> listByMember(Long memberId, Pageable pageable) {
return loanRepository.findByMemberId(memberId, pageable)
.map(loanMapper::toDto);
}
}
Page.map(...) 会保留分页元数据,只把元素类型换掉,是转换 Page 的标准做法。
6.1.5 readOnly 事务与转换时机
这里有一个容易踩的坑:转换必须在事务内完成。
原因在于 loanMapper.toDto(loan) 里访问了 loan.getBook().getTitle() 和 loan.getMember().getName(),而 book、member 都是懒加载关联。如果这个访问发生在事务提交之后,Session 已关闭,直接抛 LazyInitializationException。
看下面这段「看起来没问题」的代码:
// 错误:事务在 repository 方法返回时结束,转换在事务外
public LoanDto getLoan(Long id) {
Loan loan = loanRepository.findById(id).orElseThrow(); // 事务在此处已提交
return loanMapper.toDto(loan); // 访问懒加载 -> 抛异常
}
正确做法是把查询与转换放进同一个 @Transactional(readOnly = true) 方法:
@Transactional(readOnly = true)
public LoanDto getLoan(Long id) {
Loan loan = loanRepository.findById(id).orElseThrow();
return loanMapper.toDto(loan); // 仍在事务内,代理可初始化
}
readOnly = true 在这里不只是「声明语义」,它有实际收益:Spring 会把 Hibernate 的 FlushMode 设为 MANUAL,跳过脏检查(dirty checking),并且让 JDBC 连接带上只读提示。对读多写少的查询服务,这个标志能省掉每次查询后的一次全量脏检查。
但要注意它的局限:readOnly 只影响 FlushMode,不会阻止懒加载。也就是说它不会帮你「安全地」在事务外转换——转换仍然必须在事务内。想彻底避免转换期触发懒加载,办法是查询时就用投影或 join fetch 一次把需要的列/关联取回(6.3 会专门讲抓取策略)。
还有一个 4.1 的新选项值得知道:spring.datasource.connection-fetch=lazy。开启后,自动配置的 DataSource 会被包一层 LazyConnectionDataSourceProxy,物理连接直到真正执行第一条 SQL 时才从连接池取出。对「进入事务但只做只读校验、未必发 SQL」的方法,它能少占一次池连接。
spring:
datasource:
connection-fetch: lazy
6.1.6 常见坑
坑一:用 @Transactional 加在 Controller 上强行解决懒加载异常。 这会把事务边界拉长到 Web 层,等于打开 Open Session In View 的变体,既扩大连接占用,又让转换悄悄触发全表加载。正确做法是把转换收回 Service 的事务内。
坑二:DTO 里塞实体引用。 比如 LoanDto 里放一个 Book book 字段,看似转了一半,实际上实体又漏出去了,懒加载和序列化问题一个不少。DTO 里只放值类型(id、字符串、时间、枚举名)。
坑三:接口投影的属性名与实体字段名不一致。 接口投影靠「方法名 → 属性路径」推导,写错不会编译失败,运行期才报「找不到属性」。改实体字段名时,务必全局搜索对应的投影接口。
坑四:MapStruct 生成的映射在懒加载关联上失效。 @Mapping(source = "book.title") 在事务外执行会触发同样的 LazyInitializationException。MapStruct 不改变时机问题,只是把访问点挪进了生成代码,排查时更隐蔽。
坑五:把 Page<Book> 直接返回。 前面反复强调过,这里再列一次,因为它是最常见的一次性错误——单测里查出来是对的,一到接口层就变成全表加载或序列化异常。
小结
- 实体直接进接口层会出四类事故:懒加载代理序列化失败、字段泄露、双向关联循环引用、数据库契约绑死 API 契约。
- 只读几列的查询用投影:接口投影声明成本低但属性名靠约定,DTO 投影编译期安全且可跨模块复用。
- 转换代码手写适合字段少、嵌套浅;字段多、映射重复时 MapStruct 才划算,代价是构建多一个 APT 阶段。
- Repository 只管数据访问与查询定义,业务规则、事务边界、DTO 转换都在 Service;
Page<Book>不能出 Service。 - 转换必须在
@Transactional(readOnly = true)事务内完成,因为 DTO 转换会访问懒加载关联;readOnly省脏检查但不阻止懒加载。 - 4.1 可用
spring.datasource.connection-fetch=lazy让物理连接延迟到第一条 SQL 才取出。
查询层有了清晰的分层与转换约定,下一步是面对真正复杂的查询。6.2 会讲派生查询、@Query 与 Criteria/Querydsl 三种写法各自该在什么场景用。
阅读导航:上一节:5.3 幂等与并发控制 · 下一节:6.2 复杂查询的三种写法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。