Database
 sql >> Datenbank >  >> RDS >> Database

SQL vervollständigen. Geschichten von Erfolg und Scheitern

Ich arbeite seit mehr als fünf Jahren für ein Unternehmen, das IDEs für die Datenbankinteraktion entwickelt. Bevor ich anfing, diesen Artikel zu schreiben, hatte ich keine Ahnung, wie viele ausgefallene Geschichten auf mich zukommen würden.

Mein Team entwickelt und unterstützt IDE-Sprachfunktionen, und die automatische Codevervollständigung ist die wichtigste. Ich sah viele aufregende Dinge, die passierten. Manches haben wir vom ersten Versuch an super hinbekommen, manches scheiterte auch nach mehreren Aufnahmen.

SQL- und Dialektanalyse

SQL ist ein Versuch, wie eine natürliche Sprache auszusehen, und der Versuch ist ziemlich erfolgreich, sollte ich sagen. Je nach Dialekt gibt es mehrere tausend Stichwörter. Um eine Aussage von einer anderen zu unterscheiden, müssen Sie oft nach ein oder zwei Wörtern (Tokens) vorausschauen. Dieser Ansatz wird als Lookahead bezeichnet .

Es gibt eine Parser-Klassifizierung, je nachdem, wie weit sie vorausschauen können:LA(1), LA(2) oder LA(*), was bedeutet, dass ein Parser so weit vorausschauen kann, wie es nötig ist, um den richtigen Fork zu definieren.

Manchmal stimmt das Ende eines optionalen Satzes mit dem Beginn eines anderen optionalen Satzes überein. Diese Situationen erschweren die Ausführung des Parsing erheblich. T-SQL macht die Sache nicht einfacher. Außerdem können einige SQL-Anweisungen Endungen haben, die mit dem Anfang vorheriger Anweisungen in Konflikt geraten können, müssen dies jedoch nicht.

Glaubst du es nicht? Es gibt eine Möglichkeit, formale Sprachen über Grammatik zu beschreiben. Mit diesem oder jenem Tool können Sie daraus einen Parser generieren. Die bemerkenswertesten Tools und Sprachen, die Grammatik beschreiben, sind YACC und ANTLR.

YACC -generierte Parser werden in MySQL-, MariaDB- und PostgreSQL-Engines verwendet. Wir könnten versuchen, sie direkt aus dem Quellcode zu nehmen und eine Codevervollständigung zu entwickeln und andere Funktionen, die auf der SQL-Analyse basieren, die diese Parser verwendet. Außerdem würde dieses Produkt kostenlose Entwicklungsaktualisierungen erhalten und der Parser würde sich genauso verhalten wie die Quell-Engine.

Warum verwenden wir also immer noch ANTLR? ? Es unterstützt C#/.NET fest, hat ein anständiges Toolkit, seine Syntax ist viel einfacher zu lesen und zu schreiben. Die ANTLR-Syntax wurde so praktisch, dass Microsoft sie jetzt in ihrer offiziellen C#-Dokumentation verwendet.

Aber kehren wir zur SQL-Komplexität zurück, wenn es um das Parsen geht. Ich möchte die Grammatikgrößen der öffentlich verfügbaren Sprachen vergleichen. In dbForge verwenden wir unsere Grammatikstücke. Sie sind vollständiger als die anderen. Leider sind sie mit den Einfügungen des C#-Codes zur Unterstützung verschiedener Funktionen überladen.

Die Grammatikgrößen für verschiedene Sprachen sind wie folgt:

JS – 475 Parser-Zeilen + 273 Lexer =748 Zeilen

Java – 615 Parserzeilen + 211 Lexer =826 Zeilen

C# – 1159 Parser-Zeilen + 433 Lexer =1592 Zeilen

С++ – 1933 Zeilen

MySQL – 2515 Parser-Zeilen + 1189 Lexer =3704 Zeilen

T-SQL – 4035 Parser-Zeilen + 896 Lexer =4931 Zeilen

PL SQL – 6719 Parser-Zeilen + 2366 Lexer =9085 Zeilen

Die Endungen einiger Lexer enthalten die Listen der in der Sprache verfügbaren Unicode-Zeichen. Diese Listen sind für die Bewertung der Sprachkomplexität nutzlos. Daher endete die Anzahl der Zeilen, die ich genommen habe, immer vor diesen Listen.

Die Bewertung der Komplexität des Sprachparsings basierend auf der Anzahl der Zeilen in der Sprachgrammatik ist umstritten. Dennoch glaube ich, dass es wichtig ist, die Zahlen zu zeigen, die eine große Diskrepanz aufweisen.

Das ist nicht alles. Da wir eine IDE entwickeln, sollten wir uns mit unvollständigen oder ungültigen Skripten befassen. Wir mussten uns viele Tricks einfallen lassen, aber Kunden schicken immer noch viele funktionierende Szenarien mit unfertigen Skripten. Wir müssen das lösen.

Prädikatskriege

Während des Code-Parsings sagt Ihnen das Wort manchmal nicht, welche der beiden Alternativen Sie wählen sollen. Der Mechanismus, der diese Art von Ungenauigkeiten behebt, ist Lookahead im ANTLR. Die Parser-Methode ist die eingefügte Kette von ifs , und jeder von ihnen blickt einen Schritt voraus. Siehe das Beispiel der Grammatik, die die Unsicherheit dieser Art erzeugt:

