NoVerify:一个运行良好的PHP linter

有很好的PHP静态分析实用程序:PHPStan,Psalm,Phan,Exakat。Linters可以很好地完成工作,但速度却很慢,因为几乎所有的东西都是用PHP(或Java)编写的。对于个人使用或小型项目,这是正常的,但是对于拥有数百万用户的网站,这是一个关键因素。速度慢的linter会减慢CI管道的速度,使其无法用作可集成到文本编辑器或IDE中的解决方案。



一个拥有数百万用户的网站是VKontakte。开发和添加新功能,测试和修复错误,复审-在艰难的期限内,所有这些工作都应该迅速进行。因此,可以在5到10秒钟内检查500万行代码库的良好快速的Linter是不可替代的。 

市场上没有合适的棉短绒,因此Yuri Nasretdinov(你摇滚)从VKontakte向开发团队-NoVerify写了他的帮助。这是用Go编写的PHP的linter。它的工作速度是同类产品的10到30倍,可以找到PhpStorm不会警告的内容,可以轻松扩展并很好地集成到以前从未听说过静态分析的项目中。 伊斯坎德•莎里波夫(Iskander Sharipov)

会讲讲这羽短绒棉衣切入点:他们如何选择短绒棉衫并更喜欢编写自己的短绒棉衫,为什么NoVerify这么快,它是如何从内部排列的,为什么要用Go编写,它可以找到什么以及如何扩展,必须为此做出什么妥协以及可以基于它来构建。


伊斯坎德尔·沙里波夫准电解质)在VKontakte的后端基础架构中工作,并且对NoVerify十分熟悉。过去,他曾在英特尔团队中从事过Go编译器工作。他不是用PHP编写的,但这是他最喜欢的静态分析语言-其中有很多地方可能出错。

注意。要了解背景知识,请阅读NoVerify onHabré的作者Yuri Nasretdinov的文章并提供背景知识并与通常使用PHP编写的一些短绒棉进行比较。所有关于PHP的声明(在Yuri和此处的文章中)都是一个笑话。伊斯坎德尔(Iskander)喜欢PHP,每个人都喜欢PHP。

产品开发


在VKontakte中,这是KPHP上的网站开发。速度对于VKontakte很重要:修复错误,从第一阶段到最后阶段添加和开发新功能。但是,速度伴随着错误,尤其是在期限紧迫的情况下-我们急于忙碌,紧张,犯下的错误要比平静的情况多。

错误会影响用户。我们不希望他们受苦,因此我们控制质量。但是质量控制减慢了发展速度。我们也不希望这样做,因此必须使效果最小化。

为此,我们可以进行更多的代码复查,并雇用更多的测试人员并编写更多测试。但是,所有这些操作的自动化程度都很差:必须进行审查,并且必须编写测试。

我团队的主要任务是不同的。

收集指标,分析并快速修复如果出现问题,我们希望快速回滚,了解问题所在,进行修复,然后将有效代码重新添加到生产环境中。

监视管道的严格性,以使完全无法运行的代码完全不投入生产-您无需回滚它。在这里,lint可以提供帮助-静态代码分析器。我们将讨论这个。

选择一个短绒


让我们选择一个添加到管道中的短绒。我们采用一种简单的方法-制定需求。

短绒棉应该工作很快。我们的流程有几个步骤:在等待反馈的过程中,lint的操作不应花费太多时间和时间。

支持“他们的”检查。短绒很可能没有我们需要的所有东西,我们将不得不添加我们自己的支票。他们应该找到我们代码库中的典型问题,从我们的项目角度检查代码。测试并不能(或方便地)涵盖所有这些内容。

支持“自有”支票我们可以编写许多测试,但是它们会得到很好的支持吗?例如,如果我们编写正则表达式,则当您需要考虑语言的上下文,语义和语法时,它们将变得更加复杂。因此,测试不是一种选择。

我们审查的大多数短毛猫都是用PHP编写的。但是它们并不能按需传递。PHP中的Linters(尚无AOT编译)的工作速度比其他语言慢10到20倍-我们最大的文件可以分析数十秒。这会大大减慢工作流程的速度,这是一个致命的缺陷在这种情况下,开发人员会做什么?他们写自己的。

因此,我们在Go上编写了NoVerify PHP linter。为什么呢?剧透:不仅因为汝拉决定。

规范化


Go是开发速度和生产率之间的良好折衷。
图片中带有“信息图表”的第一个“证明”:良好的执行速度,易于支持。我们的发展速度有所下降,但前两点对我们而言更为重要。


这些数字是取自头顶的,没有任何后盾。

对于第二个“证据”,他的论点更为简单。


PHP较慢,Go较快,依此类推。 

我们选择Go的原因有三个。

从根本上来说,将Go语言用作实用程序的语言很容易学习。当然,在PHP开发团队中,有人听说过Go,看着Docker,知道它是用Go编写的,甚至看到了源代码。基本了解之后,经过一两个星期的强化Go学习,他们将能够在上面编写代码。

围棋非常有效。即使是初学者也不会犯很多错误,因为Go具有良好的调优能力和许多轻巧的特性。在Go中,平均代码比其他语言要好一些,因为可以用更少的方法来射击自己的腿。

Go应用易于维护。Go是一种相当成熟的编程语言,几乎所有您想要的开发人员工具都可以使用。

我们将根据需求验证NoVerify。

  • NoVerify 比其他方法快几倍

  • 为此,您可以编写扩展,包括开源和您自己的。我们必须分开这些检查,并且您可以编写自己的检查,这一点很重要。
  • 易于测试和开发。部分是因为标准的Go发行版具有带有概要分析和测试的标准框架。它主要用于单元测试。像我们一样,可以使用特别灵巧的集成-我们通过Go测试编写了集成测试。

整合的妥协


让我们从问题开始。当您首次在未使用任何分析的旧项目上启动任何linter ,您很可能会看到此情况。



哦,我的代码!没有人会纠正这么多错误。我要关闭该项目,删除短绒,并且不再运行它。如何避免这种情况?

整合


在差异模式下运行。我们不希望在整个配置项中每个CI步骤都运行一百万个错误的所有检查。也许您知道基线:在NoVerify中,这是开箱即用的,您不需要嵌入单独的实用程序。我们立即认为,需要这样一种制度。

将旧的(供应商)添加到例外中。不碰某些东西,即使有缺陷也可以搁置一旁,以免自己修改,不留下历史痕迹。

我们从检查的一部分开始。您无法连接包括样式在内的所有内容。首先,它会发现真正的错误:我们将找到,修复并切换到新的错误。

我们收集同事的反馈如何理解何时该开启其他功能?问同事。一旦他们高兴地发现错误已消失并且几乎没有发现任何错误,就打开其他东西-是时候开始工作了。

Git设置


差异模式意味着您拥有版本控制系统-Git。如果您有SVN,那么说明将无济于事,请转至Git。

我们使用短绒棉纸安装预推钩,并在开始代码之前进行检查。我们在本地计算机检查是否--no-verify绕过linter选项。使用预接收钩子并禁用服务器端linter可能会更方便,但是由于历史原因,VK中的预推钩子中发生了很多事情,因此NoVerify也内置在其中。

推送后,将启动CI检查。 NoVerify有两种操作模式:全面分析和不全面分析。在CI上,您最有可能想要(并且可以)运行--git-full-diff-CI中的计算机可以更重地加载,甚至可以检查那些未更改的文件。在本地计算机上,我们可以只对已更改的文件运行不太严格但更快的分析(快5-15秒)。 

误报




请看下面的示例:在此函数中,接受包含字段的内容,但未对类型进行任何描述。当从不同的上下文中调用函数时,根本就没有字段。在严格的版本中,lint可能会抱怨:“尚不清楚它是什么类型,如何在没有检查的情况下返回字段?” 但这不一定是错误的。

function get_foo($obj) {
    return $obj->foo;
    ^^^
}

Warning:
Property "foo" does not exist

误报会干扰。 
这是放弃短绒的主要原因。人们选择其他选项可以发现更少的错误,但产生更少的误报。

他们经常在某些事情起作用时四处走动,但这并不是一个错误。许多人都有绕过linter的机制-与标志一起运行而不检查linter。在我们国家,这个标志被称为no-verify预推钩。我们经常使用它,而这个名字就以棉短绒的名字永生。

挑剔


另一个短绒物产。例如,许多人不理解别名。在PHP中,sizeof这是一个类似物count:它不计算大小,但返回元素数。C开发人员的思维模式具有sizeof不同的含义。如果在代码库中sizeof最有可能是Mean count但这是挑剔的。

$len = sizeof($x);
    ^^^^^^

Warning:
use "count" instead of "sizeof"

怎么办呢?


要严格并强制统治一切。施加规则,要求,遵守并不允许其规避-永远行不通。为了使工作如此僵化,团队必须由同一个人组成:性格,文化水平,学究和对代码质量的认识。如果不是这样,将会发生骚乱。从您的克隆中组建一个团队比强迫遵守所有规则要容易。 

请勿阻止对评论的推送/提交,例如固定sizeofcount这很可能不是错误,而是挑剔的行为,不会影响代码。但是,然后(团队)将忽略99%的响应,并且代码中总会有多余的响应sizeof

允许不同团队和开发人员进行某种程度的配置。您可以为每个命令配置配置,以使那些不想更改sizeof为的人count无法执行此操作。让其他人遵守规则。一个不错的选择,但一致性会下降,并且在某些目录中,代码会稍差一些。

每月在subbotniks上运行一次此类检查不能每次都在CI或预推钩子上运行检查,而是每月在例行Cron中运行一次。积极开发后,运行并编辑找到的所有内容。但是这项工作需要用于自动化和验证的资源。

没做什么。也可以关闭样式检查。

妥协




一个快乐的开发人员和一个快乐的linter总是会妥协。让短毛猫高兴很容易:最严格的模式和缺乏解决方法。也许在那之后没有人会留在团队中,所以如果短毛猫干扰了工作,那就是一个问题。
首先是有用的动作。

否验证技术细节


私人检查VKontakte。 Noverify是这样写的。在GitHub中,NoVerify存储库分为两个部分:用于实现linter的框架和单独的检查vklints。这样做是为了使linter加载第三方检查:您可以在Go上编写一个单独的模块,然后它们在框架中进行注册。从NoVerify二进制文件开始后,框架将加载所有已注册的检查集,它们将作为一个整体工作。 



NoVerify既是库又是二进制文件(lint)。

我们的支票称为vklints。他们发现他们看不到PhpStorm和Open Source NoVerify-不适合一般使用的重要错误。

什么是vklints?

检查使用某些函数,类,甚至不遵循我们约定的全局变量的细节。由于样式指南中所述的各种原因,这是不能在特殊地方使用的东西。

其他样式检查。它们与PHP社区中接受的内容不符,没有在PHP标准建议书中描述甚至与之矛盾,但是对我们而言,它是标准。将它们添加到“开源”没有意义,因为您不想关注它们。

某些类型的比较要求严格。例如,我们有一项检查,要求使用比较运算符比较字符串===。特别是,它需要传递一个标志以严格比较功能,以便比较字符串。

可疑阵列键。另一个有趣的错误:有时,开发人员在提交时可以在保存文件之前按组合键。这些字符有时保留在字符串或一段代码中。一次,在数组的键中是俄语字母“ Y”。开发人员最有可能在俄语布局中按CTRL-S,保存文件并提交。有时我们会在数组中找到这样的键,但是新的错误将不再存在。

动态规则是PHP中描述的一种更简单的NoVerify扩展机制。关于此的另一篇文章已经写到:如何在不编写任何Go代码的情况下向NoVerify添加检查

NoVerify如何运作


要解析PHP,您需要一个解析器我们不能在PHP中使用PHP解析器:它很慢,从Go开始只能通过C中的包装器使用。因此,我们在Go中使用了解析器

该解析器有几个问题。不幸的是,他只能使用UTF-8,我们需要区分UTF-8而不是UTF-8除了UTF-8,在俄语PHP项目中经常可以找到Windows-1251。我们也有这样的文件。我们如何识别它们? 

该文件encodings.xml列出了使用UTF-8的文件所在的所有路径。如果我们在这些路径之外遇到文件,那么我们将通过流传输将流传输到UTF-8(无需预先转换)。


解析与分析


分几步完成。首先,我们从phpstorm-stubs加载元数据这是看起来像PHP代码的数据,但是从未执行过,并且描述了标准函数的输入/输出类型。phpStorm元数据具有一个有用的linter 覆盖指令。例如,它允许我们描述我们接受一个类型的数组T[]并返回一个类型(对函数有用array_pop)。


phpstorm-stubs首先被加载。我们使用元数据作为初始类型信息-基础。棉绒吸收了这一基础,我们开始分析其来源。

我们在吸收之前加载当前的主控我们以两种方式检查代码:

  • 本地更改:关于基准,我们在代码中发现了新的错误;
  • 我们指出了修订的范围:第一个和最后一个修订,并且它们之间的所有内容都包含在内-这是新代码,“之前”的所有内容都是旧的。

接下来是分析阶段。



AST分析现在,我们有了元数据,类型信息。我们直接在AST之上获取所有PHP源代码,parsim和分析-我们目前没有中间表示。分析原始AST并不是很方便,特别是如果您依赖于它所代表的库和数据类型。 



分析结果存储在缓存中。它用于重新分析,速度更快。

报告和过滤。然后,我们两次生成报告或警告:首先,我们发现代码的旧版本(基线之前)的警告,然后是新版本的警告。报告通过比较(diff)进行过滤-我们寻找新版本代码中出现的警告,并将其传递给用户。在某些静态分析仪中,这称为“基线模式”。



双重代码分析(在差异模式下)非常慢。但是我们负担得起-NoVerify仍然比其他PHP链接器快几十倍。同时,它还有至少30%的额外加速储备。

我们如何分析文件?在PHP中,您可以在定义函数之前先调用它-在分析函数之前,您需要了解有关该函数的信息。因此,首先我们遍历AST中的整个文件,建立索引,确定所有函数的类型,注册类,然后再对其进行分析。 



分析是文件的第二次传递大多数解释器和编译器还可以使用两遍或更多遍。为了避免第二次“扫描”文件,您必须在使用前声明,例如在C中。

类型推断


最有趣的部分是,这里最常遇到错误。它仍然不符合PHP类型系统的正确性,这很难正式定义。

该模型是什么样的。


语义模型(演示)。

类型类型:

  • 期望是我们在注释中描述的内容,我们期望程序中有某些类型,但这并不意味着它们确实在程序中使用过。
  • 实际 -程序中的实际值。例如,如果我们为某物分配一个数字,则很明显,intfloat(如果它是浮点数)将是实际类型。 

实际类型似乎“更强”-它们是真实的,真实的。但是有时我们只能通过注释获得类型。

注释(预期类型)可以分为两类:信任不信任例如,phpstorm-stubs属于第一类。在使用它们之前,它们被视为已审核(无错误)。不可靠的是其他开发人员编写的,因为它们可能有错误。

实际类型也可以分为几个部分:值,断言,谓词和类型提示,它们扩展了PHP 7的功能。但是存在类型提示无法解决的问题。

预期与实际


假设一个类Foo有一个继承者。在子孙类中,我们可以调用不在Foo中的方法,因为子孙扩展了父类。但是,如果我们使用返回类型的注释(from )来获得继承人Foo则会出现问题。我们可以调用此方法,但是IDE不会提示您-您需要指定。这是PHP中的后期静态绑定,当类继承者无法返回时,则为静态绑定。 new static()selfstatic()Foo

class Foo {
    /** @return static */
    public function newStatic() : self {
        return new static();
    }
}
// actual = Foo
// expected = static

当我们写的时候new static(),不仅班级可以返回new Foo例如,如果一个Foo类是继承bar,则可能存在new bar因此,我们至少需要两种类型的信息。它们都不是多余的-都需要。

因此,此处的实际类型将是self-对于PHP解释器。但是,为了使IDE和短毛绒工作,我们需要static如果从继承人类的上下文中调用此代码,则需要知道以下信息:它不是同一个基类,并且具有更多方法。

class Foo {
    /** @return static */
    public function newStatic() : self {
        return new static();
    }
}
// actual -  PHP 
// expected -   IDE/

类型提示


静态类型输入和类型提示不是一回事。
您可能听说过,您只能检查功能的边界。在边界处,我们检查输入和输出,其中输入是函数参数。在函数内部,您可以做任何废话:尽管已描述fooint,但可以指定一个T您可能会抱怨自己违反了声明的类型,但是对于PHP来说没有error。 

declare(strict_types=1);
    function f(T $foo) {
        $foo = 10; //  int
        return $foo;
}

一个例子比较困难-我们回来了foo吗?在函数开始时,我们确定foo它是T,并且没有关于返回值的信息。 

declare(strict_types=1);
function f(T $foo) {
    $foo = 10; //  int
    return $foo;
}
// ? 1. f -> int
// ? 2. f -> T|int
// ? 3. f -> T

哪种类型正确?前两个,我们将分析它们之间的区别。PhpStorm和linter输出第二个选项。尽管事实总是会返回int,但T|int还是会推导出类型- 类型的“联合”。这是一个可以为这两个值分配的类型:首先,我们获得了有关类型T的信息,然后我们为其分配了10,因此变量的类型foo必须与这两种类型兼容。

注解


可能有评论和注释。
在下面的示例中,我们写道,我们返回一个数字,但返回一个字符串。如果linter仅在注释和类型提示级别起作用,那么我们将认为它总是会返回int但是,实际类型只是有助于摆脱这种情况:在这里,期望类型是this int,而实际类型是字符串。Linter知道该字符串已返回,并可能警告您已承诺返回int这种分离对我们很重要。

/** @return int */
function f() { return "I lied!"; }

注释的继承。在这里,我的意思是,实现某种接口的类具有一个方法。该方法具有良好的注释,文档,类型-实现接口是必需的。但是在实现过程中没有任何评论:只有@inheritdoc一无所有。

interface IFoo {
    /** @return int */
    public function foo();
}
class Fooer implements IFoo {
    /** @inheritdoc */
    public function foo() { return "10"; }
}

什么返回此方法?似乎返回了-接口中描述的内容int,但实际上是一个字符串。这不好:PHP都是一样的,但是融合对我们很重要。

有两个选项可修复此代码。显而易见的是要返回int。但也许您需要返回其他类型。该怎么办?写下我们返回字符串。在这种情况下,IDE和linter都需要显式类型信息,以便正确分析代码。

interface IFoo {
    /** @return int */
    public function foo();
}
class Fooer implements IFoo {
    /** @return string */
    public function foo() { return "10"; }
}

如果人们发表评论,则根本不需要此信息@inheritdocPhpStorm不必了解您拥有的类型。但是,如果类型描述不正确,则会出现问题。

当我们对元数据(类型)使用相同的文件时,PhpStorm和linter会有许多不相交的错误。如果我们修复了JetBrains信息库中phpstorm-stub中所需的所有内容,那么IDE很可能会崩溃。如果您默认保留所有内容,那么并不是所有内容都无法正常工作

,因此,我们有一个小分支  -VKCOM / phpstorm-stubs添加了一些补丁程序来修复不适合的问题。对于PhpStorm,我不推荐使用它,但对于短绒棉来说,这是必需的。

开源的


Noverify是一个开源项目。它发布在GitHub上

简要说明“如果出了问题”。

如果发生故障或无法启动。错误的反应是重新发送并删除NoVerify。正确的反应:在GitHub上发布票证并讨论您的问题。最有可能在1-2天内解决。

您缺少某些功能。错误的反应:删除NoVerify并编写自己的Linter(尽管编写自己的Linter总是很酷)。正确的反应:在GitHub上发布票证,也许我们将添加一个新功能。功能要比错误要复杂得多-引起了讨论,每个人对团队中的实现都有不同的见解。但最终,它们仍在实施中。

如果您对项目的开发感兴趣,或者只想谈论静态分析,请访问我们的聊天室-noverify_linter

PHP-, , , , PHP Russia.

, . , , . telegram- @PHPRussiaConfChannel. , .

Source: https://habr.com/ru/post/undefined/


All Articles