メインコンテンツまでスキップ

Web API開発 (net/http)

.NET (C#) の経験がある開発者向けに、net/http、ServeMux、ミドルウェア、chi/gin/echo の選択、ASP.NET Core との対応を解説します。Go では標準ライブラリの net/http だけで本番品質の HTTP サーバーを書けます。Go 1.22 でメソッドとパスパラメーターを扱える ServeMux が導入されたことで、ASP.NET Core Minimal API に近い感覚のルーティングも標準だけで実現できるようになりました。一方で、DI コンテナ、モデルバインディング、フィルターといった「フレームワークが用意してくれるもの」は存在しないため、自分で組み立てる部分が増えます。

1. このドキュメントで扱うこと​

  • http.Handler と HandlerFunc
  • Go 1.22+ の ServeMux ルーティング
  • ミドルウェアの書き方
  • 外部ルータ(chi等)を使う判断
  • JSON入出力とバリデーション

http.Handler と HandlerFunc​

HTTP ハンドラーの正体は、次の 1 メソッドだけを持つインターフェースです。

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
  • http.ResponseWriter はレスポンスの書き込み先で、ヘッダー、ステータスコード、ボディをここに書きます。
  • *http.Request はリクエストで、メソッド、URL、ヘッダー、ボディ、Context を持ちます。
  • 戻り値はありません。C# の Minimal API のように return Results.Ok(...) で応答を返すのではなく、w に直接書き込みます。
  • http.HandlerFunc は、func(http.ResponseWriter, *http.Request) という関数を Handler として扱うためのアダプター型です。mux.HandleFunc は内部でこれを使います。

Go 1.22+ の ServeMux ルーティング​

Go 1.22 以降、http.ServeMux はパターンにメソッドとワイルドカードを書けます。

パターン意味
GET /usersGET の /users のみ (GET は HEAD も受け付けます)
GET /users/{id}パスの 1 セグメントを id として取得します
GET /files/{path...}残りのパス全体を path として取得します
GET /{$}ルート / だけに完全一致します (末尾の {$} が完全一致の指定)
/static/末尾が / のパターンは、そのプレフィックス配下すべてに一致します
example.com/api/ホスト名付きのパターンも書けます
  • 値は r.PathValue("id") で文字列として取得します。型変換とエラー処理は自分で行います。
  • 複数のパターンが一致する場合は、より具体的なパターンが優先されます。優先順位を決められない競合するパターンを登録すると、登録時に panic します。
  • メソッドが一致しないパスには、自動的に 405 Method Not Allowed が返ります。

ミドルウェアの書き方​

Go のミドルウェアは、http.Handler を受け取って http.Handler を返す関数です。ASP.NET Core の app.Use(...) に相当するパイプラインを、ハンドラーのラッピングで表現します。

func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 前処理
next.ServeHTTP(w, r)
// 後処理
})
}
  • next.ServeHTTP を呼ばなければ、後続の処理は実行されません (認証失敗時の打ち切りなど)。
  • 外側のミドルウェアほど先に実行されます。適用順序は、ラップする順序で決まります。
  • ルート単位で適用したい場合は、特定のハンドラーだけをラップします。全体に適用したい場合は http.Server の Handler に、ラップした mux を渡します。

外部ルータ(chi等)を使う判断​

Go 1.22 以降は、標準の ServeMux で多くの API を書けます。そのうえで、次のような要件があるときに外部ルータやフレームワークを検討します。

選択肢特徴
標準 net/http (ServeMux)依存なし。http.Handler 標準の形のままで、他のライブラリとも組み合わせやすい
chihttp.Handler 互換のまま、ルートのグルーピング (Route / Group)、サブルーター、ミドルウェア集を提供する軽量ルータ
gin / echo独自の Context 型を持つフレームワーク。バインディングやバリデーションが組み込みで、記述量が減る代わりに http.Handler の標準形から離れる
  • ルートのグルーピングや、ルートごとのミドルウェア適用が多い場合は chi が候補になります。
  • 標準で足りるなら、依存を増やさない選択が基本です。http.Handler 互換のルーターを選んでおくと、後から入れ替えるコストも小さくなります。
  • gin や echo は独自の Context に依存するため、ミドルウェアやハンドラーの資産が net/http と互換になりません。採用するときはチームでその前提を共有します。

JSON入出力とバリデーション​

ASP.NET Core のモデルバインディングや [ApiController] による自動 400 応答に相当するものは、標準にはありません。ハンドラー内で、次の手順を明示的に書きます。

  1. json.NewDecoder(r.Body).Decode(&req) でリクエストをデコードします。
  2. 入力値を検証します (検証ライブラリを使うか、自前の Validate メソッドを書きます)。
  3. 適切なステータスコードで json.NewEncoder(w).Encode(v) を使ってレスポンスを書き出します。

w.WriteHeader を呼んだ後にヘッダーを設定しても反映されません。ヘッダー、ステータスコード、ボディの順に書く必要があります。

2. .NET との対応表​

.NET (C#)Go
ASP.NET Core Minimal APInet/http + ServeMux
Middleware パイプラインhttp.Handler のラッピング
Controller / Model BindingHandler 内での手動デコード / バリデーション
Kestrelhttp.Server

上の表を補う対応関係です。

.NET (C#)Go
app.MapGet("/users/{id}", ...)mux.HandleFunc("GET /users/{id}", ...)
ルートパラメーター (int id)r.PathValue("id") (文字列。変換は自分で行う)
app.Use(async (ctx, next) => ...)func(next http.Handler) http.Handler
Results.Ok(obj) / Results.NotFound()json.NewEncoder(w).Encode(obj) / http.NotFound(w, r) / http.Error(w, msg, code)
HttpContext.RequestAbortedr.Context()
builder.Services.AddScoped<T>()構造体のフィールドとコンストラクターで依存を渡す (DI コンテナなし)
ILogger<T>log/slog の *slog.Logger
UseExceptionHandlerrecover を使うミドルウェア
IHostApplicationLifetime / StopAsyncsignal.NotifyContext と Server.Shutdown
WebApplicationFactory / TestServernet/http/httptest
Kestrel のタイムアウト設定http.Server の ReadHeaderTimeout / ReadTimeout / WriteTimeout / IdleTimeout

3. C# 開発者がつまずきやすいポイント​

ハンドラーは戻り値を持たず、w に直接書き込む​

IResult を返すと、フレームワークがシリアライズとステータスコードの設定を行ってくれる C# とは異なり、Go ではハンドラーが自分でレスポンスを書き出します。エラー時にも return を忘れると、後続のコードが実行され、レスポンスが二重に書かれます。

if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return // 忘れると処理が続いてしまう
}

レスポンスの書き込み順序: ヘッダー、ステータス、ボディ​

w.Write または w.WriteHeader を一度呼ぶと、ヘッダーは送信済みになり、それ以降の w.Header().Set は効きません。WriteHeader を呼ばずに Write した場合は、暗黙的に 200 OK になります。また、WriteHeader を 2 回呼んでも 2 回目は無視されます。

パスパラメーターは文字列で、型変換とエラー処理は自分で書く​

/users/{id} の id は常に string で、ASP.NET Core のようにルート制約 ({id:int}) で型を絞ることはできません。strconv.Atoi などで変換し、失敗したときは 400 または 404 を返します。

標準のモデルバインディングやバリデーションはない​

[Required] や [FromBody] のような属性ベースのバインディングはありません。encoding/json のデコードは、型が合わない場合にだけエラーとし、値の妥当性は検証しません。構造体タグで検証を行う go-playground/validator などの外部ライブラリを使うか、Validate メソッドを自作します。

リクエストボディのデコードは防御的に書く​

クライアントから任意のサイズのボディが送られてくるおそれがあります。http.MaxBytesReader でサイズに上限を設け、必要に応じて DisallowUnknownFields で未知のフィールドを拒否します。

ミドルウェアでレスポンスのステータスを読み取れない​

http.ResponseWriter にはステータスコードを取得するメソッドがありません。ログ出力で記録したい場合は、ResponseWriter をラップする構造体を作り、WriteHeader を上書きして記録します。ただしラップすると http.Flusher などの任意インターフェースが隠れるため、Unwrap() http.ResponseWriter メソッドを実装して、http.ResponseController (Go 1.20 以降) が元のライターに到達できるようにします。

DI コンテナがない: 依存はハンドラーを持つ構造体に載せる​

builder.Services.AddScoped<...>() に相当するものはありません。データベースやロガーなどの依存は構造体のフィールドに持たせ、ハンドラーをその構造体のメソッドにします。ルート登録の際に、メソッド値 (s.getUser) を渡します。詳細は DI のドキュメントを参照してください。

http.ListenAndServe をそのまま本番で使わない​

http.ListenAndServe が作るサーバーにはタイムアウトが設定されていません。低速なクライアントによってコネクションが枯渇するおそれがあるため、本番では http.Server を明示的に作り、ReadHeaderTimeout などを設定します。Kestrel はデフォルトでタイムアウトを持ちますが、Go の標準サーバーにはありません。

ハンドラー内で起動したゴルーチンの r.Context() に注意​

r.Context() は、ハンドラーが戻るとキャンセルされます。ハンドラーの完了後も続ける非同期処理に r.Context() を渡すと、すぐにキャンセルされてしまいます。意図的に切り離す場合は、context.WithoutCancel(r.Context()) を使います。

panic はハンドラー単位で捕捉される​

net/http は、ハンドラー内の panic を接続ごとに捕捉してログに記録し、プロセスは落とさず、その接続を閉じます。ただし、ハンドラーが起動した別のゴルーチンの panic は捕捉されず、プロセス全体が落ちます。

4. コード例​

例 1: ServeMux でルーティングする​

C# (Minimal API):

var app = WebApplication.Create(args);

app.MapGet("/healthz", () => "ok");
app.MapGet("/users/{id}", (int id) => $"user {id}");

app.Run("http://localhost:8080");

Go:

package main

import (
"fmt"
"log"
"net/http"
)

func main() {
mux := http.NewServeMux()

// メソッド付きのパターン
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})

