Rust: скачиваем ленту и парсим JSON
Я покажу вам, как написать небольшую программу, которая скачивает ленту (feed) в формате JSON, парсит и выводит список заметок на консоль в форматированном виде.
У нас все вылилось в очень лаконичный код. Как? Смотрите под катом.
Скачаем Rust
Обычным способом получения Rust является использование программы rustup.
Проверьте, возможно, rustup
уже имеется в репозитории вашего дистрибутива.
rustup
управляет наборами (toolchains) утилит для сборки проектов.
Он позволяет вам изменять используемую версию Rust, управлять дополнительными средствами
разработки, например RLS (Rust Language Server), и скачивать утилиты для разработки под разные
платформы.
Когда у вас будет установлен Rust, наберите следующую команду:
1 | rustup install stable |
Для этого примера нам нужно использовать как минимум Rust версии 1.20, потому что это требуют некоторые зависимости.
Посмотрим, что у нас есть.
Эта команда установила:
- rustc — компилятор
- cargo — менеджер пакетов и сборщик проекта
- документацию
- некоторые другие инструменты.
Для просмотра документации в браузере наберите 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 для разбора ленты * Функция для форматированного вывода результатов на экран. |
Касательно набора возможностей, я хочу добавить немного дополнительных условий, достаточных для того, чтобы приступить к работе:
- программа будет принимать ключ
--count
, который велит программе вывести количество заметок в ленте - программа будет принимать ключ
--number [NUMBER]
, задающий количество заметок, которое должна вывести программа.
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
.
Она указывает компилятору на то, что следует генерировать дополнительный код, основанный на данной структуре.
- Debug генерирует отладочное представление в виде строк, что часто бывает полезно
- Serialize/Deserialize генерируют код для сериализации нашей структуры данных. Это по-прежнему не привязано к формату JSON, который мы планируем использовать.
Создадим структуру для представления элемента в ленте:
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 для представления списка чего-либо. Он может
содержать в себе любой набор объектов одного и того же типа. Для тех, кто
привык к генерикам в других языках, данное обозначение является уже привычным.
Сделаем так, чтобы 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 является языком системного программирования, который
предоставляет два способа передачи аргументов:
- передача владения
(
pass ownership
, owned) - передача ссылки — заимствование (
borrow
, borrowed).
Владение — это означает, что вызывающий код потеряет доступ к передаваемому объекту (передаст владение объектом). С теми значениями, которыми вы владеете, вы можете делать все: уничтожать их, игнорировать их, использовать их. Заимствование — это означает, что вы можете только «посмотреть» на объект, после чего будете должны вернуть объект его владельцу. Владелец можете дать или не дать вам разрешение на изменение объекта. Так как сейчас нам не нужно изменять объект, то мы заимствуем его по неизменяемой (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(); } |
Здесь есть несколько моментов, на которые стоит обратить внимание:
- цикл используется для того, чтобы «пробежаться» по элементам и вывести их
- некоторые авторы любят делать заметки с заголовками, содержащими в себе более 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
. Наконец, здесь указано
время жизни (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
выдаёт некоторое количество элементов из исходного итератора и после этого
завершает своё выполнение.
Все готово! Исходный код вы можете найти здесь.
Что делать дальше?
Мы написали программу, которую вы можете расширять. Например, можно попробовать следующее:
- вычислить статистику заметок в ленте
- выбрать только те заметки, заголовки которых соответствуют регулярному выражению
- улучшить обработку ошибок с выводом более подробных сообщений
- представить даты переменными соответствующего типа, а не строками (
String
) - пройти по ссылкам и получить содержимое заметок.
Итог
Мы получили программу из 79 строк в которой проверяем данные на ошибки, завершая программу после их обработки.
Данные в формате JSON
парсятся безопасно, выявляя наличие ошибок.
- Программа хорошо справляется с неожиданными ситуациями. Например, используя итерацию вместо индексированного доступа к массиву, мы защищены от обращения к элементу за пределами массива, то есть от segmentation fault.
- Программа написана с использованием хорошо документированных библиотек, с которыми вы можете быстро перейти к написанию своего кода. Ситуация с библиотека в Rust сейчас обстоит гораздо лучше, чем это было, скажем, 2 года назад, у нас уже имеется много зрелых библиотек.
- Код программы может быть скомпилирован под разные платформы (cross-platform).