背景
次のようなwildcardを含んだpathをフレームワークに頼らず、自前で実装する場合にどうパターンマッチさせるか考えてみます。
/users/:id
/articles/:id/comments
単純に考えると正規表現で次のようなパターンを使ってforループで回す、といったものがありますが
^/users/[a-zA-Z0-9_]*$
^/articles/[a-zA-Z0-9_]*/comments$
一方で次のような課題が生まれてきます。
- pathのリストが増えてくると、正規表現xfor文ではスケールしにくい
/users/:id/favorites
のようなpathにも/users/:id
がマッチングしてしまう可能性がある
こういった課題を解決する上で、基数木というデータ構造が適切なので紹介します。
環境
Go v1.20.3
基数木(Radix tree/Patricia tree)とは
基数木は、prefixを共有する文字列を木構造で表現することで、検索や挿入、削除を高速に行えるデータ構造です。
例えば以下のようなpathを基数木で表現すると、
- /abc
- /abc/:id
- /abc/:id/def
- /abc/:id/ghi
- /xyz
- /xyz/vw
次の図のようになります。
APIのpathのように、文字列がまとまっている場合はトライ木よりも基数木の方が空間効率が高いのでパフォーマンスが良くなります。
Goで実装
それでは具体的にGo言語で実装してみます。
実装
radix tree
package main import ( "net/http" "strings" ) type Route struct { Path string `json:"path"` handler http.HandlerFunc } type Node struct { Part string `json:"part"` Children []*Node `json:"children"` IsWild bool `json:"isWild"` Route Route `json:"route"` } func (n *Node) insert(pattern string, route Route) { parts := strings.Split(pattern, "/")[1:] for _, part := range parts { child := n.matchChild(part) if child == nil { child = &Node{ Part: part, IsWild: part[0] == ':' || part[0] == '*' || part[0] == '{', } n.Children = append(n.Children, child) } n = child } n.Route = route } func (n *Node) search(path string) Route { parts := strings.Split(path, "/")[1:] for _, part := range parts { child := n.matchChild(part) if child == nil { return Route{} } n = child } return n.Route } func (n *Node) matchChild(part string) *Node { for i := range n.Children { if n.Children[i].Part == part || n.Children[i].IsWild { return n.Children[i] } } return nil }
ポイントは以下です。
- Part:
/
で区切った場合のパスの一部を格納 - Node:基数木のデータ構造を表現するため自己参照を含む構造体
- IsWild:wildcardかどうかの判定。wildcardかどうかはよくある
:
,*
,{
があるかで判定 - Route:パスとハンドラの関数を持つ
http server
基数木のデータ構造ができたら実際にハンドラを登録します。
func Index(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Welcome!\n") } func Hello(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/hello/") fmt.Fprintf(w, "Hello, %s!\n", name) } func Hello2(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/hello/") fmt.Fprintf(w, "Hello2, %s!\n", name) } func main() { routes := []Route{ {Path: "/hello", handler: Index}, {Path: "/hello/:name", handler: Hello}, {Path: "/hello/:name/foo", handler: Hello2}, {Path: "/foo", handler: Index}, } tree := &Node{} for _, route := range routes { tree.insert(route.Path, route) } http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path route := tree.search(path) route.handler(w, r) }) http.ListenAndServe(":8080", nil) }
上のコードでは
- ハンドラを基数木に登録する
- ルートpath
/
でリクエストを受けつけてから、r.URL.Path
で生のpathを受け取り、それを前述の基数木ので検索する - ヒットしたハンドラを実行する
となっています。
動作確認
実際に
- /hello
- /hello/123
- /hello/234/foo
のようなリクエストを投げてみます。
$ curl localhost:8080/hello Welcome! $ curl localhost:8080/hello/123 Hello, 123! $ curl localhost:8080/hello/234/foo Hello2, 234/foo!
きちんと区別してルーティングできています。
正規表現とのベンチマーク
では正規表現と比較してどの程度パフォーマンスが変わるか確認してみます。
次のようなルーティングを用意し、
- /hello
- /hello/{name}
- /hello/{name}/foo
- /foo
- /foo/bar
- /foo/bar/{name}
- /bar
- /bar/baz
- /bar/baz/{name}
- /bar/baz/{name}/hoge
テストパターンとして以下の4つで検証してみます。
- First: 正規表現で最初に登録した/helloにルーティングする場合
- Mid: 真ん中の/foo/bar/{name}にルーティングする場合
- Last: 最後の/bar/baz/{name}/hogeにルーティングする場合
- Round: 全pathをラウンドロビンでルーティングする場合
結果
次のような結果になりました。
BenchmarkRegexFirst-10 11028405 97.70 ns/op 0 B/op 0 allocs/op BenchmarkRadixFirst-10 31994204 36.53 ns/op 32 B/op 1 allocs/op BenchmarkRegexMid-10 3229677 363.4 ns/op 0 B/op 0 allocs/op BenchmarkRadixMid-10 17497344 67.78 ns/op 64 B/op 1 allocs/op BenchmarkRegexLast-10 2487745 482.7 ns/op 0 B/op 0 allocs/op BenchmarkRadixLast-10 12847460 94.01 ns/op 80 B/op 1 allocs/op BenchmarkRegexRound-10 1506157 791.6 ns/op 0 B/op 0 allocs/op BenchmarkRadixRound-10 19155417 61.50 ns/op 51 B/op 1 allocs/op
Firstでさえ基数木の方が速く、3~13倍ほど高速でした。
10個程度のルーティングでこの結果なので、数が増えればより基数木の方が良い結果になると考えられます。
その他
サンプルコード
今回のサンプルコードはこちらです。
フレームワークを使えばいいのでは?
もちろんパフォーマンスや利便性が最適化されたフレームワークがある以上、ルーティングを自前で実装するメリットはあまりありません。
しかし例えば
- wildcardを含む特定のpathを認証のホワイトリストにしたい
- wildcardを含む特定のpathだけCache-Controlを変更したい
のようなユースケースで、フレームワークでは実現できない場合にこのようなやり方を知っていればパフォーマンスを落とすこと無く解決できます。
まとめ
パスルーティングにおいて基数木を使うことで効率的にパターンマッチできることを確認できました。