Назад к projects
project

Как написать интерпретатор языка запросов на C# — разбор на реальном примере

Написать интерпретатор языка запросов на C# — задача, которая пугает до тех пор, пока не разбить её на части. В этой статье разобрана архитектура и ключевые решения PdfQL — открытой C# библиотеки, реализующей конвейерный язык запросов для извлечения структурированных данных из PDF-документов.

PdfQL — концептуальный проект, не production-библиотека. Но в реализации есть рабочий сканер, парсер, AST и исполнитель конвейера, написанные на C# под .NET 10 — что делает его полезным справочным примером для тех, кто строит DSL или интерпретатор на .NET.

Язык C#
Фреймворк .NET 10
Тип проекта Библиотека
Статус Концепция
Лицензия AGPL-3.0
NuGet последняя версия
Загрузки загрузки
GitHub Laraue.PdfQL
Демо-приложение Laraue.Apps.PdfQL
Живое демо PDF Extractor

Зачем вообще писать язык запросов?

Извлечение структурированных данных из PDF на C# — таблиц, строк, конкретных ячеек — обычно выглядит как набор повторяющегося, хрупкого императивного кода. Открыть документ, найти элементы по позиции, пройтись по строкам циклом, вручную применить условия. Каждая новая задача по извлечению — это снова бойлерплейт.

Идея PdfQL — выражать такие операции декларативно, как SQL выражает запросы к базе данных. Вместо того чтобы писать императивный C# для поиска таблиц, где в колонке 4 значение "Name", и возврата колонки 1, достаточно написать:

select(tables)
    ->filter((item) => item.GetCell(4).Text() == 'Name')
    ->selectMany(tableRows)
    ->map((item) => item.GetCell(1))

Библиотека парсит эту строку, строит план выполнения и запускает его на PDF-документе. Это и есть основной цикл любого интерпретатора — и именно о его построении эта статья.


Почему не SQL-синтаксис

Первый инстинкт при проектировании языка запросов — взять SQL-подобный синтаксис. Он знаком, широко понятен, и у него хорошая поддержка инструментами.

Но SQL оптимизирован под реляционные данные с джойнами. Документы не имеют такой структуры. PDF-файлы редко "джойнятся". Основные операции: выбрать тип элемента, отфильтровать по условию, преобразовать результат. Это не SELECT ... FROM ... WHERE — это конвейер стадий.

Ближайший аналог — агрегационный конвейер MongoDB: каждая стадия получает вывод предыдущей, применяет трансформацию и передаёт результат дальше. PdfQL напрямую принимает эту модель, используя -> как оператор пайпа между стадиями. Синтаксис намеренно ближе к функциональному method chaining, чем к SQL.

Если концепция выйдет за рамки PDF, язык будет переименован в DocQL — та же модель конвейера, применённая к любому формату документов.


Архитектура: три слоя

Интерпретатор языка запросов решает три отдельные задачи:

  1. Сканер — превращает строку запроса в плоский список токенов
  2. Парсер — превращает список токенов в структурированное дерево (AST)
  3. Исполнитель — обходит AST и выполняет каждую операцию над данными

PdfQL добавляет четвёртый слой, специфичный для предметной области: DocumentObjectsExtractor, который конвертирует сырые байты PDF в типизированную последовательность объектов документа (таблицы, абзацы, изображения, формы) до запуска исполнителя запроса.


Слой 1: Сканер

Задача сканера — токенизация: разбить строку запроса на осмысленные единицы, отбросив пробелы и переносы строк.

Полный словарь токенов определён в TokenType.cs:

public enum TokenType
{
    Identifier, String, Integer,
    Comma, Dot,
    LessThan, GreaterThan, LessOrEqualThan, GreaterOrEqualThan,
    Assign, Equal, NotEqual, Not,
    Minus, Plus, Divide, Multiply,
    LeftParentheses, RightParentheses,
    LeftBracket, RightBracket,
    Lambda,       // =>
    NextPipeline, // ->
    False, True, Null,
    New, Eof,
}

Scanner обрабатывает входную строку посимвольно внутри приватного класса ScannerImplementation. Ключевой паттерн: отслеживаются и абсолютная позиция (смещение в сырой строке для нарезки лексем), и относительная позиция (столбец в текущей строке для сообщений об ошибках). Двухсимвольные операторы — ->, =>, ==, != — обрабатываются вспомогательным методом PopNextCharIf, который условно поглощает следующий символ, только если предикат совпадает:

case '-':
    AddToken(PopNextCharIf(c => c == '>') ? TokenType.NextPipeline : TokenType.Minus);
    break;
case '=':
    AddToken(PopNextCharIf(c => c == '>')
        ? TokenType.Lambda
        : PopNextCharIf(c => c == '=')
            ? TokenType.Equal
            : TokenType.Assign);
    break;

Сканер возвращает ScanResult, содержащий и массив токенов, и объекты ScanError — так вызывающий код получает структурированную информацию об ошибках вместо исключений на некорректный ввод.


Слой 2: Парсер и AST

Парсер берёт плоский поток токенов и строит Abstract Syntax Tree (AST) — иерархическую структуру, отражающую грамматику запроса.

Базовый узел AST намеренно минимален — Expr.cs — это просто пустой базовый record:

public record Expr
{
}

Все типы выражений наследуют от него. Использование C# record даёт структурное равенство бесплатно, что делает юнит-тесты парсера чистыми — ожидаемые и фактические деревья корректно сравниваются без кастомной логики равенства.

Конкретный пример — BinaryExpr.cs, представляющий любое двуоперандное выражение (==, !=, <, >, + и т.д.):

public record BinaryExpr : Expr
{
    public BinaryExpr(Expr left, Token @operator, Expr right)
    {
        Left = left;
        Operator = @operator;
        Right = right;
    }

    public Expr Left { get; init; }
    public Token Operator { get; init; }  // хранит TokenType и фактический lexeme
    public Expr Right { get; init; }

    public override string ToString() => $"{Left} {Operator.Lexeme} {Right}";
}

Поле Operator хранит полный Token — не только тип оператора — чтобы сообщения об ошибках могли указывать точный текст источника и позицию. Рекурсивный ToString() делает всё дерево распечатываемым для отладки, что бесценно при тестировании парсера в изоляции.

Parser использует рекурсивный спуск — каждое грамматическое правило становится методом, вызывающим другие методы для подправил. Это наиболее читаемый подход для написанных вручную парсеров и хорошо ложится на C# без инструментов генерации парсеров вроде ANTLR.


Слой 3: Исполнитель

Исполнитель обходит AST и выполняет его. Для конвейерной модели PdfQL это означает:

  1. Начать с полной последовательности объектов документа, извлечённых из PDF
  2. Для каждой стадии конвейера применить операцию стадии к текущей последовательности
  3. Вернуть финальную последовательность

Каждый тип стадии соответствует известной операции над последовательностью:

Стадия Операция Эквивалент в LINQ
select(tables) Фильтр по типу OfType<Table>()
filter((x) => ...) Применение предиката Where(x => ...)
selectMany(tableRows) Разворачивание вложенной коллекции SelectMany(x => x.Rows)
map((x) => ...) Проекция в новое значение Select(x => ...)

Узлы лямбда-выражений в AST компилируются в C# делегаты Func<> во время выполнения. Имя параметра маппится на аргумент делегата, а тело выражения — которое может включать BinaryExpr, вызовы методов, доступ к свойствам и литералы — вычисляется рекурсивно.


Генерация анонимных типов в рантайме через Reflection.Emit

Одна из наиболее нестандартных частей реализации — AnonymousTypeRegistry.cs. Когда стадия map проецирует объекты документа в новые формы — например, возвращает только текстовое содержимое ячейки, а не весь объект ячейки — исполнителю нужно конструировать результирующие типы, структура которых не известна до парсинга запроса в рантайме.

Стандартный анонимный тип C# (new { Name = "x" }) здесь не подходит — он требует, чтобы форма была известна на этапе компиляции. Вместо этого AnonymousTypeRegistry генерирует настоящие CLR-типы динамически с помощью System.Reflection.Emit:

public class AnonymousTypeRegistry
{
    private readonly string _moduleName;
    private readonly HashSet<AnonymousTypeProperties> _declaredProperties = new();
    private readonly Dictionary<AnonymousTypeProperties, Type> _anonymousClassNames = new();

    public Type GetAnonymousType(AnonymousTypeProperties anonymousTypeProperties)
    {
        if (_declaredProperties.Add(anonymousTypeProperties))
        {
            var typeName = $"PdfQLAnonymousType_{_declaredProperties.Count}";
            var type = AnonymousTypeBuilder.CreateType(_moduleName, typeName, anonymousTypeProperties);
            _anonymousClassNames.Add(anonymousTypeProperties, type);
        }

        return _anonymousClassNames[anonymousTypeProperties];
    }
}

AnonymousTypeProperties расширяет ReadOnlyDictionary<string, Type> — ключи это имена свойств, значения — их CLR-типы. Реализации GetHashCode и Equals используют XOR-хэширование по всем парам имя/тип, чтобы реестр корректно дедуплицировал запросы к идентичным формам: два запроса, оба проецирующие в { Text: string }, будут переиспользовать один сгенерированный тип, а не эмитировать его дважды.

Этот паттерн — генерация типов в рантайме и их кэширование по структурному равенству — плохо задокументирован в экосистеме .NET. Большинство статей о Reflection.Emit покрывают генерацию методов; генерация полноценных именованных типов со свойствами, их регистрация в модуле и дедупликация по форме встречается крайне редко. Реализация в PdfQL — рабочий, читаемый справочный пример этой техники.


Демо-приложение

Живое демо на laraue.com/pdf-extractor позволяет тестировать PdfQL-запросы прямо в браузере — загрузите PDF, напишите запрос и получите JSON-вывод. Готовые пресеты (извлечь все таблицы, извлечь все изображения) компилируются в PdfQL внутри.

Веб-API, лежащий в основе демо, находится в отдельном репозитории: github.com/win7user10/Laraue.Apps.PdfQL. Это тонкая ASP.NET Core обёртка над библиотекой — полезная как референс для хостинга PdfQL в веб-контексте.


Исходный код

Полная реализация на github.com/win7user10/Laraue.PdfQL. Ключевые файлы:

Проект распространяется под лицензией AGPL-3.0.


Что дальше

Текущая реализация покрывает извлечение таблиц с фильтрацией. Запланированные расширения:

  • Поддержка обычного текстаselect(textRows), select(words), select(sentences)
  • Поддержка изображений — возврат изображений по условиям; функции вроде resize(600, 400)
  • Рефакторинг — отвязать исполнитель от PDF-специфичных типов для полноценной реализации концепции DocQL
  • Пользовательские функции — регистрация собственных функций, вызываемых из лямбд запроса

Часто задаваемые вопросы

Как работает рекурсивно-спускающий парсер на C#?

Рекурсивно-спускающий парсер отображает каждое правило грамматики на C#-метод. Для парсинга бинарного выражения вроде a == b парсер вызывает ParseExpression(), который вызывает ParseEquality(), который вызывает ParseComparison() и так далее по иерархии приоритетов. Каждый метод поглощает токены, которые он распознаёт, и делегирует подвыражения правилам с более низким приоритетом. В результате стек вызовов естественным образом отражает структуру строящегося AST — код легко читать, а грамматику легко расширять.

Можно ли использовать PdfQL в production?

Нет — это концептуальный проект. Конвейер извлечения таблиц работает и покрыт тестами, но библиотека не поддерживается с прицелом на production-надёжность. Для production-извлечения данных из PDF на C# стоит смотреть на PdfPig или iTextSharp. PdfQL наиболее ценен как справочная реализация для построения интерпретаторов и DSL на C#.

Почему не ANTLR или другой генератор парсеров?

Генераторы парсеров — хороший выбор для сложных грамматик. Грамматика PdfQL достаточно проста, чтобы написанный вручную рекурсивно-спускающий парсер было легче читать, отлаживать и изменять — и написание его вручную само по себе является образовательной целью. Весь парсер умещается в несколько сотен строк C#.

В чём разница между транспилером и интерпретатором?

Интерпретатор выполняет AST напрямую в рантайме — именно так работает PdfQL. Транспилер переводит AST в исходный код другого языка (например, компилирует PdfQL в LINQ-выражения или C# код). Слои сканера и парсера идентичны для обоих — отличается только бэкенд. Архитектура PdfQL может быть расширена до транспилера заменой исполнителя на эмиттер кода.

Зачем генерировать анонимные типы динамически вместо Dictionary<string, object>?

Словарь сработал бы, но теряет информацию о типах — нельзя использовать рефлексию или expression trees против него осмысленным образом, а сериализация даёт менее полезный вывод. Генерация настоящего CLR-типа с именованными, типизированными свойствами означает, что результат ведёт себя как любой другой .NET объект: корректно сериализуется в JSON, поддерживает доступ к свойствам через рефлексию и может участвовать в дальнейших LINQ-операциях.

Работает ли этот подход для форматов, отличных от PDF?

Да — и именно в этом долгосрочный замысел дизайна. Исполнитель конвейера уже отвязан от PDF-экстрактора. Добавление поддержки нового формата документов означает реализацию нового DocumentObjectsExtractor для этого формата; язык запросов и исполнитель переиспользуются без изменений.