1
1

More than 1 year has passed since last update.

GraphQL+NestJS+TypeORM+MySQL+Serverless(offline)の構成でデータベース接続を考える

Posted at

はじめに

この記事の続きです。

前の記事でGraphQL+NestJS+TypeORM+MySQLという構成に入門したのですが、これをServerless Frameworkで動かしたいと思います。

今回の成果物はこちらにあります。

Serverless Framework(offline)の導入とNestJSへのつなぎ込み

Serverless Frameworkをインストールします。

$ npm install -g serverless

前回作成したNestJSプロジェクトのルートフォルダで以下を実行してパッケージを追加します。

$ yarn add aws-lambda aws-serverless-express express
$ yarn add -D @types/aws-serverless-express serverless-layers

serverless.yml を作成します。

serverless.yml
org: shinobushiva
app: myapp
service: compnestjs-typeorm-ts-example
frameworkVersion: '3'

provider:
  name: aws
  runtime: nodejs14.x
  region: ap-northeast-1
  stage: dev

functions:
  handler:
    handler: dist/handler.handler
    events:
      - http:
          cors: true
          path: "/"
          method: any
      - http:
          cors: true
          path: "{proxy+}"
          method: any
    environment:
      DB_HOST: ${env:DB_HOST}
      DB_PORT: ${env:DB_PORT}
      DB_USERNAME: ${env:DB_USERNAME}
      DB_PASSWORD: ${env:DB_PASSWORD}
      DB_DATABASE: ${env:DB_DATABASE}
plugins:
  - serverless-offline

src/handler.ts を作成します。

src/handler.ts
import { Context, Handler } from 'aws-lambda';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Server } from 'http';
import { ExpressAdapter } from '@nestjs/platform-express';
import * as serverless from 'aws-serverless-express';
import * as express from 'express';

let cachedServer: Server;

async function bootstrapServer(): Promise<Server> {
  const expressApp = express();
  const adapter = new ExpressAdapter(expressApp);
  const module = AppModule;
  const app = await NestFactory.create(module, adapter);
  await NestFactory.createApplicationContext(module);
  app.enableCors();
  await app.init();
  return serverless.createServer(expressApp);
}

export const handler: Handler = async (event: any, context: Context) => {
  if (!cachedServer) {
    cachedServer = await bootstrapServer();
  }

  return serverless.proxy(cachedServer, event, context, 'PROMISE').promise;
};

serverless-offline プラグインを導入

$ yarn add -D serverless-offline

ビルドして実行します。

$ yarn build & serverless offline
database connection error
[Nest] 35703  - 2022/05/01 15:40:59   ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
AlreadyHasActiveConnectionError: Cannot create a new connection named "default", because connection with such name already exist and it now has an active connection session.

ここまでで serverless が起動してhttp://localhost:3000/dev/graphqlにGraphQLプレイグラウンドが 立ち上がるのですが、コンソールには次のようなエラーが表示されます。

image.png

データベース接続エラーを解決する

このエラーはServerless Frameworkがデータベース接続を複数回行おうとしてTypeORMに同じ名前でコネクション貼ることは出来ないよ、と怒られているような状況です。

いくつか解決策はあると思うのですが、今回 TypeOrmModule の設定方法を修正することで解決します。

変更前の src/app.module.ts は次のようになっています。

src/app.module.ts 変更前
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import ormconfig from './config/ormconfig';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';

@Module({
  imports: [
    TypeOrmModule.forRoot(ormconfig),
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      debug: true,
      playground: true,
    }),
    TasksModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

まず、 データベース接続を設定するクラスを src/config/database.ts に作成します。

src/config/database.ts
import { Injectable } from '@nestjs/common'
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'
import { ConnectionManager, getConnectionManager } from 'typeorm'
import ormconfig from './ormconfig'

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  async createTypeOrmOptions(): Promise<TypeOrmModuleOptions> {
    const ormOptions: TypeOrmModuleOptions = {
      ...ormconfig,
      keepConnectionAlive: true,
      autoLoadEntities: true,
    }

    const connectionManager: ConnectionManager = getConnectionManager()
    let options: any

    if (connectionManager.has('default')) {
      options = connectionManager.get('default').options
    } else {
      options = ormOptions as TypeOrmModuleOptions
    }
    return options
  }
}

