kubuszok.com

1 lut 2018• seria: Random o Scali • tagi: Scala Cake pattern dependency injection

dawno temu w Krainie Scali pojawił się nowy bezpieczny sposób wstrzykiwania zależności. Na dłuższą metę przynosi więcej kłopotów,niż jest to warte.

wzór na ciasto

przypomnijmy czym jest wzór na ciasto. Aby to zrozumieć, musimy znać typy siebie. Miały być używane jako mixiny i wyglądają mniej więcej tak:

trait UserRepository { def fetchUsers(userIds: Seq): Seq}class UserRepositoryDbImpl(database: Database) extends UserRepository { def fetchUsers(userIds: Seq): Seq = ...}trait UserRepositoryLogging { self: UserRepository => private lazy val logger: Logger = ... override def fetchUsers(userIds: Seq): Seq = { val users = super.fetchUsers(usersIds) logger.debug(s"""Fetched users by ids: ${users.mkString(", ")}""") users }}val userRepository: UserRepository = new UserRepositoryDbImpl(database) with UserRepositoryLogging

tutaj możemy dodać cechęUserRepositoryLogging do wszystkiego, co jest implementacjąUserRepository – w przeciwnym razie nie skompilowałaby się. Dodatkowo, wewnątrzUserRepositoryLoggingZakładamy, że jest to implementacjaUserRepository. Tak więc wszystko dostępne w UserRepository jest tam również dostępne.

teraz, ponieważ możemy uzyskać dostęp do tego, co zostało zadeklarowane jako typ(Y) używany do self-type, możemy to zrobić:

trait UserRepository { def fetchUsers(userIds: Seq): Seq}trait UserRepositoryComponent { def userRepository: UserRepository}trait UserRepositoryComponentImpl extends UserRepositoryComponent { lazy val userRepository: UserRepository = new UserRepository { // ... }}trait SomeController { self: UserRepositoryComponent => def handleRequest(userIds: Seq): Response = { // ... userRepository.fetchUsers(userIds) // ... }}object SomeController extends SomeController with UserRepositoryComponentImpl

w ramach SomeControllerdeklarujemy własny typ, który zapewnia, że jego instantywna implementacja będzie miała userRepository metoda. Więc dodając mixin zapewniamy implementację i, więc upewniamy się, że zależność jest wstrzykiwana w czasie kompilacji. Bez odbicia runtime, z zabezpieczeniem typu, bez dodatkowych konfiguracji lub bibliotek.

każdy taki komponent może być warstwą naszej aplikacji (warstwa logiki biznesowej, warstwa infrastruktury itp.), którą ktoś porównał do warstw tortu. Tak więc, tworząc swoją aplikację w ten sposób skutecznie pieczesz ciasto. Tak więc wzór ciasta.

teraz … dlaczego to jest problem?

dodawanie zależności

w większych projektach liczba usług, repozytoriów i narzędzi będzie z pewnością większa niż 1-2. Tak więc Twój tort zacznie wyglądać:

object PaymentTransactionApiController extends TransactionApiController with ConfigComponentImpl with DatabaseComponentImpl with UserRepositoryComponentImpl with SessionRepositoryComponentImpl with SecurityServicesComponentImpl with ExternalPaymentApiServicesComponentImpl with PaymentServicesComponentImpl with TransactionServicesComponentImpl

w zasadzie widziałem kiedyś tort, w którymComponentImpl przeszedł przez 2-3 ekrany. Nie wstrzykiwałeś tam tylko komponentów-tych-potrzebnych-na-raz, łączyłeś wszystko razem na raz: Infrastruktura, Usługi domenowe, trwałość, logika biznesowa…

teraz wyobraź sobie, co się dzieje, gdy musisz dodać zależność. Zaczynasz od dodania innego with Dependency do swojego własnego typu. Następnie sprawdzasz, gdzie używana jest zaktualizowana cecha. Być może trzeba dodać własny typ tutaj, jak również. Wspinasz się na drzewo mixinów, aż dotrzesz do klasy lub obiektu. Uff.

tyle, że tym razem sam to dodałeś, więc wiesz, co trzeba dodać. Ale kiedy robisz rebase lub rozwiązujesz konflikt, możesz skończyć w sytuacji, gdy pojawi się jakaś nowa zależność, której nie masz. Błąd kompilatora mówi tylko, że self-type X does not conform to Y. Z ponad 50 komponentami możesz równie dobrze zgadnąć, który z nich nie powiedzie się. (Lub rozpocznij wyszukiwanie binarne od usunięcia komponentów, aż błąd zniknie).

gdy wielkość ciasta i jego ilość będzie rosła, warto zastosować suchą regułę i podzielić ją na mniejsze ciastka, które zostaną później złożone. To odkrywa nowe głębiny piekła.

usuwanie zależności

przy normalnym DI kiedy przestaniesz używać jakiegoś obiektu, Twój IDE / kompilator powie Ci, że nie jest już potrzebny, więc możesz go usunąć. Z wzorem ciasta nie będziesz informowany o takich rzeczach.

w rezultacie sprzątanie jest znacznie trudniejsze i jest całkiem możliwe, że w pewnym momencie skończysz z dużo większą ilością zależności, niż naprawdę potrzebujesz.

czas kompilacji

wszystko to sumuje się do czasu kompilacji. To może nie być oczywiste na pierwszy rzut oka, ale można skończyć z:

trait ModuleAComponentImpl { self: ModuleBComponent => }trait ModuleBComponentImpl { self: ModuleAComponent => }

która jest zależnością cykliczną. (Wszyscy wiemy, że są złe, ale możesz nawet nie zauważyć, że właśnie je stworzyłeś. Zwłaszcza jeśli inicjujesz wszystko leniwie).

więc te 2 cechy będą bardzo często kompilowane razem, a Zinc incremental compiler nie pomoże w tym. Dodatkowo zmiany w którymkolwiek z komponentów w cake ’ u wywołają rekompilację rzeczy nad nim na wykresie zależności.

testowanie

zależności Cykliczne tworzy kolejny problem. Możesz bardzo dobrze skończyć z czymś takim jak:

trait AComponentImpl { self: BComponent => lazy val a: A = new A { b.someMethod() // use b }}trait BComponentImpl { self: AComponent => lazy val b: B = new B { a.someMethod() // use a }}object Cake extends AComponentImpl with BComponentImplCake.a // NPE!Cake.b // NPE!

każdy komponent będzie w swoim własnym pliku, więc nie zauważysz tej zależności. Testy z mockiem Ci w tym nie pomogą:

trait BComponentMock { val b: B = mock }val aComponent = new AComponentImpl with BComponentMock {}aComponent.a // works!

więc wszystko będzie dobrze, dopóki nie uruchomisz kodu. Wiesz-kod jest bezpieczny dla typu, co oznacza, że wartość, którą zbudujesz, będzie zgodna z ograniczeniami typu. Ale te ograniczenia nic nie znaczą, ponieważ Nothing (wyrzucony wyjątek) jest całkowicie poprawnym podtypem wymaganego typu.

kolejność inicjalizacji

poprzedni problem jest związany z tym: jaka jest kolejność inicjalizacji komponentu?

z normalnym DI jest to oczywiste-zanim włożysz jedną rzecz w drugą, musisz ją stworzyć. Wystarczy spojrzeć na kolejność tworzenia obiektów.

z ciastem można o nim zapomnieć. Wartości w komponentach często będą to lazy vals lub objects. więc pierwszy atrybut, do którego uzyskamy dostęp, spróbuje utworzyć instancję swoich zależności, które spróbują utworzyć instancję swoich zależności, itd.

nawet jeśli pójdziemy zval s to kwestia kolejności, w jakiej skomponowaliśmy tort – wiesz, cecha linearyzacji. Tak długo, jak każdy komponent będzie używany dokładnie raz – nie ma problemu. Ale jeśli Wysuszasz ciasto, a niektóre komponenty powtarzają się, kiedy je łączysz…

w idealnym świecie takie rzeczy jak kolejność inicjalizacji nie powinny mieć znaczenia, ale dość często tak jest i chcielibyśmy wiedzieć np. kiedy zaczęło się jakieś połączenie z bazą danych lub subskrypcja tematu Kafka i logować rzeczy w celu debugowania potencjalnych awarii.

Boilerplate i lock-in

jak zapewne zauważyliście ten wzorzec generuje dużo boilerplate – w zasadzie każde repozytorium lub usługa musiałaby mieć wrapper, tylko dla adnotacji zależności.

Po przejściu tej drogi zauważysz, że jest dość trudno pozbyć się wzoru ciasta. Każda implementacja jest umieszczana wewnątrz cechy, a jej zależności pochodzą z zakresu, a nie z parametrów. Tak więc dla każdej klasy, musisz najpierw wykonać refactor, aby móc utworzyć instancję Bez opakowania.

ale wtedy zależności również musiałyby być refakturowane i tak dalej. Wzór ciasta jest brzydkim zobowiązaniem, które boli i sprzeciwia się, gdy próbujesz wycofać się z niego stopniowo.

dosłownie każda inna forma DI w Scali, którą znam (runtime reflection a 'la Guice, adnotacje makr a’ la MacWire, implicits, kombinacje tych 3) pozwala powiedzieć stop! w każdej chwili i stopniowo przełączyć się na inne rozwiązanie.

podsumowanie

wzór ciasta jest rozwiązaniem problemu DI, który na dłuższą metę robi więcej szkody niż pożytku. Z tego, co słyszałem, został użyty w kompilatorze Scali i żałują tego. Był to domyślny mechanizm DI dla frameworka Play, ale ze względu na te wszystkie problemy autorzy zdecydowali się przełączyć na Guice-tak, Programiści funkcyjni woleli odbicie runtime ’ u od bezpiecznego pisania, więc to wiele mówi.

więc jakie są alternatywy?

  • zwykłe stare przekazywanie argumentów manualnych – nie trzeba wyjaśniać, że
  • niejawne – mają swoje zalety, dopóki twój projekt nie jest utrzymywany przez więcej niż ciebie, a współpracownicy zaczynają narzekać na niewiedzę, co się dzieje, powolną kompilację, nie rozumienie niejawnych…
  • runtime reflection – Guice jest dojrzałym frameworkiem robiącym DI dokładnie tak, jak nienawidzę. Był używany w wielu projektach, Programiści Javy uwielbiają go-tak jak kochają wiosnę. Niektóre z nich pozwalały na wyciek poprzez przekazanie wtryskiwacza do klasy. Niektórzy z nich chcą, aby ich konfiguracja była elastyczna – tak elastyczna, że okablowanie klasowe może nie działać w czasie wykonywania, bleh!
  • MacWire-w połowie przekazywania argumentów ręcznie i używanie makr do wszystkiego-skanuje bieżący zakres w czasie kompilacji i wstrzykuje zależności dla Ciebie. Jeśli brakuje niektórych kompilacji nie powiedzie się. W międzyczasie piszesz tylko wire
  • Airframe – framework oparty na makrach, w którym budujesz przepis na DI za pomocą DSL. Posiada wsparcie dla zarządzania cyklem życia obiektu
  • pulp – moja bezwstydna autopromocja. W celu wygenerowania dostawców dla klas

IMHO każda z nich powinna działać lepiej niż wzór cake.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.