Hold the Framework – Exploring Dependency Injection Patterns

Publicat: 2022-03-11

Opiniile tradiționale privind inversarea controlului (IoC) par să tragă o linie dură între două abordări diferite: locatorul de servicii și modelele de injectare a dependenței (DI).

Practic, fiecare proiect pe care îl cunosc include un cadru DI. Oamenii sunt atrași de ele pentru că promovează o cuplare slabă între clienți și dependențele lor (de obicei prin injectarea constructorului) cu cod standard minim sau deloc. Deși acest lucru este excelent pentru dezvoltarea rapidă, unii oameni consideră că poate face codul dificil de urmărit și de depanat. „Magia din culise” se realizează de obicei prin reflecție, care poate aduce un întreg set de probleme noi.

În acest articol, vom explora un model alternativ care este potrivit pentru bazele de cod Java 8+ și Kotlin. Acesta păstrează cele mai multe dintre beneficiile unui cadru DI, fiind în același timp la fel de simplu ca un localizator de servicii, fără a necesita instrumente externe.

Motivația

  • Evitați dependențele externe
  • Evitați reflexia
  • Promovați injecția de constructor
  • Minimizați comportamentul de rulare

Un exemplu

În exemplul următor, vom modela o implementare TV, în care pot fi folosite diferite surse pentru a obține conținut. Trebuie să construim un dispozitiv care să poată primi semnale din diverse surse (de exemplu, terestre, prin cablu, prin satelit etc.). Vom construi următoarea ierarhie de clase:

Ierarhia de clasă a unui dispozitiv TV care implementează o sursă de semnal arbitrară

Acum să începem cu o implementare DI tradițională, una în care un cadru precum Spring conectează totul pentru noi:

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

Observăm câteva lucruri:

  • Clasa TV exprimă o dependență de o sursă TV. Un cadru extern va vedea acest lucru și va injecta o instanță a unei implementări concrete (Terestru sau Cablu).
  • Modelul de injectare a constructorului permite testarea ușoară, deoarece puteți construi cu ușurință instanțe TV cu implementări alternative.

Am început bine, dar ne dăm seama că introducerea unui cadru DI pentru acest lucru ar putea fi puțin exagerat. Unii dezvoltatori au raportat probleme de depanare a problemelor de construcție (urme de stivă lungă, dependențe de neidentificat). Clientul nostru a mai spus că timpii de fabricație sunt puțin mai lungi decât se aștepta, iar profilerul nostru arată încetiniri în apelurile reflective.

O alternativă ar fi aplicarea modelului Service Locator. Este simplu, nu folosește reflectarea și ar putea fi suficient pentru mica noastră bază de cod. O altă alternativă este să lăsați clasele în pace și să scrieți codul locației dependenței în jurul lor.

După evaluarea multor alternative, alegem să o implementăm ca o ierarhie a interfețelor furnizorilor. Fiecare dependență va avea un furnizor asociat care va avea singura responsabilitate de a localiza dependențele unei clase și de a construi o instanță injectată. De asemenea, vom face din furnizor o interfață interioară pentru ușurință în utilizare. O vom numi Mixin Injection deoarece fiecare furnizor este amestecat cu alți furnizori pentru a-și găsi dependențele.

Detaliile de ce m-am stabilit pe această structură sunt elaborate în Detalii și justificare, dar iată varianta scurtă:

  • Segregează comportamentul locației dependenței.
  • Extinderea interfețelor nu intră în problema diamantului.
  • Interfețele au implementări implicite.
  • Lipsa dependențelor împiedică compilarea (puncte bonus!).

Următoarea diagramă arată cum interacționează dependențele și furnizorii, iar implementarea este ilustrată mai jos. Adăugăm, de asemenea, o metodă principală pentru a demonstra cum ne putem compune dependențele și construi un obiect TV. O versiune mai lungă a acestui exemplu poate fi găsită și pe acest GitHub.

Interacțiuni între furnizori și dependențe

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

Câteva note despre acest exemplu:

  • Clasa TV depinde de un TvSource, dar nu cunoaște nicio implementare.
  • TV.Provider extinde TvSource.Provider deoarece are nevoie de metoda tvSource() pentru a construi o TvSource și o poate folosi chiar dacă nu este implementată acolo.
  • Sursele terestre și de cablu pot fi folosite în mod interschimbabil de către televizor.
  • Interfețele Terrestrial.Provider și Cable.Provider oferă implementări concrete TvSource.
  • Metoda principală are o implementare concretă MainContext a TV.Provider care este folosită pentru a obține o instanță TV.
  • Programul necesită o implementare TvSource.Provider la momentul compilării pentru a instanția un televizor, așa că includem Cable.Provider ca exemplu.

Detalii și justificare

Am văzut modelul în acțiune și o parte din raționamentul din spatele lui. S-ar putea să nu fii convins că ar trebui să-l folosești până acum și ai avea dreptate; nu este tocmai un glonț de argint. Personal, cred că este superior modelului de localizare a serviciilor în majoritatea aspectelor. Cu toate acestea, în comparație cu cadrele DI, trebuie să se evalueze dacă avantajele depășesc costul general al adăugarii codului standard.

Furnizorii extind alți furnizori pentru a-și localiza dependențele

Când un furnizor extinde altul, dependențele sunt legate între ele. Aceasta oferă baza de bază pentru validarea statică, care împiedică crearea de contexte invalide.

Unul dintre principalele puncte dure ale modelului de localizare a serviciului este că trebuie să apelați o metodă generică GetService<T>() care vă va rezolva cumva dependența. La momentul compilării, nu aveți garanții că dependența va fi înregistrată vreodată în locator, iar programul dvs. ar putea eșua în timpul rulării.

Nici modelul DI nu abordează acest lucru. Rezolvarea dependențelor se face de obicei prin reflectarea de către un instrument extern care este în mare parte ascuns utilizatorului, care eșuează și în timpul execuției dacă dependențele nu sunt îndeplinite. Instrumente precum CDI-ul IntelliJ (disponibil doar în versiunea plătită) oferă un anumit nivel de verificare statică, dar numai Dagger cu preprocesorul său de adnotare pare să abordeze această problemă prin proiectare.

Clasele mențin injecția tipică de constructor a modelului DI

Acest lucru nu este necesar, dar cu siguranță dorit de comunitatea de dezvoltatori. Pe de o parte, poți doar să te uiți la constructor și să vezi imediat dependențele clasei. Pe de altă parte, permite tipul de testare unitară la care aderă mulți oameni, adică prin construirea subiectului testat cu simulari ale dependențelor sale.

Acest lucru nu înseamnă că alte modele nu sunt acceptate. De fapt, s-ar putea găsi chiar că Mixin Injection simplifică construirea de grafice complexe de dependență pentru testare, deoarece trebuie doar să implementați o clasă de context care extinde furnizorul subiectului dvs. MainContext de mai sus este un exemplu perfect în care toate interfețele au implementări implicite, astfel încât poate avea o implementare goală. Înlocuirea unei dependențe necesită doar înlocuirea metodei furnizorului acesteia.

Să ne uităm la următorul test pentru clasa TV. Trebuie să instanțieze un televizor, dar în loc să apeleze constructorul clasei, folosește interfața TV.Provider. TvSource.Provider nu are implementare implicită, așa că trebuie să o scriem noi înșine.

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

Acum să adăugăm o altă dependență la clasa TV. Dependența CathodeRayTube lucrează magic pentru a face o imagine să apară pe ecranul televizorului. Este decuplat de implementarea televizorului, deoarece ar putea dori să trecem la LCD sau LED în viitor.

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Dacă faceți acest lucru, veți observa că testul pe care tocmai l-am scris încă se compilează și trece conform așteptărilor. Am adăugat o nouă dependență la televizor, dar am oferit și o implementare implicită. Aceasta înseamnă că nu trebuie să-l batjocorim dacă vrem doar să folosim implementarea reală, iar testele noastre pot crea obiecte complexe cu orice nivel de granularitate simulată ne dorim.

Acest lucru este util atunci când doriți să bateți joc de ceva specific într-o ierarhie de clasă complexă (de exemplu, doar nivelul de acces la baza de date). Modelul permite stabilirea cu ușurință a tipului de teste sociabile care sunt uneori preferate testelor solitare.

Indiferent de preferințele tale, poți fi sigur că poți apela la orice formă de testare care se potrivește mai bine nevoilor tale în fiecare situație.

Evitați dependențele externe

După cum puteți vedea, nu există referințe sau mențiuni la componente externe. Aceasta este cheia pentru multe proiecte care au constrângeri de dimensiune sau chiar de securitate. De asemenea, ajută la interoperabilitate, deoarece cadrele nu trebuie să se angajeze într-un cadru DI specific. În Java, au existat eforturi, cum ar fi JSR-330 Dependency Injection pentru Java Standard, care atenuează problemele de compatibilitate.

Evitați reflecția

