薄いブログ

技術の雑多なことを書く場所

go-openapi/loadsを速くした話

github.com

GitHub - go-openapi/loads: openapi specification object model の速度を改善したときの話をしたいと思います.

go-openapi/loadsはgoでopenapiのschemaを読み込むためのライブラリです.

調査の結果json.Unmarshalが重く,このライブラリのAnalyzedという関数から呼び出されていることがわかっていました.

実際に見てみると以下のような実装でした.

// Analyzed creates a new analyzed spec document
func Analyzed(data json.RawMessage, version string) (*Document, error) {
    if version == "" {
        version = "2.0"
    }
    if version != "2.0" {
        return nil, fmt.Errorf("spec version %q is not supported", version)
    }

    raw := data
    trimmed := bytes.TrimSpace(data)
    if len(trimmed) > 0 {
        if trimmed[0] != '{' && trimmed[0] != '[' {
            yml, err := swag.BytesToYAMLDoc(trimmed)
            if err != nil {
                return nil, fmt.Errorf("analyzed: %v", err)
            }
            d, err := swag.YAMLToJSON(yml)
            if err != nil {
                return nil, fmt.Errorf("analyzed: %v", err)
            }
            raw = d
        }
    }

    swspec := new(spec.Swagger)
    if err := json.Unmarshal(raw, swspec); err != nil {
        return nil, err
    }

    origsqspec := new(spec.Swagger)
    if err := json.Unmarshal(raw, origsqspec); err != nil {
        return nil, err
    }

    d := &Document{
        Analyzer: analysis.New(swspec),
        schema:   spec.MustLoadSwagger20Schema(),
        spec:     swspec,
        raw:      raw,
        origSpec: origsqspec,
    }
    return d, nil
}

パッと見ると遅いjson.Unmarshalが2回同じ対象に実行されていることがわかります.

詳細な背景はわかりませんが, どうやら一つはプログラム中で変更してしまうもので元のデータを保持しておきたいためのものだとわかります.

つまり, 2回目のjson.UnmarshalはjsonをUnmarshalしたい目的ではなくswspecと同等のものを用意したい, deep copyがしたいという意図でした.

json.Unmarshalのコストが低ければこれでも問題ないと思うのですが, spec.Swaggerはjson.Unmarshal時にOpenAPIの仕様を満たすために独自のjson.Unmarshalerが実装されています.

どうなっているか確認してみました.

spec/swagger.go at 93213dab6b424cc2dd3fe3aab33e6c5660aa3343 · go-openapi/spec · GitHub

func (s *Swagger) UnmarshalJSON(data []byte) error {
    var sw Swagger
    if err := json.Unmarshal(data, &sw.SwaggerProps); err != nil {
        return err
    }
    if err := json.Unmarshal(data, &sw.VendorExtensible); err != nil {
        return err
    }
    *s = sw
    return nil
}

まず最初にswaggerが満たすべき最低条件のためにsw.SwaggerPropsをjson.Unmarshalし, X-から始まる拡張用のフィールドのために再度json.Unmarshalする実装になっています.

それ以外にもspec.Swaggerのjson.UnmarshalはX-から始まる拡張用フィールドをいたるところでサポートしているので普通のものと比べて重い処理であることがわかります.

一度jsonを解釈してgoの構造体に落としているのだから, deep copyのためだけに再度jsonを解釈するコストが無駄なように思えます.

確かに構造体をポインタなども含めてちゃんとdeep copyをするコードを書くのは面倒くさいのでやりたくない気持ちは理解できます.

Golang Benchmark: gob vs json · GitHub

をみてgobにencodeしてからdecodeするほうがjson.Unmarshalによるdeep copyより速いのではないかと思いました.

実際に実装して, Benchmarkのコードを書いてみると2倍くらい速度の改善が見られました.

しかし,どんな場合でもjson.Unmarshalよりgobのほうが優れているということはないはずなのでちゃんと計測を行いましょう.

そしてgobを実際に使用する場合, 事前にgob.Registerで登録していないと[]interface{}map[string]interface{}がうまく扱えないので注意してください.

また,encodeしてdecodeする形式は一時的にメモリを使用するという点とちゃんと実装されていなければ公開されていないフィールドは扱えないのでそこに注意する必要があります.

ちなみにPRの本文に書いてあるようなBenchmark間の差を見たい場合は

perf/cmd/benchstat at master · golang/perf · GitHub

のようなツールが非常に便利です. ぜひ使ってみてください.

最後に

このようなケースにおいて, 高速なdeep copyを実現する場合にもっと優れている方法を知っている方は教えていただけると幸いです.

もしくはPRを送ってもらってもいいと思います.