Повышение качества кода и удобство сопровождения с помощью внедрения зависимостей

Внедрение зависимостей (DI) — это шаблон проектирования и метод программирования для управления зависимостями между различными компонентами.

В DI зависимости класса или другого зависимого компонента создаются и предоставляются извне (внедряются), а не начинают создаваться зависимым компонентом.

Понимание внедрения зависимостей является ключом к следованию принципу инверсии зависимостей.

Основные компоненты

Три основных компонента внедрения зависимостей:

  1. Зависимость. Зависимость – это объект или служба, на которые другой класс опирается при выполнении своих задач. Он представляет собой контракт или интерфейс, определяющий требуемую функциональность.
  2. Клиент: зависимый класс, также известный как клиентский класс, — это класс, который полагается на зависимость для выполнения своих функций. Обычно он объявляет зависимость через параметры конструктора, методы установки или контракты интерфейса.
  3. Инжектор. Инжектор (он же контейнер, ассемблер, фабрика) отвечает за создание и управление зависимостями, а также за их внедрение в зависимый класс (клиент). Инжектор может быть фреймворком или контейнером, предоставляемым DI-библиотекой, или пользовательской реализацией.

Эти три компонента работают вместе, чтобы обеспечить преимущества DI, такие как слабая связь, модульность, тестируемость и гибкость.

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

Выполнение

В качестве примера рассмотрим два класса: Engine и Car.

Чтобы создать экземпляр класса Car, нам нужен соответствующий объект Engine.

class Engine {
  private horsepower: number;
  private fuelType: string;

  constructor(horsepower: number, fuelType: string) {
    this.horsepower = horsepower;
    this.fuelType = fuelType;
  }

  public start() {
    console.log(`Engine started. Horsepower: ${this.horsepower}, Fuel Type: ${this.fuelType}`);
  }

  public stop() {
    console.log("Engine stopped.");
  }
}

class Car {
  private engine: Engine;
  private brand: string;
  private model: string;

  constructor(brand: string, model: string) {
    this.brand = brand;
    this.model = model;
    this.engine = new Engine(200, "Gasoline");
  }

  public startCar() {
    console.log(`Starting ${this.brand} ${this.model}`);
    this.engine.start();
  }

  public stopCar() {
    console.log(`Stopping ${this.brand} ${this.model}`);
    this.engine.stop();
  }
}

// Example usage
const car = new Car("Toyota", "Camry");

car.startCar();
car.stopCar();

// To consturct a car with Gasoline engine required a manual edit of Car class

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

Ввод параметров

Чтобы решить такую ​​проблему, мы можем воспользоваться инъекцией параметров. Перепишем текущий код.

class Engine {
  // same implementation
}

class Car {
  private engine: Engine;
  private brand: string;
  private model: string;

  constructor(brand: string, model: string, horsepower: number, fuelType: string) {
    this.brand = brand;
    this.model = model;
    this.engine = new Engine(horsepower, fuelType);
  }

  public startCar() {
    console.log(`Starting ${this.brand} ${this.model}`);
    this.engine.start();
  }

  public stopCar() {
    console.log(`Stopping ${this.brand} ${this.model}`);
    this.engine.stop();
  }
}

// Example usage
const car1 = new Car("Toyota", "Camry", 200, "Gasoline");

car1.startCar();
car1.stopCar();

// Easy change Engine parameters
const car2 = new Car("BMW", "X5", 300, "Diesel");

car2.startCar();
car2.stopCar();

Теперь общая логика не меняется; вместо этого мы можем легко вносить изменения в соответствии с нашими потребностями.

Внедрение конструктора/установщика

В предыдущем примере мы использовали внедрение параметров, чтобы изменить horsepower и fuelType для класса Engine. Однако это может стать громоздким при работе с большим количеством зависимостей.

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

class Engine {
  // same implementation
}

class Car {
  private engine: Engine;
  private brand: string;
  private model: string;

  constructor(brand: string, model: string, engine: Engine) {
    this.brand = brand;
    this.model = model;
    this.engine = engine;
  }

  public startCar() {
    // same logic
  }

  public stopCar() {
    // same logic
  }
}

// Example usage
const gasolineEngine = new Engine(200, "Gasoline");
const car1 = new Car("Toyota", "Camry", gasolineEngine);

car1.startCar();
car1.stopCar();

// Easy change Engine parameters
const dieselEngine = new Engine(300, "Diesel");
const car2 = new Car("BMW", "X5", dieselEngine);

car2.startCar();
car2.stopCar();

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

Та же реализация, но с внедрением сеттера:

class Engine {
  // same implementation
}

class Car {
  private brand: string;
  private model: string;
  private engine: Engine;

  constructor(brand: string, model: string) {
    this.brand = brand;
    this.model = model;
  }

  public setEngine(engine: Engine) {
    this.engine = engine;
  }

  public startCar() {
    // same logic
  }

  public stopCar() {
    // same logic
  }
}

// Example usage
const gasolineEngine = new Engine(200, "Gasoline");
const car1 = new Car("Toyota", "Camry");
car1.setEngine(gasolineEngine);

car1.startCar();
car1.stopCar();


const dieselEngine = new Engine(300, "Diesel");
const car2 = new Car("BMW", "X5");
car2.setEngine(dieselEngine);

car2.startCar();
car2.stopCar(); 

Инъекция интерфейса

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

Чтобы сделать классы Engine и Car более слабо связанными, мы можем привязать Car к интерфейсу (или абстрактному классу в качестве интерфейса) вместо конкретного дочернего класса Engine.

interface Engine {
  start(): void;
  stop(): void;
}

class GasolineEngine implements Engine {
  private horsepower: number;
  private fuelType: string;

  constructor(horsepower: number) {
    this.horsepower = horsepower;
    this.fuelType = "Gasoline";
  }

  public start() {
    console.log(`Gasoline engine started. Horsepower: ${this.horsepower}`);
  }

  public stop() {
    console.log("Gasoline engine stopped.");
  }
}

class DieselEngine implements Engine {
  private horsepower: number;
  private fuelType: string;

  constructor(horsepower: number) {
    this.horsepower = horsepower;
    this.fuelType = "Diesel";
  }

  public start() {
    console.log(`Diesel engine started. Horsepower: ${this.horsepower}`);
  }

  public stop() {
    console.log("Diesel engine stopped.");
  }
}

class Car {
  private engine: Engine;
  private brand: string;
  private model: string;

  // class Car expect any valid Engine implementation
  constructor(brand: string, model: string, engine: Engine) {
    this.brand = brand;
    this.model = model;
    this.engine = engine;
  }

  public startCar() {
    // same logic
  }

  public stopCar() {
    // same logic
  }
}

// Example usage
const gasolineEngine = new GasolineEngine(200);
const car1 = new Car("Toyota", "Camry", gasolineEngine);

car1.startCar();
car1.stopCar();

const dieselEngine = new DieselEngine(300);
const car2 = new Car("BMW", "X5", dieselEngine);

car2.startCar();
car2.stopCar();

Теперь класс Car отделен от конкретной реализации класса Engine. Это позволяет вам легко заменять различные типы двигателей без изменения самого класса Car.

Форсунки

До сих пор я говорил только о зависимостях и клиентах.

Ручное создание зависимостей может быть болезненным. Особенно, если есть несколько уровней вложенности. Тут на помощь приходят форсунки.

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

Примерами для JavaSript/TypeScript являются InversifyJS, Awilix, TypeDI и NestJS, для C# — ASP.NET Core Dependency Injection, для Java — Spring Framework и для Go — Google Wire.

Перепишем последнюю реализацию с внедрением интерфейса с помощью контейнера TypeDI:

import { Service, Inject, Container } from 'typedi';
import 'reflect-metadata';

interface Engine {
  start(): void;
  stop(): void;
}

@Service()
class GasolineEngine implements Engine {
  private horsepower: number;
  private fuelType: string;

  constructor(@Inject('horsepower') horsepower: number) {
    this.horsepower = horsepower;
    this.fuelType = 'Gasoline';
  }

  start() {
    console.log(`Gasoline engine started. Horsepower: ${this.horsepower}`);
  }

  stop() {
    console.log('Gasoline engine stopped.');
  }
}

@Service()
class DieselEngine implements Engine {
  private horsepower: number;
  private fuelType: string;

  constructor(@Inject('horsepower') horsepower: number) {
    this.horsepower = horsepower;
    this.fuelType = 'Diesel';
  }

  start() {
    console.log(`Diesel engine started. Horsepower: ${this.horsepower}`);
  }

  stop() {
    console.log('Diesel engine stopped.');
  }
}

@Service()
class Car {
  private engine: Engine;
  private brand: string;
  private model: string;

  constructor(@Inject('brand') brand: string, @Inject('model') model: string, @Inject('engine') engine: Engine) {
    this.brand = brand;
    this.model = model;
    this.engine = engine;
  }

  public startCar() {
    console.log(`Starting ${this.brand} ${this.model}`);
    this.engine.start();
  }

  public stopCar() {
    console.log(`Stopping ${this.brand} ${this.model}`);
    this.engine.stop();
  }
}

// Register dependencies with the container
Container.set('horsepower', 200);
Container.set('brand', 'Toyota');
Container.set('model', 'Camry');
Container.set({ id: 'engine', type: GasolineEngine }); 
Container.set({ id: Car, type: Car });

Container.set('horsepower', 300);
Container.set('brand', 'BMW');
Container.set('model', 'X5');
Container.set({ id: 'engine', type: DieselEngine }); 
Container.set({ id: Car, type: Car });

// Example usage
const car1 = Container.get(Car);
car1.startCar();
car1.stopCar();

const car2 = Container.get(Car);
car2.startCar();
car2.stopCar();

// console.log:
Starting Toyota Camry
Gasoline engine started. Horsepower: 200
Stopping Toyota Camry
Gasoline engine stopped.
Starting BMW X5
Diesel engine started. Horsepower: 300
Stopping BMW X5
Diesel engine stopped.

Использование контейнера внедрения зависимостей упрощает управление зависимостями и клиентами. Это не только позволяет создавать сложный граф зависимостей, но и упрощает тестирование компонентов с помощью заглушек и макетов.

Заключение

Таким образом, внедрение зависимостей — ценный метод проектирования гибких, модульных и тестируемых программных систем. Это способствует слабой связи, повышает возможность повторного использования кода и упрощает настройку и управление зависимостями.

Применяя DI, вы можете писать более удобные в сопровождении, масштабируемые и надежные приложения.

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

  1. Википедия: Инъекция зависимостей
  2. Мартин Фаулер: Инверсия контейнеров управления и шаблон внедрения зависимостей

Спасибо, что прочитали эту статью! Если у вас есть какие-либо вопросы или предложения, не стесняйтесь писать комментарии.

Посмотрите другие мои статьи:





Дополнительные материалы на PlainEnglish.io.

Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter, LinkedIn, YouTube и Discord .