1、WebSocket是什么
WebSocket是一種在單個TCP連接上進行全雙工通信的協議,其目的是在瀏覽器和服務器之間建立一個不受限的雙向通信的通道,使得服務器可以主動發送消息給瀏覽器。在HTML5中包含了WebSocket API規范。
WebSocket 協議在2008年誕生,2011年成為國際標准。目前所有瀏覽器都已經支持。
根據安全與否,與HTTP/HTTPS類似,WebSocket有ws/wss兩種協議。
(需要注意的是,目前瀏覽器對WebSocket未做同源策略限制,因此采用WebSocket的應用需要注意防范跨站請求偽造)
2、Web雙向通信方案
所謂雙向通信,關鍵在於服務端”主動“能發信息給客戶端。
傳統的HTTP協議是一個請求-響應協議,是無狀態的:請求必須先由瀏覽器發給服務器,服務器才能響應這個請求,再把數據發送給瀏覽器。即服務端處於被動狀態,只有在收到客戶端請求后才能響應發數據給客戶端,且請求與響應一一對應。
在WebSocket之前,在Web要實現類似雙向通信功能,只能通過 ajax poll (輪詢)或 long poll (長輪詢) 等。
1、ajax poll (輪詢):客戶端每隔幾秒發送請求詢問服務端是否有新消息,服務器接收到請求后馬上返回並關閉連接。
優點:后台實現簡單。
缺點:實時性不夠;TCP建立和關閉操作浪費時間和帶寬,頻繁請求造成大訪問壓力,且有很多是無用請求,浪費帶寬和服務器資源。
實例:小型應用
2、long poll (長輪詢):本質也是輪詢,不同的是客戶端發起請求后,服務端若沒有新消息則hold阻塞,直到有消息才返回並關閉連接。
優點:在無消息的情況下不會頻繁請求,耗費資源小。
缺點:以多線程模式運行的服務器會讓大部分線程大部分時間都處於掛起狀態,極大浪費服務器資源;HTTP長時間沒傳數據,該連接可能被網關關閉,不可控,故需要發”心跳“。
實例:WebQQ、Hi網頁版、Facebook IM
3、HTTP長連接:一個TCP連接可以發送多次HTTP請求,而不是傳統那樣每個請求都重新建立一個連接。
在頁面里嵌入一個隱蔵iframe,將這個隱蔵iframe的src屬性設為對一個長連接的請求或是采用xhr請求,服務器端就能源源不斷地往客戶端輸入數據。
優點:消息即時到達,不發無用請求;管理起來也相對方便。
缺點:服務器維護一個長連接會增加開銷,當客戶端越來越多的時候,server壓力大。
4、Flash Socket:在頁面中內嵌入一個使用了Socket類的 Flash 程序,JavaScript通過調用此Flash程序提供的Socket接口與服務器端的Socket接口進行通信,JavaScript在收到服務器端傳送的信息后控制頁面的顯示。
優點:實現真正的即時通信,而不是偽即時。
缺點:客戶端必須安裝Flash插件,移動端支持不好,IOS系統中沒有flash的存在;非HTTP協議,無法自動穿越防火牆。
實例:網絡互動游戲。
3、WebSocket的特點
WebSocket的出現可以取代輪詢和長連接,客戶端不用定期輪詢(網關問題仍存在,故WebSocket內部也定期發送”心跳“),其特點有:
1、建立在 TCP 協議之上,服務器端的實現比較容易。(基於TCP,所以可以支持全雙工通信)
2、與 HTTP 協議有着良好的兼容性。默認端口也是80和443,並且握手階段采用 HTTP 協議,因此握手時不容易屏蔽,能通過各種 HTTP 代理服務器。
3、數據格式比較輕量,性能開銷小,通信高效。
4、可以發送文本,也可以發送二進制數據。(通常用JSON,方便處理)
5、沒有同源限制,客戶端可以與任意服務器通信。
相對於傳統HTTP每次請求-應答都需要客戶端與服務端建立連接的模式,WebSocket是類似Socket的TCP長連接通訊模式。一旦WebSocket連接建立后,后續數據都以幀序列的形式傳輸。在客戶端斷開WebSocket連接或Server端中斷連接前,不需要客戶端和服務端重新發起連接請求。在海量並發及客戶端與服務器交互負載流量大的情況下,極大的節省了網絡帶寬資源的消耗,有明顯的性能優勢,且客戶端發送和接受消息是在同一個持久連接上發起,實時性優勢明顯。
4、WebSocket協議原理
可以視為兩個階段,先借助HTTP進行握手,握手完成后就建立了連接以后直接雙向通信。
1、握手階段利用HTTP協議發送一次握手請求,協商升級到WebSocket協議(101 Switching Protocols)。握手請求是一個標准HTTP請求,格式如下:

請求格式示例: GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13 Origin: http://example.com 響應格式示例: HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk= Sec-WebSocket-Protocol: chat
2、握完手后與HTTP協議無關了,客戶端和服務端直接建立了連接,可以直接雙向主動發送數據
WebSocket 是獨立創建在TCP上的協議,HTTP協議中的那些概念都和WebSocket 沒有關聯,唯一關聯的是使用HTTP協議的 101 狀態碼進行協議切換。
5、WebSocket實踐(SocketIO)
關於WebSocket的API,前端有WebSocket API已經成為HTML5標准的一部分,后端有很多框架,Java中也有很多。這里以SocketIO為例.
5.1、SocketIO
SocketIO是基於WebSocket實現的一個跨平台的實時通信庫,基於engine.io實現。engine.io 使用了 WebSocket 和 XMLHttprequest或JSONP封裝了一套自己的 Socket 協議(暫時叫 EIO Socket),在低版本瀏覽器里面使用長輪詢替代 WebSocket。一個完整的 EIO Socket 包括多個 XHR 和 WebSocket 連接。
SocketIO不僅支持WebSocket,為了兼容有些瀏覽器不支持WebSocket的問題還提供了降級功能:
Websocket
Adobe® Flash® Socket
AJAX long polling
AJAX multipart streaming
Forever Iframe
JSONP Polling
這些降級功能對用戶來說是透明的,SocketIO會根據瀏覽器支持情況進行自動選擇。
此外,SocketIO還提供了命名空間、自動重連等功能。
關於SocketIO的庫很多:
5.2、SocketIO示例
Java服務端:
依賴:(netty-socketio、socket-io-client)

<dependency> <groupId>io.socket</groupId> <artifactId>socket.io-client</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>com.corundumstudio.socketio</groupId> <artifactId>netty-socketio</artifactId> <version>1.7.12</version> </dependency>
Java服務端代碼示例:

public class SocketServer { private final Logger logger = LoggerFactory.getLogger(this.getClass()); private static SocketIOServer server = initServer(); /** * 初始化服務端 * * @return */ private static SocketIOServer initServer() { Configuration config = new Configuration(); config.setHostname("localhost"); config.setPort(9090); config.setContext("/wsapi");// 前端連接時通過指定path與此對應 config.setAuthorizationListener(new AuthorizationListener() {// 授權 @Override public boolean isAuthorized(HandshakeData data) { String token = data.getSingleHeader("X-Authorization");// cannot // get,always // null return true; } }); server = new SocketIOServer(config); return server; } /** * 啟動服務端 */ public void startServer() { // 添加連接監聽 server.addConnectListener(new ConnectListener() { @Override public void onConnect(SocketIOClient socketIOClient) { String acid = socketIOClient.getHandshakeData().getSingleUrlParam("acid");// 前端連接時帶上的參數 String clientId = socketIOClient.getSessionId().toString(); logger.info("server 服務端啟動成功"); } }); // 添加斷開連接監聽 server.addDisconnectListener(new DisconnectListener() { @Override public void onDisconnect(SocketIOClient socketIOClient) { logger.info("server 服務端斷開連接"); } }); // 添加事件監聽 server.addEventListener("join", String.class, new DataListener<String>() { @Override public void onData(SocketIOClient socketIOClient, String str, AckRequest ackRequest) throws Exception { logger.info("收到客戶端加入消息:" + str); server.getBroadcastOperations().sendEvent("joinSuccess", "join success"); } }); // 啟動服務端 server.start(); } /** * 停止服務端 */ public void stopServer() { server.stop(); } }
前端代碼示例:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <script src="https://cdn.bootcss.com/socket.io/2.1.1/socket.io.dev.js"></script> <title>socketio-client</title> </head> <body> <br> <div style="border-style:solid"> <button id="preBtn">預請求</button> <p id="engineInfoContainer"></p> </div> <br> <div style="border-style:solid"> <button id="engineBtn">引擎響應</button> <p id="engineCallInfoContainr"></p> </div> <script> var wsEventKey4Req = "expRequest"; var wsEventKey4Res = "expResponse"; var token = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ6aGFuZ3NhbiIsInNjb3BlcyI6WyJST0xFX1NUVURFTlQiXSwidXNlcklkIjoiMTdmNDIyYmQtNzQzOC00NTA0LWJhZTItZGU0ZmU1ZDg4MmUyIiwiaXNzIjoiemh1eWFuYm8iLCJpYXQiOjE1MzIzMTE5MjcsImV4cCI6MTUzNTkxMTkyN30._BCXb_ukWmpummKGXwcYTvJVHjPjlPyDC59C3-anLehnmWL-PBSJOrKBU9Vsa3Wom3ARCji3vOI32LlPYj3SVg"; var socket = io.connect('http://localhost:8090?X-Authorization=' + token, { "path":"/wsapi" } ); socket.on('connect', function () { console.log("connected to server") ; socket.emit("zsmtest","hello there, I'm client."); }); socket.on('disconnect', function () { console.log("server disconnected") }); console.log(socket); //front call below //namespace, room // socketio.of('/private').in('chat').send("send to all the clients in the chat room which belong to namespace(private)"); // socketio.of('/private').send("send to all the clients which belong to namespace(priavte)"); // socketio.send("send to the clients which belong to default namespace(/)"); // socket.broadcast.in('chat').emit('message', "send to the clients which belong to namespace(socket belong to) except sender"); // socket.broadcast.emit('message', "send to the clients which belong to namespace(socket belong to) except sender"); document.getElementById("preBtn").onclick = function () { var data = { mykey: 'clientdata' }; socket.emit(wsEventKey4Req, data); console.log("send data: " + JSON.stringify(data)); } socket.on(wsEventKey4Req, function (data) { console.log(data); document.getElementById("engineInfoContainer").textContent = JSON.stringify(data); }); //engine call below document.getElementById("engineBtn").onclick = function () { var data = { engineHost: 'sensetime', enginePort:8888 }; data={clientId:"c883779f-1bfb-4101-b679-0805ea1d84ee", data:data}; socket.emit(wsEventKey4Res, data); } socket.on(wsEventKey4Res, function (data) { console.log(data); document.getElementById("engineCallInfoContainr").textContent = JSON.stringify(data); }); </script> </body> </html>
socket.io 提供了三種默認的事件(客戶端和服務器都有):connect 、message 、disconnect 。當與對方建立連接后自動觸發 connect 事件,當收到對方發來的數據后觸發 message 事件(通常為 socket.send() 觸發),當對方關閉連接后觸發 disconnect 事件。
此外,socket.io 還支持自定義事件,畢竟以上三種事件應用范圍有限,正是通過這些自定義的事件才實現了豐富多彩的通信。
最后,需要注意的是,在服務器端區分以下三種情況:
socket.emit() :向建立該連接的客戶端廣播
socket.broadcast.emit() :向除去建立該連接的客戶端的所有客戶端廣播
io.sockets.emit() :向所有客戶端廣播,等同於上面兩個的和
遇到的坑:
- 關於協議支持。上述的Java SocketIO client 版本(1.0.0)不支持WebSocket,只支持polling(服務端則兩者都支持),故client 需要指定Transports: options.transports = new String[] { "polling" }; ,否則需要使用者自己code配合server實現升級到WebSocket協議。前面說法錯誤,完全支持websocket,且比polling穩定,示例(注意該Client庫所用的HTTP工具為okhttp,后者默認支持並發數為5):
options.forceNew = true; options.transports = new String[] { "websocket" }; ConnectionPool connectionPool = new ConnectionPool(200, 10, TimeUnit.SECONDS); OkHttpClient okHttpClient = new OkHttpClient.Builder().connectionPool(connectionPool).build(); options.webSocketFactory = okHttpClient; options.callFactory = okHttpClient;
- 關於連接復用。SocketIo client默認會復用連接,導致server對不同請求拿到的sessionId一樣。因此若在server基於該sessionId來維護與相應SocketIoClient的映射則可能會出問題(在本人實踐中Socket server等待其他服務發來的消息,根據消息里的sessionId字段轉發給相應SocketIoClient,發完后移除對應SocketIoClient,由於sessionId有重,導致后續相同sessionId的消息找不到相應client從而造成某些client收不到消息)。解決:調用者(客戶端)設置參數以禁用連接復用: options.forceNew = true; (不管是用polling還是websocket均如是)
- 關於ACK。對於addEventListener添加的事件(onConnect等預定義事件不屬於此),server收到client發過來的數據后觸發addEventListener中的onData方法,方法最后默認會調用AckRequest.sendData以回發收到消息確認信息(當然亦可手動調用)。
這里的AckRequest是client所傳的ack對象,若client在emit未傳該對象則client在發送數據后會一直等待直到超時(不傳ack對象的話,在並發壓測時服務端表現為不能立馬收到所有client發送的數據,而是5個一批超時后再下一批,why??)。
故client emit時須帶上ack對象,若不帶,則server在收到消息時往任意eventKey上回發任意數據此時client也會當成已確認收到(why??)。示例:server.addEventListener(wsEvent4FrontExpReq, Map.class, new DataListener<Map>() { @Override public void onData(SocketIOClient socketIOClient, Map reqMap, AckRequest ackRequest) throws Exception { if (!ackRequest.isAckRequested()) { socketIOClient.sendEvent(UUID.randomUUID().toString(), "got message"); // ackRequest.sendAckData("got message"); } // else // { // ackRequest.sendAckData("got message");// 會在本方法結束后自動被調用 // } //business code... } }
其他相關:
如何帶認證參數:官方文檔中說通過extraHeaders不過只在polling模式下生效故不可取,可以在連接url中作為參數傳輸不過url可以直接看到故不安全。
設置context path:如代碼中所示,前端通過連接時的path參數設置,不設置則默認為 /socket.io
更多啟動選項設置參考:https://github.com/socketio/engine.io-client#methods
如:transportOptions (Object): hash of options, indexed by transport name, overriding the common options for the given transport
6、相關資料
https://socket.io/get-started/chat/