every Tech Blog

株式会社エブリーのTech Blogです。

echo.Contextはどこからきているのか

はじめに

開発1部でデリッシュキッチンのプレミアム機能の開発を担当している岩﨑です。

私は入社して初めてWebフレームワークのlabstack/echoに触れました。

使っていく中で「便利だけどこれどうやって動いているんだろう?」と思うことが増えてきました。

そこを意識しなくていいのがフレームワークの良いところなんだとは思いますが、気になるので内部実装を覗いてみようと思います。

要約

ここから先は順次実装を追っていくので最初に結論を記載しておきます。

  • Echoのハンドラ関数がecho.Contextを引数に取るのは、フレームワークのHandlerFunc型でそのように定義されているため。
  • echo.Contextは、HTTPリクエストを受け取った際にDIされたEchoのServeHTTPメソッド内でオブジェクトプールから取得・初期化される。
  • そして、ルーティングによってリクエストに対応するハンドラが特定され、このecho.Contextが引数として渡されて実行される。

echo.Contextはどこからきているのか

見出しに記載の通りですが、今回はecho.Contextってどこから呼ばれてるんだろう、という疑問を解消したいと思います。

これがどういう問いなのかは、コードを読んでもらった方が早いと思います。

以下はEchoを使用した最小のWebサーバーのコードです。

package main

import (
  "net/http"

  "github.com/labstack/echo/v4"
)

// Handler
func hello(c echo.Context) error {
  return c.String(http.StatusOK, "Hello, World!")
}

func main() {
  // Echo instance
  e := echo.New()

  // Routes
  e.GET("/", hello)

  // Start server
  e.Start(":1323")
}

これは、ルートパス/にGETリクエストを処理するハンドラを追加し、そのハンドラは"Hello, World!"レスポンスを返すようなシンプルなWebサーバーです。

echo.New()でechoのインスタンスを作成しています。

ここで、ハンドラ関数の引数としてecho.Contextを受け取っていますが、これはどこからきているのでしょうか?

ハンドラ関数の引数にecho.Contextを設定しなければならない理由

まずはハンドラ関数を登録している部分の内部実装を詳しく見ていきます。

e.GET()メソッドの実装を見ると、第2引数としてHandlerFunc型を期待していることがわかります。(※1)

// github.com/labstack/echo/v4@v4.13.4/echo.go
func (e *Echo) GET(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
    return e.Add(http.MethodGet, pth, h, m...)
}

HandlerFuncの型定義により、登録するすべてのハンドラ関数は以下のシグネチャに従う必要があります。

  • 引数:echo.Contextを受け取る
  • 戻り値:errorを返す
// github.com/labstack/echo/v4@v4.13.4/echo.go
// HandlerFunc defines a function to serve HTTP requests.
type HandlerFunc func(c Context) error

よってこの型定義に従わないハンドラを登録しようとすると、下記のようにエラーが出ます。

以上により、echoフレームワークを使用したハンドラ関数には必ずecho.Contextを引数として定義しなければならないことがわかりました。

ではどこからecho.Contextがきているのか

ここからが本題です。

GETメソッドが呼ばれた時に、どのようにハンドラにecho.Contextが渡されるのかについて見ていきます。

クライアントからHTTPリクエストがくると、Goの標準ライブラリであるhttp.ServerがServeHTTPメソッドを呼び出します。

net/http

ここでGoの標準ライブラリであるnet/httpパッケージについて簡単に触れておきます。

net/httpパッケージのHandlerインターフェースは、ServeHTTPメソッドを1つだけ持ちます。

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

Web サーバがリクエストを受け取るとServeHTTPが各リクエストごとに実行され、レスポンスの出力先であるhttp.ResponseWriter にレスポンス内容が書き込まれます。

標準パッケージ内では基底コンテキストを定義し、接続ごとのコンテキストを組み立てていることがわかります。

func (srv *Server) Serve(l net.Listener) error {
    // 省略

    baseCtx := context.Background()
    if srv.BaseContext != nil {
        baseCtx = srv.BaseContext(origListener)
        if baseCtx == nil {
            panic("BaseContext returned a nil context")
        }
    }

    // 省略

    ctx := context.WithValue(baseCtx, ServerContextKey, srv)
    for {

        // 省略

        connCtx := ctx
        if cc := srv.ConnContext; cc != nil {
            connCtx = cc(connCtx, rw)
            if connCtx == nil {
                panic("ConnContext returned nil")
            }
        }

        // 省略

        go c.serve(connCtx)
    }

EchoのServeHTTP

echo.New()で作成されるEchoインスタンスは、先ほど説明したhttp.Handlerインターフェースを実装しています。

つまりEchoもServeHTTPメソッドを持っています。

Echoのインスタンス作成時にhttp.ServerのHandlerフィールドに依存性注入(DI)しているため、リクエストを受け取るとEchoインスタンスのServeHTTPが呼ばれるのです。

// github.com/labstack/echo/v4@v4.13.4/echo.go
func New() (e *Echo) {
    e = &Echo{
        Server:    new(http.Server),
        // その他の初期化
    }
    // 省略

    e.Server.Handler = e

    // 省略
    return
}

ではEchoのServeHTTPメソッドをもう少し詳しくみていきます。

// github.com/labstack/echo/v4@v4.13.4/echo.go
// ServeHTTP implements `http.Handler` interface, which serves HTTP requests.
func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Acquire context
    c := e.pool.Get().(*context)
    c.Reset(r, w)
    var h HandlerFunc

    if e.premiddleware == nil {
        e.findRouter(r.Host).Find(r.Method, GetPath(r), c)
        h = c.Handler()
        h = applyMiddleware(h, e.middleware...)
    } else {
        h = func(c Context) error {
            e.findRouter(r.Host).Find(r.Method, GetPath(r), c)
            h := c.Handler()
            h = applyMiddleware(h, e.middleware...)
            return h(c)
        }
        h = applyMiddleware(h, e.premiddleware...)
    }

    // Execute chain
    if err := h(c); err != nil {
        e.HTTPErrorHandler(err, c)
    }

    // Release context
    e.pool.Put(c)
}

まずe.pool.Get().(*context) でプール(※2)からcontextを取得し、Resetメソッドで初期化します。

ここで、Echoのcontext構造体は以下のようなリクエスト、レスポンスに関する情報を持ちます。

// github.com/labstack/echo/v4@v4.13.4/context.go
type context struct {
    logger   Logger
    request  *http.Request
    response *Response
    query    url.Values
    echo     *Echo

    store Map
    lock  sync.RWMutex

    // following fields are set by Router
    handler HandlerFunc

    // path is route path that Router matched. It is empty string where there is no route match.
    // Route registered with RouteNotFound is considered as a match and path therefore is not empty.
    path string

    // Usually echo.Echo is sizing pvalues but there could be user created middlewares that decide to
    // overwrite parameter by calling SetParamNames + SetParamValues.
    // When echo.Echo allocated that slice it length/capacity is tied to echo.Echo.maxParam value.
    //
    // It is important that pvalues size is always equal or bigger to pnames length.
    pvalues []string

    // pnames length is tied to param count for the matched route
    pnames []string
}

次にe.findRouter(r.Host).Find(r.Method, GetPath(r), c)によりHTTPリクエストから取得したHTTPメソッドとパスから探索を行います。(※3)

詳細な探索アルゴリズムは割愛しますが、ルーティングは「静的パス > パラメータ付きパス > ワイルドカードパス」の順にツリーのノードを探索します。

そしてルートが見つかった場合、以下の情報がcontextに設定されます。

  • ハンドラ関数(ctx.handler
  • ルートパス(ctx.path
  • パラメータ名の配列(ctx.pnames
// github.com/labstack/echo/v4@v4.13.4/router.go
func (r *Router) Find(method, path string, c Context) {
    // 省略

    var rPath string
    var rPNames []string
    if matchedRouteMethod != nil {
        rPath = matchedRouteMethod.ppath
        rPNames = matchedRouteMethod.pnames
        ctx.handler = matchedRouteMethod.handler
    } else {
        // 省略
    }
    ctx.path = rPath
    ctx.pnames = rPNames
}

こうして得られたハンドラ関数にecho.Contextを引数として渡すことで処理が実行されます。(※4)

よって先ほど定義したルートパスのhello関数はここで実行されているのです。

// github.com/labstack/echo/v4@v4.13.4/echo.go
// ServeHTTP implements `http.Handler` interface, which serves HTTP requests.
func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 省略

    // Execute chain
    if err := h(c); err != nil {
        e.HTTPErrorHandler(err, c)
    }

    // Release context
    e.pool.Put(c)
}

あとはe.HTTPErrorHandler(err, c)によるエラーハンドリングを経て、contextをプール(※1)へPUTすれば処理は完了です。

まとめ

まとめは以下の通りです。

  • Echoのハンドラ関数がecho.Contextを引数に取るのは、フレームワークのHandlerFunc型でそのように定義されているため。
  • echo.Contextは、HTTPリクエストを受け取った際にDIされたEchoのServeHTTPメソッド内でオブジェクトプールから取得・初期化される。
  • そして、ルーティングによってリクエストに対応するハンドラが特定され、このecho.Contextが引数として渡されて実行される。

ほんの一部ですが、Echoの中を覗くことでHTTPリクエストがどの流れで処理されているのか大枠把握することができました。

調べているうちにmiddlewareで何をしているのかが気になってきたので、次はそこを調べてみようと思います。

最後まで読んでいただきありがとうございました!

脚注

※1) 今回はGETメソッドを使っていますが、その他のHTTPメソッドでも同じ登録処理を行なっています。

※2) sync package - sync - Go Packages

※3)ミドルウェアの処理は今回のスコープ外なので省略しています。

※4)実際には、ハンドラ関数はミドルウェアでラップされておりミドルウェアのチェーン処理の実行後にハンドラ関数が実行されますが、今回の記事のスコープ外なので省略しています。