Statische Analyse großer Mengen von Python-Code: Instagram-Erfahrung. Teil 1

Der Instagram-Servercode ist ausschließlich in Python geschrieben. Nun, im Grunde ist es so. Wir verwenden ein wenig Cython und die Abhängigkeiten enthalten viel C ++ - Code, der wie bei C-Erweiterungen von Python aus bedient werden kann.



Unsere Serveranwendung ist ein Monolith, eine große Codebasis, die aus mehreren Millionen Zeilen besteht und mehrere tausend Django-Endpunkte enthält ( hier ist ein Vortrag über die Verwendung von Django auf Instagram). All dies wird geladen und dient als eine Einheit. Dem Monolithen wurden mehrere Dienste zugewiesen, aber unser Plan sieht keine starke Trennung des Monolithen vor.

Unser Serversystem ist ein Monolith, der sich sehr oft ändert. Jeden Tag machen Hunderte von Programmierern Hunderte von Commits zum Code. Wir setzen diese Änderungen kontinuierlich alle sieben Minuten ein. Infolgedessen wird das Projekt ungefähr hundert Mal am Tag in der Produktion eingesetzt. Wir bemühen uns sicherzustellen, dass zwischen dem Festschreiben eines Commits in der Hauptniederlassung und dem Bereitstellen des entsprechenden Codes in der Produktion weniger als eine Stunde vergeht ( hier ein Vortrag auf der PyCon 2019).

Es ist sehr schwierig, diese riesige monolithische Codebasis aufrechtzuerhalten, täglich Hunderte von Commits zu tätigen und sie gleichzeitig nicht in einen Zustand völligen Chaos zu bringen. Wir möchten Instagram zu einem Ort machen, an dem Programmierer produktiv sein und schnell neue nützliche Funktionen des Systems vorbereiten können.

Dieses Material konzentriert sich darauf, wie wir Flusen und automatisches Refactoring verwenden, um die Verwaltung einer Python-Codebasis zu vereinfachen.

Wenn Sie einige der in diesem Material erwähnten Ideen ausprobieren möchten , sollten Sie wissen, dass wir kürzlich in die Kategorie der Open-Source-Projekte LibCST übergegangen sind , die vielen unserer internen Tools für das Lining und das automatische Code-Refactoring zugrunde liegen.

Der zweite Teil

Flusen: Dokumentation, die dort angezeigt wird, wo sie benötigt wird


Linting hilft Programmierern, Probleme und Antimuster zu finden und zu diagnostizieren, von denen Entwickler selbst möglicherweise nichts wissen, ohne sie im Code zu bemerken. Dies ist für uns wichtig, da die relevanten Ideen zum Design des Codes umso schwieriger zu verteilen sind, je mehr Programmierer an dem Projekt arbeiten. In unserem Fall sprechen wir über Hunderte von Spezialisten.


Sorten von Flusen

Flusen ist nur eine der vielen Arten der statischen Code-Analyse, die wir auf Instagram verwenden.

Die primitivste Methode zum Implementieren von Flusenregeln ist die Verwendung regulärer Ausdrücke. Reguläre Ausdrücke sind einfach zu schreiben, aber Python ist keine „normale“ Sprache . Infolgedessen ist es sehr schwierig (und manchmal unmöglich), mit regulären Ausdrücken zuverlässig nach Mustern in Python-Code zu suchen.

Wenn wir über die komplexesten und fortschrittlichsten Methoden zur Implementierung von Linter sprechen, gibt es Tools wie mypy und Pyre . Dies sind zwei Systeme zur statischen Typprüfung von Python-Code, mit denen Programme eingehend analysiert werden können. Instagram benutzt Pyre. Dies sind leistungsstarke Tools, die sich jedoch nur schwer erweitern und anpassen lassen.

Wenn wir über Flusen auf Instagram sprechen, meinen wir normalerweise das Arbeiten mit einfachen Regeln, die auf einem abstrakten Syntaxbaum basieren. Genau so etwas liegt unseren eigenen Flusenregeln für Servercode zugrunde.

