protolintの自作pluginによるprotoファイルのレビュー負荷軽減のための取り組み

こんにちは。cybozu.com Cloud Platformチームの pddg です。Cloud Platformチームでは現在、精力的に cybozu.com のインフラ基盤の移行に取り組んでいます*1。その取り組みの一環として、gRPCを用いたスキーマファーストな開発を推進しています。
今回はスキーマを記述したprotoファイルのレビューにおいて一部のレビュー項目をCIで自動的に確認されるようにし、レビュワーの負荷を軽減できたのでその方法についてご紹介します。

※ これは Cybozu Advent Calendar 2022 15日目の記事です。

背景

cybozu.com のバックエンドでは、これまではサービスのAPIのスキーマをドキュメント等によりカバーしてきました。これはその実装チームが整備し、適宜更新していくものを指します。しかし、こういったドキュメントはしばしばメンテされなくなり、実際のサービスの仕様と一致しなくなってしまうケースが多々見られました。これにより他チームがそのドキュメントを信用できなくなったり、開発の手戻りが増えたりという問題がありました。また、実装と共に仕様を書いていると、そのチームの実装が終わるまでそれを使うチームでは開発が止まってしまうことがあるなどの問題もありました。

インフラ基盤の移行では、複数のチームが開発する様々なサービス間でやりとりをする必要があることが予めわかっていました。また、並行運用期間を最小に抑えるため開発スピードも求められます。これまでの経験から、以前と同様のやり方では、移行という特に変化の激しい時期に複数チームが関わる開発は難しいと考えていました。

そこで、スキーマファーストな開発手法を導入することにしました。これにより、以下の様なメリットが得られたと考えています。

  • 最初に関わるチーム間で仕様を考え、テキストに落とし込む文化が根付く
  • 仕様ができれば各チームは独立して開発を進められる
  • 仕様と実装の乖離がおきにくくなる

スキーマファーストな開発手法にはいくつありますが、本プロジェクトではgRPCを推奨することにしました。

grpc.io

gRPCはGoogleが開発しているRPCのフレームワークで、シリアライゼーションフォーマットとしてProtocol Buffersを採用しています。 Protocol BuffersはGoogleが開発したIDLで、.proto という拡張子を持つファイルに記述します。本記事ではこのファイルをprotoファイルと呼んでいます。

protoファイルを他チームと共有することでAPIの仕様変更を相互にレビューしながら行える他、各言語向けのコード自動生成などにより実装負担も軽減することができるようになりました。

protoファイルの管理における課題

cybozu.com のサービスに関わるチームでは中央に一つスキーマを共有するリポジトリ(以下、中央リポジトリ)を置き、そこから自動生成された各言語向けのコードを各言語ごとに用意されたリポジトリへpushする方式を採用しています。これは自動生成されるコードによるDiffでPRが見づらくなったりすることや、各言語向けのコードの見通しを良くするなどの狙いがあります。
どのチームでも他チームから参照されるサービスの定義を中央リポジトリに追加することができるようになっています。

もちろんなんでも追加して良いというわけではなく、仕様以外にもスタイルやプラクティスの観点からレビューを行う必要があります。そのため以下の様な課題がありました。

  • Protocol Buffersにおける一般的なプラクティスや、社内で強制したいルール等に関して習熟している人は少なくレビュー負荷が集中する恐れがある
  • 仕様を決めることに集中したいのに、スタイルやルールの指摘などに時間を使ってしまう
  • 目grepではレビューから漏れてしまうことがある

強制したいルールの例

最初に、中央リポジトリを利用する際に求められるスタイルおよびルールの一部を紹介します。

まず、Googleが公開しているProtocol Buffersのスタイルガイドというものが存在します。

developers.google.com

複数チームそれぞれが独自のスタイルを持ってしまうことを避けるため、できるだけこのガイドラインに従うようにしています。

また、protoファイルにはファイルオプションというものを記述することができます。

