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:

SOLID z przykładami w TypeScript

SOLID (Single responsibility principle, Open/closed principle, Liskov substitution principle, Interface segregation principle, Dependency inversion principle), czyli pięć zasad programowania obiektowego, które każdy powinien przestrzegać.

Sprawdź ten wpis

Jak zostać architektem oprogramowania?

Aby projektować dobrą architekturę oprogramowania trzeba najpierw zaznajomić się z jej przesłankami, rodzajami, a także sposobami wdrażania. Architektura nie jest przecież czymś stałym, co wszędzie implementowane jest w jednakowy sposób.

Sprawdź ten wpis

Kompozycja zamiast dziedziczenia (composition over inheritance)

Czym jest podejście composition over inheritance? Dzisiaj o tym, dlaczego każdy powinien przemyśleć czy dziedziczenie klas w jego projekcie jest naprawdę potrzebne. Prawdopodobnie powinieneś ograniczyć dziedziczenie klas na rzecz ich kompozycji.

Sprawdź ten wpis

Command-Query Separation (CQS)

Command-Query Separation, czyli zasada o rozdzielaniu zadań metod tak, aby były jedynie komendami lub zapytaniami. Jeżeli Twoja metoda realizuje logikę i zwraca wyniki, lub podczas pobierania danych wykonuje side taski, to łamie ona CQS.

Sprawdź ten wpis

Prawo Demeter (The Law of Demeter)

Omawiam dzisiaj mało znaną zasadę, której stosowanie skutkuje posiadaniem łatwo utrzymywalnego i testowalnego kodu. Mowa o prawie demeter, które w najprostszym ujęciu zakłada, że obiekty powinny operować jedynie na najbliższym im otoczeniu.

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.