如何使用WebSocket开发一套系统(一. 在onMessage中分发客户端请求)

832 阅读4分钟

我们部门有一个非常不稳定的项目(广告聚合),像一颗炸弹一样,随时会炸的那种。这个项目的交互其实很简单,最讨厌的是需要调用大量的第三方接口,大家都知道数据不在自己这边的痛苦。

某次会议上,这个项目的重构任务交给了我们项目组,我们本来想使用Golang来写这个项目的,但是考虑到这个项目原本用的是Laravel,最好还是使用PHP的框架,最后就选择了基于Swoole扩展的Hyperf框架来重构这个项目。

其实网上关于服务端如何实现一套websocket服务还是蛮少的,我也是自己瞎琢磨
本文会介绍该项目的创建、配置、基类实现
  1. 首先说一下我的开发环境
  • PHP 7.4
  • Swoole 4.5.10 查看命令是:php --ri swoole
  • Hyperf 2.0
  1. 安装并启动Hyperf项目骨架 文档地址
  • composer create-project hyperf/hyperf-skeleton
  • cd create-project && php bin/hyperf.php start
  1. 配置websocket服务
  • composer require hyperf/websocket-server
  • config/server.php 中新增websocket相关配置
'servers' => [  
    [  
        'name' => 'http',  
        'type' => Server::SERVER_HTTP,  
        'host' => '0.0.0.0',  
        'port' => 9501,  
        'sock_type' => SWOOLE_SOCK_TCP,  
        'callbacks' => [  
            Event::ON_REQUEST => [Hyperf\HttpServer\Server::class, 'onRequest'],  
        ],  
    ],  
    [  
        'name' => 'ws',  
        'type' => Server::SERVER_WEBSOCKET,  
        'host' => '0.0.0.0',  
        'port' => 9502,  
        'sock_type' => SWOOLE_SOCK_TCP,  
    'callbacks' => [  
        Event::ON_HAND_SHAKE => [Hyperf\WebSocketServer\Server::class, 'onHandShake'],  
        Event::ON_MESSAGE => [Hyperf\WebSocketServer\Server::class, 'onMessage'],  
        Event::ON_CLOSE => [Hyperf\WebSocketServer\Server::class, 'onClose'],  
    ],  
    'settings' => [  
            Constant::OPTION_OPEN_WEBSOCKET_PROTOCOL => true, // websocket 协议  
            Constant::OPTION_HEARTBEAT_CHECK_INTERVAL => 150,  
            Constant::OPTION_HEARTBEAT_IDLE_TIME => 300,  
        ],  
    ],  
],
  • 定义websocket基类,文档上给了基础示例,但是并不满足我的需求,于是我对该示例进行了二次封装app/Controller/WebsocketController.php,基类中涉及了一些工具类和方法,我会在下文中补充
