この記事はCraft Egg Advent Calendar 2021の12日目の記事です。
昨日は@kawase_ikutaさんの「スクラムっぽいことをやって感じたこと」でした。
はじめに
チャットボットや社内ツールとして細々と導入し始めていたGo言語ですが、現在は新規プロジェクトのメイン言語となるなど、社内で使われる機会が少しずつ増えつつあります。
本記事では、そんな社内ツールを作成する際にちょうど最近気になって実装を読んだgo doc
コマンドの仕組みについて解説していこうと思います。
ちなみに今回実装を読んだGo 1.17.3時点でgo doc
に関するコードはテストコードを除くと1500行弱です。
年末年始のお供としてもちょうどよいボリューム感なのでぜひみなさんも読んでみてください。
go doc
コマンドの使い方
まずはgo doc
コマンドの使い方について軽くおさらいしておきます。
ヘルプを見てみましょう。
$ go doc -h
Usage of [go] doc:
go doc
go doc <pkg>
go doc <sym>[.<methodOrField>]
go doc [<pkg>.]<sym>[.<methodOrField>]
go doc [<pkg>.][<sym>.]<methodOrField>
go doc <pkg> <sym>[.<methodOrField>]
For more information run
go help doc
Flags:
-all
show all documentation for package
-c symbol matching honors case (paths not affected)
-cmd
show symbols with package docs even if package is a command
-short
one-line representation for each symbol
-src
show source code for symbol
-u show unexported symbols as well as exported
例えばstringsパッケージのSplit
関数に関するGoDocは次のようなコマンドで確認可能です。
$ go doc strings.Split # または go doc strings Split
package strings // import "strings"
func Split(s, sep string) []string
Split slices s into all substrings separated by sep and returns a slice of
the substrings between those separators.
If s does not contain sep and sep is not empty, Split returns a slice of
length 1 whose only element is s.
If sep is empty, Split splits after each UTF-8 sequence. If both s and sep
are empty, Split returns an empty slice.
It is equivalent to SplitN with a count of -1.
また、ヘルプにもあるように例えば-src
フラグでソースコードを見るといったこともできるようになっています。
$ go doc -src strings.Split
package strings // import "strings"
// Split slices s into all substrings separated by sep and returns a slice of
// the substrings between those separators.
//
// If s does not contain sep and sep is not empty, Split returns a
// slice of length 1 whose only element is s.
//
// If sep is empty, Split splits after each UTF-8 sequence. If both s
// and sep are empty, Split returns an empty slice.
//
// It is equivalent to SplitN with a count of -1.
func Split(s, sep string) []string { return genSplit(s, sep, 0, -1) }
このように、go doc
コマンドはパッケージやそこで定義されているものに関するGoDocや実装を確認できるCLIツールです。
ちなみにGoDocの書き方等はこちらの記事が詳しいです。
go doc
の中身
雑に一言でまとめるとgo doc
コマンドはローカルのファイルシステムから必要な情報を取得して表示する仕組みです。
これを実現するためのポイントとなる部分をここからいくつか見ていこうと思います。
走査対象ディレクトリの列挙
go doc
コマンドが必要な情報を探す際、全てのディレクトリを走査するわけではありません。
以下のようにいくつかの設定を考慮して走査対象のディレクトリ群をコマンド実行時に事前に列挙するようになっています。
GOPATHモードとmodule-awareモード
現在はGo Modulesを使っていることが多いかと思いますが、もちろんGOPATHモードでも動くようになっています。
両者の違いはコマンド実行時に列挙されるディレクトリのリストアップ方法です。
GOPATHモードではシンプルで、GOROOT/srcおよび各GOPATHが走査対象となります。
一方で、module-awareモードではVendoringが有効かどうかも気にする必要があります。
共通して対象に追加されるディレクトリはGOROOT/src, GOROOT/src/cmd, モジュールルートで、Vendoringが無効の場合はgo.modに記載されている各パッケージに対応したディレクトリ1が対象として追加され、Vendoringが有効の場合はモジュールルート以下のvendorディレクトリが対象として追加されます。
例えば ~/demo/go.mod が下記の場合、
module demo
go 1.17
require golang.org/x/mod v0.5.1
モードおよびVendoring on/offの違いによる対象の違いは下記のようになります。
モード | Vendoring | 対象 |
---|---|---|
GOPATH | on/off | GOROOT/src, 各GOPATH |
module-aware | on | GOROOT/src, GOROOT/src/cmd, ~/demo/, ~/demo/vendor |
module-aware | off | GOROOT/src, GOROOT/src/cmd, ~/demo/, GOMODCACHE/golang.org/x/mod@v0.5.1 |
モードの判定
モードの判定にはgo env GOMOD
の結果を利用しています。ドキュメントにも記載されていますが、go env GOMOD
の結果で判定を切り分ける際、module-awareモードかつgo.modが存在しない場合には/dev/null
が返され、module-awareモードが無効な場合は空文字列が返されるので注意が必要です。
Vendoring
-
GOFLAGS
環境変数に-mod=vendor
が含まれている場合 - vendorディレクトリが存在していてかつGo1.14以上
のいずれかの場合はVendoringが有効とみなされます。
go list
モジュールのインポートパスとそれに対応するディレクトリ情報の取得はgolang.org/x/modなどを使っているのかな?と思っていましたが、実際は素朴にgo list
コマンドの結果をパースして取得しているようでした。
cmd := exec.Command("go", "list", "-m", "-f={{.Path}}\t{{.Dir}}", "all")
こちらはモジュールや利用しているGoバージョンに関する情報を取得している部分。
const format = `{{.Path}}
{{.Dir}}
{{.GoVersion}}
{{range context.ReleaseTags}}{{if eq . "go1.14"}}{{.}}{{end}}{{end}}
`
cmd := exec.Command("go", "list", "-m", "-f", format)
対象ディレクトリの走査
Dirs の仕組み
走査したディレクトリを保持するためのデータ構造としてDirs
が定義されています。
type Dirs struct {
scan chan Dir // Directories generated by walk.
hist []Dir // History of reported Dirs.
offset int // Counter for Next.
}
(*Dirs).Next
メソッドではoffsetを一つすすめ、すでにhistにキャッシュされたディレクトリであればそれを返し、そうでなければ別のゴルーチンによって走査された結果が送られてくるscanから新たに受け取ってhistに詰めつつ返すようになっています。
func (d *Dirs) Next() (Dir, bool) {
if d.offset < len(d.hist) {
dir := d.hist[d.offset]
d.offset++
return dir, true
}
dir, ok := <-d.scan
if !ok {
return Dir{}, false
}
d.hist = append(d.hist, dir)
d.offset++
return dir, ok
}
これによって、同じディレクトリの走査は一度のみになるといった工夫がなされています。
走査アルゴリズム
肝心の走査方法ですが、幅優先探索2でディレクトリを走査しています。
幅優先探索そのものに関する詳細は割愛しますが、以下の順番で走査されるようになっています。
.
├── io①
│ └── fs④
├── net②
│ └── http⑤
│ └── httptest⑥
└── strings③
パッケージ情報の取得と結果の表示
走査の結果得られるディレクトリ情報はインポートパスと絶対パスのみなので、詳細を取得する必要があります。具体的には、
*build.Package
*ast.Package
*doc.Package
*Package
といった流れで変換されており、ここまで来るとあとは整形して表示するのみです。(このあたりは一つずつ実装していくのみなのであまり解説ポイントがありませんでした...😥)
終わりに
駆け足ではありましたが、go doc
の実装におけるいくつかのポイントについて解説してみました。
実装を読み進めたことでgo doc
コマンドだけでなくGo Modulesや静的解析などの周辺領域の理解も深まったように感じます。
また、cmd/docに関するissueやCLで行われている議論もすんなり理解できるようになりました。
各所で頻繁に触れられている通り、Go言語の標準パッケージには非常に読みやすく学びの多いものがいくつもあります。
ぜひ皆さんもこれを機に読んでみてください!
明日は内定者アルバイトとして活躍してくれている @reo_chocsar の「エディターのショートカットとデバッグ機能を全く使ってこなかったので、エンジニアになって痛い目を見た話」です。お楽しみに
参考
後日談?
cmd/docのコードを読みすすめる中で一部気になる挙動を発見しました。
些細な違いですが、利用しているファイルシステムに依存してgo doc
コマンドの結果が変わりうるようです。
意図した挙動ではないかもしれないということでissueでレポートしてみています。
-
replaceディレクティブなどを利用していなければGOMODCACHE(デフォルトではGOPATH/pkg/mod)以下の対応するディレクトになる ↩
-
https://ja.wikipedia.org/wiki/%E5%B9%85%E5%84%AA%E5%85%88%E6%8E%A2%E7%B4%A2 ↩