マルチテナントサービスの拡張性パターン
本記事は、ゆるWeb勉強会@札幌のアドベントカレンダーの11日目の記事として投稿しました。
コミュニティには参加したことがないのですが、主催者さんが記事を募集していたのと、私自身も期限を設けることで書ききりたかったので参加させてもらいました。
はじめに
私はここ2年ほど、マルチテナントサービスの立ち上げに開発メンバーとして関わってきました。少ない人数での開発だったので、サービスの設計からインフラ構築から実装から運用まですべてを経験しました。そして今また、新しいマルチテナントサービスの立ち上げで設計・実装を行ってる最中です。これらの経験を通じて、マルチテナントサービスで拡張性を実現する方法をある程度パターン化できそうと思ったので、アウトプットしてみようと思いました。ちなみに本記事の内容は、実際に試したものもあれば、妄想段階のものもありますので、その点はご了承ください。
本記事の言葉の定義
- サービス: サービスと一口に言っても色々な提供形態がありますが、本記事が想定する(私が関わってきた)サービス)は、いわゆるBaaSのようなサービスです。特定の分野のクライアントアプリの開発に必要な機能をAPIやデータベース、管理画面などと共に提供します。
- マルチテナント: マルチテナントは、複数のテナント(利用者)が1つのリソースを共有する形で提供するサービスのことです。もちろんテナントごとにデータは分離されているので、テナント(利用者)から見ると他のテナント(利用者)の情報は見えません。
- 拡張性: 拡張性とは、あるテナントが独自の仕様(データのスキーマや機能の振る舞い)を実現することをいいます。
なぜ拡張性が必要なのか?
利用目的が決まっているサービスであれば、基本的にはサービスとしての仕様を決めて利用者はその中の機能を利用することが前提となります。BaaSのような開発者向けのサービスの場合、できることがクライアントアプリでできることと直結するため、機能の拡張性(自由度といったほうがしっくりくるかも)が求められます。例えば、データのスキーマを定義できたり、任意の認証プロバイダと連携できたり、外部のサービスとデータを連動したり、などなど。そういうことができないと、サービスとクライアントアプリの中間にもうひとつProxy的なサービスを置く必要があり、トータルとして利用者側の負担が高くなってしまいます。
拡張性を実現する難しさ
拡張性を内包することは常にトレードオフが付きまといます。できることが増えることで、組み合わせが増えて仕様が複雑になりがちです。また、一度公開したインターフェイスは提供し続けなければなりません。安易な方法で拡張性を実現すると、その部分はすぐに負債となり開発速度や品質の低下を招きます。
また、どのくらい拡張性を見越して設計するかも難しいところです。私は普段はYAGNIの原則を強く意識していますが、こういったサービスではどこまでの拡張性を想定するかを非常によく考え、オープン・クローズドの原則を強く意識します。
パターン
拡張性を実現する部分を、システムのレイヤーごとに分類してみるとこのようになりました。
- データベースレイヤー
- アプリケーションレイヤー
- インターフェイスレイヤー
- プレゼンテーションレイヤー
- その他
1つのパターンが複数のレイヤーに跨っている場合もあります。
あと、どのパターンが一番良いというものではありません。それぞれにトレードオフが存在します。そしてベストな選択はサービスの特性やステージによっても異なります。
データベースレイヤー
データベースレイヤーでの拡張性はサービスの利用者の要求を叶えるためにとても重要です。なぜなら利用者は利用者側のドメインモデルをできるだけサービスに正しく反映させたいからです。データベースに拡張性をもたせることで、利用者の独自のデータを管理することができるようになります。データベースレイヤーでは2つの方法を紹介します。どちらも古典的なパターンだと思います。基本的にはRDBを想定していますが、NoSQLについてもサービス側でスキーマ定義を持つのでだいたい同じことが言えるかと思います。
拡張フィールド
- 利用者が自由に使うことができるフィールドを用意します。フィールドを複数提供したり、JSON型のフィールドを提供することでより自由な使い方をさせることができます。
- サービスは利用者のフィールドの使い方についての知識を持たないため、サービスとしては維持コストが低いです。
- フィールドの使い方は利用者のクライアントのみが知識を持つため、フィールドのデータの管理はすべてクライアントが責任を持つことになります。
拡張フィールド+メタ情報
- サービスが拡張フィールドのメタ情報(フィールド名や型など)を管理します。利用者から見るとモデルやエンティティを定義するようなイメージです。
- サービスとしては抽象的な実装になるため、やや高度な実装になりますが、サービスのインターフェイス(APIや管理画面など)をメタ情報を通じて具体的な情報にすることができるので、クライアントには優しいです。
- メタ情報を他のレイヤーでも参照することで様々な部分で拡張フィールドの情報を利用することができます。
アプリケーションレイヤー
データベースレイヤーの拡張により扱えるデータを増やすことはできましたが、ビジネスロジックを含むことはできません。アプリケーションレイヤーの拡張では、ビジネスロジックの拡張を実現します。
Config
- いわゆるフラグです。フラグの設定によりロジックを切り替えます。拡張性というよりは、サービスが提供するロジックのパターンを増やして、利用者がそれを選択するというパターンです。
- サービスとして提供するロジックを増やしているので、安易にパターンを増やしていくとすぐに組合せ爆発を引き起こし手がつけられない状態になります。なので、このパターンを使う場合は、フラグごとにモジュールを切り出すような実装にしたほうが良いです。
- 例えば、サービスとしていくつかの認証サービスに予め対応しておき、テナントごとにどれを利用するかを選択できるようにする、などといった設定ができます。
Plugin
- サービスの仕様として定めたインターフェイス(APIなど)を通じて、拡張機能を追加します。インターフェイスを公開している場合は、利用者が機能を追加することもできます。
- この方式の場合はインターフェイスをバージョニングして管理していく必要がありますが、拡張機能はサービスの外側になるので維持コストがかかりません。
ロジックの埋め込み
- サービスに利用者が独自の任意のロジック(プログラム)を埋め込み、任意のトリガーで実行することでビジネスロジックやインターフェイスなどを拡張します。
- ロジックのインターフェイス(ロジックに与えるパラメータ)や実行リソースはサービスが提供します。インターフェイスが決まっているという点ではPluginの進化系と言えるかもしれません。
- 例えば「APIの認証で任意のロジックを実行して利用者が管理する認証サービスでトークンを検証する」とか、「特定のイベントが発生したことをトリガーとして任意のロジックを実行して外部サービスと連携する」とか、色々考えられて自由度が高いです。
- 実際のサービスの例としては、認証基盤サービスのAuth0にRulesという機能があります。Rulesは、特定のイベントが発生したタイミングで(ユーザがログインしたときなど)実行されるロジックで、決められたインターフェイスを通じてパラメータを受け取り、ユーザの属性を操作したり、idTokenの内容を拡張したり、外部のサービスと連携したりすることができます。
- 実行環境としては、GCPのCloud RunやAWS Lambdaなど、起動時間が短くコンピューティングリソースの分離や制限がしやすいServerless環境との相性が良いと思います。
インターフェイスレイヤー
サービスの拡張性をクライアントアプリに連携するには、サービスと通信するインターフェイスの拡張も必要です。
GraphQL
- 不特定多数のクライアントに利用されるAPIの場合、GraphQLを使うことでクライアントが必要とするクエリを組み立てやすくなります。REST APIではリソースがエンドポイントで分離されているので何度もリクエストしなければならなかったのが、1回のリクエストで取得できるようになったりします。
- さらにデータベースレイヤーの拡張パターンのメタ情報を用いることで、メタ情報をそのままスキーマに反映することができます。つまりクライアントごとに独自のスキーマを提供することができます。これをいち早く実現したサービスにGraphCMSというサービスがあります。
SearchTemplate
- Elasticsearchにある機能で、予め検索条件とパラメータ項目を設定したqueryを登録しておき、クライアントからパラメータを与えて検索することができます。任意のqueryを利用者ごとに設定できるようになり、非常に自由度が高いです。
- AWSであればマネージドサービスがありますのでクラスタの管理コストは低いですが、費用的なコストはやや高めです。瞬時にスケールできないのでバースト対策にはレートリミットを設けるなど注意が必要です。
プレゼンテーションレイヤー
メールやプッシュ通知などのプレゼンテーションレイヤーの拡張性です。
テンプレート
- テンプレートを用意しておき可変部分を変数化してパラメータを埋め込みます。
- 細かい話ですが、プッシュ通知の拡張性で大変なのがバッジです。全て一律で1にするのなら問題ないのですが、2以上の数字を扱う場合はサービスとクライアントで数字の同期(整合性合わせ)が必要になるためとても大変です。
その他
サービスの外側で拡張するパターンです。
Webhook
- サービスは特定のイベントをトリガーとして、データを任意の場所に投げます。
- 利用者は受け取ったデータを使って任意の処理を行うことができます。
Event Messaging
- サービスは、特定のイベントをトリガーとして、メッセージをキューやPub/Subなどのサービスに追加します。
- 利用者はキューやPub/Subから受け取ったデータを使って任意の処理を行うことができます。
Proxy
- サービスとクライアントアプリの間に位置して、データを変換したり、機能を拡張したり、外部のサービスと連携したりします。
おわりに
- 目次を書き始めたときは、本ができそうなくらい書けることがたくさんあると思ったのですが、いざ書いてみるとまとまった文章を書くのが難しくて、持っている情報を全然出しきれず薄い内容になってしまいました。
- 全体的に見ると目新しさは少ないですが、個人的にはロジックの埋め込みはServerlessのパラダイムで出現した新しいパターンかなと思っていますので、今後深堀りしていきたいと思っています。