Шаблоны проектирования и приёмы рефакторинга

Следовать принципу инверсии зависимостей помогают инъекция зависимостей, наблюдатель и шаблонный метод.

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

Внедрять зависимости можно тремя способами: через конструктор, через сеттеры и интерфейсно.

Инъекция через конструктор

Самый распространённый вид инъекции — через конструктор. При создании класса в конструкторе мы перечисляем все зависимости, которые требуются для создания экземпляра:

class Room {
  private chair: Chair
  private couch: Couch
  private table: Table

  constructor(chair: Chair, couch: Couch, table: Table) {
    this.chair = chair
    this.couch = couch
    this.table = table
  }
}

// Или используя короткий вариант записи:

class Room {
  constructor(
    private chair: Chair,
    private couch: Couch,
    private table: Table
  ) {}
}

Непосредственно внедрением обычно занимается специальная сущность — DI-контейнер. Контейнеры автоматизируют создание экземпляров нужных классов и помогают в управлении их жизненным циклом.

В языках со статической типизацией DI опирается на типы зависимостей, объявленных в конструкторе. Используя эти типы и конфигурацию приложения, контейнер выбирает конкретные классы, которые будет внедрять:

const container = new DIContainer();

// При регистрации зависимостей мы будто объявляем «соответствие»
// между абстрактным типом или интерфейсом и конкретной реализацией:

container.registerSingleton<Chair, WoodenChair>();
container.registerSingleton<Couch, LeatherCouch>();
container.registerSingleton<Table, DiningTable>();

// Мы будто говорим:
// — Контейнер, если увидишь в коде тип `Chair`,
//   замени его на экземпляр класса `WoodenChair`.

Это, например, помогает подменять зависимости во время тестирования, заменив регистрацию:

// Во время тестирования мы укажем другие классы,
// которые будут реализовывать те же интерфейсы,
// но, например, будут представлять собой моки или стабы:

container.registerSingleton<Chair, TestChair>();
container.registerSingleton<Couch, TestCouch>();
container.registerSingleton<Table, TestTable>();

В TypeScript также существуют DI-контейнеры, которые работают на декораторах, но подобное внедрение выглядит не так чисто и требует изменений непосредственно кода. Внедрение же, основанное на типах, помогает отделить «инфраструктурный» код от кода приложения.

Инъекция через конструктор отлично подходит, когда все или большая часть зависимостей необходимы для работы класса. Если же зависимости опциональны или нам нужно менять их в процессе работы приложения, мы можем воспользоваться инъекцией через сеттер.

Инъекция через сеттер

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

Например, наличие в комнате мебели не обязательно, комната сможет «работать» и без неё, потому зависимости могут быть опциональными:

class Room {
  public chair?: Chair
  public couch?: Couch
  public table?: Table
}

Мы можем создать комнату без мебели, а потом обставлять её по мере необходимости:

const livingRoom = new Room();

livingRoom.couch = new LeatherCouch();
livingRoom.table = new OakTable();

Это же позволяет заменять зависимости во время работы, что может быть полезно при использовании паттерна «Стратегия»:

livingRoom.couch = new LeatherCouch();

// ...Спустя какое-то время диван можно заменить:

livingRoom.couch = new FabricCouch();

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

Инъекция с помощью интерфейса

Внедрение с помощью интерфейса похоже на предыдущий подход, только в нём используются не сеттеры, а отдельные методы-инжекторы. Их мы описываем в интерфейсе RoomBuilder, который реализует класс Room:

interface RoomBuilder {
  injectChair(dep: Chair): void
  injectCouch(dep: Couch): void
  injectTable(dep: Table): void
}

class Room implements RoomBuilder {
  private chair?: Chair
  private couch?: Couch
  private table?: Table

  injectChair(chair: Chair) {
    this.chair = chair
  }

  injectCouch(couch: Couch) {
    this.couch = couch
  }

  injectTable(table: Table) {
    this.table = table
  }
}

Часто такое внедрение используют вместе с паттерном «Фабрика» или «Строитель» для более явного построения экземпляра. Например, внутри функции roomFactory мы создаём экземпляр класса Room, а потом вызываем инжекторы, передавая в аргументах нужные зависимости:

function roomFactory(): Room {
  const room = new Room()

  room.injectChair(new WoodenChair())
  room.injectTable(new WoodenTable())

  return room
}

Вопросы

Наблюдатель

Наблюдатель — шаблон, который создаёт механизм подписки, когда некоторые сущности могут реагировать на поведение других.

Наблюдатель инвертирует контроль за выполнением программы схожим образом, как это делают обработчики событий в GUI. Обработчики событий вызываются в момент пользовательского события ввода: щелчок мыши, нажатие клавиши; наблюдатель — реагирует на изменение состояния наблюдаемого объекта.

В примере из раздела об OCP класс SoftwareEngineerApplicant следит за появлением новой вакансии у HrAgency. Метод update решает, как обработать изменение состояния.

Взаимодействие классов SoftwareEngineerApplicant и HrAgency «становится фреймворком», который следит за изменениями и вызывает нужные методы.

Вопросы

Шаблонный метод

Шаблонный метод — это шаблон, который определяет скелет алгоритма, а некоторые шаги даёт реализовывать подклассам. Так подклассы могут переопределять части алгоритма, не меняя общей структуры.

В примере ниже шаблонный метод brewBeverage задаёт каркас алгоритма приготовления напитка.

abstract class BeverageMachine {
  public brewBeverage(): Beverage {
    this.turnOn()
    this.prepareIngredients()
    this.prepareContainer()
    this.brew()
    this.hook()
  }

  // Базовые операции имеют реализацию:

  public turnOn(): void {
    this.on = true
  }

  // Специфичные для каждого подкласса операции
  // будут переопределяться потомками:

  abstract public prepareIngredients(): void
  abstract public prepareContainer(): void
  abstract public brew(): void

  // Хуки предоставляют дополнительные точки расширения
  // в некоторых критических местах алгоритма.
  // Их переопределять не обязательно,
  // так как есть пустая реализация по умолчанию:

  public hook(): void {}
}

Конкретные классы реализуют абстрактные методы базового. Они также могут переопределить и некоторые методы по умолчанию. Как правило, конкретные переопределяют только часть функциональности.

class CoffeeMachine extends BeverageMachine {
  abstract public prepareIngredients(): void {
    this.grindBeans()
    this.heatMilk()
  }

  abstract public prepareContainer(): void {
    this.getNewCup()
  }

  abstract public brew(): void {
    this.pourEspresso()
    this.pourMilk()
  }

  // ...
}

В стандартной модели наследования потомки вызывают методы базового класса. Здесь же наоборот — методы, реализованные в конкретных классах, вызываются в базовом через шаблонный метод.

Преимущество такого подхода в повторном использовании алгоритма с различными вариациями. Опасность шаблона — в случайном нарушении LSP при изменении функциональности подкласса.

Вопросы

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