← 返回 Java 后端知识路线
阶段 04Spring 与后端

Boot + MyBatis + 参数校验:一条写接口怎样穿过三层

实现创建草稿的请求链,追踪 @Valid DTO、Service 事务、MyBatis Mapper 和统一错误响应。

第 30 / 35 篇
Spring BootMyBatisValidation事务DTO

先看这一课值不值得学

学完后,你手里多了哪些代码积木

把请求校验、业务调用和 MyBatis 持久化接成闭环。

本课正式新增

语法 / API / 命令你必须会到什么程度
@Mapper让 MyBatis 创建 Mapper 代理
@Valid / @Validated触发 Bean Validation
@NotBlank / @Size / @Positive声明字段约束
Page / RowBounds表达分页输入输出

本课只借用,先别硬背

  • 认证授权第 31 课,缓存第 32 课

学完必须能独立写

  • 完成文章新增、查询和参数校验
  • 从 JSON 一直追到 Mapper 和数据库行
本课目录
  1. 1. 现实问题:校验写在 Controller 里仍可能写入坏数据
  2. 2. 最小可运行示例:DTO、Service 与 Mapper
  3. 3. 调用链与对象变化
  4. 4. 为什么这样设计
  5. 5. 项目落点:把创建草稿落到博客系统
  6. 6. 易错排查
  7. 7. 一页复习

1. 现实问题:校验写在 Controller 里仍可能写入坏数据

创建草稿需要标题、正文和作者,标题不能为空且长度有限,作者必须有权限,slug 还要唯一。@NotBlank 能拦住空标题,却不能判断作者是否存在、slug 是否冲突。输入校验、业务校验、数据库约束分别位于不同边界,少一层都可能留下竞态漏洞。

这篇把一个写接口完整串起来:请求 JSON 先绑定 CreateDraftRequest,校验通过进入 Service,Service 开事务调用 Mapper,数据库约束失败由统一异常处理转成稳定响应。

2. 最小可运行示例:DTO、Service 与 Mapper

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

record CreateDraftRequest(
        @NotBlank @Size(max = 160) String title,
        @NotBlank String body) {}

@RestController
class DraftController {
    private final DraftService service;
    DraftController(DraftService service) { this.service = service; }

    @PostMapping("/api/drafts")
    @ResponseStatus(HttpStatus.CREATED)
    DraftView create(@Valid @RequestBody CreateDraftRequest request) {
        return service.create(request);
    }
}

Service 伪代码应保持语义一致:

@Transactional
DraftView create(CreateDraftRequest request) {
    String slug = slugger.from(request.title());
    if (mapper.countBySlug(slug) > 0) throw new SlugConflictException(slug);
    mapper.insert(new DraftRecord(slug, request.title(), request.body()));
    return mapper.findViewBySlug(slug);
}

count 只是用户体验上的早提示,最终仍依赖数据库唯一索引防止并发重复;Controller 不直接拼 SQL,也不把数据库 record 直接暴露为 JSON。

3. 调用链与对象变化

HTTP JSON 字节被消息转换器读成 CreateDraftRequest,Jakarta Validation 读取注解并对字段生成 ConstraintViolation;失败时不会进入 Service。通过校验后,Controller 把同一个不可变 DTO 引用交给 Service,Service 生成 slug 和 DraftRecord,Mapper 将字段绑定到 INSERT 参数。

事务代理在 Service 外层开启连接事务,insert 成功后再查询 View;全部成功 commit,唯一键冲突或其他异常 rollback。返回的 DraftView 经消息转换器编码为 JSON 和 201。每一层都可以用日志记录 request id、slug hash、数据库耗时,但避免记录正文全文。

4. 为什么这样设计

DTO 是外部输入契约,领域对象承载业务规则,Record 是持久化映射,分开后字段扩展不会直接泄漏。Bean Validation 适合结构性约束,Service 负责跨字段和外部资源,数据库负责最终唯一性和非空。校验重复不是坏事,它们的证据和并发边界不同。

事务包住“生成/写入/返回所需读取”的一致性动作,但不包住邮件、对象存储等外部副作用。@Transactional 只有在方法经过代理、事务管理器配置正确且异常从边界抛出时才会按预期工作。接口测试应验证 HTTP,集成测试验证真实 MyBatis SQL 和约束。

5. 项目落点:把创建草稿落到博客系统

草稿创建后默认 status=DRAFT、deleted_at=null、created_at 由服务器时钟产生;作者从认证上下文获取,不接受前端传 userId。Mapper 方法参数写清名称,动态字段走 #{}。统一错误响应区分 validation_failed、slug_conflict、not_found 和 internal_error。

练习:增加 @Size(max=200000) 正文限制和标题/正文跨字段校验;写 MockMvc 测试 400,Service 测试 slug 冲突,MySQL 集成测试唯一键竞态。并发执行两个同 slug 请求,确认只能一个 201,另一个得到可理解的冲突结果。

6. 易错排查

  • @Valid 不生效:检查依赖、参数注解位置和异常处理器是否读取 MethodArgumentNotValidException。
  • 事务不回滚:查看代理、自调用、异常是否被包装或吞掉,确认 DataSourceTransactionManager。
  • 唯一键先查后写仍重复:count 不是锁,必须依赖数据库唯一约束并翻译冲突。
  • DTO 允许客户端传 status/authorId:只定义允许输入字段,服务器生成受保护字段。

7. 一页复习

