Формат файла 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:

/src/bin/csv_read_stdin.rs

После того, как этот код скомпилирован, его можно вызвать, передав ему загруженный файл 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:

/src/bin/csv_read_serde.rs

Важно отметить, что десериализация данных сопряжена с некоторыми накладными расходами. Если вы сравните первые два примера с этим, вы обнаружите, что десериализация данных будет медленнее.

Обработка неверных данных с помощью 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:

/src/bin/csv_write_stdout.rs

Поскольку это просто записывает в stdout, программу можно вызвать так:

./target/release/csv_write_stdout

Или вы можете передать файл, подобный этому:

/target/release/csv_write_stdout > test.csv

Запись в файл

Другой вариант использования для записи данных CSV - это запись в файл. csv crate предоставляет метод from_path для создания структуры Writer для записи в данный файл. За исключением метода from_path, этот пример аналогичен записи в stdout.

/src/bin/csv_write_file.rs

Написание с Серде

Подобно структуре Reader, структуры Writer поддерживают сериализацию данных из типов Rust в записи CSV с помощью Serde. В этом примере повторно используется схема Customer, использованная ранее, и преобразуются экземпляры Customer в данные CSV.

/src/bin/csv_write_serde.rs

Ключевым отличием этого примера от других примеров записи является использование метода 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 г.