新しいアプリ開発でbackendをNode.jsでAPIをやってみようと思いタイトルの構成でAPIサーバーを作成してみたら、結構ハマりどころがあったので記事した
リポジトリ
モチベーション
- 現在Flutterでアプリを作成中
- FlutterでAPIのやり取りで型安全にするならgraphql_codegenが使える
- もし、React NativeだったらGraphQLでは無くtRPCを採用していたと思うが、Flutterはサポートしていないので除外
- 他の言語も検討したが、Prismaを使用したかったので Node.jsを採用
使用技術
実装
NestJSだと以下で新規プロジェクトを作ってくれるので、そこから作成
$ npm i -g @nestjs/cli $ nest new project-name
まず、Fastifyを導入
import { NestFactory } from '@nestjs/core' import { FastifyAdapter, NestFastifyApplication, } from '@nestjs/platform-fastify' import { AppModule } from './app.module' async function bootstrap() { const app = await NestFactory.create<NestFastifyApplication>( AppModule, new FastifyAdapter({ logger: true }) ) const port = Number(process.env.PORT) || 8080 console.log(`Listening on port ${port}`) await app.listen(port, '0.0.0.0') } bootstrap()
次にGraphQLはSchema Firstで実装したかったので、GraphQLの定義を作成
type Query { hello: String categories: [Category] } type Category { "カテゴリーID" id: ID! "カテゴリー名" name: String! "順番" order: Int! } type Item { "アイテムID" id: ID! "カテゴリーID" categoryId: ID! "アイテム名" name: String! "在庫数" stock: Int! "消費期限" expirationDate: Time } input NewCategory { "カテゴリー名" name: String! "順番" order: Int! } type Mutation { createCategory(input: NewCategory!): Category! } scalar Time
コードの方は以下のように実装
import { Module } from '@nestjs/common' import { GraphQLModule } from '@nestjs/graphql' import { MercuriusDriver, MercuriusDriverConfig } from '@nestjs/mercurius' import { AppController } from '@src/app.controller' import { AppService } from '@src/app.service' import { HelloResolver } from './hello.resolver' import { CategoryResolver } from '@src/resolver/category' import { PrismaService } from '@src/modules/prisma/prisma.service' @Module({ controllers: [AppController], providers: [AppService, HelloResolver, CategoryResolver, PrismaService], imports: [ GraphQLModule.forRoot<MercuriusDriverConfig>({ driver: MercuriusDriver, graphiql: true, typePaths: ['./**/schema.graphql'], }), ], }) export class AppModule {}
resolverでGraphQLのtypeを使用したいのでGraphQL Code Generatorで型を自動生成
import type { CodegenConfig } from '@graphql-codegen/cli' const config: CodegenConfig = { overwrite: true, schema: './src/schema.graphql', generates: { 'src/generated/graphql.ts': { plugins: ['typescript'], }, }, } export default config
package.jsonに以下のコマンドを追加
"scripts": { ... "codegen": "graphql-codegen --config codegen.ts" }
以下のコマンド実行でtypeファイルを作成
$ pnpm run codegen
作成されたファイルが以下
... export type Category = { __typename?: 'Category'; /** カテゴリーID */ id: Scalars['ID']['output']; /** カテゴリー名 */ name: Scalars['String']['output']; /** 順番 */ order: Scalars['Int']['output']; }; export type Item = { __typename?: 'Item'; /** カテゴリーID */ categoryId: Scalars['ID']['output']; /** 消費期限 */ expirationDate?: Maybe<Scalars['Time']['output']>; /** アイテムID */ id: Scalars['ID']['output']; /** アイテム名 */ name: Scalars['String']['output']; /** 在庫数 */ stock: Scalars['Int']['output']; };
上記のtypeファイルを使用してresolverを作成 ※Guardとかの概念の説明は長くなるので次回の記事で紹介する
import { Resolver, Query, Mutation, Args, Context } from '@nestjs/graphql' import { UseGuards } from '@nestjs/common' import { Query as QueryType, NewCategory, Category, } from '@src/generated/graphql' import { PrismaService } from '@src/modules/prisma/prisma.service' import { format } from '@src/lib/graphql' import { AuthGuard } from '@src/common/guards/auth/auth.guard' @Resolver('') export class CategoryResolver { constructor(private prisma: PrismaService) {} @Query(() => String) @UseGuards(AuthGuard) async categories(@Context() context): Promise<QueryType['categories']> { const user = context.req.auth const r = await this.prisma.category.findMany({ where: { userId: user.userId, }, orderBy: { order: 'asc', }, }) return r.map((c) => format(c)) } @Mutation('createCategory') @UseGuards(AuthGuard) async createCategory( @Args('input') input: NewCategory, @Context() context ): Promise<Category> { const user = context.req.auth const r = await this.prisma.category.create({ data: { userId: user.userId, name: input.name, order: input.order, }, }) return format(r) } }
ローカルで起動して実行してレスポンスを確認
問題なく動作しているのを確認したので、Cloud Runでデプロイの準備
最初はCloud Native Buildpacksを使用してDockerfile無しでの実装を検討したが以下の記事の通り、うまくいかなった
なので、以下のDockerfileを用意
# ステージ1: 依存関係のインストールとビルド FROM node:18 AS builder WORKDIR /usr/src/app # pnpmのインストール RUN npm install -g pnpm # package.jsonとpnpm-lock.yamlをコピー COPY package.json pnpm-lock.yaml ./ # 依存関係のインストール RUN pnpm install # Prismaクライアントの生成 COPY prisma ./prisma/ RUN npx prisma generate # アプリケーションのソースコードをコピー COPY . . # アプリケーションのビルド RUN pnpm run build # ステージ2: 本番環境用イメージの作成 FROM node:18-alpine AS production WORKDIR /usr/src/app # 環境変数 NODE_ENV を production に設定 ENV NODE_ENV=production # pnpmのインストール RUN npm install -g pnpm # package.jsonとpnpm-lock.yamlをコピー COPY package.json pnpm-lock.yaml ./ # 本番依存関係のみをインストール RUN pnpm install # ビルドしたファイルをコピー COPY --from=builder /usr/src/app/dist ./dist COPY --from=builder /usr/src/app/node_modules ./node_modules COPY --from=builder /usr/src/app/prisma ./prisma COPY --from=builder /usr/src/app/src ./src # アプリケーションがリッスンするポート番号 EXPOSE 8080 CMD ["node", "dist/src/main"]
上記でdocker buildしたら以下のエラーが発生
PrismaClientInitializationError: Prisma Client could not locate the Query Engine for runtime "linux-musl-arm64-openssl-3.0.x". This happened because Prisma Client was generated for "darwin-arm64", but the actual deployment required "linux-musl-arm64-openssl-3.0.x". Add "linux-musl-arm64-openssl-3.0.x" to `binaryTargets` in the "schema.prisma" file and run `prisma generate` after saving it: generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"] }
PrismaはLinuxとMacで実行時のbinaryTargetsを指定しないとエラーになるので、エラーに記載の通りに以下を追加 ■prisma/schema.prisma
generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x"] }
これで以下のコマンドでビルド成功
$ docker build --platform linux/amd64 -t gcr.io/your-project-id/stock-keeper-backend . $ docker push gcr.io/your-project-id/stock-keeper-backend
最後に以下のコマンドでデプロイ完了
$ gcloud run deploy stock-keeper \ --image gcr.io/your-project-id/stock-keeper-backend2 \ --platform managed \ --region asia-northeast1 \ --allow-unauthenticated
中なハマりどころの多かったが、なんとかデプロイまで出来るようになった