Чому SOLID — важлива складова мислення програміста. Розбираємося на прикладах з кодом

Привіт! Мене звати Іван, співправцюю з EPAM Systems як Solution Architect, а кар'єр єру в IT почав 10 років тому. За цей час помітив, що майже всі люблять працювати на проєктах, які починаються з нуля. Та не всім вдається побудувати систему, яку за рік розробки буде все ще легко підтримувати і розвивати. Дехто через кілька місяців робить спробу номер два, оскільки вже знає, як треба було починати правильно. Це природно, що зі зростанням системи зростає і її складність. Успіх розробки такої системи буде залежати від того, наскільки добре ви тримаєте під контролем її складність. Для цього існують дизайн-патерни, найкращі практики, а головне — принципи проєктування, такі які SOLID, GRASP та DDD. У статті хочу звернути увагу на те, що SOLID — це важлива складова мислення розробника, яку потрібно розвивати і тренувати.

Ця стаття є другою частиною моєї публікації , що присвячена алгоритмами. Я покажу кілька прикладів з кодом, де принципи SOLID порушуються. Ми з'єднання ясуємо, до чого це може призвести в довгостроковій перспективи і як це виправити. Стаття має бути цікавою як бекенд, так і фронтенд-розробникам різних рівнів.

Навіщо потрібен SOLID

SOLID — це набір принципів об'єктно-орієнтованого програмування, які представивши Роберт Мартін (дядько Боб) у 1995 році. Їхня ідея в тому, що треба унікати залежностей між компонентами кодом. Якщо є велика кількість залежностей, такий код важко підтримувати (спагеті-код). Його основні проблеми:

Принцип єдиного обов'язку (Single Responsibility Principle)

Кожен клас повинен виконувати лише один обов'язок. Це не означає, що в нього має бути тільки один метод. Це означає, що всі методи класу мають бути сфокусовані на виконання одного спільного завдання. Якщо є методи, які не відповідають меті існування класу, їх треба винести за його межі.

Наприклад, клас User. Його обов'язок надавати інформацію про користувача: ім'я, email і тип підписки, яку він використовує в сервісі.

enum SubscriptionTypes {
 BASIC = 'BASIC',
 PREMIUM = 'PREMIUM'
}

class User {
 constructor (
 public readonly firstName: string,
 public readonly lastName: string,
 public readonly email: string,
 public readonly subscriptionType: SubscriptionTypes,
 public readonly subscriptionExpirationDate: Date
 ) {}

 public get name(): string {
 return `${this.firstName} ${this.lastName}`;
}

 public hasUnlimitedContentAccess() {
 const now = new Date();

 return this.subscriptionType === SubscriptionTypes.PREMIUM
 && this.subscriptionExpirationDate > now;
}
}

Розглянємо метод hasUnlimitedContentAccess. На основі типу підписки він визначає, чи є у користувача необмежений доступ до контенту. Але ж стоп, хіба це відповідальність класу User робити такий висновок? Виходить, у класу User є дві мети для існування: надавати інформацію про користувача і робити висновок, який у нього доступ до контенту на основі підписки. Це порушує принцип Single Responsibility.

Чому існування методом hasUnlimitedContentAccess у класі User має негативні наслідки? Бо контроль над типом підписки розпливається по всій програмі. Крім класу User, можуть бути класі MediaLibrary та Player, які теж вирішуватимуть, що їм робити на основі цих даних. Кожен клас трактує по-своєму, що означає тип підписки. Якщо правила наявних підписок змінюються, треба оновлювати всі класі, оскільки кожен вибудував свій набір правил роботи з ними.

Видалимо метод hasUnlimitedContentAccess у класі User і створимо новий клас, який буде відповідати за роботу з підписками.

class AccessManager {
 public static hasUnlimitedContentAccess(user: User) {
 const now = new Date();

 return user.subscriptionType === SubscriptionTypes.PREMIUM
 && user.subscriptionExpirationDate > now;
}

 public static getBasicContent(movies: Movie[]) {
 return movies.filter(movie => movie.subscriptionType === SubscriptionTypes.BASIC);
}

 public static getPremiumContent(movies: Movie[]) {
 return movies.filter(movie => movie.subscriptionType === SubscriptionTypes.PREMIUM);
}

 public static getContentForUserWithBasicAccess(movies: Movie[]) {
 return AccessManager.getBasicContent(movies);
}

 public static getContentForUserWithInfiniteAccess(movies: Movie[]) {
 return movies;
}
}

Ми інкапсулювали всі правила роботи з підписками в одному класі. Якщо будуть зміни у правилах, вони залишаться тільки у цьому класі та не зачіплять інші.

Single Responsibility Principle стосується не тільки рівня класів — модулі класів теж потрібно проєктувати таким чином, щоб вони були вузько спеціалізовані.

Деякі його принципи перетинаються з SOLID. Якщо говорити про Single Responsibility Principle, то йому можна співставити:

Принцип відкритості/закритості (Open/Close Principle)

Класи мають бути відкриті до розширення, але закриті для змін. Якщо є клас, функціонал якого передбачає чимало розгалужень або багато послідовних кроків, і є великий потенціал, що їх кількість буде збільшуватись, то потрібно спроєктувати клас таким чином, щоб нові розгалуження або кроки не призводили до його модифікації.

Напевно, кожен з нас бачив нескінченні ланцюжки if then else або switch. Щойно додається чергова умова, ми пишемо черговий if then else, змінюючи при цьому сам клас. Або клас виконує процес з багатьма послідовними кроками — і кожен новий крок призводить до його зміни. А це порушує Open/Close Principle.

Як можна розширювати клас і водночас не змінювати його? Розглянємо кілька способів.

class Rect {
constructor(
 public readonly width: number,
 public readonly height: number
 ) { }
}

class Square {
constructor(
 public readonly width: number
 ) { }
}

class Circle {
constructor(
 public readonly r: number
 ) { }
}

class ShapeManager {
 public static getMinArea(shapes: (Rect | Square | Circle)[]): number {
 const areas = shapes.map(shape => {
 if (shape instanceof Rect) {
 return shape.width * shape.height;
}

 if (shape instanceof Square) {
 return Math.pow(shape.width, 2);
}

 if (shape instanceof Circle) {
 return Math.PI * Math.pow(shape.r, 2);
}

 throw new Error('Is not implemented');
});

 return Math.min(...areas);
}
}

Як бачимо, додавання нових фігур буде призводити до модифікації класу ShapeManager. Оскільки площа фігури тісно пов'язана із самою фігурою, можна змусити фігури самостійно рахувати свою площу, привести їх до одного інтерфейсу, а тоді передавати їх у метод getMinArea.

interface IShape {
 getArea(): number;
}

class Rect implements IShape {
constructor(
 public readonly width: number,
 public readonly height: number
 ) { }

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

class Square implements IShape {
constructor(
 public readonly width: number
 ) { }

 getArea(): number {
 return Math.pow(this.width, 2);
}
}

class Circle implements IShape {
constructor(
 public readonly r: number
 ) { }

 getArea(): number {
 return Math.PI * Math.pow(this.r, 2);
}
}

class ShapeManager {
 public static getMinArea(shapes: IShape[]): number {
 const areas = shapes.map(shape => shape.getArea());
 return Math.min(...areas);
}
}

Тепер, якщо у нас з'єднання бути нові фігури, все, що потрібно зробити, — це імплементувати інтерфейс IShape. І клас ShapeManager відразу буде її підтримувати без жодних модифікацій.

А що робити, якщо не можемо додавати методи до фігур? Існують методи, які суперечать Single Responsibility Principle. Тоді можна скористатися шаблоном проєктування «Стратегія» (Strategy): створити множину схожих алгоритмів і викликати їх за певним ключем.

interface IShapeAreaStrategiesMap {
 [shapeClassName: string]: (shape: IShape) => number;
}

class ShapeManager {
constructor(
 private readonly strategies: IShapeAreaStrategiesMap
 ) {}

 public getMinArea(shapes: IShape[]): number {
 const areas = shapes.map(shape => {

 const className = shape.constructor.name;
 const strategy = this.strategies[className];

 if (strategy) {
 return strategy(shape);
}

 throw new Error(`Could not find Strategy for '${className}");

});

 return Math.min(...areas);
}
}

// Strategy Design Pattern
const strategies: IShapeAreaStrategiesMap = {
 [Rect.name]: (shape: Rect) => shape.width * shape.height,
 [Square.name]: (shape: Square) => Math.pow(shape.width, 2),
 [Circle.name]: (shape: Circle) => Math.PI * Math.pow(shape.r, 2)
};

const shapes = [
 new Rect(1, 2),
 new Square(1),
 new Circle(1),
];

const shapeManager = new ShapeManager(strategies);
console.log(shapeManager.getMinArea(shapes));

Перевага Strategy у тому, що є змога змінювати в рантаймі набір стратегій і спосіб їх вибору. Можна прочитати файл конфігурацій (.json, .xml .yml) і на його основі збудувати стратегії. Тоді, якщо відбувається зміна стратегій, не потрібно розробляти нову версію програми і деплоїти її на сервери, достатня підмінити файл з конфігураціями і сказати програмі, щоб та його знову прочитала. Крім того, стратегії можна реєструвати в Inversion of Control контейнері. У такому разі клас, який їх потребує, отримає стратегії автоматично на етапі створення.

Розглянємо тепер ситуацію, коли триває послідовна багатокрокова обробка даних. Якщо кількість кроків зміниться, нам доведеться змінювати клас.

class ImageProcessor {
...
 public processImage(bitmap: ImageBitmap): ImageBitmap {
this.fixColorBalance(bitmap);
this.increaseContrast(bitmap);
this.fixSkew(bitmap);
this.highlightLetters(bitmap);

 return bitmap;
}
}

Застосуємо дизайн-патерн «Конвеєр» (Pipeline).

type PipeMethod = (bitmap: ImageBitmap) => void;

// Pipeline Design Pattern
class Pipeline {
constructor(
 private readonly bitmap: ImageBitmap
 ) { }

 public pipe(method: PipeMethod) {
method(this.bitmap);
}

 public getResult() {
 return this.bitmap;
}
}

class ImageProcessor {
 public static processImage(bitmap: ImageBitmap, pipeMethods: PipeMethod[]): ImageBitmap {
 const pipeline = new Pipeline(bitmap);
 pipeMethods.forEach(method => pipeline.pipe(method))

 return pipeline.getResult();
}
}

const pipeMethods = [
fixColorBalance,
increaseContrast,
fixSkew,
highlightLetters
];

const result = ImageProcessor.processImage(scannedImage, pipeMethods);

Тепер, якщо потрібно змінити спосіб обробки зображення, ми модифікуємо масив з методами. Сам клас ImageProcessor залишається незмінним. Тепер уявіть, що треба обробляти різні зображення по-різному. Замість того, щоб писати різні версії ImageProcessor, по-іншому скомбінуємо в масиві pipeMethods потрібні нам методи.

Ще кілька переваг. Раніше ми завдавали новий метод обробки зображення прямо в ImageProcessor, і у нас виникала потреба додавати нові залежності. Наприклад, метод highlightLetters вімагає додаткову бібліотеку для пошуку символів на зображенні. Відповідно, більше методів — більше залежностей. Зараз кожен PipeMethod можна розробити в окремому модулі й підключати тільки необхідні залежності. Після такої декомпозиції все дуже легко тестувати. Ну й на останок: така структура коду мотивує розробника писати якомога коротші методи обробки з чіткими інтерфейсами. До цього можна було зробити один великий метод fixQuality, де б відбувалося і виправлення балансу кольорів, і вирівнювання зображення, і збільшення контрасту. Але в такому великому методі було б складно контролювати параметри шкірного накладеного на зображення фільтру. Ймовірно, виникла б ситуація, коли fixQuality працював бі добре для одного зразка зображення, але для іншого на етапі тестування він би не працював зовсім. Маючи кілька добре програнульованих методів, значно простіше скоригувати параметри, щоб отримати потрібний результат.

Принципами GRASP, що спільні з Open/Close Principle :

Принцип підстановки Лісков (Liskov Substitution Principle)

Якщо об'єкт базового класу замінити об'єднання об'єктом його похідного класу, то програма має продовжувати працювати коректно. Тобто якщо ми перевизначаємо похідні методи від батьківського класу, то нова поведінка не має суперечити поведінці базового класу. Як приклад порушення цього принципу розглянємо такий код:

class BaseClass {
 public add(a: number, b: number): number {
 return a + b;
}
}

class DerivedClass extends BaseClass {
 public add(a: number, b: number): number {
 throw new Error('This operation is not supported');
}
}

Принцип розділення інтерфейсу (Interface Segregation Principle)

Краще, коли є багато спеціалізованих інтерфейсів, ніж один загальний. Маючи один загальний інтерфейс, є ризик потрапити в ситуацію, коли похідний клас логічно не зможе успадкувати якийсь метод. Розглянємо приклад:

interface IDataSource {
 connect(): Promise<boolean>;
 read(): Promise<string>;
}

class DbSource implements IDataSource {
 connect(): Promise<boolean> {
 // implementation
}

 read(): Promise<string> {
 // implementation
}
}

class FileSource implements IDataSource {
 connect(): Promise<boolean> {
 // implementation
}

 read(): Promise<string> {
 // implementation
}
}

Оскільки з файлу мі читаємо локально, то метод Connect зайвій. Розділимо загальний інтерфейс IDataSource:

interface IDataSource {
 read(): Promise<string>;
}

interface IRemoteDataSource extends IDataSource {
 connect(): Promise<boolean>;
}

class DbSource implements IRemoteDataSource {
}

class FileSource implements IDataSource {
}

Тепер кожна імплементація використовує тільки тієї інтерфейс, який може забезпечити.

Принцип інверсії залежностей (Dependency Inversion Principle)

Він складається з двох тверджень:

Розберемо код, який порушує ці твердження:

class UserService {
 async getUser(): Promise<User> {
 const now = new Date();

 const item = localStorage.getItem('user');
 const cachedUserData = item && JSON.parse(item);

 if (cachedUserData && new Date(cachedUserData.expirationDate) > now) {
 return cachedUserData.user;
}

 const response = await fetch('/user');
 const user = await response.json();

 const expirationDate = new Date();
 expirationDate.setHours(expirationDate.getHours() + 1);

 localStorage.setItem('user', JSON.stringify({
user,
expirationDate
}));

 return user;
}
}

Наш модуль верхнього рівня UserService використовує деталі реалізації трьох модулів нижнього рівня: localStorage, fetch та Date. Такий підхід поганий тім, що якщо ми, наприклад, вирішимо замість fetch користуватися бібліотекою, яка робить HTTP-запити, то доведеться переписувати UserService. Крім того, такий код важко покрити тестами.

Ще одним порушенням є ті, що з методом getUser ми повертаємо реалізований клас User, а не його абстракцію — інтерфейс IUser.

Створимо абстракції, з якими було б зручно працювати всередині модуля UserService.

interface ICache {
 get<T>(key: string): T | null;
 set<T>(key: string, user: T): void;
}

interface IRemoteService {
 get<T>(url: string): Promise<T>;
}

class UserService {
constructor(
 private readonly cache: ICache,
 private readonly remoteService: IRemoteService
 ) {}

 async getUser(): Promise<IUser> {
 const cachedUser = this.cache.get<IUser>('user');

 if (cachedUser) {
 return cachedUser;
}

 const user = await this.remoteService.get<IUser>('/user');
 this.cache.set('user', user);

 return user;
}
}

Як бачимо, код вийшов значно простішим і його можна легко протестувати. Тепер поглянемо на реалізацію інтерфейсів ICache та IRemoteService.

interface IStorage {
 getItem(key: string): any;
 setItem(key: string value: string): void;
}

class LocalStorageCache implements ICache {
 private readonly storage: IStorage;

constructor(
 getStorage = (): IStorage => localStorage,
 private readonly createDate = (dateStr?: string) => new Date(dateStr)
 ) {
 this.storage = getStorage()
}

 get<T>(key: string): T | null {
 const item = this.storage.getItem(key);
 const cachedData = item && JSON.parse(item);

 if (cachedData) {
 const now = this.createDate();

 if (this.createDate(cachedData.expirationDate) > now) {
 return cachedData.value;
}
}

 return null;
}

 set<T>(key: string value: T): void {
 const expirationDate = this.createDate();
 expirationDate.setHours(expirationDate.getHours() + 1);

 this.storage.setItem(key, JSON.stringify({
value,
expirationDate
}));
}
}

class RemoteService implements IRemoteService {
 private readonly fetch: ((input: RequestInfo, init?: RequestInit) => Promise<Response>)

constructor(
 getFetch = () => fetch
 ) {
 this.fetch = getFetch()
}

 async get<T>(url: string): Promise<T> {
 const response = await this.fetch(url);
 const obj = await response.json();

 return obj;
}
}

Ми нікого врапери над localStorage та fetch. Важливим моментом у реалізації двох класів є те, що ми не використовуємо localStorage та fetch прямо. Ми весь час працюємо зі створеними для них інтерфейсами. LocalStorage та fetch будуть передаватися в конструктор, якщо там не буде вказано жодних параметрів. Для тестів можна створити mocks або stubs, які замінять localStorage або fetch, і передати їх як параметри в конструктор.

Схожий прийом використовують і для даті: якщо нічого не передати, то шкірного разу LocalStorageCache буде отримувати нову дату. Якщо ж тестів потрібно зафіксувати певну дату, треба передати її в параметрі конструктора.

Висновки

Це природно, що з розвитком системи зростає її складність. Важливо завжди тримати цю складність під контролем. Інакше може виникнути ситуація, коли додавання нових фіч, навіть не дуже складних, обійдеться занадто дорого. Деякі проблеми повторюються особливо часто. Щоб їх унікати, було розроблено принципи проєктування. Якщо будемо їх дотримуватися, то не допустимо лавиноподібного підвищення складності системи. Найпростішими такими принципами є SOLID.

І на останок: Роберта Мартіна считают Rock Star у розробці програмного забезпечення. На його книгах вже виросло декілька поколінь суперуспішних програмістів. «Clean Code» і «Clean Coder» — дві його книги про те, як писати якісний код і відповідати найвищим стандартам в індустрії

Опубліковано: 04/08/20 @ 10:00
Розділ Різне

Рекомендуємо:

Кар'єра в IT: NLP Engineer і NLP Researcher
Як за допомогою тестів пришвидшити реліз
Що потрібно знати тестировщику про рецензування та як його використовувати в роботі
5 книжок, які допомогли зрозуміти зміни у світі, від Павла Кузнєцова, Sr. Product Manager Zalando
Українка – про роботу в Coca-Cola у Сингапурі: "Я відповідаю за Data Science в усьому регіоні Азії та Тихого океану"