1. y-temp4

    誤字の修正

    y-temp4
Changes in body
Source | HTML | Preview

こんにちはGAOGAOの代表をしております @tejitak です。GAOGAOアドベントカレンダー 17日目の記事です。GAOGAOのスタートアップスタジオにて、最近お手伝いしている海外のお客様案件にてTypeORMを導入しています。

今回の記事では、TypeORMとはなんぞや?という方を対象として、まだ比較的日本語記事が少ないTypeORMについてのご紹介します。

Node.jsのORM

Node.jsでサーバーサイドを実装する際にはExpressを使うことが多いと思います。ExpressはサーバーサイドのWebフレームワークで、データベースを扱うORMは自由に導入することができます。以下代表的なORMを紹介します。

  • mongoose
    公式ドキュメント: https://mongoosejs.com/
    MongoDBはJavaScriptとの相性の良さから昔からNode.jsの多くのプロジェクトで見かけるDBラッパーライブラリとしていmongooseは人気です。ドキュメント指向DBの特性的に柔軟なモデルの定義が可能になり、CRUDだけではなくドキュメント間の参照の展開なども簡単に行うことができます。

  • Sequelize
    公式ドキュメント: https://sequelize.org/
    Nodejsをサーはバーサイドとして使っていて、MongoDB以外の選択肢、Postgres、MySQL、MariaDB、SQLite、 Microsoft SQL Serverを使いたいならSequelizeが現在一番メジャーな選択肢のようです。ちなみに筆者は使ったことないため、ここではあまり詳しいことは語れません。

  • TypeORM
    公式ドキュメント: https://typeorm.io/
    TypeScriptと相性の良い比較的新しいNode.js用のORM。リレーショナルDBサポート、DB migrationの仕組みがある、RepositoryパターンもしくはActiveRecordパターンどちらも対応可、などの特徴があります(詳しくは後述)。Webを見る限りSequelizeよりもTypeScriptとの相性が良いという点でTypeORMの方が評判が良さそうです。

TypeORMはNode.jsの開発のスタンダードになるか?

もちろんケースバイケースではあるため、スタンダードになるというと言い切ってしまうのは大げさですが、 現時点でNodejs + TypeScriptによるサーバーサイド開発をする際には他の選択肢と比べてオススメできるポイントが多い です。その理由を以下に述べていきます。

Nodejsによるサーバーサイド開発の不安を取り除ける存在である

これまでNode.jsがサーバーサイドの多くのエンジニアから敬遠される理由の多くは、実は以下の先入観によるものが多いと思います。

  • DBがMongoDB一択なので不安
    → 否! MySQLなどRDB使えます

  • JSは型がない
    → 否! TypeScriptで書ける(最近のJS界隈ではむしろTSの方が主流になってきている)

  • Rails/Laravelと比べると情報量・技術者が少ない
    → この点に関しては、おそらくYes。

Node.js/Express/routing-controllers + TypeScript + TypeORM + MySQL であればある程度マイナスイメージを払底できるのではないでしょうか。

サーバーサイドで多人数でチームを組む必要がある場合、エンジニア人材の確保という観点でRails/Laravelと比べて難しい状況もあるかもしれないです。

しかし、最近はフロントはどのプロジェクトでもJSerが必要になってきている時代です。上記不安が取り除けるのであれば、今後サーバーも同じ言語でかけるならフロントと人材との壁が低くなりますし、コードの再利用の観点でも良い点があります。もしJS/TSを一通りできてサーバーサイドの理解があるメンバーで少人数で進めるのであれば、フロント<>サーバーの垣根なくフルスタックに開発を進められるので、非常に効率が良いです。

TypeORMの特長

  • 公式ドキュメントはわかりやすく、ボリュームもそこまでないので学習コストは低め
  • リレーショナルDBサポート (MySQL / MariaDB / Postgres / CockroachDB / SQLite / Microsoft SQL Server / Oracle / sql.js)
  • Entityから差分を自動検知するDB migrationの仕組み
  • RepositoryパターンもしくはActiveRecordパターンどちらも採用可
  • SQLのQueryBuilderやTransactionの仕組みも提供
  • その他色々。詳しくは公式ドキュメント