Wenn Python ein Modul ausführt, startet es zunächst den Parser und übergibt ihm den Quellcode. Dadurch wird ein Analysebaum erstellt - eine Art konkreter Syntaxbaum (CST). Dieser Baum ist eine verlustfreie Darstellung des eingegebenen Quellcodes. In diesem Baum werden alle Details wie Kommentare, Klammern und Kommas gespeichert. Basierend auf CST können Sie den ursprünglichen Code vollständig wiederherstellen.


Python- Analysebaum (eine Variation von CST), der von lib2to3 generiert wird

Leider führt ein solcher Ansatz zur Erstellung eines komplexen Baums, der es schwierig macht, daraus semantische Informationen zu extrahieren, die für uns von Interesse sind.

Python kompiliert den Analysebaum in einen abstrakten Syntaxbaum (AST). Einige Informationen zum Quellcode gehen bei dieser Konvertierung verloren. Wir sprechen von "zusätzlichen syntaktischen Informationen" - wie Kommentaren, Klammern, Kommas. Die Semantik des Codes in AST bleibt jedoch erhalten.


Vom Ast- Modul generierter abstrakter Python-Syntaxbaum

Wir haben LibCST entwickelt - eine Bibliothek, die uns das Beste aus der Welt von CST und AST bietet. Es gibt eine Darstellung des Codes, in dem alle Informationen darüber gespeichert sind (wie in CST), aber es ist einfach, semantische Informationen darüber aus einer solchen Darstellung des Codes zu extrahieren (wie bei der Arbeit mit AST).


Darstellung eines bestimmten LibCST-Syntaxbaums

Unsere Flusenregeln verwenden den LibCST- Syntaxbaum , um Muster im Code zu finden. Dieser Syntaxbaum auf hoher Ebene ist leicht zu erkunden und ermöglicht es Ihnen, die Probleme zu beseitigen, die mit der Arbeit mit der "unregelmäßigen" Sprache einhergehen.

Angenommen, in einem bestimmten Modul besteht aufgrund des Typimports eine zyklische Abhängigkeit. Python löst dieses Problem, indem if TYPE_CHECKING in einen if TYPE_CHECKING Block if TYPE_CHECKING . Dies ist ein Schutz gegen das Importieren von Objekten zur Laufzeit.

 #    from typing import TYPE_CHECKING from util import helper_fn #    if TYPE_CHECKING:    from circular_dependency import CircularType 

Später fügte jemand dem Code einen weiteren Typimport und einen weiteren if Block hinzu. Derjenige, der dies getan hat, weiß jedoch möglicherweise nicht, dass ein solcher Mechanismus bereits im Modul vorhanden ist.

 #    from typing import TYPE_CHECKING from util import helper_fn #    if TYPE_CHECKING:    from circular_dependency import CircularType if TYPE_CHECKING: #   !    from other_package import OtherType 

Sie können diese Redundanz mit der Linter-Regel beseitigen!

Beginnen wir mit der Initialisierung des Zählers der im Code enthaltenen „Schutzblöcke“.

 class OnlyOneTypeCheckingIfBlockLintRule(CstLintRule):    def __init__(self, context: Context) -> None:        super().__init__(context)        self.__type_checking_blocks = 0 

Wenn wir dann die entsprechende Bedingung erfüllen, erhöhen wir den Zähler und prüfen, ob der Code nicht mehr als einen solchen Block enthält. Wenn diese Bedingung nicht erfüllt ist, generieren wir eine Warnung an der entsprechenden Stelle im Code und rufen den Hilfsmechanismus auf, der zum Generieren solcher Warnungen verwendet wird.

 def visit_If(self, node: cst.If) -> None:    if node.test.value == "TYPE_CHECKING":        self.__type_checking_blocks += 1        if self.__type_checking_blocks > 1:            self.context.report(                node,                "More than one 'if TYPE_CHECKING' section!"            ) 

Ähnliche Flusenregeln funktionieren, indem Sie den LibCST-Baum betrachten und Informationen sammeln. In unserem Linter wird dies anhand des Besuchermusters implementiert. Wie Sie vielleicht bemerkt haben, überschreiben die Regeln die Besuchsmethoden und belassen die dem Knotentyp zugeordneten Methoden. Diese "Besucher" werden in einer bestimmten Reihenfolge aufgerufen.

 class MyNewLintRule(CstLintRule):    def visit_Assign(self, node):        ... #      def visit_Name(self, node):        ... #        def leave_Assign(self, name):        ... #      


Besuchsmethoden werden aufgerufen, bevor Nachkommen von Knoten besucht werden. Urlaubsmethoden werden aufgerufen, nachdem alle Nachkommen besucht wurden

Wir halten uns an die Prinzipien der Arbeit, nach denen einfache Aufgaben zuerst gelöst werden. Unsere erste eigene Linter-Regel wurde in einer einzigen Datei implementiert, enthielt einen „Besucher“ und verwendete einen gemeinsamen Status.


Eine Datei, ein "Besucher" im gemeinsam genutzten Status

Die Single Visitor muss Informationen über den Status und die Logik aller unserer Flusenregeln enthalten, die nicht damit zusammenhängen. Darüber hinaus ist nicht immer klar, welcher Zustand einer bestimmten Regel entspricht. Dieser Ansatz zeigt sich gut in einer Situation, in der es buchstäblich einige Ihrer eigenen Flusenregeln gibt, aber wir haben ungefähr hundert dieser Regeln, was die Unterstützung des single-visitor erheblich erschwert.


Es ist schwierig zu wissen, welcher Zustand und welche Logik mit jeder der Prüfungen verbunden sind.

Als eine der möglichen Lösungen für dieses Problem könnte man natürlich die Definition mehrerer „Besucher“ und die Organisation eines solchen Arbeitsschemas in Betracht ziehen, dass jeder von ihnen jedes Mal den gesamten Baum betrachtet. Dies würde jedoch zu einem ernsthaften Rückgang der Produktivität führen, und der Linter ist ein Programm, das schnell funktionieren sollte.


Jede Linter-Regel kann einen Baum wiederholt durchlaufen. Bei der Verarbeitung einer Datei werden die Regeln nacheinander ausgeführt. Dieser Ansatz, der häufig den Baum durchquert, würde jedoch zu einem ernsthaften Leistungsabfall führen.

Anstatt so etwas zu realisieren, ließen wir uns von den Lintern inspirieren, die in Ökosystemen anderer Programmiersprachen - wie ESLint aus JavaScript - verwendet wurden, und erstellten ein zentrales Register der „Besucher“ (Besucherregister).


Zentrales Besucherregister. Wir können effektiv bestimmen, welcher Knoten an jeder Regel des Linter interessiert ist, was Zeit für Knoten spart, die nicht daran interessiert sind.

Wenn die Linter-Regel initialisiert wird, werden alle Überschreibungen der Regelmethoden in der Registrierung gespeichert. Wenn wir um den Baum herumgehen, schauen wir uns alle registrierten „Besucher“ an und rufen sie an. Wenn die Methode nicht implementiert ist, müssen Sie sie nicht aufrufen.

Dies reduziert den Verbrauch von Computerressourcen durch das System, wenn neue Flusenregeln hinzugefügt werden. Normalerweise überprüfen wir mit einem Linter eine kleine Anzahl kürzlich geänderter Dateien. Wir können jedoch alle Regeln auf der gesamten Codebasis des Instagram-Servers in nur 26 Sekunden parallel überprüfen.

Nachdem wir Leistungsprobleme behoben hatten, erstellten wir ein Testframework, das auf die Einhaltung fortgeschrittener Programmiertechniken abzielte und Tests in Situationen erforderte, in denen etwas eine gewisse Qualität aufweisen muss, und in Situationen, in denen etwas keine gewisse Qualität aufweisen sollte sollte.

 class MyCustomLintRuleTest(CstLintRuleTest):    RULE = MyCustomLintRule       VALID = [        Valid("good_function('this should not generate a report')"),        Valid("foo.bad_function('nor should this')"),    ]       INVALID = [        Invalid("bad_function('but this should')", "IG00"),    ] 

Fortsetzung → zweiter Teil

Liebe Leser! Verwenden Sie Linters?


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


All Articles