Skip to content

Instantly share code, notes, and snippets.

@podhmo
Last active June 25, 2025 04:52
Show Gist options
  • Save podhmo/f0d31a849e81820d8888edbc29890cbd to your computer and use it in GitHub Desktop.
Save podhmo/f0d31a849e81820d8888edbc29890cbd to your computer and use it in GitHub Desktop.
goのnet/httpでclientを作ったりserverを作ったりでのはまりどころなど

readme

ユーザー入力履歴

ユーザー: goでweb apiを作る時に知っておくべき知識を箇条書きで列挙してください(net/httpを考えてます)。初級者向けの知識の話と玄人でも忘れがちなチェックリストの話と分けて回答してください。

さらっと眺めて分かる基礎知識ではなくハマりどころとかですね箇条書きで欲しいのは。

たとえば初心者向けにはstatus codeの設定前にbodyを出力すると200になってしまうだとか

玄人向けにはcontext cancelをどうハンドリングするべきか?とか。getはcancelすれば良いがpostは…とか。

初心者向けにはエラーハンドリングをミドルウェアでやるようにするにはどうするかなどもありそうですね(500のときなど)

最後にwebフレームワークを使うことを考えるのはどのタイミングですか?


ユーザー: もう少しありそうではないですか?たとえばより実装に近づくとResponseWriterを自作したときにhttp.Flusherにも実は対応しておいた方が良いみたいな話など。


ユーザー: 今度は一般的なイディオムというかパターンについて教えてください。たとえばどういうものかと言えばミドルウェア上でstatus codeをログに取るときのためにそれを保持するResponseWriterでラップするだとか。

500を返す時にはエラーメッセージをそのまま渡さずログに出力してinternal server errorとだけレスポンスのメッセージにするだとか。

あとはテストのときにどこでエラーが出たか分かるようにstatus codeのチェック時にResponseのbodyもt.Fatalfなどのメッセージに含めるだとか。


ユーザー: ちなみに今度はクライアントの方の実装で考えるべきことなどを加えてください。

たとえばio.Discardなどにbodyを渡してあげないとkeep-aliveの関係でコネクションが再利用されないだとか。初心者向けにはhttp.Clientを使い回すべきとか、並列処理のときのidle connectionsの数とか。

最後にapi Clientのライブラリを作るときのイディオムというかパターンを教えてください。


ユーザー: バックオフ対応とbodyは消費されてしまうの言及も欲しいですね


ユーザー: あれ、DefaultClientにはタイムアウトが設定されてたはずです。DefaultTransport経由で。

var DefaultTransport RoundTripper = &Transport{
	Proxy: ProxyFromEnvironment,
	DialContext: defaultTransportDialContext(&net.Dialer{
		Timeout:   30 * time.Second,
		KeepAlive: 30 * time.Second,
	}),
	ForceAttemptHTTP2:     true,
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

http.DefaultClient はグローバルなクライアントですが、タイムアウトなどが設定されていないため、プロダクションではカスタム設定したクライアントを推奨します。

効かないタイミングはどのような時ですか?


ユーザー: あなたはgoの開発者であり思慮深い執筆者です。これまでの思考の内容を追体験させるような文章がほしいです。自己完結型の文章にしてください。わざと短くまとめようとせずに丁寧に長さを気にせずテキストとして出力してください(2000字や5000字を超えても構いません)。コード例を丁寧に書いて説明を加えてください。文章がいくら長くなったとしても途中で止まらず応答を求めずすべてを出力しきってください。

一つの記事にまとめられない場合は同様の想定で2つ目の記事や3つ目の記事と複数の記事に分割して同様の形式で出力して構いません。


記事のフォーマットについて

  • 記事は#1つのタイトル名からはじめてください
  • 記事は幾分かの章で分割してください
  • 章タイトルには絵文字を1つ加えてください
  • 記事はmarkdownフォーマットを念頭に入れて出力してください
  • *や**による太字や強調を禁止します
  • 対象読者と狙いをFront matter で簡潔に冒頭につけてください
  • 文章は長くなっても構いません。丁寧に記述してください。

重要なこと

  • 読者は私およびあなたの存在を知りません。伝える必要もありません。文中で参照しないでください。
  • 読者は投稿元のSNSの投稿を知りません。それについて触れずに表現してください。

それではお願いします。

除外事項

  • web apiフレームワークをいつ使うか?は不要です

ユーザー: 続けて。その別の記事を出力して

Go net/http 深掘りガイド: サーバーサイド開発の実践知

Go言語の標準パッケージである net/http は、Webサーバーやクライアントを実装するための強力で柔軟な基盤を提供します。多くのサードパーティフレームワークも、その内部で net/http の機能を活用しています。このパッケージを深く理解することは、GoによるWeb開発の効率と品質を大きく向上させる鍵となります。

この記事では、net/http を用いたサーバーサイド開発に焦点を当て、基本的な使い方から一歩進んで、開発者が直面しがちな課題や、より洗練されたコードを書くためのテクニック、そして知っておくべきイディオムについて、具体的なコード例を交えながら掘り下げていきます。

🚀 ハンドラの基本とレスポンス制御の罠

Webサーバーの核となるのは、リクエストを受け取りレスポンスを返すハンドラです。net/http では、http.Handler インタフェースまたは http.HandlerFunc 型を使ってハンドラを定義します。

http.Handler インタフェース:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

http.HandlerFunc 型:

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTPメソッドを実装しているため、Handlerインタフェースを満たす
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

これにより、func(http.ResponseWriter, *http.Request) というシグネチャを持つ任意の関数を http.HandlerFunc にキャストすることで、http.Handler として扱えるようになります。

package main

import (
	"fmt"
	"log"
	"net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello, World!")
}

func main() {
	http.HandleFunc("/hello", helloHandler) // http.HandlerFuncへのキャストが内部で行われる
	log.Println("Server starting on port 8080...")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}

ステータスコードとヘッダーの書き込みタイミング

レスポンスをクライアントに返す際には、ステータスコード、ヘッダー、そしてボディの順序が重要になります。net/httphttp.ResponseWriter は、この順序を強制する仕組みになっています。

  1. ヘッダーの設定: w.Header().Set("Key", "Value")
  2. ステータスコードの設定: w.WriteHeader(statusCode)
  3. ボディの書き込み: w.Write([]byte("body content"))

最も重要な注意点は、w.WriteHeader() または最初の w.Write() が呼び出された時点で、ヘッダーとステータスコードがクライアントに送信されてしまうということです。これ以降、ヘッダーやステータスコードを変更することはできません。

もし w.WriteHeader() を明示的に呼び出す前に w.Write() が呼び出された場合、net/http は自動的にステータスコード 200 OK を設定してヘッダーを送信します。

悪い例: ステータスコード設定前にボディを書き出す

func badStatusHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("This will be a 200 OK response.\n"))
	w.WriteHeader(http.StatusInternalServerError) // この行は効果がない
	fmt.Fprint(w, "Tried to set 500, but it's too late.")
}

// 実行結果のレスポンス:
// HTTP/1.1 200 OK
// Content-Type: text/plain; charset=utf-8
// Date: ...
//
// This will be a 200 OK response.
// Tried to set 500, but it's too late.

この例では、最初の w.Write() が呼び出された時点でステータスコード 200 OK が送信されてしまうため、その後の w.WriteHeader(http.StatusInternalServerError) は無視されます。

正しい順序:

func correctStatusHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("X-Custom-Header", "my-value")
	w.WriteHeader(http.StatusCreated) // 201 Created
	fmt.Fprint(w, "Resource created successfully.")
}

また、w.WriteHeader() を複数回呼び出すこともできません。最初の呼び出しのみが有効となります。

Content-Type ヘッダーの重要性

クライアントがレスポンスボディを正しく解釈できるように、Content-Type ヘッダーを適切に設定することは非常に重要です。例えば、JSONレスポンスを返す場合は application/json、HTMLを返す場合は text/html を設定します。

func jsonHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	responseJSON := `{"message": "Hello, JSON!"}`
	fmt.Fprint(w, responseJSON)
}

http.ResponseWriter は、Content-Type が設定されていない場合、w.Write() で書き込まれるデータの先頭512バイトから http.DetectContentType を使って自動的に推測しようとしますが、明示的に設定することが推奨されます。

🎣 リクエスト処理の勘所

クライアントからのリクエストには、URL、メソッド、ヘッダー、そしてボディが含まれます。これらを正しく処理する方法を見ていきましょう。

リクエストボディの読み取り

サーバーサイドでクライアントから送信されたリクエストボディ (r.Body) を扱う際には、いくつかの注意点があります。

  1. r.Bodyio.ReadCloser です。 読み取り後は、必ず Close() メソッドを呼び出してリソースを解放する必要があります。defer r.Body.Close() を使うのが一般的です。

    func bodyEchoHandler(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "Only POST method is allowed", http.StatusMethodNotAllowed)
            return
        }
        defer r.Body.Close() // 必ずクローズする
    
        bodyBytes, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "Error reading request body", http.StatusInternalServerError)
            log.Printf("Error reading body: %v", err)
            return
        }
        w.WriteHeader(http.StatusOK)
        w.Write(bodyBytes)
    }
  2. r.Body は一度しか読み取れません。 io.Reader の性質上、一度読み取るとストリームは終端に達し、再度読み取ることはできません。もし複数回ボディの内容を参照する必要がある場合は、最初に io.ReadAll などでバイトスライスに読み込んでから利用します。

    func processBodyMultipleTimes(w http.ResponseWriter, r *http.Request) {
        defer r.Body.Close()
    
        bodyBytes, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "Error reading request body", http.StatusInternalServerError)
            return
        }
    
        // 1回目の利用: ログ出力
        log.Printf("Received body: %s", string(bodyBytes))
    
        // 2回目の利用: JSONデコードなど
        var data map[string]interface{}
        // bodyBytes を使ってデコード (r.Bodyではない)
        if err := json.Unmarshal(bodyBytes, &data); err != nil {
            http.Error(w, "Error unmarshalling JSON", http.StatusBadRequest)
            return
        }
        fmt.Fprintf(w, "Successfully processed JSON: %+v", data)
    }
  3. リクエストボディサイズの制限 (http.MaxBytesReader) 悪意のあるクライアントが非常に大きなリクエストボディを送信してサーバーリソースを枯渇させるのを防ぐため、読み取るボディサイズに上限を設けることが推奨されます。http.MaxBytesReader を使うと、指定したサイズを超えて読み込もうとした場合にエラーを返します。

    func limitedBodyHandler(w http.ResponseWriter, r *http.Request) {
        const maxBodySize = 1 * 1024 * 1024 // 1MB
    
        r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
        defer r.Body.Close()
    
        bodyBytes, err := io.ReadAll(r.Body)
        if err != nil {
            // サイズ超過の場合、errは *http.MaxBytesError 型になる
            var maxBytesErr *http.MaxBytesError
            if errors.As(err, &maxBytesErr) {
                http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
                return
            }
            http.Error(w, "Error reading request body", http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "Received %d bytes", len(bodyBytes))
    }

JSONデコードとエラーハンドリング

APIではJSON形式でデータを受け取ることが一般的です。json.NewDecoder を使ってリクエストボディを直接Goの構造体にデコードできます。

type CreateUserRequest struct {
	Username string `json:"username"`
	Email    string `json:"email"`
	Age      int    `json:"age,omitempty"` // omitempty はエンコード時にゼロ値なら省略
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Only POST method is allowed", http.StatusMethodNotAllowed)
		return
	}
	defer r.Body.Close()

	var req CreateUserRequest
	decoder := json.NewDecoder(r.Body)
	// decoder.DisallowUnknownFields() // リクエストJSONに構造体にないフィールドが含まれていたらエラーにする

	if err := decoder.Decode(&req); err != nil {
		var syntaxError *json.SyntaxError
		var unmarshalTypeError *json.UnmarshalTypeError

		switch {
		case errors.As(err, &syntaxError):
			msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
			http.Error(w, msg, http.StatusBadRequest)
		case errors.Is(err, io.ErrUnexpectedEOF):
			msg := "Request body contains badly-formed JSON"
			http.Error(w, msg, http.StatusBadRequest)
		case errors.As(err, &unmarshalTypeError):
			msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
			http.Error(w, msg, http.StatusBadRequest)
		case errors.Is(err, io.EOF): // ボディが空の場合
			msg := "Request body must not be empty"
			http.Error(w, msg, http.StatusBadRequest)
		// case strings.HasPrefix(err.Error(), "json: unknown field "): // DisallowUnknownFields使用時
		// 	fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
		// 	msg := fmt.Sprintf("Request body contains unknown field %s", fieldName)
		// 	http.Error(w, msg, http.StatusBadRequest)
		default:
			log.Printf("Error decoding JSON: %v", err)
			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		}
		return
	}

	// バリデーション (例)
	if req.Username == "" {
		http.Error(w, "Username is required", http.StatusBadRequest)
		return
	}

	log.Printf("Creating user: %+v", req)
	w.WriteHeader(http.StatusCreated)
	fmt.Fprintf(w, "User %s created.", req.Username)
}

decoder.DisallowUnknownFields() を呼び出すと、リクエストJSONにGoの構造体で定義されていないフィールドが含まれている場合にデコードエラーとなり、より厳密なリクエスト形式を強制できます。エラーハンドリングも、どのような問題でデコードに失敗したかを判別し、クライアントに適切なエラーメッセージを返すことが重要です。

URLクエリパラメータとフォームデータのパース

GETリクエストのクエリパラメータ (/search?q=golang&lang=en) や、POSTリクエストのフォームデータ (application/x-www-form-urlencoded または multipart/form-data) を取得するには、まずリクエストのパースが必要です。

  • r.ParseForm(): クエリパラメータと application/x-www-form-urlencoded 形式のボディをパースします。
  • r.ParseMultipartForm(maxMemory): multipart/form-data 形式のボディをパースします。maxMemory はメモリ上に保持するファイルの最大サイズ(これを超えると一時ファイルに保存されます)。

パース後、以下の方法で値を取得できます。

  • r.URL.Query().Get("key"): URLクエリパラメータから値を取得(パース不要)。
  • r.FormValue("key"): クエリパラメータまたはパース済みのフォームデータから値を取得。キーが存在しない場合は空文字列を返します。内部で必要に応じてパースを試みます。
  • r.PostFormValue("key"): パース済みのフォームデータからのみ値を取得。
  • r.Form: パース後の全フォームデータ(クエリパラメータ含む)を url.Values (map[string][]string) 型で保持。
  • r.PostForm: パース後のボディからのフォームデータのみを url.Values 型で保持。
  • r.MultipartForm: multipart/form-data パース後のデータを保持。ファイルや値にアクセスできます。
func formHandler(w http.ResponseWriter, r *http.Request) {
	// GET /form?name=Alice&age=30
	// POST /form (Content-Type: application/x-www-form-urlencoded) name=Bob&age=25
	// POST /form (Content-Type: multipart/form-data) ...

	// クエリパラメータと x-www-form-urlencoded ボディをパース
	// (ParseMultipartForm を呼ぶ場合は不要、そちらが内部でParseFormも呼ぶため)
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Error parsing form", http.StatusInternalServerError)
		return
	}

	name := r.FormValue("name") // クエリ or x-www-form-urlencoded ボディから取得
	ageStr := r.FormValue("age")

	// multipart/form-data の場合 (ファイルアップロードなど)
	// 例: 32MBまでのファイルをメモリで処理、それ以上は一時ファイル
	// if err := r.ParseMultipartForm(32 << 20); err != nil {
	// 	if !errors.Is(err, http.ErrNotMultipart) { // multipartでないエラーは処理
	// 		http.Error(w, "Error parsing multipart form", http.StatusInternalServerError)
	// 		return
	// 	}
	// }
	// if r.MultipartForm != nil {
	// 	file, handler, err := r.FormFile("uploadFile")
	// 	if err == nil {
	// 		defer file.Close()
	// 		log.Printf("Uploaded File: %+v\n", handler.Filename)
	// 		log.Printf("File Size: %+v\n", handler.Size)
	// 		// ファイル処理...
	// 	} else if !errors.Is(err, http.ErrMissingFile) {
	// 		log.Printf("Error retrieving file: %v", err)
	// 	}
	// 	name = r.MultipartForm.Value["name"][0] // multipart の場合の値の取り方
	// }


	if name == "" {
		http.Error(w, "Name is required", http.StatusBadRequest)
		return
	}

	age, err := strconv.Atoi(ageStr)
	if err != nil && ageStr != "" {
		http.Error(w, "Age must be a number", http.StatusBadRequest)
		return
	}

	fmt.Fprintf(w, "Received Name: %s, Age: %d", name, age)
}

r.FormValue は便利ですが、キーが存在しない場合にエラーを返さず空文字列を返すため、必須項目のチェックは別途行う必要があります。また、大きなファイルを扱う場合は r.ParseMultipartForm を使い、メモリ使用量に注意しましょう。

🛡️ エラーハンドリングとミドルウェアパターン

堅牢なAPIを構築するには、一貫性のあるエラーハンドリングと、共通処理をまとめるミドルウェアの活用が不可欠です。

ハンドラからのエラー返却パターン

各ハンドラで http.Errorjson.NewEncoder().Encode() を直接呼び出す代わりに、ハンドラが error を返し、共通のラッパーやミドルウェアでHTTPレスポンスを生成するパターンがよく用いられます。これにより、ハンドラのロジックがシンプルになり、エラー処理を一元化できます。

まず、アプリケーション固有のエラー型を定義します。これには、HTTPステータスコードやユーザー向けのメッセージなどを含めることができます。

type AppError struct {
	StatusCode int
	Message    string
	Err        error // 元のエラー (ログ用)
}

func (e *AppError) Error() string {
	if e.Err != nil {
		return fmt.Sprintf("%s: %v", e.Message, e.Err)
	}
	return e.Message
}

// ユーザー向けのエラーレスポンス構造体
type ErrorResponse struct {
	Error string `json:"error"`
}

次に、この AppError を返すシグネチャを持つハンドラ型と、それを http.Handler に変換するアダプタを定義します。

type appHandler func(w http.ResponseWriter, r *http.Request) *AppError

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if appErr := fn(w, r); appErr != nil {
		// 内部エラーをロギング
		log.Printf("Error: %v (Status: %d, Path: %s)", appErr.Err, appErr.StatusCode, r.URL.Path)

		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		// X-Content-Type-Options はセキュリティヘッダーの一例
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.WriteHeader(appErr.StatusCode)

		err := json.NewEncoder(w).Encode(ErrorResponse{Error: appErr.Message})
		if err != nil {
			// JSONエンコード失敗時はさらに内部エラー、ただしヘッダーは送信済み
			log.Printf("Could not encode error response: %v", err)
			// この時点でクライアントにはステータスコードは送信されている
			// ボディは空か、部分的に書き込まれている可能性がある
		}
	}
}

// 具体的なハンドラ
func getUserHandler(w http.ResponseWriter, r *http.Request) *AppError {
	userID := r.URL.Query().Get("id")
	if userID == "" {
		return &AppError{
			StatusCode: http.StatusBadRequest,
			Message:    "User ID is required",
			Err:        errors.New("user id missing from query"),
		}
	}

	// データベースなどからユーザーを取得する処理 (仮)
	// user, err := db.GetUser(userID)
	// if err != nil {
	// 	if errors.Is(err, dberrors.NotFound) {
	// 		return &AppError{StatusCode: http.StatusNotFound, Message: "User not found", Err: err}
	// 	}
	// 	return &AppError{StatusCode: http.StatusInternalServerError, Message: "Failed to retrieve user", Err: err}
	// }

	// 成功 (仮のレスポンス)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	fmt.Fprintf(w, `{"id": "%s", "name": "Dummy User"}`, userID) // 本来はjson.Encoderを使う
	return nil // エラーなし
}

main 関数での利用:

// http.Handle("/users", appHandler(getUserHandler))

このパターンでは、ハンドラはビジネスロジックとエラーの生成に集中でき、HTTPレスポンスの具体的な形式やロギングは appHandler.ServeHTTP メソッドが一手に引き受けます。500系のエラーが発生した場合でも、ユーザーには汎用的なメッセージ (Internal Server Error など) を返し、詳細なエラー情報はサーバーログにのみ記録することが重要です。

パニックリカバリミドルウェア

ハンドラ内で予期せぬパニックが発生した場合、サーバーはデフォルトでクラッシュし、クライアントへの接続は中断されます。これを防ぎ、パニックをキャッチして500エラーとしてグレースフルに処理するためのミドルウェアを導入します。

func recoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("Panic recovered: %v\n%s", err, debug.Stack()) // スタックトレースをログに

				// クライアントには詳細を返さない
				// ただし、ヘッダーが既に送信されていないことを確認する必要がある
				// (非常に稀だが、panic前にWriteが一部呼ばれている可能性も理論上はありえる)
				// ここでは簡単のため、ヘッダー未送信と仮定
				w.Header().Set("Content-Type", "application/json; charset=utf-8")
				w.WriteHeader(http.StatusInternalServerError)
				json.NewEncoder(w).Encode(ErrorResponse{Error: "Internal Server Error"})
			}
		}()
		next.ServeHTTP(w, r)
	})
}

ミドルウェアは、http.Handler を受け取り、http.Handler を返す関数として実装するのが一般的です。main 関数で他のミドルウェアやハンドラと共にチェインして使用します。

// mux := http.NewServeMux()
// mux.HandleFunc("/hello", helloHandler)
//
// protectedHandler := authMiddleware(appHandler(getUserHandler)) // 例
// mux.Handle("/users", protectedHandler)
//
// finalHandler := recoverMiddleware(loggingMiddleware(mux)) // ミドルウェアのチェイン
// log.Fatal(http.ListenAndServe(":8080", finalHandler))

ロギングミドルウェア

リクエストの処理時間、ステータスコード、リクエストパスなどを記録するロギングミドルウェアは、APIの監視やデバッグに非常に役立ちます。

ステータスコードを後から取得するために、http.ResponseWriter をラップするカスタムの ResponseWriter を使います。

type loggingResponseWriter struct {
	http.ResponseWriter
	statusCode int
	// size       int // レスポンスサイズも記録する場合
}

func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
	return &loggingResponseWriter{w, http.StatusOK} // デフォルトは200 OK
}

func (lrw *loggingResponseWriter) WriteHeader(code int) {
	lrw.statusCode = code
	lrw.ResponseWriter.WriteHeader(code)
}

// Writeもラップして、WriteHeaderが呼ばれなかった場合のデフォルト200を記録したり、
// レスポンスサイズを記録したりできる
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
	if lrw.statusCode == 0 { // WriteHeaderが明示的に呼ばれず、これが最初の書き込みの場合
		lrw.statusCode = http.StatusOK
	}
	// n, err := lrw.ResponseWriter.Write(b)
	// lrw.size += n
	// return n, err
	return lrw.ResponseWriter.Write(b)
}

// http.Flusher インタフェースへの対応 (SSEなどで必要)
func (lrw *loggingResponseWriter) Flush() {
	if flusher, ok := lrw.ResponseWriter.(http.Flusher); ok {
		flusher.Flush()
	}
}

// http.Hijacker インタフェースへの対応 (WebSocketなどで必要)
func (lrw *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
	if hijacker, ok := lrw.ResponseWriter.(http.Hijacker); ok {
		return hijacker.Hijack()
	}
	return nil, nil, fmt.Errorf("http.Hijacker interface not supported")
}


func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		lrw := newLoggingResponseWriter(w)

		// リクエストIDを生成・付与 (例: UUID)
		requestID := uuid.NewString() // "github.com/google/uuid"
		w.Header().Set("X-Request-ID", requestID)
		ctx := context.WithValue(r.Context(), "requestID", requestID) // ハンドラで利用可能に
		r = r.WithContext(ctx)

		next.ServeHTTP(lrw, r)

		duration := time.Since(start)
		log.Printf(
			"[%s] %s %s %s %d %s %s",
			requestID,
			r.RemoteAddr,
			r.Method,
			r.URL.RequestURI(),
			lrw.statusCode,
			// lrw.size, // レスポンスサイズ
			r.UserAgent(),
			duration,
		)
	})
}

この loggingResponseWriter は、元の ResponseWriter が実装している可能性のある他のインタフェース(http.Flusher, http.Hijacker など)も適切に委譲または実装する必要があります。そうしないと、ストリーミングレスポンスやWebSocketへのアップグレードなどが正しく機能しなくなる可能性があります。http.Pusherio.ReaderFrom も同様に考慮すべき場合があります。

⚙️ http.Server の設定とグレースフルシャットダウン

http.ListenAndServe は便利ですが、プロダクション環境ではより細やかな制御のために http.Server 構造体を直接設定・利用することが推奨されます。

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/hello", helloHandler)
	// ... 他のハンドラやミドルウェアを設定 ...
	// finalHandler := recoverMiddleware(loggingMiddleware(mux))

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux, // または finalHandler
		// タイムアウト設定
		ReadTimeout:       5 * time.Second,  // リクエストヘッダとボディ全体の読み取りタイムアウト
		WriteTimeout:      10 * time.Second, // レスポンス書き込みタイムアウト
		IdleTimeout:       120 * time.Second, // Keep-Alive時のアイドルコネクションタイムアウト
		ReadHeaderTimeout: 2 * time.Second,  // リクエストヘッダ読み取り専用のタイムアウト (Go 1.18+)
	}

	// グレースフルシャットダウンのための準備
	go func() {
		log.Printf("Server starting on %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Could not listen on %s: %v\n", server.Addr, err)
		}
	}()

	// OSからの停止シグナルを待つ
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, os.Interrupt, syscall.SIGTERM) // syscall.SIGINTも一般的
	<-quit
	log.Println("Server is shutting down...")

	// シャットダウン処理のためのコンテキスト(例: 30秒の猶予)
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// サーバーに現在のリクエスト処理完了を待ってシャットダウンするよう指示
	if err := server.Shutdown(ctx); err != nil {
		log.Fatalf("Server shutdown failed: %v", err)
	}
	log.Println("Server gracefully stopped")
}

タイムアウト設定の重要性

  • ReadTimeout: クライアントからのリクエストヘッダとボディ全体の読み取りが完了するまでの最大時間。遅いクライアントや悪意のあるリクエスト (Slowloris攻撃など) からサーバーを保護します。
  • WriteTimeout: サーバーがクライアントにレスポンスを書き込む処理全体の最大時間。
  • IdleTimeout: HTTP Keep-Alive が有効な場合に、アイドル状態のコネクションを維持する最大時間。これを超えるとコネクションは閉じられます。リソースの効率的な利用に繋がります。
  • ReadHeaderTimeout: (Go 1.18以降) リクエストヘッダのみの読み取りタイムアウト。ReadTimeout より短い値を設定することで、ヘッダ解析段階での攻撃により早く対処できます。

これらのタイムアウトを設定しないと、リソースが枯渇したり、サービスが応答不能になったりするリスクがあります。

グレースフルシャットダウンの実装

サーバーが停止シグナル (Ctrl+C など) を受け取った際に、処理中のリクエストを中断せずに完了させてから安全に終了する仕組みです。

  1. サーバーを別のゴルーチンで起動 (go server.ListenAndServe())。
  2. メインゴルーチンでOSの停止シグナル ( os.Interrupt, syscall.SIGTERM など) を待ちます。
  3. シグナルを受け取ったら、server.Shutdown(ctx) を呼び出します。このメソッドは新しいリクエストの受け付けを停止し、既存のコネクションがアイドルになるか、指定したコンテキストがタイムアウト/キャンセルされるまで待機します。

これにより、クライアントへの影響を最小限に抑えつつ、安全にサーバーを停止できます。

💨 高度なトピック: ストリーミングとコンテキスト

よりインタラクティブな、あるいは効率的なAPIを構築するためには、ストリーミングやコンテキストの活用が鍵となります。

http.Flusher とServer-Sent Events (SSE)

Server-Sent Events (SSE) は、サーバーからクライアントへ一方向のデータストリームを送信するための技術です。text/event-stream という Content-Type を使用します。 SSEを実装するには、レスポンスをチャンクで送信し、バッファリングされずに即座にクライアントに届くようにする必要があります。このために http.Flusher インタフェースを利用します。

func sseHandler(w http.ResponseWriter, r *http.Request) {
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	// w.Header().Set("Access-Control-Allow-Origin", "*") // 必要に応じてCORS設定

	messageChan := make(chan string)

	// 別ゴルーチンでイベントを生成してチャネルに送信する (例)
	go func() {
		defer close(messageChan)
		for i := 0; i < 10; i++ {
			time.Sleep(1 * time.Second)
			eventData := fmt.Sprintf("Event %d at %s", i, time.Now().Format(time.RFC3339))
			messageChan <- eventData
			if r.Context().Err() != nil { // クライアント切断を検知
				log.Println("SSE client disconnected, stopping event generation.")
				return
			}
		}
	}()

	// クライアント切断を検知するためのコンテキスト
	ctx := r.Context()

	for {
		select {
		case <-ctx.Done(): // クライアントが接続を切断した
			log.Println("SSE client disconnected.")
			return
		case message, open := <-messageChan:
			if !open { // チャネルがクローズされた (イベント送信終了)
				log.Println("SSE event stream finished.")
				return
			}
			// SSE形式でデータを書き込む (例: "data: message\n\n")
			fmt.Fprintf(w, "id: %s\n", uuid.NewString()) // イベントID (省略可)
			fmt.Fprintf(w, "event: message\n")           // イベントタイプ (省略可)
			fmt.Fprintf(w, "data: %s\n\n", message)      // データ本体
			flusher.Flush()                              // バッファをフラッシュしてクライアントに送信
		}
	}
}

flusher.Flush() を呼び出すことで、ResponseWriter の内部バッファがクライアントに送信されます。クライアントの切断は r.Context().Done() で検知できます。

http.Hijacker とWebSocketへのアップグレード

WebSocket通信を開始するには、HTTP/1.1コネクションを "ハイジャック" し、TCPコネクションの直接制御を奪う必要があります。これには http.Hijacker インタフェースを利用します。 サードパーティのWebSocketライブラリ (例: gorilla/websocket) は、内部でこのインタフェースを利用してプロトコルアップグレード処理を行います。自前で実装することは稀ですが、仕組みを理解しておくことは有益です。

// websocketHandler (gorilla/websocketを使った場合のイメージ)
// var upgrader = websocket.Upgrader{
// 	ReadBufferSize:  1024,
// 	WriteBufferSize: 1024,
// 	CheckOrigin: func(r *http.Request) bool { return true }, // 本番ではオリジンチェックを適切に
// }
//
// func wsHandler(w http.ResponseWriter, r *http.Request) {
// 	conn, err := upgrader.Upgrade(w, r, nil) // ここで内部的にHijackerが使われる
// 	if err != nil {
// 		log.Println("Failed to upgrade to websocket:", err)
// 		return
// 	}
// 	defer conn.Close()
// 	// WebSocket通信処理 (conn.ReadMessage, conn.WriteMessage)
// }

Hijack() を呼び出した後は、開発者がコネクションのライフサイクル全体(エラーハンドリング、クローズなど)に責任を持つことになります。

r.Context() の活用

リクエストスコープのコンテキスト (r.Context()) は、タイムアウトやキャンセルシグナルの伝播、リクエスト固有の値の受け渡しに非常に重要です。

  1. クライアント切断の検知 クライアントがリクエストの途中で接続を切断した場合、r.Context().Done() チャネルがクローズされます。長時間かかる処理を行うハンドラでは、定期的にこのチャネルをチェックすることで、無駄な処理を中断できます。

    func longRunningTaskHandler(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        log.Println("Starting long running task...")
    
        select {
        case <-time.After(10 * time.Second): // 10秒かかるタスク (仮)
            fmt.Fprint(w, "Task completed.")
            log.Println("Task completed normally.")
        case <-ctx.Done(): // クライアント切断 or サーバー側タイムアウト (例: http.Server.WriteTimeout)
            err := ctx.Err()
            log.Printf("Task cancelled: %v", err)
            // ここでレスポンスを書き込んでもクライアントには届かない可能性が高い
            // サーバー側のリソースクリーンアップ処理などを行う
            if errors.Is(err, context.Canceled) {
                // クライアントが切断
            } else if errors.Is(err, context.DeadlineExceeded) {
                // サーバー設定のタイムアウトなど
            }
        }
    }
  2. ミドルウェアからの値の受け渡し ミドルウェアで認証情報やリクエストIDなどをコンテキストに格納し、後続のハンドラで取り出して利用できます。キーの衝突を避けるため、キーには非公開の独自型を使うのが定石です。

    type contextKey string
    const requestIDKey contextKey = "requestID"
    const userIDKey contextKey = "userID"
    
    // (loggingMiddleware の例で requestID をコンテキストにセットする部分を参照)
    
    // 認証ミドルウェア (例)
    func authMiddleware(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // ... トークン検証などの認証処理 ...
            // authenticatedUserID := "user-123"
            // ctx := context.WithValue(r.Context(), userIDKey, authenticatedUserID)
            // next.ServeHTTP(w, r.WithContext(ctx))
            next.ServeHTTP(w, r) // この例では認証処理は省略
        })
    }
    
    // ハンドラでの利用
    func protectedDataHandler(w http.ResponseWriter, r *http.Request) {
        reqID, ok := r.Context().Value(requestIDKey).(string)
        if !ok {
            log.Println("Request ID not found in context")
            // エラー処理
        }
        // userID, ok := r.Context().Value(userIDKey).(string)
        // if !ok {
        // 	http.Error(w, "Unauthorized", http.StatusUnauthorized)
        // 	return
        // }
        log.Printf("[%s] Accessing protected data for user...", reqID)
        fmt.Fprintf(w, "[%s] Protected data accessed.", reqID)
    }

    コンテキストから値を取り出す際は、型アサーションと、値が存在しない場合のフォールバック処理を忘れずに行います。

🧪 テストのプラクティス

net/http パッケージ自体がテストを容易にするための優れたツールを提供しています。httptest パッケージがその中心です。

httptest パッケージの活用

  • httptest.NewServer(handler http.Handler): 指定したハンドラをリッスンするテスト用のHTTPサーバーを起動します。実際のネットワーク通信を伴うため、E2Eテストに近い形でテストできます。サーバーのURL (ts.URL) が返されるので、http.Client を使ってリクエストを送信できます。
  • httptest.NewRequest(method, target, body io.Reader): テスト用の *http.Request を簡単に作成できます。
  • httptest.NewRecorder(): http.ResponseWriter インタフェースを実装したオブジェクトで、ハンドラが書き込んだレスポンス (ステータスコード、ヘッダー、ボディ) を記録します。ネットワーク通信を伴わないため、ハンドラの単体テストに非常に高速かつ便利です。

ハンドラの単体テスト ( ResponseRecorder を使用):

func TestHelloHandler(t *testing.T) {
	req, err := http.NewRequest("GET", "/hello", nil)
	if err != nil {
		t.Fatal(err)
	}

	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(helloHandler) // テスト対象のハンドラ

	handler.ServeHTTP(rr, req)

	// ステータスコードのチェック
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v",
			status, http.StatusOK)
	}

	// レスポンスボディのチェック
	expected := "Hello, World!"
	if rr.Body.String() != expected {
		t.Errorf("handler returned unexpected body: got %q want %q",
			rr.Body.String(), expected)
	}
}

func TestGetUserHandler_Success(t *testing.T) {
	// appHandler を使ったハンドラのテスト例
	handler := appHandler(getUserHandler) // getUserHandler は *AppError を返す

	req, err := http.NewRequest("GET", "/users?id=123", nil)
	if err != nil {
		t.Fatal(err)
	}

	rr := httptest.NewRecorder()
	handler.ServeHTTP(rr, req) // appHandlerのServeHTTPを呼び出す

	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v. Body: %s",
			status, http.StatusOK, rr.Body.String())
	}

	// レスポンスボディのJSONをデコードしてチェック (より堅牢)
	var respBody map[string]string
	if err := json.NewDecoder(rr.Body).Decode(&respBody); err != nil {
		t.Fatalf("Could not decode response body: %v", err)
	}
	if id, ok := respBody["id"]; !ok || id != "123" {
		t.Errorf("Expected user ID '123', got '%s'", id)
	}
}

func TestGetUserHandler_MissingID(t *testing.T) {
	handler := appHandler(getUserHandler)

	req, err := http.NewRequest("GET", "/users", nil) // IDなし
	if err != nil {
		t.Fatal(err)
	}
	rr := httptest.NewRecorder()
	handler.ServeHTTP(rr, req)

	if status := rr.Code; status != http.StatusBadRequest {
		t.Errorf("handler returned wrong status code for missing ID: got %v want %v. Body: %s",
			status, http.StatusBadRequest, rr.Body.String())
	}

	var errResp ErrorResponse
	if err := json.NewDecoder(rr.Body).Decode(&errResp); err != nil {
		t.Fatalf("Could not decode error response body: %v", err)
	}
	expectedErrMsg := "User ID is required"
	if errResp.Error != expectedErrMsg {
		t.Errorf("Expected error message %q, got %q", expectedErrMsg, errResp.Error)
	}
}

テーブルドリブンテスト

複数のテストケースを効率的に記述・実行するために、テーブルドリブンテストのパターンがよく用いられます。

func TestVariousHandlers(t *testing.T) {
	tests := []struct {
		name           string
		method         string
		path           string
		body           io.Reader
		handler        http.HandlerFunc // または appHandler
		expectedStatus int
		expectedBody   string // 単純な文字列比較の場合。JSONなら構造体で比較も
	}{
		{
			name:           "Hello success",
			method:         "GET",
			path:           "/hello",
			handler:        helloHandler,
			expectedStatus: http.StatusOK,
			expectedBody:   "Hello, World!",
		},
		{
			name:           "Correct status handler",
			method:         "GET",
			path:           "/correct-status",
			handler:        correctStatusHandler,
			expectedStatus: http.StatusCreated,
			expectedBody:   "Resource created successfully.",
		},
		// ... 他のテストケース ...
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req, err := http.NewRequest(tt.method, tt.path, tt.body)
			if err != nil {
				t.Fatalf("Could not create request: %v", err)
			}

			rr := httptest.NewRecorder()
			// if ah, ok := tt.handler.(appHandler); ok { // appHandlerの場合
			// 	ah.ServeHTTP(rr, req)
			// } else if hf, ok := tt.handler.(http.HandlerFunc); ok { // http.HandlerFuncの場合
			// 	hf.ServeHTTP(rr, req)
			// } else {
			// 	t.Fatalf("Unsupported handler type")
			// }
			tt.handler.ServeHTTP(rr, req) // この例ではhttp.HandlerFuncを前提

			if status := rr.Code; status != tt.expectedStatus {
				t.Errorf("Status: got %v want %v. Body: %s", status, tt.expectedStatus, rr.Body.String())
			}
			if tt.expectedBody != "" && rr.Body.String() != tt.expectedBody {
				t.Errorf("Body: got %q want %q", rr.Body.String(), tt.expectedBody)
			}
		})
	}
}

net/http を使ったサーバーサイド開発は奥が深く、ここで紹介した内容はほんの一端に過ぎません。しかし、これらの基本的な罠を避け、より良いパターンを適用することで、堅牢で保守性の高いWeb APIを構築するための強固な土台となるでしょう。継続的な学習と実践を通じて、さらなる知見を深めていくことが重要です。

Go net/http 深掘りガイド: クライアントサイド開発とライブラリ設計

Go言語の net/http パッケージは、サーバーサイドだけでなく、HTTPクライアントを実装するための強力な機能も提供しています。外部APIとの連携やマイクロサービス間の通信など、クライアントとしての役割は現代のアプリケーションにおいて不可欠です。

この記事では、net/http を用いたクライアントサイド開発に焦点を当て、基本的なリクエスト送信から、タイムアウト管理、コネクションプーリング、リトライ戦略、そして再利用可能で堅牢なAPIクライアントライブラリを設計するためのイディオムやパターンについて、具体的なコード例と共に解説します。

🚀 基本的なリクエスト送信とレスポンス処理

net/http パッケージでHTTPリクエストを送信する最も基本的な方法は、http.Get, http.Post, http.PostForm といったヘルパー関数、またはより柔軟な http.NewRequesthttp.Client.Do メソッドの組み合わせです。

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"
)

func main() {
	// GETリクエスト
	simpleGetRequest()

	// POSTリクエスト (JSON)
	postJSONRequest()

	// カスタムクライアントと詳細なリクエスト制御
	customClientRequest()
}

func simpleGetRequest() {
	log.Println("--- Simple GET Request ---")
	resp, err := http.Get("https://httpbin.org/get?name=GoUser&age=10")
	if err != nil {
		log.Printf("Error making GET request: %v", err)
		return
	}
	defer resp.Body.Close() // 必須!

	log.Printf("Status: %s (Code: %d)", resp.Status, resp.StatusCode)
	log.Printf("Headers: %+v", resp.Header)

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("Error reading response body: %v", err)
		return
	}
	log.Printf("Body: %s", string(bodyBytes))
}

func postJSONRequest() {
	log.Println("--- POST JSON Request ---")
	jsonData := `{"message": "Hello from Go client", "id": 123}`
	resp, err := http.Post("https://httpbin.org/post", "application/json", strings.NewReader(jsonData))
	if err != nil {
		log.Printf("Error making POST request: %v", err)
		return
	}
	defer resp.Body.Close()

	log.Printf("Status: %s (Code: %d)", resp.Status, resp.StatusCode)
	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("Error reading response body: %v", err)
		return
	}
	log.Printf("Body: %s", string(bodyBytes))

	// POST x-www-form-urlencoded
	// formData := url.Values{
	// 	"key1": {"value1"},
	// 	"key2": {"value2"},
	// }
	// respForm, err := http.PostForm("https://httpbin.org/post", formData)
	// ... (同様の処理) ...
}

レスポンスボディのクローズ

resp.Body (io.ReadCloser) は、読み取り後、あるいは不要な場合でも必ず Close() メソッドを呼び出す必要があります。これを怠ると、コネクションが解放されず、コネクションプールのコネクションが枯渇し、最終的に新しいリクエストが失敗する (too many open filesconnection timed out など) 原因となります。defer resp.Body.Close() を使うのが定石です。

レスポンスボディの読み捨て

たとえレスポンスボディの内容が不要な場合 (例えば、HEAD リクエストや、ステータスコードのみ確認したい場合など) でも、コネクションを再利用可能にするためには、ボディを完全に消費 (読み捨て) する必要があります。http.Client のデフォルトの Transport は、ボディが完全に読み取られた後にコネクションをプールに戻そうとします。

func checkStatusOnly(url string) (int, error) {
	resp, err := http.Get(url)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()

	// ボディの内容は不要だが、コネクション再利用のために読み捨てる
	_, err = io.Copy(io.Discard, resp.Body) // io.Discard は書き込みを破棄する io.Writer
	if err != nil {
		return resp.StatusCode, fmt.Errorf("failed to discard response body: %w", err)
	}

	return resp.StatusCode, nil
}

io.Copy(io.Discard, resp.Body) は、ボディの内容をすべて読み取り、どこにも保存せずに破棄するための効率的な方法です。

⚙️ http.Client の設定とライフサイクル

http.Gethttp.Post といったヘルパー関数は、内部でグローバルな http.DefaultClient を使用します。しかし、プロダクション環境では、タイムアウトやコネクションプーリングなどを細かく制御するために、独自の http.Client インスタンスを作成・設定し、それを使い回すことが強く推奨されます。

http.Client の再利用

http.Client インスタンスはスレッドセーフであり、複数のゴルーチンから安全に共有して使用できます。リクエストごとに新しい http.Client を作成すると、TCPコネクションの再利用 (Keep-Alive) やコネクションプーリングが効率的に機能せず、パフォーマンスが大幅に低下し、システムリソース (ポートなど) を浪費します。

悪い例: リクエストごとにクライアントを生成

// func makeRequestBad(url string) {
// 	client := &http.Client{} // NG: リクエストごとに生成
// 	resp, err := client.Get(url)
// 	// ...
// }

良い例: クライアントを一度生成して再利用

var myAppClient = &http.Client{ // アプリケーション全体で共有するクライアント
	Timeout: 15 * time.Second, // リクエスト全体のタイムアウト
}

func makeRequestGood(url string) (*http.Response, error) {
	return myAppClient.Get(url)
}

アプリケーションの起動時に一度だけクライアントを初期化し、それを必要な箇所で共有するのが一般的なパターンです。

タイムアウトの設定 (http.Client.Timeout)

http.ClientTimeout フィールドは、リクエスト全体の処理時間を制限します。これには、DNS解決、TCPコネクション確立、TLSハンドシェイク、リクエスト送信、レスポンスヘッダー受信、レスポンスボディ受信の全てが含まれます。 この値を設定しない (デフォルトはゼロ値で無制限) と、応答のないサーバーに対して無駄に待ち続け、リソースを占有してしまう可能性があります。

func customClientRequest() {
	log.Println("--- Custom Client Request ---")
	client := &http.Client{
		Timeout: 10 * time.Second, // サーバーからの応答が10秒なければタイムアウト
	}

	req, err := http.NewRequest("GET", "https://httpbin.org/delay/3", nil) // 3秒遅延するエンドポイント
	if err != nil {
		log.Printf("Error creating request: %v", err)
		return
	}
	req.Header.Set("X-Custom-Header", "GoClient-Demo")

	resp, err := client.Do(req)
	if err != nil {
		// タイムアウトエラーの判定
		if ue, ok := err.(*url.Error); ok && ue.Timeout() {
			log.Printf("Request timed out: %v", err)
		} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
			log.Printf("Network timeout: %v", err) // より広範なネットタイムアウト
		} else {
			log.Printf("Error performing request: %v", err)
		}
		return
	}
	defer resp.Body.Close()

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("Error reading response body: %v", err)
		return
	}
	log.Printf("Status: %s, Body length: %d", resp.Status, len(bodyBytes))
}

http.Transport のカスタマイズ

http.Client の振る舞いの多くは、その Transport フィールド (通常は *http.Transport 型) によって制御されます。より高度なカスタマイズ (コネクションプーリングの設定、TLS設定、プロキシ設定など) を行うには、http.Transport を独自に設定します。

http.DefaultTransport は、http.DefaultClient が使用するデフォルトのトランスポートであり、以下のような設定がされています。

// var DefaultTransport RoundTripper = &Transport{
// 	Proxy: ProxyFromEnvironment, // 環境変数 (HTTP_PROXY, HTTPS_PROXY) からプロキシ設定を読み込む
// 	DialContext: defaultTransportDialContext(&net.Dialer{
// 		Timeout:   30 * time.Second, // TCPコネクション確立のタイムアウト
// 		KeepAlive: 30 * time.Second, // TCP Keep-Alive間隔
// 	}),
// 	ForceAttemptHTTP2:     true,
// 	MaxIdleConns:          100, // 全ホストに対するアイドル状態の最大コネクション数
// 	IdleConnTimeout:       90 * time.Second, // アイドルコネクションがプールから削除されるまでの時間
// 	TLSHandshakeTimeout:   10 * time.Second, // TLSハンドシェイクのタイムアウト
// 	ExpectContinueTimeout: 1 * time.Second,  // "100 Continue" 応答待ち時間
// }

独自のトランスポートを設定する例:

func createCustomTransportClient() *http.Client {
	// DefaultTransportをベースにカスタマイズするのが一般的
	customTransport := http.DefaultTransport.(*http.Transport).Clone()
	// または、完全に新規作成:
	// customTransport := &http.Transport{}

	customTransport.MaxIdleConns = 50                     // アイドルコネクションの総数を変更
	customTransport.MaxIdleConnsPerHost = 10              // 特定ホストへのアイドルコネクション数を変更 (デフォルトは2)
	customTransport.ResponseHeaderTimeout = 20 * time.Second // レスポンスヘッダ受信までのタイムアウト
	// customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // 非推奨: 自己署名証明書などテスト用

	client := &http.Client{
		Transport: customTransport,
		Timeout:   30 * time.Second, // リクエスト全体のタイムアウトはClient側で設定
	}
	return client
}

// 使用例
// var specializedClient = createCustomTransportClient()
// resp, err := specializedClient.Get("...")

MaxIdleConnsPerHost は、特定のAPIエンドポイントに対して多数の並列リクエストを送信する場合に重要となることがあります。デフォルトの2では、並列度が高い場合にコネクション確立のオーバーヘッドが大きくなる可能性があります。

http.Client.TimeoutTransport 内の各種タイムアウトの関係性には注意が必要です。Client.Timeout はリクエスト全体の包括的なタイムアウトである一方、Transport のタイムアウトはコネクション確立、TLSハンドシェイク、レスポンスヘッダー受信といった個別のフェーズに対するものです。Client.Timeout が設定されていないと、Transport のタイムアウトが個別に効いたとしても、例えばレスポンスボディの受信が非常に遅い場合に際限なく待ち続ける可能性があります。

🔄 リクエストの再試行とボディの扱い

ネットワークは本質的に不安定なものであり、一時的なエラーが発生することは避けられません。堅牢なクライアントは、このような状況に対処するためのリトライロジックを備えているべきです。

リクエストボディと GetBody

HTTPリクエストがリダイレクト (特に 307 Temporary Redirect308 Permanent Redirect) される際や、リトライ処理を行う際には、クライアントがリクエストボディを再度送信する必要が生じることがあります。http.RequestBody フィールドは io.ReadCloser であり、一度読み取られると消費されてしまい、再度読み取ることはできません。

この問題に対処するため、http.Request には GetBody というフィールド ( func() (io.ReadCloser, error) 型) があります。GetBody が設定されていると、http.Client は必要に応じてこの関数を呼び出し、新しい io.ReadCloser (内容は同じ) を取得してボディを再送信します。

  • bytes.NewReader, strings.NewReader, os.File など io.Seeker を実装する型を http.NewRequest のボディとして渡すと、net/http パッケージが自動的に GetBody を適切に設定してくれます (内部で Seek(0, io.SeekStart) を行うため)。
  • bytes.Bufferio.Seeker を実装していないため、これを直接ボディとして使うと GetBody は設定されず、リダイレクトやリトライ時にボディが失われる可能性があります。bytes.NewReader(buf.Bytes()) のように bytes.NewReader でラップするのが良いでしょう。
  • カスタムの io.Reader を使う場合や、オンデマンドでボディを生成したい場合は、明示的に GetBody を設定する必要があります。
func requestWithGetBody(urlStr string, bodyData []byte) (*http.Response, error) {
	client := &http.Client{Timeout: 10 * time.Second} // リダイレクトポリシーはデフォルト

	// bytes.NewReader は Seeker を実装しているので GetBody が自動設定される
	req, err := http.NewRequest("POST", urlStr, bytes.NewReader(bodyData))
	if err != nil {
		return nil, err
	}

	// もし Seeker ではない io.Reader を使う場合や、毎回生成したい場合:
	// var bodyContent = []byte("some dynamic content")
	// req.GetBody = func() (io.ReadCloser, error) {
	// 	log.Println("GetBody called to regenerate request body")
	// 	return io.NopCloser(bytes.NewReader(bodyContent)), nil
	// }
	// req.ContentLength = int64(len(bodyContent)) // GetBody を使う場合はContentLengthも設定推奨

	return client.Do(req)
}

GetBody が設定されていない状態でリダイレクトやリトライが発生し、ボディの再送が必要になると、ボディなしでリクエストが送信されたり、http: ContentLength=XXX with Body=nil のようなエラーが発生したりすることがあります。

リトライ戦略: エクスポネンシャルバックオフとジッター

一時的なネットワークエラー (例: net.ErrorTemporary()true を返すもの) や、サーバー側の5xx系エラー (例: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout) は、時間をおいてリトライすることで成功する可能性があります。

効果的なリトライ戦略には以下の要素が含まれます。

  • エクスポネンシャルバックオフ: リトライ間の待機時間を指数関数的に増やします (例: 1秒, 2秒, 4秒, 8秒...)。これにより、障害発生直後のサーバーへの負荷集中を避けます。
  • ジッター: バックオフ間隔にランダムな揺らぎを加えます。これにより、複数のクライアントが全く同じタイミングでリトライする「Thundering Herd Problem (雷鳴の群れ問題)」を緩和します。
  • リトライ回数/時間制限: 無限にリトライしないように、最大リトライ回数や合計リトライ時間の上限を設定します。
  • 冪等性の考慮: GET, HEAD, OPTIONS, PUT, DELETE は一般的に冪等 (何度実行しても結果が同じ) なのでリトライしやすいですが、POST は冪等でない場合が多いため、リトライの可否を慎重に判断する必要があります。APIが冪等性キーをサポートしていれば活用します。

cenkalti/backoff/v4 のようなライブラリを利用すると、これらのロジックを簡単に実装できます。

// import "github.com/cenkalti/backoff/v4"

type RetryingClient struct {
	HTTPClient *http.Client
	MaxRetries int // backoffライブラリではElapsedTimeで制御することが多い
}

