periscope
symproject icon 4

ANTLR Parsergenerator

Der Umgang mit unstrukturierten Daten stellt viele Entwickler vor Probleme. Vor allem, wenn diese Daten aus einem System eines Drittanbieters stammen und für das Projekt benötigt werden, das man gerade erstellt. 

Normalerweise kann man einen benutzerdefinierten Parser erstellen, der die gleiche Sprache wie das Projekt verwendet, und dann kann man die unstrukturierten Daten lesen. Aber wenn es um benutzerdefinierte Parser und komplexe unstrukturierte Daten geht, wird der benutzerdefinierte Parser bald selbst komplex, anfällig für Fehler und kann in einigen Fällen zu Leistungsproblemen führen.

Ich habe in der Vergangenheit schon benutzerdefinierte Parser entwickelt, und bei meiner derzeitigen Tätigkeit stieß ich bei der Arbeit an einem Projekt in Java auf unstrukturierte Daten. Als ich mir die Daten zum ersten Mal ansah, sahen sie JSON sehr ähnlich, aber sie konnten weder als JSON verwendet werden noch war es einfach, sie in JSON umzuwandeln. 

Ich wusste, dass ich einen Parser verwenden musste, um die Daten in mein Projekt laden zu können. Aufgrund meiner früheren Erfahrungen wusste ich immer, dass ich am Ende des Tages einen benutzerdefinierten Parser erstellen kann, aber ich erinnere mich auch daran, dass es eine sehr unangenehme Erfahrung war, einen eigenen zu erstellen. Diesmal wollte ich etwas Neues ausprobieren, ich wollte eine bestehende Bibliothek verwenden, die dies für mich erledigen kann.

Ein Kollege hatte zuvor von ANTLR gehört und hat mich darauf aufmerksam gemacht. Er hatte ANTLR noch nie benutzt, wusste aber, dass es sich um eine Parser-Generator-Bibliothek handelt. Und so begann ich zu lernen, wie man ANTLR verwendet.

Was mich an ANTLR wirklich beeindruckt hat, war seine Einfachheit. Dank des Artikels "The ANTLR Mega Tutorial" von Gabrielle Tomasseti war es für mich extrem einfach, mit dem Schreiben meiner ersten Grammatikklasse anzufangen. Eine ANTLR-Grammatikklasse, die mit der Endung '.g4' endet, besteht aus Lexer- und Parserregeln, die wir später mit ANTLR verwenden können, um Parsing-Klassen in meiner Zielsprache (z.B. Java) zu generieren. Wir können diese Klassen dann verwenden und einige von ihnen erweitern, und schon können wir unsere unstrukturierten Daten parsen. Ich konnte ANTLR in weniger als einem Arbeitstag (8 Stunden) erlernen und war am Ende meines ersten Lern- und Anwendungstages in der Lage, ein Beispiel aus meinen unstrukturierten Daten zu analysieren.

ANTLR hat mir sehr gut gefallen, es war einfach zu erlernen, einfach anzuwenden und vor allem sehr leistungsfähig. Ich fütterte ANTLR mit einer Datei von 10 MB und erhielt innerhalb von 2 Sekunden eine Parsing-Antwort. Ich konnte alles, was ich für meine Aufgabe brauchte, innerhalb des ersten Tages erreichen. Die verbleibende Zeit für den Aufbau der restlichen ANTLR-Grammatik nahm nur deshalb mehr Zeit in Anspruch, weil meine unstrukturierten Daten sehr vielfältig und komplex waren, und ich alle Fälle unterstützen wollte.

Hätte ich dagegen einen eigenen Parser bauen wollen, hätte ich sicherlich mehr als 8 Stunden gebraucht, um das zu erreichen, was ich mit ANTLR erreicht habe.

Schließlich ist ANTLR auch in vielen Sprachen verfügbar. Sie können dieselbe Grammatikklasse mit allen folgenden Sprachen erzeugen: Java, C#, C++, Python2|3, JavaScript, Go, Swift, Dart und PHP. Es hat mir wirklich Spaß gemacht, dieses leistungsstarke Tool zu erlernen und zu benutzen, und ich bin froh, es meinem Arsenal hinzufügen zu können. Es ist schnell, einfach zu bedienen und kann uns eine Menge Zeit sparen.

Im folgenden Abschnitt gehe ich aus technischer Sicht auf ANTLR ein: 

Wie bereits erwähnt, erstellen wir eine Grammatikklasse '.g4', die wir mit ANTLR verwenden, um Klassen in unserer Zielsprache zu erzeugen. Innerhalb der Grammatikklasse müssen wir unsere Lexer- und Parserregeln definieren. Diese legen fest, wie wir das Parsen der unstrukturierten Daten handhaben wollen. Normalerweise schreibt man die Lexer in den unteren Teil der Grammatikdatei und die Parserregeln in den oberen Teil.

Das folgende einfache Beispiel veranschaulicht diese Konzepte:

Angenommen, wir haben den folgenden Text 'Hello + World = Basic'.

Unser Lexer definiert die Daten auf unterster Ebene, während unser Parser die Regeln der oberen Ebene festlegt. Unsere Klasse würde dann wie folgt aussehen:

 

filename = Example.g4

Content: 

 

grammar Example;

/*
* Parser Rule
*/
message: TEXT PLUS TEXT EQUAL TEXT

/*
* Lexer Rules
*/
TEXT: [A-Za-z]+;
PLUS: '+';
EQUAL: '=';
WHITESPACE: ' ' -> skip ;

 

Durch die Definition unserer Lexer-Regeln erhalten wir nun unsere Nachricht, die gleich "Hallo + Welt = Basic" ist.

Intern, beim Parsen dieser Nachricht, lädt ANTLR die Zeichenkette. Der Lexer entfernt darin alle WHITESPACE Zeichen (Alle Leerzeichen werden übersprungen), so dass wir Hello+World=Basic erhalten.

Dann erwartet unsere Nachrichtenregel einen Text, gefolgt von einem Pluszeichen, dann einen weiteren Text, gefolgt von einem Gleichheitszeichen und dann einen abschließenden Text. Unsere Eingabe entspricht dieser Regel, also wird sie geladen.

Wenn wir die folgende Grammatik ausführen, erhalten wir die folgende Ausgabe:

 

Wir können unsere Erkennung verbessern, indem wir weitere Parser-Regeln wie folgt hinzufügen:

firstArgument PLUS secondArgument EQUAL result;
firstArgument: TEXT;
secondArgument: TEXT;
result: TEXT;

Wenn wir dasselbe Beispiel ausführen, erhalten wir die folgende Ausgabe:

Wir sehen jetzt, dass wir mehr Regeln in unserem Baum haben. Dies ist nützlich, wenn wir mit größeren und komplexeren Daten arbeiten.

Eine weitere wichtige Funktion in ANTLR ist, dass wir die generierte 'BaseListener'-Klasse für unsere spezifische Grammatik erweitern können, so dass wir nun Zugriff auf jede definierte Grammatik in unserem Baum haben. Bitte beachten Sie, dass der BaseListener immer nach dem Namen unserer g4-Datei benannt wird, z.B. ExampleBaseListener.

Um zum letzten Beispiel zurückzukehren, haben wir jetzt die folgende Parser-Regel:

“message”, “firstArgument”, “secondArgument”, “result”

Wir können den Basis-Listener und Methoden überschreiben:

Bei Ansicht dieser Methodendeklaration wird klar, dass wir nur Parser-Regeln, nicht aber Lexer-Regeln überschreiben können. Aus dem ctx-Parameter erhalten wir den Text, den ANTLR zuordnen konnte. Diese Daten können wir dann in unserem Projekt verwenden.

Das obige Beispiel kratzt nur an der Oberfläche dessen, was ANTLR leisten kann. Es ist sehr flexibel und kann zum Parsen komplexer Strukturen genutzt werden. I

Im folgenden Beispiel zeige ich die unstrukturierten Daten, die wir hatten, und wie wir ANTLR eingesetzt haben, um sie zu bewältigen.

DATE:TEXT:TYPE:
   OBJECT_LEVEL_1{
      OBJECT_LEVEL_2_1{
         SUB_OBJECT_LEVEL_2_1_1 = VALUE_1
         SUB_OBJECT_LEVEL_2_1_2 = VALUE_2
      }
      OBJECT_LEVEL_2_2 {
         SUB_OBJECT_LEVEL_2_2_1 = VALUE_3
         SUB_OBJECT_LEVEL_2_2_2 = VALUE_4
      }
      OBJECT_LEVEL_2_3 {
         OBJECT_LEVEL_3_1 {
            OBJECT_LEVEL_4_1_type_1 {
               OBJECT_LEVEL_5_1 {
                  SUB_OBJECT_LEVEL_5_1_1 {
                     OBJECT_LEVEL_6_1 {
                        SUB_OBJECT_LEVEL_6_1_1 = { number, VALUE_5 }
                        SUB_OBJECT_LEVEL_6_1_2 = VALUE_6
                     }
                  }
                 SUB_OBJECT_LEVEL_5_1_2 = VALUE_7
                 SUB_OBJECT_LEVEL_5_1_3 = { number, VALUE_8
                    THAT_SPANS
                    MULTIPLE
                    LINES }
                 SUB_OBJECT_LEVEL_5_1_4 {
                     SUB_OBJECT_LEVEL_5_1_4_1 = { VALUE_9 }
                     SUB_OBJECT_LEVEL_5_1_4_2 = { number, VALUE_10 }
                  }
               }
            }
         }
      }
   }

 

Diese Nachricht wird hundert- und tausendfach wiederholt. Jede Wiederholung hat andere Werte als die anderen. Der Inhalt von "OBJECT_LEVEL_2_3" ist am kompliziertesten, da sich der Wert am häufigsten ändert und es mehrere verschiedene mögliche Werte für die Unterobjektebene gibt.

Um dieses Objekt zu analysieren, wurde der folgende ANTLR-Code verwendet (der tatsächliche Code ist viel größer):

messageHeader: messageHeaderValue*;
messageHeaderValue: header topLevelMessages CRLF? dashes CRLF;
header: DATE TIME SEMICOLON modeUsed SEMICOLON type SEMICOLON CRLF;

topLevelMessages: topLevelObjectString OPEN_BRACKET CRLF topLevelObjectOne topLevelObjectTwo topLevelObjectThree CLOSE_BRACKET CRLF;
topLevelObjectString: ‘OBJECT_LEVEL_1’;
topLevelObjectOne: topLevelObjectOneString OPEN_BRACKET CRLF sub_OBJECT_LEVEL_2_1_1 EQUALSIGN VALUE_1 CRLF sub_OBJECT_LEVEL_2_1_2 EQUALSIGN VALUE_2 CRLF CLOSE_BRACKET CRLF;
topLevelObjectOneString: ‘OBJECT_LEVEL_2_1’;
topLevelObjectTwo: topLevelObjectTwoString OPEN_BRACKET CRLF sub_OBJECT_LEVEL_2_2_1 EQUALSIGN VALUE_3 CRLF sub_OBJECT_LEVEL_2_2_2 EQUALSIGN VALUE_4 CRLF CLOSE_BRACKET CRLF;
topLevelObjectTwoString: ‘OBJECT_LEVEL_2_2’;
topLevelObjectThree: topLevelObjectThreeString OPEN_BRACKET CRLF object_LEVEL_3_1 CLOSE_BRACKET CRLF;
topLevelObjectThreeString: ‘OBJECT_LEVEL_2_3’;

object_LEVEL_3_1: object_LEVEL_3_1_up | object_LEVEL_3_1_down;
object_LEVEL_3_1_up: object_LEVEL_3_1_upString OPEN_BRACKET CRLF object_LEVEL_4_1 CLOSE_BRACKET CRLF;
object_LEVEL_3_1_upString: 'object_LEVEL_3_1';
object_LEVEL_4_1: firstTypeMessages | secondTypeMessages | thirdTypeMessages;

firstTypeMessages: firstTypeMessagesString OPEN_BRACKET CRLF MESSAGE_TYPE_1 CLOSE_BRACKET CRLF;
firstTypeMessagesString: 'OBJECT_LEVEL_4_1_type_1';

secondTypeMessages: secondTypeMessagesString OPEN_BRACKET CRLF MESSAGE_TYPE_2 CLOSE_BRACKET CRLF;
secondTypeMessagesString: 'OBJECT_LEVEL_4_1_type_2';

thirdTypeMessages: thirdTypeMessagesString OPEN_BRACKET CRLF MESSAGE_TYPE_3 CLOSE_BRACKET CRLF;
thirdTypeMessagesString: 'OBJECT_LEVEL_4_1_type_3';

MESSAGE_TYPE_1: type1 | type2 | type3 | type4 | type5 | type6;
MESSAGE_TYPE_2: type7 | type8 | type9;
MESSAGE_TYPE_3: type10 | type11 | type12 | type13;

type1: type1String OPEN_BRACKET CRLF subObjectLevelFiveEntryOne subObjectLevelFiveEntryTwo subObjectLevelFiveEntryThree CLOSE_BRACKET CRLF;
type1String: 'OBJECT_LEVEL_5_1';

subObjectLevelFiveEntryOne: subObjectLevelFiveEntryOneString OPEN_BRACKET CRLF level6Value CLOSE_BRACKET CRLF;
subObjectLevelFiveEntryOneString: 'SUB_OBJECT_LEVEL_5_1_1';

level6Value: level6String OPEN_BRACKET CRLF levelSixValueOne levelSixValueTwo CLOSE_BRACKET CRLF;
level6String: 'OBJECT_LEVEL_6_1';

