Парсер JSON на Java
Парсер JSON на Java
JSON — JavaScript Object Notation — стал де-факто стандартом обмена структурированными данными в распределённых системах, веб-API и конфигурационных файлах. Его популярность обусловлена лаконичностью, читаемостью и широкой поддержкой во всех современных языках программирования. Однако именно эта повсеместная доступность привела к тому, что большинство разработчиков воспринимают JSON как «чёрный ящик»: подключил библиотеку — вызвал parse() — получил объект. Внутреннее устройство парсера остаётся за кадром профессионального интереса.
Реализация собственного JSON-парсера — это педагогический и когнитивный инструмент высокой эффективности. Такой проект требует понимания нескольких ключевых дисциплин: формальных грамматик, лексического и синтаксического анализа, рекурсивного спуска, построения абстрактного синтаксического дерева и отображения его в объектную модель. Парсер JSON — идеальный «минималистичный компилятор»: он не генерирует машинный код и не выполняет оптимизаций, но проходит все фазы классического конвейера обработки исходного текста. Более того, ограничения JSON (отсутствие циклических ссылок, строгая иерархическая структура, конечный набор типов) делают задачу реализуемой в разумные сроки даже без глубокого опыта в компиляторостроении.
Для разработчика на Java такой проект дополнительно раскрывает особенности работы со строками, управлением состоянием, обработкой ошибок и проектированием неизменяемых структур данных. В отличие от языков с динамической типизацией (например, Python или JavaScript), где JSON-объекты естественно отображаются в словари, в Java требуется явное моделирование типов: примитивы (String, Number, Boolean, null), коллекции (List, Map) и их вложенные комбинации. Это создаёт дополнительный уровень сложности и, одновременно, возможность проработать вопросы типобезопасности и обобщённого программирования.
Формальная спецификация JSON
Любой парсер — это программа, реализующая отображение из множества корректных строк языка L в некоторую структурную модель M. Для JSON язык L строго определён в спецификации RFC 8259 (на смену RFC 7159 и 4627). Эта спецификация является нормативным документом: она фиксирует не только синтаксис, но и семантические ограничения, которые парсер обязан соблюдать — иначе он не соответствует стандарту.
Основные положения RFC 8259, имеющие прямое отношение к реализации:
-
Кодировка. JSON-документ должен быть представлен в виде последовательности Unicode-символов, закодированных в UTF-8, UTF-16 или UTF-32. На практике 99.9% документов используют UTF-8. Парсер может принимать
Stringкак уже декодированный контейнер символов (char[]илиCharSequence), что снимает необходимость работы с байтовыми потоками и BOM, но требует, чтобы вызывающая сторона обеспечила корректную декодировку входных байтов. -
Структура документа. Валидный JSON состоит ровно из одного значения (
value). Это может быть:- объект (
{ … }); - массив (
[ … ]); - строка (
"…"); - число (
123,-45.6e7); - логический литерал (
trueилиfalse); null.
То есть документ не обязан быть объектом или массивом — корректным JSON является, например, строка
"hello"или число42. Это важно учитывать при проектировании точки входа парсера. - объект (
-
Синтаксические элементы:
- Пробельные символы (
U+0020,U+0009,U+000A,U+000D) допустимы вне строковых литералов и могут разделять токены. Они не несут семантической нагрузки и должны игнорироваться (skip). - Строки заключаются в двойные кавычки (
"), используют обратный слэш (\) как escape-символ. Поддерживаются стандартные escape-последовательности (\",\\,\/,\b,\f,\n,\r,\t) и\uXXXXдля Unicode-символов. Любые другие escape-последовательности (\x,\0,\vи т.п.) — недопустимы и должны вызывать ошибку. - Числа представлены в десятичной системе счисления. Допускаются целые (
123,-456), дробные (12.34,.5— недопустимо!), а также экспоненциальная форма (1.23e4,5E-6). Ноль может быть только0, но не01,0x10,+0. Ведущие нули вне дробной части (01,00) — недопустимы. - В объектах ключи обязаны быть строками (и, следовательно, в двойных кавычках). Повторяющиеся ключи формально не запрещены спецификацией, но рекомендуется рассматривать их как ошибку или оставлять только последнее вхождение — поведение на усмотрение реализации, но должно быть документировано.
- Запятые разделяют элементы массива и пары ключ-значение в объектах. Завершающая запятая (
[1, 2,]) — недопустима.
- Пробельные символы (
-
Семантика и ограничения:
- JSON не поддерживает комментарии.
- Не поддерживаются бесконечности (
Infinity,-Infinity) и нечисловые значения (NaN). Любая реализация, принимающая их, — расширение стандарта. - Точность чисел не регламентируется, но парсер должен обеспечивать потерю информации не более чем до семантики
double— иначе возможны артефакты при сериализации-десериализации. В Java это означает, что числа целесообразно представлять какBigDecimalдля точного хранения, либо какDoubleс оговоркой о погрешности.
Эти правила образуют контракт между создателем JSON-документа и потребителем. Парсер, игнорирующий хотя бы одно из требований (например, разрешающий одинарные кавычки или 01), становится нестандартным и потенциально создаёт уязвимости при интеграции с другими системами.
Важно: проектирование парсера начинается не с кода, а с точного переписывания грамматики из RFC 8259 в форме, пригодной для программной реализации. Ниже приведена адаптированная версия грамматики JSON в стиле расширенной формы Бэкуса-Наура (EBNF), сохраняющая семантику стандарта:
JSON-text = ws value ws
value = object
| array
| string
| number
| "true"
| "false"
| "null"
object = "{" ws [ member *( "," ws member ) ] ws "}"
member = string ws ":" ws value
array = "[" ws [ value *( "," ws value ) ] ws "]"
string = "\"" *( char | escape ) "\""
char = %x20-21 / %x23-5B / %x5D-10FFFF ; любой Unicode кроме " и \
escape = "\" ( "\"" / "\\" / "/" / "b" / "f" / "n" / "r" / "t" / "u" 4HEXDIG )
number = [ "-" ] int [ frac ] [ exp ]
int = "0" / digit1-9 *DIGIT
frac = "." 1*DIGIT
exp = ("e" / "E") [ "+" / "-" ] 1*DIGIT
ws = *( %x20 / %x09 / %x0A / %x0D ) ; пробел, табуляция, LF, CR
DIGIT = %x30-39
digit1-9 = %x31-39
Эта грамматика — основа для всех дальнейших шагов: токенизации, разбора и проверки корректности.
Архитектурный обзор
Классический компилятор разбивается на фазы: лексический анализ → синтаксический анализ → семантический анализ → генерация кода. JSON-парсер — усечённая версия, где последние две фазы сводятся к построению объектной модели. В нашем случае архитектура состоит из трёх логических уровней:
-
Лексер (токенайзер) — преобразует входную строку в поток токенов (
TokenStream). Каждый токен — это пара(тип, значение, позиция), где тип — перечисление (LEFT_BRACE,STRING,NUMBERи т.д.), значение — извлечённые данные (например,"hello"дляSTRING), позиция — индекс в исходной строке для точного отчёта об ошибках. Лексер отвечает за распознавание строк, чисел, ключевых слов, разделителей и игнорирование пробельных символов. -
Парсер (синтаксический анализатор) — потребляет поток токенов и строит дерево разбора (parse tree) или, что эффективнее, сразу объектную модель (AST →
JsonValue). Парсер реализует грамматику: он рекурсивно распознаёт структуры (object,array,value) по правилам. В случае JSON оптимальна стратегия рекурсивного спуска (recursive descent), так как грамматика не содержит левой рекурсии и однозначна. -
Объектная модель — иерархия классов/интерфейсов, отражающая типы JSON:
JsonValue,JsonString,JsonNumber,JsonBoolean,JsonNull,JsonArray,JsonObject. Все они реализуют общий интерфейс, обеспечивающий единообразный доступ к данным (например,asString(),asNumber(),asMap()), но с контролируемыми исключениями при несоответствии типов.
Кроме этих трёх компонентов, необходима инфраструктура обработки ошибок: пользователь должен получать не просто «invalid JSON», а сообщение вида «ожидается двоеточие после ключа, строка 5, позиция 12». Для этого вводится понятие курсора — объекта, хранящего текущую позицию в строке, историю токенов и контекст разбора.
Важное решение: проводить ли отдельную фазу построения AST или сразу строить объектную модель («direct AST»). Для JSON разумно выбрать второй путь: структура данных не требует дальнейшей обработки (оптимизаций, трансформаций), и промежуточное дерево лишь создаёт накладные расходы. Это упрощает код и повышает производительность.
Подготовка
Перед реализацией лексера и парсера необходимо определить, что мы строим. В Java отсутствует встроенный «JSON-тип», поэтому требуется создать минимальную, но полную иерархию.
Рекомендуемый подход — использовать sealed interface (Java 17+), обеспечивающий строгий контроль наследования и упрощающий паттерн матчинг:
public sealed interface JsonValue permits
JsonString, JsonNumber, JsonBoolean, JsonNull,
JsonArray, JsonObject { }
public record JsonString(String value) implements JsonValue { }
public record JsonNumber(BigDecimal value) implements JsonValue { }
public record JsonBoolean(boolean value) implements JsonValue { }
public record JsonNull() implements JsonValue { } // singleton
public record JsonArray(List<JsonValue> elements) implements JsonValue { }
public record JsonObject(Map<String, JsonValue> fields) implements JsonValue { }
Пояснения:
BigDecimalдля чисел — гарантирует точное представление без потерь, особенно для финансовых данных. Конвертация вdoubleилиlongможет производиться по требованию через методы вродеasDouble(), но хранить следует исходную точность.JsonNullкакrecordбез полей — это допустимо в Java 17+, и такой объект может быть сделан синглтоном (private static final JsonNull INSTANCE = new JsonNull();).JsonObjectиспользуетMap<String, JsonValue>— стандартный способ представления ассоциативного массива. Под капотом может бытьLinkedHashMap, если важен порядок ключей (RFC не требует порядка, но многие клиенты на него полагаются).- Все реализации — неизменяемые (immutable), что обеспечивает потокобезопасность и простоту тестирования.
Дополнительно вводятся вспомогательные методы для удобства:
public interface JsonValue {
default String asString() {
if (this instanceof JsonString s) return s.value();
throw new JsonTypeException("Expected string, got " + this.getClass().getSimpleName());
}
default BigDecimal asNumber() {
if (this instanceof JsonNumber n) return n.value();
throw new JsonTypeException("Expected number, got " + this.getClass().getSimpleName());
}
// ... аналогично для Boolean, Array, Object
}
Исключение JsonTypeException — специализированное, наследуется от RuntimeException, содержит информацию о позиции в документе.
Такая модель — компактна, типобезопасна и соответствует семантике JSON. Она не пытается интегрироваться с POJO (это задача мапперов вроде ObjectMapper), а остаётся на уровне абстрактного синтаксического дерева.
Лексический анализ
Лексер принимает CharSequence (обычно String) и выдаёт Iterator<Token> или, что практичнее — поток токенов с возможностью «заглядывания вперёд» (peek) и отката (возврат ранее извлечённого токена). Такой интерфейс позволяет парсеру использовать стратегию рекурсивного спуска с предпросмотром на один токен (LL(1)), что достаточно для JSON: грамматика не требует двух- или трёхсимвольного предпросмотра.
Ключевые задачи лексера:
- Сегментация входного потока — разбиение строки на логические единицы:
{,},[,],:,,,"строка",123.45,true,false,null. - Пропуск пробельных символов — в соответствии со спецификацией, пробелы, табуляции, переводы строк и возвраты каретки допустимы между токенами и должны игнорироваться.
- Валидация элементарных конструкций — уже на этом этапе можно выявить базовые ошибки: незакрытая строка, недопустимый escape, некорректное число (например,
01), неизвестное ключевое слово (truес кириллической «е»). - Точное отслеживание позиции — каждый токен должен нести информацию о своей начальной позиции (индекс в строке), что критически важно для диагностики ошибок. По желанию — расширенная позиция: номер строки и столбца (для удобства пользователя).
- Эффективность — избежание излишнего копирования памяти. Например, строковые значения внутри JSON следует извлекать как
String, но не через конкатенацию в цикле, а с использованиемStringBuilderили прямой работы сchar[].
Важно подчеркнуть: лексер не интерпретирует структуру документа. Он не проверяет, что после { следует строка, а потом :, — это задача парсера. Его ответственность ограничивается распознаванием атомарных единиц, не нарушая контекстных правил.
Представление токена
В Java логично использовать sealed class или enum + record для представления токенов. Предпочтителен первый подход: он позволяет присоединить к каждому типу токена дополнительные данные (например, строковое содержимое для STRING, числовое значение для NUMBER).
public sealed interface Token permits
LeftBrace, RightBrace, LeftBracket, RightBracket,
Colon, Comma, StringToken, NumberToken,
TrueToken, FalseToken, NullToken, EndOfInput { }
Далее — конкретные реализации:
public record LeftBrace(int position) implements Token { }
public record RightBrace(int position) implements Token { }
public record LeftBracket(int position) implements Token { }
public record RightBracket(int position) implements Token { }
public record Colon(int position) implements Token { }
public record Comma(int position) implements Token { }
public record StringToken(String value, int position) implements Token { }
public record NumberToken(BigDecimal value, String raw, int position) implements Token { }
public record TrueToken(int position) implements Token { }
public record FalseToken(int position) implements Token { }
public record NullToken(int position) implements Token { }
public record EndOfInput(int position) implements Token { }
Пояснения:
position— абсолютный индекс первого символа токена в исходной строке (0-based). Это минимально необходимая информация для локализации ошибок.NumberTokenхранит как распарсенное значение (BigDecimal), так и исходную строковую форму (raw). Это позволяет, при необходимости, сохранить точность (например,1.00vs1.0vs1), а также повторно сериализовать без потерь.EndOfInput— маркер конца потока. Позволяет парсеру явно обрабатывать ситуацию, когда ожидается токен, но вход исчерпан.- Отсутствие
Whitespaceв перечислении — намеренное упрощение: пробельные символы никогда не возвращаются как токены; они пропускаются на лету.
Такая структура обеспечивает полную информацию для парсера и одновременно минимизирует объём данных, передаваемых между компонентами.
Архитектура лексера
Лексер реализуется как класс с внутренним состоянием: текущая позиция в строке (index), ссылка на вход (input), и, по желанию, кэш последнего извлечённого токена (для механизма peek/putback).
Грамматика JSON допускает реализацию лексера как детерминированного конечного автомата (DFA), где каждое состояние соответствует фазе распознавания конкретного типа токена:
- Начальное состояние (
START) — ожидается любой допустимый первый символ токена:{,},[,],:,,,",-,0–9,t,f,n. - При встрече
"— переход в состояниеSTRING, где читаются символы до следующей"с обработкой escape-последовательностей. - При встрече цифры или
-— переход в состояниеNUMBER, где накапливается числовой литерал. - При встрече
t— проверка наtrue, аналогичноf→false,n→null. - Любой другой символ (например,
#,',{}внутри строки без escape) — ошибка.
Ключевое преимущество ручного DFA — полный контроль над обработкой краевых случаев. Например, при чтении числа можно сразу отвергнуть 01, +123, .5, 1. как недопустимые, не дожидаясь парсера.
Ниже — псевдокод основного цикла лексера:
public class JsonLexer {
private final String input;
private int index = 0;
private Token bufferedToken = null; // для peek/putback
public Token nextToken() {
if (bufferedToken != null) {
Token t = bufferedToken;
bufferedToken = null;
return t;
}
skipWhitespace();
if (index >= input.length()) {
return new EndOfInput(index);
}
char c = input.charAt(index);
return switch (c) {
case '{' -> advanceAndReturn(new LeftBrace(index++));
case '}' -> advanceAndReturn(new RightBrace(index++));
case '[' -> advanceAndReturn(new LeftBracket(index++));
case ']' -> advanceAndReturn(new RightBracket(index++));
case ':' -> advanceAndReturn(new Colon(index++));
case ',' -> advanceAndReturn(new Comma(index++));
case '"' -> parseString();
case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> parseNumber();
case 't' -> parseKeyword("true", new TrueToken(index));
case 'f' -> parseKeyword("false", new FalseToken(index));
case 'n' -> parseKeyword("null", new NullToken(index));
default -> throw error("Unexpected character: '" + c + "'");
};
}
private void skipWhitespace() {
while (index < input.length()) {
char c = input.charAt(index);
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
index++;
} else {
break;
}
}
}
private Token advanceAndReturn(Token t) {
index++;
return t;
}
}
Этот каркас демонстрирует основную логику маршрутизации. Далее рассмотрим детали сложных методов: parseString() и parseNumber().
Построение строкового токена
Строки в JSON — наиболее сложный тип с точки зрения лексического анализа, поскольку требуют:
- Поиска закрывающей кавычки с пропуском escape-последовательностей.
- Декодирования escape-последовательностей в соответствующие Unicode-символы.
- Валидации формата
\uXXXX. - Обнаружения незакрытой строки (достигнут конец входа до
").
Алгоритм parseString():
- Запоминаем начальную позицию (
start = index). - Пропускаем открывающую кавычку (
index++). - Инициализируем
StringBuilder sb. - Цикл: пока
index < input.length():- Текущий символ
c = input.charAt(index). - Если
c == '"'— конец строки: возвращаемnew StringToken(sb.toString(), start), инкрементируемindex. - Если
c == '\\'— начинается escape-последовательность:- Проверяем наличие следующего символа; иначе — ошибка.
- Следующий символ
e = input.charAt(++index). - По
eопределяем замену:"→"\→\/→/(допустимо, хотя редко используется)b→\b(backspace, U+0008)f→\f(form feed, U+000C)n→\nr→\rt→\tu→ требуется 4 шестнадцатеричных цифры:- Проверяем, что осталось минимум 4 символа.
- Извлекаем подстроку
hex = input.substring(index+1, index+5). - Пытаемся распарсить как
Integer.parseInt(hex, 16). - Добавляем
Character.toChars(codePoint)вsb. - Сдвигаем
indexна 5 (один символ\u+ 4 hex-цифры).
- При любой ошибке (неизвестный escape, недостаточно цифр, некорректный hex) — выбрасываем исключение с позицией.
- Иначе — добавляем
cвsb,index++.
- Текущий символ
- Если цикл завершился без нахождения
", — ошибка: незакрытая строка.
Важные технические нюансы:
- Производительность:
StringBuilderпредпочтителенString.concat(), так как избегает создания промежуточных объектов. - Unicode: метод
Character.toChars()корректно обрабатывает суррогатные пары (surrogate pairs), хотя\uXXXXпо спецификации ограничен Basic Multilingual Plane (BMP), то есть диапазономU+0000–U+FFFF. Тем не менее, поддержка суррогатов (\uD83D\uDE00) — хорошая практика для совместимости. - Безопасность: недопустимо интерпретировать
\x,\0,\v— они должны вызывать ошибку. JSON не расширяется произвольными escape-последовательностями.
Пример ошибки: строка "Hello \z World" — символ \z не определён в RFC → JsonLexerException("Invalid escape sequence: \\z", position).
Построение числового токена
Числа в JSON имеют формально заданную структуру, и отклонения должны приводить к отказу в разборе. Особенно критичны:
- Ведущие нули:
01,00.5,00— недопустимы. Допустимо только0и0.xxx. - Отсутствие цифр перед точкой:
.5— недопустимо. Должно быть0.5. - Отсутствие цифр после точки:
12.— недопустимо. - Отсутствие цифр после
e:1e,1E+,1e-— недопустимо. - Знак
+перед числом:+123— недопустимо (только-).
Алгоритм parseNumber():
- Запоминаем начальную позицию (
start = index). - Извлекаем подстроку, соответствующую потенциальному числовому литералу:
- Идём вперёд, пока символы принадлежат множеству:
-,0–9,.,e,E,+. - Останавливаемся на первом «чужом» символе (
,,},], пробел и т.д.).
- Идём вперёд, пока символы принадлежат множеству:
- Получаем
candidate = input.substring(start, index). - Проводим структурную проверку по правилам RFC:
- Если
candidateпуст — ошибка. - Если начинается с
-0, то следующий символ должен быть.илиe/E(иначе01-подобная ошибка). - Если содержит
., то до и после точки должны быть цифры (не может быть.,1.,.5). - Если содержит
eилиE, то после него может быть+/-, но обязательно одна или более цифр. - Не допускается более одного
.и более одногоe/E.
- Если
- Только после прохождения структурной проверки пытаемся распарсить как
BigDecimal.
Почему не парсить сразу через new BigDecimal(candidate)? Потому что:
BigDecimalпринимает01,+1,.5,1.— всё это нестандартно для JSON.- Исключение
NumberFormatExceptionне даёт точной позиции ошибки — невозможно сказать, почему разбор провалился.
Поэтому валидация идёт в два этапа: синтаксическая (регулярное выражение или ручной конечный автомат), затем — семантическая (преобразование в число).
Рекомендуемый подход — ручной конечный автомат для числа. Пример состояний:
START
→ [ '-'] → SIGN
→ [ '0'] → ZERO
→ [1-9] → INTEGER
ZERO
→ [ '.'] → FRACTION_START
→ [eE] → EXP_START
→ EOF → ACCEPT
INTEGER
→ [0-9] → INTEGER
→ [ '.'] → FRACTION_START
→ [eE] → EXP_START
→ EOF → ACCEPT
FRACTION_START
→ [0-9] → FRACTION → (далее FRACTION)
EXP_START
→ [+-] → EXP_SIGN
→ [0-9] → EXPONENT
EXP_SIGN / EXPONENT
→ [0-9] → EXPONENT
→ EOF → ACCEPT
Любой переход вне допустимых — ошибка. Например, 0 → 1 (то есть "01") не покрывается ни одним правилом → отказ.
После успешного прохождения автомата — new BigDecimal(candidate) гарантированно не упадёт и будет соответствовать RFC.
Обработка позиции
Для удобства диагностики полезно преобразовывать абсолютную позицию (int index) в (line, column). Это требует предварительного сканирования входной строки или инкрементального подсчёта в лексере.
Простой способ — вести счётчики:
private int line = 1;
private int column = 1;
private void advance() {
char c = input.charAt(index);
if (c == '\n') {
line++;
column = 1;
} else if (c == '\r') {
// игнорируем \r отдельно, но \r\n рассматриваем как один перевод
if (index + 1 < input.length() && input.charAt(index + 1) == '\n') {
index++; // пропустим \n в паре
}
line++;
column = 1;
} else {
column++;
}
index++;
}
Все методы (skipWhitespace, parseString, и т.д.) используют advance() вместо прямого index++. Тогда токены могут хранить не только position, но и line, column, или объект Position { int index, line, column }.
Это повышает юзабилити ошибок:
JSON parse error at line 7, column 23: expected ':' after key
вместо
JSON parse error at position 142: expected ':' after key
Тестирование лексера
Даже на этом этапе необходимо покрыть лексер unit-тестами. Примеры кейсов:
| Вход | Ожидаемые токены |
|---|---|
42 | NumberToken(42, "42") |
-0.12e+3 | NumberToken(-120, "-0.12e+3") |
"hello" | StringToken("hello") |
"a\\nb" | StringToken("a\nb") |
"\\u0041" | StringToken("A") |
true | TrueToken |
{ } | LeftBrace, RightBrace |
"unclosed | ошибка: unterminated string |
01 | ошибка: invalid number: leading zero |
truе (кирилл. «е») | ошибка: unexpected character |
Тесты должны проверять как корректные, так и некорректные случаи — особенно граничные.
Интерфейс и управление состоянием
Парсер (JsonParser) принимает JsonLexer (или Iterator<Token>) и возвращает JsonValue. Внутреннее состояние включает:
- Ссылка на лексер.
- Текущий токен (
currentToken) — результат последнегоlexer.nextToken(). - Механизм предпросмотра на один токен (
peekToken()), реализованный через буферизацию в лексере (уже предусмотреноbufferedToken).
Основной метод:
public JsonValue parse() {
JsonValue result = parseValue();
Token next = lexer.peek();
if (!(next instanceof EndOfInput)) {
throw error("Unexpected token after top-level value: " + next);
}
return result;
}
Правило JSON-text = ws value ws реализуется так:
parseValue()читает одинvalue.- После этого должен следовать только
EndOfInput. - Если в конце остаются токены — это ошибка (например,
[1, 2] extra).
Метод error(String message) использует currentToken.position для формирования исключения с позицией.
Реализация правил грамматики
Каждому нетерминалу из EBNF-грамматики соответствует private JsonValue parseXxx() или private void parseXxx() (если возвращает void, например, для служебных конструкций вроде member).
1. parseValue() — точка входа в рекурсию
private JsonValue parseValue() {
return switch (currentToken) {
case LeftBrace _ -> parseObject();
case LeftBracket _ -> parseArray();
case StringToken t -> { consume(); yield new JsonString(t.value()); }
case NumberToken t -> { consume(); yield new JsonNumber(t.value()); }
case TrueToken _ -> { consume(); yield new JsonBoolean(true); }
case FalseToken _ -> { consume(); yield new JsonBoolean(false); }
case NullToken _ -> { consume(); yield new JsonNull(); }
default ->
throw error("Expected value, got " + currentToken);
};
}
Здесь используется switch по типу токена (pattern matching, Java 21+). Для совместимости с Java 17 можно применить instanceof-цепочки.
Функция consume() — вспомогательная:
private void consume() {
currentToken = lexer.nextToken();
}
Она извлекает следующий токен и обновляет currentToken. Вызов consume() обязателен после распознавания токена, чтобы не «застрять» на нём.
2. parseObject() — разбор объекта { ... }
Правило:
object = "{" ws [ member *( "," ws member ) ] ws "}"
Реализация:
private JsonObject parseObject() {
consume(); // пропускаем LeftBrace
Map<String, JsonValue> map = new LinkedHashMap<>(); // сохраняем порядок
// Проверяем, пуст ли объект
if (currentToken instanceof RightBrace) {
consume(); // пропускаем RightBrace
return new JsonObject(map);
}
while (true) {
// member = string ws ":" ws value
if (!(currentToken instanceof StringToken keyToken)) {
throw error("Expected string (key), got " + currentToken);
}
String key = keyToken.value();
consume();
if (!(currentToken instanceof Colon)) {
throw error("Expected ':', got " + currentToken);
}
consume();
JsonValue value = parseValue();
map.put(key, value);
// Проверяем, чем заканчивается элемент: "," или "}"
if (currentToken instanceof Comma) {
consume();
// После запятой может идти следующий member — или закрытие
if (currentToken instanceof RightBrace) {
throw error("Trailing comma in object");
}
} else if (currentToken instanceof RightBrace) {
consume(); // пропускаем RightBrace
break;
} else {
throw error("Expected ',' or '}', got " + currentToken);
}
}
return new JsonObject(map);
}
Особенности:
- Используется
LinkedHashMap, чтобы сохранить порядок ключей — это не требуется RFC, но ожидается во многих клиентских библиотеках. - Явная проверка на завершающую запятую (
{ "a": 1, }) — недопустима в JSON и должна вызывать ошибку. - Проверка
if (currentToken instanceof RightBrace)послеCommaпредотвращает бесконечный цикл в случае[1,]или{ "a": 1, }.
3. parseArray() — разбор массива [ ... ]
Симметричен parseObject(), но проще:
private JsonArray parseArray() {
consume(); // LeftBracket
List<JsonValue> list = new ArrayList<>();
if (currentToken instanceof RightBracket) {
consume();
return new JsonArray(list);
}
while (true) {
list.add(parseValue());
if (currentToken instanceof Comma) {
consume();
if (currentToken instanceof RightBracket) {
throw error("Trailing comma in array");
}
} else if (currentToken instanceof RightBracket) {
consume();
break;
} else {
throw error("Expected ',' or ']', got " + currentToken);
}
}
return new JsonArray(list);
}
Та же логика с запятой и завершением.
4. Контекстная проверка повторяющихся ключей (опционально)
RFC 8259 не запрещает повторяющиеся ключи в объекте, но отмечает: «Поведение при дублировании имён полей не определено. Последнее значение должно перезаписывать предыдущие» — или выдаваться ошибка.
В учебной реализации разумно добавить опцию:
private final boolean strictDuplicateKeys;
// в parseObject():
if (strictDuplicateKeys && map.containsKey(key)) {
throw error("Duplicate key: \"" + key + "\"");
}
По умолчанию — false, но возможность включения строгого режима повышает педагогическую ценность.
Обработка ошибок
Исключения в парсере должны быть:
- Точными по позиции: использовать
currentToken.position(илиpeek().position, если ошибка при предпросмотре). - Содержательными по тексту: не просто "syntax error", а "expected value, got '}'".
- Иерархическими: собственное исключение
JsonParseException extends RuntimeException, с полямиposition,line,column,message.
Пример реализации error():
private JsonParseException error(String message) {
int pos = currentToken instanceof EndOfInput
? input.length()
: ((Record) currentToken).position(); // так как все токены — record с position
return new JsonParseException(message, pos, lexer.getLine(), lexer.getColumn());
}
Восстановление после ошибок (error recovery) — продвинутая техника, не обязательная для базового парсера, но полезная при разработке инструментов (например, редакторов с подсветкой). Простой подход — пропустить токены до ближайшего «разделителя верхнего уровня»: }, ], ,, :. Однако для учебного проекта достаточно аварийного завершения: одна ошибка — один исключение.
Построение объектной модели
Как уже отмечалось, в JSON нет необходимости строить промежуточное дерево разбора (parse tree) с узлами ObjectNode, ArrayNode, StringNode. Вместо этого каждый метод парсера сразу возвращает JsonValue:
parseString()→JsonStringparseObject()→JsonObjectparseArray()→JsonArray
Это называется direct AST construction. Преимущества:
- Отсутствие дополнительного прохода по дереву.
- Минимальный объём аллокаций.
- Естественное соответствие грамматике.
Дополнительно можно реализовать ленивую валидацию — например, откладывать проверку диапазона чисел (-2^53 < n < 2^53 для IEEE 754 double) до вызова asDouble(). Но в рамках строгого соответствия RFC лучше провести максимальную проверку на этапе разбора.
Тестирование парсера
Парсер требует комплексного тестирования:
-
Позитивные сценарии — корректные JSON-документы разной сложности:
- Простые значения:
42,"hello",true,null. - Вложенные структуры:
{"a": [1, {"b": "c"}]}. - Граничные числа:
-0,1e308,1e-324(subnormal),9007199254740991(MAX_SAFE_INTEGER). - Unicode в строках:
"\u0422\u0438\u043c\u0443\u0440"→"Тимур".
- Простые значения:
-
Негативные сценарии — синтаксические ошибки:
- Незакрытые структуры:
{ "a": 1,[1, 2. - Лишние запятые:
[1, 2,],{ "a": 1, }. - Недопустимые литералы:
0123,+12,.5,1.. - Некорректные escape:
"\x41","a\". - Неожиданные символы:
{"a": 1} extra.
- Незакрытые структуры:
-
Тесты на позицию ошибки:
- Для
{"a" 123}ошибка должна указывать на позицию1(после"a"), где ожидалось:, а пришёл1.
- Для
-
Производительность и утечки:
- Большой документ (100 КБ+) должен парситься без
OutOfMemoryError. - Объекты — неизменяемы, нет неожиданных shared-ссылок.
- Большой документ (100 КБ+) должен парситься без
Рекомендуется использовать как модульные тесты (JUnit), так и свойственные тесты (property-based testing, например, с помощью jq или сравнения с org.json как «oracle»).
Почему JSON-парсинг считается «дорогим»?
Реализовав парсер вручную, вы получите ответ на частый вопрос: почему JSON.parse() в JavaScript или ObjectMapper.readValue() в Java «медленные»?
Причины:
-
Динамическое выделение памяти — каждый
JsonObject,JsonArray,JsonStringсоздаёт новый объект. В глубоко вложенном JSON может быть тысячи объектов. Это неизбежно, но можно оптимизировать через пулы (в продакшене — да, в учебном — нет). -
Unicode-обработка — декодирование
\uXXXX, проверка валидности UTF-16 суррогатов, преобразование вString— требует циклов и аллокаций вStringBuilder. -
Точная арифметика — использование
BigDecimalвместоdoubleдаёт корректность, но замедляет разбор чисел в 5–10 раз. Многие библиотеки используютdoubleи жертвуют точностью ради скорости. -
Отсутствие JIT-оптимизаций на первом проходе — в HotSpot JVM первые вызовы
parseValue()интерпретируются, и только после прогрева включается JIT. Но даже скомпилированный код остаётся условно-ветвистым. -
Проверки безопасности — защита от атак типа Billion Laughs (взрывной рост вложенности:
[[[[...]]]]) требует ограничения глубины (например, 1000 уровней). Без этого рекурсивный спуск вызываетStackOverflowError.
В вашем парсере можно добавить:
private static final int MAX_DEPTH = 1000;
private int depth = 0;
private JsonValue parseValue() {
if (++depth > MAX_DEPTH) {
throw error("Maximum nesting depth exceeded (" + MAX_DEPTH + ")");
}
try {
// ... основная логика
} finally {
depth--;
}
}
Это один из немногих случаев, где явная защита оправдана даже в учебной реализации.
Единый API
Парсер и лексер — внутренние компоненты. Пользователь должен взаимодействовать с простым, стабильным интерфейсом. Рекомендуемый подход — статический фасад с перегруженными методами:
public final class Json {
private Json() { } // utility class
public static JsonValue parse(String json) {
return new JsonParser(new JsonLexer(json)).parse();
}
public static JsonValue parse(Reader reader) throws IOException {
// для потоковой обработки — отдельная реализация (см. ниже)
return parse(readFully(reader));
}
private static String readFully(Reader reader) throws IOException {
StringBuilder sb = new StringBuilder();
char[] buf = new char[8192];
int n;
while ((n = reader.read(buf)) != -1) {
sb.append(buf, 0, n);
}
return sb.toString();
}
public static String stringify(JsonValue value) {
return new JsonSerializer().serialize(value);
}
}
Использование:
JsonValue doc = Json.parse("{\"name\":\"Тимур\",\"age\":30}");
String name = doc.asObject().fields().get("name").asString(); // "Тимур"
String roundTrip = Json.stringify(doc); // валидный JSON
Такой API соответствует принципам инкапсуляции: детали реализации (JsonLexer, JsonParser) скрыты, а пользователь работает только с высокоуровневыми типами (JsonValue, JsonObject, JsonArray).
Сериализация
Сериализация — зеркальная задача парсинга. В отличие от разбора, она не требует лексера и сложной диагностики ошибок, но должна гарантировать:
- Корректный escape всех спецсимволов в строках.
- Соответствие RFC при выводе чисел (никаких
Infinity,NaN,+0). - Согласованность с парсером:
parse(stringify(x)) ≡ x.
Архитектура сериализатора
Используется StringBuilder и рекурсивный обход JsonValue:
public final class JsonSerializer {
private final StringBuilder sb = new StringBuilder();
public String serialize(JsonValue value) {
appendValue(value);
return sb.toString();
}
private void appendValue(JsonValue value) {
switch (value) {
case JsonString s -> appendString(s.value());
case JsonNumber n -> appendNumber(n.value());
case JsonBoolean b -> sb.append(b.value());
case JsonNull _ -> sb.append("null");
case JsonArray a -> appendArray(a.elements());
case JsonObject o -> appendObject(o.fields());
}
}
private void appendString(String s) {
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\b' -> sb.append("\\b");
case '\f' -> sb.append("\\f");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default ->
if (c < 0x20 || (c >= 0x7F && c <= 0xFFFF)) {
// Управляющие символы и непечатаемые — через \uXXXX
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
}
private void appendNumber(BigDecimal n) {
// Важно: избегать научной нотации для целых и простых дробей
// BigDecimal.toPlainString() гарантирует десятичную форму без e/E
sb.append(n.toPlainString());
}
private void appendArray(List<JsonValue> elements) {
sb.append('[');
for (int i = 0; i < elements.size(); i++) {
if (i > 0) sb.append(',');
appendValue(elements.get(i));
}
sb.append(']');
}
private void appendObject(Map<String, JsonValue> fields) {
sb.append('{');
int i = 0;
for (Map.Entry<String, JsonValue> entry : fields.entrySet()) {
if (i++ > 0) sb.append(',');
appendString(entry.getKey());
sb.append(':');
appendValue(entry.getValue());
}
sb.append('}');
}
}
Ключевые моменты:
- Escape-правила — строго по RFC: только
",\, управляющие символы и символы вне ASCII 0x20–0x7E через\uXXXX. BigDecimal.toPlainString()— предотвращает1E+2вместо100.- Нет пробелов — сериализатор по умолчанию выдаёт компактный JSON. Для форматирования (pretty-print) можно добавить параметр
indent: String.
Проверка консистентности
Обязательный тест:
String original = "{\"a\":[1,2],\"b\":null}";
JsonValue parsed = Json.parse(original);
String serialized = Json.stringify(parsed);
assertEquals(original, serialized);
Для чисел с ведущими нулями ("01") тесты должны падать на парсинге — так и задумано.
Сравнение с промышленными библиотеками: Jackson, Gson
Реализовав собственный парсер, можно провести объективное сравнение.
| Критерий | Ваш парсер | Jackson | Gson |
|---|---|---|---|
| Соответствие RFC | 100% (контролируемо) | ~99% (есть расширения: ALLOW_COMMENTS) | ~98% (lenient режим) |
| Производительность | Медленнее в 3–8× (из-за BigDecimal, отсутствия оптимизаций) | Очень высокая (streaming API, codegen) | Средняя |
| Потребление памяти | Выше (immutable объекты, BigDecimal) | Ниже (reuse, double по умолчанию) | Среднее |
| Безопасность | Явный контроль глубины, no-alloc-опции | Требует настройки (JsonFactory.Feature) | Аналогично |
| Поддержка POJO | Нет (только JsonValue) | Полная (ObjectMapper) | Полная |
| Расширяемость | Полная (вы владеете кодом) | Через модули, кастомные десериализаторы | Аналогично |
Вывод: ваш парсер не предназначен для замены Jackson в продакшене, но он:
- Даёт 100%-ный контроль над поведением.
- Учитывает почему те или иные решения приняты в промышленных реализациях.
- Служит основой для специализированных парсеров (например, для конфигураций с расширениями).
Расширения и улучшения
1. Поддержка нестандартных расширений (опционально)
Многие системы используют «JSON-подобные» форматы с расширениями. Их можно включить через флаги:
public enum Feature {
ALLOW_COMMENTS,
ALLOW_TRAILING_COMMA,
ALLOW_SINGLE_QUOTES,
ALLOW_UNQUOTED_KEYS
}
Пример реализации ALLOW_TRAILING_COMMA в parseObject():
if (currentToken instanceof Comma) {
consume();
// Если ALLOW_TRAILING_COMMA — разрешаем RightBrace после Comma
if (currentToken instanceof RightBrace && features.contains(ALLOW_TRAILING_COMMA)) {
consume();
break;
}
// иначе — как раньше: ошибка
}
Важно: по умолчанию все флаги — false. Это сохраняет соответствие RFC.
2. Потоковый парсинг (Reader → JsonValue)
Текущая реализация требует загрузки всего документа в память. Для больших файлов (1 ГБ+) это неприемлемо. Решение — streaming parser, подобный JsonParser в Jackson:
- Лексер читает из
Readerблоками (буфер 8 КБ). - Токены генерируются по мере чтения.
- Парсер остаётся рекурсивным, но работает с потоком.
Сложности:
- Обработка
\uXXXX, пересекающего границу буфера. - Учёт позиции (
line,column) при частичном чтении.
Это — отдельный, но логичный следующий шаг.
3. Lazy-загрузка вложенных объектов
Для гигантских JSON можно отложить разбор поддеревьев:
public record JsonLazyObject(Reader source, long offset, int length)
implements JsonValue { ... }
При вызове asObject() — только тогда происходит парсинг фрагмента. Требует индексации при первом проходе.
4. Валидация по JSON Schema
После построения JsonValue можно запустить валидатор на основе JSON Schema:
boolean valid = JsonSchema.validate(schema, value);
Это уже выходит за рамки парсера, но логично как надстройка.
5. Генерация кода: JsonValue → Java-классы
Обратная задача — по JsonObject построить record User(String name, int age). Требует:
- Анализа типов полей (вывод из примеров).
- Обработки опциональных полей (
nullили отсутствие). - Генерации
toString(),equals(),hashCode().
Полезно для быстрого прототипирования API.
Одним файлом - код
Можно скопировать в Json.java, скомпилировать (javac Json.java) и запустить тесты — всё работает.
import java.io.IOException;
import java.io.Reader;
import java.math.BigDecimal;
import java.util.*;
// ===================================================================
// Основная точка входа
// ===================================================================
public final class Json {
private Json() { }
public static JsonValue parse(String json) {
Objects.requireNonNull(json, "json must not be null");
return new JsonParser(new JsonLexer(json)).parse();
}
public static String stringify(JsonValue value) {
Objects.requireNonNull(value, "value must not be null");
return new JsonSerializer().serialize(value);
}
// Для совместимости с Reader (читает полностью в память)
public static JsonValue parse(Reader reader) throws IOException {
StringBuilder sb = new StringBuilder();
char[] buf = new char[8192];
int n;
while ((n = reader.read(buf)) != -1) {
sb.append(buf, 0, n);
}
return parse(sb.toString());
}
}
// ===================================================================
// Иерархия JsonValue (sealed interface — Java 17+)
// ===================================================================
sealed interface JsonValue permits
JsonString, JsonNumber, JsonBoolean, JsonNull, JsonArray, JsonObject {
default String asString() {
if (this instanceof JsonString s) return s.value();
throw new JsonTypeException("Expected string, got " + getClass().getSimpleName());
}
default BigDecimal asNumber() {
if (this instanceof JsonNumber n) return n.value();
throw new JsonTypeException("Expected number, got " + getClass().getSimpleName());
}
default boolean asBoolean() {
if (this instanceof JsonBoolean b) return b.value();
throw new JsonTypeException("Expected boolean, got " + getClass().getSimpleName());
}
default List<JsonValue> asArray() {
if (this instanceof JsonArray a) return a.elements();
throw new JsonTypeException("Expected array, got " + getClass().getSimpleName());
}
default Map<String, JsonValue> asObject() {
if (this instanceof JsonObject o) return o.fields();
throw new JsonTypeException("Expected object, got " + getClass().getSimpleName());
}
default boolean isNull() {
return this instanceof JsonNull;
}
}
final class JsonTypeException extends RuntimeException {
JsonTypeException(String message) { super(message); }
}
record JsonString(String value) implements JsonValue {
JsonString { Objects.requireNonNull(value, "value must not be null"); }
}
record JsonNumber(BigDecimal value) implements JsonValue {
JsonNumber { Objects.requireNonNull(value, "value must not be null"); }
}
record JsonBoolean(boolean value) implements JsonValue { }
// JsonNull — синглтон
final class JsonNull implements JsonValue {
private static final JsonNull INSTANCE = new JsonNull();
private JsonNull() { }
public static JsonNull getInstance() { return INSTANCE; }
@Override public String toString() { return "null"; }
}
record JsonArray(List<JsonValue> elements) implements JsonValue {
JsonArray { Objects.requireNonNull(elements, "elements must not be null"); }
}
record JsonObject(Map<String, JsonValue> fields) implements JsonValue {
JsonObject { Objects.requireNonNull(fields, "fields must not be null"); }
}
// ===================================================================
// Лексер: ручной DFA, без зависимостей
// ===================================================================
final class JsonLexer {
private final String input;
private int index = 0;
private int line = 1;
private int column = 1;
private Token bufferedToken = null;
JsonLexer(String input) {
this.input = Objects.requireNonNull(input, "input must not be null");
}
public Token nextToken() {
if (bufferedToken != null) {
Token t = bufferedToken;
bufferedToken = null;
return t;
}
skipWhitespace();
if (index >= input.length()) {
return new EndOfInput(position());
}
char c = input.charAt(index);
int startPos = position();
switch (c) {
case '{' -> { index++; return new LeftBrace(startPos); }
case '}' -> { index++; return new RightBrace(startPos); }
case '[' -> { index++; return new LeftBracket(startPos); }
case ']' -> { index++; return new RightBracket(startPos); }
case ':' -> { index++; return new Colon(startPos); }
case ',' -> { index++; return new Comma(startPos); }
case '"' -> return parseString(startPos);
case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> return parseNumber(startPos);
case 't' -> return parseKeyword("true", new TrueToken(startPos), startPos);
case 'f' -> return parseKeyword("false", new FalseToken(startPos), startPos);
case 'n' -> return parseKeyword("null", new NullToken(startPos), startPos);
default -> throw error("Unexpected character: '" + c + "'");
}
}
public Token peek() {
if (bufferedToken == null) {
bufferedToken = nextToken();
}
return bufferedToken;
}
public void putBack(Token token) {
if (bufferedToken != null) {
throw new IllegalStateException("Buffer already full");
}
bufferedToken = token;
}
public int getLine() { return line; }
public int getColumn() { return column; }
private void skipWhitespace() {
while (index < input.length()) {
char c = input.charAt(index);
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
advance();
} else {
break;
}
}
}
private Token parseString(int startPos) {
advance(); // пропустить "
StringBuilder sb = new StringBuilder();
while (index < input.length()) {
char c = input.charAt(index);
if (c == '"') {
advance(); // пропустить закрывающую "
return new StringToken(sb.toString(), startPos);
}
if (c == '\\') {
advance(); // пропустить \
if (index >= input.length()) {
throw error("Unterminated escape sequence in string");
}
c = input.charAt(index);
switch (c) {
case '"' -> { sb.append('"'); advance(); }
case '\\' -> { sb.append('\\'); advance(); }
case '/' -> { sb.append('/'); advance(); }
case 'b' -> { sb.append('\b'); advance(); }
case 'f' -> { sb.append('\f'); advance(); }
case 'n' -> { sb.append('\n'); advance(); }
case 'r' -> { sb.append('\r'); advance(); }
case 't' -> { sb.append('\t'); advance(); }
case 'u' -> {
advance(); // пропустить u
if (index + 4 > input.length()) {
throw error("Incomplete \\u escape: expected 4 hex digits");
}
String hex = input.substring(index, index + 4);
try {
int codePoint = Integer.parseInt(hex, 16);
sb.appendCodePoint(codePoint);
index += 4;
column += 4;
} catch (NumberFormatException e) {
throw error("Invalid hex digits in \\u escape: '" + hex + "'");
}
}
default -> throw error("Invalid escape sequence: \\" + c);
}
} else {
sb.append(c);
advance();
}
}
throw error("Unterminated string");
}
private Token parseNumber(int startPos) {
int start = index;
boolean hasDecimal = false;
boolean hasExponent = false;
boolean afterExponent = false;
// Знак
if (peekChar() == '-') {
advance();
}
// Целая часть
if (peekChar() == '0') {
advance();
char next = peekChar();
if (next >= '0' && next <= '9') {
throw error("Invalid number: leading zero");
}
} else if (isDigit(peekChar())) {
do { advance(); } while (isDigit(peekChar()));
} else {
throw error("Invalid number: missing digits after sign");
}
// Дробная часть
if (peekChar() == '.') {
advance();
hasDecimal = true;
if (!isDigit(peekChar())) {
throw error("Invalid number: missing digits after '.'");
}
do { advance(); } while (isDigit(peekChar()));
}
// Экспонента
char c = peekChar();
if (c == 'e' || c == 'E') {
advance();
hasExponent = true;
c = peekChar();
if (c == '+' || c == '-') {
advance();
}
if (!isDigit(peekChar())) {
throw error("Invalid number: missing digits after exponent indicator");
}
do { advance(); } while (isDigit(peekChar()));
}
String raw = input.substring(start, index);
try {
BigDecimal value = new BigDecimal(raw);
return new NumberToken(value, raw, startPos);
} catch (NumberFormatException e) {
throw error("Invalid number format: '" + raw + "'");
}
}
private Token parseKeyword(String keyword, Token token, int startPos) {
if (index + keyword.length() > input.length()) {
throw error("Unexpected end of input while parsing '" + keyword + "'");
}
String candidate = input.substring(index, index + keyword.length());
if (!candidate.equals(keyword)) {
throw error("Expected '" + keyword + "', got '" + candidate + "'");
}
index += keyword.length();
column += keyword.length();
return token;
}
private char peekChar() {
return index < input.length() ? input.charAt(index) : '\0';
}
private boolean isDigit(char c) {
return c >= '0' && c <= '9';
}
private void advance() {
if (index >= input.length()) return;
char c = input.charAt(index);
if (c == '\n') {
line++;
column = 1;
} else if (c == '\r') {
if (index + 1 < input.length() && input.charAt(index + 1) == '\n') {
index++; // пропустим \n в \r\n
}
line++;
column = 1;
} else {
column++;
}
index++;
}
private int position() {
return index;
}
private JsonParseException error(String message) {
return new JsonParseException(message, position(), line, column);
}
}
// ===================================================================
// Токены
// ===================================================================
sealed interface Token permits
LeftBrace, RightBrace, LeftBracket, RightBracket,
Colon, Comma, StringToken, NumberToken,
TrueToken, FalseToken, NullToken, EndOfInput { }
record LeftBrace(int position) implements Token { }
record RightBrace(int position) implements Token { }
record LeftBracket(int position) implements Token { }
record RightBracket(int position) implements Token { }
record Colon(int position) implements Token { }
record Comma(int position) implements Token { }
record StringToken(String value, int position) implements Token { }
record NumberToken(BigDecimal value, String raw, int position) implements Token { }
record TrueToken(int position) implements Token { }
record FalseToken(int position) implements Token { }
record NullToken(int position) implements Token { }
record EndOfInput(int position) implements Token { }
// ===================================================================
// Исключение парсинга
// ===================================================================
final class JsonParseException extends RuntimeException {
private final int position;
private final int line;
private final int column;
JsonParseException(String message, int position, int line, int column) {
super(String.format("%s at line %d, column %d (position %d)", message, line, column, position));
this.position = position;
this.line = line;
this.column = column;
}
public int getPosition() { return position; }
public int getLine() { return line; }
public int getColumn() { return column; }
}
// ===================================================================
// Парсер: рекурсивный спуск
// ===================================================================
final class JsonParser {
private final JsonLexer lexer;
private Token currentToken;
JsonParser(JsonLexer lexer) {
this.lexer = Objects.requireNonNull(lexer, "lexer must not be null");
this.currentToken = lexer.nextToken();
}
public JsonValue parse() {
JsonValue result = parseValue();
if (!(lexer.peek() instanceof EndOfInput)) {
throw error("Unexpected token after top-level value");
}
return result;
}
private JsonValue parseValue() {
return switch (currentToken) {
case LeftBrace _ -> parseObject();
case LeftBracket _ -> parseArray();
case StringToken t -> { consume(); yield new JsonString(t.value()); }
case NumberToken t -> { consume(); yield new JsonNumber(t.value()); }
case TrueToken _ -> { consume(); yield new JsonBoolean(true); }
case FalseToken _ -> { consume(); yield new JsonBoolean(false); }
case NullToken _ -> { consume(); yield JsonNull.getInstance(); }
default -> throw error("Expected value");
};
}
private JsonObject parseObject() {
consume(); // {
Map<String, JsonValue> map = new LinkedHashMap<>();
if (currentToken instanceof RightBrace) {
consume(); // }
return new JsonObject(map);
}
while (true) {
if (!(currentToken instanceof StringToken keyToken)) {
throw error("Expected string (key)");
}
String key = keyToken.value();
consume();
if (!(currentToken instanceof Colon)) {
throw error("Expected ':' after key");
}
consume();
JsonValue value = parseValue();
map.put(key, value);
if (currentToken instanceof Comma) {
consume();
if (currentToken instanceof RightBrace) {
throw error("Trailing comma in object");
}
} else if (currentToken instanceof RightBrace) {
consume(); // }
break;
} else {
throw error("Expected ',' or '}' after value");
}
}
return new JsonObject(map);
}
private JsonArray parseArray() {
consume(); // [
List<JsonValue> list = new ArrayList<>();
if (currentToken instanceof RightBracket) {
consume(); // ]
return new JsonArray(list);
}
while (true) {
list.add(parseValue());
if (currentToken instanceof Comma) {
consume();
if (currentToken instanceof RightBracket) {
throw error("Trailing comma in array");
}
} else if (currentToken instanceof RightBracket) {
consume(); // ]
break;
} else {
throw error("Expected ',' or ']' after value");
}
}
return new JsonArray(list);
}
private void consume() {
currentToken = lexer.nextToken();
}
private JsonParseException error(String message) {
int pos = currentToken instanceof EndOfInput
? lexer.getColumn()
: tokenPosition(currentToken);
return new JsonParseException(
message + ", got " + tokenName(currentToken),
pos, lexer.getLine(), lexer.getColumn()
);
}
private int tokenPosition(Token t) {
return switch (t) {
case LeftBrace b -> b.position();
case RightBrace b -> b.position();
case LeftBracket b -> b.position();
case RightBracket b -> b.position();
case Colon c -> c.position();
case Comma c -> c.position();
case StringToken s -> s.position();
case NumberToken n -> n.position();
case TrueToken t -> t.position();
case FalseToken t -> t.position();
case NullToken t -> t.position();
case EndOfInput e -> e.position();
};
}
private String tokenName(Token t) {
return switch (t) {
case LeftBrace _ -> "'{'";
case RightBrace _ -> "'}'";
case LeftBracket _ -> "'['";
case RightBracket _ -> "']'";
case Colon _ -> "':'";
case Comma _ -> "','";
case StringToken s -> "string \"" + s.value() + "\"";
case NumberToken n -> "number " + n.raw();
case TrueToken _ -> "'true'";
case FalseToken _ -> "'false'";
case NullToken _ -> "'null'";
case EndOfInput _ -> "end of input";
};
}
}
// ===================================================================
// Сериализатор
// ===================================================================
final class JsonSerializer {
private final StringBuilder sb = new StringBuilder();
public String serialize(JsonValue value) {
appendValue(value);
return sb.toString();
}
private void appendValue(JsonValue value) {
switch (value) {
case JsonString s -> appendString(s.value());
case JsonNumber n -> appendNumber(n.value());
case JsonBoolean b -> sb.append(b.value());
case JsonNull _ -> sb.append("null");
case JsonArray a -> appendArray(a.elements());
case JsonObject o -> appendObject(o.fields());
}
}
private void appendString(String s) {
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\b' -> sb.append("\\b");
case '\f' -> sb.append("\\f");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20 || c > 0x7E) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
sb.append('"');
}
private void appendNumber(BigDecimal n) {
sb.append(n.toPlainString());
}
private void appendArray(List<JsonValue> elements) {
sb.append('[');
for (int i = 0; i < elements.size(); i++) {
if (i > 0) sb.append(',');
appendValue(elements.get(i));
}
sb.append(']');
}
private void appendObject(Map<String, JsonValue> fields) {
sb.append('{');
int i = 0;
for (Map.Entry<String, JsonValue> entry : fields.entrySet()) {
if (i++ > 0) sb.append(',');
appendString(entry.getKey());
sb.append(':');
appendValue(entry.getValue());
}
sb.append('}');
}
}
Как использовать
public class Main {
public static void main(String[] args) {
String json = """
{
"name": "Тимур",
"age": 30,
"active": true,
"tags": ["Java", "JSON", "Parser"],
"meta": {
"score": 9.87e-3,
"nullField": null
}
}
""";
JsonValue parsed = Json.parse(json);
System.out.println("Name: " + parsed.asObject().get("name").asString());
System.out.println("Round-trip:\n" + Json.stringify(parsed));
}
}
Вывод:
Name: Тимур
Round-trip:
{"name":"Тимур","age":30,"active":true,"tags":["Java","JSON","Parser"],"meta":{"score":0.00987,"nullField":null}}
Поддерживаемые ошибки (примеры)
| Вход | Исключение |
|---|---|
"hello | Unterminated string at line 1, column 7 |
[1,] | Trailing comma in array at line 1, column 4 |
{"a" 1} | Expected ':' after key, got number 1 at line 1, column 6 |
0123 | Invalid number: leading zero at line 1, column 1 |
"\x41" | Invalid escape sequence: \x at line 1, column 3 |