Counter App Error Handling
前のセクションでは、左および右矢印キーを押すために、カウンターの値を制御するarrowを押すユーザーに応答する basic counter app を作成しました。このチュートリアルは、そのコードから始まり、エラーとパニック処理を追加します。
基本的なアプリで中断した場所を簡単に思い出させる:
Cargo.toml (click to expand)
# -- snip --
[dependencies]ratatui = "0.29.0"crossterm = "0.28.1"
main.rs (click to expand)
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};use ratatui::{ buffer::Buffer, layout::Rect, style::Stylize, symbols::border, text::{Line, Text}, widgets::{Block, Paragraph, Widget}, DefaultTerminal, Frame,};
fn main() -> io::Result<()> { let mut terminal = ratatui::init(); let app_result = App::default().run(&mut terminal); ratatui::restore(); app_result}
#[derive(Debug, Default)]pub struct App { counter: u8, exit: bool,}
impl App { /// runs the application's main loop until the user quits pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { while !self.exit { terminal.draw(|frame| self.draw(frame))?; self.handle_events()?; } Ok(()) }
fn draw(&self, frame: &mut Frame) { frame.render_widget(self, frame.area()); }
/// updates the application's state based on user input fn handle_events(&mut self) -> io::Result<()> { match event::read()? { // it's important to check that the event is a key press event as // crossterm also emits key release and repeat events on Windows. Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { self.handle_key_event(key_event) } _ => {} }; Ok(()) }
fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event.code { KeyCode::Char('q') => self.exit(), KeyCode::Left => self.decrement_counter(), KeyCode::Right => self.increment_counter(), _ => {} } }
fn exit(&mut self) { self.exit = true; }
fn increment_counter(&mut self) { self.counter += 1; }
fn decrement_counter(&mut self) { self.counter -= 1; }}
impl Widget for &App { fn render(self, area: Rect, buf: &mut Buffer) { let title = Line::from(" Counter App Tutorial ".bold()); let instructions = Line::from(vec![ " Decrement ".into(), "<Left>".blue().bold(), " Increment ".into(), "<Right>".blue().bold(), " Quit ".into(), "<Q> ".blue().bold(), ]); let block = Block::bordered() .title(title.centered()) .title_bottom(instructions.centered()) .border_set(border::THICK);
let counter_text = Text::from(vec![Line::from(vec![ "Value: ".into(), self.counter.to_string().yellow(), ])]);
Paragraph::new(counter_text) .centered() .block(block) .render(area, buf); }}
#[cfg(test)]mod tests {
use super::*; use ratatui::style::Style;
#[test] fn render() { let app = App::default(); let mut buf = Buffer::empty(Rect::new(0, 0, 50, 4));
app.render(buf.area, &mut buf);
let mut expected = Buffer::with_lines(vec![ "┏━━━━━━━━━━━━━ Counter App Tutorial ━━━━━━━━━━━━━┓", "┃ Value: 0 ┃", "┃ ┃", "┗━ Decrement <Left> Increment <Right> Quit <Q> ━━┛", ]); let title_style = Style::new().bold(); let counter_style = Style::new().yellow(); let key_style = Style::new().blue().bold(); expected.set_style(Rect::new(14, 0, 22, 1), title_style); expected.set_style(Rect::new(28, 1, 1, 1), counter_style); expected.set_style(Rect::new(13, 3, 6, 1), key_style); expected.set_style(Rect::new(30, 3, 7, 1), key_style); expected.set_style(Rect::new(43, 3, 4, 1), key_style);
assert_eq!(buf, expected); }
#[test] fn handle_key_event() -> io::Result<()> { let mut app = App::default(); app.handle_key_event(KeyCode::Right.into()); assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into()); assert_eq!(app.counter, 0);
let mut app = App::default(); app.handle_key_event(KeyCode::Char('q').into()); assert!(app.exit);
Ok(()) }}
問題
前のセクションで作成したアプリには、カウンターがすでに 0 のときにユーザーが 左 矢印キーを押すとアプリがパニックになる意図的なエラーがあります。これが発生すると、メイン関数は終了する前にターミナル状態を復元する機会がありません。
fn main() -> io::Result<()> { let mut terminal = ratatui::init(); let app_result = App::default().run(&mut terminal); ratatui::restore(); app_result}
アプリケーションのデフォルトのパニック ハンドラが実行され、詳細が乱雑に表示されます。これは、raw モードではターミナルが通常の方法で改行を解釈できなくなるためです。シェル プロンプトも間違った場所に表示されます。
これから回復するには、macosまたはLinuxコンソールで、 reset
コマンドを実行します。Windowsコンソールでは、コンソールを再起動する必要がある場合があります。
セットアップフック
Rust アプリケーションが失敗する方法は 2 つあります。Rust の書籍の エラー処理 の章で、これについてより詳しく説明されています。
Rust はエラーを recoverable と unrecoverable の 2 つの主なカテゴリに分類します。file not found error などの回復可能なエラーの場合、おそらくユーザーに問題を報告して操作を再試行するだけです。回復不可能なエラーは常にバグの症状であり、配列の末尾を超える場所にアクセスしようとしている場合など、プログラムを直ちに停止する必要があります。— https://doc.rust-lang.org/book/ch09-00-error-handling.html
未処理のエラーを簡単に表示する方法の 1 つは、color-eyre クレートを使用してエラー報告フックを拡張することです。[raw モード] の alternate screen で実行されている ratatui アプリケーションでは、これらのエラーをユーザーに表示する前にターミナルを復元することが重要です。
color-eyre
クレートを追加します
cargo add color-eyre
main
functionのreturn値をcolor_eyre::Result<()>
に更新し、color_eyre::install
関数を呼び出します。また、アプリユーザーがターミナルを復元した場合に何をすべきかを理解するのに役立つエラーメッセージを追加することもできます。
use color_eyre::{ eyre::{bail, WrapErr}, Result,};
fn main() -> Result<()> { color_eyre::install()?; let mut terminal = tui::init()?; let app_result = App::default().run(&mut terminal); if let Err(err) = tui::restore() { eprintln!( "failed to restore terminal. Run `reset` or restart your terminal to recover: {}", err ); } app_result}
次に、tui::init()
関数を更新して、パニック フックを、パニック情報を出力する前にまずターミナルを復元するフックに置き換えます。これにより、アプリケーションが終了したときに、パニックと未処理のエラー (つまり、メイン関数の最上位レベルにバブルアップする Result::Err
) の両方がターミナルに正しく表示されるようになります。
/// Initialize the terminalpub fn init() -> io::Result<Tui> { execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; set_panic_hook(); Terminal::new(CrosstermBackend::new(stdout()))}
fn set_panic_hook() { let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { let _ = restore(); // ignore any errors as we are already failing hook(panic_info); }));}
Color_eyreを使用する
Color eyre は、結果に追加情報を追加することで機能します。wrap_err
(color_eyre::eyre::WrapErr
trait で定義) を呼び出すことで、エラーにコンテキストを追加できます。
App::run
関数を更新して、更新関数の失敗に関する情報を追加し、戻り値を変更します。
impl App { /// runs the application's main loop until the user quits pub fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { while !self.exit { terminal.draw(|frame| self.render_frame(frame))?; self.handle_events().wrap_err("handle events failed")?; } Ok(()) }}
回復可能なエラーを作成する
チュートリアルでは、回復可能なエラーの処理方法を示すために合成エラーが必要です。
handle_key_event
を変更して color_eyre::Result
を返すようにし、増分呼び出しと減分呼び出しに ?
演算子が含まれ、呼び出し元にエラーが伝わるようにしてください。
impl App { fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> { match key_event.code { KeyCode::Char('q') => self.exit(), KeyCode::Left => self.decrement_counter()?, KeyCode::Right => self.increment_counter()?, _ => {} } Ok(()) }}
カウンターが2を超えているときに発生するエラーを追加しましょう。また、両方のメソッドの戻りタイプを変更します。 increment_counter
メソッドに新しいエラーを追加します。これには bail!
マクロを使用できます。
impl App { fn decrement_counter(&mut self) -> Result<()> { self.counter -= 1; Ok(()) }
fn increment_counter(&mut self) -> Result<()> { self.counter += 1; if self.counter > 2 { bail!("counter overflow"); } Ok(()) }}
handle_events
メソッドで、どのキーが障害を引き起こしたかについての追加情報を追加し、返品値を更新します。
impl App { /// updates the application's state based on user input fn handle_events(&mut self) -> Result<()> { match event::read()? { // it's important to check that the event is a key press event as // crossterm also emits key release and repeat events on Windows. Event::Key(key_event) if key_event.kind == KeyEventKind::Press => self .handle_key_event(key_event) .wrap_err_with(|| format!("handling key event failed:\n{key_event:#?}")), _ => Ok(()), } }}
このメソッドのテストを更新して、handle_key_eventsへの呼び出しを解除します。これにより、エラーが返されるとテストが失敗します。
mod tests { #[test] fn handle_key_event() { let mut app = App::default(); app.handle_key_event(KeyCode::Right.into()).unwrap(); assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into()).unwrap(); assert_eq!(app.counter, 0);
let mut app = App::default(); app.handle_key_event(KeyCode::Char('q').into()).unwrap(); assert!(app.exit); }}
パニックおよびオーバーフロー条件のテストを追加します
mod tests { #[test] #[should_panic(expected = "attempt to subtract with overflow")] fn handle_key_event_panic() { let mut app = App::default(); let _ = app.handle_key_event(KeyCode::Left.into()); }
#[test] fn handle_key_event_overflow() { let mut app = App::default(); assert!(app.handle_key_event(KeyCode::Right.into()).is_ok()); assert!(app.handle_key_event(KeyCode::Right.into()).is_ok()); assert_eq!( app.handle_key_event(KeyCode::Right.into()) .unwrap_err() .to_string(), "counter overflow" ); }}
テストを実行します:
cargo test
running 4 teststhread 'tests::handle_key_event_panic' panicked at code/counter-app-error-handling/src/main.rs:94:9:attempt to subtract with overflowtest tests::handle_key_event ... okstack backtrace:
test tests::handle_key_event_overflow ... oktest tests::render ... ok20 collapsed lines
0: rust_begin_unwind at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/std/src/panicking.rs:645:5 1: core::panicking::panic_fmt at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:72:14 2: core::panicking::panic at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:144:5 3: counter_app_error_handling::App::decrement_counter at ./src/main.rs:94:9 4: counter_app_error_handling::App::handle_key_event at ./src/main.rs:79:30 5: counter_app_error_handling::tests::handle_key_event_panic at ./src/main.rs:200:17 6: counter_app_error_handling::tests::handle_key_event_panic::{{closure}} at ./src/main.rs:198:32 7: core::ops::function::FnOnce::call_once at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/ops/function.rs:250:5 8: core::ops::function::FnOnce::call_once at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/ops/function.rs:250:5note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.test tests::handle_key_event_panic - should panic ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
完成したアプリ
これを完全に言えば、これで次のファイルが必要です。
main.rs (click to expand)
use color_eyre::{ eyre::{bail, WrapErr}, Result,};use ratatui::{ buffer::Buffer, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, layout::Rect, style::Stylize, symbols::border, text::{Line, Text}, widgets::{Block, Borders, Paragraph, Widget}, Frame,};
mod tui;
fn main() -> Result<()> { color_eyre::install()?; let mut terminal = tui::init()?; let app_result = App::default().run(&mut terminal); if let Err(err) = tui::restore() { eprintln!( "failed to restore terminal. Run `reset` or restart your terminal to recover: {}", err ); } app_result}
#[derive(Debug, Default)]pub struct App { counter: u8, exit: bool,}
impl App { /// runs the application's main loop until the user quits pub fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { while !self.exit { terminal.draw(|frame| self.render_frame(frame))?; self.handle_events().wrap_err("handle events failed")?; } Ok(()) }
fn render_frame(&self, frame: &mut Frame) { frame.render_widget(self, frame.area()); }
/// updates the application's state based on user input fn handle_events(&mut self) -> Result<()> { match event::read()? { // it's important to check that the event is a key press event as // crossterm also emits key release and repeat events on Windows. Event::Key(key_event) if key_event.kind == KeyEventKind::Press => self .handle_key_event(key_event) .wrap_err_with(|| format!("handling key event failed:\n{key_event:#?}")), _ => Ok(()), } }
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> { match key_event.code { KeyCode::Char('q') => self.exit(), KeyCode::Left => self.decrement_counter()?, KeyCode::Right => self.increment_counter()?, _ => {} } Ok(()) }
fn exit(&mut self) { self.exit = true; }
fn decrement_counter(&mut self) -> Result<()> { self.counter -= 1; Ok(()) }
fn increment_counter(&mut self) -> Result<()> { self.counter += 1; if self.counter > 2 { bail!("counter overflow"); } Ok(()) }}
impl Widget for &App { fn render(self, area: Rect, buf: &mut Buffer) { let title = Line::from(" Counter App Tutorial ".bold()); let instructions = Line::from(vec![ " Decrement ".into(), "<Left>".blue().bold(), " Increment ".into(), "<Right>".blue().bold(), " Quit ".into(), "<Q> ".blue().bold(), ]); let block = Block::default() .title(title.centered()) .title_bottom(instructions.centered()) .borders(Borders::ALL) .border_set(border::THICK);
let counter_text = Text::from(vec![Line::from(vec![ "Value: ".into(), self.counter.to_string().yellow(), ])]);
Paragraph::new(counter_text) .centered() .block(block) .render(area, buf); }}
#[cfg(test)]mod tests { use ratatui::style::Style;
use super::*;
#[test] fn render() { let app = App::default(); let mut buf = Buffer::empty(Rect::new(0, 0, 50, 4));
app.render(buf.area, &mut buf);
let mut expected = Buffer::with_lines(vec![ "┏━━━━━━━━━━━━━ Counter App Tutorial ━━━━━━━━━━━━━┓", "┃ Value: 0 ┃", "┃ ┃", "┗━ Decrement <Left> Increment <Right> Quit <Q> ━━┛", ]); let title_style = Style::new().bold(); let counter_style = Style::new().yellow(); let key_style = Style::new().blue().bold(); expected.set_style(Rect::new(14, 0, 22, 1), title_style); expected.set_style(Rect::new(28, 1, 1, 1), counter_style); expected.set_style(Rect::new(13, 3, 6, 1), key_style); expected.set_style(Rect::new(30, 3, 7, 1), key_style); expected.set_style(Rect::new(43, 3, 4, 1), key_style);
assert_eq!(buf, expected); }
#[test] fn handle_key_event() { let mut app = App::default(); app.handle_key_event(KeyCode::Right.into()).unwrap(); assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into()).unwrap(); assert_eq!(app.counter, 0);
let mut app = App::default(); app.handle_key_event(KeyCode::Char('q').into()).unwrap(); assert!(app.exit); }
#[test] #[should_panic(expected = "attempt to subtract with overflow")] fn handle_key_event_panic() { let mut app = App::default(); let _ = app.handle_key_event(KeyCode::Left.into()); }
#[test] fn handle_key_event_overflow() { let mut app = App::default(); assert!(app.handle_key_event(KeyCode::Right.into()).is_ok()); assert!(app.handle_key_event(KeyCode::Right.into()).is_ok()); assert_eq!( app.handle_key_event(KeyCode::Right.into()) .unwrap_err() .to_string(), "counter overflow" ); }}
tui.rs (click to expand)
use std::io::{self, stdout, Stdout};
use ratatui::{ backend::CrosstermBackend, crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }, Terminal,};
/// A type alias for the terminal type used in this applicationpub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminalpub fn init() -> io::Result<Tui> { execute!(stdout(), EnterAlternateScreen)?; enable_raw_mode()?; set_panic_hook(); Terminal::new(CrosstermBackend::new(stdout()))}
fn set_panic_hook() { let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { let _ = restore(); // ignore any errors as we are already failing hook(panic_info); }));}
/// Restore the terminal to its original statepub fn restore() -> io::Result<()> { execute!(stdout(), LeaveAlternateScreen)?; disable_raw_mode()?; Ok(())}
パニックの処理
アプリケーションがパニックになったときに何が起こるかを確認するために実験します。このアプリケーションには、カウンターフィールドに u8
を使用する意図的なバグがありますが、これを下回ることを防ぐことはできません。アプリを実行して、左矢印キーを押します。
エラーが発生した場所に関する詳細情報を取得するには、コマンドの前に RUST_BACKTRACE=full
を追加します。
取り扱いエラー
結果として、アプリケーションが未処理のエラーを返したときに何が起こるかを確認します。このアプリは、カウンターが2を超えて増加したときにこれを発生させます。アプリを実行し、右矢印を3回押します。
エラーが発生した場所に関する詳細情報を取得するには、コマンドの前に RUST_BACKTRACE=full
を追加します。