ShareDan CMS はマルチテナント構成です。ひとつのデータベースに多数のテナントのコンテンツが同居しますが、公開API(各テナントのWebサイトが叩く読み取り専用API)はシンプルで速く保てています。その理由は2つ —テナント分離をアプリではなくデータベースに寄せたこと、そしてバージョン付きのレスポンスキャッシュです。
テナント分離はアプリではなくDBで
分離を「アプリのクエリに毎回where('tenant_id', …)を足す」で守ろうとすると、1か所書き忘れるだけで他テナントのデータが漏れます。ShareDan CMS はこれをPostgreSQL の Row Level Security(RLS)に任せています。
プレーンごとにDBロールと接続を分けています。運営プレーンはテーブル所有者ロールで接続し(RLSをバイパスして横断管理する)、テナントプレーンはapp_tenant、公開APIはapi_publicという非所有者ロールで接続します。非所有者にはRLSが必ず適用されます。
各テナント表には、次のポリシーが貼られています。
-- 分離境界の解決関数:app.tenant_id が未設定なら NULL を返す
CREATE FUNCTION current_tenant_id() RETURNS bigint LANGUAGE sql STABLE AS $$
SELECT NULLIF(current_setting('app.tenant_id', true), '')::bigint
$$;
-- 各テナント表のポリシー(tenant_id で分離)
CREATE POLICY tenant_isolation ON t_posts
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());ポイントは、app.tenant_id(セッション変数=GUC)が未設定だとcurrent_tenant_id()がNULLを返し、どの行も見えなくなる点です。つまりfail-closed— 境界を設定し忘れたら「全部見える」ではなく「何も見えない」に倒れます。
公開APIの1リクエストは、まず所有者接続でX-API-KEY/X-SITE-CODEからテナントとサイトを特定し、そのあとapi_public接続へ切り替えて境界をセットします。
// テナントを解決したら、公開ロール接続にリクエストの境界をセットする
DB::connection('pgsql_public')
->statement("SELECT set_config('app.tenant_id', ?, false)", [$tenantId]);
// … このリクエストの処理 …
// 接続再利用(永続接続・RDS Proxy 等)で他テナントへ漏れないよう、finally で必ず空へ戻す
DB::connection('pgsql_public')
->statement("SELECT set_config('app.tenant_id', '')");結果として、コントローラのクエリからテナント分離の心配が消えます。Post::query()->where('site_id', …)->published()->…のように素直なクエリを書くだけで、他テナントの行はDBが原理的に返しません。分離が1本のインデックス付き述語に収まり、アプリ側の防御ロジックも要らない — これは正しさと速さの両取りです。
最小権限の読み取り専用ロール
api_publicは公開APIが必要とするものしか触れません。テナント系テーブルへのSELECT(+公開フォーム送信のための限定的なINSERT)だけを持ち、運営アカウント・操作ログ・セッションやジョブといった枠組みテーブルへの権限は明示的に剥奪してあります。仮にSQLに想定外の参照が混ざっても、公開ロールにはそもそも見る権限がありません。
速さの主役:バージョン付きレスポンスキャッシュ
分離の話と並ぶもう一本の柱がキャッシュです。公開の読み取りレスポンスはPublicApiCacheServiceを通し、組み立て済みのJSONをキャッシュから返します。キャッシュキーにはコンテンツ種別・サイトID・リクエストパラメータに加えて、バージョン番号を含めます。
// public-api:{種別}:site:{siteId}:v{version}:{パラメータのハッシュ}
$key = sprintf('public-api:%s:site:%d:v%d:%s',
$contentType, $siteId, $version, md5(json_encode($keyParts)));
return Cache::remember($key, 3600, $callback); // 3600 は旧バージョン掃除用のTTLヒットすればDBには一切触れません。一覧・個別・ページ・カテゴリといったバリエーションはキー違いで個別にキャッシュされます。
パージはバージョンを1つ進めるだけ
このキャッシュの気持ちよさは無効化にあります。パージはバージョン番号をインクリメントするだけです。
// CachePurgeService::purge
Cache::forever($versionKey, $this->version($contentType, $siteId) + 1);バージョンが上がると、古いv{n}を含むキーは二度と参照されなくなり、TTLで自然に消えます。個々のキーを列挙して消す必要がないので、タグ機能を持たないキャッシュストア(database ドライバなど)でも確実に動きます。
無効化はピンポイントです。管理画面でのコンテンツ更新や、公開日時を反映するバッチ(BAT-001)が、更新のあったサイト×種別のバージョンだけを上げます。他サイト・他種別のキャッシュはそのまま生き続けます。
ステートレスだから前段にも置ける
公開APIの認証はCookieやセッションではなくヘッダ(X-API-KEY/X-SITE-CODE)です。GETのレスポンスはサイトごとに決定的なので、アプリ内キャッシュだけでなく、必要なら手前にCDNを置くのも容易です。一方で、フォーム送信やプレビューといった動的な経路にはCache-Control: no-storeを付け、キャッシュ経路に載らないようにしています。
まとめ
公開APIが速くシンプルでいられるのは、分離をDB(RLS)へ寄せてクエリと権限を素直に保ち、バージョン付きキャッシュでヒット時はDBに触れず、パージはバージョンを1つ進めるだけにしているからです。SDK・エンドポイントの詳細はAPIリファレンスを参照してください。