首页 / 资讯中心 / 文章详情

ThinkPHP6插件化实战:think-addons实现模块解耦与钩子扩展

ThinkPHP6插件化实战:think-addons实现模块解耦与钩子扩展 ★ FEATURED ARTICLE
简介think-addons 是一套专为 ThinkPHP6 打造的插件扩展包面向需要将业务模块按插件化方式拆分、降低模块耦合、统一扩展入口的 PHP 开发者。其核心价值在于安装后能在配置目录自动生成插件配置文件也支持通过命令行指令手动创建配合钩子和路由配置开发者可以在业务代码中灵活挂载、启停插件将公共逻辑沉淀为可复用插件。压缩包共 15 个文件整体仅 33KB主体为 11 个 PHP 源码文件用于实现插件基类、自动加载与辅助函数另含模板、配置、版本管理与说明文档内容精简且结构完整适合快速部署到项目中学习。包内的核心类、辅助函数和语言包目录尤其值得细读能帮助读者理清插件注册、自动加载、钩子分发等关键环节便于快速接入项目或二次开发。目前已有 2207 人学习适合有一定 ThinkPHP 基础、希望规范项目插件机制的开发者下载参考。1. think-addons 是给 ThinkPHP6 补上的那层插件能力think-addons 是 ThinkPHP6 生态里一套很实用的插件程序包解决的问题非常具体你有一套跑了两三年的 TP6 系统客户每次提需求都要动核心代码A 项目的下单逻辑和 B 项目的下单逻辑高度相似但就是不敢提公共模块一提就牵一发动全身。think-addons 的思路是给 TP6 装上「应用级插件」机制让业务以插件为单位拆开每个插件有自己的控制器、模型、视图和配置可以独立装、卸、启、停还能通过钩子嵌入主业务流程。它不是替代 TP6 的多应用模式也不是把框架改造成微内核它只是把「插件化」这件事补齐让模块边界从约定上升为机制。这篇文章围绕这套插件程序包展开先讲清它装了什么、怎么配再给出一条从零跑通的最小命令路径之后专门分析钩子、路由、模板这三处最常见的翻车现场最后落到打包分发和验证清单。适合正在维护多个相似 TP6 系统、想把业务拆开给团队并行开发的人也适合刚被「复制粘贴改参数」折磨过一轮的开发者——照着做能省下不少后悔药。2. 安装与目录约定composer 引入后的最小可用结构think-addons 能成立依赖一套严格但不复杂的目录约定。这一章先把安装、配置、目录结构、命名空间映射和插件生命周期讲透后面所有操作都建立在这套约定上所以这一章值得多花五分钟。2.1 composer 安装与 config/addons.php 关键参数引入 think-addons 的方式没有特殊性一条 composer 命令的事。需要注意你的环境里包名可能带不同的 vendor 前缀下面命令里的your-vendor要替换成实际引入包的仓库归属这不是可有可无的细节后续命名空间和自动加载配置都要和它对齐。# 安装 think-addonsvendor 段替换为实际来源 composer require your-vendor/think-addons --prefer-dist包装好后需要在config目录下新建addons.php这是整套插件体系的配置文件。不同来源的包在字段命名上略有差异但下面这几个字段是这个标题下最常见、也最不能省的?php // config/addons.php return [ // 插件根目录必须写绝对路径结尾带目录分隔符 addons_path root_path() . addons . DIRECTORY_SEPARATOR, // 插件命名空间前缀所有插件的类都挂在这个前缀下面 addons_namespace think\\addons, // 访问插件资源的 URL 前缀 addons_route_prefix addons, // 不带控制器名的默认控制器访问 /addons/shop 时自动落到 Index addons_default_controller Index, ];这里最容易搞错的是addons_path的结尾分隔符。root_path()已经带了一个目录分隔符如果你再拼一个/进去在 Windows 下会出现addons//shop这类路径某些文件系统操作会触发不可名状的错误。我一般统一用DIRECTORY_SEPARATOR拼接别手打斜杠。addons_namespace为什么要单独配置而不是写死在代码里因为这套插件机制会把「插件目录名」直接映射成「命名空间段」。比如目录是addons/shop那这个插件的控制器类就在think\addons\shop\controller下。如果你和团队里其他人都在用同一套 TP6 项目但各自装了不同改版的 addons 包命名空间前缀不一致会导致两个包互相认不出对方装的插件所以配好这个前缀并且全团队统一是插件能否被自动加载的第一道门。2.2 插件目录结构与 PSR-4 命名空间映射think-addons 的目录结构直接决定你能写什么、不能写什么。一个最小可用的插件长这样项目根目录/ ├── addons/ │ └── shop/ │ ├── Addon.php # 插件引导类包机制会扫描它 │ ├── config.php # 插件自身配置 │ ├── hooks.php # 插件要订阅的事件映射 │ ├── controller/ │ │ └── Index.php │ ├── model/ │ ├── view/ │ │ └── index.html │ └── install.sql # 安装脚本可选的 ├── app/ ├── config/ │ └── addons.php ├── public/ └── composer.json目录名shop就是插件唯一标识它同时承担三个身份URL 访问标识、命名空间段、日志和缓存目录下标。改插件目录名等于换了一个新插件这一点很多人没意识到改名后旧数据全变成悬空数据。为了让think\addons\shop\controller\Index这种类能被自动加载必须在composer.json里加一段 PSR-4 映射{ autoload: { psr-4: { think\\addons\\: addons/ } } }加完执行一次composer dump-autoload。这行映射的含义是所有think\addons\开头的类都去addons/目录下按命名空间找文件。因为 PSR-4 是「前缀映射」think\addons\shop\controller\Index会落向addons/shop/controller/Index.php新增目录不需要改配置这正是插件化最需要的能力——你不能每次塞一个新插件都去改一次 composer.json 再加一次 dump。用 classmap 或files自动加载都做不到这种运行时零配置扩展这就是选型上 PSR-4 压倒另外两种加载方式的核心原因。2.3 安装、卸载与启停插件生命周期怎么落地think-addons 的机制规定每个插件根目录必须有一个Addon.php引导类它继承包提供的think\Addons基类并实现install、uninstall、enable、disable这四个生命周期方法。这四个方法不是摆设它们会在你通过命令行或后台点击「安装插件 / 卸载插件 / 启用 / 停用」时被调起来负责建数据表、写菜单、清理数据这类脏活。?php namespace think\addons\shop; use think\Addons; use think\facade\Db; class Addon extends Addons { protected $info [ name shop, title 商城插件, desc 一个演示用的商城插件, status 1, author your-team, version 1.0.0, ]; // 安装建表、初始化菜单、写入默认配置 public function install() { Db::startTrans(); try { Db::execute(CREATE TABLE IF NOT EXISTS shop_order ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_sn VARCHAR(32) NOT NULL DEFAULT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4); Db::commit(); return true; } catch (\Throwable $e) { Db::rollback(); return false; } } // 卸载删掉安装时写入的东西这一步很多插件做得不彻底 public function uninstall() { Db::execute(DROP TABLE IF EXISTS shop_order); return true; } public function enable() { // 更新插件状态为启用记录启用的时间点 return true; } public function disable() { // 停用时不删数据只关掉入口给以后重新启用留后路 return true; } }$info数组是插件的身份证name必须和目录名完全一致否则包机制在做事件路由时会匹配不上。install和uninstall要写成互逆操作install 建什么uninstall 就删什么。这里最稳妥的写法是用事务把多条 SQL 包起来任意一步失败整体回滚避免装到一半插件状态是「已安装」但表是空的这种半吊子状态会浪费你一下午。启用和停用的实现则刻意区分于安装卸载停用不删表只关入口。这样设计是因为插件往往积累了业务数据误停还能无痛恢复。disable 里你只需要更新一个状态位或配置文件里的启用开关绝不碰数据结构。3. 跑通第一个插件引导文件、控制器、路由、视图这一章以「从零建一个能访问的插件」为目标按引导文件、控制器、路由、视图四步走。每一步都给最小可运行代码并解释为什么要这样写。走完这套流程你就拥有一个可以独立访问的插件实例。3.1 Addon.php 引导类info 字段一张表看全上一章已经见过Addon.php的骨架这一章再抠细一点把info里的字段职责讲清楚因为后台展示、插件列表、安装判断都靠它字段说明约束name插件唯一标识必须与插件目录名一致全局唯一title后台列表显示名称可以带中文只做展示desc插件功能描述一句话说清用途status默认启用状态1 启用0 停用安装后生效author开发者署名文本字段不影响运行version插件版本号推荐用x.y.z语义化版本一个常被忽略的点name只允许小写字母、数字和下划线不要用中划线。因为它在命名空间里要当合法的 PHP 段名shop-center这种目录名会直接在自动加载阶段报语法级错误你查半天查不到。当初我因为一个-折腾了两个小时最后把目录改成shop_center才通这个坑记到避坑章里但这里先给你提个醒。引导类的install方法还有个细节值得展开它返回true包机制才把插件标记为已安装。如果你在安装过程中要写入系统菜单、权限节点、后台路由这类依赖主应用表结构不存在的记录尽量在install里用Schema::hasTable()先判断别硬写。插件在不同主项目上装主项目的菜单表前缀可能是menu_也可能是rule_写死会导致换个项目就装不上。我一般会把所有涉及主项目表名的操作收口到一个配置项里让使用方安装前按实际情况改。3.2 控制器与 URL 访问规则插件的控制器和普通 TP6 控制器的写法基本一致差别在基类和视图定位。一个最小控制器如下?php namespace think\addons\shop\controller; use think\Addons\Controller; class Index extends Controller { // 访问地址/addons/shop/index/index public function index() { // fetch() 不带参数时自动定位到 addons/shop/view/index/index.html return $this-fetch(); } // 访问地址/addons/shop/index/detail public function detail($id 0) { return json([id $id, name demo]); } }为什么要继承think\Addons\Controller而不是框架自带的think\Controller因为插件的视图目录不在app/下TP6 原生控制器的fetch()只会按app/下的模块路径找模板找不到插件目录去。think-addons 的控制器基类在fetch()内部做了路径重定向解析当前控制器所属命名空间取到插件名再把模板路径拼到addons/{插件名}/view/{控制器名}/{操作名}.html。你自己写也行但每个控制器都要写一遍包机制帮你收口了。URL 访问规则是这套体系里最重要的一个约定/addons/{插件名}/{控制器}/{操作}。{插件名}对应目录名{控制器}默认首字母大写、不用加controller后缀操作名就是控制器里的方法名。如果你希望 URL 更短可以配置addons_default_controller这样/addons/shop会自动落到Index::index()。有的场景下你想给插件定义一套对外更友好的路由而不是露出三段式原生 URL。通常做法是在插件安装时向主应用注册路由规则或在全局route配置里追加// config/route.php 或应用全局路由文件 use think\facade\Route; // /shop/detail/1001 映射到插件内的 Goods 控制器 Route::rule(shop/detail/:id, \\think\\addons\\shop\\controller\\Goodsdetail);这里有一个优先级问题全局路由和插件三段式 URL 同时存在时显式注册的路由优先匹配三段式 URL 作为兜底。所以插件模式和平常开发没有冲突你完全可以给核心插件配语义化路由内部插件继续用默认三段式。3.3 视图模板定位fetch 与模板覆盖顺序插件视图的目录结构是固定的如果你不按约定放文件fetch()就会报模板不存在。规范如下addons/shop/view/ ├── index/ │ └── index.html # Index 控制器的 index 操作 └── goods/ └── detail.html # Goods 控制器的 detail 操作fetch()无参调用时解析逻辑是view/当前控制器名转为小写/当前操作名.html。控制器名Index变成目录index是约定不是框架自动帮你转的Windows 上大小写不敏感没事Linux 上写错一个字母就是白屏加一条template not exists日志。模板内容组织上我建议插件的视图不引用主应用的公共头尾保持独立。原因很实际插件要能在多个 TP6 项目里复用每个主项目的公共头部样式、JS 依赖版本都不一样插件视图一旦include主应用的公共文件换一个项目就要改模板插件就失去了「装上即用」的意义。插件自己带一套极简布局丑一点没关系可移植性优先。模板里的静态资源引用也有讲究。常见做法是插件资源发布到public/addons/{插件名}/目录下模板里用根路径引用!-- 插件独立资源发布到 public/addons/shop/ 后这样引用 -- link relstylesheet href/addons/shop/css/style.css script src/addons/shop/js/app.js/script这里要注意/addons/在 public 下和插件路由前缀addons撞了。如果路由里已经定义了addons/:addon的规则静态文件请求也会被路由接管导致样式加载失败。我的习惯是主项目里给静态资源先写一条排除规则把public/addons/的物理文件请求在路由之前直接放行这属于部署层问题放避坑章细说。4. 钩子把插件嵌进业务流监听注册与插件间调用插件能做独立访问只是第一步真正让插件体系产生价值的是钩子机制主业务流程在关键节点抛事件插件订阅这些事件并做出响应双方都不需要 import 彼此的类。这一章讲钩子从哪来、参数怎么传、返回值怎么收以及插件之间怎么安全地互相调用。4.1 业务钩子怎么埋主应用埋点与系统钩子think-addons 的钩子建立在 TP6 事件机制之上。主应用在业务节点埋点插件在hooks.php里订阅这是两个不同侧面的工作缺一不可。主应用侧在订单支付成功后埋一个事件?php namespace app\index\controller; use think\facade\Event; class Order { public function pay() { // 支付成功后触发订单支付事件 $orderId 10086; $amount 299.00; Event::trigger(order.paid, [ order_id $orderId, amount $amount, paid_at date(Y-m-d H:i:s), ]); return json([status 0, msg ok]); } }插件侧在插件根目录写hooks.php声明这个插件要订阅哪些事件?php // addons/shop/hooks.php return [ // 事件名 监听器列表 order.paid [ \think\addons\shop\listener\OrderPaid::class, ], ];监听器类如下?php namespace think\addons\shop\listener; class OrderPaid { public function handle($event) { // 收到事件参数做自己插件的业务处理 // $event[order_id] 拿到订单号 // 写入积分、发通知、同步库存都在这做 return true; } }这套交互的实质是主应用完全不知道有哪些插件订阅了order.paid它只负责把事件抛出去插件也完全不关心主应用怎么处理订单它只关心事件参数里有没有自己要的数据。双方通过「事件名 参数数组」解耦这正是把业务织进主流程而不污染核心代码的机制基础。选事件而非直接函数调用的理由也在这里新增一个响应方不需要改主应用任何代码。需要注意事件命名用点分格式如order.paid是为了可读性和冲突规避而不是框架要求。同一个事件名可以被多个插件订阅全部依次执行一个插件也可以订阅多个事件写多个键值对即可。4.2 参数传递与返回值约定事件参数是插件间信息传递的正式通道但它有一个先天限制参数必须能被可靠地序列化传递。TP6 事件系统在同一进程内同步触发时传数组没问题但如果你在事件里接收一个模型对象或一个资源句柄后续落库、记录日志、投递队列时对象状态可能已经变化或根本无法序列化。经验法则是事件参数只传标量数据和 ID需要完整数据时监听器内部根据 ID 再去查一遍库。// 埋点侧只传关键 ID Event::trigger(order.paid, [order_id $orderId]); // 监听器侧用 ID 自取数据 public function handle($event) { $order \think\facade\Db::name(order)-where(id, $event[order_id])-find(); // 继续处理 }这样做还有一个额外收益如果监听器因为数据库延迟或网络问题失败事件的发起方不受影响日志里记录的是 ID重放时只需要把历史 ID 重新触发一遍数据不会重复插入。如果你在参数里直接传对象引用重放时拿到的可能是一份已经变更过的快照排查一致性问题会非常痛苦。返回值这块TP6 事件监听器的返回值和事件触发的返回聚合在不同版本上表现不完全一致。我习惯不用链式聚合而是让监听器把结果写入自己插件的日志表或队列任务主应用侧只需要确认事件已触发不依赖监听器的处理结果。这样把同步耦合降到最低避免一个插件的故障拖垮主流程。4.3 插件间调用别直接 new走统一入口业务复杂到一定程度插件之间难免要互相调用购物车插件要读库存插件的状态库存插件要读订单插件的配置。最直觉的写法是直接new对方的类但这是在插件体系里最容易被埋雷的写法。常见做法是包机制提供一个统一的插件入口按插件名拿门面对象?php // 通过全局 addons() 辅助函数拿插件实例 $order addons(order); // 调用该插件的控制器方法 $data $order-index-detail(1001); if ($data[status] 0) { // 走到这里说明订单插件可用处理后续逻辑 }为什么不推荐直接new \think\addons\order\controller\Index()核心原因有二。第一TP6 的容器管理了配置解析、依赖注入和中间件插件控制器继承的基类在构造函数里可能做了视图定位、请求上下文绑定等初始化你绕过容器直接 new等于跳过了这些初始化拿到的控制器实例缺失运行时上下文调用时出现「配置不存在」「模板定位错误」都是正常的。第二插件可能被停用或卸载统一入口可以在内部做状态检查插件停用了就直接返回空结果而new一个已停用插件的类仍然能成功实例化只是业务数据已经不存在接口返回一堆空壳数据会让上层困惑。插件间调用还有一条纪律要守着——不允许出现循环调用。插件 A 调 B、B 又调 A在事件场景里的表现是同一事件被反复触发日志会刷疯。如果你发现某个接口突然卡顿优先查是不是两个插件的事件监听器互相触发解法是事件里加去重标记或把跨插件数据交互改成队列异步别在同步链路里打转。5. 避坑指南think-addons 最常见的 5 个翻车现场这一章是血泪经验每条都是现象、原因、解决的结构直接对号入座就行。翻车 1类找不到报Class think\addons\shop\controller\Index not found现象插件目录结构完全正常文件都在但访问 URL 时抛类不存在异常。 原因大多数情况是composer.json的 PSR-4 映射没写或者addons_namespace配置和映射前缀不一致。think\addons\和think\\addons\\写错一个反斜杠、大小写不一致都会导致找不到类。另一个常见原因是装完插件后没有重新执行composer dump-autoload新加的文件不在自动加载索引里。 解决先composer dump-autoload -o然后检查config/addons.php的addons_namespace和composer.json里 psr-4 的键是否一致两边必须是同一个前缀。再用composer dump-autoload -o生成优化索引如果还不通直接看vendor/composer/autoload_psr4.php里有没有生成对应映射这一步能定位到底是谁没对齐。翻车 2模板报template not exists但文件明明在现象控制器正常执行fetch()抛异常说模板文件不存在路径搬出来对着看文件确实在那个位置。 原因模板文件命名和控制器方法名不匹配。比如控制器方法叫orderList但模板文件名是order_list.htmlTP6 视图规则默认按小写下划线风格匹配或者控制器是驼峰命名OrderGoods而目录写成了ordergoods。Linux 下大小写敏感任何一个字母的偏差都会让模板查找失败。 解决核对三层view/{控制器名转小写}/{操作名}.html。控制器名转为小写是约定所以OrderGoods对应view/ordergoods/方法名orderList对应order_list.html。如果急着跑通可以显式传路径$this-fetch(goods/detail)但根治还是统一命名规范别混用驼峰和下划线。翻车 3URL 访问 404或路由规则把插件路径吞了现象本地环境访问/addons/shop/index/index一切正常部署到 Nginx 后同样地址直接 404。 原因Nginx 配置里如果只做了try_files $uri $uri/ /index.php?$query_string;$uri指向一个不存在的物理路径时请求被重写到入口文件并带上了完整路径参。但有的配置写死了location /只放行index.php的请求/addons/前缀不在放行范围内匹配不中就返回 404。这不是插件代码问题是 Web 服务器的重写规则没给插件 URL 留通道。 解决确认伪静态规则把路由交给入口文件时不要对addons前缀做特殊拦截。Nginx 侧可以这样加location / { try_files $uri $uri/ /index.php?s$uri$args; # 如果路径以 /addons/ 开头直接交给 index.php 处理 if (!-e $request_filename) { rewrite ^/addons/(.*)$ /index.php?s$1 last; } }Apache 侧则确认.htaccess里的RewriteRule没有把addons排除。这条最隐蔽因为本地 PHP 内置服务器或集成环境里伪静态规则已经配好了只有上了正式环境才炸。翻车 4钩子不执行改完hooks.php没反应现象主应用里Event::trigger(order.paid)有代码插件里监听器也注册了但监听器就是没跑日志也没有任何痕迹。 原因最常见的三个第一TP6 有配置缓存和路由缓存早期读过一次hooks.php后缓存了后面文件改了不生效第二监听器类名写错或者监听器类没有加入自动加载事件触发时报错但被吞掉了第三事件名大小写不一致埋点时写OrderPaid订阅时写order.paid事件系统严格区分字符串。还要检查插件是不是处于停用状态停用的插件不会有监听器被注册。 解决按序排查先php think clear清一遍所有缓存再检查hooks.php的return数组里监听器类是否存在、命名空间对不对然后composer dump-autoload确保监听器类可加载。最后在监听器handle第一行写日志确认事件真的到达而不是在主应用侧就抛异常了。这套做下来80% 的「钩子不执行」都是缓存或类名问题。翻车 5卸载插件后数据表和配置残留现象后台点了卸载插件目录没了但数据库里插件建的表还在系统菜单里还留着插件写入的菜单项重新安装时报「表已存在」。 原因uninstall方法没写全只删了部分数据或者install和uninstall不是对称的安装时建了 5 张表卸载时只 drop 了 2 张。另一个隐蔽原因是安装时用了文件里的install.sql卸载时只调了uninstall()方法而方法里没有读取install.sql去反向生成 drop 语句。 解决把install和uninstall写成一对照表。我自己的做法是安装时把创建的每张表名记录到插件配置的installed_tables字段里卸载时遍历这个字段逐张DROP TABLE IF EXISTS。同时卸载的最后一步检查config/addons.php里是否还残留该插件的配置键有就一并删除。这样即使插件代码写得毛糙卸载动作也是可预期的不会污染主项目。6. 打包分发与验证清单让别人一条 composer 命令装起来插件写到自己用不难难在交付给另一个人或另一个团队时对方环境里能不能一条命令装起来、装上就能跑。这一章给一份能照着勾的验证清单以及把插件包做成可分发状态的一个技巧。先给验证清单你在交包之前逐项过检查项通过标准失败时会看到什么干净环境安装在只有基础 TP6 的机器上 composer 安装后直接访问插件 URL 正常类不存在、路由 404install 幂等连续执行两次安装第二次仍正常且不报表已存在table already exists异常uninstall 清理卸载后数据库无残留表、菜单配置无残留项旧表残留、重装冲突钩子订阅触发事件时监听器执行日志有对应记录钩子无响应静态资源public/addons/{插件名}/下资源全部请求 200样式丢失、CSS 404路由兼容插件默认三段式 URL 和自定义路由都能访问部分入口 404分发层面最标准的做法是把插件包放到私有 composer 仓库团队内成员用composer require直接拉取。你的私有仓库不需要多么复杂的平台支持Git 地址就够了{ repositories: [ { type: vcs, url: gityour-gitlab:plug-ins/shop-addon.git } ], require: { your-vendor/shop-addon: ^1.0.0 } }这里有个直接影响分发成败的细节插件目录要安置到addons/下但 composer 包默认是装进vendor/的如果你的包直接在根目录建了addons/shop这条路径会被安装者项目里已有的addons/覆盖或冲突掉。我一般的做法是包内目录结构为addons/shop/然后在包根目录的composer.json里配置安装路径覆盖{ extra: { installer-paths: { addons/{$name}: [type:think-addon] } } }让包的类型标记为自定义类型think-addon配合composer/installers这类安装器安装时直接把整个插件落到目标项目的addons/shop/。这个技巧能让「一条 composer 命令装上插件」真正成立而不是装完还要手动拷贝目录。最后一个发布技巧给插件加一个「自检命令」。我会在包的命令空间里放一个php think addon:check shop的指令检查项就是上面表格里的那六项输出 PASS 或 FAIL。交付时明确要求对端先跑自检再验收能省掉大量「在我这里好好的到你那就不行」的玄学对线。当初我吃过这个亏插件发给同事对方装上后页面空白远程查了半小时才发现是他主项目缓存了旧路由配置——有了自检命令这种环境差异问题一分钟就能定位。做插件化的核心思路就一句话把不确定的东西全部变成可检查、可回滚、可重放的明确动作。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站