Reader Monad for Dependency Injection: wiele zależności, zagnieżdżone wywołania


87

Na pytanie o Dependency Injection w Scali, sporo odpowiedzi wskazuje na używanie Reader Monad, albo tej ze Scalaz, albo po prostu rozwijanie własnej. Jest wiele bardzo jasnych artykułów opisujących podstawy tego podejścia (np. Wykład Runara , blog Jasona ), ale nie udało mi się znaleźć bardziej kompletnego przykładu i nie widzę zalet takiego podejścia w porównaniu np. tradycyjne „ręczne” DI (patrz poradnik, który napisałem ). Prawdopodobnie brakuje mi jakiegoś ważnego punktu, stąd pytanie.

Na przykład wyobraźmy sobie, że mamy te klasy:

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 = {}
}

Tutaj modeluję rzeczy za pomocą klas i parametrów konstruktora, co bardzo dobrze współgra z „tradycyjnym” podejściem do DI, jednak ten projekt ma kilka dobrych stron:

  • każda funkcjonalność ma jasno wyliczone zależności. Zakładamy, że zależności są naprawdę potrzebne, aby funkcjonalność działała poprawnie
  • zależności są ukryte między funkcjami, np. UserRemindernie ma pojęcia, że FindUserspotrzebuje magazynu danych. Funkcjonalności mogą być nawet w oddzielnych jednostkach kompilacji
  • używamy tylko czystej Scali; implementacje mogą wykorzystywać niezmienne klasy, funkcje wyższego rzędu, metody "logiki biznesowej" mogą zwracać wartości opakowane w IOmonadę, jeśli chcemy uchwycić efekty itp.

Jak można to modelować za pomocą monady Reader? Dobrze byłoby zachować powyższe cechy, aby było jasne, jakiego rodzaju zależności potrzebuje każda funkcjonalność i ukryć zależności jednej funkcjonalności od drugiej. Zauważ, że używanie classes to bardziej szczegół implementacji; być może „poprawne” rozwiązanie przy użyciu monady Reader wymagałoby czegoś innego.

Znalazłem nieco powiązane pytanie, które sugeruje:

  • używając jednego obiektu środowiska ze wszystkimi zależnościami
  • przy użyciu lokalnych środowisk
  • wzór „parfait”
  • mapy indeksowane według typów

Jednak oprócz tego, że jest (ale to subiektywne) trochę zbyt skomplikowane jak na tak prostą rzecz, we wszystkich tych rozwiązaniach np. retainUsersMetoda (która wywołuje emailInactive, które wywołuje w inactivecelu znalezienia nieaktywnych użytkowników) musiałaby wiedzieć o Datastorezależności, aby móc poprawnie wywołać funkcje zagnieżdżone - czy się mylę?

W jakich aspektach użycie Reader Monad do takiej „aplikacji biznesowej” byłoby lepsze niż użycie parametrów konstruktora?


1
Monada Czytelnika nie jest srebrną kulą. Myślę, że jeśli potrzebujesz wielu poziomów zależności, twój projekt jest całkiem dobry.
ZhekaKozlov

Często jest jednak opisywany jako alternatywa dla Dependency Injection; może w takim razie należy to określić jako uzupełnienie? Czasami mam wrażenie, że DI jest odrzucane przez "prawdziwych programistów funkcjonalnych", dlatego zastanawiałem się "co zamiast tego" :) Tak czy inaczej, myślę, że posiadanie wielu poziomów zależności, a raczej wielu usług zewnętrznych, z którymi musisz porozmawiać, to jak jak wygląda każda średnio-duża „aplikacja biznesowa” (na pewno nie dotyczy to bibliotek)
adamw

2
Zawsze myślałem o monadzie Reader jako o czymś lokalnym. Na przykład, jeśli masz moduł, który komunikuje się tylko z bazą danych, możesz zaimplementować ten moduł w stylu monady czytnika. Jeśli jednak Twoja aplikacja wymaga wielu różnych źródeł danych, które powinny być ze sobą połączone, nie sądzę, aby monada Reader była do tego dobra.
ZhekaKozlov

