diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 37309c16..20c73d53 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -80,9 +80,11 @@ module.exports = { { title: 'HTTP 组件', collapsable: true, - sidebarDepth: 2, + sidebarDepth: 1, children: [ 'http/zmrequest', + 'http/websocket-access', + 'http/websocket-client', ], }, { @@ -99,7 +101,7 @@ module.exports = { { title: '存储组件', collapsable: true, - sidebarDepth: 2, + sidebarDepth: 1, children: [ 'store/file-system', 'store/cache', diff --git a/docs/components/common/global-defines.md b/docs/components/common/global-defines.md index 64e6a78f..f8c006d2 100644 --- a/docs/components/common/global-defines.md +++ b/docs/components/common/global-defines.md @@ -382,3 +382,18 @@ public function testRoute(HttpRequestEvent $event) 快速生成一个符合 PSR-7 的 HTTP Response 对象。 有关参数,等同于 HttpFactory 对象,详见 HttpFactory 文档(TODO)。 + +### ws_socket() + +获取驱动的 WebSocket 操作对象。 + +定义:`function ws_socket(int $flag = 1): WSServerResponse` + +传入一个 flag 值(值为你在 `global.php` 中为 server 设置的 flag 值),返回对应端口的 WebSocket 操作对象。 + +操作对象可以主动发送消息到指定客户端、可以获取指定端口的配置信息等。 + +```php +$socket = ws_socket(); +$socket->send('hello world', $event->getFd()); // 客户端的连接 fd 编号可以通过 WebSocketOpenEvent 等事件获取 +``` diff --git a/docs/components/common/hot-update.md b/docs/components/common/hot-update.md index 0b59bb8a..90dbfe30 100644 --- a/docs/components/common/hot-update.md +++ b/docs/components/common/hot-update.md @@ -2,7 +2,7 @@ ::: danger -目前此功能还在开发中,暂不可用,预计 3.0 正式版发布后可使用。 +目前此功能还在开发中,暂不可用。 ::: diff --git a/docs/components/container/dependencies.md b/docs/components/container/dependencies.md index fba6ee74..f93695ab 100644 --- a/docs/components/container/dependencies.md +++ b/docs/components/container/dependencies.md @@ -11,47 +11,47 @@ GitHub 链接:https://github.com/zhamao-robot/zhamao-framework/blob/main/src/Z 在任何事件(或任何支持依赖注入的地方)中,你都可以使用以下依赖项: -- `Psr\Log\LoggerInterface`:日志记录器 -- `Psr\Container\ContainerInterface`:容器 +- `Psr\Log\LoggerInterface`:日志记录器(可使用类的别名 `LoggerInterface`) +- `Psr\Container\ContainerInterface`:容器(可使用别名 `ContainerInterface`) - `DI\Container`:容器,区别在于可以使用 `set` 方法来动态设置依赖项,与 `container` 函数返回的实例相同 -- `ZM\Config\ZMConfig`:配置,与 `config` 函数返回的实例相同 +- `ZM\Config\ZMConfig`:配置,与 `config` 函数返回的实例相同(可使用别名 `ZMConfig`) - ... ## OneBot 事件 在 OneBot 事件(`@BotEvent`)中,你可以使用以下依赖项: -- `OneBot\V12\Object\OneBotEvent`:当前事件的实例 -- `ZM\Context\BotContext`:当前事件的上下文,部分事件可能不可用(要求传入的事件存在 `platform` 字段) +- `OneBot\V12\Object\OneBotEvent`:当前事件的实例(可使用别名 `OneBotEvent`) +- `ZM\Context\BotContext`:当前事件的上下文,可使用别名 `BotContext`,部分事件可能不可用(要求传入的事件存在 `platform` 字段) ## OneBot 动作响应 在 OneBot 动作响应(`@BotActionResponse`)中,你可以使用以下依赖项: -- `OneBot\V12\Object\ActionResponse`:当前动作响应的实例 +- `OneBot\V12\Object\ActionResponse`:当前动作响应的实例(可使用别名 `ActionResponse`) ## HTTP 请求事件(路由事件) 在 HTTP 请求事件(`@Route`)中,你可以使用以下依赖项: -- `OneBot\Driver\Event\Http\HttpRequestEvent`:当前事件的实例 -- `Psr\Http\Message\ServerRequestInterface`:当前请求的实例 +- `OneBot\Driver\Event\Http\HttpRequestEvent`:当前事件的实例(可使用别名 `HttpRequestEvent`) +- `Psr\Http\Message\ServerRequestInterface`:当前请求的实例(可使用别名 `ServerRequestInterface`) ## WebSocket 连接事件 在 WebSocket 连接事件(`@BindEvent(WebSocketOpenEvent::class)`)中,你可以使用以下依赖项: -- `OneBot\Driver\Event\WebSocket\WebSocketOpenEvent`:当前事件的实例 +- `OneBot\Driver\Event\WebSocket\WebSocketOpenEvent`:当前事件的实例(可使用别名 `WebSocketOpenEvent`) ## WebSocket 消息事件 在 WebSocket 消息事件(`@BindEvent(WebSocketMessageEvent::class)`)中,你可以使用以下依赖项: -- `OneBot\Driver\Event\WebSocket\WebSocketMessageEvent`:当前事件的实例 -- `Choir\WebSocket\FrameInterface`:当前消息(帧)的实例 +- `OneBot\Driver\Event\WebSocket\WebSocketMessageEvent`:当前事件的实例(可使用别名 `WebSocketMessageEvent`) +- `Choir\WebSocket\FrameInterface`:当前消息(帧)的实例(可使用别名 `FrameInterface`) ## WebSocket 关闭事件 在 WebSocket 关闭事件(`@BindEvent(WebSocketCloseEvent::class)`)中,你可以使用以下依赖项: -- `OneBot\Driver\Event\WebSocket\WebSocketCloseEvent`:当前事件的实例 +- `OneBot\Driver\Event\WebSocket\WebSocketCloseEvent`:当前事件的实例(可使用别名 `WebSocketCloseEvent`) diff --git a/docs/components/http/websocket-access.md b/docs/components/http/websocket-access.md new file mode 100644 index 00000000..b6cbd1ae --- /dev/null +++ b/docs/components/http/websocket-access.md @@ -0,0 +1,163 @@ +# 接入其他 WebSocket 客户端 + +众所周知,炸毛框架提供了 HTTP、WebSocket 服务器功能,但默认的框架只接收 HTTP 请求和 OneBot 12 标准 的 WebSocket 客户端。 + +想要接入其他 WebSocket 客户端,例如通过浏览器前端、游戏客户端、手机移动端等方式通过 WebSocket 接入框架从而和机器人实现联动,也是很容易的。 + +## 接入 + +框架默认只会接入 `Sec-WebSocket-Protocol` 为 `12.xxx` 类型的 WS 客户端,同时默认也会断掉所有未设置连接状态信息的 WebSocket 客户端握手请求。 + +所以接入框架只需要写一个 `WebSocketOpenEvent` 的监听事件,在函数内通过 `ConnectionUtil::setConnection()` 方法标记该连接有效,框架就不会断掉连接,并保存该连接的信息。 + +```php +#[BindEvent(WebSocketOpenEvent::class)] +public function onCustomOpen(WebSocketOpenEvent $event) +{ + // 例如通过判断 Sec-WebSocket-Protocol 头来识别一个第三方客户端类型 + if ($event->getRequest()->getHeaderLine('Sec-WebSocket-Protocol') === 'my-custom-ws-client') { + \ZM\Utils\ConnectionUtil::setConnection($event->getFd(), ['my-custom-ws-client' => '123']); + // 如果你的客户端要求握手回包中必须返回一个合法的 Sec-WebSocket-Protocol 头,则可以使用下面这行代码来添加额外的 Response Header + $event->withResponse(zm_http_response(status_code: 101, headers: ['Sec-WebSocket-Protocol' => $event->getRequest()->getHeaderLine('Sec-WebSocket-Protocol')])); + } +} +``` + +上方的例子的 `setConnection` 第二个参数是一个数组,你可以设置一些自己的键名和键值传入,用于保存该连接对应的信息,此后可以通过 `ConnectionUtil::getConnection($fd)` 方式获取。 + + + +## 接收和发送 + +接入后,你可以通过 `WebSocketMessageEvent` 来监听客户端发来的消息帧。当然,这里你需要用到一个内置的中间件,用于限定事件只获取指定类型的事件: + +```php +#[BindEvent(WebSocketMessageEvent::class)] +#[Middleware(WebSocketFilter::class, ['my-custom-ws-client' => true])] +public function onCustomMessage(WebSocketMessageEvent $event, FrameInterface $frame) +{ + logger()->info('收到了自定义 ws 客户端发来的消息事件:' . $frame->getData()); +} +``` + +这里要注意,这个中间件 `WebSocketFilter` 的后面数组内为限定查询的参数。这个限定列表支持多个参数,键名为你在 `WebSocketOpenEvent` 中通过 `setConnection()` 方法添加的连接信息。 + +这里的例子和上方形成了联动,比如这里使用 `['my-custom-ws-client' => true]` 的含义就是: +只要收到的 WebSocket 消息事件所属连接信息存在 `my-custom-ws-client` 字段,即为真,否则为假(不响应此事件)。 + +> 如果你不使用 `WebSocketFilter` 中间件做过滤,那么该 BindEvent 绑定的函数将响应所有类型的客户端连接的所有消息帧。 + +收到消息后,我们也可以使用 `$event->send()` 方法发送消息帧到客户端: + +```php +$event->send('hello world'); // 传入字符串时,将自动转换为发送 UTF-8 文本的消息帧 +$event->send(\Choir\WebSocket\FrameFactory::createTextFrame('ohuo')); // 你也可以直接传入一个符合 FrameInterface 接口的消息帧 +``` + +## 连接断开事件 + +如果客户端主动断开与服务端的连接,服务端会触发 WebSocketCloseEvent 事件,同时伴随一个关闭帧。 + +```php +#[BindEvent(WebSocketCloseEvent::class)] +public function onClose(WebSocketCloseEvent $event) +{ + logger()->info('fd ' . $event->getFd() . ' 关闭了连接'); +} +``` + +## 消息帧 + +WebSocket 通信少不了一个概念:消息帧。框架采用了 Choir 的 HTTP 组件,内包含一个 FrameInterface,消息帧的接口类型。 +框架支持发送所有实现了 FrameInterface 的消息帧,例如发送 PING 包、PONG 包、二进制数据包、UTF-8 文本包。 + +框架默认使用的消息帧对象是:`\Choir\WebSocket\Frame`,你可以使用 FrameFactory 工厂类创建一个 Frame: + +```php +$frame = \Choir\WebSocket\FrameFactory::createTextFrame('hello'); // 创建文本帧 +$frame = \Choir\WebSocket\FrameFactory::createBinaryFrame(file_get_contents('a.jpg')); // 创建二进制数据帧 +$frame = \Choir\WebSocket\FrameFactory::createPingFrame(); // 创建 ping 帧 +$frame = \Choir\WebSocket\FrameFactory::createPongFrame(); // 创建 pong 帧 +$frame = \Choir\WebSocket\FrameFactory::createCloseFrame(1000); // 创建关闭请求帧,用于主动正常断开 WebSocket 连接,参数为 WebSocket 的状态码,可参考 RFC +``` + +## 事件外主动发送消息帧 + +在使用 WebSocket 接入客户端时,往往会有不在事件内需要向已连接的客户端发送 WebSocket 消息的情况,框架的 Driver 层抽象了全局方法 `ws_socket()` 来提供一个可在事件外发送 WS 消息的功能。 + +举例一,我们想在收到一个 HTTP 请求时,发送一条消息到所有已连接的同一类型 WebSocket 客户端。我们在接入客户端的时候,对客户端的 fd 做了缓存: + +```php +#[BindEvent(WebSocketOpenEvent::class)] +public function onWSOpen(WebSocketOpenEvent $event) +{ + if ($event->getRequest()->getHeaderLine('Sec-WebSocket-Protocol') === 'special-app') { + logger()->info('special-app 已接入,正在鉴权'); + // 为了更贴近真实开发案例,这里假装通过 GET 请求传入的 token 参数进行一个鉴权,实际业务的鉴权逻辑请自行编写! + if (($event->getRequest()->getQueryParams()['token'] ?? null) !== 'emhhbWFvLWZyYW1ld29yaw==') { + logger()->warning('客户端 [' . $event->getFd() . '] 鉴权失败'); + return; + } + logger()->info('鉴权成功!'); + \ZM\Utils\ConnectionUtil::setConnection($event->getFd(), ['special-app' => time()]); + } +} + +#[Route('/ws-test/{name}')] +public function onRouteTest(array $params) +{ + // 这行的 ws_socket 传入的参数 1 为多 server 对应的 flag 参数,留空则默认使用 1。(框架的默认配置第一个 websocket 的 flag 也是 1) + // sendMultiple 的作用在于同时到多个客户端,第二个参数是一个回调函数,可以用它来过滤选择自己相应连接 + ws_socket(1)->sendMultiple('收到了网页请求,它说它是 ' . $params['name'], fn ($fd) => isset(ConnectionUtil::getConnection($fd)['special-app'])); + // 创建一个 HTTP Response 返回用户网页 + return zm_http_response(body: 'hello, ' . $params['name']); +} +``` + +除此之外,你也可以使用 `ws_socket()->send()` 方法,只给一个客户端发送消息帧,通过指定第二个参数 fd 来实现: + +```php +ws_socket(1)->send('hello', $fd); +``` + +另外,对于开发上的考虑,对于在事件外挑选一个客户端发送消息时,涉及 fd 存储的问题,你可以配合 `kv()`、`db()` 等组件对 fd 进行缓存。 + +## 多服务器端口 + +框架 3.0 支持了同时监听多个端口,例如框架默认的配置就同时监听了 20001、20002 分别做 WebSocket 服务端和 HTTP 服务端。你也可以继续让框架添加多个端口进行监听。 + +```php +// global.php +/* 要启动的服务器监听端口及协议 */ +$config['servers'] = [ + [ + 'host' => '0.0.0.0', + 'port' => 20001, + 'type' => 'websocket', + ], + [ + 'host' => '0.0.0.0', + 'port' => 20002, + 'type' => 'http', + 'flag' => 20002, + ], + [ + 'host' => '0.0.0.0', + 'port' => 20003, + 'type' => 'websocket', + 'flag' => 4, + ], +]; +``` + +每个服务端支持四个参数,`host`、`port`、`type`、`flag`。其中 flag 参数可忽略,忽略则默认值为 1。 + +flag 的用处就是在事件内区分来源服务监听的端口,例如上方的配置,我监听了两个 websocket 的端口,第一个 flag 是 1,第二个 flag 是 4。 + +我们在 WebSocket 的三种事件(WebSocketOpenEvent、WebSocketMessageEvent、WebSocketCloseEvent)中,均可使用 WebSocketFilter 中间件进行过滤限定 flag。 +下方为一个 Open 事件使用 Filter 过滤端口 20003 来源的客户端连接: + +```php +#[BindEvent(WebSocketOpenEvent::class)] +#[Middleware(WebSocketFilter::class, ['flag' => 4])] +``` diff --git a/docs/components/http/websocket-client.md b/docs/components/http/websocket-client.md new file mode 100644 index 00000000..18cae363 --- /dev/null +++ b/docs/components/http/websocket-client.md @@ -0,0 +1,3 @@ +# 框架内置 WebSocket 客户端 + +> 框架的内置 WebSocket 客户端还没有抽象完接口,文档暂时先鸽了。这部分会尽快写完的! diff --git a/docs/components/store/redis.md b/docs/components/store/redis.md index 08f241a6..4b6be5c1 100644 --- a/docs/components/store/redis.md +++ b/docs/components/store/redis.md @@ -41,3 +41,9 @@ $redis->set('key', 'value'); ## 连接池 框架会自动为每个 Redis 连接创建一个连接池,你可以通过 `pool_size` 配置项来设置连接池的大小。 + +## 通过 KV 库方式使用 Redis + +默认情况下,你使用 `redis()` 方法获取的是 redis 扩展的原生操作对象,使用方式和传统的 redis 扩展完全相同。 + +框架实现了一个 KVInterface 接口,继承于 PSR SimpleCache 标准,你可以使用 PSR-16 的方式来使用其中一个 redis 库。详见 [KV 库](/components/store/cache)。 diff --git a/docs/event/bot.md b/docs/event/bot.md index d82489fd..5142401c 100644 --- a/docs/event/bot.md +++ b/docs/event/bot.md @@ -6,11 +6,11 @@ > 在使用注解绑定事件时,如果不存在 **必需** 参数,可一个参数都不写,效果就是此事件在任何情况下都会调用此方法,例如 `#[BotEvent()]` 会在收到任意机器人事件时调用。 -> + ## BotAction -啊? +BotAction 注解将在 OneBot 12 标准的动作发送前会触发,体现在代码层面就是在使用机器人上下文 `ctx()->sendAction()` 方法时会触发。 | 参数名称 | 允许值 | 用途 | 默认 | |---------------|--------|---------------|-------| @@ -18,14 +18,60 @@ | need_response | string | 动作是否需要响应 | false | | level | int | 事件优先级(越大越先执行) | 20 | +举例一,你可以通过设置一个 BotAction 注解事件,来收集和统计所有机器人发出的消息、执行的动作: + +```php +#[BotAction()] +public function onBotAction(\OneBot\V12\Object\Action $action) +{ + logger()->info('机器人执行了动作:' . $action->action); +} +``` + +举例二,你可以通过设置 BotAction 注解的限定参数来限定捕获触发的动作事件: + +```php +// 限定只获取 send_message 动作的触发 +#[BotAction(action: 'send_message')] +public function onSendMessage(\OneBot\V12\Object\Action $action) +{ + logger()->info('机器人发送了消息:' . \ZM\Utils\MessageUtil::getAltMessage($action->params['message'])); +} +``` + +举例三,你可以通过 `need_response` 参数来限定 BotAction 触发的时机。默认情况下,BotAction 在调用 `ctx()->sendAction()` 后立刻触发, +如果限定 `need_response: true`,该事件将会在动作收到响应后再触发,届时你可以通过依赖注入的方式,获取 ActionResponse 对象: + +```php +#[BotAction(need_response: true)] +public function onActionWithResponse(\OneBot\V12\Object\Action $action, \OneBot\V12\Object\ActionResponse $response) +{ + logger()->info('机器人发送了动作:' . $action->action . ',并且返回状态码为 ' . $response->retcode); +} +``` + ## BotActionResponse -啊?? +BoActionResponse 注解将在 OneBot 12 标准的动作发出,并收到了合法的响应内容时触发。 -| 参数名称 | 允许值 | 用途 | 默认 | -|---------|-----|---------------|------| -| retcode | int | 响应码 | null | -| level | int | 事件优先级(越大越先执行) | 20 | +| 参数名称 | 允许值 | 用途 | 默认 | +|-----------|--------|----------------|-------| +| status | string | 用于限定成功与否的状态 | null | +| retcode | int | 响应码 | null | +| level | int | 事件优先级(越大越先执行) | 20 | + +举例一,你需要获取所有响应不成功的动作,则只需设置 status 为 failed 即可: + +```php +#[BotActionResponse(status: 'failed')] +public function onFailedResponse(\OneBot\V12\Object\ActionResponse $response) +{ + logger()->error('动作请求失败,错误码:' . $response->retcode. ',错误消息:' . $response->message); +} +``` + +如果你的机器日代码逻辑更偏向于关注单个动作请求的成功与否, +这里其实更推荐使用上方的 `BotAction` 注解,并采用 `need_response: true` 参数,这样可以同时使用 Action 和 ActionResponse 对象。 ## BotEvent @@ -38,6 +84,29 @@ | sub_type | string | 对应标准中的事件子类型 | null | | level | int | 事件优先级(越大越先执行) | 20 | +除了 level 外的参数,均可做限定事件内容的参数。 + +举例一,你想写一个事件注解绑定的方法,但只获取 `type` 为 `notice` 消息类的事件: + +```php +#[BotEvent(type: 'notice')] +public function onNotice(BotContext $ctx, OneBotEvent $event) +{ + logger()->info('收到了机器人 ' . $event->self['user_id'] . ' 的通知事件,子类型为 ' . $event->detail_type); +} +``` + +举例二,你想限定获取群所有群消息,通过设置 `type`、`detail_type` 两个参数组合来获取: + +```php +#[BotEvent(type: 'message', detail_type: 'group')] +public function onGroupMessage(OneBotEvent $event) +{ + // getAltMessage() 为返回一个终端可读的展示型文本,非消息原文 + logger()->info('来自群组 ' . $event->getGroupId() . ':' . $event->getUserId() . ' 的消息:' . $event->getAltMessage()); +} +``` + ## BotCommand 对于 `BotEvent` 的封装,用于支持常用的命令式调用(如:”天气 深圳”)。 @@ -57,7 +126,6 @@ | level | int | 事件优先级(越大越先执行) | 20 | > 机器人命令注册的实例可参见【一堆例子链接】 -> ## CommandArgument diff --git a/src/Globals/global_class_alias.php b/src/Globals/global_class_alias.php index d27a60f7..db2a9667 100644 --- a/src/Globals/global_class_alias.php +++ b/src/Globals/global_class_alias.php @@ -47,6 +47,7 @@ class_alias(\ZM\Utils\ZMRequest::class, 'ZMRequest'); class_alias(\ZM\Utils\ZMUtil::class, 'ZMUtil'); class_alias(\ZM\Store\KV\LightCache::class, 'LightCache'); class_alias(\ZM\Store\KV\Redis\KVRedis::class, 'KVRedis'); +class_alias(\ZM\Config\ZMConfig::class, 'ZMConfig'); // 下面是 OneBot 相关类的全局别称 class_alias(\OneBot\Driver\Event\WebSocket\WebSocketOpenEvent::class, 'WebSocketOpenEvent'); @@ -56,6 +57,16 @@ class_alias(\OneBot\Driver\Event\Http\HttpRequestEvent::class, 'HttpRequestEvent // OneBot 12 的对象 class_alias(\OneBot\V12\Object\OneBotEvent::class, 'OneBotEvent'); +class_alias(\OneBot\V12\Object\Action::class, 'Action'); // 下面是 Choir 相关的全局别称 class_alias(\Choir\Http\HttpFactory::class, 'HttpFactory'); +class_alias(\Choir\WebSocket\FrameInterface::class, 'FrameInterface'); + +// PSR 接口的别名 +class_alias(\Psr\Http\Message\ServerRequestInterface::class, 'ServerRequestInterface'); +class_alias(\Psr\Http\Message\RequestInterface::class, 'RequestInterface'); +class_alias(\Psr\Http\Message\ResponseInterface::class, 'ResponseInterface'); +class_alias(\Psr\Http\Message\UriInterface::class, 'UriInterface'); +class_alias(\Psr\Log\LoggerInterface::class, 'LoggerInterface'); +class_alias(\Psr\Container\ContainerInterface::class, 'ContainerInterface');