vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

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 個のボタンが表示され、クリックすると順に「◯」「✕」が表示されます。

f:id:mslGt:20200704165550j:plain

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>

現状

ここまでで下記のような流れができています。

f:id:mslGt:20200704165630j:plain

Effects を使う

次は Effects(@ngrx/effects) を使ってみます。

名前からすると「side-effects (副作用)」なのかな?と思いきや、ネットワークリクエストなど、外部リソースにアクセスする場合に使われるようです。

Reducer のように、「store.dispatch」を使って Action を発行することで処理を呼び出します。

流れとしてはこんな感じ。。。のはず。

f:id:mslGt:20200704165653j:plain

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" というテーブルが追加されます。 f:id:mslGt:20200607163135j:plain

反映された処理はこのテーブルに記録されているため、下記のような操作をすると、最後の処理が無視されます。

  1. マイグレーションファイルによってテーブルを作る。
  2. PgAdmin などを使って直接 1.のテーブルを削除する。
  3. "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(テーブル)を用意する方法もありますが、今回はコードから生成してみます。

インストールとプロジェクトの作成

インストール

もちろん 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));

f:id:mslGt:20200603205731p:plain

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 などに対応してくれるのはありがたいですね。

※ここより先は後ほど追記します(..)_

Angular で PWA に挑戦したい話

はじめに

※2019/12/06 一旦書き終わったところだけ公開いたします。
残りは仕事が終わったあと追記予定です(..)_

この記事は PWA Advent Calendar 2019 の 6 日目の記事です。

JSConf China 2019 で聴いて以来気になってはいた PWA(Progressive Web Application)。

Advent Calender に勢いで参加してみたは良いものの、思った以上に手が回らなかったので今回はとりあえず作って動かしてみる、ということで。

せっかくなので SheetJS を使って Excel 読み込み→表示をしてデスクトップアプリ感?を楽しんでみたいと思います。

インストール

とりあえず雑にプロジェクトを作りまして。。。

ng new pwa-xlsx-sample

ng add で PWA 用のパッケージを追加してもらう、と。

ng add @angular/pwa --project pwa-xlsx-sample

更に SheetJS を追加しておきます。

npm install xlsx --save

で ng s とかすればよいのかしら?と思ったらそうではなく、ビルドしてサーバー上で動かす( ng s ではなく)必要があるようです。

npm install http-server --save
ng build --prod

ようやく?準備ができたので、早速実行してみます。

npx http-server -p 8080 -c-1 dist/pwa-xlsx-sample

PC の Chrome などではどこからたどれば良いのかわからなかったのですが、 AndroidChrome からは Home に追加することも確認できました。

PWA 完全に理解した。

【TypeScript】type の使いどころが知りたい話

はじめに

※2020/04/12
お前の仕事はいつまで続くんだという話ですが、ようやく書き終わりました。。。

この記事は TypeScript Advent Calendar 2019 の 6 日目の記事です。

ふとコードを書いていて気になったのが、例えばサーバー側から受け取った値を JSON に変換したいとき、 class を使うべき? interface を使うべき? それとも type ?ということでした。

C# であれば class を選択することになります。

では TypeScript の場合は?

というか、 C# にも存在する class と interface はともかく、 type って何よ?どう使うの?となったので調べてみることにしました。

type について

まず type (正確には Type Aliases) とは何か、という話から。

alias (別名)の名前通り、型に対する別名をつけるためのものです。

使い方はこんな感じです。

type SampleType = string;
let sample: SampleType = "hello";

type SampleType2 = {
    id: number,
    name: string,
}
let sample2: SampleType2 = {
    id: 0,
    name: "world",
}

interface と type aliases

共通点

よく比較される interface と type aliases ですが、実際使い方によっては全く区別がつかない場合もあります。

interface SampleInterface {
    id: number;
    name: string;
}
let sample3: SampleInterface = {
    id: 1,
    name: "!!!",
}

どちらも JavaScript への変換後は消えてしまうためあくまで TypeScript 内での振る舞いに留まる点や、同じプロパティを持っていればその interface / type aliases として扱われる点など。

