Serde в Rust: как отсутствующее поле JSON незаметно отключало настройки приложения
В Rust есть немало способов выстрелить себе в ногу, даже когда компилятор доволен, тесты проходят, а приложение работает без ошибок. Иногда для этого достаточно одного атрибута, который делает ровно то, что написано в документации.
При разработке NetRuleRouter столкнулся с довольно странным поведением. Две настройки должны были быть включены по умолчанию, но после обмена данными между компонентами приложения оказывались выключенными. При этом JSON успешно обрабатывался, ошибок десериализации не возникало, а подозрительные значения выглядели совершенно допустимыми.
Причина оказалась не в Qt, не в IPC и даже не в ошибке библиотеки. Проблема скрывалась в том, как обычное отсутствие поля превращалось в конкретное значение.
Разобраться помог Serde — один из наиболее распространённых инструментов сериализации в экосистеме Rust. Причём позднее похожая ситуация обнаружилась уже при диагностике сетевых адаптеров, где цена неправильного значения могла быть значительно выше, чем просто сброшенная настройка.
Что такое Serde и где его используют
Serde — фреймворк сериализации и десериализации данных в Rust.
Сериализация преобразует данные программы в представление, которое можно записать в файл или передать другому процессу. Десериализация выполняет обратное преобразование.
Serde используется в самых разных задачах:
Чтение и запись конфигурационных файлов в JSON, TOML, YAML и других форматах.
Обработка запросов и ответов REST API.
Обмен данными между процессами через IPC.
Работа с сообщениями в сетевых протоколах.
Преобразование структур при хранении и передаче данных.
Простейший пример:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Preferences {
auto_start: bool,
language: String,
}Такую структуру можно сериализовать в JSON:
let prefs = Preferences {
auto_start: true,
language: "ru".to_string(),
};
let json = serde_json::to_string(&prefs).unwrap();Результат:
{"auto_start":true,"language":"ru"}И восстановить обратно:
let restored: Preferences =
serde_json::from_str(&json).unwrap();Для примера потребуются две зависимости:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"Одна из особенностей Serde — разделение описания данных и формата сериализации. Вместо JSON можно использовать другие поддерживаемые форматы, не переписывая структуру и реализации трейтов Serialize и Deserialize.
При этом Serde активно использует систему типов Rust и генерацию кода во время компиляции.
А как в других языках?
Сама задача универсальна. В других языках для сериализации используются свои инструменты.
Язык | Инструмент | Особенность |
|---|---|---|
Rust | Serde + serde_json | Типизированные структуры, трейты и derive |
Go | encoding/json | Структуры и нулевые значения типов |
Python | json | Обычно словари и списки, без автоматического создания пользовательских объектов |
Java | Jackson | Привязка JSON к объектам и настраиваемая обработка полей |
Разница особенно заметна при чтении неполных данных.
Например, стандартный encoding/json в Go при декодировании JSON в новую структуру оставляет отсутствующие поля с нулевыми значениями соответствующих типов.
Serde по умолчанию ведёт себя строже: отсутствие обязательного поля обычно приводит к ошибке десериализации.
Чтобы разрешить отсутствие поля, можно использовать:
#[serde(default)]Но здесь возникает вопрос: какое именно значение должно появиться вместо отсутствующего?
Почему Serde понадобился в NetRuleRouter
NetRuleRouter — открытая утилита для управления маршрутизацией сетевого трафика по правилам.
Основная логика приложения реализована на Rust, графический интерфейс — на Qt/QML. Между компонентами используется IPC, в том числе сообщения в формате JSON.
По мере развития программы в настройках появлялись новые поля, а разные компоненты не всегда одновременно знали обо всех изменениях формата сообщений.
Например, отправитель мог передать JSON старого формата, в котором ещё не существовало новой настройки.
Если поле обязательное, Serde вернёт ошибку. Атрибут #[serde(default)] позволяет обработать такое сообщение, подставив значение по умолчанию.
При этом важно различать две ситуации:
Поле отсутствует, хотя структура получателя его ожидает.
Поле присутствует, но получатель о нём не знает.
По умолчанию Serde при работе со структурами обычно игнорирует неизвестные поля JSON. При необходимости это поведение можно изменить с помощью deny_unknown_fields.
А вот для отсутствующих ожидаемых полей требуется отдельно определить поведение.
И именно здесь возникла ошибка.
Как отсутствующие поля отключали настройки
В структуре настроек появились два булевых поля:
#[derive(Deserialize)]
struct UiPreferences {
#[serde(default)]
auto_load_rules_on_launch: bool,
#[serde(default)]
export_include_comments: bool,
}Первое отвечало за автоматическую загрузку правил при запуске. Второе — за включение комментариев при экспорте.
Оба параметра должны были быть включены по умолчанию.
Но отправитель, не знавший об этих полях, передавал JSON без них.
Получалась следующая последовательность:
Никакой ошибки десериализации не было. Значения успешно подставлялись, но пользовательские настройки фактически оказывались выключенными.
Почему именно false?
#[serde(default)] при отсутствии поля использует Default::default() для типа этого поля.
Для bool это false:
assert_eq!(bool::default(), false);Для других распространённых типов результат выглядит так:
Тип | Значение Default::default() |
|---|---|
|
|
| Пустая строка |
|
|
|
|
| Пустой вектор |
Serde не знает, что автоматическая загрузка правил должна быть включена. Для него false — совершенно корректное значение по умолчанию для типа bool.
Но для конкретной настройки приложения правильный дефолт — true.
Значение по умолчанию для типа и значение по умолчанию для настройки — не одно и то же.
Исправление: явные дефолты
Serde позволяет назначить функцию, возвращающую нужное значение при отсутствии поля.
В исправленном варианте это выглядит так:
use serde::Deserialize;
fn default_auto_load_rules() -> bool {
true
}
fn default_export_comments() -> bool {
true
}
#[derive(Debug, Deserialize)]
struct UiPreferences {
#[serde(default = "default_auto_load_rules")]
auto_load_rules_on_launch: bool,
#[serde(default = "default_export_comments")]
export_include_comments: bool,
}Теперь, если поле отсутствует, используется значение, соответствующее требованиям приложения.
Технически оба случая можно было обработать одной функцией:
fn default_true() -> bool {
true
}Но именованные функции удобнее для сопровождения. Например, default_export_comments явно показывает, какая настройка получает значение по умолчанию.
Проверка тестами
Для начала проверяется сообщение, в котором новых полей вообще нет:
#[test]
fn missing_fields_use_expected_defaults() {
let prefs: UiPreferences =
serde_json::from_str("{}").unwrap();
assert!(prefs.auto_load_rules_on_launch);
assert!(prefs.export_include_comments);
}Но одного такого теста недостаточно.
Если пользователь явно передал false, Serde должен сохранить это значение:
#[test]
fn explicit_false_is_preserved() {
let json = r#"{
"auto_load_rules_on_launch": false,
"export_include_comments": false
}"#;
let prefs: UiPreferences =
serde_json::from_str(json).unwrap();
assert!(!prefs.auto_load_rules_on_launch);
assert!(!prefs.export_include_comments);
}Таким образом, отсутствующее поле получает нужный дефолт, а явно переданное значение не изменяется.
Стоит учитывать ещё одну особенность: #[serde(default)] влияет на десериализацию, а не на сериализацию. Сам по себе он не заставляет Serde пропускать поля, равные значению по умолчанию, при формировании JSON.
А если правильного значения по умолчанию вообще нет?
Позднее при работе над NetRuleRouter возникла похожая задача, но уже связанная с диагностикой сетевых адаптеров.
Понадобилось передавать из службы в графический интерфейс признак has_forwarding_path — есть ли через выбранный адаптер путь для передачи трафика.
Это позволяет, например, отличать виртуальный host-only адаптер от VPN-туннеля, у которого нет обычного шлюза.
Первоначально напрашивался простой вариант:
#[serde(default)]
has_forwarding_path: bool,Но возник вопрос: что произойдёт, если поле не пришло?
Предположим, используется старая версия компонента, которая ещё не передаёт этот признак. Или результат проверки маршрутов оказался недоступен.
Serde подставит false.
Графический интерфейс может интерпретировать его как подтверждение отсутствия пути и показать предупреждение о проблеме с адаптером.
Хотя на самом деле проверка могла вообще не выполняться.
Здесь выяснилось, что у признака не два, а три возможных состояния:
Значение | Смысл |
|---|---|
| Проверка подтвердила наличие пути |
| Проверка подтвердила отсутствие пути |
| Результат неизвестен |
Поэтому вместо bool был выбран:
has_forwarding_path: Option<bool>,Для Serde поле типа Option<bool> может отсутствовать в JSON — тогда оно получит None. Явное значение null тоже будет преобразовано в None.
Если приложению потребуется различать отсутствие поля и явный null, понадобится более подробная модель данных.
В данном случае достаточно трёх состояний: путь есть, пути нет, результат неизвестен.
Небольшой тест демонстрирует нужное поведение:
#[derive(Debug, serde::Deserialize)]
struct AdapterStatus {
has_forwarding_path: Option<bool>,
}
#[test]
fn missing_path_is_not_a_negative_result() {
let status: AdapterStatus =
serde_json::from_str("{}").unwrap();
assert_eq!(status.has_forwarding_path, None);
}Теперь графический интерфейс может показывать предупреждение только при Some(false).
Если результат неизвестен, оснований утверждать, что маршрут отсутствует, нет.
В отличие от истории с настройками, этот случай удалось обнаружить на этапе разработки, до появления ложных предупреждений у пользователей.
Почему не использовать Option везде?
Казалось бы, можно просто заменить все булевы поля на Option<bool>.
Но это лишь перенесёт принятие решения в другое место.
Для настройки автоматической загрузки правил есть два состояния: включено и выключено. Если поле отсутствует в старом сообщении, приложение должно использовать заранее установленный дефолт.
А для результата диагностики отсутствие значения само по себе имеет смысл.
Поэтому правильный выбор зависит от предметной области.
Не всякое отсутствующее значение означает false, 0 или пустую строку. Иногда отсутствие данных — отдельное состояние, которое нужно явно сохранить.
Что в итоге
После первого случая я стал внимательнее относиться к значениям по умолчанию при десериализации.
Второй напомнил, что иногда проблему нужно исправлять не значением, а самим типом.
При добавлении новых полей в JSON-контракт полезно проверять три вещи:
Что произойдёт, если старый отправитель не передаст поле?
Совпадает ли
Default::default()с предполагаемым поведением приложения?Не теряется ли важное состояние при выборе типа данных?
Для таких случаев полезно иметь отдельные тесты на отсутствие новых полей и на сохранение явно переданных значений.
В конечном счёте #[serde(default)] выполнял свою работу правильно. Ошибка заключалась в предположении, что стандартное значение типа автоматически соответствует требованиям приложения.
Значение по умолчанию — часть контракта приложения, а не просто свойство типа.
А стоит ли продолжать?
В процессе разработки NetRuleRouter и других приложений на Rust накопилось немало подобных историй — от небольших ловушек типов и сериализации до взаимодействия с Windows API, Linux, IPC и сетевыми механизмами.
Думаю периодически делиться такими случаями: что не работало, почему, какие варианты решения рассматривались и что в итоге помогло.
Интересен ли такой формат практических заметок по Rust? И какие темы хотелось бы увидеть в первую очередь: особенности самого языка, разработку десктопных приложений или системное и сетевое программирование?
Будет интересно почитать и о похожем опыте других разработчиков.
Ссылки:

Комментарии 0