Superenkel dependency injection

Mer fleksibelt enn Spring og Guice. Praktisk talt ingen ytelsestap. Støttes av alle web-containere. Veldig bra dokumentert. Velg din egen lisens. Ingen xml.

For godt til å være sant?

Det heter "vanilla X", der X er navnet på favorittprogrammeringsspåket ditt. For hva er IoC egentlig? Jo, bare et fancy navn på vanlige metodekall.  

La oss begynne fra scratch.  Jeg har valgt å bruke coffeescript som språk, siden det er enkelt, konsist og relativt velkjent, men det kan brukes i de fleste programmeringsspråk.

La oss anta at vi har en webapplikasjon med en modell som vi kan kjøre queries mot.

class Model 
 constructor: () ->
 @userModel = new Users
 users: () -> 
  @userModel

...

class Users
 constructor: ->
 get:(id) -> …
 put:(id,user) -> …

Vi skal endre navnet på en bruker, og starter med en naiv tilnærming.

window.model = new Model()
setName = (id,name) ->
 user = model.users.get(id) #Hent brukeren fra modellen
 updateduser = user.with({name:name}) #oppdater brukeren med en funksjon i brukerobjektet
 model.users.put(id, updateduser) #Lagre den oppdaterte brukeren i modellen igjen

Dette virker, men vi har en avhengighet til model som gjør koden vår lite fleksibel. Det løser vi greit med IoC, dvs at vi injecter model som et argument i metodekallet. Nå er kontrollen overlatt til klienten:

setName = (id, name, model) ->
 user = model.users.get(id)
 updateduser = user.with({name:name})
 model.users.put(id, updateduser)

Nå har vi kvittet oss med den globale avhengigheten, men må istedenfor sende med model objektet med alle metodekall, noe som blir omstendig dersom kallstakken blir dyp, siden vi da må sende med model som argument gjennom hele stakken:

obj1 -> obj2 -> obj3 -> obj4 -> obj5.setName (:trenger modell objekt)

Currying snur på flisa

Istedenfor å sende med model hele veien ned, returnerer vi en funksjon som tar inn model:

setName = (id, name) ->
 (model) ->
  user = model.users.get(id)
  updateduser = user.with({name:name})
  model.users.put(id, updateduser)

Funksjonen som setName returnerer kan vi bruke som en verdi. Denne verdien er i praksis et lite program, som ved hjelp av et miljø (her: model) endrer navnet på en bruker. 

Så langt er ikke dette så veldig mye mer nyttig enn den naive framgangsmåten. La oss lage funksjonalitet som lar oss sette sammen flere ulike slike småprogrammer til større programmer, og et lite rammeverk som lar oss kjøre programmene. Dette vil i praksis bli IoC rammverket.

Vi starter med en Reader

Først lager vi en klasse som inneholder et program - r - som leser fra et miljø:

class Reader
 #r::Model->object 
 #r er en funksjon som tar inn et objekt av typen Model
 #og som returnerer et vilkårlig objekt.
 constructor:(@r) ->
 #apply::env -> object, der env kan være et vilkårlig objekt. Det er dette som er miljøet ditt.
 #"Kjører" readeren
 apply:(env) -> @r(env)

La oss legge til muligheten til å manipulere verdien Readeren har hentet ut:

Vi legger til map, en funksjon som tar som argument en vanlig funksjon, og som returnerer en ny reader. Den nye readeren leser først ut verdien til denne readeren, og så sender den inn resultatet til f, og får dermed en ny reader.

class Reader
 constructor:(@r) ->
 apply:(env) -> @r(env)
 #map::(a->b) -> Reader
 map:(f) -> new Reader((env) -> f(@r(env)))

map kjenner de fleste fra ulike container-typer, slik som lister, arrays, options osv. Det som er snedig i denne sammenhengen er at vi kan definere hva som skal gjøres med verdien raderen leser ut - med f - før lesingen er gjort. 

Men det hadde vært praktisk om vi kan sette sammen flere reader operasjoner, så vi legger til bind, som tar inn en funksjon fra et objekt til en reader. Bind returnerer en ny reader som først kjører denne readeren,  og så setter resultatet inn i f, får en ny reader, og så kjører denne.

class Reader
 constructor:(@r) ->
 apply:(env) -> @r(env)
 map:(f) -> new Reader((env) -> f(@r(env)))
 bind:(f) -> new Reader((env) -> f(@r(env))(env))

