ProtocolBuffersスキーマ運用の改善: 手動から自動生成への移行

クラスター株式会社でSoftware Engineerをしている thara です。

cluster ではシステム間連携の一部にProtocol Buffers(以下protoと呼称)を使用しています。
protoのスキーマ定義を独立したproto管理リポジトリに配置し、そのスキーマから生成した各プラットフォーム向けのコードを複数のproto利用リポジトリで利用しています。

このprotoスキーマとそれから生成したコードを利用するまでのプロセスは、長い間運用されていましたが、問題を抱えていなかったわけではありません。
開発メンバーも増え、徐々にその問題が無視できなくなってきました。

この記事では、長年行ってきたその運用プロセスを最近改善したので、その事例を共有します。

まず、長年行ってきた「手動でコード自動生成した旧運用」とその課題について述べ、次にどのような運用に改善したかを解説します。

手動でコード自動生成する旧運用

旧運用を採用した背景として、以下のものがありました。

  • proto更新はそれほど頻繁に発生するタスクではなかった(月に1回あるかないか)
  • proto更新のために開発メンバーが行う必要がある事前準備は限りなく少なくしたい

それを踏まえて改善前の運用では、protoファイルを修正する際は、protoを修正した各メンバーがそれぞれ以下のような手順を取っていました。

  1. proto管理リポジトリのスキーマ定義を変更してcommitしてPull Request(PR)作成。PR reviewが通ったらマージ。
  2. proto利用リポジトリでgit submodule update
  3. gradleタスクでコード自動生成してcommitしてPR作成。CIが通ったらマージ。

4のGradleタスクでは、protobuf-gradle-pluginを使用してprotoファイルからコードを生成し、ローカルのworking treeに対して生成されたコードを再配置する、といったことを行っています。

なぜGradleを使っているかというと、クロスプラットフォームで実行可能なタスクランナーだからです。開発メンバーはWindows/macOS/Linuxとそれぞれ異なった環境で日々の開発をしているため、それらの環境で手頃に動くタスクランナーが、Android開発ですでに使用実績があるGradleでした。

Dockerという選択肢もありましたが、3D/2Dフロントエンドの開発者にproto更新のためだけに慣れていないDocker runtimeをインストールさせるのは憚られるため、採用されていませんでした。
(前述の背景「proto更新のために開発メンバーが行う必要がある事前準備は限りなく少なくしたい」を思い出してください)

先ほど「protoファイルからコードを生成し、ローカルのworking treeに対して生成されたコードを再配置する」と述べましたが、なぜ生成されたコードを再配置する必要があるかというと、protoスキーマ管理リポジトリにproto定義を置き、それを他のリポジトリから利用するという仕組み上の問題があるからです。
protoファイルにはgo_package optionなど、protoコード生成時に出力ファイルをどういったディレクトリ構造に配置するかを設定できます。しかし、これを実際にprotoが使用されるproto利用リポジトリのディレクトリ構成に合わせた設定にすると、proto定義リポジトリがproto利用リポジトリの内部に依存する、という逆の依存関係が新たに生まれてしまいます。また、そもそも複数のproto利用リポジトリのディレクトリ構成が一意に定まることを期待できないため、出力ディレクトリに関わる設定も一意に定める事ができない、という問題もあります。

よって、旧運用では「protoファイルからコードを生成し、ローカルのworking treeに対して生成されたコードを再配置する」という方法によって、go_package optionなどにはprotoのpackageに合わせた設定にしておき、proto利用リポジトリ側で必要に応じてファイルを再配置する、という手法を採用しました。

旧運用の課題

上記の運用でしばらくは問題なかったのですが、近年開発メンバーが増えたこともあって、以下のような課題が明らかになってきました。

note.com

Gradleをインストールする必要がある

「proto更新のために開発メンバーが行う必要がある事前準備は限りなく少なくしたい」と言ったものの旧運用でもGradleをインストールする必要がありました。Gradle自体のインストールはgradlewで省力化することはできていましたが、JavaVMのインストールは避けられません。Dockerよりも重くないものの、JavaVMのバージョンなどの考慮ポイントは、できれば無くしたい要素でした。

Windows上でコード生成した際にSwiftのコードが生成されていない

これは盲点だったのですが、proto更新するメンバーがprotoコードの自動生成をする、という運用上、protoコードの自動生成環境はproto更新するメンバーの開発環境になります。

clusterはAndroid/iOSアプリにUaaLで3Dフロントエンドを組み込むという構成にしているため、一つのリポジトリにUnity projectとAndroid/iOS projectが同居しているmonorepoになっています。
Unityをメインに開発するエンジニアはWindowsを主な開発環境として使うのですが、その環境だとswift-protobuf を使用するSwift向けのコード生成が機能していませんでした。
上述のGradleタスクでは、swift-protobufがインストールされていない状態でもタスク全体としては正常終了してしまっており、Swiftのコードが自動生成されていないことに気付けない状況が発生していました。

