🎄

Go で Protocol Buffer で JSON エンコード・デコードする

2024/12/24に公開

この記事は Magic Moment Advent Calendar 2024 17 日目の記事です。

メリークリスマスイブ!🎄

Magic Moment ソフトウェアエンジニアの scent-y です。

弊社では特定の時点でのデータの状態をスナップショットとして DB にそのまま保存したいケースがあり、そういったデータをスキーマ定義してアプリケーションコードで扱いやすくするために、Protocol Buffers を活用しています。

Protobuf は構造化されたデータを言語やプラットフォームに依存せずにシリアライズすることを可能にします。

Protobuf を採用している理由としては、単に JSON で永続化するだけだとスキーマの内容が制約されず、異なるサービス間で参照したい際に扱いづらかったりするので、メンテナンス性を考慮して Protobuf を採用しています。

ドキュメントに記載がある通り、Protobuf は JSON エンコードをサポートしているので、 デバッガビリティの観点から Protobuf でスキーマ定義してバイナリ形式でそのまま保存するのではなく、JSON エンコードして DB に永続化しています。

今回は、Go で Protobuf messages を JSON 形式で扱う方法に関して記述した、ライトな内容の記事になります。

protojson パッケージを利用する

Go の標準パッケージである
encoding/json は Protobuf messages では正しく動作しない可能性があるので、Protobuf のガイドラインに追従している encoding/protojson パッケージを利用することが推奨されています。

protojson でマーシャル・アンマーシャルする

簡単なサンプルで、.proto ファイルで定義したスキーマを、JSON にエンコードする例を見ていきます。

下記のような定義があったとします。

syntax = "proto3";

option go_package = "protojsonsample/pb";

import "google/protobuf/timestamp.proto";

message Cat {
  int32 id = 1;
  string name = 2;
  int32 age = 3;
  string breed = 4;
  google.protobuf.Timestamp last_updated = 5;
}

protojson で JSON にエンコードします。

package main

import (
	"fmt"
	"log"

	"google.golang.org/protobuf/encoding/protojson"
	"google.golang.org/protobuf/types/known/timestamppb"
	
	"protojsonsample/pb"
)

func main() {
	cat := pb.Cat{
		Id:          1,
		Name:        "meow",
		Age:         8,
		Breed:       "mix",
		LastUpdated: timestamppb.Now(),
	}

	jsonData, err := protojson.Marshal(&cat)
	if err != nil {
		log.Fatalf("marshal error: %v", err)
	}
	
	fmt.Printf("marshaled json:\n%s\n", jsonData)

	var newCat pb.Cat
	if err = protojson.Unmarshal(jsonData, &newCat); err != nil {
		log.Fatalf("unmarshal error: %v", err)
	}

	fmt.Printf("unmarshaled cat: %+v", &newCat)
}

上記を実行すると、JSON に変換されていることが確認できます。

marshaled json:
{"id":1, "name":"meow", "age":8, "breed":"mix", "lastUpdated":"2024-12-22T10:52:04.993964Z"}

unmarshaled cat: id:1  name:"meow"  age:8  breed:"mix"  last_updated:{seconds:1734864724  nanos:993964000}

encoding/json との違い

上記のコードを、encoding/json パッケージに変えてみるとどのような結果になるのでしょうか。

package main

import (
	"encoding/json"
	"fmt"
	"log"

	"google.golang.org/protobuf/types/known/timestamppb"

	"protojsonsample/pb"
)

func main() {
	cat := pb.Cat{
		Id:          1,
		Name:        "meow",
		Age:         8,
		Breed:       "mix",
		LastUpdated: timestamppb.Now(),
	}

	jsonData, err := json.Marshal(&cat)
	if err != nil {
		log.Fatalf("marshal error: %v", err)
	}
	
	fmt.Printf("marshaled json:\n%s\n", jsonData)

	var newCat pb.Cat
	if err = json.Unmarshal(jsonData, &newCat); err != nil {
		log.Fatalf("unmarshal error: %v", err)
	}

	fmt.Printf("unmarshaled cat: %+v", &newCat)
}
marshaled json:
{"id":1,"name":"meow","age":8,"breed":"mix","last_updated":{"seconds":1734864536,"nanos":565153000}}

unmarshaled cat: id:1  name:"meow"  age:8  breed:"mix"  last_updated:{seconds:1734864536  nanos:565153000}

protojson でエンコードした場合は、キー名がキャメルケースに変換されていることが分かります。
ドキュメントGenerates JSON objects. Message field names are mapped to lowerCamelCase and become JSON object keys.と記載がある通り、protojson は Protobuf の仕様に従い、 キー名をキャメルケースに変換しています。

対して encoding/json は、.proto のスキーマ定義をそのまま利用するため、キー名は last_updated のままです。

エンコードでのタイムスタンプの変換にも違いがあることが分かります。
protojsonは Timestamp 型を RFC 3339 形式の文字列に変換し、encoding/json は Timestamp 型を秒とナノ秒に変換しています。

JSON エンコードでキー名をデフォルトの挙動から変えたい場合

protojson で JSON のキー名を任意のものに変換したい場合、json_name を利用することで可能になります。

message Cat {
  int32 id = 1 [json_name = "catId"];
  string name = 2 [json_name = "catName"];
  int32 age = 3 [json_name = "catAge"];
  string breed = 4 [json_name = "catBreed"];
  google.protobuf.Timestamp last_updated = 5 [json_name = "lastUpdatedAt"];
}

上記を使用したエンコード結果は下記の通りです。

marshaled json:
{"catId":1, "catName":"meow", "catAge":8, "catBreed":"mix", "lastUpdatedAt":"2024-12-22T11:34:20.801995Z"}

JSON のキー名として .proto ファイルで定義したフィールド名をそのまま使用したい場合、オプションで指定することで可能になります。

marshaler := protojson.MarshalOptions{
    UseProtoNames: true, // オプションを追加
}

jsonData, err := marshaler.Marshal(&cat)
if err != nil {
    log.Fatalf("marshal error: %v", err)
}

fmt.Printf("marshaled json:\n%s\n", jsonData)

結果は下記の通りで、デフォルトの仕様であるキャメルケースに変換されず、last_updated になっています。

marshaled json:
{"id":1, "name":"meow", "age":8, "breed":"mix", "last_updated":"2024-12-22T11:47:00.975035Z"}

最後に

今回の記事で普段利用しているパッケージ間の挙動の違いを改めて確認するきっかけになり、仕様を正しく把握することは思わぬバグを防ぐことなどにつながるので、良い機会になりました。

ここまで読んでいただいてありがとうございました。

皆さま素敵なクリスマスイブをお過ごしください🎁

アドベントカレンダーのラストを飾るのは、清家さんの「ADR から始める ADR」 です。お楽しみに!

Discussion