【RxJS】処理に失敗したときに早期リターンしたい話
はじめに
C# でコードを書く時に、下記のような処理をよく書きます。
private async Task ExecuteExampleAsync() { var result1 = await DoSomethingAsync1(); if (result1 == null) { return; } var result2 = await DoSomethingAsync2(); if (result2 == null) { return; } return await DoSomethingAsync3(); }
例えば一つ目の処理に失敗した場合はそこで処理をストップする、という話なのですが、これを RxJS でやるとどうなるの?というのが前から気になっていたので試してみました。
Environments
- Angular ver.10.1.0-next.1
- RxJS ver.6.6
empty, throwError, throw new Error
今回調べてみたなかで、実現できそうな方法は3つありました。
サンプルコード(ベースだけ)
今回は下記の ngOnInit からメソッドを呼ぶ、という形で試すことにしました。
workflow-page.component.ts
import { Component, OnInit } from '@angular/core'; import { Observable, of, empty, throwError } from 'rxjs'; import { flatMap, catchError } from 'rxjs/operators'; @Component({ selector: 'app-workflow-page', templateUrl: './workflow-page.component.html', styleUrls: ['./workflow-page.component.css'] }) export class WorkflowPageComponent implements OnInit { constructor() { } ngOnInit(): void { // call methods } }
empty
「empty」が呼ばれると、subscribe の「complete」ハンドラーが呼ばれて処理が完了します。
... ngOnInit(): void { console.log('-- Throw empty from first call --'); this.executeEmpty(4); console.log('-- Throw empty from second call --'); this.executeEmpty(3); console.log('-- End --'); } private executeEmpty(startValue: number) { this.getEmpty(startValue) .pipe( flatMap(result => { console.log(`2nd execution: ${result}`); return this.getEmpty(result); }), catchError(error => { console.error(`catch: ${error}`); return of(error); }) ) .subscribe(result => console.log(`next: ${result}`), error => console.error(`error: ${error}`), () => console.log('complete')); } private getEmpty(lastValue: number): Observable<number> { if (lastValue > 3) { return empty(); } return of(lastValue + 1); }
結果
-- Throw empty from first call -- complete -- Throw empty from second call -- 2nd execution: 4 complete -- End --
以上から、「empty」を使う場合、処理の結果(今回は null が返ってきた)は呼び出し先である「getEmpty」がハンドリングする必要があります。
呼び出し元には「complete」ハンドラーが呼ばれたことしか返ってこないからですね。
throwError
... ngOnInit(): void { console.log('-- Throw throwError from first call --'); this.executeThrowError(4); console.log('-- Throw throwError from second call --'); this.executeThrowError(3); console.log('-- Throw throwError with catchError from first call --'); this.executeThrowErrorWithCatchError(4); console.log('-- Throw throwError with catchError from second call --'); this.executeThrowErrorWithCatchError(3); console.log('-- End --'); } private executeThrowError(startValue: number) { this.getThrowError(startValue) .pipe( flatMap(result => { console.log(`2nd execution: ${result}`); return this.getThrowError(result); }) ) .subscribe(result => console.log(`next: ${result}`), error => console.error(`error: ${error}`), () => console.log('complete')); } private executeThrowErrorWithCatchError(startValue: number) { this.getThrowError(startValue) .pipe( flatMap(result => { console.log(`2nd execution: ${result}`); return this.getThrowError(result); }), catchError(error => { console.error(`catch: ${error}`); return of(error); }) ) .subscribe(result => console.log(`next: ${result}`), error => console.error(`error: ${error}`), () => console.log('complete')); } public getThrowError(lastValue: number): Observable<number> { if (lastValue > 3) { return throwError('Error from throwError'); } return of(lastValue + 1); }
結果
-- Throw throwError from first call -- error: Error from throwError ... -- Throw throwError from second call -- 2nd execution: 4 error: Error from throwError ... -- Throw throwError with catchError from first call -- catch: Error from throwError ... next: Error from throwError complete -- Throw throwError with catchError from second call -- 2nd execution: 4 catch: Error from throwError ... next: Error from throwError complete -- End --
「catchError」を使っていない場合はsubscribeの「error」ハンドラーが呼ばれます。
「catchError」を使っている場合、「catchError」で「of(error)」を返すと通常と同じく「next」と「complete」が呼ばれます。
ということで、処理に失敗したことを subscribe でエラーとしてハンドリングしたい場合は「catchError」無しで「throwError」を使うか、「catchError」からさらに「throwError」を実行する、といった書き方になりそうです。
throw new Error
「throw new Error」で Error を投げる、または呼び出したメソッド内で Error が発生した場合はどうでしょうか。
... ngOnInit(): void { console.log('-- Throw new Error from first call --'); this.executeThrowNewError(4); console.log('-- Throw new Error from second call --'); this.executeThrowNewError(3); } private executeThrowNewError(startValue: number) { this.getThrowNewError(startValue) .pipe( flatMap(result => { console.log(`2nd execution: ${result}`); return this.getThrowNewError(result); }) ) .subscribe(result => console.log(`next: ${result}`), error => console.error(`error: ${error}`), () => console.log('complete')); } // throw new Error public getThrowNewError(lastValue: number): Observable<number> { if (lastValue > 3) { throw new Error('Error from new Error()'); } return of(lastValue + 1); }
結果
-- Throw new Error from first call -- ERROR Error: Error from new Error()
最初の結果しか出力されない。。。(´・ω・`)
というのも、 pipe でつながれていない、最初の処理で Error が発生してしまうと「catchError」や subscribe の「error」では受け取ることができず、処理が止まってしまうからですね。
引数を2回目の実行時に Error を投げることになる 3 に変更すると、下記のような結果になります。
-- Throw new Error from first call -- 2nd execution: 4 error: Error: Error from new Error() ...
ということで、 Observable を返すメソッド(関数)では「throw new Error」は使わない方が良さそうです。
また、メソッド内で Error が発生する場合も呼び出し元には「throwError」として返す必要があるかと。
処理失敗の通知に「throwError」を使うべきか
今回試した「empty」、「throwError」、「throw new Error」の中では「throwError」を使うのがよさそうです。
が、まだ処理失敗の通知に「throwError」を使うべきか、というのはよくわかっておりません。
もう少しサンプルなど漁った方がよさそうですね。
TypeOrm を追加する
はじめに
今回は TypeOrm を追加して Database にアクセスできるようにしますよ。
Angular 10 + NgRx + Nest.js でプロジェクトを作る
TypeOrm を使う
Install
前回と同じくいくつかパッケージをインストールします。
Nest.js のプロジェクトに統合するためのパッケージもあるので合わせてインストールします。
npm install --save @nestjs/typeorm typeorm pg
Configuration
前回は「ormconfig.json」を使って TypeOrm の設定( Database の接続文字列など)を行っていました。
Nest.js のプロジェクトでは、「app.module.ts」の中で設定することもできます。
server/app.module.ts
import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { Workflow } from './entities/workflow.entity';
@Module({
imports: [
AngularUniversalModule.forRoot({
viewsPath: join(process.cwd(), 'dist/browser'),
bundle: require('../server/main'),
liveReload: false
}),
TypeOrmModule.forRoot({
type: "postgres",
host: "localhost",
port: 5432,
username: "test",
password: "test",
database: "Example",
entities: [Workflow],
synchronize: true,
logging: true,
cache: false
})
],
controllers: [],
providers: []
})
export class ApplicationModule {}
結局 JSON ファイルを選択した
結局、前回同様「ormconfig.json」を使うことにしました。
理由は、マイグレーションファイルを使った Database 更新をしたい場合、 TypeOrm のコマンドを使うのですが、この時コマンドからは「app.module.ts」の内容が参照できません(多分)。
ということで、いずれにしても「ormconfig.json」が必要になるわけですね。
ormconfig.json
{
"type": "postgres",
"host": "localhost",
"port": 5432,
"username": "test",
"password": "test",
"database": "Example",
"entities": ["dist/**/entities/*.entity{.ts,.js}"],
"migrations": ["server/migrations/*.{.ts,.js}"],
"synchronize": true,
"logging": true,
"cache": false,
"cli": {
"entitiesDir": "server/entities",
"migrationsDir": "server/migrations"
}
}
なお「ormconfig.json」を使う場合の「app.module.ts」はこんな感じです。
server/app.module.ts
~省略~
@Module({
imports: [
~省略~
TypeOrmModule.forRoot()
],
controllers: [],
providers: []
})
export class ApplicationModule {}
Entity クラスを追加する
server/entities/workflow.entity.ts
import {Entity, PrimaryGeneratedColumn, Column, OneToMany, UpdateDateColumn} from "typeorm";
@Entity("Workflow")
export class Workflow {
@PrimaryGeneratedColumn()
id: number = -1;
@Column({ type: 'text', nullable: false })
createUserMail: string = '';
@Column({ type: 'timestamp with time zone', nullable: true })
circulationLimit: Date|null = null;
@Column({ type: 'timestamp with time zone', nullable: false })
lastUpdateDate: Date = new Date();
public create(createUserMail: string, circulationLimit: Date|null) {
if (createUserMail == null ||
createUserMail.length <= 0) {
throw new Error("Can not set createUserMail empty");
}
this.createUserMail = createUserMail;
this.update(circulationLimit);
}
public update(circulationLimit: Date|null) {
this.circulationLimit = circulationLimit;
this.lastUpdateDate = new Date();
}
}
「nest generate」を使う
TypeOrm のクラスにアクセスするため、いくつか Nest.js のプロジェクトにクラスを追加していきます。
が、「nest generate ~」を実行すると、ファイルが「src」ディレクトリにできてしまう。。。(´・ω・`)
サーバー側のファイルを置いている「server」ディレクトリ以下にファイルを出力するにはどうすれば・・・?
結果としては、プロジェクト直下に「nest-cli.json」を置くことで解決しました。
nest-cli.json
{
"collection": "@nestjs/schematics",
"sourceRoot": "server"
}
「@nestjs/testing」の追加
Controller や Service などのクラスを追加したところ、エラー発生。
どうも一緒に追加されたテストクラスが必要としている、「@nestjs/testing」が足りない模様。
ということでインストールしました。
npm install --save-dev @nestjs/testing
Repository クラスから Database にアクセスする
Database にアクセスする(失敗)
まず Database にアクセスするための Controller や Service を追加します。
server/workflow/workflow.controller.ts
import { Controller, Get } from '@nestjs/common';
import { WorkflowService } from './workflow.service';
import { Workflow } from '../entities/workflow.entity';
@Controller('workflow')
export class WorkflowController {
constructor(private readonly service: WorkflowService) {
}
@Get("search")
public async getWorkflowItems(): Promise<Workflow[]> {
return await this.service.findAll();
}
}
server/workflow/workflow.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Workflow } from '../entities/workflow.entity';
import { Repository } from 'typeorm';
import { of } from 'rxjs';
@Injectable()
export class WorkflowService {
constructor(
@InjectRepository(Workflow)
private workflowRepositiory: Repository<Workflow>
){}
public findAll(): Promise<Workflow[]> {
return this.workflowRepositiory.find();
}
}
server/app.module.ts
...
import { WorkflowController } from './workflow/workflow.controller';
import { WorkflowService } from './workflow/workflow.service';
import { Workflow } from './entities/workflow.entity';
@Module({
imports: [
...
],
controllers: [WorkflowController],
providers: [WorkflowService]
})
export class ApplicationModule {}
OK。では実行してみます。
…エラーが発生しました(´・ω・`)
... [Nest] 17376 - 07/11/2020, 8:22:27 PM [ExceptionHandler] Nest can't resolve dependencies of the WorkflowService (?). Please make sure that the argument WorkflowRepository at index [0] is available in the ApplicationModule context. Potential solutions: - If WorkflowRepository is a provider, is it part of the current ApplicationModule? - If WorkflowRepository is exported from a separate @Module, is that module imported within ApplicationModule? @Module({ imports: [ /* the Module containing WorkflowRepository */ ] }) +570ms Error: Nest can't resolve dependencies of the WorkflowService (?). Please make sure that the argument WorkflowRepository at index [0] is available in the ApplicationModule context. Potential solutions: - If WorkflowRepository is a provider, is it part of the current ApplicationModule? - If WorkflowRepository is exported from a separate @Module, is that module imported within ApplicationModule? @Module({ imports: [ /* the Module containing WorkflowRepository */ ] }) at Injector.lookupComponentInParentModules (C:\Users\example\Documents\workspace\proofreading-workflow\node_modules\@nestjs\core\injector\injector.js:190:19) at processTicksAndRejections (internal/process/task_queues.js:97:5) [nodemon] app crashed - waiting for file changes before starting...
依存を解決する
どうも Repository クラスが DI できてないようです。
結局「app.module.ts」にモジュールを追加しました。
server/app.module.ts
...
import { Workflow } from './entities/workflow.entity';
@Module({
imports: [
...
TypeOrmModule.forRoot(),
TypeOrmModule.forFeature([Workflow])
],
controllers: [ProofreaderController, WorkflowController],
providers: [ProofreaderService, WorkflowService]
})
export class ApplicationModule {}
ようやく Database にアクセスできるようになりました :).
参照
Angular 10 + NgRx + Nest.js でプロジェクトを作る
はじめに
今回はサーバー側のプログラムも追加してみます。
今回は TypeScript 好きである、ということもあって Nest.js を選びました。
Environments
- Angular ver.10.0.2
- @ngrx/store ver.9.2.0
- @ngrx/effects ver.9.2.0
- @nestjs/ng-universal ver.2.0.1
Angular ver.10のプロジェクトに @ngrx/store が追加できない
前回同様「npx ng add @ngrx/store」で NgRx を追加しようとするとエラーが発生。
Installing packages for tooling via npm. Installed packages for tooling via npm. The package that you are trying to add does not support schematics. You can try using a different version of the package or contact the package author to add ng-add support.
下記の Issue によると、「@latest」をつける必要があるとのこと。
npx ng add @ngrx/store@latest
「@latest」をつけない場合、 1.2.0 のような古いバージョンのものがインストールされているようでした。
cannot use ng add to install ngrx store · Issue #2604 · ngrx/platform · GitHub
「@ngrx/effects」については「@latest」が必要かどうかはわかりませんでしたが、とりあえずつけてインストールしました。
npx ng add @ngrx/effects@latest
Nest.js のプロジェクトを追加する
「@nestjs/ng-universal」を使って Nest.js のプロジェクトを追加します。
GitHub - nestjs/ng-universal: Angular Universal module for Nest framework (node.js) 🌷
元々は Server-Side Rendering のためのものではありますが。
「ng add」コマンドを実行すると、プロジェクト名を聞かれるので先に作成した Angular のプロジェクト名を入力します。
npx ng add @nestjs/ng-universal
プロジェクトを実行する(失敗)
「npm run serve」で実行し、「localhost:4200」にアクセスするわけですが、ここでエラーが発生しました。
... [Nest] 12736 - 07/08/2020, 1:04:45 AM [ExceptionsHandler] Cannot read property 'indexOf' of undefined +28479ms TypeError: Cannot read property 'indexOf' of undefined ...
下記の Issue によると、「liveReload」を false にする必要があるとのこと。
TypeError: Cannot read property 'indexOf' of undefined · Issue #188 · nestjs/ng-universal · GitHub
server/app.module.ts
import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';
@Module({
imports: [
AngularUniversalModule.forRoot({
viewsPath: join(process.cwd(), 'dist/browser'),
bundle: require('../server/main'),
liveReload: false
})
]
})
export class ApplicationModule {}
白いページが表示される
「liveReload」自体も気になるところですが、とにあれこれでエラーなしでページを表示できました。
が、そこにあるのはただ真っ白のページ。Angular のページではありません。
プロジェクトの README.md にある「bootstrap」は(恐らく)仕様変更のため「AngularUniversalOptions」に存在せず、サンプルコードをそのままコピペすることはできないようです。
ただ、「localhost:4200/index.html」にアクセスすると、正しく Angular のページが表示できました。
ということで、 デフォルトの Static file が設定されていないようだ、ということはわかりました。
またまた README.md を見ると「templatePath」というプロパティがあったため、追加してみたのですが動作しないようです。
server/app.module.ts
import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';
@Module({
imports: [
AngularUniversalModule.forRoot({
viewsPath: join(process.cwd(), 'dist/browser'),
bundle: require('../server/main'),
liveReload: false,
templatePath: join(process.cwd(), 'dist/browser/index.html'),
})
]
})
export class ApplicationModule {}
「@nestjs/serve-static」を使ったり、ルーティングで index.html を表示させる、といった処理が必要なのでしょうか。。。?
2020-07-08 追記
@nestjs/serve-static 追加
今回は Nest.js で静的ファイルを扱うことができるようにする「@nestjs/serve-static」を使って解決することにしました。
npm install --save @nestjs/serve-static
server/main.ts
import { NestFactory } from '@nestjs/core';
import { ApplicationModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create(ApplicationModule);
app.setGlobalPrefix('api');
app.useStaticAssets(join(__dirname, '..', 'browser'));
await app.listen(4200);
}
bootstrap();
NgRx に触れてみる 1
はじめに
前から気になっていた NgRx に触れてみます。
NgRX は Redux のように状態を管理するためのものです。
今回は Redux のチュートリアルにある tic-tac-toe をなんとなく真似してみることにします。
環境
- Angular: 10.0.2
- @ngrx/store: 9.2.0
- @ngrx/effects: 9.2.0 (Effects で使用)
Install
npx ng add @ngrx/store
元のプロジェクト
まずは NgRx を使用しないプロジェクトを用意しますよ( Effects に関連するところは後で)。
app.component.html
<app-board></app-board>
board.component.html
<div class="board">
<app-square
*ngFor="let s of state.squares; let i = index"
[props]="s" (onClick)="updateSquare(i)"></app-square>
</div>
board.component.ts
import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
@Component({
selector: 'app-board',
templateUrl: './board.component.html',
styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
public state: BoardState;
constructor() {
this.state = this.initialState();
}
ngOnInit(): void {
}
public updateSquare(index: number) {
const squares = this.state.squares.slice();
squares[index] = (this.state.nextIsX)? '✕': '◯';
this.state = {
nextIsX: ! this.state.nextIsX,
squares
};
}
private initialState(): BoardState {
return {
nextIsX: true,
squares: Array(9).fill(null),
};
}
}
board.component.css
.board {
display: flex;
flex-direction: row;
flex-wrap: wrap;
height: auto;
width: 21vw;
}
board-state.ts
import { SquareValue } from './square/square-value';
export type BoardState = {
nextIsX: boolean,
squares: Array<SquareValue>
};
square.component.html
<button class="square" (click)="click()">{{props}}</button>
square.component.ts
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { SquareValue } from './square-value';
@Component({
selector: 'app-square',
templateUrl: './square.component.html',
styleUrls: ['./square.component.css']
})
export class SquareComponent implements OnInit {
@Input() props: SquareValue;
@Output() onClick: EventEmitter<void> = new EventEmitter();
constructor() { }
ngOnInit(): void {
}
public click() {
this.onClick.emit();
}
}
square.component.css
.square{
height: 7vw;
width: 7vw;
}
実行すると 9 個のボタンが表示され、クリックすると順に「◯」「✕」が表示されます。
Action, Reducer, Store を追加する
Action を作る
「何が起こったか」(今回はボタンがクリックされた) を表現する Action を作ります。
game.actions.ts
import { createAction, props } from '@ngrx/store';
export const updateSquare = createAction('[Game] updateSquare',
props<{index: number}>());
Action を作る上で必須になるのは第一引数である「type」のみです。
ですが、今回はクリックされたボタンの配列番号が欲しかっったので「props」を第二引数として追加しています。
Reducer を作る
次は「Action によって状態がどう変わるか」(今回は「◯」または「✕」が入る)を表現する Reducer を追加します。
game.reducer.ts
import { createReducer, on, Action } from "@ngrx/store";
import { BoardState } from './board/board-state';
import { updateSquare } from './game.actions';
function initialState(): BoardState {
return {
nextIsX: true,
squares: Array(9).fill(null),
};
}
function getUpdatedState(lastState: BoardState, index: number): BoardState {
const squares = lastState.squares.slice();
squares[index] = (lastState.nextIsX)? '✕': '◯';
return {
nextIsX: ! lastState.nextIsX,
squares
};
}
const _gameReducer = createReducer(initialState(),
on(updateSquare, (state, {index}) => getUpdatedState(state, index)));
export function reducer(state: BoardState | undefined, action: Action) {
return _gameReducer(state, action);
}
今回は Action を一つだけ登録していますが、二つ以上の場合は下記のように追加していきます。
const _gameReducer = createReducer(initialState(),
on(updateSquare, (state, {index}) => getUpdatedState(state, index)),
on(resetSqure, (state) => initialState()));
StoreModule に Reducer を登録する
Reducer を有効にし、状態管理が行われるようにするため StoreModule に登録します。
app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { BoardComponent } from './tic-tac-toe/board/board.component';
import { SquareComponent } from './tic-tac-toe/board/square/square.component';
import * as gameReducer from './tic-tac-toe/game.reducer';
@NgModule({
declarations: [
AppComponent,
BoardComponent,
SquareComponent
],
imports: [
BrowserModule,
StoreModule.forRoot({game: gameReducer.reducer})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Component に接続する
「Board」 Component で今の状態の取得、 Action 、 Reducer を使って状態の更新をできるようにします。
board.component.ts
import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { updateSquare } from '../game.actions';
@Component({
selector: 'app-board',
templateUrl: './board.component.html',
styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
// receive current state
public state$: Observable<BoardState>;
constructor(private store: Store<{game: BoardState}>) {
this.state$ = store.pipe(select('game'));
}
ngOnInit(): void {
}
public updateSquare(index: number) {
// call the reducer to update the state
this.store.dispatch(updateSquare({index}));
}
}
ここで重要だと思うのは「select('game')」です。
引数の「game」は app.module.ts で登録したものと合わせる必要があります。
ここが違っているとエラーは発生しませんが状態を受け取ることができない、という問題が発生します(ちょっとハマりました)。
board.component.html
<div class="board">
<app-square
*ngFor="let s of (state$|async)?.squares; let i = index"
[props]="s" (onClick)="updateSquare(i)"></app-square>
</div>
現状
ここまでで下記のような流れができています。
Effects を使う
次は Effects(@ngrx/effects) を使ってみます。
名前からすると「side-effects (副作用)」なのかな?と思いきや、ネットワークリクエストなど、外部リソースにアクセスする場合に使われるようです。
Reducer のように、「store.dispatch」を使って Action を発行することで処理を呼び出します。
流れとしてはこんな感じ。。。のはず。
Install
npx ng add @ngrx/effects
元のプロジェクトに Service を追加する
今回のサンプルでは、ボタンがクリックされ、状態が変化したタイミングでゲームの勝敗がついたかを確認する処理を追加します。
簡易化のためサーバーにアクセスなどはしてません。
check-game-finished.ts
import { SquareValue } from './board/square/square-value';
import { GameResult } from './game-result';
type CheckTarget = {
index: number,
match: readonly CheckTarget[],
unmatch: readonly CheckTarget[]
};
const emptyTarget = {
match: [],
unmatch: []
};
const checkTargets: CheckTarget = {
index: 0,
match: [{
index: 1,
match: [{
index:2,
...emptyTarget
}],
unmatch: []
}, {
index: 3,
match: [{
index: 6,
...emptyTarget
}],
unmatch: []
}, {
index: 4,
match: [{
index: 8,
...emptyTarget
}],
unmatch: []
}],
unmatch: [{
index: 4,
match: [{
index: 1,
match: [{
index: 7,
...emptyTarget
}, {
index: 3,
match: [{
index: 5,
...emptyTarget
}],
unmatch: []
}],
unmatch: []
}],
unmatch:[{
index: 8,
match: [{
index: 2,
...emptyTarget
},{
index: 6,
...emptyTarget
}],
unmatch: []
}]
}]
};
function getTargetIndices(squares: readonly SquareValue[]): { circles: number[], crosses: number[]} {
const circles = new Array<number>();
const crosses = new Array<number>();
for(let i = 0; i < squares.length; i++) {
if (squares[i] == '◯') {
circles.push(i);
} else if(squares[i] == '✕') {
crosses.push(i);
}
}
return { circles, crosses };
}
function check(targetIndices: readonly number[],
nextTarget: CheckTarget): boolean {
if (targetIndices.some(i => nextTarget.index == i)) {
if (nextTarget.match.length <= 0) {
return true;
}
if (nextTarget.match.some(t => check(targetIndices, t))) {
return true;
}
} else {
if (nextTarget.unmatch.length <= 0) {
return false;
}
if (nextTarget.unmatch.some(t => check(targetIndices, t))) {
return true;
}
}
return false;
}
export function checkGameFinished(squares: readonly SquareValue[]): GameResult {
console.log("check");
const targets = getTargetIndices(squares);
if (check(targets.circles, checkTargets)) {
return { finished: true, winner: '◯' };
} else if (check(targets.crosses, checkTargets)) {
return { finished: true, winner: '✕' };
}
return { finished: false, winner: null };
}
ゲームの勝敗確認の処理はもう少し簡略化したい。。。
game-score.service.ts
import { Injectable } from '@angular/core';
import { SquareValue } from './board/square/square-value';
import { Observable, of } from 'rxjs';
import { GameResult } from './game-result';
import * as GameChecker from './check-game-finished';
@Injectable({
providedIn: 'root'
})
export class GameScoreService {
constructor() { }
public checkResult(squares: readonly SquareValue[]): Observable<GameResult> {
return of(GameChecker.checkGameFinished(squares));
}
}
Effects を追加する
game-result.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { GameScoreService } from './game-score.service';
import { checkScore, gameFinished } from './game.actions';
import { EMPTY } from 'rxjs';
@Injectable()
export class GameResultEffects {
/* this would be fired by dispatching "checkScore" action. */
checkResult$ = createEffect(() => this.actions$.pipe(
ofType(checkScore),
/* call service */
mergeMap(action => this.gameScoreService.checkResult(action.squares)
.pipe(
/* make an action fired */
map(result => gameFinished({ result })),
catchError(error => {
console.error(error);
return EMPTY;
})
))));
constructor(
private actions$: Actions,
private gameScoreService: GameScoreService
) {}
}
game.action.ts
import { createAction, props } from '@ngrx/store';
import { SquareValue } from './board/square/square-value';
import { GameResult } from './game-result';
...
export const checkScore = createAction('[Score] checkScore',
props<{ squares: readonly SquareValue[]}>());
export const gameFinished = createAction('[Game] finished',
props<{ result: GameResult }>());
Effects を登録する
Reducer と同じように app.module.ts に Effects を追加します。
app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { BoardComponent } from './tic-tac-toe/board/board.component';
import { SquareComponent } from './tic-tac-toe/board/square/square.component';
import * as gameReducer from './tic-tac-toe/game.reducer';
import { EffectsModule } from '@ngrx/effects';
import { GameResultEffects } from './tic-tac-toe/game-result.effects';
@NgModule({
declarations: [
AppComponent,
BoardComponent,
SquareComponent
],
imports: [
BrowserModule,
StoreModule.forRoot({game: gameReducer.reducer}),
EffectsModule.forRoot([GameResultEffects])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Component に接続する(失敗)
こちらも Reducer のときと同じように Component に状態受け取りと Action 発行の処理を追加します。
board.component.ts
import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { updateSquare, checkScore } from '../game.actions';
import { GameResult } from '../game-result';
import { SquareValue } from './square/square-value';
@Component({
selector: 'app-board',
templateUrl: './board.component.html',
styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
public state$: Observable<BoardState>;
public result$: Observable<GameResult>;
constructor(private store: Store<{game: BoardState, result: GameResult }>) {
this.state$ = store.pipe(select('game'));
this.state$.subscribe(s => this.onValueChanged(s.squares));
this.result$ = store.select(state => state.result);
this.result$.subscribe(r => console.log(r));
}
ngOnInit(): void {
}
public updateSquare(index: number) {
this.store.dispatch(updateSquare({index}));
}
private onValueChanged(squares: readonly SquareValue[]) {
this.store.dispatch(checkScore({squares}));
}
}
課題と対策
「gameScoreService.checkResult」の処理実行などは問題ないにもかかわらず、「this.result$.subscribe(r => console.log(r));」が実行されません。
結局 Effects から発行されている「gameFinished」の Action をハンドリングする Reducer がいない、ということが原因でした。
Effects から Action を発行すると、良い感じに StoreModule が状態をさばいてくれる、とかいう都合の良い展開にはならない、ということですね。
ということで Reducer を追加します。
game-result.reducer.ts
import { GameResult } from './game-result';
import { createReducer, on, Action } from '@ngrx/store';
import { gameFinished } from './game.actions';
function initialState(): GameResult {
return {
finished: false,
winner: null
};
}
const _resultReducer = createReducer(initialState(),
on(gameFinished, (state, {result}) => {
if (result.finished) {
return result;
}
return state;
}));
export function reducer(state: GameResult | undefined, action: Action) {
return _resultReducer(state, action);
}
app.module.ts
...
import * as gameResultReducer from './tic-tac-toe/game-result.reducer';
import { EffectsModule } from '@ngrx/effects';
import { GameResultEffects } from './tic-tac-toe/game-result.effects';
@NgModule({
...
imports: [
BrowserModule,
StoreModule.forRoot({game: gameReducer.reducer, result: gameResultReducer.reducer}),
EffectsModule.forRoot([GameResultEffects])
],
...
これで result$ の変化も受け取ることができました。
今回は何もしていませんが、 Service で例外が発生した場合 Effects から Action として発行することができるため、実運用ではこちらも Reducer で処理することになると思います。
次回に続く。。。はず
TypeORMに触れてみる 2
はじめに
今回はマイグレーションと外部キーの設定について。
Migration
まずマイグレーションから。 TypeORM のコマンドでファイルを作ります。
npx typeorm migration:create -n AddUpdateDate
生成されたファイルは src/migration に出力されます。
1591186678545-AddUpdateDate.ts
import {MigrationInterface, QueryRunner} from "typeorm"; export class AddUpdateDate1591186678545 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { } public async down(queryRunner: QueryRunner): Promise<void> { } }
そのままだと何も実行されないので、処理を追加します。 今回は "updateDate" カラムを "SampleUser" に追加します。
1591186678545-AddUpdateDate.ts
import {MigrationInterface, QueryRunner} from "typeorm"; export class AddUpdateDate1591186678545 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(`ALTER TABLE "SampleUser" ADD COLUMN "updateDate" date DEFAULT current_timestamp NOT NULL`); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(`ALTER TABLE "SampleUser" DROP COLUMN "updateDate"`); } }
重要(多分)な点として、 SQL ではデータ型で何もしないと Nullable になる、ということです。
Entity クラスはデフォルトで Not Null なので、実行するとエラーになって??になる、という。
マイグレーション実行(失敗)
"migration:run" コマンドを実行すると処理が反映されるはず。
npx typeorm migration:run
しかし実際はエラーになります。
import {Entity, PrimaryGeneratedColumn, Column} from "typeorm"; ^^^^^^ SyntaxError: Cannot use import statement outside a module at wrapSafe (internal/modules/cjs/loader.js:1101:16) at Module._compile (internal/modules/cjs/loader.js:1149:27) at Object.Module._extensions..js (internal/modules/cjs/loader.js:1205:10) at Module.load (internal/modules/cjs/loader.js:1034:32) at Function.Module._load (internal/modules/cjs/loader.js:923:14) at Module.require (internal/modules/cjs/loader.js:1074:19) at require (internal/modules/cjs/helpers.js:72:18) at Function.PlatformTools.load (C:\Users\example\Documents\workspace\gen-typeorm-sample\node_modules\typeorm\platform\PlatformTools.js:114:28) at C:\Users\example\Documents\workspace\gen-typeorm-sample\node_modules\typeorm\util\DirectoryExportedClassesLoader.js:39:69 at Array.map (<anonymous>)
マイグレーションを実行する(成功)
これは、今回 "ts-node" を使用していることが原因のようです。
下記のように実行すれば OK です。
npx ts-node ./node_modules/typeorm/cli.js migration:run
また、処理を取り消したい場合は下記のようにすれば OK です。
npx ts-node ./node_modules/typeorm/cli.js migration:revert
"migration:revert" を実行すると、最後に実行した処理が取り戻されます。
"migration" テーブル
"migration:run" を最初に実行すると、"migration" というテーブルが追加されます。
反映された処理はこのテーブルに記録されているため、下記のような操作をすると、最後の処理が無視されます。
- マイグレーションファイルによってテーブルを作る。
- PgAdmin などを使って直接 1.のテーブルを削除する。
- "migration:run" で 1.を再実行する。
いつ新しい ID が発行されるか
たとえば2つのテーブル(テーブル A 、B とする)に一度に追加する場合。
B が A の ID を参照しているとします。
これを実現しようとすると、 A にデータを追加したあと、その ID を B に渡す必要があります。
では、A の ID はいつ発行されるでしょうか。
というのを試してみます。
index.ts
import "reflect-metadata"; import {createConnection } from "typeorm"; import { SampleUser } from "./entity/sample-user"; createConnection().then(async connection => { const queryRunner = connection.createQueryRunner(); await queryRunner.startTransaction(); try{ const thirdUser = new SampleUser(); thirdUser.firstName = 'Hello4'; thirdUser.lastName = 'World4'; thirdUser.age = 43; console.log("Before saving"); console.log(thirdUser); await queryRunner.manager.save(thirdUser); console.log("Before committing"); console.log(thirdUser); queryRunner.commitTransaction(); console.log("After"); console.log(thirdUser); }catch(error) { await queryRunner.rollbackTransaction(); } finally { await queryRunner.release(); } }).catch(error => console.log(error));
結果
Before saving SampleUser { id: -1, firstName: 'Hello4', lastName: 'World4', age: 43 } Before committing SampleUser { id: 6, firstName: 'Hello4', lastName: 'World4', age: 43 } After SampleUser { id: 6, firstName: 'Hello4', lastName: 'World4', age: 43 }
ということで、 "queryRunner.manager.save" 実行後に ID が取得できます。
外部キー
"SampleUser" の "id" を外部キーとして設定するにはどうすれば良いでしょうか。
@ManyToOne(), @OneToMany(), @OneToOne(), @JoinColumn() を使って実現できます。
sample-user.ts
import {Entity, PrimaryGeneratedColumn, Column, OneToMany, UpdateDateColumn} from "typeorm"; import { Post } from "./post"; @Entity("SampleUser") export class SampleUser { @PrimaryGeneratedColumn() id: number = -1; @Column({ type: 'text' }) firstName: string = ''; @Column({ type: 'text' }) lastName: string = ''; @Column() age: number = -1; @UpdateDateColumn({ type: 'timestamptz' }) updateDate: Date = new Date(); @OneToMany(type => Post, post => post.user) posts: Post[]|null = null; }
post.ts
import {Entity, PrimaryGeneratedColumn, Column, ManyToOne, UpdateDateColumn, OneToOne, JoinColumn} from "typeorm"; import { SampleUser } from "./sample-user"; import { Category } from "./category"; @Entity("Post") export class Post { @PrimaryGeneratedColumn() id: number = -1; @ManyToOne(type => SampleUser, user => user.posts) user: SampleUser = new SampleUser(); @OneToOne(() => Category) @JoinColumn([{ name: 'categoryId', referencedColumnName: 'id' }]) category: Category = new Category(); @Column({ type: 'text' }) title: string = ''; @Column({ type: 'text' }) article: string = ''; @UpdateDateColumn({ type: 'timestamptz' }) updateDate: Date = new Date(); }
category.ts
import {Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn} from "typeorm"; @Entity("Category") export class Category { @PrimaryGeneratedColumn() id: number = -1; @Column({ type: 'text' }) name: string = ''; @UpdateDateColumn({ type: 'timestamptz' }) updateDate: Date = new Date(); }
テーブル生成を synchronize: true ですべきかマイグレーションファイルですべきか
これまで、 ormconfig.json で "synchronize" を true に設定して実行していました。
そのため、実行したときにテーブルが存在していなければ自動で生成されていました。
しかし、テーブルの追加はマイグレーションファイルに "CREATE TABLE" を書いても実現できます。
1591186678544-CreateSampleUserTable.ts
import {MigrationInterface, QueryRunner} from "typeorm"; export class AddCreateSampleUserTable1591186678544 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(`CREATE TABLE "SampleUser" ( id serial PRIMARY KEY, "firstName" text NOT NULL, "lastName" text NOT NULL, "age" integer NOT NULL)`); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(`DROP TABLE "SampleUser"`); } }
どちらを使うべきでしょうか。
という答えはまだ見つけられていないのですが、少なくともマスタテーブル(基本的に更新せず、他のテーブルから参照するためのデータを持つテーブル)については "synchronize" では解決できないので、マイグレーションファイルからテーブル生成も行おうかと思います。
1591356501422-AddCategories.ts
import {MigrationInterface, QueryRunner} from "typeorm"; import { Category } from "../entity/category"; export class AddCategories1591356501422 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { const programming = new Category(); programming.name = 'Programming'; await queryRunner.manager.save(programming); const book = new Category(); book.name = 'Book'; await queryRunner.manager.save(book); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(`DELETE FROM "Category"`); } }
TypeORMに触れてみる 1
はじめに
久々すぎて書き方も忘れつつある今日このごろ。 プライベートでは TypeScript ばかり触れているわけなのですが、今回は TypeORM を試すことにしました。
あらかじめ DB(テーブル)を用意する方法もありますが、今回はコードから生成してみます。
- GitHub - typeorm/typeorm
- TypeORM - Amazing ORM for TypeScript and JavaScript (ES7, ES6, ES5). Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms.
インストールとプロジェクトの作成
インストール
もちろん TypeScript や TypeORM 、 PostgreSQL のドライバーは必要なのですが、それに加えて "reflect-metadata" が必要になります。
npm install --save typeorm reflect-metadata pg typescript tsc npm install --save-dev @types/node
あと必須ではありませんが、 JavaScript に変換せずに TypeScript そのままで実行できるよう、"ts-node" も入れておきます。
npm install --save ts-node
準備
TypeORM のコマンドでプロジェクトを作ることもできます。
npx typeorm init --name gen-typeorm-sample --database postgres
今回はこれで生成されるファイルを参考に、順を追ってプロジェクトを作成してみます。
フォルダーとファイルの追加
とりあえず tsconfig.json を作っておきます。
npx tsc --init
lib やデコレーターの有効化などを行います。
tsconfig.json
{ "compilerOptions": { /* Basic Options */ "incremental": true, "target": "es5", "module": "commonjs", "lib": [ "es5", "es6" ], "sourceMap": true, "outDir": "./build", /* Strict Type-Checking Options */ "strict": true, "noImplicitAny": true, "strictNullChecks": true, "alwaysStrict": true, /* Additional Checks */ "noUnusedLocals": true, /* Module Resolution Options */ "moduleResolution": "node", "esModuleInterop": true, /* Experimental Options */ "experimentalDecorators": true, "emitDecoratorMetadata": true, /* Advanced Options */ "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }
で、 TypeORM で使用するファイルを格納するフォルダーを追加しておきます。
typeorm-sample(my project name) L node_modules L src L entity L migration L package-lock.json L package.json L tsconfig.json
PostgreSQL の接続文字列などを設定する、 ormconfig.json を追加します。
ormconfig.json
{ "type": "postgres", "host": "localhost", "port": 5432, "username": "test", "password": "test", "database": "test", "synchronize": true, "logging": true, "cache": false, "entities": [ "src/entity/**/*.ts" ], "migrations": [ "src/migration/**/*.ts" ], "subscribers": [ "src/subscriber/**/*.ts" ], "cli": { "entitiesDir": "src/entity", "migrationsDir": "src/migration", "subscribersDir": "src/subscriber" } }
※テーブルはコードから生成できますが、データベースは先に作っておく必要があります。
というのを忘れて実行したせいでエラーになりました\(^o^)/
Entity クラスを追加する
sample-user.ts
import {Entity, PrimaryGeneratedColumn, Column} from "typeorm"; @Entity("SampleUser") export class SampleUser { @PrimaryGeneratedColumn() id: number = -1; @Column() firstName: string = ''; @Column() lastName: string = ''; @Column() age: number = -1; }
テーブル名はファイル名から決定されます。
そのため、今回のサンプルでそのまま実行するとテーブル名は "sample_user" となります。
@Entity() に "SampleUser" のように設定することで、テーブル名を指定できます。
余談ですが、 TypeORM のコマンドでプロジェクトを生成すると "user" というテーブルが作られることになるのですが、 "SELECT * FROM user" としてしまうと PostgreSQL のユーザー情報が出てきてしまうので、コマンドで作る場合もテーブルの名前は変えたほうが良いかもしれません。
テーブルを作成する
テーブルは最初にデータベースのテーブルにアクセスしたときに作成されます。
[2020-06-05 Update] テーブルが作成されるのはデータベースに接続したとき、でした。。。
index.ts
import "reflect-metadata"; import {createConnection} from "typeorm"; import { SampleUser } from "./entity/sample-user"; createConnection().then(async connection => { const user = new SampleUser(); user.firstName = "Timber"; user.lastName = "Saw"; user.age = 25; // テーブルが生成される. await connection.manager.save(user); // 上のコードがない場合、ここで空のテーブルが生成される. const users = await connection.manager.find(User); console.log("Loaded users: ", users); }).catch(error => console.log(error));
Primary key(自動採番)
"@PrimaryGeneratedColumn()" を指定すると、カラムの型は "serial" になり自動採番されます。
注意が必要なのは、この値(今回のサンプルでは "id" )は自分で値を変えることができない、ということです。
Create のときは "id" に入れた値が無視され、 Update のときは "id" の値を変えると別物として扱われます(新規登録される)。
NOT NULL
デフォルトでは "NOT NULL" としてカラムの型が設定されます。
Nullable にする必要がある場合は @Column() にオプションを追加します。
sample-user.ts
@Entity("SampleUser") export class SampleUser { ... @Column({ nullable: true }) age: number = -1; }
@Column() で指定できるオプションはデフォルト値や型などいろいろな種類があります。 typeorm/entities.md at master · typeorm/typeorm · GitHub
また、 PostgreSQL で使用できる型の種類はこちらから。
typeorm/entities.md at master · typeorm/typeorm · GitHub
トランザクションを利用する
トランザクションを利用するのはどうすれば良いでしょうか。
調べてみるといくつか方法があるようですが、今回は QueryRunner を使うことにしました。
index.ts
import "reflect-metadata"; import {createConnection } from "typeorm"; import { SampleUser } from "./entity/sample-user"; createConnection().then(async connection => { const queryRunner = connection.createQueryRunner(); await queryRunner.startTransaction(); try{ const secondUser = new SampleUser(); secondUser.firstName = 'Hello2'; secondUser.lastName = 'World2'; secondUser.age = 36; await queryRunner.manager.save(secondUser); queryRunner.commitTransaction(); }catch(error) { await queryRunner.rollbackTransaction(); } finally { await queryRunner.release(); } }).catch(error => console.log(error));
注意点として、 "queryRunner.startTransaction()" や "queryRunner.rollbackTransaction()" は "connection.manager" には効かない、ということです。 上記の "queryRunner.manager.save(secondUser)" を "connection.manager.save(secondUser)" にしても動作はしますが、途中で例外が発生してもロールバックはされません。
続きます。
Babelを使ってみようとしたらハマった話とドラッグで要素を動かしてみたい話
はじめに
投稿が大変遅れましたが、この記事は JavaScript Advent Calendar 2019 の13日目の記事です。
要素をドラッグして動かしたい。
せっかくなので JavaScript で書いてみよう。
と思ったら Babel ハマったという話です。
Babel と Webpack を使う
TypeScript で書いていたときは、(Promise などを除いて) IE でも動くようトランスパイルしてくれていました。
じゃあ JavaScript では?というと、いくつか方法はあると思うのですが、今回は Babel を使ってみることにしました。
このときと同じく、 Webpack も使います。
インストール
インストール、セットアップは下記ドキュメント( Build system として Webpack を選択)に従えば OK でした。
Babel 、 Webpack に加え、ドラッグで要素を移動させるときに使用する RxJs も追加しておきます。
npm install --save webpack webpack-cli rxjs babel-loader @babel/core @babel/preset-env
設定ファイル
Babel ver.7 では .babelrc というファイルを設定ファイルとして使用するようです(ググってると babel.config.js とか出てきて混乱しました)。
.babelrc
{ "presets": ["@babel/preset-env"], }
サンプルコード(失敗)
MainPage.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Hello</title> </head> <body> <button onclick="Page.doSomething()">Click</button> </body> <script src="../js/main.bundle.js"></script> </html>
mainPage.js
import { AsyncSample } from "./asyncSample"; export async function doSomething() { const s = new AsyncSample(); await s.doSomethingAsync(); }
asyncSample.js
export class AsyncSample { async doSomethingAsync() { return new Promise((resolve, reject) => { console.log('Hello World!'); resolve(); }) .catch((reason) => reject(reason)); } }
webpack.config.js
var path = require('path'); module.exports = { mode: 'development', entry: { 'main': './src/js/mainPage.js', }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" } ] }, resolve: { extensions: [ '.js' ] }, output: { filename: '[name].bundle.js', path: path.resolve(__dirname, './dist/js'), library: 'Page', libraryTarget: 'umd' } };
そしてエラー
npx webpack -w とやるとコンパイルは成功します。
で、ボタンを押すとエラー発生と。
ReferenceError: regeneratorRuntime is not defined
regeneratorRuntime ってなんぞ?というと、async/await を Babel でトランスパイルするときに使われるもののようです。
なんとかする
で、これが足りないのでどうするか。
ぐぐると @babel/polyfill の話( ver.7.4? で Deprecated )が大量に出てきてこれまた混乱するのですが、今は regenerator-runtime と core-js を使うようです。
npm install --save regenerator-runtime core-js
どう使うのか、というと、 async/await や Promise を使うところで import します。
mainPage.js
import { AsyncSample } from "./asyncSample"; import "regenerator-runtime/runtime"; export async function doSomething() { const s = new AsyncSample(); await s.doSomethingAsync(); }
asyncSample.js
import "core-js"; import "regenerator-runtime/runtime"; export class AsyncSample { async doSomethingAsync() { return new Promise((resolve, reject) => { console.log('Hello World!'); resolve(); }) .catch((reason) => reject(reason)); } }
直接使っているわけでないものをインポートするのはしっくりこない気はしますが、別途 Polyfill を入れなくても Promise などに対応してくれるのはありがたいですね。
※ここより先は後ほど追記します(..)_