Skip to content

项目 A:后端分层与接口设计 ​

S5-003必学进阶约 90 分钟JDK 21项目实战 · 项目 A:测试管理平台

1. 本节定位 ​

《项目 A:数据库设计》定好三张表;本节把它们变成可调用的接口,并讲清三层各自的边界。

text
Controller  → 只做协议转换(HTTP ↔ 方法参数)
Service     → 业务规则 + 事务边界          ← ★ 真正的代码在这里
Repository  → 只做 SQL 与结果映射

★ 本节的接口全部可调用 ✓(labs/project-a,9 个接口测试全绿)

2. 学习目标 ​

  • 能说清三层各自的职责,以及"不该做什么" ★★★;
  • 能设计一份带错误码的接口清单 ★★★;
  • 知道事务边界应该画在 Service 而不是 Controller ★★★;
  • 知道 @RequestParam / @PathVariable 要显式写 name ★★★;
  • 知道兜底异常处理器要"写在最后、覆盖要窄" ★★★;
  • 知道 4xx 与 5xx 的划分依据 ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看本项目踩到的一个真实故障 ✗✗:

text
现象:GET /api/books 返回 500,但代码看起来完全没问题 ✗
报错:Name for argument of type [int] not specified ...
     Ensure that the compiler uses the '-parameters' flag
原因:@RequestParam(defaultValue = "1") int page  —— 没写 name,
     而编译时没开 -parameters,Spring 拿不到参数名 ✗✗

★ 这类"代码对但跑不通"的问题,只有真跑一遍才会发现 ✓✓

5. 核心概念 ​

5.1 三层的职责边界 ★★★ ​

层做什么不做什么
Controller ★★★收参数、返响应、状态码不写业务判断 ✗✗
Service ★★★业务规则 + 事务边界不碰 HTTP、不拼 SQL ✓
Repository ★★拼 SQL、映射结果不写业务规则 ✗✗

★★ 判断 Controller 是否写对了的标准 ✓✓:

"把 HTTP 换成 gRPC,这个类需要改多少?"

如果要改业务逻辑 → 说明业务漏到了 Controller 层 ✗

★ 本项目的实例 ✓:

java
@RestController
@RequestMapping("/api/books")
public class BookController {
    @GetMapping
    public BookService.Page<Book> search(
            @RequestParam(name = "keyword", required = false) String keyword,
            @RequestParam(name = "page", defaultValue = "1") int page,
            @RequestParam(name = "size", defaultValue = "10") int size) {
        // ★ 一行转发给 Service,没有任何判断 ✓✓
        return bookService.search(keyword, page, size);
    }
}

5.2 事务边界画在 Service ★★★ ​

★ 为什么不是 Controller ✗:

text
① Controller 可能有多次 Service 调用 → 事务范围会变大 ✗
② 事务应该在"业务操作"这一层,而不是"协议"这一层 ✓✓
③ Service 可能被定时任务 / 消息消费者复用 —— 那时也必须带事务 ✓✓

★ 本项目的写法 ✓:

java
@Transactional
public BorrowRecord borrow(long bookId, String cardNo) {
    // 校验(读者、上限、重复借)
    // ★ 原子扣库存
    if (!bookRepository.tryBorrowOne(bookId)) { throw new BusinessException("NO_AVAILABLE_COPY", ...); }
    // 插借阅记录
    return borrowRepository.findById(id).orElseThrow();
}

★★ 顺序也很讲究 ✓✓:"先改记录、再改库存"还是"先改库存、再改记录"?

借书:先原子扣库存(成功才插记录)—— 扣库存失败就直接拒绝 ✓

还书:先标记记录(成功才加库存)—— 记录没改成就不动库存 ✓✓

共同点:两个写操作都在同一事务里,失败会一起回滚 ✓

5.3 接口清单:9 个接口 ★★★ ​

方法路径说明成功码
GET/api/books?keyword=&page=&size=分页查询200
GET/api/books/{id}详情200
POST/api/books新增图书201 + Location ✓
POST/api/books/{id}/copies增加副本200
POST/api/borrows借书201 ✓
POST/api/borrows/{id}/return还书200
POST/api/borrows/{id}/renew续借200
GET/api/readers/{cardNo}/borrows借阅历史200
GET/api/borrows/overdue超期清单200

★ 三条 REST 约定 ✓✓:

约定例子为什么
写操作用 POST/PUT/DELETE,不用 GET ★★★借书用 POST /api/borrowsGET 会被浏览器/爬虫/预取顺手触发 ✗✗
创建成功返回 201 + Location ★★★Location: /api/books/1调用方能拿到新资源地址 ✓
资源用名词复数 ★★/api/books 而不是 /api/getBooks可读性 + 一致性 ✓

5.4 状态码与错误码 ★★★ ​

★ 4xx 还是 5xx?判断依据只有一条 ✓✓:

类别含义本项目
4xx调用方的问题(参数错、业务不允许)BusinessException → 409 Conflict ✓
5xx我们的问题(空指针、数据库挂了)兜底 → 500 ✓

★ 本项目的错误码 ✓:

错误码场景
READER_NOT_FOUND / READER_SUSPENDED读者不存在 / 已停用
BORROW_LIMIT_REACHED已达借阅上限
ALREADY_BORROWED同一本书未还
NO_AVAILABLE_COPY库存不足
ALREADY_RETURNED重复归还(幂等保护)
OVERDUE_CANNOT_RENEW / RENEW_LIMIT_REACHED超期不能续借 / 次数用完
DUPLICATE_ISBNISBN 重复
INVALID_ARGUMENT / MALFORMED_JSON参数/JSON 问题
INTERNAL_ERROR兜底

★★ 为什么错误码必须是"契约" ✓✓:

前端要能按码分支: · NO_AVAILABLE_COPY → 提示"换一本" · READER_SUSPENDED → 提示"去前台激活"

如果只返回一句中文,前端只能整段显示,体验差且无法适配 ✓

★ 响应体结构统一 ✓:

json
{
  "code": "NO_AVAILABLE_COPY",
  "message": "《设计模式》暂无可借副本(共 1 本)",
  "timestamp": "2026-09-22T16:00:00"
}

5.5 参数绑定:显式写 name ★★★ ​

★ 本项目踩到的坑(前面提到过) ✗✗:

java
// ✗ 没写 name:Spring 要靠反射读参数名,而这需要编译时带 -parameters
@RequestParam(defaultValue = "1") int page

// ✓ 显式写 name:不依赖编译配置 ✓✓
@RequestParam(name = "page", defaultValue = "1") int page

★ 报错信息 ✓:

text
java.lang.IllegalArgumentException:
Name for argument of type [int] not specified, and parameter name information
not available via reflection. Ensure that the compiler uses the '-parameters' flag.

★★ 两条应对,建议都做 ✓✓:

#做法
①显式写 name = "..."(最稳,不依赖编译配置)✓✓
②在 pom 里开 <parameters>true</parameters>(对其它场景也有用)✓

5.6 全局异常处理器:兜底要"窄" ★★★ ​

★ 本项目踩到的第二个坑 ✗✗:

text
现象:请求体是坏 JSON,期望 400,实际得到 500 ✗
原因:只写了一个 @ExceptionHandler(Exception.class) 的兜底,
     它把「客户端错误」也当成了 500 ✗✗

★ 修复 ✓:先处理所有已知类型,兜底放在最后 ✓✓:

java
@ExceptionHandler(BusinessException.class)                 → 409(业务)
@ExceptionHandler(HttpMessageNotReadableException.class)   → 400(坏 JSON)
@ExceptionHandler(MethodArgumentNotValidException.class)   → 400(参数校验)
@ExceptionHandler(Exception.class)                         → 500(兜底,记 error 日志)

★★★ 两条硬要求 ✓✓:

#要求
①5xx 的响应里不能带堆栈(那是内部信息泄露)✗✗
②5xx 必须记 error 日志(否则线上无从排查)✓✓

5.7 一个容易漏的点:分页上限 ★★★ ​

java
private static final int MAX_PAGE_SIZE = 100;
int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);

★ 不限制 size 的接口,等于把数据库的生死交给调用方 ✗✗ (?size=1000000 会拖垮数据库)

★ 项目的测试直接断言了这一点 ✓:pageSizeIsCapped

6. 递进示例 ​

6.1 最小示例:一个接口方法 ​

java
@PostMapping
public ResponseEntity<Book> create(@RequestBody CreateBookRequest request) {
    Book created = bookService.create(request.isbn(), request.title(),
            request.author(), request.category(), request.copies());
    return ResponseEntity.created(URI.create("/api/books/" + created.id())).body(created);
}

6.2 业务示例:Service 的事务方法 ​

见 5.2 ✓

6.3 反例:Controller 里写业务判断 ​

java
// ✗ 业务判断跑到了 Controller —— 换成 gRPC 时这段代码要重写
@GetMapping("/{id}")
public Book get(@PathVariable(name = "id") long id) {
    Book book = repo.findById(id);
    if (book == null) { throw new RuntimeException("不存在"); }   // ✗
    if (book.availableCopies() <= 0) { /* 又一段业务 */ }         // ✗✗
    return book;
}

7. 完整可运行代码与实测 ​

代码:

