Формат файла CSV - очень распространенный метод хранения табличных данных. В этой статье рассматриваются основы чтения и записи файлов CSV, (де) сериализации данных CSV с использованием библиотеки Serde, а также приводится пример агрегирования данных.
Вступление
Идея этой статьи возникла из-за того, что мне нужен был быстрый и удобный способ работы с данными в формате CSV. Один из вариантов - написать свою собственную библиотеку, но я не считал это удобным решением. Итак, после небольшого исследования, я решил использовать ящик csv, созданный Andrew Gallant. Я выбрал этот ящик, потому что API довольно прост и, что наиболее важно для меня, документация очень тщательная.
Настраивать
Чтобы использовать этот ящик, просто создайте новый проект Rust, используя cargo new
, и добавьте его в [dependencies]
в Cargo.toml
файле:
csv = "1.1"
Кроме того, часть этой статьи будет использовать настраиваемую функциональность Serde, так что добавьте и это:
serde = { version = "1", features = ["derive"] }
Источники данных
Все данные в этой статье получены из Mockaroo, бесплатного сервиса фиктивных данных, который очень гибок и прост в использовании и совместном использовании. Схемы, которые я буду использовать, можно найти по следующим ссылкам:
Одно небольшое примечание о схеме
Order
. Он имеет отношение внешнего ключа к схемеCustomer
. Чтобы настроить это на сайте Mockaroo, сначала создайте схемуCustomer
, загрузите ее как файл csv, загрузите файл csv как набор данных, а затем вы можете создать схемуOrder
.
Чтение данных CSV
csv
crate предоставляет Reader
структуру, которая используется для преобразования необработанных данных CSV в стандартные типы Rust. Reader
имеет функцию чтения из входного потока или файла и десериализации данных в пользовательскую структуру с помощью Serde.
Чтение с stdin
Чтение из stdin
может быть не обычным вариантом использования, если вы не передаете данные CSV в приложение командной строки. Для чтения из stdin
ящик предоставляет функцию под названием from_reader
. Этот метод работает для любого типа, реализующего эту черту, что означает, что вы можете читать из массива байтов в Reader
. В следующем примере показано, как читать данные CSV из stdin
:
После того, как этот код скомпилирован, его можно вызвать, передав ему загруженный файл CSV. Например, я скомпилировал приведенное выше, используя cargo build --release
, и смог запустить его, набрав это в терминале:
./target/release/read_csv_stdin < data/Customers.csv
Результат будет выглядеть примерно так:
StringRecord(["26fa5a49-5194-40eb-8faf-219c9fff831e", "Arline", "Jardine", "[email protected]", "61635 Dawn Hill"]) StringRecord(["708fcc04-e387-47e3-b85d-99ccd4e18320", "Baillie", "Pierri", "[email protected]", "70540 Gerald Way"]) StringRecord(["9a8ffa76-0280-4754-b75c-4de7508cdd67", "Jenda", "Print", "[email protected]", "928 Bluestem Parkway"]) StringRecord(["c5b4b822-2b59-435f-a697-6f1d4bbb0684", "Lucia", "Rhucroft", "[email protected]", "212 Schiller Pass"]) StringRecord(["f266b61e-534f-483b-9f70-0029b7424aa8", "Sybila", "Valdes", "[email protected]", "2 Gateway Avenue"])
Вы, возможно, заметили, что .records()
не включает строку заголовка. Этот метод пропускает первую строку, поскольку по умолчанию она интерпретируется как запись заголовка. В структуре Reader
есть отдельный метод, называемый .headers()
, для получения записи заголовка.
Чтение из файла
Чтение из файла, вероятно, является наиболее частым вариантом использования структуры Reader
. В ящике есть метод from_path
, который создает Reader
из пути к файлу данных CSV. Код для чтения из файла очень похож на код для чтения из stdin
. В следующем примере показано, как читать CSV из файла:
Чтение с Серде
Удобной особенностью этого ящика является поддержка Serde, которая обеспечивает функциональность для чтения данных CSV и десериализации их в пользовательские типы данных Rust. Например, необработанные Customer
данные, которые выглядят так:
customer_guid,first_name,last_name,email,address 26fa5a49-5194-40eb-8faf-219c9fff831e,Arline,Jardine,[email protected],61635 Dawn Hill
- может быть десериализован в структуру Rust, которая выглядит так:
struct Customer {
customer_guid: String,
first_name: String,
last_name: String,
email: String,
address: String
}
Вместо использования метода .records
, как в предыдущем примере. Структура Reader
предоставляет другой метод, называемый .deserialize
, для создания итератора для каждой десериализованной записи. В следующем примере показано, как читать данные CSV и десериализовать их в тип Rust:
Важно отметить, что десериализация данных сопряжена с некоторыми накладными расходами. Если вы сравните первые два примера с этим, вы обнаружите, что десериализация данных будет медленнее.
Обработка неверных данных с помощью Serde
Все предыдущие примеры предполагали довольно чистые данные. Однако формат файла CSV не делает таких предположений. Поэтому, если мы попытаемся десериализовать данные, которые не могут быть преобразованы в объявленный тип, возникнет ошибка.
Например, для схемы Orders структура Rust может выглядеть примерно так:
Тогда предположим, что есть запись, которая выглядит так:
order_guid,customer_guid,order_date,total 02a771c1-9885-40d6-9acc-75cd108e9218,022d6000-b4da-412b-ba7c-cd0dbf868af9,4/16/2020,Null
Когда Serde пытается десериализовать значение Null
, возникает ошибка, которая выглядит следующим образом:
CSV deserialize error: record 1 (line: 2, byte: 42): field 3: invalid float literal
Чтобы решить эту проблему, ящик предоставляет вспомогательную функцию, которую можно применить к Option
полям, которая сообщает Serde преобразовать значение в None
в случае возникновения ошибки. Добавление этой функции выглядит так:
#[derive(Debug, Deserialize)]
struct Order {
order_guid: String,
customer_guid: String,
order_date: String,
#[serde(deserialize_with = "csv::invalid_option")]
total: Option<f64>
}
За исключением вспомогательного метода invalid_option
, остальной код точно такой же, как в предыдущем примере.
Запись данных CSV
Запись данных CSV немного проще, чем их чтение, поскольку у вас больше контроля над выводом. Ящик предоставляет структуру Writer
, интерфейс которой очень похож на структуру Reader
. Структура Writer
предоставляет функции для записи в stdout
, записи в файл и сериализации типов Rust в данные CSV.
Запись в stdout
Простым вариантом использования может быть запись данных CSV в stdout
, чтобы их можно было передать по конвейеру в другое приложение. Для записи в stdout
ящик предоставляет метод from_writer
для создания структуры Writer
, настроенной для записи в stdout
. После создания структуры Writer
записи добавляются путем вызова метода write_record
экземпляра Writer
. Ниже показано, как записать данные CSV в stdout
:
Поскольку это просто записывает в stdout
, программу можно вызвать так:
./target/release/csv_write_stdout
Или вы можете передать файл, подобный этому:
/target/release/csv_write_stdout > test.csv
Запись в файл
Другой вариант использования для записи данных CSV - это запись в файл. csv
crate предоставляет метод from_path
для создания структуры Writer
для записи в данный файл. За исключением метода from_path
, этот пример аналогичен записи в stdout
.
Написание с Серде
Подобно структуре Reader
, структуры Writer
поддерживают сериализацию данных из типов Rust в записи CSV с помощью Serde. В этом примере повторно используется схема Customer
, использованная ранее, и преобразуются экземпляры Customer
в данные CSV.
Ключевым отличием этого примера от других примеров записи является использование метода serialize
вместо метода write_record
для записи данных.
Агрегация CSV
При работе с данными CSV обычно используется чтение файла CSV, обработка данных и последующая запись результатов обратно в файл. В этом примере считываются два файла CSV, выполняется внутреннее соединение, а затем результат записывается в файл. Поскольку этот пример длиннее других, я просто хочу выделить пару разделов, но исходный код можно найти здесь.
Первое, что я решил сделать, - это определить структуру для хранения заголовков и записей, чтобы немного упростить агрегирование. Эта структура выглядит так:
#[derive(Debug)]
struct DataSet {
/// Header row of CSV file
headers: StringRecord,
/// Records from CSV file
records: Vec<StringRecord>,
}
Тип StringRecord
- это структура из csv
ящика, определяющая запись. Использование этого типа в DataSet
обеспечивает простую интеграцию с внутренними компонентами Reader
и Writer
при чтении и записи данных CSV.
Чтобы объединить эти два набора данных, я собираюсь использовать алгоритм объединения сортировка-слияние. Обычно он сортирует оба набора данных в заданном столбце, а затем сканирует оба набора данных в поисках совпадений. Когда совпадение найдено, он объединяет записи из обоих наборов данных в одну запись и добавляет ее в новый набор данных.
Поскольку у меня есть настраиваемая структура, в которой хранятся записи, я могу добавить блок impl
с функцией для сортировки записей в наборе данных.
/// Sort data records by the given index.
///
/// # Errors
///
/// An error occurs if the index is out of bounds
fn sort_by_index(&mut self, index: usize) -> Result<(), Box<dyn Error>> {
if index >= self.headers.len() {
Err(Box::new(IndexError(format!(
"Index '{}' out of bounds",
index
))))
} else {
self.records.sort_by(|a, b| a[index].cmp(&b[index]));
Ok(())
}
}
Как только оба набора данных будут отсортированы, я могу объединить их вместе. Основная логика метода внутреннего соединения выглядит так:
let mut left_cursor = 0;
let mut right_cursor = 0;
while left_cursor < self.records.len() && right_cursor < right.records.len() {
// If two fields match, merge fields into a single record
// and add to records vector
// If they don't match and the left value is less then right value advance the left cursor
// else advance the right cursor
if self.records[left_cursor][left_index] == right.records[right_cursor][right_index] {
let record = StringRecord::from(
self.records[left_cursor]
.iter()
.chain(right.records[right_cursor].iter())
.collect::<Vec<&str>>(),
);
records.push(record);
// Since data sets are sorted
// Advance cursor through right data set to
// see if there are matches
let mut k = right_cursor + 1;
while k < right.records.len()
&& self.records[left_cursor][left_index] == right.records[k][right_index]
{
let record = StringRecord::from(
self.records[left_cursor]
.iter()
.chain(right.records[k].iter())
.collect::<Vec<&str>>(),
);
records.push(record);
k += 1;
}
left_cursor += 1;
} else if self.records[left_cursor][left_index]
< right.records[right_cursor][right_index]
{
left_cursor += 1;
} else {
right_cursor += 1;
}
}
Алгоритм сортировки-слияния - один из многих для эффективного объединения наборов данных. Если вы хотите узнать больше об этих типах алгоритмов, я обнаружил, что эта статья была хорошей отправной точкой.
Рассмотрение
В целом, я считаю, что с ящиком csv
очень легко работать, и это мой первый выбор, когда мне нужна библиотека для быстрого анализа CSV. Ящик извлекает выгоду из хорошо документированного исходного кода и подробного учебного документа, в котором содержится более подробная информация. Сообщество Rust, кажется, чувствует то же самое, поскольку этот ящик был загружен более 3 миллионов раз.
Спасибо
Спасибо за прочтение! Если вы хотите подключиться или оставить отзыв, свяжитесь со мной в LinkedIn.
Первоначально опубликовано на https://andrewleverette.github.io 30 июня 2020 г.