最近在看spring cloud zuul(版本Finchley.SR1)的源代碼掀亩,一不小心還看到了個bug(我認(rèn)為是哈)马篮,更神奇的是,這個bug一年前已經(jīng)有人提了issue喜最,并提交了PR(竟然搶在我之前了)芥永。但是現(xiàn)在還沒有合并進(jìn)來,7天前被管理員放進(jìn)了icebox杰妓,這是什么操作藻治?我不太清楚?是說會拿出來合并么稚失?還是啥栋艳?哪位有經(jīng)驗的同學(xué)知道麻煩告訴我。那這整個事情是怎么樣的呢句各?這都得從Zuul路由管理與SpringMVC的請求接入說起
起源
我們知道在配置zuul property的時候吸占,當(dāng)配置了一個route的path之后,zuul就會自動讀取這些路由規(guī)則并進(jìn)行配置凿宾。那這一切是怎么做到的呢矾屯?我們首先看我們啟用@EnableZuulProxy啟動zuul之后spring具體做了什么:
這個配置類引入了一個ZuulProxyMarkerConfiguration
而這個類只是引入了一個Marker Bean,通過find usage我們看到
這個autoConfiguration類是通過spring.factories來注入的自動配置初厚。而這個類他繼承了ZuulServerAutoConfiguration:
這個類中我們注意的是一個SimpleRouteLocator件蚕,這個類注入了zuulProperties:
這個類就是讀取的配置文件中的zuul相關(guān)的properties,那注入這個properties的SimpleRouteLocator就很有可能是生成route的地方产禾。
SimpleRouteLocator類中的代碼也表明了我的判斷排作。到這里我們知道了配置是怎么映射到route規(guī)則的,當(dāng)然僅僅這一點(diǎn)遠(yuǎn)遠(yuǎn)不夠亚情,我們知道妄痪,當(dāng)我們定義了一個route規(guī)則之后,我們可以直接請求訪問這個route的path來達(dá)到我們想要到達(dá)的serviceId或者url而不需要定義任何controller楞件,這個spring是如何做到的呢衫生?
引路人
針對上面的問題,我重新回到之前提到的幾個配置類土浸,發(fā)現(xiàn)了如下信息:
我們知道本身Zuul是通過servlet來做的入口罪针,而我們上圖看到的這個ZuulController
我們可以發(fā)現(xiàn)他就是一個Servlet的包裝,通過將請求代理給ZuulServlet來實現(xiàn)zuul的功能黄伊,可見在這個Controller前面肯定需要一個組件去把請求forward給它泪酱,這個組件很有可能就是之前看到的ZuulHandlerMapping,因為它的初始化使用到了zuulController
通過查看ZuulHandlerMapping繼承關(guān)系和注釋我們看到了他繼承了AbstractUrlHandlerMapping抽象類,熟悉SpringMVC的同學(xué)知道西篓,對于請求入口或者我們自己編寫的Controller方法愈腾,SpringMVC會生成HandlerMapping示例,DispatcherServlet通過遍歷spring上下文中已經(jīng)存在的HandlerMapping來進(jìn)行http請求的查找匹配岂津,執(zhí)行鏈路的組建和請求的執(zhí)行,我給大家列出來DispatcherServlet中的代碼悦即,具體的調(diào)用鏈路和原理有興趣的同學(xué)可以下來看看:
針對ZuulHandlerMapping中的代碼吮成,我們目前只需要知道,對于每次request請求進(jìn)來辜梳,dispatcherServlet都會調(diào)用ZuulHandlerMapping的lookupHandler方法粱甫,來查找是否有合適的zuul route規(guī)則,如果有就將請求導(dǎo)入給ZuulController作瞄,那么我們再來仔細(xì)看看具體的方法:
之前一通常規(guī)操作茶宵,然后通過一個volatile變量dirty判斷目前的route是否有變更,如果有就重新注冊路由信息并且重置dirty變量為false宗挥,最后調(diào)用父類的loopupHandler乌庶。dirty變量默認(rèn)為true,就保證在請求進(jìn)來的時候肯定會有一個初始化的過程契耿,那我們進(jìn)入registerHandlers方法看看
這里我們就明白了瞒大,這個方法對當(dāng)前所有的routes信息都調(diào)用父類的registerHandler來注冊能處理的path。從而完成了整個調(diào)用鏈路的匹配與搭建搪桂。ZuulHandlerMapping就像是一個引路人一樣指引每一個能被zuul處理的request到ZuulController中透敌。一切看起來都非常美好對吧。但是善于思考的同學(xué)又會有新的問題了踢械,dirty只會在初始化的時候使用么酗电?routes可以中途刷新么?答案是可以的内列。
Bad Smell
我注意到ZuulHandlerMapping類中有這樣一個方法:
通過find usage我們知道這個方法會在一些EventListener中被調(diào)用
通過上述兩份代碼和查閱Spring Cloud Zuul的文檔我知道撵术,如果你想要使得你的RouteLocator能夠可以更新,那就讓你的RouteLocator類實現(xiàn)RefreshableRouteLocator接口并實現(xiàn)refresh方法德绿,然后在每次需要更新的時候向spring 上下文發(fā)布RoutesRefreshedEvent就行了荷荤,剩下的一切就交給剛剛看到的代碼和spring做就行了。這一切看上去也很perfect移稳。但是我總覺得哪里不對蕴纳,感覺聞到了怪怪的味道。這邊再給大家仔細(xì)列一下代碼:
當(dāng)一個更新事件發(fā)起的時候个粱,setDirty方法會先設(shè)置dirty為true古毛,然后調(diào)用routeLocator的refresh方法,這沒問題。在一個請求進(jìn)來的時候稻薇,會檢查dirty是否為true嫂冻,如果有,則重新注冊path和handler塞椎,這似乎也沒問題桨仿,還用了double check。但是這兩個加在一起案狠,是否存在這樣的場景服傍,當(dāng)一次routeLocator refresh的時間比較長而這時候zuul的請求load比較高的時候,一個請求進(jìn)來發(fā)現(xiàn)此時需要重新注冊handler骂铁,但這是routes信息并沒有完成刷新吹零,或者說根本沒有開始刷新,那這時候注冊的拉庵,還是刷新前的老的數(shù)據(jù)灿椅,也就是說,更新之后的路由信息完完全全沒有被注冊到springmvc的處理鏈路中钞支,整個網(wǎng)關(guān)并不會處理新增加的path茫蛹,或者還會接入已經(jīng)刪除的path,這是個bug伸辟!歸納起來麻惶,就是當(dāng)registerHandler的調(diào)用線程優(yōu)先于routeLocator的refresh的調(diào)用,那么路由數(shù)據(jù)的更新就會失效并且這是不可恢復(fù)的信夫!我在github上查找有關(guān)dirty的issue窃蹋,也發(fā)現(xiàn)了下面的記錄
https://github.com/spring-cloud/spring-cloud-netflix/pull/2259
這個issue跟我描述的基本上一毛一樣,并且也提了PR静稻,也就是本文最開始所提到(有木有哪位老鐵告訴我啥叫icebox熬弧)。
當(dāng)然BB是不夠的振湾,我下面會通過一個例子來闡述這個bug:
證據(jù)
下面所講的代碼都已經(jīng)提交的github:
https://github.com/ro9er/zuul-dirty-bug-sample
首先我們定義一個RefreshableRouteLocator
這個實現(xiàn)其實很簡單杀迹,用Entiry抽象了路由信息,并且在每次刷新的時候重新完成Entiry到Route的映射工作押搪,并且實現(xiàn)了SimpleRouteLocator的getRoutes和getMatchingRoute方法树酪,getRoutes在我們之前看到的ZuulHandlerMapping中registerHandlers中被調(diào)用,用來注冊handler信息大州。getMatchingRoute方法在Spring Cloud Zuul實現(xiàn)的PreDecorationFilter中被調(diào)用续语,用來確定一個具體的route,并設(shè)置到跳轉(zhuǎn)規(guī)則中厦画,這里就不具體展開了疮茄。這里我在refresh方法中注釋了線程sleep10秒的操作滥朱,后面我會打開它。當(dāng)這個routeLocator初始化的時候只有一個/baidu路由規(guī)則跳轉(zhuǎn)到百度
然后我實現(xiàn)了一個Controller:
這個controller暴露一個刷新路由信息力试,這里我們看到它會向我之前定義的routeLocator增加一條/163跳轉(zhuǎn)網(wǎng)易的規(guī)則徙邻,并且發(fā)出一個RoutesRefreshedEvent,從而觸發(fā)路由規(guī)則觸發(fā)流程畸裳。然后我們來看看整個調(diào)用場景:
通過上面整個場景流程我們知道缰犁,在在開始啟動的時候,只有/baidu規(guī)則有效躯畴,/163會直接404民鼓,在我們刷新路由之后,在此訪問/163蓬抄,成功跳轉(zhuǎn)到網(wǎng)易,證明我們的刷新機(jī)制是生效了夯到。那么我們現(xiàn)在來復(fù)現(xiàn)bug嚷缭,為了讓這個bug比較容易的復(fù)現(xiàn),我在refresh方法中打開了線程sleep 10s的操作耍贾,使得我們的刷新路由操作會延遲執(zhí)行:
再次重復(fù)之前的流程阅爽,重復(fù)的步驟我就不貼圖了,我們知道在增加了這個線程sleep的情況下荐开,我們的refreshRoutes接口會變慢付翁,當(dāng)我們在這個接口執(zhí)行的過程中我們調(diào)用一個/163,會因為dirty重新出發(fā)regsiterHandler,并且返回404(顯然的晃听,因為現(xiàn)在根本沒有增加163這個規(guī)則)百侧,然后我們在refreshRoutes返回之后再次執(zhí)行/163:
從上述的調(diào)用可以發(fā)現(xiàn),刷新之后新的路由并沒有生效能扒,而且這個除非你重新調(diào)用一次refresh佣渴,不然不可能恢復(fù)。然鵝初斑,就算你調(diào)用refresh辛润,也不一定能夠恢復(fù),因為有可能下次request進(jìn)來又把你沖掉了见秤。
解決方案
問題明確了砂竖,怎么解決呢?如之前PR中所說的鹃答,可以把setDirty中的dirty賦值操作放到最后:
這個修改應(yīng)該就能解決這個問題乎澄,但是現(xiàn)在并沒有合并進(jìn)來,還有沒有其他辦法呢挣跋?
我的辦法是增加一個Listener三圆,并且通過Ordered接口保證第一個執(zhí)行,在消息處理里面手動觸發(fā)refresh,不過弊端就是refresh會調(diào)用兩次
親測可用舟肉。
結(jié)語
到此整個bug的出現(xiàn)我大概已經(jīng)說明清楚了修噪,并且順帶把zuul的handler mapping流程也梳理了一遍,大家有什么問題歡迎留言路媚,希望能跟大家一起交流黄琼,共同進(jìn)步。