💪

Angular歴3年半の私が経験した失敗とその対応について

17 min read

これは Angular Advent Calendar 2021 の22日目の記事です。
昨日は@FumioYoshidaさんの「Nullish Coalescing」でした。

3年半の間、仕事でAngularアプリの立ち上げ&設計を2回、Angularアプリへのリニューアルを2回、Angular libraryを使ってのUI Component Libraryの作成と運用と、なかなか濃いAngular経験を積んできました。
そこで、この3年半のAngular経験で失敗したことと、その対応をできるだけ多く書こうと思います。

前提

  • ComponentやModalが数十個の大規模なAngularアプリの事例を元にしてます
  • BackendとのInterfaceにREST APIを採用していました
  • 状態管理に「Akita」を採用していました
  • Angularアプリ特有ではない話も入っています

失敗と対応方法

エラーハンドリングしきれてない

対応策: Intercetorと独自ErrorクラスをつかってErrorHandlerで一括処理。独自処理は都度try-catchで対処できるように

エラーハンドリングの設計きちんと決まっておらず、場当たり的に処理してしまっていて、例外時の体験が統一できてないという問題がありました。そこで以下の指針のもと、エラーハンドリングの設計をし直しました

  • 実装中エラーハンドリングを気にしなくても、例外時に最低限のUXを提供できるようにする
  • ↑の仕組みで最低限の部分を担保しつつ、例外時に提供するUXをこだわりたいときにこだわれるようにする

これを実現するために以下をおこないました。

  1. 独自のErrorクラスの定義
  2. InterceptorでのErrorクラスの変換
  3. Angularで用意されているErrorHandlerの実装

独自のErrorクラスの定義

JSのErrorクラスをextendしたErrorクラスのことで、以下を目的にしてます

  • 他のErrorクラスとの区別
    • 例外をthrowするときは自分で定義したErrorクラスを使うことによってErrorHandlerでのハンドリングをかんたんにできる。例えば Error のときは 想定外の問題が発生しました とクライアントに表示してリロードを促しつつ、Sentryにスタックトレースを送信する。独自に定義した DevelopmentError であれば、localやDev環境でのみクライアントに表示して、Production環境では表示しないなど。
    • 通信エラーを独自定義した RequestError にすることでAPI Clientが変更になってもエラーハンドリングに問題がないように
  • Errorに持たせる情報の追加
    • 通信エラーであれば、レスポンスコード、レスポンスボディなど、様々な情報をErrorクラスに持たせておくて、エラーハンドリング時にその情報を使用して、ざまざまなことを表現することができる
class RequestError extends Error {
  constructor(public request: HttpRequest, public response: HttpResponse) {
    super(response.body)
  }

  get statusCode() {
    return this.response.statusCode
  }
}

InterceptorでのErrorクラスの変換

Angularでは HttpClientAPI の間に Interceptor を使って処理を挟むことができます。
(Interceptorの説明はlacolacoさんのAfter Tutorialに詳しく書いてあります。)

このInterceptorを使って、HttpClientで発生した通信エラーを独自定義した RequestError に詰め直すということをしました。
こうすることによって、後述するErrorHandlerでも、APIコールの呼び出し時でも、ケアするのは RequestError で良くなり、AngularのHttpClientが出す例外クラスの仕様が変更になっても、RequestErrorもしくはInterceptorの実装を修正するだけで済むようになります。

Angularで用意されているErrorHandlerの実装

AngularにはErrorHandlerというInterfaceが用意されており、このInterfaceを実装することでAngularアプリ内で発生してすべての例外をハンドリングできるようになります。

// my-error-handler.ts
@Injectable({ providedIn: 'root' })
export class MyErrorHandler implements ErrorHandler {
  handleError(e: any) {
    if (e instanceof RequestError) {
      // エラー用のトーストを表示
      this.showErrorToast(e.message);
    } else if (e instanceof DevelopmentError) {
      // 開発中のみ発生するエラーのためなにもしない
    } else {
      this.showErrorToast('想定外のエラーが発生しました。');
      Sentry.captureException(e);
    }
  }
}

// xxx.module.ts
@NgModule({
  providers: [{provide: ErrorHandler, useClass: MyErrorHandler}]
})
class MyModule {}

ChangeDetectionStrategy.OnPushつけたら動かなくなる

対応策: ChangeDetectionStrategy.OnPushをデフォルトに

