LoginSignup
26
15

More than 5 years have passed since last update.

json.Unmarshalする際に時刻のフォーマットを柔軟に変更できるようにする #Go

Last updated at Posted at 2017-12-04

この記事は Go Advent Calendar 2017 5日目の記事です。

はじめに

みなさんはじめまして、@cia_ranaと申します。

今回は json.Unmarshal する際に時刻のフォーマットを自由に変更できるようにするためのライブラリtson(ツォン)を開発したお話です。

:warning: tson†黒魔術† を使用しています。用法用量を守り正しくお使いください。

この記事で使う構造体や変数

ここでは本記事で使用する構造体や変数を定義します。hogeとかhugaとかでは頭に入ってきづらいと思うので、少し意味を持たせた内容となっています。

構造体Personは、Personの特徴Featureと、Personが作成された日付CreatedAtを持ちます。

type Person struct {
    Feature   Feature    `json:"feature"`
    CreatedAt *time.Time `json:"created_at"`
}

構造体Featureは、Personが持つ特徴の内容を表しています。

type Feature struct {
    Name  string     `json:"name"`
    Birth *time.Time `json:"birth"`
}

変数jsonStringは、構造体Personのオブジェクトにアンマーシャル1する対象となるJSON文字列です。特に時刻のフィールド(birthcreated_at)が今回のキモとなるので覚えておいてください!

var jsonString = `
{
    "feature": {
        "name": "cia_rana",
        "birth": "1987-10-08"
    },
    "created_at": "2017-12-05"
}
`

まずはライブラリを使ってみよう

tsonを使ったJSON文字列から構造体のオブジェクトへのアンマーシャルは、標準ライブラリencoding/jsonの関数json.Unmarshalと同じやり方、つまり関数tson.Unmarshalでできます。

ただ通常のjson.Unmarshalと違うのは、tson.Unmarshalの手前で関数tson.SetLayoutを呼んでいるところです。tson.SetLayoutは時刻のフォーマットを変更する関数で、この関数を呼んだ後にtson.Unmarshalする際は変更したフォーマットが使用されます。

var person Person

tson.SetLayout(`2006-01-02`)

err := tson.Unmarshal([]byte(jsonString), &person)
if err != nil {
    fmt.Println("error:", err)
} else {
    fmt.Println(person)
}
// => {{cia_rana 1987-10-08 00:00:00 +0000 UTC} 2017-12-05 00:00:00 +0000 UTC}

きちんとアンマーシャルできていますね!

一体この処理の裏側では何が行われているのでしょう?

この記事を書こうと思ったきっかけ

ところで・・・

GoでJSON文字列を構造体のオブジェクトにアンマーシャルしたとき、こんな経験した人いますよね?

var person Person

err := json.Unmarshal([]byte(jsonString), &person)
if err != nil {
    fmt.Println("error:", err)
} else {
    fmt.Println(person)
}
// => error: parsing time ""1988-10-10"" as ""2006-01-02T15:04:05Z07:00"": cannot parse """ as "T"

エラー!!?

そう、GoでJSON中の時刻を*time.Timeにマッピングするとき、その時刻はGoがもともと用意しているフォーマットで記述されていなければきちんとマッピングできません。
もうちょっと融通が効いても良いと思うのですが、こればかりはGoの仕組み上どうしようもないのです。

ちなみに時刻の文字列をtime.Time型のオブジェクトに変換する場合、時刻のフォーマットを明示的に指定できるのですが、Goはその指定の仕方が特殊で、YYYY-MM-DDのように指定するのではなく、プログラマが読みやすいフォーマットで指定できるようになっています。それゆえ細かい数字を覚えなくてはいけません・・・。

解決策

さて、エラーが出ないようにきちんとアンマーシャルするためには、普通は構造体内の*time.Time型をUnmarshalJSONを備えたオリジナルのTime型に変更しますよね?

ただ、いちいち構造体を書き換えてUnmarshalJSONを実装するのは大変だし、ライブラリ内の構造体を利用したい場合その構造体を容易に変更できない場合すらあります。

そこで、プログラム処理中に元からある構造体を基に「自由に時刻のフォーマットを変更できる新しい構造体を動的に生成する」という新しいアプローチを提案します。

このアプローチによって、"まずはライブラリを使ってみよう"の章で見たように、用意された関数に構造体のオブジェクトを渡すだけで、あらかじめ設定した時刻のフォーマットに基づいてJSON文字列内の時刻を*time.Timeにうまいことマッピングしながらアンマーシャリングを行うことができるようになります。

