😊

再考 net/http package

2022/12/01に公開

この記事は 2022 Go Advent Calendar 1日目 の記事です。

はじめに

APIの開発の際、欠かすことのできないのがnet/httpパッケージ。
他のフレームワークもありますが、Goでは標準を使っているところも多い印象です。
内部でどういう処理を行なっているのかと改めてみてみたり、Goで開発し始めてこういうユースケースの時はこういう実装してたなみたいなところを振り返ってまとめた記事になってます。

ゴール

  • 内部実装を見てみる(きっかけを作る)
  • ユースケースごとにどういう実装したのか

内部的な処理を追ってみる

ありがたいことに、よく使うところの内部的な仕組みについてはわかりやすくまとまった記事がすでにあるので、そちらが参考になりました!
こちらの記事がめちゃくちゃわかりやすく書いてあるので、こちらでの説明等は割愛させていただきます...

実際のユースケース

ここからは、実際に開発を通しての具体的なユースケースの話。

リクエストを受け取った後、メインロジックの前後に何か処理を挟みたい

実際のサービスでは、リクエストを受け取った時メインの処理を行う前後で、認証処理をしたり、リクエスト情報などをログに記録するなどの処理をする必要が出てくると思います。
そういう時はMiddlewareを挟むことが一般的。

main.go
package main

import (
	"log"
	"net/http"
)

type middleware func(http.Handler) http.Handler

func middlewareOne(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 前処理
		log.Println("one")
		h.ServeHTTP(w, r)
		// 後処理
	})
}

func middlewareTwo(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Println("two")
		h.ServeHTTP(w, r)
	})
}

func do(w http.ResponseWriter, r *http.Request) {
	// do something...
	log.Println("do")
}

func main() {
	mainHandlerFunc := http.HandlerFunc(do)

	http.Handle("/", middlewareOne(middlewareTwo(mainHandlerFunc)))
	http.ListenAndServe(":8000", nil)
}

h.ServeHTTP(w, r)の前後に任意の処理をいれることができ、処理はLIFOで行われる。

> go run .
2022/11/30 19:46:30 one
2022/11/30 19:46:30 two
2022/11/30 19:46:30 do

Middlewareが複数あるとmiddlewareOne(middlewareTwo(do))のように入れ子にして書くこともできるが、ただ、Middlewareの数が増えてきたら大変なので、良さげに書く

main.go
+func chainMiddleware(h http.Handler, mid []middleware) http.Handler {
+	for i := len(mid) - 1; i >= 0; i-- {
+		h = mid[i](h)
+	}
+	return h
+}

func main() {
	mainHandlerFunc := http.HandlerFunc(do)

+	mid := []middleware{
+		middlewareOne,
+		middlewareTwo,
+		...
+	}

-	http.Handle("/", middlewareOne(middlewareTwo(mainHandlerFunc)))
+	http.Handle("/", chainMiddleware(mainHandlerFunc, mid))
	http.ListenAndServe(":8080", nil)
}

Middlewareなどで取得した情報を伝播させて、他のところで使いたい

例えば、メタデータなどをリクエストのログとして記録したいとして、情報を伝播させるためにContextに情報を乗せていきたい...
1リクエストでは1Contextであることを前提とする。
シンプルに上流から下流にContextを引き継げるケースでは、*http.Requestに含まれるContextに情報を詰め直して、*http.Requestとする。

詳しくはこちらの記事がわかりやすかったです!

main.go
func middlewareOne(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 前処理
		log.Println("one")

+		ctx := context.WithValue(r.Context(), "user-id", "abc")
+		r = r.WithContext(ctx)

		h.ServeHTTP(w, r)
		// 後処理
	})
}

次は、func do(w http.ResponseWriter, r *http.Request)のメインロジックの中で取得した値を伝播させたいが、context.WithValueで生成したContextを引きついで...(あれ、Context渡すところなくない??)

そういう時は、

  1. あらかじめメインロジックの前にMiddlewareのところで、空の値でContextに詰めておく
  2. メインロジックで空の値になってるところに、入れたい値をセットする
  3. Middlewareの後処理のところで、そのContextから値を取得する
    というような感じで情報をContextで伝播させる。
main.go
+type metadata struct {
+	userID string
+}

func middlewareOne(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 前処理
		log.Println("one")

+		ctx := context.WithValue(r.Context(), "metadata", &metadata{})
+		r = r.WithContext(ctx)

		h.ServeHTTP(w, r)
+		log.Println(r.Context().Value("metadata"))
		// 後処理
	})
}

func middlewareTwo(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Println("two")
		h.ServeHTTP(w, r)
	})
}

func do(w http.ResponseWriter, r *http.Request) {
	// do something...
	log.Println("do")
+	data := r.Context().Value("metadata").(*metadata)
+	data.userID = "gegeson"
}

レスポンスの情報をとってきて、ログなどに出す

StatusCodeなどのレスポンスの情報を取得したい時は、直接*http.Responseは参照できないので、http.ResponseWriterをWrapします。
https://pkg.go.dev/net/http#ResponseWriter

main.go
type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (rec *statusRecorder) WriteHeader(code int) {
	rec.status = code
	rec.ResponseWriter.WriteHeader(code)
}
func (rec *statusRecorder) Status() int {
	return rec.status
}

func (rec *statusRecorder) Header() http.Header {
	return rec.ResponseWriter.Header()
}

func (rec *statusRecorder) Write(data []byte) (int, error) {
	return rec.ResponseWriter.Write(data)
}

// WriteHeader sends an HTTP response header with the provided
// status code.
//
// If WriteHeader is not called explicitly, the first call to Write
// will trigger an implicit WriteHeader(http.StatusOK).
// Thus explicit calls to WriteHeader are mainly used to
// send error codes or 1xx informational responses.

呼び出し側でWriteHeaderでStatusCodeを指定してしない場合は、http.StatusOKが返ります。
2回setすると、1回目のstatusが採用されて、処理は中断しないがエラーログだけ出る。

外部APIを叩く時に、リトライ処理やリクエスト制限、レスポンスのキャッシュなどをする

外部のAPIなどを叩く時に、何かリクエストを送る前に処理を挟みたいなどする時は、http.RoundTripperをみたす実装を独自で行うことで、HTTPプロキシ、ロギングを追加する処理やリクエストにヘッダーを追加する処理、OpenTelemetryでSpanを作成する処理でなどを挟むことができる。

https://pkg.go.dev/net/http#RoundTripper

こちらの記事で紹介されている例は、protocol bufferで扱うための処理とかも挟んでいるみたいですね〜

https://github.com/golang/appengine/blob/b9aad5d628b283f265adf8d3557faae187a8d015/urlfetch/urlfetch.go#L121-L205

あと、http.RoundTripのmiddlewareを挟むパターンは、HTTP Clientのテストでモック作成する際にも有効。任意のResponseを返すことができる。

こちらをみてもらうとわかりやすかったです!

まとめ

ユースケース的な話がメインになりましたが、net/http packageについての説明は以上です!
最近ではgRPCを採用するケースも多いと思いますが、grpc-gateway packgeのhttp.Handlerを実装したServeMuxなどに差し替えることで、対応することもできます。

最後まで見ていただきありがとうございました!!

Discussion