0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Feature Flagについて理解したい

0
Posted at

はじめに

フィーチャーフラグという仕組みについて調べて、CakePHPとUnleashで実際に動くものを作ってみました。
この記事では、フィーチャーフラグの概念を整理した上で、実装してみた記録をまとめます。

フィーチャーフラグとは

デプロイと機能のリリースを分離する仕組みです。
条件分岐で機能の ON/OFF を切り替えられるようにしておき、デプロイ後も設定だけで挙動を変えられるようにします。

イメージ

if (FeatureToggle::isEnabled('new_checkout')) {
    return $newCheckoutService->execute();
}
return $legacyCheckoutService->execute();

Pete Hodgson の分類によると、フラグは寿命や用途で4種類に分けられます。

種類 寿命
Release Toggle 短期 開発中の新機能を隠してデプロイ
Experiment Toggle 中期 A/Bテスト
Ops Toggle 長期 Kill Switch(障害時の緊急停止)
Permission Toggle 長期 プレミアムプラン限定機能

どういう場面で嬉しいのか

  • 新機能をリリースしたら本番で障害が発生した。ロールバックのデプロイを待たずに、フラグを OFF にするだけで機能を止められる(Kill Switch)
  • 全ユーザーに一気に出すのが怖い。まず 1% → 10% → 100% と段階的に公開して、問題があれば 0% に戻せる
  • 機能が未完成だけど、フィーチャーブランチを長く持ちたくない。フラグで隠したまま main にマージしておける(トランクベース開発)
補足: 割合ロールアウトと「リロード問題」

「ユーザーの10%に公開」を rand() < 10 のような乱数で実現しようとすると、同じユーザーがリロードするたびに新機能が見えたり見えなかったりして壊れます。

フィーチャーフラグサービスではハッシュ関数を使い、同じユーザーには必ず同じ結果を返すようにしています。

function isEnabled(string $flagKey, string $userId, int $percent): bool
{
    $bucket = abs(crc32($flagKey . ':' . $userId)) % 100;
    return $bucket < $percent;
}

ユーザー ID とフラグ名からハッシュ値を計算するので、同じユーザーなら毎回同じ結果になります。さらに 10% → 25% に引き上げたとき、元の 10% のユーザーはそのまま含まれるという性質もあります

なお、PHP の crc32() は負の値を返すことがあるため abs() で正の値に変換しています。実際のサービスでは crc32 ではなく MurmurHash3 など一様分布の質が高いハッシュ関数が使われており、LaunchDarkly は公式に MurmurHash3 の使用を明記しています。

ここまで読むと「それ環境変数で条件分岐させれば良いのでは?」と思うかもしれませんが、
割合ロールアウトやユーザー属性での出し分けを環境変数で自前実装するのは大変ですし、切り替えのたびに再デプロイが必要になるので、デプロイと機能のリリースを分離するという観点ではフィーチャーフラグサービスが必要になってきます。

CakePHP で「フラグを消しやすい」実装を試してみた

フィーチャーフラグは便利ですが、条件分岐を Controller に直接書くと、フラグが不要になったときに書き換え箇所が散らばって辛くなります。

// Controller にフラグ判定を直接書いた場合
class SomeController extends AppController
{
    public function index(): void
    {
        if (FeatureToggle::isEnabled('new_feature')) {
            $result = (new NewFeature())->execute();
        } else {
            $result = (new OldFeature())->execute();
        }

        $this->set('result', $result);
        $this->viewBuilder()->setOption('serialize', ['result']);
    }
}

この書き方だと、フラグを消すときに Controller の if/else を削除し、new OldFeature() の参照を消し、テストも書き換える必要があります。フラグが複数の Controller に散らばっていれば、その分だけ修正箇所が増えてしまいます。

今回は「Feature Toggle は捨てやすく使おう」で紹介されていた Interface + DI の設計を参考に、CakePHP で実装してみました。

切り替えたい機能の Interface を定義し、旧実装と新実装をそれぞれ用意します。

interface SomeFeature
{
    public function execute(): array;
}

class OldFeature implements SomeFeature { /* 旧ロジック */ }
class NewFeature implements SomeFeature { /* 新ロジック */ }

DI コンテナでフラグに応じてどちらを注入するか切り替えます。

public function services(ContainerInterface $container): void
{
    $container->add(SomeFeature::class, function () {
        if (FeatureToggle::disabled('new_feature')) {
            return new OldFeature();
        }

        return new NewFeature();
    });
}

Controller 側はフラグの存在を一切知りません。Interface だけに依存しています。

class SomeController extends AppController
{
    public function index(SomeFeature $feature): void
    {
        $result = $feature->execute();
        $this->set('result', $result);
        $this->viewBuilder()->setOption('serialize', ['result']);
    }
}

フラグを消すとき

この設計の効果が出るのはフラグを消すときです。変更するのは Application.php だけで、Controller には触りません。

  public function services(ContainerInterface $container): void
  {
-     $container->add(SomeFeature::class, function () {
-         if (FeatureToggle::disabled('new_feature')) {
-             return new OldFeature();
-         }
-
-         return new NewFeature();
-     });
+     $container->add(SomeFeature::class, NewFeature::class);
  }

あとは OldFeature.php をファイルごと消して、Unleash(後述)からフラグを削除すれば完了です。Controller、Interface、新実装には触る必要がありません。

試してみた実装: 書籍レコメンドAPIでの適用例

今回は書籍レコメンドAPIを題材に、「新着書籍を返す旧ロジック」と「パーソナライズ推薦を返す新ロジック」をフラグで切り替えました。

interface RecommendationEngine
{
    /** @return array<int, array{title: string, score: float, reason?: string}> */
    public function recommend(string $userId): array;
}
// 旧実装: 新着書籍を返す
class RecentBooksRecommendationEngine implements RecommendationEngine
{
    public function recommend(string $userId): array
    {
        return [
            ['title' => 'CakePHP 5 入門', 'score' => 0.80],
            ['title' => 'PHP 8.3 の新機能', 'score' => 0.75],
            ['title' => 'Docker 実践ガイド', 'score' => 0.70],
        ];
    }
}
// 新実装: パーソナライズ推薦を返す
class PersonalizedRecommendationEngine implements RecommendationEngine
{
    public function recommend(string $userId): array
    {
        return [
            ['title' => 'デザインパターン入門', 'score' => 0.95, 'reason' => '購入履歴に基づくおすすめ'],
            ['title' => 'リファクタリング 第2版', 'score' => 0.90, 'reason' => '閲覧履歴に基づくおすすめ'],
            ['title' => 'Clean Architecture', 'score' => 0.88, 'reason' => '似たユーザーが購入'],
        ];
    }
}

DI コンテナでの切り替え:

$container->add(RecommendationEngine::class, function () {
    if (FeatureToggle::disabled('new_recommendation')) {
        return new RecentBooksRecommendationEngine();
    }

    return new PersonalizedRecommendationEngine();
});

Controller はフラグを知らず、RecommendationEngine だけに依存しています:

class RecommendationsController extends AppController
{
    public function index(RecommendationEngine $engine): void
    {
        $recommendations = $engine->recommend('user-1');

        $this->set('recommendations', $recommendations);
        $this->viewBuilder()->setOption('serialize', ['recommendations']);
    }
}

Unleash でフラグを外部管理する

今回は Unleash というオープンソースのフィーチャーフラグプラットフォームを使い、管理画面から再デプロイなしにフラグを切り替えられるようにしました。

UNLEASH_URL が設定されていれば Unleash を使い、なければ環境変数にフォールバックするので、開発時は環境変数、本番では Unleash という使い分けができるように実装してみました。

FeatureFlagSource の実装
interface FeatureFlagSource
{
    public function isEnabled(string $flag): bool;
}
// Unleash から取得する実装
class UnleashFeatureFlagSource implements FeatureFlagSource
{
    public function __construct(private Unleash $unleash) {}

    public function isEnabled(string $flag): bool
    {
        return $this->unleash->isEnabled($flag);
    }
}
// 環境変数から取得するフォールバック実装(開発・テスト用)
class EnvFeatureFlagSource implements FeatureFlagSource
{
    public function isEnabled(string $flag): bool
    {
        $key = 'FEATURE_' . strtoupper($flag);

        return env($key, 'false') === 'true';
    }
}

FeatureToggle はこの FeatureFlagSource に委譲するファサードです。source が未設定なら EnvFeatureFlagSource にフォールバックします。

class FeatureToggle
{
    private static ?FeatureFlagSource $source = null;

    public static function setSource(FeatureFlagSource $source): void
    {
        static::$source = $source;
    }

    public static function isEnabled(string $flag): bool
    {
        if (static::$source === null) {
            static::$source = new EnvFeatureFlagSource();
        }

        return static::$source->isEnabled($flag);
    }

    public static function disabled(string $flag): bool
    {
        return !static::isEnabled($flag);
    }
}
DI コンテナでの組み立て

UNLEASH_URL が設定されていれば Unleash を使い、なければ環境変数にフォールバックします。

public function services(ContainerInterface $container): void
{
    // フラグ取得元: Unleash があれば使い、なければ環境変数にフォールバック
    $container->add(FeatureFlagSource::class, function () {
        $unleashUrl = env('UNLEASH_URL');
        if ($unleashUrl) {
            $unleash = UnleashBuilder::create()
                ->withAppName('api-a')
                ->withAppUrl((string)$unleashUrl)
                ->withInstanceId('api-a-local')
                ->withHeader('Authorization', (string)env('UNLEASH_API_KEY', ''))
                ->build();

            return new UnleashFeatureFlagSource($unleash);
        }

        return new EnvFeatureFlagSource();
    });

    // レコメンドエンジン: フラグに応じて実装を切り替え
    $container->add(RecommendationEngine::class, function () use ($container) {
        $flagSource = $container->get(FeatureFlagSource::class);
        FeatureToggle::setSource($flagSource);

        if (FeatureToggle::disabled('new_recommendation')) {
            return new RecentBooksRecommendationEngine();
        }

        return new PersonalizedRecommendationEngine();
    });
}
docker-compose.yml(Unleash 部分)
services:
  unleash-db:
    image: postgres:15
    environment:
      POSTGRES_USER: unleash
      POSTGRES_PASSWORD: unleash
      POSTGRES_DB: unleash

  unleash:
    image: unleashorg/unleash-server:latest
    ports:
      - "4242:4242"
    environment:
      DATABASE_URL: postgres://unleash:unleash@unleash-db:5432/unleash
      DATABASE_SSL: "false"
      UNLEASH_DEFAULT_ADMIN_USERNAME: admin
      UNLEASH_DEFAULT_ADMIN_PASSWORD: unleash4all
    depends_on:
      - unleash-db

docker compose up -d で起動すると http://localhost:4242 で管理画面が使えます。

動作確認

Unleash の管理画面でフラグを ON/OFF するだけで、再デプロイなしに API のレスポンスが切り替わることを確認できました。Unleash PHP SDK がフラグの状態をポーリングしているため、切り替え後少し待つ必要はあります。

管理画面

image.png

画面

旧ロジックを適用している場合

image.png

新ロジックを適用している場合

image.png

デプロイなしで機能を切り替えられることを確認できました。

なお、今回はグローバルな ON/OFF のみを試しました。前半で紹介した割合ロールアウトを Unleash で行う場合は、isEnabled()Context オブジェクトでユーザー ID を渡す必要があります。

おわりに

フィーチャーフラグの概念を整理し、CakePHP + Unleash で実際に動くものを作ってみました。

Interface + DI でフラグの判定を DI コンテナに閉じ込めることで、フラグを消すときの変更箇所を Application.php だけに限定できました。Controller や新実装のコードに手を入れなくて済むのは、実際にやってみると想像以上に楽でした。

参考にさせていただいた記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?