net/httpのhandlerにdbなどの依存を持ち込む方法のメモ
昨日の記事のついでに色々goでのwebプログラミングに関する初歩的なことをメモしておこうと思った。
今回はhandlerに依存を持ち込む方法のメモ。
方法は概ね3つ。後はそれらのバリエーション。
- グローバル変数として状態を保持
- handlerを関数ではなく特定のstructのメソッドとして定義し、そのstructのフィールドに保持
- handlerではなくhandlerを返す関数として定義し、routerに登録するのは返されたクロージャにする
ちなみにわかっている人にはこのStack Overflowのページを見せれば終了という気もする。
依存とは?
そもそも此処で言う「依存」とは何のことか?ある種この場で勝手に定めた自作の用語と言っても良いかもしれないのでもう少し説明を追加する。 DI的な意味で言えば依存コンポーネント的な観点で捉えたほうがわかりやすいかもしれない。
例えばの例で言えば、タイトルなどにもあるようにDBにアクセスするためのコンポーネント。あるいは現在時刻を返す関数やロガーなんかも依存として受け取りたくなる。
これらをどうやってnet/httpのHandlerに持ち込むか?
var Handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { // どうやって依存(e.g. db)にアクセスする? }
net/http.HandlerFuncは以下の様な型の関数になっている。正確に言えばnet/http.Handlerであっても良いのだけれどHandlerFuncだけに限定しても十分なのでここではHandlerFuncをHandlerとして考えることにする。
ここでは以下の様なstructをdbを模したものとして扱うことにする。
type Store struct { articles map[string]*Article } type Article struct { Title string `json:"title"` } func (s *Store) FindArticleByID(id string) *Article { return s.articles[id] }
1. グローバル変数として状態を保持
1ファイルで収まるようなちょっとした内部的なインフラツール等の場合はこれでも十分な事が多い。グローバル変数に状態を持たせてしまう。
// ただのグローバル変数 var store = &Store{ articles: map[string]*Article{ "1": {Title: "hello"}, }, }
ただのグローバル変数なのでhandlerの中ではそれを参照してあげれば良いだけ。
// GET /articles/{articleId} 的なものを検討 func Handler(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") articleID := parts[len(parts)-1] ob := store.FindArticleByID(articleID) // 触れる if ob == nil { w.WriteHeader(http.StatusNotFound) return } b, err := json.Marshal(ob) if err != nil { http.Error(w, "ISE", http.StatusInternalServerError) return } w.Write(b) }
手軽ではあるがデメリットは複数の状態を持てないこと。結局の所global singletonに過ぎないので。 あと、この種の機能が一つではなく複数になっているとつらみが溢れる1。
余談: 環境変数をオプション代わりに利用
手抜きで冗長性を確保したいときなどにはこれと環境変数を見るで凌ぐ場合もある。というか多くのインフラ用のツールではこの程度で十分なことが多い。
var debugLogger = logger.NoopLogger() // 何もしないようなもの func init(){ if v, err := strconv.ParseBool(os.Getenv("DEBUG")); err == nil { debugLogger = lgger.NewLogger("debug") } }
例えばエラーメッセージを翻訳付きで扱いたいvalidatorなどでも状態を少し管理したいと思うことはある。
余談: 現在時刻の固定について
この辺は余談になるが、巷に溢れるテスト用に現在時刻を固定できる関数を作るようなコードもこれの類型。先程の話の通り時刻の固定の方法が各自様々な状況だと辛い。
var Now func()time.Time = time.Now // SetNow()みたいな関数があったり、cleanupも返えす関数になっていたりしたり、内部で真面目にlockをしていたりなど細かな違いはある
flextimeというパッケージはこれをもう少し頑張って作り込んだものの。
その他現在時刻に関する関連情報はこの辺を読むと良い
- Goで時刻をモックする · hnakamur's blog
- Goの並列テストと現在時刻に依存した実装について - pospomeのプログラミング日記
- スレッドセーフなテスト用の時間を固定するライブラリを作った - tenntenn.dev
2. handlerを関数ではなく特定のstructのメソッドとして定義
この状態をもう少しだけ範囲を限定して内に留めておく方法がある。まぁ手軽でそこそこカプセル化されていてちょっとしたmicroserviceの実装などには便利。 1.の状態で厳しくなってくるのは、環境変数で状態を指定するのが辛くなってきたとき、あるいは設定ファイルのようなものを受け取ってそれに伴って依存を初期化したいとき。
type Server struct { Store *Store } func (s *Server) GetArticle(w http.ResponseWriter, r *http.Request) { store := s.Store // フィールドなので触れる parts := strings.Split(r.URL.Path, "/") articleID := parts[len(parts)-1] ob := store.FindArticleByID(articleID) if ob == nil { w.WriteHeader(http.StatusNotFound) return } b, err := json.Marshal(ob) if err != nil { http.Error(w, "ISE", http.StatusInternalServerError) return } w.Write(b) }
今度はstructのフィールドに状態を持つことになったのでメソッドの内部ではグローバル変数のときと同様に参照できる。 この場合はhandlerを定義するパッケージを頑張って分けようとかし始めたタイミングで結構頑張ったり考えたりする事が出てくる。
(おすすめの記事があれば貼る)
3. handlerではなくhandlerを返す関数として定義
結局の所を内部的な状態をどう保持するかという話になるので別のアプローチも存在する。クロージャに閉じ込めてしまえば良い。 関数ではなく関数を返す関数を定義することになるが個人的にはこちらの方針のほうが好みではある。
func GetArticle(store *Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") articleID := parts[len(parts)-1] ob := store.FindArticleByID(articleID) if ob == nil { w.WriteHeader(http.StatusNotFound) return } b, err := json.Marshal(ob) if err != nil { http.Error(w, "ISE", http.StatusInternalServerError) return } w.Write(b) } }
例えば、以下の様な形でrouterに登録されることになる。
func Mount(r Router, store *Store) { r.Get("/articles/{articleId}", GetArticle(store)) }
基本的にはこちらのパターンかstructにフィールドをもたせるパターンで落ち着くんじゃないかとは思う。
(おすすめの記事があれば貼る2)
echoやginやそれぞれのWAF越しの利用
どのWAFを利用するにしても依存の扱いは今までの通り。以下の例は例えば2.を使った方法のechoでの実装。
(おすすめの記事があれば貼る)
認証付きのmiddleware
ここからはちょっと余談になってしまうが、認証付きのmiddlewareを考えると結構抽象の破れ的なことに気づきやすい3。 ここでのmiddlewareは以下の様なもののこと。ラップしたhttp.Handlerを返すadapter。実行時には玉ねぎ状に関数が呼ばれていく。
type Middleware = func(http.Handler) http.Handler
loginRequired的なmiddlewareを考えてみる。ここでもdbにアクセスしたい。とりあえず同様にクロージャを返すことにする。 (このときstructのfieldに状態を持つパターンでかつ肥大化してきたパッケージを分割したいみたいな気持ちが出てきたときに考えることが増える)
func LoginRequired(store *Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { k := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") _, ok := store.users[k] if !ok { w.Header().Set("WWW-Authenticate", `Bearer realm="token_required"`) w.WriteHeader(http.StatusUnauthorized) return } // TODO: bind loginUser next.ServeHTTP(w, r) }) } }
このmiddlewareをどこで提供するか?例えばhandlerの定義は一箇所にまとまっていて欲しいというとこうなる。
func GetArticle(store *Store) http.Handler { var h http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { // ... 中身は一緒 } return LoginRequired(store)(h) }
一方で、routing時に設定されて欲しい場合もある。
// chi.Routerの例 r.With(LoginRequired(store)).Get("/articles/{articleId}", GetArticle(store))
routingの方に持ってくると特定のグループに所属するendpoint全部にミドルウェアとして設置することができるのが便利。
r.Route("/myroute", func(r chi.Router) { r.With(myMiddleware).Route("/{myparam}", func(r chi.Router) { r.Get("/", getHandler) r.Put("/", putHandler) }) })
余談:特定のライフタイムを持つ依存をmiddlewareでも利用したい
これは完全に余談の余談の話になるが、dbオブジェクト的なものがライフタイムを持つようなsession的なものである必要が出てきたときに辛い話はあるかもしれない。
db, cleanup := GetDB(r)
defer cleanup()
特にこの状態で認証middlewareがそのライフタイム付きの依存を必要とした場合。 例えば、requestの持つContextにその依存をWithValueで埋め込む必要があるかもしれないし、middlewareの適用順序に慎重になる必要があるかもしれない。
ところで、それらを考慮した上でミスなく漏れなくドキュメントに認証に関する情報を記載したくなったりするがなかなか悩ましい4。
まとめ
net/httpのhandlerに依存を持ち込む方法は概ね以下3つ。
- グローバル変数として状態を保持
- handlerを関数ではなく特定のstructのメソッドとして定義し、そのstructのフィールドに保持
- handlerではなくhandler返す関数として定義し、routerに登録するのは返されたクロージャにする
dbに依存した認証middlewareのことを考えると考慮漏れに気づきやすい。
この記事も以下の記事のフォローアップみたいな感じになってしまった。
echoやGinでもGorillaでもなんでも使っても問題ありません。どれもnet/httpの兄弟です。こいつらの方が、ミドルウェアなどはたくさんリリースしているので、他の言語ユーザーには親しみやすいかもしれません。これをフレームワークと呼ぶかどうか、便利なライブラリ集と見るかはあなた次第です。
gist
net/httpのhello worldから始めてどういうタイミングでどのライブラリが必要になるかのメモ
goを始めたばかりの人の質問に対して以下の様な記事や回答はまれによく見る(自分自身もそのように答えることはままある)。
とはいえ、ある程度煩雑になってくるとライブラリなどを追加したくなる1。どのようなタイミングで何が欲しくなるなどをメモしておくと頭の整理も兼ねられて良さそう。というわけでメモ。
大雑把な目次は以下のようなもの。
- hello world -- 単一の機能へのエンドポイントがほしい
- mux -- 複数の機能を提供したい
- path parameters -- 自分の見ている視界を他人にも共有したい
- parameter binding -- 文字列型以外の値をいい感じに扱いたい
- enhancement -- より高速に。より便利に。
今回は概ねroutingに絞った話。middlewareがどうこうだとかテストがどうこうだとか実際に本番で運用するときのロギングやモニタリングの話などは含まれていない。
1. hello world
net/httpの最もシンプルなコード例は以下の様なもの。ただ単一の機能を外部に向けて公開したいときに使われる。
package main import ( "fmt" "log" "net/http" "os" "strconv" ) func Hello(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") fmt.Fprintf(w, `{"message": "hello"}`) } func main() { port := 8888 if v, err := strconv.Atoi(os.Getenv("PORT")); err == nil { port = v } log.Print("listen ...", port) handler := http.HandlerFunc(Hello) addr := fmt.Sprintf(":%d", port) if err := http.ListenAndServe(addr, handler); err != nil { log.Fatalf("!! %+v", err) } }
が、たいていの場合はより多くのものが求められる事が多い(気がする)。
2. mux
複数の機能を提供したい場合には一つの入力を複数の出力につなげるmultiplexier的なものが欲しくなる。これはnet/http.ServeMuxを使えば良い。ただしpathは固定された値。此処までは標準ライブラリの範囲。
package main import ( "fmt" "log" "net/http" "os" "strconv" ) func NewGreeting(message string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") fmt.Fprintf(w, `{"message": %q}`, message) } } func main() { port := 8888 if v, err := strconv.Atoi(os.Getenv("PORT")); err == nil { port = v } log.Print("listen ...", port) mux := new(http.ServeMux) mux.Handle("/hello", NewGreeting("hello")) mux.Handle("/byebye", NewGreeting("byebye")) addr := fmt.Sprintf(":%d", port) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatalf("!! %+v", err) } }
用途としては、net/http/pprof等が挙げられる。複数のview(機能)が一人のユーザーに対して提供されている。
実際のところ、インフラ用の内部ツール的なものを作っているときなどはここまでで十分なことも多い。
他には、静的ファイルの配信(net/http.ServeFileなども個人的にはこの範疇に含めている2。
3. path parameters
今度は自分の見ている視界を他人にも共有したくなってくる。このようなときURLのpathの一部を変数として扱いたい。例えば以下の様なREST APIの一部のようなものがそれ。
GET /articles GET /articles/{articleId}
このようなものへの対応にはrouterライブラリを使いたくなる。ここで外部ライブラリの利用を検討し始める。別解3も存在するがあまり頑張る意義はない気がしている。 routerライブラリとは例えば go-chi/chiやjulienschmidt/httprouterやgorilla/muxのようなもののこと。
package main import ( "context" "fmt" "log" "net/http" "os" "strconv" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" ) type Article struct { Title string `json:"title"` } var articles = map[string]*Article{ "1": {Title: "hello"}, "2": {Title: "byebye"}, } func ListArticle(w http.ResponseWriter, r *http.Request) { result := make([]*Article, 0, len(articles)) for _, ob := range articles { result = append(result, ob) } render.JSON(w, r, result) } func GetArticle(w http.ResponseWriter, r *http.Request) { articleID := chi.URLParam(r, "articleId") ob, ok := articles[articleID] if !ok { // go-chi/renderを使わない場合には w.WriteHeader()を使う r := r.WithContext(context.WithValue(r.Context(), render.StatusCtxKey, http.StatusNotFound)) render.JSON(w, r, map[string]string{"message": "not found"}) return } render.JSON(w, r, ob) } func main() { port := 8888 if v, err := strconv.Atoi(os.Getenv("PORT")); err == nil { port = v } log.Print("listen ...", port) r := chi.NewRouter() r.Use(middleware.Logger) r.Get("/articles", ListArticle) r.Get("/articles/{articleId}", GetArticle) r.NotFound(func(w http.ResponseWriter, r *http.Request) { r = r.WithContext(context.WithValue(r.Context(), render.StatusCtxKey, http.StatusNotFound)) render.JSON(w, r, map[string]string{"message": http.StatusText(http.StatusNotFound)}) }) addr := fmt.Sprintf(":%d", port) if err := http.ListenAndServe(addr, r); err != nil { log.Fatalf("!! %+v", err) } }
より細かくrouterライブラリの分類を気にしたければ以下の記事が良い。フローチャートでより細かく分類してくれている。
その他補足事項として、routerライブラリには、デフォルトのエラーハンドラーを設定する機構が存在している事が多い。必要なら忘れずに上書きしておきたい。特にweb APIなどを作っている場合には。 ライブラリの利用をここまでに留めてた場合には素のnet/http.Handlerを尊重した作りを意識していることが多い。その場合にも以下の様なミドルウェアは使える。
- gorilla/handlers: A collection of useful middleware for Go HTTP services & web applications 🛃
- chi/middleware at master · go-chi/chi
4. parameter binding
先程利用したpath parameterを異なる型の値にマッピングしたい場合に、もう少し強力な機能を使いたくなる。ただ以下の様にstrconvで頑張ってやれないことはないので辛くなったら利用という感じかもしれない。
var articleID int64 // stringではなくint v, err := strconv.Atoi(chi.URLParam(r, "articleId")); if err != nil { ... } else { articleID = v }
そういうstrconvをwrapしたようなユーティリティを持つWAF(Web Application Framework)も存在する4。
もう少し範囲を広げて汎用的に利用できる機能がたいていのgoのWAFには存在している。値のバリデーションではなく文字列として得られた入力を別の型に変換したいときに、reflectの力を使いたくなる。そしてたいていの場合はタグで諸々のメタデータを指定する。機能的にはpathに限らずquery stringやheaderにも対応している事が多い。
- labstack/echo - Binding request Data
- gin-gonic/gin - Bind form-data request with custom struct
- (go-fiberはQueryParser, BodyParserという切り口でpath用のbindingはなさそう) Context - Fiber
package main import ( "fmt" "log" "net/http" "os" "strconv" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) type Article struct { Title string `json:"title"` } var articles = map[int64]*Article{ 1: {Title: "hello"}, 2: {Title: "byebye"}, } func ListArticle(c echo.Context) error { result := make([]*Article, 0, len(articles)) for _, ob := range articles { result = append(result, ob) } return c.JSON(http.StatusOK, result) } type GetArticleInput struct { ID int64 `param:"articleId"` } func GetArticle(c echo.Context) error { var input GetArticleInput if err := c.Bind(&input); err != nil { return err } ob, ok := articles[input.ID] if !ok { return c.JSON(http.StatusOK, map[string]string{"message": "not found"}) } return c.JSON(http.StatusOK, ob) } func main() { port := 8888 if v, err := strconv.Atoi(os.Getenv("PORT")); err == nil { port = v } log.Print("listen ...", port) // Echo instance e := echo.New() // Middleware e.Use(middleware.Logger()) e.Use(middleware.Recover()) // Routes e.GET("/articles", ListArticle) e.GET("/articles/:articleId", GetArticle) // Start server addr := fmt.Sprintf(":%d", port) if err := e.Start(addr); err != nil { e.Logger.Fatalf("!! %+v", err) } }
この辺やJSON responseなどを返す時のユーティリティも含んでいる事が多い。これらをそのまま利用したくなった場合にはrouterライブラリだけで過ごすのを辞めたくなるかもしれない。 またコレの他にvalidationの機能をechoやginなどは持っている。
直接利用したい場合にはgorilla/schemaなどをつかう5。
5. enhancement
より頑張りたい場合の話。
例えば、以下の様なもの。
- 高速化のためにreflectの利用の一切を排除したい ( 先程のbindingの話、あるいはjsonのserializerの特殊化も含む )
- 存在するAPIにドキュメントをつけたい6 (e.g. openAPI doc, graphql)
- observebility的な話
(TODO: 元気があったら情報を追加する。情報は募集中)
まとめ
まとめると以下。
- 単一の機能へのエンドポイントがほしい -> http.HandlerFuncを直に利用
- 複数の機能を提供したいだけ -> http.ServeMux
- pathに変数を持ち込みたい(自分の見ている視界を他人にも共有したい) -> go-chiやrouterライブラリでも戦える
- 異なる型の値に変換して利用したい(parameter binding) -> ginやechoがgo-fiberが便利
- より高速に。より便利に。 -> goaなどに進むか何らかの開拓が必要
gist
-
とはいえ、全部追加したとしても、最高に便利で心地の良い開発体験という感じではないけれど↩
-
機能はパラメタライズされていないので。単にエンドポイントが機能の数だけ存在しているだけ。↩
-
net/httpで頑張る別解 How to not use an http-router in go あるいはこれらをまとめてくれた記事 Different approaches to HTTP routing in Go↩
-
正直な話をすると、このあたりやvalidation周りに場当たり感を感じなくもない。元気があれば別の実装に乗り換えたくなる程度には。。↩
-
parameter binding用のライブラリとdata validation用のライブラリは別。前者はjson.Unmarshal()なども含まれる。後者の例としてはたとえば https://github.com/go-playground/validator↩
-
厳密に言えばこれらの話は欄外と考える事もできなくはない。特にgraphqlに関しては。net/http.Handlerを生成するという切り口にして。↩