このクラスはデータベース接続のためのオプションを返すもので、 default 接続が存在する場合は既存のオプションを返すようになっています。 getConnectionManager などが deprecatedになっているのですが、現状では新しい書き方がわからないのでこのままにしています。

これを使って src/app.module.tsを次のように修正します。

src/app.module.ts 変更後
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
import { TypeOrmConfigService } from './config/database';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [TasksModule],
      useClass: TypeOrmConfigService,
    }),
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      debug: true,
      playground: true,
    }),
    TasksModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

再度ビルドして実行すると接続エラーは解消されています。

$ yarn build & serverless offline

No metadata for "EntityName" was found

これで完成かと思って意気揚々とGraphQLのクエリを実行すると次のエラーに遭遇します。

"No metadata for \"Task\" was found."

image.png

これが大変にわかりにくいエラーで解決に丸1日潰しました。

No metadata エラーがなぜ発生するか調べる

エラー発生時のスタックトレースは次のようになっています。

No metadata エラー
[Nest] 51685  - 2022/05/01 16:20:33   ERROR [ExceptionsHandler] No metadata for "Task" was found.
EntityMetadataNotFoundError: No metadata for "Task" was found.
    at DataSource.getMetadata (/Users/izumi/Programs/nestjs-typeorm-ts-example/node_modules/typeorm/data-source/DataSource.js:286:19)
    at Repository.get metadata [as metadata] (/Users/izumi/Programs/nestjs-typeorm-ts-example/node_modules/typeorm/repository/Repository.js:23:40)
    at Repository.find (/Users/izumi/Programs/nestjs-typeorm-ts-example/node_modules/typeorm/repository/Repository.js:167:39)

該当部分のコード(JS)に飛んでみます。

image.png

metadataが取得できていないようなので、findMetadataを見に行きます。

findMetadata
      findMetadata(target) {
        return this.entityMetadatas.find((metadata) => {
            if (metadata.target === target)
                return true;
            if (InstanceChecker_1.InstanceChecker.isEntitySchema(target)) {
                return metadata.name === target.options.name;
            }
            if (typeof target === "string") {
                if (target.indexOf(".") !== -1) {
                    return metadata.tablePath === target;
                }
                else {
                    return (metadata.name === target ||
                        metadata.tableName === target);
                }
            }
            if (ObjectUtils_1.ObjectUtils.isObject(target) &&
                typeof target.name === "string") {
                if (target.name.indexOf(".") !== -1) {
                    return metadata.tablePath === target.name;
                }
                else {
                    return (metadata.name === target.name ||
                        metadata.tableName === target.name);
                }
            }
            return false;
        });
    }

どうやら entityMetadatas から該当の metadata を探し出すメソッドのようです。

