[GO] structを定義せずにJSONファイルを読み書きしてみた

何事もゆるふわに扱っていきたい人生だった。
2021.07.19

GOに入門しています。 GOでJSONを扱う場合、標準サポートされてるencoding/jsonが存在します。 これを使ってJSONを読み込む場合、 多くの場合対応した構造体を用意して読み込むやり方と共に紹介されています。 しかしここ数年ずっとPythonばっかり書いていた私には、これは正直面倒すぎました。。。 おそらくGO的なスタイルとしては構造体を定義した方が正しいんだと思いますが、 私がJSONを読み込みたかった場面はこんな状況でした。

  • 使いたいのはある程度大きなJSONのごく一部
  • JSONの構造がまだこれから変わりうる

現実として、ある程度大きなJSONを読み込む場合でも 使いたい情報はそのごく一部であることも多いかと思います。 また、その使いたい情報とは全然関係ないところの構造が変わったという時に、 そのために構造体の定義を作り直すことも面倒です。

そこで、できるだけお手軽にJSONを読みつつ必要な情報だけを取り出し、 また、そのJSONを保存する方法を探していました。 読み込む方のやり方としては、もうまったく完全にこちらのページにある通りやっただけです。

golang は ゆるふわに JSON を扱えまぁす!

(香り屋さん、Windows時代は香り屋版Vimもよく使ってました。感謝!!)

また書き出しする方法についても調べました。 構造体を定義しなくても書き出ししたいと思っていたのですが、 それにちょうどいい情報がなかなか見つかりませんでした。 しかし結果的に言えば、非常にスタンダードなやり方で普通に書き出しもできました。

やりたいこと

以下のことを、できるだけ標準ライブラリだけで実行したかったんです。

読み込み

  • JSONファイルを(その構造として)メモリ上に読み込みたい
  • 読み込むJSONの構造にかかわらず、「毎回こうすれば良い」な方法が欲しい

利用

  • 値を取り出したり、書き換えたりしたい
  • できるだけ直感的な方法で読み書きしたい

書き出し

  • 書き換えたメモリ上のJSONデータをファイルに保存したい。
  • 書き出すJSONの構造にかかわらず、「毎回こうすれば良い」な方法が欲しい

やってみた

JSONファイル

サンプルとして適当なJSONファイルを用意しました。 DynamoDB関連のものですが、内容はなんでもいいので適当です。

sample.json.1

{
  "AttributeDefinitions": [
    {
      "AttributeName": "tableName",
      "AttributeType": "S"
    }
  ],
  "KeySchema": [
    {
      "AttributeName": "tableName",
      "KeyType": "HASH"
    }
  ],
  "GlobalSecondaryIndexes": [
    {
      "IndexName": "tableName-index",
      "KeySchema": [
        {
          "AttributeName": "tableName",
          "KeyType": "HASH"
        }
      ],
      "Projection": {
        "ProjectionType": "ALL"
      }
    }
  ],
  "BillingMode": "PAY_PER_REQUEST"
}

GOファイル

JSONファイルを読み込んで、一部を出力&変更しつつ保存するGOのコードです。 エラー処理は全て省略しているので、使用する際には適宜補って下さい。

main.go

package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"os"
)

func loadJson(inputPath string) interface{} {
	byteArray, _ := ioutil.ReadFile(inputPath)
	var jsonObj interface{}
	_ = json.Unmarshal(byteArray, &jsonObj)
	return jsonObj
}

func saveJson(jsonObj interface{}, outputPath string) {
	file, _ := os.Create(outputPath)
	defer file.Close()
	_ = json.NewEncoder(file).Encode(jsonObj)
}

func main() {
	// JSON読み込み
	jsonObj := loadJson("sample.json.1")
	// KeySchema -> 0 -> KeyTypeを表示
	fmt.Println(jsonObj.(map[string]interface{})["KeySchema"].([]interface{})[0].(map[string]interface{})["KeyType"].(string))
	// KeySchema -> 0 -> KeyTypeに"HOGE"を代入
	jsonObj.(map[string]interface{})["KeySchema"].([]interface{})[0].(map[string]interface{})["KeyType"] = "HOGE"
	// KeySchema -> 0 -> KeyTypeを表示
	fmt.Println(jsonObj.(map[string]interface{})["KeySchema"].([]interface{})[0].(map[string]interface{})["KeyType"].(string))
	// JSON書き出し
	saveJson(jsonObj, "sample.json.2")
}

実行結果

$ go run main.go
HASH
HOGE
$ diff sample.json.1 <(jq < sample.json.2)
26c26
<       "KeyType": "HASH"
---
>       "KeyType": "HOGE"

入出力を比較すると HASH => HOGE の置き換えがきちんと動作していることが確認できます。 なお、この書き出し方法の場合、 出力されるのは改行なしのぎちぎちJSONファイルですので、 比較の際にjqを通しています。

やっていること

読み込み

ioutil.ReadFile(inputPath)でファイル全体を一気に読み込みます。 JSONは{ }などの開閉関係が正しくなければ意味がないので、 一気に全部読み込む方法が適しています。

次に、interface{}として定義したjsonObj変数にファイルの内容を格納して行きます。 構造体の定義は不要です。 json.Unmarshalを使うだけです。

値の取り出し

これでjsonObjにデータが格納されましたので、そこから一部のデータを取り出してみます。 取り出す時にはコツが必要で、 .(map[string]interface{})などの「型アサーション」を使って1階層ずつ型を指定して行く必要があります。

この部分については、下部で再度触れます。

値の書き換え

ある値を書き換えたい時も基本は同じで、 型アサーションをしつつ目的の左辺までたどり着いたら、 あとは普通に=で代入するだけです。

なお、左辺の最後["KeyType"]については型アサーションは不要です。 というか、これからここに代入しようとしているので型アサーションするという対象ではなく、 むしろ、ここに新たにstring以外の型の値を代入することも可能です。 なんと言っても左辺はinterface{}型なのでどんな型でも入れられます。

書き込み

書き込みにはjson.NewEncoder(file).Encode(jsonObj)を使います。 jsonObjは読み込んだ時と同じinterface{}型のままで大丈夫です!

なお、NewEncoderの対になっているNewDecoderは、 ドキュメントに、structを定義して読み込む例とセットで書かれているので、 NewEncoderの方もしっかりと型が定義されていないと使えないかと思ったのですが、 interface{}のままでも問題ありませんでした。

型アサーション

型アサーションって何?

型アサーション は、インターフェースの値の基になる具体的な値を利用する手段を提供します。

Type assertions - A Tour of Go

なるほど、わかってる人にはわかるけど、わからない人にはわからない感じがします。

キャストに近いものだという感覚ですが、私の理解としては、

あなたは実は生来string型だったんですよ

のように、「本来の型を思い出させてあげる」という解釈が良さそうだなと思っています。 一方「キャスト」は

あなたは今だけはstring型として振る舞って下さい

と、振る舞い方を強制させるイメージでしょうか。

interface{}型は、「どんな型の変数でも代入ができる」ものですので、 「実際はstring型」といった「正体(本来の姿)」が存在しています。 「正体」が存在しているので、間違った型に型アサーションしようとすると怒られます。

var x interface{}
x = "1"                // string型の値を代入
fmt.Println(x.(int))   // intに型アサーションしようとする
panic: interface conversion: interface {} is string, not int

「正体」があるなら、実行時に自動的に解決して欲しい気もしますが、そこは 「いや、普通に型定義すりゃいい所を、お前が敢えてinterface{}型に代入してるんだから、 そこは自分で責任持てや」というわけなんだと思って諦めるのが良さそうです。

実際にたどってみる

jsonObj.(map[string]interface{})["KeySchema"].([]interface{})[0].(map[string]interface{})["KeyType"].(string)

の部分について、備忘録も兼ねて書いておきます。 これに該当する部分のJSONは以下のようになります。

jsonObj

{
  "KeySchema": [
    {
      "AttributeName": "tableName",
      "KeyType": "HASH"
    }
  ]
}

1「層」ずつ型アサーションする工程を、敢えてものすごく冗長に書いて行きます。

  • jsonObjinterface{}型だけど、正体はmap[string]interface{}
    • jsonObj.(map[string]interface{})とすることで、["KeySchema"]を指定できるようになる
    • ただしそれで取れるものは、まだinterface{}
  • jsonObj.(map[string]interface{})["KeySchema"]interface{}型だけど、正体は[]interface{}
    • jsonObj.(map[string]interface{})["KeySchema"].([]interface{})とすることで、[0]と指定できるようになる
    • ただしそれで取れるものは、まだinterface{}
  • jsonObj.(map[string]interface{})["KeySchema"].([]interface{})[0]interface{}型だけど、正体はmap[string]interface{}
    • jsonObj.(map[string]interface{})["KeySchema"].([]interface{})[0].([]interface{})とすることで、["KeyType"]を指定できるようになる
    • ただしそれで取れるものは、まだinterface{}
  • jsonObj.(map[string]interface{})["KeySchema"].([]interface{})[0].(map[string]interface{})["KeyType"]interface{}型だけど、正体はstring
    • jsonObj.(map[string]interface{})["KeySchema"].([]interface{})[0].([]interface{})["KeyType"].(ttring)とすることで、stringの値を取得できる

すごく冗長ですが、下記だけ理解すれば、あとは長いけど書くだけですね。

  • 内部構造を持つ「層」はマップ(map[string]interface{})かスライス([]interface{})しかない
  • 型アサーションは1「層」ずつやらないといけない

まとめ

GOでJSONをゆるふわに扱いつつ、 型アサーションってこんなものなのかぁということを勉強しました。

まだまだGOは触り始めで書き方にも慣れていない感じですが、 型を一つずつ確認しながらやるプログラミングって結構楽しくて好きなんですよね。 パズルを一つずつハメながら進んでいく感じが気持ちいいです。