🏁

お手製 Feature Flag の導入と開発フローの変化

2024/03/22に公開

FeatureFlag とは

「コードを書き換えずに機能をリリースする」を実現するのが FeatureFlag です。
機能の状態を表す"値"をデータベースなどに持ち、この値を書き換えるだけで機能のリリースが可能になり、これにより後述するいくつものメリットを享受することができます

コードで表すととてもシンプル。

const enableHogeFeature = getFeatureFlag('ENABLE_HOGE') === 'true'

if (enableHogeFeature) {
  // 新機能の処理
} else {
  // 既存の処理
}

なぜ FeatureFlag を導入したか

バックテックでは FeatureFlag 導入前までは topic branch による開発を各開発者 or チームが行っていました。
しかし嬉しいことにエンジニアが増え、チームが1チームから2チームになるタイミングでいくつかの課題が発生しました。

  • 生存期間の長い topic branch 同士の Conflict が多々発生する
  • Conflict だけなら直せばいいが、各開発者の手元で最新かどうか分からないコードが動いていることになる
  • Deploy のタイミングがリリースのタイミングとなり、リリース作業が煩雑な作業となったり、切り戻しが困難

設計

DB

FeatureFlags テーブル

カラム 意味
id id
name string FeatureFlag 名
value string FeatureFlag の値
createdAt, updatedAt datetime

FeatureFlag のライフサイクル

FeatureFlag の追加

featureFlag.csv というファイルで FeatureFlag 名と値を管理します。
これをデータベースの migration のタイミングで読み取り、FeatureFlags テーブルに保存します。

name,defaultValue
ENABLE_TEST_SHOULD_NOT_BE_REFERENCED,false

以下のようなシンプルなコードで db に保存します。

migrateFeatureFlags.ts
migrateFeatureFlags.ts
import { PrismaClient } from "@prisma/client";
import csv from "csvtojson";
import path from "path";
const log4js = require("log4js");
const logger = log4js.getLogger();
logger.level = "debug";

(async () => {
  const prisma = new PrismaClient({
    log: ["query", "warn", "error"],
  });

  // CSV 取得
  const featureFlagsFromCSV = await csv().fromFile(
    path.join(__dirname, "./featureFlags.csv")
  );
  logger.debug("featureFlagsFromCSV", featureFlagsFromCSV);

  const featureFlagsFromDB = await prisma.featureFlags.findMany();
  logger.debug("featureFlagsFromDB", featureFlagsFromDB);

  // prisma の transaction を張る
  await prisma.$transaction(async (txn) => {
    // CSV を1行ずつループ
    // ループの中で await prismaTransaction を使うと、エラーになるので、Promise.all を使う
    //ref. https://github.com/prisma/prisma/issues/13713#issuecomment-1318439301
    await Promise.all(
      featureFlagsFromCSV.map(async (row) => {
        // CSV の1行を取得
        const { name, defaultValue } = row;

        // DB に同一の name があるか確認
        // あれば早期 return
        const featureFlag = featureFlagsFromDB.find((row) => row.name === name);
        if (featureFlag) {
          return;
        }

        logger.debug("create", name, defaultValue);
        await txn.featureFlags.create({
          data: {
            name,
            value: defaultValue,
          },
        });
      })
    );

    // db の FeatureFlags テーブルの中身をループ
    await Promise.all(
      featureFlagsFromDB.map(async (featureFlag) => {
        // DB の1行を取得
        const { name } = featureFlag;

        // CSV に同一の name があるか確認
        // あれば早期 return
        const matched = featureFlagsFromCSV.find((row) => row.name === name);
        if (matched) {
          return;
        }

        logger.debug("delete", name);
        await txn.featureFlags.delete({
          where: {
            name,
          },
        });
      })
    );
  });
  logger.debug("success");
})().catch((err) => {
  logger.error(err);
});

FeatureFlag の値更新

弊社社員しかアクセスできない admin 画面に、FeatureFlag の管理画面を用意してます。
できることは FeatureFlag の確認と更新だけです。

機能リリース時はこの admin 画面から値を更新するだけです。

FeatureFlag の削除

featureFlag.csv から行を削除したら db のレコードから削除されます。
全ての参照箇所を削除してから CSV から削除します。

バックエンド

  • どのファイルからでも容易に呼び出せること
  • パフォーマンス考慮(都度 DB アクセスしない)

よって、Express の middleware でリクエストごとに FeatureFlag 全体を取得し、それをシングルトンクラスのインスタンスで保持することにしました。

FeatureFlags テーブルにアクセスして保持するだけなのでコードは割愛。

フロントエンド

  • どの js|ts ファイルでも容易に呼び出せること
  • FeatureFlag のせいで画面がちらつくことはあってはならない(一瞬旧画面が見えてから新画面が見れるなど)

よって、React がマウントされてから API を叩いて取得するのではなく、サーバーが返す HTML に最初から埋め込まれている状態にし、それを任意の JS から参照できるようにしました。これにより React のレンダー時に値が確定することができ、ちらつきを防げます。

バックテックではテンプレートエンジンとして EJS を使っているため、Express が渡す featureFlags の値を JSON.stringify した状態で埋め込みます。

<script type="application/json" id="featureFlags"><%- JSON.stringify(Object.values(featureFlags)) %></script>

これを以下のような関数で参照できるようにします。

export type FeatureFlag = { key: string; value: string };

export const getFeatureFlags = (): FeatureFlag[] => {
  const elem = document.getElementById('featureFlags');
  if (!elem || !elem.textContent) {
    return [];
  }

  const injectionData = JSON.parse(elem.textContent);
  return injectionData;
};

export const getFeatureFlag = (key: string): string | undefined => {
  const flags = getFeatureFlags();
  const flag = flags.find((f) => f.key === key);
  return flag?.value;
};

生まれたメリット

  • リリース作業は"リリース"に集中できるようになった(Deploy からの解放)
  • リリースの取り下げが容易になった(FeatureFlag の値を戻すだけ)
  • develop branch にガンガン merge して開発できるようになった
  • 定期 Deploy ができるようになった(直接的な効果ではないが、develop に merge してるため日々の commit 数も多く、自然と定期 Deploy したくなった)

新たな課題

  • 全チームが FeatureFlag を使えているわけではない。topic branch での開発と開発フローが大きく変わるので、使えるようにするためのレクチャーが必要。
  • どこで分岐するかが難しい・経験が必要。細かすぎる(例えば関数内の中で三項分岐など)と面倒だし、ざっくり過ぎる(例えばファイルを hoge.tshogeV2.ts のように分ける)と重複したコードが一時的に複数存在したり Git の情報が失われてしまう

導入の感想

薄い実装で要件を実現できたのが満足度高いです。実際設計が9割で、実装は3日もかかりませんでした。
ただこれからの運用フェーズのほうが重要です。トータルの開発体験は高いと思うので継続して改善していきます!

バックテック【ヘルステック系スタートアップの試行錯誤】

Discussion