要讓 /users 這個網址對到一段處理程式,Servlet 時代你得先開一個 class 繼承 HttpServlet,覆寫 doGet,再翻到 web.xml 補上兩段 XML:一段 <servlet> 宣告這個 class,一段 <servlet-mapping> 把它綁到 /users。兩段分開寫,中間靠一個 <servlet-name> 字串對上。字串打錯,編譯器不會理你,Tomcat 起來之前你也不會知道,等到瀏覽器噴 404 才回頭找,原來是 mapping 裡的名字少打一個字。

同樣一件事,現在你只要在方法上面掛一行 @GetMapping("/users")

大部分人講到這裡就結束了,結論是「註解比較簡潔,少打很多字」。這句話沒錯,但它把重點講小了。註解省下的不是那幾行 XML,是那張「哪個請求該交給哪段程式」的分派表本身。這張表以前存在你腦子裡,也散在每個 doGet 的 if-else 裡。現在它被搬走了。這篇就順著同一個動作,一段一段對照,看那張表是怎麼從你手上消失的。

掛一個網址上去,以前要動三個地方

Servlet 的世界裡,一個端點的身分證分散在三處。class 本身、web.xml 的宣告、web.xml 的對應。

1
2
3
4
5
6
7
8
9
<!-- web.xml:光註冊一個端點就要這兩段 -->
<servlet>
<servlet-name>userServlet</servlet-name>
<servlet-class>com.example.UserServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>userServlet</servlet-name>
<url-pattern>/users</url-pattern>
</servlet-mapping>
1
2
3
4
5
6
// 然後才是真正做事的 class
public class UserServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
// ...
}
}

我維護過一個 web.xml 快 800 行的老專案。光是想知道某個網址對到哪支程式,就得先 Ctrl-F 找 url-pattern,抄下 servlet-name,再 Ctrl-F 一次 servlet-name,才連到 servlet-class。三跳。而且每加一個端點,這套動作重來一遍。端點多起來之後,web.xml 自己就變成一個要維護的怪物。

換到 Spring MVC,這三處收成一處:

1
2
3
4
5
6
7
8
@RestController
public class UserController {

@GetMapping("/users")
public List<User> list() {
// ...
}
}

網址、HTTP 方法、要跑的程式,全部黏在同一個地方。改路徑就改這一行,沒有第二個檔案會跟你的記憶不同步。

GET 還是 POST,參數又是怎麼進來的

第二個對照更能看出差別。以前你在 Servlet 裡分辨請求是 GET 還是 POST,靠的是覆寫不同的方法名:doGetdoPostdoPut。方法名就是你的分岔器。

拿參數更痛。request.getParameter("id") 回給你的永遠是 String,而且可能是 null。所以每支程式的開頭都長一個樣:

1
2
3
4
5
6
7
8
9
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
String idStr = req.getParameter("id");
if (idStr == null || idStr.isEmpty()) {
resp.setStatus(400);
return;
}
int id = Integer.parseInt(idStr); // 這行還可能炸 NumberFormatException
// 真正的邏輯到這裡才開始
}

前面五六行全是雜訊,取值、判空、轉型、擋錯。每支程式都抄一遍,抄到你懷疑人生。

Spring MVC 把這一段吃掉了:

1
2
3
4
5
@GetMapping("/users/{id}")
public User getOne(@PathVariable int id,
@RequestParam(defaultValue = "false") boolean detail) {
// 進到這裡,id 已經是 int,detail 已經有預設值
}

@PathVariable 從路徑挖值,@RequestParam 從 query string 挖值,型別轉換框架幫你做,缺值給不給預設你自己說了算。你寫的第一行就是業務邏輯,不是擋參數的樣板。這個轉變很多人無感,因為他們沒手寫過那五六行。寫過的人第一次用到 @RequestParam,只會講一句:真香。

同一個網址,想分岔怎麼辦

最能秀肌肉的地方在這。同一條路徑 /users,你可能想讓「送 JSON 的請求」和「送表單的請求」走不同的方法,或者依 header 分流給不同版本的 API。

Servlet 時代這種分岔只能寫在程式裡:

1
2
3
4
5
6
7
8
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
String contentType = req.getContentType();
if (contentType != null && contentType.contains("application/json")) {
// 處理 JSON
} else {
// 處理表單
}
}

分岔的邏輯藏在方法內部。你不點進去讀,永遠不知道這個端點原來會依 content type 走兩條路。

@RequestMapping 把這些分岔條件全部提到方法簽名外面,變成可以宣告的屬性:

1
2
3
4
5
6
7
@RequestMapping(value = "/users", method = RequestMethod.POST,
consumes = "application/json", produces = "application/json")
public User createFromJson(@RequestBody User user) { ... }

@RequestMapping(value = "/users", method = RequestMethod.POST,
consumes = "application/x-www-form-urlencoded")
public User createFromForm(User user) { ... }

consumes 管「我只收這種 content type」,produces 管「我回這種」,還有 paramsheaders 可以要求某個參數存在、某個 header 等於某值。路徑一樣,條件不同,Spring 自己會挑對的方法。分岔的規則從「藏在程式裡的 if」變成「寫在方法門口的標籤」,你掃一眼註解就知道這個端點吃什麼、吐什麼。

註解不是魔法,框架只是幫你把那張表建好了

講到這裡該停下來問一句:這些標籤到底怎麼生效的?如果你以為 Spring 每次收到請求都去掃一遍所有註解,那它早就慢到炸了。

真相樸素很多。Spring MVC 前面站著一個 DispatcherServlet,它其實就是那個 Servlet 時代你要自己註冊的東西,只是這次全站只有一個,所有請求都先進它手裡。應用程式啟動時,框架做一件事:把所有 @Controller 掃過一遍,把每個方法上的 @RequestMapping 資訊抽出來,建成一張對照表,存在一個叫 RequestMappingHandlerMapping 的元件裡。表的 key 是「路徑加上那些條件」,value 是「該呼叫哪個方法」。

之後每個請求進來,DispatcherServlet 拿請求的路徑、方法、header、content type 去查這張表,找到對應的 handler method,準備好參數,呼叫它。就這樣。

這在設計上有個名字,叫 Front Controller。所有流量走同一個門,門後掛一張路由表。你會發現,這跟 web.xml 那套查表在本質上是同一件事:都是「路徑 → 程式」的對照。差別只在誰來維護這張表。以前是你,用手在 XML 和 if-else 裡維護;現在是框架,在啟動那一刻掃註解自動建好。註解從來不是魔法,它只是把你本來手寫的分派表,改成用貼標籤的方式讓框架幫你組。

這套自由,也是有代價的

我不想把它講得只有好處。端點散落在各個 Controller,這件事反過來也是缺點:沒有任何一個檔案能讓你一眼看完全站有哪些網址。web.xml 至少醜歸醜,全部都在那一頁。現在你想看全貌,得靠 Spring Boot Actuator 的 /mappings 端點,或是 IDE 幫你列。

還有一個隱藏成本。加端點變得太容易,容易到大家隨手就加,結果同一個專案裡 /getUser/user/query/users 三種命名風格並存,沒人統一。工具把摩擦降到零的時候,紀律就得由你自己補上。註解的條件也一樣,paramsheadersconsumes 疊太多,路由會變得比 if-else 還難讀。方便和隱晦,常常是同一枚硬幣的兩面。

真正被換掉的東西

回到開頭那張分派表。

Servlet 的路由是命令式的:你寫程式,一步步告訴機器「先看路徑、再看方法、然後 if 這個 else 那個」,分派的流程是你親手跑的。Spring MVC 的路由是宣告式的:你只描述「這個方法負責 POST 的 /users、只收 JSON」,至於怎麼從一大堆請求裡把對的那個送過來,交給框架。

差別不在少打幾行 XML,在於「這個請求該給誰」這個決策,從每次請求都要跑一遍的 runtime if-else,被搬到了啟動時建好、之後只查不改的一張表。你腦子裡不用再存那張表,程式裡也不用再跑那套分岔。

所以下次有人問你 @RequestMapping 好在哪,別只回「比較簡潔」。它真正做的事,是把路由這件事從「你要親自分派的動作」變成「你只要描述的事實」。能被描述清楚、交出去的東西,就不必再由你每次親手做一遍。這才是那一行註解換掉的東西。