静态分析器(PHPStan)的自定义规则:针对项目特定业务逻辑的类型检查

PHPStan 自定义规则:针对项目特定业务逻辑的类型检查

大家好,今天我们来聊聊如何利用 PHPStan 的自定义规则,针对项目特定的业务逻辑进行类型检查,提升代码质量和可维护性。

PHPStan 是一款强大的 PHP 静态分析工具,它可以在不运行代码的情况下,检测代码中的潜在错误,例如类型错误、未定义的变量、以及其他潜在的问题。它基于类型推断,可以识别出很多 PHP 运行时可能发生的错误,从而帮助我们提前发现并修复 bug。

虽然 PHPStan 内置了很多规则,能够覆盖大部分常见的错误,但在实际项目中,往往会遇到一些特定的业务逻辑,这些逻辑无法被通用的规则所覆盖。这时,我们就需要编写自定义规则,来针对这些特定的场景进行检查。

1. 为什么要编写自定义规则?

  • 强制执行项目特定约束: 确保代码遵循项目约定的规范和最佳实践。例如,强制某些类只能在特定的上下文中实例化,或者某些方法只能接受特定类型的参数。
  • 预防业务逻辑错误: 提前发现与业务逻辑相关的类型错误。例如,确保金额计算总是使用 Money 对象,而不是浮点数或字符串,避免精度问题。
  • 提高代码可读性和可维护性: 通过明确的规则,可以帮助开发者更好地理解代码的意图,减少理解偏差,提高代码的可读性和可维护性。
  • 降低运行时错误: 静态分析可以帮助我们在开发阶段发现潜在的运行时错误,减少上线后的 bug 数量。

2. PHPStan 自定义规则的基本结构

一个 PHPStan 自定义规则主要由以下几个部分组成:

  • Rule 类: 实现了 PHPStanRulesRule 接口,包含 getNodeType()processNode() 两个方法。
  • getNodeType() 方法: 定义了该规则要处理的 AST 节点类型。例如,PhpParserNodeExprMethodCall 表示方法调用节点。
  • processNode() 方法: 实现了具体的规则逻辑,接收一个 AST 节点作为参数,并返回一个包含错误信息的数组。如果没有错误,则返回空数组。

3. 创建第一个自定义规则:禁止直接使用 new DateTime()

假设我们的项目要求所有日期时间都必须通过 Carbon 类来处理,禁止直接使用 new DateTime(),以统一日期时间处理方式。我们可以创建一个自定义规则来实现这个约束。

<?php

namespace MyProjectPHPStanRules;

use PhpParserNode;
use PhpParserNodeExprNew_;
use PHPStanAnalyserScope;
use PHPStanRulesRule;

/**
 * 禁止直接使用 new DateTime(),强制使用 Carbon。
 */
class NoDirectDateTimeInstantiationRule implements Rule
{
    public function getNodeType(): string
    {
        return New_::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        if (!($node instanceof New_)) {
            return [];
        }

        if (!($node->class instanceof NodeName)) {
            return [];
        }

        if ($node->class->toString() === 'DateTime') {
            return [
                '禁止直接使用 new DateTime(),请使用 Carbon。',
            ];
        }

        return [];
    }
}

代码解释:

  • NoDirectDateTimeInstantiationRule 类实现了 Rule 接口。
  • getNodeType() 方法返回 New_::class,表示该规则要处理 new 表达式。
  • processNode() 方法首先判断节点是否是 New_ 实例,然后判断 new 的类名是否是 DateTime。如果是,则返回一个包含错误信息的数组。

4. 配置 PHPStan 使用自定义规则

我们需要在 phpstan.neon 配置文件中注册自定义规则。

includes:
    - vendor/nunomaduro/larastan/extension.neon

parameters:
    paths:
        - src

    rules:
        - MyProjectPHPStanRulesNoDirectDateTimeInstantiationRule

代码解释:

  • includes 引入了 Larastan 的扩展,这是一个针对 Laravel 项目的 PHPStan 扩展,包含了许多有用的规则。
  • paths 指定了要分析的代码目录。
  • rules 列出了要使用的规则,包括我们自定义的 NoDirectDateTimeInstantiationRule

5. 测试自定义规则

我们可以使用 PHPStan 的命令行工具来测试自定义规则。

./vendor/bin/phpstan analyse src

如果 src 目录下有使用 new DateTime() 的代码,PHPStan 就会报告错误。

6. 一个更复杂的例子:确保金额计算使用 Money 对象

假设我们的项目使用 Money 对象来表示金额,以避免浮点数精度问题。我们需要确保所有金额计算都使用 Money 对象,而不是浮点数或字符串。

<?php

namespace MyProjectPHPStanRules;

use PhpParserNode;
use PhpParserNodeExprBinaryOp;
use PhpParserNodeExprMethodCall;
use PhpParserNodeExprPropertyFetch;
use PhpParserNodeExprStaticCall;
use PhpParserNodeName;
use PHPStanAnalyserScope;
use PHPStanRulesRule;
use PHPStanTypeObjectType;

/**
 * 确保金额计算使用 Money 对象。
 */
class MoneyCalculationRule implements Rule
{
    public function getNodeType(): string
    {
        return BinaryOp::class; // 监听二元运算符
    }