func (rc *RetryingClient) DoWithRetry(req *http.Request) (*http.Response, error) {
	var resp *http.Response
	var err error

	// リクエストボディの準備 (GetBodyが設定されていることを確認)
	if req.GetBody == nil && req.Body != nil {
		// Seekerでない場合、ここでGetBodyを設定する試みが必要になるかもしれない
		// あるいは、リトライ対象のリクエストではGetBodyが必須であるという前提を置く
		bodyBytes, readErr := io.ReadAll(req.Body)
		req.Body.Close() // 元のボディは閉じる
		if readErr != nil {
			return nil, fmt.Errorf("failed to read original body for retry: %w", readErr)
		}
		req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) // 最初の試行用
		req.GetBody = func() (io.ReadCloser, error) {      // リトライ用
			return io.NopCloser(bytes.NewReader(bodyBytes)), nil
		}
		req.ContentLength = int64(len(bodyBytes))
	}


	operation := func() error {
		// GetBodyから新しいボディを取得してリクエストを"リフレッシュ"する必要がある
		// ただし、client.Doが内部でGetBodyを呼ぶので、reqインスタンスの再生成は不要な場合が多い。
		// client.Do が req.Body を消費した後、リトライ時に req.GetBody を呼んでくれる。

		currentReq := req // 通常は同じリクエストオブジェクトを再利用できる

		// もしリクエストごとにヘッダなどを動的に変えたい場合は、ここで currentReq を複製・変更する
		// clonedReq := req.Clone(req.Context()) // ボディは複製されないので注意
		// if req.GetBody != nil {
		// 	body, _ := req.GetBody()
		// 	clonedReq.Body = body
		// }


		resp, err = rc.HTTPClient.Do(currentReq)
		if err != nil {
			// ネットワークエラーなど (タイムアウト含む)
			log.Printf("Retryable network error: %v", err)
			return err // backoff.Retryにエラーを通知
		}

		// 5xx系エラーはリトライ対象とする (例)
		if resp.StatusCode >= 500 && resp.StatusCode <= 599 {
			// レスポンスボディは消費してコネクションを解放する
			// これをしないと次のリトライでコネクションが不足する可能性がある
			defer func() {
				io.Copy(io.Discard, resp.Body)
				resp.Body.Close()
			}()
			log.Printf("Retryable server error: %s (status code: %d)", resp.Status, resp.StatusCode)
			return fmt.Errorf("server error: %s", resp.Status) // backoff.Retryにエラーを通知
		}

		// 成功またはリトライ対象でないエラー
		return nil
	}

	bo := backoff.NewExponentialBackOff()
	bo.MaxElapsedTime = 30 * time.Second // 合計リトライ時間の上限
	// bo.MaxInterval = 5 * time.Second    // 最大バックオフ間隔

	// backoff.RetryWithData を使うとレスポンスを返せるが、ここでは単純なRetry
	retryErr := backoff.Retry(operation, bo)

	if retryErr != nil {
		// 全てのリトライが失敗した
		// resp が nil の場合もある (最初の試行でネットワークエラーなど)
		// resp が nil でない場合、それはリトライ対象外のエラーレスポンスか、
		// 最後のリトライでリトライ対象エラーだったが、最終的に retryErr として返されたもの
		if resp != nil && resp.Body != nil { // resp があるなら念のため閉じる
			// ボディが既に上で閉じられているか確認が必要
		}
		return nil, fmt.Errorf("request failed after retries: %w (last HTTP response was: %v)", retryErr, resp)
	}

	return resp, nil // 最後の成功したレスポンス
}

この例では、DoWithRetry メソッド内でリトライロジックを実装しています。リトライ対象とするエラーの判定 (ネットワークエラーや特定のHTTPステータスコード)、レスポンスボディの適切な処理 (成功時以外は読み捨ててクローズ) が重要です。また、req.GetBody がリトライのために適切に機能するよう、ボディの準備に注意を払う必要があります。

🌐 context.Context の活用

サーバーサイドと同様に、クライアントサイドでも context.Context はタイムアウト制御やキャンセルシグナルの伝播に不可欠です。

http.NewRequestWithContext(ctx, method, url, body) を使用すると、リクエストにコンテキストを紐付けることができます。

  • コンテキストに設定されたタイムアウト (context.WithTimeout) は、http.Client.Timeout とは独立して機能し、より優先されます (両方設定されている場合、短い方が有効)。
  • 親コンテキストがキャンセルされると (cancelFunc の呼び出しなど)、進行中のHTTPリクエストもキャンセルしようとします (TCPコネクションの切断など)。これにより、不要になったリクエストのリソースを早期に解放できます。
func requestWithContextTimeout(urlStr string) {
	client := &http.Client{
		Timeout: 20 * time.Second, // クライアント全体のデフォルトタイムアウト
	}

	// このリクエスト専用のタイムアウト (5秒)
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel() // 必ず cancel を呼ぶ

	req, err := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/delay/10", nil) // 10秒遅延
	if err != nil {
		log.Printf("Error creating request: %v", err)
		return
	}

	log.Println("Sending request with 5s context timeout to a 10s delay endpoint...")
	resp, err := client.Do(req)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) { // ctx.Err() の結果
			log.Printf("Context deadline exceeded (request took too long): %v", err)
		} else if ue, ok := err.(*url.Error); ok && ue.Timeout() { // client.Timeoutの可能性も
			log.Printf("Request timed out (url.Error): %v", err)
		} else {
			log.Printf("Error performing request: %v", err)
		}
		return
	}
	defer resp.Body.Close()
	// ... レスポンス処理 ...
	log.Printf("Received response: %s", resp.Status)
}

コンテキストのキャンセルは、特に長時間かかる可能性のあるリクエストや、複数のAPI呼び出しを連鎖させるような場合に、アプリケーション全体の応答性を保つために重要です。

🛠️ APIクライアントライブラリの設計イディオム

外部APIを利用するための専用クライアントライブラリを作成する場合、使いやすく、堅牢で、テストしやすい設計を心がけることが重要です。

  1. クライアント構造体 (Client) とコンストラクタ

    • APIのベースURL、認証情報 (APIキー、トークンなど)、内部で使用する http.Client インスタンスなどを保持する Client 構造体を定義します。
    • NewClient のようなコンストラクタ関数を提供し、設定オプションを受け取れるようにします。Functional Options Pattern は、オプション設定を柔軟かつ拡張可能にするためによく用いられます。
    // (APIクライアントライブラリの例)
    // package myapi
    
    type Client struct {
        BaseURL    *url.URL
        APIKey     string
        HTTPClient *http.Client
        UserAgent  string
    
        // リソースごとのサービス (例)
        // Users    *UserService
        // Products *ProductService
    }
    
    type ClientOption func(*Client) error
    
    func NewClient(baseURLStr string, apiKey string, opts ...ClientOption) (*Client, error) {
        if baseURLStr == "" {
            return nil, errors.New("baseURL cannot be empty")
        }
        parsedBaseURL, err := url.Parse(baseURLStr)
        if err != nil {
            return nil, fmt.Errorf("invalid base URL: %w", err)
        }
    
        c := &Client{
            BaseURL:    parsedBaseURL,
            APIKey:     apiKey,
            HTTPClient: &http.Client{Timeout: 10 * time.Second}, // デフォルトのHTTPクライアント
            UserAgent:  "MyGoAPIClient/1.0",                 // デフォルトのUser-Agent
        }
    
        for _, opt := range opts {
            if err := opt(c); err != nil {
                return nil, err // オプション適用時のエラー
            }
        }
        return c, nil
    }
    
    // Functional Options の例
    func WithHTTPClient(hc *http.Client) ClientOption {
        return func(c *Client) error {
            if hc == nil {
                return errors.New("http client cannot be nil")
            }
            c.HTTPClient = hc
            return nil
        }
    }
    
    func WithUserAgent(ua string) ClientOption {
        return func(c *Client) error {
            if ua == "" {
                return errors.New("user agent cannot be empty")
            }
            c.UserAgent = ua
            return nil
        }
    }
    // 使用例:
    // customHC := &http.Client{Timeout: 5 * time.Second}
    // apiClient, err := myapi.NewClient("https://api.example.com/v1", "secret-key",
    // 	myapi.WithHTTPClient(customHC),
    // 	myapi.WithUserAgent("MySpecificApp/0.1"))
  2. リソースごとのサービス分割 APIが複数のリソース (例: Users, Products, Orders) を持つ場合、それぞれの操作をまとめたサービス構造体 (例: client.Users.Get(id), client.Products.List()) として提供すると、APIの構造が明確になり、使いやすくなります。これらのサービス構造体は、親の Client インスタンスへの参照を持ちます。

  3. 共通リクエスト処理メソッド (do メソッド) リクエストの作成 (http.NewRequestWithContext)、URLの解決、ヘッダーの共通設定 (認証トークン、Content-Type, Accept, User-Agent など)、リクエストの実行 (client.HTTPClient.Do)、レスポンスボディのデコード、共通エラーハンドリングといった処理は、プライベートな do メソッドに集約します。これにより、各APIエンドポイントに対応するメソッドの実装がDRYになります。

    // (Client構造体内のメソッドとして)
    func (c *Client) newRequest(ctx context.Context, method, pathStr string, bodyData interface{}) (*http.Request, error) {
        // ベースURLと相対パスを結合
        rel, err := url.Parse(pathStr)
        if err != nil {
            return nil, fmt.Errorf("invalid path: %w", err)
        }
        u := c.BaseURL.ResolveReference(rel)
    
        var bodyReader io.Reader
        if bodyData != nil {
            // bodyData を JSON などにエンコードして bodyReader を準備
            jsonData, err := json.Marshal(bodyData)
            if err != nil {
                return nil, fmt.Errorf("failed to marshal request body: %w", err)
            }
            bodyReader = bytes.NewBuffer(jsonData)
        }
    
        req, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader)
        if err != nil {
            return nil, fmt.Errorf("failed to create request: %w", err)
        }
    
        // 共通ヘッダーの設定
        if bodyData != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        req.Header.Set("Accept", "application/json")
        req.Header.Set("Authorization", "Bearer "+c.APIKey)
        req.Header.Set("User-Agent", c.UserAgent)
    
        return req, nil
    }
    
    // v はレスポンスボディをデコードする先のポインタ (nilの場合はボディを読まない)
    func (c *Client) do(req *http.Request, v interface{}) (*http.Response, error) {
        // ここでリトライロジックを組み込むことも可能 (RetryingClientの例を参照)
        resp, err := c.HTTPClient.Do(req)
        if err != nil {
            // context.Canceled や context.DeadlineExceeded をチェック
            select {
            case <-req.Context().Done():
                return nil, req.Context().Err() // 元のコンテキストエラーを返す
            default:
            }
            return nil, fmt.Errorf("failed to execute request to %s: %w", req.URL, err)
        }
        // defer resp.Body.Close() は呼び出し元でやるか、ここで完全に処理するか方針を決める
        // この例では、成功時以外はここでボディを処理し、成功時はレスポンスを返す
    
        // エラーレスポンスのチェック (例: 4xx, 5xx)
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            defer resp.Body.Close() // エラー時はここで閉じる
            // API固有のエラー構造体にデコードを試みる
            // apiErr := &APIErrorResponse{}
            // if err := json.NewDecoder(resp.Body).Decode(apiErr); err == nil {
            // 	return resp, apiErr // カスタムエラー型を返す
            // }
            // ボディの読み捨て
            _, _ = io.Copy(io.Discard, resp.Body)
            return resp, fmt.Errorf("API request failed with status %s (%s)", resp.Status, req.URL)
        }
    
        if v != nil { // レスポンスボディをデコードする場合
            // defer resp.Body.Close() は、ここでデコードしきるなら不要かもしれない
            // ただし、途中でエラーになった場合を考えると、やはりdeferが安全
            // 呼び出し元にrespを返す場合、resp.Body.Close()は呼び出し元の責任
            // このdoメソッドがresp.Bodyのクローズまで責任を持つか明確にする。
            // ここでは、vにデコードしたらクローズする設計とする。
            err = json.NewDecoder(resp.Body).Decode(v)
            resp.Body.Close() // デコード後、またはエラーでもクローズ
            if err != nil && err != io.EOF { // EOFは空ボディや成功時の末尾で発生しうる
                 return resp, fmt.Errorf("failed to decode response body from %s: %w", req.URL, err)
            }
        } else if resp.Body != nil {
            // ボディが不要な場合は読み捨ててクローズ
            io.Copy(io.Discard, resp.Body)
            resp.Body.Close()
        }
        // v にデコードした場合、resp.Bodyは既にクローズされている。
        // v が nil の場合も、ここでresp.Bodyはクローズされている。
        // 呼び出し元に加工前のrespを返す場合は、Bodyのクローズは呼び出し元。
        // ここでは、vにデコードすることを主目的とし、加工前のrespも返す。
        // しかし、vにデコードしたらBodyは読み終わっているので、
        // 呼び出し元が再度Bodyを読もうとしても読めない。
        // -> 設計として、vにデコードするか、生のrespを返すか、どちらか一方に特化する方がシンプル。
        //    ここでは、vにデコードし、*http.Responseは主にステータスやヘッダ確認用とする。
        return resp, nil
    }

    do メソッドの責務(特に resp.Body.Close() のタイミング)は明確に設計する必要があります。レスポンスボディをデコードして結果を返す場合は do メソッド内でクローズし、生の *http.Response を返す場合は呼び出し元がクローズする、といったルールを設けます。

  4. context.Context の標準的な受け入れ 全ての公開APIメソッドは、第一引数に context.Context を取るべきです。これにより、呼び出し元がタイムアウトやキャンセルを制御できるようになります。

  5. 明確なエラー型 API特有のエラー (レートリミット、認証失敗、バリデーションエラーなど) を表すカスタムエラー型を定義し、do メソッドでそれらを返却するようにします。これにより、ライブラリ利用者は errors.As などでエラーの種類を判別し、適切な処理を行うことができます。

  6. テスト容易性 コンストラクタで *http.Client を注入可能にすることで、テスト時には httptest.NewServer を使ったモックサーバーでテストできるようになります。

Goの net/http パッケージは、クライアントサイド開発においても非常に柔軟かつ強力なツールセットを提供します。これらのプラクティスを適用することで、外部サービスと連携するアプリケーションの信頼性と保守性を高めることができるでしょう。APIの特性や要件に応じて、これらのパターンを適宜調整し、最適化していくことが重要です。

対話履歴

ユーザー: goでweb apiを作る時に知っておくべき知識を箇条書きで列挙してください(net/httpを考えてます)。初級者向けの知識の話と玄人でも忘れがちなチェックリストの話と分けて回答してください。

さらっと眺めて分かる基礎知識ではなくハマりどころとかですね箇条書きで欲しいのは。

たとえば初心者向けにはstatus codeの設定前にbodyを出力すると200になってしまうだとか

玄人向けにはcontext cancelをどうハンドリングするべきか?とか。getはcancelすれば良いがpostは…とか。

初心者向けにはエラーハンドリングをミドルウェアでやるようにするにはどうするかなどもありそうですね(500のときなど)

最後にwebフレームワークを使うことを考えるのはどのタイミングですか?


AIの思考プロセス

Delving into HTTP Pitfalls

I'm currently focused on outlining common pitfalls and best practices when developing Web APIs with Go's net/http package. The structure is now taking shape, specifically catering to both beginners and seasoned developers, which requires further refinement. I'm prioritizing clarity and practical examples for each level, ensuring valuable insights.

Categorizing HTTP Errors

I'm now structuring the information into distinct sections for beginners and experts, with specific categories. For beginners, I'm focusing on common HTTP status code pitfalls and best practices. Code examples are prioritized to illustrate the nuances of WriteHeader and error handling. I'm also considering when to discuss the benefits of using a web framework.

Refining the Categorization

My focus has shifted to categorizing the information. I'm actively developing the "Beginner" section with points, such as HTTP status code nuances, request body best practices, and response body configuration. I'm emphasizing practical examples in code, and using JSON data. The "Expert" section's preliminary considerations will include more nuanced points, which will be added later. Also, I'm working on how and when to recommend the usage of Go Web frameworks.

Deep Diving into Details

I'm now structuring detailed information for both beginners and experts, with specific categories. For beginners, I'm now refining points, such as the timing of WriteHeader calls, appropriate status code selection, and the nuances of request body handling, with a strong focus on illustrative examples. For experts, I've started brainstorming on the use of r.Context() and how to build efficient middleware using the http.Handler interface.

Outlining Key Considerations

I'm now structuring information into two sections for easy consumption. For beginners, I'll emphasize critical areas such as WriteHeader timing and response structuring, illustrated through concise code examples. My planning now extends to expert discussions, where I'll delve into nuances of context management and error wrapping, ensuring comprehensive coverage of pitfalls and best practices.

Expanding the Scope

I've outlined crucial elements for both beginners and experts. The beginner's guide is fleshing out nicely, emphasizing WriteHeader timings and status code consistency. I've added more details to the expert section to include deep-diving on the nuances of context management, along with practical examples. I'm also preparing to discuss HTTP/2 and HTTP/3.

Expanding & Detailing Pitfalls

I'm now refining the organization of beginner and expert sections, and also adding specific code examples to demonstrate each pitfall and best practice, such as defer r.Body.Close(). The expert section details are focused on context management, graceful shutdown, and server security. I've also added more details to include discussions on streaming and the use of the http.Flusher interface.

Outlining Beginner & Expert Tips

I'm now structuring details for beginner and expert levels. The beginner section will cover essential topics, such as calling WriteHeader first and consistent response structuring, accompanied by concise code samples. For experts, I will dive into more advanced topics such as contextual information, error wrapping, and graceful shutdown to ensure a thorough understanding of potential pitfalls.

I've begun organizing the information into beginner and expert sections, prioritizing clarity and practical code examples. For beginners, I'm focusing on common pitfalls, like WriteHeader usage and status code selection. For experts, I'm delving into context management and error wrapping to ensure a comprehensive understanding of potential issues. I'm actively integrating code snippets and examples to enhance understanding.

Finalizing the Structure

I'm solidifying the structure into two main sections: "Beginner" and "Expert." The "Beginner" section will cover common pitfalls like HTTP status code nuances and response body structuring, accompanied by concise code samples. The "Expert" section will dive deeper into context management, error wrapping, and graceful shutdowns, ensuring a well-rounded understanding. I've also incorporated examples to illustrate how and when to use a web framework. I'm focusing on "gotchas" with code snippets.

Organizing Beginner & Expert Focus

I've outlined two major sections, one for beginners and one for experts, with beginner tips. I've broken down each with clear bullet points. I will be incorporating concise code samples within each section to illustrate common pitfalls and best practices. I will start the "Beginner" checklist with clear examples, and finish the "Expert" section with more detailed topics. I'm also discussing when to consider using a web framework.

AI: Goの`net/http`パッケージを使ってWeb APIを開発する際に知っておくべき知識を、初級者向けと玄人向けのハマりどころ・チェックリストとしてまとめました。

初級者向けのハマりどころ・チェックリスト

基本的なnet/httpの使い方を学んだ方が、次に直面しやすいポイントや、知っておくと開発がスムーズになる知識です。

  1. レスポンスヘッダーとステータスコードの書き込みタイミング

    • w.WriteHeader(statusCode) は、w.Write(body) を呼び出す に実行する必要があります。一度ボディを書き込み始めると、ステータスコードは自動的に 200 OK となり、ヘッダーも送信されてしまうため、後から変更できません。
    • 例: w.Write([]byte("error")) の後に w.WriteHeader(http.StatusInternalServerError) を呼んでも、ステータスコードは200のままです。
  2. リクエストボディのクローズ忘れ

    • r.Bodyio.ReadCloser です。読み取り後は必ず defer r.Body.Close() を呼び出してリソースを解放してください。そうしないと、コネクションが残り続け、リソースリークの原因となります。
  3. リクエストボディの複数回読み取り

    • r.Body は一度しか読み取れません。例えば、JSONをデコードした後に、再度生のリクエストボディを参照しようとしても空になっています。複数回処理する必要がある場合は、一度バイトスライスなどに読み込んでから利用します。
  4. エラーレスポンスの形式

    • http.Error(w, "エラーメッセージ", http.StatusInternalServerError) は便利ですが、APIではJSONなどの構造化されたエラーレスポンスを返すのが一般的です。独自のエラー構造体を定義し、JSONにエンコードして返すようにしましょう。
    • 例: {"error": {"code": 123, "message": "具体的なエラー内容"}}
  5. Content-Type ヘッダーの設定忘れ

    • JSONレスポンスを返す場合は w.Header().Set("Content-Type", "application/json") を忘れずに設定しましょう。クライアントがレスポンスを正しく解釈するために重要です。
  6. URLクエリパラメータやフォームデータのパース

    • クエリパラメータ (r.URL.Query()) やフォームデータ (r.ParseForm(), r.FormValue(), r.PostFormValue()) を利用する前に、必要に応じて r.ParseForm()r.ParseMultipartForm() を呼び出す必要があります。(r.FormValue などは内部でパースを試みますが、エラーハンドリングやサイズの大きなデータには明示的なパースが推奨されます)。
  7. エラーハンドリングの集約(シンプルなミドルウェア的発想)

    • 各ハンドラでエラー時のレスポンス書き込みロジックが重複しがちです。共通のエラー処理関数を作り、それを利用するか、http.Handler をラップするミドルウェアでエラーをキャッチして一元的にレスポンスを生成することを検討しましょう。
    • 例: func myHandler(w http.ResponseWriter, r *http.Request) error のようなシグネチャにして、エラーが返されたら共通処理でHTTPレスポンスを生成するラッパーを作る。
  8. ハンドラ内での予期せぬpanic

    • ハンドラ内でpanicが発生すると、サーバーはデフォルトでクライアントへの接続を中断し、ログにスタックトレースを出力します。プロダクション環境では、リカバリ用のミドルウェアを導入し、panicをキャッチして500エラーを返すなど、グレースフルな対応を検討しましょう。
  9. http.ServeMux のパスの扱い

    • http.ServeMux (デフォルトのルーター) のパスは、サフィックスマッチングです。/users/ と登録すると、/users/1/users/profile にもマッチします。意図しないマッチを避けるためには、パスの最後に / をつけるかつけないかを意識し、より厳密なルーティングが必要な場合はサードパーティルーターを検討します。
  10. タイムアウト設定の重要性

    • http.Server 構造体には ReadTimeout, WriteTimeout, IdleTimeout などのタイムアウト設定があります。これらを設定しないと、悪意のあるクライアントや遅いネットワークによってリソースが枯渇する可能性があります。

玄人でも忘れがちなチェックリスト

経験豊富な開発者でも見落としがちな点や、より高度なAPIを構築する上で考慮すべきポイントです。

  1. context.Context の適切なハンドリング

    • キャンセル伝播: r.Context() を利用して、リクエストのキャンセル(クライアントの切断など)やタイムアウトを検知し、無駄な処理を中断します。特に長時間かかる処理や外部API呼び出しでは必須です。
      • GETリクエスト: キャンセルされたら、処理を中断し、リソースを速やかに解放するのが一般的です。
      • POST/PUT/DELETEリクエスト:
        • 冪等でない操作: キャンセルされた場合、処理を中途半端に中断するとデータ不整合を引き起こす可能性があります。トランザクション管理や、処理を最後まで実行しきるか、あるいは安全にロールバックできる設計が必要です。場合によっては、キャンセルを無視して処理を完了させる判断もあり得ます(クライアントにはエラーを返しつつ)。
        • 冪等な操作: 中断しても再度リクエストされれば同じ結果になるため、比較的安全に中断できます。
    • 値の受け渡し: ミドルウェアからハンドラへリクエストスコープの値を渡すのに使いますが、型アサーションの失敗に注意し、キーの衝突を避けるために独自型をキーに使うなどの工夫が必要です。
  2. Graceful Shutdownの実装

    • サーバー停止シグナル(SIGINT, SIGTERM)を受け取った際に、進行中のリクエストが完了するのを待ってから安全にシャットダウンする仕組み (http.Server.Shutdown(ctx)) を実装します。これにより、データ損失や中途半端な処理を防ぎます。
  3. HTTP/2 Pushの適切な利用とServer-Sent Events (SSE)

    • HTTP/2 Server Pushは関連リソースを先読みさせるのに有効ですが、キャッシュとの相性やPushするリソースの選定を誤ると逆効果になることもあります。
    • SSE (text/event-stream) を使う場合、http.Flusher を使ってレスポンスを適切にフラッシュし、クライアントにリアルタイムでデータが送信されるようにする必要があります。また、接続が長時間維持されるため、リソース管理に注意が必要です。
  4. リクエスト/レスポンスボディのストリーミング処理

    • 大きなファイルを扱う場合など、ボディ全体をメモリに読み込むのではなく、ストリームとして処理することでメモリ効率を大幅に改善できます。io.Copy やチャンクでの読み書きを検討します。
  5. エラーのラップとスタックトレース

    • errors.Is, errors.As を活用し、エラーの種類に応じた適切な処理やHTTPステータスコードの返却を行います。エラーをラップ (fmt.Errorf("...: %w", err)) することで、根本原因のスタックトレースを保持し、デバッグを容易にします。
  6. net/http/pprof によるプロファイリング

    • パフォーマンスボトルネックの特定やメモリリークの調査には net/http/pprof が非常に強力です。本番環境ではセキュリティに配慮し、別のポートで公開するか認証をかけるなどの対策が必要です。
  7. コネクションプーリングとhttp.Transportのカスタマイズ

    • 外部APIを呼び出すクライアント (http.Client) では、http.Transport をカスタマイズしてコネクションプールの設定 (MaxIdleConns, MaxIdleConnsPerHost, IdleConnTimeoutなど) を適切に行うことで、パフォーマンスを最適化し、コネクション関連のエラーを防ぎます。デフォルトのhttp.DefaultTransportを安易に書き換えるのではなく、専用のhttp.Clientインスタンスを作成・利用しましょう。
  8. 冪等性の担保

    • 特にPOST, PUT, DELETEリクエストにおいて、ネットワークエラーなどでクライアントがリトライした場合でも、複数回実行されて副作用が重複しないようにAPIを設計することが重要です。リクエストIDやETagなどを活用します。
  9. CORS (Cross-Origin Resource Sharing) の厳密な設定

    • Access-Control-Allow-Origin に安易に * を設定するのではなく、許可するオリジンを限定します。また、Access-Control-Allow-Methods, Access-Control-Allow-Headers なども適切に設定し、プリフライトリクエスト (OPTIONS) にも対応します。
  10. レートリミットとリソース制限

    • http.MaxBytesReader でリクエストボディのサイズを制限するだけでなく、IPアドレスや認証トークンに基づいたレートリミットを実装し、DoS攻撃やAPIの乱用を防ぎます。
  11. 構造化ロギング

    • リクエストID、ユーザーID、処理時間、ステータスコードなど、検索や分析が容易な形式でログを出力します。ログレベルの使い分けも重要です。
  12. 設定の外部化と動的リロード

    • ハードコーディングされた設定値を避け、環境変数や設定ファイルから読み込むようにします。高度なケースでは、設定変更を検知して動的にリロードする仕組みも検討されます。

