聊聊OkHttp實(shí)現(xiàn)WebSocket細(xì)節(jié),包括鑒權(quán)和長連接?;罴捌湓恚?/h1>
一、序
OkHttp 應(yīng)該算是 Android 中使用最廣泛的網(wǎng)絡(luò)庫了,我們通常會(huì)利用它來實(shí)現(xiàn) HTTP 請(qǐng)求,但是實(shí)際上它還可以支持 WebSocket,并且使用起來還非常的便捷。
那本文就來聊聊,利用 OkHttp 實(shí)現(xiàn) WebSocket 的一些細(xì)節(jié),包括對(duì) WebSocket 的介紹,以及在傳輸前如何做到鑒權(quán)、長連接?;罴捌湓?。
二、WebSocket 簡介
2.1 為什么使用 WebSocket?
我們做客戶端開發(fā)時(shí),接觸最多的應(yīng)用層網(wǎng)絡(luò)協(xié)議,就是 HTTP 協(xié)議,而今天介紹的 WebSocket,下層和 HTTP 一樣也是基于 TCP 協(xié)議,是一種輕量級(jí)網(wǎng)絡(luò)通信協(xié)議,也屬于應(yīng)用層協(xié)議。
WebSocket 與 HTTP/2 一樣,其實(shí)都是為了解決 HTTP/1.1 的一些缺陷而誕生的,而 WebSocket 針對(duì)的就是「請(qǐng)求-應(yīng)答」這種"半雙工"的模式的通信缺陷。
「請(qǐng)求-應(yīng)答」是"半雙工"的通信模式,數(shù)據(jù)的傳輸必須經(jīng)過一次請(qǐng)求應(yīng)答,這個(gè)完整的通信過程,通信的同一時(shí)刻數(shù)據(jù)只能在一個(gè)方向上傳遞。它最大的問題在于,HTTP 是一種被動(dòng)的通信模式,服務(wù)端必須等待客戶端請(qǐng)求才可以返回?cái)?shù)據(jù),無法主動(dòng)向客戶端發(fā)送數(shù)據(jù)。
這也導(dǎo)致在 WebSocket 出現(xiàn)之前,一些對(duì)實(shí)時(shí)性有要求的服務(wù),都是基于輪詢(Polling)這種簡單的模式來實(shí)現(xiàn)。輪詢就是由客戶端定時(shí)發(fā)起請(qǐng)求,如果服務(wù)端有需要傳遞的數(shù)據(jù),可以借助這個(gè)請(qǐng)求去響應(yīng)數(shù)據(jù)。
輪詢的缺點(diǎn)也非常明顯,大量空閑的時(shí)間,其實(shí)是在反復(fù)發(fā)送無效的請(qǐng)求,這顯然是一種資源的損耗。
雖然在之后的 HTTP/2、HTTP/3 中,針對(duì)這種半雙工的缺陷新增了 Stream、Server Push 等特性,但是「請(qǐng)求-應(yīng)答」依然是 HTTP 協(xié)議主要的通信方式。
WebSocket 協(xié)議是由 HTML5 規(guī)范定義的,原本是為了瀏覽器而設(shè)計(jì)的,可以避免同源的限制,瀏覽器可以與任意服務(wù)端通信,現(xiàn)代瀏覽器基本上都已經(jīng)支持 WebSocket。
雖然 WebSocket 原本是被定義在 HTML5 中,但它也適用于移動(dòng)端,盡管移動(dòng)端也可以直接通過 Socket 與服務(wù)端通信,但借助 WebSocket,可以利用 80(HTTP) 或 443(HTTPS)端口通信,有效的避免一些防火墻的攔截。
WebSocket 是真正意義上的全雙工模式,也就是我們俗稱的「長連接」。當(dāng)完成握手連接后,客戶端和服務(wù)端均可以主動(dòng)的發(fā)起請(qǐng)求,回復(fù)響應(yīng),并且兩邊的傳輸都是相互獨(dú)立的。
2.2 WebSocket 的特點(diǎn)
WebSocket 的數(shù)據(jù)傳輸,是基于 TCP 協(xié)議,但是在傳輸之前,還有一個(gè)握手的過程,雙方確認(rèn)過眼神,才能夠正式的傳輸數(shù)據(jù)。
WebSocket 的握手過程,符合其 "Web" 的特性,是利用 HTTP 本身的 "協(xié)議升級(jí)" 來實(shí)現(xiàn)。
在建立連接前,客戶端還需要知道服務(wù)端的地址,WebSocket 并沒有另辟蹊徑,而是沿用了 HTTP 的 URL 格式,但協(xié)議標(biāo)識(shí)符變成了 "ws" 或者 "wss",分別表示明文和加密的 WebSocket 協(xié)議,這一點(diǎn)和 HTTP 與 HTTPS 的關(guān)系類似。
以下是一些 WebSocket 的 URL 例子:
- ws://cxmydev.com/some/path
- ws://cxmydev.com:8080/some/path
- wss://cxmydev.com:443?uid=xxx
而在連接建立后,WebSocket 采用二進(jìn)制幀的形式傳輸數(shù)據(jù),其中常用的包括用于數(shù)據(jù)傳輸?shù)臄?shù)據(jù)幀 MESSAGE 以及 3 個(gè)控制幀:
- PING:主動(dòng)?;畹?PING 幀;
- PONG:收到 PING 幀后回復(fù);
- CLOSE:主動(dòng)關(guān)閉 WebSocket 連接;
更多 WebSocket 的協(xié)議細(xì)節(jié),可以參考《WebSocket Protocol 規(guī)范》,具體細(xì)節(jié),有機(jī)會(huì)為什么再開單篇文章講解。
了解這些基本知識(shí),我們基本上就可以把 WebSocket 使用起來,并且不會(huì)掉到坑里。
我們?cè)傩〗Y(jié)一下 WebSocket 的特性:
- WebSocket 建立在 TCP 協(xié)議之上,對(duì)服務(wù)器端友好;
- 默認(rèn)端口采用 80 或 443,握手階段采用 HTTP 協(xié)議,不容易被防火墻屏蔽,能夠通過各種 HTTP 代理服務(wù)器;
- 傳輸數(shù)據(jù)相比 HTTP 輕量,少了 HTTP Header,性能開銷更小,通信更高效;
- 通過 MESSAGE 幀發(fā)送數(shù)據(jù),可以發(fā)送文本或者二進(jìn)制數(shù)據(jù),如果數(shù)據(jù)過大,會(huì)被分為多個(gè) MESSAGE 幀發(fā)送;
- WebSocket 沿用 HTTP 的 URL,協(xié)議標(biāo)識(shí)符是 "ws" 或 "wss"。
那接下來我們就看看如何利用 OkHttp 使用 WebSocket。
三、WebSocket之OkHttp
3.1 建立 WebSocket 連接
借助 OkHttp 可以很輕易的實(shí)現(xiàn) WebSocket,它的 OkHttpClient 中,提供了 newWebSocket() 方法,可以直接建立一個(gè) WebSocket 連接并完成通信。
- fun connectionWebSockt(hostName:String,port:Int){
- val httpClient = OkHttpClient.Builder()
- .pingInterval(40, TimeUnit.SECONDS) // 設(shè)置 PING 幀發(fā)送間隔
- .build()
- val webSocketUrl = "ws://${hostName}:${port}"
- val request = Request.Builder()
- .url(webSocketUrl)
- .build()
- httpClient.newWebSocket(request, object:WebSocketListener(){
- // ...
- })
- }
我想熟悉 OkHttp 的朋友,對(duì)上面這段代碼不會(huì)有疑問,只是 URL 換成了 "ws" 協(xié)議標(biāo)識(shí)符。另外,還需要配置 pingInterval(),這個(gè)細(xì)節(jié)后文會(huì)講解。
調(diào)用 newWebSocket() 后,就會(huì)開始 WebSocket 連接,但是核心操作都在 WebSocketListener 這個(gè)抽象類中。
3.2 使用 WebSocketListener
WebSocketListener 是一個(gè)抽象類,其中定義了比較多的方法,借助這些方法回調(diào),就可以完成對(duì) WebSocket 的所有操作。
- var mWebSocket : WebSocket? = null
- fun connectionWebSockt(hostName:String,port:Int){
- // ...
- httpClient.newWebSocket(request, object:WebSocketListener(){
- override fun onOpen(webSocket: WebSocket, response: Response) {
- super.onOpen(webSocket, response)
- // WebSocket 連接建立
- mWebSocket = webSocket
- }
- override fun onMessage(webSocket: WebSocket, text: String) {
- super.onMessage(webSocket, text)
- // 收到服務(wù)端發(fā)送來的 String 類型消息
- }
- override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
- super.onClosing(webSocket, code, reason)
- // 收到服務(wù)端發(fā)來的 CLOSE 幀消息,準(zhǔn)備關(guān)閉連接
- }
- override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
- super.onClosed(webSocket, code, reason)
- // WebSocket 連接關(guān)閉
- }
- override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
- super.onFailure(webSocket, t, response)
- // 出錯(cuò)了
- }
- })
- }
在 WebSocketListener 的所有方法回調(diào)中,都包含了 WebSocket 類型的對(duì)象,它就是當(dāng)前建立的 WebSocket 連接實(shí)體,通過它就可以向服務(wù)端發(fā)送 WebSocket 消息。
如果需要在其他時(shí)機(jī)發(fā)送消息,可以在回調(diào) onOpen() 這個(gè)建立連接完成的時(shí)機(jī),保存 webSocket 對(duì)象,以備后續(xù)使用。
OkHttp 中的 WebSocket 本身是一個(gè)接口,它的實(shí)現(xiàn)類是 RealWebSocket,它定義了一些發(fā)送消息和關(guān)閉連接的方法:
- send(text):發(fā)送 String 類型的消息;
- send(bytes):發(fā)送二進(jìn)制類型的消息;
- close(code, reason):主動(dòng)關(guān)閉 WebSocket 連接;
利用這些回調(diào)和 WebSocket 的方法,我們就可以完成 WebSocket 通信了。
3.3 Mock WebSocket
有時(shí)候?yàn)榱朔奖阄覀儨y(cè)試,OkHttp 還提供了擴(kuò)展的 MockWebSocket 服務(wù),來模擬服務(wù)端。
Mock 需要添加額外的 Gradle 引用,最好和 OkHttp 版本保持一致:
- api 'com.squareup.okhttp3:okhttp:3.9.1'
- api 'com.squareup.okhttp3:mockwebserver:3.9.1'
Mock WebServer 的使用也非常簡單,只需要利用 MockWebSocket 類即可。
- var mMockWebSocket: MockWebServer? = null
- fun mockWebSocket() {
- if (mMockWebSocket != null) {
- return
- }
- mMockWebSocket = MockWebServer()
- mMockWebSocket?.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {
- override fun onOpen(webSocket: WebSocket, response: Response) {
- super.onOpen(webSocket, response)
- // 有客戶端連接時(shí)回調(diào)
- }
- override fun onMessage(webSocket: WebSocket, text: String) {
- super.onMessage(webSocket, text)
- // 收到新消息時(shí)回調(diào)
- }
- override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
- super.onClosing(webSocket, code, reason)
- // 客戶端主動(dòng)關(guān)閉時(shí)回調(diào)
- }
- override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
- super.onClosed(webSocket, code, reason)
- // WebSocket 連接關(guān)閉
- }
- override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
- super.onFailure(webSocket, t, response)
- // 出錯(cuò)了
- }
- }))
- }
Mock WebSocket 服務(wù)端,依然需要用到我們前面講到的 WebSocketListener,這個(gè)就比較熟悉,不再贅述了。
之后就可以通過 mMockWebSocket 獲取到這個(gè) Mock 的服務(wù)的 IP 和端口。
- val hostName = mMockWebSocket?.getHostName()
- val port = mMockWebSocket?.getPort()
- val url = "ws:${hostName}:${port}"
需要注意的是,這兩個(gè)方法需要在子線程中調(diào)用,否者會(huì)收到一個(gè)異常。
雖然有時(shí)候在服務(wù)端完善的情況下,我們并不需要使用 Mock 的手段,但是在學(xué)習(xí)階段,依然推薦大家在本地 Mock 一個(gè)服務(wù)端,打一些日志,觀察一個(gè)完整的 WebSocket 鏈接和發(fā)送消息的過程。
3.4 WebSocket 如何鑒權(quán)
接下來我們聊聊 WebSocket 連接的鑒權(quán)問題。
所謂鑒權(quán),其實(shí)就是為了安全考慮,避免服務(wù)端啟動(dòng) WebSocket 的連接服務(wù)后,任誰都可以連接,這肯定會(huì)引發(fā)一些安全問題。其次,服務(wù)端還需要將 WebSocket 的連接實(shí)體與一個(gè)真實(shí)的用戶對(duì)應(yīng)起來,否則業(yè)務(wù)就無法保證了。
那么問題就回到了,WebSocket 通信的完整過程中,如何以及何時(shí)將一些業(yè)務(wù)數(shù)據(jù)傳遞給服務(wù)端?當(dāng)然在 WebSocket 連接建立之后,立即給服務(wù)端發(fā)送一些鑒權(quán)的數(shù)據(jù),必然是可以做到業(yè)務(wù)實(shí)現(xiàn)的,但是這樣明顯是不夠優(yōu)雅的。
前文提到,WebSocket 在握手階段,使用的是 HTTP 的 "協(xié)議升級(jí)",它本質(zhì)上還是 HTTP 的報(bào)文頭發(fā)送一些特殊的頭數(shù)據(jù),來完成協(xié)議升級(jí)。
例如在 RealWebSocket 中,就有構(gòu)造 Header 的過程,例如 Upgrade、Connection 等等。
- public void connect(OkHttpClient client) {
- // ...
- final Request request = originalRequest.newBuilder()
- .header("Upgrade", "websocket")
- .header("Connection", "Upgrade")
- .header("Sec-WebSocket-Key", key)
- .header("Sec-WebSocket-Version", "13")
- .build();
- //....
- }
那么實(shí)際我們?cè)?WebSocket 階段,也可以通過 Header 傳輸一些鑒權(quán)的數(shù)據(jù),例如 uid、token 之類,具體方法就是在構(gòu)造 Request 的時(shí)候,為其增加 Header,這里就不舉例說明了。
另外 WebSocket 的 URL 也是可以攜帶參數(shù)的。
- wss://cxmydev.com:443?uid=xxx&token=xxx
3.5 WebSocket 保活
WebSocket 建立的連接就是我們所謂的長連接,每個(gè)連接對(duì)于服務(wù)器而言,都是資源。而服務(wù)器傾向于在一個(gè)連接長時(shí)間沒有消息往來的時(shí)候,將其關(guān)閉。而 WebSocket 的?;睿瑫r(shí)機(jī)上就是定時(shí)向服務(wù)端發(fā)送一個(gè)空消息,來保證連接不會(huì)被服務(wù)端主動(dòng)斷開。
那么我們自己寫個(gè)定時(shí)器,固定間隔向服務(wù)端 mWebSocket.send() 一個(gè)消息,就可以達(dá)到?;畹哪康模@樣發(fā)送的其實(shí)是 MESSAGE 幀數(shù)據(jù),如果使用 WebSocket 還有更優(yōu)雅的方式。
前文我們提到,WebSocket 采用二進(jìn)制幀的形式傳輸數(shù)據(jù),其中就包括了用于?;畹?PING 幀,而 OkHttp 只需要簡單的配置,就可以自動(dòng)的間隔發(fā)送 PING 幀和數(shù)據(jù)。
我們只需要在構(gòu)造 OkHttpClient 的時(shí)候,通過 pingInterval() 設(shè)置 PING 幀發(fā)送的時(shí)間間隔,它的默認(rèn)值為 0,所以不設(shè)置不發(fā)送。
- val httpClient = OkHttpClient.Builder()
- .pingInterval(40, TimeUnit.SECONDS) // 設(shè)置 PING 幀發(fā)送間隔
- .build()
這里設(shè)置的時(shí)長,需要和服務(wù)端商議,通常建議最好設(shè)置一個(gè)小于 60s 的值。
具體的邏輯在 RealWebSocket 類中。
- public void initReaderAndWriter(String name, Streams streams) throws IOException {
- synchronized (this) {
- // ...
- if (pingIntervalMillis != 0) {
- executor.scheduleAtFixedRate(
- new PingRunnable(), pingIntervalMillis, pingIntervalMillis, MILLISECONDS);
- }
- // ...
- }
- // ...
- }
PingRunnabel 最終會(huì)去間隔調(diào)用 writePingFrame() 用以向 WebSocketWriter 中寫入 PING 幀,來達(dá)到服務(wù)端長連接?;畹男Ч?。
四、小結(jié)
到這里本文就介紹清楚 WebSocket 以及如何使用 OkHttp 實(shí)現(xiàn) WebSocket 支持。
這里還是簡單小結(jié)一下:
- WebSocket 是一個(gè)全雙工的長連接應(yīng)用層協(xié)議,可以通過它實(shí)現(xiàn)服務(wù)端到客戶端主動(dòng)的推送通信。
- OkHttp 中使用 WebSocket 的關(guān)鍵在于 newWebSocket() 方法以及 WebSocketListener 這個(gè)抽象類,最終連接建立完畢后,可以通過 WebSocket 對(duì)象向?qū)Χ税l(fā)送消息;
- WebSocket 鑒權(quán),可以利用握手階段的 HTTP 請(qǐng)求中,添加 Header 或者 URL 參數(shù)來實(shí)現(xiàn);
- WebSocket 的?;?,需要定時(shí)發(fā)送 PING 幀,發(fā)送的時(shí)間間隔,可以通過 pingInterval()方法設(shè)置;
額外提一句,OkHttp 在 v3.4.1 中添加的 WebSocket 的支持,之前的版本需要 okhttp-ws 擴(kuò)展庫來支持,但是那畢竟已經(jīng)是 2016 年的事了,我想現(xiàn)在應(yīng)該沒有人在用那么老版本的 OkHttp 了。
本文對(duì)你有幫助嗎?留言、轉(zhuǎn)發(fā)、點(diǎn)好看是最大的支持,謝謝!如果本文各項(xiàng)數(shù)據(jù)好,之后會(huì)再分享一篇 OkHttp 中針對(duì) WebSocket 的實(shí)現(xiàn)以及 WebSocket 協(xié)議的講解。
參考:
- WebSocket教程:http://www.ruanyifeng.com/blog/2017/05/websocket.html
- The WebSocket Protocol:https://tools.ietf.org/html/rfc6455#page-37