Создание приложений на Angular с использованием продвинутых возможностей DI

0 Favorite

[

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

Слоеный пирог приложения

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

  1. Хранение данных и осуществление операций с данными (слой данных).

  2. Преобразование информации к виду, требуемому для отображения, обработка действий пользователя (слой управления или контроллер).

  3. Визуализация данных и делегация событий (слой представления).

В контексте фреймворка они будут обладать следующими характерными особенностями:

  • элементы слоя представления – компоненты;

  • зависимости слоя управления находятся в элементных инжекторах, а слоя данных – в модульных;

  • связь между слоями осуществляется средствами системы DI;

  • элементы каждого уровня могут иметь дополнительные зависимости, которые непосредственно к слою не относятся;

  • слои связаны в строгом порядке: сервисы одного уровня не могут зависеть друг от друга, компоненты слоя представления могут внедрять только контроллеры, а контроллеры – только сервисы слоя данных.

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

Вообще говоря, под данными, передаваемыми между слоями, имеются в виду произвольные объекты. Однако, в большинстве случаев ими будут Observable, которые идеально подходят к описываемому подходу. Как правило, слой данных отдает Observable с частью состояния приложения. Затем в слое управления с помощью операторов rxjs данные преобразовываются к нужному формату, и в шаблоне компонента осуществляется подписка через async pipe. События на странице связываются с обработчиком в контроллере. Он может иметь сложную логику управления запросами к слою данных и подписывается на Observable, которые возвращают асинхронные команды. Подписка позволяет гибко реагировать на результат выполнения отдельных команд и обрабатывать ошибки, например, открывая всплывающие сообщения. Элементы слоя управления я буду дальше называть контроллерами, хотя они отличаются от таковых в MVC паттерне.

Слой данных

Сервисы слоя данных хранят состояние приложения (бизнес-данные, состояние интерфейса) в удобном для работы с ним виде. В качестве дополнительных зависимостей используются сервисы для работы с данными (например: http клиент и менеджеры состояния). Для непосредственного хранения данных удобно использовать BehaviourSubject в простых случаях, и такие библиотеки как akita, Rxjs или ngxs – для более сложных. Однако, на мой взгляд, последние две избыточны при данном подходе. Лучше всего для предлагаемой архитектуры подходит akita. Ее преимуществами являются отсутствие бойлерплейта и возможность переиспользовать стейты обычным наследованием. При этом обновлять стейт можно непосредственно в операторах rxjs запросов, что гораздо удобнее, чем создание экшенов.

@Injectable({providedIn: 'root'})
export class HeroState {
  private hero = new BehaviorSubject(null);

  constructor(private heroService: HeroService) {}

  load(id: string) {
    return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));
  }

  save(hero: Hero) {
    return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));
  }

  get hero$(): Observable<Hero> {
    return this.hero.asObservable();
  }
}

Слой управления

Так как каждый сервис слоя относится к конкретному компоненту с его поддеревом, логично назвать сервис контроллером компонента. Благодаря тому, что контроллер компонента находится в элементном инжекторе, в нем можно использовать OnDestroy hook и внедрять те же зависимости, что и в компоненте, например ActivatedRoute. Безусловно, можно не создавать отдельный сервис для контроллера в тех случаях, где это равноценно вынесению кода из компонента.

Помимо зависимостей из слоя данных, в контроллере могут быть внедрены зависимости управляющие визуализацией (например: открытие диалогов, роутер) и помогающие с преобразованием данных (например: FormBuilder).

@Injectable()
export class HeroController implements OnDestroy {
  private heroSubscription: Subscription;
  
  heroForm = this.fb.group({
    id: [],
    name: ['', Validators.required],
    power: ['', Validators.required]
  });

  constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }

  save() {
    this.heroState.save(this.heroForm.value).subscribe();
  }

  initialize() {
    this.route.paramMap.pipe(
      map(params => params.get('id')),
      switchMap(id => this.heroState.load(id)),
    ).subscribe();
    this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));
  }
  
  ngOnDestroy() {
    this.heroSubscription.unsubscribe();
  }
}

Слой представления

Функцией слоя представления является визуализация и связывание событий с их обработчиками. И то, и другое происходит в шаблоне компонента. При этом класс будет содержать только код, внедряющий зависимости уровня управления. Простые компоненты (в том числе из внешних библиотек), не использующие внедрение, будут относиться к дополнительным зависимостям. Они получают данные через Input поля и делегируют события через Output.

@Component({
  selector: 'hero',
  template: `
    <hero-form [form]="heroController.heroForm"></hero-form>
    <button (click)="heroController.save()">Save</button>
  `,
  providers: [HeroController]
})
export class HeroComponent {
  constructor(public heroController: HeroController) {
    this.heroController.initialize();
  }
}

Повторное использование кода

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

В демо-приложении я постарался уместить различные методы их использования. Возможно, здесь они немного избыточны, но будут полезны в реальных задачах.

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

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

export abstract class EntityState<T> {
    abstract get entities$(): Observable<T[]>; // список сущностей

    abstract get selectedId$(): Observable<string>; // id выбранного элемента

    abstract get selected$(): Observable<T>; // выбранный элемент

    abstract select(id: string); // выбрать элемент с указанным id

    abstract load(): Observable<T[]> // загрузить список

    abstract save(entity: T): Observable<T>; // сохранить сущность
}

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

@Injectable()
export class EntityCardController {
    isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));

    constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {
    }

    save(form: FormGroup) {
        this.entityState.save(form.value).subscribe({
            next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),
            error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })
        })
    }
}

В самом компоненте используем еще один способ внедрения зависимости – через директиву @ContentChild.

@Component({
    selector: 'entity-card',
    template: `
        <mat-card>
            <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">
                <mat-card-title>
                    <ng-content select=".header"></ng-content>
                </mat-card-title>
                <mat-card-content>
                    <ng-content></ng-content>
                </mat-card-content>
                <mat-card-actions>
                    <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>
                </mat-card-actions>
            </ng-container>
            <ng-template #notSelected>Select Item</ng-template>
        </mat-card>
    `,
    providers: [EntityCardController]
})
export class EntityCardComponent {
    @ContentChild(EntityFormController) entityFormController: EntityFormController<any>;

    constructor(public entityCardController: EntityCardController) {
        this.entityCardController.initialize();
    }
}

Для того чтобы это было возможно, необходимо в провайдерах компонента, который проецируется в entity-card, указать реализацию EntityFormController:

providers: [{ provide: EntityFormController, useClass: HeroFormController }]

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

<entity-card>
	<hero-form></hero-form>
</entity-card>

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

export interface Entity {
    value: string;
    label: string;
}

@Injectable()
export abstract class EntityListController<T> {
    constructor(protected entityState: EntityState<T>) {}

    select(value: string) {
        this.entityState.select(value);
    }

    selected$ = this.entityState.selectedId$;

    abstract get entityList$(): Observable<Entity[]>;
}

Для уточнения преобразования конкретной модели данных к отображаемому виду теперь достаточно объявить наследника и переопределить абстрактное свойство.

@Injectable()
export class FilmsListController extends EntityListController<Film> {
    entityList$ = this.entityState.entities$.pipe(
        map(films => films.map(f => ({ value: f.id, label: f.title })))
    )
}

Компонент списка использует этот сервис, однако его реализация будет предоставлена внешним компонентом.

@Component({
    selector: 'entity-list',
    template: `
        <mat-selection-list [multiple]="false" 
                            (selectionChange)="entityListController.select($event.options[0].value)">
            <mat-list-option *ngFor="let item of entityListController.entityList$ | async"
                             [selected]="item.value === (entityListController.selected$ | async)"
                             [value]="item.value">
                {{ item.label }}
            </mat-list-option>
        </mat-selection-list>
    `
})
export class EntityListComponent {
    constructor(public entityListController: EntityListController<any>) {}
}

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

@Component({
    selector: 'entity-page',
    template: `
        <mat-sidenav-container>
            <mat-sidenav opened mode="side">
                <entity-list></entity-list>
            </mat-sidenav>
            <ng-content></ng-content>
        </mat-sidenav-container>
    `,
})
export class EntityPageComponent {}

Использование компонента entity-page:

@Component({
    selector: 'film-page',
    template: `
        <entity-page>
            <entity-card>
                <span class="header">Film</span>
                <film-form></film-form>
            </entity-card>
        </entity-page>
    `,
    providers: [
        { provide: EntityState, useExisting: FilmsState },
        { provide: EntityListController, useClass: FilmsListController }
    ]
})
export class FilmPageComponent {}

Компонент entity-card передается через проекцию содержимого для возможности использования ContentChild.

Послесловие

Описанный подход позволил мне значительно упростить процесс проектирования и ускорить разработку без ущерба качеству и читаемости кода. Он отлично масштабируется к реальным задачам. В примерах были продемонстрированы лишь базовые техники переиспользования. Их комбинация с такими фичами как multi-провайдеры и модификаторы доступа (Optional, Self, SkipSelf, Host) позволяет гибко выделять абстракции в сложных случаях, используя меньше кода, чем обычное переиспользование компонентов.



Перейти в источник

Похожие статьи

О классах Program и Startup — инициализация ASP.NET приложения. Часть II: IWebHostBuilder и Startup / Хабр

0 Favorite [ Введение Это – продолжение статьи, первая часть которой была опубликована ранее. В той части был рассмотрен процесс инициализации, общий для любого приложения…

Инвентаризация ИТ-активов штатными средствами Windows с минимальными правами доступа

0 Favorite [ Коллеги, в предыдущей статье мы обсудили принципы эффективной работы с событиями аудита ОС Windows. Однако, для построения целостной системы управления ИБ важно…

Цифровая трансформация офисной печати от зарождения до современных технологий

0 Favorite [ СодержаниеГлава №1. Краткая история зарождения офисной печати1.1. Пионеры1.2. ЭнтузиастыГлава №2. От CapEx к MPS и далее к DaaS2.1. Капитальные расходы (CapEx)2.2. Управляемые…

Ответы