Featured image of post コードメカニック【Decorator】1年眠った証跡が、監査の朝に開かなかった〜専用型の掛け算を、積み重ねの一枚に解く〜

コードメカニック【Decorator】1年眠った証跡が、監査の朝に開かなかった〜専用型の掛け算を、積み重ねの一枚に解く〜

1年前に長期保管した取引証跡が、いざ復元すると末尾が欠けて開かない。圧縮・暗号化・バッファを組み合わせごとの型で抱え、最も複雑な型の閉じ処理を落としたのが原因。同じio.Writerを一枚ずつ被せるDecoratorで、掛け算で増える型を積み重ねに解く整備記録。

開かなかった、1年前の箱

監査法人への提出は、今日の午後だった。

俺は金融機関向けに、取引の監査証跡——いつ、誰の口座で、いくら動いたかの記録——を長期保管するアーカイブ基盤を作っている。設計も実装も、ほぼ俺ひとり。保管先ごとに要件が違うから、要件の組み合わせごとに、専用の書き込み型を、律儀に一つずつ起こしてきた。雑な手抜きはしていない。むしろ、丁寧に作ってきたつもりだった。

その丁寧さが、今朝、音を立てて崩れた。

監査対応で、1年前にコールドストレージへ預けた証跡を、初めて復元しようとした。コマンドを叩く。途中で止まる。

1
restore: unexpected EOF

何度やっても、同じところで止まる。箱は、開かなかった。1年前のその箱は、圧縮して、暗号化して、バッファをかませて——うちで一番複雑な経路を通って、保管したものだった。

前の職場の元同僚が、一度だけ言っていた。「コードが本番で動かなくなって、誰にも分からなくなったら、“親方"って呼ばれてる出張整備の人がいる」。半信半疑で連絡先だけ寝かせていた。けさ、締切に追い詰められて、俺は自分のプライドより先に、その番号を押した。

親方は、名乗るより先に、俺のモニタを二枚とも自分の方へ向けた。そして、症状ではなく、別のことを聞いた。

「その長期保管に使ってる Writer、いま何種類ある?」

質問の意図が、掴めなかった。壊れたのは1個だ。種類の数なんて、関係あるのか。少しムッとしながら、俺はファイルを開いた。保管先プロファイルごとに、俺が起こしてきた型が、ずらりと並んでいる。

1
2
3
4
5
// この基盤は、保管先プロファイルごとに専用の Writer 型を起こしてきた:
//
//	gzipWriter / encryptWriter / bufWriter                … 単機能
//	gzipEncryptWriter / bufGzipWriter / bufEncryptWriter  … 2機能の組み合わせ
//	bufferedGzipEncryptWriter                             … 3機能(最新・最複雑)

「壊れたのは、これです」と俺は、一番下の型を指した。圧縮も暗号化もバッファも、ぜんぶ入った長期保管用。bufferedGzipEncryptWriter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 圧縮+暗号化(外部監査法人への提出用)。バッファが無いので Close の後始末は
// gzip を閉じる1手順だけ=落としにくい。
type gzipEncryptWriter struct {
	gz  *gzip.Writer
	dst io.WriteCloser
}

func (w *gzipEncryptWriter) Close() error {
	if err := w.gz.Close(); err != nil { // 圧縮の後始末(残り+フッタを押し出す)
		return err
	}
	return w.dst.Close()
}

// 圧縮+暗号化+バッファ(長期保管用)← 4個目。これを書いていて「キリがない」と思った
type bufferedGzipEncryptWriter struct {
	gz  *gzip.Writer
	buf *bufio.Writer
	dst io.WriteCloser
}

func (w *bufferedGzipEncryptWriter) Close() error {
	if err := w.gz.Close(); err != nil {
		return err
	}
	return w.dst.Close()
}

ここで補足しておくと、io.WriteCloser は「書き込めて(Write)、閉じられる(Close)」という、Go で共通の繋ぎ口(インターフェース)だ。gzip.Writer は書いたデータを圧縮し、bufio.Writer は小さな書き込みをいったん溜めてからまとめて流す——どちらも標準ライブラリの部品だ。俺は、それらを組み合わせて、保管先ごとに専用の型を作ってきた。

