Gin
Gin 是基于 Go 标准库 net/http 的 Web 框架,提供路由、中间件、参数绑定和响应渲染等功能。本文使用 Go 1.26.x 和 Gin 1.12.0。该 Gin 版本要求 Go 1.25 或更高版本。
1. 安装
先创建模块,再添加指定版本的依赖:
mkdir gin-notes
cd gin-notes
go mod init example.com/gin-notes
go mod edit -go=1.26.0
go get github.com/gin-gonic/gin@v1.12.0将下一节代码保存为模块目录中的 main.go,再运行 go mod tidy 和 go run main.go。go get 在这里用于管理模块依赖,不是安装可执行程序。初次安装无需使用 -u 同时升级其他依赖。
2. Hello World
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func helloWorld(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "world"})
}
func main() {
router := gin.Default()
router.GET("/hello", helloWorld)
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}gin.Default() 创建带有 Logger 和 Recovery 中间件的路由引擎,分别用于请求日志和请求处理链中的 panic 恢复。gin.New() 不添加这些默认中间件。Recovery 不能替其他 goroutine 恢复 panic。
示例只监听本机的 127.0.0.1:8000。Run 会阻塞并返回监听或服务错误,应检查返回值。另开终端访问:
curl http://127.0.0.1:8000/hello响应状态为 200 OK,正文为:
{"message":"world"}c.JSON 会序列化数据并设置 JSON 的 Content-Type。gin.H 的底层类型是 map[string]any,适合构造简单响应。
3. 路由分组
假设需要提供 /goods/list、/goods/add 和 /goods/del,可以分别注册路由:
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/goods/list", goodsList)
router.POST("/goods/add", addGoods)
router.POST("/goods/del", delGoods)
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}
// 以下处理函数只演示路由,不访问数据库。
func goodsList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"action": "list"})
}
func addGoods(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"action": "add"})
}
func delGoods(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"action": "del"})
}这些路径都有 /goods 前缀,可以将 main 中创建路由引擎和注册路由的部分替换为:
router := gin.Default()
goodsGroup := router.Group("/goods")
goodsGroup.GET("/list", goodsList)
goodsGroup.POST("/add", addGoods)
goodsGroup.POST("/del", delGoods)goodsList、addGoods、delGoods 和最后的 Run 保持原样。分组后的完整路径和 HTTP 方法不变。分组也可以接收中间件,统一作用于该组随后注册的路由。
示例的处理函数只返回操作名称,没有访问数据库。GET 用于查询,添加和删除这里用 POST 演示,不应仅因路径名称包含 delete 就用 GET 修改资源。
4. 路径参数
4.1 单个路径段
将 Hello World 的 main 中创建路由引擎和注册路由的部分替换为:
router := gin.Default()
router.GET("/goods/:id", func(context *gin.Context) {
id := context.Param("id")
context.JSON(http.StatusOK, gin.H{
"id": id,
})
})/goods/:id 匹配一个非空路径段。访问 /goods/123 时,Param("id") 返回字符串 "123",不会自动转换成整数。只替换上例的 GET 注册语句,多个参数可以写为:
router.GET("/goods/:id/:action", func(context *gin.Context) {
id := context.Param("id")
action := context.Param("action")
context.JSON(http.StatusOK, gin.H{
"id": id,
"action": action,
})
})router 沿用前例,两个示例分别运行。请求 /goods/123/delete 时,响应为:
{"action":"delete","id":"123"}这个路由只回显参数,不执行删除操作。:action 只匹配一个非空路径段,不能匹配 /goods/123/delete/test。
4.2 通配参数
*action 匹配剩余路径,必须放在路由末尾,取得的值包含开头的 /。沿用已有的 router,将 GET 注册语句替换为:
router.GET("/goods/:id/*action", func(context *gin.Context) {
id := context.Param("id")
action := context.Param("action")
context.JSON(http.StatusOK, gin.H{
"id": id,
"action": action,
})
})在默认配置下,响应如下:
| 请求路径 | 状态码 | action 或重定向位置 |
|---|---|---|
/goods/123/delete | 200 | "/delete" |
/goods/123/delete/test | 200 | "/delete/test" |
/goods/123/ | 200 | "/" |
/goods/123 | 301 | Location: /goods/123/ |
最后一种请求由默认开启的 RedirectTrailingSlash 重定向,第一次响应不是 JSON。curl -L 会跟随重定向,随后得到 action 为 "/" 的 JSON。关闭这个选项后,该路径返回 404。
:action 和 *action 是两种替代写法,不要把这两个相冲突的路由同时注册到同一个引擎。
4.3 绑定路径参数
ShouldBindUri 按 uri 标签将路径参数绑定到结构体,并执行 binding 验证:
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
type Goods struct {
ID int `uri:"id" binding:"required"`
Name string `uri:"name" binding:"required"`
}
func main() {
router := gin.Default()
router.GET("/goods/:id/:name", func(c *gin.Context) {
var goods Goods
if err := c.ShouldBindUri(&goods); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": goods.ID, "name": goods.Name})
})
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}请求 /goods/123/abc 时,响应为:
{"id":123,"name":"abc"}uri:"id" 与路由中的 :id 对应。ID 为 int,因此非整数参数会绑定失败,示例返回 400。数值字段的 required 会拒绝零值,但不会自动要求它为正数。业务需要正数 ID 时,可再增加 gt=0。
5. 查询参数与表单
5.1 查询参数
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/hello", hello)
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}
func hello(c *gin.Context) {
// 参数缺失或值为空时,Query 都返回空字符串。
lang := c.Query("lang")
// 参数缺失时使用默认值,显式空值不会被替换。
framework := c.DefaultQuery("framework", "Gin")
c.JSON(http.StatusOK, gin.H{"lang": lang, "framework": framework})
}Query 和 DefaultQuery 读取 URL 的查询字符串,并不限定只能在 GET 请求中使用。
| 请求路径 | 响应正文 |
|---|---|
/hello | {"framework":"Gin","lang":""} |
/hello?lang=Java&framework=Spring | {"framework":"Spring","lang":"Java"} |
/hello?framework= | {"framework":"","lang":""} |
参数缺失与显式传入空值有所区别。Query 在这两种情况下都返回空字符串,DefaultQuery 只在参数缺失时使用默认值。需要区分两者时使用 GetQuery 返回的布尔值。重复参数可以通过 QueryArray 获取。
5.2 表单参数
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.POST("/hello", hello)
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}
func hello(c *gin.Context) {
lang := c.PostForm("lang")
framework := c.DefaultPostForm("framework", "Gin")
c.JSON(http.StatusOK, gin.H{"lang": lang, "framework": framework})
}发送 URL 编码的表单:
POST http://127.0.0.1:8000/hello
Content-Type: application/x-www-form-urlencoded
lang=Go&framework=Gin响应为:
{"framework":"Gin","lang":"Go"}表单正文不要在字段名和值之间额外加入空格,否则空格可能成为数据的一部分。也可以用下面的命令,由 curl 编码表单字段:
curl --data-urlencode 'lang=Go' --data-urlencode 'framework=Gin' \
http://127.0.0.1:8000/helloPostForm 和 DefaultPostForm 读取 URL 编码或 multipart 表单,不读取 URL 查询参数,也不会解析 JSON 正文。DefaultPostForm 同样只在字段缺失时使用默认值,GetPostForm 可以区分字段缺失和显式空值。
6. Protobuf 渲染
先安装 Protobuf 编译器 protoc。将下面的消息定义保存为模块根目录下的 msg.proto:
syntax = "proto3";
package example;
option go_package = "example.com/gin-notes/pb;pb";
message Teacher {
string name = 1;
repeated string courses = 2;
}package example 声明 Protobuf 命名空间。go_package 中的 example.com/gin-notes/pb 是生成代码的 Go 导入路径,;pb 指定 Go 包名。这里与第 1 节的模块路径对应,换成其他模块时需要同时调整下面的导入。
将服务代码保存为同一目录的 main.go:
package main
import (
"log"
"net/http"
"example.com/gin-notes/pb"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/hello", hello)
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}
func hello(c *gin.Context) {
teacher := &pb.Teacher{
Name: "zhang",
Courses: []string{"Gin", "GoLang"},
}
c.ProtoBuf(http.StatusOK, teacher)
}安装 Go 代码生成器,将其加入 PATH,再生成 pb/msg.pb.go:
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.10
export PATH="$(go env GOPATH)/bin:$PATH"
mkdir -p pb
protoc --go_out=pb --go_opt=paths=source_relative msg.proto
go mod tidy
go run main.gopaths=source_relative 让输出文件按输入文件的相对路径放在 --go_out 目录下。生成后的目录包含:
gin-notes/
├── go.mod
├── go.sum
├── main.go
├── msg.proto
└── pb/
└── msg.pb.goc.ProtoBuf 返回 Protobuf 二进制数据,Content-Type 为 application/x-protobuf,不会自动转换成 JSON。另开终端,在模块根目录获取并解码响应:
curl --output response.bin http://127.0.0.1:8000/hello
protoc --decode=example.Teacher msg.proto < response.bin解码结果为:
name: "zhang"
courses: "Gin"
courses: "GoLang"7. 参数绑定与验证
7.1 JSON 绑定
Gin 使用 go-playground/validator/v10 执行 binding 标签中的验证规则。Gin 1.12.0 默认依赖 validator 10.30.1,以下错误示例按这个版本展示。
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
type SignUpInfo struct {
Username string `json:"username" binding:"required,min=3,max=20"`
Password string `json:"password" binding:"required,min=8,max=20"`
RePassword string `json:"rePassword" binding:"required,eqfield=Password"`
Email string `json:"email" binding:"required,email"`
Age uint `json:"age" binding:"lte=120"`
}
func main() {
router := gin.Default()
router.POST("/signUp", signUp)
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}
func signUp(c *gin.Context) {
var info SignUpInfo
if err := c.ShouldBindJSON(&info); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "验证通过"})
}ShouldBindJSON 明确按 JSON 解析请求正文,绑定或验证失败时返回错误,不会自动设置错误响应。示例选择返回 400,并结束当前处理函数。BindJSON 则在失败时自动中止处理链并写入 400,不适合在这之后再改成其他响应状态。
| 标签 | 含义 |
|---|---|
required | 字段不能为对应类型的零值 |
min=3,max=20 | 字符串长度在 3 到 20 之间 |
min=8,max=20 | 字符串长度在 8 到 20 之间 |
eqfield=Password | 与同一结构体中的 Go 字段 Password 相等 |
email | 符合该验证器支持的邮箱格式 |
lte=120 | 数值不大于 120 |
字符串长度按 Unicode 码点数量检查,不是 UTF-8 字节数。Age 使用无符号整数,负数或不匹配的 JSON 类型会在解析阶段失败。这里没有给年龄设置 required,省略它时保留零值 0,仍能通过验证。如果需要区分未传字段与显式传入零值,可以使用指针字段。
发送以下请求,用户名长度、密码确认、邮箱格式和年龄会分别验证失败:
POST http://127.0.0.1:8000/signUp
Content-Type: application/json
{
"username": "ab",
"password": "password123",
"rePassword": "other123",
"email": "invalid",
"age": 130
}响应状态为 400,JSON 正文中的 error 是字符串,包含以下各行。序列化时换行会编码为 \n:
Key: 'SignUpInfo.Username' Error:Field validation for 'Username' failed on the 'min' tag
Key: 'SignUpInfo.RePassword' Error:Field validation for 'RePassword' failed on the 'eqfield' tag
Key: 'SignUpInfo.Email' Error:Field validation for 'Email' failed on the 'email' tag
Key: 'SignUpInfo.Age' Error:Field validation for 'Age' failed on the 'lte' tag示例只检查这些字段约束,没有执行完整的用户注册流程。
7.2 中文错误信息
可以在启动服务前注册中文翻译,并使用 JSON 字段名作为响应中的错误键:
package main
import (
"errors"
"log"
"net/http"
"reflect"
"strings"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
zhtranslations "github.com/go-playground/validator/v10/translations/zh"
)
type SignUpInfo struct {
Username string `json:"username" binding:"required,min=3,max=20"`
Password string `json:"password" binding:"required,min=8,max=20"`
RePassword string `json:"rePassword" binding:"required,eqfield=Password"`
Email string `json:"email" binding:"required,email"`
Age uint `json:"age" binding:"lte=120"`
}
func initTranslator() (ut.Translator, error) {
v, ok := binding.Validator.Engine().(*validator.Validate)
if !ok {
return nil, errors.New("unexpected validator engine")
}
v.RegisterTagNameFunc(func(field reflect.StructField) string {
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
locale := zh.New()
trans, ok := ut.New(locale, locale).GetTranslator("zh")
if !ok {
return nil, errors.New("Chinese translator not found")
}
if err := zhtranslations.RegisterDefaultTranslations(v, trans); err != nil {
return nil, err
}
return trans, nil
}
func main() {
trans, err := initTranslator()
if err != nil {
log.Fatal(err)
}
router := gin.Default()
router.POST("/signUp", signUp(trans))
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}
func signUp(trans ut.Translator) gin.HandlerFunc {
return func(c *gin.Context) {
var info SignUpInfo
if err := c.ShouldBindJSON(&info); err != nil {
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
messages := make(map[string]string, len(validationErrors))
for _, fieldError := range validationErrors {
messages[fieldError.Field()] = fieldError.Translate(trans)
}
c.JSON(http.StatusBadRequest, gin.H{"error": messages})
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "JSON 格式或字段类型不正确"})
}
return
}
c.JSON(http.StatusOK, gin.H{"msg": "验证通过"})
}
}同一个无效请求会返回 400,正文为:
{
"error": {
"age": "age必须小于或等于120",
"email": "email必须是一个有效的邮箱",
"rePassword": "rePassword必须等于Password",
"username": "username长度必须至少为3个字符"
}
}eqfield 的参数仍使用 Go 字段名,因此默认翻译中的 Password 对应结构体里的同名字段。示例是平铺结构体,错误键使用 Field()。有嵌套结构或切片时,需要设计完整的字段路径,避免同名字段相互覆盖。
只有 validator.ValidationErrors 才能按字段翻译。JSON 语法错误、空正文和字段类型不匹配属于绑定错误,示例为它们返回独立的提示。初始化失败会停止启动,避免继续使用未初始化的翻译器。字段名称规则和翻译应在处理并发请求前注册完成。
8. 中间件
8.1 执行顺序
中间件与最终处理函数使用同一种 gin.HandlerFunc 类型,按注册顺序组成请求处理链:
package main
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func MyLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
log.Printf("status=%d latency=%s", c.Writer.Status(), time.Since(start))
}
}
func main() {
router := gin.New()
router.Use(gin.Logger(), gin.Recovery())
router.GET("/hello", MyLogger(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "world"})
})
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}c.Next() 在当前中间件内部执行尚未执行的处理函数,返回后再执行计时和日志逻辑。多个中间件都使用这个结构时,前置逻辑按注册顺序执行,后置逻辑按相反顺序返回。
上例的 Logger 和 Recovery 用于全局路由,MyLogger 仅用于 /hello。也可以在 Group 中配置中间件。Use 应放在相应路由注册之前,后续添加的中间件不会改变已注册路由的处理链。子组创建时也会复制已有的上级处理链。
8.2 return 与 Abort
直接 return 只结束当前中间件函数,框架仍会继续调用后面的处理函数:
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func finalHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "handler executed"})
}
func main() {
router := gin.Default()
router.GET("/continue", func(c *gin.Context) {
return // 只结束当前中间件,finalHandler 仍会执行。
}, finalHandler)
router.GET("/abort", func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "request denied"})
return // Abort 不会结束当前函数,仍需按逻辑返回。
}, finalHandler)
if err := router.Run("127.0.0.1:8000"); err != nil {
log.Fatal(err)
}
}访问 /continue 时,最终处理函数仍执行并返回 200。访问 /abort 时,AbortWithStatusJSON 中止后续处理链并返回 403,最终处理函数不会执行。
Abort 不会终止当前函数,也不会撤销已经执行的逻辑。调用后是否继续执行当前函数,由普通 Go 控制流决定。仅调用 Abort() 不会自动写入错误状态或正文,所以通常搭配 AbortWithStatus 或 AbortWithStatusJSON,并按需要 return。外层已经进入 Next 的中间件仍可能继续执行自己的后置逻辑。
8.3 处理链源码
RouterGroup.Use 追加中间件:
func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
group.Handlers = append(group.Handlers, middleware...)
return group.returnObj()
}注册路由时,combineHandlers 将组中间件和路由处理函数合成新的切片:
func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
finalSize := len(group.Handlers) + len(handlers)
assert1(finalSize < int(abortIndex), "too many handlers")
mergedHandlers := make(HandlersChain, finalSize)
copy(mergedHandlers, group.Handlers)
copy(mergedHandlers[len(group.Handlers):], handlers)
return mergedHandlers
}引擎匹配路由后调用 Next,它按索引执行处理链:
func (c *Context) Next() {
c.index++
for c.index < safeInt8(len(c.handlers)) {
if c.handlers[c.index] != nil {
c.handlers[c.index](c)
}
c.index++
}
}Abort 将索引设置为中止值,后续调用不再进入循环:
const abortIndex int8 = math.MaxInt8 >> 1
func (c *Context) Abort() {
c.index = abortIndex
}处理链源码见该版本的 context.go。
8.4 goroutine 与请求上下文
*gin.Context 会被复用,不应直接交给请求结束后仍在运行的 goroutine。优先在启动 goroutine 前取出需要的数据,确需传递 Gin 上下文时使用 c.Copy() 的副本,只用于读取,不能通过副本写入响应。
Copy 不会让共享数据自动获得并发保护,也不会延长 c.Request.Context() 的生命周期。需要取消信号和请求截止时间的下游调用,应使用请求的标准库 Context。
9. 优雅退出
优雅退出是先停止接受新连接,再为正在处理的 HTTP 请求留出完成时间。Gin 路由引擎实现了 http.Handler,可以交给显式创建的 http.Server,通过 Shutdown 控制关闭过程:
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
stopCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
router := gin.Default()
router.GET("/", func(c *gin.Context) {
log.Println("Request started")
// 模拟耗时请求,连接关闭时停止等待。
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
c.String(http.StatusOK, "Welcome Gin Server")
case <-c.Request.Context().Done():
return
}
})
srv := &http.Server{
Addr: "127.0.0.1:8000",
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
serverErr := make(chan error, 1)
go func() {
serverErr <- srv.ListenAndServe()
}()
select {
case err := <-serverErr:
return err
case <-stopCtx.Done():
stop() // 恢复默认信号行为,允许再次发送信号结束进程。
}
log.Println("Shutdown started")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
return errors.Join(fmt.Errorf("shutdown: %w", err), srv.Close())
}
if err := <-serverErr; err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
log.Println("Server exiting")
return nil
}示例使用 signal.NotifyContext 接收 SIGINT 和 SIGTERM,适用于这里的 Linux/Unix 信号场景。SIGKILL 不能捕获,也不能用于触发优雅退出。
可以先启动耗时请求,再在服务器终端按 Ctrl+C。服务器停止接受新连接,但该请求在 2 秒后仍可得到响应,随后程序退出。这里使用独立的 context.Background() 创建 10 秒关闭期限,不能直接把已经取消的信号上下文传给 Shutdown。
需要区分几个行为:
ListenAndServe在关闭时返回http.ErrServerClosed,这属于正常退出。主 goroutine 仍应等待Shutdown返回,不能只因监听结束就退出程序。Shutdown的期限限制等待时间,不会在超时时自动强制关闭仍在处理的连接。示例在失败时调用Close,并返回错误。Shutdown不会自动等待自行创建的后台任务,也不会关闭或等待已经被接管的连接,例如 WebSocket。应用需要另外通知这些任务或连接结束,并等待完成。- 正常请求不会仅因调用
Shutdown就被强制中断。请求提前取消时,上例通过c.Request.Context().Done()停止模拟等待。
这些关闭行为由标准库的 http.Server.Shutdown 定义。使用 http.Server 也便于按实际服务需要配置请求读取和响应写入超时。