levelSixValueOne: levelSixValueOneString EQUALSIGN OPEN_BRACKET length COMMA VALUE_5 CLOSE_BRACKET CRLF;
levelSixValueOneString: 'SUB_OBJECT_LEVEL_6_1_1';
levelSixValueTwo: levelSixValueTwoString EQUALSIGN ESCAPED_SINGLE_QUOTE VALUE_6 ESCAPED_SINGLE_QUOTE CRLF;
levelSixValueTwoString: 'SUB_OBJECT_LEVEL_6_1_2';

subObjectLevelFiveEntryTwo: subObjectLevelFiveEntryTwoString EQUALSIGN subObjectLevelFiveEntryTwoIntegerValue CRLF;
subObjectLevelFiveEntryTwoString: 'SUB_OBJECT_LEVEL_5_1_2';
subObjectLevelFiveEntryThreeValue: subObjectLevelFiveEntryThreeString EQUALSIGN OPEN_BRACKET length COMMA value_8_Spanning_multiple_Lines CLOSE_BRACKET CRLF;
subObjectLevelFiveEntryThreeString: 'SUB_OBJECT_LEVEL_5_1_3';

VALUE_1: NUMBER;
sub_OBJECT_LEVEL_2_1_1: 'SUB_OBJECT_LEVEL_2_1_1';

VALUE_2: NUMBER;
sub_OBJECT_LEVEL_2_1_2: 'SUB_OBJECT_LEVEL_2_1_2';

length: NUMBER;
value_8_Spanning_multiple_Lines: (CRLF? ALPHANUMERIC_TEXT CRLF?)*?;
dashes: '--'*;

fragment DIGIT : [0-9];
fragment TWODIGIT : DIGIT DIGIT;
fragment LETTER : [A-Za-z];
fragment ALPHANUMERIC : [0-9A-Za-z];

ESCAPED_SINGLE_QUOTE : '\'';
EQUALSIGN : '=';
COMMA : ',';
NUMBER : DIGIT+ ;
DATE : TWODIGIT TWODIGIT '/' TWODIGIT '/' TWODIGIT;
TIME : TWODIGIT ':' TWODIGIT ':' TWODIGIT '.' NUMBER;
TEXT : LETTER+;
CRLF : '\r'? '\n' | '\r';
WHITESPACE : ' ' -> skip ;
OPEN_BRACKET: '{';
CLOSE_BRACKET: '}';
ALPHANUMERIC_TEXT : ALPHANUMERIC* ;
SEMICOLON: ':';
DASH: '-';
UNDERSCORE: '_';
DOT: '.';
OPEN_CROTCHET: '[';
CLOSE_CROTCHET: ']'; 

Wir nutzen die Flexibilität des Parsers, indem wir die Gruppen zusammen abbilden und den Operator OR verwenden, um verwandte Gruppen zu verbinden. Dies ist sehr hilfreich, wenn ähnliche Strukturen mit unterschiedlichen inneren Werten abgebildet werden sollen.

Nachdem unsere ANTLR-Parser-Grammatik erstellt wurde, können wir nun die g4-Datei kompilieren und erhalten generierte Klassen, die zum Parsen von Dateien mit diesen ANTLR-Parser-Regeln verwendet werden können.

Bevor wir die Datei laden und parsen, können wir eine benutzerdefinierte Listener-Klasse erstellen, die den von ANTLR generierten "BaseListener" erweitert. Wenn die ANTLR-Datei "example.g4" heißt, erzeugt ANTLR die folgenden Klassen: "ExampleBaseListener", "ExampleLexer", "ExampleListener", "ExampleParser". Wir müssen dann eine Klasse 'ExampleCustomListener' erstellen und die Klasse 'ExampleBaseListener' erweitern. Dies ermöglicht das Überschreiben der "entry"- und "exit"-Methoden jeder Regel in unserer Grammatik. Zum Beispiel können wir die Methode 'exitValue_8_Spanning_multiple_Lines(context)' überschreiben und dann diesen Wert in ein Java-Objekt laden.

Indem wir die Methoden für alle benötigten Werte überschreiben und unseren eigenen CustomListener einrichten, können wir die geparsten Daten in die Java-Objekte unserer Wahl übersetzen. Mit diesem Schritt sind wir nun fertig und können eine Datei parsen. Dazu erstellen wir eine Lexer-Objektinstanz und übergeben ihr einen characterStreamInput. Dann erstellen wir einen CommonTokenStream und eine Parser-Instanz. Mit einem ParseTreeWalker, dem wir eine CustomListener-Instanz übergeben, durchlaufen wir den Parse-Baum und erhalten den Parser-Eingangspunkt (d.h. 'messageHeader' in unserem obigen Beispiel).

Auf diese Weise konnte ich Tausende von Java-Objekten aus den in Dateien gefundenen Daten laden und parsen.  

 

Quellen: antlr.org/ tomassetti.me/antlr-mega-tutorial/