緑のまま、本番だけが詰まった
NewServer にタイムアウト設定を1つ足すだけの、小さな変更のはずだった。
俺たちプラットフォームチームは、各サービスが共通で使うHTTPサーバの起動関数を保守している。設定項目が増えるたびに、NewServer の引数を末尾へ1つずつ足してきた。今回足すのは「1接続あたりのタイムアウト秒数」。引数が1つ増えるから、この関数を呼んでいる各サービスの main.go を、順番に直して回った。
俺は引数の順番に人一倍気をつける人間だ。READMEには、引数の並び順をまとめた表まで作ってある。
ビルドは通った。go vet——書き方の怪しいコードを機械的に指摘してくれる静的チェック——も、静かだった。CIも緑。レビューも二人に通した。何ひとつ、警告は出なかった。
なのに、リリースから30分後。課金サービスの本番だけが、徐々に応答を返さなくなった。コネクションが詰まっていく。ログに panic はない。スタックトレースもない。どこも「壊れて」はいないのだ。ただ、捌けていない。
一旦ロールバックして、火は止めた。だが、ロールバックした先は、今回足したかったタイムアウト機能がまだ入っていないバージョンだ。タイムアウトなしのままだと、応答が返らない遅い接続をいつまでも掴み続ける別の穴がある——今日、それを塞ぐはずだった。だから本当は、直して出し直さないといけない。なのに、なぜ全部緑だったのかが分からないまま、同じ仕組みにもう一度デプロイボタンを押す気になれない。出し直せない時間だけが過ぎていく。
以前、別案件で一度だけ立ち会った同僚が「変なコード整備士がいる」と言っていた。その時に控えた連絡先にかけた。小一時間して、物静かな女性が来た。薄いノートPCを一台だけ提げている。世間話はなかった。挨拶のあと、隣に座る。同僚は「整備士」と言っていたが、黙ってノートPCを開くその手つきが職人のもので、俺はいつのまにか親方と呼んでいた。
「共通の NewServer にタイムアウト設定を足したんです」と俺は事実を並べた。「リリース後に1サービスだけコネクションが詰まって、ロールバックで止めました。何をしたかは分かってます。引数を2つ、入れ替えた。それだけです。……分からないのは、なんでそれが本番まで行けたのか、です。コンパイラもテストも、何も言わなかった」
「panic は出ましたか」
「いえ。それが気味悪くて。どこも落ちてない。ただ詰まっていく」
親方は少し間を置いて言った。「落ちないのが、いちばん厄介なやつです」
server.go を開いた。設定が増えるたびに引数を足してきた NewServer が、そこにある。
| |
「今回 timeoutSec を足して、引数が5つになりました。int が3つ並んでて……ここを直して回ったんです」
そして、事故った呼び出しを開いた。
| |
画面を指す手が、少し硬くなる。「30と1000が、逆です。maxConn が30になってた。本番の同時接続は、常時その何十倍も来る。30で頭打ちして、詰まった」
順番を1つ間違えただけなんです、と俺は言った。「でも——なんで誰も気づかなかったんですか。コンパイラも、go vet も、CIも、全部緑だったんですよ」
順番を直しても、順番は残る
親方は、取り違えた1行を、正しい順序に直した。
| |
ロールバックを解除して、再リリース。詰まりが解けていく。p99——遅いほうから数えた応答時間の指標——が、戻ってくる。
直った。だが、俺は得意げにはなれなかった。むしろ、不満が残った。「直りました。……でも、これ、また誰かがやりますよね。俺だって、またやる」
「順番は、人間が守るものです」と親方は言った。「守れなかったとき、コンパイラは何も言わない。型が同じだから」
少し考えて、俺は自分なりの手を出した。手順を整えるのは得意なほうだ。「じゃあ、用途ごとに専用の関数を用意すればいいんじゃ。TLSあり版とか、タイムアウト指定版とか。順番を間違えようがない入口を、分けておけば」
言いながら、気まずくなって白状した。「……実は、もう少しやってあるんです。これ」
| |
親方は、その作りかけのリストを上から下まで眺めた。
「設定が n 個あると、組み合わせは増え続けます。TLSとタイムアウトで4通り、もう1個足せば8通り。入口を分けるほど、入口の数が爆発する。さっき詰まった原因と、同じ匂いがします——“足すたびに増える”」
俺は自分の書きかけたコードを見た。「……確かに。NewServerWithTLS の次は、NewServerWithTLSAndなんとか だ。きりがない」
順番を間違えないようにしたいだけなのに、なんで入口を増やすと、もっとこじれるんだ。応急で火が消えたばかりなのに、俺はまだ、出口が見えていなかった。
ビルドは、取り違えを知らない
「呼び出し側を、引数ぜんぶ声に出して読んでみてください」と親方が言った。
俺は読んだ。「"billing.internal"、8080、……えっと、この 1000 は……maxConn? timeoutSec? どっちだ」
自分で読んでも、並んだ位置からは、どっちなのか確定しない。
親方は、わざと取り違えたままのコードに戻して、ターミナルで go build を走らせた。通る。続けて go vet。何も言わない。
| |
「道具は、これを間違いだと思っていません」と親方は言った。「maxConn も timeoutSec も int。型が同じなら、入れ替えても“正しい形”に見える。人間が順番を覚えているあいだだけ、合っているんです」
俺はREADMEの順番表を思い出した。「……俺があの表を作ってたのは、コンパイラがやってくれないことを、手で肩代わりしてたってことか」
「これは telescoping constructor といいます」と親方は言った。「望遠鏡みたいに、引数を継ぎ足して伸ばし続けたコンストラクタのことです。telescoping は“望遠鏡のように段階的に伸びる”という意味。伸ばすほど、並び順という“位置”だけで意味を区別することになる」
親方は、なぜこれが破綻するのかを、3つに分けて示した。
1つ目。同じ型の引数が並ぶと、順序の取り違えが型チェックを素通りする。今回踏んだのがこれだ。int が3つ、string が2つと並べば、コンパイラには見分けられない。
2つ目。値の意味が、呼び出し側から読めない。NewServer("billing.internal", 8080, 1000, 30, true) の true が何のフラグなのか、30 が何の数字なのか、Server の定義を開かないと分からない。
3つ目。引数を1つ足すたびに、全部の呼び出し側へ波及する。今回まさに、timeoutSec を足したせいで全サービスの main.go を直して回り、その途中で1つ取り違えた。
「int だけの話じゃありません」と親方は付け加えた。「同じ型が2つ隣り合えば、string が2つでも同じこと。host と name を逆に渡しても、どちらも string だからビルドは通る。順番でしか区別できないものは、いつか順番で間違える」

