去年了解到多吉云这个CDN 厂商，感觉性价比很高，并且网友使用后的评价也很不错 @二丫讲梵。然后看了一眼现在使用的又拍云 CDN，还有一些余额没有用完。想着还是等用完了这些余额，再迁移到多吉云上去。直到今年十月前后，终于快用完了。但是迁移时发现 Typecho 没有支持多吉云的插件，有一个支持 S3 存储协议的插件，但是阅读完多吉云文档后发现，多吉云的 S3 文档与标准版 S3 规范有一些差异，不能直接使用，所以就有了这个插件。DogeUpload 插件Typecho 多吉云附件上传插件, 版本要求 Typecho >= 1.2.0 版本。直接上传附件到多吉云，且支持更新和删除附件。自定义加速域名。多吉云图片处理后缀支持。时间戳防盗链，支持自定义 Token 有效期。历史资源处理 （将 博客域名自动替换成 CDN 域名）。超轻量级设计，贯彻 Typecho 极简设计理念，插件体积仅 32 KB。代码结构简单、注释完善、使用命名空间，适合作为插件开发参考。下载地址：https://cnb.cool/dbkuaizi/blog/-/tree/main/typecho_plugin下载 "DogeUpload.zip"安装方式：将压缩包中的文件夹 DogeUpload 上传至 Typecho 插件目录 typecho/usr/plugins 下 并在插件面板启用。配置页预览更高性价比的选择博客建立后一直使用又拍云的CDN 服务，去年发现了多吉云这个CDN厂商，了解后得知 多吉云其实是一个 "二道贩子"，他们底层使用 阿里云、腾讯云等国内大厂的 CDN 节点作为基础支撑，得益于量大的原因，他们可以给出比直接购买阿里云、腾讯云CDN 更加实惠的价格。因为我前期一直在使用 又拍云，所以对又拍云的计费规则比较了解，这里就对比一下 又拍云 和 多吉云的成本差异：注：这里以 2025年10月15日 各厂商官网价格为基础作比较，若需核算成本，请以官网价格为准。又拍云云存储：0.0043 元 / GB / 天 (注：可以以当天流量使用情况进行抵扣)CDN 国内流量：0.29 元 / GBHTTPS 请求：0.05 元 / 万次HTTPS 证书免费更新多吉云云存储：0.003 元 / GB / 日CDN 国内流量：0.11 元 / GB （每月前 20 GB 免费）HTTPS 请求：0.04 元 / 万次（每月前 200W 次免费，日流量或云存储超过2TB 可联系客服免除 HTTPS 费用）HTTPS 证书续订目前免费，但官方说后期会收费，默认证书厂商 1元/次，指定厂商2元/次（后面有免费方案）我这里以 月流量 100 GB、云存储 10 GB、HTTPS 月请求数量 60W 来计算两家使用成本的差异（注：如果考虑购买资源包，成本会更低）。费用项目又拍云多吉云备注1. 云存储费用 - 计算公式10GB × 0.0043元/天 × 30天10GB × 0.003元/天 × 30天 - 小计1.29 元0.9 元 2. CDN流量费用 - 计算公式100GB × 0.29元/GB(100GB - 20GB) × 0.11元/GB多吉云每月前20GB免费- 小计29 元8.8 元 3. HTTPS请求费用 - 计算公式60万次 × 0.05元/万次0 元多吉云每月前200万次免费，60万次在免费额度内- 小计3 元0 元 4. 费用抵扣 - 规则存储费可抵扣流量费无额外抵扣规则又拍云特色- 抵扣金额1.29 元 月度最终成本 - 计算公式(CDN费 - 抵扣额) + HTTPS费存储费 + CDN费 + HTTPS费 - 总计(29 - 1.29) + 3 = 30.71 元0.9 + 8.8 + 0 = 9.7 元 大家可以参考这个费用情况核算一下，更换为多吉云后可节省多少钱，基本上都能节省 2/3 左右。HTTPS 证书更新多吉云的 CDN HTTPS 证书与又拍云有些差异，又拍云提供免费与收费的证书，且免费的证书可以免费自动续签。多吉云本身提供免费的 HTTPS 证书，但自动申请证书的服务是收费的（更新成功 默认厂商 1元/次，指定厂商 2元/次），但目前处于公测阶段，限时免费 ，具体可见官方文档： https://docs.dogecloud.com/cdn/manual-config-auto-cert。 现阶段证书是免费更新的，但官方文档中说后续肯定是会收费的，那么每年的成本是多少？以多吉云证书自动更新规则来计算，证书有效期 90 天，到期前 10 天自动更新，则每个证书有效期 记为 80 天，则每年续签 5 次，需要 5 ~10 元的证书更新费用。如果有多个CDN 加速域名，那也是一笔不不少的开销，那么是否有免费方案呢？有的，兄弟！可以自行部署 Certimate（https://docs.certimate.me）服务，这是一个开源的 SSL 证书管理工具，可以帮助你自动申请、部署 SSL 证书，并在证书即将过期时自动续期。先关闭掉 多吉云 HTTPS 证书无忧服务，接着在 Certimate 中设置工作流，每天凌晨检查证书有效期是否小于 5 天，如果是就自动续订，并通过 API 接口，自动部署到多吉云 CDN 服务上。 即可实现 CDN 证书的免费更新服务。
前言目前使用的垃圾评论过滤插件在添加词库时十分繁琐，需要进入插件的设置页面才能添加。但从操作的角度上来说，直接在评论管理页面进行操作是最符合直觉的，所以有了这款插件。功能兼容性要求：依赖 Typecho 1.2.0+ 版本，利用命名空间功能。无需修改数据库结构：通过插件配置表实现拦截功能，安全高效。便捷操作：支持直接在评论管理页面一键添加屏蔽词，操作直观。智能处理：当新增屏蔽关键词时，自动处理符合条件的待审评论，节省人工审核时间。属性联动拦截：插件可自动检测评论中被拉黑的属性。例如，若拉黑某个 IP 地址，可自动屏蔽该 IP 提交的所有评论内容，并将其加入屏蔽词库。详细统计：在插件管理页面显示以下统计信息：已定义的过滤规则总数累计拦截次数自动收集的屏蔽词数量适配移动端：注入拉黑操作按钮时，适配移动端小屏幕自定义提示：多种拦截提示可选，也可自定义提示内容界面评论管理页面拉黑操作插件配置页面下载去码云下载插件
Yii 2 开发文档，主要参考 Yii 2.0 权威指南 两双筷子: https://www.dbkuaizi.comYii 2 安装Composercomposer create-project --prefer-dist yiisoft/yii2-app-basic basicdownload下载地址：https://www.yiiframework.com/download修改 config/web.php 文件，给 cookieValidationKey 配置项 添加一个密钥。（使用 Composre 安装自动完成该步骤）'cookieValidationKey' => '在此处输入你的密钥',内置服务器使用 serve 命令启动 php 内置服务器，启动后可通过：http://localhost:8080/ 访问php yii serve::: alert-info默认情况下会使用 8080 端口，如果需要手动指定端口可以 加上 --port 来指定:::php yii serve --port=8888基础目录结构有部分精简，主要列出核心的目录结构 ├── assets // 目录用于存放前端资源包PHP类 ├── codeception.yml ├── commands // 控制器 ├── config // 应用配置及其他配置 │ ├── console.php // 控制台应用配置信息 │ ├── db.php // 数据库配置文件 │ ├── params.php // 参数配置文件 │ ├── test.php │ ├── test_db.php │ └── web.php // Web 应用配置信息 ├── controllers // 用于存放控制器的文件夹 ├── mail // 与邮件相关的布局文件 ├── models // 模型文件夹 ├── runtime // 记录日志和缓存，要求权限 777 ├── tests // 用于存放一些测试类 ├── vendor // composer 包及 Yii 框架核心 ├── views // 视图目录 ├── web // web服务根目录（同public） │ ├── index.php // 入口文件 ├── widgets // 存放一些常用的小挂件的类文件 ├── yii // yii 控制台入口 （同 think 与 artisan ） └── yii.bat // 与 yii 文件 功能一致，运行时可省略开头的 php 框架请求流程用户向入口脚本 web/index.php 发起请求。入口脚本加载应用配置并创建一个应用 实例去处理请求。应用通过请求组件解析请求的 路由。应用创建一个控制器实例去处理请求。控制器创建一个动作实例并针对操作执行过滤器。如果任何一个过滤器返回失败，则动作取消。如果所有过滤器都通过，动作将被执行。动作会加载一个数据模型，或许是来自数据库。动作会渲染一个视图，把数据模型提供给它。渲染结果返回给响应组件。响应组件发送渲染结果给用户浏览器。模块模块是一个独立的软件单元，由 模型、视图、控制器和其他组件组成，终端用户可以访问应用主体中已安装的模块的控制器，模块应该当作小应用主体来看待，和应用主体不同的是，模块不能单独部署，必须属于某个应用主体。可以看作是 ThinkPHP 的多应用模式模块的目录结构modules // 模块目录 └── admin // admin 模块 ├── controllers // 控制器目录 │ └── DefaultController.php // 模块默认控制器 ├── models // 模型目录 ├── Module.php // 模块 └── views // 视图目录 └── default └── index.php模块构建有两种方法，一种是 使用 Gii 工具创建，另一种是手动创建。Gii 创建模块 参考：https://www.cnblogs.com/liuwanqiu/p/6815817.html以 Admin 模块为例创建模块类<?php modules/admin/Module.php namespace app\modules\admin; class Module extends \yii\base\Module { public $controllerNamespace = 'app\modules\admin\controllers'; public function init() { parent::init(); // 在这里可以引入模块自己的配置项 \Yii::configure($this, require __DIR__ . '/config.php'); } }给模块定义一个控制器// modules/admin/controllers/DefaultController.php <?php namespace app\modules\admin\controllers; use yii\web\Controller; class DefaultController extends Controller { public function actionIndex() { return $this->render('index'); } } 通过命令调用模块php yii <模块名>/<控制器>/<操作>URL 重写Nginx URl 重写if (!-e $request_filename) { rewrite ^/(.*)$ /index.php?s=$1 last; break; }请求 Request请求数据::: alert-warning当获取的参数不存在时，返回 NULL:::GET 请求$request = Yii::$app->request; // 获取所有 get 数据 $request->get(); // 获取 get 中的 id $id = $request->get('id'); // 获取 get 中的 type,如果未传递 就返回 default $type = $request->get('type','default');POST 请求与 GET用法 一致$request = Yii::$app->request; $post = $request->post(); $name = $request->post('name'); $name = $request->post('name','default');所有参数有时候前端传参除了 POST GET 外还会用 POST，PUT，PATCH 等其他方法传递。可以使用 getBodyParam 获取$request = Yii::$app->request; // 返回所有参数 $params = $request->bodyParams; // 返回参数 "id" $param = $request->getBodyParam('id');请求方法获取当前请求使用的HTTP方法，也可以直接使用方法进行判断$request = Yii::$app->request; // 获取当前请求的方法 $request->method; if ($request->isAjax) { /* 该请求是一个 AJAX 请求 */ } if ($request->isGet) { /* 请求方法是 GET */ } if ($request->isPost) { /* 请求方法是 POST */ } if ($request->isPut) { /* 请求方法是 PUT */ }请求URLYii 提供了一些成员属性用来获取 URL 的信息，我们以 http://example.com/admin/index.php/product?id=100 为例：属性名说明$request->url获取URL，不包含主机部分：返回 /admin/index.php/product?id=100$request->absoluteUrl获取完整URL返回：http://example.com/admin/index.php/product?id=100$request->hostInfo获取主机部分，返回 http://example.com$request->pathInfo获取入口文件之后的路径部分，返回 /product$request->queryString获取 问号后面的部分，返回 id=100$request->baseUrl获取 主机之后，入口脚本之前的内容，返回 /admin$request->scriptUrl获取没有路径信息和查询字符串的部分，返回 /admin/index.php$request->serverName获取主机名称，返回 example.com$request->serverPort获取客户端接口Header 头获取 Header 的方法$headers = Yii::$app->request->headers; // 获取 header 头中，Accept 的值 $headers->get('Accept'); // 判断 header 头是否存在 $headers->has('Token');客户端信息可以通过 userHost 和 userIP 来获取客户端的 信息// 获取主机名 Yii::$app->request->userHost; // 获取 ip Yii::$app->request->userIP;数据库操作数据库配置在配置文件中 配置数据的 dsn 以及账户密码'db' => [ 'class' => 'yii\db\Connection', 'driverName' => 'mysql', 'dsn' => ' mysql:host=localhost;dbname=mydatabase', 'username' => 'root', 'password' => '', ],配置完成之后就可以使用 Yii::$app->db 来操作数据库了创建数据库连接如果只需要在某个地方单独使用数据库，创建一个 yii\db\Connection 实例来与之建立连接。$db = new yii\db\Connection([ 'dsn' => 'mysql:host=localhost;dbname=example', 'username' => 'root', 'password' => '', 'charset' => 'utf8', ]);查询数据库查询多条数据返回二维数组，如果查询为空返回空数组。Yii::$app->db->createCommand('SELECT * FROM post')->queryAll();查询单条返回一维数组，为空则返回空数组Yii::$app->db->createCommand('SELECT * FROM post WHERE id=1')->queryOne();查询一列返回一维数组，为空则返回空数组Yii::$app->db->createCommand('SELECT title FROM post')->queryColumn();查询一个只获取一个字段的时候，有点像tp中的 value()$count = Yii::$app->db->createCommand('SELECT COUNT(*) FROM post')->queryScalar();绑定参数创建 SQL 的时候，可以通过绑定参数来防止 SQL 注入。$parame = ['username' => '两双筷子']; // 批量绑定 Yii::$app->db->createCommand('SELECT * FROM `user` WHERE username =:username',$parame) ->queryAll(); // 或 Yii::$app->db->createCommand('SELECT * FROM `user` WHERE username =:username') ->bindValues($parame)->queryAll();也可以使用一个预处理语句实现多次查询$command = Yii::$app->db->createCommand('SELECT * FROM `user` WHERE username =:username'); $query1 = $command->bindValue('username','张三')->queryOne(); $query2 = $command->bindValue('username','李四')->queryOne();通过引用绑定参数$username = ''; // 要先声明变量 $command = Yii::$app->db->createCommand('SELECT * FROM `user` WHERE username =:username') ->bindParam(':username',$username); $username = '张三'; $query1 = $command->queryOne(); $username = '李四'; $query2 = $command->queryOne();非查询语句对数据进行非查询操作需要使用 execute() 方法，返回影响行数。Yii::$app->db->createCommand('UPDATE `gm_managers` SET username = "王五" WHERE id = 20') ->execute();对于 INSERT() 、UPDATE()、DELETE() 语句，可以不用写 SQL 而直接使用相应的方法来构建SQL。插入单条数据插入单条数据，返回影响行数。// INSERT("表名",[字段数组]) Yii::$app->db->createCommand() ->insert('user', ['username' => '赵六','age' => 18]) ->execute();插入多条数据批量插入多条数据// batchInsert("表明",[列数组]，[插入数组]) Yii::$app->db->createCommand()->batchInsert('user', ['name', 'age'], [ ['Tom', 30], ['Jane', 20], ['Linda', 25], ])->execute();更新数据更新数据，返回影响行数// UPDATE("表名",[更新字段],[条件数组]|”条件字符串“) Yii::$app->db->createCommand() ->update('user', ['username' => '赵六'],['id' => 20]) ->execute();删除数据删除数据，返回影响行数// DELETE(”表名“,"条件") Yii::$app->db->createCommand() ->delete('user', 'status = 0') ->execute();注意： 上面只是构建 SQL 语句，最后在调用 execute() 时才会执行。执行事务自动事务执行出错时将自动回滚。Yii::$app->db->transaction(function($db) { $db->createCommand($sql1)->execute(); $db->createCommand($sql2)->execute(); });手动事务通过手动事务可以更精准的进行错误控制$db = Yii::$app->db; $transaction = $db->beginTransaction(); try { $db->createCommand($sql1)->execute(); $db->createCommand($sql2)->execute(); $transaction->commit(); } catch(\Exception $e) { $transaction->rollBack(); throw $e; } catch(\Throwable $e) { $transaction->rollBack(); throw $e; }指定隔离级别默认情况下直接使用数据库设置的隔离级别，Yii 也支持设定其他的事务隔离级别。$isolationLevel = \yii\db\Transaction::REPEATABLE_READ; // 自动事务设定 Yii::$app->db->transaction(function ($db) { .... }, $isolationLevel); // 手动事务 设定 $transaction = Yii::$app->db->beginTransaction($isolationLevel);注意： 隔离级别是针对整个链接设置的，后续的事务操作即使没有指定，也会使用相同的隔离级别。查询构建器使用 查询构造器可以更灵活的构建SQL，同时也提高了 SQL 的安全性。SELECT指定SELECT 子句$query = new Query(); // 以下的写法都是等同的 $query->select('user.id AS user_id, email'); $query->select(['user.id AS user_id', 'email']); $query->select(['user_id' => 'user.id', 'email']); // 追加 SELECT 子句 $query->addSelect(['email']);子查询从 Yii 2.0.1 开始，就可以使用子查询来嵌套构建SQL了$subQuery = (new Query())->select('COUNT(*)')->from('user'); // SELECT `id`, (SELECT COUNT(*) FROM `user`) AS `count` FROM `post` $query = (new Query())->select(['id', 'count' => $subQuery])->from('post');Distinct调用 distinct 过滤重复行// SELECT DISTINCT `user_id` ... $query->select('user_id')->distinct();FROM通过 form() 方法指定 SQL 中的 FROM 子句// SELECT * FROM `user` $query->from('user'); // 以下的写法都是等同的 $query->from('public.user u, public.post p'); $query->from(['public.user u', 'public.post p']); $query->from(['u' => 'public.user', 'p' => 'public.post']);FROM 中使用子查询$subQuery = (new Query())->select('id')->from('user')->where('status=1'); // SELECT * FROM (SELECT `id` FROM `user` WHERE status=1) u $query->from(['u' => $subQuery]);WHERE使用 where() 可以构建 WHERE 子句，支持以下四种格式：字符串格式：status=1哈希格式：["status" => 1,"type" => 2]操作符格式：['like' 'name' ,'test']对象格式：new LikeCondition('name','LIKE','test')字符串格式$query->where('status=1'); // 或使用参数绑定来绑定动态参数值 $query->where('status=:status', [':status' => $status]); // 原生 SQL 在日期字段上使用 MySQL YEAR() 函数 $query->where('YEAR(somedate) = 2015'); // 使用 addParams 进行参数绑定 $query->where('status=:status')->addParams([':status' => $status]);哈希格式将多个数组条件使用 AND 拼接起来，使用哈希格式，Yii 会在内部对相应的值进行参数绑定。// ...WHERE (`status` = 10) AND (`type` IS NULL) AND (`id` IN (4, 8, 15)) $query->where([ 'status' => 10, 'type' => null, 'id' => [4, 8, 15], ]);操作符格式操作符格式可以指定类程序任意风格的条件语句：["操作符", "操作数1", "操作数2" ...]每一个操作数都可以是 字符串格式、哈希格式或嵌套的操作符格式，而操作符可以是下面中的一个：操作符用法说明AND字符串：['and', 'id=1', 'id=2']哈希：['AND',['name' => "张三"],['age' => 18]]AND 查询OR哈希：['or',['name' => "张三"],['age' => 18]]OR 查询NOT['not', ['status' => 'draft', 'name' => 'example'],['delete_time' => null]]NOT 取反查询BETWEEN['between', 'id', 1, 10]范围查找 id 1~ 10NOT BETWEEN['not between', 'id', 1, 10]查找不在 1~10 范围IN['in', 'id', [1, 2, 3]]in 查询NOT IN['not in', 'id', [1, 2, 3]]not inLIKE全模糊：['like','name',"张三"]右模糊：['like','name',"张三%",false] 查询数组 ['like', 'name', ['test', 'sample']]模糊查询OR LIKE['or like','name',["张三","李四"]]多个 LIKE 查询 使用 or 拼接NOT LIKE['not like','name','张三']模糊查询 取反OR NOT LIKE['or not like','name',["张三","李四"]]多个 not like 查询 使用 or 拼接EXISTS['exists',(yii\db\Query 实例的子查询)]EXISTS 查询NOT EXISTS['not exists',(yii\db\Query 实例的子查询)]NOT EXISTS 查询>,<,>=,<=['>','age',18]比较运算符，生成 age > 18追加条件可以使用 andWhere() 或 orWhere() 在原有条件的基础上追加额外的条件，可以多次调用追加不同的条件。$query->where(['status' => 1]); $search Yii::$app->request->get('title'); if (!empty($search)) { $query->andWhere(['like', 'title', $search]); } // ... WHERE (`status` = 10) AND (`title` LIKE '%yii%')过滤条件使用 filterWhere() 方法可以过滤掉 值为空的条件（ 当一个值为 null、空数组、空字符串或者一个只包含空格的字符串时，那么它将被判定为空值）。$query->from('users'); $username = '张三'; $email = ''; $query->filterWhere([ 'name' => $username, 'email' => $email, ]); $result = $query->createCommand()->getRawSql(); // SELECT * FROM `users` WHERE `name`='张三'同样可以使用 andFilterWhere() 和 orFilterWhere() 追加过滤条件。此外可以使用 andFilterCompare() 可以根据值中的内容智能地确定运算符；$query->andFilterCompare('name', 'John Doe'); $query->andFilterCompare('rating', '>9'); $query->andFilterCompare('value', '<=100');也可以显式的指定运算符：$query->andFilterCompare('name', 'Doe', 'like');HAVINGYii 从2.0.11 版本开始，提供了 HAVING 条件的构建方法：$query->having(['age' => 20]); // 追加 AND $query->andHaving(['>', 'age', 30]); // 追加 or $query->orHaving(['>', 'age', 30]); $query->filterHaving(['name' => null, 'age' => 20]); $query->andFilterHaving();OrderBy使用 ORDER BY 子句来构建排序语句// ... ORDER BY `id` ASC, `name` DESC $query->orderBy([ 'id' => SORT_ASC, 'name' => SORT_DESC, ]); // 简单的排序可以直接使用字符串声明 $query->orderBy('id ASC, name DESC');groupBy()用来指定分组，简单的字段名称，你可以使用字符串来声明它。$query->groupBy(['id', 'status']); $query->groupBy('id, status');limit 与 offset用来指定 SQL 语句当中 的 LIMIT 和 OFFSET 子句，如果设定了一个错误的值比如 负值，则会忽略这个语句// ... LIMIT 10 OFFSET 20 $query->limit(10)->offset(20);JOIN用来指定SQL 中的 JOIN 子句。$query->join('type', 'table', 'on','params');type：连接类型，例如 INNER JOIN，LEFT JOIN、RIGHT JOINtable：连接表名称on：连接条件，只能用 字符串格式的 Where 条件params：绑定参数，on用的可以分别调用如下的快捷方法来指定 INNER JOIN, LEFT JOIN 和 RIGHT JOIN。$query->innerJoin('post', 'post.user_id = user.id'); $query->leftJoin('post', 'post.user_id = user.id'); $query->rightJoin('post', 'post.user_id = user.id');除了连接表之外，也可以使用子查询：$subQuery = (new \yii\db\Query())->from('post'); $query->leftJoin(['u' => $subQuery], 'u.id = author_id');查询方法查询构造器提供了一套用于查询不同数据的方法all()多条查询，返回二维数组one() 返回结果集的第一行记录column() 返回结果集的第一列scalar() 返回结果集的第一行第一列标量值exists() 返回一个表示该查询是否包含结果集的值。count() 返回 COUNT 查询的结果其他方法：sum($q)、average($q)、max($q)、min(q) 等。$q 是必选字段，可以是一个字段也可以是一个表达式。indexBy使用 all() 查询数据，会返回一个整数的索引数组，有时候我们想要指定某个字段做这个数组的键。可以在 all() 方法之前调用 indexBy() 方法，并传递一个字段或表达式// 使用字段做key $query = (new \yii\db\Query()) ->from('user') ->limit(10) ->indexBy('id') ->all(); // 使用闭包函数的返回值做 key $query = (new \yii\db\Query()) ->from('user') ->indexBy(function ($row) { return $row['id'] . $row['username']; })->all();批量查询处理当处理大数据的时候，使用all()一次性将所有数据加载在内存上就不太合适了，Yii 提供了批量查询功能，MySQL 先保存查询结果，然后 Yii 使用游标分批获取数据。use yii\db\Query; $query = (new Query()) ->from('user') ->orderBy('id'); foreach ($query->batch() as $users) { // $users 是一个包含100条或小于100条用户表数据的数组 } // or to iterate the row one by one foreach ($query->each() as $user) { // 数据从服务端中以 100 个为一组批量获取， // 但是 $user 代表 user 表里的一行数据 }MySQL中批量查询的局限性打印 SQL可以使用 createCommand() 函数返回的对象打印 SQL$query = new Query(); $command = $query->select(['id', 'email'])->from('user')->createCommand(); // 打印 SQL 语句 echo $command->sql; // 或者使用 getRawSql() 函数 echo $command->getRawSql(); // 打印被绑定的参数 print_r($command->params);模型操作Yii2 提供了一个面向对象模型类，用以访问和操作数据库中的数据。模型基类Yii2 中基础模型类 yii\base\Model，官方基于这个类提供了很多高级模型，例如 yii\db\ActiveRecord 。而基类自身提供如下特性：属性模型通过属性代表业务数据，就像Thinkphp 及 Laravel 那样，结果集既可以对象访问，也可以数组访问。$FilmModel = \app\models\Film::findOne(1); echo $FilmModel->title; echo $FilmModel['title'];场景一个模型可能会在多个场景下使用，在不同的场景下我们希望校验不同的值，比如我们注册用户的时候邮箱是必填的，但登录的时候却不是。这种情况下可以配合验证规则实现不同场景的数据校验。设置场景// 场景作为属性来设置 $model = new User; $model->scenario = 'login'; // 场景通过构造初始化配置来设置 $model = new User(['scenario' => 'login']);验证规则 核心验证器（Core Validators）当接收到了用户提交的数据，可以通过验证规则来进行校验，而不需要手动判断。使用验证$model = new \app\models\ContactForm; // 用户输入数据赋值到模型属性 $model->attributes = \Yii::$app->request->post('ContactForm'); if ($model->validate()) { // 所有输入数据都有效 all inputs are valid } else { // 验证失败：$errors 是一个包含错误信息的数组 $errors = $model->errors; }定义验证规则 如果想让校验在所有场景下都生效，去掉 on 即可。public function rules() { return [ // 在"register" 场景下 username, email 和 password 必须有值 [['username', 'email', 'password'], 'required', 'on' => 'register'], // 在 "login" 场景下 username 和 password 必须有值 [['username', 'password'], 'required', 'on' => 'login'], ]; }块赋值块赋值只用一行代码将用户所有输入填充到一个模型，非常方便， 它直接将输入数据对应填充到 yii\base\Model::attributes() 属性。$model = new \app\models\ContactForm; $model->attributes = \Yii::$app->request->post('ContactForm');安全属性因为块赋值会将用户提交的所有数据都放模型中，有时候我们只希望用户修改部分数据，这时可以通过设置 安全属性来实现（个人理解就是块赋值的黑白名单） 在登陆场景下允许提交 用户名和密码，注册场景下，允许提交 用户名、密码和邮箱。通过别名为 safe 的验证器来表示这些字段是安全的public function scenarios() { return [ 'login' => ['username', 'password'], 'register' => ['username', 'email', 'password'], ]; }不安全属性如果要表示不安全属性，则在属性前面加一个 ! 即可。public function rules() { return [ [['username', 'password', '!secret'], 'required', 'on' => 'login'] ]; }不安全的属性在块赋值的时候会被过滤掉，如果需要必须手动指定。$model->secret = $secret;快捷填充使用 load() 填充可以简化代码：例如：if (isset($_POST['FormName'])) { $model->attributes = $_POST['FormName']; if ($model->save()) { // handle success } }简化写法：if ($model->load($_POST) && $model->save()) { // handle success }声明模型类声明一个模型类 需要继承 yii\db\ActiveRecord 类。::: alert-warning需要注意的是 Yii2 提供的 base 只是一个基类，并没有模型的操作方法，所以我们需要继承的是 yii\db\ActiveRecord 类，而不是 yii\base\Model 类:::<?php namespace app\models; use yii\db\ActiveRecord; class Film extends ActiveRecord { }设置表名称设置模型的表名称有两种方式，第一种就是使用模型类名，第二种方式是手动指定一个表名。使用模型类名类名使用大驼峰，例如：类名表名UseruserAdminUseradmin_user手动指定// 手动指定表名称 public static function tableName() { return 'film'; } // 手动指定表名并使用前缀 public static function tableName() { return '{{%film}}'; }指定数据库如果想在这个类中单独使用其他数据库配置，可以重写 getDb()方法：public static function getDb() { // 使用 "db2" 组件 return \Yii::$app->db2; }查询数据Find使用 Active Query 查询数据，这种操作和直接使用查询生成器差不多，唯一的区别在于 使用 find()方法获取查询生成器对象，而不是 new 一个新对象。 // 返回 ID 为 123 的客户： // SELECT * FROM `customer` WHERE `id` = 123 $customer = Customer::find() ->where(['id' => 123]) ->one(); // 取回所有活跃客户并以他们的 ID 排序： // SELECT * FROM `customer` WHERE `status` = 1 ORDER BY `id` $customers = Customer::find() ->where(['status' => Customer::STATUS_ACTIVE]) ->orderBy('id') ->all(); // 取回活跃客户的数量： // SELECT COUNT(*) FROM `customer` WHERE `status` = 1 $count = Customer::find() ->where(['status' => Customer::STATUS_ACTIVE]) ->count(); // 以客户 ID 索引结果集： // SELECT * FROM `customer` $customers = Customer::find() ->indexBy('id') ->all();findOne 与 findAll根据主键获取 id 是比较常用的功能，所以 Yii 提供了两个快捷方法：findOne() 查询单条数据findAll() 查询多条数据这两个方法接受如下传参：整数值：这个值会当作主键去查询，Yii 通过读取数据库信息来识别主键列。整数数组：主键的 IN 查询关联数组：需要传递一个哈希格式的条件数组。// 返回 id 为 123 的客户 // SELECT * FROM `customer` WHERE `id` = 123 $customer = Customer::findOne(123); // 返回 id 是 100, 101, 123, 124 的客户 // SELECT * FROM `customer` WHERE `id` IN (100, 101, 123, 124) $customers = Customer::findAll([100, 101, 123, 124]); // 返回 id 是 123 的活跃客户 // SELECT * FROM `customer` WHERE `id` = 123 AND `status` = 1 $customer = Customer::findOne([ 'id' => 123, 'status' => Customer::STATUS_ACTIVE, ]); // 返回所有不活跃的客户 // SELECT * FROM `customer` WHERE `status` = 0 $customers = Customer::findAll([ 'status' => Customer::STATUS_INACTIVE, ]);::: alert-danger如果你需要将用户输入传递给这些方法，请确保输入值是标量或者是 数组条件，确保数组结构不能被外部所改变：// yii\web\Controller 确保了 $id 是标量 public function actionView($id) { $model = Post::findOne($id); // ... } // 明确了指定要搜索的列，在此处传递标量或数组将始终只是查找出单个记录而已 $model = Post::findOne(['id' => Yii::$app->request->get('id')]); // 不要使用下面的代码！可以注入一个数组条件来匹配任意列的值！ $model = Post::findOne(Yii::$app->request->get('id'));:::原生SQL除了查询生成器之外，模型还支持使用原生SQL查询，使用 findBySql() 方法，需要注意的是 findBySql()后面追加的其他查询方法都会被忽略$sql = 'SELECT * FROM customer WHERE status=:status'; $customers = Customer::findBySql($sql, [':status' => Customer::STATUS_INACTIVE])->all();访问数据查询的数据被返回在模型实例中，可以通过模型属性来访问结果集。// "id" 和 "email" 是 "customer" 表中的列名 $customer = Customer::findOne(123); $id = $customer->id; $email = $customer->email;修改器与获取器官方的叫法是“数据转换”，但在其他框架中更通用的名字叫做“获取器”。class Customer extends ActiveRecord { // 设置获取器 public function getBirthdayText() { return date('Y/m/d', $this->birthday); } // 设置修改器 public function setBirthdayText($value) { $this->birthday = strtotime($value); } }asArray虽然 Yii 中的模型操作十分灵活，但如果查询大量数据时，对象会造成大量的内存占用。我们可以在查询前调用 asArray() 方法来转换为数组，以节省内存。$customers = Customer::find()->asArray()->all();分批获取数据使用分批获取数据，可以最小化内存使用。// 每次获取 10 条客户数据 foreach (Customer::find()->batch(10) as $customers) { // $customers 是个最多拥有 10 条数据的数组 } // 每次获取 10 条客户数据，然后一条一条迭代它们 foreach (Customer::find()->each(10) as $customer) { // $customer 是个 `Customer` 对象 } // 贪婪加载模式的批处理查询 foreach (Customer::find()->with('orders')->each() as $customer) { // $customer 是个 `Customer` 对象，并附带关联的 `'orders'` }保存数据save()Active Record 类提供了 save 方法，可以轻松的将数据存入数据库。// 插入新记录 $customer = new Customer(); $customer->name = 'James'; $customer->email = 'james@example.com'; $customer->save(); // 更新已存在的记录 $customer = Customer::findOne(123); $customer->email = 'james@newexample.com'; $customer->save();插入还是写入数据，这取决于模型实例是通过 new 获取的还是通过查询获取的。:::alert-info在保存时如果确认数据不需要校验，可以通过 save(false) 的方式来跳过验证过程。:::insert()插入数据，返回布尔值 表示成功还是失败$customer = new Customer; $customer->name = $name; $customer->email = $email; $customer->insert();update()更新数据，返回影响行数:::alert-w需要注意的是，更新可能不会影响表中的任何行。 在该情况下，此方法有可能返回 0。 因此，需要使用 !== false 的方式判断 update() 是否成功：:::$customer = Customer::findOne($id); $customer->name = $name; $customer->email = $email; $customer->update();更新计数在数据库中经常会遇到需要自增或自减的场景，我们将这些列称之为计数列，可以使用 updateCounters() 更新一个或多个计数列。如果是递减 使用负值即可。$post = Post::findOne(100); // UPDATE `post` SET `view_count` = `view_count` + 1 WHERE `id` = 100 $post->updateCounters(['view_count' => 1]);:::alert-warning如果使用 save() 更新一个计数列，可能会因为并发请求导致出现错误的结果。:::脏属性调用 save() 保存数据的时候，只有“脏属性”即被修改的属性才会被保存。需要注意的是，无论如何 Active Record 都会执行数据验证 不管有没有脏属性。Active Record 类会自动维护一个脏属性列表，并且保存所有属性的旧值，并于新值比较（使用 === 比较）。可以通过 getDirtyAttributes() 来获取当前的脏属性，也可以通过 markAttributeDirty('field_name') 将属性显式的标注为脏属性。如果需要获取原先的旧值，可以通过 getOldAttributes() 来一次性获取所有旧值，或使用 getOldAttribute('field_name') 获取单个旧值。批量更新上面所说的保存数据是针对单条的，而批量更新多条数据时我们可以使用 updateAll() 方法。updateAll($attributes, $condition = '', $params = [])// UPDATE `customer` SET `status` = 1 WHERE `email` LIKE `%@example.com%` Customer::updateAll(['status' => Customer::STATUS_ACTIVE], ['like', 'email', '@example.com']);批量更新计数计数列也可以通过批量的方式来更新：updateAllCounters($counters, $condition = '', $params = [])// UPDATE `customer` SET `age` = `age` + 1 Customer::updateAllCounters(['age' => 1]);删除数据删除单条返回 删除的行数，如果由于某种原因删除失败，则为 false。 注意，即使删除执行成功，删除的行数也可能为 0。$customer = Customer::findOne(123); $customer->delete();删除多条批量删除数据，传参是删除条件，返回删除行数。Customer::deleteAll(['status' => 1]);:::alert-danger需要注意的是，Yii2 模型方法中没有软删除，如果批量删除条件出错，可能会导致数据表被清空。:::生命周期通过模型的生命周期，可以在不同的节点调用想要执行的代码，具体参考：Active Record 的生命周期下面以 Yii2 的删除方法为例，实现软删除的扩展：class Film extends ActiveRecord { public function beforeDelete() { if (!parent::beforeDelete()) { return false; } // 如果存在软删除字段，就修改软删除状态,并返回false if(isset($this->is_delete)) { $this->is_delete = 1; $this->save(); return false; } // 如果不存在 就走正常删除 return true; } }:::alert-info调用以下方法，不会触发任何生命周期，因为这些方法是直接操作数据库而不是 ActiveRecord 模型：updateAll()deleteAll()updateCounters()updateAllCounters():::事务操作Yii 包含两种事务方法。显式事务$customer = Customer::findOne(123); Customer::getDb()->transaction(function($db) use ($customer) { $customer->id = 200; $customer->save(); // ...其他 DB 操作... }); // 或 $transaction = Customer::getDb()->beginTransaction(); try { $customer->id = 200; $customer->save(); // ...other DB operations... $transaction->commit(); } catch(\Exception $e) { $transaction->rollBack(); throw $e; } catch(\Throwable $e) { $transaction->rollBack(); throw $e; }::: alert-warning上面的 \Exception 用于兼容 PHP5，如果开发环境是 PHP7+，则可以跳过这部分。:::自动事务在模型中的 transactions() 方法里声明需要事务支持的DB操作：class Customer extends ActiveRecord { public function transactions() { return [ 'admin' => self::OP_INSERT, 'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE, // 上面等价于： // 'api' => self::OP_ALL, ]; } }key 是场景，描述了在不同场景下的事务要求：OP_INSERT 执行插入操作时触发事务OP_UPDATE 执行更新操作时触发事务OP_DELETE 执行删除操作时触发事务多个操作使用 | 拼接，如果需要全部触发，OP_ALL 执行 插入、更新、删除操作时都会触发。自动事务的执行逻辑是：相应的事务在调用 beforeSave() 方法时开启， 在调用 afterSave() 方法时被提交。模型关联模型管理以 MySQL 官方示例库 sakila 为例，展示模型关联用法。一对一关联语法：return $this->hasOne($class, $link);$class 关联类$link 主外键按约束，数组格式。key是关联表字段 ，value 是主表字段return 返回关联查询对象// 电影演员表模型 class FilmActor extends ActiveRecord { // 一对一关联 获取演员信息 public function getInfo() { return $this->hasOne(Actor::class, ['actor_id' => 'actor_id']); } }一对多关联语法：return $this->hasMany($class, $link);$class 关联类$link 主外键按约束，数组格式。key是关联表字段 ，value 是主表字段return 返回关联查询对象// 电影表模型 class Film extends ActiveRecord { // 一对多关联，获取电影演员 public function getActor() { return $this->hasMany(FilmActor::class, ['film_id' => 'film_id']); } }访问关联数据定义好关联关系后，我们就可以通过关联属性来访问数据了。而 Yii2 提供了两种方法用以访问这些数据：通过方法直接通过关联方法名获取的是一个 ActiveRecord 查询对象。$FilmModel = Film::findOne(1); var_dump($FilmModel->getActor()); 通过属性如果通过属性获取，则定义关联时方法名必须是 'getXxx()' 的命名形式，否则无法获取到属性。使用时 去掉 前缀get，并且首字母小写，例：getActor() -> actor、getAcTor-> acTor一对多关联时，通过属性获取到的是一个二维数组，数组的元素是 yii\db\BaseActiveRecord 对象。$FilmModel = Film::findOne(1); foreach ($FilmModel->actor as $key => $value) { var_dump($value->info->first_name); }动态关联查询由于关联方法返回的是 ActiveRecord 查询对象，所以你可以在这个对象上继续进行查询操作。需要注意的是，每次动态的关联查询时，都会执行SQL语句。$FilmModel = Film::findOne(1); // 获取结果中的第一列 var_dump($FilmModel->getActor()->column()); // 获取关联表的 数组数据 var_dump($FilmModel->getActor()->asArray()->all()); // 查询演员id 小于50 的数据 $actorModel = $FilmModel->getActor()->where(['<','actor_id','50'])->all(); var_dump($actorModel);也可以通过传参的方式简化动态查询我们以查询演员id 小于50的为例。class Film extends ActiveRecord { // 获取id小于某个值的演员， public function getMinActor($number = 50) { return $this->hasMany(FilmActor::class, ['film_id' => 'film_id']) ->where(['<','actor_id',$number]); } } $FilmModel->getMinActor(40)->asArray()->all()::: alert-w如果需要通过属性获取，则必须给关联方法设置一个默认值。:::多对多关联在日常的查询中，当两张表的关系是 多对多，通常会使用一个中间表来关联。例如，一个电影有多个演员，而一个演员也会出演多部电影。那么演员表 actor 和电影表 film 之间就会有一个电影演员关系表 film_actor。Yii2 有两种方法声明 多对多关联，一种是使用 via() 引用现有的一对多关联方法，另一种是使用 viaTable() 来直接声明中间表。viaTable语法：viaTable($tableName, $link, callable $callable = null);$tableName 中间表名$link 主表与中间表关联条件$callable 一个 PHP 的回调，用于定制与连接表相关的关联。class Film extends ActiveRecord { // 多对多关联 public function getActorInfo() { // hasMany 第二个参数是 演员表和电影演员关系表的关联关系 return $this->hasMany(Actor::class, ['actor_id' => 'actor_id']) ->viaTable('film_actor', ['film_id' => 'film_id']); } via使用已有的一对多关联方法来指定连接表class Film extends ActiveRecord { // 多对多关联 public function getActorInfo() { return $this->hasMany(Actor::class, ['actor_id' => 'actor_id']) ->via('actor'); } // 一对多关联，获取电影演员 public function getActor() { return $this->hasMany(FilmActor::class, ['film_id' => 'film_id']); }多表连续关联通过使用 via() 方法们可以通过多个表来定义关联声明。比如 一个演员会出演多部电影 ，每部电影会有不同的分类，我们可以通过如下方法获取：class Actor extends ActiveRecord { // 通过电影类别中间表关联类别 public function getActorCate() { return $this->hasMany(Category::class,['category_id' => 'category_id']) ->via('filmCate'); } // 关联电影的类别中间表 public function getFilmCate() { return $this->hasMany(FilmCategory::class, ['film_id' => 'film_id']) ->via('film'); } // 演员关联电影 public function getFilm() { return $this->hasMany(FilmActor::class,['actor_id' => 'actor_id']); } }预加载前面有提到 访问关联数据的时候才会执行SQL，这样就会存在一个性能问题，比如下面这段代码，执行了101次SQL查询。// SELECT * FROM `customer` LIMIT 100 $customers = Customer::find()->limit(100)->all(); foreach ($customers as $customer) { // 每次循环都会被执行一次 // SELECT * FROM `order` WHERE `customer_id` = ... $orders = $customer->orders; }我们可以使用 with()方法进行预加载，这时需要执行的 SQL 语句只有两条。// SELECT * FROM `customer` LIMIT 100; // SELECT * FROM `orders` WHERE `customer_id` IN (...) $customers = Customer::find() ->with('orders') ->limit(100) ->all(); foreach ($customers as $customer) { // 没有任何的 SQL 执行 $orders = $customer->orders; }with各种用法// 即时加载 "orders" and "country" $customers = Customer::find()->with('orders', 'country')->all(); // 等同于使用数组语法 如下 $customers = Customer::find()->with(['orders', 'country'])->all(); // 即时加载“订单”和嵌套关系“orders.items”，这个嵌套层级是没有限制的，可以“a.b.c.d” $customers = Customer::find()->with('orders.items')->all();使用预加载的时候可以通过闭包函数实现条件过滤：// 查找所有客户，并带上他们国家和活跃订单 // SELECT * FROM `customer` // SELECT * FROM `country` WHERE `id` IN (...) // SELECT * FROM `order` WHERE `customer_id` IN (...) AND `status` = 1 $customers = Customer::find()->with([ 'country', 'orders' => function ($query) { $query->andWhere(['status' => Order::STATUS_ACTIVE]); }, ])->all();::: alert-warning如果你在即时加载的关联中调用 select() 方法，你要确保 在关联声明中引用的列必须被 select。:::其他操作可以参考 Yii2 的模型文档：活动记录（Active Record）数据库迁移通过数据库迁移，可以记录数据库结构的变化，使数据库可以和源代码一样受版本控制。迁移命令提交迁移命令： yii migrate 该操作会列出所有未提交的迁移，如果确定要提交这些迁移，将会按照类名中的时间戳，逐个运行里面的 up() 方法或 safup() 方法。其中任意一个迁移执行失败（比如执行报错），那么这条命令将会退出并停止剩下未执行的迁移。每次迁移成功，都会在数据库的 migration 表中添加一条迁移成功的记录，用于判断那些迁移是已提交的，那些是未提交的。有时候篇，我们只想提交前几个迁移，而不是提交所有可用迁移。下面的命令则表示只提交前三个可用迁移：php yii migrate 3提交特定迁移也可以只提交特定的迁移，使用 migrate/to 命令。yii migrate/to 150101_185401 # 使用时间戳来指定迁移 yii migrate/to "2015-01-01 18:54:01" # 使用一个可以被 strtotime() 解析的字符串 yii migrate/to m150101_185401_create_news_table # 使用全名 yii migrate/to 1392853618 # 使用 UNIX 时间戳如果指定的迁移前面还有未提交的迁移，则未提交的迁移将会被提交。如果指定的迁移在之前已经被提交过，则在其之后的迁移将会被还原。创建迁移命令：yii migrate/create <name>可以使用此命令创建一个新的迁移文件，创建的迁移文件类名将按照 m<YYMMDD_HHMMSS>_<Name> 的格式自动生成，其中：<YYMMDD_HHMMSS> 指执行创建迁移命令的 UTC 时间。<Name> 指创建迁移命令所带的 Name 参数值创建命令执行后会生成一个类文件，里面包含 up() 和 down() 方法。当提交迁移文件时 up() 将会被执行，当还原迁移时 down() 将会被提交。如果迁移文件无法被还原时，例如删除了一条记录，则应该在 down() 方法中返回 false 来表示这个 migration 是无法恢复的。还原迁移命令：yii migrate/down 可以通过此命令还原之前提交的一个或多个迁移：yii migrate/down # 还原最近一次提交的迁移 yii migrate/down 3 # 还原最近三次提交的迁移重做迁移命令：yii migrate/redo重做的意思是，先还原指定的迁移，然后再次提交。yii migrate/redo # 重做最近一次提交的迁移 yii migrate/redo 3 # 重做最近三次提交的迁移刷新迁移命令：yii migrate/fresh删除数据表中所有的表和外键，从头开始重新提交所有迁移。列出迁移可以通过命令列出已提交了那些或未提交那些迁移：yii migrate/history # 显示最近10次提交的迁移 yii migrate/history 5 # 显示最近5次提交的迁移 yii migrate/history all # 显示所有已经提交过的迁移 yii migrate/new # 显示前10个还未提交的迁移 yii migrate/new 5 # 显示前5个还未提交的迁移 yii migrate/new all # 显示所有还未提交的迁移修改迁移历史有时候我们只需要标记一下你的数据库升级到一个特定的迁移，而不是实际提交或还原迁移。这个经常发生在已经手动修改了数据库的结构，而又不想相应的迁移被重复提交，那么你可以使用如下命令来达到目的：yii migrate/mark 150101_185401 # 使用时间戳来指定迁移 yii migrate/mark "2015-01-01 18:54:01" # 使用一个可以被 strtotime() 解析的字符串 yii migrate/mark m150101_185401_create_news_table # 使用全名 yii migrate/mark 1392853618 # 使用 UNIX 时间戳自定义迁移可以通过很多方法来自定义迁移命令。命令行选项迁移命令附带了几个命令行选项，可以用自定义它的行为：interactive 布尔值（默认为true）指定是否通过交互模式来提示用户将执行那些迁移，如果希望在后台执行该命令，应该设置为 false。migrationPath 字符串|数组（默认值为 @app/migrations） 指定存放迁移类文件的目录。该选项可以是路径，也可以是路径别名。需要注意的是指定的目录必须存在，否则将会触发一个错误。从 2.0.12 版本开始，可以使用数组来指定多个来源读取迁移文件。migrationTable 字符串（默认值为 migration）指定用于存储迁移历史信息的数据库表名称，如果这张表不存在，那么迁移命令将自动的创建这张表。db 字符串（默认值为 db） 用于指定被该命令迁移的数据库templateFile：字符串 (默认值为 @yii/views/migration.php)， 指定生产迁移框架代码类文件的模版文件路径。fields 由用来创建迁移代码的多个字段定义字符串所组成的数组。默认是 []。 字段定义的格式是 COLUMN_NAME:COLUMN_TYPE:COLUMN_DECORATOR。例如，--fields=name:string(12):notNull 会创建 一个长度为 12 的，非空的，字符串类型的字段。
前段时间工作中遇到了一个字符串变量提取的场景，通过正则实现了，感觉实现思路蛮有意思的，就分享一下。前言对接过国内短信发送平台的开发者，应该知道大部分短信平台都需要使用 "短信模板"来发送短信。既：在短信服务提供商那边申请一个模板，在发送短信时只需要传递模板中的变量即可。例如短信内容是："您的验证码是：123456，5分钟内有效，请勿泄露"。在短信平台中模板格式是："您的验证码是：${code}，${time}分钟内有效，请勿泄露"我们只需要向短信平台传递 {"code":123456,"time":5} 这样一段请求参数，再由短信平台将参数替换为短信正文，最后发送到用户手机上。需求我们的业务系统本身需要对接多个短信平台，有的短信平台要求直接使用短信模板，有的短信平台要求直接传递短信原文。为了统一短信组件的调用，我们在业务中统一向短信发送组件传递短信原文，再由短信发送组件根据不同短信平台要求，拆出短信变量。利用模板变量尝试通过正则实现这个功能，步骤如下：通过匹配变量标签，提取出模板中所以的 key，拿到一个由 key 组成的一维数组接着将模板中变量标签部分的内容，用正则替换成另一个正则表达式，用这个正则提取短信原文中的值最后将 第一步的数组 key 和第二步的数组 value 组合成一个新的数组，实现短信变量的提取。使用一个相对复杂的例子，实现代码如下：<?php $template = '尊敬的${name}你好，您的订单${on}将于${y}年${m}月${d}过期，请及时通过${pay}续费，支付金额：${money} ${unit}。'; $content = '尊敬的张三你好，您的订单144514将于2023年8月21过期，请及时通过支付宝续费，支付金额：2.34 RMB。'; // {"name":"张三","on":"144514","y":"2023","m":"8","d":"21","pay":"支付宝","money":"2.34","unit":"RMB"} echo json_encode(GetTempParam($template,$content),JSON_UNESCAPED_UNICODE); /** * @param $template 第三方短信平台模板 * @param $content 需要发送的短信原文 * @param $identifier 第三方平台变量标签 * @return array 返回变量数组 */ function GetTempParam($template, $content, $identifier = ['${', '}']) { /** * 模板编写注意：变量不能连续、不可嵌套、若包含正则语法 需要转义(自动) 例如："尊敬的\[$name\]您好" * 模板: 尊敬的${name}您好，您的订单${on}将于${y}年${m}月${d}过期，请及时通过${pay}续费，支付金额：${money} ${unit}。 * 内容：尊敬的张三您好，您的订单114514将于2023年8月21过期，请及时通过支付宝续费，支付金额：2.33 RMB。 * 标签：['${', '}'] */ // 标签转义为 ['\${', '}'] $identifier = array_map('quotemeta', $identifier); // 第一步：使用 /(?<=\${)(.*?)(?=})/ 提取变量 keys ['name','on','y' ...] $pattern = sprintf('/(?<=%s)(.*?)(?=%s)/', $identifier[0], $identifier[1]); preg_match_all($pattern, $template, $temp_keys); // 如果没找到 key 就没有变量 if (empty($temp_keys[1])) return []; /** * 第二步：将模板标签替换为 (.*),结果当作正则匹配内容 * $content_pattern = 尊敬的(.*)你好，您的订单(.*)将于(.*)年(.*)月(.*)过期，请及时通过(.*)续费，支付金额：(.*) (.*)。 * $content_vals[0] = ['张三','114514','2023' ...] ,注意 若 模板不匹配 则为 null */ $content_pattern = preg_replace(sprintf('/%s(.*?)%s/', $identifier[0], $identifier[1]), '(.*)', $template); preg_match_all('/' . $content_pattern . '/', $content, $content_vals, PREG_SET_ORDER); // 如果为 0 就是没匹配上 大概率是内容错了 if (empty($content_vals[0])) { return false; } // 去掉多余的匹配内容 array_shift($content_vals[0]); // ["name"=>"张三","on"=>"114514","y"=>"2023"...] return array_combine($temp_keys[1], $content_vals[0]); }
前言出于网站安全性的考虑，想在 Typecho 管理后台登录时加一个基于 OTP 的多因素认证功能，网上找了半天没有合适的扩展，唯一找到的一个扩展还不支持多用户，于是就自己写了一个。依赖要求：Typecho 1.2.0+特点：界面简洁、支持多用户更新记录2025-09-12修复因命名空间冲突，导致OTP 配置读取失败，导致验证不通过的问题2024-02-01解决所有表单都添加 otp 输入框的bug2024-01-24修复 PHP 8.2 + 语法不兼容的错误提示 Deprecated: Using ${var} in strings is deprecated, use {$var} 调整 OTP 动态码 input 框的挂载方式，尽量兼容更多后台模板使用说明插件开启后，在登录页面会多一个动态码输入框，需要输入账户密码时同时输入OTP 动态码(令牌)进行校验，校验失败时会提示相应错误信息。绑定 OTP 密钥在个人设置页面，使用身份验证器扫码或手动绑定，扫描后输入六位动态码进行验证。绑定成功后密钥固定不再变化，未绑定时每次加载个人页面随机生成密钥（若怀疑密钥已泄露不安全，则解绑后重新绑定即可）。otpauth 信息中包含站点名称、当前用户名称，扫码添加后效果如下：解绑 OTP 认证绑定成功后再进入个人设置页面，可以校验六位验证码，可以进行解绑操作。登录认证插件开启后，后台登陆页面会增加一个动态码输入框，已绑定的后台用户在登陆时需要校验 OTP 动态码，未绑定的用户则不会校验，为空正常登录即可。插件安装去码云下载插件将压缩包中的文件夹Login2FA 上传至 typecho 插件目录 typecho/usr/plugins在插件管理页面启用插件 "Login2FA"插件配置第一个是 OTP 认证码的时间窗口期，主要用于解决客户端和服务器时间戳不一致的问题。第二个为一个防呆设计，因为 Typecho 插件卸载后插件的所有数据都将删除，这就会导致所有人的 OTP 密钥全部丢失，即使重新开启插件也无法恢复，为了防止不小心点到禁用按钮，需要在设置页面改为确认卸载后才允许卸载插件。
作为一个服务端语言，PHP不可避免会有在程序中执行外部程序的需求，那么这篇文章就总结一下 PHP 执行外部程序的函数以及需要注意的坑。执行运算符 与 shell_exec()通过 shell 环境执行命令，并且将完整的输出以字符串的方式返回。PHP 执行运算符，使用反引号表示。PHP 会尝试将反引号的内容当做 Shell 命令来执行，并将其输出的信息返回（既，外部程序的执行输出会返回给而不是输出到终端）。执行运算符与函数 shell_exec() 相同（可以理解为是别名的关系）。 需要注意的是，在 php.ini 中如果禁用了 shell_exec() 函数，则执行运算符也会失效。 参数说明shell_exec(string $cmd): string || nullcmd 要执行的命令，与双引号一样的是，执行运算符支持解析变量。返回值外部程序的标准输出，如果执行过程中发生错误或进程不产生输出，则返回 null。 因为不产生输出和发生错误都会返回 null，所以，使用此函数的返回值无法检测外部程序是否执行成功，如果想获取程序执行的退出码，可以使用 exec() 函数的 return_var 参数。 例子<?php // 执行运算符与 shell_exec 效果一样 var_dump(`ls /home`); var_dump(shell_exec('ls /home')); // 执行错误的命令 输出 Null var_dump(shell_exec('ls /homes')); ?>执行后终端输出如下内容：dbkuaizi-alpine:/home# php test/demo.php string(13) "project test " string(13) "project test " ls: /homes: No such file or directory NULLsystem执行外部程序，并显示外部程序的输出结果。如果运行在 PHP 服务器模块中，system() 函数还会尝试在每行输出完毕后，自动刷新 Web 服务器输出的缓存。参数说明system(string $command[, int &$return_var = ?]): stringcommand 要执行的命令return_var 如果提供此参数，则外部命令执行后的状态将会被设置在此变量中。返回值成功则返回命令输出的最后一行，失败则返回 false。 若要判断命令是否成功执行，需要要判断状态码是否为 0，不要通过返回值是否等于 false 来判断执行是否成功。因为实际测试中，执行失败的返回值是空字符串。 例子<?php $a = system('ls /home',$b); var_dump($a,$b); echo '------'.PHP_EOL; $c = system('ll /home',$d); var_dump($c,$d); ?>输出：dbkuaizi-alpine:/home# php test/demo.php project test string(4) "test" int(0) ------ sh: ll: not found string(0) "" int(127)exec执行一个外部程序。参数说明exec(string $command[, array &$output = ?, int &$return_var = ?]): stringcommand 要执行的命令output 如果提供了此参数，那么会用执行命令的输出填充此数组，每行输出填充数组的一个元素。需要注意的是 exec() 函数会在数组末尾追加函数，所以在多次调用的情况下，请在 exec() 函数调用，对数组使用 unset() 进行重置。return_var 命令执行后的返回状态将会被写入到此变量中。返回值例子<?php $a = exec('ls /home',$b,$c); var_dump($a,$b,$c); echo '------'.PHP_EOL; $a = exec('ls /usr',$b,$c); var_dump($a,$b,$c); ?>输出dbkuaizi-alpine:/home# php test/demo.php string(4) "test" array(2) { [0]=> string(7) "project" [1]=> string(4) "test" } int(0) ------ string(5) "share" array(8) { [0]=> string(7) "project" [1]=> string(4) "test" [2]=> string(3) "bin" [3]=> string(3) "lib" [4]=> string(7) "libexec" [5]=> string(5) "local" [6]=> string(4) "sbin" [7]=> string(5) "share" } int(0) 由于 $output 是追加写入到数组的末尾，所以如果多次调用 一定要在调用之前 unset() 重置这个变量。 passthru执行外部程序并显示原始输出，输出所执行命令的二进制数据。诸如执行 pbmplus 之类的可以直接输出图像流的命令，通过设置 Content-type 为 image/gif， 然后调用 pbmplus 程序输出 gif 文件， 就可以从 PHP 脚本中直接输出图像到浏览器。参数说明passthru(string $command[, int &$result_code = null]): ?boolcommand 要执行的命令result_code 若提供此参数，则命令执行返回的状态码将会被记录到此参数中返回值成功返回 null，失败返回 false。 实际测试时，成功失败返回的都是 null。若要判断是否执行成功，请使用状态码判断。 例子<?php $a = passthru('ls /home',$b); var_dump($a,$b); echo '------' . PHP_EOL; $c = passthru('ls /homec',$d); var_dump($c,$d) ?>输出：dbkuaizi-alpine:/home# php ./test/demo.php project test NULL int(0) ------ ls: /homec: No such file or directory NULL int(1) dbkuaizi-alpine:/home# 需要特别注意的是，如果是在 Web 方式下执行上述的函数，当外部程序执行耗时大于 PHP 设定的超时时间，则会直接结束进程，不会有任何返回值。 参考文档：https://www.php.net/manual/zh/book.exec.php笔记作者：两双筷子博客：www.dbkuaizi.com
前言我第一次知道 amis 这个项目应该在 19 年的时候，那会 amis 才开源时间不长，除了百度内部用之外，几乎没有听过那家公司有使用案例，所以也没有做过多关注（即使到了现在各种程序员社交平台上的讨论也绝不算多）。后来在深圳一家公司的工作经历接触到了 LowCode 这个概念，相应的对 amis 也有了一定的关注，但还是因为没有听过太多公司的应用案例，所以也没想过 用在日常的项目开发中。 然后就到了去年（2021年）在深圳找工作面试过程中，通过与面试官交流技术栈，发现有一些公司有使用 amis 去构建自己的一些中后台项目。之后就有了用 Amis 构建一个后台项目的想法。Tpamis在项目的开发初期发现目前没有 基于 PHP 和 Amis 这两个技术栈的开源项目，甚至基于 Amis 的衍生开源项目也很少。于是就打算将写的这个项目开源，即使直接使用我这个开源项目的人不多，也希望可以提供一些借鉴的方案。就这样经过半年的迭代项目的功能也逐渐完善，取名 Tpamis 开源到了 github 和 gitee 上。关于项目的具体信息，可以关注：https://gitee.com/dbkuaizi/tpamis 和 https://github.com/dbkuaizi/tpamis 这里不做赘述。随着 Tpamis 开发的过程，对 amis 的了解也逐渐的加深，用一句话来总结就是: Amis 像是一座功能完善的围城，在城内提供了绝对数量的组件可以满足 80% 甚至 90% 的功能。但 Amis 没有的功能组件，对于后端来说就是无法实现的。(毕竟有专业前端支持的项目，又为什么会选择 Amis 呢)。举几个例子：Tpamis 没有多标签页的功能，并不是我不想做进去，而是通过 jssdk 的开发方式，不支持这个功能。在移动端模式下，个人主页和退出登录按钮是显示在页面菜单上面的， 原因也是因为 amis jssdk 没有提供相应的功能。当你遇到上面的问题时，有两种解决方法，一种是像我一样换一个方式实现这个功能，另一种就是找前端去写 react 的代码，扩充 api 或者扩充组件的功能。amis 的优缺点经过 tpamis 的开发，我把我认为的优点和缺点总结一下，希望可以对你是否选择 amis 提供一些参考。优点足够多的组件支持，按照官方的说法 目前提供了 120+ 组件，如果只是做内部的中后台管理系统，组件绝对是够用的状态。JSON 化构建页面，整个过程中无需前端人员参与，后端开发者通过 JSON 配合 amis 官方文档，即可实现页面。Echarts 支持，由于是百度的中后台项目，所以对 Echarts 支持度很好。有大厂背书，更新频率也不算低，不用担心忽然不维护。缺点组件即围城，正如我上面所说，如果你需要一个组件目前没有支持的功能，那么要么选择 react 自行开发，要么选择通过其他组件来实现这个功能，不过好在组件很多，总有可以替代的方式。JSON 构建页面，JSON 是个双刃剑，既降低页面的开发难度，也限制了页面的可能性，如果你需要在实现一个比较复杂的功能，那么你需要写出很复杂的JSON 来实现它，例如 Tpamis 的登录页面，就是混合了 HTML、JS、CSS、JSON 才实现的效果。社区活跃度极差，在开发的过程中遇到解决不了的问题（组件Bug、文档不完善等），加过官方的交流群、也提过 Issues，但基本上都没有任何回应。也就是说 一旦你开发中遇到了问题，95% 的可能性只能自己解决。细小的 Bug，组件数量达到这个量级，出现 bug 也是在所难免的。但问题在于 amis 开源版的使用者大部分都是纯后端开发者，没有能力去修复这些bug 只能等待官方处理。（这里大家可以去看一下 amis 的 issues，很多 bug 的提问并没有得到官方的答复：https://github.com/baidu/amis/issues ）总结Amis 好用吗？如果你们项目对后台的需求只有增删改查，那么无疑 Amis 的很不错的一个选择，减少了后端开发写页面的负担。我会在工作中选择 Amis 吗？目前为止，我不太建议在工作中使用 Amis 来开发正式项目。毕竟一个没有社区积极的支持，出了问题只能靠自己。我不想在自己负责的项目中引入这些隐患。对于 Amis 还有其他选择吗？ Amis 其实是百度爱速搭项目的一个衍生开源项目，爱速搭毕竟是商业项目，必然有着更好的商业支持，也是一个不错的选择。上面的这些内容只是出于在这个阶段对于 amis 的感受，或许在后期的迭代中，Amis会有更完善的生态体系，让它变成一个不错的选择。
github地址：https://github.com/QThans/jwt-auth安装 jwt 组件composer require thans/tp-jwt-auth生成密钥php think jwt:create自定义中间件<?php declare (strict_types = 1); /** * 登录状态中间件，用来处理jwt的登录状态 */ namespace app\middleware; use thans\jwt\exception\JWTException; use thans\jwt\exception\TokenBlacklistException; use thans\jwt\exception\TokenBlacklistGracePeriodException; use thans\jwt\exception\TokenExpiredException; use thans\jwt\middleware\JWTAuth; use think\exception\HttpException; class Auth extends JWTAuth { /** * 处理请求 * * @param \think\Request $request * @param \Closure $next * @return Response */ public function handle($request, \Closure $next) { try { $payload = $this->auth->auth(); } catch (TokenExpiredException $e) { // 捕获token过期 // 尝试刷新token，会将旧token加入黑名单 try { $this->auth->setRefresh(); $token = $this->auth->refresh(); $payload = $this->auth->auth(false); } catch (TokenBlacklistGracePeriodException $e) { $payload = $this->auth->auth(false); } catch (JWTException $exception) { // 如果捕获到此异常，即代表 refresh 也过期了，用户无法刷新令牌，需要重新登录。 // throw new HttpException(401, $exception->getMessage()); throw new HttpException(401, 'refresh 也过期了'); } } catch (TokenBlacklistGracePeriodException $e) { // 捕获黑名单宽限期 $payload = $this->auth->auth(false); } catch (TokenBlacklistException $e) { // 捕获黑名单，退出登录或者已经自动刷新，当前token就会被拉黑 throw new HttpException(401, '当前用户未登录'); } catch (JWTException $e){ throw new HttpException(401, 'Token不能为空'); } // 可以获取 payload里自定义的字段，比如uid $request->user_id = $payload['uid']->getValue(); // 如果有新的token，则在响应头返回（前端判断一下响应中是否有 token，如果有就直接使用此 token 替换掉本地的 token，以此达到无痛刷新token效果） if (isset($token)) { $response = $next($request); $response = $response->header(['Access-Control-Expose-Headers' => 'Authorization,ExportFileName']); return $this->setAuthentication($response, $token); } return $next($request); } }
做这款插件的目的就是为了能够在 Typecho 后台查看一些服务器上的信息，具体的功能可以去看之前发的文章：https://www.dbkuaizi.com/archives/121.html更新内容使用了命名空间，这意味着不支持 Typecho 1.1 版本。（作为 PHP 开发者来说，能用命名空间简直是太爽了，瞬间感觉回到了一个现代的 PHP 项目）修复了 cpu 百分比计算结果过长的问题 （例如：CPU：0.98999999999 %），更新后只显示小数点后两位。下载地址去码云下载插件将压缩包中的文件夹LinuxInfo 上传至 typecho 插件目录 typecho/usr/plugins在 管理端 的插件管理页面启用插件 "LinuxInfo"
假设这么一个场景: 有一个通过 Yii ActiveForm 构建的表单，里面所有的字段都是虚拟的，比如说 这个表单的字段来自于一个 JSON 或者说数据库的结果集，需要实现同一个模型，根据不同的传参，构建不同的表单项。问题： Yii 的表单是通过模型来创建的，也就是说我有这个表单项，模型中必须有对应的字段（成员属性），否则就会报错。且这个成员属性在模型实例化之后是不允许添加的，那么我们如何去动态的添加这些表单项的字段？解决思路： 既然模型字段都是不存在的，我们干脆用一个虚拟的模型，不是说这个模型文件不存在，而是指模型文件不对应任何实际的数据表。那么我们只需要在实例化模型的时候，传入动态的字段，然后在对应的 rules() 、attributes() 、attributeLabels() 方法里面返回我们的动态字段，就可以实现需要的效果。模型部分创建一个虚拟的模型，重写构造方法，传入字段，赋值给成员属性 $field<?php namespace app\models; use Attribute; use yii\db\ActiveRecord; class Actor extends ActiveRecord { public $field; public function __construct($field) { $this->field = $field; parent::__construct([]); } public function rules() { return [ [$this->field, 'required'] ]; } public function attributes() { return $this->field; } }控制器部分<?php namespace app\controllers; use Yii; use Yii\base\Controller; use app\models\Actor; class TestController extends Controller { public function actionIndex() { /** * 动态创建的字段，在实例化模型的时候传递进去 * 这里只是为了测试，实际应用的时候可以是从其他途径来的数组: * 比如 JSON 动态转换、数据库查询出的结果集等等 */ $field = ['username','json','token']; $model = new Actor($field); if (Yii::$app->request->getIsGet()) { return $this->render('index',['model' => $model]); } // 装载数据 $model->load(Yii::$app->request->post()); // 获取模型提交的数据 var_dump($model->getDirtyAttributes()); } }视图部分<?php use yii\helpers\Html; use yii\widgets\ActiveForm; $form = ActiveForm::begin([ 'id' => 'login-form', 'options' => ['class' => 'form-horizontal'], ]) ?> <? foreach($model->field as $field) { ?> <?= $form->field($model, $field) ?> <? }?> <?= Html::submitButton('submit', ['class' => 'btn btn-primary']) ?> <?php ActiveForm::end() ?>效果展示构建的表单提交后打印的数据