上一節我們講述了websocket在swoole中的使用,並且我們也給出了一個簡單的聊天模型,不同的客戶端可以相互發消息。有些同學不以為然,server有swoole提供強大的API,客戶端由h5提供websocket API,操作很方便,沒感覺到什么問題呀,這一章節是否有存在的必要性呢?
有,非常有。今天我們就針對websocket中常見的幾個問題做一個詳細的總結說明,具體要說的重點大概有下面3個
- 心跳檢測的必要性
- 校驗客戶端連接的有效性
- 客戶端的重連機制
我們分別來看下
心跳檢測
還記得我們在進程模型一文中介紹的Master進程嗎?當時我們說過,Master進程,包括主線程,多個Reactor線程等。其實主進程內還包括其他線程,比如我們現在講的心跳檢測,在Master進程內就有專門用於心跳檢測的線程。
那到底什么是心跳檢測呢?說着websocket,怎么談到要醫治病人了?這個心跳檢測呢,是server定時檢測客戶端是否還連接的意思,即server定時檢測client是否還活着,所以我們說的專業點就是所謂的心跳檢測。
等等,老師你說“定時檢測”?是不是說之前學的定時器可以派上用場了?
怎么感覺之前講的不教你在實際場景中運用一次你就不會似的。當然,你要是用定時器也沒問題,不過呢,我們都說有專門的心跳檢測線程的存在了,所以,我們只需要簡單的配置,開啟這個心跳檢測線程就可以了。
有同學還有疑問,server我們有onClose回調,客戶端斷開連接我們可以主動關閉連接或者刪除客戶端的映射關系,再者說,即使連接無效,斷了就斷了唄,反正我的server面向的client也沒有多少,心跳檢測就真的有存在的必要性么?
正常情況下,不需要。客戶端斷開連接能夠通知到server,server自然也就可以主動關閉連接。但是,有很多非正常情況的存在,比如斷電斷網尤其是移動網絡盛行的當下,二者之間建立的友好關系(連接)非常不穩定,這就必然會導致大量的fd(fd的數量是有限的,還記得最大是多少嗎?)被浪費!所以為了解決這些問題,swoole內置了心跳檢測機制。
我們只需要做如下簡單的配置即可
$serv->set([
'heartbeat_check_interval' => N,
'heartbeat_idle_time' => M,
]);
如上,分別配置heartbeat_check_interval和heartbeat_idle_time參數,二者配合使用,其含義就是N秒檢查一次,看看哪些連接M內沒有活動的,就認為這個連接是無效的,server就會主動關閉這個無效的連接。
是不是說N秒server會主動向客戶端發一個心跳包,沒有收到客戶端響應的才認為這個連接是死連接呢?那還要heartbeat_idle_time做什么,對吧?
swoole的實現原理是這樣的:server每次收到客戶端的數據包都會記錄一個時間戳,N秒內循環檢測下所有的連接,如果M秒內該連接還沒有活動,才斷開這個連接。
心跳檢測的問題,記得自己動手實踐實踐哦,有不懂的可以下面給我留言。
校驗客戶端連接的有效性
按照我們上文創建的websocket server,當然只有本地的ip才能連接上,因為server監聽的ip是127.0.0.1。實際項目上線后,如果你的websocket server是對外開放的,就需要把ip修改為服務器外網的ip地址或者修改為0.0.0.0。
如此,也便帶來了新的問題:
任意客戶端都可以連接到我們的server了,這個“任意”可不止我們自己認為有效的客戶端,還包括你的我的所有的非有效或者惡意的連接,這可不是我們想要的。
如何避免這一問題呢?方法有很多種,比如我們可以在連接的時候認為只有get傳遞的參數valid=1才允許連接;或者我們只允許登錄用戶才可以連接server;再或者我們可以校驗客戶端每次send所攜帶的token,server對該值校驗通過后才認為當前是有效連接等等。與此同時,server開啟心跳檢測,對於惡意無效的連接,直接干掉!
上面簡單的介紹了一些解決方案,下面我們以client 連接server時攜帶token為例做一個實際說明。
首先我們只允許登錄用戶才可以連接server,假設某用戶的唯一標識uid=100,token的生成規則我們約定如下:token=md5(md5(uid)+key),其中key=客戶端和服務端雙方約定的某個字符串,我們這里假設key="^manks.top&swoole$",不包括雙引號。
server的代碼實現如下(詳細的代碼參考WebSocketServerValid.php )
<?php
class WebSocketServerValid
{
private $_serv;
public $key = '^manks.top&swoole$';
public function __construct()
{
$this->_serv = new swoole_websocket_server("127.0.0.1", 9501);
$this->_serv->set([
'worker_num' => 1,
'heartbeat_check_interval' => 30,
'heartbeat_idle_time' => 62,
]);
$this->_serv->on('open', [$this, 'onOpen']);
$this->_serv->on('message', [$this, 'onMessage']);
$this->_serv->on('close', [$this, 'onClose']);
}
/**
* @param $serv
* @param $request
*/
public function onOpen($serv, $request)
{
$this->checkAccess($serv, $request);
}
/**
* @param $serv
* @param $frame
*/
public function onMessage($serv, $frame)
{
$this->_serv->push($frame->fd, 'Server: ' . $frame->data);
}
public function onClose($serv, $fd)
{
echo "client {$fd} closed.\n";
}
/**
* 校驗客戶端連接的合法性,無效的連接不允許連接
* @param $serv
* @param $request
* @return mixed
*/
public function checkAccess($serv, $request)
{
// get不存在或者uid和token有一項不存在,關閉當前連接
if (!isset($request->get) || !isset($request->get['uid']) || !isset($request->get['token'])) {
$this->_serv->close($request->fd);
return false;
}
$uid = $request->get['uid'];
$token = $request->get['token'];
// 校驗token是否正確,無效關閉連接
if (md5(md5($uid) . $this->key) != $token) {
$this->_serv->close($request->fd);
return false;
}
}
public function start()
{
$this->_serv->start();
}
}
$server = new WebSocketServerValid;
$server->start();
可以看到,checkAccess是授權方法,我們在onOpen回調內對uid以及token進行了校驗,無效則關閉連接。
為了模擬效果,我們分別貼上兩種客戶端代碼,連接失敗和連接成功
連接失敗的主要jsdiamante如下(詳細代碼見源碼的websocket-client-faild.html)
var ws = new WebSocket('ws://127.0.0.1:9501');
ws.onopen = function(event) {
ws.send('This is websocket client.');
};
ws.onmessage = function(event) {
console.log(event.data);
};
ws.onclose = function(event) {
console.log('Client has closed.\n');
};
無論是console控制台還是server終端我們都可以看到客戶端連接被關閉的提醒。下面我們再看模擬一種成功的結果
部分php代碼和js代碼如下(詳細代碼見源碼的websocket-client-success.html)
<?php
$key = '^manks.top&swoole$';
$uid = 100;
$token = md5(md5($uid) . $key);
?>
<script>
var ws = new WebSocket("ws://127.0.0.1:9501?uid=<?php echo $uid; ?>&token=<?php echo $token; ?>");
ws.onopen = function(event) {
ws.send('This is websocket client.');
};
ws.onmessage = function(event) {
console.log(event.data);
};
ws.onclose = function(event) {
console.log('Client has closed.\n');
};</script>
可以看到,這次連接沒有被關閉且console控制台會正常輸出一些信息
Server: This is websocket client.
即我們完成了校驗連接有效性的案例,下面我們接着看最后一個問題
客戶端重連機制
有同學注意到,我們剛剛設置的心跳檢測時間是30秒,如果客戶端62秒內沒有與server通信,server會關閉該連接,即部分人在上述success案例中的console控制台上會看到Client has closed.的提醒。這是我們設置的機制,屬於正常現象。
那我們要說的重連機制又是什么呢?
客戶端重連機制又可以理解為一種保活機制,你也可以跟服務端的心跳檢測在一起理解為雙向心跳。即我們有一種需求是,如何能保證客戶端和服務端的連接一直是有效的,不斷開的。
其實很簡單,對客戶端而言,只要觸發error或者close再或者連接失敗,就主動重連server,這便是我們的目的。
下面貼一段js代碼,來解決這個問題(詳細代碼見commentClient.html)
<script>
var ws;//websocket實例
var lockReconnect = false;//避免重復連接
var wsUrl = 'ws://127.0.0.1:9501';
function createWebSocket(url) {
try {
ws = new WebSocket(url);
initEventHandle();
} catch (e) {
reconnect(url);
}
}
function initEventHandle() {
ws.onclose = function () {
reconnect(wsUrl);
};
ws.onerror = function () {
reconnect(wsUrl);
};
ws.onopen = function () {
//心跳檢測重置
heartCheck.reset().start();
};
ws.onmessage = function (event) {
//如果獲取到消息,心跳檢測重置
//拿到任何消息都說明當前連接是正常的
heartCheck.reset().start();
}
}
function reconnect(url) {
if(lockReconnect) return;
lockReconnect = true;
//沒連接上會一直重連,設置延遲避免請求過多
setTimeout(function () {
createWebSocket(url);
lockReconnect = false;
}, 2000);
}
//心跳檢測
var heartCheck = {
timeout: 60000,//60秒
timeoutObj: null,
serverTimeoutObj: null,
reset: function(){
clearTimeout(this.timeoutObj);
clearTimeout(this.serverTimeoutObj);
return this;
},
start: function(){
var self = this;
this.timeoutObj = setTimeout(function(){
//這里發送一個心跳,后端收到后,返回一個心跳消息,
//onmessage拿到返回的心跳就說明連接正常
ws.send("");
self.serverTimeoutObj = setTimeout(function(){//如果超過一定時間還沒重置,說明后端主動斷開了
ws.close();//如果onclose會執行reconnect,我們執行ws.close()就行了.如果直接執行reconnect 會觸發onclose導致重連兩次
}, self.timeout);
}, this.timeout);
}
}
createWebSocket(wsUrl);
</script>
在這種情況下,你可以嘗試把server中斷或者斷網試試,結果是client會不停的每隔一定時間嘗試連接server,直至連接成功。