前置き
以前、 ScalaっぽいDI を紹介しました。
この記事を書いた時、
「DI」 == 「テストの "手段"」 なので、簡単であるほどよくて、それだけでいい
という指向の元、 Scaladia を公開しました。
当時は v1.2.0 あたりだったかと思いますが、現在は v3.0.0 になり、考え方にもだいぶ変化が出てきています。
今でもAbemaTVの広告配信システムで最新版を利用しているわけですが、前述の記事を書いた時と違い、本当にやりたかったことに近づいてきたので、あたらめてその考え方について紹介したいと思います。
Dependency Injection == テスト効率化の為の手段
AbemaTVの広告配信システムは、 ビジネス要件がかなり複雑 (内容は割愛します) で、ドメイン層から単体テストまで、よく考えて設計しないと本当に手をつけられなくなります。
その複雑な要求仕様を守りつつ、品質を保証するために、 「機能ごとに動作を保証する」 必要があります。
当然普通に単体テストを書いても関係ないロジックまで走ってしまうので、ある機能の変更が、他の機能のテストを失敗させる可能性があります。それを防ぐために Mock なり Stub なりに差し替えられないといけないわけで、結果、ユースケースを考えて、ひとまず
- DIコンテナに自動でロードする
- 依存性を上書きする
- 任意のオブジェクトをInjectionできるクラス / オブジェクトを制限する
- injectionする
らへんの機能が実装されました。
ここまでも Injection対象を自動でロードする仕組みだったりを0から作ったのでだいぶ時間はかかったのですが、いざふたを開けてみるとこれだけだと 全然物足りないことに気づきました。
この辺が、全記事を書いたときくらいですね。
※ 「既存のOSSツカエヨ」 と思う方は全記事をさらっと眺めていただければ。
よく使われるDIフレームワークの課題
使い込みは浅いですが、いろんなDIフレームワークを使ってきました。
アーキテクチャとしてどうこうは置いておいて、前回にも書いたように、 「実装量が膨らむ」、「bind構成を自ら宣言しなければいけない」 などいろいろとまだ課題が多いと感じています。
もちろん、Scaladiaにも課題が山積みで、「実装量は膨らまない」、かつ、「bind構成も基本的には必要がない」 ものの、 「依存性の差し替えに少々不便」 があったり、 「parallelExecutionが有効な時の依存性の差し替えが不安定」 な部分がありました。
(後者はDIコンテナがシングルトンであったことによる弊害です)
(たまにTwitterのタイムラインにもDIフレームワークにブチギレてる方がいらっしゃったりしてそれを見ていたたまれない気持ちになったりしています。笑)
個人的に現存DIフレームワークには大きくこんな課題があるかなと思います。
(深く使い込んでいないので、回避できるものがある場合は教えてください。)
- 深くネストした依存性の差し替えを行うために、最下位から最上位まで差し替えを伝搬させる必要がある
- 依存性を上書きするために書くものが多い
- 純粋なランタイムリフレクションを使ったフレームワークは遅い (全部とは言わない)
- Macroを使用したフレームワークはマルチプロジェクト構成で不便がある
これらを v1.2.0 のscaladiaで説明します。
1. 深くネストした依存性の差し替えを行うために、最下位から最上位まで差し替えを伝搬させる必要がある
フレームワークによって定義は異なるので、scaladia-containerを例に、
例えばこんな定義があったとします。
trait A {
def get: Future[String]
}
object A extends A with HttpClient {
/**
* HTTP requestを行う
**/
def get: Future[String] = http("http://localhost:80/").asString.run
}
trait B extends Deserializer {
val a: A = inject[A]
/**
* {{{A.get}}} をcallし、T型にデシリアライズする
**/
def get[T]: Future[T] = this.a.get.map(_.deserialize[T])
}
object B extends B
trait C {
val b: B = inject[B]
/**
* {{{B.get}}}をcallする
**/
def get[T]: Future[T] = this.b.get[T]
}
object C extends C
(scalatestで) trait Cの正常系と異常系のテストをしたいのですが、普通に叩くとhttp通信が発生してしまうので、offlineの状態でテストができないのはちょっとよくないですね。というわけで、Aだけ差し替えます。
"C.get" should {
"success" in {
object AStub extends A {
def get: Future[String] = Future(Hoge("hoge", "huga").serialize)
}
object BStub extends B
object CStub extends C
narrow[A](AStub).accept(BStub).indexing()
narrow[B](BStub).accept(CStub).indexing()
narrow[C](CStub).accept(this).indexing()
inject[C].get[Hoge] shouldBe Hoge("hoge", "huga").serialize
}
"Failure in A" in {
object AStub extends A {
def get: Future[String] = Future(throw new Exception(""))
}
object BStub extends B
object CStub extends C
narrow[A](AStub).accept(BStub).indexing()
narrow[B](BStub).accept(CStub).indexing()
narrow[C](CStub).accept(this).indexing()
inject[C].get[Hoge].map(_ => fail()).recover {
case e: Throwable => succeed
}
}
}
たった2つのケースを書くだけでこうなってしまいます。なんで?と思う方もいるかもしれませんが、
scalaはビルドがとても遅いので、テストケースの並列実行が(個人的に)当たり前なわけで、同じテストクラスの中だと直列なんですが、これが別々のテストクラスだったりすると、どちらからでも参照可能なスコープで依存性を定義(bind)してしまうと実行順序が保証されていないのでたまにテストがこけたりと不安定になってしまうんですよね。
そのため、Aが複数パターンあったときに、 このBから使えるAはこれですよ。でもこっちのBから使うAはこれですよ と教えておげなければなりません。
2. 依存性を上書きするために書くものが多い
もう 前述 1 の例を見てもらえれば一目瞭然ですが、
- 個々のStub / Mockを作成
- 上書きする依存性をbindするための設定
これがテストパターンごとに出てくるわけですが、他からも参照できるところに実装するとしても、限定的なものまで同じところに置くとどのケースでどれを使っているかわからなくなってきたり、既存のスタブでいいのに新しく作ってしまったりともう訳が分からなくなります。 (まぁこの場合関数の分け方に問題があるかもしれませんが、例として。)
3. 純粋なランタイムリフレクションを使ったフレームワークは遅い
これはもう言うまでもないですね。Spring bootなんかを使ったことがあれば容易に想像できると思いますが、
ただでさえ時間のかかるJVMの起動時間がN倍になったかのような遅さになることもあります。
4. Macroを使用したフレームワークはマルチプロジェクト構成で不便がある
こちらはちょっと高度な話なので、設計の問題という話にもなってきてしまうと思うので
Scaladiaを作る上で、実現が難しい問題点という認識で見ていただければと思います。
僕たちのチームではマイクロサービスアーキテクチャを導入しており、
いくつかのサービスに分かれており、
Valueクラス や GRPC IO、 scalatestラッパーなどの全マイクロサービス共通モジュールと、実際の配信に関わるいつくかのモジュールがあります。
依存関係的にはだいたいこんな感じになっています。
common -> server の数字が若い順にビルドされていくわけなんですが、macroの特性上 common 1 で展開されたmacroは、 common 4 をビルドした時には再展開はされません。もちろん、 common 4 の中で呼び出されたmacroは common 4 の中で展開されます。
何が不便なのかというと、たとえば common 1 に interface があり、そのinterfaceの実装が common 5 にあるとします。
in common 1
trait A
in common 5
object Aimpl extends A with AutoInject[A]
そして、 trait A をinjectionするobjectが common 4 にあるとします
in common 4
object Hoge {
def xxx = inject[A].xxx
}
ところが、この inject[A] が、 Aimpl をinjectionしてくれることはありません。
なぜかというと、 common 4 をコンパイルしたときにこの inject がmacro展開されますが、その時点のclasspathには common 5 が存在しないためです。
「なにを当たり前のことを行っているんだ」 と思う方もいるでしょうが、個人的にアーキテクチャとしてのDIを実現する上で、この機能を実現することは必須でした。
理由はこのあと説明します。
ScaladiaはOSSなので、最たる目的は
幅広くいろんなプロダクト,OSSに使ってもらいたいDIフレームワークのデファクトスタンダードに名を連ねたいいろんな人がメンテナンスに加わってほしい
です。
それに向けて、他のDIフレームワークができないことをできないと土俵にはのれないなと思っていました。
だいぶ時間がかかってしまったものの、試行錯誤のうえ今ではこれらの課題をおおかた解決したので、改めて機能の紹介と、今後どうなっていくのか、どう使ってほしいか、という話をしたいと思います。
機能
基本的には ScalaっぽいDI で紹介したInjectionが主になりますので、そこは割愛させていただきます。
1. Tagging injection
その名の通り、InjectionするオブジェクトとInjectionするタイプにタグ付けをすることができます。
trait A
sealed trait TagType
trait TagTypeX extends TagType
trait TagTypeY extends TagType
object AImpl extends A with AutoInject[A]
object AwithX extends A with Tag[TagTypeX] with AutoInject[A @@ TagTypeX]
object AwithY extends A with Tag[TagTypeY] with AutoInject[A @@ TagTypeY]
inject[A] // should be AImpl
inject[A @@ TagTypeX] // should be AwithX
inject[A @@ TagTypeY] // should be AwithY
今までは、
trait A[T <: TagType] {
protected val tService = inject[TService[T]]
}
みたいなことをしないと、同じIFを持った複数の依存性のハンドリングができなかったところを、柔軟に対応できるようになりました。
2. Priority injection
自動ロード可能な依存性を表す AutoInjectable[T] はPriorityを持ちます。
| Function | Priority |
|---|---|
| RecoveredInject | 0 |
| AutoInject | 1,000 |
| overwrite | 1,100 |
| narrow | Int.MAX |
| AutoInjectCustomPriority | ??? |
それぞれが 自動ロード可能な依存性であることを表す trait / class または 依存性を上書きするための関数です。
injectされた変数にアクセスされた時点でDIコンテナにindexingされている、アクセス可能なオブジェクトのうち、もっとも優先度の高い物をinjectします。
3. Effective injection
これは、特定のEffectに紐付けられたobjectのみinject可能になる制御です。
言葉だけではうまく説明できないので、サンプルを見ていただければイメージしていただけるかと思います。
主に、コンテクストによってinjectするものを切り替えたい場合に使用します。
たとえば、環境ごとの実行構成objectのDIです。
trait Conf {
val server_port: Int
}
object DevConf extends Conf {
val server_port: Int = 9000
}
object StgConf extends Conf {
val server_port: Int = 12000
}
object PrdConf extends Conf {
val server_port: Int = 15001
}
こんなConfを、 java -cp ./classpath/ -Denv=stg com.exec.Main こんな感じで実行するとします。
普通にDIさせようとすると,
object AppConfig {
overwrite[Conf](
sys.props.getOrElse("env", "dev") match {
case "dev" => DevConf
case "stg" => StgConf
case "prd" => PrdConf
}
)
}
こんな感じになるわけですが、じゃあたとえば Logger とか、 SlackClient とか、環境ごとに変化するものをcontainerに突っ込むとき、全部overwriteしたりとか、この初期化処理で env ごとの初期化関数叩くとか、ちょっとださいんですよね。
Effective injectionを使えば、こんな感じにできます。
object Effects {
def getKind = sys.props.getOrElse("env", "dev")
object DEV extends com.phylage.scaladia.effect.Effect {
def activate: Boolean = getKind == "dev"
}
object STG extends com.phylage.scaladia.effect.Effect {
def activate: Boolean = getKind == "stg"
}
object PRD extends com.phylage.scaladia.effect.Effect {
def activate: Boolean = getKind == "prd"
}
}
@Effective(DEV)
object DevConf extends Conf with AutoInject[Conf]
@Effective(STG)
object StgConf extends Conf with AutoInject[Conf]
@Effective(PRD)
object PrdConf extends Conf with AutoInject[Conf]
コンテクストごとに Effect を継承したobjectを宣言します。
この DEV / STG / PRD 、 Autoinject[Effect] なので、勝手にコンテナにINDEXINGされています。
つづいて AutoInject[Conf] 達は、 @Effective(Effect) を付与します。
もうお察しかと思いますが、
inject[???] した時に、 ??? の候補 (この場合 DevConf / StgConf / PrdConf ) に @Effective(_) が付与されていた場合、Effective injectionであると判断され、 activate == true な Effect を持つobjectのみがinjection候補になります。
何が便利なのかというと、 Effect までは常に再利用されるので、
コンテクストごとに差し替えが必要なobjectがいくつに増えてきても @Effective(???) を付与するだけでハンドリングできるようになります。
4. Container shading
基本的にscaladiaのコンテナはprocessに1つしか存在しませんが、一時的なダミーコンテナを作成することができます。
実行環境でこの機能を使うことは想定しておらず、テストフェーズ意外での使用は非推奨です。
これのなにが便利かというと、前述の課題 1 と 2 に効果を表します。
先ほどの例で記述した、正常系と異常系のテストを、この機能を使って書いて見たいとおもいます。
"C.get" should {
"success" in {
shade { implicit c =>
overwrite[A](
new A {
def get: Future[String] = Future(Hoge("hoge", "huga").serialize)
}
)
inject[C].get[Hoge] shouldBe Hoge("hoge", "huga").serialize
}
}
"Failure in A" in {
shade { implicit c =>
overwrite[A](
new A {
def get: Future[String] = Future(throw new Exception(""))
}
)
inject[C].get[Hoge].map(_ => fail()).recover {
case e: Throwable => succeed
}
}
}
}
先ほどと比べてもらえれば、書き物がだいぶ減ってスッキリして、だいぶ見通しがよくなりました。
これ、なにが起きているかと言いますと、
shade 関数が 一時的なcontainer を暗黙値として受け取ることで、このスコープ内だけで利用可能なcontainerが存在します。
このスコープ内で inject したものは常に 一時的なcontainer から取得され、取得されたobjectにも伝搬するので、
new A がinjectされる B を宣言する必要はなく、 new A をグローバルスコープとしてoverwriteしてしまっていいわけです。
機能として増えたのはほぼこの3つだけですので、変わったほとんどの部分は内部的な実装です。
ベンチマーク
現在のバージョン, v2.5.4 では、macro injection と runtime injectionを併用しています。
当然macro injectionで事足りるならそれだけでよかったんですが、 課題4 を解決するにあたって、どうしてもruntime injectionがないと、 実行クラス内でのコンテナ初期化処理 だったり、何かしら一番最初にmacroを展開させる処理がないといけない制約が出てきてしまうので併用する形になりました。
パッと重たいruntime injectionの実装サンプルが書けないので、比較は無しで、どのくらいの性能が出るか試して見たいと思います。
あえて、自動ロード可能なobjectが多くなるよう、上の例の、 server 4 でinectionしたいと思います。
このモジュールのクラスパスには、 AutoInject可能 なobjectが 100 ~ 200個 くらいあります。
object Main extends App with Injector {
val from = System.currentTimeMillis()
inject[Logger]._provide
print(s"TIME: ${System.currentTimeMillis() - from} ms")
}
TIME: 77 ms
Process finished with exit code 0
初回injectionだけInjection poolの初期化が走るので、ちょっと時間がかかります。
object Main extends App with Injector {
val from = System.currentTimeMillis()
inject[Logger]._provide
inject[Logger]._provide
inject[Logger]._provide
inject[Logger]._provide
inject[Logger]._provide
inject[Logger]._provide
inject[Logger]._provide
inject[Logger]._provide
inject[Logger]._provide
inject[Logger]._provide
print(s"TIME: ${System.currentTimeMillis() - from} ms")
}
TIME: 520 ms
Process finished with exit code 0
初回以外は単純計算で45msです。純粋な代入なら 1ms もかからないと考えると、50msはちょっとかかりすぎる気もしますが、
この速度で 柔軟な依存性のハンドリングをできるなら・・
普通にruntime reflectionでクラスパスを全てロードして、runtime mirrorからインスタンスを取得したら数秒くらいかかってもおかしくないところだと思うので、個人的には満足しています。
今後の展開
掲題の通り、もともとテストのためのツールとして作ったDIコンテナを、今はアプリケーションアーキテクチャのような位置付けで利用しています。
いろいろ大きい課題があったものの、不完全ではありますがそれらも徐々に潰してきたところで、今後はこのDIライブラリを使ったフレームワークを作って行こうかなと思っています。
社内でも実践しているのですが、このDIを使うことで、例えばORMならの ConnectionPoolの初期化をする処理 だとか、例えばHTTP serverなら サーバーの実行環境設定 だったりとか、そういう部分をinterfaceだけ定義しておいて、それを inject して利用する何かがあり、その実態はruntime classpath内のどこかに AutoInject を継承したobjectを一個用意すれば注入できるわけです。
これにはメリデメあると思っていて、 完全に、呼び元と呼び先の参照が分断できる 反面、 実装者以外からみたときブラックボックスになる可能性がある というリスクがあります。
この機能をプロダクトで使う、となると厳しいと思う方もいるかもしれませんが、個人的にはScalaを採用しているプロダクトな時点で開発メンバーが揃わないリスクを抱えていると思っているのでそれと比べると・・・、なんて思ってしまいます笑
ただ、Third party frameworkにはなかなか心強い機能なのではないかという想いもあるので、これから Akka http をラップした Scaladia http の開発と、 container shading を使った Testフレームワークの開発を進めて行こうと思っています。
Scalaエンジニアの方ともっと交流していきたいので、ご連絡等々お待ちしています!
追記
Scala秋祭りで、ScaladiaについてLTさせていただきました!
https://docs.google.com/presentation/d/e/2PACX-1vQh38exYkKX3yMJ_GI-XF3VjQciNSdV1dZtVazxveQTp-2SshzrMh2P-8dA5e3JRVFPXItXZYDIICld/pub?start=false&loop=false&delayms=3000&slide=id.p