SE(たぶん)の雑感記

一応SEやっている筆者の思ったことを書き連ねます。会計学もやってたので、両方を生かした記事を書きたいと考えています。 でもテーマが定まってない感がすごい。

『Scalaにおける最適なDependency Injectionの方法を考察する』を試す

DI(Dependency Injection)関連のお話です。以前以下の記事を書いたように、C#ならある程度理解できていました。

hiroronn.hatenablog.jp

Scalaだとよくわからなくて…試したらだいぶすっきりした、という記事です。

いきさつ

最近Scalaで業務やっていて、テストするために依存性を解除しなきゃならないソースがありました。

とはいえ業務中にいろいろ試す時間はなく、また規模も小さかったため、手動テストで乗り切りました。

また、Scalaをやっていてずっと思っていたのが、

C#だとDIはコンストラクタ注入が主流*1だけど、C#のinterfaceとScalaのtraitは内容が違いすぎて、Scalaではコンストラクタ注入が使いやすいようには思えない

というものです。なんとなく、そう思っていました。

そこで見つけた記事が、この記事のタイトルにもなっている以下の記事です。

qiita.com

今回、この記事にあるソースをアレンジして実装し、実際にテストを書いてみるところまでやってみたので、それについて書きます。また、C#のinterfaceとScalaのtraitの違いについても説明します。

C#のinterfaceとScalaのtraitの比較

まず、これについて説明します。

traitについて様々な説明を見ましたが、

Javaでいえばインターフェイスに近い

というものが多いです。じゃあ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をどのようにしたら差し替えられるのか、疑問でした。

参照記事に書いてあったこと

再びリンクです。

qiita.com

この記事で紹介されている

最小のCakeパターン(Minimal Cake Pattern)

について、私が理解した言葉で表現すると、

  • 抽象化対象
    • 抽象化したい処理のtrait(UserRepository
    • 抽象化したい処理のtraitをフィールドとして持つtrait(UsesUserRepository
  • 実装側
    • 抽象化処理の実装trait(UserRepositoryImpl
    • 上記実装した処理のtraitをフィールドに指定した実装を返すtrait(MixInUserRepository
  • 利用側
    • UsesUserRepositoryをミックスインし、抽象化されているUserRepositoryを使用するtrait(UserService) ※これがテスト対象
    • UserServiceおよびMixInUserRepositoryをミックスインし、実体化するためのclassまたはobject(システムで使用するオブジェクト。例ではコンパニオンオブジェクトになっていた)

という感じになります。

さらに、私のほうで試した感じだと、テストで準備するものとして、

  • テスト
    • UserRepositoryのテスト用実装
    • UsesUserRepositoryのテスト用実装(テスト用のUserRepositoryを返すだけ)
    • UserServiceUsesUserRepositoryをミックスインしたclassまたはobject(テストでUserRepositoryとして呼び出す)

というものが必要です。

実装例を通して細かく見ていきます。

実装例

モデル

適当ですが準備します。

package user.model
case class User(name: String, id: Int)

抽象化対象

モデルを取得するリポジトリの抽象を作成します。

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))
  }
}

MinIntraitは、フィールドのuserRepositoryに対して値を設定するという実装を行っています。すなわち、このtraitをミックスインしたクラス等は、userRepositoryの実装としてMailUserRepositoryが使用されます。

利用側

リポジトリを使用する箇所です。サービスとします。

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

テスト対象であるUserFindServicetraitと、DIで渡すためにUsesUserRepositoryを実装したFakeUserRepositoryをミックスインします。これでテスト準備は完了です。

テストを実施する

behavior of "UserFindServiceTest"

it should "findByUser" in {
  val u: UserFindService = FakeUserFindService
  assert(u.findByUser().name === "test")
}

ちゃんと動きます。UserFindServicetraitに定義した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の柔軟さに驚かされました。

というか、テストコードはこういう感じで作るの正解なのでしょうか?一応これなら実現できますし、モック使う必要もないし、いいのではと思っています。

図にしてみた

ざっくりですが。

f:id:hiroronn:20190512172836p:plain
サンプル図

抽象であるUserRepositoryを参照したUserFindServiceという抽象を、プロダクトとテストから参照できているよ、というものです。

ソース公開してみた

github.com

ソースの品質は良くないですが、動かせたほうがいいかな、と思いまして。

おわりに

どうやってテストやろうか…と業務で頭を悩ませていましたが、なんとか光明が見えてきた感じです。traitにロジックが書ける利点が、ちょっとわかってきたのかもしれません。

C#のinterfaceのように、ふるまいを追加する形でも使えますし、処理を直接使う場合にも使える。うまく使えばすごく強力ですね。本当に、テストが通ったときは驚きました。すごいなこれ…って。

やはり、言語に沿った設計というものはあると思いました。まだまだ研鑽を積まねば。

*1:動きが読みやすいし、ASP.NET MVCのDIもそうなっている。https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-2.2

*2:使い方では、JavaC#のinterfaceはあまり変わらない

*3:定義が無い空のメソッドのみ

*4:拡張メソッドを使えば、ある程度は可能

*5:『Adaptive Code C#実践開発手法 第2版』第10章 10.2.3 Interface Soup アンチパターン