Carpe Diem

備忘録

proto-gen-validate を使う

概要

HTTPやgRPCなどのAPIでvalidationを実装する際、次のような課題に直面することがよくあります。

  • validationの実装を忘れる
    • 正常系だけを考え、異常系のイメージがない
  • validationがどこに実装されているか分からない
    • クライアント側との認識がずれたり、テスト漏れが起きやすい
  • validationをどこに実装するか悩む
    • オニオンアーキテクチャでいう「インフラ層・ユースケース層・ドメイン層」のどこに置くべきか、チーム内で議論になりがち

もしProtobufを使ってAPI定義を行っている(スキーマ駆動開発)なら、validationの責務もProtobuf側に寄せてしまうという設計が有効です。

この記事では、そのためのツールである proto-gen-validate を使い、Protobufスキーマでvalidationを定義・自動生成する方法を紹介します。

環境

  • proto-gen-validate v1.2.1
  • Go 1.25.1

proto-gen-validate

proto-gen-validate とは

proto-gen-validate は、Protobufで定義したメッセージに対して、バリデーションルールを宣言的に記述できるツールです。

スキーマに直接制約条件を記述することで、サーバー・クライアント双方で一貫した入力検証を自動化できます。

基本的な使い方

まずは簡単な使い方を紹介します。

記述フォーマット

記述フォーマットは以下です。

// 制約条件が1つの場合
[(validate.rules).<型>.<条件> = <値>];

// 制約条件が複数の場合、オブジェクト的に記述
[(validate.rules).<型> = {<条件1>: <値>, <条件2>: <値>}];

具体的にはこのように書きます。

// 20文字以下
string x = 1 [(validate.rules).string.max_len = 20];

// 5文字以上、10文字以下
string x = 1 [(validate.rules).string = {min_len: 5, max_len: 10}];

また、「オプションだから無くても良いが、使う場合は制約したい」といった場合はignore_emptyを併用します。

string x = 1 [(validate.rules).string = {ignore_empty: true, len: 2}];

使い方

スキーマ定義

まず以下の様にスキーマ定義をします。

syntax = "proto3";

package examplepb;

import "validate/validate.proto";

message User {
  string name = 1 [(validate.rules).string = {min_len: 1, max_len: 20}];
  string email = 2 [(validate.rules).string.email = true];
  uint32 age = 3 [(validate.rules).uint32 = {gte: 0, lte: 120}];
}

コンパイル方法

コンパイルは次のようにパラメータを指定します。

protoc \
  -I . \
  -I $(GOPATH)/pkg/mod/github.com/envoyproxy/protoc-gen-validate@v1.2.1 \
  --go_out=":../generated" \
  --validate_out="lang=go:../generated" \
  example.proto

Go言語でprotocを使う場合に何かしらハマった場合は、以下の記事を参考にすると解決できると思います。

protocのパラメータ指定でハマった場合はこちら↓

christina04.hatenablog.com

go_package周りでハマった場合はこちら↓

christina04.hatenablog.com

実装

実際にvalidationする際は次のように利用します。

package main

import (
    "fmt"
    examplepb "path/to/gen"
)

func main() {
    u := &examplepb.User{}

    err := u.Validate()
    fmt.Println(err) // err: 名前がない

    u.Name = "Alice"

    err = u.Validate()
    fmt.Println(err) // err: メールアドレスが不適切

    u.Email = "alice@example.com"
    u.Age = 999

    err = u.Validate()
    fmt.Println(err) // err: 年齢が不適切

    u.Age = 30

    err = u.Validate()
    fmt.Println(err) // nil: OK
}

よく使う制約条件

次によく使う制約条件の書き方を紹介します。

Numerics

数字に対する制約です。

lt/lte/gt/gte

// 小なり(<)
int32 x = 1 [(validate.rules).int32.lt = 10];

// 大なりイコール(≧)
uint64 x = 1 [(validate.rules).uint64.gte = 20];

// 範囲指定
fixed32 x = 1 [(validate.rules).fixed32 = {gte:30, lt: 40}];

in/not_in

// 指定した数字のどれか
uint32 x = 1 [(validate.rules).uint32 = {in: [1,2,3]}];

// 指定した数字がNG
float x = 1 [(validate.rules).float = {not_in: [0, 0.99]}];

String

Stringに対する制約です。

文字数指定

// 文字数固定
string x = 1 [(validate.rules).string.len = 5];

// 何文字以上
string x = 1 [(validate.rules).string.min_len = 3];

// 文字数の範囲指定
string x = 1 [(validate.rules).string = {min_len: 5, max_len: 10}];

パターンマッチング

// x must be a non-empty, case-insensitive hexadecimal string
string x = 1 [(validate.rules).string.pattern = "(?i)^[0-9a-f]+$"];

prefix, suffix, contain,

// prefix
string x = 1 [(validate.rules).string.prefix = "foo"];

// suffix
string x = 1 [(validate.rules).string.suffix = "bar"];

// x must contain "baz" anywhere inside it
string x = 1 [(validate.rules).string.contains = "baz"];

// x cannot contain "baz" anywhere inside it
string x = 1 [(validate.rules).string.not_contains = "baz"];

// x must begin with "fizz" and end with "buzz"
string x = 1 [(validate.rules).string = {prefix: "fizz", suffix: "buzz"}];

// x must end with ".proto" and be less than 64 characters
string x = 1 [(validate.rules).string = {suffix: ".proto", max_len:64}];

よく使われるフォーマット

// メールアドレス (RFC 5322準拠)
string x = 1 [(validate.rules).string.email = true];

// ホスト名(RFC 1034準拠)
string x = 1 [(validate.rules).string.hostname = true];

// IPアドレス (v4 or v6)
string x = 1 [(validate.rules).string.ip = true];

// IPv4アドレス
// 例: "192.168.0.1"
string x = 1 [(validate.rules).string.ipv4 = true];

// IPv6アドレス
// 例: "fe80::3"
string x = 1 [(validate.rules).string.ipv6 = true];

// URI (RFC 3986準拠)
string x = 1 [(validate.rules).string.uri = true];

// UUID (RFC 4122準拠)
string x = 1 [(validate.rules).string.uuid = true];

Messages

独自の型(オブジェクト・構造体)に対する制約です。

必須条件

Person x = 1 [(validate.rules).message.required = true];

Repeated

配列・スライスに対する制約です。

要素数

// 最低数
repeated int32 x = 1 [(validate.rules).repeated.min_items = 3];

// 範囲指定
repeated Person x = 1 [(validate.rules).repeated = {min_items: 5, max_items: 10}];

// 固定数
repeated double x = 1 [(validate.rules).repeated = {min_items: 7, max_items: 7}];

ユニーク

repeated int64 x = 1 [(validate.rules).repeated.unique = true];

要素に対する制約

// 各要素0以上
repeated float x = 1 [(validate.rules).repeated.items.float.gt = 0];

その他

サンプルコード

今回のサンプルコードはこちら

github.com

まとめ

proto-gen-validateの使い方を説明しました。
これを使うことでAPIの入力チェックをスキーマに統合し、サーバー・クライアント間で一貫したバリデーションを自動化できるようになります。