ブログ

フライウィールにおける protovalidate の導入と活用

はじめに

はじめまして、フライウィールのソフトウェアエンジニアの新幡です。

データソリューションを提供しているフライウィールでは、 Protocol Buffers (Protobuf) を活用して開発を行っています。Protobuf を使うことで、JSON と比べてデータを効率的にやりとりすることができます。また、社内のマイクロサービス間通信には、 Protobuf をベースにした Remote Procedure Call (RPC) フレームワークである gRPC を用いています。

Protobuf を保存したり、 gRPC で Protobuf を通じてサービスとやり取りをしたりする際、 そこに格納されている値が意図した値になっているか、「ありえない」値が入ってしまっていないかをチェックするのは重要なことです。そこで、この記事では protovalidate という Protobuf の値をバリデーションするツールについて紹介します。加えて、実際に、フライウィールへ導入した際の経験とその結果についても述べます。

protovalidate とは

protovalidate は、 Protobuf のためのツールチェーンを多数開発している Buf によって開発中の Protobuf のバリデーションができるツールです。現在、ベータ版がリリースされています。Protobuf の各 field の option にバリデーション用の制約条件を書くことができます。

簡単な例としては以下の形になります。

syntax = "proto3";
import "buf/validate/validate.proto";

message Request {
  // Identifier. Must be non-empty.
  string id = 1 [(buf.validate.field).string.min_len = 1];
}

上記の Protobuf 定義では、Request の id が空文字列ではないという制約を表現しています。

Common Expression Language (CEL) という記述方法を用いることで、さらに複雑なロジックを書くことができます。例えば、以下のようなバリデーションロジックです。このロジックでは、 output に入る文字列の集合 がinput に入る文字列の集合の部分集合であるという制約を表現しています。

syntax = "proto3";

import "buf/validate/validate.proto";

message InputOutputSetting {
  message Input {
    repeated string column_names = 1;
  }
  message Output {
    repeated string column_names = 1;
  }
  Input input = 1;
  Output output = 2;
  option (buf.validate.message).cel = {
    message: "Output columns should be a subset of input columns",
    expression: "this.output.column_names.all(x, this.input.column_names.exists(y, x == y))"
  };
}

実際に記述された制約に従っているかどうかのバリデーションを行うには、protovalidate の提供する関数(例えば Python の場合は protovalidate.validate)に Protobuf message を与えることで行います。

以下の例は、上記で定義した InputOutputSetting を用いて、バリデーションによって不正な値を検知できることを確認するテストコードです。

なお、 Protobuf の定義から生成されたコードは example_pb2 として import しています。

import example_pb2

import protovalidate
import pytest

def test_validation() -> None:
    message = example_pb2.InputOutputSetting(
        input=example_pb2.InputOutputSetting.Input(column_names=["test_column_name"]),
        output=example_pb2.InputOutputSetting.Output(
            column_names=["test_column_name", "test_column_name_not_in_input"]
            # test_column_name_not_in_input は Input にない値であり、不正な値なのでバリデーションエラーが期待される
        ),
    )
    with pytest.raises(
        protovalidate.validator.ValidationError,
        match="invalid InputOutputSetting",
    ) as e:
        protovalidate.validate(message)
    error_message = e.value.errors()[0].message
    assert error_message == "Output columns should be a subset of input columns"

フライウィールで導入した背景

フライウィールで protovalidate を導入した狙いは、Protobuf を定義しているチームにバリデーションロジックまで書いてもらうことです。

社内で現在開発中のデータ活用プラットフォーム Conata(コナタ)™ において、さまざまなニーズに対応するため、プロジェクトごとにどのようにデータを処理するかの設定をまとめた config を保存・管理する機能を開発しています。各 config は Protobuf で記述され、システム内の様々な箇所から gRPC を通して読み込むことができる形であり、また config の値は Web フロントエンドから設定できるようにする必要があります。全体としては、以下の図のような設計となります。

結果として、以下のような状況となりました:

  • Web フロントエンド上から config が自由に設定できる形のため、入力のバリデーションをWeb フロントエンド上で行いたい
  • 一方、config の内容自体は Web フロントエンド 以外のマイクロサービスやバッチジョブなどで利用されることが基本であるため、Web フロントエンドチーム以外のチームが config のスキーマとバリデーション内容を決めている

