wakuwaku

サービスビルダー

共有する依存を型レベルのレジストリに登録し、そこからすべてのコンポーネントを組み立て、登録漏れがないことをコンパイラに検査させます。

wakuwaku のサービスは、すべて ServiceBuilder から始まります。データベースハンドル、クライアント、設定など、コンポーネント間で共有する依存を登録しておくレジストリです。cron ジョブ、イベントハンドラ、gRPC サービスはここから組み立てられます。

Spring でいえばアプリケーションコンテキストにあたりますが、違いが二つあります。依存はアプリケーションの起動時ではなく コンパイル時 に型で解決されます。また、コンポーネントスキャンもアノテーションもプロキシもありません。値は明示的に登録し、各コンポーネントは自分がどの値から作られるかを明示します。

型レベルの連結リストとしての ServiceBuilder: provide::<B, _>() はコンパイル時に位置 There<Here> へ解決される。コンポーネントは 1 つの依存を clone して作る (ServiceCreation) か、ビルダー全体を借用する compose_* クロージャで作るlet services = ServiceBuilder::new(a).push(b).push(c);// ServiceBuilder<C, ServiceBuilder<B, ServiceBuilder<A, ()>>>ServiceBuilder<C, …>that: ServiceBuilder<B, …>that: ServiceBuilder<A, ()>this: C例: Iggy クライアントHere最後に登録this: B例: RedisThere<Here>this: A例: DB プールThere<There<Here>>最初に登録that: ()リストの終端C ≠ B: that へ進むトレイトソルバーがコンパイル時にリストをたどって推論: _ = There<Here>登録されていない型には ProvideAt の実装がなく、パニックではなくコンパイルエラーになります。provide::<B, _>()コンパイル時に解決.clone()Arc・プール・ハンドルcreate_service(b)ServiceCreation | compose_*コンポーネントcron・gRPC・Iggy
push のたびにビルダーが新しいノードで包まれます。型で検索し、Any もダウンキャストも実行時マップも使いません。compose_* クロージャはビルダー全体を借用し、複数のサービスを取り出せます。

登録と取得

ServiceBuilder::new で最初の値を登録し、push で値を追加し、provide で登録済みの値を型によって借用します。

src/main.rs
use wakuwaku::ServiceBuilder;

#[derive(Clone)]
pub struct AppConfig {
    pub grpc_addr: SocketAddr,
}

pub fn basics() {
    let services = ServiceBuilder::new(AppConfig { grpc_addr: "[::]:50051".parse().unwrap() })
        .push(AccountStore::default())
        .push(Mailer::default());

    let config: &AppConfig = services.provide::<AppConfig, _>();
    let mailer: &Mailer = services.provide::<Mailer, _>();
    let _ = (config, mailer);
}

provide の二つ目の型パラメータは、ビルダー内での値の 位置 です。その型が一度だけ登録されていれば、_ を渡すとコンパイラが推論します。

仕組み

push するたびにビルダーは新しいノードで包まれます。上のビルダーの型は次のとおりです。

ServiceBuilder<Mailer, ServiceBuilder<AccountStore, ServiceBuilder<AppConfig>>>

レジストリは型レベルの連結リストです。位置は最後に push したものから数えます。Here が先頭 (Mailer)、There<Here> が一つ前 (AccountStore)、There<There<Here>> が AppConfig です。取得はトレイト境界 ProvideAt<T, Position> で表され、これは T が格納されている位置にだけ実装されます。解決するのはトレイトソルバーです。実行時には Any もダウンキャストもハッシュマップもなく、provide はフィールドアクセスにコンパイルされます。

重要なのはその帰結です。登録されていない型を要求すると コンパイルが通りません (後述)。

コンポーネントを作る二つの方法

どの種類のコンポーネントも、ビルダーから二通りの方法で作れます。コンポーネントが実装するトレイトを通して 登録済みの依存一つ から作る方法と、ビルダーを借用するクロージャ が完成したコンポーネントを返す方法です。

コンポーネント登録済みの依存一つからクロージャで
cron ジョブ.cron_job::<Job, _>().compose_cron_job(|s| job)
イベントハンドラ.iggy_consumer::<Handler, _, _>().compose_iggy_consumer(|s| Arc::new(handler))
gRPC サーバー.grpc_service::<XxxServer<_>, Impl, _>(XxxServer::new).compose_grpc_service(|s| XxxServer::new(implementation))
登録するサービス.construct_subservice::<Service, _>().compose_subservice(|s| service)

左の列ではコンポーネントが ServiceCreation を実装している必要があります。右の列では値そのもの以外に何も要りません。最後の行は、結果をコンポーネントではなく新しいサービスとして登録します。ほかのサービスから作るサービス を参照してください。

コンポーネントの組み立て: ServiceCreation

一つ目の方法で作るコンポーネントは、自分で依存を探しに行きません。ServiceCreation を実装して、自分が作られる元になる登録済みの型を一つ宣言します。

pub trait ServiceCreation {
    type Dep: Clone;

    fn create_service(dep: Self::Dep) -> Self;
}
src/components.rs
pub struct ExpireSessions {
    sessions: SessionStore,
}

impl ServiceCreation for ExpireSessions {
    type Dep = SessionStore;
    fn create_service(sessions: SessionStore) -> Self {
        Self { sessions }
    }
}

コンポーネントを登録すると、ビルダーは Dep を型で探して clone し、その clone を create_service に渡します。そのため Dep は Clone でなければならず、clone は 安価 であるべきです。リソースそのものではなく、リソースを共有するハンドルを Dep にしてください。適した Dep の例は次のとおりです。

  • wakuwaku::entity の Db<S, C> ハンドル (ソースがプールや多重化コネクションを包んでいます)
  • sqlx::Pool、redis::aio::MultiplexedConnection、Arc<IggyClient> などのプールやクライアントのハンドル
  • このページで使っている SessionStore のように、Arc の裏に置いた独自の状態
src/components.rs
/// Login sessions and when they expire.
#[derive(Clone, Default)]
pub struct SessionStore {
    expires_at: Arc<Mutex<HashMap<String, SystemTime>>>,
}

コンポーネントごとに別の clone を受け取ります

同じ Dep から作られるコンポーネントは、それぞれ登録時に一度だけ取られた別々の clone を受け取ります。共有したい状態は、すべての clone から見えるようにハンドル (Arc やプール) の裏に置いてください。

クロージャでコンポーネントを組み立てる

compose_* メソッドは型パラメータの代わりにクロージャを受け取ります。クロージャはビルダーを参照で受け取り、必要なものを provide で取り出して、完成したコンポーネントを返します。SendInvoices はアカウントとメーラーの両方を必要とします。

src/billing.rs
use std::time::Duration;

use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::{CronJob, CronJobInterval, CronTick};

/// Built from two registered values, so it implements no `ServiceCreation`.
pub struct SendInvoices {
    pub accounts: AccountStore,
    pub mailer: Mailer,
}

impl Processor<CronTick> for SendInvoices {
    type Output = ();
    type Error = MailError;

    async fn process(&self, _tick: CronTick) -> Result<(), MailError> {
        for account in self.accounts.with_open_invoices() {
            self.mailer.send_invoice(&account).await?;
        }
        Ok(())
    }
}

impl CronJob for SendInvoices {
    const INTERVAL: CronJobInterval = CronJobInterval::AfterFinish(Duration::from_secs(60 * 60));
}
src/main.rs
let (_services, cron) = ServiceBuilder::new(AccountStore::default())
    .push(SessionStore::default())
    .push(Mailer::default())
    .cron_job::<ExpireSessions, _>()
    .compose_cron_job(|s| SendInvoices {
        accounts: s.provide::<AccountStore, _>().clone(),
        mailer: s.provide::<Mailer, _>().clone(),
    })
    .start();

