Wprowadzenie
SOLID to akronim mnemotechniczny oznaczający pięć zasad projektowania obiektowego, które zostały wprowadzone przez Roberta C. Martina, powszechnie nazywanego Uncle Bob. Zasady te mają na celu pomóc projektantom oprogramowania, architektom, inżynierom i programistom w tworzeniu bardziej elastycznego, łatwego w utrzymaniu i skalowalnego oprogramowania. Przestrzegając tych zasad, można projektować klasy, które są łatwiejsze do testowania, refaktoryzacji, ponownego użycia i rozszerzania.
Akronim SOLID oznacza:
S – Zasada jednej odpowiedzialności (Single Responsibility Principle)
O – Zasada otwarte-zamknięte (Open-Closed Principle)
L – Zasada podstawienia Liskov (Liskov Substitution Principle)
I – Zasada segregacji interfejsów (Interface Segregation Principle)
D – Zasada odwrócenia zależności (Dependency Inversion Principle)
W tym artykule wyjaśnimy każdą zasadę z osobna, aby zrozumieć, jak może ona pomóc w pisaniu lepszego kodu. Dodatkowo dodamy fragmenty kodu dla każdej zasady, aby pokazać, jak można je zastosować w swojej pracy programistycznej, a także czego należy unikać w architekturze czystego kodu. Aby zademonstrować te pojęcia, użyjemy języka programowania Kotlin stworzonego przez JetBrains i współtwórców open-source.
Zasada jednej odpowiedzialności
Zasada jednej odpowiedzialności (SRP) to zasada projektowania oprogramowania, która mówi, że każda klasa lub moduł w programie powinien mieć jedną, dobrze zdefiniowaną odpowiedzialność. Oznacza to, że klasa powinna mieć tylko jeden powód do zmiany i powinna być odpowiedzialna tylko za jedną część funkcjonalności programu.
Rozważmy poniższy fragment kodu jako przykład tego, jak tę zasadę można zastosować w języku Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
Słowo kluczowe data w języku Kotlin wskazuje, że ta klasa jest klasą danych (data class), co oznacza, że jej przeznaczeniem jest przechowywanie danych i nie posiada ona żadnych złożonych zachowań. Używając tej klasy danych, możemy utworzyć klasę UserService do zarządzania użytkownikami, jak pokazano poniżej:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // kod do utworzenia użytkownika w bazie danych } fun deleteUser(user: User) { // kod do usunięcia użytkownika z bazy danych } fun updateUser(user: User) { // kod do aktualizacji użytkownika w bazie danych } fun getUser(id: Int): User { // kod do pobrania użytkownika z bazy danych } } |
W tym przykładzie klasa UserService ma jedną odpowiedzialność: zarządzanie użytkownikami w bazie danych. Każdy użytkownik jest reprezentowany przez data class User z kodu udostępnionego wcześniej. Wszystkie metody w klasie UserService są powiązane z tą odpowiedzialnością, a zatem są spójne. Sprawia to, że klasa jest łatwiejsza do zrozumienia i utrzymania, ponieważ jasne jest, że wszystkie metody w klasie są powiązane z jednym, dobrze zdefiniowanym zadaniem.
Metoda naruszająca zasadę jednej odpowiedzialności (SRP) w klasie UserService byłaby taką, która nie jest powiązana z główną odpowiedzialnością klasy, jaką jest zarządzanie użytkownikami w bazie danych. Na przykład rozważmy następujący wariant klasy UserService :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // kod do utworzenia użytkownika w bazie danych } fun deleteUser(user: User) { // kod do usunięcia użytkownika z bazy danych } fun updateUser(user: User) { // kod do zaktualizowania użytkownika w bazie danych } fun getUser(id: Int): User { // kod do pobrania użytkownika z bazy danych } fun sendEmail(user: User, subject: String, message: String) { // kod do wysłania wiadomości e-mail do użytkownika } } |
W tym przykładzie metoda sendEmail nie odnosi się do głównej odpowiedzialności klasy UserService, która polega na zarządzaniu użytkownikami w bazie danych. Ta metoda jest odpowiedzialna za wysyłanie wiadomości e-mail, co jest osobną kwestią od zarządzania użytkownikami. W rezultacie metoda ta narusza zasadę SRP, ponieważ wprowadza drugi powód do zmiany klasy UserService.
Aby przestrzegać zasady pojedynczej odpowiedzialności (Single Responsibility Principle), lepiej byłoby wydzielić funkcjonalność wysyłania wiadomości e-mail do osobnej klasy, takiej jak EmailService . Pozwoliłoby to klasie UserService skupić się na jej głównej odpowiedzialności, jaką jest zarządzanie użytkownikami, a klasie EmailService skupić się na odpowiedzialności za wysyłanie wiadomości e-mail.
Należy zauważyć, że zasada pojedynczej odpowiedzialności nie dotyczy liczby metod, jakie posiada klasa, ale raczej spójności tych metod i wyraźnego podziału odpowiedzialności wewnątrz klasy.
Zasada otwarte-zamknięte (Open-Closed Principle)
Zasada otwarte-zamknięte (OCP) to zasada projektowania oprogramowania, która mówi, że encje oprogramowania (takie jak klasy, moduły czy funkcje) powinny być otwarte na rozszerzanie, ale zamknięte na modyfikację. Oznacza to, że powinno być możliwe dodanie nowej funkcjonalności do klasy lub modułu bez zmiany jego istniejącego kodu.
Rozważmy klasę UserService z poprzedniego przykładu. Załóżmy, że chcemy dodać nową funkcję do klasy UserService , która pozwala nam wyszukiwać użytkowników po adresie e-mail. Jednym ze sposobów na zrobienie tego byłoby dodanie nowej metody do klasy UserService , jak wyróżniono w poniższym kodzie:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // kod do utworzenia użytkownika w bazie danych } fun deleteUser(user: User) { // kod do usunięcia użytkownika z bazy danych } fun updateUser(user: User) { // kod do zaktualizowania użytkownika w bazie danych } fun getUser(id: Int): User { // kod do pobrania użytkownika z bazy danych } fun searchUsersByEmail(email: String): List<User> { // kod do wyszukiwania użytkowników po adresie e-mail } } |
To podejście działa, ale narusza zasadę otwarte-zamknięte (Open Closed Principle), ponieważ musieliśmy zmodyfikować istniejącą klasę UserService w celu dodania nowej funkcji. Lepszym podejściem byłoby użycie dziedziczenia lub kompozycji w celu rozszerzenia funkcjonalności klasy UserService bez modyfikowania jej kodu.
Aby to osiągnąć, moglibyśmy utworzyć nową klasę o nazwie UserSearchService , która rozszerza klasę UserService i dodaje funkcjonalność wyszukiwania po adresie e-mail:
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // kod do wyszukiwania użytkowników po adresie e-mail } } |
W tym przykładzie klasa UserSearchService jest otwarta na rozszerzanie, ponieważ zapewnia dodatkową funkcjonalność wykraczającą poza to, co oferuje UserService klasy. Jednocześnie klasa UserService pozostaje zamknięta na modyfikacje, ponieważ nie musieliśmy modyfikować jej kodu, aby dodać funkcję wyszukiwania po adresie e-mail.
Zasada podstawienia Liskov
Zasada podstawienia Liskov (LSP) to zasada projektowania oprogramowania, która mówi, że obiekty klasy nadrzędnej powinny być zastępowalne obiektami klasy podrzędnej bez wpływu na poprawność programu. Oznacza to, że klasa podrzędna powinna być odpowiednim zamiennikiem dla swojej klasy nadrzędnej i powinna zachowywać się w ten sam sposób, co klasa nadrzędna, gdy jest używana w tym samym kontekście.
Będziemy kontynuować demonstrację, używając klas User oraz UserService z poprzednich przykładów. Aby umożliwić rozszerzenie klasy User, używamy słowa kluczowego open w języku Kotlin:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Oto oryginalna klasa UserService używająca klasy danych User powyżej:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // kod do utworzenia użytkownika w bazie danych } fun deleteUser(user: User) { // kod do usunięcia użytkownika z bazy danych } fun updateUser(user: User) { // kod do aktualizacji użytkownika w bazie danych } fun getUser(id: Int): User { // kod do pobrania użytkownika z bazy danych } } |
Załóżmy, że chcemy utworzyć podklasę klasy User o nazwie AdminUser reprezentującą użytkowników z uprawnieniami administracyjnymi. Możemy to zrobić w ten sposób:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
W tym przykładzie klasa AdminUser jest odpowiednim zamiennikiem dla klasy User, ponieważ zachowuje się w ten sam sposób co klasa User i może być używana wszędzie tam, gdzie oczekiwany jest obiekt klasy User. Na przykład możemy użyć klasy AdminUser z klasą UserService w ten sposób:
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
Ten kod jest poprawny, ponieważ klasa AdminUser jest odpowiednim zamiennikiem dla klasy User i może być używana w ten sam sposób co obiekt klasy User.
Ważne jest, aby zauważyć, że zasada podstawienia Liskov dotyczy czegoś więcej niż tylko dziedziczenia. Chodzi o zapewnienie, że obiekty podklasy zachowują się w ten sam sposób, co obiekty klasy nadrzędnej, niezależnie od sposobu implementacji podklasy. Na przykład, jeśli klasa AdminUser miałaby zachowywać się w jakiś sposób inaczej niż klasa User, naruszyłoby to zasadę podstawienia Liskov, ponieważ nie byłaby ona odpowiednim zamiennikiem dla klasy User.
Zasada segregacji interfejsów
Zasada segregacji interfejsów (ISP) to zasada projektowania oprogramowania, która mówi, że klienci nie powinni być zmuszani do zależności od interfejsów, których nie używają. Oznacza to, że ogólnie dobrym pomysłem jest tworzenie małych, wyspecjalizowanych interfejsów, które dobrze robią jedną rzecz, zamiast tworzyć duże, ogólnego przeznaczenia interfejsy, które próbują robić wiele rzeczy.
Oto przykład, jak można zastosować tę zasadę, przepisując klasy User oraz UserService z poprzednich przykładów:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // kod do utworzenia użytkownika w bazie danych } override fun deleteUser(user: User) { // kod do usunięcia użytkownika z bazy danych } override fun updateUser(user: User) { // kod do aktualizacji użytkownika w bazie danych } override fun getUser(id: Int): User { // kod do pobrania użytkownika z bazy danych } } |
W tym przykładzie UserService interfejs definiuje cztery metody związane z zarządzaniem użytkownikami w bazie danych. Klasa DatabaseUserService implementuje ten interfejs i dostarcza konkretne implementacje dla tych metod.
Załóżmy, że chcemy dodać nową funkcję do interfejsu UserService , który pozwala nam wyszukiwać użytkowników po adresie e-mail. Jednym ze sposobów byłoby dodanie nowej metody do interfejsu UserService :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
Twój kod się nie uruchomi, chyba że zaimplementujesz tę metodę również we wszystkich klasach implementujących interfejs UserService :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // kod do utworzenia użytkownika w bazie danych } override fun deleteUser(user: User) { // kod do usunięcia użytkownika z bazy danych } override fun updateUser(user: User) { // kod do aktualizacji użytkownika w bazie danych } override fun getUser(id: Int): User { // kod do pobrania użytkownika z bazy danych } override fun searchUsersByEmail(email: String): List<User> { // kod do wyszukiwania użytkowników po adresie e-mail } } |
Choć to podejście działa, narusza ono zasadę segregacji interfejsów (Interface Segregation Principle), ponieważ zmusza klasę DatabaseUserService do zaimplementowania metody ( searchUsersByEmail ), której może nie potrzebować ani nie używać.
Lepszym podejściem byłoby stworzenie osobnego interfejsu dla funkcji wyszukiwania po adresie e-mail:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Teraz mamy osobne, małe i wyspecjalizowane interfejsy, tj. UserService oraz UserSearchServicektóre mają jedną odpowiedzialność. Klasa wymagająca wszystkich funkcjonalności tych interfejsów może je zaimplementować w sposób pokazany w poniższym fragmencie kodu:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // kod do tworzenia użytkownika w bazie danych } override fun deleteUser(user: User) { // kod do usuwania użytkownika z bazy danych } override fun updateUser(user: User) { // kod do aktualizacji użytkownika w bazie danych } override fun getUser(id: Int): User { // kod do pobierania użytkownika z bazy danych } override fun searchUsersByEmail(email: String): List<User> { // kod do wyszukiwania użytkowników po adresie e-mail } } |
Jest to zgodne z zasadą segregacji interfejsów (Interface Segregation Principle), ponieważ gwarantuje, że klienci (tacy jak DatabaseUserService klasa) nie są zmuszani do zależenia od interfejsów, których nie używają.
To understand this concept better, suppose we have another class called MemoryUserService która implementuje UserService interfejs, ale nie potrzebuje funkcji wyszukiwania po adresie e-mail, możemy zapisać kod w ten sposób:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
W tym przykładzie klasa MemoryUserService musi jedynie zaimplementować metody zdefiniowane w interfejsie UserService i nie musi martwić się o funkcjonalność wyszukiwania e-mail. Pozwala to klasie MemoryUserService skupić się na jej głównej odpowiedzialności, jaką jest zarządzanie użytkownikami w pamięci, zamiast być zmuszaną do implementowania niepowiązanych funkcjonalności.
Dependency Inversion Principle
Zasada odwrócenia zależności (Dependency Inversion Principle - DIP) to zasada projektowania oprogramowania, która mówi, że moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu, lecz jedne i drugie powinny zależeć od abstrakcji. Oznacza to, że ogólnie dobrą praktyką jest projektowanie oprogramowania w taki sposób, aby komponenty wysokiego poziomu nie były powiązane z konkretnymi implementacjami komponentów niskiego poziomu, lecz zależały od abstrakcji (takich jak interfejsy lub klasy abstrakcyjne), które można zaimplementować na różne sposoby.
Przyjrzyjmy się przykładowi, jak tę zasadę można zastosować do klas User oraz UserService użytych w poprzednich fragmentach kodu:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
W tym przykładzie klasa UserService zależy od abstrakcji nazywanej interfejsem UserRepository, zamiast zależeć od konkretnej implementacji repozytorium użytkowników. Pozwala nam to na implementację interfejsu UserRepository na różne sposoby, na przykład za pomocą bazy danych lub pamięci podręcznej (in-memory), bez wpływu na klasę UserService.
Na przykład, oto implementacja interfejsu UserRepository korzystająca z bazy danych:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // kod do tworzenia użytkownika w bazie danych } override fun delete(user: User) { // kod do usuwania użytkownika z bazy danych } override fun update(user: User) { // kod do aktualizacji użytkownika w bazie danych } override fun get(id: Int): User { // kod do pobierania użytkownika z bazy danych } } |
Oto inna implementacja interfejsu UserRepository korzystająca z pamięci podręcznej (in-memory):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
To sprawia, że system jest bardziej elastyczny i łatwiejszy w utrzymaniu, ponieważ pozwala nam na zmianę implementacji repozytorium bez wpływu na resztę systemu. Ułatwia to również testowanie klasy UserService, ponieważ możemy mockować zależność UserRepository w naszych testach.
Podsumowanie
W tym artykule omówiliśmy pięć zasad SOLID kod i udostępnione fragmenty kodu spełniające każdą z zasad. Przestrzeganie tych zasad może pomóc w projektowaniu systemów oprogramowania, które są bardziej elastyczne, łatwiejsze w utrzymaniu i skalowalne. Należy jednak pamiętać, że zasady te są wytycznymi, a nie sztywnymi regułami, i to do programisty należy decyzja, kiedy i jak je zastosować w kontekście konkretnego projektu. Kontynuuj naukę, odwiedzając nasz blog, aby znaleźć bardziej szczegółowe i aktualne artykuły oraz samouczki na temat chmury obliczeniowej i DevOps, projektowania i tworzenia oprogramowania, trendów technologicznych, na które warto zwrócić uwagę, i nie tylko.
Miłego kodowania!
Komentarze
Brak komentarzy. Bądź pierwszy.