技術備忘記

日々調べたこととかとりとめなく

go/typesでgo moduleを有効にするまでの旅路

はじめに

この記事はGo2 Advent Calendar 2021 23日目の記事です。

TL; DR;

go/types で go moduleを有効にするためには golang.org/x/tools/go/packages を使う。以上。

pkg.go.dev godoc.org

旅のはじまり

日頃仕事でDX(Developer Experienceの方)おじさんをやっている関係上、Code Generate ツールを書くことが結構ありまして、その際に go/types が便利なのでよく使っています。

AST は生で触ると結構大変ですが、このgo/typesパッケージを利用することで、あたかも reflection のような感覚で扱うことが出来ます。 例えば embedded は AST の鬼門ですが、go/types を使うと非常に楽ちんです。go/typesサイコー!

例えばこんなファイルがあったとして

sample/foo.go

package sample

type Foo struct {
    Bar
}

type Bar struct {
}

こんな感じで Foo の型情報にアクセスすることが出来ます。

func main() {
    set := token.NewFileSet()
    path := "path/to/sample/foo.go"
    pkgs, _ := parser.ParseDir(set, path, nil, parser.ParseComments)
    pkg, _ := pkgs["sample"]
    cfg := types.Config{
        Importer: importer.Default(),
    }
    afs := []*ast.File{}
    for _, f := range pkg.Files {
        afs = append(afs, f)
    }
    pkz, _ := cfg.Check("", set, afs, nil)
    obj := pkz.Scope().Lookup("Foo")

    tp := obj.Type().Underlying()
    fmt.Println(tp) // *types.Struct

    strct := tp.(*types.Struct)
    f := strct.Field(0)
    fmt.Println(f.Name(), f.Embedded()) // Bar true
}

そんなこんなで、go/types を使って Happy Hacking な毎日を送っていましたが、 とある問題に直面しました。

GOPATH モード非対応パッケージで躓く

日頃使っていて、どうやら go/types が 依存パッケージの参照を GOPATH を参照、 いわゆるGOPATHモードで動作していることには気づいていました。

2021年にもなってどうなのか、とは思っていましたが、基本利用シーンはツールですので、 深追いはせずに、適宜依存パッケージが見つからない旨のエラーが出たら

$ GO111MODULE=off go get -u xxxx

をして事なきを得ていました。しかしある日、事件は起きました。

$ GO111MODULE=off go get -u github.com/labstack/echo/v4
cannot find package "github.com/labstack/echo/v4" in any of:
        $GOROOT/src/github.com/labstack/echo/v4 (from $GOROOT)
        $GOPATH/src/github.com/labstack/echo/v4 (from $GOPATH)

ついに来てしまいました。 モジュールモードがデフォルトでONになって久しく、 昨今こういったGOPATHモード非対応のライブラリも増えてきています。

という訳で、仕方がないので重い腰を上げて go/types のコードを読むことにしました。

go/types Deep Dive

先程掲載したサンプルコードのこの部分。

   cfg := types.Config{
        Importer: importer.Default(),
    }

怪しいですね。ということで定義を見てみました。

go/types/api.go

// A Config specifies the configuration for type checking.
// The zero value for Config is a ready-to-use default configuration.
type Config struct {
~
    // An importer is used to import packages referred to from
    // import declarations.
    // If the installed importer implements ImporterFrom, the type
    // checker calls ImportFrom instead of Import.
    // The type checker reports an error if an importer is needed
    // but none was installed.
    Importer Importer
~
}

~

// An Importer resolves import paths to Packages.
//
// CAUTION: This interface does not support the import of locally
// vendored packages. See https://golang.org/s/go15vendor.
// If possible, external implementations should implement ImporterFrom.
type Importer interface {
    // Import returns the imported package for the given import path.
    // The semantics is like for ImporterFrom.ImportFrom except that
    // dir and mode are ignored (since they are not present).
    Import(path string) (*Package, error)
}

~

// An ImporterFrom resolves import paths to packages; it
// supports vendoring per https://golang.org/s/go15vendor.
// Use go/importer to obtain an ImporterFrom implementation.
type ImporterFrom interface {
    // Importer is present for backward-compatibility. Calling
    // Import(path) is the same as calling ImportFrom(path, "", 0);
    // i.e., locally vendored packages may not be found.
    // The types package does not call Import if an ImporterFrom
    // is present.
    Importer

    // ImportFrom returns the imported package for the given import
    // path when imported by a package file located in dir.
    // If the import failed, besides returning an error, ImportFrom
    // is encouraged to cache and return a package anyway, if one
    // was created. This will reduce package inconsistencies and
    // follow-on type checker errors due to the missing package.
    // The mode value must be 0; it is reserved for future use.
    // Two calls to ImportFrom with the same path and dir must
    // return the same package.
    ImportFrom(path, dir string, mode ImportMode) (*Package, error)
}

types.Config に設定された Importer インターフェースを使って依存パッケージを探しに行くことが読み取れました。 そして、更に ImporterFrom が実装されていればそっちを使うと。なるほど。

で、importer.Default() を見てみると、

go/importer/importer.go

// Default returns an Importer for the compiler that built the running binary.
// If available, the result implements types.ImporterFrom.
func Default() types.Importer {
    return For(runtime.Compiler, nil)
}

コメントが何やら含みをもたせていますが ImporterFrom を実装してる可能性がありそうです。  runtime.Compiler は長くなるので引用しませんがGoデフォルトのコンパイラを示す gc を返します。

go/importer/importer.go

func ForCompiler(fset *token.FileSet, compiler string, lookup Lookup) types.Importer {
    switch compiler {
    case "gc":
        return &gcimports{
            fset:     fset,
            packages: make(map[string]*types.Package),
            lookup:   lookup,
        }
~
}

どうやら、Importer を実装しているのはこの gcimports のようでした

go/importer/importer.go

type gcimports struct {
    fset     *token.FileSet
    packages map[string]*types.Package
    lookup   Lookup
}

func (m *gcimports) Import(path string) (*types.Package, error) {
    return m.ImportFrom(path, "" /* no vendoring */, 0)
}

func (m *gcimports) ImportFrom(path, srcDir string, mode types.ImportMode) (*types.Package, error) {
    if mode != 0 {
        panic("mode must be 0")
    }
    return gcimporter.Import(m.fset, m.packages, path, srcDir, m.lookup)
}

gcimporter は ImporterFrom を実装していました。 ということは、どうにかして srcDir を指定することが出来れば良さそうです。

// If the installed importer implements ImporterFrom, the type

// checker calls ImportFrom instead of Import.

こちらのコメントの実装部分を探します。

go/types/resolver.go

func (check *Checker) importPackage(at positioner, path, dir string) *Package {
~
    // no package yet => import it
    if path == "C" && (check.conf.FakeImportC || check.conf.go115UsesCgo) {
        imp = NewPackage("C", "C")
        imp.fake = true // package scope is not populated
        imp.cgo = check.conf.go115UsesCgo
    } else {
        // ordinary import
        var err error
        if importer := check.conf.Importer; importer == nil {
            err = fmt.Errorf("Config.Importer not installed")
        } else if importerFrom, ok := importer.(ImporterFrom); ok {
            imp, err = importerFrom.ImportFrom(path, dir, 0)
            if imp == nil && err == nil {
                err = fmt.Errorf("Config.Importer.ImportFrom(%s, %s, 0) returned nil but no error", path, dir)
            }
~
}

ありました。このpathがどこから来ているかというと・・ この辺は長くなるので割愛します。結論、

   pkz, _ := cfg.Check("", set, afs, nil)

冒頭のサンプルコードのこの部分。定義を見てみます

go/types/api.go

// Check type-checks a package and returns the resulting package object and
// the first error if any. Additionally, if info != nil, Check populates each
// of the non-nil maps in the Info struct.
//
// The package is marked as complete if no errors occurred, otherwise it is
// incomplete. See Config.Error for controlling behavior in the presence of
// errors.
//
// The package is specified by a list of *ast.Files and corresponding
// file set, and the package path the package is identified with.
// The clean path must not be empty or dot (".").
func (conf *Config) Check(path string, fset *token.FileSet, files []*ast.File, info *Info) (*Package, error) {
    pkg := NewPackage(path, "")
    return pkg, NewChecker(conf, fset, pkg, info).Files(files)
}

第一引数で空文字を渡していた path 、こちらに任意のパスを指定することで そちらを見てくれるようです。尚、その指定がない場合gcimportsの実装的には $GOROOT$GOPATH を参照、つまり GOPATHモードと同じ動きになるようでした。

一旦動作。しかし・・

pathの指定の仕方がわかった所で、次のような手順でやってみました

1 ツール上で必要なパッケージをimport

後述しますが、ツール自体がパッケージを必要としているのではなく、 Importerに知らせる場所(vendor/) に配置するためimportします。

import (
~

    _ "github.com/labstack/echo/v4" // ツールでは使用しないため blank identifier を指定
)

2 ツール上で go mod vendor を実行

$ go mod vendor
go: finding module for package github.com/labstack/echo/v4
go: found github.com/labstack/echo/v4 in github.com/labstack/echo/v4 v4.6.1

これで vendor/ 以下に依存パッケージがダウンロードされます

$ tree vendor/ -L 2
vendor/
├── github.com
│   ├── labstack
│   ├── mattn
│   └── valyala
├── golang.org
│   └── x
└── modules.txt

3 path にvendorのパスを知らる

   pkz, _ := dcg.Check("/path/to/tool/vendor", set, afs, nil)

こうして、めでたく動作しました。やった!! ・・とはなりませんでした。

解析するプログラムが依存しているだけで、本来ツールに不要な依存を生み出してしまいます。 それはあまりにもイケてません、こういったパッケージが増えるたびにvendor以下に追加していく? そもそも2021年にもなって (略)

というわけで、この方法はボツにしました。

Google先生に聞いて一瞬で解決

仕方がないのでGoogle先生の力を借りることにしました。 はい。すぐ見つかりました(半ギレ)

https://github.com/golang/go/issues/28328 https://github.com/golang/go/issues/27556#issuecomment-419468978 (上記issueから引用)

github.com github.com

旅のおわり

というわけで、回り道しましたが golang.org/x/tools/go/packages を使うことで、 めでたく go module を参照することができるようになりました。こんな感じです。

import (
~
    "golang.org/x/tools/go/packages"
    "golang.org/x/tools/imports"
)


func main() {
    set := token.NewFileSet()
    path := "path/to/sample/foo.go"
    pkgs, _ := parser.ParseDir(set, path, nil, parser.ParseComments)
    pkg, _ := pkgs["sample"]
    cfg := &packages.Config{
        Mode: packages.LoadAllSyntax,
    }
    ptns := []string{}
    for k := range pkg.Files {
        ptns = append(ptns, k)
    }
    pkzs, _ := packages.Load(cfg, ptns...)
    var pkz *types.Package
    for _, p := range pkzs {
        if p.Types.Name() == name {
            pkz = p.Types
            break
        }
    }
    obj := pkg.Scope().Lookup("Foo")
    tp := obj.Type().Underlying()
    fmt.Println(tp) // *types.Struct

    strct := tp.(*types.Struct)
    f := strct.Field(0)
    fmt.Println(f.Name(), f.Embedded()) // Bar true
}

最初からGoogle先生に聞けば5分で終わった話ではありますが、 久しぶりに標準パッケージをじっくり読んで、遠回りした分、学びもそれなりにあったの良しとします。

今回は以上です。それでは、良いお年を。