⚙️

GoのHTTPハンドラーとconfig

2021/12/21に公開

どうも、gureguです。こちらはGo Advent Calendar 2の21日の記事です。

GoでHTTPサーバーを作るときの共通設定の実装について、3つのパターン(&アンチパターン)を紹介します。

問題定義

  • 環境変数でSQLデータベースのDSNを設定したい
  • 複数のHTTPハンドラーで1つの*sql.Connを共有したい

例① グローバル変数にする(悪くない)

まず、一番シンプルなパターンです。起動時に環境変数を取得して、*sql.Connのグローバル変数を作ります。

var db *sql.Conn

func main() {
	dsn := os.Getenv("DB_DSN")
	var err error
	db, err = sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	
	http.HandleFunc("/foo", fooHandler)
	log.Fatal(http.ListenAndServe(":8000", nil))
}

// HTTPハンドラーは普通の関数
func fooHandler(w http.ResponseWriter, r *http.Request) {
	// グローバルのdbを使います
	_, err := db.Exec("...")
	// ...
}

メリット

  • めちゃくちゃシンプルで書きやすい

デメリット

  • モック・テストしにくい
    • 一部のテストだけでモックする、みたいなことが難しい
    • テスト毎のデータベースの初期化のタイミングなどが難しい
  • パッケージを分けにくい?
    • フラットな構成を目指すなら問題ないかも

結論

個人開発やフラットさを極めたい時に使うといいでしょう。

例② structにする(ベストプラクティス)

structにconfig情報を詰めて、ハンドラーをstructのメソッドにします。


type Server struct {
	db *sql.Conn
}

func (srv Server) fooHandler(w http.ResponseWriter, r *http.Request) {
	// Serverのdbを使います
	_, err := srv.db.Exec("...")
}

func main() {
	dsn := os.Getenv("DB_DSN")
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	srv := Server{
		db: db,
	}
	http.HandleFunc("/foo", srv.fooHandler)
	log.Fatal(http.ListenAndServe(":8000", nil))
}

メリット

  • グローバルじゃないのでテストしやすいです。
    • テスト毎にServerを作っちゃって大丈夫です
  • コードが読みやすい
    • config周りは一か所だけ

デメリット

  • handlerのパッケージは分けられない
    • 逆にいいことかも?

結論

真面目なプロジェクトならこれがいいと思います。Server自体をhttp.Handlerにするとなお良いです。

例③ contextに入れる(アンチパターン)

皆さんはkamiを覚えていますか?
kamiの主な機能はkami.ContextによってベースとなるGod Objectなcontextです。
kami.Context*sql.Connを入れれば、全てのリクエストのhttp.Request.Context()から取り出せます。
使い方についてはkamiのREADMEへどうぞ。
テストでモック入れたい場合はcontextの値を上書きして、使いやすい!と思いきや…

実はこれはアンチパターンです。リクエストスコープ以外のデータをcontextに入れてはいけないと、公式のドキュメントではそう書いてあります。

不思議なことに、いつのまにか標準ライブラリに同じ機能が入りました。Server.BaseContextをいじると、kamiと同じことが出来ます。しかしBaseContextは関数なので、必ずしも悪用な用途にはならないかと思います。

メリット

  • パッケージが分けやすい?

デメリット

  • 公式認定のアンチパターン
  • contextにどこから何が入っているかが分かりにくい

結論

やめときましょう。

おまけ: os.Getenvを使うタイミング

これは好みかもしれませんが、個人的には、mainパッケージ[1]以外でos.Getenvなどを呼びたくないです。
全てのconfigの初期化は同じ場所でやりたい。複数のパッケージがos.Getenvを呼んでいると、どこが何に依存しているかが分かりにくいです。

おわりに

皆さんはどういうconfigが好きですか?他のやり方があったらぜひコメントしてください。
私はシンプルなプロトタイプなどではグローバル変数を使って、真面目な仕事はstructにしています。

脚注
  1. config専用のパッケージでもOKです ↩︎

Discussion