rule1:
  'a' rule2 | rule3
;

rule2:
  'b' 'c' 'd'
;

rule3:
  'b' 'c' 'e'
;

In der Mitte von Regel1, wenn das Token „a“ bereits übergeben wurde, schaut der Parser zwei Schritte nach vorne, um die zu befolgende Regel auszuwählen. Diese Überprüfung wird noch einmal durchgeführt, aber diese Grammatik kann neu geschrieben werden, um den Lookahead auszuschließen . Der Nachteil ist, dass solche Optimierungen der Struktur schaden, während der Leistungsschub eher gering ist.

Es gibt komplexere Möglichkeiten, diese Art von Unsicherheit zu lösen. Zum Beispiel das Syntax-Prädikat (SynPred) Mechanismus in ANTLR3 . Es hilft, wenn das optionale Ende einer Klausel den Anfang der nächsten optionalen Klausel kreuzt.

In Bezug auf ANTLR3 ist ein Prädikat eine generierte Methode, die eine virtuelle Texteingabe gemäß einer der Alternativen durchführt . Bei Erfolg wird true zurückgegeben -Wert, und die Prädikatvervollständigung ist erfolgreich. Wenn es sich um einen virtuellen Eintrag handelt, spricht man von einem Backtracking Modus Eintrag. Wenn ein Prädikat erfolgreich funktioniert, erfolgt der eigentliche Eintrag.

Es ist nur ein Problem, wenn ein Prädikat innerhalb eines anderen Prädikats beginnt. Dann kann eine Strecke hundert- oder tausendmal überwunden werden.

Sehen wir uns ein vereinfachtes Beispiel an. Es gibt drei Unsicherheitspunkte:(A, B, C).

  1. Der Parser gibt A ein, merkt sich seine Position im Text und startet einen virtuellen Level-1-Eintrag.
  2. Der Parser gibt B ein, merkt sich seine Position im Text und startet einen virtuellen Level-2-Eintrag.
  3. Der Parser gibt C ein, merkt sich seine Position im Text und startet einen virtuellen Level-3-Eintrag.
  4. Der Parser vervollständigt einen virtuellen Level-3-Eintrag, kehrt zu Level-2 zurück und übergibt C erneut.
  5. Der Parser vervollständigt einen virtuellen Eintrag der Ebene 2, kehrt zu Ebene 1 zurück und übergibt erneut B und C.
  6. Der Parser vervollständigt einen virtuellen Eintrag, kehrt zurück und führt einen echten Eintrag durch A, B und C durch.

Infolgedessen werden alle Überprüfungen in C 4 Mal, in B – 3 Mal, in A – 2 Mal durchgeführt.

Was aber, wenn eine passende Alternative an zweiter oder dritter Stelle der Liste steht? Dann schlägt eine der Prädikatstufen fehl. Seine Position im Text wird zurückgesetzt und ein anderes Prädikat beginnt zu laufen.

Bei der Analyse der Gründe für das Einfrieren der App stoßen wir häufig auf die Spur von SynPred mehrere tausend Mal hingerichtet. SynPred s sind besonders problematisch bei rekursiven Regeln. Leider ist SQL von Natur aus rekursiv. Die Möglichkeit, Unterabfragen fast überall zu verwenden, hat ihren Preis. Es ist jedoch möglich, die Regel so zu manipulieren, dass ein Prädikat verschwindet.

SynPred schadet der Leistung. Irgendwann wurde ihre Zahl streng kontrolliert. Das Problem ist jedoch, dass SynPred beim Schreiben von Grammatikcode für Sie nicht offensichtlich erscheinen kann. Darüber hinaus kann das Ändern einer Regel dazu führen, dass SynPred in einer anderen Regel erscheint, und das macht die Kontrolle über sie praktisch unmöglich.

Wir haben einen einfachen regulären Ausdruck erstellt Tool zum Steuern der Anzahl der Prädikate, die von der speziellen MSBuild-Aufgabe ausgeführt werden . Wenn die Anzahl der Prädikate nicht mit der in einer Datei angegebenen Anzahl übereinstimmte, ließ die Aufgabe den Build sofort fehlschlagen und warnte vor einem Fehler.

Wenn ein Entwickler den Fehler sieht, sollte er den Code der Regel mehrmals neu schreiben, um die redundanten Prädikate zu entfernen. Wenn man Prädikate nicht vermeiden kann, würde der Entwickler es zu einer speziellen Datei hinzufügen, die zusätzliche Aufmerksamkeit für die Überprüfung auf sich zieht.

In seltenen Fällen haben wir unsere Prädikate sogar mit C# geschrieben, nur um die von ANTLR generierten zu vermeiden. Glücklicherweise gibt es diese Methode auch.

Grammatikvererbung

Wenn es Änderungen an unseren unterstützten DBMS gibt, müssen wir diese in unseren Tools berücksichtigen. Die Unterstützung für grammatikalische Syntaxkonstruktionen ist immer ein Ausgangspunkt.

Wir erstellen für jeden SQL-Dialekt eine spezielle Grammatik. Es ermöglicht einige Codewiederholungen, aber es ist einfacher, als zu versuchen, herauszufinden, was sie gemeinsam haben.

Wir haben unseren eigenen ANTLR-Grammatikpräprozessor geschrieben, der die Grammatikvererbung durchführt.

