Файловият формат 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 осигурява 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

Удобна функция за тази кутия е поддръжката за 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 обаче не налага такива предположения. Така че, ако се опитаме да десериализираме данни, които не могат да бъдат преобразувани в декларирания тип, тогава ще има грешка.

Например за схемата на поръчките структурата на 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 предоставя метода from_path за създаване на Writer структура за запис в дадения файл. Освен метода from_path, този пример е същият като писането в stdout.

/src/bin/csv_write_file.rs

Писане със Serde

Подобно на структурата 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 г.