Appearance
实战:Spring Boot 实现
本章导读:把上一章的设计稿落成能跑的 Spring Boot 3 代码,重点示范五个规范点:状态码精确返回、
If-Match乐观锁、强 ETag、Idempotency-Key幂等、RFC 9457 统一错误。技术栈:Spring Boot 3.x + Spring MVC(ProblemDetail原生支持)+ Spring Data JPA + Validation。
1. 工程骨架
blog-api/
├── src/main/java/cn/awna/blog/
│ ├── api/
│ │ ├── ArticleController.java
│ │ ├── dto/ArticleDtos.java // 请求/响应 DTO(record)
│ │ ├── support/ApiExceptionHandler.java
│ │ ├── support/IdempotencyFilter.java
│ │ └── support/ETagSupport.java
│ ├── domain/Article.java ArticleStatus.java ArticleService.java
│ └── BlogApiApplication.java
└── src/main/resources/application.ymljava
// BlogApiApplication.java
@SpringBootApplication
@EnableSpringDataWebSupport(pageSerialization = PageSerializationMode.VIA_DTO)
public class BlogApiApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApiApplication.class, args);
}
}TIP
pageSerialization = VIA_DTO 让 Spring Data 的 Page 以干净的 content/page/size/total JSON 输出,避免老版本把整个 Pageable 序列化出来的噪音——规范第 3 篇"分页外壳"的最小配合。
2. 领域与 DTO:可写字段白名单
java
// domain/ArticleStatus.java —— 枚举字符串化,状态机集中定义
public enum ArticleStatus {
DRAFT, IN_REVIEW, PUBLISHED, ARCHIVED;
public boolean canTransitTo(ArticleStatus target) {
return switch (this) {
case DRAFT -> target == IN_REVIEW || target == PUBLISHED;
case IN_REVIEW -> target == DRAFT || target == PUBLISHED;
case PUBLISHED -> target == DRAFT || target == ARCHIVED; // 撤回/归档
case ARCHIVED -> false;
};
}
}java
// api/dto/ArticleDtos.java
public final class ArticleDtos {
/** 创建:只含可写字段(防批量赋值 API6) */
public record CreateArticleRequest(
@NotBlank @Size(max = 200) String title,
@Size(max = 500) String excerpt,
@NotNull String content,
List<String> tagIds) {}
/** 全量替换(PUT 语义:未提供字段视为清除) */
public record ReplaceArticleRequest(
@NotBlank @Size(max = 200) String title,
@Size(max = 500) String excerpt,
@NotNull String content,
List<String> tagIds) {}
/** 状态迁移(PUT /status) */
public record StatusRequest(@NotNull ArticleStatus status) {}
/** 响应表示:四层结构(标识/属性/关系/元数据) */
public record ArticleResponse(
String id, String title, String excerpt, String content,
ArticleStatus status, List<String> tagIds, String authorId,
long viewCount, Instant createdAt, Instant updatedAt,
Instant publishedAt, int version,
Map<String, String> links) {
public static ArticleResponse of(Article a, String base) {
return new ArticleResponse(
a.getId().toString(), a.getTitle(), a.getExcerpt(), a.getContent(),
a.getStatus(), a.getTagIds(), a.getAuthorId(),
a.getViewCount(), a.getCreatedAt(), a.getUpdatedAt(),
a.getPublishedAt(), a.getVersion(),
Map.of(
"self", base + "/articles/" + a.getId(),
"author", base + "/users/" + a.getAuthorId(),
"draft", base + "/articles/" + a.getId() + "/draft",
"comments",base + "/articles/" + a.getId() + "/comments"));
}
}
}3. Controller:状态码的精确表达
java
@RestController
@RequestMapping("/v1/articles")
@Validated
public class ArticleController {
private final ArticleService service;
ArticleController(ArticleService service) { this.service = service; }
/** GET 集合:分页过滤排序 → 200 + 短公共缓存 */
@GetMapping
@Cacheable(cacheNames = "articles-list") // 或用 HTTP 缓存头
public Page<ArticleResponse> list(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize,
@RequestParam(required = false) ArticleStatus status,
@RequestParam(required = false) String tag,
@RequestParam(required = false) String authorId,
@RequestParam(defaultValue = "-createdAt") String sort) {
// 参数直通仓储层;非法 sort 字段抛 InvalidSortException → 400(见异常处理器)
return service.search(status, tag, authorId, sort, PageRequest.of(page - 1, pageSize))
.map(a -> ArticleResponse.of(a, "/v1"));
}
/** GET 单资源:200 + 强 ETag;软删后的资源 → 410 */
@GetMapping("/{id}")
public ResponseEntity<ArticleResponse> get(@PathVariable UUID id,
HttpServletRequest req) {
Article a = service.findOrGone(id); // 缺失→NoSuchArticle(404);墓碑→GoneArticle(410)
ETagSupport.checkPreconditionIfNoneMatch(req, a.getETag()); // 命中则抛 NotModified → 304
return ResponseEntity.ok()
.eTag(a.getETag()) // 强 ETag:基于 id+version+updatedAt 哈希
.cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))
.cachePublic().staleWhileRevalidate(Duration.ofSeconds(60)))
.body(ArticleResponse.of(a, "/v1"));
}
/** POST 创建:201 + Location(幂等键由 IdempotencyFilter 兜底) */
@PostMapping
public ResponseEntity<ArticleResponse> create(
@Valid @RequestBody ArticleDtos.CreateArticleRequest body,
@AuthenticationPrincipal AppUser user) {
Article a = service.createDraft(user.id(), body.title(), body.excerpt(),
body.content(), body.tagIds());
URI loc = URI.create("/v1/articles/" + a.getId());
return ResponseEntity.created(loc)
.eTag(a.getETag())
.body(ArticleResponse.of(a, "/v1"));
}
/** PUT 全量替换:强制 If-Match,缺失→428,不匹配→412;成功 200 + 新 ETag */
@PutMapping("/{id}")
public ResponseEntity<ArticleResponse> replace(
@PathVariable UUID id,
@RequestHeader(name = "If-Match", required = false) String ifMatch,
@Valid @RequestBody ArticleDtos.ReplaceArticleRequest body,
@AuthenticationPrincipal AppUser user) {
Article a = service.replace(id, user.id(), ifMatch,
body.title(), body.excerpt(), body.content(), body.tagIds());
return ResponseEntity.ok().eTag(a.getETag())
.body(ArticleResponse.of(a, "/v1"));
}
/** PUT 状态迁移:非法迁移 → 409 */
@PutMapping("/{id}/status")
public ResponseEntity<ArticleResponse> transit(
@PathVariable UUID id,
@Valid @RequestBody StatusRequest body,
@AuthenticationPrincipal AppUser user) {
Article a = service.transit(id, user.id(), body.status()); // 内部校验状态机
return ResponseEntity.ok().eTag(a.getETag())
.body(ArticleResponse.of(a, "/v1"));
}
/** DELETE:软删 → 204;二次删除幂等返回 204(团队约定) */
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id,
@AuthenticationPrincipal AppUser user) {
service.softDelete(id, user.id());
return ResponseEntity.noContent().build();
}
}4. 乐观锁与强 ETag(service + support)
java
// support/ETagSupport.java —— 强 ETag 生成:与内容绑定,而非 updatedAt 时间戳
public final class ETagSupport {
public static String etagOf(Article a) {
String raw = a.getId() + ":" + a.getVersion() + ":"
+ Hashing.sha256().hashString(a.getTitle() + a.getContent(), StandardCharsets.UTF_8);
return '"' + Base64.getUrlEncoder().withoutPadding()
.encodeToString(raw.getBytes(StandardCharsets.UTF_8)).substring(0, 27) + '"';
}
public static void checkPreconditionIfNoneMatch(HttpServletRequest req, String current) {
String in = req.getHeader("If-None-Match");
if (in != null && (in.equals("*") || List.of(in.split(",")).contains(current))) {
throw new NotModifiedException(); // → 304,无 body
}
}
}java
// ArticleService.replace(...) 核心片段
@Transactional
public Article replace(UUID id, String actorId, String ifMatch, ...) {
Article a = repo.findById(id).orElseThrow(NoSuchArticleException::new);
requireAuthor(a, actorId); // 对象级授权 → 403
if (ifMatch == null) throw new PreconditionRequiredException(); // 428
if (!ifMatch.equals(ETagSupport.etagOf(a)))
throw new PreconditionFailedException(); // 412
a.setTitle(title); a.setExcerpt(excerpt); a.setContent(content);
a.setTagIds(tagIds); a.bumpVersion(); // JPA @Version 同用:双保险
return a;
}5. RFC 9457 全局异常处理
java
// support/ApiExceptionHandler.java
@RestControllerAdvice
public class ApiExceptionHandler {
private static ProblemDetail problem(HttpStatus s, String code, String title, String detail) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(s, detail);
p.setTitle(title);
p.setType(URI.create("https://api.example.com/problems/" + code));
p.setProperty("code", code.toUpperCase().replace('-', '_'));
return p;
}
@ExceptionHandler(MethodArgumentNotValidException.class) // Bean Validation
public ProblemDetail onValidation(MethodArgumentNotValidException e) {
ProblemDetail p = problem(HttpStatus.UNPROCESSABLE_ENTITY, "validation-failed",
"请求校验失败", e.getBindingResult().getFieldErrorCount() + " 个字段不符合约束");
p.setInstance(URI.create(currentRequestPath()));
p.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of("field", fe.getField(),
"constraint", fe.getCode(),
"message", fe.getDefaultMessage()))
.toList());
return p;
}
@ExceptionHandler(PreconditionFailedException.class)
public ProblemDetail onPreconditionFailed(PreconditionFailedException e) {
return problem(HttpStatus.PRECONDITION_FAILED, "version-conflict",
"资源版本已变化", "If-Match 与当前 ETag 不一致,请重新 GET 后合并修改");
}
@ExceptionHandler(IllegalTransitionException.class)
public ProblemDetail onTransition(IllegalTransitionException e) {
return problem(HttpStatus.CONFLICT, "invalid-status-transition",
"非法状态迁移", "%s → %s 不被状态机允许".formatted(e.from(), e.to()));
}
@ExceptionHandler(GoneArticleException.class)
public ProblemDetail onGone(GoneArticleException e) {
return problem(HttpStatus.GONE, "article-gone", "文章已永久删除", null);
}
@ExceptionHandler(NoSuchArticleException.class)
public ResponseEntity<ProblemDetail> onNotFound(NoSuchArticleException e) {
return ResponseEntity.notFound() // 404 可无 body;此处仍给 problem 保持一致
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem(HttpStatus.NOT_FOUND, "resource-not-found", "资源不存在", null));
}
@ExceptionHandler(NotModifiedException.class)
public ResponseEntity<Void> onNotModified() {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build(); // 304 无 body
}
@ExceptionHandler(Exception.class) // 兜底:绝不泄露堆栈
public ProblemDetail onOther(Exception e, HttpServletRequest req) {
String traceId = req.getHeader("X-Request-Id");
log.error("unhandled", e);
ProblemDetail p = problem(HttpStatus.INTERNAL_SERVER_ERROR, "internal-error",
"服务内部错误", "请稍后重试;如持续发生请携带 traceId 联系支持");
p.setProperty("traceId", traceId);
return p;
}
}6. Idempotency-Key 过滤器(简化实现)
java
// support/IdempotencyFilter.java —— 思路展示;生产考虑并发锁与序列化细节
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class IdempotencyFilter extends OncePerRequestFilter {
private final IdempotencyStore store; // Redis: key → (fingerprint, status, headers, body), TTL 24h
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ... {
String key = req.getHeader("Idempotency-Key");
boolean needs = "POST".equalsIgnoreCase(req.getMethod())
&& req.getRequestURI().startsWith("/v1/");
if (!needs || key == null) { chain.doFilter(req, res); return; } // 规范可强制缺失→400,按团队策略
String fingerprint = Sha256.of(req.getRequestURI() + body(req));
var cached = store.find(key);
if (cached != null) {
if (!cached.fingerprint().equals(fingerprint)) {
write(res, 422, problemJson("idempotency-key-reuse",
"幂等键已用于另一请求")); return;
}
replay(res, cached); return; // 重放首次结果(含状态码/Location)
}
CopierResponse wrapped = new CopierResponse(res); // 捕获下游响应
chain.doFilter(req, wrapped);
store.save(key, fingerprint, wrapped.snapshot(), Duration.ofHours(24));
}
}配合 POST /v1/articles 的 DB 唯一索引(如 author_id + client_ref)做最终一致兜底——过滤器防重放,数据库防幻觉。
7. 规范映射清单(自查)
| 设计决策 | 代码落点 |
|---|---|
| 201 + Location | ResponseEntity.created(loc) |
| 204 无 body | ResponseEntity.noContent() |
| 强 ETag / If-Match / 412 / 428 | ETagSupport + service 校验 |
| 304 协商缓存 | checkPreconditionIfNoneMatch |
| 公共短缓存 + SWR | CacheControl.maxAge(30s).cachePublic().swr(60s) |
| 分页 max=100 | @Max(100) |
| problem+json + type 目录 | ApiExceptionHandler |
| 幂等重放 | IdempotencyFilter |
| 500 不泄露 | 兜底 handler + traceId |
| camelCase/枚举字符串 | DTO 字段与 @JsonFormat/全局 ObjectMapper |
别忘了在
WebMvcConfigurer/ObjectMapper层面统一:WRITE_DATES_AS_TIMESTAMPS=false(Instant→ISO-8601/RFC 3339)、未知请求属性报错(FAIL_ON_UNKNOWN_PROPERTIES=true落实"字段白名单")。
8. 下一步
代码写好了,用 HTTP 客户端与测试把它"验"成规范 → 实战:API 测试