GoでAPIサーバを書き始めると、最初に router や framework を選びたくなる。でも、本当に先に決めるべきなのはそこではない。先に固定したいのは、実行モデル、責務分割、エラーの境界、停止順序。
router は後で変えられる。停止順序が壊れている設計は、あとから直すのが面倒。ここを最初に固めておいた方が楽だと思っている。
この記事は Go の一般論というより、次に自分が API サーバを実装するときに迷わないための基本方針メモ。
先に方針を固定する
| 項目 | 方針 |
|---|---|
| 最終成果物 | まずは cobra の単一バイナリに寄せる |
| HTTPサーバ起動 | myapp serve のように serve サブコマンドで起動する |
| 中心の単位 | Operation[In, Out] を中心に置く |
| transport | HTTP と CLI は adapter として分ける |
| framework | chi / gin / Echo は transport/http の外へ漏らさない |
| cross-cutting | HTTP middleware と Operation interceptor は分離する |
| error | 内部エラーは boundary で安全に translate する |
| shutdown | server.Shutdown を呼ぶだけで終わらせず、停止順序を deterministic にする |
要するに、HTTP framework 中心ではなく、Operation と lifecycle 中心で組み立てる。
HTTPサーバもCLIとして扱う
HTTPサーバも実態は CLI で起動する。だったら、最初から cobra に寄せて 1 バイナリにまとめた方が分かりやすい。
推奨する公開コマンド体系は次の形。
./myapp serve./myapp health./myapp users get 123./myapp users create --name aliceserver run のような二段構えは冗長だと思っている。serve で意味が完結しているし、タイプ量も減る。
単一バイナリを先に推す理由は単純。
- 配布物が 1 つで済む
- 設定読み込み、logger、DB 初期化、error handling を 1 系統にできる
- HTTP サーバと CLI で bootstrap を共有しやすい
- 後で 2 バイナリ化したくなっても、
cmd/を薄く増やせば済む
逆に、最初から分離バイナリを選ぶのは、サーバ用コンテナを極端に小さくしたい、server と cli で依存や権限がかなり違う、リリース頻度を分けたい、といった理由が見えてからでいい。
実装の中心は Operation に置く
HTTP handler を中心に設計すると、router や framework の都合がすぐ奥まで入り込む。そうすると CLI を生やしたくなったときに同じ処理をもう一度書きがち。
中心は Operation にした方がいい。
type Operation[In any, Out any] interface { Execute(ctx context.Context, in In) (Out, error)}users get でも users create でも、HTTP と CLI は同じ Operation を呼ぶだけにする。HTTP は request/response を変換する adapter、CLI は flag/stdin/stdout を変換する adapter だと割り切る。
この形にしておくと、transport を増やしてもアプリケーションの中心は変わらない。
framework は transport/http に閉じ込める
chi、gin、Echo のどれを使うかは transport の話。ドメインや Operation の話ではない。
router の選定でやりたいのは、API サーバの責務を決めることではなく、HTTP にどう乗せるかを決めることだけ。だから framework 差分は internal/transport/http 配下に閉じ込める。
イメージはこんな形。
internal/ bootstrap/ operation/ interceptor/ transport/ http/ cli/ domain/ infra/ apperr/operation から chi や gin.Context が見えたら、たぶん漏れている。ここが漏れ始めると、framework を変える話ではなく、アプリ全体を書き換える話になる。
HTTP middleware と Operation interceptor を混ぜない
ここも分けて考えた方がすっきりする。
| 層 | 置くもの |
|---|---|
| HTTP middleware | request id、access log、recover、authn、CORS など HTTP 固有のもの |
| Operation interceptor | validation、authz、timeout、metrics、ユースケース単位の logging |
HTTP middleware は transport の都合。Operation interceptor はアプリケーションの都合。同じ logging でも置き場所の意味が違う。
この 2 つを一緒にすると、HTTP に閉じた設計になりやすい。CLI を足した瞬間に、validation や authz を別実装で書き直すことになる。
error は boundary で安全に変換する
内部エラーをそのまま HTTP response や CLI の標準出力に出さない。ここは最初からルール化しておいた方が楽。
アプリ内では、最低でも次の 4 つを持った error を使う。
| 項目 | 役割 |
|---|---|
Code | bad_request や not_found などの分類 |
Public | クライアントに返してよい文言 |
Internal | 内部向けの詳細エラー |
Meta | ログや補助情報 |
HTTP では status code と JSON へ translate する。CLI では stderr と exit code に translate する。重要なのは、translate は boundary でやること。Operation 側で HTTP status を考え始めないこと。
graceful shutdown は停止順序まで含めて設計する
ここは Go の API サーバ実装でかなり重要なポイント。srv.Shutdown(ctx) を呼んだだけで「graceful shutdown 対応済み」と扱わない。
net/http の公式ドキュメント では、Shutdown は listener を閉じて新規受付を止め、idle connection を閉じ、active connection が idle に戻るのを待つ。一方で ListenAndServe は Shutdown や Close の呼び出し後に ErrServerClosed を返す。つまり、その返却だけ見て main を終わらせると、停止途中でプロセスが終わり得る。
さらに Shutdown は hijacked connection を待たない。必要なら RegisterOnShutdown を使うし、handler 側へ shutdown を伝播したいなら BaseContext を使う。os/signal の NotifyContext で signal を context 化しておくと、終了処理の起点も揃えやすい。
Kubernetes 前提なら停止順序はさらに大事。Pod lifecycle では Pod に graceful termination 期間があり、container lifecycle hooks では PreStop 実行中もそのカウントダウンが進む。だから、止め方を雑にすると、drain しきる前に時間切れになる。
自分の基本方針はこの順番。
| 順番 | やること |
|---|---|
| 1 | signal.NotifyContext で終了シグナルを受ける |
| 2 | readiness を落として新規流入を止める |
| 3 | queue consumer / cron / background worker の入口を閉じる |
| 4 | 少し drain してロードバランサ反映を待つ |
| 5 | handler へ context cancel を伝播する |
| 6 | server.Shutdown(timeoutCtx) を呼ぶ |
| 7 | WaitGroup / errgroup で in-flight job 完了を待つ |
| 8 | DB / cache / broker producer を最後に閉じる |
| 9 | timeout 超過時だけ server.Close() を最終手段にする |
この話の核心は Go 固有というより、停止シーケンスを deterministic に設計せよ、という点だと思っている。
health は 2 種類ある
ここも混同しやすい。
| 名前 | 意味 |
|---|---|
myapp health | ローカルで依存関係を直接診断する CLI |
GET /api/health | 起動中サーバの HTTP ヘルスエンドポイント |
同じ health でも役割が違う。CLI 側は診断、HTTP 側は readiness / liveness に近い。特に shutdown 中は HTTP 側の health が 503 を返せる設計の方が安全。
最小のディレクトリ方針
最初の骨格はこのくらいで十分。
cmd/ myapp/internal/ apperr/ bootstrap/ domain/ infra/ interceptor/ operation/ health/ users/ transport/ cli/ http/ version/cmd/myapp は薄く保つ。初期化は bootstrap、ユースケースは operation、HTTP と CLI は transport に寄せる。後から 2 バイナリ化したくなっても、この切り方なら cmd/myapp-server を足すだけで済みやすい。
固定しておきたい一文
Go の API サーバ実装で先に決めるべきなのは framework ではなく、実行モデル、責務分割、エラー境界、停止順序。
この一文でだいたい足りる。chi を使うか Echo を使うかは、そのあとに決めればいい。
まとめ
自分の基本方針はこう。まずは cobra の単一バイナリにまとめ、serve で HTTP サーバを起動する。中心は Operation に置き、HTTP と CLI は adapter として分離する。framework は transport/http に閉じ込める。error は boundary で安全に変換する。shutdown は server.Shutdown で終わらせず、readiness、drain、worker 停止、resource cleanup まで順序を設計する。
この形にしておくと、あとから framework を変えても、CLI を足しても、worker を増やしても、設計の軸がぶれにくい。
hsb.horse