由workerman-chat再认识HTML5的WebSocket

    先前已经看了几次把workerman-chat源码,对于里面的worker和gateway具体的实现现在还是不是很清楚,但是还是学到不少东西,比如关于WebSocket的一些知识,以及在这个框架中他是怎么实现WebSocket通信的。下面做一些整理,主要是WebSocket的一些基础知识,以及对于workerman-chat里关于WebSocket那一块的代码解读和记录。


    WebSocket的出现背景

    我们知道Web应用的传统交互过程通常是:

    1. 客户端通过浏览器发送一个请求
    2. 服务器端收到请求,然后进行处理并把结果返回给客户端
    3. 客户端浏览器将收到的信息呈现出来
    

    这种机制对于信息变化不是特别频繁的应用尚可,但对于实时要求高、海量并发的应用来说显得捉襟见肘,尤其在当前业界移动互联网蓬勃发展的趋势下,高并发与用户实时响应是 Web 应用经常面临的问题,比如金融证券的实时信息,Web 导航应用中的地理位置获取,社交网络的实时消息推送等。

    具体的来说:

    监控系统:后台硬件热插拔、LED、温度、电压发生变化;
    即时通信系统:其它用户登录、发送信息;
    即时报价系统:后台数据库内容发生变化;
    

    对于上面的情况,也是有传统的解决方案的。通常是采用实时通讯方案,常见的就有两种,第一种是轮询,第二种是基于Flash。

    轮询:简单来说,就是客户端以固定的频率向服务器端发送请求,来保持客户端和服务器端的数据同步,但服务器端的数据可能没有更新,所以效率底,浪费带宽。

    现很多网站为了实现即时通讯,所用的技术都是轮询(polling)。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP request,然后由服务器返回最新的数据给客户端的浏览器。这种传统的HTTP request 的模式带来很明显的缺点 – 浏览器需要不断的向服务器发出请求,然而HTTP request 的header是非常长的,里面包含的有用数据可能只是一个很小的值,这样会占用很多的带宽。

    基于Flash:AdobeFlash 通过自己的 Socket 实现完成数据交换,再利用 Flash 暴露出相应的接口为 JavaScript 调用,从而达到实时传输目的。此方式比轮询要高效,且因为 Flash 安装率高,应用场景比较广泛,但在移动互联网终端上 Flash 的支持并不好。IOS 系统中没有 Flash 的存在,在 Android 中虽然有 Flash 的支持,但实际的使用效果差强人意,且对移动设备的硬件配置要求较高。2012 年 Adobe 官方宣布不再支持 Android4.1+系统,宣告了 Flash 在移动终端上的死亡。

    我们看到上面这两种方法都有各自的缺点,尤其在处理高并发和实时的需求时。我们需要一种能够很好的双向通信机制来保证数据的实时传输,这个时候我们的WebSocket就出现了。


    WebSocket机制

    到这里先问几个问题:WebSocket到底是什么?是一个协议,是HTML5的一个新协议。它能用来干什么呢?用来实现浏览器与服务器的双全工通信。它建立于TCP之上,同HTTP一样利用TCP来传输数据,但是又和HTTP有很大的不同。不同在哪里呢?先看这个图:

    上面是传统的HTTP请求响应和WebSocket的请求相应交互图,我们可以看到在 WebSocket API,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。

    所以上面的答案是:区别最大的地方在:双向通信,服务器和客户端都可以主动发送或者接收数据。就像打电话一样,两个人先建立连接,然后接通后,两个人想说啥就说啥,A可以和B主动说话,B也可以主动跟A说话。这里隐含着,也是要先建立连接的,也就是第一次还是需要三次握手的。建立连接成功后,就可以一直通信了。最开始还是需要一次TCP连接的。

    相对于传统 HTTP 每次请求-应答都需要客户端与服务端建立连接的模式,WebSocket 是类似 Socket 的 TCP 长连接的通讯模式,一旦 WebSocket 连接建立后,后续数据都以帧序列的形式传输。在客户端断开 WebSocket 连接或 Server 端断掉连接前,不需要客户端和服务端重新发起连接请求。在海量并发及客户端与服务器交互负载流量大的情况下,极大的节省了网络带宽资源的消耗,有明显的性能优势,且客户端发送和接受消息是在同一个持久连接上发起,实时性优势明显。


    通过WebSocket的客户端和服务器端的报文

    WebSocket客户端连接报文

    GET / HTTP/1.1
    Host: localhost
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: xqBt3ImNzJbYqRINxEFlkg==
    Origin: http://localhost:8080
    Sec-WebSocket-Version: 13
    

    解释下:这里的upgrade表明这是一个WebSocket类型的请求。(为了更方便地部署新协议,HTTP/1.1 引入了 Upgrade 机制,它使得客户端和服务端之间可以借助已有的 HTTP 语法升级到其它协议)。“Sec-WebSocket-Key”是 WebSocket 客户端发送的一个 base64 编码的密文,要求服务端必须返回一个对应加密的“Sec-WebSocket-Accept”应答,否则客户端会抛出“Error during WebSocket handshake”错误,并关闭连接。

    WebSocket 服务端响应报文

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: K7DJLdLooIwIG/MOpvWFB3y3FE8=
    

    “Sec-WebSocket-Accept”的值是服务端采用与客户端一致的密钥计算出来后返回客户端的,“HTTP/1.1 101 Switching Protocols”表示服务端接受 WebSocket 协议的客户端连接,经过这样的请求-响应处理后,客户端服务端的 WebSocket 连接握手成功, 后续就可以进行 TCP 通讯了。


    WebSocket客户端API

    WebSocket的实现分为客户端和服务器端两个部分,客户端通过浏览器发送WebSocket连接请求,服务端响应,然后TCP三次捂手,然后就在客户端和服务器端形成了一条HTTP长连接快速通道,两者后面就不要再次建立链接了。看看workerman-chat是分别怎么实现客户端和服务器端的。

    先看下我们一般在客户端用Javascript如何建立WebSocket连接:首先,new一个WebSocket,然后在socket上监听WebSocket对象。主要通过onopen,onmessage,onclose和onerror四个事件实现对socket消息的异步响应。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    var wsServer = 'ws://localhost:8888/Demo';
    var websocket = new WebSocket(wsServer);
    websocket.onopen = function (evt) { onOpen(evt) };
    websocket.onclose = function (evt) { onClose(evt) };
    websocket.onmessage = function (evt) { onMessage(evt) };
    websocket.onerror = function (evt) { onError(evt) };
    function onOpen(evt) {
    console.log("Connected to WebSocket server.");
    }
    function onClose(evt) {
    console.log("Disconnected");
    }
    function onMessage(evt) {
    console.log('Retrieved data from server: ' + evt.data);
    }
    function onError(evt) {
    console.log('Error occured: ' + evt.data);
    }


    看下workerman-chat的客户端代码

    首先是onload="connect();"onload的时候调用connect创建连接socket。

    它在connect里面创建了socket,并且设置了onopen,onmessage,onclose和onerror这几个事件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function connect() {
    // 创建websocket
    ws = new WebSocket("ws://"+document.domain+":7272");
    // 当socket连接打开时,输入用户名
    ws.onopen = onopen;
    // 当有消息时根据消息类型显示不同信息
    ws.onmessage = onmessage;
    ws.onclose = function() {
    console.log("连接关闭,定时重连");
    connect();
    };
    ws.onerror = function() {
    console.log("出现错误");
    };
    }

    再来看看它的onopen和onmessage函数。在这两个函数中,获取到客户端用户的client_name后,利用ws.send把数据(包括type类型,是login)发送给服务器。服务器那边收到消息会做相应的处理,然后把数据返回给客户端,这个时候调用onmessage,里面也包括了type的类型,将该用户设置入client_list,并且广播给所有的用户。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    // 连接建立时发送登录信息
    function onopen()
    {
    if(!name)
    {
    show_prompt();
    }
    // 登录
    var login_data = '{"type":"login","client_name":"'+name.replace(/"/g, '\\"')+'","room_id":"<?php echo isset($_GET['room_id']) ? $_GET['room_id'] : 1?>"}';
    console.log("websocket握手成功,发送登录数据:"+login_data);
    ws.send(login_data);
    }
    // 服务端发来消息时
    function onmessage(e)
    {
    console.log(e.data);
    var data = eval("("+e.data+")");
    switch(data['type']){
    // 服务端ping客户端
    case 'ping':
    ws.send('{"type":"pong"}');
    break;;
    // 登录 更新用户列表
    case 'login':
    //{"type":"login","client_id":xxx,"client_name":"xxx","client_list":"[...]","time":"xxx"}
    say(data['client_id'], data['client_name'], data['client_name']+' 加入了聊天室', data['time']);
    if(data['client_list'])
    {
    client_list = data['client_list'];
    }
    else
    {
    client_list[data['client_id']] = data['client_name'];
    }
    flush_client_list();
    console.log(data['client_name']+"登录成功");
    break;
    // 发言
    case 'say':
    //{"type":"say","from_client_id":xxx,"to_client_id":"all/client_id","content":"xxx","time":"xxx"}
    say(data['from_client_id'], data['from_client_name'], data['content'], data['time']);
    break;
    // 用户退出 更新用户列表
    case 'logout':
    //{"type":"logout","client_id":xxx,"time":"xxx"}
    say(data['from_client_id'], data['from_client_name'], data['from_client_name']+' 退出了', data['time']);
    delete client_list[data['from_client_id']];
    flush_client_list();
    }
    }

    当用户要提交对话的时候,我们获取到表单里的内容,将数据同样利用ws.send()发送给服务器,服务器同样做相应的处理,并将数据返回给客户端。然后再调用客户端的onmessage,这个时候的类型很明显就不是login了,而是say

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //提交数据
    function onSubmit() {
    var input = document.getElementById("textarea");
    var to_client_id = $("#client_list option:selected").attr("value");
    var to_client_name = $("#client_list option:selected").text();
    ws.send('{"type":"say","to_client_id":"'+to_client_id+'","to_client_name":"'+to_client_name+'","content":"'+input.value.replace(/"/g, '\\"').replace(/\n/g,'\\n').replace(/\r/g, '\\r')+'"}');
    input.value = "";
    input.focus();
    }
    // 发言就是把数据append进入对应的回话区
    function say(from_client_id, from_client_name, content, time){
    $("#dialog").append('<div class="speech_item"><img src="http://lorempixel.com/38/38/?'+from_client_id+'" class="user_icon" /> '+from_client_name+' <br> '+time+'<div style="clear:both;"></div><p class="triangle-isosceles top">'+content+'</p> </div>');
    }

    看下workerman-chat的服务器端代码

    这里的代码很少,主要是定义了Event类的两个方法,一个是onMessage方法,这里根据客户端传过来的type来返回不同的数据。比如如果是login就加入到client_list中去,然后设置session什么的。这里他有调用Gateway里面的一些方法如:Gateway::getClientInfoByGroup()等。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    class Event
    {
    /**
    * 有消息时
    * @param int $client_id
    * @param mixed $message
    */
    public static function onMessage($client_id, $message)
    {
    // debug
    echo "client:{$_SERVER['REMOTE_ADDR']}:{$_SERVER['REMOTE_PORT']} gateway:{$_SERVER['GATEWAY_ADDR']}:{$_SERVER['GATEWAY_PORT']} client_id:$client_id session:".json_encode($_SESSION)." onMessage:".$message."\n";
    // 客户端传递的是json数据
    $message_data = json_decode($message, true);
    if(!$message_data)
    {
    return ;
    }
    // 根据类型执行不同的业务
    switch($message_data['type'])
    {
    // 客户端回应服务端的心跳
    case 'pong':
    return;
    // 客户端登录 message格式: {type:login, name:xx, room_id:1} ,添加到客户端,广播给所有客户端xx进入聊天室
    case 'login':
    // 判断是否有房间号
    if(!isset($message_data['room_id']))
    {
    throw new \Exception("\$message_data['room_id'] not set. client_ip:{$_SERVER['REMOTE_ADDR']} \$message:$message");
    }
    // 把房间号昵称放到session中
    $room_id = $message_data['room_id'];
    $client_name = htmlspecialchars($message_data['client_name']);
    $_SESSION['room_id'] = $room_id;
    $_SESSION['client_name'] = $client_name;
    // 获取房间内所有用户列表
    $clients_list = Gateway::getClientInfoByGroup($room_id);
    foreach($clients_list as $tmp_client_id=>$item)
    {
    $clients_list[$tmp_client_id] = $item['client_name'];
    }
    $clients_list[$client_id] = $client_name;
    // 转播给当前房间的所有客户端,xx进入聊天室 message {type:login, client_id:xx, name:xx}
    $new_message = array('type'=>$message_data['type'], 'client_id'=>$client_id, 'client_name'=>htmlspecialchars($client_name), 'time'=>date('Y-m-d H:i:s'));
    Gateway::sendToGroup($room_id, json_encode($new_message));
    Gateway::joinGroup($client_id, $room_id);
    // 给当前用户发送用户列表
    $new_message['client_list'] = $clients_list;
    Gateway::sendToCurrentClient(json_encode($new_message));
    return;
    // 客户端发言 message: {type:say, to_client_id:xx, content:xx}
    case 'say':
    // 非法请求
    if(!isset($_SESSION['room_id']))
    {
    throw new \Exception("\$_SESSION['room_id'] not set. client_ip:{$_SERVER['REMOTE_ADDR']}");
    }
    $room_id = $_SESSION['room_id'];
    $client_name = $_SESSION['client_name'];
    // 私聊
    if($message_data['to_client_id'] != 'all')
    {
    $new_message = array(
    'type'=>'say',
    'from_client_id'=>$client_id,
    'from_client_name' =>$client_name,
    'to_client_id'=>$message_data['to_client_id'],
    'content'=>"<b>对你说: </b>".nl2br(htmlspecialchars($message_data['content'])),
    'time'=>date('Y-m-d H:i:s'),
    );
    Gateway::sendToClient($message_data['to_client_id'], json_encode($new_message));
    $new_message['content'] = "<b>你对".htmlspecialchars($message_data['to_client_name'])."说: </b>".nl2br(htmlspecialchars($message_data['content']));
    return Gateway::sendToCurrentClient(json_encode($new_message));
    }
    $new_message = array(
    'type'=>'say',
    'from_client_id'=>$client_id,
    'from_client_name' =>$client_name,
    'to_client_id'=>'all',
    'content'=>nl2br(htmlspecialchars($message_data['content'])),
    'time'=>date('Y-m-d H:i:s'),
    );
    return Gateway::sendToGroup($room_id ,json_encode($new_message));
    }
    }
    /**
    * 当客户端断开连接时
    * @param integer $client_id 客户端id
    */
    public static function onClose($client_id)
    {
    // debug
    echo "client:{$_SERVER['REMOTE_ADDR']}:{$_SERVER['REMOTE_PORT']} gateway:{$_SERVER['GATEWAY_ADDR']}:{$_SERVER['GATEWAY_PORT']} client_id:$client_id onClose:''\n";
    // 从房间的客户端列表中删除
    if(isset($_SESSION['room_id']))
    {
    $room_id = $_SESSION['room_id'];
    $new_message = array('type'=>'logout', 'from_client_id'=>$client_id, 'from_client_name'=>$_SESSION['client_name'], 'time'=>date('Y-m-d H:i:s'));
    Gateway::sendToGroup($room_id, json_encode($new_message));
    }
    }
    }

    涨知识-建立socket连接

    建立Socket连接至少需要一对套接字,其中一个运行于客户端,称为ClientSocket ,另一个运行于服务器端,称为ServerSocket 。

    套接字之间的连接过程分为三个步骤:服务器监听,客户端请求,连接确认。

    服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态,等待客户端的连接请求。

    客户端请求:指客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。

    连接确认:当服务器端套接字监听到或者说接收到客户端套接字的连接请求时,就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户端,一旦客户端确认了此描述,双方就正式建立连接。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求

    我们这里还要区分一个概念就是http和socket的区别。这两个千万不能混为一谈,Http(无状态)是超文本传输协议,是协议,是基于TCP/IP协议基础上的应用层协议。Socket不是协议,是一个调用接口,Socket是基于TCP/IP的协议封装。Socket是长连接,http只能走tcp,sockett不仅能走tcp还能走udp。总的来说:Socket是应用层与TCP/IP协议族通信的中间软件抽象层,一组接口,把复杂的TCP/IP协议族隐藏在Socket接口后面。


    总结

    主要是总结了下WebSocket,然后以workerman-chat的例子为讲解,看下别人的代码怎么写的这么好。WebSocket 的优点上面我们看到了有很多,但是现在用它是也有风险,因为它正处于还不是非常成熟的阶段。另外的一个风险就是微软的 IE 作为占市场份额最大的浏览器,和其他的主流浏览器相比,对 HTML5 的支持是比较差的,这是我们在构建企业级的 Web 应用的时候必须要考虑的一个问题。现在它这个是并没有和数据库打交道的。但是我是要从数据库中读出相应的群组,然后可以群组对话,或者专一对话。并且不是只有在线才能发消息。这里workerman-chat也没有把消息存到数据库中去,这肯定对我的项目来说是不行的,我需要建表,然后把对应的会话存入数据库。它是有一些DB类的,可以用这个。回头改完,再写一篇文章。