code

의존성 주입을위한 리더 모나드 : 다중 의존성, 중첩 된 호출

codestyles 2020. 9. 17. 07:56
반응형

의존성 주입을위한 리더 모나드 : 다중 의존성, 중첩 된 호출


Scala의 Dependency Injection에 대해 물었을 때 Scalaz의 Reader Monad를 사용하거나 직접 롤링하는 것에 대한 많은 답변이 있습니다. 접근 방식의 기본 사항을 설명하는 매우 명확한 기사 (예 : Runar 's talk , Jason 's blog )가 많이 있지만, 더 완전한 예를 찾지 못했고, 예를 들어 more보다 그 접근 방식의 장점을 보지 못했습니다. 전통적인 "수동"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필요한지 모릅니다 . 기능은 별도의 컴파일 단위에서도 가능합니다.
  • 우리는 순수한 스칼라만을 사용하고 있습니다. 구현은 불변 클래스, 고차 함수를 활용할 수 있으며 IO, 효과 등을 캡처하려는 경우 "비즈니스 로직"메서드는 모나드에 래핑 된 값을 반환 할 수 있습니다 .

Reader 모나드로 어떻게 모델링 할 수 있습니까? 위의 특성을 유지하여 각 기능에 필요한 종속성의 종류를 명확히하고 한 기능의 종속성을 다른 기능으로부터 숨기는 것이 좋습니다. classes 를 사용 하는 것은 구현 세부 사항에 더 가깝습니다. Reader 모나드를 사용하는 "올바른"솔루션은 다른 것을 사용할 것입니다.

나는 다음 중 하나를 제안 하는 다소 관련된 질문찾았습니다 .

  • 모든 종속성이있는 단일 환경 개체 사용
  • 지역 환경 사용
  • "파르페"패턴
  • 유형 색인지도

그러나 (그러나 그것은 주관적입니다) 그러한 간단한 것만 큼 너무 복잡하다는 것을 제외하고, 이러한 모든 솔루션에서 예를 들어 retainUsers( 비활성 사용자를 찾기 위해 emailInactive호출하는를 호출 하는) 메서드 inactiveDatastore종속성 에 대해 알아야 합니다. 중첩 된 함수를 제대로 호출 할 수 있습니까? 아니면 내가 틀렸습니까?

그런 "비즈니스 응용 프로그램"에 Reader Monad를 사용하는 것이 생성자 매개 변수를 사용하는 것보다 더 나은 측면은 무엇입니까?


이 예제를 모델링하는 방법

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)

각 것을 명심하십시오 Dep, Arg, Res유형이 완전히 임의적 일 수 있습니다 튜플, 함수 또는 간단한 유형입니다.

Here's the sample code after the initial adjustments, transformed into functions:

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

One thing to notice here is that particular functions don't depend on the whole objects, but only on the directly used parts. Where in OOP version UserReminder.emailInactive() instance would call userFinder.inactive() here it just calls inactive() - a function passed to it in the first parameter.

Please note, that the code exhibits the three desirable properties from the question:

  1. it is clear what kind of dependencies each functionality needs
  2. hides dependencies of one functionality from another
  3. retainUsers method should not need to know about the Datastore dependency

Modelling step 2. Using the Reader to compose functions and run them

Reader monad lets you only compose functions that all depend on the same type. This is often not a case. In our example FindUsers.inactive depends on Datastore and UserReminder.emailInactive on EmailServer. To solve that problem one could introduce a new type (often referred to as Config) that contains all of the dependencies, then change the functions so they all depend on it and only take from it the relevant data. That obviously is wrong from dependency management perspective because that way you make these functions also dependent on types that they shouldn't know about in the first place.

Fortunately it turns out, that there exist a way to make the function work with Config even if it accepts only some part of it as a parameter. It's a method called local, defined in Reader. It needs to be provided with a way to extract the relevant part from the Config.

This knowledge applied to the example at hand would look like that:

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

}

Advantages over using constructor parameters

In what aspects would using the Reader Monad for such a "business application" be better than just using constructor parameters?

I hope that by preparing this answer I made it easier to judge for yourself in what aspects would it beat plain constructors. Yet if I were to enumerate these, here's my list. Disclaimer: I have OOP background and I may not appreciate Reader and Kleisli fully as I don't use them.

  1. Uniformity - no mater how short/long the for comprehension is, it's just a Reader and you can easily compose it with another instance, perhaps only introducing one more Config type and sprinkling some local calls on top of it. This point is IMO rather a matter of taste, because when you use constructors nobody prevents you to compose whatever things you like, unless someone does something stupid, like doing work in constructor which is considered a bad practice in OOP.
  2. Reader is a monad, so it gets all benefits related to that - sequence, traverse methods implemented for free.
  3. In some cases you may find it preferable to build the Reader only once and use it for wide range of Configs. With constructors nobody prevents you to do that, you just need to build the whole object graph anew for every Config incoming. While I have no problem with that (I even prefer doing that on every request to application), it isn't an obvious idea to many people for reasons I may only speculate about.
  4. Reader pushes you towards using functions more, which will play better with application written in predominantly FP style.
  5. Reader separates concerns; you can create, interact with everything, define logic without providing dependencies. Actually supply later, separately. (Thanks Ken Scrambler for this point). This is often heard advantage of Reader, yet that's also possible with plain constructors.

I would also like to tell what I don't like in Reader.

  1. Marketing. Sometimes I get impression, that Reader is marketed for all kind of dependencies, without distinction if that's a session cookie or a database. To me there's little sense in using Reader for practically constant objects, like email server or repository from this example. For such dependencies I find plain constructors and/or partially applied functions way better. Essentially Reader gives you flexibility so you can specify your dependencies at every call, but if you don't really need that, you only pay its tax.
  2. Implicit heaviness - using Reader without implicits would make the example hard to read. On the other hand, when you hide the noisy parts using implicits and make some error, compiler will sometimes give you hard to decipher messages.
  3. Ceremony with pure, local and creating own Config classes / using tuples for that. Reader forces you to add some code that isn't about problem domain, therefore introducing some noise in the code. On the other hand, an application that uses constructors often uses factory pattern, which is also from outside of problem domain, so this weakness isn't that serious.

What if I don't want to convert my classes to objects with functions?

You want. You technically can avoid that, but just look what would happen if I didn't convert FindUsers class to object. The respective line of for comprehension would look like:

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

which is not that readable, is that? The point is that Reader operates on functions, so if you don't have them already, you need to construct them inline, which often isn't that pretty.


I think the main difference is that in your example you are injecting all dependencies when objects are instantiated. The Reader monad basically builds a more and more complex functions to call given the dependencies, wich are then returned to the highest layers. In this case, the injection happens when the function is finally called.

One immediate advantage is flexibility, especially if you can construct your monad once and then want to use it with different injected dependencies. One disadvantage is, as you say, potentially less clarity. In both cases, the intermediate layer only need to know about their immediate dependencies, so they both work as advertised for DI.

참고URL : https://stackoverflow.com/questions/29174500/reader-monad-for-dependency-injection-multiple-dependencies-nested-calls

반응형