本指南解释了我如何简化 Go 微服务中请求、验证和响应的处理,旨在实现简单性、可重用性和更易于维护的代码库。
我在 Go 中使用微服务已经有一段时间了,我一直很欣赏这种语言提供的清晰度和简单性。我最喜欢 Go 的事情之一是幕后没有发生任何事情;代码始终是透明且可预测的。
但是,开发的某些部分可能非常乏味,尤其是在验证和标准化 API 端点中的响应时。我尝试了许多不同的方法来解决这个问题,但最近,在编写 Go 课程时,我想到了一个相当意想不到的想法。这个想法给我的训练员增添了一丝“魔力”,令我惊讶的是,我喜欢它。通过这个解决方案,我能够集中请求的验证、解码和参数解析的所有逻辑,并统一 API 的编码和响应。最终,我在保持代码清晰性和减少重复实现之间找到了平衡。
开发 Go 微服务时,一项常见任务是有效处理传入的 HTTP 请求。此过程通常涉及解析请求主体、提取参数、验证数据以及发回一致的响应。让我用一个例子来说明这个问题:
package main import ( "encoding/json" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-playground/validator/v10" "log" "net/http" ) type SampleRequest struct { Name string `json:"name" validate:"required,min=3"` Age int `json:"age" validate:"required,min=1"` } var validate = validator.New() type ValidationErrors struct { Errors map[string][]string `json:"errors"` } func main() { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Post("/submit/{name}", func(w http.ResponseWriter, r *http.Request) { sampleReq := &SampleRequest{} // Set the path parameter name := chi.URLParam(r, "name") if name == "" { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "name is required", }) return } sampleReq.Name = name // Parse and decode the JSON body if err := json.NewDecoder(r.Body).Decode(sampleReq); err != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "Invalid JSON format", }) return } // Validate the request if err := validate.Struct(sampleReq); err != nil { validationErrors := make(map[string][]string) for _, err := range err.(validator.ValidationErrors) { fieldName := err.Field() validationErrors[fieldName] = append(validationErrors[fieldName], err.Tag()) } w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "Validation error", "body": ValidationErrors{Errors: validationErrors}, }) return } // Send success response w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusOK, "message": "Request received successfully", "body": sampleReq, }) }) log.Println("Starting server on :8080") http.ListenAndServe(":8080", r) }
让我解释一下上面的代码,重点是我们手动处理的处理程序部分:
虽然代码有效,但它涉及大量的样板逻辑,必须为每个新端点重复这些逻辑,这使得维护更加困难并且容易出现不一致。
那么,我们该如何改进呢?
为了解决这个问题并提高代码的可维护性,我决定将逻辑分为三个不同的层:请求、响应和验证。这种方法封装了每个部分的逻辑,使其可重用且更易于独立测试。
Request 层负责从传入的 HTTP 请求中解析和提取数据。通过隔离这个逻辑,我们可以标准化数据的处理方式,并确保所有解析得到统一处理。
验证层仅专注于根据预定义规则验证解析的数据。这使验证逻辑与请求处理分开,使其在不同端点之间更易于维护和重用。
Response 层处理程序响应的构造和格式化。通过集中响应逻辑,我们可以确保所有 API 响应遵循一致的结构,从而简化调试并改进客户端交互。
所以......虽然将代码分成层可以带来诸如可重用性、可测试性和可维护性之类的好处,但它也带来了一些权衡。复杂性的增加可能会使新开发人员更难掌握项目结构,而对于简单的端点,使用单独的层可能会让人感觉过度,可能会导致过度设计。了解这些优点和缺点有助于决定何时有效应用此模式。
在一天结束的时候,总是关于最困扰你的事情。正确的?所以,现在让我们介入我们的旧代码并开始实现上面提到的层。
首先,我们重构代码,将请求解析封装到专用的函数或模块中。该层仅专注于读取和解析请求主体,确保其与处理程序中的其他职责分离。
创建一个新文件httpsuite/request.go:
package main import ( "encoding/json" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-playground/validator/v10" "log" "net/http" ) type SampleRequest struct { Name string `json:"name" validate:"required,min=3"` Age int `json:"age" validate:"required,min=1"` } var validate = validator.New() type ValidationErrors struct { Errors map[string][]string `json:"errors"` } func main() { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Post("/submit/{name}", func(w http.ResponseWriter, r *http.Request) { sampleReq := &SampleRequest{} // Set the path parameter name := chi.URLParam(r, "name") if name == "" { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "name is required", }) return } sampleReq.Name = name // Parse and decode the JSON body if err := json.NewDecoder(r.Body).Decode(sampleReq); err != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "Invalid JSON format", }) return } // Validate the request if err := validate.Struct(sampleReq); err != nil { validationErrors := make(map[string][]string) for _, err := range err.(validator.ValidationErrors) { fieldName := err.Field() validationErrors[fieldName] = append(validationErrors[fieldName], err.Tag()) } w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "Validation error", "body": ValidationErrors{Errors: validationErrors}, }) return } // Send success response w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusOK, "message": "Request received successfully", "body": sampleReq, }) }) log.Println("Starting server on :8080") http.ListenAndServe(":8080", r) }
注意:此时,我不得不使用反射。也许我太愚蠢了,找不到更好的等待去做。 ?
当然,我们也可以测试这个,创建测试文件httpsuite/request_test.go:
package httpsuite import ( "encoding/json" "errors" "github.com/go-chi/chi/v5" "net/http" "reflect" ) // RequestParamSetter defines the interface used to set the parameters to the HTTP request object by the request parser. // Implementing this interface allows custom handling of URL parameters. type RequestParamSetter interface { // SetParam assigns a value to a specified field in the request struct. // The fieldName parameter is the name of the field, and value is the value to set. SetParam(fieldName, value string) error } // ParseRequest parses the incoming HTTP request into a specified struct type, handling JSON decoding and URL parameters. // It validates the parsed request and returns it along with any potential errors. // The pathParams variadic argument allows specifying URL parameters to be extracted. // If an error occurs during parsing, validation, or parameter setting, it responds with an appropriate HTTP status. func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, pathParams ...string) (T, error) { var request T var empty T defer func() { _ = r.Body.Close() }() if r.Body != http.NoBody { if err := json.NewDecoder(r.Body).Decode(&request); err != nil { SendResponse[any](w, "Invalid JSON format", http.StatusBadRequest, nil) return empty, err } } // If body wasn't parsed request may be nil and cause problems ahead if isRequestNil(request) { request = reflect.New(reflect.TypeOf(request).Elem()).Interface().(T) } // Parse URL parameters for _, key := range pathParams { value := chi.URLParam(r, key) if value == "" { SendResponse[any](w, "Parameter "+key+" not found in request", http.StatusBadRequest, nil) return empty, errors.New("missing parameter: " + key) } if err := request.SetParam(key, value); err != nil { SendResponse[any](w, "Failed to set field "+key, http.StatusInternalServerError, nil) return empty, err } } // Validate the combined request struct if validationErr := IsRequestValid(request); validationErr != nil { SendResponse[ValidationErrors](w, "Validation error", http.StatusBadRequest, validationErr) return empty, errors.New("validation error") } return request, nil } func isRequestNil(i interface{}) bool { return i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil()) }
如您所见,请求层使用验证层。然而,我仍然希望在代码中保持层的分离,不仅是为了更容易维护,而且因为我可能还想使用隔离的验证层。
根据需要,将来我可能会决定将所有层保持隔离,并通过使用一些接口来允许其相互依赖。
一旦请求解析被分离,我们就创建一个独立的验证函数或模块来处理验证逻辑。通过隔离此逻辑,我们可以轻松测试它并跨多个端点应用一致的验证规则。
为此,我们创建 httpsuite/validation.go 文件:
package main import ( "encoding/json" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-playground/validator/v10" "log" "net/http" ) type SampleRequest struct { Name string `json:"name" validate:"required,min=3"` Age int `json:"age" validate:"required,min=1"` } var validate = validator.New() type ValidationErrors struct { Errors map[string][]string `json:"errors"` } func main() { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Post("/submit/{name}", func(w http.ResponseWriter, r *http.Request) { sampleReq := &SampleRequest{} // Set the path parameter name := chi.URLParam(r, "name") if name == "" { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "name is required", }) return } sampleReq.Name = name // Parse and decode the JSON body if err := json.NewDecoder(r.Body).Decode(sampleReq); err != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "Invalid JSON format", }) return } // Validate the request if err := validate.Struct(sampleReq); err != nil { validationErrors := make(map[string][]string) for _, err := range err.(validator.ValidationErrors) { fieldName := err.Field() validationErrors[fieldName] = append(validationErrors[fieldName], err.Tag()) } w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusBadRequest, "message": "Validation error", "body": ValidationErrors{Errors: validationErrors}, }) return } // Send success response w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "code": http.StatusOK, "message": "Request received successfully", "body": sampleReq, }) }) log.Println("Starting server on :8080") http.ListenAndServe(":8080", r) }
现在,创建测试文件httpsuite/validation_test.go:
package httpsuite import ( "encoding/json" "errors" "github.com/go-chi/chi/v5" "net/http" "reflect" ) // RequestParamSetter defines the interface used to set the parameters to the HTTP request object by the request parser. // Implementing this interface allows custom handling of URL parameters. type RequestParamSetter interface { // SetParam assigns a value to a specified field in the request struct. // The fieldName parameter is the name of the field, and value is the value to set. SetParam(fieldName, value string) error } // ParseRequest parses the incoming HTTP request into a specified struct type, handling JSON decoding and URL parameters. // It validates the parsed request and returns it along with any potential errors. // The pathParams variadic argument allows specifying URL parameters to be extracted. // If an error occurs during parsing, validation, or parameter setting, it responds with an appropriate HTTP status. func ParseRequest[T RequestParamSetter](w http.ResponseWriter, r *http.Request, pathParams ...string) (T, error) { var request T var empty T defer func() { _ = r.Body.Close() }() if r.Body != http.NoBody { if err := json.NewDecoder(r.Body).Decode(&request); err != nil { SendResponse[any](w, "Invalid JSON format", http.StatusBadRequest, nil) return empty, err } } // If body wasn't parsed request may be nil and cause problems ahead if isRequestNil(request) { request = reflect.New(reflect.TypeOf(request).Elem()).Interface().(T) } // Parse URL parameters for _, key := range pathParams { value := chi.URLParam(r, key) if value == "" { SendResponse[any](w, "Parameter "+key+" not found in request", http.StatusBadRequest, nil) return empty, errors.New("missing parameter: " + key) } if err := request.SetParam(key, value); err != nil { SendResponse[any](w, "Failed to set field "+key, http.StatusInternalServerError, nil) return empty, err } } // Validate the combined request struct if validationErr := IsRequestValid(request); validationErr != nil { SendResponse[ValidationErrors](w, "Validation error", http.StatusBadRequest, validationErr) return empty, errors.New("validation error") } return request, nil } func isRequestNil(i interface{}) bool { return i == nil || (reflect.ValueOf(i).Kind() == reflect.Ptr && reflect.ValueOf(i).IsNil()) }
最后,我们将响应构造重构为一个单独的模块。这可确保所有响应遵循一致的格式,从而更轻松地管理和调试整个应用程序中的响应。
创建文件httpsuite/response.go:
package httpsuite import ( "bytes" "context" "encoding/json" "errors" "fmt" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "log" "net/http" "net/http/httptest" "strconv" "strings" "testing" ) // TestRequest includes custom type annotation for UUID type TestRequest struct { ID int `json:"id" validate:"required"` Name string `json:"name" validate:"required"` } func (r *TestRequest) SetParam(fieldName, value string) error { switch strings.ToLower(fieldName) { case "id": id, err := strconv.Atoi(value) if err != nil { return errors.New("invalid id") } r.ID = id default: log.Printf("Parameter %s cannot be set", fieldName) } return nil } func Test_ParseRequest(t *testing.T) { testSetURLParam := func(r *http.Request, fieldName, value string) *http.Request { ctx := chi.NewRouteContext() ctx.URLParams.Add(fieldName, value) return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) } type args struct { w http.ResponseWriter r *http.Request pathParams []string } type testCase[T any] struct { name string args args want *TestRequest wantErr assert.ErrorAssertionFunc } tests := []testCase[TestRequest]{ { name: "Successful Request", args: args{ w: httptest.NewRecorder(), r: func() *http.Request { body, _ := json.Marshal(TestRequest{Name: "Test"}) req := httptest.NewRequest("POST", "/test/123", bytes.NewBuffer(body)) req = testSetURLParam(req, "ID", "123") req.Header.Set("Content-Type", "application/json") return req }(), pathParams: []string{"ID"}, }, want: &TestRequest{ID: 123, Name: "Test"}, wantErr: assert.NoError, }, { name: "Missing body", args: args{ w: httptest.NewRecorder(), r: func() *http.Request { req := httptest.NewRequest("POST", "/test/123", nil) req = testSetURLParam(req, "ID", "123") req.Header.Set("Content-Type", "application/json") return req }(), pathParams: []string{"ID"}, }, want: nil, wantErr: assert.Error, }, { name: "Missing Path Parameter", args: args{ w: httptest.NewRecorder(), r: func() *http.Request { req := httptest.NewRequest("POST", "/test", nil) req.Header.Set("Content-Type", "application/json") return req }(), pathParams: []string{"ID"}, }, want: nil, wantErr: assert.Error, }, { name: "Invalid JSON Body", args: args{ w: httptest.NewRecorder(), r: func() *http.Request { req := httptest.NewRequest("POST", "/test/123", bytes.NewBufferString("{invalid-json}")) req = testSetURLParam(req, "ID", "123") req.Header.Set("Content-Type", "application/json") return req }(), pathParams: []string{"ID"}, }, want: nil, wantErr: assert.Error, }, { name: "Validation Error for body", args: args{ w: httptest.NewRecorder(), r: func() *http.Request { body, _ := json.Marshal(TestRequest{}) req := httptest.NewRequest("POST", "/test/123", bytes.NewBuffer(body)) req = testSetURLParam(req, "ID", "123") req.Header.Set("Content-Type", "application/json") return req }(), pathParams: []string{"ID"}, }, want: nil, wantErr: assert.Error, }, { name: "Validation Error for zero ID", args: args{ w: httptest.NewRecorder(), r: func() *http.Request { body, _ := json.Marshal(TestRequest{Name: "Test"}) req := httptest.NewRequest("POST", "/test/0", bytes.NewBuffer(body)) req = testSetURLParam(req, "ID", "0") req.Header.Set("Content-Type", "application/json") return req }(), pathParams: []string{"ID"}, }, want: nil, wantErr: assert.Error, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseRequest[*TestRequest](tt.args.w, tt.args.r, tt.args.pathParams...) if !tt.wantErr(t, err, fmt.Sprintf("parseRequest(%v, %v, %v)", tt.args.w, tt.args.r, tt.args.pathParams)) { return } assert.Equalf(t, tt.want, got, "parseRequest(%v, %v, %v)", tt.args.w, tt.args.r, tt.args.pathParams) }) } }
创建测试文件httpsuite/response_test.go:
package httpsuite import ( "errors" "github.com/go-playground/validator/v10" ) // ValidationErrors represents a collection of validation errors for an HTTP request. type ValidationErrors struct { Errors map[string][]string `json:"errors,omitempty"` } // NewValidationErrors creates a new ValidationErrors instance from a given error. // It extracts field-specific validation errors and maps them for structured output. func NewValidationErrors(err error) *ValidationErrors { var validationErrors validator.ValidationErrors errors.As(err, &validationErrors) fieldErrors := make(map[string][]string) for _, vErr := range validationErrors { fieldName := vErr.Field() fieldError := fieldName + " " + vErr.Tag() fieldErrors[fieldName] = append(fieldErrors[fieldName], fieldError) } return &ValidationErrors{Errors: fieldErrors} } // IsRequestValid validates the provided request struct using the go-playground/validator package. // It returns a ValidationErrors instance if validation fails, or nil if the request is valid. func IsRequestValid(request any) *ValidationErrors { validate := validator.New(validator.WithRequiredStructEnabled()) err := validate.Struct(request) if err != nil { return NewValidationErrors(err) } return nil }
此重构的每一步都允许我们通过将特定职责委托给定义良好的层来简化处理程序逻辑。虽然我不会在每个步骤中显示完整的代码,但这些更改涉及将解析、验证和响应逻辑移动到各自的函数或文件中。
现在,我们需要更改旧代码以使用图层,让我们看看它会是什么样子。
package httpsuite import ( "github.com/go-playground/validator/v10" "testing" "github.com/stretchr/testify/assert" ) type TestValidationRequest struct { Name string `validate:"required"` Age int `validate:"required,min=18"` } func TestNewValidationErrors(t *testing.T) { validate := validator.New() request := TestValidationRequest{} // Missing required fields to trigger validation errors err := validate.Struct(request) if err == nil { t.Fatal("Expected validation errors, but got none") } validationErrors := NewValidationErrors(err) expectedErrors := map[string][]string{ "Name": {"Name required"}, "Age": {"Age required"}, } assert.Equal(t, expectedErrors, validationErrors.Errors) } func TestIsRequestValid(t *testing.T) { tests := []struct { name string request TestValidationRequest expectedErrors *ValidationErrors }{ { name: "Valid request", request: TestValidationRequest{Name: "Alice", Age: 25}, expectedErrors: nil, // No errors expected for valid input }, { name: "Missing Name and Age below minimum", request: TestValidationRequest{Age: 17}, expectedErrors: &ValidationErrors{ Errors: map[string][]string{ "Name": {"Name required"}, "Age": {"Age min"}, }, }, }, { name: "Missing Age", request: TestValidationRequest{Name: "Alice"}, expectedErrors: &ValidationErrors{ Errors: map[string][]string{ "Age": {"Age required"}, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := IsRequestValid(tt.request) if tt.expectedErrors == nil { assert.Nil(t, errs) } else { assert.NotNil(t, errs) assert.Equal(t, tt.expectedErrors.Errors, errs.Errors) } }) } }
通过将处理程序代码重构为请求解析、验证和响应格式化层,我们成功删除了之前嵌入处理程序本身的重复逻辑。这种模块化方法不仅提高了可读性,而且通过保持每个职责的重点和可重用性来增强可维护性和可测试性。现在,处理程序已得到简化,开发人员可以轻松理解和修改特定层,而不会影响整个流程,从而创建更清晰、更具可扩展性的代码库。
我希望这份关于使用专用请求、验证和响应层构建 Go 微服务的分步指南能够为创建更清晰、更可维护的代码提供深入见解。我很想听听您对这种方法的想法。我错过了什么重要的事情吗?您将如何在自己的项目中扩展或改进这个想法?
我鼓励您探索源代码并直接在项目中使用httpsuite。您可以在 rluders/httpsuite 存储库中找到该库。您的反馈和贡献对于使这个库对 Go 社区更加强大和有用来说非常宝贵。
大家下期见。
以上是改进 Go 微服务中的请求、验证和响应处理的详细内容。更多信息请关注PHP中文网其他相关文章!