《Spring Boot 实战》6.1 Repository 分层与 DTO 转换

实体直接出现在接口层会带来懒加载代理序列化失败、字段泄露与循环引用三类事故。本节给出投影(interface / DTO)的用法与边界、手写转换与 MapStruct 的取舍、Repository 与 Service 的职责划分,以及 readOnly 事务与转换时机的关系。

本节目标:说清实体为什么不能直接进接口层,掌握接口投影与 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 复杂查询的三种写法 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计