kanna

kanna / validate

kanna-validate

v0.0.11 Go 1.27 pkg.go.dev

CI codecov Go Reference License: MIT Release English

kanna-validate は、構造体の validate タグから Validate メソッドを生成します。生成されるのは素の if 文で、リフレクションは使いません。破られたルールは、Rails のエラーコードで、フィールドの JSON 名をキーにして報告します。

kanna コードジェネレータファミリーの 1 つです。encoding/json/v2 を使うため、Go 1.27 以上が必要です。

インストール

go get -tool github.com/go-kanna/validate/cmd/kanna-validate

生成コードは、このモジュールのルートにある小さなランタイム github.com/go-kanna/validate を呼び出します。上のコマンドで依存に追加されます。

使い方

構造体に //kanna:validate を付け、go-playground/validator を使うときと同じようにルールを書きます:

package user

//go:generate go tool kanna-validate

//kanna:validate
type CreateUserRequest struct {
	Name     string   `json:"name"     validate:"required,max=50"`
	Age      int      `json:"age"      validate:"gte=18,lte=130"`
	Role     string   `json:"role"     validate:"required,oneof=admin member guest"`
	Tags     []string `json:"tags"     validate:"max=5,dive,max=20"`
	Address  Address  `json:"address"  validate:"required"`
	Password string   `json:"password" validate:"required,min=12"`
	Confirm  string   `json:"confirm"  validate:"eqfield=Password"`
}

//kanna:validate
type Address struct {
	Zip string `json:"zip" validate:"required,pattern=^\\d{3}-\\d{4}$"`
}

go generate ./... を実行すると、同じディレクトリに validate_gen.go が書き出されます:

// Code generated by kanna-validate. DO NOT EDIT.

func (x CreateUserRequest) Validate() error {
	var errs validate.Errors

	if x.Name == "" {
		errs.Add("name", validate.Blank())
	} else {
		if utf8.RuneCountInString(x.Name) > 50 {
			errs.Add("name", validate.TooLong(50))
		}
	}

	if x.Age < 18 {
		errs.Add("age", validate.GreaterThanOrEqualTo(18))
	}
	// ...

	for i := range x.Tags {
		if utf8.RuneCountInString(x.Tags[i]) > 20 {
			errs.Add(validate.Index("tags", i), validate.TooLong(20))
		}
	}

	if x.Address == (Address{}) {
		errs.Add("address", validate.Blank())
	} else {
		errs.Nest("address", x.Address.Validate())
	}
	// ...

	return errs.Err()
}

どのルールも、読むことができ、デバッガで止めることもできる if 文です。タグを変更すると生成コードも変わるので、ルールの変更はレビューに現れます。

存在しないルールやフィールドに合わないルールは、それに当たる最初のリクエストを止めるのではなく、生成を止めます:

$ go generate ./...
./request.go:5:2: error: unknown rule "requried" (field Name)
  did you mean "required"?
./request.go:6:2: error: rule "email" cannot apply to int (field Age)
./request.go:7:2: error: rule "max": 300 does not fit uint8 (field Level)
./request.go:8:2: error: invalid pattern: error parsing regexp: missing closing ]: `[0-9` (field Zip)
./request.go:10:2: error: eqfield=Pasword: no such field (field Confirm)
  did you mean "Password"?

スペルミスのあるディレクティブには警告が出ます。警告がなければ、構造体に Validate がないまま、何の知らせもないことになります:

./request.go:3:1: warning: "//kanna:validte" is not recognized as a directive
  did you mean "//kanna:validate"?

ルール

タグは go-playground/validator のもの、コードとパラメータは Rails の ActiveModel::Validations のものです。

