🦍

gorilla/muxのソースコードを読んだらめっちゃシンプルな仕組みだった

に公開

HTTPルータを書いたことはありますか?
Webサーバを作る時に使う、リクエスト(多くはHTTPメソッドやパス)に応じて処理を振り分けるやつです。

例えばこんな感じ(標準ライブラリのドキュメントから):

http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})

https://pkg.go.dev/net/http#hdr-Servers

あるパスにマッチするかどうかを調べ、マッチした場合に特定のハンドラを起動する仕組みには、トライ木やその派生形のパトリシア木、基数木といったデータ構造がよく使われます。

トライ木は文字列などの値を複数保持する際に、共通部分を節、相違部分を葉とした木構造で、URLのパスから対応するハンドラを探すのに適しています。

例えば、以下の複数のパスを木構造にすると……

  • /login/
  • /users/
  • /users/alice/
  • /users/alice/followers/
  • /users/bob/
  • /about/

こうなります:

/
+- login/
+- users/
|    +- alice/
|    |    +- followers/
|    +- bob/
+- about/

トライ木を使った実装については以下の記事がとてもわかりやすかったのでオススメです。

https://bmf-tech.com/posts/net/httpでつくるHTTPルーター自作入門

Goのライブラリに関していえば、Ginechochiなどのライブラリがそれにあたります(chiには「基数木の実装はarmon/go-radixを基にした」というコメントがありますね)。

https://github.com/gin-gonic/gin/blob/master/tree.go

https://github.com/labstack/echo/blob/master/router.go

https://github.com/go-chi/chi/blob/master/tree.go

いろいろあってこれらのコードを読んでいたところ、gorilla/muxトライ木を使わないとてもシンプルな仕組みを採用していて驚いたので紹介します。

https://github.com/gorilla/mux/

Router

gorilla/muxはGoの標準ライブラリであるnet/httpと組み合わせて使うことを想定しており、メインのルータであるRouterhttp.Handlerインターフェースを実装しています。

http.HandlerインターフェースはServeHTTP(w http.ResponseWriter, r *http.Request)というメソッドただ1つを持つインターフェースです。

type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

https://pkg.go.dev/net/http#Handler

gorilla/muxではこんな感じで実装しています。

https://github.com/gorilla/mux/blob/main/mux.go#L184-L229

何をしているのかというと:

  1. パスをきれいにする
  2. 自身のMatch()メソッドを呼び出してリクエストにマッチするルートRouteがあるか調べる
  3. マッチしたルートがあれば、リクエストに追加の情報をセットしてそのルートに対応するハンドラを呼び出す
  4. 無ければnotFoundHandler()methodNotAllowedHandler()など事前に定義済みのハンドラを呼び出す

という極めて自然なことをしています。

Router#Match()

上述の「2.」で呼び出されるRouter#Match()はこれです。

https://github.com/gorilla/mux/blob/main/mux.go#L140-L182

保持しているRouteを順に辿り、そのルートのMatch()メソッドを呼び出してマッチするか調べています。
マッチした時の付随情報(ハンドラや変数など)はRouter#Match()の第2引数であるRouteMatchに格納するようになっており、あるルートがマッチした場合はそのハンドラにミドルウェアを適用しています。

Route#Match()

各ルートを表すRouteRoute#matchersMatcherの配列を保持しています。

https://github.com/gorilla/mux/blob/main/route.go#L16-L38

https://github.com/gorilla/mux/blob/main/mux.go#L81-L110

そしてRoute#Match()が呼ばれると、Matcher#Match()を呼び出してリクエストがマッチするか調べます。

https://github.com/gorilla/mux/blob/main/route.go#L46-L114

Matcher

ではMatcherはなんなのかというと、Match(*http.Request, *RouteMatch) boolというメソッドを持つインターフェースです。

https://github.com/gorilla/mux/blob/main/route.go#L233-L236

条件の対象となる項目はパスだけでなく、HTTPメソッドの種類やホスト名、スキーマ(httpとかhttpsとか)なども考えられます。
そのため、共通のインターフェースを定義して.それぞれの項目毎に実装となるMatcherを用意しているわけです。

具体的な実装として、HTTPスキーマにマッチするschemeMatcher

https://github.com/gorilla/mux/blob/main/route.go#L484-L502

ヘッダにマッチするheaderMatcher/headerRegexpMatcher

https://github.com/gorilla/mux/blob/main/route.go#L296-L319

https://github.com/gorilla/mux/blob/main/route.go#L321-L326

そしてパスにマッチするrouteRegexpなどが用意されています。

https://github.com/gorilla/mux/blob/main/regexp.go#L167-L209

作ってみよう

何かを理解するには車輪の再発明が有用です。作ってみましょう。

Matcher

まずはリクエストを受け取ってそのリクエストが事前に与えられた条件にマッチするか調べるMatcherと、マッチした場合に使用する付随情報Matchedgorilla/muxにおけるRouteMatch)を考えましょう。

今回は単純に「パスを正規表現でマッチしたときのサブマッチ(つまり、/users/(?<name>[^/]+)name部分)」を保持するVarsのみ定義しておきます。

type Matched struct {
    Vars map[string]string
}

func NewMatched() *Matched {
    return &Matched{
        Vars: map[string]string{},
    }
}

type Matcher interface {
    Match(r *http.Request, matched *Matched) bool
}

例えば、HTTPメソッドが与えられた値ならマッチするMethodMatcherはこうなります。

type MethodMatcher struct {
    Method string
}

// Matcher implementation
func (m *MethodMatcher) Match(r *http.Request, matched *Matched) bool {
    return r.Method == m.Method
}

さらに、パスが事前に与えられた正規表現とマッチするかチェックするRegexpMatcherはこうです。
パスが正規表現にマッチしたら、名前付きのサブマッチの値をMatched.Varsにセットします。

type RegexpMatcher struct {
    *regexp.Regexp
}

// Matcher implementation
func (m *RegexpMatcher) Match(r *http.Request, matched *Matched) bool {
    matches := m.Regexp.FindStringSubmatch(r.Url.Path)
    if matches == nil {
        return false
    }
    if matched != nil && matched.Vars != nil {
        for i, name := range m.Regexp.SubexpNames() {
            if name != "" {
                matched.Vars[name] = matches[i]
            }
        }
    }
    return true
}

Route

これらMatcherとハンドラを紐づけるRoute構造体を定義します。
Routeはそのルートがリクエストとマッチするか調べるMatchメソッドと、各種Matcherやハンドラを登録するためのメソッドを持っています。
Matcherやハンドラを登録するためのメソッドたちはメソッド・チェインできるようにRoute自身を返すようにしました。

type Route struct {
    matchers []Matcher
    matched  *Matched
    Handler  http.Handler
}

// 後で作るRouter.NewRoute()で作ってほしいため非公開にする
func newRoute() *Route {
    return &Route{
        matched: NewMatched(),
    }
}

// Matcher implementation
func (r *Route) Match(req *http.Request) bool {
    for _, m := range r.matchers {
        if m.Match(req, r.matched) {
            return true
        }
    }
    return false
}

// パスにマッチ
func (r *Route) Path(re *regexp.Regexp) *Route {
    r.matchers = append(r.matchers, &RegexpMatcher{re})
    return r
}

// HTTPメソッドにマッチ
func (r *Route) Method(string name) *Route {
    r.matchers = append(r.matchers, &MethodMatcher{name})
    return r
}

// ハンドラを登録
func (r *Route) SetHandler(h http.Handler) *Route {
    r.Handler = h
}

// http.Handler implementation
func(r *Route) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    r.Handler.ServeHTTP(w, req)
}

Router

さて、Routeは1つのルートを表す構造体ですから、リクエストが来たら複数のルートから一番最初にマッチしたルートに紐付けられたハンドラ呼び出すようにすればRouterを作ることができます。
付随情報であるMatchedも後から使えるように保存しておきます。

マッチするルートがなかったときには専用のハンドラ(ここでは仮にnotFoundHandlerとしましょう)を呼び出します。

type Router struct {
    Matched         *Matched
    routes          []*Route
    notFoundHandler http.Handler
}

func NewRouter() *Router {
    return &Router{
        // notFoundHandlerの実装は省略
        notFoundHandler: notFoundHandler,
    }
}

// 新しくRouteを追加
func (r *Router) NewRoute() *Route {
    route := newRoute()
    r.routes = append(r.routes, route)
    return route
}

// Routeが見つからなかった場合のハンドラを登録
func (r *Router) SetNotFoundHandler(h http.Handler) {
    // nilだと困るので一応チェック
    if h != nil {
        r.notFoundHandler = h
    }
}

// マッチするRouteを探す
func (r *Router) FindRoute(req *http.Request) *Route {
    for _, route := range r.routes {
        if route.Match(req) {
            // 最初にマッチしたルートを返す
            return route
        }
    }
    return nil
}

// http.Handler implementation
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // マッチするルートを探してハンドラを呼ぶ
    if route := r.FindRoute(req); route != nil {
        r.Matched = route.Matched
        route.ServeHTTP(w, req)
        return
    }

    // 1つもマッチしなかった場合
    r.notFoundHandler(w, req)
}

// 名前付きサブマッチの値を取得するユーティリティ
func (r *Router) Var(name string) (value string, ok bool) {
    if r.Matched == nil {
        return
    }
    value, ok = r.Matched.Vars[name]
    return
}

SubRouter

新しくルータを作ってRoute#Handlerに設定すれば、あるルート以下をまとめるSubRouterも作れます。

func (r *Route) SubRouter() *Router {
    router := NewRouter()
    route.SetHandler(router)
    return router
}

簡単ですね。

Middleware

ミドルウェアもhttp.Handlerを受け取ってhttp.Handlerを返す型と考えると実は簡単です。

ルートやルーターにミドルウェアを複数持たせることができるようにし、ハンドラを呼ぶ時にミドルウェアを経由してあげれば実現できます。

// ミドルウェアを表すインターフェイス
type Middleware interface {
    Apply(next http.Handler) http.Handler
}

// 関数でもMiddlewareインターフェイスを満たせるようにする
type MiddlewareFunc func(next http.Handler) http.Handler

func (f MiddlewareFunc) Apply(next http.Handler) http.Handler {
  return f(next)
}

// 複数のミドルウェアを適用したハンドラを返す
func applyMiddlewares(next http.Handler, middlewares []Middleware) http.Handler {
    h := next
    for _, m := range middlewares {
        h = m.Apply(h)
    }
    return h
}

Routeごとのミドルウェア:

type Route struct {
    middlewares []Middleware
    // 省略
}

func (r *Route) Use(m Middleware) *Router {
    r.middlewares = append(r.middlewares, m)
    return r
}

// http.Handler implementation
func (r *Route) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    handler := applyMiddlewares(r.Handler, r.middlewares)
    handler.ServeHTTP(w, req)
}

Routerごとのミドルウェア:

type Router struct {
    middlewares []Middleware
    // 省略
}

func (r *Router) Use(m Middleware) *Router {
    r.middlewares = append(r.middlewares, m)
    return r
}

// http.Handler implementation
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // マッチするルートを見つける
    if route := r.FindRoute(req); route != nil {
        r.Matched = route.Matched
        handler := applyMiddlewares(route, r.middlewares)
        handler.ServeHTTP(w, req)
    }
    // 1つもマッチしなかった場合
    r.notFoundHandler(w, req)
}

完成!

ここまでくると、こんな感じでそれっぽく使うことができるルータができました!

import "net/http"

// sample middleware
func Logging(next http.Handler) http.Handler {
    return func(w http.ResponseWriter, req *http.Request) {
        log.Printf("Request: %v\n", req)
        next(w, req)
    }
}

func main() {
    // ルータを作る
    r := NewRouter()
    // ミドルウェアを追加
    r.Use(Logging)
    // ルートを定義する
    r.NewRoute().Method("GET").Path("/users/(?<user>[^/]+)/?").SetHandler(func (w http.ResponseWriter, req *http.Request) {
        user, ok := r.Var("user")
        if !ok {
            user = "Guest"
        }
        io.WriteString(w, "Hello, " + user + "!")
    })
    // ListenAndServeを呼ぶなりなんなりする
    http.ListenAndServe(":8080", r)
}

Discussion