go/analysisのSuggestedFixでコードを修正する
Goの既存コードを修正するツールを作る時、
- 既存コードをどう書き換えて
- 出力して
- テストするか
を考えなければいけないのが少し面倒だと思っていました。
が、golang.org/x/tools/go/analysis
のSuggestedFixを使えばすごく簡単にできてしまいます。
golang.org/x/tools/go/analysis
は staticcheckやgolangci-lintなどの静的解析ツールでよく使われているパッケージです。
例えば以下のような、関数の引数にcontext.Context
があるかどうかチェックするツールがあったとして、
func run(pass *analysis.Pass) (interface{}, error) { inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) nodeFilter := []ast.Node{ (*ast.FuncDecl)(nil), } inspect.Preorder(nodeFilter, func(n ast.Node) { decl := n.(*ast.FuncDecl) if decl.Type.Params.NumFields() > 0 { // NOTE: 第1引数のみを文字列でチェックしているので厳密ではない if types.ExprString(decl.Type.Params.List[0].Type) == "context.Context" { return } } pass.Reportf(decl.Pos(), "missing ctx in parameter") }) return nil, nil }
これを、もしチェックに引っ掛かったら引数にcontext.Context
を追加できるように変更してみます。
まずはpass.Reportf
をpass.Report
に変更し、直接Diagnosticを渡せる形にします。
pass.Report(analysis.Diagnostic{
Pos: decl.Pos(),
Message: "missing context in parameter",
})
そしてSuggestedFixes
としてコードを変更する場所(Pos
からEnd
)と書き換え後のコード(NewText
)を渡します。
pass.Report(analysis.Diagnostic{ Pos: decl.Pos(), Message: "missing context in parameter", SuggestedFixes: []analysis.SuggestedFix{{ Message: "add ctx to parameter", TextEdits: []analysis.TextEdit{{ Pos: decl.Pos(), End: decl.Type.Params.Closing + 1, NewText: b, }}, }}, })
書き換え後のコードは標準パッケージのformat.Nodeを使って作ります。
func newText(pass *analysis.Pass, decl *ast.FuncDecl) ([]byte, error) { // Godoc、戻り値、関数の中身は使わずにコードを整形する f := &ast.FuncDecl{ Recv: decl.Recv, Name: decl.Name, Type: &ast.FuncType{ Params: &ast.FieldList{ List: append([]*ast.Field{{ Names: []*ast.Ident{{Name: "ctx"}}, Type: &ast.SelectorExpr{ X: &ast.Ident{Name: "context"}, Sel: &ast.Ident{Name: "Context"}, }, }}, decl.Type.Params.List...), }, }, } var buf bytes.Buffer if err := format.Node(&buf, pass.Fset, f); err != nil { return nil, err } return buf.Bytes(), nil }
この書き換えを実際に適用するにはコマンドラインツールとして実行する時に-fix
フラグを付けるようにすればOKです。
なお、-fix
フラグはunitcheckerだと渡せないため、main.go
はsinglecheckerかmulticheckerを使う必要があります。
もしくは、gopls
にAnalyzer
として組み込むことでエディタと連携して使うことも可能です。
多少作り込みが甘くても、リファクタリングする時だけ以下に追加し、go install
して使ってみても良いかもしれません。
https://github.com/golang/tools/blob/gopls/v0.6.4/internal/lsp/source/options.go#L1108-L1150
vim + vim-lspは該当箇所で:LspCodeAction
を実行すると呼び出せます。
テストについてはanalysistest.Runをanalysistest.RunWithSuggestedFixesに変更すればgoldenファイルと比較してくれるようになります。
go-cmpでmap[string]interface{}のJSONを比較する
GoでJSONを扱う際、型を定義せずに map[string]interface{}
を使いたくなることがあります。
var ( a = map[string]interface{}{ "data": map[string]interface{}{ "value": int64(1), }, } b = map[string]interface{}{ "data": map[string]interface{}{ "value": float64(1), }, } )
ちょっとした用途であれば特に問題ないかもしれませんが、テストで使おうとするとたまに数値のフィールドがfloat64とint64で比較できずに困ってしまいます。
(goldenファイルを読み込んだ場合など)
func TestReflect(t *testing.T) { if !reflect.DeepEqual(a, b) { t.Errorf("%v != %v", a, b) } }
こちらはint64が含まれている方をjson.Marshal
し、再度json.Unmarshal
することでfloat64にすることで回避できます。
func TestReflect2(t *testing.T) { tmp, err := json.Marshal(a) if err != nil { t.Fatal(err) } var got map[string]interface{} if err := json.Unmarshal(tmp, &got); err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, b) { t.Errorf("%v != %v", got, b) } }
ただ、なんだか無駄な変換をしているようでモヤモヤします。
モヤモヤするのであればきちんと型を定義するべきだとは思いますが、どうしてもstructを作りたくないことがあるかもしれません。
そんな時はgithub.com/google/go-cmp/cmpのFilterValuesを使用すると数値をfloat64として比較できます。
func TestCmpWithOpt(t *testing.T) { opt := cmp.FilterValues(func(x, y interface{}) bool { return isNumber(x) && isNumber(y) }, cmp.Comparer(func(x, y interface{}) bool { return cmp.Equal(toFloat64(x), toFloat64(y)) })) if !cmp.Equal(a, b, opt) { t.Errorf("%v != %v", a, b) } } func isNumber(v interface{}) bool { k := reflect.ValueOf(v).Kind() return k == reflect.Int64 || k == reflect.Float64 } func toFloat64(v interface{}) float64 { rv := reflect.ValueOf(v) if rv.Kind() == reflect.Int64 { return float64(rv.Int()) } return rv.Float() }
FilterValues
の第1引数には第2引数(opt
)を評価する条件となる関数を指定します。
mapのフィールドは全てinterface{}なのでxとyの型はinterface{}にする必要があります。
第2引数では実際に比較する関数を指定します。
このタイミングで数値をfloat64に変換して比較します。
なお、cmp.Comparer
のみだとcannot use an unfiltered option
でpanicしてしまいます。
全体のコードはこちらです。
編集を加速するVimのquickfix機能
この記事はVim Advent Calendar 2020の3日目の記事です。
昨日は@mira010さんのvim pluginsをインストールしてみましょうでした。
みなさんquickfixを使っていますか?
Vimのquickfix機能はgrepやmakeなどの結果を保持する専用のバッファと、それを扱うための各種コマンドからなります。
IDEには当たり前のようにあるような機能ですが、Vimの場合は他の機能と組み合わせることで編集操作を格段に効率化できます。
- 外部コマンドを指定した引数で実行し、
- ファイル名や行番号、メッセージなどの出力を解析し、
- ジャンプのために使えるリストを作ってくれる
のは共通です。
このリストはquickfixリストと呼ばれるもので、:copenで専用のウィンドウが開きます。
そして<Enter>
キーやダブルクリックで該当行にジャンプします。
ただデフォルトのgrepコマンドはまだしも、makeコマンドは滅多に使わないという人もいるかもしれません。
その際はgrepprg
やmakeprg
オプションで実行する外部コマンドを任意のコマンドに変更できます。
(出力される結果が解析できない形式の場合はerrorformat
なども変更する必要があります。)
grepコマンドはgit grep
、
" スペースはエスケープが必要 set grepprg=git\ grep\ -n\ --no-color
makeコマンドはlinterやタスクランナーなどにするとグッと使いやすくなります。
" 使用例 " :make ./... " :make --disable-all -E staticcheck set makeprg=golangci-lint\ run
これらの設定はvimrcに書いておいたり、簡単に切り替えられるコマンドやマッピングを用意しておいても良いでしょう。
しかし、quickfixリストは同時に複数の結果を表示することができません。
そのため、複数のquickfixリストを扱うには:colderや:cnewerや:chistoryを使って履歴を行き来する必要があります。
もしくはlocationリストを使います。
locationリストとはウィンドウローカルなquickfixリストのことで、コマンドのプレフィックスが
だけで、quickfixリストと同じように使えます。
そのため、別のウィンドウやタブページで個別にlocationリストを開くことで複数の結果を表示できます。
さて、ここまで紹介した機能だと便利なジャンプリストでしかありません。
quickfixリストを使ってさらに効率的な編集をするには、:cnextとマクロを組み合わせて使います。
例えばgrepで絞り込んだ行の特定の文字列を置換したければ、以下の操作を(qq
などで)マクロに記録し、
:s/Before/After/ :w :cnext
大きな数を指定して(100@q
や1000@q
で)実行すると、quickfixリストの最後まで自動的に繰り返してくれます。
リストが指定した数より少なければそこでマクロが止まってくれるので重複実行は気にしなくて大丈夫です。
この時、さらにquickfixリストを絞り込みたくなることがあるかもしれません。
:grep
の場合は正規表現を工夫しても良いですが、:packadd cfilter
で使えるようになる:Cfilterでquickfixリストを絞り込んでしまうのがとても楽です。
それでも絞り込むのが難しい場合、いったん別のバッファにコピーして編集し、:cbufferや:cgetbufferでquickfixリストを読み込み直しても構いません。
(quickfixリストを:setlocal modifiable
して書き換えるのはちょっと乱暴なので非推奨です。)
また、quickfixリスト自体をファイルとして保存しておき、:cfileや:cgetfileで読み込み直すことも可能です。
そうするとジャンプ可能なTODOリストとしても使えるので、リストが巨大だったら少しずつ進めたり、他のVimmerと作業を分担する、なんてこともできるかもしれません。
ということで、ぜひquickfix機能を活用してみてください。
本当はより実践的な例として、最近やったことを具体的なコマンド付きで紹介できれば良かったんですが、ある事情でそのヒストリーをロストして再現環境もなく...
覚えている範囲でフワッと紹介して終わります。
目的はterraformerで生成したmonitoring_alert_policy.tf
ファイルのdocumentation
に、フィルター付きでCloud LoggingのWeb画面に飛べるリンクを追加することでした。
そのフィルターの内容はlogging_metric.tf
ファイルに定義されているものを使います。
- monitoring_alert_policy.tf(のサンプル)
resource "google_monitoring_alert_policy" "alert_policy_error" { display_name = "My Alert Policy(Error)" combiner = "OR" conditions { display_name = "test condition" condition_threshold { filter = "resource.type=gae_app AND metric.type=logging.googleapis.com/user/my-error-metric" duration = "60s" comparison = "COMPARISON_GT" aggregations { alignment_period = "60s" per_series_aligner = "ALIGN_RATE" } threshold_value = 0.1 trigger { count = 1 } } } documentation = { mime_type = "text/markdown" # TODO: ここに↓の形式でリンクを入れたい # - [NAME](https://console.cloud.google.com/logs/query?project=PROJECT_ID&query=FILTER) content = "" } }
- logging_metric.tf(のサンプル)
resource "google_logging_metric" "logging_metric_error" { name = "my-error-metric" filter = "resource.type=gae_app AND severity>=ERROR" metric_descriptor { metric_kind = "DELTA" value_type = "INT64" } }
手順:
:grep
する:copen
して、不要な行があれば除外する- マクロを記録開始して、
getline()
とsubstitute()
で検索するmetricの名前を抽出するlogging_metric.tf
のバッファに移動するsearch()
とgetline()
とsubstitute()
を使ってfilterを取得する- 元バッファに戻る
- documentationのcontentがある行に移動する
- リンクを生成して追加する
- conditionsが複数ある場合はそれぞれのリンクを追加する
- フィルターはbase64化してクエリパラメータにする必要がある
- webapi-vimを使ったはず
:w
で保存する:cnext
する
- マクロの記録を終了する
- 1000回ほど繰り返す
実は初めの数回は関数などを使用せず、ノーマルモードコマンドで普通に編集をしていました。
ただ同じような操作を繰り返していることは薄々感じていたのと、残りの件数を見て即マクロに切り替えたという経緯があります。
そのまま続けていたら数時間はかかっていたと思いますが、ほぼ一瞬で終わらせることができました。
明日は@kaneshinさんです。
GoでDBのテストにgithub.com/cockroachdb/copyistを使う
久しぶりにMySQLを使ったシステムを触ることになったものの、現状のテストが遅すぎたので高速化に取り組むことにした。
遅い原因としては、
- テストでDBを使うパッケージが多いので接続時間がそれなりになってしまう
- テストケースごとにTRUNCATE→INSERTでデータを入れ直している
といったところ。
なので今回はDBにアクセスしないでテストができるようになるcopyistを試してみようと思う。
仕組みとしては実行されたSQLを記録しておき、以降はその時のデータを使うという
などと似たようなものとなる。
README.mdにはMySQLは非対応と書いてあったが、普通に使うことができた。
(もしかしたら一部正しく記録/再生できないクエリがあるのかもしれない)
MySQL版のサンプルコードはこちら。
mysql_test.go
package example import ( "database/sql" "log" "testing" "github.com/cockroachdb/copyist" _ "github.com/go-sql-driver/mysql" ) // GetName をテストする。 func GetName(db *sql.DB, id int64) (string, error) { var name string err := db.QueryRow("SELECT name FROM `user` WHERE id=?", id).Scan(&name) return name, err } // resetDBで使うためにグローバル変数にしておく。 var db *sql.DB // 記録時にcopyist.Open()の中で実行される。 func resetDB() { db.Exec("DROP TABLE `user`") db.Exec("CREATE TABLE `user` (`id` INT(11) unsigned NOT NULL AUTO_INCREMENT, `name` VARCHAR(20) NOT NULL, PRIMARY KEY (`id`))") db.Exec("INSERT INTO `user` (id, name) VALUES (?, ?)", 1, "Andy") } func TestMain(m *testing.M) { // ドライバ名は"copyist_mysql"になる。 copyist.Register("mysql", resetDB) // resetDBとテストで使うdbを作る。 var err error db, err = sql.Open("copyist_mysql", "admin:pass@/copyist") if err != nil { log.Fatal(err) } defer db.Close() m.Run() } func TestGetName(t *testing.T) { defer copyist.Open(t).Close() // found name, err := GetName(db, 1) if err != nil { t.Fatal(err) } if name != "Andy" { t.Error("failed test") } // not found if _, err := GetName(db, 100); err != sql.ErrNoRows { t.Error(err) } } // subtest版 func TestGetName_subtest(t *testing.T) { t.Run("found", func(t *testing.T) { defer copyist.Open(t).Close() name, err := GetName(db, 1) if err != nil { t.Fatal(err) } if name != "Andy" { t.Error("failed test") } }) t.Run("not found", func(t *testing.T) { defer copyist.Open(t).Close() if _, err := GetName(db, 100); err != sql.ErrNoRows { t.Error(err) } }) }
1回目は以下のように -record
をつけてテストを実行する。
$ go test -v -record === RUN TestGetName --- PASS: TestGetName (0.09s) === RUN TestGetName_subtest === RUN TestGetName_subtest/found === RUN TestGetName_subtest/not_found --- PASS: TestGetName_subtest (0.12s) --- PASS: TestGetName_subtest/found (0.06s) --- PASS: TestGetName_subtest/not_found (0.06s) PASS
そうするとtestdataディレクトに以下のようなmysql_test.copyistが生成される。
1=DriverOpen 1:nil 2=ConnPrepare 2:"SELECT name FROM `user` WHERE id=?" 1:nil 3=StmtNumInput 3:1 4=StmtQuery 1:nil 5=RowsColumns 9:["name"] 6=RowsNext 11:[10:QW5keQ] 1:nil 7=RowsNext 11:[] 7:EOF "TestGetName"=1,2,3,4,5,6,2,3,4,5,7 "TestGetName_subtest/found"=1,2,3,4,5,6 "TestGetName_subtest/not_found"=1,2,3,4,5,7
この状態で -record
をつけずに実行すると↑のデータが使われる。
$ go test -v === RUN TestGetName --- PASS: TestGetName (0.00s) === RUN TestGetName_subtest === RUN TestGetName_subtest/found === RUN TestGetName_subtest/not_found --- PASS: TestGetName_subtest (0.00s) --- PASS: TestGetName_subtest/found (0.00s) --- PASS: TestGetName_subtest/not_found (0.00s) PASS
なお、記録する前に -record
無しで実行するとpanicになる。
$ go test -v === RUN TestGetName --- FAIL: TestGetName (0.00s) panic: no recording exists with this name: TestGetName [recovered] panic: no recording exists with this name: TestGetName
また、以下のようにクエリを変えて、
--- a/mysql_test.go +++ b/mysql_test.go @@ -12,7 +12,7 @@ import ( // GetName をテストする。 func GetName(db *sql.DB, id int64) (string, error) { var name string - err := db.QueryRow("SELECT name FROM `user` WHERE id=?", id).Scan(&name) + err := db.QueryRow("SELECT name FROM `user` WHERE id=? AND TRUE", id).Scan(&name) return name, err }
記録し直さずにそのまま実行してもpanicになる。
$ go test -v === RUN TestGetName --- FAIL: TestGetName (0.00s) panic: mismatched argument to ConnPrepare, expected SELECT name FROM `user` WHERE id=? AND TRUE, got SELECT name FROM `user` WHERE id=? - regenerate recording [recovered] panic: mismatched argument to ConnPrepare, expected SELECT name FROM `user` WHERE id=? AND TRUE, got SELECT name FROM `user` WHERE id=? - regenerate recording
ただし、プレースホルダーの値が変わっても返ってくるレコードは変わらないのでその点は注意が必要。
例えば以下のようにidを逆にしてもテストはPassしてしまう。
--- a/mysql_test.go +++ b/mysql_test.go @@ -45,7 +45,7 @@ func TestGetName(t *testing.T) { defer copyist.Open(t).Close() // found - name, err := GetName(db, 1) + name, err := GetName(db, 100) if err != nil { t.Fatal(err) } @@ -54,7 +54,7 @@ func TestGetName(t *testing.T) { } // not found - if _, err := GetName(db, 100); err != sql.ErrNoRows { + if _, err := GetName(db, 1); err != sql.ErrNoRows { t.Error(err) } } @@ -63,7 +63,7 @@ func TestGetName_subtest(t *testing.T) { t.Run("found", func(t *testing.T) { defer copyist.Open(t).Close() - name, err := GetName(db, 1) + name, err := GetName(db, 100) if err != nil { t.Fatal(err) } @@ -75,7 +75,7 @@ func TestGetName_subtest(t *testing.T) { t.Run("not found", func(t *testing.T) { defer copyist.Open(t).Close() - if _, err := GetName(db, 100); err != sql.ErrNoRows { + if _, err := GetName(db, 1); err != sql.ErrNoRows { t.Error(err) } })
細かい部分はまだ何とも言えないが、とりあえず使えそうだし高速化にも期待ができそう。
さて、あとはこれをどうやって既存のテストに組み込んでいくか。
既存のテストではxormが使われているが、copyistに対応していないので少し特殊な初期化をしないといけないようだ。
engine, err := xorm.NewEngine("mysql", "") // エラーハンドリングやengineの設定など // ... // DBをsql.Open("copyist_mysql", ...)したものと差し替える engine.DB().DB = db
これを何とかするのが一番大変なのかもしれない...
ISUCON10に初参戦してみた
ゴリラさんと出てみようと話していたものの、申し込みのタイミングを逃してオワタと思っていたら@inductorさんに拾ってもらい、カンガルーと犬とゴリラ
としてなんとかISUCON10に参加することができました。
当日は開始ちょっと前*1にDiscordとGoogleドキュメントでコミュニケーションすることを決め、 開始直後は
- 環境を整備する
- アプリを見る
- ドキュメントを見る
といった感じで役割分担。
30分くらい経って、これからはベンチマークを走らせながらどこから手を付けるか考えようと思っていましたがトラブルのためなかなか実行できず、New Relicの設定をしたあとはしばらくWebアプリをポチポチしたりホストの中をウロウロしたりしていました。
ベンチマークが走ってからは、
- 検索系
- インデックス
- DB分割
- 最終的にはApp1台 + DB2台に
- nazotte
- LIMITでループを抜ける
- それ以上のチューニングは結局できず...
- low_priced
- 静的に返したり、キャッシュしたり
- botからのアクセス
- nginxで弾く
をなんとかすることに。
これでスコアが480→805になったのがだいたい19:00で、そこから都合により20:20くらいまで離席...
戻ってきたタイミングで検索系がさらに最適化されてスコアは1280まで伸びましたが、最終的には1244で競技終了の21:00になりました。
以前に参加した社内ISUCONでは個人でもチームでもスコアアップに繋がるようなことがほとんどできなかったので*2、それに比べれば多少はできるようになったかもしれませんが、まだまだ力不足*3なことを痛感しました。。。
goplsのSymbolMatcherとSymbolStyleオプション
以下の記事で、workspace/symbol
のオプションは補完と同じmatcher
と紹介したが、現在のv0.4.4ではsymbolMatcher
とsymbolStyle
になっている。
daisuzu.hatenablog.com
まずはv0.4.1でmatcher
がsymbolMatcher
に変わり、それまでと同様、
- fuzzy
- caseSensitive
- caseInsensitive(デフォルト)
の3つが補完と独立して設定できるようになった。
その後、v0.4.4でsymbolStyle
が追加され、
- package(デフォルト)
- full
- dynamic
を指定できるようになった。
packageの場合は今までと同様、パッケージ名をクエリに含める際にはコードで使う時のようにcontext.Context
といった形式で検索する。
新たに追加されたfullではgolang.org/x/net/context.Context
のようにすることで同じパッケージ名の別モジュールを区別することが可能になった。
dynamicはpackage→fullの順番でマッチさせるため、パッケージ名を含んだクエリはpackageと同じ結果になり、インポートパスから指定したクエリはfullと同じ結果になる。
Goでprotobufの定義をimportせずにフィールドの値を使う
以下のようなコードでreq
にあるフィールドを使いたい場合、
opt := grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { // ここでreqから特定のフィールド値を取得したい return nil })
型が定義されているパッケージをインポートして、
if v, ok := req.(*pb.Req); ok { // v.Bodyでフィールドにアクセスできる }
のように変換することになる。
この時、インポートしたくない or インポートできないのであれば、protoc
が生成する定義にはGetterも同時に生成されるため、対象フィールドのGetterがあるinterfaceで型アサーションすると手軽にできる。
if v, ok := req.(interface{ GetBody() []byte }); ok { // v.GetBody()でフィールドの値を取得できる }
あまり使い所はないかもしれないが、google.golang.org/appengineは定義がinternal配下にあるため、例えばテストでWithAPICallFuncを使う際など、以下のようにしてtaskqueue.Addのリクエスト(TaskQueueAddRequest)からフィールドの値を取得できる。
var got []byte ctx = appengine.WithAPICallFunc(ctx, func(ctx context.Context, service, method string, in, out proto.Message) error { if service == "taskqueue" && method == "Add" { if v, ok := in.(interface{ GetBody() []byte }); ok { got = v.GetBody() } } return nil }) DoSomething(ctx) // 関数内でtaskqueue.Add()が呼ばれる if !reflect.DeepEqual(got, want) { t.Errorf("body = %q, want %q", got, want) }
なお、フィールドがインポートできない型になっているとinterfaceも作れないのでこの方法は使えない。
それでも必要な場合はencoding/json
パッケージでMarshal
してからmap[string]interface{}
や独自型にUnmarshal
すれば良い。