【待望】GoのWebフレームワーク echo v5が正式リリース!6年の沈黙を破りGenericsとslogを完全統合
はじめに
普段、GoのWebフレームワークのechoで実装しています!そのechoが6年ぶりにv5がリリースされた㊗️ので、嬉しくなったので記事にして共有します!!
2019年のv4リリースから約6年。Goの定番ウェブフレームワークが、ついにメジャーバージョンアップを果たし v5 として正式リリースされました。
今回のアップデートは単なる機能追加ではなく、Go 1.18以降の Generics や、標準ライブラリの slog といった、モダンなGoの仕様に合わせた「フルモデルチェンジ」と言える内容になっています。
この記事では、v5で何が変わったのか、コードのビフォーアフターを交えて徹底解説します。
1. EchoとGoの進化の歩み:v1からv5までの長い道のり
Echoの歴史を振り返ると、ウェブフレームワークとしての黎明期から、今回のモダン化に至るまでのダイナミックな流れが見えてきます。特にv4からv5までの「約6年半」という沈黙の期間は、Goコミュニティにとっても非常に長い安定期でした。
| Echo バージョン | リリース時期 | 当時の最新Go | トピック |
|---|---|---|---|
| v1.0 | 2015年 5月 | Go 1.4 | 黎明期。「高速・軽量」を掲げ人気を確立。 |
| v2.0 | 2016年 3月 | Go 1.6 | ミドルウェア刷新。HTTP/2の標準サポート対応。 |
| v3.0 | 2017年 1月 | Go 1.7 | 標準ライブラリの context 導入に合わせた設計変更。 |
| v4.0 | 2019年 9月 | Go 1.13 | Go Modulesへの正式対応。ここから長い「安定期」へ。 |
| (v4 時代) | 2019-2025 | Go 1.14-1.24 | Generics(1.18)やslog(1.21)が登場するも、v4は破壊的変更を避け維持。 |
| v5.0 | 2026年 1月 | Go 1.25 | 約6年4ヶ月ぶりのメジャーアップデート。モダンGoへの完全対応。 |
なぜこれほど時間がかかったのか?
v4は、Go Modulesという大きな変化に対応した「完成形」の一つでした。その後、Go言語には Generics (1.18) や slog (1.21) といった破壊的とも言える強力な機能が次々と追加されました。
Echoチームは「既存のプロジェクトを壊さない(安定性)」を最優先しながらも、裏では「v5-alpha」として数年間にわたり実験的な開発を続けてきました。そして2026年、ついにこれら全ての言語仕様を飲み込み、「これからの10年」を見据えた設計としてv5が結実したのです。
3. Echo v5 の注目アップデート:ここが変わった!
今回のアップデートで特に注目すべき「目玉機能」を4つピックアップしました。
① Genericsによる型安全なパラメータ取得
v4ではパスパラメータやクエリパラメータは必ず文字列で受け取る仕様でした。intやboolとして使いたい場合は、受け取り→変換→エラーハンドリングの3ステップを毎回自分で書く必要があります。エンドポイントやパラメータが増えるほど、同じ変換コードがコードベース全体に増殖していきます。
v5ではGo 1.18以降のGenericsを活用し、PathParam[int]のように型を指定するだけで変換まで自動で行われます。変換失敗時もエラーとして受け取れるため、ボイラープレートコードが不要になります。
下記が実際のサンプルコードです!
// ============================================================
// 特徴1【v4】: パスパラメータ・クエリパラメータの取得
//
// v4では型がないため、すべて「文字列で受け取る」→「手動で変換する」
// エンドポイントが増えるほど同じ変換コードが増殖していく。
// ============================================================
package main
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/users/:id", getUser)
e.GET("/users", listUsers)
e.Logger.Fatal(e.Start(":8801"))
}
// getUser パスパラメータ :id を int として使いたい場合
func getUser(c echo.Context) error {
// ① まず「文字列」で受け取るしかない
idStr := c.Param("id")
// ② 自分で int に変換 + エラーハンドリング
// これをエンドポイントの数だけ繰り返す羽目になる
id, err := strconv.Atoi(idStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "idは整数で指定してください")
}
return c.JSON(http.StatusOK, map[string]any{
"id": id,
"name": "山田太郎",
"version": "v4",
})
}
// listUsers クエリパラメータも同様に、全部文字列→変換の繰り返し
func listUsers(c echo.Context) error {
// page: 文字列取得 → デフォルト値処理 → int変換 → エラーハンドリング
pageStr := c.QueryParam("page")
page := 1
if pageStr != "" {
var err error
page, err = strconv.Atoi(pageStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "pageは整数で指定してください")
}
}
// limit も同じコードをもう一度...
limitStr := c.QueryParam("limit")
limit := 10
if limitStr != "" {
var err error
limit, err = strconv.Atoi(limitStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "limitは整数で指定してください")
}
}
// active (bool) も同じコードをもう一度...
activeStr := c.QueryParam("active")
active := true
if activeStr != "" {
var err error
active, err = strconv.ParseBool(activeStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "activeはtrue/falseで指定してください")
}
}
return c.JSON(http.StatusOK, map[string]any{
"page": page,
"limit": limit,
"active": active,
"version": "v4",
})
}
// ============================================================
// 特徴1【v5】: パスパラメータ・クエリパラメータの取得
//
// v5では Generics を使い「型を指定するだけ」で取得できる。
// 変換もデフォルト値処理も1行で完結する。
// ============================================================
package main
import (
"log/slog"
"net/http"
"github.com/labstack/echo/v5"
)
func main() {
e := echo.New()
e.GET("/users/:id", getUser)
e.GET("/users", listUsers)
// v5: e.Logger は *slog.Logger なので Fatal() メソッドがない
// → 標準的な Go のエラーハンドリングで書く
if err := e.Start(":8802"); err != nil {
slog.Error("サーバーエラー", "error", err)
}
}
// getUser パスパラメータ :id を int として使いたい場合
func getUser(c *echo.Context) error {
// PathParam[int] で「文字列取得」→「int変換」→「エラーハンドリング」が1行
// "abc" を渡した場合は自動的に 400 エラー相当のエラーを返す
id, err := echo.PathParam[int](c, "id")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "idは整数で指定してください")
}
return c.JSON(http.StatusOK, map[string]any{
"id": id,
"name": "山田太郎",
"version": "v5",
})
}
// listUsers クエリパラメータも型を指定するだけ。デフォルト値も1行
func listUsers(c *echo.Context) error {
// QueryParamOr[int] = 「文字列取得」+「int変換」+「デフォルト値」が1行
page, err := echo.QueryParamOr[int](c, "page", 1)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "pageは整数で指定してください")
}
// 同じパターンを繰り返すが、変換コードが一切ない
limit, err := echo.QueryParamOr[int](c, "limit", 10)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "limitは整数で指定してください")
}
// bool も同様に1行
active, err := echo.QueryParamOr[bool](c, "active", true)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "activeはtrue/falseで指定してください")
}
return c.JSON(http.StatusOK, map[string]any{
"page": page,
"limit": limit,
"active": active,
"version": "v5",
})
}
② echo.Context の構造体化
v4 では echo.Context はインターフェースでした。DBコネクションや認証済みユーザー情報など、リクエストをまたいで共有したい値をハンドラーに渡すには「カスタムContext」を作るのが一般的なパターンでした。
しかし、インターフェースを実装した独自構造体を作ると、ハンドラー内で毎回型アサーションが必要になります。型アサーションはミスが起きやすく、間違えると実行時にパニックします。
// v4: 型アサーションが毎回必要。失敗すると実行時パニック
cc, ok := c.(*CustomContext)
v5 では echo.Context が構造体(*echo.Context)になりました。カスタムContextを作る必要はなく、c.Set() で値を保存し、echo.ContextGetT でGenericsを使って型安全に取り出すだけです。
// v5: 型を指定して安全に取り出す。失敗してもパニックにならずエラーで受け取れる
repo, err := echo.ContextGet[*UserRepo](c, "repo")
型アサーションが消えた分、コードがシンプルになり、実行時パニックのリスクもなくなります。
// ============================================================
// 特徴2【v4】: カスタムContext
//
// v4 の echo.Context は「インターフェース」。
// 独自フィールド(DB接続など)を追加するには、
// インターフェースを埋め込んだ構造体を作るしかない。
//
// 問題点:
// ハンドラー内で毎回「型アサーション」が必要になる。
// 型アサーションに失敗すると実行時パニックになる。
// ============================================================
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
// --- モック: 本来は *sql.DB などが入る ---
type UserRepo struct{}
func (r *UserRepo) FindByID(id int) map[string]any {
return map[string]any{"id": id, "name": "山田太郎"}
}
// ============================================================
// v4 のカスタムContext パターン
//
// echo.Context(インターフェース)を埋め込んで独自フィールドを追加する。
// ============================================================
type CustomContext struct {
echo.Context // インターフェースを埋め込む
Repo *UserRepo // 独自フィールド
}
// injectRepo はCustomContextをセットするミドルウェア
func injectRepo(repo *UserRepo) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// 元のContextをCustomContextでラップして差し替える
cc := &CustomContext{c, repo}
return next(cc)
}
}
}
func main() {
repo := &UserRepo{}
e := echo.New()
e.Use(injectRepo(repo))
e.GET("/users/:id", getUser)
e.Logger.Fatal(e.Start(":8803"))
}
func getUser(c echo.Context) error {
// ハンドラー内で毎回「型アサーション」が必要
// ミドルウェアでCustomContextをセットし忘れると実行時パニック
cc, ok := c.(*CustomContext)
if !ok {
// ok チェックをサボると panic になる
return echo.NewHTTPError(http.StatusInternalServerError, "CustomContextへの変換失敗")
}
idStr := c.Param("id")
id := 0
for _, ch := range idStr {
id = id*10 + int(ch-'0')
}
user := cc.Repo.FindByID(id)
user["version"] = "v4"
return c.JSON(http.StatusOK, user)
}
// ============================================================
// 特徴2【v5】: Contextの構造体化
//
// v5 の echo.Context は「構造体」(*echo.Context)。
// インターフェースではないため、カスタムContextを作る必要がない。
//
// 代わりに:
// c.Set("key", value) でContextに値を保存
// echo.ContextGet[T](c, "key") で型安全に取り出す
//
// 型アサーションが不要なので、実行時パニックのリスクがない。
// ============================================================
package main
import (
"log/slog"
"net/http"
"github.com/labstack/echo/v5"
)
// --- モック: 本来は *sql.DB などが入る ---
type UserRepo struct{}
func (r *UserRepo) FindByID(id int) map[string]any {
return map[string]any{"id": id, "name": "山田太郎"}
}
// injectRepo はContextに値を保存するミドルウェア
func injectRepo(repo *UserRepo) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// カスタムContextを作る必要がない。c.Set で保存するだけ。
c.Set("repo", repo)
return next(c)
}
}
}
func main() {
repo := &UserRepo{}
e := echo.New()
e.Use(injectRepo(repo))
e.GET("/users/:id", getUser)
if err := e.Start(":8804"); err != nil {
slog.Error("サーバーエラー", "error", err)
}
}
func getUser(c *echo.Context) error {
// ContextGet[*UserRepo] で型安全に取り出す
// 型が違っていてもパニックにならず、エラーとして受け取れる
repo, err := echo.ContextGet[*UserRepo](c, "repo")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "repoが見つかりません")
}
id, err := echo.PathParam[int](c, "id")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "idは整数で指定してください")
}
user := repo.FindByID(id)
user["version"] = "v5"
return c.JSON(http.StatusOK, user)
}
③ slog ネイティブ統合
v4 では Echo 独自のロガーインターフェース(echo.Logger)が使われていました。一方、アプリケーション内のログは log.Println や Zap、Zerolog など別のライブラリで書くことが多く、ログの形式が混在しがちでした。
・アプリログ → テキスト形式
2026/04/29 [APP] ユーザー取得処理開始 id=42
・Echoのリクエストログ → Echo独自のJSON形式
{"time":"...","latency_human":"252µs","remote_ip":"..."}
CloudWatch や Datadog でログを調査しようとしたとき、「このリクエストのアプリログはどれ?」と探し回る羽目になります。
v5 では Go 1.21 から標準ライブラリに加わった log/slog をネイティブで採用しました。リクエストログもアプリログもすべて slog に統一されます。
{"time":"...","level":"INFO","msg":"ユーザー取得処理開始","id":42}
{"time":"...","level":"INFO","msg":"request","method":"GET","status":200}
全ログが同じ JSON 形式になるため、ログ監視ツールでのクエリが格段にシンプルになります。また、ライブラリごとにロガーのラッパーを書く必要もなくなります。
// ============================================================
// 特徴3【v4】: ログ出力
//
// v4 の悩み: ログの形式がバラバラになる
//
// ① Echoのリクエストログ → Echo独自フォーマット(gommon/log)
// ② アプリのログ → log.Println など標準ライブラリ
//
// 出力形式が統一されず、CloudWatch / Datadog などで
// 「どのログがどのリクエストのものか」を追うのが難しくなる。
// ============================================================
package main
import (
"log"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
e.HideBanner = true
// ① Echoのリクエストログ: Echo独自フォーマットで出力される
e.Use(middleware.Logger())
e.GET("/users/:id", getUser)
e.Logger.Fatal(e.Start(":8805"))
}
func getUser(c echo.Context) error {
id := c.Param("id")
// ② アプリのログ: Go標準の log.Println で出力
// → Echoのリクエストログと形式が全然違う
log.Printf("[APP] ユーザー取得処理開始 id=%s", id)
// 処理の途中でも形式がバラバラ
log.Printf("[APP] DBクエリ実行 id=%s took=%s", id, 5*time.Millisecond)
return c.JSON(http.StatusOK, map[string]any{
"id": id,
"name": "山田太郎",
"version": "v4",
})
}
// ============================================================
// 特徴3【v5】: slog ネイティブ統合
//
// v5 の恩恵: すべてのログが slog に統一される
//
// ① Echoのリクエストログ → slog の JSON フォーマット
// ② アプリのログ → slog の JSON フォーマット(同じ)
//
// 全ログが同じ JSON 形式になるため、
// CloudWatch / Datadog のクエリで簡単に絞り込める。
// ============================================================
package main
import (
"log/slog"
"net/http"
"os"
"time"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
)
func main() {
// アプリ全体で使う slog ロガーを1つ定義する
// JSON形式にすることでCloudWatch / Datadog での検索が容易になる
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
e := echo.New()
// ① Echoのリクエストログも slog で出力するよう設定
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogMethod: true,
LogURI: true,
LogStatus: true,
LogLatency: true,
LogValuesFunc: func(c *echo.Context, v middleware.RequestLoggerValues) error {
// slog で出力 → アプリログと同じ JSON 形式になる
slog.InfoContext(c.Request().Context(), "request",
"method", v.Method,
"uri", v.URI,
"status", v.Status,
"latency", v.Latency,
)
return nil
},
}))
e.GET("/users/:id", getUser)
if err := e.Start(":8806"); err != nil {
slog.Error("サーバーエラー", "error", err)
}
}
func getUser(c *echo.Context) error {
id, err := echo.PathParam[int](c, "id")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "idは整数で指定してください")
}
// ② アプリのログも slog で出力
// → リクエストログと同じ JSON 形式になる
slog.InfoContext(c.Request().Context(), "ユーザー取得処理開始", "id", id)
// key=value の構造化ログなので、CloudWatchで "id=42" を検索できる
slog.InfoContext(c.Request().Context(), "DBクエリ実行",
"id", id,
"took", 5*time.Millisecond,
)
return c.JSON(http.StatusOK, map[string]any{
"id": id,
"name": "山田太郎",
"version": "v5",
})
}
~/develop/go_echo_v5/feature3_slog/v4 (main)$ go run .
⇨ http server started on [::]:8805
2026/04/29 15:47:12 [APP] ユーザー取得処理開始 id=42
2026/04/29 15:47:12 [APP] DBクエリ実行 id=42 took=5ms
{"time":"2026-04-29T15:47:12.583896+09:00","id":"","remote_ip":"::1","host":"localhost:8805","method":"GET","uri":"/users/42","user_agent":"curl/8.7.1","status":200,"error":"","latency":790583,"latency_human":"790.583µs","bytes_in":0,"bytes_out":49}
~/develop/go_echo_v5/feature3_slog/v5 (main)$ go run .
{"time":"2026-04-29T15:48:07.061134+09:00","level":"INFO","msg":"Echo (v5.1.0). High performance, minimalist Go web framework https://echo.labstack.com","version":"5.1.0"}
{"time":"2026-04-29T15:48:07.061465+09:00","level":"INFO","msg":"http(s) server started","address":"[::]:8806"}
{"time":"2026-04-29T15:49:02.891813+09:00","level":"INFO","msg":"ユーザー取得処理開始","id":42}
{"time":"2026-04-29T15:49:02.891978+09:00","level":"INFO","msg":"DBクエリ実行","id":42,"took":5000000}
{"time":"2026-04-29T15:49:02.89246+09:00","level":"INFO","msg":"request","method":"GET","uri":"/users/42","status":200,"latency":739125}
④Graceful Shutdown
本番環境でサーバーを停止・再起動する際、処理中のリクエストを最後まで完了させてから終了する仕組みが Graceful Shutdown です。
これがないと、デプロイのたびに処理中のリクエストが強制切断され、ユーザーにエラーが返ります。ECS/Fargate などのコンテナ環境では頻繁にコンテナの入れ替えが発生するため、本番運用では必須の設定です。
v4 でも実現できますが、必要なコードをすべて自前で書く必要があります。
// v4: 定型コードを自前で書く必要がある
s := &http.Server{Addr: ":8080", Handler: e}
go func() { // goroutine で起動
s.ListenAndServe()
}()
quit := make(chan os.Signal, 1) // シグナル用チャネルを用意
signal.Notify(quit, os.Interrupt)
<-quit // シグナルを待つ
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.Shutdown(ctx) // 手動でシャットダウン
v5 では StartConfig に GracefulTimeout を設定し、signal.NotifyContext で作った ctx を渡すだけです。
// v5: StartConfig に集約するだけ
sc := echo.StartConfig{
Address: ":8080",
GracefulTimeout: 10 * time.Second,
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
sc.Start(ctx, e) // ctx がキャンセルされたら自動でGraceful Shutdown
goroutine もシグナル用チャネルも不要になり、設定の意図がコードから読み取りやすくなります。
// ============================================================
// 特徴4【v4】: Graceful Shutdown
//
// v4 でも Graceful Shutdown は実現できる。
// ただし「http.Server の組み立て」「goroutine での起動」
// 「シグナル用チャネル」「手動での Shutdown 呼び出し」を
// すべて自前で書く必要がある。
//
// 【動作確認手順】
// ターミナル1: go run .
// ターミナル2: curl http://localhost:8809/slow ← 5秒かかるリクエスト
// ターミナル1: すぐ Ctrl+C を押す
// → "処理完了" が返ってくることを確認(即死しない)
// ============================================================
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.HideBanner = true
// 5秒かかる重い処理を模したエンドポイント
e.GET("/slow", func(c echo.Context) error {
fmt.Println("処理開始...")
time.Sleep(5 * time.Second) // 重い処理のシミュレーション
fmt.Println("処理完了!")
return c.JSON(http.StatusOK, map[string]string{
"message": "処理完了",
"version": "v4",
})
})
// タイムアウト設定には http.Server を自前で組み立てる
s := &http.Server{
Addr: ":8809",
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
Handler: e,
}
// サーバーを goroutine で起動(でないとシグナル待ちができない)
go func() {
fmt.Println("サーバー起動 :8809")
if err := s.ListenAndServe(); err != http.ErrServerClosed {
e.Logger.Fatal(err)
}
}()
// シグナル待ち受け用チャネルを自前で用意
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit // Ctrl+C が押されるまでここでブロック
fmt.Println("\nシャットダウン開始(処理中のリクエストを待ちます)...")
// タイムアウト付きで Shutdown を呼ぶ(自前)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil {
e.Logger.Fatal(err)
}
fmt.Println("サーバーを正常に終了しました")
}
// ============================================================
// 特徴4【v5】: Graceful Shutdown with StartConfig
//
// v5 では StartConfig 構造体に設定をまとめるだけ。
// 「http.Server の組み立て」「goroutine での起動」
// 「シグナル用チャネル」がすべて不要になる。
//
// 【動作確認手順】
// ターミナル1: go run .
// ターミナル2: curl http://localhost:8810/slow ← 5秒かかるリクエスト
// ターミナル1: すぐ Ctrl+C を押す
// → "処理完了" が返ってくることを確認(即死しない)
// ============================================================
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/labstack/echo/v5"
)
func main() {
e := echo.New()
// 5秒かかる重い処理を模したエンドポイント
e.GET("/slow", func(c *echo.Context) error {
slog.Info("処理開始...")
time.Sleep(5 * time.Second) // 重い処理のシミュレーション
slog.Info("処理完了!")
return c.JSON(http.StatusOK, map[string]string{
"message": "処理完了",
"version": "v5",
})
})
// StartConfig に設定をまとめて宣言するだけ
sc := echo.StartConfig{
Address: ":8810",
GracefulTimeout: 10 * time.Second, // シャットダウン待機タイムアウト
BeforeServeFunc: func(s *http.Server) error {
s.ReadTimeout = 10 * time.Second
s.WriteTimeout = 15 * time.Second // /slow が5秒かかるので余裕を持たせる
return nil
},
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// ctx がキャンセルされた(= Ctrl+C が押された)タイミングをログに出す
go func() {
<-ctx.Done()
slog.Info("Ctrl+C を受信。シャットダウン開始(処理中リクエストを待ちます)...")
}()
if err := sc.Start(ctx, e); err != nil {
slog.Error("サーバーエラー", "error", err)
}
slog.Info("サーバーを正常に終了しました")
}

さいごに
echo v5がリリースされたので、ぜひみなさんversionアップして使ってみて下さい!!
Discussion