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 som Either<Left, Right> hvor Left signaliserer en eller annen feil mens Right 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 andre Result verdier. Du blir også fortsatt nødt til å jobbe med Exeptions som feilverdi.

Personlig mener jeg at Result er et bedre og tydeligere navn enn Either, 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)
}
  1. Dersom du wrapper en funksjon med either får du muligheten til å jobbe med flere Either verdier i sekvens. Så fort en av de returnerer en Left vil den bryte ut av blokken og returnere denne Left verdien
  2. bind gir deg en av to ting. Dersom verdien er en Either.Left skjønner either blokka det (se pkt 1.). Dersom det er en Either.Right “pakker den ut” Either.Right verdien for deg. Slik at du kan jobbe med den som om den var en helt vanlig verdi.
  3. ensure er litt fancy sukker som lar deg trigge en Either.Left dersom angitt boolean sjekk returnerer false. Som bonus skjønner kompilatoren at verdien vår i dette tilfellet definitivt ikke er null 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 returnerer Either. Either har ikke noen mapNotNull funksjon heller. Funksjonenen withSession og withTx 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 hvordan Arrow.kt kan hjelpe deg med manipulering av nøstede datastrukture anbefaler jeg å ta en kikk på Manipulering av ikke muterbare datastrukturer.