タグ 対象 コード パラメータ
required ゼロ値を持つものすべて blank
min, max, len 文字列(文字数で数える)、スライス、マップ too_short, too_long, wrong_length count
min, max 数値 greater_than_or_equal_to, less_than_or_equal_to count
gt, gte, lt, lte, eq, ne 数値 greater_than, greater_than_or_equal_to, less_than, ..., other_than count
oneof=a b c 文字列、整数 inclusion
eqfield=Field 同じ型のフィールド confirmation attribute: 相手のフィールドの JSON 名
pattern=regexp 文字列 invalid
email, url, uuid 文字列 email, url, uuid(go-kanna/value のもの)
rule=Type, rule=pkg.Type Type が保持するもの、またはバリデーションするもの ルール自身のコード ルール自身のパラメータ
omitempty ゼロ値を持つものすべて ゼロ値には、後に続くルールを適用しない
dive スライス、配列、マップ 後に続くルールを各要素に適用する。マップでは各値に適用する

go-playground との違い:

  • フィールドが破ったルールは、最初の 1 つだけでなくすべて報告されます。例外は required を満たさない値で、blank だけが報告されます。他に破っているルールがあっても、値がないことを繰り返し伝えるだけだからです。
  • required は「ゼロ値ではない」という意味です。ゼロ値とは ""、0、false、nil のことで、IsZero() を持つ型ではそのメソッドで判定します。nil でない空のスライスは値があるものとして扱われます。長さは min=1 で制限してください。0 を送ることと何も送らないことは区別されません。そのため、0 や、ルールがゼロ値を受け入れる値オブジェクトのゼロ値を、値がない状態とは別の値として扱う場合は、フィールドをポインタにしてください。そうすると required は nil だけを報告します。
  • nil ポインタには required 以外のどのルールも適用されません。max=20 を付けた *string は nil を受け入れますが、required,max=20 を付けたものは受け入れません。
  • 浮動小数点数の NaN は、適用されるすべての比較に違反します。min=0 でも max=100 でも報告されます。生成コードは !(x >= 0) のように条件を否定した形でチェックします。x < 0 では NaN を通してしまうからです。
  • pattern= はカンマも含めてタグの残り全体を受け取るので、最後に書きます。タグの値は Go の文字列リテラルなので、バックスラッシュは \\ と書きます。アンクォートできないタグは、黙って読み飛ばされるのではなく生成を止めます。
  • omitempty は、同じ階層のルールの中で最初に書きます。

rule=Type は、Type のルールをフィールドの値に適用します。dive の後では各要素に適用します。Type には 3 種類あります。1 つ目は kanna-value の値オブジェクトで、kanna-value が与える形から判別され、コンストラクタ NewType(T) (Type, error) が値にルールを守らせます。2 つ目は kanna-value の enum で、コンストラクタ NewType(string) (Type, error) は定数だけを受け入れます。3 つ目はメソッド Validate(T) error を持つ型で、このメソッドはゼロ値に対して呼び出されます。修飾なしの Type は、その構造体のパッケージの型を指します。pkg.Type はパッケージを名前で指定します。対象は、自分のモジュールのパッケージと、構造体のパッケージが直接または間接にインポートしているパッケージです。同じ名前のパッケージが 2 つある場合は、a/pkg.Type のように、区別に必要なだけインポートパスを書きます。生成されたファイルがインポートするので、構造体のファイルでインポートする必要はありません。生成コードは、コンストラクタまたは Validate が返したものを、ルールが与えたコードとパラメータとともにそのフィールドに記録します。

ネストとフック

