YAMAGUCHI::weblog

土足で窓から失礼いたします。今日からあなたの息子になります。 当年とって92歳、下町の発明王、エジソンです。

GoのASTを使ってパッケージのメンテナンスを考える

はじめに

こんにちは、Go界の骨盤職人です。buildersconにmattnさんがいらしていたということで生mattnさんに謁見したかったのですが、諸事情でこの日程はどうしても都合がつかず、参加できなかったことが非常に残念でいまでも悔やんでいます。

さて、Goは安定して開発が進んでおり、いまは安定バージョンが1.8にもなろうというところです。セルフホスティングも1.5で達成し、GCの高速化も順調に進んでいる中、いまだにGoの問題として挙げられるものとして「パッケージバージョンの管理」があります。今日はその辺の話をしようと思います。

TL;DR

go パッケージを使って、自分たちが書いたコードが依存してるパッケージを明らかにし、依存先パッケージの更新に追従していこう。

前置き

以下の話はGoをプロダクションで中規模〜大規模に利用している環境を想定しており、細かな閉じられたパッケージのみを開発している状況は想定していません。(そのような状況であれば既存のパッケージ管理ツールでも運用可能だと思っている)

背景

現在Goのバージョン管理には公式ではGo 1.5からvendoringがあり、3rd partyなものとしては gbgodepglide などがあります。すでに1.8にもなろうとしているのに、なぜ公式がいまだにパッケージのバージョン管理のサポートを暫定的な措置であるvendoringでしかサポートしていないのか、その理由は公式ドキュメントのFAQから垣間見ることが出来ます。

"Go get" does not have any explicit concept of package versions. Versioning is a source of significant complexity, especially in large code bases, and we are unaware of any approach that works well at scale in a large enough variety of situations to be appropriate to force on all Go users. What "go get" and the larger Go toolchain do provide is isolation of packages with different import paths. For example, the standard library's html/template and text/template coexist even though both are "package template". This observation leads to some advice for package authors and package users.

Packages intended for public use should try to maintain backwards compatibility as they evolve. The Go 1 compatibility guidelines are a good reference here: don't remove exported names, encourage tagged composite literals, and so on. If different functionality is required, add a new name instead of changing an old one. If a complete break is required, create a new package with a new import path.

If you're using an externally supplied package and worry that it might change in unexpected ways, the simplest solution is to copy it to your local repository. (This is the approach Google takes internally.) Store the copy under a new import path that identifies it as a local copy. For example, you might copy "original.com/pkg" to "you.com/external/original.com/pkg". The gomvpkg program is one tool to help automate this process.

The Go 1.5 release includes an experimental facility to the go command that makes it easier to manage external dependencies by "vendoring" them into a special directory near the package that depends upon them. See the design document for details.

ここにあるように、そもそもの思想として「パッケージ管理は一筋縄ではいかない」「パプリックで公開しているものは作者は依存パッケージのアップデートに追従すべき」というものがあり、Googleが実際に行っている方法として「破壊的変更が心配ならローカルのレポジトリにバージョン固定でコピー」をおすすめしています。(この思想がvendoringにも反映されていると見ていいでしょう)

ローカルにパッケージをコピーすればひとまずバージョンは固定できますから、オリジナルで破壊的変更があってもローカル(個人なら手元、会社で使っている場合は社内)ではビルドが壊れないので安心です。またローカルでバージョンを固定しておくことで他のプロジェクトがそのパッケージを利用する場合にも同一のバージョンを利用しているという安心感があります。実際に、1パッケージ1バージョンを維持することの重要性は今後のパッケージ管理のロードマップでも語られています。

とはいえ、オリジナルとローカルのインタフェースの差分が大きくなればなるほど追従が難しくなります。たとえば外部パッケージAをローカルに保存し、それ依存したローカルパッケージB、Cを開発している場合、Aから破壊的変更があったバージョンA'に更新すると、BとCの作者は壊れないように追従しなければいけません。さらにB、Cに依存したパッケージDの作者はさらに追従しなければいけません。またBとCがA1, A2とAの異なるバージョンに依存している場合にも、どちらを優先すべきかなどの問題が発生します。 このような連鎖的な依存関係はよくあることですが、製品規模が大きくなった場合にこのような変更にどのように追従していけばよいのでしょうか。

体制

一例としてGoogleの例を挙げます。ACMGoogleのソフトウェアエンジニアが社内での開発体制を紹介した記事でも語っていますが、Googleではサードパーティーライブラリ(社内で開発していないもの)は特定のディレクトリ以下にスナップショットを取り特定のバージョンを利用するようにしています。

そのスナップショットのバージョンを上げるとなると、それに依存して製品を作っている人々は対応をしなければいけないわけです。次の引用がまさにその状況を説明しています。

This model also requires teams to collaborate with one another when using open source code. An area of the repository is reserved for storing open source code (developed at Google or externally). To prevent dependency conflicts, as outlined earlier, it is important that only one version of an open source project be available at any given time. Teams that use open source software are expected to occasionally spend time upgrading their codebase to work with newer versions of open source libraries when library upgrades are performed.

サードパーティーライブラリのバージョンを上げるときは依存した製品の開発責任を持つ人々が同じタイミングで製品の動作保証をするように対応するわけです。

Goをメインに使って開発を行う場合に、このような体制をどのように構築したら良いのでしょうか。体制とまではいかずとも、このような依存関係を常に意識する簡単な方法はないでしょうか。

パッケージの依存関係をGoの抽象構文木(AST)から知る

長い前置きとなりましたが、ようやく本題です。Goでは標準パッケージ内に go パッケージがあり、Goのソースコードそのものをなるべく扱いやすくできるようになっています。Goの利点として語られるところにIDEに頼らずとも、標準ツールやサードパーティーツールと連携させることで、VimEmacsVisual Studio Code等々のエディタでも高い生産性を発揮できるというものがありますが、それらのツールもこの go パッケージを利用して作られています。go パッケージの操作の簡易性は日本でも多くの方々がすでに良い記事を書いてくださっているので説明はそちらに譲ります。(記事のリンク等は参考の節にまとめました)

さて、go パッケージの中にはサブパッケージとして go/parser があり、これを使うとカジュアルにGoのソースコードをASTにしてくれます。実際にどれくらいカジュアルか、あるディレクトリ配下のソースコードを全部パースするコードを書いてみます。

import (
    "go/parser"
    "go/token"
)

fset := token.NewFileSet()
pkgs, first := parser.ParseDir(fset, path, nil, parser.ImportsOnly)

以上です。驚くほど簡単です。さらに go/parser は用途によりパースの内容を定数で変更できます。

parser.Mode で定義してあるのですが、デフォルトの 0ソースコードすべてを解析します。それ以外の用途のためにいくつかのオプションが用意されています。ドキュメントを見ると解説付きでわかりやすく書いてあります。

const (
        PackageClauseOnly Mode             = 1 << iota // stop parsing after package clause
        ImportsOnly                                    // stop parsing after import declarations
        ParseComments                                  // parse comments and add them to AST
        Trace                                          // print a trace of parsed productions
        DeclarationErrors                              // report declaration errors
        SpuriousErrors                                 // same as AllErrors, for backward-compatibility
        AllErrors         = SpuriousErrors             // report all errors (not just the first 10 on different lines)
)

今回は依存しているパッケージを明らかにすることが目的なので、 ImportsOnlyimport 文までを読めば事足ります。先ほどの parser.ParseDir でパースすると、戻り値の pkgs にそのパッケージを構成するすべての情報が入っています。これを解析したASTのルートと考えていいでしょう。

ASTが手に入ったので、次はこれをいろいろと辿りながらいろいろと工作をしていきたくなるわけですが、「木構造を辿る」というような計算機科学でよく出てくる課題のようなプログラムを書かないといけないかというと、そんなことはありません。おあつらえ向きの関数が用意されています。

func Walk(v Visitor, node Node)

Walk という関数名は見たことがありますね。そうです、これは filepath.Walk のように、ASTのルートとWalkのための関数を渡してあげるとあとはよしなに深さ優先探索でASTを辿ってくれるという関数です。ASTの各ノードは次のように定義されています。

type Node interface {
        Pos() token.Pos // position of first character belonging to the node
        End() token.Pos // position of first character immediately after the node
}

このインターフェースを実装した構造体が各ノードになっているわけです。各構造体の定義は go/ast のドキュメントを参照してください。

さて ast.Walk に渡す ast.Visitor ですが、これは適当にそのインターフェースを満たす構造体を宣言するだけです。今回のサンプルでは次のように実装しています。

type packageVisitor struct {
    imports []string
}

func (p *packageVisitor) Visit(node ast.Node) ast.Visitor {
    if node != nil {
        switch n := node.(type) {
        case *ast.ImportSpec:
            is := (*ast.ImportSpec)(n)
            if is.Path != nil {
                p.imports = append(p.imports, (*is.Path).Value)
            }
            return p
        default:
            return p
        }
    }
    return p
}

これも実装が非常に素直です。Visit はノードに着くたびに呼ばれるので、その都度、ast.Node の実際の型によって処理を切り替えてあげるだけです。今回は import の中身だけみたいので、ImportSpecの場合だけ処理すればよいでしょう。さて、つらつらと説明してきましたが、この辺で簡単にサンプルを置いてみます。

もうちょっといろいろやろうと思ったんですが、とりあえずはここまで。この example ディレクトリ内の main.go をビルドしてそのディレクトリで実行すると repos.json 内に書いてある3rd partyパッケージが依存しているパッケージがすべて羅列されます。試しに実行してみると

% ./example
[Processing]: github.com/simeji/jid
[Done]: github.com/simeji/jid
/tmp/src/github.com/simeji/jid :
     "bytes"
     "github.com/bitly/go-simplejson"
     "github.com/nsf/termbox-go"
     "github.com/pkg/errors"
     "github.com/stretchr/testify/assert"
     "io"
     "io/ioutil"
     "os"
     "regexp"
     "sort"
     "strconv"
     "strings"
     "testing"
/tmp/src/github.com/simeji/jid/cmd/jid :
     "flag"
     "fmt"
     "github.com/simeji/jid"
     "github.com/stretchr/testify/assert"
     "os"
     "testing"
...

と、つらつらと雑な感じでつらつらと依存しているパッケージが羅列されます。このサンプルでは一段下までしかやりませんが、go getすると依存パッケージはすべて獲得するので、取得したパッケージを入力にして再帰してあげればすべての依存ツリーを作成することが可能です。(実際の go get の実装ではそのようになっています

先にあげたサンプルはちょろちょろと書きなぐっただけのものですが、ほんの少しの手間でパッケージの依存を明らかにすることが出来ます。開発中のパッケージのオーナー情報と依存パッケージの関係を紐付けておくことで、通知などを行うこともできるでしょう。

おわりに

ということでパッケージ管理の一例を紹介し、それに関連してASTを使った簡単なツールの作成を行ってみました。正規表現reflectgo generate 使っても強力なツールを作ることができますが、ASTを使うことでより強力なツールが作れるようになると思います。冬休みにぜひ試してみて下さい。

参考