Compare commits

...

8 Commits
2.1.4 ... 2.1.5

Author SHA1 Message Date
crazywhalecc
e9e3e5e129 update docs 2021-01-13 15:46:55 +08:00
crazywhalecc
1ef8225d10 update to 2.1.5 version
change route to Symfony routing
2021-01-13 15:40:27 +08:00
crazywhalecc
ccadec23e4 update docs and change console command suitable 2021-01-07 16:01:01 +08:00
jerry
0972a1959e update README.md 2021-01-05 23:35:49 +08:00
crazywhalecc
ce74191947 update docs 2021-01-05 16:19:35 +08:00
crazywhalecc
4feeb9519c update docs 2021-01-04 16:59:19 +08:00
crazywhalecc
efee146215 update docs 2021-01-04 16:45:06 +08:00
jerry
96ce7b30d0 add InterruptException catcher to onRequest 2021-01-04 01:35:54 +08:00
28 changed files with 1811 additions and 180 deletions

View File

@@ -84,6 +84,8 @@ public function index() {
## 关于
框架和 SDK 是 炸毛机器人 项目的核心框架开源部分。炸毛机器人是作者写的一个高性能机器人,曾获全国计算机设计大赛一等奖。
作者的炸毛机器人已从2018年初起稳定运行了**三年**,并且持续迭代。
欢迎随时在 HTTP-API 插件群里提问,当然更好的话可以加作者 QQ627577391或提交 Issue 进行疑难解答。
本项目在更新内容时,请及时关注 GitHub 动态,更新前请将自己的模块代码做好备份。

View File

@@ -3,7 +3,7 @@
"description": "High performance QQ robot and web server development framework",
"minimum-stability": "stable",
"license": "Apache-2.0",
"version": "2.1.4",
"version": "2.1.5",
"extra": {
"exclude_annotate": [
"src/ZM"

65
docs/component/atomics.md Normal file
View File

@@ -0,0 +1,65 @@
# ZMAtomic 原子计数器
原子计数器是用于多进程间跨进程使用的原子计数使用的,比如统计入站请求数量等。此功能基于 Swoole 的 Atomic详情见 [Swoole - 文档]([进程间无锁计数器(Atomic) (swoole.com)](http://wiki.swoole.com/#/memory/atomic))。
## 配置和初始化
见配置文件:`config/global.php` 中的 `init_atomics` 字段:
```php
/** zhamao-framework在框架启动时初始化的atomic们 */
$config['init_atomics'] = [
'foo' => 0,
'bar' => 4,
];
```
这时我们就成功初始化两个原子计数器,名字分别为 `foo``bar`
!!! warning "注意"
初始化的值必须是不小于 0 的 int32 值!
## 使用
定义和命名空间:`ZM\Store\ZMAtomic`
连接计数示例:
```php
<?php
namespace Module\Example;
use ZM\Annotation\Swoole\OnRequestEvent;
use ZM\Store\ZMAtomic;
class Hello {
/**
* @OnRequestEvent()
*/
public function onRequest() {
$cnt = ZMAtomic::get("foo")->add(1);
ctx()->getResponse()->end("当前已访问:".$cnt."");
}
}
```
### ZMAtomic::get()->get()
获取计数的数字:`dump(ZMAtomic::get("bar")->get());` 返回 4。
### ZMAtomic::get()->add($num)
加上一定的数并返回结果:`dump(ZMAtomic::get("bar")->add(5));` 返回 9。
### ZMAtomic::get()->sub($num)
要减少的数值(必须为正整数):`dump(ZMAtomic::get("bar")->sub(5));` 返回 5。
### ZMAtomic::get()->set($num)
设置计数的数字:`ZMAtomic::get("bar")->set(77);`
!!! note "提示"
还有一些不常用的方法,可以看 Swoole 官方的文档,这里就不一一列举了。

161
docs/component/console.md Normal file
View File

@@ -0,0 +1,161 @@
# Console 控制台
Console 类所在命名空间:`\ZM\Console\Console`
Console 类为框架的终端输出管理类。
## 设置 Log 输出等级
**输出等级** 控制了输出到命令行的内容的重要性。在框架的输出中,消息有以下几种不同等级的类别
- **error** / **log**: 0
- **warning**: 1
- **info** / **success**: 2
- **verbose**: 3
- **debug**: 4
输出等级设置后显示的消息类别为小于等于当前 log 的。假设你将 log 等级设置为 3你可以看到除 debug 外的所有 log 内容。
通过配置文件 `global.php` 中的 `init_atomics -> info_level` 的数值你可以更改框架的默认 log 等级(默认为 2
你也可以在启动框架的命令行中添加参数来切换 log 等级:
```bash
vendor/bin/start server --log-error # 以 error 等级启动框架
vendor/bin/start server --log-warning # 以 warning 等级启动框架
vendor/bin/start server --log-info # 以 info 等级启动框架
vendor/bin/start server --log-verbose # 以 verbose 等级启动框架
vendor/bin/start server --log-debug # 以 debug 等级 启动框架
```
## 使用 Log 输出内容
作为模块开发者的你,你可以主动调用框架内的 Console 类输出信息到终端。
### Console::log()
输出 0 级别的普通 log。
- 参数:`$msg, $color`,分别为内容和字体颜色。
> 此 log 不会被 info_level 所限制,无论如何也会输出到终端。
### Console::error()
输出 error 级别的红色醒目 log。一般此 log 为框架内部出现不可忍受的错误比如内存不足、PHP fatal error 等错误。
- 参数:`$msg`
> 此 log 不会被 info_level 所限制,无论如何也会输出到终端。
### Console::warning()
输出 warning 级别的 log。
!!! warning 注意
框架内出现的用户态异常,比如无法发送 API、无法连接数据库等错误都是 warning 错误,不会导致框架崩溃或功能错误的异常情况建议都使用 warning 输出而不是 error。
### Console::info()
输出 info 级别的 log。
### Console::success()
输出 success 级别的log。
### Console::verbose()
输出 verbose 级别的 log。
### Console::debug()
输出 debug 级别的 log。
### Console::stackTrace()
输出栈追踪信息。
### Console::setColor()
返回:彩色的字符串。
- **string**: 要变颜色的字符串
- **color**: 要变的颜色。支持 `red``green``yellow``reset``blue``gray``gold``pink``lightblue``lightlightblue`
```php
Console::log("This is normal msg. (0)");
Console::error("This is error msg. (0)");
Console::warning("This is warning msg. (1)");
Console::info("This is info msg. (2)");
Console::success("This is success msg. (2)");
Console::verbose("This is verbose msg. (3)");
Console::debug("This is debug msg. (4)");
Console::stackTrace();
$str = Console::setColor("I am gold color.", "gold");
```
## 终端交互命令
炸毛框架支持从终端输入命令来进行一些操作,例如重启框架、停止框架、执行函数等。
::: warning 注意
在 Docker、systemd、daemon 状态下启动的框架会自动关闭终端等待输入,交互不可用。
:::
### reload
重新加载除 `src/Framework/` 下的所有模块。
- 别名:`r`
### stop
停止框架。
### logtest
输出各种等级的 log 示例文本。
### call
执行对应类的成员方法。下面是例子:
```bash
call \ZM\Utils\ZMUtil reload
```
### bc
直接执行 PHP 代码,输入格式为 base64。
```bash
bc XEZyYW1ld29ya1xDb25zb2xlOjp3YXJuaW5nKCJoZWxsbyB3YXJuaW5nISIpOw==
# 代码内容:\ZM\Console\Console::warning("hello warning!");
# 终端输出:[19:14:32] [W] hello warning!
```
### echo
输出文本
```bash
echo hello
```
### color
按照颜色输出文本
```bash
color green 我是绿色的字
```
## MOTD
在 1.4 版本开始,框架支持启动时的 motd 内容修改。
文件位置:`config/motd.txt`

View File

@@ -254,7 +254,7 @@ if($r["retcode"] == 0) Console::success("消息发送成功!");
参数同 `reply()`。
## waitMessage()
## waitMessage() - 等待用户消息
- 参数:`waitMessage($prompt = "", $timeout = 600, $timeout_prompt = "")`
- 用途:等待用户输入消息
@@ -286,14 +286,139 @@ function yourName(){
( 你都10分钟不理我了嘤嘤嘤
</chat-box>
## getArgs()
## getArgs() - 自动获取参数
TODO还没写到这里下次更新今晚太困了。
为 `waitMessage()` 的封装,目的是让机器人的回复更加智能化。最好的例子就是在框架自带的默认示例中“随机数”的例子,我们假设要写一个随机数功能,但是用户从来都是不思考就使用机器人的。抛开人工智能,我们能做的就是“专家系统”,同时让我们写的代码尽可能适配用户所说的每一句话:
- 随机数 1 100
- 随机数(一般不知道怎么用这个功能的人都会只说一个关键词)
- 从2到9的随机数
所以,在匹配第一和第二种情况时候,我们不需要重复写代码,而第一种的话用户已经将参数给你的时候,你不需要再次使用 `waitMessage()` 方式进行等待询问,只需要取到使用就好了。`getArgs()` 就是做这个的。
定义:`getArgs($mode, $prompt_msg)`
`$mode`:获取模式,有三种:
- `ZM_MATCH_ALL`:效果等同于 `getFullArg()`,获取全部的内容,把空格也当作一部分
- `ZM_MATCH_NUMBER`:效果等同于 `getNumArg()`,获取下一个数字参数
- `ZM_MATCH_FIRST`:效果等同于 `getNextArg()`,获取下一个参数
`$prompt_msg`:字符串,指定如果参数缺失时询问用户的内容。
```php
/**
* @CQCommand("test")
*/
public function argTest1() {
$s = ctx()->getArgs(ZM_MATCH_FIRST, "请输入你要传入的参数内容");
return "参数内容:".$s;
}
```
<chat-box>
) test
( 请输入你要传入的参数内容
) test2
( 参数内容test2
</chat-box>
`getArgs()` 也有三层封装,在使用过程中避免麻烦的话,推荐使用下面这几种 `get*Arg()` 方式。
## getFullArg()
获取关键词后的整个字符串参数,包括空格,如果不存在则询问。
典型例子:`复读机 你好 你好`,获取参数时会将 `你好 你好` 当作一个参数来获取。
```php
/**
* @CQCommand("test")
*/
public function argTest1() {
$s = ctx()->getFullArg("请输入你要传入的参数内容");
return "参数内容:".$s;
}
```
<chat-box>
) test abc def argtest
( 参数内容abc def argtest
) test
( 请输入你要传入的参数内容
) abc def
( 参数内容abc def
</chat-box>
## getNextArg()
获取下一个参数分隔符可以是空格tab。
```php
/**
* @CQCommand("test")
*/
public function argTest1() {
$s = ctx()->getNextArg("请输入你要传入的参数内容");
return "参数内容:".$s;
}
```
<chat-box>
) test abc def argtest
( 参数内容abc
) test
( 请输入你要传入的参数内容
) abc
( 参数内容abc
</chat-box>
## getNumArg()
> 2.1.5 版本起可用。
获取下一个数字型参数,如果 `is_numeric()` 为 true 则获取成功,如果没有符合的则询问用户。
```php
/**
* @CQCommand("test")
*/
public function argTest1() {
$s = ctx()->getNextArg("请输入你要传入的数字内容");
return "数字参数内容:".$s;
}
```
<chat-box>
) test abc 334 argtest
( 数字参数内容334
) test abc
( 请输入你要传入的数字内容
) 998
( 参数内容998
</chat-box>
## copy()
t
获取整个上下文的所有内容的数组形式。
```php
$arr = ctx()->copy();
dump($arr);
```
## getOption() - 获取匹配参数内容
```php
/**
* @CQCommand("test")
*/
public function argTest1() {
return "参数内容:".implode(", ", ctx()->getOption());
}
```
<chat-box>
) test abc 334 argtest
( 参数内容abc, 334, argtest
</chat-box>

View File

@@ -0,0 +1,63 @@
# 协程池
首先要声明的一点是,协程池这个概念是我自己编的。
因为协程的特点,它是单线程下运行的,所以在一个进程内同时实际上只会有一个协程的代码在执行逻辑,但是后面的 IO 操作、协程挂起等待的操作都是同时去做的,比如数据库的大数据读取、写入需要耗时几秒甚至几十秒,这时用基于协程的 MySQL 连接池就完全不是问题。
但是就拿 MySQL 举例,我们 MySQL 使用的是 TCP 连接,而无论是 MySQL 还是 TCP 连接,最大数量都是有限的。我们即使设置了允许最大协程数量非常大,比如上百万,但是也不能让数据库连接池一个池支持上百万的连接。
这时假设高并发进来了怎么办呢?这时就需要框架提出的一个折中方案:协程池了。
顾名思义,协程池是一个容纳协程的区域,而协程里又容纳着各种各样需要阻塞调用被协程调用的 IO 操作,协程池用作限制协程的数量。
```php
use ZM\Utils\CoroutinePool;
use ZM\DB\DB;
// 传统写法,一旦高并发则可能导致 Too many connections
go(funuction(){
DB::rawQuery("INSERT INTO users VALUES(?,?)", ["admin", "password"]);
});
// 协程池写法
CoroutinePool::go(function(){
DB::rawQuery("INSERT INTO users VALUES(?,?)", ["admin", "password"]);
}, "foo");
```
参数:`go(callable $func, $name = "default")`
`$name` 为协程池对应的名字,你可以设置多个协程池,用来支持不同的需要限制并发 IO 数量的地方,例如 Redis 和 MySQL 设置不同的名字。`$func` 可为闭包或可调用的方法名称或数组。
## 配置
默认情况下,直接调用 `CoroutinePool::go()` 时,协程池大小为 30也就是如果有 30 个协程进入了挂起状态(比如数据库在执行查询语句),那么更多的协程执行时就会阻塞并以协程等待的方式等待,直到现有的 30 个协程中的一部分完成了它的工作。
## 方法
### CoroutinePool::go()
将协程放入协程池运行。
如果不写 `$name` 参数,则使用的是默认协程池。
### CoroutinePool::defaultSize()
设置默认协程池的大小(默认 30
```php
CoroutinePool::defaultSize(64);
for($i = 0; $i < 1000; ++$i) {
CoroutinePool::go(function(){
DB::rawQuery("SELECT * FROM users");
});
}
```
### CoroutinePool::setSize()
定义:`setSize($name, int $size)`
`$name` 为字符串,是你要用的协程池的名称。
`$size` 为大小,最大不可超过 Swoole 配置文件中指定的最大协程数量。

View File

@@ -76,6 +76,41 @@ class Hello {
[ https://zhamao.xin/file/hello.jpg
</chat-box>
## CQ 码操作
### CQ::decode()
CQ 码字符反转义。
| 反转义前 | 反转义后 |
| -------- | -------- |
| `&amp;` | `&` |
| `&#91;` | `[` |
| `&#93;` | `]` |
```php
$str = CQ::decode("&#91;我只是一条普通的文本&#93;");
// 转换为 "[我只是一条普通的文本]"
```
### CQ::encode()
转义 CQ 码的敏感符号,防止 酷Q 把不该解析为 CQ 码的消息内容当作 CQ 码处理。
```php
$str = CQ::encode("[CQ:我只是一条普通的文本]");
// $str: "&#91;CQ:我只是一条普通的文本&#93;"
```
### CQ::removeCQ()
去除字符串中所有的 CQ 码。
```php
$str = CQ::removeCQ("[CQ:at,qq=all]这是带表情的全体消息[CQ:face,id=8]");
// $str: "这是带表情的全体消息"
```
## CQ 码列表
### CQ::face() - 发送 QQ 表情

View File

@@ -0,0 +1,231 @@
# 全局方法
全局方法就是 PHP 的全局函数,任意位置都可以调用,无需使用 use 字样。
## getClassPath()
根据加载的用户编写的代码类名来获取类所在的文件路径。
=== "src/Module/Example/Hello.php"
```php
<?php
namespace Module\Example;
class Hello { ... }
```
=== "src/Module/Example/Start.php"
```php
<?php
namespace Module\Example;
use ZM\Annotation\Swoole\OnStart;
class Start {
/**
* @OnStart()
*/
public function onStart() {
Console::info("Path: ".getClassPath(Hello::class));
}
}
```
=== "输出结果"
```
[11:12:02] [I] [#0] Path: /mnt/d/project/zhamao-framework/src/Module/Example/Hello.ph
```
## explodeMsg()
切割字符串的函数支持多空格换行tab。
定义:`explodeMsg($msg, $ban_comma = false)`
```php
$s = explodeMsg("你好啊 你好你好\n我还有多个空格 哈哈哈");
echo json_encode($s, 128|256); // ["你好啊","你好你好","我还有多个空格","哈哈哈"]
```
## unicode_decode()
Unicode 解码,一般用于被转义的 Unicode 转回来。
```php
echo unicode_decode("\u4f60\u597d"); // 你好
```
## matchPattern()
根据星号匹配字符串(非正则表达式)。
匹配示例:
- `你今天*了吗` -> 你今天喝水了吗
- `*的天气怎么样` -> 德州的天气怎么样
- `把*翻译成*` -> 把茶翻译成英语
定义:`matchPattern($pattern, $context)`
`$pattern` 为匹配模式,例如 `你今天*了吗`。
`$context` 为要判断是否匹配的内容。
返回值:`bool`,当为 true 时代表规则是匹配的false 代表不匹配。
```php
matchPattern("*是个啥?", "996是个啥"); // true
matchPattern("我想听*唱歌", "你想听谁唱歌"); // false
matchPattern("*把*翻译成*", "请把你好翻译成阿拉伯语"); // true
```
## split_explode()
和 `explodeMsg()` 类似,用作分割字符串,不过此函数加入了对 `中文|数字` 两者的分割,也就是说中文和数字之间也会被分割。
定义:`split_explode($del, $str, $divide_en = false)`
```php
split_explode(" ", "前进20 急啊急啊"); // ["前进","20","急啊急啊"]
```
`$del` 和 `explode()` 的第一个参数作用相同,作为初期分割的标志。
`$str` 表示待分割的内容。
`$divide_en` 表示是否分割中文和英文,如果为是,则中文和英文之间也会被分割开。
## matchArgs()
`matchPattern()` 的扩展,如果 `matchPattern()` 格式的字符串和模式匹配成功,则通过星号位置来提取星号匹配到的内容,参数同 `matchPattern()`。
```php
$r = matchArgs("把*翻译成*", "把日语翻译成英语"); // ["日语","英语"]
```
## connectIsQQ()
判断当前 WebSocket 连接是否为 OneBot 标准的机器人客户端。
## connectIsDefault()
判断连接是否是未定义类型的 WebSocket 连接。
## connectIs()
判断连接是否是对应类型的 WebSocket 连接。
```php
connectIs("your_another_type_connect");
```
## set_coroutine_params()
设置当前上下文中的一些变量。
```php
set_coroutine_params(["data" => [
"post_type" => "message",
...
]]);
```
## ctx()
别名:`context()`,获取当前协程的上下文,见 [上下文](/component/context/)。
## zm_debug()
同 `Console::debug($msg)`。
## zm_sleep()
协程版 `sleep()` 函数。
定义:`zm_sleep($s = 1)`
`$s`睡眠的时间可支持小数。例如0.001 代表 1 毫秒)
为什么不用 PHP 自带的 sleep 呢?因为炸毛框架是基于协程的,协程版 sleep 需要使用 Swoole 自带的 sleep。此函数做了一个简单的封装。
```php
zm_sleep(5);
zm_sleep(0.05);
```
## zm_exec()
执行系统命令,替代 PHP 的 `exec()`。
定义:`zm_exec($cmd)`
返回值:
```php
array(
'code' => 0, // 进程退出的状态码
'signal' => 0, // 信号
'output' => 'hello world', // 输出内容
);
```
```php
$result = zm_exec("echo 'hello world'")["output"];
```
## zm_cid()
获取当前协程的 ID效果等同于 `\Swoole\Coroutine::getCid()`。
## zm_yield()
挂起当前协程,直到手动恢复,效果等同于 `\Swoole\Coroutine::yield()`。
## zm_resume()
恢复继续执行协程,效果等同于 `\Swoole\Coroutine::resume()`。
```php
$r = 0;
function test() {
echo "hello-1\n";
global $r;
$r = zm_cid();
zm_yield();
echo "hello-2\n";
}
go("test");
echo "hello-3\n";
zm_resume($r);
```
输出结果:
```
hello-1
hello-3
hello-2
```
## server()
获取 Swoole Server 对象进行操作,效果等同于 `\ZM\Framework::$server`。
```php
echo server()->worker_id.PHP_EOL; // 0
```
## bot()
返回 ZMRobot 操作机器人 API 的对象。
对于默认的模式,如果框架连接了多个机器人实例,则会随机返回一个机器人的 API 实例。如果使用了单例模式,则返回单例模式的机器人 API 实例。
```php
bot()->sendPrivateMsg(123456, "你好啊!!");
// 等同于 ZMRobot::getRandom()->sendPrivateMsg(123456, "你好啊!!");
```

View File

@@ -0,0 +1,226 @@
# LightCache 轻量缓存
在炸毛框架 1.x 时代,框架里有非常方便使用的 ZMBuf 缓存,但是由于 2.x 版本框架加入了多进程模式所以不能再以传统的存到全局变量的方式来构建和管理缓存了LightCache 就是替代方案。LightCache 依旧是 key-value 键值对形式的存储,支持多种类型的变量。
定义:`ZM\Store\LightCache`
## 与 ZMBuf 的不同
从存储内容角度LightCache 存入的是 Swoole 初始化的共享内存,基于 Swoole/Table 编写。优势在于多进程下的性能极佳,而且没有数据同步问题;劣势在于它需要在启动框架前就声明总大小,不能根据存储数据的大小来划定,需提前指定最大能存储的容量。而 ZMBuf 基于直接把变量存到静态成员中 `public static $data` 类似这样,且 1.x 框架基于单进程单线程,无任何数据同步的问题。
总之来说LightCache 是让用户在涉及多进程编程时,一个折中的解决方案,提出和解决了很多多进程开发时存储数据遇到的问题:数据同步、进程间通信效率、数据是否需要上锁等。
- 数据同步:多进程下因为是固定的内存大小区域,所以每个进程读取和写入都是只有一份数据的,不存在数据不同步的问题。
- 进程间通信:因为多个进程共享一片区域的内存,所以不需要进程间通信,无协程切换。
- 镀锡是否需要上锁:看情况。一般情况下 Swoole/Table 模块自带一个行锁,只有两个进程在两个 CPU 上同时读取一行数据时才会发生抢锁,作为框架的使用者,如果只写或只读,是无需手动上任何锁的。只有在先 `get()``set()` 这样的情况才需要上自旋锁。后面的段会详细讲述。
使用体验上,基本和 ZMBuf 无差,如果没有用过 1.x 的版本,可无视此段话。
## 使用
### 配置和初始化
配置文件还是在 `config/global.php` 文件里,字段是 `light_cache`
```php
/** 轻量字符串缓存,默认开启 */
$config['light_cache'] = [
'size' => 1024, //最多允许储存的条数需要2的倍数
'max_strlen' => 16384, //单行字符串最大长度需要2的倍数
'hash_conflict_proportion' => 0.6, //Hash冲突率越大越好但是需要的内存更多
'persistence_path' => $config['zm_data'].'_cache.json',
'auto_save_interval' => 900
];
```
其中 `$size` 是最多保存的键值对数目,填写非 2 的倍数时底层会自动修正为 2 的倍数值。
`$max_strlen` 为单条值最长保存的长度。因为 Swoole/Table 只能存数字、字符串所以在存取数组等变量时会先将其序列化为字符串形式保存get 时自动反序列化回来。在存数组等非字符串变量时,请先自行计算你要存取的内容序列化后的最大长度。如果长度超出最大长度,则无法保存,`set()` 将返回 false。
`hash_conflict_proportion`Table 模块底层使用 hash 表,会存在 hash 冲突,调大 Hash 冲突率会提升 `size` 指定条目数的准确性,但也将增加物理内存的使用。这里单位是百分比,`0.6``60%`
`persistence_path` 是持久化保存变量的文件保存位置,默认在 `zm_data/_cache.json` 文件。
`auto_save_interval` 是持久化保存变量的自动保存时间,单位秒。
### LightCache::set()
设置内容。
定义:`LightCache::set($key, $value, $expire = -1)`
返回值:`bool`。当 value 超出了最大长度或内存不足时,返回 false其余 true。
参数:
`$key` 的长度不能超过 64 字节,且不能存入二进制内容。
`$value` 可存入 `bool``string``int``array` 等可被 `json_encode()` 的变量,闭包函数和对象不可存入。
`$expire``int`,超时时间(秒)。如果设定了大于 0 的值,则表明是在 `$expire` 秒后自动删除。如果为 -1 则什么都不做,如果框架使用了 `stop` 或 Ctrl+C 或意外退出时数据会丢失。如果为 -2则会将此数据持久化保存保存在上方配置文件指定的 json 文件中,待关闭后再次启动框架会自动加载回来,不会丢失。
```php
// use ZM\Store\LightCache;
/**
* @CQCommand("store")
*/
public function store() {
LightCache::set("key1", ["value1" => "strOrInt", "value2" => 123]);
return "OK!";
}
/**
* @CQCommand("storeAfterRemove")
*/
public function storeAfterRemove() {
LightCache::set("store1", "remove1", 30);
ctx()->reply(LightCache::get("store1") !== null ? "内容存在!" : "内容不存在!");
zm_sleep(30);
ctx()->reply(LightCache::get("store1") !== null ? "内容存在!" : "内容不存在!");
}
```
<chat-box>
) store
( OK
) storeAfterRemove
( 内容存在!
^ 等待 30 秒
( 内容不存在!
</chat-box>
### LightCache::get()
获取内容。
返回值:`mixed|null`。当无内容或过期时返回 null剩余情况返回原数据。
### LightCache::getExpire()
获取存储项剩余过期时间(秒)。
定义:`LightCache::getExpire(string $key)`
```php
$s = LightCache::set("test", "hello", 20);
zm_sleep(10);
dump(LightCache::getExpire("test")); // 返回 10
```
### LightCache::getMemoryUsage()
获取轻量缓存使用的总空间大小(字节)
```php
LightCache::getMemoryUsage());
```
轻量缓存的内存手工计算方式:(Table 结构体长度` + `KEY 长度 64 字节 + `$size`) * (1 + `$conflict_proportion`) * 列尺寸。
Table 结构体长度根据你所设定的 `max_strlen` 会变化。
> 框架默认配置下的轻量缓存启动后大约占用内存 25MB 左右。
### LightCache::isset()
判断某项是否存在。
```php
LightCache::set("foo", "bar");
dump(LightCache::isset("foo")); // true
```
### LightCache::unset()
删除某项。
```php
LightCache::set("foo", "bar");
LightCache::unset("foo");
dump(LightCache::isset("foo")); // false
```
### LightCache::getAll()
获取所有项。
```php
LightCache::set("k1", ["I", "am", "array"]);
LightCache::set("k2", "v2");
LightCache::set("k3", 20001);
dump(LightCache::getAll());
/*
{
"k1": ["I", "am", "array"],
"k2": "v2",
"k3": 20001
}
*/
```
### LightCache::savePersistence()
立刻保存所有被标记为持久化的缓存项到磁盘。
!!! note "提示"
在一般情况下框架定时执行此方法来保存在停止框架、reload 框架和 Ctrl+C 停止框架的时候,均会执行保存。
### 持久化
`set()` 的 expire 设置为 -2 即可。
```php
/**
* @CQCommand("store")
*/
public function store() {
LightCache::set("msg_time", time(), -2);
return "OK!";
}
/**
* @CQCommand("getStore")
*/
public function getStore() {
return "存储时间:".date("Y-m-d H:i:s", LightCache::get("msg_time"));
}
```
<chat-box>
^ 我在 2021-01-05 15:21:00 发送这条消息
) store
( OK!
^ 这时我用 Ctrl+C 停止框架,过一会儿再启动
) getStore
( 存储时间2021-01-05 15:21:00
</chat-box>
### 数据加锁
在特定情况下,使用 LightCache 必须配合锁使用,否则会出现数据错乱。我们来看下面的例子:
```php
/**
* @RequestMapping("/test")
*/
public function test() {
$s = LightCache::get("web_count");
if($s === null) $s = 1;
else $s += 1;
LightCache::set("web_count", $s);
return "<h1>It works!</h1>";
}
```
我们使用压测工具,例如 `ab`,对此路由接口开很多很多线程进行测试,假设我们设置请求总数为 200000 次,框架的工作进程数为 8我用的是 2020 年末的 i5 MacBook Pro 13 inch
> 懒得再测了,下面就口述过程吧。
在运行完测试后,通过 `LightCache::get("web_count")`,获取到的数你会发现不是 200000。怎么回事呢请自行翻阅多进程开发相关的书籍哦或者简单理解为有一些情况下进程 1 执行到了 `if-else` 语句,另一个进程也执行到了这里,两次在代码层面加的数是相同的,则虽然请求了两次,但是后执行 set 的那个进程又覆盖了前一个进程执行的值,导致最终结果加了 1 而不是 2
!!! note "提示"
同样的场景,使用 ZMAtomic 就不需要使用锁了。Atomic 是一句话:`add(1)` 立即加值的。而 LightCache 需要加锁的情况一般都是 `get->改值->set` 这样的代码。
解决这一问题,就需要用到锁。这种情况下,我们首先考虑的是自旋锁,框架也因此内置了一个方便使用的自旋锁组件。详见下一章:自旋锁。

97
docs/component/mysql.md Normal file
View File

@@ -0,0 +1,97 @@
# MySQL 数据库
## 配置
炸毛框架的数据库组件支持原生 SQL、查询构造器去掉了复杂的对象模型关联同时默认为数据库连接池使开发变得简单。
数据库的配置位于 `config/global.php` 文件的 `sql_config` 段。
数据库操作的唯一核心工具类和功能类为 `\ZM\DB\DB`,使用前需要配置 host 和 use 此类。
## 查询构造器
在 炸毛框架 中,数据库查询构造器为创建和执行数据库查询提供了一个方便的接口,它可用于执行应用程序中大部分数据库操作。同时,查询构造器使用 `prepare` 预处理来保护程序免受 SQL 注入攻击,因此没有必要转义任何传入的字符串。
### 新增数据
```php
DB::table("admin")->insert(['admin_name', 'admin_password'])->save();
// INSERT INTO admin VALUES ('admin_name', 'admin_password')
```
其中 `insert` 的参数是插入条目的数据列表。假设 admin 表有 `name``password` 两列。
> 自增 ID 插入 0 即可。
### 删除数据
```php
DB::table("admin")->delete()->where("name", "admin_name")->save();
// DELETE FROM admin WHERE name = 'admin_name'
```
其中 `where` 语句的第一个参数为列名,当只有两个参数时,第二个参数为值,效果等同于 SQL 语句:`WHERE name = 'admin_name'`,当含有第三个参数且第二个参数为 `=``!=``LIKE` 的时候,效果就是 `WHERE 第一个参数 第二个参数的操作符 第三个参数`
### 更新数据
```php
DB::table("admin")->update(["name" => "fake_admin"])->where("name", "admin_name")->save();
// UPDATE admin SET name = 'fake_admin' WHERE name = 'admin_name'
```
`update()` 方法中是要 SET 的内容的键值对,例如上面把 `name` 列的值改为 `fake_admin`
### 查询数据
```php
$r = DB::table("admin")->select(["name"])->where("name", "fake_admin")->fetchFirst();
// SELECT name FROM admin WHERE name = 'fake_admin'
echo $r["name"];
echo DB::table("admin")->where("name", "fake_admin")->value("name");
// SELECT * FROM admin WHERE name = 'fake_admin'
```
`select()` 方法的参数是要查询的列,默认留空为 `["*"]`,也就是所有列都获取,也可以在 table 后直接 where 查询。
其中 `fetchFirst()` 获取第一行数据。
如果只需获取一行中的一个字段值,也可以通过上面所示的 `value()` 方法直接获取。
多列数据获取需要使用 `fetchAll()`
```php
$r = DB::table("admin")->select()->fetchAll();
// SELECT * FROM admin WHERE 1
foreach($r as $k => $v) {
echo $v["name"].PHP_EOL;
}
```
### 查询条数
```php
DB::table("admin")->where("name", "fake_admin")->count();
//SELECT count(*) FROM admin WHERE name = 'fake_admin'
```
## 直接执行 SQL
> 在查询器外执行的 SQL 语句都不会被缓存,都是一定会请求数据库的。
### DB::rawQuery()
- 用途:直接执行模板查询的裸 SQL 语句。
- 参数:`$line``$params`
- 返回:查到的行的数组
`$line` 为请求的 SQL 语句,`$params` 为模板参数。
```php
$r = DB::rawQuery("SELECT * FROM admin WHERE name = ?", ["fake_admin"]);
//SELECT * FROM admin WHERE name = 'fake_admin'
echo $r[0]["password"];
```
> 参数查询已经从根本上杜绝了 SQL 注入的问题。

62
docs/component/redis.md Normal file
View File

@@ -0,0 +1,62 @@
# Redis
炸毛框架内置了 Redis 连接池,供开发者使用。使用前需要先安装 `redis` 扩展:
```bash
pecl install redis
```
> 如果是 Docker 环境,则默认已安装。
## 配置
配置文件在 `config/global.php` 的全局配置文件下,详情见 [配置](/guide/basic-config/#redis_config)。
示例配置(假设 Redis Server 开到了本地):
```php
/** Redis连接信息host留空则启动时不创建Redis连接池 */
$config['redis_config'] = [
'host' => '127.0.0.1',
'port' => 6379,
'timeout' => 1,
'db_index' => 0,
'auth' => ''
];
```
## 使用
当写好配置文件后,不可以使用 reload 进行重载,因为连接池需要在主进程中声明配置,才可以应用到多个工作进程中。所以必须输入 `stop` 或 Ctrl+C 停止后再启动框架。
定义:`ZM\Store\Redis\ZMRedis`
因为使用的是连接池,所以每次使用完一个连接需要归还连接给连接池。框架封装了两种方式自动归还,你可以选择下面其中的任意一种。
以下的方式获取的 `$redis` 都是 `redis` 扩展的对象 `\Redis`,关于 redis 扩展的方法文档,详情见:[Redis 文档](https://www.php.cn/course/49.html)。
### 对象模式
```php
$obj = new ZMRedis();
$redis = $obj->get();
ctx()->reply($redis->ping("123"));
```
### 回调模式
```php
// 前面的代码
ZMRedis::call(function($redis) {
$redis->set("key1", "hello world");
$result = $redis->get("key1");
ctx()->reply($result);
});
// 后面的代码
```
### 二者的区别
选一个喜欢的就好。硬要是说区别的话,对象模式是在 PHP 自动回收这个 `ZMRedis` 对象时会归还连接,也可以通过手动 `unset($obj)` 进行回收,否则就会执行到函数结尾自动回收。切记不可将 `$obj` 对象持久化存到静态或全局变量等。
回调模式看似是回调,但是是同步执行的,不会发生顺序错乱。也就是说到了 `ZMRedis::call()` 方法里面的时候,后面的代码不会提前执行,是顺序执行的。回调的作用仅仅是用作自动回收连接对象。

View File

@@ -0,0 +1,43 @@
# 单例类SingletonTrait
单例类,顾名思义,就是让用户声明的类拥有单例的特性,而这一组件引入的方式也最直接。它是一个 PHP 的 `trait`
我们传统写单例类的方式很手动,比如下面这样:
```php
<?php
class Foo {
public $test = 0;
private static $instance;
public static function getInstance() {
if (null === self::$instance) {
self::$instance = new Foo();
}
return self::$instance;
}
}
Foo::getInstance()->test = 4;
$obj = Foo::getInstance()->test;
var_dump($obj); // 4
```
这就要求我们每个需要声明为单例的类都写一个成员静态方法和成员静态变量。
框架使用了 PHP Trait 来快速让一个类支持这一特性:
```php
<?php
use ZM\Utils\SingletonTrait;
class Foo {
use SingletonTrait;
public $test = 0;
}
Foo::getInstance()->test = 5;
var_dump(Foo::getInstance()->test);
```
只需要在类中使用:`use \ZM\Utils\SingletonTrait;` 一句话即可。

View File

@@ -0,0 +1,73 @@
# SpinLock 自旋锁
前面讲到 LightCache 轻量缓存在特定的情况下为了保证数据不被多进程的因素导致丢失或覆盖,在高并发情况下修改数据需要加锁,所以炸毛框架内置了 SpinLock 自旋锁。
## 配置
自旋锁使用无需配置,和 LightCache 同源。
## 使用
定义:`ZM\Store\Lock\SpinLock`
### SpinLock::lock($key)
给信号量 `$key` 上锁。如果该信号量已经被上锁,则原地等待直到其他资源释放锁。
```php
SpinLock::lock("foo");
```
### SpinLock::unlock($key)
给信号量 `$key` 释放锁。
```php
SpinLock::unlock("foo");
```
### SpinLock::tryLock($key)
给信号量 `$key` 上锁。如果该信号量已经被上锁,则立刻返回 false。
```php
SpinLock::lock("foo");
```
## 综合实例
我们这里以之前在 LightCache 中的实例进行继续讲解,如何给之前那样的情况加锁:
```php
/**
* @RequestMapping("/test")
*/
public function test() {
SpinLock::lock("web_count"); // 加上这行
$s = LightCache::get("web_count");
if($s === null) $s = 1;
else $s += 1;
LightCache::set("web_count", $s);
SpinLock::unlock("web_count"); // 再加上这行
return "<h1>It works!</h1>";
}
```
加两行就 OK。这时再使用压测工具请求 200000 次,值就会是 200000 了!
原理剖析:在 LightCache 获取前,先对此内容上锁,这时如果其他进程有同时也在执行这个代码的时候,就会在 `SpinLock::lock()` 这行代码处原地等待,防止继续执行。等前面的那个进程执行到 `SpinLock::unlock()` 释放锁时,其他进程才可继续执行,从而避免了多个进程并行执行这段代码导致的数据错乱。
!!! error "警告"
使用锁时务必谨慎,如果不按照下面的规则使用自旋锁可能导致 CPU 占用率上升。
自旋锁使用约定:
- 使用 `SpinLock::lock()` 指定信号量名称时必须指定为字符串,且最好与你的 LightCache 缓存名称相同。
- 使用 `lock()` 时最好紧跟在 `LightCache::get()` 代码前。
- 使用自旋锁后,`LightCache::get()``LightCache::set()` 之间的代码段一定不能有 **读写文件、数据库操作和网络请求** 等代码,最好为纯 PHP 逻辑代码,且越短越好,如示例代码。
-`LightCache::set()` 后最好紧跟 `SpinLock::unlock()`
## 性能
使用自旋锁几乎没有性能损失,自旋锁要比其他类型的锁性能强很多,在上方举例使用的 `ab` 压测工具测试 100万请求 下使用自旋锁和不适用自旋锁的测试成绩时间分别为7.4s 和 6.9s。

272
docs/component/zmrequest.md Normal file
View File

@@ -0,0 +1,272 @@
# ZMRequestHTTP 客户端)
框架提供了轻量的 HTTP 请求发起工具类,直接静态调用即可。
命名空间:`use ZM\Requests\ZMRequest;`
!!! warning "注意"
在使用 Swoole 4.6.0 以下(不包含)的版本时,最好使用 Swoole 官方推荐的 Saber 或者 ZMRequest 这个轻量的 HTTP 请求客户端,不要使用 curl_exec因为在老版本的 Swoole 上对 curl 的协程 Hook 支持不是很完善。
## ZMRequest::get()
发起 GET 请求。
定义:`ZMRequest::get($url, $headers = [], $set = [], $return_body = true)`
全局函数别名:`zm_request_get($url, $headers = [], $set = [], $return_body = true)`
`$url`:要请求的 url`http://captive.apple.com/`
`$headers`:要请求的 Headers例如`["User-Agent" => "Chrome"]`,数组形式
`$set`:请求时的一些设置,例如超时时间等等。详见下方“设置参数”
`$return_body`:是否只返回请求回来的内容部分,默认为 true如果为 false 时则会返回一个 `\Swoole\Coroutine\Http\Client` 对象,可查阅 [Swoole 文档](http://wiki.swoole.com/#/coroutine_client/http_client) 进行接下来的一系列操作。
如果 `$return_body` 为 true但是请求失败HTTP 状态码不是 200 或无法连接到目标服务器或者无法解析域名等问题)时,方法会返回 false否则会返回内容。
返回值:`false|string|\Swoole\Coroutine\Http\Client`
```php
$r = ZMRequest::get("http://captive.apple.com/", ["User-Agent" => "Chrome"]);
echo $r.PHP_EOL; // <HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>
```
```php
$r = zm_request_get("http://captive.apple.com/", [], [], false);
echo $r->body.PHP_EOL; // 这行输出和上方的一致
dump($r);
/*
^ Swoole\Coroutine\Http\Client {#170
+errCode: 0
+errMsg: ""
+connected: false
+host: "captive.apple.com"
+port: 80
+ssl: false
+setting: array:1 [
"timeout" => 15.0
]
+requestMethod: "GET"
+requestHeaders: []
+requestBody: null
+uploadFiles: null
+downloadFile: null
+downloadOffset: 0
+statusCode: 200
+headers: array:4 [
"content-type" => "text/html"
"content-length" => "68"
"date" => "Thu, 07 Jan 2021 06:22:32 GMT"
"connection" => "keep-alive"
]
+set_cookie_headers: null
+cookies: null
+body: "<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>"
}
*/
```
## ZMRequest::post()
发送一个 POST 请求。
定义:`ZMRequest::post($url, array $header, $data, $set = [], $return_body = true)`
全局函数别名:`zm_request_post($url, array $header, $data, $set = [], $return_body = true)`
`$url`:同上,填入 url必填
`$header`:请求的 Headers必填数组形式例如 `["Content-Type" => "application/json"]`
`$data`:请求的数据体,默认应该传入数组,如果传入 `array` 类型,则会默认当作 `Content-Type: application/x-www-form-urlencoded` 方式自动转码和转换,例如 `["key1" => "b1", "key2" => "b2"]` 会变成 `key1=b1&key2=b2`
`$set`:同上,见下面的设置参数部分。
`$return_body`:同上。
```php
$s = ZMRequest::post("http://captive.apple.com/", ["Content-Type" => "application/json"], json_encode(["key1" => "value1"]));
```
## ZMRequest::request()
发起自定义一切参数的 HTTP 请求。
参数:
- `$url`请求的链接自动解析端口、HTTPS、DNS 等操作
- `$attribute`:请求的属性,示例见下方
- `$return_body`:可选参数,`bool` 类型,和上面的 `$return_body` 参数意义相同
其中 `$attribute` 参数对应可设置的有:
- `method`:可选 `GET``POST` 等 HTTP 请求的方式
- `set`:设置 Swoole 客户端的参数
- `headers`:要请求的 HTTP Headers
- `data`:请求的 body 数据,为数组时自动转换头部为 `x-www-form-urlencoded`
- `file`:要发送的文件,数组,可选多个文件
例1使用 GET 请求发送带有 Body 的 HTTP 请求
```php
$r = ZMRequest::request("http://example.com", [
"method" => "GET",
"data" => [
"key1" => "value1"
]
]);
```
例2设置请求超时时间并指定自定义头部
```php
$r = ZMRequest::request("http://example.com", [
"method" => "POST",
"headers" => [
"X-Custom-Header" => "Hello world",
"User-Agent" => "HEICORE"
],
"set" => ["timeout" => 10.0]
]);
```
例3发送文件和 data
```php
$r = ZMRequest::request("http://example.com/sendfile", [
"file" => [
[
"path" => "/path/to/image1.jpg", // path字段必填
"name" => "file1", // name字段必填这个是 POST 过去的 key
//"mime_type" => "image/jpeg", // 可选字段,底层会自动推断
//"filename" => "a.jpg", // 可选字段,文件名称
//"offset" => 0, // 可选字段,可以从指定文件的中间部分开始传输数据,此特性用于断点续传
//"length" => 1024 // 可选字段,默认为整个文件的尺寸
],
[
"path" => "/path/to/image2.jpg",
"name" => "file2"
]
],
"data" => [
"key1" => "value1"
]
]);
```
## ZMRequest::downloadFile()
下载文件到本地。
定义:`ZMRequest::downloadFile($url, $dst = null)`
`$url`:不多讲,下载链接。
`$dst`:本地位置,例如 `/tmp/hello.html`
下载成功返回 true 或指定的文件位置,失败返回 false。
```php
ZMRequest::downloadFile("http://captive.apple.com/", "/tmp/apple.html");
```
## ZMRequest::websocket()
创建一个 WebSocket 连接。因为 Swoole 提供的是同步协程的方案,但对于 WebSocket 这样的全双工通信,反而不是一个好的代码逻辑,炸毛框架将此同步协程的方案封装成了异步事件调用的方式。
定义:`ZMRequest::websocket($url, $set = ['websocket_mask' => true], $header = [])`
返回:一个 `\ZM\Requests\ZMWebSocket` 对象
效果等同于:`$s = new \ZM\Requests\ZMWebSocket($url, $set = ['websocket_mask' => true], $header = [])`
这个是 ZMRequest 扩展而来的异步 WebSocket 客户端,可供方便地连接、收发 WebSocket 消息所定制。
命名空间:`\ZM\Requests\ZMWebSocket`
```php
$ws = ZMRequest::websocket("ws://127.0.0.1:12345/"); //使用工具函数
// $ws = new ZMWebSocket("ws://127.0.0.1:12345/"); //直接构造
if($ws->is_available) {
$ws->onMessage(function(\Swoole\WebSocket\Frame $frame, $client) {
var_dump($frame->data);
});
$ws->onClose(function($client){
Console::info("Websocket closed.");
});
$result = $ws->upgrade();
var_dump($result);
}
```
### 属性
#### is_available
`bool` 类型,用于判断构造对象是否成功或链接是否可用。在构建新的对象并执行 `upgrade()` 前,如果 ws 链接没有问题,则会变为 true`onClose()` 回调执行后,此值变回 false。
### 方法
#### __construct()
客户端对象的构造方法。
参数:
- `$url`:要请求到的 WebSocket 目标地址,必须以 `ws(s)://` 开头
- `$set`可选Swoole 客户端的参数,例如超时、是否使用 `websocket_mask` 等,如果为空数组则默认为 `["websocket_mask" => true]`,具体可设置的内容见 [Swoole 文档](https://wiki.swoole.com/#/coroutine_client/http_client?id=set)
- `$header`:可选,请求的头部信息,数组
```php
$a = new ZMWebSocket("ws://127.0.0.1:8080/", ["websocket_mask" => true], [
"User-Agent" => "Firefox"
]);
```
#### onMessage()
设置收到消息的回调函数。
回调的参数:
- `$frame``Swoole\WebSocket\Frame` 类型,消息帧,一般只用 `$frame->data` 获取数据,具体见 [Swoole 文档](https://wiki.swoole.com/#/websocket_server?id=swoolewebsocketframe)
- `$client``Swoole\Coroutine\Http\Client` 类型,为客户端本身的对象,用于 push 数据等
```php
$a->onMessage(function($frame, $client){
echo "收到消息:".$frame->data.PHP_EOL;
$client->push("hello world");
});
```
#### onClose()
设置连接断开后执行的回调函数。
回调的参数:
- `$client`:同上,但断开连接后不能使用 `push()` 发送数据了,只建议作为重连等机制的使用
```php
$a->onClose(function($client){
echo "WS 链接断开了!".PHP_EOL;
});
```
#### upgrade()
发起连接。
返回值:`true|false`,当为 `true` 时代表握手成功,此时可以在回调里愉快地收发消息了。如果为 `false` 表明握手失败。
!!! warning "注意"
这里由于是协程转异步,所以不能确定 `upgrade()``onMessage()` 哪个先会被触发(一般情况下如果服务器不是立刻响应回包信息,总是会先返回 `upgrade()` 的结果。
## 设置参数
见:[Swoole - HTTP 客户端](http://wiki.swoole.com/#/coroutine_client/http_client?id=set)

23
docs/component/zmutil.md Normal file
View File

@@ -0,0 +1,23 @@
# ZMUtil 杂项工具类
调用前先 use`use ZM\Utils\ZMUtil;`
## ZMUtil::stop()
停止框架运行。
## ZMUtil::reload()
重载框架,这会断开所有到框架的连接和重载所有 `src/` 目录下的用户源码并重新加载所有 Worker 进程。
## ZMUtil::getModInstance()
根据类名称拿到此类的单例(前提是目标的类的构造函数为空)。
```php
class ASD{
public $test = 0;
}
ZMUtil::getModInstance(ASD::class)->test = 5;
```

View File

@@ -1,3 +1,167 @@
# 中间件注解
TODO师傅莫催快肝完了
对于 `@RequestMapping` 等注解绑定的事件函数,还支持中间件,可以完成 Session 会话、认证、日志记录等功能。中间件是用于控制 `请求到达``响应请求` 的整个流程的。从一定意义上来说相当于切面编程AOP
在炸毛框架中,中间件最直白的意思就是注解事件执行前、执行后、执行过程中可进行插入代码但不破坏原有代码。
```伪代码
@中间件1
@带条件的注解1
function 我的方法() {
blablabla...
}
//插入中间件,下面是执行流程
-> 判断注解1的执行条件是否为true
-> 中间件1的前置插入代码
-> 我的方法
-> 中间件1的后置插入代码
X -> 我的方法有异常时执行中间件1的异常处理
//不插入中间件,下面是执行流程
-> 判断注解1的执行条件是否为true
-> 我的方法
X -> 有异常则直接跳到最外层被框架捕获
```
中间件和事件分发器是紧密相连的,炸毛框架的内部分发器在分发注解事件的过程中会判断将要执行的事件是否含有中间件,框架内部执行流程图见下一章:事件分发器。
## 定义中间件
下方就是一个可以在终端打印路由函数运行的总时间的中间件,只需给中间件标明里面的 `@MiddlewareClass` 到中间件的类上就可以了。
```php
<?php
namespace Module\Middleware;
use Exception;
use ZM\Annotation\Http\HandleAfter;
use ZM\Annotation\Http\HandleBefore;
use ZM\Annotation\Http\HandleException;
use ZM\Annotation\Http\MiddlewareClass;
use ZM\Console\Console;
use ZM\Http\MiddlewareInterface;
/**
* @MiddlewareClass("timer")
*/
class TimerMiddleware implements MiddlewareInterface
{
private $starttime;
/**
* @HandleBefore()
* @return bool
*/
public function onBefore() {
$this->starttime = microtime(true);
return true;
}
/**
* @HandleAfter()
*/
public function onAfter() {
Console::info("Using " . round((microtime(true) - $this->starttime) * 1000, 2) . " ms.");
}
/**
* @HandleException(\Exception::class)
* @param Exception $e
* @throws Exception
*/
public function onException(Exception $e) {
Console::error("Using " . round((microtime(true) - $this->starttime) * 1000, 2) . " ms but an Exception occurred.");
throw $e;
}
}
```
技术要素:
1. 将需要声明为中间件的 class 类标上注解 `@MiddlewareClass`,并带有参数,参数为中间件名称,字符串即可。
2. 使用 `@MiddlewareClass` 的需要先 use`use ZM\Annotation\Http\MiddlewareClass;`
3. 类成员中声明执行前插入、执行后插入和异常捕获函数也需要注解,分别是 `@HandleBefore``@HandleAfter``@HandleException`,都在 `ZM\Annotation\Http` 命名空间下。
4. `@HandleBefore` 类似 `@CQBefore`,需要返回 bool 类型值,如果不返回,默认为 true。当为 true 时,则不会阻断执行事件函数本身。
5. 中间件内的函数不可被绑定为注解事件。
6. `@HandleException` 可以写多个,但其中的参数只能写想要捕获的异常的类全称,例如 `\Exception::class` 返回的就是 `\\Exception``\ZM\Exception\InterruptException::class` 返回的是 `ZM\\Exception\\InterruptException`,举的这两个例子这样写都是可以的。
7. 如果 `@HandleException` 有多个的话,则会按照声明顺序依次让其捕获,看其是否为要被捕获的错误的类或父类。例如在最后一个 `@HandleException` 捕获 `\Throwable` 则最终此中间件会捕获所有异常。
8. 中间件内可以正常使用和注解事件执行的内容同一上下文,例如 `@RequestMapping` 下你可以使用 `ctx()->getRequest()``@CQMessage` 可以使用 `ctx()->getMessage()` 等,以此类推。
## 使用中间件
如上图,我们举了一个非常简单的例子,打印出函数执行的时间。我们假设一个需要耗时较长的函数:
```php
/**
* @RequestMapping("/testTime")
* @Middleware("timer")
*/
public function testTime() {
zm_sleep(3); //等待3秒再返回
return "OK!";
}
```
在执行后,你的执行结果可能为:
```
[11:18:56] [I] [#0] Using 3000.07 ms
```
或者,我们也可以将中间件注解写到类上:
```php
/**
* @Middleware("timer")
*/
class Hello {
/**
* @RequestMapping("/test/ping")
*/
public function ping(){
return "pong";
}
/**
* @RequestMapping("/test/ping2")
*/
public function ping2(){
return "pong2";
}
}
```
效果等同于给此类下每个注解事件写一个 `@Middleware`
## 使用多个中间件
多个使用中间件可以同时生效多个流程的中间件。这里要注意,多个中间件中,`@HandleBefore` 方法中如果返回了 `false`,则不会执行接下来的中间件和事件本身要触发的函数,直接跳到最后此中间件的 `@HandleAfter` 方法。
```php
/**
* @CQCommand("你好")
* @Middleware("timer1")
* @Middleware("timer2")
*/
public function hello() { return "成功执行!"; }
```
## 使用中间件捕获异常
通常情况下,如果用户定义的函数内抛出了异常(包括 `message` 等事件),会返回到框架基层去返回默认定义的内容。如果想自己捕获可以使用 `try/catch` ,但不方便复用,多处使用的话就需要重复写代码。这里可以使用中间件的异常处理方便地捕获错误。这个函数写到中间件类里即可
```php
/**
* @HandleException(\Exception::class)
* @param Exception|null $e
*/
public function onThrowing(?Exception $e) {
ctx()->getResponse()->endWithStatus(500, "Error on this.");
}
```
这里的 `@HandleException` 中的参数为要捕获的类名,注意这里面的类名的命名空间需要写全称,不能上面 use 再使用,否则会无法找到异常类。
`context()` 为获取当前协程空间绑定的 `request``response` 对象。

View File

@@ -61,12 +61,12 @@ pecl install swoole
如果你是通过**主机安装 PHP 部署的环境**,下方是通过脚手架来构建项目的命令行。
```bash
git clone https://github.com/zhamao-robot/zhamao-framework-starter.git
cd zhamao-framework-starter/
composer update
composer create-project zhamao/framework-starter zhamao-app
cd zhamao-app/ # 这个是你可以自己定义的名称
vendor/bin/start server # 启动框架
```
如果是通过 **Docker 部署的环境**,则需要在先克隆脚手架后在文件夹内使用 Docker 命令下的 `composer update`
如果是通过 **Docker 部署的环境**,则需要在先克隆脚手架后在文件夹内使用 Docker 命令下的 `composer update`(如果主机环境有 composer 也可以使用 `composer create-project` 的方式拉取脚手架。)
```bash
git clone https://github.com/zhamao-robot/zhamao-framework-starter.git

View File

@@ -1,4 +1,10 @@
# 快速上手 - HTTP 服务器篇
HTTP 服务器篇暂时先放一放,大家应该主要都是奔着机器人开发来的吧~
HTTP 服务器篇主要讲解如何通过炸毛框架来实现微服务、API 通用接口等等这些东西的。
- [HTTP 服务器 - 路由和静态文件篇](/event/route-annotations/)
- [HTTP 服务器 - 存储 - LightCache 轻量缓存](/component/light-cache/)
- [HTTP 服务器 - 存储 - Redis](/component/redis/)
- [HTTP 服务器 - 存储 - MySQL](/component/mysql/)
- [HTTP 客户端](/component/zmrequest/)

View File

@@ -46,20 +46,25 @@ function setCookie(name, value) {
}
s_theme=getCookie("_theme");
if(s_theme === undefined) s_theme = "default";
document.body.setAttribute("data-md-color-scheme", s_theme)
var name = document.querySelector("#__code_0 code span:nth-child(7)")
name.textContent = s_theme
if(s_theme !== undefined) {
document.body.setAttribute("data-md-color-scheme", s_theme)
var name = document.querySelector("#__code_0 code span:nth-child(7)")
name.textContent = s_theme
}
s_primary=getCookie("_primary_color");
document.body.setAttribute("data-md-color-primary", s_primary);
var name2 = document.querySelector("#__code_2 code span:nth-child(7)");
if(s_primary !== null && name2 !== null) name2.textContent = s_primary.replace("-", " ");
if(s_primary !== null) {
document.body.setAttribute("data-md-color-primary", s_primary);
var name2 = document.querySelector("#__code_2 code span:nth-child(7)");
if (s_primary !== null && name2 !== null) name2.textContent = s_primary.replace("-", " ");
}
s_accent=getCookie("_accent_color");
document.body.setAttribute("data-md-color-accent", s_accent);
var name3 = document.querySelector("#__code_3 code span:nth-child(7)");
if(s_accent !== null && name3 !== null) name3.textContent = s_accent.replace("-", " ");
if(s_accent !== null) {
document.body.setAttribute("data-md-color-accent", s_accent);
var name3 = document.querySelector("#__code_3 code span:nth-child(7)");
if (s_accent !== null && name3 !== null) name3.textContent = s_accent.replace("-", " ");
}
setTimeout(() => {
let ls = document.querySelectorAll("chat-box");

View File

@@ -1,5 +1,22 @@
# 更新日志v2 版本)
## v2.1.5
> 更新时间2021.1.13
- 优化:终端对 PHP Warning 和 PHP Notice 的报错信息显示,统一格式
- 新增:`ctx()->getNumArg()` 上下文中快速获取数字类型的参数的方法
- 优化:删除不必要的调试信息
- 优化:路由组件全面替换为 `symfony/routing`,兼容性和稳定性 up
## v2.1.4
> 更新时间2021.1.3
- 修复:启动时会提示丢失类的 bug
- 优化HTTP 响应类如果被使用了则一律返回 false
- 优化PHP Warning 等报错统一样式
## v2.1.3
> 更新时间2021.1.2

View File

@@ -9,6 +9,9 @@ theme:
logo: assets/logos.png
favicon: assets/favicon.png
language: zh
palette:
primary: blue
accent: blue
features:
- navigation.tabs
extra_javascript:
@@ -72,6 +75,18 @@ nav:
- 机器人 API: component/robot-api.md
- CQ 码(多媒体消息): component/cqcode.md
- 上下文: component/context.md
- 存储:
- LightCache 轻量缓存: component/light-cache.md
- MySQL 数据库: component/mysql.md
- Redis 数据库: component/redis.md
- ZMAtomic 原子计数器: component/atomics.md
- SpinLock 自旋锁: component/spin-lock.md
- 协程池: component/coroutine-pool.md
- 单例类: component/singleton-trait.md
- ZMUtil 杂项: component/zmutil.md
- 全局方法: component/global-functions.md
- HTTP 和 WebSocket 客户端: component/zmrequest.md
- Console 终端: component/console.md
- 进阶开发:
- 进阶开发: advanced/index.md
- 从 v1 升级: advanced/to-v2.md

View File

@@ -12,6 +12,7 @@ use ReflectionMethod;
use ZM\Annotation\Http\{HandleAfter, HandleBefore, Controller, HandleException, Middleware, MiddlewareClass, RequestMapping};
use ZM\Annotation\Interfaces\Level;
use ZM\Annotation\Module\Closed;
use ZM\Http\RouteManager;
use ZM\Utils\DataProvider;
class AnnotationParser
@@ -113,7 +114,7 @@ class AnnotationParser
$method_anno->class = $v;
$method_anno->method = $method_name;
if ($method_anno instanceof RequestMapping) {
$this->registerRequestMapping($method_anno, $method_name, $v, $methods_annotations); //TODO: 用symfony的routing重写
RouteManager::importRouteByAnnotation($method_anno, $method_name, $v, $methods_annotations);
} elseif ($method_anno instanceof Middleware) {
$this->middleware_map[$method_anno->class][$method_anno->method][] = $method_anno->middleware;
}
@@ -121,11 +122,6 @@ class AnnotationParser
}
}
}
//预处理4生成路由树换成symfony后就不需要了
$tree = $this->genTree($this->req_mapping);
$this->req_mapping = $tree[0];
Console::debug("解析注解完毕!");
}
@@ -175,109 +171,6 @@ class AnnotationParser
//private function below
private function registerRequestMapping(RequestMapping $vss, $method, $class, $methods_annotations) {
$prefix = '';
foreach ($methods_annotations as $annotation) {
if ($annotation instanceof Controller) {
$prefix = $annotation->prefix;
break;
}
}
$array = $this->req_mapping;
$uid = count($array);
$prefix_exp = explode("/", $prefix);
$route_exp = explode("/", $vss->route);
foreach ($prefix_exp as $k => $v) {
if ($v == "" || $v == ".." || $v == ".") {
unset($prefix_exp[$k]);
}
}
foreach ($route_exp as $k => $v) {
if ($v == "" || $v == ".." || $v == ".") {
unset($route_exp[$k]);
}
}
if ($prefix_exp == [] && $route_exp == []) {
$array[0]['method'] = $method;
$array[0]['class'] = $class;
$array[0]['request_method'] = $vss->request_method;
$array[0]['route'] = $vss->route;
$this->req_mapping = $array;
return;
}
$pid = 0;
while (($shift = array_shift($prefix_exp)) !== null) {
foreach ($array as $k => $v) {
if ($v["name"] == $shift && $pid == ($v["pid"] ?? -1)) {
$pid = $v["id"];
continue 2;
}
}
$array[$uid++] = [
'id' => $uid - 1,
'pid' => $pid,
'name' => $shift
];
$pid = $uid - 1;
}
while (($shift = array_shift($route_exp)) !== null) {
/*if (mb_substr($shift, 0, 1) == "{" && mb_substr($shift, -1, 1) == "}") {
$p->removeAllRoute();
Console::info("移除本节点其他所有路由中");
}*/
foreach ($array as $k => $v) {
if ($v["name"] == $shift && $pid == ($v["pid"] ?? -1)) {
$pid = $v["id"];
continue 2;
}
}
if (mb_substr($shift, 0, 1) == "{" && mb_substr($shift, -1, 1) == "}") {
foreach ($array as $k => $v) {
if ($pid == $v["id"]) {
$array[$k]["param_route"] = $uid;
}
}
}
$array[$uid++] = [
'id' => $uid - 1,
'pid' => $pid,
'name' => $shift
];
$pid = $uid - 1;
}
$array[$uid - 1]['method'] = $method;
$array[$uid - 1]['class'] = $class;
$array[$uid - 1]['request_method'] = $vss->request_method;
$array[$uid - 1]['route'] = $vss->route;
$this->req_mapping = $array;
}
/** @noinspection PhpIncludeInspection */
private function loadAnnotationClasses() {
$class = getAllClasses(WORKING_DIR . "/src/ZM/Annotation/", "ZM\\Annotation");
foreach ($class as $v) {
$s = WORKING_DIR . '/src/' . str_replace("\\", "/", $v) . ".php";
//Console::debug("Requiring annotation " . $s);
require_once $s;
}
$class = getAllClasses(DataProvider::getWorkingDir() . "/src/Custom/Annotation/", "Custom\\Annotation");
foreach ($class as $v) {
$s = DataProvider::getWorkingDir() . '/src/' . str_replace("\\", "/", $v) . ".php";
Console::debug("Requiring custom annotation " . $s);
require_once $s;
}
}
private function genTree($items) {
$tree = array();
foreach ($items as $item)
if (isset($items[$item['pid']]))
$items[$item['pid']]['son'][] = &$items[$item['id']];
else
$tree[] = &$items[$item['id']];
return $tree;
}
private function registerMiddleware(MiddlewareClass $vs, ReflectionClass $reflection_class) {
$result = [
"class" => "\\" . $reflection_class->getName(),

View File

@@ -242,6 +242,14 @@ class Context implements ContextInterface
*/
public function getFullArg($prompt_msg = "") { return $this->getArgs(ZM_MATCH_ALL, $prompt_msg); }
/**
* @param string $prompt_msg
* @return int|mixed|string
* @throws InvalidArgumentException
* @throws WaitTimeoutException
*/
public function getNumArg($prompt_msg = "") { return $this->getArgs(ZM_MATCH_NUMBER, $prompt_msg); }
public function cloneFromParent() {
set_coroutine_params(self::$context[Co::getPcid()] ?? self::$context[$this->cid]);
return context();

View File

@@ -35,6 +35,7 @@ use ZM\Context\Context;
use ZM\Context\ContextInterface;
use ZM\DB\DB;
use ZM\Exception\DbException;
use ZM\Exception\InterruptException;
use ZM\Framework;
use ZM\Http\Response;
use ZM\Module\QQBot;
@@ -300,7 +301,7 @@ class ServerEventHandler
* @param $request
* @param $response
*/
public function onRequest($request, $response) {
public function onRequest(?Request $request, ?\Swoole\Http\Response $response) {
$response = new Response($response);
unset(Context::$context[Co::getCid()]);
Console::debug("Calling Swoole \"request\" event from fd=" . $request->fd);
@@ -345,6 +346,8 @@ class ServerEventHandler
//Console::warning('返回了404');
HttpUtil::responseCodePage($response, 404);
}
} catch (InterruptException $e) {
// do nothing
} catch (Exception $e) {
$response->status(500);
Console::info($request->server["remote_addr"] . ":" . $request->server["remote_port"] .

View File

@@ -255,6 +255,7 @@ class Framework
}
break;
case 'disable-console-input':
case 'no-interaction':
if ($y) $terminal_id = null;
break;
case 'log-error':
@@ -267,6 +268,7 @@ class Framework
if ($y) Console::setLevel(2);
break;
case 'log-verbose':
case 'verbose':
if ($y) Console::setLevel(3);
break;
case 'log-debug':
@@ -277,6 +279,10 @@ class Framework
Console::$theme = $y;
}
break;
default:
//Console::info("Calculating ".$x);
//dump($y);
break;
}
}
if ($coroutine_mode) Runtime::enableCoroutine(true, SWOOLE_HOOK_ALL);

View File

@@ -0,0 +1,35 @@
<?php
namespace ZM\Http;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
use ZM\Annotation\Http\Controller;
use ZM\Annotation\Http\RequestMapping;
class RouteManager
{
/** @var null|RouteCollection */
public static $routes = null;
public static function importRouteByAnnotation(RequestMapping $vss, $method, $class, $methods_annotations) {
if(self::$routes === null) self::$routes = new RouteCollection();
// 拿到所属方法的类上面有没有控制器的注解
$prefix = '';
foreach ($methods_annotations as $annotation) {
if ($annotation instanceof Controller) {
$prefix = $annotation->prefix;
break;
}
}
$route_name = $prefix."/".$vss->route;
$route = new Route($route_name, ['_class' => $class, '_method' => $method]);
$route->setMethods($vss->request_method);
self::$routes->add(md5($route_name), $route);
}
}

View File

@@ -5,57 +5,47 @@ namespace ZM\Utils;
use Co;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
use ZM\Config\ZMConfig;
use ZM\Console\Console;
use ZM\Event\EventManager;
use ZM\Http\Response;
use ZM\Http\RouteManager;
class HttpUtil
{
public static function parseUri($request, $response, $uri, &$node, &$params) {
$uri = explode("/", $uri);
$uri = array_diff($uri, ["..", "", "."]);
$node = EventManager::$req_mapping;
$params = [];
while (true) {
$r = array_shift($uri);
if ($r === null) break;
if (($cnt = count($node["son"] ?? [])) == 1) {
if (isset($node["param_route"])) {
foreach ($node["son"] as $k => $v) {
if ($v["id"] == $node["param_route"]) {
$node = $v;
$params[mb_substr($v["name"], 1, -1)] = $r;
continue 2;
}
}
} elseif ($node["son"][0]["name"] == $r) {
$node = $node["son"][0];
continue;
}
} elseif ($cnt >= 1) {
if (isset($node["param_route"])) {
foreach ($node["son"] as $k => $v) {
if ($v["id"] == $node["param_route"]) {
$node = $v;
$params[mb_substr($v["name"], 1, -1)] = $r;
continue 2;
}
}
}
foreach ($node["son"] as $k => $v) {
if ($v["name"] == $r) {
$node = $v;
continue 2;
}
}
}
$context = new RequestContext();
$context->setMethod($request->server['request_method']);
try {
$matcher = new UrlMatcher(RouteManager::$routes ?? new RouteCollection(), $context);
$matched = $matcher->match($uri);
} catch (ResourceNotFoundException $e) {
if (ZMConfig::get("global", "static_file_server")["status"]) {
HttpUtil::handleStaticPage($request->server["request_uri"], $response);
return null;
}
$matched = null;
} catch (MethodNotAllowedException $e) {
$matched = null;
}
if ($matched !== null) {
$node = [
"route" => RouteManager::$routes->get($matched["_route"])->getPath(),
"class" => $matched["_class"],
"method" => $matched["_method"],
"request_method" => $request->server['request_method']
];
unset($matched["_class"], $matched["_method"]);
$params = $matched;
return true;
} else {
return false;
}
return !isset($node["route"]) ? false : true;
}
public static function getHttpCodePage(int $http_code) {
@@ -83,9 +73,9 @@ class HttpUtil
return true;
}
if (is_dir($path)) {
if(mb_substr($uri, -1, 1) != "/") {
if (mb_substr($uri, -1, 1) != "/") {
Console::info("[302] " . $uri);
$response->redirect($uri."/", 302);
$response->redirect($uri . "/", 302);
return true;
}
foreach ($base_index as $vp) {

View File

@@ -2,13 +2,17 @@
require __DIR__ . "/../vendor/autoload.php";
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Route;
$root = new RouteCollection();
$route = new Route('/foo', array('controller' => 'MyController'));
$routes = new RouteCollection();
$routes->add('route_name', $route);
@@ -35,30 +39,37 @@ $routes->add('date', new Route(
array() // methods
));
$route = new Route('/archive/test');
$routes->add('qwerty', $route);
$route = new Route('/');
$route = new Route('/{aas}/{test}', ['_class' => stdClass::class, '_method' => 'foo'],[],["class" => stdClass::class]);
$routes->add('root', $route);
$context = new RequestContext();
$matcher = new UrlMatcher($routes, $context);
//$root->addCollection($routes);
$matcher = new UrlMatcher($root, $context);
$root->addCollection($routes);
dump($root->all());
//$parameters = $matcher->match('/test/foo');var_dump($parameters);
$parameters = $matcher->match('/archive/2012-01');
var_dump($parameters);
// array(
// 'controller' => 'showArchive',
// 'month' => '2012-01',
// 'subdomain' => 'www',
// '_route' => ...
// )
try {
$parameters = $matcher->match('/fooss/%20');
var_dump($parameters);
} catch (ResourceNotFoundException $e) {
echo $e->getMessage().PHP_EOL;
} catch (MethodNotAllowedException $e) {
}
$parameters = $matcher->match('/');
var_dump($parameters);
$sub = new RouteCollection();