go doc コマンドを知る


この記事は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/[email protected]

モードの判定

モードの判定には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③

パッケージ情報の取得と結果の表示

走査の結果得られるディレクトリ情報はインポートパスと絶対パスのみなので、詳細を取得する必要があります。具体的には、

  1. *build.Package
  2. *ast.Package
  3. *doc.Package
  4. *Package

といった流れで変換されており、ここまで来るとあとは整形して表示するのみです。(このあたりは一つずつ実装していくのみなのであまり解説ポイントがありませんでした...😥)

終わりに

駆け足ではありましたが、go docの実装におけるいくつかのポイントについて解説してみました。
実装を読み進めたことでgo docコマンドだけでなくGo Modulesや静的解析などの周辺領域の理解も深まったように感じます。
また、cmd/docに関するissueやCLで行われている議論もすんなり理解できるようになりました。

各所で頻繁に触れられている通り、Go言語の標準パッケージには非常に読みやすく学びの多いものがいくつもあります。
ぜひ皆さんもこれを機に読んでみてください!

明日は内定者アルバイトとして活躍してくれている @reo_chocsar の「エディターのショートカットとデバッグ機能を全く使ってこなかったので、エンジニアになって痛い目を見た話」です。お楽しみに

参考

後日談?

cmd/docのコードを読みすすめる中で一部気になる挙動を発見しました。
些細な違いですが、利用しているファイルシステムに依存してgo docコマンドの結果が変わりうるようです。
意図した挙動ではないかもしれないということでissueでレポートしてみています。


  1. replaceディレクティブなどを利用していなければGOMODCACHE(デフォルトではGOPATH/pkg/mod)以下の対応するディレクトになる 

  2. https://ja.wikipedia.org/wiki/%E5%B9%85%E5%84%AA%E5%85%88%E6%8E%A2%E7%B4%A2