この記事は Go 4 Advent Calendar 2020 の23日目の記事ですʕ◔ϖ◔ʔ
普段は業務でC#を書いたり、CI/CD基盤の構築に頭を悩ませたり、ちょっとしたツールやWebサービスをGoで書いて人や組織の隙間を埋めるお仕事をしています、kemokemoと申します。お初にお目にかかる方、はじめまして。お久しぶりの方、お元気ですか?
Goを書き始めて、Goが大好きになって、Goでいろんなものを作り、Goを通じてたくさんのことを学びました。それこそ、「1I am the bone of my go...」と詠唱のひとつもしたいぐらいに。
今日は、そんなGoでソフトウェアを開発して、OSSとして公開する時に使えそうなツールを作ったのでご紹介します!ʕ◔ϖ◔ʔ
作ったもの
先人たちが作ってくださった素敵なパッケージの数々を駆使することで、私たちは簡単に高機能なソフトウェアを開発できます。素晴らしきかなGoのエコシステム٩(ˊᗜˋ*)و
さて、依存するパッケージにはそれぞれライセンスが適用されています。それらがどんなライセンスなのかを一望できるようにして確認し、作ったソフトウェアに「このソフトウェアは、こんなライセンスのパッケージを使っていますよ」という情報を同梱してリリースできると良さそうです。
というわけで、go moduleの仕組みを使ってパッケージのライセンス一覧を生成するツールを作りました。
https://github.com/kemokemo/gomrepo
This small tool adds license information to the 'go module' information and outputs it in various formats.
使用環境
本記事は、以下の環境で実行した結果を元に執筆しています。
- macOS Catalina Ver. 10.15.7
- go version go1.15.5 darwin/amd64
使った技術
ざっくりと、以下のような技術を使いました。
- 外部コマンドを実行し、結果を取得して使う。
- httpsのクライアントを使ってWebサイトにアクセスし、情報を取得する。
- goqueryを使って、取得したWebページからライセンス情報だけを抽出する。
- セマフォを設定して過剰にならないよう制御しながら、複数のパッケージのライセンス情報を並行にクロールする。
- Go言語のtext/templateを使って、指定の形式で出力する。
それぞれ、実際のコードを元に簡略化してご説明できればと思います。
外部コマンドの実行
Go言語のプログラムから外部コマンドを実行するには、os/execパッケージを使うのが簡単です。以下の例では、まずexec.Command
で実行するコマンドを登録し、あとで文字列に変換して処理するためにコマンドの実行結果の出力先をbytes.Buffer
に変更し、Run()
で実行しています。
cmd := exec.Command("go", "list", "-m", "all")
var cmdOut bytes.Buffer
cmd.Stdout = &cmdOut
err = cmd.Run()
上記で使っているgo list -m all
コマンドは、go module
で使っている依存パッケージの一覧を出力するコマンドで、以下のような出力が得られます。
これで、依存パッケージの識別子(github.com/PuerkitoBio/goqueryなど)が一覧で取得できました。
Webサイトにアクセス
あちこちのWebサイトにアクセスし、種々のファイルからライセンス情報を収集してまわるのはとても難しそうです。何かよい方法がないか、調べては悩み、また調べという日々をしばらく過ごしていました。
そんなある日、pkg.go.devでパッケージ情報を調べている時に、あることに気づきました。パッケージの情報は、みな以下の形式のURLで閲覧できるのです。
さらに、ライセンス情報は以下の形式のURLに掲載されています。
先の手順で入手したパッケージの識別子を使って、pkg.go.devからライセンス情報を入手することにしました。
まずはhttps
接続するためのクライアントを作成します。
tr := &http.Transport{
TLSClientConfig: &tls.Config{ServerName: "go.dev"},
}
client := &http.Client{
Transport: tr,
}
このクライアントを使って、ライセンスページのBody
を取得します。name
には先のコマンドで取得したパッケージの識別子を適用します。
u, err := url.Parse(fmt.Sprintf("https://pkg.go.dev/%s?tab=licenses", name))
if err != nil {
return "", err
}
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
goquery
で特定のNodeを抽出する
さて、ライセンス情報を含むWebページが取得できたのでライセンス情報を抽出しましょう。目的の情報を一意に指定して抽出したい、そんなときはWebブラウザの開発者ツールで見てみましょう。
どうやら、id="#lic-0"
の要素を抽出すれば良いみたいです。こんな時に便利に使えるパッケージがgoqueryです。以下のようにして、変数license
に文字列BSD-3-Clause
を保存できました。先人の偉業、素晴らしいですねʕ◔ϖ◔ʔ
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return "", err
}
license := doc.Find(`#\#lic-0`).Text()
複数パッケージのライセンス情報を並行にクロール
ここまでの内容で、パッケージの識別子からライセンス情報を取得可能になりました。依存しているパッケージが多い場合に時間を短縮するため、並行にクロールして情報を取得しましょう。
const semaphore = 10
pkgCn := make(chan pkginfo)
tokens := make(chan struct{}, semaphore)
var counter int
for _, module := range modules {
fields := strings.Fields(module)
if len(fields) < 2 {
continue
}
counter++
go func(id, ver string) {
tokens <- struct{}{}
lic, err := GetLicense(id)
<-tokens
pkgCn <- pkginfo{id, ver, lic, err}
}(fields[0], fields[1])
}
semaphore
はその名の通り、並行処理の最大数を決める数字です。modules
には先の外部コマンドgo list -m all
で取得したパッケージ一覧の文字列を指定し、go.dev
にアクセスしてライセンス情報を取得するメソッドGetLicense
を並行に呼び出して処理します。これで、セマフォによって並行処理の数を調整しながらライセンス情報を迅速に収集できます。
text/templateで指定の形式に出力
ライセンス情報を付与したパッケージ情報を収集できたので、最後は指定のフォーマットで出力すれば完成です。
フォーマットの種類が増えても呼び出し側のコードが影響を受けないよう、以下のようなインターフェイスを定義しました。パッケージ情報を扱う内部structのpkginfo
の定義も示します。
// Formatter is the formats to output go module report.
type Formatter interface {
table(io.Writer, []pkginfo) error
}
// pkginfo is the info of packages.
type pkginfo struct {
ID string
Version string
License string
Error error
}
たとえば、Markdownフォーマットを指定するための実態は、以下のようにパッケージ外部に公開します。
// MD is the table formatter for markdown.
var MD Formatter = (*md)(nil)
GetLicenseList(w io.Writer, modules []string, tf Formatter)
という公開メソッドの引数で渡して使います。この機構と、出力先を引数のio.Writer
で渡すようにすることで、テストしやすくしています。
text/templateを使ったフォーマットテキストを返す処理は以下のように実装しました。テンプレートエンジンて便利ですね。いろいろと凝ることもできるようなのですが、詳細は公式ドキュメントをご覧ください。m(_ _)m
type md struct{}
const formatMd = `|ID|Version|License|
|:---|:---|:---|
{{range .}}{{if .Error}}{{else}}|{{.ID}}|{{.Version}}|{{.License}}|
{{end}}{{end}}`
// Format returns the table format string for markdown.
func (m *md) table(w io.Writer, pkgs []pkginfo) error {
if len(pkgs) == 0 {
return fmt.Errorf("there is no data to be formatted")
}
tpl, err := template.New("").Parse(formatMd)
if err != nil {
return fmt.Errorf("failed to parse template for markdown: %v", err)
}
sort.SliceStable(pkgs, func(i, j int) bool {
return pkgs[i].ID < pkgs[j].ID
})
err = tpl.Execute(w, pkgs)
if err != nil {
return fmt.Errorf("failed to execute template for markdown: %v", err)
}
return nil
}
こぼれ話
実は、各種フォーマットのテストで「実は余分な半角スペースが入ってた!(でも、go test
の比較結果みても全然分からないorz)」みたいな罠にハマりました。
そこで、Go 4 Advent Calendar 2020 の8日目で紹介されていた「google/go-cmp」をテストコードで使ったところ、差分がめちゃめちゃ分かりやすくなって生産性がとてもあがりました。この場をお借りしてお礼申し上げます!m(_ _)m
まとめ
Go言語で作ったツールで使った技術をご説明する、という構成の記事でお送りしました。何か参考にしていただける箇所があれば幸いです。
でも、よく考えると「pkg.go.devサイトが凄いんだよ」という他力MAXな内容でした。にょーん(´・ω・`)
リスペクトの気持ちを込めて、最後に、執筆時点(2020年12月20日)でのgo.dev
の歴史をThe Go Blog
の記事でまとめたいと思います。
- Go.dev: a new hub for Go developers
- Next steps for pkg.go.dev
- Pkg.go.dev is open source!
- Pkg.go.dev has a new look!
-
好きな魔術は、Unlimited Blade Worksです。 ↩