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

Когда его спрашивают об Injection Dependency in Scala, довольно много ответов указывают на использование Reader Monad, либо из Scalaz, либо просто для вашего собственного. Существует ряд очень четких статей, описывающих основы подхода (например, Обсуждение рунаров, Jason blog), но мне не удалось найти более полный пример, и я не вижу преимуществ такого подхода, например, более традиционный "ручной" 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? Было бы неплохо сохранить вышеприведенные характеристики, чтобы было ясно, какие зависимости нужны каждой функциональности, и скрыть зависимости одной функциональности от другой. Обратите внимание, что использование class es больше детализирует реализацию; возможно, "правильное" решение с использованием монады Reader будет использовать что-то еще.

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

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

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

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

Ответ 1

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

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

Я не уверен, что это должно быть смоделировано с помощью Reader, но это может быть:

  • кодирование классов как функций, которые делают код более приятным с Reader
  • составление функций с помощью Reader в для понимания и использования

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

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() - функция, переданная ему в первом параметре.

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

  • ясно, какие зависимости нужны каждой функциональности
  • скрывает зависимости одной функциональности от другой
  • retainUsers метод не должен знать о зависимости Datastore

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

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

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

}

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

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

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

  • Однородность - не малый, насколько короткий/длительный для понимания, это просто читатель, и вы можете легко составить его с помощью другого экземпляр, возможно, только введение еще одного типа Config и разбрызгивание некоторых local вызовов поверх него. Этот момент - ИМО скорее вопрос вкуса, потому что, когда вы используете конструкторы, никто не мешает вам сочинять все, что вам нравится, если кто-то делает что-то глупое, как работа в конструкторе, которая считается плохой практикой в ​​ООП.
  • Reader - это монада, поэтому он получает все преимущества, связанные с этим - sequence, traverse методы реализованы бесплатно.
  • В некоторых случаях предпочтительнее создавать Reader только один раз и использовать его для широкого спектра конфигураций. С конструкторами никто не мешает вам это сделать, вам просто нужно построить весь графический объект заново для каждой конфигурации входящие. Хотя у меня нет проблем с этим (я даже предпочитаю делать это при каждом запросе приложения), это не явная идея для многих людей по причинам, о которых я могу только догадываться.
  • Читатель подталкивает вас к использованию функций больше, что будет лучше работать с приложением, написанным преимущественно в стиле FP.
  • Читатель разделяет проблемы; вы можете создавать, взаимодействовать со всем, определять логику без предоставления зависимостей. Собственно поставляйте позже, отдельно. (Спасибо Кен Скрамблеру за этот момент). Это часто слышно преимущество Reader, но это также возможно с помощью простых конструкторов.

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

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

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

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

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

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

Ответ 2

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

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