Diary

Diary

日々学んだことをアウトプットする場として初めてみました

openapi-generator の作る Go の構造体を使ったら json.Marshal が 10 倍遅くなった

この記事は 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

手順

  1. petstore.yaml を (openapi.yaml という名前で) 保存
  2. 対象とする構造体の jsonres.json として用意
  3. .openapi-generator-ignore を指定パスに用意
  4. docker コマンドを使って構造体のみ生成 (component と component_method の 2 つ)
  5. component パッケージの方から UnmarshalJSON, MarshalJSON を削除
  6. 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 等では使ってみてください。