Help us understand the problem. What is going on with this article?

Angularから@ngrx/dataとActiveRecordを使ってRDBにアクセスする(追記あり)

1 はじめに

この記事は Angular #2 Advent Calendar 2019 6日目の記事です。

 スターアプリケーションズ株式会社の吉田文雄です。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 概念図

本記事における概念図を載せます。

qiita 画像.jpg

本図で特に強調したいのは、赤い矢印線です。この矢印線のようにデーターが一方向で流れていくことを示しています。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 for Mac又は、Docker for Windowsを導入してください。Dockerに関しては別資料を参照してください。

Angularのソースは、FrontEnd-srcディレクトリー下にあります。

2.1 サンプルの内容

 サンプルプログラムはMySQLデータベース内の4つのテーブルを使って、データを参照したり、更新したりできます。初期データは予め登録されています。
sampleprogTable.jpg

2.2 サンプルの実行

 サンプルを実行するには、以下の順で行ってください。
  1. PCの適当なディレクトリにサンプルのクローンを作成
  2. cd angularRDB
  3. docker-compose build
  4. docker-compose up -d
  5. 自分のブラウザーから http://localhost:4567/
  6. 終了する場合 docker-compose down
 なお、6のコマンドはimagesも削除しますので、次回は上記3のbuildからやり直してください。その他、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の命名規則に沿った形になります。

database.ddl
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のパターンになります。

main.rb
#
#  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系となっています。

main.rb
#  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/ トランザクション処理        

5 @ngrx/dataの実践

 いよいよ@ngrx/data です。ここからは、Angularから@ngrx/dataを使う方法について述べます。

5.1 設定

 @ngrx/dataの設定はそれほど難しいものではありません。基本はentity単位で以下の3つの項目をセットするだけです。

  • entityクラスの定義
  • 対象entityのサービス設定
  • entity情報をentity-metadataに設定する

上記entity-metadataをNgModuleの中で登録すると@ngrx/dataが自動的に使えるようになります。

5.1.1 @ngrx/data config

 @ngrx/dataのdefault path は'/api'となっています。この部分を'/api/arreadwrite'に変更します。変更方法ですが、dataservice-config.tsというファイルを作って行います。

dataservice-config.ts
import { DefaultDataServiceConfig } from '@ngrx/data';


export const defaultDataServiceConfig: DefaultDataServiceConfig = {
    root: 'api/arreadwrite',
    timeout: 3000, // request timeout
  }

この後、NgModuleのprovidersで定義します。

NgModule一部
import { defaultDataServiceConfig } from './dataservice-config';
        :
        :
  providers: [{ provide: DefaultDataServiceConfig,useValue: defaultDataServiceConfig}],

5.1.2 クラス定義

 各entity毎に定義します。データベースのテーブル定義でも説明しように4つのフィールドすなわち(id,lock_version,created_at,updated_at)は必須です。

organization.ts
//
// 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毎に定義していきます。

organization.service
 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 メタデータの登録

次の図はサンプルプログラムのメタデータ登録になります。

entity-metadata
 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 const entityMetadata: EntityMetadataMap = {
11   Branch: {},
12   Organization: {},
13   Position: {},
14   Employee: {},
15  // change id -> joinid
16   Organizationjoin: {
17  //  selectId: (organizationjoin: Organizationjoin) => organizationjoin.joinid 
18      selectId : organizationjoinSelectid 
19  },
20  Calculation: {},
21  Finder: {} ,
22  Transaction: {}
23 };
24
25 const pluralNames = {
26   Branch: 'Branches'
27   };
28
29 export const entityConfig: EntityDataModuleConfig = {
30   entityMetadata,
31   pluralNames
32};

 上図の11〜14行がentity登録になります。何もなければ1行で済みます。16行目のOrganizationjoinはjoin
専用のentityで、主キーをidではなくjoinidに変更使用としています。マニュアル通りなら、本来は17行目のコードで良いはずですが、これだと何故かコンパイルエラーが出てしまうので18行目の書き方をしています。20〜22行目は独自のentityでこの後順次説明していきます。25〜27行目は複数形の定義です。ここで定義しない場合は、複数形は単純に's'を追加するだけです。29〜31行目はメタデータ全体の登録になります。entity毎の細かな設定は、ほぼこの場所で行います。

次はNgModuleの登録です.entity-metadataは、entityConfigの中に含まれています。

app.module.ts
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
 コンポーネントでどのような処理が行われるか見ていきます。

organization-list.component.ts
 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行目でサービスをローカルに定義します。次にテンプレート側を見ていきます。

organization-list.component.html
 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)="onCancel()" 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)を追加する例を示します。

organization-newentry.component.ts
 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)を更新する例を示します。

organization-detail.component.ts
 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の雛形を持ってきます

organization-join-list.component.ts
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処理

 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は、割と簡単に導入できるので、これからも積極的に使って見たいと思います。みなさまから、こうした方が良いとか、ここが良くないとかあったらどんどん教えてください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away