じゃあ、どうすれば——順番を覚えなくて済むんですか。俺が訊きたいのは、もう、そこだけだった。
位置をやめて、名前で締める
「必須の締めと、後付けの調整を、分けます」と親方は言った。「アドレスみたいに、無いと始まらないものは、これまで通り位置で渡す。タイムアウトや最大接続みたいな“後から調整するねじ”は、名前を付けて渡す。位置じゃなく、名前で」
親方は、コンストラクタの形を変えながら、書く手を止めずに言った。「func NewServer(addr string, opts ...Option)。この ... は、0個でも何個でも受け取れる印です」
... を付けた引数は「可変長引数」と呼ぶ。0個以上をまとめて受け取れる引数で、関数の中では []Option——Option が並んだスライスとして、for で回せる。Option を1個も渡さなくてもいいし、3個渡してもいい。
次に、親方は Option という型を定義した。
| |
「Option は……*Server を受け取る関数の型、ですね。関数に、名前を付けてるだけか」と俺は読んだ。型の定義そのものは読める。
「そうです。Option は『*Server を受け取って、その場で設定を書き込む関数』。やることは1つ——渡された Server の、自分の担当のねじを締めるだけ」
そして親方は、設定ごとの With 関数を書いた。
| |
ここで俺は引っかかった。「クロージャは……使ったことあります。でも、WithMaxConn は、設定する関数を“返してる”。そういう使い方を、するのか」
関数の中に関数があって、それを返り値にする。書き方は知っている。だが「設定する道具」として返すという発想が、すぐには結びつかなかった。
「WithMaxConn(1000) と呼ぶと、1000 という値を“中に覚えた関数”が返ってきます」と親方は言った。「返ってきた関数は、あとで Server を渡されたとき、その覚えた 1000 を maxConn に書き込む。値を持ち歩く小さな道具を、1個つくって渡している、と思ってください」
俺は自分の言葉に置き換えてみた。「WithMaxConn(1000) は、『この Server の maxConn を1000にしておけ』っていう、ラベル付きの指示書か。数字をそのまま渡すんじゃなく、“どのねじに入れるか”まで含んだ指示書を、渡してる」
親方は「それでいい」とうなずいた。
そして、コンストラクタの本体。
| |
「まずデフォルトで組み上げて、渡された指示書を順に適用していく」と親方は言った。「指示書が来ていないねじは、デフォルトのまま。opts から取り出した o は、With* が返した関数そのものです。o(s) と書けば、その指示書を s に実行する」
俺はコードを目で追った。指示書を1つずつ取り出して、o(s) で実行していく。覚えていた値が、担当のねじに書き込まれていく。さっきの「関数を返す」が、ここで——返して、受け取って、実行する、とつながって——閉じた。
呼び出し側は、こうなる。
| |
俺は、自分から確かめにいった。「これ……WithMaxConn(1000) と WithTimeout(30) を、逆の順番で書いたら、どうなるんですか」
「やってみてください」
俺は2行を入れ替えて書いた。結果は、同じだった。maxConn は1000、timeoutSec は30のまま。
声のトーンが、自分でも変わったのが分かった。「変わらない……。WithMaxConn は、maxConn にしか書き込まない。だから1000がどこに並んでても、行き先は maxConn で確定してる」
「そう」と親方は言った。「位置で渡すのをやめたから、入れ違える“位置”が、もう無い。30 を maxConn の枠に落とす、という事故が——枠が無いので、起こせない」
俺は、READMEの順番表を思い浮かべた。「……あの順番の表が、要らなくなったのか。順番が、無くなったんだから」
Beforeは、int が3つ並び、型チェックが取り違えを止められない状態だった。Afterは、各設定が WithXxx という名前付きの関数を経由してしか書き込まれない。だから、30 という値が maxConn のフィールドへ流れ込む経路そのものが存在しない。コンパイルが通る・通らないという話ですらなく、取り違えという事象が、構造として表現できなくなっている。これが「なぜ直るか」の核心だった。
「じゃあ、引数が多い関数は、全部これにすべきなんですか」と俺は訊いた。
「いえ」と親方は即答した。「これは記述量が増えます。設定が2つ3つで、しかもほとんどの呼び出しが全部を指定するなら、設定をまとめた構造体——Config のような型を1つ定義して、それを丸ごと渡す設計のほうが素直です」
その場合、呼び出し側は Config{MaxConn: 1000, Timeout: 30} のように、MaxConn: や Timeout: とフィールド名を書いて値を入れる。名前で書くから、こちらも順番で間違えることはない。Goは同じ名前の関数を2つ持てないので、NewServer 自体を両対応にはできない——構造体で渡すと決めたら、最初からそちらに寄せる設計判断になる。
「Functional Optionsが効くのは、任意の設定が多くて、ほとんどの呼び出しはデフォルトでいいときです」と親方は続けた。「今回の NewServer みたいに、これからも設定が増えていくやつ」
俺はもう一つ訊いた。「これ、前に名前だけ聞いた Builder ってやつと、何が違うんですか」
「狙いは同じです。順番で渡すのをやめて、名前で組む。Builderは、組み立て途中の箱を持って、最後に .Build() で仕上げる形。Goなら、関数をそのまま渡せるので、箱を作らずにこの形で済む。Goでは、こっちが定石です」
親方がやったこれには、名前があった。Functional Options(ファンクショナル・オプション)——設定を「その場で適用する小さな関数」として、必要な分だけ名前を付けて渡す、Goで定番の組み立て方だ。

