Skip to content

09-日志、错误处理与测试

日志、错误处理、测试,这三件事看似不起眼,却是区分"能跑的程序"和"可维护的生产系统"的关键。本篇将系统讲解 Gin 应用在这三方面的最佳实践。

Gin 默认日志机制

使用 gin.Default() 创建的引擎默认启用了 gin.Logger() 中间件,它会把每个请求的核心信息(状态码、耗时、方法、路径)输出到标准输出:

text
[GIN] 2024/01/01 - 12:00:00 | 200 |     1.234ms |       127.0.0.1 | GET    "/ping"

默认日志的颜色用 ANSI 转义码,在终端里好看,但写入文件时是乱码。可以通过 gin.DisableConsoleColor() 关闭颜色,或直接自定义格式。

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

func main() {
	// 关闭控制台颜色
	gin.DisableConsoleColor()
	r := gin.Default()

	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"msg": "pong"})
	})

	r.Run(":8080")
}

把日志写入 io.Writer

gin.DefaultWriter 是一个 io.Writer,默认指向 os.Stdout。可以重定向到文件:

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"os"
)

func main() {
	f, _ := os.Create("gin.log")
	gin.DefaultWriter = io.MultiWriter(os.Stdout, f) // 同时输出到终端和文件

	r := gin.Default()
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"msg": "pong"})
	})
	r.Run(":8080")
}

需要在 import 里加上 "io"

自定义日志中间件:记录到文件

默认日志格式有时不够灵活,可以自定义中间件记录更详细的信息。

go
package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"log"
	"net/http"
	"os"
	"time"
)

func LoggerToFile() gin.HandlerFunc {
	logger := log.New(os.Stdout, "[GIN] ", log.LstdFlags)

	return func(c *gin.Context) {
		start := time.Now()
		path := c.Request.URL.Path
		raw := c.Request.URL.RawQuery

		c.Next()

		latency := time.Since(start)
		status := c.Writer.Status()
		clientIP := c.ClientIP()
		method := c.Request.Method
		bodySize := c.Writer.Size()

		fullPath := path
		if raw != "" {
			fullPath = path + "?" + raw
		}

		logger.Printf(
			"%s | %3d | %13v | %s | %s | %s | %d bytes | %s",
			start.Format("2006-01-02 15:04:05"),
			status,
			latency,
			clientIP,
			method,
			fullPath,
			bodySize,
			c.Request.UserAgent(),
		)

		// 错误请求额外记录错误信息
		if status >= 400 {
			for _, e := range c.Errors.ByType(gin.ErrorTypePrivate) {
				logger.Printf("错误: %v", e.Err)
			}
		}
	}
}

func main() {
	r := gin.New()
	r.Use(LoggerToFile(), gin.Recovery())

	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"msg": "pong"})
	})

	r.GET("/error", func(c *gin.Context) {
		_ = c.Error(fmt.Errorf("业务出错了"))
		c.JSON(http.StatusInternalServerError, gin.H{"error": "fail"})
	})

	r.Run(":8080")
}

日志轮转:使用 lumberjack

长期运行的服务,日志文件会越来越大,需要按大小或时间切分。gopkg.in/natefinch/lumberjack.v2 是 Go 生态最常用的日志轮转库。

bash
go get -u gopkg.in/natefinch/lumberjack.v2
go
package main

import (
	"github.com/gin-gonic/gin"
	"gopkg.in/natefinch/lumberjack.v2"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	logger := &lumberjack.Logger{
		Filename:   "./logs/gin.log",
		MaxSize:    100, // MB,单文件最大 100MB
		MaxBackups: 7,   // 保留 7 个旧文件
		MaxAge:     30,  // 保留 30 天
		Compress:   true, // gzip 压缩旧文件
		LocalTime:  true,
	}

	// Gin 默认日志输出到 lumberjack
	gin.DefaultWriter = io.MultiWriter(os.Stdout, logger)

	r := gin.Default()
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"msg": "pong"})
	})

	// 自定义 logger 也可以用
	appLog := log.New(logger, "[APP] ", log.LstdFlags)
	appLog.Println("服务启动")

	r.Run(":8080")
}

结构化日志:使用 slog 或 zap

文本日志对人友好,但对机器解析不友好。生产环境推荐结构化日志(每行一个 JSON),方便日志系统(ELK、Loki)检索。

使用 slog(Go 1.21+ 标准库)

go
package main