// パスパラメーター
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "user %s\n", id)
})

// 残りのパス全体を取得する
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "file: %s\n", r.PathValue("path"))
})

// ルート "/" だけに完全一致する
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "home")
})

log.Fatal(http.ListenAndServe(":8080", mux))
}

/users/42 を GET すると user 42 が返り、同じパスに POST すると 405 Method Not Allowed が返ります。

例 2: JSON の入出力とバリデーション​

C# (コントローラーによるモデルバインディング):

public record CreateUserRequest([Required] string Name, [EmailAddress] string Email);

[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
[HttpPost]
public ActionResult<User> Create(CreateUserRequest req)
{
// バインディングと検証は [ApiController] が自動で行い、失敗時は 400 を返す
var user = _store.Add(req.Name, req.Email);
return CreatedAtAction(nameof(Get), new { id = user.Id }, user);
}
}

Go では、デコード、検証、レスポンスの書き出しをハンドラー内で明示的に書きます。依存 (ここではストア) は構造体に持たせます。

package main

import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
)

type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}

type createUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}

// Validate は項目ごとのエラーメッセージを返す。問題がなければ空。
func (r createUserRequest) Validate() map[string]string {
problems := map[string]string{}
if strings.TrimSpace(r.Name) == "" {
problems["name"] = "required"
}
if !strings.Contains(r.Email, "@") {
problems["email"] = "must be an email address"
}
return problems
}

type server struct {
mu sync.Mutex
nextID int
users map[int]User
}

func newServer() *server {
return &server{nextID: 1, users: map[int]User{}}
}

func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write response", "err", err)
}
}

func (s *server) createUser(w http.ResponseWriter, r *http.Request) {
// ボディのサイズに上限を設ける (1 MiB)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // 未知のフィールドを拒否する

var req createUserRequest
if err := dec.Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if problems := req.Validate(); len(problems) > 0 {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": problems})
return
}

s.mu.Lock()
u := User{ID: s.nextID, Name: req.Name, Email: req.Email}
s.users[u.ID] = u
s.nextID++
s.mu.Unlock()

w.Header().Set("Location", fmt.Sprintf("/users/%d", u.ID))
writeJSON(w, http.StatusCreated, u)
}

func (s *server) getUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be an integer"})
return
}

s.mu.Lock()
u, ok := s.users[id]
s.mu.Unlock()
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
return
}
writeJSON(w, http.StatusOK, u)
}

func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", s.createUser)
mux.HandleFunc("GET /users/{id}", s.getUser)
return mux
}

func main() {
s := newServer()
if err := http.ListenAndServe(":8080", s.routes()); err != nil {
slog.Error("server stopped", "err", err)
}
}
  • ハンドラーを *server のメソッドにして、s.createUser のようにメソッド値として登録しています。これが DI の代わりになります。
  • ステータスコードは http.StatusCreated のような定数で指定します。
  • 本番では、go-playground/validator などで検証を宣言的に書くこともできます。

例 3: ミドルウェア (ログと panic 回復)​

C#:

app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
await next();
logger.LogInformation("{Method} {Path} -> {Status} in {Elapsed}ms",
context.Request.Method, context.Request.Path,
context.Response.StatusCode, sw.ElapsedMilliseconds);
});

Go:

package main

import (
"log/slog"
"net/http"
"os"
"time"
)

type Middleware func(http.Handler) http.Handler

// Chain は先頭のミドルウェアが最も外側になるように連結する。
func Chain(h http.Handler, ms ...Middleware) http.Handler {
for i := len(ms) - 1; i >= 0; i-- {
h = ms[i](h)
}
return h
}

