freeeの開発情報ポータルサイト

GoのASTを解析してFeature Toggleを掃除する

この記事はfreee Developers Advent Calendar 2023の21日目の記事です。

こんにちは、金融開発部でEMをしている ogugu です。
今回は、Go言語において Feature Toggle の分岐を掃除するCLIを作ったので、ご紹介します。
毎年恒例の開発合宿 での成果になります。

Feature Toggle とは

Feature Toggle とは、特定機能の有効・無効をフラグによって切り替える方法です。
金融開発部では、以下のように Feature Toggle を実現できるライブラリを用意しています。

if featflg.Enabled(ctx, "feature_name") {
    doNewLogic()
} else {
    doOldLogic()
}

一般に、フラグ設定は、設定ファイル・環境変数・ストレージなどから読み出し、それらに対する変更でON/OFFを切り替えます。
例えば、我々のチームではyamlファイルにフラグ設定を記述しています。

Feature Toggleは、主に「コードのデプロイ」と「機能のリリース」を分離するために利用します。
トランクベース開発のようなシンプルなブランチ運用を維持するためには、欠かせない手段です。

さて、機能リリースを終えたら、分岐を削除して、機能ONのロジックのみを残す、といった掃除作業を行います。
一方、その作業が億劫になると、Feature Toggle ではなく Feature ブランチによってコード退避をすることになり、ブランチ運用の複雑化につながります。

今回は、そういった掃除作業を自動化することで、Feature Toggle の利用ハードルを下げることを狙いました。

go/ast の課題

Feature Toggle の自動掃除は、いわゆるリファクタリングツールに相当します。
実現方法はいくつかありますが、今回はASTを解析する方法をベースにしました。

さて、Goには go/ast という標準のASTパッケージが存在します。
これを使うことで、ほとんどの静的解析を簡単に行うことができます。

そんな go/ast ですが、実は「ASTノードを変更するとコードコメントとの位置関係がズレる」という課題があります*1

code := `package a

func main(){
  var a int    // foo
  var b string // bar
}
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "", code, parser.ParseComments)
if err != nil {
    panic(err)
}

list := f.Decls[0].(*ast.FuncDecl).Body.List
list[0], list[1] = list[1], list[0]

if err := format.Node(os.Stdout, fset, f); err != nil {
    panic(err)
}

//期待値:
//package a
//
//func main() {
// var b string // bar
// var a int    // foo
//}

//実際の結果:
//a と b の変数宣言を入れ替えると、コードコメントが崩れる
//package a
//
//func main() {
// // foo
// var b string
// var a int
// // bar
//}

今回は、「特定のifブロックの中身を抜き出して、if文を削除すること」がメインです。 それゆえ、まさに「元のifブロック前後でコードコメントがズレる」ということが起きました。

ちなみに、go/anlysis (Goの静的解析ツールのエコシステム) や uber/gopatch (patch ファイルを定義して適用するリファクタリングツール) なども検討しましたが、要件に合いませんでした。

前者は、簡単な autofix 機能を実現することができますが、go/ast をベースにしているため、 go/ast と同様の課題を抱えています。
後者は、「ブロックの中身(ast.Stmtのリスト)を取り出してどこかに挿入する」という操作をpatchファイルで表現できませんでした。

dave/dst

そんな go/ast の課題を払拭するために dave/dst というパッケージが存在します。
dave/dst では、以下のようにASTノード自身がコメント情報を保持します。

// Node is satisfied by all nodes types.
type Node interface {
    // Decorations returns the common Node decorations (Before, After, Start, End). This returns nil for Package nodes.
    Decorations() *NodeDecs
}

// NodeDecs holds the decorations that are common to all nodes (except Package).
type NodeDecs struct {
    Before SpaceType
    Start  Decorations
    End    Decorations
    After  SpaceType
}

// Decorations is a slice of strings which are rendered with the node. Decorations can be comments (starting "//" or "/*") or newlines ("\n").
type Decorations []string

ASTノードが保持する NodeDecs という構造体は、

  • そのノードの前後に空行などのスペースがあるかどうか: Before After
  • そのノードの前後にあるコメント本文: Start End

という情報を持ち、それによってコードとコメントの関係を表現しています。

一方で、go/ast では Comment という構造体がコメントの位置をバイトオフセットで持っています *2
なお、バイトオフセットはノードから見て相対的なものではなく、ファイルの先頭から数えたものです。

この違いによって、dave/dst では、ASTノードを入れ替えてもそれに付随するコメントの位置関係が崩れない ようになっています。

また、go/ast には astutil という、パースだけでなくノードの変更まで行えるユーティリティがあります。
詳しい使い方は、以下のtentenさんの記事が参考になります。
astutil.Applyで抽象構文木を置き換える #golang #Go - Qiita

実は、dave/dst では、これを模した dstutil というパッケージも用意されており、 go/ast との互換をかなり意識しています。
以下は、あらゆる if 文に対してブロックの中身だけを抽出する例です。

dstutil.Apply(file, func(cr *dstutil.Cursor) bool {
    dn := cr.Node() // 現在のノードへの参照を取得する
    ifStmt, ok := dn.(*dst.IfStmt) // if文であることを型アサーション
    if !ok {
        return true
    }
    for _, stmt := range ifStmt.Body.List {
        cr.InsertBefore(stmt) // ifブロックの中身を抽出していき、ifブロックの手前に挿入
    }
    cr.Delete() // 元々のifブロックは削除
    return true
}

dstutil.Cursor が現在のノードへの参照とともに、ノードを操作するための様々なメソッド (InsertBefore Delete など) を持っています。

したがって、今回は dave/dst を使ってASTを解析・編集することにしました。

フラグ判定をより解析しやすいIFへ

解析手段が決まったので実装に入りたいところですが、その前に、既存の判定ロジックを見直しました。
単純ですが、以下のようにフラグ判定のIFを変更しました。

// 変更前
if featflg.FromContext(ctx).Enabled("example_feature") {
    // ...
}

// 変更後
if featflg.Enabled(ctx, "example_feature") {
    // ...
}

変更前は、context に保持されたフラグ情報を FromContext で取得し、 Enabled メソッドで指定した機能のON/OFFを取得していました。
このようにメソッドチェーンしていると、静的解析にわずかながら手間がかかります。
そこで、変更後のようなショートカットしたIFを用意しました。

分岐パターンを整理する

さらに、サポートすべき分岐パターンも整理することにしました。
Goは三項演算子もなく、分岐構文は比較的少ないですが、それでも想定する実装パターンを狭めないと大変です。
特に、条件式の形に着目して、以下のケースに絞りました。

1) if の条件式がフラグ判定のみの場合

if featflg.Enabled(ctx, "feature_name") {
    doSomething() // この行だけ残す
} else if otherBool {
    doAnother()
} else {
    doAnother()
}

2) if の条件式がフラグ判定の論理否定のみの場合

if !featflg.Enabled(ctx, "feature_name") {
    doAnother()
} else if otherBool {
    doAnother() // if ブロックとして残す
} else {
    doAnother() // 上記に対する else ブロックとして残す
}

if !featflg.Enabled(ctx, "feature_name") {
    doAnother()
} else {
    doAnother() // この行だけ残す
}

3) if の条件式がフラグ判定と論理積 && の組み合わせの場合

if featflg.Enabled(ctx, "feature_name") && otherBool { // 条件式が otherBool のみになる
    doSomething()
}

これだけを見ると、「このパターンはいいのだろうか」という懸念が次々と湧いてきます。
一方、1つ1つ考えてサポートしていくとキリがないので、 以下のようにポリシーを定めました。

  • まず、自分たちのプロダクトに適用して、期待通りの結果になることだけを目指す(今やってない使い方は考えない)
  • サポート範囲から外れる運用があれば「本当に必要な運用か」を考える

実際に、サポート対象外としたケースを見ていきます。

1) 同じフラグの分岐がネストするケースはサポートしない

if featflg.Enabled(ctx, "feature_hoge") {
    doSomething()
    if featflg.Enabled(ctx, "feature_hoge") {
        doAnother()
    }
}

これが本質的に必要になることはなく、たいていの場合に冗長であることは一目瞭然かと思います。

2) フラグ判定に重ねる二項演算は && 以外サポートしない

条件式に着目しているため、二項演算は論理演算に絞って考えます。
フラグ判定に論理積 && を重ねるケースは実運用でも見るため、サポート対象としましたが、論理和 || を重ねるケースはあまり見られませんでした。

実際に、論理和 || を使ったケースを考えてみます。

if featflg.Enabled(ctx, "feature_hoge") || otherBool {
    doSomething() // ONになるとotherBoolに関わらず必ず発火する
}

この場合、従来のコードは以下のようになっていたはずです。

if otherBool {
    doSomething()
}

また、掃除した後は、以下のようにしたいはずです。

doSomething()

実は、このフラグ分岐は、サポートケース 1) に書き直すこともできます。

if featflg.Enabled(ctx, "feature_hoge") {
    doSomething()
} else {
    if otherBool {
        doSomething()
    }
}

こちらの方が、else ブロックに従来のコード構造がそのまま維持されています。
Feature Toggle を導入する際は、OFFの時に従来の挙動と変わらないことが重要であり、こちらはそれが一目でわかるため、より好ましいと言えるでしょう。
そこで、思い切って論理和 || のサポートを外すことにしました。

3) switch case に対するサポートはしない

別のチームでは、フラグ (bool) ではなく、リリースフェーズを表す整数値 (int) を利用するケースも見受けられます。

switch featflg.Enabled(ctx, "hoge_phase") {
case 1:
    doPhase1Logic()
case 2:
    doPhase2Logic()
default:
    doOldLogic()
}

一方、自チームでの運用を考えると、以下の運用でカバーできるのでは、と考えました。

  1. フェーズ1のフラグをboolで追加
  2. フェーズ1のリリースが完了したらフラグを削除
  3. フェーズ2のフラグをboolで追加
  4. 以下同様

そこで、MVPとしては思い切って switch case のサポートを外しました。

実際に分岐を消していく

さて、実際に分岐を消していきましょう。
まずは、サポートケースのif文を探すことを考えます。
条件式が featflg.Enabled(ctx, "feature_name") の形になっているかは、以下のように判定できます。

// featflg.Enabled(ctx, "feature_name") という式か判定する
func isEnabledFunc(expr dst.Expr) bool {
    e, ok := expr.(*dst.CallExpr) // 関数呼び出しを表す型であることをアサーション
    if !ok {
        return false
    }
    if selExpr, ok := e.Fun.(*dst.SelectorExpr); ok {
        if selExpr.Sel.Name == "Enabled" {
            if ident, ok := selExpr.X.(*dst.Ident); ok {
                if ident.Name == "featflg" {
                    // サンプルのためフラグ名はハードコード
                    if len(e.Args) >= 2 && e.Args[1] == "feature_name" {
                        return true
                    }
                }
            }
        }
    }
    return false
}

Go言語では、if文の条件式は必ず ast.Expr (dst では dst.Expr) という interface になっています。
したがって、dst.Expr を引数で受け取って、想定する評価式であるかどうかを bool で返す IF としています。

さらに、 featflg.Enabled という関数呼び出しを想定するため、関数呼び出しを表す型 ast.CallExpr (dst では dst.CallExpr) へアサーションしています。
すると、パッケージ名や引数の情報を芋づる式に取得できます。

また、論理否定は、 ast.UnaryExpr (dst では dst.UnaryExpr) という型になります。
条件式がフラグの論理否定 !featflg.Enabled(ctx, "feature_name") となっているかは、同様に以下で判定できます。

// !featflg.Enabled(ctx, "feature_name") という式かどうかを判定する
func isDisabledFunc(expr dst.Expr) bool {
    e, ok := expr.(*dst.UnaryExpr)
    if !ok {
        return false
    }
    if e.Op != token.NOT {
        return false
    }
    callExpr, ok := e.X.(*dst.CallExpr)
    if !ok {
        return false
    }
    return isEnabledFunc(callExpr)
}

上記で定義した関数とともに dstutil を用いると、1) と 2) のケースは以下のように解決できます。

func main() {
    // ...
    dstutil.Apply(file, func(cr *dstutil.Cursor) bool {
        ifStmt, ok := cr.Node().(*dst.IfStmt)
        if !ok {
            return true
        }
        switch ifStmt.Cond.(type) {
        case *dst.CallExpr:
            // 1) のケース
            if !isEnabledFunc(ifStmt.Cond) {
                return true
            }
            for _, stmt := range ifStmt.Body.List {
                cr.InsertBefore(stmt) // ifブロックの中身を抽出して挿入
            }
            cr.Delete() // 元々のifブロックは削除
        case *dst.UnaryExpr:
            // 2) のケース
            if !isDisabledFunc(ifStmt.Cond) {
                return true
            }
            switch elseStmt := ifStmt.Else.(type) {
            case *dst.BlockStmt:
                for _, stmt := range elseStmt.List {
                    cr.InsertBefore(stmt) // elseブロックの中身を抽出して挿入
                }
                cr.Delete()
            case *dst.IfStmt:
                cr.InsertBefore(elseStmt)
                cr.Delete()
            case nil:
                cr.Delete()
            default:
                log.Println("unexpected statement for else block")
            }
        case *dst.BinaryExpr:
            // 3) のケース

        }
        return true
    }, nil)
    // ...
}

一方、3) のケースは少し特殊です。
if文の条件式を取り出し、再度 dstutil.Apply を実行することで、if文の条件式を再構成する必要があります。

そして、論理積 && の左側にフラグ判定がある場合、右側の評価式で式全体を置き換えます。
例) featflg.Enabled(ctx, "feature_name") && someBoolsomeBool
逆も同様にして、dstutil.Apply を回していくと、評価式のツリーから featflg.Enabled を削除することができます。

func main() {
    // ...
    dstutil.Apply(file, func(cr *dstutil.Cursor) bool {
        // ...
        switch ifStmt.Cond.(type) {
        // ...
        case *dst.BinaryExpr:
            // 3) のケース
            // if文の条件式のASTノードを再構成し、戻り値で受け取る
            res := dstutil.Apply(ifStmt.Cond, func(c *dstutil.Cursor) bool {
                expr, ok := c.Node().(*dst.BinaryExpr)
                if !ok {
                    return true
                }
                if expr.Op != token.LAND {
                    // && 以外の二項演算は考慮しない
                    return true
                }
                if isEnabledFunc(expr.X) {
                    // `if featflg.Enabled(ctx, "feature_name") && someBool {...}` の場合
                    c.Replace(expr.Y)
                }
                if isEnabledFunc(expr.Y) {
                    // `if someBool && featflg.Enabled(ctx, "feature_name") {...}` の場合
                    c.Replace(expr.X)
                }
                return true
            }, nil)
            cond, ok := res.(dst.Expr)
            if !ok {
                log.Printf("unexpected type for if condition: %T", res)
                return true
            }
            ifStmt.Cond = cond // 再構成した条件式ノードを再代入
        return true
    }, nil)
    // ...

最後に、dstutil.Apply の戻り値として再構成したノードが返されるので、これを ifStmt.Cond = cond のように再代入する必要があります。
こうして、1) ~ 3) の主要な分岐パターンをサポートすることができました。

さいごに

今回の開発を通して、t_wadaさんの Second-System Syndrome の発表に通じるものを感じました。

アーキテクトは、最初の仕事では、自制して過剰なものを付け加えないことが多い。自分で何をしているのかがわからないことを認識しているので、何事も慎重かつ節度をもって行う。
最初の仕事をデザインしていくとき、次から次へとフリルやら飾りやら余計なものが思い浮かんでくるのだが、それは「次回」使おうと取っておく。

これは、ライブラリ開発に限らず、アジャイルなソフトウェア開発においても重要な考え方かと思います。
実際、ここまでサポートするケースを狭めて、ようやく2日で実装できたので、こういった割り切りなしで作り切ることは難しかったと感じます。

我々の金融開発部では、こうした静的解析ツールを自作するなどして、品質改善に役立てています。
少しでもご興味を持った方は、転職意思を問わず、ぜひカジュアルにお話しましょう。

求人:クレジットカード事業開発エンジニア

*1:詳細は本家のIssueをご覧ください: go/ast: Free-floating comments are single-biggest issue when manipulating the AST · Issue #20744 · golang/go · GitHub

*2: 具体的には、Comment 構造体の Slash というフィールドにバイトオフセット位置を持っています: ast package - go/ast - Go Packages