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の場合)あまり見当らなかったので書くことにした。