golang.org/x/exp/jsonrpc2を使ってgoplsに接続する

最近golang.org/x/expjsonrpc2が追加されていることを知ったので試してみた。 pkg.go.dev

基本的な使い方としてはDialConnectionを作成し、

  • レスポンスが返ってくるメソッドはCall + Await
  • レスポンスが返ってこないメソッドはNotify

を呼べば良い。

Content-Lengthなんかの処理はConnectionOptionsがデフォルトで設定するHeaderFramerがやってくれる。

以前に書いたgoplsに接続するコードに比べるとだいぶスッキリした。 daisuzu.hatenablog.com

import (
    "context"
    "net"

    "golang.org/x/exp/jsonrpc2"
)

type Client struct {
    conn *jsonrpc2.Connection
}

func Connect(ctx context.Context, addr string, initializeParams map[string]any) (*Client, error) {
    conn, err := jsonrpc2.Dial(ctx,
        jsonrpc2.NetDialer("tcp", addr, net.Dialer{}),
        jsonrpc2.ConnectionOptions{}, // goplsに繋ぐ時は全てデフォルト値でOK
    )
    if err != nil {
        return nil, err
    }

    var initializeResult map[string]any // このサンプルではレスポンスを受け取るだけで使わない
    if err := conn.Call(ctx, "initialize", initializeParams).Await(ctx, &initializeResult); err != nil {
        return nil, err
    }

    if err := conn.Notify(ctx, "initialized", map[string]any{}); err != nil {
        return nil, err
    }

    return &Client{conn: conn}, nil
}

func (c *Client) Shutdown(ctx context.Context) error {
    return c.conn.Notify(ctx, "shutdown", map[string]any{})
}

go-cmpでmap内の時間文字列を近似比較する

以下のような関数をテストする際、期待する値もtime.Now()で生成して概ね問題ない。

func f() map[string]interface{} {
    return map[string]interface{}{
        "time": time.Now().Format("2006-01-02 15:04:05"),
    }
}

ただし関数内に多くの処理がある場合など、ごく稀に時間がズレてFAILしてしまうことがある。
こういった関数を作らないことで回避することもできるが、cmp.FilterPathを使うと次のような比較処理を実装できる。

  1. mapの特定のキーに対して、
  2. 値をinterface{}からtime.Timeに変換し、
  3. 2つの値の差が1秒以下ならOKとする
cmp.FilterPath(func(p cmp.Path) bool {
    if mi, ok := p.Index(-1).(cmp.MapIndex); ok {
        // 1. mapのキーがtimeの場合はComparerを使う
        return mi.Key().String() == "time"
    }
    return false
}, cmp.Comparer(func(x, y interface{}) bool {
    // 2. time.Timeに変換する
    xt, ok := parseTime(x)
    if !ok {
        return false
    }
    yt, ok := parseTime(y)
    if !ok {
        return false
    }

    if xt.Before(yt) {
        // xt.Sub(yt)が負の値にならないように入れ替える
        xt, yt = yt, xt
    }

    // 3. Durationが1秒以下ならOKとする
    return xt.Sub(yt) <= time.Second
}))

使用例はこちらgo.dev

Vimのterminalでパイプを使う

この記事はVim Advent Calendar 2021の9日目の記事です。

Vimにterminal機能が追加されてずいぶん経ちましたが、普段はtmux上でVimを使っていたので実際のところ使用頻度はそんなに高くありませんでした。
たまに使った時は出力がVimの中に閉じているため、検索したり編集したりはtmuxのペイン分割と比べてやりやすいなと思うことがあったくらいです。

ただ、最近になって似たようなコマンドを何度も実行することが増えてきて、その度にシェルの履歴から探してきてはコマンドライン引数を変更して実行するのが煩わしくなってきました。
例えば以下のようなコマンドです。

go test \
    # 1. 詳細な出力が欲しい(-v)
    # 2. カバレッジが取りたい(-covermode, -coverprofile, -coverpkg)
    # 3. 実行するテストを指定したい(-run)
    # 4. goldenファイルを更新したい(-golden)
    # 5. テストの分析がしたい時は出力を自作コマンドに流し込む

1〜4までは:executeを使って組み立てたcmdを実行するようにすれば良いので簡単です。

:execute 'terminal ' . cmd

ところが5で出力を流すのに、パイプ(|)をどのように使えば良いのかわかりませんでした。
いくつか試してみたところ、bash(やzsh)の-cオプションに""で実行したいコマンドを渡してあげれば良いことがわかりました。

" grepが効かない(`|`以降もvimコマンドの引数として扱われる)
:terminal vim -h | grep vimrc

" エラー
:terminal bash -c 'vim -h | grep vimrc'

" OK
:terminal bash -c "vim -h | grep vimrc"

これを自作コマンドにしておくと簡単に実行できます。

当初は柔軟に実行できる形にしようと思っていましたが、組み合わせはある程度固定化されていたので3、4パターンほどコマンドとして定義しておき、必要に応じて修正するような使い方をしています。
シェルスクリプトMakefileにしておくのも手かと思いますが、管理の手間などを考えると自分にとってはvimrcに書いておくのが一番楽でした。

curlとjqを組み合わせても良さそうなので、必要になったら同じようにしてやってみようと思っています。

Goのモジュールを個別に更新する

特に問題なければ go get -u ./... で全て更新してしまうのが楽ですが、更新できないモジュールがある場合は個別に更新する必要があります。

go list -m -u all で全モジュールとその更新有無を確認できるため、 -f でgo getコマンドを出力するようにし、必要なものだけ実行すると楽です。

例えばgoplsはそのまま実行すると以下のようになりますが、

$ go list -m -u all
golang.org/x/tools/gopls
github.com/BurntSushi/toml v0.4.1
github.com/davecgh/go-spew v1.1.1
github.com/google/go-cmp v0.5.6
github.com/google/safehtml v0.0.2
github.com/jba/templatecheck v0.6.0
github.com/kr/pretty v0.1.0 [v0.3.0]
github.com/kr/pty v1.1.1 [v1.1.8]
github.com/kr/text v0.1.0 [v0.2.0]
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e
github.com/pmezard/go-difflib v1.0.0
github.com/rogpeppe/go-internal v1.8.0
github.com/sanity-io/litter v1.5.1
github.com/sergi/go-diff v1.1.0 [v1.2.0]
github.com/stretchr/objx v0.1.0 [v0.3.0]
github.com/stretchr/testify v1.4.0 [v1.7.0]
github.com/yuin/goldmark v1.4.1 [v1.4.4]
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 [v0.0.0-20211108221036-ceb1ce70b4fa]
golang.org/x/mod v0.5.1
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f [v0.0.0-20211112202133-69e39bad7dc2]
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 [v0.0.0-20211113001501-0c823b97ae02]
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 [v0.0.0-20210927222741-03fcf44c2211]
golang.org/x/text v0.3.7
golang.org/x/tools v0.1.7 => ../
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 [v1.0.0-20201130134442-10cb98267c6c]
gopkg.in/errgo.v2 v2.1.0
gopkg.in/yaml.v2 v2.2.4 [v2.4.0]
honnef.co/go/tools v0.2.1 [v0.2.2]
mvdan.cc/gofumpt v0.1.1 [v0.2.0]
mvdan.cc/xurls/v2 v2.3.0

更新があり、直接使っている(not .Indirect)モジュールのgo getコマンドを生成すると以下のようになります。

$ go list -m -u -f '{{if (and .Update (not .Indirect))}}go get {{.Path}}@{{.Update.Version}}{{end}}' all
go get github.com/sergi/go-diff@v1.2.0
go get golang.org/x/sys@v0.0.0-20211113001501-0c823b97ae02
go get honnef.co/go/tools@v0.2.2
go get mvdan.cc/gofumpt@v0.2.0

aws-sdk-go-v2をモックせずにテストする

テストでaws-sdk-go-v2を使う場合はドキュメントにある通り、Clientのモックを用意するのが一般的な手法かと思います。
ただテストのためだけにinterfaceを書きたくないので、aws-sdk-go-v2が提供するClientをそのまま使える形にしたいです。

幸いaws-sdk-go-v2はClientをカスタマイズするためのオプションがあるため、大別して以下の2つの方法で実現可能です。

1つ目はAPIリクエストの送信先を変更する方法です。
こちらはWithEndpointResolverWithHTTPClientを用いることで、リクエストをhttptestで立ち上げたサーバーなど、任意の宛先に送信できます。

2つ目はClientの処理に任意の処理を割り込ませる方法です。
各Clientは下図のStackが実装されており、WithAPIOptionsで任意の処理を追加できるようになっています。 middleware
(詳細はイメージのリンク先へ)

通常はStackを順番に処理していくようになっていますが、途中で次を呼ばずに打ち切ってしまうこともできます。

例えばs3のGetObjectは以下のように呼ぶことでAWSにアクセスせずに"ok"を返せます。

input := &s3.GetObjectInput{
  Bucket: aws.String("bucket"),
  Key:    aws.String("key"),
}
output, err := client.GetObject(ctx, input, s3.WithAPIOptions(func(stack *middleware.Stack) error {
  return stack.Finalize.Add(
    middleware.FinalizeMiddlewareFunc("test",
      func(context.Context, middleware.FinalizeInput, middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) {
        return middleware.FinalizeOutput{
          Result: &s3.GetObjectOutput{
            Body: io.NopCloser(strings.NewReader("ok")),
          },
        }, middleware.Metadata{}, nil
      },
    ),
    middleware.Before,
  )
}))

※ s3はFinalizeにリトライ処理があるため、それが呼ばれる前に処理を打ち切ることで数秒のロスを回避できる

しかし、実際のプロダクションコードだと個別のメソッドにオプションを渡すのは難しい形になっているかもしれません。
その際はClientをDIできるようにしておき、config.WithAPIOptionsを使ってClient側にオプションを設定します。

また、WithAPIOptionsはコードがそこそこ大きいのでfunc(*middleware.Stack) errorを返す関数を作成し、応答を渡せるようにしておくと使いやすいです。

以下がテストコードのサンプルです。

package main

import (
    "bytes"
    "context"
    "errors"
    "io"
    "strings"
    "testing"

    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    "github.com/aws/smithy-go/middleware"
)

type resp struct {
    body string
    err  error
}

func middlewareForGetObject(r resp) func(*middleware.Stack) error {
    return func(stack *middleware.Stack) error {
        return stack.Finalize.Add(
            middleware.FinalizeMiddlewareFunc(
                "test",
                func(context.Context, middleware.FinalizeInput, middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) {
                    return middleware.FinalizeOutput{
                        Result: &s3.GetObjectOutput{
                            Body: io.NopCloser(strings.NewReader(r.body)),
                        },
                    }, middleware.Metadata{}, r.err
                },
            ),
            middleware.Before,
        )
    }
}

func Test_GetObject(t *testing.T) {
    type args struct {
        bucket string
        key    string
    }
    tests := []struct {
        name    string
        args    args
        resp    resp
        want    []byte
        wantErr bool
    }{
        {
            name: "success",
            args: args{bucket: "Bucket", key: "Key"},
            resp: resp{body: "ok"},
            want: []byte("ok"),
        },
        {
            name:    "failure",
            args:    args{bucket: "Bucket", key: "Key"},
            resp:    resp{err: errors.New("object not found")},
            wantErr: true,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            ctx := context.TODO()

            cfg, err := config.LoadDefaultConfig(ctx,
                config.WithRegion("ap-northeast-1"),
                config.WithAPIOptions([]func(*middleware.Stack) error{middlewareForGetObject(tt.resp)}),
            )
            if err != nil {
                t.Fatal(err)
            }
            client := s3.NewFromConfig(cfg)

            out, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: &tt.args.bucket, Key: &tt.args.key})
            if (err != nil) != tt.wantErr {
                t.Errorf("GetObject() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if tt.wantErr {
                return
            }

            got, _ := io.ReadAll(out.Body)
            if !bytes.Equal(tt.want, got) {
                t.Errorf("GetObject() = %q, want %q", got, tt.want)
            }
        })
    }
}

xorm.EngineGroupで複数のDBをまとめる

データベースをWriterとReaderで分けている時、xorm.ioのEngineGroupを使うことでどちらのDBを使うのかを任せられるようになります。

使い方は通常のNewEngineと似ていて、NewEngineGroupの第2引数に接続先をスライスで渡します。
接続先のスライスは1番目がWriter、2番目以降がReaderになります。

xorm.NewEngineGroup(driverName, []string{dsnWriter, dsnReader})

もしくは既存のEngineを直接渡すこともできます。
この時は第1引数がWriterで第2引数がReaderのスライスです。

xorm.NewEngineGroup(engineWriter, []*xorm.Engine{engineReader})

これで自動的に参照クエリはReader、更新クエリはWriterが使われるようになります。
また、トランザクションの中では参照クエリでもWriterが使われます。

  • Readerが使われる
func get(eg *xorm.EngineGroup, bean interface{}) {
    s := eg.NewSession()
    s.Get(bean)
}
  • Writerが使われる
func insert(eg *xorm.EngineGroup, bean interface{}) {
    s := eg.NewSession()
    s.Insert(bean)
}
func getTx(eg *xorm.EngineGroup, bean interface{}) {
    s := eg.NewSession()
    s.Begin()
    s.Get(bean)
    s.Commit()
}

この動作は以下のコードを使用して確認しました。
(実際のDBに接続していないのでエラーは無視しています)

play.golang.org

ただGoだとxorm以外に同じようなことができるORMを知らないんですが、やっぱりアプリケーションの中でやるよりも外でやった方が良いから他では実装されないんですかね。

goplsと静的解析を活用して変更の影響範囲を調べたい

1000パッケージ弱あるような巨大なリポジトリだと、関数1つの修正でどこまで影響があるのかを調べるのが結構大変*1だったりする。

Vimプラグインを作ったり、goplsを魔改造してみたりしてみたものの、使う人や環境を選ぶし、実行速度もイマイチだったのでもっと使い勝手の良いものが欲しかった。

そこでPull Requestに対して自動的にチェックしてくれると便利そうだったので以下のようなツールを考えてみた。

  1. git diffの結果から、
  2. 変更のあったシンボルの位置を特定し、
  3. (決められたところまで)呼び出し元を辿る

うまく実装できれば公開するかもしれない。

3は前回のblogに書いた方法でLSPのcallHierarchy/incomingCallsを繰り返していけば良いので処理的には比較的簡単。

daisuzu.hatenablog.com

2のシンボルの位置はもっと簡単な方法がありそうな気もしたけど、ast.FileDeclsを使って変更行が範囲内かどうかを調べれば良さそう。

package a

func sum(a, b int) int {
    total := a + b
    return total
}

このsum関数の場合は以下のようになっているので、

Decls: []ast.Decl (len = 1) {
.  0: *ast.FuncDecl {
.  .  Name: *ast.Ident {
.  .  .  NamePos: a.go:3:6
.  .  .  Name: "sum"
.  .  .  Obj: *ast.Object {
.  .  .  .  Kind: func
.  .  .  .  Name: "sum"
.  .  .  .  Decl: *(obj @ 7)
.  .  .  }
.  .  }
.  .  Type: *ast.FuncType {
.  .  .  Func: a.go:3:1
.  .  .  Params: *ast.FieldList {
.  .  .  .  Opening: a.go:3:9
.  .  .  .  List: []*ast.Field (len = 1) {
.  .  .  .  .  0: *ast.Field {
.  .  .  .  .  .  Names: []*ast.Ident (len = 2) {
.  .  .  .  .  .  .  0: *ast.Ident {
.  .  .  .  .  .  .  .  NamePos: a.go:3:10
.  .  .  .  .  .  .  .  Name: "a"
.  .  .  .  .  .  .  .  Obj: *ast.Object {
.  .  .  .  .  .  .  .  .  Kind: var
.  .  .  .  .  .  .  .  .  Name: "a"
.  .  .  .  .  .  .  .  .  Decl: *(obj @ 22)
.  .  .  .  .  .  .  .  }
.  .  .  .  .  .  .  }
.  .  .  .  .  .  .  1: *ast.Ident {
.  .  .  .  .  .  .  .  NamePos: a.go:3:13
.  .  .  .  .  .  .  .  Name: "b"
.  .  .  .  .  .  .  .  Obj: *ast.Object {
.  .  .  .  .  .  .  .  .  Kind: var
.  .  .  .  .  .  .  .  .  Name: "b"
.  .  .  .  .  .  .  .  .  Decl: *(obj @ 22)
.  .  .  .  .  .  .  .  }
.  .  .  .  .  .  .  }
.  .  .  .  .  .  }
.  .  .  .  .  .  Type: *ast.Ident {
.  .  .  .  .  .  .  NamePos: a.go:3:15
.  .  .  .  .  .  .  Name: "int"
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  }
.  .  .  .  Closing: a.go:3:18
.  .  .  }
.  .  .  Results: *ast.FieldList {
.  .  .  .  Opening: -
.  .  .  .  List: []*ast.Field (len = 1) {
.  .  .  .  .  0: *ast.Field {
.  .  .  .  .  .  Type: *ast.Ident {
.  .  .  .  .  .  .  NamePos: a.go:3:20
.  .  .  .  .  .  .  Name: "int"
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  }
.  .  .  .  Closing: -
.  .  .  }
.  .  }
.  .  Body: *ast.BlockStmt {
.  .  .  Lbrace: a.go:3:24
.  .  .  List: []ast.Stmt (len = 2) {
.  .  .  .  0: *ast.AssignStmt {
.  .  .  .  .  Lhs: []ast.Expr (len = 1) {
.  .  .  .  .  .  0: *ast.Ident {
.  .  .  .  .  .  .  NamePos: a.go:4:2
.  .  .  .  .  .  .  Name: "total"
.  .  .  .  .  .  .  Obj: *ast.Object {
.  .  .  .  .  .  .  .  Kind: var
.  .  .  .  .  .  .  .  Name: "total"
.  .  .  .  .  .  .  .  Decl: *(obj @ 67)
.  .  .  .  .  .  .  }
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  .  TokPos: a.go:4:8
.  .  .  .  .  Tok: :=
.  .  .  .  .  Rhs: []ast.Expr (len = 1) {
.  .  .  .  .  .  0: *ast.BinaryExpr {
.  .  .  .  .  .  .  X: *ast.Ident {
.  .  .  .  .  .  .  .  NamePos: a.go:4:11
.  .  .  .  .  .  .  .  Name: "a"
.  .  .  .  .  .  .  .  Obj: *(obj @ 27)
.  .  .  .  .  .  .  }
.  .  .  .  .  .  .  OpPos: a.go:4:13
.  .  .  .  .  .  .  Op: +
.  .  .  .  .  .  .  Y: *ast.Ident {
.  .  .  .  .  .  .  .  NamePos: a.go:4:15
.  .  .  .  .  .  .  .  Name: "b"
.  .  .  .  .  .  .  .  Obj: *(obj @ 36)
.  .  .  .  .  .  .  }
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  }
.  .  .  .  1: *ast.ReturnStmt {
.  .  .  .  .  Return: a.go:5:2
.  .  .  .  .  Results: []ast.Expr (len = 1) {
.  .  .  .  .  .  0: *ast.Ident {
.  .  .  .  .  .  .  NamePos: a.go:5:9
.  .  .  .  .  .  .  Name: "total"
.  .  .  .  .  .  .  Obj: *(obj @ 72)
.  .  .  .  .  .  }
.  .  .  .  .  }
.  .  .  .  }
.  .  .  }
.  .  .  Rbrace: a.go:6:1
.  .  }
.  }
}

token.FileSetPosition()Pos()End()を渡せば行番号を取得できる。

1のgit diffは良い方法が思いつかなかったので標準出力をパースしてみる。
余計な情報は減らしておきたいので--diff-filter=Mで変更のあったファイルのみを対象にし、-U0で変わった行だけを出力する。

diff --git a/a.go b/a.go
index 2d1b2ea..4cceff6 100644
--- a/a.go
+++ b/a.go
@@ -4,2 +4 @@ func sum(a, b int) int {
-       total := a + b
-       return total
+       return a + b

最低限必要なのは、

  • bのファイル名(a.go)
  • @@の行の+の後ろにある数字(4)
  • @@以降で+から始まる行がいくつあるか(1つ)

の3つ。
ファイルをparser.ParseFileで開いたら行番号を起点として変更行の数だけシンボルの位置(このケースだとa.go:3:6のみ)を探していく。

ただ、これだけだと関数の位置を移動しただけでも影響があることになってしまうのでこういった部分は除外したい。
そして完全に新規で追加されたシンボルはきっとどこかで使われているはずなのでこれも除外したい。

また、構造体やgoroutineの呼び出しはcallHierarchyが使えないのでtextDocument/referencesや2を使って関数の位置を調べる必要がある。

あとは結果をどう見せるのかも悩ましいところ。
GitHubでリンクになっているのが良いかもしれないし、さらに他のツールと連携することを考えるとgo vetのような形式やJSONの方が扱いやすいかもしれない。

*1:アーキテクチャがおかしいのかもしれないけど、実際そうなってしまっているので。。。