Skip to content

响应处理与路由分组

本篇是 Gin 系列教程的第四篇。前两篇我们解决了「请求进来」(路由、参数绑定、校验)的问题,本篇聚焦「响应出去」——如何返回 JSON、XML、字符串、文件、HTML,如何操作状态码、响应头、Cookie,以及如何用路由分组组织大型项目的 API。最后我们会综合前三篇知识,写一个完整的 RESTful 用户 CRUD 示例。

一、JSON 响应:c.JSON、gin.H、结构体响应

JSON 是现代 Web API 最主流的响应格式。Gin 提供了多种返回 JSON 的方式。

1. 使用 gin.H

gin.Hmap[string]interface{} 的别名,写起来最简单,适合字段少或临时构造的场景。

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.GET("/json1", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"code": 0,
			"msg":  "success",
			"data": gin.H{"id": 1, "name": "alice"},
		})
	})

	r.Run(":8080")
}

2. 使用结构体

当响应字段固定、需要复用、或希望享受类型检查时,用结构体更合适。结构体的 json 标签控制输出字段名。

go
package main

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)

// UserResp 用户响应结构体
type UserResp struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email,omitempty"` // omitempty: 空值时省略
	CreatedAt time.Time `json:"created_at"`
}

// APIResponse 统一响应结构
type APIResponse struct {
	Code int         `json:"code"`
	Msg  string      `json:"msg"`
	Data interface{} `json:"data"`
}

func main() {
	r := gin.Default()

	r.GET("/users/:id", func(c *gin.Context) {
		user := UserResp{
			ID:        1,
			Name:      "alice",
			Email:     "alice@example.com",
			CreatedAt: time.Now(),
		}
		c.JSON(http.StatusOK, APIResponse{
			Code: 0,
			Msg:  "success",
			Data: user,
		})
	})

	r.Run(":8080")
}

访问 /users/1 返回:

json
{
    "code": 0,
    "msg": "success",
    "data": {
        "id": 1,
        "name": "alice",
        "email": "alice@example.com",
        "created_at": "2024-01-01T12:00:00.123456789Z"
    }
}

3. 直接序列化切片

c.JSON 第二个参数可以是任意可序列化的对象,包括切片、map。

go
r.GET("/users", func(c *gin.Context) {
	users := []UserResp{
		{ID: 1, Name: "alice"},
		{ID: 2, Name: "bob"},
	}
	c.JSON(http.StatusOK, users)
})

实际项目建议用统一的 APIResponse 结构体包装响应,让所有接口返回格式一致,方便前端处理。gin.H 适合快速原型或字段不固定的场景。

二、XML、YAML、ProtoBuf 响应

除 JSON 外,Gin 还内置支持多种序列化格式。

XML 响应

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

type Person struct {
	XMLName struct{} `xml:"person"`
	ID      int      `xml:"id"`
	Name    string   `xml:"name"`
}

func main() {
	r := gin.Default()

	r.GET("/xml", func(c *gin.Context) {
		p := Person{ID: 1, Name: "alice"}
		c.XML(http.StatusOK, p)
	})

	r.Run(":8080")
}

访问 /xml 返回:

xml
<person><id>1</id><name>alice</name></person>

YAML 响应

go
r.GET("/yaml", func(c *gin.Context) {
	c.YAML(http.StatusOK, gin.H{
		"id":   1,
		"name": "alice",
	})
})

返回内容类型为 application/yaml; charset=utf-8

ProtoBuf 响应

用于 gRPC 或高性能二进制场景,需要先定义 .proto 文件并生成 Go 代码,然后:

go
c.ProtoBuf(http.StatusOK, &pb.Person{Id: 1, Name: "alice"})

日常 Web 开发 95% 以上用 JSON。XML 多用于对接老系统或 RSS,YAML 偶尔用于配置接口,ProtoBuf 主要在 gRPC 生态里使用。

三、String 响应:c.String

当需要返回纯文本(如健康检查、纯文本消息、CSV)时,用 c.String

go
package main

