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 实现一个基础的「增删改查」接口集,顺便体验分页、条件筛选等常见接口要素,继续让我们的项目"动"起来!

相关推荐
我是前端小学生1 小时前
面试官:在go语言中,主协程如何等待其余协程完毕再操作?
go
关山月2 小时前
学习Go语言:循环和条件语句
go
我是前端小学生2 小时前
面试官:在go语言中,使用for select时,如果通道已经关闭会怎么样?如果只有一个case呢?
go
我是前端小学生2 小时前
面试官:在go语言中,在 for range 循环中对切片(slice)使用append操作,会造成无限循环吗?
go
三块钱07949 小时前
【原创】通过S3接口将海量文件索引导入elasticsearch
大数据·elasticsearch·搜索引擎·go
一个热爱生活的普通人19 小时前
JWT认证:在gin服务中构建安全的API接口
后端·go·gin
洛卡卡了21 小时前
Gin 框架学习实录 · 第3篇:集成 GORM + MySQL,实现注册用户入库
go
Pandaconda1 天前
【新人系列】Golang 入门(七):闭包详解
开发语言·经验分享·笔记·后端·golang·go·闭包
forever231 天前
kubebuilder创建k8s operator项目(下)
go