《Spring Boot 入门》18.2 实现

按 18.1 的设计逐层实现图书借阅服务:实体与 Repository(派生查询与 @Query)、Service 事务边界与业务校验、Controller 参数校验与统一响应、全局异常处理,并给出多环境配置组织、Flyway 迁移脚本、实现顺序与每步验证方法,以及三类常见实现偏差。

本节目标:照着 18.1 的设计把图书借阅服务写出来,掌握按层落地的顺序、各层核心类的完整代码、事务边界的划法,以及实现中最容易走样的三类偏差。
适用版本:Spring Boot 4.1.x(Java 21)

18.2 实现

设计已经写死,本节把它翻译成能编译、能运行的代码。顺序很关键——从里往外(实体 → Repository → Service → Controller),每完成一层都能单独验证,而不是先写 Controller 再一路往回填。包结构直接对应 18.1 的分层:web 放控制器与全局异常处理,service 放业务,repository 放数据访问,domain 放实体,dto 放接口契约。

pom.xml 里引入 spring-boot-starter-webmvc(MVC + 内嵌 Tomcat,4.x 新名,旧名 -web 已废弃)、spring-boot-starter-validation(第 9 章)、spring-boot-starter-data-jpa(Hibernate 7.2,第 12 章)、spring-boot-starter-flyway(4.x 必须独立引入,只加 flyway-core 不再触发自动配置)与 spring-boot-starter-test(第 17 章)。

18.2.1 实体与 Repository

实体只映射表结构,把「库存加减」这类状态变化留在实体自己身上,业务规则仍在 Service:

package com.example.library.domain;

import jakarta.persistence.*;

@Entity
@Table(name = "book")
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false, unique = true, length = 20)
    private String isbn;
    @Column(nullable = false, length = 200)
    private String title;
    @Column(nullable = false, length = 120)
    private String author;
    @Column(nullable = false, length = 60)
    private String category;
    @Column(name = "total_copies", nullable = false)
    private int totalCopies;
    @Column(name = "available_copies", nullable = false)
    private int availableCopies;

    protected Book() {
    }

    public Book(String isbn, String title, String author, String category, int totalCopies) {
        this.isbn = isbn;
        this.title = title;
        this.author = author;
        this.category = category;
        this.totalCopies = totalCopies;
        this.availableCopies = totalCopies;
    }

    public void borrowOne() {
        if (availableCopies <= 0) {
            throw new IllegalStateException("no available copy");
        }
        availableCopies--;
    }
    // returnOne() 与它对称;getter / setter 略(isbn、title、author、category、totalCopies、availableCopies)
}

borrowOne 里的 IllegalStateException 是防御性的——真正的业务判断在 Service,实体方法只兜住「不该发生的状态」,避免出现负库存。

Repository 用派生查询(第 12 章)加一条 @Query(第 13 章)覆盖模糊搜索:

package com.example.library.repository;

import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import com.example.library.domain.Book;

public interface BookRepository extends JpaRepository<Book, Long> {

    boolean existsByIsbn(String isbn);

    @Query("""
            select b from Book b
            where (:keyword is null
                   or lower(b.title) like lower(concat('%', :keyword, '%'))
                   or lower(b.author) like lower(concat('%', :keyword, '%')))
            """)
    Page<Book> search(@Param("keyword") String keyword, Pageable pageable);
}

search 用一条 JPQL 同时覆盖「关键字为空返回全部」与「按书名或作者模糊匹配」,省去 Service 里的分支。三引号文本块是 Java 15+ 语法,比字符串拼接可读得多。

18.2.2 DTO 与校验

校验注解(第 9 章)贴在入参 DTO 上,不贴实体——实体是持久化模型,不该承载 HTTP 语义。用 record 承载 DTO,不可变、无样板;出参只暴露该暴露的字段:

package com.example.library.dto;

import jakarta.validation.constraints.*;

public record BookCreateRequest(
        @NotBlank @Size(max = 20) String isbn,
        @NotBlank @Size(max = 200) String title,
        @NotBlank @Size(max = 120) String author,
        @NotBlank @Size(max = 60) String category,
        @Min(1) int totalCopies) {
}

出参 BookResponse 用 record 只暴露 id、isbn、title、author、category、totalCopies、availableCopies 七个字段,并提供静态工厂 from(Book) 把实体映射成 DTO,让「实体 → DTO」的转换收敛在一处。统一响应外壳 ApiResponse<T> 只有 code、message、data 三个字段,配 ok(data) 与 error(code, msg) 两个静态工厂;PageResponse<T> 同理,含 items、page、size、totalElements 四项,并提供 from(page, mapper) 把 Spring Data 的 Page 转成稳定形状。

18.2.3 Service 层与事务边界

Service 是业务规则与事务边界的所在地。借书是最关键的一处:它同时改动 loan 与 book 两张表,必须原子。

package com.example.library.service;

import java.time.LocalDate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.library.domain.*;
import com.example.library.dto.LoanCreateRequest;
import com.example.library.repository.*;

@Service
public class LoanService {

    private final BookRepository bookRepository;
    private final LoanRepository loanRepository;
    private final MemberRepository memberRepository;

    public LoanService(BookRepository bookRepository, LoanRepository loanRepository,
                       MemberRepository memberRepository) {
        this.bookRepository = bookRepository;
        this.loanRepository = loanRepository;
        this.memberRepository = memberRepository;
    }

    @Transactional
    public Loan borrow(LoanCreateRequest request) {
        Book book = bookRepository.findById(request.bookId())
                .orElseThrow(() -> new BookNotFoundException(request.bookId()));
        Member member = memberRepository.findById(request.memberId())
                .orElseThrow(() -> new MemberNotFoundException(request.memberId()));

        if (!member.isActive()) {
            throw new BusinessException(40905, "会员已被停用");
        }
        if (loanRepository.existsByMemberIdAndBookIdAndStatus(
                member.getId(), book.getId(), Loan.Status.BORROWED)) {
            throw new BusinessException(40902, "该会员已借阅此书且未归还");
        }

        book.borrowOne();
        Loan loan = new Loan(book, member, LocalDate.now(), LocalDate.now().plusDays(30));
        return loanRepository.save(loan);
    }
}

returnBook(Long loanId) 与它对称:查出记录、校验未归还、markReturned() 回填归还时间,再调 book.returnOne() 把库存加一,同样标 @Transactional。

两处设计值得展开。其一,写方法都标 @Transactional:借书要「校验库存 → 扣库存 → 写记录」三步全成功或全失败,任一步抛异常都整体回滚;默认传播行为 REQUIRED(有事务则加入、否则新建),传播与隔离的完整讨论见 14.2。其二,库存扣减走 book.borrowOne() 而非先查后改再 save——findById 拿到的是受管实体,修改它会由 Hibernate 脏检查自动同步,无需显式保存。

BookService 结构相同:create 校验 ISBN 唯一后 save,delete 先查有无在借记录再删,两者都标 @Transactional;而 search 是只读查询,不加 @Transactional,交给数据库即可。注意 delete 的「先查在借、再删」必须在同一事务内,否则校验与删除之间存在竞态窗口。

18.2.4 Controller 层与统一响应

Controller 只做三件事:绑定参数、调 Service、包装响应。它不认识 Repository,也不写业务规则:

package com.example.library.web;

import org.springframework.data.domain.Page;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import com.example.library.domain.Book;
import com.example.library.dto.*;
import com.example.library.service.BookService;
import jakarta.validation.Valid;

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @PostMapping
    public ResponseEntity<ApiResponse<BookResponse>> create(
            @Valid @RequestBody BookCreateRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(ApiResponse.ok(bookService.create(request)));
    }

    @GetMapping
    public ApiResponse<PageResponse<BookResponse>> search(
            @RequestParam(required = false) String keyword,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        Page<Book> result = bookService.search(keyword, page, size);
        return ApiResponse.ok(PageResponse.from(result, BookResponse::from));
    }
}

@Valid 触发第 9 章的校验,失败抛 MethodArgumentNotValidException,由全局处理器转成 400——Controller 里看不到一行 try/catch。update 与 delete 两个方法结构相同,此处从略。

18.2.5 全局异常处理

第 10 章的收口:一个 @RestControllerAdvice 把业务异常与框架异常都映射成 ApiResponse:

package com.example.library.web.advice;

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import com.example.library.dto.ApiResponse;
import com.example.library.service.exception.*;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNotFound(BookNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiResponse.error(40401, ex.getMessage()));
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(ApiResponse.error(ex.getCode(), ex.getMessage()));
    }
}

再补 handleValidation(MethodArgumentNotValidException) 把校验失败转成业务码 40001,以及 @ExceptionHandler(Exception.class) 兜底——对外只回「服务器内部错误」(业务码 50000)、对内记日志。BusinessException 携带业务码,是 Service 向 Controller 层传递「业务失败」的唯一手段:Service 不返回 ResponseEntity,只抛异常,由 advice 翻译成 HTTP。

18.2.6 配置文件的组织

第 6 章的 profile 在这里落地。公共配置放 application.yml,环境差异放各自的 profile 文件:

# application.yml —— 公共部分
spring:
  application:
    name: library-service
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
  flyway:
    enabled: true