<?php  

 namespace App\Controller;  

 use _PHPStan_b8e553790\Nette\Neon\Exception;  
 use App\Constants\ClassCollection;  
 use App\Constants\ErrorCode;  
 use App\Constants\Websocket;  
 use App\Middleware\OperateLog;  
 use App\Middleware\TokenAuthenticator;  
 use App\Traits\SocketResponse;  
 use Firebase\JWT\ExpiredException;  
 use Hyperf\Contract\OnCloseInterface;  
 use Hyperf\Contract\OnMessageInterface;  
 use Hyperf\Contract\OnOpenInterface;  
 use Hyperf\Engine\WebSocket\Opcode;  
 use Hyperf\HttpServer\Request;  
 use Hyperf\HttpServer\Response;  
 use Hyperf\Logger\LoggerFactory;  
 use Hyperf\Utils\Codec\Json;  
 use Hyperf\WebSocketServer\Context;  
 use Hyperf\WebSocketServer\Sender;  
 use Psr\Log\LoggerInterface;  

 class WebSocketController implements OnOpenInterface, OnMessageInterface, OnCloseInterface  
 {  
 use SocketResponse;  

 protected $sender;  

 protected $request;  
 protected $authenticator;  
 protected $operate;  

 protected $opened = false;  

 protected LoggerInterface $logger;  
 
 public function __construct(Sender $sender, Request $request, Response $response, LoggerFactory $loggerFactory, TokenAuthenticator $authenticator, OperateLog $operateLog)  
 {  
     $this->sender = $sender;  
     $this->request = $request;  
     $this->authenticator = $authenticator;  
     $this->operate = $operateLog;  
     $this->logger = $loggerFactory->get('log', 'websocket');  
 }  
 public function onOpen($server, $request): void  
 {  
     try {  
         $token = $this->authenticator->authenticate($request);  
     if (empty($token)) {  
         $this->sender->disconnect($request->fd);  
         return;  
     }  
         $this->onOpenBase($server, $request);  
     }catch (\Exception $e){  
         $this->logger->error(sprintf("\r\n [message] %s \r\n [line] %s \r\n [file] %s \r\n [trace] %s", $e->getMessage(), $e->getLine(), $e->getFile(), $e->getTraceAsString()));  
         $this->send($server, $request->fd, $this->failJson($e->getCode()));  
         $this->sender->disconnect($request->fd);  
         return;  
     }  
 }  

 public function onMessage($server, $frame): void  
 {  
     if ($this->opened) {  
         if ($frame->data === 'ping') {  
             $this->send($server, $frame->fd, 'pong');  
         }else{  
             $this->onMessageBase($server, $frame);  
         }  
     }  
 }  

 public function onClose($server, int $fd, int $reactorId): void  
 {  
     $this->onCloseBase($server, $fd, $reactorId);  
 }  

 /**  
 * 该方法用于向全体人员广播  
 * @param $message  
 * @param $senderId  
 * @return void  
 */  
 public function broadcast($message, $senderId = null): void  
 {  
     $connections = Context::get(Websocket::WEBSOCKET_CONNECTIONS, []);  
     foreach ($connections as $fd => $connection) {  
         if ($senderId === null || $senderId !== $fd) {  
             $this->sender->push($fd, $message);  
         }  
     }  
 }  

 protected function onOpenBase($server, $request): void  
 {  
     $fd = $request->fd;  
     $this->registerConnection($fd);  
     $this->opened = true;  
     var_dump('opened');  
 }  

 protected function onCloseBase($server, int $fd, int $reactorId): void  
 {  
     $this->unregisterConnection($fd);  
 }  

 protected function onMessageBase($server, $frame): void  
 {  
     $fd = $frame->fd;  
     $message = $frame->data;  

     $payload = json_decode($message, true);  
     if (json_last_error() !== JSON_ERROR_NONE) {  
         $this->send($server, $fd, $this->outputWithOrigin([], $this->fail(ErrorCode::PARAMS_INVALID)));  
         return;  
     }  

     $class = ClassCollection::$collection[$payload[Websocket::CONTROLLER]] ?? null;  
     if (empty($class)) {  
         $this->send($server, $fd, $this->outputWithOrigin($payload, $this->fail(ErrorCode::PARAMS_INVALID)));  
         return;  
     }  

     $method = $payload[Websocket::ACTION] ?? null;  
     if (empty($method)) {  
         $this->send($server, $fd, $this->outputWithOrigin($payload, $this->fail(ErrorCode::PARAMS_INVALID)));  
         return;  
     }  

     $params = $payload[Websocket::PARAMS] ?? null;  
     if (method_exists($class, $method)) {  
         $this->operate->autoCatch($class, $method, Json::encode($params));  
         $respond = (new $class)->{$method}($server, $fd, $params) ?? [];  
         $this->send($server, $fd, $this->outputWithOrigin($payload, $respond));  
         }else{  
         $this->send($server, $fd, $this->outputWithOrigin($payload, $this->fail(ErrorCode::FUNCTION_ERROR)));  
     }  
 }  

 protected function send($server, $fd, $data): void  
 {  
     $this->sender->push($fd, json_encode($data, JSON_UNESCAPED_UNICODE));  
 }  
 
 
 protected function registerConnection($fd): void  
 {  
     $connections = Context::get(Websocket::WEBSOCKET_CONNECTIONS, []);  
     $connections[$fd] = true;  
     Context::set(Websocket::WEBSOCKET_CONNECTIONS, $connections);  
 }  
 

 protected function unregisterConnection($fd): void  
 {  
     $connections = Context::get(Websocket::WEBSOCKET_CONNECTIONS, []);  
     unset($connections[$fd]);  
     Context::set(Websocket::WEBSOCKET_CONNECTIONS, $connections);  
 }  
}
我觉得有必要介绍一下这个websokcet控制器的基类
  1. 文档中给出了三个方法,分别是:
  • 建立连接:public function onOpen($server, Request $request): void;
  • 消息交互:public function onMessage($server, Frame $frame): void;
  • 关闭连接:public function onClose($server, int $fd, int $reactorId): void;
  1. 此时需要思考一个问题,那就是前端与后端交互时,需要区分不同的业务,我们又不可能每个控制器都开启一个连接,那么我们该怎么进行业务的分发呢?
  • 一开始我考虑了抽象类,但是用起来并不是特别灵活,还不如代码全堆一起省事
  • 后来我就选择了以上代码的方案,新建一个全局的文件,文件中是类简写与带命名空间类的配置,前端与后端建立连接后,传递约定好的类名与类方法,通过反射或者可变函数的方式对类方法调用
  • 调用方案1:(new $class)->{$method}
  • 调用方案2:call_user_func_array([new $class(), $method], [$server, $fd, $params])
  • 如果使用第2种方案调用类方法,需要将方法的返回值存入上下文中(注意,在使用上下文时,请选择同一个管道存取)
  • 类映射文件相关代码段:
// 定义常量 app/Constants/Websocket.php
class Websocket extends AbstractConstants  
{  
    public const WEBSOCKET_CONNECTIONS = 'WEBSOCKET_CONNECTIONS';  

    public const SecWebsocketProtocol = 'sec-websocket-protocol';  

    public const MANAGER_UID = 'MANAGER_UID';  