よく言われる(?)のが、(type aliases は継承ができないため)継承が必要なら interface を使う、という内容。

ver.3.8.3 現在、Classが実装する(implements)場合はどちらも可能です。

interface ISample{
    name: string;
}
type TSample = {
    message: string
};
/** OK */
class IClassSample implements ISample {
    public name: string = 'Hello'
}
/** OK */
class TClassSample implements TSample {
    public message: string = 'World'
}

interface / type aliases で違いはあるのか?その使い分けは?というのが気になったので調べてみることにしました。
(口調がいかがでしたかブログっぽいな)

違い

declaration merging

interface の大きな特徴(だと思う)は "declaration merging" です。
同じ namespace 、同じファイル、同じモジュール内に同名の interface がある場合、ひとまとまりの interface として扱われます。

interface ISample{
    name: string;
}
interface ISample{
    message: string;
}
function main() {
    // 1つ目、2つ目の ISample が統合される.
    const iSample: ISample = {
        name: 'Hello',
        message: 'World'
    };
}

同様のことは type alias で Cross 型を使って再現することはできますが、明示的に書く必要があります。


interface ISample{
    name: string;
}
type TSample = {
    message: string
};
type TCrossSample = ISample & TSample
function main() {
    const tSample: TCrossSample = {
        name: 'Hello',
        message: 'World'
    };
}

extends

これも interface のみの特徴ですが、 interface は interface や type aliases を拡張 (extends) できます。


interface ISample{
    name: string;
}
type TSample = {
    message: string
};
interface IExtendSample extends ISample, TSample {
    id: number;
}
function main() {
    const iSample: IExtendSample = {
        id: 0,
        name: 'Hello',
        message: 'World'
    };
}

これも同じく type alias で Cross 型を使って再現できます。

定義できる型

interface で定義できるのは下記のような形式のみです。
(propertyName はなくてもOK)


interface InterfaceName {
    propertyName: Type
};

type aliases はあくまで型に別名をつけるだけ、ということもあってか、色々な種類の型を定義できます。


interface ISample{
    name: string;
}
type TSample = {
    message: string
};
/** Union type */
type TUnionSample = ISample|TSample;
/** Cross type */
type TCrossSample = ISample & TSample & { id: number; }
/** Tuple */
type TTupleSample = [string, number];
/** Function */
type TFunctionSample = (message: string) => void;
/** Object */
type TObjectSample = {
    message: string
};
/** Mapped type */
type TBase = {
    id: number,
    name: string
};
type TMappedSample = { [P in keyof TBase]: string; };
/** Conditional type */
type TConditionalSample = T extends string? 'string': 'other';
function main() {
    const crossSample: TCrossSample = {
        id: 0,
        name: 'Hello',
        message: 'World'
    };
    const tupleSample: TTupleSample = ['hello', 0];
    const mappedSample: TMappedSample = {
        id: '0',
        name: 'Hello'
    };
    const conditionalSample: TConditionalSample = 'string';
}

Union 型、 Tuple は type aliases のみで定義できるため、 interface は使えません。

いつ type aliases を使うべきか?

interface と type aliases の違いはわかった気がしますが、 ではいつ type aliases を使うか。
(どちらも使用可能な場合)

Effective TypeScript によると、プロジェクトのスタイルに合わせる・または "declaration merging" が必要かどうかで判断するべき、と。

個人的には C# に慣れていることもあり、振る舞い(メソッド)を定義して Class で実装する場合は interface 、Data Transfer Object の用に、データを格納するためのオブジェクトは type aliases を使いたい気がします。

参照

ASP.NET Core + Entity Framework Core のプロジェクトを 2.2 から 3.0 にアップグレードした話

はじめに

この記事は C# Advent Calendar 2019 の二日目の記事です。

冷静に考えるとこれを C# の話として書いていいのか?と今更ながら思ったりもするのですが、プロジェクトは C# で書いてるし、 C# に関連する話もないではないから許してください(..)_

環境

  • .NET Core : ver.2.2.402 (from), ver.3.0.100 (to)
  • Microsoft.EntityFrameworkCore : ver.2.2.6 (from), ver.3.0.0 (to)
  • Npgsql.EntityFrameworkCore.PostgreSQL : ver.2.2.4 (from), ver.3.0.1 (to)
  • Windows10 : ver.1903
  • Rider : ver.2019.2.3
  • PostgreSQL : ver.12.0

元のプロジェクト

まずはアップグレードする前のプロジェクトを用意します。

UpgradeSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>netcoreapp2.2</TargetFramework>
        <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
        <RuntimeIdentifier>win-x86</RuntimeIdentifier>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.App" />
        <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.2.6" />
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.2.4" />
    </ItemGroup>
</Project>
  • RuntimeIdentifier は Self-contained app (実行環境に .NET Core が入っていなくても動作するよう出力されたアプリケーション) のために追加しています。
  • Program.cs も存在しますが、特にプロジェクト作成時から変更なく、 3.0 にアップグレード後も触らないため省略しています。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Serialization;
using UpgradeSample.Models;
using UpgradeSample.Products;

namespace UpgradeSample
{
    public class Startup
    {
        private IConfigurationRoot Configuration { get; }
        public Startup(IHostingEnvironment env)
        {
            // config ファイル読み込み.
            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", false, true)
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", false, true)
                .AddEnvironmentVariables();
            Configuration = builder.Build();
        }
        public void ConfigureServices(IServiceCollection services)
        {
            // DB Connect
            services.AddDbContext<UpgradeSampleContext>(options =>
                options.UseNpgsql(Configuration["DbConnect"]));

            // 生成される JSON のプロパティ名を大文字始まりで出力する.
            services.AddMvc()
                .AddJsonOptions(options =>
                {
                    options.SerializerSettings.ContractResolver = new DefaultContractResolver();
                });
            // DI
            services.AddScoped<IProductService, ProductService>();
        }
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseStaticFiles();
            app.UseMvc();
        }
    }
}

ApiController.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using UpgradeSample.Models;
using UpgradeSample.Products;

namespace UpgradeSample.Controllers
{
    public class ApiController: Controller
    {
        private readonly IProductService _productService;
        public ApiController(IProductService productService)
        {
            _productService = productService;
        }
        [Route("/")]
        [Route("/Home")]
        public string Index()
        {
            return "hello";
        }
        [HttpGet]
        [Route("/products")]
        public async Task<List<Product>> GetProducts([FromQuery] string[] names)
        {
            return await _productService.GetProductsAsync(names);
        }
    }
}

Product.cs

using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;

namespace UpgradeSample.Models
{
    [Table("Product")]
    public class Product
    {
        [Column("ProductId")]
        [JsonProperty("ProductId")]
        public int? ProductId { get; set; }

        [Column("ProductName")]
        [JsonProperty("ProductName")]
        public string ProductName { get; set; }
    }
}
  • JsonProperty は今回あまり役に立ってはいませんが、 ASP.NET Core プロジェクト内と JSON で名前が異なるときなどに活躍します。

UpgradeSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace UpgradeSample.Models
{
    public class UpgradeSampleContext: DbContext
    {
        public UpgradeSampleContext(DbContextOptions<UpgradeSampleContext> options)
            :base(options)
        {
        }
        public DbSet<Product> Products { get; set; }
    }
}

IProductService.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using UpgradeSample.Models;

namespace UpgradeSample.Products
{
    public interface IProductService
    {
        Task<List<Product>> GetProductsAsync(string[] names);
    }
}

ProductService.cs

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using UpgradeSample.Models;

namespace UpgradeSample.Products
{
    public class ProductService: IProductService
    {
        private readonly UpgradeSampleContext _context;
        public ProductService(UpgradeSampleContext context)
        {
            _context = context;
        }
        public async Task<List<Product>> GetProductsAsync(string[] names)
        {
            return await _context.Products
              .Where(p => names.Length <= 0 ||
                  names.Any(n => p.ProductName == n))
              .ToListAsync();
        }        
    }
}

