OkHttp 實現 WebSocket 真的太好用了,聊聊長連接如何保活


OkHttp 實現 WebSocket 真的太好用了,聊聊長連接如何保活

一、序

OkHttp 應該算是 Android 中使用中使用最廣泛的網絡庫了,我們通常會利用它來實現 HTTP 請求,但是實際上它還可以支持 WebSocket,並且使用起來還非常的便捷。

那本文就來聊聊,利用 OkHttp 實現 WebSocket 的一些細節,包括對 WebSocket 的介紹,以及在傳輸前如何做到鑑權、長連接保活及其原理。

二、WebSocket 簡介

2.1 為什麼使用 WebSocket?

我們做客戶端開發時,接觸最多的應用層網絡協議,就是 HTTP 協議,而今天介紹的 WebSocket,下層和 HTTP 一樣也是基於 TCP 協議,是一種輕量級網絡通信協議,也屬於應用層協議。

WebSocket 與 HTTP/2 一樣,其實都是為了解決 HTTP/1.1 的一些缺陷而誕生的,而 WebSocket 針對的就是「請求-應答」這種"半雙工"的模式的通信缺陷。

「請求-應答」是"半雙工"的通信模式,數據的傳輸必須經過一次請求應答,這個完整的通信過程,通信的同一時刻數據只能在一個方向上傳遞。它最大的問題在於,HTTP 是一種被動的通信模式,服務端必須等待客戶端請求才可以返回數據,無法主動向客戶端發送數據。

這也導致在 WebSocket 出現之前,一些對實時性有要求的服務,都是基於輪詢(Polling)這種簡單的模式來實現。輪詢就是由客戶端定時發起請求,如果服務端有需要傳遞的數據,可以藉助這個請求去響應數據。

輪詢的缺點也非常明顯,大量空閒的時間,其實是在反覆發送無效的請求,這顯然是一種資源的損耗。

雖然在之後的 HTTP/2、HTTP/3 中,針對這種半雙工的缺陷新增了 Stream、Server Push 等特性,但是「請求-應答」依然是 HTTP 協議主要的通信方式。

WebSocket 協議是由 HTML5 規範定義的,原本是為了瀏覽器而設計的,可以避免同源的限制,瀏覽器可以與任意服務端通信,現代瀏覽器基本上都已經支持 WebSocket。

雖然 WebSocket 原本是被定義在 HTML5 中,但它也適用於移動端,儘管移動端也可以直接通過 Socket 與服務端通信,但藉助 WebSocket,可以利用 80(HTTP) 或 443(HTTPS)端口通信,有效的避免一些防火牆的攔截。

OkHttp 實現 WebSocket 真的太好用了,聊聊長連接如何保活

g

WebSocket 是真正意義上的全雙工模式,也就是我們俗稱的「長連接」。當完成握手連接後,客戶端和服務端均可以主動的發起請求,回覆響應,並且兩邊的傳輸都是相互獨立的。

2.2 WebSocket 的特點

WebSocket 的數據傳輸,是基於 TCP 協議,但是在傳輸之前,還有一個握手的過程,雙方確認過眼神,才能夠正式的傳輸數據。

WebSocket 的握手過程,符合其 "Web" 的特性,是利用 HTTP 本身的 "協議升級" 來實現。

在建立連接前,客戶端還需要知道服務端的地址,WebSocket 並沒有另闢蹊徑,而是沿用了 HTTP 的 URL 格式,但協議標識符變成了 "ws" 或者 "wss",分別表示明文和加密的 WebSocket 協議,這一點和 HTTP 與 HTTPS 的關係類似。

OkHttp 實現 WebSocket 真的太好用了,聊聊長連接如何保活

以下是一些 WebSocket 的 URL 例子:

<code>ws://cxmydev.com/some/path
ws://cxmydev.com:8080/some/path
wss://cxmydev.com:443?uid=xxx/<code>

而在連接建立後,WebSocket 採用二進制幀的形式傳輸數據,其中常用的包括用於數據傳輸的數據幀 MESSAGE 以及 3 個控制幀:

  • PING:主動保活的 PING 幀;
  • PONG:收到 PING 幀後回覆;
  • CLOSE:主動關閉 WebSocket 連接;

更多 WebSocket 的協議細節,可以參考《WebSocket Protocol 規範》,具體細節,有機會為什麼再開單篇文章講解。

瞭解這些基本知識,我們基本上就可以把 WebSocket 使用起來,並且不會掉到坑裡。

