Molto tempo fa nella terra di Scala è emerso un nuovo tipo di iniezione sicura delle dipendenze. A lungo andare, porta più problemi di quanti ne valga la pena.
Cake pattern
Ricordiamo che cosa è un modello di torta. Per capirlo abbiamo bisogno di conoscere auto-tipi. Erano destinati ad essere usati come mixin e sembrano più o meno così:
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
Qui possiamo aggiungere trait UserRepositoryLogging
a tutto ciò che è UserRepository
implementazione – non compilerebbe altrimenti. Inoltre, all’interno di UserRepositoryLogging
supponiamo che sia l’implementazione di UserRepository
. Quindi tutto ciò che è accessibile all’interno di UserRepository
è accessibile anche lì.
Ora, dal momento che possiamo accedere a qualsiasi tipo dichiarato usato per l’auto-tipo, ci è permesso farlo:
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
All’interno diSomeController
dichiariamo self-type, che garantisce, che la sua implementazione istanziabile avrà userRepository
metodo. Quindi aggiungendo il mixin forniamo l’implementazione e, quindi, ci assicuriamo che la dipendenza venga iniettata al momento della compilazione. Senza runtime reflection, con type-safety, senza configurazioni o librerie aggiuntive.
Ogni componente di questo tipo potrebbe essere un livello della nostra applicazione (livello di logica aziendale, livello di infrastruttura, ecc.), che qualcuno ha confrontato con gli strati della torta. Quindi, creando la tua applicazione in questo modo stai effettivamente cuocendo una torta. Così modello di torta.
Ora why perché è un problema?
Aggiunta di dipendenze
In progetti più grandi, il numero di servizi, repository e utilità sarà sicuramente maggiore di 1-2. Quindi la tua torta inizierà a sembrare:
object PaymentTransactionApiController extends TransactionApiController with ConfigComponentImpl with DatabaseComponentImpl with UserRepositoryComponentImpl with SessionRepositoryComponentImpl with SecurityServicesComponentImpl with ExternalPaymentApiServicesComponentImpl with PaymentServicesComponentImpl with TransactionServicesComponentImpl
Per il fatto che ho visto una volta una torta in cui ComponentImpl
ha attraversato 2-3 schermate. Non stavi iniettando lì solo componenti-quelli-necessari-alla-volta, stavi combinando tutto insieme in una volta: infrastruttura, servizi di dominio, persistenza, logica di business
Ora, immagina cosa succede quando devi aggiungere una dipendenza. Si inizia aggiungendo un altrowith Dependency
al proprio self-type. Quindi, controlli dove viene utilizzato il tratto aggiornato. Probabilmente devi aggiungere anche l’auto-tipo qui. Si sale l’albero di mixins fino a raggiungere una classe o un oggetto. Uff.
Tranne, questa volta l’hai aggiunto tu stesso, quindi sai cosa deve essere aggiunto. Ma quando stai facendo rebase o risolvendo un conflitto, potresti finire in una situazione in cui è apparsa una nuova dipendenza che non hai. L’errore del compilatore dice solo che self-type X does not conform to Y
. Con oltre 50 componenti, potresti anche indovinare quale fallisce. (O iniziare a fare una ricerca binaria con la rimozione di componenti fino a quando l’errore scompare).
Quando la dimensione della torta e la quantità crescono ulteriormente, si potrebbe voler implementare la regola SECCA e dividerla in torte più piccole, che verranno messe insieme in seguito. Questo scopre nuove profondità dell’inferno.
Rimozione delle dipendenze
Con normal DI quando smetti di usare qualche oggetto, il tuo IDE / compilatore può dirti che non è più necessario, quindi potresti rimuoverlo. Con il modello di torta, non sarai informato su queste cose.
Di conseguenza, la pulizia è molto più difficile ed è del tutto possibile che ad un certo momento finirai con molte più dipendenze di quelle di cui hai veramente bisogno.
Tempo di compilazione
Tutto questo si aggiunge al tempo di compilazione. Potrebbe non essere ovvio a prima vista, ma si può finire con:
trait ModuleAComponentImpl { self: ModuleBComponent => }trait ModuleBComponentImpl { self: ModuleAComponent => }
che è una dipendenza ciclica. (Sappiamo tutti che sono cattivi, ma si potrebbe anche non notare, che hai appena creato uno. Soprattutto se inizializzi tutto pigramente).
Quindi quei 2 tratti saranno molto spesso compilati insieme e il compilatore incrementale di Zinco non aiuterà in questo. Inoltre, le modifiche a uno qualsiasi dei componenti di una torta attiveranno la ricompilazione delle cose sopra di esso nel grafico delle dipendenze.
Testing
Le dipendenze cicliche creano un altro problema. Si potrebbe benissimo finire con qualcosa di simile:
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!
Ogni componente sarà nel proprio file, quindi non noterai questa dipendenza. I test con mock non ti aiuteranno in questo:
trait BComponentMock { val b: B = mock }val aComponent = new AComponentImpl with BComponentMock {}aComponent.a // works!
quindi andrà tutto bene e dandy fino a quando non eseguirai effettivamente il codice. Sai-il codice è sicuro per il tipo, il che significa che il valore che costruirai seguirà i vincoli di tipo. Ma questi vincoli non significano nulla in quantoNothing
(eccezione generata) è un sottotipo perfettamente valido del tipo richiesto.
Ordine di inizializzazione
Il problema precedente è relativo a questo: qual è l’ordine di inizializzazione del componente?
Con normal DI è ovvio – prima di mettere una cosa nell’altra è necessario crearla. Quindi basta guardare l’ordine in cui, si crea oggetti.
Con una torta puoi dimenticartene. I valori all’interno dei componenti saranno spesso lazy val
s o object
s. Quindi l’attributo a cui si accede per primo proverà a istanziare le sue dipendenze, che proverà a istanziare le sue dipendenze, ecc.
Anche se andiamo con val
s è una questione di ordine in cui abbiamo composto la torta – sai, la linearizzazione dei tratti. Finché ogni componente verrà utilizzato esattamente una volta-nessun problema. Ma se hai asciugato la torta, e alcuni componenti si ripetono, quando li unisci
In un mondo perfetto cose come l’ordine di inizializzazione non dovrebbero avere importanza, ma abbastanza spesso lo fa, e vorremmo sapere ad esempio quando è iniziata una connessione al DB o all’abbonamento all’argomento Kafka, e registrare le cose per eseguire il debug di potenziali errori.
Boilerplate e lock-in
Come probabilmente hai notato questo modello genera un sacco di boilerplate – fondamentalmente ogni repository o servizio dovrebbe avere wrapper, solo per annotare le dipendenze.
Una volta che si va su quella strada si noterà che è abbastanza difficile sbarazzarsi del modello di torta. Ogni implementazione viene inserita all’interno di un tratto e le sue dipendenze provengono dall’ambito, non tramite parametri. Quindi, per ogni classe, dovresti prima eseguire il refactoring per poterlo istanziare senza il wrapper.
Ma poi le dipendenze dovrebbero anche essere refactored e così via. Cake pattern è un brutto impegno, che fa male e si oppone quando si tenta di ritirarsi gradualmente da esso.
Letteralmente, qualsiasi altra forma di DI in Scala, che conosco (runtime reflection a’la Guice, annotazioni macro a’la MacWire, implicita, combinazioni di quelle 3) ti permette di dire stop! in qualsiasi momento, e gradualmente passare a un’altra soluzione.
Sommario
Cake pattern è una soluzione a un problema DI, che a lungo andare fa più male che bene. Da quello che ho sentito è stato usato nel compilatore Scala, e se ne pentono. Era il meccanismo DI default per Play Framework, ma a causa di tutti questi problemi gli autori hanno deciso di passare a Guice – sì, i programmatori funzionali preferivano la riflessione del runtime alla torta sicura per il tipo, quindi questo dice molto.
Quindi, quali sono le alternative?
- semplice vecchio argomento manuale che passa – non c’è bisogno di spiegare che
- implica – ha i suoi vantaggi fino a quando il tuo progetto non viene mantenuto da più di te stesso, e i colleghi iniziano a lamentarsi di non sapere, cosa sta succedendo, compilazione lenta, non capire implica reflection
- runtime reflection – Guice è un framework maturo che fa DI esattamente E ‘ stato utilizzato in molti progetti, programmatori Java lo amano – proprio come amano la primavera. Alcuni di loro lasciano che l’astrazione perda passando l’iniettore nella classe. Alcuni di loro vogliono che la loro configurazione sia flessibile, così flessibile che il cablaggio di classe potrebbe fallire in fase di runtime, bleh!
- MacWire-a metà strada tra il passaggio di argomenti manualmente e l’utilizzo di macro per tutto-analizza l’ambito corrente in fase di compilazione e inietta le dipendenze per te. Se alcuni mancano la compilazione fallisce. Nel frattempo scrivi solo
wire
- Airframe – framework basato su macro in cui costruisci la ricetta per DI con un DSL. Ha il supporto per la gestione del ciclo di vita degli oggetti
- pulp-la mia auto-promozione spudorata. Ha usato impliciti e annotazioni macro per generare provider per le classi
IMHO ognuno di essi dovrebbe funzionare meglio, rispetto al modello cake.