Docs Vault

在 Go 项目开发中,请求处理的另外一个核心处理项是对请求参数进行校验。HTTP 请求的请求参数校验方法在本课程的第 21 讲中,已经介绍过了,本节课不再介绍。


本节课会详细介绍下 gRPC 请求的请求参数校验逻辑实现。


为什么要 API 接口请求参数


对 API 请求参数进行校验是 Web 开发中需要实现的一个核心功能之一,它不仅能够提升系统的可靠性,还可以提高用户体验、数据安全性以及代码的可维护性。以下是具体原因的介绍:

  1. 保证系统的稳定性:API 接收到的请求参数可能从客户端或第三方应用发起,这些参数可能由于客户端开发错误、意外修改或恶意构造而不符合预期。如果未对请求参数进行校验,可能导致系统逻辑错误,甚至出现程序崩溃,从而影响服务的可用性。例如:未校验分页参数,可能引发数据库查询性能急剧下滑,如负数页码或极大的 limit;
  2. 确保数据的合法性和完整性:无论是前端用户输入还是对接方系统调用,都有可能提交不符合业务要求的数据,如必填字段缺失、字符串格式不正确、超出预期范围等。如果直接写入数据库或业务逻辑处理,可能会产生错误数据,导致后续问题难以排查;
  3. 增强用户体验:不进行参数校验,错误通常会发生在逻辑处理阶段(如存储数据库层或业务逻辑层),错误提示可能与用户的实际问题无关,而是以难以理解的系统错误呈现。这不仅难以定位问题,还会让用户感到困惑。通过校验参数,可以在第一时间返回清晰的错误信息,告诉用户问题所在,改善用户体验。例如"username"字段为空时提示:"用户名不能为空";
  4. 维护代码的清晰性和可维护性:没有参数校验的代码通常需要开发者在业务逻辑部分反复进行参数检查,例如空值判断、格式验证、一层层的数据过滤,这会导致业务逻辑代码杂乱且难以维护。通过集中化参数校验:
  5. 将参数校验逻辑从业务逻辑中剥离,保持代码简洁;
  6. 参数检查可以在控制器层完成,使核心业务处理代码得到解耦。
  7. 服务端可信性原则:在开发中,应遵循“永远不要完全信任客户端”的原则。即使在客户端已做校验(如前端的表单必填检查),也必须在后端进行校验。


对 API 请求参数进行校验,最核心的目的是提升系统的健壮性和安全性,提供良好的用户体验并减少错误传播。在 Go 项目开发中,服务端必须对所有来自客户端的数据进行严格校验,确保系统处于受控状态。


API 接口请求参数校验方法


API 接口请求参数校验方法有多种。本节会介绍这些校验方法,并结合真实场景下的请求参数校验需求,实现 miniblog 的请求参数校验方法。具体来说,有以下几种请求参数校验方法:

  1. 手动校验;
  2. 第三方校验库;
  3. 使用 Web 框架内置校验功能;
  4. 基于工具生成校验代码;
  5. 中间件校验。


在实际开发中,不少开发者会同时使用上述校验方法中的两种或更多种,导致项目的校验方式不够规范和统一,从而增加了代码阅读的难度,降低了开发效率,并提高了维护成本。导致同时使用多种校验方式的原因有多方面,例如项目缺乏统一的校验规范,开发者随意选择自己偏好的校验方法,或者现有的校验方式在形式和功能上无法完全满足项目的实际需求。


所以,miniblog 项目结合实际 Go 项目开发中的业务校验场景,设计一种更加通用和标准化的 API 接口请求参数校验方法。


手动校验


手动校验指的是直接在代码中判断参数是否合法。这种方法适用于简单的项目,不需要引入额外工具或包,但维护成本较高,不适合复杂的项目。


代码清单 10-1 展示了一个手动校验的代码示例。

代码清单 10-1 手动校验

package main  
 
import (  
      "errors"  
      "fmt"  
)  
 
type LoginRequest struct {  
      Username string  
      Password string  
}  
 
func validate(req LoginRequest) error {  
      if req.Username == "" {  
             return errors.New("username is required")  
      }  
      if len(req.Password) < 6 {  
             return errors.New("password must be at least 6 characters long")  
      }  
      return nil  
}  
 
func main() {  
      req := LoginRequest{Username: "user", Password: "12345"}  
      if err := validate(req); err != nil {  
             fmt.Println("Validation failed:", err)  
             return  
      }  
      fmt.Println("Validation passed")  
}


第三方校验库


Go 项目有许多成熟且功能强大的第三方参数校验库,这些校验库根据结构体标签来进行字段校验。例如常用的校验库包括:go-playground/validator(常用)、asaskevich/govalidator、ozzo-validation 等。


这些库提供了丰富的校验规则(如必填字段、正则表达式、数值范围等),还支持自定义规则并可自动处理嵌套结构体。


代码清单 10-2 展示了使用使用 go-playground/validator 进行请求参数校验的代码示例。

代码清单 10-2 第三方校验库

package main  
 
import (  
      "fmt"  
      "github.com/go-playground/validator/v10"  
)  
 
type LoginRequest struct {  
      Username string `validate:"required"`          // 必填字段  
      Password string `validate:"required,min=6"`    // 最小长度为6  
      Email    string `validate:"required,email"`    // 必填且必须是邮箱格式  
}  
 
func main() {  
      validate := validator.New() // 创建验证器  
 
      req := LoginRequest{  
             Username: "user",  
             Password: "12345",  
             Email:    "invalid-email",  
      }  
 
      // 校验结构体  
      err := validate.Struct(req)  
      if err != nil {  
             // 获取校验错误并打印  
             for _, err := range err.(validator.ValidationErrors) {  
                   fmt.Printf("Field '%s' failed validation, rule '%s'\n", err.Field(), err.Tag())  
             }  
      } else {  
             fmt.Println("Validation passed!")  
      }  
}


使用第三方验证库校验,优点是可以直接复用现成的校验逻辑,并且直接基于结构体标签来进行验证,更加高效,代码更加简洁。但缺点是缺乏灵活性,难以满足复杂的校验场景。


使用 Web 框架内置校验功能


Gin 框架支持结合 go-playground/validator 的校验,在处理请求数据时,利用 binding 标签可以直接解析和校验。示例代码如代码清单 10-3 所示。

代码清单 10-3 使用 Web 框架内置功能

package main  
 
import (  
      "net/http"  
 
      "github.com/gin-gonic/gin"  
      "github.com/go-playground/validator/v10"  
)  
 
type LoginRequest struct {  
      Username string `json:"username" binding:"required"`  
      Password string `json:"password" binding:"required,min=6"`  
}  
 
func main() {  
      r := gin.Default()  
 
      r.POST("/login", func(c *gin.Context) {  
             var req LoginRequest  
             if err := c.ShouldBindJSON(&req); err != nil {  
                   // 返回校验错误  
                   errs := err.(validator.ValidationErrors)  
                   c.JSON(http.StatusBadRequest, gin.H{"error": errs.Error()})  
                   return  
             }  
 
             // 校验通过  
             c.JSON(http.StatusOK, gin.H{"message": "Login successful"})  
      })  
 
      r.Run()  
}

使用 Web 框架自带的校验功能,简单便捷,但无法满足复杂的校验场景。


基于工具生成校验代码


在一些大型项目中,可以使用工具自动生成校验规则(例如基于 OpenAPI/Swagger 的定义),通过自动化的方式生成校验逻辑,减少手动编写的工作量,提高开发效率和代码一致性。常用的工具包括:

  1. OpenAPI Generator:支持根据 OpenAPI 描述生成 Go 代码,包括参数校验逻辑;
  2. gqlgen(GraphQL 工具):自动生成 API 代码,其中包含参数校验等功能。


使用工具生成校验代码优点是简单便捷,开发工作量小。但缺点也是无法满足真实企业应用开发中,遇到的复杂校验场景。


中间件校验


在某些情况下,可以将校验逻辑抽象为中间件处理,比如 Token 校验、权限校验、固定格式的参数校验等,例如:

func ValidationMiddleware(next http.Handler) http.Handler {  
      return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {  
             if r.Header.Get("X-API-Key") == "" {  
                   http.Error(w, "Missing API Key", http.StatusUnauthorized)  
                   return  
             }  
             next.ServeHTTP(w, r)  
      })  
}


miniblog 请求参数校验设计


在实际的 Go 项目开发中,对于接口请求参数校验方法的的一般诉求如下:

  1. 支持自定义复杂校验逻辑:能够根据具体需求定义复杂的参数校验规则。这些规则可能超出简单的数据长度或大小校验的范畴,例如需要通过查询数据库验证记录是否存在,或依赖与第三方微服务的交互来完成复杂的校验逻辑;
  2. 复用已有的参数校验逻辑:支持将某个参数的校验逻辑封装并复用。例如,用户密码的校验逻辑在创建用户时需要用到,修改用户密码时同样适用。这种情况下,校验规则应在不同接口间保持一致性,避免重复实现;
  3. 灵活通用的校验方式:允许根据不同场景灵活调整校验逻辑,使请求参数校验更具通用性,适应多样化的需求场景,提升开发效率与代码维护性。
  4. 校验方式简单易维护:校验方式需要简单,并且容易维护。


基于上述需求,miniblog 项目设计了以下请求参数校验方案:

  1. 校验方式易维护:项目中所有 API 请求参数校验逻辑集中保存在 internal/apiserver/pkg/validation 目录下。不同资源的校验逻辑保存在不同的源码文件中,便于查阅和维护各资源的校验逻辑。
  2. 校验方式标准化:所有请求接口的校验函数声明为统一的规范格式,例如:Validate<请求参数结构体名>(ctx context.Context, rq *apiv1.<请求参数结构体名>) error;
  3. 支持自定义校验逻辑:通过创建专门的校验类型,将数据库连接、第三方微服务客户端、缓存客户端等依赖实例注入到校验类型的实例中。在自定义校验逻辑中,使用这些依赖实例,进行复杂的逻辑校验;
  4. 支持灵活的校验方法:既支持复杂的自定义校验逻辑,又支持复用某个请求参数的校验逻辑。


因为请求参数校验,几乎是每个接口都需要的功能,所以最理想的情况是通过 Web 中间件来校验请求参数。基于此设计思路,设计了 miniblog 的请求参数校验方案,如图 10-2 所示。

图 10-2 请求参数校验设计

图 10-2 中,定义一个 Validator 结构体类型,结构体类型中包含了自定义请求参数校验需要的各类依赖项。Validator 结构体类型包含了格式如 ValidateXXX(ctx context.Context, rq *apiv1.XXX) error 的请求参数校验方法,用来对名为 XXX 的请求参数结构体类型进行校验。为了提高项目的可维护性,建议 XXX 的命名格式为 <接口名>Request,例如 Login 接口的参数校验方法为:ValidateLoginRequest(ctx context.Context, rq *apiv1.LoginRequest) error。


图 10-2 中,封装了一个通用校验层,通用校验层会解析 Validator 类型的实例,遍历该实例中的所有方法,并提取出格式为 ValidateXXX(ctx context.Context, rq *apiv1.XXX) error 的方法,将这些方法保存在一个 map 类型的变量中,键为请求参数结构体名,值为校验方法本身。


Web 中间件层,通过通用校验层来对接口进行验证。在校验请求参数时,根据请求参数类型名,从通用校验层中查找键为类型名的键值对,并调用值(校验方法)进行参数校验。


通用校验层提供了 ValidateAllFields(obj any, rules Rules) error 函数,该函数支持复用某个请求参数的校验逻辑,下文会详细介绍。


miniblog 请求参数校验实现


上一节介绍了 miniblog 项目的请求参数校验设计方案。本节将详细说明 miniblog 是如何实现这些校验方案的。


