🙆‍♀️

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

https://docs.growthbook.io/guide/nextjs-app-router

ポイントはgb.destroy()でこれによりRSCではレンダリング後にリソースが解放される形になる。

余談だがNode.jsのドキュメントでは GrowthBookClient() という別のオブジェクトが利用されている。これはシングルトン化されており、サーバプロセスが実行されている間は存在し続ける作りになる。このように一言で「サーバーサイド」と言っても実装方針はRSCとでは全く異なる。

usingキーワードを使ってリソース解放を確実にする

RSCでは destroy() 関数を使って最終的にリソースを解放することが推奨される。ちなみに destroy() 関数自体が何をやっているかというと

- サブスクリプションをクリア
- 割り当てられた実験をクリア
- トラッキングデータをクリア
- その他のメモリ参照を解放
- アクティブな自動実験を元に戻す

ということらしい。ただし、この解放処理はやらないとエラーになる等の類ではないため、処理が抜け落ちてしまうことが怖い。
なので、TypeScript5.2で導入されたusingキーワードを使い、スコープを抜ける際に自動的かつ確実にリソースが解放される仕組みを導入する。

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html

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