ディレクトリ構成

TypeORMを導入したプロジェクトでは、一般的には以下のようになると思います(lintなどの設定は割愛)。

<your-api>/
|- ormconfig.json
|- package.json
|- tsconfig.json
|- src/
  |- controller/
  |- entity/
  |- middleware/
  |- migration/
  |- repository/
  |- service/
  |- index.ts 

モデルの実装例

例えば、フルーツの名称とイメージ画像のURLを持つ Fruit というEntityを定義すると以下のようになります。

  • src/entity/Fruits.ts
import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";

@Entity()
export class Fruits {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @Column()
    image_url: string;
}

新規でモデルを作成する際にはこれだけで良いです。MySQLなどにテーブルやカラムを手動やmigrationファイルを追加する必要はありません。後述しますが、migrationのコマンドを実行することにより、自動的にテーブルやカラムの更新のSQLを流すmigrationファイルを生成してくれます。

以下、もう少し具体的にオススメのポイントを紹介します。

おすすめ1: TypeScriptによるモデル定義で変更に強いコードが書ける

TypeORMのモデル(Entity)定義がTypeScriptの型推論が利くため、例えば、上記のFruitのプロパティ名やプロパティの型を変更した際に参照している部分が未対応の場合、きちんとコンパイルエラーになってくれます。結果、これまでのORMと比べてバグが起きにくいプログラムを書くことができます。(mongooseやSequelizeではTSベースではなく複雑なのでTSによる恩恵を十分に受けられないようです)

おすすめ2: Routing Controllerとasync/awaitで快適コーディングライフ

アノテーションベースでExpressのコントローラーなど実装ができるライブラリです。今回の案件でTypeORMと共に初めて使いましたが、快適ですた。例えば、簡単な上で定義したフルーツEntityのCRUD APIの実装例として以下のように書けます。

  • src/controller/api/v1/FruitsController.ts
import { Authorized, Get, UploadedFile, JsonController, Post, Req, Res, Put, Body, Delete, Param } from 'routing-controllers';
import { getCustomRepository } from 'typeorm';
import { Express } from 'express';
import { FruitsRepository } from '~/repository/FruitsRepository';
import { putObject } from '~/util/s3Util';

const repository = getCustomRepository(FruitsRepository);

@JsonController('/api/v1/fruits')
export class FruitsController {
  // Fruit一覧のJSONを返すGET API
  @Authorized()
  @Get('/')
  async getAll(@Req() req: any, @Res() res: any) {
    const list = await repository.find();
    return list;
  }

  // 指定されたidのFruitのJSONを返すGET API
  @Authorized()
  @Get('/:id')
  get(@Param('id') id, @Res() res: any) {
    return repository.findOneOrFail(id);
  }

  // 新規のFruitを作るAPI
  @Authorized()
  @Post('/')
  async create(@Body() body: any, @UploadedFile('imageFile') imageFile: Express.Multer.File, @Res() res: any) {
    // フルーツの画像ファイルがあったらS3にアップロードする
    body.image_url = await putObject(imageFile.buffer);
    const model = repository.create(body);
    await repository.save(model);
    return model;
  }

  // idを指定したFruitを更新するAPI
  @Put('/:id')
  async update(
    @Param('id') id,
    @Body() body: any,
    @UploadedFile('imageFile') imageFile: Express.Multer.File,
    @Res() res: any,
  ) {
    // フルーツの画像ファイルがあったらS3にアップロードする
    body.image_url = await putObject(imageFile.buffer);
    await repository.update(id, body);
    return { success: true };
  }

  // idを指定したFruitを削除するAPI
  @Authorized()
  @Delete('/:id')
  async delete(@Param('id') id, @Res() res: any) {
    await repository.delete(id);
    return { success: true };
  }
}

上記の例は、FruitモデルのCRUD処理の例でフルーツ名と画像を渡すとそれぞれ処理されます。
すっきり書けると思います。

  • パラメーターの受け渡しや /middleware 以下のモジュールをアノテーションで指定可能
  • ルーティングの定義もアノテーションなので、別途ルーティング用のファイルは不要
  • controllerの実装でasync/awaitが使える (Node.jsではよく苦しめられる非同期処理、よりスッキリ書けますね)
  • @Authorized() アノテーションをつけると authorizationChecker というmiddlewareとして実装した認証を突破したリクエストのみが使えるAPIとなります。その辺は、詳しくは今回の記事では割愛します。

おすすめ3: migrationが割と良い感じ

TypeORMには entityとして読み込ませたモデル定義と現在の接続先のMySQLのカラム定義との差分を取って自動でmigrationファイルを生成してくれる仕組み があります。

コマンドは typeorm migration:generate -n で行います。詳しくはこちら

例えば新規のモデルファイルを作成しコマンドを流すと、テーブルを生成するSQL用のファイルがmigrationディレクトリ以下に自動で生成されます。

  • entity/Fruits.ts を作成したのち typeorm migration:generate -n CreateFruits 実行すると migration/1576569322125-CreateFruits.ts というファイルが自動生成される
import {MigrationInterface, QueryRunner} from "typeorm";

export class CreateFruits1576569322125 implements MigrationInterface {

    public async up(queryRunner: QueryRunner): Promise<any> {
        await queryRunner.query("CREATE TABLE `fruits` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL, `image_url` varchar(255) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB", undefined);
    }

    public async down(queryRunner: QueryRunner): Promise<any> {
        await queryRunner.query("DROP TABLE `fruits`", undefined);
    }

}

次に、カラムの変更/削除の時の例は以下の様になります。
例えばFruitのモデルに以下の様な作成 created_at や更新日時 updated_at のカラムを追加してみます。

  • src/entity/Fruits.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Timestamp } from 'typeorm';

@Entity()
export class Fruits {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  image_url: string;

  @CreateDateColumn()
  created_at: Timestamp;

  @UpdateDateColumn()
  updated_at: Timestamp;
}

再度migration生成コマンドを実行すると、以下の様に差分を検知して、migrationファイルを生成してくれます。

  • 自動生成された migration/1576569652336-UpdateFruits.ts
import {MigrationInterface, QueryRunner} from "typeorm";

export class UpdateFruits1576569652336 implements MigrationInterface {

    public async up(queryRunner: QueryRunner): Promise<any> {
        await queryRunner.query("ALTER TABLE `fruits` ADD `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)", undefined);
        await queryRunner.query("ALTER TABLE `fruits` ADD `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)", undefined);
    }

    public async down(queryRunner: QueryRunner): Promise<any> {
        await queryRunner.query("ALTER TABLE `fruits` DROP COLUMN `updated_at`", undefined);
        await queryRunner.query("ALTER TABLE `fruits` DROP COLUMN `created_at`", undefined);
    }

}

便利ですね。生成したmigrationファイル記載のSQLを実際に実行するには、typeorm migration:run というコマンドえばOKです。また、Rollbackなどもコマンド一つで可能です。

どのフレームワークのmigrationでも注意は必要なことではありますが、うっかりリファクタリングでプロパティ名を変更したなどでデータ喪失が起きうるので注意が必要です。本番環境で流す前は、ステージングで確認や、バックアップはきちんと取っておきましょう。

参考までに、今回のプロジェクトでは環境によってDBの接続先などを変えるために package.json のscriptsで以下のように定義して npm run migration:generate:production などとして実行しています。

  • package.json
  "scripts": {
    ...
    "typeorm": "./node_modules/.bin/ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
    "migration:generate": "npm run typeorm -- migration:generate --config config/local/ormconfig.json -n Initialize",
    "migration:generate:staging": "npm run typeorm -- migration:generate --config config/staging/ormconfig.json -n Initialize",
    "migration:generate:production": "npm run typeorm -- migration:generate --config config/production/ormconfig.json -n Initialize",
    "migration:run": "npm run typeorm -- migration:run --config config/local/ormconfig.json",
    "migration:run:staging": "npm run typeorm -- migration:run --config config/staging/ormconfig.json",
    "migration:run:production": "npm run typeorm -- migration:run --config config/production/ormconfig.json",
    ...
  }

おすすめ4: 一対多・多対多のリレーションを簡単に実現できる

