Skip to content

Spring Boot Web 请求链与商业工程实践

商业 Web 服务不是“Controller 调 Service,再返回 JSON”这么简单。一个创建订单请求还要经历协议解析、身份认证、参数校验、幂等检查、事务、库存或支付下游调用、异常转换、日志追踪和监控;任何一个环节设计错误,都可能产生重复订单、脏数据、超时雪崩或敏感信息泄露。

本章使用 JDK 8 + Spring Boot 2.7 的订单接口贯穿讲解。Spring MVC 组件内部源码见Spring MVC 请求执行链,事务代理和传播规则见Spring 事务全过程

一、学习目标

学完后应能:

  1. 从网络入口追踪到 Controller、Service、数据库和响应写回。
  2. 区分 DTO、Command、领域对象、Entity、VO,知道为什么不能全部共用一个类。
  3. 解释参数校验发生在哪一层、为什么校验通过不代表业务合法。
  4. 设计统一错误码和全局异常处理,而不是在每个 Controller 写 try-catch
  5. 正确划分事务边界,理解“数据库提交成功”和“MQ 发送成功”不是一个事务。
  6. 设计幂等、traceId、超时、重试和生产监控。
  7. 分层编写单元测试、MVC 测试和集成测试。
  8. 根据 400、401、403、404、409、415、500、503 和超时现象定位问题。

二、创建订单请求的完整链路

mermaid
flowchart TD
    A["客户端发送 POST /api/orders"] --> B["Nginx 或网关"]
    B --> C["Tomcat 解析 HTTP"]
    C --> D["Filter:traceId、编码、安全"]
    D --> E["Spring Security 认证授权"]
    E --> F["DispatcherServlet"]
    F --> G["参数解析、JSON 反序列化、校验"]
    G --> H["Controller 转换请求并调用 Service"]
    H --> I["Service:幂等、业务规则、事务"]
    I --> J["Repository 或 Mapper 写数据库"]
    J --> K["事务提交或回滚"]
    K --> L["返回值处理与 JSON 序列化"]
    L --> M["记录指标、访问日志并返回响应"]

不要把上图理解成所有步骤都属于同一个数据库事务:Filter、认证、参数校验通常发生在事务之前;响应序列化常发生在 Service 事务完成之后;外部 HTTP 和 MQ 是否参与一致性,要由专门方案决定。

三、先划清每一层的职责

text
com.example.order
├─ web
│  ├─ OrderController.java
│  ├─ CreateOrderRequest.java
│  ├─ OrderResponse.java
│  └─ GlobalExceptionHandler.java
├─ application
│  ├─ OrderApplicationService.java
│  └─ CreateOrderCommand.java
├─ domain
│  ├─ Order.java
│  ├─ OrderRepository.java
│  └─ OrderStatus.java
├─ infrastructure
│  ├─ persistence
│  ├─ client
│  └─ messaging
├─ config
└─ OrderApplication.java
输入输出核心职责禁止堆放
Web/ControllerHTTP DTO、状态码、Header协议转换、参数校验、调用用例SQL、长事务、复杂业务规则
Application ServiceCommand、用例结果编排业务步骤、事务边界、幂等入口Servlet API、JSON 细节
Domain业务值和状态不变量、状态机、业务计算Controller 注解、数据库 SQL
Repository/Mapper领域查询和持久化结果数据读写与映射决定支付、库存业务规则
Client/Gateway下游协议超时、错误转换、调用指标将第三方 DTO 泄漏给所有业务层

分层不是为了增加目录数量,而是为了隔离变化:HTTP 协议变化不应迫使领域规则修改,数据库表结构变化不应直接改变外部响应,第三方接口升级不应影响所有 Service。

四、为什么 Request、Entity、Domain 和 Response 不能混用

错误做法:Controller 直接接收数据库 Entity,再原样返回。

后果包括:

  1. 客户端可能提交 idstatuscreatedTime 等不应控制的字段。
  2. Entity 新增内部敏感字段后可能被意外序列化到公网。
  3. 数据库字段改名会破坏 API 合约。
  4. 懒加载关联可能触发 N+1、序列化递归或会话关闭异常。
  5. 校验规则被数据库结构绑死,创建和修改接口无法表达不同约束。

