博客地址是:https://qinyuanpei.github.io。
WebSocket是HTML5標准中的一部分,從Socket這個字眼我們就可以知道,這是一種網絡通信協議。WebSocket是為了彌補HTTP協議的不足而產生的,我們知道,HTTP協議有一個重要的缺陷,即:請求只能由客戶端發起。這是因為HTTP協議采用了經典的請求-響應模型,這就限制了服務端主動向客戶端推送消息的可能。與此同時,HTTP協議是無狀態的,這意味着連接在請求得到響應以后就關閉了,所以,每次請求都是獨立的、上下文無關的請求。這種單向請求的特點,注定了客戶端無法實時地獲取服務端的狀態變化,如果服務端的狀態發生連續地變化,客戶端就不得不通過“輪詢”的方式來獲知這種變化。毫無疑問,輪詢的方式不僅效率低下,而且浪費網絡資源,在這種背景下,WebSocket應運而生。
WebSocket協議最早於2008年被提出,並於2011年成為國際標准。目前,主流的瀏覽器都已經提供了對WebSocket的支持。在WebSocket協議中,客戶端和服務器之間只需要做一次握手操作,就可以在客戶端和服務器之間實現雙向通信,所以,WebSocket可以作為服務器推送的實現技術之一。因為它本身以HTTP協議為基礎,所以對HTTP協議有着更好的兼容性,無論是通信效率還是傳輸的安全性都能得到保證。WebSocket沒有同源限制,客戶端可以和任意服務器端進行通信,因此具備通過一個單一連接來支持上下游通信的能力。從本質上來講,WebSocket是一個在握手階段使用HTTP協議的TCP/IP協議,換句話說,一旦握手成功,WebSocket就和HTTP協議再無瓜葛,下圖展示了它與HTTP協議的區別:

構建一個聊天室
OK,在對WebSocket有了一個基本的認識以后,接下來,我們以一個最簡單的場景來體驗下WebSocket。這個場景是什么呢?你已經知道了,答案就是網絡聊天室。這是一個非常典型的實時場景。這里我們分為服務端實現和客戶端實現,其中:服務端實現自豪地采用.NET Core,而客戶端實現采用Vue的雙向綁定特性。現在是公元2018年了,當jQuery已成往事,操作DOM這種事情交給框架去做就好,而且我本人很喜歡MVVM這種模式,Vue的漸進式框架,非常適合我這種不會寫ES6的偽前端。
.NET Core與中間件
關於.NET Core中對WebSocket的支持,這里主要參考了官方文檔,在這篇文檔中,演示了一個最基本的Echo示例,即服務端如何接收客戶端消息並返回消息給客戶端。這里,我們首先需要安裝Microsoft.AspNetCore.WebSockets這個庫,直接通過Visual Studio Code內置的終端安裝即可。接下來,我們需要在Startup類的Configure方法中添加WebSocket中間件:
1 |
app.UseWebSockets() |
更一般地,我們可以配置以下兩個配置,其中,KeepAliveInterval表示向客戶端發送Ping幀的時間間隔;ReceiveBufferSize表示接收數據的緩沖區大小:
1 |
var webSocketOptions = new WebSocketOptions() |
好了,那么怎么接收一個來自客戶端的請求呢?這里以官方文檔中的示例代碼為例來說明。首先,我們需要判斷下請求的地址,這是客戶端和服務端約定好的地址,默認為/,這里我們以/ws為例;接下來,我們需要判斷當前的請求上下文是否為WebSocket請求,通過context.WebSockets.IsWebSocketRequest來判斷。當這兩個條件同時滿足時,我們就可以通過context.WebSockets.AcceptWebSocketAsync()方法來得到WebSocket對象,這樣就表示“握手”完成,這樣我們就可以開始接收或者發送消息啦。
1 |
if (context.Request.Path == "/ws") |
一旦建立了Socket連接,客戶端和服務端之間就可以開始通信,這是我們從Socket中收獲的經驗,這個經驗同樣適用於WebSocket。這里分別給出WebSocket發送和接收消息的實現,並針對代碼做簡單的分析。
1 |
private async Task SendMessage<TEntity>(WebSocket webSocket, TEntity entity) |
這里我們提供一個泛型方法,它負責對消息進行序列化並轉化為byte[],最終調用SendAsync()方法發送消息。與之相對應地,客戶端會在onmessage()回調中就會接受到消息,這一點我們放在后面再說。WebSocket接收消息的方式,和傳統的Socket非常相似,我們需要將字節流循環讀取到一個緩存區里,直至所有數據都被接收完。下面給出基本的代碼示例:
1 |
var buffer = new ArraySegment<byte>(new byte[bufferSize]); |
雖然不大清楚,為什么這里反序列化后的內容中會有大量的\0,以及這個全新的類型ArraySegment到底是個什么鬼,不過程序員的一生無非都在糾結這樣兩個問題,“it works” 和 “it doesn’t works”,就像人生里會讓你糾結的無非是”她喜歡你“和”她不喜歡我“這樣的問題。有時候,這樣的問題簡直就是玄學,五柳先生好讀書而不求甚解,我想這個道理在這里同樣適用,截止到我寫這篇博客前,這個代碼一直工作得很好,所以,這兩個問題我們可以暫時先放在一邊,因為眼下還有比這更為重要的事情。
通過這篇文檔,我們可以非常容易地構建出一個”實時應用“,可是它離我們這篇文章中的目標依然有點距離,如果各位足夠細心的話,就會發現這樣一個問題,即示例中的代碼都是寫在app.Use()方法中的,這樣會使我們的Startup類顯得臃腫,而熟悉OWIN或者ASP.NET Core的朋友,就會知道Startup類是一個非常重要的東西,我們通常會在這里配置相關的組件。在ASP.NET Core中,我們可以通過Configure()方法來為IApplicationBuilder增加相關組件,這種組件通常被稱為中間件。那么,什么是中間件呢?

