SOLID w JavaScript: Tworzenie Solidnych Aplikacji

SOLID w JavaScript

Wprowadzenie do SOLID w JavaScript: Zasady, które zmieniają sposób, w jaki piszesz kod

W dzisiejszych czasach tworzenie skalowalnych i łatwych w utrzymaniu aplikacji JavaScript stało się kluczowym wyzwaniem dla programistów. Wraz z rosnącym rozwojem technologii webowych, pojawia się coraz większa potrzeba budowania oprogramowania, które jest elastyczne, modułowe i odporne na zmiany.

W tym artykule chciałbym przedstawić Wam temat, który jest nieodłączną częścią programowania obiektowego w JavaScript, są nim podstawy SOLID. SOLID to skrót od pięciu fundamentalnych zasad projektowania oprogramowania, które zostały zaproponowane przez Roberta C. Martina. Stosowanie tych zasad może znacznie ułatwić proces tworzenia aplikacji JavaScript, wpływając pozytywnie na jej jakość, skalowalność i możliwość rozbudowy.

Zasady SOLID w JavaScript są szczególnie przydatne w zarządzaniu zależnościami, rozszerzaniu funkcjonalności, modularyzacji kodu, elastycznym projektowaniu obiektów oraz tworzeniu odpowiednich interfejsów, co przyczynia się do lepszej czytelności, łatwości utrzymania i testowania aplikacji JavaScript.

Odkryjemy, jak można zastosować te zasady w programowaniu obiektowym w JavaScript, aby tworzyć solidne, elastyczne i łatwe w utrzymaniu aplikacje. Gotowi? Zaczynamy!

Single Responsibility Principle (SRP) w JavaScript: Skupienie na jednym celu dla każdej klasy

SRP odnosi się do konceptu, w którym każda klasa powinna mieć tylko jeden, dobrze zdefiniowany cel i odpowiedzialność. To fundamentalne podejście do projektowania aplikacji, które promuje wysoką spójność i modularność kodu.

W oryginale zasada ta została sformułowana następująco:

Klasa powinna mieć tylko jeden powód do zmiany

Istotnym jest, aby zrozumieć, że powyższa definicja nie mówi o tym, że klasa ma wykonywać tylko jedną czynność. Zasada mówi o tym, że zmiana klasy jest uzasadniona gdy zmieni się jej odpowiedzialność.

Poprzez ograniczenie odpowiedzialności pojedynczej klasy do jednego celu, unikamy związanych z nią nadmiernych zadań. Dzięki SRP unikamy sytuacji, w których zmiana jednej funkcjonalności wpływa na wiele innych, co ułatwia zarządzanie zmianami w projekcie.

Zastosowanie SRP w JavaScript: Lepsza organizacja, łatwiejsze utrzymanie i rozszerzalność kodu

Istnieje wiele sytuacji, w których można zastosować SRP w celu osiągnięcia lepszej organizacji, łatwiejszego utrzymania i rozszerzalności kodu JavaScript. Oto kilka konkretnych przykładów:

  1. Obsługa formularzy: Stosując SRP, możemy podzielić obsługę formularzy na dwie klasy – jedną odpowiedzialną za wyświetlanie formularza i obsługę interakcji użytkownika, a drugą zajmującą się walidacją i przetwarzaniem danych. Dzięki temu każda klasa ma jasno określony cel i odpowiedzialność, co ułatwia zarządzanie logiką formularzy.
  2. Zarządzanie danymi: W przypadku operacji na danych, takich jak pobieranie, zapisywanie i aktualizowanie, SRP sugeruje podzielenie kodu na klasy odpowiedzialne za interakcję z bazą danych oraz klasy zarządzające logiką biznesową. Dzięki temu mamy oddzielne klasy, które skupiają się na swoich unikalnych zadaniach, co przekłada się na bardziej czytelny i modularny kod.
  3. Komunikacja z serwerem: W przypadku komunikacji z serwerem, SRP sugeruje podział kodu na klasy odpowiedzialne za wysyłanie i odbieranie żądań HTTP oraz klasy, które przetwarzają i interpretują odpowiedzi. To pozwala na lepszą organizację i łatwiejsze testowanie każdej z tych odpowiedzialności.
  4. Logika interfejsu użytkownika: SRP można zastosować do oddzielenia logiki interfejsu użytkownika od logiki biznesowej. Dzięki temu każda klasa skupia się na konkretnym aspekcie interfejsu, na przykład na obsłudze przycisków, walidacji pól tekstowych lub renderowaniu list. To pozwala na większą elastyczność i możliwość ponownego użycia komponentów interfejsu.
  5. Manipulacja danymi: SRP można również zastosować w przypadku manipulacji danymi, takiej jak transformacja, filtrowanie czy sortowanie. Każda klasa może być odpowiedzialna za jedną konkretną operację na danych, co ułatwia zrozumienie i utrzymanie kodu.

Praktyczny przykład wykorzystania SRP do manipulacji danymi

Paktyczny przykład zastosowania zasady SRP do manipulacji danymi w JavaScript może obejmować funkcję filtrowania danych w aplikacji. W ramach SRP możemy podzielić tę funkcjonalność na dwie klasy:

// Klasa odpowiedzialna za filtrowanie danych
class DataFilter {
  filterData(data: number[], criteria: { minValue: number }): number[] {
    // Logika filtrowania danych na podstawie kryteriów
    // ...
    const filteredData: number[] = []; // Przykładowa implementacja filtrowania
    return filteredData;
  }
}

// Klasa odpowiedzialna za prezentację przefiltrowanych danych
class DataPresenter {
  presentData(data: number[]): void {
    // Logika prezentacji danych
    // ...
    console.log("Przedstawienie danych:", data);
  }
}

// Przykładowe dane
const sampleData: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// Użycie klas w celu filtrowania i prezentacji danych
const dataFilter = new DataFilter();
const dataPresenter = new DataPresenter();

// Filtrowanie danych
const filteredData = dataFilter.filterData(sampleData, { minValue: 5 });

// Prezentacja przefiltrowanych danych
dataPresenter.presentData(filteredData);

Klasa DataFilter jest odpowiedzialna za filtrowanie danych na podstawie określonych kryteriów. Metoda filterData przyjmuje dane wejściowe oraz kryteria filtracji i zwraca przefiltrowane dane.

Klasa DataPresenter zajmuje się prezentacją przefiltrowanych danych. W tym przykładzie, metoda presentData po prostu wyświetla przefiltrowane dane w konsoli za pomocą console.log. Oczywiście, w rzeczywistym projekcie ta klasa mogłaby mieć bardziej rozbudowaną logikę prezentacji danych, na przykład renderowanie ich w interfejsie użytkownika.

Dzięki takiemu podziałowi mamy dwie klasy o jasno zdefiniowanych odpowiedzialnościach. Klasa „DataFilter” skupia się wyłącznie na manipulacji danymi, podczas gdy klasa „DataPresenter” zajmuje się prezentacją tych danych. To pozwala na łatwiejsze zrozumienie, testowanie i modyfikowanie każdej z tych odpowiedzialności osobno. Dodatkowo, jeśli w przyszłości będziemy chcieli zmienić sposób prezentacji danych, nie będzie konieczności modyfikacji klasy „DataFilter”, co wpływa na łatwość rozszerzalności kodu.

Open-Closed Principle (OCP) w JavaScript: Rozszerzalność bez modyfikacji istniejącego kodu

Ta zasada SOLID zakłada, że klasy i moduły powinny być otwarte na rozszerzanie, ale jednocześnie zamknięte na modyfikacje. OCP wprowadza kilka kluczowych zalet, które przyczyniają się do elastyczności i skalowalności kodu:

  • Rozszerzalność: Dzięki OCP, istniejący kod może być łatwo rozbudowywany o nową funkcjonalność bez konieczności zmiany już istniejącego kodu. Rozszerzanie odbywa się poprzez dodanie nowych klas lub modułów, które dziedziczą lub implementują istniejące abstrakcje, a nie poprzez zmienianie istniejących implementacji.
  • Łatwiejsze utrzymanie: Dzięki OCP, utrzymywanie kodu staje się łatwiejsze. Ponieważ istniejący kod nie jest modyfikowany podczas wprowadzania zmian, zmniejsza się ryzyko wprowadzenia błędów i naruszenia działania istniejących funkcji.
  • Skalowalność: OCP umożliwia skalowanie aplikacji poprzez dodawanie nowych funkcjonalności bez konieczności rewizji już istniejącego kodu.

Praktyczny przykład wykorzystania OCP w JavaScript

// Abstrakcyjna klasa reprezentująca produkt
abstract class Product {
  constructor(private name: string, private price: number) {}

  getName(): string {
    return this.name;
  }

  getPrice(): number {
    return this.price;
  }
}

// Klasa reprezentująca produkt fizyczny, dziedzicząca po klasie Product
class PhysicalProduct extends Product {
  constructor(name: string, price: number, private weight: number) {
    super(name, price);
  }

  getWeight(): number {
    return this.weight;
  }
}

// Klasa reprezentująca produkt cyfrowy, dziedzicząca po klasie Product
class DigitalProduct extends Product {
  constructor(name: string, price: number, private downloadLink: string) {
    super(name, price);
  }

  getDownloadLink(): string {
    return this.downloadLink;
  }
}

// Klasa reprezentująca koszyk zakupowy
class ShoppingCart {
  private products: Product[] = [];

  addProduct(product: Product): void {
    this.products.push(product);
  }

  getTotalPrice(): number {
    let totalPrice = 0;
    for (const product of this.products) {
      totalPrice += product.getPrice();
    }
    return totalPrice;
  }
}

// Użycie klas zgodnie z zasadą OCP
const shoppingCart = new ShoppingCart();
shoppingCart.addProduct(new PhysicalProduct("Laptop", 1500, 2.5));
shoppingCart.addProduct(new DigitalProduct("Ebook", 20, "https://example.com/ebook"));

console.log("Total Price:", shoppingCart.getTotalPrice());

W powyższym przykładzie zastosowano OCP, tworząc abstrakcyjną klasę Product, która reprezentuje ogólny produkt w sklepie e-commerce. Następnie tworzone są konkretne klasy dziedziczące, takie jak PhysicalProduct i DigitalProduct, które reprezentują odpowiednio produkty fizyczne i cyfrowe.

Klasa ShoppingCart nie musi znać konkretnych typów produktów, ale operuje na ogólnym typie Product. Może dodawać różne produkty do koszyka i obliczać całkowitą wartość zamówienia.

Dzięki takiemu podejściu, klasa ShoppingCart jest otwarta na rozszerzanie, ponieważ można dodawać nowe typy produktów, dziedzicząc po klasie Product, bez modyfikacji kodu w klasie ShoppingCart. To pozwala na dodawanie różnych rodzajów produktów do koszyka bez konieczności zmiany istniejącego kodu i naruszania zasady OCP.

Liskov Substitution Principle (LSP) – Zasada Podstawienia Liskov

Bardzo prosta zasada, która odnosi się do interakcji między typami i mówi, że obiekty typu bazowego powinny być zastępowalne przez obiekty typu pochodnego bez zmiany poprawności działania programu. LSP w pewnym sensie stanowi rozwinięcie zasady otwarte/zamknięte.

Innymi słowy najważniejsze w tej zasadzie jest to, że podtypy puszą dać się wstawić w miejsce typu bazowego. Jest to równoznaczne z tym, że obiekty pochodne zachowują się zgodnie z kontraktem określonym przez obiekt bazowy. Jeśli ten kontrakt zostanie naruszony, to może to prowadzić do nieprzewidywalnego zachowania się programu.

Zasadę LSP, najlepiej zrozumieć na przykładzie. Poniżej przedstawiam chyba najpopularniejszy przykład, z którym można się spotkać podczas demonstracji tejże zasady:

// Interfejs reprezentujący kształt
interface Shape {
  getArea(): number;
}

// Klasa reprezentująca prostokąt implementująca interfejs Shape
class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}

  getArea(): number {
    return this.width * this.height;
  }
}

// Klasa reprezentująca kwadrat implementująca interfejs Shape
class Square implements Shape {
  constructor(private sideLength: number) {}

  getArea(): number {
    return this.sideLength ** 2;
  }
}

// Funkcja przyjmująca dowolny obiekt implementujący interfejs Shape
function printArea(shape: Shape): void {
  console.log("Area:", shape.getArea());
}

// Użycie funkcji printArea z różnymi typami obiektów
const rectangle = new Rectangle(5, 10);
const square = new Square(5);

printArea(rectangle); // Area: 50
printArea(square); // Area: 25

W powyższym przykładzie mamy interfejs Shape, który definiuje metodę getArea(). Klasa Rectangle i Square implementują ten interfejs, ale reprezentują różne kształty. Dzięki temu, funkcja printArea może przyjmować dowolny obiekt implementujący interfejs Shape, niezależnie od konkretnego typu kształtu. Dzięki temu możemy bezproblemowo używać różnych typów obiektów w kontekstach, gdzie oczekiwane jest zachowanie zgodne z interfejsem Shape.

Zasada Interface Segregation (ISP) w JavaScript: Elastyczność poprzez precyzyjne interfejsy

Zasada ISP odnosi się do tworzenia precyzyjnych interfejsów, które są dostosowane do konkretnych potrzeb klienckich. W kontekście JavaScript, ISP jest istotne, ponieważ pozwala na elastyczne projektowanie kodu, unikając nadmiernego uzależnienia od niepotrzebnych metod i funkcji.

W praktycznych zastosowaniach tworząc biblioteki lub moduły w JavaScript, należy projektować interfejsy tak, aby były precyzyjne i dopasowane do konkretnych funkcjonalności. Dzięki temu użytkownicy biblioteki mogą wybierać tylko te części, które są im potrzebne, bez konieczności implementowania niepotrzebnych funkcji.

W przypadku, gdy tworzone są obiekty złożone z mniejszych składowych części, warto rozważyć podział interfejsów na bardziej specjalizowane. Dzięki temu poszczególne składowe mogą dostarczać tylko te metody, które są niezbędne dla ich specyficznej funkcjonalności, co przyczynia się do większej modularności i elastyczności kodu.

Oto przykład kodu w TypeScript, który obrazuje zasadę Interface Segregation (ISP):

// Interfejs dla funkcjonalności wysyłania powiadomień
interface INotificationSender {
  sendNotification(): void;
}

// Interfejs dla funkcjonalności logowania
interface ILogging {
  log(message: string): void;
}

// Implementacja funkcjonalności wysyłania powiadomień
class EmailSender implements INotificationSender {
  sendNotification(): void {
    console.log("Wysyłanie powiadomienia email...");
    // Logika wysyłania powiadomienia
  }
}

// Implementacja funkcjonalności logowania do pliku
class FileLogger implements ILogging {
  log(message: string): void {
    console.log("Zapisywanie logu do pliku...");
    // Logika zapisywania logu do pliku
  }
}

// Implementacja funkcjonalności logowania do konsoli
class ConsoleLogger implements ILogging {
  log(message: string): void {
    console.log("Logowanie do konsoli...");
    // Logika logowania do konsoli
  }
}

// Klasa, która wykorzystuje funkcjonalności powiadomień i logowania
class UserManager {
  private notificationSender: INotificationSender;
  private logger: ILogging;

  constructor(notificationSender: INotificationSender, logger: ILogging) {
    this.notificationSender = notificationSender;
    this.logger = logger;
  }

  createUser(): void {
    // Logika tworzenia użytkownika

    this.notificationSender.sendNotification();
    this.logger.log("Użytkownik został utworzony.");
  }
}

// Użycie klas w celu utworzenia użytkownika z powiadomieniem i logowaniem
const emailSender = new EmailSender();
const fileLogger = new FileLogger();

const userManager = new UserManager(emailSender, fileLogger);
userManager.createUser();

W powyższym przykładzie mamy interfejsy INotificationSender i ILogging, które reprezentują konkretne funkcjonalności. Klasy EmailSender, FileLogger i ConsoleLogger implementują te interfejsy, dostarczając konkretnej implementacji dla wysyłania powiadomień oraz logowania.

Klasa UserManager wykorzystuje funkcjonalności powiadomień i logowania poprzez wstrzyknięcie odpowiednich obiektów za pomocą konstruktora. Dzięki temu UserManager nie jest zależny od konkretnych implementacji, ale tylko od abstrakcji interfejsów.

Dzięki zastosowaniu zasady ISP, każda klasa ma precyzyjne interfejsy, które odpowiadają tylko za konkretne funkcjonalności. Możemy elastycznie wybierać, które funkcjonalności chcemy używać i łatwo rozbudowywać kod, dodając nowe implementacje interfejsów bez konieczności modyfikacji istniejącego kodu.

Jak widać zasada ISP jest dość podobna do zasady pojedynczej odpowiedzialności. Najważniejsze są prostota i spójność tworzonych komponentów. Różnica polega na tym, że SRP odnosi się do pojedynczego komponentu, podczas gdy zasada ISP dotyczy tylko publicznego interfejsu.

Dependency Inversion Principle (DIP) w JavaScript

Zasada ta mówi, że moduły wyższego poziomu nie powinny zależeć od modułów niższego poziomu, jedne i drugie powinny zależeć od abstrakcji. W kontekście JavaScript oznacza to, że zamiast tworzyć sztywne zależności między klasami, powinniśmy polegać na abstrakcjach, interfejsach lub klasach bazowych, które definiują wspólne zachowanie.

Dzięki zastosowaniu DIP, moduły są słabo powiązane i mogą być łatwo modyfikowane lub rozbudowywane bez wpływu na inne części systemu co dodatkowo ułatwia testowanie kodu poprzez możliwość podmiany implementacji modułów na ich atrapy (mocki) lub zastąpienie ich innymi implementacjami w celu przeprowadzenia testów.

Przykład kodu, gdzie wykorzystano zasadę odwrócenia zależności pokazano przy okazji omawiania zasady ISP, gdzie zastosowano zasadę Dependency Inversion Principle (DIP) poprzez wykorzystanie interfejsów INotificationSender i ILogging. Zamiast bezpośrednio odwoływać się do konkretnych implementacji wysyłania powiadomień (EmailSender) i logowania (FileLogger), klasa UserManager przyjmuje instancje tych interfejsów jako argumenty konstruktora.

Dzięki temu, UserManager nie jest bezpośrednio zależny od konkretnych implementacji, co umożliwia elastyczność i odwrócenie zależności. Możemy przekazać dowolną klasę implementującą interfejs INotificationSender i ILogging, co pozwala na łatwą wymianę konkretnych funkcjonalności bez konieczności modyfikacji klasy UserManager.

Innym praktycznym przykładem zastosowania DIP w JavaScript może być sytuacja, w której mamy różne źródła danych, takie jak baza danych, pliki CSV i API. Zamiast bezpośrednio odwoływać się do konkretnych implementacji tych źródeł danych, możemy zdefiniować interfejs DataSource, a następnie stworzyć konkretne implementacje dla każdego źródła danych. Dzięki temu, klasy korzystające z tych danych mogą operować na abstrakcyjnym interfejsie, co sprawia, że są bardziej elastyczne i odporne na zmiany.

Konsekwencje nieprzestrzegania zasady DIP często mogą być znaczące dla projektu w długim terminie. Tworzenie twardych zależności między modułami może prowadzić do sztywności kodu. Każda zmiana w jednym module może wymagać zmiany w wielu innych miejscach, co utrudnia utrzymanie i rozbudowę systemu. W dodatku gdy moduły są mocno powiązane, testowanie ich oddzielnie może być trudne.

Podsumowanie

Zasady SOLID stanowią fundamentalne wytyczne projektowania oprogramowania, które mogą przynieść wiele korzyści w tworzeniu łatwych w utrzymaniu aplikacji w języku JavaScript. Odpowiednie zastosowanie zasad SOLID, takich jak SRP, OCP, LSP, ISP i DIP, może prowadzić do lepszego zorganizowania kodu, zwiększenia elastyczności, łatwości testowania i zmniejszenia skomplikowania systemu.

Dzięki temu tworzenie skalowalnych aplikacji staje się bardziej osiągalne, a zmiany i rozbudowa systemu są bardziej kontrolowane i przewidywalne. Wykorzystanie SOLID pozwala uniknąć sztywności, zmniejsza liczbę błędów i ułatwia współpracę w zespole programistycznym.

Dodaj komentarz

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