From ce741919474ac67bc2bd386c30e77827acf4c8e5 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Tue, 5 Jan 2021 16:19:35 +0800 Subject: [PATCH] update docs --- docs/component/atomics.md | 65 ++++++++++ docs/component/context.md | 133 +++++++++++++++++++- docs/component/cqcode.md | 35 ++++++ docs/component/light-cache.md | 226 ++++++++++++++++++++++++++++++++++ docs/component/mysql.md | 97 +++++++++++++++ docs/component/redis.md | 62 ++++++++++ docs/component/spin-lock.md | 73 +++++++++++ docs/event/middleware.md | 166 ++++++++++++++++++++++++- mkdocs.yml | 6 + src/ZM/Context/Context.php | 8 ++ 10 files changed, 866 insertions(+), 5 deletions(-) create mode 100644 docs/component/atomics.md create mode 100644 docs/component/light-cache.md create mode 100644 docs/component/mysql.md create mode 100644 docs/component/redis.md create mode 100644 docs/component/spin-lock.md diff --git a/docs/component/atomics.md b/docs/component/atomics.md new file mode 100644 index 00000000..0b0fbe39 --- /dev/null +++ b/docs/component/atomics.md @@ -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 +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 官方的文档,这里就不一一列举了。 + diff --git a/docs/component/context.md b/docs/component/context.md index 64e3ae57..e4db3258 100644 --- a/docs/component/context.md +++ b/docs/component/context.md @@ -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分钟不理我了,嘤嘤嘤 -## 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; +} +``` + + +) test +( 请输入你要传入的参数内容 +) test2 +( 参数内容:test2 + + +`getArgs()` 也有三层封装,在使用过程中避免麻烦的话,推荐使用下面这几种 `get*Arg()` 方式。 + +## getFullArg() + +获取关键词后的整个字符串参数,包括空格,如果不存在则询问。 + +典型例子:`复读机 你好 你好`,获取参数时会将 `你好 你好` 当作一个参数来获取。 + +```php +/** + * @CQCommand("test") + */ +public function argTest1() { + $s = ctx()->getFullArg("请输入你要传入的参数内容"); + return "参数内容:".$s; +} +``` + + +) test abc def argtest +( 参数内容:abc def argtest +) test +( 请输入你要传入的参数内容 +) abc def +( 参数内容:abc def + + +## getNextArg() + +获取下一个参数,分隔符可以是空格,tab。 + +```php +/** + * @CQCommand("test") + */ +public function argTest1() { + $s = ctx()->getNextArg("请输入你要传入的参数内容"); + return "参数内容:".$s; +} +``` + + +) test abc def argtest +( 参数内容:abc +) test +( 请输入你要传入的参数内容 +) abc +( 参数内容:abc + + +## getNumArg() + +> 2.1.5 版本起可用。 + +获取下一个数字型参数,如果 `is_numeric()` 为 true 则获取成功,如果没有符合的则询问用户。 + +```php +/** + * @CQCommand("test") + */ +public function argTest1() { + $s = ctx()->getNextArg("请输入你要传入的数字内容"); + return "数字参数内容:".$s; +} +``` + + +) test abc 334 argtest +( 数字参数内容:334 +) test abc +( 请输入你要传入的数字内容 +) 998 +( 参数内容:998 + ## copy() -t +获取整个上下文的所有内容的数组形式。 + +```php +$arr = ctx()->copy(); +dump($arr); +``` ## getOption() - 获取匹配参数内容 +```php +/** + * @CQCommand("test") + */ +public function argTest1() { + return "参数内容:".implode(", ", ctx()->getOption()); +} +``` + +) test abc 334 argtest +( 参数内容:abc, 334, argtest + \ No newline at end of file diff --git a/docs/component/cqcode.md b/docs/component/cqcode.md index 993ac69e..8acbe257 100644 --- a/docs/component/cqcode.md +++ b/docs/component/cqcode.md @@ -76,6 +76,41 @@ class Hello { [ https://zhamao.xin/file/hello.jpg +## CQ 码操作 + +### CQ::decode() + +CQ 码字符反转义。 + +| 反转义前 | 反转义后 | +| -------- | -------- | +| `&` | `&` | +| `[` | `[` | +| `]` | `]` | + +```php +$str = CQ::decode("[我只是一条普通的文本]"); +// 转换为 "[我只是一条普通的文本]" +``` + +### CQ::encode() + +转义 CQ 码的敏感符号,防止 酷Q 把不该解析为 CQ 码的消息内容当作 CQ 码处理。 + +```php +$str = CQ::encode("[CQ:我只是一条普通的文本]"); +// $str: "[CQ:我只是一条普通的文本]" +``` + +### CQ::removeCQ() + +去除字符串中所有的 CQ 码。 + +```php +$str = CQ::removeCQ("[CQ:at,qq=all]这是带表情的全体消息[CQ:face,id=8]"); +// $str: "这是带表情的全体消息" +``` + ## CQ 码列表 ### CQ::face() - 发送 QQ 表情 diff --git a/docs/component/light-cache.md b/docs/component/light-cache.md new file mode 100644 index 00000000..50d44dfa --- /dev/null +++ b/docs/component/light-cache.md @@ -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 ? "内容存在!" : "内容不存在!"); +} +``` + + +) store +( OK! +) storeAfterRemove +( 内容存在! +^ 等待 30 秒 +( 内容不存在! + + +### 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")); +} +``` + + +^ 我在 2021-01-05 15:21:00 发送这条消息 +) store +( OK! +^ 这时我用 Ctrl+C 停止框架,过一会儿再启动 +) getStore +( 存储时间:2021-01-05 15:21:00 + + +### 数据加锁 + +在特定情况下,使用 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 "

