- Co to jest?
- Przykładowa aplikacja
- S: Single Responsibility Principle(SRP)
- O: Open-Closed Principle(OCP)
- L: Liskov Substitution Principle (LSP)
- I: Interface Segregation Principle (ISP)
- D: Dependency Inversion Principle (DIP)
- Bibliografia
To zbiór pięciu zasad, które definiują, w jaki sposób powinien być budowany, rozwijany i utrzymywany projekt od strony kodu. Korzystając z tych zasad kod,
który powstanie nie powinien przypominać plątaniny przewodów podłączonych do bomby, która wybuchnie jeżeli usuniemy niewłaściwy przewód.
Aby w przystępny sposób zaprezentować dlaczego warto stosować zasady SOLID stworzyłem prostą aplikację, w której zaprezentowałem różnice między kodem, w który nie uwzględniono tych zasad a kodem korzystającym z nich w prawidłowy sposób.
Przyjmijmy, że tworzymy system przeznaczony dla pracowników uczelni wyższej, który ma być odpowiedzialny za tworzenie raportów na temat wyników studentów oraz komunikacji z nimi.
Klasy i metody powinny być odpowiedzialne tylko za jedną rzecz oraz powinna istnieć tylko jeden powód aby je zmieniać
SRP wymaga, aby klasa miała tylko jeden powód do zmiany. Klasa zgodna z tą zasadą wykonuje tylko kilka powiązanych zadań. Myśląc w ramach SRP, nie trzeba ograniczać się tylko do problemów powiązanych z klasami.
Zasadę SRP można zastosować także do metod czy modułów, upewniając się, że są odpowiedzialne, tylko za jedną rzecz oraz że mają tylko jeden powód do zmiany.
Historyjka:
Doktor Jan Kowalski zgłosił potrzebę generowania raportów z wynikami jego studentów z różnych grup, oraz możliwości przesyłania tych raportów.
class StudentsReport {
private report: Map<number, Student> = new Map<number, Student>();
constructor(students: StudentData[]) {
this.createReport(students);
}
private createReport(students: StudentData[]): void {
// create report logic
}
private formatReport(type: ReportType): HTMLElement {
const formattedReport = document.createElement('table');
switch (type) {
case ReportType.DEAN:
// Logic responsible for generating report for dean
break;
case ReportType.LECTURER:
// Logic responsible for generating report for lecturer
break;
case ReportType.UNIVERSITY_WORKER:
// Logic responsible for generating report for university worker
break;
default:
throw Error('Not supported report type')
} // create formatted report
return formattedReport;
}
public generateAndSendReport(email: string, type: ReportType): Promise<void> {
const preparedReport: HTMLElement = this.formatReport(type);
void SaveFileService.saveFile(preparedReport);
void UniversityEmailService.sendEmail(email, preparedReport);
return Promise.resolve();
}
}
Kod
Diagram UML
Powyższa klasa nie stosuje zasad SOLID i ma parę błędów, które poniżej wypunktuję:
generateReport
→ nie jest odpowiedzialny tylko za generowanie raportów, ale również za automatyczne wysyłanie tych raportów e-mailem. Jest to słabe rozwiązanie, ponieważ, jeżeli osoba generująca raportu ustawi sobie jakąś regułę, która będzie automatycznie generować raport dla wszystkich grup studentów (powiedzmy cyklicznie codziennie), wtedy prawdopodobnie zawalimy komuś skrzynkę, a może nawet wyślemy dane, które nie powinny być wysłane.
class StudentsReport {
private report: Map<number, Student> = new Map<number, Student>();
constructor(students: StudentData[]) {
this.createReport(students);
}
private formatReport(type: ReportType): HTMLElement {
const formattedReport = document.createElement('table');
switch (type) {
case ReportType.DEAN:
// Logic responsible for generating report for dean
break;
case ReportType.LECTURER:
// Logic responsible for generating report for lecturer
break;
case ReportType.UNIVERSITY_WORKER:
// Logic responsible for generating report for university worker
break;
default:
throw Error('Not supported report type')
} // create formatted report
return formattedReport;
}
private createReport(students: StudentData[]): void {
// create report logic
}
public getReport(type: ReportType): HTMLElement {
return this.formatReport(type);
}
}
const studentsReport: StudentsReport = new StudentsReport([]);
const report: HTMLElement = studentsReport.getReport(ReportType.DEAN);
void GenerateReportService.generateReport('./path', report);
void UniversityEmailService.sendEmail('test@test.pl', report);
Kod
Diagram UML
Po refaktoryzacji możemy zauważyć, że StudentsReport odpowiada w tym momencie tylko za utworzenie odpowiednio sformatowanego raportu, który zostanie utworzony przez jeden z formaterów. Może i metoda odpowiadająca za formatowanie składa się z wielu ifów, ale odpowiada tylko za jedną rzecz, mianowicie formatowanie raportu. Generowanie raportu na dysk oraz wysyłanie raportu jest realizowane przez dwie niezależne od siebie klasy. Takie klasy są teraz odpowiedzialne tylko za jedną rzecz oraz mają tylko jeden powód do zmiany
Elementy oprogramowania powinny być otwarte na rozszerzanie ale zamknięte na modyfikacje
Ryzyko zmiany istniejącej klasy polega na tym, że możemy nieumyślnie wprowadzić zmianę w logice oraz zachowaniu klasy. Rozwiązaniem tego problemu jest stworzenie klasy,
która przesłoni logikę działania oryginalnej klasy. Wykorzystując tę zasadę, będzie znacznie łatwiej utrzymywać napisany kod (nieoczekiwane zmiany w logice, bugi...) oraz reużywać już raz stworzonego kodu.
class StudentsReport {
private report: Map<number, Student> = new Map<number, Student>();
constructor(students: StudentData[]) {
this.createReport(students);
}
private formatReport(type: ReportType): HTMLElement {
const formattedReport = document.createElement('table');
switch (type) {
case ReportType.DEAN:
// Logic responsible for generating report for dean
break;
case ReportType.LECTURER:
// Logic responsible for generating report for lecturer
break;
case ReportType.UNIVERSITY_WORKER:
// Logic responsible for generating report for university worker
break;
default:
throw Error('Not supported report type')
} // create formatted report
return formattedReport;
}
private createReport(students: StudentData[]): void {
// create report logic
}
public getReport(type: ReportType): HTMLElement {
return this.formatReport(type);
}
}
Kod
Diagram UML
Podejście to ma parę wad, które wymienię poniżej:
formatReport
→ formatuje raport w zależności, dla kogo ma być on stworzony. Co w wypadku gdy będziemy chcieli dodać raport dla studenta lub innych użytkowników? Funkcja wraz z całą klasą urośnie, do ogromnych rozmiarów, co przyczyni się do tego, że kod będzie trudny w czytaniu, ciężko będzie się go utrzymywać i testować. Ponadto zmiany w tej jednej funkcji, mogą mieć wpływ na całą klasę, z czym wiąże się prawdopodobne wygenerowanie błędów.
class StudentsReport {
private report: Map<number, Student> = new Map<number, Student>();
constructor(students: StudentData[]) {
this.createReport(students);
}
private createReport(students: StudentData[]): void {
// create report logic
}
public getReport(type: ReportType): HTMLElement {
const reportFormatter: AbstractReportFormatterService = ReportFormatterServiceFactory.getReportFormatter(type);
return reportFormatter.formatReport();
}
}
class ReportFormatterServiceFactory {
public static getReportFormatter(type: ReportType): DeanReportFormatterService | LecturerReportFormatterService | UniversityWorkerReportFormatterService {
let formatterService: DeanReportFormatterService | LecturerReportFormatterService | UniversityWorkerReportFormatterService;
switch (type) {
case ReportType.DEAN:
formatterService = new DeanReportFormatterService();
break;
case ReportType.LECTURER:
formatterService = new LecturerReportFormatterService();
break;
case ReportType.UNIVERSITY_WORKER:
formatterService = new UniversityWorkerReportFormatterService();
break;
default:
throw Error('Not supported report type')
}
return formatterService;
}
}
Kod
Diagram UML
Każdy formatter otrzymał swoją własną klasę, dzięki czemu jeżeli powstanie jakiś nowy typ użytkownika to wystarczy dla niego utworzyć nową klasę i dodać przypadek w fabryce.
Klasy potomne nigdy nie powinny łamać definicji typów klas nadrzędnych
Klasa dziedzicząca z klasy podstawowej powinna jedynie rozszerzać funkcjonalność (subklasa nie powinna modyfikować zachowania klasy podstawowej np.: ilość przyjmowanych argumentów konstruktora / funkcji) klasy bazowej oraz zwracać ten sam typ danych. Oznacza to, że w miejscu klasy bazowej powinniśmy być w stanie skorzystać z dowolnej klasy po niej dziedziczącej.
class ReportFormatterServiceFactory {
public static getReportFormatter(type: ReportType): DeanReportFormatterService | LecturerReportFormatterService | UniversityWorkerReportFormatterService {
let formatterService: DeanReportFormatterService | LecturerReportFormatterService | UniversityWorkerReportFormatterService;
switch (type) {
case ReportType.DEAN:
formatterService = new DeanReportFormatterService();
break;
case ReportType.LECTURER:
formatterService = new LecturerReportFormatterService();
break;
case ReportType.UNIVERSITY_WORKER:
formatterService = new UniversityWorkerReportFormatterService();
break;
default:
throw Error('Not supported report type')
}
return formatterService;
}
}
Kod
Diagram UML
Po przeanalizowaniu kodu możemy zauważyć, że każdy z formatterów ma swój osobny typ i nie są ze sobą powiązane, czyli nie możemy skorzystać z innego formattera w miejscu, w którym już na jakiś się zdecydowaliśmy
class ReportFormatterServiceFactory {
public static getReportFormatter(type: ReportType, report: Map<number, Student>): IHandleFormatterService {
let formatterService: IHandleFormatterService;
switch (type) {
case ReportType.DEAN:
formatterService = new DeanReportFormatterService(report);
break;
case ReportType.LECTURER:
formatterService = new LecturerReportFormatterService(report);
break;
case ReportType.UNIVERSITY_WORKER:
formatterService = new UniversityWorkerReportFormatterService(report);
break;
default:
throw Error('Not supported report type')
}
return formatterService;
}
}
abstract class AbstractReportFormatterService implements IHandleFormatterService {
private report: Map<number, Student>;
public abstract formatReport(): HTMLElement;
constructor(report: Map<number, Student>) {
this.report = report;
}
}
class DeanReportFormatterService extends AbstractReportFormatterService implements IHandleFormatterService {
public formatReport(): HTMLElement {
const formattedReport: HTMLElement = document.createElement('table');
// Report formatting logic
return formattedReport;
}
}
Kod
Diagram UML
Utworzona wspólna klasa abstrakcyjna, w której można zawrzeć powtarzającą się logikę oraz dodatkowo zapewniliśmy, to każdy formatter może być podmieniony przez inny, który także dziedziczy po AbstractReportFormatterService
Użytkownik nie powinien musieć polegać na interfejsacg, których nie używa
Często jest tak, że interfejs jest opisem całej klasy. ISP to zasada, która mówi, że klasa powinna być opisana szeregiem mniejszych interfejsów (bardziej szczegółowych odpowiedzialnych tylko za jedną rzecz SRP),
które udostępniają tylko niektóre zasoby klasy zamiast całej jej zawartości w jednym miejscu.
Historyjka:
Doktor Jan Kowalski zgłosił potrzebę aktualizacji raportów dla pojedynczych studentów oraz grup.
interface IHandleFormatterService {
formatReport(): HTMLElement;
updateReport(indexes: number[]): void;
}
abstract class AbstractReportFormatterService implements IHandleFormatterService{
private report: Map<number, Student>;
protected formattedReport: HTMLElement;
public abstract formatReport(): HTMLElement;
public abstract updateReport(indexes: number[]): void;
constructor(report: Map<number, Student>) {
this.report = report;
}
}
class UniversityWorkerReportFormatterService extends AbstractReportFormatterService implements IHandleFormatterService {
public formatReport(): HTMLElement {
const formattedReport: HTMLElement = document.createElement('table');
// Report formatting logic
return formattedReport;
}
public updateReport(indexes: number[]): void {
// update report logic
}
}
Kod
Diagram UML
Nie powinniśmy rozszerzać głównego interfejsu, z którego korzysta każda grupa użytkowników, ponieważ:
- Za każdym razem gdy dołożymy kolejny element do interfejsu
IHandleFormatterService
, będziemy musieli go zaimplementować w każdej klasie, która go implementuje (co najwyżej jeżeli będzie to element opcjonalny), a niekoniecznie go potrzebuje updateReport
nie powinno być dostępnie dla pani sekretarki, które pracuje na uczelni, ponieważ nie powinna ingerować w raporty studentów podległych jakiemuś doktorow
type IHandleReportFormatterService = IDeanReportFormatterService | ILecturerReportFormatterService | IUniversityWorkerReportFormatterService;
interface IBaseReportFormatterService {
formatReport(): HTMLElement;
}
interface UniversityAdministrator {
updateReport(indexes: number[]): void;
}
interface IDeanReportFormatterService extends IBaseReportFormatterService { }
interface ILecturerReportFormatterService extends IBaseReportFormatterService, UniversityAdministrator {}
interface IUniversityWorkerReportFormatterService extends IBaseReportFormatterService { }
abstract class AbstractReportFormatterService implements IBaseFormatterService {
private report: Map<number, Student>;
protected formattedReport: HTMLElement;
public abstract formatReport(): HTMLElement;
constructor(report: Map<number, Student>) {
this.report = report;
}
}
export class LecturerReportFormatterService extends AbstractReportFormatterService implements ILecturerFormatterService {
public formatReport(): HTMLElement {
const formattedReport: HTMLElement = document.createElement('table');
// Report formatting logic
return formattedReport;
}
public updateReport(indexes: number[]): void {
// Update report logic
}
}
Kod
Diagram UML
Interfejs IHandleFormatterService
stał się typem union, który jest zbiorem interfejsów. Każdy typ użytkownika ma swój własny interfejs, dzięki czemu jeżeli trzeba dołożyć jakąś funkcjonalność / pole dla tylko jednej grupy użytkowników to wystarczy zrobić to tylko dla nich, a nie dla wszystkich. Dodatkowo wyodrębniliśmy wspólny interfejs, który zawiera deklarację implementacji metody formatReport
, która jest wspólna dla wszystkich typów użytkowników i każdy musi ją zaimplementować.
Abstrakcja nie powinna zależeć od detali. Detale powinny zależeć od abstrakcji
Abstrakcja lub moduł niższego poziomu nie powinien być zależny od klas czy modułów wyższego poziomu, ponieważ zmiany, które będą wymagane do wprowadzenia w bardziej wysokopoziomowej klasie prawdopodbnie
będą miały wpływ na niskopoziomową klasę / moduł, przez co wymagane będą zmiany w klasach / modułach, które korzystają te bardziej niskopoziomową
Historyjka:
Dziekan uczelni podpisał kontrakt z Microsoft na dostarczenie serwisu mailingowego. Musieliśmy stworzyć nowy serwis mailingowy, po czym okazało się, że zerwał kontrakt z Microsoft i podpisał kontrakt z Amazon. Wymusiło to, że znowu trzeba zmienić serwis odpowiedzialny za wysłanie wiadomości email. Po paru miesiącach dziekan dostał ofertę od Google i ją przyjął oraz zerwał kontrakt z Amazon. Po raz kolejny musimy zmienić nas serwis mailingowy.
class UniversityEmailService {
public sendEmail(email: string, data: unknown): Promise<void> {
// Send University email logic
return Promise.resolve();
}
}
class AzureEmailService {
public sendEmail(email: string, data: unknown): Promise<void> {
// Send Azure email logic
return Promise.resolve();
}
}
class AmazonEmailService {
public sendEmail(email: string, data: unknown): Promise<void> {
// Send Amazon email logic
return Promise.resolve();
}
}
class GoogleEmailService {
public sendEmail(email: string, data: unknown): Promise<void> {
// Send Google email logic
return Promise.resolve();
}
}
Code
Diagram UML
Podejście to jest złe, ponieważ za każdym razem musimy zmieniać serwis odpowiedzialny za wysyłanie emaili.
type IHandleEmailService = IAzureEmailService | IAmazonEmailService | IGoogleEmailService;
interface IBaseEmailService {
sendEmail(email: string): Promise<void>;
}
interface IAzureEmailService extends IBaseEmailService { }
interface IAmazonEmailService extends IBaseEmailService { }
interface IGoogleEmailService extends IBaseEmailService { }
abstract class AbstractEmailService implements IBaseEmailService {
protected data: unknown;
public abstract sendEmail(): Promise<void>;
constructor(data: unknown) {
this.data = data;
}
}
class GoogleEmailService extends AbstractEmailService implements IGoogleEmailService {
public sendEmail(email: string): Promise<void> {
// Send email logic
return Promise.resolve();
}
}
Diagram UML
W tym podejściu została wyodrębniona klasa abstrakcyjna, po której dziedziczy każdy unikalny serwis do wysyłania emaili. Dzięki temu rozwiązaniu można rozwiązać problem zależności modułu wyższego poziomu, od modułu niższego poziomu. Dodatkowo nie będzie potrzeby modyfikowania funkcjonalności odpowiedzialnej za wysyłanie emaili, wystarczy, że zmienimy serwis, z którego korzystamy.
- Ciekawy artukół na teamt SOLID: SOLID Is OOP for Dummies