Skip to content

实战: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.yml
java
// 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 + LocationResponseEntity.created(loc)
204 无 bodyResponseEntity.noContent()
强 ETag / If-Match / 412 / 428ETagSupport + service 校验
304 协商缓存checkPreconditionIfNoneMatch
公共短缓存 + SWRCacheControl.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 测试