京東到家基於netty與websocket的實踐

背景
在京東到家商家中心繫統中,商家提出在 Web 端實現自動打印的需求,不需要人工盯守點擊打印,直接打印小票,以節約人工成本。

解決思路

關於問題的思考邏輯:

第一種:想到的是可以用ajax來輪詢服務端獲取最新訂單,也就是pull。

第二種:我們是否可以用類似推送的設計來實現,也就是push。

兩種思路我們評估其優缺點:

ajax方式實現簡單,只需要定時從服務端pull數據即可,但也增加了很多次無效的輪詢, 無形中增加服務端無效查詢。

push方式實現稍複雜,需要服務端與PC端保持連接,這就需要建立長連接,最終通過長連接的方式來實現push效果。

經過討論,我們選擇了第二種,訂單中心生產出的新訂單,通過MQ的方式推送給web端,最終獲得一個比較好的用戶體驗。

方案介紹

關於長連接方案的選擇,我們參考了不少帖子,最終選擇使用websocket協議來實現長連接,類似場景如IM,服務端即時推送等都使用了這個協議。

接下來我們比較一下websocket的框架,比較主流的有netty、tomcat、socketIO 三個框架。

基於支持websocket的容器,開發簡單,例如tomcat,但在高併發的支持不是很好,連接的時候容易連接斷開,還有就是依賴容器。

netty-socketIO是在netty4基礎之上做了一層封裝,效率如同netty一樣,是一個全平臺方案,友好的API,京東的logbook也是用了socketIO來傳遞日誌,也是我們的一個備選方案。

netty是業內主流的NIO框架,netty對javaNIO做了封裝,讓開發者更多關注業務,降低開發成本,很多著名的RPC框架都採用了netty作爲傳輸層,友好的API,功能強大,內置了很多編解碼協議,實現websocket協議也是十分方便。

那我們橫向比較一下這些框架。

京東到家基於netty與websocket的實踐
所以在選型方面我們還是定位在socketIO 與 netty 上面,在兼顧擴展性與靈活性的同時,我們也考慮到netty可以提供http的功能,最終我們選擇了使用netty,當然socketIO封裝了很多功能,也是十分強大,相比較來說netty更適合我們,比較輕量。

netty的特性
netty具有異步非阻塞的特性,傳統IO是面向流的,NIO是面向緩衝區的,這也是它的非阻塞原因所在。

netty的線程模型如圖所示:

京東到家基於netty與websocket的實踐
這種模型就是我們常說的Reactor模型,boss線程其實是一個獨立的NIO線程池,用於接收client請求,默認線程池大小爲1,worker線程池用於處理具體的讀寫操作,默認線程池大小爲2*cpu個數。

在上述模型中要特別注意ExecutionHandler,ExecutionHandler是運行在worker線程中的,所以耗時的操作最好在線程池中運行, 比如IO或者計算,不然會影響整個netty的吞吐。

瞭解了這些,我們根據自己的業務設計出流程如下圖所示:

步驟(1) web端請求服務端進行註冊,註冊成功保持長連接。

步驟(2)服務端發送MQ。

步驟(3)netty將收到的消息推送給web端。

步驟(4)web端調用打印控件進行打印,打印控件需提前安裝好(打印控件是pc上安裝的一個驅動程序,用過JS方式來調用)。

如果調用JS成功,控件將把打印信息放入打印隊列,如果不成功,重複步驟(4)

京東到家基於netty與websocket的實踐
當然現在的結構只是單機版,不滿足生產條件,那將來的結構可能會演變成如下圖所示:

京東到家基於netty與websocket的實踐

我們會在服務端與netty之間建立路由層,路由層的主要職責:

第一:收集集羣存活信息。

第二:記錄落點,就是落在哪一臺機器上面。

第三:接收消息與分發消息。

有了這三種能力,我們就可以輕鬆的指定信息分發策略。這裏我們希望使用http協議來路由,所以就需要netty有http短連接接收的能力 ,所以netty整體上需要長短連接兩種能力。

講了這麼多,還是來點乾貨,下面是部分代碼。

netty啓動類,我們通過spring來啓動netty,因爲netty啓動會阻塞主線程,所以需要在子線程中來啓動netty,下面是啓動參數。

京東到家基於netty與websocket的實踐
接着來寫我們的ChannelInitializer,HttpServerCodec爲編×××,WSServerProtocolHandler爲websocket協議握手,其中我們更關注業務層面自定義的兩個hander,httpRequestHandler,authorizeHandler。

京東到家基於netty與websocket的實踐
httpRequestHandler的作用是處理url是否合法,接收參數,httpRequestHandler此方法中也可以根據URI來過濾,自定義自己的短連接請求。

京東到家基於netty與websocket的實踐
authorizeHandler的作用是校驗數據是否正確,如果正確會將channel保存到map中,通過map建立起業務ID與通道之間的關係。

校驗的過程我們在authorizeHandler中的channelRead展開,如果未通過,直接關閉當前channel,如果通過校驗,則通過ctx.fireChannelRead(msg);方法將信息傳入下一個handler去處理。

在項目裏主要是以傳遞參數來進行數據校驗的,也就是通過URL傳參來實現。在httpRequestHandler中我們將URL參數set到channel的attr中,並傳遞給了下一個handler,也就是authorizeHandler,所以在authorize方法中我們可以利用get()方法得到參數值,u是經過加密的數據,我們需要在這裏進行解密,解密失敗,可認爲校驗失敗。

當然如果有跨應用的服務,也可以通過Cookie的方式來進行加密串的讀寫,通過request.getHeader 是可以獲取Cookie中的信息,這就看具體業務了,示例代碼如下:

京東到家基於netty與websocket的實踐
這個map 可以理解爲servlet中的session,當有信息需要傳送給某個客戶端時,我們調用map.get(key)方式的到當前該客戶端的channel,調用writeAndFlush方法將信息發送出去,下面舉例通過接收MQ消息後的處理邏輯。

接下來有人可能想到,那如果通道關閉了怎麼辦?map中的channel是不是就失效了呢?那其實我們還需要有一個類似心跳的機制去維護channel,間接的去維護這個map,如果是通道正常關閉,可以通過channelInactive方法來監聽,如果是長時間空閒:在項目中我們使用了增加的IdleStateHandler來處理,通過覆蓋userEventTriggered方法來監聽空閒channel,當某個channel到達我們設置的超時時間時,netty會回調此方法。

至此,核心部分已經處理完成,剩下的就是通過保存的channel來發送信息給客戶端了。

最後在web端,我們採用了 reconnecting-websocket,它是一個小型的 JavaScript 庫,封裝了 WebSocket API, 提供了在連接斷開時自動重連的機制,很能夠幫助我們完成斷開重連的操作。

遇到的問題

經過測試,在ws的uri後面不能傳遞參數,不然在netty實現websocket協議握手的時候會出現斷開連接的情況,針對這種情況在websocketHandler之前做了一層httpHander過濾,將傳遞參數放入channel的attr中,然後重寫request的uri,並傳入下一個管道中,基本上解決這個問題。

在讀寫空閒的時候儘量以發心跳包的方式維護連接,但在客戶端由於網絡不穩定或者是服務端重啓,連接會斷開,瞬間有可能接收不到訂單消息,爲此在客戶端需要實現斷開重連機制,此問題我們採用 reconnecting-websocket的js框架,此框架擴展了原生websocket的實現,做了斷開重連機制,有效的防止斷開後不能及時連接。

在測試過程中由於控件與小票機的問題,可能會出現打印異常或者小票機沒紙的情況,Lodop控件其實是將打印信息放入電腦的打印隊列,如果沒紙了,小票機會報警,再次放入小票紙,打印機會自動打印隊列中的數據。

出現調用控件異常偶爾發生,現在處理辦法是在js中進行了的try catch 如果失敗 進行重試,重試次數自定義,超過重試次數暫不做處理,此處還不太嚴謹,需要在進行優化。

總結

通過上面的實踐,我們基本已經實現了web端的自動打印,經過長時間的內部測試,服務端與客戶端通信穩定,我們將灰度商家做用戶體驗。

在特定的場景下,選擇適當的技術會提高我們的效率,否則會適得其反。選擇長連接,大家可以把握三個大原則:

服務端是否需要主動推送數據到客戶端以實現控制的效果。

對於實時性的要求是否苛刻。

對於客戶端是否需要關注其在線狀態的實時變化。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章