1. ホーム
  2. scala

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

2023-04-13 12:15:29

質問

ScalaのDependency Injectionについて質問されたとき、多くの回答がReader Monadの使用、Scalazからのもの、または自分でローリングすることを指摘しています。このアプローチの基本を説明した非常にわかりやすい記事がたくさんあります(例. Runarのトーク , ジェイソンのブログ を参照)、しかしもっと完全な例は見つかりませんでしたし、このアプローチがより伝統的な "マニュアル" 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 = {}
}

ここではクラスとコンストラクタのパラメータを使用してモデリングしています。

  • 各機能は明確に列挙された依存関係を持っています。私たちは、依存関係がその機能が適切に動作するために本当に必要であると仮定しています。
  • 依存関係が機能間で隠されている、例えば UserReminderFindUsers がデータストアを必要とすることを知りません。この機能は別々のコンパイル単位でも可能です。
  • 実装は、不変クラス、高階関数、ビジネスロジック、メソッドにラップされた値を返すことができます。 IO モナドにラップされた値を返すことができます。

Readerモナドでどのようにモデル化できるでしょうか。各機能がどのような依存関係を必要とするかを明確にし、ある機能の依存関係を他の機能から隠すために、上記の特徴を保持することが良いでしょう。なお class 多分、Reader モナドを使用する "correct" ソリューションは他のものを使用するでしょう。

私は やや関連した質問 のどちらかを示唆しています。

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

しかし、(主観的ですが) このような単純なものにしては少し複雑すぎるということ以外に、これらの解決策のすべてにおいて、たとえば retainUsers メソッド (これは emailInactive を呼び出す。 inactive を呼び出して非アクティブユーザーを探す) は、そのために Datastore の依存関係を知っている必要があります。

このような "ビジネスアプリケーション" のためにリーダーモナドを使用することは、単にコンストラクタのパラメータを使用するよりもどのような面で優れているのでしょうか?

どのように解決するのですか?

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

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. クラスを関数としてエンコードする

多分それはオプションで、私はよく分からないが、後でそれはfor comprehensionをよりよく見えるようにする。 結果の関数が curried であることに注意してください。また、最初のパラメータ(パラメータリスト)として元コンストラクタの引数を取ります。 そうすると

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)

それぞれの Dep , Arg , Res の型は完全に任意です:タプル、関数、単純な型など。

以下は初期調整後のサンプルコードで、関数に変換されたものです。

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版では UserReminder.emailInactive() インスタンスは userFinder.inactive() を呼び出しますが、ここでは単に inactive() - を呼び出すだけです。

このコードは、質問にあった3つの望ましい特性を示していることに注意してください。

  1. 各機能がどのような依存関係を必要とするかが明確である。
  2. ある機能の依存関係を別の機能から隠すことができる
  3. retainUsers メソッドはデータストアの依存関係を知る必要がありません。

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

Readerモナドでは、すべて同じ型に依存する関数だけを合成することができます。これは、しばしばそうではありません。この例では FindUsers.inactive に依存している DatastoreUserReminder.emailInactiveEmailServer . この問題を解決するために を導入し,その型に依存するように関数を変更し,その型から関連する データのみを取得するようにすることができます. 関数を変更し、すべての関数をそれに依存させ、関連するデータのみをそこから取得するようにします。 しかし、これは依存関係の管理という観点からは明らかに間違っています。 この方法では、これらの関数を、そもそも知るべきでない型に依存させることになるため、依存関係の管理の観点からは明らかに間違っています。

幸いなことに、この関数を 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("[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)()

}

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

このようなビジネスアプリケーションでリーダーモナドを使用することは、コンストラクタのパラメータを使用するよりもどのような点で優れているのでしょうか?

この回答を用意したことで、どのような点で普通のコンストラクタより優れているのか、ご自身で判断しやすくなったかと思います。 しかし、もしこれらを列挙するとしたら、私のリストは以下のようになります。免責事項:私はOOPのバックグラウンドがあり、ReaderとKleisliを使用していないので、十分に評価できないかもしれません を使用していないため、完全に評価できないかもしれません。

  1. 統一性 - 理解のためにどれだけ短く/長くても、それは単なる Reader であり、他のインスタンスと簡単に構成することができます。 インスタンスと簡単に構成できます。おそらく、もう 1 つの Config タイプを導入し、いくつかの local を呼び出すだけです。この点は、IMO なぜなら、コンストラクタを使用する場合、誰もあなたが好きなものを構成することを妨げないからです。 誰かが何か馬鹿なことをしない限り、例えば OOP で悪い習慣とされているコンストラクタでの作業を行うようなことはありません。
  2. Reader はモナドなので、モナドに関連するすべての利点を得ることができます。 sequence , traverse のメソッドを無償で実装しました。
  3. 場合によっては、Reader を一度だけ構築し、それを様々な Configs で使用することが望ましいと感じるかもしれません。 コンストラクタを使えば、誰もそれを妨げません。 を受信するたびにオブジェクトグラフ全体を新たに構築する必要があるだけです。私はそれで問題ないのですが (アプリケーションへのリクエストごとにそうする方が好きなぐらいです)、 多くの人にとってそれは明白なアイデアではありません。 多くの人々にとって明白なアイデアではありません。
  4. Reader は、主に FP スタイルで書かれたアプリケーションでよりよく機能する、関数をより多く使用する方向にあなたを導きます。
  5. Readerは関心事を分離します。あなたは依存関係を提供することなく、すべてを作成し、対話し、ロジックを定義することができます。実際には、後で別々に供給します。(この点については Ken Scrambler に感謝します)。これはしばしばReaderの利点として聞かれますが、しかし、それは普通のコンストラクタでも可能です。

また、Readerの嫌いなところを挙げたいと思います。

  1. マーケティング。時々、私は、Readerが、それがセッションクッキーかデータベースかの区別なく、あらゆる種類の依存性のために販売されているという印象を受けます。 を売り込んでいるような印象を受けることがあります。私にとっては、この例のメールサーバーやリポジトリのような、実質的に一定のオブジェクトにReaderを使う意味はほとんどありません。 サーバーやリポジトリなどです。このような依存関係に対しては、普通のコンストラクタや部分的に適用される関数の方が優れていると思います。 の方がいいと思います。基本的にReaderは柔軟性があるので、呼び出すたびに依存関係を指定することができますが、もしその必要がなければ しかし、もし本当にそれが必要でないのなら、あなたはその税金を払うだけです。
  2. 暗黙の重さ - 暗黙の重さなしでReaderを使用すると、例が読みづらくなります。一方、暗黙知を利用してノイズとなる部分を隠蔽し を隠蔽してエラーを出すと、コンパイラは解読しにくいメッセージを出すことがあります。
  3. を使った式 pure , local といった具合に、独自のConfigクラスを作成したり、タプルを使ったりします。リーダーを使うと、問題領域とは関係ないコードも追加しなければならないので そのため、コードにノイズが入ります。一方、コンストラクタを使うアプリケーションでは コンストラクタを使うアプリケーションはファクトリパターンを使うことが多く、これも問題領域外のものなので、この弱点はそれほど深刻ではありません。 この弱点はそれほど深刻ではありません。

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

したい。あなたは技術的に できます。 を避けることができますが、もし私がを変換しなかったらどうなるか見てみましょう。 FindUsers クラスをオブジェクトに変換しなかったらどうなるか見てみましょう。理解するためのそれぞれの行は、次のようになります。

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

というのは、あまり読みやすいものではありませんね。要は、Readerは関数で動くので、もし関数がなければインラインで構築する必要があるのですが、これがあまり美しくないことが多いのです。