はじめからはじめる @ngrx/data 第1話(プロジェクト作成編)

@ngrx/data は、アプリケーションの CRUDクラッド 処理を強力にアシストしてくれる魔法のようなライブラリです。

2019年に公式リリースされたばかりですが、やはり後発だけあって非常に使いやすいので、ぜひこの場でご紹介したいな、と思った次第です。


Angular アプリの CRUD 処理は、@ngrx/data で決まり!

今回のテーマは @ngrx/data だよ。

かなりのながちょうになるので、いくつかに分けて話そうと思う。

あれ??

先日、NgRx は難しいって話してなかったけ?

したよ。

NgRx シリーズには、もともと Redux をベースにした @ngrx/store, @ngrx/effects などがあって、これらは確かに使いこなすのが大変なんだ。

でも、@ngrx/data だけは毛色が違う


@ngrx/data を使用すると、非常に少ないコードで、NgRx をまったく知らなくても、大規模なエンティティモデルを迅速に開発できます。

【原文】
With NgRx Data you can develop large entity models quickly with very little code and without knowing much NgRx at all.

【出典】


ちなみに、NgRx にありがちな リアルタイム Web とか、プッシュ通知とか、状態管理とか、そういうおしゃれな話は一切出てきません。

業務アプリケーションにおいてとても普遍的な CRUD 処理をラクに実装するためのアプローチとして、@ngrx/data を使ってみませんか、という趣旨です。

うーん、CRUD は分かるけど、NgRx が唐突すぎてさっそく話についていけないのだが?

言い換えよう。 この際、NgRx の知識は要らないんだ。

すごく便利なライブラリがあるからステップバイステップで試していこう!

前提知識が要らないってこと?

うん。

面倒なところはほとんど全てライブラリが内部的に処理してくれる。 オートマ車みたいだ。

面倒なところ ── ?

そう。

そもそも CRUD の実装って意外と面倒なんだよ。 単に SQL を発行してレコードを更新すればよい、という単純な話ではない。

だから適切なライブラリがあるならば、その力を借りた方がいいんだ。


補足: CRUD 処理とトランザクション制御について

通常、関係データベースから取得したデータ群は、アプリケーションの変数に一時的にキャッシュされます。

このとき、データベースとキャッシュの整合性をきちんとたもつことがポイントになってきます。

たとえばデータベースへの書き込みに失敗したときは、アプリケーションでキャッシュしている変数の値もロールバックしなくてはなりません。

さもないと、データベースとキャッシュに差異が出てしまい、思わぬバグの原因になりかねないからです。

これは、トランザクションが複雑になればなるほど、難しい課題として開発者を苦しめます。

例えば、同じレコードを複数のユーザがほぼ同時に更新しようとしてコミットが競合した場合を考えてみよう。

アプリ側のキャッシュに更新できなかったデータが残り続けたらおかしくなっちゃうでしょ?

なるほど。

@ngrx/data がやってくれること

実習に入る前に、ちょっと @ngrx/data について基本的なところを押さえておこう。

@ngrx/data がやってくれることは、下図の 1〜9 ほぼ全てだよ。

f:id:tercel_s:20200211200738p:plain
@ngrx/data Architecture overview

  1. The view/component calls EntityCollectionService.getAll(), which dispatches the hero's QUERY_ALL EntityAction to the store.
  2. NgRx kicks into gear ...
    1. The NgRx Data EntityReducer reads the action's entityName property (Hero in this example) and forwards the action and existing entity collection state to the EntityCollectionReducer for heroes.
    2. The collection reducer picks a switch-case based on the action's entityOp (operation) property. That case processes the action and collection state into a new (updated) hero collection.
    3. The store updates the entity cache in the state tree with that updated collection.
    4. NgRx observable selectors detect and report the changes (if any) to subscribers in the view.
  3. The original EntityAction then goes to the EntityEffects.
  4. The effect selects an EntityDataService for that entity type. The data service sends an HTTP request to the server.
  5. The effect turns the HTTP response into a new success action with heroes (or an error action if the request failed).
  6. NgRx effects Dispatches that action to the store, which reiterates step #2 to update the collection with heroes and refresh the view.

【出典】

英語……。

あとでちゃんと具体的に話すけど、View(コンポーネント)から、CRUD それぞれに対応するメソッドを呼ぶと、自動で HTTP の Web API を叩いたり、その結果に応じて内部のキャッシュを更新したりしてくれる。

NgRx 特有の Action だとか Reducer だとか Selector だとかの存在をプログラマが意識することは、ほとんど無い。

すごい大仕掛けだね。

基本的に、HTTP 通信(Web API の実行)は非同期処理なので、そこをハンドリングするための Effects がアーキテクチャ上必須なんだ。


@ngrx/data がやってくれないこと

ここまで至れり尽くせりで、プログラマのやる事が無くなるんじゃない?

負担は減るけどゼロにはならないね。

当然ながら、エンティティのデータ定義は個別にコーディングしなきゃいけないし。

まぁ、そりゃそうか。

あと、@ngrx/data がやってくれるのは HTTP を発行して Web API を叩くところまでだよ。

呼び出される側、つまりデータベースそのものにアクセスするサーバサイド API は別途実装しなきゃいけない

そこは Angular の世界の外側だしね。

仕方ないね。

この連載の目標は、@ngrx/data を使えるようになること

ステップバイステップのチュートリアルみたいな形を目指しています。

このチュートリアルを終える頃には、とりあえず @ngrx/data が何なのか、雰囲気を掴めると思います。

ふむふむ。

今回の環境

今回は Angular v9, @ngrx v8.6 で検証しました。

今回の環境
今回の環境

共通手順:プロジェクトの作成と初期設定

まずは、すべての出発点となる環境設定手順を以下に示すよ。

@ngrx/data を使う場合、プロジェクトごとにほぼ毎回必要な手順になります。

コマンドのオプションは、できる限りデフォルトのものを用いているよ。

最初はこの通りに打ち込んで、慣れてきたら少しずつ好きな構成にアレンジしてみよう。

早速ターミナルを開いてコマンドを打つべし。


プロジェクトの作成

$ ng new NgRxDataSample --routing --style scss

ちなみに、この NgRxDataSample の部分はプロジェクト名です。

べつに絶対コレじゃなきゃダメというわけではなく、任意の名前で作成可能だよ。


コマンドが正常終了したら、cd でプロジェクトフォルダ(ここではNgRxDataSample)へ移動しましょう。

$ cd ./NgRxDataSample

ライブラリを追加するため、以下のコマンドを順に打ち込んでいきます。

$ ng add @ngrx/store
$ ng add @ngrx/store-devtools
$ ng add @ngrx/effects
$ ng add @ngrx/entity
$ ng add @ngrx/data

※ 普通はここで問題なく成功するのですが、2020年2月11日現在、どうやらエラーになってしまうようです。

あれ…… なんかいきなり失敗したぞ。

package.jsonを見ると、明らかに古すぎるバージョンを入れようとしているっぽい。

f:id:tercel_s:20200211085021p:plain

そんなときは、最新のバージョンがインストールされるよう、代わりにコレを打ち込んでみよう。
$ ng add @ngrx/store@latest

パッケージ名の後ろに、スペースを開けず @latest と続けるんだよ。

f:id:tercel_s:20200211084928p:plain
おー、今度は成功した。

Angular のメジャーバージョンが上がったばっかりなせいか、依存関係の解決がちょっと不安定だね。

警告も出ているし。

ここはそのうちきちんと対策されるだろうと思っているから、とりあえず先に進もうか。

ところで、こんなに一度にたくさんのコマンドを打ち込んで大丈夫だったのかな? 何が起きたか心配なんだけど。

先ほどの図にもある通り、@ngrx/data を動かすためには、@ngrx/store, @ngrx/effects, @ngrx/entity が必須なんだ。

@ngrx/store-devtools は、入れても入れなくてもどっちでもいいけど、あると便利だから敢えて入れてる。

改めて、すごい大仕掛けだね。

まぁね。 車を走らせるためにはとりあえずガソリンを入れるでしょ?

そのとき、なんでガソリンが必要なのかなんていちいち考えないでしょ?

なんか今日は自動車のたとえ話が多いね。

車でも買うの?


一連のng addコマンドによって何が起きたか

ちょっと、この一連の操作によってプロジェクトの身に何が起きたかを確認していこう。

興味がなければ読み飛ばしてもいいけど。

……なんか、新しいファイルが増えてる?

うん。いくつか NgRx 関連のファイルが作られたり、既存のファイルが書き換えられたりしている。

コマンド実行後のフォルダ階層を見てみよう。