Webフレームワークを検討するタイミング

net/http は非常に強力で柔軟ですが、ある程度の規模や複雑さになると、Webフレームワークの導入を検討する価値が出てきます。以下のようなタイミングが考えられます。

  1. ルーティングが複雑になった時:
    • パスパラメータの抽出 (/users/{id})、メソッドベースのルーティング (GET /users, POST /users)、正規表現によるルーティングなど、net/http の標準機能だけではコードが煩雑になり始めたら。
  2. ミドルウェアの管理が煩雑になった時:
    • 認証、ロギング、CORS、リカバリなど、多くのミドルウェアをチェインして適用する必要があり、その管理や順序が複雑になってきたら。多くのフレームワークは洗練されたミドルウェア機構を提供します。
  3. リクエストのバリデーションやデータバインディングを効率化したい時:
    • リクエストボディのJSONやフォームデータを構造体にマッピングし、バリデーションルール(必須項目、型、フォーマットなど)を宣言的に定義したい場合。
  4. 定型的な処理が増えてきた時:
    • JSONレスポンスの生成、エラーハンドリング、テンプレートエンジンとの連携、セッション管理など、Webアプリケーションでよく必要とされる機能を自前で実装するコストが高くなってきたら。
  5. 開発効率と標準化を重視する時:
    • チームでの開発において、コードの書き方や構造をある程度統一し、生産性を向上させたい場合。フレームワークが提供する規約に従うことで、学習コストは初期にかかりますが、その後の開発速度が上がることが期待できます。
  6. エコシステムを活用したい時:
    • 特定のフレームワークが持つ豊富なプラグインや、周辺ツール(ORM、マイグレーションツールなど)との連携の恩恵を受けたい場合。
  7. より高度な機能が簡単に使えることを期待する時:
    • WebSocketサポート、gRPCゲートウェイ、OpenAPI仕様からのコード自動生成など、フレームワークがサポートしている高度な機能を利用したい場合。

結論として: net/http だけで十分なケースも多いですが、「車輪の再発明」感が出てきたり、ボイラープレートコードが増えて本質的なロジックに集中できなくなってきたと感じたら、フレームワークの導入を検討する良いタイミングです。プロジェクトの規模、チームのスキルセット、要件などを総合的に判断しましょう。

(返答終了理由: STOP)


ユーザー: もう少しありそうではないですか?たとえばより実装に近づくとResponseWriterを自作したときにhttp.Flusherにも実は対応しておいた方が良いみたいな話など。


AIの思考プロセス

Analyzing HTTP Gotchas

I'm focusing on the deeper pitfalls of net/http for the user. Currently, I'm digging into http.ResponseWriter and its complexities. My aim is to surface less obvious implementation techniques, particularly those related to interface compliance. I'm exploring common omissions in fulfilling the contract of that important interface.

Refining the Expert Checklist

Building on the previous exploration of http.ResponseWriter, I'm now refining the scope to create a more comprehensive checklist for expert users. I'm focusing on interface fulfillment, server configuration, and client/transport customization. Specifically, I'm digging into wrapping http.ResponseWriter while ensuring compliance with interfaces like http.Hijacker and http.Pusher. I'm also planning to delve into TLSConfig settings and ConnState hooks for the server. Finally, I'm turning attention to customizing http.Client via DialContext, including DNS cache and custom resolvers.

Extending the Checklist Scope

Now I'm moving beyond the initial scope to incorporate more advanced techniques, like utilizing io.ReaderFrom when wrapping http.ResponseWriter. I'm also planning to incorporate deeper configurations for http.Server, especially in TLS, ConnState, and ErrorLog customization. Moreover, I'll extend the scope to encompass http.Client optimizations, addressing retry logic, custom dial contexts, and detailed TLS ClientConfig fine-tuning. I intend to integrate advanced request/response processing tricks using trailers and chunked encoding. I'll include the details of HTTP/2 aspects, such as prioritization, flow control, and header compression (HPACK). In addition, I will incorporate security-related niche topics, such as security headers and timing attack considerations.

Extending for Expert Users

I've taken the user's feedback about http.Flusher seriously. It highlights how important it is to be thorough with interface implementation. Now I'm expanding the scope to include topics like wrapping http.ResponseWriter and ensuring compliance with multiple interfaces, including http.Hijacker and http.Pusher. I'm also planning to delve into more advanced configurations for http.Server, especially TLS settings and ConnState hooks. Finally, I'll incorporate nuances of http.Client optimization, including DNS cache and custom resolvers via DialContext. I also will incorporate retry logic implementation and ExpectContinueTimeout behavior.

Elaborating on Interface Fulfillment

I'm now focusing on deepening the checklist for experts, taking the user's feedback about http.Flusher seriously. I'm exploring the implications of wrapping http.ResponseWriter and ensuring compliance with multiple interfaces, including http.Hijacker, http.Pusher, and io.ReaderFrom, to guarantee smooth operation. The intricacies of http.Request.ContentLength and chunked transfer encoding, and http.Hijacker interactions, are under my microscope.

Expanding Expert Checklist Scope

Now I'm delving deeper into the checklist, with a focus on comprehensive interface fulfillment. I'm exploring the implications of properly implementing http.Flusher, http.Hijacker, and http.Pusher when wrapping http.ResponseWriter. I'm also planning to incorporate strategies for efficient use of io.ReaderFrom, along with nuances of managing http.Request.ContentLength and chunked transfer encoding, and interactions in situations where http.Hijacker is involved. My plan includes an HTTP/2 section to address PUSH_PROMISE and cache complications. Finally, I will address custom RoundTrippers.

Delving Deeper for the Expert Checklist

I'm now concentrating on expanding the checklist for experts, specifically addressing interface compliance intricacies. I'm exploring the implications of wrapping http.ResponseWriter while ensuring that http.Flusher, http.Hijacker, http.Pusher, and io.ReaderFrom interfaces are properly managed. I'm considering the ContentLength nuances, chunked encoding considerations, and interactions with http.Hijacker. I will address HTTP/2 complexities, including push, and consider custom RoundTrippers. I'm also addressing request context deadlines with external calls.

Delving Deeper on Optimization

I'm now expanding the checklist to address advanced areas for expert users, focusing on edge cases in net/http. I'm exploring the complexities of ContentLength with chunked encoding, as well as the nuances of using http.Hijacker, and efficient use of io.ReaderFrom. I'm also preparing for HTTP/2 nuances. Finally, I'm adding custom RoundTripper implementation considerations, and addressing request context management.

Delving Deeper: Expert Checklist Update

I'm now focusing on a more advanced checklist for experts, taking the user's feedback seriously. I'm exploring the implications of wrapping http.ResponseWriter while ensuring that http.Flusher, http.Hijacker, http.Pusher, and io.ReaderFrom interfaces are properly managed. This extends to ContentLength, chunked encoding, interactions with http.Hijacker, and HTTP/2 nuances. My goal is to incorporate best practices for custom RoundTrippers and request context management for users.

AI: ご指摘ありがとうございます!確かに、より実装に近い部分や、インタフェースの充足といった細部には、経験者でもうっかりハマってしまう落とし穴が潜んでいますね。

先の回答に加えて、さらに玄人向けとして意識しておくと良い点を追記します。


玄人でも忘れがちなチェックリスト (追加・深掘り)

  1. http.ResponseWriter のラップとインタフェース実装の網羅性:

    • カスタム ResponseWriter (ロギング、メトリクス収集、レスポンス改変など) を実装する際、ラップする元の ResponseWriter が実装している可能性のあるインタフェース (http.Flusher, http.Hijacker, http.Pusher, io.ReaderFrom) も適切に委譲または実装しないと、特定の機能が動作しなくなります。
      • http.Flusher: Server-Sent Events (SSE) などでストリーミングレスポンスを送信する際に必須。これが欠けると、レスポンスがバッファリングされてリアルタイム性が失われます。
      • http.Hijacker: WebSocketへのプロトコルアップグレードや、TCPコネクションを直接操作する場合に必要。
      • http.Pusher: HTTP/2 Server Push を行う場合に必要。
      • io.ReaderFrom: io.Copy などが内部でこのインタフェースを検出し、最適化されたデータ転送(sendfileシステムコールなど)を行うことがあります。ファイルサーバーなどでパフォーマンスに影響します。
    • http.CloseNotifier は非推奨 (r.Context().Done() を使用) ですが、古いコードやライブラリとの互換性のために意識することがあるかもしれません。
  2. リクエストボディの扱いと http.MaxBytesReader:

    • r.Body = http.MaxBytesReader(w, r.Body, maxBytes) でリクエストボディサイズを制限できますが、Content-Length が未指定または Transfer-Encoding: chunked の場合、読み込みを開始してから制限を超えるか判断されるため、巨大なチャンクが少量送られてくるだけで一時的にメモリを圧迫する可能性も考慮します。
    • ストリーミングで処理する場合でも、悪意のあるクライアントが無限にデータを送り続けるケースに対応するため、何らかのタイムアウトや全体サイズの監視は必要です。
  3. http.Hijacker 利用時の責任:

    • コネクションをハイジャックした後は、HTTPサーバーの管理下から外れます。エラーハンドリング、タイムアウト管理、コネクションのクローズは全て開発者の責任となります。これらを怠るとリソースリークに繋がります。
  4. http.Clienthttp.Transport の詳細な設定と再利用:

    • カスタム DialContext: DNS解決のカスタマイズ、特定のネットワークインターフェースの使用、コネクション確立前の処理追加などに。
    • TLSClientConfig: InsecureSkipVerify はデバッグ時以外は絶対に避け、RootCAsClientCAs を適切に設定してセキュアな通信を担保します。
    • ExpectContinueTimeout: 100-continue レスポンスを待つ時間を設定します。大きなボディを送信する前にサーバーの意向を確認する際に影響します。
    • http.Client インスタンスはスレッドセーフであり、再利用が推奨されます。リクエストごとに生成すると、コネクションプーリングが効かずパフォーマンスが著しく低下します。設定が同じであれば、グローバルまたは長期間生存するインスタンスを使い回しましょう。
  5. Trailerヘッダーの取り扱い:

    • チャンクエンコーディングされたリクエスト/レスポンスの最後に送信されるヘッダー。例えば、ストリーミング処理完了後にデータのダイジェストを送信するなどの用途があります。Request.TrailerResponseWriter.Header().Add("Trailer", "X-My-Trailer")Write 後の Header().Set("X-My-Trailer", "value") のような使い方を理解しておく必要があります。
  6. http.ServeMux のパス正規化と末尾スラッシュ:

    • 標準の ServeMux はパスを内部で path.Clean します(例: //foo -> /foo)。また、登録したパスの末尾にスラッシュがあるか否かでマッチングの挙動が変わります(/path//path/anything にマッチするが、/path/path にのみ厳密にマッチ)。これが意図しない挙動やセキュリティリスクに繋がることが稀にあります。
  7. ヘッダーキーの正規化と大文字・小文字の区別:

    • HTTPヘッダーキーは仕様上ケースインセンシティブですが、net/httptextproto.CanonicalMIMEHeaderKey (例: content-type -> Content-Type) を用いて正規化します。r.Header.Get("foo")r.Header.Get("Foo") は同じ結果を返しますが、ヘッダーマップを直接操作する場合や、外部システムとの連携で厳密なキーが求められる場合は注意が必要です。
  8. SetCookie の属性の完全な理解と設定:

    • Domain, Path, Secure, HttpOnly, SameSite (Lax, Strict, None) といった属性を適切に設定しないと、CSRF脆弱性や意図しないCookieの送信/ブロックが発生します。特に SameSite=None を使う場合は Secure 属性も必須です。
  9. Transfer-Encoding vs Content-Encoding:

    • Transfer-Encoding (例: chunked) はホップバイホップのエンコーディングで、メッセージボディを転送する方法を定義します。プロキシなどが途中で変更することがあります。
    • Content-Encoding (例: gzip) はエンドツーエンドのエンコーディングで、リソース自体のエンコーディングを示します。
    • ミドルウェアで圧縮/解凍を行う際は、これらのヘッダーを正しく処理・設定する必要があります。例えば、自前でgzip圧縮した場合、Content-Encoding: gzip を設定し、Content-Length も圧縮後のサイズにする必要があります。
  10. ミドルウェアにおける ResponseWriter のバッファリング:

    • 後続のハンドラが書き込んだレスポンスをミドルウェアで加工したい場合(例: レスポンスボディの改変、共通ヘッダーの追加)、一度レスポンスをバッファリングする必要があります。この際、大きなレスポンスに対するメモリ使用量や、http.Flusher など元の ResponseWriter が持つ機能をどう扱うかを考慮する必要があります。httptest.ResponseRecorder がこの目的で使えることがありますが、本番コードでは注意が必要です。
  11. Context の値の伝播と型安全性:

    • ミドルウェアからハンドラへ値を渡すために context.WithValue を使う際、キーの衝突を避けるために非公開の独自型をキーとして使うことが推奨されます。また、取り出す際の型アサーションが失敗する可能性を常に考慮し、適切にエラーハンドリングする必要があります。

これらの点は、net/http を深く使い込んでいると遭遇したり、考慮が必要になったりするポイントです。ご指摘いただいたように、実際に手を動かしてハマってみることでより深く理解できる部分も多いですね。

(返答終了理由: STOP)


ユーザー: 今度は一般的なイディオムというかパターンについて教えてください。たとえばどういうものかと言えばミドルウェア上でstatus codeをログに取るときのためにそれを保持するResponseWriterでラップするだとか。

500を返す時にはエラーメッセージをそのまま渡さずログに出力してinternal server errorとだけレスポンスのメッセージにするだとか。

あとはテストのときにどこでエラーが出たか分かるようにstatus codeのチェック時にResponseのbodyもt.Fatalfなどのメッセージに含めるだとか。


AI: 承知いたしました。net/http を使ったWeb API開発における、一般的なイディオムや実践的なパターンについて解説します。これらはコードの可読性、保守性、テストの容易性を高めるのに役立ちます。


net/http 開発における一般的なイディオム・パターン

  1. レスポンスステータスコード記録用 ResponseWriter ラッパー (ミドルウェア)

    • 目的: アクセスログやメトリクス収集のために、ハンドラが最終的に設定したステータスコードを後から(ミドルウェア層で)取得する。
    • パターン:
      type statusRecorder struct {
          http.ResponseWriter
          status int
      }
      
      func (r *statusRecorder) WriteHeader(statusCode int) {
          r.status = statusCode
          r.ResponseWriter.WriteHeader(statusCode)
      }
      
      // Writeメソッドもラップして、WriteHeaderが呼ばれなかった場合のデフォルト200を記録
      func (r *statusRecorder) Write(b []byte) (int, error) {
          if r.status == 0 { // WriteHeaderが呼ばれていない
              r.status = http.StatusOK
          }
          return r.ResponseWriter.Write(b)
      }
      
      func loggingMiddleware(next http.Handler) http.Handler {
          return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
              recorder := &statusRecorder{
                  ResponseWriter: w,
                  status:         0, // 初期値(WriteHeader未呼び出し判定用)
              }
              start := time.Now()
              next.ServeHTTP(recorder, r)
              duration := time.Since(start)
      
              // recorder.status で最終的なステータスコードを取得できる
              log.Printf("[%s] %q %d %v", r.Method, r.URL.Path, recorder.status, duration)
          })
      }
    • ポイント: WriteHeader をオーバーライドしてステータスコードを記録。Write もオーバーライドし、WriteHeader が呼び出されずにボディが書き込まれた場合に 200 OK を記録するようにする。ラップする元の ResponseWriter が持つ http.Flusher などのインタフェースも適切に実装/委譲することを忘れない。
  2. エラーレスポンスの抽象化と一元化

    • 目的: ユーザーに見せるエラーメッセージと、ログに出力する詳細なエラー情報を分離し、一貫したエラーレスポンス形式を提供する。
    • パターン:
      • ハンドラは詳細なエラー (error 型) を返す。
      • 共通のエラーハンドリングミドルウェアやラッパー関数が、エラーの種類に応じて適切なHTTPステータスコードとユーザーフレンドリーなJSONレスポンスを生成する。
      • 詳細なエラー情報(スタックトレースなど)はログにのみ出力する。
      // 例: 共通エラーレスポンス構造体
      type APIErrorResponse struct {
          Error string `json:"error"`
      }
      
      func handleError(w http.ResponseWriter, r *http.Request, err error, statusCode int, userMessage string) {
          log.Printf("Error serving request %s %s: %v", r.Method, r.URL.Path, err) // 詳細エラーをログに
          w.Header().Set("Content-Type", "application/json")
          w.WriteHeader(statusCode)
          json.NewEncoder(w).Encode(APIErrorResponse{Error: userMessage})
      }
      
      // ハンドラ内での使い方
      // ...
      // if err != nil {
      //     handleError(w, r, err, http.StatusInternalServerError, "An internal server error occurred.")
      //     return
      // }
    • ポイント: 500系エラーの場合は特に、内部実装の詳細をユーザーに漏らさない。「Internal Server Error」などの一般的なメッセージを返し、詳細はログで確認する。
  3. リクエスト/レスポンスのヘルパー関数

    • 目的: JSONのエンコード/デコード、エラーレスポンスの書き込みなど、定型的な処理を関数化してDRY原則を守る。
    • パターン:
      func decodeJSONBody(r *http.Request, dst interface{}) error {
          // Content-Typeチェック、MaxBytesReaderの設定など
          dec := json.NewDecoder(r.Body)
          dec.DisallowUnknownFields() // 意図しないフィールドが含まれていたらエラーにする
          return dec.Decode(dst)
      }
      
      func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
          w.Header().Set("Content-Type", "application/json")
          w.WriteHeader(statusCode)
          if data != nil {
              if err := json.NewEncoder(w).Encode(data); err != nil {
                  // エンコードエラー時のログ出力など
                  log.Printf("Error encoding JSON response: %v", err)
              }
          }
      }
      
      func respondError(w http.ResponseWriter, statusCode int, message string) {
          respondJSON(w, statusCode, map[string]string{"error": message})
      }
    • ポイント: これらのヘルパーは、ハンドラのロジックをシンプルにし、一貫性を保つのに役立つ。
  4. context.Context を活用した値の受け渡し (ミドルウェア -> ハンドラ)

    • 目的: リクエストスコープのデータ(認証情報、リクエストIDなど)を安全にハンドラに渡す。
    • パターン:
      type contextKey string // 衝突を避けるための非公開型
      const userIDKey contextKey = "userID"
      
      func authMiddleware(next http.Handler) http.Handler {
          return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
              // ... 認証処理 ...
              userID := "user-123" // 認証されたユーザーID
              ctx := context.WithValue(r.Context(), userIDKey, userID)
              next.ServeHTTP(w, r.WithContext(ctx))
          })
      }
      
      func myHandler(w http.ResponseWriter, r *http.Request) {
          userID, ok := r.Context().Value(userIDKey).(string)
          if !ok {
              // エラー処理: userIDが見つからない or 型が違う
              respondError(w, http.StatusInternalServerError, "Could not retrieve user ID")
              return
          }
          // userIDを使った処理
          fmt.Fprintf(w, "Hello, user %s!", userID)
      }
    • ポイント: キーには非公開の独自型を使い、値を取り出す際は型アサーションとエラーチェックを忘れずに行う。
  5. テストにおける詳細なエラーメッセージ (レスポンスボディの包含)

    • 目的: テスト失敗時に、期待したステータスコードと実際の結果だけでなく、レスポンスボディも確認できるようにしてデバッグを容易にする。
    • パターン:
      // httptest と組み合わせる
      func TestMyHandler_Success(t *testing.T) {
          req := httptest.NewRequest("GET", "/test", nil)
          rr := httptest.NewRecorder()
          handler := http.HandlerFunc(myHandler)
      
          handler.ServeHTTP(rr, req)
      
          if status := rr.Code; status != http.StatusOK {
              t.Errorf("handler returned wrong status code: got %v want %v. Body: %s",
                  status, http.StatusOK, rr.Body.String())
          }
      
          expectedBody := `{"message":"success"}`
          if rr.Body.String() != expectedBody {
              t.Errorf("handler returned unexpected body: got %s want %s",
                  rr.Body.String(), expectedBody)
          }
      }
    • ポイント: t.Errorft.Fatalf のメッセージに rr.Body.String() を含めることで、具体的に何が返ってきたのかが一目でわかる。
  6. ハンドラシグネチャの統一 (エラーを返すパターン)

    • 目的: ハンドラからエラーを返し、共通のミドルウェア/ラッパーでエラー処理とレスポンス生成を一元化する。
    • パターン:
      type appHandler func(w http.ResponseWriter, r *http.Request) error
      
      func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
          if err := fn(w, r); err != nil {
              // ここでエラーの種類に応じてHTTPステータスコードやレスポンスを決定
              log.Printf("Handler error: %v", err)
              var appErr *myAppError // カスタムエラー型
              if errors.As(err, &appErr) {
                  respondError(w, appErr.StatusCode, appErr.Message)
              } else {
                  respondError(w, http.StatusInternalServerError, "Internal Server Error")
              }
          }
      }
      
      // ハンドラの定義
      func mySpecificHandler(w http.ResponseWriter, r *http.Request) error {
          // ... 処理 ...
          if someCondition {
              return &myAppError{StatusCode: http.StatusBadRequest, Message: "Invalid input"}
          }
          // ...
          respondJSON(w, http.StatusOK, map[string]string{"data": "success"})
          return nil
      }
      
      // ルーターへの登録
      // mux.Handle("/path", appHandler(mySpecificHandler))
      myAppErrorError() メソッドと、StatusCodeMessage などのフィールドを持つカスタムエラー型。
    • ポイント: ハンドラがHTTPレスポンスの書き込みとエラー処理のロジックを分離でき、よりビジネスロジックに集中できる。
  7. 依存性の注入 (Dependency Injection)

    • 目的: ハンドラやサービスが依存するコンポーネント(データベース接続、外部サービスクライアントなど)を外部から注入することで、テスト容易性を高め、関心の分離を促進する。
    • パターン:
      type DB interface {
          GetUser(id string) (User, error)
      }
      
      type UserHandler struct {
          db DB
          // logger *log.Logger など
      }
      
      func NewUserHandler(db DB) *UserHandler {
          return &UserHandler{db: db}
      }
      
      func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
          // ... id を取得 ...
          // user, err := h.db.GetUser(userID)
          // ... レスポンス処理 ...
      }
      
      // main.go やセットアップ時
      // realDB := NewRealDBConnector()
      // userHandler := NewUserHandler(realDB)
      // mux.HandleFunc("/users/{id}", userHandler.GetUser)
      
      // テスト時
      // mockDB := new(MockDB) // testify/mock などのモックライブラリを使用
      // userHandler := NewUserHandler(mockDB)
      // ... テスト実行 ...
    • ポイント: ハンドラが具体的な実装ではなくインタフェースに依存するようにする。コンストラクタで依存性を注入する。

これらのパターンは、net/http を使った開発をより構造化し、堅牢にするための良い出発点となります。プロジェクトの規模や要件に応じて、これらを組み合わせたり、さらに洗練させたりしていくことになります。

(返答終了理由: STOP)


ユーザー: ちなみに今度はクライアントの方の実装で考えるべきことなどを加えてください。

たとえばio.Discardなどにbodyを渡してあげないとkeep-aliveの関係でコネクションが再利用されないだとか。初心者向けにはhttp.Clientを使い回すべきとか、並列処理のときのidle connectionsの数とか。

最後にapi Clientのライブラリを作るときのイディオムというかパターンを教えてください。


AI: 承知いたしました。net/http を使ったクライアント側の実装で考慮すべき点と、APIクライアントライブラリを作成する際のイディオムやパターンについて解説します。


net/http クライアント実装における考慮点

初心者向けのハマりどころ・基本

  1. http.Client の再利用

    • 問題: リクエストごとに http.Client{} を生成すると、TCPコネクションの再利用(Keep-Alive)やコネクションプーリングが機能せず、パフォーマンスが著しく低下し、リソース(ポートなど)を浪費します。
    • 対策: アプリケーションのライフサイクルで一度 http.Client を生成し、それを使い回します。http.DefaultClient はグローバルなクライアントですが、タイムアウトなどが設定されていないため、プロダクションではカスタム設定したクライアントを推奨します。
      var myClient = &http.Client{Timeout: 10 * time.Second} // 例
      
      func fetchData() {
          // resp, err := http.DefaultClient.Get("...") // 非推奨 (タイムアウトなし)
          resp, err := myClient.Get("http://example.com/api/data")
          // ...
      }
  2. レスポンスボディのクローズ忘れ

    • 問題: resp.Body を読み取った後、Close() しないとコネクションが解放されず、コネクションプールのコネクションが枯渇し、最終的に新しいリクエストが失敗する(too many open files など)原因になります。
    • 対策: defer resp.Body.Close() を必ず呼び出します。
      resp, err := myClient.Get("http://example.com/api/data")
      if err != nil {
          // エラー処理
          return
      }
      defer resp.Body.Close()
      
      bodyBytes, err := io.ReadAll(resp.Body)
      // ...
  3. レスポンスボディの読み捨て (コネクション再利用のため)

    • 問題: レスポンスボディを全く読まない場合や、途中で読むのをやめた場合でも、コネクションを再利用するためにはボディを完全に消費(読み捨て)する必要があります。そうしないと、そのコネクションは再利用されず、閉じられてしまう可能性があります。
    • 対策: レスポンスボディの内容が不要な場合でも、io.Copy(io.Discard, resp.Body) で読み捨てます。
      resp, err := myClient.Post("http://example.com/api/action", "application/json", someBody)
      if err != nil {
          // ...
      }
      defer resp.Body.Close()
      
      if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { // 例: 成功時以外
          // ボディの内容が不要でも読み捨てる
          _, err := io.Copy(io.Discard, resp.Body)
          if err != nil {
              log.Printf("Failed to discard response body: %v", err)
          }
          // エラー処理
          return
      }
      // 必要ならボディを読む
      // bodyBytes, _ := io.ReadAll(resp.Body)
    • 注意: http.ClientTransport (通常は http.DefaultTransport) は、ボディが完全に読み取られた後にコネクションをプールに戻そうとします。
  4. リクエストタイムアウトの設定

    • 問題: http.ClientTimeout フィールドを設定しないと、リクエスト全体(接続、リクエスト送信、レスポンスヘッダー受信、レスポンスボディ受信の合計)のタイムアウトが無制限になり、応答のないサーバーに対して無駄に待ち続ける可能性があります。
    • 対策: http.Client{Timeout: duration} で適切なタイムアウトを設定します。個別のリクエストでより詳細なタイムアウト制御が必要な場合は、context.WithTimeout をリクエストに紐付けます。
      // Client全体のタイムアウト
      client := &http.Client{Timeout: 30 * time.Second}
      
      // リクエストごとのタイムアウト
      ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
      defer cancel()
      
      req, _ := http.NewRequestWithContext(ctx, "GET", "http://example.com", nil)
      resp, err := client.Do(req)
      // ...

