こんにちは、FANTECH本部の前田(@arabian9ts)です。
以前、マルチリージョンで稼働する内製Feature Flagsの実装で、Feature Flagsをどのように利用しているかをご紹介しました。
今回は、Feature Flags自体の運用をどうしているか?について、ちょっとした工夫を紹介します。

使わなくなったFeature Flagsを消し忘れると?

削除を忘れると、大まかに次の課題が発生します。

  1. コード上の認知負荷が上がる
  2. ネットワークI/Oが発生するFeature Flagsシステムの場合、システムパフォーマンスに影響する

つまり、Feature Flags自体の運用にも配慮する必要があります。

また、Feature Flagsの管理システムと、クライアントとなるコードベースが分離されている場合だと、使われているフラグなのかを認識するのが大変になります。
管理方法次第では、IDEの機能でコード上に呼び出し元が存在するかを確認することができますが、”そもそも必要なフラグなのか?”を判断するコストがあります。
時間が経てば経つほど、人間の記憶は曖昧になっていくので、Feature Flagsは使い終わったらすぐに消すことを徹底したいところです。

reminder-lint

私のチームでは、サーバーサイドで主にGo言語を使用しています。
Go言語では、簡単に静的解析ツールを実装することができ、go vetを通して実行することが可能です。
そのため、開発効率を上げたり、リスクを軽減するための簡易的なlintをいくつか実装しており、普段の開発でCI上で品質チェックに利用しています。
reminder-lintはその1つであり、コード上の日付が書かれたコメントを拾い、その日付を過ぎていればステータスコード1でexitする静的解析ツールです。

使用方法

Goのコードで、例えば以下のように使用します。

func (s *SomeUsecase) DoSomething(ctx context.Context) {
  // remind:2024-04-01 全ユーザーにTest機能をリリースしたら、Feature Flagsを削除 @arabian9ts
  enabled, err := s.featureFlags.EnableTestFeature(ctx)
  if err != nil {
    enabled = false // Feature Flagsを解決できなかった場合のデフォルト値
  }
  
  if enabled {
    // ...
  }
}

このように記述しておくと、2024/04/01以降、CIがFAILするような仕組みとなっています。

実装

実装方法としては、2パターンあると思います。

  1. 全ファイルを正規表現で走査する
  2. Goのanalyzerを利用して走査する

複数の言語をモノレポ管理している場合などは、2で解析すると苦しむことになりそうです。
今回は、管理しているリポジトリがすべてGoで書かれていることと、既存のlint実装がありそれを踏襲したことから、Analyzerでの実装となっています。

実装内容としては非常に単純で、コード上のコメントをひたすら走査します。
remind を含むコメントを見つけたら、位置を保持しておきます。

package analyzer

import (
	"fmt"
	"go/ast"
	"go/token"
	"reflect"
	"strings"

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/analysis/passes/inspect"
)

var Analyzer = &analysis.Analyzer{
	Name:       "comments",
	Doc:        "analyze files include remind comment directive",
	Run:        run,
	Requires:   []*analysis.Analyzer{inspect.Analyzer},
	ResultType: reflect.TypeOf(map[ast.Node]struct{}{}),
}

type Comment struct {
	Text string
	Pos  token.Pos
}

func run(pass *analysis.Pass) (any, error) {
	res := make(map[string]Comment)
	hmap := make(map[string]struct{})
	for _, f := range pass.Files {
		fileName := pass.Fset.File(f.Pos()).Name()
		if strings.HasSuffix(fileName, "_test.go") {
			continue
		}
		cMap := ast.NewCommentMap(pass.Fset, f, f.Comments)
		ast.Inspect(f, func(node ast.Node) bool {
			switch node.(type) {
			case *ast.File,
				*ast.InterfaceType,
				ast.Spec,
				ast.Decl,
				ast.Stmt,
				ast.Expr,
				*ast.Comment,
				*ast.CommentGroup:
			default:
				return false
			}
			cg := cMap.Filter(node)
			for _, c := range cg.Comments() {
				if !strings.Contains(c.Text(), "remind") {
					continue
				}
				k := fmt.Sprintf("%s-%d", fileName, c.Pos())
				if _, ok := hmap[k]; ok {
					continue
				}
				res[k] = Comment{
					Text: c.Text(),
					Pos:  c.Pos(),
				}
				hmap[k] = struct{}{}
			}
			return true
		})
	}
	return res, nil
}

走査結果のコメントから、日付のフォーマットを正規表現でチェックし、マッチしなければエラーとして扱っています。
あとは、日付を過ぎているかどうかチェックして、過ぎている場合は Reportf でレポートします。

package reminder

import (
	"errors"
	"go/token"
	"regexp"
	"strings"
	"time"

	"github.com/ca-irvine/server/linter/reminder/analyzer"
	"golang.org/x/tools/go/analysis"
)

const doc = "reminder is lint to check if the date past"

var reg = regexp.MustCompile(`\d{4}[-|/]\d{2}[-|/]\d{2}`)

// Analyzer is lint to check if the date past
var Analyzer = &analysis.Analyzer{
	Name: "reminder",
	Doc:  doc,
	Run:  run,
	Requires: []*analysis.Analyzer{
		analyzer.Analyzer,
	},
}

func run(pass *analysis.Pass) (any, error) {
	result := pass.ResultOf[analyzer.Analyzer].(map[string]analyzer.Comment)
	if result == nil {
		return nil, nil
	}
	for _, c := range result {
		inspect(pass, c.Pos, c.Text)
	}
	return nil, nil
}

func inspect(pass *analysis.Pass, pos token.Pos, comment string) {
	const directive = "remind:"
	if !strings.Contains(comment, directive) {
		return
	}

	dates := reg.FindStringSubmatch(comment)
	if len(dates) == 0 {
		pass.Reportf(pos, "%s: invalid remind comment", comment)
		return
	}

	message := strings.Replace(comment, directive, "", 1)
	for _, date := range dates {
		t, err := date(date)
		if err != nil {
			pass.Reportf(pos, "%s: invalid date", date)
			return
		}
		if t.Before(time.Now()) {
			pass.Reportf(pos, "%s", message)
			return
		}
	}
}

func date(v string) (t time.Time, err error) {
	const (
		slash  = "2006/01/02"
		hyphen = "2006-01-02"
	)
	t, err = time.Parse(slash, v)
	if err == nil {
		return t, nil
	}
	t, err = time.Parse(hyphen, v)
	if err == nil {
		return t, nil
	}
	return time.Time{}, errors.New("invalid date")
}

実際に削除漏れは防げているのか

結論から言うと、防げています。
Feature Flagsの削除漏れを防げるようになり、システムを健全に保ちやすくなりました。
また、Feature Flagsに限らず、後方互換性のために一時的に実装する処理などは、どうしても削除を忘れがちです。
そういった、未来で必要なくなる処理を間違いなく仕留めるために、リマインドシステムは有益だと考えています。
もちろん、カレンダーやチケットでリファクタリングタスクを管理することも可能ですが、コード上にコンテキストが残ることで、実装者は一時的な処理であることを理解して実装することができます。

ことFeature Flagsの削除漏れについては、運用面での工夫ポイント”>マルチリージョンで稼働する内製Feature Flagsの実装 – 運用面での工夫ポイント –でもご紹介しましたが、IaC管理とコード自動生成によって、Feature Flagsとコードベースが密接な関係になっていることも大きな要因です。
Feature Flagsの呼び出しもとにreminder-lintを仕込んでおくことで、さらに削除漏れのリスクを下げられていると思います。

2020年新卒入社のエンジニア。FANTECH本部所属。会社でコーヒー紅茶を淹れて周りに無差別提供します。