В реальной жизни

Принцип открытости-закрытости побуждает исследовать отношения между сущностями до того, как вы начнёте писать код. Это помогает выявлять ошибки проектирования на ранних этапах.

Кроме этого OCP помогает отвязать модули друг от друга. Это минимизирует количество модулей, которые надо обновить, при изменении требований.

Инъекция зависимостей и тестирование

При тестировании модулей, которые зависят от других модулей, разработчики могут столкнуться с проблемой, когда необходимо создать экземпляры каждой из зависимостей.

Если модуль зависит от конкретной реализации другого, разработчикам придётся имитировать конкретную реализацию зависимости. Допустим, есть класс, который работает с хранилищем:

class StorageService {
  get(key: string): any {
    return JSON.parse(localStorage.getItem(key))
  }
}

Чтобы протестировать метод get, необходимо создать глобальный мок-объект localStorage. Такие объекты и переменные при тестировании могут привести к неправильной работе соседних тестов. (Например, если кто-то забыл сбросить localStorage после использования.)

Если же мы привяжем зависимость не через конкретный объект, а через интерфейс, то получим возможность подменять зависимости на лету. Этот паттерн называется инъекция зависимостей (DI).

interface Storage {
  getItem(key: string): any
}

interface StorageDependencies {
  storage: Storage
}

class StorageService {
  storage: Storage

  // указываем, какие зависимости следует использовать
  // JSON тоже стоит внедрять подобным образом,
  // но для простоты примера берём в расчёт только localStorage
  constructor({ storage = localStorage }: StorageDependencies) {
    this.storage = storage
  }

  get(key: string): any {
    // используем зависимость через интерфейс
    return JSON.parse(this.storage.getItem(key))
  }
}

Теперь при тестировании мы можем указать мок-объект для Storage локально. При этом нам не потребуется эмулировать работу объекта localStorage полностью. Нам достаточно описать метод getItem, работу которого мы и проверим. Например, используя Jest:

describe('StorageService', () => {
  it('should return specified value', () => {
    const mock: Storage = {
      getItem: (key: string) => '42'
    }

    const service = new StorageService({storage: mock})
    expect(service.get('test key')).toEqual(42)
  })
})

Если нам важно проверить, вызвался ли правильный метод у зависимости, DI снова сделает решение задачи проще:

describe('StorageService', () => {
  it('should call now method of dependency object', () => {
    const mock: Storage = {
      getItem: jest.fn()
    }

    const service = new StorageService({storage: mock})
    service.get('test key')

    expect(mock.getItem).toHaveBeenCalled()
  })
})

Такой подход удобен при разработке через тестирование (TDD). Он позволяет продумать API модуля заранее и продумать организацию зависимостей модулей друг от друга.

Инъекция зависимостей и расширение функциональности

Теперь представим, что в приложении появляются два места, где используются разные хранилища: localStorage в одном и verySophisticatedStorage в другом.

Если наш класс зависел напрямую от localStorage, у нас проблемы. При добавлении нового хранилища, нам придётся проверять, с каким из хранилищ мы имеем дело. А если API хранилищ сильно отличается, то код метода get сильно разрастётся из-за проверок и адаптеров.

class StorageService {
  storageType: string

  constructor(storageType: string) {
    this.storageType = storageType
  }

  get(key: string): any {
    if (this.storageType === 'verySophisticatedStorage') {
      return verySophisticatedStorage.getByKey(key)
    }

    return JSON.parse(localStorage.getItem(key))
  }
}

С другой стороны, если мы зависим от интерфейса, то метод get и конструктор класса StorageService не изменятся. Разное API хранилищ мы приведём к одному виду через адаптеры (отдельные новые сущности), которые будут имплементировать интерфейс Storage.

interface Storage {
  getItem(key: string): any
}

// добавляем адаптер для verySophisticatedStorage;
// он будет имплементировать интерфейс Storage,
// поэтому его можно будет передать как зависимость для StorageService
class SophisticatedStorageAdapter implements Storage {
  getItem(key: string): any {
    return verySophisticatedStorage.getByKey(key)
  }
}

// то же для localStorage
class LocalStorageAdapter implements Storage {
  getItem(key: string): any {
    return JSON.parse(localStorage.getItem(key))
  }
}

// код класса StorageService не меняется!
class StorageService {
  storage: Storage

  constructor({ storage = localStorage }: StorageDependencies) {
    this.storage = storage
  }

  get(key: string): any {
    // вся реализация методов обоих хранилищ скрыта за адаптерами
    return this.storage.getItem(key)
  }
}

// работает как со старым хранилищем
const storageServiceWithLocalStorage = new StorageService({
  storage: new LocalStorageAdapter()
})

// так и с новым
const storageServiceWithSophisticatedStorage = new StorageService({
  storage: new SophisticatedStorageAdapter()
})

Таким образом новые бизнес-требования не затронут код уже созданного модуля StorageService, а будут внедрены через создание новых сущностей.

Материалы к разделу