Dane wejściowe systemu i ich walidacja

Jak poprawnie przeprowadzić proces walidacji przychodzących danych? Na co uważać podczas wyboru mechanizmu sprawdzającego? w niniejszym wpisie poruszone zostały te i inne problemy związane z koniecznością kontroli wpływających informacji.


Idea walidacji

Czym byłby system, który nie zbiera, bądź nie przetwarza pewnego rodzaju danych. W większości przypadków rola backendu to udostępnienie pewnego API, za pomocą którego klienci będą mogli zlecać przetworzenie, zapisanie czy manipulację danymi. Mam nadzieję, że dla każdego programisty oczywistym faktem jest to, że informacje przychodzące trzeba zawsze sprawdzić, bo jaki jest sens tworzenia oprogramowania, które przyjmuje pewne błędne dane, ślepo wierząc swoim klientom, a następnie ponosząc tego konsekwencje w postaci błędnego działania logiki. Tak, jak zawsze, realizacja zadania zależeć będzie od technologii, frameworka czy użytej biblioteki. Walidacja może odbywać się za pomocą dekoratorów, zbioru reguł, schematu walidacji czy jeszcze innych form. 

Sprawdzanie danych przy użyciu dekoratorów

W technologii w której pracuję, sposób ten jest bardzo często wykorzystywany. Taka klasa danych wejściowych jest zazwyczaj automatycznie tworzona na podstawie zapytania przychodzącego, a sam proces walidacji wyzwalany jest w tle, dzięki dekoratorom. Daje to dużą prostotę i małą ilość kodu potrzebną do stworzenia spójnego sposobu odbioru i walidacji danych. Metoda ta niesie za sobą jednak wiele niebezpieczeństw. Po pierwsze łamie ona zasadę pojedynczej odpowiedzialności, gdyż pełni rolę zarówno walidatora, jak i pewnego rodzaju obiektu transferowego. Dodatkowo wyobraźmy sobie sytuację, że w przyszłości, w już działającym systemie, musimy zmienić bibliotekę odpowiedzialną za walidację i zrezygnować z dekoratorów. Musimy wtedy przeroboić dosłownie wszystkie tego typu klasy oraz najczęściej kod, który z nich korzysta. Jest to idealny przykład na to, iż jeżeli coś ułatwia nam pracę to niesie ze sobą jakieś ryzyko. Niemniej jednak, uważam że przy małych projektach, wdrożeniach MVP czy proof of concept, nie musimy martwić się o wady tego rozwiązania, a jedynie cieszyć się z jego prostoty i czytelności. 

export class CalculateYearIncomeRequest {
  @Min(2000)
  @Max(new Date().getFullYear())
  year: number;

  @IsUUID()
  companyId: string;
}

export class IncomesController {
  ...
  
  async calculateYearIncome(@Body() request: CalculateYearIncomeRequest): Promise<YearIncomeResponse> {
    ...
  }
}

Formularz (Form Class)

Jeżeli obawiamy się sytuacji o których wspomniałem, możemy użyć walidacji opartej na regułach czy schemacie. Do tego, w zależności, jak bardzo poprawni chcemy być, jesteśmy w stanie dołożyć trochę abstrakcji.  W poniższym przykładzie używamy podejścia Form Class i mimo, że nie jesteśmy jeszcze do końca poprawni, ponieważ wciąż w jakimś stopniu łamiemy zasadę pojedynczej odpowiedzialności, to rozwiązuje to część naszych problemów. Jesteśmy zabezpieczeni na wypadek ewentualnej chęci zmiany biblioteki czy podejścia. Nie będziemy wtedy musieli zmieniać kodu używającego formularzy walidujących. Konieczna będzie jedynia zmiana reguł / schematu oraz metody validate()

export abstract class Form {
  protected schema = Joi.object();

  protected dataValidated = false;

  validate(): void {
    const { error } = this.schema.validate(this, { 
      allowUnknown: true, 
      presence: 'required' 
    });

    if (error) {
      throw new ValidationError(error.message);
    }
    
    this.dataValidated = true;
  }
  
  getData(): any {
    const { schema, options, dataValidated, ...data } = this;

    if (!dataValidated) {
      throw new RuntimeException('Cannot get not validated data.');
    }
    
    return data;
  }
}

export type CalculateYearIncomeRequest = {
  year: string;
  companyId: string;
}

export class CalculateYearIncomeForm extends Form {
  year: string;
  companyId: string;
  
  protected schema = Joi.object().keys({
    year: Joi.number.min(2000).max(new Date().getFullYear()),
    companyId: Joi.string().guid(),
  });
}

export class IncomesController {
  // ...
  
