Rust 에러 처리 실무 — 라이브러리는 thiserror, 앱은 anyhow
Rust 실무의 에러 처리는 둘로 나눈다. 라이브러리는 thiserror 로 무엇이 틀렸는지 경우를 나눈 에러 타입을 만들고, 앱은 anyhow 로 어떤 에러든 받아 context 로 사람이 읽을 설명을 붙인다. main 이 anyhow::Result 를 돌려주면 원인 사슬을 찍고 종료 코드 1 로 끝난다.
입문 30·31편의 Result 와 ? 를 그대로 쓴다. 두 크레이트는 그 위에서 반복되는 코드를 줄여 줄 뿐이다.
01 thiserror — 경우를 나눈 에러 타입
설정 파일의 port = … 줄을 읽는 함수다. 틀릴 수 있는 경우가 셋이다.
use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Error)]
enum ConfigError {
#[error("line {line}: missing '='")]
NoEquals { line: usize },
#[error("line {line}: port is not a number")]
BadPort { line: usize, source: ParseIntError },
#[error("unknown key '{0}'")]
UnknownKey(String),
}
fn parse_port(text: &str) -> Result<u16, ConfigError> {
let mut port = 8080;
for (i, raw) in text.lines().enumerate() {
let line = i + 1;
let (key, value) = raw.split_once('=').ok_or(ConfigError::NoEquals { line })?;
match key.trim() {
"port" => {
let n: u16 = value.trim().parse().map_err(|source| ConfigError::BadPort { line, source })?;
port = n;
}
"name" => {}
other => return Err(ConfigError::UnknownKey(other.to_string())),
}
}
Ok(port)
}
fn main() {
for text in ["name = web\nport = 3000", "port 3000", "port = abc", "color = red"] {
match parse_port(text) {
Ok(p) => println!("ok: port {p}"),
Err(e) => println!("error: {e}"),
}
}
} Running `target/debug/config`
ok: port 3000
error: line 1: missing '='
error: line 1: port is not a number
error: unknown key 'color'- 에러를 입문 24편의 enum 으로 만든다. 경우마다 필요한 정보(줄 번호, 원래 에러)를 칸으로 둔다.
#[derive(Error)]와 경우마다 붙인#[error("…")]가 사람이 읽을 문장을 만든다(입문 33편의Display트레이트 구현을 대신 써 준다).{line}·{0}자리는 칸 값으로 채워진다.source라는 이름의 칸은 "이 에러의 원인" 으로 기록된다. 숫자 변환 실패(ParseIntError)를 버리지 않고 안에 품는다.- 부르는 쪽은
match로 경우마다 다르게 처리할 수 있다. 예를 들어UnknownKey만 경고로 넘기는 식이다. 라이브러리가 경우를 나눠 주는 이유가 이것이다.
split_once('=') 는 처음 나오는 = 를 기준으로 둘로 나눠 Option 으로 준다. ok_or(…)? 는 None 이면 그 에러로 바로 돌려준다.
02 anyhow — 앱에서는 설명을 붙여 위로
앱의 main 근처에서는 경우를 나누기보다 "무엇을 하다가 실패했는지" 가 중요하다.
use anyhow::{Context, Result, bail};
use std::fs;
fn read_port(path: &str) -> Result<u16> {
let text = fs::read_to_string(path).with_context(|| format!("cannot read config file {path}"))?;
let value = text.trim().strip_prefix("port=").context("expected a line like port=8080")?;
let port: u16 = value.parse().context("port must be a number")?;
if port < 1024 {
bail!("port {port} needs admin rights, use 1024 or above");
}
Ok(port)
}
fn main() -> Result<()> {
println!("port: {}", read_port("good.conf")?);
println!("port: {}", read_port("missing.conf")?);
Ok(())
} Running `target/debug/config`
port: 3000
Error: cannot read config file missing.conf
Caused by:
No such file or directory (os error 2)anyhow::Result<T>는 "어떤 에러든 담을 수 있는Result" 다. 파일 에러·숫자 변환 에러가 섞여도?한 글자로 올린다..with_context(|| …)와.context("…")는 에러에 설명을 한 겹 씌운다. 원래 에러(No such file or directory)는 버리지 않고Caused by:아래에 남는다.bail!("…")은 그 자리에서 설명을 담은 에러를 돌려준다(return Err(anyhow!("…"))의 줄임).main이Result를 돌려주고Err로 끝나면, 맨 위 설명과 원인 사슬을 찍고 종료 코드 1 로 끝난다(입문 31편과 같다).
03 실습 — 원인 사슬 읽기
같은 앱에 port=eighty 가 든 파일을 줘 보자.
Running `target/debug/config`
port: 3000
Error: port must be a number
Caused by:
invalid digit found in string맨 위 줄은 우리가 붙인 설명(port must be a number), Caused by: 아래는 원래 에러(숫자가 아닌 글자)다. 설명이 없었다면 invalid digit found in string 한 줄만 남아, 어느 파일의 무엇이 틀렸는지 알 수 없었다.
| thiserror | anyhow | |
|---|---|---|
| 어디서 | 라이브러리·모듈의 공개 함수 | 앱(main 과 그 가까이) |
| 무엇을 | 경우를 나눈 에러 타입을 만든다 | 어떤 에러든 받아 설명을 붙인다 |
| 부르는 쪽은 | match 로 경우마다 처리 | 찍고 끝내거나 그대로 올린다 |
read_port 에 "port 가 65535 를 넘으면" 이라는 bail! 을 더해 보자. 사실 u16 이 이미 65535 까지만 받아서, 70000 을 넣으면 변환 단계(context)에서 먼저 걸린다. 어느 줄의 설명이 찍히는지 확인하면 사슬이 손에 익는다. 다음 편부터는 코드를 잘 쓰는 습관이다. 첫 번째는 clippy 와 rustfmt 다.