LoginSignup
3
1

More than 3 years have passed since last update.

go moduleリストにライセンス情報を付与して出力するツール作りました

Last updated at Posted at 2020-12-22

この記事は 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として公開する時に使えそうなツールを作ったのでご紹介します!ʕ◔ϖ◔ʔ

作ったもの

スクリーンショット 2020-12-20 20.38.48.png

先人たちが作ってくださった素敵なパッケージの数々を駆使することで、私たちは簡単に高機能なソフトウェアを開発できます。素晴らしきかな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で使っている依存パッケージの一覧を出力するコマンドで、以下のような出力が得られます。
スクリーンショット 2020-12-16 23.10.50.png

これで、依存パッケージの識別子(github.com/PuerkitoBio/goqueryなど)が一覧で取得できました。

Webサイトにアクセス

あちこちのWebサイトにアクセスし、種々のファイルからライセンス情報を収集してまわるのはとても難しそうです。何かよい方法がないか、調べては悩み、また調べという日々をしばらく過ごしていました。

スクリーンショット 2020-12-16 23.43.47.png
そんなある日、pkg.go.devでパッケージ情報を調べている時に、あることに気づきました。パッケージの情報は、みな以下の形式のURLで閲覧できるのです。

https://pkg.go.dev/{パッケージの識別子}

さらに、ライセンス情報は以下の形式のURLに掲載されています。

https://pkg.go.dev/{パッケージの識別子}?tab=licenses

先の手順で入手したパッケージの識別子を使って、pkg.go.devからライセンス情報を入手することにしました。

まずはhttps接続するためのクライアントを作成します。

tr := &http.Transport{
    TLSClientConfig: &tls.Config{ServerName: "go.dev"},
}
client := &http.Client{
    Transport: tr,
}

スクリーンショット 2020-12-18 20.32.10.png

このクライアントを使って、ライセンスページの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ブラウザの開発者ツールで見てみましょう。

スクリーンショット 2020-12-18 20.36.10.png

どうやら、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 4 Advent Calendar 2020 8日目) go-cmpを使う理由とTipsの紹介

まとめ

Go言語で作ったツールで使った技術をご説明する、という構成の記事でお送りしました。何か参考にしていただける箇所があれば幸いです。

でも、よく考えると「pkg.go.devサイトが凄いんだよ」という他力MAXな内容でした。にょーん(´・ω・`)

リスペクトの気持ちを込めて、最後に、執筆時点(2020年12月20日)でのgo.devの歴史をThe Go Blogの記事でまとめたいと思います。


  1. 好きな魔術は、Unlimited Blade Worksです。 

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1