Il y a 6 ans -
Temps de lecture 6 minutes
Pimp my Javaslang validator
Dès que nos logiciels communiquent avec l’extérieur nous avons le risque d’avoir des données erronées, soit de la part d’un utilisateur soit d’un logiciel externe.
Même si vous n’êtes pas adepte de la programmation défensive, il est toujours nécessaire de vérifier tous les champs d’un formulaire, vérifier les entrées lues à partir d’un fichier CSV et parser ce vieux XML qui traine depuis longtemps.
Nous allons donc vous montrer comment vous défendre des aggressions externes avec Javaslang.
Un problème métier
Imaginons que nous construisons un service Rest qui permet la création de personnes dans une base de données selon le modèle suivant :
[java]class Person {
private String name;
private String email;
private int age;
…
}[/java]
Avant de persister ces données dans la base, nous devons d’abord être sûrs qu’elles sont correctes. Par exemple, nous avons besoin de vérifier que le nom n’est pas vide, l’adresse mail suit une regex particulière, l’âge n’est pas négatif, etc. Donc, nous nous posons la question : comment faire pour vérifier qu’une instance de Person est valide ?
Une première approche
L’approche typique en Java nous amène à vouloir valider champ par champ (e.g. name, email, age) et lancer des exceptions dans les cas où les données ne seraient pas valides. Par exemple :
[java]boolean isValidPerson(String name, String email, int age) {
if (followsNameRules(name)) {
if (followsMailRules(email)) {
if (followsAgeRules(age)) {
return true;
} else {
throw new InvalidAgeException(age);
}
} else {
throw new InvalidMailException(email);
}
} else {
throw new InvalidNameException(name);
}
}[/java]
Malheureusement, cette implémentation est difficile à lire et à modifier, comporte une complexité cyclomatique élevée et ne permet pas d’accumuler les erreurs.
JSR-303
Un autre approche consiste à utiliser une implémentation de la JSR-303 (e.g. hibernate validator) et ajouter les contraintes directement dans nos attributes:
[java]public class Person {
@Size(min = 8, max = 16)
private String name;
@NotNull
private String email;
@DecimalMin(value="0")
@DecimalMax(value="120")
private int age;
…
}
…
Person aPerson = new Person("xxx", "toto@mail.com", -3);
Set<ConstraintViolation<Person>> violations = Validation.buildDefaultValidatorFactory()
.getValidator().validate(aPerson);[/java]
Cette idée reste une très bonne alternative pour des cas de validation simples. L’API fournit un ensemble limité d’annotations pour les cas typiques (e.g. ranges, nulls, etc).
Nous avons aussi la possibilité de faire nos propres validations. Cependant, nous trouvons rapidement des problèmes potentiels:
- Validation entre paramètres.
- Des appels aux services externes.
- La construction de validations custom reste un peu verbeux.
- L’activation/desactivation au choix.
Javaslang validator
L’alternative proposée par Javaslang consiste à utiliser la classe Validation dont il existe deux valeurs possibles : Valid et Invalid. Par exemple, pour valider le nom de la personne nous pouvons construire un validateur comme suit :
[java]private Validation<String, String> validateName(String name) {
return StringUtils.isNotEmpty(name)
? Validation.valid(name)
: Validation.invalid("Name must not be empty");
}[/java]
Avec cette technique, chaque validation est exécutée dans sa propre fonction, de type Function1<IN, Validation<ER, OU>> où:
- IN est le type de donnée d’entrée à valider, par exemple String pour l’email.
- OU est le type correspondant à une validation positive, par exemple String pour un email valide.
- ER, est le type d’erreur, par exemple String pour un message d’erreur, ou RuntimeException pour une exception.
Les données sont donc validées de manière individuelle, son résultat englobé dans une instance de Validation, puis chacune est combinée avec les autres pour avoir un résultat global :
[java]class PersonValidator {
public Validation<List<String>, Person> validatePerson(String name, String email, int age) {
return Validation
.combine(
validateName(name),
validateEmail(email),
validateAge(age))
.ap((validName, validEmail, validAge) -> new Person(validName, validEmail, validAge));
}
…
}[/java]
Grâce à cette technique nous sommes capables d’accumuler l’ensemble des erreurs rencontrées dans une instance de Person.
Mais…
Javaslang nous impose une limite inattendue : nous ne pouvons enchaîner que 8 validations avec combine, donc, le code suivant ne compile pas :
[java]Validation
.combine(
validateName(name),
validateEmail(email),
validateAge(age),
validateField4(param4),
validateField5(param5),
validateField6(param6),
validateField7(param7),
validateField8(param8),
validateField9(param9))
.ap(Person::buildFromParameters);[/java]
De plus, nous découvrons que :
- Nous sommes « obligés » d’avoir un constructeur ou une fabrique qui reçoit tous les paramètres dans le même ordre donné pour la fonction ap.
- La fonction ap reçoit un lambda avec l’ensemble des valeurs validées (Function3<String, String, Integer, T> pour les attributs name, email et age).
- Nous ne pouvons pas juste réutiliser un objet déjà construit (e.g. une instance de Person créée à partir d’une deserialization du Json) et nous devons le décomposer en attributes.
Des alternatives ?
Nous voudrions une autre implémentation qui nous aide à :
- Combiner un nombre « infini » de validations.
- Accumuler toutes les erreurs.
- Exécuter les validations de manière lazy et pouvoir déclencher les validations au choix.
- Ne pas être forcé de créer un objet à la fin des validations.
Ces besoins nous amènent au code suivant :
[java]Validation<List<String>, Person> validation =
new CommonValidation<String, Person>()
.combine(this::validateAge)
.combine(this::validateName)
.combine(this::validateEmail)
.apply(person);[/java]
Avec cette implémentation, nos validations retournent une instance de Person déjà validée, dont la signature est Function1<A, Validation<Err, A>> :
[java]private Validation<String, Person> validateName(Person person) {
return StringUtils.isNotEmpty(person.getName())
? Validation.valid(person)
: Validation.invalid("Name must not be empty");
}[/java]
Notre nouvelle implémentation est lazy, réutilisable, permet de combiner un nombre infini de validations et de ne pas créer un objet une fois les validations terminées.
Montre moi le code !
Mais d’abord, qu’est-ce que c’est que cette classe CommonValidation ?
C’est juste un utilitaire pour accumuler des fonctions de validation, les exécuter puis créer une instance de Valid si elles sont toutes valides ou Invalid le cas échéant.
[java]public class CommonValidation<ERR, OBJ> implements Function<OBJ, Validation<List<ERR>, OBJ>> {
private final List<Function<OBJ, Validation<List<ERR>, OBJ>>> functions = new ArrayList<>();
public CommonValidation<ERR, OBJ> combine(Function<OBJ, Validation<List<ERR>, OBJ>> func) {
functions.add(func);
return this;
}
@Override
public Validation<List<ERR>, OBJ> apply(OBJ obj) {
Objects.requireNonNull(obj);
if (functions.isEmpty()) {
return Validation.valid(obj);
} else {
List<Validation<List<ERR>, OBJ>> validations = functions.stream()
.map(f -> f.apply(obj))
.collect(Collectors.toList());
if (allAreValid(validations)) {
return validations.get(0);
} else {
List<ERR> allErrors = validations.stream()
.filter(Validation::isInvalid)
.flatMap(v -> v.getError().stream())
.collect(Collectors.toList());
return Validation.invalid(allErrors);
}
}
}
private boolean allAreValid(List<Validation<List<ERR>, OBJ>> validations) {
return validations.stream()
.filter(Validation::isValid)
.count() == validations.size();
}[/java]
où :
- OBJ, est le type à valider. Person pour cet exemple.
- ERR, est le type d’erreur à accumuler, String dans l’exemple.
Conclusions
Nous avons exploré la proposition de javaslang pour la validation des données, ensuite nous avons trouvé quelques scénarios pour lesquels nous arrivons aux limites de la classe Validation. Finalement, nous avons proposé une autre alternative pour combiner les validations de manière lazy.
Commentaire