推荐映射:

mermaid
flowchart TD
    A["JSON 请求"] --> B["CreateOrderRequest"]
    B --> C["CreateOrderCommand"]
    C --> D["Order 领域对象"]
    D --> E["OrderEntity 持久化模型"]
    D --> F["OrderResponse"]
    F --> G["JSON 响应"]

小系统可以适当合并 Command 和简单 DTO,但外部请求模型与持久化 Entity 仍应谨慎隔离。避免为了“分层”机械复制几十个完全相同的类,也避免一个类贯穿所有边界。

五、请求 DTO 与参数校验

Boot 2.7 使用 javax.validation

Boot 2.3 以后,校验能力不应假设一定由 starter-web 间接带入,项目中显式加入:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
java
package com.example.order.web;

import javax.validation.Valid;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;

public class CreateOrderRequest {

    @NotBlank
    @Size(max = 64)
    private String customerId;

    @NotBlank
    @Size(max = 64)
    private String requestId;

    @NotNull
    @DecimalMin(value = "0.01")
    private BigDecimal totalAmount;

    @Valid
    @NotEmpty
    @Size(max = 100)
    private List<OrderItemRequest> items;

    // getter、setter 省略
}

Controller:

java
@PostMapping
public ApiResponse<OrderResponse> create(
        @Valid @RequestBody CreateOrderRequest request) {
    CreateOrderCommand command = converter.toCommand(request);
    Order result = orderApplicationService.create(command);
    return ApiResponse.success(converter.toResponse(result));
}

5.1 校验发生在哪里

mermaid
flowchart TD
    A["HttpMessageConverter 读取 JSON"] --> B["创建 CreateOrderRequest"]
    B --> C["参数解析器发现 @Valid"]
    C --> D["Bean Validation 执行约束"]
    D --> E{"是否有校验错误"}
    E -- "有" --> F["抛出 MethodArgumentNotValidException"]
    E -- "无" --> G["调用 Controller 方法"]

JSON 语法错误、字段类型错误通常在反序列化阶段失败;@NotBlank@DecimalMin 等是在对象创建后校验。二者异常类型不同,但都应转换为稳定的客户端错误响应。

5.2 参数校验不等于业务校验

校验示例应放位置
格式约束customerId 非空、金额大于 0DTO Bean Validation
集合内部约束商品数量大于 0嵌套 DTO + @Valid
业务规则商品是否存在、是否可售Service/Domain
并发不变量requestId 唯一、库存不能为负Service + 数据库约束/原子操作
权限规则用户只能创建自己的订单Security + Service 二次校验

仅在 Controller 查询“requestId 是否存在”再插入,会产生并发竞态:两个请求都查不到,然后同时插入。最终不变量必须依靠数据库唯一约束或原子机制兜底。

六、Controller 为什么要保持薄

Controller 应做:

  1. 接收 Path、Query、Header、Body。
  2. 触发格式校验。
  3. 将外部 DTO 转换为用例 Command。
  4. 调用一个清晰的应用服务入口。
  5. 将结果转换为响应 DTO 和状态码。

错误示例:

java
@PostMapping
public Object create(@RequestBody CreateOrderRequest request) {
    if (orderMapper.exists(request.getRequestId())) {
        return "duplicate";
    }
    inventoryClient.deduct(request.getItems());
    orderMapper.insert(request);
    mqTemplate.send(request);
    return "ok";
}

这里混入了幂等、库存、数据库、消息和协议,无法建立清晰事务边界;任何一步失败都不知道前面步骤是否需要补偿。Controller 单元也难以复用到 MQ 消费、定时任务或 RPC 入口。

七、Application Service 如何编排用例

java
@Service
public class OrderApplicationService {
    private final OrderRepository orderRepository;
    private final InventoryGateway inventoryGateway;
    private final OutboxRepository outboxRepository;

