Dependency injection is een erg populair design pattern om koppelingen tussen classes te regelen. Door een afhankelijkheid te annoteren met @Autowire of @Inject zorgt een extern framework voor de gewenste implementatie.
Een klassiek voorbeeld om de werking van dependency injection uit te leggen is een virtueel koffieautomaat. In Listing 1 zie je een simpele applicatie, die virtuele koffie maakt. De code maakt geen gebruik van dependency injection, want de CoffeeMaker class is zelf verantwoordelijk voor de aanmaak van zijn afhankelijkheden.
class CoffeeMaker{
final Grinder grinder;
final Heater heater;
final Pump pump;
CoffeeMaker(){
this.grinder = new Grinder();
this.heater = new BrassHeater();
this.pump = new Thermosiphon(heater);
}
Coffee brew(){/** **/}
}
class CoffeeApp{
public static void main(String[] args){
Coffee nice = new CoffeeMaker().brew();
}
}
In Listing 2 zie je hoe de aanmaak van de afhankelijkheden in de CoffeeMaker wordt overgelaten aan een framework. De CoffeeMaker heeft zelf geen weet hoe of welk object hij ontvangt. Dit biedt de ruimte om makkelijker te wisselen van implementaties voor bijvoorbeeld een Heater of Pump.
class CoffeeMaker{
ˆ@Inject
Grinder grinder;
@Inject
Heater heater;
@Inject
Pump pump;
Coffee brew(){/** **/}
}
Het blijft vaak wat magisch hoe het nu allemaal precies functioneert. Het lijkt erop dat door een simpele annotatie toe te voegen het gewoon werkt. Veel dependency injection frameworks gebruiken reflectie om de afhankelijkheden uit te zoeken en de object graph op te bouwen. Mocht dit niet lukken, dan krijg je een runtime exceptie, die aangeeft wat er mis gaat. De stacktraces van deze excepties zijn niet voor iedereen even helder en overzichtelijk.
Het uitzoeken van de object graph tijdens de runtime is niet altijd wenselijk. Het is een complexe en resource intensieve actie. Niet alle platformen hebben deze resources beschikbaar. Je kunt hierbij dan denken aan het mobiele platform en IoT-oplossingen. Daarnaast is het logischer om de fouten in de configuratie te ontdekken op het moment dat je de code compileert.
Wellicht is Dagger een alternatief. Het is een dependency framework dat net als CDI, Spring en Guice de standaard dependency injection API (JSR 330) ondersteunt. De annotatie @Inject is een bekend voorbeeld van deze API. Het voornaamste doel van Dagger is het uitzoeken van de object graph al tijdens compilatie te doen, zodat eventuele gelimiteerde rekenkracht tijdens het draaien geen probleem zou zijn.
In het ontwerp van Dagger hebben de ontwikkelaars de eenvoud van gebruik de voorkeur gegeven boven gemak, zodat zelfs in de meest ingewikkelde object graphs nog steeds duidelijk is wat er gedaan wordt om de dependency injection mogelijk te maken.
Laten we weer even teruggaan naar Listing 2, waar de afhankelijke classes waren geannoteerd met @Inject. Sommige afhankelijkheden waren een interface, dus we moeten ergens aangeven welke implementatie we wensen. Dit doe je met de annotatie @Provides en @Module. De eerste annotatie geeft aan welke implementatie je wenst en de tweede geeft aan dat deze class configuratie bevat, die nodig is voor de opbouw van de object graph.
@Module
class CoffeeMakerModule{
@Provides Grinder provideGrinder(){
return new Grinder();
}
@Provides Heater provideHeater(){
return new BrassHeater();
}
/**
* Op deze manier geef je aan dat de implementatie zelf ook een afhankelijkheid heeft
* (zie Listing 1)
**/
@Provides Pump providePump(Thermosiphon pump){
return pump;
}
De laatste stap is het aanmaken van de object graph, zodat we de objecten kunnen gebruiken in de CoffeeApp. Hiervoor gebruik je de annotatie @Component waar je de modules als argument aan meegeeft. Dagger genereert de implementatie met een builder pattern en geeft de gegenereerde class de prefix Dagger.
@Component(modules={CoffeeMakerModule.class})
interface CoffeeMakerComponent{
CoffeeMaker maker();
}
class CoffeeApp{
public static void main(String[] args){
CoffeeMaker maker = DaggerCoffeeMakerComponent.builder().build().maker();
maker.brew();
}
}
Dagger genereert code om de annotaties te verwerken. Hierdoor is Dagger in staat om tijdens het compileren al problemen vast te stellen en daar ook de juiste en nuttige feedback over te geven. Het gevolg daarvan is wel dat je hier en daar wat meer code nodig hebt dan bijvoorbeeld in Spring of Guice. Dit komt, doordat je de koppelingen expliciet moet aan geven.
De code wordt gegenereerd met behulp van annotation processors. Deze annotation processors genereren de implementatie van standaard dependency injection API. Dit is één interface.
public interface Provider<T>{
T get()
}
Voor ons voorbeeld wordt onder andere de volgende code gegenereerd:
public final class DaggerCoffeeMakerComponent implements CoffeeMakerComponent {
/** ... **/
private void initialize(final Builder builder) {
this.provideGrinderProvider = CoffeeMakerModule_ProvideGrinderFactory.create(builder.coffeeMakerModule);
this.provideHeaterProvider = CoffeeMakerModule_ProvideHeaterFactory.create(builder.coffeeMakerModule);
this.providePumpProvider = CoffeeMakerModule_ProvidePumpFactory.create(builder.coffeeMakerModule, Thermosiphon_Factory.create());
this.coffeeMakerMembersInjector = CoffeeMaker_MembersInjector.create(provideGrinderProvider, provideHeaterProvider, providePumpProvider);
this.coffeeMakerProvider = CoffeeMaker_Factory.create(coffeeMakerMembersInjector);
}
@Override
public CoffeeMaker maker() {
return coffeeMakerProvider.get();
}
Hier zie je goed hoe Dagger voor alle afhankelijkheden providers bouwt, die netjes hun afhankelijkheden als providers weer meekrijgen. Alles wat specifiek gedefinieerd staat in de module wordt ook netjes aangeroepen in de module, zodat je daar volledige controle houdt over bijvoorbeeld properties en andere secundaire afhankelijkheden.
Zo is het verplaatsen van het zwaarste rekenwerk gedaan. Het live instantiëren van classes door middel van reflection is vervangen door codegeneratie door annotation processors. Het instantiëren van de benodigde classes zijn zo voor de JVM normale constructor calls, die op alle mogelijke manieren normaal geoptimaliseerd kunnen worden. Ook zijn stacktraces makkelijker te ontcijferen en wordt het stap-voor-stap doorlopen van injectie-code om bugs op te sporen simpeler.
De magie van Dagger schuilt dus in codegeneratie via annotation processors. Annotation processors zijn niet nieuw. Het bestaat al sinds Java 5, maar de API is pas echt bruikbaar geworden in Java 6.
Annotation processors zijn erg handig voor implementaties van boilerplate code, zoals bijvoorbeeld Factories of Data Transfer Objects. Mocht je een vrije middag te besteden hebben, dan is het zeker aan te raden hier eens mee te gaan spelen.
Je kunt je eigen annotation processors bouwen door javax.annotation.processing.AbstractProcessor te implementeren. Een annotation processor is een Java service. Je maakt daarom een file genaamd javax.annotation.processing.Processor aan, die je plaatst in META-INF/services. Deze file bevat de volledige namen van jouw eigen implementaties van AbstractProcessor. De JVM pikt deze file op en verwerkt de annotation processor tijdens het compilatie proces.
Conclusie
Dagger is een interessante technologie, die gebruik maakt van krachtige concepten. Doordat de object graph al tijdens het compileren wordt opgebouwd, is Dagger zeer geschikt in IoT of mobiele applicaties. Hiermee is het een volwaardig alternatief voor Guice. Mocht je Spring enkel gebruiken voor Dependency Injection, dan raad ik je ook zeker aan om even Dagger te bekijken. Het geeft je alleen dependency injection, dus mocht je meer eisen hebben aan een framework (zoals transaction management), dan moet je het óf zelf implementeren óf een ander framework gebruiken.