發(fā)路徑對齊實戰(zhàn))
1. 從一次線上404說起context-path與網(wǎng)關(guān)路由的碰撞事情發(fā)生在某個再普通不過的周三下午。我們團隊維護的一套微服務(wù)系統(tǒng)剛完成一輪迭代把其中一個核心業(yè)務(wù)服務(wù)從單體拆成了獨立服務(wù)部署到測試環(huán)境后前端同學(xué)反饋所有經(jīng)過網(wǎng)關(guān)訪問這個服務(wù)的接口全部返回404但繞過網(wǎng)關(guān)直接訪問服務(wù)實例的端口接口又是通的。這個現(xiàn)象非常典型——直連能通、過網(wǎng)關(guān)就404幾乎可以斷定問題出在網(wǎng)關(guān)的轉(zhuǎn)發(fā)環(huán)節(jié)而不是業(yè)務(wù)服務(wù)本身。我們這套系統(tǒng)用的是 Spring Cloud Gateway 做統(tǒng)一入口服務(wù)注冊發(fā)現(xiàn)走的是注冊中心路由配置一部分是靜態(tài)的 YAML一部分是動態(tài)下發(fā)的。出問題的這個服務(wù)恰好是唯一一個在application.yml里配置了server.servlet.context-path的服務(wù)。排查過程不算復(fù)雜但背后牽扯到的知識點卻相當(dāng)密集網(wǎng)關(guān)的斷言Predicate怎么匹配路徑、StripPrefix過濾器怎么裁剪前綴、context-path在服務(wù)端是怎么參與路徑解析的、注冊中心里注冊的到底是哪個路徑。這幾個環(huán)節(jié)只要有一個對不上404 就會如約而至。我把這次排查的完整鏈路、每個環(huán)節(jié)的判斷依據(jù)、以及最后落地的幾種修復(fù)方案整理出來希望能幫到正在被同類問題折磨的同學(xué)。這篇文章適合三類人看一是剛接觸 Spring Cloud Gateway、對路由匹配機制還不太熟的同學(xué)二是正在做服務(wù)拆分、被路徑問題卡住的開發(fā)者三是想系統(tǒng)梳理網(wǎng)關(guān)路徑轉(zhuǎn)發(fā)鏈路的架構(gòu)同學(xué)。我會盡量把每個為什么講透而不是只丟一個結(jié)論讓你抄。2. 先搞清楚404到底是誰返回的遇到404第一反應(yīng)不應(yīng)該是改配置而是先確認這個404是誰吐出來的。是網(wǎng)關(guān)自己沒匹配到路由返回的還是網(wǎng)關(guān)匹配到了路由、轉(zhuǎn)發(fā)給下游服務(wù)后下游服務(wù)返回的這兩種情況的排查方向完全不同。2.1 網(wǎng)關(guān)層404與下游404的區(qū)分方法最直接的辦法是看響應(yīng)體和響應(yīng)頭。Spring Cloud Gateway 在找不到匹配路由時默認返回的是一個比較干凈的404響應(yīng)體通常為空或者只有極簡內(nèi)容而且不會帶有下游服務(wù)特有的響應(yīng)頭。而下游服務(wù)返回的404往往會帶上該服務(wù)自己的錯誤結(jié)構(gòu)比如統(tǒng)一的 JSON 錯誤碼、Content-Type是application/json、甚至帶有服務(wù)名標(biāo)識。另一個更可靠的手段是打開網(wǎng)關(guān)的日志。把org.springframework.cloud.gateway這個包的日志級別調(diào)到DEBUG你會看到類似Route matched或者No RoutePredicateHandlerMapping found這樣的關(guān)鍵信息。如果日志里壓根沒有路由匹配成功的記錄那就是網(wǎng)關(guān)層沒匹配上如果匹配上了、轉(zhuǎn)發(fā)了但下游返回404那問題就在下游的路徑解析上。我們當(dāng)時的情況是網(wǎng)關(guān)日志顯示路由匹配成功了請求也確實轉(zhuǎn)發(fā)到了下游服務(wù)但下游返回了404。這就把問題范圍一下子縮小到了轉(zhuǎn)發(fā)過去的路徑和下游期望的路徑不一致上。2.2 用直連對比法快速定位我習(xí)慣用一個笨但極其有效的辦法拿到網(wǎng)關(guān)轉(zhuǎn)發(fā)時實際使用的目標(biāo) URL然后手動用這個 URL 去直連下游服務(wù)。怎么拿到實際轉(zhuǎn)發(fā)的 URL在網(wǎng)關(guān)日志里DEBUG級別會打印出轉(zhuǎn)發(fā)目標(biāo)地址或者臨時加一個全局過濾器把ServerWebExchange里的GATEWAY_REQUEST_URL_ATTR打出來。假設(shè)網(wǎng)關(guān)配置的路由是/api/order/**轉(zhuǎn)發(fā)到order-service而order-service配置了context-path: /order。那么網(wǎng)關(guān)轉(zhuǎn)發(fā)過去的路徑可能是/api/order/list但下游服務(wù)實際期望的是/order/api/order/list或者/order/list——具體是哪個取決于StripPrefix有沒有配、配了幾位。直連測試時你分別拼出這幾種路徑去請求哪個返回200哪個就是下游真正期望的路徑。這一步做完問題基本就水落石出了。提示直連測試時記得帶上和網(wǎng)關(guān)轉(zhuǎn)發(fā)時一致的請求頭尤其是Host和Content-Type有些服務(wù)對Host有校驗會干擾判斷。3. context-path在服務(wù)端到底做了什么很多同學(xué)對context-path的理解停留在給接口加個前綴但它在 Servlet 容器層面到底怎么參與路徑解析其實直接決定了網(wǎng)關(guān)該怎么配。這里必須把原理講清楚否則你只是記住了要加個 StripPrefix換個場景又會懵。3.1 Servlet容器中的contextPath與servletPath在 Servlet 規(guī)范里一個請求的路徑被拆成三部分contextPath、servletPath、pathInfo。假設(shè)請求是/order/api/list而應(yīng)用配置了context-path: /order那么容器解析結(jié)果是contextPath /orderservletPath /api/list在 Spring MVC 里通常整體作為映射路徑處理pathInfo一般為空。關(guān)鍵點在于Spring MVC 的DispatcherServlet在做 HandlerMapping 時用的是去掉contextPath之后的路徑。也就是說你的 Controller 上寫RequestMapping(/api/list)它匹配的是/api/list而不是/order/api/list。context-path是容器層面的前綴不參與 Controller 的映射匹配。這就解釋了一個常見困惑為什么我配了context-pathController 里卻不用寫這個前綴因為容器已經(jīng)幫你剝掉了。3.2 網(wǎng)關(guān)轉(zhuǎn)發(fā)時路徑是怎么拼的網(wǎng)關(guān)這邊路由配置里的uri指向的是服務(wù)實例地址比如lb://order-service。當(dāng)請求/api/order/list進來網(wǎng)關(guān)匹配到路由后默認會把完整的原始路徑轉(zhuǎn)發(fā)給下游除非你配置了StripPrefix之類的過濾器去裁剪。所以默認情況下下游收到的是/api/order/list。但下游服務(wù)配了context-path: /order容器會認為contextPath應(yīng)該是/order可實際請求路徑開頭是/api對不上。這時候容器會怎么處理它會認為這個請求不屬于本應(yīng)用的 context直接返回404。這就是我們那次問題的根因——網(wǎng)關(guān)轉(zhuǎn)發(fā)的路徑?jīng)]有包含下游的 context-path 前綴導(dǎo)致下游容器拒絕處理。3.3 一個容易混淆的等價關(guān)系這里有個等價關(guān)系必須記住下游服務(wù)配了context-path: /order等價于所有接口的訪問路徑前面都要加/order。網(wǎng)關(guān)要做的就是保證轉(zhuǎn)發(fā)過去的路徑以/order開頭。有兩種達成方式一是路由的Path斷言本身就帶/order前綴轉(zhuǎn)發(fā)時原樣帶過去二是斷言不帶但通過RewritePath或SetPath過濾器在轉(zhuǎn)發(fā)前把前綴補上。選哪種取決于你的路由規(guī)劃風(fēng)格后面會詳細對比。4. 路由斷言、StripPrefix與context-path的三方博弈把這三個東西放在一起看很多404的成因就一目了然了。它們之間的關(guān)系可以用一句話概括斷言決定什么樣的請求進來StripPrefix 決定轉(zhuǎn)發(fā)前砍掉幾段context-path 決定下游要求路徑以什么開頭。三者必須自洽。4.1 StripPrefix的工作原理與計數(shù)陷阱StripPrefix1的意思是把請求路徑按/分割后砍掉最前面的1段。注意是段不是字符。/api/order/list按/分割后是[, api, order, list]開頭的空串不算有效段StripPrefix1砍掉api剩下/order/list。計數(shù)陷阱在于很多人以為StripPrefix1是砍掉第一個斜杠前的內(nèi)容其實它砍的是第一個有效路徑段。如果你的斷言是/api/**那StripPrefix1正好砍掉api但如果斷言是/api/v1/**你想砍掉api/v1兩段就得寫StripPrefix2。數(shù)錯段數(shù)轉(zhuǎn)發(fā)路徑就會多一段或少一段404 隨之而來。4.2 三種典型配置組合的對照我把常見的配置組合和結(jié)果整理成表方便你對照自己的場景斷言 PathStripPrefix下游 context-path下游收到路徑結(jié)果/api/order/**0不配/order/api/order/list404前綴不對/api/order/**1/order/order/list200正確/api/order/**2/order/list404砍多了/order/**0/order/order/list200正確/api/**1/order/order/list若原路徑為/api/order/list200正確看這張表你會發(fā)現(xiàn)核心不是 StripPrefix 配幾而是轉(zhuǎn)發(fā)后的路徑是否以 context-path 開頭。只要滿足這個條件怎么配都行。理解了這一點你就不會再死記context-path 要配 StripPrefix1這種口訣了。4.3 為什么直連能通會誤導(dǎo)人直連測試時你手動輸入的路徑往往已經(jīng)帶了/order前綴因為你從服務(wù)日志或接口文檔里看到的就是這個所以能通。但網(wǎng)關(guān)轉(zhuǎn)發(fā)時用的是另一套路徑拼接邏輯兩者不一致就出現(xiàn)了直連通、過網(wǎng)關(guān)404的詭異現(xiàn)象。這不是矛盾而是你測試時用的路徑和網(wǎng)關(guān)實際轉(zhuǎn)發(fā)的路徑根本不是一個。排查時一定要以網(wǎng)關(guān)實際轉(zhuǎn)發(fā)的 URL為準(zhǔn)而不是以你腦子里以為的 URL 為準(zhǔn)。5. 完整排查鏈路從404到定位根因這一節(jié)我把當(dāng)時的排查步驟完整還原你可以照著這個順序走一遍基本能覆蓋90%以上的同類問題。5.1 第一步確認404來源打開網(wǎng)關(guān) DEBUG 日志搜索路由匹配記錄。如果沒匹配上檢查斷言的Path是否寫對、路由是否真的加載了動態(tài)路由的話檢查配置中心有沒有下發(fā)成功。如果匹配上了進入下一步。5.2 第二步打印實際轉(zhuǎn)發(fā)URL臨時加一個全局過濾器優(yōu)先級設(shè)低一點Ordered.LOWEST_PRECEDENCE在filter方法里打印exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)。這個屬性里就是網(wǎng)關(guān)最終要請求的完整 URL。把它記下來。5.3 第三步直連驗證下游期望路徑用上一步拿到的 URL 的 path 部分直連下游服務(wù)實例。如果404說明下游期望的路徑和這個不一樣。這時候去看下游服務(wù)的context-path配置以及 Controller 的實際映射路徑反推出下游真正期望的完整路徑。5.4 第四步反推網(wǎng)關(guān)配置該怎么改對比網(wǎng)關(guān)實際轉(zhuǎn)發(fā)路徑和下游期望路徑差異在哪就改哪。如果是少了 context-path 前綴要么給斷言加上這個前綴要么用RewritePath補上如果是多了前綴用StripPrefix砍掉。改完重啟網(wǎng)關(guān)再走一遍驗證。5.5 第五步回歸測試所有相關(guān)路由改完一個服務(wù)的路由后別忘了檢查同一網(wǎng)關(guān)下其他服務(wù)的路由有沒有受影響。尤其是當(dāng)你調(diào)整了全局過濾器或公共路由配置時很容易誤傷。我們當(dāng)時就因為改了一個StripPrefix的默認值導(dǎo)致另一個服務(wù)的路徑被多砍了一段又排查了半小時。注意動態(tài)路由場景下改完配置后要確認配置中心推送成功、網(wǎng)關(guān)確實刷新了路由。有些團隊的路由刷新有緩存或延遲改完立刻測可能還是舊配置白白浪費時間。6. 幾種修復(fù)方案的取舍與落地定位到根因后修復(fù)方案不止一種。選哪種取決于你的路由規(guī)劃風(fēng)格和團隊約定。我把幾種方案擺出來說說各自的適用場景和坑。6.1 方案一斷言帶context-path前綴不裁剪這是最直觀的方案。路由斷言直接寫成/order/**轉(zhuǎn)發(fā)時不裁剪下游收到/order/list正好匹配 context-path。優(yōu)點是配置簡單、路徑一目了然缺點是網(wǎng)關(guān)對外暴露的路徑帶上了服務(wù)內(nèi)部的前綴如果這個前綴是內(nèi)部命名、對外不友好就不太合適。另外如果多個服務(wù)都想用統(tǒng)一的對外前綴比如都走/api這個方案就不好統(tǒng)一了。6.2 方案二斷言用對外前綴StripPrefix裁剪后補context-path這是最常見的方案。斷言寫/api/order/**StripPrefix1砍掉api剩下/order/list正好匹配。但如果對外前綴有多段比如/api/v1/order/**就要StripPrefix2。這個方案的坑在于段數(shù)容易數(shù)錯而且一旦對外前綴調(diào)整所有相關(guān)路由的 StripPrefix 都要跟著改維護成本偏高。6.3 方案三用RewritePath精確重寫RewritePath用正則做路徑重寫最靈活也最精確。比如filters: - RewritePath/api/order/(?segment.*), /order/$\{segment}這個配置把/api/order/xxx重寫成/order/xxx不管后面跟什么都能正確拼接。優(yōu)點是意圖明確、不受段數(shù)影響缺點是要寫正則對不熟悉正則的同學(xué)有一定門檻而且正則寫錯會導(dǎo)致更難排查的問題。6.4 方案四統(tǒng)一在網(wǎng)關(guān)層剝離context-path如果團隊約定所有服務(wù)都不配context-path把前綴統(tǒng)一交給網(wǎng)關(guān)管理那問題從根上就不存在了。這是最干凈的方案但需要推動所有服務(wù)改造存量系統(tǒng)遷移成本高。新項目建議優(yōu)先考慮這種約定能省掉大量路徑對齊的麻煩。我個人在項目里的選擇是新服務(wù)一律不配 context-path對外路徑由網(wǎng)關(guān)統(tǒng)一規(guī)劃存量服務(wù)如果已經(jīng)配了用 RewritePath 精確重寫而不是用 StripPrefix 數(shù)段數(shù)。這樣既避免了段數(shù)陷阱也讓路徑映射關(guān)系在配置里一眼可見。7. 幾個容易忽略的細節(jié)與踩坑記錄排查完這次問題后我又陸續(xù)遇到過幾個相關(guān)的坑一并記下來都是文檔里不太會寫、但實際很常見的。7.1 注冊中心里注冊的路徑帶不帶context-path有些注冊中心的健康檢查或服務(wù)元數(shù)據(jù)里會帶上 context-path有些不會。如果你的網(wǎng)關(guān)是基于服務(wù)實例的元數(shù)據(jù)來拼路徑的比如自定義了負載均衡策略那注冊的路徑信息就會影響轉(zhuǎn)發(fā)結(jié)果。我們有一次就是因為服務(wù)注冊時上報的路徑帶了 context-path而網(wǎng)關(guān)又額外補了一次導(dǎo)致路徑里出現(xiàn)了兩個/order直接404。排查時記得看一眼注冊中心里這個服務(wù)的元數(shù)據(jù)。7.2 大小寫與末尾斜杠的隱形差異/Order和/order在默認配置下是不等價的/order/list和/order/list/也可能被下游區(qū)別對待。網(wǎng)關(guān)轉(zhuǎn)發(fā)時如果路徑末尾多了或少了一個斜杠下游的路徑匹配就可能失敗。這類問題在日志里看起來路徑明明一樣但就是404非常折磨人。建議在網(wǎng)關(guān)層加一個規(guī)范化過濾器統(tǒng)一處理末尾斜杠和大小寫。7.3 全局過濾器順序?qū)β窂降挠绊懭绻阃瑫r用了StripPrefix和RewritePath它們的執(zhí)行順序會影響最終路徑。過濾器是按Order排序執(zhí)行的順序不對可能先重寫再裁剪結(jié)果完全不是你想要的。排查時把相關(guān)過濾器的Order值打出來確認執(zhí)行順序符合預(yù)期。7.4 本地能通、測試環(huán)境不通的環(huán)境差異本地開發(fā)時很多人圖省事不配 context-path或者用不同的路由配置導(dǎo)致本地一切正常一上測試環(huán)境就404。這類問題的根源是環(huán)境間配置不一致。建議把路由配置納入版本管理各環(huán)境用同一份基礎(chǔ)配置只覆蓋必要的差異項減少這類環(huán)境玄學(xué)。8. 把路徑對齊這件事做成規(guī)范踩過幾次坑之后我在團隊里推了一套簡單的路徑規(guī)范執(zhí)行下來同類問題基本絕跡了。核心就三條第一對外路徑統(tǒng)一由網(wǎng)關(guān)規(guī)劃服務(wù)內(nèi)部不感知對外前綴第二新服務(wù)不配 context-path如果非要配必須在服務(wù)文檔里顯式標(biāo)注并在網(wǎng)關(guān)路由配置里加注釋說明第三所有涉及路徑裁剪或重寫的路由必須寫清楚轉(zhuǎn)發(fā)前路徑和轉(zhuǎn)發(fā)后路徑的對照注釋方便后來人一眼看懂。這三條看起來簡單但真正落地后路徑相關(guān)的404從每月好幾次降到了幾乎為零。路徑問題本質(zhì)上不是技術(shù)難題而是約定和溝通問題——只要轉(zhuǎn)發(fā)前后的路徑關(guān)系在配置里寫得明明白白誰來看都不會懵。最后分享一個我常用的小技巧在網(wǎng)關(guān)里加一個只在測試環(huán)境生效的調(diào)試端點輸入任意路徑返回它會匹配到哪條路由、最終轉(zhuǎn)發(fā)成什么 URL。這個端點幫我們省下了大量改配置-重啟-測試的循環(huán)時間定位路徑問題的效率提升非常明顯。如果你也在維護網(wǎng)關(guān)強烈建議做一個類似的工具投入很小回報很大。