https://developers.google.com/protocol-buffers/docs/proto3#options

このファイルオプションには、例えば java_packagego_package のようなコードの自動生成時に利用されるパラメータが含まれます。 これは中央リポジトリ全体で統一の取れたものを採用する必要があり、かつファイル内に記述する以外に指定する方法が無いため、protoファイルの追加時に含まれている必要があります。

この他にも、以下の様な複数のルールが存在していました。

  • importしてよいサードパーティprotoファイルの制限
    • google.* なprotoのみ、のような制限
  • パッケージ名の制限
    • 必ず cybozu.サービス名.メジャーバージョン というプレフィクスを持つ
    • サービスごとにpackage名が重複しないように、また、後々破壊的変更が必要になったとき対応出来るように
  • ...etc

protolintの紹介

これらのルールが遵守されているかCI上で自動的に検証する方法として、protolintを利用させて頂いています。作成された yoheimuta氏に深く感謝いたします。

github.com

先述したGoogle公式スタイルガイドに関するルール(デフォルト有効)および、独自のルール(デフォルト無効)が含まれており、これらを満たさないファイルがあるとチェックが失敗します。 これをCI上で実行することで最低限守られるべきルールが守られた変更のみがマージされるようになります。

protolint plugin

protolintによってGoogleのスタイルガイドを守ることはできますが、そのままでは社内独自のルールまでカバーすることはできません。 そこで、protolintが持つプラグインの機構を利用することで独自のlintルールをprotolintに実装するという方法を採用しました。

以降の情報は、以下の時点のバージョンでのものになります。

  • Go v1.19.3
  • protolint v0.42.2

今回紹介しているコードは以下のリポジトリにおいて、筆者がApache License 2.0で公開しているもののコピーになります。

github.com

諸注意:この記事で紹介するサンプル実装の正しさは保証しません。

簡単なpluginを書く

protolintのpluginはGo標準の plugin パッケージでは無く、 hashicorp/go-plugin が利用されています。 実際のところgo-pluginの使い方は綺麗に隠蔽されており、利用者はgo-pluginの詳細を知る必要はありません。非常に利用しやすい良いインターフェースだと感じました。

やることは大きく分けて3つです。

  1. Visitorの実装
  2. Ruleの実装
  3. 作成したRuleをprotolintに登録するコマンドの実装

まずVisitorを作成します。今回は例のため、特に内容に意味の無いものになっていることにご留意ください。

package main

import (
    "github.com/yoheimuta/go-protoparser/v4/parser"
    "github.com/yoheimuta/protolint/linter/visitor"
)

type sampleVisitor struct {
    // BaseAddVisitorを埋め込む。BaseAddVisitor自体の初期化は後から行う。
    *visitor.BaseAddVisitor
}

func (s *sampleVisitor) VisitPackage(pkg *parser.Package) bool {
    s.AddFailuref(pkg.Meta.Pos, "sample visitor: given=%v", pkg.Name)
    return true
}

BaseAddVisitorBaseVisitor がembedされており、BaseVisitor に各種 Visit* 関数が実装*2されています。 実際に実装されているものの一覧は以下のファイルで確認できます。

github.com

Visitorは Visit* 関数を使ってprotoファイルのASTをトラバースし、違反を見つけたら AddFailuref 関数を使って違反を報告します。

次にVisitorを組み込んだRuleを実装します。実装するRuleは以下のインターフェースを満たす必要があります。

github.com

package main

import (
    "github.com/yoheimuta/go-protoparser/v4/parser"
    "github.com/yoheimuta/protolint/linter/report"
    "github.com/yoheimuta/protolint/linter/visitor"
)

type SimpleDenyRule struct{}

// ID - アッパースネークケースのIDを返す。一意でなければならない。
func (r *SimpleDenyRule) ID() string {
    return "SIMPLE_DENY_RULE"
}

