Advanced custom bean validation

Szinte minden komolyabb alkalmazás fejlesztése során sarkalatos pont, hogy miként kezeljük programunk funkcionalitásának szempontjából fontos adatainkat.
Felmerülhetnek itt olyan kérdések, hogy hol tároljuk őket (adatbázisban, vagy mondjuk valamilyen struktúrált fájl formátumban, például xml), hogyan tároljuk le őket (például adatbázis esetén JDBC, vagy valamilyen ORM keretrendszer), hogyan biztosítsuk a letárolt adataink védelmét és integritását, de téma lehet az archiválás, vagy akár az utólagos feldolgozás és még sok más egyéb.

Mi most közelebbről az adatok validációját fogjuk megvizsgálni. A ‘tiszta' adatok gyakorta előfeltételei a program helyes működésének, tehát nem árt már ‘születésük' pillanatától erőfeszítéseket tennünk ennek érdekében. A gondolat természetesen nem újszerű, mitöbb JSR szabvány is létezik erre, konkrétan JSR-303, mely a JavaEE 6-os verziójától kezdve már integráltan érkezik. Ahogy az lenni szokott, a szabványnak létezik referencia implementációja is, ez pedig a Hibernate Validator. Cikkünk során igyekszünk csak szabványos megoldásokat használni. A konkrét implementációk rendelkez(het)nek kiterjesztésekkel ehhez képest.

Keretrendszeri lehetőségek

Nem meglepő módon, ha mindössze a szabványos validációs lehetőségekkel szeretnénk élni, a dolog ujjgyakorlat szintjére egyszerűsödik.

@Entity
@Table(name="user")
public class User extends implements Serializable {

private static final long serialVersionUID = -353317929900858714L;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

@Column(name="username")
@NotNull
@Size(min = 4, max = 256)
private String username;

@Column(name="password")
@Size(min = 8, max = 32)
private String password;

@Transient
private String confirmPassword;

@Column(name="email")
@Size(min = 2, max = 128)
@Pattern(regexp="[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}")
protected String email

}


A példában használt JPA annotációkra most nem térünk ki külön, amúgy is viszonylag magától értetődőek. Mindössze a @Transient lehet érdekes, amivel is a confirmPassword mező szeretné elkerülni az ORM keretrendszer figyelmét.

Ami a validációs keretrendszer annotációit illeti (@NotNull, @Size, @Pattern, stb) ezeken kívül nincs is más dolgunk. Persze annotációnként külön attribútumokkal általában még szabályozhatunk egy-két dolgot, akit ez konkrétabban érdekel, például itt utána nézhet.
Ezek után vagy a validációs motor programozott megszólításakor (lásd alább), vagy pedig az entitásunk perzisztálásakor automatikusan megtörténhet a deklarált kritériumok ellenőrzése.

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set constraintViolations = validator.validate(bean);

Custom validatorok

Mint minden jó keretrendszerben, természetesen itt is van lehetőségünk kiterjeszteni a funkcionalitást, vagyis írhatunk saját validációs annotációkat. Ez már nem annyira kézenfekvő, és nem árt ha egy kicsit tisztában vagyunk az annotációk működésével, lelki világával. Egy @FiledMatch példán keresztül próbáljuk meg most ezt szemléltetni, amivel is garantálni tudjuk majd, hogy a megadott jelszó, illetve annak ellenőrző mezejében megadott jelszó megegyeznek.

