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

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

hiroga
March 18, 2022

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

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

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

    View Slide

  2. ⽬次
    \ お話したい /
    1
    1. はじめに
    2. NestJS × マルチテナント × 認証
    3. NestJS × マルチテナント × MongoDB
    4. NestJS × マルチテナント × ロギング
    5. おわりに

    View Slide

  3. \ あなたと⼀緒に働きたい! /
    はじめに

    View Slide

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

    View Slide

  5. \ 全職種採⽤中 /
    4

    View Slide

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

    View Slide

  7. \ 積極採⽤中 /
    会社紹介
    そのノウハウを元に保険SaaSを提供しています。
    6
    顧客 保険会社*
    *事業会社や保険代理店
    のご利用も可能
    プラン選択
    本人認証
    告知・重要事項説明
    会員資格確認
    商品ページ(LP)

    申込フォーム
    契約参照
    異動・解約
    決済
    契約更新
    お客様
    ポータル
    査定・承認
    問合せ
    提出書類の参照
    (電子データ)
    支払記録
    保険金
    請求フォーム

    View Slide

  8. \ 積極採⽤中 /
    会社紹介
    保険業務をSaaSでなめらかにし、みなさんがよい保険にアクセスしやすいようにしています。
    7

    View Slide

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

    View Slide

  10. \ 全職種採⽤中 /
    9

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  14. \ 積極採⽤中 /
    useClassを⽤いる
    • NestJSでは、GuardのようなMiddleWareもProviderである
    • ただし、`app.useGlobalGuards()` で追加した場合、DIのタイミングを逃してしまう
    • AppModuleのようなトップレベルのモジュールに対し、特定のInjectionTokenを⽤いてInjectすることで、DIのタイミングを
    逃さずにGlobalGuard同様に運⽤できる
    13

    View Slide

  15. \ 積極採⽤中 /
    useClassを⽤いる
    DIのタイミングを逃す例
    14
    app.useGlobalGuards(new AuthGuard());
    @Module({
    imports: […],
    controllers: […],
    providers: [
    AppService,
    {
    provide: APP_GUARD,
    useClass: AuthGuard
    }
    ],
    })
    export class AppModule { }
    AppModuleに対して明⽰的にInjectしている例

    View Slide

  16. \ 積極採⽤中 /
    デモ
    15

    View Slide

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

    View Slide

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

    View Slide

  19. \ 積極採⽤中 /
    TL;DR
    • MongoDBのDatabaseでテナントを分割した
    • ORMにMongooseを選定した
    • MongooseのコネクションはDatabaseと1:1
    • リクエストスコープでMongooseをInjectするとメモリ不⾜になる
    • Serviceのメソッド実⾏時、適切なコネクションでModelを⽣成する
    18

    View Slide

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

    View Slide

  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相当の処理を⾃分で書くなら

    View Slide

  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を使⽤します。注⼊スコープの詳細については、ドキュメントを参照してください。
    以前に作成した同じ接続を使⽤するにはどうすればよいですか。

    View Slide

  23. \ 積極採⽤中 /
    デモ
    22

    View Slide

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

    View Slide

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

    View Slide

  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]
    }

    View Slide

  27. \ 積極採⽤中 /
    デモ
    26

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  32. \ 積極採⽤中 /
    デモ
    31

    View Slide

  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,
    ) { }

    View Slide

  34. \ 積極採⽤中 /
    デモ
    33

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  39. \ 積極採⽤中 /
    チームの⽂化
    • 物理的なホワイトボードの良さも認めるが、全員リモートワークが⼤好き
    • フルスタックかつ専⾨領域がある、T字型スキル志向の⽅が多い
    • いらないMTGを消したときに達成感を覚える
    • Slackのハドルで突発的にペアプロが始まる
    • 議論の前に、NotionでPros/Consを整理しておく
    • 同じくらいのスキルの⼈がいたら、よりチームにないバックグラウンドの⼈にオファーする
    • いい仕事をした時はお互いに褒め合う
    • スクラム開発を尊ぶ
    38

    View Slide

  40. \ 全職種採⽤中 /
    39

    View Slide

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

    View Slide