text
labs/project-a/src/main/java/cn/kb/learn/library/
├── web/         BookController、BorrowController、ApiExceptionHandler
├── service/     BookService、BorrowService、BusinessException
├── repository/  BookRepository、ReaderRepository、BorrowRepository
├── domain/      Book、Reader、BorrowRecord
└── config/      LibraryRules

运行与调用:

powershell
cd labs
.\mvnw.cmd -pl project-a spring-boot:run

# 查询
curl "http://localhost:18080/api/books?keyword=Java&page=1&size=5"
# 借书
curl -X POST http://localhost:18080/api/borrows \
     -H "Content-Type: application/json" -d '{"bookId":4,"cardNo":"R001"}'
# 还书
curl -X POST http://localhost:18080/api/borrows/1/return

★ 实测 ✓:接口层 9 个测试全部通过 ✓✓

8. Debug 观察点 ​

  1. 去掉 @RequestParam 的 name:观察 500(本节开头的那个坑)✗✗;
  2. 删掉 HttpMessageNotReadableException 的 handler:观察坏 JSON 变成 500 ✗✗;
  3. 故意让 Service 抛 NPE:观察响应体里有没有堆栈(不应该有)✓✓;
  4. 请求 ?size=100000:观察被压到 100 ✓;
  5. 把 @Transactional 从 Service 移到 Controller:思考会多出什么问题 ✓。

9. 常见错误 ​

#错误说明
1Controller 里写业务判断 ✗✗分层失效 ✓✓
2事务画在 Controller ✗范围变大、复用时丢事务 ✓
3@RequestParam 不写 name ✗✗缺 -parameters 就 500 ✓✓
4兜底 handler 覆盖太宽 ✗✗客户端错误被当成 500 ✓✓
55xx 响应里带堆栈 ✗✗信息泄露 ✓
6不限制分页 size ✗✗数据库被拖垮 ✓
7错误码不规范 ✗前端无法分支处理 ✓

10. 动手实验 ​

目标:亲手跑通全部接口,并验证错误处理。

步骤:

  1. 启动项目,用 curl 或页面逐个调用 9 个接口 ✓;
  2. 构造一个坏 JSON 请求,确认返回 400 而不是 500 ✓✓;
  3. 触发每种业务异常(库存不足、读者停用、重复归还),记录错误码 ★★★;
  4. 去掉参数名上的一个 name,观察 500 并修复 ✓;
  5. 给项目加一个新接口(如"按分类查图书"),走完 Controller→Service→Repository ★★。

验收方式:能说出"你的接口在哪些输入下会返回 4xx、哪些会 5xx"。

扩展任务:给借书接口加参数校验(用 Bean Validation):

  • cardNo 不能为空、格式必须是 R + 数字 ✓;
  • bookId 必须 > 0 ✓;
  • 说明:为什么"校验要在 Controller 层做、业务规则在 Service 层做" ★★

11. 练习题 ​

  1. 判断:事务注解应该加在 Controller 方法上。(对 / 错)
  2. 填空:4xx 表示 ______ 的问题,5xx 表示 ______ 的问题。
  3. 简答:为什么兜底异常处理器要"写在最后、覆盖要窄"?
  4. (判断)
java
@GetMapping("/{id}")
public Book get(@PathVariable long id) { ... }

这段代码在什么情况下会出问题?怎么改? 5. (设计)给"还书"设计接口:要考虑重复归还、记录不存在、已经是已还状态。

参考答案(要点)
  1. 错。应在 Service(业务操作这一层)✗ —— Controller 可能有多次调用,且 Service 被其它入口复用时也要有事务 ✓★
  2. 调用方;我们(服务端) ✓
  3. 因为 Exception.class 会吃掉一切 ✗ —— 如果把它写在前面/只写它,"坏 JSON""参数错"这类客户端错误会被当成 500 ✓✓★
  4. 编译没开 -parameters 时会 500(Spring 读不到参数名)✗ —— 改成 @PathVariable(name = "id") long id ✓✓
  5. 要点:POST /api/borrows/{id}/return(写操作用 POST)✓; 记录不存在 → 404 或 409 + BORROW_NOT_FOUND ✓; 已归还 → 409 + ALREADY_RETURNED,且库存不变 ✓✓; 关键是"标记归还"的 SQL 要带 status = 'BORROWED' 条件(幂等)✓

12. 掌握标准 ​

  • [ ] 能说清三层职责与"不该做什么" ★★★;
  • [ ] 能设计带错误码的接口清单 ★★★;
  • [ ] 知道事务边界在 Service ★★★;
  • [ ] 知道参数绑定要显式写 name ★★★;
  • [ ] 知道兜底 handler 要窄、且 5xx 不带堆栈 ★★★;
  • [ ] 知道分页 size 必须设上限 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《项目 A:前端页面实现》:一个静态 HTML 怎么把接口用起来。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引