
Slik validerer jeg data i Java
Validering av data i vanilla Java, med full kontroll og fleksibilitet. đ
"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.
String username = getAsString("username").orElseThrow(()->new IllegalStateException("username not defined"));
Integer age = getAsInt("age").orElseThrow(()->new IllegalStateException("age not defined");
Eller sÄ behandler man Optional som en litt brysom null:
Optional<String> maybeUsername = getAsString("username");
Â
if(username.isDefined()){
    String username = maybeUsername.get();
} else {
    throw new IllegalStateException("username not defined");
}
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.
Men vi kan gjĂžre det bedre!
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 A 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:
Validated<String> username = Validated.of(getAsString("username"),"username not defined"));
Validated<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:
/**
 * Accumulates the values of two Validated values.
 * If both are Valid, the values are applied to the provided function, returning a Valid with the result of the application.
 * If either Validated is Fail, the Fail is returned.
 * If both are Fail, their messages are append into a new Fail.
 */
public static <A,B,C> Validated<C> accum(Validated<A> av,Validated<B> bv,BiFunction<A,B,C> combiner){...}
Vi utsetter implementasjonen av denne metoden til senere, la oss fĂžrst se hvordan man kunne ha brukt den:
Validated<String> username = Validated.of(getAsString("username"),"username not defined"));
Validated<Integer> age = Validated.of(getAsString("age"),"age not defined"));
Â
Validated<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.
interface Param{
    Validated<String> getAsString(String name);
    Validated<Integer> getAsInt(String name);
}
Validated<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:
/**
* If the Validated is Valid, then this method return a new
* Validated with the function applied to its contents. If the
* Validated is Failed, then it has no effect.
*/
<B> Validated<B> map(Function<A,B> function);
SÄ kan vi prÞve pÄ params:
Validated<Param> params = loadParams();
Validated<Validated<String>> username = params.map(p->p.getAsString("username"));
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.
- Den ytre Validated er Fail
- Den ytre Validated er Valid som inneholder en Fail
- 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):
public <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.
Validated<User> user =
    params.flatMap(p -> Validated.accum(p.getAsString("username"), p.getAsInt("age"), User::new));
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.
public class Age {
public final int value;
private Age(int value) {
this.value = value;
}
}
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>:
public class Age {
public final int value;
private Age(int value) {
this.value = value;
}
/**
* The only way to create an Age is through this method, thereby assuring that it is valid.
* @param value
* @return
*/
public static Validated<Age> toAge(int value) {
return Validated.validate(value, v -> (v >= 0 && v < 150), " The age must be in the range [0,150)").map(Age::new);
}
}
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:
public class ValidatedExample {
public static void main(String[] args) {
var settings = Settings.empty();
var username = settings.getAsString("username");
var age = settings.getAsInt("age").flatMap(Age::toAge);
var user = Validated.accum(username, age, User::new);
//Prints out a Fail with two messages
System.out.println(user);
var settings2 = settings.with("age", 35).with("username", "Ola");
var username2 = settings2.getAsString("username");
var age2 = settings2.getAsInt("age").flatMap(Age::toAge);
var user2 = Validated.accum(username2, age2, User::new);
//Prints out a Valid user
System.out.println(user2);
}
}
Sweet!
Uten noe sÊrlig boilerplate kan vi nÄ:
- Kvitte oss med exceptions
- Samle feilmeldinger
- NĂžste valideringer
- SlÄ sammen valideringer dersom alle er gyldige, eller summere feilmeldingene for eventuelle feil
- 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 pÄ bloggen vÄr!