Swoole和Websocket实现简易聊天室

骑驴找蚂蚁 · 2017-09-01

在这个全民直播的时代,在线视频直播已经成为我们饭后必看的内容了。视频直播中有个弹幕功能,相信大家也玩过其实这个类似一个聊天室。今天要讲的内容就是使用SwooleWebsocket怎么实现一个简易聊天室,下面图片就是最终实现出来的效果。

chat png

什么是Websocket

WebSocket是一种计算机通信协议,通过单个TCP连接提供全双工通信信道。 WebSocket协议在2011年被IETF标准化为RFC6455,WebSocket旨在在Web浏览器和Web服务器中实现,但可由任何客户端或服务器应用程序使用。 WebSocket协议是独立的基于TCP的协议。它与HTTP的唯一关系是它的握手被HTTP服务器解释为升级请求。WebSocket协议允许浏览器和Web服务器之间的交互具有较低的开销,从而实现从服务器的实时数据传输。大多数主要浏览器(包括Google Chrome,Microsoft Edge,Internet Explorer,Firefox,Safari和Opera)都支持WebSocket协议,大部分程序语言都可实现Websocket服务PHP的Swoole就是其中一个。

环境准备

  1. Chrome
  2. PHP 7.1.* + swoole2.0.*
  3. Nginx
  4. Node.js + Npm + Webpack2

上面Nginx可选,我用的环境是VagrantPHP(v7.1.4)Chrome(v60)(Node.js(v6.10)Webpack2

开始工作

使用Swoole绑定事件实现消息接收和广播消息。广播消息使所有连上服务的socket都能收到其它socket的消息,从而达到主动推送到客户端。

绑定事件

<?php
namespace Chat\Server;

use Swoole\Websocket\Server as WebSocket;
use Swoole\Websocket\Frame;
use Swoole\Http\Request;

class WebSocketServer
{
    private $socket = null;

    private $config = [
        //host 地址为0.0.0.0开放所有地址连接
        'host' => '0.0.0.0',
        'port' => 9527,
    ];

    public function __construct(array $config = [])
    {
        foreach ($config as $key => $value) {
            if (array_key_exists($key, $this->config)) {
                $this->config[$key] = $value;
            }
        }
        $this->initialize();
    }

    /**
     * 初始化socket,绑定open, message, close回调事件
     * @see https://wiki.swoole.com/wiki/page/397.html
     */
    private function initialize()
    {
        $this->socket = new WebSocket($this->config['host'], $this->config['port']);

        foreach (['open', 'message', 'close'] as $callback) {
            # code...
            $this->socket->on($callback, [$this, $callback]);
        }
    }

    //返回所有socket
    public function getConnections()
    {
        return $this->socket->connections;
    }

    public function open(WebSocket $server, Request $request)
    {
        echo $request->fd . '--open';        
    }

    public function close(WebSocket $server, $fd)
    {
        echo "$fd--close";
    }

    //开启服务
    public function run()
    {
        $this->socket->start();
    }
}

接收推送消息

<?php
namespace Chat\Server;

use Swoole\Websocket\Server as WebSocket;
use Swoole\Websocket\Frame;

class ChatServer extends WebSocketServer
{
    private $message = '';

    //聊天室的人数
    private $online = 0;

    /**
     * 在收到客户发送消息后会回调此函数, 帧数据存在$frame变量中
     * @param WebSocket $server
     * @param Frame $frame
     * @see https://wiki.swoole.com/wiki/page/402.html
     */
    public function message(WebSocket $server, Frame $frame)
    {   

        $message = '';
        if ($frame->data == "new user") {
            $this->online++;
        } else {
            $this->message .= $frame->data;
            if ($frame->finish) {
                $message = $this->message;
                $this->message = '';
                //遍历所有连接,将当前消息推送给其它的连接(客户端)
                foreach ($this->getConnections() as $fd) {
                    if ($frame->fd === $fd) continue;
                    $server->push($fd, $message);
                }
            }       
        }
    }

    //重写close, 在连接断开之后人数自减
    public function close(WebSocket $server, $fd)
    {
        $this->online--;
        echo "$fd--close";
    }
}

注意: 定义private $message 是因为数据帧不完整,一个WebSocket请求可能会分成多个数据帧进行发送,所有我们必须使用$frame->finish来检测数据帧的完整性。在不完整的情况我们使用类属性$message来保存帧数据。

通过以上两个类我们完成了推送接收消息,接下来我们要完成Html页面的内容制作和Websocket(JavaScript)的脚本编写。Html页面我们使用Bootstrap构建的模板

静态页面

  1. 创建一个html5标准的index.html, chat.js, app.css三个文件。
  2. 打开 https://bootsnipp.com/snippets/WaEvr地址。
  3. 将上地址的HTML、CSS、JS页签的内容拷贝到index.html, app.css, chat.js对应的文件
  4. 添加依赖到index.html
    <link href="//cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
    <link href="//cdn.bootcss.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
    <link href="./dist/app.css" rel="stylesheet">

    <script type="text/javascript" src="./dist/bundle.js"></script>

上面这个开源的模板是没有聊天室昵称功能,我们要为它加上昵称功能。

  • 修改HTML将以下内容添加到body之内
    <div class="popup">
        <div class="form">
            <h3 class="title">输入您的聊天室名?</h3>
            <input class="usernameInput" type="text" maxlength="14">
        </div>
    </div>
  • 修改CSS将以下内容添加到app.css
  .popup {
        position:absolute;
        width:100%;
        height:100%;
        background-color:#f4645f
  }
  .popup .form {
      height: 100px;
      margin-top: -100px;
      position: absolute;
      text-align: center;
      top: 50%;
      width: 100%;
  }
  .form .title, .form .usernameInput {
      color: #fff;
      font-weight: 100;
      font-size: 200%;
  }
  .form .usernameInput {
    background-color: transparent;
    border: none;
    border-bottom: 2px solid #fff;
    outline: none;
    padding-bottom: 15px;
    text-align: center;
    width: 400px;
  }

chat-name

  • 编写websocket代码
function Socket() {
    if (!(this instanceof Socket)) return new Socket();
    var _this = this;
    //socket连接状态
    this.isConnection = false;
    this.message = null;
    //这个是我本地websocket的服务
    this.socket = new WebSocket("ws://192.168.56.101:9527");
    this.socket.onopen = function(event) {
        _this.isConnection = true;
        //连接成功,向服务端发送一个new user的信息, 表示一个新的用户连接上了
        _this.socket.send("new user");
    }
    //接收服务端推送的消息
    this.socket.onmessage = function(event) {
        if (_this.message) _this.message(event.data);
        else console.log(event);
    };
    this.socket.onclose = function() {
        _this.isConnection = false;
    }
};
Socket.prototype.send = function(message) {
    if (this.isConnection) {
        this.socket.send(message);
    }   
}
Socket.prototype.bind = function(callback) {
    this.message = callback;
}
Socket.prototype.close = function() {
    this.socket.close();
}
  • chat.js最终的完整代码
require("css/app.css");
global.$ = window.$ = require('jquery');

(function () {
    var Message;
    Message = function (arg) {
        this.text = arg.text, this.message_side = arg.message_side, this.user = arg.user;
        this.draw = function (_this) {
            return function () {
                var $message;
                $message = $($('.message_template').clone().html());
                $message.addClass(_this.message_side).find('.text').html(_this.text);
                $message.find(".avatar").html(_this.user);
                $('.messages').append($message);
                return setTimeout(function () {
                    return $message.addClass('appeared');
                }, 0);
            };
        }(this);
        return this;
    };
    function Socket() {
        if (!(this instanceof Socket)) return new Socket();
        var _this = this;
        this.isConnection = false;
        this.message = null;
        this.socket = new WebSocket("ws://192.168.56.101:9527");
        this.socket.onopen = function(event) {
            _this.isConnection = true;
            _this.socket.send("new user");
        }
        this.socket.onmessage = function(event) {
            if (_this.message) _this.message(event.data);
            else console.log(event);
        };
        this.socket.onclose = function() {
            _this.isConnection = false;
        }
    };
    Socket.prototype.send = function(message) {
        if (this.isConnection) {
            this.socket.send(message);
        }   
    }
    Socket.prototype.bind = function(callback) {
        this.message = callback;
    }
    Socket.prototype.close = function() {
        this.socket.close();
    }
    $(function () {
        var getMessageText, message_side, sendMessage, userName, chat;
        message_side = 'right';
        getMessageText = function () {
            var $message_input;
            $message_input = $('.message_input');
            return $message_input.val();
        };
        sendMessage = function (text) {
            var $messages, message, messageStruct = JSON.parse(text);
            if (text.trim() === '') {
                return;
            }
            $('.message_input').val('');
            $messages = $('.messages');
            message_side = userName == messageStruct.user ? 'right' : 'left';
            message = new Message({
                text: messageStruct.message,
                message_side: message_side,
                user: messageStruct.user
            });
            message.draw();
            return $messages.animate({ scrollTop: $messages.prop('scrollHeight') }, 300);
        };
        $('.send_message').click(function (e) {
            var text = getMessageText();
            if (text.trim() === '') return ;
            var message = JSON.stringify({user: userName, message: text});
            chat.send(message);
            return sendMessage(message);
        });
        $('.message_input').keyup(function (e) {
            if (e.which === 13) {
                var text = getMessageText();
                if (text.trim() === '') return ;
                var message = JSON.stringify({user: userName, message: text});
                chat.send(message);
                return sendMessage(message);
            }
        });
        $(".usernameInput").on("keyup", function(e) {
            var val = $(this).val();
            if (val != "" && e.keyCode == 13) {
                userName = val;
                $(".popup").remove();
                chat = new Socket();
                chat.bind(sendMessage);
            }
        });
        $(window).on("unload", function(e) {
            if (chat) {
                chat.close(); 
                chat = null;
            }
        });
        $(window).on("beforeunload", function(e) {
            if (chat) {
                chat.close(); 
                chat = null;
            }
        });

        /**
        sendMessage('Hello Philip! :)');
        setTimeout(function () {
            return sendMessage('Hi Sandy! How are you?');
        }, 1000);
        return setTimeout(function () {
            return sendMessage('I\'m fine, thank you!');
        }, 2000);
        */
    });
}.call(this));
  • 配置webpack
const path = require("path");
const webpack = require('webpack')
// importing plugins that do not come by default in webpack
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const css = new ExtractTextPlugin('app.css');

const plugins = [


];

const sourcePath = path.join(__dirname, "./src");

const buildPath = path.join(__dirname, "./public/dist");
module.exports = {
    context: sourcePath,
    //预编译入口
    entry: "./chat.js",
    //预编译输出
    output: {
        // options related to how webpack emits results

        path: buildPath, // string
        // the target directory for all output files
        // must be an absolute path (use the Node.js path module)

        filename: "bundle.js", // string
        // the filename template for entry chunks

        publicPath: "./public", // string
        // the url to the output directory resolved relative to the HTML page

        library: "", // string,
        // the name of the exported library

        libraryTarget: "umd", // universal module definition
        // the type of the exported library

        /* Advanced output configuration (click to show) */
    },

    module: {

        rules: [
            {
                test: /\.css$/,
                use: css.extract([ 'css-loader'])
            },
            {
                test: /\.(html|svg|jpe?g|png|ttf|woff2?)$/,
                exclude: /node_modules/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: 'static/[name]-[hash:8].[ext]',
                    },
                },
            }
        ]
    },
    resolve: {
        extensions: ['.webpack-loader.js', '.web-loader.js', '.loader.js', '.js', '.jsx'],
        modules: [path.resolve(__dirname, 'node_modules'), sourcePath],
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            async: true,
            children: true,
            minChunks: 2,
        }),

        // setting production environment will strip out
        // some of the development code from the app
        // and libraries
        new webpack.DefinePlugin({
            'process.env': { NODE_ENV: JSON.stringify(process.env.NODE_ENV) }
        }),

        // create css bundle
        css
    ]
}
  • 执行webpack生成文件

整个前端流程到此结束

最终工作

  • 创建websocket服务
<?php
include __DIR__ . '/../vendor/autoload.php'; 

use Chat\Server\ChatServer;

$chat = new ChatServer();

$chat->run();
  • 启动服务
[root@meshell chat]# php bin/chat.php
  • 绑定域名,查看效果

效果图

项目地址

https://github.com/TianLiangZhou/loocode-example

推荐阅读

  1. https://www.w3.org/TR/2009/WD-websockets-20091222/
  2. https://developer.mozilla.org/en/docs/Web/API/WebSocket
  3. https://tools.ietf.org/html/rfc6455
  4. https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API