いかん。。。元のコードを並べるだけで結構スペースを取ってしまった。。。

3.0 にアップグレードする

気を取り直して早速 3.0 にアップグレードしてみますよ。

プロジェクトのバージョンを更新するには、 1 つまたは 2 つのファイルを変更する必要があります。

global.json

{
  "sdk": {
    "version": "3.0.100"
  }
}

UpgradeSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>netcoreapp3.0</TargetFramework>
        <RuntimeIdentifier>win-x86</RuntimeIdentifier>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.0.0" />
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.0.1" />
    </ItemGroup>
</Project>
  • global.json は Rider でプロジェクトを作った場合は出力されていましたが、 Powershelldotnet new empty を実行して作った場合はありませんでした。
  • Rider ではもう一つ、 Run/Debug configuration のバージョンも上げる必要があるかもしれません。
  • これらのファイルを変更後、自動で実行されない場合は dotnet restore を実行します。

エラーを修正する

メジャーバージョンアップということで、主に下記に関連してエラーや警告が出ました。

  1. Endpoint Routing への変更
  2. Newtonsoft.Json から System.Text.Json への変更
  3. Entity Framework Core で、 Linq のクエリをクライアントで実行しない
  4. RuntimeIdentifier がロードできない

  5. Migrate from ASP.NET Core 2.2 to 3.0 - Microsoft Docs

  6. What's new in ASP.NET Core 3.0 - Microsoft Docs
  7. Comparing Startup.cs between the ASP.NET Core 3.0 templates: Exploring ASP.NET Core 3.0 - Part 2 - Andrew Lock | .NET Escapades
  8. My First Look at ASP.NET Core 3.0 - Shawn Wildermuth

1. MVC から Endpoint Routing への変更

2.2 ではルーティングを使うのに AddMvc, UseMvc を使っていましたが、 3.0 からは AddControllers, UseRouting, UseEndpoints に変わります。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Identity;
using Models;
using UpgradeSample.Models;
using UpgradeSample.Products;
using UpgradeSample.Users;

namespace UpgradeSample
{
    public class Startup
    {
        ...
        public void ConfigureServices(IServiceCollection services)
        {
            ...            
            // for setting JSON parameter names pascal case.
            services.AddControllers()
                .AddJsonOptions(options =>
                    options.JsonSerializerOptions.PropertyNamingPolicy = null);
            // DI
            ...
        }
        public void Configure(IApplicationBuilder app, IHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseStaticFiles();
            
            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                // [Routing("")] を使ってルーティングする場合
                endpoints.MapControllers();
            });
        }
    }
}

また今回は使用していませんが、認証などを行う場合、 UseRouting の後、 UseEndpoints の前に UseAuthentication, と UseAuthorization を記載順に実行する必要があります(順序が違ったり UseAuthentication が実行されていないと実行時に例外が発生します)。

操作しようとした人が誰かを確認した後、その人がその操作を実行できるか?を確認する、ということで、考えれば順序がわかるのですが、実行時に例外が発生するという辺りはちょっとややこしいですね。

2. Newtonsoft.Json から System.Text.Json への変更

3.0 にアップグレードしたときにコンパイルエラーが発生したのは、この Json 関連だけでした。

今回のサンプルで対象となるのは 2 箇所です。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Identity;
using Models;
using UpgradeSample.Models;
using UpgradeSample.Products;
using UpgradeSample.Users;

