Gin 框架学习实录 · 第4篇:参数校验、结构体拆分与控制器职责解耦

前言

在上一篇中,我们实现了将用户注册信息写入 MySQL 数据库的完整流程,接口也已经能够正常接收参数并写入数据表。

但是我们可能也注意到了:我们并没有对请求参数做任何校验

这意味着,哪怕用户提交的是空字符串、非法格式,甚至完全缺失某些字段,接口仍然会照样"成功",甚至将脏数据写入数据库 ------ 这在实际开发中是非常危险的。 所以今天我们将简单做一些数据校验,引入 Gin 的参数校验机制(binding 标签),实现基础的字段必填、长度范围限制等规则

场景:

  • /register 接口中,我们对用户提交的数据进行校验

  • 比如:

    • name 是必填
    • age 必须在 18 ~ 120 岁之间

Gin 的校验方式:

Gin 使用 go-playground/validator 做参数验证,它非常强大,我们只需要在结构体字段上添加 binding:"..." 标签即可。

修改请求结构体并加校验标签

controller/user.go

go 复制代码
type RegisterRequest struct {
	Name string `json:"name" binding:"required"`         // 必填
	Age  int    `json:"age" binding:"required,gte=18,lte=120"` // 必填,18~120
}

测试验证效果

正确请求(成功):

json 复制代码
POST /register
{
  "name": "Alice",
  "age": 25
}

返回:

json 复制代码
{
  "message": "注册成功",
  "user_id": 1
}

错误请求 1(缺少 name):

json 复制代码
{
  "age": 25
}

返回:

json 复制代码
{
  "error": "Key: 'RegisterRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag"
}

错误请求 2(age 太小):

json 复制代码
{
  "name": "Gin",
  "age": 10
}

返回:

json 复制代码
{
  "error": "Key: 'RegisterRequest.Age' Error:Field validation for 'Age' failed on the 'gte' tag"
}

大概的常用校验标签:

标签 含义
required 必填
gte=10 数值 ≥ 10
lte=100 数值 ≤ 100
email 必须是邮箱格式
len=11 长度必须是 11
min=6 max=20 字符串最小/最大长度
oneof=a b c 值必须是给定集合之一

我们后面也可以加上:

  • 使用 validator/v10 自定义错误提示(中文也行)
  • 自定义 tag 校验(比如手机号格式)
  • 配合 Binding 和中间件统一返回错误格式

补充说明:int 类型字段不建议使用 binding:"required"

我们上面最开始写的校验结构体如下:

go 复制代码
type RegisterRequest struct {
    Name string `json:"name" binding:"required"`                   // 必填
    Age  int    `json:"age" binding:"required,gte=18,lte=120"`     // 必填 + 范围限制
}

但是在测试时我发现一个小问题:

Go 的 int 类型是 值类型 ,不能为 nil,即使我们没传 age 字段,它的默认值也是 0

所以:

  • binding:"required" 对于 int 类型来说是无效的
  • 不管传不传,req.Age 都有值(默认是 0),不会触发 "缺失" 的校验错误

所以我们正确的做法应该是:

go 复制代码
type RegisterRequest struct {
    Name string `json:"name" binding:"required"`           // 字符串可用 required
    Age  int    `json:"age" binding:"gte=18,lte=120"`      // 只校验范围即可
}

这样可以确保:

  • Name 不能为空字符串(required)
  • Age 必须在 18 ~ 120 之间,默认值 0 会触发范围错误

如果我们真的想"强制要求 age 字段必须传",可以将类型改为 *int(指针类型)

go 复制代码
Age *int `json:"age" binding:"required,gte=18,lte=120"`

这样改的话就可以:

  • 如果没传 age,会校验 required
  • 如果传了 null,也会报错
  • 但是需要我们自己判空:if req.Age == nil {}

但是因为我们才接触Gin不久 所以我一般推荐的做法是:

对于 int/float 类型,用范围校验(gte、lte)替代 required

对于 string 类型,可以使用 required + 长度控制

如何自定义错误信息

我们在上一步中实现了字段的参数校验,但我们也注意到:

当校验失败时,接口返回的错误信息是这样的:

json 复制代码
{
  "error": "Key: 'RegisterRequest.Age' Error:Field validation for 'Age' failed on the 'gte' tag"
}

这对开发者来说也许还能理解,但对终端用户来说,这种提示完全不友好,甚至是"看不懂"的。

比如:"字段验证失败","gte 标签错误"------ 一般用户谁知道 gte 是啥啊 😂

那我们能不能把这些错误提示变得更友好一些,比如:

json 复制代码
{
  "error": "年龄不能小于 18"
}

这样是不是一下子就清晰多了?用户秒懂、前端也好处理,也是我们写接口返回比较常见的方式

接下来,我们封装一个通用的错误翻译函数

为了避免每个接口都重复写字段错误提示逻辑,我们可以把「字段映射 + 校验 tag 翻译」封装成一个简单的工具函数,放在统一的 utils/validator.go 中,方便后续复用。

新建文件 utils/validator.go

go 复制代码
package utils

import (
	"fmt"
	"github.com/go-playground/validator/v10"
)

// TranslateValidationError 接收 validator 错误 + 字段中文映射表,返回第一个错误提示
func TranslateValidationError(errs validator.ValidationErrors, fieldMap map[string]string) string {
	if len(errs) == 0 {
		return "参数验证失败"
	}

	fe := errs[0] // 这里只处理第一条错误(也可以遍历所有)

	// 字段名中文映射
	name, ok := fieldMap[fe.Field()]
	if !ok {
		name = fe.Field()
	}

	// 翻译错误类型
	switch fe.Tag() {
	case "required":
		return fmt.Sprintf("%s是必填字段", name)
	case "gte":
		return fmt.Sprintf("%s不能小于 %s", name, fe.Param())
	case "lte":
		return fmt.Sprintf("%s不能大于 %s", name, fe.Param())
	default:
		return fmt.Sprintf("%s格式不正确", name)
	}
}

如何使用:

我们已经封装了一个简单的自定义错误工具函数,现在我们只需要在接口里传入一个字段名映射表,就能自动生成友好的中文错误提示。

go 复制代码
fieldMap := map[string]string{
	"Name": "用户名",
	"Age":  "年龄",
}

msg := utils.TranslateValidationError(ve, fieldMap)

这样是不是非常方便?后续每个接口只要定义自己的字段映射表就可以了

修改 Register 函数

现在我们已经有了通用的错误翻译函数 TranslateValidationError(),接下来我们来优化注册接口的写法:

  • 将错误处理提取出去,逻辑更清晰
  • 引入字段映射表,实现中文提示
  • 保持控制器"干净、专注于业务逻辑"

修改后的 Register 接口如下:

go 复制代码
func Register(c *gin.Context) {
	var req RegisterRequest

	if err := c.ShouldBindJSON(&req); err != nil {
		// 判断是否为 validator 错误
		var ve validator.ValidationErrors
		if errors.As(err, &ve) {
			fieldMap := map[string]string{
				"Name": "用户名",
				"Age":  "年龄",
			}
			msg := utils.TranslateValidationError(ve, fieldMap)
			c.JSON(http.StatusBadRequest, gin.H{"error": msg})
		} else {
			// 其他绑定错误,如 JSON 格式错误
			c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数格式不正确"})
		}
		return
	}

	// 参数通过,写入数据库
	user := model.User{
		Name: req.Name,
		Age:  req.Age,
	}

	if err := config.DB.Create(&user).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "用户保存失败"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"message": "注册成功",
		"user_id": user.ID,
	})
}

这样我们就可以做到只需定义一次字段映射表,错误提示自动翻译,不再写死字符串。如果我们以后想做到多语言、自动翻译所有错误 ,可以用 validator + universal-translator,实现:

go 复制代码
validator.New().RegisterTranslation()

但是目前我们还是初步学习,所以上面那种手动翻译 + 简洁映射的方式我们现在已经非常实用。


控制器结构越来越重,参数结构体是不是也该拆一下?

到目前为止,我们在 Register 接口中已经实现了参数校验、错误翻译、数据库写入等功能,整体结构如下:

go 复制代码
package controller

import (
    "errors"
    "gin-learn-notes/config"
    "gin-learn-notes/model"
    "gin-learn-notes/utils"
    "github.com/gin-gonic/gin"
    "github.com/go-playground/validator/v10"
    "net/http"
)

type RegisterRequest struct {
    Name string `json:"name" binding:"required"`      // 必填
    Age  int    `json:"age" binding:"gte=18,lte=120"` // 必填,18~120
}

func Register(c *gin.Context) {
    var req RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
       // 使用 validator 类型断言
       var ve validator.ValidationErrors
       if errors.As(err, &ve) {
          fieldMap := map[string]string{
             "Name": "用户名",
             "Age":  "年龄",
          }
          msg := utils.TranslateValidationError(ve, fieldMap)
          c.JSON(http.StatusBadRequest, gin.H{"error": msg})
       } else {
          // 其他绑定错误,如 JSON 格式错误
          c.JSON(http.StatusBadRequest, gin.H{"error": "参数格式不正确"})
       }
       return
    }

    user := model.User{
       Name: req.Name,
       Age:  req.Age,
    }

    if err := config.DB.Create(&user).Error; err != nil {
       c.JSON(http.StatusInternalServerError, gin.H{"error": "用户保存失败"})
       return
    }

    c.JSON(http.StatusOK, gin.H{
       "message": "注册成功",
       "user_id": user.ID,
    })
}

这个结构看起来没有问题,但是呢我们就发现:

我们仅仅实现了一个注册接口,就已经在控制器文件中写了一个专用的参数结构体。

那如果后面我们继续写用户登录、修改、删除等功能,或者换一个模块要新增文章、评论、标签......

是不是每个接口都要往控制器里堆一个 xxxRequest 结构体?

那我们是不是可以把参数结构体也"模块化",从 controller 中拆出去,单独维护呢?

比如我们可以把所有请求结构体集中放在 request/ 目录下,例如:

go 复制代码
gin-learn-notes/
├── request/                ✅ 请求参数结构体
│   └── user_request.go     // 放所有与 User 相关的请求结构体
├── controller/
│   └── user.go             // 控制器逻辑只关注业务

示例:提取 RegisterRequest

我们新建 request/user_request.go 然后吧 上面的参数结构体放到这里来:

go 复制代码
package request

type RegisterRequest struct {
    Name string `json:"name" binding:"required"`      // 必填
    Age  int    `json:"age" binding:"gte=18,lte=120"` // 必填,18~120
}

然后我们在controller/user.go 中使用:

go 复制代码
func Register(c *gin.Context) {
    var req  request.RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
       // 使用 validator 类型断言
       var ve validator.ValidationErrors
       if errors.As(err, &ve) {
          fieldMap := map[string]string{
             "Name": "用户名",
             "Age":  "年龄",
          }
          msg := utils.TranslateValidationError(ve, fieldMap)
          c.JSON(http.StatusBadRequest, gin.H{"error": msg})
       } else {
          // 其他绑定错误,如 JSON 格式错误
          c.JSON(http.StatusBadRequest, gin.H{"error": "参数格式不正确"})
       }
       return
    }

    user := model.User{
       Name: req.Name,
       Age:  req.Age,
    }

    if err := config.DB.Create(&user).Error; err != nil {
       c.JSON(http.StatusInternalServerError, gin.H{"error": "用户保存失败"})
       return
    }

    c.JSON(http.StatusOK, gin.H{
       "message": "注册成功",
       "user_id": user.ID,
    })
}

我们这样做的好处:

优点 说明
结构清晰 请求参数和控制器分离,职责明确
可复用 多个接口可以复用同一个结构体
更好维护 结构体太多时,便于统一查看和管理
支持多版本 后续接口版本升级也可以版本化结构体文件夹(如 request/v1

那同样的我们是不是也可以把返回响应也一样拆分封装一下呢?

示例: 统一响应工具

我们新建工具 utils/response.go 然后我们写入以下内容:

go 复制代码
package utils

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

func Success(c *gin.Context, data interface{}) {
    c.JSON(http.StatusOK, gin.H{
       "code": 0,
       "msg":  "success",
       "data": data,
    })
}

func Fail(c *gin.Context, msg string) {
    c.JSON(http.StatusBadRequest, gin.H{
       "code": -1,
       "msg":  msg,
    })
}

然后我们就可以修改下我们的控制器里面的返回信息逻辑:

go 复制代码
func Register(c *gin.Context) {
    var req request.RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
       // 使用 validator 类型断言
       var ve validator.ValidationErrors
       if errors.As(err, &ve) {
          fieldMap := map[string]string{
             "Name": "用户名",
             "Age":  "年龄",
          }
          msg := utils.TranslateValidationError(ve, fieldMap)
          utils.Fail(c, msg)
       } else {
          // 其他绑定错误,如 JSON 格式错误
          utils.Fail(c, "参数格式不正确")
       }
       return
    }

    user := model.User{
       Name: req.Name,
       Age:  req.Age,
    }

    if err := config.DB.Create(&user).Error; err != nil {
       utils.Fail(c, "用户保存失败")
       return
    }

    utils.Success(c, gin.H{
       "user_id": user.ID,
    })
}

请求 & 响应对称管理结构示意图:

go 复制代码
gin-learn-notes/
├── controller/
│   └── user.go             // 控制器
├── request/
│   └── user_request.go     // 请求结构体
├── utils/
│   └── response.go         // 响应工具

最终效果(前后端接口风格):

成功:

json 复制代码
{
  "code": 0,
  "msg": "success",
  "data": {
    "user_id": 1
  }
}

错误:

json 复制代码
{
  "code": -1,
  "msg": "用户名是必填字段"
}

请求结构体和响应结构体都拆了,那控制器就"标准"了吗?

在刚才的结构优化中,我们已经把控制器中用到的:

  • 请求参数结构体(如 RegisterRequest
  • 返回响应的数据结构(如 RegisterResponse

都统一拆分到了 request/response/ 目录中,控制器看起来清爽多了。

那现在我们来看看,当前的 Register 控制器是不是就已经很标准了呢?

我们回顾一下这段逻辑中的一部分:

go 复制代码
func Register(c *gin.Context) {
    var req request.RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
       // 使用 validator 类型断言
       var ve validator.ValidationErrors
       if errors.As(err, &ve) {
          fieldMap := map[string]string{
             "Name": "用户名",
             "Age":  "年龄",
          }
          msg := utils.TranslateValidationError(ve, fieldMap)
          utils.Fail(c, msg)
       } else {
          // 其他绑定错误,如 JSON 格式错误
          utils.Fail(c, "参数格式不正确")
       }
       return
    }

    user := model.User{
       Name: req.Name,
       Age:  req.Age,
    }

    if err := config.DB.Create(&user).Error; err != nil {
       utils.Fail(c, "用户保存失败")
       return
    }

    utils.Success(c, gin.H{
       "user_id": user.ID,
    })
}

我们可以看到在控制器中直接操作了数据库,包括:

  • 构建模型
  • 调用 Create() 插入数据库
  • 处理插入失败逻辑

从工程实践来看,控制器的职责应该是:

只负责接收请求、调用业务逻辑、返回响应结果

❌ 不建议处理复杂的业务或数据库逻辑

所以我们是不是可以将数据库相关的逻辑拆分成一个独立的"服务层"(service),例如:

go 复制代码
gin-learn-notes/
├── service/
│   └── user_service.go   // 负责用户相关操作,如注册、查找、删除等

然后控制器只调用:

go 复制代码
err := service.RegisterUser(req)

这样项目结构就更清晰了:

层级 作用
controller 接收请求,做调度,返回响应
request/response 定义请求 & 响应结构体
service 处理业务逻辑,调用 model & db
model 定义数据库模型

接下来我们就来把数据库逻辑从控制器中解耦出去 。

结构优化目标:引入 service

go 复制代码
gin-learn-notes/
├── controller/
│   └── user.go         ✅ 控制器只负责接收 & 返回
├── service/
│   └── user_service.go ✅ 所有业务逻辑处理写这
├── request/
│   └── user_request.go
├── model/
│   └── user.go
├── utils/
│   └── response.go

首先我们创建 service/user_service.go

go 复制代码
package service

import (
    "gin-learn-notes/config"
    "gin-learn-notes/model"
    "gin-learn-notes/request"
)

func RegisterUser(req request.RegisterRequest) (*model.User, error) {
    user := &model.User{
       Name: req.Name,
       Age:  req.Age,
    }

    if err := config.DB.Create(user).Error; err != nil {
       return nil, err
    }

    return user, nil

}

然后我们修改控制器 controller/user.go

go 复制代码
package controller

import (
    "errors"
    "gin-learn-notes/request"
    "gin-learn-notes/service"
    "gin-learn-notes/utils"
    "github.com/gin-gonic/gin"
    "github.com/go-playground/validator/v10"
)

func Register(c *gin.Context) {
    var req request.RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
       // 使用 validator 类型断言
       var ve validator.ValidationErrors
       if errors.As(err, &ve) {
          fieldMap := map[string]string{
             "Name": "用户名",
             "Age":  "年龄",
          }
          msg := utils.TranslateValidationError(ve, fieldMap)
          utils.Fail(c, msg)
       } else {
          // 其他绑定错误,如 JSON 格式错误
          utils.Fail(c, "参数格式不正确")
       }
       return
    }

    user, err := service.RegisterUser(req)
    if err != nil {
       utils.Fail(c, "保存用户失败:"+err.Error())
    }

    utils.Success(c, gin.H{
       "user_id": user.ID,
    })
}

这样我们的结构就变成这样了

层级 职责
controller 接收请求、解析参数、调用 service、返回响应
service 承担业务逻辑、处理数据库操作或事务
model 定义数据结构(ORM 模型)
request 请求结构体定义
utils 通用工具函数,如响应、加解密、日志等

当前项目结构目录(已模块化整理)

text 复制代码
gin-learn-notes/
├── config/                # 配置模块
│   └── database.go        # 数据库连接与初始化
│
├── controller/            # 控制器层,处理请求与响应
│   ├── hello.go
│   ├── index.go
│   └── user.go            # 用户相关接口(如注册)
│
├── model/                 # 数据模型层,对应数据库表结构
│   └── user.go
│
├── request/               # 请求参数结构体
│   └── user_request.go    # RegisterRequest 等参数定义
│
├── response/              # 响应结构体封装(返回给前端的数据格式)
│   └── response.go
│
├── router/                # 路由统一注册管理
│   └── router.go
│
├── service/               # 业务逻辑处理层(对接 model + 封装逻辑)
│   └── user_service.go
│
├── utils/                 # 工具方法(校验、通用响应等)
│   ├── validator.go       # 参数校验错误翻译函数
│   └── response.go        # 响应封装工具(如统一返回格式)
│
├── main.go                # 项目入口:初始化 + 启动服务
├── go.mod                 # Go Modules 配置文件
├── .gitignore
└── README.md

最后

通过这篇文章,我们已经将一个基础的注册接口完成了结构化拆分,项目结构逐步演进为更清晰、更工程化的组织方式:

  • 请求与响应结构体解耦
  • 控制器仅处理调度逻辑
  • 业务逻辑下沉到 service 层
  • 错误校验提示中文化

这一套目录结构也将作为我们后续开发中通用的模块化基准模板,在这个基础上,我们可以继续扩展中间件、统一响应结构、JWT 鉴权等功能模块。

本篇对应代码提交记录

commit: 1388f925804caa8422142077ea27fb7413641d65

👉 GitHub 源码地址:github.com/luokakale-k...


注册只是第一步,我们还需要补全用户模块的其他常规功能:查询、修改、删除用户等。

下一篇我们将继续用 Gin 实现一个基础的「增删改查」接口集,顺便体验分页、条件筛选等常见接口要素,继续让我们的项目"动"起来!

相关推荐
研究司马懿11 小时前
【云原生】Gateway API高级功能
云原生·go·gateway·k8s·gateway api
梦想很大很大1 天前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰1 天前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘1 天前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤1 天前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt112 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto5 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室6 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题6 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo