先前已经看了几次把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
18var 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 | function connect() { |
再来看看它的onopen和onmessage函数。在这两个函数中,获取到客户端用户的client_name
后,利用ws.send
把数据(包括type类型,是login)发送给服务器。服务器那边收到消息会做相应的处理,然后把数据返回给客户端,这个时候调用onmessage,里面也包括了type的类型,将该用户设置入client_list
,并且广播给所有的用户。
1 | // 连接建立时发送登录信息 |
当用户要提交对话的时候,我们获取到表单里的内容,将数据同样利用ws.send()
发送给服务器,服务器同样做相应的处理,并将数据返回给客户端。然后再调用客户端的onmessage
,这个时候的类型很明显就不是login
了,而是say
。
1 | //提交数据 |
看下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
116class 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类的,可以用这个。回头改完,再写一篇文章。