利用 PHPStan 的规则和扩展强化 PHP 的代码质量

1,539 阅读5分钟

PHPStan 简介

PHPStan 相信大家都不陌生,作为一款广受欢迎的 PHP 静态检查工具,PHPSan 在大型 PHP 项目中在质量控制扮演了重要的角色。

在我们使用 PHPStan 的过程中,发现 PHPStan 的规则和级别(level)是 PHPStan 在一个项目中应用成功与否的重要因素。

一个项目配置了 PHPStan 的检查并不自动保证了这个项目代码质量。我们对 PHPStan 的配置有严格要求以确保大量的检查得以执行。

PHPStan 的 Level 体系

根据对语法检查的严格程度,PHPStan 划分了不同的级别 (level),目前共有 9 个级别,从 0 到 8,越来越严格。每个级别有不同的规则 (rule),这些规则描述了 PHPStan 会从哪些方面检查代码。对于新集成 PHPStan 的项目可以先使用最低级别,不至于面对大量的错误而无从下手。

如果你对 PHPStan 尚不甚了解

PHP 是动态语言,不像静态语言那样有些错误可以直接在编译阶段发现,很多错误只有在线上运行了之后才会发现,这个时候可能已经对系统产生了影响。PHPStan 是一款针对 PHP 语言的代码静态分析工具,它无需实际运行代码就可以发现其中的语法错误。

内置的校验的规则

我们以 PHPStan 0.12 为例,下面列举了比较有代表性的规则,完整内容请查看 phpstan-src,示例代码都可以在 PHPStan playground 测试,运行前需要选择相应的级别。

Level 0

  • 数组重复键值

    $arr = ['id' => 1, 'id' => 1]; // error: Array has 2 duplicate keys with value 'id'
    
  • 使用空下标读取数组

    $arr = ['id' => 1];
    $id  = $arr[]; // error: Cannot use [] for reading.
    
  • 禁用内部函数

    function foo()
    {
        function bar() // error: Inner named functions are not supported by PHPStan. Consider refactoring to an anonymous function, class method, or a top-level-defined function.
        {
        }
    }
    
  • 打印函数参数检查

    sprintf('%s %s', 'foo'); // error: Call to sprintf contains 2 placeholders, 1 value given.
    sscanf($str, '%d%d', $number); // error: Call to sscanf contains 2 placeholders, 1 value given.
    
  • $this 可用性

    class Foo
    {
       public static function foo()
       {
           $this->bar(); // error: Using $this in static method Foo::foo()
       }
    
       public function bar()
       {
       }
    }
    
  • 未定义的函数

  • 类属性可见性及是否存在

  • 检查函数实参数量是否和形参一致

Level 1

  • 匿名函数未使用到的 use 引入的变量

    $used   = 1;
    $unused = 3;
    
    function () use ($used, $unused) { // error: Anonymous function has an unused use $unused.
       echo $used;
    };
    
  • 没有用到的构造函数参数

  • 未定义的常量

Level 2

  • 非法的类型转换

    (string) new \stdClass(); // error: Cannot cast stdClass to string.
    (int) []; // error: Cannot cast array() to int.
    (int) 'blabla'; // error: Cannot cast 'blabla' to int.
    
  • 字符串中非法的变量类型

    function foo(string $str, \stdClass $std)
    {
       $s = "$str bar $std bar"; // error: Part $std (stdClass) of encapsed string cannot be cast to string.
    }
    
  • 参数类型和默认值不兼容

    function takesString(string $string = false): void
    {
    }
    
  • 非法的二元运算

    function foo()
    {
       5 / 0; // error: Binary operation "/" between 5 and 0 results in an error.
       1 + "blabla"; // error: Binary operation "+" between 1 and 'blabla' results in an error.
    }
    
  • 非法的比较运算

    function foo(stdClass $ob, int $a)
    {
        $ob == $a;
        $ob != $a;
        $ob < $a;
        $ob > $a;
        $ob <= $a;
        $ob >= $a;
        $ob <=> $a;
    }
    