「動いてたんです」と俺は言った。「1年前は、ちゃんとテストも通って、緑で。なんで今になって、開かないんだ」

親方はモニタから目を離さずに、もう一度、最初の質問を繰り返したように見えた。「何種類、ある?」

末尾を、戻す

親方は、俺のキーボードに手を伸ばさなかった。「もう一度、最後まで読み戻してみろ」とだけ言った。再現を、俺の手でやらせるつもりらしい。

俺は、保管した箱を復元する処理を、もう一度走らせた。復元というのは、保管の逆をたどることだ——暗号を解いて、伸長して、元のレコードに戻す。

暗号を解く部品は、保管したときと同じ鍵(key)と、それと対で要る初期値(iv)から作る。次のコードの最初の数行——aescipher を呼んでいるあたり——が、その「暗号を解きながら読む口」を組み立てている部分だ。中身の細部は本筋じゃないので、読み飛ばしていい。ひとつだけ頭の隅に置いてくれれば足りる。これは「流れてくるデータを、その場で1バイトずつ変換していくストリーム暗号」だということ——あとで、この性質が効いてくる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 復元: 復号 → gunzip → 平文。バッファは「いつ書くか」を変えるだけで
// バイトそのものは変えないので、読み側に「バッファを戻す」操作は要らない。
func readBack(data, key, iv []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	dec := cipher.StreamReader{S: cipher.NewCTR(block, iv), R: bytes.NewReader(data)}
	gz, err := gzip.NewReader(dec)
	if err != nil {
		return nil, err
	}
	defer gz.Close()
	return io.ReadAll(gz) // ← 末尾が欠けていて、ここで止まる
}

unexpected EOF。同じ場所で、止まった。

指が、キーから動かなかった。1年分の証跡が、途中で切れている。伸長しようとした gzip が、データの締めくくり——末尾のフッタ——を見つけられずに、息を止めた。

gzip は、最後にフッタを書く」と親方は言った。「圧縮した中身が正しく届いたか、照合するための印だ。それが、無い。途中で、切れてる」

親方は、事故った型の Close を、指で上から下へなぞった。gz.Close()dst.Close()。二行。

gzip を閉じると、最後のひと塊と、そのフッタが、バッファに入る。buf の中だ」と親方は言った。「それを、外——ファイル——へ押し出す一行が、無い」

そうだ。buf.Flush() が無い。バッファは、溜まった分がいっぱいになれば勝手に流れる。でも、最後に残った半端なひと塊は、誰かが「流せ」と言うまで、中に居座る。その最後のひと塊に、gzip のフッタが、丸ごと入っていた。流されないまま。

親方は、その一行を足した。

1
2
3
4
5
6
7
8
9
func (w *bufferedGzipEncryptWriter) Close() error {
	if err := w.gz.Close(); err != nil {
		return err
	}
	if err := w.buf.Flush(); err != nil { // 応急: 忘れていたバッファの押し出し
		return err
	}
	return w.dst.Close()
}

新しく保管し直して、もう一度、復元する。今度は、最後まで通った。原本と、ぴたりと合う。

「末尾は、戻りました」と俺は、息をついた。「今日の監査には、間に合う。じゃあ、これで……」

「これは、一個直しただけだ」

親方は、並んだ他の型を、上から順に指でなぞった。gzipEncryptWriterbufGzipWriterbufferedGzipEncryptWriter。「同じ後始末が、この型にも、この型にも、手で書いてある。次に新しい組み合わせを足す日、また誰かが——たぶんお前が——その一個を、落とす」

俺は、食い下がった。崩された気がして。「でも、丁寧に作ってきたんだ。組み合わせごとに、ちゃんと型を分けて、ひとつずつ」

親方は、初めて俺の方を向いた。「その"丁寧"を、数で見せろ」

丁寧さが、掛け算になっていた