Ach, to może być dobra wskazówka, jak połączyć te dwie koncepcje. I rzeczywiście wydawałoby się, że DI i RM się uzupełniają. Wydaje mi się, że w rzeczywistości dość powszechne jest posiadanie funkcji, które działają tylko na jednej zależności, a użycie RM tutaj pomogłoby w wyjaśnieniu granic zależności / danych.
adamw

Odpowiedzi:


36

Jak modelować ten przykład

Jak można to modelować za pomocą monady Reader?

Nie jestem pewien, czy powinno to być wzorowane na Czytniku, ale można to zrobić przez:

  1. kodowanie klas jako funkcji, dzięki czemu kod jest przyjemniejszy w programie Reader
  2. komponowanie funkcji w programie Reader w celu zrozumienia i używania

Tuż przed rozpoczęciem muszę opowiedzieć o niewielkich korektach kodu przykładowego, które uznałem za korzystne dla tej odpowiedzi. Pierwsza zmiana dotyczy FindUsers.inactivemetody. Pozwoliłem mu zwrócić, List[String]aby lista adresów mogła zostać użyta w UserReminder.emailInactivemetodzie. Dodałem również proste implementacje do metod. Na koniec przykład będzie używał następującej ręcznie zwijanej wersji monady 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)
}

Krok modelowania 1. Kodowanie klas jako funkcji

Może to jest opcjonalne, nie jestem pewien, ale później sprawia, że ​​zrozumienie wygląda lepiej. Zwróć uwagę, że wynikowa funkcja jest curried. Przyjmuje również poprzednie argumenty konstruktora jako ich pierwszy parametr (lista parametrów). W ten sposób

class Foo(dep: Dep) {
  def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)

staje się

object Foo {
  def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)

Należy pamiętać, że każdy Dep, Arg, Restypy mogą być całkowicie arbitralny: krotki, funkcję lub typ prosty.

Oto przykładowy kod po wstępnych dostosowaniach, przekształcony w funkcje:

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

Należy tu zauważyć, że poszczególne funkcje nie zależą od całych obiektów, ale tylko od bezpośrednio używanych części. Gdzie w OOP wersja UserReminder.emailInactive()przykład nazwałbym userFinder.inactive()tu właśnie nazywa inactive() - funkcja przeszedł do niego w pierwszym parametrze.

Zwróć uwagę, że kod wykazuje trzy pożądane właściwości z pytania:

  1. jest jasne, jakiego rodzaju zależności potrzebuje każda funkcjonalność
  2. ukrywa zależności jednej funkcjonalności od innej
  3. retainUsers nie musi wiedzieć o zależności Datastore

Modelowanie krok 2. Używanie programu Reader do tworzenia funkcji i uruchamiania ich

Reader Monad umożliwia tworzenie tylko funkcji, które wszystkie zależą od tego samego typu. To często nie jest przypadek. W naszym przykładzie FindUsers.inactivezależy od Datastorei UserReminder.emailInactiveod EmailServer. Aby rozwiązać ten problem, można wprowadzić nowy typ (często nazywany Config), który zawiera wszystkie zależności, a następnie zmienić funkcje tak, aby wszystkie od niego zależały i pobierały z niego tylko odpowiednie dane. To oczywiście jest złe z punktu widzenia zarządzania zależnościami, ponieważ w ten sposób uzależniasz te funkcje także od typów, o których nie powinny wiedzieć.

Na szczęście okazuje się, że istnieje sposób, aby funkcja działała, Confignawet jeśli przyjmuje tylko część jej jako parametr. Jest to metoda o nazwie local, zdefiniowana w programie Reader. Musi mieć możliwość wyodrębnienia odpowiedniej części z pliku Config.

Ta wiedza zastosowana do podanego przykładu wyglądałaby następująco:

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

}

Zalety w stosunku do używania parametrów konstruktora

W jakich aspektach użycie Reader Monad dla takiej „aplikacji biznesowej” byłoby lepsze niż użycie parametrów konstruktora?

Mam nadzieję, że przygotowując tę ​​odpowiedź, ułatwiłem sobie ocenę, w jakich aspektach pokonałby zwykłych konstruktorów. Jeśli jednak miałbym je wymienić, oto moja lista. Zastrzeżenie: Mam tło OOP i mogę nie doceniać w pełni Czytnika i Kleisli, ponieważ ich nie używam.

  1. Jednolitość - bez względu na to, jak krótkie / długie jest zrozumienie, to tylko czytnik i możesz łatwo skomponować go z inną instancją, być może wprowadzając tylko jeszcze jeden typ konfiguracji i posypując kilka localwywołań. To kwestia IMO raczej kwestia gustu, ponieważ gdy używasz konstruktorów, nikt nie stoi na przeszkodzie, aby skomponować to, co lubisz, chyba że ktoś zrobi coś głupiego, jak praca w konstruktorze, co jest uważane za złą praktykę w OOP.
  2. Czytnik jest monada, więc robi się wszystkie korzyści związane że - sequence, traversemetody realizowane za darmo.
  3. W niektórych przypadkach może się okazać, że lepiej jest zbudować czytnik tylko raz i używać go do wielu różnych konfiguracji. Z konstruktorami nikt ci tego nie zabroni, po prostu musisz zbudować cały wykres obiektu od nowa dla każdego przychodzącego Config. Chociaż nie mam z tym problemu (nawet wolę to robić przy każdym zgłoszeniu), dla wielu osób nie jest to oczywisty pomysł z powodów, o których mogę tylko spekulować.
  4. Czytnik popycha Cię do większego wykorzystania funkcji, które będą działać lepiej z aplikacjami napisanymi głównie w stylu FP.
  5. Czytelnik oddziela obawy; możesz tworzyć, współdziałać ze wszystkim, definiować logikę bez dostarczania zależności. Właściwie dostarczyć później, osobno. (Dzięki Ken Scrambler za ten punkt). Jest to często słyszalna zaleta programu Reader, ale jest to również możliwe w przypadku zwykłych konstruktorów.

Chciałbym też powiedzieć, czego nie lubię w Czytniku.

  1. Marketing. Czasami odnoszę wrażenie, że Reader jest sprzedawany pod kątem wszelkiego rodzaju zależności, bez rozróżnienia, czy jest to plik cookie sesji, czy baza danych. Dla mnie nie ma sensu używanie programu Reader do praktycznie stałych obiektów, takich jak serwer poczty lub repozytorium z tego przykładu. Dla takich zależności uważam, że zwykłe konstruktory i / lub częściowo zastosowane funkcje są o wiele lepsze. Zasadniczo Reader zapewnia elastyczność, dzięki czemu możesz określić swoje zależności przy każdym połączeniu, ale jeśli tak naprawdę tego nie potrzebujesz, płacisz tylko podatek.
  2. Ukryta ciężkość - używanie programu Reader bez implikacji sprawiłoby, że przykład byłby trudny do odczytania. Z drugiej strony, kiedy ukryjesz zaszumione części za pomocą implikacji i popełnisz jakiś błąd, kompilator czasami daje trudne do rozszyfrowania wiadomości.
  3. Uroczystość z pure, locali tworzenie własnych klas config / używając krotki za to. Czytnik zmusza Cię do dodania kodu, który nie dotyczy problematycznej domeny, wprowadzając w ten sposób pewien szum w kodzie. Z drugiej strony aplikacja korzystająca z konstruktorów często używa wzorca fabrycznego, który również pochodzi spoza domeny problemowej, więc ta słabość nie jest aż tak poważna.

A jeśli nie chcę konwertować moich klas na obiekty z funkcjami?

Chcesz. Technicznie można tego uniknąć, ale spójrz tylko, co by się stało, gdybym nie przekształcił FindUsersklasy w obiekt. Odpowiedni wiersz do zrozumienia wyglądałby następująco:

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

który nie jest tak czytelny, prawda? Chodzi o to, że Reader działa na funkcjach, więc jeśli jeszcze ich nie masz, musisz je skonstruować w linii, co często nie jest takie ładne.


Dzięki za szczegółową odpowiedź :) Jedna kwestia, która nie jest dla mnie jasna, to dlaczego Datastorei EmailServersą pozostawione jako cechy, a inne stały się object? Czy istnieje zasadnicza różnica w tych usługach / zależnościach / (jak je nazywasz), która powoduje, że są one traktowane inaczej?
adamw

No cóż ... Nie mogę też zamienić np. EmailSenderNa obiekt, prawda? Nie byłbym wtedy w stanie wyrazić zależności bez posiadania typu ...
adamw

Ach, zależność przybrałaby wówczas postać funkcji z odpowiednim typem - więc zamiast używać nazw typów, wszystko musiałoby trafić do sygnatury funkcji (nazwa byłaby tylko przypadkowa). Może, ale nie jestem przekonany;)
adamw

Poprawny. Zamiast EmailSenderpolegać na tym, na czym byś polegał (String, String) => Unit. Czy to jest przekonujące, czy nie, to inna kwestia :) Aby być pewnym, jest to przynajmniej bardziej ogólne, ponieważ wszyscy już na nich polegają Function2.
Przemek Pokrywka

Cóż, z pewnością chciałbyś nazwać (String, String) => Unit tak, aby zawierało jakieś znaczenie, chociaż nie z aliasem typu, ale z czymś, co jest sprawdzane w czasie kompilacji;)
adamw

3

Myślę, że główna różnica polega na tym, że w twoim przykładzie wstrzykujesz wszystkie zależności podczas tworzenia instancji obiektów. Monada Reader zasadniczo buduje coraz bardziej złożone funkcje do wywołania, biorąc pod uwagę zależności, które następnie są zwracane do najwyższych warstw. W takim przypadku wstrzyknięcie następuje po ostatecznym wywołaniu funkcji.

Jedną z natychmiastowych zalet jest elastyczność, zwłaszcza jeśli możesz raz skonstruować monadę, a następnie chcesz jej użyć z różnymi wstrzykniętymi zależnościami. Jedną z wad jest, jak powiedziałeś, potencjalnie mniejsza przejrzystość. W obu przypadkach warstwa pośrednia musi tylko wiedzieć o ich bezpośrednich zależnościach, więc obie działają zgodnie z zapowiedzią dla DI.


Skąd warstwa pośrednia wiedziałaby tylko o swoich zależnościach pośrednich, a nie o wszystkich? Czy możesz podać przykład kodu pokazujący, jak można zaimplementować przykład przy użyciu monady czytelnika?
adamw

Prawdopodobnie nie mógłbym tego lepiej wyjaśnić niż blog JSona (który opublikowałeś) Aby zacytować tam formularz „W przeciwieństwie do przykładu implicits, w podpisach userEmail i userInfo nie ma nigdzie UserRepository”. Sprawdź dokładnie ten przykład.
Daniel Langdon,

1
No tak, ale zakłada się, że monada czytelnika, której używasz, jest sparametryzowana i Configzawiera odniesienie do UserRepository. Więc to prawda, nie jest to bezpośrednio widoczne w podpisie, ale powiedziałbym, że jest jeszcze gorzej, nie masz pojęcia, jakich zależności używa twój kod na pierwszy rzut oka. Czy bycie zależnym od a Configze wszystkimi zależnościami nie oznacza, że ​​każda metoda zależy od nich wszystkich ?
adamw

To zależy od nich, ale nie musi o tym wiedzieć. To samo, co w Twoim przykładzie z klasami. Widzę je jako całkiem równoważne :-)
Daniel Langdon

W przykładzie z klasami polegasz tylko na tym, czego faktycznie potrzebujesz, a nie na obiekcie globalnym ze wszystkimi zależnościami wewnątrz. I pojawia się problem, jak zdecydować, co znajduje się w „zależnościach” elementu globalnego config, a co jest „tylko funkcją”. Prawdopodobnie skończyłbyś również z wieloma samowystarczalnościami. W każdym razie to bardziej kwestia preferencji niż pytania i odpowiedzi :)
adamw
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.