Hopp til hovedinnhold

Teknologi / 8 minutter /

Validering av data, sånn helt generelt

Dette er kanskje starten på en serie med tips og triks for å sove bedre om natten når man releaser software. Først ut: validering av data, med vanilla java med full kontroll og fleksibilitet.

(Kode for denne artikkelen finner du her)

Validering

Ordet vekker minner hos enhver systemutvikler, vi kommer alle innom denne oppgaven fra tid til annen. Ofte forbinder man validering med å sjekke at data som kommer inn et grensesnitt har korrekt format, f.eks. at et telefonnummer består bare av siffer eller "+" eller at et navn er kun bokstaver. Noen ganger tenker man på validering når man sjekker at konfigurasjon er satt opp riktig. Andre ganger igjen brukes det for å fange opp feil der det blir upraktisk at typesystemet verner oss mot det, slik som deling på 0, eller at en verdi er null.

Hvis man skal se litt stort på det, kan man egentlig si at validering er både en sjekk på at en verdi tilfredsstiller en bestemt regel, og håndtering av situasjonen dersom den ikke gjør det.

Vi har hatt ulike verktøy som hjelper oss med dette i Java en stund nå, slik som det innebygde assert, eller Bean Validation. Med Java 8 kom også Optional, som flere bruker for å markere en verdi som manglende eller ugyldig. Alle disse tilnærmingene har fordeler, men også ulemper. I denne artikkelen skal vi se om vi kan finne en måte å utnytte fordelene med Optional-tilnærmingen, men uten ulempene.

Optional hjelper oss å sørge for at vi ikke gjør operasjoner som kaster NullPointerExceptions ved hjelp av map() og flatMap(), men når man ønsker å rapportere hvorfor det ikke lar seg opprette et objekt er det vanlig å se litt keitete logikk rundt Optional. Dette løses ofte med en orgie av exceptions.

1String username = getAsString("username").orElseThrow(()->new IllegalStateException("username not defined")); 
2Integer age = getAsInt("age").orElseThrow(()->new IllegalStateException("age not defined");
3

Eller så behandler man Optional som en litt brysom null:

1Optional<String> maybeUsername = getAsString("username");
2 
3if(username.isDefined()){
4    String username = maybeUsername.get();
5} else {
6    throw new IllegalStateException("username not defined");
7}

Men vi kan gjøre det bedre! Ville det ikke være bedre å kombinere Optional med feilrapportering - dog uten å måtte kaste exceptions hele tiden? Kunne man kanskje også samle alle feilene i en bolk dersom man skulle ønske dette? Og kunne man i tillegg unngå alle innrykkene som flatMap krever? Svaret er selvfølgelig ja.

La oss lage en klasse for dette. Vi kaller den Validated.

Først lager vi en halvformell definisjon av den nye typen vår, dette guider oss videre når vi skal implementere den: Validated<A> representerer et objekt som enten inneholder et objekt av typen eller inneholder en liste medfeilmeldinger.

I Java er det greit å representere denne dualiteten med et grensesnitt Validated <A> som har to implementasjoner: Valid<A> og Failed<A>.

Ok, hvordan skal vi så lage en Validated? Vi trenger i hvertfall to statiske factory-metoder, la oss kalle gi dem åpenbare navn Validated<T> valid(A a) og Validated<T> fail(String msg). Videre kan det være greit å kunne konvertere en Optional til en Validated: Validated<T> of(Optional<T> o, String onEmpty).

La oss se hvordan Validated kan erstatte Optional:

1Validated<String> username = Validated.of(getAsString("username"),"username not defined"));
2Validated<Integer> age = Validated.of(getAsInt("age"),"age not defined"));

Vi har kvittet oss med exceptions, men username og age er nå pakket inn i en Validated. Med Optional brukte vi flatMap for å pakke ut først den ene og så den andre, men det er knot så la oss se om vi får til å løse dette smooth. Vi kunne f.eks. laget en statisk metode som tar inn to Validated, og så - dersom begge er Valid - gjør innholdene tilgjengelige for oss samtidig. En slik metode kunne vært definert slik:

1/**
2 *  Accumulates the values of two Validated values. 
3 *  If both are Valid, the values are applied to the provided function, returning a Valid with the result of the application.
4 *  If either Validated is Fail, the Fail is returned. 
5 *  If both are Fail, their messages are append into a new Fail. 
6 */
7public static <A,B,C> Validated<C> accum(Validated<A> av,Validated<B> bv,BiFunction<A,B,C> combiner){...}
8

Vi utsetter implementasjonen av denne metoden til senere, la oss først se hvordan man kunne ha brukt den:

1Validated<String> username = Validated.of(getAsString("username"),"username not defined"));
2Validated<Integer> age = Validated.of(getAsString("age"),"age not defined"));
3 
4Validated<User> user = Validated.accum(username,age,User::new);

Ikke så verst! user inneholder nå enten alle feilmeldingene, eller et User objekt. Men man ser også et par åpenbar ulemper: Man trenger en ny statisk metode for hvert antall Validated objekt man ønsker å kombinere, og det finnes ikke noen standard @FunctionalInterface for funksjoner som tar inn mer enn to argumenter i java. Det første løser man ganske greit (sjekk koden i medfølgende eksempler), det andre løser man ved å importere et api som støtter dette, for eksempel functionaljava eller vavr.io

Nå som vi har bestemt oss for hvordan vi løser det å slå sammen flere valideringer, må vi finne ut hvordan vi løser problemet der en validering er avhengig av resultatet fra en annen validering. La oss utvide eksempelet over og anta at getAsString og getAsInt er definert på et Params objekt som lastes inn.

1interface Param{
2    Validated<String> getAsString(String name);
3    Validated<Integer> getAsInt(String name);
4}
5Validated<Param> params = loadParams();

Hvordan skal vi nå få ut username og age? La oss se igjen på hvordan man bruker en Optional for inspirasjon:

Dersom man skal manipulere et objekt i en Optional, uten å måtte sjekke om den er defined først, bruker vi map(). map() tar inn en funksjon som endrer innholdet. Denne funksjonen anvendes bare dersom Optional er defined, så dersom vi kaller map på en Optional som er empty, skjer det ingenting.

Dette virker fornuftig å ha på Validated også. Men Validated "inneholder" data i både Valid og Failed tilstandene, så vi må bestemme oss for hvilken tilstand map skal gjelde for. Siden det vanligvis ikke er så spennende å endre feilmeldinger bestemmer vi oss for at map skal gjelde Valid og bli ignorert ved Failed.

La oss skrive javadoc og signatur for map:

1/**
2* If the Validated is Valid, then this method return a new Validated with the function applied to its contents. If the Validated is
3* Failed, then it has no effect.
4*/
5<B> Validated<B> map(Function<A,B> function);

Så kan vi prøve på params:

1Validated<Param> params = loadParams();
2Validated<Validated<String>> username = params.map(p->p.getAsString("username"));
3

Hmmm. Typen ser riktig ut: Det er en validation av resultatet av en validation. Men det er upraktisk at det nøstet sånn. For å løse opp i dette kan vi se på hvilke tilstander Validated<Validated<String>> kan ha.

  1. Den ytre Validated er Fail
  2. Den ytre Validated er Valid som inneholder en Fail
  3. Den ytre Validated er Valid som inneholder en Valid

Vi har egentlig da bare to tilstander, enten to varianter av Fail, eller en variant av Valid. Dette kan vi utnytte ved å slå de to Fail situasjonene sammen. La oss definere flatMap, som først mapper og så slår de sammen etterpå (implementasjonen tar vi senere):

1public <B> Validated<B> flatMap(Function<A,Validated<B>> function);

Flatmap kalles ofte "bind" siden det i praksis "binder" to objekter av samme type sammen i rekkefølge, slik at den første blir evaluert først, og så den andre. La oss sjekke hvordan flatMap brukes.

1Validated<User> user = 
2    params.flatMap(p -> Validated.accum(p.getAsString("username"), p.getAsInt("age"), User::new));
3

Men vi kan dra den enda litt lenger! Hva om vi ønsker å sikre oss at alderen til brukeren alltid er mellom 0 og 150, slik at vi fanger opp åpenbare feil? Eller enda bedre: Hva om vi gjør dette til en regel som gjelder i hele systemet vårt? La oss lage en klasse Age som representerer alder.

1public class Age {
2    public final int value;
3     
4    private Age(int value) {
5        this.value = value;
6    }
7}

Legg merke til at konstruktoren er private! Vi vil nemlig ikke at man skal kunne opprette et Age-objekt uten først å ha sjekket om tallet er korrekt. Vi utvider Age litt ved å lage en factory-metode som returnerer en Validated<Age>:

1public class Age {
2    public final int value;
3     
4    private Age(int value) {
5        this.value = value;
6    }
7 
8    /**
9    * The only way to create an Age is through this method, thereby assuring that it is valid.
10    * @param value
11    * @return
12    */
13    public static Validated<Age> toAge(int value) {
14        return Validated.validate(value, v -> (v >= 0 && v < 150), " The age must be in the range [0,150)").map(Age::new);
15    }
16}

Den eneste måten å opprette et Age-objekt nå er gjennom factory-metoden, og den sjekker om verdien er gyldig og pakker Age inn i en Validation. Vi kan nå sammenfatte alt i et eksempel:

1public class ValidatedExample {
2 
3    public static void main(String[] args) {
4        var settings = Settings.empty();
5 
6        var username = settings.getAsString("username");
7        var age = settings.getAsInt("age").flatMap(Age::toAge);
8 
9        var user = Validated.accum(username, age, User::new);
10 
11        //Prints out a Fail with two messages
12        System.out.println(user);
13 
14        var settings2 = settings.with("age", 35).with("username", "Ola");
15        var username2 = settings2.getAsString("username");
16        var age2 = settings2.getAsInt("age").flatMap(Age::toAge);
17 
18        var user2 = Validated.accum(username2, age2, User::new);
19 
20        //Prints out a Valid user
21        System.out.println(user2);
22        }
23}

Sweet!

Uten noe særlig boilerplate kan vi nå:

  1. Kvitte oss med exceptions
  2. Samle feilmeldinger
  3. Nøste valideringer
  4. Slå sammen valideringer dersom alle er gyldige, eller summere feilmeldingene for eventuelle feil
  5. Være helt sikre på at objekter vi oppretter inneholder gyldige verdier

Og det beste er at vi kan bruke samme prinsipp overalt, og at vi kan bruke det på samme måte som Optional. Herlig :)

For å se selve implementasjonen av Validation er det greiest å bare se på koden til implementasjonen og eksempelet.

Det kan jo også hende at man istedet for å bare beholde en feilmelding har lyst til å lagre en liste med Exceptions. Da kan man beholde stacktracen også, som jo kan være veldig praktisk. Eller så ønsker man enda større frihet og vil bestemme fra gang til gang hva feil-tilstanden skal inneholde. Dette finnes heldigvis allerede implementert i en rekke biblioteker, f.eks. vavr.io eller functionaljava.com så jeg kan anbefale en titt der. Ellers så kan man ta koden fra eksempelet her og modde den etter eget behov.

Håper det var lærerikt og følg med! Det kommer mer!