Strona główna / Artykuły / Zrozumienie zasad SOLID poprzez praktyczne przykłady kodu

Zrozumienie zasad SOLID poprzez praktyczne przykłady kodu

Ten przewodnik wyjaśnia wszystkie pięć zasad SOLID za pomocą konkretnych przykładów kodu, pokazując, jak są one stosowane w rzeczywistych projektach oraz aplikacjach React.

1903 słów

Gdy tworzysz oprogramowanie, poprawne uruchomienie kodu to zaledwie połowa pracy.

Gdy aplikacja zaczyna się rozwijać, jej baza kodu często staje się trudniejsza do odczytania, trudniejsza do modyfikacji i trudniejsza w utrzymaniu poprawnego funkcjonowania. Niewielka zmiana w jednej części systemu może potajemnie uszkodzić coś zupełnie innego.

To właśnie taki rodzaj problemu ma rozwiązać zasada SOLID.

SOLID to zbiór pięciu zasad projektowania z programowania obiektowego, które pomagają tworzyć kod, który jest:

  • Prostszy w utrzymaniu
  • Prostszy w testowaniu
  • Prostszy w rozszerzaniu
  • Mniej powiązany
  • Lepszy do zrozumienia przez zespół

Skrót ten oznacza:

S — Zasada pojedynczej odpowiedzialności

O — Zasada otwartości/zamknięcia

L — Zasada podstawienia Liskova

I — Zasada segregacji interfejsów

D — Zasada odwrócenia zależności

Rozważmy każdą z nich na prostych przykładach.

1. S — Zasada pojedynczej odpowiedzialności

„Klasa powinna mieć tylko jeden powód do zmiany.”

Mówiąc prościej, każda klasa lub moduł powinien być zbudowany wokół jednego zadania.

Rozważmy klasę User, która jest odpowiedzialna za:

  • Zachowywanie danych użytkownika
  • Komunikację z bazą danych
  • Wysyłanie e-maili
  • Tworzenie raportów

To zbyt wiele dla jednej klasy.

class User {
  createUser() {
    // create user
  }
saveToDatabase() {
    // save user
  }
  sendEmail() {
    // send email
  }
  generateReport() {
    // generate report
  }
}

Jeśli logika wysyłania e-maili musi ulec zmianie, edytujesz klasę User.

Jeśli zmienia się sposób obsługi bazy danych, znów musisz edytować tę samą klasę.

Czystsze podejście polega na rozdzieleniu tych zadań.

class User {
  createUser() {
    // create user
  }
}
class UserRepository {
  saveToDatabase() {
    // database logic
  }
}
class EmailService {
  sendEmail() {
    // email logic
  }
}
class ReportService {
  generateReport() {
    // report logic
  }
}

Dzięki takiemu podziałowi każda klasa obejmuje dokładnie jedną funkcję.

Dlaczego to jest przydatne?

Gdy tylko zmieni się jakieś wymaganie, od razu wiesz, którą część kodu należy edytować.

Jedna funkcja na klasę oznacza jeden powód, dla którego ta klasa może ulec zmianie.

2. O — Zasada otwartości/zamknięcia

„Entytety oprogramowania powinny być otwarte na rozbudowę, ale zamknięte na modyfikację.”

Sformułowanie to brzmi abstrakcyjnie, ale koncepcja stojąca za nim nie jest.

Celem jest wprowadzanie nowych funkcjonalności bez ciągłego przepisywania już działającego kodu.

Weźmy przykład przetwarzania płatności:

function processPayment(type, amount) {
  if (type === "card") {
    // card payment
  } else if (type === "upi") {
    // UPI payment
  } else if (type === "paypal") {
    // PayPal payment
  }
}

Załóżmy teraz, że musisz obsłużyć:

  • Stripe
  • Razorpay
  • Apple Pay
  • Google Pay

Każda nowa opcja sprawia, że ta funkcja staje się większa i bardziej złożona.

Lepszą strategią jest przypisanie każdej metodzie płatności własnej klasy.

class CardPayment {
  pay(amount) {
    console.log(`Card payment: ${amount}`);
  }
}
class UpiPayment {
  pay(amount) {
    console.log(`UPI payment: ${amount}`);
  }
}
class PaypalPayment {
  pay(amount) {
    console.log(`PayPal payment: ${amount}`);
  }
}

Gdy taka struktura już istnieje, dodanie kolejnej opcji płatności nie wymaga modyfikowania już napisanych klas.

class StripePayment {
  pay(amount) {
    console.log(`Stripe payment: ${amount}`);
  }
}

Pierwotne implementacje pozostają dokładnie takie, jakie były.

Idea

Rozwijaj system poprzez dodawanie nowego kodu, a nie poprzez ciągłe edytowanie już stabilnego kodu.

3. L — Zasada podstawienia Liskova

„Podtypy powinny być zastępowalne przez swoje typy bazowe.”

W istocie zasada ta stanowi:

Jeśli B jest podtypem A, powinno być możliwe zastąpienie B wszędzie tam, gdzie używany jest A, a aplikacja powinna nadal prawidłowo funkcjonować.