玄人向けのチェックリスト・詳細設定

  1. http.Transport のカスタマイズ

    • http.Client の振る舞いの多くは、内部の Transport (通常は *http.Transport) によって制御されます。これをカスタマイズすることで、より高度な制御が可能です。
    • MaxIdleConns: 全ホストに対するアイドル状態のコネクションの最大数。デフォルトは100。
    • MaxIdleConnsPerHost: 単一ホストに対するアイドル状態のコネクションの最大数。デフォルトは DefaultMaxIdleConnsPerHost (現在は2)。高トラフィックな特定ホストに対しては増やすことを検討。
    • IdleConnTimeout: アイドル状態のコネクションがプールから削除されるまでの時間。デフォルトは90秒。
    • ResponseHeaderTimeout: レスポンスヘッダーの受信を待つタイムアウト。
    • ExpectContinueTimeout: Expect: 100-continue ヘッダーを送信した場合に、サーバーからの 100 Continue 応答を待つタイムアウト。
    • TLSHandshakeTimeout: TLSハンドシェイクのタイムアウト。
    • DialContext: カスタムのダイアル関数を設定可能(例: ネットワークインターフェースの指定、カスタムDNSリゾルバ)。
    • TLSClientConfig: クライアント証明書、ルートCA、サーバー名検証のスキップ (InsecureSkipVerify は極力避ける) などを設定。
      transport := &http.Transport{
          MaxIdleConns:        100,
          MaxIdleConnsPerHost: 10,
          IdleConnTimeout:     90 * time.Second,
          // ... 他の設定
      }
      client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
    • 重要: http.DefaultTransport を直接変更するのは避け、新しい http.Transport インスタンスを作成して http.Client に設定します。
  2. リトライロジックの実装

    • 一時的なネットワークエラーやサーバー側の5xxエラーに対して、リトライ処理を組み込むことが推奨されます。
    • 単純なリトライではなく、エクスポネンシャルバックオフやジッター(ランダムな遅延追加)を導入して、サーバーへの負荷集中を避けます。
    • 冪等でないリクエスト (POSTなど) のリトライは慎重に行う必要があります。
  3. リクエストボディの再読み込み (GetBody の活用)

    • リダイレクト時やリトライ時に、http.Client がリクエストボディを再度送信する必要がある場合があります。io.Reader は一度しか読めないため、http.RequestGetBody フィールド (関数) を設定しておくと、必要に応じてボディを再生成できます。
    • bytes.NewReader, strings.NewReader は内部的に GetBody をうまく扱えるように Seek メソッドを実装していますが、カスタムの io.Reader を使う場合は注意が必要です。
      bodyBytes := []byte(`{"key":"value"}`)
      req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyBytes)) // bytes.NewReader は Seekable
      req.GetBody = func() (io.ReadCloser, error) { // 明示的に設定する例
          return io.NopCloser(bytes.NewReader(bodyBytes)), nil
      }
  4. Context Cancelation の伝播

    • http.NewRequestWithContext を使用して、リクエストに context.Context を紐付けます。親コンテキストがキャンセルされると、進行中のHTTPリクエストもキャンセルしようとします(TCPコネクションの切断など)。
    • これにより、不要になったリクエストのリソースを早期に解放できます。
  5. エラーの種類の判別

    • net.Error インタフェース (Temporary(), Timeout() bool) や url.Erroros.IsTimeout などを使って、エラーの種類(タイムアウト、DNSエラー、接続拒否など)を判別し、適切な処理(リトライ、フォールバックなど)を行います。
  6. プロキシ設定

    • http.TransportProxy フィールド (func(*Request) (*url.URL, error)) を設定することで、リクエストごとに動的にプロキシを指定したり、環境変数 (HTTP_PROXY, HTTPS_PROXY) から読み込んだりできます (http.ProxyFromEnvironment)。

APIクライアントライブラリ作成時のイディオム・パターン

Goで外部APIを利用するためのクライアントライブラリを作成する際には、以下のようなイディオムやパターンがよく用いられます。

  1. クライアント構造体 (Client) の定義

    • APIのベースURL、認証情報(APIキー、トークンなど)、http.Client インスタンスなどを保持する構造体を定義します。
    • コンストラクタ関数 (NewClient) を提供し、設定オプションを受け取れるようにします(Functional Options Pattern がよく使われます)。
      type Client struct {
          BaseURL    *url.URL
          apiKey     string
          httpClient *http.Client
          // UserAgent  string
          // common service encapsulators like client.Users or client.Products
      }
      
      type ClientOption func(*Client) error
      
      func NewClient(baseURL string, apiKey string, opts ...ClientOption) (*Client, error) {
          parsedBaseURL, err := url.Parse(baseURL)
          if err != nil {
              return nil, fmt.Errorf("invalid base URL: %w", err)
          }
          c := &Client{
              BaseURL:    parsedBaseURL,
              apiKey:     apiKey,
              httpClient: &http.Client{Timeout: 10 * time.Second}, // デフォルト
          }
          for _, opt := range opts {
              if err := opt(c); err != nil {
                  return nil, err
              }
          }
          return c, nil
      }
      
      func WithHTTPClient(hc *http.Client) ClientOption {
          return func(c *Client) error {
              if hc == nil {
                  return fmt.Errorf("http client cannot be nil")
              }
              c.httpClient = hc
              return nil
          }
      }
      // 使用例: client, err := NewClient("https://api.example.com", "mykey", WithHTTPClient(customHTTPClient))
  2. リソースごとのサービス/メソッド

    • APIのリソース(例: Users, Products, Orders)ごとに、関連する操作をまとめたメソッドを Client 構造体に持たせるか、あるいはサブ構造体 (例: client.Users.Get(), client.Products.List()) として整理します。
      // サブ構造体パターン
      type UserService struct {
          client *Client
      }
      func (s *UserService) Get(ctx context.Context, userID string) (*User, error) { /* ... */ }
      
      type Client struct {
          // ...
          Users *UserService
      }
      func NewClient(...) (*Client, error) {
          // ...
          c.Users = &UserService{client: c}
          return c, nil
      }
      // 利用: user, err := apiClient.Users.Get(ctx, "123")
  3. 共通リクエスト処理メソッド (do メソッド)

    • リクエストの作成、ヘッダー設定(認証、Content-Type, User-Agentなど)、リクエスト送信、レスポンスのデコード、エラーハンドリングといった共通処理をプライベートな do メソッドに集約します。
      func (c *Client) newRequest(ctx context.Context, method, path string, body interface{}) (*http.Request, error) {
          rel, err := url.Parse(path)
          if err != nil {
              return nil, err
          }
          u := c.BaseURL.ResolveReference(rel)
      
          var buf io.ReadWriter
          if body != nil {
              buf = new(bytes.Buffer)
              enc := json.NewEncoder(buf)
              enc.SetEscapeHTML(false) // APIによっては必要
              if err := enc.Encode(body); err != nil {
                  return nil, err
              }
          }
      
          req, err := http.NewRequestWithContext(ctx, method, u.String(), buf)
          if err != nil {
              return nil, err
          }
      
          if body != nil {
              req.Header.Set("Content-Type", "application/json")
          }
          req.Header.Set("Accept", "application/json")
          req.Header.Set("Authorization", "Bearer "+c.apiKey)
          // req.Header.Set("User-Agent", c.UserAgent)
          return req, nil
      }
      
      func (c *Client) do(req *http.Request, v interface{}) (*http.Response, error) {
          resp, err := c.httpClient.Do(req)
          if err != nil {
              // context Canceled or DeadlineExceeded の場合、早期リターン
              select {
              case <-req.Context().Done():
                  return nil, req.Context().Err()
              default:
              }
              return nil, fmt.Errorf("failed to execute request: %w", err)
          }
          defer resp.Body.Close()
      
          // エラーレスポンスのチェック (例: 4xx, 5xx)
          if resp.StatusCode >= 400 {
              // エラーレスポンスをデコードして詳細なエラー情報を含める
              // apiErr := &APIError{}
              // if err := json.NewDecoder(resp.Body).Decode(apiErr); err == nil {
              //    return resp, apiErr
              // }
              return resp, fmt.Errorf("API error: %s (status code: %d)", http.StatusText(resp.StatusCode), resp.StatusCode)
          }
      
          if v != nil { // レスポンスボディをデコードする場合
              if w, ok := v.(io.Writer); ok { // レスポンスを直接 io.Writer に書き込む
                  _, err = io.Copy(w, resp.Body)
              } else {
                  err = json.NewDecoder(resp.Body).Decode(v)
              }
              if err != nil && err != io.EOF { // EOFは空ボディの場合ありうる
                   return resp, fmt.Errorf("failed to decode response body: %w", err)
              }
          } else { // ボディが不要な場合は読み捨てる
              _, _ = io.Copy(io.Discard, resp.Body)
          }
          return resp, nil
      }
  4. context.Context の受け入れ

    • 全ての公開メソッドは第一引数に context.Context を取るようにし、タイムアウトやキャンセルを呼び出し元から制御できるようにします。
  5. 明確なエラー型とエラーハンドリング

    • API特有のエラー(レートリミット超過、認証失敗、バリデーションエラーなど)を表すカスタムエラー型を定義し、do メソッドでこれらのエラーを返却するようにします。これにより、ライブラリ利用者は errors.As などでエラーの種類を判別しやすくなります。
      type APIError struct {
          StatusCode int
          Message    string
          // Details map[string]interface{} // APIが返す詳細エラー
      }
      
      func (e *APIError) Error() string {
          return fmt.Sprintf("API error (status %d): %s", e.StatusCode, e.Message)
      }
  6. リクエスト/レスポンス構造体の定義

    • APIの各エンドポイントでやり取りされるJSONなどのデータ構造に対応するGoの構造体を定義します。JSONタグ (json:"fieldName") を適切に付与します。
  7. ページネーションのサポート

    • リスト系のAPIでページネーションが採用されている場合、それを扱うためのヘルパーメソッドや、イテレータパターンなどを提供すると便利です。
  8. テスト容易性

    • http.Client を注入可能にすることで、テスト時には httptest.NewServer を使ったモックサーバーでテストできるようにします。
    • do メソッドが共通化されていれば、テストの焦点も当てやすくなります。
  9. User-Agentの設定

    • ライブラリ名とバージョンを含む User-Agent ヘッダーを設定することは、API提供者にとってデバッグや利用状況の把握に役立つため、良いプラクティスです。

これらのパターンを参考にすることで、使いやすく、堅牢で、テストしやすいAPIクライアントライブラリを構築できます。

(返答終了理由: STOP)


ユーザー: バックオフ対応とbodyは消費されてしまうの言及も欲しいですね


AI: 承知いたしました。先の回答に、リトライ時のエクスポネンシャルバックオフ対応と、リクエストボディが消費されてしまう問題(特にリトライ時)への言及と対策を補足します。


net/http クライアント実装における考慮点 (補足)

玄人向けのチェックリスト・詳細設定 (補足)

  1. リトライロジックの実装 (エクスポネンシャルバックオフとジッター)

    • 問題: 一時的なネットワークエラー (net.ErrorTemporary()true を返すもの) やサーバー側の5xxエラー (502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout) は、時間をおいてリトライすることで成功する可能性があります。しかし、単純な固定間隔でのリトライは、障害発生時にサーバーへの負荷を増大させる可能性があります。
    • 対策:
      • エクスポネンシャルバックオフ: リトライ間隔を指数関数的に増やします (例: 1s, 2s, 4s, 8s...)。
      • ジッター: バックオフ間隔にランダムな要素を加えることで、複数のクライアントが同時にリトライする「Thundering Herd Problem (雷鳴の群れ問題)」を緩和します。
      • リトライ回数制限: 無限にリトライしないように、最大リトライ回数や合計リトライ時間の上限を設定します。
      • 冪等性の考慮: GET, HEAD, OPTIONS, PUT, DELETE は一般的に冪等なのでリトライしやすいですが、POST は冪等でない場合が多いため、リトライの可否を慎重に判断する必要があります。API側で冪等性キーをサポートしていれば活用します。
    • ライブラリの活用: cenkalti/backoff などのライブラリを利用すると、これらのロジックを簡単に実装できます。
      // 例 (cenkalti/backoff を使ったイメージ)
      // import "github.com/cenkalti/backoff/v4"
      
      // operation := func() error {
      //     resp, err := client.Do(req) // req は GetBody が設定されている想定
      //     if err != nil {
      //         return err // ネットワークエラーなど
      //     }
      //     // ボディクローズと読み捨てをここで行うか、エラー種別で判断
      //     defer func() {
      //          io.Copy(io.Discard, resp.Body)
      //          resp.Body.Close()
      //     }()
      
      //     if resp.StatusCode >= 500 && resp.StatusCode <= 599 { // 5xx系エラーならリトライ対象
      //         return fmt.Errorf("server error: %d", resp.StatusCode)
      //     }
      //     // 成功時の処理 (レスポンスボディを読むなど)
      //     // ...
      //     return nil // 成功
      // }
      
      // b := backoff.NewExponentialBackOff()
      // b.MaxElapsedTime = 2 * time.Minute // 最大リトライ時間
      // err := backoff.Retry(operation, b)
      // if err != nil {
      //     // リトライしても最終的に失敗した
      // }
  2. リクエストボディの再読み込み (GetBody の活用とリトライ時の注意)

    • 問題: http.Client がリダイレクト (特に 307 Temporary Redirect, 308 Permanent Redirect でメソッドとボディを維持する場合) やリトライを行う際、元のリクエストボディを再度送信する必要があります。しかし、http.RequestBody フィールドは io.ReadCloser であり、一度読み取られると消費されてしまいます(EOFになる)。
    • 対策:
      • http.RequestGetBody フィールドに、ボディを何度でも再生成できる関数を設定します。この関数は、呼び出されるたびに新しい io.ReadCloser (内容は同じ) を返す必要があります。
      • bytes.NewReader, strings.NewReader は内部的に Seek(0, io.SeekStart) を行うことで GetBody を暗黙的にサポートします (これらの型は io.Seeker を実装しているため、http.NewRequest がよしなに GetBody を設定してくれます)。
      • ファイルからボディを読み込む場合は、os.Fileio.Seeker を実装しているので同様に扱えますが、毎回ファイルを開き直す GetBody を実装することも考えられます。
      • メモリに乗り切らない巨大なストリームデータをボディとする場合、GetBody の実装は難しく、リトライ戦略も変わってくる可能性があります(リトライ不可とするか、一時ファイルに書き出すなど)。
      // bytes.Buffer は Seeker ではないので注意。bytes.NewReader を使う。
      bodyBytes := []byte(`{"key":"value"}`)
      
      // パターン1: bytes.NewReader (SeekerなのでGetBodyが自動設定される)
      req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
      
      // パターン2: 明示的に GetBody を設定 (カスタムio.Readerの場合など)
      req, _ = http.NewRequest("POST", url, createCustomBodyReader()) // createCustomBodyReader() が初回読み取り用
      req.GetBody = func() (io.ReadCloser, error) {
          // ここで再度ボディを生成するロジック
          return io.NopCloser(createCustomBodyReader()), nil
      }
      
      // リトライ処理内で req を再利用する際に GetBody が使われる
      // resp, err := client.Do(req)
    • 重要: GetBody が設定されていない場合、リダイレクトやリトライ時にボディが失われ、空のボディでリクエストが送信されてしまうか、http: ContentLength=XXX with Body=nil のようなエラーが発生することがあります。

APIクライアントライブラリ作成時のイディオム・パターン (補足)

  1. 共通リクエスト処理メソッド (do メソッド) (補足)

    • do メソッド内でリトライロジックを組み込む場合、req *http.Request を引数に取るこのメソッド自体をリトライ処理の operation 関数内で呼び出す形になります。
    • リトライ対象とするエラー(ネットワークエラー、特定のHTTPステータスコード)の判定ロジックも do メソッド内か、それを呼び出すリトライ制御部分に含めます。
    • req.GetBody が適切に設定されていることを前提とします。
  2. context.Context の受け入れ (補足)

    • リトライ処理全体にも context.Context を渡し、例えば backoff.WithContext(b, ctx) のようにして、コンテキストのキャンセルがリトライループも中断するようにします。

これらの補足点を加えることで、より堅牢で実用的なHTTPクライアントおよびAPIクライアントライブラリの実装に近づくことができます。特にリトライ処理とボディの扱いは、見落とされがちですが重要なポイントです。

(返答終了理由: STOP)


ユーザー: あれ、DefaultClientにはタイムアウトが設定されてたはずです。DefaultTransport経由で。

var DefaultTransport RoundTripper = &Transport{
	Proxy: ProxyFromEnvironment,
	DialContext: defaultTransportDialContext(&net.Dialer{
		Timeout:   30 * time.Second,
		KeepAlive: 30 * time.Second,
	}),
	ForceAttemptHTTP2:     true,
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

http.DefaultClient はグローバルなクライアントですが、タイムアウトなどが設定されていないため、プロダクションではカスタム設定したクライアントを推奨します。

効かないタイミングはどのような時ですか?


AI: ご指摘ありがとうございます。大変失礼いたしました。私の先の回答で http.DefaultClient のタイムアウトに関する説明に誤りがありました。

http.DefaultClient は、内部で http.DefaultTransport を使用しており、この DefaultTransport にはご提示いただいた通り、以下のようなタイムアウト関連の設定がデフォルトで組み込まれています。

  • DialContext 内の net.DialerTimeout: 30秒 (TCPコネクション確立までのタイムアウト)
  • TLSHandshakeTimeout: 10秒 (TLSハンドシェイクのタイムアウト)
  • IdleConnTimeout: 90秒 (アイドルコネクションのプール内保持時間)
  • ExpectContinueTimeout: 1秒 (100 Continue 応答待ち時間)

では、なぜカスタムクライアントの推奨や、http.Client 直下の Timeout フィールドが重要視されるのか、そして DefaultTransport のタイムアウトが「効かない」ように見える、あるいは不十分となるのはどのような場合か、について説明します。

  1. http.Client.TimeoutTransport のタイムアウトは役割が異なる

    • http.Client.Timeout (フィールド): リクエスト全体のタイムアウトです。これには、コネクション確立、リクエスト送信、レスポンスヘッダー受信、レスポンスボディ受信の全ての処理が含まれます。この値を設定すると、この時間内に処理が完了しなければエラーとなります。
    • http.Transport の各種タイムアウト:
      • DialContext (net.Dialer.Timeout): TCPコネクションを確立するまでの時間。
      • TLSHandshakeTimeout: TLSハンドシェイクが完了するまでの時間。
      • ResponseHeaderTimeout: (これは DefaultTransport にはデフォルトで明示的な値は設定されていませんが、設定可能です) リクエスト送信後、レスポンスヘッダーを完全に受信するまでの時間。
      • ExpectContinueTimeout: Expect: 100-continue ヘッダーを送信した場合に、サーバーからの 100 Continue 応答を待つタイムアウト。

    http.DefaultClient (および DefaultTransport を使うクライアント) で http.Client.Timeout が設定されていない場合 (デフォルトでは0、つまり無制限)、リクエスト全体のタイムアウトは効きません。 この状態だと、例えば以下のような状況が発生し得ます。

    • コネクション確立 (30秒以内) とTLSハンドシェイク (10秒以内) は成功した。
    • しかし、サーバーがリクエストを処理するのに非常に時間がかかっている、またはレスポンスボディの送信が非常に遅い。
    • この場合、DefaultTransport の設定だけでは、クライアントはサーバーからの応答を際限なく待ち続ける可能性があります。なぜなら、レスポンスボディ受信に関する明示的なタイムアウトが DefaultTransport にはデフォルトで設定されておらず、かつリクエスト全体のタイムアウト (http.Client.Timeout) も無制限だからです。
  2. DefaultTransport の設定だけではカバーできないシナリオ

    • 遅いサーバーレスポンス: サーバーがリクエストを受け付けた後、処理に時間がかかり、レスポンスヘッダーやボディの送信が遅れる場合。DialContextTLSHandshakeTimeout は既に完了しているので効きません。http.Client.Timeout が設定されていれば、ここでタイムアウトさせることができます。
    • 巨大なレスポンスボディの受信: レスポンスボディが非常に大きく、ネットワーク帯域が細い場合など、ボディの受信に長時間かかるケース。これも http.Client.Timeout がなければ際限なく待ちます。
    • ストリーミングレスポンス: SSE (Server-Sent Events) のように、サーバーが長時間にわたって断続的にデータを送り続ける場合、意図的に長時間接続を維持しますが、何らかの理由でサーバーからのデータ送信が完全に停止した場合にタイムアウトさせたい、といった細かい制御は http.Client.Timeout だけでは難しい場合があります (この場合は ReadTimeout のようなより細かい制御が必要になることも)。
  3. http.Client.Timeout の利便性

    • リクエスト全体の時間をシンプルに制限したい場合に非常に便利です。多くのケースで、「このAPI呼び出しは全体でX秒以内に終わってほしい」という要件を満たせます。

結論として、http.DefaultClient (ひいては DefaultTransport) にはコネクション確立やTLSハンドシェイクに関するタイムアウトは設定されていますが、リクエスト全体の包括的なタイムアウトは設定されていません。

そのため、

  • 応答の遅いサーバーやネットワークの問題からアプリケーションを保護するため
  • リソースを無駄に占有し続けないため

に、カスタムの http.Client を作成し、その Timeout フィールドに適切な値を設定することが強く推奨されます。

先の回答での「タイムアウトなどが設定されていないため」という表現は、この「リクエスト全体のタイムアウト」が設定されていないことを指していました。言葉足らずで誤解を招く表現でした。ご指摘いただき、より正確な情報をお伝えする機会をありがとうございました。

(返答終了理由: STOP)


ユーザー: あなたはgoの開発者であり思慮深い執筆者です。これまでの思考の内容を追体験させるような文章がほしいです。自己完結型の文章にしてください。わざと短くまとめようとせずに丁寧に長さを気にせずテキストとして出力してください(2000字や5000字を超えても構いません)。コード例を丁寧に書いて説明を加えてください。文章がいくら長くなったとしても途中で止まらず応答を求めずすべてを出力しきってください。

一つの記事にまとめられない場合は同様の想定で2つ目の記事や3つ目の記事と複数の記事に分割して同様の形式で出力して構いません。


記事のフォーマットについて

  • 記事は#1つのタイトル名からはじめてください
  • 記事は幾分かの章で分割してください
  • 章タイトルには絵文字を1つ加えてください
  • 記事はmarkdownフォーマットを念頭に入れて出力してください
  • *や**による太字や強調を禁止します
  • 対象読者と狙いをFront matter で簡潔に冒頭につけてください
  • 文章は長くなっても構いません。丁寧に記述してください。

重要なこと

  • 読者は私およびあなたの存在を知りません。伝える必要もありません。文中で参照しないでください。
  • 読者は投稿元のSNSの投稿を知りません。それについて触れずに表現してください。

それではお願いします。

除外事項

  • web apiフレームワークをいつ使うか?は不要です

AIの思考プロセス

Developing HTTP APIs in Go

I'm considering a deep dive into Go's net/http package for crafting robust web APIs. My focus is on providing a comprehensive guide that caters specifically to beginners, covering both server-side and client-side development. I'm exploring the best structure for the tutorial to ensure a smooth learning curve.

Crafting a Comprehensive Guide

I've been meticulously crafting a comprehensive guide to building web APIs in Go using the net/http package. My goal is to create a detailed resource for both novice and experienced developers. I'm focusing on covering common pitfalls, advanced techniques, and idiomatic Go practices. I'm structuring the guide with clear examples and explanations to ensure it's easy to follow. I plan to use an article format with a strong focus on self-contained chapters, ensuring the guide is complete and useful.

Expanding Content Scope

I've significantly expanded the content scope, moving beyond initial server-side focus. I'm now crafting a detailed, self-contained guide with a clear article structure, including a title, chapter outlines (with emojis), and comprehensive front matter. I'm meticulously incorporating clear explanations and practical code examples to address both beginner pitfalls and advanced techniques, targeting a broad audience. My goal is to transform this resource into a go-to guide for Go web API development. I have already drafted the initial chapter outlines.

Finalizing the Article Outline

I've been making substantial progress on the Go net/http guide. My focus now is on creating a comprehensive article structure, complete with a compelling title and detailed chapter outlines enriched with emojis. The article will provide a thorough walkthrough of server-side development, detailing common pitfalls and advanced techniques for Go beginners. I've drafted an engaging Front matter section that clarifies the target audience and article objectives, ensuring a high-value learning experience.

Crafting a Detailed Article

I've been working on a comprehensive article about Go's net/http package. My focus is now on creating a complete, self-contained guide with two major parts: server-side and client-side development. I'm aiming to provide in-depth explanations and practical code examples, from beginner-friendly concepts to advanced techniques, addressing common pitfalls. The article structure, including title, chapter outlines with emojis, and Front matter, is taking shape, and the first part focuses on the server-side implementations.

Expanding Article Scope

I've significantly shifted my focus to crafting a detailed article on Go's net/http package. I'm aiming for a comprehensive resource that includes both server-side and client-side development. The structure includes an engaging title, chapter outlines with emojis, and a detailed Front matter. The content will dive deep into common pitfalls, advanced techniques, and idiomatic Go practices, ensuring a high-value learning experience for all levels. I've almost finished the initial drafts for both the server and the client side sections.

Expanding the Guide's Scope

I'm now focusing on a complete article about Go's net/http package. I'm structuring the article to include both server and client-side development. The content covers common pitfalls, advanced techniques, and idiomatic Go practices to create a high-value learning experience. I have drafted the engaging title, chapter outlines with emojis, and the detailed Front matter, and will now focus on the content.

Structuring a Detailed Article

I'm now focusing on a complete, in-depth article about Go's net/http package. This comprehensive resource will cover both server-side and client-side development. The structure will include an engaging title, chapter outlines with emojis, and a detailed Front matter. The content will include common pitfalls, advanced techniques, and idiomatic Go practices to create a high-value learning experience. I have drafted chapter outlines and aim for a very detailed explanation with code examples.

Finalizing Article Structure

I've finalized the detailed article structure, dividing it into server-side and client-side sections, each with a clear outline and informative front matter. My primary focus is to deliver a practical and in-depth guide covering common pitfalls and advanced techniques for Go API development. I'm prioritizing the inclusion of numerous practical code examples.

AI: --- 対象読者: GoでWeb API開発を始めたばかりの初級者から、より高度なテクニックを求める中級・上級開発者まで 狙い: `net/http` パッケージの基本的な使い方を超え、実践的な開発で遭遇するハマりどころ、パフォーマンス最適化、堅牢なコードを書くためのイディオムやパターンを深く理解する。 --- # Go net/http 深掘りガイド: サーバーサイド開発の実践知

Go言語の標準パッケージである net/http は、Webサーバーやクライアントを実装するための強力で柔軟な基盤を提供します。多くのサードパーティフレームワークも、その内部で net/http の機能を活用しています。このパッケージを深く理解することは、GoによるWeb開発の効率と品質を大きく向上させる鍵となります。

この記事では、net/http を用いたサーバーサイド開発に焦点を当て、基本的な使い方から一歩進んで、開発者が直面しがちな課題や、より洗練されたコードを書くためのテクニック、そして知っておくべきイディオムについて、具体的なコード例を交えながら掘り下げていきます。

🚀 ハンドラの基本とレスポンス制御の罠

Webサーバーの核となるのは、リクエストを受け取りレスポンスを返すハンドラです。net/http では、http.Handler インタフェースまたは http.HandlerFunc 型を使ってハンドラを定義します。

http.Handler インタフェース:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

http.HandlerFunc 型:

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTPメソッドを実装しているため、Handlerインタフェースを満たす
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

これにより、func(http.ResponseWriter, *http.Request) というシグネチャを持つ任意の関数を http.HandlerFunc にキャストすることで、http.Handler として扱えるようになります。

package main

import (
	"fmt"
	"log"
	"net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello, World!")
}

