Golangのfor rangeでのポインタ問題をLinterで検知する
Resilire Tech Blog は zenn に移行しました
私たちのテックブログをご覧いただきありがとうございます! 2024年から掲載先を zenn に移行しました。是非フォローして最新記事をご覧ください。
これからも Resilire Tech Blog をよろしくお願いします。
はじめに
こんにちは、バックエンドエンジニアのmynkitです。
Resilireでは、バックエンドにGolangを採用しています。Golangはとても書きやすく個人的にも好きな言語ですが、稀にひっかかる部分があります。
今回はfor range内でのポインタの挙動で気をつけないといけないことと、これをLinterで検知する方法を紹介させていただきます。
for range内でのポインタの挙動
まず以下のコードを実行すると、結果はどうなるでしょうか。
// main.go package main import "fmt" type User struct { id int name string } func main() { users := []User{{1, "John"}, {2, "Melissa"}, {3, "Robert"}} var names []*string for _, v := range users { names = append(names, &v.name) } for _, v := range names { fmt.Println(*v) } }
結果はこうなります。すべて最後のUserの値で置き換えられてしまっています。
Robert Robert Robert
これはfor _, v := range
文の中では、毎回同じアドレスが使われていることに起因します。
対策方法
これを防ぐためには以下のように[i]
でアクセスすることで対応できます。
// main_fix.go package main import "fmt" type User struct { id int name string } func main() { users := []User{{1, "John"}, {2, "Melissa"}, {3, "Robert"}} var names []*string for i := range users { names = append(names, &(users[i]).name) } for i := range names { fmt.Println(*names[i]) } }
出力結果
John Melissa Robert
Linterによる検知
今回はmain.go
のような書き方になってしまっているファイルをLinterで発見する仕組みを考えます。
技術選定
GolangでLinterといえば、golangci-lintが思いつくと思います。
カスタムルールを作成する方法も紹介されていますが、.so
を書き出す必要があったりして、既存のプロジェクトに組み込むのは拡張性などの面で少しハードルがあります。
https://golangci-lint.run/contributing/new-linters/#how-to-add-a-private-linter-to-golangci-lint
保守性と拡張性を考えた時、goastというものが使えそうです
https://github.com/m-mizutani/goast
goast概要
以下の開発者の記事によると、
Goのコードを読み込み、コードの抽象表現であるAST(Abstract Syntax Tree、構文抽象木)をRegoで記述されたポリシーによって評価します。ASTに関する説明はこちらの資料などがわかりやすいかと思います。 parser パッケージを使ってGoソースコードのASTを取得し、これをRegoのポリシーで評価します。評価はファイル全体のASTを一度だけ渡す、あるいはASTのノード毎に評価するモードを用意しています。
とのことです。
https://zenn.dev/mizutani/articles/go-static-analysis-with-rego
そのため、Regoでカスタムルールを書いていくことになります。
Linter実装
先に結論
プロジェクトディレクトリに.goast
ディレクトリを作っておきます。ここの中に.regoファイルを書いておきます。
これでfor rangeのvalueのアドレスを、append関数の中で利用しようとした場合にエラーを吐き出します。
# do_not_append_for_range_value_memory_address.rego package goast fail[res] { input.Kind == "RangeStmt" input.Node.Value != null [appendPath, appendValue] := walk(input.Node.Body.List) appendValue[_].Fun.Name == "append" # search for append func only [path, value] := walk(appendValue) value.Op == 17 # operator: & [pathDetail, valueDetail] := walk(value) valueDetail.Name == input.Node.Value.Name res := { "msg": "do not append for range value memory address", "pos": value.OpPos, "sev": "ERROR", } }
次にgoastコマンドを実行するため、インストールしておきます
go install github.com/m-mizutani/goast/cmd/goast@latest
localからコマンドラインでLinterを実行する場合は以下のコマンドを実行します。
goast eval -p .goast --ignore-auto-generated **/*.go
試しに冒頭のmain.go
に対して、コマンド実行すると、以下の出力が得られます。
$ goast eval -p .goast/do_not_append_for_range_value_memory_address.rego main.go [main.go:14] - do not append for range value memory address: "&v" names = append(names, &v.name) ~~~~~~~~ Detected 1 violations
Github Actionsを利用したい場合は、goast-actionを用意していただいているため簡単に設定できます。
# ci.yml name: goast on: pull_request: jobs: eval: runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@v2 - name: run goast Lint uses: m-mizutani/goast-action@main with: policy: ./.goast format: text source: ./ ignore_auto_generated: true
Regoファイルの作成方法
基本ルール
上記のRegoファイルの作成方法を解説していきます。
まず、goastのRegoファイルにいくつかルールがあるようです。
- package が goast でなければならない
- 入力: input には Path、Kind のようなメタ情報と、実際のASTである Node が渡される
- 出力:違反があった場合、 fail という変数に以下のフィールドをもつ構造体を入れる
- msg: 違反内容のメッセージ(文字列)
- pos: ファイル内の位置を示す整数値
- sev: 深刻度。INFO, WARNING, もしくは ERROR
そのため基本フォーマットは以下のようになります
package goast fail[res] { # ここにルールを記述 res := { "msg": "エラーメッセージ", "pos": エラーの位置.~~Pos, "sev": "ERROR", } }
次にルールを書いていきたいですが、その前にいまのコードの構造がどうなっているのかASTを出力して確認したほうが良いです。
ASTの出力
goast dump main.go >> ast.json
出力されたast.json
から、該当のfor文にあたるものを探します
{ "Path": "main.go", "FileName": "main.go", "DirName": ".", "Node": { "For": 171, "Key": { "NamePos": 175, "Name": "_", "Obj": null }, "Value": { "NamePos": 178, "Name": "v", "Obj": null }, "TokPos": 180, "Tok": 47, "Range": 183, "X": { "NamePos": 189, "Name": "users", "Obj": null }, "Body": { "Lbrace": 195, "List": [ { "Lhs": [ { "NamePos": 199, "Name": "names", "Obj": null } ], "TokPos": 205, "Tok": 42, "Rhs": [ { "Fun": { "NamePos": 207, "Name": "append", "Obj": null }, "Lparen": 213, "Args": [ { "NamePos": 214, "Name": "names", "Obj": null }, { "OpPos": 221, "Op": 17, "X": { "X": { "NamePos": 222, "Name": "v", "Obj": null }, "Sel": { "NamePos": 224, "Name": "name", "Obj": null } } } ], "Ellipsis": 0, "Rparen": 228 } ] } ], "Rbrace": 231 } }, "Kind": "RangeStmt" }
"Op": 17
が、演算子(Op)「&」(17)を表しています。
Regoファイルの作成
最後にRegoファイルの書き方です。
基本的にはASTのjsonを探索していって、すべての条件がマッチしたものだけが最後のres
にたどり着いてエラー文を吐きます。
たとえば以下のルールでは、for rangeのvalueの使用自体を禁止できます。
# do_not_use_for_range_value.rego package goast fail[res] { input.Kind == "RangeStmt" input.Node.Value != null res := { "msg": "do not use for range value", "pos": input.Node.Value.NamePos, "sev": "ERROR", } }
$ goast eval -p .goast/do_not_use_for_range_value.rego main.go [main.go:13] - do not use for range value for _, v := range users { ~~~~~~~~~~~~~~~~~~ [main.go:16] - do not use for range value for _, v := range names { ~~~~~~~~~~~~~~~~~~ Detected 2 violations
複雑な探索をしたいときは、walk関数が便利です。
path
はあるkeyまでのkeyの配列、value
はそのkey以下のjsonです。
例えば、以下のように書くと
fail[res] { input.Kind == "RangeStmt" input.Node.Value != null [path, value] := walk(input) value.Op == 17 }
pathとvalueの値はそれぞれ
path: ["Node","Body","List",0,"Rhs",0,"Args",1] value: {"Op":17,"OpPos":221,"X":{"Sel":{"Name":"name","NamePos":224,"Obj":null},"X":{"Name":"v","NamePos":222,"Obj":null}}}
となります。
※ちなみにdebug時にpathやvalueを確認したい場合は、json.marshal関数で文字列に変換すればresのmsgに含ませたりすることで書き出せます。
これを利用すれば、for rangeの中で&vの使用を禁止するルールがかけます。
# do_not_use_for_range_value_memory_address.rego package goast fail[res] { input.Kind == "RangeStmt" input.Node.Value != null [path, value] := walk(input) value.Op == 17 # operator: & [pathDetail, valueDetail] := walk(value) valueDetail.Name == input.Node.Value.Name res := { "msg": "do not use for range value memory address", "pos": value.OpPos, "sev": "ERROR", } }
append関数の中での&vの使用を禁止したければ、appendがあるリストと同階層から探索すればよさそうなので、以下のようになります。
# do_not_append_for_range_value_memory_address.rego package goast fail[res] { input.Kind == "RangeStmt" input.Node.Value != null [appendPath, appendValue] := walk(input) appendValue[_].Fun.Name == "append" # search for append func only [path, value] := walk(appendValue) value.Op == 17 # operator: & [pathDetail, valueDetail] := walk(value) valueDetail.Name == input.Node.Value.Name res := { "msg": "do not append for range value memory address", "pos": value.OpPos, "sev": "ERROR", } }
おわりに
Resilireでは仲間を募集しています。
サーバーサイドだけでなく、フロントエンドやSREの採用も積極的に行っています。話を聴いてみたい!だけでも良いので、ご興味ある方はぜひご連絡ください!