Es wurde auch offensichtlich, dass wir einen Mechanismus für Polymorphismus brauchten – die Fähigkeit, nicht nur die Regel im Nachkommen neu zu definieren, sondern auch die Grundregel aufzurufen. Wir möchten auch die Position beim Aufrufen der Basisregel kontrollieren.

Tools sind ein definitives Plus, wenn wir ANTLR mit anderen Spracherkennungstools, Visual Studio und ANTLRWorks vergleichen. Und diesen Vorteil möchte man bei der Umsetzung der Vererbung nicht verlieren. Die Lösung bestand darin, grundlegende Grammatik in einer geerbten Grammatik in einem ANTLR-Kommentarformat anzugeben. Für ANTLR-Tools ist es nur ein Kommentar, aber wir können alle erforderlichen Informationen daraus extrahieren.

Wir haben eine MsBuild-Aufgabe geschrieben, die als Pre-Build-Aktion in das gesamte Build-System eingebettet wurde. Die Aufgabe bestand darin, die Arbeit eines Präprozessors für die ANTLR-Grammatik zu übernehmen, indem die resultierende Grammatik aus ihrer Basis und geerbten Peers generiert wurde. Die resultierende Grammatik wurde von ANTLR selbst verarbeitet.

ANTLR-Nachbearbeitung

In vielen Programmiersprachen können Schlüsselwörter nicht als Subjektnamen verwendet werden. Je nach Dialekt kann es zwischen 800 und 3000 Schlüsselwörter in SQL geben. Die meisten von ihnen sind an den Kontext innerhalb von Datenbanken gebunden. Daher würde es Benutzer frustrieren, sie als Objektnamen zu verbieten. Aus diesem Grund hat SQL reservierte und nicht reservierte Schlüsselwörter.

Sie können Ihr Objekt nicht als reserviertes Wort (SELECT, FROM usw.) benennen, ohne es zu zitieren, aber Sie können dies für ein nicht reserviertes Wort (CONVERSATION, AVAILABILITY usw.) tun. Diese Interaktion erschwert die Parser-Entwicklung.

Während der lexikalischen Analyse ist der Kontext unbekannt, aber ein Parser benötigt bereits unterschiedliche Zahlen für den Bezeichner und das Schlüsselwort. Aus diesem Grund haben wir dem ANTLR-Parser eine weitere Nachbearbeitung hinzugefügt. Es hat alle offensichtlichen Identifier-Prüfungen durch den Aufruf einer speziellen Methode ersetzt.

Diese Methode hat eine detailliertere Prüfung. Wenn der Eintrag einen Bezeichner aufruft und wir erwarten, dass der Bezeichner weiter erfüllt wird, ist alles in Ordnung. Aber wenn ein nicht reserviertes Wort ein Eintrag ist, sollten wir es noch einmal überprüfen. Diese zusätzliche Prüfung überprüft die Verzweigungssuche im aktuellen Kontext, in dem dieses nicht reservierte Schlüsselwort ein Schlüsselwort sein kann. Wenn es keine solchen Zweige gibt, kann es als Identifikator verwendet werden.

Technisch könnte dieses Problem mit ANTLR gelöst werden, aber diese Entscheidung ist nicht optimal. Der ANTLR-Weg besteht darin, eine Regel zu erstellen, die alle nicht reservierten Schlüsselwörter und eine Lexem-ID auflistet. Anstelle eines Lexem-Identifikators dient weiterhin eine spezielle Regel. Diese Lösung sorgt dafür, dass ein Entwickler nicht vergisst, das Schlüsselwort dort hinzuzufügen, wo es verwendet wird, und in die Sonderregel. Außerdem optimiert es die aufgewendete Zeit.

Fehler bei der Syntaxanalyse ohne Bäume

Der Syntaxbaum ist normalerweise ein Ergebnis der Parserarbeit. Es ist eine Datenstruktur, die den Programmtext durch formale Grammatik widerspiegelt. Wenn Sie einen Code-Editor mit automatischer Sprachvervollständigung implementieren möchten, erhalten Sie höchstwahrscheinlich den folgenden Algorithmus:

  1. Parsen Sie den Text im Editor. Dann erhalten Sie einen Syntaxbaum.
  2. Finde einen Knoten unter dem Wagen und vergleiche ihn mit der Grammatik.
  3. Finden Sie heraus, welche Keywords und Objekttypen am Point verfügbar sein werden.

In diesem Fall kann man sich die Grammatik leicht als Graph oder Zustandsmaschine vorstellen.

Leider war nur die dritte Version von ANTLR verfügbar, als die dbForge-IDE mit der Entwicklung begonnen hatte. Es war jedoch nicht so wendig und obwohl man ANTLR sagen konnte, wie man einen Baum baut, war die Nutzung nicht reibungslos.

Darüber hinaus wurde in vielen Artikeln zu diesem Thema vorgeschlagen, den „Aktions“-Mechanismus zum Ausführen von Code zu verwenden, wenn der Parser die Regel durchläuft. Dieser Mechanismus ist sehr praktisch, hat aber zu architektonischen Problemen geführt und die Unterstützung neuer Funktionen komplexer gemacht.

Die Sache ist die, dass eine einzelne Grammatikdatei aufgrund der großen Anzahl von Funktionalitäten, die besser auf verschiedene Builds hätten verteilt werden sollen, damit begann, „Aktionen“ zu sammeln. Wir haben es geschafft, Aktions-Handler auf verschiedene Builds zu verteilen und eine hinterhältige Abonnenten-Notifier-Mustervariation für diese Maßnahme zu erstellen.