  async calculateYearIncome(@Body() form: CalculateYearIncomeForm): Promise<YearIncomeResponse> {
    form.validate();
    
    const data = form.getData();    
    
    // ...
  }
}
Co jednak zrobić, aby być bardziej fancy w kwestii walidacji? Należałoby rozdzielić funkcję sprawdzania poprawności do osobnego bytu, nazwanego np. walidatorem. Byłaby to klasa wstrzykiwana, jako zależność, na której moglibyśmy odwoływać się np. do metody validate, która jako parametr przyjmowałaby dane do zwalidowania.

export interface ValidatorInterface {
  validate(data: any): void;
}

export type CalculateYearIncomeRequest = {
  year: string;
  companyId: string;
}

export class CalculateYearIncomeRequestValidator implements ValidatorInterface { 
  private schema = Joi.object().keys({
    year: Joi.number.min(2000).max(new Date().getFullYear()),
    companyId: Joi.string().guid(),
  });
  
  validate(data: CalculateYearIncomeRequest): void {
    const status = this.schema.validate(data);

    if (status.error) {
      throw new ValidationError(status.error.message);
    }
  }
}

Walidacja oparta na schemacie (Validation Schema)

Dobrym wyjściem byłoby także użycie abstrakcyjnej klasy walidatora, która zawierałaby logikę odpowiadającą za proces sprawdzania, lub też, chcąc spełniać podejście Composite over Inheritance, możemy wyizolować schemat walidacji do osobnych plików i wstrzykiwać je do klasy walidującej, w zależności od kontekstu - tak, aby nie powielać kodu. W naszych rozwiązaniach króluje ta metoda sprawdzania danych, ponieważ nie łamie kluczowych zasad, jest łatwa w utrzymaniu, a ewentualna zmiana podejścia byłaby tylko minimalnie odczuwalna.

export const yearIncomeRequestValidationSchema = Joi.object().keys({
  year: Joi.number.min(2000).max(new Date().getFullYear()),
  companyId: Joi.string().guid(),
});

export class Validator implements ValidatorInterface {  
  validate(data: any, schema Joi.Schema): void {
    const status = schema.validate(data);

    if (status.error) {
      throw new ValidationError(status.error.message);
    }
  }
}

Podsumowując

Każdy powinien znaleźć swój złoty środek w kwestii przyjęcia i sprawdzenia danych, w zależności od technologii w której pracuje. Pragnę jeszcze dodać, iż w celu ograniczenia ilości kodu, pominąłem dodatkowe interfejsy dla klas czy użycie getterów. 





Polecane wpisy:

Wzorzec stan (state)

Dziś prezentuję kolejny wzorzec projektowy, który pozwala uniknąć drabinek if-else if. Mowa tutaj o wzorcu state, który idealnie nada się, jeśli posiadasz różne stany w swoim systemie oraz chcesz mieć możliwość płynnego przechodzenia pomiędzy nimi.

Sprawdź ten wpis

Wzorzec pełnomocnik (virtual proxy)

Wzorzec virtual proxy służy do tworzenia obiektów pośrednich, które nadzorują dostęp do obiektów dla których są pełnomocnikami. Dzięki temu, że pełnomocnik i klasa bazowa udostępniają jednakowy interfejs, możemy ukryć w pośredniku dodatkową logikę.

Sprawdź ten wpis

Wzorzec strategia (strategy pattern)

Jeżeli masz dość ifologii w swoim kodzie, to konieczne sprawdź czym jest czynnościowy wzorzec projektowy strategia. Pozwala on mądrze obsługiwać różne scenariusze w procesie i jednocześnie być fancy pod względem zasad SOLID.

Sprawdź ten wpis

Wzorzec adapter

Strukturalny wzorzec projektowy adapter to spore udogodnienie w walce z systemami legacy. Pozwala użyć niepasującego interfejsu w innym. Dzięki temu zabiegowi pomimo, iż nie posiadamy spójnej abstrakcji możemy zastosować pożądaną logikę.

Sprawdź ten wpis

Wzorzec dekorator

Strukturalny wzorzec projektowy dekorator, to lekarstwo na problemy z mnogością różnych podtypów. Pozwala on na dynamiczne generowanie kolejnych typów obiektów bazujących na pierwotnym typie, bez konieczności deklarowania nowych, dedykowanych klas.

Sprawdź ten wpis

Autor wpisu:

Gabriel Ślawski

Fanatyk czystego i prostego kodu. Zwolennik podejść DDD oraz Modular Monolith. Na codzień pracuje jako programista i architekt. Po godzinach spełnia się w projektach open source, udziela się na blogach oraz czyta książki o kosmosie i astrofizyce.