我們再小結一下 WebSocket 的特性:

  1. WebSocket 建立在 TCP 協議之上,對服務器端友好;
  2. 默認端口採用 80 或 443,握手階段採用 HTTP 協議,不容易被防火牆屏蔽,能夠通過各種 HTTP 代理服務器;
  3. 傳輸數據相比 HTTP 輕量,少了 HTTP Header,性能開銷更小,通信更高效;
  4. 通過 MESSAGE 幀發送數據,可以發送文本或者二進制數據,如果數據過大,會被分為多個 MESSAGE 幀發送;
  5. WebSocket 沿用 HTTP 的 URL,協議標識符是 "ws" 或 "wss"。

那接下來我們就看看如何利用 OkHttp 使用 WebSocket。

三、WebSocket之OkHttp

3.1 建立 WebSocket 連接

藉助 OkHttp 可以很輕易的實現 WebSocket,它的 OkHttpClient 中,提供了 newWebSocket() 方法,可以直接建立一個 WebSocket 連接並完成通信。

<code>fun connectionWebSockt(hostName:String,port:Int){  
val httpClient = OkHttpClient.Builder()    
.pingInterval(40, TimeUnit.SECONDS) // 設置 PING 幀發送間隔    
.build()  
val webSocketUrl = "ws://${hostName}:${port}"  
val request = Request.Builder()    
.url(webSocketUrl)    
.build()  
httpClient.newWebSocket(request, object:WebSocketListener(){
// ...
})
}/<code>

我想熟悉 OkHttp 的朋友,對上面這段代碼不會有疑問,只是 URL 換成了 "ws" 協議標識符。另外,還需要配置 pingInterval(),這個細節後文會講解。

調用 newWebSocket() 後,就會開始 WebSocket 連接,但是核心操作都在 WebSocketListener 這個抽象類中。

3.2 使用 WebSocketListener

WebSocketListener 是一個抽象類,其中定義了比較多的方法,藉助這些方法回調,就可以完成對 WebSocket 的所有操作。

<code>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)
// 收到服務端發送來的 String 類型消息
}

override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
super.onClosing(webSocket, code, reason)
// 收到服務端發來的 CLOSE 幀消息,準備關閉連接
}

override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
super.onClosed(webSocket, code, reason)
// WebSocket 連接關閉
}

override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
super.onFailure(webSocket, t, response)
// 出錯了
}
})

}/<code>

在 WebSocketListener 的所有方法回調中,都包含了 WebSocket 類型的對象,它就是當前建立的 WebSocket 連接實體,通過它就可以向服務端發送 WebSocket 消息。

如果需要在其他時機發送消息,可以在回調 onOpen() 這個建立連接完成的時機,保存 webSocket 對象,以備後續使用。

OkHttp 中的 WebSocket 本身是一個接口,它的實現類是 RealWebSocket,它定義了一些發送消息和關閉連接的方法:

  • send(text):發送 String 類型的消息;
  • send(bytes):發送二進制類型的消息;
  • close(code, reason):主動關閉 WebSocket 連接;

利用這些回調和 WebSocket 的方法,我們就可以完成 WebSocket 通信了。

3.3 Mock WebSocket

有時候為了方便我們測試,OkHttp 還提供了擴展的 MockWebSocket 服務,來模擬服務端。

Mock 需要添加額外的 Gradle 引用,最好和 OkHttp 版本保持一致:

<code>api 'com.squareup.okhttp3:okhttp:3.9.1'
api 'com.squareup.okhttp3:mockwebserver:3.9.1'/<code>

Mock WebServer 的使用也非常簡單,只需要利用 MockWebSocket 類即可。

<code>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)
// 有客戶端連接時回調
}

override fun onMessage(webSocket: WebSocket, text: String) {
super.onMessage(webSocket, text)
// 收到新消息時回調
}

override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
super.onClosing(webSocket, code, reason)
// 客戶端主動關閉時回調
}

override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
super.onClosed(webSocket, code, reason)
// WebSocket 連接關閉
}

override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
super.onFailure(webSocket, t, response)
// 出錯了
}
}))
}/<code>

Mock WebSocket 服務端,依然需要用到我們前面講到的 WebSocketListener,這個就比較熟悉,不再贅述了。

之後就可以通過 mMockWebSocket 獲取到這個 Mock 的服務的 IP 和端口。

<code>val hostName = mMockWebSocket?.getHostName()
val port = mMockWebSocket?.getPort()
val url = "ws:${hostName}:${port}"/<code>

需要注意的是,這兩個方法需要在子線程中調用,否者會收到一個 NetworkOnMainThreadException 異常。

雖然有時候在服務端完善的情況下,我們並不需要使用 Mock 的手段,但是在學習階段,依然推薦大家在本地 Mock 一個服務端,打一些日誌,觀察一個完整的 WebSocket 鏈接和發送消息的過程。

