最近想做个网页聊天室,很简单的能即时聊天就行,而且是群聊。自然就得是WebSocket方面的知识了,要求不高,后台用的php。粗略记录如下:
前端部分:
这没什么可说的,支持WebSocket即可,Chrome和Firefox基本没问题,IE得10或以上版本。先是创建socket对象:
var socket = new WebSocket("ws://127.0.0.1:8000");
一般是ws协议+主机名+端口号即可。主机名是IP地址或者域名。接着监听这几个事件:
onopen,socket对象创建成功时触发,触发后会发送握手请求;
onmessage,最主要的事件,接收到信息时触发,包括握手信息、断开信息和普通的聊天信息;
onerror,发生错误时触发,一般用不到;
onclose,服务器主动断开时触发;
另外有两个方法:1、send,发送信息,最主要的方法;2、close,主动断开。
后端PHP部分
PHP是不太适合干这个的,新生的Node.js都干得比PHP要好。但不会其他了,只能将就。主要步骤有以几步:
1、创建主socket套接字,绑定主机并监听端口。这个按手册来即会,看下边代码,不再啰嗦。
2、开启死循环,设置阻塞监听所有套接字选择有状态变化的。
这里有个关键函数:socket_select($sockets,$write,$except,$tv_sec)。$sockets是所有的套接字,监听它们。$write不太好理解,一般为NULL。$except是要排除的套接字,也就是不监听它们。$tv_sec是最大阻塞时间,一般为NULL,表示一直阻塞着直到有套接字发生了状态变化,为具体数字时表示最多阻塞这么长时间,即使没有状态变化也返回。前三个参数都是引用赋值的数组或者NULL。返回值是有状态变化的套接字的个数,返回时会将$sockets里边有状态变化的保留,其他都删除之!这是关键点。
3、接收数据,解码编码返回信息。主要用到的函数:
socket_accept($socket),主$socket有新主机接入时创建子socket负责它的通信。
socket_recv($socket, $buf, 1024, 0),从$socket中读取信息,存储在$buf中,后面两个分别是最大字节数和一个标记,一般为0,返回值是读取到的字节数。
socket_write($client, $msg, strlen($msg)),往某$socket写入信息也就是发信息回客户端。
大体过程:客户端发送握手请求,会发来一个Sec-WebSocket-Key,拿到它连上固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,将结果sha1()一下加上true参数取原始20位的二进制数据,再将结果base64_encode一下返回。具体格式看以下代码或参考链接,都讲得比较详细。返回后浏览器确认正确即完成握手操作,客户端与服务器就连上了。之后就是循环有状态变化的套接字接收数据返回数据即可。
还有一个难点是如果判断发来的信息是普通的聊天信息还是断开信息。主要也是卡在这里,按理论是浏览器执行close()方法或者关闭浏览器断开发送的信息里边会有个opcode信息值为1000之类的,但socket_recv()或者进一步解码数据帧也看不到什么opcode,不懂了。网上代码里边有的根据socket_recv()返回的字节数为0就说是断开信息,实测发送个空字符串也有6个字节返回。有的是根据解码$buf是否为空,这个还靠谱一点。但还有一个问题就是刷新浏览器发送的字节是8个,解码后也不为空,这可就难办了,最终想了个旁门左道的办法那就是是浏览器正常发送的聊天信息是json字符串,接收解码后json_decode回去如果不是对象,那就是断开信息或者刷新信息了。其实应该是有专业的做法的,哎,暂时找不到。
很简单的代码,只是纯文字的聊天,如果要添加表情图片神马的估计还得加工完善一些:
ob_implicit_flush();
$host = '127.0.0.1';
$port = 2222;
$maxClient = 1000;
const MSG_TYPE_HANDSHAKE = 0;//握住信息
const MSG_TYPE_MESSAGE = 1;//正常聊天信息
const MSG_TYPE_DISCONNECT = -1;//退出信息
const MSG_TYPE_JOIN = 2;//请求加入信息,给特定用户
const MSG_TYPE_LOGIN = 3;//加入聊天信息,给全体发
$master = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);//注意不是SQL
if ($master === FALSE)
{
echo 'socket_create() failed:'.socket_strerror(socket_last_error());
exit();
}
socket_set_option($master, SOL_SOCKET, SO_REUSEADDR, 1);//一个端口释放可立即使用,测试其实还是不可用
$bind = socket_bind($master, $host, $port);
if ($bind === FALSE)
{
echo 'socket_bind() failed:'.socket_strerror(socket_last_error());
exit();
}
$listen = socket_listen($master, $maxClient);//超过最大监听数会有WSAECONNREFUSED错误
if ($listen === FALSE)
{
echo 'socket_listen() failed:'.socket_strerror(socket_last_error());
exit();
}
$clients = array();//负责用户通信的socket列表
$users = array();//用户信息
//开始循环
while(1)
{
$sockets = $clients;//所有的socket,用于监听哪些有状态变化
$sockets[] = $master;//包括监听主机端口的这个
$write = NULL;//函数参数是传递引用,必须定义变量
$except = NULL;
$tv_sec = NULL;
socket_select($sockets, $write, $except, $tv_sec);//多路选择,监听哪些socket有状态变化,返回时将有状态变化的保留在$sockets中,其他都删除之!
//循环有状态变化的socket
$time = date('Y-m-d H:i:s', time());
foreach ($sockets as $socket)
{
if ($socket === $master)
{
//监听主机端口的socket有状太变化,说明有新用户接入
$client = socket_accept($master);//创建新socket负责该用户通信
if ($client === FALSE)
{
echo 'socket_accept() failed:'.socket_strerror(socket_last_error());
}
else
{
$clients[] = $client;//加入用户列表
doHandshake($client);//进行握手
socket_getpeername($client, $ip);//获取用户IP地址
$response = frameEncode(json_encode(array('type' => MSG_TYPE_HANDSHAKE, 'msg' => $ip.' connected', 'time' => $time)));//编码数据帧
sendMessage($response, $client);
echo "new connected $ip\r\n";
}
}
else
{
//其他socket的状态变化
$bytes = socket_recv($socket, $buf, 1024, 0);//读取发送过来的信息的字节数
$data = frameDecode($buf);//正常信息为json字符串,
if ($bytes === FALSE)
{
echo 'socket_recv() failed:'.socket_strerror(socket_last_error());
}
elseif($bytes <= 6 || empty($data) || !is_object(json_decode($data)))
{
$index = array_search($socket, $clients);//寻找该socket在用户列表中的位置
$userInfo = $users[$index];
socket_getpeername($socket, $ip);//获取用户IP地址
$response = frameEncode(json_encode(array('type' => MSG_TYPE_DISCONNECT, 'msg' => $userInfo, 'time' => $time)));
sendMessage($response);
unset($clients[$index]);//删除用户
unset($users[$index]);
socket_close($socket);
echo "user $ip($index) disconnect\r\n";
}
else
{
//正常聊天信息
$data = json_decode($data);//对象
if ($data->type == MSG_TYPE_JOIN)
{
//握手成功请求加入
$index = array_search($socket, $clients);
$users[$index] = $data->userinfo;//记录用户信息,含id的用户名的json字符串
sendUserList($socket, $data->userinfo);//发送用户列表
echo "ask to join in \r\n";
}
elseif($data->type == MSG_TYPE_MESSAGE)
{
$response = frameEncode(json_encode(array('type' => MSG_TYPE_MESSAGE, 'msg' => $data->msg, 'time' => $time, 'username' => $data->username)));
sendMessage($response);
echo "receive message\r\n";
}
}
}
}
}
/**
* 握手操作
* Enter description here ...
* @param unknown_type $client
*/
function doHandshake($client)
{
$header = socket_read($client, 1024);//读取头信息
if (preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $header, $match))//冒号后面有个空格
{
$secKey = $match[1];
$secAccept = base64_encode(sha1($secKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', TRUE));//握手算法固定的
$upgrade = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" .
"Upgrade: websocket\r\n" .
"Connection: Upgrade\r\n" .
"Sec-WebSocket-Accept:$secAccept\r\n\r\n";
socket_write($client, $upgrade, strlen($upgrade));
}
}
/**
* 编码数据帧
* Enter description here ...
* @param unknown_type $text
*/
function frameEncode($text)
{
$b1 = 0x80 | (0x1 & 0x0f);
$length = strlen($text);
if($length <= 125)
{
$header = pack('CC', $b1, $length);
}
elseif($length > 125 && $length < 65536)
{
$header = pack('CCn', $b1, 126, $length);
}
elseif($length >= 65536)
{
$header = pack('CCNN', $b1, 127, $length);
}
return $header.$text;
}
/**
* 解码数据帧
* Enter description here ...
* @param unknown_type $text
*/
function frameDecode($text) {
$length = ord($text[1]) & 127;
if($length == 126)
{
$masks = substr($text, 4, 4);
$data = substr($text, 8);
}
elseif($length == 127)
{
$masks = substr($text, 10, 4);
$data = substr($text, 14);
}
else
{
$masks = substr($text, 2, 4);
$data = substr($text, 6);
}
$text = "";
for ($i = 0; $i < strlen($data); ++$i)
{
$text .= $data[$i] ^ $masks[$i%4];
}
return $text;
}
/**
* 发送信息
* Enter description here ...
* @param unknown_type $msg
*/
function sendMessage($msg, $receiver = '')
{
if (!empty($receiver))
{
socket_write($receiver, $msg, strlen($msg));
}
else
{
global $clients;
foreach ($clients as $client)
{
socket_write($client, $msg, strlen($msg));
}
}
}
/**
* 给某用户发送在线用户列表
* Enter description here ...
* @param unknown_type $client
*/
function sendUserList($client, $userinfo)
{
global $users;
$userList = json_encode($users);
$time = date('Y-m-d H:i:s', time());
$response = frameEncode(json_encode(array('type' => MSG_TYPE_JOIN, 'msg' => $userList, 'time' => $time, 'count' => count($users))));
socket_write($client, $response, strlen($response));//给特定用户发送在线用户列表
echo "send user list \r\n";
//通知其他用户有新用户登陆
sendMessage(frameEncode(json_encode(array('type' => MSG_TYPE_LOGIN, 'msg' => $userinfo, 'time' => $time))));
echo "login in success\r\n";
}
前台根据不同的信息类型做相应的数据展示即可!demo神马的,就不用了。