親方は、俺のメモ用紙に、縦に三つ、言葉を書いた。圧縮。暗号化。バッファ。

「機能の軸が、三つ。それぞれ、付けるか付けないか。組み合わせは——」

書きかけて、止めた。続きは、俺に数えさせるつもりらしい。俺は、指を折りながら数えた。「圧縮だけ。暗号化だけ。バッファだけ……これで三つ。二つ組むのが、圧縮+暗号化、圧縮+バッファ、暗号化+バッファ……三つ。全部入りが、一つ。合わせて、七個。何も付けないのを入れたら、八」

「軸を、もう一つ足したら?」

俺は、口を開きかけて——止まった。たとえば、改竄を防ぐ署名。その軸を足したら。「……いま並んでる全部に、“署名あり版"が要る。七個が、十四個。いや、何も無しを入れて、八が十六」

声が、自分でも嫌になるくらい、小さくなった。「倍だ。軸を一つ足すたびに、ほぼ倍。足し算じゃない。掛け算だ。俺、掛け算に、丁寧にしてた」

「これは組み合わせ爆発(Combinatorial Explosion)だ」と親方は言った。

組み合わせ爆発——機能の組み合わせごとに型を用意して、軸が増えると、型の数が掛け算で増えていく状態のことだ。

「型が増えること自体は、まだいい」と親方は続けた。「本当に効いてくるのは、増えた型の一つひとつに、同じ後始末を、手で書き直すことだ。gzip を閉じる。buf を流す。dst を閉じる。単機能の型なら、後始末は一手順。二つ組めば二手順。三つ組んだお前の長期保管用は——三手順だ。複雑な型ほど、書く手順が増えて、どれか一個を、落とす」

「俺が落としたのは」と俺は言った。「一番複雑な、三つ重なった型の、最後の一行」

「そうだ」

俺は、ひとつ引っかかっていたことを、口に出した。「でも、1年前は緑だったんです。テストも、ちゃんと通ってた。なんで、すり抜けたんだ」

「そのテスト、何を確かめてた?」

思い返して、俺は——うなだれた。「……書けたか、だけだ。Write がエラーを返さないか。Close がエラーを返さないか。それだけ。読み戻して、本当に開けるかは——確かめてなかった」

Close は、buf.Flush() を忘れても、nil を返す」と親方は言った。「書けた、は、読み戻せる、じゃない。お前は、書けたことだけを, 緑にしていた」

親方の言葉が、胸に突き刺さる。書けたことだけを喜んでいた自分。その裏で、組み合わせごとに専用の「車体」を用意し、それぞれの Close に手作業で後始末をコピペしていた。

機能が3つでこれだ。もし4つ目の機能として署名が入ったら、16個の型それぞれに正しい順序で Close を実装しなければならない。どれか1つでも間違えたら、その型のデータは二度と元に戻らない。

俺は頭の中で、自分が積み上げてきた「車体」の山を思い浮かべていた。

Combinatorial explosion diagram showing independent feature boxes connecting to handcrafted custom writers, highlighting the missing buf.Flush in Close() cleanup

丁寧だと思っていたその作業は、ただの「コピペの温床」だった。一番複雑な組み合わせで、一番大事な一行を落とす。それは防げない必然だったのだ。

「機能の組み合わせで車体を作るのをやめる」と俺は呟いた。「でも、どうやって? 暗号化して、圧縮して、バッファする機能自体は、どうしても要るんですよ」

専用車体をやめ、一枚ずつ被せる

「機能ごとに専用車体を起こしてちゃ、キリがない」と親方は言った。整備の比喩で話すのは、これが初めてだった。「同じ繋ぎ口のアタッチメントを、一枚ずつ被せていけ」

繋ぎ口。俺の型は、ぜんぶ io.WriteCloser という同じ口を持っている。圧縮の型も、暗号化の型も、外から見れば「書けて、閉じられる」同じ口だ。

「その口を、保ったまま」と親方は言った。「機能を、一機能ずつ、別々の"層"にする。層は、自分と同じ口のやつを、内側に一つ持つ。書かれたら、自分の処理を足して、内側へ渡す」

