Echo v4のミドルウェアは、Engine全体・ルートグループ・個別ハンドラの3階層で適用できます。実運用では「ヘルスチェックだけ認証を外したい」「静的ファイルにログを残したくない」のような要件が出てくる。これをSkipperで処理する設計を整理します。
Echo v4のミドルウェアは3階層で重ねる
Echoのミドルウェアは適用範囲ごとに書く場所が変わります。最新の安定版はv4.15.2(2025年5月1日リリース)。公式ドキュメントの Middleware セクションに Pre(), Use(), グループ.Use(), ルート別の3つの当て方がまとまっています。
Engine全体に適用するUse()
全ルートで動かしたい Logger や Recover は e.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.dev の github.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 v4 | Gin v1.10系 |
|---|---|---|
| シグネチャ | func(next HandlerFunc) HandlerFunc | func(c *gin.Context) |
| 次のハンドラ呼び出し | next(c) 必須 | c.Next() 任意 |
| エラー伝播 | 戻り値の error で連鎖 | c.Errors に蓄積 |
| Skipper | 組み込みConfigに統一 | 個別実装 |
| Context | interface型 | 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階層で適用先を切り替える
MiddlewareFuncはfunc(next HandlerFunc) HandlerFunc。前処理→next(c)→後処理の順で書き、エラーは必ず上流に返す- 条件付きスキップは
Skipper func(c echo.Context) boolを組み込みConfigに渡す - JWT認証はGroup単位で当て、Custom Configurationの
ErrorHandlerで期限切れと改ざんを分岐 - カスタムミドルウェアに共有状態を持たせるなら
atomicかsync.Mutex。-raceを必ず通す
v4.15系で ContextTimeout と Generic Parameter Binding が追加され、旧 Timeout ミドルウェアは Deprecated 扱いに移っています。

