Go Echo v4のミドルウェア設計—Skipperで認証を柔軟に外す実装パターン

Go Echo v4のミドルウェア設計—Skipperで認証を柔軟に外す実装パターン | mohablog

Echo v4のミドルウェアは、Engine全体・ルートグループ・個別ハンドラの3階層で適用できます。実運用では「ヘルスチェックだけ認証を外したい」「静的ファイルにログを残したくない」のような要件が出てくる。これをSkipperで処理する設計を整理します。

目次

Echo v4のミドルウェアは3階層で重ねる

Echoのミドルウェアは適用範囲ごとに書く場所が変わります。最新の安定版はv4.15.2(2025年5月1日リリース)。公式ドキュメントの Middleware セクションに Pre(), Use(), グループ.Use(), ルート別の3つの当て方がまとまっています。

Engine全体に適用するUse()

全ルートで動かしたい LoggerRecovere.Use(...) で登録します。

package main

import (
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	e.GET("/", func(c echo.Context) error {
		return c.String(200, "ok")
	})
	e.Logger.Fatal(e.Start(":8080"))
}

実行結果:

{"time":"2026-05-12T10:00:01.123+09:00","id":"5f3b","remote_ip":"127.0.0.1","host":"localhost:8080","method":"GET","uri":"/","status":200,"latency":48000}

Group単位で絞る

API系統だけにJWT認証をかけたい場合は Group() を作ってから .Use() します。

api := e.Group("/api", echojwt.JWT([]byte("secret")))
api.GET("/users/:id", getUser)
api.POST("/posts", createPost)

この書き方なら /healthz/login はJWT検査を通らず素通りします。

Routeごとに個別に当てる

ハンドラの第3引数以降にミドルウェアを並べると、そのルートだけに適用されます。「アップロード経路だけBodyLimitを5MBに拡張したい」場面で使う。

e.POST("/upload", uploadHandler, middleware.BodyLimit("5M"))

MiddlewareFuncの最小実装を読み解く

カスタムミドルウェアを書くために、Echoの型定義を確認します。pkg.go.devgithub.com/labstack/echo/v4 ページに以下の型が載っています。

type MiddlewareFunc func(next HandlerFunc) HandlerFunc
type HandlerFunc    func(c Context) error

MiddlewareFunc「次のハンドラを受け取って、ラップした新しいハンドラを返す関数」。前処理は next(c) の前、後処理はその後ろに書く。

処理時間を計測するミドルウェア

func Timing() echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			start := time.Now()
			err := next(c)
			elapsed := time.Since(start)
			c.Response().Header().Set("X-Elapsed-Ms",
				strconv.FormatInt(elapsed.Milliseconds(), 10))
			return err
		}
	}
}

実行すると、レスポンスヘッダに処理時間が乗ります。

HTTP/1.1 200 OK
X-Elapsed-Ms: 17
Content-Type: application/json

errを必ず上に返す

next(c) の戻り値はエラーです。これを握り潰すと、後段の HTTPErrorHandler が動かずクライアントに500すら返らない。必ず return err で上流に流すのが原則。

組み込みミドルウェアで揃える定番セット

新規プロジェクトで最初に入れるものを一覧にします。

ミドルウェア役割本番での効き目
Logger()アクセスログ出力JSON構造化で集計できる
Recover()panic回収して500応答1リクエストの失敗で全体が落ちない
RequestID()X-Request-Id付与ログとトレースが紐づく
CORS()クロスオリジン許可SPAから直接呼べる

CORSは設定無しの素のままだと全許可になる。本番では AllowOrigins を明示する。

e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
	AllowOrigins: []string{"https://app.example.com"},
	AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodDelete},
	MaxAge:       3600,
}))

v4.15.0で TimeoutMiddleware が非推奨になり、リクエスト全体のタイムアウトは ContextTimeout を使う設計に切り替わっています。

Skipperで条件付きスキップを書く

Skipperの型は github.com/labstack/echo/v4/middleware パッケージに定義されています。

type Skipper func(c echo.Context) bool

戻り値が true ならそのリクエストはミドルウェアを通らず素通り。組み込みミドルウェアはほぼ全てConfigに Skipper を持つ。

ヘルスチェックだけログを残さない

e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
	Skipper: func(c echo.Context) bool {
		return c.Path() == "/healthz"
	},
}))

k8sのliveness probeは1秒間隔で /healthz を叩く。これがログに出続けると、1日あたり86,400件のノイズが溜まる。Skipperで外せば障害調査時のgrepが軽くなる。

PathとMethodの両方で判定する

「OPTIONSは認証スキップ、POSTは認証する」のような条件もSkipperで書けます。

authSkipper := func(c echo.Context) bool {
	if c.Request().Method == http.MethodOptions {
		return true
	}
	publicPaths := map[string]bool{
		"/api/v1/login":  true,
		"/api/v1/signup": true,
	}
	return publicPaths[c.Path()]
}