通例であれば、 Web フロントエンド で行われるバリデーションの実装は、そのまま Web フロントエンドチームが行うのが普通です。しかし、今回の config の場合には、バリデーション内容はスキーマを決めた別チームだけが知っているため、都度その内容を Web フロントエンドチームに伝達せねばならず、コミュニケーションコストが非常に高くなってしまいます。

可能であれば、config のスキーマを決めているチームがそのままバリデーションロジックも書けるような形にしたい、と模索しました。結果として protovalidate を用いればそれが達成できると判断され、導入が決まりました。特に、以下のことが決め手になりました。

  • Protobuf の定義自体にバリデーションが書かれているので、「スキーマを決めると同時にバリデーションロジックも書く」というフローで開発することができる。
  • Protobuf の定義にバリデーションロジックを書けば、JavaPython など、内部のマイクロサービスやバッチジョブで使われている言語でバリデーションが実行できる。 また、Web フロントエンドで行われている TypeScript についても将来的にサポートする計画がある。

様々な言語でのprotovalidate対応

Protobuf はフライウィールにおいて広く使われており、protovalidate の導入には社内で利用されている様々な言語での対応・工夫が必要でした。

  • Python
    Python を Python 3.11へアップデートすることが必要でした。これは protovalidate-python は Python 3.11 以上でしか動かないからです。当初は、protovalidate-python の pyproject.toml には Python 3.7以上という表記があったので、この問題には気づきませんでした。実際に試してみた後で気づき、社内で使われる Python をアップデートして対応しました。
    また、[BUG] requires-python = “>=3.7” in pyproject.toml is inaccurate という issue を立てたところすぐに対応していただき、現在は正しく Python 3.11以上が要求されるようになっています。
  • Java (Kotlin)
    Java では、protobuf-java のバージョンが 3.22.0 以上であることが必要です。これは protovalidate-java が新しい protobuf-java に依存しているためで、古い protobuf-java を使用すると Code generated w/ protobuf-java 3.22.0 broken in Quarkus に記載されているエラーが吐かれます。社内で用いている protobuf-java のバージョンが古かったため、バージョンを上げて対処しました。
  • Scala
    Scala では protovalidate のサポートはありません。詳しくは #300 v2 `protovalidate` support というGitHub Issue を参照ください。しかしながら、 Protobuf の定義から scala のコードを生成する箇所において、protovalidate の影響がありました。Protovalidate を使った Protobuf を定義する際に import する必要がある valdiate.proto などはoptionalという syntax を用いています。つまり、protovalidate の記述が含まれる Protobuf からコード生成を行う際には、 optional に対応している必要があります。 社内の scala の Protobuf コード生成には scalapb を用いているのですが、Comment on #870 Support `field_presence` from protoc 3.12 にあるように、scalapb は バージョン 0.11.0-M2 以降で optional をサポートしています。社内の scalapb はそれより古かったため、 scalapb をアップデートする必要がありました
  • TypeScriptのサポート
    残念ながら protovalidate はまだ TypeScript をサポートしていません。そのため、TypeScript で書かれている Web フロントエンドのために、protobuf のバリデーションを行うだけのマイクロサービスを別言語(Kotlin)で実装することで一時的に対処しています。将来的に、TypeScript を protovalidate がサポートした暁には、このサービスは不要になり、 Web フロントエンドで直接バリデーションを行うことができるようになるはずです。

実際に使ってみて

社内エンジニアからは、バリデーションロジックを書くための言語である CEL を書くのが難しいという声がありますが、バリデーションロジックも Protobuf 定義にかけ、わかりやすくて良いと概ね好評です。また、狙い通り、Web フロントエンドチームのコミュニケーションコストも下げることができました。

フライウィールでは、このように Protobuf / gRPC を用いた開発を行っています。現在、ソフトウェアエンジニアを積極採用中ですので、少しでもご興味をお持ち頂けましたら、まずはお気軽にカジュアル面談・ご応募頂ければと思います!