親方は、まず圧縮の層を、一枚だけ書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// gzipLayer は圧縮の層。内側に同じ io.WriteCloser を一つ持つ。
type gzipLayer struct {
	gz    *gzip.Writer
	inner io.WriteCloser
}

// Compress は inner を圧縮の層で包む。
func Compress(inner io.WriteCloser) io.WriteCloser {
	return &gzipLayer{gz: gzip.NewWriter(inner), inner: inner}
}

func (l *gzipLayer) Write(p []byte) (int, error) { return l.gz.Write(p) }

inner が、内側に持った"同じ口"だ」と親方は言った。「Compress は、その口を圧縮で包んで、また同じ口を返す。包む前と、包んだ後で、外から見た形は変わらない」

俺は、自分の言葉に直してみた。「内側に、自分と同じ口のやつを、持つのか。書かれたら、圧縮してから、内側へ流す。……包んでも、口の形は同じだから、その上からまた、別の層で包める」

これが、**Decorator(デコレーター)**だ。同じインターフェースを保ったまま、機能を一枚ずつ包んで(ラップして)積み重ねていく技法。親方の整備の言葉でいえば、車体ごと作り直すんじゃなく、同じ繋ぎ口にアタッチメントを一枚被せる。

ここまで、層には Write しか書いていない。でも io.WriteCloser は、WriteClose の両方が揃って、はじめて口に嵌まる。いまの gzipLayer は、まだ片方しか無い——Close を足すまでは、io.WriteCloser を名乗れない。その Close を、まだ見せていないだけだ。今朝、俺が一行落としたのは、まさにその Close だ。

「待ってください」と俺は、今朝の傷口に触れた。「層をバラバラにしたら、Close は、どうなるんですか。バラして、かえって、閉じ忘れが増えるんじゃ」

親方は、答える代わりに、残りの二層を書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// bufLayer はバッファの層。
type bufLayer struct {
	bw    *bufio.Writer
	inner io.WriteCloser
}

func Buffer(inner io.WriteCloser) io.WriteCloser {
	return &bufLayer{bw: bufio.NewWriter(inner), inner: inner}
}

func (l *bufLayer) Write(p []byte) (int, error) { return l.bw.Write(p) }

func (l *bufLayer) Close() error {
	ferr := l.bw.Flush()    // 自分の後始末: バッファを押し出す
	ierr := l.inner.Close() // 内側へ伝播
	if ferr != nil {
		return ferr
	}
	return ierr
}

「見ろ」と親方は、bufLayerClose を指した。「バッファの層の後始末は、buf.Flush() だ。それを、この層が、自分で持っている。お前が今朝、手書きの三層型から落とした、あの一行を」

ひとつ、コードの細かいところが目に留まった。Close の中で、Flush のエラーを ferr、内側を閉じたエラーを ierr と、いったん両方を変数に受けている。return l.bw.Flush() と素直に一行で書かないのは、わざとだろう。Flush が失敗しても、inner.Close() は必ず呼ぶ——途中で諦めて返したら、内側の層が、閉じ残る。今朝、俺が末尾を落としたのと、地続きだ。だから両方を受けて、自分の後始末のエラーを、先に返す。

「圧縮の層は?」と俺は聞いた。

「圧縮の層は、gzip を閉じる。それだけを持つ」

1
2
3
4
5
6
7
8
func (l *gzipLayer) Close() error {
	cerr := l.gz.Close()    // 自分の後始末: 残りを押し出し、フッタを書く
	ierr := l.inner.Close() // 内側へ伝播
	if cerr != nil {
		return cerr
	}
	return ierr
}

腑に落ちた。「一枚ずつが、自分の閉じ方を、知ってるんだ。圧縮の層は gzip を閉じる。バッファの層は buf を流す。それぞれ、自分の後始末だけ持って、終わったら内側の Close を呼ぶ。だから——何枚積んでも、誰の後始末も、落ちない」

さっき、バラしたら閉じ忘れが増えるんじゃないか、と思った。逆だった。閉じるとき呼ぶのは、一番外の層の Close、ただ一度だ。それが inner.Close() で内側の層へ渡り、その層がまた内側へ——数珠つなぎに伝わって、最後の dst まで、順に閉じていく。今朝みたいに、組み合わせごとに後始末を手で書き並べて、その一個を落とす——そんな隙は、もう無い。

「後始末は、層の数だけ書けば、それで全部だ」と親方は言った。「組み合わせの数だけ、書き直す必要はない」

親方がホワイトボードに、入れ子になった箱の図を描いた。 一番外側の箱を閉めると、その衝撃で次の箱が閉まり、さらに奥の箱が閉まる。俺たちの作った層は、まさにその「連鎖する箱」だった。

外側から見える口は、どれも同じ io.WriteCloser だ。だからこそ、どんな順番で包んでも、最外周の Close() を一度呼ぶだけで、すべてが数珠つなぎで閉じていく。

Decorator stack diagram showing writer.Close() propagating Close() calls sequentially from outer to inner layers

「バッファも、圧縮も、自分の部屋の片付けだけをすればいい」と俺は言った。「隣の部屋のゴミまで心配しなくていい。片付けの号令は、外から勝手に伝わってくるから」

「そうだ」と親方はうなずいた。「引導を渡すように、順番に閉じていくだけだ。工程をどういう順で並べるかが問題だ」

順番が、仕様になる

暗号化の層も、ほとんど同じ形だった。ただ、二つ、違うところがある。一つは、作る関数 Encrypterror も返すこと——鍵の長さが規格に合わなければ、暗号エンジンを組む段階で失敗しうるからだ。CompressBuffer には、その失敗が無いから、error も返さない。もう一つは、Close だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// encryptLayer は暗号化の層。ストリーム暗号なので後始末は不要、Close は伝播のみ。
type encryptLayer struct {
	sw    cipher.StreamWriter
	inner io.WriteCloser
}

func Encrypt(key, iv []byte, inner io.WriteCloser) (io.WriteCloser, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	return &encryptLayer{
		sw:    cipher.StreamWriter{S: cipher.NewCTR(block, iv), W: inner},
		inner: inner,
	}, nil
}

func (l *encryptLayer) Write(p []byte) (int, error) { return l.sw.Write(p) }
func (l *encryptLayer) Close() error               { return l.inner.Close() }

「暗号化の層は、自分用の後始末が要らない」と親方は言った。「さっき復元で見たろ。ストリーム暗号は、書かれた分をその場で暗号文に変えて、すぐ内側へ流す。あとでまとめて吐き出す溜まりも、締めくくりの印も、持たない。gzip が最後にフッタ——あの締めの印——を書いて、閉じる手間が要ったのとは、逆だ。こっちは1バイトごとに、その場で片がつく。だから Close は、内側へ渡すだけでいい。——後始末が無い層は、無いと書く。それでいい」

そして親方は、三枚を積んで、長期保管の組み立てを書いた。

1
2
3
4
5
6
7
8
// LongTermArchive は長期保管用の組み立て: 圧縮 → 暗号化 → バッファ → dst。
func LongTermArchive(dst io.WriteCloser, key, iv []byte) (io.WriteCloser, error) {
	enc, err := Encrypt(key, iv, Buffer(dst)) // dst ← Buffer ← Encrypt
	if err != nil {
		return nil, err
	}
	return Compress(enc), nil // ← Compress(最外)
}

「一番外が、圧縮ですか」と俺は聞いた。「なんで、圧縮が、先なんだ」

「書くときは、外から内へ流れる」と親方は言った。「一番外の圧縮層に平文を渡すと、圧縮して、暗号化して、バッファに溜めて、ファイルへ——と、内へ内へ落ちていく。だから一番外の層が、最初の加工だ」

「暗号文は、ほとんど乱数だ」と親方は続けた。「乱数は、縮まない。先に暗号化したら、その後の圧縮は、ただ無駄に回るだけで、何も縮まない。だから、圧縮が先。順番を、間違えちゃいけない」

