とんかつ時々あんどーなつ

〜たとえ低空でも飛行していられるように〜

ComponentStore を使った Angular アプリケーションのアーキテクチャについて考える

突然だが、NgRx の ComponentStore を使ったときのアーキテクチャを考えている。

https://ngrx.io/guide/component-store

ComponentStore 自体については以前のブログや発表などで何度か説明をしているのでどういうものかはそちらに譲ることにする。

使い捨てでないアプリケーションは個人的には保守性を下げすぎないようにレイヤードアーキテクチャに沿うことが多いのでそれが前提の話となる。 今回は ComponentStore をアプリケーション層の usecase として実装する方法を提案してみる。

レイヤードアーキテクチャ

まず、前提の共有としてレイヤードアーキテクチャについて軽く触れておく。 複雑なアプリケーションはレイヤーを分割して凝集度を高めることで保守性を上げることができる。 関心事を分離することで UI の変更でビジネスロジックが壊れてしまう、というようなことを防ぐ。 レイヤーを何層にするのかは諸説あるが、今回は 4 層で進めていく。

イメージとしては以下のような感じである。

f:id:kasaharu:20210414201934j:plain

レイヤー 説明
プレゼンテーション層 実際にユーザーに情報を表示するレイヤー
アプリケーション層 アプリケーションのユースケースを表現するレイヤー、やるべき一連の処理が並ぶ
ドメイン ビジネス的に必要なロジックが集まるレイヤー
データアクセス層 / インフラストラクチャ層 データアクセスに対する処理をするレイヤー、フロントエンドであればバックエンドの API を呼び出すことが多い

Tour of Heroes をレイヤードアーキテクチャに当てはめてみた

みんな大好き Tour of HeroesDashboard のページをレイヤードアーキテクチャに沿って作ってみる。

まず hero 情報を取得するための API 呼び出しだが、これは hero.service.ts と実装内容は変わっていない。 今回はレイヤードアーキテクチャに当てはめ、データアクセス層として data-access/gateways/hero.gateway.ts という名前にした。

dashboard.component.ts をプレゼンテーション層とし、ここが直接 hero.gateway.ts のメソッドを呼ばないようにアプリケーション層の usecase を用意することにする。 dashboard.usecase.ts とそれを呼び出す dashboard.component.ts は以下のようになる。

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Hero } from '../../../domain/hero';
import { HeroGateway } from '../../../data-access/gateways/hero.gateway';

@Injectable({
  providedIn: 'root',
})
export class DashboardUsecase {
  constructor(private readonly _heroGateway: HeroGateway) {}

  _heroes$ = new BehaviorSubject<Hero[] | null>(null);
  readonly heroes$: Observable<Hero[] | null> = this._heroes$.asObservable();

  async fetchHeroes(): Promise<void> {
    const heroes = await this._heroGateway.getHeroes().toPromise();
    this._heroes$.next(heroes);
  }
}
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { map } from 'rxjs/operators';
import { DashboardUsecase } from '../../applications/dashboard.usecase';

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent implements OnInit {
  constructor(private readonly _usecase: DashboardUsecase) {}

  heroes$ = this._usecase.heroes$.pipe(map((heroes) => (heroes === null ? null : heroes.slice(1, 5))));

  ngOnInit(): void {
    this._usecase.fetchHeroes();
  }
}

ここまででも十分責務を分割できていて問題はないと思うが、さらにアプリケーションの規模が大きくなることを想定する。 そこで API で取得してきた heroes を管理するために Store の導入をしていく。

ComponentStore の導入

ここで global store を使うか local store を使うかという議論が発生すると思う。 今回はバックエンドから取得した hero 情報を Dashboard の表示専用に保存するために使うので local store として ComponentStore を選択する。

dashboard.component.ts 用に dashboard.store.ts を作成する。

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { Observable } from 'rxjs';
import { Hero } from '../../../domain/hero';

export interface DashboardState {
  heroes: Hero[] | null;
}

@Injectable()
export class DashboardStore extends ComponentStore<DashboardState> {
  constructor() {
    super({ heroes: null });
  }

  readonly heroes$: Observable<Hero[] | null> = this.select((state) => state.heroes);
  readonly saveHeroes = this.updater((_, heroes: Hero[]) => ({ heroes }));
}

store 実装内容は公式ドキュメント以上のことをしていないので割愛する。 store を用意したので usecase は「データを取得する → 取得したデータを store に保存する」と言ったような手続き的な処理を書くように修正する。

import { Injectable } from '@angular/core';
import { HeroGateway } from '../../../infrastructures/gateways/hero.gateway';
import { DashboardStore } from './dashboard.store';

@Injectable({
  providedIn: 'root',
})
export class DashboardUsecase {
  constructor(private readonly _componentStore: DashboardStore, private readonly _heroGateway: HeroGateway) {}

  async fetchHeroes(): Promise<void> {
    const heroes = await this._heroGateway.getHeroes().toPromise();
    this._componentStore.saveHeroes(heroes);
  }
}

また component 側も heroes$ を store から取得するようにする。

@Component({
...
})
export class DashboardComponent implements OnInit {
  constructor(private readonly _componentStore: DashboardStore, private readonly _usecase: DashboardUsecase) {}

  heroes$ = this._componentStore.heroes$.pipe(map((heroes) => (heroes === null ? null : heroes.slice(1, 5))));

  ...
}

このように store を用意することで dashboard という feature でどういう状態を管理したいのかが明確になる(store の型を見ただけで管理したい state がわかる)。

しかし、ここで注意したいことがある。 ComponentStore はその特性上 ElementInjector として以下のように component の provider に設定する必要がある。 そうすることで component のインスタンスごとに store のインスタンスがシングルトンになり、component のインスタンスが destroy されたときに store のインスタンスも破棄される。

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [DashboardStore],
})
export class DashboardComponent implements OnInit {
  ...
}

すると問題が発生してしまう。 DashboardStore は usecase でも DI されているが usecase は root の ModuleInjector に登録されているため DashboardUsecase -> DashboardStore の依存の解決ができなくなる。

これの解決自体は簡単で DashboardUsecase も ElementInjector として component の provider に設定するとよい。

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [DashboardStore, DashboardUsecase],
})
export class DashboardComponent implements OnInit {
  ...
}

これで問題ないのだが ComponentStore を継承したわけではない DashboardUsecase が ElementInjector に設定されているのが少しわかりにくい気がした。 (こういうものだ、と割り切れなくもないので個人の感想である。)

よって、今回は ComponentStore を継承した usecase としてまとめることを提案をしてみる。

ComponentStore を継承した usecase class

DashboardStore を削除して DashboardStore と DashboardUsecase を混ぜたような新しい DashboardUsecase を作成する。

import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { Observable } from 'rxjs';
import { Hero } from '../../../domain/hero';
import { HeroGateway } from '../../../data-access/gateways/hero.gateway';

export interface DashboardState {
  heroes: Hero[] | null;
}

@Injectable()
export class DashboardUsecase extends ComponentStore<DashboardState> {
  constructor(private readonly _heroGateway: HeroGateway) {
    super({ heroes: null });
  }

  readonly heroes$: Observable<Hero[] | null> = this.select((state) => state.heroes);
  readonly saveHeroes = this.updater((_, heroes: Hero[]) => ({ heroes }));

  async fetchHeroes(): Promise<void> {
    const heroes = await this._heroGateway.getHeroes().toPromise();
    this.saveHeroes(heroes);
  }
}

f:id:kasaharu:20210414202122j:plain

このようにすることで以下のメリットを得られる。

  • ElementInjector に登録するのは ComponentStore を継承した class だけになる
  • 管理したい state をが明確になる(この例だと DashboardState を見れば良い)

逆にデメリットも考えられる。

  • usecase と store が一つのファイルになったことによる責務の混在
  • アプリケーション層が ComponentStore に依存

まとめ

今回は ComponentStore を継承した usecase class というものを考えてみた。

メリット・デメリットそれぞれあるので結局はアプリケーションの規模次第、ということになりそうだが「1 ページで管理する状態が少ない場合」や「1 ページでユーザーができるアクションが少ない場合」には一考の余地はあるかもしれない。

試行錯誤しているリポジトリはここ: GitHub - kasaharu/angular-architecture