Airframe DIについて調べて遊んでみたところ良さそうだったので紹介します。DIについて知っている人は先頭2節をスキップしてよいです。
サンプルもあります。
- ソフトウェアでよくある問題: 分割、結合、交換
- DIとは
- Airframe DIとは
- Airframe DIの使い方
- コンポーネントの依存関係がネストする場合
- セッション
- Airframe DIのデメリット
- 便利機能
- まとめ
- 参考文献
ソフトウェアでよくある問題: 分割、結合、交換
ソフトウェアは、なんらかの問題を解くために存在する。しかし大抵の場合、大きな問題を分割して扱うことになる。大きい問題はそのままでは解けないからだ。これに呼応して、ソフトウェアも複数の部品に分割されて問題を扱う。そしてこれらの部品は事前に再統合されて1つになり、実行される。
問題は、時として部品を組替える必要があることだ。ソフトウェアのテストを実行するために本番環境のDBに接続してはいけない。開発環境でメールを送信してはいけない。本番環境でテスト用データを表示してはいけない(もちろん、テスト用データにうんこを入れてもいけない)。
www.slideshare.net
他方でもう一つの問題が浮かび上がる。ソフトウェアの安全性や構造を保ちつつ、いくつかの部品を器用に交換するのはむずかしいのだ。プログラムの中で動的に部品を生成してそれを受け渡していけば部品の挙動を交換できる。でも、どう受け渡していくのか?動的なものが増えるということは自明なものが減るということで、危険だ。
我々はプログラムというものを安全にするために、データを受け渡しする方法に強い制限を加えている。引数で渡すか、継承などの方法で受け継ぐか、ファイルなどから読み込む必要がある。 多くの場合、そのために書かれるコードは我々が行いたいビジネスとは何の関係もない。頑張って部品を交換できるようにしても、部品を交換するためだけに必要なコードとビジネスロジックが混在してしまうし、複数の箇所に複雑性を撒き散らしてしまう。
部品を交換したい。しかしそれが難しいという状況である。
DIとは
ここまでに述べた問題により、ビジネスによっておカネをガッポリ儲けたいという関心事と、器用にソフトウェアの特定の部品を交換したいという関心事とをうまく分離する必要がある。この問題を解決するために、DI(Dependency Injection; 依存性注入)という概念が発達した。この分野は、部品を交換するという実際の仕事をなんとかしてコードの見えない場所に隠し、なるだけ宣言的に部品を交換するために編み出された技術の集合体だ。
Scalaでよく行なわれる代表的なDI手法として、Cake Patternというものがある。これは、Scalaのtraitとself-type annotationという言語標準のメカニズムを用いて、特別なライブラリ無しにこの目的を達成する。この技法は導入しやすい一方で、エラーメッセージが難読になってしまったり、コンポーネントを記述するための記法が冗長になりがちであるという問題があった。このため軽量Cake Patternといったいくつかの派生技法が開発されたりした。
この他にも、Tagless Finalといった関数型的な技法も開発されるなど、DIはScalaエンジニアの関心の一角を常に占めている。
Airframe DIとは
Airframe DIは、Taro L. Saitoさんによって開発され、Treasure Dataによって利用されているライブラリで、AirframeというライブラリシリーズのうちDIを担当しているやつだ。Scala 3にも対応している。JVMはもちろんのこと、Scala.jsにも対応がある。
ScalaでDIを行いたいときにはJavaのDIライブラリを流用することもあるが、Airframeは最初からScala向けに作られたライブラリで、非常に軽量で使い勝手が良く仕上がっている。
Airframeの特長は、個々の部品に特別なボイラープレートを書く必要が全くなく、どのコンポーネントを利用したいかだけ考えれば良くなる点だ。
Airframe DIの使い方
ミニマルな例を通じてAirframe DIの使い方を見ていこう。この例では、以下のようなシチュエーションを扱う:
- Applicationは、Mailerに依存している。
- 後に、AplicationをDatabaseにも依存させ、依存性を2つにする。
- Databaseは、ConnectionPoolに依存している。
Airframe DIを導入するには、依存性を1つだけ追加すればよい。
// 執筆時点で最新 libraryDependencies += "org.wvlet.airframe" %% "airframe" % "24.4.0"
加えて、scalafmtの設定を1つ増やしておくとコードが綺麗にまとまる。
// .scalafmt.conf version = 3.8.1 runner.dialect = scala3 trailingCommas = always // for Airframe DI optIn.breaksInsideChains = true
ここで注意点なのだが、Airframe DIには書き方のスタイルが2つあり、Scala 3ではこのうちConstructor Injectionスタイルのみが提供される。この記事でもConstructor Injectionのみを紹介する。もう一つのIn-Trait Injectionは使われなくなる予定だから、覚えなくてよい。
あとはやることはシンプルだ。コンストラクタで依存性を定義し、Designで依存性を注入し、buildでそれを実行する。この三位一体をやるだけでよい。詳しく見ていこう。
依存性の定義
Airframe DIでは、依存性の定義、つまり欲しいものはクラスのコンストラクタに置く。コンストラクタで渡ってくるので、コンポーネント内では好きにそれを使うことができる。これは最も直感的でまっすぐな依存性の定義方法だ。Airframeを利用せずにこの技法単体でDIを行う人もたまにいるくらいだ。
package dev.capslock.exercise.airframedi // Applicationは、Mailerに依存する class Application(mailer: Mailer) { def run() = mailer.sendMail("windymelt@example.com") }
依存される側の定義
依存される側の定義は、特に何も変哲のないtraitでよい。
今回はsendMailというメソッドを1つだけ用意した。
trait Mailer { def sendMail(to: String): Unit }
Design: 依存性の注入
Airframe DIでは、依存性の注入、つまりどのコンポーネントを選択するかをDesignという概念で構成する。これ自体は設計図のようなもので、実際のインスタンス生成は行われない。ここで互いに絡みあった依存性をやりくりする。
Designの利点は、具体的にどのコンポーネントを注入するのかといった関心事がたった一つの値で表現されることだ。しかもDesignはイミュータブルなので、派生したDesignを作ることも容易だ。
今回は本番環境で使うためのproductionという名前のDesignを作ろう。加えて、まだMailerの具体的な実装がまだだったのでRealMailerを実装しよう。
Designを作るには、newDesignを呼び出せばよい。そして依存性の数だけ、どの型に対しては何のインスタンスを注入するのか、という対応を.bind[A].toInstance(...)という形式で書く。
import wvlet.airframe.* // productionでは、Mailerに対してRealMailerのインスタンスを利用する、という設定 val production = newDesign .bind[Mailer].toInstance(RealMailer())
Mailerの実装RealMailerは、単にMailerをextendsして実装すればよい。
class RealMailer extends Mailer { override def sendMail(to: String): Unit = println(s"sending real mail to $to") }
build: 全てをインスタンス化する
最後にDesignに対して.buildを呼び出すと実際に依存性が注入され、インスタンスが作られる。
import wvlet.airframe.* production.build[Application] { app => // appで実際にApplicationがインスタンス化される app.run() } // ここを抜けるとMailerインスタンスは捨てられる
別のコンポーネントを注入する
productionとは別に、ローカル環境で動かすときのDesignも作ってみよう。といっても、あまり難しいことはない。実際にはメールを送らないニセのMailerを用意して、Design上で紐付けるだけだ:
import wvlet.airframe.* val local = newDesign .bind[Mailer].toInstance(FakeMailer())
class FakeMailer extends Mailer { override def sendMail(to: String): Unit = println(s"mail not send to $to because it's fake") }
こうすると、localを利用したApplicationを作ることができる:
import wvlet.airframe.*
local.build[Application] { app =>
app.run()
}
ちょっとしたまとめ
- コンポーネントが何かに依存されるとき、特に何もしなくてよい。
- コンポーネントが何かに依存するとき、コンストラクタから受け取るようにする。
- Airframe DIは、あらかじめ定義された
Designに基いて、コンポーネントをコンストラクタに持ってきてくれる。 Designに対して.buildを呼び出すことで、具体的なインスタンス化が行われる。
コンポーネントの依存関係がネストする場合
前節ではコンポーネントの依存性の定義方法について学んだ。この調子で、新たな依存性としてDatabaseを導入してみよう。Databaseはその名の通りデータベース接続を扱い、特に本番環境で使うRealDBは、インスタンス化のためにConnectionPoolが必要だ、という設定にしよう。
trait Database { def add(userName: String): Unit }
case class ConnectionPool() class RealDB(url: String, connPool: ConnectionPool) extends Database { println(s"connecting to $url") override def add(userName: String): Unit = println("add called on RealDB") }
class FakeDB extends Database { override def add(userName: String): Unit = println("add called on FakeDB") }
ApplicationをDatabaseに依存させる。
class Application(database: Database, mailer: Mailer) { def run() = database.add("Windymelt") mailer.sendMail("windymelt@example.com") }
依存性がネストする場合: toProvider
この状態では、Applicationをインスタンス化できない。というのもDatabaseに対して何もbindしていないからだ。Airframe DIでは、このように依存性の注入に不足がある場合は例外となる。基本的にコンポーネントはbuild時に即時インスタンス化されるので、しばらく気付かないということはないはずだ。
2024-04-16 20:11:30.279+0900 warn [AirframeSession] [session:1cdf56c7] No binding nor the default constructor for Database at Main.scala:8 is found. Add bind[Database].toXXX to your design or make sure Database is not an abstract class. The dependency order: Application -> Database - (AirframeSession.scala:458) [error] [MISSING_DEPENDENCY] Binding for Database at Main.scala:8 is not found: Database <- Application [error] at wvlet.airframe.AirframeException$MISSING_DEPENDENCY$.apply(AirframeException.scala:36)
エラーメッセージの指示通り、productionではDatabaseに対してRealDatabaseをbindしてあげよう。
しかしここで問題が発生する。我々が注入したいRealDBはさらにConnectionPoolに依存しているが、ConnectionPoolは別の場所でbindしているため、直接得られないのだ。どうしよう?
val production = newDesign .bind[db.ConnectionPool].toInstance(db.ConnectionPool()) .bind[db.Database].toInstance( db.RealDB("mysql://foo:bar@db.example.com:3306/foodb", ???), // poolをどうやって持ってくるんだ?? ) .bind[mail.Mailer].toInstance( mail.RealMailer(), )
依存性がさらに別の依存性を持っているとき、Airframe DIではtoProviderを利用する。toProviderは引数の形で別の依存性を引っ張ってきてくれる:
val production = newDesign .bind[db.ConnectionPool].toInstance(db.ConnectionPool()) .bind[db.Database].toProvider( (pool: db.ConnectionPool) => db.RealDB("mysql://foo:bar@db.example.com:3306/foodb", pool), ) .bind[mail.Mailer].toInstance( mail.RealMailer(), )
toProviderによって依存性が別の依存性を持てるようになったため、ネストしていても問題がなくなった。
シングルトンの利用: .to
あるコンポーネントの生成方法が自明である場合がある。例えばFakeMailerクラスはその性質上コンストラクタ引数がいらないので、常に同じインスタンスができる:
class FakeMailer extends Mailer { override def sendMail(to: String): Unit = println(s"mail not send to $to because it's fake") }
このようなインスタンスは、1つだけ生成してシングルトンとして使い回したほうが効率が良い。
Airframe DIは、シングルトンになるようなクラスに対して特別な構文を提供している。.bind[A].toInstance(AImpl)のとき、AImplの生成方法が自明であるときは.bind[A].to[AImpl]と書ける。
val test = newDesign // ... .bind[mail.Mailer].to[mail.FakeMailer]
この構文はobjectに対しては使う必要がない。というのもobjectがシングルトンなのは自明だからだ:
// objectを使う場合 object FakeMailer extends Mailer { override def sendMail(to: String): Unit = println(s"mail not send to $to because it's fake") } val test = newDesign // ... .bind[mail.Mailer].toInstance(mail.FakeMailer)
ただしこの場合、objectの初期化タイミングは制御しにくいことに注意が必要だ。
シングルトンの利用: .toSingleton (作り方が自明なインスタンスは自動的に導出される)
依存されている型と実際の型が一致しているような場合がある。例えばサービスクラスを定義している場合なんかがそうだ。ユーザ登録のためにメールとDBを一気に扱うUserManagerを仮に導入したとしよう:
import db.Database class UserManager(db: Database, mailer: mail.Mailer) { def addUser(userName: String): Unit = { db.add(userName) mailer.sendMail(userName) } }
そして、ApplicationからはUserManagerを呼び出すだけだとする:
class Application(userManager: UserManager) { def run() = userManager.addUser("Windymelt") }
この場合、依存性はtraitではなくUserManagerというクラスそのものに対して向けられているから、依存性とその実装の型が一致しているし、このインスタンスは1つだけで十分だ。このような場合には.toSingletonを呼び出すことで自動的にインスタンスを作ってくれる。
val production = newDesign .bind[db.ConnectionPool].toInstance(/* ... */) .bind[db.Database].toProvider( (pool: db.ConnectionPool) => // ... ) .bind[mail.Mailer].toInstance( // ... ) .bind[UserManager].toSingleton // UserManagerを作る
しかしながら多くの場合、インスタンスの作り方は自明だ。例えばUserManagerを作るにはDataBaseとMailerが必要で、これは依存性を解決すればすぐ得られる。このようなとき、Airframe DIは自動的に必要に応じてUserManagerを作成するので、実はこの記述は書かなくてもよい:
val production = newDesign .bind[db.ConnectionPool].toInstance(/* ... */) .bind[db.Database].toProvider( (pool: db.ConnectionPool) => // ... ) .bind[mail.Mailer].toInstance( // ... )
この、作り方が自明なインスタンスは自動的に導出されるという特長がAirframe DIの非常に嬉しいところだ。例えばCake Patternでは、コンポーネント数がどんどん増えていくにつれて、コンポーネントを増やすたびに周辺のコンポーネントに推移的な依存性を明示して回らなければならなかった。Airframe DIは、作り方が自明ではないコンポーネントにだけ注力すれば、後は全自動でやってくれる。
セッション
基本的な使い方はこれで終わり。ここからは、リソースの作成と破壊に配慮が必要なコンポーネントを扱うときに便利な概念を紹介する。
Airframe DIはどのようにインスタンスを作成し、そして捨てるのだろう。いつDBを初期化すればいいのだろう、そしていつファイルを閉じればいいのか?その栄枯盛衰について確認してみよう。
セッションの開始と終了、セッションの操作
design.build[App] { app => ... }すると、自動的にDesignをもとに各種のインスタンスやシングルトンが作成され、スコープ内で保持される。この状態をセッションと呼ぶ。
普段セッションはdesign.buildすると自動的に初期化され、スコープ脱出時に自動的に破棄されるが、これを明示的に取り扱う記法も用意されている。
val session = design.newSession val app = session.build[App] // do something with app session.shutdown()
上記のコードでは、Designに対して.newSessionすることでセッションが開始する。そしてsession.buildを行うことでDesignで作れるインスタンスを取り出せる。最後にsession.shutdown()を呼び出すと、内部で生成されたインスタンスが全て捨てられ、セッションが終わる。
セッションを明示的に取り扱う一つの利点は、子セッションを起動できることだ。動的にセッションの一部を変更して一連の処理を行い、処理完了時に親セッションの状態に戻るといったことが可能になる。これはHTTPリクエストなどの、局所的になんらかのコンテキストが生じるようなアプリケーションに応用できる。Perlで言うところのScope::Containerだ。
val session = ... val childDesign = ... session.withChildSession(childDesign) { childSession => val x = childSession.build[X] ... }
ちなみにDesignには安全な.withSessionという構文があるため、より安全にセッションを扱うことが可能だ。普通はこちらを使うとよいだろう。
val design = ...
design.withSession { sess =>
design.build[Application] { app =>
sess.withChildSession(...) { csess => ... }
}
}
AutoCloseable
ちなみにjava.lang.AutoCloseableをmix-inしたインスタンスをbindすると、セッションが終了するときに自動的にcloseを呼び出してもらえるので覚えておくとよい。
例えば、RealDatabaseでは以下のような実装を考えることができる:
class RealDB(url: String, connPool: ConnectionPool) extends Database, AutoCloseable { println(s"connecting to $url") override def add(userName: String): Unit = println("add called on RealDB") override def close(): Unit = { println("closing connection") } }
ライフサイクル
このような、セッションが開始してから閉じるまでの流れをライフサイクルと呼ぶ。
Airframe DIはライフサイクルイベント、例えばセッション開始やオブジェクトの注入時に応じた処理をインスタンスに対してフックできる。このためにはonInitなどをbindした後で付ければよい:
class RealDB(url: String, connPool: ConnectionPool) extends Database { override def add(userName: String): Unit = println("add called on RealDB") def initnalizeConnectionPool(): Unit = ??? } val production = newDesign .bind[db.ConnectionPool].toInstance(/* ... */) .bind[db.Database].toProvider( (pool: db.ConnectionPool) => // ... ).onInit(db => db.initializeConnectionPool()) // ...
Airframe DIは様々なライフサイクルフックを用意している。一覧はドキュメントから確認できる。
https://wvlet.org/airframe/docs/airframe.html#life-cyclewvlet.org
Airframe DIのデメリット
Airframe DIにもデメリットがある。
- 依存性の注入が足りているかの検査は動的に行なわれる
- コンパイルが通ったからといってコンポーネントが足りているとは限らない点に注意する必要がある。しかし大多数の場合、DIはアプリケーションの起動時に瞬時に行なわれるためあまり心配はいらないかも。
- 現時点でScala Nativeには対応していない
- Native対応アプリケーションを使いたい場合には障害になるだろう。
便利機能
Designに対してwithProductionModeを指定しておくと、build時にシングルトンが即座に作られるようになるので、起動後のパフォーマンスを良くできる。
val production = newDesign // ... .withProductionMode
まとめ
Airframe DIは利用度に応じた設定のしやすさを持っており、ライトな用途では簡単に使えるし、ヘヴィーな用途では柔軟に設定を組替えることができる。自明なインスタンスを自動的に導出する機能はかなり強力で、ボイラープレート削減に役立つはずだ。
Airframe DIは非常に使いやすいが日本語でのすこし網羅的な利用法のドキュメントが(特にScala 3の場合)あまり見当らなかったので書くことにした。