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

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

Avatar for shuntaka shuntaka
July 08, 2023
16k

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

Avatar for shuntaka

shuntaka

July 08, 2023
Tweet

More Decks by shuntaka

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/)