3.4 WebSocket 如何鑑權

接下來我們聊聊 WebSocket 連接的鑑權問題。

所謂鑑權,其實就是為了安全考慮,避免服務端啟動 WebSocket 的連接服務後,任誰都可以連接,這肯定會引發一些安全問題。其次,服務端還需要將 WebSocket 的連接實體與一個真實的用戶對應起來,否則業務也無法保證了。

那麼問題就回到了,WebSocket 通信的完整過程中,如何以及何時將一些業務數據傳遞給服務端?當然在 WebSocket 連接建立之後,立即給服務端發送一些鑑權的數據,必然是可以做到業務實現的,但是這樣明顯是不夠優雅的。

前文提到,WebSocket 在握手階段,使用的是 HTTP 的 "協議升級",它本質上還是 HTTP 的報文頭髮送一些特殊的頭數據,來完成協議升級。

例如在 RealWebSocket 中,就有構造 Header 的過程,例如 Upgrade、Connection 等等。

<code>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();
//....
}/<code>

那麼實際我們在 WebSocket 階段,也可以通過 Header 傳輸一些鑑權的數據,例如 uid、token 之類,具體方法就是在構造 Request 的時候,為其增加 Header,這裡就不舉例說明了。

另外 WebSocket 的 URL 也是可以攜帶參數的。

<code>wss://cxmydev.com:443?uid=xxx&token=xxx/<code>

3.5 WebSocket 保活

WebSocket 建立的連接就是我們所謂的長連接,每個連接對於服務器而言,都是資源。而服務器傾向於在一個連接長時間沒有消息往來的時候,將其關閉。而 WebSocket 的保活,時機上就是定時向服務端發送一個空消息,來保證連接不會被服務端主動斷開。

那麼我們自己寫個定時器,固定間隔向服務端 mWebSocket.send() 一個消息,就可以達到保活的目的,但這樣發送的其實是 MESSAGE 幀數據,如果使用 WebSocket 還有更優雅的方式。

前文我們提到,WebSocket 採用二進制幀的形式傳輸數據,其中就包括了用於保活的 PING 幀,而 OkHttp 只需要簡單的配置,就可以自動的間隔發送 PING 幀和數據。

我們只需要在構造 OkHttpClient 的時候,通過 pingInterval() 設置 PING 幀發送的時間間隔,它的默認值為 0,所以不設置不發送。

<code>val httpClient = OkHttpClient.Builder()
.pingInterval(40, TimeUnit.SECONDS) // 設置 PING 幀發送間隔
.build()/<code>

這裡設置的時長,需要和服務端商議,通常建議最好設置一個小於 60s 的值。

具體的邏輯在 RealWebSocket 類中。

<code>public void initReaderAndWriter(String name, Streams streams) throws IOException {
synchronized (this) {
// ...
if (pingIntervalMillis != 0) {
executor.scheduleAtFixedRate(
new PingRunnable(), pingIntervalMillis, pingIntervalMillis, MILLISECONDS);
}
// ...
}
// ...
}/<code>

PingRunnabel 最終會去間隔調用 writePingFrame() 用以向 WebSocketWriter 中寫入 PING 幀,來達到服務端長連接保活的效果。

四、小結

到這裡本文就介紹清楚 WebSocket 以及如何使用 OkHttp 實現 WebSocket 支持。

這裡還是簡單小結一下:

  1. WebSocket 是一個全雙工的長連接應用層協議,可以通過它實現服務端到客戶端主動的推送通信。
  2. OkHttp 中使用 WebSocket 的關鍵在於 newWebSocket() 方法以及 WebSocketListener 這個抽象類,最終連接建立完畢後,可以通過 WebSocket 對象向對端發送消息;
  3. WebSocket 鑑權,可以利用握手階段的 HTTP 請求中,添加 Header 或者 URL 參數來實現;
  4. WebSocket 的保活,需要定時發送 PING 幀,發送的時間間隔,可以通過 pingInterval() 方法設置;

額外提一句,OkHttp 在 v3.4.1 中添加的 WebSocket 的支持,之前的版本需要 okhttp-ws 擴展庫來支持,但是那畢竟已經是 2016 年的事了,我想現在應該沒有人在用那麼老版本的 OkHttp 了。

本文對你有幫助嗎?留言、轉發、收藏是最大的支持,謝謝!如果本文各項數據好,之後會再分享一篇 OkHttp 中針對 WebSocket 的實現以及 WebSocket 協議的講解。



在頭條號私信我。我會送你一些我整理的學習資料,包含:Android反編譯、算法、設計模式、虛擬機、Linux、Kotlin、Python、爬蟲、Web項目源碼。


分享到:


相關文章: