複数のサブスキーマを持つデータへの対応におけるスキーマ記述言語の比較

2020年10月14日 水曜日


【この記事を書いた人】
四谷 兼三

IIJ 技術研究所 技術開発室所属。次世代認証・認可関連技術の研究に取り組んでいます。

「複数のサブスキーマを持つデータへの対応におけるスキーマ記述言語の比較」のイメージ

プログラミング中、何度も同じような、でも、その都度少しずつ異なるコード(ボイラープレートコードとも呼ばれます)を記述しなければならない場面は多々発生します。 可能ならば、このようなパターン化された単調なコードの記述は省略して、より重要なロジックの実装に集中したいものです。

Web APIの開発においても、毎回のように実装が必要なパターン化された処理は多々あります。 リクエストやレスポンスのデータ(現在、ほとんどがJSON形式)のバリデーションもその代表です。 データ種別ごとに手動でバリデーション処理を記述するのでは無く、データ仕様の定義(データスキーマ)を与えれば、自動的にバリデーションするような仕組みが理想でしょう。

本稿では、そんなバリデータの調査や検証中に見つけた課題についてご紹介したいと思います。

ポリモーフィックパターン

まず、以下のようなJSONデータを処理するケースについて考えてみます。 IoT機器から収集したデータを蓄積するようなAPIをイメージしてください。

[
    {
        "id": 0,
        "time": "2020-10-01T14:48:10Z",
        "device": "Light",
        "detail": {
            "switch": "Off"
        }
    },
    {
        "id": 1,
        "time": "2020-10-02T15:00:00Z",
        "device": "Thermometer",
        "detail": {
            "temperature": 26.5
        }
    }
]
  • リストに含まれる各データは共通のプロパティ(id、time、device、detail)からなります。
  • detailの構造はdeviceの種類により異なります。

このように、「データの一部(この場合はdetail)が異なるデータ構造を持つデータを、同じ種類のデータとして扱うデータモデリング手法」を、ここでは「ポリモーフィックパターン(Polymorphic Pattern)」と呼ぶことにします。 ポリモーフィックパターンは特にドキュメントデータベース等のスキーマレスなデータベースの活用例として多く見られます。 代表的なユースケースとしては、上記の例のようにログや通知等のイベント情報を表すデータで、個々のイベント種別ごとに異なる属性情報を持たせたい場合等が挙げられます。 例えば、Amazon EventBridge APIにおけるAWS Eventsデータなどはその典型と言えるでしょう。

さて、ポリモーフィックパターンは柔軟なモデリングを実現する一方、スキーマ定義にあいまいさを持ち込むことにもなります。 バリデータの実装においては、単にデータとバリデーションルールを照合させれば良いのではなく、どのサブスキーマと照合すべきか決定するロジックの実装も必要になってきます。 そして、そのロジックの実装の難易度は、スキーマ記述言語がポリモーフィックパターンをどの程度考慮しているかに大きく左右されます。 以下、JSONデータ定義用の代表的なスキーマ記述言語が、それぞれどのようにサブスキーマとその特定ロジックを定義しているのか、見てみることにしましょう。

JSON Schema

JSONのスキーマ記述言語として最も広く使われているのは、JSON Schemaでしょう。 JSON Schemaではサブスキーマの定義のために、anyOfoneOfなどのキーワードが用意されています(Combining schemas)。 先ほどのデータのスキーマをoneOfを用いて定義してみましょう。

{
    "type": "object",

    "properties": {
        "id": { "type": "integer" },
        "time": {
            "type": "string",
            "format": "date-time"
        },
        "device": { "enum": ["Light", "Thermometer"] },
        "detail": {
            "oneOf" : [
                { "$ref": "#definitions/LightLog" },
                { "$ref": "#definitions/ThermometerLog" }
            ]
        }
    },

    "definitions": {
        "LightLog": {
            "type": "object",
            "properties": {
                "switch": { "enum": ["Off", "On"] }
            }
        },
        "ThermometerLog": {
            "type": "object",
            "properties": {
                "temperature": { "type": "number" }
            }
        }
    }
}

詳細な仕様の解説は省略しますが、「detailのデータ構造はoneOfのリストで参照しているサブスキーマのいずれかである」と定義されていることはわかるかと思います。 上記のような記述によってサブスキーマを定義できますが、実際に対応プログラムを実装するに当たってはいくつかの課題があります。

(1) パフォーマンスへの悪影響

まず、実際のデータ構造がどのサブスキーマに合致するのか、anyOfの場合はマッチするサブスキーマが見つかるまで、oneOfの場合はそのリスト内すべてのサブスキーマと、照合を繰り返す必要があります。 したがって、サブスキーマの種類が増えれば増えるほど、バリデーションにかかる時間も増えることになります。 これは、シビアな性能が要求される環境の場合、無視できない負荷となる可能性があります。

