在京東到家商家中心系統(tǒng)中,商家提出了要在 Web 端實(shí)現(xiàn)自動(dòng)打印的需求,不再需要人工盯守點(diǎn)擊打印,直接打印小票,以節(jié)約人工成本。
解決思路
關(guān)于問題的兩種思考邏輯:
可以用 ajax 來輪詢服務(wù)端獲取最新訂單,也就是 pull。
可以用類似推送的設(shè)計(jì)來實(shí)現(xiàn),也就是 push。
我們?cè)u(píng)估了兩種思路的優(yōu)缺點(diǎn):
ajax 方式實(shí)現(xiàn)簡(jiǎn)單,只需要定時(shí)從服務(wù)端 pull 數(shù)據(jù)即可,但也增加了很多次無效的輪詢,即無形中增加服務(wù)端無效查詢。
push 方式實(shí)現(xiàn)稍復(fù)雜,需要服務(wù)端與 PC 端保持連接,這就需要建立長(zhǎng)連接,最終通過長(zhǎng)連接的方式來實(shí)現(xiàn) push 效果。
經(jīng)過討論,我們選擇了第二種,訂單中心生產(chǎn)出的新訂單,通過 MQ 的方式推送給 Web 端,最終獲得一個(gè)比較好的用戶體驗(yàn)。
方案介紹
關(guān)于長(zhǎng)連接方案的選擇,我們參考了不少帖子,最終選擇了使用 websocket 協(xié)議來實(shí)現(xiàn)長(zhǎng)連接,類似場(chǎng)景如 IM,服務(wù)端即時(shí)推送等都使用了這個(gè)協(xié)議。
接下來我們比較一下 websocket 的框架,比較主流的有 netty、tomcat、socketIO 三個(gè)框架:
基于支持 websocket 的容器,開發(fā)簡(jiǎn)單,例如 tomcat,但在高并發(fā)的支持不是很好,連接的時(shí)候容易連接斷開,還有就是依賴容器。
netty-socketIO 是在 netty4 基礎(chǔ)之上做了一層封裝,效率如同 netty 一樣,是一個(gè)全平臺(tái)方案,友好的 API。京東的 logbook 也是用了 socketIO 來傳遞日志,也是我們的一個(gè)備選方案。
netty 是業(yè)內(nèi)主流的 NIO 框架,netty 對(duì) Java NIO 做了封裝,讓開發(fā)者更多關(guān)注業(yè)務(wù),降低開發(fā)成本。
很多著名的 RPC 框架都采用了 netty 作為傳輸層,友好的 API,功能強(qiáng)大,內(nèi)置了很多編解碼協(xié)議,實(shí)現(xiàn) websocket 協(xié)議也是十分方便。
那我們橫向比較一下這些框架:
所以在選型方面我們還是定位在 socketIO 與 netty 上面,在兼顧擴(kuò)展性與靈活性的同時(shí),我們也考慮到 netty 可以提供 http 的功能。
最終我們選擇了使用 netty,當(dāng)然 socketIO 封裝了很多功能,也是十分強(qiáng)大,相比較來說 netty 更適合我們,比較輕量。
Netty 的特性
netty 具有異步非阻塞的特性,傳統(tǒng) IO 是面向流的,NIO 是面向緩沖區(qū)的,這也是它的非阻塞原因所在。
netty 的線程模型如圖所示:
這種模型就是我們常說的 Reactor 模型,boss 線程其實(shí)是一個(gè)獨(dú)立的 NIO 線程池,用于接收 client 請(qǐng)求,默認(rèn)線程池大小為 1,worker 線程池用于處理具體的讀寫操作,默認(rèn)線程池大小為 2*cpu 個(gè)數(shù)。
在上述模型中要特別注意 ExecutionHandler,ExecutionHandler 是運(yùn)行在 worker 線程中的,所以耗時(shí)的操作最好在線程池中運(yùn)行, 比如 IO 或者計(jì)算,不然會(huì)影響整個(gè) netty 的吞吐。
了解了這些,我們根據(jù)自己的業(yè)務(wù)設(shè)計(jì)出流程,如下圖所示:
步驟 1:Web 端請(qǐng)求服務(wù)端進(jìn)行注冊(cè),注冊(cè)成功保持長(zhǎng)連接。
步驟 2:服務(wù)端發(fā)送 MQ。
步驟 3:netty 將收到的消息推送給 Web 端。
步驟 4:Web 端調(diào)用打印控件進(jìn)行打印,打印控件需提前安裝好(打印控件是 PC 上安裝的一個(gè)驅(qū)動(dòng)程序,用過 JS 方式來調(diào)用)。
如果調(diào)用 JS 成功,控件將把打印信息放入打印隊(duì)列,如果不成功,重復(fù)步驟 4。
當(dāng)然現(xiàn)在的結(jié)構(gòu)只是單機(jī)版,不滿足生產(chǎn)條件,那將來的結(jié)構(gòu)可能會(huì)演變成如下圖所示:
我們會(huì)在服務(wù)端與 netty 之間建立路由層,路由層的主要職責(zé)有:
收集集群存活信息
記錄落點(diǎn),就是落在哪一臺(tái)機(jī)器上面
接收消息與分發(fā)消息
有了這三種能力,我們就可以輕松的指定信息分發(fā)策略。我們希望使用 http 協(xié)議來路由,這就需要 netty 有 http 短連接接收的能力 ,所以 netty 整體上需要長(zhǎng)短連接兩種能力。
下面是部分代碼:
netty 啟動(dòng)類,我們通過 spring 來啟動(dòng) netty,因?yàn)?netty 啟動(dòng)會(huì)阻塞主線程,所以需要在子線程中來啟動(dòng) netty,下面是啟動(dòng)參數(shù)。
接著來寫我們的 ChannelInitializer,HttpServerCodec 為編解碼器,WSServerProtocolHandler 為 websocket 協(xié)議握手。
我們更關(guān)注業(yè)務(wù)層面自定義的兩個(gè) hander,httpRequestHandler,authorizeHandler。
httpRequestHandler 的作用是處理 URL 是否合法,接收參數(shù)。
httpRequestHandler 此方法中也可以根據(jù) URL 來過濾,自定義自己的短連接請(qǐng)求。
authorizeHandler 的作用是校驗(yàn)數(shù)據(jù)是否正確,如果正確會(huì)將 channel 保存到 map 中,通過 map 建立起業(yè)務(wù) ID 與通道之間的關(guān)系。
校驗(yàn)的過程我們?cè)?authorizeHandler 中的 channelRead 展開,如果未通過,直接關(guān)閉當(dāng)前 channel。
如果通過校驗(yàn),則通過 ctx.fireChannelRead(msg);方法將信息傳入下一個(gè) handler 去處理。
在項(xiàng)目里主要是以傳遞參數(shù)來進(jìn)行數(shù)據(jù)校驗(yàn)的,也就是通過 URL 傳參來實(shí)現(xiàn)。
在 httpRequestHandler 中我們將 URL 參數(shù) set 到 channel 的 attr 中,并傳遞給了下一個(gè) handler,也就是 authorizeHandler。
所以在 authorize 方法中我們可以利用 get() 方法得到參數(shù)值,u 是經(jīng)過加密的數(shù)據(jù),我們需要在這里進(jìn)行解密,解密失敗,可認(rèn)為校驗(yàn)失敗。
當(dāng)然如果有跨應(yīng)用的服務(wù),也可以通過 Cookie 的方式來進(jìn)行加密串的讀寫,通過 request.getHeader 是可以獲取 Cookie 中的信息,這就看具體業(yè)務(wù)了。
示例代碼如下:
這個(gè) map 可以理解為 servlet 中的 session,當(dāng)有信息需要傳送給某個(gè)客戶端時(shí),我們調(diào)用 map.get(key) 方式到當(dāng)前該客戶端的 channel,調(diào)用 writeAndFlush 方法將信息發(fā)送出去,下面舉例通過接收 MQ 消息后的處理邏輯。
接下來有人可能想到,如果通道關(guān)閉了怎么辦?map 中的 channel 是不是就失效了呢?
其實(shí)我們還需要有一個(gè)類似心跳的機(jī)制去維護(hù) channel,間接的去維護(hù)這個(gè) map。
如果是通道正常關(guān)閉,可以通過 channelInactive 方法來監(jiān)聽。
如果是長(zhǎng)時(shí)間空閑,在項(xiàng)目中我們使用了增加的 IdleStateHandler 來處理,通過覆蓋 userEventTriggered 方法來監(jiān)聽空閑 channel,當(dāng)某個(gè) channel 到達(dá)我們?cè)O(shè)置的超時(shí)時(shí)間時(shí),netty 會(huì)回調(diào)此方法。
至此,核心部分已經(jīng)處理完成,剩下的就是通過保存的 channel 來發(fā)送信息給客戶端了。
最后在 Web 端,我們采用了 reconnecting-websocket,它是一個(gè)小型的 JavaScript 庫(kù),封裝了 WebSocket API, 提供了在連接斷開時(shí)自動(dòng)重連的機(jī)制,能夠幫助我們完成斷開重連的操作。
遇到的問題
經(jīng)過測(cè)試,在 ws 的 uri 后面不能傳遞參數(shù),不然在 netty 實(shí)現(xiàn) websocket 協(xié)議握手的時(shí)候會(huì)出現(xiàn)斷開連接的情況。
針對(duì)這種情況在 websocketHandler 之前做了一層 httpHander 過濾,將傳遞參數(shù)放入 channel 的 attr 中,然后重寫 request 的 uri,并傳入下一個(gè)管道中,基本上解決了這個(gè)問題。
在讀寫空閑的時(shí)候盡量以發(fā)心跳包的方式維護(hù)連接,但在客戶端由于網(wǎng)絡(luò)不穩(wěn)定或者是服務(wù)端重啟,連接會(huì)斷開,瞬間有可能接收不到訂單消息,為此在客戶端需要實(shí)現(xiàn)斷開重連機(jī)制。
此問題我們采用 reconnecting-websocket的 JS 框架,此框架擴(kuò)展了原生 websocket 的實(shí)現(xiàn),做了斷開重連機(jī)制,有效的防止斷開后不能及時(shí)連接。
在測(cè)試過程中由于控件與小票機(jī)的問題,可能會(huì)出現(xiàn)打印異常或者小票機(jī)沒紙的情況。Lodop 控件可以將打印信息放入電腦的打印隊(duì)列。
如果沒紙了,小票機(jī)會(huì)報(bào)警,再次放入小票紙,打印機(jī)會(huì)自動(dòng)打印隊(duì)列中的數(shù)據(jù)。
出現(xiàn)調(diào)用控件異常偶爾發(fā)生,現(xiàn)在處理辦法是在 JS 中進(jìn)行了的 try catch。
如果失敗,進(jìn)行重試,重試次數(shù)自定義,超過重試次數(shù)暫不做處理,此處還不太嚴(yán)謹(jǐn),需要再進(jìn)行優(yōu)化。
總結(jié)
通過上面的實(shí)踐,我們基本已經(jīng)實(shí)現(xiàn)了 Web 端的自動(dòng)打印,經(jīng)過長(zhǎng)時(shí)間的內(nèi)部測(cè)試,服務(wù)端與客戶端通信穩(wěn)定,我們將灰度商家做用戶體驗(yàn)。
在特定的場(chǎng)景下,選擇適當(dāng)?shù)募夹g(shù)會(huì)提高我們的效率,否則會(huì)適得其反。
選擇長(zhǎng)連接,大家可以把握這三個(gè)大原則:
服務(wù)端是否需要主動(dòng)推送數(shù)據(jù)到客戶端以實(shí)現(xiàn)控制的效果。
對(duì)于實(shí)時(shí)性的要求是否苛刻。
對(duì)于客戶端是否需要關(guān)注它在線狀態(tài)的實(shí)時(shí)變化。
評(píng)論