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.
UserReminder
nie ma pojęcia, żeFindUsers
potrzebuje 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
IO
monadę, 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 class
es 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. retainUsers
Metoda (która wywołuje emailInactive
, które wywołuje w inactive
celu znalezienia nieaktywnych użytkowników) musiałaby wiedzieć o Datastore
zależ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?
Odpowiedzi:
Jak modelować ten przykład
Nie jestem pewien, czy powinno to być wzorowane na Czytniku, ale można to zrobić przez:
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.inactive
metody. Pozwoliłem mu zwrócić,List[String]
aby lista adresów mogła zostać użyta wUserReminder.emailInactive
metodzie. 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
,Res
typy 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łbymuserFinder.inactive()
tu właśnie nazywainactive()
- funkcja przeszedł do niego w pierwszym parametrze.Zwróć uwagę, że kod wykazuje trzy pożądane właściwości z pytania:
retainUsers
nie musi wiedzieć o zależności DatastoreModelowanie 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.inactive
zależy odDatastore
iUserReminder.emailInactive
odEmailServer
. 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,
Config
nawet jeśli przyjmuje tylko część jej jako parametr. Jest to metoda o nazwielocal
, zdefiniowana w programie Reader. Musi mieć możliwość wyodrębnienia odpowiedniej części z plikuConfig
.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("[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)() }
Zalety w stosunku do używania 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.
local
wywoł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.sequence
,traverse
metody realizowane za darmo.Chciałbym też powiedzieć, czego nie lubię w Czytniku.
pure
,local
i 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ł
FindUsers
klasy 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.
źródło
Datastore
iEmailServer
są 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?EmailSender
Na obiekt, prawda? Nie byłbym wtedy w stanie wyrazić zależności bez posiadania typu ...EmailSender
polegać 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
.(String, String) => Unit
tak, aby zawierało jakieś znaczenie, chociaż nie z aliasem typu, ale z czymś, co jest sprawdzane w czasie kompilacji;)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.
źródło
Config
zawiera odniesienie doUserRepository
. 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 aConfig
ze wszystkimi zależnościami nie oznacza, że każda metoda zależy od nich wszystkich ?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 :)