117,55
Рейтинг
EPAM
Компания для карьерного и профессионального роста
19 октября

Почему SOLID – важная составляющая мышления программиста. Разбираемся на примерах с кодом

Блог компании EPAMПрограммированиеСовершенный кодПроектирование и рефакторинг
Из песочницы
Привет! Меня зовут Иван, я сотрудничаю со львовским офисом EPAM как Solution Architect, а карьеру в IT начал 10 лет назад. За это время заметил, что многие любят работать на проектах, которые начинаются с нуля. Однако не всем удается построить систему, которую будет все еще легко поддерживать и развивать спустя год.

Вполне естественно, что вместе с разрастанием системы будет повышаться и ее сложность. Успех разработки такой системы будет зависеть от того, насколько хорошо вы держите под контролем ее сложность. Для достижения этой цели существуют дизайн-паттерны, лучшие практики, а главное – принципы проектирования, такие как SOLID, GRASP и DDD.

В данной статье я хочу акцентировать внимание на том, что SOLID – важная составляющая мышления разработчика, которую нужно развивать и тренировать.

Я покажу несколько примеров с кодом, где нарушаются принципы SOLID. Мы выясним, к каким последствиям это может привести в долгосрочной перспективе и как это можно исправить. На мой взгляд, статья будет интересна как back-end, так и front-end разработчикам разных уровней.



Зачем нужен SOLID


SOLID – набор принципов объектно-ориентированного программирования, который представил Роберт Мартин в 1995 году. Их идея состоит в необходимости избегать зависимостей между компонентами кода. Код с большим количеством зависимостей (т.н. «спагетти-код») поддерживать сложно. Основные проблемы такого кода:

  • Жесткость (Rigidity)– каждое изменение ведет к большому числу других изменений.
  • Хрупкость (Fragility) – изменения в одной части могут сломать работу других частей.
  • Неподвижность (Immobility) – код нельзя использовать повторно вне его контекста.

Принцип единственной ответственности (Single Responsibility Principle)


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

Например, класс User. Его ответственность – предоставлять информацию о пользователе: имя, e-mail и тип подписки, которую он использует в сервисе.

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. На основе типа подписки он определяет есть ли у пользователя неограниченный доступ к контенту. Но разве делать такой вывод – ответственность класcа 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 getContentForUserWithUnlimitedAccess(movies: Movie[]) {
    return movies;
  }
}

Мы инкапсулировали все правила работы с подписками в одном классе. Если возникнут изменения в правилах, они останутся только в этом классе и не зацепят остальные.

Single Responsibility Principle касается не только уровня классов – модули классов также необходимо проектировать таким образом, чтобы они были узкоспециализированы.

Кроме SOLID существует и другой набор принципов проектирования программного обеспечения – GRASP. Некоторые его принципы пересекаются с SOLID. Если же говорить о Single Responsibility Principle, то с GRASP можно сопоставить:

  • Информационный эксперт (Information Expert) – объект, владеющий полной информацией о предметной области.
  • Низкая связанность (Low Coupling) и высокое зацепление (High Cohesion) — компоненты разных классов или модулей должны иметь слабые связи между собой, но компоненты одного класса или модуля должны быть логично связаны или тесно взаимодействовать друг с другом.

Приницип открытости/закрытости (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);
  }
} 

Как видим, добавление новых фигур приведет к модификации класса ShapeManger. Поскольку площадь фигуры тесно связана с самой фигурой, можно заставить фигуры самостоятельно считать свою площадь, привести их к единому интерфейсу, а затем передавать их в метод 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:

  • Устойчивость к изменениям (Protected Variations): необходимо защищать компоненты от влияния изменений других компонентов. Поэтому для компонентов, которые потенциально часто будут претерпевать изменения, мы создаем один интерфейс и несколько его имплементаций, используя полиморфизм.
  • Полиморфизм (Polymorphism): возможность иметь разные варианты поведения основываясь на типе класса. Типы класса с вариативным поведением должны наследовать один интерфейс.
  • Перенаправление (Indirection): слабая связанность между компонентами и возможность их повторного использования достигается благодаря созданию посредника (mediator), который берет на себя взаимодействие между компонентами.
  • Чистая выдумка (Pure Fabrication):можно создать искусственный объект, которого нет в домене, но который будет обладать свойствами, дающими возможность уменьшить зависимость между объектами. Например, в домене есть товар и склад. Если сделаем так, что склад будет контролировать наличие товаров, станет сложно создать функционал, который, например, будет проверять наличие товара у партнеров и предлагать его пользователю. Поэтому мы добавляем объект ProductManager, который проверяет присутствие позиции на складе. В случае отсутствия – проверяет наличие у партнеров. Поскольку благодаря ProductManager мы отвязали товар от склада, можем полностью избавиться от него и продавать товары партнеров, если возникнет необходимость.

Принцип подстановки Лисков (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');
  }
}

Также возможен случай, когда родительский метод будет противоречить логике классов-потомков.

Рассмотрим следующую иерархию транспортных средств:

class Vehicle {
  accelerate() {
    // implementation
  }
 
  slowDown() {
    // implementation
  }
 
  turn(angle: number) {
    // implementation
  }
}
 
class Car extends Vehicle {
}
 
class Bus extends Vehicle {
}

Все работает до момента, когда мы добавляем новый класс – Поезд.

class Train extends Vehicle {
  turn(angle: number) {
    // is that possible?
  }
}

Поскольку поезд не может произвольно менять направление своего движения, то turn родительского класса будет нарушать принцип подстановки Лисков.

Чтобы исправить ситуацию, мы можем добавить два родительских класса: FreeDirectionalVehicle – будет разрешить произвольное направление движения и BidirectionalVehicle — движение только вперед и назад. Теперь все классы будут наследовать лишь те методы, которые смогут обеспечить.

class FreeDirectionalVehicle extends Vehicle {
  turn(angle: number) {
    // implementation
  }
}
 
class BidirectionalVehicle extends Vehicle {
}

Кроме того, класс-потомок не должен добавлять какие-либо условия до и после выполнения метода. Например:

class Logger {
  log(text: string) {
    console.log(text);
  }
}
 
class FileLogger extends Logger {
  constructor(private readonly path: string) {
    super();
  }
 
  log(text: string) {
    // append text file
  }
}
 
class TcpLogger extends Logger {
  constructor(private readonly ip: string, private readonly port: number) {
    super();
  }
 
  log(text: string) {
    // implementation
  }
 
  openConnection() {
    // implementation
  }
 
  closeConnection() {
    // implementation
  }
}

В этой иерархии мы не сможем легко заменить объекты родительского класса Logger объектами TcpLogger, т.к. до и после вызова метода нам необходимо дополнительно вызвать openConnection и closeConnection. Получается, мы накладываем 2 дополнительных условия на вызов метода log, что также нарушает принцип подстановки Лисков.

Чтобы решить ситуацию выше, мы можем сделать методы openConnection и closeConnection приватными. В методе log класса TcpLogger организуем запись логов в файл. С периодичностью (например, каждую минуту) будем открывать соединение, отправлять файл с логами и закрывать соединение. Дополнительно необходимо убедиться, что прежде, чем программа будет закрыта, мы отправили все логи. Если программа была завершена аварийно, можем отправить логи во время следующего ее запуска.

Принцип разделения интерфейса (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

И в заключение: отца-основателя SOLID, Роберта Мартина, небезосновательно считают настоящей рок-звездой в мире разработки ПО. На его книгах уже выросло не одно поколение суперуспешных программистов. «Clean Code» и «Clean Coder» — две его книги о том, как писать качественный код и соответствовать высочайшим стандартам в индустрии. Думаю, многие из вас уже успели причитать хотя бы одну из них, а если нет, то у вас есть неплохой шанс прокачать свой уровень разработчика.
Теги:solidтех-инсайдер
Хабы: Блог компании EPAM Программирование Совершенный код Проектирование и рефакторинг
+1
4,2k 45
Комментарии 7
Похожие публикации
Лучшие публикации за сутки