Внедрение зависимостей в Angular 2

Original author: Pascal Precht
  • Translation
Добрый вечер, уважаемые дамы и господа!

При всей неослабевающей популярности фреймворка AngularJS мы все-таки не успели отметиться с книгой по его первой версии, а теперь решили не дожидаться второй и поинтересоваться: насколько вам импонирует вот эта работа, охватывающая кроме AngularJS и более широкий контекст JavaScript-разработки?



Под катом вы найдете перевод регулярно обновляемой статьи Паскаля Прехта (версия от 12 октября 2015 года), рассказывающей о таких высоких материях, как внедрение зависимостей в AngularJs и, что самое интересное, тех доработках, которые ждут этот механизм в Angular 2.

Внедрение зависимостей всегда было одной из наиболее заметных и козырных особенностей Angular. Итак, этот фреймворк позволяет внедрять зависимости в различные компоненты в разных точках приложения; при этом не требуется знать, как эти зависимости создаются, либо в каких зависимостях они в свою очередь нуждаются. Однако, оказывается, что современный механизм внедрения зависимостей (в Angular 1) имеет некоторые проблемы, которые предстоит решить в Angular 2, чтобы построить фреймворк нового поколения. В этой статье мы поговорим о новой системе внедрения зависимостей — для будущих поколений.

Прежде чем приступить к изучению нового материала, давайте разберемся, что же такое «внедрение зависимостей» (DI) и какие проблемы с DI возникают в Angular 1.

Внедрение зависимостей

Войта Джина сделал отличный доклад о внедрении зависимостей на конференции ng-conf 2014. В своем выступлении он изложил историю создания новой системы внедрения зависимостей, которая будет разрабатываться для Angular 2, описал идеи, на которых она базируется. Кроме того, он четко описал, что DI можно рассматривать в двух ипостасях: как паттерн проектирования и как фреймворк. В первом случае объясняются шаблоны применения DI, а во втором случае речь идет о системе поддержки и сборки зависимостей. Эту статью я собираюсь построить таким же образом, поскольку так будет проще объяснить всю эту концепцию

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

class Car {
  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
    this.doors = app.get('doors');
  }
}



Ничего особенного. Имеем класс Car
с конструктором, в котором задаем все необходимое для создания объекта car («автомобиль»), как только он нам понадобится. В чем проблема с этим кодом? Как видите, конструктор не только присваивает необходимые зависимости внутренним свойствам, но и знает, как создается их объект. Например, объект engine («двигатель») создается при помощи конструктора Engine
, Tires
(шины) по-видимому, представляет собой интерфейс-одиночку, а doors
(дверцы) запрашиваются через глобальный объект, действующий в качестве локатора сервисов.

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

Итак, как можно было бы улучшить этот код и сделать его более удобным для тестирования? Это очень просто, и вы уже, вероятно, понимаете, о чем речь. Меняем код так:

class Car {
  constructor(engine, tires, doors) {
    this.engine = engine;
    this.tires = tires;
    this.doors = doors;
  }
}


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

var car = new Car(
  new Engine(),
  new Tires(),
  new Doors()
);


Круто? Теперь зависимости отделены от нашего класса, что позволяет нам сообщать мок-зависимости при написании тестов:

var car = new Car(
  new MockEngine(),
  new MockTires(),
  new MockDoors()
);


Представляете, это и есть внедрение зависимости. Точнее, именно этот паттерн еще называется "внедрение конструктора". Есть еще два паттерна: внедрение через метод класса (setter injection) и внедрение через интерфейс (interface injection), но мы не будем рассматривать их в этой статье.

Отлично, вот мы и используем DI, но где начинает задействоваться система DI? Как уже упоминалось ранее, мы буквально сдвинули ответственность за создание зависимостей на уровень выше. Именно в этом заключается наша новая проблема. Кто организует сборку всех этих зависимостей для нас? Мы сами.

function main() {
  var engine = new Engine();
  var tires = new Tires();
  var doors = new Doors();
  var car = new Car(engine, tires, doors);

  car.drive();
}



Сейчас нам нужна поддержка функции main. Делать это вручную весьма обременительно, особенно по мере роста приложения. Может быть, лучше поступить примерно так?

function main() {
  var injector = new Injector(...)
  var car = injector.get(Car);

  car.drive();
}