これを echojwt.Config{Skipper: authSkipper, ...} に渡せば、ログインとサインアップだけJWT検査を通らない。

middleware.DefaultSkipper

Skipperを明示しない場合は middleware.DefaultSkipper が使われます。中身は常に false を返すだけで、「全リクエストに適用する」が初期値。

JWT認証をRoute Groupに当てる

JWT認証は github.com/labstack/echo-jwt/v4 パッケージを使います。e.Use() で全体に当てると /login 自身が通れなくなるので、Group単位で適用するのが基本。

echo-jwtの最小設定

import (
	"github.com/golang-jwt/jwt/v5"
	echojwt "github.com/labstack/echo-jwt/v4"
)

api := e.Group("/api")
api.Use(echojwt.WithConfig(echojwt.Config{
	SigningKey:    []byte("your-256-bit-secret"),
	SigningMethod: "HS256",
	TokenLookup:   "header:Authorization:Bearer ",
}))

api.GET("/me", func(c echo.Context) error {
	token := c.Get("user").(*jwt.Token)
	claims := token.Claims.(jwt.MapClaims)
	return c.JSON(200, claims)
})

有効なトークンを付けて叩くと、claimsがJSONで返る。

$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." http://localhost:8080/api/me
{"exp":1747200000,"name":"moha","sub":"user-42"}

Custom Configurationで認証失敗を分岐させる

公式ドキュメント Custom Configuration セクションでは ErrorHandler を上書きする方法が示されています。トークン切れと改ざんを別ステータスで返したい場合に効く。

api.Use(echojwt.WithConfig(echojwt.Config{
	SigningKey: []byte("secret"),
	ErrorHandler: func(c echo.Context, err error) error {
		if errors.Is(err, jwt.ErrTokenExpired) {
			return c.JSON(419, map[string]string{"error": "token_expired"})
		}
		return c.JSON(401, map[string]string{"error": "unauthorized"})
	},
}))

419を返せばクライアント側で「期限切れだから refresh エンドポイントに投げる」という分岐をクリーンに書ける。

EchoとGinのミドルウェア比較

Goのフレームワーク選定でEchoとGinが並ぶとき、ミドルウェアのシグネチャと呼び出し規約が一番分かれる部分です。

項目Echo v4Gin v1.10系
シグネチャfunc(next HandlerFunc) HandlerFuncfunc(c *gin.Context)
次のハンドラ呼び出しnext(c) 必須c.Next() 任意
エラー伝播戻り値の error で連鎖c.Errors に蓄積
Skipper組み込みConfigに統一個別実装
Contextinterface型struct型

Echoはミドルウェアを関数のラップとして書くので、後ろから読むと処理順がそのまま見える。Ginは c.Next() を呼ばないと前処理だけで終わってしまうので、書き忘れに気付きにくい。

data raceを避けるための注意点

Echoは1リクエストにつき1ゴルーチン。Echoインスタンスや独自ミドルウェアで保持するフィールドにグローバル状態を置くと data race が起きます。

echo.Echoのフィールドは起動後にいじらない

公式ドキュメントには「Do not mutate Echo instance fields after server has started」と明記されています。ルート登録は e.Start() 前に済ませる。動的なルート追加は別の仕組みに移す。

カウンタを持つミドルウェアの落とし穴

NGパターンから示します。

// NG: 並行アクセスでカウンタが壊れる
func Counter() echo.MiddlewareFunc {
	count := 0
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			count++ // data race
			c.Response().Header().Set("X-Count", strconv.Itoa(count))
			return next(c)
		}
	}
}

OKパターンは atomic を使う。

func Counter() echo.MiddlewareFunc {
	var count atomic.Int64
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			n := count.Add(1)
			c.Response().Header().Set("X-Count", strconv.FormatInt(n, 10))
			return next(c)
		}
	}
}

本番に出す前は go test -race ./... で確認する。以前、ロガーで集計用のmapを直接書き込む実装をマージしたところ、ステージング負荷試験で「ログがランダムに2件混ざる」現象に当たった。-race 付きで走らせると同じmapに複数ゴルーチンから書いている箇所が一発で検知できた。

まとめ

  • Echo v4のミドルウェアはEngine/Group/Routeの3階層で適用先を切り替える
  • MiddlewareFuncfunc(next HandlerFunc) HandlerFunc。前処理→next(c)→後処理の順で書き、エラーは必ず上流に返す
  • 条件付きスキップは Skipper func(c echo.Context) bool を組み込みConfigに渡す
  • JWT認証はGroup単位で当て、Custom Configurationの ErrorHandler で期限切れと改ざんを分岐
  • カスタムミドルウェアに共有状態を持たせるなら atomicsync.Mutex-race を必ず通す

v4.15系で ContextTimeout と Generic Parameter Binding が追加され、旧 Timeout ミドルウェアは Deprecated 扱いに移っています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次