退屈なURLクエリパースは gorilla/schema にやらせよう
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.Query
や http.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