Deretter lager vi et lite dsl for å gjøre det litt enklere for oss:

pure returnerer bare samme verdi:

pure = (a) -> new Reader((env) -> a)

reader gjør det litt enklere å lage en ny reader

reader = (f) -> new Reader(f)

Og så til rammeverket vårt

Vi lager en klasse som injecter avhengigheten inn i vårt program:

class ModelProvider
 constructor:(@model) ->
 apply:(pr) -> pr(@model)

Vi kan nå lage to ulike instanser av ModelProvider, en med testdata, og en som f.eks. henter data fra serveren:

testProvider = new ModelProvider(testModel)
serverProvider = new ModelProvider(serverConnectedModel)

Nå som vi har dette på plass kan vi ta en titt på programmet vårt igjen. Når vi ser nøye på det er det egentlig dårlig strukturert. Det er ikke mange kodelinjene, men det har allikevel ansvar for tre ulike oppgaver, nemlig å lese ut brukeren, endre navn på brukeren og til skrive tilbake den oppdaterte brukeren. La oss skille dette fra hverandre, slik at vi får en komponent som leser ut en bruker, en komponent som skriver den tilbake, og en komponent som endrer navn på en bruker:

getUser = (id) -> reader( (m) ->
 m.users.get(id))

setUser = (user) -> reader( (m) ->
 m.users.set(user.id, user))

changeName = (newName) -> (user) -> 
 user.with({name:newName})

getUser og setUser er nå gjenbrukbare komponenter som kan brukes i mange situasjoner. changeName er blitt en frittstående funksjon som endrer brukerens navn, og er nå helt frikoblet operasjonene som henter og lagrer data. Forretningslogikken vår (changeName) trenger nå ikke bry som om det som har med å hente fram en bruker å gjøre. Hvis getUser er robust, kan changeName være helt sikker på at den får inn en bruker og ingenting annet (null, undefined e.l.) , og man trenger ikke tenke på exceptions i denne koden.

Ferdig!

Det nye programmet vårt ser nå slik ut:

changeUsername = 
 getUser("123")
 .map(changeName("NewName"))
 .bind(setUser)

Vi kunne ha kjørt det med testdata først:

testProvider.apply(changeUsername)

Deretter med data fra serveren:

serverProvider.apply(changeUsername)

Vi kunne også ha valgt provider under kjøretid:

provider = if debug testProvider else serverProvider
provider.apply(changeUsername)

Vi har nå et lite og enkelt rammeverk for IoC, og styrken ligger i denne enkelheten. På serversiden unngår vi xml eller unødvendige systemer som  CDI, Spring, Guice eller lignende. Passer du på at objektene dine er uforanderlige i tillegg gjør du livet ditt (og dine medarbeidere) enda enklere.

For å teste fleksibiliteten kunne du f.eks. prøve å lage en modellimplementasjon som er asynkron med promises. Du vil oppdage at du kan la programmet ditt være akkurat det samme, med andre ord så har du full uavhengighet mellom forretningslogikken og rammeverket rundt.

Men...

Selv om en slik løsning kan virke elegant er det jo en del utfordringer: Det kan virke knotete å bruke map og bind for å sette sammen operasjoner, istedenfor å bare skrive statements etter hverandre. Språk som C# og Scala gjør dette relativt enkelt, men man må allikevel har tunga litt mer rett i munnen. Man får heller ingen automatikk.

Noen (inklusive meg) mener at dette er en god ting, men det krever en del erfaring for raskt å kunne komme i gang med nye prosjekter. 

Til slutt

For de som er Scalaentusiaster virker dette materialet kanskje kjent. Det er kraftig inspirert av Rúnar Óli Bjarnason sitt foredrag på Nescala 2012 [1]. Jeg kan anbefale å gå inn på Nescala-sidene og se på noen av foredragene der, det er veldig lærerikt uansett om man jobber med Scala, C# eller andre språk.

[1]: http://nescala.org/2012#t-4283131

Litt om forfatteren

Atle-Prange

Atle Prange

Allsidig utvikler innen Java, Scala og webrammeverk. Foretrekker enkle og robuste løsninger, og har en forkjærlighet for typesikkerhet og store datamengder.
comments powered by Disqus