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. 

  • Walidacja tylko po stronie frontendu to zdecydowanie za mało.
  • Pominięcie walidacji przyjmowanych danych może skończyć się fatalnie (na pewno się skończy).
  • Nie ma złotego środka w kwestii sposobu walidacji, który zależy od technologii w jakiej pracujemy.
  • Implementując mechanizm odbioru i sprawdzania danych wejściowych pamiętajmy, że kiedyś możemy chcieć zmienić użyte rozwiązanie.
Autor wpisu

blog@orbisbit.com

Komentarze

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Sprawdź również
  • CQRS – Command Query Responsibility Segregation

    Command Query Responsibility Segregation czyli CQRS. Jest to wzorzec projektowy, który rozdziela zadania odczytu i zapisu do osobnych modeli. Sprawdź ten wpis, aby dowiedzieć się kiedy i jak z niego skorzystać.

    Zobacz wpis

  • GRASP – kolejny zbiór zasad Clean Code do zapamiętania

    Pewnie większość z Was słyszała o zasadach SOLID. Są one bardzo rozpowszechnione i dosyć często stosowane, ale czy słyszeliście o GRASP? General Responsibility Assignment Software Patterns, to kolejna dawka zasad czystego kodu do zapamiętania.

    Zobacz wpis

  • Wzorzec strategia (strategy pattern)

    Jeżeli masz dość if-ologii 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.

    Zobacz wpis