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

NestJSで作るマルチテナントSaaS / Multi-tenant NestJS-based SaaS

8e8410eb785755ae2cb485f85dbab6e8?s=47 hiroga
March 18, 2022

NestJSで作るマルチテナントSaaS / Multi-tenant NestJS-based SaaS

NestJS meetup Online #1
NestJSで作るマルチテナントSaaS

8e8410eb785755ae2cb485f85dbab6e8?s=128

hiroga

March 18, 2022
Tweet

More Decks by hiroga

Other Decks in Technology

Transcript

  1. \ 積極採⽤中 / 2022年3⽉18⽇ ⼩笠原寛明(@xhiroga) NestJSで作るマルチテナントSaaS NestJS meetup Online #1

  2. ⽬次 \ お話したい / 1 1. はじめに 2. NestJS ×

    マルチテナント × 認証 3. NestJS × マルチテナント × MongoDB 4. NestJS × マルチテナント × ロギング 5. おわりに
  3. \ あなたと⼀緒に働きたい! / はじめに

  4. \ 積極採⽤中 / ⾃⼰紹介 3

  5. \ 全職種採⽤中 / 4

  6. \ 積極採⽤中 / 会社紹介 ⽇本初の商品を連発している保険会社です。 5

  7. \ 積極採⽤中 / 会社紹介 そのノウハウを元に保険SaaSを提供しています。 6 顧客 保険会社* *事業会社や保険代理店 のご利用も可能

    プラン選択 本人認証 告知・重要事項説明 会員資格確認 商品ページ(LP) & 申込フォーム 契約参照 異動・解約 決済 契約更新 お客様 ポータル 査定・承認 問合せ 提出書類の参照 (電子データ) 支払記録 保険金 請求フォーム
  8. \ 積極採⽤中 / 会社紹介 保険業務をSaaSでなめらかにし、みなさんがよい保険にアクセスしやすいようにしています。 7

  9. \ あなたと⼀緒に働きたい! / サンプルコード

  10. \ 全職種採⽤中 / 9

  11. \ あなたと⼀緒に働きたい! / NestJS × マルチテナント × 認証

  12. \ 積極採⽤中 / TL;DR • 認可トークンを⽤いたテナントIDの取得を⼀箇所で⾏うため、AuthGuardを⽤いる • テナントIDをLoggerに注⼊するために、useClass構⽂を⽤いる 11

  13. \ 積極採⽤中 / AuthGuardを⽤いる • ヘッダーやパス、サブドメインからテナントIDを取得する場合、必ずしもAuthGuardは必要ではない • OAuthを⽤いて認可を⾏い、認可トークンからテナントIDを取得する場合、AuthGuardは必要 • 今回のデモでは簡略化のためヘッダーからテナントIDを取得する

    12 @Injectable() export class AuthGuard implements CanActivate { constructor(private readonly logger: PinoLogger) { } canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> { const request: Request = context.switchToHttp().getRequest(); … const tenantId = request.headers['x-tenant-id’]; if (tenantId !== undefined) { request['tenantId'] = tenantId; this.logger.debug(`canActivate(): tenantId=${tenantId}`); this.logger.assign({ tenantId }) return true } } }
  14. \ 積極採⽤中 / useClassを⽤いる • NestJSでは、GuardのようなMiddleWareもProviderである • ただし、`app.useGlobalGuards()` で追加した場合、DIのタイミングを逃してしまう •

    AppModuleのようなトップレベルのモジュールに対し、特定のInjectionTokenを⽤いてInjectすることで、DIのタイミングを 逃さずにGlobalGuard同様に運⽤できる 13
  15. \ 積極採⽤中 / useClassを⽤いる DIのタイミングを逃す例 14 app.useGlobalGuards(new AuthGuard()); @Module({ imports:

    […], controllers: […], providers: [ AppService, { provide: APP_GUARD, useClass: AuthGuard } ], }) export class AppModule { } AppModuleに対して明⽰的にInjectしている例
  16. \ 積極採⽤中 / デモ 15

  17. \ 積極採⽤中 / まとめ • AuthGuardで認証を⾏い、テナントIDをどこからでも取得可能にした • useClass構⽂でトップレベルのモジュールにAuthGuardを注⼊することで、LoggerをDIできた 16

  18. \ あなたと⼀緒に働きたい! / NestJS × マルチテナント × MongoDB

  19. \ 積極採⽤中 / TL;DR • MongoDBのDatabaseでテナントを分割した • ORMにMongooseを選定した • MongooseのコネクションはDatabaseと1:1

    • リクエストスコープでMongooseをInjectするとメモリ不⾜になる • Serviceのメソッド実⾏時、適切なコネクションでModelを⽣成する 18
  20. \ 積極採⽤中 / MongoDBのDatabaseでテナントを分割した 前提 • AWS DocumentDBを⽤いる • テナントごとにデータを分離する必要がある

    19 単位 Pros Cons Cluster セキュリティが最も⾼い インフラ・管理コストのいずれも⾼い Database インフラ費⽤がテナント数に⽐例しない 。RBACを活⽤しやすい DatabaseをまたいだJOINはできあいので、 マスターデータとテナント固有データを合 わせて使うには⼯夫が必要 Collection インフラ費⽤がテナント数に⽐例しない 。コネクションを使い回せるので、パフ ォーマンスが⾼い 特定のテナントのみアクセス可能なRoleを アクセスするのが⾯倒 Row インフラ費⽤がテナント数に⽐例しない 。実装は簡単 MongoDBはRLSをサポートしていない
  21. \ 積極採⽤中 / ORMにMongooseを選定した MongoDB事例の多さから、⼿堅くMongooseを選定しました。 20 Package NestJS Support Pros

    Cons mongoose 公式Module 実績あり。NestJS公式ドキュメン トに記載あり。トランザクション 使える。 複数Connectionをサポートしてい ない。 Typegoose 公式サポートなし クラスやデコレーターを使って Modelを素早く構築できる 設定難易度が⾼い TypeORM 公式Moduleあり 実績あり(ただしRDBの割合⾼) MongoDB4系をサポートしていな いため、トランザクションが使え ない MikroORM MikroORM公式の NestJS Module 事例が少ない Prisma 公式ドキュメント 解説のみ MongoDBサポートがPreview MongoDB SDK 柔軟性は⾼い ORM相当の処理を⾃分で書くなら
  22. \ 積極採⽤中 / リクエストスコープでMongooseをInjectするとメモリ不⾜になる • 複数のDatabaseにテナントごとに接続するには、複数のConnectionをリクエストに応じて使い分ける必要がある • 公式のMongooseModuleは複数Connectionの保持に対応していない • 最も簡単なやり⽅はMongooseModuleをリクエストスコープで⽣成することだが…

    21 https://stackoverflow.com/questions/55571382/how-to-change-a-database-connection-dynamically-with-request-scope-providers-in その後、必要なものをリクエストにアタッチし、リクエストごとにデータベースを変更できます。 したがって、Scope.REQUESTを使⽤します。注⼊スコープの詳細については、ドキュメントを参照してください。 以前に作成した同じ接続を使⽤するにはどうすればよいですか。
  23. \ 積極採⽤中 / デモ 22

  24. \ 積極採⽤中 / Serviceのメソッド実⾏時、適切なコネクションでModelを⽣成する やりかたは2通り考えられる。 1. DIを使わない。ConnectionのPoolを⾃前で持ち、サービスの呼び出し時にModelを⽣成する。 2. DIを使う。Modelをリクエストスコープで宣⾔し、ConnectionのPoolをするProviderをInjectする。 23

  25. \ 積極採⽤中 / DIを使わないサンプル 24 @Injectable() export const CatsService {

    constructor() { private readonly connectionProvider: ConnectionProvider; } async getCats() { const connection = await this.connectionProvider.getConnection(); const cats = await connection.model('cats').find(); return cats; } } @Injectable() export class ConnectionProvider{ // 省略 getConnection() { const tenant = this.request.params.tenantId; } }
  26. \ 積極採⽤中 / DIを使うサンプル 25 export const DogSchema = SchemaFactory.createForClass(Dog);

    export const DogModelInjectionToken = "DogModel" export const dogModelFactory = { provide: DogModelInjectionToken, useFactory: (mongoConnectionMapProvider: MongoConnectionMapProvider, request: Request & { tenantId: string }) => { const tenantId = request.headers['x-tenant-id'] as string console.debug(`dogModelFactory.useFactory(): tenantId=${tenantId}`) return mongoConnectionMapProvider.getConnection(tenantId).model("Dog", DogSchema) }, inject: [MongoConnectionMapProvider, REQUEST] }
  27. \ 積極採⽤中 / デモ 26

  28. \ 積極採⽤中 / まとめ 単にMongooseModuleをリクエストスコープで利⽤するとコネクション数に問題が発⽣する。 MongoDBのコネクションを⾃前で管理し、Model⽣成時に適切に注⼊することで要件とパフォーマンスを両⽴できる。 27

  29. \ あなたと⼀緒に働きたい! / NestJS × マルチテナント × ロギング

  30. \ 積極採⽤中 / TL;DR • ログにリクエストIDとテナントIDを含める • 全てのErrorをCatchするExceptionsFilterを実装し、エラーを確実にログする 29

  31. \ 積極採⽤中 / ログにリクエストIDとテナントIDを含める。 • ログへのリクエスト情報付与のために、nestjs-pinoを⽤いる • AuthGuardでリクエストIDを取得する際に、loggerにテナントIDをassignすることで、そのリクエストに対するログにテ ナントIDを付与できる(厳密なスコープは未検証) 30

    canActivate(context: ExecutionContext,): boolean | Promise<boolean> | Observable<boolean> { const request: Request = context.switchToHttp().getRequest(); const tenantId = request.headers['x-tenant-id’]; if (tenantId !== undefined) { request['tenantId'] = tenantId; this.logger.debug(`canActivate(): tenantId=${tenantId}`); this.logger.assign({ tenantId }) return true } }
  32. \ 積極採⽤中 / デモ 31

  33. \ 積極採⽤中 / 全てのErrorをCatchするExceptionsFilterを実装し、エラーを確実にログする • NestJSは、デフォルトでは全てのエラーをロギングするわけではない • ドキュメントの通り、全てのエラーをキャッチするExceptionsFilterを実装する • useClass構⽂を⽤いてExceptionsFilterを注⼊することで、AuthGuardで設定したPinoLoggerを利⽤できる。

    32 @Catch() export class AllExceptionsFilter implements ExceptionFilter { constructor( private readonly httpAdapterHost: HttpAdapterHost, private readonly logger: PinoLogger, ) { } …
  34. \ 積極採⽤中 / デモ 33

  35. \ 積極採⽤中 / まとめ • nestjs-pinoでログを整形し、かつリクエストIDとテナントIDを含める • ⾃前で実装したExceptionsFilterをuseClass構⽂を⽤いて注⼊することで、全てのエラーをテナントID付きでログに出⼒で きる 34

  36. \ あなたと⼀緒に働きたい! / おわりに

  37. \ 積極採⽤中 / チームについて • 今回ご紹介したのは、justInCaseTechnologiesでの取り組みの⼀部です • 私だけでなく、チームメンバーと⼀緒に取り組んだ成果でもあります • もっと知りたいという⽅、ぜひお話したいです!

    36
  38. \ 積極採⽤中 / チームの技術スタック バックエンド • NestJS, Fastify • pnpm

    • TypeScript フロントエンド • React, Redux, Next.js • MUI, Emotion, Storybook • Jest, SWC • TypeScript インフラ • AWS, ECS, DocumentDB, Backup, ALB, WAF, GuardDuty, CodePipeline, Control Tower, AWS SSO, CDK v2, TypeScript • Vercel • GitHub Actions 37
  39. \ 積極採⽤中 / チームの⽂化 • 物理的なホワイトボードの良さも認めるが、全員リモートワークが⼤好き • フルスタックかつ専⾨領域がある、T字型スキル志向の⽅が多い • いらないMTGを消したときに達成感を覚える

    • Slackのハドルで突発的にペアプロが始まる • 議論の前に、NotionでPros/Consを整理しておく • 同じくらいのスキルの⼈がいたら、よりチームにないバックグラウンドの⼈にオファーする • いい仕事をした時はお互いに褒め合う • スクラム開発を尊ぶ 38
  40. \ 全職種採⽤中 / 39

  41. \ あなたと⼀緒に働きたい! / Thank you for listening!!!