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:#fff9c4URL 解析常見錯誤#
@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 dateHeader 解析常見錯誤#
使用 Map 接收 Header 丟失多值#
// 當同一個 Header 有多個值時:
// myheader: h1
// myheader: h2
// 錯誤:使用 Map 只能拿到第一個值
@RequestHeader() Map map // {myheader=h1, ...}
// 正確:使用 MultiValueMap
@RequestHeader() MultiValueMap map // [myheader:"h1", "h2", ...]
// 更好:使用 HttpHeaders(推薦)
@RequestHeader() HttpHeaders headersHeader 名稱大小寫問題#
// 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#
| 特性 | Filter | Interceptor |
|---|---|---|
| 規範 | Servlet | Spring |
| 執行時機 | 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 |