kubuszok.com

1 Feb 2018 • serie: slumpmässigt om Scala * taggar: scala cake pattern dependency injection

för länge sedan i Scala-landet uppstod ett nytt typsäkert sätt att bero på injektion. På lång sikt ger det mer problem än det är värt.

Kakemönster

låt oss påminna om vad som är ett kakemönster. För att förstå det måste vi känna till självtyper. De var avsedda att användas som mixins och ser mer eller mindre ut så:

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

Här kan vi lägga till egenskap UserRepositoryLogging till allt som är UserRepository implementering – det skulle inte kompilera annars. Dessutom, inuti UserRepositoryLogging antar vi att det är implementeringen av UserRepository. Så allt tillgängligt inom UserRepository är också tillgängligt där.

nu, eftersom vi kan komma åt vad som förklarades typ (er) som används för självtyp, får vi göra detta:

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

inom SomeControllervi förklarar självtyp, som säkerställer, att dess omedelbara implementering kommer att hauserRepositorymetod. Så genom att lägga till mixin tillhandahåller vi implementeringen och så ser vi till att beroende injiceras vid kompileringstiden. Utan runtime reflektion, med typ-säkerhet, inga ytterligare konfigurationer eller bibliotek.

varje sådan komponent kan vara ett lager av vår applikation (business logic layer, infrastructure layer, etc), som någon jämförde med kakans lager. Så, genom att skapa din ansökan så att du effektivt bakar en tårta. Således kaka mönster.

nu … varför är det ett problem?

lägga till beroenden

i större projekt kommer antalet tjänster, förråd och verktyg säkert att vara större än 1-2. Så din tårta börjar se ut som:

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

faktum är att jag en gång såg en tårta där ComponentImpl gick igenom 2-3 skärmar. Du injicerade inte bara komponenter-de som behövdes-i taget, du kombinerade allt på en gång: Infrastruktur, domäntjänster, uthållighet, affärslogik…

föreställ dig nu vad som händer när du behöver lägga till ett beroende. Du börjar med att lägga till ett annat with Dependency till din självtyp. Sedan kontrollerar du var den uppdaterade egenskapen används. Du måste eventuellt lägga till självtyp här också. Du klättrar upp mixins träd tills du når en klass eller ett objekt. Uff.

förutom, den här gången har du lagt till det här själv, så du vet vad som behöver läggas till. Men när du gör rebase eller löser en konflikt kan du hamna i en situation när något nytt beroende uppstod som du inte har. Kompilatorfel säger bara att self-type X does not conform to Y. Med 50 + komponenter kan du lika gärna gissa vilken som misslyckas. (Eller börja göra en binär sökning med att ta bort komponenter tills felet försvinner).

När kakstorleken och mängden växer ytterligare kanske man vill implementera TORRREGELN och dela den i mindre kakor, som kommer att sättas ihop senare. Detta avslöjar nya djup i helvetet.

ta bort beroenden

med normal DI när du slutar använda något objekt kan din IDE/kompilator berätta att det inte längre behövs, så du kan ta bort det. Med kakemönster kommer du inte att bli informerad om sådana saker.

som ett resultat är rengöring mycket svårare och det är mycket möjligt att du någon gång kommer att få mycket mer beroenden än du verkligen behöver.

kompileringstid

allt detta lägger till kompileringstiden. Det kanske inte är uppenbart vid första anblicken, men du kan sluta med:

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

vilket är ett cykliskt beroende. (Vi vet alla att de är dåliga, men du kanske inte ens märker, att du just skapat en. Särskilt om du initierar allt Lat).

så dessa 2 egenskaper kommer ofta att sammanställas tillsammans, och zink inkrementell kompilator hjälper inte med det. Dessutom ändras någon av komponenterna i en kaka kommer att utlösa omkompilering av saker ovanför den i beroendediagrammet.

testning

cykliska beroenden skapar ett annat problem. Du kan mycket väl sluta med något som:

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!

