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

оригинал: Florian Gilcher • перевод: bmusin • обучение • поддержите на Patreon

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

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

Скачаем Rust

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

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

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

1
rustup install stable

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

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

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

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

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

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

1
cargo init --bin readrust-cli

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

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

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

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

1
2
3
4
5
6
[package]
name = "readrust-cli"
version = "0.1.0"
authors = ["Florian Gilcher <florian.gilcher@asquera.de>"]

[dependencies]

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

Запустим:

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

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

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

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

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

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

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

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

CLAP

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

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

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

1
2
[dependencies]
clap = "2.29"

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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, для того чтобы получить сообщение-инструкцию:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

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

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

Теперь нам нужны данные, которые мы будем обрабатывать в соответствии с полученными выше аргументами. Давайте выделим получение данных в функцию со следующей сигнатурой:

1
2
3
fn get_feed() -> String {
    // реализация
}

Мы будем использовать простой HTTP-клиент reqwest, но более опытные пользователи могут обратить внимание на более низкоуровневую библиотеку hyper от того же автора.

1
2
[dependencies]
reqwest = "0.8"

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.

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

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

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

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

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

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

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

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

1
2
3
4
[dependencies]
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"

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

Лента и элементы имеют автора. Внесём изменения в код:

1
2
3
4
5
6
7
8
9
10
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.

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

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

1
2
3
4
5
6
7
8
9
#[derive(Debug, Deserialize, Serialize)]
struct FeedItem {
    id: String,
    title: String,
    content_text: String,
    url: String,
    date_published: String,
    author: Author,
}

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

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

1
2
3
4
5
6
7
8
9
10
#[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:

1
2
3
4
5
6
7
8
9
10
11
12
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 изменит формат кодирования ленты, мы сразу же это заметим.

Подсчёт

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

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

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

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

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

1
2
3
4
5
6
7
let matches = app.get_matches();

let feed = get_feed();

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

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

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

1
2
3
4
5
[ 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

1
2
[dependencies]
prettytable-rs = "0.6"

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[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();
}

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

Заметьте, 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. Наконец, здесь указано время жизни (lifetime) 'feed.

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
    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 выдаёт некоторое количество элементов из исходного итератора и после этого завершает своё выполнение.

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

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

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

Итог

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

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