На вопрос о внедрении зависимостей в 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 для такого «бизнес-приложения» было бы лучше, чем просто использование параметров конструктора?
Ответы:
Как смоделировать этот пример
Я не уверен, следует ли это моделировать с помощью 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()
- функцию, переданную ему в первом параметре.Обратите внимание, что код демонстрирует три желаемых свойства из вопроса:
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 и Kleisli в полной мере, поскольку я их не использую.
local
вызовов поверх него. Это вопрос IMO, скорее, вопрос вкуса, потому что, когда вы используете конструкторы, никто не мешает вам создавать все, что вам нравится, если только кто-то не делает что-то глупое, например, выполнение работы в конструкторе, что считается плохой практикой в ООП.sequence
,traverse
методы реализованы бесплатно.Еще хочу сказать, что мне не нравится в Reader.
pure
,local
и создание собственных классов Config / с помощью кортежей для этого. Reader вынуждает вас добавить код, не относящийся к проблемной области, что вносит некоторый шум в код. С другой стороны, приложение, использующее конструкторы, часто использует фабричный шаблон, который также находится за пределами проблемной области, поэтому эта слабость не так серьезна.Что, если я не хочу преобразовывать свои классы в объекты с функциями?
Вы хотите. Технически этого можно избежать, но посмотрите, что бы произошло, если бы я не преобразовал
FindUsers
класс в объект. Соответствующая строка для понимания будет выглядеть так:getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
который не читается, не так ли? Дело в том, что Reader работает с функциями, поэтому, если у вас их еще нет, вам нужно построить их встроенными, что часто не так уж и красиво.
источник
Datastore
иEmailServer
остались чертами, а другие сталиobject
s? Есть ли в этих сервисах / зависимостях / (как вы их называете) принципиальная разница, из-за которой к ним следует относиться по-разному?EmailSender
в объект, верно? Я бы тогда не смог выразить зависимость, не имея типа ...EmailSender
, чтобы зависеть от вас(String, String) => Unit
. Убедительно это или нет - другой вопрос :) Безусловно, это по крайней мере более общий характер, так как все уже зависят отFunction2
.(String, String) => Unit
так, чтобы оно передавало какой-то смысл, но не с псевдонимом типа, а с чем-то, что проверяется во время компиляции;)Я думаю, что основное отличие состоит в том, что в вашем примере вы вводите все зависимости при создании экземпляров объектов. Монада Reader в основном строит все более и более сложные функции для вызова с учетом зависимостей, которые затем возвращаются на высшие уровни. В этом случае инъекция происходит при окончательном вызове функции.
Одним из непосредственных преимуществ является гибкость, особенно если вы можете создать свою монаду один раз, а затем захотите использовать ее с различными внедренными зависимостями. Одним из недостатков является, как вы говорите, потенциально меньшая ясность. В обоих случаях промежуточный уровень должен знать только об их непосредственных зависимостях, поэтому они оба работают так, как заявлено для DI.
источник
Config
содержит ссылку наUserRepository
. Это правда, это не видно напрямую в подписи, но я бы сказал, что еще хуже, вы на первый взгляд понятия не имеете, какие зависимости использует ваш код. Разве зависимость от aConfig
со всеми зависимостями не означает, что каждый вид метода зависит от всех из них?config
, а что «просто функция». Вероятно, вы тоже столкнетесь со многими самозависимостями. В любом случае, это скорее обсуждение предпочтений, чем вопросы и ответы :)