5GCのOpenAPI定義からファイル間循環参照を取り除いてみた話

この記事はBBSakuraNetworksアドベントカレンダー2022の3日目の記事です。

こんにちは。BBSakura Networks株式会社の金井です。 5GコアネットワークのOpenAPI定義を調べていた際の課題について少し書きたいと思います。

5GコアネットワークのOpenAPIの辛いところ

モバイル網で実用化されている5Gのコアネットワーク(以降、5GC)のAPIのインタフェース定義にはOpenAPIが採用されています。 そのためプロトタイプ実装は容易にできると考えられがちです。しかし実際のところ手軽ではありません。 次の2つが大きな問題としてあります。

  • OpenAPI3の安定したオープンなコード生成ツールは見当たりません
  • JSONとHTTPだけではなく別のバイナリプロトコルが埋め込まれる箇所があります

これらとは別に循環参照があるとGoでコンパイルが通らない問題があります。今回はこの循環参照について書きたいと思います。

ためしに自作のデバッグツールで仕様ファイル間の依存関係を出力してみます。

5GCの仕様ファイル間の依存関係のグラフ
5GCの仕様ファイル間の依存関係のグラフ

あまりに大きな依存グラフなので切り抜きました。完全なグラフはGoogle ドライブに置きキャプションからリンクしています。 理解するのが大変そうですね。

では今回の流れです。

  1. 目的: OpenAPIのコード生成を使ってプロトタイプ実装をGoで手早く取り組みたい。
  2. 課題: 仕様ファイルに循環した依存関係があり、生成されるGoコードがコンパイルエラーとなる。
  3. 方針: 仕様ファイルに順序を入れて循環がなくなるようにスキーマ定義を移動する。
  4. 結果: 循環参照が解決しました。
  5. 後日談: OSSのコード生成ツールはバグが多く利用できません。

仕様の相互参照がなぜ困るのか

Goは循環依存するパッケージを許可しません。そのため循環参照を持つ仕様ファイルから単純にコード生成するとパッケージ間依存が循環しコンパイルエラーをおこします。

課題と方針

一部のコード生成ツールには外部参照先の定義を埋め込む機能があるため検討しましたが、5GCの定義は巨大で一つにまとめるのは難しいと判断しました。 そのため前処理として5GCの仕様ファイル群から循環参照を取り除いたファイルを作ることにしました。

参照関係だけみると仕様ファイルはこんな感じになっています。これを元にどのような変更を加えたいか確認します。

凡例

たとえば次のような参照関係を持つ仕様ファイルはファイルレベルで循環グラフになっています。

循環参照を起こしている定義の具体例
循環参照を起こしている定義の具体例

スキーマを消してファイルだけ表示してみます。

ファイル間循環参照の具体例
ファイル間循環参照の具体例

この循環参照をとりのぞくためにスキーマ定義を移動します。 今回は仕様ファイル間に順序を定めて強制するようにしました。 順序があれば循環は現れません。またアルゴリズムが単純で実装も簡単です。 そして仕様ファイルから決定的な結果が得られます。これは大事な特性です。 もしかしたら焼きなまし法を使えば変更を少なく出来るかもしません。ですが結果が決定的にならないためコード生成には不適切です。

導入した順序にしたがって仕様ファイルに含めるスキーマ定義を決定していきます。 処理中のファイルから後ろのファイルに定義されたスキーマへの参照がある場合は今のファイルへ移動します。 スキーマは内部でさらに別のスキーマへ参照を持っているので再帰的に実行します。

日本語で書くと長いのですがやることは単純です。疑似コードや図を見れば理解できると思います。 次のような擬似コードになります。

func main() {
    removeCircularRefs(graph)
}
func removeCircularRefs(graph ファイルのグラフ) {
    sort.Sort(graph.仕様ファイルたち)
    for _, current := range graph.仕様ファイルたち {
        for _, ref := current.使われている参照たち {
            if ref.To.所属先ファイル が current または ref.To.所属先ファイル.処理済み {
                continue
            }
            参照先オブジェクトの定義ファイルを更新(ref.To, current)
        }
        ファイル.処理済み = true
    }
}
func 参照先オブジェクトの定義ファイルを更新(スキーマ, current 仕様ファイル) {
    スキーマ.所属先ファイル = current
    for _, ref := range スキーマ.参照しているところ {
        ref.仕様ファイルないに書かれている文字列 = currentを反映したobjectPathの値
    }
    スキーマ内部にある参照を探していたらcallbackを呼ぶ(スキーマ, func(ref) {
        if ref.To.所属先ファイル が current または ref.To.所属先ファイル.処理済み {
            return
        }
        参照先オブジェクトの定義ファイルを更新(ref.To, current)
    })
}

1つのスキーマ移動に伴う再帰的な変化と参照の変更を図にしました。 右のファイルから処理が進んでいきます。灰色のファイルが処理済みを表しています。

スキーマ移動の例
スキーマ移動の例

あと細かいことですがスキーマを移動すると元の定義場所が追えなくなるため名前に元の仕様ファイルを示すプレフィックスを付けました。

ファイル順序

このヒューリスティックではファイル順序が結果を大きく左右します。 まず素朴にファイル名を使ってみました。この方針を名前ベースと呼ぶことにします。 じつはいろいろなところから使われている ComonData3GPP TS29.571 に含まれていて後ろ側にあります。 昇順ではうまく行かなそうですね。実際に複雑なグラフになりました。画像にし忘れたのですが降順で実行しても良い結果になりませんでした。 そこでリンクを考慮することにして下のようにファイルの順序関係を比較しました。これをリンク集計ベースとと呼ぶことにします。

ファイル間参照関係を矢印で表現した図
ファイルの比較材料

func (m ファイル毎のリンク数の集計) Compare(o ファイル毎のリンク数の集計) int {
        // 1. ファイルに向かってくるエッジが多いと前にくる(先に処理する)
        if m.外部からの参照数 != o.外部からの参照数 {
                return -(m.m.外部からの参照数 - o.m.外部からの参照数)
        }
        // 2. ファイルから出ていくエッジが少ないと前にくる(先に処理する)
        if m.FromNum != o.FromNum {
                return m.外部への参照数 - o.外部への参照数
        }
        // 3. ファイル内部での参照が多いと前にくる(先に処理する)
        if m.LocalNum != o.LocalNum {
                return -(m.内部参照数 - o.内部参照数)
        }
        return 0
}

前処理の結果

リンク集計ベースでは名前ベースよりスキーマ移動を抑えられました。 処理結果を移動したスキーマ数・更新された参照数で比較します。

順序 移動したスキーマ 更新された参照数
ファイル名 809 17822
エッジ考慮 245 950

どちらの項目も減っていますが、更新された参照数が特に大きく影響を受けています。 これは多く参照されているスキーマを優先して位置を確定できているためだと考えています。 画像で確認するとわかりやすいと思ったので実際に比較してみます。

ファイル名の辞書順

まず基準となったファイル名による順序です。ファイル名は3GPPの各仕様書のzipに含まれており TSxxyyy_zzzz.yaml という形式を満たしています。

ファイル名に基づいた順序をつかって循環参照を取り除いた結果
ファイル名に基づいた順序をつかって循環参照を取り除いた結果

循環参照はなくなっているはずですが参照が錯綜していて読めません。

参照関係を利用

参照を集計して処理順を決めた実装ではグラフが落ち着きました。

参照を集計して順序を導入した結果
参照を集計して順序を導入した結果

改善するには

全てのファイルをまとめてスキーマ移動をせず、対象ファイルを小さなグループにわけられれば改善に繋がると考えています。 事前処理として強連結成分分解を使うのが適切で図の上段に対して強連結成分分割をして下段のように分割してから実行します。

強連結成分の抜き出し例の画像
強連結成分の抜き出し

振り返りと後日談

まだ強連結成分分解は試していません。まずGoでのコード生成を優先したからです。ところがコード生成は循環参照を取り除いてもまだ失敗します。

いくつか試したコード生成ツールでは事前に確認できていなかった細かい問題が重なりコード生成や生成済みコードのビルドが失敗しています。 また仮に動いたとしても5GCに頻出するoneOf,anyOfの多用されたコードは使い勝手が悪く採用に悩みます。

そのため今の時点ではどのコード生成ツールも採用できないと判断し強連結成分分解の検討はとめています。 (この文を書いてる間にogen-go/ogenはどうかと提案を受けたので試してみます)

おおむねOpenAPIのエコシステムは芳しくありませんでした。 そして生みの親のWeb界隈ではAPI定義にgRPCGraphQLといった代替技術が採用される傾向があります。 そのためモバイルコア業界からOpenAPIに貢献していかないとエコシステム(少なくともオープンなツール)の大きな改善は望めなそうだと感じています。 エコシステムを絶やさないように貢献し動きやすいモバイル業界にしたいですね。