一対多・多対多それぞれEntityでアノテーションを利用することで簡単に実現できます。
例えば、 Baskets というEntityを新規で作成して、一つのバスケットには複数のフルーツが入る様な設計をしてみます。

  • src/entity/Baskets.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Fruits } from './Fruits';

@Entity()
export class Baskets {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @OneToMany(
    type => Fruits,
    fruits => fruits.basket,
  )
  fruits: Fruits[];
}

バスケットから見ると、一つに対して複数のフルーツに関連つけられるので、 @OneToMany を使います。この様にすることで、basketから関連付けられたFruits一覧をrelationでORM上で簡単に取り出すことができる様になります。

逆にFruits側では以下の様に @ManyToOne を用いて定義します。

  • src/entity/Fruits.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  Timestamp,
  ManyToOne,
  JoinColumn,
} from 'typeorm';
import { Baskets } from './Baskets';

@Entity()
export class Fruits {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  image_url: string;

  @CreateDateColumn()
  created_at: Timestamp;

  @UpdateDateColumn()
  updated_at: Timestamp;

  @ManyToOne(
    type => Baskets,
    basket => basket.fruits,
  )
  @JoinColumn()
  basket: Baskets;
}

@JoinColumn で定義したbasketは、mysqlの実際のカラムには basketId という名前で生成され、TypeORMを通じてfruitからbasketへリレーションを簡単に展開することができます。

同じような要領で @ManyToMany を使用することで多対多のリレーション定義も可能です。

その他TypeORMにはまだまだオススメな機能があります

  • Repositoryパターン/ActiveRecordパターン選べる
  • Transactionもアノテーションベース @Transaction() が使える
  • 柔軟にSQLを実現できる QueryBuilder が提供されている

今回はTypeORMのざっくりとしたご紹介ということで割愛しますが、この辺りは詳しくは別記事で書こうと思います。

その他雑感

TypeORM vs mongoose

mongoDBを使用した場合に一番人気の高いラッパーライブラリmongoose。辛いと呼ばれる所以は、クラスターの管理、トラブルシューティングのノウハウが少ない、jsonの柔軟性が高すぎて構造を定義できない、indexの最適化が難しい、migrationの仕組みがないなどがあると思います。(DB Transactionに対応していないなどもありましたが、そちらはMongoDBが最近対応したようです)

そのような観点で、TypeORMを用いてRDBを簡単に操作できるのであれば、あまりMongoDB/mongooseをNode.jsのプロジェクトで積極的に使う理由はないかなと思いました。

TypeORM vs firebase

完全CRUDオンリーシンプルサーバーサイドであればfirebaseオススメします。ただし、「relationを多用する必要があるとき」、「認証をカスタマイズするとき」、「適切な権限の設定する必要があるとき」、「CloudFunctionsを使わざるをえないとき」、など、多少firebaseでも複雑な処理を記述する必要が出てくる場合は今回のスタックである Node.js/Express/routing-controllers + TypeScript + TypeORM + MySQL の導入を検討してみてください。ルーティングの制御やSQLを自由に使えるという点も考慮すると、メリットが多いと思います。

TypeORM vs Laraval

ルーティングの仕組みなど含めるとLaravelの方が包括的なWebフレームワークです。そもそも言語は違うので比較するのはなんともですが、TypeORMのEntityに対応するところが、LaravelのEloquentとなります。この点に関しては、ほとんど同等の実装の実現を同等のお手軽さでできると思っています。ただし、migrationの仕組みに関して、Laravelはmigrationファイルが大元→モデルを定義しますが、TypeORMではモデルが大元→migrationファイルを生成という逆の発想で作られているので、もしLaravel使いがTypeORMを使うことがあればmigrationの実現方式は大きな差異の一つです。

まとめ

今回の私がTypeORMのオススメできる点をまとめてみました。

今回の開発プロジェクトでは、3名のフルスタックJSエンジニアが担当し、それぞれ フロントやサーバーなど垣根がなく、機能単位の縦割りで爆速実装を進めることができました

もしフルスタックJSerがいてスクラッチで開発する機会があるならTypeORM導入してみてはどうでしょうか?まだ日本の情報も少ないので、ぜひコメント/記事お待ちしています。