Rust: качаем ленту и парсим JSON

http://asquera.de/blog/2018-01-20/getting-started-with-rust-on-the-command-line/
  • Перевод
  • Tutorial

Я покажу вам, как написать небольшую программу, которая скачивает ленту(feed) в формате JSON, парсит и выводит список заметок на консоль в форматированном виде.


У нас все вылилось в очень лаконичный код. Как? Смотрите под катом.


Скачаем Rust


Обычным способом скачивания Rust является его загрузка на компьютер посредством использования программы rustup. Проверьте, возможно, rustup уже имеется в репозитории вашего дистрибутива.


rustup управляет наборами (toolchains) утилит для разработки. Он позволяет вам изменять используемую версию Rust, управлять дополнительными средствами разработки, например RLS, и скачивать утилиты для разработки под разные платформы.


Когда у вас будет установлен Rust, наберите следующую команду:


rustup install stable

Для этого примера нам нужно использовать как минимум Rust версии 1.20, потому что это требуют некоторых зависимости.


Посмотрим, что у нас есть.


Эта команда установила:


  • rustc — компилятор
  • cargo — менеджер пакетов и сборщик проекта
  • документацию
  • некоторые другие инструменты.

Для просмотра документации в браузере наберите rustup doc.


Настройка проекта: cargo


cargo управляет Rust проектами. Мы хотим собрать маленький исполняемый файл, поэтому мы указываем cargo, что нужно собирать программу, а не библиотеку:


cargo init --bin readrust-cli

Это команда создаст директорию readrust-cli.


Давайте посмотрим, что находится в данной директории:


.
|-- Cargo.toml
|-- src
    |-- main.rs

Вы заметите, что проект имеет простую структуру: он содержит только код нашей программы (src/main.rs) и (Cargo.toml). Давайте посмотрим, что содержится в Cargo.toml:


[package]
name = "readrust-cli"
version = "0.1.0"
authors = ["Florian Gilcher <florian.gilcher@asquera.de>"]

[dependencies]

В данный момент конфигурационный файл содержит немного описательной информации о проекте. Заметьте, секция dependencies пока что пустует, а в main.rs содержится небольшой "hello world" по умолчанию.


Запустим:


$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/readrust-cli`
Hello, world!

Отлично. Все работает. cargo run сама запустила компилятор rustc, собрала программу и затем запустила её. cargo также может выявлять все изменения в коде, которые мы сделали, и перекомпилировать их заново.


А теперь приступим!


Запланируем ход наших действий заранее. Мы хотим написать утилиту, с которой можно взаимодействовать посредством интерфейса командной строки.


С другой стороны, мы хотим решить нашу проблему и не делать лишнюю работу.


Что нам нужно:


* Парсер аргументов командной строки
* HTTP-клиент для скачивания ленты
* Парсер JSON для разбора ленты
* Функция для форматированного вывода результатов на экран.

Касательно набора возможностей, я хочу добавить немного гибкости, достаточной для того, чтобы приступить к работе:


  • программа будет принимать --count, который велит программе вывести количество заметок в ленте
  • программа будет принимать --number [NUMBER], задающий количество заметок, которое должна вывести программа.

CLAP


— расшифровывается как command line argument parser.
Это было легко, не правда ли? CLAP имеет обширную документацию, мы же с вами задействуем лишь небольшой функционал.


Во-первых, мы должны добавить пакет clap в качестве зависимости.


Для этого мы должны указать название и версию в Cargo.toml:


[dependencies]
clap = "2.29"

Если вы сейчас запустите cargo build, то clap будет скомпилирован вместе с нашей программой. Для того чтобы использовать CLAP, мы должны указать Rust, что используем библиотеку (crate в терминологии Rust). Мы также должны внести используемые нами типы в пространство имён. CLAP имеет очень удобный API, который дает нам возможность настолько глубокой настройки, насколько нам это нужно.


extern crate clap;

use clap::App;

fn main() {
    let app = App::new("readrust")
        .version("0.1")
        .author("Florian G. <florian.gilcher@asquera.de>")
        .about("Reads readrust.net")
        .args_from_usage("-n, --number=[NUMBER] 'Only print the NUMBER most recent posts'
                          -c, --count           'Show the count of posts'");

    let matches = app.get_matches();
}

Следующим шагом скомпилируем и запустим программу, указав опцию --help, для того чтобы получить сообщение-инструкцию:


readrust 0.1
Florian G. <florian.gilcher@asquera.de>
Reads readrust.net

USAGE:
    readrust [FLAGS] [OPTIONS]

FLAGS:
    -c, --count      Show the count of posts
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -n, --number <NUMBER>    Only print the NUMBER most recent posts

Хорошо! Пара простых строчек, и вот у нас уже имеется полноценная инструкция по использованию нашей программы.


Получение необходимой информации


Для того чтобы протестировать нашу программу, нам нужен необходимый материал.


Обернем это в функцию со следующей сигнатурой:


fn get_feed() -> String {
    // implementation
}

Одним из хороших HTTP-клиентов является reqwest. Имеется ещё и hyper от того же автора. Hyper является более "низкоуровневой" библиотекой, в то время как reqwest позволяет решать задачи вида "давайте сделаем это по-быстрому".


[dependencies]
reqwest = "0.8"

Реализуется эта функция достаточно просто:


extern crate reqwest;

pub static URL: &str = "http://readrust.net/rust2018/feed.json";

fn get_feed() -> String {
    let client = reqwest::Client::new();
    let mut request = client.get(URL);

    let mut resp = request.send().unwrap();

    assert!(resp.status().is_success());

    resp.text().unwrap()
}

Это очень похоже на то, как вы бы делали это в других языках программирования: создаем клиента, который позволяет нам делать запросы.


Вызывая send(), мы делаем запрос и получаем ответ.
Вызывая text() у ответа, мы получаем его в виде строки.


Обратите внимание на слово mut. В Rust так объявляются изменяемые (mutable) переменные. Отправляя запрос и получая ответ, мы меняем внутреннее состояние
объекта request, поэтому он должен быть изменяемым.


Наконец, unwrap и assert.
Отправка запроса или считывание ответа это операции, которые могут завершиться неудачно, например, оборвется связь.


Поэтому send ( отправляет запрос) и text ("читает" ответ) возвращают объект Result.


Rust ожидает от нас то, что мы проанализируем содержимое возвращенного объекта и
предпримем необходимые действия. unwrap приводит к панике (panic) — программа завершается, но перед этим "подчищает" за собой использованную память.


Если же не было ошибки, мы получаем необходимое значение. Запрос может быть успешным в том смысле, что сервер ответил, но код возврата HTTP не 200 SUCCESS (Internal Server Error?).


assert препятствует тому, чтобы мы считывали содержимое ответа от запроса, который завершился с ошибкой.


Во многих скриптовых языках в данном месте мы получили бы необработанное исключение (exception), что приводит к схожему эффекту.


В Rust исключений нет — вместо них мы используем ADT (как Maybe в Haskell).


Не бойтесь часто использовать unwrap во время вашего обучения.


Позже вы научитесь использовать другие средства.


Разбор JSON: подключаем serde


Сейчас нам нужно распарсить JSON-ленту.
Для этого Rust имеет serde — библиотеку для (се/десе)риализации.
serde поддерживает не только JSON, но и другие форматы.
serde также предоставляет удобные способы для
задания сериализуемых типов, так называемые derive'ы.


По этой причине нам нужно добавить следующие 3 зависимости:
serde, serde_derive, serde_json.


[dependencies]
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"

serde_json дает вам возможность обращаться с JSON двумя способами: парсить строку в JSON-дерево или сообщать Serde структуру ожидаемой информации.
Второй способ является гораздо более быстрым и удобным.
Посмотрев на определение ленты (feed), мы видим, что имеются 3 главных типа:


  • авторы
  • элементы
  • лента

Лента и элементы имеют автора.


Внесем изменения в код:


extern crate serde;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;

#[derive(Debug, Deserialize, Serialize)]
struct Author {
    name: String,
    url: String,
}

Для того чтобы пример был более наглядным, мы представили URL как обычную строку, однако в будущем мы можем это поменять. Также мы определяем простую структуру данных с двумя полями. Имена этих полей совпадают с соответствующими полями в JSON. Все самое интересное таится в строке с derive.


Она указывает компилятору на то, что следует генерировать дополнительный код, основанный на данной структуре.


  • Debug генерирует отладочное представление в виде строк, что часто бывает полезно
  • Serialize/Deserialize генерируют код для сериализации нашей структуры данных. Это по-прежнему не привязано к формату JSON, который мы планируем с вами использовать.

Cоздадим структуру для представления элемента в ленте:


#[derive(Debug, Deserialize, Serialize)]
struct FeedItem {
    id: String,
    title: String,
    content_text: String,
    url: String,
    date_published: String,
    author: Author,
}

Это похоже на структуры, которые мы уже задавали. Можно видеть, что мы использовали композицию для включения поля тип Author.


Мы назвали наш тип FeedItem, ибо это красноречивее указывает на то, зачем данный тип нужен.


Давайте посмотрим, как будет выглядеть тип нашей ленты:


#[derive(Debug, Deserialize, Serialize)]
struct Feed {
    version: String,
    title: String,
    home_page_url: String,
    feed_url: String,
    description: String,
    author: Author,
    items: Vec<FeedItem>,
}

Здесь нет ничего нового, кроме того, что мы включили поле items, которое представляет собой вектор, который включает в себя элементы ленты.


Vec — стандартный тип в Rust для представления списка чего-либо. Может содержать в себе любой набор объектов одного и того же типа.


Для тех, кто привык к generic`ам в других языках, данное обозначение является уже привычным.


