
從 Ajax 到 WebSocket
背景
在建立 HTTP 標准規范的時候,設計者的初衷主要是想把 HTTP 當做傳輸靜態 HTML 文檔的協議,但是隨着互聯網的發展,Web 應用的用途更加多樣性,逐漸誕生了電商網站(如淘寶、亞馬遜)、社交網絡(如Facebook、Twitter)等功能更加復雜的應用,這些網站的功能單純靠靜態 HTML 顯然是實現不了的,因此又產生了通過 CGI 將 Web 服務器與后台動態應用連接起來,從而通過后台腳本語言實現的應用驅動網站功能,這些腳本語言包括 PHP、Python、Ruby、Node、JSP、ASP 等,通過這種方式雖然解決了 Web 應用的功能擴展問題,但是 HTTP 協議本身的限制和性能問題卻沒有得到有效解決。
HTTP 功能和性能上的問題雖然可以通過創建一套新的協議來徹底解決,但是目前基於 HTTP 的服務端和客戶端應用遍布全球,完全拋棄不太現實,但問題卻要解決,因此,誕生了很多基於 HTTP 協議的新技術和新協議來補足 HTTP 協議本身的缺陷。
Ajax
隨着網站功能的復雜,對資源實時性的要求也越來越高,但是 HTTP 本身無法做到實時顯示服務器端更新的內容,要獲取服務器端的最新內容,就得頻繁從客戶端發起新的請求(比如刷新頁面),如果服務器上沒有更新,就會造成通信的浪費,而且從用戶體驗來說也不夠友好。
為了解決這個問題,誕生了 Ajax 技術,其全稱是 Asynchronous JavaScript And XML,即異步 Javascript 與 XML 技術,它是一種可以有效利用 JavaScript 與 DOM 操作,實現 Web 頁面局部刷新,而不用重新加載頁面的異步通信技術。其核心技術是一個名為 XMLHttpRequest 的 API, 通過 JavaScript 的調用就可以實現與服務器的通信,以便在已加載成功的頁面發起請求,再通過 DOM 操作實現頁面的局部刷新,在早期返回的數據格式是 XML,但是隨着更加輕量級的 JSON 出現,現在 Ajax 調用多返回 JSON 格式數據,與返回完整 HTML 文檔不同,局部刷新返回的數據體量更小。
Ajax 雖好,但是仍然沒有從根本上解決 HTTP 的問題,請求還是得從客戶端發起,而且客戶端也感知不到服務器上資源的更新,如果想要獲取某個部分的實時數據,還是得頻繁發起 Ajax 請求,造成通信的浪費,只是這個工作不用用戶做,可以交給 JavaScript 定時器去做,而且基於 Ajax 獲取資源也不會刷新頁面,對用戶來說,體驗上已經好很多。
為了徹底解決實時顯示服務端資源的問題,必須有一種機制能夠在服務器資源有更新的時候能夠將更新實時推送到客戶端,而為了實現這種機制,誕生了 WebSocket 技術。
WebSocket
WebSocket 本來是作為 HTML5 的一部分,而現在卻變成了一個獨立的協議,它是 Web 客戶端與服務器之間實現全雙工通信的標准。既然是全雙工,就意味着不是之前那種只能從客戶端向服務器發起請求的單向通信,服務端在必要的時候也可以推送信息到客戶端,而不是被動接收客戶端請求再返回響應。
一旦客戶端與服務器之間建立起了基於 WebSocket 協議的通信連接,之后所有的通信都依靠這個協議進行,雙方可以互相發送 JSON、XML、HTML、圖片等任意格式的數據。由於 WebSocket 是基於 HTTP 協議的,所以連接的發起方還是客戶端,而一旦建立起 WebSocket 連接,不論是服務器還是客戶端,都可以直接向對方發送報文。
為了實現 WebSocket 的通信,在 HTTP 連接建立之后,還需要完成一次「握手」的步驟:
1)請求階段
WebSocket 復用了 HTTP 的握手通道,要建立 WebSocket 通信,需要在連接發起方的 HTTP 請求報文中通過 Upgrade 字段告知服務器通信協議升級到 Websocket,然后通過 Sec-WebSocket-* 擴展字段提供 WebSocket 的協議、版本、鍵值等信息:

2)響應階段
對於上述握手請求,服務器會返回 101 Switching Protocols 響應表示協議升級成功:

響應頭中 Sec-WebSocket-Accept 字段的值是根據請求頭中 Sec-WebSocket-Key 的字段值生成的,兩者結合起來用於防止惡意連接和意外連接。
成功握手確立 WebSocket 連接后,后續通信就會使用 WebSocket 數據幀而不是 HTTP 數據幀。下面是 WebSocket 通信的時序圖:

WebSocket 協議對應的 scheme 是 ws,如果是加密的 WebSocket 對應的 scheme 是 wss,域名、端口、路徑、參數和 HTTP 協議的 URL 一樣。
介紹完 WebSocket 的基本原理,給大家介紹 WebSocket 的客戶端和服務器簡單實現,客戶端部分基於 JavaScript 的 WebSocket API 即可,服務器將基於 Swoole 實現。
WebSocket 客戶端和服務端的簡單實現

WebSocket 復用了 HTTP 協議來實現握手,通過 Upgrade 字段將 HTTP 協議升級到 WebSocket 協議來建立 WebSocket 連接,一旦 WebSocket 連接建立之后,就可以在這個長連接上通過 WebSocket 數據幀進行雙向通信,客戶端和服務端可以在任何時候向對方發送報文,而不是 HTTP 協議那種服務端只有在客戶端發起請求后才能響應,從而解決了在 Web 頁面實時顯示最新資源的問題。
在本篇分享中學院君將在服務端基於 Swoole 實現簡單的 WebSocket 服務器,然后在客戶端基於 JavaScript 實現 WebSocket 客戶端,通過這個簡單的實現加深大家對 WebSocket 通信過程的理解。
WebSocket 服務器
PHP 異步網絡通信引擎 Swoole 內置了對 WebSocket 的支持,通過幾行 PHP 代碼就可以寫出一個異步非阻塞多進程的 WebSocket 服務器:
<?php
// 初始化 WebSocket 服務器,在本地監聽 8000 端口
$server = new Swoole\WebSocket\Server("localhost", 8000);
// 建立連接時觸發
$server->on('open', function (Swoole\WebSocket\Server $server, $request) {
echo "server: handshake success with fd{$request->fd}\n";
});
// 收到消息時觸發推送
$server->on('message', function (Swoole\WebSocket\Server $server, $frame) {
echo "receive from {$frame->fd}:{$frame->data},opcode:{$frame->opcode},fin:{$frame->finish}\n";
$server->push($frame->fd, "this is server");
});
// 關閉 WebSocket 連接時觸發
$server->on('close', function ($ser, $fd) {
echo "client {$fd} closed\n";
});
// 啟動 WebSocket 服務器
$server->start();
將這段 PHP 代碼保存到 websocket_server.php 文件。
WebSocket 客戶端
在客戶端,可以通過 JavaScript 調用瀏覽器內置的 WebSocket API 實現 WebSocket 客戶端,實現代碼和服務端差不多,無論服務端還是客戶端 WebSocket 都是通過事件驅動的,我們在一個 HTML 文檔中引入相應的 JavaScript 代碼:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Chat Client</title>
</head>
<body>
<script>
window.onload = function () {
var nick = prompt("Enter your nickname");
var input = document.getElementById("input");
input.focus();
// 初始化客戶端套接字並建立連接
var socket = new WebSocket("ws://localhost:8000");
// 連接建立時觸發
socket.onopen = function (event) {
console.log("Connection open ...");
}
// 接收到服務端推送時執行
socket.onmessage = function (event) {
var msg = event.data;
var node = document.createTextNode(msg);
var div = document.createElement("div");
div.appendChild(node);
document.body.insertBefore(div, input);
input.scrollIntoView();
};
// 連接關閉時觸發
socket.onclose = function (event) {
console.log("Connection closed ...");
}
input.onchange = function () {
var msg = nick + ": " + input.value;
// 將輸入框變更信息通過 send 方法發送到服務器
socket.send(msg);
input.value = "";
};
}
</script>
<input id="input" style="width: 100%;">
</body>
</html>
將這個 HTML 文檔命名為 websocket_client.html。在命令行啟動 WebSocket 服務器:
php websocket.php
然后在瀏覽器中訪問 websocket_client.html,首先會提示我們輸入昵稱:

輸入之后點擊確定,JavaScript 代碼會繼續往下執行,讓輸入框獲取焦點,然后初始化 WebSocket 客戶端並連接到服務器,這個時候通過開發者工具可以看到 Console 標簽頁已經輸出了連接已建立日志:

這個時候我們在輸入框中輸入「你好,WebSocket!」並回車,即可觸發客戶端發送該數據到服務器,服務器接收到消息后會將其顯示出來:

同時將「This is server」消息推送給客戶端,客戶端通過 onmessage 回調函數將獲取到的數據顯示出來。在開發者工具的 Network->WS 標簽頁可以查看 WebSocket 通信細節:

看起來,這個過程還是客戶端觸發服務器執行推送操作,但實際上,在建立連接並獲取到這個客戶端的唯一標識后,后續服務端資源有更新的情況下,仍然可以通過這個標識主動將更新推送給客戶端,而不需要客戶端發起拉取請求。WebSocket 服務器和客戶端在實際項目中的實現可能會更加復雜,但是基本原理是一致的。
HTTP/2.0 簡介

目前主流的 HTTP 通信都是基於 HTTP/1.1 的,而 HTTP/1.1 自 1999 年發布的 RFC2616 之后再未進行過修訂,而隨着互聯網的蓬勃發展,HTTP/1.1 自身所暴露的問題也越來越多,於是負責互聯網技術標准的 IETF 組織創建了專門的工作組來推進下一代 HTTP —— HTTP/2.0 的標准化,其首要目標就是解決 HTTP 的性能瓶頸,縮短 Web 頁面的加載時間。
HTTP/1.1 的性能瓶頸主要有以下這些:
- 一條連接同時只能發送一個請求;
- 請求只能從客戶端發起;
- 請求/響應首部未經壓縮就直接發送,首部信息越多延遲越大;
- 每次請求/響應都會發送冗長的首部信息,造成通信的浪費;
為了解決這些問題,HTTP/2.0 會對 HTTP 首部(或者叫做 HTTP 頭)進行一定的壓縮,將原來每次通信都要攜帶的大量頭信息(鍵值對)在兩端建立一個索引表,對相同的頭只發送索引表中的索引。
另外,HTTP/2.0 協議會將一個 TCP 連接切分成多個流,每個流都有自己的 ID,而且流可以是客戶端發往服務端,也可以是服務端發往客戶端,為了解決並發請求導致響應慢的問題,還可以為流設置優先級。HTTP/2.0 還將所有的傳輸信息分割為更小的消息和幀,並對它們采用二進制格式編碼,常見的幀有 Header 幀,用於傳輸 HTTP 頭信息,並且會開啟一個新的流;再就是 Data 幀,用來傳輸 HTTP 報文實體,多個 Data 幀屬於同一個流。
通過這兩種機制,HTTP/2.0 的客戶端可以將多個請求分到不同的流中,以實現在一個 TCP 連接上處理所有請求,然后將請求內容拆分成幀,進行二進制傳輸,這些幀可以打散亂序發送,然后根據每個幀首部的流標識符重新組裝,並且可以根據優先級,決定優先處理哪個流的數據。
這樣一來,HTTP/2.0 成功消除了 HTTP/1.1 的性能瓶頸和限制,減少了 TCP 連接數對服務器性能的影響,同時可以將頁面的多個 css、js、 圖片等資源通過一個數據鏈接進行傳輸,能夠加快頁面組件的傳輸速度。
HTTP/2.0 雖然大大增加了並發性,但還是有問題的,為 HTTP/2.0 還是基於 TCP 協議的,TCP 協議在建立連接時有額外的開銷,在處理包時有嚴格的順序要求,當其中一個數據包遇到問題,TCP 連接需要等待這個包完成重傳之后才能繼續進行。要解決這些問題只能通過 UDP 協議來實現才可以最大化提升 Web 的性能,不過這就屬於另一個話題了。
HTTP/2.0 標准於 2015 年以 RFC7540 正式發表,具體的標准化工作由 Chrome、Opera、Firefox、IE、Safari、Edge 等瀏覽器提供支持,目前主流瀏覽器都已經支持了該協議。