func main() {
	http.HandleFunc("/hello", helloHandler) // http.HandlerFuncへのキャストが内部で行われる
	log.Println("Server starting on port 8080...")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}

ステータスコードとヘッダーの書き込みタイミング

レスポンスをクライアントに返す際には、ステータスコード、ヘッダー、そしてボディの順序が重要になります。net/httphttp.ResponseWriter は、この順序を強制する仕組みになっています。

  1. ヘッダーの設定: w.Header().Set("Key", "Value")
  2. ステータスコードの設定: w.WriteHeader(statusCode)
  3. ボディの書き込み: w.Write([]byte("body content"))

最も重要な注意点は、w.WriteHeader() または最初の w.Write() が呼び出された時点で、ヘッダーとステータスコードがクライアントに送信されてしまうということです。これ以降、ヘッダーやステータスコードを変更することはできません。

もし w.WriteHeader() を明示的に呼び出す前に w.Write() が呼び出された場合、net/http は自動的にステータスコード 200 OK を設定してヘッダーを送信します。

悪い例: ステータスコード設定前にボディを書き出す

func badStatusHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("This will be a 200 OK response.\n"))
	w.WriteHeader(http.StatusInternalServerError) // この行は効果がない
	fmt.Fprint(w, "Tried to set 500, but it's too late.")
}

// 実行結果のレスポンス:
// HTTP/1.1 200 OK
// Content-Type: text/plain; charset=utf-8
// Date: ...
//
// This will be a 200 OK response.
// Tried to set 500, but it's too late.

この例では、最初の w.Write() が呼び出された時点でステータスコード 200 OK が送信されてしまうため、その後の w.WriteHeader(http.StatusInternalServerError) は無視されます。

正しい順序:

func correctStatusHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("X-Custom-Header", "my-value")
	w.WriteHeader(http.StatusCreated) // 201 Created
	fmt.Fprint(w, "Resource created successfully.")
}

また、w.WriteHeader() を複数回呼び出すこともできません。最初の呼び出しのみが有効となります。

Content-Type ヘッダーの重要性

クライアントがレスポンスボディを正しく解釈できるように、Content-Type ヘッダーを適切に設定することは非常に重要です。例えば、JSONレスポンスを返す場合は application/json、HTMLを返す場合は text/html を設定します。

func jsonHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	responseJSON := `{"message": "Hello, JSON!"}`
	fmt.Fprint(w, responseJSON)
}

http.ResponseWriter は、Content-Type が設定されていない場合、w.Write() で書き込まれるデータの先頭512バイトから http.DetectContentType を使って自動的に推測しようとしますが、明示的に設定することが推奨されます。

🎣 リクエスト処理の勘所

クライアントからのリクエストには、URL、メソッド、ヘッダー、そしてボディが含まれます。これらを正しく処理する方法を見ていきましょう。

リクエストボディの読み取り

サーバーサイドでクライアントから送信されたリクエストボディ (r.Body) を扱う際には、いくつかの注意点があります。

  1. r.Bodyio.ReadCloser です。 読み取り後は、必ず Close() メソッドを呼び出してリソースを解放する必要があります。defer r.Body.Close() を使うのが一般的です。

    func bodyEchoHandler(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "Only POST method is allowed", http.StatusMethodNotAllowed)
            return
        }
        defer r.Body.Close() // 必ずクローズする
    
        bodyBytes, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "Error reading request body", http.StatusInternalServerError)
            log.Printf("Error reading body: %v", err)
            return
        }
        w.WriteHeader(http.StatusOK)
        w.Write(bodyBytes)
    }
  2. r.Body は一度しか読み取れません。 io.Reader の性質上、一度読み取るとストリームは終端に達し、再度読み取ることはできません。もし複数回ボディの内容を参照する必要がある場合は、最初に io.ReadAll などでバイトスライスに読み込んでから利用します。

    func processBodyMultipleTimes(w http.ResponseWriter, r *http.Request) {
        defer r.Body.Close()
    
        bodyBytes, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "Error reading request body", http.StatusInternalServerError)
            return
        }
    
        // 1回目の利用: ログ出力
        log.Printf("Received body: %s", string(bodyBytes))
    
        // 2回目の利用: JSONデコードなど
        var data map[string]interface{}
        // bodyBytes を使ってデコード (r.Bodyではない)
        if err := json.Unmarshal(bodyBytes, &data); err != nil {
            http.Error(w, "Error unmarshalling JSON", http.StatusBadRequest)
            return
        }
        fmt.Fprintf(w, "Successfully processed JSON: %+v", data)
    }
  3. リクエストボディサイズの制限 (http.MaxBytesReader) 悪意のあるクライアントが非常に大きなリクエストボディを送信してサーバーリソースを枯渇させるのを防ぐため、読み取るボディサイズに上限を設けることが推奨されます。http.MaxBytesReader を使うと、指定したサイズを超えて読み込もうとした場合にエラーを返します。

    func limitedBodyHandler(w http.ResponseWriter, r *http.Request) {
        const maxBodySize = 1 * 1024 * 1024 // 1MB
    
        r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
        defer r.Body.Close()
    
        bodyBytes, err := io.ReadAll(r.Body)
        if err != nil {
            // サイズ超過の場合、errは *http.MaxBytesError 型になる
            var maxBytesErr *http.MaxBytesError
            if errors.As(err, &maxBytesErr) {
                http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
                return
            }
            http.Error(w, "Error reading request body", http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "Received %d bytes", len(bodyBytes))
    }

JSONデコードとエラーハンドリング

APIではJSON形式でデータを受け取ることが一般的です。json.NewDecoder を使ってリクエストボディを直接Goの構造体にデコードできます。

type CreateUserRequest struct {
	Username string `json:"username"`
	Email    string `json:"email"`
	Age      int    `json:"age,omitempty"` // omitempty はエンコード時にゼロ値なら省略
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Only POST method is allowed", http.StatusMethodNotAllowed)
		return
	}
	defer r.Body.Close()

	var req CreateUserRequest
	decoder := json.NewDecoder(r.Body)
	// decoder.DisallowUnknownFields() // リクエストJSONに構造体にないフィールドが含まれていたらエラーにする

	if err := decoder.Decode(&req); err != nil {
		var syntaxError *json.SyntaxError
		var unmarshalTypeError *json.UnmarshalTypeError

		switch {
		case errors.As(err, &syntaxError):
			msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
			http.Error(w, msg, http.StatusBadRequest)
		case errors.Is(err, io.ErrUnexpectedEOF):
			msg := "Request body contains badly-formed JSON"
			http.Error(w, msg, http.StatusBadRequest)
		case errors.As(err, &unmarshalTypeError):
			msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
			http.Error(w, msg, http.StatusBadRequest)
		case errors.Is(err, io.EOF): // ボディが空の場合
			msg := "Request body must not be empty"
			http.Error(w, msg, http.StatusBadRequest)
		// case strings.HasPrefix(err.Error(), "json: unknown field "): // DisallowUnknownFields使用時
		// 	fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
		// 	msg := fmt.Sprintf("Request body contains unknown field %s", fieldName)
		// 	http.Error(w, msg, http.StatusBadRequest)
		default:
			log.Printf("Error decoding JSON: %v", err)
			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
		}
		return
	}

	// バリデーション (例)
	if req.Username == "" {
		http.Error(w, "Username is required", http.StatusBadRequest)
		return
	}

	log.Printf("Creating user: %+v", req)
	w.WriteHeader(http.StatusCreated)
	fmt.Fprintf(w, "User %s created.", req.Username)
}

decoder.DisallowUnknownFields() を呼び出すと、リクエストJSONにGoの構造体で定義されていないフィールドが含まれている場合にデコードエラーとなり、より厳密なリクエスト形式を強制できます。エラーハンドリングも、どのような問題でデコードに失敗したかを判別し、クライアントに適切なエラーメッセージを返すことが重要です。

URLクエリパラメータとフォームデータのパース

GETリクエストのクエリパラメータ (/search?q=golang&lang=en) や、POSTリクエストのフォームデータ (application/x-www-form-urlencoded または multipart/form-data) を取得するには、まずリクエストのパースが必要です。

  • r.ParseForm(): クエリパラメータと application/x-www-form-urlencoded 形式のボディをパースします。
  • r.ParseMultipartForm(maxMemory): multipart/form-data 形式のボディをパースします。maxMemory はメモリ上に保持するファイルの最大サイズ(これを超えると一時ファイルに保存されます)。

パース後、以下の方法で値を取得できます。

  • r.URL.Query().Get("key"): URLクエリパラメータから値を取得(パース不要)。
  • r.FormValue("key"): クエリパラメータまたはパース済みのフォームデータから値を取得。キーが存在しない場合は空文字列を返します。内部で必要に応じてパースを試みます。
  • r.PostFormValue("key"): パース済みのフォームデータからのみ値を取得。
  • r.Form: パース後の全フォームデータ(クエリパラメータ含む)を url.Values (map[string][]string) 型で保持。
  • r.PostForm: パース後のボディからのフォームデータのみを url.Values 型で保持。
  • r.MultipartForm: multipart/form-data パース後のデータを保持。ファイルや値にアクセスできます。
func formHandler(w http.ResponseWriter, r *http.Request) {
	// GET /form?name=Alice&age=30
	// POST /form (Content-Type: application/x-www-form-urlencoded) name=Bob&age=25
	// POST /form (Content-Type: multipart/form-data) ...

	// クエリパラメータと x-www-form-urlencoded ボディをパース
	// (ParseMultipartForm を呼ぶ場合は不要、そちらが内部でParseFormも呼ぶため)
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Error parsing form", http.StatusInternalServerError)
		return
	}

	name := r.FormValue("name") // クエリ or x-www-form-urlencoded ボディから取得
	ageStr := r.FormValue("age")

	// multipart/form-data の場合 (ファイルアップロードなど)
	// 例: 32MBまでのファイルをメモリで処理、それ以上は一時ファイル
	// if err := r.ParseMultipartForm(32 << 20); err != nil {
	// 	if !errors.Is(err, http.ErrNotMultipart) { // multipartでないエラーは処理
	// 		http.Error(w, "Error parsing multipart form", http.StatusInternalServerError)
	// 		return
	// 	}
	// }
	// if r.MultipartForm != nil {
	// 	file, handler, err := r.FormFile("uploadFile")
	// 	if err == nil {
	// 		defer file.Close()
	// 		log.Printf("Uploaded File: %+v\n", handler.Filename)
	// 		log.Printf("File Size: %+v\n", handler.Size)
	// 		// ファイル処理...
	// 	} else if !errors.Is(err, http.ErrMissingFile) {
	// 		log.Printf("Error retrieving file: %v", err)
	// 	}
	// 	name = r.MultipartForm.Value["name"][0] // multipart の場合の値の取り方
	// }


	if name == "" {
		http.Error(w, "Name is required", http.StatusBadRequest)
		return
	}

	age, err := strconv.Atoi(ageStr)
	if err != nil && ageStr != "" {
		http.Error(w, "Age must be a number", http.StatusBadRequest)
		return
	}

	fmt.Fprintf(w, "Received Name: %s, Age: %d", name, age)
}

r.FormValue は便利ですが、キーが存在しない場合にエラーを返さず空文字列を返すため、必須項目のチェックは別途行う必要があります。また、大きなファイルを扱う場合は r.ParseMultipartForm を使い、メモリ使用量に注意しましょう。

🛡️ エラーハンドリングとミドルウェアパターン

堅牢なAPIを構築するには、一貫性のあるエラーハンドリングと、共通処理をまとめるミドルウェアの活用が不可欠です。

ハンドラからのエラー返却パターン

各ハンドラで http.Errorjson.NewEncoder().Encode() を直接呼び出す代わりに、ハンドラが error を返し、共通のラッパーやミドルウェアでHTTPレスポンスを生成するパターンがよく用いられます。これにより、ハンドラのロジックがシンプルになり、エラー処理を一元化できます。

まず、アプリケーション固有のエラー型を定義します。これには、HTTPステータスコードやユーザー向けのメッセージなどを含めることができます。

type AppError struct {
	StatusCode int
	Message    string
	Err        error // 元のエラー (ログ用)
}

func (e *AppError) Error() string {
	if e.Err != nil {
		return fmt.Sprintf("%s: %v", e.Message, e.Err)
	}
	return e.Message
}

// ユーザー向けのエラーレスポンス構造体
type ErrorResponse struct {
	Error string `json:"error"`
}

次に、この AppError を返すシグネチャを持つハンドラ型と、それを http.Handler に変換するアダプタを定義します。

type appHandler func(w http.ResponseWriter, r *http.Request) *AppError

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if appErr := fn(w, r); appErr != nil {
		// 内部エラーをロギング
		log.Printf("Error: %v (Status: %d, Path: %s)", appErr.Err, appErr.StatusCode, r.URL.Path)

		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		// X-Content-Type-Options はセキュリティヘッダーの一例
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.WriteHeader(appErr.StatusCode)

		err := json.NewEncoder(w).Encode(ErrorResponse{Error: appErr.Message})
		if err != nil {
			// JSONエンコード失敗時はさらに内部エラー、ただしヘッダーは送信済み
			log.Printf("Could not encode error response: %v", err)
			// この時点でクライアントにはステータスコードは送信されている
			// ボディは空か、部分的に書き込まれている可能性がある
		}
	}
}

// 具体的なハンドラ
func getUserHandler(w http.ResponseWriter, r *http.Request) *AppError {
	userID := r.URL.Query().Get("id")
	if userID == "" {
		return &AppError{
			StatusCode: http.StatusBadRequest,
			Message:    "User ID is required",
			Err:        errors.New("user id missing from query"),
		}
	}

	// データベースなどからユーザーを取得する処理 (仮)
	// user, err := db.GetUser(userID)
	// if err != nil {
	// 	if errors.Is(err, dberrors.NotFound) {
	// 		return &AppError{StatusCode: http.StatusNotFound, Message: "User not found", Err: err}
	// 	}
	// 	return &AppError{StatusCode: http.StatusInternalServerError, Message: "Failed to retrieve user", Err: err}
	// }

	// 成功 (仮のレスポンス)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	fmt.Fprintf(w, `{"id": "%s", "name": "Dummy User"}`, userID) // 本来はjson.Encoderを使う
	return nil // エラーなし
}

main 関数での利用:

// http.Handle("/users", appHandler(getUserHandler))

このパターンでは、ハンドラはビジネスロジックとエラーの生成に集中でき、HTTPレスポンスの具体的な形式やロギングは appHandler.ServeHTTP メソッドが一手に引き受けます。500系のエラーが発生した場合でも、ユーザーには汎用的なメッセージ (Internal Server Error など) を返し、詳細なエラー情報はサーバーログにのみ記録することが重要です。

パニックリカバリミドルウェア

ハンドラ内で予期せぬパニックが発生した場合、サーバーはデフォルトでクラッシュし、クライアントへの接続は中断されます。これを防ぎ、パニックをキャッチして500エラーとしてグレースフルに処理するためのミドルウェアを導入します。

func recoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("Panic recovered: %v\n%s", err, debug.Stack()) // スタックトレースをログに

				// クライアントには詳細を返さない
				// ただし、ヘッダーが既に送信されていないことを確認する必要がある
				// (非常に稀だが、panic前にWriteが一部呼ばれている可能性も理論上はありえる)
				// ここでは簡単のため、ヘッダー未送信と仮定
				w.Header().Set("Content-Type", "application/json; charset=utf-8")
				w.WriteHeader(http.StatusInternalServerError)
				json.NewEncoder(w).Encode(ErrorResponse{Error: "Internal Server Error"})
			}
		}()
		next.ServeHTTP(w, r)
	})
}

ミドルウェアは、http.Handler を受け取り、http.Handler を返す関数として実装するのが一般的です。main 関数で他のミドルウェアやハンドラと共にチェインして使用します。

// mux := http.NewServeMux()
// mux.HandleFunc("/hello", helloHandler)
//
// protectedHandler := authMiddleware(appHandler(getUserHandler)) // 例
// mux.Handle("/users", protectedHandler)
//
// finalHandler := recoverMiddleware(loggingMiddleware(mux)) // ミドルウェアのチェイン
// log.Fatal(http.ListenAndServe(":8080", finalHandler))

ロギングミドルウェア

リクエストの処理時間、ステータスコード、リクエストパスなどを記録するロギングミドルウェアは、APIの監視やデバッグに非常に役立ちます。

ステータスコードを後から取得するために、http.ResponseWriter をラップするカスタムの ResponseWriter を使います。

type loggingResponseWriter struct {
	http.ResponseWriter
	statusCode int
	// size       int // レスポンスサイズも記録する場合
}

func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
	return &loggingResponseWriter{w, http.StatusOK} // デフォルトは200 OK
}

func (lrw *loggingResponseWriter) WriteHeader(code int) {
	lrw.statusCode = code
	lrw.ResponseWriter.WriteHeader(code)
}

// Writeもラップして、WriteHeaderが呼ばれなかった場合のデフォルト200を記録したり、
// レスポンスサイズを記録したりできる
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
	if lrw.statusCode == 0 { // WriteHeaderが明示的に呼ばれず、これが最初の書き込みの場合
		lrw.statusCode = http.StatusOK
	}
	// n, err := lrw.ResponseWriter.Write(b)
	// lrw.size += n
	// return n, err
	return lrw.ResponseWriter.Write(b)
}

// http.Flusher インタフェースへの対応 (SSEなどで必要)
func (lrw *loggingResponseWriter) Flush() {
	if flusher, ok := lrw.ResponseWriter.(http.Flusher); ok {
		flusher.Flush()
	}
}

// http.Hijacker インタフェースへの対応 (WebSocketなどで必要)
func (lrw *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
	if hijacker, ok := lrw.ResponseWriter.(http.Hijacker); ok {
		return hijacker.Hijack()
	}
	return nil, nil, fmt.Errorf("http.Hijacker interface not supported")
}


func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		lrw := newLoggingResponseWriter(w)

		// リクエストIDを生成・付与 (例: UUID)
		requestID := uuid.NewString() // "github.com/google/uuid"
		w.Header().Set("X-Request-ID", requestID)
		ctx := context.WithValue(r.Context(), "requestID", requestID) // ハンドラで利用可能に
		r = r.WithContext(ctx)

		next.ServeHTTP(lrw, r)

		duration := time.Since(start)
		log.Printf(
			"[%s] %s %s %s %d %s %s",
			requestID,
			r.RemoteAddr,
			r.Method,
			r.URL.RequestURI(),
			lrw.statusCode,
			// lrw.size, // レスポンスサイズ
			r.UserAgent(),
			duration,
		)
	})
}

この loggingResponseWriter は、元の ResponseWriter が実装している可能性のある他のインタフェース(http.Flusher, http.Hijacker など)も適切に委譲または実装する必要があります。そうしないと、ストリーミングレスポンスやWebSocketへのアップグレードなどが正しく機能しなくなる可能性があります。http.Pusherio.ReaderFrom も同様に考慮すべき場合があります。

⚙️ http.Server の設定とグレースフルシャットダウン

http.ListenAndServe は便利ですが、プロダクション環境ではより細やかな制御のために http.Server 構造体を直接設定・利用することが推奨されます。

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/hello", helloHandler)
	// ... 他のハンドラやミドルウェアを設定 ...
	// finalHandler := recoverMiddleware(loggingMiddleware(mux))

	server := &http.Server{
		Addr:    ":8080",
		Handler: mux, // または finalHandler
		// タイムアウト設定
		ReadTimeout:       5 * time.Second,  // リクエストヘッダとボディ全体の読み取りタイムアウト
		WriteTimeout:      10 * time.Second, // レスポンス書き込みタイムアウト
		IdleTimeout:       120 * time.Second, // Keep-Alive時のアイドルコネクションタイムアウト
		ReadHeaderTimeout: 2 * time.Second,  // リクエストヘッダ読み取り専用のタイムアウト (Go 1.18+)
	}

	// グレースフルシャットダウンのための準備
	go func() {
		log.Printf("Server starting on %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Could not listen on %s: %v\n", server.Addr, err)
		}
	}()

	// OSからの停止シグナルを待つ
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, os.Interrupt, syscall.SIGTERM) // syscall.SIGINTも一般的
	<-quit
	log.Println("Server is shutting down...")

	// シャットダウン処理のためのコンテキスト(例: 30秒の猶予)
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// サーバーに現在のリクエスト処理完了を待ってシャットダウンするよう指示
	if err := server.Shutdown(ctx); err != nil {
		log.Fatalf("Server shutdown failed: %v", err)
	}
	log.Println("Server gracefully stopped")
}

タイムアウト設定の重要性

  • ReadTimeout: クライアントからのリクエストヘッダとボディ全体の読み取りが完了するまでの最大時間。遅いクライアントや悪意のあるリクエスト (Slowloris攻撃など) からサーバーを保護します。
  • WriteTimeout: サーバーがクライアントにレスポンスを書き込む処理全体の最大時間。
  • IdleTimeout: HTTP Keep-Alive が有効な場合に、アイドル状態のコネクションを維持する最大時間。これを超えるとコネクションは閉じられます。リソースの効率的な利用に繋がります。
  • ReadHeaderTimeout: (Go 1.18以降) リクエストヘッダのみの読み取りタイムアウト。ReadTimeout より短い値を設定することで、ヘッダ解析段階での攻撃により早く対処できます。

これらのタイムアウトを設定しないと、リソースが枯渇したり、サービスが応答不能になったりするリスクがあります。

グレースフルシャットダウンの実装

サーバーが停止シグナル (Ctrl+C など) を受け取った際に、処理中のリクエストを中断せずに完了させてから安全に終了する仕組みです。

  1. サーバーを別のゴルーチンで起動 (go server.ListenAndServe())。
  2. メインゴルーチンでOSの停止シグナル ( os.Interrupt, syscall.SIGTERM など) を待ちます。
  3. シグナルを受け取ったら、server.Shutdown(ctx) を呼び出します。このメソッドは新しいリクエストの受け付けを停止し、既存のコネクションがアイドルになるか、指定したコンテキストがタイムアウト/キャンセルされるまで待機します。

これにより、クライアントへの影響を最小限に抑えつつ、安全にサーバーを停止できます。

💨 高度なトピック: ストリーミングとコンテキスト

よりインタラクティブな、あるいは効率的なAPIを構築するためには、ストリーミングやコンテキストの活用が鍵となります。

http.Flusher とServer-Sent Events (SSE)

Server-Sent Events (SSE) は、サーバーからクライアントへ一方向のデータストリームを送信するための技術です。text/event-stream という Content-Type を使用します。 SSEを実装するには、レスポンスをチャンクで送信し、バッファリングされずに即座にクライアントに届くようにする必要があります。このために http.Flusher インタフェースを利用します。

func sseHandler(w http.ResponseWriter, r *http.Request) {
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	// w.Header().Set("Access-Control-Allow-Origin", "*") // 必要に応じてCORS設定

	messageChan := make(chan string)

	// 別ゴルーチンでイベントを生成してチャネルに送信する (例)
	go func() {
		defer close(messageChan)
		for i := 0; i < 10; i++ {
			time.Sleep(1 * time.Second)
			eventData := fmt.Sprintf("Event %d at %s", i, time.Now().Format(time.RFC3339))
			messageChan <- eventData
			if r.Context().Err() != nil { // クライアント切断を検知
				log.Println("SSE client disconnected, stopping event generation.")
				return
			}
		}
	}()

	// クライアント切断を検知するためのコンテキスト
	ctx := r.Context()

	for {
		select {
		case <-ctx.Done(): // クライアントが接続を切断した
			log.Println("SSE client disconnected.")
			return
		case message, open := <-messageChan:
			if !open { // チャネルがクローズされた (イベント送信終了)
				log.Println("SSE event stream finished.")
				return
			}
			// SSE形式でデータを書き込む (例: "data: message\n\n")
			fmt.Fprintf(w, "id: %s\n", uuid.NewString()) // イベントID (省略可)
			fmt.Fprintf(w, "event: message\n")           // イベントタイプ (省略可)
			fmt.Fprintf(w, "data: %s\n\n", message)      // データ本体
			flusher.Flush()                              // バッファをフラッシュしてクライアントに送信
		}
	}
}

flusher.Flush() を呼び出すことで、ResponseWriter の内部バッファがクライアントに送信されます。クライアントの切断は r.Context().Done() で検知できます。

http.Hijacker とWebSocketへのアップグレード

WebSocket通信を開始するには、HTTP/1.1コネクションを "ハイジャック" し、TCPコネクションの直接制御を奪う必要があります。これには http.Hijacker インタフェースを利用します。 サードパーティのWebSocketライブラリ (例: gorilla/websocket) は、内部でこのインタフェースを利用してプロトコルアップグレード処理を行います。自前で実装することは稀ですが、仕組みを理解しておくことは有益です。

// websocketHandler (gorilla/websocketを使った場合のイメージ)
// var upgrader = websocket.Upgrader{
// 	ReadBufferSize:  1024,
// 	WriteBufferSize: 1024,
// 	CheckOrigin: func(r *http.Request) bool { return true }, // 本番ではオリジンチェックを適切に
// }
//
// func wsHandler(w http.ResponseWriter, r *http.Request) {
// 	conn, err := upgrader.Upgrade(w, r, nil) // ここで内部的にHijackerが使われる
// 	if err != nil {
// 		log.Println("Failed to upgrade to websocket:", err)
// 		return
// 	}
// 	defer conn.Close()
// 	// WebSocket通信処理 (conn.ReadMessage, conn.WriteMessage)
// }

Hijack() を呼び出した後は、開発者がコネクションのライフサイクル全体(エラーハンドリング、クローズなど)に責任を持つことになります。

r.Context() の活用

リクエストスコープのコンテキスト (r.Context()) は、タイムアウトやキャンセルシグナルの伝播、リクエスト固有の値の受け渡しに非常に重要です。

  1. クライアント切断の検知 クライアントがリクエストの途中で接続を切断した場合、r.Context().Done() チャネルがクローズされます。長時間かかる処理を行うハンドラでは、定期的にこのチャネルをチェックすることで、無駄な処理を中断できます。

    func longRunningTaskHandler(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        log.Println("Starting long running task...")
    
        select {
        case <-time.After(10 * time.Second): // 10秒かかるタスク (仮)
            fmt.Fprint(w, "Task completed.")
            log.Println("Task completed normally.")
        case <-ctx.Done(): // クライアント切断 or サーバー側タイムアウト (例: http.Server.WriteTimeout)
            err := ctx.Err()
            log.Printf("Task cancelled: %v", err)
            // ここでレスポンスを書き込んでもクライアントには届かない可能性が高い
            // サーバー側のリソースクリーンアップ処理などを行う
            if errors.Is(err, context.Canceled) {
                // クライアントが切断
            } else if errors.Is(err, context.DeadlineExceeded) {
                // サーバー設定のタイムアウトなど
            }
        }
    }
  2. ミドルウェアからの値の受け渡し ミドルウェアで認証情報やリクエストIDなどをコンテキストに格納し、後続のハンドラで取り出して利用できます。キーの衝突を避けるため、キーには非公開の独自型を使うのが定石です。

    type contextKey string
    const requestIDKey contextKey = "requestID"
    const userIDKey contextKey = "userID"
    
    // (loggingMiddleware の例で requestID をコンテキストにセットする部分を参照)
    
    // 認証ミドルウェア (例)
    func authMiddleware(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // ... トークン検証などの認証処理 ...
            // authenticatedUserID := "user-123"
            // ctx := context.WithValue(r.Context(), userIDKey, authenticatedUserID)
            // next.ServeHTTP(w, r.WithContext(ctx))
            next.ServeHTTP(w, r) // この例では認証処理は省略
        })
    }
    
    // ハンドラでの利用
    func protectedDataHandler(w http.ResponseWriter, r *http.Request) {
        reqID, ok := r.Context().Value(requestIDKey).(string)
        if !ok {
            log.Println("Request ID not found in context")
            // エラー処理
        }
        // userID, ok := r.Context().Value(userIDKey).(string)
        // if !ok {
        // 	http.Error(w, "Unauthorized", http.StatusUnauthorized)
        // 	return
        // }
        log.Printf("[%s] Accessing protected data for user...", reqID)
        fmt.Fprintf(w, "[%s] Protected data accessed.", reqID)
    }

    コンテキストから値を取り出す際は、型アサーションと、値が存在しない場合のフォールバック処理を忘れずに行います。

🧪 テストのプラクティス

net/http パッケージ自体がテストを容易にするための優れたツールを提供しています。httptest パッケージがその中心です。

httptest パッケージの活用

  • httptest.NewServer(handler http.Handler): 指定したハンドラをリッスンするテスト用のHTTPサーバーを起動します。実際のネットワーク通信を伴うため、E2Eテストに近い形でテストできます。サーバーのURL (ts.URL) が返されるので、http.Client を使ってリクエストを送信できます。
  • httptest.NewRequest(method, target, body io.Reader): テスト用の *http.Request を簡単に作成できます。
  • httptest.NewRecorder(): http.ResponseWriter インタフェースを実装したオブジェクトで、ハンドラが書き込んだレスポンス (ステータスコード、ヘッダー、ボディ) を記録します。ネットワーク通信を伴わないため、ハンドラの単体テストに非常に高速かつ便利です。

ハンドラの単体テスト ( ResponseRecorder を使用):

func TestHelloHandler(t *testing.T) {
	req, err := http.NewRequest("GET", "/hello", nil)
	if err != nil {
		t.Fatal(err)
	}

	rr := httptest.NewRecorder()
	handler := http.HandlerFunc(helloHandler) // テスト対象のハンドラ

	handler.ServeHTTP(rr, req)

	// ステータスコードのチェック
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v",
			status, http.StatusOK)
	}

	// レスポンスボディのチェック
	expected := "Hello, World!"
	if rr.Body.String() != expected {
		t.Errorf("handler returned unexpected body: got %q want %q",
			rr.Body.String(), expected)
	}
}

func TestGetUserHandler_Success(t *testing.T) {
	// appHandler を使ったハンドラのテスト例
	handler := appHandler(getUserHandler) // getUserHandler は *AppError を返す

	req, err := http.NewRequest("GET", "/users?id=123", nil)
	if err != nil {
		t.Fatal(err)
	}

	rr := httptest.NewRecorder()
	handler.ServeHTTP(rr, req) // appHandlerのServeHTTPを呼び出す

	if status := rr.Code; status != http.StatusOK {
		t.Errorf("handler returned wrong status code: got %v want %v. Body: %s",
			status, http.StatusOK, rr.Body.String())
	}

	// レスポンスボディのJSONをデコードしてチェック (より堅牢)
	var respBody map[string]string
	if err := json.NewDecoder(rr.Body).Decode(&respBody); err != nil {
		t.Fatalf("Could not decode response body: %v", err)
	}
	if id, ok := respBody["id"]; !ok || id != "123" {
		t.Errorf("Expected user ID '123', got '%s'", id)
	}
}

func TestGetUserHandler_MissingID(t *testing.T) {
	handler := appHandler(getUserHandler)

	req, err := http.NewRequest("GET", "/users", nil) // IDなし
	if err != nil {
		t.Fatal(err)
	}
	rr := httptest.NewRecorder()
	handler.ServeHTTP(rr, req)

	if status := rr.Code; status != http.StatusBadRequest {
		t.Errorf("handler returned wrong status code for missing ID: got %v want %v. Body: %s",
			status, http.StatusBadRequest, rr.Body.String())
	}

	var errResp ErrorResponse
	if err := json.NewDecoder(rr.Body).Decode(&errResp); err != nil {
		t.Fatalf("Could not decode error response body: %v", err)
	}
	expectedErrMsg := "User ID is required"
	if errResp.Error != expectedErrMsg {
		t.Errorf("Expected error message %q, got %q", expectedErrMsg, errResp.Error)
	}
}

テーブルドリブンテスト

複数のテストケースを効率的に記述・実行するために、テーブルドリブンテストのパターンがよく用いられます。

func TestVariousHandlers(t *testing.T) {
	tests := []struct {
		name           string
		method         string
		path           string
		body           io.Reader
		handler        http.HandlerFunc // または appHandler
		expectedStatus int
		expectedBody   string // 単純な文字列比較の場合。JSONなら構造体で比較も
	}{
		{
			name:           "Hello success",
			method:         "GET",
			path:           "/hello",
			handler:        helloHandler,
			expectedStatus: http.StatusOK,
			expectedBody:   "Hello, World!",
		},
		{
			name:           "Correct status handler",
			method:         "GET",
			path:           "/correct-status",
			handler:        correctStatusHandler,
			expectedStatus: http.StatusCreated,
			expectedBody:   "Resource created successfully.",
		},
		// ... 他のテストケース ...
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req, err := http.NewRequest(tt.method, tt.path, tt.body)
			if err != nil {
				t.Fatalf("Could not create request: %v", err)
			}

			rr := httptest.NewRecorder()
			// if ah, ok := tt.handler.(appHandler); ok { // appHandlerの場合
			// 	ah.ServeHTTP(rr, req)
			// } else if hf, ok := tt.handler.(http.HandlerFunc); ok { // http.HandlerFuncの場合
			// 	hf.ServeHTTP(rr, req)
			// } else {
			// 	t.Fatalf("Unsupported handler type")
			// }
			tt.handler.ServeHTTP(rr, req) // この例ではhttp.HandlerFuncを前提

			if status := rr.Code; status != tt.expectedStatus {
				t.Errorf("Status: got %v want %v. Body: %s", status, tt.expectedStatus, rr.Body.String())
			}
			if tt.expectedBody != "" && rr.Body.String() != tt.expectedBody {
				t.Errorf("Body: got %q want %q", rr.Body.String(), tt.expectedBody)
			}
		})
	}
}

net/http を使ったサーバーサイド開発は奥が深く、ここで紹介した内容はほんの一端に過ぎません。しかし、これらの基本的な罠を避け、より良いパターンを適用することで、堅牢で保守性の高いWeb APIを構築するための強固な土台となるでしょう。継続的な学習と実践を通じて、さらなる知見を深めていくことが重要です。


(この記事はサーバーサイド編です。クライアントサイド編は別の記事で扱います。)

(返答終了理由: STOP)


ユーザー: 続けて。その別の記事を出力して


AI:

対象読者: Goで外部APIを利用するクライアントを実装する開発者、APIクライアントライブラリの作成者 狙い: net/http パッケージを用いたクライアントサイド開発におけるベストプラクティス、一般的な落とし穴、パフォーマンス最適化、信頼性の高いクライアントライブラリ設計のイディオムを習得する。

Go net/http 深掘りガイド: クライアントサイド開発とライブラリ設計

Go言語の net/http パッケージは、サーバーサイドだけでなく、HTTPクライアントを実装するための強力な機能も提供しています。外部APIとの連携やマイクロサービス間の通信など、クライアントとしての役割は現代のアプリケーションにおいて不可欠です。

この記事では、net/http を用いたクライアントサイド開発に焦点を当て、基本的なリクエスト送信から、タイムアウト管理、コネクションプーリング、リトライ戦略、そして再利用可能で堅牢なAPIクライアントライブラリを設計するためのイディオムやパターンについて、具体的なコード例と共に解説します。

🚀 基本的なリクエスト送信とレスポンス処理

net/http パッケージでHTTPリクエストを送信する最も基本的な方法は、http.Get, http.Post, http.PostForm といったヘルパー関数、またはより柔軟な http.NewRequesthttp.Client.Do メソッドの組み合わせです。

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"
)

func main() {
	// GETリクエスト
	simpleGetRequest()

	// POSTリクエスト (JSON)
	postJSONRequest()

	// カスタムクライアントと詳細なリクエスト制御
	customClientRequest()
}

func simpleGetRequest() {
	log.Println("--- Simple GET Request ---")
	resp, err := http.Get("https://httpbin.org/get?name=GoUser&age=10")
	if err != nil {
		log.Printf("Error making GET request: %v", err)
		return
	}
	defer resp.Body.Close() // 必須!

	log.Printf("Status: %s (Code: %d)", resp.Status, resp.StatusCode)
	log.Printf("Headers: %+v", resp.Header)

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("Error reading response body: %v", err)
		return
	}
	log.Printf("Body: %s", string(bodyBytes))
}

func postJSONRequest() {
	log.Println("--- POST JSON Request ---")
	jsonData := `{"message": "Hello from Go client", "id": 123}`
	resp, err := http.Post("https://httpbin.org/post", "application/json", strings.NewReader(jsonData))
	if err != nil {
		log.Printf("Error making POST request: %v", err)
		return
	}
	defer resp.Body.Close()

	log.Printf("Status: %s (Code: %d)", resp.Status, resp.StatusCode)
	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("Error reading response body: %v", err)
		return
	}
	log.Printf("Body: %s", string(bodyBytes))

	// POST x-www-form-urlencoded
	// formData := url.Values{
	// 	"key1": {"value1"},
	// 	"key2": {"value2"},
	// }
	// respForm, err := http.PostForm("https://httpbin.org/post", formData)
	// ... (同様の処理) ...
}

レスポンスボディのクローズ

resp.Body (io.ReadCloser) は、読み取り後、あるいは不要な場合でも必ず Close() メソッドを呼び出す必要があります。これを怠ると、コネクションが解放されず、コネクションプールのコネクションが枯渇し、最終的に新しいリクエストが失敗する (too many open filesconnection timed out など) 原因となります。defer resp.Body.Close() を使うのが定石です。

レスポンスボディの読み捨て

たとえレスポンスボディの内容が不要な場合 (例えば、HEAD リクエストや、ステータスコードのみ確認したい場合など) でも、コネクションを再利用可能にするためには、ボディを完全に消費 (読み捨て) する必要があります。http.Client のデフォルトの Transport は、ボディが完全に読み取られた後にコネクションをプールに戻そうとします。

func checkStatusOnly(url string) (int, error) {
	resp, err := http.Get(url)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()

	// ボディの内容は不要だが、コネクション再利用のために読み捨てる
	_, err = io.Copy(io.Discard, resp.Body) // io.Discard は書き込みを破棄する io.Writer
	if err != nil {
		return resp.StatusCode, fmt.Errorf("failed to discard response body: %w", err)
	}

	return resp.StatusCode, nil
}

io.Copy(io.Discard, resp.Body) は、ボディの内容をすべて読み取り、どこにも保存せずに破棄するための効率的な方法です。

⚙️ http.Client の設定とライフサイクル

http.Gethttp.Post といったヘルパー関数は、内部でグローバルな http.DefaultClient を使用します。しかし、プロダクション環境では、タイムアウトやコネクションプーリングなどを細かく制御するために、独自の http.Client インスタンスを作成・設定し、それを使い回すことが強く推奨されます。

http.Client の再利用

http.Client インスタンスはスレッドセーフであり、複数のゴルーチンから安全に共有して使用できます。リクエストごとに新しい http.Client を作成すると、TCPコネクションの再利用 (Keep-Alive) やコネクションプーリングが効率的に機能せず、パフォーマンスが大幅に低下し、システムリソース (ポートなど) を浪費します。

悪い例: リクエストごとにクライアントを生成

// func makeRequestBad(url string) {
// 	client := &http.Client{} // NG: リクエストごとに生成
// 	resp, err := client.Get(url)
// 	// ...
// }

良い例: クライアントを一度生成して再利用

var myAppClient = &http.Client{ // アプリケーション全体で共有するクライアント
	Timeout: 15 * time.Second, // リクエスト全体のタイムアウト
}

func makeRequestGood(url string) (*http.Response, error) {
	return myAppClient.Get(url)
}

アプリケーションの起動時に一度だけクライアントを初期化し、それを必要な箇所で共有するのが一般的なパターンです。

タイムアウトの設定 (http.Client.Timeout)

http.ClientTimeout フィールドは、リクエスト全体の処理時間を制限します。これには、DNS解決、TCPコネクション確立、TLSハンドシェイク、リクエスト送信、レスポンスヘッダー受信、レスポンスボディ受信の全てが含まれます。 この値を設定しない (デフォルトはゼロ値で無制限) と、応答のないサーバーに対して無駄に待ち続け、リソースを占有してしまう可能性があります。

func customClientRequest() {
	log.Println("--- Custom Client Request ---")
	client := &http.Client{
		Timeout: 10 * time.Second, // サーバーからの応答が10秒なければタイムアウト
	}

	req, err := http.NewRequest("GET", "https://httpbin.org/delay/3", nil) // 3秒遅延するエンドポイント
	if err != nil {
		log.Printf("Error creating request: %v", err)
		return
	}
	req.Header.Set("X-Custom-Header", "GoClient-Demo")

	resp, err := client.Do(req)
	if err != nil {
		// タイムアウトエラーの判定
		if ue, ok := err.(*url.Error); ok && ue.Timeout() {
			log.Printf("Request timed out: %v", err)
		} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
			log.Printf("Network timeout: %v", err) // より広範なネットタイムアウト
		} else {
			log.Printf("Error performing request: %v", err)
		}
		return
	}
	defer resp.Body.Close()

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Printf("Error reading response body: %v", err)
		return
	}
	log.Printf("Status: %s, Body length: %d", resp.Status, len(bodyBytes))
}

http.Transport のカスタマイズ

http.Client の振る舞いの多くは、その Transport フィールド (通常は *http.Transport 型) によって制御されます。より高度なカスタマイズ (コネクションプーリングの設定、TLS設定、プロキシ設定など) を行うには、http.Transport を独自に設定します。

http.DefaultTransport は、http.DefaultClient が使用するデフォルトのトランスポートであり、以下のような設定がされています。

// var DefaultTransport RoundTripper = &Transport{
// 	Proxy: ProxyFromEnvironment, // 環境変数 (HTTP_PROXY, HTTPS_PROXY) からプロキシ設定を読み込む
// 	DialContext: defaultTransportDialContext(&net.Dialer{
// 		Timeout:   30 * time.Second, // TCPコネクション確立のタイムアウト
// 		KeepAlive: 30 * time.Second, // TCP Keep-Alive間隔
// 	}),
// 	ForceAttemptHTTP2:     true,
// 	MaxIdleConns:          100, // 全ホストに対するアイドル状態の最大コネクション数
// 	IdleConnTimeout:       90 * time.Second, // アイドルコネクションがプールから削除されるまでの時間
// 	TLSHandshakeTimeout:   10 * time.Second, // TLSハンドシェイクのタイムアウト
// 	ExpectContinueTimeout: 1 * time.Second,  // "100 Continue" 応答待ち時間
// }

独自のトランスポートを設定する例:

func createCustomTransportClient() *http.Client {
	// DefaultTransportをベースにカスタマイズするのが一般的
	customTransport := http.DefaultTransport.(*http.Transport).Clone()
	// または、完全に新規作成:
	// customTransport := &http.Transport{}

	customTransport.MaxIdleConns = 50                     // アイドルコネクションの総数を変更
	customTransport.MaxIdleConnsPerHost = 10              // 特定ホストへのアイドルコネクション数を変更 (デフォルトは2)
	customTransport.ResponseHeaderTimeout = 20 * time.Second // レスポンスヘッダ受信までのタイムアウト
	// customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // 非推奨: 自己署名証明書などテスト用

	client := &http.Client{
		Transport: customTransport,
		Timeout:   30 * time.Second, // リクエスト全体のタイムアウトはClient側で設定
	}
	return client
}

// 使用例
// var specializedClient = createCustomTransportClient()
// resp, err := specializedClient.Get("...")

MaxIdleConnsPerHost は、特定のAPIエンドポイントに対して多数の並列リクエストを送信する場合に重要となることがあります。デフォルトの2では、並列度が高い場合にコネクション確立のオーバーヘッドが大きくなる可能性があります。

http.Client.TimeoutTransport 内の各種タイムアウトの関係性には注意が必要です。Client.Timeout はリクエスト全体の包括的なタイムアウトである一方、Transport のタイムアウトはコネクション確立、TLSハンドシェイク、レスポンスヘッダー受信といった個別のフェーズに対するものです。Client.Timeout が設定されていないと、Transport のタイムアウトが個別に効いたとしても、例えばレスポンスボディの受信が非常に遅い場合に際限なく待ち続ける可能性があります。

🔄 リクエストの再試行とボディの扱い

ネットワークは本質的に不安定なものであり、一時的なエラーが発生することは避けられません。堅牢なクライアントは、このような状況に対処するためのリトライロジックを備えているべきです。

リクエストボディと GetBody

HTTPリクエストがリダイレクト (特に 307 Temporary Redirect308 Permanent Redirect) される際や、リトライ処理を行う際には、クライアントがリクエストボディを再度送信する必要が生じることがあります。http.RequestBody フィールドは io.ReadCloser であり、一度読み取られると消費されてしまい、再度読み取ることはできません。

この問題に対処するため、http.Request には GetBody というフィールド ( func() (io.ReadCloser, error) 型) があります。GetBody が設定されていると、http.Client は必要に応じてこの関数を呼び出し、新しい io.ReadCloser (内容は同じ) を取得してボディを再送信します。

  • bytes.NewReader, strings.NewReader, os.File など io.Seeker を実装する型を http.NewRequest のボディとして渡すと、net/http パッケージが自動的に GetBody を適切に設定してくれます (内部で Seek(0, io.SeekStart) を行うため)。
  • bytes.Bufferio.Seeker を実装していないため、これを直接ボディとして使うと GetBody は設定されず、リダイレクトやリトライ時にボディが失われる可能性があります。bytes.NewReader(buf.Bytes()) のように bytes.NewReader でラップするのが良いでしょう。
  • カスタムの io.Reader を使う場合や、オンデマンドでボディを生成したい場合は、明示的に GetBody を設定する必要があります。
func requestWithGetBody(urlStr string, bodyData []byte) (*http.Response, error) {
	client := &http.Client{Timeout: 10 * time.Second} // リダイレクトポリシーはデフォルト

	// bytes.NewReader は Seeker を実装しているので GetBody が自動設定される
	req, err := http.NewRequest("POST", urlStr, bytes.NewReader(bodyData))
	if err != nil {
		return nil, err
	}

	// もし Seeker ではない io.Reader を使う場合や、毎回生成したい場合:
	// var bodyContent = []byte("some dynamic content")
	// req.GetBody = func() (io.ReadCloser, error) {
	// 	log.Println("GetBody called to regenerate request body")
	// 	return io.NopCloser(bytes.NewReader(bodyContent)), nil
	// }
	// req.ContentLength = int64(len(bodyContent)) // GetBody を使う場合はContentLengthも設定推奨

	return client.Do(req)
}

GetBody が設定されていない状態でリダイレクトやリトライが発生し、ボディの再送が必要になると、ボディなしでリクエストが送信されたり、http: ContentLength=XXX with Body=nil のようなエラーが発生したりすることがあります。

リトライ戦略: エクスポネンシャルバックオフとジッター

一時的なネットワークエラー (例: net.ErrorTemporary()true を返すもの) や、サーバー側の5xx系エラー (例: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout) は、時間をおいてリトライすることで成功する可能性があります。

効果的なリトライ戦略には以下の要素が含まれます。

  • エクスポネンシャルバックオフ: リトライ間の待機時間を指数関数的に増やします (例: 1秒, 2秒, 4秒, 8秒...)。これにより、障害発生直後のサーバーへの負荷集中を避けます。
  • ジッター: バックオフ間隔にランダムな揺らぎを加えます。これにより、複数のクライアントが全く同じタイミングでリトライする「Thundering Herd Problem (雷鳴の群れ問題)」を緩和します。
  • リトライ回数/時間制限: 無限にリトライしないように、最大リトライ回数や合計リトライ時間の上限を設定します。
  • 冪等性の考慮: GET, HEAD, OPTIONS, PUT, DELETE は一般的に冪等 (何度実行しても結果が同じ) なのでリトライしやすいですが、POST は冪等でない場合が多いため、リトライの可否を慎重に判断する必要があります。APIが冪等性キーをサポートしていれば活用します。

cenkalti/backoff/v4 のようなライブラリを利用すると、これらのロジックを簡単に実装できます。

// import "github.com/cenkalti/backoff/v4"

type RetryingClient struct {
	HTTPClient *http.Client
	MaxRetries int // backoffライブラリではElapsedTimeで制御することが多い
}

func (rc *RetryingClient) DoWithRetry(req *http.Request) (*http.Response, error) {
	var resp *http.Response
	var err error

	// リクエストボディの準備 (GetBodyが設定されていることを確認)
	if req.GetBody == nil && req.Body != nil {
		// Seekerでない場合、ここでGetBodyを設定する試みが必要になるかもしれない
		// あるいは、リトライ対象のリクエストではGetBodyが必須であるという前提を置く
		bodyBytes, readErr := io.ReadAll(req.Body)
		req.Body.Close() // 元のボディは閉じる
		if readErr != nil {
			return nil, fmt.Errorf("failed to read original body for retry: %w", readErr)
		}
		req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) // 最初の試行用
		req.GetBody = func() (io.ReadCloser, error) {      // リトライ用
			return io.NopCloser(bytes.NewReader(bodyBytes)), nil
		}
		req.ContentLength = int64(len(bodyBytes))
	}


	operation := func() error {
		// GetBodyから新しいボディを取得してリクエストを"リフレッシュ"する必要がある
		// ただし、client.Doが内部でGetBodyを呼ぶので、reqインスタンスの再生成は不要な場合が多い。
		// client.Do が req.Body を消費した後、リトライ時に req.GetBody を呼んでくれる。

		currentReq := req // 通常は同じリクエストオブジェクトを再利用できる

		// もしリクエストごとにヘッダなどを動的に変えたい場合は、ここで currentReq を複製・変更する
		// clonedReq := req.Clone(req.Context()) // ボディは複製されないので注意
		// if req.GetBody != nil {
		// 	body, _ := req.GetBody()
		// 	clonedReq.Body = body
		// }


		resp, err = rc.HTTPClient.Do(currentReq)
		if err != nil {
			// ネットワークエラーなど (タイムアウト含む)
			log.Printf("Retryable network error: %v", err)
			return err // backoff.Retryにエラーを通知
		}

		// 5xx系エラーはリトライ対象とする (例)
		if resp.StatusCode >= 500 && resp.StatusCode <= 599 {
			// レスポンスボディは消費してコネクションを解放する
			// これをしないと次のリトライでコネクションが不足する可能性がある
			defer func() {
				io.Copy(io.Discard, resp.Body)
				resp.Body.Close()
			}()
			log.Printf("Retryable server error: %s (status code: %d)", resp.Status, resp.StatusCode)
			return fmt.Errorf("server error: %s", resp.Status) // backoff.Retryにエラーを通知
		}

		// 成功またはリトライ対象でないエラー
		return nil
	}

	bo := backoff.NewExponentialBackOff()
	bo.MaxElapsedTime = 30 * time.Second // 合計リトライ時間の上限
	// bo.MaxInterval = 5 * time.Second    // 最大バックオフ間隔

	// backoff.RetryWithData を使うとレスポンスを返せるが、ここでは単純なRetry
	retryErr := backoff.Retry(operation, bo)

	if retryErr != nil {
		// 全てのリトライが失敗した
		// resp が nil の場合もある (最初の試行でネットワークエラーなど)
		// resp が nil でない場合、それはリトライ対象外のエラーレスポンスか、
		// 最後のリトライでリトライ対象エラーだったが、最終的に retryErr として返されたもの
		if resp != nil && resp.Body != nil { // resp があるなら念のため閉じる
			// ボディが既に上で閉じられているか確認が必要
		}
		return nil, fmt.Errorf("request failed after retries: %w (last HTTP response was: %v)", retryErr, resp)
	}

	return resp, nil // 最後の成功したレスポンス
}