varje komponent kommer att finnas i sin egen fil, så du kommer inte att märka detta beroende. Tester med mock hjälper dig inte med det:

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

så det blir allt bra och dandy tills du faktiskt kör koden. Du vet – koden är typsäker, vilket innebär att värdet du kommer att konstruera kommer att följa typbegränsningar. Men dessa begränsningar betyder ingenting eftersom Nothing (kastat undantag) är en helt giltig subtyp av din önskade typ.

Initialiseringsordning

det tidigare problemet är relaterat till det här: vad är ordningen för komponentinitieringen?

med normal DI är det uppenbart-innan du lägger en sak i den andra måste du skapa den. Så du tittar bara på den ordning i vilken du skapar objekt.

med en tårta kan du glömma det. Värden inom komponenter kommer ofta lazy vals eller objects. så första åtkomstattributet kommer att försöka instansiera dess beroenden, vilket kommer att försöka instansiera dess beroenden etc.

Även om vi går med vals det är en fråga om ordning där vi komponerade tårta – du vet, draglinjärisering. Så länge varje komponent kommer att användas exakt en gång – inga problem. Men om du torkade dig tårta, och vissa komponenter upprepa, när du sammanfoga dem…

i en perfekt värld sådana saker som initiering ordning bör inte roll, men ganska ofta det gör, och vi skulle vilja veta t.ex. när någon anslutning till DB eller prenumeration på Kafka ämne startade, och logga saker för att felsöka potentiella fel.

Boilerplate och lock-in

som du säkert märkte genererar detta mönster mycket boilerplate-i princip måste varje arkiv eller tjänst ha omslag, bara för att kommentera beroenden.

När du går den vägen kommer du att märka att det är ganska svårt att bli av med kakemönstret. Varje implementering sätts in i ett drag, och dess beroenden kommer från omfattningen, inte via parametrar. Så för varje klass måste du först utföra refactor för att kunna instansiera den utan omslaget.

men då måste beroenden också refactored och så vidare. Kakemönster är ett fult engagemang, som värker och motsätter dig när du försöker dra dig ur det gradvis.

bokstavligen, någon annan form av DI i Scala, som jag vet (runtime reflection a ’la Guice, macro annotations a’ la MacWire, implicits, kombinationer av dessa 3) låter dig säga stopp! när som helst, och gradvis byta till en annan lösning.

sammanfattning

Kakemönster är en lösning på ett DI-problem som på lång sikt gör mer skada än nytta. Från vad jag hörde det användes i Scala compiler, och de ångrar det. Det var standard di mekanism för Play Framework, men på grund av alla dessa frågor författare beslutat att byta till Guice – ja, funktionella programmerare föredrog runtime reflektion typ säker kaka, så det berättar en hel del.

Så, vad är alternativen?

  • plain old manual argument passing-inget behov av att förklara att
  • implicits – har sina fördelar tills ditt projekt upprätthålls av mer än dig själv, och medarbetare börjar klaga på att inte veta, vad som händer, långsam kompilering, inte förstå implicits…
  • runtime reflection – Guice är en mogen ram som gör DI på exakt det sätt som jag hatar. Det användes i många projekt, Java – programmerare älskar det-precis som de älskar våren. Några av dem låter abstraktion läcka genom att passera injektor i klassen. Några av dem vill att deras config att vara flexibel – så flexibel Att klass ledningar kan misslyckas vid körning, bleh!
  • MacWire-halvvägs genom att skicka argument manuellt och använda makron för allt – det skannar nuvarande omfattning i kompileringstid och injicerar beroenden åt dig. Om vissa saknas kompilering misslyckas. Du skriver under tiden bara wire
  • Airframe – makrobaserat ramverk där du bygger recept för DI med en DSL. Den har stöd för object lifecycle management
  • massa-min skamlösa auto-marknadsföring. Den använde impliciter och makroanteckningar för att generera leverantörer för klasser

IMHO var och en av dem borde fungera bättre än kakemönster.

Lämna ett svar

Din e-postadress kommer inte publiceras.