wakuwaku

Spring から来た方へ

Bean、コンストラクタインジェクション、@Scheduled、@KafkaListener、@Transactional といった Spring の概念が wakuwaku の何に対応し、どこが違うのか。

Spring Boot でサービスを作ったことがあれば、wakuwaku の多くの概念には見覚えがあるはずです。共有コンポーネントのレジストリ、依存から組み立てられるコンポーネント、スケジュールされたジョブ、メッセージリスナー、リポジトリ、バリデーションなどです。違うのは、いつ 検査されるかです。Spring は起動時にリフレクションでアプリケーションを組み立てます。wakuwaku は型によって組み立てるため、Spring がアプリケーションの起動時に報告するミスの多くは、wakuwaku ではコンパイルエラーになります。

このページの Java のコードは説明用です。比較対象となる Spring の書き方を示すもので、完全なプログラムではありません。

概念の対応表

Springwakuwaku補足
ApplicationContext、@Configuration + @BeanServiceBuilder::new(..).push(..)レジストリは main で自分で組み立てます。型による解決をコンパイラが行います。
コンストラクタインジェクションServiceCreation::Dep登録済みの依存 1 つから作るコンポーネントは、それを Dep として指定します。複数必要なコンポーネントはクロージャで組み立てます: compose_cron_job、compose_grpc_service、compose_iggy_consumer。
@Component のスキャンなしすべてのコンポーネントを明示的に登録します。
@Value、@ConfigurationPropertiesなし設定用の構造体を自分で作り、他の依存と同じように登録します。
@ScheduledCronJob::INTERVALCronJobInterval で指定します。AfterFinish ≈ fixedDelay、AtLeast ≈ fixedRate (実行は重なりません)、AfterStart ≈ fixedRate + @Async (実行が重なりえます)。cron 式はありません。
@KafkaListenerEventHandler + ServiceBuilder::iggy_consumerブローカーは Kafka ではなく Apache Iggy です。ハンドラーは 1 つのキーのイベントをバッチで受け取ります。
KafkaTemplate.sendPublisher::publishイベントのキーがトピックとパーティションを決めます。
Spring Data のリポジトリメソッドQuery + Execute を実装した クエリの値各クエリは、読み書きするエンティティを宣言する構造体です。ORM やメソッド名からのクエリ導出はありません。
@Transactional(readOnly = true)Db<S, ReadOnly>ハンドルが持つケイパビリティで、コンパイル時に検査されます。後述します。
@TransactionalTransaction + TxFlowトランザクションは値です。メソッドを包むプロキシではなく、ステップがクエリを実行するフローで、ライブラリが駆動します。
JPA のエンティティリスナー、@TransactionalEventListenerBeforeWrite / AfterWrite フックフックはハンドルを包み、書き込みクエリの前後で動きます。
Bean Validation (@Valid、@NotBlank)wakuwaku::proof検査が成功すると Proven<T, P> が返り、正しい入力を必要とする関数はこの型を受け取ります。
@CacheableRedis の Cacheアノテーション駆動ではありません。Spring の Cache や RedisTemplate を直接使うときのように、get と put を自分で呼びます。
ShedLock (@SchedulerLock)Redis の Lockリースに基づき、待機しません。取得はトークンか None を返します。ジョブの実行の中で取得します。
grpc-spring-boot-starter (@GrpcService)ServiceBuilder::grpc_serviceレジストリから tonic のサーバーを組み立てます。tonic のサーバーの起動は自分で行います。
SpringApplication.run自分で書く #[tokio::main] 関数コンポーネントの起動と停止は明示的です。ライフサイクル を参照してください。

まだ対応物がない Spring のモジュールについては ロードマップ を参照してください。

Bean、インジェクション、スケジューリング

Spring では、コンテナが DigestJob を見つけ、コンストラクタの引数に合う Bean を探し、スケジュールされたメソッドを起動します。

@Component
class DigestJob {
    private final JdbcTemplate jdbc;
    private final Mailer mailer;

    DigestJob(JdbcTemplate jdbc, Mailer mailer) {
        this.jdbc = jdbc;
        this.mailer = mailer;
    }

    @Scheduled(fixedRate = 60 * 60 * 1000)
    void run() {
        // Query `jdbc`, send mail with `mailer`.
    }
}

注目すべき点は次のとおりです。

  • 登録は明示的です。 ServiceBuilder::new(..).push(..) が @Bean メソッドの役割を果たします。クラスパスから自動で見つかるものはありません。
  • コンストラクタはクロージャです。 compose_cron_job はクロージャにビルダーを渡し、s.provide::<T, _>() が登録済みの依存を型で引きます。コンストラクタインジェクションと同じ役割で、足りない依存はコンパイルエラーになります。依存が 1 つだけのコンポーネントは、ServiceCreation を実装して cron_job::<Job, _>() で登録することもできます。依存はレジストリからクローンされるので、安価にクローンできるもの (プール、ハンドル、Arc) にしてください。
  • スケジュールは定数です。 INTERVAL はジョブの型の一部です。AtLeast は fixedRate のように少なくとも 1 時間おきに実行を始めますが、2 つの実行が重なることはありません。間隔が 0 だとビルドできません。
  • シャットダウンは自分で行います。 start() は CronRuntime を返します。shutdown().await を呼ぶと、スケジュールを止め、実行中の処理を完了させてから停止します。abort().await は実行中の処理をキャンセルします。

主な違い

配線はコンパイル時に検査される

Spring はコンテキストの起動時に Bean を解決します。Bean が足りなければ、起動時、あるいはコンテキストを読み込む最初の統合テストで NoSuchBeanDefinitionException になります。

ServiceBuilder は型レベルのリストで、依存を探すことはトレイト境界そのものです。次の例では、ExpireSessions は Dep が SessionStore の cron ジョブですが、登録されているのは Config だけです。

// `SessionStore` was never registered.
let (_services, cron) = ServiceBuilder::new(Config)
    .cron_job::<ExpireSessions, _>()
    .start();

これはコンパイルできません。

error[E0277]: the trait bound `(): ProvideAt<SessionStore, _>` is not satisfied
   --> src/main.rs:38:10
    |
 38 |         .cron_job::<ExpireSessions, _>()
    |          ^^^^^^^^ the trait `ProvideAt<SessionStore, _>` is not implemented for `()`

ビルダーには実行時のマップも Any もありません。配線のミスが本番に出ることはありません。依存解決の仕組みは サービスビルダー で説明しています。

クラスパススキャン、プロキシ、AOP はない

Spring の @Transactional、@Cacheable、@Async は、コンテナが Bean のまわりに生成するプロキシによって機能します。同じクラスの中からアノテーション付きのメソッドを呼ぶとプロキシを経由しないため、アノテーションは何も言わずに無視されます。

wakuwaku にプロキシはありません。コードのまわりで動くものはすべて、型に現れる値です。

  • トランザクションは、データベースハンドルで処理する Transaction という値です。
  • 書き込みクエリのまわりのフックは、AfterWrite<Db<..>, ..> のようなラッパー型です。
  • キャッシュの読み取りは Cache::get の呼び出しです。

コードに書いてあるものが、そのまま実行されます。

読み取り専用はヒントではなく型

@Transactional(readOnly = true) は、トランザクションマネージャーと JDBC ドライバーへのヒントで、実行時にメソッド単位で適用されます。wakuwaku では、読み取り専用はデータベースハンドルのケイパビリティです。Db<PgSource, ReadOnly> を持つコードは、宣言された効果が読み取りであるクエリしか実行できません。

/// Wired to a replica: only queries whose effect `ReadOnly` covers compile.
struct UserStats {
    db: Db<PgSource, ReadOnly>,
}

impl UserStats {
    async fn total(&self) -> Result<i64, sqlx::Error> {
        self.db.process(CountUsers).await
    }
}

Write<User> と宣言されたクエリをこのハンドルに渡すと、コンパイルエラーになります。

error[E0277]: the trait bound `ReadOnly: CanWrite<User>` is not satisfied
  --> src/main.rs:25:8
   |
25 |     db.process(DeleteUser(1)).await
   |        ^^^^^^^ the trait `CanWrite<User>` is not implemented for `ReadOnly`

型が証明すること

ケイパビリティが証明するのはルーティングです。読み取り専用のハンドルにつながったコードは、書き込みと 宣言された クエリを決して送りません。しかし、読み取りと宣言されたクエリの中の SQL が書き込まないことまでは証明できません。レプリカでは、ホットスタンバイや default_transaction_read_only など、データベース側の防御も残してください。

バリデーションの結果が型になる

Bean Validation はアノテーションの付いたフィールドを検査し、制約を満たさなければ例外を投げます。検証済みのオブジェクトの型には、検査されたという事実は何も残りません。wakuwaku::proof では、検査が成功すると Proven<T, P> が返り、正しい入力を必要とする関数はこの型を要求します。

record SignUp(@NotBlank String username) {}

@PostMapping("/sign-up")
void signUp(@Valid @RequestBody SignUp request) {
    register(request.username()); // register(String) accepts anything
}

述語 (predicate) は非同期で、I/O を行えます。たとえばユーザー名が使用済みでないかを確認できます。And や Or で組み合わせることもできます。バリデーション を参照してください。

コンテキストなしでテストする

本物の Bean が必要な Spring のテストでは、たいてい @SpringBootTest でアプリケーションコンテキストを起動します。wakuwaku のコンポーネントはただの値です。create_service で生成し、process や handle を呼び、結果を確認します。配線はコンパイラがすでに検査しているので、ユニットテストは振る舞いに集中できます。完全な例は クイックスタート に、その他のパターンは テスト にあります。

次のステップ

目次