miniblog 项目同时支持基于 Gin 框架的 HTTP 服务器和基于 gRPC 框架的 RPC 服务器。由于两种服务器类型在请求处理中间件层能获取到的请求信息不同,因此在实现请求参数校验逻辑时也有所区别。


实现请求参数校验方法


internal/apiserver/pkg/validation/validation.go 文件中,定义了 Validator 结构体类型,该类型包含了自定义校验逻辑中需要的各类依赖项,以及用来校验请求参数的各类校验方法。Validator 结构体类型定义如下:

// Validator 是验证逻辑的实现结构体.
type Validator struct {
    // 有些复杂的验证逻辑,可能需要直接查询数据库
    // 这里只是一个举例,如果验证时,有其他依赖的客户端/服务/资源等,
    // 都可以一并注入进来
    store store.IStore
}

Post 资源相关接口的请求参数校验方法实现位于 internal/apiserver/pkg/validation/post.go 文件中,校验方法实现如代码清单 10-4 所示。

代码清单 10-4 Post 资源请求参数校验方法实现

// ValidateCreatePostRequest 校验 CreatePostRequest 结构体的有效性.
func (v *Validator) ValidateCreatePostRequest(ctx context.Context, rq *apiv1.CreatePostRequest) error {
    return genericvalidation.ValidateAllFields(rq, v.ValidatePostRules())
}

// ValidateUpdatePostRequest 校验更新用户请求.
func (v *Validator) ValidateUpdatePostRequest(ctx context.Context, rq *apiv1.UpdatePostRequest) error {
    return genericvalidation.ValidateAllFields(rq, v.ValidatePostRules())
}

// ValidateDeletePostRequest 校验 DeletePostRequest 结构体的有效性.
func (v *Validator) ValidateDeletePostRequest(ctx context.Context, rq *apiv1.DeletePostRequest) error {
    return genericvalidation.ValidateAllFields(rq, v.ValidatePostRules())
}

// ValidateGetPostRequest 校验 GetPostRequest 结构体的有效性.
func (v *Validator) ValidateGetPostRequest(ctx context.Context, rq *apiv1.GetPostRequest) error {
    return genericvalidation.ValidateAllFields(rq, v.ValidatePostRules())
}

// ValidateListPostRequest 校验 ListPostRequest 结构体的有效性.
func (v *Validator) ValidateListPostRequest(ctx context.Context, rq *apiv1.ListPostRequest) error {
    if rq.Title != nil && len(rq.Title) > 200 {
        return errno.ErrInvalidArgument.WithMessage("title cannot be longer than 200 characters")
    }
    return genericvalidation.ValidateSelectedFields(rq, v.ValidatePostRules(), "Offset", "Limit")
}


代码清单 10-4 实现了 Post 资源 CreatePost、UpdatePost、GetPost、ListPost 接口的请求参数校验逻辑。


ValidateAllFields 函数用来对请求参数中的所有字段进行校验,其中每个字段的校验规则在 ValidatePostRules 方法中设置。ValidatePostRules 方法实现如下:

// Validate 校验字段的有效性.
func (v *Validator) ValidatePostRules() genericvalidation.Rules {
    // 定义各字段的校验逻辑,通过一个 map 实现模块化和简化
    return genericvalidation.Rules{
        "PostID": func(value any) error {
            if value.(string) == "" {
                return errno.ErrInvalidArgument.WithMessage("postID cannot be empty")
            }
            return nil
        },
        "Title": func(value any) error {
            if value.(string) == "" {
                return errno.ErrInvalidArgument.WithMessage("title cannot be empty")
            }
            return nil
        },
        "Content": func(value any) error {
            if value.(string) == "" {
                return errno.ErrInvalidArgument.WithMessage("content cannot be empty")
            }
            return nil
        },
    }
}


代码清单 10-4 的 ValidateListPostRequest 方法调用了 ValidateSelectedFields 函数,该函数只会校验传入的字段 Offset、Limit。apiv1.ListPostRequest 结构体中其他字段,例如 Title 字段的校验,可以自行实现校验逻辑,通过这种方式,允许开发者根据需要选择,哪些字段使用通用的字段校验规则校验,哪些字段自行实现校验逻辑,以此满足复杂的字段校验逻辑。


这里要注意,如果指定了校验 NonExist 字段,但 NonExist 字段没有在 apiv1.ListPostRequest 结构体存在,则 ValidateSelectedFields 函数会跳过 NonExist 字段的校验。


另外,ValidateAllFields、ValidateSelectedFields 函数在校验时,如果结构体中的某个字段不存在对应的校验 Rule,则函数会跳过该字段的校验。通过给字段(例如 PostID、Title、Content)指定相同的校验规则,来保证不同 API 接口相同字段的校验逻辑一致性。


HTTP 请求参数校验


在 Gin 中间件中,无法提前获知 API 的请求参数类型,所以无法实现在中间件中对请求参数进行校验。请求参数的校验,在路由函数中实现。


在 internal/apiserver/server.go 文件中添加以下代码创建请求参数校验实例,代码如下:

import (
    ...
    "github.com/onexstack/miniblog/internal/apiserver/pkg/validation"
    ...
)
...
// ServerConfig 包含服务器的核心依赖和配置.
type ServerConfig struct {
    val *validation.Validator
}
...
// NewServerConfig 创建一个 *ServerConfig 实例.
// 进阶:这里其实可以使用依赖注入的方式,来创建 *ServerConfig.
func (cfg *Config) NewServerConfig() (*ServerConfig, error) {
    ...
    return &ServerConfig{
        ...
        val: validation.New(store),
    }, nil
}


在创建 HTTP Handler 时,传入请求参数校验实例,代码如下:

func (c *ServerConfig) InstallRESTAPI(engine *gin.Engine) {
    ...
    // 创建核心业务处理器
    handler := handler.NewHandler(c.biz, c.val)
    ...
}


在 HTTP Handler 层的方法中传入请求参数校验方法。例如,ListPost 接口 Handler 层代码实现如下:

// ListPosts 列出用户的所有博客帖子.
func (h *Handler) ListPost(c *gin.Context) {
    core.HandleQueryRequest(c, h.biz.PostV1().List, h.val.ValidateListPostRequest)
}


调用 core.HandleQueryRequest 函数时,显式会传入校验方法 ValidateListPostRequest。


gRPC 请求参数校验


gRPC 接口的请求参数校验统一通过 gRPC 拦截器实现。


在 internal/apiserver/grpcserver.go 文件中,新增以下代码,用来在拦截器链中添加请求参数校验拦截器:

import (
    ...
    genericvalidation "github.com/onexstack/onexstack/pkg/validation"
    ...
)
...
func (c *ServerConfig) NewGRPCServerOr() (server.Server, error) {
    serverOptions := []grpc.ServerOption{
        // 注意拦截器顺序!
        grpc.ChainUnaryInterceptor(
            ...
            mw.ValidatorInterceptor(genericvalidation.NewValidator(c.val)),
        ),
    }
    ...
}


上述代码用 genericvalidation.NewValidator 函数创建通用校验层实例。创建通用校验层实例时,会解析传入的请求参数校验实例 c.val,NewValidator 函数会从实例中提取出所有方法声明格式为 ValidateXXX(ctx context.Context, rq *apiv1.XXX) error 的方法,并保存在通用校验层的内部 registry 中。


ValidatorInterceptor 拦截器实现如下:

// ValidatorInterceptor 是一个 gRPC 拦截器,用于对请求进行验证.
func ValidatorInterceptor(validator RequestValidator) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, rq any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        // 调用自定义验证方法
        if err := validator.Validate(ctx, rq); err != nil {
            // 注意这里不用返回 errno.ErrInvalidArgument 类型的错误信息,由 validator.Validate 返回.
            return nil, err // 返回验证错误
        }

        // 继续处理请求
        return handler(ctx, rq)
    }
}


在 ValidatorInterceptor 拦截器中,会调用通用校验层实例的 Validate 方法,Validate 方法实现代码如下所示:

// Validate validates the request using the appropriate validation method.
func (v *Validator) Validate(ctx context.Context, request any) error {
    validationFunc, ok := v.registry[reflect.TypeOf(request).Elem().Name()]
    if !ok {
        return nil // No validation function found for the request type
    }

    result := validationFunc.Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(request)})
    if !result[0].IsNil() {
        return result[0].Interface().(error)
    }

    return nil
}


Validate 方法会从通用校验层实例的 registry 中查找键为 gRPC 接口请求参数结构体名称(例如 LoginRequest)的记录。如果找到,说明该请求参数结构体已经指定了自定义的请求参数校验方法,执行注册的校验方法进行请求参数校验。否则,不执行校验逻辑。


至此,请求参数校验代码开发完成,完整代码见 feature/s22 分支。


请求处理测试


至此,我们已经实现了 miniblog 的核心逻辑。本节就来测试下这些功能是否正常可用。测试内容包括以下几部分:

  1. 接口测试:测试健康检查接口、用户接口、博客接口是否可以正常工作;
  2. 请求处理功能测试:测试请求参数默认值设置、请求参数校验功能是否可用。


(1)接口测试


为了方便读者测试功能,miniblog 项目已经提前编写好了接口测试代码。运行以下命令来分别来测试健康检查接口、用户接口、博客接口:


修改 $HOME/.miniblog/mb-apiserver.yaml 文件,将 server-mode 设置为 grpc-gateway。


打开一个 Linux 终端,运行以下命令启动 mb-apiserver 服务:

$ make build BINS=mb-apiserver
$ _output/platforms/linux/amd64/mb-apiserver


打开另一个 Linux 终端,运行以下命令分别测试健康检查接口、用户接口、博客接口:

$ go run examples/client/health/main.go # 测试健康检查接口
{"timestamp":"2025-02-01 16:38:08"}
$ go run examples/client/user/main.go # 测试用户相关接口
2025/02/01 16:38:22 [CreateUser     ] Success to create user, userID: user-die7iy
...
2025/02/01 16:38:22 [DeleteUser     ] Success to delete user: user-die7iy
2025/02/01 16:38:22 [All            ] Success to test all user api
$ go run examples/client/post/main.go # 测试博客相关接口
2025/02/01 16:38:51 [CreateUser     ] Success to create user, userID: user-die7iy
...
2025/02/01 16:38:51 [All            ] Success to test all post api
2025/02/01 16:38:51 [Login          ] Success to login with root account


运行上述测试代码,日志输出中没有错误,说明接口功能正常。


(2)请求处理功能测试


运行以下命令测试请求处理功能是否正常工作:

$ go run examples/client/reqprocess/main.go # 测试请求处理功能
2025/02/01 16:39:17 [CreateUser     ] Success to create user, userID: user-die7iy
2025/02/01 16:39:17 [Login          ] Success to login
2025/02/01 16:39:17 [GetUser        ] Success in testing request parameter default value setting
2025/02/01 16:39:17 [GetUser        ] Success in testing request parameter validation


小结(AI 自动生成并人工审核)


本文详细介绍了在 Go 项目开发中如何实现 API 接口请求参数的校验逻辑,并以 miniblog 项目为例进行了实践。


文章首先阐述了对请求参数进行校验的重要性,强调其在提升系统稳定性、确保数据合法性、增强用户体验以及提高代码可维护性等方面的作用。随后,文章分析了常见的参数校验方法,包括手动校验、第三方库校验、框架内置校验、工具生成校验代码以及中间件校验等,并指出实际开发中可能因使用多种校验方式导致的规范性问题。


基于此,miniblog 项目设计了一种标准化、灵活且易维护的参数校验方案,采用统一的校验接口格式,并通过通用校验层实现了复杂校验逻辑的支持和复用。


具体实现中,miniblog 针对 HTTP 和 gRPC 请求分别设计了对应的校验机制,其中 HTTP 请求在路由层实现校验,gRPC 请求则通过拦截器完成参数验证。


最后,文章通过接口测试和请求处理功能测试验证了参数校验方案的正确性与可靠性,为 Go 项目开发提供了实用的参考。