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!
Eitherer konseptuelt definert somEither<Left, Right>hvorLeftsignaliserer en eller annen feil mensRightsignaliserer 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
Resulter at den ikke komponenerer spesielt bra med andreResultverdier. Du blir også fortsatt nødt til å jobbe medExeptionssom feilverdi.Personlig mener jeg at
Resulter 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
eitherfår du muligheten til å jobbe med flereEitherverdier i sekvens. Så fort en av de returnerer enLeftvil den bryte ut av blokken og returnere denneLeftverdien bindgir deg en av to ting. Dersom verdien er enEither.Leftskjønnereitherblokka 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.ensureer litt fancy sukker som lar deg trigge enEither.Leftdersom angitt boolean sjekk returnererfalse. Som bonus skjønner kompilatoren at verdien vår i dette tilfellet definitivt ikke ernulletter 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
ApplicationCallhar jo ikke noen funksjoner som returnererEither.Eitherhar ikke noenmapNotNullfunksjon heller. FunksjonenenwithSessionogwithTxer 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.ktvirker spennende og har lyst til å lære om hvordanArrow.ktkan hjelpe deg med manipulering av nøstede datastrukture anbefaler jeg å ta en kikk på Manipulering av ikke muterbare datastrukturer.
