在京東到家商家中心系統中,商家提出了要在 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 對 Java NIO 做了封裝,讓開發者更多關注業務,降低開發成本。
很多著名的 RPC 框架都采用了 netty 作為傳輸層,友好的 API,功能強大,內置了很多編解碼協議,實現 websocket 協議也是十分方便。
那我們橫向比較一下這些框架:
所以在選型方面我們還是定位在 socketIO 與 netty 上面,在兼顧擴展性與靈活性的同時,我們也考慮到 netty 可以提供 http 的功能。
最終我們選擇了使用 netty,當然 socketIO 封裝了很多功能,也是十分強大,相比較來說 netty 更適合我們,比較輕量。
Netty 的特性
netty 具有異步非阻塞的特性,傳統 IO 是面向流的,NIO 是面向緩沖區的,這也是它的非阻塞原因所在。
netty 的線程模型如圖所示:
這種模型就是我們常說的 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 之間建立路由層,路由層的主要職責有:
收集集群存活信息
記錄落點,就是落在哪一臺機器上面
接收消息與分發消息
有了這三種能力,我們就可以輕松的指定信息分發策略。我們希望使用 http 協議來路由,這就需要 netty 有 http 短連接接收的能力 ,所以 netty 整體上需要長短連接兩種能力。
下面是部分代碼:
netty 啟動類,我們通過 spring 來啟動 netty,因為 netty 啟動會阻塞主線程,所以需要在子線程中來啟動 netty,下面是啟動參數。
接著來寫我們的 ChannelInitializer,HttpServerCodec 為編解碼器,WSServerProtocolHandler 為 websocket 協議握手。
我們更關注業務層面自定義的兩個 hander,httpRequestHandler,authorizeHandler。
httpRequestHandler 的作用是處理 URL 是否合法,接收參數。
httpRequestHandler 此方法中也可以根據 URL 來過濾,自定義自己的短連接請求。
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 中的信息,這就看具體業務了。
示例代碼如下:
這個 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 端的自動打印,經過長時間的內部測試,服務端與客戶端通信穩定,我們將灰度商家做用戶體驗。
在特定的場景下,選擇適當的技術會提高我們的效率,否則會適得其反。
選擇長連接,大家可以把握這三個大原則:
服務端是否需要主動推送數據到客戶端以實現控制的效果。
對于實時性的要求是否苛刻。
對于客戶端是否需要關注它在線狀態的實時變化。
評論
查看更多