// statusRecorder は書き込まれたステータスコードを記録する。
type statusRecorder struct {
http.ResponseWriter
status int
}

func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}

// Unwrap により http.ResponseController が元のライターに到達できる。
func (s *statusRecorder) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}

func logging(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()

next.ServeHTTP(rec, r)

logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
)
})
}
}

func recoverer(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
logger.Error("panic", "value", v, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

mux := http.NewServeMux()
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
panic("something went wrong")
})

// recoverer が外側、logging が内側
handler := Chain(mux, recoverer(logger), logging(logger))

http.ListenAndServe(":8080", handler)
}
  • Chain の引数の順序が、そのまま外側から内側への順序になります。
  • slog は Go 1.21 で標準に入った構造化ログのパッケージです。NewJSONHandler で JSON 形式、NewTextHandler でテキスト形式の出力になります。
  • chi を使う場合は、同じ形のミドルウェアを r.Use(...) で登録できます。http.Handler 互換なので、ここで書いたミドルウェアはそのまま流用できます。
// chi の例 (github.com/go-chi/chi/v5)
r := chi.NewRouter()
r.Use(recoverer(logger), logging(logger))
r.Route("/users", func(r chi.Router) {
r.Get("/{id}", getUser)
})
// パスパラメーターは chi.URLParam(req, "id") で取得する

例 4: タイムアウト設定とグレースフルシャットダウン​

C#では app.Run() が Ctrl+C を受けて自動的にシャットダウンしますが、Go では自分で組み立てます。

package main

import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os/signal"
"syscall"
"time"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(3 * time.Second):
fmt.Fprintln(w, "done")
case <-r.Context().Done(): // クライアント切断や Shutdown で通知される
return
}
})

srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()

select {
case err := <-errCh:
// 起動に失敗した場合 (ポート使用中など)
slog.Error("server failed", "err", err)
return
case <-ctx.Done():
slog.Info("shutting down")
}

// 処理中のリクエストが終わるのを最大 10 秒待つ
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown", "err", err)
}
if err := <-errCh; !errors.Is(err, http.ErrServerClosed) {
slog.Error("server", "err", err)
}
}
  • ListenAndServe は、Shutdown が呼ばれると http.ErrServerClosed を返して終了します。これはエラーではなく正常な終了のしるしです。
  • Shutdown は新しい接続の受け付けを止め、処理中のリクエストの完了を待ちます。渡した ctx の期限を過ぎると、待つのを諦めてエラーを返します。
  • signal と context の詳細は、context パッケージのドキュメントを参照してください。

例 5: httptest でハンドラーをテストする​

C# の WebApplicationFactory に相当する、ネットワークを使わないテストです。

package api

import (
"net/http"
"net/http/httptest"
"testing"
)

func newMux() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("user " + r.PathValue("id")))
})
return mux
}

func TestGetUser(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
rec := httptest.NewRecorder()

newMux().ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got, want := rec.Body.String(), "user 42"; got != want {
t.Errorf("body = %q, want %q", got, want)
}
}

func TestMethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/users/42", nil)
rec := httptest.NewRecorder()

newMux().ServeHTTP(rec, req)

if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
}

httptest.NewRecorder はレスポンスをメモリに記録するため、ポートを開かずにルーターごとテストできます。実際の HTTP 経由で試したい場合は、httptest.NewServer(handler) でテスト用サーバーを起動できます。

5. まとめ​

  • Go の HTTP ハンドラーは ServeHTTP(w, r) を持つ http.Handler で、戻り値を持たず、w に直接書き込みます。
  • Go 1.22 以降の ServeMux は、GET /users/{id} のようなメソッド付きパターンと r.PathValue を標準で提供します。多くの API は標準だけで書けます。
  • ミドルウェアは func(http.Handler) http.Handler の形で、ハンドラーのラッピングによってパイプラインを作ります。
  • 依存はハンドラーを持つ構造体のフィールドとして渡します。DI コンテナやモデルバインディングは標準にはありません。
  • JSON のデコード、検証、エラー応答、ステータスコードは明示的に書きます。MaxBytesReader や DisallowUnknownFields で防御的にデコードします。
  • 本番では http.Server のタイムアウトを設定し、signal.NotifyContext と Shutdown でグレースフルシャットダウンを実装します。
  • ルートのグルーピングなどが必要になったら、http.Handler 互換の chi を検討します。gin や echo は独自の Context に依存する点を理解して選びます。
  • httptest を使うと、ネットワークを使わずにハンドラーやルーターをテストできます。

参考資料​