51
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

NestJS の TypeORM モジュールを利用したクリーンなデータストアアクセスの実装

この記事は NestJS Advent Calendar 2019 9日目の記事です。前日は @euxn23 さんによる「NestJS Service 初期化 非同期」でした。

本日はいよいよデータベースとの接続を行う TypeORM とそのモジュールを紹介いたします。一般的な Web アプリケーションサーバーとしての役割を NestJS に期待している人にとっては、もっとも抑えておきたい内容ではないでしょうか。

今日は TypeScript ベースの O/R Mapper の TypeORM と、NestJS の公式モジュールを利用方法をご紹介いたします。今回の範囲はデータベースの作成、ユーザーテーブルの作成、 CRUD の実装までです。

環境について

  • Node.js v12.x を前提としています。
  • MySQL と接続するため、Docker のインストールおよび docker-compose コマンドが有効であることを推奨します。

サンプル

例によって例のごとく完成品サンプルがあります。

TypeORM について

TypeScript ベースの O/R Mapper です。reflect-metadata を活用して依存をうまく解決する TypeScript らしい手法をとっており、これまでまともな RDB 向けの ORM が不足していると言われ続けてきた Node.js において、「よくある」ORM を提供している珍しい例といえます。

TypeORM は、以下のような特徴があります。

  • 多彩なデータベースへのアクセス手法
    • ActiveRecord のようなシンプルなデータベースアクセス
    • レポジトリとクエリビルダを利用した柔軟なデータベースアクセス
    • 特に何も考えずクエリを叩く
  • 多くのデータストアに対応
    • RDB(MySQL, PostgreSQL, Oracle, etc...)
    • NoSQL(MongoDB)
    • ブラウザ(Cordova, Expo)
  • デコレーターベースのテーブル構造の定義
  • マイグレーション機能
    • マイグレーションファイルでの手動マイグレーション
    • 開発環境で便利な自動マイグレーション

これまであえてデータストアという表現を続けてきたのは、上記に挙げたように TypeORM には RDB に限らず、多くのデータストアに対応しているためです。とはいえ、実際には RDB との接続でしか使わないはずなので、今後は RDB とのアクセスを前提にご紹介いたします。

TypeORM の魅力として、ActiveRecord パターン寄りに愚直に実装し、徐々に他の実装方法へ移しつつ開発ができること、自動マイグレーションで手元で雑に開発するときが便利なところ、DBクエリのログがクリーンなところあたりでしょうか。

NestJS のモジュールとしては、テスティングに関する機能が比較的豊富な点も挙げられます。

今回は可能な限りシンプルな実装で、ユーザー作成と簡易的なログインを実装してみたいと思います。

TypeORM と NestJS を組み合わせた開発をはじめる

プロジェクトの初期化

CLI でプロジェクトを初期化した上で、 users の諸々を作成しておいてください。

$ nest new day9-typeorm
$ nest g service users
$ nest g controller users
$ nest g module users

さらに今回は、 RDB 向けのファイルも作成します。ホスト OS の MySQL を汚したくないかたは以下の docker-compose.yml を作成。v3 にしたい人は適宜書き換えてください。

docker-compose.yml
version: '2'
services:
  db_data:
    image: busybox
    volumes:
      - /var/lib/mysql
  db:
    image: mysql:5.7
    volumes_from:
      - db_data
    ports:
      - "3306:3306"
    environment:
      MYSQL_DATABASE: nestday7
      MYSQL_ROOT_PASSWORD: password

事前に Docker 環境と Nest を立ち上げておいてください。

$ docker-compose up -d # 裏で実行
$ yarn start:dev

ただ localhost:3000 がデフォなだけで大丈夫です。

> curl -L -v http://localhost:3000
* Rebuilt URL to: http://localhost:3000/
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Content-Type: text/html; charset=utf-8
< Content-Length: 12
< ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
< Date: Mon, 09 Dec 2019 07:52:38 GMT
< Connection: keep-alive
<
* Connection #0 to host localhost left intact
Hello World!⏎

TypeORM モジュールのインストールと初期化

NestJS で TypeORM を利用する場合、オフィシャルの TypeORM module を利用できます。これは NestJS と深く統合されており、複雑な操作を必要としない場合非常に便利に利用できます。

込み入った処理を実装していく場合、モジュールを利用せず直接 TypeORM を利用したほうが都合が多いことも多いのですが、単に CRUD + α を実装する分には利点が上回ります。

TypeORM モジュールは、対応したいデータベースのライブラリと共に、 Yarn あるいは NPM で導入します。

$ yarn add @nestjs/typeorm mysql typeorm

パッケージをインストールできたら、接続先設定を行います。今回は開発環境用の設定を前提として、まずは以下のように app.module.ts を設定します。

app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users/users.service';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'password',
      database: 'nestday7',
      entities: [],
      synchronize: true,
    }),
    UsersModule,
  ],
  providers: [UsersService],
})
export class AppModule {}

動作としては、 TypeORM に存在する DB とのコネクション用の機能 createConnection をラップした形の機能となっており、データベースへの設定を記述することで、実際の接続までを任せることができます。

なお、 synchronize フラグが true の場合、TypeORM は自動でマイグレーションを実装します。開発環境においては、このコードを記述した状態で実行するだけで、テーブルに変更があればマイグレーションが発生します。

試しにユーザーのデータを作ってマイグレーションを発生させてみましょう。ユーザーのレポジトリとなる user.entity.ts を以下のように作成します。

user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

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

  @Column({ length: 16 })
  screenName: string;

  @Column({ length: 128 })
  password: string;
}

ここは全て TypeORM の機能です。TypeORMでは、 RDB へのアクセス権を持った一つの塊を Entity として定義します。そのうえで、デコレータベースで情報を付与していきます。

今回は A_I な PK である id、エイリアスとなる screenName、そして password を設定しました。実際にこれを App から読み込んでみます。

読み込む際は、 Entity を import し、TypeOrmModule の設定にある entities へと追加します。

app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users/users.service';
import { UsersModule } from './users/users.module';
import { User } from './users/users.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'password',
      database: 'nestday7',
      entities: [User],
      synchronize: true,
    }),
    UsersModule,
  ],
  providers: [UsersService],
})
export class AppModule {}

この状態でサーバーを起動すると、以下のようなテーブル構造が自動で作られていることが確認できます。

スクリーンショット 2019-12-10 0.30.47.png

これで開発環境におけるデーターベース・テーブルの作成とアクセスは OK です。

TypeORM と NestJS を利用するだけで、ここまで簡単に開発の準備にとりかかることができます。

ユーザー作成とログインの実装

users.dto.ts
export class CreateUserDTO {
  screenName: string;
  password: string;
}

// tslint:disable-next-line:max-classes-per-file
export class LoginUserDTO {
  screenName: string;
  password: string;
}

次に、モジュールのお膳立ても必要です。ここで覚えておいてほしいのは、

  1. forFeature を利用して使いたいエンティティを明示的に定義した上で import する
  2. TypeOrmModule

を export するのみです。RDB に直接アクセスする層は、このような変更を施してください。

users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { User } from './users.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  exports: [TypeOrmModule],
  providers: [UsersService],
  controllers: [UsersController],
})
export class UsersModule {}

ここでの大きなポイントは、 TypeOrmModule.forFeature([User]) を import することです。基本的に Service や Controller から別 Service を DI 経由で呼び出したい場合などは、他の Module を import しますが、TypeOrmModule は、 forFeature によって、必要なエンティティの範囲だけを効率よく import できます。

逆に言えば、これをしない限り TypeOrmModule を Service から利用できないので注意してください。

import できたら、 Servie を定義します。特別なことはしていないため、TypeORM に関係する箇所のみ紹介します。

users.service.ts
import { Injectable } from '@nestjs/common';
import { User } from './users.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as crypto from 'crypto';

const SALT = '12345';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  private createPasswordDigest(password: string) {
    return crypto
      .createHash('sha256')
      .update(SALT + '/' + password)
      .digest('hex');
  }

  async findUserByScreenName(screenName: string): Promise<boolean> {
    const user = await this.userRepository.findOne({ where: { screenName } });
    return !!user;
  }

  async register(userData: Partial<User>): Promise<void> {
    if (await this.findUserByScreenName(userData.screenName)) {
      return Promise.reject(new Error('User is already taken.'));
    }
    await this.userRepository.insert({
      ...userData,
      password: this.createPasswordDigest(userData.password),
    });
    return;
  }

  async loginUser(screenName: string, password: string) {
    return await this.userRepository.findOne({
      where: {
        screenName,
        password: this.createPasswordDigest(password),
      },
    });
  }
}

最も注目すべきはコンストラクタインジェクションの部分です。