findMetadata デバッグ出力埋め込み
    findMetadata(target) {
        return this.entityMetadatas.find((metadata) => {
            console.log('findMetadata', metadata.target, target, metadata.target === target)
            if (metadata.target === target)
                return true;
            if (InstanceChecker_1.InstanceChecker.isEntitySchema(target)) {
                return metadata.name === target.options.name;
            }

このようにデバッグ出力を埋め込んで実行してみると次のような結果になりました。

デバッグ出力
findMetadata [class Task] [class Task] false

一見、 metadata.target, target は同じclassのようですが、比較結果が false となっています。おそらくクラスの読み込みの関係(内部的にはFunction?)で別の参照先になっているのが原因で違うものと判定された結果metadataが見つからないという実行結果になるようです。

解決策1(たぶんあまり良くない)

findMetadata デバッグ出力埋め込み
    findMetadata(target) {
        return this.entityMetadatas.find((metadata) => {
            console.log('findMetadata', metadata.target, target, metadata.target === target)
            if (metadata.target === target)
                return true;
            if (metadata.target.toString() === target.toString())
                return true;

一つの解決策として参照で比較するのではなくて文字列として比較することを考えました。

これで解決します。解決しますが、Javascript力の低い私には正直これでいいのかよくわかりません。また、これだとTypeORMにこの修正を取り込んでもらわないといけません。

ということで、これは諦めて別の方法を考えます。

Repository側からの解決を考える

もう一度 No metadata エラーのスタックトレースをよく見ると Repository.get metadata となっています。

No metadata エラー
[Nest] 51685  - 2022/05/01 16:20:33   ERROR [ExceptionsHandler] No metadata for "Task" was found.
EntityMetadataNotFoundError: No metadata for "Task" was found.
    at DataSource.getMetadata (/Users/izumi/Programs/nestjs-typeorm-ts-example/node_modules/typeorm/data-source/DataSource.js:286:19)
    at Repository.get metadata [as metadata] (/Users/izumi/Programs/nestjs-typeorm-ts-example/node_modules/typeorm/repository/Repository.js:23:40)
    at Repository.find (/Users/izumi/Programs/nestjs-typeorm-ts-example/node_modules/typeorm/repository/Repository.js:167:39)

どうやら metadataRepository から取得するようです。

Repository について詳しくはこのあたりを参考にしてください。

さて、TaskRepositoryはどこで設定されているかと探してみると、src/tasks/tasks.service.tsにこのような記述がありました。

src/tasks/tasks.service.ts 一部
export class TasksService {
  constructor(
    @InjectRepository(Task)
    private taskRepostiory: Repository<Task>,
  ) {}
src/tasks/tasks.service.ts 全体(修正前)
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateTaskInput } from './dto/create-task.input';
import { UpdateTaskInput } from './dto/update-task.input';
import { Task } from './entities/task.entity';

@Injectable()
export class TasksService {
  constructor(
    @InjectRepository(Task)
    private taskRepostiory: Repository<Task>,
  ) {}

  async create(createTaskInput: CreateTaskInput) {
    const task = this.taskRepostiory.create(createTaskInput);
    await this.taskRepostiory.save(task);
    return task;
  }

  findAll() {
    return this.taskRepostiory.find();
  }

  async findOne(id: number) {
    return await this.taskRepostiory.findOne({
      where: {
        id,
      },
    });
  }

  async update(id: number, updateTaskInput: UpdateTaskInput) {
    const task = this.findOne(id);
    if (task) {
      await this.taskRepostiory.save(updateTaskInput);
    }
  }

  async remove(id: number) {
    const result = await this.taskRepostiory.delete(id);
    return result.affected > 0;
  }
}

いかにも@InjectRepository(Task)のデコーレータ部分がrepositoryを設定していそうです。InjectRepositoryの中身を読んでみたのですが、正直良くわかりませんでした。

解決策2(今の所マシな感じ)

とはいえtaskRepostioryの中身をどうにかすればうまくいくかもしれないということで、色々試した結果この様になりました。

src/tasks/tasks.service.ts 全体(修正後)
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { create } from 'domain';
import { async } from 'rxjs';
import { ConnectionManager, getConnectionManager, getRepository, Repository } from 'typeorm';
import { CreateTaskInput } from './dto/create-task.input';
import { UpdateTaskInput } from './dto/update-task.input';
import { Task } from './entities/task.entity';

@Injectable()
export class TasksService {
  // constructor(
  //   @InjectRepository(Task)
  //   private taskRepostiory: Repository<Task>,
  // ) {}
  get taskRepostiory(): Repository<Task> {
    const connectionManager: ConnectionManager = getConnectionManager();
    if (connectionManager.has('default')) {
      const entityMetadata = connectionManager
        .get('default')
        .entityMetadatas.find((metadata) => {
          return metadata.tableName === 'task';
        });
      if (entityMetadata) {
        return getRepository(entityMetadata.name);
      } else {
        return null;
      }
    }
  }

  async create(createTaskInput: CreateTaskInput) {
    const task = this.taskRepostiory.create(createTaskInput);
    await this.taskRepostiory.save(task);
    return task;
  }

  findAll() {
    return this.taskRepostiory.find();
  }

  async findOne(id: number) {
    return await this.taskRepostiory.findOne({
      where: {
        id,
      },
    });
  }

  async update(id: number, updateTaskInput: UpdateTaskInput) {
    const task = this.findOne(id);
    if (task) {
      await this.taskRepostiory.save(updateTaskInput);
    }
  }

  async remove(id: number) {
    const result = await this.taskRepostiory.delete(id);
    return result.affected > 0;
  }
}

コンストラクタでデコレーターを使って設定されていたtaskRepostioryをgetterに置き換えました。

これで No metadata エラーが発生しなくなりました。 serverless offlineでの実行、nest start --watchでの実行どちらも動作するようになりました。

まとめ

ということで、なんとか動作するところまでこぎつけられました。もっと良い解決策があるんじゃないかと思いつつ、現時点での私としては限界点です。スマートな解決策お持ちの方、ぜひご教示ください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1