Книга Angular и TypeScript. Сайтостроение для профессионалов

Книга Angular и TypeScript Всем привет! Недавно вышла новая книга, описывающая работу с непростыми и мощными инструментами веб-разработчика: Angular и TypeScript. Авторы: Яков Файн и Антон Моисеев объясняют особенности фреймворка, приводя простые примеры кода, и нескольких глав излагают, как создать одностраничное приложение для онлайн-аукционов. Ниже мы рассмотрим раздел из книги, посвященный внедрению зависимостей.

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

Мы начнем эту главу с того, что определим, какие проблемы решает DI, и рассмотрим преимущества DI как шаблона проектирования. Далее рассмотрим, как данный шаблон реализуется в Angular на примере компонента ProductComponent, который зависит от ProductService. Вы увидите, как написать внедряемый сервис и внедрить его в другой элемент.

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

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

Шаблоны проектирования — рекомендации по решению некоторых распространенных задач. Заданный шаблон проектирования может быть реализован разными способами в зависимости от используемого ПО. В этом разделе мы кратко рассмотрим два шаблона проектирования: «Внедрение зависимостей» (Dependency Injection, DI) и «Инверсия управления» (Inversion of Control, IoC).

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

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

    var product = new Product();
    createShipment(product);

Функция createShipment() зависит от существования экземпляра класса Product. Другими словами, функция createShipment() имеет зависимость: Product. Но сама по себе она не знает, как создавать объекты такого типа. Вызывающий сценарий должен каким-то образом создавать и передавать (то есть внедрять) этот объект как аргумент функции. Технически, вы отвязываете место создания объекта Product от места его использования — но обе предыдущие строки кода находятся в одном сценарии, поэтому данное отвязывание нельзя назвать настоящим. При необходимости заменить тип Product на тип MockProduct понадобится внести небольшое изменение в наш простой пример.

Что если функция createShipment() имеет три зависимости (продукт, компанию-отправителя, фулфилмент-центр), и каждая из них имеет собственные зависимости? В таком случае для создания разного набора объектов для функции createShipment() потребуется внести большое количество изменений. Можно ли попросить кого-то создать экземпляры зависимостей (и их зависимостей) вместо вас?

Здесь и нужен шаблон «Внедрение зависимостей»: если объект А зависит от объекта типа Б, то объект А не будет явно создавать объект Б (в случае использования оператора new, как в предыдущем примере). Вместо этого объект Б будет внедрен из операционной среды. Объект А просто должен объявить следующее: «Мне нужен объект типа Б; может ли кто-то его мне передать?» Слово «типа» здесь самое важное. Объект А не запрашивает конкретную реализацию объекта, и его запрос будет удовлетворен, если внедряемый объект имеет тип Б.

Шаблон Инверсия управления

Шаблон «Инверсия управления» является более общим, нежели DI. Вместо того чтобы использовать в своем приложении API фреймворка (или программного контейнера), фреймворк создает и отправляет объекты, необходимые приложению. Шаблон IoC может быть реализован разными способами, а DI — это один из способов предоставления требуемых объектов. Angular играет роль контейнера IoC и может предоставлять требуемые объекты в соответствии с объявлениями, сделанными в вашем компоненте.

Преимущества внедрения зависимости

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

Слабое связывание и повторное использование

Предположим, у вас имеется компонент ProductComponent, который получает подробную информацию о продукте с помощью класса ProductService.

Если вы не используете DI, то компонент ProductComponent должен знать, как создавать объекты класса ProductService. Это можно сделать несколькими способами, например, задействовать оператор new, вызвать метод getInstance() для объекта-синглтона или вызвать метод createProductService() какого-нибудь класса-фабрики. В любом из описанных случаев компонент ProductComponent становится тесно (жестко) связанным с классом ProductService.
Если вам нужно использовать компонент ProductComponent в другом приложении, которое применяет другой сервис для получения подробной информации о продукте, то вы должны модифицировать код (например, так: productService = new AnotherProductService()). DI позволяет отвязать компоненты приложения, избавив их от необходимости знать, как создавать зависимость.

Рассмотрим следующий пример компонента ProductComponent:

    @Component({
        providers: [ProductService]
    })
    class ProductComponent {
      product: Product;
      constructor(productService: ProductService) {
         this.product = productService.getProduct();
      }
    }

В приложениях вы регистрируете объекты для DI, указывая поставщики. Поставщик — это инструкция для Angular о том, как создать экземпляр объекта для последующего внедрения в целевой компонент или директиву. В предыдущем фрагменте кода строка providers:[ProductService] является сокращением строки providers:[{provide:ProductService, useClass:ProductService}].

Angular применяет концепцию токенов — произвольных имен, представляющих внедряемый объект. Обычно имя токена соответствует типу внедряемого объекта, соответственно, в предыдущем фрагменте кода Angular получает указание предоставить токен ProductService, задействуя класс с тем же именем. Использование объекта со свойством provide позволяет соотнести один токен с разными значениями или объектами (например, эмулировать функциональность класса ProductService, когда кто-то разрабатывает реальный класс сервиса).

Теперь, когда вы добавили свойство providers в аннотацию Component компонента ProductComponent, модуль DI, предоставляемый Angular, будет знать, что он должен создать объект типа ProductService. Компонент ProductComponent не должен знать, какую именно реализацию типа ProductService будет использовать — он применит любой объект, указанный как поставщик. Ссылка на объект типа ProductService будет внедрена с помощью аргумента конструктора, нет необходимости явно создавать объект типа ProductService в компоненте ProductComponent. Просто задействуйте его как предыдущий код, который вызывает метод сервиса getProduct() экземпляра класса ProductService, созданного Angular.

Если нужно использовать один и тот же компонент ProductComponent в разных приложениях, имеющих разную реализацию типа ProductService, то измените строку providers, как показано в следующем примере:

    providers: [{provide: ProductService, useClass: AnotherProductService}]

Теперь Angular будет создавать экземпляр класса AnotherProductService, но код, задействующий тип ProductService, не будет генерировать ошибки. В этом примере использование DI увеличивает возможность повторного применения компонента ProductComponent и разрушает тесное связывание с классом ProductService. Если один объект тесно связан с другим, то для использования хотя бы одного из них может потребоваться внести много изменений.

Тестируемость

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

Предположим, вам нужно добавить в приложение возможность авторизации. Можно создать компонент LoginComponent (для отрисовки полей ID и password), использующий компонент LoginService, который должен соединяться с определенным сервером авторизации и проверять привилегии пользователя. Сервер авторизации должен быть предоставлен другим отделом, но он еще не готов. Вы завершаете написание кода компонента LoginComponent, но затрудняетесь протестировать его по причинам, которые не можете контролировать, например, из-за зависимости или другого компонента, разрабатываемого другими людьми.

При тестировании часто применяются фальшивые объекты, имитирующие поведение реальных. В случае использования фреймворка DI можно создать фальшивый объект, MockLoginService, который не соединяется с сервером авторизации, но при этом в нем жестко закодированы привилегии, присвоенные пользователям, имеющим определенные комбинации идентификатора и пароля. Задействуя DI, можно написать всего одну строку, в которой MockLoginService будет внедрен в представление Login (Авторизация) приложения, что позволит не ждать готовности сервера авторизации. Далее, когда сервер будет готов, можно изменить строку providers так, чтобы Angular внедрил реальный компонент LoginService, как показано на рис. 4.1

Схема тестирования внедрения зависимостей

Инъекторы и поставщики

Теперь, когда вы кратко ознакомились с шаблоном «Внедрение зависимости», перейдем к деталям реализации DI в Angular. В частности, мы рассмотрим такие концепции, как инъекторы и поставщики.

Каждый компонент может иметь объект Injector, который может внедрять объекты и примитивные значения в элемент или сервис. В любом приложении Angular имеется корневой инъектор, доступный всем его модулям. Чтобы указать инъектору, что именно внедрить, вы указываете поставщик. Инъектор внедрит объект или значение, указанное в поставщике, в конструктор компонента.

Поставщики позволяют соотносить пользовательские типы (или токены) с конкретными реализациями этого типа (или значениями). Можно указать поставщики либо внутри декоратора компонента Component, либо как свойство @NgModule, что было сделано в каждом фрагменте кода, представленного до настоящего момента.

Вы будете использовать компонент ProductComponent и класс ProductService во всех примерах кода, показанных в этой главе. Если ваше приложение имеет класс, реализующий определенный тип (например, ProductService), то вы можете указать объект поставщика для данного класса во время предварительной загрузки модуля AppModule, например, так:

    @NgModule({
        ...
        providers: [{provide:ProductService,useClass:ProductService}]
    })

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

    @NgModule({
        ...
        providers: [ProductService]
    })

Можно указать свойство providers в аннотации Component. Короткая нотация поставщика ProductService в Component выглядит так:

    providers:[ProductService]

Ни один экземпляр типа ProductService еще не был создан. Строка providers указывает инъектору следующее: «Когда нужно создать объект, имеющий аргумент типа ProductService, создайте экземпляр зарегистрированного класса для внедрения в этот объект».

Если нужно внедрить разные реализации определенного типа, примените более длинную нотацию:

    @NgModule({
        ...
        providers: [{provide:ProductService,useClass:MockProductService}]
    })

Так она выглядит на уровне компонента:

    @Component({
        ...
        providers: [{provide:ProductService, useClass:MockProductService}]
    })

Она дает инъектору следующую инструкцию: «Когда нужно внедрить объект типа ProductService в компонент, создайте экземпляр класса MockProductService».

Благодаря поставщику инъектор знает, что внедрять; теперь нужно указать, куда внедрять объект. В TypeScript все сводится к объявлению аргумента конструктора с указанием его типа. Следующая строка показывает, как внедрить объект типа ProductService в конструктор компонента:

    constructor(productService: ProductService)

Конструктор останется таким же независимо от того, какая конкретная реализация класса ProductService будет указана в качестве поставщика. На рис. 4.2 показана примерная схема последовательности процесса внедрения.

Процесс внедрения

Как объявлять поставщики

Можно объявить пользовательские поставщики как массив объектов, содержащий свойство provide. Такой массив может быть указан в свойстве providers модуля или на уровне компонента.
Рассмотрим пример массива с одним элементом, в котором указан объект поставщика для токена ProductService:

    [{provide:ProductService, useClass:MockProductService}]

Свойство provide позволяет соотнести токен с методом, создающим внедряемый объект. В этом примере вы даете Angular указание создать объект класса MockProductService там, где в качестве зависимости используется токен ProductService. Но создатель объекта (инъектор Angular) может применять класс, функцию фабрики, строку или специальный класс OpaqueToken для создания объекта или его внедрения.

  • Чтобы соотнести токен и реализацию класса, используйте объект, имеющий свойство useClass, как показано в предыдущем примере.
  • При наличии функции фабрики, создающей объекты на основе определенных критериев, задействуйте объект со свойством useFactory. Оно позволяет указать функцию фабрики (или анонимное стрелочное выражение), которая знает, как создавать требуемые объекты. Такая функция может иметь необязательный аргумент с зависимостями, если они существуют.
  • Чтобы предоставить строку с простым внедряемым значением (например, URL сервиса), обратитесь к объекту со свойством useValue.

Пример приложения, задействующего Angular DI

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

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

Создадим простое приложение, применяющее компонент ProductComponent для отрисовки информации о продукте и сервис ProductService, который предоставляет данные о продукте. Если вы используете загружаемый код, поставляемый с книгой, то данное приложение находится в файле main-basic.ts в каталоге di_samples. В этом подразделе вы создадите приложение, генерирующее страницу, показанную на рис. 4.3.

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

Компонент ProductComponent может запросить внедрение объекта ProductService путем объявления аргумента конструктора с типом:

    constructor(productService: ProductService)

На рис. 4.4 показан пример приложения, которое использует эти компоненты.

Пример внедрения Объекта

Модуль AppModule предварительно загружает AppComponent, содержащий компонент, зависящий от ProductService. Обратите внимание на операторы импорта и экспорта. Определение ProductService начинается с оператора экспорта, что позволяет другим элементам получить доступ к его содержимому. Компонент ProductComponent содержит оператор импорта, который предоставляет имя класса (ProductService) и импортируемого модуля (располагается в файле product-service.ts).

Атрибут providers, определенный на уровне компонента, указывает Angular предоставить экземпляр класса ProductService по требованию. Данный класс может общаться с каким-нибудь сервером, запрашивая подробную информацию о продукте, выбранном на веб-странице. Но мы сейчас опустим эту часть и сконцентрируемся на том, как указанный сервис можно внедрить в ProductComponent. Реализуем компоненты, показанные на рис. 4.4.

Помимо index.html вы создадите следующие файлы:

  • файл main-basic.ts будет содержать код, необходимый для загрузки модуля AppModule, который содержит компонент AppComponent, размещающий ProductComponent;
  • компонент ProductComponent будет реализован в файле product.ts;
  • сервис ProductService будет реализован в файле product-service.ts.

Каждый из этих файлов довольно прост. Файл main-basic.ts, показанный в листинге 4.1, содержит код модуля и корневой компонент, который размещает компонент-потомок ProductComponent. Этот модуль импортирует и объявляет данный компонент.

Листинг 4.1. Содержимое файла main-basic.ts

    import {Component} from '@angular/core';
    import ProductComponent from './components/product';
    import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
    import { NgModule }         from '@angular/core';
    import { BrowserModule } from '@angular/platform-browser';
    
    @Component({
             selector: 'app',
             template: `<h1> Basic Dependency Injection Sample</h1>
                        <di-product-page></di-product-page>`
    })
    class AppComponent {}
    
    @NgModule({
              imports:       [ BrowserModule],
              declarations: [ AppComponent, ProductComponent],
              bootstrap:     [ AppComponent ]
    })
    class AppModule { }
    platformBrowserDynamic().bootstrapModule(AppModule);

Основываясь на теге <di-product-page>, легко догадаться, что существует элемент с селектором, имеющим это значение. Данный селектор объявлен в компоненте ProductComponent, чья зависимость (ProductService) внедрена с помощью конструктора (листинг 4.2).

Листинг 4.2. Содержимое файла product.ts

Содержимое файла product.ts

В листинге 4.2 имя типа совпадает с именем класса — ProductService, так что можно использовать короткую нотацию без необходимости явно соотносить свойства provide и useClass. При указании поставщиков имя (токен) внедряемого объекта отделяется от его реализации. В этом случае имя токена будет таким же, как и имя типа: ProductService. Сама реализация данного сервиса может находиться в классах ProductService, OtherProductService или где-то еще. Замена одной реализации на другую сводится к изменению строки providers.

Конструктор компонента ProductComponent вызывает метод getProduct() для сервиса и размещает ссылку на возвращенный объект типа Product в переменной класса продукта, которая применяется в шаблоне HTML. Используя двойные фигурные скобки, в листинге 4.2 можно связать свойства title, description и price класса Product.

Файл product-service.ts содержит определение двух классов: Product и ProductService (листинг 4.3).

Содержимое файла product-service.ts

В реальных приложениях метод getProduct() должен будет получать информацию о продукте из внешнего источника данных, например, отправляя HTTP-запрос на удаленный сервер. Чтобы запустить этот пример, откройте командную строку в каталоге проекта и выполните команду npm start. Live-сервер откроет окно, как было показано ранее на рис. 4.3. Экземпляр класса ProductService внедрен в компонент ProductComponent, который отрисовывает информацию о продукте, предоставленную сервером.

Книга на сайте издательства
Скидка 20% по купону — Angular