🍋

ContextからGinのルーティングとミドルウェアについてシンプルに理解する

に公開

はじめに

GoでWebサーバを作る際に利用されるフレームワークGinですが、引数に渡すgin.Contextについて調べていく中でルーティングやミドルウェアについての学びがあったので、まとめておこうと思います。

gin.Contextとは

Ginでは、リクエストごとに、*gin.Context型の値が生成され、すべてのハンドラ関数に渡されます。このContextには以下のような役割があります。

  • リクエスト情報にアクセス(クエリ・パラメータ・ボディなど)
  • レスポンスの出力(JSON, HTML, ステータスコード)
  • ミドルウェアの状態管理(どこまで実行したか)

ここで挙げたのは一部で、以下の公式ドキュメントに細かく載っております。自分はこのcontextをGinが1件のリクエストを処理するための「道具箱」と捉えています。正しい時に正しい道具を取り出せるようになれるよう勉強中です。

https://pkg.go.dev/github.com/gin-gonic/gin@v1.10.1#Context

Ginのルーティング

GinはURLパスのマッチングに基数木(Radix Tree)(後述します)を使って高速に処理を決定します。リクエストが特定のルートにマッチすると、そのルートに登録されたハンドラが呼ばれます。
Context内に、URLパス中の動的パラメータ(例:/user/:id:id)を扱う仕組みParamがあります。

Param

ルーティング時にURLの実際の値が抽出されて、Context.Params に格納されます。これは内部的にキー(パラメータ名)と値(実際のURL部分)がペアで保持され、c.Param("id") でアクセス可能です。

r.GET("/user/:id", func(c *gin.Context) {
    id := c.Param("id") 
    c.String(200, "User ID: %s", id)
})

基数木(Radix Tree)」とは

Ginのルーティング内部では、URLパスのマッチングに基数木(Radix Tree)というデータ構造が使われています。Radix Treeは、共通の接頭辞をまとめて管理できる圧縮トライ構造で、次のような特徴があります

  • URLのプレフィックスごとにノードを持ち、部分一致を高速に判定
  • 静的ルートと動的プレースホルダ(:id など)を効率的に切り分けられる
  • ルート定義が多くても、探索が高速で一定の性能が保たれる

たとえば、次のルートが定義されている場合

r.GET("/user/:id", ...)
r.GET("/user/list", ...)

この2つのルートは、基数木の構造の中で /user/ ノードを共有しつつ、その先に :idlist という枝が分岐するように登録されます。
これにより、Ginはリクエストされたパスをツリー構造の中から高速に探索し、適切なハンドラを見つけることができます。基数木については以下の記事がわかりやすいです。

https://ja.tech.jar.jp/network/algorithms/radix.html#google_vignette

ミドルウェア

ミドルウェアは、「リクエストとレスポンスの間に割り込んで共通処理を行う関数」です。

c.Next()

以下のコードでアクセスログを出力するミドルウェアの挙動を確認します。

func LogMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        fmt.Println("[前] LogMiddleware")
        c.Next() // 次のミドルウェアまたはハンドラへ進む
        fmt.Println("[後] LogMiddleware")
    }
}

r := gin.Default()
r.Use(LogMiddleware())

r.GET("/hello", func(c *gin.Context) {
    fmt.Println("[ハンドラ] /hello 実行")
    c.String(200, "Hello")
})s

順番に追っていきます。

r := gin.Default()

この行では、Logger と Recovery という2つのデフォルトミドルウェアを組み込んでいます。Logger は標準のアクセスログ用ミドルウェア、Recovery は、実行時にパニック(panic)が発生した場合でも、サーバが落ちないようにする保護ミドルウェアです。サーバは止まらず、500エラーが返されるだけで済みます。本番環境では非常に重要なミドルウェアで、明示的に無効化したい場合は gin.New() を使って自分でミドルウェアを組み立てる必要があります。

r.Use(LogMiddleware())

r.Use(LogMiddleware()) を追加することで、すべてのルートにこの LogMiddleware ミドルウェアが適用されます。どのルートにアクセスしてもこのミドルウェアが実行されるということです。

func LogMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        fmt.Println("[前] LogMiddleware")
        c.Next() // 次のミドルウェアまたはハンドラへ進む
        fmt.Println("[後] LogMiddleware")
    }
}

は以下のように動きます。

c.Next() の前 → リクエスト受信直後
c.Next() → 次の処理(別のミドルウェア or ハンドラ)へ進む
c.Next() の後 → 後続の処理がすべて終わった後に実行される

つまりこの設計によって、LogMiddleware() ミドルウェアはリクエストの前後をはさむように処理を入れられます。従って、以下のように整理できます。

  1. /helloにアクセス
  2. すべてのルートに適用されたミドルウェア(LogMiddleware())が実行
  3. fmt.Println("[前] LogMiddleware")
  4. c.Next() により次の処理(/helloハンドラに戻る)へ進む
  5. fmt.Println("[ハンドラ] /hello 実行")
  6. /helloのハンドラが終わったので、c.Nextの後を実行
  7. fmt.Println("[後] LogMiddleware")

よって実行結果は以下のようになります。

[前] LogMiddleware
[ハンドラ] /hello 実行
[後] LogMiddleware

Ginミドルウェアチェーンが

[LogMiddleware, "/hello"ハンドラ]

で構成され、c.Next()によってこの配列で次の要素に移行すると考えるとイメージしやすいです。
このようなミドルウェアとハンドラの実行順序を制御する役割もc.Nextというメソッドで提供してくれるContextの素晴らしさが感じられます。

c.Abort()

Ginの Context には Abort() というメソッドがあります。これを使うと、現在のミドルウェア以降の処理(後続のミドルウェアやハンドラ)をスキップできます。

ただし、Abort() は「今まさに実行中の関数」は止めないことに注意が必要です。

func M1() gin.HandlerFunc {
    return func(c *gin.Context) {
        fmt.Println("M1: before")
        c.Next()
        fmt.Println("M1: after")
    }
}

func M2() gin.HandlerFunc {
    return func(c *gin.Context) {
        fmt.Println("M2: before")
        c.Abort() // これ以降の処理は中断
        fmt.Println("M2: after (still runs)")
    }
}

r := gin.Default()
r.Use(M1(), M2())

r.GET("/", func(c *gin.Context) {
    fmt.Println("handler")
    c.String(200, "OK")
})

出力結果

M1: before
M2: before
M2: after (still runs)
M1: after

今回の場合、Ginのミドルウェアチェーンは

[M1, M2, "/"ハンドラ]

になり、この配列を c.Next() によって「1つずつ前に進む」仕組みです。

今回の出力結果を見ると、/ハンドラが実行されていないことがわかります。これはM2の中で、c.Abort()が呼び出されたことにより、上記の配列の中で次の要素以降の処理を中断するためです。ただ、自身の処理はすべて実行するのでM2: after (still runs)と出力されています。

すべての処理は Context を共有しながら進んでいくため、前のミドルウェアでセットした情報を後ろの関数で使うことも可能です。また、特定のルートのみミドルウェアを呼びたい時などもあり、それに関しては以下の記事でわかりやすく解説されていました。
https://qiita.com/tobita0000/items/d2309cc3f0dd32006ead

まとめ

  • ルーティング:URLごとに処理を振り分ける仕組み(基数木で実現)
  • ミドルウェア:共通処理を事前/事後に実行する関数
  • Context:リクエストごとの状態と操作をまとめた箱

GinにおけるContextを調べていく上で学んだ内容をまとめてみました。Contextには他にも利用法があるので今後もキャッチアップしていきます。

参考

https://fibbonachos.hashnode.dev/deep-dive-into-gin-gonic

Discussion