// Purpose - このルールの目的。
func (r *SimpleDenyRule) Purpose() string {
    return "sample implementation of protolint plugin"
}

// IsOfficial - このルールがGoogleの公式ガイドラインのものかどうか。基本的にfalse。
func (r *SimpleDenyRule) IsOfficial() bool {
    return false
}

// Apply - このルールを対象のprotoファイルに適用する。
func (r *SimpleDenyRule) Apply(proto *parser.Proto) ([]report.Failure, error) {
    return visitor.RunVisitor(&sampleVisitor{
        BaseAddVisitor: visitor.NewBaseAddVisitor(r.ID()),
    }, proto, r.ID())
}

IDは一意にするために自組織の名前をプレフィクスに入れると良いでしょう。サイボウズでは CYBOZU_ から始まるIDを設定しています。

最後にプラグインのバイナリのためのmain関数を書きます。

package main

import "github.com/yoheimuta/protolint/plugin"

func main() {
    plugin.RegisterCustomRules(
        &SimpleDenyRule{},
    )
}

複数のRuleを登録する場合、 RegisterCustomRules の引数を増やすだけでできます。

後はこのmain.goをビルドし、protolintの引数に渡すだけです。適当なprotoファイルを用いて試してみます。

$ cat << EOF > sample.proto
syntax = "proto3";

package simple;

message Test {
  string name = 1;
}
EOF
$ go build -o ./protolint-plugin-simple .
$ protolint lint -plugin ./protolint-plugin-simple sample.proto
[sample.proto:3:1] sample visitor: given=simple
$ echo $?
1

想定通り、package名を出力して失敗しました。plugin実装の一通りの流れは上記のようになっています。

特定のfile optionがあるかチェックする

シンプルなルールでは単に Visit* で訪れたノードの値を見てルールの違反を検知できますが、「特定のfile optionがある」ことを確かめるためにはファイル全体を走査してチェックする必要があります。この場合、Visitorの OnStartFinally 関数を活用することでうまく書けます。

package main

import (
    "sync"

    "github.com/yoheimuta/go-protoparser/v4/parser"
    "github.com/yoheimuta/protolint/linter/visitor"
)

type requiredOptionVisitor struct {
    *visitor.BaseAddVisitor

    requiredOptions []string

    mutex   sync.Mutex
    visited map[string]bool
    meta    *parser.ProtoMeta
}

func (r *requiredOptionVisitor) OnStart(proto *parser.Proto) error {
    r.mutex.Lock()
    defer r.mutex.Unlock()
    // 開始時に訪問済みのfile optionのmapを初期化する
    r.visited = make(map[string]bool)
    for _, opt := range r.requiredOptions {
        r.visited[opt] = false
    }
    // 最後にAddFailureするときにファイルの情報が欲しいのでメタデータを持っておく
    r.meta = proto.Meta
    return nil
}

func (r *requiredOptionVisitor) VisitOption(opt *parser.Option) bool {
    r.mutex.Lock()
    defer r.mutex.Unlock()
    // 訪れたfile optionは全てtrueにする。
    // 初期化時にfalseにされ、ファイル内に記述されていないものはfalseのまま残る。
    r.visited[opt.OptionName] = true
    return true
}

func (r *requiredOptionVisitor) Finally() error {
    r.mutex.Lock()
    defer r.mutex.Unlock()
    for opt, exists := range r.visited {
        // 記述の無い必須なfile optionがあった場合、違反を報告する
        if !exists {
            r.AddFailurefWithProtoMeta(r.meta, "'%s' is required", opt)
        }
    }
    return nil
}

ほぼ同じであるため、Ruleおよびmain関数自体の実装は省略します。リポジトリに完全な実装があるため、そちらを参照してください。

例えば go_package オプションを必須であるとして実装すると、以下の様になります。

$ cat << EOF > with_go_package.proto
syntax = "proto3";

package fileoption;