俺は、組み立ての形を、目でなぞった。Compress(Encrypt(Buffer(dst)))。外から、圧縮・暗号化・バッファ・ファイル。「順番に、意味があるのか。積む順が、そのまま——仕様なんだ」

「そうだ」と親方は言った。「そして、その順番を決めるのは、この組み立ての一箇所だけだ。外部監査法人へ出す箱は、バッファが要らない。なら——」

1
2
3
4
5
6
7
8
// AuditorExport は外部監査法人への提出用: 圧縮 → 暗号化 → dst(バッファ無し)。
func AuditorExport(dst io.WriteCloser, key, iv []byte) (io.WriteCloser, error) {
	enc, err := Encrypt(key, iv, dst)
	if err != nil {
		return nil, err
	}
	return Compress(enc), nil
}

「新しい保管先は、新しい型じゃない」と親方は言った。「積み方を、一つ書くだけだ」

俺は作業台に並んだ鋼鉄のプレートを思い浮かべた。同じ規格の口を持ち、互いを内包するように連結されたパーツたちの姿を。その構造は、極めてシンプルだった。

Decorator pattern class diagram showing wrapping layers implementation of WriteCloser interface and delegating execution to the inner layer

「型は、三つになった」と俺は言った。「層の数だけ。圧縮、暗号化、バッファ。組み合わせは、積むだけ。さっき数えた七個も、八個も、もう型じゃない。積み方の違いだ」

「軸を一つ足したら?」と親方は、さっきと同じことを聞いた。

今度は、すぐ答えられた。「層が、一枚増えるだけだ。署名の層を一枚書いて、組み立てに挟む。倍にはならない。……掛け算が、足し算になった」

書いて、読み戻す

直したコードに、テストを書いた。今度は、書けたかどうかじゃない。書いて、読み戻して、元と合うか、だ。これを round-trip という——往って、復って、同じものが返るか。今朝、俺に欠けていた確かめ方だ。

テストに出てくる小道具も、先に断っておく。closableBuffer は、メモリ上の書き込み先(bytes.Buffer)に、空の Close を一つ足しただけのテスト用の入れ物だ——bytes.Buffer には Close が無く、そのままでは io.WriteCloser の口に嵌まらないからだ。testKeytestIV は固定の鍵と初期値、sampleRecords は保管するレコードの見本。どれも本筋じゃない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 今日の事故の回帰テスト: 長期保管(3層)を書いて、読み戻して、末尾まで一致する。
func TestLongTermArchive_RoundTrip(t *testing.T) {
	var sink closableBuffer
	w, err := LongTermArchive(&sink, testKey, testIV)
	if err != nil {
		t.Fatalf("build: %v", err)
	}
	if _, err := w.Write(sampleRecords); err != nil {
		t.Fatalf("write: %v", err)
	}
	if err := w.Close(); err != nil {
		t.Fatalf("close: %v", err)
	}

	got, err := readBack(sink.Bytes(), testKey, testIV)
	if err != nil {
		t.Fatalf("restore: %v", err) // 手書きの三層型では、ここで unexpected EOF だった
	}
	if !bytes.Equal(got, sampleRecords) {
		t.Fatalf("round-trip mismatch:\n got=%q\nwant=%q", got, sampleRecords)
	}
}

bufLayer が、自分の FlushClose に持っている。だから、三層を積んでも、最後のひと塊は、ちゃんと外へ流れる。今朝の俺が落とした一行は、もう、層の中にいる。落としようがない。

保管側で残しておいた、手書きの三層型のテストも、見返した。あれは「書けたか」だけを見て、緑になっていた。読み戻すと、unexpected EOF で落ちる。事故が、テストとして、ちゃんと記録に残っている。

走らせた。

1
2
3
$ go test ./...
ok  	code-mechanic/combinatorial-explosion-decorator/after   0.210s
ok  	code-mechanic/combinatorial-explosion-decorator/before  0.366s

