GoでJSONを扱う場合、その構造をstruct
として定義することが一般的です。
しかし、プログラム内では実際のJSONとは異なる型を使いたいことがあるかもしれません。
例えば、JSON上では時刻を「unixtime形式」で表現し、プログラム中では「time.Time
型」として表現することで、プログラム中で時刻の比較や計算をしやすくしたい、といったケースです。
Goのencoding/jsonパッケージには、そのためのインタフェースとしてMarshalerとUnmarshalerが用意されているため、以下のようにして実現可能です。
// 対象の構造体
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用の割り当て>"`
また、式中では右辺のレシーバに展開するための特殊文字として$
が使えるようにします。
最初のステップとしてコードを生成するためのテンプレートを作成します。以下のデータをパラメータ化し、最初は固定値を利用して生成してみます。
要素 | 意味 |
| レシーバ名 |
| 対象のフィールド名 |
| 対象のJSON上の型 |
| 対象のJSON上のフィールド名 |
| MarshalJSON用の式 |
| 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/json
やtime
パッケージがインポートされていません。また、コードも整形されていない状態となっています。
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/json
とtime
がインポートされ、コードも整形されるようになりました。
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でコードを見る。
レシーバの型名であるResource
はTypeSpecのNameから取得することができます。
// 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でコードを見る。
こちらを実行すると、以下のようにField
とTag
が出力されます。
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.ConfigのCheckメソッドで取得しておきます。
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)"`
表. 構造体のフィールド定義から得られた情報
フィールド名 |
|
MarshalJSON用の式 |
|
UnmarshalJSON用の割り当て |
|
JSON上の型 |
|
次にMarshalJSON
メソッドとUnmarshalJSON
メソッド用のコードを生成します。
// MarshalJSON用のコード
AliasTimestamp: v.Timestamp.Unix(),
// UnmarshalJSON用のコード
v.Timestamp = time.Unix(aux.AliasTimestamp, 0)
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
メソッドでは、対象の構造体のレシーバを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で作られたこちらです。