1 はじめに
この記事は Angular #2 Advent Calendar 2019 6日目の記事です。
下記モジュールをRel 9.x に変更しました。
Angular,AngularMaterial,NgRx,flex-layout
スターアプリケーションズ株式会社の吉田文雄です。Angularからデータベースにアクセスする話だと、FirebaseとかGraphQLの話題が多いようですが、昨今NoSQLからRDBに回帰する動きもあり、リレーショナルデータベースに挑戦してみました。ただ、AngularからSQL文をゴリゴリ書くのは、少しつらいところもあり、ActiveRecordを挟む構成とします。また2019年の夏に、リアクティブプログラミングのNgRx8.0がリリースされた際、@ngrx/dataというモジュールも一緒にリリースされました。この機能を使うことによりデータを一方向で流すパターンを、簡単に作れるようになります。本稿は、当初RDBのCRUDのみを考えていたのですが、せっかくRDBを使うのですから、Join、トランザクションも頑張ってみました。本記事の後半にあります。
##1.1 ActiveRecord
データベースのテーブルをオブジェクト指向のクラスにマッピング(写像)するものは、一般にORマッパーと呼ばれています。ActiveRecordは数あるORマッパーの中でも、有名なRuby on Railsに実装されており、今回、以下の理由から採用しました。
- 特定のRDBに依存したくない
- 比較的長く使われており、それなりにこなれている
- データベースの定義、データの作成で、マイグレーション,fixturesなどのツールがあり簡単にできる
##1.2 @ngrx/data
NgRxは、Reduxにインスパイアされたリアクティブプログラミングで、Angularではデファクトスタンダードとも言われています。NgRxはとてもよいのですが、データの種類が多い場合、例えばデータベースから何種類ものテーブルを使ってデータのやりとりをする(業務アプリでは結構このようなケースが多い)場合に、膨大な数のAction,Reducer,Selectorを書いてテストしなければなりません。これはプログラマーに大きな負担になります。そこで負担を軽減するためのファサード(Facade)のようなものとして@ngrx/dataが利用できます。この辺は、私が能書きを垂れるよりも、@ngrx/dataのドキュメントを見ていただくと良いと思います。また、NgRxのStoreはむずかしくってチョットという方も@ngrx/dataを試して見てはいかがでしょうか。表面上はActionもDispatchもReducerも出てきません。直感的にわかりやすいCRUD系のコマンドだけです。ただしentity系のデータのみなので、それ以外のタイプはStoreを使っていきましょう。
##1.3 概念図
本記事における概念図を載せます。
本図で特に強調したいのは、赤い矢印線です。この矢印線のようにデーターが一方向で流れていくことを示しています。StoreのデータはReadOnlyであり、新規作成や変更や削除は必ずコマンド(commands)発行経由になります。
また、Storeは一種のグローバルキャッシュであり、Angularの各Componentからアクセスすることができます。DataBaseからStoreにデータを蓄積したり、StoreにあるデータをDataBaseに更新するのは、@ngrx/dataがやってくれるので、Angularアプリが行うのはコマンド発行のみです。
なお、上記の図はあくまでも概念図であり、実装上の形態はAngularアプリケーションの中にStoreやEffectが存在しています。
#2 サンプルプログラム
サンプルプログラムの場所は次のとおりです。https://github.com/YoshidaFumio/AngularRDB
サンプルプログラムを動かすに当たって、以下の2つのプログラムが必要になります。
- docker
- docker-compose
もし、上記が無い場合、Docker Desktop for Mac又は、for Windowsを導入してください。Dockerに関しては別資料を参照してください。
Angularのソースは、FrontEnd-srcディレクトリー下にあります。ソースプログラムを取り込んで動かすことも可能です。詳細はREADME.mdを参照してください。
##2.1 サンプルの内容
サンプルプログラムはMySQLデータベース内の4つのテーブルを使って、データを参照したり、更新したりできます。初期データは予め登録されています。
##2.2 サンプルの実行
サンプルを実行するには、以下の順で行ってください。
1. PCの適当なディレクトリにサンプルのクローンを作成
2. cd angularRDB
3. docker-compose build
4. docker-compose up -d
5. 自分のブラウザーから http://localhost:4567/
6. 終了する場合 docker-compose down
なお、やり直す場合は、上記4のupからやり直してください。データベースの内容はリセットされます。その他、以下のようなdocker-compose コマンドが使えます。
- ActiveRecordサービスのコンテナに入る
docker-compose exec activerecord-service /bin/bash
- DataBaseサービスのコンテナに入る
docker-compose exec database /bin/bash
#3 データベース
ActiveRecordは、Ruby on Railsの設計哲学の一つである「設定よりも規約」(Convention over Configuration)の方針に従い、かなり厳密な命名規約があります。今回はこれを踏襲していきます。
##3.1 命名規約
データベースのテーブル、ActiveRecordのModel Class、@ngrx/dataのentityは現時点では1:1:1対応しています。それぞれ名前が明確に決められており、この名前を維持することにより、ActiveRecord,@ngrx/dataが機能していきます。次の表は、従業員(employee)と事業所(branch)のそれぞれでどのような名前になるか示唆しています。
項目名 | employee | branch |
---|---|---|
データベースのテーブル名 | employees | branches |
ActiveRecord Model名 | Employee | Branch |
@ngrx/data entity名 | Employee | Branch |
URL 単数系 | employee | branch |
URL 複数系 | employees | branches |
URL Join系 | employeejoins | branchjoins |
上記の表のうちURL~は、@ngrx/data からBackEndのActiveRecord サービスにRestでデータを渡す際のURLになります。
- URL単数系対象コマンド:getByKey,add,update,delete
- URL複数系対象コマンド:getWithQuery,getAll
- URLJoin系対象コマンド:getWithQuery
テーブル内の各フィールドに関しては、データベース、Modelクラス、entity、で全て合わせます。
次の名前は予約されていますので、テーブル名で使用できません。
- transactions
- calculations
- finders
##3.2 テーブルの内容
実際にテーブルの構造を見ていきます。下記の図は、MySQLのコマンドでTableを作成した例です。ここでもActiveRecordの命名規則に沿った形になります。
create table employees (
id int not NULL auto_increment PRIMARY KEY,
branch_id int not NULL ,
organization_id int ,
position_id int ,
first_name varchar(64) default NULL,
last_name varchar(64) default NULL,
mobile_number varchar(128) default NULL,
mail_address varchar(128) default NULL,
twitter_link varchar(128) default NULL,
birthday datetime default NULL ,
entering_company datetime default NULL ,
english_test int default 0 ,
lock_version int default 0,
created_at datetime not NULL,
updated_at datetime not NULL,
FOREIGN KEY(branch_id) REFERENCES branches(id),
FOREIGN KEY(position_id) REFERENCES positions(id),
FOREIGN KEY(organization_id) REFERENCES organizations(id)
)ENGINE=InnoDB CHARACTER SET utf8;
各テーブルを作成する際に必ず、以下の4つのフィールドが必要になります。
1. id number | string
2. lock_version number
3. created_at datetime
4. updated_at datetime
上記のうち、1,3,4はRailsのmigrationなどを使うと、自動的に設定されます。lock_versionは、更新命令(PUT)の際に必ずチェックするので入れます。idを別の名前に置き換えたり、複数のフィールドの合成にすることは可能ですが、@ngrx/dataの設定時に処理が入ります。他テーブルの参照リンクは、ActiveRecordに準拠していきます。上図で organization_id は、organizationsテーブルのidを参照します。
#4 ActiveRecord サービス
ActiveRecordサービスは、BackEndで@ngrx/dataのリクエストに従ってコマンドをデータベースに繋ぎます。データベースからの返信データはJSONに変換して、FrontEnd(@ngrx/data,Angular)に戻します。言わば**「JSON配給係」**になります。今回、この部分はRubyとSinatraで書きました。「ActiveRecordなのにRailsじゃないのかよ」お叱りを受けそうですが、Railsはやりレールに乗っていると快適ですが、少しで脱線すると、とても辛いものがありますので、自由度の高いSinatraにしました。
##4.1 モデル定義
モデル定義は特に変わったところはありません。普通ののActiveRecordのパターンになります。
#
# ActiveRecord Models
#
class Branch < ActiveRecord::Base
has_many :employees
end
class Organization < ActiveRecord::Base
has_many :employees
end
class Position < ActiveRecord::Base
has_many :employees
end
class Employee < ActiveRecord::Base
belongs_to :branch
belongs_to :position
belongs_to :organization
end
モデル定義と同時にリクエストのURLから、モデル名を特定するテーブルがあります。
1列目から、モデル名、単数系、複数系、Join系となっています。
# search table
#
# [ModelName,single,plurial,joinsName]
#
MODEL_TABLE = [
['Branch' , 'branch' , 'branches' , 'branchjoins'],
['Organization' , 'organization' , 'organizations' , 'organizationjoins'],
['Position' , 'position' , 'positions' , 'positionjoins'],
['Employee' , 'employee' , 'employees' , 'employeejoins'],
]
もし、自分でテーブルを追加したりするのであれば、Ruby/Sinatraは上記2カ所の修正になります。
##4.2 振り分け処理
SinatraはRuby用のDSLで主にルーティング機能を司ります。Sinatra側にどのようなリクエストが発生してそれを振り分けるのか見ていきます。以下、Employeeというentityを例とします。
コマンド | URL | 内容 |
---|---|---|
get | ~/employee/3 | id=3のレコード読み込み |
get | ~/employees/ | employee全件読み込み |
get | ~/employees/?where~ | where以下の条件に合うレコード |
post | ~/employee | データを1件新規作成 |
put | ~/employee/4 | id=4のレコードを更新 |
delete | ~/employee/5 | id=5のレコードを削除 |
get | ~/employeejoins/?joins(~ | joinsを実行 |
get | ~/calculations/?where~ | 最大、最小、平均などを求める |
get | ~/finders/?where~ | 条件に合致するものがあるか確認 |
post | ~/transaction/ | トランザクション処理 |
- employeeの部分がentityごとに切り替わります
- 先頭の'~/'の部分はドメイン等です。例 http://localhost:4567/api/arreadwrite
#5 @ngrx/dataの実践
いよいよ@ngrx/data です。ここからは、Angularから@ngrx/dataを使う方法について述べます。
##5.1 設定
@ngrx/dataの設定はそれほど難しいものではありません。基本はentity単位で以下の4つの項目をセットするだけです。
- Restインタフェースのrootpath設定
- entityクラスの定義
- 対象entityのサービス設定
- entity情報をentity-metadataに設定する
上記entity-metadataをNgModuleの中で登録すると@ngrx/dataが自動的に使えるようになります。
###5.1.1 Restインターフェースのroot path
@ngrx/dataのdefault path は'/api'となっています。この部分を'/api/arreadwrite'に変更します。変更方法ですが、dataservice-config.tsというファイルを作って行います。
import { DefaultDataServiceConfig } from '@ngrx/data';
export const defaultDataServiceConfig: DefaultDataServiceConfig = {
root: 'api/arreadwrite',
timeout: 3000, // request timeout
}
この後、NgModuleのprovidersで定義します。
import { defaultDataServiceConfig } from './dataservice-config';
:
:
providers: [{ provide: DefaultDataServiceConfig,useValue: defaultDataServiceConfig}],
###5.1.2 クラス定義
各entity毎に定義します。データベースのテーブル定義でも説明しように4つのフィールドすなわち(id,lock_version,created_at,updated_at)は必須です。
//
// Organization Class Define
//
export class Organization {
id: number;
org_code: string ;
org_name: string ;
lock_version: number ;
created_at: string ;
updated_at: string
}
###5.1.3 対象サービスの設定
これも各entity毎に定義していきます。
1 //
2 // Organization Service
3 //
4 // Facade of command(create , read , update , delete) and selector$(entities)
5 //
6 import { Injectable } from '@angular/core';
7 import { EntityCollectionServiceBase, EntityCollectionServiceElementsFactory }
from '@ngrx/data';
8 import { Organization } from './organization' ;
9 @Injectable({ providedIn: 'root' })
10 export class OrganizationService extends EntityCollectionServiceBase<Organization> {
11 constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory) {
12 super('Organization', serviceElementsFactory);
13 }
14 }
新しいentityのサービスを作る場合、上図で'Organization'となっている部分5カ所を、全て別なentity名に変えれば十分です。8行目2カ所、10行目2カ所、12行目1カ所、サービスとクラスのファイルは近くに置いておくとよいでしょう。サービスをroute に作成しましたので、どこからもアクセスすることができます。ここでは静的にサービスを作成しましたが、動的にサービスを設定することもできます。詳細はentityCollectionServiceFactoryを参照してください。
###5.1.4 メタデータの登録
次のリストはサンプルプログラムのメタデータ登録になります。
1 import { EntityMetadataMap, EntityDataModuleConfig } from '@ngrx/data';
2 import { EntityAdapter , createEntityAdapter } from '@ngrx/entity';
3 import { Organizationjoin } from './models/organizationjoin';
4 import { SelectionModel } from '@angular/cdk/collections';
5 import { IdSelector, Comparer } from '@ngrx/entity';
6
7 export function organizationjoinSelectid (organizationjoin:Organizationjoin)
8 { return organizationjoin.joinid }
9
10 export function sortBranch(a: { branch_code: string }, b: { branch_code: string }): number {
11 return a.branch_code.localeCompare(b.branch_code);
12 }
13 export function sortOrganization(a: { org_code: string }, b: { org_code: string }): number {
14 return a.org_code.localeCompare(b.org_code);
15 }
16
17 export function sortPosition(a: { pos_code: string }, b: { pos_code: string }): 18 number {
19 return a.pos_code.localeCompare(b.pos_code);
20 }
21
22 const entityMetadata: EntityMetadataMap = {
23 Branch: {
24 sortComparer: sortBranch ,
25 entityDispatcherOptions : { optimisticDelete : false}
26 },
27 Organization: {
28 sortComparer: sortOrganization ,
29 entityDispatcherOptions : { optimisticDelete : false}
30 },
31 Position: {
32 sortComparer: sortPosition ,
33 entityDispatcherOptions : { optimisticDelete : false}
34 },
35 Employee: {
36 entityDispatcherOptions : { optimisticDelete : false}
37 },
38 Organizationjoin: {
39 // selectId: (organizationjoin: Organizationjoin) => organizationjoin.joinid
40 selectId : organizationjoinSelectid
41 },
42 Calculation: {},
43 Finder: {} ,
44 Transaction: {}
45 };
46
47 const pluralNames = {
48 Branch: 'Branches'
49 };
50
51 export const entityConfig: EntityDataModuleConfig = {
52 entityMetadata,
53 pluralNames
54 };
上記リストの22〜45行がentity登録になります。何もなければ1行で済みます。各entityの定義で、sortComparerを設定しているのは、entity毎のソート順を表しています。またentityDispatcherOptionsはDelete時の振る舞いのデフォルトをpessimistic(サーバー更新後にキャッシュを更新)にするものです。Delete以外のAdd,Updateはデフォルトpessimisticです。38行目のOrganizationjoinはjoin専用のentityで、主キーをidではなくjoinidに変更使用としています。42〜44行目は独自のentityでこの後順次説明していきます。47〜49行目は複数形の定義です。ここで定義しない場合は、複数形は単純に's'を追加するだけです。51〜54行目はメタデータ全体の登録になります。entity毎の細かな設定は、ほぼこの場所で行います。
次はNgModuleの登録です.entity-metadataは、entityConfigの中に含まれています。
import { DefaultDataServiceConfig,EntityDataModule } from '@ngrx/data';
import { entityConfig } from './entity-metadata';
import { defaultDataServiceConfig } from './dataservice-config';
:
imports[
:
EntityDataModule.forRoot(entityConfig)
]
##5.2 データの流れ
データの流れの説明に入る前に、もう一度前提を整理しておきます。1.3 概念図も参照してください。
前提
1. entityのデータはStoreという大きな枠の中に格納されている
2. Storeのデータには直接書き込めない、ReadOnlyである
3. Storeのデータを変えたい場合はコマンドを発行しなければならない
4. Storeのデータを直接見ることはできない
5. Storeのデータを見るためにはObservable変数を経由して見る。1.3図 Selectors
コンポーネントでどのような処理が行われるか見ていきます。
1 import { Component, OnInit , ChangeDetectionStrategy,
2 ɵSWITCH_RENDERER2_FACTORY__POST_R3__ } from '@angular/core';
3 import { Observable } from 'rxjs';
4 import { ActivatedRoute ,Router, NavigationExtras} from '@angular/router' ;
5 import { Organization , OrganizationService } from '../../models/organization';
6
7 @Component({
8 selector: 'app-organization-list',
9 templateUrl: './organization-list.component.html',
10 styleUrls: ['./organization-list.component.css'],
11 changeDetection : ChangeDetectionStrategy.OnPush
12 })
13 export class OrganizationListComponent implements OnInit {
14 organization$: Observable <Organization[]> ;
15
16 constructor(
17 private navroute : Router ,
18 private organizationService: OrganizationService,
19 ) {
20 this.organization$ = organizationService.entities$ ;
21 }
22
23 ngOnInit() {
24 }
1〜5でentity関連のファイルを取り込みます。14行目、Observableな変数を定義します。末尾が$の変数はObservableな変数です、18行目でサービスをローカルに定義します。次にテンプレート側を見ていきます。
1 <ng-container *ngIf = "organization$ | async as organizations">
2 <mat-toolbar color="primary">
3 <span class = "toolbar-top">部門一覧</span>
4 </mat-toolbar>
5 <div class="message-t">
6 クリックすると詳細を表示して編集ができます。
7 </div>
8 <div class="outline">
9 <div>
10 <span class="header-pos1">コード</span>
11 <span class="header-pos2">名前</span>
12 </div>
13 <mat-nav-list>
14 <mat-list-item *ngFor="let organization of organizations"
15 (click)="onSelect(organization.id)">
16 <span>{{ organization.org_code }}</span>
17 <span class="item-pos">{{ organization.org_name }}</span>
18 </mat-list-item>
19 </mat-nav-list>
20 <div class="button-row">
21 <button mat-raised-button (click)="onClose()" class="button-m">閉じる</button>
22 <button mat-raised-button (click)="onNew()" class="button-m">新規</button>
23 </div>
24 </div>
25 </ng-container>
1行目のasyncパイプで、organization$にデータがよびこまれると同時にorganizationsにも展開されます。このorganizationsという複数形は、entityの配列そのものです。14行目のngFor文で配列の要素を1個ずつ取り出してorganizationという単数形の変数に代入します。
##5.3 CRUD処理
@ngrx/dataでは、元々1つのentityに対して何種類ものコマンドを用意しています。参考:Entity Commandsその中でも特に重要な8つコマンドを説明しておきます。
コマンド | 機能 |
---|---|
add | entityの追加を行います |
getByKey | 指定idからデータを1件取り込みます |
getAll | 全データを取り込みます |
load | 全データを取り込みます |
getWithQuery | queryの内容を見て、データを取り込みます |
update | 指定されたidのデータを更新します |
delete | 指定されたidのデータを削除します |
clearCache | Store内でentityのキャッシュデータを削除します |
getAllとloadの違いは、storeにすでにデータがある場合、getAllはマージするのに対してloadは置き換えを行います。
###5.3.1 CREATE処理
サンプルの中から、組織(organization)を追加する例を示します。
1 onSave() {
2 let orgdata = new Organization ;
3 orgdata.org_code = this.organizationForm.value['code'] ;
4 orgdata.org_name = this.organizationForm.value['name'] ;
5 orgdata.lock_version = 0 ;
6 this.organizationService.add(orgdata) ;
7 let extra:NavigationExtras = { }
8 this.navroute.navigate(['/organizationlist'],extra);
9 }
CRUDコマンド全般に言えることですが、コマンドのパラメータとして渡すデータは、Storeのデータを用いてはなりません。上記の2行目で、パラメータ用のデータ領域を作成しています。3〜4行目フォームのデータをセットします。lock_versionの値は初期0になります。なお、新規で作成する場合、idは未設定にしておきます。7行目でorganizationService.add でコマンド発行になります。
###5.3.2 READ処理
Read系の処理には、3つのパターンがあります。employee(従業員entity)での例を示します。全てentityのemployeeServiceというrouteサービスから実行します。(5.1.3参照)
#### ●特定idの読み込み
employeeService.getByKey(4)
上の例はid=4のデータを読み込みます。
#### ●全件の読み込み
employeeService.getAll()
getAllの他にloadというコマンドもあります。
#### ●特定の条件のデータの読み込み
const QueryStr: string = `where("branch_id = ?")` ;
;
queryNew = QueryStr.replace("?",3);
this.employeeService.getWithQuery(queryNew) ;
branch_id=3 の条件に当てはまるデータ読み込みます。条件式は、where文などActiveRecordのフォーマットが使えます。
###5.3.3 UPDATE処理
サンプルの中から、組織(organization)を更新する例を示します。
1 let orgdata = new Organization ;
2 orgdata.id = this.curOrganization.id ;
3 orgdata.org_code = this.organizationForm.value['code'] ;
4 orgdata.org_name = this.organizationForm.value['name'] ;
5 orgdata.lock_version = this.curOrganization.lock_version ;
6 this.organizationService.update(orgdata) ;
上記1行目でパラメータ用の領域を作成、2〜5行目でデータを設定します。idとlock_versionは、必ずセットしなければなりません。サンプルでは、curOrganizationというStore上のデータから持ってきています。それ以外のデータはFormの入力データになります。
###5.3.4 DELETE処理
employeeService.delete(5)
上の例はid=5のデータを削除します。
##5.4 Join処理
さて、せっかくテーブル間の関係(リレーション)が記述できるのに、CRUDだけでは物足りません。そこでJoinが使えるようにしました。まずは、どのような項目を取ってくるJoinなのか見極め、次にそれに合わせたデータ構造をもつクラスを作成します。
まずqueryの雛形を持ってきます
const QueryStr: string = `where("organizations.id = ?").joins(:employees).select('org_code,org_name,employees.*')` ;
上図の中で?の記号がありますが、ここは後から数値が代入されるものです。ここで注目して欲しいのは、select命令です。selectの中身を見ると、employeeの全データと、org_code,org_nameのフィールドが必要になります。そこで、この2つのフィールドを持ったクラスを作成します。
//
// Organizationjoin Class Define
//
export class Organizationjoin {
joinid: number ;
org_code: string ;
org_name: string ;
id: number;
branch_id: number;
organization_id: number;
position_id: number;
first_name: string ;
last_name: string ;
mobile_number: string ;
mail_address: string ;
twitter_link: string ;
birthday: string ;
entering_company: string ;
english_test: number ;
lock_version: number ;
created_at: string ;
updated_at: string ;
pos_code: string ;
pos_name: string ;
}
さて、org_name、org_codeを追加したクラスですが、もう一つjoinidというものを追加しています。これは、テーブルのidがjoinの結果によっては、重複してしまうからです。joinidは、BackEndのActiveRecord Serviceで自動的に付与します。
##5.5 Transaction処理
@ngrx/dataには、元々Multiple entityというトランザクションっぽい処理がありますが、今回はActiveRecordのTransaction処理を使用します。 Transaction処理では、予めTransactionという仮想のentityを立ち上げ、このentityのPOST処理、すなわちADD処理において、Transactionのパラメータの例を示します。
begin
Employee.transaction do
m1 = Organization.new
m1['org_code'] = "500"
m1['org_name'] = "新規事業部"
m1.save!
e1 = Employee.new
e1['organization_id'] = m1.id
e1['position_id'] = 4
e1['branch_id'] = 1
e1['first_name'] = "光"
e1['last_name'] = "伊藤"
e1['mail_address'] = "hikaru.itou@tamanegidesign.co.jp"
e1.save!
end
@retstatus = "OK"
@retmessage ="success"
rescue => e
@retstatus = "NG"
@retmessage = "Error occured (#{e.class})"
end
上図のトランザクションでは、新しい部門を一つ作り、その部門コードを従業員テーブルのorganization_idにセットしてテーブルを作成していきます。
##5.6 計算処理
ActiveRecordには、最大値、最小値、合計、平均などいくつか計算結果を返すものがあります。これもCalculationという仮想のEntityを立ち上げ、そこで計算した結果を取得します。サンプルプログラムでは、英語の試験という形で平均点を算出します。サンプルプログラムのQueryを以下に示します。
const AverageQueryStr: string = `Employee.average(:english_test)` ;
#6 まとめ
突貫工事で作成したためサンプルにセキュリティ対策やエラー処理は入っていません、ご了承ください。とりあえず@ngrx/dataとActiveRecordをつないでデータベースに接続することができました。@ngrx/dataは、割と簡単に導入できるので、これからも積極的に使って見たいと思います。みなさまから、こうした方が良いとか、ここが良くないとかあったらどんどん教えてください。