緑だ。だが今度の緑は、書けた緑じゃない。書いて、読み戻せた緑だ。

緑を見ながら、今朝の応急のことを、もう一度考えた。buf.Flush() を一行足した、あれ。あれも、今日の復元はちゃんと通す。Decorator で積み直したこれも、通す。今日の箱が開くか——それだけを見るなら、二つに差は無い。

分かれ目は、その先にあった。応急は、あの一行を、俺が正しく書けているあいだだけ正しい。次に新しい組み合わせを手で起こせば、また後始末の一個を落とす。たぶん、落とすのは俺だ。Decorator のほうは、後始末が層の中に宿っているから、何枚積んでも、もう落とす場所が無い。

「お前が直したのは、Close の一行だ」と親方は言った。「俺が直したのは——組み合わせを、手で書く、その癖の方だ」

親方は、俺のホワイトボードに、四角を縦に三つ積んで、一番下に dst と書いた。圧縮、暗号化、バッファ、ファイル。「次に"署名"の層が要る日が来ても、ここに一枚、足すだけだ。下の積みは、崩さなくていい」

ペンを、トレイに戻す。来たときと同じく、長居はしなかった。

「層を足したら」と、去り際に親方は言った。「必ず、書いて、読み戻すテストを付けろ。書けた、で終わるな。——1年後のお前が、また、箱を開けるんだ」

俺は、1年前の自分のコミットを、もう一度見た。保管先ごとに、律儀に積み上げた型の山。丁寧だった。ただ、丁寧の向きが、違っていた。組み合わせの数だけ型を起こすのは、掛け算に丁寧なだけだった。

機能は、一枚ずつ被せる。後始末は、その一枚に持たせる。そして、書けた、では安心しない。読み戻して、はじめて、保管だ。

監査の箱は、もう、開く。


整備記録簿

こんな異音・症状が出たら入れるべき整備(Decorator)まだ様子見でいい
機能の組み合わせごとに専用の型を起こし、軸(圧縮・暗号化等)を足すと型が掛け算で増えていく
同じ後始末(CloseFlush/フッタ書き出し)が組み合わせ型ごとに手書きでコピペされ、複雑な型ほど一手順を落とす
「エラーなく書けたか」しかテストせず、読み戻し(round-trip)で本当に復元できるかを確かめていない
機能の軸が1〜2個で、今後も増える見込みが無い/順序も組み合わせも固定✓(素直な1関数で十分。層に分けるのは過剰)

整備手順

  1. 組み合わせごとに起こした型を洗い出し、独立した「機能の軸」(圧縮・暗号化・バッファ等)を特定する。
  2. 各軸を、1つの層にする。層は内側に同じ io.WriteCloser を一つ持ち、Write で自分の処理を足して内側へ委譲する。
  3. 各層の Close には「自分の後始末 → 内側へ伝播」だけを持たせる。バッファの層は Flush、圧縮の層は gzip を閉じる。後始末の無い層は、無いと書く。
  4. 組み立て(積む順番=仕様)は、LongTermArchive のようなファクトリ一箇所に集約する。新しい保管先は、新しい型ではなく、積み方を一つ足すだけ。
  5. テストは必ず round-trip にする。書いて、読み戻して、元と一致するまでを確かめる。「書けた」で緑にしない。

親方より

丁寧に作る、その向きを間違えるな。組み合わせの数だけ型を起こすのは、掛け算に丁寧なだけだ。軸が一つ増えれば、型はほぼ倍に膨れる。膨れた型の一つひとつに、同じ後始末を手で書き直し、いつか、複雑な一個で、一行を落とす。今日のは、それだ。

機能は、一枚ずつ被せろ。後始末は、その一枚に持たせろ。そうすれば、何枚積んでも、閉じ忘れる場所が無い。組み合わせは、積み方で表せ。型を起こすな。

そして——書けた、で安心するな。書いたものは、いつか必ず、読み戻される。読み戻して合うことまで確かめて、はじめて、整備だ。1年後のお前が、黙って箱を開けられるように。

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