ServiceCreation と比べて変わる点です。

  • 実装すべきトレイトはありません。 クロージャがコンストラクタです。登録済みの値をいくつでも取れ、途中で追加の処理もでき、引数を無視して、登録していないものから事前に作った値を move することもできます (.compose_cron_job(|_| job) のように)。
  • 取得は引き続きコンパイラが検査します。 クロージャ内の provide はそれぞれ普通の ProvideAt 境界なので、登録されていない型を要求するとコンパイルが通りません。
  • 自動では何も clone されません。 provide は参照を貸すだけで、保持するハンドルはクロージャが clone します。返したコンポーネントはそのままレジスタセンターに move され、その後 clone されることはありません。
  • コンポーネントはサービスとしては登録されません。 入るのはレジスタセンターだけなので、provide では見つからず、ほかのコンポーネントの元にもなりません。値をコンポーネント間で共有したいときは、代わりに compose_subservice で登録してください。
  • クロージャはすぐに実行されます。 create_service と同じく、登録メソッドを呼んだ時点です。

上の cron_job と compose_cron_job のように、二つの形は同じレジスタセンター上で順序を問わず自由につなげられます。

どちらを使うか

コンポーネントが一つのハンドルから自然に作れるなら ServiceCreation を選んでください。依存が型の隣に記録され、登録は型パラメータ一つで済み、どのサービスでもどのテストでも create_service で同じように作れます。

コンポーネントが複数の登録値を必要とするとき、登録していない値 (main で読んだ設定や事前に作ったクライアント) を必要とするとき、途中で追加の処理が要るとき (メッセージサイズの上限を設定した gRPC サーバーなど) は compose_* メソッドを使います。配線は main の登録呼び出しの場所に、ほかの配線と並んで書かれます。

コンポーネントを ServiceCreation に合わせるためだけに、ほかの登録値を束ねた構造体を登録するのはやめましょう。コンポーネントをクロージャで組み立ててください。構造体をビルダーに登録すべきなのは、それ自体が複数のコンポーネントで共有する振る舞いを持つサービスであるときです。それが次の節です。

ほかのサービスから作るサービス

construct_subservice と compose_subservice は同じ二通りの方法で値を作り、それをビルダーに push します。結果は普通の登録済みサービスです。Here の位置にあり、provide で取得でき、後から登録するすべてのコンポーネントの Dep にできます。

src/billing.rs
/// Account queries on top of the store.
#[derive(Clone)]
pub struct AccountRepo {
    store: AccountStore,
}

impl ServiceCreation for AccountRepo {
    type Dep = AccountStore;
    fn create_service(store: AccountStore) -> Self {
        Self { store }
    }
}

/// Billing logic shared by several components. Cheap to clone: it holds handles.
#[derive(Clone)]
pub struct Billing {
    accounts: AccountRepo,
    mailer: Mailer,
}
src/main.rs
let services = ServiceBuilder::new(AccountStore::default())
    .push(Mailer::default())
    // From one registered dependency, through `ServiceCreation`.
    .construct_subservice::<AccountRepo, _>()
    // From several, through a closure.
    .compose_subservice(|s| Billing {
        accounts: s.provide::<AccountRepo, _>().clone(),
        mailer: s.provide::<Mailer, _>().clone(),
    });

// Both jobs implement `ServiceCreation` with `type Dep = Billing`.
let (_services, cron) = services
    .cron_job::<CloseBillingPeriod, _>()
    .cron_job::<RemindOverdue, _>()
    .start();

補足です。

  • これらは ServiceBuilder 自身のメソッドで、push と同じくノードを一つ追加します。サービスはレジスタセンターに入る前か、終了メソッドが返したビルダーの上で登録してください。
  • 登録したサービスは、ほかの Dep と同じく、そこから作るコンポーネントごとに clone されます。リソースそのものではなくハンドル (Arc、プール、クライアント) を持たせてください。
  • compose_* との違いは結果の行き先だけです。compose_subservice はビルダーに登録し、compose_cron_job などはレジスタセンターに入れます。

レジスタセンターはビルダーを受け取り、返す

コンポーネントの種類ごとに レジスタセンター があります。ビルダーから埋めていく、コンポーネントの型レベルのリストです。最初の登録で ServiceBuilder から入り、以降の登録はどちらの形でもそこにつながり、終了メソッドがビルダーを返します。

コンポーネント登録終了戻り値
cron ジョブ.cron_job::<Job, _>()、.compose_cron_job(|s| …).start()(ServiceBuilder, CronRuntime)
イベントハンドラ.iggy_consumer::<Handler, _, _>()、.compose_iggy_consumer(|s| …).start(client, config).await?(ServiceBuilder, ConsumerRuntime)
gRPC サービス.grpc_service::<XxxServer<_>, Impl, _>(XxxServer::new)、.compose_grpc_service(|s| …).into_router_fn()(ServiceBuilder, impl FnOnce(Router<L>) -> Router<L>)
いずれも.into_parts()(ServiceBuilder, 未起動のレジスタセンター)

登録メソッドはどれもビルダーを 値で 受け取り、返すレジスタセンタービルダーの中 (公開フィールド builder) に保持します。そのため、次の登録でも依存を探せます。終了メソッドがビルダーを返すので、それを次の種類のコンポーネントに渡していきます。

src/main.rs
let services = ServiceBuilder::new(AccountStore::default())
    .push(SessionStore::default())
    .push(AuditLog::default())
    .push(Mailer::default());

let (services, consumers) = services
    .iggy_consumer::<SendWelcomeMail, _, _>()
    .start(iggy.clone(), ConsumerConfig::new("shop", "accounts", vec![0]))
    .await?;

let (services, add_grpc) = services
    .grpc_service::<AccountsServer<_>, AccountsService, _>(AccountsServer::new)
    .compose_grpc_service(|s| {
        AdminServer::new(AdminService {
            store: s.provide::<AccountStore, _>().clone(),
            audit: s.provide::<AuditLog, _>().clone(),
        })
    })
    .into_router_fn();

let (_services, cron) = services.cron_job::<ExpireSessions, _>().start();

コンポーネントは登録メソッドを呼んだ時点で 即座に 作られます。Dep が clone されて create_service が実行される、あるいはクロージャが実行されるのはこのときです。各ステップを実行すべき順序や停止の方法を含めた完全なプログラムは ライフサイクル にあります。各レジスタセンターの詳細は cron ジョブ、gRPC、イベントの消費 を参照してください。

into_parts はビルダーと未起動のレジスタセンターを別々に返します。リストを後で起動するコードや、チェーンの外で push して伸ばすコードのためのものです。手で組み立てたコンポーネントに into_parts は要りません。対応する compose_* メソッドで登録してください。

同じ型の重複

同じ型の値を二つ登録することはできますが、_ ではどちらか選べなくなります。Here と There で位置を指定してください。

use wakuwaku::services::builder::{Here, There};

#[derive(Clone)]
pub struct HttpClient {
    pub base_url: &'static str,
}

let services = ServiceBuilder::new(HttpClient { base_url: "https://payments.example" })
    .push(HttpClient { base_url: "https://mail.example" });

// `Here` is the most recent push; each `There` steps one push back.
let mail = services.provide::<HttpClient, Here>();
let payments = services.provide::<HttpClient, There<Here>>();

登録メソッドも同じ位置パラメータを受け取ります。

let (_services, cron) = ServiceBuilder::new(HttpClient { base_url: "https://payments.example" })
    .push(HttpClient { base_url: "https://mail.example" })
    .cron_job::<PingPayments, There<Here>>() // the payments client
    .start();

位置は push を足すたびにずれるので、アプリケーションのコードでは役割ごとに別の型を与えるほうがよいでしょう。newtype にはコストがかからず、_ もそのまま使えます。

#[derive(Clone)]
pub struct PaymentsClient(pub HttpClient);

#[derive(Clone)]
pub struct MailClient(pub HttpClient);

let services = ServiceBuilder::new(PaymentsClient(HttpClient { base_url: "https://payments.example" }))
    .push(MailClient(HttpClient { base_url: "https://mail.example" }));

let payments = services.provide::<PaymentsClient, _>();

型パラメータが異なるハンドルは、もともと別の型です。レプリカ用の Db<PgSource, ReadOnly> とプライマリ用の Db<PgSource, ReadWrite> は両方登録でき、どちらも _ で取得できます。

ProvideAt でジェネリックなコードを書く

ある型を含む 何らかの ビルダーがあればよい関数は、ビルダーについてジェネリックにできます。ProvideAt と位置パラメータで境界を付ければ、呼び出し側は位置を書く必要がありません。

src/config.rs
use wakuwaku::services::builder::{ProvideAt, ServiceBuilderPosition};

/// Works with any builder that has exactly one `AppConfig` somewhere in it.
pub fn grpc_addr<S, I>(services: &S) -> SocketAddr
where
    S: ProvideAt<AppConfig, I>,
    I: ServiceBuilderPosition,
{
    services.provide_at().grpc_addr
}

任意のビルダーの上に依存のまとまりを登録する関数も書けます。配線をモジュールに分けるのに便利です (Spring の @Configuration クラスに近い発想です)。

src/sessions.rs
/// Registers the session infrastructure on top of any builder.
pub fn with_sessions<H, C>(
    services: ServiceBuilder<H, C>,
) -> ServiceBuilder<Mailer, ServiceBuilder<SessionStore, ServiceBuilder<H, C>>> {
    services.push(SessionStore::default()).push(Mailer::default())
}
src/main.rs
let services = with_sessions(ServiceBuilder::new(AppConfig {
    grpc_addr: "[::]:50051".parse().unwrap(),
}));
let addr = grpc_addr(&services);

コンパイルエラーの読み方

出会うエラーは主に二つで、意味が分かれば短いものです。以下はどちらも実際の出力で、パスと help: の一覧を省略しています。

登録されていない依存。 ExpireSessions は SessionStore を必要としますが、登録されているのは Mailer だけです。

let (_services, cron) = ServiceBuilder::new(Mailer) // no SessionStore registered
    .cron_job::<ExpireSessions, _>()
    .start();
error[E0277]: the trait bound `(): ProvideAt<SessionStore, _>` is not satisfied
   |
37 |         .cron_job::<ExpireSessions, _>()
   |          ^^^^^^^^ the trait `ProvideAt<SessionStore, _>` is not implemented for `()`
   |
   = note: required for `ServiceBuilder<Mailer>` to implement `ProvideAt<SessionStore, There<_>>`
note: required by a bound in `ServiceBuilder::<Head, Chain>::cron_job`

「SessionStore を探してリストの終端 () まで到達した」と読んでください。ProvideAt<…> の最初の型が、足りない依存です。Mailer のないビルダーで provide::<Mailer, _>() を呼んでも同じエラーになります。

曖昧な取得。 同じ型の値が二つ登録されていて、_ を使っています。

#[derive(Clone)]
struct Db(&'static str);

let services = ServiceBuilder::new(Db("primary")).push(Db("replica"));
let _db: &Db = services.provide::<Db, _>();
error[E0283]: type annotations needed
  |
8 |     let _db: &Db = services.provide::<Db, _>();
  |                             -------       ^ cannot infer type of the type parameter `I` declared on the method `provide`
  |
  = note: multiple `impl`s satisfying `ServiceBuilder<Db, ServiceBuilder<Db>>: ProvideAt<Db, _>` found in the `wakuwaku` crate:
          - impl<Head, T, Chain, I> ProvideAt<T, There<I>> for ServiceBuilder<Head, Chain>
          - impl<T, Chain> ProvideAt<T, Here> for ServiceBuilder<T, Chain>;

位置を明示するか、より良い方法として newtype を使って解決してください。

目次