依存性注入のためのリーダーモナド:複数の依存性、ネストされた呼び出し


87

Scalaでの依存性注入について尋ねられたとき、かなり多くの回答が、Scalazからのものか、または単に独自のものをローリングするかのいずれかで、ReaderMonadを使用することを示しています。アプローチの基本を説明する非常に明確な記事がいくつかありますが(たとえば、Runarの講演Jasonのブログ)、より完全な例を見つけることができず、そのアプローチの利点を他の人よりも理解できていません。従来の「手動」DI(私が書いたガイドを参照)。おそらく私はいくつかの重要な点を見逃しているので、質問です。

例として、次のクラスがあると想像してみましょう。

trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }

class FindUsers(datastore: Datastore) {
  def inactive(): Unit = ()
}

class UserReminder(findUser: FindUsers, emailServer: EmailServer) {
  def emailInactive(): Unit = ()
}

class CustomerRelations(userReminder: UserReminder) {
  def retainUsers(): Unit = {}
}

ここでは、クラスとコンストラクターパラメーターを使用してモデル化しています。これは、「従来の」DIアプローチと非常にうまく機能しますが、この設計にはいくつかの良い面があります。

  • 各機能には、明確に列挙された依存関係があります。機能が正しく機能するためには、依存関係が本当に必要であると想定しています。
  • 依存関係は機能間で隠されています。たとえば、データストアUserReminderFindUsers必要かどうかはわかりません。機能は、別々のコンパイルユニットでも可能です
  • 純粋なScalaのみを使用しています。実装は不変クラス、高階関数を活用でき、「ビジネスロジック」メソッドはIO、効果などをキャプチャする場合にモナドにラップされた値を返すことができます。

これをReaderモナドでどのようにモデル化できますか?上記の特性を保持して、各機能に必要な依存関係の種類を明確にし、ある機能の依存関係を別の機能から隠すとよいでしょう。classesの使用は、実装の詳細であることに注意してください。おそらく、Readerモナドを使用した「正しい」ソリューションは他のものを使用するでしょう。

私はどちらかを示唆するいくぶん関連した質問を見つけました:

  • すべての依存関係を持つ単一の環境オブジェクトを使用する
  • ローカル環境の使用
  • 「パフェ」パターン
  • タイプインデックス付きマップ

ただし、そのような単純なことに関しては(しかしそれは主観的ですが)少し複雑すぎることを除けば、これらのソリューションのすべてで、たとえばretainUsersメソッド(呼び出しemailInactiveinactive非アクティブなユーザーを見つけるために呼び出す)は、Datastore依存関係について知る必要があります。ネストされた関数を適切に呼び出すことができます-または私は間違っていますか?

このような「ビジネスアプリケーション」にReaderMonadを使用する方が、コンストラクターパラメーターを使用するよりも優れている点は何でしょうか。


1
リーダーモナドは特効薬ではありません。多くのレベルの依存関係が必要な場合、デザインはかなり良いと思います。
zhekaKozlov 2015年

ただし、依存性注入の代替として説明されることがよくあります。多分それは補完として説明されるべきですか?DIが「真の関数型プログラマー」によって却下されていると感じることがあるので、「代わりに何を」と思っていました:)いずれにせよ、複数のレベルの依存関係、またはむしろ複数の外部サービスと話し合う必要があると思います。すべての中規模の「ビジネスアプリケーション」は次のようになります(確かに図書館の場合はそうではありません)
adamw 2015年

2
私はいつもReaderモナドをローカルなものとして考えてきました。たとえば、DBとのみ通信するモジュールがある場合、このモジュールをReaderモナドスタイルで実装できます。ただし、アプリケーションでさまざまなデータソースを組み合わせる必要がある場合は、Readerモナドがそのために適しているとは思いません。
zhekaKozlov 2015年

ああ、それは2つの概念を組み合わせる方法の良いガイドラインかもしれません。そして実際、DIとRMは互いに補完し合っているように見えます。実際、1つの依存関係のみを操作する関数を使用することは非常に一般的であり、ここでRMを使用すると、依存関係とデータの境界を明確にするのに役立ちます。
adamw 2015年

