2 ポイント 投稿者 GN⁺ 2024-02-10 | 1件のコメント | WhatsAppで共有
  • 長く維持される 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.Handlemux.HandleFunchttp.NotFoundHandler のようなルーティングに集中する
    • ハンドラ自体がエラーを返す設計なら、addRoutes もエラーを返せる

mainrun だけを呼ぶ

  • func main()run() を呼び、エラーがあれば stderr に書いて異常終了する薄い関数として置く
    • runcontext.Context、引数、入出力、環境アクセス関数のような OS の基本要素を引数で受け取る
    • run はエラーを返すので、通常の Go コードのようにエラーハンドリングできる
  • run に渡せる値の例は次のとおり
    • os.Args: プログラム実行引数とフラグ解析に使用
    • os.Stdin: 入力の読み取り
    • os.Stdout: 出力の書き込み
    • os.Stderr: エラーログの書き込み
    • os.Getenv: 環境変数の読み取り
    • os.Getwd: 現在の作業ディレクトリの取得
  • signal.NotifyContextrun の中で設定する
    • Ctrl+C のような終了シグナルが入るとコンテキストがキャンセルされる
    • runnil を返せば正常終了する
    • エラーを返せば 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 に記録する
  • テストでサーバーが実際に準備できたか確認するには、/healthz/readyz エンドポイントを置く
    • 別チャネルで準備シグナルを作ることもできるが、実際の HTTP リクエストで確認する方式を好む
    • 準備確認ループは 200 OK が返るまでリクエストする
    • コンテキストキャンセルやタイムアウトに達したらエラーを返す
    • 例のループはリクエストの間に 250ms 休む

ハンドラ構成方式

  • ハンドラ関数は http.Handlerhttp.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) のように期待型を明示する必要がある
  • バリデーションには単一メソッドのインターフェースを使う
    • Validator インターフェースは Valid(ctx context.Context) map[string]string という形である
    • 問題がなければ長さ 0 の map が返る
    • 問題があるフィールドはフィールド名を key、人が読める説明を value にする
  • バリデーション対象は高速なフィールド確認に向いている
    • 必須フィールドが空でないか
    • メールアドレスのような特定文字列形式に合っているか
    • 数値が許容範囲内か
  • データベース参照のような、より複雑な検査は別の場所で処理する
    • そうした検査は高速なバリデーション関数の中に隠すには重要度が高い
    • ジェネリクス版の decodeValid[T Validator] は、型 T が必ず Validator を実装することを強制する
    • nil map に 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.NewRecorderhttp.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件のコメント

 
GN⁺ 2024-02-10
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/
    • 概念的には古くからある private コンストラクタファクトリメソッドの手法と同じ
    • 関連リンク: Parse, don't validate (2019) - https://news.ycombinator.com/item?id=35053118 - 2023年3月、Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=27639890 - 2021年6月、Parse, Don’t Validate - https://news.ycombinator.com/item?id=21476261 - 2019年11月、Parse, Don't Validate - https://news.ycombinator.com/item?id=21471753 - 2019年11月
    • Go ではこのパターンを適用するのが難しかった。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_USE API を使う必要があったのに、それでオブジェクトを変えて障害を起こしたこと
    • 妥当な批判だと思う。より使いやすいパターンは何だと思うのか気になる
  • Mat Ryer の仕事が本当に好きで、この記事の 2018 年版に出てきたアイデアの大半を、それ以降のすべての Go プロジェクトに適用してきた
    ただ、NewServer がすべての依存関係を引数で受け取る大きなコンストラクタであり、テストで不要な依存関係は使わないという合図として nil を渡す、という部分はずっと気になっていた
    その結果、コードの大きな部分が不要な共有状態を大量に持つことになる。実際には、リクエストユーザーがリソースにアクセスできるか確認し、データストアの関数を 1 つ呼ぶだけでよい HTTP ハンドラも多いのに、親サーバーが持つすべてのオブジェクトとデータストア全体にアクセスできる巨大な塊の一部になってしまう
    2 つのメソッドだけをモックしてテストしたくても、単純なテストを書くのが難しくなる。Mat Ryer のパターンはこれまで見た中で最高だが、もっとよい解法があるのではないかという感覚が残る

    • 作業しているリポジトリでは、こうした高い求心性結合の地点にだんだん敏感になってきた。特に Bazel の世界に深く入るほど、依存関係管理と物理設計が書くコードに与える影響が大きくなる
      可能であれば、プラグインは優れたコード境界戦略になる。プラグインアーキテクチャは基本的にオプトアウトではなく、明示的に選択して初めて表に出るため、あらゆる可能性をあるコードの塊に強制しない
      こうした性質のソフトウェアを「アラカルト式」と呼んでいる。一般には、「何かをするためにすべてを行う」状況は避けるべきだ
    • ロジックの大半はパッケージとして書くことが多い。たとえば HN を作るなら、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 構造体の中に、その処理に必要な storecacheloggerpub といった依存関係だけを入れ、ServeHTTP を実装する
      main.go や依存関係を設定する場所で各処理を作り、必要な依存関係だけを渡す。こうすると、特定の処理/ハンドラ用のヘルパーメソッドをその構造体の private メソッドとして保てるのでよい
      ただし、ある処理が別の処理を必要とする場合は、互いに渡し始めるか、別パッケージ/サービスに切り出す必要があり、面倒になることがある
    • 必ずしも 9 個以上の個別引数である必要はない。言語によっては、ハンドラに必要なものだけを含む単一の context/env オブジェクトにできる
      たとえば 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 スキーマ検証などをやってくれる。自分がやることはサービスを実装するだけ。
    クエリ文字列の整数範囲検証みたいなものは退屈すぎるし、自分で書くとタイプミスもしやすい。
    まだ触って遊んでいる段階なので、悪いところは見つけていない。

    • このアプローチの問題は、OpenAPI を最初から手で書く作業がものすごく退屈だということ。
      Protobuf や capnproto のような似た IDL を書くほうが、ずっと生産的に感じる。
    • Go コードから OpenAPI 仕様を出力するほうを好むなら、danielgtaylor/huma[1] と swaggest/rest[2] も良い。
      [1] https://github.com/danielgtaylor/huma
      [2] https://github.com/swaggest/rest
    • 同じように oapigen から始めた: github.com/deepmap/oapi-codegen
      仕様を書くのは退屈だろうと思っていたが、想像よりずっと良かったし、どうせ仕様が必要なら先に書くほうがいいと思っている。
  • 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 のクイックスタートガイドはもう見たくない。