Level 3

  • 往数组中添加类型错误的数据

    class Foo
    {
       /** @var int[] */
       private $integers;
    
       public function foo()
       {
           $this->integers[] = 4;
           $this->integers['foo'] = 5;
           $this->integers[] = 'foo'; // error: Array (array<int>) does not accept string.
       }
    }
    
  • 数组操作符赋值

    $value = 'Foo';
    $value['foo'] = 1; // error: Cannot assign offset 'foo' to string.
    
  • 解包运算符操作对象是否可遍历

    function foo(array $integers, string $str)
    {
       $foo = [
       ...[1, 2, 3],
       ...$integers,
       ...$str // error: Only iterables can be unpacked, string given.
       ];
    }
    
  • 生成器返回类型

    /**
     * @return \Generator<string, int>
     */
    function foo(): \Generator
    {
       yield 'foo' => 1;
       yield 'foo' => 'bar'; // error: Generator expects value type int, string given.
    }
    
  • 函数返回类型

  • 箭头函数返回类型

  • 闭包函数返回类型

  • foreach 语句中的变量是否可遍历

  • 属性类型

  • 变量是否可复制

Level 4

  • 数值比较结果恒定

    function (int $i): void {
       if ($i > 5) {
           if ($i <= 2) { // error: Condition always false
    
           }
       }
    };
    
  • 不会执行到的代码

    function foo(bool $foo)
    {
        if ($foo) {
            return;
        } else{
            return;
        }
    
        return $foo; // error: Unreachable
    }
    
  • 无效的 catch 语句

    function foo()
    {
        try {
    
        } catch (\Throwable $e) {
    
        } catch (\TypeError $e) { // error: Dead catch - TypeError is already caught by Throwable above.
    
        }
    }
    
  • 无效的方法调用

    $arr1 = [1, 2];
    $arr2 = [3, 4];
    array_merge($arr1, $arr2); // error: Call to function array_merge() on a separate line has no effect.
    
  • 太宽泛的返回值类型声明

    function bar(): ?string // error: Function bar() never returns string so it can be removed from the return typehint.
    {
        return null;
    }
    

Level 5

  • 函数实参类型

    function foo(string $foo)
    {
    }
    
    foo(1); // error: Parameter #1 $foo of function foo expects string, int given.
    
  • 形参为引用类型时实参必须为变量

    function foo(&$foo)
    {
    }
    
    $foo = 'foo';
    foo($foo);
    foo('foo'); // error: Parameter #1 $foo of function foo is passed by reference, so it expects variables only.
    

Level 6

  • PHPDoc 函数参数和代码中不一致

    /**
     * @param int $a
     * @param int $b
     * @param int $c // error: PHPDoc tag @param references unknown parameter: $c
     */
    function globalFunction($a, $b): void
    {
    
    }
    
  • PHPDoc 函数返回值类型和代码不一致

  • PHPDoc 属性类型和代码不一致

Level 7

  • 联合类型

    /**
     * @param  string|null  $key
     * @return string|array<string>|null
     */
    function foo($key = null)
    {
        if (is_null($key)) {
            return null;
        } elseif (strpos($key, ',') !== false) {
            return explode(',', $key);
        } else {
            return $key;
        }
    }
    
    $len = strlen(foo('xx')); // error: Parameter #1 $string of function strlen expects string, array<string>|string|null given.
    

Level 8

  • 可能为空的值

    /**
     * @property Author|null $author
     */
    class Post {}
    
    /**
     * @property string $name
     */
    class Author {}
    
    $post    = new Post();
    $comment = $post->author->name; // error: Cannot access property $name on Author|null.
    

扩展