swift-protobufをWindowsにインストールすれば良いのですが、公式で提供されているバイナリやインストール手順はなく、まずWindows向けのswift tool chainをインストールして自前でswift-protobufをビルドする必要があります。

protoc pluginのバージョンが開発者の各環境ごとに差異が生まれることを防ぐ手段がない

proto利用リポジトリで複数言語を扱っている場合、proto生成に必要なpluginも多くなります。
上述のprotobuf-gradle-pluginでは、protoc pluginのバージョンを管理する仕組みはないため、それらのバージョンはタスク実行者の環境に委ねられます。

生成されたファイルにprotoc pluginのバージョンが記載されていればPRでチェックできるのですが、protoc pluginによってはバージョンを記載されていないものがあり、それを機械的にチェックするのは難しい状況でした。

開発メンバーがそれほど多くない時代ではproto変更する人それぞれに対してアナウンスすることができていました。ですが、開発メンバーが増え、proto変更する頻度が増えたり並列でproto変更が加えられたりするにつれ、そのような運用でカバーする方法は開発プロセス上の明確なボトルネックとして浮き彫りになってきました。

自動生成による改善

そこで、これらの課題を解決するために、proto変更を行う開発メンバーがGradleタスクによってコード生成するのではなく、proto変更時に自動的にコードを生成し、proto利用リポジトリにPRを出す、という運用に変えました。

大まかなプロセスは以下の通りです。

  1. proto管理リポジトリに対して、proto変更 & PRを作成。PR reviewが通ったらマージ。
  2. proto管理リポジトリのGitHub Actions workflowでprotoからコードを自動生成
  3. git patchによってpatchファイルを生成
  4. GitHub releasesを作成し、patchファイルをassetとしてアップロード
  5. proto利用リポジトリのworkflowを起動
  6. proto利用リポジトリのGitHub Actions workflowで3のpatchファイルをGitHub releasesから取得し、ローカルのworking treeに適用
  7. patchから取り込んだファイルをworking treeの所定の位置に再配置
  8. workflowからcommit & PR作成し、1を行った開発者に通知。
  9. 開発者はCIが通ったらマージ。

まず、CI上でproto生成することにより、常に一定の環境でコード生成するようにしました。
これにより、先ほど課題で述べたSwiftコードを生成できない問題を解決し、さらにproto pluginのバージョンを固定化できるようになりました。
また、開発者も単にproto変更のPRを出すだけで済むようになりました。

特徴的なのは git patchによってpatchファイルを生成 の部分です。
旧運用をそのままCIに移行すると、proto利用リポジトリで定義したworkflow内でコード生成することになり複数リポジトリからのprotoを利用するという構成上、「一定の環境でコード生成する」とは言えません。
そこでproto管理リポジトリのworkflowでコード生成することになるのですが、そのコード生成した結果をproto利用リポジトリ側に渡す手段として、patchファイルを採用しました。
proto利用リポジトリでは、patchファイルをgit applyすることでその変更を取り込むことができます。
なお、patchファイルは巨大なファイルになる可能性があるため、GitHub Releasesには圧縮したファイルをアップロードしています。副次的な効果として、GitHub Releasesのリリースノートを自動生成することによって、proto利用リポジトリ側でprotoの変更を取り込む際の変更範囲を把握できるようになりました。

なぜ直接protoスキーマ管理リポジトリからproto利用リポジトリにcommit & pushしないのかというと、protoスキーマ管理リポジトリからproto利用リポジトリのディレクトリ構成に依存したくなかったためです。
workflow起動という、protoスキーマ管理リポジトリからproto利用リポジトリへの依存はありますが、workflowというインタフェースに限定されるため、proto利用リポジトリ内でのディレクトリ構成の変更がprotoスキーマ管理リポジトリのworkflowに影響を与えない、という利点があります。

この運用のデメリットとして、新たなproto packageを追加した際にprotoスキーマ管理リポジトリとproto利用リポジトリ双方のworkflowに手を加える必要がありますが、以下の点においてカバーできていると思います。

  • 新たにproto packageを追加する機会は新規システム追加やマイクロサービス化などのタイミングであるため限定的である
  • 双方のリポジトリにどのような変更を加えるかを説明したドキュメントを用意する

まとめ

git patchという、現在主流と思われるGitHubやGitlabを使用したソフトウェア開発においてはあまり用いられないような機能を使っていますが、それによって開発プロセスがシームレスになるような運用にできたのではないかと思います。

今回は開発プロセスに関することなので直接プロダクションコードに関係しないとはいえ、メタバースという用語から印象を受けるような新しい概念や技術だけではなく、上記のような所謂「枯れた技術」の組み合わせで成り立っている要素もクラスターでは少なくありません。

クラスターでは、先人が築いてきた礎と新技術の組み合わせで、新たな価値を創造することに興味があるソフトウェアエンジニアを募集しています。

recruit.cluster.mu