packagestestのExported.Expectを使ってGoのソースからマーカーを収集する

golang.org/x/tools/go/packages/packagestestパッケージは静的解析など、Goのソースを読み込んで何かしらの処理を行うようなツールのテスト用にダミープロジェクトを作るためのパッケージです。

そういったツールを作る際、テストで期待する結果を完全に決め打ちで_test.goに書いておいても良いかもしれませんが、パターンを増やす時にはダミーのソースとそれに対応する期待結果をそれぞれ追加していかなければいけないので、数が増えてくるとメンテナンスが大変になってしまいます。

そのため、ダミーのソースの中にマーカーを埋め込んでおいて、それを元に期待する結果をテスト時に組み立てるようにすると、パターンを追加する時の変更箇所が1つになり、またテストコード自体の見通しも良くなります。

具体例はgoplsのテストを見るのが良いですが、簡単にまとめると、

1) テスト関数の本体でpackagestest.TestAllを呼び、

func Test(t *testing.T) {
    packagestest.TestAll(t, test)
}

2) testの中でpackagestest.Exportを使ってダミープロジェクトをセットアップし、

// Moduleにはtestdataにあるモジュールを指定しても良い
exported := packagestest.Export(t, e, []packagestest.Module{
    {
        Name: "example.com/pkg", // モジュール名
        Files: map[string]interface{}{
            "foo/foo.go": `package foo

const Foo = 100 //@Foo, aaa(Foo, "const")
`,
            "bar/bar.go": `package bar

import "example.com/pkg/foo"

const Bar = foo.Foo * 10 //@bbb("Bar", Foo)
`,
        },
    },
})
defer exported.Cleanup()

3) その戻り値のExpectメソッドでマーカーを収集する、

if err := exported.Expect(map[string]interface{}{
    // @aaa()用
    "aaa": func(pos token.Pos, arg string) {
        t.Logf("pos = %v, arg = %v", pos, arg)
    },
    // @bbb()用
    "bbb": func(pos token.Pos, arg token.Pos) {
        t.Logf("pos = %v, arg = %v", pos, arg)
    },
}); err != nil {
    t.Fatal(err)
}

という流れになります。
あとは収集したマーカーを使って期待する結果を作ってあげればOKです。

ただExpectメソッドがどうやって位置に変換しているのか、少しわかりにくかったのでメモを残しておきます。

  • マーカーの文字列からtoken.Posに変換する場合
// OK: ↓をそのまま文字列にする
const Foo = 100 //@aaa("Foo", "const")

// NG: 同じ行にない場合はエラー
//@aaa("Foo", "const")
const Foo = 100
  • マーカーの識別子からtoken.Posに変換する場合
// OK:            ↓識別子をマーカーにしておく
const Foo = 100 //@Foo, aaa(Foo, "const")

// どこかにマーカーがあれば別の場所でも使える
const Bar = foo.Foo * 10 //@bbb("Bar", Foo)

// NG: Barはマーカーになっていないのでエラー
const Bar = foo.Foo * 10 //@bbb(Bar, Foo)
// NG: 識別子にドットは使えないのでfoo.Fooにはできない
const Bar = foo.Foo * 10 //@bbb("Bar", foo.Foo)

// OK: かわりにmarkで識別子の名前を"fooFoo"にしておくと、
const Foo = 100 //@mark("fooFoo", Foo)
// 別の場所でfooFooが使えるようになる
const Bar = foo.Foo * 10 //@bbb("Bar", fooFoo)
  • 別のマーカーを流用してtoken.Posに変換する場合
//                     ↓を
type A string //@type("AString", "A")
//                             ↓で識別子として使いたい
type Alias = A //@bbb("Alias", AString)

Expectbbbが呼ばれる前に、typeMarkするExpectを呼んでおく。

if err := exported.Expect(map[string]interface{}{
    "type": func(name string, r packagestest.Range, _ []string) {
        exported.Mark(name, r)
    },
}); err != nil {
    t.Fatal(err)
}

もし需要がありそうならゴリラ.Goのネタにするかもしれません。

goplsでworkspace/symbolが使いたい

現状の最新版(gopls/v0.2.2-pre2)では未実装。

