Det er ikke til å stikke under en stol at det er en god del ting å tenke på når man implementerer
api-endepunkter. Det er mye å tenke på i forhold til feilhåndtering og forskjellige statuskoder. Det
er fort gjort å glemme å håndtere feil og/eller koden kan bli uoversiktlig eller vanskelig å lese. I denne bloggposten
viser vi eksempler på hvordan Arrow.kt og Either
kan hjelpe til med å gjøre rutehåndtererene dine robuste og
lettleste.
Arrow.kt
Arrow er et knippe biblioteker som gir deg tilgang til å skrive mer idiomatisk funksjonell kode i Kotlin.
I denne bloggposten skal vi bare kikke på en liten del av kjerne-biblioteket (arrow-core
). Vi skal kikke spesielt på typen Either
som et alternativ til å bruke Exceptions
.
¶Either sa du ?
I Java og forsåvidt Kotlin er det ganske vanlig å bruke Exceptions
for å signalisere feil. Det kan kanskje t.o.m. sies å
være idiomatisk i disse språkene. Det er ganske lettvint og kan fungere veldig bra i mange tilfeller. Ulempen med Exeptions
er
at det er ikke spesielt funksjonelt. I tillegg så endrer Exceptions
på kontrollflyten i programmet ditt.
En måte å tydelig signalisere at en funksjon kan feile på er å representere det i signaturen ved å returnere en type som angir
suksess eller feil. Nettopp til dette formålet kan vi bruke Either
!
Either
er konseptuelt definert somEither<Left, Right>
hvorLeft
signaliserer en eller annen feil mensRight
signaliserer en suksessverdi.
Så i stedet for:
fun getPerson(id: Int): Person {
val dbPerson = runPersonDBQuery(id) // might throw an SQLException
if(dbPerson == null) {
throw CustomNotFoundException("No person with id: $id exists")
}
return toPerson(dbPerson)
}
kan du med Either gjøre noe alla:
fun getPerson(id: Int): Either<MyCustomError, Person> {
val dbPersonResult = Either.catch {
runPersonDBQuery(id) // might still throw but we catch it into an Either now
}.mapLeft {ex -> MyCustomError.DBError(ex) } // map exception to our custom error type
return when(val dbPerson = dbPersonResult) {
is Either.Left -> dbPerson.value // return Either(.Left) with Error
is Either.Right -> {
if (dbPerson == null) {
Either.Left(MyCustomError.NotFound)
} else {
Either.Right(toPerson(dbPerson.value))
}
}
}
}
Urk! Dette var da fryktelig mye ekstra kode (riktignok skrevet mer verbost enn nødvendig for å forhåpentligvis være lettere å forstå).
Vi skal snart se på hvordan dette kan gjøres smudere, spesielt når man skal komponere sammen flere slike funksjoner.
Det vi har “vunnet” er at funksjonen vår ikke lenger kaster Exceptions
og funksjonen kommuniserer
tydelig hvilke type feil den kan returnere (via typen MyCustomError
, i dette tilfellet en sealed class
)
Hvorfor ikke bare bruke Kotlin sin innebygde
Result<T>
Det kan man forsåvidt fint gjøre, men ulempen med
Result
er at den ikke komponenerer spesielt bra med andreResult
verdier. Du blir også fortsatt nødt til å jobbe medExeptions
som feilverdi.Personlig mener jeg at
Result
er et bedre og tydeligere navn ennEither
, men den er ikke like kraftig.
¶Either blokk og DSL
I forrige eksempel så vi at det var en del overhead ved bruk av Either
. Either har
masse fine funksjoner som gjør det enklere å jobbe med de, slik som: map
, mapLeft
, fold
, flatMap
osv.
La oss først se om vi ikke kan gjøre getPerson
funksjonen litt mer kompakt.
fun getPerson(id: Int): Either<MyCustomError, Person> = Either.catch {
runPersonDBQuery(id)
}.mapLeft {ex -> MyCustomError.DBError(ex) }
.flatMap {maybeDbPerson ->
if (maybeDbPerson == null) {
Either.Left(MyCustomError.NotFound)
} else {
Either.Right(toPerson(dbPerson))
}
}
Dersom du jobber med flere Either
verdier i en funksjon finnes det en schnasen DSL
. La oss skrive om funksjonen
vi hadde over til å bruke litt av denne:
fun getPerson(id: Int): Either<MyCustomError, Person> = either { // 1
val dbPerson = Either.catch {
runPersonDBQuery(id)
}.mapLeft {ex -> MyCustomError.DBError(ex) }
.bind() // 2
ensure(dbPerson != null) { MyCustomError.NotFound } // 3
toPerson(dbPerson)
}
- Dersom du wrapper en funksjon med
either
får du muligheten til å jobbe med flereEither
verdier i sekvens. Så fort en av de returnerer enLeft
vil den bryte ut av blokken og returnere denneLeft
verdien bind
gir deg en av to ting. Dersom verdien er enEither.Left
skjønnereither
blokka det (se pkt 1.). Dersom det er enEither.Right
“pakker den ut” Either.Right verdien for deg. Slik at du kan jobbe med den som om den var en helt vanlig verdi.ensure
er litt fancy sukker som lar deg trigge enEither.Left
dersom angitt boolean sjekk returnererfalse
. Som bonus skjønner kompilatoren at verdien vår i dette tilfellet definitivt ikke ernull
etter sjekken er gjort.
Det begynner å likne på noe som er ganske lettlest. Det oppslaget mot database er fortsatt litt meh
dog, men det kan vi pynte
på ved f.eks å lage en liten generisk hjelpe-funksjon som pakker inn kall til database-funksjoner.
Rutehåndtering
Da har vi snakket mye om Either, men har ikke sett snurten av rutehåndtering enda. La oss prøve oss på
å vise hvordan en typisk rutehåndterer kan se ut uten/men Either
i Ktor.
Forøvrig er mye av dette ikke Ktor
-spesifikt og kan fint brukes i andre web-rammeverk/web-biblioteker.
¶En relativt vanlig rutehåndterer ?
put("person/{personID}") {
val personID = call.parameters["personID"]?.toIntOrNull()
if (personID == null) {
call.respond(HttpStatusCode.BadRequest)
} else {
val putPerson = call.receiveNullable<AddPerson>()
if (putPerson == null) {
call.respond(HttpStatusCode.BadRequest)
} else {
val errors: List<String> = PersonHandler.validateOldSchool(putPerson)
if (errors.isNotEmpty()) {
call.respond(
HttpStatusCode.BadRequest,
RouteError.BadRequest(errors.joinToString(","))
)
} else {
val existingPerson =
using(sessionOf(ds)) { session ->
Queries.findPersonByID(session, personID)
}
if (existingPerson == null) {
call.respond(
HttpStatusCode.NotFound,
RouteError.NotFound("Person with id: $personID not found"),
)
} else {
val updatedPerson =
using(sessionOf(ds)) { session ->
Queries.updatePerson(session, personID, putPerson)
}
call.respond(updatedPerson)
}
}
}
}
}
Dette fungerer jo helt fint altså. Dersom du får exceptions så vil det fanges opp og det returneres en ServerError
.
Andre feil-tilfeller håndterer vi eksplisitt. Dog var det ganske mye nøsting her. “Jukser man litt” og returnerer
tidlig (ved å bruke return@
i Ktor
) kan man kvitte seg med mye av dette.
Sånt juks ville jo ødelegge litt av den klassiske før/etter sammenlikningen. Det kan vi vel ikke ha noe av.
¶Rutehåndterer med Arrow
put("person/{personID}") {
val res = either<RouteError, Person> {
val personID = call.requestParamInt("personID").bind()
val putPerson = call.bodyObject<AddPerson>().bind()
PersonHandler.validateAdd(putPerson).bind()
withSession(ds) { session ->
Queries.findPersonByID(session, personID)
}.mapNotNull("Person with id: $personID not found")
.bind()
withTx(ds) {tx ->
Queries.updatePerson(tx, personID, putPerson)
}.bind()
}
call.respondEither(res)
}
Vi kan vel alle være enige om at denne varianten er lettere å lese. Den største bøygen er å
skjønne hvordan either
og bind
spiller sammen. Så lenge alt er ok flyter alt sekvensielt, men så fort
det møter på en feil (dvs Either.Left
så vil den bryte ut av blokka og returnere denne).
Ok så er det litt mer juks involvert her. Ktor sin
ApplicationCall
har jo ikke noen funksjoner som returnererEither
.Either
har ikke noenmapNotNull
funksjon heller. FunksjonenenwithSession
ogwithTx
er også innført.
Vi har laget noen generiske hjelpe funksjoner og extension-functions
som er hendige på tvers av alle være rutehåndterer.
Det er kanskje ikke noe poeng å vise alle, men la oss kikke raskt på en av de.
fun ApplicationCall.requestParamInt(name: String): Either<RouteError, Int> = either {
ensureNotNull(parameters[name]?.toIntOrNull()) {
RouteError.BadRequest("Parameter $name is required and must be an Int")
}
}
Her har vi lagt til en funksjon på ApplicationCall
for å hente ut en obligatorisk navngitt Int
parameter.
Dersom den ikke finner den eller ikke er en Int
returnerer vi en Either.Left
med en feil som typisk
skal resultere i en BadRequest
http kode (evt med payload).
Sjekk ut denne lille kista for å se resten av “juksekoden”.
Noen refleksjoner helt til slutt
Vi har nå fått litt kjennskap til Arrow.kt
og Either
. Ved å bruke Either
har vi innført
strukturert og funksjonell feilhåndtering som et alternativ til f.eks Exceptions
. Det går an å få
ganske pen kode, og man er ikke nødt til å skrive om all koden sin. Ei heller trenger man å bruke Either
overalt.
Med litt hjemmelaget sukker i tillegg så ble rutehåndterene våre ganske lette å lese. De er robuste og håndterer
feil på en eksplisitt og tydelig måte.
I Kotlin kan man velge å skrive koden sin et sted mellom ganske/stort sett funksjonell til veldig lite funksjonell.
Trekker man inn Arrow.kt
i prosjektet sitt, er det verdt å tenke igjennom hva teamet ditt tenker om funksjonell
programmering og hvordan det vil påvirke resten av kodebasen din, vedlikehold, onboarding av nye osv.
Basert på erfaring fra noen prosjekter nå, synes jeg personlig at det er en liten sweet-spot for rutehåndterere. Dog
prøver jeg å være forsiktig med å innføre Arrow.kt
dersom jeg usikker på om resten av teamet er med på det.
Dersom du synes
Arrow.kt
virker spennende og har lyst til å lære om hvordanArrow.kt
kan hjelpe deg med manipulering av nøstede datastrukture anbefaler jeg å ta en kikk på Manipulering av ikke muterbare datastrukturer.