Читательская Монада для инъекции зависимостей: несколько зависимостей, вложенные вызовы


когда его спрашивают о инъекции зависимостей в Scala, довольно много ответов указывают на использование монады Reader, либо той, что из Scalaz, либо просто сворачивает свой собственный. Есть ряд очень четких статей, описывающих основы подхода (например,разговор Рунара,Джейсона блог), но мне не удалось найти более полный пример, и я не вижу преимуществ этого подхода, например, более традиционного "ручного" DI (см. руководство I написал). Скорее всего, я упускаю какой-то важный момент, отсюда и вопрос.

просто в качестве примера, давайте представим, что у нас есть эти классы:

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 монада, если мы хотим, чтобы захватить эффекты так далее.

как это может быть смоделировано с помощью монады читателя? Было бы неплохо сохранить приведенные выше характеристики, чтобы было понятно, какие зависимости нужны каждой функциональности, и скрыть зависимости одной функциональности от другой. Обратите внимание, что с помощью classes-это скорее деталь реализации; возможно, "правильное" решение с использованием монады чтения будет использовать что-то еще.

Я нашел несколько связанных вопрос, который предполагает либо:

  • использование одного объекта среды со всеми зависимостями
  • использование местных условиях
  • "парфе" шаблон
  • тип-индексированные карты

Однако, помимо того, что это (но это субъективно) слишком сложно, как для такой простой вещи, во всех этих решениях, например,retainUsers метод (который называет emailInactive, которая называет inactive чтобы найти неактивных пользователей) нужно было бы знать о Datastore зависимость, чтобы иметь возможность правильно вызывать вложенные функции - или я ошибаюсь?

в каких аспектах использование монады чтения для такого "бизнес-приложения" было бы лучше, чем просто использование параметров конструктора?

2 78

2 ответа:

как смоделировать этот пример

как это можно смоделировать с помощью читателя монады?

я не уверен, что это должны быть смоделированы с читателем, но это может быть:

  1. кодирование классов как функций, что делает код играть приятнее с читателем
  2. составление функций с читателем в A для понимания и использования его

как раз перед началом I нужно рассказать вам о небольших корректировках кода образца, которые я чувствовал себя полезным для этого ответа. Первое изменение касается FindUsers.inactive метод. Я позволил ему вернуться List[String] таким образом, список адресов может быть использован в UserReminder.emailInactive метод. Я также добавил простые реализации к методам. Наконец, пример будет использовать следующая ручная версия Reader monad:

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

здесь следует отметить, что отдельные функции не зависят от целых объектов, а только от непосредственно используемых частей. Где в версии ООП UserReminder.emailInactive() экземпляр назвали бы userFinder.inactive() вот он просто звонит inactive() - функция, переданная ему в первом параметре.

обратите внимание, что код демонстрирует три желаемых свойства из вопроса:

  1. понятно, какие зависимости у каждого функционала потребности
  2. скрывает зависимости одной функции от другой
  3. retainUsers метод не должен знать о зависимости хранилище

моделирование Шаг 2. Использование читателя для создания функций и их запуска

Reader monad позволяет создавать только функции, которые зависят от одного и того же типа. Это часто не так. В нашем примере FindUsers.inactive зависит от Datastore и UserReminder.emailInactive on EmailServer. Чтобы решить эту проблему можно введите новый тип (часто называемый конфигурацией), который содержит все зависимости, а затем измените функции поэтому все они зависят от него и только берут из него соответствующие данные. Это, очевидно, неправильно с точки зрения управления зависимостями, потому что таким образом Вы делаете эти функции также зависимыми о видах, которые они не должны знать в первую очередь.

к счастью, оказывается, что существует способ заставить функцию работать с 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)()

}

преимущества перед использованием параметров конструктора

в каких аспектах использование монады чтения для такого "бизнес-приложения" было бы лучше, чем просто использование конструктора параметры?

я надеюсь, что, подготовив этот ответ, я облегчил себе судить о том, в каких аспектах он будет бить простых конструкторов. Но если бы я перечислил их, вот мой список. Отказ от ответственности: у меня есть фон ООП, и я не могу оценить читателя и Клейсли полностью, как я их не использую.

  1. единообразие-независимо от того, насколько короткий / длинный для понимания, это просто читатель, и вы можете легко составить его с другим например, возможно только вводя еще один тип конфигурации и разбрызгивая некоторые local звонки сверху. Этот момент-ИМО скорее дело вкуса, потому что при использовании конструкторов никто не мешает вам сочинять все, что вам нравится, если кто-то не делает что-то глупое, как делать работу в конструкторе, который считается плохой практикой в ООП.
  2. читатель-это монада, поэтому он получает все выгоды, связанные с этим - sequence,traverse методы реализованы бесплатно.
  3. в некоторых случаях вы можете найти предпочтительным построить читателя только один раз и использовать его для широкого спектра конфигураций. С конструкторами никто не мешает вам это сделать, вам просто нужно построить весь граф объектов заново для каждой конфигурации входящий. Хотя у меня нет проблем с этим (я даже предпочитаю делать это по каждому запросу к приложению), это не так очевидная идея для многих людей по причинам, о которых я могу только догадываться.
  4. читатель толкает вас к использованию функций больше, которые будут играть лучше с приложение написано преимущественно в стиле FP.
  5. читатель отделяет проблемы; вы можете создавать, взаимодействовать со всем, определять логику без предоставления зависимостей. На самом деле поставка позже, отдельно. (Спасибо Ken Scrambler за этот момент). Это часто слышно преимущество читателя, но это также возможно с простыми конструкторами.

я также хотел бы сказать, что мне не нравится в Reader.

  1. маркетинг. Иногда я получаю впечатление, этот читатель продается для всех видов зависимостей, без различия, если это файл cookie сеанса или база данных. Для меня нет смысла использовать Reader для практически постоянных объектов, таких как электронная почта сервер или репозиторий из этого примера. Для таких зависимостей я нахожу простые конструкторы и / или частично применяемые функции намного лучше. По сути читатель дает вам гибкость, так что вы можете указать свои зависимости при каждом вызове, но если вы на самом деле это не нужно, вы только платите его налог.
  2. неявное тяжесть - использование ридера без неявные преобразования сделало бы пример трудно читать. С другой стороны, когда вы прячетесь шумной части, используя неявные преобразования и сделать некоторые ошибки, компилятор иногда даст вам трудно расшифровать сообщения.
  3. церемония с pure,local и создание собственных классов конфигурации / использование кортежей для этого. Читатель заставляет вас добавить некоторый код это не касается проблемной области, поэтому вводит некоторый шум в код. С другой стороны, приложение это использует конструкторы часто использует заводской шаблон, который также находится за пределами проблемной области, поэтому эта слабость не так серьезный.

что делать, если я не хочу конвертировать мои классы в объекты с функциями?

вы хотите. Ты технически можете избегайте этого, но просто посмотрите, что произойдет, если я не конвертирую FindUsers класс объекта. Соответствующая строка для понимания будет выглядеть например:

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

который не читается, не так ли? Дело в том, что Reader работает с функциями, поэтому, если у вас их еще нет, вам нужно построить их встраиваемыми, что часто не так красиво.

Я думаю, что основное различие заключается в том, что в вашем примере вы вводите все зависимости, когда объекты создаются. Читательская монада в основном строит все более сложные функции для вызова заданных зависимостей, которые затем возвращаются на самые высокие уровни. В этом случае инъекция происходит при окончательном вызове функции.

одним из непосредственных преимуществ является гибкость, особенно если вы можете построить свою монаду один раз, а затем хотите использовать ее с разными вводимые зависимости. Одним из недостатков является, как вы говорите, потенциально меньшая ясность. В обоих случаях промежуточный уровень должен знать только о своих непосредственных зависимостях, поэтому они оба работают так, как объявлено для DI.