前言
在上一篇中,我们实现了将用户注册信息写入 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 实现一个基础的「增删改查」接口集,顺便体验分页、条件筛选等常见接口要素,继续让我们的项目"动"起来!