ANTLR3 arbeitet nach unseren Messungen 6-mal schneller als ANTLR4. Außerdem konnte der Syntaxbaum für große Skripts zu viel RAM beanspruchen, was keine gute Nachricht war, sodass wir innerhalb des 32-Bit-Adressraums von Visual Studio und SQL Management Studio arbeiten mussten.

ANTLR-Parser-Nachbearbeitung

Bei der Arbeit mit Zeichenketten ist einer der kritischsten Momente die Phase der lexikalischen Analyse, in der wir das Skript in einzelne Wörter aufteilen.

ANTLR nimmt als Eingabe Grammatik, die die Sprache angibt, und gibt einen Parser in einer der verfügbaren Sprachen aus. Irgendwann wuchs der generierte Parser so stark an, dass wir Angst hatten, ihn zu debuggen. Sollten Sie beim Debuggen F11 (Step-In) drücken und zur Parser-Datei wechseln, würde Visual Studio einfach abstürzen.

Es stellte sich heraus, dass es aufgrund einer OutOfMemory-Ausnahme beim Analysieren der Parser-Datei fehlgeschlagen ist. Diese Datei enthielt mehr als 200.000 Codezeilen.

Aber das Debuggen des Parsers ist ein wesentlicher Teil des Arbeitsprozesses, und Sie können es nicht auslassen. Mit Hilfe von C#-Partialklassen haben wir den generierten Parser mit regulären Ausdrücken analysiert und in wenige Dateien aufgeteilt. Visual Studio funktionierte perfekt damit.

Lexikalische Analyse ohne Substring vor Span-API

Die Hauptaufgabe der lexikalischen Analyse ist die Klassifizierung – das Definieren der Grenzen der Wörter und deren Vergleich mit einem Wörterbuch. Wenn das Wort gefunden wird, würde der Lexer seinen Index zurückgeben. Wenn nicht, wird das Wort als Objektidentifizierer angesehen. Dies ist eine vereinfachte Beschreibung des Algorithmus.

Hintergrund-Lexing während des Dateiöffnens

Die Syntaxhervorhebung basiert auf lexikalischer Analyse. Dieser Vorgang nimmt normalerweise viel mehr Zeit in Anspruch als das Lesen von Text von der Festplatte. Was ist der Haken? In einem Thread wird der Text aus der Datei gelesen, während die lexikalische Analyse in einem anderen Thread durchgeführt wird.

Der Lexer liest den Text Zeile für Zeile. Wenn es eine Zeile anfordert, die nicht existiert, hält es an und wartet.

BlockingCollection von BCL funktioniert auf einer ähnlichen Basis, und der Algorithmus umfasst eine typische Anwendung eines gleichzeitigen Producer-Consumer-Musters. Der im Haupt-Thread arbeitende Editor fordert Daten über die erste hervorgehobene Zeile an, und wenn sie nicht verfügbar ist, hält er an und wartet. In unserem Editor haben wir das Producer-Consumer-Pattern und die Blocking-Collection zweimal verwendet:

  1. Das Lesen aus einer Datei ist ein Producer, während Lexer ein Consumer ist.
  2. Lexer ist bereits ein Producer und der Texteditor ist ein Consumer.

Mit diesen Tricks können wir die Zeit, die zum Öffnen großer Dateien aufgewendet wird, erheblich verkürzen. Die erste Seite des Dokuments wird sehr schnell angezeigt, das Dokument kann jedoch einfrieren, wenn Benutzer versuchen, innerhalb der ersten Sekunden zum Ende der Datei zu gelangen. Dies geschieht, weil der Hintergrundleser und Lexer das Ende des Dokuments erreichen müssen. Wenn sich der Benutzer jedoch langsam vom Anfang des Dokuments zum Ende hin bewegt, gibt es keine merklichen Einfrierungen.

Mehrdeutige Optimierung:teilweise lexikalische Analyse

Die syntaktische Analyse wird üblicherweise in zwei Ebenen unterteilt:

  • Der eingegebene Zeichenstrom wird verarbeitet, um Lexeme (Tokens) basierend auf den Sprachregeln zu erhalten – dies wird als lexikalische Analyse bezeichnet
  • der Parser verbraucht den Token-Stream, prüft ihn gemäß den formalen Grammatikregeln und erstellt oft einen Syntaxbaum.

Die Verarbeitung von Zeichenfolgen ist eine kostspielige Operation. Zur Optimierung haben wir uns entschieden, nicht jedes Mal eine vollständige lexikalische Analyse des Textes durchzuführen, sondern nur den geänderten Teil erneut zu analysieren. Aber wie geht man mit mehrzeiligen Konstrukten wie Blockkommentaren oder Zeilen um? Wir haben für jede Zeile einen Zeilenendzustand gespeichert:„keine mehrzeiligen Token“ =0, „der Anfang eines Blockkommentars“ =1, „der Anfang eines mehrzeiligen Zeichenfolgenliterals“ =2. Die lexikalische Analyse beginnt mit dem geänderten Abschnitt und endet, wenn der Zeilenende-Zustand gleich dem gespeicherten ist.

Bei dieser Lösung gab es ein Problem:Es ist äußerst unpraktisch, Zeilennummern in solchen Strukturen zu überwachen, während die Zeilennummer ein erforderliches Attribut eines ANTLR-Tokens ist, da beim Einfügen oder Löschen einer Zeile die Nummer der nächsten Zeile entsprechend aktualisiert werden sollte. Wir haben es gelöst, indem wir sofort eine Zeilennummer gesetzt haben, bevor wir das Token an den Parser übergeben haben. Die später von uns durchgeführten Tests haben gezeigt, dass sich die Leistung um 15-25 % verbessert hat. Die tatsächliche Verbesserung war sogar noch größer.

Die für all dies erforderliche RAM-Menge erwies sich als viel größer als wir erwartet hatten. Ein ANTLR-Token bestand aus:einem Anfangspunkt – 8 Bytes, einem Endpunkt – 8 Bytes, einem Link zum Text des Wortes – 4 oder 8 Bytes (ohne die Zeichenfolge selbst zu erwähnen), einem Link zum Text des Dokuments – 4 oder 8 Bytes, und ein Token-Typ – 4 Bytes.

Was können wir also schlussfolgern? Wir haben uns auf die Leistung konzentriert und einen übermäßigen RAM-Verbrauch an einer Stelle festgestellt, die wir nicht erwartet hatten. Wir haben nicht angenommen, dass dies passieren würde, weil wir versucht haben, leichte Strukturen anstelle von Klassen zu verwenden. Indem wir sie durch schwere Objekte ersetzten, haben wir uns bewusst für zusätzliche Speicherkosten entschieden, um eine bessere Leistung zu erzielen. Glücklicherweise hat uns dies eine wichtige Lektion erteilt, sodass jetzt jede Leistungsoptimierung mit der Profilerstellung des Speicherverbrauchs endet und umgekehrt.

Das ist eine Geschichte mit Moral. Einige Funktionen begannen fast sofort zu arbeiten und andere nur ein bisschen schneller. Schließlich wäre es unmöglich, den Trick der lexikalischen Hintergrundanalyse durchzuführen, wenn es kein Objekt gäbe, in dem einer der Threads Token speichern könnte.

Alle weiteren Probleme ergeben sich im Kontext der Desktop-Entwicklung auf dem .NET-Stack.

Das 32-Bit-Problem

Einige Benutzer entscheiden sich für eigenständige Versionen unserer Produkte. Andere bleiben bei der Arbeit in Visual Studio und SQL Server Management Studio. Viele Erweiterungen werden für sie entwickelt. Eine dieser Erweiterungen ist SQL Complete. Zur Verdeutlichung:Es bietet mehr Befugnisse und Funktionen als die standardmäßige Codevervollständigung SSMS und VS für SQL.

Die SQL-Analyse ist ein sehr kostspieliger Prozess, sowohl in Bezug auf CPU- als auch auf RAM-Ressourcen. Um die Liste der Objekte in Benutzerskripten ohne unnötige Aufrufe des Servers abzurufen, speichern wir den Objektcache im RAM. Oft nimmt es nicht viel Platz ein, aber einige unserer Benutzer haben Datenbanken, die bis zu einer Viertelmillion Objekte enthalten.

Die Arbeit mit SQL ist ganz anders als die Arbeit mit anderen Sprachen. In C# gibt es selbst mit tausend Zeilen Code praktisch keine Dateien. Inzwischen kann ein Entwickler in SQL mit einem Datenbank-Dump arbeiten, der aus mehreren Millionen Codezeilen besteht. Daran ist nichts Ungewöhnliches.

DLL-Hölle in VS

Es gibt ein praktisches Tool zum Entwickeln von Plugins in .NET Framework, es ist eine Anwendungsdomäne. Alles wird isoliert ausgeführt. Entladen ist möglich. Meistens ist die Implementierung von Erweiterungen vielleicht der Hauptgrund, warum Anwendungsdomänen eingeführt wurden.

Außerdem gibt es das MAF-Framework, das von MS entwickelt wurde, um das Problem der Erstellung von Add-Ons für das Programm zu lösen. Es isoliert diese Add-Ons so weit, dass es sie an einen separaten Prozess senden und die gesamte Kommunikation übernehmen kann. Ehrlich gesagt ist diese Lösung zu umständlich und hat nicht viel Popularität erlangt.

Leider implementieren Microsoft Visual Studio und SQL Server Management Studio, die darauf aufbauen, das Erweiterungssystem anders. Es vereinfacht den Zugriff auf Hosting-Anwendungen für Plugins, aber es zwingt sie dazu, sich innerhalb eines Prozesses und einer Domäne mit einer anderen zusammenzufügen.

Wie jede andere Anwendung im 21. Jahrhundert hat auch unsere viele Abhängigkeiten. Die meisten von ihnen sind bekannte, bewährte und beliebte Bibliotheken in der .NET-Welt.

Nachrichten in ein Schloss ziehen

Es ist nicht allgemein bekannt, dass .NET Framework die Windows Message Queue in jedes WaitHandle pumpt. Um es in jede Sperre zu stecken, kann jeder Handler eines beliebigen Ereignisses in einer Anwendung aufgerufen werden, wenn diese Sperre Zeit hat, in den Kernelmodus zu wechseln, und sie nicht während der Spin-Wait-Phase freigegeben wird.

Dies kann zu einem Wiedereintritt an einigen sehr unerwarteten Orten führen. Einige Male führte es zu Problemen wie „Collection was modifyed during enumeration“ und diversen ArgumentOutOfRangeException.

Hinzufügen einer Assembly zu einer Lösung mit SQL

Wenn das Projekt wächst, entwickelt sich die anfangs einfache Aufgabe des Hinzufügens von Baugruppen zu einem Dutzend komplizierter Schritte. Einmal mussten wir der Lösung ein Dutzend verschiedener Assemblys hinzufügen, wir führten ein großes Refactoring durch. Fast 80 Lösungen, einschließlich Produkt- und Testlösungen, wurden auf der Grundlage von rund 300 .NET-Projekten erstellt.

Basierend auf Produktlösungen haben wir Inno Setup-Dateien geschrieben. Sie enthielten Listen von Assemblys, die in der Installation gepackt waren, die der Benutzer heruntergeladen hatte. Der Algorithmus zum Hinzufügen eines Projekts lautete wie folgt:

  1. Neues Projekt erstellen.
  2. Fügen Sie ihm ein Zertifikat hinzu. Richten Sie das Tag des Builds ein.
  3. Versionsdatei hinzufügen.
  4. Konfigurieren Sie die Pfade neu, wohin das Projekt führt.
  5. Benennen Sie den Ordner um, damit er der internen Spezifikation entspricht.
  6. Fügen Sie das Projekt erneut zur Lösung hinzu.
  7. Fügen Sie ein paar Assemblys hinzu, zu denen alle Projekte Links benötigen.
  8. Fügen Sie den Build zu allen erforderlichen Lösungen hinzu:Test und Produkt.
  9. Fügen Sie für alle Produktlösungen die Assemblys zur Installation hinzu.

Diese 9 Schritte mussten etwa 10 Mal wiederholt werden. Die Schritte 8 und 9 sind nicht so trivial, und man vergisst leicht, überall Builds hinzuzufügen.

Angesichts einer so großen und routinemäßigen Aufgabe würde jeder normale Programmierer sie automatisieren wollen. Genau das wollten wir tun. Aber wie geben wir an, welche Lösungen und Installationen genau dem neu erstellten Projekt hinzugefügt werden sollen? Es gibt so viele Szenarien und darüber hinaus ist es schwierig, einige davon vorherzusagen.

Wir kamen auf eine verrückte Idee. Lösungen sind mit Projekten wie Many-to-Many verbunden, Projekte mit Installationen auf die gleiche Weise, und SQL kann genau die Art von Aufgaben lösen, die wir hatten.

Wir haben eine .Net Core-Konsolen-App erstellt, die alle .sln-Dateien im Quellordner scannt, die Liste der Projekte mit Hilfe der DotNet-Befehlszeilenschnittstelle aus ihnen abruft und sie in die SQLite-Datenbank einfügt. Das Programm hat einige Modi:

  • Neu – erstellt ein Projekt und alle notwendigen Ordner, fügt ein Zertifikat hinzu, richtet ein Tag ein, fügt eine Version hinzu, minimale notwendige Assemblies.
  • Projekt hinzufügen – fügt das Projekt allen Lösungen hinzu, die die SQL-Abfrage erfüllen, die als einer der Parameter angegeben wird. Um das Projekt zur Projektmappe hinzuzufügen, verwendet das Programm darin die DotNet CLI.
  • Add-ISS – fügt das Projekt allen Installationen hinzu, die SQL-Abfragen erfüllen.

Obwohl die Idee, die Liste der Lösungen durch die SQL-Abfrage anzuzeigen, umständlich erscheinen mag, hat sie alle bestehenden Fälle vollständig geschlossen und höchstwahrscheinlich alle möglichen Fälle in der Zukunft.

Lassen Sie mich das Szenario demonstrieren. Erstellen Sie ein Projekt „A“ und fügen Sie es allen Lösungen hinzu, in denen Projekte „B“ sind wird verwendet:

dbforgeasm add-project Folder1\Folder2\A "SELECT s.Id FROM Projects p JOIN Solutions s ON p.SolutionId = s.Id WHERE p.Name = 'B'"

Ein Problem mit LiteDB

Vor ein paar Jahren bekamen wir den Auftrag, eine Hintergrundfunktion zum Speichern von Benutzerdokumenten zu entwickeln. Es hatte zwei Hauptanwendungsabläufe:die Fähigkeit, die IDE sofort zu schließen und zu verlassen, und bei der Rückkehr dort weiterzumachen, wo Sie aufgehört haben, und die Fähigkeit, in dringenden Situationen wie Stromausfällen oder Programmabstürzen wiederherzustellen.

Um diese Aufgabe umzusetzen, war es notwendig, den Inhalt der Dateien irgendwo auf der Seite zu speichern, und zwar oft und schnell. Abgesehen von den Inhalten mussten einige Metadaten gespeichert werden, was eine direkte Speicherung im Dateisystem umständlich machte.

An diesem Punkt stießen wir auf die LiteDB-Bibliothek, die uns durch ihre Einfachheit und Leistung beeindruckte. LiteDB ist eine schnelle, leichtgewichtige eingebettete Datenbank, die vollständig in C# geschrieben wurde. Die Geschwindigkeit und die allgemeine Einfachheit haben uns überzeugt.

Im Laufe des Entwicklungsprozesses war das gesamte Team zufrieden mit der Arbeit mit LiteDB. Die Hauptprobleme begannen jedoch nach der Veröffentlichung.

Die offizielle Dokumentation garantierte, dass die Datenbank ein ordnungsgemäßes Arbeiten mit gleichzeitigem Zugriff von mehreren Threads sowie mehreren Prozessen gewährleistete. Aggressive synthetische Tests haben gezeigt, dass die Datenbank in einer Multithread-Umgebung nicht richtig funktioniert.

Um das Problem schnell zu beheben, haben wir die Prozesse mit Hilfe des selbst geschriebenen Interprozess ReadWriteLock synchronisiert. Jetzt, nach fast drei Jahren, funktioniert LiteDB viel besser.

StreamStringList

Dieses Problem ist das Gegenteil des Falls bei der partiellen lexikalischen Analyse. Wenn wir mit einem Text arbeiten, ist es bequemer, mit ihm als Stringliste zu arbeiten. Zeichenfolgen können in zufälliger Reihenfolge angefordert werden, aber eine gewisse Speicherzugriffsdichte ist immer noch vorhanden. Irgendwann war es notwendig, mehrere Tasks auszuführen, um sehr große Dateien ohne volle Speicherlast zu verarbeiten. Die Idee war wie folgt:

  1. Um die Datei Zeile für Zeile zu lesen. Denken Sie an Offsets in der Datei.
  2. Auf Anfrage die nächste Zeile ausgeben, einen erforderlichen Offset setzen und die Daten zurückgeben.

Die Hauptaufgabe ist erledigt. Diese Struktur nimmt im Vergleich zur Dateigröße nicht viel Platz ein. In der Testphase überprüfen wir gründlich den Speicherbedarf für große und sehr große Dateien. Große Dateien wurden lange verarbeitet und kleine werden sofort verarbeitet.

Es gab keinen Hinweis zur Überprüfung der Ausführungszeit . RAM wird als Random Access Memory bezeichnet – es ist sein Wettbewerbsvorteil gegenüber SSD und insbesondere gegenüber HDD. Diese Treiber funktionieren schlecht für den wahlfreien Zugriff. Es stellte sich heraus, dass dieser Ansatz die Arbeit um fast das 40-fache verlangsamte, verglichen mit dem vollständigen Laden einer Datei in den Speicher. Außerdem lesen wir die Datei je nach Kontext 2,5 - 10 Mal.

Die Lösung war einfach, und die Verbesserung reichte aus, sodass der Vorgang nur wenig länger dauerte, als wenn die Datei vollständig in den Speicher geladen wurde.

Ebenso war der RAM-Verbrauch unbedeutend. Wir fanden Inspiration im Prinzip des Ladens von Daten aus dem RAM in einen Cache-Prozessor. Wenn Sie auf ein Array-Element zugreifen, kopiert der Prozessor Dutzende benachbarter Elemente in seinen Cache, da die erforderlichen Elemente häufig in der Nähe sind.

Viele Datenstrukturen nutzen diese Prozessoroptimierung, um Spitzenleistung zu erzielen. Aufgrund dieser Besonderheit ist der wahlfreie Zugriff auf Array-Elemente viel langsamer als der sequentielle Zugriff. Wir haben einen ähnlichen Mechanismus implementiert:Wir haben einen Satz von tausend Strings gelesen und uns an ihre Offsets erinnert. Wenn wir auf die 1001. Zeichenfolge zugreifen, löschen wir die ersten 500 Zeichenfolgen und laden die nächsten 500. Falls wir eine der ersten 500 Zeilen benötigen, gehen wir separat darauf ein, da wir bereits den Offset haben.

Der Programmierer muss nicht unbedingt nicht-funktionale Anforderungen sorgfältig formulieren und prüfen. Als Ergebnis haben wir uns für zukünftige Fälle daran erinnert, dass wir sequentiell mit persistentem Speicher arbeiten müssen.

Analyse der Ausnahmen

Sie können Benutzeraktivitätsdaten einfach im Web sammeln. Bei der Analyse von Desktop-Anwendungen ist dies jedoch nicht der Fall. Es gibt kein solches Tool, das in der Lage ist, eine unglaubliche Reihe von Metriken und Visualisierungstools wie Google Analytics bereitzustellen. Wieso den? Hier sind meine Annahmen:

  1. Während des größten Teils der Geschichte der Entwicklung von Desktop-Anwendungen hatten sie keinen stabilen und dauerhaften Zugriff auf das Web.
  2. Es gibt viele Entwicklungstools für Desktop-Anwendungen. Daher ist es unmöglich, ein Mehrzweck-Tool zur Erfassung von Benutzerdaten für alle UI-Frameworks und -Technologien zu erstellen.

Ein wichtiger Aspekt beim Sammeln von Daten ist das Nachverfolgen von Ausnahmen. Zum Beispiel sammeln wir Daten über Abstürze. Zuvor mussten unsere Benutzer selbst an die E-Mail des Kundensupports schreiben und einen Stack Trace eines Fehlers hinzufügen, der aus einem speziellen App-Fenster kopiert wurde. Nur wenige Benutzer haben alle diese Schritte befolgt. Die gesammelten Daten werden vollständig anonymisiert, was uns die Möglichkeit nimmt, Reproduktionsschritte oder andere Informationen des Benutzers zu erfahren.

Andererseits befinden sich Fehlerdaten in der Postgres-Datenbank, was den Weg für eine sofortige Überprüfung von Dutzenden von Hypothesen ebnet. Sie können die Antworten sofort erhalten, indem Sie einfach SQL-Abfragen an die Datenbank stellen. Oft ist es bei nur einem Stack oder Ausnahmetyp unklar, wie die Ausnahme aufgetreten ist, deshalb sind all diese Informationen entscheidend, um das Problem zu untersuchen.

Darüber hinaus haben Sie die Möglichkeit, alle gesammelten Daten zu analysieren und die problematischsten Module und Klassen zu finden. Basierend auf den Ergebnissen der Analyse können Sie Refactoring oder zusätzliche Tests planen, um diese Teile des Programms abzudecken.

Stack-Decodierungsdienst

.NET-Builds enthalten IL-Code, der mit mehreren Spezialprogrammen einfach und bedienergenau in C#-Code zurückgewandelt werden kann. Eine der Möglichkeiten, den Programmcode zu schützen, ist seine Verschleierung. Programme können umbenannt werden; Methoden, Variablen und Klassen können ersetzt werden; Code kann durch sein Äquivalent ersetzt werden, aber es ist wirklich unverständlich.

Die Notwendigkeit, den Quellcode zu verschleiern, zeigt sich, wenn Sie Ihr Produkt so verteilen, dass der Benutzer die Builds Ihrer Anwendung erhält. Desktop-Anwendungen sind solche Fälle. Alle Builds, einschließlich Zwischenbuilds für Tester, werden sorgfältig verschleiert.

Unsere Quality Assurance Unit verwendet Dekodierungs-Stack-Tools des Obfuscator-Entwicklers. Um mit der Dekodierung zu beginnen, müssen sie die Anwendung ausführen, von CI für einen bestimmten Build veröffentlichte Entschleierungskarten finden und den Ausnahme-Stack in das Eingabefeld einfügen.

Verschiedene Versionen und Editoren wurden auf unterschiedliche Weise verschleiert, was es einem Entwickler erschwerte, das Problem zu studieren, oder ihn sogar auf die falsche Fährte bringen konnte. Es war offensichtlich, dass dieser Prozess automatisiert werden musste.

Das Entschleierungskartenformat erwies sich als ziemlich einfach. Wir haben es leicht entparst und ein Stack-Decodierungsprogramm geschrieben. Kurz zuvor wurde ein Web-UI entwickelt, um Ausnahmen nach Produktversionen darzustellen und nach Stack zu gruppieren. Es war eine .NET Core-Website mit einer Datenbank in SQLite.

SQLite ist ein nettes Werkzeug für kleine Lösungen. Wir haben versucht, dort auch Entschleierungskarten zu platzieren. Jeder Build generierte ungefähr 500.000 Verschlüsselungs- und Entschlüsselungspaare. SQLite konnte eine so aggressive Einfügungsrate nicht verarbeiten.

Während Daten zu einem Build in die Datenbank eingefügt wurden, wurden zwei weitere zur Warteschlange hinzugefügt. Kurz vor diesem Problem hörte ich mir einen Bericht über Clickhouse an und wollte es unbedingt ausprobieren. Es hat sich als ausgezeichnet erwiesen, die Insertrate wurde um mehr als das 200-fache beschleunigt.

Allerdings verlangsamte sich die Stack-Decodierung (Lesen aus der Datenbank) um fast das 50-fache, aber da jeder Stack weniger als 1 ms dauerte, war es kostenintensiv, Zeit mit der Untersuchung dieses Problems zu verbringen.

ML.NET zur Klassifizierung von Ausnahmen

On the subject of the automatic processing of exceptions, we made a few more enhancements.

We already had the Web-UI for a convenient review of exceptions grouped by stacks. We had a Grafana for high-level analysis of product stability at the level of versions and product lines. But a programmer’s eye, constantly craving optimization, caught another aspect of this process.

Historically, dbForge line development was divided among 4 teams. Each team had its own functionality to work on, though the borderline was not always obvious. Our technical support team, relying on their experience, read the stack and assigned it to this or that team. They managed it quite well, yet, in some cases, mistakes occurred. The information on errors from analytics came to Jira on its own, but the support team still needed to classify tasks by team.

In the meantime, Microsoft introduced a new library – ML.NET. And we still had this classification task. A library of that kind was exactly what we needed. It extracted stacks of all resolved exceptions from Redmine, a project management system that we used earlier, and Jira, which we use at present.

We obtained a data array that contained some 5 thousand pairs of Exception StackTrace and command. We put 100 exceptions aside and used the rest of the exceptions to teach a model. The accuracy was about 75%. Again, we had 4 teams, hence, random and round-robin would only attain 25%. It sufficiently saved up their time.

To my way of thinking, if we considerably clean up incoming data array, make a thorough examination of the ML.NET library, and theoretical foundation in machine learning, on the whole, we can improve these results. At the same time, I was impressed with the simplicity of this library:with no special knowledge in AI and ML, we managed to gain real cost-benefits in less than an hour.

Schlussfolgerung

Hopefully, some of the readers happen to be users of the products I describe in this article, and some lines shed light on the reasons why this or that function was implemented this way.

And now, let me conclude:

We should make decisions based on data and not assumptions. It is about behavior analytics and insights that we can obtain from it.

We ought to constantly invest in tools. There is nothing wrong if we need to develop something for it. In the next few months, it will save us a lot of time and rid us of routine. Routine on top of time expenditure can be very demotivating.

When we develop some internal tools, we get a super chance to try out new technologies, which can be applied in production solutions later on.

There are infinitely many tools for data analysis. Still, we managed to extract some crucial information using SQL tools. This is the most powerful tool to formulate a question to data and receive an answer in a structured form.