Внедрение зависимостей как фреймворк

Здесь мы начинаем использовать внедрение зависимостей как фреймворк. Как известно, в Angular 1 имеется собственная система DI, позволяющая аннотировать сервисы и другие компоненты; также с ее помощью инжектор может узнать, какие зависимости необходимо инстанцировать. Например, в следующем коде показано, как можно аннотировать наш класс Car
в Angular 1:

class Car {
  ...
}

Car.$inject = ['Engine', 'Tires', 'Doors'];


Затем мы регистрируем наш Car
как сервис и всякий раз, запрашивая его, получаем от него экземпляр-одиночку, совершенно не заморачиваясь о создании необходимых зависимостей для «автомобиля».

var app = angular.module('myApp', []);

app.service('Car', Car);

app.service('OtherService', function (Car) { 
  // доступен экземпляр Car
});


Все классно, но выясняется, что у имеющегося механизма DI все-таки есть некоторые проблемы:

  • Внутренний кэш – зависимости выдаются как одиночки. Всякий раз, когда мы запрашиваем сервис, он создается в рамках жизненного цикла приложения лишь однажды. Создавать фабричную машинерию довольно обременительно.
  • Конфликт пространства имен – в приложении может быть только один маркер конкретного “типа”. Если у нас есть сервис car и стороннее расширение, также вводящее в программу одноименный сервис – у нас проблема.
  • Встроенность во фреймворк – Внедрение зависимостей в Angular 1 встроено прямо во фреймоврк. Нет возможности использовать этот механизм отдельно как самостоятельную систему.


Эти проблемы необходимо решить, чтобы перевести внедрение зависимостей в Angular на новый уровень.

Внедрение зависимостей в Angular 2

Прежде чем перейти к рассмотрению кода, нужно понять концепцию, лежащую в основе DI в Angular 2. Следующий рисунок иллюстрирует обязательные компоненты новой системы DI.



Внедрение зависимостей в Angular 2 принципиально состоит из трех элементов:

  • Инжектор – объект, предоставляющий нам различные API для создания экземпляров зависимостей.
  • Провайдер – провайдер напоминает рецепт, описывающий инжектору, как создать экземпляр зависимости. У провайдера есть маркер, отображаемый им на фабричную функцию, создающую объект.
  • Зависимость – зависимость это тип, к которому должен относиться создаваемый объект.


Итак, получив представление об этой концепции, рассмотрим, как она реализуется в коде. Продолжим работать с нашим классом Car
и его зависимостями. Вот как можно использовать внедрение зависимостей в Angular 2, чтобы получить экземпляр Car
:

import { Injector } from 'angular2/di';

var injector = Injector.resolveAndCreate([
  Car,
  Engine,
  Tires,
  Doors
]);
          
var car = injector.get(Car);


Мы импортируем из Angular 2 Injector
, предоставляющий некоторые статические API для создания инжекторов. Метод resolveAndCreate()
– это, по сути, фабричная функция, создающая инжектор и принимающая список провайдеров. Вскоре мы обсудим, как предполагается использовать эти классы в качестве провайдеров, но пока сосредоточимся на injector.get()
. Видите, как мы запрашиваем экземпляр Car
в последней строке? Как наш инжектор узнает, какие зависимости необходимо создать, чтобы инстанцировать «car»? Что ж, рассмотрим класс Car


import { Inject } from 'angular2/di';

class Car {
  constructor(
    @Inject(Engine) engine,
    @Inject(Tires) tires,
    @Inject(Doors) doors
  ) {
    ...
  }
}


Мы импортируем из фреймворка сущность Inject и применяем ее в качестве декоратора к параметрам нашего конструктора.

Декоратор Inject
прикрепляет метаданные к нашему классу Car
, который затем потребляется нашей системой DI. В принципе, вот что мы здесь делаем: сообщаем DI, что первый параметр конструктора должен быть экземпляром типа Engine
, второй — типа Tires
и третий — типа Doors
. Мы можем переписать этот код в духе TypeScript, чтобы он выглядел более естественно:

class Car {
  constructor(engine: Engine, tires: Tires, doors: Doors) {
    ...
  }
}



