Appearance
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.v2go
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/zapgo
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")
}注意上面 main 里 slog.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())
}
})
}
}注意 strconv 和 strings 需要在 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 应用上线前必做的三件事:
日志
- 默认日志:
gin.Logger()输出到 stdout,可重定向gin.DefaultWriter。 - 自定义日志中间件:自由记录方法、路径、状态、耗时、UA 等。
- 日志轮转:用
lumberjack按大小/时间切分,避免单文件过大。 - 结构化日志:
slog(标准库,Go 1.21+)或zap(高性能),输出 JSON 便于日志系统检索。 - 请求ID关联:在中间件生成 request_id,让所有日志带上这个字段,便于链路追踪。
错误处理
- 统一响应格式:所有错误返回一致的 JSON 结构(code + message)。
- 自定义错误类型:
AppError封装 HTTP 码、业务码、错误信息,配合errors.As解包。 - 错误中间件:用
c.Errors收集错误,统一在中间件中处理。 - panic 恢复:自定义
Recovery中间件,记录堆栈,返回 500,避免进程崩溃。
测试
- httptest:构造请求 + 录制响应,是 HTTP 测试的基石。
- 测试路由处理器:通过
setupRouter()复用引擎,针对每个端点写测试。 - 测试中间件:验证
c.Set写入的值,验证c.Abort的状态码。 - 表驱动测试:用表组织测试用例,循环执行,便于扩展用例。
- 测试覆盖率:
go test -cover,生成 HTML 报告定位未覆盖代码。
至此,Gin 进阶系列告一段落。从中间件、模板、数据库、认证到日志与测试,你已经具备了构建生产级 Web 应用的所有核心能力。剩下的就是在实战中不断打磨和积累。