【原】WebSocket的PHP和JavaScript实现
in 备忘 with 0 comment

【原】WebSocket的PHP和JavaScript实现

in 备忘 with 0 comment

如果说我看得比别人远一些,那是因为我站在巨人的肩膀上 --艾萨克·牛顿


技术背景

今天在优化现有游戏GM系统(以下简称系统)时候,出现一个需求:低级管理员(以下简称A)请求危险URL时候需要高级管理员(以下简称B)授权才可以。那么在老版本系统中,我的解决方法是当B遇到这样的情况时候,人工将这个情况告诉A通过qq等手段,然后A做出相应。这显然是一个较为麻烦和不可靠的方法,所以,新版本中将通过系统发送通知的形式来实现这个需求。粗略考虑,两种解决方案:

  1. 使用JS,setTimeout() 方法不断的异步请求数据,间隔时间可以是10秒或者更短,主要看需求。
  2. 使用WebSocket,由服务器主动向客户端发送数据。[我选择用它]

前期准备

在明确了前文的需求之后,对前文所提的方案进行比较,最后还是选择了2,因为,可以借机熟悉下WebSocket这门技术。好了废话不多说,看过程吧!

  1. 一台Linux服务器[最好可以访问国外,你懂得]
  2. 安装好Web服务器及PHP环境
  3. 安装好nvm [用于管理nodeJs的工具包]

搭建WebSocket服务

这里要说明的是,我们将采用第三种方式来实现我们的WebSocket服务。请参看

  1. 安装WebSocket服务
    去github上搜索下,有很多直接将PHP作为一个Socket服务端来做,但是,我在考虑速度和效率以及开发周期这些因素后,我放弃了这么做。因为说实话PHP对于Socket的支持并没有那么好。所以将使用node下的ws先来做一个Socket服务端。安装步骤如下:
nvm install v4.3  //安装Node.js
npm install ws    //安装Socket服务端
vim server.js     //配置Socket服务器
node server.js    //启动Socket服务器
/** 一般的Socket服务器需要一直运行,即便我们关闭了terminal **/
npm install -g forever //全局模式安装forever,用这个来运行server.js
forever start server.js //运行服务端
forever list //查看forever管理的全部任务

附上我的server.js的配置源码:

console.log("Server started");
//TODO::发呆过久踢下线
var id2Obj = new Array(); //根据用户ID找到的目标socket通道
var lv2Id = new Array();  //根据这个随机出目标管理员推送消息
var WebSocketServer = require('ws').Server;
var webSocket = new WebSocketServer({port: 8010});
webSocket.on('connection', function(ws) {
    ws.on('message', function(message) {
        var msgObj = JSON.parse(message);
        if( msgObj.type == 'OPEN' ){
                id2Obj[msgObj.content] = ws;
                if( msgObj.lv == 'AUTH' ){
                        lv2Id[msgObj.content] = msgObj.content;
                }
        }
        if( msgObj.type == 'CLOSE' ){
                id2Obj[msgObj.content] = null;
        }
        if( msgObj.type == 'RULE' ){
                for (var key in lv2Id) {
                            tar = id2Obj[key];
                            tar.send('ADD NEW MESSAGE');
                        }
        }
       // ws.send('Server received from client: ' + message);
    });
});
  1. 创建PHP Socket客户端
    这里主要参看的是github项目,在这里就不在赘述了。

附上我优化后的代码

<?php
namespace MyOrg\WebSocket;
use MyOrg\String\StringOrg;

/**
 * Class Client
 * @package MyOrg\WebSocket
 * @auth https://github.com/Textalk/websocket-php
 */

class Client extends Base{

    protected $socket_uri;
    public $options;

    public function __construct ($uri, $options = array()) {
        $this->options = $options;
        if (!array_key_exists('timeout', $this->options)) $this->options['timeout'] = 5;
        if (!array_key_exists('fragment_size', $this->options)) $this->options['fragment_size'] = 4096;
        $this->socket_uri = $uri;
    }

    public function __destruct (){
        if ($this->socket) {
            if (get_resource_type($this->socket) === 'stream') fclose($this->socket);
            $this->socket = null;
        }
    }

    protected function connect (){

        $url_parts = parse_url($this->socket_uri);
        $scheme = $url_parts['scheme'];
        $host = $url_parts['host'];
        $user = isset($url_parts['user']) ? $url_parts['user'] : '';
        $pass = isset($url_parts['pass']) ? $url_parts['pass'] : '';
        $port = isset($url_parts['port']) ? $url_parts['port'] : ($scheme === 'wss' ? 443 : 80);
        $path = isset($url_parts['path']) ? $url_parts['path'] : '/';
        $query = isset($url_parts['query']) ? $url_parts['query'] : '';
        $fragment = isset($url_parts['fragment']) ? $url_parts['fragment'] : '';

        $path_with_query = $path;
        if (!empty($query)) $path_with_query .= '?' . $query;
        if (!empty($fragment)) $path_with_query .= '#' . $fragment;

        if (!in_array($scheme, array('ws', 'wss'))) {
            E("Url should have scheme ws or wss, not '$scheme' from URI '$this->socket_uri' .");
        }

        $host_uri = ($scheme === 'wss' ? 'ssl' : 'tcp') . '://' . $host;
        if (isset($this->options['context'])) {
            if (@get_resource_type($this->options['context']) === 'stream-context') {
                $context = $this->options['context'];
            } else {
                throw new \InvalidArgumentException(
                    "Stream context in \$options['context'] isn't a valid context"
                );
            }
        } else {
            $context = stream_context_create();
        }

        $this->socket = @stream_socket_client(
            $host_uri . ':' . $port,
            $errno,
            $errstr,
            $this->options['timeout'],
            STREAM_CLIENT_CONNECT,
            $context
        );

        if ($this->socket === false) {
            E("Could not open socket to \"$host:$port\": $errstr ($errno).");
        }
        stream_set_timeout($this->socket, $this->options['timeout']);

        $key = base64_encode(StringOrg::randString(16,0,'!"$&/()=[]{}0123456789'));
        $headers = array(
            'host' => $host . ":" . $port,
            'user-agent' => 'websocket-client-php',
            'connection' => 'Upgrade',
            'upgrade' => 'websocket',
            'sec-websocket-key' => $key,
            'sec-websocket-version' => '13',
        );

        if ($user || $pass) {
            $headers['authorization'] = 'Basic ' . base64_encode($user . ':' . $pass) . "\r\n";
        }

        if (isset($this->options['origin'])) {
            $headers['origin'] = $this->options['origin'];
        }

        if (isset($this->options['headers'])) {
            $headers = array_merge($headers, array_change_key_case($this->options['headers']));
        }

        $header =
            "GET " . $path_with_query . " HTTP/1.1\r\n"
            . implode(
                "\r\n", array_map(
                    function ($key, $value) {
                        return "$key: $value";
                    }, array_keys($headers), $headers
                )
            )
            . "\r\n\r\n";
        $this->write($header);

        $response = stream_get_line($this->socket, 1024, "\r\n\r\n");
        if (!preg_match('#Sec-WebSocket-Accept:\s(.*)$#mUi', $response, $matches)) {
            $address = $scheme . '://' . $host . $path_with_query;
            E("Connection to '{$address}' failed: Server sent invalid upgrade response:\n" . $response);
        }

        $keyAccept = trim($matches[1]);
        $expectedResonse = base64_encode(pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));

        if ($keyAccept !== $expectedResonse) {
            E('Server sent bad upgrade response.');
        }
        $this->is_connected = true;

    }

}

<?php
namespace MyOrg\WebSocket;

/**
 * Class Base
 * @package MyOrg\WebSocket
 * @auth https://github.com/Textalk/websocket-php
 */

class Base {

    protected $socket;
    protected $is_connected = false;
    protected $is_closing = false;
    protected $last_opcode = null;
    protected $close_status = null;
    protected $huge_payload = null;
    public $options;

    protected static $opcodes = array(
        'continuation' => 0,
        'text' => 1,
        'binary' => 2,
        'close' => 8,
        'ping' => 9,
        'pong' => 10,
    );

    protected function connect(){}

    public function getLastOpcode(){
        return $this->last_opcode;
    }

    public function getCloseStatus(){
        return $this->close_status;
    }

    public function isConnected(){
        return $this->is_connected;
    }

    public function setTimeout($timeout){
        $this->options['timeout'] = $timeout;
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
            stream_set_timeout($this->socket, $timeout);
        }
    }

    public function setFragmentSize($fragment_size){
        $this->options['fragment_size'] = $fragment_size;
        return $this;
    }

    public function getFragmentSize(){
        return $this->options['fragment_size'];
    }

    public function send($payload, $opcode = 'text', $masked = true){
        if (!$this->is_connected) $this->connect();

        if (!in_array($opcode, array_keys(self::$opcodes))) {
            E("Bad opcode '$opcode'.  Try 'text' or 'binary'.");
        }

        $payload_length = strlen($payload);
        $fragment_cursor = 0;
        while ($payload_length > $fragment_cursor) {

            $sub_payload = substr($payload, $fragment_cursor, $this->options['fragment_size']);
            $fragment_cursor += $this->options['fragment_size'];
            $final = $payload_length <= $fragment_cursor;
            $this->send_fragment($final, $sub_payload, $opcode, $masked);
            $opcode = 'continuation';

        }

    }

    protected function send_fragment($final, $payload, $opcode, $masked){
        $frame_head_binstr = '';
        $frame_head_binstr .= (bool)$final ? '1' : '0';
        $frame_head_binstr .= '000';
        $frame_head_binstr .= sprintf('%04b', self::$opcodes[$opcode]);
        $frame_head_binstr .= $masked ? '1' : '0';

        $payload_length = strlen($payload);
        if ($payload_length > 65535) {
            $frame_head_binstr .= decbin(127);
            $frame_head_binstr .= sprintf('%064b', $payload_length);
        } elseif ($payload_length > 125) {
            $frame_head_binstr .= decbin(126);
            $frame_head_binstr .= sprintf('%016b', $payload_length);
        } else {
            $frame_head_binstr .= sprintf('%07b', $payload_length);
        }

        $frame = '';
        foreach (str_split($frame_head_binstr, 8) as $binstr){
            $frame .= chr(bindec($binstr));
        }
        if ($masked) {
            $mask = '';
            for ($i = 0; $i < 4; $i++) {
                $mask .= chr(rand(0, 255));
            }
            $frame .= $mask;
        }
        for ($i = 0; $i < $payload_length; $i++) {
            $frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i];
        }
        $this->write($frame);

    }

    public function receive(){
        if (!$this->is_connected) $this->connect();
        $this->huge_payload = '';
        $response = null;
        while (is_null($response)) {
            $response = $this->receive_fragment();
        }
        return $response;
    }

    protected function receive_fragment(){

        $data = $this->read(2);
        $final = (boolean)(ord($data[0]) & 1 << 7);
        $rsv1 = (boolean)(ord($data[0]) & 1 << 6);
        $rsv2 = (boolean)(ord($data[0]) & 1 << 5);
        $rsv3 = (boolean)(ord($data[0]) & 1 << 4);

        $opcode_int = ord($data[0]) & 31;
        $opcode_ints = array_flip(self::$opcodes);
        if (!array_key_exists($opcode_int, $opcode_ints)) {
            E("Bad opcode in websocket frame: $opcode_int");
        }
        $opcode = $opcode_ints[$opcode_int];
        if ($opcode !== 'continuation') {
            $this->last_opcode = $opcode;
        }
        $mask = (boolean)(ord($data[1]) >> 7);

        $payload = '';
        $payload_length = (integer)ord($data[1]) & 127;
        if ($payload_length > 125) {
            if ($payload_length === 126) $data = $this->read(2);
            else                         $data = $this->read(8);
            $payload_length = bindec(self::sprintB($data));
        }

        if ($mask) {
            $masking_key = $this->read(4);
        }
        if ($payload_length > 0) {
            $data = $this->read($payload_length);
            if ($mask) {
                for ($i = 0; $i < $payload_length; $i++) {
                    $payload .= ($data[$i] ^ $masking_key[$i % 4]);
                }
            } else {
                $payload = $data;
            }
        }
        if ($opcode === 'close') {
            if ($payload_length >= 2) {
                $status_bin = $payload[0] . $payload[1];
                $status = bindec(sprintf("%08b%08b", ord($payload[0]), ord($payload[1])));
                $this->close_status = $status;
                $payload = substr($payload, 2);
                if (!$this->is_closing) {
                    $this->send($status_bin . 'Close acknowledged: ' . $status, 'close', true);
                }
            }
            if ($this->is_closing) {
                $this->is_closing = false;
            }
            fclose($this->socket);
            $this->is_connected = false;
        }
        if (!$final) {
            $this->huge_payload .= $payload;
            return null;
        } else if ($this->huge_payload) {
            $payload = $this->huge_payload .= $payload;
            $this->huge_payload = null;
        }
        return $payload;

    }

    public function close($status = 1000, $message = 'ttfn'){
        $status_binstr = sprintf('%016b', $status);
        $status_str = '';
        foreach (str_split($status_binstr, 8) as $binstr) $status_str .= chr(bindec($binstr));
        $this->send($status_str . $message, 'close', true);

        $this->is_closing = true;
        $response = $this->receive();

        return $response;
    }

    protected function write($data){
        $written = fwrite($this->socket, $data);
        if ($written < strlen($data)) {
            E("Could only write $written out of " . strlen($data) . " bytes.");
        }
    }

    protected function read($length){
        $data = '';
        while (strlen($data) < $length) {
            $buffer = fread($this->socket, $length - strlen($data));
            if ($buffer === false) {
                $metadata = stream_get_meta_data($this->socket);
                E('Broken frame, read ' . strlen($data) . ' of stated ' . $length . ' bytes.  Stream state: ' . json_encode($metadata));
            }
            if ($buffer === '') {
                $metadata = stream_get_meta_data($this->socket);
                E('Empty read; connection dead?  Stream state: ' . json_encode($metadata));
            }
            $data .= $buffer;
        }
        return $data;
    }

    protected static function sprintB($string){
        $return = '';
        for ($i = 0; $i < strlen($string); $i++) {
            $return .= sprintf("%08b", ord($string[$i]));
        }
        return $return;
    }
}
  1. 创建JS Socket客户端
    这里网上也有很多的源码,百度下就可以。

到这里,基本配置都已经完成,下面要做的就是测试啦,各位试试吧,不过要注意的是我这些代码并不具备通用性,如果想要探讨,欢留言哦!

最后说两句,至于我为什么敢于去弄一个Node.js的Socket服务端,主要是因为老大。没有他的帮助我基本不可能这么快的上手,有兴趣也开以去看看他的博客。@toby,对了,我基本不懂JS,但是我也能写点出来,所以如果只是想用下,根本不用担心学不会。加油!!!

Comments are closed.