REST APIに対応したマルチプレクサの作成

REST API対応のマルチプレクサを標準ライブラリのみで作成する方法を説明します。 シンプルな設計・実装をコンセプトに数十行の小さなコードで実現します。 サードパーティのライブラリを使わなくても、シンプルに実装できるということを体感してみてください。

要件

REST APIに必要なマルチプレクサの機能を定義します。

  • パスとHTTPメソッドの組み合わせでルーティングする
  • 動的なパスに対応する(例:/tasks/:id:idは動的に決まる)
  • 動的パスの値(リソース名)を取得できる(例:/tasks/:id:id部分の値を取得)

外部仕様

マルチプレクサを利用する側のコードを書いて、マルチプレクサの使われ方を定めます。

ルーティングの内容がわかりやすいコードを目指します。 標準ライブラリで手軽に実装するため、パスの指定は正規表現を利用することとします。 動的パスの値は正規表現のサブマッチで取得することとします。

func main() {
    // NewRouter関数でマルチプレクサを生成
    mux := NewRouter()
    // Addメソッドでルートを追加
    // 第一引数はメソッド
    // 第二引数はパス
    // 第三引数はハンドラ関数
    mux.Add(http.MethodPost, "/tasks", postTask)
    mux.Add(http.MethodGet, "/tasks", getTasks)
    // 動的パスは正規表現で対応
    // 動的パスの値は正規表現のサブマッチで取得
    mux.Add(http.MethodGet, "/tasks/([^/]+)", getTask)
    mux.Add(http.MethodPut, "/tasks/([^/]+)", putTask)
    mux.Add(http.MethodDelete, "/tasks/([^/]+)", deleteTask)
    // リソースのネストに対応
    mux.Add(http.MethodPost, "/tasks/([^/]+)/tags", postTaskTag)
    mux.Add(http.MethodDelete, "/tasks/([^/]+)/tags/([^/]+)", deleteTaskTag)
    // マルチプレクサを指定して、サーバを起動
    http.ListenAndServe(":8080", mux)
}

// ハンドラ関数で動的パスの値を取得する
func getTask(w http.ResponseWriter, r *http.Request) {
    // PathParamは動的パスの値を取得する関数
    // 第二引数には何番目(0から始まる)のサブマッチかを指定
    id := PathParam(r, 0)
}

コーディング

マルチプレクサのコーディングに入ります。 マルチプレクサをどう実装したら良いかを説明した後に、実際のコードとその説明をします。

マルチプレクサの作り方

マルチプレクサはどのように実装すれば良いでしょうか。 マルチプレクサの定義を確認してみましょう。

マルチプレクサはhttp.ListenAndServe関数の第二引数に指定します。 引数の型はHandlerと定義されています。

func ListenAndServe(addr string, handler Handler) error

Handlerとは何でしょうか。 Handlerはインターフェースであり、ServeHTTPメソッドを実装したものです。

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

これがマルチプレクサの正体です。マルチプレクサは単なるハンドラです。 ServeHTTPメソッドにて、パスやメソッドに応じたハンドラ関数を実行するように実装します。

ルーティングのデータ定義

ルーティングに必要なデータを格納する構造体を定義します。

ルータは全ルートのデータを保持します。データ型はルートのスライスとします。 ルートにはメソッド・パス・ハンドラ関数を保持します。

type router struct {
    routes []route
}

type route struct {
    method  string
    path    *regexp.Regexp
    handler http.HandlerFunc
}

ルートの登録処理

構造体を初期化する関数を作成します。

func NewRouter() *router {
    return &router{}
}

ルートを追加するメソッドを作成します。

func (r *router) Add(method string, pattern string, handler http.HandlerFunc) *router {
    newRoute := route{method, regexp.MustCompile("^" + pattern + "$"), handler}
    r.routes = append(r.routes, newRoute)
    return r
}

これでルーティングに必要なものが揃いました。

ルーティングの処理

マルチプレクサの中核であるハンドラを実装します。

ハンドラは次の処理をします。

  • リクエストされたパスとメソッドに一致するルートを探す
  • 動的パスを取得してコンテキストに格納する
  • ルートに対応するハンドラを実行する

ルートが見つからない場合は次の処理をします。

  • パスとメソッドが一致しない場合は404をレスポンス
  • メソッドのみ一致しない場合は405をレスポンス
type pathParamCtxKey struct{}

func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    var allow []string
    for _, route := range r.routes {
        matches := route.path.FindStringSubmatch(req.URL.Path)
        if len(matches) > 0 {
            if req.Method != route.method {
                allow = append(allow, route.method)
                continue
            }
            ctx := context.WithValue(req.Context(), pathParamCtxKey{}, matches[1:])
            route.handler(w, req.WithContext(ctx))
            return
        }
    }
    if len(allow) > 0 {
        w.Header().Set("Allow", strings.Join(allow, ", "))
        writeJSONError(w, http.StatusMethodNotAllowed, "")
        return
    }
    writeJSONError(w, http.StatusNotFound, "")
}

動的パス取得のヘルパー関数

コンテキストに格納した動的パスを取得するヘルパー関数を作成します。

func PathParam(r *http.Request, index int) string {
    params := r.Context().Value(pathParamCtxKey{}).([]string)
    return params[index]
}