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

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유형이 완전히 임의적 일 수 있습니다 튜플, 함수 또는 간단한 유형입니다.

다음은 초기 조정 후 함수로 변환 된 샘플 코드입니다.

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()
에서는 첫 번째 매개 변수에 전달 된 함수를 호출합니다 .

코드는 질문에서 세 가지 바람직한 속성을 나타냅니다.

  1. 각 기능에 필요한 종속성의 종류가 분명합니다.
  2. 한 기능의 종속성을 다른 기능에서 숨 깁니다.
  3. retainUsers 메소드는 Datastore 종속성에 대해 알 필요가 없습니다.

모델링 2 단계. Reader를 사용하여 기능을 구성하고 실행

Reader 모나드는 모두 동일한 유형에 의존하는 함수 만 작성할 수 있습니다. 이것은 종종 사실이 아닙니다. 이 예에서는
및 에 FindUsers.inactive의존 합니다 . 이 문제를 해결하기 위해 모든 종속성을 포함하는 새로운 유형 (종종 Config라고 함)을 도입 한 다음 함수를 변경하여 모두 의존하고 관련 데이터 만 가져 오도록 할 수 있습니다. 종속성 관리 관점에서 보면 이러한 함수를 처음부터 알면 안되는 유형에도 종속되도록 만드는 방식은 분명히 잘못된 것입니다.DatastoreUserReminder.emailInactiveEmailServer

다행히도 함수의 Config일부만 매개 변수로 받아들이더라도 함수가 작동하도록하는 방법이 있습니다 . localReader에 정의 된 라는 메서드 입니다. 에서 관련 부분을 추출하는 방법을 제공해야합니다 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)()

}

생성자 매개 변수 사용의 장점

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

이 답변을 준비함으로써 평범한 생성자를 능가하는 측면에서 스스로 판단하기가 더 쉬웠기를 바랍니다. 그러나 이것을 열거한다면 여기에 내 목록이 있습니다. 면책 조항 : 저는 OOP 배경 지식이 있으며 Reader와 Kleisli를 사용하지 않기 때문에 완전히 감사하지 않을 수 있습니다.

  1. 균일 성-이해력이 얼마나 짧거나 긴지는 상관없고, 리더 일 뿐이며 다른 인스턴스로 쉽게 구성 할 수 있습니다. 아마도 하나 이상의 Config 유형을 도입 local하고 그 위에 몇 가지 호출을 뿌릴 수 있습니다. 생성자를 사용할 때 OOP에서 나쁜 관행으로 간주되는 생성자에서 작업하는 것과 같은 어리석은 작업을 수행하지 않는 한, 생성자를 사용할 때 아무도 당신이 좋아하는 것을 작성하는 것을 막을 수 없기 때문입니다.
  2. 그것은 그와 관련된 모든 혜택을 얻을 수 있도록 리더, 모나드이다 – sequence, traverse방법은 무료로 구현했습니다.
  3. 어떤 경우에는 Reader를 한 번만 빌드하고 광범위한 구성에 사용하는 것이 더 나을 수 있습니다. 생성자를 사용하면 아무도 그렇게하지 못하므로 들어오는 모든 구성에 대해 전체 개체 그래프를 새로 작성하면됩니다. 나는 그것에 문제가 없지만 (저는 신청에 대한 모든 요청에 ​​대해 그렇게하는 것을 선호합니다), 내가 추측 할 수있는 이유 때문에 많은 사람들에게 명백한 아이디어가 아닙니다.
  4. Reader는 주로 FP 스타일로 작성된 응용 프로그램에서 더 잘 작동하는 기능을 더 많이 사용하도록 유도합니다.
  5. 독자는 우려를 분리합니다. 종속성을 제공하지 않고 로직을 생성하고, 모든 것과 상호 작용하고, 정의 할 수 있습니다. 나중에 별도로 공급하십시오. (이 점에 대해 Ken Scrambler에게 감사드립니다). 이것은 종종 Reader의 장점으로 들리지만 일반 생성자에서도 가능합니다.

또한 Reader에서 내가 싫어하는 점을 말하고 싶습니다.

  1. 마케팅. 때때로 나는 Reader가 세션 쿠키인지 데이터베이스인지 구별하지 않고 모든 종류의 종속성에 대해 마케팅된다는 인상을받습니다. 나에게는이 예제의 이메일 서버 나 저장소와 같은 실질적으로 일정한 객체에 Reader를 사용하는 것이 거의 의미가 없습니다. 이러한 종속성의 경우 일반 생성자 및 / 또는 부분적으로 적용된 함수가 훨씬 더 좋습니다. 본질적으로 Reader는 유연성을 제공하므로 모든 호출에서 종속성을 지정할 수 있지만 실제로 필요하지 않으면 세금 만 지불하면됩니다.
  2. 암시 적 무거움-암시 적없이 Reader를 사용하면 예제를 읽기가 어렵습니다. 반면에 암시 적을 사용하여 시끄러운 부분을 숨기고 오류를 만들면 컴파일러에서 메시지를 해독하기 어려운 경우가 있습니다.
  3. 와 의식 pure, local그리고 자신의 구성 클래스 / 그것에 대해 튜플을 사용하여 작성. Reader는 문제 영역에 관한 것이 아닌 일부 코드를 추가해야하므로 코드에 약간의 노이즈가 발생합니다. 반면에 생성자를 사용하는 응용 프로그램은 종종 문제 영역 외부의 팩토리 패턴을 사용하므로이 약점은 그다지 심각하지 않습니다.

클래스를 함수가있는 객체로 변환하지 않으려면 어떻게합니까?

당신이 원합니다. 기술적으로 피할 있지만 FindUsers클래스를 객체 로 변환하지 않으면 어떻게 될지 살펴보십시오 . for comprehension의 각 줄은 다음과 같습니다.

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

그게 읽을 수없는 건가요? 요점은 Reader가 함수에서 작동한다는 것입니다. 따라서 아직 기능이 없다면 인라인으로 구성해야합니다.


답변

주요 차이점은 귀하의 예제에서 객체가 인스턴스화 될 때 모든 종속성을 주입한다는 것입니다. Reader 모나드는 기본적으로 종속성이 주어지면 호출 할 점점 더 복잡한 함수를 빌드합니다. 그러면 가장 높은 계층으로 반환됩니다. 이 경우 함수가 마지막으로 호출 될 때 주입이 발생합니다.

즉각적인 이점 중 하나는 유연성입니다. 특히 모나드를 한 번 생성 한 다음 삽입 된 다른 종속성과 함께 사용하려는 경우 더욱 그렇습니다. 한 가지 단점은 잠재적으로 덜 명확하다는 것입니다. 두 경우 모두 중간 계층은 즉각적인 종속성에 대해서만 알면되므로 둘 다 DI에 대해 광고 된대로 작동합니다.


답변