Сделаем так, чтобы get_feed возвращал Feed вместо String:


fn get_feed() -> Feed {
    let client = reqwest::Client::new();
    let mut request = client.get(URL);

    let mut resp = request.send().unwrap();

    assert!(resp.status().is_success());

    let json = resp.text().unwrap();

    serde_json::from_str(&json).unwrap()
}

Здесь осталось добавить только две вещи: мы присваиваем возвращенный текст переменной json и вызываем функцию для парсинга данной переменной.


Так как парсинг может завершаться неуспешно, программа может вернуть Result, содержащий ошибочное значение. Если функция выполнилась успешно, то для того, чтобы извлечь значение нужно вызвать unwrap.


По тому, как мы изменили тип возвращаемого значения в функции get_feed, Rust выяснил, что мы хотим преобразовать JSON-текст в переменную типа Feed.


Если json не содержит в себе правильный (valid) JSON, то программа завершится с ошибкой, поэтому если readrust.net изменит формат кодирования ленты, мы сразу же это заметим.


Подсчет


Мы близки к завершению, однако пока еще не написали код показа результата пользователю.
Исправим — научим нашу программу показывать пользователю количество элементов в ленте.


fn print_count(feed: &Feed) {
    println!("Number of posts: {}", feed.items.len());
}

Посмотрите на &: Rust является языком системного программирования, который предоставляет два способа передачи аргументов:


  • передача владения
    (pass ownership, owned)
  • передача ссылки — заимствование (borrow, borrowed).

Владение — это означает, что вызывающий код потеряет доступ к передаваемому объекту (передаст владение объектом). С теми значениями, которыми вы владеете, вы можете делать все: уничтожать их, игнорировать их, использовать их.


Заимствование — это означает, что вы можете только "посмотреть" на объект, после чего будете должны вернуть объект его владельцу. Владелец можете дать или не дать вам разрешение на изменение объекта. Так как сейчас нам не нужно изменять объект, то мы заимствуем его по неизменяемой (immutable) ссылке.


Вот как это выглядит в main:


let matches = app.get_matches();

let feed = get_feed();

if matches.is_present("count") {
    print_count(&feed);
}

gen_matches определила, какие аргументы были переданы программе.


Вызов is_present позволяет нам узнать, передавал ли пользователь аргумент --count. Заметьте, мы должны использовать & и здесь, чтобы указать компилятору на то, что мы хотим передать объект по ссылке.


Запустим:


[ skade readrust-cli ] cargo run -- --count
   Compiling readrust v0.1.0 (file:///Users/skade/Code/rust/readrust-cli)
    Finished dev [unoptimized + debuginfo] target(s) in 2.46 secs
     Running `target/debug/readrust --count`
Number of posts: 82

Форматированный вывод


Теперь нам осталось научить программу выводить результаты на экран. Я решил вывести таблицу, используя библиотеку prettytable


[dependencies]
prettytable-rs = "0.6"

Посмотрим на один из примеров использования библиотеки и адаптируем её под наш случай:


#[macro_use]
extern crate prettytable;

fn print_feed_table<'feed, I: Iterator<Item = &'feed FeedItem>>(items: I) {
    let mut table = prettytable::Table::new();

    table.add_row(row!["Title", "Author", "Link"]);

    for item in items {
        let title = if item.title.len() >= 50 {
            &item.title[0..49]
        } else {
            &item.title
        };

        table.add_row(row![title, item.author.name, item.url]);
    }

    table.printstd();
}

Здесь есть несколько моментов, на которые стоит обратить внимание:


  • цикл используется для того, чтобы "пробежаться" по элементам и
    вывести их
  • некоторые авторы любям делать заметки с заголовками,
    содержащими в себе более 50 символов, укоротим их

Посмотрим на if, который в Rust является выражением, возвращающим значение.
Это значит, что мы можем присвоить переменной title результат вычисления if. Если взглянете на две возможные ветки выполнения, то снова увидите символы & — это называется "взять срез" (slice). Если заголовок слишком длинный, то мы возьмём ссылку на первые 50 символов, так что нам не приходится копировать его. Сходство с & для обозначения заимствования не случайно: мы заимствуем срез.


Это приводит к тому, что сигнатура выглядит следующими образом:
fn print_feed_table<'feed, I: Iterator<Item = &'feed FeedItem>>(items: I)


Функции могут работать с обобщениями (generics) и я решил добавить их в реализацию print_feed_table. Данная функция принимает объект, который реализует Iterator и выдает нам заимствованные элементы.


Сущности, которые Iterator нам выдает, называются Item — параметр-тип, в данном случае FeedItem. Наконец, здесь имеется 'feed.


Rust проверяет то, что все ссылки указывают на что-то: то, на что указывают, должно существовать.


Данная семантика выражается в сигнатурах функций. Для того чтобы вернуть ссылки на элементы, мы должны убедиться, что данные элементы находятся в памяти. Грубо говоря, <'feed, I: Iterator<Item = &'feed FeedItem>> означает то, что имеется некая сущность вне функции, которая существует в течение времени 'feed


Эта сущность предоставляет нам 'элементы (items), которые мы заимствуем. Мы получаем итератор I, которые "пробегает" по элементам, давая нам элементы для заимствования. Время жизни (lifetime) выражает данные соотношения.


Это выглядит так:


    if matches.is_present("count") {
        print_count(&feed);
    } else {
        let iter = feed.items.iter();

        if let Some(string) = matches.value_of("number") {
            let number = string.parse().unwrap();
            print_feed_table(iter.take(number))
        } else {
            print_feed_table(iter)
        }
    }

Здесь мы видим причину того, почему я выбрал именно эту реализацию. Для того чтобы включить поддержку опции --number, я решил использовать Iterator.


Если пользователь предоставляет число, я преобразовываю его в строку (это, конечно, может завершиться неудачно, если передана случайная строка).


После я преобразовываю набор оставшихся элементов в итератор Take. Take выдает некоторое количество элементов из исходного итератора и после этого завершает свое выполнение.


Все готово! Исходный код вы можете найти здесь


Что делать дальше?


Мы написали программу, которую вы можете расширять.


Например, можно попробовать следующее:


  • вычислить статистику заметок в ленте
  • выбрать только те заметки, заголовки которых соответствуют регулярному выражению
  • улучшить обработку ошибок с выводом более подробных сообщений
  • представить даты переменными соответствующего типа, а не строками (String)
  • пройти по ссылкам и получить содержимое заметок.

Итог


Мы получили программу, в которой проверяем данные на ошибки, завершая
программу после их обработки.


Данные в формате JSON парсятся безопасно, выявляя наличие ошибок.


  • Программа хорошо справляется с неожиданными ситуациями. Например, используя итерацию вместо индексированного доступа к массиву, мы защищены от обращения к элементу за пределами массива => нет segfault'а.


  • Программа написана с использованием хорошо документированных библиотек, с которыми вы можете быстро перейти к написанию своего кода. Ситуация с библиотека в Rust сейчас обстоит гораздо лучше, чем это было, скажем, 2 года назад, у нас уже имеется много зрелых библиотек.


  • Программа переносима (cross-platform) => можно поделиться программой, просто скопировав исполняемый файл.

Вот полный исходный код:


extern crate clap;
#[macro_use]
extern crate prettytable;
extern crate reqwest;
extern crate serde;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;

use clap::App;

pub static URL: &str = "http://readrust.net/rust2018/feed.json";

#[derive(Debug, Deserialize, Serialize)]
struct Author {
    name: String,
    url: String,
}

#[derive(Debug, Deserialize, Serialize)]
struct FeedItem {
    id: String,
    title: String,
    content_text: String,
    url: String,
    date_published: String,
    author: Author,
}

#[derive(Debug, Deserialize, Serialize)]
struct Feed {
    version: String,
    title: String,
    home_page_url: String,
    feed_url: String,
    description: String,
    author: Author,
    items: Vec<FeedItem>,
}

fn print_count(feed: &Feed) {
    println!("Number of posts: {}", feed.items.len());
}

fn print_feed_table<'feed, I: Iterator<Item=&'feed FeedItem>>(items: I) {
    let mut table = prettytable::Table::new();

    table.add_row(row!["Title", "Author", "Link"]);

    for item in items {
        let title = if item.title.len() >= 50 {
            &item.title[0..49]
        } else {
            &item.title
        };

        table.add_row(row![title, item.author.name, item.url]);
    }

    table.printstd();
}

fn get_feed() -> Feed {
    let client = reqwest::Client::new();
    let mut request = client.get(URL);

    let mut resp = request.send().unwrap();

    assert!(resp.status().is_success());

    let json = resp.text().unwrap();

    serde_json::from_str(&json).unwrap()
}

fn main() {
    let app = App::new("readrust")
        .version("0.1")
        .author("Florian G. <florian.gilcher@asquera.de>")
        .about("Reads readrust.net")
        .args_from_usage(
            "-n, --number=[NUMBER] 'Only print the NUMBER most recent posts'
                          -c, --count           'Show the count of posts'",
        );

    let matches = app.get_matches();

    let feed = get_feed();

    if matches.is_present("count") {
        print_count(&feed);
    } else {
        let iter = feed.items.iter();

        if let Some(string) = matches.value_of("number") {
            let number = string.parse().unwrap();
            print_feed_table(iter.take(number))
        } else {
            print_feed_table(iter)
        }
    }
}

Большое спасибо всем из сообщества Rustycrate, кто участвовал в переводе, вычитке и редактировании данной статьи. А именно: born2lose, vitvakatu.


UPDATE: добавил полный исходный код.

Нужна ли Rust поддержка исключений?

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 7
  • +3
    Программа переносима (cross-platform) => можно поделиться программой, просто скопировав исполняемый файл.

    Думаю, автор имел ввиду 1) кросс-компиляцию и 2) бинарник без зависимостей в рамках одной платформы:


    The program is cross-platform and can be compiled on many platforms. You can ship it to other people by just copying the binary around.
    • +2
      Ни то ни то не верно на 100%
      1. Кросс-платформенность ограничена определенным количеством известных платформ. У того же reqwest, на сколько я помню, нет биндингов на ios или на тот же redox (операционная система, разрабатываемая на расте). Следовательно, работать программа не будет.

      2. На линукс reqwest требует OpenSSL, а на виндовс любой бинарник, собраный с msvc-тулчейном (рекомендуемым по умолчанию), требует установленный ms c++ runtime той же версии.

      P.S. Это не критика, а уточнение, полезное для тех, у кого раст — первый системный язык программирования. В отличии от java/python/c#/etc системный язык требует куда более тщательной проверки зависимостей
    • +1

      Я так и не понял, почему request и response должны быть mutable

      • 0
        akurilov, отправляя запрос и получая ответ, мы меняем внутреннее состояние
        объекта request, поэтому он должен быть изменяемым.
        • +1

          Извиняюсь за занудство, но мне всегда казалось что запрос должен заталкиваться в сокет без всяких изменений состояния. Вы не пробовали просто убрать "mut"? Возможно, библиотека имеет какую-то неочевидную структуру, если запрос в процессе отправки изменяется… В общем, выглядит довольно странно.

          • 0
            Мне тоже это кажется нелогичным. Да, без mut не сработает.
            • +1

              В первом случае client.get возвращает структуру RequestBuilder, в котором send требует &mut self, хотя не совсем понятно почему в состав RequestBuilder вообще входит send. В моем понимании он должен собирать структуру Request которая должна потом передаваться инстансу клиента для отправки.


              Во втором случае та же ситуация, у структуры Response метод text требует &mut self, я так понимаю это как то связано с тем что поле body имеет тип async_impl::ReadableChunks<WaitBody>.


              Поэтому обе переменные компилятор требует объявлять мутабельно

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое