📀

【Go】レコード型JSONの変換・入出力モジュール

2024/11/10に公開

概要

突然ですが、レコード型JSON(※)ってよく出てきますよね?

※正式名称がわからないのでこのように呼びました。以下のように、最上位がリストになっていて、その要素として同じ構造の Key-Value オブジェクトが入っているような形式です。

ほら、よく目にしませんか。

[
    {"datetime": "2024/11/10 10:01:00", "no": 1, "value": 10},
    {"datetime": "2024/11/10 10:02:00", "no": 2, "value": 20},
    {"datetime": "2024/11/10 10:03:00", "no": 3, "value": 30},
    ...
]
2024/12/11 追記

DataFrameっていうのが一番しっくりくるのかもしれないですが、少しニュアンスが異なる気も?

例えば、次のようなケースで扱ったことがあるのではないかと思います。

  • JSON形式のログデータ
  • DBからSelectしてきたデータを格納するとき
  • 解析した結果を一時的に格納しておくためのオブジェクトとして

私はGo言語で解析ツールを作ることがあり3つ目のケースによく遭遇するのですが、上記の形式で保持したオブジェクトの内容をJSONファイルやCSVファイルとして保存するときに毎回一手間必要になってしまうため共通化したモジュールにしておきたいと考えました。

作成したモジュール

https://github.com/bugph0bia/go-mapslice

モジュール名はセンスの良い名前を思いつかなかったので安直です。
(Goだとレコード形式のJSONは Map と Slice というデータ型の組み合わせで実現します)

機能はシンプルで、大別すると次の2つだけです。

  1. レコード型JSON形式とテーブル形式のデータを相互変換する。
  2. レコード型JSON形式のファイル入出力をする。

ジェネリクスについて

用意した関数はジェネリクスを利用しているので、Key と Value が様々な型のデータを扱えるようになっていますが、any にはしていないので一部扱えない型があります。実用の面で問題になることは無いと思っていますが。具体的には、次の型制約が存在します。

  • Key の型は、型制約 cmp.Ordered を満たす必要があります。
    • Mapのキーにするためには一致・不一致を比較するための型制約 comparable を満たす必要があり、且つ、テーブル形式に変換するときに順序比較を行う必要があったので型制約 cmp.Ordered を満たす必要がありました。後者は前者を内包しますので、結果的に型制約は cmp.Ordered となりました。
  • Value の型は、型制約 comparable を満たす必要があります。
    • ゼロ値かどうかの判定をして値を無視するオプションを付け加えたかったためです。

機能詳細

1.レコード型JSON形式とテーブル形式のデータを相互変換する

格納したデータをテーブル形式に変換するには、関数 MaplistToTable を使用します。
データをCSVファイルに保存することがよくあるため用意しました。一度変換してしまえば、後はCSVファイルに保存するのは簡単です。

レコード型JSONは、Goだと例えば []map[string]int のような型となります。テーブル形式になると、[][]int のような二次元スライスとなります。
※実際にはヘッダ部とデータ部は別の型になることがあります。その点は後述します。

// レコード型JSON形式
[
  {"key1": 1, "key2": 2, "key3": 3},
  {"key1": 4, "key3": 5, "key4": 6},
]

↓ こんな風に変換したい

// テーブル形式
[
  ["key1", "key2", "key3", "key4"],
  [1, 2, 3, 0],
  [4, 0, 5, 6]
]

次のように関数を呼び出すと、変換をすることができます。

import "github.com/bugph0bia/go-mapslice"

func main() {
    maplist := []map[string]int{
        {
            "key1": 1,
            "key2": 2,
            "key3": 3,
        },
        {
            "key1": 4,
            "key3": 5,
            "key4": 6,
        },
    }

    header, data := mapslice.MaplistToTable(maplist, nil)
  • 変換結果は、ヘッダ部(テーブルの1行目)とデータ部(テーブルの2行目以降)に分かれて返されます。ジェネリクスにより、ヘッダ部はマップのKeyの型のスライス、データ部はマップのValueの形の2次元スライスとなります。
  • 上記の例は、あえて1つ目と2つ目のレコードのキーを不一致にしています。実際にこのようなケースもありえますので、これが許容される関数だと便利だと考えました。
  • 関数の第2引数には、固定列にしたいきー(変換後のテーブルで左側に寄せたいキー)の配列を指定できます。不要であれば nil でよいです。

上記で自身の目的は満たされましたが、テーブル形式からレコード型JSON形式への変換関数 TableToMaplist も用意しました。引数と戻り値が逆になります。

maplist = TableToMaplist(header, data, true)

上記のように呼び出すと、逆変換により元のデータに戻りますが、第3引数に false を指定することで、ゼロ値を残したまま格納できます。

2.レコード型JSON形式のファイル入出力をする

GoでJSONファイルの入出力をする場合、標準ライブラリだと json.Marshal json.Unmarshal を使いますが、これらは予め準備した構造体変数にJSONファイルの内容をマッピングすることで入出力を行うため、レコード型JSONのように、最上位がリストになっている場合は利用することができません。そのため、レコード型JSONを入出力するための関数を用意することにしました。

関数 ReadJson で、io.Reader から読み込むことができます。
KeyとValueの型を表すような引数は存在しないため、型パラメータを明示的に書く必要があります。

import (
    "os"

    "github.com/bugph0bia/go-mapslice"
)

func main() {
    f, err := os.Open("foo.json")
    if err != nil {
        panic(err)
    }
    defer f.Close()

    var maplist []map[string]int

    maplist, err := ReadJson[string, int](f)
    if err != nil {
        panic(err)
    }
}

関数 WriteJsonio.Writer へ書き出すことができます。
出力対象のオブジェクトを引数に渡すので、型パラメータを明示的に書く必要はありません。

import (
    "os"

    "github.com/bugph0bia/go-mapslice"
)

func main() {
    maplist := []map[string]int{
        {
            "key1": 1,
            "key2": 2,
            "key3": 3,
        },
        {
            "key1": 4,
            "key2": 5,
            "key3": 6,
        },
    }

    f, err := os.Create("foo.json")
    if err != nil {
        panic(err)
    }
    defer f.Close()

    err := WriteJson(f, maplist) // WriteJson[string, int](f, maplist) と同じ
    if err != nil {
        panic(err)
    }
}

内部の仕組みですが、関数内では結局のところ json.Marshal json.Unmarshal を呼び出しています。前述の通り、そのままではレコード型JSONを使えないのですが、内部で更に上位のオブジェクトでラップすることで構造体で扱える形にしてから読み書きするという仕組みです。

[
  {"key1": 1, "key2": 2, "key3": 3},
  {"key1": 4, "key3": 5, "key4": 6},
]

↓ これを内部でこのように変換して扱っている。

{
  "body": [
    {"key1": 1, "key2": 2, "key3": 3},
    {"key1": 4, "key3": 5, "key4": 6},
  ]
}

そうすると、`json.Marshal` `json.Unmarshal` を利用して下記の構造体と入出力ができる。

struct{
    Body []map[string]int  `json:"body"`
}

感想

大したコード量のモジュールではないですが、自分でよく使う関数はライブラリ化しておくと便利ですね。Goはgithubに気軽に公開モジュールを作れるのがとても魅力的です。
また、今回Goのジェネリクスを初めて利用することで良い勉強になりました。

Discussion