写接口链是 JSON → DTO 绑定/结构校验 → Service 业务校验 → Mapper 参数化 SQL → 事务提交 → View JSON。校验是分层的,数据库约束是最终防线,事务代理是调用链的一部分。每次声称“接口成功”都要验证状态码、数据库行和错误路径。

创建请求的对象状态可按边界记录:原始 JSON 只有 title/body,绑定后形成 CreateDraftRequest,Bean Validation 生成 violations 或放行;Service 从认证上下文得到 authorId,生成 slug 和 server time;Mapper 把 DraftRecord 绑定到 INSERT;commit 后查询 ArticleView。客户端传入的 status、authorId 和 createdAt 从头到尾都不应该成为受信字段。

项目文件可以把 request/response DTO 放 web/article, 状态迁移和 slug 规则放 application/article, Mapper/XML 放 persistence/article, 约束迁移放 db/migration。Controller 校验结构,Service 查权限和重复,数据库唯一键兜底;统一 Advice 将三层错误分别转为 validation_failed、conflict 和 internal_error。事务代理包住真正 Service 调用,不能在同类内部 self-invocation 后期待它生效。

排错时校验没触发查 @Valid、依赖和参数位置;Mapper 参数找不到查 @Param/XML 名称;重复 slug 仍产生两行查唯一索引而不是 count;发布失败后草稿已变更查事务代理、异常传播和 Connection。接口返回 201 但数据库没有行时,同时核对提交结果和读库连接,不能只相信响应。

练习是并发提交相同标题的两个请求,记录两条请求的 traceId、受影响行数和唯一键异常;再让 audit insert 故意失败,断言 article 状态回到 DRAFT。为同一个字段写前端提示、Bean Validation 错误和数据库错误三份输出,比较它们各自负责的边界。

写接口的输入输出可以分成四层:JSON 只有客户端允许的 title/body,绑定后形成 request record,校验后进入 Service,Service 生成 actor、slug、status 和 server time,再变成 persistence record;数据库返回生成 id,Assembler 变成 ArticleView,Controller 返回 201。客户端传入的 authorId/status/createdAt 从契约上就不存在,避免越权字段穿透。

事务调用链需要确认方法经过代理且异常仍然向外传播。slug 先查只是用户体验提示,真正并发安全来自数据库唯一索引;两个请求都查到不存在时,只有一个 insert 成功,另一个收到唯一冲突并翻译为 409。审计插入失败时,article 行也必须回滚;若 cache/event 放在提交后,失败补偿另行处理。

项目文件可按 feature 放 web/article/dtoapplication/articledomain/articlepersistence/article,校验注解放 request,状态迁移放 domain/service,MyBatis XML 放 resources/mapper。Advice 将字段错误转换成 fieldErrors,业务冲突带 resource id,未知错误只返回 traceId。Controller 不直接调用 Mapper,Mapper 不决定 HTTP 状态。

排错时 400 先看原始 JSON、Content-Type 和约束路径,Mapper 绑定失败查参数名/扫描,重复 slug 查唯一索引和异常翻译,状态未回滚查事务代理/连接/异常,返回 201 但查不到查读写数据库和 commit。日志带 actor/article/affected rows,不记录正文。练习是并发提交相同 slug、故意让 audit 失败、模拟数据库断连,比较三种最终状态。

验证清单:对创建草稿发送合法 title/body、缺 title、超长 body、未知字段、客户端伪造 authorId/status、重复 slug 六组请求,记录 JSON、DTO、violations、Service actor、Mapper 参数、受影响行数、commit 和响应状态。合法路径应只由服务端生成 actor/status/time,Bean Validation 在 Controller 边界拦截结构错误,Service 再做权限和状态规则,数据库唯一索引处理并发冲突。故意让审计写入失败,确认文章行回滚;故意让第二个并发请求撞唯一键,确认它返回 409 而不是 500 或第二行。

调用链复盘要保留对象变化:JSON 字节绑定成请求 record,校验器产生字段错误或放行,Service 生成包含 actor、slug、status 和 server time 的命令,Mapper 将持久化 record 绑定到参数化 INSERT,事务代理提交后由查询映射成 ArticleView。客户端传入的受保护字段不能在任何中间对象里成为可信值。Advice 把 validation_failed、conflict、internal_error 映射到响应,不能让 Mapper 决定 HTTP 状态。

项目验收再做一次真实读写:启动 Boot 应用,创建草稿,查库确认行和唯一索引,再发布/修改并检查状态迁移;同时保存 traceId、actor、affected rows 和事务结果,不记录正文。若返回 201 但读库没有行,先检查 commit、读写数据源和查询条件;若校验消息为空,先查参数注解和异常处理器,不要把所有问题归为 MyBatis。

再做一次提交前验收:把相同 title 的两个请求放在同一时刻开始,保存两个 DTO、两个 actor、两个 slug、唯一键结果和最终数据库行;一个成功、一个冲突且只有一条 audit 才符合契约。把未知字段、越权字段和非法分页分别送入 Controller,确认错误在绑定/校验边界返回,不能进入 Service。最后用查询接口读回 View,核对 status、author 和 createdAt 来自服务端事实,说明“返回 201”之外的对象变化也被验证。

进阶附录:分组校验与幂等键

不同操作可用 validation groups,但组越多越要维护文档。客户端重试创建接口时,可以要求 Idempotency-Key,并在同一事务中保存请求指纹和结果;幂等键的过期、冲突和并发插入都需要数据库约束和测试。

本课按「Spring Boot 4.1、Jakarta Validation、MyBatis 与事务整合」的学习范围组织,正文与示例均为本站原创整理。