バリデーション (Proof)
生の値ではなく検証済みの証明を受け渡すことで、「検証し忘れ」をコンパイルエラーにします。
wakuwaku::proof (wakuwaku-proof クレート) は、証明を持ち運ぶバリデーションの仕組みです。ルールを 述語 (predicate) として記述し、prove で一度だけ実行すると、Proven<T, P> が返ります。これは値そのものと、ルールが成り立ったという証拠 (evidence) の組です。検証済みの値を必要とするコードはシグネチャで Proven<T, P> を要求するので、未検証の値はそこに届きません。
防げるバグ
バリデーションのバグの多くは、ルールが間違っていることではなく、ルールが実行されなかったことから生まれます。
// 呼び出し側は必ず先に `name` を検証しなければならない
fn register(name: String) { /* ... */ }検証済みの String と未検証の String は同じ型なので、新しいエンドポイントやバックグラウンドジョブ、リファクタリング後のコードが生の入力で register を呼んでも何も止めてくれません。Spring でいえば、コントローラーの引数に @Valid を付け忘れる典型的なケースです。制約は宣言されていても、それがチェックされることを強制するものがありません。
wakuwaku::proof では、この要件を型に移します。
fn register(name: Proven<String, ValidUsername>) { /* ... */ }Proven<String, ValidUsername> を手に入れる方法は、ValidUsername のチェックを実行する (または別の証明から導出する) ことだけです。検証のし忘れは型エラーになります。
セットアップ
proof フィーチャーはデフォルトで有効なので、wakuwaku だけで使えます。述語は普通の非同期コードです。tokio を直接依存に加える必要があるのは #[tokio::main] や #[tokio::test] を使うときだけで、wakuwaku のサービス内では gRPC ハンドラーがすでにランタイム上で動いています。
[dependencies]
wakuwaku = "1.0.0-rc3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }構成要素
| 項目 | 役割 |
|---|---|
ValidateContext | 判定中にチェックが参照するもの (設定、接続ハンドル、キャッシュなど) と、「判定できなかった」ときのエラー型。 |
Predicate<T> | 実行時に判定される T についての命題。述語の型そのものが証拠になります。 |
prove | 対象 (subject) に述語を適用し、成功すれば両者を Proven に包みます。 |
PureVerifier<T, P, B = T> | T の借用形 &B を受け取る関数ポインタとして表した純粋なチェック。static や const として宣言できます。Proven を得るもう一つの方法です。 |
Proven<T, P> | 対象と、P が成り立った証拠。prove、PureVerifier、または別の証明からしか得られません。T と P が Debug なら Debug です。 |
ValidateError<E> | ルールが偽なら Deny(DeniedReason)、判定できなければ IoError(E)。E が Debug + Display なら std::error::Error です。 |
DeniedReason | 拒否の理由を表す &'static str。同一性で比較されます。Display はそのテキストです。 |
コンテキスト
すべての述語は自分のコンテキスト型を指定します。コンテキストは ValidateContext を実装し、判定に失敗したときのエラー型を決めます。純粋なチェックはそのような失敗をしないので Infallible を使います。
use std::convert::Infallible;
use wakuwaku::proof::{
And, CheckOrder, DeniedReason, Predicate, Proven, ValidateContext, ValidateError, prove,
};
/// チェックが参照するもの。ここでは設定だけ
#[derive(Clone)]
pub struct Limits {
pub max_len: usize,
}
impl ValidateContext for Limits {
// 純粋なチェックは判定に失敗しない
type IoError = Infallible;
}述語
述語とは Predicate<T> を実装した型です。check は self を取りません。判定に必要なものはすべて対象とコンテキストから得ます。ルールが成り立つと、check は述語の型そのものの値を返します。この値が 証拠 です。報告することがないルールはユニット構造体に、判定中に何かを知るルールはそれをフィールドに保持します。
pub static EMPTY: DeniedReason = DeniedReason("username is empty");
pub static TOO_LONG: DeniedReason = DeniedReason("username is too long");
/// 「名前が空でない」。報告することがないのでユニット構造体
pub struct NonEmpty;
impl Predicate<String> for NonEmpty {
type Ctx = Limits;
async fn check(
name: &String,
_: &Limits,
_: CheckOrder,
) -> Result<Self, ValidateError<Infallible>> {
if name.is_empty() {
Err(ValidateError::Deny(EMPTY))
} else {
Ok(NonEmpty)
}
}
}
/// 「名前が `max_len` バイトに収まる」。残りの余裕を記録する
pub struct ShortEnough {
pub spare: usize,
}
impl Predicate<String> for ShortEnough {
type Ctx = Limits;
async fn check(
name: &String,
limits: &Limits,
_: CheckOrder,
) -> Result<Self, ValidateError<Infallible>> {
match limits.max_len.checked_sub(name.len()) {
Some(spare) => Ok(ShortEnough { spare }),
None => Err(ValidateError::Deny(TOO_LONG)),
}
}
}check が返す Future は Send でなければならないので、どの tonic ハンドラーの中でもチェックを実行できます。CheckOrder 引数は他の述語を組み合わせる述語にとってだけ意味があり、末端の述語は無視して構いません (述語の合成 を参照)。
証明する
prove は対象を値で受け取り、チェックを実行して、証明かエラーを返します。And<NonEmpty, ShortEnough> は両方が成り立つときに成り立つ述語で、詳しくは 述語の合成 で扱います。
/// 両方が成り立つ必要がある
pub type ValidUsername = And<NonEmpty, ShortEnough>;
/// `prove` を通った名前でしか呼べない
pub fn register(name: Proven<String, ValidUsername>) -> String {
let And(_, short_enough) = name.evidence();
format!("{} ({} bytes to spare)", name.subject(), short_enough.spare)
}
pub async fn handle(input: String) -> Result<String, &'static str> {
let limits = Limits { max_len: 16 };
match prove::<_, ValidUsername>(input, &limits, CheckOrder::Sequential).await {
Ok(name) => Ok(register(name)),
Err(ValidateError::Deny(reason)) => Err(reason.0),
// `Infallible` には値が存在しないので、このアームは決して実行されない
Err(ValidateError::IoError(never)) => match never {},
}
}handle("haruki".into()) は Ok("haruki (10 bytes to spare)") を返し、空文字列なら Err("username is empty") を返します。チェックが失敗すると、対象はエラーとともに破棄されます。
純粋なチェック: PureVerifier
スラッグの形式、値の範囲、チェックサムのように、対象以外を何も読まないルールがあります。こうしたルールはコンテキストを必要とせず、判定に失敗することもなく、合成するものもありません。その場合は PureVerifier<T, P, B> を使えます。これはチェックそのものを関数ポインタ fn(&B) -> Result<P, ValidateError<Infallible>> として表したものです。B は対象の型 T の借用形 (T: Borrow<B>) で、省略すると T になります。PureVerifier::new は const fn なので、検証器は証拠の型の隣に static (または const) として一度だけ宣言でき、証明は普通の同期呼び出しになります。
use std::convert::Infallible;
use wakuwaku::proof::{DeniedReason, Proven, PureVerifier, ValidateError};
pub static NOT_A_SLUG: DeniedReason = DeniedReason("not a lowercase slug");
/// 「文字列が URL スラッグである」。非公開フィールドにより、このモジュールだけが作れる
pub struct Slug(());
fn is_slug(s: &str) -> Result<Slug, ValidateError<Infallible>> {
let valid = !s.is_empty()
&& s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if valid { Ok(Slug(())) } else { Err(ValidateError::Deny(NOT_A_SLUG)) }
}
/// `String` を所有し、`&str` として検査する
pub static SLUG: PureVerifier<String, Slug, str> = PureVerifier::new(is_slug);
/// `SLUG` を通った文字列でしか呼べない
pub fn publish(slug: Proven<String, Slug>) -> String {
format!("/posts/{}", slug.subject())
}
pub fn handle(input: String) -> Result<String, &'static str> {
match SLUG.prove(input) {
Ok(slug) => Ok(publish(slug)),
Err(ValidateError::Deny(reason)) => Err(reason.0),
// I/O エラーの型は `Infallible`。純粋なチェックは拒否しかできない
Err(ValidateError::IoError(never)) => match never {},
}
}結果は普通の Proven<String, Slug> です。これを要求するコードには、どちらの方法で得られたかは区別できず、区別する必要もありません。
借用を通した検査。 3 つ目のパラメーターにより、関数は対象を借用形で受け取れます。SLUG は String を所有し、&str として検査します。Vec<u8> の検証器なら &[u8] を検査できます。同じ is_slug は PureVerifier<Box<str>, Slug, str> にも使えます。B を省略すると T になり、関数は &T を受け取ります。証明の健全性は T の Borrow<B> に依存します。関数が P を判定するのは借用されたビューについてであり、そのビューが対象を表すと言えるのは、借用形が所有された値と同じように振る舞うという Borrow の契約があるからです。標準ライブラリの実装はこの契約を守っています。別のビューを返すような手書きの Borrow では、P はそのビューについての証明になってしまいます。
誰が検証器を書けるか。 関数は Slug を返さなければならないので、Slug を構築できるコードだけがその検証器を書けます。Slug に非公開フィールドがあるのはそのためです。述語の意味はただ一つの impl Predicate<T> で決まりますが、同じ証拠の型を対象とする検証器はいくつでも書けます。pub struct NonEmpty; のようなユニット構造体だと、誰でも PureVerifier::new(|_| Ok(NonEmpty)) と書いて何でも証明できてしまいます。非公開フィールドがあれば、Slug の証明はすべてそれを定義するモジュール、つまりルールの意味が置かれている場所で書かれることになります。
使い分け。
Predicate + prove | PureVerifier | |
|---|---|---|
| 読むもの | 対象と ValidateContext (設定、データベースハンドルなど) | 対象だけ |
| 結果 | 成立、Deny、IoError | 成立、Deny |
| 呼び出し | async。CheckOrder を受け取る | 同期。コンストラクタや TryFrom の実装の中でも使える |
And / Or や 導出の法則 との合成 | できる | できない (P は Predicate を実装しなくてよい) |
ルールが値そのものの局所的な性質で、コンテキスト型も .await もなしに値を作る場所で検査したいときは PureVerifier を選んでください。ルールに設定や I/O が必要なとき、またはより大きなルールの一部になるときは述語を使います。検証器で得た証明も、述語を必要としない Proven::from_both、from_a、from_b を通して他の証明と組み合わせられます。
3 つの結果
チェックの結果は必ず次の 3 つのうちどれか 1 つです。
| 結果 | 意味 | 典型的な扱い |
|---|---|---|
Ok(evidence) | 命題が成り立つ。prove は Proven<T, P> を返す。 | 証明を持って処理を続ける。 |
Err(ValidateError::Deny(reason)) | 命題は判定され、偽だった。 | 入力を拒否する (INVALID_ARGUMENT、ALREADY_EXISTS など)。 |
Err(ValidateError::IoError(e)) | 命題を判定できなかった。例: データベースに到達できない。 | 不正な入力ではなく、サーバー側の障害として扱う (UNAVAILABLE)。 |
I/O エラーは拒否ではありません
IoError は対象が正しいかどうかについて何も語りません。これをクライアントに「不正な入力」として返すと、障害の責任をユーザーに押し付けることになります。2 つのアームは分けて扱ってください。tonic::Status への変換例は サービスでの証明の利用 にあります。
標準のエラーとして
ValidateError<E> は、E が Debug + Display であれば (Infallible も含みます) std::error::Error と Display を実装します。Deny は理由のテキストとして、IoError は could not decide the proposition: に続けて I/O エラーとして表示されます。最初の失敗で止まるバッチジョブのように、呼び出し側が 2 つを区別する必要がない場合は、? で Box<dyn Error> や anyhow::Error に伝播できます。
use std::error::Error;
/// 不正な名前が 1 つでもあればインポートを中断する
pub async fn import(names: Vec<String>) -> Result<Vec<String>, Box<dyn Error>> {
let limits = Limits { max_len: 16 };
let mut registered = Vec::new();
for name in names {
let name = prove::<_, ValidUsername>(name, &limits, CheckOrder::Sequential).await?;
registered.push(register(name));
}
Ok(registered)
}import(vec![String::new()]) は、username is empty と表示されるエラーで失敗します。
拒否の理由
DeniedReason は &'static str を包んだ型で、等価性は文字列の内容ではなく ポインタの同一性 で判定されます。同じテキストでもアドレスが異なれば等しくならず、文字列リテラルや const への参照が同じアドレスを共有する保証もありません。理由はそれぞれ一度だけ static として宣言し、その static と比較してください。
pub static TAKEN: DeniedReason = DeniedReason("username is already taken");
// 後でエラーを変換するとき
if reason == TAKEN { /* ... */ }DeniedReason は Display を実装しており、そのテキストを書き出すので、format!("rejected: {reason}") のように使えます。reason.0 は &'static str そのもので、&'static str を受け取る API に渡すときに使います。
証拠
述語の型がそのまま証拠になります。Proven::evidence() はそれを 参照で 返します。
NonEmptyは何も持ちません。証明を持っていること自体が情報です。ShortEnough { spare }は残りの余裕を記録しているので、registerは長さを測り直さずにshort_enough.spareを読めます。- I/O を行う述語は検索したデータベースの行を持てるので (I/O を行う述語 を参照)、後続のコードで同じクエリを繰り返す必要がありません。
Proven、And、Or の中では、各証拠は Evidence<P> に包まれています。これは &P に Deref でき、値として取り出すことはできず、P が Clone でも Clone ではありません。証拠を複製するのはクレートが提供するブール代数の法則のうち、結果に同じ証明を 2 回含むものだけで、それらは P: Clone を要求します (証明の導出 を参照)。
対象も Proven::subject() を通して &T としてしか公開されないため、チェック後に変更することはできません (内部可変性を除く)。対象を値で返すメソッドはないので、所有権が必要な場合はクローンしてください。
Proven<T, P> は T と P が Debug なら Debug を実装します (Evidence、And、Or も同様です)。そのため、対象と述語の型が Debug を実装していれば、証明を持つ構造体にも #[derive(Debug)] を付けられます。
証明はスナップショットです
対象も証拠も、チェックした瞬間の状態を表しています。「このユーザー名は空いている」のように外部の状態に依存する述語では、その状態がすでに変わっている可能性があります。最終的な防御としてデータベース自身の制約 (一意インデックスなど) を残してください。
述語そのものを受け取らない
証拠の型を直接要求したくなるかもしれません。
// これは何も証明しない
fn register(name: String, _proof: NonEmpty) { /* ... */ }NonEmpty は誰でも書けますし、Predicate::check を直接呼べばどの対象とも結び付いていない証拠が得られます。シグネチャでは常に Proven<T, P> を受け取り、P 単体は受け取らないでください。
Proven を要求するのが安全なのは、フィールドが private だからです。クレートの外で Proven を得る方法は prove、PureVerifier、または既存の証明から Proven::from_a、from_b、from_both、into_a、into_b、derive を通すことだけです。近道はどちらもコンパイルできません。
let input = String::from("haruki");
register(input); // error[E0308]: mismatched types
// expected `Proven<String, And<NonEmpty, ShortEnough>>`, found `String`
let fake = Proven { subject: String::new(), evidence: todo!() };
// error[E0451]: fields `subject` and `evidence` of struct `Proven` are private