    public OrderApplicationService(OrderRepository orderRepository,
                                   InventoryGateway inventoryGateway,
                                   OutboxRepository outboxRepository) {
        this.orderRepository = orderRepository;
        this.inventoryGateway = inventoryGateway;
        this.outboxRepository = outboxRepository;
    }

    @Transactional
    public Order create(CreateOrderCommand command) {
        Order existing = orderRepository.findByRequestId(command.getRequestId());
        if (existing != null) {
            return existing;
        }

        inventoryGateway.checkAvailable(command.getItems());
        Order order = Order.create(command);
        orderRepository.save(order);
        outboxRepository.save(OrderCreatedEvent.from(order));
        return order;
    }
}

这段代码表达了用例顺序,但仍必须继续分析:

  • findByRequestId 不是并发幂等的最终保障,数据库还要有唯一索引。
  • inventoryGateway.checkAvailable 如果是远程调用,它不受本地数据库事务回滚控制。
  • 事务中执行慢远程调用会延长连接和锁占用。
  • Outbox 与订单写入同一本地数据库事务,提交后再由独立任务可靠投递消息。

八、事务边界为什么通常在 Service

mermaid
flowchart TD
    A["Controller 调用代理后的 Service"] --> B["事务拦截器开启事务"]
    B --> C["Service 执行业务编排"]
    C --> D["Repository 使用事务绑定连接"]
    D --> E{"方法是否正常完成"}
    E -- "是" --> F["提交事务"]
    E -- "否且满足回滚规则" --> G["回滚事务"]
    F --> H["返回 Controller"]
    G --> I["异常继续向外传播"]

Service 表达一个完整业务用例,因此适合作为事务边界。Controller 事务通常过大,会把 JSON 处理、下游等待和响应转换也包进事务;Repository 单方法事务又可能把一个业务用例拆成多次独立提交。

必须知道的限制:

  1. @Transactional 通常由 AOP 代理实现,外部通过代理调用才会拦截。
  2. 同一个对象中 this.create() 自调用可能绕过代理。
  3. 默认情况下,常见运行时异常触发回滚;受检异常规则要显式配置或设计。
  4. 捕获异常后不再抛出,事务拦截器可能认为方法成功。
  5. 本地事务只能直接控制同一事务资源,不能自动回滚远程 HTTP、Redis 和普通 MQ 发送。

完整声明式和编程式事务见Spring 事务全过程

九、创建接口为什么必须考虑幂等

客户端可能因超时没有收到响应而重试;网关可能重试;用户可能重复点击。第一次请求也许已经提交,只是响应丢失。

mermaid
flowchart TD
    A["客户端发送 requestId=R100"] --> B["服务创建订单并提交"]
    B --> C["响应在网络中丢失"]
    C --> D["客户端使用相同 requestId 重试"]
    D --> E{"是否有幂等保障"}
    E -- "没有" --> F["生成第二张订单"]
    E -- "有" --> G["返回第一次创建结果"]

推荐组合:

  1. 客户端为一次业务操作生成稳定 requestId
  2. 数据库建立 UNIQUE(request_id),作为并发最终防线。
  3. 插入发生唯一键冲突时查询并返回原结果,或返回明确冲突。
  4. requestId 必须与用户、租户和接口语义绑定,防止跨用户碰撞。
  5. 不要只使用先查后写;它无法阻止并发窗口。

Redis SET NX 可以减少重复请求压力,但如果 Redis 锁释放和数据库提交之间设计不严谨,它不能替代数据库唯一约束。

十、统一响应和错误码怎样设计

成功响应可以统一包装,也可以遵循纯 HTTP 状态码 + 资源表示。关键不是“必须套一层 code”,而是全系统合约一致。

java
public class ApiResponse<T> {
    private String code;
    private String message;
    private String traceId;
    private T data;

    public static <T> ApiResponse<T> success(T data) {
        ApiResponse<T> response = new ApiResponse<T>();
        response.code = "SUCCESS";
        response.message = "success";
        response.data = data;
        return response;
    }
    // getter、setter 省略
}

