## swoole+Redis實現即時資料推送
<?php/** * *************************************** * 單進程保護 * * *************************************** */$phpSelf = realpath($_SERVER['PHP_SELF']);$lockFile= $phpSelf.'.lock';$lockFileHandle = fopen($lockFile, "w");if ($lockFileHandle == false) {exit("Can not create lock file $lockFile\n");}if (!flock($lockFileHandle, LOCK_EX + LOCK_NB)) {exit(date("Y-m-d H:i:s")."Process already exist.\n");}/** * *************************************** * 進入程式,定義相關配置 * * *************************************** */set_time_limit(0);//socket會話的逾時時間,根據業務情境設定,這裡設定為永不逾時//如果設定了時間,則從socket建立=>傳輸=>關閉整個過程必須在定義的時間內完成,否則自動close該socket並拋出warningini_set('default_socket_timeout', -1);$conf = array('listen' => array('host' => '0.0.0.0','port' => '8008'),'setting' => array(//程式允許的最大串連數,用以設定server最大允許維持多少個TCP串連,超過該數量後,新串連將被拒絕,預設為ulimit -n的值,如果設定大於ulimit -n則強制重設為ulimit- n,如果確實需要設定超過ulimit -n的值,請修改系統值 vim /etc/security/limits.conf 修改nofile的值"max_conn"=> 1024,//啟用CPU親和設定(在全非同步非阻塞是可啟用),在多核的伺服器中,啟用此特性會將swoole的reactor線程/worker進程綁定到固定的一個核上。可以避免進程/線程的運行時在多個核之間互相切換,提高CPU Cache的命中率,如何確定綁定在了哪個核上,請參考文檔, 查看命令: taskset -p 進程id'open_cpu_affinity'=> 0,//配置task進程數量,配置此參數後將會啟用task功能。所以Server務必要註冊onTask、onFinish2個事件回呼函數。如果沒有註冊,伺服器程式將無法啟動.Task進程是同步阻塞的,配置方式與Worker同步模式一致。'task_worker_num'=> 20,//設定task進程的最大任務數。一個task進程在處理完超過此數值的任務後將自動結束。這個參數是為了防止PHP進程記憶體溢出。如果不希望進程自動結束可以設定為0, 預設是0'task_max_request'=> 1024, //設定task的資料臨時目錄,在swoole_server中,如果投遞的資料超過8192位元組,將啟用臨時檔案來儲存資料。這裡的task_tmpdir就是用來設定臨時檔案儲存的位置。'task_tmpdir'=> '/tmp/',//worker進程數量,根據業務代碼的模式作調整,全非同步非阻塞可設定為CPU核心數的1-4倍;同步阻塞,請參考文檔調整'worker_num'=> 8,//指定swoole錯誤記錄檔檔案'log_file' => '/tmp/log/log.txt',//SSL公開金鑰和私密金鑰的位置,啟用wss必須在編譯swoole時加入--enable-openssl選項'ssl_cert_file'=> '/usr/local/nginx/conf/server.cer','ssl_key_file'=> '/usr/local/nginx/conf/server.key',),);/** * *************************************** * 初始化Redis串連 * * *************************************** */$redis = null;$redis = new Redis();$redis->connect(REDIS_HOST, REDIS_PORT);$redis->auth(REDIS_PWD);$GLOBALS['redis']=$redis;/** * *************************************** * 指令碼重啟時,清除曆史的資料 * * *************************************** */$sArr = $redis->sMembers(REDIS_S_KEY);if (!empty($sArr)) {foreach ((array)$sArr as $key => $sc) {$fdArr = $redis->sMembers(REDIS_S_FD.$sc);foreach ((array)$fdArr as $k => $fd) {$res1 = $redis->del(REDIS_FD_S.$fd);}$res2 = $redis->del(REDIS_S_FD.$sc);}$redis->del(REDIS_S_KEY);}$redis->del(REDIS_ZS_KEY);/** * *************************************** * 綁定回調事件 * * *************************************** */$ws = null;//wss服務$ws = new swoole_websocket_server($conf['listen']['host'], $conf['listen']['port'], SWOOLE_PROCESS, SWOOLE_SOCK_TCP | SWOOLE_SSL);$ws->set($conf['setting']);/** * Server啟動在主進程的主線程回調此函數 * 在此事件之前Swoole Server已進行了如下操作 * 已建立了manager進程 * 已建立了worker子進程 * 已監聽所有TCP/UDP連接埠 * 已監聽了定時器 * 在onStart中建立的全域資來源物件不能在worker進程中被使用,因為發生onStart調用時,worker進程已經建立好了。新建立的對象在主進程內,worker進程無法訪問到此記憶體地區。因此全域對象建立的代碼需要放置在swoole_server_start之前 */$ws->on('start', function ($ws) {swoole_set_process_name(PROCESS_NAME.'_master');});/** * 與onStart回調在不同進程中並存執行的回呼函數(不存在先後順序) * @param: $ws swoole_websocket_server object * @param: $wid 建立該進程時swoole分配的id(不是進程id) * 注意點: * 1. 此事件在worker進程/task進程啟動時發生。onWorkerStart/onStart是並發執行的,沒有先後順序,這裡建立的對象可以在進程生命週期內使用 * 2. swoole1.6.11之後task_worker中也會觸發onWorkerStart,故而在下面的處理中,加入了判斷業務類型$jobType是task還是work,如果是task則命名為****_Tasker_$id,如果是worker則命名為****_Worker_$id * 3. 發生PHP致命錯誤或者代碼中主動調用exit時,Worker/Task進程會退出,管理進程會重新建立新的進程 * 5. 如果想使用swoole_server_reload實現代碼重載入,必須在workerStart中require你的業務檔案,而不是在檔案頭部。在onWorkerStart調用之前已包含的檔案,不會重新載入代碼。 * 6. 可以將公用的,不易變的php檔案放置到onWorkerStart之前(例如上面的redis配置)。這樣雖然不能重載入代碼,但所有worker是共用的,不需要額外的記憶體來儲存這些資料。 * 7. onWorkerStart之後的代碼每個worker都需要在記憶體中儲存一份 */$ws->on('workerstart', function ($ws, $wid) {$jobType = $ws->taskworker ? 'Tasker' : 'Worker';swoole_set_process_name(PROCESS_NAME.'_'.$jobType.'_'.$wid);$GLOBALS['ws'] = $ws; //儲存server對象到全域中以待使用if ($jobType == 'Worker') { //在某個worker進程上綁定redis訂閱進程if ($wid === 0) { $dataRedis = null; $dataRedis = new Redis(); $dataRedis->connect(REDIS_HOST_DATA, REDIS_PORT_DATA); $dataRedis->auth(REDIS_PWD_DATA); //使用psubscribe訂閱指定模式的頻道,這裡*表示所有頻道 //請注意,redis訂閱不提供區分庫(db)的功能,所以多個庫都同時在發布同一個名字的頻道時,都將被訂閱到$dataRedis->psubscribe(array("*"), "sendTask");}}});/** * 管理進程啟用時,調用該回呼函數 * 注意manager進程中不能添加定時器 * manager進程中可以調用sendMessage介面向其他背景工作處理序發送訊息 */$ws->on('managerstart', function ($ws) {swoole_set_process_name(PROCESS_NAME.'_manage');});/** * swoole websocket服務特有的回呼函數,此函數在websocket伺服器中必須定義實現,否則websocket服務將無法啟動 * 當伺服器收到來自用戶端的資料幀時會回調此函數 * @param: $ws為swoole_websocket_server對象,其結構在調試時可var_dump查看 * @param: $frame為swoole_websocket_frame對象,包含了用戶端發來的資料幀資訊,包含以下四個屬性: * @param: $frame->fd: 用戶端的socket id,每個id對應一個用戶端,推送訊息的時候需要指定 * @param: $frame->data: 資料內容,可以是常值內容或者是位元據(圖片等),可以通過opcode的值來判斷。$data 如果是文本類型,編碼格式必然是UTF-8,這是WebSocket協議規定的 * @param: $frame->opcode: WebSocket的OpCode類型,可以參考WebSocket協議標準文檔, WEBSOCKET_OPCODE_TEXT = 0x1 ,文本資料; WEBSOCKET_OPCODE_BINARY = 0x2 ,位元據 * @param: $frame->finish: 表示資料幀是否完整,一個WebSocket請求可能會分成多個資料幀進行發送 * 注意點: 用戶端發送的ping幀不會觸發onMessage,底層會自動回複pong包 */$ws->on('message', function ($ws, $frame) { echo "Server has receive message\n"; //接收到用戶端請求,並建立串連之後,進行相應業務的處理 handleClientData($ws, $frame);});/** * 在task_worker進程內被調用。worker進程可以使用swoole_server_task函數向task_worker進程投遞新的任務(此處使用的是taskwait) * 當前的Task進程在調用onTask回呼函數時會將進程狀態切換為忙碌,這時將不再接收新的Task,當onTask函數返回時會將進程狀態切換為空白閑然後繼續接收新的Task。 * @param: $ws swoole_websocket_server object * @param: $tid task process id * @param: $wid from id 表示來自哪個Worker進程。$task_id和$wid組合起來才是全域唯一的,不同的worker進程投遞的任務ID可能會有相同 * @param: $data 需要執行的任務內容 * 注意點: onTask函數執行時遇到致命錯誤退出,或者被外部進程強制kill,當前的任務會被丟棄,但不會影響其他正在排隊的Task */$ws->on('task', function ($ws, $tid, $wid, $data) {switch ($data['cmd']) {case 'pushToClient': $ret = pushToClientTask($ws, $data['key'], $data['val']); break;}//1.7.2以上的版本,在onTask函數中 return字串,表示將此內容返回給worker進程。worker進程中會觸發onFinish函數,表示投遞的task已完成。return的變數可以是任意非null的PHP變數return $returnContent;//1.7.2以前的版本,需要調用swoole_server->finish()函數將結果返回給worker進程// $ws->finish($data);});/** * 當worker進程投遞的任務在task_worker中完成時,task進程會通過$ws->finish()方法將任務處理的結果發送給worker進程。 * @param: $ws swoole_websocket_server object * @param: $tid task_id * @param: $data 任務處理後的結果內容 * 注意點: task進程的onTask事件中沒有調用finish方法或者return結果,worker進程不會觸發onFinish * 執行onFinish邏輯的worker進程與下發task任務的worker進程是同一個進程 */$ws->on('finish', function($ws, $tid, $data) {});/** * TCP用戶端串連關閉後,在worker進程中回調此函數 * 在函數中可以做一些類似於刪除業務中與每個用戶端互動時存放的資料的操作 * @param: $ws swoole_websocket_server object * @param: $fd 已關閉的fd interger * @param: $rid(可選),來自哪個reactor線程 * 注意點: * 1. onClose回呼函數如果發生了致命錯誤,會導致串連泄漏。通過netstat命令會看到大量CLOSE_WAIT狀態的TCP串連 * 2. 查看命令netstat -anopc | grep 連接埠號碼,可以查看到TCP接收和發送隊列是否有堆積以及TCP串連的狀態 * 3. 無論由用戶端發起close還是伺服器端主動調用$serv->close()關閉串連,都會觸發此事件。因此只要串連關閉,就一定會回調此函數 * 4. 1.7.7+版本以後onClose中依然可以調用connection_info方法擷取到串連資訊,在onClose回呼函數執行完畢後才會調用close關閉TCP串連 * 5. 這裡回調onClose時表示用戶端串連已經關閉,所以無需執行$server->close($fd)。代碼中執行$serv->close($fd)會拋出PHP錯誤警示。也就是在onclose中不能再$ws->close()了. * 6. swoole-1.9.7版本修改了$reactorId參數,當伺服器主動關閉串連時,底層會設定此參數為-1,可以通過判斷$reactorId < 0來分辨關閉是由伺服器端還是用戶端發起的(debug時可以使用) */$ws->on('close', function ($ws, $fd) {$redis = new Redis();$redis->connect(REDIS_HOST, REDIS_PORT);$redis->auth(REDIS_PWD);$sArr = $redis->sMembers(REDIS_FD_S.$fd);if (!empty($sArr)) {foreach ((array)$sArr as $key => $sc) {$res = $redis->sRem(REDIS_S_FD.$sc, $fd);$num = $redis->sCard(REDIS_S_FD.$sc);if ($num == '0') {$redis->sRem(REDIS_S_KEY, $sc);$redis->hDel(REDIS_ZS_KEY, $sc);}}}$redis->del(REDIS_FD_S.$fd);$redis->close();echo "FD $fd has closed.\n";});/** * 開啟swoole_websocket_server服務 */$ws->start();/** * 接受到訊息以後進行響應非同步任務的執行 * @param: $ws swoole_websocket_sever object * @param: $frame swoole_websocket_frame obejct */function handleClientData($ws, $frame) {$data = $frame->data;$redis = new Redis();$redis->connect(REDIS_HOST, REDIS_PORT);$redis->auth(REDIS_PWD);$isMembers = $redis->sIsmember(REDIS_S_FD.$sc, $frame->fd);if (!$isMembers) {$res = $redis->sAdd(REDIS_S_FD.$sc, $frame->fd);}$redis->sAdd(REDIS_FD_S.$frame->fd, $sc);$isMembers = $redis->sIsmember(REDIS_S_KEY, $sc);if (!$isMembers) { $redis->sAdd(REDIS_S_KEY, $sc);}}/** * redis訂閱後的回呼函數 * @param: $ins instance執行個體 * @param: $pattern 匹配模式 * @param: $channel 頻道名 * @param: $data 資料 * 注意點: subscribe和psubscribe兩種不同的訂閱者式的回呼函數的參數個數不一樣,後者多了$pattern參數 */function sendTask($ins, $pattern, $channel, $data) {//滿足一些條件後,投遞到task進程中進行推送$taskData = array('cmd' => 'pushToClient','key' => $sc,'val' => $data,);//請注意,taskwait是同步阻塞的,所以改指令碼並不是全非同步非阻塞的$GLOBALS['ws']->taskwait($taskData);}/** * 推送訊息到指定的用戶端 * @param: $ws swoole_websocket_server object * @param: $sc 股票代號 * @param: $data 要推送的資料 */function pushToClientTask($ws, $sc, $data) { $redis = new Redis(); $redis->connect(REDIS_HOST, REDIS_PORT); $redis->auth(REDIS_PWD);$fdList = $redis->sMembers(REDIS_S_FD.$sArr[4]);if (!empty($fdList)) {foreach ((array)$fdList as $fd) {$res = $GLOBALS['ws']->push($fd, $data);echo "FD: $fd push $res.\n";if (!$res) { //推送失敗,即用戶端已經中斷連線//從該fd訂閱的所有股票中刪除該fd$sArrOfFd = $redis->sMembers(REDIS_FD_S.$fd);if (!empty($sArrOfFd)) {foreach ((array)$sArrOfFd as $key => $sc) {$res = $redis->sRem(REDIS_S_FD.$sc, $fd);$num = $redis->sCard(REDIS_S_FD.$sc);if ($num == '0') {$redis->sRem(REDIS_S_KEY, $sc);$redis->hDel(REDIS_ZS_KEY, $sc);}}}$redis->del(REDIS_FD_S.$fd);}}} $redis->close();}