logo hsb.horse
← ブログ一覧に戻る

ブログ

GolangでAPIサーバを実装するときの基本方針

単一バイナリ + cobra、Operation中心、HTTP/CLI分離、secure error handling、graceful shutdown まで、GoでAPIサーバを書くときの自分用の基本方針を整理した。

公開日:

GoでAPIサーバを書き始めると、最初に router や framework を選びたくなる。でも、本当に先に決めるべきなのはそこではない。先に固定したいのは、実行モデル、責務分割、エラーの境界、停止順序。

router は後で変えられる。停止順序が壊れている設計は、あとから直すのが面倒。ここを最初に固めておいた方が楽だと思っている。

この記事は Go の一般論というより、次に自分が API サーバを実装するときに迷わないための基本方針メモ。

先に方針を固定する

項目方針
最終成果物まずは cobra の単一バイナリに寄せる
HTTPサーバ起動myapp serve のように serve サブコマンドで起動する
中心の単位Operation[In, Out] を中心に置く
transportHTTP と CLI は adapter として分ける
frameworkchi / gin / Echo は transport/http の外へ漏らさない
cross-cuttingHTTP middleware と Operation interceptor は分離する
error内部エラーは boundary で安全に translate する
shutdownserver.Shutdown を呼ぶだけで終わらせず、停止順序を deterministic にする

要するに、HTTP framework 中心ではなく、Operation と lifecycle 中心で組み立てる。

HTTPサーバもCLIとして扱う

HTTPサーバも実態は CLI で起動する。だったら、最初から cobra に寄せて 1 バイナリにまとめた方が分かりやすい。

推奨する公開コマンド体系は次の形。

Terminal window
./myapp serve
./myapp health
./myapp users get 123
./myapp users create --name alice

server run のような二段構えは冗長だと思っている。serve で意味が完結しているし、タイプ量も減る。

単一バイナリを先に推す理由は単純。

  1. 配布物が 1 つで済む
  2. 設定読み込み、logger、DB 初期化、error handling を 1 系統にできる
  3. HTTP サーバと CLI で bootstrap を共有しやすい
  4. 後で 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 に閉じ込める

chiginEcho のどれを使うかは transport の話。ドメインや Operation の話ではない。

router の選定でやりたいのは、API サーバの責務を決めることではなく、HTTP にどう乗せるかを決めることだけ。だから framework 差分は internal/transport/http 配下に閉じ込める。

イメージはこんな形。

internal/
bootstrap/
operation/
interceptor/
transport/
http/
cli/
domain/
infra/
apperr/

operation から chigin.Context が見えたら、たぶん漏れている。ここが漏れ始めると、framework を変える話ではなく、アプリ全体を書き換える話になる。

HTTP middleware と Operation interceptor を混ぜない

ここも分けて考えた方がすっきりする。

置くもの
HTTP middlewarerequest id、access log、recover、authn、CORS など HTTP 固有のもの
Operation interceptorvalidation、authz、timeout、metrics、ユースケース単位の logging

HTTP middleware は transport の都合。Operation interceptor はアプリケーションの都合。同じ logging でも置き場所の意味が違う。

この 2 つを一緒にすると、HTTP に閉じた設計になりやすい。CLI を足した瞬間に、validation や authz を別実装で書き直すことになる。

error は boundary で安全に変換する

内部エラーをそのまま HTTP response や CLI の標準出力に出さない。ここは最初からルール化しておいた方が楽。

アプリ内では、最低でも次の 4 つを持った error を使う。

項目役割
Codebad_requestnot_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 に戻るのを待つ。一方で ListenAndServeShutdownClose の呼び出し後に 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 しきる前に時間切れになる。

自分の基本方針はこの順番。

順番やること
1signal.NotifyContext で終了シグナルを受ける
2readiness を落として新規流入を止める
3queue consumer / cron / background worker の入口を閉じる
4少し drain してロードバランサ反映を待つ
5handler へ context cancel を伝播する
6server.Shutdown(timeoutCtx) を呼ぶ
7WaitGroup / errgroup で in-flight job 完了を待つ
8DB / cache / broker producer を最後に閉じる
9timeout 超過時だけ 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 で終わらせず、readinessdrain、worker 停止、resource cleanup まで順序を設計する。

この形にしておくと、あとから framework を変えても、CLI を足しても、worker を増やしても、設計の軸がぶれにくい。

参考