順番表が、要らなくなった
テストを書こうとして、俺は「緑の罠」の正体に気づいた。
NewServer は、渡された値を忠実に格納するだけだ。それ自体は壊れていないし、騙されてもいない。取り違えは呼び出し側——main.go で起きていて、その呼び出し側には単体テストが無かった。サーバ側のどのテストも、「呼び出し側が引数をどの順で並べたか」までは見ない。だから go build も go vet も、サーバパッケージのテストも、全部緑のまま通っていた。それが「緑なのに本番で落ちる」の構造だった。
Beforeのテストは、その忠実さを、あえて見せる形にした。
| |
NewServer 自体を責められない、というのが、いちばん怖いところだった。
Afterのテストは、逆向きのことを確かめる。順番を入れ替えても結果は変わらない——つまり、位置の取り違えが起こせないことを、テストで示す。
| |
| |
「設定は、これから何個増えても、With を1個足すだけです」と親方は言った。「呼び出し側は、要るねじだけ名前で締める。順番は、もう誰も覚えなくていい」
俺はREADMEを開いて、引数の並び順を書いた表を、消した。3年ぶんの「順番を守れ」という注意書きが、画面から消えた。守らせるための表が要らなくなったのは、守らなくてよくなったからだ。
「次に設定を足すときは、引数を伸ばさない」と親方は言った。「With を1個書いて、要るところで呼ぶ。それだけにしてください」
「引数は、もう伸ばしません」と俺は答えた。「名前で、締めます」
親方は席を立った。来たときと同じ、薄いノートPC一台だけを提げて、出ていった。
俺は、消したばかりのREADMEの空白を少し見てから、課金サービスの main.go を開いた。残りの呼び出しも、With の形に直し始める。位置で渡していた数字が、1つずつ、名前のついたねじに変わっていった。
整備記録簿
| こんな異音・症状が出たら | 入れるべき整備(Functional Options) | まだ様子見でいい |
|---|---|---|
コンストラクタの引数が増え続け、同じ型(int/string)が隣り合って並んでいる | ✓ | |
| 引数の順序を、READMEやコメントで「人間が守る」運用で防いでいる | ✓ | |
NewServerWithA/NewServerWithAB… と組み合わせコンストラクタが増え始めた | ✓ | |
| 設定を1つ足すたび、全呼び出し側の引数を直して回っている | ✓ | |
| 設定が2〜3個で、ほぼ全部の呼び出しが毎回それを指定する | ✓(設定structを1個渡すほうが素直。Functional Optionsは過剰) |
整備手順
- 必須引数(無いと始まらないもの=
addr)と、任意の設定(後から調整する=maxConn・timeout・tls)を見分ける - 任意設定を表す関数型
type Option func(*Server)を定義する - 設定ごとに
WithMaxConn(n) OptionのようなWith*関数を作る(値を覚えた関数=クロージャを返す) - コンストラクタを
NewServer(addr string, opts ...Option)にし、内部でデフォルト値を入れてからfor _, o := range opts { o(s) }で適用する - 呼び出し側は、必要なねじだけ
WithTimeout(30)のように名前で渡す。順番は気にしなくていい - 設定が増えたら、引数を伸ばさず
With*を1個足す。コンストラクタの署名は変えない
親方より
引数を継ぎ足して伸ばすのを、やめろ。同じ型が並んだ時点で、順番は人間の記憶頼みになる。記憶は、いつか外れる。コンパイラは、外れたことを教えてくれない。型が同じだからだ。
必須の締めは位置で、後付けの調整は名前付きのねじで渡せ。位置を無くせば、入れ違える位置も無くなる。——ただし、何でもこれにするな。ねじが2つ3つで、毎回ぜんぶ締めるなら、設定をまとめて一枚で渡すほうが早い。これから増えていくやつにだけ、この組み方を使え。