错误码应稳定、可搜索、可监控:

HTTP 状态业务错误码示例含义
400REQUEST_INVALIDJSON、类型或格式校验错误
401AUTH_REQUIRED未认证或凭证失效
403ACCESS_DENIED已认证但无权限
404ORDER_NOT_FOUND指定资源不存在
409ORDER_STATE_CONFLICT状态冲突、重复操作
415CONTENT_TYPE_UNSUPPORTED请求体媒体类型不支持
429RATE_LIMITED被限流
503DEPENDENCY_UNAVAILABLE必要下游暂不可用
500INTERNAL_ERROR未分类服务端错误

不要把所有错误都返回 HTTP 200,否则网关、监控、客户端重试和缓存无法准确理解结果;也不要把数据库异常栈直接返回给用户。

十一、全局异常处理全过程

java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Void> handleValidation(
            MethodArgumentNotValidException exception) {
        String message = exception.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> error.getField() + ":" + error.getDefaultMessage())
                .collect(Collectors.joining(","));
        return error("REQUEST_INVALID", message);
    }

    @ExceptionHandler(OrderNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiResponse<Void> handleNotFound(OrderNotFoundException exception) {
        return error("ORDER_NOT_FOUND", exception.getMessage());
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiResponse<Void> handleUnknown(Exception exception) {
        log.error("unexpected request failure", exception);
        return error("INTERNAL_ERROR", "系统繁忙,请稍后重试");
    }
}
mermaid
flowchart TD
    A["Controller 或参数解析抛出异常"] --> B["DispatcherServlet 进入异常解析链"]
    B --> C["匹配 @ExceptionHandler"]
    C --> D["选择 HTTP 状态和业务错误码"]
    D --> E["记录带 traceId 的内部日志"]
    E --> F["返回不泄露内部信息的响应"]

全局兜底异常必须记录堆栈;预期业务异常通常记录关键上下文即可,避免每次“订单不存在”都打印巨大 ERROR 堆栈。日志和客户端消息是两个边界:客户端得到稳定、安全的说明,内部日志保留定位证据。

十二、traceId 和日志如何贯穿请求

Filter 可以读取上游 traceId,没有则生成,再放入 MDC:

java
public class TraceIdFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null || traceId.trim().isEmpty()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        MDC.put("traceId", traceId);
        response.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("traceId");
        }
    }
}

为什么 finally 必须清理:Tomcat 线程会被线程池复用;如果 MDC 不删除,下一个请求可能继承前一个请求的 traceId,造成日志串号甚至信息混淆。

日志至少包含:traceId、接口、耗时、结果码、必要业务主键和下游结果。禁止记录密码、完整 Token、身份证、手机号、患者原文、银行卡和密钥。订单号也要评估是否属于敏感业务标识。

十三、调用下游为什么必须设置超时

没有超时时,下游连接或读取可能长期占用 Tomcat 工作线程和连接池:

mermaid
flowchart TD
    A["请求进入 Tomcat 线程池"] --> B["同步调用慢下游"]
    B --> C["线程持续等待"]
    C --> D["新请求继续占用线程"]
    D --> E["线程池和连接池耗尽"]
    E --> F["健康接口也超时,形成级联故障"]

至少分别配置连接超时、读取超时和连接池获取超时。重试只适合可重试、幂等、短暂失败的操作,并应限制次数、退避和总时间预算。

绝不能看到超时就盲目重试:创建订单、扣款、发放优惠券等非幂等请求可能被执行多次。即使调用方超时,也不代表下游没有执行成功。

十四、数据库提交与消息发送的一致性

错误流程:

text
保存订单 -> 提交数据库 -> 发送 MQ

数据库提交后进程崩溃,消息永远没发;如果先发消息再提交数据库,消费者可能读不到订单。

本地消息表/Outbox 思路:

mermaid
flowchart TD
    A["开启本地数据库事务"] --> B["写订单表"]
    B --> C["写 Outbox 事件表"]
    C --> D["提交同一本地事务"]
    D --> E["独立投递器扫描未发送事件"]
    E --> F["发送 MQ"]
    F --> G["记录已发送或等待重试"]
    G --> H["消费者按事件 ID 幂等"]

Outbox 解决“订单和待发送事件同生共死”,但不等于消息只消费一次。投递器在“MQ 已接收但状态未更新”时会重复发送,消费者仍须幂等。更多方案见分布式事务专栏

十五、认证、授权、CSRF 和 CORS 不要混淆

概念回答的问题
认证 Authentication你是谁
授权 Authorization你能做什么
CORS浏览器是否允许前端页面跨源读取响应
CSRF浏览器是否被诱导携带现有凭证发起非预期请求
参数校验输入格式是否满足约束

CORS 放开不等于接口无权限;JWT 存在不等于已经校验签名、过期、issuer 和 audience;Controller 传入 customerId 也不能直接信任,应以认证上下文中的主体为准并做资源归属校验。完整内容见Spring Security 专栏

十六、测试怎样覆盖不同风险

mermaid
flowchart TD
    A["领域与 Service 单元测试:快且定位准"] --> B["MVC 切片测试:协议、校验、异常、JSON"]
    B --> C["数据库集成测试:SQL、约束、事务"]
    C --> D["应用集成测试:Bean 和自动配置"]
    D --> E["端到端测试:网络和真实依赖链"]

16.1 MVC 测试应验证什么

java
@WebMvcTest(OrderController.class)
class OrderControllerWebTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderApplicationService orderApplicationService;

    @Test
    void rejectsInvalidAmount() throws Exception {
        String body = "{\"customerId\":\"C1\","
                + "\"requestId\":\"R1\","
                + "\"totalAmount\":0,\"items\":[]}";

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(body))
                .andExpect(status().isBadRequest());
    }
}

要覆盖:正确请求、JSON 语法错误、字段类型错误、嵌套校验、Content-Type 错误、业务异常映射、未知异常脱敏、认证和权限。

16.2 数据库集成测试应验证什么

重点验证唯一索引、事务回滚、锁、SQL 映射、时区和真实数据库方言。只使用 H2 可能掩盖 MySQL/PostgreSQL 差异,关键 SQL 更适合 Testcontainers 或测试数据库。

十七、生产环境要观测哪些指标

指标价值异常含义示例
请求量、成功率判断流量和整体质量流量突增或成功率下降
P50/P95/P99 耗时发现尾延迟平均值正常但少量请求极慢
HTTP 状态和业务错误码区分客户端、权限、冲突、服务端错误400 激增可能是发布不兼容
Tomcat 活跃线程与队列判断入口是否饱和活跃线程长期打满
数据库连接池判断连接等待和泄漏pending 增长、active 达上限
下游调用耗时与错误定位依赖故障某医院接口超时率上升
JVM GC、Heap、线程判断运行时压力Full GC、线程暴增
幂等冲突和 Outbox 积压发现重复请求与消息投递问题待投递事件持续增加

指标 tag 不能使用 orderNo、requestId、userId 等高基数字段,否则监控时间序列数量会爆炸。主键应进入日志和 trace,不应成为指标标签。

十八、线上问题排查 Runbook

18.1 大量 400

  1. 按错误码区分 JSON 解析失败、字段校验失败和业务校验失败。
  2. 对比客户端与服务端版本,检查字段名称和类型是否变更。
  3. 检查 Content-Type 和字符编码。
  4. 抽样查看脱敏后的失败请求特征,不能直接全量打印敏感 Body。

18.2 大量 500

  1. 使用 traceId 找到服务端完整异常链。
  2. 先找最早业务根因,不只看全局异常处理器最后一行。
  3. 判断异常发生在参数转换、Service、数据库、下游、事务提交还是响应序列化。
  4. 检查同一错误码、版本、实例和发布时点是否聚集。
  5. 必要时降级、回滚或摘除异常实例,再修复根因。

18.3 接口耗时上升但 CPU 不高

这常见于等待而非计算:检查线程栈、数据库连接池、慢 SQL、锁等待、HTTP 连接池、DNS、下游耗时和 MQ 同步确认。低 CPU 不能证明应用健康。

