はじめに
この記事の続きです。
前の記事で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
を作成します。
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
を作成します。
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
[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プレイグラウンドが 立ち上がるのですが、コンソールには次のようなエラーが表示されます。
データベース接続エラーを解決する
このエラーはServerless Frameworkがデータベース接続を複数回行おうとしてTypeORMに同じ名前でコネクション貼ることは出来ないよ、と怒られているような状況です。
いくつか解決策はあると思うのですが、今回 TypeOrmModule
の設定方法を修正することで解決します。
変更前の 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
に作成します。
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
を次のように修正します。
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."
これが大変にわかりにくいエラーで解決に丸1日潰しました。
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)に飛んでみます。
metadata
が取得できていないようなので、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(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(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
となっています。
[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)
どうやら metadata
は Repository
から取得するようです。
Repository
について詳しくはこのあたりを参考にしてください。
さて、Task
のRepository
はどこで設定されているかと探してみると、src/tasks/tasks.service.ts
にこのような記述がありました。
export class TasksService {
constructor(
@InjectRepository(Task)
private taskRepostiory: Repository<Task>,
) {}
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
の中身をどうにかすればうまくいくかもしれないということで、色々試した結果この様になりました。
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
での実行どちらも動作するようになりました。
まとめ
ということで、なんとか動作するところまでこぎつけられました。もっと良い解決策があるんじゃないかと思いつつ、現時点での私としては限界点です。スマートな解決策お持ちの方、ぜひご教示ください。