一、什么是WebSocket?
1.1 简介
WebSocket协议是基于TCP的一种新的网络协议。它实现了浏览器与服务器全双工(full-duplex)通信,即允许服务器主动发送信息给客户端。因此,在WebSocket中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,客户端和服务器之间的数据交换变得更加简单。
1.2 WebSocket的优势
现在,很多网站为了实现推送技术,所用的技术都是Ajax轮询。轮询是在特定的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。
这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求。然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。HTML5定义的WebSocket协议优势如下:
1、小Header,互相沟通的Header非常小,只有2Bytes左右。
2、服务器不再被动接收到浏览器的请求之后才返回数据,而是在有新数据时就主动推送给浏览器。
3、WebSocket协议能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。
1.3 WebSocket的原理
▪ Websocket协议由RFC 6455定义,协议分为两个部分: 握手阶段和全双工通信阶段。
客户端发送的header内容
GET /nickname11 HTTP/1.1
Host: 127.0.0.1:9090
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: wJdg8v4EJiDsIZg5+s0hY8RUQ2A=
Sec-WebSocket-Version: 13
Origin: http://127.0.0.1
服务端响应的header内容,这里的Sec-WebSocket-Accept要根据发送的Sec-WebSocket-Key来处理算出来,计算方法:base64_encode(sha1(websocket_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true)) 。
HTTP/1.1 101 Switching Protocol
Upgrade: WebSocket
Sec-WebSocket-Version: 13
Connection: Upgrade
Sec-WebSocket-Accept: wJdg8v4EJiDsIZg5+s0hY8RUQ2A=
▪ Websocket协议的握手阶段是使用的HTTP协议。
▪ Websocket协议的“全双工”消息通信是基于 TCP/IP 的协议集之上的,客户端和服务端可随时发送数据。协议连接是“ws”或者加密的“wss”。
▪ 通信的数据是基于“帧(frame)”的,可以传输文本数据,也可以直接传输二进制数据,效率高。
一条消息(message)可由一个或多个帧(Frame)组成,很多时候会将帧和消息混用,因为大部分时候一条消息只使用一个帧
二、使用PHP实现WebSocket通信
1、server.php(服务端)
<?php
header('Content-Type:application/json; charset=utf-8');
class server{
protected $sockets;
protected $users;
protected $master;
protected $ip = '0.0.0.0';
protected $port = '9090';
protected $backlog = 5; //排队等候的连接队列最大值
protected $length = 1024*8; //可读取的最大字节数
protected $redisIp = '127.0.0.1';
protected $redisPort = 6379;
protected $redisLength = 1024*600;
public function __construct(){
$this->master = $this->createWebSocket();
//创建socket连接池
$this->sockets=array($this->master);
}
public function start(){
while (true) {
$changes=$this->sockets;
$write=NULL;
$except=NULL;
//设置非阻塞,让多个连接能同时正常往下执行
@socket_select($changes, $write, $except, NULL);
foreach($changes as $socket){
//判断是否新的socket连接
if($socket == $this->master){
$client=socket_accept($socket);
$key=uniqid();
$this->sockets[]=$client;
$this->users[$key]=array(
'client'=>$client,
'is_shake'=>0
);
}else{
$len=0;
$buffer='';
do{
$l=socket_recv($socket,$buf,1024,0);
$len+=$l;
$buffer.=$buf;
}while($l==1024);
$key = $this->search($socket);
// 如果接收的信息长度小于7,则该client的socket为断开连接
if($len<7){
unset($this->users[$key]);
socket_close($socket);
continue;
}
//判断连接是否已握手
if(!$this->users[$key]['is_shake']){
$this->shake($key, $buffer);
}else{
//接收客户端发送消息
$buffer = $this->getMsg($buffer);
if($buffer === false){
continue;
}
//发送消息
$this->sendMsg($key,$buffer);
}
}
}
}
}
protected function intoRedis($data)
{
$redis = new Redis();
$redis->pconnect($this->redisIp, $this->redisPort, $this->redisLength);
$redis->lpush("ws_".$this->getMd5Key($data['username']), json_encode($data));
return true;
}
protected function search($socket)
{
foreach ($this->users as $key=>$val){
if($socket==$val['client'])
return $key;
}
return false;
}
protected function shake($key, $buf)
{
preg_match("/Sec-WebSocket-Key: (.*)\r\n/i",$buf,$match);
//用于服务端计算Sec_WebSocket_Accept的固定的字符串
$keyStr = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
$res= "HTTP/1.1 101 Switching Protocol".PHP_EOL
."Upgrade: WebSocket".PHP_EOL
."Sec-WebSocket-Version: 13".PHP_EOL
."Connection: Upgrade".PHP_EOL
."Sec-WebSocket-Accept: " . base64_encode(sha1($match[1].$keyStr ,true)) .PHP_EOL.PHP_EOL; // 注意需要两个换行
// 向客户端应答 Sec-WebSocket-Accept
socket_write($this->users[$key]['client'], $res, strlen($res));
//对已经握手的client做标志
$this->users[$key]['is_shake'] = 1;
return true;
}
protected function sendMsg($key, $buffer)
{
$index = strpos($buffer, ":");
$data = [
'username' => substr($buffer, 0, $index),
'msg' => substr($buffer, ($index+1)),
'time' => date("Y-m-d H:i:s", time()),
];
foreach($this->users as $val){
$msg = $this->buildMsg(json_encode($data));
socket_write($val['client'], $msg, strlen($msg));
}
//通过redis记录消息
$this->intoRedis($data);
echo "<pre/>";
print_r($data);
}
// 编码服务端向客户端发送的内容
protected function buildMsg($msg) {
$frame = [];
$frame[0] = '81';
$len = strlen($msg);
if ($len < 126) {
$frame[1] = $len < 16 ? '0' . dechex($len) : dechex($len);
} else if ($len < 65025) {
$s = dechex($len);
$frame[1] = '7e' . str_repeat('0', 4 - strlen($s)) . $s;
} else {
$s = dechex($len);
$frame[1] = '7f' . str_repeat('0', 16 - strlen($s)) . $s;
}
$data = '';
$l = strlen($msg);
for ($i = 0; $i < $l; $i++) {
$data .= dechex(ord($msg{$i}));
}
$frame[2] = $data;
$data = implode('', $frame);
return pack("H*", $data);
}
// 解析客户端向服务端发送的内容
protected function getMsg($buffer) {
$res = '';
$len = ord($buffer[1]) & 127;
if ($len === 126) {
$masks = substr($buffer, 4, 4);
$data = substr($buffer, 8);
} else if ($len === 127) {
$masks = substr($buffer, 10, 4);
$data = substr($buffer, 14);
} else {
$masks = substr($buffer, 2, 4);
$data = substr($buffer, 6);
}
for ($index = 0; $index < strlen($data); $index++) {
$res .= $data[$index] ^ $masks[$index % 4];
}
return $res;
}
//建立WebSocket链接
protected function createWebSocket(){
$server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1);//1代表接受所有的数据包
socket_bind($server, $this->ip, $this->port);
socket_listen($server);
echo 'Socket连接创建成功,时间: '.date('Y-m-d H:i:s').PHP_EOL;
return $server;
}
protected function getMd5Key($username)
{
return md5($username."WebSocket");
}
}
$server = new server();
$act = isset($_POST['act']) ? $_POST['act'] : 'start';
if($act == 'start'){
$server->start();
}else if($act == 'getAllMsg'){
$server->getRedis();
}
2、getredis.php(获取存在redis的历史消息)
<?php
//查看redis里全部的聊天信息
$act = isset($_POST['act']) ? $_POST['act'] : '';
if(!$act){
echo json_encode(['code'=>500, 'msg'=>'act参数不能为空', 'data'=>[]]);
exit;
}
if($act != 'getAllMsg'){
echo json_encode(['code'=>500, 'msg'=>'act传参错误', 'data'=>[]]);
exit;
}
$redisIp = '127.0.0.1';
$redisPort = 6379;
$redisLength = 1024*600;
$redis = new Redis();
$redis->pconnect($redisIp, $redisPort, $redisLength);
$keys = $redis->keys("ws_*");
$data = [];
if($keys){
foreach($keys as $key){
$res = $redis->lGetRange($key, 0, -1);
if($res){
foreach($res as &$val){
$val = json_decode($val, JSON_UNESCAPED_UNICODE);
$val['time_stamp'] = strtotime($val['time']);
}
$data = array_merge($res, $data);
}
}
}
if($data){
$sort = array_column($data, 'time_stamp');
array_multisort($sort, SORT_ASC, $data);
}
echo json_encode(['code'=>200, 'msg'=>'获取成功', 'data'=>$data]);
exit;
3、chat.html(客户端)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket聊天室</title>
</head>
<style type="text/css">
body{
font-size:14px;
}
h4{
text-align: center;
font-size:16px
}
.divBox{
width:30%;
float:left;
border: 0.5px solid #bbb0b0;
padding: 10px;
margin-left: 10px;
}
.content{
width: 100%;
height: 500px;
overflow-y: scroll;
}
.chat{
width: 100%;
height: 500px;
overflow-y: scroll;
}
</style>
<body>
<div class="divBox">
<h4>状态栏</h4>
<p>当前用户:<span id="username"></span> 在线情况:<span style="color:red" id="situation">离线</span>
<button onclick="createWebsocket()">重新连接websocket</button>
<!-- <button onclick="closeWebsocket()">关闭websocket</button> -->
</p>
<textarea id="textarea" style="width:260px;height: 100px"></textarea>
<br/>
<input type="button" value="发送数据" id="send">
</div>
<div class="divBox">
<h4>聊天记录栏</h4>
<div class="chat"></div>
</div>
<div class="divBox">
<h4>webSocket事件输出栏</h4>
<div class="content"></div>
</div>
</body>
<script type="text/javascript" src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script type="text/javascript">
var date = new Date();
let username = prompt("请输入您的昵称", "nickname11");
username = username.replace(":", "");
$("#username").html(username);
var is_online = 0;
//创建webSocket连接
createWebsocket();
//获取存在redis的历史聊天记录
setTimeout(getAllMsg(), 3*1000);
function createWebsocket(){
let ws = new WebSocket("ws://127.0.0.1:9090/"+username);
ws.onopen = function(){
$(".content").append("连接成功..." + "<br/>");
is_online = 1;
// 点击发送数据
$("#send").click(function(){
var data = $("#textarea").val();
if(data){
ws.send(username+ ":"+ data);
$("#textarea").blur();
$("#textarea").val("");
}
})
}
ws.onmessage = function(event){
var data = $.parseJSON(event.data);
var chatStr = '';
if(data.username == username){
chatStr += "<font color='grey'>" + data.username + "</font><font color='green'>(本人)</font>";
}else{
chatStr += "<font color='grey'>" + data.username + "</font>";
}
chatStr += ":<font style='font-size:16px'>" + data.msg + "</font> <font color='grey'>" + data.time + "</font><br/><br/>";
$(".chat").append(chatStr);
}
ws.onclose = function(event){
$(".content").append("websocket 断开: " + event.code + " " + event.reason + " " + event.wasClean + "<br/>");
$(".content").append("连接已关闭" + "<br/>");
is_online = 0;
}
ws.onerror = function(event){
console.log(event.data);
}
}
function send() {
var data = document.getElementById('textarea').value;
ws.send(username+ ":"+ data);
}
function getAllMsg(){
//获取消息内容
$.post("http://127.0.0.1/websocket/getredis.php", {act:"getAllMsg"}, function(res){
var res = $.parseJSON(res);
console.log(res);
var chatStr = "";
$.each(res.data, function(k, v){
if(v.username == username){
chatStr += "<font color='grey'>" + v.username + "</font><font color='green'>(本人)</font>";
}else{
chatStr += "<font color='grey'>" + v.username + "</font>";
}
chatStr += ":<font style='font-size:16px'>" + v.msg + "</font> <font color='grey'>" + v.time + "</font><br/><br/>";
});
$(".chat").html(chatStr);
});
}
function closeWebsocket(){
}
setInterval(function () {
if(is_online == 1){
$("#situation").html("在线");
$("#situation").css({"color":"green"});
}else{
$("#situation").html("离线");
$("#situation").css({"color": "red"});
}
}, 2*1000)
</script>
</html>