18.4 客户端超时但服务端订单已创建

不要让客户端换 requestId 重新创建。应使用相同幂等键查询或重试,服务端返回第一次结果。检查响应写回、网关超时、网络和下游链路,区分“业务未执行”和“执行成功但响应丢失”。

18.5 数据库有订单但 MQ 没消息

确认采用的是直接发送、事务消息还是 Outbox。Outbox 场景检查事件记录是否存在、投递状态、重试次数、投递器存活、MQ ACK 和死信;如果订单表和 Outbox 都没有同事务写入,则设计本身存在一致性缺口。

十九、常见错误与后果

错误后果修复方向
Controller 直接操作多个资源无清晰事务和补偿边界用 Application Service 编排
Entity 直接作为请求和响应越权赋值、字段泄露、API 与库表耦合使用边界 DTO 并转换
所有异常都返回 200监控、网关和客户端无法理解失败正确 HTTP 状态 + 稳定业务码
捕获 Exception 后返回成功事务可能提交,故障被掩盖只捕获可处理异常,失败继续传播
先查后插实现幂等并发下生成重复数据唯一约束 + 冲突处理
事务中执行无超时远程调用长时间占用连接和锁缩短事务、设置预算、重构一致性
对创建请求自动无限重试重复扣款、重复订单先保证幂等,再有限重试
日志打印整个请求对象密码、Token、患者数据泄露字段白名单、脱敏和审计
MDC 不清理线程复用后 trace 串号在 finally 中 remove/clear

二十、面试标准回答

一次 Spring Boot HTTP 请求怎么走

请求先由 Nginx 或网关转发到内嵌 Tomcat,Tomcat 解析 HTTP 并进入 Filter 链;Spring Security 完成认证授权后,请求到 DispatcherServlet。HandlerMapping 查找 Controller 方法,HandlerAdapter 使用参数解析器和 HttpMessageConverter 完成参数解析、JSON 反序列化与校验。Controller 将请求 DTO 转成 Command 并调用 Service;Service 通过事务代理执行业务编排和数据访问。正常完成后事务提交,返回值处理器再通过 Jackson 序列化响应;异常则进入 HandlerExceptionResolver 和 @RestControllerAdvice 转换成统一错误。

为什么事务通常放 Service

Service 通常表达一个完整业务用例,能覆盖多个 Repository 操作,又不会把 HTTP 解析和响应序列化包含进事务。@Transactional 由 AOP 代理开启、提交或回滚事务;要注意自调用、异常回滚规则和本地事务不能自动控制远程 HTTP、Redis 或普通 MQ。

怎样保证创建订单幂等

客户端为同一次业务操作提供稳定 requestId,服务端将它与用户或租户绑定,并在数据库建立唯一约束作为并发最终防线。相同 requestId 重试时返回第一次结果;只做先查后写存在并发窗口,Redis 锁也不能替代数据库唯一约束。调用下游还必须传递幂等语义。

数据库成功但 MQ 失败怎么办

不能假设两个独立资源处于同一本地事务。常见方案是订单和 Outbox 事件在同一数据库事务写入,再由投递器重试发送;或使用消息中间件事务消息。由于确认与状态更新之间仍可能失败,生产端和消费端都要按事件 ID 幂等,并监控积压和重试。

更多标准回答统一放在Spring Boot 面试题,原理以本页和对应专栏为准。

二十一、关联知识点

本章小结

生产级 Web 接口是一条有边界的执行链:入口层处理协议和身份,参数解析与校验阻止无效输入,Application Service 编排用例和事务,Domain 保证业务规则,Repository 持久化,Client 隔离外部协议,唯一约束保障并发幂等,Outbox 或事务消息处理跨资源一致性,全局异常、traceId、指标和分层测试保证系统可诊断、可验证。

只有能说明每一步由谁触发、在事务内还是事务外、失败会留下什么状态、如何重试或补偿,才算真正掌握 Spring Boot Web 工程,而不只是会写 Controller。