Implementările de localizare a serviciilor nu se bazează de obicei pe reflecție, dar implementările DI se bazează (cu excepția notabilă a Dagger 2). Acest lucru are principalele dezavantaje de a încetini pornirea aplicației, deoarece cadrul trebuie să vă scaneze modulele, să rezolve graficul de dependență, să vă construiască în mod reflectiv obiectele etc.

Mixin Injection vă solicită să scrieți codul pentru a vă instanția serviciile, similar cu pasul de înregistrare din modelul de localizare a serviciilor. Această mică muncă suplimentară elimină complet apelurile reflectorizante, făcând codul mai rapid și mai simplu.

Două proiecte care mi-au atras recent atenția și beneficiază de evitarea reflecției sunt Graal's Substrate VM și Kotlin/Native. Ambele se compilează la bytecode nativ, iar acest lucru necesită ca compilatorul să știe în avans orice apeluri reflectorizante pe care le veți efectua. În cazul lui Graal este specificat într-un fișier JSON care este greu de scris, nu poate fi verificat static, nu poate fi refactorizat cu ușurință folosind instrumentele tale preferate. Utilizarea Mixin Injection pentru a evita reflectarea în primul rând este o modalitate excelentă de a obține beneficiile compilației native.

Minimizați comportamentul de rulare

Prin implementarea și extinderea interfețelor necesare, construiți graficul de dependență pe rând. Fiecare furnizor stă lângă implementarea concretă, ceea ce aduce ordine și logică programului tău. Acest tip de stratificare va fi familiar dacă ați mai folosit modelul Mixin sau modelul Cake.

În acest moment, ar putea merita să vorbim despre clasa MainContext. Este rădăcina graficului de dependență și cunoaște imaginea de ansamblu. Această clasă include toate interfețele furnizorului și este cheia pentru activarea verificărilor statice. Dacă ne întoarcem la exemplu și eliminăm Cable.Provider din lista de implementări, vom vedea clar acest lucru:

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

Ce s-a întâmplat aici este că aplicația nu a specificat sursa TvSource concretă de utilizat, iar compilatorul a detectat eroarea. Cu locatorul de servicii și DI bazat pe reflectare, această eroare ar fi putut trece neobservată până când programul s-a prăbușit în timpul execuției, chiar dacă toate testele unitare au trecut! Cred că acestea și celelalte beneficii pe care le-am arătat depășesc dezavantajul scrierii standardului necesar pentru ca modelul să funcționeze.

Prindeți dependențe circulare

Să ne întoarcem la exemplul CathodeRayTube și să adăugăm o dependență circulară. Să presupunem că vrem să fie injectată o instanță TV, așa că extindem TV.Provider:

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Compilatorul nu permite moștenirea ciclică și nu putem defini acest tip de relație. Majoritatea cadrelor eșuează în timpul execuției atunci când se întâmplă acest lucru, iar dezvoltatorii tind să o rezolve doar pentru a face programul să ruleze. Chiar dacă acest anti-model poate fi găsit în lumea reală, este de obicei un semn de design prost. Când codul nu reușește să se compileze, ar trebui să fim încurajați să căutăm soluții mai bune înainte de a fi prea târziu pentru a fi schimbat.

Mențineți simplitatea în construcția obiectelor

Unul dintre argumentele în favoarea SL față de DI este că este simplu și mai ușor de depanat. Din exemple, reiese clar că instanțiarea unei dependențe va fi doar un lanț de apeluri de metodă de la furnizor. Urmărirea sursei unei dependențe este la fel de simplă ca să intri în apelul de metodă și să vezi unde ajungi. Depanarea este mai simplă decât ambele alternative, deoarece puteți naviga exact unde sunt instanțiate dependențele, chiar de la furnizor.

Durata de viață a serviciului

Un cititor atent ar fi observat că această implementare nu abordează problema duratei de viață a serviciului. Toate apelurile către metodele furnizorului vor instanția noi obiecte, făcând acest lucru asemănător cu domeniul de aplicare al prototipului Spring.

Acestea și alte considerații sunt puțin în afara scopului acestui articol, deoarece am vrut doar să prezint esența modelului fără a distrage atenția. Utilizarea și implementarea completă într-un produs ar trebui, totuși, să ia în considerare soluția completă cu suport pe viață.

Concluzie

Indiferent dacă sunteți obișnuit cu cadre de injectare a dependenței sau să vă scrieți propriile locatoare de servicii, este posibil să doriți să explorați această alternativă. Luați în considerare utilizarea modelului mixin pe care tocmai l-am văzut și vedeți dacă puteți face codul mai sigur și mai ușor de raționat.

Înrudit : Cele mai bune practici JS: Construiți un bot Discord cu TypeScript și Dependency Injection