
Ich werde Ihnen erzählen, wie wir es geschafft haben, einen Linter zu schreiben, der schnell genug war, um bei jedem Git-Push nach Änderungen zu suchen, und dies in 5-10 Sekunden mit einer Codebasis von 5 Millionen Zeilen in PHP. Wir haben es NoVerify genannt.
NoVerify unterstützt grundlegende Dinge wie den Übergang zur Definition und die Suche nach Verwendungen und kann im
Sprachservermodus arbeiten. Unser Tool konzentriert sich zunächst auf die Suche nach potenziellen Fehlern, kann aber auch den Stil überprüfen. Heute erschien der Quellcode in Open Source auf GitHub. Suchen Sie nach dem Link am Ende des Artikels.
Warum brauchen wir unseren Linter?
Mitte 2018 beschlossen wir, einen Linter für PHP-Code zu implementieren. Es gab zwei Ziele: Reduzierung der Anzahl der Fehler, die Benutzer sehen, und strengere Überwachung der Einhaltung des Codestils. Das Hauptaugenmerk lag auf der Vermeidung typischer Fehler: das Vorhandensein nicht deklarierter und nicht verwendeter Variablen im Code, nicht erreichbarer Code und andere. Ich wollte auch, dass der statische Analysator so schnell wie möglich auf unserer Codebasis funktioniert (5-6 Millionen Zeilen PHP-Code zum Zeitpunkt des Schreibens).
Wie Sie wahrscheinlich wissen, ist der Quellcode für den größten Teil der Site in PHP geschrieben und mit
KPHP kompiliert.
Daher wäre es logisch, diese Überprüfungen dem Compiler hinzuzufügen. Tatsächlich ist jedoch nicht der gesamte Code für die Ausführung über KPHP sinnvoll. Beispielsweise ist der Compiler nur schwach mit Bibliotheken von Drittanbietern kompatibel, sodass für einige Teile der Site weiterhin reguläres PHP verwendet wird. Sie sind ebenfalls wichtig und sollten vom Linter überprüft werden. Daher gibt es leider keine Möglichkeit, sie in KPHP zu integrieren.
Warum NoVerify?
Angesichts der Menge an PHP-Code (ich möchte Sie daran erinnern, dass dies 5 bis 6 Millionen Zeilen sind) ist es nicht möglich, ihn sofort zu "reparieren", damit er unsere Prüfungen im Linter besteht. Trotzdem möchte ich, dass der sich ändernde Code allmählich sauberer wird, den Codierungsstandards strenger folgt und auch weniger Fehler enthält. Aus diesem Grund haben wir beschlossen, dass der Linter in der Lage sein sollte, die Änderungen zu überprüfen, die der Entwickler starten wird, und nicht auf den Rest zu schwören.
Dazu muss der Linter das gesamte Projekt indizieren, die Dateien vor und nach den Änderungen vollständig analysieren und die Differenz zwischen den generierten Warnungen berechnen. Dem Entwickler werden neue Warnungen angezeigt, die vor dem Push behoben werden müssen.
Es gibt jedoch Situationen, in denen dieses Verhalten unerwünscht ist und Entwickler dann ohne lokale Hooks pushen können - mit dem
git push --no-verify
. Option
--no-verify
und gab einem Linter einen Namen :)
Was waren die Alternativen
Die Codebasis in VK verwendet wenig OOP und besteht im Wesentlichen aus Funktionen und Klassen mit statischen Methoden. Wenn Klassen in PHP das automatische Laden unterstützen, funktionieren Funktionen nicht. Daher können wir keine statischen Analysatoren ohne wesentliche Änderungen verwenden, die ihre Arbeit auf der Tatsache basieren, dass beim automatischen Laden der gesamte fehlende Code geladen wird. Zu diesen Lintern gehört beispielsweise
Psalm von Vimeo .
Wir haben die folgenden statischen Analysewerkzeuge untersucht:
- PHPStan - Single-Threaded, erfordert automatisches Laden , Codebasisanalyse hat 30% in einer halben Stunde erreicht;
- Phan - selbst im Schnellmodus mit 20 Prozessen kam die Analyse nach 20 Minuten um 5% zum Stillstand;
- Psalm - erfordert Autoload, Analyse dauerte 10 Minuten (ich möchte immer noch viel schneller sein);
- PHPCS - überprüft den Stil, aber nicht die Logik;
- phpcf - prüft nur auf Formatierung.
Wie Sie dem Titel des Artikels entnehmen können, entspricht keines dieser Tools unseren Anforderungen, daher haben wir unser eigenes geschrieben.
Wie wurde der Prototyp erstellt?
Zuerst haben wir uns entschlossen, einen kleinen Prototyp zu bauen, um zu verstehen, ob es sich lohnt, einen vollwertigen Linter herzustellen. Da eine der wichtigsten Anforderungen für den Linter die Geschwindigkeit ist, haben wir uns für Go anstelle von PHP entschieden. "Schnell" bedeutet, dem Entwickler so schnell wie möglich eine Rückmeldung zu geben, vorzugsweise in nicht mehr als 10 bis 20 Sekunden. Andernfalls verlangsamt der Zyklus "Code korrigieren, Linter erneut ausführen" die Entwicklung erheblich und beeinträchtigt die Stimmung für die Menschen :)
Da für den Prototyp Go ausgewählt ist, benötigen Sie einen PHP-Parser. Es gibt mehrere davon, aber das
PHP-Parser- Projekt schien uns das ausgereifteste zu sein. Dieser Parser ist nicht perfekt und wird noch entwickelt, aber für unsere Zwecke ist er durchaus geeignet.
Für den Prototyp wurde beschlossen, eine der auf den ersten Blick einfachsten Inspektionen durchzuführen: den Zugriff auf eine undefinierte Variable.
Die Grundidee für die Implementierung einer solchen Inspektion sieht einfach aus: Erstellen Sie für jeden Zweig (z. B. if) einen separaten verschachtelten Bereich und kombinieren Sie die Variablentypen am Ausgang. Ein Beispiel:
<?php if (rand()) { $a = 42;
Es sieht einfach aus, oder? Bei gewöhnlichen bedingten Anweisungen funktioniert alles gut. Aber wir müssen zum Beispiel ohne Unterbrechung wechseln;
<?php switch (rand()) { case 1: $a = 1;
Aus dem Code geht nicht sofort hervor, dass $ c tatsächlich immer definiert wird. Insbesondere ist dieses Beispiel fiktiv, aber es zeigt gut, welche schwierigen Momente für den Linter (und in diesem Fall auch für die Person) sind.
Betrachten Sie ein komplexeres Beispiel:
<?php exec("hostname", $out, $retval); echo $out, $retval;
Ohne Kenntnis der Signatur der exec-Funktion kann nicht gesagt werden, ob $ out und $ retval definiert werden. Signaturen der integrierten Funktionen können aus dem Repository
github.com/JetBrains/phpstorm-stubs entnommen werden. Die gleichen Probleme treten jedoch beim Aufrufen benutzerdefinierter Funktionen auf, und ihre Signatur kann nur durch Indizieren des gesamten Projekts ermittelt werden. Die exec-Funktion nimmt das zweite und dritte Argument als Referenz, was bedeutet, dass die Variablen $ out und $ retval definiert werden können. Hier ist der Zugriff auf diese Variablen nicht unbedingt ein Fehler, und der Linter sollte nicht auf einen solchen Code schwören.
Ähnliche Probleme beim impliziten Link-Passing treten bei Methoden auf, gleichzeitig wird jedoch die Notwendigkeit hinzugefügt, Variablentypen abzuleiten:
<?php if (rand()) { $a = some_func(); } else { $a = other_func(); } $a->some_method($b); echo $b;
Wir müssen wissen, welche Typen die Funktionen some_func () und other_func () zurückgeben, um später in diesen Klassen eine Methode namens some_method zu finden. Nur dann können wir sagen, ob die Variable $ b definiert wird oder nicht. Die Situation wird durch die Tatsache kompliziert, dass häufig einfache Funktionen und Methoden keine phpdoc-Annotationen enthalten. Sie müssen daher weiterhin in der Lage sein, die Arten von Funktionen und Methoden basierend auf ihrer Implementierung zu berechnen.
Bei der Entwicklung des Prototyps musste ich etwa die Hälfte aller Funktionen implementieren, damit die einfachste Inspektion ordnungsgemäß funktionierte.
Arbeite als Sprachserver
Um das Debuggen der Logik des Linter und das Anzeigen der von ihm ausgegebenen Warnungen zu vereinfachen, haben wir beschlossen, den Betriebsmodus als
Sprachserver für PHP hinzuzufügen. Im Integrationsmodus mit Visual Studio Code sieht es ungefähr so aus:

In diesem Modus ist es praktisch, Hypothesen und komplexe Fälle zu testen (danach müssen Sie natürlich Tests schreiben). Es ist auch gut, die Leistung zu testen: Selbst bei großen Dateien zeigt der PHP-Parser unter Go eine gute Geschwindigkeit.
Die Unterstützung von Sprachservern ist alles andere als ideal, da der Hauptzweck darin besteht, Linter-Regeln zu debuggen. In diesem Modus gibt es jedoch mehrere zusätzliche Funktionen:
- Tipps für Variablennamen, Konstanten, Funktionen, Eigenschaften und Methoden.
- Markieren Sie abgeleitete Variablentypen.
- Gehen Sie zur Definition.
- Suche nach Verwendungen.
Inferenz vom Typ "Lazy"
Im Sprachservermodus ist Folgendes erforderlich: Sie ändern den Code in einer Datei, und wenn Sie zu einer anderen wechseln, sollten Sie mit bereits aktualisierten Informationen darüber arbeiten, welche Typen in Funktionen oder Methoden zurückgegeben werden. Stellen Sie sich die Dateien vor, die in der folgenden Reihenfolge bearbeitet werden:
<?php
Da wir Entwickler nicht zwingen, immer PHPDoc zu schreiben (insbesondere in solchen einfachen Fällen), benötigen wir eine Möglichkeit, Informationen darüber zu speichern, welchen Typ die Funktion B :: Something () zurückgibt. Wenn sich die A.php-Datei ändert, sind die Typinformationen in der C.php-Datei sofort auf dem neuesten Stand.
Eine mögliche Lösung besteht darin, "faule Typen" zu speichern. Beispielsweise ist der Rückgabetyp der B :: Something () -Methode tatsächlich ein Ausdruckstyp (neues A) -> Prop. In diesem Formular speichert der Linter Informationen über den Typ. Dank dieser Funktion können Sie alle Metainformationen für jede Datei zwischenspeichern und nur aktualisieren, wenn sich diese Datei ändert. Dies sollte sorgfältig durchgeführt werden, damit nicht versehentlich zu spezifische Informationen über Typen herauskommen. Es ist auch erforderlich, die Cache-Version zu ändern, wenn sich die Typinferenzlogik ändert. Trotzdem beschleunigt ein solcher Cache die Indizierungsphase (auf die ich später noch eingehen werde) um das 5- bis 10-fache im Vergleich zum wiederholten Parsen aller Dateien.
Zwei Arbeitsphasen: Indizierung und Analyse
Wie wir uns erinnern, sind selbst für die einfachste Code-Analyse Informationen über alle Funktionen und Methoden im Projekt erforderlich. Dies bedeutet, dass Sie nicht nur eine Datei getrennt vom Projekt analysieren können. Und doch - dass dies nicht in einem Durchgang möglich ist: Mit PHP können Sie beispielsweise auf Funktionen zugreifen, die in der Datei weiter deklariert sind.
Aufgrund dieser Einschränkungen besteht der Betrieb des Linters aus zwei Phasen: der primären Indizierung und der anschließenden Analyse nur der erforderlichen Dateien. Nun mehr zu diesen beiden Phasen.
Indizierungsphase
In dieser Phase werden alle Dateien analysiert und eine lokale Analyse des Codes von Methoden und Funktionen sowie des Codes auf oberster Ebene durchgeführt (um beispielsweise die Typen globaler Variablen zu bestimmen). Informationen zu den deklarierten globalen Variablen, Konstanten, Funktionen, Klassen und ihren Methoden werden gesammelt und in den Cache geschrieben. Für jede Datei im Projekt ist der Cache eine separate Datei auf der Festplatte.
Ein globales Wörterbuch aller Metainformationen über das Projekt, das sich in Zukunft nicht ändert *, wird aus einzelnen Teilen zusammengestellt.
* Zusätzlich zur Funktionsweise als Sprachserver wird bei jeder Bearbeitung eine Indizierung und Analyse der geänderten Datei durchgeführt.Analysephase
In dieser Phase können wir Metainformationen (über Funktionen, Klassen ...) verwenden und den Code bereits direkt analysieren. Hier ist eine Liste dessen, was NoVerify standardmäßig überprüfen kann:
- nicht erreichbarer Code;
- Zugriff auf Objekte als Array;
- unzureichende Anzahl von Argumenten beim Aufrufen der Funktion;
- Aufrufen einer undefinierten Methode / Funktion;
- Zugriff auf die fehlende Klasseneigenschaft / Konstante;
- Mangel an Klasse;
- Ungültiges PHPDoc
- Zugriff auf eine undefinierte Variable;
- Zugriff auf eine Variable, die nicht immer definiert ist;
- Mangel an "Pause"; nach case in switch / case Konstrukten;
- Syntaxfehler
- nicht verwendete Variable.
Die Liste ist ziemlich kurz, aber Sie können projektspezifische Schecks hinzufügen.
Während des Betriebs des Linter stellte sich heraus, dass die nützlichste Inspektion nur die letzte (nicht verwendete Variable) ist. Dies passiert häufig, wenn Sie den Code umgestalten (oder einen neuen schreiben) und ihn im Variablennamen versiegeln: Dieser Code ist aus Sicht von PHP gültig, aber logisch fehlerhaft.
Arbeitsgeschwindigkeit
Wie lange ist die Änderung, die wir pushen möchten, überprüft? Es hängt alles von der Anzahl der Dateien ab. Mit NoVerify kann der Vorgang bis zu einer Minute dauern (als ich 1400 Dateien im Repository geändert habe). Wenn jedoch nur wenige Änderungen vorgenommen wurden, werden normalerweise alle Überprüfungen in 4 bis 5 Sekunden durchgeführt. Während dieser Zeit wird das Projekt vollständig indiziert und analysiert neue Dateien sowie deren Analyse. Wir konnten durchaus einen Linter für PHP erstellen, der auch mit unserer großen Codebasis schnell funktioniert.
Was ist das Ergebnis?
Da die Lösung in Go geschrieben ist, muss das Repository
github.com/JetBrains/phpstorm-stubs verwendet werden, damit Definitionen aller Funktionen und Klassen in PHP integriert sind. Im Gegenzug erreichten wir eine hohe Arbeitsgeschwindigkeit (Indizierung von 1 Million Zeilen pro Sekunde, Analyse von 100.000 Zeilen pro Sekunde) und konnten als einen der ersten Schritte bei Git-Push-Hooks Überprüfungen mit einem Linter hinzufügen.
Es wurde eine praktische Basis für die Erstellung neuer Inspektionen entwickelt und ein Niveau des Codeverständnisses in der Nähe von PHPStorm erreicht. Aufgrund der Tatsache, dass der Modus mit Diff-Berechnung standardmäßig unterstützt wird, ist es möglich, den Code schrittweise zu verbessern und neue potenziell problematische Konstruktionen im neuen Code zu vermeiden.
Das Zählen von Diff ist nicht ideal: Wenn beispielsweise eine große Datei in mehrere kleine Dateien unterteilt wurde, kann git und damit NoVerify nicht feststellen, dass der Code verschoben wurde, und der Linter muss alle gefundenen Probleme beheben. In dieser Hinsicht verhindert die Berechnung von diff ein umfangreiches Refactoring, weshalb es in solchen Fällen häufig deaktiviert wird.
Das Schreiben eines Linter on Go hat einen weiteren Vorteil: Nicht nur der AST-Parser ist schneller und verbraucht weniger Speicher als PHP, sondern die nachfolgende Analyse ist auch sehr schnell im Vergleich zu allem, was in PHP durchgeführt werden könnte. Dies bedeutet, dass unser Linter eine komplexere und tiefere Analyse des Codes durchführen kann, während gleichzeitig eine hohe Leistung erhalten bleibt (zum Beispiel erfordert die Funktion "Lazy Types" eine ziemlich große Anzahl von Berechnungen im Prozess).
Open Source
NoVerify ist als Open Source auf GitHub verfügbarViel Spaß beim Einsatz in Ihrem Projekt!
UPD: Ich habe eine
Demo vorbereitet
, die über WebAssembly funktioniert . Die einzige Einschränkung dieser Demo ist das Fehlen von Funktionsdefinitionen von phpstorm-stubs, sodass der Linter auf integrierte Funktionen schwören wird.
Yuri Nasretdinov, Entwickler der Infrastrukturabteilung von VKontakte