本节目标:照着 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 打包与运行 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。