警告灯は、ひとつも点いていなかった
その日は、何も起きていないはずだった。
平日の昼下がり。アラートは鳴っていない。Slackのインシデントチャンネルも静かで、自分はいつもの開発作業をしていた。半年前に辞めた先輩から引き継いだ、注文管理サービス。引き継ぎは口頭と、Wikiの断片だけだった。コードは読める。けれど、注文がどう状態を移っていくのか、その全体像は、正直まだ頭に入りきっていない。
最初の異変は、CSからの一件のエスカレーションだった。「キャンセルして、返金も受け取ったお客様から『商品が届いた』と連絡が来ています」。
最初は配送のミスかと思った。倉庫が取り違えたのだろう、と。だが注文を開いて、手が止まった。status は "shipped"。キャンセルの記録も、返金の記録も、ちゃんと残っている。両方が、同じ注文に、ある。返金して、なお商品を送っている。会社は、二重に損をしていた。
DBを調べると、同じ「キャンセル済みなのに発送済み」が、先月から数件あった。調べているそばで、CSの問い合わせがまた一件増えた。
なのに、エラーログは一件も無い。発送バッチは毎晩、正常終了している。panic も、スタックトレースも、どこにもない。「壊れた」形跡が、どこにも無いのだ。
エラーが出ていれば、まだ追える。スタックトレースを辿って、落ちた行まで行ける。でも、何も出ていない。コードは、ちゃんと動いていた。ちゃんと動いて、キャンセル済みの注文を、発送した。
監視は全部緑だ。ロールバックするものすら無い。本番は、最後まで健康に見えていた。気づいたきっかけは、CSに届いた、たった一人のお客様の声だけ。鳴っていないことは、何も起きていないこと、ではなかった。静かに、確実に、損が積み上がっていた。そして自分は、引き継いだこの仕組みのどこが、いつ、こうなったのかを、説明できなかった。
注文管理を引き継いだとき、前任が残したWikiの隅に、「詰まったら、ここに連絡」とだけ書かれた連絡先があった。半信半疑で、かけてみた。小一時間して、物静かな女性が来た。薄いノートPCを一台だけ提げている。世間話はなかった。挨拶をして、隣に座る。
「キャンセルされた注文が、発送されてるんです」と自分は切り出した。「エラーは……一件も出ていません。発送バッチも、毎晩ちゃんと正常終了していて。何が『壊れた』のかも、分からなくて」
その人は、症状ではなく、別のことを聞いた。「注文の status を、どこで変えていますか」
「えっと……Pay と Ship と Cancel、それぞれの関数で。あと、発送バッチが、夜中に Ship を呼んでます」
「その3つ、全部見せてください」
迷いのない声だった。自分は、いつのまにかこの人を、親方と呼んでいた。開いた画面には、引き継いだ3つの関数が並んでいた。
| |
「これ、半年前に辞めた先輩が書いた部分で……」自分は、少し言い訳がましくなった。「cancelled は、自分が入った後に、別のチームが足したんです。だから、Ship がそれにちゃんと対応してるか、正直、把握してなくて」
親方は Ship の中を、上から指でなぞった。「shipped と new は見ている。でも、cancelled が無い」
そうだ。Ship のガードには、shipped と new を弾く if はあるのに、cancelled を弾く行が、無い。「でも」と自分は食い下がった。「なんで、エラーも出ずに、発送できちゃうんですか。status がキャンセル済みなら、せめて何か……」
「Status が、ただの文字列だからです」と親方は言った。「"cancelled" でも、o.Status = "shipped" は書ける。文字列に、それを止める理由が、ひとつも無い」
一行足せば、火は消える
親方は何も言わず、自分のキーボードを借りた。そして、たった数行のコードを書いた。注文を new から paid、そして cancel まで動かして、最後に Ship() を呼ぶ。err と Status を、そのまま表示する。
| |
走らせた。出力は、これだった。
| |
「見てください」と親方は画面を指した。「エラーは nil。なのに、キャンセル済みが、発送済みになった」
ぞっとした。「ほんとだ……。何も、止めてくれない。これが、先週の注文で起きてたことだ」。抽象的な「事故」だったものが、いま目の前で、<nil> shipped という一行になって出ていた。
それから親方は、Ship に一行だけ足した。
| |
さっきの数行を、もう一度走らせる。
| |
「直りましたね」自分は、少し息をついた。「エラーで、止まるようになった。じゃあ、これで……」
「火は消えました」と親方は言った。「でも、また起きます」
そして、Pay・Cancel・Ship の三つを、順番に指でさした。「この3つ、それぞれが『どの状態から動いてよいか』を、自分のやり方で書いている。Pay は != "new" で見る。Cancel は switch で new と paid を見る。Ship は if を並べる。同じ『遷移していいか』の判断が、3箇所に、バラバラの書き方で、散っている」
「今日の事故は、その散らばった1箇所——Ship——が、後から足した cancelled を知らなかったから起きた。あなたは Ship を直した。でも、次に状態を一つ足すとき、3箇所ぜんぶを、漏れなく見直せますか」
言葉に詰まった。痛いところだった。それでも、まだ腑に落ちていなかった。「でも、状態が変わるんだから、状態を変える関数にガードを書くのは、自然じゃないんですか」
「ガードを書くのは自然です」と親方は言った。「おかしいのは、『どの状態から、どこへ行ってよいか』というルールが、どこにも一箇所に、まとまっていないことです。3つの関数に、それぞれの解釈で散っている。だから、足し忘れる」
その遷移図は、コードのどこにある
親方は、手元のメモ用紙に、注文の状態の移り変わりを描いた。
| |
「あなたの頭の中には、たぶん、この図がある」と親方は言った。「注文は new から始まって、払えば paid、発送すれば shipped。途中まではキャンセルもできる。でも shipped と cancelled は終わりで、そこからはもう動けない」
「はい」と自分はうなずいた。「だいたい、そういう認識です」
「この図は、コードのどこに、書いてありますか」
コードを見返した。Pay を見る。new のことしか知らない。Ship を見る。paid から発送することしか書いていない。Cancel は、new と paid のことだけ。どの関数も、図の一部分しか、持っていなかった。
「……どこにも、無いです」と自分は言った。「Pay は new のことしか知らない。Ship は paid のことしか。Cancel は new と paid だけ。ぜんぶバラバラで——この図ぜんぶを持ってる場所が、無い」
「これは、型コード(Type Code)です」と親方は言った。型コード。状態や種別を、専用の型ではなく、文字列や数値の定数で持って、各所で見分けるやり方のことだ。
「Status が string だから、コンパイラは『この文字列は、この関数に来ていいのか』を、何も確かめない。"cancelled" を "shipped" に書き換える代入も、ただの文字の置き換えとして、素通りさせる」
引き継いだとき、status が文字列なのは、当たり前だと思っていた。new とか paid とか、見れば意味が分かる。分かりやすいと思っていた。でも、分かりやすいのは、人間が読むときだけだった。コンパイラにとっては、ただの文字の並びでしかない。試しに "banana" を入れても、たぶん、何も言わずに通してしまう。
string の status は、コンパイラにとって意味を持たない。"shipped" も "cancelled" も、同じ string だ。だから「この状態で、この操作をしていいか」を,型のレベルで問えない。問えるのは、実行時に、人間が手で書いた if が、たまたま拾ったときだけ。拾い忘れれば、素通りする。先週のあれは、まさに、それだった。
「見てごらん」と親方は、錆びたボルトが転がるスチールの作業台に、一枚の配線図を広げた。「注文(Order)という大きな筐体の中に、ただの文字情報として Status が転がっている。そして、それを取り巻くスイッチ(メソッド)たちが、各自の都合でその文字を書き換えているんだ」

描かれた図は、単純に見えて、あちこちにバイパスが通っていた。Pay や Ship というスイッチが、それぞれ個別に Status というバルブを覗き込んで、開閉を判断している。どこか一つのスイッチで判断を誤れば、あるいはバルブに予想外の泥が詰まれば、全体の流れは容易に破綻する。
「各パーツが自分勝手にバルブの状態を判断しているから、全体を制御するバルブの動作規程がどこにもない」と親方は言った。「だから、新しく cancelled という泥が混入したとき、Ship というスイッチだけがそれを素通りさせてしまったんだ」
状態を、文字列から型へ
「状態を、文字列じゃなくて、型にします」と親方は言った。「状態ごとに型を作って、『その状態が次にどこへ行けるか』を、状態自身に、持たせる」
まず、状態が答えるべきことを、interface に宣言した。
| |
「状態が……関数を持つんですか」と自分は聞き返した。「new とか paid が」
「そう。new という状態に、『お前は Pay されたら、次どうなる』と聞ける形にする。new は『paid になる』と答える。cancelled は『発送はできない』と答える。答えるのは、状態自身です」
interface(インターフェース)は、「この一覧のメソッドさえ持っていれば、どんな型でも同じものとして扱える」という約束だ。ここでは Pay・Ship・Cancel・Name の4つに答えられることを、State という名前の約束にした。そして、それぞれのメソッドが返すのは、値が二つ。次の状態と、エラーだ。Goの関数は、こうして値を二つ並べて返せる。許可された遷移なら「次の状態」と nil、許可されない遷移なら nil と「エラー」を返す——その約束を、(State, error) という戻り値の形が表していた。
次に、状態ごとに型を作った。Goでは、interface を満たすのに implements とは書かない。宣言された4つのメソッドを、その形で持っている型なら、自動的に State として扱われる。
| |
struct{} は、中身(フィールド)のない型だ。状態には new・paid のような名前(型)だけが要って、持ち回るデータが無いから、中身は空でいい。メソッドの受け手が func (newState) Pay(...) と名前すら無いのも、同じ理由だ。状態は中身を書き換えないので、受け手を変数で受け取る必要がなく、名前を省ける(使わないものは書かない)。
自分は、cancelledState の Ship の行を見た。「cancelled が『発送はできない』って、自分で言ってる。さっき Ship のガードに足した、あの cancelled チェックが……cancelled っていう状態の、中に入った」
「そう」と親方は言った。
そして、注文そのもの——Order を組み直した。
| |
3つのメソッドは、同じ形をしている。今の状態に、その操作を聞く。エラーが返れば、状態を変えずにそのまま返す。次の状態が返れば、それに差し替える。共通処理を一つにまとめることもできるけれど、ここでは、何が起きているかが読めるように、あえて素直に並べた。状態側と違って、受け手が (o *Order) とポインタ(* の付いた形)になっているのは、Order が自分の state を書き換えるからだ。中身を書き換えるものは、ポインタで受け取る。
「Order は、もう自分で『どの状態から動いてよいか』を判断しない」と親方は言った。「今の状態に聞いて、返ってきた次の状態に、入れ替わるだけ」
判断が、Order から、状態の型に移ったんだ。自分は、頭の中で言い換えた。Order は、聞いて、差し替えるだけ。
「今日の事故——キャンセル済みの発送——の答えは、いま cancelledState.Ship() の、ただ1箇所にあります」と親方は続けた。「Pay にも、Ship の本体にも、Cancel にも、散っていない。『キャンセル済みは発送できるか』を知りたければ、cancelled の型を見れば済む」
「散らばってたのが、1箇所に集まったんですね」と自分は言った。それから、ふと引っかかった。「……でも、集めただけなら、また別の状態を足すとき、同じように、書き忘れませんか」
「やってみましょう」と親方は言った。「次に『返品中(returning)』という状態を足してください」
自分は returningState を作り始めた。Name() を書き、Pay と Cancel も埋めた。残るは Ship だ。返品中の注文を、発送していいんだっけ——そこで、手が止まった。とりあえず後で、と飛ばした、そのとき。親方が、一行を見せた。
| |
ビルドすると、止まった。
| |
「var _ State = returningState{} は、空き地に『ここは State だ』と立て札を打つ行です」と親方は言った。_(アンダースコア)は「この値は使わない、名前は要らない」というしるしだ。ここでは、型が State の約束を満たすかを確かめたいだけで、変数として持っておく必要はない。「returningState が Pay・Ship・Cancel・Name を全部持っていなければ、ここでビルドが止まる。新しい状態は、自分が次に何をできるかを全部答えるまで、コンパイルが通らない」
コンパイラが指したのは、まさに、さっき飛ばした Ship だった。返品中の商品を、また発送する? ……いや、しない。迷って手を止めた問いに、答えるしかなくなった。
| |
書きながら、気づいた。さっきは、Ship のガードに cancelled を足し忘れても、誰も何も言わなかった。コンパイラも、テストも、本番も、緑のままだった。今度は、返品中を足したら、答えていない遷移があるかぎり、コンパイラが——いまの Ship のように——名指しで止めてくる。全部の遷移を埋めるまで、ビルドは、通らない。
ただし、と自分は思い直した。コンパイラが聞いてくるのは、「返品中は、次に何ができるか」——出ていく先だけだ。逆に、「どの状態から返品中へ来られるか」——たとえば shipped から返品中へ繋ぐ——は、shipped の側を自分で書き直さないと繋がらない。そこは、コンパイラは促してくれない。
それに、もし「返品中は発送できる」と間違って書いても、コンパイルは通ってしまう。コンパイラが見るのは「答えたかどうか」であって、「答えが正しいかどうか」ではない。
それでも、と自分は思った。今日の事故——答えること自体を、まるごと忘れる——は、もう起きない。聞かれれば、少なくとも、考える。
ふと、似たものを思い出した。「これ、Strategy ってやつに似てません? 種類ごとに型を分けて、interface でやる……名前だけは、聞いたことがあって」
「名前は近いです」と親方は言った。「でも Strategy は『やり方を選ぶ』話。種類は、注文ごとに最初に決まって、途中でクレジットがコンビニ払いに化けたりしない。これは『状態が移り変わる』話です。注文は new から paid、shipped へと、時間とともに動く。そして、今の状態が、次にどこへ行けるかまで持っている。そこが、違う」
いま親方がやったこれには、名前があった。State(ステート)——状態ごとに型を作り、その状態でできること(振る舞い)と、次に進める状態を、状態自身に持たせるデザインパターン(振る舞いを扱うパターンの一つ)だ。
ひとつ、現実的な心配が残っていた。「でも、発送バッチは、DBから注文を読みますよね。DBに入ってるのは、"paid" みたいな文字列です。型じゃなくなったら、バッチは、どうやってその型の Order を手に入れるんですか」
「文字列は、端っこだけに残します」と親方は言った。「DBから読んだときに一度だけ、文字列を状態の型に戻す。保存するときは Name() で文字列に書き戻す。中で持ち回るのは、ずっと型のままです」
| |
ただ、と自分は気づいた。この parseState の switch だけは、新しい状態を足したとき、コンパイラが case の追加を強制してくれない。returningState を足しても、ここに case "returning" を書き忘れれば、読み込み時に default の error に落ちる。気づくのは、実行時だ。型の世界に入ってからは、各状態が全部の操作に答えているか——メソッドの抜け——を、コンパイラが見てくれる。でも、文字列から型へ入る、この1箇所だけは、人が手で保つしかない。だから、ここは default で必ず error にして、テストで守る。端を一箇所に絞ったぶん、守る場所も、ここ一箇所に絞れた。
「今度は、こうだ」 親方は、作業台の上の配線図を、新しい設計図へと差し替えた。

「さっきは一つの筐体の中でスイッチが散らばっていたが、今度は違う。状態(State)そのものを独立した油圧バルブ(型)として独立させたんだ。注文(Order)は、単にその時接続されているバルブに油圧を伝えるだけの『ソケット』にすぎない」
図を見ると、Order は中央の State というソケットを介して、newState や paidState といった専用のバルブプレートと結ばれていた。それぞれのバルブプレートには、次の遷移先への配線(メソッドの戻り値)が厳密に刻まれている。
「これなら」と自分は図をなぞった。「新しいバルブを繋ぐとき、すべての配線ポート(メソッド)を正しく接続しなければ、ソケットにはまらない(コンパイルエラーになる)んですね」
「その通りだ」親方は小さく頷いた。「余計なバイパスは存在しない。バルブ自体が、自らの進路を知っている」
走り出す前に、コンパイラが訊く
直したコードに、テストを書いた。先週の事故を、二度と起こさないための回帰テストだ。new から paid、そして cancel。その後で Ship を呼ぶ。
| |
ここで使った errors.Is は、返ってきたエラーが ErrInvalidTransition と同じ目印かを確かめる関数だ。Before のときは、ここが err == nil で、Status が "shipped" に化けていた。After では、ErrInvalidTransition が返り、しかも弾かれたときは、状態は "cancelled" のまま、変わらない。
状態ごとの遷移も、型ごとに、単体で確かめられる。paidState{}.Ship() が shippedState を返すか。cancelledState{}.Ship() が ErrInvalidTransition を返すか。巨大な注文オブジェクトを組み立てなくても、状態の型を一つ作って、聞くだけでいい。
走らせた。
| |
ひとつ、確かめておきたいことがあった。あの応急処置——Ship に if o.Status == "cancelled" を一行足したやつ——も、キャンセル済みの発送は、ちゃんと弾く。型にした After も、弾く。だから、「キャンセル済みは発送しない」という結果だけ見れば、二つは同じだ。
違うのは、そのルールが、どこに、どう書かれているか、だった。応急のほうは、Ship という一つの関数の中に、また一行、判断が増えただけ。Pay や Cancel に散った仲間は、そのまま。次に状態が増えれば、また三箇所を、手で見て回ることになる。型にしたほうは、ルールが状態ごとに宿って、新しい状態は、コンパイラが「次に何ができる」と全部訊いてくる。直したのは、同じ一つのバグ。変えたのは、次のバグが、黙って通れるかどうか、だった。
「状態は、これから何個でも足せます」と親方は言った。「足すたびに、コンパイラが、その状態にできることを全部訊いてくる。あなたが今日、訊き忘れたことを」
親方は、薄いノートPCを閉じて、来たときと同じ身軽さで立ち上がった。
引き継いだとき、いちばん怖かったのは、自分の知らない状態遷移が、どこかに隠れていることだった。今は、状態が型になっている。新しい状態を足せば、コンパイラが、抜けを教えてくれる。引き継いだコードが、初めて、自分に話しかけてくれた気がした。
「次に状態を足すとき、約束してください」と親方は言った。「文字列を増やして、あちこちの if を探し回るんじゃなく——型を一つ書いて、var _ State = の立て札を打つこと。あとは、コンパイラに、訊かせればいい」
「if は、探しません」と自分は言った。「型を足して、コンパイラに、訊きます」
親方が描いていったメモ用紙の、あの状態遷移図を、もう一度見た。頭の中にしかなかったあの図が、いまは型になって、コードの中に、ある。
整備記録簿
| こんな異音・症状が出たら | 入れるべき整備(State) | まだ様子見でいい |
|---|---|---|
状態を文字列や定数で持ち、複数の関数がそれぞれ switch status / if で遷移を手書きしている | ✓ | |
| 状態を一つ足すたびに、複数箇所の分岐を漏れなく直さないと事故る(今日の足し忘れがまさにそれ) | ✓ | |
| 「キャンセル済みを発送」のような不正な状態遷移を、型レベルで防げず、実行時にすら素通りする | ✓ | |
| 状態が2つだけで遷移も単純(フラグ一つで足りる)/状態ごとの振る舞いがほぼ無い | ✓(boolで十分。State は過剰) |
整備手順
- 状態を表す
string/定数フィールド(Status)と、それをswitch/ifで見分けている関数(Pay/Ship/Cancel)を洗い出す。 - 状態が答えるべき操作を
Stateインターフェースに宣言する。遷移メソッドは(State, error)を返す——許可されれば次の状態、不許可ならErrInvalidTransition。 - 状態ごとに型(
newStateなどのstruct)を作り、許可された遷移は次の状態を返し、不許可はErrInvalidTransitionを返す。散っていたガードを、状態の型に集める。 - コンテキスト(
Order)はStateを一つ持ち、操作を現在の状態に委譲して、返ってきた次の状態に差し替えるだけにする。 - 各状態に
var _ State = xxxState{}の立て札を置く。新しい状態は、全ての遷移を答えるまで、ビルドが通らなくなる。文字列に戻す境界(DB読み込み等)は一箇所に絞り、defaultを error にしてテストで守る。
親方より
文字列の状態は、人間には読みやすい。だが、コンパイラには、ただの文字だ。「この状態で、それをしていいか」を、文字は何も知らない。だから、知っているつもりの人間が、あちこちの if に判断を散らし、いつか一箇所を忘れる。今日のは、それだ。
状態を、型にしろ。「次にどこへ行けるか」を、状態自身に持たせろ。そうすれば、判断は一箇所に宿る。新しい状態を足すときは、コンパイラが「お前は次に何ができる」と必ず訊いてくる。答えるまで、走り出せない——それは、足枷じゃない。お前が忘れる前に、訊いてくれているんだ。