回答:


36

この例をモデル化する方法

これをReaderモナドでどのようにモデル化できますか?

これReaderでモデル化する必要があるかどうかはわかりませんが、次の方法でモデル化できます。

  1. コードをReaderでより適切に再生する関数としてクラスをエンコードする
  2. 理解のためにReaderで関数を構成して使用する

開始の直前に、この回答に有益だと感じた小さなサンプルコードの調整について説明する必要があります。最初の変更はFindUsers.inactive方法についてです。List[String]アドレスのリストをUserReminder.emailInactiveメソッドで使用できるように、それを返させます。また、メソッドに簡単な実装を追加しました。最後に、サンプルでは、​​次の手動ロールバージョンのReaderモナドを使用します。

case class Reader[Conf, T](read: Conf => T) { self =>

  def map[U](convert: T => U): Reader[Conf, U] =
    Reader(self.read andThen convert)

  def flatMap[V](toReader: T => Reader[Conf, V]): Reader[Conf, V] =
    Reader[Conf, V](conf => toReader(self.read(conf)).read(conf))

  def local[BiggerConf](extractFrom: BiggerConf => Conf): Reader[BiggerConf, T] =
    Reader[BiggerConf, T](extractFrom andThen self.read)
}

object Reader {
  def pure[C, A](a: A): Reader[C, A] =
    Reader(_ => a)

  implicit def funToReader[Conf, A](read: Conf => A): Reader[Conf, A] =
    Reader(read)
}

モデリングステップ1.クラスを関数としてエンコードする

それはオプションかもしれませんが、よくわかりませんが、後で理解しやすくなります。結果の関数はカレーされていることに注意してください。また、以前のコンストラクター引数を最初のパラメーター(パラメーターリスト)として使用します。そのように

class Foo(dep: Dep) {
  def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)

になります

object Foo {
  def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)

それぞれのことを覚えておいてくださいDepArgResタイプは完全に任意でよい:タプル、関数やシンプルタイプ。

関数に変換された、初期調整後のサンプルコードは次のとおりです。

trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }

object FindUsers {
  def inactive: Datastore => () => List[String] =
    dataStore => () => dataStore.runQuery("select inactive")
}

object UserReminder {
  def emailInactive(inactive: () => List[String]): EmailServer => () => Unit =
    emailServer => () => inactive().foreach(emailServer.sendEmail(_, "We miss you"))
}

object CustomerRelations {
  def retainUsers(emailInactive: () => Unit): () => Unit =
    () => {
      println("emailing inactive users")
      emailInactive()
    }
}

ここで注意すべきことの1つは、特定の関数がオブジェクト全体に依存するのではなく、直接使用される部分にのみ依存することです。OOPバージョンのUserReminder.emailInactive()インスタンスがuserFinder.inactive()ここで呼び出す場所はinactive() 、最初のパラメーターで渡された関数です。

コードは、質問から3つの望ましいプロパティを示していることに注意してください。

  1. 各機能に必要な依存関係の種類は明らかです
  2. ある機能の依存関係を別の機能から隠します
  3. retainUsers メソッドはデータストアの依存関係について知る必要はありません

モデリングステップ2.リーダーを使用して関数を作成して実行する

リーダーモナドでは、すべて同じタイプに依存する関数のみを作成できます。多くの場合、これは当てはまりません。この例では FindUsers.inactiveに依存DatastoreしてUserReminder.emailInactiveEmailServer。この問題を解決するには、すべての依存関係を含む新しいタイプ(多くの場合、Configと呼ばれます)を導入し、関数を変更して、すべてがそれに依存し、関連するデータのみを取得するようにします。依存関係管理の観点からは、これは明らかに間違っています。これは、これらの関数を、そもそも知らないはずの型にも依存させるためです。