import (
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	// 纯文本
	r.GET("/ping", func(c *gin.Context) {
		c.String(http.StatusOK, "pong")
	})

	// 带格式化的文本
	r.GET("/greet", func(c *gin.Context) {
		name := c.DefaultQuery("name", "Guest")
		c.String(http.StatusOK, "Hello, %s!", name)
	})

	// 返回 CSV
	r.GET("/export.csv", func(c *gin.Context) {
		c.Header("Content-Disposition", "attachment; filename=users.csv")
		c.Data(http.StatusOK, "text/csv", []byte("id,name\n1,alice\n2,bob\n"))
	})

	r.GET("/info", func(c *gin.Context) {
		c.String(http.StatusOK, fmt.Sprintf("Method: %s\nPath: %s", c.Request.Method, c.Request.URL.Path))
	})

	r.Run(":8080")
}

c.String 与 c.Data 的区别

  • c.String(code, format, args...):带 fmt.Sprintf 格式化,Content-Type 为 text/plain
  • c.Data(code, contentType, data):直接写字节流,需要自己指定 Content-Type,更通用(可以返回 CSV、任意二进制)。

四、HTML 模板响应基础:c.HTML

Gin 支持渲染 HTML 模板(基于 Go 标准库 html/template)。完整模板用法会在后续章节展开,这里只做基础介绍。

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	// 1. 加载模板文件(支持 glob 模式)
	r.LoadHTMLGlob("templates/*")
	// 或加载多个目录
	// r.LoadHTMLFiles("templates/index.html", "templates/about.html")

	// 2. 设置静态资源目录
	r.Static("/assets", "./assets")

	// 3. 渲染模板
	r.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", gin.H{
			"title": "首页",
			"user":  gin.H{"name": "alice", "age": 25},
			"items": []string{"Go", "Gin", "Web"},
		})
	})

	r.Run(":8080")
}

templates/index.html 示例:

html
<!DOCTYPE html>
<html>
<head><title>{{ .title }}</title></head>
<body>
    <h1>欢迎,{{ .user.name }}({{ .user.age }} 岁)</h1>
    <ul>
        {{ range .items }}
        <li>{{ . }}</li>
        {{ end }}
    </ul>
</body>
</html>

这里仅作入门演示。模板语法、模板继承、自定义函数等高级用法会在后续「模板渲染」章节详细讲解。

五、文件响应:c.File、c.FileAttachment

c.File:直接展示文件

返回一个文件,浏览器会根据类型决定是展示(图片、PDF)还是下载。

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	// 直接返回文件(图片、PDF 等会直接在浏览器展示)
	r.GET("/file", func(c *gin.Context) {
		c.File("./assets/logo.png")
	})

	// 静态文件目录(更常用)
	// 访问 /static/xxx.png 实际读取 ./assets/xxx.png
	r.Static("/static", "./assets")

	r.Run(":8080")
}

c.FileAttachment:强制下载

c.FileAttachment 会在响应头加上 Content-Disposition: attachment,并指定下载文件名,浏览器会强制下载而非展示。

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	// 强制下载
	r.GET("/download", func(c *gin.Context) {
		// 第二个参数是下载时显示的文件名
		c.FileAttachment("./assets/report.pdf", "月度报告.pdf")
	})

	// 动态生成 CSV 并下载
	r.GET("/export", func(c *gin.Context) {
		c.Header("Content-Type", "text/csv; charset=utf-8")
		c.Header("Content-Disposition", `attachment; filename="users.csv"`)
		c.String(http.StatusOK, "id,name\n1,alice\n2,bob\n")
	})

	r.Run(":8080")
}

Static / StaticFS / StaticFile 的区别

方法用途
r.Static(relPath, root)把磁盘目录映射为 URL 前缀
r.StaticFS(relPath, http.FileSystem)用自定义 FileSystem(如 embed 内嵌资源)
r.StaticFile(relPath, filepath)只暴露单个文件

六、状态码设置:c.Status、c.AbortWithStatus

c.Status:只设置状态码

go
r.POST("/items", func(c *gin.Context) {
	// ... 创建逻辑
	c.Status(http.StatusNoContent) // 204,无响应体
})

常用状态码

含义典型场景
200OK请求成功
201Created资源创建成功
204No Content成功但无响应体
400Bad Request参数错误
401Unauthorized未登录
403Forbidden无权限
404Not Found资源不存在
500Internal Server Error服务器内部错误

c.AbortWithStatus:设置状态码并中止

Abort 会停止后续中间件/handler 的执行,常用于鉴权失败等场景。

go
r.GET("/secure", func(c *gin.Context) {
	token := c.GetHeader("Authorization")
	if token == "" {
		c.AbortWithStatus(http.StatusUnauthorized)
		return
	}
	c.JSON(http.StatusOK, gin.H{"data": "secret"})
})

组合方法

Gin 还提供了一些常用的组合快捷方法:

go
// 设置状态码 + JSON 响应
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})

// 设置状态码 + 字符串
c.AbortWithStatus(http.StatusForbidden)

七、响应头操作:c.Header

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.GET("/api", func(c *gin.Context) {
		// 设置响应头
		c.Header("X-Request-Id", "abc-123-456")
		c.Header("X-Powered-By", "Gin")
		// CORS 跨域响应头
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")

		c.JSON(http.StatusOK, gin.H{"data": "ok"})
	})

	// 获取请求头
	r.GET("/agent", func(c *gin.Context) {
		ua := c.GetHeader("User-Agent")
		c.JSON(http.StatusOK, gin.H{"user_agent": ua})
	})

	r.Run(":8080")
}

测试响应头:

bash
curl -i http://localhost:8080/api
# HTTP/1.1 200 OK
# X-Request-Id: abc-123-456
# X-Powered-By: Gin
# ...

注意 c.Header 是「设置」而非「追加」。若要追加同名头(如多个 Set-Cookie),用 c.Writer.Header().Add("Set-Cookie", ...)

Cookie 常用于会话、用户偏好等。Gin 封装了 SetCookie 方法。

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.POST("/login", func(c *gin.Context) {
		// 业务校验省略
		// 设置 Cookie
		c.SetCookie("token", "abc123", 3600, "/", "localhost", false, true)
		// 参数:
		// name, value, maxAge(秒), path, domain, secure(仅https), httpOnly(禁JS访问)
		c.JSON(http.StatusOK, gin.H{"msg": "登录成功"})
	})

	r.GET("/profile", func(c *gin.Context) {
		// 读取 Cookie
		token, err := c.Cookie("token")
		if err != nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"token": token})
	})

	r.POST("/logout", func(c *gin.Context) {
		// 删除 Cookie:设置 maxAge 为 -1
		c.SetCookie("token", "", -1, "/", "localhost", false, true)
		c.JSON(http.StatusOK, gin.H{"msg": "已退出"})
	})

	r.Run(":8080")
}
参数含义
nameCookie 名称
valueCookie 值
maxAge有效期(秒);<0 立即删除,=0 会话级,>0 持久化
path生效路径,通常 /
domain生效域名
securetrue 表示仅 HTTPS 传输
httpOnlytrue 表示 JS 无法通过 document.cookie 读取(防 XSS)

生产环境对鉴权 Cookie,建议 secure=true(HTTPS)+ httpOnly=true + 合理的 SameSite,避免 CSRF/XSS。

九、路由分组:r.Group

当项目有多个模块、多版本 API 时,路由会非常多。r.Group 允许把路由按前缀分组,避免重复书写前缀,也便于统一加中间件。

基础分组

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	// /api/v1 前缀分组
	v1 := r.Group("/api/v1")
	{
		v1.GET("/users", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"path": "/api/v1/users"})
		})
		v1.POST("/users", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"path": "/api/v1/users"})
		})
		v1.GET("/orders", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"path": "/api/v1/orders"})
		})
	}

	// /api/v2 前缀分组
	v2 := r.Group("/api/v2")
	{
		v2.GET("/users", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"path": "/api/v2/users"})
		})
	}

	r.Run(":8080")
}

访问:

bash
curl http://localhost:8080/api/v1/users   # {"path":"/api/v1/users"}
curl http://localhost:8080/api/v2/users   # {"path":"/api/v2/users"}

{} 只是为了视觉上把同一组的路由归拢,不影响作用域,可以不写,但写了更清晰。

十、分组中间件

路由分组最大的价值之一,是可以给整组路由统一加中间件,例如鉴权、日志。

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

// 鉴权中间件
func AuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		token := c.GetHeader("Authorization")
		if token != "Bearer secret" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
			return
		}
		c.Set("user_id", 42) // 后续 handler 可取
		c.Next()
	}
}

func main() {
	r := gin.Default()

	// 公开接口
	r.GET("/api/public", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"msg": "公开接口"})
	})

	// 受保护接口:加鉴权中间件
	auth := r.Group("/api", AuthMiddleware())
	{
		auth.GET("/profile", func(c *gin.Context) {
			uid := c.GetInt("user_id") // 取中间件设置的值
			c.JSON(http.StatusOK, gin.H{"user_id": uid})
		})
		auth.GET("/orders", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"orders": []int{1, 2, 3}})
		})
	}

	r.Run(":8080")
}

测试:

bash
# 没带 token,401
curl http://localhost:8080/api/profile
# {"error":"未授权"}

# 带 token,成功
curl -H "Authorization: Bearer secret" http://localhost:8080/api/profile
# {"user_id":42}

c.Set(key, value) / c.Get(key) 是中间件与 handler 之间传递数据的标准方式。

十一、嵌套分组

分组可以嵌套,灵活组合多层前缀。

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	// 顶层分组
	api := r.Group("/api")
	// api.Use(全局日志中间件)  // 可以给 api 加中间件,对所有子分组生效

	// 一级嵌套:/api/admin
	admin := api.Group("/admin")
	admin.Use(func(c *gin.Context) {
		// admin 鉴权
		if c.GetHeader("X-Role") != "admin" {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
			return
		}
		c.Next()
	})
	{
		admin.GET("/users", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"path": "/api/admin/users"})
		})
		admin.DELETE("/users/:id", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"deleted": c.Param("id")})
		})
	}

	// 一级嵌套:/api/user
	user := api.Group("/user")
	{
		user.GET("/profile", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"path": "/api/user/profile"})
		})
	}

	r.Run(":8080")
}

测试:

bash
# 普通用户接口
curl http://localhost:8080/api/user/profile

# 管理员接口,需要 X-Role 头
curl -H "X-Role: admin" http://localhost:8080/api/admin/users
curl -H "X-Role: admin" -X DELETE http://localhost:8080/api/admin/users/5

嵌套分组的中间件会累加:子分组会继承父分组的所有中间件,并加上自己的。

十二、版本化 API 设计示例

结合分组 + 中间件,做一个典型的多版本 API 设计。

go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

// 通用日志中间件
func LogMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 简单记录
		// 实际项目可记录到文件/ES
		c.Next()
	}
}

// v1 版本的用户控制器
type UserV1Controller struct{}

func (uc *UserV1Controller) List(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"version": "v1",
		"users":   []gin.H{{"id": 1, "name": "alice"}},
	})
}

func (uc *UserV1Controller) Create(c *gin.Context) {
	c.JSON(http.StatusCreated, gin.H{"version": "v1", "msg": "已创建"})
}

// v2 版本的用户控制器(字段更丰富)
type UserV2Controller struct{}

func (uc *UserV2Controller) List(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"version": "v2",
		"users":   []gin.H{{"id": 1, "name": "alice", "email": "alice@example.com"}},
		"total":   1,
	})
}

func main() {
	r := gin.Default()
	r.Use(LogMiddleware())

	v1 := r.Group("/api/v1")
	{
		uc := &UserV1Controller{}
		v1.GET("/users", uc.List)
		v1.POST("/users", uc.Create)
	}

	v2 := r.Group("/api/v2")
	{
		uc := &UserV2Controller{}
		v2.GET("/users", uc.List)
	}

	r.Run(":8080")
}

这种结构让 v1、v2 互不干扰,便于渐进式升级:v2 上线后保留 v1 一段时间,待客户端全部迁移后再下线 v1。

十三、完整的 RESTful API 示例(用户 CRUD)

把前三篇学到的「路由、参数绑定、校验、响应、分组、中间件」全部串起来,实现一个用户 CRUD 接口。

设计

  • 路由前缀:/api/v1
  • 用内存切片模拟数据库
  • 接口:
    • GET /api/v1/users 列表
    • GET /api/v1/users/:id 详情
    • POST /api/v1/users 创建
    • PUT /api/v1/users/:id 更新
    • DELETE /api/v1/users/:id 删除
  • 请求参数用结构体绑定 + 校验
  • 响应统一用 APIResponse

完整代码

go
package main

import (
	"errors"
	"net/http"
	"strconv"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
)

// ===== 数据模型 =====

type User struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	Age       int       `json:"age"`
	CreatedAt time.Time `json:"created_at"`
}

// ===== 请求结构体 =====

type CreateUserReq struct {
	Name  string `json:"name" binding:"required,min=2,max=20"`
	Email string `json:"email" binding:"required,email"`
	Age   int    `json:"age" binding:"required,gte=1,lte=150"`
}

type UpdateUserReq struct {
	Name  string `json:"name" binding:"omitempty,min=2,max=20"`
	Email string `json:"email" binding:"omitempty,email"`
	Age   int    `json:"age" binding:"omitempty,gte=1,lte=150"`
}

// ===== 统一响应 =====

type APIResponse struct {
	Code int         `json:"code"`
	Msg  string      `json:"msg"`
	Data interface{} `json:"data,omitempty"`
}

// ===== 模拟数据库 =====

type UserStore struct {
	mu     sync.RWMutex
	nextID int
	users  map[int]*User
}

func NewUserStore() *UserStore {
	return &UserStore{
		nextID: 1,
		users:  make(map[int]*User),
	}
}

func (s *UserStore) Create(req *CreateUserReq) *User {
	s.mu.Lock()
	defer s.mu.Unlock()
	u := &User{
		ID:        s.nextID,
		Name:      req.Name,
		Email:     req.Email,
		Age:       req.Age,
		CreatedAt: time.Now(),
	}
	s.users[s.nextID] = u
	s.nextID++
	return u
}

func (s *UserStore) Get(id int) (*User, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	u, ok := s.users[id]
	if !ok {
		return nil, errors.New("user not found")
	}
	return u, nil
}

func (s *UserStore) List() []*User {
	s.mu.RLock()
	defer s.mu.RUnlock()
	list := make([]*User, 0, len(s.users))
	for _, u := range s.users {
		list = append(list, u)
	}
	return list
}

func (s *UserStore) Update(id int, req *UpdateUserReq) (*User, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.users[id]
	if !ok {
		return nil, errors.New("user not found")
	}
	// 只更新非零值字段(简单处理,实际可借助指针区分空值)
	if req.Name != "" {
		u.Name = req.Name
	}
	if req.Email != "" {
		u.Email = req.Email
	}
	if req.Age != 0 {
		u.Age = req.Age
	}
	return u, nil
}

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

// ===== 控制器 =====

type UserController struct {
	store *UserStore
}

func NewUserController(store *UserStore) *UserController {
	return &UserController{store: store}
}

// 校验错误信息映射
var errMsg = map[string]string{
	"Name":  "姓名必填,2-20 字符",
	"Email": "邮箱格式不正确",
	"Age":   "年龄需在 1-150 之间",
}

func parseValidateErr(err error) (field, msg string) {
	var valErrs validator.ValidationErrors
	if errors.As(err, &valErrs) {
		for _, fe := range valErrs {
			if m, ok := errMsg[fe.Field()]; ok {
				return fe.Field(), m
			}
		}
	}
	return "", "参数错误"
}

func (uc *UserController) List(c *gin.Context) {
	c.JSON(http.StatusOK, APIResponse{
		Code: 0,
		Msg:  "success",
		Data: uc.store.List(),
	})
}

func (uc *UserController) Get(c *gin.Context) {
	idStr := c.Param("id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		c.JSON(http.StatusBadRequest, APIResponse{Code: 10001, Msg: "id 必须为数字"})
		return
	}
	u, err := uc.store.Get(id)
	if err != nil {
		c.JSON(http.StatusNotFound, APIResponse{Code: 10002, Msg: "用户不存在"})
		return
	}
	c.JSON(http.StatusOK, APIResponse{Code: 0, Msg: "success", Data: u})
}

func (uc *UserController) Create(c *gin.Context) {
	var req CreateUserReq
	if err := c.ShouldBindJSON(&req); err != nil {
		_, msg := parseValidateErr(err)
		c.JSON(http.StatusBadRequest, APIResponse{Code: 10001, Msg: msg})
		return
	}
	u := uc.store.Create(&req)
	c.JSON(http.StatusCreated, APIResponse{Code: 0, Msg: "创建成功", Data: u})
}

func (uc *UserController) Update(c *gin.Context) {
	idStr := c.Param("id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		c.JSON(http.StatusBadRequest, APIResponse{Code: 10001, Msg: "id 必须为数字"})
		return
	}
	var req UpdateUserReq
	if err := c.ShouldBindJSON(&req); err != nil {
		_, msg := parseValidateErr(err)
		c.JSON(http.StatusBadRequest, APIResponse{Code: 10001, Msg: msg})
		return
	}
	u, err := uc.store.Update(id, &req)
	if err != nil {
		c.JSON(http.StatusNotFound, APIResponse{Code: 10002, Msg: "用户不存在"})
		return
	}
	c.JSON(http.StatusOK, APIResponse{Code: 0, Msg: "更新成功", Data: u})
}

func (uc *UserController) Delete(c *gin.Context) {
	idStr := c.Param("id")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		c.JSON(http.StatusBadRequest, APIResponse{Code: 10001, Msg: "id 必须为数字"})
		return
	}
	if err := uc.store.Delete(id); err != nil {
		c.JSON(http.StatusNotFound, APIResponse{Code: 10002, Msg: "用户不存在"})
		return
	}
	c.JSON(http.StatusOK, APIResponse{Code: 0, Msg: "删除成功"})
}

// ===== 入口 =====

func main() {
	r := gin.Default()

	// 初始化存储与控制器
	store := NewUserStore()
	uc := NewUserController(store)

	// 版本化 API 分组
	v1 := r.Group("/api/v1")
	{
		v1.GET("/users", uc.List)
		v1.GET("/users/:id", uc.Get)
		v1.POST("/users", uc.Create)
		v1.PUT("/users/:id", uc.Update)
		v1.DELETE("/users/:id", uc.Delete)
	}

	// 健康检查
	r.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, APIResponse{Code: 0, Msg: "ok"})
	})

	r.Run(":8080")
}

测试流程

bash
# 1. 创建用户
curl -X POST http://localhost:8080/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"name":"alice","email":"alice@example.com","age":25}'
# 返回创建的用户(id=1)

# 2. 再创建一个
curl -X POST http://localhost:8080/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"name":"bob","email":"bob@example.com","age":30}'

# 3. 列表
curl http://localhost:8080/api/v1/users

# 4. 详情
curl http://localhost:8080/api/v1/users/1

# 5. 更新
curl -X PUT http://localhost:8080/api/v1/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"alice2"}'

# 6. 删除
curl -X DELETE http://localhost:8080/api/v1/users/2

# 7. 校验失败示例:邮箱格式错
curl -X POST http://localhost:8080/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"name":"x","email":"bad","age":25}'
# {"code":10001,"msg":"邮箱格式不正确"}

这段代码体现了哪些最佳实践

  • 分层结构:Model / Req / Response / Store / Controller 各司其职,便于扩展。
  • 并发安全UserStoresync.RWMutex 保护 map,避免并发写冲突。
  • 统一响应:所有接口都返回 APIResponse,前端处理逻辑统一。
  • 校验集中:参数校验交给 binding 标签,错误信息统一映射。
  • RESTful 语义:方法与操作对齐,状态码用得规范(201 创建、404 不存在、400 参数错)。
  • 版本化分组/api/v1 前缀为未来 v2 留出空间。

真实项目里,UserStore 会被替换成数据库访问层(如 GORM),UserController 会拆到 internal/controller 目录,路由注册会放到 internal/router。但骨架与本示例一致。

十四、小结

本篇把 Gin 的「响应」和「组织」两侧能力补齐,并完成了一个完整 CRUD 示例:

  1. JSON 响应c.JSON + gin.H 或结构体;推荐用统一 APIResponse 结构体。
  2. 多格式响应:XML / YAML / ProtoBuf / String / Data,按需选用。
  3. HTML 模板LoadHTMLGlob + c.HTML,配合 Static 提供静态资源。
  4. 文件响应c.File(展示)/ c.FileAttachment(下载)/ c.Static(目录)。
  5. 状态码c.Statusc.AbortWithStatusc.AbortWithStatusJSON,搭配合理的 HTTP 状态码。
  6. 响应头c.Header 设置;同名追加用 c.Writer.Header().Add
  7. Cookiec.SetCookie / c.Cookie,注意 secure、httpOnly 等安全选项。
  8. 路由分组r.Group 减少前缀重复,配合 Use 加中间件。
  9. 嵌套分组:父分组中间件会传递给子分组。
  10. 版本化 API:通过 /api/v1/api/v2 分组实现平滑升级。
  11. 综合实战:用户 CRUD 串联了参数绑定、校验、响应、分组、并发安全,可作为项目脚手架。

至此,Gin 入门篇的四篇教程就完成了。你已经具备用 Gin 独立开发一个中小型 Web API 服务的能力。后续进阶篇我们会讲解中间件原理与自定义、错误处理、模板渲染、JWT 鉴权、文件上传、SSE/WebSocket、与数据库集成、性能优化与部署等内容。


上一篇03-请求处理:参数绑定与验证