浅析WebSocket及实践

项目中有两个业务逻辑需要Server端主动推送Client端,之前使用了ajax轮询,众所周知,该方式缺点很明显,资源浪费,大量无用请求,消耗大量的宽带内存,查阅资料后遂决定使用WebSocket重构该业务。

介绍:WebSocket 是伴随HTML5发布的基于TCP的一种新的持久化协议,真正实现了全双工通信,详细介绍戳这里:https://mp.weixin.qq.com/s/7aXMdnajINt0C5dcJy2USg
优点:client只需一次握手就可以持久化连接,通信实时且高效,server端可以主动推送等

以下记录了我使用websocket及部署上线(PHP):

建立流程

客户端建立连接时请求报文

GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://example.com
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13

可以看到跟http报文不同的是多了两行

Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ== //是由浏览器随机生成的,提供基本的防护,防止恶意或者无意的连接。
Sec-WebSocket-Version: 13

这两行表示发起websocket连接
服务端收到连接后通过握手算法加密Sec-WebSocket-Key然后拼接返回报文头返回给客户端
加密方法:

  • 将 Sec-WebSocket-Key 跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接;
  • 通过 SHA1 计算出摘要,并转成 base64 字符串。
    然后服务端告知客户端握手成功,升级协议完成,至此,持久化连接已建立,后边就是该我们登场了,可以处理我们的业务逻辑,进行主动推送了

详细流程及数据报文戳这里https://segmentfault.com/a/1190000014643900

Socket关键函数简介

socket_create
//创建一个socket套接字   正确时返回一个套接字,失败时返回 FALSE 
socket_create ( int $domain , int $type , int $protocol ) 

参数 $domain 代表可用的协议

  • AF_INET IPv4协议
  • AF_INET6 很明显这个是IPv6协议
  • AF_UNIT 本地通讯协议

参数 $type 是套接字的类型

  • SOCK_STREAM 可靠的,顺序的,双全工,基于连接的字节流(TCP协议基于此类型)
  • SOCK_DGRAM 支持数据报文,无连接,不可靠,固定最大长度 (UDP协议一般基于这个)
  • SOCK_SEQPACKET 顺序化的、可靠的、全双工的、面向连接的、固定最大长度的数据通信
  • SOCK_RAW 支持原始网络协议
  • SOCK_RDM 支持可靠的数据层,但不保证到达顺序。

参数 $protocol 设定套接字的具体协议
使用函数 getprotobyname()进行读取,也可以直接使用常量 SOL_TCP(TCP协议) 和 SOL_UDP (UDP协议)

socket_last_error socket_strerror
//用户获取socket的错误信息 返回错误代码(int)
socket_last_error ([ resource $socket ] )
//错误代码通过此方法获取详细文字说明
socket_strerror(int $errno )

以上是socket错误信息的获取函数

socket_bind
//给套接字绑定名字
socket_bind ( resource $socket , string $address [, int $port = 0 ] )

socket_bind函数很好理解,给套接字绑定地址$address和端口$port用于访问

socket_listen
//监听套接字连接
socket_listen( resource $socket [, int $backlog= 0 ])

socket_listen则用于监听套接字的连接,第二个参数$backlog表示并发连接数,如果超过这个连接数将被排队等待处理

socket_select
//检测一个或者多个socket是否可读或者可写,或者有错误产生。返回可操作的socket数目,出错返回FALSE
socket_select ( array &$read , array &$write , array &$except , int $tv_sec [, int $tv_usec = 0 ] )

socket_select 函数用来检查socket连接,可以看到参数$read就是可以读的,同理$write就是可以写的,而$except就是异常,最后$tv_sec代表超时时间。我们可以用这个函数用来监视连接server端的client端连接,以此来检查client端的状态。

socket_accept
//接收套接字的资源信息,成功返回套接字的信息资源,失败为FALSE。
socket_accept( resource $socket )

socket_accept用来接收socket连接,连接成功后会返回一个新的socket资源用来交互,没有连接,进程将会堵塞,直到有连接进来

socket_recv socket_read
//从连接的套接字接收数据
socket_recv ( resource $socket , string &$buf , int $len , int $flags )
//同样也是读取数据
socket_read ( resource $socket , int $length [, int $type = PHP_BINARY_READ ] ) 

socket_recv函数有4个参数,参数$socket是socket_accept返回的新的socket资源,参数$buf是一个缓冲区,用来存储接收到的数据 $len则是你打算接收缓冲区数据的长度,$flags是一个标志,一般设置为0 。
socket_read 函数有2个必须参数,第一个参数$socket也是socket_accept返回的新socket资源,参数$length同样也是打算接收数据的长度。
这两个函数都是用来读取数据的,socket_recv 相比 socket_read 的优势在于可以检测socket是否关闭,而且支持多个flags,建议使用socket_recv

socket_write
//向连接的socket写入数据
socket_write ( resource $socket , string $buffer [, int $length = 0 ] )

写数据的函数,第一个参数$socket为要写入数据的socket套接字,第二个参数$buffer就是要写入的数据,可选参数则为写入数据的长度

socket_close
//主动关闭socket连接
socket_close( resource $socket )

关键的socket函数大概就是这些,熟悉了之后就可以拿来实践了

Server实现

server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

            // 设置IP和端口重用,在重启服务器后能重新使用此端口;
            socket_set_option($this->server, SOL_SOCKET, SO_REUSEADDR, 1);

            // 将IP和端口绑定在服务器socket上;
            socket_bind($this->server, $host, $port);
            
            //监听端口 第二个参数是并发连接数(超过连接数请求将被堵塞直达队列有请求释放)
            socket_listen($this->server, 10);

        } catch (\Exception $e) {

            //出现异常捕获并写入错误日志
            $err_code = socket_last_error();
            $err_msg = socket_strerror($err_code);
            $this->writeLog('error',['error_init_server',$err_code, $err_msg]);
        }

        $this->sockets[0] = ['resource' => $this->server];
        
        //写入日志
        $this->writeLog('debug',["Socket服务: {$this->server} {$host}:{$port} 正在运行!"]);

        //控制台输出
        echo "Socket服务: {$this->server} {$host}:{$port} 正在运行!";
        
    }

    /**
    * 服务入口
    */
    public function run(){
        //运行服务 一直循环保证有client连接时及时更新连接池
        while (true) {
            try {
                $this->server();
            } catch (\Exception $e) {
                $this->writeLog('error',['error_server',$e->getCode(),$e->getMessage()]);
            }
        }

    }

    /**
    * 服务逻辑处理
    */
    private function server() {
        $write = $except = NULL;
        $sockets = array_column($this->sockets, 'resource');
        // select 监视函数,参数分别是(监视可读,可写,异常,超时时间),返回可操作数目,出错时返回false;
        $readNum = socket_select($sockets, $write, $except, NULL);
        if ( $readNum === false ) {
            $this->writeLog('error',['sockets_select',socket_strerror(socket_last_error())]);
            return;
        }

        foreach ($sockets as $socket) 
        {
            // 如果可读的是服务器socket ( Client客户端第一次连接Server服务端时 )
            if ($this->server == $socket) {
                /*
                accept函数将会接受socket要来的连接,一旦有一个连接成功,将会返回一个新的socket资源用以交互,如果是一个多个连接的队列,只会处理第一个,如果没有连接的话,进程将会被阻塞,直到连接上
                */
                $newClient = socket_accept($this->server);
                if ($newClient === false) {
                    $this->writeLog('error',['socket_accept',socket_strerror($socket)]);
                    continue;
                } else {
                    //放入连接池
                    self::connect($newClient);
                    continue;
                }
            } else {
                // 读取数据
                $bytes = @socket_recv($socket, $buffer, 2048, 0);

                if ($bytes < 7) { 
                    //关闭客户端
                    $info = $this->disconnect($socket);
                } else {
                    //判断是否握手 
                    if (!$this->sockets[(int)$socket]['handshake']) {
                        //给客户端握手
                        self::handShake($socket, $buffer);
                        continue;
                    } 
                    //处理客户端发过来的消息
                    $info = self::parse($buffer);
                }

                //处理业务逻辑
                $msg = self::processBusiness($socket, $info);
                //推送消息
                $this->publishMessage($msg);
            }
        }
    }

    /**
     * 将socket添加到已连接列表,但握手状态留空;
     * @param $socket
     */
    public function connect($socket) {
        socket_getpeername($socket, $ip, $port);
        $info = [
            'resource'  =>  $socket,
            'handshake' =>  false,
            'ip'        =>  $ip,
            'port'      =>  $port,
        ];
        $this->sockets[(int)$socket] = $info;
        $this->writeLog('debug',array_merge(['socket_connect'], $info));
    }

    /**
     * 关闭客户端连接
     * @param $socket
     * @return array
     */
    private function disconnect($socket) {
        socket_close($socket);
        unset($this->sockets[(int)$socket]);
        $resource['param'] = 'disconnect';
        return $resource;
    }

    /**
     * 公共握手算法
     * @param $socket
     * @param $buffer
     * @return bool
     */
    public function handShake($socket, $buffer) {
        // 获取到客户端的升级密匙
        $line_with_key = substr($buffer, strpos($buffer, 'Sec-WebSocket-Key:') + 18);
        $key = trim(substr($line_with_key, 0, strpos($line_with_key, "\r\n")));
        // 生成升级密匙,并拼接websocket升级头
        $upgrade_key = base64_encode(sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true));// 升级key的算法
        $upgrade_message = "HTTP/1.1 101 Switching Protocols\r\n";
        $upgrade_message .= "Upgrade: websocket\r\n";
        $upgrade_message .= "Sec-WebSocket-Version: 13\r\n";
        $upgrade_message .= "Connection: Upgrade\r\n";
        $upgrade_message .= "Sec-WebSocket-Accept:" . $upgrade_key . "\r\n\r\n";
        socket_write($socket, $upgrade_message, strlen($upgrade_message));// 向socket里写入升级信息
        $this->sockets[(int)$socket]['handshake'] = true;
        socket_getpeername($socket, $ip, $port);
        $this->writeLog('debug',[
            'hand_shake',
            $socket,
            $ip,
            $port
        ]);
        // 向客户端发送握手成功消息;
        $msg = [
            'code' =>  1,
            'type' => 'hand',
            'data' => "连接成功",
        ];
        $msg = $this->build(json_encode($msg));
        socket_write($socket, $msg, strlen($msg));
        return true;
    }

    /**
     * 解析数据
     * @param $buffer
     * @return bool|string
     */
    private function parse($buffer) {
        $decoded = '';
        $len = ord($buffer[1]) & 127;
        if ($len === 126) {
            $masks = substr($buffer, 4, 4);
            $data = substr($buffer, 8);
        } else if ($len === 127) {
            $masks = substr($buffer, 10, 4);
            $data = substr($buffer, 14);
        } else {
            $masks = substr($buffer, 2, 4);
            $data = substr($buffer, 6);
        }
        for ($index = 0; $index < strlen($data); $index++) {
            $decoded .= $data[$index] ^ $masks[$index % 4];
        }
        //return $decoded;
        //这里客户端发送过来的是json数据 所以需要转化为数组
        return json_decode($decoded, true);
    }
    /**
     * 将普通信息组装成websocket数据帧
     * @param $msg
     * @return string
     */
    private function build($msg) {
        $frame = [];
        $frame[0] = '81';
        $len = strlen($msg);
        if ($len < 126) {
            $frame[1] = $len < 16 ? '0' . dechex($len) : dechex($len);
        } else if ($len < 65025) {
            $s = dechex($len);
            $frame[1] = '7e' . str_repeat('0', 4 - strlen($s)) . $s;
        } else {
            $s = dechex($len);
            $frame[1] = '7f' . str_repeat('0', 16 - strlen($s)) . $s;
        }
        $data = '';
        $l = strlen($msg);
        for ($i = 0; $i < $l; $i++) {
            $data .= dechex(ord($msg{$i}));
        }
        $frame[2] = $data;
        $data = implode('', $frame);
        return pack("H*", $data);
    }

    /**
     * 处理业务逻辑
     * @param $socket  socket字节流
     * @param $request 客户端请求参数
     * @return string
     */
    private function processBusiness($socket, $request) {
        $response = [];
        $response['type'] = $request['param'];
        //根据类型做对应业务处理即可 这里只做实例
        switch ($request['param']) {
            case 'online':
                // 取得在线人数
                $response['code'] = 1;
                $response['data'] = count($this->sockets) - 1;
                break;
            case 'disconnect':
                //断开连接
                $response['code'] = 0;
                $response['data'] = count($this->sockets) - 1;
                break;
           case 'message':
                $response['code'] = 1;
                $response['data'] = "我是websocket服务端啊";
                break;
             //更多业务处理请自行编写
            default:
                $response['type'] = 'close';
                $response['code'] = 0;
                $response['data'] = "未收到任何请求参数";     
        }
        return $response;
    }

    /**
     * 推送消息
     * @param $data
     */
    public function publishMessage($msg) {
        //在线人数需要一直推送
        if($msg['type'] != 'online'){
            $online = [
                'code'=>1,
                'type'=>'online',
                'data'=>count($this->sockets) - 1
            ];
            $onlineBuild = $this->build(json_encode($online));
        }
        //编码消息
        $message = $this->build(json_encode($msg));
        foreach ($this->sockets as $socket) {
            //不用给当前服务器推送
            if ($socket['resource'] == $this->server) {
                continue;
            }
            //消息写入客户端 参数(socket套接字,数据信息,数据长度)
            socket_write($socket['resource'], $message, strlen($message));

            if($msg['type'] != 'online'){ //在线人数推送
                socket_write($socket['resource'], $onlineBuild, strlen($onlineBuild));
            }
        }
    }


     /**
     * 日志处理 如果有需要 请自行修改路径
     * @param array $info
     */
     private function writeLog(string $type,array $info)
     {
        $time = date('Y-m-d H:i:s');
        array_unshift($info, $time);
        $info = array_map('json_encode', $info); //转化为json字符串
        switch ($type) {
            case 'debug':
                file_put_contents('./websocket_debug.log', implode(' | ', $info) . "\r\n", FILE_APPEND);
                break;
            
           case 'error':
                file_put_contents('./websocket_error.log', implode(' | ', $info) . "\r\n", FILE_APPEND);
                break;
        }

    }


}

Client端

websocket提供一系列的api供我们使用,非常简洁明了,很容易上手开发


//由于项目网站使用了ssl,所以我们选择wss  注:5shop.com不是真实项目地址 这里只做示例
//var ws = new WebScoket('ws://127.0.0.1:9803');
var ws = new WebSocket('wss:///5shop.com/websocket'); //这里使用wss协议而不是ws

ws.onopen = function(){
    showMsg("websocket连接成功");
}

ws.onerror = function () {
    showMsg("websocket连接失败!");
};

ws.onmessage = function(event){
    
    let data = JSON.parse(event.data); 
    switch(data.type){
        case 'online': 
            onlineRequest.show(data);
            break;
        case 'message':
            console.log(data);
            messageRequest.show(data);
            break;
        case 'hand': //收到握手信息 获取在线人数及消息
            console.log("webSocket:收到信息",event.data);
            onlineRequest.send();
            messageRequest.send();
            break;
        case 'disconnect': //断开连接
            console.log("有一台主机断开连接!当前主机连接数为"+data.data);
            showMsg("当前连接主机数:":data.data.data);
            break;  
    }
}

ws.onclose = function(){
    showMsg("WebSocket服务器已关闭");
    ws.close();
}


//在线人数
var onlineRequest = {
    show : function(data){
        if(data.code == 1){
            showMsg("当前连接主机数:":data.data.data);
        }
    },
    send : function(){
        let msg = {'param':'online'};
        sendServerMessage(msg);
    }
};

var messageRequest = {
    show : function(data){
        
        if(data.code == 1){
            showMsg("收到一条消息:":data.data.data);
        }
        
    },
    send : function(){
        let msg = {'param':'message' };
        sendServerMessage(msg);
    }

}


function showMsg(msg){
    var show = document.getElementById("msg");
    var msgs = document.createElement("p");
    msgs.innerHTML = msg;
    show.appendChild(msgs);
    show.scrollTop = show.scrollHeight;
}



function sendServerMessage(msg){
    let data = JSON.stringify(msg);
    console.log(data);
    if(ws.readyState == 1){
        ws.send(data);
    }
}

//刷新或关闭页面时 需要主动关闭websocket 否则server端会报异常而退出
window.onbeforonload = function(){
    ws.send('disconnect');
    ws.close();
};

启动服务

这里使用cli模式启动服务(也就是win下cmd或powershell)
本项目使用了ThinkPHP框架 可以自定义命令行 这里我们使用提前定义好的命令行来启动(其实也就是执行server类)

namespace app\command;
use app\index\controller\Publish;
use think\console\Command;

class Sub extends Command
{
    protected  function  configure()
    {
        //命令名称
        $this->setName("sub:message")->setDescription("接收订阅消息");
    }

    public function execute()
    {
       $server = new Publish('127.0.0.1',9803);
       $server->run();
    }


}
 php think sub:message

