🙆

Go の入力バリデーションパッケージ ozzo-validation を試した。

2021/11/07に公開

はじめに

Go のウェブアプリで使う入力バリデーションに関して、ozzo-validation を検討した。

これまでのバリデーション

普段、仕事では labstack/echo という Go のウェブフレームワークを使う事が多いのだけど、バリデーションに関しては labstack/echo のサンプルに合わせて go-playground/validator を使ってきた。

https://github.com/go-playground/validator

go-playground/validator は機能も豊富で(一応)痒い所に手は届くのだけど、struct にタグを付けて判定させないといけない。これが実に煩わしい。以前 labstack/echo を使ったサンプルを書いたので、それを見て欲しい。

https://github.com/mattn/echo-example/

// Comment is a struct to hold unit of request and response.
type Comment struct {
	Id      int64     `json:"id" db:"id,primarykey,autoincrement"`
	Name    string    `json:"name" form:"name" db:"name,notnull,size:200"`
	Text    string    `json:"text" form:"text" validate:"required,max=20" db:"text,notnull,size:399"`
	Created time.Time `json:"created" db:"created,notnull"`
	Updated time.Time `json:"updated" db:"updated,notnull"`
}

json はまぁ分かる。これに ORM の為の db と、validator の為の validate が含まれており、記述が難しい。間違っても実行時までエラーにならない。また IDE の入力補完も効かない。辛い。

ORM のエラーには頼れない

実は最近、ORM 周りを ent にスイッチした。

https://zenn.dev/mattn/articles/c08072b42f7a5cdcd749

https://entgo.io/

ent はスキーマをコードで書く。なので struct tag を汚す事もない。こちらの ORM にも値のバリデーションはあるのだが、ent が吐き出すエラーはあくまで DB のスキーマを定義する為の物。決してウェブの入力の為の物ではない。

ozzo-validation が良さそう

このまま仕方なく、struct tag を書いていくしかないのかな、と思っていたのだけど ozzo-validation というのを見つけた。

https://github.com/go-ozzo/ozzo-validation

実は新しい訳じゃなく、単に僕が見付けられていなかっただけで、結構人気もあるし、使われているみたい。ozzo-validationgo-playground/validator と異なり、struct tag を使わない。ルールはコードで書く。

package main

import (
	"fmt"

	"github.com/go-ozzo/ozzo-validation/v4"
	"github.com/go-ozzo/ozzo-validation/v4/is"
)

func main() {
	data := "example"
	err := validation.Validate(data,
		validation.Required,       // not empty
		validation.Length(5, 100), // length between 5 and 100
		is.URL,                    // is a valid URL
	)
	fmt.Println(err)
	// Output:
	// must be a valid URL
}

実に潔く、間違いもエラー表示されるし、IDE の入力補完も効く。良い。

サンプルを作ってみた

試しに labstack/echoozzo-validation を使ってサンプルアプリケーションを作ってみた。

https://github.com/mattn/go-ozzo-validation-example

本体コードは短いのでそのまま貼る。

package main

