サービスでの証明の利用
gRPC リクエストを境界で検証し、証明をドメインロジックとクエリに渡し、エラーを tonic::Status に変換し、述語をテストします。
証明が真価を発揮するのはサービスの境界です。パターンは常に同じです。
- ハンドラーは生の入力を受け取り、
ServiceBuilderに登録したコンテキストを使ってproveを一度だけ実行します。 - 失敗は
tonic::Statusに変換して返します。 - 成功したら
Provenの値を内側へ渡します。ドメイン関数とクエリはProven<T, P>を受け取るので、チェックを通らずにそこへ到達する経路はありません。
このページでは、述語の合成 の Directory コンテキストと NewUsername 述語の上に Register RPC を作ります。
pub type NewUsername = And<UsernameAvailable, And<NonEmpty, ShortEnough>>;証明を要求するクエリ
要件はできるだけ下の層まで押し下げます。ユーザーを挿入するクエリは、検証済みの名前からしか作れないようにします。
use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::deps::sqlx::{self, PgConnection};
use wakuwaku::entity::sqlx::backend::PgSource;
use wakuwaku::entity::{Execute, Query, Write};
use wakuwaku::proof::Proven;
/// ユーザーを挿入する。`NewUsername` を通った名前からしか作れない
pub struct InsertUser {
name: Proven<String, NewUsername>,
}
impl InsertUser {
pub fn new(name: Proven<String, NewUsername>) -> Self {
Self { name }
}
}
impl Query for InsertUser {
type Effect = Write<User>;
}
impl Execute<PgSource> for InsertUser {
type Output = i64;
type Error = sqlx::Error;
async fn execute(self, conn: &mut PgConnection) -> Result<i64, sqlx::Error> {
let (id,) = sqlx::query_as::<_, (i64,)>("INSERT INTO users (name) VALUES ($1) RETURNING id")
.bind(self.name.subject().as_str())
.fetch_one(conn)
.await?;
Ok(id)
}
}フィールドは private なので、モジュールの外で InsertUser を作る方法は証明を要求する InsertUser::new だけです。この保証を得るために、クエリは専用のモジュールに置いてください。
ドメインロジックは、ハンドルについてはジェネリックに保つという データアクセスの慣習 に従い、型としては証明を要求します。
/// ドメインロジック: ハンドルにはジェネリック、名前には証明を要求する
pub async fn create_account<D>(db: &D, name: Proven<String, NewUsername>) -> Result<i64, sqlx::Error>
where
D: Processor<InsertUser, Output = i64, Error = sqlx::Error>,
{
db.process(InsertUser::new(name)).await
}ValidateError を gRPC ステータスに変換する
ValidateError も tonic::Status も外部の型なので、オーファンルールにより impl From<ValidateError<LookupError>> for Status は書けません。関数を用意して map_err で使います。理由はそれぞれの static と比較し (等価性は同一性で判定されます。拒否の理由 を参照)、I/O エラーは拒否と分けて扱います。ValidateError の Display (I/O エラーの型が Display を実装していれば使えます) を近道に使うこともしないでください。Status::invalid_argument(error.to_string()) のように一律に変換すると、障害が不正な入力として報告されてしまいます。
use wakuwaku::deps::tonic::Status;
use wakuwaku::proof::ValidateError;
pub fn validation_status(error: ValidateError<LookupError>) -> Status {
match error {
ValidateError::Deny(reason) if reason == TAKEN => Status::already_exists(reason.0),
ValidateError::Deny(reason) if reason == EMPTY || reason == TOO_LONG => {
Status::invalid_argument(reason.0)
}
ValidateError::Deny(reason) => Status::failed_precondition(reason.0),
// 判定できなかった: 入力については何も分からない
ValidateError::IoError(LookupError::Db(error)) => {
// データベースの詳細はレスポンスに含めない
eprintln!("username lookup failed: {error}");
Status::unavailable("could not validate the request, try again")
}
}
}| エラー | ステータス | 理由 |
|---|---|---|
形式が不正な値に対する Deny | INVALID_ARGUMENT | クライアントは決して正しくなりえない値を送った。 |
既存の状態との衝突に対する Deny | ALREADY_EXISTS / FAILED_PRECONDITION | 形式は正しいが、現在の状態では受け付けられない。 |
IoError | UNAVAILABLE (または INTERNAL) | 入力については何も分からない。クライアントは再試行してよい。 |
理由の文字列はクライアント向けのものとして書いてください。内部の詳細を含めてはいけません。
gRPC ハンドラー
サービス定義は普通の protobuf です。
syntax = "proto3";
package accounts;
service Accounts {
rpc Register(RegisterRequest) returns (RegisterReply);
}
message RegisterRequest {
string username = 1;
}
message RegisterReply {
int64 id = 1;
}コードは gRPC サービス の説明に従って生成します。生成されたコードには tonic、tonic-prost、prost への直接の依存と、ビルド依存の tonic-prost-build が必要です。サービスは検証用のコンテキストと書き込み用のハンドルを持ちます。サーバーを組み立てるときに、登録済みのこの 2 つのサービスから作られるので (コンテキストの組み込み を参照)、ServiceCreation の実装は不要です。
pub mod pb {
tonic::include_proto!("accounts");
}
use pb::accounts_server::{Accounts, AccountsServer};
use pb::{RegisterReply, RegisterRequest};
use wakuwaku::deps::tonic::{Request, Response, Status};
use wakuwaku::entity::{Db, ReadWrite};
use wakuwaku::proof::{CheckOrder, prove};
pub struct AccountsService {
directory: Directory,
writer: Db<PgSource, ReadWrite>,
}
#[tonic::async_trait]
impl Accounts for AccountsService {
async fn register(
&self,
request: Request<RegisterRequest>,
) -> Result<Response<RegisterReply>, Status> {
let RegisterRequest { username } = request.into_inner();
// 1. 境界で検証する
let username = prove::<_, NewUsername>(username, &self.directory, CheckOrder::Parallel)
.await
.map_err(validation_status)?;
// 2. 証明をドメインに渡す
let id = create_account(&self.writer, username).await.map_err(|error| match error {
// チェック時には空いていたが、その後別のリクエストが取った
sqlx::Error::Database(db) if db.is_unique_violation() => {
Status::already_exists(TAKEN.0)
}
_ => Status::internal("could not create the account"),
})?;
Ok(Response::new(RegisterReply { id }))
}
}対象は単一のフィールドである必要はありません。ルールが複数のフィールドにまたがる場合は、リクエストメッセージ全体に対して述語を実装できます (impl Predicate<RegisterRequest> for …)。
証明はスナップショット: データベースの制約を残してください
UsernameAvailable はチェックした時点では真でした。同じ名前に対する 2 つの並行リクエストは、どちらかが挿入する前に両方ともこのチェックを通過できます。証明ではそれを防げませんが、users.name の一意インデックスなら防げます。上のハンドラーは、その結果として起こる一意制約違反を、拒否の場合と同じ ALREADY_EXISTS に変換しています。
チェック順序の選択
CheckOrder::Parallel にすると、データベース検索と安価なルールが並行に実行されます。レイテンシが重要な場合のよいデフォルトです。Sequential では、NewUsername は検索を先に実行し (左オペランドだからです)、名前が使われていれば安価なルールを省きます。データベースに触れずに不正な名前を拒否したい場合は、prove::<_, NewUsername> の代わりに I/O の前に安価なチェックを の段階的な check_new_username を使ってください。同じ Proven<String, NewUsername> が返ります。
コンテキストの組み込み
コンテキストは他の依存と変わりません。書き込み用のハンドルと並べてサービスビルダーに登録し、compose_grpc_service でサーバーを組み立てます。このクロージャはビルダーを借用するので、サービスはそれぞれのクローンを受け取れます。And はチェックのたびにコンテキストをさらにクローンするので、クローンが安価である必要があります。PgSource 上の Db はプールのハンドルを包んでいるだけなので、クローンは安価です。
use wakuwaku::ServiceBuilder;
use wakuwaku::deps::sqlx::postgres::PgPoolOptions;
use wakuwaku::deps::tonic::service::Routes;
use wakuwaku::deps::tonic::transport::Server;
use wakuwaku::entity::{Db, ReadOnly, ReadWrite};
pub async fn serve(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let pool = PgPoolOptions::new().connect(database_url).await?;
let directory = Directory {
// 検証は読み取りだけなので、読み取り専用のハンドルを渡す
users: Db::<_, ReadOnly>::new(PgSource::new(pool.clone())),
limits: Limits { max_len: 32 },
};
let (_services, register) = ServiceBuilder::new(directory)
.push(Db::<_, ReadWrite>::new(PgSource::new(pool)))
.compose_grpc_service(|s| {
AccountsServer::new(AccountsService {
directory: s.provide::<Directory, _>().clone(),
writer: s.provide::<Db<PgSource, ReadWrite>, _>().clone(),
})
})
.into_router_fn();
let router = Server::builder().add_routes(Routes::default());
register(router).serve("[::1]:50051".parse()?).await?;
Ok(())
}述語に ReadOnly のハンドルを渡すと、検証コードは書き込みクエリを実行できません。実行しようとするとコンパイルエラーになります (クエリ を参照)。ここでは 2 つのハンドルが 1 つのプールを共有しています。ケイパビリティが制御するのは、各ハンドルがどのクエリを受け付けるかだけです。
述語のテスト
述語は対象とコンテキストを受け取る普通の非同期関数なので、純粋な述語は prove を直接呼んで単体テストできます。「失敗した」だけでなく、具体的な理由をアサートしてください。
#[cfg(test)]
mod predicate_tests {
use super::*;
use wakuwaku::proof::{CheckOrder, ValidateError, prove};
fn limits() -> Limits {
Limits { max_len: 8 }
}
#[tokio::test]
async fn rejects_long_names() {
let result = prove::<_, ShortEnough>("a".repeat(9), &limits(), CheckOrder::Sequential).await;
assert!(matches!(result, Err(ValidateError::Deny(reason)) if reason == TOO_LONG));
}
#[tokio::test]
async fn reports_spare_room() {
let Ok(name) = prove::<_, ShortEnough>("haruki".to_owned(), &limits(), CheckOrder::Sequential).await
else {
panic!("`haruki` fits in 8 bytes");
};
assert_eq!(name.evidence().spare, 2);
}
#[tokio::test]
async fn composite_reports_which_rule_failed() {
for order in [CheckOrder::Sequential, CheckOrder::Parallel] {
let result = prove::<_, ValidUsername>(String::new(), &limits(), order).await;
assert!(matches!(result, Err(ValidateError::Deny(reason)) if reason == EMPTY));
}
}
}#[tokio::test] には、macros と rt フィーチャーを有効にした tokio への直接の依存が必要です (rt-multi-thread は rt を含みます)。
いくつかの指針:
- 結果に依存している合成述語は、両方のチェック順序でテスト してください。どのオペランドが実行されるか、複数が失敗したときにどのエラーが報告されるかが異なります (チェック順序 を参照)。
- 成功だけでなく 証拠もテスト してください。後続のコードが証拠を読むので、チェックが通っても
spareや行が間違っていればバグです。 Provenを受け取る関数は本物の証明でテスト します。テスト専用のコンストラクターは意図的に用意されていません。テスト内でprove(または証拠の型のPureVerifier) から証明を得てください。純粋なチェックなら手間はかかりません。検証器ならランタイムも不要で、普通の#[test]で済みます。- I/O を行う述語 は具体的なコンテキスト (
DirectoryはDb<PgSource, ReadOnly>を持ちます) を通して読むので、統合テストで本物のデータベースに対してテストします。UsernameAvailableのように判定そのものは単純にしておき、込み入ったロジックは純粋な述語に置いてください。全体的な方針は テスト を参照してください。