
我将告诉您我们如何编写出一个速度足够快的Linter,以检查每次git push期间的更改,并在5到10秒钟内使用PHP中的500万行代码来完成。 我们称之为NoVerify。
NoVerify支持基本内容,例如过渡到定义和搜索用途,并且能够在
Language Server模式下工作。 首先,我们的工具侧重于搜索潜在的错误,但它也可以检查样式。 今天,它的源代码出现在GitHub上的开源中。 在文章末尾查找链接。
为什么我们需要我们的棉绒
在2018年中期,我们认为是时候为PHP代码实现一个linter了。 有两个目标:减少用户看到的错误数量,并更严格地监视对代码样式的遵守情况。 主要重点是防止典型错误:代码中存在未声明和未使用的变量,无法访问的代码等。 我还希望静态分析器在我们的代码基础上尽快运行(在撰写本文时,PHP代码为5-6百万行)。
您可能知道,大多数站点的源代码都是用PHP编写的,并使用
KPHP进行编译,因此将这些检查添加到编译器中是合乎逻辑的。 但是实际上,并不是所有代码都可以通过KPHP运行-例如,编译器与第三方库之间的兼容性很弱,因此在网站的某些部分中仍使用常规PHP。 它们也很重要,应该由短绒检查,因此,不幸的是,没有办法将其集成到KPHP中。
为什么不验证
考虑到PHP代码的数量(我会提醒您,这是5-6百万行),不可能立即对其进行“修复”,以使其通过我们的检查。 不过,我希望更改的代码逐渐变得更整洁,更严格地遵循编码标准,并且包含更少的错误。 因此,我们决定,lint应该能够检查开发人员将要启动的更改,而在其余时间不发誓。
为此,lint需要索引整个项目,在更改前后对文件进行全面分析,并计算所生成警告之间的差异。 新的警告会显示给开发人员,我们要求在进行推送之前将其修复。
但是在某些情况下,这种行为是不可取的,然后开发人员可以在不使用本地钩子的情况下进行推送-使用
git push --no-verify
。 选项
--no-verify
并给短毛绒起了个名字:)
有哪些选择
VK中的代码库使用很少的OOP,基本上由带有静态方法的函数和类组成。 如果PHP中的类支持自动加载,则函数不支持。 因此,我们不能在没有重大修改的情况下使用静态分析器,因为静态分析器的工作基于自动加载将加载所有缺少的代码这一事实。 这样的短绒包括例如
来自Vimeo的诗篇 。
我们检查了以下静态分析工具:
- PHPStan-单线程,需要自动加载,半小时内代码库分析已达到30%;
- 藩切 -即使在具有20个过程的快速模式下,20分钟后分析也会停滞5%;
- 诗篇 -需要自动加载,分析耗时10分钟(我仍然希望更快);
- PHPCS-检查样式,但不检查逻辑;
- phpcf-仅检查格式。
从本文标题可以猜到,这些工具都不满足我们的要求,因此我们编写了自己的工具。
原型是如何创建的?
首先,我们决定构建一个小型原型,以了解是否值得尝试制作成熟的短绒。 由于对linter的重要要求之一是它的速度,因此我们选择了Go而不是PHP。 “快速”是指尽可能快地反馈给开发人员,最好不超过10-20秒。 否则,“更正代码,再次运行linter”周期将开始显着减慢开发速度并破坏人们的心情:)
由于选择了Go作为原型,因此您需要一个PHP解析器。 其中有几个,但是
php-parser项目在我们看来似乎是最成熟的。 该解析器并不完美,仍在开发中,但对于我们的目的而言,它非常适合。
对于原型,决定尝试实施最简单的检查之一:检查未定义的变量。
实现这种检查的基本思想很简单:对于每个分支(例如,if),创建一个单独的嵌套作用域,并在其出口处合并变量的类型。 一个例子:
<?php if (rand()) { $a = 42;
看起来很简单,对吧? 对于普通的条件语句,一切都很好。 但是我们必须处理例如不间断的切换;
<?php switch (rand()) { case 1: $a = 1;
从代码中尚不清楚,实际上总是会定义$ c。 具体而言,该示例是虚构的,但它很好地说明了短毛猫(在这种情况下也对人)造成的困难时刻。
考虑一个更复杂的示例:
<?php exec("hostname", $out, $retval); echo $out, $retval;
在不知道exec函数签名的情况下,不能说是否将定义$ out和$ retval。 内置功能的签名可以从
github.com/JetBrains/phpstorm-stubs存储库中
获取 。 但是,调用用户定义的函数时也会发生相同的问题,并且只有通过索引整个项目才能找到它们的签名。 exec函数通过引用接受第二个和第三个参数,这意味着可以定义变量$ out和$ retval。 在这里,访问这些变量不一定是一个错误,并且linter不应该对这种代码发誓。
方法会产生与隐式链接传递类似的问题,但同时又增加了推导变量类型的需求:
<?php if (rand()) { $a = some_func(); } else { $a = other_func(); } $a->some_method($b); echo $b;
我们需要知道some_func()和other_func()函数返回什么类型,以便稍后在这些类中找到一种称为some_method的方法。 只有这样我们才能说是否将定义变量$ b。 由于通常简单的函数和方法没有phpdoc批注,因此情况变得复杂,因此您仍然需要能够根据其实现来计算函数和方法的类型。
在开发原型时,我必须实现所有功能的大约一半,以便最简单的检查可以正常进行。
充当语言服务器
为了使调试linter的逻辑更容易并且更容易看到它发出的警告,我们决定将操作模式添加
为PHP的
语言服务器 。 在与Visual Studio Code的集成模式下,它看起来像这样:

在这种模式下,测试假设和测试复杂案例非常方便(当然,此后您需要编写测试)。 测试性能也很不错:即使在Go上的大型文件php-parser上也显示出了不错的速度。
语言服务器支持远非理想,因为它的主要目的是调试linter规则。 但是,在此模式下,还有其他一些功能:
- 有关变量名称,常量,函数,属性和方法的提示。
- 突出显示变量的派生类型。
- 转到定义。
- 搜索用途。
“惰性”类型推断
在语言服务器模式下,需要执行以下操作:在一个文件中更改代码,然后在切换到另一个文件时,应使用有关函数或方法中返回哪种类型的已更新信息。 想象一下按以下顺序编辑的文件:
<?php
鉴于我们不强迫开发人员始终编写PHPDoc(尤其是在这种简单情况下),我们需要一种方法来存储有关B :: something()函数返回哪种类型的信息。 因此,当A.php文件更改时,C.php文件中的类型信息将立即更新。
一种可能的解决方案是存储“惰性类型”。 例如,B :: something()方法的返回类型实际上是一个表达式类型(新A)-> prop。 通过这种形式,lint会存储有关类型的信息,因此,您可以缓存每个文件的所有元信息,并仅在此文件更改时对其进行更新。 应当仔细进行此操作,以免意外泄露有关类型的过多特定信息。 当类型推断逻辑更改时,还必须更改缓存版本。 尽管如此,与重复解析所有文件相比,这种缓存将索引阶段(我将在后面讨论)加快了5到10倍。
工作分为两个阶段:索引编制和分析
我们记得,即使是最简单的代码分析,也至少需要有关项目中所有功能和方法的信息。 这意味着您只能与项目分开分析一个文件。 但是,这不能一pass而就:例如,PHP允许您访问文件中进一步声明的函数。
由于这些限制,lint的操作包括两个阶段:主要索引编制和仅对必要文件进行后续分析。 现在更多关于这两个阶段。
索引阶段
在此阶段中,将解析所有文件,并对方法和函数的代码以及顶层代码进行本地分析(例如,确定全局变量的类型)。 收集有关声明的全局变量,常量,函数,类及其方法的信息,并将其写入高速缓存。 对于项目中的每个文件,缓存都是磁盘上的单独文件。
*有关该项目的所有元信息的全局词典,将来不会更改,它是从各个部分汇编而来的。
*除了作为语言服务器的操作模式外,在每次编辑时都对已更改文件进行索引和分析时。分析阶段
在此阶段,我们可以使用元信息(关于函数,类...),并且已经直接分析了代码。 以下是NoVerify默认可以检查的内容的列表:
- 无法访问的代码;
- 以数组的形式访问对象;
- 调用函数时参数数量不足;
- 调用未定义的方法/函数;
- 访问缺少的类属性/常量;
- 缺乏阶级;
- 无效的PHPDoc
- 访问未定义的变量;
- 访问并非总是定义的变量;
- 缺乏“休息”; 事后切换/事例构造;
- 语法错误
- 未使用的变量。
该列表很短,但是您可以添加特定于项目的检查。
事实证明,在短绒棉机运行期间,最有用的检查只是最后一个(未使用的变量)。 当您重构代码(或编写新代码)并将其密封在变量名中时,通常会发生这种情况:从PHP的角度来看,此代码是有效的,但在逻辑上是错误的。
工作速度
我们要推送更改多长时间? 这完全取决于文件的数量。 使用NoVerify,此过程最多可能需要一分钟的时间(那是我在存储库中更改1400个文件时的时间),但是如果编辑很少,则通常所有检查都会在4-5秒钟内通过。 在此期间,将对项目进行完全索引,解析新文件及其分析。 我们相当有能力为PHP创建一个linter,即使在我们的大型代码库中也可以快速运行。
结果如何?
由于该解决方案是用Go编写的,因此需要使用
github.com/JetBrains/phpstorm-stubs存储库才能对PHP中内置的所有函数和类进行定义。 作为回报,我们获得了很高的工作速度(每秒索引100万行,每秒分析10万行),并且能够使用lint作为git push hook的第一步来添加支票。
开发了一个方便的基础来创建新的检查,并达到了与PHPStorm接近的代码理解水平。 由于支持开箱即用的diff计算模式,因此可以逐步改进代码,从而避免了新代码中可能出现问题的新结构。
计算diff并不理想:例如,如果将一个大文件分为几个小文件,则git(因此NoVerify)将无法确定代码已被移动,而linter则需要修复所有发现的问题。 在这方面,diff的计算会阻止大规模重构,因此在这种情况下,通常会禁用它。
在Go上编写lint的另一个好处是:不仅AST解析器比PHP更快,消耗的内存更少,而且与PHP相比,后续分析也非常快。 这意味着我们的linter可以在保持高性能的同时对代码进行更复杂,更深入的分析(例如,“惰性类型”功能在此过程中需要进行大量计算)。
开源的
NoVerify在GitHub上的开源上可用享受您在项目中的使用!
UPD:我准备了一个
可以通过WebAssembly运行的
演示 。 该演示的唯一局限性是缺少phpstorm-stubs中的函数定义,因此linter会使用内置函数。
VKontakte基础架构部开发人员Yuri Nasretdinov