PHPStan 支持开发者在其基础上进行开发,用来增加新的规则,或者对框架进行适配。下面列举了一些常用的扩展。

  • phpstan-strict-rules

    项目地址: github

    可选规则:

    • ifelseif、三目运算运算、! 后面、&&|| 两边必须是 boolean,不能是隐示转换。

      if ('foo') { // error: Only booleans are allowed in an if condition, string given.
      }  
      
    • 数值运算符号 +-*/**% 只能用于数值类型的数据。

    • 自增,自减运算符只能用于数值类型的数据。

    • 内置函数中为了类型安全包含 $strict 参数的,在使用时必须设置为 true

      • in_array(第三个参数)
      • array_search (第三个参数)
      • array_keys (当第二个参数被设置时的第三个参数)
      • base64_decode (第二个参数)
    • while 循环条件中分配的变量和 forforeach 循环初始分配的变量不能在循环后使用。

      for ($i = 0; $i < 10; $i++)
      {
          $foo = $i;
      }
      
      $j = $i + 1; // error: Variable $i might not be defined.
      
    • swith 语句中的数据类型和 case 中的值必须一致。

    • 静态方法必须静态调用。

    • 禁用 empty 函数。

    • 禁用 ?: 操作符号。

    • 禁用可变的变量 (?foo$this->$method() 等)。

    • 禁止覆盖 foreach 语句中的「键」,「值」变量。

    • 检查永远为真的 instanceof 操作,is_* 系列函数类型检查,严格比较 ===!==

    • 被引用和被调用的函数名大小写必须正确。

    • 继承和实现的方法名大小写必须正确。

    • 继承方法的参数类型要比父类方法参数类型派生程度大 (逆变),返回值类型要比父类方法返回值类型派生程度小 (协变) (也被称为 Liskov 替换原则,Liskov substitution principle - LSP)。

      class T {}
      class S extends T {}
      
      class A
      {
          public function foo(T $foo): S
          {
              return new S();
          }
      }
      
      class B extends A
      {
          // error: Parameter #1 $bar (S) of method B::foo() should be contravariant with parameter $foo (T) of method A::foo()
          // error: Return type (T) of method B::foo() should be covariant with return type (S) of method A::foo()
          public function foo(S $bar): T
          {
              return new T();
          }
      }
      
    • 继承的静态方法也要遵循 LSP 原则。

    • 子类构造函数中必须调用父类的构造函数。

    • 禁用反引号运算符。

  • phpstan-banned-code

    项目地址:github

    作用:当代码中存在以下函数时会抛出警告信息,避免调试用的函数在代码仓库中存在。

    • debug_backtrace()
    • dump()
    • exec()
    • passthru()
    • phpinfo()
    • print_r()
    • proc_open()
    • shell_exec()
    • system()
    • var_dump()
  • Safe

    简介:Safe 对部分 PHP 核心函数(出错时返回 false 而不是抛出异常)进行了重写,使其在出错时用抛出异常来代替返回 false

    项目地址:GitHub

    作用:PHP 早期没有异常处理机制,函数在处理失败时不会抛出异常而是返回 false,在 PHP 有了异常处理机制之后为了与早期版本兼容这些函数依然回不抛出异常。所以在使用这些函数时需要开发者检查返回值并手动抛出异常。

    $content = file_get_contents('foobar.json');
    
    if ($content === false) {
        throw new FileLoadingException('Could not load file foobar.json');
    }
    

    这样的写法太啰嗦,使用 Safe 中重写后的函数可以让代码变得清爽。

    $content = \Safe\file_get_contents('foobar.json');
    
  • phpstan-deprecation-rules

    项目地址:GitHub

    作用:检测废弃类、方法、属性、常量和特征 (trait) 的使用。

  • larastan

    项目地址:GitHub

    作用:Laravel 项目集成 PHPStan 时的必备扩展。

自定义规则

PHPStan 支持自定义规则,可以参考 custom-rules

结语

我们对公司内部几乎所有的 PHP 项目都要求执行最高级别 max 的 PHPStan 的检查,同时我们也要求开启以下的扩展:

  • phpstan-deprecation-rules
  • phpstan-safe-rule
  • phpstan-banned-code
  • PHPStan 和 thecodingmachine 的 phpstan-strict-rules
  • PHPStan 的 bleedingEdge
  • phpstan-mockery
  • larastan
  • 公司自制规则

我们将 PHPStan 检查添加到了 CI (Continuous Integration) 的 pipeline 之中,PHPStan 检查不通过时 PR 无法合并。

在我们的日常中,PHPStan 时常会发现肉眼难以发现的 bug,代码质量得到明显提升。


欢迎关注我们的微信公众号「RightCapital」