アプリが大きくなってくるとパフォーマンス・チューニングが必要になることが往々にしてあると思います。
パフォーマンスの劣化は様々な要因があるため、細かいことはその時がきたら対応するで良いとは思うんですが、Componentの changeDetectionChangeDetectionStrategy.OnPush をデフォルトで指定しておくことをおすすめします。(階層のTopのコンポーネント以外は。Presentational Componentは特に)
一行だけの追加なので、可読性にそれほど影響しませんし、ほとんどのケースでは ChangeDetectionStrategy.OnPush でも特に問題なくCopmponentを実装することができます。

想定通りに動作しなくなる危険性があるため、アプリが育ってからの ChangeDetectionStrategy.OnPush への変更は簡単にはできません。(十分な自動テストがあれば話は別ですが)

ng generate component で作成されるComponentにデフォルトでChangeDetectionStrategy.OnPushが付与されるオプションがあるので、これを有効にしておくとよいでしょう。

// angular.json
"projects": {
  "[your-project-name]": {
    "projectType": "application",
    "schematics": {
      "@schematics/angular:component": {
        "style": "scss",
        "changeDetection": "OnPush" // <-- 追記
      },

componentsディレクトリが汚い

対応策: 種類ではなく、関連するComponentファイルは近くのディレクトリにまとめる

Container/Presentatinal構成で開発していたので当初copmponents以下のディレクトリ構成はこのような形にしてました

- containers
  - users-container.component
  - groups-container.component
  - messages-container.component
  - etc...
- presentationals
  - user-list.component
  - user.component
  - group.component
  - message.component
  - etc...

一見、そんな不思議ではないと思うんですが、アプリケーションが大きくなってくるとこんな問題が発生します

  • ディレクトリをまたいで作業することが多くなる(containerとpresentationalのディレクトリをいったりきたりする)
  • presentatinalがどこの画面で使われているものなのかコードをちゃんとみないとわからない(専用なのか共有されてるのか?)
  • ディレクトリツリーを一切みないで作業するようになり、ディレクトリの中身に無頓着になる(エディタのコードジャンプのみで作業。悪循環)

例のように、Componentが数個ならそんなに気にならないのですが、数十個ともなってくるとディレクトリの構成の汚さはコーディングやデバッグ(logでパス読んだりするときとか)が地味にしんどいです。慣れている箇所であればエディタのファイル名検索やコードジャンプを駆使してスムーズにコーディングは可能ですが、あまり見てないところはディレクトリがきれいに切られている方が理解の助けになります。

この問題を解決するために、このような形にしました。

- pages: 画面ごとにディレクトリを切る。その配下に親となるpageComponentと画面を構成するComponent郡を配置
 - users-page
  - users-page.component
  - users-list.component
  - user.component
 - groups-page
  - etc...
- shared: 画面をまたがって共用するコンポーネントはここに置く
  - button.component
  - input.component
  - etc...

これにより以下のような恩恵を得られました

  • 例えばユーザ一覧画面であれば pages/users-page のディレクトリ内で完結できる
  • shared配下のComponentは複数の箇所から使われてるから慎重に改修が必要だとか、pages/users-page/xxx.component はユーザ一覧画面のコンポーネントだなと、ディレクトリ構成だけで類推が可能に

今回紹介した例では細かいところは省いて紹介しているので、実際はもうちょっと整理して配置はしてます。(shared/userとかmodelのディレクトリをつくってmodel依存の汎用コンポーネント郡をいれたり。sharedの中は最初はフラットにおいて、必要に応じてあとからサブディレクトリを作成していく形もありだと思います。汎用的なUIパーツ(ボタンとか)を除いたら共有したいComponentって実はそこまで多くなかったりします)
雰囲気だけでも理解してもらえたら幸いです。

Modelをクラスで表現する必要がなかった

対応策: 値はObjectに、必要あれば関数を定義

Angularでは殆どの実装がClassで表現されるため、ModalもついClassで表現してしまっていたのですが、その必要があまりなかったという話です。むしろ以下のようなデメリットのほうが大きかったです。

  • ClassよりObjectのほうが色んな面で扱いが楽
  • 値とふるまいが両方定義されることによって、Modelが肥大化しやすい
  • 状態管理で使用したAkitaと相性が悪く、思わぬバグを引き起こす

ClassよりObjectのほうが色んな面で扱いが楽

// Field定義とそのSetと2つ書かないといけない面倒くささ
class UserModel {
  readonly id string;
  readonly name string;
  readonly email string;

  constructor(args: UserArgs) {
    this.id = args.id
    this.name = args.name
    this.email = args.email

    // 以下で楽にできるが、型を無視してsetしてしまうので不安
    // Object.assign(this, args)
  }
}

// type(or interface)だとこれだけ
type User = {
  id string;
  name string;
  email string;
}
const createUser = (args: UserArgs): User => ({...args})

// 分割代入も安心してつかえる。classでも使用できるが、型が変わってしまう。この挙動がたまにめんどうなことも
const uClass = new UserModel({ id: '1', name: '名前', email: 'example@example.com' })
const uClass2 = { ...uClass } // uClass2はUserModal型ではない

const u: User = { id: '1', name: '名前', email: 'example@example.com' }
const u2 = { ...u } // u2もUser型

値とふるまいが両方定義されることによって、ModelClassが肥大化しやすい

気をつけていてもすぐ近くに値があるからか、その値を使った関数をたくさん定義してしまう傾向がありそうです。
このことはObjectで定義したからといって解消されるわけではありませんが、比較的マシになる感覚です。
Classにせよ、Objectにせよ、そのモデルが持つべき値なのか?振る舞いなのか?は常に考えて実装する必要はありそうです。

状態管理で使用したAkitaと相性が悪く、思わぬバグを引き起こす

Akitaでは一応Class Supportをしてはいるのですが、制約があったり、想定外の値が生成されたりと、ハマることが多く、おとなしく値だけのObjectで定義しておいたほうが安心してAkitaを使える印象です。

いろいろデメリットを書きましたが、クラスで書くことで以下のようなメリットもあるので、何を重要視するかを見極めて選択するのがよいとは思ってます

  • Modelの生成方法を強制できる
    • Objectだとcreate用の関数呼ばないで生成することもできるので強制できないが、Classの場合constructorを必ず経由できるので、必要な変換処理や異常値の除去などを確実に挟むことができる
  • Modelがもつ振る舞いをエディタの機能でかんたんに知ることができる
    • modelを扱うときにエディタの補完機能でどんな振る舞いを持つかかんたんに知れる

様々な種類のQueryをDI&データの加工でPageComponentが肥大化

対応策: PageComponent専用のQueryServiceを作成

Angularではデータの取得のためにDIされたService経由でAPIコール&データの取得を行うことは一般的だと思います。
ですが、アプリケーションが大きいと1画面で読み込むデータの種類が多く、リソース単位のServiceでは数多くのServiceをDIすることになってComponentの見通しが悪くなることがあります。(下記の例ではReadのためのServiceをQueryと表現してます)

@Component({
  selector: 'app-some-page',
  template: `some template`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SomePageComponent implements OnInit {
  // DIしたServiceを購読するために様々なストリームを生成する
  // ただ購読するだけじゃなく、加工や条件が加わるとさらにComponentが煩雑になっていく
  loading$ = this.userQuery.selectLoading();
  users$ = this.userQuery.selectAll();
  adminUsers$ = this.userQuery.select().pipe(filter((u) => u.isAdmmin));
  some$ = combineQueries(
    this.groupQuery.selectAll(),
    this.someQuery.selectAll()
  ).pipe(filter(([groups, some]) => somefilter));

  constructor(
    private userQuery: UserQuery,
    private groupQuery: GroupQuery,
    private someQuery: SomeQuery
  ) {}
}

この問題をQueryのFacadeを作成し、Read処理をまとめることで解決しました。
また、ComponentにSingle State Stream Patternを採用することで、さらに見通しをスッキリすることに成功しました。
下記は雑な例ですがこのような形です。

export interface SomePageQueryState {
  users: User[];
  loading: boolean;
  adminUsers: User[];
  some?: any;
}

@Injectable()
export class SomePageQuery {
  state$ = new BehaviorSubject<SomePageQueryState>({
    users: [],
    loading: true,
    adminUsers: [],
  });

  constructor(
    userQuery: UserQuery,
    groupQuery: GroupQuery,
    someQuery: SomeQuery
  ) {
    // 条件や読み込むリソースの種類が増えるとこのストリームに値を流すタイミングや順番のチューニングが必要になるというデメリットもある。ストリームのデバックはやや面倒なのでここでハマることも、、
    // また、ページネーションや無限スクロールなど、画面の状態に応じて追加分を取得するなどの場合、少し工夫が必要
    combineQueries(
      userQuery.selectLoading(),
      userQuery.selectAll(),
      groupQuery.selectAll,
      someQuery.selectAll
    ).subscribe(([loading, users, groups, some]) => {
      this.state$.next({loading, users, adminUsers: users.filter(u => u.isAdmin), some})
    });
  }
}

interface State extends SomePageQueryState {
}

@Component({
  selector: 'app-some-page',
  template: `<ng-container *ngIf="state$ | async as state">some templates<ng-container>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers:[SomePageQuery]
})
export class SomePageComponent implements OnInit {
  state$ = new BehaviorSubject<State>(this.query.state$.getValue())

  constructor(private query: SomePageQuery) {
    this.query.state$.asObservable().subscribe(state => this.state$.next(state))
  }
}

Container/Presentationalを守るだけのためのOutputのバケツリレー

対応策: PresentationalCompomentでDIされたServiceを通じてアクションを実行できるように(APIコールなど)

Container/Presentainalの構成において、APIコールはContainerComponentで行うのが一般的だと思います。
ルールとしてはシンプルですし、ロジックをContainerに寄せることもできるのでメリットも多いです。
ですが、アプリケーションが大きくなり、Componentの依存関係が深くなっていくると、Presentational → Containerにイベントを伝播させるためのOutputの定義がめんどくさくなります。また親コンポーネントの都合で子コンポーネントがOutputを定義しないといけないことに違和感もでてきます。

@Component({
  selector: 'app-users-container',
  template: `<app-a (clickButton)="log($event)"></app-a>`,
})
export class UsersContainerComponent {
  log(e: MouseEvent) {
    console.log(e);
  }
}

@Component({
  selector: 'app-a',
  template: `<app-b (clickButton)="clickButton.emit($event)"></app-b>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AComponent {
  @Output() clickButton = new EventEmitter<MouseEvent>();
}


@Component({
  selector: 'app-b',
  template: `<button type="button" (click)="clickButton.emit($event)">
    Button!
  </button>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BComponent {
  @Output() clickButton = new EventEmitter<MouseEvent>();
}

専用のServiceクラスをDIし、アクション時に直接呼ぶことで無駄にOutputを定義することなく改良したのがこちらの例です。

@Injectable()
export class ClickButtonService {
  doSomething(e: MouseEvent) {
    console.log(e);
  }
}

@Component({
  selector: 'app-users-container',
  template: `<app-a></app-a>`,
  providers: [ClickButtonService],
})
export class UsersContainerComponent {
}

@Component({
  selector: 'app-a',
  template: `<app-b></app-b>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AComponent {
}

@Component({
  selector: 'app-b',
  template: `<button type="button" (click)="service.doSomething($event)">
    Button!
  </button>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BComponent {
  constructor(public service: ClickButtonService) {
  }
  @Output() clickButton = new EventEmitter<MouseEvent>();
}

Serviceに処理を書いているので、Componentも汚れません。
また、InjectするServiceを変更することも可能なので、子コンポーネントの使用場所によって処理を切り替えることもできますし、テストも問題なく書くことができます。
欠点を上げるとすると、処理が行われたことを親コンポーネントが知れないということと、DIのルールがないとServiceとコンポーネントの関係が煩雑になる可能性があるということです。
前者に関しては、状態管理のしくみをつかってうまく親コンポーネントに結果をsubscribeすることで回避しました。
後者に関しては、次の項目が回答になっていると思います。

ある画面で発生するアクションがわからない

対応策: One Action,One UseCaseにして、PageComponentと同じディレクトリに配置

アプリケーションが大きくなってくると、ある画面で発生しるうるアクションとその挙動の詳細が把握できなくなるという問題が発生しました。
原因として、汎用的なServiceをComponent側で組み合わせて使用することによって、処理の全体像が見えづらくなっていたのと同時に、ContainerComponentで多数の処理をもつService複数をDIしていることによって、この画面で行われる処理はなんなのかが見えづらくなっていたというのがあります。(APIコールはServiceに書かれてて、その前処理後処理はComponentに書かれてる、create, update, deleteの機能を持つServiceがDIされているが、Componentで実際使用されているのはcreateのみなど)

これを解決するために以下の方法を取りました

  • Componentからアクションで発生するする処理を1つの関数にまとめ、これをUsecaseと呼ぶ
  • Usacaseは1つのクラスで表現し、必要なServiceをDIできる
  • Usecaseは共有を禁止にし、ContainerComponentと同じところに配置する
  • UsecaseはContainerComponentでDIされ、配下の子コンポーネントが使用できる
// ディレクトリ構成

- page
  - users-page
    - user-page.component
    - some.component
    - usecases // ユーザ一覧画面で発生するアクションがディレクトリ構成から分るようになる
      - user-create.usecase.ts
      - user-update.usecase.ts
      - user-delete.usedcse.ts
@Injectable()
export class UserCreateUsecase {
  constructor(
    private service: UserService,
    private toastService: ToastService,
    private router: Router
  ) {}

  // 一つのユースケースで行われる処理がわかりやすくなる
  // また、1ケースごとクラスにすることで必要最小限のDIができ、テストも書きやすくなる
  async exec(newUser: User) {
    await this.service.add(newUser);

    this.router.navigateByUrl('/users');
    this.toastService.success('ユーザ一を作成しました。');
  }
}

@Component({
  selector: 'app-users-page',
  template: `<app-some></app-some>`,
  // 1つの画面で発生するユースケースがわかりやすくなる
  providers: [UserCreateUsecase, UserUpdateUsecase, UserDeleteUsecase],
})
export class UsersPageComponent {}

@Component({
  selector: 'app-some',
  template: `<button (click)="usecase.exec()">create!</app-some>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SomeComponent {
  // 親コンポーネントでprovideされてるので子コンポーネントはどこからでもユースケースが実行できる
  constructor(private usecase: UserCreateUsecase)
}

おなじusecaseを共有したいことがあるのですが、その場合はusecaseは別々に定義して、中身の処理をService化して共有するのがよいと思ってます。

ReactiveFormのControl名をtypoしてバグらせた

対策: ngneat/reactive-formsを導入して、ReactiveFormに型を付けられるようにする

ngneat/reactive-forms を導入すれば、使い勝手そのままに型付きのReactiveFormを使うことができます。
もしかしたら、Angularでも対応してくるかもしれませんが、その場合でもimportを先を切り替えるだけで済みそうなので、使って損はなさそうです。
専用のLintもあるので、間違えて本家のReactiveFormを使って実装してしまうなんてことも防げます。

気づいたらよくわからないコードになってた

対策: ユニットテスト書きましょう

初期フェーズだと、仕様が変わることが多いので、テストコードのコストが高そうに思えたのですが、これが大きな間違いだったという話です。
コードベースが落ち着いてからユニットテストを拡充していこうとおもってても、コードがおちつくことなんて永遠にこないし、もし落ち着いたとしてもそのときにはユニットテストがかけるコードにではなかったりします。

Componentのテストはちゃんとやるとめんどうなので、他の方法(画像レグレッションテストなど)で担保するのがよいですが、ロジックが書かれるModelやServiceのテストは最初から書くことをおすすめします。テストを書くことで、常にテストを書きやすいコードを書くことを心がけられます。

API仕様がいつのまにか変更になる

対応策: OpenAPIでAPI仕様をドキュメント化&コードを生成

Backendチームとのコミュニケーションの齟齬があって、想定と異なるレスポンスが返ってきてバグってしまうという問題が度々発生しました。

それをできるだけ防止するために、決めたAPI仕様からFrontendとBackendのコードを自動生成するという方法をとるべく、OpenAPIドキュメントからの自動生成を採用しました。(OpenAPITools/openapi-generatorなどを使用する)
確定した仕様がドキュメント化されているので、ドキュメントをベースにして話を進められますし、APIをコールしなくてもどのようなリクエストが必要でどのようなレスポンスが返ってくるかを知ることができるのはとても便利でした。コードがドキュメントを元に自動生成されることは仕様に沿ったコードに必ずなるという安心感もありました。

すべてのAPI仕様をOpenAPIにするのはできないのですが、Backendチームとコミュニケーションが取る必要がなくなるというわけではないのですが、意図しない仕様変更はなくすことができました。

注意点として 生成されたコードは直接参照しない ようにしておくことをおすすめします。
理由としては以下の2点です。

  • 生成されたコードがすべてのケースにおいて完璧なコードというわけではないので、それを補正する層を用意しておく
  • generatorの仕様変更に対応できるようにしておく

最後に

実は、転職をきっかけにAngularを触る機会がなくなりそうなので、Angularのことを書くのはこれで最後になるかもしれません。。
個人的にはアプリケーションを作ることに専念できるのですごく気に入ってるフレームワークなので、なにかきっかけがあればまたAngularのお仕事したいなと思ってます。

明日は@lacolacoの記事です!どうぞお楽しみに!

Discussion

ログインするとコメントできます