以降はこのアプローチをどのように実現したのか、その中身を紐解いていきます。

さぁ、黒魔術をはじめよう

時刻のフォーマットを考慮しながらJSON文字列をPersonオブジェクトにアンマーシャルする手順は次の通りです。

  1. Personの型情報を取得する
  2. Person型情報内の*time.Time型をtson側で用意したオリジナルのTime型に置換した新たな型を生成する
  3. 新たな型のオブジェクトを生成する
  4. JSON文字列を新たな型のオブジェクトにアンマーシャルする
  5. 新たな型のオブジェクトを再びJSON文字列に変換する
  6. 再変換したJSON文字列をPersonのオブジェクトにアンマーシャルする

json_unmarshal.png

reflectを多用した黒魔術の香りがぷんぷんしますね:blush:
それでは、黒魔術の手順をそれぞれ見ていきましょう。

1. Personの型情報を取得する

まずはベースとなるPersonの型情報を取得します。
型情報を取得するには通常は関数reflect.TypeOfを使います。ですが、Personはポインタ型で入ってくる前提なので(tson.Unmarshal([]byte(jsonString), &person)&personの部分より)、一旦ポインタが指す値を取得した後その型情報を取得します。ポインタが指す値を取得するには関数reflect.Elemを使います。あとはその戻り値の関数Typeを呼んでやれば良いです。

var v {}interface := &person // `Person`はポインタで入ってくる
rv := reflect.ValueOf(v)     // personの型情報を取得する。personの型情報は`reflect.Ptr`である
rt := rv.Elem().Type()       // `reflect.Ptr`が指す値の型情報を取得する

これでPersonの型情報が取得できました。

2. Person型情報内の*time.Time型をtson側で用意したオリジナルのTime型に置換した新たな型を生成する

ここでは、先ほど取得したPersonの型情報に含まれる*time.TimeをオリジナルのTime型(のポインタ)に置換していきます。ですが、その前にオリジナルのTime型について説明します。

オリジナルのTime型(tson.Timeと呼ぶ)は、関数tson.SetLayoutで指定した時刻のフォーマットに従ってJSON文字列内の時刻をtson.Timeにマッピングするという特徴を持っています。tson.SetLayoutで指定した時刻のフォーマットはtson側のプライベートな変数formatに格納されています。また、時刻のフォーマットはスレッドセーフに指定できるようにしています。それ以外はよく見るtime.Timeのラッパーの形ですね。

var format = struct {
    layout string
    mutex  *sync.Mutex
}{
    time.RFC3339,
    new(sync.Mutex),
}

func SetLayout(layout string) {
    format.mutex.Lock()
    format.layout = layout
    format.mutex.Unlock()
}

type Time struct {
    time.Time
}

func (t *Time) UnmarshalJSON(data []byte) error {
    if string(data) == "null" {
        return nil
    }

    tm, err := time.Parse(`"`+format.layout+`"`, string(data))
    t.Time = tm
    return err
}

tson.Timeの型情報は頻繁に使うため、tson側のプライベートな変数rttであらかじめ持っておきます。

var rtt = reflect.TypeOf(&Time{})

さて、これで準備が整いました。
黒魔術をはじめましょう。

・・・と、カッコつけましたがやることはいたって単純で、*time.Timeを置換するだけです(笑)
ここでは便宜的にPersonの型情報をrtとしておきます。

まずは、新しく生成する構造体のフィールドをまとめるスライスを定義します。スライスの型は[]reflect.StructFieldです。フィールドの数はrtのフィールドの数と同じであり、スライスの長さが分かっているので、メモリアロケーションの回数を減らす意味も込めてスライスの定義は次のように行います。

rs := make([]reflect.StructField, rt.NumField())

次にrtのフィールドにアクセスします。型情報の種別がStructであるrtのフィールドへのアクセスは、インデックスを用いて次のようにアクセスします。

f := rt.Field(i)

そして、ここから構造体のフィールドを置き換える作業になります。

戻り値fのフィールドType*time.Timeである場合、それをtson.Timeの型情報で置き換えます。置き換えはただtson.Timeの型情報を代入するだけで良いです。

f.Type = rtt

一方、f.Type*time.Timeでない場合、一見何も置き換える必要がないと思いますよね?ですが、f.Typeの種別がArraySliceStructなどの場合、それらの構成要素の中で*time.Timeが使われているかもしれません。
例えばfがスライス[]*time.Timeの場合f.TypeSliceですが、このスライスの要素の型は*time.Timeであり、置き換えの対象になります。また、fの型の構成要素が直接*time.Timeを持たない場合でも、構成要素の構成要素が*time.Timeである可能性があります。つまり、再帰的にfの構成要素にアクセスして置換していきます。
構成要素へのアクセスおよび置換された構成要素を持つ新しい型の生成は、それぞれの型の種別に合わせて行わなければいけません。

最後に、新しく生成する構造体のフィールドをまとめておいたスライスを、関数reflect.StructOf(rs)によって新しく生成する構造体の型情報へ変換します。

以上をまとめると、次の関数newStructのようになります。

func newStruct(rt reflect.Type) reflect.Type {
    rs := make([]reflect.StructField, rt.NumField())

    for i := 0; i < rt.NumField(); i++ {
        f := rt.Field(i)

        if f.Type.String() == "*time.Time" {
            f.Type = rtt
        } else {
            switch f.Type.Kind() {
            case reflect.Array:
                f.Type = reflect.ArrayOf(f.Type.Len(), newStruct(f.Type.Elem()))
            case reflect.Chan:
                f.Type = reflect.ChanOf(f.Type.ChanDir(), newStruct(f.Type.Elem()))
            case reflect.Func:
                ins := make([]reflect.Type, f.Type.NumIn())
                outs := make([]reflect.Type, f.Type.NumOut())
                for i := 0; i < f.Type.NumIn(); i++ {
                    ins[i] = newStruct(f.Type.In(i))
                }
                for i := 0; i < f.Type.NumOut(); i++ {
                    outs[i] = newStruct(f.Type.Out(i))
                }
                f.Type = reflect.FuncOf(ins, outs, f.Type.IsVariadic())
            case reflect.Interface:
                // TODO
            case reflect.Map:
                f.Type = reflect.MapOf(newStruct(f.Type.Key()), newStruct(f.Type.Elem()))
            case reflect.Ptr:
                f.Type = reflect.PtrTo(newStruct(f.Type.Elem()))
            case reflect.Slice:
                f.Type = reflect.SliceOf(newStruct(f.Type.Elem()))
            case reflect.Struct:
                f.Type = newStruct(f.Type)
            case reflect.UnsafePointer:
                // TODO
            }
        }

        rs[i] = f
    }

    return reflect.StructOf(rs)
}

//TODOとしているところはまだ未実装のところですが、今回のデモでは必要ないので省略しました。

この記事の山場は乗り越えたので、ここからはサクサクと説明していきたいと思います!

3. 新たな型のオブジェクトを生成する

まずは先ほど新しく生成した構造体の型情報を変数rtに格納しておきます。このrtは2.のrtとは別物です。

rt, err := NewStruct(v)

型情報rtからオブジェクトを生成するには次のようにすれば良いです。新たに生成されたviの型はinterface{}です

vi := reflect.New(rt).Interface()

4. JSON文字列を新たな型のオブジェクトにアンマーシャルする

生成したオブジェクトにJSON文字列をいつもやってる通りアンマーシャルします。自分で指定した時刻のフォーマットに基づいてアンマーシャルするため、そのフォーマットの指定の仕方が間違っていなければきちんとアンマーシャルされるはずです。

err := json.Unmarshal(jsonBytes, &vi)

5. 新たな型のオブジェクトを再びJSON文字列に変換する

え、なんでここでJSON文字列を生成するの?と思われるかもしれません。これは最終的にPersonのオブジェクトにJSON文字列をきちんとアンマーシャルするために行っています。元のJSON文字列中の時刻のフィールドがGo標準の時刻フォーマットに変換された新たなJSON文字列が生成されるので、何も弄っていないPersonオブジェクトにJSON文字列をきちんとアンマーシャルできるようになるのです。

data, err := json.Marshal(vi)

6. 再変換したJSON文字列をPersonのオブジェクトにアンマーシャルする

生成したJSON文字列をPerson文字列にアンマーシャルします。5.で述べた通りきちんとアンマーシャルできるはずです。

err := json.Unmarshal(data, v)

おわりに

ここまで、黒魔術の内容についてざっくりと見てきました。文章量はそこそこありましたが、やってきたことはただ型を置き換えた新たな構造体を動的に作るということだけでした。ただ、reflectを多用しており速度面やコンパイル時の型チェックが不十分な場合があるので、冒頭でも述べましたが用法用量を守り正しくお使いください。

というネタでした。

参考文献


  1. 「パース」、「デシリアライズ」ともいう 

26
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
15