Validate() error を持つ型のフィールド(別の //kanna:validate の構造体、自前でバリデーションを書いた型、インターフェース)は、そのメソッドでバリデーションされ、エラーはそのフィールドの下にネストされます。address.zip、items[2].sku、マップなら labels.primary のようになります。そのような型のスライス、配列、マップは、dive がなくても要素ごとにバリデーションされます。JSON 名のない埋め込み構造体のエラーは、encoding/json がそのフィールドを平坦化するのと同じように、埋め込んでいる構造体の階層に置かれます。

2 つのフィールドにまたがるルールのように、タグでは表現できないルールは validate メソッドに書きます。生成された Validate は、すべてのタグをチェックした後でこのメソッドを呼び出します:

func (x CreateUserRequest) validate(errs *validate.Errors) {
	if x.Role == "admin" && x.Age < 21 {
		errs.Add("age", validate.Fail("too_young_for_admin"))
	}
}

構造体全体に関するエラーは、Rails と同じく validate.Base("base")の下に置きます。

エラー

Validate は validate.Errors か nil を返します。これは Rails が errors.details をエンコードするのと同じ形式にエンコードされ、フィールドは最初にルールが破られた順に並びます:

{
  "name": [{"error": "blank"}],
  "tags[1]": [{"error": "too_long", "count": 20}],
  "confirm": [{"error": "confirmation", "attribute": "password"}]
}

フィールドのキーは、トランスポートが何であっても、json タグが与える名前か、タグがなければ Go の名前です。gRPC ハンドラの裏側にある、json タグのないユースケースの入力は TodoID と報告します。proto で宣言された名前で報告するには、フィールドに json:"id" タグを付けてください。protoc-gen-go が生成するメッセージには、proto のフィールド名を持つ json タグが最初から付いています。

拒否された値がエラーに含まれることは決してありません。エラーはログに残るものであり、値は個人情報かもしれないからです。

リクエストのデコード

validate.DecodeJSON は、リクエストボディをデコードしてバリデーションし、問題をすべてまとめて報告します:

var req user.CreateUserRequest
if err := validate.DecodeJSON(r.Body, &req); err != nil {
	if errs, ok := errors.AsType[validate.Errors](err); ok {
		writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": errs})
		return
	}
	writeError(w, http.StatusBadRequest, err) // JSON that cannot be read at all
	return
}

kanna-value の値オブジェクトのように、デコード中に型が値を拒否するフィールドがあっても、デコードは止まりません。拒否はそのフィールドのパスに報告され、残りの部分でタグが見つけたものと一緒に並びます。json.Unmarshal にはこれができません。最初の拒否で止まり、どのフィールドで起きたかも示しません。DecodeJSON は encoding/json/v2 を通してデコードします。encoding/json/v2 は名前の大文字と小文字を区別して照合し、重複した名前を拒否します。

kanna-value との連携

kanna-value は、値がどこを通っても、つまり作られるときも、デコードされるときも、データベースからスキャンされるときも、ルールを守らせる値オブジェクトを生成します。タグの email、url、uuid は、そのパッケージ rule にある同じルールを呼び出し、同じコードを報告します。そのため、タグでチェックした形式も型が保持する形式も、API レスポンスでは同じように見えます。

//kanna:value
type Email struct{ s string }

func (Email) validate(s string) error { return rule.Email(s) }

//kanna:validate
type SignUp struct {
	Email   Email  `json:"email"   validate:"required"`      // the type holds the format; the tag, that it is there
	Website string `json:"website" validate:"omitempty,url"` // the tag checks the format of a plain string
}

kanna-value の enum は、生成された UnmarshalText が定数だけを受け入れる string 型です。定数を oneof で並べ直す必要はなく、同じコード inclusion を報告します:

//kanna:validate
type ListOrders struct {
	Status *model.OrderStatus `json:"status"` // nil when omitted; "shiped" is reported as inclusion
}

rule= は、値オブジェクトのルールを、そのコンストラクタを通して普通のフィールドに適用します。gRPC メッセージのように JSON 以外から組み立てられるリクエストでは、*time.Time を受け取り、model.CompletedAt と同じようにチェックできます:

//kanna:validate
type UpdateTask struct {
	CompletedAt *time.Time `json:"completed_at" validate:"rule=model.CompletedAt"` // nil skips it
}
	if x.CompletedAt != nil {
		if _, err := model.NewCompletedAt(*x.CompletedAt); err != nil {
			errs.Add("completed_at", err)
		}
	}

同じように、rule=model.OrderStatus と書けば、普通の string を enum の定数に対してチェックできます。

go-kanna/value は独立したモジュールです。これらのタグを使うときは go get github.com/go-kanna/value を、その型を使うときは go get -tool github.com/go-kanna/value/cmd/kanna-value を実行してください。生成されたファイルがこのモジュールを必要としているのに go.mod が require していない場合は、後からビルドが失敗するのではなく、警告が出ます:

./validate_gen.go: warning: imports github.com/go-kanna/value/rule, which go.mod does not require; run go mod tidy

Lint

wrapcheck は、手書きの Validate 内の return errs.Err() を、別パッケージからのラップされていないエラーとして報告します。Err は集めたものを返すだけで、その下にラップすべき原因はないので、wrapcheck が無視するシグネチャに追加してください:

linters:
  settings:
    wrapcheck:
      extra-ignore-sigs:
        - validate.Errors).Err(

フラグ

kanna-validate [flags] [packages]
フラグ 意味
-tags <list> カンマ区切りのビルドタグ
-check 書き出す代わりに、出力が最新であることを検証する。不要な出力が残っていないことも含む
-v 書き出した、削除した、変更しなかったファイルを一覧表示する
-version バージョンを表示する

パッケージのデフォルトはカレントディレクトリのパッケージなので、//go:generate go tool kanna-validate に引数は不要です。成功した実行は、警告以外何も出力しません。警告の 1 つは実行そのものについてのものです。対象のパッケージに //kanna:validate を付けた構造体が 1 つもない場合、残っている //go:generate の行は無害なので実行は成功しますが、その行も消すつもりだった可能性が高いので、そのことを知らせます:

$ go generate ./...
warning: no struct is marked //kanna:validate in example.com/app/user

Validate は、初回の実行で書き出される前から呼び出していても構いません。kanna-validate は読み込んだパッケージを、これから書き出す内容を組み込んだ状態でもう一度型チェックし、コンパイルが通らない間は何も書き出しません。メソッドがないために隠れていた間違い、たとえば Validate の戻り値を合わない場所に渡すことも、このとき報告されます。

すべての構造体から //kanna:validate がなくなったパッケージの validate_gen.go は、実行時に読み込んだパッケージのコードがその Validate をまだ呼び出していない限り、削除されます。同じ名前でも生成コードのヘッダがないファイルには決して触れません。削除を行う go generate は open ...: no such file or directory で終わります。これは go generate 自身のエラーで、ジェネレータを実行する前にパッケージのファイルを列挙しているためです。ファイルは意図どおり削除されています(golang/go#36068)。

サンプル

example は、ハンドラと同じようにサインアップのリクエストをデコードし、それぞれが受け取るレスポンスを表示します。リクエストは、有効なもの、すべてが一度に間違っているもの、空のもの、フックが拒否するもの、不正な形式の JSON です。CI はこれを再生成し、出力が変わる場合は失敗します。

パフォーマンス

bench は、使い方 のものと同じようなリクエストを、生成された Validate と go-playground/validator でバリデーションします。どちらも同じタグを読みます。go-playground にない pattern= の代わりには len=8 を使います。go-playground は kanna-validate と同じ内容を報告するように設定しています(必須の構造体はゼロ値であってはならず、フィールドは JSON 名で示します)。両者が同じフィールドを不正と判定しなければ、テストが失敗します。

リクエスト バリデータ ns/op B/op allocs/op
有効 kanna-validate 18 0 0
有効 go-playground/validator v10.30.5 539 432 18
7 フィールドが不正 kanna-validate 231 1032 16
7 フィールドが不正 go-playground/validator v10.30.5 989 2210 51

Apple M5 Pro 上の Go 1.27.3 で 10 回実行した中央値です。生成された Validate は、有効なリクエストを go-playground の約 30 倍の速さでチェックし、不正なリクエストを約 4 倍の速さで、バイト数を 53%、アロケーション回数を 69% 減らしてチェックします。有効なリクエストのコストは if の分だけで、アロケーションはありません。不正なリクエストのコストは報告するエラーの分で、エラーはそれぞれコードとパラメータとともに作られます。

make bench                       # run it once
make bench BENCHFLAGS=-count=10  # often enough for benchstat

開発

make test       # unit tests
make lint       # golangci-lint
make example    # regenerate the example, then build and run it
make bench      # regenerate the benchmark, then run it

ライセンス

MIT