做 PHP 商城的二次开发,最怕的不是需求难,而是三个月后回头发现自己把核心代码改成了麻花:Controller 里塞了几百行 SQL,一个「加个签到功能」的需求,最后动了订单 Service、改了用户表、顺手在商品模型里加了个字段。等到官方升级,git pull 一片红。
CRMEB v8 这套代码我最近翻得比较细,它在二开这件事上给了两个抓手:一个负责「开局别写歪」,一个负责「写完能被拦下」。前者是脚手架命令 php think make:module,后者是静态校验命令 php think arch:check。这篇文章把两条命令的真实实现和输出拆开讲,代码都是从仓库里摘的,不是示意。
一、骨架长什么样
先看结果。执行:
cd backend
php think make:module demo --label=演示模块 --type=service
生成的结构是:
modules/demo/
├── plugin.json # 模块清单,后台「插件管理」据此安装/卸载
├── App.php # 注册入口:register 登记服务,boot 加载配置与事件
├── event.php # 事件监听表(字符串事件 key)
├── Installer.php # 安装器:runMigrate -> insertMenus -> seed
├── README.md
├── config/ # 模块配置
├── route/ # admin.php / user.php 场景路由
├── controller/ # 控制器
├── service/ # 业务编排
├── dao/ # 查询封装
├── model/ # 模型
├── listener/ # 事件监听器
└── crontab/ # 定时任务
空目录会补 .gitkeep,所以骨架可以直接入库,不会因为空目录被 Git 丢掉。
二、命令本身是怎么定义的
打开 backend/crmeb/command/MakeModuleCommand.php,命令定义很干净:
$this->setName('make:module')
->addArgument('name', Argument::REQUIRED, '模块名(小写字母/数字/下划线,如 demo)')
->addOption('label', null, Option::VALUE_REQUIRED, '模块中文名(展示名,默认为「{Name}模块」)')
->addOption('type', null, Option::VALUE_REQUIRED, '模块类型(service/product/trade/channel/marketing,默认 service)')
->setDescription('创建零侵入模块骨架(plugin.json + App + event + Installer + 分层目录)');
```
两个常量决定了骨架的边界:
```php
// 骨架分层目录(空目录补 .gitkeep 以便入库)
private const DIRS = ['config', 'route', 'controller', 'service', 'dao', 'model', 'listener', 'crontab'];
// 模块类型选项(与 plugin.json 的 type 字段对应)
private const TYPES = ['service', 'product', 'trade', 'channel', 'marketing'];
也就是说,模块类型在命令层面就被收敛成 5 种,你选不出第 6 种。这是刻意设计的:类型决定模块挂在哪个注册中心上,类型一多,注册中心就散了。
命令还会做两件防呆:
if (!preg_match('/^[a-z][a-z0-9_]*$/', $name)) {
$output->error("模块名 {$name} 不合法:需以小写字母开头,仅含小写字母/数字/下划线");
return 1;
}
if (is_dir($basePath)) {
$output->error("模块 {$name} 已存在: {$basePath}");
return 1;
}
```
起名强制小写加下划线,是因为模块 key 会被写进 `eb_module_install_log`、菜单归属、共享资源登记里,大小写混用后期排查会很难受。
## 三、四个生成文件,各自管一件事
### 1. plugin.json:唯一的身份来源
生成的清单模板如下,字段和仓库里已有的模块一致(比如卡密商品模块):
```json
{
"key": "demo",
"name": "演示模块",
"description": "演示模块:一句话说明本模块提供的能力",
"version": "1.0.0",
"author": "CRMEB Team",
"type": "service",
"path": "modules/demo",
"default_install": false,
"require": {
"crmeb": "^8.0",
"php": "^8.0"
}
}
```
`default_install` 这个字段值得单独说。它在安装器里有明确语义:
```php
/**
* default_install 与 required 的区别:两者都在全新安装时自动安装,
* * 但 default_install 的模块允许在后台卸载/禁用,required 则为核心保护模块不可卸载。
* */
* public function getDefaultInstallPlugins(): array
* ```
所以一个模块装完能不能被卸掉,在清单阶段就定了,不靠运行时判断。
### 2. App.php:register 与 boot 的分工
```php
class App
{
public static function register(ThinkApp $app): void
{
// TODO: 按需登记本模块服务
}
public static function boot(ThinkApp $app): void
{
if (is_file($eventFile)) {
$event = include $eventFile;
if (is_array($event)) {
$app->loadEvent($event);
}
}
}
}
```
`register` 阶段做登记(监听器、注册中心、定时任务),`boot` 阶段加载配置和事件表。这个顺序不能颠倒:`boot` 里拿不到还没注册的服务。
### 3. event.php:用字符串 key,不用类常量
```php
return [
'listen' => [],
];
```
模板注释里写得很直白:触发统一使用字符串 key `event('XxxEvent', $data)`,禁用类常量。原因也不难理解——类常量要求监听方引用触发方的类,模块之间就耦合了;字符串 key 只依赖事件名这个契约,跨模块才可能互不污染。
### 4. Installer.php:生命周期是有顺序的
```php
class Installer extends BaseModuleInstaller
{
protected function runMigrate(): void
{
// TODO: 建表
}
protected function rollbackMigrate(): void
{
// TODO: 删表
}
protected function getMenuConfig(): array
{
return [];
}
}
```
基类 `crmeb\module\BaseModuleInstaller` 的 `install()` 编排了整个流程,这段代码是理解「模块为什么能装得干净」的关键(节选,保留主干):
```php
public function install(): array
{
if ($this->isInstalled()) {
return ['status' => 'skipped', 'message' => '模块已安装,跳过'];
}
// 1. 前置检查
$check = $this->checkRequirements();
if (!empty($check['errors'])) {
throw new RuntimeException('前置检查失败');
}
// 2. 建表(DDL,必须在事务外执行)
$this->runMigrate();
// 3. 菜单、种子、共享资源、安装日志(在事务内执行)
Db::startTrans();
try {
$this->insertMenus();
$this->seed();
$this->claimSharedTables();
$this->logInstall();
Db::commit();
} catch (Throwable $e) {
Db::rollback();
throw $e;
}
return ['status' => 'success'];
}
```
注意 `isInstalled()` 的短路返回 `skipped`:安装中断后重试不会重复建表、重复插菜单、重复写种子。这就是「Installer 必须幂等」在代码里的落点,不是靠文档喊口号。
至于为什么建表放在事务外——MySQL 的 DDL 会隐式提交,包在事务里等于自欺欺人,代码注释里也写了这一句。
## 四、arch:check:把规范变成退出码
骨架只解决「新代码往哪儿放」,解决不了「有人往 app/ 里 import 了 modules/」。这就要靠 `php think arch:check`。
它的铁律表是硬编码的常量:
```php
private const FORBIDDEN_NS = [
'app' => ['modules'],
'crmeb' => ['app', 'modules'],
'driver' => ['app', 'modules'],
];
```
翻译成人话:
- `app/` 不能反向引用 `modules/`,否则核心被可选模块绑架;
- - `crmeb/` 是框架层,不能引用 `app/` 和 `modules/`;
- - `driver/` 只禁止 `app` 和 `modules`,因为驱动继承 `crmeb` 的基类、实现其接口本来就是官方范式。
除了依赖方向,它还检查跨模块互引、事件触发是否用了类常量、以及残留的 `use` 语句。默认扫描四层:
```php
private const LAYERS = ['app', 'crmeb', 'modules', 'driver'];
private const SKIP_DIRS = ['vendor', 'runtime', 'node_modules', 'backup', '.git', '.crmeb'];
实际跑一次(当前仓库状态):
cd backend
php think arch:check
输出末尾的汇总:
汇总:严重 0 处 / 一般 247 处
严重违规 > 0 时退出码为 1(可接入 CI);修复标准见 .crmeb/rules/架构规范.md
247 处「一般」基本都是残留 use 语句这类静态判定有局限的项,输出里会逐条标 请人工确认;严重级是 0,退出码为 0。这套分级挺务实——只有严重违规影响退出码,否则一堆无害告警会让 CI 直接失去意义。
要接到流水线上,用 JSON 输出更省事:
php think arch:check --json
返回结构是 files / summary / items,items 里每条带 level、check、layer、file、line、detail,解析一下就能贴到 PR 评论里。想只查某个模块:
php think arch:check --dir=modules/product/cardkey
五、几个容易踩的点
- 类型选错,后面全错。
--type只有service / product / trade / channel / marketing。做产品类型选product,做活动玩法选trade或marketing,选完就决定了你要填哪个 Handler,别指望改 json 就能糊过去。 -
- 改完事件和路由要重启长驻进程。 模块注册、事件表、队列任务都挂在常驻进程里,改完不重启
queue:listen/timer/workerman,你会以为代码没生效。
- 改完事件和路由要重启长驻进程。 模块注册、事件表、队列任务都挂在常驻进程里,改完不重启
- 菜单、路由、前端三件套要一起齐。 后台页面能不能打开,同时取决于前端组件存在、
eb_system_menus有记录、角色有接口权限、模块已启用。只加前端路由或只插菜单,成品。 -
- 别把插件路由写进核心路由文件。
backend/app/adminapi/route/*.php是核心地盘,插件路由放modules/**/route/admin.php,由模块路由加载器按安装状态决定是否加载。
六、小结
- 别把插件路由写进核心路由文件。
这套组合的价值不在于命令有多复杂,make:module 本质上就是个模板渲染,几百行代码。它真正解决的是「团队里每个人二开起手式不一样」的问题:骨架统一了目录和生命周期,arch:check 统一了依赖方向和 CI 门禁。规范写在文档里没人看,写进命令和退出码里就绕不过去了。
除了这两条,仓库里还有 plugin:manifest-check、plugin:required-check、event:check、registry:check、channel:menu-check,都是同一思路的一致性检查,提交前一起跑一遍,比事后 review 便宜得多。
项目基于 Apache-2.0 协议开源,仓库地址见评论区置顶。文中命令与代码均取自当前仓库实际文件(backend/crmeb/command/MakeModuleCommand.php、backend/app/command/ArchCheck.php、backend/crmeb/module/BaseModuleInstaller.php),不同版本可能有差异,以你本地代码为准。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/evoxwht/article/details/169545837