幸いなことに、関数のConfig一部のみをパラメーターとして受け入れる場合でも、関数を機能させる方法が存在することがわかりました。これlocalは、Readerで定義されていると呼ばれるメソッドです。から関連する部分を抽出する方法を提供する必要がありますConfig

手元の例に適用されるこの知識は、次のようになります。

object Main extends App {

  case class Config(dataStore: Datastore, emailServer: EmailServer)

  val config = Config(
    new Datastore { def runQuery(query: String) = List("john.doe@fizzbuzz.com") },
    new EmailServer { def sendEmail(to: String, content: String) = println(s"sending [$content] to $to") }
  )

  import Reader._

  val reader = for {
    getAddresses <- FindUsers.inactive.local[Config](_.dataStore)
    emailInactive <- UserReminder.emailInactive(getAddresses).local[Config](_.emailServer)
    retainUsers <- pure(CustomerRelations.retainUsers(emailInactive))
  } yield retainUsers

  reader.read(config)()

}

コンストラクターパラメーターを使用するよりも利点

このような「ビジネスアプリケーション」にReaderMonadを使用する方が、コンストラクターパラメーターを使用するよりも優れている点は何でしょうか。

この回答を準備することで、単純なコンストラクターに勝る側面を自分で判断しやすくなることを願っています。しかし、これらを列挙する場合は、ここに私のリストがあります。免責事項:私はOOPのバックグラウンドを持っており、ReaderとKleisliを使用していないため、それらを十分に理解していない可能性があります。

  1. 均一性-理解のためにどれほど短い/長いかは問題ではありません。それは単なるリーダーであり、別のインスタンスで簡単に構成できます。おそらく、もう1つのConfigタイプを導入し、そのlocal上にいくつかの呼び出しを振りかけるだけです。この点はIMOの好みの問題です。コンストラクターを使用する場合、OOPで悪い習慣と見なされているコンストラクターで作業を行うなど、誰かが愚かなことをしない限り、誰もあなたが好きなものを作成することを妨げません。
  2. Readerはモナドであるため、それに関連するすべての利点が得られます- sequencetraverseメソッドは無料で実装されます。
  3. 場合によっては、リーダーを1回だけビルドして、さまざまな構成に使用する方が望ましい場合があります。コンストラクターを使用すると、誰もそれを妨げることはありません。着信するすべての構成に対して、オブジェクトグラフ全体を新たに作成する必要があります。私はそれで問題はありませんが(私はアプリケーションへのすべての要求でそれを行うことを好みます)、私が推測するだけの理由から、多くの人にとってそれは明白な考えではありません。
  4. Readerは、関数をより多く使用するように促します。これは、主にFPスタイルで記述されたアプリケーションでより適切に機能します。
  5. 読者は懸念を分離します。依存関係を提供せずに、作成、すべての操作、ロジックの定義を行うことができます。実際には後で別々に供給してください。(この点についてはKen Scramblerに感謝します)。これはReaderの利点としてよく耳にしますが、単純なコンストラクターでも可能です。

また、Readerで気に入らない点も教えてください。

  1. マーケティング。Readerは、セッションCookieであるかデータベースであるかを区別せずに、あらゆる種類の依存関係で販売されているという印象を受けることがあります。私には、この例の電子メールサーバーやリポジトリなど、実質的に一定のオブジェクトにReaderを使用する意味はほとんどありません。このような依存関係については、単純なコンストラクターや部分的に適用された関数の方がはるかに優れていることがわかります。基本的にReaderは柔軟性を備えているため、呼び出しのたびに依存関係を指定できますが、それが本当に必要ない場合は、税金を支払うだけです。
  2. 暗黙の重さ-暗黙なしでReaderを使用すると、例が読みにくくなります。一方、暗黙的にノイズの多い部分を非表示にしてエラーを発生させると、コンパイラーによってメッセージの解読が困難になる場合があります。
  3. を使用したセレモニーpurelocalおよび独自のConfigクラスの作成/そのためのタプルの使用。Readerは、問題のあるドメインに関するものではないコードを追加するように強制するため、コードにノイズが発生します。一方、コンストラクターを使用するアプリケーションは、問題ドメインの外部からのファクトリパターンを使用することが多いため、この弱点はそれほど深刻ではありません。