Отлично, наш класс объявляет собственные зависимости, а DI может считывать эту информацию и инстанцировать все, что необходимо для создания объекта Car
. Но каким образом инжектор узнает, как создать такой объект? Здесь-то в игру и вступают провайдеры. Помните метод resolveAndCreate()
, в котором мы передали список классов?

var injector = Injector.resolveAndCreate([
  Car,
  Engine,
  Tires,
  Doors
]);


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

import {provide} from 'angular2/angular2';

var injector = Injector.resolveAndCreate([
  provide(Car, {useClass: Car}),
  provide(Engine, {useClass: Engine}),
  provide(Tires, {useClass: Tires}),
  provide(Doors {useClass: Doors})
]);


У нас есть функция provide()
, отображающая маркер на конфигурационный объект. Этот маркер может быть типом либо строкой. Если сейчас прочитать эти провайдеры, то становится гораздо понятнее, что происходит. Мы предоставляем экземпляр типа Car
через класс Car
, тип Engine
через класс Engine
и т.д. Это и есть механизм-рецепт, о котором мы говорили выше. Итак, при помощи провайдеров мы не просто сообщаем инжектору, какие зависимости используются во всем приложении, но также описываем, как будут создаваться объекты этих зависимостей.

Теперь возникает следующий вопрос: когда желательно использовать более длинный синтаксис вместо более краткого? Зачем писать provide(Foo, {useClass: Foo})
, если можно обойтись одним Foo, верно? Да, верно. Вот почему мы сразу начали с сокращенного синтаксиса. Однако более длинный синтаксис открывает перед нами большие, очень большие возможности. Взгляните на следующий фрагмент кода.

provide(Engine, {useClass: OtherEngine})


Верно. Мы можем отображать маркер практически на что угодно. Здесь мы отображаем маркер Engine
на класс OtherEngine
. Таким образом, если мы теперь запросим объект типа Engine
, то получим экземпляр класса OtherEngine
.
Это невероятно мощный механизм, позволяющий не только избегать конфликтов имен, но и создавать тип как интерфейс, привязывая его к конкретной реализации. Кроме того, мы можем выгружать в нужном месте ту или иную зависимость, заменяя ее маркером и не трогая остальной код.

Внедрение зависимостей в Angular 2 также привносит еще пару рецептов провайдеров, о которых мы поговорим в следующем разделе.

Другие конфигурации провайдеров

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

Предоставление значений

Можно предоставить простое значение при помощи

{useValue: value}
provide(String, {useValue: 'Hello World'})


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

Предоставление псевдонимов

Можно отображать маркер-псевдоним на другой маркер, вот так:

provide(Engine, {useClass: Engine})
provide(V8, {useExisting: Engine})


Предоставление фабрик

Да, наши любимые фабрики

provide(Engine, {useFactory: () => {
  return function () {
    if (IS_V8) {
      return new V8Engine();
    } else {
      return new V6Engine();
    }
  }
}})


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

provide(Engine, {
  useFactory: (car, engine) => {

  },
  deps: [Car, Engine]
})


Необязательные зависимости

Декоратор @Optional
позволяет объявлять зависимости как необязательные. Это бывает удобно, к примеру, в тех случаях, когда наше приложение рассчитывает на стороннюю библиотеку, а если эта библиотека оказывается недоступна – нужен механизм отката.

class Car {
  constructor(@Optional(jQuery) $) {
    if (!$) {
    // откат
    }
  }
}


Как видите, DI в Angular 2 решает практически все проблемы, существовавшие с DI в Angular. Но один вопрос мы еще не обсудили. Создаются ли по-прежнему одиночки при новом DI? Да.

Одноразовые зависимости и дочерние инжекторы

Если нам понадобится одноразовая зависимость (transient dependency) – такая, при запрашивании которой нам всегда требуется ее новый экземпляр, есть два варианта:

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

provide(Engine, {useFactory: () => {
  return () => {
    return new Engine();
  }
}})


Можно создать дочерний инжектор при помощи Injector.resolveAndCreateChild()
. Дочерний инжектор привносит собственные привязки, и этот экземпляр будет отличен от родительского инжектора.

var injector = Injector.resolveAndCreate([Engine]);
var childInjector = injector.resolveAndCreateChild([Engine]);

injector.get(Engine) !== childInjector.get(Engine);


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



