Share
Embed
NestJS Prisma on Fargate構成で作るWeb API開発Tips
shuntaka
July 08, 2023
Transcript
85 slides
-
NestJS x Prisma on Fargate構成で作るWeb API開発Tips 2 02 3 /
07 / 08 CX事業本部 髙橋俊⼀ a.k.a shuntaka -
⾃⼰紹介 5 shuntaka/髙橋 俊⼀ 主な業務内容 ‧サーバサイド ‧インフラ クラスメソッド CX事業本部 19年8⽉⼊社
基本的にAWSでサーバーサイド開発することが多いです! ‧IoTバックエンド開発 ‧IoTエッジソフトウェア開発 shuntaka.dev/who 詳細はこちら! 好きな技術 ‧TypeScript/Go/Neovim -
⾃作ツールの宣伝(1秒) 6 Goを使ったCLIの個⼈開発が⼤好きです! ‧oax(💥 CLI for OpenAI's ChatGPT) ‧ddbrew(🍺 Simple
DynamoDB utility CLI) ‧gh-p 2 (✨ GitHub CLI extension ProjectV 2 utility) etc.. -
サンプルコード 8 本セッションで解説する内容やコードは、動作するものをGitHubに⽤意しておりま す。 github.com/shuntaka 9576 /devio 2 023 -nest
セッションで必要な範囲は、スライドに書いていますので、後⽇全体感を確認した い場合にご利⽤ください。 -
前提 9 ライブラリは、2023年6⽉29⽇時点で最新ものを利⽤しています。 prisma: 4 . 1 6 . 1
@nestjs/apollo: 12 . 0 . 7 @nestjs/graphql: 12 . 0 . 7 詳細は、web-api/package.jsonを参照してください。 データベースエンジンは、Aurora(MySQL)を前提としています。 -
ホスティング環境 1 4 ‧Amazon Elastic Container Service (Fargate) ‧AWS App
Runner 技術選定時(2022年9⽉) AWS App RunnerはAWS WAFに対応しておらず、 ECS(Fargate)を採⽤。 今選定するならデプロイの観点で懸念はあるが、App Runnerも選択肢に⼊る。 -
Webアプリケーションフレームワークの技術選定 1 7 NestJSの社内外の評判を⽬にすることが多く検討 ‧TypeScriptサポート ‧DI(依存性の注⼊)のサポート ‧GraphQLサポートが⼿厚い ‧ドキュメントが豊富 Quick Startをやってみて、GraphQL(Apollo
Server)との連携部分が成熟してい る印象で、Dataloaderを使ったGraphQLのN+ 1 問題にも対応できそうと感じた 結果として、前のスライドのような問題はなかった -
ORM 1 8 ‧Prisma ‧TypeORM 候補としては以下のORMがあった ‧スキーマに合わせて⾃動⽣成される型安全なクライアントライブラリ ‧Prismaの機能としてN+ 1 問題の最適化機能がある
TypeORMと⽐較し、Prismaを採⽤した理由は以下の通り 結果的には、あまりメリットを活かせなかった(詳しくは後述の章) -
プロジェクト構成 2 1 モノレポを採⽤、ディレクトリルートは以下のような形式 infra/ CDK定義(フロントエンド,サーバサイド) monitoring/ CDK定義(監視系) backend/ NestJSアプリケーションコード
frontend/ Next.jsアプリケーションコード schema.graphql GraphQL定義ファイル … -
CloudFormationスタック構成 2 4 1. ネットワークスタック(VPC, 踏み台) 2. バックエンド系 2.1. バックエンドアプリスタック(ECS,
ALB) 2.2. バックエンドECRスタック 3. フロントエンド系 3.1. フロントエンドアプリスタック 3.2. フロントエンECRスタック 4 . GitHub Actionsデプロイ⽤ロールスタック(GitHub Actions OIDC to AWS) 5. 画像配信CDNスタック -
コンテナイメージ作成 2 6 FROM node:18-alpine3.17 AS build ENV GRAPHQL_SCHEMA_PATH "./schema.graphql"
WORKDIR /app COPY server/package.json ./ COPY server/package-lock.json ./ NODE_ENV=production RUN npm ci COPY schema.graphql ./ COPY server/tscon fi g.build.json ./ COPY server/tscon fi g.json ./ COPY server/schema.prisma ./ COPY server/.env ./ COPY server/src ./app RUN npx prisma generate RUN npm run build RUN npm install --omit=dev イメージサイズ削減のため、マルチステージビルドの活⽤ ビルド後は開発⽤のライブラリ群を削除する Prismaの型定義ファイルの⽣成 ホストOSから必要なファイル群を取得 コード -
コンテナイメージ作成 2 7 FROM node:18-alpine3.17 ENV PORT=80 ENV GRAPHQL_SCHEMA_PATH "./schema.graphql"
WORKDIR /app COPY --from=build /app/dist /app/dist COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/package.json /app/package.json COPY --from=build /app/schema.graphql /app/schema.graphql COPY --from=build /app/.env /app/.env EXPOSE 80 ENTRYPOINT [ "node" ] CMD [ "dist/main.js" ] ビルドステージから必要な資材のみコピーする コード -
src ᵓᴷᴷ auth/ // ೝূೝՄ(AuthGurad࣮) ᵓᴷᴷ handlers/ // ΠϯλϑΣʔε │
.. │ └── handler.module.ts // ΠϯλϑΣʔε/υϝΠϯ/ΠϯϑϥΛ1ͭͷϞδϡʔϧʹ͢Δ ᵓᴷᴷ domains/ // υϝΠϯ ᵓᴷᴷ infrastructures/ // Πϯϑϥ ᵓᴷᴷ app.module.ts // ϧʔτϞδϡʔϧ ᵓᴷᴷ main.ts // ΤϯτϦϙΠϯτ ᵓᴷᴷ graphql.ts // ࣗಈੜϑΝΠϧ └── utils/ // ϩΨʔઃఆͳͲ ソース構成 モジュール分割をせず、全てhandler.module.tsにまとめる 3 4 -
import { Module } from '@nestjs/common'; import { GetSessionUseCase }
from 'src/domains/get-session-use-case'; import { PrismaClientProvider } from 'src/infrastructures/prisma-provider'; import { SessionRepository } from 'src/infrastructures/session-repository'; import { SessionResolver } from './resolvers/session-resolver'; @Module({ imports: [], providers: [ SessionResolver, GetSessionUseCase, SessionRepository, PrismaClientProvider, ], }) export class HandlerModule {} ソース構成 コード 3 5 -
認証‧認可実装 3 6 認証にはLINEログインを利⽤ τʔΫϯͷछྨ ܗࣜ ݕূํ๏ *%5PLFO +85 )4
ϔομʔϖΠϩʔυ෦ΛνϟϯωϧγʔΫϨοτͰ)."$ 4)"ϋογϡΛͱΓݕূ "DDFTT5PLFO ҙจࣈྻ ݕূΤϯυϙΠϯτΛఏڙ ࠾༻ -
AccessToken検証⽅法 3 7 export const verifyAccessToken = async ( accessToken:
string, ): Promise<VerifyAccessTokenApiResponse> => { const res = await Axios.request<VerifyAccessTokenApiResponse>({ method: 'get', url: `${LINE_API_BASE_URL}/verify?access_token=${accessToken}`, }); if (res.data.client_id !== LINE_CHANNEL_ID) { throw new LineApiVerifyAccessTokenInvalidClientIdError(accessToken, res); } if ( res.data.expires_in <= 0 // LINEΞΫηετʔΫϯਪݕূࣄ߲ ) { throw new LineApiVerifyAccessTokenExpiredError(accessToken, res); } return res.data; }; コード -
AccessToken検証⽅法 3 8 以下のライブラリを利⽤ ‧passport-http-bearer ‧@nestjs/passport @Injectable() export class LineHttpBearerStrategy
extends PassportStrategy( Strategy, lineStrategy, ) { private readonly logger = new Logger(LineHttpBearerStrategy.name); async validate(bearerToken: string): Promise<UserPro fi le> { try { await Line.verifyAccessToken(bearerToken); const userInfo = await Line.getUserInfo(bearerToken); return { sub: userInfo.sub, }; } catch (e) { this.logger.log('UnauthorizedRequest', { bearerToken: bearerToken, error: e, }); throw new UnauthorizedException(); } } } @Injectable() export class GraphqlLineAuthGuard extends AuthGuard(lineStrategy) { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); return ctx.getContext().req; } } コード コード -
AccessToken検証⽅法 3 9 @UseGuradsアノテーションを使⽤して、GraphQLに認可処理を追加 @UseGuards(GraphqlLineAuthGuard) @Query(() => SessionConnection) async sessions(
@CurrentUser() user: UserPro fi le, @Args() option?: unknown, ): Promise<SessionConnection> { try { this.logger.log('CalledSessions', { option: option, user: user, }); … コード -
JWT(HS 25 6 )の検証⽅法 4 0 export const jwtHs256Strategy =
'jwtHs256Strategy'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, jwtHs256Strategy) { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: LINE_CHANNEL_SECRET, algorithms: ['HS256'], }); } (தུ) } passport-jwtを利⽤ コード -
CORS対応 4 1 GraphQLエンドポイントのCORS対応。簡単! async function bootstrap() { (தུ) app.useGlobalFilters(new
GlobalExceptionFilter()); app.enableCors({ origin: CORS_DOMAIN, // ։ൃ*ʹ͓ͯ͘͠ͱϑϩϯτଆͷ։ൃָ͕ methods: ['POST', ‘OPTIONS'], // Access-Control-Allow-Method allowedHeaders: ‘authorization,content-type', // Access-Control-Allow-Headers }); await app.listen(port); } コード -
⾼い負荷が予測されるQuery対策 4 2 query a2 { a: sessions( fi lter:
{}) {nodes {id title speakers {id name } date start end }} b: sessions( fi lter: {}) {nodes {id title speakers {id name } date start end }} … h: sessions( fi lter: {}) {nodes {id title speakers {id name } date start end }} } 多数のクエリ1回で送信可能。サーバーに負荷を与える攻撃にもなり対策が必要。 -
⾼い負荷が予測されるQuery対策 4 3 Query complexityを計測し、複雑度が100以上のクエリはレスポンス を返却しないようにした // 1ճͷ߹ {“level”:”info","message":"QueryComplexity","params":{"complexity":10},...} //
8ճҰׅΫΤϦͷ߹ {“level”:”info”,"message":"QueryComplexity","params":{"complexity":80},...} // 12ճҰׅΫΤϦͷ߹ {“level”:”info","message":"QueryComplexityOver","params":{"complexity":120})...} コード -
Field Suggestion対策 4 4 { "errors": [ { "message": "Cannot
query fi eld \"sssions\" on type \"Query\". Did you mean \"sessions\"?", "extensions": { "code": "GRAPHQL_VALIDATION_FAILED" } } ] } フィールドのエラーメッセージでスキーマが推測されてしまう。 脆弱性に繋がるため、エラーハンドリングをする。 -
Field Suggestion対策 4 5 @Module({ controllers: [HealthCheckController], providers: [ComplexityPlugin], imports:
[ AuthModule, GraphQLModule.forRoot<ApolloDriverCon fi g>({ (தུ) formatError: (formattedError: GraphQLFormattedError) => { logger.log(`CatchException`, formattedError); if ( formattedError.extensions?.code === ApolloServerErrorCode.GRAPHQL_VALIDATION_FAILED ) { return new BadRequestException(); } (தུ) )} // Ϩεϙϯε { "errors": [ { "response": { "message": "Bad Request", "statusCode": 400 }, "status": 400, "options": {}, "message": "Bad Request", "name": "BadRequestException" } ] } コード -
ロガー 4 6 要件 ‧ユーザー毎/リクエスト毎に処理動向が追えれば良い 簡易に⽤意できるCloudWatch Logsを活⽤ ‧ライブラリは、成熟しているwinstonを利⽤ ‧pinoがパフォーマンス的には良いらしい…要検証 ‧Node.jsのAsyncLocalStorageに以下の情報を格納し、JSONで吐き出すように
‧x-amzn-trace-id(ALBのリクエストに⼀意な値) ‧LINEのuserId ‧サービスのuserId コード -
(前提) MySQLの設定 4 8 [mysqld] character_set_server=utf 8 mb 4 sql_mode=TRADITIONAL,ONLY_FULL_GROUP_BY
general_log= 1 log_output=TABLE slow_query_log= 1 long_query_time= 2 autocommit= 0 // オペレーション上不安要素が多いため transaction_isolation=READ-COMMITTED // サービス性質上最適な分離レベル local-in fi le= 1 コード -
Prismaの接続先を動的に変更する 5 1 import * as dotenv from 'dotenv'; import
{ expand } from 'dotenv-expand'; const env = dotenv.con fi g(); expand(env); DATABASE_URL= mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST:3306/$DB_DBNAME dotenv-expandは、.envに書いた環境変数を動的に展開して新しい環境変数 に再設定してくれる コード コード -
Prismaのモデル定義を書く 5 4 model Sessions { sessionId String @id @map("session_id")
title String @map("title") date String @map("date") start String @map("start") end String @map("end") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @map("updated_at") @@map("sessions") } コード -
Prismaのモデル定義を書く 5 5 model SessionSpeakers { sessionId String @map("session_id") speakerId
String @map("speaker_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @map("updated_at") @@id([sessionId, speakerId]) @@map("session_speakers") } コード -
Prismaのモデル定義を書く 5 6 model Speakers { speakerId String @id @map("speaker_id")
name String @map("name") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @map("updated_at") @@map("speakers") } コード -
レスポンスを想像し、SQLを書いてみる 5 7 SELECT sessions.session_id AS sessionId, sessions.title AS title,
sessions.start AS start, sessions.end AS end, sessions.date AS date, session_speakers.speaker_id AS speakerId, speakers.name AS name FROM sessions LEFT JOIN session_speakers ON sessions.session_id = session_speakers.session_id LEFT JOIN speakers ON session_speakers.speaker_id = speakers.speaker_id WHERE speakers.name = 'speakerName1'; { "data": { "sessions": { "nodes": [ { "id": "session1", "title": "ηογϣϯλΠτϧ1", "speakers": [ { "id": "CM001", "name": "speakerName1" } ], "date": "2023/07/07", "start": "13:30", "end": "14:10" } ] } } } -
Relationsを使う 6 0 model Sessions { sessionId String @id @map("session_id")
title String @map("title") date String @map("date") start String @map("start") end String @map("end") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @map("updated_at") sessionSpeakers SessionSpeakers[] @@map("sessions") } コード -
Relationsを使う 6 1 model SessionSpeakers { sessionId String @map("session_id") speakerId
String @map("speaker_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @map("updated_at") sessions Sessions @relation( fi elds: [sessionId], references: [sessionId]) speakers Speakers @relation( fi elds: [speakerId], references: [speakerId]) @@id([sessionId, speakerId]) @@map("session_speakers") } コード -
Relationsを使う 6 2 model Speakers { speakerId String @id @map("speaker_id")
name String @map("name") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @map("updated_at") sessionSpeakers SessionSpeakers[] @@map("speakers") } コード -
Relationsを使う 6 3 -- AddForeignKey ALTER TABLE `session_speakers` ADD CONSTRAINT
`session_speakers_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sessions`(`session_id`) ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE `session_speakers` ADD CONSTRAINT `session_speakers_speaker_id_fkey` FOREIGN KEY (`speaker_id`) REFERENCES `speakers`(`speaker_id`) ON DELETE RESTRICT ON UPDATE CASCADE; マイグレーションを実⾏すると、外部キー制約が作成される -
Session⼀覧を取得する実装 6 4 const records = await this.prismaClient.$transaction((tx) => tx.sessionSpeakers.
fi ndMany({ select: { speakerId: true, sessions: { select: { sessionId: true, title: true, start: true, end: true, date: true, }, }, speakers: { select: { speakerId: true, name: true, }, }, }, where: { speakers: { name: speakerName, }, }, }), ); スキーマ定義から型が⽣成され、右図の ように、補完を効かせながら取得したい 項⽬を選択できる 補⾜: selectでもautocommit= 0 の場合、 トランザクションが残ります。 BEGIN~COMMITが発⾏が必要なため $transactionをつけています。 参考 -
Transactionはデフォルトタイム設定に注意 6 5 const records = await this.prismaClient. $transaction( async
(tx) => { // TODO }, { maxWait: 2000, // default timeout: 20000, // default: 5000 }, ); 5秒以上かかり、例外にしたくないクエリ には設定が必要 -
Relations発⾏されるSQL 6 6 本実装で、データ取得関わるSELECTは基本的に3つ 1 . SessionsとSpeakersをJOIN、SpeakerNameをWHEREで指定 => sessionIdとspeakerIdを取得 2
. sessionsテーブルへ、1.のsessionIdを指定して、レコード取得 3 . speakersテーブルへ、1.のspeakerIdを指定して、レコード取得 2と3はPK検索だが、3つのテーブルをJOINして、データを取得しているわけで はない。 チューニングする際には、generallogの確認が必須 -
queryRawを使う 6 8 問題点 ‧type safeではない ‧Number型だと思っていたら、BigInt型でJSONパースで例外になることもあった await tx.$queryRaw<SessionRecord[]>`SELECT *
…`; 特徴 ‧``で括ったSQLで、引数はプリペアードステートメントになり実⾏ ‧意識せずインジェクション対策済み -
queryRawを使う 6 9 await this.prismaClient.$transaction( async (tx) => { return
await tx.$queryRaw<SessionRecord[]>`SELECT * … FROM sessions LEFT JOIN session_speakers ON sessions.session_id = session_speakers.session_id LEFT JOIN speakers ON session_speakers.speaker_id = speakers.speaker_id ${ speakerName ? Prisma.sql`WHERE speakers.name = ${speakerName}` : Prisma.sql`` };`; }); `; }, ); コード 引数にする場合、Prismaのテンプレートリテラルが必要 (インジェクション対策になる) -
queryRawUnsafeを使う 7 0 問題点 ‧セキュリティ(インジェクション)リスクがある await tx.$queryRawUnSafe<SessionRecord[]>`SELECT * …`; 特徴
‧動的にSQL⽣成する場合、queryRawだと出来ないケースがあります -
queryRawUnsafeを使う 7 1 const getSpeakerQuery = `SELECT speaker_id FROM speakers
WHERE name like ?`; // ?͕ϓϦϖΞʔυεςʔτϝϯτʹͳΓɺΠϯδΣΫγϣϯରࡦ const(தུ) const res = await this.prismaClient.$transaction(async (tx) => { // ?ʹϚοϐϯά͞ΕΔbindValuesྻೖΕΔ greturn await tx.$queryRawUnsafe(execQuery, ...bindValues); }); -
kyselyでSQLを書いた場合 7 4 const records = await db .selectFrom('sessions') .select([
'sessions.session_id', 'sessions.title', 'sessions.start', 'sessions.end', 'sessions.date', ]) .leftJoin( 'session_speakers', 'sessions.session_id', 'session_speakers.session_id', ) .select(['session_speakers.speaker_id']) .leftJoin( 'speakers', 'session_speakers.speaker_id', 'speakers.speaker_id', ) .select(['speakers.name']) .where('speakers.name', '=', speakerName) .execute(); コード 返却される値が型安全 (queryRawで⾒落としがち) -
データ投⼊スクリプトにはzxを利⽤ 7 7 DBへのデータ投⼊スクリプトにbashではなく、google/zxを活⽤ google/zxは、Node の child_process のラッパー で、$で囲んで shell
コマンドを簡単に実⾏できる const result = await $`ls -al`; ‧コマンドラッパーが書きやすい ‧nodeの機能/記法が使える ‧⾮同期IO ‧try catch ‧複雑になってきたら、TypeScriptに移⾏しやすい ‧対話型機能がユーテリティとしてあり、よくわるy/N実装の⼿間が少ない -
データ投⼊スクリプトにはzxを利⽤ 7 8 ⾮同期IOで複数csvを、nkfでSJIS->utf変換する await Promise.all( COMMON_CONVERT_FILE_NAMES.map(async ( fi leName)
=> { await $`nkf -w -Lu ${path.join( CSV_PATH, COMMON_DIR_NAME, fi leName, )} > ${path.join(TMP_PATH, fi leName)}.utf8`; }), ) -
開発を通して感じたこと 7 9 ‧NestJSとGraphQLの構成は成熟している ‧豊富なドキュメントと実装例 ‧攻撃に対する対応策 ‧認証/認可周りもライブラリが充実しており、⼿軽に実装できる ‧GraphQLのDSLでフロント/バックエンドでスキーマ駆動開発が捗る ‧Prismaを使っていて、意図通りのクエリが作れないケースは少ない(詰まない) ‧(課題)
現状の状態だとリポジトリを跨ぐトランザクション要件に弱い ‧(課題) queryRawに頼ると型安全性で問題あり、クエリビルダへ変更検討 ‧(対策) prismaのマイグレーションと型安全両⽅取りたい場合、マイグレー ションはprismaでクライアントはkysely構成もあり -
最後に 8 0 ご清聴ありがとうございました github.com/shuntaka 9576 /devio 2 023 -nest
他に気になる点や、サンプルコードが動作しない場合 は、以下のリポジトリでissueを起票して頂ければ、 可能な限り対応させて頂きます。 -
参考⽂献 8 3 ‧[Prismaの各実装と実際に発⾏されるSQLを確認してみる](https://zenn.dev/shuntaka/scraps/ 8 3 9 f 1 9
3 6 2 0 0 0 0 6 ) ‧[Prisma](https://www.prisma.io/docs/concepts) ‧[NestJS](https://docs.nestjs.com/) ‧[PrismaのJOINの挙動を観察](https://qiita.com/masayasviel/items/ 5 fa 974 4 ac 4 5 d 846 9 0 3 a 9 ) ‧[kysely.dev](https://kysely.dev/) ‧[AsyncLocalStorage](https://nodejs.org/api/async_context.html) ‧[LINEログイン v 2 . 1 APIリファレンス](https://developers.line.biz/ja/reference/line-login/)