Funkcyjna wieloetapowa walidacja kolekcji

0

Cześć

Klepię enterprise cruda w C#, jednak temat nie jest ściśle związany z technologią, dlatego piszę w tym dziale. Mam kilka typów kolekcji obiektów importowanych z zewnętrznego systemu, z którego każdy musi zostać poddany walidacji i zmapowany na model domenowy. Szukam eleganckiego sposobu na zapisanie tego.
Walidacja jest podzielona na kilka etapów mogących się składać z kilku operacji, np. walidacji pojedynczych pól. Pseudokod który z grubsza chciałbym osiągnąć:

List<ImportModel> inputData;
List<Either<List<Error>, DomainModel>> outputData = inputData
  .ValidateFieldA()
  .ValidateFieldB()
  .StopIfAnyErrors()
  .ValidateSomeComplicatedDependenciesBetweenItems()
  .StopIfAnyErrors()
  .ValidateMoreComplicatedDependencies()
  .StopIfAnyErrors()
  .MapToDomainModel()

Próbowałem zrobić to za pomocą funkcyjnych typów danych z biblioteki LanguageExt, jednak nie potrafię sobie poradzić z eleganckim uzyskaniem kilku operacji na jednym etapie walidacji tak, żeby błąd na jednym polu nie zatrzymywał walidacji. Czy ma ktoś wskazówki jak najlepiej to osiągnąć?

2

Posłużę się vavr.io za przykład, bo już go parę razy widziałem, a z drugiej strony kod Scalowy pewnie sporo osób by nie zrozumiało.

Najpierw trzeba się skierować w stronę applicative functors zamiast monad, bo jak napisano na http://www.vavr.io/vavr-docs/#_validation :

The Validation control is an applicative functor and facilitates accumulating errors. When trying to compose Monads, the combination process will short circuit at the first encountered error. But 'Validation' will continue processing the combining functions, accumulating all errors. This is especially useful when doing validation of multiple fields, say a web form, and you want to know all errors encountered, instead of one at a time.

Interfejs io.vavr.control.Validation ma metody combine oraz buildery które pozwalają zmieścić do 8 parametrów w jednym Validation, a więc jeśli ktoś chce zwalidować więcej parametrów naraz to musi sobie zrobić hierarchię konstruktorów/ metod fabrykujących z mniejszą ilością jednoczesnych parametrów. Interfejs ten posiada też bardzo pomocną metodę statyczną: https://static.javadoc.io/io.vavr/vavr/0.9.3/io/vavr/control/Validation.html#sequence-java.lang.Iterable- służącą do zamiany kolekcji obiektów typu Validation na jeden obiekt Validation zawierający kolekcję błędów, albo kolekcję poprawnie zwalidowanych elementów.

Jeśli mamy np klasy:

class InnerA { int a1, a2; }
class InnerB { String b1, b2; }
class Composite { InnerA a; InnerB b; }

To kod do walidacji może wyglądać np tak:

var result = Validate.combine(
  Validate.combine(myValidateInt(i1), myValidateInt(i2)).ap((int1, int2) -> { var o = new InnerA(); o.a1 = int1; o.a2 = int2; return o; }),
  Validate.combine(myValidateString(s1), myValidateString(s2)).ap((string1, string2) -> { var o = new InnerB(); o.b1 = string1; o.b2 = string2; return o; })
).ap((innerA, innerB) -> { var o = new Composite(); o.a = innerA; o.b = innerB; return o; });

Sorry za brzyki kod, ale siedzę w robocie :)

3
mad_penguin napisał(a):

Próbowałem zrobić to za pomocą funkcyjnych typów danych z biblioteki LanguageExt, jednak nie potrafię sobie poradzić z eleganckim uzyskaniem kilku operacji na jednym etapie walidacji tak, żeby błąd na jednym polu nie zatrzymywał walidacji. Czy ma ktoś wskazówki jak najlepiej to osiągnąć?

Pytanie zasadnicze - co ma dać ta "funkcyjna wieloetapowa walidacja kolekcji", czego nie może zrobić FluentValidation?

Wibowit napisał(a):

Sorry za brzyki kod, ale siedzę w robocie :)

Spoko, nikogo nie dziwi, że w robocie też ładnie nie piszesz. :P

2

Pytanie zasadnicze - co ma dać ta "funkcyjna wieloetapowa walidacja kolekcji", czego nie może zrobić FluentValidation?

Rzucanie exceptionów nie jest funkcyjne.

1