namespace UpgradeSample
{
    public class Startup
    {
        ...
        public void ConfigureServices(IServiceCollection services)
        {
            ...            
            // for setting JSON parameter names pascal case.
            services.AddControllers()
                .AddJsonOptions(options =>
                    options.JsonSerializerOptions.PropertyNamingPolicy = null);
            // DI
            ...
        }
        ...

Product.cs

using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;

namespace UpgradeSample.Models
{
    [Table("Product")]
    public class Product
    {
        [Column("ProductId")]
        public int? ProductId { get; set; }
        [Column("ProductName")]
        [JsonPropertyName("ProductName")]
        public string ProductName { get; set; }
    }
}

基本的な使い方は変わらず、書き方が微妙に違っている、という感じです。

3. Entity Framework Core で、 Linq のクエリをクライアントで実行しない

個人的には一番影響が大きいのでは?と思っているところです。

Entity Framework Core で、 Linq を使ったコード( Where )が SQL に変換できない場合、例外を発生するようになりました。

これによってパフォーマンスの悪化が防げる、というメリットと、 List を Linq を使って検索するつもりで雑にアクセスしていると実行時例外を発生させまくるというデメリットがあります。

ProductService.cs

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using UpgradeSample.Models;

namespace UpgradeSample.Products
{
    public class ProductService: IProductService
    {
        ...
        public async Task<List<Product>> GetProductsAsync(string[] names)
        {
            // 実行時に例外発生
            return await _context.Products
              .Where(p => names.Length <= 0 ||
                  names.Any(n => p.ProductName == n))
              .ToListAsync();
        }        
    }
}

対策としては、 Any など SQL に変換できない条件で検索したい場合、先に AsEnumerable を実行する、というのが考えられます。

ProductService.cs

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using UpgradeSample.Models;

namespace UpgradeSample.Products
{
    public class ProductService: IProductService
    {
        ...
        public async Task<List<Product>> GetProductsAsync(string[] names)
        {
            return await Task.Run(() => 
            {
              return _context.Products
                .AsEnumerable()
                .Where(p => names.Length <= 0 ||
                  names.Any(n => p.ProductName == n))
                .ToList();
            });
        }        
    }
}

ややこしいのが、例えば Where の中身が例えば Where(p => p.ProductId == id) のような内容なら( id は引数となどで渡されているとして)わざわざ AsEnumerable を含む必要がない、というところ。

全部に AsEnumerable を含む、というのはおかしな気がするので、できるだけ Any などを使わず、 SQL に変換できるように書くのが良い。。。のかしら?

BeginTransaction

今回使用していませんが、 C# 8 で using 句に await が使えるようになった影響で、トランザクション開始時に実行する _context.Database.BeginTransaction() が await できるようになりました。

await using (var transaction = _context.Database.BeginTransaction())

public async Task SetUserNameAsync(ApplicationUser user, string userName, CancellationToken cancellationToken)
{
    await using var transaction = _context.Database.BeginTransaction();
    try
    {
      user.UserName = userName;
      _context.Users.Update(user);
      _context.SaveChanges();
      transaction.Commit();
    }
    catch (Exception e)
    {
        transaction.Rollback();
    }
}

Rider 先生曰く await using(var transaction = _context.Database.BeginTransaction()){ } より上記のように {} を使わない書き方を推奨、ということで、なぜかしら?と思っていたのですが、どうも単にネストを浅くしようとしているだけのようです。

4. RuntimeIdentifier がロードできない

3.0 にアップグレード後、 win-x86 のパッケージ?がロードできない旨の警告の後、全 C# クラスがエラーになる問題が発生してビビったという話です。

あれこれ試してみた結果、リストア -> 再ビルド で解決しました。

Single-file executable

また RuntimeIdentifier に関連して? Single-file executable というものが追加されています。

これまで Self-contained app で出力すると大量にファイルが出力されていたのですが、この設定をしておくとファイル数がぐっと少なくなります。

UpgradeSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>netcoreapp3.0</TargetFramework>
        <RuntimeIdentifier>win-x86</RuntimeIdentifier>
        <PublishSingleFile>true</PublishSingleFile>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="3.0.0" />
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.0.0" />
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.0.1" />
    </ItemGroup>
</Project>

これで通常通り dotnet publish -c Release -r win-x86 を実行すると下記ファイルが出力されます。

おわりに

まだおっかなびっくり試しているのと、慌ててやるのはダメゼッタイというところではありますが、壊滅的な状況になることもなく 3.0 に上げることができました。

サポート期間的なこともありますが、パフォーマンスなど多くの改善点が含まれているだけに、早めに更新していきたいところです。

C# Advent Calendar, 明日は @takayoshitanaka さんです。

よろしくお願いいたします(..)_