SilentJSON: не просто быстрый JSON-парсер для Go
- четверг, 12 ноября 2026 г. в 00:00:19
Доброго дня!
Этот релиз — не про очередные +10% скорости на микробенчмарках. Это про изменение подхода к тому, как Go-приложения работают с потоками данных
В этом обновлении основное внимание было уделено корректности работы с данными: улучшена поддержка типов, стала строже проверка экранирования, доработана обработка UTF-символов.
Фактически это важный этап развития библиотеки. SilentJSON стал инструментом, который можно уверенно использовать в production, а не только экспериментировать с ним в высоконагруженных приложениях.
Но сегодня я хочу рассказать не столько о самом релизе, сколько о возможностях библиотеки и о том, какие архитектурные решения можно построить на её основе.
Изначально библиотека создавалась для решения вполне конкретной задачи — уменьшить накладные расходы на обработку JSON в приложениях, где работа с данными становится одним из основных ограничителей производительности.
Однако со временем стало понятно, что дело не только в скорости парсинга.
В традиционной схеме обработки данных часто получается примерно следующее:
Получаем JSON.
Декодируем его в структуры или универсальные объекты.
Создаём промежуточные представления данных.
Обрабатываем полученную информацию.
Снова сериализуем результат в JSON.
Отправляем его дальше.
На каждом этапе могут появляться дополнительные выделения памяти, копирование данных и преобразования типов.
Особенно заметно это становится при обработке больших объёмов информации или большом количестве одновременных HTTP-запросов.
SilentJSON предлагает другой подход: по возможности работать непосредственно с исходными данными, не создавая лишних промежуточных объектов.
При этом библиотека не пытается заменить все инструменты стандартной библиотеки Go. Напротив, одна из её особенностей — возможность комбинировать разные способы обработки данных в рамках одного приложения.
Одна из интересных возможностей SilentJSON — поддержка RawMessage.
Она позволяет использовать вложенные JSON-данные как самостоятельные фрагменты, не превращая каждый из них в отдельную структуру Go.
Представим обычную ситуацию: у нас есть HTTP-сервис, который получает информацию о товарах из нескольких источников и формирует единый ответ.
type Product struct { ID int `json:"id"` Name string `json:"name"` Price float64 `json:"price"` Attributes silentjson.RawMessage `json:"attributes"` }
Допустим, данные приходят в таком виде:
{ "id": 1001, "name": "Smartphone", "price": 2499.00, "attributes": { "brand": "Example", "memory": "256GB", "color": "Black", "display": { "size": 6.7, "resolution": "2400x1080" } } }
Нам не обязательно создавать отдельные структуры Go для всех вложенных объектов, заранее знать все возможные поля и поддерживать их соответствие.
Мы можем работать с основной частью объекта как с обычной структурой, а вложенный JSON сохранить в RawMessage.
Например, следующий код разбирает массив товаров, изменяет основное поле и сериализует результат обратно:
var productRegistry = silentjson.BuildRegistry( reflect.TypeOf(Product{}), ) func processProducts(raw []byte, count int) ([]byte, error) { products := make([]Product, count) products, err := silentjson.UnmarshalSlice( raw, productRegistry, products, ) if err != nil { return nil, err } for i := range products { products[i].Name = strings.TrimSpace(products[i].Name) } result := make([]byte, 0, len(raw)) result = silentjson.MarshalSlice( products, productRegistry, result, ) return result, nil }
При этом attributes не превращается в набор промежуточных структур и не сериализуется как экранированная строка. Он остаётся самостоятельным JSON-значением.
Здесь важно понимать одну особенность реализации: при разборе RawMessage библиотека копирует соответствующий фрагмент в поле структуры. Это не zero-copy для самого RawMessage, но позволяет сохранить исходное представление вложенного JSON и избежать необходимости разбирать его на отдельные Go-объекты.
Это особенно полезно для агрегаторов, API-шлюзов, прокси-сервисов и систем интеграции, где структура данных может отличаться в зависимости от источника.
Один поставщик передаёт характеристики товара в виде плоского объекта, другой использует вложенные группы, третий добавляет собственные поля.
Вместо того чтобы пытаться привести всё к единой сложной структуре, можно сохранить часть информации в RawMessage и передать её дальше.
Таким образом, JSON становится не просто форматом сериализации объектов, а полноценным протоколом обмена данными между компонентами системы.
При этом ответственность за бизнес-валидацию и проверку допустимости данных остаётся на уровне приложения.
Ещё одна важная возможность — работа с потоками данных.
В традиционном подходе часто сначала получают весь JSON-документ, а уже затем начинают его разбирать.
Для небольших запросов это совершенно нормально. Но представим, что нам необходимо обработать файл размером несколько гигабайт.
Загружать его целиком в оперативную память только для того, чтобы получить несколько интересующих полей, — не самое рациональное решение.
SilentJSON предоставляет StreamDecoder, который позволяет читать JSON-массив непосредственно из io.Reader.
Например, мы можем обрабатывать каталог товаров по одному объекту:
func processStream(reader io.Reader) error { decoder := silentjson.NewStreamDecoder[Product]( reader, productRegistry, ) return decoder.Next(func(product *Product) bool { // Здесь обрабатываем текущий товар. // Например, сохраняем его в базу данных. fmt.Println(product.ID, product.Name) return true }) }
Обратите внимание на сам принцип работы.
Нам не требуется заранее знать размер файла или количество объектов. Декодер самостоятельно читает данные по мере необходимости.
При этом используется один переиспользуемый экземпляр структуры, а не создаётся новый объект для каждой записи.
Это позволяет обрабатывать большие выгрузки, не загружая весь документ в память.
Есть и ещё одна интересная деталь: при потоковом чтении SilentJSON использует собственный внутренний буфер. Начальный размер составляет 256 КБ, а при необходимости буфер расширяется для обработки отдельного большого объекта.
Строковые значения при потоковом разборе копируются, поскольку внутренний буфер переиспользуется. Это позволяет избежать ситуации, когда строка продолжает ссылаться на уже перезаписанные данные.
Но что, если нам вообще не требуется преобразовывать данные в Go-структуры?
Например, мы получаем огромный каталог товаров и хотим разбить его на небольшие JSON-файлы для последующей обработки другими компонентами.
Для этого в SilentJSON есть NextRawBlock().
Метод позволяет получать сразу несколько JSON-объектов в виде одного непрерывного фрагмента []byte.
Пример:
func splitStream(reader io.Reader, output io.Writer) error { decoder := silentjson.NewStreamDecoder[Product]( reader, productRegistry, ) for { block, count, err := decoder.NextRawBlock(1000, 0) if count > 0 { // block содержит JSON-объекты, // разделённые запятыми, без внешних скобок массива. if _, writeErr := io.WriteString(output, "["); writeErr != nil { return writeErr } if _, writeErr := output.Write(block); writeErr != nil { return writeErr } if _, writeErr := io.WriteString(output, "]\n"); writeErr != nil { return writeErr } } if err == io.EOF { return nil } if err != nil { return err } } }
В этом примере мы вообще не создаём структуры товаров.
Библиотека определяет границы отдельных JSON-объектов и возвращает их группами.
В результате можно организовать обработку примерно так:
Большой JSON-файл | v StreamDecoder | +-------+-------+ | | | v v v Block 1 Block 2 Block 3 | | | v v v Worker Worker Worker | | | +-------+-------+ | v Результат
Это полезно не только для импорта каталогов, но и для построения промежуточных хранилищ, проксирования данных, распределённых обработчиков и конвейеров ETL.
Важный нюанс: возвращаемый block ссылается на внутренний буфер декодера и действителен до следующего чтения. Поэтому его можно сразу записывать или обрабатывать, как в примере выше. Если требуется передать данные асинхронному обработчику или сохранить их надолго, необходимо сделать собственную копию.
Высокая скорость обработки одного документа — это хорошо. Но в реальном приложении гораздо важнее, как система ведёт себя при обработке больших объёмов данных.
Для больших JSON-массивов SilentJSON предоставляет UnmarshalArrayParallel.
В отличие от последовательного парсинга, здесь библиотека самостоятельно распределяет обработку массива между рабочими потоками.
Использование практически не отличается от обычной десериализации:
func parseLargeCatalog(raw []byte, count int) ([]Product, error) { products := make([]Product, count) products, err := silentjson.UnmarshalArrayParallel[Product]( raw, productRegistry, products, ) if err != nil { return nil, err } return products, nil }
Никакой генерации кода, ручного распределения объектов между goroutine или дополнительной синхронизации со стороны пользователя не требуется.
При этом реестр типов создаётся один раз и используется повторно.
Здесь есть важный архитектурный момент.
Параллельный парсинг лучше всего подходит для больших пакетных операций: импорта, обработки выгрузок, фоновых задач и аналитики.
Для обычных HTTP-обработчиков, работающих под высокой конкурентной нагрузкой, зачастую разумнее использовать последовательный UnmarshalSlice. Если каждый запрос начнёт самостоятельно задействовать все ядра процессора, можно получить конкуренцию за CPU и ухудшить общую отзывчивость системы.
Поэтому выбор режима обработки должен зависеть от характера нагрузки, а не только от скорости обработки одного документа.
Отдельно хочется остановиться на работе с памятью.
В высоконагруженных приложениях выделение памяти — это не бесплатная операция. Особенно если речь идёт о миллионах небольших объектов, которые создаются и уничтожаются в процессе обработки запросов.
SilentJSON позволяет строить обработку с минимальным количеством лишних аллокаций и повторным использованием рабочих буферов.
Например, при сериализации можно заранее подготовить буфер и использовать его повторно:
func serializeProducts(products []Product, registry *silentjson.Registry) []byte { buf := make([]byte, 0, 1024*1024) buf = silentjson.MarshalSlice( products, registry, buf[:0], ) return buf }
Аналогичный принцип применяется при десериализации: заранее выделенный срез позволяет избежать дополнительных выделений памяти при заполнении результата.
Разумеется, это не означает, что в приложении вообще не будет аллокаций. Но появляется возможность контролировать их количество и не создавать объекты там, где они не нужны.
В результате уменьшается дополнительная нагрузка на сборщик мусора, а поведение приложения под нагрузкой становится более предсказуемым.
Здесь тоже стоит обозначить границы библиотеки.
SilentJSON не стремится реализовать полную совместимость со всеми возможностями encoding/json.
Поддержка динамических типов вроде interface{}, map и произвольных псевдотипов не является целью проекта. В частности, не стоит рассчитывать на прямую поддержку time.Time и подобных специальных типов.
Это осознанное архитектурное решение.
Библиотека ориентирована на работу с конкретными структурами данных и поддерживаемыми примитивами: строками, числами, логическими значениями, структурами и некоторыми видами срезов.
Попытка поддержать абсолютно все возможные варианты представления данных неизбежно усложнила бы реализацию и увеличила бы накладные расходы.
Вместо этого SilentJSON придерживается более узкой специализации.
Если бизнес-логике требуется работа со сложными типами, их вполне можно обрабатывать стандартными инструментами Go на соответствующем уровне приложения.
Не обязательно заставлять один инструмент выполнять всю работу.
SilentJSON начинался как инструмент для собственного проекта. В процессе разработки я учитывал не только требования непосредственно к парсеру, но и особенности разных этапов обработки информации. В итоге результат настолько меня устроил, что библиотека стала одним из ключевых инструментов в ядре моей key-value базы данных.
По сути, это инструмент для построения систем, в которых важна эффективность обработки данных.
Его основные возможности позволяют:
Быстро разбирать JSON и работать с поддерживаемыми типами.
Использовать RawMessage для передачи вложенных JSON-структур без необходимости описывать для них отдельные типы.
Обрабатывать потоки данных без обязательной загрузки всего документа в память.
Разбивать большие массивы на небольшие фрагменты.
Распараллеливать обработку больших наборов данных.
Минимизировать аллокации и повторно использовать рабочие буферы.
Сериализовать данные без создания лишних промежуточных объектов.
При этом все эти возможности не обязательно использовать одновременно.
Можно взять только парсер, только RawMessage или потоковую обработку. Можно построить собственную архитектуру, комбинируя SilentJSON со стандартной библиотекой Go и другими инструментами.
Мне кажется, в этом и заключается смысл специализированных решений: не пытаться охватить все возможные сценарии, а выбирать подходящие механизмы под конкретную задачу.
Исходный код SilentJSON открыт и распространяется под лицензией MIT. Библиотека продолжает развиваться, поэтому я буду рад конструктивным замечаниям, предложениям и независимому анализу реализации.
Исходный код: https://github.com/GenshIv/silentjson