🦍

退屈なURLクエリパースは gorilla/schema にやらせよう

2022/06/02に公開

Intro

こんにちは。みなさんは gorilla/schema というGoのライブラリをご存知ですか? gorilla/muxなどで有名な、Go言語でのWeb開発向けライブラリコレクションであるgorila toolkitのひとつです。私は恥ずかしながら最近まで知らなかったのですが、最近お仕事で使ったところ大変便利だったので、備忘録がてら紹介記事を書きます。

gorilla/schema: Package gorilla/schema fills a struct with form values.

gorilla/schema は、端的に言えば url.Values 型( url.URL.Queryhttp.Request.Form の値)を任意のGoの構造体との相互変換機能を提供するパッケージです。encoding/json などと同じように、変換ルールは構造体タグで定義することができます。

Decode (URLクエリ → 構造体)

まずはデコード(URLクエリから構造体への変換)から紹介します。Webアプリケーションを書く場合、大抵は最初にHTTPリクエストをアプリケーション層が要求するリクエスト構造体などに変換する必要があります。

しかし、たとえば、 /search?keyword=gorilla&page=2&author-ids=11,20 というURLに含まれるクエリパラメータ(文字列、数値、数値の配列)を構造体にパースする処理をナイーブに書くと、次に示すように意外とコードが冗長になってしまいます。

type SearchBlogRequest struct {
	Keyword   string
	Page      int
	AuthorIDs []int
}

func SearchBlogHandler(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	req := &SearchBlogRequest{Page: 1} // Pageの初期値は1とする

	// 必須のstring値のkeywordをパース
	if v := q.Get("keyword"); v != "" {
		req.Keyword = v
	} else {
		http.Error(w, "missing required parameter: 'keyword'", http.StatusBadRequest)
		return
	}

	// int値のpageをパース
	if v := q.Get("page"); v != "" {
		i, err := strconv.Atoi(v)
		if err != nil {
			http.Error(w, "invalid parameter: 'page'", http.StatusBadRequest)
			return
		}
		req.Page = int(i)
	}

	// カンマ区切りのint sliceである author-ids をパース
	if v := q.Get("author-ids"); v != "" {
		tok := strings.Split(v, ",")
		req.AuthorIds = make([]int, 0, len(tok))
		for _, a := range tok {
			i, err := strconv.Atoi(a)
			if err != nil {
				http.Error(w, "invalid parameter: 'author-ids'", http.StatusBadRequest)
				return
			}
			req.AuthorIds = append(req.AuthorIds, int(i))
		}
	}
	// ...
}

長いですね。ただの文字列しかないなら構造体に代入するだけで済みますが、数値や配列なると意外なほどにコードが膨れますし、この上にさらに「パースエラーが発生した全てのパラメータをユーザーにレスポンスしたい」のような要件も入ってきがちです。この調子でパラメータやエンドポイントが増えていくとコードが散らかってしょうがありませんし、このようなコードを書くのは退屈です。そこでこのパース処理に gorilla/schema を導入すると、次のように一撃でリクエストのURLクエリを構造体にデコードすることができます。

// schema 構造体タグでパラメータ名を指定する。
type SearchBlogRequest struct {
	Keyword   string `schema:"keyword,required"` // 必須パラメータ
	Page      int    `schema:"page"`
	AuthorIDs []int  `schema:"author-ids"`
}

// decoderは型情報をキャッシュするので使い回す
var decoder = schema.NewDecoder()

func SearchBlogHandler2(w http.ResponseWriter, r *http.Request) {
	log.Println(r.RequestURI)
	req := &SearchBlogRequest{Page: 1}
	if err := decoder.Decode(req, r.URL.Query()); err != nil {
		http.Error(w, fmt.Sprintf("bad request: %s", err.Error()), http.StatusBadRequest)
		return
	}
	log.Printf("%+v", req)
	// ...
}

出力:

2022/06/01 19:33:54 /?keyword=gorilla&page=2&author-ids=10,20
2022/06/01 19:33:54 &{Keyword:gorilla Page:2 AuthorIds:[10 20]}

たったこれだけです。カンマ区切りのintのスライスといった、手書きではコストの高かったクエリも問題なく []int にデコードできていることに注目してください。単にコードが短くなったというだけでなく、URLクエリと構造体との変換が宣言的に記述できる様になったという点でも優れています。

Notes

  • スライスは foo=1,2,3 のようなカンマ区切りの他、foo=1&foo=2&foo=3の形式も受け付けます
  • デフォルトではURLクエリに未定義のパラメータが含まれてたらエラーになります。decoder.IgnoreUnknownKeys(true) で未定義パラメータを無視できます。
  • パースに失敗した全てのパラメータのエラーは schema.MultiError 型のマップに格納されて返却されるので、これを errors.As で受け取って適切なエラーレスポンスを構成することができます。

Encode (構造体 → URLクエリ)

gorilla/schema は上記の逆変換、つまり構造体 → URLクエリへのEncodeにも対応しています。これはREST APIクライアントを実装するときなどに便利です。

type SearchBlogRequest struct {
	// omitempty を追加してフィールド値がゼロ値の場合にクエリを省略する
	Keyword   string `schema:"keyword,required,omitempty"`
	Page      int    `schema:"page,omitempty"`
	AuthorIDs []int  `schema:"author-ids,omitempty"`
}

var encoder = schema.NewEncoder()

func foo() {
	req := SearchBlogRequest{
		Keyword:   "foo",
		Page:      123,
		AuthorIds: []int{10, 20},
	}
	query := url.Values{}
	if err := encoder.Encode(req, query); err != nil {
		log.Fatal(err)
	}
	fmt.Print(url.String())

出力:

author-ids=10&author-ids=20&keyword=foo&page=123

Tips: sliceをカンマ区切り結合してエンコードする

なお、上記の出力例では []int 型のAuthorIDsの値が []string{"10","20","30","40"}、つまり author-ids=10&author-ids=20... の形式にエンコードされていますが、カンマ区切りで連結した一つのクエリパラメータにエンコードしたい場合は、以下のように[]int 型にマッピングされるエンコーダーを独自に実装してやればOKです。

// []int{} 型のエンコーダーを登録する。引数vは []int{} のreflect.Value である
encoder.RegisterEncoder([]int{}, func(v reflect.Value) string {
	ss := make([]string, 0)
	for _, v := range v.Interface().([]int) {
		ss = append(ss, strconv.Itoa(v))
	}
	return strings.Join(ss, ",")
})

出力

author-ids=10%2C20&keyword=foo&page=123

',' が '%2C' にエスケープされていて分かりにくいですが、'10,20' と一つのパラメータに結合されていることがわかります。

Discussion