(2) エラーメッセージの不明瞭化

バリデーションの結果が失敗の場合、スキーマのどの定義に違反しているのか、エラーメッセージとしてユーザーに提示すべきでしょう。 しかし、oneOfanyOfを使用して複数のサブスキーマと照合した場合、バリデーションエラーの原因も複数存在することになります。 それらすべての原因をエラーメッセージとして提示するとなると、サブスキーマの種類が多くなるにつれ、ユーザーにとってはエラー原因の理解が難しくなります。

JSON Schema Draft-07

上記の問題を解決する一助として、Draft-07にて、ifthenelseキーワードを使用した条件判定によるサブスキーマの適用(Applying subschemas conditionally)が導入されました。

{
    "type": "object",

    "properties": {
        "id": { "type": "integer" },
        "time": {
            "type": "string",
            "format": "date-time"
        },
        "device": { "enum": ["Light", "Thermometer"] }
    },

    "if": {
        "properties": {
            "device": { "const": "Light" }
        }
    },
    "then": {
        "properties": {
            "detail": { "$ref": "#definitions/LightLog" }
        }
    },
    "else": {
        "properties": {
            "detail": { "$ref": "#definitions/ThermometerLog" }
        }
    },

    "definitions": {
        "LightLog": {
            "type": "object",
            "properties": {
                "switch": { "enum": ["Off", "On"] }
            }
        },
        "ThermometerLog": {
            "type": "object",
            "properties": {
                "temperature": { "type": "number" }
            }
        }
    }
}

このようにifブロックの評価結果に応じて、thenブロックかelseブロックの一方が適用され、サブスキーマとの照合前にデータ構造の特定が可能です。 ただし、以下のような問題点もあります。

  • サブスキーマの種類が3つ以上の場合、elseブロックをネストして記述する必要があります。データモデルを素直に表現できないとともにスキーマの記述も煩雑になります。
  • サブスキーマ数の増加に伴い、ifの条件判定の試行数も増えるので、パフォーマンス上の問題を完全に解決できるわけではありません。

以上の点から、ポリモーフィックパターンの扱いに関してだけ述べると、JSON Schemaは最適の選択とは言えません。

OpenAPI

OpenAPIは単なるスキーマ記述言語では無く、Web API全体の仕様を記述するためのドキュメント記述仕様です。 その一部に、Schema Objectとして、スキーマ記述仕様が含まれています。 Schema Objectの記述仕様はJSON Schemaに独自の拡張を追加したものとなっており、JSON Schema同様、oneOfanyOf等のキーワードが使用可能です。 加えて、OpenAPIの独自仕様として、discriminatorというキーワードが追加されています(Inheritance and Polymorphism)。 discriminatorを使うと、どのサブスキーマを使用するか特定するための「タグ」となるプロパティを指定できます。

{
    "components": {
        "schemas": {
            "IoTLog": {
                "type": "object",
                "properties": {
                    "id": { "type": "integer" },
                    "time": {
                        "type": "string",
                        "format": "date-time"
                    },
                    "device": { "enum": ["Light", "Thermometer"] },
                    "detail": {
                        "oneOf" : [
                            { "$ref": "#components/schemas/LightLog" },
                            { "$ref": "#components/schemas/ThermometerLog" }
                        ],
                        "discriminator": {
                            "propertyName": "device",
                            "mapping": {
                                "Light": "#components/schemas/LightLog",
                                "Thermometer": "#components/schemas/ThermometerLog"
                            }
                        }
                    }
                }
            },

            "LightLog": {
                "type": "object",
                "properties": {
                    "switch": { "enum": ["Off", "On"] }
                }
            },
            
            "ThermometerLog": {
                "type": "object",
                "properties": {
                    "temperature": { "type": "number" }
                }
            }
        }
    }
}

detailの属性としてdiscriminatorに指定されているプロパティの値に応じて、mappingで指定されたサブスキーマが適用されます。 これにより、サブスキーマの決定が簡単になり、JSON Schemaでは発生していた問題を解決できます。 ポリモーフィックパターンの扱いに関しては、JSON SchemaよりもOpenAPIのスキーマ記述仕様の方が向いていると言えるでしょう。

OpenAPI v3.1

しかし、残念ながら、今後は状況が変わるかもしれません。 discriminatorキーワードは現在策定中のOpenAPIの次期バージョンv3.1にて、非推奨となることが予定されているのです(OpenAPI v3.1 and JSON Schema 2019-09)。 v3.1はJSON Schemaの最新の仕様への準拠を目指しており、OpenAPI独自の拡張を撤廃する方向で仕様が検討されています(Update Schema Objects to JSON Schema Draft 2019-09)。 その一環として、discriminatorも非推奨となるようです。