option go_package = "github.com/pddg/protolint-plugin-sample";

message Test {
  string name = 1;
}
EOF
$ cat << EOF > without_go_package.proto
syntax = "proto3";

package fileoption;

message Test {
  string name = 1;
}
EOF
$ go build -o ./protolint-plugin-required-option .
$ protolint lint -plugin ./protolint-plugin-required-option *.proto
[without_go_package.proto:1:1] 'go_package' is required

plugin実装のはまりどころ

デバッグ方法が難しい

作成したプラグインのバイナリ単体を動かしてもどうしようもなく、protolintにpluginを実行させると出力が全て虚空に吸われて消えるように見えます。 protolint lint の実行時に -v オプションを付けると、その他の大量のログと共に自分のログもstderrに吐かれるのでこれを見るのが良いでしょう。

protolint lint -v -plugin path/to/plugin *.proto 2> stderr.log

クォートが付いてくる場合がある

例えば VisitOptionVisitImport で取れる値にはダブルクォートが付いてきます。つまり以下の様なファイルがあったとき、

syntax = "proto3";

option go_package = "hoge";

取得できる値は hoge ではなく "hoge" になります。

func (v *Visitor) VisitOption(opt *parser.Option) bool {
    log.Printf("'%v'\n", opt.OptionName) // -> 'go_package'
    log.Printf("'%v'\n", opt.Constant)   // -> '"hoge"'
    return true
}

そのため「go_package は必ず github.com/cybozu というプレフィクスを持つ」のようなルールを書きたい場合、最初に " を削除しなければなぜかlintに違反してしまって首をかしげることになります。

今後の課題

gRPCやProtocol Buffers自体は後方互換性が重要視されており、APIの仕様を変更する際に後方互換を壊さないよう様々な工夫がされています。しかし、様々な事情によりAPIのインターフェースを(しばしば大きく)変更しなければならなくなることはあり、この中央リポジトリでは必ずprotoファイルのパッケージ名にメジャーバージョンを入れるというルールを導入しています。

基本的には後方互換を壊さないように各サービスの実装チームが工夫するのですが、意図しない後方互換の破壊を防げるとなお良いと考えています。非互換になる変更には警告を出す、メジャーバージョンの変更を促すなどができるとより安心してprotoを変更できるかもしれません。

これは単純にprotolintのpluginでどうにかというレベルの話ではないため、もっと複雑な仕組みが必要になり、現状ではどのように行えば良いかなども見当は付いていない状況です。良い方法をご存じの方がおられれば、参考にさせて頂きたいと思っています。

まとめ

Protocol Buffers(およびgRPC)によるAPIの管理は、特にチームをまたいだサービス間の連携において非常に大きな恩恵をもたらすものです。

各チームでの開発をさらに実のあるものにするため、protoファイルの品質は高く保たれるべきです。protolintは標準で公式のスタイルガイドに沿っていることを確かめてくれる素晴らしいlinterであり、単体での導入のみでもprotoファイルの品質向上に高く貢献してくれるでしょう。

更に社内で独自のルールを課したい場合、protolintのカスタムpluginが第一の候補として考えられます。簡単な実装で目視チェックを自動化でき、protoファイルのレビューにおいてより仕様策定に集中できるようになるはずです。

サイボウズではまだgRPCは使い始めたばかりと言っても過言ではなく、他社様の事例をたくさん参考にさせて頂いています。この記事も同様に皆様の参考になれば幸いです。


*1:以前はManekiチームと呼ばれていたチームが、現在はCloud Platformチームの一部に再編されました。今はCloud PlatformチームとしてManeki Projectという移行プロジェクトに取り組んでいます。新インフラ基盤NecoやManekiに関する詳細は以下をご覧下さい。

blog.cybozu.io

*2:何もしない実装が存在します。interfaceになっていないのは、不要な実装をするよりもメソッドをオーバーライドする方が楽であるためでしょう。