我们部门有一个非常不稳定的项目(广告聚合),像一颗炸弹一样,随时会炸的那种。这个项目的交互其实很简单,最讨厌的是需要调用大量的第三方接口,大家都知道数据不在自己这边的痛苦。
某次会议上,这个项目的重构任务交给了我们项目组,我们本来想使用
Golang来写这个项目的,但是考虑到这个项目原本用的是Laravel,最好还是使用PHP的框架,最后就选择了基于Swoole扩展的Hyperf框架来重构这个项目。
其实网上关于服务端如何实现一套websocket服务还是蛮少的,我也是自己瞎琢磨
本文会介绍该项目的创建、配置、基类实现
- 首先说一下我的开发环境
- PHP 7.4
- Swoole 4.5.10 查看命令是:
php --ri swoole - Hyperf 2.0
- 安装并启动
Hyperf项目骨架 文档地址
composer create-project hyperf/hyperf-skeletoncd create-project && php bin/hyperf.php start
- 配置
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控制器的基类
- 文档中给出了三个方法,分别是:
- 建立连接:
public function onOpen($server, Request $request): void; - 消息交互:
public function onMessage($server, Frame $frame): void; - 关闭连接:
public function onClose($server, int $fd, int $reactorId): void;
- 此时需要思考一个问题,那就是前端与后端交互时,需要区分不同的业务,我们又不可能每个控制器都开启一个连接,那么我们该怎么进行业务的分发呢?
- 一开始我考虑了抽象类,但是用起来并不是特别灵活,还不如代码全堆一起省事
- 后来我就选择了以上代码的方案,新建一个全局的文件,文件中是类简写与带命名空间类的配置,前端与后端建立连接后,传递约定好的类名与类方法,通过反射或者可变函数的方式对类方法调用
- 调用方案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;
- 基类
app/Controller/WebsocketController.php中存在一个属性protected $opened = false;,我为什么要定义它呢?因为我发现onMessage中的代码并不会等待onOpen,而我需要在onOpen中验证令牌,通过后再进入onMessage,所以定义了这样一个属性,在onOpen中将其改为true protected $sender;这是一个发送器,通过它可以向客户端推送消息。protected $operate;这是一个全局的日志捕获,后面再细说。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;
}
}
protected $authenticator;这是我定义的令牌中间件,下一篇细说。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,
],
],
],
- 基类中还有一些看起来稀奇古怪的代码,我简单说一下我的理解
$fd是什么?我的理解:它是本次与客户端建立连接所产生的唯一标识,可以通过$request->fd获取到,也可以通过$frame->fd获取到,连接关闭后onClose它会被复用$frame是什么?我的理解:它是ws协议中通信双方数据传输的基本单元,它可以传递前后端建立连接的控制帧$frame->opcode,也可以在握手成功后进行数据的传输$frame->data$this->outputWithOrigin是什么?它是我自定义的友好返回方法,我会将客户端传来的参数一起与后端返回值合并后一起返回给前端
- 客户端如何与服务端建立
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测试