blog.euxn.me

NestJS Service 初期化 非同期

2019-12-08 Sun.

この記事は NestJS アドベントカレンダー 2019 8 日目の記事です。

はじめに

この記事では DB のコネクションやクラウドサービスの認証など、 Service として切り出したいが初期化が非同期になるものの扱い方を説明します。

サンプルコードのリポジトリは以下になります。

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day08-initialize-async-provider

なお、環境は執筆時点での Node.js の LTS である v12.13.1 を前提とします。

おさらい: NestJS における Provider の初期化タイミング

NestJS の Module において定義された Provider (Service など) は、 NestJS のエントリーポイントで NestFactory.create() された際にインスタンスの生成がされます。 @Injectable() を追記することにより、 NestJS 内部に隠蔽された DI コンテナでシングルトンとして管理されます。 class の new は同期的に処理されるため constructor も同期的に実行されます。 この記事では、 Provider の非同期な初期化を NestJS の Module の仕組みに乗せて解決する方法を説明します。

非同期な初期化処理であるデータベースのコネクション生成を解決する

先日の例では以下のように Domain の Service で DB を初期化しました。

1import { Injectable } from "@nestjs/common";
2import { createConnection, Connection } from "typeorm";
3
4@Injectable()
5export class ItemsService {
6 connection: Connection;
7
8 constructor() {
9 createConnection({
10 type: "mysql",
11 host: "0.0.0.0",
12 port: 3306,
13 username: "root",
14 database: "test",
15 })
16 .then((connection) => {
17 this.connection = connection;
18 })
19 .catch((e) => {
20 throw e;
21 });
22 }
23
24 // connection が確立していないタイミングがあるため待ち受ける
25 private async waitToConnect() {
26 if (this.connection) {
27 return;
28 }
29 await new Promise((resolve) => setTimeout(resolve, 1000));
30 await this.waitToConnect();
31 }
32
33 async createItem(title: string, body: string, deletePassword: string) {
34 if (!this.connection) {
35 await this.waitToConnect();
36 }
37 await this.connection.query(
38 `INSERT INTO items (title, body, deletePassword) VALUE (?, ?, ?)`,
39 [title, body, deletePassword]
40 );
41 }
42}

しかしこれには設計上の問題が、わかりやすく 2 つは存在します。

  1. 他の Domain でも DB 接続を行うことを前提に、 DB 接続管理を別のサービスに委譲するべき
  2. constructor で非同期な初期化処理を行なっているので、メソッドの実行タイミングによっては初期化が完了していない

1 の問題を解決するために ItemsModule から切り離し、 DatabaseModule としてそのまま定義すると以下のようになります。

database.service.ts
1import { Injectable } from '@nestjs/common';
2import { createConnection, Connection } from 'typeorm';
3
4@Injectable()
5export class DatabaseService {
6 connection: Connection;
7
8 constructor() {
9 createConnection({
10 type: 'mysql',
11 host: '0.0.0.0',
12 port: 3306,
13 username: 'root',
14 database: 'test',
15 })
16 .then(connection => {
17 this.connection = connection;
18 })
19 .catch(e => {
20 throw e;
21 });
22 }
23}

しかしこれでは上で説明した通り、 connection 確立が非同期なので、完了するまでの間に DB アクセスが呼ばれてしまう恐れがあります。

以下では上記 2 の解決を例に挙げながら、初期化と非同期について説明します。

Async Providers

NestJS 公式では Module の Custom Provider として @Module() に渡すオプションによって様々な Provider の宣言を行える機能が備わっています。

https://docs.nestjs.com/fundamentals/custom-providers

その中でも今回のように特に必要と思われる Async Provider を取り上げます。

https://docs.nestjs.com/fundamentals/async-providers

1{
2 provide: 'ASYNC_CONNECTION',
3 useFactory: async () => {
4 const connection = await createConnection(options);
5 return connection;
6 },
7}

サンプルコードでは connection を直接 provider に指定していますが、上記の Service に当てはめて書き換えてみます。

database.service.ts
1import { Injectable } from '@nestjs/common';
2import { createConnection, Connection } from 'typeorm';
3
4
5@Injectable()
6export class DatabaseService {
7 connection: Connection;
8
9 async initialize() {
10 this.connection = await createConnection({
11 type: 'mysql',
12 host: '0.0.0.0',
13 port: 3306,
14 username: 'root',
15 database: 'test',
16 })
17 }
18}
database.module.ts
1import { Module } from '@nestjs/common';
2import { DatabaseService } from './database.service';
3
4
5@Module({
6 providers: [
7 {
8 provide: 'DatabaseService',
9 useFactory: async () => {
10 const databaseService = new DatabaseService();
11 await databaseService.initialize();
12 },
13 },
14 ],
15})
16export class DatabaseModule {}

Async な要素を Service の初期化時に引数として渡す

上記の例でも動作しますが、 initialize された後かどうかの管理が必要になるとともに、状態を持ってしまうため TypeScript とは相性が悪くなってしまいます。 そこで、非同期な要素のみを Service の外で(@Module() の useFactory 関数内で)処理し、結果のみを Service に渡して同期的に初期化することで、シンプルな形になります。

database.service.ts
1import { Injectable } from '@nestjs/common';
2import { Connection } from 'typeorm';
3
4@Injectable()
5export class DatabaseService {
6 constructor(public readonly connection: Connection) {}
7}
database.module.ts
1import { Module } from '@nestjs/common';
2import { DatabaseService } from './database.service';
3import { createConnection } from 'typeorm';
4
5@Module({
6 providers: [
7 {
8 provide: 'DatabaseService',
9 useFactory: async () => {
10 const connection = await createConnection({
11 type: 'mysql',
12 host: '0.0.0.0',
13 port: 3306,
14 username: 'root',
15 database: 'test',
16 });
17 return new DatabaseService(connection);
18 },
19 },
20 ],
21})
22export class DatabaseModule {}

動作を確認するために MySQL を用意します。 以下の 3 ファイルを定義し docker-compose up することでこのプロジェクト用に初期化済みの MySQL を起動できます。 Docker を使用しない方は、 [email protected] 向けに test データベースを作成し、 create-table.sql を流し込んでください。

docker-compose.yml
1version: '3'
2
3services:
4 db:
5 build:
6 context: .
7 dockerfile: Dockerfile
8 environment:
9 MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
10 MYSQL_DATABASE: test
11 TZ: 'Asia/Tokyo'
12 ports:
13 - 3306:3306
Dockerfile
1FROM mysql:5.7
2
3COPY create-table.sql /docker-entrypoint-initdb.d/create-table.sql
create-table.sql
1CREATE TABLE helloworld (message VARCHAR(32));
2INSERT INTO helloworld (message) VALUES ("Hello World");

次に、database.controller を追加して、動くことを確認します。

database.controller.ts
1import { Controller, Get } from '@nestjs/common';
2import { DatabaseService } from './database.service';
3
4@Controller('database')
5export class DatabaseController {
6 constructor(private readonly databaseService: DatabaseService) {}
7
8 @Get()
9 async selectAll(): Promise<string> {
10 const res = await this.databaseService.connection.query(
11 `SELECT message FROM helloworld`,
12 );
13 return res[0].message;
14 }
15}
1$ curl localhost:3000/database
2Hello World%

おわりに

この記事では DB のコネクションやクラウドサービスの認証など、 Service として切り出したいが初期化が非同期になるものの扱い方を説明しました。 明日は @potato4d さんによる TypeORM についての回です。