SOLID, KISS i DRY

SOLID, KISS i DRY to jedne z najpopularniejszych pojęć/skrótów wśród programistów. Są to na tyle kluczowe zagadnienia oraz przydatne w zadaniach programisty, że nie mogło ich zabraknąć na moim blogu.

Wszystkie przykłady w tym artykule zostały napisane w TypeScripcie. Jeśli jednak TypeScript jest Ci obcy, to przykłady w dalszym ciągu powinny być dla Ciebie zrozumiałe.

Czym jest SOLID?

SOLID to akronim, który można rozwinąć w pięć zasad programowania obiektowego:

  1. Single Responsibility Principle – Zasada jednej odpowiedzialności
  2. Open/Closed Principle – Zasada otwarte-zamknięte
  3. Liskov Substitution Principle – Zasada podstawienia Liskov
  4. Interface Segregation Principle – Zasada segregacji interfejsów
  5. Dependency Inversion Principle – Zasada odwrócenia zależności

Zasady SOLID zostały przedstawione po raz pierwszy przez Roberta C. Martina (Uncle Bob) w artykule Design Principles and Design Patterns z 2000 roku. Sam akronim SOLID został opracowany nieco później przez Michaela Feathersa.

Zanim przejdę do omówienia poszczególnych zasad, chciałbym podkreślić fakt że reguły SOLID to nie jest coś czego należy bezwzględnie się trzymać, niezależnie od okoliczności. Może zdarzyć się tak, że w pewnych przypadkach pewne reguł SOLID nie znajdą zastosowania lub okażą się przerostem formy nad treścią – co za dużo to niezdrowo. Osobiście traktuję programowanie według zasad SOLID’a jako istotną rekomendację, ale nie jako obowiązek.

S jak Single Responsibility Principle

Zasada jednej odpowiedzialności, jak sama nazwa wskazuje, mówi o tym, że klasa lub moduł powinny mieć tylko jedną odpowiedzialność. Można również powiedzieć, że klasa lub moduł powinny mieć jeden powód do zmiany. Mówiąc jeszcze prościej, klasa lub moduł powinny robić tylko i wyłącznie jedną rzecz.

Aby zrozumieć na czym polega ta reguła przeanalizuj poniższy przykład:


import bcrypt from 'bcrypt';

import database from './databaseConnection';
import HTTPResponse from './HTTPResponse';

interface IUserData {
  name: string;
  password: string;
}

const SALT_ROUNDS: number = 10;

class UserService {
  public async registerUser( userData: IUserData ): Promise<HTTPResponse> {
    if ( userData.name.length < 1 || userData.name.length > 24 ) {
      return HTTPResponse.badRequest( 'Invalid user name.' );
    }

    if ( userData.password.length < 8 ) {
      return HTTPResponse.badRequest( 'Password is too short!' );
    }

    const id: number = await database.insert( {
      table: 'users',
      data: {
        name: userData.name,
        password: await bcrypt.hash( userData.password, SALT_ROUNDS )
      },
      {
        createTimestamps: true
      }
    );

    return HTTPResponse.created( { id } );
  }
}

Już na pierwszy rzut oka można zauwazyć, że coś z tym kodem jest nie tak. Liczba odpowiedzialności w tym kodzie jest zdecydowanie wyższa niż 1:

  • Sprawdzanie poprawności danych wejściowych.
  • Wykonywanie operacji na bazie danych – jawne wykorzystanie ORM, jawne przekazanie szczegółów implementacyjnych takich jak nazwa tabeli docelowej, struktury kolumn w tabeli, obecność timestampów.
  • Wygenerowanie wyniku funkcji skrótu (hasha) przekazanego hasła. Jawne użycie algorytmu bcrypt oraz przekazanie dodatkowych parametrów, które są nieistotne z puktu widzenia logiki biznesowej.
  • Zwrócenie odpowiedzi HTTP.

Jest to oczywiste złamanie zasady pojedynczej odpowiedzialności. W powyższym przypadku brzydko wyglądający kod jest najmniejszym problemem. Podczas dalszych prac nad projektem ten kod prawdopodobnie urośnie, powstaną nowe odpowiedzialności, powiązania czy zależności. Mogą się także zmienić wymagania biznesowe lub techniczne. Kod powyżej nie jest w żaden sposób na to przygotowany, a w miarę rozwoju projektu dokonywanie zmian w nim będzie coraz trudniejsze, bardziej czasochłonne i mniej odporne na błędy. Wisienką na torcie jest brak możliwości przetestowania tego kodu za pomocą testów jednostkowych. Jeśli temat testów jest Ci obcy to zachęcam do przeczytania artykułu Podstawy testów automatycznych oprogramowania. Zobaczmy jak mógłby wyglądać kod po zastosowaniu reguły SRP:


import UsersRepository from './UsersRepository';
import Validators, { validate } from './Validators';
import HTTPResponse from './HTTPResponse';

class UserService {
  public constructor( private readonly _usersRepository: UsersRepository ){}

  public async registerUser(
    @validate( Validators.username() )
        username: string,
    @validate( Validators.password() )
        password: string
    ): Promise {
    const id: number = await this._usersRepository.add( username, password );

    return HTTPResponse.created( { id } );
  }
}

Ten kod, moim zdaniem, wygląda zdecydowanie lepiej. Udało się pozbyć trzech z czterech odpowiedzialności. Walidacja odbywa się teraz za pomocą wcześniej przygotowanych walidatorów. Do walidacji wykorzystano dekoratory, dzięki czemu ukryto szczegóły implementacyjne walidacji. Operacje na bazie danych oraz generowania hasha również zostały przeniesione do innej klasy. Do tego celu powstała klasa UsersRepository, która jest odpowiedzialna za wykonywanie zapytań do silnika bazodanowego. Dzięki tym krokom, złożoność metody registerUser została znacząco zredukowana i poprawiła się czytelność kodu. Co więcej, kod zyskał na elastyczności oraz stał się testowalny za pomocą testów jednostkowych – klasa UsersRepository może zostać zastąpiona mockiem.

SOLID - zasada otwarte-zamknięte - obrazek alegoryczny

O jak Open/Closed Principle

Zasada otwarte-zamknięte mówi o tym, że klasa powinna być otwarta na rozbudowę, lecz zamknięta na modyfikację. Oznacza, to że zachowanie danej klasy powinno być rozszerzalne lecz nie powinno być modyfikowalne. Tutaj również przydatny jest przykład:


import FilesRepository from './FilesRepository';
import HTTPResponse from './HTTPResponse';

interface IFile {
  name: string;
  size: number;
  mimeType: string;
  metatada: Record<string, unknown>;
}

class FileUploader {
  public constructor( private readonly _filesRepository: FilesRepository ) {}

  public async upload( file: IFile ): Promise<HTTPResponse> {
    if ( file.mimeType  === 'text/css' ) {
      this._cssParse( file );
    }

    if ( file.mimeType  === 'image/jpeg' ) {
      this._jpegParse( file );
    }

    await this._filesRepository.add( file );

    return HTTPResponse.ok();
  }

  private _cssParse( file: IFile ): void {
    // Some business logic
  }

  private _jpegParse( file: IFile ): void {
    // Some business logic
  }
}

Powyższy kod jest odpowiedzialny za upload plików na serwer. Każdy plik musi zostać wcześniej przeparsowany. W obecnej formie kod wspiera pliki z mimeType mage/jpeg oraz text/css. Problem zacznie się gdy będziemy chcieli dodać wsparcie dla innych typów plików. Każdy dodatkowy mimeType to dodatkowa instrukcja warunkowa oraz dodatkowa metoda prywatna. Dodanie wsparcia dla kolejnych typów wymusza modyfikację klasy. Przykładowe rozwiązanie tego problemu może wyglądać następująco:


import FilesRepositoryfrom './FilesRepository';
import HTTPResponse from './HTTPResponse';
import { IParser } from './Parser';

interface IFile {
  name: string;
  size: number;
  mimeType: string;
  metatada: Record<string, unknown>;
}

class FileUploader {
  public constructor(
    private readonly _filesRepository: FilesRepository,
    private readonly _parsers: Map<string, IParser>
    ) {}

  public async upload( file: IFile ): Promise {
    const parser: IParser | undefined = this._parsers.get( file.mimeType );

    if ( !parser ) {
      throw new Error( `${ file.mimeType } mimeType not supported!` );
    }

    await parser.parse( file );

    await this._filesRepository.add( file );

    return HTTPResponse.ok();
  }
}

Problem wielu parserów rozwiązano poprzez przekazanie mapy z parserami, gdzie kluczem parsera jest mimeType pliku. Dzięki temu dodanie kolejnego parsera będzie sprowadzone do dodaania go do mapy, co jest zdecydowanie wygodniejsze niż każdorazowa modyfikacja klasy FileUploader. Co więcej, to podejście pozwoliło w bardzo łatwy sposób obsłużyć przypadek, gdy został podany niewspierany mimeType. Co prawda w pierwszym przykładzie również jest to możliwe, ale wiązałoby się to z bardzo długą listą klauzul if else ( osobna dla każdego mimeType ), lub stworzeniu redundantnej tablicy ze wspieranymi typami co zaciemniłoby kod. Na deser udało się zredukować liczbę odpowiedzialności dzięki przeniesienu kodu odpowiedzialnego za parsowanie do parserów, dzięki czemu spełniona została zasada SRP.

L jak Liskov Substitution Principle

Zasada podstawienia Liskov została zdefiniowana w książce Data Abstraction and Hierarchy przez Barbarę Liskov. Zasada ta mówi o tym, że jeżeli w danej funkcji będzie wykorzystany obiekt klasy potomnej, to wywołanie na nim metody pierwotnie zdefiniowanej w klasie bazowej powinno dać te same rezultaty. Przykład złamania zasady LSP:


abstract class Car {
  protected _gear: number = 0;

  public gearUp() {
    this._gear++;
  }

  public gearDown() {
    this._gear--;
  }
  public drive(): void {
    // Some business logic
  }

  public get gear() {
    return this._gear;
  }
}

class Mercedes extends Car {}

class Tesla extends Car {
   public gearUp() {
    return;
  }

  public gearDown() {
    return;
  }
}

const tesla: Car = new Tesla();
const mercedes: Car = new Mercedes()

tesla.gearUp();
tesla.gearUp();
mercedes.gearUp();
mercedes.gearUp();

console.log( mercedes.gear, tesla.gear ); // 2 0

Na podstawie istniejącej klasy abstrakcyjnej Car stworzono dwie klasy potomne – Tesla oraz Mercedes. Z uwagi na to, że Tesla nie posiada skrzyni biegów, wywoływanie metod gearUp oraz gearDown mija się z celem. Dlatego też ich zachowanie zmieniło się względem klasy bazowej. W tym momencie następuje złamanie zasady podstawienia Liskov ponieważ zachowanie obiektu potomnego da inny rezultat niż w przypadku klasy bazowej. Rozwiązaniem tego problemu jest zrezygnowanie z dziedziczenia po klasie Car w przypadku klasy Tesla i stworzenie, na przykład, klasy bazowej ElectricCar.

I jak Interface Segregation Principle

Zasada segregacji interfejsów mówi o o tym, że klient wykorzystujący dany interfejs nie powinien być zmuszony do obsłużenia metod, których nie używa. Przykład:


interface IBird {
  makeSound(): string;
  fly(): void;
  eat(): void;
}

class Duck implements IBird {
  makeSound(): string {
    return 'Quack!';
  }

  fly(): void {
    // Fly!
  }

  eat(): void {
    // Eat!
  }
}

class Chicken implements IBird {
  makeSound(): string {
    return 'Cluck!';
  }

  fly(): void {
    throw new Error( 'Chicken cannot fly!' );
  }

  eat(): void {
    // Eat!
  }
}

W przypadku klasy Chicken, niemożliwe jest poprawne zaimplementowanie metody fly. Przez wykorzystanie tego samego interfejsu co w przypadku klasy Duck złamano zasadę ISP. Rozwiązaniem tego problemu jest… segregacja interfejsów!


interface IBird {
  makeSound(): string;
  eat(): void;
}

interface IFlyingBird extends IBird {
  fly(): void;
}

class Duck implements IFlyingBird {
  makeSound(): string {
    return 'Quack!';
  }

  fly(): void {
    // Fly!
  }

  eat(): void {
    // Eat!
  }
}

class Chicken implements IBird {
  makeSound(): string {
    return 'Cluck!';
  }

  eat(): void {
    // Eat!
  }
}

Interfejs IBird podzielono na dwa interfejsy – IBird oraz IFlyingBird, który jest interfejsem potomnym względem IBird. Pozwoliło to na wyeliminiowanie zbędnej implementacji metody w klasie Chicken. Co więcej, w dalszym ciągu obie klasy implementują ten sam interfejs – IBird.

D jak Dependency Inversion Principle

Zasada odwrócenia zależności mówi o tym, że wysokopoziomowe moduły nie powinny zależeć od modułów niskopoziomowych. Co więcej, abstrakcje nie powinny być zależne od implementacji tylko to implementacje powinny być zależne od abstrakcji. Tak jak w przypadku poprzednich zasad posłużę się przykładem:


import Database from './Database';
import HTTPResponse from './HTTPResponse';

class UserService {
  private readonly _database: Database;

  public constructor(){
    this._database = new Database( {
      database: 'db_local',
      password: 'secret',
      user: 'root',
      url: '127.0.0.1',
      port: '1234'
    } );
  }

  public async registerUser(username: string, password: string ): Promise<HTTPResponse> {
    const id: number = await this._database.insert( 'users', {
      username,
      password
    } );

    return HTTPResponse.ok( { id } );
  }
}

Powyższy przykład miesza ze sobą warstwy wysokiego poziomu, czyli logikę biznesową, z warstwami niskiego poziomu czyli tworzeniu połączenia z bazą danych oraz wykonywaniu zapytań do bazy danych. Taka implementacja będzie problematyczna w momencie zastąpnia obecnego rozwiązania bazodanowego innym. Przez zmiany na niskim poziomie, należy dostosować kod wysokiego poziomu czyli logikę biznesową aplikacji co powoduje złamanie zasady odwrócenia zależności. W tym wypadku, zamiast konkretnego rozwiązania w postaci połączenia z bazą danych należy wykorzystać abstrakcję, pozwalającą na pełne odseparowanie wartsw aplikacji i uniezależnienie wyższych warstw od niższych:


import HTTPResponse from './HTTPResponse';

interface IUsersRepository {
  add( username: string, password: string ): number;
}

class UserService {
  public constructor( private readonly _usersRepository: IUsersRepository ){}

  public async registerUser( username: string, password: string ): Promise {
    const id: number = await this._usersRepository.add( username, password );

    return HTTPResponse.ok( { id } );
  }
}

W tym fragmencie kodu wykorzystano abstrakcję. Przy tworzeniu obiektu klasy UsersService otrzymujemy abstrakcję spełniającą interfejs IUsersRepository pozwalającą na wywołanie metody add, która zwraca spodziewany rezultat.

Z poziomu logiki biznesowej, programisty nie interesuje jakiego rozwiązania niskopoziomowego użyto, ani nie jest od niego w żaden sposób zależny. Co więcej, takie rozwiązanie pozwala na szybką zmianę rozwiązania zaimplementowanego w repozytorium na inne, bez ingerencji w logikę biznesową.

KISS - obrazek alegoryczny

KISS – Keep It Simple, Stupid

Reguła ta oznacza dosłownie “zrób to prosto, głupku”. Programista powinien dążyć do jak najprosztszych i jak najbardziej zrozumiałych rozwiązań. Jest to odpowiedź na tendencje programistów do tworzenia nadmiarowych i przekombinowanych rozwiązań. Wbrew pozorom, prawdziwą sztuką jest tworzenie rozwiązań zarówno eleganckich pod względem czystości kodu jak i prostych pod względem złożoności.

O regule KISS warto pamiętać szczególnie w kontekście pracy zespołowej. Pamiętaj, że rozwiązanie, które jest dla Ciebie proste i zrozumiałe, może nie być takim dla innych członków zespołu.

Zasada ta idzie w parze z zasadą YAGNI czyli “you ain’t gonna need it”. Nie należy tworzyć kodu “na zaś” ani zostawiać kodu “bo się przyda”. Jeżeli w danym momencie fragment kodu jest zbędny to nie należy go utrzymywać.

DRY – Don’t Repeat Yourself

Ta zasada brzmi bardzo prosto – “nie powtarzaj się”. Stosowanie się do reguły DRY niesie ze sobą szereg zalet:

  • łatwość utrzymania kodu – wprowadzanie zmian jest łatwiejsze gdy trzeba to zrobić w jednym miejscu
  • łatwiejsza detekcja błędów – debuggowanie jedengo fragmentu kodu jest prostsze
  • mniejsze ryzyko popełnienia błędu – jeżeli zmieniamy kilka fragmentów kodu istnieje większe ryzyko popełnienia błędu. Co więcej, błąd popełnia się tylko w jednym miejscu co spowoduje różne zachowanie pozornie takiego samego kodu w różnych częściach systemu.

Należy pamiętać jednak, że zduplikowany kod jest zdecydowanie lepszy niż źle zaprojektowana abstrakcja. Wyeliminowanie zduplikowanego kodu to zdecydowanie mniejszy koszt niż pozbycie się źle zaprojektowanej abstrakcji. Zmiany w abstrakcjach, w skrajnych przypadkach mogą rzutować na całą aplikację, a ich naprawienie może oznaczać ogromny nakład pracy.

Będąc DRY należy również pamiętać o unikaniu couplingu (wiązania). Zmiany w jednym komponencie nie powinny powodować konieczności dopasowania innych komponentów. Oprócz tego, powinna istnieć możliwość bezproblemowego zastąpienia obecnego komponentu innym. Programista powinien dążyć do jak najmniejszego powiązania pomiędzy komponentami – zarówno poszczególnych klas, abstrakcji, modułów jak i w szerszym kontekście, na przykład serwisów.

Podsumowanie

Mam nadzieję, że ta solidna dawka wiedzy okaże się przydatna w twojej pracy. Zdecydowałem się poruszyć ten temat z uwagi na to, że reguły SOLID proczątkowo traktowałem jako coś teoretycznego, jak regułki do wykucia na pamięć.

Znając jedynie teorię SOLIDa nie potrafiłem zastosować go w praktyce. Dużo czasu zajęło mi nauczenie się stosowania tych reguł w praktyce oraz zdobycie umiejętności odnajdywania problemów związanych z łamaniem zasad SOLIDa w istniejącym już kodzie. Co więcej, nawet zastosowanie tak pozornie prostych reguł jak KISS i DRY w praktyce również nie jest takie łatwe jak począkowo mogłoby się wydawać.

Zachęcam do zostawienia komentarza oraz oceny. Jeśli wpis okazał się dla Ciebie przydatny to będę wdzięczny za udostępnienie go.

Źródła i materiały dodatkowe