可以看到服务已经启动


image.png

打开客户端(此处我打开了四个测试又关闭了一个 可以看到每次发生变化时都实时推送)


image.png

上线部署

遇到的问题

  • 项目使用了ssl协议,使用ws报错

was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://example.com/'. This request has been blocked; this endpoint must be available over WSS

此处也提示了,需要使用wss,跟ws是一样的,但是使用了wss后依旧连接不上,查阅资料后,发现需要修改nginx配置

 location /websocket {
        proxy_pass http://127.0.0.1:9803;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

大概意思是将请求 /websocket的连接代理到127.0.0.1:9803 也就是我们的socket服务,并且将连接设置为upgrade ,至此这个问题得以解决。(这个方法并没有使用证书,只是代理到已经启动的服务端上,算是折中的一种解决方案)

端口号和ip可以自行设置,这里ip我设置的本地,端口号数字大一些保证不重复

  • 守护进程
    命令行启动后,如果不是守护进程的方式,在关闭终端的时候socket服务就会关闭。
    php-cli并没有守护进程模式,这里我借助linux下的systemd来解决这个问题
    linux中启动项以前是一直采用init进程,例如:
    $ sudo /etc/init.d/apache start
    或者
    service apache restart
    该方式有两个缺点,只能串行启动(也就是必须等待前一个进程启动完才可以继续启动下一个),再者就是启动脚本复杂。所以systemd由此而生,在linux中d就是守护进程的缩写。
    首先配置一个启动文件
    cd /etc/systemd/system/ #进入系统启动目录
    vi websocket.service #建立websocket服务配置文件
    文件内容
[Unit]  
Description=php daemon for beanstalkd  
After=network.target  
StartLimitIntervalSec=0  
[Service]  
Type=simple  
Restart=always  
RestartSec=1  
User=root  
ExecStart=/bin/php /home/wwwroot/project/admin/think sub:message   //这行是脚本目录请自行更换
  
[Install]  
WantedBy=multi-user.target

/home/wwwroot/project/admin/think sub:message是项目socket服务路径
然后重新载入配置项
systemctl daemon-reload
载入成功后执行
systemctl start websocket

tips:websocket名字可以自定义,但是要对应配置文件

至此,websocket重构业务逻辑完成

你可能感兴趣的:(浅析WebSocket及实践)