開かなかった、1年前の箱
監査法人への提出は、今日の午後だった。
俺は金融機関向けに、取引の監査証跡——いつ、誰の口座で、いくら動いたかの記録——を長期保管するアーカイブ基盤を作っている。設計も実装も、ほぼ俺ひとり。保管先ごとに要件が違うから、要件の組み合わせごとに、専用の書き込み型を、律儀に一つずつ起こしてきた。雑な手抜きはしていない。むしろ、丁寧に作ってきたつもりだった。
その丁寧さが、今朝、音を立てて崩れた。
監査対応で、1年前にコールドストレージへ預けた証跡を、初めて復元しようとした。コマンドを叩く。途中で止まる。
| |
何度やっても、同じところで止まる。箱は、開かなかった。1年前のその箱は、圧縮して、暗号化して、バッファをかませて——うちで一番複雑な経路を通って、保管したものだった。
前の職場の元同僚が、一度だけ言っていた。「コードが本番で動かなくなって、誰にも分からなくなったら、“親方"って呼ばれてる出張整備の人がいる」。半信半疑で連絡先だけ寝かせていた。けさ、締切に追い詰められて、俺は自分のプライドより先に、その番号を押した。
親方は、名乗るより先に、俺のモニタを二枚とも自分の方へ向けた。そして、症状ではなく、別のことを聞いた。
「その長期保管に使ってる Writer、いま何種類ある?」
質問の意図が、掴めなかった。壊れたのは1個だ。種類の数なんて、関係あるのか。少しムッとしながら、俺はファイルを開いた。保管先プロファイルごとに、俺が起こしてきた型が、ずらりと並んでいる。
| |
「壊れたのは、これです」と俺は、一番下の型を指した。圧縮も暗号化もバッファも、ぜんぶ入った長期保管用。bufferedGzipEncryptWriter。
| |
ここで補足しておくと、io.WriteCloser は「書き込めて(Write)、閉じられる(Close)」という、Go で共通の繋ぎ口(インターフェース)だ。gzip.Writer は書いたデータを圧縮し、bufio.Writer は小さな書き込みをいったん溜めてからまとめて流す——どちらも標準ライブラリの部品だ。俺は、それらを組み合わせて、保管先ごとに専用の型を作ってきた。
「動いてたんです」と俺は言った。「1年前は、ちゃんとテストも通って、緑で。なんで今になって、開かないんだ」
親方はモニタから目を離さずに、もう一度、最初の質問を繰り返したように見えた。「何種類、ある?」
末尾を、戻す
親方は、俺のキーボードに手を伸ばさなかった。「もう一度、最後まで読み戻してみろ」とだけ言った。再現を、俺の手でやらせるつもりらしい。
俺は、保管した箱を復元する処理を、もう一度走らせた。復元というのは、保管の逆をたどることだ——暗号を解いて、伸長して、元のレコードに戻す。
暗号を解く部品は、保管したときと同じ鍵(key)と、それと対で要る初期値(iv)から作る。次のコードの最初の数行——aes や cipher を呼んでいるあたり——が、その「暗号を解きながら読む口」を組み立てている部分だ。中身の細部は本筋じゃないので、読み飛ばしていい。ひとつだけ頭の隅に置いてくれれば足りる。これは「流れてくるデータを、その場で1バイトずつ変換していくストリーム暗号」だということ——あとで、この性質が効いてくる。
| |
unexpected EOF。同じ場所で、止まった。
指が、キーから動かなかった。1年分の証跡が、途中で切れている。伸長しようとした gzip が、データの締めくくり——末尾のフッタ——を見つけられずに、息を止めた。
「gzip は、最後にフッタを書く」と親方は言った。「圧縮した中身が正しく届いたか、照合するための印だ。それが、無い。途中で、切れてる」
親方は、事故った型の Close を、指で上から下へなぞった。gz.Close()。dst.Close()。二行。
「gzip を閉じると、最後のひと塊と、そのフッタが、バッファに入る。buf の中だ」と親方は言った。「それを、外——ファイル——へ押し出す一行が、無い」
そうだ。buf.Flush() が無い。バッファは、溜まった分がいっぱいになれば勝手に流れる。でも、最後に残った半端なひと塊は、誰かが「流せ」と言うまで、中に居座る。その最後のひと塊に、gzip のフッタが、丸ごと入っていた。流されないまま。
親方は、その一行を足した。
| |
新しく保管し直して、もう一度、復元する。今度は、最後まで通った。原本と、ぴたりと合う。
「末尾は、戻りました」と俺は、息をついた。「今日の監査には、間に合う。じゃあ、これで……」
「これは、一個直しただけだ」
親方は、並んだ他の型を、上から順に指でなぞった。gzipEncryptWriter。bufGzipWriter。bufferedGzipEncryptWriter。「同じ後始末が、この型にも、この型にも、手で書いてある。次に新しい組み合わせを足す日、また誰かが——たぶんお前が——その一個を、落とす」
俺は、食い下がった。崩された気がして。「でも、丁寧に作ってきたんだ。組み合わせごとに、ちゃんと型を分けて、ひとつずつ」
親方は、初めて俺の方を向いた。「その"丁寧"を、数で見せろ」
丁寧さが、掛け算になっていた
親方は、俺のメモ用紙に、縦に三つ、言葉を書いた。圧縮。暗号化。バッファ。
「機能の軸が、三つ。それぞれ、付けるか付けないか。組み合わせは——」
書きかけて、止めた。続きは、俺に数えさせるつもりらしい。俺は、指を折りながら数えた。「圧縮だけ。暗号化だけ。バッファだけ……これで三つ。二つ組むのが、圧縮+暗号化、圧縮+バッファ、暗号化+バッファ……三つ。全部入りが、一つ。合わせて、七個。何も付けないのを入れたら、八」
「軸を、もう一つ足したら?」
俺は、口を開きかけて——止まった。たとえば、改竄を防ぐ署名。その軸を足したら。「……いま並んでる全部に、“署名あり版"が要る。七個が、十四個。いや、何も無しを入れて、八が十六」
声が、自分でも嫌になるくらい、小さくなった。「倍だ。軸を一つ足すたびに、ほぼ倍。足し算じゃない。掛け算だ。俺、掛け算に、丁寧にしてた」
「これは組み合わせ爆発(Combinatorial Explosion)だ」と親方は言った。
組み合わせ爆発——機能の組み合わせごとに型を用意して、軸が増えると、型の数が掛け算で増えていく状態のことだ。
「型が増えること自体は、まだいい」と親方は続けた。「本当に効いてくるのは、増えた型の一つひとつに、同じ後始末を、手で書き直すことだ。gzip を閉じる。buf を流す。dst を閉じる。単機能の型なら、後始末は一手順。二つ組めば二手順。三つ組んだお前の長期保管用は——三手順だ。複雑な型ほど、書く手順が増えて、どれか一個を、落とす」
「俺が落としたのは」と俺は言った。「一番複雑な、三つ重なった型の、最後の一行」
「そうだ」
俺は、ひとつ引っかかっていたことを、口に出した。「でも、1年前は緑だったんです。テストも、ちゃんと通ってた。なんで、すり抜けたんだ」
「そのテスト、何を確かめてた?」
思い返して、俺は——うなだれた。「……書けたか、だけだ。Write がエラーを返さないか。Close がエラーを返さないか。それだけ。読み戻して、本当に開けるかは——確かめてなかった」
「Close は、buf.Flush() を忘れても、nil を返す」と親方は言った。「書けた、は、読み戻せる、じゃない。お前は、書けたことだけを, 緑にしていた」
親方の言葉が、胸に突き刺さる。書けたことだけを喜んでいた自分。その裏で、組み合わせごとに専用の「車体」を用意し、それぞれの Close に手作業で後始末をコピペしていた。
機能が3つでこれだ。もし4つ目の機能として署名が入ったら、16個の型それぞれに正しい順序で Close を実装しなければならない。どれか1つでも間違えたら、その型のデータは二度と元に戻らない。
俺は頭の中で、自分が積み上げてきた「車体」の山を思い浮かべていた。

丁寧だと思っていたその作業は、ただの「コピペの温床」だった。一番複雑な組み合わせで、一番大事な一行を落とす。それは防げない必然だったのだ。
「機能の組み合わせで車体を作るのをやめる」と俺は呟いた。「でも、どうやって? 暗号化して、圧縮して、バッファする機能自体は、どうしても要るんですよ」
専用車体をやめ、一枚ずつ被せる
「機能ごとに専用車体を起こしてちゃ、キリがない」と親方は言った。整備の比喩で話すのは、これが初めてだった。「同じ繋ぎ口のアタッチメントを、一枚ずつ被せていけ」
繋ぎ口。俺の型は、ぜんぶ io.WriteCloser という同じ口を持っている。圧縮の型も、暗号化の型も、外から見れば「書けて、閉じられる」同じ口だ。
「その口を、保ったまま」と親方は言った。「機能を、一機能ずつ、別々の"層"にする。層は、自分と同じ口のやつを、内側に一つ持つ。書かれたら、自分の処理を足して、内側へ渡す」
親方は、まず圧縮の層を、一枚だけ書いた。
| |
「inner が、内側に持った"同じ口"だ」と親方は言った。「Compress は、その口を圧縮で包んで、また同じ口を返す。包む前と、包んだ後で、外から見た形は変わらない」
俺は、自分の言葉に直してみた。「内側に、自分と同じ口のやつを、持つのか。書かれたら、圧縮してから、内側へ流す。……包んでも、口の形は同じだから、その上からまた、別の層で包める」
これが、**Decorator(デコレーター)**だ。同じインターフェースを保ったまま、機能を一枚ずつ包んで(ラップして)積み重ねていく技法。親方の整備の言葉でいえば、車体ごと作り直すんじゃなく、同じ繋ぎ口にアタッチメントを一枚被せる。
ここまで、層には Write しか書いていない。でも io.WriteCloser は、Write と Close の両方が揃って、はじめて口に嵌まる。いまの gzipLayer は、まだ片方しか無い——Close を足すまでは、io.WriteCloser を名乗れない。その Close を、まだ見せていないだけだ。今朝、俺が一行落としたのは、まさにその Close だ。
「待ってください」と俺は、今朝の傷口に触れた。「層をバラバラにしたら、Close は、どうなるんですか。バラして、かえって、閉じ忘れが増えるんじゃ」
親方は、答える代わりに、残りの二層を書いた。
| |
「見ろ」と親方は、bufLayer の Close を指した。「バッファの層の後始末は、buf.Flush() だ。それを、この層が、自分で持っている。お前が今朝、手書きの三層型から落とした、あの一行を」
ひとつ、コードの細かいところが目に留まった。Close の中で、Flush のエラーを ferr、内側を閉じたエラーを ierr と、いったん両方を変数に受けている。return l.bw.Flush() と素直に一行で書かないのは、わざとだろう。Flush が失敗しても、inner.Close() は必ず呼ぶ——途中で諦めて返したら、内側の層が、閉じ残る。今朝、俺が末尾を落としたのと、地続きだ。だから両方を受けて、自分の後始末のエラーを、先に返す。
「圧縮の層は?」と俺は聞いた。
「圧縮の層は、gzip を閉じる。それだけを持つ」
| |
腑に落ちた。「一枚ずつが、自分の閉じ方を、知ってるんだ。圧縮の層は gzip を閉じる。バッファの層は buf を流す。それぞれ、自分の後始末だけ持って、終わったら内側の Close を呼ぶ。だから——何枚積んでも、誰の後始末も、落ちない」
さっき、バラしたら閉じ忘れが増えるんじゃないか、と思った。逆だった。閉じるとき呼ぶのは、一番外の層の Close、ただ一度だ。それが inner.Close() で内側の層へ渡り、その層がまた内側へ——数珠つなぎに伝わって、最後の dst まで、順に閉じていく。今朝みたいに、組み合わせごとに後始末を手で書き並べて、その一個を落とす——そんな隙は、もう無い。
「後始末は、層の数だけ書けば、それで全部だ」と親方は言った。「組み合わせの数だけ、書き直す必要はない」
親方がホワイトボードに、入れ子になった箱の図を描いた。 一番外側の箱を閉めると、その衝撃で次の箱が閉まり、さらに奥の箱が閉まる。俺たちの作った層は、まさにその「連鎖する箱」だった。
外側から見える口は、どれも同じ io.WriteCloser だ。だからこそ、どんな順番で包んでも、最外周の Close() を一度呼ぶだけで、すべてが数珠つなぎで閉じていく。

「バッファも、圧縮も、自分の部屋の片付けだけをすればいい」と俺は言った。「隣の部屋のゴミまで心配しなくていい。片付けの号令は、外から勝手に伝わってくるから」
「そうだ」と親方はうなずいた。「引導を渡すように、順番に閉じていくだけだ。工程をどういう順で並べるかが問題だ」
順番が、仕様になる
暗号化の層も、ほとんど同じ形だった。ただ、二つ、違うところがある。一つは、作る関数 Encrypt が error も返すこと——鍵の長さが規格に合わなければ、暗号エンジンを組む段階で失敗しうるからだ。Compress や Buffer には、その失敗が無いから、error も返さない。もう一つは、Close だ。
| |
「暗号化の層は、自分用の後始末が要らない」と親方は言った。「さっき復元で見たろ。ストリーム暗号は、書かれた分をその場で暗号文に変えて、すぐ内側へ流す。あとでまとめて吐き出す溜まりも、締めくくりの印も、持たない。gzip が最後にフッタ——あの締めの印——を書いて、閉じる手間が要ったのとは、逆だ。こっちは1バイトごとに、その場で片がつく。だから Close は、内側へ渡すだけでいい。——後始末が無い層は、無いと書く。それでいい」
そして親方は、三枚を積んで、長期保管の組み立てを書いた。
| |
「一番外が、圧縮ですか」と俺は聞いた。「なんで、圧縮が、先なんだ」
「書くときは、外から内へ流れる」と親方は言った。「一番外の圧縮層に平文を渡すと、圧縮して、暗号化して、バッファに溜めて、ファイルへ——と、内へ内へ落ちていく。だから一番外の層が、最初の加工だ」
「暗号文は、ほとんど乱数だ」と親方は続けた。「乱数は、縮まない。先に暗号化したら、その後の圧縮は、ただ無駄に回るだけで、何も縮まない。だから、圧縮が先。順番を、間違えちゃいけない」
俺は、組み立ての形を、目でなぞった。Compress(Encrypt(Buffer(dst)))。外から、圧縮・暗号化・バッファ・ファイル。「順番に、意味があるのか。積む順が、そのまま——仕様なんだ」
「そうだ」と親方は言った。「そして、その順番を決めるのは、この組み立ての一箇所だけだ。外部監査法人へ出す箱は、バッファが要らない。なら——」
| |
「新しい保管先は、新しい型じゃない」と親方は言った。「積み方を、一つ書くだけだ」
俺は作業台に並んだ鋼鉄のプレートを思い浮かべた。同じ規格の口を持ち、互いを内包するように連結されたパーツたちの姿を。その構造は、極めてシンプルだった。

「型は、三つになった」と俺は言った。「層の数だけ。圧縮、暗号化、バッファ。組み合わせは、積むだけ。さっき数えた七個も、八個も、もう型じゃない。積み方の違いだ」
「軸を一つ足したら?」と親方は、さっきと同じことを聞いた。
今度は、すぐ答えられた。「層が、一枚増えるだけだ。署名の層を一枚書いて、組み立てに挟む。倍にはならない。……掛け算が、足し算になった」
書いて、読み戻す
直したコードに、テストを書いた。今度は、書けたかどうかじゃない。書いて、読み戻して、元と合うか、だ。これを round-trip という——往って、復って、同じものが返るか。今朝、俺に欠けていた確かめ方だ。
テストに出てくる小道具も、先に断っておく。closableBuffer は、メモリ上の書き込み先(bytes.Buffer)に、空の Close を一つ足しただけのテスト用の入れ物だ——bytes.Buffer には Close が無く、そのままでは io.WriteCloser の口に嵌まらないからだ。testKey/testIV は固定の鍵と初期値、sampleRecords は保管するレコードの見本。どれも本筋じゃない。
| |
bufLayer が、自分の Flush を Close に持っている。だから、三層を積んでも、最後のひと塊は、ちゃんと外へ流れる。今朝の俺が落とした一行は、もう、層の中にいる。落としようがない。
保管側で残しておいた、手書きの三層型のテストも、見返した。あれは「書けたか」だけを見て、緑になっていた。読み戻すと、unexpected EOF で落ちる。事故が、テストとして、ちゃんと記録に残っている。
走らせた。
| |
緑だ。だが今度の緑は、書けた緑じゃない。書いて、読み戻せた緑だ。
緑を見ながら、今朝の応急のことを、もう一度考えた。buf.Flush() を一行足した、あれ。あれも、今日の復元はちゃんと通す。Decorator で積み直したこれも、通す。今日の箱が開くか——それだけを見るなら、二つに差は無い。
分かれ目は、その先にあった。応急は、あの一行を、俺が正しく書けているあいだだけ正しい。次に新しい組み合わせを手で起こせば、また後始末の一個を落とす。たぶん、落とすのは俺だ。Decorator のほうは、後始末が層の中に宿っているから、何枚積んでも、もう落とす場所が無い。
「お前が直したのは、Close の一行だ」と親方は言った。「俺が直したのは——組み合わせを、手で書く、その癖の方だ」
親方は、俺のホワイトボードに、四角を縦に三つ積んで、一番下に dst と書いた。圧縮、暗号化、バッファ、ファイル。「次に"署名"の層が要る日が来ても、ここに一枚、足すだけだ。下の積みは、崩さなくていい」
ペンを、トレイに戻す。来たときと同じく、長居はしなかった。
「層を足したら」と、去り際に親方は言った。「必ず、書いて、読み戻すテストを付けろ。書けた、で終わるな。——1年後のお前が、また、箱を開けるんだ」
俺は、1年前の自分のコミットを、もう一度見た。保管先ごとに、律儀に積み上げた型の山。丁寧だった。ただ、丁寧の向きが、違っていた。組み合わせの数だけ型を起こすのは、掛け算に丁寧なだけだった。
機能は、一枚ずつ被せる。後始末は、その一枚に持たせる。そして、書けた、では安心しない。読み戻して、はじめて、保管だ。
監査の箱は、もう、開く。
整備記録簿
| こんな異音・症状が出たら | 入れるべき整備(Decorator) | まだ様子見でいい |
|---|---|---|
| 機能の組み合わせごとに専用の型を起こし、軸(圧縮・暗号化等)を足すと型が掛け算で増えていく | ✓ | |
同じ後始末(Close/Flush/フッタ書き出し)が組み合わせ型ごとに手書きでコピペされ、複雑な型ほど一手順を落とす | ✓ | |
| 「エラーなく書けたか」しかテストせず、読み戻し(round-trip)で本当に復元できるかを確かめていない | ✓ | |
| 機能の軸が1〜2個で、今後も増える見込みが無い/順序も組み合わせも固定 | ✓(素直な1関数で十分。層に分けるのは過剰) |
整備手順
- 組み合わせごとに起こした型を洗い出し、独立した「機能の軸」(圧縮・暗号化・バッファ等)を特定する。
- 各軸を、1つの層にする。層は内側に同じ
io.WriteCloserを一つ持ち、Writeで自分の処理を足して内側へ委譲する。 - 各層の
Closeには「自分の後始末 → 内側へ伝播」だけを持たせる。バッファの層はFlush、圧縮の層はgzipを閉じる。後始末の無い層は、無いと書く。 - 組み立て(積む順番=仕様)は、
LongTermArchiveのようなファクトリ一箇所に集約する。新しい保管先は、新しい型ではなく、積み方を一つ足すだけ。 - テストは必ず round-trip にする。書いて、読み戻して、元と一致するまでを確かめる。「書けた」で緑にしない。
親方より
丁寧に作る、その向きを間違えるな。組み合わせの数だけ型を起こすのは、掛け算に丁寧なだけだ。軸が一つ増えれば、型はほぼ倍に膨れる。膨れた型の一つひとつに、同じ後始末を手で書き直し、いつか、複雑な一個で、一行を落とす。今日のは、それだ。
機能は、一枚ずつ被せろ。後始末は、その一枚に持たせろ。そうすれば、何枚積んでも、閉じ忘れる場所が無い。組み合わせは、積み方で表せ。型を起こすな。
そして——書けた、で安心するな。書いたものは、いつか必ず、読み戻される。読み戻して合うことまで確かめて、はじめて、整備だ。1年後のお前が、黙って箱を開けられるように。
