相关背景
PHPStan 是近期比较热门的一个 PHP 代码静态分析工具,可以在不运行代码的情况下找出代码中潜在的 bug。
然而 Laravel 框架使用了太多“魔法“,Laravel 项目代码通常会大量使用容器、动态方法调用等等,使 PHPStan 无法推导出相关变量的类型。
为了解决这个问题,Nuno Maduro 开源了 larastan 这个针对 Laravel 项目的 PHPStan 扩展,加载了 larastan 扩展的 PHPStan 能够识别绝大多数的 Laravel 特性,包括容器、Facade、甚至会通过扫描项目的 migrations 文件来推导出数据库模型的各个字段类型。
发现问题
在 RightCapital,技术团队每周都会把项目依赖升级到最新版本。在最近一次升级后,larastan 升级到了 0.5.5,我们发现 larastan 针对布尔类型的数据库字段的推导失效了,所有布尔类型的数据库字段全部被推导成了 mixed,导致我们引入的另外一个 PHPStan 扩展 phpstan-strict-rules 爆出了大量错误,所以就有了本次 larastan 的 bug 追踪之旅。
初步分析
PHPStan 的扩展通常都会提供一个 .neon 格式的配置文件,larastan 也不例外,如图:
其中 services 段就是告诉 PHPStan 这个扩展会由哪些类来扩展功能,所以我们分析问题的第一个步骤就是看看 larastan 用了哪些类来扩展。
根据 services 段里各个类的名字,我们很容易就锁定了与模型字段推导相关的类 NunoMaduro\Larastan\Properties\ModelPropertyExtension(论命名的重要性),简单浏览这个类的代码之后可以确定这个就是我们要找的地方,有 bug 的代码极有可能就在这个文件里。
在找到疑似有问题的文件之后,通常可以通过查看这个文件的历史变更记录来追踪可能出问题的代码行,但这种方式比较适合于熟悉整个项目架构的人,对于我这个第一次看 larastan 源码的人来说可能需要花费较长时间去了解 PHPStan 的扩展机制以及 larastan 的代码架构,从时间成本来看不太合适,所以我选择了 var_dump 调试大法。
var_dump 的正确姿势
既然选择了 var_dump 大法,我们需要决定在哪个文件的哪一行 dump 哪一个变量,合理的 dump 事半功倍,不合理的 dump 只会让自己迷晕在深不可测的调用栈中。
首先分析一下 ModelPropertyExtension 这个类的结构,可以发现只有 hasProperty() 和 getProperty() 两个公共方法。hasProperty 会扫描项目的 migrations 文件来构建各个表各个字段的数据库类型,并判断参数中传入的模型所对应的表是否存在对应的字段,顺带根据数据库字段类型、模型的 Casts 属性分析了这个字段的读(readableType)、写(writableType)类型并保存下来;而 getProperty 就更简单了,直接返回了 hasProperty 推导出的字段类型。
所以我们的第一个 dump 打在 getProperty 的类型推导之后,看看 larastan 推导出来的类型是不是真的是 mixed 而不是 boolean,从方法名可以知道负责推导的方法是 getReadableAndWritableTypes(再次论命名的重要性):
由于整个项目代码量非常多,这里的 dump 会被调用非常多次,为了减少干扰,我们专门新建一个类来给 PHPStan 分析:
app/LarastanDebug.php
<?php
namespace App;
class LarastanDebug
{
public function checkBooleanProperty(): void
{
$user = new \App\User();
if ($user->blocked) {
// do something
}
}
}
其中 $user->blocked 字段在在 migrations 中是 boolean 类型,在 \App\User 类中也 cast 成了 boolean:
app/User.php
.
.
.
protected $casts = [
'blocked' => 'boolean',
];
.
.
.
现在用 PHPStan 来分析一下这个 LarastanDebug 这个类:
./vendor/bin/phpstan analyze app/LarastanDebug.php
可以看到输出:
string(5) "mixed"
string(5) "mixed"
string(8) "App\User"
string(7) "blocked"
看来真的是 larastan 的锅,需要看一下负责推导的 getReadableAndWritableTypes 方法里到底做了什么事:
可以看到 $readableType 变量被初始化成 mixed,然后根据 $column->readableType 的值去做对应的调整。
所以我们的第二个 dump 就要看看 $column->readableType 的值到底是什么:
再次执行 PHPStan 分析命令,会发现这次没有任何的 dump 输出了,这是因为 PHPStan 在 0.12 之后的版本会缓存分析结果,如果文件内容没有发生变化会直接使用上一次的分析结果。
我们直接在 LarastanDebug 类最后面加一个空行并保存,再次执行分析命令,输出:
string(7) "boolean"
string(5) "mixed"
string(5) "mixed"
string(8) "App\User"
string(7) "blocked"
可以看到 $column->readableType 的值是 boolean,而 getReadableAndWritableTypes 方法里的 switch 只有 bool 没有 boolean,导致其跳过了整个 swicth,所以返回了初始值 mixed。
Bug 修复
这个 bug 的修复也十分简单,直接在 getReadableAndWritableTypes 方法里的 switch 里添加一行 case 'boolean': 即可:
删掉 LarastanDebug 类最后一个空行之后再次执行分析命令,输出:
string(7) "boolean"
string(3) "boolean"
string(8) "boolean"
string(8) "App\User"
string(7) "blocked"
符合预期。
最终提交了 PR 给 larastan 仓库,目前已经合并。
请关注我们的微信公众号「rightcapital」