Featured image of post コードメカニック【Functional Options】コンパイルもCIも緑なのに本番だけ落ちた〜順番で締める組み付けを名前付きのねじへ〜

コードメカニック【Functional Options】コンパイルもCIも緑なのに本番だけ落ちた〜順番で締める組み付けを名前付きのねじへ〜

共通サーバの引数を1つ足したら、同じ型の値を取り違えてもコンパイルもCIも緑のまま本番だけ停止。telescoping constructorをFunctional Optionsに組み直し、位置でなく名前で設定を渡す整備記録。

緑のまま、本番だけが詰まった

NewServer にタイムアウト設定を1つ足すだけの、小さな変更のはずだった。

俺たちプラットフォームチームは、各サービスが共通で使うHTTPサーバの起動関数を保守している。設定項目が増えるたびに、NewServer の引数を末尾へ1つずつ足してきた。今回足すのは「1接続あたりのタイムアウト秒数」。引数が1つ増えるから、この関数を呼んでいる各サービスの main.go を、順番に直して回った。

俺は引数の順番に人一倍気をつける人間だ。READMEには、引数の並び順をまとめた表まで作ってある。

ビルドは通った。go vet——書き方の怪しいコードを機械的に指摘してくれる静的チェック——も、静かだった。CIも緑。レビューも二人に通した。何ひとつ、警告は出なかった。

なのに、リリースから30分後。課金サービスの本番だけが、徐々に応答を返さなくなった。コネクションが詰まっていく。ログに panic はない。スタックトレースもない。どこも「壊れて」はいないのだ。ただ、捌けていない。

一旦ロールバックして、火は止めた。だが、ロールバックした先は、今回足したかったタイムアウト機能がまだ入っていないバージョンだ。タイムアウトなしのままだと、応答が返らない遅い接続をいつまでも掴み続ける別の穴がある——今日、それを塞ぐはずだった。だから本当は、直して出し直さないといけない。なのに、なぜ全部緑だったのかが分からないまま、同じ仕組みにもう一度デプロイボタンを押す気になれない。出し直せない時間だけが過ぎていく。

以前、別案件で一度だけ立ち会った同僚が「変なコード整備士がいる」と言っていた。その時に控えた連絡先にかけた。小一時間して、物静かな女性が来た。薄いノートPCを一台だけ提げている。世間話はなかった。挨拶のあと、隣に座る。同僚は「整備士」と言っていたが、黙ってノートPCを開くその手つきが職人のもので、俺はいつのまにか親方と呼んでいた。

「共通の NewServer にタイムアウト設定を足したんです」と俺は事実を並べた。「リリース後に1サービスだけコネクションが詰まって、ロールバックで止めました。何をしたかは分かってます。引数を2つ、入れ替えた。それだけです。……分からないのは、なんでそれが本番まで行けたのか、です。コンパイラもテストも、何も言わなかった」

panic は出ましたか」

「いえ。それが気味悪くて。どこも落ちてない。ただ詰まっていく」

親方は少し間を置いて言った。「落ちないのが、いちばん厄介なやつです」

server.go を開いた。設定が増えるたびに引数を足してきた NewServer が、そこにある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Server struct {
	addr       string
	port       int
	maxConn    int // 同時接続の上限
	timeoutSec int // 1接続あたりのタイムアウト秒数(今回追加した設定)
	tls        bool
}

// 設定を足すたびに引数を末尾へ伸ばしてきた
func NewServer(addr string, port int, maxConn int, timeoutSec int, tls bool) *Server {
	return &Server{addr: addr, port: port, maxConn: maxConn, timeoutSec: timeoutSec, tls: tls}
}

「今回 timeoutSec を足して、引数が5つになりました。int が3つ並んでて……ここを直して回ったんです」

そして、事故った呼び出しを開いた。

1
2
3
4
5
6
// 課金サービスの main.go。timeoutSec を足すとき、maxConn と入れ違えた
//   意図: maxConn=1000, timeoutSec=30
srv := NewServer("billing.internal", 8080, 30, 1000, true)
//                                    ^^   ^^^^
//                                maxConn  timeoutSec の“位置”
//   実際にできたもの → maxConn=30, timeoutSec=1000

画面を指す手が、少し硬くなる。「30と1000が、逆です。maxConn が30になってた。本番の同時接続は、常時その何十倍も来る。30で頭打ちして、詰まった」

順番を1つ間違えただけなんです、と俺は言った。「でも——なんで誰も気づかなかったんですか。コンパイラも、go vet も、CIも、全部緑だったんですよ」


順番を直しても、順番は残る

親方は、取り違えた1行を、正しい順序に直した。

1
2
srv := NewServer("billing.internal", 8080, 1000, 30, true)
//                                    addr  maxConn  timeoutSec

ロールバックを解除して、再リリース。詰まりが解けていく。p99——遅いほうから数えた応答時間の指標——が、戻ってくる。

直った。だが、俺は得意げにはなれなかった。むしろ、不満が残った。「直りました。……でも、これ、また誰かがやりますよね。俺だって、またやる」

「順番は、人間が守るものです」と親方は言った。「守れなかったとき、コンパイラは何も言わない。型が同じだから」

少し考えて、俺は自分なりの手を出した。手順を整えるのは得意なほうだ。「じゃあ、用途ごとに専用の関数を用意すればいいんじゃ。TLSあり版とか、タイムアウト指定版とか。順番を間違えようがない入口を、分けておけば」

言いながら、気まずくなって白状した。「……実は、もう少しやってあるんです。これ」

1
2
3
4
func NewServer(addr string, port int, maxConn int, timeoutSec int, tls bool) *Server
func NewServerWithTLS(addr string, port int, maxConn int, timeoutSec int) *Server // tls=true 固定
func NewServerSimple(addr string, port int, tls bool) *Server                     // maxConn/timeoutSec を既定値に固定
// この次は「TLSあり かつ お手軽版」が欲しくなって……

親方は、その作りかけのリストを上から下まで眺めた。

「設定が n 個あると、組み合わせは増え続けます。TLSとタイムアウトで4通り、もう1個足せば8通り。入口を分けるほど、入口の数が爆発する。さっき詰まった原因と、同じ匂いがします——“足すたびに増える”」

俺は自分の書きかけたコードを見た。「……確かに。NewServerWithTLS の次は、NewServerWithTLSAndなんとか だ。きりがない」

順番を間違えないようにしたいだけなのに、なんで入口を増やすと、もっとこじれるんだ。応急で火が消えたばかりなのに、俺はまだ、出口が見えていなかった。


ビルドは、取り違えを知らない

「呼び出し側を、引数ぜんぶ声に出して読んでみてください」と親方が言った。

俺は読んだ。「"billing.internal"8080、……えっと、この 1000 は……maxConn? timeoutSec? どっちだ」

自分で読んでも、並んだ位置からは、どっちなのか確定しない。

親方は、わざと取り違えたままのコードに戻して、ターミナルで go build を走らせた。通る。続けて go vet。何も言わない。

1
2
$ go build ./...   # 通る
$ go vet ./...     # 何も言わない

「道具は、これを間違いだと思っていません」と親方は言った。「maxConntimeoutSecint。型が同じなら、入れ替えても“正しい形”に見える。人間が順番を覚えているあいだだけ、合っているんです」

俺は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つでも同じこと。hostname を逆に渡しても、どちらも string だからビルドは通る。順番でしか区別できないものは、いつか順番で間違える」

「Telescoping Constructor」における引数の順序取り違え脆弱性を示す図。メインの鋼鉄製プレート「NewServer」の内部で、同じ「int」型を持つ3つのパラメータ(port, maxConn, timeoutSec)が連続して並んでいます。これらが同じサイズのネジ穴のように見えるため、順序を誤って接続しても型チェックをすり抜けてしまい、本番で論理エラーを引き起こす問題が警告インジケータと共に描かれています。

じゃあ、どうすれば——順番を覚えなくて済むんですか。俺が訊きたいのは、もう、そこだけだった。


位置をやめて、名前で締める

「必須の締めと、後付けの調整を、分けます」と親方は言った。「アドレスみたいに、無いと始まらないものは、これまで通り位置で渡す。タイムアウトや最大接続みたいな“後から調整するねじ”は、名前を付けて渡す。位置じゃなく、名前で」

親方は、コンストラクタの形を変えながら、書く手を止めずに言った。「func NewServer(addr string, opts ...Option)。この ... は、0個でも何個でも受け取れる印です」

... を付けた引数は「可変長引数」と呼ぶ。0個以上をまとめて受け取れる引数で、関数の中では []Option——Option が並んだスライスとして、for で回せる。Option を1個も渡さなくてもいいし、3個渡してもいい。

次に、親方は Option という型を定義した。

1
type Option func(*Server)

Option は……*Server を受け取る関数の型、ですね。関数に、名前を付けてるだけか」と俺は読んだ。型の定義そのものは読める。

「そうです。Option は『*Server を受け取って、その場で設定を書き込む関数』。やることは1つ——渡された Server の、自分の担当のねじを締めるだけ」

そして親方は、設定ごとの With 関数を書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func WithPort(p int) Option {
	return func(s *Server) { s.port = p }
}

func WithMaxConn(n int) Option {
	return func(s *Server) { s.maxConn = n }
}

func WithTimeout(sec int) Option {
	return func(s *Server) { s.timeoutSec = sec }
}

func WithTLS() Option {
	return func(s *Server) { s.tls = true }
}

ここで俺は引っかかった。「クロージャは……使ったことあります。でも、WithMaxConn は、設定する関数を“返してる”。そういう使い方を、するのか」

関数の中に関数があって、それを返り値にする。書き方は知っている。だが「設定する道具」として返すという発想が、すぐには結びつかなかった。

WithMaxConn(1000) と呼ぶと、1000 という値を“中に覚えた関数”が返ってきます」と親方は言った。「返ってきた関数は、あとで Server を渡されたとき、その覚えた 1000maxConn に書き込む。値を持ち歩く小さな道具を、1個つくって渡している、と思ってください」

俺は自分の言葉に置き換えてみた。「WithMaxConn(1000) は、『この ServermaxConn を1000にしておけ』っていう、ラベル付きの指示書か。数字をそのまま渡すんじゃなく、“どのねじに入れるか”まで含んだ指示書を、渡してる」

親方は「それでいい」とうなずいた。

そして、コンストラクタの本体。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func NewServer(addr string, opts ...Option) *Server {
	s := &Server{
		addr:       addr,
		port:       8080, // 以下、未指定時のデフォルト
		maxConn:    1000,
		timeoutSec: 30,
	}
	for _, o := range opts {
		o(s) // 受け取った「指示書」を順に適用する
	}
	return s
}

「まずデフォルトで組み上げて、渡された指示書を順に適用していく」と親方は言った。「指示書が来ていないねじは、デフォルトのまま。opts から取り出した o は、With* が返した関数そのものです。o(s) と書けば、その指示書を s に実行する」

俺はコードを目で追った。指示書を1つずつ取り出して、o(s) で実行していく。覚えていた値が、担当のねじに書き込まれていく。さっきの「関数を返す」が、ここで——返して、受け取って、実行する、とつながって——閉じた。

呼び出し側は、こうなる。

1
2
3
4
5
6
srv := NewServer("billing.internal",
	WithMaxConn(1000),
	WithTimeout(30),
	WithTLS(),
)
// port は渡してない → デフォルトの 8080 のまま

俺は、自分から確かめにいった。「これ……WithMaxConn(1000)WithTimeout(30) を、逆の順番で書いたら、どうなるんですか」

「やってみてください」

俺は2行を入れ替えて書いた。結果は、同じだった。maxConn は1000、timeoutSec は30のまま。

声のトーンが、自分でも変わったのが分かった。「変わらない……。WithMaxConn は、maxConn にしか書き込まない。だから1000がどこに並んでても、行き先は maxConn で確定してる」

「そう」と親方は言った。「位置で渡すのをやめたから、入れ違える“位置”が、もう無い。30maxConn の枠に落とす、という事故が——枠が無いので、起こせない」

俺は、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で定番の組み立て方だ。

「Functional Options」パターンによる解決策を示す図。メインプレート「NewServer」には必須の「addr string」と汎用レシーバ「opts」のみが定義されています。その右側に独立したオプションプレート「WithMaxConn」「WithTimeout」「WithTLS」が配置され、それぞれがオレンジ色のワイヤーで「opts」に接続されています。引数の位置(順番)への依存を無くし、名前付きアタッチメントで安全に組み付けられる構造を表現しています。


順番表が、要らなくなった

テストを書こうとして、俺は「緑の罠」の正体に気づいた。

NewServer は、渡された値を忠実に格納するだけだ。それ自体は壊れていないし、騙されてもいない。取り違えは呼び出し側——main.go で起きていて、その呼び出し側には単体テストが無かった。サーバ側のどのテストも、「呼び出し側が引数をどの順で並べたか」までは見ない。だから go buildgo vet も、サーバパッケージのテストも、全部緑のまま通っていた。それが「緑なのに本番で落ちる」の構造だった。

Beforeのテストは、その忠実さを、あえて見せる形にした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Before: NewServer は忠実に格納する。取り違えは呼び出し側で起き、
//         呼び出し境界には意図を確かめるテストが無い(=緑の罠の正体)
func TestNewServer_StoresFaithfully(t *testing.T) {
	// 呼び出し側が maxConn(1000) と timeoutSec(30) を取り違えた(両方 int で通る)
	s := NewServer("billing.internal", 8080, 30, 1000, true)

	// NewServer は言われたとおり格納するだけ。30 を maxConn に入れて“正しく”動く
	if s.maxConn != 30 || s.timeoutSec != 1000 {
		t.Fatalf("NewServer は引数を忠実に格納する: %+v", s)
	}
}

NewServer 自体を責められない、というのが、いちばん怖いところだった。

Afterのテストは、逆向きのことを確かめる。順番を入れ替えても結果は変わらない——つまり、位置の取り違えが起こせないことを、テストで示す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// After: Option の順序を入れ替えても結果は同じ=位置の取り違えが起こせない
func TestOptionOrderDoesNotMatter(t *testing.T) {
	a := NewServer("billing.internal", WithMaxConn(1000), WithTimeout(30))
	b := NewServer("billing.internal", WithTimeout(30), WithMaxConn(1000)) // 逆順

	if a.maxConn != b.maxConn || a.timeoutSec != b.timeoutSec {
		t.Fatal("Option の順序で結果が変わってはいけない")
	}
	if a.maxConn != 1000 || a.timeoutSec != 30 {
		t.Fatalf("got maxConn=%d timeoutSec=%d, want 1000/30", a.maxConn, a.timeoutSec)
	}
}

// 何も渡さなければデフォルト
func TestDefaults(t *testing.T) {
	s := NewServer("billing.internal")
	if s.port != 8080 || s.maxConn != 1000 || s.timeoutSec != 30 {
		t.Fatalf("defaults broken: %+v", s)
	}
}
1
2
3
$ go test ./...
ok  	code-mechanic/telescoping-functional-options/after    0.590s
ok  	code-mechanic/telescoping-functional-options/before   0.370s

「設定は、これから何個増えても、With を1個足すだけです」と親方は言った。「呼び出し側は、要るねじだけ名前で締める。順番は、もう誰も覚えなくていい」

俺はREADMEを開いて、引数の並び順を書いた表を、消した。3年ぶんの「順番を守れ」という注意書きが、画面から消えた。守らせるための表が要らなくなったのは、守らなくてよくなったからだ。

「次に設定を足すときは、引数を伸ばさない」と親方は言った。「With を1個書いて、要るところで呼ぶ。それだけにしてください」

「引数は、もう伸ばしません」と俺は答えた。「名前で、締めます」

親方は席を立った。来たときと同じ、薄いノートPC一台だけを提げて、出ていった。

俺は、消したばかりのREADMEの空白を少し見てから、課金サービスの main.go を開いた。残りの呼び出しも、With の形に直し始める。位置で渡していた数字が、1つずつ、名前のついたねじに変わっていった。


整備記録簿

こんな異音・症状が出たら入れるべき整備(Functional Options)まだ様子見でいい
コンストラクタの引数が増え続け、同じ型(int/string)が隣り合って並んでいる
引数の順序を、READMEやコメントで「人間が守る」運用で防いでいる
NewServerWithANewServerWithAB… と組み合わせコンストラクタが増え始めた
設定を1つ足すたび、全呼び出し側の引数を直して回っている
設定が2〜3個で、ほぼ全部の呼び出しが毎回それを指定する✓(設定structを1個渡すほうが素直。Functional Optionsは過剰)

整備手順

  1. 必須引数(無いと始まらないもの=addr)と、任意の設定(後から調整する=maxConn・timeout・tls)を見分ける
  2. 任意設定を表す関数型 type Option func(*Server) を定義する
  3. 設定ごとに WithMaxConn(n) Option のような With* 関数を作る(値を覚えた関数=クロージャを返す)
  4. コンストラクタを NewServer(addr string, opts ...Option) にし、内部でデフォルト値を入れてから for _, o := range opts { o(s) } で適用する
  5. 呼び出し側は、必要なねじだけ WithTimeout(30) のように名前で渡す。順番は気にしなくていい
  6. 設定が増えたら、引数を伸ばさず With* を1個足す。コンストラクタの署名は変えない

親方より

引数を継ぎ足して伸ばすのを、やめろ。同じ型が並んだ時点で、順番は人間の記憶頼みになる。記憶は、いつか外れる。コンパイラは、外れたことを教えてくれない。型が同じだからだ。

必須の締めは位置で、後付けの調整は名前付きのねじで渡せ。位置を無くせば、入れ違える位置も無くなる。——ただし、何でもこれにするな。ねじが2つ3つで、毎回ぜんぶ締めるなら、設定をまとめて一枚で渡すほうが早い。これから増えていくやつにだけ、この組み方を使え。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。