Сериализация данных посредством serde. Недавно я писал Rust-код для работы со сторонним источником данных в TOML-формате. В других языках я бы подгрузил данные какой-либо TOML-библиотекой и прогнал бы по ним мою программу, однако я слышал про serde — библиотеку сериализации на Rust, так что я решил попробовать ее.
Подробности — под катом.
Основы
Ниже приведен упрощенный пример данных, с которыми я работаю.
manifest-version = "2"
# ...другие полезные поля...
[renames.oldpkg]
to = "newpkg"
Это довольно простой формат данных, и довольно легко написать Rust-структуру, которая могла бы быть сериализована/десериализована.
#[derive(Serialize, Deserialize)]
struct ThirdPartyData {
#[serde(rename = "manifest-version")]
manifest_version: String,
// ...другие полезные поля...
renames: BTreeMap<String, BTreeMap<String, String>>,
}
Данная структура соответствует структуре входных данных, и единственный дополнительный код, который я написал — это атрибут serde(rename = "blah")
, ибо manifest-version
не является корректным Rust-идентификатором.
Улучшенная структура
В сообществах сильно-типизированных языков распространено утверждение: "Делайте некорректные состояния непредставимыми". Это значит, что если ваша программа делает какие-то предположения о характере данных, вы должны использовать систему типов для того, чтобы гарантировать, что это так.
Возьмем, скажем, поле manifest-version
. Это не является той частью данных, которая мне интересна, это — метаинформация, сведения о нужных мне данных. При сериализации этому полю должно быть выставлено значение "2". При десериализации, если это не "2", тогда это должен быть другой файловый формат, с которым я не работаю => считывание данных прекращается. Коду, который использует данные, не нужно работать с данным полем, и если что-то поменяет значение этого поля, то в дальнейшем это приведет к проблемам. Лучший способ убедиться, что никто не читает поле и не пишет в него — полностью удалить его, не тратя на него память.
Поле renames
создает другие проблемы. Определенно это данные, которые меня интересуют, но они представлены в виде странных вложенных словарей. Что будет означать соответствие пустого словаря одному из ключей внешнему словаря? Отображение "старое имя" => "новое имя" должно быть BTreeMap<String, String>, и некорректные состояния просто не смогут возникнуть. Говоря кратко, я хочу, чтобы моя Rust-структура выглядела подобно этому:
#[derive(Serialize, Deserialize)]
struct ThirdPartyData {
// без поля `manifest_version`!
// ...другие полезные поля...
renames: BTreeMap<String, String>,
}
К сожалению, это не делает то, что мне нужно: код не проверяет, что manifest-version
у присвоено корректное значение.
1-ая попытка: сделайте это сами
Если derive
-макрос у serde не может сделать это, мы должны сделать это вручную, не так ли? Исходя из этого, я написал свою реализацию serde::Serialize
и serde::Deserialize
типажей для моей структуры ThirdPartyData
. Кратко говоря, это сработало! Однако это было утомительно писать и сложно понимать.
Документация serde по сериализации структуры проста, и процесс несложен: напишите метод serialize
для вашей структуры, который вызывает нужные методы у serde::Serializer
, и все готово. Однако документация по десериализации гораздо более сложна: вам нужно не только реализовать Deserialize
для вашей структуры, вам также нужна вспомогательная структура, для которой реализован типаж serde::Visitor
.
В документации длинный пример Deserialize
показывает написание десериализации только для примитивного типа, подобного i32
. Десериализация структуры занимает отдельную страницу документации, и реализация гораздо сложнее.
Как я уже сказал, я добился того, что это работает, но у меня не было удовлетворения от проделанной работы, когда я коммитил данный код в мой проект.
2-ая попытка: атрибуты полей
Частью стоящей передо мной задачи была реализация Serialize
и Deserialize
вручную, что ставило меня перед необходимостью писать код для обработки всех полей в моей структуре, хотя serde мог сделать большую часть этого вручную.
Как оказалось, одним из многих предоставляемых атрибутов к полям в serde является атрибут serde(with = "module")
. Данный атрибут указывает название модуля, содержащего функции serialize
и deserialize
, которые будут использоваться для сериализации/десериализации полей, тогда как остальная часть структуры обрабатывается serde как обычно.
Для поля renames
это замечательно. Все же мне пришлось приложить некоторые усилия по работе с Visitor
, тем не менее мне пришлось сделать это только для одного поля, а не для всех полей в структуре.
При работе с полем manifest-version
это не помогло. Так как я не хотел иметь поле manifest-version
, не было того, к чему бы я мог добавить атрибут.
Поэтому я вздохнул, удалил этот код и попытался решить проблему другим способом.
Успех: использование промежуточных структур
Оглянемся назад и посмотрим, какую задачу мы решали:
- я могу писать Rust-структуры, которые удобно использовать, но они не соответствуют в точности входному формату
- я могу писать Rust-структуры, которые в точности соответствуют входному формату, однако эти структуры не так удобно использовать
- преобразование входного формата напрямую в удобные структуры делает необходимым написание большого количества избыточного кода
Думаю, вы уже догадались, что нужно делать: использовать serde для преобразования входного формата в Rust-структуры, которые соответствуют ему, формату, в точности, после этого вручную преобразовать данные в Rust-структуры, которые удобно использовать.
Я использую версию ThirdPartyData
, которую я вкратце обрисовал выше, однако десериализующий код теперь выглядит так:
impl<'de> serde::Deserialize<'de> for ThirdPartyData {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
// Промежуточная структура, которая соответствуют входному формату.
#[derive(Deserialize)]
struct EncodedThirdPartyData {
#[serde(rename = "manifest-version")]
pub manifest_version: String,
// ...другие полезные поля...
pub renames: BTreeMap<String, BTreeMap<String, String>>,
}
// Так как мы получили `Deserialize` автоматически,
// serde делает за нас всю тяжелую работу.
let input = EncodedThirdPartyData::deserialize(deserializer)?;
// Валидация поля `manifest_version` проста.
if input.manifest_version != "2" {
return Err(D::Error::invalid_value(
::serde::de::Unexpected::Str(&input.manifest_version),
&"2",
));
}
// Преобразование структуры поля `renames` тоже просто.
let mut renames = BTreeMap::new();
for (old_pkg, mut inner_map) in input.renames {
let new_pkg = inner_map
.remove("to")
.ok_or(D::Error::missing_field("to"))?;
renames.insert(old_pkg, new_pkg);
}
// Перемещаем все данные в экземпляр нашей "замечательной"
// структуры.
Ok(Channel {
renames: renames,
})
}
}
Наша промежуточная структура владеет десериализуемыми данными, так что мы можем разобрать ее на части для построения удобной структуры без дополнительных выделений памяти… Хорошо, нам нужно создать несколько BTreeMap
для изменения структуры словаря renames
, однако нам не нужно копировать ключи и значения.
Для сериализации структуры мы можем использовать ту же самую промежуточную структуру и работать в обратном порядке, но так как структура владеет данными, мы должны разобрать нашу удобную структура на части для того, чтобы получить данные или их клонировать. Данные варианты не очень привлекательные, поэтому будем использовать другую структуру, заменяющую типы String
типом &str.serde
, сериализует их одинаковым образом, также это значит, что мы можем делать сериализацию не выделяя память.
impl serde::Serialize for ThirdPartyData {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Промежуточная структура, которая соответствует входному формату
// и использует `&str` вместо `String`.
#[derive(Serialize)]
struct EncodedThirdPartyData<'a> {
#[serde(rename = "manifest-version")]
manifest_version: &'a str,
// ...другие полезные поля...
renames: BTreeMap<&'a str, BTreeMap<&'a str, &'a str>>,
}
// Преобразование структуры поля `renames` с использованием
// ссылок на исходные данные.
let mut renames = BTreeMap::new();
for (old_pkg, new_pkg) in self.renames.iter() {
let mut inner = BTreeMap::new();
inner.insert("to", new_pkg.as_str());
renames.insert(old_pkg.as_str(), inner);
}
let output = EncodedThirdPartyData {
// Мы можем указать напрямую версию манифеста, который
// мы хотим сериализовать.
manifest_version: "2",
renames: renames,
};
output.serialize(serializer)
}
}
В итоге мы получили структуру с почти полностью автоматизированными сериализацией/десериализацией, которая включает несколько строк кода для выполнения некоторых проверок и преобразований.