アレについて記す

Nest(TypeScript)で遊んでみる 〜DB連携編〜

Posted on September 16, 2018 at 16:00 (JST)

今回はTypeORMを利用してDB(MySQL5.6)へ接続する方法にて記載する。

  1. 事前準備: dockerでMySQLを立ち上げる
  2. ライブラリをインストール
  3. 接続情報設定
  4. RepositoryパターンでCRUD実装
  5. テスト時にモック差し替え
  6. はまりどころ

作成したコード [ nest-angular-sample: works/07_database ]

動作環境

OS: macOS High Sierra ver. 10.13.4
Nodejs: v8.10.0
npm: 5.6.0
nest(core): 5.3.6
Docker: 18.06.1-ce

1. 事前準備: dockerでMySQLを立ち上げる

ローカル開発用に起動/停止(イメージ破棄)のシェルスクリプトを作っておくと便利。

[docker/mysql/run_mysql.sh]

#!/bin/bash

SCRIPT_DIR=$(cd $(dirname $0);pwd)

docker run -v $SCRIPT_DIR/conf.d/:/etc/mysql/conf.d \
  -v $SCRIPT_DIR/ddl:/docker-entrypoint-initdb.d -d \
  -p 3306:3306 \
  --name mysql56 \
  -e MYSQL_ROOT_PASSWORD=secret \
  -e MYSQL_USER=user \
  -e MYSQL_PASSWORD=password \
  -e MYSQL_DATABASE=test_db \
  mysql:5.6
$ docker/mysql/run_mysql.sh
$ docker/mysql/destroy_mysql.sh

起動コマンドのオプションについてはDocker Hubを参照。

2. ライブラリをインストール

公式ドキュメント に従って進める。

$ npm install --save @nestjs/typeorm typeorm mysql

3. 接続情報設定

TypeOrmModule.forRoot を imports に追加する。
接続情報の設定方法は2通りある。

  1. ルートディレクトリにjsonファイルを用意する
  2. オブジェクトとして定義する

後々パスワード等を環境変数で指定することになるため、今回はオブジェクトとして定義した。

[src/app.module.ts]

import { TypeOrmModule } from '@nestjs/typeorm';
import { dbConfig } from './db.config';

@Module({
  imports: [
    TypeOrmModule.forRoot(dbConfig),
    LoggerModule,
    TasksModule,
    SamplesModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

[src/db.config.ts]

import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { TaskEntity } from './tasks/entities/task.entity';

export const dbConfig: TypeOrmModuleOptions = {
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  username: 'user',
  password: 'password',
  database: 'test_db',
  entities: [TaskEntity],
  synchronize: false,
};

entities の設定はドキュメントでは [__dirname + '/**/*.entity{.ts,.js}'] となっているが、start:hmr 指定で起動した際にエラーとなるためEntityクラスを直接指定する形にしている。

synchronize が true を設定することでEntityクラスへの変更がDBスキーマに反映されるようになるが、今回はDDLをDockerイメージ作成時に流しているため false にしている。

4. RepositoryパターンでCRUD実装

TypeORMはCRUDなどの基本的なDB操作用メソッドをRepository APIとして提供している。
typeormのRepositoryは型引数に操作対象テーブルのEntityクラスを指定し、DIすることで利用できる。

[src/tasks/entities/task.entity.ts]

import { Entity, Column, PrimaryColumn } from 'typeorm';

@Entity({name: 'tasks'})
export class TaskEntity {
  @PrimaryColumn({ length: 36 })
  id: string;

  @Column({ length: 256 })
  overview: string;

  @Column('int')
  priority: number;

  @Column()
  deadline: Date;
}

[src/tasks/tasks.module.ts]

import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { LoggerModule } from '../logger/logger.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TaskEntity } from './entities/task.entity';

@Module({
  imports: [TypeOrmModule.forFeature([TaskEntity]), LoggerModule],
  controllers: [TasksController],
  providers: [TasksService],
})
export class TasksModule {}

[src/tasks/tasks.service.ts]

import { Injectable } from '@nestjs/common';
import { Task } from './interfaces/task.interface';
import { v4 as uuidV4 } from 'uuid';
import { Logger } from '../logger/logger.service';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.dto';
import { TaskEntity } from './entities/task.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

@Injectable()
export class TasksService {
  constructor(
    @InjectRepository(TaskEntity)
    private readonly tasksRepository: Repository<TaskEntity>,
    private readonly logger: Logger,
  ) {}

  async findAll(): Promise<Task[]> {
    const entities = await this.tasksRepository.find();
    return entities as Task[];
  }

  async findById(id: string): Promise<Task | null> {
    const entity = await this.tasksRepository.findOne({ id });
    return entity as Task;
  }

  async create(dto: CreateTaskDto): Promise<Task> {
    const id = uuidV4();
    const entity = {...dto, id} as TaskEntity;
    await this.tasksRepository.insert(entity);
    return entity;
  }

  async update(dto: UpdateTaskDto): Promise<Task> {
    const entity = dto as TaskEntity;
    await this.tasksRepository.update(entity.id, entity);
    return entity;
  }

  async destroy(id: string): Promise<void> {
    await this.tasksRepository.delete(id);
  }
}

5. テスト時にモックに差し替える

DBと接続せずにテストを行う方法が公式ドキュメントに記載されている。

[src/tasks/tasks.controller.spec.ts]

import { Test, TestingModule } from '@nestjs/testing';
import { TasksController } from './tasks.controller';
import { Logger } from '../logger/logger.service';
import { TasksService } from './tasks.service';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.dto';
import { getRepositoryToken } from '@nestjs/typeorm';
import { TaskEntity } from './entities/task.entity';

describe('TasksController', () => {
  let app: TestingModule;

  beforeAll(async () => {

    const tasks = [
      {
        id: '6a414c88-4613-486d-9990-80c1de52eea4',
        overview: 'Learn TypeScript',
        priority: 1,
        deadLine: new Date('2018-09-10T08:55:28.087Z'),
      },
      {
        id: 'd8a4132e-72ec-490c-b5f5-a8bbc4509be6',
        overview: 'Learn Node.js',
        priority: 2,
        deadLine: new Date('2018-09-11T07:41:59.711Z'),
      },
    ];

    const MockRepository = {
      provide: getRepositoryToken(TaskEntity),
      useValue: {
        find: () => tasks,
        insert: entity => tasks.push(entity),
        update: (id, entity) => entity,
        delete: () => tasks.splice(0, 1),
      },
    };

    app = await Test.createTestingModule({
      controllers: [TasksController],
      providers: [Logger, TasksService, MockRepository],
    }).compile();
  });

  describe('#index', () => {
    it('should return 2 tasks', async () => {
      const sut = app.get<TasksController>(TasksController);
      const actual = await sut.index();
      expect(actual.tasks).toHaveLength(2);
    });
  });

  describe('#create', () => {
    it('should return a task having an id', async () => {
      const sut = app.get<TasksController>(TasksController);
      const param = {
        overview: 'Learn TypeScript',
        priority: 1,
        deadline: new Date('2018-09-10T08:55:28.087Z'),
      } as CreateTaskDto;
      const actual = await sut.create(param);
      expect(actual.task.id).toBeDefined();
    });
  });

  describe('#update', () => {
    it('should return a task updated', async () => {
      const sut = app.get<TasksController>(TasksController);
      const param = {
        id: '6a414c88-4613-486d-9990-80c1de52eea4',
        overview: 'Learn TypeScript',
        priority: 1,
        deadline: new Date('2018-10-10T08:55:28.087Z'),
      } as UpdateTaskDto;
      const actual = await sut.update(param);
      expect(actual.deadline).toBe(param.deadline);
    });

    it('should throw exception when a task is not found', async () => {
      const sut = app.get<TasksController>(TasksController);
      const param = {
        id: 'not-exist',
        overview: 'Learn TypeScript',
        priority: 1,
        deadline: new Date('2018-10-10T08:55:28.087Z'),
      } as UpdateTaskDto;

      await sut.update(param).catch(error => {
        expect(error.stack).toContain('Task not found.');
      });
    });
  });

  describe('#destroy', () => {
    it('should delete a task', async () => {
      const sut = app.get<TasksController>(TasksController);
      const targetId = '6a414c88-4613-486d-9990-80c1de52eea4';
      const beforeSize = (await sut.index()).tasks.length;
      await sut.destroy(targetId);

      const afterSize = (await sut.index()).tasks.length;
      expect(beforeSize).toBe(afterSize + 1);
    });
  });
});

getRepositoryToken(TaskEntity) で差し替えたい対象Repositoryの名前を取得するのがポイント。

6. はまりどころ

DB接続情報設定で下記のエラーが発生した。

[Nest] 48712   - 2018-9-14 14:21:52   [TypeOrmModule] Unable to connect to the database. Retrying (9)... +3017ms
/Users/xxxxxxxxxxxxxx/repos/public/blog/nest/nest-angular-sample/api/src/tasks/tasks.entity.ts:1
(function (exports, require, module, __filename, __dirname) { import { Entity, Column, PrimaryGeneratedColumn, PrimaryColumn } from 'typeorm';
                                                              ^^^^^^
SyntaxError: Unexpected token import
    at createScript (vm.js:80:10)
    at Object.runInThisContext (vm.js:139:10)
    at Module._compile (module.js:616:28)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Module.require (module.js:596:17)

HMRで動かすときは entities: [__dirname + '/**/*.entity{.ts,.js}']entities: [SomeEntity] に変更すればOK。

Githubのissueにて、Nestjsの作者が上記の方法が今のところ最善だとコメントしている。

なお、エラーに気づかずにAPIにリクエストを投げるとさらに下記のエラーが発生する。

[Nest] 61318   - 2018-9-16 14:59:50   [TypeOrmModule] Unableto connect to the database. Retrying (1)...
Error: EACCES: permission denied, scandir '/Library/Application Support/Apple/AssetCache/Data'
    at Object.fs.readdirSync (fs.js:904:18)
    at GlobSync._readdir (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:288:41)
    at GlobSync._readdirInGlobStar (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:267:20)
    at GlobSync._readdir (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:276:17)
    at GlobSync._processReaddir (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:137:22)
    at GlobSync._process (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:132:10)
    at GlobSync._processGlobStar (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:380:10)
    at GlobSync._process (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:130:10)
    at GlobSync._processGlobStar (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:383:10)
    at GlobSync._process (/Users/username/repos/public/blog/nest/nest-angular-sample/api/node_modules/glob/sync.js:130:10)

main.hmr.ts に下記プリントデバッグを仕込んだところ、__dirname/ になっていたため、ローカルマシンのディスク全検索をしようとしてエラーを吐いていたことがわかった。
(PathではなくEntityを指定する方法にすれば発生しない。)


以上。


参考URL