httprouter にグループ機能を外付けするライブラリを書いてみた

10 min read読了の目安(約9600字

まえがき

julienschmidt/httprouter という Go で書かれた HTTP ルーターは、Go で Web アプリケーションを作る人の多くが知っているライブラリだと思います。(使ったことはなくても)
シンプルで高速な為、僕もよく利用させて頂いています。

しかしながら、現時点では httprouter にはグループで定義する機能がありません。ライブラリ作者の Julien Schmidt さんは v2 でサブルーターを 実装するつもりではいるようです が、投稿が残念ながら4年前...。また、ライブラリ自体も1年半ほど更新がありません。お忙しいのでしょうか...?

そこで、無いなら作ろうということで、ライブラリを書いてみることにしました。この記事はライブラリを書くにあたって考えたことの記録と簡単な利用方法のご紹介です。

成果物

mythrnr/httprouter-group
Go 1.13 以降で確認しています。詳しい利用方法は README を見てみてください。後ほど使い方も簡単に紹介します。

コンセプト

ライブラリを作成するにあたって、いくつか方針を立てています。

1. httprouter のパフォーマンスを落とさない

絶対に外せません。そして、僕のようなへっぽこぴーが下手に fork して内部に手を加えたらパフォーマンスを落とすので、別途外付けのライブラリという形を取りました。

「定義を楽にするためのライブラリ」に目的を絞り、mythrnr/httprouter-group はサーバー起動完了前に役目を終えます。

2. メソッドチェーンでもりもりルーティングを定義する

グループ化する機能がある他のライブラリ(FW)として、httprouter をカスタマイズして利用している gin 、他には echo などが有名どころとしてあります。他にもたくさん。

これらを利用するのも当然GOODなのですが、メソッドチェーンで定義する方式ではありません。ここをメソッドチェーンで定義できるようにすれば僕が便利に使えると思ったのでこの形を取ります。(断じて良し悪しではなく好みの問題です)

3. 親階層のパスとミドルウェアを引き継ぐ

パスとミドルウェアを引き継ぎます。これにより、例えば /protected 配下すべてに認証を要求するルーティングが簡単に記述できます。

httprouter には ミドルウェアを書く方法 が紹介されています。例に漏れず Wrapper を書くだけです。ただし、httprouter はそのままだと定義したハンドラ全てに Wrap の処理を書かないといけません。グループ化した上でこの書き方をキープするのは煩雑なので、このライブラリで吸収することにしました。

4. Julien Schmidt さんの「全てのルートをサブルーターにする」思想に準拠する

まえがきで挙げたプルリクエストの コメント には下記のようにあります。

My current idea is to make every route essentially a subrouter, ...

By Google翻訳

私の現在の考えは、すべてのルートを本質的にサブルーターにすることです

誤解でなければ、ルート(/)を含むグループAを定義した後にそのグループを別のグループBにまるごと子階層として登録することも可能にしたいということだと思います。(※マージではない)

使い方

READMEGoDoc を見てもらうのが一番ですが、簡単に使用方法をご紹介します。

  1. ルートを定義する
  2. httprouter を用意して諸々設定する
  3. httprouter1. で定義したルートを一気に登録する
  4. Listen and serve...
というのを書いた main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/julienschmidt/httprouter"
    group "github.com/mythrnr/httprouter-group"
)

// This definition provides following routes.
//
// - `GET /` with middleware 1
// - `GET /users` with middleware 1, 2
// - `GET /users/:id` with middleware 1, 2
// - `PUT /users/:id` with middleware 1, 3
// - `DELETE /users/:id` with middleware 1, 3
//
func main() {
    // first, define routes, handlers, and middlewares.
    g := group.New("/").GET(
        func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
            w.Write([]byte("GET /\n"))
        },
    ).Middleware(
        func(h httprouter.Handle) httprouter.Handle {
            return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                w.Write([]byte("Middleware 1: before\n"))
                h(w, r, p)
                w.Write([]byte("Middleware 1: after\n"))
            }
        },
    ).Children(
        group.New("/users").GET(
            func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                w.Write([]byte("GET /users\n"))
            },
        ).Middleware(
            func(h httprouter.Handle) httprouter.Handle {
                return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("Middleware 2: before\n"))
                    h(w, r, p)
                    w.Write([]byte("Middleware 2: after\n"))
                }
            },
        ).Children(
            group.New("/:id").GET(
                func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("GET /users/:id\n"))
                },
            ),
        ),
        group.New("/users/:id").PUT(
            func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                w.Write([]byte("PUT /users/:id\n"))
            },
        ).DELETE(
            func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                w.Write([]byte("DELETE /users/:id\n"))
            },
        ).Middleware(
            func(h httprouter.Handle) httprouter.Handle {
                return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("Middleware 3: before\n"))
                    h(w, r, p)
                    w.Write([]byte("Middleware 3: after\n"))
                }
            },
        ),
    )

    // next, set up and configure router.
    router := httprouter.New()
    router.PanicHandler = func(w http.ResponseWriter, r *http.Request, rec interface{}) {
        log.Fatal(rec)
    }

    // logging.
    //
    // GET     /
    // GET     /users
    // DELETE  /users/:id
    // GET     /users/:id
    // PUT     /users/:id
    fmt.Println(g.Routes().String())

    // finally, register routes to httprouter instance.
    for _, r := range g.Routes() {
        router.Handle(r.Method(), r.Path(), r.Handler())
    }

    // serve.
    log.Fatal(http.ListenAndServe(":8080", router))
}

ルートの定義方法

  1. group.New("/path") でグループの prefix を決めます。末尾の / は最終的に除去されます。グループの prefix が同じものが兄弟グループにあっても問題ありませんが、最終的なパスと HTTP メソッドの組み合わせが同一だと httprouter に弾かれます。この辺りのバリデーションはしません。httprouter に全て任せます。
動かない例(`GET /users/:id` が重複している)
// 略

func main() {
    g := group.New("/").GET(
        func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
            w.Write([]byte("GET /\n"))
        },
    ).Children(
        group.New("/users").GET(
            func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                w.Write([]byte("GET /users\n"))
            },
        ).Children(
            group.New("/:id").GET(
                func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("GET /users/:id\n"))
                },
            ),
        ),
        group.New("/users/:id").GET(
            func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                w.Write([]byte("GET /users/:id\n"))
            },
        ),
    )

    router := httprouter.New()

    // logging. ここには両方出る.
    //
    // GET     /
    // GET     /users
    // GET     /users/:id
    // GET     /users/:id
    fmt.Println(g.Routes().String())

    for _, r := range g.Routes() {
        //
        // ここで panic
        //
        router.Handle(r.Method(), r.Path(), r.Handler())
    }

    // serve.
    log.Fatal(http.ListenAndServe(":8080", router))
}
  1. Middleware メソッドでそのグループ内のルート全てで利用されるミドルウェアを登録します。ミドルウェアが実行される順番は 親グループの登録順 -> 子グループの登録順 となります。

  2. Children メソッドでグループに子グループを登録します。グループの作り方は 1.2. の通りです。尚、親グループが / で子グループも / の場合、親子は統合されます。 // にはなりません。親子が統合されるという考えは違和感があるかもしれませんが、便利な使用方法もあります。(下記の例を参照)

兄弟グループの特定のグループのみミドルウェアを追加
// 略

func main() {
    // 一番上の親要素
    g := group.New("/").Children(
        // `/a` と `/b` は `Middleware 1` を通したい
        group.New("/").Children(
            group.New("/a").GET(
                func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("GET /a\n"))
                },
            ),
            group.New("/b").GET(
                func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("GET /b\n"))
                },
            ),
        ).Middleware(
            func(h httprouter.Handle) httprouter.Handle {
                return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("Middleware 1: before\n"))
                    h(w, r, p)
                    w.Write([]byte("Middleware 1: after\n"))
                }
            },
        ),
        // `/c` と `/d` は `Middleware 2` を通したい
        group.New("/").Children(
            group.New("/c").GET(
                func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("GET /c\n"))
                },
            ),
            group.New("/d").GET(
                func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("GET /d\n"))
                },
            ),
        ).Middleware(
            func(h httprouter.Handle) httprouter.Handle {
                return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
                    w.Write([]byte("Middleware 2: before\n"))
                    h(w, r, p)
                    w.Write([]byte("Middleware 2: after\n"))
                }
            },
        ),
        // ...
    )

    // logging.
    //
    // GET     /a
    // GET     /b
    // GET     /c
    // GET     /d
    fmt.Println(g.Routes().String())

    // 略
}

このライブラリの意味

これまでたくさんの案を殺してきた自分の中の悪魔と闘います。

Q. ginecho で良くない?

世の中には素晴らしいライブラリがたくさんあるからそれを使えばいい。
「無いものは作る」は正しいが車輪の再発明にはならないのか?

A. ginecho はすごい。疑いようも無い。だけど...

  1. メソッドチェーンで書きたかった
  2. 独自コンテキストを採用しているのが好みではなかった(不便は無いと思う)
  3. 機能をそこまで必要としていない。もっと薄いものが欲しかった

Q. 誰が使うの?

A. 俺が使う

実際のところ

開発中のいくつかのプロダクトで使っていますが、だいぶ記述が楽になりました。
極々シンプルな実装ですが、自分で作ったもので自分の作業が楽になると嬉しいですね。
もりもりルーティングの定義が書けます。もりもり。
とはいえルーティングをもりもり書くタイミングはそう多くはありませんが...。

おわりに

ここまでご覧いただきありがとうございました。
誰かの助けになればなぁなんて思っております。