Chuyển đến nội dung chính
友田 陽大
Go & Echo in production
Go
Echo
型安全
セキュリティ
バリデーション
アーキテクチャ設計

Echo のリクエストバインディング&バリデーション&エラー設計:型安全な境界で不正入力を殺す

Go Echo(v5)の入力処理を本番品質にする実装ガイド。公式ドキュメントに忠実に、c.Bind と構造体タグ、単一ソースバインダ、ジェネリック型安全バインダ、go-playground/validator 連携、DTO 分離、カスタム Binder、HTTPError と集中エラーハンドラ、Problem Details 風の 422 整形までを実コードで解説します。

Published
Reading time
10 min read
Author
友田 陽大
Chia sẻ

API の堅牢性は、境界で不正入力をどれだけ殺せるかでほぼ決まります。"abc" を ID として受けて DB に投げる、必須項目が空のまま登録する、IsAdmin: true を勝手に渡される——これらは「あとでチェックする」のでは遅く、型に変換できた時点で正しい状態を作るのが本番の作法です。

この記事は、Go Echo 本番運用ガイドの入力処理・エラー設計編です。Echo v5 の バインディング → バリデーション → エラー整形 を、「外部入力は信用しない」というセキュリティ原則に沿って実装します。

この記事のルール:API は Echo 公式ドキュメント(v5・2026年6月時点) に基づきます。v5 はバインダの署名(Bind(c, target))が反転し、ジェネリックな型安全抽出(PathParam[T])が入り、HTTPError.Messagestring 化、エラーハンドラ署名が func(c *echo.Context, err error) に反転するなど、v4 から差分が大きい領域です。本番投入前に必ず公式で最新を確認してください。


1. c.Bind:4つのソースを構造体タグで束ねる

c.Bind(&dto) は、1回の呼び出しで**パスパラメータ・クエリ・リクエストボディ(JSON/XML/フォーム)**を、構造体タグに従って流し込みます。

type SearchRequest struct {
	Keyword  string   `query:"q"`
	Tags     []string `query:"tag"`  // 繰り返し ?tag=go&tag=web → ["go","web"]
	Page     int      `query:"page"`
	Category string   `param:"category"` // パスパラメータ /search/:category
}

func search(c *echo.Context) error {
	var req SearchRequest
	if err := c.Bind(&req); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
	}
	// req は型変換済み(page は int、tags は []string)
	return c.JSON(http.StatusOK, req)
}
タグソース
jsonリクエストボディ(JSON)
xmlリクエストボディ(XML)
formフォームデータ
queryクエリパラメータ
paramパスパラメータ
headerヘッダ(c.Bind の対象外。後述)

重要な落とし穴ヘッダは c.Bind では束ねられませんAuthorization や独自ヘッダを構造体に取り込みたいときは、echo.BindHeaders(c, &dto) を明示的に呼びます。「Bind したのにヘッダが空」はこれが原因の筆頭です。

1.1 単一ソースだけをバインドする

「ボディだけ」「クエリだけ」を厳密にバインドしたいときは、ソース別の関数を使います。意図しないソースからの流入を防げます。

echo.BindBody(c, &payload)        // ボディのみ
echo.BindQueryParams(c, &payload) // クエリのみ
echo.BindPathValues(c, &payload)  // パスパラメータのみ
echo.BindHeaders(c, &payload)     // ヘッダのみ

2. mass assignment を防ぐ:DTO をビジネス構造体と分ける

ここがセキュリティの肝です。DB エンティティやドメインモデルに直接 Bind してはいけません

// ❌ 危険:DB エンティティに直接 Bind
type User struct {
	ID       int64  `json:"id"`
	Email    string `json:"email"`
	IsAdmin  bool   `json:"is_admin"` // 攻撃者が {"is_admin": true} を送れば昇格できる
}

func badCreate(c *echo.Context) error {
	var u User
	_ = c.Bind(&u) // IsAdmin まで外部から上書きされる(mass assignment 脆弱性)
	// ...
}
// ✅ 安全:受け口専用の DTO を定義し、許可したフィールドだけを受ける
type CreateUserDTO struct {
	Name     string `json:"name"     validate:"required,min=2,max=50"`
	Email    string `json:"email"    validate:"required,email"`
	Password string `json:"password" validate:"required,min=12"`
	// IsAdmin は DTO に存在しない=外部から絶対にセットできない
}

func (h *UserHandler) Create(c *echo.Context) error {
	var dto CreateUserDTO
	if err := c.Bind(&dto); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
	}
	if err := c.Validate(&dto); err != nil {
		return err
	}
	// DTO → ドメインへ「明示的に」詰め替える。権限は常にサーバーが決める
	user, err := h.uc.Register(c.Request().Context(), dto.Name, dto.Email, dto.Password)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, toUserResponse(user)) // 出力も専用 DTO
}

公式も「ビジネス構造体に直接バインドせず、専用 DTO を使って明示的にマッピングし、意図しないフィールド露出を防げ」と明記しています。入力 DTO と出力 DTO を分けPassword のような機微フィールドを出力に絶対載せないのも同じ原則です。


3. ジェネリック型安全バインダ:単一の値を型付きで取る

ID やページ番号のような単一の値は、構造体を切るまでもなく型安全に取りたいものです。v5 はジェネリクスを使った型安全抽出を提供します。変換できなければエラーになるので、"abc"int64 ID として受ける事故を構造的に防げます。

// パスパラメータ /users/:id を int64 として取得
id, err := echo.PathParam[int64](c, "id")
if err != nil {
	return echo.NewHTTPError(http.StatusBadRequest, "id must be an integer")
}

// クエリ ?active=true を bool として取得
active, err := echo.QueryParam[bool](c, "active")

3.1 Fluent バインダ:複数の値をまとめて変換しエラーを集約

複数のクエリ/パラメータを一括で型変換し、エラーをまとめて拾うときは Fluent(流れるような)バインダが便利です。

var opts struct {
	IDs    []int64
	Active bool
	Limit  int64
}

err := echo.QueryParamsBinder(c).
	Int64s("id", &opts.IDs).            // ?id=1&id=2 → []int64{1,2}
	Bool("active", &opts.Active).
	Int64("limit", &opts.Limit).
	BindError()                          // 最初のエラー(または集約)を返す
if err != nil {
	return echo.NewHTTPError(http.StatusBadRequest, "invalid query parameters")
}

PathValuesBinder(c) / FormFieldBinder(c) も同系統です。各メソッドには Int64(任意)/MustInt64(必須・無ければエラー)/Int64s(複数)といった派生があり、必須・任意・複数値を型で表現できます。


4. バリデーション:go-playground/validator を被せる

Echo はバリデーションライブラリを同梱しません。代わりに echo.Validator インターフェースという差し込み口を用意しています。デファクトの github.com/go-playground/validator/v10 を被せるのが定番です。

import "github.com/go-playground/validator/v10"

type CustomValidator struct {
	validator *validator.Validate
}

func (cv *CustomValidator) Validate(i any) error {
	if err := cv.validator.Struct(i); err != nil {
		return err // ここでは生エラーを返し、整形は集中エラーハンドラに任せる
	}
	return nil
}

func main() {
	e := echo.New()
	e.Validator = &CustomValidator{validator: validator.New()}
	// ...
}

validate:"..." タグでルールを宣言します。よく使うものを挙げます。

タグ意味
required必須(ゼロ値を拒否)
emailメール形式
min=2,max=50文字列長/数値範囲
gte=0,lte=100以上/以下
oneof=draft published列挙のいずれか
uuid4UUID v4 形式
diveスライス/マップの各要素に適用

4.1 バリデーションエラーを「フィールド単位の 422」に整形する

{"message":"Key: 'CreateUserDTO.Email' Error:Field validation..."} という生のエラーをそのまま返すのは、API として不親切で情報も漏れます。本番ではどのフィールドがなぜ落ちたかを構造化して 422 Unprocessable Entity で返します。

type FieldError struct {
	Field   string `json:"field"`
	Rule    string `json:"rule"`
	Message string `json:"message"`
}

// validator のエラーをフィールド単位に変換する
func toFieldErrors(err error) []FieldError {
	var ve validator.ValidationErrors
	if !errors.As(err, &ve) {
		return nil
	}
	out := make([]FieldError, 0, len(ve))
	for _, fe := range ve {
		out = append(out, FieldError{
			Field:   fe.Field(),
			Rule:    fe.Tag(),
			Message: humanize(fe), // "email は正しい形式で入力してください" 等
		})
	}
	return out
}

この変換は、次章の集中エラーハンドラで一度だけ行います。各ハンドラに整形ロジックを散らさないのが DRY の要点です。


5. エラーハンドリング:握りつぶさず return し、一箇所で整形する

Echo のエラー処理は中央集権です。ハンドラ/ミドルウェアは errorreturn するだけで、変換は1つの HTTPErrorHandler が担います。

// ステータス付きエラー(v5 はメッセージが string・非可変長)
return echo.NewHTTPError(http.StatusNotFound, "user not found")

// メッセージ省略時はステータステキスト
return echo.NewHTTPError(http.StatusBadGateway)

// 定義済みセンチネルエラー
return echo.ErrUnauthorized // 401
return echo.ErrForbidden    // 403

5.1 本番向け集中エラーハンドラ

要件は4つ。(1) バリデーションエラーは 422 にフィールド単位で整形、(2) *echo.HTTPError はそのステータス、(3) それ以外(想定外)は 500 で詳細を隠す(4) 5xx は相関 ID 付きでログへ。v5 の署名は func(c *echo.Context, err error)(v4 から引数順が反転)です。

type ErrorResponse struct {
	Error  string       `json:"error"`
	Fields []FieldError `json:"fields,omitempty"`
}

func customHTTPErrorHandler(c *echo.Context, err error) {
	// すでに送信済みなら二重送信しない
	if resp, uErr := echo.UnwrapResponse(c.Response()); uErr == nil && resp.Committed {
		return
	}

	// ① バリデーションエラー → 422(フィールド単位)
	var ve validator.ValidationErrors
	if errors.As(err, &ve) {
		_ = c.JSON(http.StatusUnprocessableEntity, ErrorResponse{
			Error:  "validation failed",
			Fields: toFieldErrors(ve),
		})
		return
	}

	// ② ステータス解決(HTTPError は HTTPStatusCoder を実装)
	code := http.StatusInternalServerError
	msg := "internal server error"
	var sc echo.HTTPStatusCoder
	if errors.As(err, &sc) {
		if s := sc.StatusCode(); s != 0 {
			code = s
		}
	}
	var he *echo.HTTPError
	if errors.As(err, &he) {
		msg = he.Message // v5 は string
	}

	// ③ 5xx は詳細を隠し、相関 ID 付きでログへ
	if code >= 500 {
		c.Logger().Error("unhandled error",
			"error", err.Error(),
			"path", c.Request().URL.Path,
			"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
		)
		msg = "internal server error" // クライアントには内部詳細を出さない
	}

	_ = c.JSON(code, ErrorResponse{Error: msg})
}

func main() {
	e := echo.New()
	e.HTTPErrorHandler = customHTTPErrorHandler
	// ...
}

これで、各ハンドラはドメインのエラーを return するだけになり、HTTP の体裁・ログ・機微情報の遮断が一箇所に集約されます。公式も「エラーを Sentry / Elasticsearch / Splunk 等へ転送するなら、この集中ハンドラから」と推奨しています。

5.2 ドメインエラーを HTTP に対応づける

UseCase 層は HTTP を知るべきではありません。ドメイン固有のセンチネルエラーを定義し、集中ハンドラ(または薄い変換層)で HTTP ステータスに対応づけます。

// domain 層:HTTP を知らない
var (
	ErrUserNotFound = errors.New("user not found")
	ErrEmailTaken   = errors.New("email already taken")
)

// エラーハンドラ側でマッピング(抜粋)
switch {
case errors.Is(err, ErrUserNotFound):
	code, msg = http.StatusNotFound, "user not found"
case errors.Is(err, ErrEmailTaken):
	code, msg = http.StatusConflict, "email already taken"
}

この分離により、ビジネスロジックは HTTP から独立してテスト可能なまま保てます(クリーンアーキテクチャ + DI)。クライアント⇄サーバー間でエラー形式まで型で合わせたい場合は、OpenAPI + Problem Details(RFC 9457)でスキーマ化する設計が有効です(Next.js × Go × OpenAPI)。


6. カスタム Binder:独自フォーマットを受ける

Protocol Buffers や独自の Content-Type、あるいは「JSON の数値を必ず厳格に解釈する」といった要件では、echo.Binder を自作して差し替えます。

type StrictBinder struct {
	fallback echo.DefaultBinder
}

func (b *StrictBinder) Bind(c *echo.Context, i any) error { // v5 は (c, target) の順
	if c.Request().Header.Get(echo.HeaderContentType) == "application/vnd.myapp+json" {
		// 独自フォーマットの処理
		return decodeStrict(c.Request().Body, i)
	}
	return b.fallback.Bind(c, i) // それ以外は標準バインダに委譲
}

func main() {
	e := echo.New()
	e.Binder = &StrictBinder{}
}

Bind の署名が Bind(c *echo.Context, i any)(v5 で引数順が反転)である点に注意してください。標準挙動を活かしたいケースが大半なので、DefaultBinder委譲する形が安全です。


まとめ:境界設計の7原則

  1. c.Bind はパス・クエリ・ボディを束ねるヘッダは対象外——BindHeaders を明示。
  2. ビジネス構造体に直接 Bind しない。受け口専用 DTO で mass assignment を防ぐ。
  3. 入力 DTO と出力 DTO を分ける。機微フィールドを出力に載せない。
  4. 単一値は型安全に——PathParam[T] / Fluent バインダで変換失敗を 400 に。
  5. 検証は go-playground/validatorecho.Validator に被せ、422 にフィールド単位で整形。
  6. エラーは握りつぶさず return。集中ハンドラで整形・ログ・機微遮断を一箇所に集約。
  7. ドメインエラーは HTTP から独立させ、変換層でステータスに対応づける。

「動く API」と「本番で信用できる API」を分けるのは、まさにこの境界処理です。入力を型で殺し、エラーを一箇所で整形する——この型を持てば、Echo の API は格段に堅くなります。手前のミドルウェア設計と合わせ、全体像はGo Echo 本番運用ガイドに戻って俯瞰してください。

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc