DI(Dependency Injection)関連のお話です。以前以下の記事を書いたように、C#ならある程度理解できていました。
Scalaだとよくわからなくて…試したらだいぶすっきりした、という記事です。
いきさつ
最近Scalaで業務やっていて、テストするために依存性を解除しなきゃならないソースがありました。
とはいえ業務中にいろいろ試す時間はなく、また規模も小さかったため、手動テストで乗り切りました。
また、Scalaをやっていてずっと思っていたのが、
C#だとDIはコンストラクタ注入が主流*1だけど、C#のinterfaceとScalaのtraitは内容が違いすぎて、Scalaではコンストラクタ注入が使いやすいようには思えない
というものです。なんとなく、そう思っていました。
そこで見つけた記事が、この記事のタイトルにもなっている以下の記事です。
今回、この記事にあるソースをアレンジして実装し、実際にテストを書いてみるところまでやってみたので、それについて書きます。また、C#のinterfaceとScalaのtraitの違いについても説明します。
C#のinterfaceとScalaのtraitの比較
まず、これについて説明します。
traitについて様々な説明を見ましたが、
というものが多いです。じゃあC#のinterfaceともも近いはず*2なので、比較します。
比較内容 | C#(interface) | Scala(trait) |
---|---|---|
インスタンス化 | できない | できない |
値(プロパティまたはフィールド) | プロパティを持てる | フィールドを持てる |
コンストラクタ | 持てない | 持てない |
メソッド定義 | 強制される | 強制される*3 |
メソッド実装 | 持てない*4 | 持てる |
両者が全く異なる点が、実装を持てるかどうかです。
C#のクラスが、interfaceを「実装する」と、「対象は、その機能を提供する」ことを表します。そのため、C#ではinterfaceがinterfaceを実装する必要性は無く、アンチパターンであると言われていました*5。
一方、Scalaのクラス等が、traitを「ミックスインする」と、「対象は、traitの機能を使える」ことになります。機能を追加するという点で、Scalaではtraitが別のtraitをミックスインするのは、普通にあり得ることとなります。
つまり、Scalaではコンストラクタ注入による依存性の注入に頼る必要が無く、ミックスインで拡張できることとなります。
Scalaで分からなかった点
ミックスインで機能を拡張、差し替えられるとして、テストを行う時だけ差し替える方法が分かりませんでした。
C#だとコンストラクタ注入で、テストするときには引数で渡すオブジェクトを変更すればよいです。しかし、Scalaだと例えばこういうサービスクラスがあったとして、
trait UserRepository { def findById(id: Int): User }
trait UserFindService extends UserRepository { def get(id: Int): User = { // 省略 } }
テストする際、UserFindService
にextendsで指定されているUserRepository
をどのようにしたら差し替えられるのか、疑問でした。
参照記事に書いてあったこと
再びリンクです。
この記事で紹介されている
最小のCakeパターン(Minimal Cake Pattern)
について、私が理解した言葉で表現すると、
- 抽象化対象
- 抽象化したい処理のtrait(
UserRepository
) - 抽象化したい処理のtraitをフィールドとして持つtrait(
UsesUserRepository
)
- 抽象化したい処理のtrait(
- 実装側
- 抽象化処理の実装trait(
UserRepositoryImpl
) - 上記実装した処理のtraitをフィールドに指定した実装を返すtrait(
MixInUserRepository
)
- 抽象化処理の実装trait(
- 利用側
UsesUserRepository
をミックスインし、抽象化されているUserRepository
を使用するtrait(UserService
) ※これがテスト対象UserService
およびMixInUserRepository
をミックスインし、実体化するためのclassまたはobject(システムで使用するオブジェクト。例ではコンパニオンオブジェクトになっていた)
という感じになります。
さらに、私のほうで試した感じだと、テストで準備するものとして、
- テスト
UserRepository
のテスト用実装UsesUserRepository
のテスト用実装(テスト用のUserRepository
を返すだけ)UserService
とUsesUserRepository
をミックスインしたclassまたはobject(テストでUserRepository
として呼び出す)
というものが必要です。
実装例を通して細かく見ていきます。
実装例
モデル
適当ですが準備します。
- User.scala
package user.model case class User(name: String, id: Int)
抽象化対象
モデルを取得するリポジトリの抽象を作成します。
- UserRepository.scala
package user.repository import user.model.User /** * ユーザー取得リポジトリ */ trait UserRepository { def findById(id: Int): Option[User] } /** * 注入trait */ trait UsesUserRepository { val userRepository: UserRepository }
実装側
UserRepository
を実装したものですが、例なのでとても適当な実装になっています。この部分の実装を、システムでの必要に応じて差し替えます。
- MailUserRepository.scala
package mail import user.model.User import user.repository.{UserRepository, UsesUserRepository} trait MixinMailUserRepository extends UsesUserRepository { val userRepository: UserRepository = MailUserRepository } object MailUserRepository extends UserRepository { override def findById(id: Int): Option[User] = { Some(User("name@aaa.com", id)) } }
MinIn
traitは、フィールドのuserRepository
に対して値を設定するという実装を行っています。すなわち、このtraitをミックスインしたクラス等は、userRepository
の実装としてMailUserRepository
が使用されます。
利用側
リポジトリを使用する箇所です。サービスとします。
- UserFindService.scala
package mail import user.model.User import user.repository.UsesUserRepository trait UserFindService extends UsesUserRepository { /** * ユーザーを見つけます。 * @return */ def findByUser(id: Int): User = { userRepository.findById(id) match { case Some(u) => u case None => User("none", 0) } } }
あえて、利用側のtraitのみの記載です。
利用するtraitとしては、UsesUserRepository
をミックスインします。それにより、フィールドにuserRepository
が存在することとなります。このuserRepository
が、具体的にどういう実装であるかに関しては、このtrait内では不明です。そのため実装には依存していない状況となります。
UserFindService
に実装を与えたクラスを作成します。
- UserFindService.scala(追記)
package mail import user.model.User import user.repository.UsesUserRepository trait UserFindService extends UsesUserRepository { /** * ユーザーを見つけます。 * @return */ def findByUser(id: Int): User = { userRepository.findById(id) match { case Some(u) => u case None => User("none", 0) } } } object UserFindService extends UserFindService with MixinMailUserRepository
objectでもclassでもよいです。ここでextendsしているUserFindService
は、上で定義したtraitを指します。同時にMixinMailUserRepository
をミックスインすることで、具象traitを指定しています。
実際にこのサービスを使う際は、
val user = UserFindRepository.findByUser(1)
のような形になります。
テスト
UserFindService
をどうやってテストするか、です。前述の通り、traitのUserFindService
は抽象のみに依存しています。ここにテスト用の具象を与えてやれば、テストできます。
そこで、三つの具象を与えます。
UserService
のテスト実装
case class FakeRepository(name: String) extends UserRepository { override def findById(id: Int): Option[User] = { Some(User(name, id))} }
テストなので、受け取ったものをそのまま返します。具象クラスとして返すので、objectやclassで実装します。
UsesUserService
のテスト実装
trait FakeUserRepository extends UsesUserRepository { val userRepository: UserRepository = FakeRepository("test") }
上で作ったFakeRepository
をフィールドに設定したtraitを作成します。
FindUserService
を継承する
object FakeUserFindService extends UserFindService with FakeUserRepository
テスト対象であるUserFindService
traitと、DIで渡すためにUsesUserRepository
を実装したFakeUserRepository
をミックスインします。これでテスト準備は完了です。
テストを実施する
behavior of "UserFindServiceTest" it should "findByUser" in { val u: UserFindService = FakeUserFindService assert(u.findByUser().name === "test") }
ちゃんと動きます。UserFindService
traitに定義したfindByUser
を呼び出しつつ、DIでテストモジュールを渡せています。
テストケース不足なので、必ずNone
を返すリポジトリも作成しましょう。
object EmptyRepository extends UserRepository { override def findById(id: Int): Option[User] = None } trait EmptyUserRepository extends UsesUserRepository { val userRepository: UserRepository = EmptyRepository } object EmptyUserService extends UserFindService with EmptyUserRepository
こんな感じで、外部からDIでモジュールを渡せるようになりました。すごい。
感想
記事を見ただけでは、なんだかよく分かりませんでした。上記ソースを実際に書いて、Serviceを作ったあたりで意味が分かり始めて、テスト書いたときにすんなりDIが実現できて、これすごくね?と思いました。Scalaのtraitの柔軟さに驚かされました。
というか、テストコードはこういう感じで作るの正解なのでしょうか?一応これなら実現できますし、モック使う必要もないし、いいのではと思っています。
図にしてみた
ざっくりですが。
抽象であるUserRepository
を参照したUserFindService
という抽象を、プロダクトとテストから参照できているよ、というものです。
ソース公開してみた
ソースの品質は良くないですが、動かせたほうがいいかな、と思いまして。
おわりに
どうやってテストやろうか…と業務で頭を悩ませていましたが、なんとか光明が見えてきた感じです。traitにロジックが書ける利点が、ちょっとわかってきたのかもしれません。
C#のinterfaceのように、ふるまいを追加する形でも使えますし、処理を直接使う場合にも使える。うまく使えばすごく強力ですね。本当に、テストが通ったときは驚きました。すごいなこれ…って。
やはり、言語に沿った設計というものはあると思いました。まだまだ研鑽を積まねば。