クラスを関数付きのオブジェクトに変換したくない場合はどうなりますか?

あなたが欲しい。技術的にはそれを回避できますが、FindUsersクラスをオブジェクトに変換しなかった場合にどうなるかを見てください。理解のためのそれぞれの行は次のようになります。

getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)

それほど読みにくいですよね?重要なのは、Readerは関数を操作するため、関数をまだ持っていない場合は、インラインで作成する必要がありますが、これはあまり美しくないことがよくあります。


詳細な回答をありがとう:)私にははっきりしない1つのポイントは、なぜでDatastoreありEmailServer、特性として残され、他の人はobjectsになったのですか?これらのサービス/依存関係/(どのように呼んでも)に根本的な違いがあり、それらの扱いが異なりますか?
adamw 2015年

ええと...私も例えばEmailSenderオブジェクトに変換することはできませんよね?その場合、タイプがないと依存関係を表現できません...
adamw 2015年

ああ、依存関係は適切な型を持つ関数の形をとるでしょう-したがって、型名を使用する代わりに、すべてが関数シグネチャに入る必要があります(名前は単なる偶然です)。たぶん、しかし私は確信していません;)
adamw 2015年

正しい。EmailSenderあなたに依存する代わりにに依存するでしょう(String, String) => Unit。それが説得力があるかどうかは別の問題です:)確かに、誰もがすでにに依存してFunction2いるので、少なくともより一般的です。
Przemek Pokrywka 2015年

確かに、型エイリアスではなく、コンパイル時にチェックされるものを使用して、何らかの意味を伝えるように名前付けたいと思います(String, String) => Unit;)
adamw 2015年

3

主な違いは、あなたの例では、オブジェクトがインスタンス化されるときにすべての依存関係を注入していることだと思います。Readerモナドは基本的に、依存関係を指定して呼び出すためのますます複雑な関数を構築し、それが最上位のレイヤーに返されます。この場合、注入は関数が最終的に呼び出されたときに行われます。

直接的な利点の1つは柔軟性です。特に、モナドを一度作成してから、さまざまな依存関係を挿入して使用したい場合はなおさらです。1つの欠点は、あなたが言うように、潜在的に明確さが低下することです。どちらの場合も、中間層はそれらの直接の依存関係について知る必要があるだけなので、どちらもDIのアドバタイズされたとおりに機能します。


中間層は、それらのすべてではなく、それらの中間依存関係についてのみどのように知るのでしょうか?リーダーモナドを使用して例を実装する方法を示すコード例を教えてください。
adamw 2015

おそらく、Jsonのブログ(あなたが投稿したもの)と同じように説明できます。そこにあるフォームを引用すると、「暗黙の例とは異なり、userEmailとuserInfoの署名のどこにもUserRepositoryがありません」。その例を注意深く確認してください。
ダニエルラングドン

1
はい。ただし、これは、使用しているリーダーモナドがパラメーター化されConfigており、への参照が含まれていることを前提としていますUserRepository。確かに、それは署名に直接表示されませんが、さらに悪いことに、コードがどの依存関係を使用しているかが一見しただけではわかりません。依存していないConfigすべての依存関係を持つには、に依存し、各方法の種類を意味するすべてのそれらのを?
adamw 2015

それは彼らに依存しますが、それを知る必要はありません。クラスを使用した例と同じです。私はそれらをかなり同等だと思います:
ダニエルラングドン

クラスの例では、実際に必要なものだけに依存し、すべての依存関係が内部にあるグローバルオブジェクトには依存しません。そして、グローバルの「依存関係」の内部に何が入るのか、そしてconfig「単なる関数」とは何かをどのように決定するのかという問題が発生します。おそらく、あなたも多くの自己依存に終わるでしょう。とにかく、それはQ&Aよりも好みの議論です:)
adamw 2015
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.