constructor(
  @InjectRepository(User)
  private readonly userRepository: Repository<User>,
) {}

@InjectRepository デコレータを利用した上で、対象となるレポジトリを定義します。こうすることで、 User へのアクセスを管理するためのレポジトリを TypeOrmModule が生成してくれます。型情報は、 TypeORM 本体の Repository の Generic でまかないます。

こうすることで、userRepository はオーソドックスなレポジトリ層として利用可能となります。 .findOne.find.insert など、ありがちなものがだいたいあるので、補完に任せていろいろ試してみても面白いかと思います。

今回はシンプルに insert と findOne だけを利用しました。また、 crypto と SALT で簡易的なパスワードハッシュ化を行っていますが、勿論本番ではもう少し真面目に向き合う必要があります。

最後に Controller の実装です。特に何もしていないので解説は省略します。

users.controller.ts
import {
  Controller,
  Post,
  HttpCode,
  HttpStatus,
  Body,
  HttpException,
} from '@nestjs/common';
import { CreateUserDTO, LoginUserDTO } from './users.dto';
import { UsersService } from './users.service';
import { User } from './users.entity';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post('register')
  @HttpCode(HttpStatus.CREATED)
  async createUser(@Body() createUserDTO: CreateUserDTO) {
    if (this.usersService.findUserByScreenName(createUserDTO.screenName)) {
      throw new HttpException(
        {
          status: HttpStatus.CONFLICT,
          error: `Screen name '${createUserDTO.screenName}' is already taken.`,
        },
        409,
      );
    }
    try {
      await this.usersService.register(createUserDTO);
    } catch (e) {
      throw new HttpException(
        {
          status: HttpStatus.INTERNAL_SERVER_ERROR,
          error: 'Internal server error.',
        },
        500,
      );
    }
    return;
  }

  @Post('login')
  async login(@Body() loginUserDTO: LoginUserDTO): Promise<User> {
    let user: User;
    try {
      user = await this.usersService.loginUser(
        loginUserDTO.screenName,
        loginUserDTO.password,
      );
    } catch (e) {
      throw new HttpException(
        {
          status: HttpStatus.INTERNAL_SERVER_ERROR,
          error: 'Internal server error.',
        },
        500,
      );
    }
    if (!user) {
      throw new HttpException(
        {
          status: HttpStatus.NOT_FOUND,
          error: 'User Not found.',
        },
        404,
      );
    }
    return user;
  }
}

実際にこんな感じで叩けると成功です。

terminal
potato4d@kanade ~/.g/g/n/a/day9-typeorm(feat/day9)>
http post http://localhost:3000/users/register screenName="potato4d" password="test"
HTTP/1.1 201 Created
Connection: keep-alive
Content-Length: 0
Date: Mon, 09 Dec 2019 15:43:34 GMT
X-Powered-By: Express



potato4d@kanade ~/.g/g/n/a/day9-typeorm(feat/day9)>
http post http://localhost:3000/users/register screenName="potato4d" password="test"
HTTP/1.1 409 Conflict
Connection: keep-alive
Content-Length: 65
Content-Type: application/json; charset=utf-8
Date: Mon, 09 Dec 2019 15:43:36 GMT
ETag: W/"41-hK7SF/ICZVruhWSUxj+nHKg1QFI"
X-Powered-By: Express

{
    "error": "Screen name 'potato4d' is already taken.",
    "status": 409
}

potato4d@kanade ~/.g/g/n/a/day9-typeorm(feat/day9)>
http post http://localhost:3000/users/login screenName="potato4d" password="111"
HTTP/1.1 404 Not Found
Connection: keep-alive
Content-Length: 40
Content-Type: application/json; charset=utf-8
Date: Mon, 09 Dec 2019 15:44:22 GMT
ETag: W/"28-3NcD3NrMC8nG27Ir4UCIxYSUEuo"
X-Powered-By: Express

{
    "error": "User Not found.",
    "status": 404
}

potato4d@kanade ~/.g/g/n/a/day9-typeorm(feat/day9)>
http post http://localhost:3000/users/login screenName="potato4d" password="test"
HTTP/1.1 201 Created
Connection: keep-alive
Content-Length: 110
Content-Type: application/json; charset=utf-8
Date: Mon, 09 Dec 2019 15:44:38 GMT
ETag: W/"6e-ipLGHQyKqnnccmG95m6vVDQJb/M"
X-Powered-By: Express

