Перейти к основному содержимому

Java с ANTLR

· 7 мин. чтения

1. Обзор

В этом руководстве мы сделаем краткий обзор генератора парсеров ANTLR и покажем некоторые реальные приложения.

2. АНТЛР

ANTLR (ANother Tool for Language Recognition) — это инструмент для обработки структурированного текста.

Он делает это, предоставляя нам доступ к примитивам обработки языка, таким как лексеры, грамматики и синтаксические анализаторы, а также к среде выполнения для обработки текста по ним.

Он часто используется для создания инструментов и фреймворков. Например, Hibernate использует ANTLR для разбора и обработки HQL-запросов, а Elasticsearch — для Painless.

А Java — это всего лишь одна привязка. ANTLR также предлагает привязки для C#, Python, JavaScript, Go, C++ и Swift.

3. Конфигурация

Прежде всего, давайте начнем с добавления antlr-runtime в наш pom.xml :

<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4-runtime</artifactId>
<version>4.7.1</version>
</dependency>

А также antlr-maven-плагин :

<plugin>
<groupId>org.antlr</groupId>
<artifactId>antlr4-maven-plugin</artifactId>
<version>4.7.1</version>
<executions>
<execution>
<goals>
<goal>antlr4</goal>
</goals>
</execution>
</executions>
</plugin>

Задача плагина — генерировать код из указанных нами грамматик.

4. Как это работает?

По сути, когда мы хотим создать парсер с помощью плагина ANTLR Maven , нам нужно выполнить три простых шага:

  • подготовить файл грамматики
  • генерировать источники
  • создать слушателя

Итак, давайте посмотрим на эти шаги в действии.

5. Использование существующей грамматики

Давайте сначала воспользуемся ANTLR для анализа кода на наличие методов с неправильным регистром:

public class SampleClass {

public void DoSomethingElse() {
//...
}
}

Проще говоря, мы проверим, что все имена методов в нашем коде начинаются со строчной буквы.

5.1. Подготовьте файл грамматики

Что приятно, так это то, что уже есть несколько файлов грамматики, которые могут подойти для наших целей.

Давайте воспользуемся файлом грамматики Java8.g4, который мы нашли в репозитории грамматики ANTLR на Github .

Мы можем создать каталог src/main/antlr4 и загрузить его туда.

5.2. Генерация источников

ANTLR работает, генерируя код Java, соответствующий файлам грамматики, которые мы ему даем, а плагин maven упрощает эту задачу:

mvn package

По умолчанию это создаст несколько файлов в каталоге target/generated-sources/antlr4 :

  • Java8.interp
  • Java8Listener.java
  • Java8BaseListener.java
  • Java8Lexer.java
  • Java8Lexer.interp
  • Java8Parser.java
  • Java8.токены
  • Java8Lexer.токены

Обратите внимание, что имена этих файлов основаны на имени файла грамматики .

Файлы Java8Lexer и Java8Parser нам потребуются позже, когда мы будем тестировать. Однако сейчас нам нужен Java8BaseListener для создания нашего MethodUppercaseListener .

5.3. Создание MethodUppercaseListener

На основе грамматики Java8, которую мы использовали, Java8BaseListener имеет несколько методов, которые мы можем переопределить, каждый из которых соответствует заголовку в файле грамматики.

Например, грамматика определяет имя метода, список параметров и предложение throws следующим образом:

methodDeclarator
: Identifier '(' formalParameterList? ')' dims?
;

Итак, в Java8BaseListener есть метод enterMethodDeclarator , который будет вызываться каждый раз, когда встречается этот шаблон.

Итак, давайте переопределим enterMethodDeclarator , вытащим Identifier и выполним нашу проверку:

public class UppercaseMethodListener extends Java8BaseListener {

private List<String> errors = new ArrayList<>();

// ... getter for errors

@Override
public void enterMethodDeclarator(Java8Parser.MethodDeclaratorContext ctx) {
TerminalNode node = ctx.Identifier();
String methodName = node.getText();

if (Character.isUpperCase(methodName.charAt(0))) {
String error = String.format("Method %s is uppercased!", methodName);
errors.add(error);
}
}
}

5.4. Тестирование

Теперь давайте проведем небольшое тестирование. Во-первых, мы создаем лексер:

String javaClassContent = "public class SampleClass { void DoSomething(){} }";
Java8Lexer java8Lexer = new Java8Lexer(CharStreams.fromString(javaClassContent));

Затем мы создаем парсер:

CommonTokenStream tokens = new CommonTokenStream(lexer);
Java8Parser parser = new Java8Parser(tokens);
ParseTree tree = parser.compilationUnit();

А затем ходячий и слушающий:

ParseTreeWalker walker = new ParseTreeWalker();
UppercaseMethodListener listener= new UppercaseMethodListener();

Наконец, мы говорим ANTLR пройти через наш образец класса :

walker.walk(listener, tree);

assertThat(listener.getErrors().size(), is(1));
assertThat(listener.getErrors().get(0),
is("Method DoSomething is uppercased!"));

6. Построение нашей грамматики

Теперь давайте попробуем кое-что посложнее, например, разобрать файлы журналов:

2018-May-05 14:20:18 INFO some error occurred
2018-May-05 14:20:19 INFO yet another error
2018-May-05 14:20:20 INFO some method started
2018-May-05 14:20:21 DEBUG another method started
2018-May-05 14:20:21 DEBUG entering awesome method
2018-May-05 14:20:24 ERROR Bad thing happened

Поскольку у нас есть собственный формат журнала, нам сначала нужно создать собственную грамматику.

6.1. Подготовьте файл грамматики

Во-первых, давайте посмотрим, сможем ли мы создать мысленную карту того, как выглядит каждая строка журнала в нашем файле.

<дата-время> <уровень> <сообщение>

Или, если мы углубимся еще на один уровень, мы можем сказать:

<дата/время> := <год><тире><месяц><тире><день> …

И так далее. Это важно учитывать, чтобы мы могли решить, на каком уровне детализации мы хотим анализировать текст.

Файл грамматики в основном представляет собой набор правил лексера и парсера. Проще говоря, правила лексера описывают синтаксис грамматики, а правила парсера описывают семантику.

Начнем с определения фрагментов, которые являются многократно используемыми строительными блоками для правил лексера.

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

Далее определим остальные правила лексера:

DATE : TWODIGIT TWODIGIT '-' LETTER LETTER LETTER '-' TWODIGIT;
TIME : TWODIGIT ':' TWODIGIT ':' TWODIGIT;
TEXT : LETTER+ ;
CRLF : '\r'? '\n' | '\r';

Имея эти строительные блоки, мы можем построить правила парсера для базовой структуры:

log : entry+;
entry : timestamp ' ' level ' ' message CRLF;

И затем мы добавим детали для метки времени :

timestamp : DATE ' ' TIME;

Для уровня :

level : 'ERROR' | 'INFO' | 'DEBUG';

И для сообщения :

message : (TEXT | ' ')+;

Вот и все! Наша грамматика готова к использованию. Мы поместим его в каталог src/main/antlr4 , как и раньше.

6.2. Генерация источников

Напомним, что это всего лишь быстрый пакет mvn , который создаст несколько файлов, таких как LogBaseListener , LogParser и т. д., на основе имени нашей грамматики.

6.3. Создайте наш прослушиватель журнала

Теперь мы готовы реализовать наш слушатель, который мы в конечном итоге будем использовать для анализа файла журнала в объекты Java.

Итак, давайте начнем с простого класса модели для записи журнала:

public class LogEntry {

private LogLevel level;
private String message;
private LocalDateTime timestamp;

// getters and setters
}

Теперь нам нужно создать подкласс LogBaseListener , как и раньше:

public class LogListener extends LogBaseListener {

private List<LogEntry> entries = new ArrayList<>();
private LogEntry current;

current будет хранить текущую строку журнала, которую мы можем повторно инициализировать каждый раз, когда мы вводим logEntry, опять же на основе нашей грамматики:

@Override
public void enterEntry(LogParser.EntryContext ctx) {
this.current = new LogEntry();
}

Далее мы будем использовать enterTimestamp , enterLevel и enterMessage для установки соответствующих свойств LogEntry :

@Override
public void enterTimestamp(LogParser.TimestampContext ctx) {
this.current.setTimestamp(
LocalDateTime.parse(ctx.getText(), DEFAULT_DATETIME_FORMATTER));
}

@Override
public void enterMessage(LogParser.MessageContext ctx) {
this.current.setMessage(ctx.getText());
}

@Override
public void enterLevel(LogParser.LevelContext ctx) {
this.current.setLevel(LogLevel.valueOf(ctx.getText()));
}

И, наконец, давайте воспользуемся методом exitEntry , чтобы создать и добавить наш новый LogEntry :

@Override
public void exitLogEntry(LogParser.EntryContext ctx) {
this.entries.add(this.current);
}

Кстати, обратите внимание, что наш LogListener не является потокобезопасным!

6.4. Тестирование

И теперь мы можем снова протестировать, как мы это делали в прошлый раз:

@Test
public void whenLogContainsOneErrorLogEntry_thenOneErrorIsReturned()
throws Exception {

String logLine ="2018-May-05 14:20:24 ERROR Bad thing happened";

// instantiate the lexer, the parser, and the walker
LogListener listener = new LogListener();
walker.walk(listener, logParser.log());
LogEntry entry = listener.getEntries().get(0);

assertThat(entry.getLevel(), is(LogLevel.ERROR));
assertThat(entry.getMessage(), is("Bad thing happened"));
assertThat(entry.getTimestamp(), is(LocalDateTime.of(2018,5,5,14,20,24)));
}

7. Заключение

В этой статье мы сосредоточились на том, как создать собственный парсер для собственного языка с помощью ANTLR.

Мы также увидели, как использовать существующие файлы грамматики и применять их для очень простых задач, таких как анализ кода.

Как всегда, весь используемый здесь код можно найти на GitHub .