Golangのfor rangeでのポインタ問題をLinterで検知する | Resilire Tech Blog
はじめに
こんにちは、サーバーサイドエンジニアのmynkitです。
サプライチェーンリスク管理クラウドサービスResilireでは、サーバーサイドにGoを採用しています。Goはとても書きやすく個人的にも好きな言語ですが、稀にひっかかる部分があります。
今回は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で発見する仕組みを考えます。
技術選定
GoでLinterといえば、golangci-lintが思いつくと思います。
カスタムルールを作成する方法も紹介されていますが、.so
を書き出す必要があったりして、既存のプロジェクトに組み込むのは拡張性などの面で少しハードルがあります。
保守性と拡張性を考えた時、goastというものが使えそうです
goast概要
以下の開発者の記事によると、
Goのコードを読み込み、コードの抽象表現であるAST(Abstract Syntax Tree、構文抽象木)をRegoで記述されたポリシーによって評価します。ASTに関する説明はこちらの資料などがわかりやすいかと思います。 parser パッケージを使ってGoソースコードのASTを取得し、これをRegoのポリシーで評価します。評価はファイル全体のASTを一度だけ渡す、あるいはASTのノード毎に評価するモードを用意しています。
とのことです。
そのため、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の採用も積極的に行っています。話を聴いてみたい!だけでも良いので、ご興味ある方はぜひご連絡ください!
Discussion