從這張圖中可以看出,中間件實際上是指在HTTP請求管道中處理請求和響應的組件,每個組件都可以決定是否要將請求傳遞給下一個組件,比如身份認證、日志記錄就是最為常見的中間件。在ASP.NET Core中,我們通過app.Use()方法來定義一個Func<RequestDelegate,RequestDelegate>類型的參數,所以,我們可以簡單地認為,在ASP.NET Core中,Func<RequestDelegate,RequestDelegate>就是一個中間件,而通過app.Use()方法,這些中間件會根據注冊的先后順序組成一個鏈表,每一個中間件的輸入是上一個中間件的輸出,每一個中間件的輸出則會成為下一個中間件的輸入。簡而言之,每一個RequestDelegate對象不僅包含了自身對請求的處理,而且包含了后續中間件對請求的處理,我們來看一個簡單的例子:
1 |
app.Use(async (context,next)=> |
通過Postman或者任意客戶端發起請求,我們就可以得到下面的結果,現在想象一下,如果我們在第一種中間件中不調用next()會怎么樣呢?答案是中間件之間的鏈路會被打斷,這意味着后續的第二個、第三個中間件都不會被執行。什么時候我們會遇到這種場景呢?當我們的認證中間件認為一個請求非法的時候,此時我們不應該讓用戶訪問后續的資源,所以直接返回403對該請求進行攔截。在大多數情況下,我們需要讓請求隨着中間件的鏈路傳播下去,所以,對於每一個中間件來說,除了完成自身的處理邏輯以外,還至少需要調用一次next(),以保證下一個中間件會被調用,這其實和職責鏈模式非常相近,可以讓數據在不同的處理管道中進行傳播。

OK,這里我們繼續遵從這個約定,將整個聊天室相關的邏輯寫到一個中間件里,這樣做的好處是,我們可以將不同的WebSocket互相隔離開,同時可以為我們的Startup類”減負“。事實證明,這是一個正確的決定,在開發基於WebSocket的彈幕功能時,我們就是用這種方式開發了新的中間件。這里,我們給出的是WebSocketChat中間件中最為關鍵的部分,詳細的代碼我已經放在Github上啦,大家可以參考WebSocketChat類,其基本原理是:使用一個字典來存儲每一個聊天室中的會話(Socket),當用戶打開或者關閉一個WebSocket連接時,會向服務器端發送一個事件(Event),這樣客戶端中持有的用戶列表將被更新,而根據發送的消息,可以決定這條消息是被發給指定聯系人還是群發:
1 |
public async Task Invoke(HttpContext context) |
其中,HandleEvent負責對事件進行處理,HandleChat負責對消息進行處理。當有用戶加入聊天室的時候,首先會向所有客戶端廣播一條消息,告訴大家有新用戶加入了聊天室,與此同時,為了讓大家可以和新用戶進行通信,必須將新的用戶列表推送到客戶端。同理,當有用戶離開聊天室的時候,服務器端會有類似的事件推送到客戶端。事件同樣是基於消息來實現的,不過這兩種采用的數據結構不同,具體大家可以通過源代碼來了解。發送消息就非常簡單啦,給指定用戶發送消息是通過用戶名來找WebSocket對象,而群發消息就是遍歷字典中的所有WebSocket對象,這一點我們不再詳細說啦!
Vue驅動的客戶端
在實現服務端的WebSocket以后,我們就可以着手客戶端的開發啦!這里我們采用原生的WebSocket API來開發相關功能。具體來講,我們只需要實例化一個WebSocket類,並設置相應地回調函數就可以了,我們一起來看下面的例子:
1 |
var username = "PayneQin" |
這里我們使用/s這個路由來訪問WebSocket,相應地,在服務端代碼中我們需要判斷context.Request.Path,WebSocket在握手階段是基於HTTP協議的,所以我們可以以QueryString的形式給后端傳遞一個參數,這里我們需要一個用戶名,它將作為服務端存儲WebSocket時的一個鍵。一旦建立了WebSocket,我們就可以通過回調函數來監聽服務器端的響應,或者是發送消息給服務器端。主要的回調函數有onopen、onmessage、onerror和onclose四個,基本使用方法如下:
1 |
websocket.onopen = function () { |
原生的WebSocket API只有兩個方法,即send()和close(),這兩個方法非常的簡單,我們這里不再說明。需要說明的是,客戶端使用了Vue來做界面相關的綁定,作為一個不會寫CSS、不會寫ES6的偽前端,我做了一個相當簡潔(簡陋)的前端頁面,下面給出主要的頁面結構,ViewModel層的代碼比較多,大家可以參考這里:
1 |
<div id="app"> |
下面是實際的運行效果,果然是非常簡潔呢,哈哈:laughing:

再看Websocket
好了,我們花了如此大的篇幅來講WebSocket,那么你對WebSocket了解了多少呢?或許通過這個聊天室的實例,我們對WebSocket有了一個相對直觀的認識,可你是否想過換一個角度來認識它呢?我們說過,WebSocket是以HTTP協議為基礎的,那么至少可以在握手階段捕獲到相關請求吧!果斷在Chrome中打開”開發者工具“,在面板上選擇監聽”WebSocket”,然后我們就會得到下面的內容。

相比HTTP協議,WebSocket在握手階段的請求有所變化,主要體現在Upgrade、Connection這兩個字段,以及Sec-WebSocket系列的這些字段。下面來分別解釋下這些字段的含義,Upgrade和Connection這兩個字段,是最為關鍵的兩個字段,它的目的是告訴Apache、Nginx這些服務器,這是一個WebSocket請求。接下來,是Sec-WebSocket-Key、Sec-WebSocket-Protocol和Sec-WebSocket-Version這三個字段,其中Sec-WebSocket-Key是一個由瀏覽器采用Base64算法隨機生成的字符串,目的是驗證服務器是否真的支持WebSocket;Sec-WebSocket-Protocol則是一個由用戶指定的字符串,目的是區分同一URL下,不同服務所需要的協議;Sec-WebSocket-Version是告訴服務器瀏覽器支持的WebSocket版本,標准規定9-12的版本號是保留字段,所以在這里我們看到的版本號是13.

那么,對於這個瀏覽器發起的這個請求,服務端是如何做出響應的呢?這就要來看看服務端返回的內容。 和客戶端發起的請求類似,服務端返回的內容中依然會有Upgrade和Connection這兩個字段,它們和請求中的含義是完全一致的。這里需要說明的是Sec-WebSocket-Accept這個字段,我們前面提到,瀏覽器會通過WebSocket-Key檢驗服務器是否真的支持WebSocket,具體怎么檢驗呢?是通過下面的算法。除此之外,一個特殊的地方是這個Response的狀態碼是101,這表示服務端說:下面我們就按照WebSocket協議來通信吧!當然,一個更為殘酷的現實是,從這里開始,就不再是HTTP協議的勢力范圍了啊:
1 |
sec-websocket-accept = base64(hsa1(sec-websocket-key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)) |
本文小結
這篇文章選取了“實時應用”這樣一個業務場景作為切入點,引出了本文的主題——WebSocket。WebSocket是一種建立在HTTP協議基礎上的雙向通信協議,它彌補了以“請求-響應”模型為基礎的HTTP協議先天上的不足,客戶端無需再通過“輪詢”這種方式來獲取服務端的狀態變化。WebSocket在完成“握手”后,即可以長連接的方式在客戶端和服務端間構建雙向通道,因而WebSocket可以在實時應用場景下,作為服務器推送技術的一種方案選擇。本文以一個WebSocket聊天室的案例,來講解WebSocket在實際項目中的應用,在這里我們使用ASP.NET Core來完成服務端WebSocket的實現,而客戶端選用原生WebSocket API和Vue來實現,在此基礎上,我們講解了ASP.NET Core下中間件的概念,並將服務器端WebSocket以中間件的形式實現。在下一篇文章中,我們將偏重於服務器端的數據推送,客戶端將作為數據展現層而存在。好了,以上就是這篇文章的全部內容啦,謝謝大家,讓我們一起期待下一篇文章吧!