技術本部 Eight Engineering Unit Mobile Applicationグループに所属しているAndroidエンジニアの若田(@wakanao_banana)です。毎朝バナナとヨーグルトを食べています。
TL;DR
- Eightの歴史的な型の負債をOpenAPIのoneOfで吸収していた
- kotlinx_serializationでは 特定のケースでOpenAPI Generatorのコード自動生成が壊れていた
- AIと協業してOSS本体を修正し、sealed interfaceによる型安全なoneOfクラス生成を実現した
Eightが抱えている「型」の歴史的負債
Eightでは1年ほど前からAPI定義にOpenAPIの導入を進めており、Androidアプリでは OpenAPI Generatorを用いてRetrofit2+kotlinx_serializationのKotlinコードを自動生成しています。
歴史的負債:多様なレスポンス形式との共存
Eightには長年の開発の中で真偽値をbooleanではなくinteger(0/1)で表している古いコードもあり、型が混在している状況でした。全ての古いAPIの型を確認するコストも高いため、複数の型のうちどれかであることを表すoneOfを用いてその差分を以下のように吸収しています。
is_hoge: description: hogeかどうか oneOf: - type: integer enum: [1, 0] - type: boolean
OpenAPI Generatorの壁にぶつかる
Eightではkotlinx_serializationを採用していますが、OpenAPI Generatorではkotlinx_serializationのoneOfクラスの生成がサポートされておらず、以下のような空のクラスが生成されていました。
// 修正前の生成結果 — プロパティもシリアライザもない空クラス @Serializable class IsHoge () { }
中身のないクラスが生成されてしまい、最初は応急処置として手動でカスタムモデルを作成してマッピングする方法で凌いでいました。
しかしその方法では、同じパターンに遭遇するたびにクラスを手動で作る必要があります。スキーマが変更されれば手動で追従しなければならず、OpenAPI定義との二重管理になってしまいます。
そのため、OpenAPI Generatorのコード生成自体を修正することにしました。
AIと協業して解決に挑む
OpenAPI GeneratorをClaude Codeと共に探索する
最初にOpenAPI GeneratorをForkし、Claude Codeを使ってコード生成の流れを追いました。特に役立ったのは、巨大なコードベースの中でKotlinのoneOf生成に関わる経路を素早く洗い出せたことです。KotlinClientCodegen.java → DefaultCodegen.java → oneof_class.mustache という生成フローを短時間で特定し、YAMLを入力してから Kotlinコードが出力されるまでのパイプラインの地図を作ることができました。
また、原因調査だけでなく参考実装の探索にもAIは有効でした。kotlinx_serialization側ではdiscriminatorなしoneOfの実装が不足していましたが、別ライブラリ向けテンプレートには近い実装がすでに存在しており、Claude Codeがその差分を見つける助けになりました。
一方で、最終的にどの形で生成するかは自分で判断しています。既存のGson実装は actualInstance: Any? を抱える形でしたが、それをそのまま持ち込むのではなく、より型安全に扱える sealed interface + @JvmInline value class に寄せる方針を取りました。今回AIが最も効いたのはコードを書く場面よりも、コード生成パイプラインの解読、参考実装の発見、設計の壁打ちといった調査・設計のフェーズでした。
なぜ空のクラスが生成されていたのか?
原因は、OpenAPI Generatorにおける kotlinx_serialization 向けのdiscriminatorなし oneOf サポートが不足していたことにありました。
ここで言うdiscriminatorとは、oneOfの候補のうちどの型であるかを判別するためのフィールドのことです。例えば { "type": "dog", ... } のように type フィールドで型を識別できる場合はdiscriminatorあり、今回の integer | boolean のようにそうしたフィールドがない場合はdiscriminator なしとなります。
OpenAPI Generatorはコードを生成するためにMustacheというテンプレートエンジンを使っています。「この型をどんなコードに変換するか」のひな形をテンプレートファイルに書いておき、そこにYAMLから読み取った情報を流し込むことでコードが生成されます。
oneOfのコード生成を担っているのが oneof_class.mustache というテンプレートです。このテンプレートの中身は大きく2つに分かれています。
[A] discriminator あり(判別フィールドがある)
→ 型を識別するフィールドの値でどのクラスか判断できる
→ kotlinx_serialization 対応済み ✅
[B] discriminator なし(判別フィールドがない)
→ JSON の構造を見て型を推測するしかない
→ Gson 対応済み ✅
→ kotlinx_serialization 未対応 ❌ ← ここが今回の問題Eightで使っていた is_hoge のような integer | boolean のoneOfはdiscriminatorのない [B] のパターンです。このパターンではGson向けの生成処理は用意されていた一方で、kotlinx_serialization 向けには必要な生成処理が不足しており、その結果として空のクラスが出力されていました。
修正した結果
Gsonの[B]の実装は data class(var actualInstance: Any?) という方式で、型を取り出す際にunsafeなキャストが必要になっていました。
今回の修正ではGsonの実装を参考にしつつも、より型安全になるように sealed interface + @JvmInline value class パターンを採用してkotlinx_serialization向けの処理を追加しました。AIと設計を壁打ちしながら方針を詰めつつ、最終的な設計判断と実装は自分で行っています。
// 修正前の生成結果 — プロパティもシリアライザもない空クラス @Serializable class IsHoge () { } // 修正後の生成結果 - sealed interface を用いた堅牢なクラス @Serializable(with = IsHogeSerializer::class) sealed interface IsHoge { @JvmInline value class IntegerValue(val value: kotlin.Int) : IsHoge @JvmInline value class BooleanValue(val value: kotlin.Boolean) : IsHoge }
利用側では when 式で全パターンを網羅的に処理できます。sealedなのでパターンの追加漏れはコンパイルエラーになり、型安全が保証されます。
val result: IsHoge = api.getIsHoge() when (result) { is IsHoge.IntegerValue -> handleInteger(result.value) is IsHoge.BooleanValue -> handleBoolean(result.value) // sealed なのでコンパイラが網羅性を保証 }
kotlinx_serialization + OpenAPI Generatorを使っているプロジェクトにとって、役立つ修正になったのではないかと思います。
おわりに
使用しているOSSの不具合にぶつかりましたが、AIを活用して根本原因を解消することができました。AIを用いても今回のような大きなOSSの不具合を解消することは簡単ではありませんでしたが、それでも以前よりOSSへコミットするハードルは下がっていると思います。
回避策を積み重ねるのではなく、上流で根本的に解決する。言葉にすれば当たり前ですが、大きなOSSを前にすると尻込みしがちです。今はAIが強力なパートナーとなり、あらゆるコードに対して理解を深めることができます。AIを活用して自分の影響範囲を広げたいと思う方の一助となれば幸いです。
Sansan技術本部ではカジュアル面談を実施しています
Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。