LoginSignup
46
40

More than 1 year has passed since last update.

令和版: 依存関係逆転の法則の実現方法

Last updated at Posted at 2022-01-23

依存関係逆転の法則とは

コアのロジックが実装の詳細に依存しないようにして、モジュール間を疎結合にしましょうという原則。
Wikipediaでは以下のように説明されている。

  1. 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。 "High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces)."
  2. 抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。 "Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions."

ダメな例

非常に簡単な例を挙げよう。
ユーザIDからユーザ名を引くだけの非常に簡単なサービスを考える。
これを単純に実装すると、以下のようなプログラムになるだろう。(筆者の好みでScalaで解説する)

object FetchUserService {
  def run(userId: Int): Future[Either[ServiceError, String]] = {
    UserRepository.select(userId).map {
      case Some(user) => Right(user.name)
      case None       => Left(ServiceError.notFound)
    }
  }
}
object UserRepository {
  def select(id: Int): Future[Option[User]] = {
    // DBアクセスなどの下位ロジック
  }
}

この実装では FetchUserServiceUserRepository.select を直接使っているため、 サービスのコア部分のロジックがDBアクセスのような下位の実装に依存してしまっている。
それの何がまずいかというと FetchUserService をテストしようとすると、必然的に DB を用意する必要が出てくるという点である。

サービスのコア部分は重点的にテストをするべき場所であるため、テストが簡単にできる状態にしておきたい。
そのため、下位モジュールへの依存をできるだけ断ち切り、単体でテスト可能な設計にしておくことが重要なのである。

よくある解説

依存関係逆転の法則のよくある解説では、この問題をインターフェースを用いて解決している。Scala ならば trait を使うことになる。

例えば、以下のようにする。

class FetchUserService @Inject()(userRepository: UserRepository) {
  def run(userId: Int): Future[Either[ServiceError, String]] = {
    userRepository.select(userId).map {
      case Some(user) => Right(user.name)
      case None       => Left(ServiceError.notFound)
    }
  }
}
trait UserRepository {
  def select(id: Int): Future[Option[User]]
}
object UserRepositoryImpl extends UserRepository {
  def select(id: Int): Future[Option[User]] = {
    // DBアクセスなどの下位ロジック
  }
}

UserRepositorytrait 化し、DBアクセスなどの下位ロジックはそれを実装する UserRepositoryImpl 側におく。
FetchUserServiceUserRepository を実装したインスタンスを DI などを経由して受け取り、それを利用して select するようにする。

こうすることで、FetchUserService はDBアクセスロジックに直接依存しなくなった。
テスト時にはモック版の UserRepository を渡すことで DB がなくてもテストが可能である。

UserRepository は下位モジュールが満たすべき仕様を定めたものなので、これも上位のモジュールであるとみなせば、上位モジュールである FetchUserServiceUserRepository は他のモジュールに依存しておらず、下位モジュール UserRepositoryImpl は上位モジュールに依存していることになる。
最初の例の時と依存関係が逆転しているため、依存関係逆転の法則と呼ばれる。

この方法の問題点

この方法にはいくつかの問題点がある。

まず一つは、FetchUserService の単体テストのために UserRepository のモックが必要となることだ。
モックを利用したテストはしばしばモック側のバグにより壊れることがあり、モックのメンテナンスに馬鹿にならないコストがかかる。
サービスの単体テストのはずがモックのテストになっていたなんて話もしばしば耳にする。

次の問題点は、DB側の都合が select の返り値の型として漏れ出していることである。
select の返り値の型が Future であるのは完全に DB 側の都合であり、FetchUserService からすれば関心がないはずだ。
実際、FetchUserService では Future が関わるような処理は select 以外していない。
Future が絡んでこないのであれば、FetchUserService は単なる逐次実行プログラムとなり、よりテストしやすくなるはずである。

ではどうするか

ここで依存関係逆転の法則を思い出そう。

上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。

例としてインターフェースに依存するべきと書いてある。
インターフェースを使うというのは実装方法の一つに過ぎないのである。

抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。

つまり、具象的な実装内容に依存しない形で「抽象」を定義し、それに依存するようにすれば良いのである。

リクエストオブジェクトを介した依存

そこで、本稿では select を実際に実行する代わりに、UserRepository に実行してほしい内容を記述したリクエストオブジェクトを作ることを考える。
インターフェースの代わりにこのリクエストオブジェクトを「抽象」として利用する。
これは例えば以下のようなデータ型である。

case class SelectUserRequest(id: Int)

このデータを UserRepository に渡すと、UserRepository はそれに従って処理を実行する。

object UserRepository {
  def dispatch(request: SelectUserRequest): Future[Option[User]] = {
    // DBアクセスなどの下位ロジック
  }
}

さて、このオブジェクトを FetchUserServiceUserRepository に直接渡すのでは意味がない。
FetchUserService が行うべきは、 SelectUserRequest とそれが返してきた結果をその後どう処理するか(継続と呼ぶ)のペアを作って返すことである。
そうすれば、あとは FetchUserService の外側で処理を実行することができる。

object FetchUserService {
  def run(userId: Int): (SelectUserRequest, Option[User] => Either[ServiceError, String]) = {
    // リクエスト
    val request = SelectUserRequest(userId)

    // 返ってきたものをどう処理するか (継続)
    val cont: Option[User] => Either[ServiceError, String] = (userOpt: Option[User]) => {
      case Some(user) => Right(user.name)
      case None       => Left(ServiceError.notFound)
    }

    // ペアにして返す
    (request, cont)
  }
}

全体の処理を実行するには、FetchUserService.run を実行した後で、返されたリクエストを UserRepository に渡し、その結果をさらに継続に渡せば良い。

// アプリケーションとして公開する関数
def fetchUser(userId: Int): Future[Either[ServiceError, String]] = {
  val (request, cont) = FetchUserService.run(userId)
  val responseFuture = UserRepository.dispatch(request)
  responseFuture.map(response => cont(response))
}

これで FetchUserService から完全に UserRepository への依存を排除することができた。
また FutureFetchUserService からなくなっている。

FetchUserService の返り値は一見複雑になっているが、一つ目の返り値の SelectUserRequest は単なるデータ型であるため容易にテストできる。
二つ目の返り値である継続は関数オブジェクトなので、通常の単体テストと同じく入力を与えて出力を検査すれば良い。

一般化する

この方法を一般化していこう。

まずは、リクエストを一般化する。

SelectUserRequest 以外のリクエストを可能にするために、一般的なリクエストの型 RepositoryRequest を定義する。
RepositoryRequest は返却するべきオブジェクトの型情報を持つ必要がある。
また、リポジトリごとにリクエストを分岐させるために、リポジトリ単位のリクエスト型も用意しておこう。

sealed trait RepositoryRequest[T]
sealed trait UserRepositoryRequest[T] extends RepositoryRequest[T]
case class SelectUserRequest(id: Int) extends UserRepositoryRequest[Option[User]]

これを処理するリポジトリは次のようになるだろう。

object Repository {
  def dispatch[T](request: RepositoryRequest[T]): Future[T] = request match {
    case userRepositoryRequest: UserRepositoryRequest[T] => UserRepository.dispatch(userRepositoryRequest)
  }
}
object UserRepository {
  def dispatch[T](request: UserRepositoryRequest[T]): Future[T] = request match {
    case SelectUserRequest(id) => select(id)   // Scala2 の場合は asInstanceOf[Future[T]] が必要なようだ
  }
  private def select(id: Int): Future[Option[User]] = {
    // DBアクセスなどの下位ロジック
  }
}

次に、FetchUserService が返すべき型を一般化する。
サービスが返すべき型は、まだリポジトリに対するリクエストがあるなら、リクエストと継続のペアになるはずである。
ただし、その継続の部分でもリポジトリに対してリクエストを行う可能性がある点は留意しておく必要がある。
もしリポジトリに対するリクエストがないのであれば、サービス自体の返り値を返すはずである。

つまり、以下のような型を考えれば良い。

sealed trait ServiceLogic[R]
case class Continue[T, R](request: RepositoryRequest[T], cont: T => ServiceLogic[R]) extends ServiceLogic[R]
case class Done[R](value: R) extends ServiceLogic[R]

このとき FetchUserService は以下のようになる。

object FetchUserService {
  def run(userId: Int): ServiceLogic[Either[ServiceError, String]] = {
    val request = SelectUserRequest(userId)
    val cont: Option[User] => ServiceLogic[Either[ServiceError, String]] = {
      (userOpt: Option[User]) => {
        case Some(user) => Done(Right(user.name))
        case None       => Done(Left(ServiceError.notFound))
      }
    }
    Continue(request, cont)
  }
}

任意の ServiceLogic を実行するためのヘルパー関数を定義しておこう。

def runServiceLogic[R](serviceLogic: ServiceLogic[R]): Future[R] = serviceLogic match {
  case continue: Continue[_, R] => runContinue(continue)
  case Done(value)              => Future(value)
}
private def runContinue[T, R](continue: Continue[T, R]): Future[R] = {
  Repository.dispatch(continue.request).flatMap { t =>
    runServiceLogic(continue.cont(t))
  }
}

すると、fetchUser は以下のようになる。

// アプリケーションとして公開する関数
def fetchUser(userId: Int): Future[Either[ServiceError, String]] = {
  runServiceLogic(FetchUserService.run(userId))
}

以上で完成である。
上位のモジュールである FetchUserService は下位モジュールである UserRepository にまったく依存しておらず、容易に単体テストが可能になっていることがわかる。

まとめ

  • インターフェースの利用は依存関係逆転の法則の実現方法の一つでしかなく、またその方法にはいくつか問題があった
  • 依存関係逆転の法則の「抽象」としてリクエストオブジェクトを利用することで、これらの問題を解決できることを示した

追記: より便利にするには

ServiceLogicContinueT => ServiceLogic[R] という型の引数をとる。
この型はどこかでみたことがないだろうか。そう、flatMap の引数と同じ型である。

Continue が第一引数として RepositoryRequest ではなく ServiceLogic を取るようにすれば、 Continue の生成は flatMap と等価ということになる。

sealed trait ServiceLogic[R] {
  def flatMap[U](f: R => ServiceLogic[U]): ServiceLogic[U] = Continue(this, f)
}
case class Continue[T, R](serviceLogic: ServiceLogic[T], cont: T => ServiceLogic[R]) extends ServiceLogic[R]

また、ServiceLogicmap も実装しておくと便利だろう。

sealed trait ServiceLogic[R] {
  def map[U](f: R => U): ServiceLogic[U] = Continue(this, r => Done(f(r)))
}

ContinueRepositoryRequest ではなく ServiceLogic を取ると RepositoryRequest から ServiceLogic を生成する方法がなくなってしまうので、代わりに RepositoryRequest のレスポンスを直接返り値とするような ServiceLogic を生成できるようにしておこう。

case class Suspend[T](request: RepositoryRequest[T]) extends ServiceLogic[T]

ServiceLogic の定義はこうなる。

sealed trait ServiceLogic[R] {
  def flatMap[U](f: R => ServiceLogic[U]): ServiceLogic[U] = Continue(this, f)
  def map[U](f: R => U): ServiceLogic[U] = Continue(this, r => Done(f(r)))
}
case class Suspend[T](request: RepositoryRequest[T]) extends ServiceLogic[T]
case class Continue[T, R](serviceLogic: ServiceLogic[T], cont: T => ServiceLogic[R]) extends ServiceLogic[R]
case class Done[R](value: R) extends ServiceLogic[R]

こうしておくと、FetchUserService は以下のように書ける。

object FetchUserService {
  def run(userId: Int): ServiceLogic[Either[ServiceError, String]] = {
    Suspend(SelectUserRequest(userId)).map {
      case Some(user) => Right(user.name)
      case None       => Left(ServiceError.notFound)
    }
  }
}

例えば、ここで直接 Suspend[Option[User]] を生成するようなビルダーとして UserRepositoryLogic.select を定義してみよう。

object UserRepositoryLogic {
  def select(userId: Int): ServiceLogic[Option[User]] = {
    Suspend(SelectUserRequest(userId))
  }
}

すると FetchUserService はこうなる。

object FetchUserService {
  def run(userId: Int): ServiceLogic[Either[ServiceError, String]] = {
    UserRepositoryLogic.select(userId).map {
      case Some(user) => Right(user.name)
      case None       => Left(ServiceError.notFound)
    }
  }
}

さて、最初のコードを思い出そう。
最初のコードでは FetchUserService は以下のように書いた。

object FetchUserService {
  def run(userId: Int): Future[Either[ServiceError, String]] = {
    UserRepository.select(userId).map {
      case Some(user) => Right(user.name)
      case None       => Left(ServiceError.notFound)
    }
  }
}

見てわかる通り、ほぼ同じコードとなっている。

追記の追記: Free

ServiceLogic の定義は最終的にこうなった。

sealed trait ServiceLogic[R] {
  def flatMap[U](f: R => ServiceLogic[U]): ServiceLogic[U] = Continue(this, f)
  def map[U](f: R => U): ServiceLogic[U] = Continue(this, r => Done(f(r)))
}
case class Suspend[T](request: RepositoryRequest[T]) extends ServiceLogic[T]
case class Continue[T, R](serviceLogic: ServiceLogic[T], cont: T => ServiceLogic[R]) extends ServiceLogic[R]
case class Done[R](value: R) extends ServiceLogic[R]

実はこの型は Free という名前でよく知られたものである。
ただし、Free では RepositoryRequest の部分が型引数として与えられる。

以下は cats の Free の定義 (抜粋 & 一部改変) である。

sealed trait Free[S[_], R] {
  def flatMap[U](f: R => Free[S, U]): Free[S, U] = FlatMapped(this, f)
  def map[U](f: R => U): Free[S, U] = FlatMapped(this, r => Pure(f(r)))
}
case class Suspend[S[_], T](request: S[T]) extends Free[S, T]
case class FlatMapped[S[_], T, R](free: Free[S, T], cont: T => Free[S, R]) extends Free[S, R]
case class Pure[S[_], R](value: R) extends Free[S, R]

つまり cats を利用している場合、ServiceLogic は実は自分で定義する必要はない。
RepositoryRequest を定義すれば、あとは Free に与えるだけで ServiceLogic を実装できる。

type ServiceLogic[R] = Free[RepositoryRequest, R]

実行するときは RepositoryRequest を計算する方法を与えるだけで良い。

// FunctionK (自然変換) の定義方法
val compiler = new (RepositoryRequest ~> Future) {
  def apply[A](req: RepositoryRequest[A]): Future[A] = Repository.dispatch(req)
}
def runServiceLogic[R](serviceLogic: ServiceLogic[R]): Future[R] = {
  serviceLogic.foldMap(compiler)
}
46
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
46
40