Reader Monad для ін’єкції залежностей: кілька залежностей, вкладені виклики


87

Коли запитують про ін’єкцію залежності в Scala, досить багато відповідей вказують на використання Reader Monad, або тієї, що від Scalaz, або просто прокатки власної. Є ряд дуже чітких статей , що описують основи підходу (наприклад , ток Runar в , блог Джейсона ), але мені не вдалося знайти більш повний приклад, і я не бачу переваги такого підходу більш наприклад, більш традиційний "ручний" 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? Було б добре зберегти наведені вище характеристики, щоб було зрозуміло, які залежності потрібні кожному функціоналу, і приховати залежності однієї функціональності від іншої. Зверніть увагу, що використання classes - це більше деталей реалізації; можливо, "правильне" рішення, що використовує монаду Reader, використало б щось інше.

Я знайшов дещо пов'язане запитання, яке наводить на думку:

  • використання єдиного об'єкта середовища з усіма залежностями
  • використання локального середовища
  • візерунок "парфе"
  • індексовані типи карт

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

В яких аспектах використання Reader Monad для такого "ділового додатка" було б кращим, ніж просто використання параметрів конструктора?


1
Монада Reader - це не срібна куля. Я думаю, якщо вам потрібно багато рівнів залежностей, ваш дизайн досить непоганий.
Жека Козлов

Однак його часто описують як альтернативу введенню залежності; може тоді це слід описати як доповнення? У мене іноді виникає відчуття, що DI відкидають "справжні функціональні програмісти", отже, мені цікаво "що замість цього" :) У будь-якому випадку, я думаю, що наявність декількох рівнів залежностей, а точніше кілька зовнішніх служб, з якими вам потрібно поговорити, це те, як виглядає кожен "великий додаток для бізнесу" (не точно для бібліотек)
adamw,

2
Про мене завжди розглядали монаду Reader як щось місцеве. Наприклад, якщо у вас є якийсь модуль, який розмовляє лише з БД, ви можете реалізувати цей модуль у стилі монади Reader. Однак, якщо ваша програма вимагає багато різних джерел даних, які слід поєднувати разом, я не думаю, що монада Reader для цього хороша.
Жека Козлов

Ах, це може бути хорошим орієнтиром, як поєднати ці два поняття. І тоді справді здавалося б, що DI та RM доповнюють одне одного. Я думаю, насправді досить часто є функції, які працюють лише на одній залежності, і використання RM тут допомогло б пояснити межі залежності / даних.
adamw

Відповіді:


36

Як змоделювати цей приклад

Як це можна було змоделювати за допомогою монади 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()
    }
}

Тут слід зауважити, що окремі функції залежать не від цілих об’єктів, а лише від безпосередньо використовуваних частин. Де в UserReminder.emailInactive()екземплярі версії ООП userFinder.inactive()тут буде викликатися, він просто викликає inactive() - функція, передана йому в першому параметрі.

Зверніть увагу, що код має три бажані властивості з питання:

  1. зрозуміло, які види залежностей потрібні кожному функціоналу
  2. приховує залежності однієї функціональності від іншої
  3. retainUsers методу не потрібно знати про залежність Datastore

Крок моделювання 2. Використання Reader для складання функцій та їх запуску

Reader monad дозволяє створювати лише функції, які залежать від одного типу. Це часто не так. У нашому прикладі 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 / с допомогою кортежів для цього. Читач змушує вас додати якийсь код, який не стосується проблемного домену, отже, вносячи певний шум у код. З іншого боку, програма, яка використовує конструктори, часто використовує заводський шаблон, який також виходить за межі проблемного домену, тому ця слабкість не така вже й серйозна.

Що робити, якщо я не хочу перетворювати свої класи на об’єкти з функціями?

Ти хочеш. Ви технічно можете цього уникнути, але просто подивіться, що сталося б, якби я не перетворив FindUsersклас на об'єкт. Відповідний рядок для розуміння буде виглядати так:

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

що не так читабельно, чи не так? Справа в тому, що Reader оперує функціями, тому якщо у вас їх ще немає, вам потрібно побудувати їх вбудовано, що часто не так красиво.


Дякую за детальну відповідь :) Один момент, який мені незрозумілий, - чому 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
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.