strangejson最近の進捗はJSONのunmarshal部分の生成ができるようになったこと

github.com

まだ開発中で完成はしていないですが。strangejsonは最近の進捗によりJSONのunmarshal部分の生成ができるようになりました。 これはどういうことかと言うと以下のコードから。

github.com/podhmo/strangejson/examples/pointer02/person.go

package pointer

// Person :
type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`

    Father *Person `json:"father" required:"false"`
    Mother *Person `json:"mother" required:"false"`
}

UnmarshalJSONのメソッドが生成されるようになったということです。この生成されるUnmarshalJSONはrequired,unquiredのチェックを行ってくれます(詳しい内容昔の記事に書いた)。今のところ調べた範囲では任意のJSONにmarshal可能なstructは全部大丈夫だと思います。

ちなみに上のコード例ではわからないですが。外部のパッケージのimportもgoimportsの範囲では見てくれます。そしてちょっといじると元のstructと同じファイルに生成することもできそうです。

package pointer

import (
    "encoding/json"
    "log"
    "testing"
)

func TestLoad(t *testing.T) {
    {
        var p Person
        err := json.Unmarshal([]byte(`{"name": "foo"}`), &p)
        if err == nil {
            log.Fatal("must error")
        }
        t.Logf("expected: %q", err)
    }
    {
        var p Person
        err := json.Unmarshal([]byte(`{"age": 10}`), &p)
        if err == nil {
            log.Fatal("must error")
        }
        t.Logf("expected: %q", err)
    }
    {
        var p Person
        err := json.Unmarshal([]byte(`{"name": "foo", "age": 10}`), &p)
        if err != nil {
            t.Fatalf("unexpected: %q", err)
        }
    }
    {
        var p Person
        err := json.Unmarshal([]byte(`{"name": "foo", "age": 10, "father": {}}`), &p)
        if err == nil {
            log.Fatal("must error")
        }
        t.Logf("expected: %q", err)
    }
    {
        var p Person
        err := json.Unmarshal([]byte(`{"name": "foo", "age": 10, "father": {"name": "boo", "age": 30}}`), &p)
        if err != nil {
            t.Fatalf("unexpected: %q", err)
        }
    }
}

例えばこういう雑なテストコードが通ります。もうちょっとエラーメッセージは親切にしたいです。

$ go test -v
=== RUN   TestLoad
--- PASS: TestLoad (0.00s)
    pereson_test.go:16: expected: "age is required"
    pereson_test.go:24: expected: "name is required"
    pereson_test.go:39: expected: "name is required"
PASS
ok      github.com/podhmo/strangejson/examples/pointer02    0.004s

実行はこんな感じでやります。

$ strangejson --pkg github.com/podhmo/strangejson/examples/pointer02
2018/02/20 04:58:19 for github.com/podhmo/strangejson/examples/pointer02.Person.UnmarshalJSON
2018/02/20 04:58:19 write /home/nao/go/src/github.com/podhmo/strangejson/examples/pointer02/person_gen.go

生成されたコードは以下の様な感じ。

github.com/podhmo/strangejson/examples/pointer02/person_gen.go

package pointer

import (
    "encoding/json"
    "errors"
)

// UnmarshalJSON : (generated from github.com/podhmo/strangejson/examples/pointer02.Person)
func (x Person) UnmarshalJSON(b []byte) error {
    type internal struct {
        Name   *string  `json:"name"`
        Age    *int     `json:"age"`
        Father **Person `json:"father" required:"false"`
        Mother **Person `json:"mother" required:"false"`
    }

    var p internal
    if err := json.Unmarshal(b, &p); err != nil {
        return err
    }

    if p.Name == nil {
        return errors.New("name is required")
    }
    x.Name = *p.Name
    if p.Age == nil {
        return errors.New("age is required")
    }
    x.Age = *p.Age
    if p.Father != nil {
        x.Father = *p.Father
    }
    if p.Mother != nil {
        x.Mother = *p.Mother
    }

    return nil
}

golangの現在のファイルのimport関係に即したTypeStringの表記を作る

TypeString?

go/typesにQuqlifierという型がある。これと一緒にTypeStringという関数がある(いずれも、GOROOT/src/go/types/typestring.goに定義されている)。これらは型情報付きの表記をするために使われる。

type Qualifier func(*Package) string

// TypeString returns the string representation of typ.
// The Qualifier controls the printing of
// package-level objects, and may be nil.
func TypeString(typ Type, qf Qualifier) string {
    var buf bytes.Buffer
    WriteType(&buf, typ, qf)
    return buf.String()
}

Qualifierの例としては、go/typesのRelativeTo()という関数がわかりやすい。

// RelativeTo(pkg) returns a Qualifier that fully qualifies members of
// all packages other than pkg.
func RelativeTo(pkg *Package) Qualifier {
    if pkg == nil {
        return nil
    }
    return func(other *Package) string {
        if pkg == other {
            return "" // same package; unqualified
        }
        return other.Path()
    }
}

実装を見ればわかるとおり元のpackageと共通なら空文字でそうでなければpackageのpath名を付記した表現になる。

例えば golang.org/x/tools/go/loaderConfig を同一package上で使ったらどうなるか。あるいは異なるpackage(e.g. main)で使ったらどうなるかということを表せる

package main

import (
    "fmt"
    "go/types"

    xloader "golang.org/x/tools/go/loader"
)

func main() {
    conf := xloader.Config{}
    conf.Import(".")
    conf.Import("io")
    info, _ := conf.Load()

    loaderPkg := info.Package("golang.org/x/tools/go/loader").Pkg
    loaderConfig := loaderPkg.Scope().Lookup("Config")

    mainPkg := info.Package(".").Pkg

    {
        fmt.Println("in main package")
        qf := types.RelativeTo(mainPkg)

        fmt.Println("    ", types.TypeString(loaderConfig.Type(), qf))
      // main packageの中では golang.org/x/tools/go/loader.Config
    }
    {
        fmt.Println("in loader package")
        qf := types.RelativeTo(loaderPkg)
        fmt.Println("    ", types.TypeString(loaderConfig.Type(), qf))
      // golang.org/x/tools/go/loaderの中では Config
    }
}

面白いのはPointer型などでも良い感じに表記が得られる点。

// pointer *golang.org/x/tools/go/loader.Config
fmt.Println("    ", types.TypeString(types.NewPointer(loaderConfig.Type()), qf))

// slices []golang.org/x/tools/go/loader.Config
fmt.Println("    ", types.TypeString(types.NewSlice(loaderConfig.Type()), qf))

// map map[string]*golang.org/x/tools/go/loader.Config
fmt.Println("    ", types.TypeString(
    types.NewMap(
        types.Universe.Lookup("string").Type(),
        types.NewPointer(loaderConfig.Type()),
    ),
    qf,
))

似たような仕組みを使って良い感じの表記を作りたい

例えば通常importを使ってpackageから取り出したsymbolを使う場合にはimport nameに.を付けて使う。

import "golang.org/x/tools/go/loader"

// 使うときには
loader.Config

また名前付きでimportしたときにはそれが使われる。

import xloader "golang.org/x/tools/go/loader"

// 使うときには
xloader.Config

このimport nameの部分を意識せず使えるようになったら便利なような気がしていた。それでやってみようというのが今回のテーマ。

import nameなどの取得

importされたpackageに関してはファイル単位で異なるので、ast.Fileから見る必要がある。 各ast.FileのImportsのなかにimportされた用のImportSpecが入っているのでそれを取り出す。named importされた場合にはImportSpec.Nameがnil以外になる。

/*
これが元

import (
   "fmt"
   "go/types"

   xloader "golang.org/x/tools/go/loader"
)
*/

f := info.Package(".").Files[0]
for _, is := range f.Imports {
    fmt.Printf("name=%s path=%s\n", is.Name, is.Path.Value)
}
fmt.Println(f.Name)

// name=<nil> path="fmt"
// name=<nil> path="go/types"
// name=xloader path="golang.org/x/tools/go/loader"

2つを組み合わせてimport関係に即したTypeStringの表記を作る

上の2つを組み合わせて、あるファイルにおけるあるpackageに属するsymbolの表記を計算させることができる。 RelativeToと同様に同じパッケージの場合には空文字列。自身のast.Fileのimportsを見てprefixを決める(ここはキャッシュしても良いかもしれない)

すると例えばgolang.org/x/tools/go/loader をxloaderというimport nameでimportした場合のxloader.Configなどが取得できるようになる。

// .上ではxloaderという名前でimportされている
qf := NameTo(info.Package(".").Pkg, info.Package(".").Files[0])
// xloader.Config
fmt.Println(types.TypeString(loaderConfig.Type(), qf))

これが何かコード生成をする時に使えるかもしれない。これで任意のファイル上でのコード生成みたいな処理が書きやすくなる気がする。

NameToというのは以下の様な実装。

// NameTo :
func NameTo(pkg *types.Package, f *ast.File) types.Qualifier {
    return func(other *types.Package) string {
        if pkg == other {
            return "" // same package; unqualified
        }
        // todo: cache
        for _, is := range f.Imports {
            if is.Path.Value[1:len(is.Path.Value)-1] == other.Path() {
                if is.Name != nil {
                    return is.Name.String()
                }
                return other.Name()
            }
        }
        return other.Name() // todo: add import
    }
}

(ただもう少し利用方法など考えたほうが良い)

code

このコードを実行すると、このコードのソースコードをparseして、このコードがimportした文脈で golang.org/x/tools/go/loader の Configの表記を出力する。

package main

import (
    "fmt"
    "go/ast"
    "go/types"

    xloader "golang.org/x/tools/go/loader"
)

func main() {
    conf := xloader.Config{}
    conf.Import(".")
    info, _ := conf.Load()

    loaderPkg := info.Package("golang.org/x/tools/go/loader").Pkg
    loaderConfig := loaderPkg.Scope().Lookup("Config")

    qf := NameTo(info.Package(".").Pkg, info.Package(".").Files[0])
    fmt.Println(types.TypeString(loaderConfig.Type(), qf))
}

// NameTo :
func NameTo(pkg *types.Package, f *ast.File) types.Qualifier {
    return func(other *types.Package) string {
        if pkg == other {
            return "" // same package; unqualified
        }
        // todo: cache
        for _, is := range f.Imports {
            if is.Path.Value[1:len(is.Path.Value)-1] == other.Path() {
                if is.Name != nil {
                    return is.Name.String()
                }
                return other.Name()
            }
        }
        return other.Name() // todo: add import
    }
}

実行結果

xloader.Config