@Target({TYPE, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = FieldMatchImpl.class)
@Documented
public @interface FieldMatch {

String message() default "{constraints.fieldmatch}";
Class[] groups() default {};
Class[] payload() default {};

String first();
String second();

@Target({TYPE, ANNOTATION_TYPE})JPA
@Retention(RUNTIME)
@Documented
@interface List {
FieldMatch[] value();
}
}


Feltételezve, hogy mostanra mindent tudunk az annotációkról, vegyük sorra a még mindig szokatlan részeket. A @Constraint annotáció szintén a validációs keretrendszerrel érkezik, és definiálja az annotációnk implementációs osztályát, vagyis ami a tényleges ellenőrzést megvalósítja. A message(), groups() és payload() metódusok keretrendszeri előírások, melyek a hibaüzenetet, validációs csoportot, és metainformációkat hordozó objektumokat kezelik.

A first() és second() metódusok már a saját elképzelésünk származékai, egész pontosan a két mező nevét fogják hordozni, melyek egyezőségét meg szeretnénk követelni.
A FieldMatch[] value() metódus kicsit trükkös, ezzel ugyanis egyfajta önhivatkozást viszünk a rendszerbe, lehetővé téve, hogy egy annotáció struktúrával több mezőpárosra vonatkozó követelményt is le tudjunk írni, ahogy ezt majd a konkrét példában látni is fogjuk.

public class FieldMatchImpl implements ConstraintValidator {

private String firstFieldName;
private String secondFieldName;

@Override
public void initialize(final FieldMatch constraintAnnotation) {
firstFieldName = constraintAnnotation.first();
secondFieldName = constraintAnnotation.second();
}

@Override
public boolean isValid(final Object value, final ConstraintValidatorContext context) {
try {
final Object firstObj = value.getClass().getMethod("get" + WordUtils.capitalize(firstFieldName)).invoke(value);
final Object secondObj = value.getClass().getMethod("get" + WordUtils.capitalize(secondFieldName)).invoke(value);
boolean isValid = firstObj == null && secondObj == null || firstObj != null && firstObj.equals(secondObj);

if(!isValid) { //Custom constraint violation
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("password fields have to match").addNode("password").addConstraintViolation();
}
return isValid;
}
catch (final NoSuchMethodException ignore) {//TODO: log error}
catch (final InvocationTargetException ignore) {//TODO: log error}
catch (final IllegalAccessException ignore) {//TODO: log error}

return true;
}
}


Lássuk mi is történik az implementációs osztályban. Miután az inicializálás során megszerezzük a két mező nevét, a törzs metódusban reflection segítségével elvégezzük az előírt ellenőrzést. A példa az érdekesség kedvéért bemutatja a //Custom constaint violation által kommentezett szekcióban, hogy miként írhatjuk felül az annotáció default (interfészben definiált) viselkedését, saját hibaüzenettel például. Persze itt komolyabb, szerteágazóbb funkcionalitást is implementálhatunk, amíg azt az API lehetővé teszi.

Végül pedig lássuk hogyan változik az entitás osztályunk ezek fényében:

@Entity
@Table(name="user")
@FieldMatch.List({
@FieldMatch(first = "password", second = "confirmPassword", message = "passwordFieldsMustMatch")
})
public class User extends CustomValidableEntity implements Serializable {

}

A @FieldMatch.List mutatja a már említett önhivatkozást, ahol a listában további FieldMatch példányokat tart(hat)unk nyilván vesszővel elválasztva. Természetesen az itt felsorolt követelmények egymástól függetlenül lesznek kiértékelve a keretrendszer által, egymás viselkedését, eredményét nem befolyásolják.

Határon innen és túl

A végére pedig álljon itt egy érdekes probléma, feszegetve a technológia korlátait.

Adott az entitásunk, a definiált validációs szabályokkal, megfejelve jó néhány saját validációs kritériummal, és az ellenőrzést hagyjuk automatiksan triggerelődni a entitás perzisztálása során. Azt reméljük ettől, hogy ezáltal a megfelelő helyen elkapva az összes validációs kivételt (melyek keretrendszeri működésből kifolyólag tartalmazni fogják az összes kritérium sértést az adott objektumunk adataira vonatkoztatva) elegánsan kezelhetjük majd azokat a kód egy jól meghatározott, újra felhasználható, központi részén.

Igen ám, de tegyük fel, hogy szeretnénk egy olyan validációs kritériumot, mely például ellenőrzi egy felhasználói név egyediségét egy háttér adatbázisra vonatkoztatva.

Az első probléma ezzel, hogy az annotációk tipikusan light-weight megoldások, nem túl elegáns adatbázis hozzáférést programozni beléjük, nem is beszélve az ennek során felmerülő nehézségekről és az így születő irtózat hackkekről.
Megkerülhető persze a dolog azzal, hogy kivesszük az ellenőrzést a keretrendszer kezéből, és a ‘manuális' check után dobunk egy (akár custom) ConstraintViolationException-t, illeszkedve ezáltal a központi kivétel kezelő logikához. Ezzel azonban jön a második probléma, hogy a kivétel megszakítja a normál végrehajtási szekvenciát, a perzisztálás soha nem történik meg, így az objektum által definiált többi validációs kritérium sem kerül ellenőrzésre. Mondhatjuk persze hogy ez nem gond, ha nem zavar minket az, hogy a user a második kísérlete során - ezúttal helyes username megadásával - fogja megkapni a fennmaradó validációs hibákat, “két kört futva ezáltal a pályán”.

Erre a problémára javasolunk az alábbiakban egy megoldást, azzal a széljegyzettel, hogy ha bárki jobb és/vagy elegánsabb, esetleg bármilyen implementációban beépített lehetőséget tud erre, az ossza meg velünk legyen olyan jó.

Bevezetünk egy helper osztályt, melyet aztán majd kiterjesztünk azon entitásokkal, ahol erre szükségünk lesz. A @Transient annotációk fontosak, hogy az új attribútumok ne zavarjanak bele a perzisztálás folyamatába. A customConstraintViolationOccured attribútum @AssertFalse annotációja pedig egy igen elegáns módja a koncepció rendszerbe illesztésének. Ugyanis ezek után bárhol/bármikor/bárhogyan elvégezhetünk tetszőleges ellenőrzéseket az entitás életciklusa során, ahol ha hiba lépett fel, mindössze True értéket adunk ezen attribútumnak, ami máris valós kritérium sértést fog indukálni a perzisztálás során. Hogy aztán miként kezeljük az így keletkezett kivételt, már ránk van bízva, mindenestre a példában szemléltetett módon kényelmesen utaztathatunk custom ConstraintViolation-öket akár magában az entitásban is, hogy a megfelelő helyen feldolgozzuk őket.

public class CustomValidableEntity {

@Transient
@AssertFalse
private boolean customConstratinViolationOccured = false;

@Transient
private List constraintViolations = new ArrayList();

public void addConstraiontViolation(ConstraintViolation constraintViolation) {
constraintViolations.add(constraintViolation);
}

public List getConstraintViolations() {
return constraintViolations;
}

public void clearConstraintViolations() {
constraintViolations.clear();
}

public boolean isCustomConstratinViolationOccured() {
return customConstratinViolationOccured;
}

public void setCustomConstratinViolationOccured(boolean customConstratinViolationOccured) {
this.customConstratinViolationOccured = customConstratinViolationOccured;
}
}