こんにちは、Omni Hub チームの shunten31 です.
マルチテナント SaaS と テナントコンテキスト
Omni Hub は マルチテナント型の SaaS となっています. マルチテナントとは、単一のアプリケーションを複数のテナント(企業や集団) がそれぞれ利用する形式です. Omni Hub の場合は、 小売業者の企業がテナントとなります.
上記の書籍では、テナントコンテキストという概念が紹介されています. マルチテナントSaaS においては、 アプリケーションは常に特定のテナントのコンテキストを以て動作しますが、 そこでのテナントのコンテキストやそれを表すデータ型をテナントコンテキストと呼びます (そのままですが).
この記事では、 Omni Hub にテナントコンテキストを導入した方法を取り上げます.
Omni Hub におけるテナント
Omni Hub は、 EC (Shopify) と POS (スマレジ、Square POS) をつなぐソフトウェアであるという性質上、 それぞれのプラットフォームにおいてテナントID が存在しています. Shopify では、 example.myshopify.com のような shop domain がテナントID として機能しており、 またスマレジにおいては 契約ID という英数字文字列がテナントIDとして機能します.
このため、Omni Hub では単一のテナントIDというものは存在せず、 常にそれぞれのプラットフォームのテナントIDの組み合わせとしてテナントが識別されます.
これまでのテナント解決
簡単のため POS としてスマレジ だけを取り上げると、 Omni Hub のアプリケーションに到達するリクエストは、 基本的に下記のいずれかであり、リクエストによって Shopify の shop domain や スマレジ契約ID がリクエストヘッダーやボディに含まれています.
- Shopify 由来 (Shopify のオンラインストア、マーチャント管理画面、 webhook 等)
- スマレジ由来(スマレジからの webhook)
リクエストを処理する際には、 リクエスト処理を行う usecase を factory method で組み立てています. このファクトリメソッドそれぞれで DB を参照し、 必要な場合に Shopify shop domain ←→ スマレジ契約ID の対応を引いていました.
この問題点として、 下記がありました.
- アプリケーションは、 usecase 層 → service 層 → repository 層のレイヤー構造になっており、 ファクトリメソッドでの組み立てもそれに沿って段階的に行われます. このため、 同一リクエスト内でも複数のファクトリメソッドが個別に「Shopify shop domain ←→ スマレジ契約ID」の対応を DB に問い合わせてしまっていました.
- テナント解決のロジックが各ファクトリメソッドに散らばっており、 DRY 原則に違反していました.
- これまで対応していた POS はスマレジのみでした. 最近 Square POS にも対応したことで、 ファクトリメソッドが抱える条件分岐がさらに増え、 依存の組み立てとテナント解決という別物の責務が同居していることが目立つようになってきました.
テナントコンテキストの導入
こういった状態から、 テナントコンテキストを導入するため、 テナント解決を全リクエスト共通のmiddleware で 1 回だけ行い、 その結果を TenantContext という値型にまとめてファクトリメソッドに渡す形に切り替える、という方針で 進めようと考えました.
ただし、 この方針には一工夫が必要でした. 先述のとおり Omni Hub では「何をキーにテナントを解決し始めるか」自体がリクエストごとに変わるという特殊な事情があります. Shopify 由来のリクエストでは shop domain、 スマレジ由来なら契約ID であり、 さらにリクエストの種類ごとにshop domain がリクエストのどこ(ヘッダーのキー、 ボディのフィールド) に含まれるかも変わってきます.
そこで、 この「リクエストごとに異なるキー」を一つの enum に揃える中間表現として TenantIdentifier を導入しました.
pub enum TenantIdentifier { Shopify(ShopifyShopDomain), Smaregi(SmaregiContractId), Square(SquareMerchantId), }
リクエストごとの「テナント識別子を取り出す処理」は各リクエスト種別の 既存または新規の resolver 側に閉じ込め、 新たに追加する middleware は TenantIdentifier を受け取って TenantContext に解決するという役割に専念できます.
全体としては次のような流れです.
- リクエスト処理の手前の resolver で、 リクエストから取り出したテナント識別子を
TenantIdentifierとして認識する. - middleware がこの
TenantIdentifierを起点に DB を引き、 Shopify 情報 + (任意の) スマレジ情報 + (任意の) Square 情報をTenantContextに詰めてリクエストに紐付ける. - usecase を組み立てるファクトリメソッドは
&TenantContextを受け取るだけになり、 自分で DB を引いてテナント解決をすることがなくなる.
TenantContext の中身は次のような形です.
pub struct TenantContext { pub shopify: ShopifyTenantContext, // 常に存在 pub smaregi: Option<SmaregiTenantContext>, // 連携していれば pub square: Option<SquareTenantContext>, // 連携していれば }
それぞれのプラットフォーム用の構造体には、 テナントID だけでなく、 そのテナントに対するアクセストークンと scope も含めています. これにより、 外部 API を叩く service 層も「改めて DB からトークンを取り直す」必要がなくなりました.
実装の概略
具体的なコードは次のような構成になっています.
リクエスト種別ごとの resolver では、 リクエストから取り出した識別子を TenantIdentifier として req.extensions に挿入します.
// Shopify のリクエスト用 resolver let shop_domain: ShopifyShopDomain = req .headers() .get(ShopifyHeaderName::ShopDomain.to_string()) .ok_or_else(|| ErrorBadRequest("required header [X-Shopify-Shop-Domain] not found"))? .to_str()? .try_into()?; req .extensions_mut() .insert(TenantIdentifier::Shopify(shop_domain));
TenantContext を解決する middleware は、 req.extensions から TenantIdentifier を取り出して DB を引き、 結果を再び req.extensions に詰め直すだけです.
// テナントコンテキスト解決用 middleware let identifier = req.extensions().get::<TenantIdentifier>().cloned(); if let Some(identifier) = identifier { let container = req .app_data::<web::Data<AppContainer>>() .expect("app context should exist"); if let Some(tenant_context) = container.resolve_tenant_context_by_identifier(&identifier)? { req.extensions_mut().insert(tenant_context); } }
ハンドラやファクトリメソッド側は、 req.extensions から &TenantContext を取り出して受け渡すだけです.
#[post("/foo")] pub async fn handler( tenant_context: web::ReqData<TenantContext>, body: web::Json<Body>, ) -> actix_web::Result<HttpResponse> { let usecase = factory_method(&tenant_context); usecase.exec(body.into_inner()).await?; Ok(HttpResponse::Ok().finish()) }
導入してよかったこと
- DB アクセスの削減: 1 リクエスト中に何度も起きていたテナント解決クエリが、 middleware での 1 回に集約されました.
- 責務の分離: ファクトリメソッドは依存の組み立てに集中できるようになり、 テナント解決のロジックは middleware と
TenantContext構築側に閉じました. - 型でテナントの状態を表現できる: たとえば、スマレジ連携が必要な usecase は
TenantContext.smaregiがSomeであることを確認した上で処理を進める、 という書き方ができます. 「連携していないテナントに対してスマレジ API を呼ぼうとする」実装ミスを、 型でブロックしやすくなりました. - 新しい連携先を足しやすい: 連携先が増えても
TenantContextにフィールドを足して middleware の解決ロジックを 1 ヶ所拡張すれば、 各ファクトリメソッドはほぼ影響を受けません.
トレードオフ
一方で、 この構成には注意点もあります.
TenantContext を解決する middleware は、 req.extensions に TenantIdentifier が入っていることを前提としています. つまり、 各ルーティングに対してTenantIdentifier を解決する resolver/middleware を、 TenantContext 解決の middleware より手前に必ず配置するという暗黙のルールがあります.
この前後関係を間違えても型エラーとしては検知されません. req.extensions は型付きの異種コンテナとして任意の型を出し入れできるため、 コンパイル時にはチェックされず、 「TenantIdentifier が入っていないので TenantContext も入らない」という形で実行時に静かに無効化されてしまいます. 結果として「ハンドラ側で TenantContext を取り出そうとしたら None だった」というランタイムエラーとして現れます.
終わりに
今回のテナントコンテキストの導入は、 オライリーの 『マルチテナントSaaSアーキテクチャの構築』を読んで、 実際にサービスに対して適用しようと考えて行ったものです. これ以外にもサービスに適用できる設計がいろいろと紹介されていたので、 他にも実践していきたいと考えています.