GoにおけるHTTPルーティングのさまざまなアプローチ
原文は Ben Hoyt により に公開されました。 このブログを購読する
2024年3月追記: Go 1.22では標準ライブラリの
http.ServeMuxルーターに大幅な機能強化が加わりました。現在はmux.Handle("GET /{slug}/admin")のようにメソッドとパス変数を含むパターンを指定できるようになっており、コードも更新しました。新規の開発では、この記事は無視して標準ライブラリを使うことをおすすめします!
GoでHTTPのパスルーティングを行う方法は、良くも悪くも数多く存在します。標準ライブラリのhttp.ServeMuxもありますが、対応しているのは基本的なプレフィックスマッチのみです。Axel Wagner氏による興味深いShiftPathテクニックをはじめ、より高度なルーティングを自前で実装する方法もいろいろあります。そしてもちろん、サードパーティ製のルーターライブラリも多数存在します。この記事では、いくつかの自作テクニックと既製のパッケージを比較していきます。
最初に自分のバイアスを明らかにしておきます。私はシンプルで明確なコードが好きで、大きな依存関係には少しアレルギーがあります(そして両者は時に相反します)。タイトルに「フレームワーク」と付くライブラリの多くは私の好みではありませんが、1つか2つのことをしっかりこなす、よくメンテナンスされたライブラリを使うこと自体には反対ではありません。
ここでのゴールは、同じ11個のURLを8つの異なるアプローチでルーティングすることです。これらのURLは、私がメンテナンスしているウェブアプリケーションのURLの一部を基にしています。GETとPOSTを使っていますが、特にRESTfulでもよく設計されているわけでもありません――現実のシステムでよく見かけるような、ごちゃごちゃしたものです。メソッドとURLは次のとおりです。
GET / # home
GET /contact # contact
GET /api/widgets # apiGetWidgets
POST /api/widgets # apiCreateWidget
POST /api/widgets/:slug # apiUpdateWidget
POST /api/widgets/:slug/parts # apiCreateWidgetPart
POST /api/widgets/:slug/parts/:id/update # apiUpdateWidgetPart
POST /api/widgets/:slug/parts/:id/delete # apiDeleteWidgetPart
GET /:slug # widget
GET /:slug/admin # widgetAdmin
POST /:slug/image # widgetImage:slugはfoo-barのようなURLフレンドリーなウィジェットの識別子で、:idは1234のような正の整数です。各ルーティング手法はURLに完全一致する必要があります――末尾のスラッシュがある場合は404 Not Foundを返します(リダイレクトするのも良い判断ですが、ここではそうしません)。各ルーターは指定されたメソッド(GETまたはPOST)を処理し、それ以外は405 Method Not Allowedで拒否する必要があります。すべてのルーターが正しく動作することを確認するために、テーブル駆動テストを書きました。
この記事の残りでは、さまざまなアプローチのコードを紹介し、それぞれの長所と短所について論じます(すべてのコードはbenhoyt/go-routingリポジトリにあります)。コードは多めですが、どれも比較的シンプルなのでざっと眺めるだけでも理解しやすいはずです。以下のリンクから特定の手法に直接飛べます。まずは5つの自作テクニックです。
- 正規表現テーブル:事前にコンパイルした正規表現をループし、リクエストコンテキストを使ってマッチ結果を渡す
- 正規表現switch:パスパラメータを変数にスキャンする正規表現ベースの
match()ヘルパーを呼び出すcaseを持つswitch文 - パターンマッチャー:上記と同様だが、正規表現の代わりにシンプルなパターンマッチ関数を使う
- 分割switch:パスを
/で分割し、パスセグメントの内容でswitchする - ShiftPath:Axel Wagner氏による階層的な
ShiftPathテクニック
そしてサードパーティ製ルーターパッケージを使った3つのバージョンです。
高速だとされるhttprouterも試しましたが、/contactと/:slugのようなプレフィックスが重なるURLを扱えません。これはそもそもURL設計が悪いとも言えますが、現実のウェブアプリではよくあることなので、かなり制限が大きいと感じます。
他にも多くのサードパーティ製ルーターパッケージや「ウェブフレームワーク」がありますが、検索で上位に出てきたのがこの3つで(そしてそれらは比較的代表的だと思います)。
この比較では速度は気にしていません。ほとんどの手法は凝ったトライ木探索構造とは対照的に、ルートのリストをループまたはswitchでなめるだけです。これらの手法はいずれもリクエスト時間に数マイクロ秒を追加するだけであり(ベンチマーク参照)、私が携わってきたウェブアプリケーションでは問題になったことはありません。
正規表現テーブル
最初に取り上げるのは、私のウェブアプリケーションの現行バージョンで使っている手法です――数年前にGoを学び始めたときに最初に思いついたもので、今でもかなり良いアプローチだと思っています。
基本的に、事前にコンパイルしたregexpオブジェクトのテーブルと、そこをループしてパスとHTTPメソッドの両方に最初にマッチしたものを呼び出す、わずか21行のルーティング関数で構成されています。ルートとServe()ルーティング関数は次のとおりです。
var routes = []route{
newRoute("GET", "/", home),
newRoute("GET", "/contact", contact),
newRoute("GET", "/api/widgets", apiGetWidgets),
newRoute("POST", "/api/widgets", apiCreateWidget),
newRoute("POST", "/api/widgets/([^/]+)", apiUpdateWidget),
newRoute("POST", "/api/widgets/([^/]+)/parts", apiCreateWidgetPart),
newRoute("POST", "/api/widgets/([^/]+)/parts/([0-9]+)/update", apiUpdateWidgetPart),
newRoute("POST", "/api/widgets/([^/]+)/parts/([0-9]+)/delete", apiDeleteWidgetPart),
newRoute("GET", "/([^/]+)", widget),
newRoute("GET", "/([^/]+)/admin", widgetAdmin),
newRoute("POST", "/([^/]+)/image", widgetImage),
}
func newRoute(method, pattern string, handler http.HandlerFunc) route {
return route{method, regexp.MustCompile("^" + pattern + "$"), handler}
}
type route struct {
method string
regex *regexp.Regexp
handler http.HandlerFunc
}
func Serve(w http.ResponseWriter, r *http.Request) {
var allow []string
for _, route := range routes {
matches := route.regex.FindStringSubmatch(r.URL.Path)
if len(matches) > 0 {
if r.Method != route.method {
allow = append(allow, route.method)
continue
}
ctx := context.WithValue(r.Context(), ctxKey{}, matches[1:])
route.handler(w, r.WithContext(ctx))
return
}
}
if len(allow) > 0 {
w.Header().Set("Allow", strings.Join(allow, ", "))
http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
return
}
http.NotFound(w, r)
}パスパラメータはmatchesスライスをリクエストコンテキストに追加することで処理し、ハンドラ側でそこから取得します。カスタムのコンテキストキー型と、ハンドラ内で使われるgetFieldヘルパー関数を定義しています。
type ctxKey struct{}
func getField(r *http.Request, index int) string {
fields := r.Context().Value(ctxKey{}).([]string)
return fields[index]
}パスパラメータを持つ典型的なハンドラは次のようになります。
// Handles POST /api/widgets/([^/]+)/parts/([0-9]+)/update
func apiUpdateWidgetPart(w http.ResponseWriter, r *http.Request) {
slug := getField(r, 0)
id, _ := strconv.Atoi(getField(r, 1))
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}Atoi()が返すエラーはチェックしていません。なぜならIDパラメータ用の正規表現は数字のみにマッチする[0-9]+だからです。もちろん、データベースにそのオブジェクトが存在する保証はなく、それはハンドラで確認する必要があります。(数値が大きすぎる場合、Atoiはエラーを返しますが、その場合idはゼロになりデータベースの検索が失敗するので、追加のチェックは不要です。)
コンテキストを使ってフィールドを渡す代わりに、各route.handlerをフィールドを[]stringで受け取り、fieldsパラメータをクロージャするhttp.HandleFuncクロージャを返す関数にする方法もあります。Serve関数は次のようにクロージャを生成して呼び出します。
handler := route.handler(matches[1:])
handler(w, r)すると各ハンドラは次のようになります。
func apiUpdateWidgetPart(fields []string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := fields[0]
id, _ := strconv.Atoi(fields[1])
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}
}私はコンテキストを使うアプローチの方がやや好みです。ハンドラのシグネチャをシンプルなhttp.HandlerFuncのままにでき、ハンドラごとにネストした関数を作る必要もないからです。
正規表現テーブル方式に特別な巧妙さはありません。多くのサードパーティパッケージの動作とも似ています。しかし非常にシンプルで、数行のコードと数分で書けてしまいます。必要に応じて簡単に変更できるのも利点です。たとえばログ出力を追加したり、エラーレスポンスをJSONに変えたりといったことも容易です。
正規表現switch
2つ目のアプローチも正規表現を使いますが、シンプルな命令的なswitch文と、マッチを処理するmatch()ヘルパーを使います。このアプローチの利点は、各caseで他の関数を呼んだり別の条件をテストしたりできることです。また、match関数のシグネチャにより、パスパラメータを変数に「スキャン」してハンドラにより直接渡すことができます。ルートとmatch()関数は次のとおりです。
func Serve(w http.ResponseWriter, r *http.Request) {
var h http.Handler
var slug string
var id int
p := r.URL.Path
switch {
case match(p, "/"):
h = get(home)
case match(p, "/contact"):
h = get(contact)
case match(p, "/api/widgets") && r.Method == "GET":
h = get(apiGetWidgets)
case match(p, "/api/widgets"):
h = post(apiCreateWidget)
case match(p, "/api/widgets/([^/]+)", &slug):
h = post(apiWidget{slug}.update)
case match(p, "/api/widgets/([^/]+)/parts", &slug):
h = post(apiWidget{slug}.createPart)
case match(p, "/api/widgets/([^/]+)/parts/([0-9]+)/update", &slug, &id):
h = post(apiWidgetPart{slug, id}.update)
case match(p, "/api/widgets/([^/]+)/parts/([0-9]+)/delete", &slug, &id):
h = post(apiWidgetPart{slug, id}.delete)
case match(p, "/([^/]+)", &slug):
h = get(widget{slug}.widget)
case match(p, "/([^/]+)/admin", &slug):
h = get(widget{slug}.admin)
case match(p, "/([^/]+)/image", &slug):
h = post(widget{slug}.image)
default:
http.NotFound(w, r)
return
}
h.ServeHTTP(w, r)
}
// match reports whether path matches regex ^pattern$, and if it matches,
// assigns any capture groups to the *string or *int vars.
func match(path, pattern string, vars ...interface{}) bool {
regex := mustCompileCached(pattern)
matches := regex.FindStringSubmatch(path)
if len(matches) <= 0 {
return false
}
for i, match := range matches[1:] {
switch p := vars[i].(type) {
case *string:
*p = match
case *int:
n, err := strconv.Atoi(match)
if err != nil {
return false
}
*p = n
default:
panic("vars must be *string or *int")
}
}
return true
}このアプローチはかなり気に入っていることを認めざるを得ません。シンプルで直接的ですし、パスパラメータに対するスキャンのような振る舞いがすっきりしています。match()内部のスキャンでは型を検出し、必要に応じて文字列から整数への変換も行います。現在はstringとintのみをサポートしていますが、ほとんどのルートではそれで十分でしょうし、必要になれば他の型を追加するのも簡単です。
パスパラメータを持つハンドラがどうなるかを示します(繰り返しを避けるため、それら2つのパラメータを取るすべてのハンドラでapiWidgetPart構造体を使っています)。
type apiWidgetPart struct {
slug string
id int
}
func (h apiWidgetPart) update(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", h.slug, h.id)
}
func (h apiWidgetPart) delete(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "apiDeleteWidgetPart %s %d\n", h.slug, h.id)
}リクエストメソッドをチェックする、シンプルなミドルウェアのようなget()とpost()ヘルパー関数にも注目してください。次のようになっています。
// get takes a HandlerFunc and wraps it to only allow the GET method
func get(h http.HandlerFunc) http.HandlerFunc {
return allowMethod(h, "GET")
}
// post takes a HandlerFunc and wraps it to only allow the POST method
func post(h http.HandlerFunc) http.HandlerFunc {
return allowMethod(h, "POST")
}
// allowMethod takes a HandlerFunc and wraps it in a handler that only
// responds if the request method is the given method, otherwise it
// responds with HTTP 405 Method Not Allowed.
func allowMethod(h http.HandlerFunc, method string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if method != r.Method {
w.Header().Set("Allow", method)
http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
return
}
h(w, r)
}
}少しぎこちない点の1つは、複数のメソッドを扱うパスの処理方法です。おそらく別のやり方もあるでしょうが、現状では最初のルートでメソッドを明示的にテストしています――ここでのget()ラッパーは厳密には不要ですが、一貫性のために含めています。
case match(p, "/api/widgets") && r.Method == "GET":
h = get(apiGetWidgets)
case match(p, "/api/widgets"):
h = post(apiCreateWidget)当初はHTTPメソッドのマッチングもmatch()ヘルパーに含めていましたが、そうすると405 Method Not Allowedレスポンスを適切に返すのが難しくなります。
このアプローチのもう1つの側面は、遅延的な正規表現のコンパイルです。単にregexp.MustCompileを呼ぶこともできますが、そうするとリクエストごとに正規表現を再コンパイルしてしまいます。代わりに、並行処理にも安全なmustCompileCached関数を追加し、初回使用時にのみ正規表現がコンパイルされるようにしています。
var (
regexen = make(map[string]*regexp.Regexp)
relock sync.Mutex
)
func mustCompileCached(pattern string) *regexp.Regexp {
relock.Lock()
defer relock.Unlock()
regex := regexen[pattern]
if regex == nil {
regex = regexp.MustCompile("^" + pattern + "$")
regexen[pattern] = regex
}
return regex
}全体として、このアプローチの明確さやスキャン風のmatch()ヘルパーは気に入っているものの、正規表現のコンパイルをキャッシュするために必要な煩雑さは欠点です。
パターンマッチャー
このアプローチは正規表現switch方式に似ていますが、正規表現の代わりにシンプルでカスタムなパターンマッチャーを使います。
カスタムのmatch()関数に渡すパターンはワイルドカード文字+を1つ扱い、これはリクエストパスの次の/までの任意の文字にマッチ(してキャプチャ)します。これはもちろん正規表現のマッチングよりはるかに強力ではありませんが、私のルートでは「次のスラッシュまでマッチ」以上のことは一般的に必要ありませんでした。ルートとマッチ用のコードは次のとおりです。
func Serve(w http.ResponseWriter, r *http.Request) {
var h http.Handler
var slug string
var id int
p := r.URL.Path
switch {
case match(p, "/"):
h = get(home)
case match(p, "/contact"):
h = get(contact)
case match(p, "/api/widgets") && r.Method == "GET":
h = get(apiGetWidgets)
case match(p, "/api/widgets"):
h = post(apiCreateWidget)
case match(p, "/api/widgets/+", &slug):
h = post(apiWidget{slug}.update)
case match(p, "/api/widgets/+/parts", &slug):
h = post(apiWidget{slug}.createPart)
case match(p, "/api/widgets/+/parts/+/update", &slug, &id):
h = post(apiWidgetPart{slug, id}.update)
case match(p, "/api/widgets/+/parts/+/delete", &slug, &id):
h = post(apiWidgetPart{slug, id}.delete)
case match(p, "/+", &slug):
h = get(widget{slug}.widget)
case match(p, "/+/admin", &slug):
h = get(widget{slug}.admin)
case match(p, "/+/image", &slug):
h = post(widget{slug}.image)
default:
http.NotFound(w, r)
return
}
h.ServeHTTP(w, r)
}
// match reports whether path matches the given pattern, which is a
// path with '+' wildcards wherever you want to use a parameter. Path
// parameters are assigned to the pointers in vars (len(vars) must be
// the number of wildcards), which must be of type *string or *int.
func match(path, pattern string, vars ...interface{}) bool {
for ; pattern != "" && path != ""; pattern = pattern[1:] {
switch pattern[0] {
case '+':
// '+' matches till next slash in path
slash := strings.IndexByte(path, '/')
if slash < 0 {
slash = len(path)
}
segment := path[:slash]
path = path[slash:]
switch p := vars[0].(type) {
case *string:
*p = segment
case *int:
n, err := strconv.Atoi(segment)
if err != nil || n < 0 {
return false
}
*p = n
default:
panic("vars must be *string or *int")
}
vars = vars[1:]
case path[0]:
// non-'+' pattern byte must match path byte
path = path[1:]
default:
return false
}
}
return path == "" && pattern == ""
}それ以外では、get()やpost()ヘルパー、ハンドラ自体は正規表現switch方式とまったく同じです。このアプローチは(そして効率的でもあり)かなり気に入っていますが、バイト単位でのマッチングコードを書くのは少し面倒でした――間違いなくregex.FindStringSubmatch()を呼ぶほどシンプルではありません。
追記: Yuri Vishnevsky氏がGophers Slackで興味深いバリエーションを送ってくれました。彼いわく「各部分をインライン化して、マッチの引数がパスそのもののように読めるようにしました:match("foo", &bar, "baz")」。とても気に入っています――Yuri、ありがとう!
分割switch
このアプローチは単にリクエストパスを/で分割し、パスセグメントの数と各セグメントの内容を比較するswitchとcase文を使います。直接的でシンプルですが、ハードコードされた長さやインデックスが多く、少しエラーが起こりやすい面もあります。コードは次のとおりです。
func Serve(w http.ResponseWriter, r *http.Request) {
// Split path into slash-separated parts, for example, path "/foo/bar"
// gives p==["foo", "bar"] and path "/" gives p==[""].
p := strings.Split(r.URL.Path, "/")[1:]
n := len(p)
var h http.Handler
var id int
switch {
case n == 1 && p[0] == "":
h = get(home)
case n == 1 && p[0] == "contact":
h = get(contact)
case n == 2 && p[0] == "api" && p[1] == "widgets" && r.Method == "GET":
h = get(apiGetWidgets)
case n == 2 && p[0] == "api" && p[1] == "widgets":
h = post(apiCreateWidget)
case n == 3 && p[0] == "api" && p[1] == "widgets" && p[2] != "":
h = post(apiWidget{p[2]}.update)
case n == 4 && p[0] == "api" && p[1] == "widgets" && p[2] != "" && p[3] == "parts":
h = post(apiWidget{p[2]}.createPart)
case n == 6 && p[0] == "api" && p[1] == "widgets" && p[2] != "" && p[3] == "parts" && isId(p[4], &id) && p[5] == "update":
h = post(apiWidgetPart{p[2], id}.update)
case n == 6 && p[0] == "api" && p[1] == "widgets" && p[2] != "" && p[3] == "parts" && isId(p[4], &id) && p[5] == "delete":
h = post(apiWidgetPart{p[2], id}.delete)
case n == 1:
h = get(widget{p[0]}.widget)
case n == 2 && p[1] == "admin":
h = get(widget{p[0]}.admin)
case n == 2 && p[1] == "image":
h = post(widget{p[0]}.image)
default:
http.NotFound(w, r)
return
}
h.ServeHTTP(w, r)
}ハンドラは他のswitchベースの手法と同一で、getやpostヘルパーも同じです。ここで唯一のヘルパーはisId関数で、IDセグメントが実際に正の整数であるかをチェックします。
func isId(s string, p *int) bool {
id, err := strconv.Atoi(s)
if err != nil || id <= 0 {
return false
}
*p = id
return true
}そのため、このアプローチのむき出しのシンプルさ――基本的な文字列の等価比較だけで済む点――は気に入っているものの、マッチングの冗長さや間違いやすい整数定数を見ると、非常にシンプルなルーティング以外で実際に使うのはためらわれます。
ShiftPath
Axel Wagner氏はブログ記事How to not use an http-router in goの中で、ルーター(サードパーティ製かどうかを問わず)は使うべきではないと主張しています。彼は、最初のパスセグメントを返し、残りのURLをずらす小さなShiftPath()ヘルパーを使ったテクニックを紹介しています。現在のハンドラは最初のパスセグメントでswitchし、残りのURLについて同じことをするサブハンドラに処理を委譲します。
私たちのURLセットの一部に対して、Axel氏のテクニックがどのように見えるか見てみましょう。
func serve(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "":
serveHome(w, r)
case "api":
serveApi(w, r)
case "contact":
serveContact(w, r)
default:
widget{head}.ServeHTTP(w, r)
}
}
// shiftPath splits the given path into the first segment (head) and
// the rest (tail). For example, "/foo/bar/baz" gives "foo", "/bar/baz".
func shiftPath(p string) (head, tail string) {
p = path.Clean("/" + p)
i := strings.Index(p[1:], "/") + 1
if i <= 0 {
return p[1:], "/"
}
return p[1:i], p[i:]
}
// ensureMethod is a helper that reports whether the request's method is
// the given method, writing an Allow header and a 405 Method Not Allowed
// if not. The caller should return from the handler if this returns false.
func ensureMethod(w http.ResponseWriter, r *http.Request, method string) bool {
if method != r.Method {
w.Header().Set("Allow", method)
http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
return false
}
return true
}
// ...
// Handles /api and below
func serveApi(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "widgets":
serveApiWidgets(w, r)
default:
http.NotFound(w, r)
}
}
// Handles /api/widgets and below
func serveApiWidgets(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "":
if r.Method == "GET" {
serveApiGetWidgets(w, r)
} else {
serveApiCreateWidget(w, r)
}
default:
apiWidget{head}.ServeHTTP(w, r)
}
}
// Handles GET /api/widgets
func serveApiGetWidgets(w http.ResponseWriter, r *http.Request) {
if !ensureMethod(w, r, "GET") {
return
}
fmt.Fprint(w, "apiGetWidgets\n")
}
// Handles POST /api/widgets
func serveApiCreateWidget(w http.ResponseWriter, r *http.Request) {
if !ensureMethod(w, r, "POST") {
return
}
fmt.Fprint(w, "apiCreateWidget\n")
}
type apiWidget struct {
slug string
}
// Handles /api/widgets/:slug and below
func (h apiWidget) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "":
h.serveUpdate(w, r)
case "parts":
h.serveParts(w, r)
default:
http.NotFound(w, r)
}
}
func (h apiWidget) serveUpdate(w http.ResponseWriter, r *http.Request) {
if !ensureMethod(w, r, "POST") {
return
}
fmt.Fprintf(w, "apiUpdateWidget %s\n", h.slug)
}
func (h apiWidget) serveParts(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "":
h.serveCreatePart(w, r)
default:
id, err := strconv.Atoi(head)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
apiWidgetPart{h.slug, id}.ServeHTTP(w, r)
}
}
// ...このルーターでは、末尾にスラッシュがあるURLに対してNot Foundが返されるようにするためのnoTrailingSlashデコレータを書きました。私たちのURL仕様ではそうしたURLは無効と定義されているためです。ShiftPath方式では末尾スラッシュの有無を区別せず、それを区別させるシンプルな方法も見つかりません。これをすべてのルートで明示的に行うよりも、デコレータで対応するのは妥当なアプローチだと思います――特定のウェブアプリでは、末尾スラッシュを許可してリダイレクトするか、私のようにNot Foundを返すかのどちらかにするでしょう。
標準ライブラリだけを使うという発想や、パスをずらすテクニック自体はかなり巧妙で気に入っているものの、URLをすべて一か所で見渡せる方が断然好みです――Axel氏のアプローチではロジックが多くのハンドラに分散してしまい、何が何を処理しているのかを把握するのが困難です。また、かなりコード量も多く、その一部はエラーが起こりやすいものでもあります。
(Axel氏が言うように)「たとえばProfileHandlerの依存関係がコンパイル時に明確になる」という点は気に入っていますが、これは上記の他のいくつかのテクニックでも同様に当てはまります。総合的に見て、冗長すぎると感じますし、コードを読む人が「このHTTPメソッドとURLが与えられたとき、何が起こるのか?」という問いに素早く答えるのは難しいだろうと思います。
Chi
Chiは「軽量で、イディオマティックかつコンポーザブルなルーター」と謳われており、その説明に違わぬ出来だと思います。使い方はシンプルで、コードも見た目がすっきりしています。ルート定義は次のとおりです。
func init() {
r := chi.NewRouter()
r.Get("/", home)
r.Get("/contact", contact)
r.Get("/api/widgets", apiGetWidgets)
r.Post("/api/widgets", apiCreateWidget)
r.Post("/api/widgets/{slug}", apiUpdateWidget)
r.Post("/api/widgets/{slug}/parts", apiCreateWidgetPart)
r.Post("/api/widgets/{slug}/parts/{id:[0-9]+}/update", apiUpdateWidgetPart)
r.Post("/api/widgets/{slug}/parts/{id:[0-9]+}/delete", apiDeleteWidgetPart)
r.Get("/{slug}", widgetGet)
r.Get("/{slug}/admin", widgetAdmin)
r.Post("/{slug}/image", widgetImage)
Serve = r
}ハンドラもまた分かりやすいものです。正規表現テーブル方式のハンドラとよく似ていますが、カスタムのgetField()関数がchi.URLParam()に置き換わっています。小さな利点として、パラメータを番号ではなく名前で取得できることが挙げられます。
func apiUpdateWidgetPart(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
id, _ := strconv.Atoi(chi.URLParam(r, "id"))
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}私の正規表現テーブルルーターと同様に、パスパラメータの正規表現ですでに数字のみで構成されていることが確認されているため、strconv.Atoi()のエラー値は無視しています。
ある程度の規模のウェブアプリを構築するなら、Chiは実際にかなり良さそうです。メインのchiパッケージはルーティングだけを行いますが、このモジュールにはHTTP認証、ログ出力、末尾スラッシュの処理などを行う、多数のコンポーザブルなミドルウェアも付属しています。
Gorilla
Gorillaツールキットは、ルーティングやセッション処理などを実装するパッケージ群です。ここで使うのはgorilla/muxルーターパッケージです。Chiと似ていますが、メソッドのマッチングが少し冗長です。
func init() {
r := mux.NewRouter()
r.HandleFunc("/", home).Methods("GET")
r.HandleFunc("/contact", contact).Methods("GET")
r.HandleFunc("/api/widgets", apiGetWidgets).Methods("GET")
r.HandleFunc("/api/widgets", apiCreateWidget).Methods("POST")
r.HandleFunc("/api/widgets/{slug}", apiUpdateWidget).Methods("POST")
r.HandleFunc("/api/widgets/{slug}/parts", apiCreateWidgetPart).Methods("POST")
r.HandleFunc("/api/widgets/{slug}/parts/{id:[0-9]+}/update", apiUpdateWidgetPart).Methods("POST")
r.HandleFunc("/api/widgets/{slug}/parts/{id:[0-9]+}/delete", apiDeleteWidgetPart).Methods("POST")
r.HandleFunc("/{slug}", widgetGet).Methods("GET")
r.HandleFunc("/{slug}/admin", widgetAdmin).Methods("GET")
r.HandleFunc("/{slug}/image", widgetImage).Methods("POST")
Serve = r
}やはりハンドラはChiと似ていますが、パスパラメータを取得するにはmux.Vars()を呼び出します。これはすべてのパラメータを含むmapを返し、名前でインデックスして取得します(これは「設計上非効率」に感じますが、まあ仕方ありません)。あるハンドラのコードは次のとおりです。
func apiUpdateWidgetPart(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
slug := vars["slug"]
id, _ := strconv.Atoi(vars["id"])
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}Pat
Patは興味深い存在です――メソッドとパスパラメータをサポートしつつも正規表現マッチは行わない、ミニマルで単一ファイルのルーターです。ルートのセットアップコードはChiやGorillaと似ています。
func init() {
r := pat.New()
r.Get("/", http.HandlerFunc(home))
r.Get("/contact", http.HandlerFunc(contact))
r.Get("/api/widgets", http.HandlerFunc(apiGetWidgets))
r.Post("/api/widgets", http.HandlerFunc(apiCreateWidget))
r.Post("/api/widgets/:slug", http.HandlerFunc(apiUpdateWidget))
r.Post("/api/widgets/:slug/parts", http.HandlerFunc(apiCreateWidgetPart))
r.Post("/api/widgets/:slug/parts/:id/update", http.HandlerFunc(apiUpdateWidgetPart))
r.Post("/api/widgets/:slug/parts/:id/delete", http.HandlerFunc(apiDeleteWidgetPart))
r.Get("/:slug", http.HandlerFunc(widgetGet))
r.Get("/:slug/admin", http.HandlerFunc(widgetAdmin))
r.Post("/:slug/image", http.HandlerFunc(widgetImage))
Serve = r
}1つの違いは、Get()やPost()関数がhttp.HandlerFuncではなくhttp.Handlerを取ることです。これは一般的に少し扱いづらく、通常はServeHTTPメソッドを持つ型ではなく関数を扱うからです。http.HandlerFunc(h)を使って簡単に変換できますが、少しノイズが増えます。ハンドラは次のようになります。
func apiUpdateWidgetPart(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get(":slug")
id, err := strconv.Atoi(r.URL.Query().Get(":id"))
if err != nil {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "apiUpdateWidgetPart %s %d\n", slug, id)
}興味深い点の1つは、パスパラメータを格納するのにcontextとそれを取得するヘルパー関数を使う代わりに、Patはそれらを:(コロン)付きでクエリパラメータに詰め込んでいることです。賢いトリックですが――少し行儀が悪いとも言えます。
PatではAtoi()からのエラー戻り値をチェックしていることに注意してください。ルート定義にIDがすべて数字であることを保証する正規表現がないためです。別の方法としてエラーを無視し、データベース内でIDが0のパートを検索しようとして見つからなかったときにNot Foundを返すようにすることもできます(データベースのIDは通常1から始まります)。
ベンチマーク
前述のとおり、この比較では速度は気にしていません――皆さんも気にする必要はないでしょう。本当にURLのルーティングに数マイクロ秒かかることが問題になるほどの規模で運用しているのであれば、httprouterのような凝ったトライ木ベースのルーターを使うか、自分で徹底的にプロファイルしたコードを書けばよいでしょう。ここで示した手作りのルーターはすべて、関与するルート数に対して線形時間で動作します。
しかし、これらのアプローチのいずれもパフォーマンスを台無しにしないことを示すために、8つのルーターそれぞれでURL/api/widgets/foo/parts/1/updateをルーティングするシンプルなベンチマークを以下に示します(コードはこちら)。数値は「1回あたりのナノ秒」なので、低いほど高速です。「1回」にはルーティングとハンドラの呼び出しが含まれます。「noop」ルーターは実際には何もルーティングしないルーターで、ベースラインのオーバーヘッドを表します。
| ルーター | ns/op |
|---|---|
| pat | 3646 |
| gorilla | 2642 |
| retable | 2014 |
| reswitch | 1970 |
| shiftpath | 1607 |
| chi | 1370 |
| match | 1025 |
| split | 984 |
| noop | 583 |
ご覧のとおり、PatとGorillaは他よりも遅く、よく知られたライブラリだからといって高度に最適化されているわけではないことがわかります。Chiは最も高速な部類に入り、私のカスタムパターンマッチャーや単純なstrings.Split()方式が最も高速です。
ただ、念を押しておきたいのは、これらはすべて十分に高速だということです――ルーターをパフォーマンスで選ぶべきことはほぼありません。ここでの数値はマイクロ秒単位なので、Patの3646ナノ秒でさえレスポンスタイムに加わるのはわずか100万分の3.6秒です。一般的なウェブアプリでのデータベース検索時間は、その1000倍程度になるでしょう。
まとめ
全体として、これは興味深い実験でした。いくつかの新しい(私にとっては新しいだけで、決してオリジナルではない)カスタムなルーティングアプローチを考え出したほか、以前から気になっていたAxel氏の「ShiftPath」アプローチも試すことができました。
自作のアプローチの中から1つを選ぶとしたら、結局は数年前にGoで最初のサーバーを実装したときの出発点に立ち戻り、正規表現テーブル方式を選ぶと思います。正規表現はこの用途にはややヘビーですが、よく理解されており標準ライブラリに含まれていますし、Serve()関数はわずか21行です。さらに、ルート定義がすべてテーブル内に1行ずつきれいに収まっている点も気に入っています――どのURLがどこに行くのかをざっと見て把握しやすいからです。
(それでも自作アプローチの中で)僅差の2位は正規表現switchです。match()ヘルパーのスキャン風の振る舞いが気に入っていますし、こちらも非常にコンパクトです(22行)。ただ、ルート定義は少しごちゃつき(1ルートあたり2行)、パスパラメータを取るハンドラでは型やクロージャのボイラープレートが必要になります――パスパラメータをコンテキストを使って保存するのは少しトリッキーだと思いますが、シグネチャをシンプルに保てるのは確かです!
自分としては、他のカスタムアプローチはおそらく除外するでしょう。
- 私の分割switchアプローチ。単に
strings.Split()を使うだけという点は気に入っていますが、n == 3 && p[0] == "api" && p[1] == "widgets" && p[2] != ""のような比較は少し見苦しく、エラーも起こりやすいと感じます。 - 私のパターンマッチャー版。シンプルなカスタムパターンマッチャーをこのユースケース向けに作るのは楽しかったですし(わずか33行です)、バイト単位での文字列処理は少し面倒で、正規表現ベースのアプローチ(より強力で標準ライブラリに入っている)に比べて十分な利点がありません。
- ShiftPathテクニック。気に入りたいところですが、単純なURLマッチングでさえボイラープレートが多すぎますし、やはりURL定義は一か所にまとめたいと思います。
サードパーティのルーティングライブラリがルートを理解しづらくするというAxel氏の評価には同意できません。たいていの場合、ソース順にマッチするのか、最も具体的なものから順にマッチするのかを知っていれば十分です。また、(少なくともアプリのサブコンポーネントについては)すべてのルートを一か所にまとめることが悪いことだとも思いません。
サードパーティ製ライブラリという点では、Chi版がかなり気に入っています。特に大人数のチームでウェブアプリを構築するなら、真剣に採用を検討するでしょう。Chiはよく考え抜かれており、十分にテストされていますし、提供されるミドルウェアのコンポーザビリティも気に入っています。
一方で、node_modules症候群やleft-pad騒動についても十分に認識しており、依存関係は慎重に使うべきだというRuss Cox氏の見解にも同意します。開発者はちょっとしたコードを書くことを恐れるべきではありません。小さなカスタマイズされた正規表現ルーターを書くのは楽しく、理解しやすく、メンテナンスも容易です。
記事をランダムに読む
コメント
ログインしてコメントする