【ITニュース解説】[Go, Opinion] How can we write truly "middle"ware with Go's net/http?
2025年10月01日に「Dev.to」が公開したITニュース「[Go, Opinion] How can we write truly "middle"ware with Go's net/http?」について初心者にもわかりやすく解説しています。
ITニュース概要
Goのnet/httpにおける一般的なミドルウェアは、リクエスト処理前には機能するが、レスポンスのヘッダー変更など出力部分の制御が難しい。これは「半分のミドルウェア」であり、カスタムのハンドラーやリクエスト・レスポンス構造体を定義することで、サービスレベルでレスポンス処理を制御できるミドルウェアを実装する案が提案されている。
ITニュース解説
Go言語を用いたWebアプリケーション開発において、クライアントからのリクエストに対して共通の処理を適用したい場合、「ミドルウェア」という仕組みが非常に有効に利用されている。ミドルウェアは、リクエストが本来の処理を行う「ハンドラ」に届く前や、ハンドラが処理を終えてレスポンスを返す途中に介入し、認証チェック、ログ出力、タイムアウト設定といった共通の機能を実行するソフトウェアの部品である。
Goの標準ライブラリであるnet/httpパッケージでミドルウェアを実装する際、多くの開発者が採用している一般的なパターンがある。それは、既存のhttp.Handler(リクエストを実際に処理するコード)を引数として受け取り、そのハンドラの呼び出し前後に独自の処理を追加し、新しいhttp.Handlerとして返すという方法だ。具体的には、次のようなコードで表現できる。
1func AddSomeMiddleware( 2 handler http.Handler, 3 someFunc func(r *http.Request), 4) http.Handler { 5 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 6 // ハンドラがリクエストを処理する直前にsomeFuncを実行 7 someFunc(r) 8 9 // このミドルウェアがラップしている本来のハンドラを呼び出す 10 handler.ServeHTTP(w, r) 11 }) 12}
このパターンでは、someFuncという関数がリクエスト処理の前に実行され、その後、元のhandlerがリクエストを処理する。この方式は非常に分かりやすく、多くのケースで有用であるため、Goにおけるミドルウェアの標準的な実装方法として広く認識されている。
しかし、このシンプルで一般的なミドルウェアパターンには、大きな限界が存在する。それは、ハンドラが生成したレスポンスに対して、ミドルウェアが意味のある操作を行うことができないという点だ。上記のコードを見ても分かるように、handler.ServeHTTP(w, r)が実行された後で、レスポンスを表すwに対して追加の処理(例えば、特定のヘッダの追加やレスポンスボディの加工など)を試みても、ほとんどの場合、期待通りに動作しない。
この問題の根本原因は、Goのnet/httpパッケージにおけるhttp.ResponseWriterインターフェースの動作にある。http.ResponseWriterは、クライアントへ送り返すレスポンスを書き込むためのツールであり、レスポンスヘッダを書き込むWriteHeaderメソッドや、レスポンスボディを書き込むWriteメソッドを備えている。
ここで重要なのは、一度WriteHeaderメソッドが呼び出されると、その後はレスポンスヘッダの追加、変更、削除が非常に困難になるという特性だ。特に、HTTPステータスコードが1xx(情報提供用)でない限り、WriteHeaderが呼ばれた後にヘッダマップを操作しても、その変更はクライアントには反映されない。さらに、Writeメソッドは、もしWriteHeaderがまだ明示的に呼び出されていなければ、自動的にWriteHeader(http.StatusOK)(ステータスコード200 OK)を内部で実行してしまう。これはつまり、実際のハンドラがレスポンスボディのごく一部でも書き込みを開始した時点で、ヘッダを操作する機会は事実上失われることを意味する。
このようなhttp.ResponseWriterの動作のため、前述のミドルウェアパターンでは、ハンドラがレスポンスヘッダやボディを書き込み始めた後では、ミドルウェアがレスポンスの内容を制御する手段がほとんどなくなる。「net/httpパッケージは、http.HandlerがHTTPリクエスト全体を処理し終えることを期待している」という設計思想が、ミドルウェアがリクエスト処理前とレスポンス処理後の両方で自由に介入する「真のミドルウェア」としての機能を果たしにくい状況を生み出しているのだ。
では、ミドルウェアの後にレスポンスを処理する関数(例えばpostProcess)を追加すれば解決するのではないか、と考えるかもしれない。しかし、これも問題を完全に解決するには至らない。なぜなら、postProcess関数は、handler.ServeHTTPの内部で実際に何が起こったのか(具体的にどのようなヘッダが設定され、どのステータスコードが返され、どのようなボディが書き込まれたのか)を知る術がないからだ。これらの情報がないままでは、意味のある後処理を行うことは不可能である。また、ハンドラ内部でエラーが発生し、早期にレスポンスがクライアントに返されてしまった場合、その後にpostProcessを実行しても無意味な処理となってしまう。
実際、http.Error関数を使って簡潔なエラーレスポンスを返したり、json.NewEncoder(w).Encode(body)のように直接http.ResponseWriterにJSONレスポンスを書き込んだりすることは、GoのWeb開発では非常に便利で一般的な手法だ。開発者はこれらの便利な関数をハンドラ内で活用したがるため、ハンドラが直接レスポンスを書き込むことを前提とした設計から抜け出すのは容易ではない。
この「ハーフミドルウェア」と呼ばれる問題に対する一つの解決策として、カスタムな「ハンドラ」と、それらが扱う独自の「リクエスト/レスポンス」オブジェクトを導入するというアプローチが考えられる。この考え方は、net/httpが提供する低レベルなhttp.ResponseWriterと*http.Requestを直接扱う部分をアプリケーションの最も外側の層(例えばコントローラ)に限定し、内部のビジネスロジックでは、より抽象化された独自のRequestとResponseオブジェクトを使うというものだ。
このモデルでは、ミドルウェアの役割を二つのカテゴリに分けることができる。
一つ目は「低レベルミドルウェア」で、これはhttp.TimeoutHandlerのように*http.Requestを直接操作したり、リクエストの基本的な情報をログに記録したりするような、net/httpパッケージに密接に依存するミドルウェアだ。
二つ目は「サービスレベルミドルウェア」で、これはアプリケーションのビジネスロジックに近い層で機能するミドルウェアである。このタイプのミドルウェアは、カスタムRequestオブジェクトを受け取って処理した後に新しいカスタムRequestを返したり、カスタムResponseオブジェクトを加工したりする関数として実装される。これらは、func Handler(Request) (Response, error)のような形式を持つサービスロジック関数を、簡単かつ柔軟に連結できる。これにより、リクエストのパース後、サービスの実行前、サービスの実行後といった様々なタイミングで、カスタムRequestやResponseを自在に加工できるようになる。
このアプローチの最大の利点は、カスタムResponseオブジェクトが、ヘッダ、ボディ、クッキーといった全てのレスポンス情報を完全に保持できる点にある。これにより、サービスレベルミドルウェアは、ハンドラが実際にレスポンスの書き込みを始める前でも後でも、レスポンスのあらゆる側面を自由に操作できるようになる。エラー発生時の早期リターンも、このカスタムResponseオブジェクトを適切に設計することで、より効果的に扱えるようになる。
しかし、この解決策には、独自のRequestとResponse構造体を新たに設計・定義するという「抽象化のコスト」が伴う。net/httpのリクエスト・レスポンス構造に慣れている開発者にとっては、この再設計は初期段階で複雑に感じるかもしれない。だが、一度適切な設計が確立されれば、アプリケーション全体のコードがより整理され、保守性やテストのしやすさが向上するという大きなメリットも期待できる。
結論として、Goのnet/httpパッケージにおいて、「真にミドルウェア」と呼べるような、リクエストとレスポンスの両方を柔軟に操作できる共通処理を実装することは、標準的なミドルウェアパターンだけでは難しい問題だ。これは、net/httpのResponseWriterの設計が、ハンドラがレスポンスの書き込みを主導することを前提としているためである。この問題を克服するためには、アプリケーションのWeb層をより抽象化し、独自のWebフレームワークに近い形でリクエストとレスポンスの処理を設計するアプローチが必要となる場合がある。ただし、このような抽象化には設計上のコストがかかるため、全てのケースでこの方法が最善とは限らない。アプリケーションの要件や複雑性によっては、各ハンドラ内で必要な後処理ロジックを個別に記述する方が、シンプルで実用的な解決策となることもある。最終的には、開発するアプリケーションの特性を考慮し、最も適切なアプローチを選択することが重要となる。