出于安全考虑，去年开发了一个 OTP 多因素认证插件，后面就一直想着开发通行密钥插件，这样就不需要输入密码了，点两下就能登录进来，于是就花了两个小时使用 GPT-5.3-Codx 写了这个插件，不得不说 AI 还是太好用了。功能支持多用户的 PassKey 插件（通行密钥、WebAuthn 说的都是它）,支持 Typecho 1.2.0 + 的版本。支持自定义通行密钥名称一个用户支持创建多个通行密钥使用安装插件后，去个人设置页面绑定一个通行密钥通行密钥绑定后 就可以在登录时使用下载去 CNB 下载这个插件https://cnb.cool/dbkuaizi/typecho/passkey/-/releases或者直接下载代码仓库：https://cnb.cool/dbkuaizi/typecho/passkey注意：releases 发布版本 中的 zip 包已经包含 PassKey 插件目录，如果是直接下载的代码仓库，需要自行创建 usr/plugins/PassKey 目录
最近使用 1Panel 面板部署 FastAdmin 项目用于测试，站点的伪静态选择 thinkphp，在部署完成后发现用户端可以正常访问，而管理端则会无限制重定向到 login/index，最终提示 “重定向次数过多...” 如下图所示：经过排查 发现问题出现在 1Panel 自带的 fastcgi-php.conf 上这个文件上，如图 21 行:解决方案是注释掉这一行，然后使用 fastadmin 官方推荐的 fastcgi 配置，代码如下：location ~ [^/]\.php(/|$) { fastcgi_pass 127.0.0.1:9000; # include fastcgi-php.conf; # 注释掉这一行或者直接复制我的示例代码 include fastcgi_params; fastcgi_index index.php; fastcgi_split_path_info ^((?U).+\.php)(/?.+)$; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }保存修改的配置后，fastadmin 管理端入口即可正常访问：使用版本为 1Panel 2.0.16, OpenResty 版本为 1.27.1.2-2-3-focal
前言作为一名 Web 开发者，我的日常工作就是在本地进行开发，实现各种功能。过去几年，我一直使用 127.0.0.1 作为本地服务的访问地址。当需要同时开发多个项目时，我会用不同端口来区分，例如：项目A：127.0.0.1:8080项目B：127.0.0.1:8081…以此类推当然，也可以用 localhost 代替 127.0.0.1。前段时间我在想，有没有办法让本地访问的地址看起来更“真实”一点？我知道可以通过配置 HOSTS 文件将任意域名解析到本地，但有没有本身就专门设计用于解析到本地的域名呢？最初，我尝试将 local.dbkuaizi.com 及其子域名解析到 127.0.0.1，这样访问 local.dbkuaizi.com 或 test.local.dbkuaizi.com 都能指向本地站点。但用了几天后发现一个问题：域名太长，输入不方便。于是我又想，不如自己注册一个短域名？但在域名注册网站找了半天，发现两位的短域名要么已被注册，要么价格不菲。后来我在网上搜索了一番，发现确实存在一些专门解析到本地的域名。现将整理结果汇总如下：本地解析域名localhostIETF（互联网工程任务组）在 RFC 6761 中明确规定：localhost 和 *.localhost 应始终解析为本地回环地址（即 127.0.0.1）。因此，以下地址都会被解析到 127.0.0.1，满足各种使用场景：域名说明http://localhost顶级域名http://dbkuaizi.localhost二级域名http://dbkuaizi.test.localhost三级域名需要注意的是，.localhost 解析到本地是应用层规范，Safari、Chrome、CURL 等主流工具都支持，但并非所有应用都遵循。由于这是官方硬性规定，该域名非常稳定且通用性强，在绝大多数情况下推荐使用。*.lvh.me该域名的持有者是 @levicook。自 2010 年起，该域名及其泛域名均解析到 127.0.0.1。2021 年 6 月，作者因认为使用人数不多、不愿继续承担域名费用而停止续费。随后，越来越多网友在其推特下留言表示愿意捐助，希望保留该域名。最终 @levicook 恢复了续费，并开启了捐款通道（但目前该捐款地址已返回 403）。以下地址都会被解析到 127.0.0.1：域名说明http://lvh.me顶级域名http://dbkuaizi.lvh.me二级域名http://dbkuaizi.test.lvh.me三级域名这是目前最短的可用域名，也是我日常开发的选择，体验良好。*.fbi.com这个域名…很有意思，虽然不清楚所有者是谁，但至少从 2022 年至今，它一直解析到本地回环地址。域名说明http://fbi.com顶级域名http://demo.fbi.com二级域名http://demo.test.fbi.com三级域名由于所有者不明，不推荐用于正式开发，当作一个趣闻即可。*.nip.ionip.io 和 sslip.io 是一种 DNS 服务，当查询包含嵌入 IP 地址的主机名时，会将域名解析为对应 IP。这两个服务已稳定运行十多年，每秒处理约 5000 次请求，甚至被 Google 引用在 Kubernetes 文档 中。举例说明：域名解析结果http://127.0.0.1.nip.io127.0.0.1http://app.192.168.0.10.nip.io192.168.0.10http://7f000101.nip.io127.0.0.1（十六进制表示）http://127-0-0-1.nip.io127.0.0.1（带破折号格式）需要注意的是，nip.io 和 sslip.io 本身不解析到 127.0.0.1。虽然该服务稳定，但我个人不太喜欢，因为域名中必须包含 IP，与我最初的目标不符。local.gd这是 nip.io 服务的一部分，该域名及其子域名均解析到 127.0.0.1，用法与其他本地域名相同：域名说明http://local.gd顶级域名http://dbkuaizi.local.gd二级域名http://dbkuaizi.test.local.gd三级域名local.dbkuaizi.com这是我个人持有的域名。对我而言，其稳定性仅次于 localhost。如果你有需要，也可以直接使用（至少在可预见的未来，该域名会持续解析到 127.0.0.1）。域名说明http://local.dbkuaizi.com二级域名http://demo.local.dbkuaizi.com三级域名http://demo.test.local.dbkuaizi.com四级域名其他我还收集了其他一些解析到 127.0.0.1 的域名，但不建议使用，因为它们未承诺长期解析到本地：http://curd.lifehttp://crud.lifehttp://localhost.ptlogin2.qq.comhttp://localtest.me使用场景在本地开发测试中，使用本地域名可以实现以下功能：Cookie 子域名共享浏览器将 127.0.0.1 视为 IP 地址而非域名，而 Cookie 在域名和 IP 的处理逻辑上完全不同。例如，设置 Cookie 时可以使用：document.cookie = "session=abc123; domain=.local.dbkuaizi.com; path=/"这样，无论是 demo.local.dbkuaizi.com 还是 test.local.dbkuaizi.com 都能共享该 Cookie，而使用 IP 地址无法模拟此场景。OAuth 开发大多数 OAuth 服务商出于安全考虑，不允许使用 IP 作为重定向 URL，要求使用真实域名，例如：✅ https://myapp.lvh.me:3000/auth/callback✅ https://myapp.localhost:3000/auth/callback❌ https://127.0.0.1:3000/auth/callback❌ http://localhost:3000/auth/callback（部分服务商不允许 HTTP）本地同时开发多个项目例如，项目A 运行在 3000 端口，项目B 运行在 4000 端口：使用 IP:端口，难以区分：❌ http://127.0.0.1:3000❌ http://127.0.0.1:4000使用本地域名更清晰：✅ http://project-a.lvh.me → 指向项目A✅ http://project-b.lvh.me → 指向项目B多商户系统假设你正在开发一个名为 “Shop” 的电商平台，支持多个商户拥有独立店铺。使用本地域名可以完美模拟多租户架构：张氏百货：http://zhang3.shop.lvh.me李氏商城：http://li4.shop.lvh.me王氏旗舰店：http://wang5.shop.lvh.me每个租户拥有独立的子域名，数据完全隔离。多模块划分仍以多商户系统为例，通过子域名将功能模块清晰分离，每个服务都有独立的访问入口：http://admin.shop.lvh.mehttp://api.shop.lvh.me这样，开发环境与生产环境的域名结构完全一致，代码中的域名逻辑无需调整，实现从开发到上线的无缝衔接。本文由两双筷子撰写，并利用 DeepSeek 对排版与语句进行了优化。
去年了解到多吉云这个CDN 厂商，感觉性价比很高，并且网友使用后的评价也很不错 @二丫讲梵。然后看了一眼现在使用的又拍云 CDN，还有一些余额没有用完。想着还是等用完了这些余额，再迁移到多吉云上去。直到今年十月前后，终于快用完了。但是迁移时发现 Typecho 没有支持多吉云的插件，有一个支持 S3 存储协议的插件，但是阅读完多吉云文档后发现，多吉云的 S3 文档与标准版 S3 规范有一些差异，不能直接使用，所以就有了这个插件。DogeUpload 插件Typecho 多吉云附件上传插件, 版本要求 Typecho >= 1.2.0 版本。直接上传附件到多吉云，且支持更新和删除附件。自定义加速域名。多吉云图片处理后缀支持。时间戳防盗链，支持自定义 Token 有效期。历史资源处理 （将 博客域名自动替换成 CDN 域名）。超轻量级设计，贯彻 Typecho 极简设计理念，插件体积仅 32 KB。代码结构简单、注释完善、使用命名空间，适合作为插件开发参考。2026-01-26 更新 插件 1.0.1 适配 Typecho 1.3.0 版本，解决因 Upload 基类传参调整导致上传报 “Server Error” 的问题，感谢 @曾墨 的反馈下载地址：https://cnb.cool/dbkuaizi/typecho/DogeUpload/-/releases/tag/1.0.1下载 "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 证书的免费更新服务。
前言：本篇笔记内容来自于 B 站 《40分钟的Docker实战攻略，一期视频精通Docker》视频，博主 @技术爬爬虾，视频地址：https://www.bilibili.com/video/BV1THKyzBER6。Docker 核心概念Docker 容器Docker 简单来说就是通过容器化技术给应用程序封装一个独立的运行环境，每个运行环境就是一个容器，运行容器的计算机被称为 宿主机。与虚拟机最大的区别是：Docker 容器共享同一个系统内核，而每个虚拟机都包含一个完整的系统内核。所以 Docker 容器比虚拟机更轻量，启动速度更快。Docker 镜像镜像可以理解为是一个特殊的压缩包，容器就像是通过压缩包解压（创建）出来的文件，我们可以使用同一个压缩包，解压出很多份相同的文件，也可以将这个压缩包分享给他人，得到和我们一样的文件。这里所说的“文件”，就是上面提到的容器。我们也可以通过修改容器，并生成自己特定的 Docker 镜像，并将这些镜像分享给他人，其他人可以通过镜像，创建出和我们一样的容器环境。Docker 仓库Docker 仓库就是我们用来存储、分享镜像的地方。所有人都可以把自己的镜像上传到仓库里面，其他人就可以下载镜像并使用。Docker 的官方仓库是 Docker Hub (https://hub.docker.com)Docker 技术原理Docker 利用了 Linux 的两大原生功能，实现容器化：Cgroups 用来限制和隔离进程的资源使用。可以为每个容器设定 CPU、内存、网络带宽等资源的使用上限，确保容器的内存消耗不会影响到宿主机。Namespaces 用于隔离进程的资源视图，使得容器只能看到自己内部的进程ID、网络资源、文件目录，看不到宿主机的。容器本质上还是一个特殊的进程，但是当我们进入容器内部时，看起来就像是一个独立的操作系统Docker 安装Docker 是基于 Linux 的容器化技术，Windows 和 Mac 需要虚拟化一个 Linux 的子系统。所以 Docker最好的使用方式是使用 Linux系统的宿主机。Linux 系统Linux 系统可以通过包管理器安装 Docker，例如 Redhat 系可以使用 yum install docker 安装。 Alpine Linux 可以通过 apk add docker 安装Windows 安装打开 “Windows 功能”, 勾选上 Virtual Machine Paftform（虚拟机平台）、适用于 Linux 的 Windows 子系统，并重启电脑。管理员打开命令提示符，安装 wsl2wsl --set-default-version 2 wsl --update --web-download去 Docker 官网 或者 https://github.com/tech-shrimp/docker_installer/releases 下载符合自己系统的 Docker Desktop 安装包，并进行安装，安装成功后 可以在终端尝试一下：PS C:\Users\Administrator> docker --version Docker version 28.3.3, build 980b856在使用的过程中 Docker Desktop 需要一直开着，否则执行命令会出现这个报错。PS C:\Users\Administrator> docker ps error during connect: Get "http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.51/containers/json": open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.Mac 安装直接在 Docker 官网下载安装包 安装就行。Docker 镜像下载镜像Docker Pull 命令用来从仓库下载镜像。docker pull docker.io/library/nginx:latest一个Docker 镜像下载地址包含 4 部分内容：docker.io: registry：仓库地址/注册表，如果是 Docker 官方仓库，则可以省略这个地址。library: 命名空间（镜像作者），为了防止不同用户上传同一个名字的镜像 发生冲突。“library" 是docker 官方仓库的命名空间，这个空间下的所有镜像都是由 Docker 官方维护。如果是官方的命名空间 则可以省略不写nginx: 镜像名latest: 标签名、版本号。写 "latest" 或者不写 表示获取最新版本的镜像。镜像库repository: 镜像库，存放一个镜像的不同版本，"docker.io/library/nginx" 就是一个镜像库。简化后的命令如下：# 从 Docker 官方仓库的官方命名空间里面下载最新的 Nginx Docker 镜像 docker pull nginx其他例子：# 从 docker.n8n.io 的私有仓库下载 n8nio 上传的 n8n 镜像 docker pull docker.n8n.io/n8nio/n8nDocker Hubdocker 官方的镜像仓库地址为 https://hub.docker.com 可以在这个上面搜索自己需要的镜像，标有 "Docker Offcial Images" 表示 Docker 官方维护的镜像。如果打不开 可以通过 https://docker.fxxk.dedyn.io/ 镜像站来搜索镜像。镜像源配置在国内的网络环境中，如果执行 docker pull 可能会出现 下载失败的问题，可以参考这篇文档配置镜像站解决：docker_installer?tab=readme-ov-file#2-pull镜像（我尝试了 UP的这几个镜像 下载还是失败了，最后用这个镜像可以正常下载，有需要的各位可以试试看）"registry-mirrors": [ "https://docker.1ms.run" ]拉取特定架构的镜像docker pull --platform=xxx nginx默认情况下，docker 会选择当前宿主机 CPU架构的镜像，大部分情况下我们不需要关注这个参数。Mac 目前的 M系列CPU 都是 arm 架构的，但在运行 AMD64 架构的容器时，会自动调用 QEMU 来模拟 x86_64 指令集，从而实现兼容 AMD64 的镜像，不过可能会存在一些兼容性问题 以及额外的性能开销。查看镜像docker images使用此命令可以查看 所有 Pull 到本地的镜像。删除镜像docker rmi [镜像标识]使用此命令可以删除 pull 到本地的镜像，镜像标识可以选择 镜像的ID（image id）或者镜像的名称（repository）Docker 容器创建并运行容器docker run [镜像标识]使用 run 命令 可以通过 镜像创建一个容器，并启动它。镜像标识可以是 镜像ID 或者镜像名称。例如：docker run nginx 就是创建一个 Nginx 容器。docker pull 命令可以省略，直接使用 docker run 运行，如果docker发现本地没有这个镜像 则会自动拉取。分离模式默认情况下 docker run 创建容器后会导致当前终端挂起，不能进行其他操作，可以增加 -d 参数 表示容器在后台运行，不阻塞当前窗口。PS C:\Users\Administrator> docker run -d nginx c18e9f86c0c89f2d9984dbe566349542318f8e38bb86fd17579bd9f39a8c5977自定义容器名称docker run -d -name kz_nginx nginx容器的名称和 容器ID的效果是等价的，但是名字更方便记忆。端口映射容器的网络和宿主机的网络是隔离的，例如运行了一个 Nginx 容器，容器内的Nginx 监听了 80 端口。这时通过宿主机的 80 端口是无法访问到 Nginx 服务的。所以需要 -p {宿主机端口}:{容器端口} 命令进行映射，例如 -p 8080:80 标识将 Nginx 容器的 80 端口映射到宿主机的 8080 端口。目录映射（绑定挂载）与端口映射类似的就是目录映射，将容器外和容器内的目录进行绑定，容器内对文件的修改会影响宿主机的文件夹，宿主机的修改也会影响容器内文件夹。这种目录也被称为 挂载卷，他的最大作用是数据的持久化。 当容器删除时，容器内的所有数据都会被删除，但通过挂载卷映射到宿主机的文件夹将得以保留。docker run -d -p 8080:80 -v ./:/usr/share/nginx/html nginx使用绑定挂载的时候，宿主机的文件会暂时覆盖掉容器内的目录。除了这种用法 还有一种叫 Docker 卷，可以在容器之间共享和重用。环境变量可以在命令行通过 -e 参数传递环境变量到容器内部，例如创建一个数据库应用，需要在 创建容器时，就指定数据库的账户、密码等信息。docker run -d -p 27017:27017 \ -e MONGO_INITDB_ROOT_USERNAME=tech \ -e MONGO_INITDB_ROOT_PASSWORD=dbkuaizi \ mongo如果不知道容器的环境变量有哪些，可以在 Docker Hub 上搜索一下，或者去Github上看一下 readme 文档，都有详细的描述。进入容器docker run -it alpine通过 -it 参数可以在创建一共容器时，同时进入容器内部的终端。退出删除docker run -it --rm alpine--rm 表示当退出容器时，自动删除这个容器。一般和 -it 命令配合使用容器重启策略docker run -d --restart always nginx--restart 参数用来表示 容器在停止时的重启策略，它有两个选项：always 只要容器停止了，就会立即重启。包含容器因为内部错误崩溃，或者宿主机断电等场景unless-stopped ，与 always 类似，唯一区别是：手动停止的容器不会尝试重启了创建容器docker create nginx与 run 命令功能类似，区别在于只创建容器，不自动启动。容器列表PS C:\Users\Administrator> docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 48fe482ecff2 nginx "/docker-entrypoint.…" About a minute ago Up About a minute 80/tcp cool_hodgkinps 是 Process Status (进程状态) 的缩写，也是 Linux 上的一个经典命令，用于查看进程的状态信息。这一命令也被继承到 Docker 里面了这些列含义如下：CONTAINER ID 容器ID，每个容器在创建时会生成一个唯一的IDIMAGE 基于那个镜像创建出来的CREATED 镜像创建时间STATUS 镜像当前状态PORTS 镜像使用端口NAMES 容器的名字，如果创建容器时没有指定名字，系统就会随机分配一个。增加 -a 参数可以看到所有的容器，包括正在运行的和已经停止的。启停容器每次使用 docker run 运行都会创建一个新的容器，如果我们需要对同一个容器进行持续的操作，可以通过 容器的启停命令来控制。# 启动容器 docker start {容器标识} # 停止容器 docker stop {容器标识}使用 start 启停容器的时候，不需要再传递创建容器时的 端口映射、挂载卷、环境变量等参数都不需要重新写了，docker 已经自动保存了，重新启动可以按照原因运行。删除容器docker rm {容器标识}使用 rm 命令可以删除容器，如果要删除运行中的容器 需要加 -f 参数强制删除。容器内部# 进入容器内部 docker exec -it {容器标识} bash使用 exec -it 命令 可以进入容器，在容器内部执行 shell 命令docker exec {容器标识} {shell 命令}也可以通过这种方式 在容器外部执行容器内部的命令# 查看容器 alpine 中的进程信息 docker exec alpine ps -ef注意 docker 镜像为了尽可能缩小镜像体积，内部一般是一个极简的操作系统，很多系统工具、基础命令都是缺失的查看创建信息docker inspect {容器标识}使用这个命令可以看到容器的所有信息，输出的是一个 JSON 格式，可以直接丢给ai 帮忙解析容器日志docker logs {容器标识}这个命令可以看容器的日志，加上 -f 命令，可以持续输出，滚动查看。Docker 卷docker volume 命令用于管理 Docker 卷（volume）。卷是用于持久化数据的文件系统，可以将数据和应用程序分离，便于管理，可以在容器之间共享和重用。同时卷可以用于数据的备份和恢复。创建卷docker volume create {卷名称}查看卷信息docker volume inspect nginx_html [ { "CreatedAt": "2025-09-07T14:58:39Z", "Driver": "local", "Labels": null, "Mountpoint": "/var/lib/docker/volumes/nginx_html/_data", "Name": "nginx_html", "Options": null, "Scope": "local" } ]查看所有卷docker volume list DRIVER VOLUME NAME local nginx_html删除卷docker volume rm nginx_html删除未使用的卷docker volume pruneDockerfileDockerfile 是一个用来 构建镜像文件的文本文件，Dockerfile文件内包含了构建镜像所需的各种信息。在项目目录下创建一个名为 Dockerfile 的文件，并在文件中编写镜像构成的信息。# 选择一个基础镜像作为运行环境 FROM python:3.13-slim # 在镜像内切换一个工作目录，后续所有的操作都是基于这个目录来的 WORKDIR /app # 将项目文件拷贝到镜像的工作目录 # 第一个 “.” 代表当前目录，第二个 “.” 代表镜像的工作目录 COPY . . # 安装容器内环境需要的依赖 RUN pip install -r requirements.txt # 声明对外提供服务的端口是哪个 EXPOSE 8000 # 容器内服务启动命令，每次启动时容器内会自动执行这个命令 CMD ["python3","main.py"]项目内容main.pyfrom fastapi import FastAPI import uvicorn app = FastAPI() @app.get("/") def read_root(): return {"hello":"dbkuaizi"} if __name__ == "__main__": uvicorn.run(app,host='0.0.0.0',port=8000)requirements.txtfastapi uvicorn构建镜像Dockerfile 文件写好了，可以使用 docker build 构建镜像。PS E:\docker\demo> docker build -t docker_test . [+] Building 65.7s (9/9) FINISHED docker:desktop-linux => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 616B 0.0s => [internal] load metadata for docker.io/library/python:3.13-slim 0.0s => [internal] load .dockerignore 0.0s => => transferring context: 2B 0.0s => [1/4] FROM docker.io/library/python:3.13-slim@sha256:1bca0202e953784ac2a1daf36ebbc9cbebed48afcfba12d1225aaab3793eca33 0.0s => => resolve docker.io/library/python:3.13-slim@sha256:1bca0202e953784ac2a1daf36ebbc9cbebed48afcfba12d1225aaab3793eca33 0.0s => [internal] load build context 0.0s => => transferring context: 310B 0.0s => CACHED [2/4] WORKDIR /app 0.0s => [3/4] COPY . . 0.1s => [4/4] RUN pip install -r requirements.txt 63.6s => exporting to image 1.6s => => exporting layers 0.9s => => exporting manifest sha256:fb2f23e7feeb23365ee8cc3cd9a5a8c5d497c73538c16463e23195a6b873d7bf 0.0s => => exporting config sha256:41ffab42f6b9d9c336023838318f252e00e15a9d5a73cc17a765dae05fdc5435 0.0s => => exporting attestation manifest sha256:83d850f342fee281553b7b387484f4790d4553015fb12d4cec7ac00462229a25 0.0s => => exporting manifest list sha256:1bfb1d56ae1e7a710f2f6277d9c2a6b2b746e6710a83b1792bea01ef7bbec520 0.0s => => naming to docker.io/library/docker_test:latest 0.0s => => unpacking to docker.io/library/docker_test:latest 创建容器并运行PS E:\docker\demo> docker run -d -p 8000:8000 docker_test 22fd617066ad0f95b1a664257f4c00a6dc1b60d9de7c894f8940f24b34859629 PS E:\docker\demo> docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 22fd617066ad docker_test "python3 main.py" 4 seconds ago Up 4 seconds 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp sharp_banach PS E:\docker\demo> curl http://127.0.0.1:8000 StatusCode : 200 StatusDescription : OK Content : {"hello":"dbkuaizi"} RawContent : HTTP/1.1 200 OK Content-Length: 20 Content-Type: application/json Date: Tue, 09 Sep 2025 14:31:30 GMT Server: uvicorn {"hello":"dbkuaizi"} Forms : {} Headers : {[Content-Length, 20], [Content-Type, application/json], [Date, Tue, 09 Sep 2025 14:31:30 GMT], [Server, uvicorn]} Images : {} InputFields : {} Links : {} ParsedHtml : System.__ComObject RawContentLength : 20Docker 网络桥接模式Docker 网络 默认 Bridge（桥接模式），所有的容器都连接到这个网络中，每一个容器都分配了一个内部的IP地址，一般都是 172.17 开头。在这个内部子网里面，容器可以通过内部 IP 地址互相访问。但容器网络和宿主机的网络是隔离的，可以使用 docker network create 命令创建子网，默认情况下，子网也是桥接模式的一种，然后可以指定容器加入不同的子网，同一个子网内的容器可以互相通信，而跨子网则不可以通信。使用子网还有一个好处，同一个子网内的容器，可以直接使用容器名称互相访问，而不必使用内部的IP地址。# 创建一个 名叫 network1 的子网 docker network create network1 # 创建一个 nginx 容器 使用 network1 的子网 docker run -d --network network1 nginxHOST 模式host 模式下，docker 容器直接共享宿主机的网络，容器直接使用宿主机的IP地址，无需 -p参数进行端口映射，容器内的服务直接运行在宿主机的端口上，通过宿主机的IP和端口就能访问到容器中服务。docker run -d --network host nginxNONE 模式这个模式表示不联网控制命令创建网络docker network create network1查看网络PS C:\Users\Administrator> docker network list NETWORK ID NAME DRIVER SCOPE d30095b3bc13 bridge bridge local 24b646446630 host host local a26161c3eff2 network1 bridge local 7800c9b21eb6 none null local除了我们创建的模式以外，还有 Docker 自带的三种模式，需要注意的是 这三个自带的网络模式是不可删除的删除网络docker network rm network1Docker Compose有的时候 一个完整的应用可能会是很多部分组成的，例如前端、后端、数据库 以及各种附加的技术栈，这些东西应该如何容器化呢？我们可以自然的想到，将这些模块都打包在一起，做成一个巨大的容器。但这样做有一个弊端，只要其中一个模块发生故障，例如 服务端内存泄露，可能会导致整个容器都崩溃挂掉。并且这样做的可伸缩性差，如果想给系统做扩容，只能把整个大容器在复制一份，做不到对某个模块的精确扩容。多应用的最佳实践，是把每一个模块都打包成一个独立的容器。但这样多容器 增加了很多使用成本，因为想创建多个 容器 就要多次使用 docker run ，还需要配置容器之间的网络环境，尝试管理这些容器时，一个遗漏就会导致很多问题，并且若让其他人部署项目，如果操作者对部署流程不熟悉 也会导致各种问题的发生。这个时候，容器编排技术就很有用了，也就是 Docker Compose，它使用 yml 文件 管理多个容器，在这个文件中记录了容器之间时如何创建以及如何协同工作的，我们可以简单的把 Docker Compose 文件理解成一个或多个 Docker run 命令，按照特定的格式书写到一个文件中，Docker Compose 格式如下：右侧最顶级的就是 Services 元素，每个元素就对应一个 Services左侧的 --name 在右侧就变成了 services 名左侧的镜像名，在右侧写在了 image: 属性后面左侧的 -e 参数，对应右边的 environments左侧的 -v 对应右侧的 volume 也就是挂载卷。左侧的 -p 对应右边的 ports右侧的 depends_on 用来表示启动顺序关系，这里表示 my_mongodb_express 容器，依赖 my_mongodb 。这时 程序会先启动 mongodb 再启动荣亲左右两边唯一的区别是：左边自定义了一个子网 network1 ，而右边没有。同一个 compose 文件中，定义的所有容器都会自动加入同一个子网，不用我们额外维护。我们可以借助 AI 来生成 需要的 Compose 文件，而无需手动编写。使用 Compose在启动目录下创建 docker-compose.yaml 文件，内容如下：services: my_mongodb: image: mongo environment: MONGO_INITDB_ROOT_USERNAME: name MONGO_INITDB_ROOT_PASSWORD: pass volumes: - /my/datadir:/data/db my_mongodb_express: image: mongo-express ports: - 8081:8081 environment: ME_CONFIG_MONGODB_SERVER: my_mongodb ME_CONFIG_MONGODB_ADMINUSERNAME: name ME_CONFIG_MONGODB_ADMINPASSWORD: pass depends_on: - my_mongodb创建并运行启动然后执行 docker compose up -d 运行，如果容器已经在运行了 重复执行这个命令不会有任何效果。执行该命令时，会检测当前目录下 名为 docker-compose.yaml 或 compose.yaml 文件。可以通过 docker compose -f tesst.yaml up -d 指定 compose 文件。PS E:\dbkuaizi\mongodb> docker compose up -d [+] Running 3/3 ✔ Network mongodb_default Created 0.0s ✔ Container mongodb-my_mongodb-1 Started 0.6s ✔ Container mongodb-my_mongodb_express-1 Started 0.7s PS E:\dbkuaizi\mongodb>停止并删除docker compose down 命令会停止并删除容器PS E:\docker\mongodb> docker compose down time="2025-09-18T21:47:55+08:00" level=warning msg="E:\\docker\\mongodb\\docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" [+] Running 3/3 ✔ Container mongodb-my_mongodb_express-1 Removed 0.3s ✔ Container mongodb-my_mongodb-1 Removed 0.3s ✔ Network mongodb_default Removed 0.4s停止不删除docker compose stop 命令会停止并且不会删除容器启动docker compose start 命令会启动停止的容器
背景我们公司有大量的 PHP 和 Shell 脚本需要定时执行。起初使用 Linux 的 crontab 管理任务，但随着业务的增长，定时任务数量一度超过 50 个，管理成本迅速上升。为了方便线上操作，当时在 GitHub 上找到了 lisijie/webcron 这个项目。它功能简单，能满足基本的定时任务管理需求，我们便直接投入使用。遇到的问题随着时间推移，我们开始遇到以下两个关键问题：日志查询影响主业务性能 Webcron 默认使用 MySQL 存储任务和日志，与业务共用数据库。当日志量激增后，任务列表页面需要联表查询日志，页面加载时间常超过 30 秒，严重影响了主业务性能。日志数据膨胀，存储压力大部分任务执行频率高（如每 10 秒一次）；部分任务每次输出大量日志（3MB+）；日志需保留 6 个月。例如：项目月记录数MySQL .ibd 文件体积A 项目120 万条1.2 GBB 项目480 万条8.9 GB如果按照此增长速度保留半年，B 项目的日志将占用约 50GB 的磁盘空间。解决方案我们决定重构整个定时任务管理系统，重点优化三方面：1. 用 SQLite 替代 MySQL 存储日志MySQL 查询开销大、影响业务，我们将日志存储迁移到 SQLite。它无需额外服务，具备完整 SQL 支持，适合做独立日志存储方案，查询性能也更可控。2. 使用 zstd 压缩日志内容观察日志内容后发现，大量输出存在重复格式，非常适合压缩。我们评估了几种压缩算法：zstd（默认级别 / L7级别）zstd + 自训练字典gzip（Go 标准库实现）压缩对比如下：压缩方案A 项目（120 万）B 项目（480 万）MariaDB .ibd 文件1.2 GB8.9 GBSQLite 明文存储856 MB665 MBgzip（默认）212 MB405 MBzstd（无字典）198 MB335 MBzstd + B 项目字典149 MB192 MBzstd L7 + B 项目字典139 MB186 MBzstd + A 项目字典95.1 MB224 MB注：字典通过 zstd --train 命令生成，使用项目历史日志训练；字典在不同项目中交叉使用效果不一；字典需维护更新，综合考虑后未在最终方案中使用。最终策略是：当单条日志长度 > 150 字节时启用 zstd 压缩，否则直接存储明文。读取时自动判断并解压。3. 分月存储日志文件为了避免单文件过大并简化清理流程，我们将日志按月分别存储，每月 1 号新建一个 SQLite 日志库文件（如 2025-08.db）。这样带来两个好处：查询范围控制在单月文件，性能更稳定；清理历史数据只需删除 .db 文件即可，维护成本极低。实际效果经过重构后，系统在实际运行中表现稳定，存储与性能均有显著改善：压缩后磁盘占用：每 10 万条日志占用约 9~10 MB；查询性能：500 万条日志文件中，查询指定任务最近 20 条记录耗时 < 20ms；支持字段：支持根据任务 ID、执行状态、执行时间进行高效查询。技术实现细节开发语言：Go 1.24数据库引擎：GORM + SQLite（无 cgo 版本）压缩库：github.com/klauspost/compress为什么不用列式压缩？日志是按行追加写入的，若采用列式压缩，需要额外拆分压缩、整合再写入逻辑，开发成本较高，不适合高频日志场景。因此我们采用了“逐行压缩”策略，开发上更轻量。灵感来源与参考本项目思路部分参考了 V2EX 上的帖子：使用 SQLite 存日志 + zstd 压缩，但实际测试中压缩比与原贴略有差异。总体来说，该方案在中小型系统中表现优异，值得借鉴。后续优化方向✅ 探索 DuckDB 替代 SQLite：支持列式存储，可能带来更高压缩比与查询性能；✅ 动态字典优化：尝试用上月日志训练下月字典，实现自动演进，提高长期压缩效率；✅ 分布式合并查询支持：支持跨月日志合并检索，便于追溯问题全链路。小结这次定时任务系统重构，核心目标是“日志系统轻量化、可维护、低资源占用”。通过 SQLite + zstd 压缩 + 分月归档的组合，我们在不依赖额外服务的前提下，成功将海量日志压缩到可控范围，并提升了查询体验。如果你的系统也面临日志存储冗余、查询卡顿等问题，或许可以尝试这种“小数据库 + 压缩”的路线。文章内容由两双筷子原创，使用 ChatGPT 进行排版优化。
对于自己的软件产品，希望别人付费、或者在我们授权的情况下才允许使用。那么我们应该如何去这个授权码 / 许可证的机制呢？前言离线授权的方案无非就是两种，一种是软件层面的授权，一种是硬件层面的授权。软件层面：我们向用户提供一串特定的字符串，用户在软件中输入我们提供的字符串，在使用端校验用户输入的授权码，即可完成软件的授权操作。硬件层面：我们向用户提供一个 USB Key（加密狗），用户在运行软件时我们可以通过硬件加密狗来判断授权信息，这种加密狗在网上 几块钱到十几块钱就能买一个，有的加密狗还内置独立时钟，可以杜绝用户进行时钟回拨等。这种加密狗可以用在对授权安全级别更高的场景中，但需要承受额外的成本：用户的使用成本，授权分发成本（加密狗+快递费+快递时间）。软件层面的加密方案使用起来更加灵活，硬件层面加密狗安全级别更高，本篇文章主要是探讨软件层面的授权方式。授权码要点那么设计一个相对安全的离线授权码，需要满足那些哪些特征呢？绑定性：每个授权码对应一台设备的授权，客户无法通过一个授权码激活多台设备。安全性：授权码应难以被伪造或篡改可验证：授权码应能被系统快速验证其合法性可复现：当客户授权码遗失时，我们可以根据客户的机器码重新生成授权码，避免用户无法使用软件的情况。时效性（可选）：授权码可包含授权有效期，限定授权使用时长。可扩展（可选）：可根据自己需求来灵活扩展授权码内容，以方便携带更多信息。你可以以任何形式生成一个满足上方条件的授权码，无需纠结生成的思路是否与我的思路一致唯一标识在离线授权场景中，我们需要确定授权设备的唯一标识，这个标识需要具有不可更改性质，在正常情况下该唯一标识都不会发生变化（例如重启软件、重启系统不会发生变化，但重装系统、更换硬件等行为会发生变化是可接受的）。在这种场景中，我们唯一标识一般采用 CPUID、主板 BIOS UUID、网卡 MAC 地址、或自定义算法来生成唯一ID，提醒一下 如果产品是服务器应用，不应使用 硬盘序列号作为唯一标识的组成部分，因为服务器硬盘都有明确的生命周期会定期更换。当更换后就会造成授权失效的情况。这个唯一标识没有明确的授权规定，大家可以根据自己的需求来选择，也可以通过现成的开源库来获取。例如我开发的主语言是 Golang，我一般会使用 github.com/denisbrodbeck/machineid 这个库来获取硬件ID，并且可以在各个系统下运转良好。授权码方案对称方案对称方案是指授权码的生成逻辑、校验逻辑完全一样。所谓校验就是客户端内部生成正确的授权码，然后和用户输入的授权码做对比，对比成功，则说明授权有效。以我个人习惯为例，授权码的字符串一般包含“机器码”、“项目名称”、“到期时间”、“盐值”，其中盐值用于增加授权码的复杂性，即使客户知道了授权码的生成方式，在不知道盐值的情况下，无法自行伪造授权码。生成过程我们先生成一个明文的字符串，然后通过特定分隔符拼接出明文的授权码，格式可以是: 机器码:项目名称:到期时间:盐值，例如：123456:tpamis:281231:abcdef123456：客户提供的机器码，这里以 123456 代替tpamis：授权项目名称，同一个机器上可能存在多个软件产品，可以加一个项目名称用于区分。281231：到期时间，这里取 2028-12-31 的后6位作为到期时间。这里也可以写成授权天数，先选定一个时间作为起始日期，然后在这个时间上叠加偏移时间，得到的最终时间作为授权到期时间。例如：我出生的时间是：1998-11-11，我以此作为起始时间。偏移时间：9628 天，得到授权到期时间为 2028-12-31。abcdef:盐值，可以防止彩虹表攻击，即使攻击者知道生成算法，也无法通过预计算破解授权码。接着，我们通过一个 MD5 算法（也可使用其他 HASH 摘要算法）得到一个签名 "cfb9fc32230fa1a19423ef8b6af63a61"，此签名就是一个授权码。但这时的授权码有一个问题，签名是不可逆的，所以我们无法在验证签名时通过签名得到授权到期时间，所以我们需要人为的拼接一下：直接拼接： cfb9fc32230fa1a19423ef8b6af63a61281231使用分隔字符： cfb9fc32230fa1a19423ef8b6af63a61-281231舍去后六位，以 UUID 的方式呈现： cfb9fc32-230f-a1a1-9423-ef8b6a281231掩藏到期时间这个时你会发现 后六位明晃晃的摆在那里，太容易被人猜到是到期时间了，虽然不会被篡改，但仍希望隐藏在授权码中，我们可以通过 十进制转十六进制来实现：281231 转十六进制为：44a8f，这时授权码变成：cfb9fc32-230f-a1a1-9423-ef8b6a44a8f。同时你可以将到期时间藏在授权码中间，替换掉对应位置的签名值，得到这样的授权码：cf44a8f2-230f-a1a1-9423ef8b6af63a61。验证授权码到这一步，已经完成了授权码的生成过程，客户端程序需要提前内置盐值和同样的生成算法，在用户输入授权码激活时，先从授权码中截取出到期时间。再用客户端生成的授权码进行对比验证是否正确，以及是否过期。用户体验优化签名生成的授权码以及可以满足正常的离线授权的功能了，但有一些不允许插入U盘或联网的场景中，客户可能需要手动输入授权码，我们应该尽可能缩短授权码长度，方便用户输入。我们可以在前面授权码的基础上，参考 Windows 的授权码长度 （AAAAA-BBBBB-CCCCC-DDDD-EEEEE）,每组5个字符，5 组共25个字符，通过横杠分割方便用户输入。前面 MD5 生成的签名是 128 位的二进制，转换为16进制后，长度为 32 个字符。其实我们可以 MD5 转为 36 进制得到: CAQ3DFUC0YPEHDZLA7ZKHHYLD。我们再拼接上前面得到的十六进制到期时间，并转为全大写，就得到了方便输入的授权码格式：CAQ3D-FUC0Y-PEHDZ-LA7ZK-HHYLD-44A8F。如果需要长度和 Windows 一模一样，则需要在 36 进制转换后，再截取掉 5 位，拼接时间戳。这里提供一个 Go 语言 十六进制转36进制的方法示例：package main import ( "crypto/md5" "encoding/hex" "fmt" "math/big" ) // 将 MD5 哈希值转换为 36 进制字符串 func md5ToBase36(md5Hash string) string { // 将 MD5 哈希值（十六进制）转换为大整数 bigInt := new(big.Int) bigInt.SetString(md5Hash, 16) // 定义 36 进制的字符集 const charset = "0123456789abcdefghijklmnopqrstuvwxyz" // 将大整数转换为 36 进制 base36 := "" base := big.NewInt(36) zero := big.NewInt(0) remainder := new(big.Int) for bigInt.Cmp(zero) > 0 { bigInt.DivMod(bigInt, base, remainder) base36 = string(charset[remainder.Int64()]) + base36 } return base36 } func main() { // 计算字符串的 MD5 哈希值 data := "123456:tpamis:281231:abcdef" hash := md5.Sum([]byte(data)) md5Hash := hex.EncodeToString(hash[:]) // 转换为十六进制字符串 fmt.Println("MD5 哈希值:", md5Hash) // 输出: cfb9fc32230fa1a19423ef8b6af63a61 // 将 MD5 哈希值转换为 36 进制 base36Code := md5ToBase36(md5Hash) fmt.Println("36 进制编码:", base36Code) // 输出: caq3dfuc0ypehdzla7zkhhyld }几个问题补充为什么要转 36进制而不是直接截取 MD5？因为截取后过短的 MD5 增加了碰撞的概率，转为 36进制，会包含 0-9、A-Z 36 个字符。36进制相比 16进制更为紧凑，在缩短长度同时保留较高的信息密度。且 36 进制在客户输入时，不需要考虑大小写问题，用户体验更好。摘要算法选择MD5 算法已被证实存在碰撞漏洞，但在安全性要求相对不是很高的场景中，还是可以接受的。如果需要安全性更高的摘要算法，可以换成例如 SHA-256、国密 SM3 等更为安全算法，在得到签名后截取 32 位 当作 MD5 使用，实现安全性、用户体验的兼顾。36进制字符集因为 36 进制是自行实现的，所以可以自定义字符集，大家可以在这一步对字符集排布顺序进行打乱，实现混淆效果。非对称方案前面的授权码方案可以提供一个方便输入，长度较短的授权码字符串，但携带授权信息较少、密钥（盐值）需要内置在客户端中，遇到逆行破解场景，有盐值泄露的可能性。授权码设计非对称加密我们可以使用 RSA 算法来实现。当我们使用 RSA 算法时，我们可以通过授权码给客户端携带更多信息，所以我们就以 JSON 为许可证内容的载体。我们可以这样设计：{ "iss":"tpamis", "sub":"pord", "aud": "123456", "exp": "1861804800", "nbf": "1743436800", "iat":"1742728673", "rge":["功能a","功能b","功能b"] }接参考了 JWT 官方规定的 Payload 字段，设计的许可证 JSON 内容，且这些字段可以根据需求随意添加调整。iss (issuer)：授权项目sub (subject)：授权方式，prod 正式授权、test 测试授权aud (audience)：授权对象，客户的机器码exp (expiration time)：过期时间nbf (Not Before)：生效时间iat (Issued At)：签发时间rge：授权功能列表生成授权码我们生成一对 RSA 公私钥，公钥保存在客户端，私钥在我们手里，我们根据用户的机器码，生成一个授权码，以上面为例，我额外扩展了授权模块功能，可以精确控制客户允许使用的功能范围。生成包含许可证信息的JSON 字符串{"iss":"tpamis","sub":"pord","aud":"123456","exp":"1861804800","nbf":"1743436800","iat":"1742728673","rge":["功能a","功能b","功能b"]}然后我们使用 RSA 私钥加密生成密文，这个密文就是我们需要提供给客户的授权码。[binary]当许可证内容过多，导致密文过长时，可以考虑输出一个密钥文件给客户，客户在产品中上传、选择 密钥文件，我们通过读取密钥文件来获取授权码。这样体验更好。授权码验证用户将我们的授权码输入到软件中，客户端使用公钥解密，解密成功则读取进入进行进一步的授权校验，解密失败直接提示授权码错误。说明：将公钥内置到客户端，在极端情况下，存在反编译的可能性。所以我们可以假设公钥已经泄露、不安全的状态。不过只要私钥不泄露，别人拿到公钥也只能解密我们的许可证密文，而不能伪造一个许可证，相对来说也是可以接受的。代码示例以 Go 语言为例 加密解密过程如下package main import ( "encoding/json" "fmt" "github.com/dromara/dongle" ) func main() { // 声明 map[string]any 许可证结构 licenseData := map[string]any{ "iss": "tpamis", "sub": "pord", "aud": "123456", "exp": "1861804800", "nbf": "1743436800", "iat": "1742728673", "rge": []string{"功能a", "功能b", "功能b"}, } // 将 map 转换为 JSON 字符串 jsonData, _ := json.Marshal(licenseData) // 使用RSA 私钥 授权Json cipherText := dongle.Encrypt.FromBytes(jsonData).ByRsa(pkcs1PrivateKey).ToBase64String() fmt.Println("RSA密文", cipherText) // 客户端 使用 公钥进行解密 licenseJson := dongle.Decrypt.FromBase64String(cipherText).ByRsa(pkcs1PublicKey).ToString() fmt.Println("许可证Json", licenseJson) } var pkcs1PublicKey = []byte(`-----BEGIN RSA PUBLIC KEY----- MIGJAoGBAK12MTd84qkCZzp4iLUj8YSUglaFMsFlv9KlIL4+Xts40PK3+wbsXPEw cujGeUmdgMeZiK7SLLSz8QeE0v7Vs+cGK4Bs4qLtMGCiO6wEuyt10KsafTyBktFn dk/+gBLr7B/b+9+HaMIIoJUdsFksdAg3cxTSpwVApe98loFNRfqDAgMBAAE= -----END RSA PUBLIC KEY-----`) var pkcs1PrivateKey = []byte(`-----BEGIN RSA PRIVATE KEY----- [binary] -----END RSA PRIVATE KEY-----`)JWT 方案JWT 方案则是直接使用标准的 JWT 库来实现授权码的签发功能，客户端和服务端共享 JWT 的密钥来验证授权签名的有效性。本方案的优点是可以充分利用现有的标准化 JWT 库，只需定义一个密钥，而无需进行过多的开发，但许可证内容会直接暴露给客户，也可以考虑 JWE，需自行抉择。JWT Header { "alg": "HS256", "typ": "JWT" } JWT Payload { "iss": "tpamis", "sub": "pord", "aud": "123456", "exp": "1861804800", "nbf": "1743436800", "iat": "1742728673", "rge": [ "功能a", "功能b", "功能b" ] } 密钥 dbkuaizi.com JWT Token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0cGFtaXMiLCJzdWIiOiJwb3JkIiwiYXVkIjoiMTIzNDU2IiwiZXhwIjoiMTg2MTgwNDgwMCIsIm5iZiI6IjE3NDM0MzY4MDAiLCJpYXQiOiIxNzQyNzI4NjczIiwicmdlIjpbIuWKn-iDvWEiLCLlip_og71iIiwi5Yqf6IO9YiJdfQ.izIAwMyiLxPiHrWf-FDGu3fHMvfaC7bqEh40ha8YYAA结尾没有绝对的安全任何安全领域的手段都是相对安全，没有绝对的安全。再复杂的授权码的本质只能是 防君子不防小人，例如用户可以通过逆向 得到你的盐值、甚至直接修改你的程序逻辑跳过验证的逻辑，更何况我们的场景中只有离线校验的逻辑。防止时钟回拨时钟回拨是指，就是用户直接通过修改系统时间到软件授权到期之前，从而继续使用软件的目的。那么我们在离线场景中如何去避免这样的情况发生呢？首先我需要声明的是，离线授权场景中，不能彻底避免这种情况（除了独立时钟的加密狗），我们能做的就是通过代码逻辑尽可能规避这种情况的发生，一般有这几种手段：记录上一次运行时间众所周知，时间是不会倒流的，也就是说软件的第二次运行时间，不可能小于上一次运行时的系统时间。所以，在软件每次运行时我们可以先记录当前时间，并与上一次系统时间做对比，若第二次运行时的系统时间小于之前记录的时间，则认为出现了系统时钟回拨的问题，直接提示用户并终止软件运行。安全的存储时间标记那么如何保证我们存储的时间没有被篡改呢？我们可以通过 （时间戳.盐）+md5 的方式实现：例如：时间戳为 1717487962 盐 是 abcdefg，通过： md5('1717487962.abcdefg') 摘要算法，获得时间戳签名:a06de98fc28e45bf38a9a5f27630cd03。然后将这样的字符串进行存储：1717487962.a06de98fc28e45bf38a9a5f27630cd03，启动时再通过上面的逻辑进行校验，即可保证 记录的时间不被修改。如何防止用户删除时间标记可以在打包的时候，就记录一个打包时间作为初始化时间戳，这样即使用户第一次运行也必须有时间戳。若没读取不到时间戳标记，则说明用户人为清理了时间标记，结束运行。与业务数据强关联如果你产品运行过程中产生的业务数据很重要，也可以使用业务中的时间戳来做时钟回拨校验，例如使用最后一条订单的创建时间。若业务数据很重要，用户总不可能为了继续使用软件而删掉业务数据吧。抛砖引玉授权码的设计方案有很多，这里只是整理了我用过的几种方案，大家可以根据自己的需要对逻辑进行调整，如果你有更好的方案，欢迎留言交流。
前言目前使用的垃圾评论过滤插件在添加词库时十分繁琐，需要进入插件的设置页面才能添加。但从操作的角度上来说，直接在评论管理页面进行操作是最符合直觉的，所以有了这款插件。功能兼容性要求：依赖 Typecho 1.2.0+ 版本，利用命名空间功能。无需修改数据库结构：通过插件配置表实现拦截功能，安全高效。便捷操作：支持直接在评论管理页面一键添加屏蔽词，操作直观。智能处理：当新增屏蔽关键词时，自动处理符合条件的待审评论，节省人工审核时间。属性联动拦截：插件可自动检测评论中被拉黑的属性。例如，若拉黑某个 IP 地址，可自动屏蔽该 IP 提交的所有评论内容，并将其加入屏蔽词库。详细统计：在插件管理页面显示以下统计信息：已定义的过滤规则总数累计拦截次数自动收集的屏蔽词数量适配移动端：注入拉黑操作按钮时，适配移动端小屏幕自定义提示：多种拦截提示可选，也可自定义提示内容界面评论管理页面拉黑操作插件配置页面下载去码云下载插件
前言：本篇笔记内容来自于 B 站：《尚硅谷Vue3入门到实战，最新版vue3+TypeScript前端开发教程》系列视频链接地址： https://www.bilibili.com/video/BV1Za4y1r7KE/创建 Vue 工程NodeJS安装 18.3 + 的 NodeJS https://nodejs.org/初始化项目通过 Vite 初始化一个 Vue 项目 Vite 会在运行目录创建一个 项目同名的目录PS E:\code> npm create vue@latest > npx > create-vue Vue.js - The Progressive JavaScript Framework √ 请输入项目名称： ... test_vue3 √ 是否使用 TypeScript 语法？ ... 否 / 是 √ 是否启用 JSX 支持？ ... 否 / 是 √ 是否引入 Vue Router 进行单页面应用开发？ ... 否 / 是 √ 是否引入 Pinia 用于状态管理？ ... 否 / 是 √ 是否引入 Vitest 用于单元测试？ ... 否 / 是 √ 是否要引入一款端到端（End to End）测试工具？ » 不需要 √ 是否引入 ESLint 用于代码质量检测？ ... 否 / 是 ? 是否引入 Vue DevTools 7 扩展用于调试? (试验阶段) » 否 / 是之后通过 vscode 打开项目目录.运行项目运行前端项目要先看一下项目根目录的 package.json 文件，里面包含了一些项目的信息：{ "name": "test_vue3", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --build --force" }, "dependencies": { "vue": "^3.4.29" }, "devDependencies": { "@tsconfig/node20": "^20.1.4", "@types/node": "^20.14.5", "@vitejs/plugin-vue": "^5.0.5", "@vue/tsconfig": "^0.5.1", "npm-run-all2": "^6.2.0", "typescript": "~5.4.0", "vite": "^5.3.1", "vue-tsc": "^2.0.21" } }可以看到 scripts 目录下可以 通过的 dev 命令来启动项目，通过 build 来打包编译。我们使用 npm run dev 启动项目项目结构项目入口是根目录中的 index.html 文件，里面有两个核心操作，声明了 #app 容器，所有前端的内容渲染（挂载）到这个容器上，然后引入了 main.ts 文件main.ts 文件中引入了 Vue 组件，并创建了一个 App 的组件，挂载了节点为 App 的容器上。以根目录为例，代码如下：/index.html 入口文件 Vite App /src/main.ts 文件// 引入 createApp 用于创建应用 import {createApp} from 'vue' // 引入 App 根组件 import App from './App.vue' // 挂载 组件 createApp(App).mount('#app')/src/App.vue 文件 你好啊！ 总结Vite 项目中，index.html 是项目的入口文件，在项目最外层。加载index.html后，Vite 解析 src\App.vue 根文件 123 <Person /> 注：Vue3向下兼容Vue2语法，且Vue3中的模板中可以没有根标签。Vue3 核心语法OptionsAPI 和 CompositionAPIVue2 的 API 设计是 Options （配置、选项）风格的Vue3 的 API 设计是 Composition（组合、组件） 风格的Options API 弊端Options类型的 API，数据、方法、计算属性等，是分散在：data、methods、computed中的，若想新增或者修改一个需求，就需要分别修改：data、methods、computed，不便于维护和复用。setupsetup 概述setup 是 vue3 中一个新的配置项，它的值是一个函数。 setup 是 Composition API 的核心，组件中所用到的：数据、方法、计算属性、监视......等等，均配置在setup中。特点如下：setup函数返回的对象中的内容，可直接在模板中使用。setup中访问this是undefined。setup函数会在beforeCreate之前调用，它是“领先”所有钩子执行的。 姓名：{{ name }} 年龄：{{ age }} show setup 返回值若 返回一个对象：则对象中的：属性、方法等，在模板中均可以正常使用若返回一个函数：则会直接渲染返回的内容，所以可以用作自定义渲染函数。代码如下：setup(){ return ()=> 'hello Vue3!' }setup 与 Options API 的关系Vue2 的配置（data、methos......）中可以访问到 setup中的属性、方法。但在setup中不能访问到Vue2的配置（data、methos......）。如果与Vue2冲突，则setup优先。setup 语法糖setup 提供了一个语法糖，允许我们把 setup 独立出去，且不用 return，代码如下： 姓名：{{ name }} 年龄：{{ age }} show ::: alert-info当不定义组件名时（即 上方代码，省略 9 ~ 13 行 ），会自动通过文件名来推导组件名，例如文件名为 Person.vue 组件名就是 Person。当组件过于复杂时，可能会用一个单独的文件夹来编排代码，例如 /src/compositions/tree/index.vue， 这种场景下 整个 tree 目录都是组件，如果通过文件名推导，这个组件名称就是 index ，肯定不合适，所以需要显式定义组件名。:::若想简写 组件名称可以通过 vite 插件来实现：安装插件 npm i vite-plugin-vue-setup-extend -D编辑 /vite.config.ts 文件import { defineConfig } from 'vite' import VueSetupExtend from 'vite-plugin-vue-setup-extend' // 增加 export default defineConfig({ plugins: [ VueSetupExtend() ] // 增加 })的 ref 响应式数据ref 创建：基本类型响应式数据作用：定义响应式数据语法：let xxx =ref(初始值)返回值：一个 Reflmpt 的实例对象，简称 ref 对象 或 ref，ref 对象 的 value 属性是响应式的:::alert-infoJS 中操作数据 需要 xxx.vlue 不能直接覆盖 ref 对象, 但 模板中不需要 .value 可以直接使用。对于 let name = ref('dbkuaizi') 来说 name 不是响应式的，name.value 才是。::: 姓名：{{ name }} 年龄：{{ age }} set age show reactive 创建，对象类型的响应式数据作用：定义一个响应式的对象，基本类型用 ref 否则会报错语法：let 响应式对象 = reactive(源对象)返回值：一个 Proxy 的实例对象，简称响应式对象。::: alert-inforeactive 定义的响应式数据是 "深层次"的。reactive 只能定义响应式类型数据，定义基本类型会报错ref用来定义：基本类型数据、对象类型数据；::: 一辆 {{car.name}}，价值 {{ car.price }} setPrice 游戏： {{ g.name }} setGames ref 对比 reactive宏观角度ref 用来定义：基本类型数据、对象类型数据reactive 用来定义：对象类型数据区别ref 创建的变量必须使用 .value 操作，（可以通过 Vue - Official 插件设置自动添加 .value）Vscode 中找到 Vue - Official 插件设置勾选上 Auto Insert Dot Valuereactive 重新分配一个对象，会失去 响应式（可以使用 Object.assign 替换源对象中的属性） function setCar(){ // 这么写直接覆盖了 reactive 对象，car 不再是响应式的了 // car = {name:'Giant',price:3500} // 这么写 car 是一个新的 reactive 对象，而渲染时引用的是最早初始化的 reactive 对象 // car = reactive({name:'Giant',price:3500}) // 得这么写 不要覆盖原 reactive 对象，Object.assign: 使用后面对象复制到目标对象 Object.assign(car,{name:'Giant',price:3500}) }:::alert-warning注意 如果是 ref 定义的是响应式对象，可以直接通过 xxx.value = 新对象 去赋值，因为 ref.value 是响应式的，所以修改 .value 不会破坏响应式关系。:::使用原则：若需要一个基本类型的响应式数据，必须使用ref。若需要一个响应式对象，层级不深，ref、reactive都可以。若需要一个响应式对象，且层级较深，推荐使用 reactive。:::alert-info例如一个表单，有很多表单项，如果表单值是 ref，那就需要在代码中写 很多 .value，增加代码复杂度，reactive 更合适:::toRefs 和 toRef作用：将响应式对象中的属性 转为 ref对象说明：toRefs 与 toRef 功能一致，但 toRefs 可以批量转换 姓名：{{ person.name }} 年龄：{{ person.age }} computed 计算属性说明：根据已有的数据计算出新的数据 （与 Vue2 中计算属性功能一致），计算属性在多次调用、参与计算的值没有发生变化时，不会重复计算。 姓： 名： 全：{{ fullName }} 全：{{ fullName }} 全：{{ fullName }} 全：{{ fullName }} Set Name watch 监控监视 ref 定义的基本类型监视ref定义的【基本类型】数据：直接写数据名即可，监视的是其value值的改变。 求和 Sum 为： {{ sum }} +1 监视 ref 定义的对象类型监视 ref 定义的【对象类型】数据：直接写数据名，监视的是对象的【地址值】，若想监视对象内部的数据，要手动开启深度监视。:::alert-warning若修改的是 ref 定义的对象中的属性，newValue 和 oldValue 都是新值，因为它们是同一个对象。若修改整个 ref 定义的对象，newValue 是新值， oldValue 是旧值，因为不是同一个对象了。::: 姓名：{{ person.name }} 年龄：{{ person.age }} 全改 监视 reactive 定义的对象类型监视 reactive 定义的对象类型数据，默认开启深度监视 姓名：{{ person.name }} 年龄：{{ person.age }} 全改 监视某个属性监视 ref 或 reactive 定义的对象类型数据中的某个属性时，注意如下：若该属性值不是“对象类型”需要写成函数形式若该属性值依然是 “对象类型”，可以直接写，也可以写函数，这里建议写函数结论： 监视的要是对象里的属性，那么最好写函数式，注意点：若是对象监视的是地址值，需要关注对象内部，需要手动开启深度监视。 姓名：{{ person.name }} 年龄：{{ person.age }} 车：{{ person.car.c1 }} 、{{ person.car.c2 }} 修改名称 修改年龄 修改车 修改所有车 监听多个属性监控属性时可以传递一个数组，同时 newVal 也会是一个数组 姓名：{{ person.name }} 年龄：{{ person.age }} 车：{{ person.car.c1 }} 、{{ person.car.c2 }} 修改名称 修改年龄 修改车1 修改所有车 watchEffect官网：立即运行一个函数，同时响应式的追踪其依赖，并在依赖更改时重新执行该函数人话：更智能的 watch，不需要显式指定监控的响应式变量，直接写逻辑即可。在加载时会先执行一次。watch 和 watchEffect 对比都能监听响应式数据的变化，不同的是监听数据变化的方式不同watch 要明确指出监视的数据watchEffect 不用明确指出监视的数据（函数中用到哪些属性，就自动监视哪些属性）。 当前求和为：{{ sum }} 标签 Ref 属性作用：用于注册模板引用，功能类似于带命名空间的 ID ，用于设置组件内的唯一标识用在普通DOM标签上，获取的是DOM节点。用在组件标签上，获取的是组件实例对象。用在 DOM 标签上 666 999 ShowLog 用在组件上 (组件部分参考上方代码) <Person ref="ren" /> 输出组件 Props 父传子在一个父组件中引入子组件，需要向子组件传递一些数据，例如传递一个 list 让子组件进行展示，这里引入了 TS 语法 在传参时规定了类型。src\types\index.ts ts 结构定义// 定义一个接口（类似 go 的结构体） export interface User { id:string name:string age:number } // 定义一个自定义类型的 Users export type Users = Array<User>src\App.vue 父组件 <Person :list="users" /> src\components\Person.vue 子组件 {{item.id}} -- {{ item.name }} 声明周期概念：Vue组件实例在创建时要经历一系列的初始化步骤，在此过程中Vue会在合适的时机，调用特定的函数，从而让开发者有机会在特定阶段运行自己的代码，这些特定的函数统称为：生命周期钩子创建阶段：setup挂载阶段：onBeforeMount、onMounted更新阶段：onBeforeUpdate、onUpdated卸载阶段：onBeforeUnmount、onUnmounted常用的钩子：onMounted(挂载完毕)、onUpdated(更新完毕)、onBeforeUnmount(卸载之前):::alert-info生命周期整体分为四个阶段，分别是：创建、挂载、更新、销毁，每个阶段都有两个钩子，一前一后。父子组件的顺序类似于深度有限算法，顺序如下：父组件 挂载前子组件1 挂载前子组件1 挂载后子组件2 挂载前子组件2 挂载后父组件 挂载后::: {{ sum }} 自定义 Hook什么是 Hook?本质上是一个函数，把 setup 函数中使用的 Composition API 进行了封装，把同一功能的逻辑代码、业务数据汇总在了一起，方便后期功能维护。自定义hook的优势：复用代码, 让setup中的逻辑更清楚易懂。业务代码：src\hooks\useDog.ts 将 加载dog的方法、doglist 响应式变量 都放入 useDog 这个文件中注意：文件名称要按照功能来，比如 Dog 相关的功能就叫 useDog.ts，订单相关的东西 就叫 useOrder.tsimport { defineProps, onMounted,reactive } from 'vue'; import axios from 'axios'; export default () => { // 存储狗的列表 let dogList = reactive<string[]>([]) // 加载一张新图片 async function getDog() { let result = await axios.get('https://dog.ceo/api/breed/pembroke/images/random') dogList.push(result.data.message) } // hooks 中也能写生命周期函数， 挂载后直接加载一只狗 onMounted(() => { getDog() }) // 将工具和方法暴露出去 return {dogList,getDog} }src\components\Person.vue Person 组件，引入并使用 useDog 加载一只狗 路由 Routervue3 中使用路由 需要先安装 npm i vue-router基本切换效果src\router\index.ts 路由组件基本代码：// 创建一个路由 // 引入路由 import {createRouter,createWebHistory} from "vue-router" // 引入组件 import Home from "@/components/Home.vue" import News from "@/components/News.vue" import About from "@/components/About.vue" // 创建路由器 const router = createRouter({ history: createWebHistory(), // 指定工作模式 routes:[ { path:'/home', component: Home }, { path:'/news', component: News }, { path:'/about', component: About } ] }) // 暴露出去 export default routersrc\main.ts 文件代码如下：// 引入 createApp 用于创建应用 import {createApp} from 'vue' // 引入 App 根组件 import App from './App.vue' import router from './router' // 挂载 组件 const app = createApp(App) app.use(router) // 挂载整个app到页面 app.mount('#app')src\App.vue 代码如下： Vue Router <RouterLink to="/home" active-class="active">首页</RouterLink> <RouterLink to="/news" active-class="active">新闻</RouterLink> <RouterLink to="/about" active-class="active">关于</RouterLink> <RouterView></RouterView> 路由 两个注意点路由组件通常放在 /src/pages 或 /src/views 文件夹，一般组件通常放在 components 文件夹路由组件：靠路由规则渲染出来的 routes:[{path:'/demo',component:Demo}]一般组件：写标签实现一个功能的组件 <Demo/>通过路由切换的组件默认是被卸载掉了，需要的时候再去加载。路由工作模式History 模式优点：路径中不带 # 直接写路径，从用户角度看 URL 更美观缺点：生产环境中需要 Web 服务器处理路径问题，否则会出现 404 错误。（注：就是后端说的路由重新）const router = createRouter({ history:createWebHistory(), //history模式 /******/ })Nginx 配置：location / { root /root/project'; index index.html try_files $uri $uri/ /index.html }Hash 模式特点：兼容性更好，不需要服务器做特殊处理 缺点：URL 中带有 # 不太美观，且 SEO 优化方面相对较差const router = createRouter({ history:createWebHashHistory(), //hash模式 /******/ })to 的两种写法字符串的写法 <router-link active-class="active" to="/home">主页</router-link>对象写法 <router-link active-class="active" :to="{path:'/home'}">Home</router-link>命名路由作用：可以简化路由跳转及 params 传参需要用到路由规则：routes:[ { name:'zhuye', path:'/home', component:Home }, { name:'xinwen', path:'/news', component:News, }, { name:'guanyu', path:'/about', component:About } ]跳转路由： <router-link :to="{name:'guanyu'}">跳转</router-link>嵌套路由场景：例如系统配置页面，路由是 /setting，但这个页面中需要根据页面内的菜单 再嵌套更具体的配置页面，这时就需要 嵌套路由了（注：嵌套路由本质是 在一个路由组件中嵌套另一个路由组件，如果两个组件之间没有嵌套关系 则不需要使用嵌套路由）。这里以新闻页面为例，嵌套显示新闻详情路由规则配置const router = createRouter({ history: createWebHistory(), // 指定工作模式 routes:[ ... { path:'/news', component: News, children:[ // 子路由 访问路径： /news/detail {path: 'detail',component:Detail} ] }, ... ] })src\pages\News.vue 新闻组件 <RouterLink :to="{path:'/news/detail'}">{{ news.title }}</RouterLink> <RouterView></RouterView> 路由传参Query 传参通过 Query 的方式从URL种传参传递参数 <!-- <RouterLink :to="`/news/detail?id=${news.id}`">{{ news.title }}</RouterLink> --> <RouterLink :to="{ path:'/news/detail', query:{ id:news.id, title:news.title, content:news.content } }">{{ news.title }}</RouterLink> <RouterView></RouterView> ， 获取参数 编号：{{query.id}} 标题：{{query.title}} 内容：{{query.content}} Params 参数在路由种使用 占位符省略 url传参时的 Key:::alert-warning传递 params 参数时，若使用 to 的对象写法，必须使用 name 配置项，不能使用 path传递 params 参数时，需要提前在路由规则中占位，若参数为可选参数，需要使用 ? 结尾，例如： /:xxx? :::路由规则// 创建一个路由 // 引入路由 import {createRouter,createWebHistory} from "vue-router" // 引入组件 import Home from "@/pages/Home.vue" import News from "@/pages/News.vue" import About from "@/pages/About.vue" import Detail from "@/pages/Detail.vue" // 创建路由器 const router = createRouter({ history: createWebHistory(), // 指定工作模式 routes:[ { path:'/home', component: Home }, { path:'/news', component: News, children:[ // 子路由 { // params 传参对象形式必须用 name name:'xq', // content 加 "?" 表示可选参数 path: 'detail/:id/:title/:content?', component:Detail } ] }, { path:'/about', component: About } ] }) // 暴露出去 export default router传递参数 <!-- <RouterLink :to="`/news/detail/${news.id}/${news.title}/${news.content}`">{{ news.title }}</RouterLink> --> <RouterLink :to="{ name:'xq', params:news }">{{ news.title }}</RouterLink> <RouterView></RouterView> 接收参数 编号：{{route.params.id}} 标题：{{route.params.title}} 内容：{{route.params.content}} Props 参数作用：让路由组件可以通过 Porps 的方式获取参数路由写法 { // params 传参对象形式必须用 name name:'xq', // content 加 "?" 表示可选参数 path: 'detail/:id/:title/:content?', component:Detail, // props 布尔值写法，把收到的每一组params参数，作为 props 传递给 Detail 组件 // props:true, // props 函数写法，把函数返回的每一组 Key-Value 作为 props 传递给 Detail 组件 props: (route) => route.query, // props 对象写法，把对象的每一组 Key-Value 作为 props 传递给 Detail 组件 //props: {a:1,b:2} }接收参数 编号：{{id}} 标题：{{title}} 内容：{{content}} replace 属性作用：控制路由跳转时操作浏览器历史记录的模式。浏览器的历史记录有两种写入方式：分别为push和replace：push 是追加历史记录（默认值）。replace 是替换当前记录。开启 replace 模式<RouterLink replace .......>News</RouterLink>函数式编程导航大白话：通过 JS 实现 RouterLink 的功能，完成路由跳转场景只能通过 JS 跳转的，例如只有到时间了才能跳转到某个页面， RouterLink 做不到。其他事件触发跳转的，例如鼠标滑过触发跳转。不使用 RouterLink 也想跳转 查看 <RouterLink :to="{ name:'xq', params:news }">{{ news.title }}</RouterLink> <RouterView></RouterView> 路由重定向作用：当匹配到 path 时，重定向到指定路由{ path:'/', redirect:'/home' }Pinia（状态管理）大白话说法：就是组件之间数据的全局共享，例如 登录组件用户登录之后，获取到的用户信息 可以交给 状态管理工具来维护，（类似后端的 Session）安装 Pinia安装库npm i piniasrc\main.ts 引入import {createApp} from 'vue' import App from './App.vue' // 引入 pinia import { createPinia } from 'pinia' const app = createApp(App) // 创建 pinia const pinia =createPinia() app.use(pinia) app.mount('#app')存储加读取数据在 src 目录中添加 store 目录，是用来保存 状态、业务逻辑 的实体，每个组件都可以 读取、写入 它。它有三个概念：state、getter、action，相当于组件中的：data（数据）、computed（计算属性）、methods （方法）。pinia 编码文件：src/store/count.ts // 引入defineStore用于创建store import {defineStore} from 'pinia' // 定义并暴露一个store export const useCountStore = defineStore('count',{ // 动作 actions:{}, // 状态 state(){ return { sum:6 } }, // 计算 getters:{} }) 组件中使用文件：src\components\Count.vue 当前求和为：{{ countStore.sum }} 1 2 3 加 减 修改数据的三种方式直接修改countStore.sum = 666批量修改修改多条数据可以用这个，只会触发一次修改操作，性能更优countStore.$patch({ sum:666, text:"Hello,dbkuaizi" })借助 Action 修改在 store 中可以写一些逻辑，例如 在多个页面有用户注销、下单 这些功能，可以通过 action 维护import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { /*************/ actions: { //加 increment(value:number) { if (this.sum < 10) { //操作countStore中的sum this.sum += value } }, //减 decrement(value:number){ if(this.sum > 1){ this.sum -= value } } }, /*************/ })在组件中使用：// 使用countStore const countStore = useCountStore() // 调用对应action countStore.increment(n.value) storeToRefs借助storeToRefs将store中的数据转为ref对象，方便在模板中使用。注意：pinia提供的storeToRefs只会将数据做转换，而Vue的toRefs会转换store中数据。 当前求和为：{{ sum }} {{ text }} 1 2 3 加 减 Getters作用：当 state 中的数据需要经过处理后再使用，可以通过 getters 配置import {defineStore} from 'pinia' export const useCountStore = defineStore('count',{ state(){ return { sum:6, text:"hello word" } }, // 类似于计算属性 getters: { // 使用箭头函数 bigSum:state => state.sum * 10, // 使用 普通函数写法 需要指定返回值类型 upperText():string{ return this.text.toUpperCase() } } })$subscribe 监听数据变化 // 数据 const talkStore = useTalkStore() // 通过 store 的 $subscribe() 方法侦听 state 及其变化 talkStore.$subscribe((mutate,state)=>{ console.log(mutate,state) localStorage.setItem('talkList',JSON.stringify(state.talkList)) })Store 组合式写法import {defineStore} from 'pinia' import axios from 'axios'; import { nanoid } from 'nanoid'; // 选项式 export const useTalkStore = defineStore('talk',{ state(){ return { talkList:JSON.parse(localStorage.getItem('talkList') as string) || [] } }, actions:{ // 方法 async getTalk(){ // 发送请求 拿到响应值通过连续解构 赋值+重命名给 title let {data:{content:title}} = await axios.get("https://api.uomg.com/api/rand.qinghua?format=json") // 放入数组中 this.talkList.push({id: nanoid(),title}) } } }) // 组合式 import { reactive } from 'vue'; export const useTalkStore2 = defineStore('talk',()=>{ const talkList = reactive(JSON.parse(localStorage.getItem('talkList') as string) || [] ) async function getTalk(){ // 发送请求 拿到响应值通过连续解构 赋值+重命名给 title let {data:{content:title}} = await axios.get("https://api.uomg.com/api/rand.qinghua?format=json") // 放入数组中 talkList.push({id: nanoid(),title}) } return {talkList,getTalk} }); 组件通信组件关系传递方式父传子1.props 2.v-model 3.$refs 4.默认插槽、具名插槽子传父1. props 2.自定义事件 3.v-model 4.$parent 5.作用域插槽祖传孙、孙传祖1. $attrs 2. provide 、inject兄弟间、任意组件间1.mitt 2. piniapropsprops 是使用频率最高的父子组件通讯方式，常用于：父 <=> 子父传子，属性值是非函数子传父，属性值是函数父组件 父组件， 我的车：{{ car }} 儿子给的玩具：{{ toy }} <Child :car="car" :getToy="getToy"/> 子组件 子组件 我的玩具：{{ toy }} 父给我的车：{{ car }} 玩具给父亲 自定义事件概述：自定义事件通常用于子传父，简单来说就是子组件定义一个事件，并在需要的时候触发这个事件。接着在父组件使用子组件时，注册对应的自定义事件，并通过回调函数获取子组件的传参。:::alert-info事件传参的本质接收端: 定义一个函数，这个函数接受一个或多个参数，这些参数就是函数调用时发送方发送的参数，发送端：调用接收端定义的函数，并在调用时将需要传递的数据通过传参发送给对方:::注意：需要区分原生事件和自定义事件原生事件：事件名是特定的（click、mosueenter等等）事件对象 $event: 是包含事件相关信息的对象（pageX、pageY、target、keyCode）自定义事件：事件名是任意名称事件对象$event: 是调用emit时所提供的数据，可以是任意类型！！！示例 <Child @send-toy="toy = $event"/> 测试 //子组件中，触发事件： this.$emit('send-toy', 具体数据)父组件 父组件 子给的玩具：{{ toy }} <Child @send-toy="saveToy"/> 子组件 子组件 玩具：{{ toy }} 测试 Mitt与 Vue2 的 $bus、事件发布与订阅（pubsub）功能类似，可以实现任意组件间的通信。mitt 和自定义函数玩法很像，只是通过一个第三方库实现事件的订阅和触发，进而完成数据的交互工作。区别在于自定义组件，只能实现父子之间的数据传递，而第三方库只需要响应的组件引入，就可是实现事件的订阅与触发。安装 mittnpm i mitt新建文件：src\utils\emitter.ts 用于存放 mitt 初始化 全局变量// 引入mitt import mitt from "mitt"; // 创建emitter const emitter = mitt() /* // 绑定事件 emitter.on('abc',(value)=>{ console.log('abc事件被触发',value) }) emitter.on('xyz',(value)=>{ console.log('xyz事件被触发',value) }) setInterval(() => { // 触发事件 emitter.emit('abc',666) emitter.emit('xyz',777) }, 1000); setTimeout(() => { // 清理事件 emitter.all.clear() }, 3000); */ // 创建并暴露mitt export default emitter数据接收端绑定 send-toy 事件，import emitter from "@/utils/emitter"; import { onUnmounted } from "vue"; // 绑定事件 emitter.on('send-toy',(value)=>{ console.log('send-toy事件被触发',value) }) onUnmounted(()=>{ // 解绑事件 emitter.off('send-toy') })发送端触发import emitter from "@/utils/emitter"; function sendToy(){ // 触发事件 emitter.emit('send-toy',toy.value) }v-model 传参:::alert-warning这一节讲的是 UI 组件库，如何通过 v-model 实现父组件中响应式变量的双向绑定的底层原理，实际开发中如果不写底层组件 很少会用到这个。但是这个知识点很重要，需要理解底层原理。:::概述：实现父子组件间的双向通信v-model 的本质 <input type="text" :value="userName" @input="userName =(<HTMLInputElement>$event.target).value" >组件上的 v-model本质：:modelValue + @update:modelValue 事件 <AtguiguInput v-model="userName"/> <AtguiguInput :modelValue="userName" @update:modelValue="userName = $event"/>AtguiguInput 组件中 指定 value 的名称如果指定 value 的名称，则可以传递多个 双向绑定变量<AtguiguInput v-model:ming="username" v-model:mima="password"/>AtguiguInput 组件中 可以这样写 <input type="text" :value="ming" @input="emit('update:ming',(<HTMLInputElement>$event.target).value)" > <input type="text" :value="mima" @input="emit('update:mima',(<HTMLInputElement>$event.target).value)" > :::alert-info$event 是啥？ 什么时候使用 $event.target ?对于原生事件（@input 这种），$event 时事件对象，可以 .target对于自定义事件，$event 是触发时 所传递的数据，不能 .target:::defineModel 传参defineModel 是 Vue 3.4+ 开始支持且官方推荐的一种组件双向绑定的写法，可以粗暴的理解为是原始 v-model 的语法糖写法。我们以上面的 v-model 为例 使用 defineModel 可以大幅简化组件内部响应值的写法，Vue 替我们完成了 :modelValue + @update:modelValue 事件。 attrs 祖传孙说明：$attrs 是一个对象，包含了所有父组件传递的标签属性:::alert-warning注意：$attrs 会自动排除 props 中声明的属性(可以认为声明过的 props 被子组件自己“消费”了):::// 父组件 父组件 <Child :a="a" :b="b" :c="c" :d="d" v-bind="{x:100,y:200}" :updateA="updateA"/> // 子组件 ============= 子组件 {{ a }} <GrandChild v-bind="$attrs"/> // 孙组件 ============= 孙组件 b：{{ b }} c：{{ c }} d：{{ d }} x：{{ x }} y：{{ y }} 点我更新A $refs 与 $parent概述: 前面提到 $ref 是组件的唯一标识，作用类似于传统 DOM 中的 ID，所以我们也可以通过 $ref 的方式来 操作对应的组件数据、事件。$refs 值为对象，包含所有被 ref 属性标识的DOM元素或组件实例。$parent 值为对象，当前组件的父组件实例对象。:::alert-info让外部获取数据的前提时通过 defineExpose 向外暴露:::父组件 父组件 房产：{{ house }} 让所有孩子的书变多 <Child1 ref="c1"/> 子组件 子组件1 玩具：{{ toy }} 书籍：{{ book }} 本 干掉父亲的一套房产 provide 与 inject概述：实现父组件与后代组件的直接通信，而不用操作中间组件在祖先组件中通过 provide 配置向后代组件提供数据在后代组件中通过 inject 配置来声明接收数据父组件 使用 provide 提供数据 父组件 资产：{{ money }} 汽车：{{ car }} 资产+1 汽车价格+1 <Child/> 后代组件 通过 inject 获取祖辈组件提供的数据 我是孙组件 资产：{{ money }} 汽车：{{ car }} 点我 其他 APIshallowRef 与 shallowReactiveshallowRef 和 shallowReactive 的作用是创建一个响应式的数据、或响应式的对象，然后只对最顶层的数据做响应式的处理，对更深层的数据不处理。用法：let myVar = shallowRef(initialValue); const myObj = shallowReactive({ ... });:::alert-info总结通过使用 shallowRef() 和 shallowReactive() 来绕开深度响应。浅层式 API 创建的状态只在其顶层是响应式的，对所有深层的对象不会做任何处理，避免了对每一个内部属性做响应式所带来的性能成本，这使得属性的访问变得更快，可提升性能。:::readDonly 与 shallowReadonly说明：创建响应式数据的深只读副本，效果类似于数据库的视图 (可以读，原变量修改，这个数据可以跟着变，但不能修改这个数据)。特点：对象的所有嵌套属性都将变为只读。任何尝试修改这个对象的操作都会被阻止（在开发模式下，还会在控制台中发出警告）。应用场景：创建不可变的状态快照。保护全局状态或配置不被修改。let sum1 = ref(0) let sum2 = readonly(sum1) const original = reactive({ ... }); const readOnlyCopy = readonly(original);shallowReadonly与 readDonly 效果类似，但只作用于对象的顶层属性，对象内部的嵌套属性仍然是可变的。特点：只将对象的顶层属性设置为只读，对象内部的嵌套属性仍然是可变的。适用于只需保护对象顶层属性的场景。const original = reactive({ ... }); const shallowReadOnlyCopy = shallowReadonly(original);toRaw 与 markRawtoRaw作用：用于获取一个响应式对象的原始对象。（toRaw 返回的对象不再是响应式的，对其进行修改也不会触发视图更新）:::alert-info官网描述：这是一个可以用于临时读取而不影响代理访问/跟踪开销，或是写入而不触发更改的特殊方法。 不建议保持对原始对象的持久引用，需要谨慎使用。::::::alert-info使用场景：在需要讲数据提供给非 Vue 库或外部系统时，使用 toRaw 可以确保它们收到的是普通对象。axios 向服务端传递表单数据时向外部提供数据时（例如下面代码中的 showPerson 方法）lodash 处理函数::: {{person.name}} {{ person.age }} markRaw 作用：标记一个对象，使其永远不会变成响应式的。:::alert-info例如使用 mockjs 时，为了防止误把 mockjs 变为响应式对象，可以使用 markRaw 去标记 mockjs::: customRef 自定义 Ref作用：创建一个自定义的 ref，并对其依赖项跟踪和更新触发进行逻辑控制。人话：自己实现 Ref 响应式数据的效果，因为是自己实现的所以可以在数据的获取时、修改时执行一些逻辑概念梳理：track : 向 Vue 标记这个数据需要持续关注，当数据发生变化时（调用 trigger()） 需要重新通过 get 获取数据trigger : 通知 Vue Set 方法已经对数据修改完成。Ref 和 customRef 的区别：Ref 是自动挡，你只管踩油门，别的不需要管。customRef 是手动挡，离合、换挡都需要自己控制，麻烦一些，但可以在这个过程中实现更多需求自定义 Ref 实现代码： {{ msg }} 通过 Hooks 封装useMsgRef.ts 代码import {customRef} from 'vue' // 封装成 hooks 传入默认值，延迟时间 export default function(initValue:string,delay:number) { // 定义一个定时器，实现防抖的效果 let timer:number // track(跟踪、订阅)、trigger(触发、发布) let msg = customRef((track,trigger) => { return { // get 在 msg 读取时调用 get(){ // 向 Vue 标记 msg 数据很重要，需要持续关注，一旦数据发生变化，就去更新 track() return initValue }, // set 在 msg 修改时调用 set(val){ // 使用定时器 延时指定时间触发 clearTimeout(timer) timer = setTimeout(() => { initValue = val // 通知 vue 数据发生了变化 trigger() }, delay); }, } }) return {msg} }App.Vue 代码： {{ msg }} Vue3 新组件Teleport 传送门将子组件的 HTML 结构，移动到指定位置。例如下方代码 默认是在 #app 元素中渲染， 使用 to='body' 将元素渲染到 Body 标签中，脱离了 #app 根元素，且不影响交互逻辑<teleport to='body' > 我是一个弹窗 我是弹窗中的一些内容 关闭弹窗 </teleport>Suspense官方文档：https://cn.vuejs.org/guide/built-ins/suspense.html#suspense当子组件中包含了异步任务（例如网络请求）， setup 又是在组件未挂在前执行，若在 setup 中加载异步任务，如果异步任务执行较慢（例如网络请求加载用户信息）。就会造成异步加载时组件无法渲染，空着一块用户体验不好的问题。通过 Suspense 可以实现一个 load 的效果，子组件阻塞时展示一部分内容，加载完成时展示子组件内容。:::alert-warning<Suspense> 是一项实验性功能。骨架屏有专门的解决方案，不要用这个代替骨架屏::: <Suspense> <Child/> 加载中 </Suspense> Commponent 全局组件main.ts 入口文件中注册全局组件import {createApp} from 'vue' import App from './App.vue' import Child from './Child.vue' // 创建应用 const app = createApp(App) // 注册全局组件 app.component("Child",Child) app.mount('#app')App.vue 中不用引入也能用 可以直接使用这个标签 <Child/> 笔记由 两双筷子 整理，来源：https://www.bilibili.com/video/BV1Za4y1r7KE/
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 的，非空的，字符串类型的字段。