{
    "id": 3,
    "password": "6c614c4e12595a345079b78df3f5e702c6e7ecacae2e4a0430880666ccc55bb3",
    "screenName": "potato4d"
}

モックとテスティング

最後に、データベースアクセスに欠かせないテスティングについてご紹介します。データーベースが関わる層におけるテスティングは、大きく以下の二つに分けられるのではないでしょうか。

  1. 接続先の DB を変更して DB へと参照して結果を確認するテスティング(foo_test みたいなデーターベースを用意)
  2. そもそも DAO を抽象化して曖昧にテスティングを行う

これらのうち、1. が行われることも非常に多いですが、1. は forRoot の値を書き換えるだけでも実現可能ではあることなどから今回は少し込み入った実装になる 2. をご紹介いたします。

provider の抽象化とトークン作成

NestJS の TypeORM は、名前ベースでの依存解決を行っており、 @InjectRepository(User) などがそれにあたります。

普段の開発をしている分には、欲しいクラスの構造を渡すだけで要件を満たしてくれて便利なのですが、Service のテストを行う場合はこの仕様が少々厄介になります。

内部的に連携しているだけで、ユーザーランドからは可視化されていない構造のため、どうしてもテストにレポジトリの対象となるエンティティを指定できないためです。

それを解決するために、 NestJS の TypeORM モジュールでは getRepositoryToken を提供しています。

これは、 Entity -> Repository Token への変換器であり、テスト時にレポジトリのモックを作成する時に役立ちます。

モックオブジェクトを利用した Service のテスト

先にコードからご紹介します。

users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { User } from './users.entity';
import { getRepositoryToken } from '@nestjs/typeorm';

describe('UsersService', () => {
  let service: UsersService;

  beforeEach(async () => {
    const users = [
      {
        id: 1,
        screenName: 'potato4d',
        password:
          '6c614c4e12595a345079b78df3f5e702c6e7ecacae2e4a0430880666ccc55bb3', // "test"
      },
    ];
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: {
            findOne: ({
              where: { screenName },
            }: {
              where: { screenName: string };
            }) => users.find(user => user.screenName === screenName),
            insert: entity => users.push(entity),
          },
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
  });

  describe('register', () => {
    test('CREATED', async () => {
      expect.assertions(1);

      const registerResult = service.register({
        screenName: 'euxn23',
        password: '12345',
      });

      await expect(registerResult).resolves.toBe(undefined);
    });

    test('CONFLICT', async () => {
      expect.assertions(1);

      const registerPromise = service.register({
        screenName: 'potato4d',
        password: '12345',
      });

      await expect(registerPromise).rejects.toThrow('User is already taken.');
    });
  });
});

基本的には一般的な NestJS における Service のテストですが、以下が今回の特有の部分となります。

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: {
            findOne: ({
              where: { screenName },
            }: {
              where: { screenName: string };
            }) => users.find(user => user.screenName === screenName),
            insert: entity => users.push(entity),
          },
        },
      ],
    }).compile();

providers に対して getRepositoryToken を通して User 関連の機能を提供することを定義。そのうえで、モックとなるオブジェクトを用意したレポジトリを簡単にスクラッチしています。

勿論ここは何でも受け取るため、ここは何らかのライブラリで代替することも、 jest.fn とすることも可能です。

こうすることで、DBへのアクセスを防いだ上で、 Service を unit test することが可能となります。RDB への CRUD が支配的なアプリケーションではデータベース依存、 RDB への CRUD 以外のビジネスロジックが豊富な場合は、 Service でモック化がうまい使いかたでしょう。

テストデータベースへの接続については、docker-compose への設定が増えること、AsyncProvider の話が中心となるため本日は扱いませんが、前日の @euxn23 のアドベントカレンダーと、公式 Web サイトのサンプルを組み合わせることで実現できます。

アドベントカレンダー内でも、余裕があればどこかの日程でご紹介できればと。

おわりに

本日の記事では、 TypeORM と NestJS のモジュールでの簡単な利用方法を説明しました。

TypeORM の利用によって、これまでの記事の内容をぐっと実践に移しやすくなったかと思います。実装が進むにつれて循環参照などのいくつかの問題が出てくると思いますので、後半ではそのあたりについても取り扱う予定です。

明日は @euxn23 のターンです。よろしくお願いいたします。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
51
Help us understand the problem. What are the problem?