摘要:
隨着手機游戲、H5游戲以及微信小游戲的普及,越來越多的客戶端-服務器端的通訊采用websocket協議。Websocket協議是全雙工的、基於數據幀的、建立在tcp之上的長連接協議。Websocket的協議是頭是字符串的兼容http的,而握手之后的數據幀則是緊湊的二進制,所以websocket是緊湊和高效的。現在主流的PC瀏覽器以及手機瀏覽器對websocket都實現了非常成熟的支持。Websocket協議有着統一的標准的,所有websocket通訊無論實現的語言如何,無論使用的終端如何,最終都是一致的。
Websocket的有點有:
- Websocket有公共的標准,有很多公共的庫可以使用,比如web端,各個瀏覽器都已原生的支持websocket,所以拿來即用,非常的方便。比如cocos2dx就繼承了websocket。
- 比如游戲使用了websocket,那么就可以非常容易的用web調用js發websocket消息,從而模擬客戶端的操作。
- Websocket相對於http是長連接的,這樣就可以實現實時的推送消息。
- Websocket既能支持文本格式也可以支持二進制格式,這樣無論是js還是c++,都可以適當的選擇自己喜歡的數據格式。
Websocket可以說完全治好了大家關於長連接使用什么協議的糾結。再游戲行業,服務器一般都是使用C++專門開發的網絡程序,常規的一般都是使用比較傳統的二進制協議,現在想用websocket的人越來越多,但是可以用於服務器端的websocket庫卻很少,要不就是庫太重量級依賴了太多不需要的模塊要不就是綁定了特定的網絡接口實現,github上搜了下還websocket庫很少。下面介紹一下我的通用websocket解析庫,具有如下特點。
- 輕量,只封裝websocket的解析,不依賴任何網絡接口,拿來即用。
- 邏輯清晰,你可以直接看代碼,直接能夠理解websocket的協議。
- One header file only。全部實現就在一個頭文件里,集成不能再容易了。
- 目前提供C++和c#的實現。別的語言我就沒空寫了,剛興趣的可以照貓畫虎來一個。
Websocket消息頭:
模擬發送websocket非常的容易,我們寫一個很簡單的html+js就可以實現,當然你可以直接使用我的這個模擬客戶端: https://fanchy.github.io/client.html。比如我們輸入ip為127.0.0.1端口44000,將會受到這樣的文本協議。
GET /chat HTTP/1.1
Host: 127.0.0.1:44000
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36
Upgrade: websocket
Origin: https://fanchy.github.io
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Sec-WebSocket-Key: 8SIMf+o8pqn1RCe/ivxtPg==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
關鍵參數有:
- Get /chat 這個是客戶端指定的目錄,我們做游戲服務器的,基本上根據目錄區分服務器,只根據端口區分服務器,所以這個參數實際上可以忽略。
- Upgrade: websocket 這個必須有,這個是兼容http的需要,有這個字段說明這個不是普通的http是一個websocket的連接。
- Sec-WebSocket-Version版本號,可以忽略。
- Sec-WebSocket-Key這個是用作握手的key,具體使用見下文。
Websocket協議的驗證
我們游戲服務器可能使用多種協議,比如同時兼容二進制協議和websocket協議。因為有websocket一定是GET開頭的,所以我們可以通過驗證第一個消息是不是帶GET字符串從而判斷對方連接是websocket連接還是普通連接。示例代碼:
if (statusWebSocketConnection == -1)
{
return false;
}
cacheRecvData.append(buff, len);
if (dictParams.empty() == true)
{
std::string& strRecvData = cacheRecvData;
if (strRecvData.size() >= 3)
{
if (strRecvData.find("GET") == std::string::npos)
{
statusWebSocketConnection = -1;
return false;
}
}
else if (strRecvData.size() >= 2)
{
if (strRecvData.find("GE") == std::string::npos)
{
statusWebSocketConnection = -1;
return false;
}
}
else
{
if (strRecvData.find("G") == std::string::npos)
{
statusWebSocketConnection = -1;
return false;
}
}
statusWebSocketConnection = 1;
if (strRecvData.find("\r\n\r\n") == std::string::npos)//!header data not end
{
return true;
}
if (strRecvData.find("Upgrade: websocket") == std::string::npos)
{
statusWebSocketConnection = -1;
return false;
}
std::vector<std::string> strLines;
strSplit(strRecvData, strLines, "\r\n");
for (size_t i = 0; i < strLines.size(); ++i)
{
const std::string& line = strLines[i];
std::vector<std::string> strParams;
strSplit(line, strParams, ": ");
if (strParams.size() == 2)
{
dictParams[strParams[0]] = strParams[1];
}
else if (strParams.size() == 1 && strParams[0].find("GET") != std::string::npos)
{
dictParams["PATH"] = strParams[0];
}
}
Websocket的握手
Websocket因為要兼容http,所以會發一個常規的http的協議頭,然后進行一次握手從而建立安全連接。Websocket握手的時候也就是建立連接后第一個消息會包含Sec-WebSocket-Key這個字段,服務器接收到這個字段后追加一個固定的guid值"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",然后做sha1加密並轉base64變成可見字符返回給客戶端。
if (dictParams.find("Sec-WebSocket-Key") != dictParams.end())
{
const std::string& Sec_WebSocket_Key = dictParams["Sec-WebSocket-Key"];
std::string strGUID = Sec_WebSocket_Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
std::string dataHashed = sha1Encode(strGUID);
std::string strHashBase64 = base64Encode(dataHashed.c_str(), dataHashed.length(), false);
char buff[512] = {0};
snprintf(buff, sizeof(buff), "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n", strHashBase64.c_str());
addSendPkg(buff);
}
組裝成websocket協議頭如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: mzjDI+C9Ekz6tc/f5gWv38L5Hu0=
客戶端收到服務器的這個應答消息后,握手完成,連接建立完成,開始數據傳輸。
數據幀
與tcp的流式數據不同,與http相似,websocket使用幀的方式傳輸數據,這樣解包實際上是方便的,根據長度解析消息包這個最清晰了。
ABNF如下圖所示:
- FIN:1 bit,如果不是分片,這個就是1,如果是分片,並且不是最后一個片,那么就是0
- RSV1, RSV2, RSV3: 每個1 bit,簡單說用不到
- Opcode: 4 bits, 0,1, 2都代表數據,8代表關閉連接,0X9為ping,0XA為pong其他用不到。
- Mask: 1 bit 這個客戶端必須是1.
- Payload length: 7 bits, 7+16 bits, 或者 7+64 bits,,如果是小於126就用一個字節表示數據長度,如果等於126,表示后續2字節表示長度,如果是127后續8字節表示長度。
- Masking-key: 0 or 4 bytes 客戶端發送的必須有掩碼
- Payload data不出意外剩下的就是數據了。
int nFIN = ((cacheRecvData[0] & 0x80) == 0x80)? 1: 0;
int nOpcode = cacheRecvData[0] & 0x0F;
//int nMask = ((cacheRecvData[1] & 0x80) == 0x80) ? 1 : 0; //!this must be 1
int nPayload_length = cacheRecvData[1] & 0x7F;
int nPlayLoadLenByteNum = 1;
if (nPayload_length == 126)
{
nPlayLoadLenByteNum = 3;
}
int nMaskingKeyByteNum = 4;
std::string aMasking_key;
aMasking_key.assign(cacheRecvData.c_str() + 1 + nPlayLoadLenByteNum, nMaskingKeyByteNum);
std::string aPayload_data;
aPayload_data.assign(cacheRecvData.c_str() + 1 + nPlayLoadLenByteNum + nMaskingKeyByteNum, nPayload_length);
int nLeftSize = cacheRecvData.size() - (1 + nPlayLoadLenByteNum + nMaskingKeyByteNum + nPayload_length);
if (nLeftSize > 0)
{
std::string leftBytes;
leftBytes.assign(cacheRecvData.c_str() + 1 + nPlayLoadLenByteNum + nMaskingKeyByteNum + nPayload_length, nLeftSize);
cacheRecvData = leftBytes;
}
for (int i = 0; i < nPayload_length; i++)
{
aPayload_data[i] = (char)(aPayload_data[i] ^ aMasking_key[i % nMaskingKeyByteNum]);
}
if (8 == nOpcode)
{
addSendPkg(buildPkg("", nOpcode));// close
bIsClose = true;
}
else if (2 == nOpcode || 1 == nOpcode || 0 == nOpcode || 9 == nOpcode)
{
if (9 == nOpcode)//!ping
{
addSendPkg(buildPkg("", 0xA));// pong
}
if (nFIN == 1)
{
if (dataFragmentation.size() == 0)
{
addRecvPkg(aPayload_data);
}
else
{
dataFragmentation += aPayload_data;
addRecvPkg(dataFragmentation);
dataFragmentation.clear();
}
}
else
{
dataFragmentation += aPayload_data;
}
}
Ping/pong/close
收到ping就發pong,有可能ping的時候也帶着數據,所以要處理下。但是貌似Chrome很長時間不會自動發ping。
服務器收到close消息可以回一個消息應答一下,也可以直接關閉連接。
集成到網絡層
在自己的socket里加一個WSProtocol對象,在收到消息的地方一般是HandleRecv函數里加一段WSProtocol判斷和處理的代碼就可以了,示例如下:
if (m_oWSProtocol.handleRecv(buff, len))
{
const vector<string>& waitToSend = m_oWSProtocol.getSendPkg();
for (size_t i = 0; i < waitToSend.size(); ++i)
{
sp_->sendRaw(waitToSend[i]);
}
m_oWSProtocol.clearSendPkg();
const vector<string>& recvPkg = m_oWSProtocol.getRecvPkg();
for (size_t i = 0; i < recvPkg.size(); ++i)
{
const string& eachRecvPkg = recvPkg[i];
uint16_t nCmd = 0;
m_message.getHead().cmd = nCmd;
m_message.appendToBody(eachRecvPkg.c_str(), eachRecvPkg.size());
m_message.getHead().size = eachRecvPkg.size();
this->post_msg(sp_);
m_message.clear();
}
m_oWSProtocol.clearRecvPkg();
if (m_oWSProtocol.isClose())
{
sp_->close();
}
return 0;
}
總結:
- 本文實現的websocket為純協議解析,不依賴網絡層,這樣想用老的網絡層支持websocket就非常容易啦。
- 本實現就一個頭文件,依賴OpenSSL(sha1加密)
- Github地址 https://github.com/fanchy/h2engine/tree/master/fflib/net/wsprotocol.h
- 同時提供一個c#的版本https://github.com/fanchy/h2engine/blob/master/workercs/fflib/wsprotocol.cs
- 歸屬於項目:h2engine 地址:https://github.com/fanchy/h2engine,感興趣的可以star。
- 這是我的github主頁https://github.com/fanchy,有些有意思的分享。