Psalm 插件开发

解读

在国内一线/二线互联网公司的 PHP 技术面试中,静态分析能力已经成为区分“能写业务”与“能保障质量”的关键指标。Psalm 作为类型推断最激进的 PHP 静态分析工具,大厂(如腾讯广告、美团到店、阿里本地生活)普遍用它做增量卡口:MR 必须达到 level 2 以下才能合并。因此“会不会写 Psalm 插件”直接考察候选人能否把团队自定义规范、业务风险规则固化到工具链,实现“扫描即门禁”,而不是靠 code review 人肉提醒。面试官通常从三个维度切入:①插件加载机制;②抽象语法树(AST)节点处理;③缺陷上报与抑制。能把这三步讲透,并给出可落地的项目级示例,基本就能拿到“代码质量保障”这一评分项的满分。

知识点

  1. Psalm 架构
    • 内部扫描管道:FileProvider → Parser → AST → PluginHook → IssueBuffer
    • 插件基类:Psalm\Plugin\PluginEntryPointInterface
    • 钩子类型:AfterStatementAnalysisInterface、AfterExpressionAnalysisInterface、AfterClassLikeVisitInterface 等 10 余种
  2. 插件注册方式
    • 单文件插件:通过 psalm.xml 的 <plugin filename="xxx.php"/> 加载
    • Composer 包插件:在 composer.json 声明 extra.psalm.pluginClass,由 PluginClassLoader 自动注册
  3. 节点遍历与类型系统
    • PhpParser\Node 体系:Stmt、Expr、Name、Identifier 等
    • Psalm\Type\Union:表示化类型(int|float)、泛型、模板
    • 获取上下文:StatementsSource、Context、Codebase
  4. 自定义 Issue
    • 继承 Psalm\Issue\PluginIssue
    • 必须定义短名与级别(ERROR/WARN/INFO)
    • 通过 IssueBuffer::accepts() 上报,支持自动修复(可配 quickfix)
  5. 抑制与配置
    • 插件级别:在插件内实现 getSuppress 方法
    • 项目级别:psalm.xml 的 issueHandlers 节点
  6. 国内落地经验
    • 性能:插件必须缓存节点计算结果,避免每次扫描重复 IO
    • 规范:遵循 PSR-12 + 公司研发规范,Issue 错误码统一为 “CompanyName-RuleId”
    • 门禁:GitLab-CI 中 psalm --no-cache --output-format=gitlab,解析 json 后失败管道

答案

下面给出一个“禁止在 Service 层直接使用 GET/_GET/_POST” 的实战插件,覆盖注册、AST 钩子、Issue 上报、单元测试四个环节,可直接放进简历项目经历。

  1. 目录结构 psalm-plugin-service/ ├── src/ │ └── ForbiddenSuperGlobalInServicePlugin.php ├── tests/ │ └── ForbiddenSuperGlobalInServicePluginTest.php ├── composer.json └── psalm.xml.dist

  2. composer.json(关键片段) { "name": "mycompany/psalm-plugin-service", "extra": { "psalm": { "pluginClass": "MyCompany\PsalmPlugin\ForbiddenSuperGlobalInServicePlugin" } }, "require": { "php": ">=7.4", "vimeo/psalm": "^5.0" }, "autoload": { "psr-4": {"MyCompany\PsalmPlugin\": "src/"} } }

  3. 插件主体 src/ForbiddenSuperGlobalInServicePlugin.php

    <?php declare(strict_types=1); namespace MyCompany\\PsalmPlugin; use PhpParser\\Node\\Expr\\ArrayDimFetch; use PhpParser\\Node\\Expr\\Variable; use PhpParser\\Node\\Identifier; use Psalm\\CodeLocation; use Psalm\\Context; use Psalm\\IssueBuffer; use Psalm\\Plugin\\EventHandler\\AfterExpressionAnalysisInterface; use Psalm\\Plugin\\EventHandler\\Event\\AfterExpressionAnalysisEvent; use Psalm\\Plugin\\PluginEntryPointInterface; use Psalm\\Plugin\\RegistrationInterface; final class ForbiddenSuperGlobalInServicePlugin implements PluginEntryPointInterface, AfterExpressionAnalysisInterface { public function __invoke(RegistrationInterface $registration, ?\\SimpleXMLElement $config = null): void { $registration->registerHooksFromClass(self::class); } public static function afterExpressionAnalysis(AfterExpressionAnalysisEvent $event): ?bool { $expr = $event->getExpr(); if (!$expr instanceof ArrayDimFetch) { return null; } $var = $expr->var; if (!$var instanceof Variable || !is_string($var->name)) { return null; } if (!in_array($var->name, ['_GET', '_POST'], true)) { return null; } // 判断当前文件是否位于 Service 层 $source = $event->getStatementsSource(); $filePath = $source->getFilePath(); if (strpos($filePath, '/Service/') === false && strpos($filePath, '\\Service\\') === false) { return null; } $issue = new ForbiddenSuperGlobalInService( 'Service 层禁止直接使用 $_GET/$_POST,应通过 Request 对象获取参数', new CodeLocation($source, $expr) ); IssueBuffer::accepts($issue); return null; } } final class ForbiddenSuperGlobalInService extends \\Psalm\\Issue\\PluginIssue { public const ERROR_LEVEL = 2; public const SHORTCODE = 1001; }
  4. 单元测试 tests/ForbiddenSuperGlobalInServicePluginTest.php 使用 Psalm 官方提供的 Psalm\Tests\TestCase,把待测文件放到 fixtures/ 目录,运行 Psalm 后断言 IssueBuffer 是否包含指定错误码即可。此处略去 60 行样板代码,重点是在持续集成里跑 vendor/bin/phpunit,保证插件升级后规则仍生效。

  5. 使用方式 在业务仓库执行 composer require --dev mycompany/psalm-plugin-service 然后在 psalm.xml 加一句 <plugin name="mycompany/psalm-plugin-service"/> 运行 psalm,即可在 Merge Request 中看到 Service 层违规使用超全局变量的 ERROR 级别阻断。

拓展思考

  1. 性能优化
    • 利用 Codebase::fileExists() 缓存命名空间到路径映射,减少正则匹配
    • 对大型单体仓库(>2000 文件)开启 --threads=8,插件内部必须无共享可变状态
  2. 规则平台化
    • 把插件注册中心做成 Composer metapackage,各业务线按需 require 子规则包,实现“规则即服务”
    • 结合 GitLab CI 的 parallel matrix,给不同模块设置不同 Psalm level,渐进式降低技术债
  3. 自动修复
    • 实现 PluginIssue 的 getQuickFix(),返回 TextEdit 数组,可一键把 GET[x]替换成_GET['x'] 替换成 request->query->get('x')
    • 在 MR 页面通过 GitLab API 提交 suggestion,实现“扫描-修复-合并”无人值守
  4. 与 PHPStan 对比
    • PHPStan 的 Rule 接口更函数式,Psalm 的 Hook 接口更面向事件;国内团队若已用 PHPStan,可评估 rector/rector 做统一桥接
    • 在类型推断深度上,Psalm 支持 conditional return type、template covariance,适合高阶框架;PHPStan 在性能与文档友好度上更优
  5. 安全规则
    • 可扩展检测 echo $_GET 直接输出,提示 XSS 风险
    • 检测 file_get_contents 用户输入,提示 SSRF;结合公司统一网关白名单,实现“左移”安全门禁

掌握 Psalm 插件开发后,可进一步研究 Rector 的 AST 重构、PhpStorm 的 QF 脚本,把静态分析、自动化重构、IDE 提示三者打通,形成完整的国内 PHP 工程质量保障闭环。