开源项目案例 - 聊天室开发

优质
小牛编辑
134浏览
2023-12-01

本示例将演示如何使用 easySwoole 进行WebSocket聊天室开发,阅读本篇前,请先阅读文档相关部分。

  • 本示例依赖Redis,请自行安装Redis及Redis扩展
  • 本文所有文件命名空间及文件结构请自行根据业务情况修改。

一、创建WebSocket服务器

配置Config.php

在easySwoole的根目录中,Config.php是easySwoole的配置文件,可以使用Config对象获取其中的配置。

  • 本示例需要在Config.php中设置 SERVER_TYPETYPE_WEB_SOCKET_SERVER模式。
  • 本示例需要在Config.php中新增 REDIS 配置项。

更改SERVER_TYPE

  1. 'SERVER_TYPE'=>\EasySwoole\Core\Swoole\ServerManager::TYPE_WEB_SOCKET_SERVER,

新增REDIS配置

  1. 'REDIS' => [
  2. 'host' => '127.0.0.1',
  3. 'port' => 6379,
  4. 'password' => '',
  5. 'select' => 0,
  6. 'timeout' => 0,
  7. 'expire' => 0,
  8. 'persistent' => false,
  9. 'prefix' => '',
  10. ]

自定义WebSocket解析器

WebSocket模式下,Client和Server之间不再是新的请求,而是一条条消息;所以我们通过约定的格式来发送和响应消息,从而实现各种各样的功能。
通常传递自定义消息的方式是JSON和XML,在这里我们选择更方便简单的JSON作为示例;我们定义JSON数据3个键。

  1. {
  2. "controller": "Test",
  3. "action": "index",
  4. "data": {
  5. "parameter_one": "数据one",
  6. "parameter_two": "数据two"
  7. }
  8. }

例如上面的JSON数据,意思为访问Test控制器中的Index方法,参数为 parameter_oneparameter_two

easySwoole已经内置了基本的WebSocket Server封装,我们只需要实现 EasySwoole\Core\Socket\AbstractInterface\ParserInterface 解析器接口即可。

示例代码

  1. <?php
  2. namespace App\Socket\Parser;
  3. use EasySwoole\Core\Socket\AbstractInterface\ParserInterface;
  4. use EasySwoole\Core\Socket\Common\CommandBean;
  5. use App\Socket\Controller\WebSocket\Index;
  6. class WebSocket implements ParserInterface
  7. {
  8. public static function decode($raw, $client)
  9. {
  10. //检查数据是否为JSON
  11. $commandLine = json_decode($raw, true);
  12. if (!is_array($commandLine)) {
  13. return 'unknown command';
  14. }
  15. $CommandBean = new CommandBean();
  16. $control = isset($commandLine['controller']) ? 'App\\Socket\\Controller\\WebSocket\\'. ucfirst($commandLine['controller']) : '';
  17. $action = $commandLine['action'] ?? 'none';
  18. $data = $commandLine['data'] ?? null;
  19. //找不到类时访问默认Index类
  20. $CommandBean->setControllerClass(class_exists($control) ? $control : Index::class);
  21. $CommandBean->setAction(class_exists($control) ? $action : 'controllerNotFound');
  22. $CommandBean->setArg('data', $data);
  23. return $CommandBean;
  24. }
  25. public static function encode(string $raw, $client, $commandBean): ?string
  26. {
  27. // TODO: Implement encode() method.
  28. return $raw;
  29. }
  30. }

在上面的decode方法中,我们将一条JSON信息解析成调用 'App\\Socket\\Controller\\WebSocket\\' 命名空间下的控制器和方法,就像我们使用传统FPM模式那样。

注册WebSocket解析器

在easySwoole根目录中,EasySwooleEvent.php是easySwoole开放的事件注册方法,你可以简单的理解为,当程序执行到一些特定时刻,会执行Event中的方法。

注意: EasySwooleEvent 文件中的use下文都为省略模式,请自行引入其他必要组件
我们需要在 mainServerCreate (主服务创建时)方法中注册我们上面的WebSocket解析器。

  1. // 引入EventHelper
  2. use \EasySwoole\Core\Swoole\EventHelper;
  3. // 注意这里是指额外引入我们上文实现的解析器
  4. use \App\Socket\Parser\WebSocket;
  5. //...省略
  6. public static function mainServerCreate(ServerManager $server,EventRegister $register): void
  7. {
  8. // 注意一个事件方法中可以注册多个服务,这里只是注册WebSocket解析器
  9. // 注册WebSocket解析器
  10. EventHelper::registerDefaultOnMessage($register, WebSocket::class);
  11. }

接下来我们创建一个Test类来测试我们的WebSocket Server

  1. <?php
  2. namespace App\Socket\Controller\WebSocket;
  3. use EasySwoole\Core\Socket\AbstractInterface\WebSocketController;
  4. class Test extends WebSocketController
  5. {
  6. /**
  7. * 访问找不到的action
  8. * @param ?string $actionName 找不到的name名
  9. * @return string
  10. */
  11. public function actionNotFound(?string $actionName)
  12. {
  13. $this->response()->write("action call {$actionName} not found");
  14. }
  15. public function index()
  16. {
  17. $fd = $this->client()->getFd();
  18. $this->response()->write("you fd is {$fd}");
  19. }
  20. }

现在可以启动我们的Server了,在easySwoole根目录中输入以下命令启动。

php easyswoole start

如果没有任何报错,那幺已经启动了Server;这里我推荐WEBSOCKET CLIENT
测试工具来测试我们的Server。

  • 如果能正常连接服务器,说明Server已经启动
  • 如果发送 字符串消息返回 unknown command 说明解析器已经工作
  • 如果发送 {"controller": "Test","action": "index"} 返回 you fd is 1 则说明Server正常工作

到此为止WebSocket Server已经可以完成基本的工作,接下来是在easySwoole中使用Redis。

二、 在easySwoole中使用Redis

建立Redis连接

easySwoole中提供了Redis连接池,但是本示例不使用此方案,有能力的请自行选择。

php Redis连接示例

  1. <?php
  2. namespace App\Utility;
  3. class Redis
  4. {
  5. protected static $instance = null;
  6. protected $options = [
  7. 'host' => '127.0.0.1',
  8. 'port' => 6379,
  9. 'password' => '',
  10. 'select' => 0,
  11. 'timeout' => 0,
  12. 'expire' => 0,
  13. 'persistent' => false,
  14. 'prefix' => '',
  15. ];
  16. /**
  17. * 构造函数
  18. * @param array $options 参数
  19. * @access public
  20. */
  21. public function __construct($options = [])
  22. {
  23. if (!extension_loaded('redis')) {
  24. throw new \BadFunctionCallException('not support: redis');
  25. }
  26. if (!empty($options)) {
  27. $this->options = array_merge($this->options, $options);
  28. }
  29. }
  30. /**
  31. * 连接Redis
  32. * @return void
  33. */
  34. protected function connect()
  35. {
  36. if (!is_object(self::$instance)) {
  37. self::$instance = new \Redis;
  38. if ($this->options['persistent']) {
  39. self::$instance->pconnect($this->options['host'], $this->options['port'], $this->options['timeout'], 'persistent_id_' . $this->options['select']);
  40. } else {
  41. self::$instance->connect($this->options['host'], $this->options['port'], $this->options['timeout']);
  42. }
  43. if ('' != $this->options['password']) {
  44. self::$instance->auth($this->options['password']);
  45. }
  46. if (0 != $this->options['select']) {
  47. self::$instance->select($this->options['select']);
  48. }
  49. }
  50. }
  51. /**
  52. * 获取连接句柄
  53. * @return object Redis
  54. */
  55. public function handler()
  56. {
  57. $this->connect();
  58. return self::$instance;
  59. }
  60. }

easySwoole提供了Di容器,可以方便我们随时取用Redis,现在让我们在Event事件中将Redis注入到Di容器中。

  1. // 引入EventHelper
  2. use \EasySwoole\Core\Swoole\EventHelper;
  3. // 引入Di
  4. use \EasySwoole\Core\Component\Di;
  5. // 注意这里是指额外引入我们上文实现的解析器
  6. use \App\Socket\Parser\WebSocket;
  7. // 引入上文Redis连接
  8. use \App\Utility\Redis;
  9. // ...省略
  10. public static function mainServerCreate(ServerManager $server,EventRegister $register): void
  11. {
  12. // 注意一个事件方法中可以注册多个服务,这里只是注册WebSocket解析器
  13. // 注册WebSocket解析器
  14. EventHelper::registerDefaultOnMessage($register, WebSocket::class);
  15. // 注册Redis 从Config中读取Redis配置
  16. Di::getInstance()->set('REDIS', new Redis(Config::getInstance()->getConf('REDIS')));
  17. }

创建Room.php并使用Redis

现在我们新建Room.php文件作为我们的房间逻辑实现文件,第一步是连接Redis并测试。

  1. <?php
  2. namespace App\Socket\Logic;
  3. use EasySwoole\Core\Component\Di;
  4. class Room
  5. {
  6. public static function getRedis()
  7. {
  8. return Di::getInstance()->get('REDIS')->handler();
  9. }
  10. public static function testSet()
  11. {
  12. return self::getRedis()->set('test', '这是一个测试');
  13. }
  14. public static function testGet()
  15. {
  16. return self::getRedis()->get('test');
  17. }
  18. }

修改Test类的index方法用于测试

  1. <?php
  2. namespace App\Socket\Controller\WebSocket;
  3. use EasySwoole\Core\Socket\AbstractInterface\WebSocketController;
  4. use App\Socket\Logic\Room;
  5. class Test extends WebSocketController
  6. {
  7. public function index()
  8. {
  9. $this->response()->write(Room::testSet());
  10. $this->response()->write("\n");
  11. $this->response()->write(Room::testGet());
  12. }
  13. }

现在可以启动Server了,如果没有任何错误,请使用WEBSOCKET CLIENT
测试以下内容。

  • 如果发送{"controller": "Test","action": "index"}返回 1 这是一个测试 ,则说明Redis连接正常。

至此已经完成了Redis的基本使用,以下为业务部分

三、 聊天室设计

基本定义

  • fd : 连接id,Server发送消息的唯一标识,会回收,不会重复。
  • userId : 用户id,不多赘述。
  • roomId : 房间id,房间的唯一标识。

实际上聊天室就是对 fd userId roomId 的管理

设计思路

私聊

私聊实际上是指fd和uid的关系,即通过uid查询fd,发送消息。

使用Redis sorted set(有序集合)来管理 fduserId之间的关系。

keysocremember
onlineuserIdfd

全服务器广播

全服务器广播实际上是给全部fd连接发送消息,可以使用上面的online有序集合遍历发送,也可以直接遍历server->connections中的fd发送(推荐)

房间消息

房间消息其实是指发送信息到具体房间中的一个概念,房间只是fd的一种组织(管理)形式,在房间这个概念中,实际上并不需要uid这个概念,因为你在公会频道收不到队伍消息嘛。

我们只需要映射好room_id和fd的关系即可实现房间消息功能,这里我们选择Redis Hash(哈希)数据结构来维护此关系。

keyfieldvalue
roomIdfduserId

Hash允许你通过key只查询field列或者只查询value列,这样你就可以实现查询用户是否在房间(用于业务层面的检查)和房间内全部fd;随后通过迭代(遍历),来发送信息。

回收fd

由于用户断线时,我们只能获取到fd,并不能获取到roomId和userId,所以我们必须设计一套回收机制,保证Redis中的映射关系不错误;防止信息发送给错误的fd。

在上面我们其实已经建立了userId => fd 的映射关系,双向都能够找到找到对应彼此的值,唯独缺少了 roomId => fd的关系映射,在这里我们通过再建立一组关系映射,来保障fd => roomId的映射关系,由于fd是不重复的,roomId是重复的,故可以直接使用 有序集合 来管理。

keysocremember
rfMaproomIdfd

代码实现

注意:以下代码均是基本逻辑,业务使用需要根据自己业务场景丰富

Room基本逻辑

  1. <?php
  2. namespace App\Socket\Logic;
  3. use EasySwoole\Core\Component\Di;
  4. class Room
  5. {
  6. /**
  7. * 获取Redis连接实例
  8. * @return object Redis
  9. */
  10. protected static function getRedis()
  11. {
  12. return Di::getInstance()->get('REDIS')->handler();
  13. }
  14. /**
  15. * 进入房间
  16. * @param int $roomId 房间id
  17. * @param int $userId userId
  18. * @param int $fd 连接id
  19. * @return
  20. */
  21. public static function joinRoom(int $roomId, int $fd)
  22. {
  23. $userId = self::getUserId($fd);
  24. self::getRedis()->zAdd('rfMap', $roomId, $fd);
  25. self::getRedis()->hSet("room:{$roomId}", $fd, $userId);
  26. }
  27. /**
  28. * 登录
  29. * @param int $userId 用户id
  30. * @param int $fd 连接id
  31. * @return bool
  32. */
  33. public static function login(int $userId, int $fd)
  34. {
  35. self::getRedis()->zAdd('online', $userId, $fd);
  36. }
  37. /**
  38. * 获取用户id
  39. * @param int $fd
  40. * @return int userId
  41. */
  42. public static function getUserId(int $fd)
  43. {
  44. return self::getRedis()->zScore('online', $fd);
  45. }
  46. /**
  47. * 获取用户fd
  48. * @param int $userId
  49. * @return array 用户fd集
  50. */
  51. public static function getUserFd(int $userId)
  52. {
  53. return self::getRedis()->zRange('online', $userId, $userId, true);
  54. }
  55. /**
  56. * 获取RoomId
  57. * @param int $fd
  58. * @return int RoomId
  59. */
  60. public static function getRoomId(int $fd)
  61. {
  62. return self::getRedis()->zScore('rfMap', $fd);
  63. }
  64. /**
  65. * 获取room中全部fd
  66. * @param int $roomId roomId
  67. * @return array 房间中fd
  68. */
  69. public static function selectRoomFd(int $roomId)
  70. {
  71. return self::getRedis()->hKeys("room:{$roomId}");
  72. }
  73. /**
  74. * 退出room
  75. * @param int $roomId roomId
  76. * @param int $fd fd
  77. * @return
  78. */
  79. public static function exitRoom(int $roomId, int $fd)
  80. {
  81. self::getRedis()->hDel("room:{$roomId}", $fd);
  82. self::getRedis()->zRem('rfMap', $fd);
  83. }
  84. /**
  85. * 关闭连接
  86. * @param string $fd 链接id
  87. */
  88. public static function close(int $fd)
  89. {
  90. $roomId = self::getRoomId($fd);
  91. self::exitRoom($roomId, $fd);
  92. self::getRedis()->zRem('online', $fd);
  93. }
  94. }

Test测试用控制器

  1. <?php
  2. namespace App\Socket\Controller\WebSocket;
  3. use EasySwoole\Core\Socket\AbstractInterface\WebSocketController;
  4. use EasySwoole\Core\Swoole\ServerManager;
  5. use EasySwoole\Core\Swoole\Task\TaskManager;
  6. use App\Socket\Logic\Room;
  7. class Test extends WebSocketController
  8. {
  9. /**
  10. * 访问找不到的action
  11. * @param ?string $actionName 找不到的name名
  12. * @return string
  13. */
  14. public function actionNotFound(?string $actionName)
  15. {
  16. $this->response()->write("action call {$actionName} not found");
  17. }
  18. public function index()
  19. {
  20. }
  21. /**
  22. * 进入房间
  23. */
  24. public function intoRoom()
  25. {
  26. // TODO: 业务逻辑自行实现
  27. $param = $this->request()->getArg('data');
  28. $userId = $param['userId'];
  29. $roomId = $param['roomId'];
  30. $fd = $this->client()->getFd();
  31. Room::login($userId, $fd);
  32. Room::joinRoom($roomId, $fd);
  33. $this->response()->write("加入{$roomId}房间");
  34. }
  35. /**
  36. * 发送信息到房间
  37. */
  38. public function sendToRoom()
  39. {
  40. // TODO: 业务逻辑自行实现
  41. $param = $this->request()->getArg('data');
  42. $message = $param['message'];
  43. $roomId = $param['roomId'];
  44. //异步推送
  45. TaskManager::async(function ()use($roomId, $message){
  46. $list = Room::selectRoomFd($roomId);
  47. foreach ($list as $fd) {
  48. ServerManager::getInstance()->getServer()->push($fd, $message);
  49. }
  50. });
  51. }
  52. /**
  53. * 发送私聊
  54. */
  55. public function sendToUser()
  56. {
  57. // TODO: 业务逻辑自行实现
  58. $param = $this->request()->getArg('data');
  59. $message = $param['message'];
  60. $userId = $param['userId'];
  61. //异步推送
  62. TaskManager::async(function ()use($userId, $message){
  63. $fdList = Room::getUserFd($userId);
  64. foreach ($fdList as $fd) {
  65. ServerManager::getInstance()->getServer()->push($fd, $message);
  66. }
  67. });
  68. }
  69. }