この例では、DoWithRetry メソッド内でリトライロジックを実装しています。リトライ対象とするエラーの判定 (ネットワークエラーや特定のHTTPステータスコード)、レスポンスボディの適切な処理 (成功時以外は読み捨ててクローズ) が重要です。また、req.GetBody がリトライのために適切に機能するよう、ボディの準備に注意を払う必要があります。

🌐 context.Context の活用

サーバーサイドと同様に、クライアントサイドでも context.Context はタイムアウト制御やキャンセルシグナルの伝播に不可欠です。

http.NewRequestWithContext(ctx, method, url, body) を使用すると、リクエストにコンテキストを紐付けることができます。

  • コンテキストに設定されたタイムアウト (context.WithTimeout) は、http.Client.Timeout とは独立して機能し、より優先されます (両方設定されている場合、短い方が有効)。
  • 親コンテキストがキャンセルされると (cancelFunc の呼び出しなど)、進行中のHTTPリクエストもキャンセルしようとします (TCPコネクションの切断など)。これにより、不要になったリクエストのリソースを早期に解放できます。
func requestWithContextTimeout(urlStr string) {
	client := &http.Client{
		Timeout: 20 * time.Second, // クライアント全体のデフォルトタイムアウト
	}

	// このリクエスト専用のタイムアウト (5秒)
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel() // 必ず cancel を呼ぶ

	req, err := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/delay/10", nil) // 10秒遅延
	if err != nil {
		log.Printf("Error creating request: %v", err)
		return
	}

	log.Println("Sending request with 5s context timeout to a 10s delay endpoint...")
	resp, err := client.Do(req)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) { // ctx.Err() の結果
			log.Printf("Context deadline exceeded (request took too long): %v", err)
		} else if ue, ok := err.(*url.Error); ok && ue.Timeout() { // client.Timeoutの可能性も
			log.Printf("Request timed out (url.Error): %v", err)
		} else {
			log.Printf("Error performing request: %v", err)
		}
		return
	}
	defer resp.Body.Close()
	// ... レスポンス処理 ...
	log.Printf("Received response: %s", resp.Status)
}

コンテキストのキャンセルは、特に長時間かかる可能性のあるリクエストや、複数のAPI呼び出しを連鎖させるような場合に、アプリケーション全体の応答性を保つために重要です。

🛠️ APIクライアントライブラリの設計イディオム

外部APIを利用するための専用クライアントライブラリを作成する場合、使いやすく、堅牢で、テストしやすい設計を心がけることが重要です。

  1. クライアント構造体 (Client) とコンストラクタ

    • APIのベースURL、認証情報 (APIキー、トークンなど)、内部で使用する http.Client インスタンスなどを保持する Client 構造体を定義します。
    • NewClient のようなコンストラクタ関数を提供し、設定オプションを受け取れるようにします。Functional Options Pattern は、オプション設定を柔軟かつ拡張可能にするためによく用いられます。
    // (APIクライアントライブラリの例)
    // package myapi
    
    type Client struct {
        BaseURL    *url.URL
        APIKey     string
        HTTPClient *http.Client
        UserAgent  string
    
        // リソースごとのサービス (例)
        // Users    *UserService
        // Products *ProductService
    }
    
    type ClientOption func(*Client) error
    
    func NewClient(baseURLStr string, apiKey string, opts ...ClientOption) (*Client, error) {
        if baseURLStr == "" {
            return nil, errors.New("baseURL cannot be empty")
        }
        parsedBaseURL, err := url.Parse(baseURLStr)
        if err != nil {
            return nil, fmt.Errorf("invalid base URL: %w", err)
        }
    
        c := &Client{
            BaseURL:    parsedBaseURL,
            APIKey:     apiKey,
            HTTPClient: &http.Client{Timeout: 10 * time.Second}, // デフォルトのHTTPクライアント
            UserAgent:  "MyGoAPIClient/1.0",                 // デフォルトのUser-Agent
        }
    
        for _, opt := range opts {
            if err := opt(c); err != nil {
                return nil, err // オプション適用時のエラー
            }
        }
        return c, nil
    }
    
    // Functional Options の例
    func WithHTTPClient(hc *http.Client) ClientOption {
        return func(c *Client) error {
            if hc == nil {
                return errors.New("http client cannot be nil")
            }
            c.HTTPClient = hc
            return nil
        }
    }
    
    func WithUserAgent(ua string) ClientOption {
        return func(c *Client) error {
            if ua == "" {
                return errors.New("user agent cannot be empty")
            }
            c.UserAgent = ua
            return nil
        }
    }
    // 使用例:
    // customHC := &http.Client{Timeout: 5 * time.Second}
    // apiClient, err := myapi.NewClient("https://api.example.com/v1", "secret-key",
    // 	myapi.WithHTTPClient(customHC),
    // 	myapi.WithUserAgent("MySpecificApp/0.1"))
  2. リソースごとのサービス分割 APIが複数のリソース (例: Users, Products, Orders) を持つ場合、それぞれの操作をまとめたサービス構造体 (例: client.Users.Get(id), client.Products.List()) として提供すると、APIの構造が明確になり、使いやすくなります。これらのサービス構造体は、親の Client インスタンスへの参照を持ちます。

  3. 共通リクエスト処理メソッド (do メソッド) リクエストの作成 (http.NewRequestWithContext)、URLの解決、ヘッダーの共通設定 (認証トークン、Content-Type, Accept, User-Agent など)、リクエストの実行 (client.HTTPClient.Do)、レスポンスボディのデコード、共通エラーハンドリングといった処理は、プライベートな do メソッドに集約します。これにより、各APIエンドポイントに対応するメソッドの実装がDRYになります。

    // (Client構造体内のメソッドとして)
    func (c *Client) newRequest(ctx context.Context, method, pathStr string, bodyData interface{}) (*http.Request, error) {
        // ベースURLと相対パスを結合
        rel, err := url.Parse(pathStr)
        if err != nil {
            return nil, fmt.Errorf("invalid path: %w", err)
        }
        u := c.BaseURL.ResolveReference(rel)
    
        var bodyReader io.Reader
        if bodyData != nil {
            // bodyData を JSON などにエンコードして bodyReader を準備
            jsonData, err := json.Marshal(bodyData)
            if err != nil {
                return nil, fmt.Errorf("failed to marshal request body: %w", err)
            }
            bodyReader = bytes.NewBuffer(jsonData)
        }
    
        req, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader)
        if err != nil {
            return nil, fmt.Errorf("failed to create request: %w", err)
        }
    
        // 共通ヘッダーの設定
        if bodyData != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        req.Header.Set("Accept", "application/json")
        req.Header.Set("Authorization", "Bearer "+c.APIKey)
        req.Header.Set("User-Agent", c.UserAgent)
    
        return req, nil
    }
    
    // v はレスポンスボディをデコードする先のポインタ (nilの場合はボディを読まない)
    func (c *Client) do(req *http.Request, v interface{}) (*http.Response, error) {
        // ここでリトライロジックを組み込むことも可能 (RetryingClientの例を参照)
        resp, err := c.HTTPClient.Do(req)
        if err != nil {
            // context.Canceled や context.DeadlineExceeded をチェック
            select {
            case <-req.Context().Done():
                return nil, req.Context().Err() // 元のコンテキストエラーを返す
            default:
            }
            return nil, fmt.Errorf("failed to execute request to %s: %w", req.URL, err)
        }
        // defer resp.Body.Close() は呼び出し元でやるか、ここで完全に処理するか方針を決める
        // この例では、成功時以外はここでボディを処理し、成功時はレスポンスを返す
    
        // エラーレスポンスのチェック (例: 4xx, 5xx)
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            defer resp.Body.Close() // エラー時はここで閉じる
            // API固有のエラー構造体にデコードを試みる
            // apiErr := &APIErrorResponse{}
            // if err := json.NewDecoder(resp.Body).Decode(apiErr); err == nil {
            // 	return resp, apiErr // カスタムエラー型を返す
            // }
            // ボディの読み捨て
            _, _ = io.Copy(io.Discard, resp.Body)
            return resp, fmt.Errorf("API request failed with status %s (%s)", resp.Status, req.URL)
        }
    
        if v != nil { // レスポンスボディをデコードする場合
            // defer resp.Body.Close() は、ここでデコードしきるなら不要かもしれない
            // ただし、途中でエラーになった場合を考えると、やはりdeferが安全
            // 呼び出し元にrespを返す場合、resp.Body.Close()は呼び出し元の責任
            // このdoメソッドがresp.Bodyのクローズまで責任を持つか明確にする。
            // ここでは、vにデコードしたらクローズする設計とする。
            err = json.NewDecoder(resp.Body).Decode(v)
            resp.Body.Close() // デコード後、またはエラーでもクローズ
            if err != nil && err != io.EOF { // EOFは空ボディや成功時の末尾で発生しうる
                 return resp, fmt.Errorf("failed to decode response body from %s: %w", req.URL, err)
            }
        } else if resp.Body != nil {
            // ボディが不要な場合は読み捨ててクローズ
            io.Copy(io.Discard, resp.Body)
            resp.Body.Close()
        }
        // v にデコードした場合、resp.Bodyは既にクローズされている。
        // v が nil の場合も、ここでresp.Bodyはクローズされている。
        // 呼び出し元に加工前のrespを返す場合は、Bodyのクローズは呼び出し元。
        // ここでは、vにデコードすることを主目的とし、加工前のrespも返す。
        // しかし、vにデコードしたらBodyは読み終わっているので、
        // 呼び出し元が再度Bodyを読もうとしても読めない。
        // -> 設計として、vにデコードするか、生のrespを返すか、どちらか一方に特化する方がシンプル。
        //    ここでは、vにデコードし、*http.Responseは主にステータスやヘッダ確認用とする。
        return resp, nil
    }

    do メソッドの責務(特に resp.Body.Close() のタイミング)は明確に設計する必要があります。レスポンスボディをデコードして結果を返す場合は do メソッド内でクローズし、生の *http.Response を返す場合は呼び出し元がクローズする、といったルールを設けます。

  4. context.Context の標準的な受け入れ 全ての公開APIメソッドは、第一引数に context.Context を取るべきです。これにより、呼び出し元がタイムアウトやキャンセルを制御できるようになります。

  5. 明確なエラー型 API特有のエラー (レートリミット、認証失敗、バリデーションエラーなど) を表すカスタムエラー型を定義し、do メソッドでそれらを返却するようにします。これにより、ライブラリ利用者は errors.As などでエラーの種類を判別し、適切な処理を行うことができます。

  6. テスト容易性 コンストラクタで *http.Client を注入可能にすることで、テスト時には httptest.NewServer を使ったモックサーバーでテストできるようになります。

Goの net/http パッケージは、クライアントサイド開発においても非常に柔軟かつ強力なツールセットを提供します。これらのプラクティスを適用することで、外部サービスと連携するアプリケーションの信頼性と保守性を高めることができるでしょう。APIの特性や要件に応じて、これらのパターンを適宜調整し、最適化していくことが重要です。

(返答終了理由: STOP)

メタデータ

{
  "runSettings": {
    "temperature": 1,
    "model": "models/gemini-2.5-pro-preview-05-06",
    "topP": 0.95,
    "topK": 64,
    "maxOutputTokens": 65536,
    "safetySettings": [
      {
        "category": "HARM_CATEGORY_HARASSMENT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_HATE_SPEECH",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
        "threshold": "OFF"
      }
    ],
    "responseMimeType": "text/plain",
    "enableCodeExecution": false,
    "enableSearchAsATool": false,
    "enableBrowseAsATool": false,
    "enableAutoFunctionResponse": false
  },
  "systemInstruction": {}
}
@podhmo
Copy link
Author

podhmo commented Jun 25, 2025

んー、言いたいことがいくつかあるな。http.Clientを作成する時にはhttp.DefaultClientからコピーしてきて変更する部分だけを追加した方が良いだとか。

あと記事にする時に途中で諦めてる部分もあるな。。

@podhmo
Copy link
Author

podhmo commented Jun 25, 2025

あと、順序がないな。

@podhmo
Copy link
Author

podhmo commented Jun 25, 2025

より雑な

対話履歴

ユーザー:
goのnet/httpを使うときのハマりどころを素人と玄人に分けて可能な限り箇条書きで列挙してください。説明は不要です。


AI:
はい、承知いたしました。Goの net/http パッケージを使う際のハマりどころを、素人向けと玄人向けに分けて箇条書きで列挙します。説明は省略します。


素人向けのハマりどころ

  • resp.Body.Close() の忘れ
  • http.ListenAndServe のエラーハンドリング忘れ
  • http.Gethttp.Post などの簡易関数のエラーハンドリング忘れ
  • http.DefaultClient のタイムアウト未設定
  • http.DefaultServeMux のグローバルな性質の理解不足
  • URL末尾のスラッシュの有無によるルーティングの違い
  • フォーム値のパース (r.ParseForm(), r.ParseMultipartForm()) 呼び忘れ
  • http.ResponseWriter へのヘッダー書き込みタイミング (ボディ書き込み後不可)
  • http.ResponseWriter.WriteHeader の複数回呼び出し
  • リクエストボディの再読み込み不可
  • goroutine 内での http.RequestBody の扱い
  • HandleFuncHandle の違いの曖昧な理解
  • http.Error 関数の使い方
  • Content-Type ヘッダーの設定忘れ
  • リダイレクト時のステータスコードの適切な選択
  • Cookie の PathDomain のデフォルト値
  • nilhttp.Handlerhttp.ListenAndServe に渡す挙動
  • io.Copy でのエラーハンドリング
  • defer のスコープと resp.Body.Close()
  • http.StripPrefix の使い方の誤解

玄人向けのハマりどころ

  • http.Client の再利用と http.Transport の設定 (MaxIdleConns, MaxIdleConnsPerHost など)
  • http.Transport の各種タイムアウト設定 (DialContext, TLSHandshakeTimeout, ResponseHeaderTimeout, ExpectContinueTimeout, IdleConnTimeout)
  • http.Server.Shutdown でのグレースフルシャットダウンの実装とタイムアウト
  • リクエストコンテキスト (r.Context()) のキャンセルと伝播
  • http.Hijacker インターフェースの利用とコネクション管理
  • http.Flusher インターフェースとストリーミングレスポンス
  • HTTP/2 の挙動と設定 (h2c, golang.org/x/net/http2)
  • httputil.ReverseProxy のカスタマイズ (Director, ModifyResponse, ErrorHandler)
  • ミドルウェア実装時の http.ResponseWriter のラップと副作用
  • WriteHeader が暗黙的に呼ばれるタイミングの把握
  • リクエストボディのサイズ制限 (http.MaxBytesReader)
  • Expect: 100-continue ヘッダーの挙動と対応
  • Transfer-Encoding: chunked の手動制御と自動処理の境界
  • Keep-Alive の詳細な制御とコネクションプーリング
  • http.Client における goroutine リーク (レスポンスボディ未読など)
  • tls.Config の詳細設定と証明書管理 (クライアント証明書含む)
  • CookieSameSite, Secure, HttpOnly 属性の適切な設定
  • net.Listener のカスタマイズと http.Server.Serve
  • http.Server.ConnState コールバックの利用
  • HTTPトレーラー (Trailer ヘッダー) の送受信
  • io.Reader からのストリーミングアップロード時のエラー処理とリトライ
  • SetReadDeadline / SetWriteDeadline の直接利用と http.Server のタイムアウト設定の関連
  • カスタム RoundTripper 実装時の注意点
  • Accept-Encoding とレスポンス圧縮の自前実装
  • Vary ヘッダーの適切な設定とキャッシュ
  • net/http/pprof の安易な公開によるセキュリティリスク

(返答終了理由: STOP)


ユーザー:
apiのクライアントライブラリを作るときには?どのようなハマりどころがありますか?テストのことも考えてください。同様の形式で答えてください。


AI:
はい、承知いたしました。GoでAPIクライアントライブラリを作成する際のハマりどころを、素人向けと玄人向けに分けて、テストのことも考慮しつつ箇条書きで列挙します。説明は省略します。


素人向けのハマりどころ (APIクライアントライブラリ作成時)

  • API仕様 (エンドポイント、メソッド、パラメータ、レスポンス形式) の誤解
  • 認証情報 (APIキー、トークン) のハードコーディング、または安全でない管理
  • resp.Body.Close() の忘れ (net/httpと同様)
  • エラーレスポンス (4xx, 5xx系) の適切なハンドリング不足
  • リクエストボディ (JSONなど) のエンコード忘れ、Content-Type 設定忘れ
  • レスポンスボディ (JSONなど) のデコードエラーの考慮漏れ
  • http.DefaultClient の無思慮な利用 (タイムアウト未設定など)
  • URLの組み立てミス (パスパラメータ、クエリパラメータのエンコード漏れ)
  • リトライ処理の未実装、または単純すぎる実装
  • User-Agent ヘッダーの設定忘れ
  • テストで実際のAPIエンドポイントを直接叩いてしまう
  • テストケースごとのリクエスト/レスポンスのモック方法が分からない
  • APIのレートリミットを考慮しないテスト実装
  • コンテキスト (context.Context) の未利用、または不適切な利用
  • エラーのラップ不足 (APIエラーかネットワークエラーか判別困難)
  • ページネーション処理の実装漏れ、または誤り
  • ライブラリ利用者に設定オプション (タイムアウト値など) を提供しない
  • nilポインタ参照 (レスポンスの一部がオプショナルな場合など)
  • 日付や時刻のパース/フォーマットの問題
  • テストが外部環境 (ネットワーク接続) に依存している

玄人向けのハマりどころ (APIクライアントライブラリ作成時)

  • http.Client および http.Transport の設定不備 (コネクションプール、タイムアウト詳細設定)
  • 高度なリトライ戦略 (指数バックオフ + Jitter、サーキットブレーカー) の欠如、または実装不備
  • context.Context を通じたキャンセル処理、デッドライン伝播の不徹底
  • リクエスト/レスポンスのインターセプター (ミドルウェア) 機構の設計と実装
  • テストにおけるHTTPモッキングの高度な活用 (httptest.NewServer、インターフェースモック)
  • API仕様変更への追従性 (スキーマ駆動開発、コード生成ツールの活用と限界)
  • 条件付きリクエスト (ETag, Last-Modified) を利用したキャッシュ効率化の未実装
  • ストリーミングAPI (大きなリクエスト/レスポンス) の効率的なハンドリング
  • カスタムエラー型の設計とAPI固有エラー情報の集約
  • 並行リクエスト実行時のリソース管理 (goroutineプール、セマフォ)
  • 認証トークンの自動更新ロジックとスレッドセーフな管理
  • モックの柔軟性と保守性の両立 (リクエスト検証、動的レスポンス生成)
  • テストデータの管理と生成戦略 (Golden File Testing、Fuzzingの導入検討)
  • APIクライアント自体のバージョニングと互換性維持戦略
  • オプションパターンなどを利用した柔軟かつ拡張可能なクライアント設定インターフェース
  • トレーシング (OpenTelemetryなど) の組み込みとコンテキスト伝播
  • API側の冪等性キーのサポートとクライアント側での適切な利用
  • レスポンスボディの遅延読み込み/部分読み込みの実装
  • クライアントサイドでの負荷分散やフェイルオーバー機構の検討
  • ドキュメンテーションの自動生成とAPIリファレンスの品質

(返答終了理由: STOP)


ユーザー:
net/httpのハンドラーを作ったときのテストについて答えてください。同様の形式で答えてください。ただし今回はtipsないしはイディオムないしはパターンの章も最後に追加してください


AI:
はい、承知いたしました。Goの net/http のハンドラーを作成する際のテストに関するハマりどころ、およびTips/イディオム/パターンを箇条書きで列挙します。説明は省略します。


素人向けのハマりどころ (net/httpハンドラーテスト時)

  • httptest.NewRecorder の使い方を知らない、またはレスポンスが記録されない
  • httptest.NewRequest の作り方 (メソッド、URL、ボディ、ヘッダー) の誤り
  • リクエストボディ (io.Reader) の設定方法 (特にJSONやフォーム)
  • レスポンスのステータスコードの検証漏れ
  • レスポンスボディの読み取りと検証方法 (ioutil.ReadAll から io.ReadAll への移行など)
  • レスポンスヘッダーの検証漏れ
  • handler.ServeHTTP(recorder, request) の呼び出し忘れ
  • テストケースごとの recorderrequest の再初期化忘れ
  • エラーケース (不正なリクエスト、内部エラー) のテスト不足
  • URLパラメータ (/items/{id}) のテスト方法
  • クエリパラメータ (?key=value) のテスト方法
  • JSONレスポンスのデコードとフィールド検証の煩雑さ
  • テスト対象のハンドラーがグローバル変数や外部パッケージに直接依存している
  • テストが特定の順番に依存している
  • 単純な文字列比較によるJSONレスポンスの検証 (フィールドの順序問題)
  • t.Errort.Fatal の使い分けの混乱
  • テストヘルパー関数の未利用によるコードの重複

玄人向けのハマりどころ (net/httpハンドラーテスト時)

  • ミドルウェアを含めたハンドラーチェーンのテスト戦略
  • context.Context 経由で渡される値のモックと検証
  • http.Hijackerhttp.Flusher などのインターフェースを利用するハンドラーのテスト
  • ストリーミングレスポンス (Server-Sent Eventsなど) の正確な検証
  • 並行処理を行うハンドラーのテストとレースコンディションの検出
  • httptest.Server を利用したインテグレーションテストとユニットテストの境界
  • データベースや外部APIなどの依存関係のモック/スタブの高度な管理
  • 認証・認可ロジック (JWT、OAuth2など) のテストカバレッジとエッジケース
  • リクエストのバリデーションロジックの網羅的なテスト
  • テストフィクスチャの管理と生成 (DBの初期データ、リクエストペイロードなど)
  • カスタム http.ResponseWriter をラップした場合の挙動のテスト
  • テスト実行速度の最適化 (特にI/Oバウンドなテスト)
  • ベンチマークテスト (go test -bench) の導入とハンドラーの性能評価
  • プロパティベーステスト (Property-Based Testing) の適用可能性
  • テストカバレッジの局所的な偏り (ハッピーパスばかりテストしているなど)
  • テストスイートの構造化と実行制御 (TestMain の活用など)

Tips / イディオム / パターン (net/httpハンドラーテスト時)

  • httptest.NewRecorder() を使用してレスポンスをキャプチャする。
  • httptest.NewRequest(method, target, body) でリクエストを作成する。
    • bodynilio.Reader (例: strings.NewReader("json_payload"), bytes.NewBufferString("data"))。
    • req.Header.Set("Content-Type", "application/json") などでヘッダーを設定。
  • ハンドラーの ServeHTTP(recorder, request) メソッドを直接呼び出す。
  • レスポンスコードは recorder.Code で取得。
  • レスポンスボディは recorder.Body.Bytes()recorder.Body.String() で取得。
  • レスポンスヘッダーは recorder.Header().Get("Header-Name") で取得。
  • テーブル駆動テスト (Table-Driven Tests) を利用して複数のテストケースを効率的に記述する。
    tests := []struct {
        name       string
        method     string
        target     string
        body       io.Reader
        // ... other request params
        wantStatus int
        wantBody   string
        // ... other response assertions
    }{
        // ... test cases
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // ... test logic
        })
    }
  • JSONレスポンスの検証には json.Unmarshal して構造体を比較するか、encoding/json.Encoder/Decoder を使う。
    • github.com/stretchr/testify/assertgithub.com/google/go-cmp/cmp などのアサーションライブラリが便利。
  • 外部依存 (DB、外部APIクライアントなど) はインターフェースで定義し、テスト時にはモック実装を注入する。
  • テストヘルパー関数を作成して、リクエスト作成やレスポンス検証の共通処理をまとめる。
  • req = req.WithContext(context.WithValue(req.Context(), key, value)) でリクエストにコンテキスト値を設定。
  • フォームデータの送信テスト:
    • application/x-www-form-urlencoded: strings.NewReader(url.Values{"key": {"value"}}.Encode())Content-Type 設定。
    • multipart/form-data: mime/multipart パッケージを利用してボディを構築。
  • httptest.NewServer(handler) を使うと、実際のHTTPサーバーをローカルで起動してテストできる (統合テストに近い)。
    • defer server.Close() を忘れない。
    • クライアントコード (http.Get(server.URL)) を使ってテスト。
  • Golden File Testing: 期待されるレスポンスボディをファイルに保存し、テスト結果と比較する。
  • t.Parallel() を適切に使用してテストを並列実行し、高速化する (ただし副作用に注意)。
  • context.Context のタイムアウトやキャンセルをテストするシナリオも考慮する。

(返答終了理由: STOP)

メタデータ

{
  "runSettings": {
    "temperature": 1,
    "model": "models/gemini-2.5-pro-preview-05-06",
    "topP": 0.95,
    "topK": 64,
    "maxOutputTokens": 65536,
    "safetySettings": [
      {
        "category": "HARM_CATEGORY_HARASSMENT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_HATE_SPEECH",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
        "threshold": "OFF"
      }
    ],
    "responseMimeType": "text/plain",
    "enableCodeExecution": false,
    "enableSearchAsATool": false,
    "enableBrowseAsATool": false,
    "enableAutoFunctionResponse": false
  },
  "systemInstruction": {}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment