NestJS meetup Online #1 NestJSで作るマルチテナントSaaS
\ 積極採⽤中 /2022年3⽉18⽇⼩笠原寛明(@xhiroga)NestJSで作るマルチテナントSaaSNestJS meetup Online #1
View Slide
⽬次\ お話したい /11. はじめに2. NestJS × マルチテナント × 認証3. NestJS × マルチテナント × MongoDB4. NestJS × マルチテナント × ロギング5. おわりに
\ あなたと⼀緒に働きたい! /はじめに
\ 積極採⽤中 /⾃⼰紹介3
\ 全職種採⽤中 /4
\ 積極採⽤中 /会社紹介⽇本初の商品を連発している保険会社です。5
\ 積極採⽤中 /会社紹介そのノウハウを元に保険SaaSを提供しています。6顧客 保険会社**事業会社や保険代理店のご利用も可能プラン選択本人認証告知・重要事項説明会員資格確認商品ページ(LP)&申込フォーム契約参照異動・解約決済契約更新お客様ポータル査定・承認問合せ提出書類の参照(電子データ)支払記録保険金請求フォーム
\ 積極採⽤中 /会社紹介保険業務をSaaSでなめらかにし、みなさんがよい保険にアクセスしやすいようにしています。7
\ あなたと⼀緒に働きたい! /サンプルコード
\ 全職種採⽤中 /9
\ あなたと⼀緒に働きたい! /NestJS × マルチテナント × 認証
\ 積極採⽤中 /TL;DR• 認可トークンを⽤いたテナントIDの取得を⼀箇所で⾏うため、AuthGuardを⽤いる• テナントIDをLoggerに注⼊するために、useClass構⽂を⽤いる11
\ 積極採⽤中 /AuthGuardを⽤いる• ヘッダーやパス、サブドメインからテナントIDを取得する場合、必ずしもAuthGuardは必要ではない• OAuthを⽤いて認可を⾏い、認可トークンからテナントIDを取得する場合、AuthGuardは必要• 今回のデモでは簡略化のためヘッダーからテナントIDを取得する12@Injectable()export class AuthGuard implements CanActivate {constructor(private readonly logger: PinoLogger) { }canActivate(context: ExecutionContext): boolean | Promise |Observable {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}}}
\ 積極採⽤中 /useClassを⽤いる• NestJSでは、GuardのようなMiddleWareもProviderである• ただし、`app.useGlobalGuards()` で追加した場合、DIのタイミングを逃してしまう• AppModuleのようなトップレベルのモジュールに対し、特定のInjectionTokenを⽤いてInjectすることで、DIのタイミングを逃さずにGlobalGuard同様に運⽤できる13
\ 積極採⽤中 /useClassを⽤いるDIのタイミングを逃す例14app.useGlobalGuards(new AuthGuard());@Module({imports: […],controllers: […],providers: [AppService,{provide: APP_GUARD,useClass: AuthGuard}],})export class AppModule { }AppModuleに対して明⽰的にInjectしている例
\ 積極採⽤中 /デモ15
\ 積極採⽤中 /まとめ• AuthGuardで認証を⾏い、テナントIDをどこからでも取得可能にした• useClass構⽂でトップレベルのモジュールにAuthGuardを注⼊することで、LoggerをDIできた16
\ あなたと⼀緒に働きたい! /NestJS × マルチテナント × MongoDB
\ 積極採⽤中 /TL;DR• MongoDBのDatabaseでテナントを分割した• ORMにMongooseを選定した• MongooseのコネクションはDatabaseと1:1• リクエストスコープでMongooseをInjectするとメモリ不⾜になる• Serviceのメソッド実⾏時、適切なコネクションでModelを⽣成する18
\ 積極採⽤中 /MongoDBのDatabaseでテナントを分割した前提• AWS DocumentDBを⽤いる• テナントごとにデータを分離する必要がある19単位 Pros ConsCluster セキュリティが最も⾼い インフラ・管理コストのいずれも⾼いDatabase インフラ費⽤がテナント数に⽐例しない。RBACを活⽤しやすいDatabaseをまたいだJOINはできあいので、マスターデータとテナント固有データを合わせて使うには⼯夫が必要Collection インフラ費⽤がテナント数に⽐例しない。コネクションを使い回せるので、パフォーマンスが⾼い特定のテナントのみアクセス可能なRoleをアクセスするのが⾯倒Row インフラ費⽤がテナント数に⽐例しない。実装は簡単MongoDBはRLSをサポートしていない
\ 積極採⽤中 /ORMにMongooseを選定したMongoDB事例の多さから、⼿堅くMongooseを選定しました。20Package NestJS Support Pros Consmongoose 公式Module 実績あり。NestJS公式ドキュメントに記載あり。トランザクション使える。複数Connectionをサポートしていない。Typegoose 公式サポートなし クラスやデコレーターを使ってModelを素早く構築できる設定難易度が⾼いTypeORM 公式Moduleあり 実績あり(ただしRDBの割合⾼) MongoDB4系をサポートしていないため、トランザクションが使えないMikroORM MikroORM公式のNestJS Module事例が少ないPrisma 公式ドキュメント解説のみMongoDBサポートがPreviewMongoDB SDK 柔軟性は⾼い ORM相当の処理を⾃分で書くなら
\ 積極採⽤中 /リクエストスコープでMongooseをInjectするとメモリ不⾜になる• 複数のDatabaseにテナントごとに接続するには、複数のConnectionをリクエストに応じて使い分ける必要がある• 公式のMongooseModuleは複数Connectionの保持に対応していない• 最も簡単なやり⽅はMongooseModuleをリクエストスコープで⽣成することだが…21https://stackoverflow.com/questions/55571382/how-to-change-a-database-connection-dynamically-with-request-scope-providers-inその後、必要なものをリクエストにアタッチし、リクエストごとにデータベースを変更できます。したがって、Scope.REQUESTを使⽤します。注⼊スコープの詳細については、ドキュメントを参照してください。以前に作成した同じ接続を使⽤するにはどうすればよいですか。
\ 積極採⽤中 /デモ22
\ 積極採⽤中 /Serviceのメソッド実⾏時、適切なコネクションでModelを⽣成するやりかたは2通り考えられる。1. DIを使わない。ConnectionのPoolを⾃前で持ち、サービスの呼び出し時にModelを⽣成する。2. DIを使う。Modelをリクエストスコープで宣⾔し、ConnectionのPoolをするProviderをInjectする。23
\ 積極採⽤中 /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;}}
\ 積極採⽤中 /DIを使うサンプル25export 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 stringconsole.debug(`dogModelFactory.useFactory(): tenantId=${tenantId}`)return mongoConnectionMapProvider.getConnection(tenantId).model("Dog",DogSchema)},inject: [MongoConnectionMapProvider, REQUEST]}
\ 積極採⽤中 /デモ26
\ 積極採⽤中 /まとめ単にMongooseModuleをリクエストスコープで利⽤するとコネクション数に問題が発⽣する。MongoDBのコネクションを⾃前で管理し、Model⽣成時に適切に注⼊することで要件とパフォーマンスを両⽴できる。27
\ あなたと⼀緒に働きたい! /NestJS × マルチテナント × ロギング
\ 積極採⽤中 /TL;DR• ログにリクエストIDとテナントIDを含める• 全てのErrorをCatchするExceptionsFilterを実装し、エラーを確実にログする29
\ 積極採⽤中 /ログにリクエストIDとテナントIDを含める。• ログへのリクエスト情報付与のために、nestjs-pinoを⽤いる• AuthGuardでリクエストIDを取得する際に、loggerにテナントIDをassignすることで、そのリクエストに対するログにテナントIDを付与できる(厳密なスコープは未検証)30canActivate(context: ExecutionContext,): boolean | Promise |Observable {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}}
\ 積極採⽤中 /デモ31
\ 積極採⽤中 /全ての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,) { }…
\ 積極採⽤中 /デモ33
\ 積極採⽤中 /まとめ• nestjs-pinoでログを整形し、かつリクエストIDとテナントIDを含める• ⾃前で実装したExceptionsFilterをuseClass構⽂を⽤いて注⼊することで、全てのエラーをテナントID付きでログに出⼒できる34
\ あなたと⼀緒に働きたい! /おわりに
\ 積極採⽤中 /チームについて• 今回ご紹介したのは、justInCaseTechnologiesでの取り組みの⼀部です• 私だけでなく、チームメンバーと⼀緒に取り組んだ成果でもあります• もっと知りたいという⽅、ぜひお話したいです!36
\ 積極採⽤中 /チームの技術スタックバックエンド• 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 Actions37
\ 積極採⽤中 /チームの⽂化• 物理的なホワイトボードの良さも認めるが、全員リモートワークが⼤好き• フルスタックかつ専⾨領域がある、T字型スキル志向の⽅が多い• いらないMTGを消したときに達成感を覚える• Slackのハドルで突発的にペアプロが始まる• 議論の前に、NotionでPros/Consを整理しておく• 同じくらいのスキルの⼈がいたら、よりチームにないバックグラウンドの⼈にオファーする• いい仕事をした時はお互いに褒め合う• スクラム開発を尊ぶ38
\ 全職種採⽤中 /39
\ あなたと⼀緒に働きたい! /Thank you for listening!!!