Go Gin 项目实战:配置、校验、JWT 与可观测性

已有的 Gin 入门知识通常聚焦路由、参数和中间件。生产项目还需要可维护的目录边界、配置加载、统一错误、身份认证、结构化日志、请求追踪和优雅退出。

本文使用 Gin 构建一套可继续扩展的 API 骨架。

初始化项目

mkdir gin-api
cd gin-api
go mod init example.com/gin-api
go get github.com/gin-gonic/gin
go get github.com/golang-jwt/jwt/v5

建议目录:

gin-api/
├── cmd/api/main.go
├── internal/config/config.go
├── internal/httpapi/handler.go
├── internal/httpapi/middleware.go
├── internal/user/service.go
├── internal/user/repository.go
└── go.mod

cmd/api 负责组装依赖和进程生命周期;internal 防止业务包被外部模块误用;Handler、Service、Repository 分别处理协议、业务和数据访问。

从环境变量加载配置

配置结构保持显式,并在启动阶段一次性校验:

package config

import (
	"fmt"
	"os"
	"strconv"
	"time"
)

type Config struct {
	Address       string
	JWTSecret     string
	TokenTTL      time.Duration
	DatabaseURL   string
	MaxBodyBytes  int64
}

func Load() (Config, error) {
	ttl, err := time.ParseDuration(value("TOKEN_TTL", "30m"))
	if err != nil {
		return Config{}, fmt.Errorf("parse TOKEN_TTL: %w", err)
	}

	maxBody, err := strconv.ParseInt(value("MAX_BODY_BYTES", "1048576"), 10, 64)
	if err != nil || maxBody < 1 {
		return Config{}, fmt.Errorf("MAX_BODY_BYTES must be positive")
	}

	config := Config{
		Address:      value("HTTP_ADDRESS", ":8080"),
		JWTSecret:    os.Getenv("JWT_SECRET"),
		TokenTTL:     ttl,
		DatabaseURL:  os.Getenv("DATABASE_URL"),
		MaxBodyBytes: maxBody,
	}
	if config.JWTSecret == "" || config.DatabaseURL == "" {
		return Config{}, fmt.Errorf("JWT_SECRET and DATABASE_URL are required")
	}
	return config, nil
}

func value(key, fallback string) string {
	if current := os.Getenv(key); current != "" {
		return current
	}
	return fallback
}

不要把生产密钥写入源码或镜像。部署时通过 Secret 管理系统注入环境变量或文件。

统一响应协议

type ErrorBody struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

type ErrorResponse struct {
	Error ErrorBody `json:"error"`
}

func Abort(c *gin.Context, status int, code, message string) {
	c.AbortWithStatusJSON(status, ErrorResponse{
		Error: ErrorBody{Code: code, Message: message},
	})
}

机器码保持稳定,人类提示可以调整或国际化。内部错误信息只写日志,不返回客户端。

参数绑定与校验

Gin 默认集成 go-playground/validator,可以通过 binding 标签声明基础规则:

type CreateUserRequest struct {
	Name     string `json:"name" binding:"required,min=2,max=50"`
	Email    string `json:"email" binding:"required,email,max=254"`
	Password string `json:"password" binding:"required,min=12,max=128"`
}

func (h *Handler) CreateUser(c *gin.Context) {
	var input CreateUserRequest
	if err := c.ShouldBindJSON(&input); err != nil {
		Abort(c, http.StatusUnprocessableEntity, "invalid_input", "请求参数不符合要求")
		return
	}

	input.Name = strings.TrimSpace(input.Name)
	input.Email = strings.ToLower(strings.TrimSpace(input.Email))

	user, err := h.users.Create(c.Request.Context(), input)
	if err != nil {
		_ = c.Error(err)
		return
	}

	c.Header("Location", fmt.Sprintf("/api/v1/users/%d", user.ID))
	c.JSON(http.StatusCreated, user)
}

标签适合格式和长度校验,跨字段规则、唯一性和领域约束应由业务层处理。密码必须由服务端使用 Argon2id 或 bcrypt 等密码哈希算法处理,不能明文保存。

如果需要限制请求体,可以在路由入口包装 c.Request.Body

c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBodyBytes)

集中映射业务错误

Handler 将错误挂到 Context,错误中间件统一处理:

func ErrorHandler(logger *slog.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Next()

		if len(c.Errors) == 0 || c.Writer.Written() {
			return
		}

		err := c.Errors.Last().Err
		switch {
		case errors.Is(err, user.ErrNotFound):
			Abort(c, http.StatusNotFound, "not_found", "用户不存在")
		case errors.Is(err, user.ErrEmailExists):
			Abort(c, http.StatusConflict, "email_exists", "邮箱已被使用")
		case errors.Is(err, context.DeadlineExceeded):
			Abort(c, http.StatusGatewayTimeout, "timeout", "请求处理超时")
		default:
			logger.ErrorContext(c.Request.Context(), "request failed", "error", err)
			Abort(c, http.StatusInternalServerError, "internal_error", "服务器内部错误")
		}
	}
}

避免在 Repository、Service、Handler 三层重复记录同一个错误。底层负责包装上下文,系统边界统一记录。

Request ID 与结构化日志

type requestIDKey struct{}

func RequestID() gin.HandlerFunc {
	return func(c *gin.Context) {
		requestID := c.GetHeader("X-Request-ID")
		if !validRequestID(requestID) {
			requestID = randomID()
		}

		c.Header("X-Request-ID", requestID)
		ctx := context.WithValue(c.Request.Context(), requestIDKey{}, requestID)
		c.Request = c.Request.WithContext(ctx)
		c.Next()
	}
}

访问日志记录方法、路由模板、状态码、耗时、响应大小和 Request ID。使用 c.FullPath() 获取路由模板,避免把具体用户 ID 当作指标标签造成高基数。

func AccessLog(logger *slog.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next()

		logger.InfoContext(c.Request.Context(), "http request",
			"method", c.Request.Method,
			"route", c.FullPath(),
			"status", c.Writer.Status(),
			"bytes", c.Writer.Size(),
			"duration", time.Since(start),
		)
	}
}

JWT 签发

使用 github.com/golang-jwt/jwt/v5,并为 Claims 指定签发者、受众和有效期:

type TokenService struct {
	secret   []byte
	issuer   string
	audience string
	ttl      time.Duration
}

type Claims struct {
	UserID int64 `json:"uid"`
	jwt.RegisteredClaims
}

func (s *TokenService) Issue(userID int64) (string, error) {
	now := time.Now()
	claims := Claims{
		UserID: userID,
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    s.issuer,
			Audience:  jwt.ClaimStrings{s.audience},
			Subject:   strconv.FormatInt(userID, 10),
			IssuedAt:  jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(now.Add(s.ttl)),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(s.secret)
}

JWT 验证中间件

type principalKey struct{}

func (s *TokenService) Authenticate() gin.HandlerFunc {
	return func(c *gin.Context) {
		raw := strings.TrimSpace(c.GetHeader("Authorization"))
		const prefix = "Bearer "
		if !strings.HasPrefix(raw, prefix) {
			Abort(c, http.StatusUnauthorized, "unauthorized", "缺少访问令牌")
			return
		}

		claims := &Claims{}
		token, err := jwt.ParseWithClaims(
			strings.TrimPrefix(raw, prefix),
			claims,
			func(token *jwt.Token) (any, error) {
				if token.Method != jwt.SigningMethodHS256 {
					return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
				}
				return s.secret, nil
			},
			jwt.WithIssuer(s.issuer),
			jwt.WithAudience(s.audience),
			jwt.WithExpirationRequired(),
		)
		if err != nil || !token.Valid {
			Abort(c, http.StatusUnauthorized, "invalid_token", "访问令牌无效或已过期")
			return
		}

		ctx := context.WithValue(c.Request.Context(), principalKey{}, claims.UserID)
		c.Request = c.Request.WithContext(ctx)
		c.Next()
	}
}

必须固定允许的签名算法,不能信任令牌头部任意指定算法。HS256 密钥应具有足够随机性;多服务验证场景可以考虑非对称签名和密钥轮换。

JWT 不等于完整会话方案。还需决定刷新令牌、主动注销、密钥轮换和权限变更何时生效。

路由分组与中间件顺序

func Routes(handler *Handler, tokens *TokenService, logger *slog.Logger) *gin.Engine {
	gin.SetMode(gin.ReleaseMode)
	router := gin.New()
	router.Use(RequestID())
	router.Use(AccessLog(logger))
	router.Use(ErrorHandler(logger))
	router.Use(gin.Recovery())

	router.GET("/healthz", handler.Health)
	router.POST("/api/v1/sessions", handler.Login)

	authorized := router.Group("/api/v1")
	authorized.Use(tokens.Authenticate())
	authorized.POST("/users", handler.CreateUser)
	authorized.GET("/users/:id", handler.GetUser)

	return router
}

中间件顺序决定谁能观察到请求和错误。这里让 Request ID 和访问日志包裹恢复中间件,因此 Handler 发生 Panic 时,恢复逻辑会先写入 500,外层日志随后记录正确状态。错误映射和认证顺序也应通过测试确认。

Server 超时与优雅退出

server := &http.Server{
	Addr:              config.Address,
	Handler:           router,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       15 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       60 * time.Second,
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		logger.Error("HTTP server failed", "error", err)
		stop()
	}
}()

<-ctx.Done()

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
	logger.Error("HTTP shutdown failed", "error", err)
}

Handler 测试

Gin 测试仍使用 httptest

func TestCreateUser_InvalidInput(t *testing.T) {
	gin.SetMode(gin.TestMode)
	router := Routes(fakeHandler(), testTokens(), slog.Default())

	body := strings.NewReader(`{"name":"","email":"bad"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/users", body)
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set("Authorization", "Bearer "+validToken(t))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusUnprocessableEntity {
		t.Fatalf("status = %d, want 422", recorder.Code)
	}
}

至少覆盖认证缺失、签名错误、过期令牌、参数错误、业务冲突、内部错误、Panic 恢复和 Request ID 传播。

生产检查清单

  1. 配置是否在启动阶段校验,密钥是否由 Secret 系统注入。
  2. 请求体、字符串长度和上传文件是否有明确上限。
  3. 业务错误是否统一映射为稳定响应码。
  4. JWT 是否固定签名算法并校验签发者、受众和过期时间。
  5. 日志是否结构化并携带 Request ID,同时避免记录密码和令牌。
  6. 指标标签是否使用路由模板而不是具体路径。
  7. HTTP Server 和下游调用是否设置超时。
  8. 服务是否支持优雅退出并关闭数据库、缓存和后台任务。
  9. 认证、校验和错误路径是否具有自动化测试。

Gin 提供的是高效路由和请求处理能力,生产质量来自路由之外的工程约束。把配置、校验、认证、错误、日志和生命周期作为统一系统设计,才能让项目持续扩展。