It works!

"; +} +``` + +我们使用压测工具,例如 `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` 这样的代码。 + + +解决这一问题,就需要用到锁。这种情况下,我们首先考虑的是自旋锁,框架也因此内置了一个方便使用的自旋锁组件。详见下一章:自旋锁。 + diff --git a/docs/component/mysql.md b/docs/component/mysql.md new file mode 100644 index 00000000..3492c3cc --- /dev/null +++ b/docs/component/mysql.md @@ -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 注入的问题。 \ No newline at end of file diff --git a/docs/component/redis.md b/docs/component/redis.md new file mode 100644 index 00000000..e76f6af0 --- /dev/null +++ b/docs/component/redis.md @@ -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()` 方法里面的时候,后面的代码不会提前执行,是顺序执行的。回调的作用仅仅是用作自动回收连接对象。 \ No newline at end of file diff --git a/docs/component/spin-lock.md b/docs/component/spin-lock.md new file mode 100644 index 00000000..ab2d1c39 --- /dev/null +++ b/docs/component/spin-lock.md @@ -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 "

It works!

"; +} +``` + +加两行就 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。 \ No newline at end of file diff --git a/docs/event/middleware.md b/docs/event/middleware.md index aefa537c..653210d4 100644 --- a/docs/event/middleware.md +++ b/docs/event/middleware.md @@ -1,3 +1,167 @@ # 中间件注解 -TODO:师傅,莫催,快肝完了! \ No newline at end of file +对于 `@RequestMapping` 等注解绑定的事件函数,还支持中间件,可以完成 Session 会话、认证、日志记录等功能。中间件是用于控制 `请求到达` 和 `响应请求` 的整个流程的。从一定意义上来说相当于切面编程(AOP)。 + +在炸毛框架中,中间件最直白的意思就是注解事件执行前、执行后、执行过程中可进行插入代码但不破坏原有代码。 + +```伪代码 +@中间件1 +@带条件的注解1 +function 我的方法() { + blablabla... +} +//插入中间件,下面是执行流程 +-> 判断注解1的执行条件是否为true +-> 中间件1的前置插入代码 +-> 我的方法 +-> 中间件1的后置插入代码 +X -> 我的方法有异常时执行中间件1的异常处理 + +//不插入中间件,下面是执行流程 +-> 判断注解1的执行条件是否为true +-> 我的方法 +X -> 有异常则直接跳到最外层被框架捕获 +``` + +中间件和事件分发器是紧密相连的,炸毛框架的内部分发器在分发注解事件的过程中会判断将要执行的事件是否含有中间件,框架内部执行流程图见下一章:事件分发器。 + +## 定义中间件 + +下方就是一个可以在终端打印路由函数运行的总时间的中间件,只需给中间件标明里面的 `@MiddlewareClass` 到中间件的类上就可以了。 + +```php +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` 对象。 + diff --git a/mkdocs.yml b/mkdocs.yml index 1b546e60..10da54d1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,12 @@ 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 - 进阶开发: - 进阶开发: advanced/index.md - 从 v1 升级: advanced/to-v2.md diff --git a/src/ZM/Context/Context.php b/src/ZM/Context/Context.php index e1de0e1e..fc77e004 100644 --- a/src/ZM/Context/Context.php +++ b/src/ZM/Context/Context.php @@ -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();