CatchAdmin PHP 后台管理框架 Logo CatchAdmin

Mago,基于 Rust 的极速 PHP 静态分析工具

对静态分析工具的钟爱,在同事们看来已经到了令人厌烦的地步。像 PHPStan 和 Psalm 这样的工具,能在 bug 进入生产环境之前就将其捕获;一旦习惯了这层安全网,就很难再想象没有它的编码方式。但这些工具有一个让人颇为抓狂的地方:它们“慢”。

在大型代码库上,一次完整分析可能耗时数分钟并占用数 GB 内存。为了把构建时间压回可接受的范围,常见的做法是把分析拆分成多个块并行运行。虽然这种方案有效,但它始终像是一种权宜之计。

本文介绍一种可能更好的方案。它叫 Mago(读作 mah-go),是一款面向 PHP 的静态分析工具,但它不是用 PHP 写的,而是用 Rust 写的,因此速度快得惊人。接下来将依次介绍它的安装、代码 lint、bug 分析与代码格式化。

Mago 是什么

Mago(读作 mah-go)是面向 PHP 的静态分析工具链,但与其他静态分析工具不同,它不是用 PHP 写的,而是用 Rust 写的。正因为基于 Rust,它可以被编译成单个原生二进制文件:运行它不需要 PHP 运行时,安装它不需要 Composer,每次启动也没有引导开销。只需运行这个二进制文件,它就能立刻开始工作。

安装 Mago

安装 Mago 最快的方式是使用官方安装脚本(也请留意该脚本是否已有更新):

bash
curl --proto '=https' --tlsv1.2 -sSf https://carthage.software/mago.sh | bash

该脚本会为你的操作系统下载对应的二进制文件,并将其放置到 PATH 中的某个位置。如果偏好其他方式,Mago 也可以通过常规的 Composer 安装;此外还有 Homebrew、Docker,以及(据我所知,这还是头一次见到的)WinGet 可供选择,挑一个最适合自己工作流的方案即可。在 Mac 上,brew install mago 几乎是最省事的方式。

安装完成后,验证一下是否生效:

bash
mago --version
mago 1.43.0

另外还有 setup-mago GitHub Action,可以在 GitHub Actions 中直接运行它。

配置设置

第一步是创建配置文件:

bash
mago init

它会引导你在项目根目录创建 mago.toml 文件。如果用过 PHPStan 或 Psalm,可以把 mago.toml 视为 phpstan.neon 或 psalm.xml 的对应物。在这个配置文件中告诉 Mago 要扫描哪些目录、以哪个 PHP 版本为目标,以及规则需要多严格。.toml 格式简洁易读,即使之前从未见过也能很快上手。配置项非常多,但建议先从最简单的配置开始,之后再逐步增加选项。

检查代码

mago lint 命令会根据涵盖正确性、一致性和清晰度的规则来检查代码:

bash
mago lint

假设你有下面这样一个函数:

php
<?php

function getDiscount(User $user): float
{
    if ($user->isActive()) {
        if ($user->isPremium()) {
            return 0.25;
        }
    }

    return 0.0;
}

你会得到类似如下的输出:

text
./mago lint test.php
warning[strict-types]: Missing `declare(strict_types=1);` statement at the beginning of the file.
  ┌─ test.php:1:1

1 │ <?php
  │ ^^^^^

  = The `strict_types` directive enforces strict type checking, which can prevent subtle bugs.
  = Help: Add `declare(strict_types=1);` at the top of your file.

help[function-name]: Function name `getDiscount` should be in snake case.
   ┌─ test.php:3:10

 3 │ ╭ function getDiscount($user): float
   │            ^^^^^^^^^^^ Function `getDiscount` is declared here
 4 │ │ {
 5 │ │     if ($user->isActive()) {
 6 │ │         if ($user->isPremium()) {
   · │
11 │ │     return 0.0;
12 │ │ }
   │ ╰─' Function `getDiscount` is defined here

   = The function name `getDiscount` does not follow snake naming convention.
   = Help: Consider renaming it to `get_discount` to adhere to the naming convention.

warning: found 2 issues: 1 warning(s), 1 help message(s)
 = 1 issues contain auto-fix suggestions

由于这是安全的自动修复,可以让 Mago 直接重写代码:

bash
./mago lint --fix test.php
 WARN Skipped 1 potentially unsafe fixes. Use `--potentially-unsafe` or `--unsafe` to apply them.
 INFO No fixes were applied.
./mago lint --fix  --unsafe test.php
 INFO Successfully applied 1 fixes.

现在,文件顶部已经有了 declare(strict_types=1):

php
<?php

declare(strict_types=1);

类型安全更进一步,而且无需手动修改。

接下来是广告时间,稍后继续。

分析 bug

lint 关注的是风格与清晰度,而分析关注的是捕获真正的 bug。Mago 官网上有一段描述很贴切:

分析器会为整个代码库构建语义模型。它知道函数返回什么类型、类具有哪些属性、什么操作可能抛出异常,还能找出逻辑上不可能的情况,例如对当前类型调用并不存在的方法。

这正是 mago analyze 所做的事情:

bash
mago analyze

它会排查类型不匹配、死代码以及根本不可能成立的逻辑等问题。

下面是一个简单的例子。这个函数声明要返回 int,实际却返回了字符串:

php
<?php

function totalItems(int $cartCount): int
{
    return $cartCount . " items";
}

运行分析器,问题立刻就被发现:

text
./mago analyze test.php
error[invalid-return-statement]: Invalid return type for function `totalItems`: expected `int`, but found `truthy-lowercase-string`.
  ┌─ test.php:8:12

8 │     return $cartCount . " items";
  │            ^^^^^^^^^^^^^^^^^^^^^ This has type `truthy-lowercase-string`

  = The type `truthy-lowercase-string` returned here is not compatible with the declared return type `int`.
  = Help: Change the return value to match `int`, or update the function's return type declaration.

error: found 1 issues: 1 error(s)

这正是那种会一直静悄悄地潜伏、直到某位客户在一年中最繁忙的一天踩中它的 bug。

格式化代码

Mago 还允许对代码强制执行统一的格式。mago fmt 命令会为整个代码库应用确定性且一致的格式:

bash
./mago fmt test.php
 INFO Formatted 1 file(s) successfully.

只需运行一次,团队里每个人的代码就都会是同一套风格,不会再因空格或大括号的摆放产生任何争论。

关于速度

Mago 用一条命令就能完成的事,在其他方案中需要运行另外两个工具才能实现;而且不知为何,它发现的问题比在其他工具中发现的更多(推测是由于错误的归类方式不同)。在测试中,对于约 13.4 万行的个人项目,Mago 耗时约 3.4 秒,而其他方案约 13.9 秒(8.2 秒 + 5.7 秒)。差距相当大,尤其考虑到这类工具一天要运行几十次,累积起来十分可观。

融入工作流

在 CI/CD 流水线和 pre-commit 脚本中运行 Mago 这类工具,是实践中的一大偏好。目前它正被当作 PHPStan 和 PHP_CodeSniffer 的替代品在技术栈中测试,更快的反馈结果令人期待;但它的实现仍有一些暂时无法绕开的空白,而这些是更成熟的工具早已具备的。眼下它被用作测试流程中一个“锦上添花”式的环节,以便获得更快的反馈,但它看起来还不能直接替代现有工具。

注意事项

有几点需要留意。首先,Mago 仍在不断成熟。它速度快、能力也强,但并非每一条 PHPStan 或 Psalm 规则都有对应的实现,因此你可能会发现某个习以为常的检查在这里并不存在。

其次,mago fmt 会就地重新格式化整个代码库。请先提交当前的工作,这样即使格式化带来了意料之外的结果,也能在 diff 中看清到底改了什么,并干净地回滚。

最后,请记住 Mago 只从 mago.toml 读取配置。它不会读取已有的 phpstan.neon 或 psalm.xml,因此需要重新配置一遍。

需要了解的重点

  • Mago 是一款用 Rust 编写的 PHP 静态分析工具链
  • mago lint 检查正确性、一致性与清晰度
  • mago analyze 捕获类型错误和死代码
  • mago fmt 负责格式化

它还没有生态中其他工具那么成熟,希望这种情况能很快改变。

本作品采用《CC 协议》,转载必须注明作者和本文链接