基于websocket实现的简易的聊天室功能

使用前得先开启 socket 扩展;
不止可以在 Linux 系统上实现,也可以在 window 系统上部署实现(参考最后的特别感谢)

查看是否安装 socket 扩展

输入命令 php -m,查看是否拥有 sockets,参考下图:

  • 配置 socket 扩展

    • Linux 环境
    1. 查看 swoole.so 文件所在位置,输入指令 find / -name swoole.so*

    2. 找到使用对应PHP版本的配置文件(php.ini),输入指令 find / -name php.ini*

    3. 进入该文件,查找 extension = swoole.so,把前面的注释(;)去掉,没找到则新增一行,参考下图:

    4. 重启 php 服务,指令为:sudo service php7.3-fpm restart

      ps:重启 php 如果无法启动,找到 php 的目录下执行

    • window 环境

      小皮面板或者宝塔面板都是一样的,直接去 php.ini 文件新增一行 extension=php_sockets.dll,然后重启 php 服务就可以了

      参考博客:PHP开启Socket扩展

  • 查看 socket 扩展是否生效

    • 使用 php 内置函数 phpinfo 打印查看,搜索 sockets 能找到单独的模块就是生效了

    • 使用代码进行测试,源码如下:

     if(extension_loaded('sockets')){
       echo "1";
    }else{
       echo "0";
    }
    

    如果输出1,则说明配置正确,如果输出0,则配置错误,需要检查并重新配置

