Spring MVC 是 Spring 生態系統中用於構建 Web 應用的核心模組,提供了完整的 MVC 架構支援。


Spring MVC 請求處理流程#

flowchart TD
    REQ["HTTP Request"]
    REQ --> DS["DispatcherServlet<br/>(前端控制器)"]
    DS --> HM["HandlerMapping"]
    HM -->|查詢| HC["Handler<br/>(Controller)"]
    DS --> HA["HandlerAdapter<br/>(執行 Handler)"]
    HA --> MV["ModelAndView"]
    MV --> VR["ViewResolver<br/>(視圖解析)"]
    VR --> RES["HTTP Response"]

    style DS fill:#ffccbc
    style HC fill:#c8e6c9
    style VR fill:#fff9c4

URL 解析常見錯誤#

@PathVariable 遇到 /#

當路徑變數中包含 / 時,Spring 無法正確解析

@RequestMapping(path = "/hi1/{name}", method = RequestMethod.GET)
public String hello1(@PathVariable("name") String name) {
    return name;
}

// http://localhost:8080/hi1/xiaoming     -> 正常:xiaoming
// http://localhost:8080/hi1/xiao/ming    -> 404 錯誤!
// http://localhost:8080/hi1/xiaoming/    -> 正常xiaoming尾部 / 被自動去除

**解決方案:使用 ** 匹配**

private AntPathMatcher antPathMatcher = new AntPathMatcher();

