iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Modern Web

Golang x Echo 30 天:零基礎GO , 後端入門系列 第 25

以 Go + Echo 打造部落格|第 9 集:後端餐廳的出包 SOP

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20251009/20178818G5d6zaCLOX.jpg

為什麼要做這些?
想像你的後端是一間餐廳:

  • Recovery:廚房失火(panic)也不會整家店關門,會立刻滅火、顧客收到「抱歉我們 500 了」的通知。

  • Request ID:每張餐點單都有編號。客人抱怨「牛肉變成空氣」,你只要看單號就能查發生什麼事。

  • 結構化日誌(JSON):不是一大坨文字,而是「有欄位」的資料:路徑、狀態碼、時間…像 Excel 一樣好搜。

  • API/網頁都用同一套錯誤格式,前端不必猜。

  • CORS 白名單:只讓你家的前端域名點餐,陌生網域別亂來。

小結:有了這五件事,你的服務就更像「可維運的產品」,不是只在你筆電上能跑的作品。😎


  1. 專案變更樹:這次要動刀的地方(一次看懂要加什麼)

(Git 記得先開個新分支,畢竟這次動刀蠻大的: git switch -c feat/mw-errors)

這次的變動,主要在 middleware(中介層)、responder(回覆者) 和 errors(錯誤) 幾個新夥伴:

go-echo-blog/
├─ cmd/server/main.go                      # 掛中介層、CORS 白名單、自訂錯誤處理
├─ internal/http/
│  ├─ middleware/
│  │  ├─ reqid.go                          # Request ID
│  │  └─ logging.go                        # 結構化日誌(slog)
│  ├─ responder/responder.go               # 統一成功/錯誤 JSON
│  ├─ errors/errors.go                     # 業務錯誤型別(AppError)
│  └─ http_error_handler.go                # 自訂 HTTPErrorHandler(HTML/JSON)
├─ .env.example                            # 新增 CORS_ORIGINS
└─ ...

  1. .env.example:熟客名單(CORS 白名單)

我們只讓這些網址的前端來點餐,安全又放心!

# 允許的前端網域(開發+正式)
CORS_ORIGINS=http://localhost:5173,https://your-frontend.example.com

  1. internal/http/middleware/reqid.go:服務生請給單號!

這是給每個進來的請求一個 獨一無二的「單號」。

如果客人自己有帶單號(X-Request-ID Header),就用他的。

沒有?沒關係,我們自己用 uuid.NewString() 隨機生成一組。

這個單號會被塞進 Context 裡,讓後面所有的程式碼(例如日誌)都能取用。
package middleware

import (
	"context"

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

type ctxKey string
const RequestIDKey ctxKey = "request_id"

// 為每個請求產生 UUID,放進 Context 與回應 Header,日誌也會用到
func WithRequestID(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		id := c.Request().Header.Get("X-Request-ID")
		if id == "" {
			id = uuid.NewString()
		}
		req := c.Request().WithContext(context.WithValue(c.Request().Context(), RequestIDKey, id))
		c.SetRequest(req)
		c.Response().Header().Set("X-Request-ID", id)
		return next(c)
	}
}

// 其他地方要取 ID(例如 logger)就用這個
func FromContext(ctx context.Context) string {
	if v, ok := ctx.Value(RequestIDKey).(string); ok {
		return v
	}
	return ""
}


  1. internal/http/middleware/logging.go:收銀機開始用 JSON 記帳

有了 Request ID,我們的日誌(Log)就不再是雜亂無章的,而是結構化的!

我們使用 Go 內建的 log/slog,把它變成 JSON 格式,把 Request ID、路徑、狀態碼、花了多少時間...通通記下來。

梗:下次程式爆了,你只要拿著 Request ID 去日誌裡一搜,就像拿著單號去翻收銀機帳本,三秒鐘就知道哪個環節出了大問題!
package middleware

import (
	"log/slog"
	"time"

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

// 將每個請求以 JSON 欄位輸出(method/path/status/duration/request_id/...)
func WithSlog(logger *slog.Logger) echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			start := time.Now()
			err := next(c) // 先讓後面跑
			status := c.Response().Status
			req := c.Request()
			id := FromContext(req.Context())

			logger.Info("http_request",
				slog.String("request_id", id),
				slog.String("method", req.Method),
				slog.String("path", req.URL.Path),
				slog.Int("status", status),
				slog.String("remote_ip", c.RealIP()),
				slog.String("user_agent", req.UserAgent()),
				slog.Duration("duration", time.Since(start)),
			)
			return err
		}
	}
}


  1. internal/http/responder/responder.go:訓練服務生統一說話!

後端回覆格式百百種?不行!我們要求服務生(responder)統一規格。

成功(JSONOK):一定有 request_id 和 data。

錯誤(JSONError):一定有 request_id、code(錯誤碼)、message(人看的訊息)、details(額外的詳細資料)。
package responder

import (
	"net/http"

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

type ErrorPayload struct {
	RequestID string      `json:"request_id"`
	Code      string      `json:"code"`            // 例如 POST_SLUG_CONFLICT
	Message   string      `json:"message"`
	Details   interface{} `json:"details,omitempty"`
}

type OKPayload struct {
	RequestID string      `json:"request_id"`
	Data      interface{} `json:"data"`
}

func JSONOK(c echo.Context, data interface{}) error {
	id := c.Response().Header().Get("X-Request-ID")
	return c.JSON(http.StatusOK, OKPayload{RequestID: id, Data: data})
}

func JSONError(c echo.Context, status int, code, message string, details any) error {
	id := c.Response().Header().Get("X-Request-ID")
	return c.JSON(status, ErrorPayload{
		RequestID: id,
		Code:      code,
		Message:   message,
		Details:   details,
	})
}


  1. internal/http/errors/errors.go:定義我們餐廳的「業務錯誤」

一般的錯誤是系統錯誤,但 「業務錯誤」 是指「你點的菜跟我的規定不符」。

例如:「文章 Slug 已經被用過」、「帳號密碼不正確」。

我們自己定義一個 AppError 型別,它可以帶:

Status: HTTP 狀態碼 (400, 404...)

Code: 獨特的錯誤碼(例如:POST_SLUG_CONFLICT)

Message: 錯誤訊息(中文或英文都可)

Details: 更多資訊(例如哪個 Slug 衝突了)

這樣在 Handler 裡丟出錯誤時,邏輯就超清晰!

package errors

import "net/http"

// 可預期的業務錯誤:會由 HTTPErrorHandler 長相統一地回給前端
type AppError struct {
	Status  int
	Code    string
	Message string
	Details any
}

func (e *AppError) Error() string { return e.Message }

func New(status int, code, message string, details any) *AppError {
	return &AppError{Status: status, Code: code, Message: message, Details: details}
}

func NotFound(code, msg string, details any) *AppError  { return New(http.StatusNotFound,  code, msg, details) }
func BadRequest(code, msg string, details any) *AppError { return New(http.StatusBadRequest, code, msg, details) }
func Conflict(code, msg string, details any) *AppError   { return New(http.StatusConflict,  code, msg, details) }


  1. internal/http/http_error_handler.go:強大的店經理!

這是最關鍵的一步!我們取代了 Echo 預設的錯誤處理,讓 ErrorHandler 成為強大的 「店經理」:

判斷錯誤型別:他會先檢查丟出來的錯誤,是不是我們自定義的 *AppError,還是 Echo 內建的 *echo.HTTPError。

判斷客人需求:經理會看客人(瀏覽器)的 Header,判斷他是要 JSON 格式(wantsJSON)還是要 HTML 網頁。

分流處理:

    如果是 *AppError:就用 responder.JSONError 回 JSON,或用 c.Render 渲染錯誤網頁(例如 404.html)。

    如果是 *echo.HTTPError:一樣分流回覆。

    如果是 「未知的錯誤」(unhandled_error):通常是程式碼爆炸或資料庫連線斷了,經理會趕快 發出日誌警報(h.Logger.Error),然後回給客人一個友善的 500 錯誤:「系統忙線中,拜託再試一次」。
package httpx

import (
	"errors"
	"html/template"
	"log/slog"
	"net/http"
	"strings"

	appErr "your/module/internal/http/errors"
	"your/module/internal/http/responder"

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

type ErrorHandler struct {
	Logger   *slog.Logger
	Renderer echo.Renderer // 讓 HTML 404/500 可以用你的模板
}

func (h *ErrorHandler) Handle(err error, c echo.Context) {
	if c.Response().Committed {
		return // 已經回應過就不動
	}

	// 1) 我們自己定義的「業務錯誤」
	var ae *appErr.AppError
	if errors.As(err, &ae) {
		if wantsJSON(c) {
			_ = responder.JSONError(c, ae.Status, ae.Code, ae.Message, ae.Details)
			return
		}
		_ = c.Render(ae.Status, pickErrorPage(ae.Status), map[string]any{
			"title":   "Error",
			"message": ae.Message,
			"code":    ae.Code,
		})
		return
	}

	// 2) Echo 內建 HTTPError
	var he *echo.HTTPError
	if errors.As(err, &he) {
		msg := "something went wrong"
		if v, ok := he.Message.(string); ok {
			msg = v
		}
		if wantsJSON(c) {
			_ = responder.JSONError(c, he.Code, "HTTP_ERROR", msg, he.Internal)
			return
		}
		_ = c.Render(he.Code, pickErrorPage(he.Code), map[string]any{
			"title":   "Error",
			"message": template.HTMLEscapeString(msg),
			"code":    "HTTP_ERROR",
		})
		return
	}

	// 3) 其他未知錯誤 → 記錄並回 500
	h.Logger.Error("unhandled_error", slog.String("err", err.Error()))
	if wantsJSON(c) {
		_ = responder.JSONError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "系統忙線中,拜託再試一次", nil)
		return
	}
	_ = c.Render(http.StatusInternalServerError, pickErrorPage(http.StatusInternalServerError), map[string]any{
		"title":   "Error",
		"message": "系統忙線中,拜託再試一次",
		"code":    "INTERNAL_ERROR",
	})
}

// 判斷要回 JSON 還是 HTML(Accept/Content-Type 或路徑以 /api/ 開頭)
func wantsJSON(c echo.Context) bool {
	accept := c.Request().Header.Get("Accept")
	ct := c.Request().Header.Get("Content-Type")
	return strings.Contains(accept, echo.MIMEApplicationJSON) ||
		strings.Contains(ct, echo.MIMEApplicationJSON) ||
		strings.HasPrefix(c.Path(), "/api/")
}

func pickErrorPage(status int) string {
	switch status {
	case http.StatusNotFound:
		return "pages/404.html"
	default:
		return "pages/error.html"
	}
}

店經理名言:「絕不讓客人看到程式碼堆棧!所有未知的錯誤都要被記錄,並回覆統一的 500 訊息。」


  1. cmd/server/main.go:總控台:裝上所有裝備!

在 main.go 裡,我們把所有新功能「開機啟動」!

package main

import (
	"log/slog"
	"net/http"
	"os"
	"strings"

	httpx "your/module/internal/http"
	appmw "your/module/internal/http/middleware"

	"github.com/gorilla/sessions"
	"github.com/labstack/echo-contrib/session"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()

	// 1) Recovery:避免 panic 把服務炸掉
	e.Use(middleware.Recover())

	// 2) JSON 結構化日誌(slog)+ Request ID
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
	e.Use(appmw.WithRequestID)
	e.Use(appmw.WithSlog(logger))

	// 3) CORS 白名單:從環境變數讀
	var origins []string
	if env := os.Getenv("CORS_ORIGINS"); env != "" {
		for _, s := range strings.Split(env, ",") {
			origins = append(origins, strings.TrimSpace(s))
		}
	}
	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: origins,
		AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions},
		AllowHeaders: []string{"Content-Type", "Authorization", "X-CSRF-Token"},
	}))

	// 4) Session(沿用第 7 篇)
	e.Use(session.Middleware(sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))))

	// 5) 自訂錯誤處理:統一 HTML/JSON 回應
	eh := &httpx.ErrorHandler{Logger: logger, Renderer: e.Renderer}
	e.HTTPErrorHandler = eh.Handle

	// 健檢
	e.GET("/health", func(c echo.Context) error { return c.String(http.StatusOK, "ok") })
	e.GET("/_ping", func(c echo.Context) error { return c.String(http.StatusOK, "pong") })

	e.Logger.Fatal(e.Start(":1323"))
}


  1. Handler 丟錯示範:現在丟錯超簡單!

從此以後,在你的 Handler 函式裡,丟出錯誤就像丟垃圾一樣簡單又乾淨!

(A)丟出業務錯誤(自定義 AppError)

當你發現資料不符合業務邏輯時,直接回傳 appErr.Conflict 就好!

import (
	appErr "your/module/internal/http/errors"
	"net/http"
	"github.com/labstack/echo/v4"
)

type PostsHandler struct{}

func (h *PostsHandler) Create(c echo.Context) error {
	// 假設 slug 重複
	return appErr.Conflict("POST_SLUG_CONFLICT", "slug 已被使用,換一個比較吉利", map[string]any{
		"slug": c.FormValue("slug"),
	})
}

(B)丟出標準 HTTP 錯誤

如果只是單純的「資料格式不對」這種標準錯誤,也可以繼續用 Echo 內建的:

return echo.NewHTTPError(http.StatusBadRequest, "資料格式不正確")

太棒了!這篇關於錯誤處理與中介層的硬核文章,我來幫你把它改成一篇超接地氣、國中生也能秒懂、充滿梗的 iThome 鐵人賽部落格文!

我們就用「後端餐廳」的比喻,把技術名詞變成大家都能會心一笑的日常用語。

第 9 篇|阿娘喂!後端餐廳的出包 SOP 啦!

主題:廚房失火?馬上救!帳單編號?一定有!客人抱怨?查得到!
目標:讓你的後端不再「土法煉鋼」(台語:Ló͘-hoat-liān-kǹg,意指老舊、粗糙的方法),出事時能看得懂、查得到、回得一致,成為一家五星級「不、會、爆」的餐廳!
發文日:2006-01-02(對,我就愛復古風!)

  1. 餐廳出包,工程師在衝三小?(2 分鐘人話版)

想像一下,你的後端系統就像一家 24 小時不打烊的「網際網路餐廳」:
技術名詞 餐廳情境(人話) 達成的目標
Recovery 廚房失火(Panic)立刻滅火 服務不會因為小錯誤整個當掉,客人只會收到「抱歉 500 了」的通知,而不是「服務器停止運作」的黑畫面。
Request ID 每張餐點單都有編號 客人說「我的牛肉麵怪怪的」,你說「請給我單號!」。從此「查水表」又快又準。
結構化日誌(JSON) 收銀機帳本變成 Excel 表格 記錄不是一坨亂七八糟的文字,而是有欄位、可以篩選的資料:路徑、狀態、時間... 像 Excel 一樣好搜、好分析。
統一錯誤回應 不管 API 還是網頁,出錯訊息都長一樣 前端工程師不必再「靠感覺」猜錯在哪,出包格式固定,世界和平!
CORS 白名單 只准熟客訂位,陌生人請滾! 只讓你家(設定好)的前端網域來點餐,防止奇怪的網站亂竄來要資料。

  1. 專案變更樹:這次要動刀的地方(一次看懂要加什麼)

(Git 記得先開個新分支,畢竟這次動刀蠻大的: git switch -c feat/mw-errors)

這次的變動,主要在 middleware(中介層)、responder(回覆者) 和 errors(錯誤) 幾個新夥伴:

go-echo-blog/
├─ cmd/server/main.go # 總控台!掛中介層、CORS、自訂錯誤處理
├─ internal/http/
│ ├─ middleware/
│ │ ├─ reqid.go # 報單號!Request ID
│ │ └─ logging.go # 記帳本!結構化日誌 (slog)
│ ├─ responder/responder.go # 服務生!統一成功/錯誤 JSON 回應
│ ├─ errors/errors.go # 菜單!定義我們餐廳會出的「業務錯誤」
│ └─ http_error_handler.go # 店經理!自訂 HTTPErrorHandler(處理 HTML/JSON 兩種客人)
├─ .env.example # 新增 CORS_ORIGINS,指定我們的「熟客名單」
└─ ...

  1. .env.example:熟客名單(CORS 白名單)

我們只讓這些網址的前端來點餐,安全又放心!

CORS_ORIGINS=http://localhost:5173,https://your-frontend.example.com

  1. internal/http/middleware/reqid.go:服務生請給單號!

這是給每個進來的請求一個 獨一無二的「單號」。

如果客人自己有帶單號(X-Request-ID Header),就用他的。

沒有?沒關係,我們自己用 uuid.NewString() 隨機生成一組。

這個單號會被塞進 Context 裡,讓後面所有的程式碼(例如日誌)都能取用。

(程式碼跟原文一樣,重點在理解:「 Request ID,追蹤錯誤的命脈!」)

  1. internal/http/middleware/logging.go:收銀機開始用 JSON 記帳

有了 Request ID,我們的日誌(Log)就不再是雜亂無章的,而是結構化的!

我們使用 Go 內建的 log/slog,把它變成 JSON 格式,把 Request ID、路徑、狀態碼、花了多少時間...通通記下來。

梗:下次程式爆了,你只要拿著 Request ID 去日誌裡一搜,就像拿著單號去翻收銀機帳本,三秒鐘就知道哪個環節出了大問題!

(程式碼跟原文一樣,重點是把 Request ID 塞進 slog.String("request_id", id))

  1. internal/http/responder/responder.go:訓練服務生統一說話!

後端回覆格式百百種?不行!我們要求服務生(responder)統一規格。

成功(JSONOK):一定有 request_id 和 data。

錯誤(JSONError):一定有 request_id、code(錯誤碼)、message(人看的訊息)、details(額外的詳細資料)。

Go

// 錯誤格式,出包時長這樣,前端一看就懂!
type ErrorPayload struct {
RequestID string json:"request_id" // 單號
Code string json:"code" // 錯誤代碼,給程式看的
Message string json:"message" // 錯誤訊息,給人看的
Details interface{} json:"details,omitempty" // 其他資料
}
// ... 還有成功格式 OKPayload

  1. internal/http/errors/errors.go:定義我們餐廳的「業務錯誤」

一般的錯誤是系統錯誤,但 「業務錯誤」 是指「你點的菜跟我的規定不符」。

例如:「文章 Slug 已經被用過」、「帳號密碼不正確」。

我們自己定義一個 AppError 型別,它可以帶:

Status: HTTP 狀態碼 (400, 404...)

Code: 獨特的錯誤碼(例如:POST_SLUG_CONFLICT)

Message: 錯誤訊息(中文或英文都可)

Details: 更多資訊(例如哪個 Slug 衝突了)

這樣在 Handler 裡丟出錯誤時,邏輯就超清晰!
Go

// 這是我們自定義的「業務錯誤」結構
type AppError struct {
Status int
Code string
Message string
Details any
}

func NotFound(code, msg string, details any) *AppError {
return New(http.StatusNotFound, code, msg, details)
}
// ... 還有 BadRequest, Conflict 等等方便的建構函式

  1. internal/http/http_error_handler.go:強大的店經理!

這是最關鍵的一步!我們取代了 Echo 預設的錯誤處理,讓 ErrorHandler 成為強大的 「店經理」:

判斷錯誤型別:他會先檢查丟出來的錯誤,是不是我們自定義的 *AppError,還是 Echo 內建的 *echo.HTTPError。

判斷客人需求:經理會看客人(瀏覽器)的 Header,判斷他是要 JSON 格式(wantsJSON)還是要 HTML 網頁。

分流處理:

    如果是 *AppError:就用 responder.JSONError 回 JSON,或用 c.Render 渲染錯誤網頁(例如 404.html)。

    如果是 *echo.HTTPError:一樣分流回覆。

    如果是 「未知的錯誤」(unhandled_error):通常是程式碼爆炸或資料庫連線斷了,經理會趕快 發出日誌警報(h.Logger.Error),然後回給客人一個友善的 500 錯誤:「系統忙線中,拜託再試一次」。

店經理箴言:「絕不讓客人看到程式碼堆棧!所有未知的錯誤都要被記錄,並回覆統一的 500 訊息。」
  1. cmd/server/main.go:總控台:裝上所有裝備!

在 main.go 裡,我們把所有新功能「開機啟動」!
Go

func main() {
e := echo.New()

// 1. Recovery:廚房失火?自動滅火!
e.Use(middleware.Recover())

// 2. Request ID & 結構化日誌
logger := slog.New(slog.NewJSONHandler(os.Stdout, ...))
e.Use(appmw.WithRequestID)
e.Use(appmw.WithSlog(logger))

// 3. CORS 白名單:只讓熟客點餐
// ... 讀取 CORS_ORIGINS 環境變數並設定 CORS 中介層

// 4. 掛上店經理:接管所有錯誤!
eh := &httpx.ErrorHandler{Logger: logger, Renderer: e.Renderer}
e.HTTPErrorHandler = eh.Handle // 把 Echo 預設的換成我們自訂的 Handle 函式!

// ... 啟動伺服器

}

  1. Handler 丟錯示範:現在丟錯超簡單!

從此以後,在你的 Handler 函式裡,丟出錯誤就像丟垃圾一樣簡單又乾淨!

(A)丟出業務錯誤(自定義 AppError)

當你發現資料不符合業務邏輯時,直接回傳 appErr.Conflict 就好!
Go

func (h *PostsHandler) Create(c echo.Context) error {
// 發現 Slug 已經被用過了!
return appErr.Conflict("POST_SLUG_CONFLICT", "slug 已被使用,換一個比較吉利",
map[string]any{
"slug": c.FormValue("slug"), // 把衝突的 slug 也回傳給前端
})
}
// 店經理 (ErrorHandler) 會接手這個 AppError,並回覆 409 Conflict 的 JSON 錯誤格式。

(B)丟出標準 HTTP 錯誤

如果只是單純的「資料格式不對」這種標準錯誤,也可以繼續用 Echo 內建的:
Go

return echo.NewHTTPError(http.StatusBadRequest, "資料格式不正確")
// 店經理也會接手,並把它包裝成統一的 JSON 錯誤格式。


  1. JSON 範例:前端收到的完美格式!

成功:

{"request_id":"8a9c-..." , "data":{"id":123,"title":"哈囉世界"}}

錯誤:

{
  "request_id":"8a9c-...",
  "code":"POST_SLUG_CONFLICT",
  "message":"slug已被使用,換一個比較吉利",
  "details":{
    "slug":"hello-world"
  }
}

常見坑(快速排雷)

  • CORS 卡住:正式環境請把 CORS_ORIGINS 設成你的前端網域,不要全開。

  • Request ID 沒印出來:確認 WithRequestID 有掛在 WithSlog 之前或一起掛上。

  • HTML 404 沒吃到模板:檢查 Renderer 是否有設好、模板路徑是否吻合 pickErrorPage。

  • 判斷 JSON 不準:wantsJSON 用 strings.HasPrefix(path, "/api/") + Accept/Content-Type,避免只比對單一 header


你已經升級了 🔧

現在你的服務遇到錯誤時:

不會直接倒(Recovery);

能用單號追查(Request ID);

log 友善(slog 結構化);

API/HTML 錯誤長相統一;

CORS 更安全。

這套就是後端的「安全帶+黑盒子」。遇到事故不慌,事後也查得到。👏


上一篇
以 Go + Echo 打造部落格|第 8 集:JWT 魔法小卡
系列文
Golang x Echo 30 天:零基礎GO , 後端入門25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言