    public function processNode(Node $node, Scope $scope): array
    {
        if (!($node instanceof BinaryOp)) {
            return [];
        }

        $allowedOperators = ['Plus', 'Minus', 'Mul', 'Div'];

        // 只关注加减乘除
        $operatorClassName = get_class($node);
        $operatorName = substr($operatorClassName, strrpos($operatorClassName, '\') + 1);
        if (!in_array($operatorName, $allowedOperators, true)) {
            return [];
        }

        $leftType = $scope->getType($node->left);
        $rightType = $scope->getType($node->right);

        $moneyObjectType = new ObjectType('MoneyMoney');

        // 检查左右操作数是否都是 Money 对象,或者其中一个是 Money 对象,另一个是数字
        if (
            !($moneyObjectType->isSuperTypeOf($leftType)->yes() && $moneyObjectType->isSuperTypeOf($rightType)->yes())
            && !(($moneyObjectType->isSuperTypeOf($leftType)->yes() && $rightType->isInteger()->yes())
                || ($moneyObjectType->isSuperTypeOf($leftType)->yes() && $rightType->isFloat()->yes())
                || ($moneyObjectType->isSuperTypeOf($rightType)->yes() && $leftType->isInteger()->yes())
                || ($moneyObjectType->isSuperTypeOf($rightType)->yes() && $leftType->isFloat()->yes()))
        ) {
            return [
                '金额计算必须使用 Money 对象,或者 Money 对象与数字之间的运算。',
            ];
        }

        return [];
    }
}

代码解释:

  • MoneyCalculationRule 类实现了 Rule 接口。
  • getNodeType() 方法返回 BinaryOp::class,表示该规则要处理二元运算符。
  • processNode() 方法首先判断节点是否是 BinaryOp 实例,然后判断运算符是否是加减乘除。
  • $leftType$rightType 分别表示左右操作数的类型。
  • $moneyObjectType 表示 MoneyMoney 对象的类型。
  • 规则检查左右操作数是否都是 Money 对象,或者其中一个是 Money 对象,另一个是数字。如果不是,则返回一个包含错误信息的数组。

7. 其他自定义规则的示例

  • 强制使用依赖注入: 检查类中是否使用了 new 关键字来创建对象,如果使用了,则建议使用依赖注入。
  • 禁止使用全局变量: 检查代码中是否使用了全局变量,如果使用了,则建议使用依赖注入或配置。
  • 强制使用特定的命名约定: 检查类名、方法名、变量名是否符合项目约定的命名规范。
  • 限制某些类的使用范围: 检查某些类是否在允许的上下文中使用。

8. 自定义规则的注意事项

  • 性能: 自定义规则会影响 PHPStan 的分析速度,因此要尽量避免编写过于复杂的规则。
  • 准确性: 自定义规则应该尽可能准确地识别出错误,避免误报。
  • 可维护性: 自定义规则应该易于理解和维护,方便后续修改和扩展。
  • 测试: 编写自定义规则后,一定要进行充分的测试,确保规则能够正常工作。
  • 只处理特定的业务逻辑: 不要试图用自定义规则来替代 PHPStan 内置的规则。自定义规则应该只处理项目特定的业务逻辑。

9. 如何调试自定义规则

调试 PHPStan 自定义规则可能会比较困难,因为错误信息通常比较抽象。以下是一些调试技巧:

  • 使用 var_dump()dump() 函数:processNode() 方法中,可以使用 var_dump()dump() 函数来输出 AST 节点的信息,帮助理解节点的结构和内容。
  • 使用 PHPStan 的 --debug 选项: 使用 --debug 选项可以输出更详细的调试信息,包括 PHPStan 的类型推断过程。
  • 逐步缩小范围: 如果规则无法正常工作,可以逐步缩小规则的范围,例如只处理特定的文件或类,以便更容易定位问题。
  • 阅读 PHPStan 的源代码: 如果遇到难以解决的问题,可以阅读 PHPStan 的源代码,了解其内部实现机制。

10. 高级技巧:使用 Reflection API

在某些情况下,我们需要使用 Reflection API 来获取类或方法的更详细的信息。例如,我们可以使用 Reflection API 来判断一个方法是否被标记为 @deprecated

<?php

namespace MyProjectPHPStanRules;

use PhpParserNode;
use PhpParserNodeStmtClassMethod;
use PHPStanAnalyserScope;
use PHPStanRulesRule;
use ReflectionMethod;

class NoDeprecatedMethodCallRule implements Rule
{
    public function getNodeType(): string
    {
        return ClassMethod::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        if (!($node instanceof ClassMethod)) {
            return [];
        }

        $methodName = $node->name->toString();
        $className = $scope->getClassReflection()->getName();

        try {
            $reflectionMethod = new ReflectionMethod($className, $methodName);
            $docComment = $reflectionMethod->getDocComment();

            if ($docComment !== false && strpos($docComment, '@deprecated') !== false) {
                return [
                    sprintf('方法 %s::%s 已被废弃,请使用替代方案。', $className, $methodName),
                ];
            }
        } catch (ReflectionException $e) {
            // 方法不存在或无法访问,忽略
        }

        return [];
    }
}

代码解释:

  • NoDeprecatedMethodCallRule 类实现了 Rule 接口。
  • getNodeType() 方法返回 ClassMethod::class,表示该规则要处理类方法。
  • processNode() 方法首先判断节点是否是 ClassMethod 实例。
  • 使用 ReflectionMethod 获取方法的反射信息。
  • 获取方法的文档注释,并判断是否包含 @deprecated 标记。

11. 使用第三方扩展

除了编写自定义规则之外,我们还可以使用第三方扩展来扩展 PHPStan 的功能。例如,Larastan 是一个针对 Laravel 项目的 PHPStan 扩展,包含了许多有用的规则,例如模型属性类型检查、路由参数类型检查等。

总结:编写自定义规则,提升代码质量

通过编写 PHPStan 自定义规则,我们可以针对项目特定的业务逻辑进行类型检查,提前发现潜在的错误,提高代码质量和可维护性。自定义规则可以帮助我们强制执行项目规范、预防业务逻辑错误、提高代码可读性、降低运行时错误。在使用自定义规则时,需要注意性能、准确性、可维护性和测试。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注