@RequestMapping(path = "/hi1/**", method = RequestMethod.GET)
public String hi1(HttpServletRequest request) {
    String path = (String) request.getAttribute(
        HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
    String matchPattern = (String) request.getAttribute(
        HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
    return antPathMatcher.extractPathWithinPattern(matchPattern, path);
}

@RequestParam 省略參數名稱的陷阱#

// 方式 1:明確指定(推薦)
@RequestParam("name") String name

// 方式 2:省略參數名(有風險)
@RequestParam String name

方式 2 依賴編譯時保留參數名稱資訊(-parameters 選項)。 如果編譯時關閉了 debug 資訊,將會失效!

最佳實踐:永遠明確指定參數名稱

請求參數是否必須#

// 預設情況下,參數是必須的
@RequestParam("address") String address  // 缺少會報 400 錯誤

// 設定非必須的幾種方式:
@RequestParam(value = "address", required = false) String address
@RequestParam(value = "address", defaultValue = "no address") String address
@RequestParam(value = "address") @Nullable String address
@RequestParam(value = "address") Optional<String> address

日期參數轉換#

// 錯誤:直接使用 Date,格式不被支援
@RequestParam("date") Date date
// http://localhost:8080/hi6?date=2021-5-1 20:26:53 -> 400 錯誤

// 正確:使用 @DateTimeFormat 指定格式
@RequestParam("date")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
Date date

Header 解析常見錯誤#

使用 Map 接收 Header 丟失多值#

// 當同一個 Header 有多個值時:
// myheader: h1
// myheader: h2

// 錯誤:使用 Map 只能拿到第一個值
@RequestHeader() Map map  // {myheader=h1, ...}

// 正確:使用 MultiValueMap
@RequestHeader() MultiValueMap map  // [myheader:"h1", "h2", ...]

// 更好:使用 HttpHeaders(推薦)
@RequestHeader() HttpHeaders headers

Header 名稱大小寫問題#

// HTTP 協定規定 Header 名稱不區分大小寫
// 但從 Map 中獲取時需要注意:

@RequestHeader("MyHeader") String myHeader  // 可以忽略大小寫
@RequestHeader() MultiValueMap map
map.get("MyHeader")  // 可能為 null!要用實際的 key

// HttpHeaders 可以忽略大小寫(使用 LinkedCaseInsensitiveMap)
@RequestHeader() HttpHeaders headers
headers.get("MyHeader")  // 正常工作

無法自訂 Content-Type#

// 在 Controller 中設定 Content-Type 可能不生效
@RequestMapping(path = "/hi3", method = RequestMethod.GET)
public String hi3(HttpServletResponse response) {
    response.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
    return "ok";
}
// 實際回傳的可能是 text/plain;charset=UTF-8

// 解決方案 1:在請求中帶上 Accept 頭
// Accept: application/json

// 解決方案 2:使用 produces 屬性
@RequestMapping(path = "/hi3", method = RequestMethod.GET,
                produces = {"application/json"})

Body 處理常見錯誤#

No converter found for return value#

非 Spring Boot 專案可能缺少 JSON 編解碼器

<!-- 需要添加 JSON 依賴 -->
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.8.6</version>
</dependency>

<!-- 或使用 Jackson -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.6</version>
</dependency>

依賴變更導致序列化行為變化#

// Gson 預設不序列化 null
// Jackson 預設會序列化 null

// 依賴從 Gson 變為 Jackson 後:
// {"name":"xiaoming"} -> {"name":"xiaoming","age":null}

// 統一行為:
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Student {
    private String name;
    private Integer age;
}

Request Body 被讀取後無法再讀#

// 在 Filter 中讀取 Body 後,Controller 會報錯
public class ReadBodyFilter implements Filter {
    public void doFilter(ServletRequest request, ...) {
        // Body 是流,讀取後就沒了
        String requestBody = IOUtils.toString(request.getInputStream(), "utf-8");
        chain.doFilter(request, response);
    }
}
// Controller 會報錯:Required request body is missing

// 解決方案:使用 RequestBodyAdviceAdapter
@ControllerAdvice
public class PrintRequestBodyAdviceAdapter extends RequestBodyAdviceAdapter {
    @Override
    public Object afterBodyRead(Object body, HttpInputMessage inputMessage, ...) {
        System.out.println("print request body: " + body);
        return super.afterBodyRead(body, inputMessage, ...);
    }
}

參數驗證#

啟用驗證#

// 物件參數必須加上 @Valid 或 @Validated
@PostMapping("/students")
public void addStudent(@Valid @RequestBody Student student) { }

// 路徑參數需要在 Controller 類上加 @Validated
@RestController
@Validated
public class StudentController {
    @GetMapping("/students/{id}")
    public void getStudent(@PathVariable @Min(1) Long id) { }
}

巢狀物件驗證#

@Data
public class Student {
    @Size(max = 10)
    private String name;

    @Valid  // 必須加上 @Valid 才會驗證巢狀物件
    private Phone phone;
}

@Data
public class Phone {
    @Size(max = 10)
    private String number;
}

@Size 不能約束 null#

// @Size(min=1, max=10) 不能阻止 null
@Size(min = 1, max = 10)
private String name;  // null 可以通過驗證!

// 正確做法:組合使用
@NotNull
@Size(min = 1, max = 10)
private String name;

// 或使用 @NotEmpty(同時檢查 null 和空字串)
@NotEmpty
@Size(max = 10)
private String name;

過濾器與攔截器#

Filter vs Interceptor#

特性FilterInterceptor
規範ServletSpring
執行時機DispatcherServlet 之前Handler 執行前後
能否存取 Spring Bean有限制可以
能否存取 Handler 資訊不能可以

@WebFilter 的坑#

使用 @WebFilter 標記的 Filter 無法直接自動注入

@WebFilter
public class TimeCostFilter implements Filter { }

// 錯誤:無法注入
@Autowired
private TimeCostFilter timeCostFilter;  // 會報錯

// 正確:注入 FilterRegistrationBean
@Autowired
@Qualifier("com.example.TimeCostFilter")
private FilterRegistrationBean timeCostFilter;

原因: @WebFilter 標記的類會被包裝為 FilterRegistrationBean,原始類只是一個內部 Bean。

Filter 不要多次呼叫 doFilter#

// 錯誤:可能導致業務執行多次
public void doFilter(ServletRequest request, ServletResponse response,
                     FilterChain chain) {
    try {
        // 業務邏輯
        throw new RuntimeException();
    } catch (Exception e) {
        chain.doFilter(request, response);  // 第一次
    }
    chain.doFilter(request, response);  // 第二次!業務會執行兩次
}

// 正確:確保只呼叫一次
public void doFilter(ServletRequest request, ServletResponse response,
                     FilterChain chain) {
    try {
        // 業務邏輯
    } catch (Exception e) {
        // 處理例外但不呼叫 doFilter
    }
    chain.doFilter(request, response);  // 只呼叫一次
}

例外處理#

全域例外處理#

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.toList());

        return ResponseEntity.badRequest()
            .body(new ErrorResponse("Validation Failed", errors));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        return ResponseEntity.status(500)
            .body(new ErrorResponse("Internal Server Error", ex.getMessage()));
    }
}

最佳實踐速查#

場景最佳實踐
路徑參數可能含 /使用 ** 配合 AntPathMatcher
請求參數名稱永遠明確指定,不要省略
接收多值 Header使用 HttpHeaders 而非 Map
需要讀取 Request Body使用 RequestBodyAdviceAdapter
參數驗證物件加 @Valid,Controller 加 @Validated
巢狀物件驗證巢狀屬性上加 @Valid
自訂 Filter優先繼承 OncePerRequestFilter