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

87

На вопрос о внедрении зависимостей в Scala довольно много ответов указывают на использование Reader Monad, будь то из Scalaz или просто скатываете свою собственную. Есть ряд очень четких статей, описывающих основы подхода (например , выступление Рунара , блог Джейсона ), но мне не удалось найти более полный пример, и я не вижу преимуществ этого подхода перед, например, более традиционный "ручной" 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 монаду, если мы хотим зафиксировать эффекты и т. д.

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

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

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

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

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

Адамв
источник
1
Монада Читателя - не серебряная пуля. Я думаю, что если вам требуется много уровней зависимостей, ваш дизайн будет довольно хорош.
ZhekaKozlov
Однако его часто описывают как альтернативу внедрению зависимостей; может, тогда его следует описать как дополнение? Иногда у меня возникает ощущение, что DI отвергают «настоящие функциональные программисты», поэтому мне было интересно, «а что вместо этого? каждое «бизнес-приложение» среднего и большого размера выглядит так (точно не в случае с библиотеками)
adamw
2
Я всегда считал монаду Читателя чем-то локальным. Например, если у вас есть модуль, который общается только с БД, вы можете реализовать этот модуль в стиле монады Reader. Однако, если вашему приложению требуется много различных источников данных, которые следует объединить вместе, я не думаю, что монада Reader подходит для этого.
ZhekaKozlov
Ах, это могло бы быть хорошим руководством, как объединить две концепции. И тогда действительно могло бы показаться, что DI и RM дополняют друг друга. Я предполагаю, что на самом деле довольно часто есть функции, которые работают только с одной зависимостью, и использование RM здесь поможет прояснить границы зависимости / данных.
adamw

Ответы:

36

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

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

Я не уверен, следует ли это моделировать с помощью 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()
    }
}

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

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

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

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

Монада 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("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 для такого «бизнес-приложения» было бы лучше, чем просто использование параметров конструктора?

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

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

Еще хочу сказать, что мне не нравится в Reader.

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

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

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

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

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

Przemek Pokrywka
источник
Спасибо за подробный ответ :) Один момент, который мне непонятен, почему Datastoreи EmailServerостались чертами, а другие стали objects? Есть ли в этих сервисах / зависимостях / (как вы их называете) принципиальная разница, из-за которой к ним следует относиться по-разному?
adamw
Ну ... Я тоже не могу преобразовать, например, EmailSenderв объект, верно? Я бы тогда не смог выразить зависимость, не имея типа ...
adamw
Ах, тогда зависимость приняла бы форму функции с подходящим типом - поэтому вместо использования имен типов все должно было бы перейти в сигнатуру функции (имя было случайным). Может быть, но я не уверен;)
adamw
Верный. Вместо того EmailSender, чтобы зависеть от вас (String, String) => Unit. Убедительно это или нет - другой вопрос :) Безусловно, это по крайней мере более общий характер, так как все уже зависят от Function2.
Przemek Pokrywka
Что ж, вы бы наверняка захотели назвать (String, String) => Unit так, чтобы оно передавало какой-то смысл, но не с псевдонимом типа, а с чем-то, что проверяется во время компиляции;)
adamw
3

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

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

Дэниел Лэнгдон
источник
Как промежуточный уровень узнает только об их промежуточных зависимостях, а не обо всех? Не могли бы вы привести пример кода, показывающий, как этот пример может быть реализован с использованием монады чтения?
adamw
Я, вероятно, мог бы объяснить это не лучше, чем блог Json (который вы разместили). Процитируем форму там: «В отличие от примера имплицита, у нас нет UserRepository нигде в подписях userEmail и userInfo». Внимательно проверьте этот пример.
Дэниел Лэнгдон,
1
Ну да, но это предполагает, что монада считывателя, которую вы используете, параметризована и Configсодержит ссылку на UserRepository. Это правда, это не видно напрямую в подписи, но я бы сказал, что еще хуже, вы на первый взгляд понятия не имеете, какие зависимости использует ваш код. Разве зависимость от a Configсо всеми зависимостями не означает, что каждый вид метода зависит от всех из них?
adamw
Это действительно зависит от них, но не обязательно об этом знать. То же, что и в вашем примере с классами. Я считаю их довольно эквивалентными :-)
Дэниел Лэнгдон
В примере с классами вы зависите только от того, что вам действительно нужно, а не от глобального объекта со всеми зависимостями внутри. И у вас возникает проблема, как решить, что входит в «зависимости» глобального config, а что «просто функция». Вероятно, вы тоже столкнетесь со многими самозависимостями. В любом случае, это скорее обсуждение предпочтений, чем вопросы и ответы :)
adamw