ファイルやフォルダ名の右側に アイコンが付いているのは、コマンドによって新しく作られたもの、 アイコンは書き換えられたものを示しているよ。

  • NgRxDataSample
    • node_modules
    • src
      • app
        • reducers
          • index.ts
        • app-routing.module.ts
        • app.component.html
        • app.component.scss
        • app.component.spec.ts
        • app.component.ts
        • app.effects.spec.ts
        • app.effects.ts
        • app.module.ts
        • entity-metadata.ts
      • assets
      • environments
      • favicon.ico
      • index.html
      • main.ts
      • polyfills.ts
      • styles.scss
    • angular.json
    • package.json
    • package-lock.json

変更された package.json をエディタで開いてみよう。

dependencies に 5 つのパッケージが追加されていることが分かるね。


package.json(差分)

   "dependencies": {
     "@angular/animations": "~9.0.0",
     "@angular/common": "~9.0.0",
     "@angular/compiler": "~9.0.0",
     "@angular/core": "~9.0.0",
     "@angular/forms": "~9.0.0",
     "@angular/platform-browser": "~9.0.0",
     "@angular/platform-browser-dynamic": "~9.0.0",
     "@angular/router": "~9.0.0",
+    "@ngrx/data": "^8.6.0",
+    "@ngrx/effects": "^8.6.0",
+    "@ngrx/entity": "^8.6.0",
+    "@ngrx/store": "^8.6.0",
+    "@ngrx/store-devtools": "^8.6.0",
     "rxjs": "~6.5.4",
     "tslib": "^1.10.0",
     "zone.js": "~0.10.2"
  },


あとは app.module.tsimports にいくつか自動でコードが追加されている。

NgRx をじかいじるなら知っておいた方がよいものもあるけど、今のところ特に気にする必要はない。

app.module.ts(差分のみ)

 @NgModule({
   declarations: [
     AppComponent
   ],
   imports: [
     BrowserModule,
-    AppRoutingModule
+    AppRoutingModule,
+    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 { }

共通手順:app.module.ts に HttpClientModule を import する

実はこの状態だとコンパイルは通るんだけど実行時エラーが発生するんだ。

main.ts:12 NullInjectorError: R3InjectorError[EntityDataModule -> EntityCacheEffects -> EntityCacheDataService -> HttpClient -> HttpClient -> HttpClient]:
NullInjectorError: No provider for HttpClient!
at NullInjector.get (http://localhost:4200/vendor.js:10919:27)
at R3Injector.get (http://localhost:4200/vendor.js:24553:33)
at R3Injector.get (http://localhost:4200/vendor.js:24553:33)
at R3Injector.get (http://localhost:4200/vendor.js:24553:33)
at injectInjectorOnly (http://localhost:4200/vendor.js:10774:33)
at Module.ɵɵinject (http://localhost:4200/vendor.js:10784:57)
at Object.EntityCacheDataService_Factory [as factory] (http://localhost:4200/vendor.js:65667:252)
at R3Injector.hydrate (http://localhost:4200/vendor.js:24779:63)
at R3Injector.get (http://localhost:4200/vendor.js:24541:33)
at injectInjectorOnly (http://localhost:4200/vendor.js:10774:33)

こんなときは、app.module.tsimportsHttpClientModule を追加しよう。

app.module.ts(差分のみ)

+ import { HttpClientModule } from '@angular/common/http';

 @NgModule({
   declarations: [
     AppComponent
   ],
   imports: [
     BrowserModule,
     AppRoutingModule,
+    HttpClientModule,
     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 { }

importsの中なら、どこに追加してもいいの?

いや、上の例のように、 HttpClientModuleEffectsModule よりも先に書かないとダメ

できた!

よし、じゃあ早速 ng serve を打ち込んでアプリケーションを実行してみよう。
f:id:tercel_s:20200211220040p:plain
http://localhost:4200/
動いた!!

おめでとう。

今回は、プロジェクトを作ってライブラリを入れて、エラーを直してとりあえず動く状態にしたね。

(……このペースで本当に完結するのかな)

実際にデータベースを用意したり API 用のサーバを別途立てたりするのは大変なので、簡単のため Angular In Memory Web API を使うことにするよ。

次回は、実際に例題を解いていこうね。

ではまた!

激闘編につづく。

tercel-tech.hatenablog.com

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