daisuzu.hatenablog.com

を書いていたら欲しくなったので第1回 ゴリラ合宿で実装したのがコチラ。

github.com

symbolの探索はServersessionViews()から Snapshotを取ってきて、
KnownPackages()CompiledGoFiles()Parse()して*ast.Fileを引っ張り出して、
あとは地道にWorkspaceSymbolParamsにマッチするものを探していくようにした。

ただ、WorkspaceSymbolParamsの仕様*1がよくわかっておらず、実装の仕方も上記のやり方で良かったのか全然わかっていない。
また、最初は素直にスライスを作るように実装してみたところ、Vimから:tselectなどを実行すると

  • タグが重複したり、
  • 実行するたびに順番が変わったり、

するので、
重複を排除した上で、Vimで指定した文字列に近く、現在のパッケージに近いものを優先するようにしてみた。

とりあえず自分用としては使えそうなので色々と試してみることにする。

*1:特にQuery

VimのtagfuncでLSPを使う

この記事はVim Advent Calendar 2019の6日目の記事です。

今年の4月にv8.1.1228tagfuncという機能が追加されました。
こちらは:tag:tselectなどのタグ系コマンド*1を実行した時、tagsファイルを検索する代わりに呼ばれる関数を設定するためのオプションです。

設定する関数の形式としては次のようなものです。

" pattern: タグ検索中に使用されたタグ識別子
" flags: 関数の挙動を制御するためのフラグのリスト
"   'c' -> ノーマルモードのコマンドで呼び出された
"   'i' -> インサートモードのタグ補完で呼び出された
" info: 以下の情報を持つ辞書
"   {
"     'buf_ffname': 'フルファイル名',
"     'user_data': 'カスタムデータ文字列',
"   }
function! MyTagFunc(pattern, flags, info)
  " タグ情報のリストを返す
  " もしくはv:nullを返すとtagsファイルが使われる
  return [
        \  {
        \    'name': 'タグ名',
        \    'filename': 'タグが定義されているファイル名',
        \    'cmd': 'ファイル内のタグを見つけるためのExコマンド',
        \    'kind': 'タグの種類(Optional)',
        \    'user_data': 'カスタムデータ文字列(Optional)',
        \  },
        \]
endfunction

ヘルプにはtaglist()の結果を並べ替える関数が例として記載されていますが、LSPを使ってタグ情報のリストを生成できればタグジャンプがすごく便利になりそうです。

VimConf 2019ではsettagstack()を使ってタグスタックを直接操作する方法を紹介しましたが、こちらは、

  • タグ系のコマンドを使うために、
    • キーマップの変更が必須
    • Exコマンドはユーザ定義コマンドを定義する必要がある
  • Vim scriptでタグスタックの管理をしなければならない

といったものでした。

これに対し、tagfuncはオプションを設定するだけで、

  • 標準のキーマップ、Exコマンドがそのまま使える
    • タグ補完の時にも使える
  • タグスタックの管理をVim本体に任せられる

と、すごく良さそうです。

試しに任意の文字列で検索するworkspace/symbolvim-lspから呼び出してみます。

function! MyTagFunc(pattern, flags, info) abort
  let l:servers = filter(lsp#get_whitelisted_servers(),
      \ 'lsp#capabilities#has_workspace_symbol_provider(v:val)')
  if len(l:servers) == 0
    echoerr 'not supported: workspace/symbol'
    return []
  endif

  " タグ補完の場合はa:patternの先頭に \< がつくので削除する
  " (サーバ側が正規表現に対応していないかもしれないので...)
  let l:query = a:flags =~# 'i' ?
      \ substitute(a:pattern, '^\\<', '', '') : a:pattern

  let l:ctx = {'result': []}
  for l:server in l:servers
    call lsp#send_request(l:server, {
        \ 'method': 'workspace/symbol',
        \ 'params': {
        \   'query': l:query,
        \ },
        \ 'sync': 1,
        \ 'on_notification': function('s:make_taglist', [l:ctx]),
        \ })
  endfor
  return l:ctx.result
endfunction

" ctx.resultにタグ情報を追加する
func s:make_taglist(ctx, data) abort
  for result in a:data.response.result
    call add(a:ctx.result, {
        \ 'name': result.name,
        \ 'filename': lsp#utils#uri_to_path(result.location.uri),
        \ 'cmd': string(result.location.range.start.line + 1),
        \ })
  endfor
endfunction

上記関数をset tagfunc=MyTagFuncで設定すると、ctagsとほぼ同じ使い勝手のタグジャンプができるようになります。

ただ、workspace/symboltextDocument/definitionなどのようなカーソル位置を起点に対象を探すメソッドとは異なり、100%目当ての場所にジャンプできるとは限りません。

確実にジャンプしたい時はtextDocument/definitionを呼べるような実装にできれば良いのですが、MyTagFuncの引数からだと、それがExコマンドの引数に指定したタグ名なのか、カーソル位置のキーワードなのかを判別できないので、メソッドの呼び分けをするのは非常に困難です。

そのため、tagfuncに設定する関数で全部をやるのではなく、

  • CTRL-]CTRL-W ]textDocument/definition*2を呼ぶ
    • 定義元にジャンプしたい
  • それ以外はtagfuncからworkspace/symbolを呼ぶ
    • 同名の別メソッド/関数やインタフェースの実体などを探したい

というように、用途に応じて使い分けるのがオススメです!

結局settagstack()tagfuncを両方使うことに。。。

[2019/12/06 17:00追記]
flagsで判別可能でした。
@presukuさん、ありがとうございます!

tagfuncだけで完結させるにはMyTagFuncを次のようにします。

function! MyTagFunc(pattern, flags, info) abort
  let l:ctx = {'result': []}

  if a:flags ==# 'c'
    " ノーマルモード(CTRL-]など)の場合はカーソル位置の定義に飛ぶ
    let l:method = 'textDocument/definition'
    let l:servers = filter(lsp#get_whitelisted_servers(),
        \ 'lsp#capabilities#has_definition_provider(v:val)')
    let l:ctx.pattern = a:pattern
    let l:params = {
        \   'textDocument': lsp#get_text_document_identifier(),
        \   'position': lsp#get_position(),
        \ }
  else
    " Exコマンド(:tag {name}など)やタグ補完の場合はa:patternで探す
    let l:method= 'workspace/symbol'
    let l:servers = filter(lsp#get_whitelisted_servers(),
        \ 'lsp#capabilities#has_workspace_symbol_provider(v:val)')

    " タグ補完の場合はa:patternの先頭に \< がつくので削除する
    " (サーバ側が正規表現に対応していないかもしれないので...)
    let l:query = a:flags =~# 'i' ?
        \ substitute(a:pattern, '^\\<', '', '') : a:pattern
    let l:params = {
        \   'query': l:query,
        \ }
  endif

  if len(l:servers) == 0
    echoerr 'not supported: ' . l:method
    return []
  endif

  for l:server in l:servers
    call lsp#send_request(l:server, {
        \ 'method': l:method,
        \ 'params': l:params,
        \ 'sync': 1,
        \ 'on_notification': function('s:make_taglist', [l:ctx, l:method]),
        \ })
  endfor
  return l:ctx.result
endfunction

" ctx.resultにタグ情報を追加する
func s:make_taglist(ctx, method, data) abort
  for result in a:data.response.result
    if a:method ==# 'workspace/symbol'
      let l:name = result.name
      let l:location = result.location
    else
      let l:name = a:ctx.pattern
      let l:location = result
    endif
    call add(a:ctx.result, {
        \ 'name': l:name,
        \ 'filename': lsp#utils#uri_to_path(l:location.uri),
        \ 'cmd': string(l:location.range.start.line + 1),
        \ })
  endfor
endfunction

ただ一番利用頻度の高いgoplsworkspace/symbolに対応していないのでまだ常用できていません。。。

VimConf 2019に行ってきた

今年もVimConf 2019に行ってきました。

今回はテーマが “how to be more productive with Vim?” ということもあったのか、比較的Vim暦が浅い人の登壇が多かったような気もしますが、その割には発表内容が素晴らしいものばかりだったので、やっぱりVimを使う人は(良い意味で)ヤバい人ばっかりだなと思いました。

毎年のことですが、運営のみなさま大変お疲れ様でした。本当にありがとうございます。
おかげでとても楽しく過ごすことができました。


発表メモ

Vim Renaissance by Prabir Shrestha

愛用しているvim-lspの作者によるキーノート。
色々なエディタ・IDEプログラミング言語があった状況からLSPが登場し、そして未来への革命へ、的な。
乱立していると辛いとか、選択肢を広げるようにしているところとか、わかる部分もあれば自分の英語力だとちゃんと理解できているのか不安になるところもあったり...
多くの人が使えるようにするのは大事なことですけど、そこにもトレードオフがあったりするので難しいところですよね。

We can have nice things by Justin M. Keyes

2つ目のセッションはneovim作者によるキーノート。
neovimはあんまり使っていないんですが、その思想とか実装を直接聞くことができてすごく興味深かったです。
UI周りに関する作り込みとかもそうですけど、特に 「Legacy paradox」 の「表面化にある価値のあるモノ」についてはなるほどなーと思いました。

Your Vim is Only for You by mopp

午後の最初のセッション。
自分のVimを改善していくフローは今までそうしてきたことも多々あるし、今後もそうありたいと思っていることなのでとても共感できました。

Grown up from Vim User to Vim plugin developer side by IK

Vimを使い始めてからvim-jpに参加するまでの流れ。
課題を自分が直すしかない!みたいなところは思い当たることがあったり...
最後の「The next OSS contributer is YOU!!!」はとてもやる気が湧き起こる言葉ですね。

Usage and manipulation of the tag stack by daisuzu

こちらを発表してきました。 speakerdeck.com

make test by m-nishi

Vim本体のテストについて。
ターミナルが小さくてfailするのはやったことありますw
自分が以前blogに書いたことを見てくれていたりして、そういったことがこの発表に繋がっているのかなと思うとなんだか嬉しいです。

My Vim life by gorilla0513

1年前にVimを使い初めてやってきたことの軌跡。
ゴリラ.vimとか本とか、近くで関わっていて良く知ってる事もありますけど、ゴリラさんの情熱と行動力は本当にスゴいし尊敬しています!

Using Vim at Work! by Danish Prakash

心理学 + Vimといったとても興味深い発表でした。
UIについては(自分が思うように使えれば良いや、とか思ったりして)あんまり深く考えなかったりもしたけど、

  • キーボードよりマウスの方が効率的な事がある
  • テキストよりもビジュアル情報の方が6万倍速い

と言われると確かになーとなりました。

Let's Play with Vanilla Vim by Hezby Muhammad

プラグインを使わないVanilla Vimの話。
Vimを使いこなすと基本機能だけでもなんとかなってしまう事も多々あるんですよね。 :find とか補完とかタグジャンプとか。
1つ前の発表でも少し出てきましたが、まさか他の発表でタグジャンプがあるとは思っていませんでした。
そして :normalI とか A とか使うやり方はやった事なかったです。

13 Vim plugins I use every day by Tatsuhiro Ujihisa

毎日使っているプラグインの紹介。
ライブコーディングでサーバをパパッと作っていましたが、流れるようなVim捌きが本当に凄かったです!

My dark plugins development history ~ over 10 years ~ by Shougo

shougo wareの開発の歴史。
最近は使わなくなってしまいましたが...第二世代くらいまではすごくお世話になっていました!
今回で最後の登壇になるかもしれないというのがちょっと信じられません。
来年でなくてもまたいつか発表していただければなと思います。

Cloud Datastore用キャッシュパッケージの検討

現在App Engine(Standard Environment) + Go1.11環境*1goonというパッケージを利用し、Datastoreのデータをキャッシュしています。
goonはバックエンドとしてApp Engineのmemcache*2を使用しており、こちらがGo1.12から使えなくなってしまうので他のパッケージに移行しなければなりません。

App Engineのmemcacheに依存しない既存パッケージとしては、

などがありますが、どちらも独自のクライアントを作成するものとなっています。

goonを使っていて気になったのが、独自クライアント方式はGoogleが提供するパッケージには無い便利な機能があるというメリットがあるものの、本家の機能が全て使えなかったり、そもそもパッケージとしての使い方が異なっているため、Go1.11以降で推奨されているcloud.google.com/go/datastoreへの移行はもちろん、元になっているgoogle.golang.org/appengine/datastoreに戻すことすら手間がかかってしまうという点です。

機能面で困ることなく、ずっと使い続けることができるのであれば特に気にするところではないのかもしれませんが、goonの場合は更新があまり活発ではなく、移行時に大幅にコードを書き直さないといけないことがわかっているため、次のようなパッケージを検討することにしました。

  • cloud.google.com/go/datastoreClientをそのまま使うことができる
  • 使い方が大きく変わるような付加機能は提供しない
  • キャッシュのバックエンドを自由に選択できる

上記を実現するのに使用するのがgRPCのUnaryClientInterceptorというAPIです。
UnaryClientInterceptorはリモート呼び出しの代わりに呼ばれる関数となっています。 DatastoreはgRPCで各種オペレーションを実行するため、NewClientoptsUnaryClientInterceptorを渡すことでキャッシュ処理を差し込むことが可能です。

使用例:

// optを渡さなければキャッシュを無効にできる
opt := option.WithGRPCDialOption(
    grpc.WithUnaryInterceptor(MemoryInterceptor),
    // grpc.WithUnaryInterceptor(MemcacheInterceptor),
    // grpc.WithUnaryInterceptor(RedisInterceptor),
)

ただし、UnaryClientInterceptorに渡されるmethodreqreplyといった引数はDatastoreのClientの各メソッドと1:1で対応しているわけではないため、どのメソッドがどのgRPC呼び出しになるのかを把握した上でキャッシュ戦略に応じた実装をしていく必要があります。

Client gRPC
Get /google.datastore.v1.Datastore/Lookup
GetMulti /google.datastore.v1.Datastore/Lookup
Put /google.datastore.v1.Datastore/Commit
PutMulti /google.datastore.v1.Datastore/Commit
Delete /google.datastore.v1.Datastore/Commit
DeleteMulti /google.datastore.v1.Datastore/Commit
Mutate /google.datastore.v1.Datastore/Commit
Run /google.datastore.v1.Datastore/RunQuery
Count /google.datastore.v1.Datastore/RunQuery
GetAll /google.datastore.v1.Datastore/RunQuery
RunInTransaction NewTransaction後、CommitまたはRollback
NewTransaction /google.datastore.v1.Datastore/BeginTransaction
Transaction.Get /google.datastore.v1.Datastore/Lookup
Transaction.GetMulti /google.datastore.v1.Datastore/Lookup
Transaction.Put Transaction.Commitにまとめられる
Transaction.PutMulti Transaction.Commitにまとめられる
Transaction.Delete Transaction.Commitにまとめられる
Transaction.DeleteMulti Transaction.Commitにまとめられる
Transaction.Mutate Transaction.Commitにまとめられる
Transaction.Commit /google.datastore.v1.Datastore/Commit
Transaction.Rollback /google.datastore.v1.Datastore/Rollback

goonに合わせたキャッシュ戦略とする場合、

  • トランザクションでキー指定のデータ取得時にキャッシュの参照や保存を行い、
    • Get
    • GetMulti
  • データ変更時にキャッシュの削除を行う
    • Put/Transaction.Put
    • PutMulti/Transaction.PutMulti
    • Delete/Transaction.Delete
    • DeleteMulti/Transaction.DeleteMulti
    • Mutation/Transaction.Mutation

ので、LookupCommitの2つのRPCをインターセプトすることになります。

func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
    switch method {
    case "/google.datastore.v1.Datastore/Lookup":
        // キャッシュの参照や保存
        return nil
    case "/google.datastore.v1.Datastore/Commit":
        // キャッシュの削除
        return nil
    }
    return invoker(ctx, method, req, reply, cc, opts...)
} 
/google.datastore.v1.Datastore/Lookup (キャッシュの参照や保存)

LookupreqとしてLookupRequestを受け取り、結果はreplyLookupResponseを設定します。

実装する処理としては次のような流れです。

1. reqのread_optionsをチェックし、トランザクション内だった場合はinvokerを呼び出してreturnする
2. reqのkeysを使ってキャッシュを参照する
   *  全てのデータが取得できればreplyのfoundに設定してreturnする
3. reqのkeysを2で取得できなかったキーのみにしてinvokerを呼び出す
4. replyのfoundに2で取得したデータを追加する
5. 3で取得したデータをキャッシュに書き込む

データが見つからなかった場合のハンドリングはClientGetGetMulti内で行われるため、UnaryClientInterceptorの中で特殊な処理をする必要はありません。

/google.datastore.v1.Datastore/Commit (キャッシュの削除)

CommitreqとしてCommitRequestを受け取り、結果はreplyCommitResponseを設定します。

実装する処理としては次のような流れです。

1. invokerを呼び出す
   * エラーだった場合はreturnする
2. reqのmutationsにupdate、upsert、deleteがあれば対応するキーのキャッシュデータを削除する
   * 削除に失敗した場合はエラーを返す

キャッシュの削除に失敗すると古いデータが残り続けてしまうため、

のいずれかを行わないとデータに不整合が生じてしまいます。

これについて、特に困っているのがRunInTransactionの場合、キャッシュ削除の失敗はロールバックの対象にならず、個別にロールバックを呼び出すこともできないというところです。
そのため、

  • キャッシュのバックエンドにリトライ処理を入れる
    • 無限にリトライしてしまう可能性もある...
  • キャッシュに有効期限を持たせる
    • 一定時間は不整合を許容できるのであれば...
  • キャッシュを削除してからDatastoreを更新する
    • キャッシュ削除〜Datastore更新の間に別の処理が入ると結局不整合になる
    • それならキャッシュ戦略を変更した方が...
  • いっそのことRunInTransactionを使わないようにする

などなど、このあたりはもう少し検討をしなければいけない状況です。
ただ実際に動かしてみないとわからないところもあると思うので、近いうちに実装して色々と試してみようと思っています。

Stackdriverの設定をterraformで管理する

今までアラートポリシーやカスタム指標などのStackdriverの設定をyamlやテキストで保存し、自作ツールで適用するようにしていましたが、自作ツールのメンテナンスが面倒になってきたのでterraformに移行することにしました。

移行にあたって、既存の設定ファイルは破棄し、terraformerを使って以下のように再作成しました。

1. 既存設定のインポート

PROJECT_ID="プロジェクトID"
BUCKET="gs://バケット名"
OUT="出力先ディレクトリ"
terraformer import google --resources=monitoring -o $OUT -p '{output}' --state=bucket --bucket=$BUCKET --projects=$PROJECT_ID

※terraformer v0.7.8の場合、terraform v0.12への対応ができていない部分があるので以下を行う必要があります

  • bucket.tfを修正
  • terraform 0.12upgrade を実行

※slack通知はauth_tokenがマスクされた状態だったので設定を削除し、terraform state rmで管理から外しました

2. カスタム指標の作成

terraformer v0.7.8はカスタム指標に対応していないため、gcloudコマンドで取得した内容を .tf に変換しました。

gcloud --project=$PROJECT_ID logging metrics list --format=json

このままだとterraform apply時に新規作成しようとしてエラーになってしまうため、terraform importでterraformの管理下に追加します。

3. 設定の適用

手元に一通りの設定が揃ったら適用します。

terraform fmt
terraform validate
terraform plan
terraform apply

※必須フィールドが無い場合はterraform apply時にエラーになるので個別に対応します

Datastore Emulatorのエンドポイント

Cloud Datastore エミュレータは起動した後、gcloudコマンドではなくREST APIを使って操作するらしい。
が、そのあたりの情報が全然まとまっていなかったので本体の中をチラ見して必要そうなところを調べてみた。

cloud-datastore-emulator 2.1.0
Method Path Description
GET / OKを返すだけ
POST /reset データをリセットする*1
POST /persist データを永続化する*2
POST /shutdown datastore emulatorを落とす
POST /_ah/admin/datastore /reset と同じ
POST /_ah/admin/quit /shutdownと同じ

ユニットテストで使う際は--no-store-on-diskをつけて起動し、

  • テストケースごとに POST /reset
  • テストが終わったらPOST /shutdown

すると良さそう。

*1:--store-on-diskの場合はヌルポ

*2:--no-store-on-diskの場合はnot supportedが返ってくる