    public const PARAMS = 'params';  

    public const CONTROLLER = 'class';  

    public const ACTION = 'function';  
  
}


// 定义类映射 app/Constants/ClassCollection.php
public static array $collection = [  
    'test' => 'App\Controller\TestController'  
];

// 基类中通过解析前端的参数找到类 app/Controller/WebsocketController.php
$class = ClassCollection::$collection[$payload[Websocket::CONTROLLER]] ?? null;
  1. 基类app/Controller/WebsocketController.php中存在一个属性protected $opened = false;,我为什么要定义它呢?因为我发现onMessage中的代码并不会等待onOpen,而我需要在onOpen中验证令牌,通过后再进入onMessage,所以定义了这样一个属性,在onOpen中将其改为true
  2. protected $sender; 这是一个发送器,通过它可以向客户端推送消息。
  3. protected $operate; 这是一个全局的日志捕获,后面再细说。
  4. use SocketResponse; 这是我定义的格式化返回工具,是一个trait,其实还有一个基于HTTP的友好返回,后面再说。
trait SocketResponse  
{  
    public function successJson(array $data = [], string $msg = ''): string  
    {  
        return Json::encode($this->success($data, $msg));  
    }  

    public function failJson(int $code, string $msg = '', array $data = []): string  
    {  
        return Json::encode($this->fail($code, $msg, $data));  
    }  

    public function success(array $data = [], string $msg = ''): array  
    {  
        return [  
        'err_no' => ErrorCode::OK,  
        'err_msg' => empty($msg) ? ErrorCode::getMessage(ErrorCode::OK) : $msg,  
        'result' => $data,  
        ];  
    }  

    public function fail(int $code, string $msg = '', array $data = []): array  
    {  
        return [  
        'err_no' => $code,  
        'err_msg' => empty($msg) ? ErrorCode::getMessage($code) : $msg,  
        'result' => $data,  
        ];  
    }  

    public function outputWithOrigin(array $origin, array $returnData): array  
    {  
        if (empty($returnData)) {  
        $returnData = $this->success();  
        }  
        $returnData['origin'] = $origin;  
        return $returnData;  
    }  
}
  1. protected $authenticator; 这是我定义的令牌中间件,下一篇细说。
  2. protected LoggerInterface $logger; 这是异常日志,可以根据文档了解相关内容,通过$this->logger = $loggerFactory->get('log', 'websocket');第二个参数可以给日志分组
// 这是日志的配置 config/autoload/logger.php

'websocket' => [  
    'handler' => [  
        'class' => Monolog\Handler\RotatingFileHandler::class,  
        'constructor' => [  
            'filename' => BASE_PATH . '/storage/logs/websocket.log',  
            'level' => Monolog\Logger::DEBUG,  
        ],  
    ],  
    'formatter' => [  
        'class' => Monolog\Formatter\LineFormatter::class,  
        'constructor' => [  
            'format' => null,  
            'dateFormat' => 'Y-m-d H:i:s',  
            'allowInlineLineBreaks' => true,  
            // 'batchMode' => Monolog\Formatter\JsonFormatter::BATCH_MODE_JSON,  
        ],  
    ],  
],
  1. 基类中还有一些看起来稀奇古怪的代码,我简单说一下我的理解
  • $fd 是什么?我的理解:它是本次与客户端建立连接所产生的唯一标识,可以通过$request->fd获取到,也可以通过$frame->fd获取到,连接关闭后onClose它会被复用
  • $frame是什么?我的理解:它是ws协议中通信双方数据传输的基本单元,它可以传递前后端建立连接的控制帧$frame->opcode,也可以在握手成功后进行数据的传输$frame->data
  • $this->outputWithOrigin是什么?它是我自定义的友好返回方法,我会将客户端传来的参数一起与后端返回值合并后一起返回给前端
  1. 客户端如何与服务端建立websocket连接?
  • 方案1:使用phpstorm新建一个http文件(linux或者mac),代码如下:
###  
WEBSOCKET ws://0.0.0.0:9502/ws/  
Sec-WebSocket-Protocol: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2ODMxNjk5MjMsIm5iZiI6MTY4MzE2OTkyMywiZXhwIjoxNjgzMjU2MzIzLCJkYXRhIjp7InVpZCI6MTExfX0.B-nvMMMrUusv8rhYGaliVksDVlpMGCcAitQaoyOkpJc  

{  
  "class": "test",  
  "function": "list",  
      "params": {  
      "foo": "bar"  
  }  
}
  • 方案2:使用JS新建一个websocket管理器(这种代码网上到处都是,自己搜吧):
// 伪代码片段
const ws = new WebSocket('ws://0.0.0.0:9502/websocket/test')
ws.onopen = function(event) {  
      console.log('WebSocket connection opened.');  
      ws.send('1111');  
  };
  ws.onmessage = function(event) {  
      console.log('Received message:', event.data);  
  };
  ...
  • 方案3:使用一些开发工具,如apipost或者百度搜在线websocket测试
先写到这里吧,下一篇我来写一下与客户端建立连接时,如何进行令牌校验