application-dev.yml 只覆盖 datasource(指向本地库 library_dev)并打开 show-sql;application-prod.yml 把用户名密码换成环境变量 ${DB_URL} / ${DB_USER} / ${DB_PASSWORD} 并关掉 show-sql。三条纪律:生产绝不写死密码(第 6 章外部化配置);ddl-auto 一律 validate,让 Flyway 管表结构;open-in-view: false 关掉 OSIV,逼自己在 Service 里把数据取全。

18.2.7 Flyway 迁移脚本

表结构来自 18.1.5,落成版本化脚本(第 15 章)。V1__create_schema.sql 建三张表(member、loan 如下),V2__create_indexes.sql 建组合索引:

-- V1__create_schema.sql(member 与 loan 部分)
CREATE TABLE member (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(80) NOT NULL, email VARCHAR(160) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    CONSTRAINT uk_member_email UNIQUE (email)
);
CREATE TABLE loan (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    book_id BIGINT NOT NULL REFERENCES book (id),
    member_id BIGINT NOT NULL REFERENCES member (id),
    borrowed_at DATE NOT NULL, due_at DATE NOT NULL, returned_at DATE,
    status VARCHAR(20) NOT NULL DEFAULT 'BORROWED'
);
-- V2__create_indexes.sql
CREATE INDEX idx_loan_member_status ON loan (member_id, status);
CREATE INDEX idx_loan_book_status   ON loan (book_id, status);

迁移脚本一旦提交就不再修改。 需要调整表结构时新增 V3__xxx.sql,而不是回头改 V1——Flyway 用校验和校验历史脚本,改历史脚本会导致启动直接失败。

18.2.8 实现顺序与每步验证

从里往外做,每层都能独立验证,问题不会攒到最后一起爆:

步骤做什么怎么验证
1建工程、加依赖、跑通启动类日志出现 Started LibraryApplication
2写实体 + Repository@DataJpaTest 存一本书再读回
3写 DTO + 校验注解单元测试直接构造非法 DTO 校验
4写 Flyway 脚本启动后查 flyway_schema_history 表
5写 Service + 事务@SpringBootTest 调 borrow,断言行数变化
6写 Controller + 统一响应MockMvc 打 /api/books,断言 201
7写全局异常处理MockMvc 打不存在的 id,断言 404 与业务码
8补配置 profile用 --spring.profiles.active=prod 启动验证

第 2、5、6 步的测试写法分别对应第 17 章的切片测试与集成测试。

18.2.9 常见实现偏差

实现最容易走样的地方就三处,几乎每个初学者都会踩。

偏差一:把业务逻辑写进 Controller。 典型症状是 Controller 里出现 if (book.getAvailableCopies() == 0)。后果是这段规则无法被 Service 层测试覆盖,别的入口复用时只能复制粘贴。判据很简单——Controller 里出现任何处理业务的 if 分支,就该往 Service 搬。

偏差二:DTO 与实体混用。 直接 return book; 让实体序列化出去,短期省事,长期导致:数据库加一个内部字段就泄露到接口、懒加载关联在序列化时触发 LazyInitializationException、改表就得改接口。接口的输入输出必须是 DTO,实体只在 Service 与 Repository 之间流动。

偏差三:事务边界划错。 三种典型错法:写操作漏标 @Transactional(部分成功无法回滚);把 @Transactional 加在 Controller 上(范围过大,把 HTTP 处理也包进来);在同一个类里自调用带事务的方法(this.borrow() 绕过代理,事务不生效——这正是 14.3 讲过的代理陷阱)。

小结

  • 实现从里往外:实体 → Repository → DTO → Service → Controller → 异常处理 → 配置,每层单独验证。
  • 实体只映射表、承载状态变化(borrowOne),业务规则与事务在 Service。
  • Repository 用派生查询加一条 @Query 覆盖模糊搜索;DTO 用 record 承载并做校验,实体绝不直接出 Service。
  • @Transactional 只加在写操作上;借还改多张表必须原子,读方法不开事务;传播与隔离见 14.2。
  • Controller 只做绑定、调用、包装;所有异常由 @RestControllerAdvice 统一转成 ApiResponse。
  • 配置按 dev / test / prod 分文件,ddl-auto=validate、open-in-view=false、生产密码走环境变量。
  • 三类高频偏差:业务逻辑进 Controller、DTO 与实体混用、事务边界划错。

代码写完还不算完——它得能被构建、能被打包、能被别人拉起来跑通。18.3 把这份实现真正运行起来。

阅读导航:上一节:18.1 需求与设计 · 下一节:18.3 打包与运行 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.1 需求与设计
  3. 《Spring Boot 入门》17.3 集成测试