完整源码

  • 前端源码

    <!DOCTYPE html>
    <html lang="en">
    <head>
    	<meta charset="UTF-8">
    	<meta name="viewport" content="width=device-width, initial-scale=1">
    	<title>websocket聊天室</title>
    	<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600" rel="external nofollow" rel="stylesheet">
    	<link rel="stylesheet" href="/css/reset.min.css" rel="external nofollow" >
    	<link rel="stylesheet" href="/css/style.css" rel="external nofollow" >
    	<link rel="stylesheet" href="/layer/theme/default/layer.css" rel="external nofollow" >
    	<style>
    		.message img {
    			float: left;
    			width: 40px;
    			height: 40px;
    			margin-right: 12px;
    			border-radius: 50%;
    		}
    
    		.you {
    			margin-left: 60px;
    			margin-top: -39px;
    		}
    
    		.me-header {
    			float: right !important;
    			margin-right: 0 !important;
    		}
    
    		.me {
    			margin-right: 60px;
    			margin-top: -39px;
    		}
    
    		.active-chat::-webkit-scrollbar, .left::-webkit-scrollbar {
    			width: 2px;
    		}
    	</style>
    </head>
    <body>
    
    <div class="wrapper">
    	<div class="container">
    		<div class="left">
    			<div class="top" style="padding: 20px 29px;height: auto;">
    				<div class="" style="font: 400 13.3333px Arial;font-weight: 400;">在线人数:<span id="numbers">0</span> 人
    				</div>
    			</div>
    			<ul class="people">
    			</ul>
    		</div>
    		<div class="right">
    			<div class="top"><span>Tips: <span class="name">PHP之websocket聊天室</span></span></div>
    			<div class="chat active-chat" data-chat="person1"
    				 style="height: auto;border-width: 0px;padding: 10px;height: 483px; padding: 10px;overflow-y: auto;scrollTop: 100px">
    			</div>
    			<div class="write">
    				<a href="javascript:;" rel="external nofollow" rel="external nofollow" rel="external nofollow" class="write-link attach"></a>
    				<input type="text" id="input-value" onkeydown="confirm(event)"/>
    				<a href="javascript:;" rel="external nofollow" rel="external nofollow" rel="external nofollow" class="write-link smiley"></a>
    				<a href="javascript:;" rel="external nofollow" rel="external nofollow" rel="external nofollow" class="write-link send" onclick="send()"></a>
    			</div>
    		</div>
    	</div>
    </div>
    
    <script src="/js/jquery-2.1.1.min.js"></script>
    <script src="/js/index.js"></script>
    <script src="/layer/layer.js"></script>
    <script>
    	var uname = "user" + uuid(8, 11);
    	layer.open({
    		title: '您的用户名如下',
    		content: uname,
    		closeBtn: 0,
    		yes: function (index, layero) {
    			layer.close(index);
    		}
    	});
    	// 随机选出一个头像
    	var avatar = ['a1.jpg', 'a2.jpg', 'a3.jpg', 'a4.jpg', 'a5.jpg', 'a6.jpg', 'a7.jpg', 'a8.jpg', 'a9.jpg', 'a10.jpg'];
    	if (avatar[Math.round(Math.random() * 10)]) {
    		var headerimg = "img/" + avatar[Math.round(Math.random() * 10)];
    	} else {
    		var headerimg = "img/" + avatar[0];
    	}
    
    	var ws = null;
    	// 创建websocket连接
    	connect();
    	function connect() {
    		// 创建一个 websocket 连接 ws://ip:端口号
    		ws = new WebSocket("ws://127.0.0.1:1234");
    
    		// 连接状态 1已建立连接
    		console.log(ws.readyState)
    
    		// 连接建立时触发
    		ws.onopen = onopen;
    
    		// 客户端接收服务端数据时触发
    		ws.onmessage = onmessage;
    
    		// 连接关闭时触发
    		ws.onclose = onclose;
    
    		// 通信发生错误时触发
    		ws.onerror = onerror;
    	}
    
    	// 通信建立成功
    	function onopen()
    	{
    		var data = "系统消息:建立连接成功";
    		console.log(data);
    	}
    
    	// 接收客户端的数据,发送数据
    	function onmessage(e)
    	{
    		var data = JSON.parse(e.data);
    		//console.log(data)
    
    		switch (data.type) {
    			case 'handShake':
    				//首次登录,发送登陆数据
    				var user_info = {'type': 'login', 'msg': uname, 'headerimg': headerimg};
    				sendMsg(user_info);
    				break;
    			case 'login':
    				userList(data.user_list);
    				systemMessage('系统消息: ' + data.msg + ' 已上线');
    				break;
    			case 'logout':
    				userList(data.user_list);
    				if (data.msg.length > 0) {
    					systemMessage('系统消息: ' + data.msg + ' 已下线');
    				}
    				break;
    			case 'user':
    				messageList(data);
    				break;
    			case 'system':
    				systemMessage();
    				break;
    		}
    	}
    	function onclose()
    	{
    		console.log("连接关闭,定时重连");
    		connect();
    	}
    
    	// websocket 错误事件
    	function onerror()
    	{
    		var data = "系统消息 : 出错了,请退出重试.";
    		console.log(data);
    	}
    
    	function confirm(event) {
    		var key_num = event.keyCode;
    		if (13 == key_num) {
    			send();
    		} else {
    			return false;
    		}
    	}
    
    	// 发送数据
    	function send() {
    		var msg = document.querySelector("input#input-value").value;
    		var reg = new RegExp("\r\n", "g");
    		msg = msg.replace(reg, "");
    		sendMsg({type: "user", msg: msg});
    		document.querySelector("input#input-value").value = "";
    	}
    
    	// 发送数据
    	function sendMsg(msg) {
    		var data = JSON.stringify(msg);
    		ws.send(data);
    	}
    
    	// 追加数据 上下线的系统消息
    	function systemMessage(msg) {
    		var html = `<div class="conversation-start">
    		<span>` + msg + `</span>
    		</div>`;
    		var active_chat = document.querySelector('div.active-chat');
    		var oldHtml = active_chat.innerHTML;
    		active_chat.innerHTML = oldHtml + html;
    		active_chat.scrollTop = active_chat.scrollHeight;
    	}
    
    	// 追加从服务端返回的数据 左侧在线人数列表
    	function userList(user) {
    		var html = '';
    		for (var i = 0; i < user.length; i++) {
    			html += `<li class="person" data-chat="person1">
    		<img src="` + user[i].headerimg + `" alt=""/>
    		<span class="name">` + user[i].username + `</span>
    		<span class="time">` + user[i].login_time + `</span>
    		<span class="preview" style="color: green;font-size: 7px;">在线</span>
    		</li>`;
    		}
    		document.querySelector('ul.people').innerHTML = html;
    		document.querySelector('span#numbers').innerHTML = user.length;
    	}
    
    	// 右侧聊天记录列表
    	function messageList(data) {
    
    		// 判读是不是自己发送的消息,对应的样式不同
    		if (data.from == uname) {
    			// 如果当前用户名和feom的用户名相同,就说明时自己发送的消息
    			var html = `<div class="message">
    		  <img class="me-header" src="` + data.headerimg + `" alt=""/>
    		  <div class="bubble me">` + data.msg + `</div>
    		  </div>`;
    		} else {
    			// 别人发送的信息列表
    			var html = `<div class="message">
    		  <img src="` + data.headerimg + `" alt=""/>
    		  <div class="bubble you">` + data.msg + `</div>
    		  </div>`;
    		}
    		var active_chat = document.querySelector('div.active-chat');
    		var oldHtml = active_chat.innerHTML;
    		active_chat.innerHTML = oldHtml + html;
    		active_chat.scrollTop = active_chat.scrollHeight;
    	}
    
    	/**
    	 * 生产一个全局唯一ID作为用户名的默认值;
    	 *
    	 * @param len
    	 * @param radix
    	 * @returns {string}
    	 */
    	function uuid(len, radix) {
    		var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
    		var uuid = [], i;
    		radix = radix || chars.length;
    
    		if (len) {
    			for (i = 0; i < len; i++) uuid[i] = chars[0 | Math.random() * radix];
    		} else {
    			var r;
    
    			uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
    			uuid[14] = '4';
    
    			for (i = 0; i < 36; i++) {
    				if (!uuid[i]) {
    					r = 0 | Math.random() * 16;
    					uuid[i] = chars[(i == 19) ? (r & 0x3) | 0x8 : r];
    				}
    			}
    		}
    		return uuid.join('');
    	}
    </script>
    </body>
    </html>
    
    

    ps : 实际使用中,第93行的 127.0.0.1:1234 要改成自己的服务器 ip 和 单独开放的端口号

  • 后端源码

    • 创建自定义文件 SocketServer.php

      执行 php artisan make:command SocketServer会在 app\Console\Command 目录下生成 SocketServer.php 文件,完整源码如下:

      <?php
      
      namespace App\Console\Commands;
      
      use extend\socket;
      use Illuminate\Console\Command;
      
      class SocketServer extends Command
      {
         /**
          * The name and signature of the console command.
          *
          * @var string
          */
         protected $signature = 'socket:im';
      
         /**
          * The console command description.
          *
          * @var string
          */
         protected $description = 'Command description';
      
         /**
          * Create a new command instance.
          *
          * @return void
          */
         public function __construct()
         {
         	parent::__construct();
         }
      
         /**
          * Execute the console command.
          *
          * @return int
          */
         public function handle()
         {
         	require_once "extend/sockets.php";
         	// 类外实例化
         	$sk = new \Sockets();
         	// 运行
         	$sk -> run();
         }
      }
      
      
    • 在 Kernel.php 文件中注册这个 SocketServer 类,完整源码如下:

      <?php
      
      namespace App\Console;
      
      use App\Console\Commands\SocketServer;
      use Illuminate\Console\Scheduling\Schedule;
      use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
      
      class Kernel extends ConsoleKernel
      {
      	/**
      	 * The Artisan commands provided by your application.
      	 *
      	 * @var array
      	 */
      	protected $commands = [
      		SocketServer::class
      	];
      
      	/**
      	 * Define the application's command schedule.
      	 *
      	 * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
      	 * @return void
      	 */
      	protected function schedule(Schedule $schedule)
      	{
      		// $schedule->command('inspire')->hourly();
      	}
      
      	/**
      	 * Register the commands for the application.
      	 *
      	 * @return void
      	 */
      	protected function commands()
      	{
      		$this->load(__DIR__.'/Commands');
      
      		require base_path('routes/console.php');
      	}
      }
      
      
    • Sockets 完整逻辑代码如下:

         <?php
         /**
          * Created by PhpStorm.
          * User: 25754
          * Date: 2019/4/23
          * Time: 14:13
          */
      
         use Illuminate\Support\Facades\Log;
      
         class Sockets
         {
      
         	const LISTEN_SOCKET_NUM = 9;
         	//const LOG_PATH = "./log/"; //日志
         	private $_ip = "0.0.0.0"; //ip
         	private $_port = 9588; //端口 要和前端创建WebSocket连接时的端口号一致
         	private $_socketPool = array(); //socket池,即存放套接字的数组
         	private $_master = null;  //创建的套接字对象
      
         	public function __construct()
         	{
         		$this->initSocket();
         	}
      
         	// 创建WebSocket连接
         	private function initSocket()
         	{
         		try {
         			//创建socket套接字
         			$this->_master = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
         			// 设置IP和端口重用,在重启服务器后能重新使用此端口;
         			socket_set_option($this->_master, SOL_SOCKET, SO_REUSEADDR, 1);
         			//绑定地址与端口
         			socket_bind($this->_master, $this->_ip, $this->_port);
         			//listen函数使用主动连接套接口变为被连接套接口,使得一个进程可以接受其它进程的请求,从而成为一个服务器进程。在TCP服务器编程中listen函数把进程变为一个服务器,并指定相应的套接字变为被动连接,其中的能存储的请求不明的socket数目。
         			socket_listen($this->_master, self::LISTEN_SOCKET_NUM);
         		} catch (\Exception $e) {
         			$this->debug(array("code: " . $e->getCode() . ", message: " . $e->getMessage()));
         		}
         		//将socket保存到socket池中 (将套接字放入数组)默认把当前用户放在第一个
         		$this->_socketPool[0] = array('resource' => $this->_master);
         		$pid = getmypid();
         		$this->debug(array("server: {$this->_master} started,pid: {$pid}"));
         	}
      
         	// 挂起进程遍历套接字数组,对数据进行接收、处理、发送
         	public function run()
         	{
      
         		// 死循环 直到socket断开
         		while (true) {
         			//try {
      
         				$write = $except = NULL;
         				// 从数组中取出resource列
         				$sockets = array_column($this->_socketPool, 'resource');
      
         				/*
         				$sockets 是一个存放文件描述符的数组。
         				$write 是监听是否客户端写数据,传入NULL是不关心是否有写变化
         				$except 是$sockets里面要派粗话的元素,传入null是监听全部
         				最后一个参数是超时时间,0立即结束 n>1则最多n秒后结束,如遇某一个连接有新动态,则提前返回 null如遇某一个连接有新动态,则返回
         				*/
         				// 接收套接字数字,监听他们的状态就是有新消息到或有客户端连接/断开时,socket_select函数才会返回,继续往下执行
         				$read_num = socket_select($sockets, $write, $except, NULL);
         				if (false === $read_num) {
         					$this->debug(array('socket_select_error', $err_code = socket_last_error(), socket_strerror($err_code)));
         					return;
         				}
      
         				// 遍历套接字数组
         				foreach ($sockets as $socket) {
      
         					// 如果有新的连接进来
         					if ($socket == $this->_master) {
         						// 接收一个socket连接
         						$client = socket_accept($this->_master);
         						if ($client === false) {
         							$this->debug(['socket_accept_error', $err_code = socket_last_error(), socket_strerror($err_code)]);
         							continue;
         						}
         						//连接 并放到socket池中
         						$this->connection($client);
         					} else {
      
         						//接收已连接的socket数据,返回的是从socket中接收的字节数。
         						// 第一个参数:socket资源,第二个参数:存储接收的数据的变量,第三个参数:接收数据的长度
         						$bytes = @socket_recv($socket, $buffer, 2048, 0);
      
         						// 如果接收的字节数为0
         						if ($bytes == 0) {
      
         							// 断开连接
         							$recv_msg = $this->disconnection($socket);
         						} else {
      
         							// 判断有没有握手,没有握手进行握手,已经握手则进行处理
         							if ($this->_socketPool[(int)$socket]['handShake'] == false) {
         								// 握手
         								$this->handShake($socket, $buffer);
         								continue;
         							} else {
         								// 解析客户端传来的数据
         								$recv_msg = $this->parse($buffer);
         							}
         						}
      
         						// echo "<pre>";
         						// 业务处理,组装返回客户端的数据格式
         						$msg = $this->doEvents($socket, $recv_msg);
         						// print_r($msg);
      
         						socket_getpeername ( $socket , $address ,$port );
         						$this->debug(array(
         							'send_success',
         							json_encode($recv_msg),
         							$address,
         							$port
         						));
         						// 把服务端返回的数据写入套接字
         						$this->broadcast($msg);
         					}
         				}
         			/*} catch (\Exception $e) {
         				$this->debug(array("code: " . $e->getCode() . ", message: " . $e->getMessage()));
         			}*/
      
         		}
      
         	}
      
         	/**
         	 * 数据广播
         	 * @param $data
         	 */
         	private function broadcast($data)
         	{
         		foreach ($this->_socketPool as $socket) {
         			if ($socket['resource'] == $this->_master) {
         				continue;
         			}
         			// 写入套接字
         			socket_write($socket['resource'], $data, strlen($data));
         		}
         	}
      
         	/**
         	 * 业务处理,在这可以对数据库进行操作,并返回客户端数据;根据不同类型,组装不同格式的数据
         	 * @param $socket
         	 * @param $recv_msg 客户端传来的数据
         	 * @return string
         	 */
         	private function doEvents($socket, $recv_msg)
         	{
         		$msg_type = $recv_msg['type'];
         		$msg_content = $recv_msg['msg'];
         		$response = [];
         		//echo "<pre>";
         		switch ($msg_type) {
         			case 'login':
         				// 登陆上线信息
         				$this->_socketPool[(int)$socket]['userInfo'] = array("username" => $msg_content, 'headerimg' => $recv_msg['headerimg'], "login_time" => date("H:i:s"));
         				// 取得最新的名字记录
         				$user_list = array_column($this->_socketPool, 'userInfo');
         				$response['type'] = 'login';
         				$response['msg'] = $msg_content;
         				$response['user_list'] = $user_list;
         				//print_r($response);
      
         				break;
         			case 'logout':
         				// 退出信息
         				$user_list = array_column($this->_socketPool, 'userInfo');
         				$response['type'] = 'logout';
         				$response['user_list'] = $user_list;
         				$response['msg'] = $msg_content;
         				//print_r($response);
         				break;
         			case 'user':
         				// 发送的消息
         				$userInfo = $this->_socketPool[(int)$socket]['userInfo'];
         				$response['type'] = 'user';
         				$response['from'] = $userInfo['username'];
         				$response['msg'] = $msg_content;
         				$response['headerimg'] = $userInfo['headerimg'];
         				//print_r($response);
         				break;
         		}
      
         		return $this->frame(json_encode($response));
         	}
      
         	/**
         	 * socket握手
         	 * @param $socket
         	 * @param $buffer 客户端接收的数据
         	 * @return bool
         	 */
         	public function handShake($socket, $buffer)
         	{
         		$acceptKey = $this->encry($buffer);
         		$upgrade = "HTTP/1.1 101 Switching Protocols\r\n" .
         			"Upgrade: websocket\r\n" .
         			"Connection: Upgrade\r\n" .
         			"Sec-WebSocket-Accept: " . $acceptKey . "\r\n\r\n";
      
         		// 将socket写入缓冲区
         		socket_write($socket, $upgrade, strlen($upgrade));
         		// 标记握手已经成功,下次接受数据采用数据帧格式
         		$this->_socketPool[(int)$socket]['handShake'] = true;
         		socket_getpeername ( $socket , $address ,$port );
         		$this->debug(array(
         			'hand_shake_success',
         			$socket,
         			$address,
         			$port
         		));
         		//发送消息通知客户端握手成功
         		$msg = array('type' => 'handShake', 'msg' => '握手成功');
         		$msg = $this->frame(json_encode($msg));
         		socket_write($socket, $msg, strlen($msg));
         		return true;
         	}
      
         	/**
         	 * 帧数据封装
         	 * @param $msg
         	 * @return string
         	 */
         	private function frame($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);
         	}
      
         	/**
         	 * 解析客户端的数据
         	 * @param $buffer
         	 * @return mixed
         	 */
         	private function parse($buffer)
         	{
         		$decoded = '';
         		$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++) {
         			$decoded .= $data[$index] ^ $masks[$index % 4];
         		}
         		return json_decode($decoded, true);
         	}
      
         	//提取 Sec-WebSocket-Key 信息并加密
         	private function encry($req)
         	{
         		$key = null;
         		if (preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $req, $match)) {
         			$key = $match[1];
         		}
         		// 加密
         		return base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
         	}
      
         	/**
         	 * 连接socket
         	 * @param $client
         	 */
         	public function connection($client)
         	{
         		socket_getpeername ( $client , $address ,$port );
         		$info = array(
         			'resource' => $client,
         			'userInfo' => '',
         			'handShake' => false,
         			'ip' => $address,
         			'port' => $port,
         		);
         		$this->_socketPool[(int)$client] = $info;
         		$this->debug(array_merge(['socket_connect'], $info));
         	}
      
         	/**
         	 * 断开连接
         	 * @param $socket
         	 * @return array
         	 */
         	public function disconnection($socket)
         	{
         		$recv_msg = array(
         			'type' => 'logout',
         			'msg' => @$this->_socketPool[(int)$socket]['userInfo']['username'],
         		);
         		unset($this->_socketPool[(int)$socket]);
         		return $recv_msg;
         	}
      
         	/**
         	 * 日志
         	 * @param array $info
         	 */
         	private function debug(array $info)
         	{
         		$time = date('Y-m-d H/i:s');
         		array_unshift($info, $time);
         		$info = array_map('json_encode', $info);
         		//Log::debug(implode(' | ', $info) . "\r\n");
         		//file_put_contents(self::LOG_PATH . 'websocket_debug.log', implode(' | ', $info) . "\r\n", FILE_APPEND);
         		file_put_contents(storage_path('logs/websocket_debug.log'), implode(' | ', $info) . "\r\n", FILE_APPEND);
         	}
         }
      
      

      生成的自定义命令文件调用的就是这个 Sockets 文件

运行

最后实现的效果,参考下图:

完整项目

博主部署在 gitee 上,使用了 laravel7 框架进行配置,项目地址:简易聊天室

特别提醒

使用对应的端口号,服务器就要开放对应的端口号,如果环境是宝塔面板的,宝塔面板也要开放这个对应的端口好,切记!!!

特别感谢

参考博客:php+websocket 实现的聊天室功能详解

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值