このガイドでは、シンプルさ、再利用性、より保守しやすいコードベースを目指して、Go マイクロサービスでのリクエスト、検証、応答の処理をどのように合理化したかについて説明します。
私はかなり長い間 Go でマイクロサービスを使ってきましたが、この言語が提供する明快さとシンプルさにいつも感謝しています。私が Go で最も気に入っている点の 1 つは、舞台裏では何も起こらないことです。コードは常に透過的で予測可能です。
ただし、開発の一部の部分は、特に API エンドポイントでの応答の検証と標準化に関しては、非常に面倒な場合があります。これに取り組むためにさまざまなアプローチを試してきましたが、最近、囲碁コースを書いているときに、かなり予想外のアイデアを思いつきました。このアイデアは私のハンドラーにちょっとした「魔法」を加えました。そして、驚いたことに、私はそれを気に入りました。このソリューションにより、リクエストの検証、デコード、パラメータ解析のためのすべてのロジックを一元化し、API のエンコードと応答を統一することができました。最終的に、コードの明瞭性の維持と反復的な実装の削減との間のバランスを見つけました。
Go マイクロサービスを開発するときの一般的なタスクの 1 つは、受信した 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) }
手動で処理するハンドラー部分に焦点を当てて、上記のコードを説明します。
コードは機能しますが、新しいエンドポイントごとに繰り返す必要がある大量の定型ロジックが含まれるため、保守が難しくなり、不整合が発生しやすくなります。
それでは、どうすればこれを改善できるでしょうか?
この問題に対処し、コードの保守性を向上させるために、ロジックを リクエスト、レスポンス、および 検証の 3 つの異なる層に分割することにしました。このアプローチでは、各部分のロジックがカプセル化され、再利用可能になり、独立したテストが容易になります。
Request レイヤーは、受信した HTTP リクエストからデータを解析して抽出する役割を果たします。このロジックを分離することで、データの処理方法を標準化し、すべての解析が均一に処理されるようにすることができます。
Validation レイヤーは、事前定義されたルールに従って解析されたデータを検証することだけに焦点を当てています。これにより、検証ロジックがリクエスト処理から分離され、さまざまなエンドポイント間での保守性と再利用性が向上します。
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()) }
ご覧のとおり、Request レイヤーは Validation レイヤーを使用します。ただし、メンテナンスを容易にするためだけでなく、検証レイヤーも分離して使用したい場合があるため、コード内でレイヤーを分離しておきたいと考えています。
将来的には、ニーズに応じて、すべてのレイヤーを分離し、いくつかのインターフェイスを使用して相互依存関係を許可することを決定する可能性があります。
リクエストの解析が分離されたら、検証ロジックを処理するスタンドアロンの検証関数またはモジュールを作成します。このロジックを分離することで、簡単にテストし、複数のエンドポイントに一貫した検証ルールを適用できます。
そのために、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 中国語 Web サイトの他の関連記事を参照してください。