GoでJSONを扱う場合、その構造をstructとして定義することが一般的です。

しかし、プログラム内では実際のJSONとは異なる型を使いたいことがあるかもしれません。

例えば、JSON上では時刻を「unixtime形式」で表現し、プログラム中では「time.Time型」として表現することで、プログラム中で時刻の比較や計算をしやすくしたい、といったケースです。

Goのencoding/jsonパッケージには、そのためのインタフェースとしてMarshalerUnmarshalerが用意されているため、以下のようにして実現可能です。

// 対象の構造体
type Resource struct {
        ID        int64     `json:"id"`
        Timestamp time.Time `json:"-"`
}

func (r *Resource) MarshalJSON() ([]byte, error) {
        type Alias Resource
        
        // `timestamp`についてはunixtime形式に変換
        return json.Marshal(&struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias:          (*Alias)(r),
                AliasTimestamp: r.Timestamp.Unix(),
        })
}

func (r *Resource) UnmarshalJSON(b []byte) error {
        type Alias Resource

        // JSONからデコード
        aux := &struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias: (*Alias)(r),
        }
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
        }

        // `Timestamp`については`time.Time`型に変換
        r.Timestamp = time.Unix(aux.AliasTimestamp, 0)
        return nil
}

ただ、構造体ごとに似たようなコードを書く必要があるため、多くの構造体に適用するのは大変です。

そこで、本コードラボでは静的解析を利用して構造体を解析し、MarshalJSONメソッドとUnmarshalJSONメソッドを自動生成するツールを作成してみます。

なお、静的解析の基礎については深く解説しないため、こちらのコードラボで学習しておくことをおすすめします。

学習内容

これから作るツールは、構造体から次のようなコードを生成するものです。

package a

import (
        "encoding/json"
        "time"
)

func (v *Resource) MarshalJSON() ([]byte, error) {
        type Alias Resource
        return json.Marshal(&struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias:          (*Alias)(v),
                AliasTimestamp: v.Timestamp.Unix(),
        })
}

func (v *Resource) UnmarshalJSON(b []byte) error {
        type Alias Resource
        aux := &struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias: (*Alias)(v),
        }
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
        }
        v.Timestamp = time.Unix(aux.AliasTimestamp, 0)
        return nil
}

型の変換はcustomjsonタグを付けたフィールドに対して行われるように実装します。

type Resource struct {
        ID        int64     `json:"id"`
        Timestamp time.Time `json:"-" customjson:"timestamp=$.Unix();time.Unix($, 0)"`
}

customjsonタグは以下のように、JSON上のフィールド名と各メソッドで呼ばれる任意の式を指定できる形式とします。

`customjson:"<JSONのフィールド名>=<MarshalJSON用の式>;<UnmarshalJSON用の割り当て>"`

また、式中では右辺のレシーバに展開するための特殊文字として$が使えるようにします。

最初のステップとしてコードを生成するためのテンプレートを作成します。以下のデータをパラメータ化し、最初は固定値を利用して生成してみます。

要素

意味

Receiver

レシーバ名

Aliases.Target

対象のフィールド名

Aliases.Type

対象のJSON上の型

Aliases.JSONKey

対象のJSON上のフィールド名

Exprs

MarshalJSON用の式

Assigns

UnmarshalJSON用の割り当て

package main

import (
        "bytes"
        "fmt"
        "text/template"
)

const (
        pkgName = "a"
)

// テンプレートに展開するダミー値
var data = map[string]interface{}{
        "Receiver": "Resource",
        "Aliases": []map[string]interface{}{
                {
                        "Target":  "Timestamp",
                        "Type":    "int64",
                        "JSONKey": "timestamp",
                },
        },
        "Exprs": []string{
                "AliasTimestamp: v.Timestamp.Unix(),",
        },
        "Assigns": []string{
                "v.Timestamp = time.Unix(aux.AliasTimestamp, 0)",
        },
}

func main() {
        b := new(bytes.Buffer)

        // パッケージ名の出力
        fmt.Fprintf(b, "package %s\n\n", pkgName)

        // テンプレートからコード生成
        template.Must(template.New("marshal").Parse(tmplMarshal)).Execute(b, data)
        fmt.Fprintf(b, "\n")
        template.Must(template.New("unmarshal").Parse(tmplUnmarshal)).Execute(b, data)

        // コンソールへ出力
        fmt.Println(b.String())
}

// MarshalJSON用のテンプレート
const tmplMarshal = `func (v *{{.Receiver}}) MarshalJSON() ([]byte, error) {
        type Alias {{.Receiver}}
        return json.Marshal(&struct {
                *Alias
                {{- range .Aliases }}
                Alias{{.Target}} {{.Type}} ` + "`json:" + `"{{.JSONKey}}"` + "`" + `
                {{- end }}
        }{
                Alias: (*Alias)(v),
                {{- range .Exprs }}
                {{.}}
                {{- end }}
        })
}
`

// UnmarshalJSON用のテンプレート
const tmplUnmarshal = `func (v *{{.Receiver}}) UnmarshalJSON(b []byte) error {
        type Alias {{.Receiver}}
        aux := &struct {
                *Alias
                {{- range .Aliases }}
                Alias{{.Target}} {{.Type}} ` + "`json:" + `"{{.JSONKey}}"` + "`" + `
                {{- end }}
        }{
                Alias: (*Alias)(v),
        }
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
        }
        {{- range .Assigns }}
        {{.}}
        {{- end }}
        return nil
}
`

Go Playgroundでコードを見る。

これで目的に近いコードが生成されますが、よく見るとencoding/jsontimeパッケージがインポートされていません。また、コードも整形されていない状態となっています。

package a

func (v *Resource) MarshalJSON() ([]byte, error) {
        type Alias Resource
        return json.Marshal(&struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias: (*Alias)(v),
                AliasTimestamp: v.Timestamp.Unix(),
        })
}

func (v *Resource) UnmarshalJSON(b []byte) error {
        type Alias Resource
        aux := &struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias: (*Alias)(v),
        }
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
        }
        v.Timestamp = time.Unix(aux.AliasTimestamp, 0)
        return nil
}

あらかじめ元のコードから必要となるパッケージを収集しておくことも不可能ではありませんが、より簡単なのはgoimportsを利用することです。

goimportsはコマンドラインツールとして利用するだけでなく、golang.org/x/tools/importsとして、プログラム内からAPIとして利用することも可能です。

今回使用するのはProcess関数です。コンソールへの出力処理として記述していたfmt.Println(b.String())を次のように変更します。

// goimportsを実行
out, err := imports.Process("ファイル名", b.Bytes(), nil)
if err != nil {
        panic(err)
}
fmt.Println(string(out))

Go Playgroundでコードを見る。

これでencoding/jsontimeがインポートされ、コードも整形されるようになりました。

import (
        "encoding/json"
        "time"
)

func (v *Resource) MarshalJSON() ([]byte, error) {
        type Alias Resource
        return json.Marshal(&struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias:          (*Alias)(v),
                AliasTimestamp: v.Timestamp.Unix(),
        })
}

func (v *Resource) UnmarshalJSON(b []byte) error {
        type Alias Resource
        aux := &struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias: (*Alias)(v),
        }
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
        }
        v.Timestamp = time.Unix(aux.AliasTimestamp, 0)
        return nil
}

あとは固定値を設定していたパラメータについて、静的解析で取得するようにすればOKです。

元にする構造体は以下のコードに定義されたResourceです。

//
// 静的解析対象のコード
//
package a

import (
        "time"
)

type Resource struct {
        ID        int64     `json:"id"`
        Timestamp time.Time `json:"-" customjson:"timestamp=$.Unix();time.Unix($, 0)"`
}

Go Playgroundでコードを見る。

レシーバの型名であるResourceTypeSpecNameから取得することができます。

// ASTを解析
ast.Inspect(file, func(node ast.Node) bool {
        ts, ok := node.(*ast.TypeSpec)
        if !ok {
                return true
        }

        _, ok = ts.Type.(*ast.StructType)
        if !ok {
                return true
        }

        // Output:
        // Receiver: Resource
        fmt.Println("Receiver:", ts.Name.Name)

        return true
})

Go Playgroundでコードを見る。

次に対象となるフィールドを取得します。

//
// 【再掲】静的解析対象のコード
//
package a

import (
        "time"
)

type Resource struct {
        ID        int64     `json:"id"`
        Timestamp time.Time `json:"-" customjson:"timestamp=$.Unix();time.Unix($, 0)"`
}

今回対象となるフィールドはStructTypeからcustomjsonタグが付いているものなので、それを探します。

// ASTを解析
ast.Inspect(file, func(node ast.Node) bool {
        ts, ok := node.(*ast.TypeSpec)
        if !ok {
                return true
        }

        s, ok := ts.Type.(*ast.StructType)
        if !ok {
                return true
        }

        // フィールドの一覧を走査
        for _, f := range s.Fields.List {
                if f.Tag == nil {
                        continue
                }

                // `customjson`タグが付いていないものは無視
                customtag := reflect.StructTag(f.Tag.Value).Get("customjson")
                if customtag == "" {
                        continue
                }

                // Output:
                // Field=Timestamp, Tag="timestamp=$.Unix();time.Unix($, 0)"
                fmt.Printf("Field=%s, Tag=%q\n", f.Names[0].Name, customtag)
        }

        return true
})

Go Playgroundでコードを見る。

こちらを実行すると、以下のようにFieldTagが出力されます。

Field=Timestamp, Tag="timestamp=$.Unix();time.Unix($, 0)"

フィールド名はFieldをそのまま利用できますが、JSON上の型とフィールド名についてはTagから取得する必要があります。

タグの形式を再掲します。

`customjson:"<JSONのフィールド名>=<MarshalJSON用の式>;<UnmarshalJSON用の割り当て>"`

まずは=;Tagを要素ごとに分割します。

// 処理対象のタグ
tag := "timestamp=$.Unix();time.Unix($, 0)"

// 「JSON上のフィールド名」とそれ以外に分割
s := strings.Split(tag, "=")
if len(s) != 2 {
        panic("invalid tag")
}

// 「MarshalJSON用の式」と「UnmarshalJSON用の割り当て」で分割
lhs := strings.Split(s[1], ";")
if len(lhs) != 2 {
        panic("invalid tag")
}

// Output:
// JSONKey: timestamp
// Expr: $.Unix()
// Assign: time.Unix($, 0)
fmt.Println("JSONKey:", s[0])
fmt.Println("Expr:", lhs[0])
fmt.Println("Assign:", lhs[1])

Go Playgroundでコードを見る。

JSONKeyがJSON上のフィールド名になります。

型についてはExprの戻り値を調べる必要があるため、types.Eval関数でコードを評価 することで取得します。その際にパッケージの情報を渡す必要があるため、types.ConfigCheckメソッドで取得しておきます。

conf := &types.Config{Importer: importer.Default()}
pkg, err := conf.Check("任意のパス", fset, []*ast.File{file}, nil)
if err != nil {
        panic(err)
}

また、コードの特殊文字$は実際の型に展開した状態で渡す必要があります。ここでは暫定的に文字列をそのまま置換して試してみます。

// 評価対象の式
expr := "$.Unix()"

// `$`を`Resource{}.Timestamp`に置換した上で評価(Eval)
typ, err := types.Eval(fset, pkg, 0, strings.Replace(expr, "$", "Resource{}.Timestamp", -1))
if err != nil {
        panic(err)
}
if typ.Type == nil {
        panic("invalid expr")
}

// Output:
// int64
fmt.Println(typ.Type.String())

Go Playgroundでコードを見る。(※Go Playground上では動きません)

これでJSON上の型も取得できました。

最終的に以下のフィールド定義から表に記載した情報を取得できました。

Timestamp time.Time `json:"-" customjson:"timestamp=$.Unix();time.Unix($, 0)"`

表. 構造体のフィールド定義から得られた情報

フィールド名

Timestamp

MarshalJSON用の式

$.Unix()

UnmarshalJSON用の割り当て

time.Unix($, 0)

JSON上の型

int64

次にMarshalJSONメソッドとUnmarshalJSONメソッド用のコードを生成します。

// MarshalJSON用のコード
AliasTimestamp: v.Timestamp.Unix(),

// UnmarshalJSON用のコード
v.Timestamp = time.Unix(aux.AliasTimestamp, 0)

MarshalJSON

MarshalJSONメソッドでは、対象の構造体のレシーバをvとして、Aliasプレフィックスが付きのフィールドに値を設定します。

target := "Timestamp" // フィールド名
lhs := "$.Unix()"     // 式

// Output:
// AliasTimestamp: v.Timestamp.Unix(),
fmt.Printf("Alias%s: %s,", target, strings.Replace(lhs, "$", "v."+target, -1))

Go Playgroundでコードを見る。

UnmarshalJSON

UnmarshalJSONメソッドでは、対象の構造体のレシーバをvとして、Aliasプレフィックスが付きのフィールドから値を設定します。

target := "Timestamp"    // フィールド名
lhs := "time.Unix($, 0)" // 式

// Output:
// v.Timestamp = time.Unix(aux.AliasTimestamp, 0)
fmt.Printf("v.%s = %s", target, strings.Replace(lhs, "$", "aux.Alias"+target, -1))

Go Playgroundでコードを見る。

それではここまでに実装したコードを組み合わせ、静的解析の結果をテンプレートに渡してコードを生成してみましょう。

全体のコードは次のようになります。

package main

import (
        "bytes"
        "fmt"
        "go/ast"
        "go/importer"
        "go/parser"
        "go/token"
        "go/types"
        "reflect"
        "strings"
        "text/template"

        "golang.org/x/tools/imports"
)

func main() {
        src := new(bytes.Buffer)

        // 静的解析の対象となるコード
        src.WriteString("package a\n")
        src.WriteString("\n")
        src.WriteString("import (\n")
        src.WriteString("\t\"time\"\n")
        src.WriteString(")\n")
        src.WriteString("\n")
        src.WriteString("type Resource struct {\n")
        src.WriteString("\tID        int64     `json:\"id\"`\n")
        src.WriteString("\tTimestamp time.Time `json:\"-\" customjson:\"timestamp=$.Unix();time.Unix($, 0)\"`\n")
        src.WriteString("}\n")

        path := "a"
        filename := "a.go"

        // ASTの取得
        fset := token.NewFileSet()
        file, err := parser.ParseFile(fset, filename, src, parser.ParseComments)
        if err != nil {
                panic(err)
        }

        // パッケージ情報の取得
        conf := &types.Config{Importer: importer.Default()}
        pkg, err := conf.Check(path, fset, []*ast.File{file}, nil)
        if err != nil {
                panic(err)
        }

        // 解析結果を保持する変数を用意
        data := map[string]interface{}{"Receiver": ""}
        var aliases []map[string]interface{} // data["Aliases"]用
        var exprs []string                   // data["Exprs"]用
        var assigns []string                 // data["Assigns"]用

        // 解析の実行
        ast.Inspect(file, func(node ast.Node) bool {
                ts, ok := node.(*ast.TypeSpec)
                if !ok {
                        return true
                }

                s, ok := ts.Type.(*ast.StructType)
                if !ok {
                        return true
                }
                // レシーバ名
                data["Receiver"] = ts.Name.Name

                // フィールドを走査
                for _, f := range s.Fields.List {
                        if f.Tag == nil {
                                continue
                        }
                        customtag := reflect.StructTag(f.Tag.Value).Get("customjson")
                        if customtag == "" {
                                continue
                        }

                        // タグを対象のJSON上のフィールド名(t[0])とコード(t[1])に分解
                        t := strings.Split(customtag, "=")
                        if len(t) != 2 {
                                panic("invalid tag")
                        }

                        // コードをMarshalJSON用の式とUnmarshalJSON用の割り当てに分解
                        lhs := strings.Split(t[1], ";")
                        if len(lhs) != 2 {
                                panic("invalid tag")
                        }

                        // 対象のフィールド名
                        target := f.Names[0].Name

                        // MarshalJSON用の式から対象のJSON上の型を調べる
                        expr := strings.Replace(lhs[0], "$", ts.Name.Name+"{}."+target, -1)
                        typ, err := types.Eval(fset, pkg, 0, expr)
                        if err != nil {
                                panic(err)
                        }
                        if typ.Type == nil {
                                panic("invalid expr")
                        }

                        aliases = append(aliases, map[string]interface{}{
                                "Target":  target,
                                "Type":    typ.Type.String(),
                                "JSONKey": t[0],
                        })

                        // 特殊文字を展開し、テンプレートに合うように変換
                        exprs = append(exprs, fmt.Sprintf("Alias%s: %s,", target, strings.Replace(lhs[0], "$", "v."+target, -1)))
                        assigns = append(assigns, fmt.Sprintf("v.%s = %s", target, strings.Replace(lhs[1], "$", "aux.Alias"+target, -1)))
                }

                return true
        })

        // テンプレートに渡すデータの追加
        data["Aliases"] = aliases
        data["Exprs"] = exprs
        data["Assigns"] = assigns

        // テンプレートからコードを生成
        b := new(bytes.Buffer)
        fmt.Fprintf(b, "package %s\n\n", pkg.Name())
        template.Must(template.New("marshal").Parse(tmplMarshal)).Execute(b, data)
        fmt.Fprintf(b, "\n")
        template.Must(template.New("unmarshal").Parse(tmplUnmarshal)).Execute(b, data)

        // コードの整形とインポート文の追加
        out, err := imports.Process(strings.ToLower(data["Receiver"].(string))+"_json.go", b.Bytes(), nil)
        if err != nil {
                panic(err)
        }

        // 出力
        fmt.Println(string(out))
}

// MarshalJSON用のテンプレート
const tmplMarshal = `func (v *{{.Receiver}}) MarshalJSON() ([]byte, error) {
        type Alias {{.Receiver}}
        return json.Marshal(&struct {
                *Alias
                {{- range .Aliases }}
                Alias{{.Target}} {{.Type}} ` + "`json:" + `"{{.JSONKey}}"` + "`" + `
                {{- end }}
        }{
                Alias: (*Alias)(v),
                {{- range .Exprs }}
                {{.}}
                {{- end }}
        })
}
`

// UnmarshalJSON用のテンプレート
const tmplUnmarshal = `func (v *{{.Receiver}}) UnmarshalJSON(b []byte) error {
        type Alias {{.Receiver}}
        aux := &struct {
                *Alias
                {{- range .Aliases }}
                Alias{{.Target}} {{.Type}} ` + "`json:" + `"{{.JSONKey}}"` + "`" + `
                {{- end }}
        }{
                Alias: (*Alias)(v),
        }
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
        }
        {{- range .Assigns }}
        {{.}}
        {{- end }}
        return nil
}
`

Go Playgroundでコードの全体を見る。(※Go Playground上では動きません)

実行すると以下の出力結果が得られます。

package a

import (
        "encoding/json"
        "time"
)

func (v *Resource) MarshalJSON() ([]byte, error) {
        type Alias Resource
        return json.Marshal(&struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias:          (*Alias)(v),
                AliasTimestamp: v.Timestamp.Unix(),
        })
}

func (v *Resource) UnmarshalJSON(b []byte) error {
        type Alias Resource
        aux := &struct {
                *Alias
                AliasTimestamp int64 `json:"timestamp"`
        }{
                Alias: (*Alias)(v),
        }
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
        }
        v.Timestamp = time.Unix(aux.AliasTimestamp, 0)
        return nil
}

静的解析をしてコードを生成する方法について学びました!

本コードラボの元になったツールはgolang.tokyo #22で作られたこちらです。