Upgrade to Pro — share decks privately, control downloads, hide ads and more …

NestJS Prisma on Fargate構成で作るWeb API開発Tips

shuntaka
July 08, 2023
15k

NestJS Prisma on Fargate構成で作るWeb API開発Tips

shuntaka

July 08, 2023
Tweet

Transcript

  1. NestJS x Prisma on Fargate構成で作るWeb API開発Tips 2 02 3 /

    07 / 08 CX事業本部 髙橋俊⼀ a.k.a shuntaka
  2. ⾃⼰紹介 5 shuntaka/髙橋 俊⼀ 主な業務内容 ‧サーバサイド ‧インフラ クラスメソッド CX事業本部 19年8⽉⼊社

    基本的にAWSでサーバーサイド開発することが多いです! ‧IoTバックエンド開発 ‧IoTエッジソフトウェア開発 shuntaka.dev/who 詳細はこちら! 好きな技術 ‧TypeScript/Go/Neovim
  3. サンプルコード 8 本セッションで解説する内容やコードは、動作するものをGitHubに⽤意しておりま す。 github.com/shuntaka 9576 /devio 2 023 -nest

    セッションで必要な範囲は、スライドに書いていますので、後⽇全体感を確認した い場合にご利⽤ください。
  4. 前提 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)を前提としています。
  5. ホスティング環境 1 4 ‧Amazon Elastic Container Service (Fargate) ‧AWS App

    Runner 技術選定時(2022年9⽉) AWS App RunnerはAWS WAFに対応しておらず、 ECS(Fargate)を採⽤。 今選定するならデプロイの観点で懸念はあるが、App Runnerも選択肢に⼊る。
  6. Webアプリケーションフレームワークの技術選定 1 7 NestJSの社内外の評判を⽬にすることが多く検討 ‧TypeScriptサポート ‧DI(依存性の注⼊)のサポート ‧GraphQLサポートが⼿厚い ‧ドキュメントが豊富 Quick Startをやってみて、GraphQL(Apollo

    Server)との連携部分が成熟してい る印象で、Dataloaderを使ったGraphQLのN+ 1 問題にも対応できそうと感じた 結果として、前のスライドのような問題はなかった
  7. ORM 1 8 ‧Prisma ‧TypeORM 候補としては以下のORMがあった ‧スキーマに合わせて⾃動⽣成される型安全なクライアントライブラリ ‧Prismaの機能としてN+ 1 問題の最適化機能がある

    TypeORMと⽐較し、Prismaを採⽤した理由は以下の通り 結果的には、あまりメリットを活かせなかった(詳しくは後述の章)
  8. 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スタック
  9. コンテナイメージ作成 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から必要なファイル群を取得 コード
  10. コンテナイメージ作成 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" ] ビルドステージから必要な資材のみコピーする コード
  11. src ᵓᴷᴷ auth/ // ೝূೝՄ(AuthGurad࣮૷) ᵓᴷᴷ handlers/ // ΠϯλϑΣʔε૚ │

    .. │ └── handler.module.ts // ΠϯλϑΣʔε/υϝΠϯ/ΠϯϑϥΛ1ͭͷϞδϡʔϧʹ͢Δ ᵓᴷᴷ domains/ // υϝΠϯ૚ ᵓᴷᴷ infrastructures/ // Πϯϑϥ૚ ᵓᴷᴷ app.module.ts // ϧʔτϞδϡʔϧ ᵓᴷᴷ main.ts // ΤϯτϦϙΠϯτ ᵓᴷᴷ graphql.ts // ࣗಈੜ੒ϑΝΠϧ └── utils/ // ϩΨʔઃఆͳͲ ソース構成 モジュール分割をせず、全てhandler.module.tsにまとめる 3 4
  12. 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
  13. 認証‧認可実装 3 6 認証にはLINEログインを利⽤ τʔΫϯͷछྨ ܗࣜ ݕূํ๏ *%5PLFO +85 )4

    ϔομʔϖΠϩʔυ෦ΛνϟϯωϧγʔΫϨοτͰ)."$ 4)"ϋογϡΛͱΓݕূ "DDFTT5PLFO ೚ҙจࣈྻ ݕূΤϯυϙΠϯτΛఏڙ ࠾༻
  14. 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; }; コード
  15. 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; } } コード コード
  16. 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, }); … コード
  17. 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を利⽤ コード
  18. 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); } コード
  19. ⾼い負荷が予測される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回で送信可能。サーバーに負荷を与える攻撃にもなり対策が必要。
  20. ⾼い負荷が予測される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})...} コード
  21. Field Suggestion対策 4 4 { "errors": [ { "message": "Cannot

    query fi eld \"sssions\" on type \"Query\". Did you mean \"sessions\"?", "extensions": { "code": "GRAPHQL_VALIDATION_FAILED" } } ] } フィールドのエラーメッセージでスキーマが推測されてしまう。 脆弱性に繋がるため、エラーハンドリングをする。
  22. 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" } ] } コード
  23. (前提) 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 コード
  24. 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に書いた環境変数を動的に展開して新しい環境変数 に再設定してくれる コード コード
  25. 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") } コード
  26. 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") } コード
  27. 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") } コード
  28. レスポンスを想像し、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" } ] } } }
  29. 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") } コード
  30. 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") } コード
  31. 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") } コード
  32. 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; マイグレーションを実⾏すると、外部キー制約が作成される
  33. 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をつけています。 参考
  34. Transactionはデフォルトタイム設定に注意 6 5 const records = await this.prismaClient. $transaction( async

    (tx) => { // TODO }, { maxWait: 2000, // default timeout: 20000, // default: 5000 }, ); 5秒以上かかり、例外にしたくないクエリ には設定が必要
  35. 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の確認が必須
  36. queryRawを使う 6 8 問題点 ‧type safeではない  ‧Number型だと思っていたら、BigInt型でJSONパースで例外になることもあった await tx.$queryRaw<SessionRecord[]>`SELECT *

    …`; 特徴 ‧``で括ったSQLで、引数はプリペアードステートメントになり実⾏  ‧意識せずインジェクション対策済み
  37. 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のテンプレートリテラルが必要 (インジェクション対策になる)
  38. 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); });
  39. 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で⾒落としがち)
  40. データ投⼊スクリプトには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実装の⼿間が少ない
  41. データ投⼊スクリプトには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`; }), )
  42. 開発を通して感じたこと 7 9 ‧NestJSとGraphQLの構成は成熟している  ‧豊富なドキュメントと実装例  ‧攻撃に対する対応策  ‧認証/認可周りもライブラリが充実しており、⼿軽に実装できる ‧GraphQLのDSLでフロント/バックエンドでスキーマ駆動開発が捗る ‧Prismaを使っていて、意図通りのクエリが作れないケースは少ない(詰まない)  ‧(課題)

    現状の状態だとリポジトリを跨ぐトランザクション要件に弱い  ‧(課題) queryRawに頼ると型安全性で問題あり、クエリビルダへ変更検討   ‧(対策) prismaのマイグレーションと型安全両⽅取りたい場合、マイグレー ションはprismaでクライアントはkysely構成もあり
  43. 最後に 8 0 ご清聴ありがとうございました github.com/shuntaka 9576 /devio 2 023 -nest

    他に気になる点や、サンプルコードが動作しない場合 は、以下のリポジトリでissueを起票して頂ければ、 可能な限り対応させて頂きます。
  44. 参考⽂献 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/)