import (
	"embed"
	"net/http"

	validation "github.com/go-ozzo/ozzo-validation/v4"
	"github.com/go-ozzo/ozzo-validation/v4/is"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

type Comment struct {
	Name    string `json:"name"`
	Email   string `json:"email"`
	Content string `json:"content"`
}

type CustomValidator struct{}

func (cv *CustomValidator) Validate(i interface{}) error {
	if c, ok := i.(validation.Validatable); ok {
		return c.Validate()
	}
	return nil
}

func (a Comment) Validate() error {
	return validation.ValidateStruct(&a,
		validation.Field(
			&a.Name,
			validation.Required.Error("名前は必須入力です"),
			validation.RuneLength(5, 20).Error("名前は 5~20 文字です"),
			is.PrintableASCII.Error("名前はASCIIで入力して下さい"),
		),
		validation.Field(
			&a.Email,
			validation.Required.Error("メールアドレスは必須入力です"),
			validation.RuneLength(5, 40).Error("メールアドレスは 5~40 文字です"),
			is.Email.Error("メールアドレスを入力して下さい"),
		),
		validation.Field(
			&a.Content,
			validation.Required.Error("本文は必須入力です"),
			validation.RuneLength(5, 50).Error("本文は 5~50 文字です"),
		),
	)
}

//go:embed static
var localFS embed.FS

func main() {
	e := echo.New()
	e.Debug = true
	e.Validator = &CustomValidator{}
	e.Use(middleware.Logger())
	e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
		Root:       "static",
		Filesystem: http.FS(localFS),
	}))

	e.POST("/api", func(c echo.Context) error {
		var comment Comment
		if err := c.Bind(&comment); err != nil {
			return err
		}
		if err := c.Validate(comment); err != nil {
			errs := err.(validation.Errors)
			for k, err := range errs {
				c.Logger().Error(k + ": " + err.Error())
			}
			return err
		}
		return c.JSON(http.StatusOK, &struct {
			Result string `json:"result"`
		}{
			Result: "OK",
		})
	})
	e.Logger.Fatal(e.Start(":8989"))
}

labstack/echo には元々、go-playground/validator に依存しない、バリデーション機能を注入できる機能があり、Validate メソッドを持った実装を与える事で自らバリデーションを実装できる。このコードの様に CustomValidator#Validate を実装し、それが validation.Validatable である場合には Validate メソッドを呼び出す様にすれば良い。

現状の課題

課題が何もない訳ではない。日本人向けのウェブサービスだと、エラーメッセージも英語で出す必要がある。一応、ozzo-validation にエラーメッセージを差し替える方法があるにはある。

func (a Comment) Validate() error {
	return validation.ValidateStruct(&a,
		validation.Field(
			&a.Name,
			validation.Required.Error("名前は必須入力です"),
			validation.RuneLength(5, 20).Error("名前は 5~20 文字です"),
			is.PrintableASCII.Error("名前はASCIIで入力して下さい"),
		),
		validation.Field(
			&a.Email,
			validation.Required.Error("メールアドレスは必須入力です"),
			validation.RuneLength(5, 40).Error("メールアドレスは 5~40 文字です"),
			is.Email.Error("メールアドレスを入力して下さい"),
		),
		validation.Field(
			&a.Content,
			validation.Required.Error("本文は必須入力です"),
			validation.RuneLength(5, 50).Error("本文は 5~50 文字です"),
		),
	)
}

go-playground/validator の場合は必須入力の翻訳メッセージをオフィシャルが用意してくれていた。ozzo-validation の場合は自ら実装するしかない。かつ穴あき(上記で言う 5 や 50)をメッセージですり替えられる様にはなっていない。一応、上記の通り、項目毎にメッセージが差し替えられるし、コードでパラメータを確認できるので、独自の i18n 対応さえすれば実現できなくはない。

if err := c.Validate(comment); err != nil {
	errs := err.(validation.Errors)
	for k, err := range errs {
		c.Logger().Error(k + ": " + err.Error())
		verr := err.(validation.Error)
		for k, v := range verr.Params() {
			c.Logger().Errorf("  %s: %v", k, v)
		}
	}
	return err
}
content: 本文は 5~50 文字です
  min: 5
  max: 50

おわりに

ozzo-validation により

Pros

  • struct tag が汚れるのを回避できた
  • コードでバリデーションするので間違いに気付ける様になった
  • コードでバリデーションするので IDE の補完が効く

Cons

  • エラーメッセージの翻訳はデフォルトで翻訳されない

しかしコードで全て倒せる様になってるので、個人的には好き。

追記

以下の様に実装すれば、エラーを穴あきにできる事が分かりました。

https://github.com/mattn/go-ozzo-validation-example/commit/f95f868cc5586f2221c152823912e74051461c2d

あとはこれを i18n パッケージ(gettext が扱える様な)を使って、外部ファイルで翻訳すれば良さそうです。

Discussion