🙆♀️
Next.js(RSC) x GrowthBookのリソース解放にusingキーワードを使う
あまり馴染みはないかもしれないが社内のNext.jsプロジェクトで GrowthBook というFeature Flag管理のSaaSを利用している。Next.jsのAppRouterや通常のNode.js版SDKも備えているので採用した。
RSCでのGrowthBookサンプルコードはこんな感じ
import Image from "next/image";
import { configureServerSideGrowthBook } from "./growthbookServer";
import { GrowthBook } from "@growthbook/growthbook";
export default async function Home() {
configureServerSideGrowthBook();
// Create and initialize a GrowthBook instance
const gb = new GrowthBook({
apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST,
clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY,
});
await gb.init({ timeout: 1000 });
// Set targeting attributes for the user
// TODO: get from cookies/headers/db
await gb.setAttributes({
id: "123",
employee: true,
});
// Evaluate a feature flag
const welcomeMessage = gb.isOn("welcome-message");
// Cleanup
gb.destroy();
return (
<h2>Welcome Message: {welcomeMessage ? "ON" : "OFF"}</h2>
)
}
ポイントはgb.destroy()
でこれによりRSCではレンダリング後にリソースが解放される形になる。
余談だがNode.jsのドキュメントでは GrowthBookClient()
という別のオブジェクトが利用されている。これはシングルトン化されており、サーバプロセスが実行されている間は存在し続ける作りになる。このように一言で「サーバーサイド」と言っても実装方針はRSCとでは全く異なる。
usingキーワードを使ってリソース解放を確実にする
RSCでは destroy()
関数を使って最終的にリソースを解放することが推奨される。ちなみに destroy()
関数自体が何をやっているかというと
- サブスクリプションをクリア
- 割り当てられた実験をクリア
- トラッキングデータをクリア
- その他のメモリ参照を解放
- アクティブな自動実験を元に戻す
ということらしい。ただし、この解放処理はやらないとエラーになる等の類ではないため、処理が抜け落ちてしまうことが怖い。
なので、TypeScript5.2で導入されたusingキーワードを使い、スコープを抜ける際に自動的かつ確実にリソースが解放される仕組みを導入する。
// growthbook-rsc.ts
import { GrowthBook } from "@growthbook/growthbook";
import { env } from "@/lib/env/server";
import { logger } from "./logger";
// using で管理するリソースの型定義
interface DisposableGrowthBook {
gb: GrowthBook;
[Symbol.asyncDispose]: () => Promise<void>;
}
// GrowthBookインスタンスを作成・初期化し、解放処理をセットする非同期関数
export async function createAndInitGrowthBook(): Promise<DisposableGrowthBook> {
configureServerSideGrowthBook();
const gb = new GrowthBook({
apiHost: env.NEXT_PUBLIC_GROWTHBOOK_API_HOST,
clientKey: env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY,
});
// GrowthBookを初期化
try {
await gb.init({ timeout: 1000 });
logger.info("GrowthBook initialized successfully.");
} catch (error) {
logger.error(`Failed to initialize GrowthBook: ${error}`);
// 初期化失敗時のエラーハンドリング (必要に応じて)
// ここでエラーを再スローするか、特定の状態を持つインスタンスを返すかなどを決定
// この例では、エラーが発生してもdisposeは呼ばれるようにインスタンスを返す
}
// GrowthBookインスタンスと、asyncDisposeメソッドを持つオブジェクトを返す
return {
gb,
async [Symbol.asyncDispose]() {
logger.info("Disposing GrowthBook instance...");
gb.destroy();
logger.info("GrowthBook instance disposed.");
},
};
}
// Home.tsx
import { createAndInitGrowthBook } from "growthbook-rsc";
export default async function Home() {
await using resource = await createAndInitGrowthBook();
const gb = resource.gb;
await gb.setAttributes({
role: UserRole.ADMIN,
});
const feature1Enabled = gb.isOn("feature1");
return (
<h2>Welcome Message: {feature1Enabled ? "ON" : "OFF"}</h2>
)
}
await using resource = await createAndInitGrowthBook();
でスコープを抜けた時に自動的にリソースを解放が可能になる。
Discussion