Czym jest SOLID?
SOLID to zbiór 5 zasad, które uczą nas jak powinniśmy tworzyć nasze funkcje i klasy, aby nasz kod był łatwy w zrozumieniu, utrzymaniu, testowaniu oraz rozwijaniu.
Źle napisany kod możemy porównać do nieumiejętnej budowy domu. Jeśli zbyt pochopnie i nieprawidłowo wykonamy instalacje elektryczną to w przyszłości może okazać się, że nie spełnia ona naszych potrzeb. Nagle proste dodanie lampy czy nowego gniazdka może okazać się kłopotliwe i czasochłonne. Taka zmiana może być bardzo kosztowna, a w programowaniu zmiany są nieuniknione.
Więc aby w przyszłości ułatwić sobie życie musimy pisać kod, który można łatwo zmienić. Sprawienie, że nasz kod zadziała po raz pierwszy jest bardzo łatwe, ale podczas kolejnych iteracji w których będziemy dodawać nowe funkcjonalności, zmieniać istniejący kod oraz naprawiać błędy, może się to okazać coraz bardziej czasochłonne i problematyczne. Nie sztuką jest napisać kod szybko, sztuką jest napisać go dobrze. Możemy to osiągnąć przez zastosowanie zbioru zasad jakim jest SOLID.
Jakie są zalety ze stosowania SOLID?
Kod staje się łatwy w zrozumieniu, testowaniu, utrzymaniu oraz rozwijaniu. Osiągamy to dzięki odpowiedniemu podziałowi, który wprost mówi nam za co odpowiada dana klasa i funkcja. Dzięki temu możemy z łatwością podzielić nasz kod na niezależne komponenty oraz warstwy i odwzorować nasz podział na poziomie architektury. Używanie interfejsów, abstrakcji oraz Dependency Inversion sprawia, że nasz kod staje się bardziej elastyczny co umożliwia łatwe wprowadzanie zmian.
Nasza aplikacja:
Żeby było Wam łatwiej zrozumieć zasady SOLID posłużymy się w tym artykule przykładem. Wyobraźcie sobie, że jesteśmy programistami w firmie X. Zostaliśmy zaproszeni na comiesięczne spotkanie na którym dział finansów, marketingu oraz nasz CEO wspólnie zadecydowali, że w aplikacji nad, którą pracujemy należy dodać funkcje generowania raportów finansowych, które będą wysyłane na konkretny email. Naszym zadaniem, jako programistów, jest oczywiście dostarczenie tej funkcjonalności.
Nasz początkowy kod może wyglądać na przykład następująco:
class ReportService { private awsEmailService = new Aws(...); public generateReport(data: Data): void { // Logika odpowiadająca za generowanie raportu const report = {...}; // Logika odpowiadająca za wysłanie maila z raportem this.awsEmailService.send(email, 'email@email.pl'); } } const reportService = new ReportService(); reportService.generateReport(exampleData);
Mimo, że jest na razie mały to już łamie co najmniej jedną z zasad SOLID. W miarę rozwoju naszej funkcjonalności przekonamy się, że zasady te mogą okazać się bardzo przydatne. Pozwolą nam uniknąć wielu problemów oraz sprawią, że nasza funkcjonalność będzie o wiele łatwiejsza w rozwoju.
S: Single Responsibilty Principle
Klasa lub funkcja powinna odpowiadać za jedną rzecz i mieć tylko jeden powód do zmiany.
Większość osób stosuje się tylko do pierwszej części tej zasady, ponieważ nie zdają sobie sprawy o istnieniu drugiej lub źle ją interpretują.
Klasa lub funkcja powinna odpowiadać za jedną rzecz
Pierwsza część zasady mówi nam o tym, że klasa lub funkcja powinna odpowiadać tylko za jedną konkretną rzecz. Spójrzmy zatem na nasz serwis. Już na pierwszy rzut oka widać, że nie spełnia on tego założenia. Nasza klasa oraz metoda generateReport
odpowiada jednocześnie za generowanie raportu oraz jego wysyłkę za pomocą maila. Ktoś w przyszłości może chcieć skorzystać z naszej klasy w celu automatycznego wygenerowania kilku tysięcy raportów dziennie oraz zapisaniu ich na dysku. Szczęśliwiec, który to zrobi, może mieć niemałą niespodziankę, wysyłając email do naszego CEO za każdym razem kiedy wygeneruje się raport. Chyba nie trudno będzie sobie wyobrazić jego reakcje, jeśli nasza aplikacja będzie generować kilka tysięcy raportów dziennie.
Aby uniknąć takich problemów nasza klasa oraz metoda powinna odpowiadać tylko za jedną rzecz. Na początek rozdzielmy nasz kod na dwie niezależne klasy. Pierwsza klasa będzie odpowiadać za generowanie raportu, natomiast druga za obsługę maili. W drugiej kolejności wydzielmy funkcjonalności odpowiadające za wysyłanie raportu oraz formatowanie danych do osobnych metod. Spójrzmy jak zmieni się nasz kod:
// Klasa odpowiadająca za pojedynczy raport class Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportService { private awsEmailService = new AwsEmailService(); public generateAndSendReport(email: string, data: Data): void { this.awsEmailService.sendEmailViaAWS(email, new Report(data).getReport()); } } class AwsEmailService { private awsInstance = new Aws(...); public sendEmailViaAWS(email: string, data: string): void { this.awsInstance.sendEmail(email, data); } } const reportService = new ReportService(); const report = reportService.generateAndSendReport('email@email.pl', exampleData);
Dzięki takiemu podziałowi nikt nie musi się zastanawiać za co odpowiada dana klasa oraz metoda. Zatem pierwszą część czyli pojedynczą odpowiedzialność mamy już za sobą. Skupmy się teraz na drugiej części czyli jednym powodzie do zmiany. Jak zapewne dobrze pamiętamy działy finansów, marketingu oraz nasz CEO wspólnie zadecydowali o potrzebie stworzenia funkcjonalności do generowania i wysyłania raportów. Natomiast to była ostatnia wspólna decyzja…
Klasa powinna mieć tylko jeden powód do zmiany
Za jakiś czas przychodzi do nas człowiek z działu finansów i mówi, że raport jest bardzo przydatny, ale fajnie byłoby do niego dodać informacje na temat pensji każdego pracownika. Zatem spełniamy jego prośbę i dodajemy szczegółowe informacje na temat pensji pracowników. Na drugi dzień przychodzi do nas CEO i mówi, że spodobał mu się pomysł z dodaniem informacji na temat pensji i chciałby dodać szczegółowe informacje na temat przychodów firmy. Zatem nie pozostaje nam nic innego jak dodanie tych funkcjonalności. Na trzeci dzień przychodzi do nas wściekły człowiek z działu marketingu i mówi, że informacje na temat pensji i szczegółowych przychodów firmy nie mogą się znajdować w ich raporcie, który będą prezentować klientowi bo to narusza wiele zasad bezpieczeństwa! Prosi o ich natychmiastowe usunięcie. Natomiast dla naszego CEO oraz działu finansów te informacje są bardzo potrzebne.
Co zatem robimy? Na pierwszy rzut oka jedynym słusznym rozwiązaniem będzie dodanie ifów, które będą sprawdzać jaki dział generuje raport i na tej podstawie będziemy dodawać potrzebne informacje.
// Klasa odpowiadająca za pojedynczy raport class Report { constructor(data: Data, actor: string) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu if (actor === 'ceo') { // ... } if (actor === 'financeDepartment') { // ... } if (actor === 'marketingDepartment') { // .. } } } class ReportService { private awsEmailService = new AwsEmailService(); public generateAndSendReport(email: string, data: Data, actor: string): void { this.awsEmailService.sendEmailViaAWS(email, new Report(data, actor).getReport()); } } class AwsEmailService { private awsInstance = new Aws(...); public sendEmailViaAWS(email: string, data: string): void { this.awsInstance.sendEmail(email, data); } } const reportService = new ReportService(); const report = reportService.generateAndSendReport('email@email.pl', exampleData, 'actor');
Za pierwszym razem wszystko zadziała, ale co w przypadku gdy w przyszłości pojawi się więcej różnic oraz inne działy zaczną korzystać z naszego raportu? Wtedy nasza klasa Report
stanie się ogromna. Ponadto będzie się często zmieniać i będzie ciężka w utrzymaniu, ponieważ wiele osób będzie żądać, żebyśmy wprowadzili dla nich jakieś zmiany. Co więcej takie zmiany mogą wpływać na całą klasę co spowoduje masę błędów. Gdyby nasza klasa Report
odpowiadała tylko jednej osobie/działowi nie mielibyśmy takich problemów. Żeby rozwiązać ten problem stworzymy osobną klasę raportu dla każdego działu. Każdy z nich będzie implementował wspólny interfejs żeby ujednolicić ich zachowanie.
Kod po zmianach
interface Report { getReport(): string; } class ReportForCeo implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForMarketingDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForFinanceDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportService { private awsEmailService = new AwsEmailService(); public generateAndSendReport(email: string, data: Data, actor: string): void { let report: Report; if (actor === 'ceo') { report = new ReportForCeo(data); } if (actor === 'financeDepartment') { report = new ReportForFinanceDepartment(data); } if (actor === 'marketingDepartment') { report = new ReportForMarketingDepartment(data); } this.awsEmailService.sendEmailViaAWS(email, report.getReport()); } } class AwsEmailService { private awsInstance = new Aws(...); public sendEmailViaAWS(email: string, data: string): void { this.awsInstance.sendEmail(email, data); } } const reportService = new ReportService(); const report = reportService.generateAndSendReport('email@email.pl', exampleData, 'actor');
Jak widzisz teraz każdy raport ma tylko jeden powód do zmiany. Pewnie powiesz, że teraz ify są w klasie ReportService
. Masz racje ale odpowiadają one tylko za wygenerowanie odpowiedniego raportu, a nie zmieniają logiki samego generowania. Może Ci się też również wydawać, że dzięki takiemu rozwiązaniu z duplikujesz kod. Możesz tego uniknąć trzymając wspólną część kodu w klasie abstrakcyjnej (można jej użyć zamiast interfejsu), po której będą dziedziczyć twoje klasy. Czasami okazuje się jednak, że powielenie kodu jest tylko chwilowe bądź przypadkowe, za jakiś czas kod, którym był na początku identyczny może wyglądać zupełnie inaczej.
Skąd wiadomo czy spełniamy zasadę SRP?
Odpowiedź na pytanie czy dana klasa, metoda lub funkcja spełnia zasadę SRP jest bardzo subiektywna. Nierzadko jest przedmiotem wielu gorących debat, w które angażują się programiści. Tak naprawdę nie ma jednoznacznej odpowiedzi. Ewoluuje ona wraz z rozwojem naszego programu. To, co teraz jest pojedynczym, dobrze zdefiniowanym zadaniem potem może stać się wieloma źle zdefiniowanymi zadaniami. Jedyną radą jakiej mogę udzielić jest to, aby korzystać ze zdrowego rozsądku oraz osądu swojego jak i współpracowników. Każdy popełnia błędy. Kluczem jest, aby zdawać sobie z nich sprawę i jak najszybciej je poprawiać.
Dwie oznaki tego, że twoja klasa, metoda lub funkcja nie spełnia zasady SRP
- Jeśli odpowiedź na pytanie „Co robi twoja klasa/metoda/funkcja?” wymaga dłuższej chwili zastanowienia lub co gorsza szczegółowej analizy kodu
- Jeśli wypowiedź na temat tego co robi twoja klasa/metoda/funkcja zajmuje więcej niż 5 sekund (nie musi to być dokładnie 5 sekund chodzi po prostu o dłuższą chwilę)
O: Open-Closed Principle
Oprogramowanie powinno być otwarte na rozszerzenia, ale zamknięte na modyfikacje.
Druga zasada, ze zbioru SOLID mówi nam, że jeśli chcemy dodać jakaś nową funkcjonalność do naszej aplikacji, to nie powinniśmy być zmuszeni do zmiany aktualnie posiadanego kodu. Aby to osiągnąć powinniśmy bazować na interfejsach oraz abstrakcjach, a nie faktycznie zaimplementowanych klasach. Brzmi niezrozumiale? Nie martw się! Pokaże Ci to na przykładzie naszej aplikacji.
Zostawmy na chwilę nasz serwis do wysyłania maili i skupmy się na samych raportach. Zauważ, że jeśli zgłosi się do nas jakiś nowy dział z prośbą o dodanie nowego raportu, to nie tylko jesteśmy zmuszeni do dodania klasy odpowiadającej za nowy raport, ale również do modyfikacji naszej klasy ReportService
w celu dodania nowego warunku.
interface Report { getReport(): string; } // Nowy raport dla działu IT class ReportForITDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportService { private awsEmailService = new AwsEmailService(); public generateAndSendReport(email: string, data: Data, actor: string): void { let report: Report; if (actor === 'ceo') { report = new ReportForCeo(data); } if (actor === 'financeDepartment') { report = new ReportForFinanceDepartment(data); } if (actor === 'marketingDepartment') { report = new ReportForMarketingDepartment(data); } // Aby obsłużyć nowy raport musimy zmodyfikować naszą klase przez dodanie kolejnego ifa if (actor === 'marketingDepartment') { report = new ReportForITDepartment(data); } this.awsEmailService.sendEmailViaAWS(email, report.getReport()); } }
Jak temu zaradzić? Po pierwsze musimy zdefiniować interfejs (my już takowy posiadamy), który będzie opisywał co powinna posiadać nasza klasa z raportem. W naszym przypadku będzie musiała posiadać metodę getReport
. Następnie musimy zmodyfikować nieco nasz kod w klasie ReportService
. Dodajmy metodę registerReportForActor(actor, report)
, która będzie odpowiedzialna za dynamiczne dodawanie nowej osoby/departamentu oraz odpowiadających im raportów. Możemy się również pozbyć naszych ifów.
interface Report { getReport(): string; } class ReportForCeo implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForMarketingDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForFinanceDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } // Nowy raport dla działu IT class ReportForITDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } interface ActorToReportMap { [key: string]: Report; } class ReportService { private awsEmailService = new AwsEmailService(); private actorToReportMap: ActorToReportMap = {}; public generateAndSendReport(email: string, data: Data, actor: string): void { const report = new this.actorToReportMap[actor](data); this.awsEmailService.sendEmailViaAWS(email, report.getReport()); } public registerReportForActor(actor: string, report: Report): void { this.actorToReportMap[actor] = report; } } const reportService = new ReportService(); reportService.registerReportForActor('ceo', ReportForCeo); reportService.registerReportForActor('marketingDepartment', ReportForMarketingDepartment); reportService.registerReportForActor('financeDepartment', ReportForFinanceDepartment); reportService.registerReportForActor('idDepartment', ReportForITDepartment); reportService.generateAndSendReport('email@email.pl', exampleData, 'ceo');
Teraz nasz kod wygląda zdecydowanie, lepiej. Myśl o dodaniu nowego raportu nie przyprawia nas o ból głowy. Jedyne co musimy zrobić to stworzyć nową klasę, która implementuje interfejs i zarejestrować ją w naszym serwisie.
L: Liskov-Substitution Principle
Jeżeli dla każdego obiektu o1 typu S istnieje obiekt o2 typu T taki, że dla wszystkich programów P zdefiniowanych w kategoriach T zachowanie P pozostanie niezmienione, gdy o1 zostanie podstawione za o2, to S jest podtypem T.
Trzecia zasada SOLID przyprawia o ból głowy. Może wujek Bob rozjaśni nam nieco tę definicję:
Aby zbudować system oprogramowania z wymiennych części, muszą być one zgodne z umową, która pozwala na zamianę tych części na inne.
Klasy
Naszą ” umową” jest klasa, po której można dziedziczyć. Jeśli w jakieś miejsce (np. argument funkcji lub konstruktor) możemy umieścić naszą klasę bazową, to powinna istnieć możliwość zamiany klasy bazowej na klasę, która po niej dziedziczy. Klient, który korzysta z tej klasy nie powinien zauważyć żadnej różnicy. Subklasa nie powinna modyfikować zachowań klasy bazowej, a jedynie je rozszerzać. Nie powinniśmy np. wyrzucać nowych wyjątków, o których nie wie nasza klasa bazowa, bądź pozbywać się jej funkcjonalności. Oczywiście wynik jaki otrzymamy po zamianie klas może być inny. Istotne jest to aby klasa lub funkcja, która korzysta z naszej subklasy działała poprawnie, a sama subklasa spełniała wszystkie założenia klasy bazowej.
interface File { type: string; // ... } class FileService { public save(path: string, file: File): File { // Logika odpowiedzialna za zapis } public read(path: string): File { // Logika odpowiedzianla za odczyt } } class PdfService extends FileService { public save(path: string, file: File): File { // Tutaj zamiast zapisywać plik po prostu go zwracamy przez co nasza subklasa zmienia zachowanie klasy po której dziedziczy } } class CsvService extends FileService { public read(path: string): File { // Tutaj natomiast zwracamy błąd, którego nie uwzględniliśmy w klasie po której dziedziczymy } } // Klasa spełniająca zasadę class TxtService extends FileService{ public save(path: string, file: File): File { // Logika odpowiedzialna za zapis // Tutaj nie modyfikujemy zachowania klasy FileService, a jedynie rozszerzamy ją o logowanie console.log('success'); } public read(path: string): File { // Logika odpowiedzianla za odczyt // Tutaj nie modyfikujemy zachowania klasy FileService, a jedynie rozszerzamy ją o logowanie console.log('success'); } } class ExampleService { constructor(fileService: FileService) {} } const fileService = new FileService(); const pdfService = new PdfService(); const csvService = new CsvService(); const txtService = new TxtService(); new ExampleService(fileService); // Zadziała prawidłowo new ExampleService(txtService); // Zadziała prawidłowo new ExampleService(pdfService); // Nasz plik się nie zapisze new ExampleService(csvService); // Przy odczycie zwracamy błąd, którego nie uwzględniamy w klasie bazowej przez co nasza aplikacja przestanie działać
Interfejsy
Zasada ta również tyczy się interfejsów. Możesz zauważyć, że „umową” w naszej funkcjonalności do generowania raportów jest interfejs Report
, który implementują wszystkie nasze klasy. Definiuje on, że nasze klasy muszą posiadać metodę getReport
, która zwróci nam raport. Jako, że nasza klasa ReportService
bazuje właśnie na interfejsie, a nie na implementacji bezproblemowo możemy dodawać oraz podmieniać nowe rodzaje raportów dopóki będą implementować wcześniej wspomniany interfejs.
interface Report { getReport(): string; } class ReportForCeo implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForMarketingDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForFinanceDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } // Nowy raport dla działu IT class ReportForITDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } interface ActorToReportMap { [key: string]: Report; } class ReportService { private awsEmailService = new AwsEmailService(); private actorToReportMap: ActorToReportMap = {}; public generateAndSendReport(email: string, data: Data, actor: string): void { const report = new this.actorToReportMap[actor](data); this.awsEmailService.sendEmailViaAWS(email, report.getReport()); } public registerReportForActor(actor: string, report: Report): void { this.actorToReportMap[actor] = report; } } const reportService = new ReportService(); reportService.registerReportForActor('ceo', ReportForCeo); reportService.registerReportForActor('marketingDepartment', ReportForMarketingDepartment); reportService.registerReportForActor('financeDepartment', ReportForFinanceDepartment); reportService.registerReportForActor('idDepartment', ReportForITDepartment); reportService.generateAndSendReport('email@email.pl', exampleData, 'ceo');
I: Interface Segregation Principle
Klienci nie powinni być zmuszani do polegania na interfejsach, których nie używają.
Aby to osiągnąć powinniśmy podzielić unikalne funkcjonalności na wiele interfejsów, tak aby nie zmuszać klienta naszej klasy do posiadania wiedzy o metodach, których nie używa.
Wyobraźmy sobie sytuacje, w której będziemy wykorzystywać nasza klasę ReportForCeo
w innym serwisie. Nazwijmy go OtherService
. Wymaga on aby nasz raport posiadał dodatkową metodę, która pozwoli na jego modyfikację nazwijmy ją po prostu update
. Warto zauważyć, że potrzebujemy tej funkcjonalności tylko dla klasy OtherService
. ReportService
nigdy z niej nie skorzysta. Spójrzmy na przykład:
// Dodajemy metodę update do naszego interfejsu interface Report { getReport(): string; update(): void; } class ReportForCeo implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } public update(): void { // Logika odpowiadajaca za aktualizacje naszego raportu } } // Pomimo, że w tej klasie nie potrzebujemy metody update, to i tak musimy ją zaimplementować bo wymusza to na nas interfejs class ReportForMarketingDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } public update(): void { // Logika odpowiadajaca za aktualizacje naszego raportu } } class ReportService { private awsEmailService = new AwsEmailService(); private actorToReportMap: ActorToReportMap = {}; public generateAndSendReport(email: string, data: Data, actor: string): void { const report = new this.actorToReportMap[actor](data); this.awsEmailService.sendEmailViaAWS(email, report.getReport()); } public registerReportForActor(actor: string, report: Report): void { this.actorToReportMap[actor] = report; } } // Tutaj natomiast interfejs wymaga, aby nasz Report posiadał metodę getReport, pomimo tego, że nie będzie ona nigdy użyta class OtherService { constructor(report: Report) { report.update(); } } const reportService = new ReportService(); // Argument poniższej metody implementuje interfejs Report. Więc wymaga on, aby nasz Report posiadał metodę update pomimo tego, że nie jest wykorzystywana w naszej klasie reportService.registerReportForActor('ceo', ReportForCeo);
Jak możesz zauważyć bazując na jednym wielkim interfejsie definiującym całą klase wymagamy implementacji niepotrzebnych metod, nawet kiedy nigdy z nich nie skorzystamy. Uniemożliwia to stworzenie nowego raportu, który będzie wykorzystywany tylko w klasie ReportService
bez implementacji metody update
, której nigdy nie użyjemy. Im więcej będziemy mieli metod oraz właściwości tym bardziej stanie się to problematyczne. Rozwiązaniem jest tutaj czwarta zasada SOLID, która mówi aby podzielić nasz interfejs na dwa niezależne od siebie. Dzięki temu zyskamy klarowny podział funkcjonalności, co ułatwi nam tworzenie nowych raportów oraz ich podmianę. Tworząc nowy raport dla klasy ReportService
nie będziemy zmuszeni do tworzenia na nim metody update
.
// Podzieliliśmy nasz interfejs na dwa niezależne od siebie interface ReportGenerator { getReport(): string; } interface UpdatableReport { update(): void; } // Ta klasa będzie wykorzystywana przez ReportService oraz OtherService, więc musi implementować 2 interfejsy class ReportForCeo implements ReportGenerator, UpdatableReport { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } public update(): void { // Logika odpowiadajaca za aktualizacje naszego raportu } } // Ta klasa będzie wykorzystywana tylko przez ReportService, więc implementujemy tylko jeden interfejs class ReportForMarketingDepartment implements ReportGenerator { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportService { private awsEmailService = new AwsEmailService(); private actorToReportMap: ActorToReportMap = {}; public generateAndSendReport(email: string, data: Data, actor: string): void { const report = new this.actorToReportMap[actor](data); this.awsEmailService.sendEmailViaAWS(email, report.getReport()); } public registerReportForActor(actor: string, report: ReportGenerator): void { this.actorToReportMap[actor] = report; } } // Teraz nasza klasa nie wymaga aby raport posiadał metodę getReport class OtherService { constructor(report: UpdatableReport ) { report.update(); } } const reportService = new ReportService(); // Nasza metoda nie wymaga aby raport miał zaimplementowaną metodę update reportService.registerReportForActor('ceo', ReportForCeo); reportService.registerReportForActor('marketingDepartment', ReportForMarketingDepartment);
Taki podział znacząco ułatwia nam pracę. Należy jednak pamiętać, żeby się dobrze zastanowić nad podziałem interfejsów, ponieważ jeśli nieumiejętnie to zrobimy możemy znacznie skomplikować nasz kod.
D: Dependency Inversion Principle
Abstrakcja nie powinna zależeć od detali. Detale powinny zależeć od abstrakcji.
Moduły wysokiego poziomu, nie powinny zależeć od modułów niskiego poziomu. Oba powinny zależeć od abstrakcji.
Czym jest abstrakcja?
W ostatniej zasadzie SOLID istotna jest znajomość pojęcia o nazwie abstrakcja. To nic innego jak klasa abstrakcyjna lub interfejs, które opisują jakieś zachowania i nie są ich faktyczną implementacją. Detalem natomiast jest klasa, która zawiera szczegóły implementacyjne tych zachowań. Spójrzmy na przykład:
// Abstrakcja interface Report { getReport(): string; } // Detal class ReportForCeo { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } }
W powyższym przykładzie abstrakcją jest interfejs o nazwie Report
, ponieważ zawiera on opis zachowania jakim jest metoda getReport
. Nie zawiera on natomiast szczegółów implementacyjnych. Jedyne co wiemy to to, że taka metoda istnieje oraz, że zwróci nam Report
. Natomiast nie wiemy w jaki sposób to się stanie. Detalem natomiast jest klasa ReportForCeo
, ponieważ dokładnie opisuje w jaki sposób dostaniemy nasz Report
.
Abstrakcja nie powinna zależeć od detali. Detale powinny zależeć od abstrakcji.
// Abstrakcja interface Report { getReport(): ReportForCeo; } // Detal class ReportForCeo { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } }
Widzimy tutaj, że nasza abstrakcja, czyli interfejs o nazwie Report
zależy od klasy ReportForCeo
, która jest detalem. Dlaczego to jest błędne? Detale często się zmieniają przez co są niestabilne. Wszystkie zmiany w klasie ReportForCeo
będą oddziaływać na nasz interfejs. Chociażby dodanie nowego pola do naszej klasy ReportForCeo
sprawi, że nasz interfejs się zmieni. Jeśli przyjrzymy się naszej metodzie registerReportForActor
zauważymy, że jeden z jej argumentów implementuje nasz interfejs. To sprawia, że do każdego raportu, który chcemy przekazać do tej metody będziemy musieli dodać to samo pole, które jest w klasie ReportForCeo
, nawet jeśli będzie ono nie używane. Jak powinien zatem wyglądać nasz kod?
// Abstrakcja interface Report { getReport(): string; } // Detal class ReportForCeo implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } }
Teraz to detal jakim jest klasa ReportForCeo
zależy od abstrakcji. Ewentualne zmiany, które w niej zajdą nie będą oddziaływać na resztę naszego systemu. Abstrakcje są o wiele stabilniejsze od detali, ponieważ zmiany zachodzą w nich zdecydowanie rzadziej niż w detalach. Dodając nowe pole do klasy ReportForCeo
nie będziemy zmuszeni do dokonania zmian w innych miejscach.
Moduły wysokiego poziomu, nie powinny zależeć od modułów niskiego poziomu. Oba powinny zależeć od abstrakcji
Odłóżmy na chwilę nasze raporty i skupmy się na klasie ReportService
oraz AwsEmailService
. Nasz CEO stwierdził, że wysyłanie maili za pomocą AWS jest nieopłacalne i prosi nas o dodanie nowego dostawcy, którym jest Google. Zmieniamy zatem nazwę klasy AwsEmailService
na GoogleEmailService
, podmieniamy jej metody oraz modyfikujemy klasę ReportService
aby od teraz korzystała z naszej nowej klasy. Po miesiącu okazuje się, że CEO zmienił zdanie i chciałby powrócić na AWS. Co więc robimy? Zmieniamy nazwę klasy GoogleEmailService
na AwsEmailService
, podmieniamy jej metody oraz modyfikujemy klasę ReportService
aby od teraz korzystała z naszej nowej klasy. Po miesiącu znowu przychodzi do nas CEO i mówi, że google znów stał się bardziej opłacalny. Co zatem robimy?
Podstawowym problemem jest to, że nasz moduł wyższego poziomu jakim jest ReportService
zależy bezpośrednio od modułu niższego poziomu jakim jest AwsEmailService
. Spójrzmy jak możemy temu zaradzić.
class ReportService { private emailService: EmailService; private actorToReportMap: ActorToReportMap = {}; constructor(emailService: EmailService) { this.emailService = emailService; } public generateAndSendReport(email: string, data: Data, actor: string): void { const report = new this.actorToReportMap[actor](data); this.emailService.send(email, new Report(data).getReport()); } public registerReportForActor(actor: string, report: Report): void { this.actorToReportMap[actor] = report; } } interface EmailService { send(email: string, data: string): void; } class AwsEmailService implements EmailService { private awsInstance = new Aws(...); public send(email: string, data: string): void { this.awsInstance.sendEmail(email, data); } } class GoogleEmailService implements EmailService { private googleInstance = new Google(...); public send(email: string, data: string): void { this.googleInstance.sendEmail(email, data); } } const awsEmailService = new AwsEmailService(); const googleEmailService = new GoogleEmailService(); const reportService = new ReportService(awsEmailService);
Pierwszą rzeczą, jaką należy zrobić jest zdefiniowanie nowego interfejsu o nazwie EmailService
. Następnie z klasy ReportService
usuwamy bezpośrednie odwołanie do klasy AwsEmailService
. Zamiast tego odwołujemy się do nowo utworzonego interfejsu, a naszą klasę odpowiadającą za wysyłanie maili wstrzykujemy przez konstruktor. Dzięki temu podobnie jak w przypadku raportów mamy teraz możliwość zdefiniowania kilku dostawców i bezproblemowego podmieniania ich w razie potrzeby dopóki dopóty implementują interfejs o nazwie EmailService
. Teraz nasz moduł wyższego poziomy czyli ReportService
nie jest zależny od modułu niższego poziomu jakim jest AwsEmailService
. Zamiast tego oba moduły zależą od abstrakcji czyli interfejsu o nazwie EmailService
, dzięki czemu spełniamy ostatnią zasadę ze zbioru SOLID. Teraz podmiana lub dodanie nowego dostawcy nie powinno stanowić dla nas żadnego problemu. Ułatwia nam to również testowanie kodu, ponieważ podczas testów do naszej klasy możemy przekazać z mockowany moduł, który nie będzie wymagał połączenia z zewnętrznym serwisem jakim jest AWS, czy Google.
Finalna wersja naszej aplikacji
interface Report { getReport(): string; } class ReportForCeo implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForMarketingDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForFinanceDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } class ReportForITDepartment implements Report { constructor(data: Data) { // Logika odpowiadająca za stworzenie raportu } public getReport(): string { // Logika odpowiadająca za zwrócenie z formatowanego raportu } } interface ActorToReportMap { [key: string]: Report; } class ReportService { private emailService: EmailService; private actorToReportMap: ActorToReportMap = {}; constructor(emailService: EmailService) { this.emailService = emailService; } public generateAndSendReport(email: string, data: Data, actor: string): void { const report = new this.actorToReportMap[actor](data); this.emailService.send(email, new Report(data).getReport()); } public registerReportForActor(actor: string, report: Report): void { this.actorToReportMap[actor] = report; } } interface EmailService { send(email: string, data: string): void; } class AwsEmailService implements EmailService { private awsInstance = new Aws(...); public send(email: string, data: string): void { this.awsInstance.sendEmail(email, data); } } class GoogleEmailService implements EmailService { private googleInstance = new Google(...); public send(email: string, data: string): void { this.googleInstance.sendEmail(email, data); } } const awsEmailService = new AwsEmailService(); const googleEmailService = new GoogleEmailService(); const reportService = new ReportService(awsEmailService);
Podsumowanie
Jak, widzisz SOLID to bardzo przydatny zbiór zasad, których należy przestrzegać. Dzięki nim nasz kod staje się elastyczny przez co wprowadzanie zmian jest łatwe i szybkie, a przez to, że polegamy na abstrakcjach oraz Dependency Inversion testowanie nie przyprawia nas o ból głowy. Należy jednak pamiętać, aby być pragmatycznym i nie podążać ślepo za wszystkimi zasadami.
Źródła:
Clean Architecture : A Craftsman’s Guide to Software Structure and Design