サンプルコードで身につける: 失敗を型にする — UrlError と Result で「どう壊れていたか」を返す
解説で学んだ概念を、3つの具体例で確認します。素朴な例から実務寄りの例へと進みます。
1失敗の理由を enum にする
parse の戻り値を <Url, UrlError> にし、失敗の理由を 4 つの variant(MissingScheme / UnsupportedScheme / EmptyHost / InvalidPort)で返します。関数の中では ok_or と ? で「None ならここで Err を返す」、 と ? で「数値変換の失敗をこの層の型に言い換える」を使っています。実行すると 6 本の入力のうち 1 本が ok、5 本が NG になり、NG はそれぞれ別の理由の文で出るので、呼ぶ側が で理由ごとに分岐できることが文字列のエラーとの違いだと分かります。
#[derive(Debug)]
struct Url {
scheme: String,
host: String,
port: u16,
path: String,
query: Option<String>,
}
/// 分解に失敗した理由。文字列ではなく enum にすると、呼ぶ側が match で場合分けできる
#[derive(Debug)]
enum UrlError {
/// "://" が無い
MissingScheme,
/// http 以外のスキーム
UnsupportedScheme(String),
/// ホストが空(例: http:///path)
EmptyHost,
/// ポートが 0〜65535 の整数でない
InvalidPort(String),
}
fn parse(input: &str) -> Result<Url, UrlError> {
// ok_or で Option を Result に変え、? で「失敗ならここで return Err」
let (scheme, rest) = input.split_once("://").ok_or(UrlError::MissingScheme)?;
let scheme = scheme.to_ascii_lowercase();
if scheme != "http" {
return Err(UrlError::UnsupportedScheme(scheme));
}
let rest = rest.split('#').next().unwrap_or("");
let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
let (authority, tail) = (&rest[..authority_end], &rest[authority_end..]);
let (host, port) = match authority.rsplit_once(':') {
// map_err で「数値変換の失敗」を「この層の失敗の型」に言い換える
Some((h, p)) => (h, p.parse::<u16>().map_err(|_| UrlError::InvalidPort(p.to_string()))?),
None => (authority, 80),
};
if host.is_empty() {
return Err(UrlError::EmptyHost);
}
let (path, query) = match tail.split_once('?') {
Some((p, q)) => (p, Some(q.to_string())),
None => (tail, None),
};
Ok(Url {
scheme,
host: host.to_ascii_lowercase(),
port,
path: if path.is_empty() { "/".to_string() } else { path.to_string() },
query,
})
}
fn main() {
let inputs = [
"http://example.com/a?b=1",
"example.com",
"https://example.com",
"http:///path",
"http://example.com:80a/",
"http://example.com:70000/",
];
for input in inputs.iter() {
match parse(input) {
Ok(u) => println!("ok {input:<28} scheme={} host={} port={} path={} query={:?}", u.scheme, u.host, u.port, u.path, u.query),
Err(UrlError::MissingScheme) => println!("NG {input:<28} スキームが無い"),
Err(UrlError::UnsupportedScheme(s)) => println!("NG {input:<28} {s} には対応していない(http だけ)"),
Err(UrlError::EmptyHost) => println!("NG {input:<28} ホストが空"),
Err(UrlError::InvalidPort(p)) => println!("NG {input:<28} ポート {p:?} は 0〜65535 の整数でない"),
}
}
}
2表で固めるテスト
Url と UrlError に PartialEq を すると、(parse(input), Err(UrlError::EmptyHost)) のように Ok/Err をまるごと比べられます。入力と期待値の表 cases() を 1 つ作り、 の中の と main の両方から使うので、 では assert_eq! で、テストランナーが無いこの実行では main が同じ表を回して検査します。実行すると 7 件すべて ok、NG 0 件と出ます。
// PartialEq を derive すると、Ok/Err をまるごと assert_eq! で比べられる
#[derive(Debug, PartialEq)]
struct Url {
host: String,
port: u16,
path: String,
}
#[derive(Debug, PartialEq)]
enum UrlError {
MissingScheme,
UnsupportedScheme(String),
EmptyHost,
InvalidPort(String),
}
fn parse(input: &str) -> Result<Url, UrlError> {
let (scheme, rest) = input.split_once("://").ok_or(UrlError::MissingScheme)?;
if !scheme.eq_ignore_ascii_case("http") {
return Err(UrlError::UnsupportedScheme(scheme.to_ascii_lowercase()));
}
let rest = rest.split('#').next().unwrap_or("");
let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
let (authority, tail) = (&rest[..authority_end], &rest[authority_end..]);
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => (h, p.parse::<u16>().map_err(|_| UrlError::InvalidPort(p.to_string()))?),
None => (authority, 80),
};
if host.is_empty() {
return Err(UrlError::EmptyHost);
}
let path = tail.split('?').next().unwrap_or("");
Ok(Url {
host: host.to_ascii_lowercase(),
port,
path: if path.is_empty() { "/".to_string() } else { path.to_string() },
})
}
fn url(host: &str, port: u16, path: &str) -> Url {
Url { host: host.to_string(), port, path: path.to_string() }
}
/// 入力と期待値の表。テストと main の両方がこの 1 つの表を使う
fn cases() -> Vec<(&'static str, Result<Url, UrlError>)> {
vec![
("http://example.com", Ok(url("example.com", 80, "/"))),
("http://Example.COM:8000/a#x", Ok(url("example.com", 8000, "/a"))),
("example.com", Err(UrlError::MissingScheme)),
("https://example.com", Err(UrlError::UnsupportedScheme("https".to_string()))),
("http:///path", Err(UrlError::EmptyHost)),
("http://example.com:80a/", Err(UrlError::InvalidPort("80a".to_string()))),
("http://example.com:70000/", Err(UrlError::InvalidPort("70000".to_string()))),
]
}
// cargo test で動く部分。rustc で普通にビルドするときは丸ごと無視される
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table() {
for (input, expected) in cases() {
assert_eq!(parse(input), expected, "input = {input}");
}
}
}
fn main() {
// テストランナーが無い場所でも、同じ表を回して目で確かめられる
let mut ng = 0;
for (input, expected) in cases() {
let actual = parse(input);
let mark = if actual == expected { "ok" } else { "NG" };
if mark == "NG" {
ng += 1;
}
println!("{mark} {input:<28} -> {actual:?}");
}
println!("\n{} 件中 NG {ng} 件", cases().len());
}
3実務: 設定ファイルの URL を一括検証する
UrlError に Display を実装し、? と map_err で「何行目のどのキーが、なぜ」まで足して上の層へ流します。KEY= の形の設定を 7 行読み、正しい行は ok、壊れた行は理由を集めて最後にまとめて出すので、実行すると ok 2 件、NG 5 件( の未対応・スキーム無し・ 9090a・空・KEY=URL でない行)がそれぞれ別の文で報告されます。デプロイ前の設定チェックや CI の lint で同じ形を使います。
use std::fmt;
#[derive(Debug)]
enum UrlError {
MissingScheme,
UnsupportedScheme(String),
EmptyHost,
InvalidPort(String),
}
// Display を実装すると {} で人向けの文になり、ログや CI の出力にそのまま使える
impl fmt::Display for UrlError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
UrlError::MissingScheme => write!(f, "スキームがありません(例: http://...)"),
UrlError::UnsupportedScheme(s) => write!(f, "対応していないスキームです: {s}(http だけ)"),
UrlError::EmptyHost => write!(f, "ホストが空です"),
UrlError::InvalidPort(p) => write!(f, "ポートが不正です: {p:?}"),
}
}
}
impl std::error::Error for UrlError {}
/// 戻り値は host:port だけ(設定の検証に必要なのはそこまで)
fn parse(input: &str) -> Result<(String, u16), UrlError> {
let (scheme, rest) = input.split_once("://").ok_or(UrlError::MissingScheme)?;
if !scheme.eq_ignore_ascii_case("http") {
return Err(UrlError::UnsupportedScheme(scheme.to_ascii_lowercase()));
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => (h, p.parse::<u16>().map_err(|_| UrlError::InvalidPort(p.to_string()))?),
None => (authority, 80),
};
if host.is_empty() {
return Err(UrlError::EmptyHost);
}
Ok((host.to_ascii_lowercase(), port))
}
/// 設定ファイルの 1 行 "KEY=URL" を検証する。失敗は「何行目のどのキーが、なぜ」まで足して上に流す
fn check_line(lineno: usize, line: &str) -> Result<String, String> {
let (key, value) = line
.split_once('=')
.ok_or_else(|| format!("{lineno} 行目: KEY=URL の形ではない: {line:?}"))?;
let (host, port) = parse(value).map_err(|e| format!("{lineno} 行目 {key}: {e}"))?;
Ok(format!("{key} -> {host}:{port}"))
}
fn main() {
let config = "\
API_BASE=http://api.example.com:8080
AUTH_URL=https://auth.example.com
CDN_URL=http://cdn.example.com/
DOCS=example.com/docs
METRICS=http://metrics:9090a
LEGACY_URL=http:///old
BAD LINE";
let mut errors = Vec::new();
for (i, line) in config.lines().enumerate() {
match check_line(i + 1, line) {
Ok(summary) => println!("ok {summary}"),
Err(e) => errors.push(e),
}
}
println!("\nNG {} 件", errors.len());
for e in &errors {
println!(" {e}");
}
// CI で止めたいなら、ここで std::process::exit(1)
}