この記事は Go 言語 Advent Calendar 2023 シリーズ 2 の 10 日目の記事です。
openapi から Go のコードを生成するツールとしては、oapi-codegen, ogen などが勢いありますが、今回は openapi generator にまつわる話をします。
このライブラリで生成されたコードに起因して、json の Marshal が極端に遅くなってしまう事件が発生しました。
[目次]
生成手順と生成コード
以下のように、docker で cli を起動して生成しています。 (実験時の最新バージョンである v7.0.0 を使用。)
current_dir := $(shell pwd) .PHONY: gen gen: clear docker run --rm -v $(current_dir):/project openapitools/openapi-generator-cli:v7.0.0 \ generate -i /project/openapi.yaml -g go \ --additional-properties=packageName=component -o /project/gen/component -p enumClassPrefix=true .PHONY: clear clear: ls gen/component | xargs -n1 | grep -v ".openapi-generator" | xargs -I{} rm -r "gen/component/{}"
最終的には、以下のようなコードが生成されます(例)。
func (o Pet) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { return []byte{}, err } return json.Marshal(toSerialize) } func (o Pet) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} if !IsNil(o.Id) { toSerialize["id"] = o.Id } toSerialize["name"] = o.Name if !IsNil(o.PhotoUrls) { toSerialize["photoUrls"] = o.PhotoUrls } if !IsNil(o.Status) { toSerialize["status"] = o.Status } return toSerialize, nil }
一般に、構造体を json に変換するときは json#Marshaler のインタフェースが使われており、上記のように各自で独自の MarshalJSON を実装することも可能です。
実装しなくても基底型の Marshaler が使われることになるため、普段から意識することは少なく、実際今回も生成されてることを忘れていました。
MarshalJSON を削除してみた
ある API の中で JSON の変換に時間がかかっており、さまざまなサードパーティ製のライブラリを探す中で MarshalJSON が余計なことをしてる説が浮上しました。
そこで、openapi-generator のつくる MarshalJSON を削除したものを用意し、単純に時間を比べてみました。
# 19.3 MB の JSON で比較 # 特徴 # { # "total": 20000, # "result": [ # { # "a": "ほげほげ", # "b": "aaa", # "date": "2021/01/27 01:04:48", # "highlight": [ # "Xxx" # ], # "c": "59876" # }, # ] # } # MarshalJSON 削除前(デフォルト) average: 235 ms (num=10) # 削除後 average: 37 ms (num=10)
MarshalJSON がある状態とない状態(削除後)で 5 倍以上の差がついてしまってることがわかります。
もちろん構造体の性質・データ量にもよると思いますが、これほどの差が生まれてしまうとちょっとしんどいものがあります。。。
遅くなってそうな原因
MarshalJSON の実装の中で、string を key とする interface に詰め込んでました。 せっかく型情報を持っているのにわざわざ any に落とす必要がなく、最終的な Marshal 時に必要な情報が足りなくなることが想定されます。
openapi-generator の修正
ベンチマーク手順
OpenAPI-Specification のだす petstore を対象にベンチマークテストを行いました。
. ├── Makefile ├── gen │ ├── component │ └── .openapi-generator-ignore │ └── component_method │ └── .openapi-generator-ignore ├── go.mod ├── main_test.go ├── openapi.yaml └── res.json
手順
- petstore.yaml を (openapi.yaml という名前で) 保存
- 対象とする構造体の json を
res.json
として用意 .openapi-generator-ignore
を指定パスに用意- docker コマンドを使って構造体のみ生成 (component と component_method の 2 つ)
- component パッケージの方から UnmarshalJSON, MarshalJSON を削除
- Benchmark テストを作成
.openapi-generator-ignore
の中身
api_* client.go configuration.go .travis.yml README.md git_push.sh go.mod go.sum api/ docs/ test/
res.json
の中身
今回は、実際に自分が遭遇したケースと比べてかなり小さい JSON で実験しました。
{ "id": 111, "name": "Test name 111", "tag": [ "test tag", "kawaii" ] }
生成に使ったコマンド(Makefile)
current_dir := $(shell pwd) .PHONY: gen gen: clear docker run --rm -v $(current_dir):/project openapitools/openapi-generator-cli:v7.0.0 \ generate -i /project/openapi.yaml -g go \ --additional-properties packageName=component -o /project/gen/component -p enumClassPrefix=true .PHONY: clear clear: ls gen/component | xargs -n1 | grep -v ".openapi-generator" | xargs -I{} rm -r "gen/component/{}" .PHONY: genm genm: clearm docker run --rm -v $(current_dir):/project openapitools/openapi-generator-cli:v7.0.0 \ generate -i /project/openapi.yaml -g go \ --additional-properties packageName=component_method -o /project/gen/component_method -p enumClassPrefix=true .PHONY: clearm clearm: ls gen/component_method | xargs -n1 | grep -v ".openapi-generator" | xargs -I{} rm -r "gen/component_method/{}" .PHONY: bench bench: go test -bench .
ベンチマークテスト(
main_test.go
)
package main import ( "benchmark/gen/component" "benchmark/gen/component_method" "encoding/json" "os" "testing" ) // ==================== Unmarshal ==================== func BenchmarkJsonUnMarshal(b *testing.B) { bytes, _ := os.ReadFile("res.json") b.ResetTimer() for i := 0; i < b.N; i++ { var res component.Pet json.Unmarshal(bytes, &res) } } func BenchmarkJsonUnMarshalMethod(b *testing.B) { bytes, _ := os.ReadFile("res.json") b.ResetTimer() for i := 0; i < b.N; i++ { var res component_method.Pet json.Unmarshal(bytes, &res) } } // ==================== Marshal ==================== func BenchmarkJsonMarshal(b *testing.B) { bytes, _ := os.ReadFile("res.json") var res component.Pet json.Unmarshal(bytes, &res) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = json.Marshal(res) } } func BenchmarkJsonMarshalMethod(b *testing.B) { bytes, _ := os.ReadFile("res.json") var res component_method.Pet json.Unmarshal(bytes, &res) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = json.Marshal(res) } }
ベンチマーク結果
$ make bench go test -bench . goos: darwin goarch: arm64 pkg: benchmark BenchmarkJsonUnMarshal-8 1000000 1464 ns/op BenchmarkJsonUnMarshalMethod-8 1071717 1357 ns/op BenchmarkJsonMarshal-8 7063731 208.6 ns/op BenchmarkJsonMarshalMethod-8 1000000 1126 ns/op PASS ok benchmark 10.630s
実際の API で測定したレスポンスよりかなり小さい JSON でも同様に、明らかなパフォーマンスの差が確認できました。
Issue を立ててみた
openapi-generator 起因でパフォーマンスが悪くなっていることが確認でき、またこの実装になってる原因も分からなかったため Issue を作成してみました(Issue#16948)。 (OSS に Issue を立てるのが初めてだったため緊張しました。)
すると1日も経たないうちに
カスタマイズされた MarshalJSON メソッドは、プロパティの oneOf/anyOf スキーマ、 追加プロパティ、nullableプロパティなどの usecase に対応するために追加されました。 あなたのユースケースでこれらが必要ない場合、MarshalJSON メソッドの生成を スキップするオプションを追加するのはどうでしょうか?
とメンテナーの方から言われたため、オプションを追加してみることにしました。
additional-properties に追加
openapi-generator の cli を使う時に additional-properties を用いて生成内容をコントロールできるんですが、今回はそこに1つフラグを追加することにしました。
ベンチマークで UnmarshalJSON については差がなかったため、そちらの生成は残したままにすることにしました。
Java もマスタッシュ構文もわからない状況からだったんですが、なんとか PR がマージされ、v7.1.0 にとり込まれました。
これにより、v7.1.0 では generateMarshalJSON
フラグを false にすることで、MarshalJSON メソッドが生成されなくなります。
v7.1.0 を使ってみる
新しく additional-properties を追加した v7.1.0 のバージョンを使って、同様の条件でベンチマーク比較してみます。
Makefile の追加
.PHONY: gen710 gen710: clear710 docker run --rm -v $(current_dir):/project openapitools/openapi-generator-cli:v7.1.0 \ generate -i /project/openapi.yaml -g go \ --additional-properties generateMarshalJSON=false \ --additional-properties packageName=component_710 -o /project/gen/component_710 -p enumClassPrefix=true .PHONY: clear710 clear710: ls gen/component_710 | xargs -n1 | grep -v ".openapi-generator" | xargs -I{} rm -r "gen/component_710/{}"
Benchmark テストの追加(
main_test.go
)
func BenchmarkJsonUnMarshal710(b *testing.B) { bytes, _ := os.ReadFile("res.json") b.ResetTimer() for i := 0; i < b.N; i++ { var res component_710.Pet json.Unmarshal(bytes, &res) } } func BenchmarkJsonMarshal710(b *testing.B) { bytes, _ := os.ReadFile("res.json") var res component_710.Pet json.Unmarshal(bytes, &res) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = json.Marshal(res) } }
$ make bench go test -bench . goos: darwin goarch: arm64 pkg: benchmark BenchmarkJsonUnMarshal-8 1155070 1033 ns/op BenchmarkJsonUnMarshalMethod-8 1206620 1010 ns/op BenchmarkJsonUnMarshal710-8 374690 3348 ns/op BenchmarkJsonMarshal-8 7542001 176.8 ns/op BenchmarkJsonMarshalMethod-8 1220074 988.1 ns/op BenchmarkJsonMarshal710-8 8350143 123.1 ns/op PASS ok benchmark 10.379s
BenchmarkJsonMarshalMethod (v7.0.0) と BenchmarkJsonMarshal710 (v7.1.0) に着目して比べると、1/8 ほどの実行時間になっていることがわかります。
v7.1.0 で UnmarshalJSON が遅くなってそう?
ベンチマークをしてて思ったのですが、UnmarshalJSON が3倍程度遅くなってそうに見えます。
よくみてみると v7.1.0 で試した時は、構造体に以下のような UnmarshalJSON が追加されていました。
生成された UnmarshalJSON
func (o *Pet) UnmarshalJSON(bytes []byte) (err error) { // This validates that all required properties are included in the JSON object // by unmarshalling the object into a generic map with string keys and checking // that every required field exists as a key in the generic map. requiredProperties := []string{ "id", "name", } allProperties := make(map[string]interface{}) err = json.Unmarshal(bytes, &allProperties) if err != nil { return err; } for _, requiredProperty := range(requiredProperties) { if _, exists := allProperties[requiredProperty]; !exists { return fmt.Errorf("no value given for required property %v", requiredProperty) } } varPet := _Pet{} err = json.Unmarshal(bytes, &varPet) if err != nil { return err } *o = Pet(varPet) return err }
これは PR#16863 により v7.1.0 から入った変更で、『required がない時や anyOf が不適切な時などに validation エラーを吐く』ためのものでした。
yaml のなかに AdditionalProperties がある場合などは、構造体に生えてる UnmarshalJSON の中で json.UnmarshalJSON が 3 回も呼ばれており、なんとか改善できるんじゃないかな〜と思ってますが、いい案は浮かんでません。
おわりに
普通に使ってただけで JSON の取り扱いがめちゃくちゃ遅くなっててびっくりしましたが、ライブラリの中が何をしてるかはきちんと理解しないとダメだなと思いました。
nullable 属性の扱い方などはありますが、大きいレスポンスを扱う API 等では使ってみてください。