LSP 実装メモ(gopls cache `Snapshot` 編)

前回

tennashi.hatenablog.com

先週はやる気が消失したためお休みした。

前回のまとめ

ひたすらに (*Session).NewView() からの呼び出しを追っていった。

今回は残った (*snapshot).load() メソッドの処理を読む。
が、全て詳細に読むと長くなりすぎてしまうので、さっくりと読んだ後、Snapshot は何をするためのものなのかを追う。

(*snapshot).load()

https://github.com/golang/tools/blob/5d1fdd8fa3469142b9369713b23d8413d6d83189/internal/lsp/cache/load.go#L43-L199

このメソッドは以下の部分に分割できる。

  • packages.Load() に渡す引数の準備
  • packages.Load() の呼び出し
  • 返り値から必要な情報を抽出して、cache データを構築する

packages.Load() は第一引数にどう package を parse するかなどのコンフィグを取り、第二引数に parse する package をパターン文字列で指定する。

これらの準備を以下の部分で行っている。

https://github.com/golang/tools/blob/5d1fdd8fa3469142b9369713b23d8413d6d83189/internal/lsp/cache/load.go#L44-L139

そして packages.Load() を呼び、以下の部分で parse した package から情報を cache に溜めていく。

https://github.com/golang/tools/blob/5d1fdd8fa3469142b9369713b23d8413d6d83189/internal/lsp/cache/load.go#L166-L194

このとき cache としての肝となるのは例えば以下のような x/tools/internal/memoize package を使った部分である。

https://github.com/golang/tools/blob/5d1fdd8fa3469142b9369713b23d8413d6d83189/internal/lsp/cache/snapshot.go#L1369-L1389

例えば上記では、builtin package のファイルを parse した結果を s.generation.Bind() メソッドで登録しておき、s.builtin フィールドに保持している。
必要になれば s.builtin.handle.Get() メソッド呼び出しをすれば登録されたハンドラの処理結果を取得できるという訳だ。

x/tools/internal/memoize package については以下を参照。

godoc.org

snapshot

snapshot 型のフィールドを見てみる。

type snapshot struct {
    memoize.Arg // allow as a memoize.Function arg

    id   uint64
    view *View

    generation *memoize.Generation

    builtin *builtinPackageHandle

    mu sync.Mutex

    ids map[span.URI][]packageID

    metadata map[packageID]*metadata

    importedBy map[packageID][]packageID

    files map[span.URI]source.VersionedFileHandle

    goFiles map[parseKey]*parseGoHandle

    packages map[packageKey]*packageHandle

    actions map[actionKey]*actionHandle

    workspacePackages map[packageID]packagePath

    workspaceDirectories map[span.URI]struct{}

    unloadableFiles map[span.URI]struct{}

    parseModHandles map[span.URI]*parseModHandle

    modTidyHandles    map[span.URI]*modTidyHandle
    modUpgradeHandles map[span.URI]*modUpgradeHandle
    modWhyHandles     map[span.URI]*modWhyHandle

    modules map[span.URI]*moduleRoot

    workspaceModuleHandle *workspaceModuleHandle
}

source.VersionedFileHandle 以外の .*Handle 型は(全部は見てないので恐らく)先に説明した memoize package による cache を保持するための型だ。

つまり Go のコードとしての情報はほとんどこの型に保持されているので、LSP メソッドの処理はこの snapshot を取得するところから始まると推測される。

まとめ

少し詳細に踏み込みすぎて目的を見失ってしまった感があったが、あくまで LSP サーバ実装の一例としての cache 機構がどのようになっているのかを gopls から学びたかったのだ。

LSP サーバを書く際 TextDocument の扱いで私が気になっていたのは以下の点である。

  • いつその言語の文字列として parse するか
    • textDocument/didOpen 通知が飛んできたらその URI のファイルを開き、parse までして cache を構築する
    • ただし goroutine で非同期に処理される
  • いつ関連するファイル(import された package など)を開くか
    • cache を構築するときに必要なので、同じタイミングで行なわれる
  • parse 結果をどのように cache するか
    • ディスク上のファイルは Cache(fileHandle) に保持される
    • LSP により送信された TextDocument は Session(overlays) に保持される
    • Go のコードとして parse された結果は View(snapshot) に保持される

gopls cache 編は一旦ここまでとする。

次回からは LSP 仕様とその実装を追うことに戻るが、何から読んでいこうか。

LSP 実装メモ(gopls cache `View` 生成詳解編)

前回

tennashi.hatenablog.com

引き続き、gopls の cache 実装を読む。

前回のまとめ

View 実装のフィールドとその生成タイミングを見ることで、ディレクトリ単位で何かしらの cache をしていることが分かった。

View の初期化処理をもう少し詳しく追っていく。

View は Session の NewView() メソッドにより生成される。
ここからコールスタックは以下のように積まれる。

  • (*Session).NewView()
    • (*Session).createView()
      • (*View).setBuildInformation()
        • (*View).goVersion()
        • (*View).setGoEnv()
      • (*View).findAndBuildWorkspaceModule()
        • (*snapshot).buildWorkspaceModule()
      • (*View).setBuildConfiguration()
      • (*View).findWorkspaceDirectories()
      • (*View).initialize()
        • (*snapshot).load()

今回はこれらのメソッドの中でやっていることを追う。

(*Session).NewView()

シグネチャは以下。

func (s *Session) NewView(ctx context.Context, name string, folder span.URI, options source.Options) (source.View, source.Snapshot, func(), error)

これは単純なエントリポイントで、メインの実装は次の createView() だ。
ここではロックを取って、createView() が生成したものを View は Session に保持しつつそのまま返す。

(*Session).createView()

シグネチャは以下。

func (s *Session) createView(ctx context.Context, name string, folder span.URI, options source.Options, snapshotID uint64) (*View, *snapshot, func(), error)

引数に snapshotID が追加されており、NewView() からは 0 と指定される。
また source.View source.Snapshot の実装となっている *cache.View *cache.snapshot 型が返り値になっている点が NewView() との違いである。

cache.View 型と cache.session 型のフィールドの初期化して返すのがここでの仕事である。
初期化するためにいくつかの処理が必要なものは別メソッドとして分離されており、それらは以下で説明するものである。

(*View).setBuildInformation()

シグネチャは以下。

func (v *View) setBuildInformation(ctx context.Context, options source.Options) error

options にはユーザが指定した gopls の設定が入る。

まず最初に checkPathCase() という関数を呼んでいる。
これは、case-insensitiveファイルシステムをどうにか扱うために以下のコミットで追加された処理だ。

github.com

ファイルシステム上は Hoge/Fuga.go という名前で保存されたファイルをエディタが hoge/fuga.go などのファイル名で送信してきたとき、gopls では扱えなくなるので、エラーにして叩き落とすという処理をしている。
macOS(darwin) と Windows のみ意味のある処理をしており、それ以外の OS では何もせず nil を返す。
これはファイルシステムの問題であり、OS の問題ではないので、Linuxcase-insensitiveファイルシステムを利用する場合は...まぁ下手に大文字ファイル名を使わなければハマらない...多分。

その後 goVersion() で Go コマンドのバージョンを埋める。

さらに setGoEnv()GO*環境変数の設定をする。
この返り値は $GOMOD になっており、$GOMOD は module-aware mode において、main module が見付からないと /dev/null を返す仕様になっている。
cf. go help environment

もし $GOMOD/dev/null ならこの時点で処理は終了する。
なお注意すべきは GOPATH mode なら、$GOMOD == "" なので処理が続くということだ。

次に (View).modURI(View).sumURI を埋め、ExpandWorkspaceToModule という設定値に応じて v.root を埋めるかどうかを決める。
ExpandWorkspaceToModulegopls が現在開こうとしているディレクトリ(== ワークスペース)からそれを含むモジュール全体へとスコープを拡張するための設定値だ。
これを true にすることで現在開こうとしているディレクトリを含む module からさらにディレクトリ探索をして go.mod ファイルの位置なども cache するように動作する。
monorepo で特に便利になる。 cf. https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/source/options.go#L334-L337

TempModfilefalse か GOPATH mode の場合はここで createView() に戻る。

TempModfile は Go 1.14 から追加された、-modfile オプションを gopls が解釈するかどうかを指定する設定値だ。
cf. https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/source/options.go#L319-L320

その後に到達するということは TempModfile == true かつ v.modURI != "" なので、v.workspaceMode を設定して終了である。

(*View).goVersion()

シグネチャは以下。

func (v *View) goVersion(ctx context.Context, env []string) (int, error)

GO111MODULE=off go list -e -f '{{context.ReleaseTags}}'v.root で実行すると例えば以下のように Go のバージョンリストが手に入る。

$ GO111MODULE=off go list -e -f '{{context.ReleaseTags}}'
[go1.1 go1.2 go1.3 go1.4 go1.5 go1.6 go1.7 go1.8 go1.9 go1.10 go1.11 go1.12 go1.13 go1.14]

これをパースして、Go 1.14 であれば 14 を取得する。

(*View).setGoEnv()

シグネチャは以下。

func (v *View) setGoEnv(ctx context.Context, configEnv []string) (string, error)

ここでは go env -json GO111MODULE GOFLAGS GOINSECURE GOMOD GOMODCACHE GONOPROXY GONOSUMDB GOPATH GOPROXY GOROOT GOSUMDB を実行して JSON 形式で必要な環境変数を取得している。
実行時の環境変数として configEnv の値を入れることで環境変数の上書きを実現している。

それらの環境変数v.goEnv に入れられ、その中でも GOCACHE GOPATH GOPRIVATE GOMODCACHE の中身は v.go.* という変数にも投入される。
さらに GOMODCACHE は Go 1.15 で追加されたもので、それ以前のバージョンは GOMODCACHE=$GOPATH/pkg/mod として解釈している。

さらに GOPACKAGESDRIVER という環境変数の処理が続く。
これは x/tools/go/packages パッケージで使われるもので、go env では返ってこないので自前でやっている。
この環境変数packages.Load() 関数の処理を自前で用意した実行ファイルに置き替えるための変数である。
詳細は以下を読むとよい。

github.com

(*View).findAndBuildWorkspaceModule()

では setBuildInformation() とそこから呼ばれているメソッドを解説したので、createView() に戻って、次のメソッドを解説する。

シグネチャは以下。

unc (v *View) findAndBuildWorkspaceModule(ctx context.Context, options source.Options) error

このメソッドは v.root から順に go.mod を探していき、見付けたらそれを v.modules に追加していく。

この処理をするためには ExpandWorkspaceToModule オプションと ExperimentalWorkspaceModule オプションの両方が有効になっている必要がある。

この処理をするときには v.workspaceMode には workspaceModule フラグが立てられる。

探索では go コマンドが無視するディレクトリは無視される。
具体的には . _ から始まるディレクトリ名と testdata ディレクトリ、/vendor 配下のディレクトリだ。

探索が終わると (*snapshot).buildWorkspaceModule() を呼ぶ。

(*snapshot).buildWorkspaceModule()

シグネチャは以下。

func (s *snapshot) buildWorkspaceModule(ctx context.Context) (*modfile.File, error)

返り値の *modfile.File は go.mod ファイルそのものである。
cf. https://godoc.org/golang.org/x/mod/modfile#File

ここでやっていることは現在関連付けられている v.modules 全てを依存として持つ大きな(仮想) module (workspace module と呼ぶ)を作成することである。

例えば以下のようなディレクトリ構成があるとする。

main/
  - go.mod # module main
  - sub_a/
    - go.mod # module sub_a
  - sub_b/
    - go.mod # module sub_b

このときに以下のような(仮想) go.mod が作成される。

module gopls-workspace

require (
  main v0.0.0-00010101000000-000000000000
  sub_a v0.0.0-00010101000000-000000000000
  sub_b v0.0.0-00010101000000-000000000000
)

replace (
  main => ./main
  sub_a => ./main/sub_a
  sub_b => ./main/sub_b
)

上記の例では一度目の for ループで完結するが、main sub_a sub_b いずれかでさらに replace 文があることを想定して、それを全て反映するための二度目の for ループが存在する。

この生成結果は v.workspaceModule に保持される。

(*View).setBuildConfiguration()

ではまた createView() から呼ばれているメソッドに戻る。

この処理はあまり名前と処理が合っていないように思うが、やっていることは、ここまでの設定値が "正しい" ことを検証している。

  • GOPACKAGESDRIVER が指定されている場合は問答無用で正しい
  • go.mod が見付かっている場合や v.modules を複数見つけている場合は正しい(module-aware mode)
  • それ以外は GOPATH mode のはずで、このときは v.folder$GOPATH/src 配下に存在すれば正しい

というチェックをして、全て満たさない場合は false が返る。
また同時に v.hasValidBuildConfiguration にもその結果が保持される。

(*snapshot).findWorkspaceDirectories()`

また createView() に戻る。
v.modURI が空でない場合は s.GetFile() を呼び cache に go.mod の内容を保持しておく。
その後この findWorkspaceDirectories() が呼ばれる。

シグネチャは以下

func (s *snapshot) findWorkspaceDirectories(ctx context.Context, modFH source.FileHandle) map[span.URI]struct{}

GOPATH mode なら、その s.view.root (つまりそのディレクトリ自体)だけが返される。

module-aware mode のとき、go.mod の replace 文に記載されたディスク上のディレクトリのみ Set(map[span.URI]struct{}) に追加されて返される。

コメントに書いてあるが、GOPATH mode のときは $GOPATH/src` に含まれるディレクトリ全てを見るのが本来やるべき処理だが、too expensive なのでやらないとのことだ。

この返り値は v.snapshot.workspaceDirectories に保持される。

(*View).initialize()

https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/cache/view.go#L691-L744

シグネチャは以下。

func (v *View) initialize(ctx context.Context, s *snapshot, firstAttempt bool)

これは s.load() が実体であり、それを goroutine safe に呼ぶことが仕事である。

ただあまりよく分かってないのが、semaphore と sync.Once を併用しているところだ。
最初に呼ばれた一回、という制約をかけるためだろうか...?

その後は引数を準備して、s.load() を呼び chan を閉じれば完了だ。

なお、このように初期化処理は並行に行なわれるため、View には AwaitInitialized() というメソッドが用意されており、初期化の完了を待つことができる。

(*session).load()

...つかれたのでここまで...

次回は (*session).load() から

-- 追記(2020/09/28) -- かいた

tennashi.hatenablog.com

LSP 実装メモ(gopls cache `View` 生成編)

前回

tennashi.hatenablog.com

引き続き、gopls の cache 実装を読む。

前回のまとめ

gopls で採用されている cache は 3 層で前回読んだのは Cache/Session の 2 層だ。

  • Cache
    • 素朴な OS ファイルシステム上のファイルの cache
    • cache 自体の実体は source.FileHandle 型(の実装)
  • Session
    • LSP の Text Document Synchronization でやりとりされる TextDocument の cache
    • cache 自体の実体は source.Overlay 型(の実装)

Session に cache が登録されるときに、その TextDocument の URI から OS ファイルシステム上のファイルを Cache に cache することを注意しておく。

Session の定義は以下だ。

  • source.Session interface
type Session interface {
    NewView(ctx context.Context, name string, folder span.URI, options Options) (View, Snapshot, func(), error)
    View(name string) View
    ViewOf(uri span.URI) (View, error)
    Views() []View
    Shutdown(ctx context.Context)
    GetFile(ctx context.Context, uri span.URI) (FileHandle, error)
    DidModifyFiles(ctx context.Context, changes []FileModification) ([]Snapshot, []func(), []span.URI, error)
    Overlays() []Overlay
    Options() Options
    SetOptions(Options)
}
  • cache.Session
type Session struct {
    cache *Cache
    id    string

    options source.Options

    viewMu  sync.Mutex
    views   []*View
    viewMap map[span.URI]*View

    overlayMu sync.Mutex
    overlays  map[span.URI]*overlay

    gocmdRunner *gocommand.Runner
}

今回はこの Session からさらに生成される View について読む。

View

View は設定と設定された package を持つものだ。
cf. https://github.com/golang/tools/blob/master/gopls/doc/implementation.md#viewsessioncache

いよいよよく分からない、のでさっさとコードを読む。

type View interface {
    Session() Session
    Name() string
    Folder() span.URI
    ModFile() span.URI
    BackgroundContext() context.Context
    Shutdown(ctx context.Context)
    AwaitInitialized(ctx context.Context)
    WriteEnv(ctx context.Context, w io.Writer) error
    RunProcessEnvFunc(ctx context.Context, fn func(*imports.Options) error) error
    Options() Options
    SetOptions(context.Context, Options) (View, error)
    Snapshot(ctx context.Context) (Snapshot, func())
    Rebuild(ctx context.Context) (Snapshot, func(), error)
    ValidBuildConfiguration() bool
    IsGoPrivatePath(path string) bool
    IgnoredFile(uri span.URI) bool
}
type View struct {
    session *Session
    id      string

    optionsMu sync.Mutex
    options   source.Options

    mu sync.Mutex

    baseCtx context.Context

    backgroundCtx context.Context

    cancel context.CancelFunc

    name string

    folder span.URI

    root span.URI

    importsMu sync.Mutex

    processEnv              *imports.ProcessEnv
    cleanupProcessEnv       func()
    cacheRefreshDuration    time.Duration
    cacheRefreshTimer       *time.Timer
    cachedModFileIdentifier string
    cachedBuildFlags        []string

    filesByURI  map[span.URI]*fileBase
    filesByBase map[string][]*fileBase

    snapshotMu sync.Mutex
    snapshot   *snapshot

    initialized            chan struct{}
    initCancelFirstAttempt context.CancelFunc

    initializationSema chan struct{}
    initializeOnce     *sync.Once
    initializedErr     error

    hasValidBuildConfiguration bool

    modURI, sumURI span.URI

    tmpMod bool

    hasGopackagesDriver bool

    gocache, gomodcache, gopath, goprivate string

    goEnv map[string]string
}

コメントは省略したが、session.View interface には以下のようなコメントがある。
// 原文はリンク先を参照してほしい、ここでは訳を書いておく。

// View は単一のワークスペースを表現する。
// これは、ワーキングディレクトリやビルドタグのような設定値を保つためのレベルだ。

View の生成

これを確かめるために View の生成がどこで行われているかを追っていく。

View の生成は (Session).NewView() メソッドにより行われる。
このメソッドは (*Server).addView() からのみ呼ばれており、さらに addView()(*Server).addFolders() からのみ呼ばれる
この addFolders() メソッドは以下の3ヶ所で呼ばれている。

それぞれ以下の LSP メソッドと対応している。

とにかく、クライアントが何か TextDocument を開いたときに叩かれるものだと思えばよい。

gopls が initialize ハンドラでなく initialized ハンドラで addFolders() を呼んでいるのは初期化フェイズが完全に完了したことを gopls が確認するためである。
initialize() ハンドラでは pendingFolders というフィールドに一時的に溜めておき、initialized() ハンドラで実際に View を作成する。

didOpen() から始めて View の生成を追っていく。

didOpen() では通知された URI を含むディレクトリごと addFolders() に渡している。
cf. https://github.com/golang/tools/blob/c1903db4dbfe5ab8e2ec704e203535dae53c2adc/internal/lsp/text_synchronization.go#L101-L106

addFolders() ではディレクトリ毎に addView() を呼んでいる。
cf. https://github.com/golang/tools/blob/d1954cc86c824f62dd7845fadb8b09b089425026/internal/lsp/general.go#L202

で、addFolders() は最終的に NewView() を呼び、そのディレクトリで View を生成する。
cf. https://github.com/golang/tools/blob/c1903db4dbfe5ab8e2ec704e203535dae53c2adc/internal/lsp/workspace.go#L41

結果として View がディレクトリ単位で生成されることが分かる。

ワークスペース、とは

ここまで濁してきたのだが、ワークスペースとはなんだろうか。
例えば、initialize リクエストで送信される、InitializeParams に書かれた以下のコメントを読む。

   /**
    * ワークスペースの rootUri。開いているフォルダが無い場合
         * は null。`rootPath` と `rootUri` が両方設定されている場合
         * は `rootUri` が勝つ。
    */

ワークスペースとは特定のディレクトリのことで、その root が初期化時に渡されるのだろうと分かる。

一方、LSP 仕様にはワークスペースフォルダという言葉も出現しており、例えば以下のようなところで見られる。

https://microsoft.github.io/language-server-protocol/specification#workspace_workspaceFolders

全体の訳は書かないが、適当にかいつまむと、

  • さまざまなツールがワークスペース毎に複数のルートフォルダをサポートする
    • 例えば VS Code の multi-root support、Atom の project folder、Sublime の project
  • このような機能を持つエディタが複数のワークスペースフォルダを開くときには InitializeParamworkspaceFolders に入れてくれ

細かいニュアンスは原文を参照してもらいたいが、ワークスペースのルートフォルダ == ワークスペースフォルダと読める。

先の workspace/didChangeWorkspaceFolders はこれを追加、削除するための通知だ。

LSP 仕様的にはワークスペースフォルダとはただのディレクトリではなくプロジェクトルートのような意味合いで書いているように読める。

では、Go での対応する概念はなんだろうか。

先に述べたとおり session.View は単一のワークスペースを表わす。
cf. https://github.com/golang/tools/blob/master/internal/lsp/source/view.go#L122

その実装である cache.View には root というフィールドがあり、ここには GOPATH mode ならただのディレクトリで、module-aware mode なら module root が入る。
cf. https://github.com/golang/tools/blob/39188db5885871ea0132a6314eab0c1b5d179a8b/internal/lsp/cache/view.go#L63-L65

つまり module(module-aware mode なら) または package(GOPATH mode なら) 単位であると思えばよさそうだ。

ただし、View 自体はディレクトリ毎に生成される点はここまで読んだ通りだ。

ここまでで、View が単一ディレクトリの何かしらの意味での cache であることまでが分かった。

長くなってしまったので今回はここまで。

次回は View がどのような意味での cache であるのかを明らかにしたい。

facebook/ent を使ってみた

最近 GraphQL の素振りのため、シンプルな Web アプリケーションを作ることを考えていた。
で、どうせなら最低限の機能はシングルバイナリ + いくつかのファイルを用意するだけで動作させようと思い、以下のような構成だけなんとなく考えた。

  • フロントアプリは build 結果を rakyll/statik で Go のバイナリに埋め込む
  • データベースはデフォルトで SQLite を使い、必要に応じて利用者が用意した RDBMS を利用できる

ところで、facebook/ent をご存知だろうか。

entgo.io

これは facebook connectivity の中の人が書いている ORM で以下のコンセプトを持つ。 // facebook connectivity はどうやらネットワークの会社っぽい...?

  • Schema As Code
    • DB スキーマは Go のオブジェクトとして宣言される
  • Easily Traverse Any Graph
    • グラフ構造を持つデータに対するクエリ、集約、走査を簡単にする
  • Statically Typed And Explicit API
    • コード生成により静的に型付けされており、API は明示的である
  • Multi Storage Driver

cf. https://github.com/facebook/ent#ent---an-entity-framework-for-go

  • Easily Traverse Any Graph -> GraphQL と相性良さそう!!
  • Multi Storage Driver -> オプションで使う RDBMS 簡単に切り替えられそう!!
  • Statically Typed And Explicit API -> interface{} が頻発しないの最高!

とまぁ安易ではあるが、今回やろうとしていることと合致していると感じ、簡単にではあるが触ってみたので、その紹介をしようと思う。

hello ent

タスクスケジューラを作る、という想定で以下のグラフを書いた。
// 例えば Rundeck のようなものだと思ってくれればよい。

f:id:tennashi:20200905221507j:plain

  • UserNamespace に所属する
  • Namespace には少なくとも一人の管理者が存在し、管理者のみユーザをメンバーに追加できる。
  • NamespaceWorker を複数持つ
  • タスクは実行ターゲットとして Worker を登録する
  • タスクにはワンライナーを実行する CommandTask とユーザが記述したシェルスクリプトを実行する ScriptTask が存在する

大して ent の Quick Introduction の例以上の面白みがある訳ではないが、そこはまぁやってみた記事のご愛嬌ということで。

全体像はそんなところで、この記事では ent を使って左上部分 UserNamespace 間の実装を書くことを目的とする。

entc の導入

ent は Go で書かれたスキーマからコードを生成する。
この生成ツールが entc だ。

まずはこの entc を導入する。
導入は README にある通り、go get で行なう。
cf. https://github.com/facebook/ent#quick-installation

$ go get github.com/facebook/ent/cmd/entc

データベーススキーマの生成

entcスキーマ自体の生成もしてくれるので、まずはそこから。

$ entc init User Namespace

するとコマンドを実行したディレクトリ直下に ent というディレクトリが作成されており、以下のような構造でファイルが生成されているはずだ。

ent/
  - generate.go
  - schema/
    - namespace.go
    - user.go

いくつかのファイルの中身を見てみる。

  • sample/ent/generate.go
package ent

//go:generate go run github.com/facebook/ent/cmd/entc generate ./schema

これは go generate ./... でぱっとスキーマからコード生成できるように entc が気をきかせて生成してくれているものだ。

  • ent/schema/user.go
package schema

import "github.com/facebook/ent"

// User holds the schema definition for the User entity.
type User struct {
    ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
    return nil
}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return nil
}

重要なのはこちらのファイルだ。
この Fields() の返り値としてフィールドを記述し、Edges の返り値としてグラフの関連を記述する。

フィールドを定義する

早速 User のフィールドを定義していく。

User は以下のようなフィールドを持つとする。

  • ID : ユーザ ID。必須かつ Unique である。string
  • Name : ユーザ名。必須かつ Unique である。string
  • AvatarURL : ユーザのアバター画像の URL。省略可能。string

  • sample/ent/schema/user.go

package schema

import (
    "github.com/facebook/ent"
    "github.com/facebook/ent/schema/field"
)

// User holds the schema definition for the User entity.
type User struct {
    ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("id").Unique(),
        field.String("name").Unique(),
        field.String("avatar_url").Optional(),
    }
}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return nil
}

フィールドはデフォルトで必須であり、省略可能にするときに Optional() メソッドを呼ぶ。
このように制約はメソッドチェインの形で指定する。
UNIQUE 制約を付けたいときは Unique() メソッドだ。

ここで、値のバリデーションをかけることも可能だ。
具体的に他にどのようなことが出来るかについては以下のドキュメントを参照してほしい。

https://entgo.io/docs/schema-fields/

同様に NamespaceID Name という必須フィールドを持つとして以下のように書く。

  • sample/ent/schema/namespace.go
package schema

import (
    "github.com/facebook/ent"
    "github.com/facebook/ent/schema/field"
)

// Namespace holds the schema definition for the Namespace entity.
type Namespace struct {
    ent.Schema
}

// Fields of the Namespace.
func (Namespace) Fields() []ent.Field {
    return []ent.Field{
        field.String("id").Unique(),
        field.String("name").Unique(),
    }
}

// Edges of the Namespace.
func (Namespace) Edges() []ent.Edge {
    return nil
}

エッジの定義

次に UserNamespace 間の関連(エッジ)を定義する。

  • NamespaceUsermembers という多対多の関連を持つ
  • NamespaceUseradmins という多対多の関連を持つ

このことを以下のように記述する。

  • sample/ent/schema/namespace.go
package schema

import (
    "github.com/facebook/ent"
    "github.com/facebook/ent/schema/edge"
    "github.com/facebook/ent/schema/field"
)

// Namespace holds the schema definition for the Namespace entity.
type Namespace struct {
    ent.Schema
}

// Fields of the Namespace.
func (Namespace) Fields() []ent.Field {
    return []ent.Field{
        field.String("id").Unique(),
        field.String("name").Unique(),
    }
}

// Edges of the Namespace.
func (Namespace) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("members", User.Type),
        edge.To("admins", User.Type).Required(),
    }
}
  • sample/ent/schema/user.go
package schema

import (
    "github.com/facebook/ent"
    "github.com/facebook/ent/schema/edge"
    "github.com/facebook/ent/schema/field"
)

// User holds the schema definition for the User entity.
type User struct {
    ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("id").Unique(),
        field.String("name").Unique(),
        field.String("avatar_url").Optional(),
    }
}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("namespaces", Namespace.Type).Ref("members"),
        edge.From("owned_namespaces", Namespace.Type).Ref("admins"),
    }
}

edge.To() edge.From() 関数で関連を示す。
これで、先の2つの関連を作ることができる。 これで、Namespace 及び User のフィールドとエッジが定義できたのでコードを生成し、実行して、ここまでの要件が満たされていることを確認する。

データを作成し、クエリを投げてみる

entc が用意してくれていた、generate.go のおかげで、以下のコマンド一発でコードが生成される。

$ go generate ./...

ent ディレクトリ内に大量のファイル/ディレクトリが作成されてしまい、面食らったかもしれない。
これらの詳細を見る前にとにかく使ってみる。

  • sample/main.go
package main

import (
    "context"
    "fmt"

    "github.com/tennashi/ent-sample/ent"
    "github.com/tennashi/ent-sample/ent/namespace"
    "github.com/tennashi/ent-sample/ent/user"

    _ "github.com/mattn/go-sqlite3"
)

func initClient() (*ent.Client, error) {
    client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
    if err != nil {
        return nil, err
    }
    // Run the auto migration tool.
    if err := client.Schema.Create(context.Background()); err != nil {
        return nil, err
    }

    return client, nil
}

func createEntities(ctx context.Context, c *ent.Client) error {
    fmt.Println("=== Create users ===")

    userNames := []string{"user-a", "user-b", "user-c"}
    bulk := make([]*ent.UserCreate, len(userNames))
    for i, name := range userNames {
        bulk[i] = c.User.Create().SetID("user:" + fmt.Sprintf("%.3d", i+1)).SetName(name)
    }
    us, err := c.User.CreateBulk(bulk...).Save(ctx)
    if err != nil {
        return err
    }

    fmt.Println(us)

    fmt.Println("=== Create namespaces ===")

    nsa, err := c.Namespace.Create().SetID("namespace:001").SetName("namespace-a").
        AddMembers(us[0], us[1], us[2]).AddAdmins(us[0]).Save(ctx)
    if err != nil {
        return err
    }

    fmt.Println(nsa)

    nsb, err := c.Namespace.Create().SetID("namespace:002").SetName("namespace-b").
        AddMembers(us[0], us[1]).AddAdmins(us[0], us[1]).Save(ctx)
    if err != nil {
        return err
    }

    fmt.Println(nsb)

    nsc, err := c.Namespace.Create().SetID("namespace:003").SetName("namespace-c").
        AddMembers(us[2]).AddAdmins(us[0]).Save(ctx)
    if err != nil {
        return err
    }

    fmt.Println(nsc)

    return nil
}

func main() {
    c, err := initClient()
    if err != nil {
        panic(err)
    }
    defer c.Close()

    ctx := context.Background()

    err = createEntities(ctx, c)
    if err != nil {
        panic(err)
    }

    fmt.Println("=== Query members of namespace-a ===")

    us, err := c.Namespace.Query().Where(namespace.NameEQ("namespace-a")).QueryMembers().All(ctx)
    if err != nil {
        panic(err)
    }

    fmt.Println(us)

    fmt.Println("=== Query namespaces to which user-a belongs ===")

    nss, err := c.User.Query().Where(user.NameEQ("user-a")).QueryNamespaces().All(ctx)
    if err != nil {
        panic(err)
    }

    fmt.Println(nss)
}

これを実行すると以下のような出力が得られるはずだ。

go run main.go
=== Create users ===
[User(id=user:001, name=user-a, avatar_url=) User(id=user:002, name=user-b, avatar_url=) User(id=user:003, name=user-c, avatar_url=)]
=== Create namespaces ===
Namespace(id=namespace:001, name=namespace-a)
Namespace(id=namespace:002, name=namespace-b)
Namespace(id=namespace:003, name=namespace-c)
=== Query members of namespace-a ===
[User(id=user:001, name=user-a, avatar_url=) User(id=user:002, name=user-b, avatar_url=) User(id=user:003, name=user-c, avatar_url=)]
=== Query namespaces to which user-a belongs ===
[Namespace(id=namespace:001, name=namespace-a) Namespace(id=namespace:002, name=namespace-b)]

詳細の解説はドキュメントに任せるが、ざっくりと以下のようなことをしている。

  • Create users 以下では、3つの User を bulk insert している
  • Create namespace 以下では、3つの Namespace を3つのクエリで作成している
  • Query members of namespace-a 以下ではグラフの members エッジ(スキーマedge.To() で作成したもの)を辿って、namespace-a に所属する User を全て取得する
  • Query namespaces to which user-a belongs 以下では、グラフの namespaces エッジ(スキーマedge.From() で作成したもの)を辿って、user-a が members に入っているような Namespace を全て取得する
    • 上記と同様 QueryNamespaces() を使って辿っている

このように、ent はメソッドチェインをベースにクエリを作成する。
その際用いる、各種メソッドは entc により自動生成されたものだ。

ところで、先のスキーマ定義で軽く流したところが気になっている人もいるかもしれない。
つまり、何故 edge.To() edge.From() をあのように組み合わせることで、多対多が表現できるのか、ということだ。

エッジ定義のもう少し詳細

答えを最初に書くと、そう実装されているから、だ。
つまり、edge.To() edge.From() 単体で何か意味を持つ訳ではなく、entcスキーマ定義全体を読んで、その edge.To() edge.From() の組合せによってどのような関連にするか、ということが予め決められている。

どの関連を表現したければどう書くのか、についての一覧は以下を読んでほしい。

entgo.io

ここでは実装の話をする。
スキーマから entc の内部表現としてのグラフを生成するコードは以下の部分だ。

ここのコメントに以下のようなことが書かれている。

// resolve resolves the type reference and relation of edges.
// It fails if one of the references is missing or invalid.
//
// relation definitions between A and B, where A is the owner of
// the edge and B uses this edge as a back-reference:
//
//     O2O
//      - A have a unique edge (E) to B, and B have a back-reference unique edge (E') for E.
//      - A have a unique edge (E) to A.
//
//     O2M (The "Many" side, keeps a reference to the "One" side).
//      - A have an edge (E) to B (not unique), and B doesn't have a back-reference edge for E.
//      - A have an edge (E) to B (not unique), and B have a back-reference unique edge (E') for E.
//
//     M2O (The "Many" side, holds the reference to the "One" side).
//      - A have a unique edge (E) to B, and B doesn't have a back-reference edge for E.
//      - A have a unique edge (E) to B, and B have a back-reference non-unique edge (E') for E.
//
//     M2M
//      - A have an edge (E) to B (not unique), and B have a back-reference non-unique edge (E') for E.
//      - A have an edge (E) to A (not unique).

これを読めば、どう書けばどの関係が出るのかが一目瞭然である。
用語とメソッドの関連だけ書いておくと、

  • back-reference : edge.From() で関連付けられるエッジのこと
  • unique edge : Unique() メソッドを呼ばれたエッジのこと(edge.To()edge.From() 双方から呼び出せて、エッジが unique であることを示す)

ここで何を言いたかったかと言うと、edge.From() edge.To() はあくまでコード生成機がコードを生成する際のエンティティ間の関連を明記するための記法であって、"edge.From() があるから QueryNamespace() が生えて" というような生成結果を操作するものではない、ということだ。
あくまでも edge.From() edge.To() 及び Unique() メソッド呼び出しの組合せに応じて O2O O2M M2O M2M のパターンを導出するだけのものである。

なお、現在のスキーマでエンティティ間の関連をどう解釈しているのかは、entc describe コマンドで確認できる。
// 折り返しで見辛いかもしれないが、手元で確認してほしい。

$ entc describe ./ent/schema/
Namespace:
    +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+
    | Field |  Type  | Unique | Optional | Nillable | Default | UpdateDefault | Immutable |       StructTag       | Validators |
    +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+
    | id    | string | true   | false    | false    | false   | false         | false     | json:"id,omitempty"   |          0 |
    | name  | string | true   | false    | false    | false   | false         | false     | json:"name,omitempty" |          0 |
    +-------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------+------------+
    +---------+------+---------+---------+----------+--------+----------+
    |  Edge   | Type | Inverse | BackRef | Relation | Unique | Optional |
    +---------+------+---------+---------+----------+--------+----------+
    | members | User | false   |         | M2M      | false  | true     |
    | admins  | User | false   |         | M2M      | false  | false    |
    +---------+------+---------+---------+----------+--------+----------+

User:
    +------------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    |   Field    |  Type  | Unique | Optional | Nillable | Default | UpdateDefault | Immutable |          StructTag          | Validators |
    +------------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    | id         | string | true   | false    | false    | false   | false         | false     | json:"id,omitempty"         |          0 |
    | name       | string | true   | false    | false    | false   | false         | false     | json:"name,omitempty"       |          0 |
    | avatar_url | string | false  | true     | false    | false   | false         | false     | json:"avatar_url,omitempty" |          0 |
    +------------+--------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    +------------------+-----------+---------+---------+----------+--------+----------+
    |       Edge       |   Type    | Inverse | BackRef | Relation | Unique | Optional |
    +------------------+-----------+---------+---------+----------+--------+----------+
    | namespaces       | Namespace | true    | members | M2M      | false  | true     |
    | owned_namespaces | Namespace | true    | admins  | M2M      | false  | true     |
    +------------------+-----------+---------+---------+----------+--------+----------+

とにかくこれで最低限の ent の機能紹介と、サンプルコードの作成が完了した。

その他注意点

スキーマファイルの出力先をコマンドラインオプションで変更できるが、上手く動いてくれず、いくつか手作業での修正が必要になる。
詳細は下記の Issue を読んでほしい。

まず、entc init でデフォルトではスキーマファイルは ent/schema ディレクトリに schema パッケージとして生成される。
この出力先を変更する --target オプションが存在するのだが

  • --target オプションを指定すると最初の方で説明した generate.go が生成されない
    • これは出力先を変えたのだから、entc generate のオプションも変更する必要があるはずで、それを自動で判断できないからだ
  • entc generate を動作させるためにはスキーマファイルを配置しているディレクトリ名とパッケージ名が一致していなければならない
    • 一致していない場合は entc generate で import エラーが発生しこける
  • --target オプションで指定したディレクトリ名に関わらず、生成されるスキーマファイルのパッケージ名は schema 固定である
    • .*/schema というディレクトリ以外に配置したければ、entc init で生成した後にパッケージ名をディレクトリ名に手で編集する
  • スキーマファイルを配置するディレクトリ名は ent であってはならない
    • entc が内部で使っている ent パッケージと衝突して entc generate がこける

と、いうことで --target オプションで指定するディレクトリ名は /ent で終了してはならず、それ以外なら指定は可能だが、パッケージ名を手で修正しなければならない。

例えば

sample/
  - schema/
    - database/
      - user.go // スキーマファイル
  - ent/
    - ... // entc generate によって生成されたファイル群

という配置にしたければ以下のようにする。

$ entc init --target schema/database User # schema/database/user.go が生成される
$ vim schema/database/user.go # package database に修正

そして generate.go を以下のように作成する。

  • sample/ent/generate.go
package ent

//go:generate go run github.com/facebook/ent/cmd/entc generate --target . ../schema/database

これで任意の配置にできるはずだ。

まとめ

facebook/ent の最低限の使い方と私がハマったところを解説した。

まだ使い始めたばかりなので、性能面ではどうか、や n + 1 問題はどうやって解決するのか、生成される SQL は妥当か、などまだまだ要検証な部分も残っている。
まだ読んでいないが、性能面や生成される SQL を調査してくれてそうな記事もある。

全体像として示したようなアプリを gqlgen + ent + React + Appolo client + graphql-codegen という組み合わせで鋭意作成中なので、今後はその辺りも調査していければと思う。

ざっと使った感じ、やはり GraphQL のリゾルバ実装はとても直感的に書け、CRUD している限りは gqlgen の生成結果と entc の生成結果の構造体を詰め替えるだけでほぼ頭を使わずに書けるという印象だ。
ただし、やはり ent 側は裏に RDB がいるという制約があり、GraphQL の柔軟さ(例えば Union 型)にどうやって追従するのかというところは考えないといけない。

なお、今回のサンプルコードは https://github.com/tennashi/ent-sample に置いてある。

k8s 上での Redis の永続化戦略

Redis を起動すると次のようなログが出力されることがある。

1:M 31 Aug 2020 12:36:07.554 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.

これは残メモリが少ないときにバックグラウンドでの永続化に失敗することがあるので vm.overcommit_memory = 1 としといたほうがいいぞ、という内容である。

Redis の永続化

redis.io

Redis はオンメモリデータベースとして有名であるが、それは永続化できないことを意味していない。

Redis は以下のような永続化戦略を提供している。

  • RDB: 一定期間毎にその時点でのスナップショットをファイルに保存する
  • AOF(Append Only File): 追記専用ファイルに実行したコマンドを逐次書き込み、再起動時はそれを上から順に実行する

これらの戦略のメリットデメリットや RDBMS との比較については Redis 作者の書いた以下の記事が参考になる。

oldblog.antirez.com

重要なのは、これらの戦略が fork(2) を必要とすることである。

AOF で fork(2) が必要になるのはログの最適化をするためなので、最悪失敗しても永続化としての役目を失なう訳ではないためよいが、RDB はスナップショットを取るために fork(2) を利用するため fork(2) の失敗は永続化機能としては重大な影響がある。

なぜ fork(2) の成否を気にする必要があるかというと、fork(2) をすると親プロセスが使用している仮想メモリ空間を子プロセスの仮想メモリ空間にコピーするからである。
ただし Linux では Copy-on-Write の仕組みにより、実メモリとしては親子で共有しており、書き込む瞬間だけその部分がコピーされる。が、とにかくスナップショットプロセスの走る時点の Redis が使用している仮想メモリの倍が必要になるということが重要だ。
// スナップショットはメモリ上のデータをほぼ読むだけでよいはずで、より Copy-on-Write の恩恵を受けられる。

vm.overcommit_memory

この仮想メモリ確保時の挙動を制御するのが、vm.overcommit_memory というカーネルパラメータである。

https://www.kernel.org/doc/Documentation/vm/overcommit-accounting

細かい制御までは踏み込まないが、ざっくりと以下のような挙動をする。

  • 0: ある許容量を越えない限りはオーバーコミットする
  • 1: 常にオーバーコミットする
  • 2: 常にオーバーコミットしない(確保した瞬間に実メモリを消費する)

ここで言うオーバーコミットとは物理メモリの空き容量を越えてメモリを確保することを指す。

つまり fork(2) をした瞬間にはメモリへの書き込みが発生しないと仮定すると、以下のような挙動になると推測される。
// 実際試した訳ではないので細かいところは異なるかもしれないが、だいたい、ということで...

  • 0: Redis が多くのデータを保持している場合、メモリ確保に失敗する
  • 1: 常に成功する
  • 2: Redis が実メモリの 50% 以上使用していると必ず失敗する。

オーバーコミットをする場合気を付けなければならないのが、仮想メモリは確保したものの、実メモリが足りなくて OOM Killer が走ることである。
が、ことスナップショットという処理の性質上、追加の実メモリはほとんど必要とならないはずなので、気にしなくてよい。(ので vm.overcommit_memory = 1 にしろよ、という WARNING を出している、と思われる)

k8sカーネルパラメータ

kubernetes.io

k8s 上の Pod は securityContextsysctls フィールドを利用してカーネルパラメータを指定できる。
カーネルパラメータは namespaced なものとそうでないものに分類されており、ここで指定できるのは以下の namespaced なパラメータである。

  • kernel.shm*
  • kernel.msg*
  • kernel.sem
  • fs.mqueue.*
  • net.* のうちいくつかを除いたもの

vm.* ...は...?

というわけで、今回必要な vm.overcommit_memory は Pod のフィールドで指定できない。
// なお他にも k8s 上では safe と unsafe に分類されており、上記の方法で指定するためには別途 kubelet に --allowed-unsafe-sysctls で指定する必要がある。
// 今回は話題にしていないが、これまた必要になるであろう net.core.somaxconn も unsafe なパラメータと指定されている。

namespaced でない(Node レベルの)カーネルパラメータを指定するには、Node の OS で指定するか、privileged なコンテナを持つ DaemonSet で動作させる必要がある。

これらの権限を持つ幸運な(不幸な?)人間であれば良いが、そうでない一般の人々はどうすればよいのだろうか。
また実際 vm.overcommit_memory = 1 にできたとしても Node は様々なプロセスを動作させることを考えると常にオーバーコミットされるのも OOM Killer のリスクをふまえると考えものである。

前置きが長くなってしまったが、これが本題である。

k8s と Redis 永続化

ざっくり以下のような戦略があると思われる。

  • AOF を信用する(AOF only)
  • AOF/RDB を有効にして、k8s スケジューラーに全てをまかせる
  • AOF/RDB を有効にして、Redis の maxmemory を指定しつつ、Pod のメモリ要求/制限量をその倍(+ α)に指定する

AOF を信用する

これはある種噛ませ犬的候補として用意したのだが、以下の Helm chart のデフォルト値はこの設定である。

github.com

というのも、ここまで触れてこなかった事実として、Redis の Replication による冗長化において、Replica 側にデータを受け渡すときに使うのが RDB なのである。
// experimental な機能として直接 replica 側のソケットに RDB ファイルを書き込む機能はある。が...

redis.io

つまり RDB が信用できない == Replication が信用できない、ということになる。
また、どうせ RDB ファイルを生成するのなら、両方有効化しとけばいいでしょ、という話でもある。
// もちろん頻度の問題はある、かもしれないが

もちろん、Redis が一台で Replication もしないというのであれば、これで十分だと思われる。

AOF/RDB を有効にして、k8s スケジューラに全てをまかせる

というわけで少なくともプロダクションに乗せる構成だと AOF only な選択はほとんどこの選択肢と同一であることが分かったので、こちらを考察する。

といっても結局 RDB の取得に失敗することが許容できるのかどうかである。
では、一番恐ろしい状況を想像してみる。
以下はどうだろうか。

  • 全 Node うまくメモリ使用量がバランスされており、どの Node でも Redis は通常に動作するがスナップショットの取得の度にメモリ確保に失敗して RDB が取得できない

この状況でも AOF は動作できるため、大抵の場合は問題にならない。
さらにひょんなことから、AOF 書き込み中に Redis が死んでしまい、中途半端な AOF が生成されてしまった。

このときの挙動は aof-load-truncated yes であれば自動で修復(壊れた部分は落とす)されるし、それが嫌であれば no を指定した上で k8s に auto restart をさせなければよいだろう。

つまりこの選択は

  • 最悪でも(AOF のパースにバグがなければ)最新数回のクエリが失なわれる
  • Replication が機能しない状態も想定しておく
    • Node 全体が本当に常に逼迫しているようであれば repl-diskless-sync yes にすることも検討

ということになるかと思われる。

AOF/RDB を有効にして、Redis の maxmemory を指定しつつ、Pod のメモリ要求/制限量をその倍(+ α)に指定する

これは、RDB が動作できる環境を手で保証しようとする試みである。

もちろんバーストしたときには守られないが、それはそもそも Redis 自体の書き込みで死ぬのとそうリスクとしては変わらないことを考えて許容したい。

この選択肢を取るにはまずはリソース使用量の計測をしなければならない、が k8s を使っているところはまず exporter を導入し、メモリ使用量などを取得できることは前提としてよいであろう。

その上で考慮すべきはメモリ使用量の分散かと考える。
分散が大きいのであれば、ベストエフォートにしておけばよいし、分散が小さく安定しているのであれば、上限値を基準にリソース制限/要求を設定すればよい。

ここまでくると本質的には通常の Pod のリソース制限/要求をどのように定めるのかと同じ議論が適用できるかと思うので、今回はここまでにしておこうと思う。

今のところだと私は AOF/RDB をベストエフォートで動作させつつ、メトリクスを取得し、トレンドが見えたらリソース制限/要求を指定する、という(非常に無難かつありきたりな)方針でやるかなぁと思う。

調査しきれてない部分や不正確な部分、他の選択肢などあると思うが、そも sysctl が触れない状況下での永続化をどのように考えるかについて書いた資料が見当らなかったので、私のぱっと考えたことを垂れ流す次第である。 まぁ叩き台だと思っていろいろ意見いただけるとありがたいところである。

LSP 実装メモ(gopls cache `Cache` `Session` 編)

前回

tennashi.hatenablog.com

今回から少し gopls の cache 機構を集中して読んでいく。
というのも結局 LSP サーバの実装の肝は

  • クライアントから受け取った TextDocument の中身をいつパースするか
  • どのようにパースするか
  • どのように保持するか
  • どうやって TextDocument の中身にアクセスするか

なはずで、gopls の場合この cache 機構を読み解くことがこの大部分の理解に繋がると思われるからである。

cache 機構に関連して参考になるドキュメントは以下である。

github.com

gopls の cache は 3層になっており、それぞれ

  • Cache
  • Session
  • View

と名付けられており、各々が互いに参照及び構築できる。

各々について、もう少し詳細を見ていく。

Cache

cache の最下層にある構造。
ファイルシステムやそのコンテンツの情報などのグローバルな情報を保持する、らしいがよく分からないので、gopls 内の実装も読んでみる。

型定義

Cache 型は以下のように定義されている。

type Cache struct {
    id      string
    fset    *token.FileSet
    options func(*source.Options)

    store memoize.Store

    fileMu      sync.Mutex
    fileContent map[span.URI]*fileHandle
}

メソッド

また、この型の公開されたメソッドは以下である。

func (c *Cache) GetFile(ctx context.Context, uri span.URI) (source.FileHandle, error)
func (c *Cache) NewSession(ctx context.Context) *Session
func (c *Cache) FileSet() *token.FileSet

他にも MemStats()/PackageStats()/ID() というメソッドが存在するが debug ページの表示にしか使われていないため除外する。 // cf. https://github.com/golang/tools/tree/master/internal/lsp/debug

source.FileHandle

GetFile() に返される source.FileHandle とは次で定義される interface 型である。

type FileHandle interface {
    URI() span.URI
    Kind() FileKind
    FileIdentity() FileIdentity
    Read() ([]byte, error)
}

FileKind は対象のファイルが .go ファイルなのか go.mod なのか go.sum なのかを区別するための型で、FileIdentityURI とその中身の Hash を保持し、テキストを特定するための型である。
また Read() でファイルの中身を []byte で取り出せる。
つまりファイルそのものであると思える。

token.FileSet

cache のメソッドの解説に戻り、FileSet() が返す token.FileSet 型はこの手のソースコードを解析するコードを書く人にはおなじみの型で、複数ファイルのファイル上での位置情報を保持してくれる型である。
位置情報の参照がしたいときに使う、はず。
// なお私はそういうコードまだあまり書かないので別におなじみではない。

CacheFileSet() メソッドで token.FileSet を返すが、これは保持しているだけで、FileSetFile を追加するなどの操作をするのは、呼び出し側の仕事のようである。

GetFile()

CacheGetFile() の実装を読んでみる。

func (c *Cache) GetFile(ctx context.Context, uri span.URI) (source.FileHandle, error) {
    fi, statErr := os.Stat(uri.Filename())
    if statErr != nil {
        return &fileHandle{err: statErr}, nil
    }

    fh, ok := c.fileContent[uri]
    if ok && fh.modTime.Equal(fi.ModTime()) {
        return fh, nil
    }

    fh = readFile(ctx, uri, fi.ModTime())

    c.fileContent[uri] = fh

    return fh, nil
}

func readFile(ctx context.Context, uri span.URI, modTime time.Time) *fileHandle {
    data, err := ioutil.ReadFile(uri.Filename())
    if err != nil {
        return &fileHandle{
            modTime: modTime,
            err:     err,
        }
    }

    return &fileHandle{
        modTime: modTime,
        uri:     uri,
        bytes:   data,
        hash:    hashContents(data),
    }
}

これは並行処理のための Lock などを省略したコードだが、やっていることは同じ、はず。

  • 指定されたファイルが存在しなければ即時エラーを格納して返却
  • cache に存在すれば、ディスク上のファイルと最終更新日時を比較して一致していればそれを返す
  • 存在しなければ、ディスクからファイルを読み込み、メモリに cache する

ここまでの内容から CacheGetFile() されたファイルの内容と token.FileSet を保持する素朴な意味での cache であることが分かる。

Session

Session はエディタへのコネクション情報を保持する。
例えば、編集したファイルを(overlays という名前で) 保持している。
らしいがこれまた読んでもよく分からんので定義をあさる。

これは internal/lsp/source パッケージに interface が定義され、実装は internal/lsp/cache パッケージに存在する。

型定義

まずは internal/lsp/source パッケージにある interface 定義。

type Session interface {
    NewView(ctx context.Context, name string, folder span.URI, options Options) (View, Snapshot, func(), error)
    View(name string) View
    ViewOf(uri span.URI) (View, error)
    Views() []View
    Shutdown(ctx context.Context)
    GetFile(ctx context.Context, uri span.URI) (FileHandle, error)
    DidModifyFiles(ctx context.Context, changes []FileModification) ([]Snapshot, []func(), []span.URI, error)
    Overlays() []Overlay
    Options() Options
    SetOptions(Options)
}

SessionCacheNewSession() から生成され、自身を生成した Cache を保持することが期待されており、その Cache を生で操作するための Cache() というメソッドもあるが、これは debug 用とのことなので省略した。

NewView()DidModifyFiles() で取得される Snapshot 型は View を生成した瞬間、やファイルを変更した瞬間のスナップショットなのだが、これは恐らく利便性のためにここで返しているだけで、本来は View との関連が強い型なので詳細は View の解説の中で紹介する。

この実装である cache.Session 型の定義は以下。

type Session struct {
    cache *Cache
    id    string

    options source.Options

    viewMu  sync.Mutex
    views   []*View
    viewMap map[span.URI]*View

    overlayMu sync.Mutex
    overlays  map[span.URI]*overlay

    gocmdRunner *gocommand.Runner
}

Server 構造体が保持するのはこの Session 型(正確には source.Session interface)であり、Cache は間接的に操作される。

Overlay

Overlays() で返される Overlay 型は以下で定義される interface 型である。

type Overlay interface {
    VersionedFileHandle
    Saved() bool
}

VersionedFileHandle は前述の FileHandle にバージョン情報を付加したものである。

ここで、internal/lsp/cache パッケージ側の実装を読むと、Overlay が何であるかが少し分かる。

source.Session interface の実装である。cache.Session 型は overlays という map[span.URI]*overlay 型のフィールドを持つ。
この overlay 型が source.Overlay interface を満たす、internal/lsp/cache パッケージ内の実装である。

このフィールドに overlay が追加するためのメソッドは updateOverlays() であり、これは DidModifyFiles() からのみ呼ばれる。

updateOverlays() ではファイルへの変更をスライスで受け取り、最新のファイルの中身を overlays に更新している。
さらに、処理をよく読むと、変更種別が削除/保存でないときは s.cache.getFile() が呼ばれている。

つまり、

  • overlays には LSP クライアントから送信された TextDocument の中身
  • Cache には指定された URI のディスク上の中身

が保持されている。

もうすこし言葉を加えると、overlays は LSP でやりとりされる TextDocument の cache であり、Cache はそれに限らないディスク上のファイルの cache である。
なお、ここでもまだ Go ファイルとしてのパースは行われていない。

疲れたので今日はここまで。

次回は View を読む。

-- 追記(2020/09/06) -- かいた

tennashi.hatenablog.com

LSP 実装メモ (Text Document Synchronization `textDocument/didClose` 編)

前回

tennashi.hatenablog.com

引き続き、Text Document Synchronization 周りの仕様について書いていく。

  • textDocument/didOpen 通知 <- done
  • textDocument/didChange 通知 <- done
  • textDocument/willSave 通知 <- done
  • textDocument/willSaveWaitUntil リクエスト <- done
  • textDocument/didSave 通知 <- done
  • textDocument/didClose 通知 <- 今日ここ

Text Document Synchronization 編、感動の最終回。

textDocument/didClose 通知

サーバに TextDocument を閉じたことを伝えるための通知。

通知パラメータもシンプルに TextDocumentIdentifier が送信されてくるのみである。

interface DidCloseTextDocumentParams {
    textDocument: TextDocumentIdentifier;
}

textDocument/didOpen 通知で触れていたように、制約として、必ず textDocument/didClose 通知の前に textDocument/didOpen 通知が送信されてなければならない。

tennashi.hatenablog.com

なお textDocument/didOpen 回でよく分かっていなかった以下の文章だが、今回の textDocument/didClose 通知の仕様にも記述されている。

microsoft.github.io

Note that a server’s ability to fulfill requests is independent of whether a text document is open or closed.

requests と書かれていることに今さら気付き、この文章は恐らく、他のリクエスト(例えば補完リクエストなど)には、事前に textDocument/didOpen していなければならない、とか textDocument/didClose された TextDocument に対してリクエストしてはならない、といった制約は無いぞ、と言っているだけのようである。
// とはいえ、意味のある情報を返せるかどうかは別だとは思う。

サーバ実装例

を読んでいく。

efm-langserver/sqls

ハンドラはここ。

中で closeFile() を呼んでおり、

中身は Go 組み込みの delete() 関数である。
これは削除対象のキーが map に存在しない場合でもエラーや panic したりはしない。
つまり事前に mapURI が登録されていなくても(つまり事前に textDocument/didOpen 通知が送信されていなくても)単純に無視する。

LSP の仕様は、クライアントが必ず textDocument/didOpentextDocument/didClose を対にして送信しろ、と言っているだけで、クライアントがそれに反した通知をサーバに送信してきたときには何も規定されていないためこれで十分なはずである。

sqls もほぼ同一の実装である。

gopls

gopls では TextDocument の中身を nil として変更する、という方針のようである。

...と、(分かってはいたし、何度も言及したが) didModifyFiles() の実装をちゃんと読まん限りあんまり gopls を読んでも面白くないので、次回は didModifyFiles() から gopls のキャッシュ構造を追う回にします。

Capability

ここまで部分部分でも触れていたが、Text Document Synchronization 周りをひとしきり終えたので、ここでクライアントとサーバが事前に互いの機能を通知し合う initialize リクエストで送信されるパラメータを一覧しておく。

クライアント側

クライアントが対応している機能を通知するための型は以下である。

export interface TextDocumentSyncClientCapabilities {
    dynamicRegistration?: boolean;
    willSave?: boolean;
    willSaveWaitUntil?: boolean;
    didSave?: boolean;
}

dynamicRegistration については今回割愛するが、他の項目は各通知を送信するかどうかについての boolean である。
textDocument/didOpen textDocument/didClose textDocument/didChange(TextDocumentSyncKind == Full TextDocumentSyncKind == Incremental 双方含む) についてはクライアントは必ず実装しなければならないため、ここには含まれない。

textDocument/willSave textDocument/willSaveWaitUntil textDocument/didSave についてはクライアントは実装してもしなくてもよいため、自身が実装しているかどうかをサーバに通知する。

vim-lsp では textDocument/willSave textDocument/willSaveWaitUntil` は実装されていない。

textDocument/didSave については デフォルトではサーバ側に通知していないが、サーバが willSave を実装していないと主張しない(後で出てくる TextDocumentSyncOptionssave フィールド参照)限りは 送信するようになっている
// 一応 PR チャンス?

サーバ側

サーバが対応している機能を通知するための型は以下。

export namespace TextDocumentSyncKind {
    export const None = 0;
    export const Full = 1;
    export const Incremental = 2;
}

export interface SaveOptions {
    includeText?: boolean;
}

export interface TextDocumentSyncOptions {
    openClose?: boolean;
    change?: number;
    willSave?: boolean;
    willSaveWaitUntil?: boolean;
    save?: boolean | SaveOptions;
}

textDocument/didOpen textDocument/didClose textDocument/didChange はサーバ側は実装しなくてもよい。
ただし、仕様上、全て実装するか、全て実装しないかのどちらかしか指定してはならないと決められている。
textDocument/didChangeTextDocumentSyncKind を指定する都合でフィールドが分かれているが、openClose == false && change == None のような指定は禁止されている。

textDocument/willSave textDocument/willSaveWaitUntil textDocument/didSave は実装が任意なのでそれぞれフィールドが用意されており、textDocument/didSave はさらに通知パラメータの text フィールドを参照するかどうかを指定する includeText が指定できる。

ややこしいのが、実際リクエストパラメータに指定できるのは TextDocumentSyncOptions | number であることだ。

number とは TextDocumentSyncKind を直接指定することを示しており、この指定方法の場合、openClose は仕様から判断できる(None なら openClose == false それ以外なら openClose == true)、が willSave willSaveWaitUntil save についてはクライアントに判断が任される(仕様としてどう判断するか定められていない)。

実際 vim-lsp では、"save": { "includeText" : false } と指定されたものとして処理される(willSave willSaveWaitUntilvim-lsp が対応していない)。

通知/リクエスト(textDocument/ は省略) gopls efm-langserver sqls
didOpen didClose o o o
didChange Incremental Full Full
willSave x -(クライアント実装に依存) -(クライアント実装に依存)
willSaveWaitUntil x -(クライアント実装に依存) -(クライアント実装に依存)
didSave o(includeText == false) -(クライアント実装に依存) -(クライアント実装に依存)

まとめ

これで Text Document Synchronization の仕様に関連する通知/リクエストの仕様は終わりである。

  • didOpen -> didChange -> (willSave) (willSaveWaitUntil) -> (didSave) -> didClose という流れで TextDocument のライフサイクルが管理されている
  • サーバは TextDocument の URI を直接開くことは禁止されているので、効率的に TextDocument の更新を受けたければ TextDocumentSyncKind.Incremental を実装する

ここまででは TextDocument の中身をどう利用するのか(いつパースする? どう TextDocument の中身を持ち続ける? どういうクエリで TextDocument から情報を取り出す?) については触れてこなかったので、これからは一旦そのあたりを中心にして読んでいきたい。

次回、手始めに gopls のキャッシュ周りを調査する。

-- 追記(2020/08/30) -- かいた

tennashi.hatenablog.com