13年後のGoでHTTPサービスを書く方法
(grafana.com)- 長く維持される Go の HTTP サービスは、明示的な依存性注入、一箇所に集約したルート、テスト可能な
run関数で構成すると、保守と検証がしやすくなる - ハンドラはサーバー構造体のメソッドよりも、必要な値をクロージャで受け取る
http.Handlerを返す関数 として作り、共通ミドルウェアはサーバー生成とルート登録の段階で組み合わせる func main()は薄く保ち、run()にcontext.Context、引数、環境アクセス、標準入出力を注入すると、終了処理とテスト制御 が単純になる- リクエスト/レスポンスのエンコード、バリデーション、ミドルウェアアダプタ、
sync.Onceによる遅延初期化は、繰り返しコードを減らしつつ Go 標準のnet/httpフロー を維持する - テストは個別ハンドラよりも、実際の API 呼び出しに近い エンドツーエンド方式 を好み、各テストが独自のサーバーを起動して
/healthzや/readyzで準備状態を確認する
サーバー生成とサービスのエントリポイント
NewServerコンストラクタ は、サービスの中核となるhttp.Handlerを作る関数として置く- 通常はサービスごとに 1 つ置き、内部ルートがリクエストを各ハンドラへ振り分ける
- ロガー、設定、ストレージ、外部クライアントのような依存性をすべて引数で受け取る
- 可能なら
http.Handlerを返し、複雑な場合は専用型を使ってもよい - 独自の muxer を構成したうえで、
routes.goのルート登録関数に渡す
- すべてのエンドポイントに共通して必要な HTTP 処理は
NewServerでまとめる- CORS
- 認証ミドルウェア
- ロギング
- トレース ID ミドルウェア
- 依存性引数の一覧が長くなっても、関数引数 として明示する方式を好む
- 構造体フィールドの入れ忘れはコンパイラが防げないことがあるが、関数引数なら必要な値を渡さない限り呼び出せない
- 長い引数一覧は縦に整形すると読みやすい
- 特定のテストで使わない依存性は
nilを渡し、使われないことのシグナルにする
routes.go に API サーフェスを集約する
routes.goは、サービスのすべてのルートを一箇所で確認できるファイルとして置く- プロジェクトごとに API サーフェスを見渡すための単一の場所ができる
NewServerの大きな依存性一覧のため、addRoutesにも似たような引数一覧が生じることがある- Go の型チェックが、引数の欠落や順序ミスを検出してくれる
addRoutesはできるだけ 単純でフラットに 保つ- エラーになり得る処理は
run関数で先に処理する - ハンドラ登録段階では
mux.Handle、mux.HandleFunc、http.NotFoundHandlerのようなルーティングに集中する - ハンドラ自体がエラーを返す設計なら、
addRoutesもエラーを返せる
- エラーになり得る処理は
main は run だけを呼ぶ
func main()はrun()を呼び、エラーがあればstderrに書いて異常終了する薄い関数として置くrunはcontext.Context、引数、入出力、環境アクセス関数のような OS の基本要素を引数で受け取るrunはエラーを返すので、通常の Go コードのようにエラーハンドリングできる
runに渡せる値の例は次のとおりos.Args: プログラム実行引数とフラグ解析に使用os.Stdin: 入力の読み取りos.Stdout: 出力の書き込みos.Stderr: エラーログの書き込みos.Getenv: 環境変数の読み取りos.Getwd: 現在の作業ディレクトリの取得
signal.NotifyContextはrunの中で設定するCtrl+Cのような終了シグナルが入るとコンテキストがキャンセルされるrunがnilを返せば正常終了する- エラーを返せば
mainがエラーを出力し、0 以外のコードで終了する
- グローバル状態を避けると、より多くのテストで
t.Parallel()を使えるrunを何度呼んでも各実行が互いに干渉しない- フラグはグローバル
flagの代わりにrun内のflags.NewFlagSetで処理する - 環境変数は実際の環境を変更する代わりに
getenv func(string) stringを注入して制御する - この方式なら
t.SetEnvと違って並列テストを継続して使える
終了と準備状態の処理
- コンテキストはサービスの全レイヤーに渡すべきである
- 終了シグナルが来るとコンテキストはキャンセルされる
- 長時間の処理や繰り返し処理は
ctx.Err()やctx.Done()を確認して中断する - 別の goroutine を開始した場合も、コンテキストで停止時点を判断する
- HTTP サーバーは終了時に
Shutdownを呼び、グレースフルに停止する- 例では別 goroutine で
ctx.Done()を待つ - 終了用コンテキストに
10 * time.Secondのタイムアウトを置く - 終了中にエラーがあれば
stderrに記録する
- 例では別 goroutine で
- テストでサーバーが実際に準備できたか確認するには、
/healthzや/readyzエンドポイントを置く- 別チャネルで準備シグナルを作ることもできるが、実際の HTTP リクエストで確認する方式を好む
- 準備確認ループは
200 OKが返るまでリクエストする - コンテキストキャンセルやタイムアウトに達したらエラーを返す
- 例のループはリクエストの間に
250ms休む
ハンドラ構成方式
- ハンドラ関数は
http.Handlerやhttp.HandlerFuncを直接実装するより 返す 形にする- 例:
func handleSomething(logger *Logger) http.Handler - ハンドラごとのクロージャ環境を作れる
- 初期化した値をリクエスト処理時に使える
- 例:
- 共有データは読み取り専用でのみ使うのが安全である
- ハンドラが値を変更するなら mutex などの保護が必要になる
- プログラム状態をクロージャに保存する方式は通常推奨されない
- クラウド環境では、インスタンスが長く維持されると仮定しにくい
- サーバーがリソース節約のため停止されたり、別の理由でクラッシュしたりし得る
- 複数インスタンスが同時に動き、リクエストが予測しにくい形で分散されることもある
- 実プロジェクトの永続状態はデータベースや別の保存 API に置くほうがよい
リクエスト/レスポンスのエンコードとバリデーション
- すべてのサービスではリクエストボディのデコードとレスポンスボディのエンコードが必要なので、
encode/decodeヘルパーを置く- 例では JSON の
Content-Typeを設定し、ステータスコードを書いた後にjson.NewEncoder(w).Encode(v)を呼ぶ - デコードは
json.NewDecoder(r.Body).Decode(&v)を包み、エラーに文脈を付ける - ジェネリクスを使えば
encode(w, r, http.StatusOK, obj)のように型推論が可能になる decodeは戻り値型なので、decode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)のように期待型を明示する必要がある
- 例では JSON の
- バリデーションには単一メソッドのインターフェースを使う
ValidatorインターフェースはValid(ctx context.Context) map[string]stringという形である- 問題がなければ長さ 0 の map が返る
- 問題があるフィールドはフィールド名を key、人が読める説明を value にする
- バリデーション対象は高速なフィールド確認に向いている
- 必須フィールドが空でないか
- メールアドレスのような特定文字列形式に合っているか
- 数値が許容範囲内か
- データベース参照のような、より複雑な検査は別の場所で処理する
- そうした検査は高速なバリデーション関数の中に隠すには重要度が高い
- ジェネリクス版の
decodeValid[T Validator]は、型Tが必ずValidatorを実装することを強制する nilmap にlen(problems)を呼んでも 0 なので panic にはならない
ミドルウェアアダプタパターン
- ミドルウェアは
http.Handlerを受け取り、新しいhttp.Handlerを返す- 元のハンドラ呼び出しの前後でコードを実行できる
- 条件によっては元のハンドラを呼ばないこともある
- 例の
adminOnlyは管理者でなければHTTP 404 Not Foundを返し、元のハンドラを呼ばない
- ミドルウェアの適用場所は通常
routes.goに置く- エンドポイント一覧を見るだけで、どのルートにどのミドルウェアが付いているか分かる
- ミドルウェア一覧が長くなれば複数行に分けて読みやすくする
- 依存性の多いミドルウェアは、ミドルウェアを返す関数で包む
newMiddleware(logger, db, slackClient, rroll)がfunc(http.Handler) http.Handlerを返す- ルート登録コードでは
middleware(handleSomething(...))のように簡潔に使える - 別途
type middleware func(h http.Handler) http.Handlerを定義することもできるが、戻り値型を直接書くほうがコードの可読性は高い
リクエスト/レスポンス型のスコープを狭める
- 特定エンドポイントでのみ使うリクエスト/レスポンス型は ハンドラ関数の内部 に定義できる
- グローバル名前空間をきれいに保てる
- 他のハンドラが、安定である保証のない型に依存してしまうのを防げる
- テストコードで同じ型が必要になると摩擦が生じることがある
- この場合は型を外へ出すのも妥当である
- リクエスト/レスポンス型がハンドラ内にあるなら、テストでは新しい匿名構造体やローカル型を宣言できる
- テストのローカル型は意図を示しやすい
- たとえば
/greetエンドポイントがPerson全体ではなくNameフィールドだけを必要とするなら、テスト入力構造体にはNameだけを置く - テストを読む人は、そのエンドポイントがどのフィールドに関心を持つかすぐ分かる
- たとえば
sync.Once による初期化遅延
- ハンドラ準備中の高コストな処理は
sync.Onceで最初のリクエスト時点まで遅らせる- アプリケーション起動時間を短縮できる
- ハンドラが呼ばれなければ高コスト処理は実行されない
- 例ではテンプレートファイルのパースを最初のリクエストで一度だけ実行する
sync.Onceはコードが一度だけ実行されることを保証する- 同時に来た他のリクエストは初期化が終わるまで待機する
- エラー確認は
init.Doの外で行い、エラーを継続して表面化させる
- この方式は初期化時間を起動時点から、ランタイム中の最初のエンドポイントアクセス時点へ移す
- Google App Engine を多用する環境では、この方式が適していることがある
- デプロイ環境に応じて、どこでいつ
sync.Onceを使うか判断すべきである
テスト戦略
- この構造ではテスト容易性を重要な目標にしている
run関数によって、テストコードからプログラムを直接実行できる- テストは、プログラム動作を理解しやすいか、変更時に壊れる不安を減らせるか、通過後に本番デプロイへの信頼を与えられるかを基準にする
- ハンドラだけを独立してテストすることもできる
- ハンドラ生成関数を呼び、必要な依存性を渡す
httptest.NewRecorderとhttp.NewRequestでリクエストとレスポンスを構成する- ステータスコード、レスポンスボディ、ヘッダを検証する
- この方式は認証などのミドルウェアを通さず、ハンドラコードへ直接入る
- より好まれる方式は エンドツーエンドテスト に近い
runを呼んで、実際の実行方式に近い形でプログラムを起動する- 引数解析、依存性配線、データベースマイグレーション、サーバー起動まで含む
- テストが API を呼べば、すべてのレイヤーと
routes.goも一緒に検証される - 実際のデータベースと相互作用できる
- この方式は重複テストを減らす助けになる
- すべてのレイヤーを別々にテストすると、同じ内容を少しずつ異なる形で何度も検証しがちである
- エンドツーエンドテストは、ユーザーとシステムの相互作用を説明する中心的なテストセットを提供する
- TDD などで既に生まれた単体テストは適切なら維持しつつ、エンドツーエンドテストと同じ内容を繰り返すなら削除できる
- 各テストは独自のプログラムインスタンスを実行できる
- テストごとに異なる引数、フラグ、標準入出力、環境変数を渡す
context.WithCancelでキャンセル関数を作り、t.Cleanup(cancel)に登録する- テストが終わるとコンテキストがキャンセルされ、プログラムがグレースフルに終了する
- Go 1.14 の
t.Cleanupは、直接deferを使う代替手段として使われる
実際の適用範囲と組織的文脈
- シンプルな API を作るとき、このパターンは読みやすく拡張しやすいコードを目指す
- パターンをコピーして拡張しやすい
- 新しい人が作業しやすい
- 変更時の不安を減らせる
- 魔法のような挙動なしに明示的に構成される
- コード生成ツールを使っていても、この方式は維持できる
- 例として、テンプレートベースのボイラープレート生成に Oto package を使える
- 大規模プロジェクトや大きな組織では、既存の技術選択が判断を変えることがある
- Grafana Labs のような組織では、特定のツールや抽象化がすでに広く使われている場合がある
- gRPC がその例である
- 確立されたパターンと経験があるなら、その流れに従う実用的な選択になる
- Grafana IRM 製品群の文脈も含まれる
- Grafana IRM は Grafana Labs が構築中の製品群である
- Grafana Alerting は、メトリクスが許容範囲を外れたときにアラートを送る
- Grafana OnCall は、スケジュールとエスカレーションルールにより適切な人へ連絡する流れを自動化する
- Grafana Incident は、Zoom ルーム、専用 Slack チャネル、イベントタイムラインを作成し、インシデント対応を支援する
- Slack チャネルでロボットの顔絵文字リアクションが付いた項目はタイムラインに追加される
1件のコメント
Hacker News のコメント
Validメソッドのようにバリデータを別に置く方式も試したことがあるが、Lexi Lambda の「Parse, Don’t Validate」[0] を読んでからは、Go の型チェッカーを活用するほうがエラーがはるかに少ないと感じているたとえば、ユーザーが山括弧を含む不正なユーザー名を絶対に指定できないようにするには、バリデータ方式では信頼できない入力からユーザー名が来るすべてのコードパスでバリデータを呼び出す必要がある
代わりに
Username型とNewUsername(username string) (Username, error)コンストラクタを用意すれば、Usernameオブジェクトが存在するという事実だけで、すでに検証を通過していることが保証される[0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
stringで渡すのではなく、パースして型を付けるべきこのパターンは検証をいつでも早くも遅くもできるようにしてくれるが、いつ行うべきかまでは教えてくれない。たいていは、より大きなオブジェクトをパース/検証する過程の一部として行うのが最善なことが多い
UI の文脈で未検証のデータを扱うアイデアについては、Steven Witten の「I is for Intent」[1] が参考になる
[1] https://acko.net/blog/i-is-for-intent/
Usernameが構造体の中に含まれていて値を設定し忘れると、制約に違反しうる zero value が入ってしまうシステム全体の設定を表す Config オブジェクトを受け取り、それをあちこちにミュータブルに渡す方式は、最も嫌いなパターンの一つ
そうすると、あらゆるものが設定オブジェクトを介して結合される。あるシステムでは、誰かが受け取った設定オブジェクトに再び値を書き込み、正常に動かすには各部分を特定の順序で設定しなければならなかった
また別のケースでは、あるサブシステムが後で読むデータを設定オブジェクトに書き込んでしまい、システムの一部を無効化できなかった
「設定が一つの大きなミュータブルな値」であるパターンは、Go だけでなく他の言語でもかなり厄介なパターン
Python プロジェクトでは不変の設定
dataclassをよく使い、複数のモジュールに渡している。複数の関数が複数の値に依存する場合、それぞれを関数引数として渡して型を別々に定義する代わりに、dataclassの一か所にすべての変数と型定義があるので、かなり便利な設計パターンになるOption関数で構成できるようにすること内部の
optionsは生成中だけ変更し、外部にはConfigとアクセサだけを公開する。たとえばconfig.New(config.Name("Emanon"))のように作り、cfg.Name()で読むようにできるConfig構造体を作り、configs.Configは各パッケージのConfigを集めた形にすることが多いGo のベストプラクティスではないかもしれないが、起動時にはシステム全体の設定を一つのエンティティとして作れ、各パッケージには必要最小限の依存だけを渡せるので良い
テスト時にも、1つのパッケージだけをテストするために設定全体をフェイクで作る必要がなくなり、少し楽になる
絶対に変更してはいけない。どこでどう使われているかわからず、無駄になる人件費がもったいないし、変更が必要なら元の値から派生した値を作るほうがよい
笑えるのは、設計上その設定オブジェクトはある程度不変で、変更するには
WARNING_DO_NOT_USEAPI を使う必要があったのに、それでオブジェクトを変えて障害を起こしたことMat Ryer の仕事が本当に好きで、この記事の 2018 年版に出てきたアイデアの大半を、それ以降のすべての Go プロジェクトに適用してきた
ただ、
NewServerがすべての依存関係を引数で受け取る大きなコンストラクタであり、テストで不要な依存関係は使わないという合図としてnilを渡す、という部分はずっと気になっていたその結果、コードの大きな部分が不要な共有状態を大量に持つことになる。実際には、リクエストユーザーがリソースにアクセスできるか確認し、データストアの関数を 1 つ呼ぶだけでよい HTTP ハンドラも多いのに、親サーバーが持つすべてのオブジェクトとデータストア全体にアクセスできる巨大な塊の一部になってしまう
2 つのメソッドだけをモックしてテストしたくても、単純なテストを書くのが難しくなる。Mat Ryer のパターンはこれまで見た中で最高だが、もっとよい解法があるのではないかという感覚が残る
可能であれば、プラグインは優れたコード境界戦略になる。プラグインアーキテクチャは基本的にオプトアウトではなく、明示的に選択して初めて表に出るため、あらゆる可能性をあるコードの塊に強制しない
こうした性質のソフトウェアを「アラカルト式」と呼んでいる。一般には、「何かをするためにすべてを行う」状況は避けるべきだ
usersパッケージやcommentsパッケージのように置くこれらのパッケージには HTTP インターフェースはまったくないが、それぞれ自分の
mainと一種の CLI インターフェースを持つ。そのファイルコメントの//go:build ignoreが役に立つfunc HandleX(w http.ResponseWriter, req *http.Request)のようにハンドラを定義する代わりに、func HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFuncの形で必要な依存関係を受け取り、内部で実際のhttp.HandlerFuncを返すようにするそしてエントリポイントで一度だけ初期化する
NewServerが作ったオブジェクトが多くのことをしすぎている、という意味だ。多すぎるデータ型と振る舞いが結合している可能性が高い簡単な例として、ロガーをコンストラクタ依存関係に追加すると、そのオブジェクトは最初の単純な実装より少し多くのことをするようになる。それ自体は問題ないが、単純なものの実装を直さずにロギングする方法を見つけられないなら惜しい
高階関数、たとえばロガーデコレータは合成を可能にしてくれるが、それにも欠点はある。それでも扱える構造の一形態であって、間違いではない
要点は、
NewServerの中で任意設定構造体の値を検証したうえで、サーバー構造体へコピーする方式だ。そうすると、より少ない依存関係だけをモックできるので、テストがずっと楽になる関数オプションパターンも、多くの人が勧めるように何度も試したが、結局やめた。少し賢すぎて読みにくく、設定構造体 + 検証後コピーのパターンよりボイラープレートも多いと感じた
[0] https://news.ycombinator.com/item?id=39320170
どの言語の HTTP サービスでも、この考え方がもっと広く受け入れられてほしい。ハンドラが依存関係を必要とするなら、それを引数として直接要求すべきであり、サーバー構造体にぶら下がったメソッドにして、テスト時に予期しない依存関係が出てくるようにすべきではない
HTTP サービスのハンドラにはたいてい多くのビジネスロジックが入り、そのロジックは多くの依存関係を持つ可能性が高い。実際、単一のハンドラが DB、キャッシュ、Blob ストレージ、エンドポイント固有の権限チェック、ライセンスチェッカー、キュー、特殊なロガー、メトリクスクライアントなどを使う例をよく見る
パラメータは 9 個以上になることもあり、リンターや経験則はたいていそれを避けようとするが、依存関係が消えるわけではない。
serverクラス/構造体に隠して、メソッドシグネチャが短いから依存関係が少ないと見せかけているだけだ時間がたって 20 個になったとしても、すべての依存関係が関数/メソッドシグネチャに現れているコードのほうがよいと感じる。そうすれば、コードの複雑さが増していることを偽らずに済む
たとえば
CreateUser構造体の中に、その処理に必要なstore、cache、logger、pubといった依存関係だけを入れ、ServeHTTPを実装するmain.goや依存関係を設定する場所で各処理を作り、必要な依存関係だけを渡す。こうすると、特定の処理/ハンドラ用のヘルパーメソッドをその構造体の private メソッドとして保てるのでよいただし、ある処理が別の処理を必要とする場合は、互いに渡し始めるか、別パッケージ/サービスに切り出す必要があり、面倒になることがある
たとえば
handleHello({ db, cache, blobStore, authz }, req, res)のように書けば、2 つのハンドラがまったく同じコンテキストを使う場合に再利用でき、呼び出し側でハンドラごとのコンテキストを宣言するのも簡単だこの記事の多くに同意しており、いくつか付け加えたい
アプリケーションコンテキストと一緒に WaitGroup をサービス構造体へ渡すと、割り込みがコンテキスト経由でアプリ終了をトリガーし、メイン goroutine は実際に終了する前に WaitGroup を待てる
CLI プログラムなら stdout、stdin、stderr、args、env などをテストするのは有用だが、HTTP サーバーではそれほどでもないと思う。
run関数には構造化された設定を渡し、テストの焦点をより絞るだろうハンドラで
sync.Onceを使ってテンプレートをパースする方式には反対だ。ハンドラがテンプレートをパースすべきではなく、アプリ起動時に行うべきだと思う。テンプレートをパースできないなら、アプリはリクエストを受け付ける準備完了状態になるべきではなく、非ゼロの終了コードで終わるべきだ最近 ogen で遊んでいる: https://github.com/ogen-go/ogen
OpenAPI 定義を書くと、ルーティング、構造体定義、JSON スキーマ検証などをやってくれる。自分がやることはサービスを実装するだけ。
クエリ文字列の整数範囲検証みたいなものは退屈すぎるし、自分で書くとタイプミスもしやすい。
まだ触って遊んでいる段階なので、悪いところは見つけていない。
Protobuf や capnproto のような似た IDL を書くほうが、ずっと生産的に感じる。
[1] https://github.com/danielgtaylor/huma
[2] https://github.com/swaggest/rest
仕様を書くのは退屈だろうと思っていたが、想像よりずっと良かったし、どうせ仕様が必要なら先に書くほうがいいと思っている。
fx(https://github.com/uber-go/fx) は、アプリケーションを設計するための非常にシンプルで多用途なツールだと感じる。
記事の助言は今でも有用だが、「Y が必要とするときに X が初期化済みであることをどう保証するか」という部分を完全に取り除いてくれる。N*M 問題が N 問題に減り、各部品をどう初期化するかだけを気にすればよく、部品間の初期化の同期を心配しなくて済む。
複数の言語で依存性注入ライブラリをかなり使ってきたし、自分で実装したこともあるが、fx のシンプルさと汎用性が今のところ一番気に入っている。
よく設計されたシステムでは、この問題は些細なはずだ。何かを使いたいときに初期化済みであることを保証するのは、コンストラクタ引数として渡せるように準備できているかどうかの問題だ。
stockService := NewStockService(),orderService := NewOrderService(),orderProcessor := NewOrderProcessor(stockService, orderService)のように作ればよい。初期化の「同期」などは必要ないはずで、間違えればコンパイルされない。循環依存を追加しても、正しい順序で組み立てられないので明確に表面化する。
興味深いアイデアが多い素晴らしい記事だ。
signal.NotifyContextを知らなかったのが信じられない。これでプロジェクトごとにコピペしなくても、シグナル処理の方法を覚えていられそうだ。
ここでのやり方はかなり気に入っているが、自分のテストは少し違う。
newTestServer()で依存関係をフェイクに差し替えたサーバーを立ち上げ、依存関係のエラーをテストしたい場合は、その属性をエラーを返すフェイクに変える。そうすれば、エラーパス、ログ項目、メトリクス発行、タイムアウト、graceful shutdown まで検証できる。
サーバーが起動した後は、どのポートにバインドされたかを確認する。デフォルトが
:0なので、実際に割り当てられたポートを待つ必要があるためだ。「ユニット」テストはハンドラレベルでも HTTP レベルでもでき、ミドルウェア全体を通すことも、まったく通さないこともできる。そのうえで、ユーザーが目にする形に沿ってコードを十分にテストできる。N 個のインスタンスを立ち上げて並列にテストすることも可能だ。
Go は使っていないが、こうしたパターンは気に入っている。テスト可能なコードにかなり普遍的に適用できる感じがする。
依存関係を暗黙的・静的・テスト不能な形で扱うもの、特に Python のクイックスタートガイドはもう見たくない。