Klasycznym przykładem są ptaki.

Załóżmy, że definiujemy:

class Bird {
  fly() {
    console.log("Flying");
  }
}

Następnie to rozszerzasz:

class Sparrow extends Bird {
  fly() {
    console.log("Sparrow is flying");
  }
}

Na razie wszystko w porządku.

Ale co się stanie z pingwinem?

class Penguin extends Bird {
  fly() {
    throw new Error("Penguins cannot fly");
  }
}

To ujawnia wadę w projekcie.

Jeśli inne części kodu zakładają, że każdy obiekt typu Bird potrafi latać, przekazanie mu instancji Penguin złamie to założenie.

Bardziej rozsądny projekt wyodrębnia zachowanie lotu do osobnego modułu.

class Bird {
  eat() {
    console.log("Eating");
  }
}
class FlyingBird extends Bird {
  fly() {
    console.log("Flying");
  }
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}

W ten sposób pingwiny nie są już zmuszane do obsługi zachowań, które do nich nie odnoszą się.

Lekcja

Unikaj tworzenia hierarchii dziedziczenia, które nie są logicznie spójne.

Podklasa musi działać poprawnie wszędzie tam, gdzie oczekuje się, że będzie działać jej klasa nadrzędna.

4. I — Zasada segregacji interfejsów

„Klienci nie powinni być zmuszani do polegania na metodach, których nie używają.”

Wyobraź sobie interfejs zbudowany w ten sposób:

print()
scan()
fax()
copy()

Wyobraźmy teraz sobie prosty drukarkę, która potrafi tylko drukować.

Dlaczego ta drukarka miałaby musieć implementować również funkcje scan(), fax() i copy()?

Nie ma ku temu żadnego sensownego powodu.

Lepszym podejściem jest podział interfejsu według tego, co każda z tych funkcji faktycznie robi.

Naprzykład:

class Printer {
  print() {
    console.log("Printing...");
  }
}
class Scanner {
  scan() {
    console.log("Scanning...");
  }
}
class FaxMachine {
  fax() {
    console.log("Faxing...");
  }
}

Prosta drukarka musi implementować tylko te funkcje, które rzeczywiście obsługuje.

We współczesnym JavaScript

JavaScript nie posiada formalnych interfejsów w takim stopniu jak Java czy C#, ale podstawowa idea pozostaje ta sama.

Można to zrealizować poprzez:

  • Małe moduły
  • Małe API
  • Kompozycję
  • Odrębne usługi
  • Skupione komponenty React

Zamiast łączyć wszystko w jedną ogromną usługę:

userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();

Rozdziel odpowiedzialności:

userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();

Lekcja

Nigdy nie zmuszaj komponentu, klasy ani modułu do korzystania z funkcjonalności, której nie potrzebuje.

5. D — Zasada odwrócenia zależności

„Moduły wysokiego poziomu nie powinny polegać bezpośrednio na modułach niskiego poziomu. Obie powinny polegać na abstrakcjach.”

Zasada ta ma na celu zmniejszenie ścisłego powiązania.

Weźmy ten przykład:

class MongoDB {
  save(data) {
    console.log("Saving to MongoDB");
  }
}
class UserService {
  constructor() {
    this.database = new MongoDB();
  }
  saveUser(user) {
    this.database.save(user);
  }
}

Problem polega na tym, że UserService jest bezpośrednio połączony z MongoDB.

Przejście później na PostgreSQL oznaczałoby konieczność ponownej modyfikacji samego UserService.

Lepszym podejściem jest wstrzykiwanie zależności zamiast tego.

class UserService {
  constructor(database) {
    this.database = database;
  }
saveUser(user) {
    this.database.save(user);
  }
}

Teraz można swobodnie przekazywać różne implementacje bazy danych.

const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);

A później wymiana ich jest prosta:

const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);

UserService nigdy nie musi wiedzieć, jaka baza danych znajduje się za nim.

Dlaczego to jest przydatne?

Dzięki temu otrzymujemy kod, który jest:

  • Latwiejszy do testowania
  • Latwiejszy do zastąpienia
  • Mniej ściśle powiązany
  • Latwiejszy do utrzymania z biegiem czasu

SOLID w rzeczywistym projekcie

Przestrzeganie zasad SOLID nie oznacza tworzenia osobnej klasy dla każdej rzeczy.

To rozróżnienie ma duże znaczenie.

SOLID dotyczy podejmowania rozsądnych decyzji projektowych, a nie gromadzenia abstrakcji tylko dla samej przyjemności.

W aplikacji React na przykład te zasady naturalnie się objawiają, gdy oddzielimy:

Components
    ↓
Hooks
    ↓
Services
    ↓
API Layer
    ↓
Database

Głównym zadaniem komponentu jest obsługa interfejsu użytkownika.

Własny hook może zawierać logikę stanu, którą można wykorzystywać wielokrotnie.

Służba API może zajmować się komunikacją HTTP.

Tło aplikacji obsługuje logikę biznesową.

Szyna bazy danych zajmuje się trwałością danych.

Taki podział utrzymuje aplikację w zrozumiałej formie w miarę jej rozwoju.

SOLID i React

Mimo że SOLID wywodzi się z projektowania orientowanego na obiekty, kilka jego koncepcji dobrze sprawdza się w pracy z React.

Jedna odpowiedzialność

Zamiast budować jeden ogromny komponent:

Dashboard.jsx

który próbuje robić wszystko, rozdziel go na:

Dashboard
UserProfile
Statistics
RecentOrders
Notifications

Każda z tych części ma wtedy znacznie jaśniejszy zakres obowiązków.

Otwarty/zamknięty

Projektuj komponenty wielokrotnego użycia, które uzyskują nowe zachowanie poprzez props, zamiast przepisywać ich wewnętrzną strukturę za każdym razem, gdy potrzebna jest nowa wersja.

<Button variant="primary">
  Save
</Button>
<Button variant="danger">
  Delete
</Button>

Inwersja zależności

Zamiast łączyć komponent bezpośrednio z konkretnym sposobem pobierania danych, przechowuj logikę API w usłudze lub hooku.

const users = await userService.getUsers();

Sam komponent nie musi wiedzieć, w jaki sposób ta prośba jest realizowana pod spodem.

Dlaczego SOLID ma znaczenie

Rzeczywiste korzyści z SOLID niewiele mają wspólnego z nadaniem kodowi eleganckiego wyglądu.

Chodzi o to, by przyszłe zmiany były mniej uciążliwe.

Wyobraź sobie projekt obejmujący:

10 programistów → 100 funkcji → tysiące plików → ciągłe zmiany

Bez starannego projektowania jeden mały wymóg może wywołać łańcuch niepowiązanych błędów.

Dzięki jasnej separacji i luźnemu powiązaniu zmiany stają się znacznie bardziej przewidywalne.

SOLID może ci pomóc:

  • Zredukować duplikację kodu
  • Zmniejszyć ścisłe powiązania
  • Poprawić możliwości testowania
  • Ułatwić rozbudowę funkcji
  • Uproszczyć debugowanie
  • Poprawić współpracę w zespole
  • Zapewnić utrzymaność dużych aplikacji

SOLID nie oznacza nadprojektowania

To może być najważniejszy wniosek z całego tekstu.

Nie stosuj zasad SOLID jako sztywnej listy kontrolnej.

Weź prostą funkcję na przykład:

function add(a, b) {
  return a + b;
}

Ona nie wymaga pięciu klas, trzech interfejsów ani kontenera iniekcji zależności.

Celem nigdy nie było uczynienie kodu bardziej skomplikowanym.

Celem jest ułatwienie pracy z prawdziwie złożonym kodem.

Zastosuj zasady SOLID dopiero wtedy, gdy złożoność systemu faktycznie uzasadnia dodatkową strukturę.

Krótka podsumowanie

S — Jedna odpowiedzialność: klasa lub moduł powinien mieć jedną główną funkcję.

O — Otwarty/zamknięty: rozwijaj funkcjonalność, zamiast wielokrotnie edytować już działający kod.

L — Zasada podstawienia Liskova: podklasy powinny działać poprawnie wszędzie tam, gdzie oczekuje się klasę nadrzędną.

I — Zasada segregacji interfejsów: nie zmuszaj klientów do korzystania z funkcjonalności, których nie potrzebują.

D — Zasada odwrócenia zależności: polegaj na abstrakcjach, a nie na bezpośrednim używaniu konkretnych implementacji.

Ostateczne uwagi

SOLID nie został stworzony po to, by zapamiętywać pięć definicji na rozmowę kwalifikacyjną.

To sposób myślenia o projektowaniu oprogramowania.

Podczas pisania kodu warto zatrzymać się i zadać sobie pytania:

Czy ten moduł próbuje robić zbyt wiele rzeczy naraz?

Czy dodanie nowej funkcji zmusi do przepisania istniejącego kodu?

Czy wprowadzane są tu niepotrzebne zależności?

Czy ten kod można przetestować bez trudności?

Czy coś jest zmuszane do obsługi zachowań, których tak naprawdę nie potrzebuje?

Rozważanie tych pytań ma zazwyczaj większe znaczenie niż umiejętność wymienienia, co oznaczają poszczególne litery SOLID.

Dobry oprogramowanie to nie tylko takie, które teraz poprawnie funkcjonuje.

Dobry oprogramowanie to takie, które stopniowo się zmienia w sposób uporządkowany, zamiast przerodzić się w koszmar.

Literatura pokrewna

  • Dlaczego abstrakcje frontendu cicho przekształcają się w dług techniczny — Dowiedz się, dlaczego przedwczesne abstrakcje frontendu wprowadzają ukrytą złożoność oraz jak ocenić, czy wspólne komponenty, hooki lub narzędzia rzeczywiście warto tworzyć.
  • Sześć wzorów projektowych JavaScriptu do unikania kodu spaghetti — Opisuje sześć praktycznych wzorów JavaScriptu – Strategia, Fabryka, Obserwator, Adapter, Kompozycja i Kanał – które zastępują splątany kod strukturą łatwą w utrzymaniu.