Rust: структурирование и обработка ошибок в 2020 году
Перевод | Автор оригинала: nick.groenen
Недавно я начал изучать язык программирования Rust, прочитав «книгу», которая дает феноменальную работу по объяснению основ языка.
Проработав основное содержание книги, я приступил к работе со своим первым нетривиальным, реальным приложением. Но вскоре я столкнулся с вопросом, с которым еще не чувствовал себя хорошо подготовленным:
«Как вы должны структурировать обработку ошибок в зрелом приложении для Rust?»
В этой статье описывается мой путь к ответу на этот вопрос. Я попытаюсь объяснить шаблон, который я выбрал, вместе с примером кода, показывающим его реализацию, в надежде, что другим новичкам будет легче начать работу.
Вступление
В книге рассматриваются основы обработки ошибок, включая использование типа std::Result и распространение ошибок с помощью? оператора, он в значительной степени замалчивает различные шаблоны использования этих инструментов в реальных приложениях или компромиссы, связанные с различными подходами.
Когда я начал изучать передовой опыт, я наткнулся на довольно много устаревших советов по использованию крэйта отказов. Неудача имела полуофициальный вид из-за того, что она находилась в пространстве имен rust-lang-nurry, но недавно она устарела.
За последние два года в трейт std::error::Error был внесен ряд улучшений.
Это в целом сделало отказ менее необходимым и привело к появлению ряда более современных библиотек, использующих эти улучшения, чтобы предложить лучшую эргономику.
Прочитав довольно много исторического контекста и оценив ряд библиотек, я остановился на (в основном независимом от библиотеки) шаблоне для структурирования ошибок, который я реализую с помощью крэйтов anyhow и thiserror.
Остальная часть этой статьи будет:
- Представьте относительно тривиальное приложение для подсчета слов, чтобы исследовать и объяснять проблемное пространство.
- Объясните, почему приложения и библиотеки должны использовать разные шаблоны обработки ошибок.
- Продемонстрируйте, как применять эти шаблоны, используя anyhow и thiserror.
Подсчет слов
Давайте представим пример кода для использования в оставшейся части этой статьи. Мы создадим программу для подсчета количества слов в текстовом файле, как это делает wc -w.
Наивная реализация с базовой обработкой ошибок с использованием std::Result может выглядеть так:
use std::env;
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;
use std::io::BufReader;
/// Count the number of words in the given input.
///
/// Any potential errors, such as being unable to read from the input will be propagated
/// upwards as-is due to the use of `line?` just before `split_whitespace()`.
fn count_words<R: Read>(input: &mut R) -> Result<u32, Box<dyn Error>> {
let reader = BufReader::new(input);
let mut wordcount = 0;
for line in reader.lines() {
for _word in line?.split_whitespace() {
wordcount += 1;
}
}
Ok(wordcount)
}
fn main() -> Result<(), Box<dyn Error>> {
for filename in env::args().skip(1).collect::<Vec<String>>() {
let mut reader = File::open(&filename)?;
let wordcount = count_words(&mut reader)?;
println!("{} {}", wordcount, filename);
}
Ok(())
}
Давайте создадим входной файл для нашего нового счетчика слов и попробуем его запустить:
$ fortune > words.txt
$ cargo run --quiet -- words.txt
50 words.txt
Однако, если у вас нет файла words.txt, вы столкнетесь со следующей ошибкой:
$ cargo run --quiet -- words.txt
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
Эта ошибка является результатом того, что File::open() возвращает ошибку в main().
Чтобы завершить пример, давайте также смоделируем ошибку в вызове read(), происходящую под капотом внутри count_words().
так что мы можем увидеть, как это выглядит:
$ cargo run --quiet -- words.txt
Error: Custom { kind: BrokenPipe, error: "read: broken pipe" }
Отсутствует контекст
Так что же не так с вышеуказанной ошибкой? Хотя основная причина ошибки («сломанная труба») ясна, мы упускаем большую часть контекста. Мы не можем сказать, какой файл не удалось открыть, и нет информации о последовательности событий, приведших к этой ошибке.
Если подумать, здесь есть цепочка ошибок:
- main() возвращает ошибку, потому что count_words() возвращает ошибку.
- count_words() возвращает ошибку, потому что мы сталкиваемся с ошибкой, повторяющейся в reader.lines() (строки 14-15).
- Итерация ошибок reader.lines(), потому что мы внедрили реализацию std::io::Read, которая дает сбой при первом вызове read().
Однако мы не видим, чтобы это отражалось в приведенных выше сообщениях об ошибках.
В этом примере имя файла является входным аргументом для самой программы. Это упрощает сопоставление ошибки с файлом, который он пытался открыть.
А теперь представьте ошибку, происходящую 5 вызовов глубоко внутри библиотеки в гораздо более крупном программном обеспечении. Без какой-либо информации о цепочке событий в таком случае быстро становится очень трудно понять, что может вызвать ошибку.
Библиотеки против приложений
Ранее я упоминал две разные библиотеки, anyhow и thiserror (хотя обе принадлежат одному автору, dtolnay). Вам может быть интересно, зачем нам нужны две отдельные библиотеки, чтобы делать что-то столь простое, как обработка ошибок.
Мне потребовалось время, чтобы оценить это различие, но есть смысл подходить к обработке ошибок по-разному между библиотеками и приложениями, поскольку они, как правило, имеют разные проблемы:
- Библиотеки должны сосредоточиться на создании осмысленных, структурированных типов / вариантов ошибок. Это позволяет приложениям легко различать различные случаи ошибок.
- Приложения в основном потребляют ошибки.
- Библиотеки могут захотеть преобразовать ошибки из одного типа в другой. Ошибка ввода-вывода, вероятно, должна быть заключена в тип ошибки высокого уровня, предоставляемый библиотекой.
- В противном случае ошибку ввода-вывода в библиотеке foo нельзя будет отличить от аналогичной ошибки ввода-вывода в панели библиотеки.
- Если этого не сделать, потребителю также необходимо знать внутреннее устройство библиотеки. Например, могут ли возвращаться только ошибки ввода-вывода? А как насчет ошибок HTTP, которые могут исходить - от клиента HTTP, внутреннего по отношению к библиотеке?
- Библиотеки должны быть осторожны при изменении ошибок или создании новых ошибок, поскольку они могут легко внести критические изменения для потребителей. Они могут вызывать новые внутренние ошибки, но они вряд ли потребуют специальной структуры и могут быть легко изменены по желанию.
- Если библиотеки возвращают ошибки, приложения решают, будут ли и как эти ошибки форматироваться и отображаться для пользователей.
- Приложения могут также захотеть анализировать и проверять ошибки, например, чтобы перенаправить их в службы отслеживания исключений или повторить операции, когда это считается безопасным.
Кроме того, и я думаю, что это очень важно, библиотеки всегда должны использовать std::Result вместе с типом ошибки, реализующим std::error::Error в своих общедоступных API. Пользовательские типы результатов, такие как failure::Fail, могут плохо сочетаться с другими частями вашего пользовательского кода и заставлять их изучать еще одну библиотеку.
Границы API
Возвращаясь к нашему примеру с подсчетом слов, представьте, что мы хотим сделать count_words доступным в виде публичной библиотеки. Обычно вы не сделали бы этого для такого небольшого и простого фрагмента кода, но может быть полезно сделать функциональность доступной через общедоступные крэйти в более крупных проектах.
В качестве демонстрации мы можем определить границы в нашем счетчике слов, чтобы разделить этот код на библиотеку и часть приложения.
Мы извлечем count_words в крэйт библиотеки с именем wordcounter. Ниже я выделю соответствующие части, но если вы хотите пропустить, вы можете найти полный src/wordcounter.rs на GitHub.
Все, что находится за пределами count_words, - это код нашего приложения. Он будет жить в двоичном крэйте, который мы назовем rwc (от Rust Word Count - я знаю, очень оригинально). Соответствующие файлы для этого - src/main.rs и src/lib.rs.
Тип ошибки библиотеки
Для нашей библиотеки wordcounter мы определим тип ошибки верхнего уровня под названием WordCountError. В этом перечислении есть варианты ошибок для каждой возможной ошибки, с которой может столкнуться наша библиотека.
Вот тут-то и появляется эта ошибка. Хотя мы можем реализовать это вручную, thiserror позволяет нам избежать написания большого количества шаблонного кода:
use thiserror::Error;
/// WordCountError enumerates all possible errors returned by this library.
#[derive(Error, Debug)]
pub enum WordCountError {
/// Represents an empty source. For example, an empty text file being given
/// as input to `count_words()`.
#[error("Source contains no data")]
EmptySource,
/// Represents a failure to read from input.
#[error("Read error")]
ReadError { source: std::io::Error },
/// Represents all other cases of `std::io::Error`.
#[error(transparent)]
IOError(#[from] std::io::Error),
}
(Цитата из официальной документации: «Thiserror намеренно не появляется в вашем общедоступном API. Вы получаете то же самое, как если бы вы написали реализацию std::error::Error вручную, и переключитесь с рукописных импликаций на thiserror или наоборот. не является критическим изменением. ")
С этим типом ошибки мы теперь можем изменить подпись count_words следующим образом:
fn count_words<R: Read>(input: &mut R) -> Result<u32, WordCountError> { /* .. */ }
Помните, ранее подпись выглядела так:
fn count_words<R: Read>(input: &mut R) -> Result<u32, Box<dyn Error>> { /* .. */ }
По сравнению с предыдущей версией наш новый код намного более конкретен. Теперь пользователи получают гораздо больше информации о возможных случаях ошибок, которые могут быть возвращены. В качестве дополнительного преимущества нам также больше не нужно использовать Box Error, потому что размер WordCountError может быть определен во время компиляции.
Возврат ошибок библиотеки
В WordCountError выше мы указываем три возможных типа ошибок.
EmptySource может рассматриваться как ошибка, связанная с нашим бизнес-доменом. Мы можем вернуть это из нашей функции count_words, используя следующий код:
if wordcount == 0 {
return Err(WordCountError::EmptySource);
}
ReadError - это пример включения ошибки более низкого уровня в ошибку нашей библиотеки высокого уровня. Это используется для возврата значимой ошибки для ошибок чтения, и его можно увидеть здесь:
for line in reader.lines() {
let line = line.map_err(|source| WordCountError::ReadError { source })?;
for _word in line.split_whitespace() {
wordcount += 1;
}
}
Самый интересный код в приведенном выше фрагменте находится в строке 2, которая содержит line.map_Err(| source | WordCountError::ReadError {source})?;. Тем не менее, здесь происходит довольно много всего, поэтому давайте рассмотрим это шаг за шагом:
- Мы перебираем строки из читателя, которые возвращаются как io::Result
, потому что операции чтения могут завершиться ошибкой. - Если результат относится к варианту Err, наше использование map_err() преобразует значение ошибки, встроенное в этот результат, из io::Error в WordCountError::ReadError. Если результат - вариант ОК, он остается без изменений.
- Затем распаковываем результат с помощью? оператор. Если это был вариант Ok, то он присваивается переменной строке. Если это был вариант Err, функция завершается здесь, возвращая это как возвращаемое значение (помните, что тип возврата - Result<u32, WordCountError>).
Поскольку мы инкапсулируем io::Error в исходный атрибут WordCountError::ReadError, наша цепочка контекст / ошибка остается неизменной. Это гарантирует, что в любом случае, что мы будем использовать в описании нижеприведенных приложений, в конечном итоге отобразятся обе ошибки.
.
Прозрачная пересылка
На этом этапе стоит отметить, что ошибки могут использовать error (transparent) для перенаправления методов source и Display напрямую к основной ошибке без добавления дополнительного сообщения. Это можно увидеть в случае WordCountError::IOError, который действует как универсальный вариант для всех других ошибок ввода-вывода.
Если бы нас не интересовал специализированный вариант WordCountError::ReadError, это означало бы, что мы могли бы также написать наш код следующим образом, и в этом случае нам больше не нужно использовать map_err() и мы можем использовать? напрямую:
for line in reader.lines() {
for _word in line?.split_whitespace() {
wordcount += 1;
}
}
С помощью этого шаблона мы избегаем добавления дополнительного кода обертывания ошибок, но при этом преобразуем ошибки в наш высокоуровневый WordCountError, чтобы поддерживать чистоту нашего общедоступного API.
Ошибки приложения
Имея указанный выше API, мы можем настроить остальную часть нашего кода для решения проблем на уровне приложения, таких как синтаксический анализ аргументов и вызов wordcounter::count_words.
Как бы то ни было, мы можем получить эту основную функцию:
// Some `use` statements have been omitted here for brevity
use anyhow::{Context, Result};
fn main() -> Result<()> {
for filename in env::args().skip(1).collect::<Vec<String>>() {
let mut reader = File::open(&filename).context(format!("unable to open '{}'", filename))?;
let wordcount =
count_words(&mut reader).context(format!("unable to count words in '{}'", filename))?;
println!("{} {}", wordcount, filename);
}
Ok(())
}
Это привело к нескольким изменениям.
1. Упрощенный тип результата
Вместо того, чтобы создавать собственные типы ошибок или везде использовать std::Result<T, Box
В случае с main() выше это позволяет нам в любом случае напрямую возвращать::Result<()>. Это кажется мелочью, но я считаю, что возможность сосредоточиться только на типе данных успеха без необходимости аннотировать дополнительные типы ошибок добавляет здесь много ясности.
2. Аннотирование ошибок
Трэйта anyhow::Context, которую мы ввели через use anyhow::Context выше, включает метод context() для типов Result. Это позволяет нам заключать / аннотировать ошибки с дополнительной информацией более эргономичным способом записи, чем подход map_err, используемый в коде библиотеки:
let mut reader = File::open(&filename)
.context(format!("unable to open '{}'", filename))?;
let wordcount = count_words(&mut reader)
.context(format!("unable to count words in '{}'", filename))?;
Это предоставляет пользователю приложения ценную информацию о том, что предпринималось в случае возникновения ошибки. С этими вызовами наши ошибки теперь будут отображаться следующим образом:
$ cargo run --quiet -- words.txt
Error: unable to open 'words.txt'
Caused by:
No such file or directory (os error 2)
$ cargo run --quiet -- words.txt
Error: unable to count words in 'words.txt'
Caused by:
0: Error encountered while reading from input
1: read: broken pipe
В обоих случаях наше сообщение об ошибке теперь включает имя файла, с которым мы работали. Мы также описываем, какая высокоуровневая операция была предпринята, когда возникла проблема.
3. Отображение ошибки
Вы заметите, что нам не пришлось писать какой-либо дополнительный код форматирования ошибок, чтобы получить эти красивые сообщения об ошибках. Все, что нам нужно было сделать, это изменить тип возвращаемого значения main на тип Result.
Нет необходимости полагаться на это неявное поведение при возврате Result из main. Мы могли бы вместо этого переместить весь наш код в функцию выполнения, а затем написать main следующим образом:
fn main() {
if let Err(err) = wordcount::run() {
eprintln!("Error: {:?}", err);
std::process::exit(1);
}
}
Это приведет к точно такому же результату.
Одно из преимуществ этого подхода (помимо большего контроля над выходом нашей программы, например, с помощью другого кода выхода) заключается в том, что он позволяет нам изменять формат сообщения.
Например, если мы используем вместо этого eprintln! ("{: #?}", Err) (обратите внимание на {: #?} Vs {:?}), Мы получим представление в стиле структуры:
$ cargo run --quiet -- words.txt
Error {
context: "unable to count words in \'words.txts\'",
source: ReadError {
source: Custom {
kind: BrokenPipe,
error: "read: broken pipe",
},
},
}
(Различные варианты задокументированы в любом случае в Медийных представлениях.)
Обратные следы
До сих пор мы не говорили об обратных трассировках, которые часто используются при отладке сложных проблем.
В любом случае также позволяет нам фиксировать и отображать обратную трассировку при возникновении ошибки. На данный момент поддержка обратной трассировки доступна только в ночном Rust, поскольку модуль std::backtrace в настоящее время является экспериментальным API, предназначенным только для ночной работы.
При использовании ночного канала соответствующая установка RUST_BACKTRACE включит обратную трассировку:
$ RUST_BACKTRACE=1 cargo run --quiet -- words.txt
Error: unable to count words in 'words.txt'
Caused by:
0: Error encountered while reading from input
1: read: broken pipe
0: <E as anyhow::context::ext::StdError>::ext_context
at /home/zoni/.cargo/registry/src/github.com-1ecc6299db9ec823/anyhow-1.0.28/src/backtrace.rs:26
1: anyhow::context::<impl anyhow::Context<T,E> for core::result::Result<T,E>>::context::{{closure}}
at /home/zoni/.cargo/registry/src/github.com-1ecc6299db9ec823/anyhow-1.0.28/src/context.rs:50
2: core::result::Result<T,E>::map_err
at /rustc/2454a68cfbb63aa7b8e09fe05114d5f98b2f9740/src/libcore/result.rs:612
3: anyhow::context::<impl anyhow::Context<T,E> for core::result::Result<T,E>>::context
at /home/zoni/.cargo/registry/src/github.com-1ecc6299db9ec823/anyhow-1.0.28/src/context.rs:50
4: wordcount::run
at src/lib.rs:58
5: rwc::main
at src/main.rs:9
6: std::rt::lang_start::{{closure}}
at /rustc/2454a68cfbb63aa7b8e09fe05114d5f98b2f9740/src/libstd/rt.rs:67
7: std::rt::lang_start_internal::{{closure}}
at src/libstd/rt.rs:52
std::panicking::try::do_call
at src/libstd/panicking.rs:297
std::panicking::try
at src/libstd/panicking.rs:274
std::panic::catch_unwind
at src/libstd/panic.rs:394
std::rt::lang_start_internal
at src/libstd/rt.rs:51
8: std::rt::lang_start
at /rustc/2454a68cfbb63aa7b8e09fe05114d5f98b2f9740/src/libstd/rt.rs:67
9: main
10: __libc_start_main
11: _start
Обычно я нахожу обратные следы Rust слишком загадочными и запутанными, чтобы они могли сильно помочь, поэтому их отсутствие поддержки на стабильном канале не было проблемой для меня лично. Пока что для меня было более чем достаточно отображения цепочки ошибок.
Вывод
На этом история ошибок Rust не заканчивается. Изменения все еще происходят, и еще неизвестно, останутся ли эти две библиотеки такими же популярными, как сегодня.
Одно можно сказать наверняка: история обработки ошибок прошла долгий путь, и с текущим состоянием Rust вы можете писать очень надежное программное обеспечение в приятной и практичной манере.
Надеюсь, эта статья оказалась для вас полезной. Если вы это сделали, рассмотрите возможность отправки быстрой благодарственной записки по электронной почте или через твит на @NickGroenen.
Отзывы и разговоры
На Reddit ведется небольшая дискуссия, и один опубликованный там комментарий, кажется, стоит включить сюда. /u/Yaahallo пишет:
-
Я думаю, что то, что обработка ошибок различается в зависимости от того, пишете ли вы библиотеку или приложение, является упрощением, обычным для сообщества rust, но также источником путаницы.
-
Причины использования anyhow vs thiserror на самом деле основаны не на том, библиотека это или приложение, а на том, нужно ли вам обрабатывать ошибки или сообщать о них.
-
Библиотеки часто хотят поддерживать как можно больше вариантов использования обработки ошибок для своих потребителей. В конечном итоге это означает, что они хотят экспортировать типы ошибок, которые являются как обрабатываемыми (он же перечисление), так и отчетными (он же реализует std::error::Error).
-
С другой стороны, приложения часто в конечном итоге выполняют обработку ошибок или составление отчетов. Для работы вам обычно не нужна библиотека, вы просто используете match. Для сообщения вам нужен тип ошибки, или, точнее, тип сообщения об ошибке, для чего в любом случае и предназначен::Error.
Burntsushi (от ripgrep fame) согласен со многими моими замечаниями, но также ставит под сомнение использование библиотек на основе proc-макросов, таких как thiserror, для определенных случаев использования, в первую очередь из-за увеличения времени компиляции в результате их использования. В дополнение к своей точке зрения он показывает нам, как вручную написать реализацию WordCountError из этой статьи.
Также есть интересная тема, касающаяся влияния на производительность использования context() по сравнению с with_context().