На рисунке показано три инжектора, два из которых – дочерние. Каждый инжектор получает собственную конфигурацию провайдеров. Теперь, если мы запросим у второго дочернего инжектора экземпляр типа Car, то этот дочерний инжектор создаст объект «car». Однако «engine» (двигатель) будет создаваться первым дочерним инжектором, а «tires» (шины) и «doors» (дверцы) – самым вышестоящим из внешних родительских инжекторов. Получается нечто вроде цепочки прототипов.

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

Как же все это будет работать в Angular 2?

Теперь, когда мы рассмотрели внедрение зависимостей в Angular 2, давайте обсудим, как этот механизм работает в рамках фреймворка. Должны ли мы создавать инжекторы вручную, когда собираем компоненты Angular 2? К счастью, команда Angular не жалела времени и сил, и создала красивый API, скрывающий всю машинерию инжектора при сборке компонентов в Angular 2.

Рассмотрим следующий простой компонент Angular 2.

@Component({
  selector: 'app'
})
@View({
  template: '<h1>Hello !</h1>'
})
class App {
  constructor() {
    this.name = 'World';
  }
}
          
bootstrap(App);


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

class NameService {
  constructor() {
    this.name = 'Pascal';
  }

  getName() {
    return this.name;
  }
}


Опять же, ничего особенного. Просто создаем класс. Затем, чтобы открыть к нему в нашем приложении доступ как к внедряемому объекту, мы должны сообщить инжектору нашего приложения некоторые данные о конфигурации провайдера. Но как это сделать? Мы еще даже не создали инжектор.

Метод bootstrap()
занимается созданием корневого инжектора для нашего приложения при его начальной загрузке. Он принимает список провайдеров в качестве второго аргумента, и этот список будет передаваться непосредственно инжектору прямо на этапе его создания. Иными словами, вот что нужно здесь сделать:

bootstrap(App, [NameService]);


Вот и все. Теперь, переходя к внедрению как к таковому, мы применяем изученные выше декораторы @Inject
.

class App {
  constructor(@Inject(NameService) NameService) {
    this.name = NameService.getName();
  }
}



Либо, если остановимся на TypeScript, можем просто добавить аннотации типов к нашему конструктору:

class App {
  constructor(NameService: NameService) {
    this.name = NameService.getName();
  }
}


Шикарно! Вся нутрянка Angular чудесным образом куда-то исчезла! Но остается еще один вопрос: что делать, если для конкретного компонента нам понадобится иная конфигурация привязок?

Допустим, у нас есть сервис NameService
, который можно внедрять в пределах всего приложения для типа NameService
, но ровно одному компоненту требуется иной сервис? Здесь-то нам и пригодится свойство providers
аннотации @Component
. Оно позволяет нам добавлять провайдеры к конкретному компоненту (а также его дочерним компонентам).

@Component({
  selector: 'app',
  providers: [NameService]
})
@View({
  template: '<h1>Hello !</h1>'
})
class App {
  ...
}


Поясню: providers
не конфигурирует экземпляры, которые будут внедряться. Оно конфигурирует дочерний инжектор, который будет создаваться для этого компонента. Как было указано выше, мы также можем конфигурировать видимость наших привязок, точнее — указывать, какой компонент что может внедрять. Например, свойство viewProviders позволяет открывать доступ к зависимостям только для представления компонента, но не для его потомков.

Заключение

Новая система внедрения зависимостей в Angular решает все проблемы, существовавшие с DI в Angular 1. Больше никаких конфликтов имен. Это отдельный компонент фреймворка, может использоваться как самостоятельная система, даже без Angular 2.

Only registered users can participate in poll. Log in, please.

Книга по JavaScript, jQuery, AngularJS

  • 37.6%Издавайте, 900+ страниц в переводе — не страшно35
  • 55.9%Не стоит, издавайте Angular 2 как появится52
  • 26.9%Спасибо за статью!25
  • 8.6%Много кода из ничего, я лучше Флэнагана перечитаю8

Comments 3

    +2
    Переведите лучше эту книгу пожалуйста: manning.com/books/angular-2-development-with-typescript
      +1
      Большое спасибо, обсудим
      0
      Спасибо за перевод. Про jQuery писать не нужно, в проектах с Ангуляром он не нужен

      Only users with full accounts can post comments. Log in, please.