
Badoo已经存在超过12年了。 我们有很多PHP代码(数百万行),甚至可能保留了12年前编写的行。 我们已经在PHP 4和PHP 5时代写回了代码。我们每天两次上传代码,每个布局包含大约10-20个任务。 此外,程序员可以发布紧急补丁-小改动。 在出现此类补丁的那天,我们获得了几十个补丁。 通常,我们的代码正在非常积极地进行更改。
我们一直在寻找机会,以加快开发速度并提高代码质量。 因此,有一天我们决定实施静态代码分析。 它的结果,在切口下阅读。
严格类型:为什么我们还没有使用它
一次,在我们公司的PHP聊天中开始了讨论。 一位新员工告诉他们在以前的工作场所他们是如何为整个代码引入强制性strict_types +标量
类型提示的-这大大减少了生产中的bug数量。
大多数聊天老朋友都反对这种创新。 主要原因是PHP没有在编译时检查代码中所有类型的编译器,并且如果您没有100%覆盖测试代码,则始终有可能在生产中弹出错误,而我们不会要允许。
当然,strict_types将发现一定百分比的错误,这些错误是由类型不匹配以及PHP如何“无声地”转换类型引起的。 但是许多经验丰富的PHP程序员已经知道PHP中的类型系统如何工作,通过什么规则进行类型转换,并且在大多数情况下,他们编写正确的工作代码。
但是,我们喜欢让某个系统显示代码中类型不匹配的地方的想法。 我们考虑了strict_types的替代方案。
最初,我们甚至想修补PHP。 我们希望,如果函数采用某种标量类型(例如int),并且引入了另一种标量类型(例如float),则不会引发TypeError(本身是一个例外),但是会发生类型转换,以及将此事件记录在error.log中。 这将使我们能够找到所有关于类型的假设都不正确的地方。 但是这样的补丁对我们来说似乎是冒险的,甚至外部依赖项可能存在问题,还没有为这种行为做好准备。
我们放弃了修补PHP的想法,但是随着时间的流逝,所有这些都与Phan静态分析器的第一个发行版相吻合,其中的第一个提交由Rasmus Lerdorf亲自完成。 因此,我们想到了尝试使用静态代码分析器的想法。
什么是静态代码分析?
静态代码分析器仅读取代码并尝试查找其中的错误。 它们可以执行非常简单和明显的检查(例如,检查类,方法和函数的存在以及更棘手的检查(例如,查找类型不匹配,竞争条件或代码中的漏洞)。关键是分析器不执行代码-它们分析程序的文本并检查是否存在典型(并非如此)错误。
静态PHP代码分析器最明显的示例是PHPStorm中的检查:在编写代码时,它会突出显示对函数,方法,参数类型不匹配等的不正确调用。但是,PHPStorm不会运行您的PHP代码-只会对其进行分析。
我注意到在本文中,我们谈论的是在代码中寻找错误的分析器。 还有另一类分析器-它们检查代码的编写样式,循环复杂度,方法大小,行长等。我们在此不考虑此类分析器。
尽管并不是我们正在考虑的分析仪发现的所有东西都是错误的。 错误地说,我的意思是致命的代码将在生产中创建。 分析人员发现的结果常常是不准确的。 例如,可能在PHPDoc中指定了错误的参数类型。 这种不精确性不会影响代码的操作,但是随后代码会演化-另一个程序员可能会犯错误。
现有的PHP代码分析器
有三种流行的PHP代码分析器:
- PHPStan 。
- 诗篇
- 潘
还有
Exakat ,我们还没有尝试过。
在用户端,所有三个分析器都是相同的:您安装它们(很可能是通过Composer进行安装),进行配置,然后可以开始对整个项目或文件组进行分析。 通常,分析仪可以在控制台中漂亮地显示结果。 您还可以将结果以JSON格式输出并在CI中使用它们。
这三个项目现在都在积极开发中。 他们的维护者非常积极地响应GitHub上的问题。 通常,在创建票证后的第一天,他们至少会对此做出反应(评论或添加类似bug / enhanced的标签)。 我们发现许多错误已在几天之内修复。 但是我特别喜欢这样的事实,即项目维护者之间要积极地沟通,互相报告错误,并发送请求请求。
我们已经实现并使用了所有三个分析器。 每个人都有自己的细微差别,错误。 但是,同时使用三个分析仪有助于了解真正的问题在哪里以及误报在哪里。
分析仪可以做什么
分析器具有许多共同的功能,因此,我们先来看一下它们的全部功能,然后再介绍它们各自的功能。
标准支票
当然,分析器会针对以下事实执行所有标准代码检查:
- 该代码不包含语法错误;
- 所有类,方法,函数,常量都存在;
- 存在变量;
- 在PHPDoc中,提示是正确的。
此外,解析器还会检查代码中是否有未使用的参数和变量。 这些错误中有许多会导致代码真正致命。
乍一看,好的程序员似乎并没有犯这样的错误,但是有时候我们很着急,有时候会粘贴粘贴,有时候我们只是不专心。 在这种情况下,这些检查可以节省很多。
数据类型检查
当然,静态分析器还执行有关数据类型的标准检查。 如果它是用函数接受的代码(例如int)编写的,则分析器将检查是否有将对象传递给该函数的地方。 对于大多数分析器,您可以配置测试的严重性并模拟strict_types:验证没有字符串或布尔值传递给此函数。
除了标准检查之外,分析仪还有很多工作要做。
工会类型所有分析器都支持联合类型的概念。 假设您有一个类似的功能:
function isYes($yes_or_no) :bool { if (\is_bool($yes_or_no)) { return $yes_or_no; } elseif (is_numeric($yes_or_no)) { return $yes_or_no > 0; } else { return strtoupper($yes_or_no) == 'YES'; } }
它的内容不是很重要-输入参数
string|int|bool
很重要。 也就是说,
$yes_or_no
变量可以是字符串,也可以是整数,或者是
Boolean
。
使用PHP,无法描述这种类型的功能参数。 但是在PHPDoc中,这是可能的,并且许多编辑器(如PHPStorm)都可以理解。
在静态分析器中,此类型称为
联合类型 ,它们非常擅长检查此类数据类型。 例如,如果我们这样编写上面的函数(不检查
Boolean
):
function isYes($yes_or_no) :bool { if (is_numeric($yes_or_no)) { return $yes_or_no > 0; } else { return strtoupper($yes_or_no) == 'YES'; } }
分析器将看到字符串或布尔值都可能到达strtoupper,并返回错误-您不能将布尔值传递给strtoupper。
这种检查有助于程序员正确处理错误或函数无法返回数据的情况。 我们经常编写可以返回一些数据或
null
函数:
对于此类代码,分析器将告诉您
$User
变量在此处可以为
null
并且此代码可能会导致致命事故。
输入假在PHP语言本身中,有很多函数可以返回某些值或false。 如果要编写这样的函数,我们将如何记录它的类型?
function fopen(...) { … }
形式上,这里的一切似乎都是正确的:fopen返回resource或
false
(类型为
Boolean
)。 但是,当我们说一个函数返回某种数据类型时,这意味着它可以从属于该数据类型的集合中返回
任何值。 在我们的示例中,对于分析器,这意味着
fopen()
可以返回
true
。 并且,例如,在这样的代码的情况下:
$fp = fopen('some.file','r'); if($fp === false) { return false; } fwrite($fp, "some string");
分析器会抱怨
fwrite
接受第一个参数资源,然后我们将
bool
传递
bool
(因为分析器认为可以使用true选项)。 因此,所有分析器都将这种“人工”数据类型理解为
false
,在我们的示例中,我们可以编写
@return false|resource
。 PHPStorm也理解这种类型的描述。
阵列形状通常,PHP中的数组用作
record
类型-具有清晰字段列表的结构,其中每个字段都有其自己的类型。 当然,许多程序员已经为此使用类。 但是我们在Badoo中有很多遗留代码,并且在那里积极使用了数组。 碰巧的是,程序员过于懒惰,无法为某个一次性结构创建单独的类,在这种情况下,也经常使用数组。
这种数组的问题在于,在代码中没有对此结构的清晰描述(字段及其类型的列表)。 程序员在使用这种结构时可能会犯错误:忘记必填字段或添加“向左”键,会使代码更加混乱。
分析器允许您输入此类结构的描述:
function showUrl(array $parsed_url) { … }
在此示例中,我们描述了一个具有三个字符串字段的数组:
scheme, host
和
path
。 如果在函数内转到另一个字段,分析仪将显示错误。
如果您不描述类型,则分析器将尝试“猜测”数组的结构,但是,如实践所示,它们不会真正成功地使用我们的代码。 :)
这种方法有一个缺点。 假设您有一个在代码中活跃使用的结构。 您不能在一个地方声明一个伪类型,然后在任何地方使用它。 您将必须在代码中的任何地方向PHPDoc注册数组的描述,这非常不方便,尤其是在数组中有很多字段的情况下。 以后再编辑此类型(添加和删除字段)也会有问题。
阵列键类型的描述在PHP中,数组键可以是整数和字符串。 类型有时对于静态分析很重要(对于程序员而言也是如此)。 静态分析器允许您在PHPDoc中描述数组键:
$users = UserLoaders::loadUsers($user_ids);
在此示例中,使用PHPDoc,我们添加了一个提示:
$users
数组中的键是整数int,值是
\User
类的对象。 我们可以将类型描述为\ User []。 这将告诉分析器数组中的
\User
类中有对象,但是不会告诉我们有关键类型的任何信息。
PHPStorm支持此格式来描述从版本2018.3。开始的数组。
您在PHPDoc中的名称空间PHPStorm(和其他编辑器)和静态分析器对PHPDoc的理解不同。 例如,分析器支持以下格式:
function showUrl($parsed_url) { … }
但是PHPStorm不了解他。 但是我们可以这样写:
function showUrl($parsed_url) { … }
在这种情况下,将同时满足分析器和PHPStorm的要求。 PHPStorm将使用
@param
,分析器将使用其自己的PHPDoc标签。
PHP功能检查
最好通过示例来说明这种测试。
我们都知道
explode()函数可以返回什么吗? 如果您看了一下文档,似乎它返回了一个数组。 但是,如果您仔细检查一下,我们会发现它也可能返回false。 实际上,如果传递错误的类型,则它可以返回null和错误,但是传递具有错误的数据类型的错误值已经是一个错误,因此,此选项现在对我们来说并不重要。
形式上,从分析器的角度来看,如果函数可以返回false或数组,则很可能代码应检查false。 但是,仅当分隔符(第一个参数)等于空字符串时,explode()函数才返回false。 通常,它是显式地编写在代码中的,分析器可以验证它是否为空,这意味着在此位置,explode()函数准确地返回一个数组,并且不需要错误的检查。
PHP具有这么几个功能。 分析人员逐渐添加适当的检查或改进检查,而我们程序员不再需要记住所有这些功能。
我们转向特定分析仪的描述。
PHP斯坦
从捷克共和国开发了某种OndřejMirtes。 自2016年底开始积极开发。
要开始使用PHPStan,您需要:
- 安装它(最简单的方法是通过Composer)。
- (可选)配置。
- 在最简单的情况下,只需运行:
vendor/bin/phpstan analyse ./src
(而不是
src
可能会有您要检查的特定文件的列表)。
PHPStan将从传输的文件中读取PHP代码。 如果遇到未知的类,他将尝试通过自动加载并通过反射来加载它们以了解它们的接口。 您还可以将路径转移到
Bootstrap
文件中,通过该文件配置自动加载,并附加一些其他文件以简化PHPStan分析。
主要特点:
- 可能不分析整个代码库,而是仅分析部分-未知类PHPStan将尝试加载自动加载。
- 如果由于某种原因您的某些类不在自动加载中,PHPStan将无法找到它们并给出错误。
- 如果您正在通过
__call / __get / __set
积极使用魔术方法,则可以编写PHPStan插件。 Symfony,Doctrine,Laravel,Mockery等的插件已经存在。
- 实际上,PHPStan不仅对未知类执行自动加载,而且通常对所有人执行自动加载。 当我们在一个文件中创建一个类,然后立即实例化它甚至可能调用某些方法时,在匿名类出现之前,我们已经编写了许多旧代码。 此类文件的自动加载(
include
)会导致错误,因为该代码不会在正常环境中执行。
- 霓虹灯格式的配置(我从没听说过其他地方使用过这种格式)。
- 不支持其PHPDoc标记,例如
@phpstan-var, @phpstan-return
等。
另一个功能是错误带有文本,但是没有类型。 也就是说,错误文本将返回给您,例如:
Method \SomeClass::getAge() should return int but returns int|null
Method \SomeOtherClass::getName() should return string but returns string|null
在此示例中,这两种错误基本上都是相同的:方法必须返回一种类型,但实际上它返回另一种类型。 但是,尽管相似,但错误的内容却有所不同。 因此,如果要过滤掉PHPStan中的任何错误,请仅通过正则表达式进行操作。
为了进行比较,在其他分析器中,错误具有类型。 例如,在Phan中,此类错误的类型为
PhanPossiblyNullTypeReturn
,并且您可以在配置中指定不需要检查此类错误。 而且,具有错误的类型,例如,可以容易地收集关于错误的统计信息。
由于我们不使用Laravel,Symfony,Doctrine和类似的解决方案,并且我们很少在代码中使用魔术方法,因此我们对PHPStan的主要功能一无所知。 ;(此外,由于PHPStan包含了
所有要检查的类,因此有时对其分析根本无法在我们的代码库上运行。
但是,PHPStan对我们仍然有用:
- 如果需要检查多个文件,则PHPStan的速度明显比Phan快,并且比Psalm快一点(20-50%)。
- PHPStan报告使在其他分析器中更容易找到
false-positive
。 通常,如果代码中有一些明显的fatal
,那么所有分析器(或三个分析器中的至少两个)都会显示该fatal
。
更新:PHPStanOndřejMirtes的作者还阅读了我们的文章,并
告诉我们PhpStan和Psalm一样,都有一个带有“沙盒”的网站:
https ://phpstan.org/。 这对于错误报告非常方便:您可以在中重现错误并在GitHub中提供链接。
潘
由Etsy开发。 Rasmus Lerdorf首先提交。
在上述三个问题中,Phan是唯一
真正的静态分析器(从某种意义上说,它不执行任何文件-它解析
整个代码库,然后分析您所说的内容)。 即使要分析我们代码库中的几个文件,它也需要大约6 GB的RAM,此过程需要四到五分钟的时间。 但是,整个代码库的完整分析大约需要六到七分钟。 为了进行比较,Psalm在几十分钟内对其进行了分析。 从PHPStan,我们根本无法对整个代码库进行完整的分析,因为它包含了包含类。
藩的经历是双重的。 一方面,它是最优质,最稳定的分析器,在需要分析整个代码库时,它发现的东西很多,而且问题更少。 另一方面,它具有两个不愉快的特征。
在后台,Phan使用php-ast扩展名。 显然,这是整个代码库的分析相对较快的原因之一。 但是php-ast显示了AST树在PHP本身中的内部表示。 并且在PHP本身中,AST树不包含有关位于函数内部的注释的信息。 也就是说,如果您编写了类似以下内容:
function doSomething($type) { $obj = MyFactory::createObjectByType($type); … }
那么在AST树中,有关于
doSomething()
函数的外部PHPDoc的信息,但函数内部没有PHPDoc的帮助信息。 而且,潘也对她一无所知。 这是潘氏
false-positive
最常见的原因。 关于如何插入工具提示(通过字符串或assert-s),存在
一些建议 ,但是不幸的是,它们与我们的程序员习惯的非常不同。 我们通过为Phan编写插件来部分解决此问题。 但是插件将在下面讨论。
第二个令人不愉快的特征是Phan无法很好地分析对象的属性。 这是一个例子:
class A { private $a; public function __construct(string $a = null) { $this->a = $a; } public function doSomething() { if ($this->a && strpos($this->a, 'a') === 0) { var_dump("test1"); } } }
在此示例中,Phan将告诉您在strpos中可以传递null。 您可以在此处了解有关此问题的更多信息:
https :
//github.com/phan/phan/issues/204 。
总结 尽管有一些困难,但Phan是一个非常酷而有用的开发。 除了这两种类型的
false-positive
,他几乎不会犯错误,也不会犯错误,但是会涉及一些非常复杂的代码。 我们还喜欢该配置位于PHP文件中-这提供了一些灵活性。 Phan还知道如何作为语言服务器工作,但是我们没有使用此功能,因为PHPStorm对我们来说足够了。
外挂程式
Phan具有完善的插件开发API。 您可以添加自己的检查,改善代码的类型推断。 该API
有文档 ,但是特别棒的是,内部已经可以使用插件作为示例。
我们设法编写了两个插件。 第一个用于一次性检查。 我们想评估我们的代码对PHP 7.3的准备程度(特别是确定其是否具有
case-insensitive
常量)。 我们几乎可以确定没有这样的常数,但是在12年内可能发生任何事情-应该对其进行检查。 并且我们为Phan编写了一个插件,如果在
define()
中使用了第三个参数,该插件将发誓。
插件很简单 <?php declare(strict_types=1); use Phan\AST\ContextNode; use Phan\CodeBase; use Phan\Language\Context; use Phan\Language\Element\Func; use Phan\PluginV2; use Phan\PluginV2\AnalyzeFunctionCallCapability; use ast\Node; class DefineThirdParamTrue extends PluginV2 implements AnalyzeFunctionCallCapability { public function getAnalyzeFunctionCallClosures(CodeBase $code_base) : array { $define_callback = function ( CodeBase $code_base, Context $context, Func $function, array $args ) { if (\count($args) < 3) { return; } $this->emitIssue( $code_base, $context, 'PhanDefineCaseInsensitiv', 'Define with 3 arguments', [] ); }; return [ 'define' => $define_callback, ]; } } return new DefineThirdParamTrue();
在Phan中,不同的插件可以挂在不同的事件上。 特别是,在解析函数调用时
AnalyzeFunctionCallCapability
触发具有
AnalyzeFunctionCallCapability
接口的插件。 在此插件中,我们做到了这一点,以便在调用
define()
函数时,将调用匿名函数,该函数检查
define()
参数
define()
超过两个。 然后,我们刚开始Phan,找到了使用三个参数调用
define()
所有位置,并确保我们没有不
case-insensitive-
。
使用该插件,当Phan在代码内看不到PHPDoc提示时,我们还部分解决了
false-positive
问题。
我们经常使用将常量作为输入并从中创建对象的工厂方法。 该代码通常看起来像这样:
$Object = \Objects\Factory::create(\Objects\Config::MY_CONTROLLER);
Phan无法理解此类PHPDoc提示,但是在此代码中,可以从传递给
create()
方法的常量名称中获取对象类。 Phan允许您编写一个插件,当它分析函数的返回值时将触发该插件。 使用此插件,您可以告诉分析器函数在此调用中返回的类型。
此插件的示例更为复杂。 但是在
vendor/phan/phan/src/Phan/Plugin/Internal/DependentReturnTypeOverridePlugin.php.
中的Phan代码中有一个很好的例子
vendor/phan/phan/src/Phan/Plugin/Internal/DependentReturnTypeOverridePlugin.php.
总体而言,我们对Phan分析仪感到非常满意。 上面列出的
false-positive
我们部分了解了(在简单情况下,使用简单代码)要过滤的内容。 此后,潘成为了几乎参考分析仪。 但是,立即解析整个代码库(时间和大量内存)的需求仍然使实现过程复杂化。
诗篇
诗篇是Vimeo开发的。 老实说,直到我看到Psalm时,我什至不知道Vimeo使用PHP。
该分析仪是我们三个中最小的。 当我阅读Vimeo发布Psalm的消息时,我很茫然:“如果您已经拥有Phan和PHPStan,为什么要投资Psalm?” 但是事实证明,诗篇有其自己的有用功能。
Psalm紧随PHPStan的脚步:您还可以给它提供文件列表以进行分析,它将分析它们,并使用自动加载功能连接未找到的类。 同时,它
仅连接未找到的类,并且不会包含我们要分析的文件(这与PHPStan不同)。 配置存储在XML文件中(对我们来说,这可能是减号,但不是很关键)。
Psalm有一个沙盒
站点 ,您可以在其中编写PHP代码并进行分析。 这对于错误报告非常方便:您可以在网站上重现错误并在GitHub中提供链接。 顺便说一下,该站点描述了所有可能的错误类型。 进行比较:在PHPStan中,错误没有类型,在Phan中是错误,但是找不到单个列表。
我们还喜欢在输出错误时,Psalm会立即在发现错误的地方显示代码行。 这
大大简化了阅读报告。
但是,Psalm最有趣的功能可能是其自定义的PHPDoc标记,它使您可以改进分析(尤其是类型的定义)。 我们列出了其中最有趣的。
@ psalm-ignore-nullable-return
碰巧的是,一个方法可以返回
null
,但是代码已经被组织起来了,以至于这种情况永远不会发生。 在这种情况下,将这样的PHPDoc提示添加到方法/函数中非常方便-Psalm将认为不返回
null
。
对于false,存在类似的提示:
@psalm-ignore-falsable-return
。
关闭类型
如果您曾经对函数式编程感兴趣,则可能已经注意到,一个函数通常可以返回另一个函数或将某个函数作为参数。 在PHP中,这种样式会使您的同事非常困惑,原因之一是PHP没有用于记录此类功能的标准。 例如:
function my_filter(array $ar, \Closure $func) { … }
程序员如何理解第二个参数中的功能? 应该采用什么参数? 她应该还什么?
Psalm支持用于描述PHPDoc中函数的语法:
function my_filter(array $ar, \Closure $func) { … }
有了这样的描述,很明显,您需要将匿名函数传递给
my_filter
,该函数将接受一个int并返回bool。 而且,当然,Psalm将验证您在代码中传递的正是这样的函数。
枚举
假设您有一个采用字符串参数的函数,并且只能在其中传递某些字符串:
function isYes(string $yes_or_no) : bool { $yes_or_no = strtolower($yes_or_no) switch($yes_or_no) { case 'yes': return true; case 'no': return false; default: throw new \InvalidArgumentException(…); } }
Psalm允许您像下面这样描述此函数的参数:
function isYes(string $yes_or_no) : bool { … }
在这种情况下,Psalm将尝试了解将哪些特定值传递给此函数,如果存在“是
Yes
和“
No
以外的其他值,则会引发错误
No
在此处阅读有关枚举的更多信息。
输入别名
在
array shapes
的描述的前面
array shapes
我提到虽然分析器允许您描述数组的结构,但是使用它并不是很方便,因为必须在不同的地方复制数组的描述。 正确的解决方案当然是使用类而不是数组。 但是对于许多年的遗产而言,这并非总是可能的。
, , , :
- ;
- closure;
- union- (, );
- enum.
, , PHPDoc , , . Psalm . alias PHPDoc
alias
. , : PHP-. . , Psalm.
Generics aka templates
. , :
function identity($x) { return $x; }
? ? ?
, , , —
mixed
, .
mixed
— . , . ,
identity()
/ , : , . -. , :
$i = 5;
(int)
, ,
$y
(
int
).
? Psalm PHPDoc-:
function identity($x) { $return $x; }
templates Psalm , / .
Psalm templates:
—
vendor/vimeo/psalm/src/Psalm/Stubs/CoreGenericFunctions.php ;
—
vendor/vimeo/psalm/src/Psalm/Stubs/CoreGenericClasses.php .
Phan, :
https://github.com/phan/phan/wiki/Generic-Types .
, Psalm . , «» . , Psalm , , Phan PHPStan. .
PHPStorm
: , . , , .
. Phan, language server. PHPStorm, , .
, , PHPStorm ( ), . — Php Inspections (EA Extended). — , , . , . , scopes - scopes.
,
deep-assoc-completion . .
Badoo
?
, .
, . , ,
git diff
/ , , () . , .
, : -
git diff
. . , . . , , , , .
, , :

false-positive
. , , Phan , , . , - Phan , , .
QA
:
— , , , . :
- 100% ( , );
- , code review;
- , .
strict types
. ,
strict types
, :
- ,
strict types
, ;
- , (, , );
- , PHP (,
union types
, PHP);
strict types
, .
:
, . .
-, , , - , .
-, , — , , PHPDoc. — .
-, . , - , PHPDoc. :)
, , . , .