Polecam przeczytać sobie jak to jest zrobione w Ecto.Changeset (Elixirowa biblioteka do DB i nie tylko). Masz tam dane, które na początku opakowujesz sobie w strukturę (w tym wypadku Ecto.Changeset) i następnie możesz sobie zdefiniować co ma zostać sprawdzone, kod tylko sprawdza, nie modyfikuje danych, więc wszystko "przechodzi" tylko dodaje nowe błędy do listy. Na samym końcu zwyczajnie możesz sobie sprawdzić listę błędów i jak jest pusta, to się udało.

1
Wibowit napisał(a):

Pytanie zasadnicze - co ma dać ta "funkcyjna wieloetapowa walidacja kolekcji", czego nie może zrobić FluentValidation?

Rzucanie exceptionów nie jest funkcyjne.

Ale tam nie ma rzucania wyjątków, walidator zwraca wynik z listą błędów: https://github.com/JeremySkinner/FluentValidation#example

Po prostu nie widzę powodu, dla którego lepsze byłoby pisanie swojego rozwiązania zamiast użycia standardu w branży.

1

Ale tam nie ma rzucania wyjątków, walidator zwraca wynik z listą błędów: https://github.com/JeremySkinner/FluentValidation#example

OK. Mea culpa. Zbyt szybko rzuciłem okiem (taka wada siedzenia w za małym robocie :) ). Myślałem, że reguły wstawia się do konstruktora, a tymczasem trzeba naklepać całą osobną klasę z walidatorem.

Sposób działania tego FluentValidation jest, hmm.... dość osobliwy. Najpierw tworzymy pełny (potencjalnie niepoprawny) obiekt, a dopiero potem go walidujemy. W całej reszcie podanych rozwiązań jest odwrotnie - najpierw walidujemy dane, a potem dopiero konstruujemy poprawny obiekt.

Walidacja obiektu po jego skonstruowaniu ma pewne wady, np:

  • wartości wrzucone do obiektu muszą dać się wyciągnąć z poziomu walidatora - z tego wynika, że enkapsulacji nie ma, więc możemy walidować sobie głównie klasy wartościowe (aczkolwiek kolejny punkt pokazuje, że nawet to niekoniecznie),
  • jeśli chcemy np parsować sobie konfigurację i wyciągnąć inta z tego konfiga to nie mamy możliwości zakomunikowania, że np brakuje potrzebnej wartości w konfigu, albo że ma złą zawartość (np jest "ala ma kota" zamiast liczby). Trzeba więc najpierw wykryć niewłaściwe dane (czyli je zwalidować), potem jakoś to zamienić na inta (np int = -1 oznacza brak wartości, int = -2 oznacza coś nieparsowalnego, itd), a potem zdekodować to z powrotem w walidatorze. Zamieniając błędy na inta nie tylko dorzucamy sobie niepotrzebnie roboty, ale także ograniczamy sobie zakres inta (w naszym przypadku -1 i -2 nie są już poprawnymi wartościami w konfigu). Zamiast parsowania konfiga można tutaj wstawić parsowanie np CSVki, XMLa, danych z formularza, etc
  • jest dziwna :)
  • walidator w FluentValidation dalej nie jest funkcyjny (dla przykładu w konstruktorze walidatora mamy festyn efektów ubocznych) - w tym przypadku nie ma to dużego znaczenia dla używalności (chyba), ale jak ktoś chce poćwiczyć programowanie funkcyjne to jest to kiepski wybór

Spróbuję napisać mój poprzedni przykład w ładniejszy sposób, ale niestety z nielubianymi przez Jarka adnotacjami :) Lombokowymi do automatycznego robienia konstruktorów

@Data 
class InnerA { final int a1, a2; }
@Data
class InnerB { final String b1, b2; }
@Data
class Composite { final InnerA a; InnerB b; }

var result = Validate.combine(
  Validate.combine(myValidateInt(i1), myValidateInt(i2)).ap(InnerA::new),
  Validate.combine(myValidateString(s1), myValidateString(s2)).ap(InnerB::new)
).ap(Composite::new);

Jest znacznie lepiej, jak widać :]

1
Wibowit napisał(a):

Sposób działania tego FluentValidation jest, hmm.... dość osobliwy. Najpierw tworzymy pełny (potencjalnie niepoprawny) obiekt, a dopiero potem go walidujemy. W całej reszcie podanych rozwiązań jest odwrotnie - najpierw walidujemy dane, a potem dopiero konstruujemy poprawny obiekt.

Owszem, jeśli tworzymy sobie jakieś wewnątrzsystemowe obiekty (np. encje DDD), to w ogóle nie chcemy dopuścić do utworzenia obiektu w niepoprawnym stanie, więc walidację zrobimy np. w fabryce czy konstruktorze i FluentValidation nam w tym nie pomoże i raczej głupotą byłoby na siłę go używać.

Ale w ogólności tu nie ma nic osobliwego. Do znacznej części aplikacji dane wpadają po prostu z zewnątrz i są automatycznie deserializowane (czy to z requestu HTTP, czy z jakiegokolwiek formatu wymiany z innymi systemami typu JSON) na jakieś DTO (oczywiście mutowalne i bez enkapsulacji, bo to DTO). FluentValidation służy do walidacji tego typu danych.
Ja zakładam, że skoro Mam kilka typów kolekcji obiektów importowanych z zewnętrznego systemu, to właśnie o takim przypadku jest tutaj mowa.

  • walidator w FluentValidation dalej nie jest funkcyjny (dla przykładu w konstruktorze walidatora mamy festyn efektów ubocznych) - w tym przypadku nie ma to dużego znaczenia dla używalności (chyba), ale jak ktoś chce poćwiczyć programowanie funkcyjne to jest to kiepski wybór

Owszem nie jest. Pytanie tylko, czy tu się liczy cel, czy ćwiczenie, albo czy ktoś nie próbuje rozwiązać problemu XY.

Jest znacznie lepiej, jak widać :]

Pewnie tak. https://www.techopedia.com/definition/3848/not-invented-here-syndrome-nihs

1

Owszem, jeśli tworzymy sobie jakieś wewnątrzsystemowe obiekty (np. encje DDD), to w ogóle nie chcemy dopuścić do utworzenia obiektu w niepoprawnym stanie, więc walidację zrobimy np. w fabryce czy konstruktorze i FluentValidation nam w tym nie pomoże i raczej głupotą byłoby na siłę go używać.

FluentValidation nie pomoże, ale Validation z vavr.io z powodzeniem by się sprawdziło także i w takim przypadku (o ile oczywiście potrzebowaliśmy mieć listę wszystkich problemów naraz).

Ale w ogólności tu nie ma nic osobliwego.

Ja pierwszy raz widzę bibliotekę, której podaje się gotowy złożony i potencjalnie niespójny obiekt do walidacji, zamiast konstruowania obiektu po sprawdzeniu poprawności. Może to dlatego, że kodzę w Scali :)

Do znacznej części aplikacji dane wpadają po prostu z zewnątrz i są automatycznie deserializowane (czy to z requestu HTTP, czy z jakiegokolwiek formatu wymiany z innymi systemami typu JSON) na jakieś DTO (oczywiście mutowalne i bez enkapsulacji, bo to DTO). FluentValidation służy do walidacji tego typu danych.

Trzeba było od razu napisać, że FluentValidation jest tak sztywny, że nadaje się tylko do sztywnych DTOsów. Chociaż moim zdaniem nawet w takim zastosowaniu może być słabo. Weźmy dla przykładu formularz webowy, który ma dwa pola: rok urodzenia, bilans konta. Rok urodzenia musi być między (aktualny rok - 100), a (aktualny rok - 20). Bilans konta może być dowolną liczbą rzeczywistą. Jak może wyglądać DTO zdeserializowane jakimś automatycznym deserializerem? Widzę dwie opcje:

  • DTO zawiera same nieprzekonwertowane Stringi. Zaletą jest to, że jego stworzenie zawsze się powiedzie, a więc jedyne błędy to te raportowane przez walidatora FluentValidation. Wadą jest to, że konwersja typów na rok bądź liczbę rzeczywistą musi się odbyć dwurotnie - raz w walidatorze, a drugi raz przy korzystaniu z danych z DTOsa.
  • DTO zawiera inta, floata, BigDecimala, etc Zaletą jest to, że konwersja danych jest w jednym miejscu. Wadą jest to, że błędy mogą się pojawić w dwóch momentach - jeden to moment deserializacji danych do DTO, drugi to moment walidacji za pomocą FluentValidate. Dowolny błąd deserializacji danych pozbawi nas możliwości raportowania o np błędach z walidatora, a więc lipa. Np użytkownik w polu rok wpisze 1600, a w polu bilans konta wpisze "ala ma kota". Zobaczy tylko jeden błąd - nie udało się zdeserializować DTO, bo "ala ma kota" nie jest liczbą. Nie ma DTO, nie ma walidacji DTO, więc nie ma też komunikatu, że 1600 nie jest akceptowalnym rokiem urodzenia.

Przy użyciu Validate z vavr.io nie ma takich dylematów - walidacja odbywa się w tym samym kroku co wyciąganie danych i konwersja ich typów. Dowolny błąd skutkuje dołączeniem go do listy błędów, nie rozwala procesu walidacji jako całości.

Ja zakładam, że skoro Mam kilka typów kolekcji obiektów importowanych z zewnętrznego systemu, to właśnie o takim przypadku jest tutaj mowa.

Przy założeniu, że struktura jest sztywna to FluentValidation wystarczy. Jeśli jednak np na wejściu mam XMLa z danymi kontraktu walutowego, a na wyjściu chcę dostać zwalidowaną podklasę klasy Trade (możliwe podklasy to np CDS, IRS, Future, Option, etc - mają różne zestawy pól) to muszę XMLa walidować krok po kroku i na podstawie części zwalidowanych danych (np nagłówka) decydować które dane w następnego kolejności walidować i które obiekty (tzn której podklasy) konstruować. FluentValidation tego nie ogarnie.

Pewnie tak. https://www.techopedia.com/definition/3848/not-invented-here-syndrome-nihs

Podejrzewam, że funkcyjna walidacja była wymyślona ponad 50 lat temu w LISPie, podobnie jak większość funkcyjnych wynalazków.

1
Wibowit napisał(a):

Ja pierwszy raz widzę bibliotekę, której podaje się gotowy złożony i potencjalnie niespójny obiekt do walidacji, zamiast konstruowania obiektu po sprawdzeniu poprawności.

Tyle, że to nie jest obiekt w sensie OOP. To jest DTO, czyli po prostu rekord, taki prosty pojemnik na dane.

Może to dlatego, że kodzę w Scali :)

Może. Ale w Javie chyba DTO widziałeś? I chyba nawet Spring potrafi zrobić takie DTO z danych, które w HTTP reqeście przyszły.
Z drugiej strony popatrzyłem na biblioteki do czytania CSV w Javie i dwa pierwsze wyniki z Google pokazały twory, które aczkolwiek potrafią deserializować do fasolek, to jednak wspierają też czytanie wierszy do kolekcji stringów. Widzę, że podejście stringly typed wciąż trzyma się mocno.

Trzeba było od razu napisać, że FluentValidation jest tak sztywny, że nadaje się tylko do sztywnych DTOsów.

A vavr jest tak sztywny, że nawet messageboxa nie potrafi narysować na ekranie, do tego nie generuje SQL. :P
To jest oczywiste, że biblioteka robi to, do czego jest przeznaczona, a nie coś innego i przy okazji wszystko pozostałe.

  • DTO zawiera same nieprzekonwertowane Stringi. Zaletą jest to, że jego stworzenie zawsze się powiedzie

To jest tak smutne, że powinni ten pomysł zekranizować, kobiety lubią wyciskacze łez, więc ruch przed walentynkami byłby spory.

  • DTO zawiera inta, floata, BigDecimala, etc Zaletą jest to, że konwersja danych jest w jednym miejscu. Wadą jest to, że błędy mogą się pojawić w dwóch momentach - jeden to moment deserializacji danych do DTO, drugi to moment walidacji za pomocą FluentValidate. Dowolny błąd deserializacji danych pozbawi nas możliwości raportowania o np błędach z walidatora, a więc lipa. Np użytkownik w polu rok wpisze 1600, a w polu bilans konta wpisze "ala ma kota". Zobaczy tylko jeden błąd - nie udało się zdeserializować DTO, bo "ala ma kota" nie jest liczbą. Nie ma DTO, nie ma walidacji DTO, więc nie ma też komunikatu, że 1600 nie jest akceptowalnym rokiem urodzenia.

To już zależy od tego jak zaimplementowana jest deserializacja. Może np. zwracać własną listę błędów, albo wstawiać domyślną wartość oznaczającą błąd, którą następnie walidator wykryje i zwróci swój error.

Przy użyciu Validate z vavr.io nie ma takich dylematów - walidacja odbywa się w tym samym kroku co wyciąganie danych i konwersja ich typów. Dowolny błąd skutkuje dołączeniem go do listy błędów, nie rozwala procesu walidacji jako całości.

Mnie osobiście nie bardzo podoba się takie łączenie odpowiedzialności. Oddzielny walidator ma tę zaletę, że zadziała na DTO pochodzących z dowolnego źródła, więc może być łatwo reużywany.

Przy założeniu, że struktura jest sztywna to FluentValidation wystarczy. Jeśli jednak np na wejściu mam XMLa z danymi kontraktu walutowego, a na wyjściu chcę dostać zwalidowaną podklasę klasy Trade (możliwe podklasy to np CDS, IRS, Future, Option, etc - mają różne zestawy pól) to muszę XMLa walidować krok po kroku i na podstawie części zwalidowanych danych (np nagłówka) decydować które dane w następnego kolejności walidować i które obiekty (tzn której podklasy) konstruować. FluentValidation tego nie ogarnie.

Owszem, nie ogarnie. Bo jak nazwa wskazuje służy do walidacji, nie deserializacji. Jeśli mamy sytuację, w której potrzebujemy własnego deserializatora, to można od razu go wyposażyć także w walidację. Jeśli dane są proste i istnieją wbudowanych deserializatorów do nich, to pisanie własnego nie ma sensu.

1

To już zależy od tego jak zaimplementowana jest deserializacja. Może np. zwracać własną listę błędów, albo wstawiać domyślną wartość oznaczającą błąd, którą następnie walidator wykryje i zwróci swój error.

O tym dokładnie wcześniej pisałem:

jeśli chcemy np parsować sobie konfigurację i wyciągnąć inta z tego konfiga to nie mamy możliwości zakomunikowania, że np brakuje potrzebnej wartości w konfigu, albo że ma złą zawartość (np jest "ala ma kota" zamiast liczby). Trzeba więc najpierw wykryć niewłaściwe dane (czyli je zwalidować), potem jakoś to zamienić na inta (np int = -1 oznacza brak wartości, int = -2 oznacza coś nieparsowalnego, itd), a potem zdekodować to z powrotem w walidatorze. Zamieniając błędy na inta nie tylko dorzucamy sobie niepotrzebnie roboty, ale także ograniczamy sobie zakres inta (w naszym przypadku -1 i -2 nie są już poprawnymi wartościami w konfigu). Zamiast parsowania konfiga można tutaj wstawić parsowanie np CSVki, XMLa, danych z formularza, etc

Jeśli chodzi o to:

Mnie osobiście nie bardzo podoba się takie łączenie odpowiedzialności. Oddzielny walidator ma tę zaletę, że zadziała na DTO pochodzących z dowolnego źródła, więc może być łatwo reużywany.

W praktyce: YAGNI. Nie widziałem potrzeby takiego re-użycia. Za to połączenie wyciągania danych, konwersji typów i sprawdzenia warunków w jednym małym kroku oznacza proste raportowanie kompletu błędów (uprzedzając pytania: tak, w projekcie w firmie jest to wykorzystane i tak, da się osiągnąć zamierzony efekt na wiele innych sposobów).

Temat w sumie wyczerpany. Każdy sobie wybierze co mu pasuje.

1

A nie dałoby tego jakoś tak +- zrobić?

var obj = new ComplexObj
{
    Prop1 = 25,
    Prop2 = "Troll"
};

var result = new CustomValidator(obj)
.Validate(x => x.Prop2 == "Noob", "incorrect string")
.Validate(x => x.Prop1 >= 18, "not old enough")
.StopIfAnyErrors()
.Validate(x => x.Prop1 == -1, "error")
.StopIfAnyErrors()
.MapToDomainModel();
public class CustomValidator
{
    private readonly List<string> _errors = new List<string>();
    private readonly ComplexObj _data;
    private bool failed = false;

    public CustomValidator(ComplexObj data)
    {
        _data = data;
    }

    public CustomValidator Validate(Func<ComplexObj, bool> requirement, string error)
    {
        if (!failed)
        {
            if (!requirement.Invoke(_data))
            {
                _errors.Add(error);
            }
        }
        return this;
    }

    public CustomValidator StopIfAnyErrors()
    {
        if (_errors.Any())
        {
            failed = true;
        }
        return this;
    }

    public (ComplexMappedObj?, List<string>) MapToDomainModel()
    {
        if (failed)
        {
            return (null, _errors);
        }
            
        return
        (
            new ComplexMappedObj
            {
                Prop1 = _data.Prop1,
                Prop2 = _data.Prop2
            }, _errors
        );
    }
}

public class ComplexObj
{
    public int Prop1 { get; set; }
    public string Prop2 { get; set; }
}

public class ComplexMappedObj
{
    public int Prop1 { get; set; }
    public string Prop2 { get; set; }
}

1 użytkowników online, w tym zalogowanych: 0, gości: 1