株式会社CINC 開発本部(旧:開発部) エンジニアブログ

開発部→開発本部!!ビッグデータ取得/分析・自然言語処理・人工知能(AI)を用いた開発を軸に、マーケティングソリューションの開発や、DX推進を行っている、株式会社CINCの開発本部です。

Go言語でハードコードされたIDの重複を自動的に検出する

Go言語でハードコードされたIDの重複を自動的に検出する

はじめに

CINC開発部です。 「処理毎にユニークなIDを付与して、ログから実行回数を集計したい」という要望がでてきた際に、IDに重複があると正しい集計ができなくなるため、ソースコード上にハードコードされたIDの重複チェックを自動化する仕組みを構築しました。その時の対応内容をまとめます。

今回の記事で使用したソースコードは下記になります

github.com

前提

今回の対応に当たって元々の処理は下記のような処理をまとめるための構造体は下記のように定義されており

type SelectorGroup struct {
    Name      string
    Selectors []SelectorItem
}

type SelectorItem struct {
    ID       string // uuid v4 をハードコードで設定する
    Selector func(datas *testdata.TestData) *testdata.TestData
}

各パッケージ内に次のようにパッケージ変数の形で利用されていました。

package testa
var TypeCheckSelector = common.SelectorGroup{
    Name: "testa type check selector",
    Selectors: []common.SelectorItem{
        {
            ID: "fc1af446-1402-46c1-a694-1579fca68731",
            Selector: func(data *testdata.TestData) *testdata.TestData {
                // (データ内に条件A-1を満たす項目がある場合の処理)
            },
        },
        {
            ID: "f35ca47fb-bdde-4fd7-b125-9a59b6611b5f",
            Selector: func(data *testdata.TestData) *testdata.TestData {
                // (データ内に条件A-2を満たす項目がある場合の処理)
            },
        },
    },
}

パッケージ毎にデータのチェックをしているのですが、どの判定で引っかかったのかを集計したいという要望が出てきたため、各処理に一意なUUIDを付与してログに出力し集計する事にしました。

この時にUUIDに重複があると正しく集計できないため、重複をチェックする仕組みを追加した形になります。

対応内容

  1. 前提のパッケージ変数をまとめるためのパッケージ変数selectorGroupsを用意して、各packageのinit()でそこに情報を登録する。
  2. 登録された情報を元に重複のチェックを行うコマンドを用意し実行する

という流れで対応しました。selectorGroupsへの登録処理を都度手動で行うと、パッケージを追加したり変数を追加したりした際に対応に漏れが発生する可能性が高いため、自動で登録用のファイルを生成できるようにしていきます。

ディレクトリ構成

今回扱っているサンプルのディレクトリ構造は下記のようになっています。 *_gen.goを自動生成していく事になります。

root
├── go.mod
├── go.sum
│
├── cmd/
│   ├── check/
│   │   └── main.go                              # チェック用コマンド実装
│   └── gencode/
│       └── main.go                              # コード生成用コマンド実装
│
└── internal/
    └── selector/
        ├── autoload/
        │   └── packages_gen.go                  # 自動生成されたパッケージ情報
        ├── common/
        │   └── selector.go
        ├── registry/
        │   └── registry.go
        ├── testa/
        │   ├── register_gen.go                  # 自動生成された登録処理
        │   └── selector.go
        └── testb/
            ├── register_gen.go                  # 自動生成された登録処理
            └── selector.go

パッケージ変数を登録して管理するパッケージの作成

下記の様な、各パッケージの変数を登録するためにregistryというパッケージを用意しました。(internal/selctor/registry/registry.go)

これに対して各パッケージのinit()registry.Register(sg []common.SelectorGroup)で、パッケージ変数を登録していき、最終的には登録されたパッケージ変数をregistry.All()で取得して、IDの重複チェックを行っていきます。

なお、一つのパッケージ内に複数の変数が存在するケースがあったため、[]common.SelectorGroupではなくて、[][]common.SelectorGroupと、パッケージ毎に[]common.SelectorGroupを登録していくようにしています。

package registry

import "blogtest/internal/selector/common"

var selectorGroups [][]common.SelectorGroup

func Register(sg []common.SelectorGroup) { selectorGroups = append(selectorGroups, sg) }
func All() [][]common.SelectorGroup      { return selectorGroups }

registryに登録する処理

各パッケージに下記のようにinit()内でregistry.Registerに対して対象パッケージの持つSelectorGroupを[]common.SelectorGroupの形で登録するためのファイルを、パッケージの情報から自動で作成して追加していけるようにします。

// Code generated by cmd/gencode; DO NOT EDIT.
package testa

import (
    "blogtest/internal/selector/common"
    "blogtest/internal/selector/registry"
)

func init() {
    registry.Register([]common.SelectorGroup{
        TypeCheckSelector,
    })
}

このファイルを都度手動で作成や更新していると対応漏れが発生するため、register_gen.goというファイル名で自動生成していきます。

ソースファイルを自動生成する。

各パッケージの情報を取り出して利用する。

自動でregistryへの登録用のファイルを作成するようにします。go/packagesを利用してパッケージの情報から、パッケージ変数の名前を取り出してソースを出力します。

packageから読み込む情報については、今回は下記のModeが必要となっています。

NeedName : pkgPathとNameを利用する。
NeedFiles : ファイル出力先のディレクトリ名をファイルのパスから取得する。
NeedSyntax : シンタックスツリーをチェックして変数を取り出す。

ソースを自動生成するためのコードは下記のようになります。(cmd/gencode/main.go)

func main() {
    modRoot := moduleRoot() // go.modのあるディレクトリを返すメソッドです。(本内容とは関係ないので詳細は省きます)
    modName := readModuleName(modRoot) // go.mod内からmodule名を取得するメソッドです。 (本内容とは関係ないので詳細は省きます)
    // packagesで読み込む内容の設定
    cfg := &packages.Config{
        Dir:  modRoot,
        Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax,
    }
    // 読み込むpackageの設定
    pkgs, err := packages.Load(cfg, "./internal/selector/...")
    if err != nil {
        panic(err)
    }
    if packages.PrintErrors(pkgs) > 0 {
        os.Exit(1)
    }

    var imports []string
    for _, p := range pkgs {
        // 各パッケージでregstryへ登録するソースファイルの生成
        isWrote, err := generateRegisterFile(p, modName)
        if err != nil {
            panic(err)
        }
        if isWrote {
            // ファイルを生成したら autoload/packages_gen.go 用のインポートに追加
            imports = append(imports, p.PkgPath)
        }
    }
    // 出力内容を安定させるためソート
    slices.Sort(imports)
    imports = slices.Compact(imports)
    // autoload 用ファイル生成
    if err := generateAutoloadFile(imports, modRoot); err != nil {
        panic(err)
    }
}

regstryに登録用のソースコードを生成

読み込むパッケージについては ./internal/selector の中身を対象としています。

    pkgs, err := packages.Load(cfg, "./internal/selector/...")

packageをloadしたら、読み込んだpackageの情報を元にファイルの作成を行っていきます

    var imports []string
    for _, p := range pkgs {
        // 各パッケージでregstryへ登録するソースファイルの生成
        isWrote, err := generateRegisterFile(p, modName)
        if err != nil {
            panic(err)
        }
        if isWrote {
            // ファイルを生成したら autoload/packages_gen.go 用のインポートに追加
            imports = append(imports, p.PkgPath)
        }
    }

generateRegisterFileは次のようになっています。

func generateRegisterFile(p *packages.Package, modName string) (bool, error) {
    if len(p.GoFiles) == 0 {
        return false, nil
    }
    // 対象ディレクトリ
    dir := filepath.Dir(p.GoFiles[0])
    // シンタックスツリーから必要な情報を収集
    sels := pickSelectorVars(p)
    // Selectors 部分を組み立て
    selectorsStr := strings.Builder{}
    if len(sels) == 0 {
        return false, nil // Selectorが無ければ生成不要
    } else {
        selectorsStr.WriteString("[]common.SelectorGroup{\n")
        for _, s := range sels {
            fmt.Fprintf(&selectorsStr, "%s,\n", s)
        }
        selectorsStr.WriteString("}")
    }
    // 各パッケージにソース生成 (register_gen.go)
    var out bytes.Buffer
    template := fmt.Sprintf(`// Code generated by cmd/gencode; DO NOT EDIT.
package %s

import (
    %q
    %q
)

func init() {
    registry.Register(%s)
}
`,
        p.Name,
        modName+"/internal/selector/registry",
        modName+"/internal/selector/common",
        selectorsStr.String())
    out.WriteString(template)

    src, err := format.Source(out.Bytes())
    if err != nil {
        // フォーマットエラーはそのまま書き出す
        src = out.Bytes()
    }

    dst := filepath.Join(dir, "register_gen.go")
    if err := os.WriteFile(dst, src, 0o644); err != nil {
        return false, err
    }
    fmt.Println("wrote", dst)
    return true, nil
}

内容としては、先程の "registryに登録する処理" の内容を各パッケージ内に register_gen.go というファイル名で出力する、というものになります。

    // 対象ディレクトリの取得
    dir := filepath.Dir(p.GoFiles[0])

p.GoFilesには対象パッケージ内のファイルのリストが渡されますので、先頭のファイルから出力先のディレクトリの絶対パスを取得しています。

出力ファイルの先頭に

// Code generated by cmd/gencode; DO NOT EDIT.

というコメントを入れていますが、編集しないようにという意図を知らせるのもそうですが、IDEによってはこの様なコメントを入れておくと、編集時に警告が出るようになっていますので入れておくとよいかと思います。

構文解析して変数名の取り出し

pickSelectorVarsという関数内でGoの構文解析を利用して、各パッケージ内のcommon.SelectorGroup型を持つパッケージ変数の変数名を抽出していきます。

処理の流れは次のようになります。

  1. varで宣言された変数のみを対象とする。
  2. 構造体リテラルcommon.SelectorGroup{...})の形式をチェックする。
  3. 型名がcommon.SelectorGroupと一致する変数名を収集する。

実装は下記のようになっています。

func pickSelectorVars(p *packages.Package) []string {
    if len(p.Syntax) == 0 {
        return nil
    }
    sels := make([]string, 0, 10)
    for _, f := range p.Syntax {
        ast.Inspect(f, func(n ast.Node) bool {
            decl, ok := n.(*ast.GenDecl)
            // var宣言以外はスキップ
            if !ok || decl.Tok != token.VAR {
                return true
            }
            for _, spec := range decl.Specs {
                vs, ok := spec.(*ast.ValueSpec)
                if !ok {
                    continue
                }
                for i, name := range vs.Names {
                    if i < len(vs.Values) && vs.Values[i] != nil {
                        // CompositeLit (構造体リテラル) の場合のみ対象とする
                        if compLit, ok := vs.Values[i].(*ast.CompositeLit); ok {
                            // 型名を確認し、common.SelectorGroup なら登録対象とする
                            typeName := getTypeName(compLit.Type)
                            if typeName == "common.SelectorGroup" {
                                sels = append(sels, name.Name)
                            }
                        }
                    }
                }
            }
            return false
        })
    }
    return sels
}
    if len(p.Syntax) == 0 {
        return nil
    }

では、対象packageにシンタックスツリーがない場合には変数は無いとしてnilを返しています。

    for _, f := range p.Syntax {
        ast.Inspect(f, func(n ast.Node) bool {
            // パッケージ内の各シンタックスツリーに対して、深さ優先で再帰処理
        })
    }

p.Syntaxにはパッケージ内の各ファイルのシンタックスツリーが含まれているので、それぞれについてast.Inspectで各ノードを再帰的に処理していきます。

処理の中で、"common.SelectorGroup"という型の変数の変数名だけを抽出していく様にします。

            decl, ok := n.(*ast.GenDecl)
            // var宣言以外はスキップ
            if !ok || decl.Tok != token.VAR {
                return true
            }

では、varで変数宣言されているもののみを対象にしています。 token.VAR以外の場合にはtrueを返してそのまま子ノードの探索を続行させます。

            for _, spec := range decl.Specs {
                vs, ok := spec.(*ast.ValueSpec)
                if !ok {
                    continue
                }
                for i, name := range vs.Names {
                    if i < len(vs.Values) && vs.Values[i] != nil {
                        // CompositeLit (構造体リテラル) の場合のみ対象とする
                        if compLit, ok := vs.Values[i].(*ast.CompositeLit); ok {
                            // 型名を確認し、common.SelectorGroup なら登録対象とする
                            typeName := getTypeName(compLit.Type)
                            if typeName == "common.SelectorGroup" {
                                sels = append(sels, name.Name)
                            }
                        }
                    }
                }
            }

変数宣言部を取得して各変数について、型を確認していきます。 今回は構造体のリテラルになることがわかっているので valueについて、*ast.CompositeLitだった場合のみ型を細かくチェックしています。 getTypeName で型名を取得して、"common.SelectorGroup"と一致していたら、その変数名を返却用のスライスに追加しています。

getTypeNameについては下記のような実装になっています。

func getTypeName(expr ast.Expr) string {
    switch t := expr.(type) {
    case *ast.Ident:
        return t.Name
    case *ast.SelectorExpr:
        return getTypeName(t.X) + "." + t.Sel.Name
    default:
        return "unknown"
    }
}

今回は "common.SelectorGroup" なので、*ast.SelectorExprの t.Xに*ast.Identで"common"が入っており、t.Sel.Nameに"SelectorGroup"が入っています。 .で結合して"common.SelectorGroup"として返すようにしています。

これでregistryへの登録部分のソースコード自動生成はできました。

各パッケージのinit()を実行させるためのimport用ファイルの生成

自動生成されたソースコードをチェック用のコマンドで利用するために、対象となる全てのpackageに対してブランクインポートをするためのimport用のファイルを自動生成させます。ブランクインポートをすることで、明示的に各packageでinit()を実行させます。

    // 出力内容を安定させるためソート
    slices.Sort(imports)
    imports = slices.Compact(imports)
    // autoload 用ファイル生成
    if err := generateAutoloadFile(imports, modRoot); err != nil {
        panic(err)
    }

自動生成したファイルが都度異なった順番で出力されないようにソートし、また念の為重複要素を排除しています。 その後generateAutoloadFile関数で、import用のファイルを生成します。 generateAutoloadFileは下記のような実装になっています。

func generateAutoloadFile(imports []string, modRoot string) error {
    var out bytes.Buffer
    // import対象
    importsStr := strings.Builder{}
    for _, imp := range imports {
        fmt.Fprintf(&importsStr, "    _ %q\n", imp)
    }

    template := fmt.Sprintf(`// Code generated by cmd/gen-infocode; DO NOT EDIT.
package autoload

import (
%s
)
`, importsStr.String())
    out.WriteString(template)

    src, err := format.Source(out.Bytes())
    if err != nil {
        // フォーマットに失敗しても一旦そのまま書き出す
        src = out.Bytes()
    }

    dst := filepath.Join(modRoot, "internal", "selector", "autoload", "packages_gen.go")
    if err := os.WriteFile(dst, src, 0o644); err != nil {
        return err
    }
    fmt.Println("wrote", dst)
    return nil
}

import対象のリストを元に、"internal/selector/autoload/packages_gen.go"ファイルを出力しています。

これで、重複チェックのために必要なファイル群の自動生成ができました。

チェック用のコマンドの追加

自動生成したimport用のパッケージautoloadをブランクインポートすることで、各パッケージについてinit()が行われ、パッケージ変数がregistryに登録されます。それをregistry.All()で取得してきて、IDの重複チェックを行うコマンドを追加します。(cmd/check/main.go)

package main

import (
    _ "blogtest/internal/selector/autoload"
    "blogtest/internal/selector/common"
    "blogtest/internal/selector/registry"
    "log"
)

func main() {
    // registry から全ての登録情報を取得
    uuidMap := make(map[string]struct{})
    for _, gs := range registry.All() {
        for _, g := range gs {
            for _, s := range g.Selectors {
                uuid := s.ID
                // 重複チェック
                if _, exists := uuidMap[uuid]; exists {
                    log.Fatalf("duplicate UUID found: %s (group: %s)", uuid, g.Name)
                }
            }
        }
    }
}

重複しているUUIDが見つかればlog.Fatalでエラー出力して処理を完了しています。 これで、チェックコマンドを利用して、IDの重複が確認できる様になりました。

下記の様なbashを用意して重複チェックを行っても良いですし

# ファイル生成
go run cmd/gencode/main.go
# 重複チェック実行
go run cmd/check/main.go

あるいは、lefthook等を利用してgitのコミット前に、ファイル生成とチェックコマンドを必ず実行するようにもできます。

まとめ

今回の対応により、以下が実現できました

  • IDの重複を自動検出する仕組み
  • ソースコード変更時の自動チェック体制
  • 手動チェックによるヒューマンエラーの排除

lefthook等のGitフックと組み合わせることで、コミット前の自動チェックが可能になり、より確実な品質管理ができるようになります。