Go 優秀實踐:請求參數校驗方法設計和實現
本節課會詳細介紹下 gRPC 請求的請求參數校驗邏輯實現。
一、為什么要 API 接口請求參數
對 API 請求參數進行校驗是 Web 開發中需要實現的一個核心功能之一,它不僅能夠提升系統的可靠性,還可以提高用戶體驗、數據安全性以及代碼的可維護性。以下是具體原因的介紹:
(1) 保證系統的穩定性:API 接收到的請求參數可能從客戶端或第三方應用發起,這些參數可能由于客戶端開發錯誤、意外修改或惡意構造而不符合預期。如果未對請求參數進行校驗,可能導致系統邏輯錯誤,甚至出現程序崩潰,從而影響服務的可用性。例如:未校驗分頁參數,可能引發數據庫查詢性能急劇下滑,如負數頁碼或極大的 limit;
(2) 確保數據的合法性和完整性:無論是前端用戶輸入還是對接方系統調用,都有可能提交不符合業務要求的數據,如必填字段缺失、字符串格式不正確、超出預期范圍等。如果直接寫入數據庫或業務邏輯處理,可能會產生錯誤數據,導致后續問題難以排查;
(3) 增強用戶體驗:不進行參數校驗,錯誤通常會發生在邏輯處理階段(如存儲數據庫層或業務邏輯層),錯誤提示可能與用戶的實際問題無關,而是以難以理解的系統錯誤呈現。這不僅難以定位問題,還會讓用戶感到困惑。通過校驗參數,可以在第一時間返回清晰的錯誤信息,告訴用戶問題所在,改善用戶體驗。例如"username"字段為空時提示:"用戶名不能為空";
(4) 維護代碼的清晰性和可維護性:沒有參數校驗的代碼通常需要開發者在業務邏輯部分反復進行參數檢查,例如空值判斷、格式驗證、一層層的數據過濾,這會導致業務邏輯代碼雜亂且難以維護。通過集中化參數校驗:
- 將參數校驗邏輯從業務邏輯中剝離,保持代碼簡潔;
- 參數檢查可以在控制器層完成,使核心業務處理代碼得到解耦。
(5) 服務端可信性原則:在開發中,應遵循“永遠不要完全信任客戶端”的原則。即使在客戶端已做校驗(如前端的表單必填檢查),也必須在后端進行校驗。
對 API 請求參數進行校驗,最核心的目的是提升系統的健壯性和安全性,提供良好的用戶體驗并減少錯誤傳播。在 Go 項目開發中,服務端必須對所有來自客戶端的數據進行嚴格校驗,確保系統處于受控狀態。
二、API 接口請求參數校驗方法
API 接口請求參數校驗方法有多種。本節會介紹這些校驗方法,并結合真實場景下的請求參數校驗需求,實現 miniblog 的請求參數校驗方法。具體來說,有以下幾種請求參數校驗方法:
- 手動校驗;
- 第三方校驗庫;
- 使用 Web 框架內置校驗功能;
- 基于工具生成校驗代碼;
- 中間件校驗。
在實際開發中,不少開發者會同時使用上述校驗方法中的兩種或更多種,導致項目的校驗方式不夠規范和統一,從而增加了代碼閱讀的難度,降低了開發效率,并提高了維護成本。導致同時使用多種校驗方式的原因有多方面,例如項目缺乏統一的校驗規范,開發者隨意選擇自己偏好的校驗方法,或者現有的校驗方式在形式和功能上無法完全滿足項目的實際需求。
所以,miniblog 項目結合實際 Go 項目開發中的業務校驗場景,設計一種更加通用和標準化的 API 接口請求參數校驗方法。
1. 手動校驗
手動校驗指的是直接在代碼中判斷參數是否合法。這種方法適用于簡單的項目,不需要引入額外工具或包,但維護成本較高,不適合復雜的項目。
代碼清單 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")
}
2. 第三方校驗庫
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!")
}
}
使用第三方驗證庫校驗,優點是可以直接復用現成的校驗邏輯,并且直接基于結構體標簽來進行驗證,更加高效,代碼更加簡潔。但缺點是缺乏靈活性,難以滿足復雜的校驗場景。
3. 使用 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 框架自帶的校驗功能,簡單便捷,但無法滿足復雜的校驗場景。
4. 基于工具生成校驗代碼
在一些大型項目中,可以使用工具自動生成校驗規則(例如基于 OpenAPI/Swagger 的定義),通過自動化的方式生成校驗邏輯,減少手動編寫的工作量,提高開發效率和代碼一致性。常用的工具包括:
- OpenAPI Generator:支持根據 OpenAPI 描述生成 Go 代碼,包括參數校驗邏輯;
- gqlgen(GraphQL 工具):自動生成 API 代碼,其中包含參數校驗等功能。
使用工具生成校驗代碼優點是簡單便捷,開發工作量小。但缺點也是無法滿足真實企業應用開發中,遇到的復雜校驗場景。
5. 中間件校驗
在某些情況下,可以將校驗邏輯抽象為中間件處理,比如 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 項目開發中,對于接口請求參數校驗方法的的一般訴求如下:
- 支持自定義復雜校驗邏輯:能夠根據具體需求定義復雜的參數校驗規則。這些規則可能超出簡單的數據長度或大小校驗的范疇,例如需要通過查詢數據庫驗證記錄是否存在,或依賴與第三方微服務的交互來完成復雜的校驗邏輯;
- 復用已有的參數校驗邏輯:支持將某個參數的校驗邏輯封裝并復用。例如,用戶密碼的校驗邏輯在創建用戶時需要用到,修改用戶密碼時同樣適用。這種情況下,校驗規則應在不同接口間保持一致性,避免重復實現;
- 靈活通用的校驗方式:允許根據不同場景靈活調整校驗邏輯,使請求參數校驗更具通用性,適應多樣化的需求場景,提升開發效率與代碼維護性。
- 校驗方式簡單易維護:校驗方式需要簡單,并且容易維護。
基于上述需求,miniblog 項目設計了以下請求參數校驗方案:
- 校驗方式易維護:項目中所有 API 請求參數校驗邏輯集中保存在 internal/apiserver/pkg/validation 目錄下。不同資源的校驗邏輯保存在不同的源碼文件中,便于查閱和維護各資源的校驗邏輯。
- 校驗方式標準化:所有請求接口的校驗函數聲明為統一的規范格式,例如:Validate<請求參數結構體名>(ctx context.Context, rq *apiv1.<請求參數結構體名>) error;
- 支持自定義校驗邏輯:通過創建專門的校驗類型,將數據庫連接、第三方微服務客戶端、緩存客戶端等依賴實例注入到校驗類型的實例中。在自定義校驗邏輯中,使用這些依賴實例,進行復雜的邏輯校驗;
- 支持靈活的校驗方法:既支持復雜的自定義校驗邏輯,又支持復用某個請求參數的校驗邏輯。
因為請求參數校驗,幾乎是每個接口都需要的功能,所以最理想的情況是通過 Web 中間件來校驗請求參數?;诖嗽O計思路,設計了 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 服務器。由于兩種服務器類型在請求處理中間件層能獲取到的請求信息不同,因此在實現請求參數校驗邏輯時也有所區別。
1. 實現請求參數校驗方法
在 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 接口相同字段的校驗邏輯一致性。
2. 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。
3. 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. 接口測試
為了方便讀者測試功能,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 項目開發提供了實用的參考。