import (
	"github.com/gin-gonic/gin"
	"log/slog"
	"net/http"
	"os"
	"time"
)

var logger *slog.Logger

func init() {
	// 输出 JSON 格式
	logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))
}

func LoggerMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		path := c.Request.URL.Path
		method := c.Request.Method
		clientIP := c.ClientIP()
		ua := c.Request.UserAgent()

		c.Next()

		latency := time.Since(start)
		status := c.Writer.Status()

		// 结构化字段
		attrs := []slog.Attr{
			slog.Int("status", status),
			slog.String("method", method),
			slog.String("path", path),
			slog.String("ip", clientIP),
			slog.Duration("latency", latency),
			slog.Int("size", c.Writer.Size()),
			slog.String("user_agent", ua),
		}

		// 关联请求 ID(如果有)
		if rid := c.GetString("X-Request-Id"); rid != "" {
			attrs = append(attrs, slog.String("request_id", rid))
		}

		if status >= 500 {
			logger.LogAttrs(c, slog.LevelError, "request completed", attrs...)
		} else if status >= 400 {
			logger.LogAttrs(c, slog.LevelWarn, "request completed", attrs...)
		} else {
			logger.LogAttrs(c, slog.LevelInfo, "request completed", attrs...)
		}
	}
}

func main() {
	r := gin.New()
	r.Use(LoggerMiddleware(), gin.Recovery())

	r.GET("/ping", func(c *gin.Context) {
		logger.Info("handling ping", slog.String("path", "/ping"))
		c.JSON(http.StatusOK, gin.H{"msg": "pong"})
	})

	r.Run(":8080")
}

输出示例:

json
{"time":"2024-01-01T12:00:00.123Z","level":"INFO","msg":"request completed","status":200,"method":"GET","path":"/ping","ip":"127.0.0.1","latency":"1.234ms","size":18,"user_agent":"curl/7.81.0"}

使用 zap

zap 是 Uber 开源的高性能日志库,性能远超标准库,适合对性能敏感的场景。

bash
go get -u go.uber.org/zap
go
package main

import (
	"github.com/gin-gonic/gin"
	"go.uber.org/zap"
	"net/http"
	"time"
)

var zapLogger *zap.Logger

func init() {
	var err error
	// 生产环境用 zap.NewProduction(),开发用 zap.NewDevelopment()
	zapLogger, err = zap.NewProduction()
	if err != nil {
		panic(err)
	}
	defer zapLogger.Sync() // 程序退出前 flush
}

func ZapLogger() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next()

		zapLogger.Info("request",
			zap.Int("status", c.Writer.Status()),
			zap.String("method", c.Request.Method),
			zap.String("path", c.Request.URL.Path),
			zap.String("ip", c.ClientIP()),
			zap.Duration("latency", time.Since(start)),
			zap.String("ua", c.Request.UserAgent()),
		)
	}
}

func main() {
	r := gin.New()
	r.Use(ZapLogger(), gin.Recovery())

	r.GET("/ping", func(c *gin.Context) {
		zapLogger.Info("pong served")
		c.JSON(http.StatusOK, gin.H{"msg": "pong"})
	})

	r.Run(":8080")
}

请求ID关联日志

在分布式系统中,一个用户请求可能跨越多个服务。给每个请求一个唯一 ID,并在所有日志中带上这个 ID,就能串联起完整的调用链。

go
package main

import (
	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"log/slog"
	"net/http"
	"time"
)

var logger = slog.New(slog.NewJSONHandler(nil, nil))

// 给 logger 加上 request_id 字段
type ctxKey string

const RequestIDKey ctxKey = "request_id"

func RequestIDMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		rid := c.GetHeader("X-Request-Id")
		if rid == "" {
			rid = uuid.New().String()
		}
		c.Set("request_id", rid)
		c.Header("X-Request-Id", rid)
		c.Next()
	}
}

// 从 context 提取 request_id 的 logger
func LoggerFromContext(c *gin.Context) *slog.Logger {
	rid, _ := c.Get("request_id")
	return slog.Default().With("request_id", rid)
}

func LoggerMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next()
		LoggerFromContext(c).Info("request",
			"status", c.Writer.Status(),
			"method", c.Request.Method,
			"path", c.Request.URL.Path,
			"latency", time.Since(start).String(),
		)
	}
}

func main() {
	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))

	r := gin.New()
	r.Use(RequestIDMiddleware(), LoggerMiddleware(), gin.Recovery())

	r.GET("/ping", func(c *gin.Context) {
		LoggerFromContext(c).Info("business event", "action", "ping")
		c.JSON(http.StatusOK, gin.H{"msg": "pong"})
	})

	r.Run(":8080")
}

注意上面 mainslog.SetDefault 用到了 os,需要在 import 里加上 "os"

错误处理策略

统一错误响应格式

API 应该有统一的错误响应格式,方便前端处理:

json
{
  "code": 40001,
  "message": "参数错误",
  "details": {"username": "required"}
}

自定义错误类型

go
package main

import (
	"errors"
	"fmt"
	"github.com/gin-gonic/gin"
	"net/http"
)

// AppError 业务错误
type AppError struct {
	Code    int    // HTTP 状态码
	ErrCode int    // 业务错误码
	Message string // 错误信息
	Cause   error  // 原始错误
}

func (e *AppError) Error() string {
	if e.Cause != nil {
		return fmt.Sprintf("%s: %v", e.Message, e.Cause)
	}
	return e.Message
}

func (e *AppError) Unwrap() error { return e.Cause }

// 预定义错误
var (
	ErrInvalidParam = &AppError{Code: 400, ErrCode: 40001, Message: "参数错误"}
	ErrUnauthorized = &AppError{Code: 401, ErrCode: 40101, Message: "未授权"}
	ErrForbidden    = &AppError{Code: 403, ErrCode: 40301, Message: "禁止访问"}
	ErrNotFound     = &AppError{Code: 404, ErrCode: 40401, Message: "资源不存在"}
	ErrInternal     = &AppError{Code: 500, ErrCode: 50000, Message: "服务器内部错误"}
)

// New 包装一个错误
func (e *AppError) Wrap(cause error) *AppError {
	return &AppError{
		Code:    e.Code,
		ErrCode: e.ErrCode,
		Message: e.Message,
		Cause:   cause,
	}
}

// 统一错误处理中间件
func ErrorHandler() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Next()

		// 处理 c.Errors 中收集的错误
		if len(c.Errors) == 0 {
			return
		}
		err := c.Errors.Last().Err

		var appErr *AppError
		if errors.As(err, &appErr) {
			c.JSON(appErr.Code, gin.H{
				"code":    appErr.ErrCode,
				"message": appErr.Message,
			})
			return
		}

		// 未知错误,统一返回 500
		c.JSON(http.StatusInternalServerError, gin.H{
			"code":    50000,
			"message": "服务器内部错误",
		})
	}
}

func main() {
	r := gin.New()
	r.Use(gin.Recovery(), ErrorHandler())

	r.GET("/users/:id", func(c *gin.Context) {
		id := c.Param("id")
		if id == "" {
			_ = c.Error(ErrInvalidParam.Wrap(errors.New("id required")))
			return
		}
		if id == "0" {
			_ = c.Error(ErrNotFound)
			return
		}
		c.JSON(http.StatusOK, gin.H{"id": id, "name": "alice"})
	})

	r.Run(":8080")
}

panic 恢复中间件

Gin 内置的 gin.Recovery() 会捕获 panic,但默认只返回 500。可以自定义更友好的版本:

go
package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"log/slog"
	"net/http"
	"runtime/debug"
)

func Recovery() gin.HandlerFunc {
	return func(c *gin.Context) {
		defer func() {
			if r := recover(); r != nil {
				// 打印堆栈
				slog.Error("panic recovered",
					"error", fmt.Sprint(r),
					"stack", string(debug.Stack()),
					"path", c.Request.URL.Path,
				)
				// 返回 500
				c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
					"code":    50000,
					"message": "服务器内部错误",
				})
			}
		}()
		c.Next()
	}
}

func main() {
	r := gin.New()
	r.Use(Recovery())

	r.GET("/panic", func(c *gin.Context) {
		panic("something bad happened")
	})

	r.Run(":8080")
}

注意:panic 应该是异常情况,不要用 panic 做流程控制。能预期到的错误(参数错误、找不到记录)应该用 error 返回。

测试基础:httptest

Go 标准库 net/http/httptest 提供了 HTTP 测试能力,配合 Gin 可以方便地测试路由处理器。

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"net/http/httptest"
	"testing"
)

func setupRouter() *gin.Engine {
	gin.SetMode(gin.TestMode)
	r := gin.New()
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"msg": "pong"})
	})
	return r
}

func TestPing(t *testing.T) {
	r := setupRouter()

	// 构造请求
	req := httptest.NewRequest("GET", "/ping", nil)
	// 记录响应
	w := httptest.NewRecorder()

	r.ServeHTTP(w, req)

	// 断言
	if w.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", w.Code)
	}
	body := w.Body.String()
	if !strings.Contains(body, "pong") {
		t.Errorf("response body should contain 'pong', got: %s", body)
	}
}

注意上面用到了 strings.Contains,需要在 import 加 "strings"

测试时切换 gin 模式

测试时建议设置 gin.SetMode(gin.TestMode),关闭调试日志,避免干扰测试输出。

测试路由处理器

更完整的示例,测试带参数、带 JSON body 的接口:

go
package main

import (
	"bytes"
	"encoding/json"
	"github.com/gin-gonic/gin"
	"net/http"
	"net/http/httptest"
	"testing"
)

type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func setupRouter() *gin.Engine {
	gin.SetMode(gin.TestMode)
	r := gin.New()

	r.GET("/users/:id", func(c *gin.Context) {
		id := c.Param("id")
		if id == "0" {
			c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
			return
		}
		c.JSON(http.StatusOK, User{ID: 1, Name: "alice"})
	})

	r.POST("/users", func(c *gin.Context) {
		var u User
		if err := c.ShouldBindJSON(&u); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		u.ID = 100
		c.JSON(http.StatusCreated, u)
	})

	return r
}

func TestGetUser(t *testing.T) {
	r := setupRouter()

	tests := []struct {
		name       string
		id         string
		wantStatus int
	}{
		{"存在的用户", "1", http.StatusOK},
		{"不存在的用户", "0", http.StatusNotFound},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req := httptest.NewRequest("GET", "/users/"+tt.id, nil)
			w := httptest.NewRecorder()
			r.ServeHTTP(w, req)

			if w.Code != tt.wantStatus {
				t.Errorf("expected %d, got %d", tt.wantStatus, w.Code)
			}
		})
	}
}

func TestCreateUser(t *testing.T) {
	r := setupRouter()

	body, _ := json.Marshal(map[string]string{"name": "bob"})
	req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("expected 201, got %d, body: %s", w.Code, w.Body.String())
	}

	var u User
	if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil {
		t.Fatal("解析响应失败:", err)
	}
	if u.ID != 100 {
		t.Errorf("expected id 100, got %d", u.ID)
	}
	if u.Name != "bob" {
		t.Errorf("expected name bob, got %s", u.Name)
	}
}

测试中间件

中间件测试的关键是构造一个能触发它的请求,并验证 c.Set 写入的值或 c.Abort 的效果。

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"net/http/httptest"
	"testing"
)

func AuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		token := c.GetHeader("Authorization")
		if token != "Bearer valid" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
			return
		}
		c.Set("user", "alice")
		c.Next()
	}
}

func newTestRouter() *gin.Engine {
	gin.SetMode(gin.TestMode)
	r := gin.New()
	r.Use(AuthMiddleware())
	r.GET("/protected", func(c *gin.Context) {
		user := c.GetString("user")
		c.JSON(http.StatusOK, gin.H{"user": user})
	})
	return r
}

func TestAuthMiddleware_Success(t *testing.T) {
	r := newTestRouter()
	req := httptest.NewRequest("GET", "/protected", nil)
	req.Header.Set("Authorization", "Bearer valid")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", w.Code)
	}
}

func TestAuthMiddleware_Unauthorized(t *testing.T) {
	r := newTestRouter()
	req := httptest.NewRequest("GET", "/protected", nil)
	// 不带 Authorization
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("expected 401, got %d", w.Code)
	}
}

表驱动测试

表驱动测试是 Go 推荐的测试风格,把测试用例组织成表,循环执行。

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"net/http/httptest"
	"testing"
)

func setupRouter() *gin.Engine {
	gin.SetMode(gin.TestMode)
	r := gin.New()

	r.GET("/square", func(c *gin.Context) {
		num := c.Query("num")
		if num == "" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "num required"})
			return
		}
		n, err := strconv.Atoi(num)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid num"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"result": n * n})
	})

	return r
}

func TestSquare(t *testing.T) {
	r := setupRouter()

	tests := []struct {
		name       string
		query      string
		wantStatus int
		wantBody   string
	}{
		{"正常数字", "?num=5", http.StatusOK, `"result":25`},
		{"负数", "?num=-3", http.StatusOK, `"result":9`},
		{"零", "?num=0", http.StatusOK, `"result":0`},
		{"缺少参数", "", http.StatusBadRequest, `"error":"num required"`},
		{"非数字", "?num=abc", http.StatusBadRequest, `"error":"invalid num"`},
		{"浮点数", "?num=3.5", http.StatusBadRequest, `"error":"invalid num"`},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req := httptest.NewRequest("GET", "/square"+tt.query, nil)
			w := httptest.NewRecorder()
			r.ServeHTTP(w, req)

			if w.Code != tt.wantStatus {
				t.Errorf("status: expected %d, got %d", tt.wantStatus, w.Code)
			}
			if !strings.Contains(w.Body.String(), tt.wantBody) {
				t.Errorf("body: expected to contain %q, got %s", tt.wantBody, w.Body.String())
			}
		})
	}
}

注意 strconvstrings 需要在 import 中声明。

测试覆盖率

go test 配合 -cover 参数可以统计测试覆盖率:

bash
go test -cover ./...

输出类似:

text
ok      myapp    0.123s    coverage: 75.3% of statements

生成 HTML 覆盖率报告:

bash
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# 然后用浏览器打开 coverage.html

覆盖率目标

  • 核心业务逻辑:建议 80% 以上
  • HTTP handler 层:60%-80%,覆盖主要路径即可
  • 整体项目:建议不低于 60%

不要为了追求覆盖率而写无意义的测试。覆盖率高不代表质量好,但覆盖率低往往意味着测试不足。

完整的测试示例

下面是一个完整的"待办事项"API + 测试示例,综合运用 httptest、表驱动、覆盖率思想。

todo.go(被测代码)

go
package main

import (
	"errors"
	"github.com/gin-gonic/gin"
	"net/http"
	"strconv"
	"sync"
)

type Todo struct {
	ID    int    `json:"id"`
	Title string `json:"title" binding:"required"`
	Done  bool   `json:"done"`
}

type TodoStore struct {
	mu    sync.Mutex
	items map[int]Todo
	next  int
}

func NewTodoStore() *TodoStore {
	return &TodoStore{items: make(map[int]Todo), next: 1}
}

func (s *TodoStore) Create(title string) Todo {
	s.mu.Lock()
	defer s.mu.Unlock()
	t := Todo{ID: s.next, Title: title, Done: false}
	s.items[s.next] = t
	s.next++
	return t
}

func (s *TodoStore) Get(id int) (Todo, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.items[id]
	if !ok {
		return Todo{}, errors.New("not found")
	}
	return t, nil
}

func (s *TodoStore) List() []Todo {
	s.mu.Lock()
	defer s.mu.Unlock()
	result := make([]Todo, 0, len(s.items))
	for _, t := range s.items {
		result = append(result, t)
	}
	return result
}

func (s *TodoStore) Update(id int, title string, done bool) (Todo, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.items[id]
	if !ok {
		return Todo{}, errors.New("not found")
	}
	if title != "" {
		t.Title = title
	}
	t.Done = done
	s.items[id] = t
	return t, nil
}

func (s *TodoStore) Delete(id int) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.items[id]; !ok {
		return errors.New("not found")
	}
	delete(s.items, id)
	return nil
}

func setupRouter(store *TodoStore) *gin.Engine {
	r := gin.New()

	r.POST("/todos", func(c *gin.Context) {
		var t Todo
		if err := c.ShouldBindJSON(&t); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		created := store.Create(t.Title)
		c.JSON(http.StatusCreated, created)
	})

	r.GET("/todos", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"data": store.List()})
	})

	r.GET("/todos/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}
		t, err := store.Get(id)
		if err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, t)
	})

	r.PUT("/todos/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}
		var t Todo
		if err := c.ShouldBindJSON(&t); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		updated, err := store.Update(id, t.Title, t.Done)
		if err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, updated)
	})

	r.DELETE("/todos/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}
		if err := store.Delete(id); err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
			return
		}
		c.Status(http.StatusNoContent)
	})

	return r
}

func main() {
	r := setupRouter(NewTodoStore())
	r.Run(":8080")
}

todo_test.go(测试代码)

go
package main

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestTodoCRUD(t *testing.T) {
	store := NewTodoStore()
	r := setupRouter(store)

	// 1. 创建
	body, _ := json.Marshal(map[string]string{"title": "学习 Gin"})
	req := httptest.NewRequest("POST", "/todos", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	if w.Code != http.StatusCreated {
		t.Fatalf("创建失败: %d, body: %s", w.Code, w.Body.String())
	}
	var created Todo
	json.Unmarshal(w.Body.Bytes(), &created)
	if created.ID != 1 || created.Title != "学习 Gin" {
		t.Errorf("创建结果不符合预期: %+v", created)
	}

	// 2. 查询
	req = httptest.NewRequest("GET", "/todos/1", nil)
	w = httptest.NewRecorder()
	r.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Errorf("查询失败: %d", w.Code)
	}

	// 3. 列表
	req = httptest.NewRequest("GET", "/todos", nil)
	w = httptest.NewRecorder()
	r.ServeHTTP(w, req)
	if !strings.Contains(w.Body.String(), `"data"`) {
		t.Errorf("列表响应错误: %s", w.Body.String())
	}

	// 4. 更新
	body, _ = json.Marshal(map[string]interface{}{"title": "学习 Gin 和测试", "done": true})
	req = httptest.NewRequest("PUT", "/todos/1", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	w = httptest.NewRecorder()
	r.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Errorf("更新失败: %d", w.Code)
	}

	// 5. 删除
	req = httptest.NewRequest("DELETE", "/todos/1", nil)
	w = httptest.NewRecorder()
	r.ServeHTTP(w, req)
	if w.Code != http.StatusNoContent {
		t.Errorf("删除失败: %d", w.Code)
	}

	// 6. 删除后查询应 404
	req = httptest.NewRequest("GET", "/todos/1", nil)
	w = httptest.NewRecorder()
	r.ServeHTTP(w, req)
	if w.Code != http.StatusNotFound {
		t.Errorf("删除后查询应 404, got %d", w.Code)
	}
}

func TestCreateTodo_Invalid(t *testing.T) {
	store := NewTodoStore()
	r := setupRouter(store)

	// 缺少 title
	req := httptest.NewRequest("POST", "/todos", strings.NewReader(`{}`))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Errorf("缺少 title 应返回 400, got %d", w.Code)
	}
}

func TestGetTodo_NotFound(t *testing.T) {
	store := NewTodoStore()
	r := setupRouter(store)

	req := httptest.NewRequest("GET", "/todos/999", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	if w.Code != http.StatusNotFound {
		t.Errorf("不存在应返回 404, got %d", w.Code)
	}
}

func TestGetTodo_InvalidID(t *testing.T) {
	store := NewTodoStore()
	r := setupRouter(store)

	req := httptest.NewRequest("GET", "/todos/abc", nil)
	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Errorf("非法 id 应返回 400, got %d", w.Code)
	}
}

运行测试:

bash
go test -v -cover ./...

小结

本篇覆盖了 Gin 应用上线前必做的三件事:

日志

  1. 默认日志gin.Logger() 输出到 stdout,可重定向 gin.DefaultWriter
  2. 自定义日志中间件:自由记录方法、路径、状态、耗时、UA 等。
  3. 日志轮转:用 lumberjack 按大小/时间切分,避免单文件过大。
  4. 结构化日志slog(标准库,Go 1.21+)或 zap(高性能),输出 JSON 便于日志系统检索。
  5. 请求ID关联:在中间件生成 request_id,让所有日志带上这个字段,便于链路追踪。

错误处理

  1. 统一响应格式:所有错误返回一致的 JSON 结构(code + message)。
  2. 自定义错误类型AppError 封装 HTTP 码、业务码、错误信息,配合 errors.As 解包。
  3. 错误中间件:用 c.Errors 收集错误,统一在中间件中处理。
  4. panic 恢复:自定义 Recovery 中间件,记录堆栈,返回 500,避免进程崩溃。

测试

  1. httptest:构造请求 + 录制响应,是 HTTP 测试的基石。
  2. 测试路由处理器:通过 setupRouter() 复用引擎,针对每个端点写测试。
  3. 测试中间件:验证 c.Set 写入的值,验证 c.Abort 的状态码。
  4. 表驱动测试:用表组织测试用例,循环执行,便于扩展用例。
  5. 测试覆盖率go test -cover,生成 HTML 报告定位未覆盖代码。

至此,Gin 进阶系列告一段落。从中间件、模板、数据库、认证到日志与测试,你已经具备了构建生产级 Web 应用的所有核心能力。剩下的就是在实战中不断打磨和积累。