web-dev-qa-db-ja.com

Reader Monad for Dependency Injection:複数の依存関係、ネストされた呼び出し

Scalaでの依存性注入について尋ねられたとき、非常に多くの回答が、ScalazのリーダーMonadを使用するか、または独自のリーダーMonadを使用することを指し示しています。アプローチの基本を説明する非常に明確な記事が多数あります(例 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アプローチと非常にうまく機能しますが、この設計にはいくつかの良い面があります。

  • 各機能には依存関係が明確に列挙されています。機能が適切に機能するためには依存関係が本当に必要だと思います
  • 依存関係は機能間で隠されています。 UserReminderは、FindUsersがデータストアを必要とすることを知りません。機能は別々のコンパイル単位でも可能です
  • 純粋なScalaのみを使用しています。実装は不変クラス、高階関数を活用でき、「ビジネスロジック」メソッドは、エフェクトなどをキャプチャする場合にIOモナドにラップされた値を返すことができます。

Readerモナドでこれをどのようにモデル化できますか?上記の特性を保持して、各機能が必要とする依存関係の種類を明確にし、ある機能の依存関係を別の機能から非表示にすることをお勧めします。 classesを使用する方が実装の詳細です。 Readerモナドを使用した「正しい」ソリューションでは、他の何かを使用する可能性があります。

やや関連する質問 を見つけました。

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

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

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

84
adamw

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

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

これがshould Readerでモデル化されているかどうかはわかりませんが、次の方法で作成できます。

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

開始する直前に、この答えに有益だと感じた小さなサンプルコードの調整について説明する必要があります。最初の変更は、_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()
    }
}
_

ここで注意すべきことは、特定の関数はオブジェクト全体に依存せず、直接使用される部分にのみ依存するということです。ここでOOP version UserReminder.emailInactive()インスタンスはuserFinder.inactive()を呼び出しますが、ここではinactive()を呼び出します-最初に渡された関数パラメータ。

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

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

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

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

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

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

_object Main extends App {

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

  val config = Config(
    new Datastore { def runQuery(query: String) = List("[email protected]") },
    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)()

}
_

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

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

この答えを準備することにより、プレーンなコンストラクターをどの面で倒すかを自分で判断しやすくなることを願っています。それでも、これらを列挙する場合、ここに私のリストがあります。免責事項:私はOOPバックグラウンドを持っていますが、私はそれらを使用しないのでReaderとKleisliを完全に評価しないかもしれません。

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

また、Readerで自分が気に入らないことを伝えたいと思います。

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

クラスを関数を持つオブジェクトに変換したくない場合はどうすればよいですか?

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

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

それはそんなに読めません、そうですか?重要なのは、Readerが関数を操作するため、まだ持っていない場合は、インラインで構築する必要があることです。

36

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

直接的な利点の1つは、特にモナドを一度構築してから、異なる注入された依存関係でモナドを使用したい場合の柔軟性です。欠点の1つは、あなたが言うように、潜在的に明快さが低いことです。どちらの場合も、中間層はそれらの直接の依存関係を知るだけでよいため、どちらもDIの広告として機能します。

3
Daniel Langdon