注册连接关闭事件

  1. // 引入EventHelper
  2. use \EasySwoole\Core\Swoole\EventHelper;
  3. // 引入Di
  4. use \EasySwoole\Core\Component\Di;
  5. // 注意这里是指额外引入我们上文实现的解析器
  6. use \App\Socket\Parser\WebSocket;
  7. // 引入上文Redis连接
  8. use \App\Utility\Redis;
  9. // 引入上文Room文件
  10. use \App\Socket\Logic\Room;
  11. // ...省略
  12. public static function mainServerCreate(ServerManager $server,EventRegister $register): void
  13. {
  14. // 注册WebSocket解析器
  15. EventHelper::registerDefaultOnMessage($register, WebSocket::class);
  16. //注册onClose事件
  17. $register->add($register::onClose, function (\swoole_server $server, $fd, $reactorId) {
  18. //清除Redis fd的全部关联
  19. Room::close($fd);
  20. });
  21. // 注册Redis
  22. Di::getInstance()->set('REDIS', new Redis(Config::getInstance()->getConf('REDIS')));
  23. }

现在可以启动Server了,如果没有任何错误,请使用WEBSOCKET CLIENT
测试以下内容。

  • 用多个浏览器标签打开WEBSOCKET CLIENT页面
  • 第一个标签开启连接时发送{“controller”: “Test”,”action”: “intoRoom”,”data”:{“userId”:”1”,”roomId”:”1000”}}
  • 第二个标签开启连接时发送{“controller”: “Test”,”action”: “intoRoom”,”data”:{“userId”:”2”,”roomId”:”1000”}}
  • 发送{“controller”: “Test”,”action”: “sendToRoom”,”data”:{“roomId”:”1000”,”message”:”发送房间消息”}},此时多个标签连接都会收到该消息
  • 第二个标签发送 {“controller”: “Test”,”action”: “sendToUser”,”data”:{“userId”:”1”,”message”:”发送私聊消息”}},此时第一个标签连接会收到消息

至此已经完成了Room的基本逻辑,下面将介绍如何实现js消息处理

js消息处理

我们可以利用JSON数据来实现js消息解析

示例

  1. // 客户端发送JSON消息格式
  2. {
  3. "controller": "Test", // 请求控制器
  4. "action": "intoRoom", // 请求方法
  5. "data":{ // 请求参数
  6. "a":"",
  7. "b":""
  8. }
  9. }
  10. // 服务端发送JSON消息格式
  11. {
  12. "code":"200", // 状态码,用于标记状态
  13. "msg":"string" // 信息,用于标记本次状态的描述
  14. "result":{ // 结构,用于传输实际数据,通常是个多维结构
  15. "type":"chat||gift||notice" // 类型,标记本次消息的类型,如聊天、礼物
  16. "data":"message" // 数据,用于传输实际内容,如具体的信息
  17. }
  18. }

当客户端收到消息时,使用JSON.parse就可以解析具体的事件。