Walidacje DSL: Operatorzy

To jest część 3 z 4-częściowej serii.

Operatory zapewniają wartość walidacji właściwości jako pojedynczej jednostki: proszę pomyśleć o wieloczęściowej kontroli warunkowej w instrukcji if. Najbardziej oczywistymi operatorami są logiczne AND (&&) i OR (!!), choć możliwe są również inne operatory.

Wdrażanie operatorów

Operatory są same w sobie walidatorami, implementującymi to samo fun validate(): Boolean jako walidatory właściwości, ale zamiast tego oceniają jeden lub więcej walidatorów w celu określenia ogólnego sukcesu lub porażki, np. wszystkie przechodzą walidację dla AND lub co najmniej jeden przechodzi walidację dla OR.

AbstractOperator

Każdy zaimplementowany operator rozszerza AbstractOperator aby zapewnić poprawność equals oraz hashCode istnieją metody podobne do AbstractPropertyValidator. Zamiast getter dostarczony podczas budowy, operator otrzymuje kolekcję PropertyOperatorValidatorprzeciwko którym operator jest stosowany.

abstract class AbstractOperator<S> (
    protected val conditionName: String,
    protected val validators: List<PropertyOperatorValidator<S>>) :
    AbstractValidator<S>() {

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as AbstractOperatorValidator<*>

        if (conditionName != other.conditionName) return false

        return true
    }

    override fun hashCode(): Int {
        return Objects.hash(conditionName)
    }
}

Walidatory właściwości i operatory implementują PropertyOperatorValidator poprzez AbstractValidator który pozwala operatorowi na rekurencyjną ocenę operatorów, podobnie jak w przypadku używania nawiasów do definiowania warunków podrzędnych w ogólnym warunku.

AndOperator/OrOperator

Operatory logiczne są konstruowane z następującymi parametrami

  • conditionName: opisowa nazwa celu lub zamiaru operatora;
  • validators: zbiór walidatorów, które są oceniane przez operatora;
  • errorMessage: komunikat o błędzie podany w ConstraintViolation; w przeciwnym razie naruszenia ograniczeń są tworzone dla każdego ocenianego walidatora.

Komunikat o błędzie specyficzny dla operatora często zapewnia lepszy kontekst niż indywidualne komunikaty dla każdego nieudanego warunku, takie jak Wymagane zarówno imię, jak i nazwisko. zamiast firstName wymagane; lastName wymagane.

class AndOperator<S>(conditionName: String,
                     validators: List<PropertyValidator<S>>,
                     val errorMessage: String? = null) :
    AbstractOperator<S>(conditionName, validators) {

    override fun validate(
        source: S,
        errors: MutableSet<ConstraintViolation<S>>): Boolean {

        val errorsToUse =
            if (errorMessage.isNullOrBlank())
                errors
            else
                mutableSetOf()

        val success = validators.all {
            it.validate(source, errorsToUse)
        }

        if (!success && !errorMessage.isNullOrBlank()) {
            addViolation(
                source,
                errorMessage,
                errorMessage,
                conditionName,
                null,
                errors)
        }

        return success
    }
}

Jedyna zmiana wymagana do wdrożenia OrOperator jest to, że tylko jeden walidator musi przejść walidację.

val success = validators.any {
    it.validate(source, errorsToUse)
}

Jak to wszystko połączyć?

Na przykład planowanie społeczne Invitee jest osobą zapraszaną. Stan osoby zaproszonej to INVITED, ACCEPTED, lub DECLINED.

data class Invitee {
   val state: InviteeState,
   val firstName: String?,
   val lastName: String?,
   val emailAddress: String,
   val howMany: Int?
}

Akceptując lub odrzucając zaproszenie, użytkownik podaje swoje imię i nazwisko. W przypadku zaakceptowanych zaproszeń wskazuje, ile osób będzie w nich uczestniczyć (np. sama zaproszona osoba, partner lub małżonek, dzieci, przyjaciele itp.) W przeciwnym razie opcjonalne właściwości nie są wymagane.

Ogólna implementacja programowania

val invitee = getInvitee(...)
if (invitee.state == InviteeState.INVITED) return true

if (invite.firstName.isNullOrBlank() || invite.lastName.isNullOrBlank()) {
    log.warn("First and last name required.")
    return false
}

if (invitee.state == InviteeState.ACCEPTED && howMany :? 0 <= 0) {
    log.warn("Must specify how many people expected to attend.")
    return false
}

return true

Rozwiązanie języka specyficznego dla domeny

val invitee = getInvitee(...)

// howMany required when invitation accepted
val accepted = OrOperator(
    conditionName = "acceptedHowMany",
    errorMessage = "Must specify number of attendees when accepting.",
    validators = setOf(

        EnumNotEqualsValidator(
          propertyName = "state",
          getter = Invitee::stage,
          value = InviteeState.ACCEPTED),
        PositiveIntegerValidator(
          propertyName = "howMany", 
          getter = Invitee::howMany)
   )
)

// Invitee must provide first/last name when accepting/declining invite
val responded = OrOperator(
    conditionName = "inviteReply",
    errorMessage = "First and last name required when accepted/declined.",
    validators = setOf(

        EnumNotEqualsValidator(
          propertyName = "state",
          getter = Invitee::stage,
          value = InviteeState.INVITED
        ),

        AndOperator (
          conditionName = "allPresent",
          errorMessage = null,
          validators = setOf(
            StringNotBlankValidator("firstName", Invitee::firstName),
            StringNotBlankValidator("lastName", Invitee::lastName),
          )
       )
   )
)

// Both of the above must be true
val operator = AndOperator (
   propertyName = "inviteeValidation",
   validators = setOf (accepted, responded)
)

// Validate the complete operator
val violations = mutableSetOf<ConstraintViolation<T>>()
operator.validate(invitee, violations)

// empty collection means successful validation
val successfullyValidated = violations.isEmpty()

Porównanie

Chociaż ogólna implementacja jest krótsza, czy jest to lepsza implementacja? Niektóre zalety walidacji za pomocą DSL to:

  • Poprawność: Nazwy walidatorów (powinny) wyraźnie identyfikować sposób walidacji właściwości. Rzeczywista walidacja jest zaimplementowana raz dla wszystkich przypadków użycia, a nie zaimplementowana ad-hoc, co zmniejsza pokrycie testami. Komunikaty o błędach operatora zapewniają kontekst dla wymagań biznesowych i przypadków użycia. Brak możliwości wprowadzenia efektów ubocznych do walidacji.
  • Spójność: Implementacja jest implementacją, nie ma różnic wszędzie tam, gdzie potrzebna jest konkretna walidacja: jeśli Apache Common LangStringUtils jest używana raz, to Apache Common Lang StringUtils jest używana zawsze. Pozwala to uniknąć niespójności i błędów spowodowanych używaniem podobnych bibliotek w różnych częściach bazy kodu.
  • Reużywalność: Zdefiniuj raz, użyj wielu: utwórz walidację we wspólnej bibliotece, do której można uzyskać dostęp w razie potrzeby. Preferowane zamiast wycinania i wklejania, powielania implementacji lub wymuszania niewygodnej hierarchii klas w celu zapewnienia wspólnego dostępu.
  • Czytelność: Prawie niezależny od języka, walidacja jest zrozumiała dzięki zrozumieniu sposobu tworzenia DSL, a nie sposobu pisania kodu. Wymaga mniejszego zrozumienia jakiegokolwiek konkretnego języka programowania JVM, na granicy samodokumentowania.
  • Ad-Hoc: Walidacje just-in-time tworzone programowo bez złożoności manipulacji kodem bajtowym poprzez ASM lub coś podobnego.

Uwagi końcowe

Operatory DSL pozwalają nam implementować bardziej złożone i użyteczne walidatory, znacznie wykraczające poza to, co jest możliwe w przypadku adnotacji/walidatorów na poziomie właściwości (np, @NotBlank, @NotNull, @Emailitp.). Ostatnim krokiem jest owinięcie tego prawdziwym Jakarta Bean Validator który może być użyty do walidacji kompletnej fasoli.

Image © 1991 Scott C Sosna