ただ、discriminatorが使えなくなることによって、上述の問題が発生することは把握されており、非推奨化の見直しに関する議論も行われているようです(Deprecate discriminator?)。 個人的には、JSON SchemaコミュニティとOpenAPIコミュニティが連携を取って、discriminatorもしくはその代替となる仕様を導入してくれることを期待しているのですが……。

CDDL(Concise Data Definition Language)

CDDL(Concise Data Definition Language)はRFC8610として標準化されているデータ仕様記述言語です。 主に、バイナリオブジェクトのデータ表現としてRFC7049で標準化されているCBOR(Concise Binary Object Representation)のデータ定義に使われていますが、JSONデータの定義にも使用できます。

先ほどのJSONデータをCDDLで定義すると以下のようになります。

iot-log = {
    id: uint,
    time: tstr,
    device: device-choice,
    detail: log-choice
}

device-choice = "Light" / "Thermometer"

log-choice = light-log // thermometer-log

light-log = {
    switch: switch-choice
}

switch-choice = "Off" / "On"

thermometer-log = {
    temperature: float
}

CDDLに関してはあまり調査できていないのですが、調べた限りではOpenAPIのdiscriminatorに相当するような仕組みは見当たりませんでした。 したがって、JSON Schemaの場合と同様の問題は残るものと思われます。 また、JSON SchemaやOpenAPIと比べ、対応ツールやライブラリが少ないため、実際に試すことが難しい点もネックです。 もし、独自に実装するとなると、JSON SchemaやOpenAPIによるスキーマ定義がJSONで記述されるのに対し、CDDLでは上記のような独自の構文が採用されているため、パーサーの実装から始める必要がある点にも注意です。

一方、CDDLを採用するメリットはその仕様の簡潔さにあると言えるでしょう。 JSON Schemaがかなり長大な仕様に膨れ上がってきているのに対し、CDDLはコンパクトな仕様にまとめられており、上述のパーサー実装の問題を除けば、対応プログラムの開発は容易かもしれません。

JTD(JSON Type Definition)

さて、ここまで見てきて、ポリモーフィックパターンを扱う場合の、スキーマ記述言語に求める条件が明確になってきました。

  1. JSON Schema/OpenAPIのようにスキーマ定義を独自の構文では無く、JSONで記述できること
  2. OpenAPIのdiscriminatorキーワードのようなサブスキーマの特定機能があること
  3. CDDLのように仕様がコンパクトであること

実は現在、上記の条件を満たすスキーマ記述言語として、JTD(JSON Type Definition)という仕様の策定がRFC化に向けて進められています(draft-ucarion-json-type-definition – JSON Type Definition)。 JTDのコンセプトはズバリ、

Its main goals are to enable code generation from schemas as well as portable validation with standardized error indicators.

と、バリデーション処理の自動生成のためのスキーマ記述仕様が目標に掲げられています。

先ほどのJSONデータをJTDで定義すると以下のようになります。

{
    "properties": {
        "id": { "type": "uint32" },
        "time": { "type": "timestamp" },
        "device": { "enum": ["Light", "Thermometer"] },
        "detail": {
            "discriminator": "device",
            "mapping": {
                "Light": {
                    "properties": {
                        "switch": { "enum": ["Off", "On"] }
                    }
                },
                "Thermometer": {
                    "properties": {
                        "temperature": { "type": "number" }
                    }
                }
            }
        }
    }
}

このようにdiscriminatorでサブスキーマを特定できるため、ポリモーフィックパターンへの対応も問題ありません。 記述にはJSONが使え、仕様も(現時点では)簡潔なため、対応プログラムを独自実装することも比較的容易でしょう。

まだJTDの仕様は不安定であり、関連ツール等もほとんど存在しないため、実用可能なレベルにあるとは言えません。 とは言え、将来、正式なRFCとなった暁には、かなり有力な選択肢となることが期待できます。

まとめ

以上、バリデーションの自動化という観点から、ポリモーフィックパターンへの対応について各スキーマ記述言語を比較してみました。

実際のところ、ほとんどのWeb API開発現場では比較検討するまでも無く、JSON Schema/OpenAPIを採用しているケースがほとんどだと思います。 でももし、本稿で取り上げたようなサブスキーマの記述仕様等、JSON Schema/OpenAPIの表現力に起因する問題にぶつかったら、他のスキーマ記述言語を検討してみても良いかもしれません。

参考ページ

四谷 兼三

2020年10月14日 水曜日

IIJ 技術研究所 技術開発室所属。次世代認証・認可関連技術の研究に取り組んでいます。

Related
関連記事