company_banner

Используем DI в Angular по максимуму — концепция частных провайдеров

    В Angular очень мощный механизм Dependency Injection. Он позволяет передавать по вашему приложению любые данные, преобразовывать и переопределять их в нужных частях.

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

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

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



    Как обычно используется DI в Angular


    Я ежедневно провожу ревью Angular-кода на работе и в опенсорсе. Как правило, в большинстве приложений DI сводится к следующей функциональности:

    1. Получить сущности Angular из дерева зависимостей: ChangeDetectorRef, ElementRef и проч.
    2. Получить сервис, чтобы использовать его в компоненте.
    3. Получить какой-нибудь глобальный конфиг по токену, который объявлен где-то наверху. Например, задать токен API_URL в рутовом модуле и получать его из DI в любом месте приложения при необходимости.

    Реже встречаются случаи, когда разработчики идут дальше и преобразуют уже существующий глобальный токен в более удобную форму. Хороший пример такого преобразования — токен на получение WINDOW из пакета @ng-web-apis/common.

    Angular предоставляет токен DOCUMENT, чтобы можно было получить объект страницы из любого места приложения: ваши компоненты не зависят от глобальных объектов, легко тестировать, ничего не сломается при SSR.

    Если вам регулярно нужен доступ до объекта WINDOW, можно написать такой токен:

    import {DOCUMENT} from '@angular/common';
    import {inject, InjectionToken} from '@angular/core';
    
    export const WINDOW = new InjectionToken<Window>(
        'An abstraction over global window object',
        {
            factory: () => {
                const {defaultView} = inject(DOCUMENT);
    
                if (!defaultView) {
                    throw new Error('Window is not available');
                }
    
                return defaultView;
            },
        },
    );
    

    Когда кто-то запросит токен WINDOW в первый раз из дерева DI, выполнится фабрика токена — он получит объект DOCUMENT у Angular и получит из него ссылку на объект window.

    Далее я предлагаю рассмотреть иной подход к таким преобразованиям — когда они выполняются непосредственно в providers компонента или директивы, который и инжектит результат.

    Частные провайдеры


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



    Давайте посмотрим сразу на солидном примере. Эрин Коглар в своем докладе The Architecture of Components на большой международной конференции Angular Connect показала такой пример:


    Если вам неудобно открывать и смотреть видео, то давайте распишу кейс здесь.

    Имеем:

    • Компонент, который отвечает за показ информации по некой сущности — организации.
    • Query-параметр роута, который указывает id организации, с которой мы работаем в текущий момент.
    • Сервис, который по id возвращает Observable с информацией об организации.

    Что хотим сделать

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

    Рассмотрим три способа добиться желаемого и разберем их.

    Как делать не нужно

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

    @Component({
       selector: 'organization',
       templateUrl: 'organization.template.html',
       styleUrls: ['organization.style.less'],
       changeDetection: ChangeDetectionStrategy.OnPush,
    })
    export class OrganizationComponent implements OnInit {
       organization: Organization;
    
       constructor(
           private readonly activatedRoute: ActivatedRoute,
           private readonly organizationService: OrganizationService,
       ) {}
    
       ngOnInit() {
           this.activatedRoute.params
               .pipe(
                   switchMap(params => {
                       const id = params.get('orgId');
    
                       return this.organizationService.getOrganizationById$(id);
                   }),
               )
               .subscribe(organization => {
                   this.organization = organization;
               });
       }
    }
    

    Чтобы использовать полученные данные в шаблоне:

    <p *ngIf="organization">
       {{organization.name}} from {{organization.city}}
    </p>
    

    Этот код будет работать, но у него есть ряд проблем:

    • Неопределенность поля organization: между моментом объявления поля при создании класса и присвоения ему значения пройдет некоторое время. Все это время в данном примере поле будет undefined. Мы либо нарушаем типизацию (такое возможно при отключенном strict у TypeScript), либо предусматриваем это в типе (organization?: Organization) и обрекаем себя на ряд дополнительных проверок.
    • Такой код тяжелее поддерживать. Завтра нам понадобится вытащить еще один параметр, мы продолжим заполнять ngOnInit, и код начнет постепенно превращаться в кашу с кучей неявных переменных и тяжелым для понимания потоком данных.
    • При подобном обновлении полей можно столкнуться с проблемами проверки изменений при использовании стратегии OnPush.

    Сделаем хорошо

    В докладе Эрин из видео, что я прикладывал выше, сделано хорошо. С ее вариантом получается примерно так:

    @Component({
       selector: 'organization',
       templateUrl: 'organization.template.html',
       styleUrls: ['organization.style.less'],
       changeDetection: ChangeDetectionStrategy.OnPush,
    })
    export class OrganizationComponent {
       readonly organization$: Observable<Organization> = this.activatedRoute.params.pipe(
           switchMap(params => {
               const id = params.get('orgId');
               return this.organizationService.getOrganizationById$(id);
           }),
       );
    
       constructor(
           private readonly activatedRoute: ActivatedRoute,
           private readonly organizationService: OrganizationService,
       ) {}
    }
    

    Чтобы использовать полученные данные в шаблоне:
    <p *ngIf="organization$ | async as organization">
       {{organization.name}} from {{organization.city}}
    </p>
    

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

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

    Сделаем еще круче: частные провайдеры


    Давайте присмотримся внимательнее к прошлому решению.

    На самом деле компонент не зависит от роутера и даже от OrganizationService. Он зависит от organization$. Но такой сущности в нашем дереве внедрения зависимостей нет, поэтому мы вынуждены выполнять преобразования в компоненте.

    Но что если выполнить это преобразование перед тем, как данные попадут в компонент? Давайте напишем Provider специально для компонента, в котором и будут происходить необходимые преобразования.

    Для удобства мы выносим провайдеры в отдельный файл рядом с компонентом, получая такую структуру файлов:



    В файле organization.providers.ts будут находиться Provider для преобразования данных и токен для их получения в компоненте:

    export const ORGANIZATION_INFO = new InjectionToken<Observable<Organization>>(
       'A stream with current organization information',
    );
    По этому токену будет идти стрим с необходимой компоненту информацией:
    
    export const ORGANIZATION_PROVIDERS: Provider[] = [
       {
           provide: ORGANIZATION_INFO,
           deps: [ActivatedRoute, OrganizationService],
           useFactory: organizationFactory,
       },
    ];
    
    export function organizationFactory(
       {params}: ActivatedRoute,
       organizationService: OrganizationService,
    ): Observable<Organization> {
       return params.pipe(
           switchMap(params => {
               const id = params.get('orgId');
    
               return organizationService.getOrganizationById$(id);
           }),
       );
    }
    

    Определим массив провайдеров для компонента. Значение для токена ORGANIZATION_INFO получим из фабрики, в которой сделаем необходимое преобразование данных.

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

    {
           provide: ACTIVE_TAB,
           deps: [
               [new Optional(), new Self(), RouterLinkActive],
           ],
           useFactory: activeTabFactory,
    }
    

    Объявим providers в компоненте:

    @Component({
       ..
       providers: [ORGANIZATION_PROVIDERS],
    })
    

    И мы готовы к использованию данных в компоненте:

    @Component({
       selector: 'organization',
       templateUrl: 'organization.template.html',
       styleUrls: ['organization.style.less'],
       changeDetection: ChangeDetectionStrategy.OnPush,
       providers: [ORGANIZATION_PROVIDERS],
    })
    export class OrganizationComponent {
       constructor(
           @Inject(ORGANIZATION_INFO) readonly organization$: Observable<Organization>,
       ) {}
    }
    

    Класс компонента сводится к одной строчке с получением данных.

    Шаблон остается прежним:

    <p *ngIf="organization$ | async as organization">
       {{organization.name}} from {{organization.city}}
    </p>
    

    Что нам дает этот подход?

    1. Чистые зависимости: компонент не внедряет в себя и не хранит лишних сущностей. Он работает только с теми данными, которые ему нужны, при этом сам остается чистым и содержит только логику для отображения данных.
    2. Простота тестирования: мы можем легко протестировать сам провайдер, потому что его фабрика — обычная функция. Нам легче тестировать компонент: в тестах нам не нужно будет собирать дерево зависимостей и подменять много сущностей — мы просто передадим по токену ORGANIZATION_INFO стрим с мокаными данными.
    3. Готовность к изменению и расширению: если компонент будет работать с другим типом данных, мы поменяем лишь одну строчку. Если нужно будет изменить преобразование — поменяем фабрику. Если потребуется добавить новых данных, то добавим еще один токен — мы можем сложить сколько угодно токенов в наш массив провайдеров.

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

    Заключение


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

    Тем не менее я надеюсь, что частные провайдеры помогут вам упростить компоненты с большим количеством зависимостей или дадут альтернативу при постепенном рефакторинге больших кусков логики.
    Tinkoff.ru
    it’s Tinkoff — просто о сложном

    Комментарии 23

      +1
      Выгода от использования провайдеров в данном случае сомнительна.
      По-моему, нужно идти в другую сторону — к упрощению архитектуры проекта, а не придумывать 100500 волшебных способов решения проблем, которых вообще не должно возникать на frontend-е.
        0
        Можете более информативно пояснить, какое упрощение можно сделать в данном кейсе? Ситуация распространённая: в зависимости от роута подгружать данные по id.
          0
          Я как раз про то, что получение данных по роуту — это совершенно стандартная задача. И для решения этой задачи предлагается метод, который, по-большому счету, ничего не меняет, а размазывает логику ещё по нескольким сущностям. И при этом усложняет понимание проекта новыми разработчиками, которые (давайте будем честны) будут тупо копипастить, ибо «тут так заведено».

          Упрощение архитектуры в данном случае вижу в максимальном упрощении и ускорении получения информации об организации. Скорее всего, это реализуется силами backend-а, кешированием и прочим.
            0
            Я всё равно не понимаю, что вы предлагаете упростить со стороны SPA или конкретного компонента. В статье приведено решение в лоб — дергаем сервис при изменении orderId.

            После манипуляций с провайдерами, компоненту вообще стало пофиг откуда организации берутся и как обновляются. Компонент теперь зависит только от Организаций, а не способа их получения. (Я предпочитаю использовать State + Resolver, чтобы на роут юзера перебрасывало только после загрузки данных. Но это дело даже не вкуса, а UX.)

            Другое дело, что boilerplate не очень приятный получается вокруг провайдеров, но это отдельная проблема.
              0
              Я за то, чтобы не усложнять. А предложенный здесь способ, по-моему, ничего существенного не делает и лишь усложняет приложение.
                0
                Простите, но вы пишете ерунду. Автор достаточно подробно описывает плюсы вынесения обработки получения данных из кода компонента в код провайдера. Это реально делает код намного чище и проще в поддержке.

                Я участвовал во нескольких больших проектах на ng и видел слишком много примеров, когда код компонента крайне усложняется тем, что нужно по каким-то параметрам получить данные из нескольких источников. Три уровня вложенности .subscribe() — лишь меньшее из виденных ужасов.
        +1
        Часто нужно обновить organization. Как это сделать в случае с провайдером?
          0
          Если хотим сделать компонент для редактирования организации, то я бы сделал взаимодействие с сервисом в компоненте. Тогда компонент зависит не от данных организации, а именно от сервиса — ему нужно уметь получить данные, ему нужно уметь обновить их, удалить и т.п.

          У нас провайдеры хорошо упрощают код в кейсах, когда компонент зависит именно от данных. Для других случаев мы используем как обычные сервисы, так и разные вроде Observabled-based или Subject-based варианты, как в статье, ссылка на которой в комментарии ниже
          0
          Можно не регистрировать токен и не использовать фабрику, а создать инжектируемый класс унаследовав его от Observable, как предлагал ваш коллега в одной из прошлых статей. тут habr.com/ru/company/tinkoff/blog/501084

          Ну или еще проще, обычный подчиненный сервис, который инжектит локальный роут и апи-сервис и предоставляет метод для получения сущности, зависимой от роута.
          Это чище читается, чем FactoryProvider.

          Но могу сказать что у этого подхода есть и минус. При чтении кода не получится быстро определить, какие сущности зависят от роута, приходится перебирать все сервисы и смотреть где что инжектится.
          Возможно самый читабельный вариант это просто
          selectOrganizatons(route: ActivatedRoute): Observable<Organization[]>
            0
            да, все верно :)
            Приведенный в статье кейс очень простой, но это плата за лаконичность статьи. Сложный кейс мог бы дать один конкретный хороший пример, но замылыть суть самой идеи. Я выбрал первый вариант

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

            Возможно, не до конца вышло донести мысль, что это лишь один из альтернативных подходов и точно не заменяющий все остальные :(
              0
              нормуль.
              Я б еще добавил что inject имеет ограниченную область применения.
              Must be used in the context of a factory function such as one defined for an InjectionToken.

              И не стоит путать с одноименной функцией из @angular/core/testing
            +1
            Привожу пример, как по мне поинтереснее из своей практики.
            Решали следующую логику: Есть таблица данных(или иное представление), с возможностью перехода в форму элемента. Переход может осуществляться как полный(средствами роутера), в модальном окне, сайд баром — определяется настройками пользователем. В одной точке в зависимости от настроек происходит подмена сервиса, который выполняет переходы. Тем самым избавляем необходимость проталкивать логику выбора в другие компоненты. Подмена поведения логики с помощью DI одна из сильных качеств)
              0
              Спасибо за статью, буквально вчера сделал так как «делать не нужно»,- будет повод исправить
                0
                Как вы применяете данный подход при работе с формами (например Reactive Forms)?
                  0

                  Обычно формы живут в рамках одного компонента, поэтому с ними таких вопросов не возникает.


                  Бывают случаи сложнее, когда форма большая и хочется ее разнести. Помню был случай, когда сложный компонент вроде выпадающего календаря нуждался в данных контрола, но с дефолтным значением. Тоже решали частными провайдерами и вот такой штукой в useFactory: https://twitter.com/marsibarsi/status/1269201441756979200

                    0
                    я иногда оформляю форму как Injectable класс, наследуемый от FormGroup.
                    Лежит в отдельном файлике, прилетает в компонент как провайдер.
                    Хотя может это и неправильно, все таки форма это часть компонента-представления.
                  0

                  Сколько не читал статей о DI, всё равно для меня не понятно как это всё работает. Фабрики какие-то, инжекторы. Я в observable быстрее въехал и активно использую. Может когда-то пойму…

                    +1
                    Ага, у меня было так, что DI шел тяжеловато, но когда разобрался, то полюбил :)

                    Могу порекомендовать тут скринкаст Степана Суворова на ютюбе, там базовые штуки: www.youtube.com/watch?v=bJvBRJUytgg&list=PLDyvV36pndZF-vwsVB48ivZyNJ4ETBKNY&index=14

                    Более фундаментально про саму концепцию IoC и конкретно DI есть интересная серия видео у Ильи Климова:
                    www.youtube.com/watch?v=ETyltCwtQHs&list=PLvTBThJr861xKTf1x6P49MwN6yoN4v69k

                    На почитать могу собственный материал порекомендовать: первая глава в angular.institute. Только там на английском, надеюсь не будет проблемой
                      0
                      Большое спасибо. На английском не проблема. Надеюсь после этого точно пойму
                    0
                    В примере ORGANIZATION_PROVIDERS — это массив провайдеров.
                    Далее в компоненте в массив providers просто добавляется ORGANIZATION_PROVIDERS. Без spread-оператора.
                    Это работает, т.к. providers видимо поддерживает работу с массивами.
                    Проблема вылезает, когда нужно сделать тест на такой компонент и переопределить провайдер: overrideComponent похоже не поддерживает массивы в providers. Со spread'ом работает.

                    Вопрос, собственно, следующий — не правильнее ли тогда везде использовать массив провайдеров со spread? Как вы решали подобные проблемы?
                      0
                      хм, не попадался в такой кейс c override, спасибо

                      Мы копались внутри Angular, providers там «съедает, что дадут»: можно один отдельный провайдер, можно массив провайдеров, можно массив с массивом с массивом с провайдером — тоже сработает
                      0
                      Первое! Автору спасибо за вложенные усилие и публикацию статьи

                      Вопросы

                      Автору
                      1) Обычно выносят в файлы тот код который планируют переиспользовать — я правильно понимаю что этот код(провайдер) будет использоваться только в одном компоненте?
                      2) С коллегами пообсуждали статью и возник еще вопрос. А не будет ли проблем с отловом ошибок если прописали не тот интерфейс в фабрике или месте инжектирования
                      3) не будет ли сложно поддерживать/использовать такой код?

                      Комментаторам
                      1) инжектируя работу роут как параметр разве мы не увеличиваем связанность разных классов — веть сервисы нужны для работы с данными/бизнесовой логикой (то есть даем ему id и получаем список организаций для этого id, сервис не должен знать что такое роутер)

                      Отвергаешь предлагай
                      собственно предложить нечего =(
                      1) разве что сделать какой то базовый компонент для всех компонентов-контейнеров в котором будет инжектиться роутер и стоять геттер на параметры роута (такой метод затрудняет понимание кода, и не стоит того)
                        0
                        Спасибо за такой развернутый комментарий :)
                        Попробую ответить на все вопросы одним сообщением, а то у меня один ответ залезает на другой:
                        Мы недавно с коллегой делали сайд проект, который полностью построили на концепции частных провайдерах. Если здесь в примерах мы код просто выносили в соседний файл «для удобства», то там как раз мы специально заделали папочку «providers» в основе проекта и добавляли туда различные провайдеры, которые позже сплетались в цепочки друг из друга. Над проектом работаем по 10-20 часов в неделю в течение нескольких месяцев, пока полет отличный — все сущности ничего не знают о существовании друг друга, весь полет данных идет через различные преобразования в провайдерах.

                        По поводу типизации: да, ошибки теоретически могут быть и TS никак не спасет, так уж устроен DI

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

                      Самое читаемое