はじめからはじめる @ngrx/data 第4話(たぶん完結編)

NgRx にビビってたみんなゴメン。

もう難所はだいたい抜けました。

麻酔の注射が痛いだけであとは大したことないパターンでしたね。

どうした? 手術でも受けたの?

前回の話
tercel-tech.hatenablog.com

今日はいよいよフロントエンドを構築して、これまで作ってきたすべてを繋ぐよ。

今まで地味だったからね。

結局、バックエンドをいくら作り込んでも、実際にフロントから動かせるようにならないといまいち面白くないよね。

今回はコンポーネント周りの実装がメインなんだけど、いかんせん HTML と TypeScript のしんこっちょうな部分なだけあって、書くコード量は今までで一番多いと思う。

たしかにビューまわりは SPA フレームワークの最も得意とするところだね。

今回は、削除・追加・更新処理で章を分けていて、それぞれ各章の冒頭に完成イメージの GIF アニメを載せているよ。

無味乾燥な仕様書をそのまま載せるよりビジュアル的だし、これから何をしようとしているか予め分かった方が不安にならずに済むかと思って。

はじめからそうしてくれていればよかったのに。


削除処理

まずは UI の作りやすさ的に削除処理から実装しよう。

削除だいじ。

この章の完成イメージ
Image from Gyazo

まずコンポーネントクラスに削除用のメソッドを追加するところからだ。

これがないと始まらない。

app.components.ts
※ 前回まで、ファイル名をパス付きで記載していましたが、今回はファイル名のみを記載しています。

  @Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
  })
  export class AppComponent implements OnInit {
    private heroService: EntityCollectionService<Hero>;

    heroes$: Observable<Hero[]>;

    constructor(entityServices: EntityServices) {
      this.heroService = entityServices.getEntityCollectionService<Hero>('Hero');
      this.heroes$ = this.heroService.entities$;
    }

    ngOnInit() {
      this.heroService.getAll()
        .subscribe(
          () => console.log('エンティティを取得しました'),
          err => console.error(`エンティティの取得に失敗しました: ${err}`));
    }

+   deleteHero(id: number) {
+     const hero = <Hero>{ id };
+
+     this.heroService.delete(hero)
+       .subscribe(
+         () => console.log('エンティティを削除しました'),
+         err => console.error(`エンティティの削除に失敗しました: ${err}`));
+   }
  }

たった今追加した deleteHero() メソッドに注目しよう。

主キーである id を指定して、エンティティを削除しているよ。

heroService を通して、以下の delete() メソッドを呼んでいるわけか。

METHOD MEANING HTTP METHOD WITH ENDPOINT
add(entity: T) 新しいエンティティを追加します POST /api/hero/
delete(id: any) 主キーの値でエンティティを削除します DELETE /api/hero/5
getAll() このエンティティタイプのすべてのインスタンスを取得します GET /api/heroes/
getById(id: any) 主キーでエンティティを取得します GET /api/hero/5
getWithQuery(queryParams: QueryParams | string) クエリを満たすエンティティを取得します GET /api/heroes/?name=bombasto
update(update: Update) 既存のエンティティを更新します PUT /api/hero/5

そう。

すると @ngrx/data が /api/hero/5 みたいな URL に変換され、HTTP Delete 要求が投げられる。

ここは基本的に @ngrx/data が内部的に行ってくれることなので、プログラマの実装には現れてこない。

なんとなく想像がつくけど一応訊いておこう。

const hero = <Hero>{ id }; というのは?

Hero 型の DTOid で初期化する構文だよ。

Hero クラスにはコンストラクタが無いので、通常は const hero = new Hero(); でオブジェクトを作ったあと、hero.id = id; みたいにプロパティを一つずつ代入する。

それだと、一度オブジェクトを生成してからプロパティを代入するコーディングはちょっとかずが多くてスマートじゃないね。

こんなとき、const hero = <Hero>{id: id}; みたいに書けるんだよ。

{プロパティ名: 設定したい変数} という感じ。

ふむ。

さらにこのとき、{id: id} のように、:の左右で識別子が一致している場合は、単に { id } と略記していいことになっている。

だから結局、const hero = <Hero>{ id }; になる。

本来、2 手かかる DTO の初期化を 1 手で済ませた感じだね。

続いてテンプレートを書こう。

app.components.html

  <table>
    <tr>
      <th>id</th>
      <th>name</th>
+     <th>delete</th>
    </tr>
    <tr *ngFor="let hero of heroes$ | async">
      <td>{{hero.id}}</td>
      <td>{{hero.name}}</td>
+     <td><button (click)="deleteHero(hero.id)">削除</button></td>
    </tr>
  </table>

テーブルの各行に「削除」ボタンを並べている。

消したい行のボタンを押すと、その行は消える。

ボタンをクリックしたときに、さっきの deleteHero() メソッドに id が引き渡される仕組みだね。

ちなみに上記のコードは、「*ngFor で一覧表示している明細のうち、どの行が選択されたかを特定したい場合」によく用いられるイディオムなので、覚えておくとよいと思う。

ハイ、削除の実装おしまい。

早っ。


新規登録

次にデータを新規登録するためのフォームを作るのだけど、ここからちょっと難易度が上がる。


この章の完成イメージ

おー、なんかテキストボックスやボタンが出たり消えたりしている。

いかにも動的って感じがするね。

大したことはないけど、Angular の、いわゆるフォーム開発の予備知識が要るんだ。

変なデータが登録されないように、バリデーションとか、ボタンの活性制御とか考えることも増えるね。

そういうこと。

Angular のフォームの実装方式は、テンプレート駆動フォームリアクティブフォームに大別できる。

なにがどうちがうの?

まぁ一長一短あって、後者の方が細かい制御ができるけど、簡単なのは前者の方。

開発の途中で他方の方式に変更することは難しいので、最初によく考えて決めておく必要がある。

ここはあまり話題の本質じゃない部分だから簡単な方がいいよ。

そうだね、テンプレート駆動フォームでいこう。

まずは FormsModule を使えるようにするよ。

app.module.ts

+ import { FormsModule } from '@angular/forms';

  @NgModule({
    declarations: [
      AppComponent
    ],
    imports: [
      BrowserModule,
      AppRoutingModule,
+     FormsModule,
      HttpClientModule,
      InMemoryWebApiModule.forRoot(MockWebApi),
      EffectsModule.forRoot([AppEffects]),
      StoreModule.forRoot(reducers, {
        metaReducers,
        runtimeChecks: {
          strictStateImmutability: true,
          strictActionImmutability: true
        }
      }),
      StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production }),
      EntityDataModule.forRoot(entityConfig)
    ],
    providers: [],
    bootstrap: [AppComponent]
  })
  export class AppModule { }

もう一度言うけど、ここから一気にコードの記述量が増える。

でも大事なのは this.heroService.add(hero) のところだよ。 忘れないでね。

METHOD MEANING HTTP METHOD WITH ENDPOINT
add(entity: T) 新しいエンティティを追加します POST /api/hero/
delete(id: any) 主キーの値でエンティティを削除します DELETE /api/hero/5
getAll() このエンティティタイプのすべてのインスタンスを取得します GET /api/heroes/
getById(id: any) 主キーでエンティティを取得します GET /api/hero/5
getWithQuery(queryParams: QueryParams | string) クエリを満たすエンティティを取得します GET /api/heroes/?name=bombasto
update(update: Update) 既存のエンティティを更新します PUT /api/hero/5

それ以外はおどしかな?

そういうわけではないけど、どうしても画面周りは装飾やら演出やらでコードが冗長になるから本質を見失いがちなんだ。

たとえば同じデータが何度も登録されないよう、入力欄をクリアしたりとかね。

app.components.ts

  @Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
  })
  export class AppComponent implements OnInit {
    private heroService: EntityCollectionService<Hero>;
+   loading$: Observable<boolean>;
    heroes$: Observable<Hero[]>;

+   isEditing = false;
+   hero: Hero;

    constructor(entityServices: EntityServices) {
      this.heroService = entityServices.getEntityCollectionService<Hero>('Hero');
+     this.loading$ = this.heroService.loading$;
      this.heroes$ = this.heroService.entities$;
    }

    ngOnInit() {
      this.heroService.getAll()
        .subscribe(
          () => console.log('エンティティを取得しました'),
          err => console.error(`エンティティの取得に失敗しました: ${err}`));
    }

    deleteHero(id: number) {
      const hero = <Hero>{ id };

      this.heroService.delete(hero)
        .subscribe(
          () => console.log('エンティティを削除しました'),
          err => console.error(`エンティティの削除に失敗しました: ${err}`));
    }

+   beginEdit(id?: number, name?: string) {
+     this.isEditing = true;
+     this.hero = <Hero>{ id, name };
+   }

+   endEdit() {
+     this.isEditing = false;
+     this.hero = <Hero>{}
+   }

+   addHero(hero: Hero) {
+     this.heroService.add(hero)
+       .subscribe(
+         () => console.log('エンティティを追加しました'),
+         err => console.error(`エンティティの追加に失敗しました: ${err}`),
+         () => this.endEdit());
+   }
  }

addHero() は、テンプレート(HTML 側)から、Hero オブジェクトを受け取って、それを heroService 経由でエンティティに追加している。

このとき、id には何も設定されていない。

サーバ側の API で主キーを採番して追加することを期待しているからだ。

クライアント側で予め主キーを設定して add() してもいいの?

いいけど、いち制約を守らないといけないよ。

たとえば何百台の端末がおのおの適当な乱数を主キーに設定したら、まず衝突する。

事故を避けるためにも、UUID のように分散環境で安全に使えるものを選ぶのが吉だね。


loading$ とか isEditing とかフラグっぽいものが出てきたね。

非同期処理中(たとえばデータの書き込み中)に、ボタンを連打されると二重登録されて困るからね。

予期しないタイミングでユーザに触られちゃうのを防ぐために、*ngIf[disabled] を使ってコンポーネントにバインドしておくんだ。

app.components.html

+ <ng-container *ngIf="isEditing; else newButton">
+   <form #myForm="ngForm">
+     <input [(ngModel)]="hero.name" name="heroName" required="heroName" />
+     <button (click)="addHero(hero)"
+             [disabled]="myForm.invalid || (loading$ | async)">追加</button>
+     <button (click)="endEdit()"
+             [disabled]="loading$ | async">キャンセル</button>
+   </form>
+ </ng-container>
+ <ng-template #newButton>
+   <button (click)="beginEdit()">新規追加</button>
+ </ng-template>

  <table>
    <tr>
      <th>id</th>
      <th>name</th>
      <th>delete</th>
    </tr>
    <tr *ngFor="let hero of heroes$ | async">
      <td>{{hero.id}}</td>
      <td>{{hero.name}}</td>
-     <td><button (click)="deleteHero(hero.id)">削除</button></td>
+     <td>
+       <button [disabled]="isEditing"
+               (click)="deleteHero(hero.id)">削除</button>
+     </td>
    </tr>
  </table>

そういえば、heroes$loading$ の後ろの $ はなにもの?

これはね、Observable<T> 型のプロパティであることを示すシルシみたいなものだよ。

そのままではバインドできないので、async パイプをセットで使う必要がある。


更新

最後に、既存データを更新する処理を追加しよう。

今までやってきたことの合わせ技みたいなものなので、決して難しくはない。

この章の完成イメージ
Image from Gyazo

app.components.ts

  @Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
  })
  export class AppComponent implements OnInit {
    private heroService: EntityCollectionService<Hero>;
    loading$: Observable<boolean>;
    heroes$: Observable<Hero[]>;

    isEditing = false;
    hero: Hero;

    /* 略 */

    endEdit() {
      this.isEditing = false;
      this.hero = <Hero>{}
    }

    addHero(hero: Hero) {
      this.heroService.add(hero)
        .subscribe(
          () => console.log('エンティティを追加しました'),
          err => console.error(`エンティティの追加に失敗しました: ${err}`),
          () => this.endEdit());
    }

+   updateHero(hero: Hero) {
+     this.heroService.update(hero)
+       .subscribe(
+         () => console.log('エンティティを更新しました'),
+         err => console.error(`エンティティの更新に失敗しました: ${err}`),
+         () => this.endEdit());
+   }
  }

あー…… なるほど、わかってきたぞ。

更新対象のエンティティオブジェクトをひきすうに渡して this.heroService.update(hero) を叩いているだけだね。

METHOD MEANING HTTP METHOD WITH ENDPOINT
add(entity: T) 新しいエンティティを追加します POST /api/hero/
delete(id: any) 主キーの値でエンティティを削除します DELETE /api/hero/5
getAll() このエンティティタイプのすべてのインスタンスを取得します GET /api/heroes/
getById(id: any) 主キーでエンティティを取得します GET /api/hero/5
getWithQuery(queryParams: QueryParams | string) クエリを満たすエンティティを取得します GET /api/heroes/?name=bombasto
update(update: Update) 既存のエンティティを更新します PUT /api/hero/5

選択中のデータを引き継いだり、更新を確定させるまでは他のレコードを編集させないようにしたりといった制御の方がめんどくさい。

app.components.html

  <ng-container *ngIf="isEditing; else newButton">
    <form #myForm="ngForm">
      <input [(ngModel)]="hero.name" name="heroName" required="heroName" />
+     <ng-container *ngIf="hero.id; else add">
+       <button (click)="updateHero(hero)"
+               [disabled]="myForm.invalid || myForm.pristine || (loading$ | async)">
+         更新
+       </button>
+     </ng-container>
+     <ng-template #add>
        <button (click)="addHero(hero)" 
                [disabled]="myForm.invalid || (loading$ | async)">追加</button>
+     </ng-template>
      <button (click)="endEdit()" [disabled]="loading$ | async">キャンセル</button>
    </form>
  </ng-container>
  <ng-template #newButton>
    <button (click)="beginEdit()">新規追加</button>
  </ng-template>

  <table>
    <tr>
      <th>id</th>
      <th>name</th>
      <th>delete</th>
+     <th>update</th>
    </tr>
    <tr *ngFor="let hero of heroes$ | async">
      <td>{{hero.id}}</td>
      <td>{{hero.name}}</td>
      <td>
        <button [disabled]="isEditing"
                (click)="deleteHero(hero.id)">削除</button>
      </td>
+     <td>
+       <button [disabled]="isEditing"
+               (click)="beginEdit(hero.id, hero.name)">更新</button>
+     </td>
    </tr>
  </table>

更新用の <button>[disabled] には、 myForm.pristine がバインドされているね。

これは、フォームが未編集の状態ではボタンを押せないようにするためだよ。

フォームが空欄の場合、フォームが未編集の場合、そしてデータの読み書き中の場合は、ボタンを押せなくしている。

もはや @ngrx/data よりもフォーム構築の方が難易度高かったね。

これで更新処理の実装もおしまい。

すごい。 一気に出来上がったね。


まとめ

そんなわけで、CRUD アプリを無理やり完成させました。

かれこれ1週間半くらいこの話を続けてきましたが、@ngrx/data ネタはいったんここで一旦おしまいです。

えー。

まだ、親子関係のある複数エンティティを同時更新したり、Web API の要件に合わせて URL をカスタマイズしたりといった応用技が残っているじゃん。

もともと、この連載のゴールは @ngrx/data でお手軽に CRUD を体験するところまでだったんだ。

ここまでくれば、あとは独力でドキュメントを読んで先に進めるとボクは思ってる。 それに──

それに……?

それに、たったこれだけを説明するだけでも、Angular In Memory Web API、Jasmine と Karma を使ったテスト、そしてフォーム開発など、わりと話すべきことが多かったので、ひとやすみしないと消化不良を起こすと思う。

たしかに。

ソースも毎回ちょっとずつ修正だから、これ以上長くなると写経する人が完走できなくなっちゃうかもね。

で、@ngrx/data はどうだった?

思った以上に簡単だった。 Angular 側から見ると、配列操作を行う感覚に近い。

いきなり売り物に組み込む前に、自分で簡単なアプリに組み込んで、手に馴染んだら本格的に使ってみたいと思った。

まぁまた気が向いたら、いつか発展編もやろうか。

そうだね。

ではまた。

ばいばい。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.