Slide 1

Slide 1 text

NestJS x Prisma on Fargate構成で作るWeb API開発Tips 2 02 3 / 07 / 08 CX事業本部 髙橋俊⼀ a.k.a shuntaka

Slide 2

Slide 2 text

スライド 2

Slide 3

Slide 3 text

スライド 3 speakerdeck.com/shuntaka (⼀番最新のスライド) #devio 2 0 23 @shuntaka_jp

Slide 4

Slide 4 text

⾃⼰紹介 4

Slide 5

Slide 5 text

⾃⼰紹介 5 shuntaka/髙橋 俊⼀ 主な業務内容 ‧サーバサイド ‧インフラ クラスメソッド CX事業本部 19年8⽉⼊社 基本的にAWSでサーバーサイド開発することが多いです! ‧IoTバックエンド開発 ‧IoTエッジソフトウェア開発 shuntaka.dev/who 詳細はこちら! 好きな技術 ‧TypeScript/Go/Neovim

Slide 6

Slide 6 text

⾃作ツールの宣伝(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..

Slide 7

Slide 7 text

本セッションについて 7

Slide 8

Slide 8 text

サンプルコード 8 本セッションで解説する内容やコードは、動作するものをGitHubに⽤意しておりま す。 github.com/shuntaka 9576 /devio 2 023 -nest セッションで必要な範囲は、スライドに書いていますので、後⽇全体感を確認した い場合にご利⽤ください。

Slide 9

Slide 9 text

前提 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)を前提としています。

Slide 10

Slide 10 text

セッション概要 1 0 ‧技術選定 ‧インフラ構成 ‧プロジェクト構成 ‧NestJSのホスティング ‧NestJS(GraphQL)のTips ‧PrismaのTips ‧その他でやって良かったこと ‧さいごに ‧参考⽂献

Slide 11

Slide 11 text

技術選定 1 1

Slide 12

Slide 12 text

チーム構成 1 2 チームの体制 ‧サーバサイド(2名) ‧フロントエンド(2名) ‧UXデザイナー(1名) ‧デザイナー(2名)

Slide 13

Slide 13 text

システム要件 1 3 システムの要件として、以下の通り => 複雑な検索や集計の要件を考慮し、RDBMSを利⽤することに決定 ‧⼀般のお客様がワード⼊⼒して、検索するWebアプリ ‧認証はLINE ‧検索要件  ‧複数の条件を使った絞り込み検索機能  ‧特定条件で絞り込んだものを集計し、上位複数を表⽰する機能

Slide 14

Slide 14 text

ホスティング環境 1 4 ‧Amazon Elastic Container Service (Fargate) ‧AWS App Runner 技術選定時(2022年9⽉) AWS App RunnerはAWS WAFに対応しておらず、 ECS(Fargate)を採⽤。 今選定するならデプロイの観点で懸念はあるが、App Runnerも選択肢に⼊る。

Slide 15

Slide 15 text

GraphQLの採⽤ 1 5 以下の理由でGraphQLを採⽤した ‧効率的なデータフェッチ ‧ApolloやRelayといった強⼒なクライアントライブラリの存在 ‧shemaファイルで型定義をバックエンド、フロントエンドで共有可能 ‧NestJSのGraphQLエコシステムが成熟しており、導⼊が容易

Slide 16

Slide 16 text

Webアプリケーションフレームワークの技術選定 1 6 私⾃⾝サーバーレスでフレームワークを使わずにアプリを作ることが多く、Web アプリケーションフレームワークを使うことに対する不安があった ‧カスタマイズ性が少なく、柔軟に実装ができなそう ‧FWのお作法を学ぶ時間がかかりそう

Slide 17

Slide 17 text

Webアプリケーションフレームワークの技術選定 1 7 NestJSの社内外の評判を⽬にすることが多く検討 ‧TypeScriptサポート ‧DI(依存性の注⼊)のサポート ‧GraphQLサポートが⼿厚い ‧ドキュメントが豊富 Quick Startをやってみて、GraphQL(Apollo Server)との連携部分が成熟してい る印象で、Dataloaderを使ったGraphQLのN+ 1 問題にも対応できそうと感じた 結果として、前のスライドのような問題はなかった

Slide 18

Slide 18 text

ORM 1 8 ‧Prisma ‧TypeORM 候補としては以下のORMがあった ‧スキーマに合わせて⾃動⽣成される型安全なクライアントライブラリ ‧Prismaの機能としてN+ 1 問題の最適化機能がある TypeORMと⽐較し、Prismaを採⽤した理由は以下の通り 結果的には、あまりメリットを活かせなかった(詳しくは後述の章)

Slide 19

Slide 19 text

Infrastructure as Code 1 9 IaCには、CDKを採⽤ ‧社内で利⽤が活発、CDKのサンプルコードが豊富 ‧⾃分が慣れている

Slide 20

Slide 20 text

プロジェクト構成 2 0

Slide 21

Slide 21 text

プロジェクト構成 2 1 モノレポを採⽤、ディレクトリルートは以下のような形式 infra/ CDK定義(フロントエンド,サーバサイド) monitoring/ CDK定義(監視系) backend/ NestJSアプリケーションコード frontend/ Next.jsアプリケーションコード schema.graphql GraphQL定義ファイル …

Slide 22

Slide 22 text

インフラ構成 2 2

Slide 23

Slide 23 text

構成図 2 3

Slide 24

Slide 24 text

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スタック

Slide 25

Slide 25 text

NestJSのホスティング 2 5

Slide 26

Slide 26 text

コンテナイメージ作成 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から必要なファイル群を取得 コード

Slide 27

Slide 27 text

コンテナイメージ作成 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" ] ビルドステージから必要な資材のみコピーする コード

Slide 28

Slide 28 text

クロスアカウントレプリケーションを使ったECR運⽤ 2 8 コード

Slide 29

Slide 29 text

クロスアカウントレプリケーションを使ったECR運⽤ 2 9 指定したタグを開発⽤ECRから取得 指定したタグを付与して本番⽤ECRへpush

Slide 30

Slide 30 text

NestJS(GraphQL)のTips 3 0

Slide 31

Slide 31 text

GraphQLスキーマ開発体制 3 1 GraphQLスキーマ定義の開発⼿法は2つ コードファースト スキーマファースト

Slide 32

Slide 32 text

GraphQLスキーマ開発体制 3 2 スキーマファーストを採⽤ ‧スクラムを採⽤しており、スプリント内で機能を完成させたい  ‧スキーマが遅れると、その分フロント側の開発着⼿が遅れる ‧フロントエンド/バックエンドどちらでもスキーマ変更の提案ができる  ‧コードファーストだとスキーマがバックエンド側の実装に引き込まれる ‧スキーマで⼿戻りが発⽣しても、ペアプロで即座に解消できる少⼈数体制

Slide 33

Slide 33 text

GraphQLスキーマ開発体制 3 3 ‧スキーマ定義は、バックエンド/フロントエンドで議論が起きやすい  ‧命名規則, レスポンス, etc.. ‧PRベースの⾮同期レビューをせず、同期でレビューを実施

Slide 34

Slide 34 text

src ᵓᴷᴷ auth/ // ೝূೝՄ(AuthGurad࣮૷) ᵓᴷᴷ handlers/ // ΠϯλϑΣʔε૚ │ .. │ └── handler.module.ts // ΠϯλϑΣʔε/υϝΠϯ/ΠϯϑϥΛ1ͭͷϞδϡʔϧʹ͢Δ ᵓᴷᴷ domains/ // υϝΠϯ૚ ᵓᴷᴷ infrastructures/ // Πϯϑϥ૚ ᵓᴷᴷ app.module.ts // ϧʔτϞδϡʔϧ ᵓᴷᴷ main.ts // ΤϯτϦϙΠϯτ ᵓᴷᴷ graphql.ts // ࣗಈੜ੒ϑΝΠϧ └── utils/ // ϩΨʔઃఆͳͲ ソース構成 モジュール分割をせず、全てhandler.module.tsにまとめる 3 4

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

認証‧認可実装 3 6 認証にはLINEログインを利⽤ τʔΫϯͷछྨ ܗࣜ ݕূํ๏ *%5PLFO +85 )4 ϔομʔϖΠϩʔυ෦ΛνϟϯωϧγʔΫϨοτͰ)."$ 4)"ϋογϡΛͱΓݕূ "DDFTT5PLFO ೚ҙจࣈྻ ݕূΤϯυϙΠϯτΛఏڙ ࠾༻

Slide 37

Slide 37 text

AccessToken検証⽅法 3 7 export const verifyAccessToken = async ( accessToken: string, ): Promise => { const res = await Axios.request({ 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; }; コード

Slide 38

Slide 38 text

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 { 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; } } コード コード

Slide 39

Slide 39 text

AccessToken検証⽅法 3 9 @UseGuradsアノテーションを使⽤して、GraphQLに認可処理を追加 @UseGuards(GraphqlLineAuthGuard) @Query(() => SessionConnection) async sessions( @CurrentUser() user: UserPro fi le, @Args() option?: unknown, ): Promise { try { this.logger.log('CalledSessions', { option: option, user: user, }); … コード

Slide 40

Slide 40 text

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を利⽤ コード

Slide 41

Slide 41 text

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); } コード

Slide 42

Slide 42 text

⾼い負荷が予測される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回で送信可能。サーバーに負荷を与える攻撃にもなり対策が必要。

Slide 43

Slide 43 text

⾼い負荷が予測される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})...} コード

Slide 44

Slide 44 text

Field Suggestion対策 4 4 { "errors": [ { "message": "Cannot query fi eld \"sssions\" on type \"Query\". Did you mean \"sessions\"?", "extensions": { "code": "GRAPHQL_VALIDATION_FAILED" } } ] } フィールドのエラーメッセージでスキーマが推測されてしまう。 脆弱性に繋がるため、エラーハンドリングをする。

Slide 45

Slide 45 text

Field Suggestion対策 4 5 @Module({ controllers: [HealthCheckController], providers: [ComplexityPlugin], imports: [ AuthModule, GraphQLModule.forRoot({ (தུ) 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" } ] } コード

Slide 46

Slide 46 text

ロガー 4 6 要件 ‧ユーザー毎/リクエスト毎に処理動向が追えれば良い 簡易に⽤意できるCloudWatch Logsを活⽤ ‧ライブラリは、成熟しているwinstonを利⽤  ‧pinoがパフォーマンス的には良いらしい…要検証 ‧Node.jsのAsyncLocalStorageに以下の情報を格納し、JSONで吐き出すように  ‧x-amzn-trace-id(ALBのリクエストに⼀意な値)  ‧LINEのuserId  ‧サービスのuserId コード

Slide 47

Slide 47 text

PrismaのTips 4 7

Slide 48

Slide 48 text

(前提) 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 コード

Slide 49

Slide 49 text

Prismaの接続先を動的に変更する 4 9 DATABASE_URL= mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST:3306/$DB_DBNAME SecretMangerの値を動的に連結して、CDK経由でタスク定義の環境変数に設 定できない NG!

Slide 50

Slide 50 text

Prismaの接続先を動的に変更する 5 0 コンテナビルド時に接続先情報を.envにハードコードするか? ‧環境ごとにコンテナイメージが必要になる  ‧クロスアカウントレプリケーション不可になる ‧コンテナイメージがクレデンシャル化する => 解決策: dotenv-expandを使う

Slide 51

Slide 51 text

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に書いた環境変数を動的に展開して新しい環境変数 に再設定してくれる コード コード

Slide 52

Slide 52 text

Prismaを使った実装例 5 2 登壇者の名前から、セッション⼀覧を引く SQLが必要 登壇者の名前から、セッション⼀覧を知りたい

Slide 53

Slide 53 text

スキーマを定義する 5 3 交差テーブル

Slide 54

Slide 54 text

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") } コード

Slide 55

Slide 55 text

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") } コード

Slide 56

Slide 56 text

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") } コード

Slide 57

Slide 57 text

レスポンスを想像し、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" } ] } } }

Slide 58

Slide 58 text

Prsimaで実装する場合2つの⽅法 5 8 前述ようなデータを取得する⽅法は以下の2つの⽅法がある。 ‧Relationsを使う⽅法 ‧queryRawを使う⽅法(ORM感はないが…

Slide 59

Slide 59 text

Relationsを使う 5 9 Relationsは、Prisma スキーマ内の 2 つのモデル間の接続する⽅法 ‧ 1 : 1 ‧ 1 :N ‧N:M

Slide 60

Slide 60 text

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") } コード

Slide 61

Slide 61 text

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") } コード

Slide 62

Slide 62 text

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") } コード

Slide 63

Slide 63 text

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; マイグレーションを実⾏すると、外部キー制約が作成される

Slide 64

Slide 64 text

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をつけています。 参考

Slide 65

Slide 65 text

Transactionはデフォルトタイム設定に注意 6 5 const records = await this.prismaClient. $transaction( async (tx) => { // TODO }, { maxWait: 2000, // default timeout: 20000, // default: 5000 }, ); 5秒以上かかり、例外にしたくないクエリ には設定が必要

Slide 66

Slide 66 text

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の確認が必須

Slide 67

Slide 67 text

Relationの問題点 6 7 Relationの問題点 ‧発⾏されるクエリがわかりにくい ‧少し冗⻑なクエリが発⾏される => チューニング状況などで、決まったクエリを実⾏したい場合、queryRawを利⽤

Slide 68

Slide 68 text

queryRawを使う 6 8 問題点 ‧type safeではない  ‧Number型だと思っていたら、BigInt型でJSONパースで例外になることもあった await tx.$queryRaw`SELECT * …`; 特徴 ‧``で括ったSQLで、引数はプリペアードステートメントになり実⾏  ‧意識せずインジェクション対策済み

Slide 69

Slide 69 text

queryRawを使う 6 9 await this.prismaClient.$transaction( async (tx) => { return await tx.$queryRaw`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のテンプレートリテラルが必要 (インジェクション対策になる)

Slide 70

Slide 70 text

queryRawUnsafeを使う 7 0 問題点 ‧セキュリティ(インジェクション)リスクがある await tx.$queryRawUnSafe`SELECT * …`; 特徴 ‧動的にSQL⽣成する場合、queryRawだと出来ないケースがあります

Slide 71

Slide 71 text

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); });

Slide 72

Slide 72 text

queryRawのtype safe問題 7 2 Prsimaはスキーマ定義とマイグレーションが強い反⾯、クライアント側の制限 が多い。 そこで、prisma-kyselyと⾔う選択肢。 Kysely (キーセーリー) は、型安全でオートコンプリート対応なTSのSQLクエリ ビルダ。Knex.jsのインスパイア。

Slide 73

Slide 73 text

queryRawのtype safe問題 7 3 そもそもの話になってしまうが、prismaのクライアントを使わず、kyselyのク ライアントをprismaのスキーマから⽣成

Slide 74

Slide 74 text

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で⾒落としがち)

Slide 75

Slide 75 text

その他でやって良かったこと 7 5

Slide 76

Slide 76 text

データベースへのデータ投⼊ 7 6 データベースへデータ投⼊要件 ‧マスターデータはお客さんのCSV  ‧CSVの形式はSJIS ‧過去データにも変更が⼊るため、都度truncateとimportが必要  ‧データ投⼊はMySQL LOAD DATA LOCAL INFLEを利⽤

Slide 77

Slide 77 text

データ投⼊スクリプトには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実装の⼿間が少ない

Slide 78

Slide 78 text

データ投⼊スクリプトには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`; }), )

Slide 79

Slide 79 text

さいごに 7 8

Slide 80

Slide 80 text

開発を通して感じたこと 7 9 ‧NestJSとGraphQLの構成は成熟している  ‧豊富なドキュメントと実装例  ‧攻撃に対する対応策  ‧認証/認可周りもライブラリが充実しており、⼿軽に実装できる ‧GraphQLのDSLでフロント/バックエンドでスキーマ駆動開発が捗る ‧Prismaを使っていて、意図通りのクエリが作れないケースは少ない(詰まない)  ‧(課題) 現状の状態だとリポジトリを跨ぐトランザクション要件に弱い  ‧(課題) queryRawに頼ると型安全性で問題あり、クエリビルダへ変更検討   ‧(対策) prismaのマイグレーションと型安全両⽅取りたい場合、マイグレー ションはprismaでクライアントはkysely構成もあり

Slide 81

Slide 81 text

最後に 8 0 ご清聴ありがとうございました github.com/shuntaka 9576 /devio 2 023 -nest 他に気になる点や、サンプルコードが動作しない場合 は、以下のリポジトリでissueを起票して頂ければ、 可能な限り対応させて頂きます。

Slide 82

Slide 82 text

参考⽂献 8 2

Slide 83

Slide 83 text

参考⽂献 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/)

Slide 84

Slide 84 text

Special Thanks 8 4 ‧otasさん(https://dev.classmethod.jp/author/ota-seiji/) ‧⻄⽥将幸さん(https://dev.classmethod.jp/author/nishida-masayuki/) 技術的なご相談させて頂いた⽅ありがとうございます! ‧Satoshi Notoさん(https://dev.classmethod.jp/author/noto-satoshi/) ‧佐藤智樹さん(https://dev.classmethod.jp/author/sato-tomoki/)

Slide 85

Slide 85 text

No content