LSP 実装メモ(gopls cache `Snapshot` 編)
前回
先週はやる気が消失したためお休みした。
前回のまとめ
ひたすらに (*Session).NewView()
からの呼び出しを追っていった。
今回は残った (*snapshot).load()
メソッドの処理を読む。
が、全て詳細に読むと長くなりすぎてしまうので、さっくりと読んだ後、Snapshot
は何をするためのものなのかを追う。
(*snapshot).load()
このメソッドは以下の部分に分割できる。
packages.Load()
に渡す引数の準備packages.Load()
の呼び出し- 返り値から必要な情報を抽出して、cache データを構築する
packages.Load()
は第一引数にどう package を parse するかなどのコンフィグを取り、第二引数に parse する package をパターン文字列で指定する。
これらの準備を以下の部分で行っている。
そして packages.Load()
を呼び、以下の部分で parse した package から情報を cache に溜めていく。
このとき cache としての肝となるのは例えば以下のような x/tools/internal/memoize
package を使った部分である。
例えば上記では、builtin package のファイルを parse した結果を s.generation.Bind()
メソッドで登録しておき、s.builtin
フィールドに保持している。
必要になれば s.builtin.handle.Get()
メソッド呼び出しをすれば登録されたハンドラの処理結果を取得できるという訳だ。
x/tools/internal/memoize
package については以下を参照。
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` 生成詳解編)
前回
引き続き、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 なファイルシステムをどうにか扱うために以下のコミットで追加された処理だ。
ファイルシステム上は Hoge/Fuga.go
という名前で保存されたファイルをエディタが hoge/fuga.go
などのファイル名で送信してきたとき、gopls では扱えなくなるので、エラーにして叩き落とすという処理をしている。
macOS(darwin) と Windows のみ意味のある処理をしており、それ以外の OS では何もせず nil
を返す。
これはファイルシステムの問題であり、OS の問題ではないので、Linux で case-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
を埋めるかどうかを決める。
ExpandWorkspaceToModule
は gopls
が現在開こうとしているディレクトリ(== ワークスペース)からそれを含むモジュール全体へとスコープを拡張するための設定値だ。
これを true
にすることで現在開こうとしているディレクトリを含む module からさらにディレクトリ探索をして go.mod ファイルの位置なども cache するように動作する。
monorepo で特に便利になる。
cf. https://github.com/golang/tools/blob/97363e29fc9b716e0d1e7c28a1098c5db06248f6/internal/lsp/source/options.go#L334-L337
TempModfile
が false
か 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()
関数の処理を自前で用意した実行ファイルに置き替えるための変数である。
詳細は以下を読むとよい。
(*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()
シグネチャは以下。
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) -- かいた
LSP 実装メモ(gopls cache `View` 生成編)
前回
引き続き、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 メソッドと対応している。
workspace/didChangeWorkspaceFolders
initialized
- クライアントからサーバへ、対応機能の合意を取るための
initialize
というリクエストがあるのだが、このレスポンスをクライアントが受け取ったことをサーバへ通知する
- クライアントからサーバへ、対応機能の合意を取るための
textDocument/didOpen
とにかく、クライアントが何か 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
全体の訳は書かないが、適当にかいつまむと、
- さまざまなツールがワークスペース毎に複数のルートフォルダをサポートする
- このような機能を持つエディタが複数のワークスペースフォルダを開くときには
InitializeParam
でworkspaceFolders
に入れてくれ
細かいニュアンスは原文を参照してもらいたいが、ワークスペースのルートフォルダ == ワークスペースフォルダと読める。
先の 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 をご存知だろうか。
これは facebook connectivity の中の人が書いている ORM で以下のコンセプトを持つ。 // facebook connectivity はどうやらネットワークの会社っぽい...?
- Schema As Code
- DB スキーマは Go のオブジェクトとして宣言される
- Easily Traverse Any Graph
- グラフ構造を持つデータに対するクエリ、集約、走査を簡単にする
- Statically Typed And Explicit API
- コード生成により静的に型付けされており、API は明示的である
- Multi Storage Driver
- MySQL、PostgreSQL、SQLite、Gremlin をサポートする
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 のようなものだと思ってくれればよい。
User
はNamespace
に所属するNamespace
には少なくとも一人の管理者が存在し、管理者のみユーザをメンバーに追加できる。Namespace
はWorker
を複数持つ- タスクは実行ターゲットとして
Worker
を登録する - タスクにはワンライナーを実行する
CommandTask
とユーザが記述したシェルスクリプトを実行するScriptTask
が存在する
大して ent の Quick Introduction の例以上の面白みがある訳ではないが、そこはまぁやってみた記事のご愛嬌ということで。
全体像はそんなところで、この記事では ent を使って左上部分 User
と Namespace
間の実装を書くことを目的とする。
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/
同様に Namespace
は ID
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 }
エッジの定義
次に User
と Namespace
間の関連(エッジ)を定義する。
Namespace
とUser
はmembers
という多対多の関連を持つNamespace
とUser
はadmins
という多対多の関連を持つ
このことを以下のように記述する。
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 している- フィールドを埋めるのは
SetID()
やSetName()
メソッドをチェインさせて行なう - https://entgo.io/docs/crud/#create-many
- フィールドを埋めるのは
Create namespace
以下では、3つのNamespace
を3つのクエリで作成している- 関連するオブジェクトは
AddMembers()
やAddAdmins()
などで登録する - https://entgo.io/docs/crud/#create-an-entity
- 関連するオブジェクトは
Query members of namespace-a
以下ではグラフのmembers
エッジ(スキーマでedge.To()
で作成したもの)を辿って、namespace-a に所属するUser
を全て取得するQueryMembers()
でmembers
エッジを辿っている- https://entgo.io/docs/crud/#query-the-graph
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()
の組合せによってどのような関連にするか、ということが予め決められている。
どの関連を表現したければどう書くのか、についての一覧は以下を読んでほしい。
ここでは実装の話をする。
スキーマから 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
固定である- スキーマファイルを配置するディレクトリ名は
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 はオンメモリデータベースとして有名であるが、それは永続化できないことを意味していない。
Redis は以下のような永続化戦略を提供している。
- RDB: 一定期間毎にその時点でのスナップショットをファイルに保存する
- AOF(Append Only File): 追記専用ファイルに実行したコマンドを逐次書き込み、再起動時はそれを上から順に実行する
これらの戦略のメリットデメリットや RDBMS との比較については Redis 作者の書いた以下の記事が参考になる。
重要なのは、これらの戦略が 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 とカーネルパラメータ
k8s 上の Pod は securityContext
の sysctls
フィールドを利用してカーネルパラメータを指定できる。
カーネルパラメータは 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 のデフォルト値はこの設定である。
というのも、ここまで触れてこなかった事実として、Redis の Replication による冗長化において、Replica 側にデータを受け渡すときに使うのが RDB なのである。
// experimental な機能として直接 replica 側のソケットに RDB ファイルを書き込む機能はある。が...
つまり 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
にすることも検討
- Node 全体が本当に常に逼迫しているようであれば
ということになるかと思われる。
AOF/RDB を有効にして、Redis の maxmemory
を指定しつつ、Pod のメモリ要求/制限量をその倍(+ α)に指定する
これは、RDB が動作できる環境を手で保証しようとする試みである。
もちろんバーストしたときには守られないが、それはそもそも Redis 自体の書き込みで死ぬのとそうリスクとしては変わらないことを考えて許容したい。
この選択肢を取るにはまずはリソース使用量の計測をしなければならない、が k8s を使っているところはまず exporter を導入し、メモリ使用量などを取得できることは前提としてよいであろう。
その上で考慮すべきはメモリ使用量の分散かと考える。
分散が大きいのであれば、ベストエフォートにしておけばよいし、分散が小さく安定しているのであれば、上限値を基準にリソース制限/要求を設定すればよい。
ここまでくると本質的には通常の Pod のリソース制限/要求をどのように定めるのかと同じ議論が適用できるかと思うので、今回はここまでにしておこうと思う。
今のところだと私は AOF/RDB をベストエフォートで動作させつつ、メトリクスを取得し、トレンドが見えたらリソース制限/要求を指定する、という(非常に無難かつありきたりな)方針でやるかなぁと思う。
調査しきれてない部分や不正確な部分、他の選択肢などあると思うが、そも sysctl が触れない状況下での永続化をどのように考えるかについて書いた資料が見当らなかったので、私のぱっと考えたことを垂れ流す次第である。 まぁ叩き台だと思っていろいろ意見いただけるとありがたいところである。
LSP 実装メモ(gopls cache `Cache` `Session` 編)
前回
今回から少し gopls の cache 機構を集中して読んでいく。
というのも結局 LSP サーバの実装の肝は
- クライアントから受け取った TextDocument の中身をいつパースするか
- どのようにパースするか
- どのように保持するか
- どうやって TextDocument の中身にアクセスするか
なはずで、gopls の場合この cache 機構を読み解くことがこの大部分の理解に繋がると思われるからである。
cache 機構に関連して参考になるドキュメントは以下である。
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
なのかを区別するための型で、FileIdentity
は URI とその中身の Hash を保持し、テキストを特定するための型である。
また Read()
でファイルの中身を []byte
で取り出せる。
つまりファイルそのものであると思える。
token.FileSet
型
cache のメソッドの解説に戻り、FileSet()
が返す token.FileSet
型はこの手のソースコードを解析するコードを書く人にはおなじみの型で、複数ファイルのファイル上での位置情報を保持してくれる型である。
位置情報の参照がしたいときに使う、はず。
// なお私はそういうコードまだあまり書かないので別におなじみではない。
Cache
は FileSet()
メソッドで token.FileSet
を返すが、これは保持しているだけで、FileSet
に File
を追加するなどの操作をするのは、呼び出し側の仕事のようである。
GetFile()
Cache
の GetFile()
の実装を読んでみる。
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 する
ここまでの内容から Cache
は GetFile()
されたファイルの内容と 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) }
Session
は Cache
の NewSession()
から生成され、自身を生成した 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) -- かいた
LSP 実装メモ (Text Document Synchronization `textDocument/didClose` 編)
前回
引き続き、Text Document Synchronization 周りの仕様について書いていく。
textDocument/didOpen
通知 <- donetextDocument/didChange
通知 <- donetextDocument/willSave
通知 <- donetextDocument/willSaveWaitUntil
リクエスト <- donetextDocument/didSave
通知 <- donetextDocument/didClose
通知 <- 今日ここ
Text Document Synchronization 編、感動の最終回。
textDocument/didClose
通知
サーバに TextDocument を閉じたことを伝えるための通知。
通知パラメータもシンプルに TextDocumentIdentifier
が送信されてくるのみである。
interface DidCloseTextDocumentParams { textDocument: TextDocumentIdentifier; }
textDocument/didOpen
通知で触れていたように、制約として、必ず textDocument/didClose
通知の前に textDocument/didOpen
通知が送信されてなければならない。
なお textDocument/didOpen
回でよく分かっていなかった以下の文章だが、今回の textDocument/didClose
通知の仕様にも記述されている。
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 したりはしない。
つまり事前に map
に URI が登録されていなくても(つまり事前に textDocument/didOpen
通知が送信されていなくても)単純に無視する。
LSP の仕様は、クライアントが必ず textDocument/didOpen
と textDocument/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
を実装していないと主張しない(後で出てくる TextDocumentSyncOptions
の save
フィールド参照)限りは 送信するようになっている。
// 一応 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/didChange
は TextDocumentSyncKind
を指定する都合でフィールドが分かれているが、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
willSaveWaitUntil
は vim-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) -- かいた