evoxwht头像

php think make:module:模块骨架与 arch:check 实测

做 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

五、几个容易踩的点

  1. 类型选错,后面全错。 --type 只有 service / product / trade / channel / marketing。做产品类型选 product,做活动玩法选 trade 或 marketing,选完就决定了你要填哪个 Handler,别指望改 json 就能糊过去。
    1. 改完事件和路由要重启长驻进程。 模块注册、事件表、队列任务都挂在常驻进程里,改完不重启 queue:listen / timer / workerman,你会以为代码没生效。
  2. 菜单、路由、前端三件套要一起齐。 后台页面能不能打开,同时取决于前端组件存在、eb_system_menus 有记录、角色有接口权限、模块已启用。只加前端路由或只插菜单,成品。
    1. 别把插件路由写进核心路由文件。 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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--