はじめに

これは、ドリコムAdvent Calendar 2021 の7日目です。
6日目は むらお さんによる「コロナ渦におけるひげ(または絵面)のはなし」です。


TL; DR

  • Webpack DefinePluginFeature toggleする事例紹介

対象読者

  • フロントエンドでの並行して複数機能開発の衝突に困ってる人
  • フロントエンドに手軽にFeature flagを適用したい人
  • フロントエンドのBundleについて、どういった役割なのか知識がある人

はじめに

おはようございます、こんにちは、こんばんは。
enza部のフロントエンドエンジニアのmasakijです。
すみません、急に引っ越しを決めてしまいバタバタしてたら、すっかりアドベントカレンダーを書くのが遅れてしまいました。

Feature flagって何? なんで使うの?

Feature flagは、Feature toggleなどとも呼ばれますが、機能を有効/無効を切り替える仕組みです。

こちらの記事によると、Feature toggleは以下のような分類に分けられます。

  • Release toggles: 機能のリリースを制御するtoggle。不完全なコードをproductionにデプロイでき、デプロイとリリースの分離ができる
  • Experiment toggles: A/Bテストで使うtoggle
  • Ops toggles: パフォーマンスへの影響が不明確な新機能を有効/無効を素早く制御するtoggle
  • Permission toggles: 特定のユーザーを対象にした機能を有効/無効を制御するtoggle

この記事においては、主にRelease togglesについて記述します。

複数機能開発を行うことを考えた時、Feature flagを使わない手法としては、直列に逐次的に開発を行うか機能ブランチを利用するかのいずれかと思います。小規模な開発であれば直列に開発を行うで問題ないかと思いますが、一般的には、機能ブランチかFeature flag、もしくは両方を利用すると思われます。機能ブランチは、ブランチが早期にメインブランチにマージできる場合や、責任境界が明確で他の機能に影響が少ない場合は、問題は少ないです。しかし、他の並行開発機能への影響が大きくなるほどにマージや検証コストが高まります。

Feature flagを使うことの利点としては、公開の制御を行えるので未了・未検証のコードを早期にメインのブランチにマージすることができ、フィードバックを早期に得ることができます。また、多くの小さな変更毎にマージできるので、マージコストの低減を図れます。また、デプロイとリリースが分離できるので、リリースの自由度が上がります。

もちろん、銀の弾丸ではないので、何にでも適用すべきではなく、機能を小さく分割して段階的にリリースできるのであれば、そちらのほうが良いです。

フロントエンドでの実装方法について

Feature flagを実装する方法としては、Feature flagの機能を提供するOSSやSasSを利用する方法などもありますが、Feature flagの仕組み自体はとても単純なものなので、Webpack DefinePluginを利用したものについて記述します。

Webpack DefinePluginは、Webpackの標準Pluginであり、dev flagの制御や環境毎の定数をbundle時にinlineに書き出してくれる、Macroのような機能を提供してくれるPluginです。

このPluginを使うと以下のようなtoggleを実装できます。

if (AWESOME_FEATURE) {
  awesomeProcess();
} else {
  oldProcess();
}

bundle時に、AWESOME_FEATUREDefinePluginに設定した値に置換されて以下のようになります。

if (true) {
  awesomeProcess();
} else {
  oldProcess();
}

さらに、minifyの設定が有効である場合、dead code eliminationやtree shakingによって、静的な不到達コードが削除され、buildされたassetは以下のようになります。

awesomeProcess();

Webpack DefinePluginを使うことのPros/Cons

Webpack DefinePluginを使うことのPros/Consとしては、以下のようになります。

  • Pros
    • Webpackの標準機能だけで実現でき、手軽に始められる
    • build後のassetから不到達コードを削除でき、不必要にassetを重くしなくて済む
      • また、リリース前の処理をコードからも隠蔽できる
  • Cons
    • flagの管理など、必要な機能なら自前で作る必要がある
    • buildしなければいけないので、即時反映は難しい
    • Typescriptなど型アノテーションに、toggleの結果が反映できない

この手法ですが、調べてはないですが、Webpack以外でも静的に定数を書き換えられ、未到達コードが削除できるbundlerなら利用できると思われます。

プロダクトでの事例紹介

自分の関わっているプロジェクトでの利用例を紹介します。
導入背景としては、プロダクトリリース後、以下のような問題が発生していました。

  • 並行して開発している大きな機能ブランチがメインブランチにマージできず、メインのブランチを切り直す
  • マージ作業の失敗による修正内容の消失
  • 緊急リリース時の、未リリース機能のrevert作業

複雑なマージのフローを単純に

そこで、以下のような簡単なFeature flagの実装を導入しました。
特徴としては、次のようになります。

  • flagの書き出しには、Webpack DefinePluginを使う
  • flagの管理は、自前の簡単な実装で行う
  • flagのON/OFF設定は、CIの環境変数を使う
    • 複数の環境毎に設定できる

Feature flagに関わる部分を切り取った実装例を記述します。

Feature flagの管理・設定読み込みには以下のような実装を利用しています。
featureFlags.ts

const FEATURE_FLAG_PREFIX = "FEATURE_FLAG_"; // 環境変数のprefix
function featureFlag(featureName, stage, defaultAvailableStages) {
  const key = `${FEATURE_FLAG_PREFIX}${featureName}`;
  const availableStages = process.env[key];
  console.log(
    `${featureName} feature flag(${key}): ${availableStages}, default available stages:`,
    defaultAvailableStages,
  );

  return {
    [featureName]: availableStages
      ? availableStages
          .split(",") // 有効環境一覧は`,`で区切られる
          .map((f) => f.trim())
          .includes(stage) // 有効環境に今の環境が含まれるか
        // dev環境やlocalで環境変数が未設定時に読み込むデフォルトの設定
      : (defaultAvailableStages || []).includes(stage) || false,
  };
}

export default (stage) => ({
  // flag一覧
  ...featureFlag("AWESOME_FEATURE", stage, ["dev"]),
  ...featureFlag("AWESOME_FEATURE2", stage, ["dev"]),
  ...featureFlag("AWESOME_FEATURE3", stage, ["dev"]),
});

上記の処理をwebpack.configで読み込み、DefinePluginに展開します。

webpack.config.ts

import webpack from "webpack";

import FeatureFlags from "./featureFlags";

const featureFlgas = FeatureFlags(process.env.NODE_STAGE);

export default {
  plugins: [
    new webpack.DefinePlugin({
      ...featureFlags, // { AWESOME_FEATURE: true, AWESOME_FEATURE2: false, AWESOME_FEATURE3: false }
    }),
  ],
}

bundle時に、以下のように実行して、Feature flagを設定します(実際には、package.jsonのscriptにしてあります)。NODE_STAGEFEATURE_FLAGS_AWESOME_FEATUREはCIの環境変数から読み込まれます。

$ cross-env TS_NODE_PROJECT=tsconfig.webpack.json NODE_STAGE=production FEATURE_FLAGS_AWESOME_FEATURE=dev,sandbox,production --config webpack.config.ts

この時、以下のような処理があったとして、Feature flagが適用され、

if (AWESOME_FEATURE) {
  awesomeProcess();
} else {
  oldProcess();
}
if (AWESOME_FEATURE2) {
  awesomeProcess2();
} else {
  oldProcess2();
}
if (AWESOME_FEATURE3) {
  awesomeProcess3();
} else {
  oldProcess3();
}

以下のように、buildされます。

awesomeProcess();
oldProcess2();
oldProcess3();

簡単な実装ですが、この機能により、以下のような利点が得られました。

  • メインブランチに小さい単位でマージできるので、衝突が起きづらく長期に渡る作業もしやすい
  • リリース時の調整作業がflagのon/offだけ考えればよくなった
    • 特定の日時に公開しないといけない機能のリリースが楽になった
  • 他の機能の反映内容が把握しやすい
  • (省略してますが)SDKにおいて、特定のアプリ向けのassetだけ機能を有効化できたり、バージョンレンジで機能を有効化できるようになり、特定の機能の影響が波及しづらくなった

現状の課題としては、以下のようなものを感じています。

  • flagの整理が少し面倒臭い
  • flag変更の履歴がほしい → とりあえず、通知ログにflagの設定を記録するようにしてます
  • flag変更の承認がほしい
    • flag設定の間違えの検知がほしい
  • Experiment togglesOps togglesのように使ってしまい、生存期間の長いflagの検証パターン

まとめ

  • Feature flagの簡単な解説を行いました
  • フロントエンドでのRelease flag実装の一例を示しました
    • Release flagの事例紹介を行いました

勉強会を聞きながら作った簡単な実装の割に、活躍してくれているのでコスパのいい改善だったと思います。


明日は・・・

Smith さんによる『「らせん階段」「カブト虫」「廃墟の街」「Go」「CGO」「C」「PHP」』です、お楽しみに!
ドリコムでは一緒に働くメンバーを募集しています!
募集一覧はコチラを御覧ください!