Стриминговый разбор JSON в Cross-Cleaner на serde/serde_json: практическая реализация
Что уже есть в коде
Cross-Cleaner уже проделал большую часть работы по снижению памяти — но не на уровне парсинга, а на уровне представления данных:
-
Arc<str>вместоStringдляcategory,program,class,sub_category,files_to_remove,directories_to_remove— с дедупликацией черезstring_interner(intern_arc); -
SharedPath— путь хранится какArc<[Arc<str>]>из интернированных сегментов, что даёт разделение общих префиксов (AppData\Local\...) между десятками тысяч записей; -
CleanerIndexиRegistryIndex— лёгкие проекции для сканирования категорий, где «неизвестные поля игнорируются serde и не аллоцируются».
Это уже сильно снижает установившийся размер базы в памяти. Но пик потребления при десериализации всё ещё определяется тем, как именно вызывается serde_json. Если база грузится через serde_json::from_reader::<_, Vec<CleanerData>>, то в момент разбора в памяти одновременно живут: буфер чтения, строящийся Vec, и промежуточные String внутри кастомного Deserialize for CleanerData (структура Helper содержит String, а не Arc<str>). Именно Helper — главный источник временных аллокаций.
Где именно теряется память при текущем Deserialize
Кастомный impl<'de> Deserialize<'de> for CleanerData делает:
rust
#[derive(Deserialize)]struct Helper { path: String, category: String, program: String, class: String, sub_category: String, files_to_remove: Vec<String>, directories_to_remove: Vec<String>, // ...}let h = Helper::deserialize(deserializer)?;// затем всё это переинтернивается в Arc<str>
То есть на каждую запись сначала аллоцируются String, а уже потом они конвертируются в Arc<str> через intern_arc. Пиковая память на запись ≈ размер всех её строк в виде String + размер интернированных Arc<str> + накладные расходы Vec. Для одной записи это немного, но при потоковом разборе важно, что Helper освобождается сразу после конвертации — а при from_reader в Vec<CleanerData> это происходит для каждой записи последовательно, так что пик по временным строкам не суммируется. Хорошая новость: сам по себе этот Deserialize уже не «загружает всё в строки разом».
Проблема остаётся в итоговом Vec<CleanerData>: все записи живут одновременно. Для базы очистки это, вероятно, приемлемо, если она нужна целиком для сканирования. Но если задача — например, построить только CleanerIndex или прогнать категорию, держать полный Vec<CleanerData> не нужно.
Что даёт StreamDeserializer в этой кодовой базе
serde_json::StreamDeserializer уместен в двух сценариях.
Сценарий 1: построение индекса без полной базы
Если база — JSON-массив CleanerData, а нужен только CleanerIndex (для сканирования категорий), можно не десериализовать CleanerData вовсе. CleanerIndex уже игнорирует лишние поля, и serde_json не будет их аллоцировать. При потоковом обходе элементов массива в памяти живёт один CleanerIndex за раз:
rust
use std::fs::File;use std::io::BufReader;fn build_index(path: &str) -> Result<Vec<CleanerIndex>, Box<dyn std::error::Error>> { let file = File::open(path)?; let reader = BufReader::new(file); let mut index = Vec::new(); for item in iter_json_array::<CleanerIndex, _>(reader) { index.push(item?); // в памяти — только CleanerIndex, без Helper и без полных данных } Ok(index)}
Ключевой момент: CleanerIndex использует deserialize_shared_opt/deserialize_shared_str_default, то есть сразу интернирует в Arc<str>. Временные String живут только внутри одного вызова Deserialize и освобождаются до перехода к следующему элементу. По сравнению с Vec<CleanerData>, где каждая запись тащит files_to_remove, directories_to_remove и flags, это радикально меньше.
Сценарий 2: обработка базы по одной записи без накопления
Если CleanerData нужны, но не все сразу (например, фильтрация по ОС, по категории, немедленная регистрация в планировщике), потоковый обход позволяет не держать Vec:
rust
fn for_each_rule<F>(path: &str, mut f: F) -> Result<(), Box<dyn std::error::Error>>where F: FnMut(CleanerData) -> Result<(), Box<dyn std::error::Error>>,{ let file = File::open(path)?; let reader = BufReader::new(file); for item in iter_json_array::<CleanerData, _>(reader) { f(item?)?; // item освобождается после вызова } Ok(())}
Здесь Helper внутри Deserialize for CleanerData создаётся и уничтожается на каждой записи, а итоговый CleanerData живёт ровно один вызов f.
Обёртка iter_json_array под топ-левел массив
StreamDeserializer не умеет итерировать элементы массива напрямую, поэтому нужна тонкая обёртка. Она не держит данные — только читает байты и пропускает [, ,, ]. Минимальная версия:
rust
use serde::de::DeserializeOwned;use serde_json::Deserializer;use std::io::{self, Read};fn skip_ws<R: Read>(reader: &mut R) -> io::Result<Option<u8>> { loop { let mut b = [0u8; 1]; match reader.read(&mut b)? { 0 => return Ok(None), _ if b[0].is_ascii_whitespace() => continue, _ => return Ok(Some(b[0])), } }}fn deserialize_one<T: DeserializeOwned, R: Read>(reader: &mut R) -> io::Result<T> { Deserializer::from_reader(reader) .into_iter::<T>() .next() .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "premature EOF"))? .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))}pub fn iter_json_array<T, R>(mut reader: R) -> impl Iterator<Item = io::Result<T>>where T: DeserializeOwned, R: Read,{ let mut started = false; let mut done = false; std::iter::from_fn(move || { if done { return None; } if !started { started = true; match skip_ws(&mut reader) { Ok(Some(b'[')) => {} Ok(_) => { done = true; return Some(Err(io::Error::new( io::ErrorKind::InvalidData, "expected `[`", ))); } Err(e) => { done = true; return Some(Err(e)); } } match skip_ws(&mut reader) { Ok(Some(b']')) => { done = true; return None; } Ok(Some(first)) => { // «вернуть» первый байт в поток невозможно, поэтому // используем Cursor с цепочкой let chained = std::io::Cursor::new([first]).chain(reader); let mut chained = chained; return Some(deserialize_one(&mut chained)); } Ok(None) => { done = true; return Some(Err(io::Error::new( io::ErrorKind::UnexpectedEof, "unterminated array", ))); } Err(e) => { done = true; return Some(Err(e)); } } } match skip_ws(&mut reader) { Ok(Some(b',')) => Some(deserialize_one(&mut reader)), Ok(Some(b']')) => { done = true; None } Ok(_) => { done = true; Some(Err(io::Error::new( io::ErrorKind::InvalidData, "expected `,` or `]`", ))) } Err(e) => { done = true; Some(Err(e)) } } })}
Важная деталь: deserialize_one создаёт StreamDeserializer на одном элементе и сразу его отбрасывает, поэтому внутренние буферы serde_json не накапливаются между элементами. Обёртка Cursor::new([first]).chain(reader) нужна только для первого элемента, чтобы не потерять уже прочитанный байт.
Почему это сочетается с текущим дизайном
Текущий код уже готов к потоковому использованию:
-
CleanerIndexиRegistryIndex— готовые лёгкие типы. ИхDeserializeиспользуетdeserialize_shared_opt, то есть сразу интернирует. ВременныеStringживут в пределах одного элемента. -
deserialize_vec_arc— принимаетVec<String>, интернирует и возвращаетVec<Arc<str>>. ВременныеStringдляfiles_to_remove/directories_to_removeосвобождаются сразу после интернирования, не дожидаясь конца базы. -
SharedPath::deserialize— читаетString, разбивает на сегменты и интернирует каждый. Временная строка пути живёт только на время одногоDeserialize. -
CleanerData::DeserializeчерезHelper— единственное место, где временныеStringживут чуть дольше, но всё равно в пределах одной записи.
То есть переход от from_reader::<_, Vec<CleanerData>> к iter_json_array::<CleanerData, _> не требует менять ни один Deserialize — он уже написан так, что временные аллокации локальны для записи.
Ограничения и подводные камни
-
StreamDeserializerиBufReader.serde_jsonне буферизует ввод. БезBufReaderкаждыйread— это syscall, и потоковый разбор станет медленнее полного.BufReaderобязателен. -
Формат базы. Если база — не топ-левел массив, а, например, объект
{"rules": [...]}, обёртку нужно адаптировать под префикс. Альтернатива — перевести базу на JSON Lines, тогдаiter_json_arrayне нужен вовсе:BufReader::lines()+serde_json::from_strна строку. -
CustomCleanerне сериализуется. Он определён в коде, а не в JSON, поэтому к нему потоковый разбор неприменим — тамVec<CustomCleaner>собирается из зарегистрированных функций. -
CleanerDataRegistry(Windows) — обычныйderive(Deserialize), без кастомногоHelper. Его тоже можно гонять черезiter_json_array, если база реестра — массив. -
Порядок и агрегация. Если после разбора нужна сортировка или глобальная дедупликация по всей базе, потоковый обход сам по себе не поможет — придётся либо накапливать индекс (что и делает
CleanerIndex), либо использовать внешнюю сортировку.
Ожидаемый эффект
Для базы очистки Cross-Cleaner переход на потоковый разбор даёт:
-
пик памяти при загрузке определяется одной записью (
CleanerData+ временныйHelper), а не всей базой; -
при построении индекса (
CleanerIndex) в памяти остаются толькоSharedPath+ триArc<str>на запись — безfiles_to_remove,directories_to_remove,flagsи безHelper; -
возможность обрабатывать базы, превышающие доступную RAM, если обработка не требует держать все записи одновременно;
-
сохранение всех существующих оптимизаций (
string_interner,SharedPath,Arc<str>) без изменений вDeserialize.
Заключение
В Cross-Cleaner снижение памяти уже достигнуто на уровне представления: интернирование строк и сегментированные пути. Следующий шаг — на уровне способа чтения: заменить serde_json::from_reader::<_, Vec<CleanerData>> на iter_json_array поверх StreamDeserializer (или на JSON Lines). Существующие Deserialize-реализации уже устроены так, что временные String локальны для одной записи, поэтому потоковый обход не требует их переписывать — достаточно обёртки, которая пропускает разделители массива и создаёт StreamDeserializer на каждый элемент отдельно.
ссылка на оригинал статьи https://habr.com/ru/articles/1099174/