Featured image of post コードメカニック【Observer】通知を一本足しただけで、ポイントが付かなくなった〜「起きた」と知らせるだけの配線へ〜

コードメカニック【Observer】通知を一本足しただけで、ポイントが付かなくなった〜「起きた」と知らせるだけの配線へ〜

通知を一本足しただけで、ポイント付与が止まった。注文確定の本体に反応が直書きされ、施策のたびに急所を開ける——散弾銃手術をObserverパターンで解き、発生側は「起きた」と知らせるだけにする整備記録。

周年祭の朝に

犯人探しは、要らなかった。

朝7時前、ベッドの中で見たスマホに、通知サービスのエラーログが並んでいた。タイムアウト。タイムアウト。タイムアウト。昨夜のリリースは1コミット。差分は、注文確定の処理に足した、周年祭クーポンの通知ブロックだけ。火元は、私だ。

私は、自家焙煎コーヒー豆のオンラインストアでバックエンドを書いている。実務2年目。焙煎所の2階が事務所で、開発は私とフロント担当の2人だけ。企画——店主と企画担当——は毎月施策を打ってくる。新豆入荷の案内、雨の日クーポン。そして今週は、焙煎所5周年祭。初日の今朝は、セール目当ての注文が朝から続いていた。それ自体は、いいニュースのはずだった。

出社して、確認して、血の気が引いた。通知がタイムアウトした注文では、ポイントが付いていない。CS(カスタマーサポート)用の操作履歴も、無い。

return err、か」

たった一行。通知が失敗したら、エラーを返す。間違ったことを書いた覚えはない。周年祭の通知は企画の肝だから、失敗を黙って握りつぶしたくなかった。丁寧にやったつもりだった。なのにその一行が、ポイントと履歴を道連れにした。

通知の失敗が、なんでポイントの失敗になるんだ。

追い打ちは、階段を上がってきた。企画担当だ。「ねえ、定期便のお客さんから『周年祭のクーポン、私には来てないんですけど』って問い合わせが。告知には“ご注文全部に付きます”って書いちゃってます」

——定期便。うちの定期便の注文は、毎朝6時のジョブが確定する。そして昨夜の私は、Webの確定処理に通知を足して、満足して、寝た。

ジョブのほうには、足し忘れていた。

コードのほうの整備士

今朝はちょうど、焙煎機の定期整備の日だった。階下で作業していた整備士さん——人のいい年配の男性だ——が、コーヒーを取りに降りた私の顔を見て言った。「機械の調子、悪い?」

機械じゃなくて、コードのほうで。力なくそう返したら、整備士さんは少し笑って、工具箱の蓋の裏から名刺を一枚出した。

「なら、コードのほうの整備士を知ってるよ。親方って呼ばれてる人でね。うちの業界で言う、出張整備だ」

藁にもすがる気持ち、というより、半分は付き合いで連絡した。直し方は分かっている。エラーを握りつぶして続行に変えればいい。それでも連絡したのは、たぶん、「直し方は分かるのに、これが最後になる気がしない」からだった。

昼前に、その人は来た。整備士というから作業着を想像していたが、現れたのは身軽な格好の女性で、荷物は肩掛けの鞄ひとつ。あいさつは短く、余計なことを何も訊かない。階段の途中で焙煎機に目を留めて、「いい整備だ」とだけ言った。機械への一言が、世間話の代わりらしかった。

私は経緯を一気に説明して、苦笑いで締めた。「……で、火元は分かってるんです。昨夜の私です。直し方も、たぶん分かってます。エラーを握りつぶして続行に変えればいい。でも——また起きますよね。次の施策でも、私はこの関数に何か足すので」

親方は私の隣に腰を下ろして、画面より先に、こっちを見た。

「昨夜、何を足したかじゃない。これまで、何回足してきたか——それを後で数えます」

「……何回、って」思わず指を折りはじめて、折りきれずにやめた。

「先に、いま燃えているところを見せてください」

燃えているところ

私は確定処理のファイルを開いた。Webのチェックアウトが呼ぶ、注文確定の本体だ。

 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
28
29
30
31
32
33
34
35
package order

// チェックアウトの確定。施策のたびに、ここへ何かが足されてきた
func ConfirmCheckout(o Order) error {
	// 在庫を引き当てる。引けなければ確定自体が失敗
	if err := reserveStock(o); err != nil {
		return err
	}
	// 注文を確定として保存
	if err := saveConfirmed(o); err != nil {
		return err
	}

	// 確認メール(前任のコメント: メールが落ちても注文は止めない)
	if err := sendConfirmMail(o); err != nil {
		log.Printf("mail: %v", err)
	}

	// 昨夜足した: 周年祭クーポンの通知
	// 企画の肝だから、失敗を握りつぶしたくなかった
	if err := notifyCampaign(o); err != nil {
		return err // ← この一行が、下の2つを道連れにした
	}

	// ポイント付与(去年の施策で追加)
	if err := grantPoints(o); err != nil {
		log.Printf("points: %v", err)
	}

	// 操作履歴(CS向け。一昨年の施策で追加)
	if err := writeHistory(o); err != nil {
		log.Printf("history: %v", err)
	}
	return nil
}

確定の関数なのに、中身の大半は確定じゃない。メール、通知、ポイント、履歴。「注文が確定したら、やること」が、確定の本体に直書きで並んでいる。

そして、もう一個ある。お見せするのが恥ずかしいやつが。私は定期便ジョブのファイルを開いた。見てほしいのは2点——並びの順番が微妙に違うこと。そして、無いものがあること。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 毎朝6時の定期便ジョブ。中身は ConfirmCheckout の"ほぼ"コピペ
func ConfirmSubscription(o Order) error {
	if err := reserveStock(o); err != nil {
		return err
	}
	if err := saveConfirmed(o); err != nil {
		return err
	}
	if err := grantPoints(o); err != nil { // ← 順番からして微妙に違う
		log.Printf("points: %v", err)
	}
	if err := sendSubscriptionMail(o); err != nil { // ← メールも定期便用の別関数
		log.Printf("mail: %v", err)
	}
	if err := writeHistory(o); err != nil {
		log.Printf("history: %v", err)
	}
	// 周年祭の通知は──無い。昨夜の私は、こっちの存在を忘れていた
	return nil
}

「同じ並びが、もう一回あるんです。順番は微妙に違って、メールも定期便用の別物で。……で、昨夜の通知は、こっちには無い。告知は“ご注文全部”なのに」

親方は両方のコードを、上から下まで黙って読んでいるように見えた。私は我慢できずに訊いた。

「直し方は分かってるんです。でも、これ、直したことになるんでしょうか。次の施策が来たら、私はまた、この2箇所を開けるんですよね」

応急——二箇所、塞ぐ

親方はキーボードに手を伸ばし、まず昨夜の一行を書き換えた。

1
2
3
4
	// 応急: 通知の失敗は記録して、先へ進む(ポイントと履歴を道連れにしない)
	if err := notifyCampaign(o); err != nil {
		log.Printf("campaign: %v", err)
	}

これで、通知サービスが詰まっても、ポイントと履歴は走る。今朝の巻き添えは止まる。

続けて親方は、Web側の通知ブロックを範囲選択して、コピーした。定期便ジョブの関数に、そのまま貼り付ける。足し忘れていた通知が、ジョブ側にも入った。

「朝の取りこぼし——付かなかったポイントと、消えた履歴は、注文の記録から引き直せます。それは手当てのスクリプトの仕事です」

私は安堵しかけて、いま見た手元を思い返した。

「……いま、コピペしましたよね」

「ええ。この構造の中では、これが正しい直し方です。同じ反応が2箇所にあるなら、2箇所に同じものを書く」

「嫌味だ……」笑ってしまった。そして、笑いながら気づいた。「つまりこの構造のままだと、次の施策の日も、誰かがこのコピペをやるんですね。で、貼り忘れた担当が事故る。——今回の担当は、昨夜の私でしたけど」

親方は画面から目を離さずに言った。

「二箇所、塞ぎました。今日の取りこぼしは、もう出ません。——ですが、次の施策の日、あなたはまた二箇所を開けて、エラーをどうするかをまた決めて、またテストを直す。開けるたびに、撃てる」

「撃てる……?」

「今朝あなたが撃った。通知を足しただけで、ポイントに当たった」

通知を足しただけで、ポイントに当たった。今朝からずっと喉に引っかかっていた違和感を、そのまま言葉にされた気がした。

「とりあえず今日は乗り切れますよね。……で、本格のほうは、何をどうするんですか。切り出して整理する、とかですか」

親方は答える代わりに、ターミナルを開いた。

整備記録を、さかのぼる

「さっきの質問の、答え合わせをしましょう。——この関数、何回足されてきたか」

親方が打ったのは、コードを表示するコマンドではなかった。

1
2
3
4
5
6
$ git log --since="3 months ago" --oneline -- internal/order/confirm.go
e3f91c2 周年祭クーポン通知を追加
a8d02b7 ポイント付与率を周年祭用に変更
4c11f0a 確認メールの文面を夏ブレンドに差し替え
9b7e6d1 操作履歴にクーポンIDを追加
...(計14件)

その発想が、私にはなかった。コードを読むんじゃない。コードが直されてきた“理由”を読む。整備記録簿をさかのぼるみたいに。気づけば椅子を引いて、画面に身を乗り出していた。

「3ヶ月で14回。この関数が直された理由を、上から読んでください」

「通知、ポイント、メール、履歴、ポイント、メール……」読み上げて、途中で手が止まった。「——注文の確定そのものを直した回が、一つも無い」

「そう。この関数の名前は『確定』なのに、開けられる用事は、いつも確定の“あと”の話だ。メールの都合。ポイントの都合。企画の都合。——確定は3ヶ月、何も変わっていないのに、確定の関数だけが14回、手術台に載っている」

親方は、ようやくこちらを向いた。

「これは Shotgun Surgery(ショットガン・サージェリー)と呼びます」

散弾銃手術。Shotgun Surgery とは、1つの変更をするために、複数の箇所へ小さな修正を撃ち込んで回る羽目になる状態を指す、コードの不吉な兆候(コードスメル)だ。今回なら「周年祭の通知を足す」というたった1つの用事が、Webの確定、定期便の確定、それぞれのテスト——と、何箇所にも散る。

「一発の用事で、弾が何箇所にも飛ぶから、散弾銃……。昨夜の私は、そのうち1箇所に撃ち忘れたわけですね」

「逆向きの名前もあります」と親方は付け加えた。「この関数ひとつに、メールの都合もポイントの都合も、変更の理由が集まりすぎていることは Divergent Change という。散るのも病気、集まりすぎるのも病気。どちらも根は同じ——『注文が確定した』という出来事と、『そのとき何をするか』が、同じ場所に書いてあることです」

親方は鞄から太いマーカーを取り出すと、作業机の上の油染みたウエスを押しのけ、裏紙に素早く図を書き殴った。 そのペン先が走る音は、錆びたボルトをレンチで力任せに回すときのような、鈍い摩擦音を立てていた。

「いいかい。君の書いた配線は、1つの動力スイッチから何本ものむき出しの銅線が、メール送信機やらポイント加算器やらに直接ハンダ付けされている状態だ。しかも、Web用と定期便用で、同じような二系統の配線盤が並んでいる」

親方の言葉を聞きながら、私は自分の頭の中の配線図と、目の前の図を重ね合わせていた。

「本来なら、どちらも“注文確定”というピストンが動くだけでいいはずなのに、そのピストンの先端に、いくつもの連動アームが無理やりボルト留めされている。だからアームを一本追加しようとしただけで、バランスが崩れて隣のアームに干渉し、折れてしまったんだ」

描かれた図は、まさに今朝の私の失態そのものだった。Web側のスイッチにはどうにか新しい追加アームをボルト留めしたものの、定期便側のスイッチにはアームを取り付け忘れて、そのまま火花を散らして止まっている。

Shotgun Surgery diagram: both ConfirmCheckout and ConfirmSubscription functions duplicately trigger individual downstream handlers (Confirm Email, Grant Points, Write History), resulting in a missed Campaign Notification call in ConfirmSubscription

半分、正しい

実は、温めていた案があった。応急のあいだ、ずっと考えていたやつだ。少しだけ自信もあった。

「……整理の仕方、考えてたんです。反応は全部それぞれ関数に切り出して——いまも一応関数ですけど、もっとちゃんと——確定の関数は、それを順番に呼ぶだけにする。それと、Webとジョブでほぼコピペになってるのをやめて、確定の関数を一本にまとめる。そうすれば、足し忘れは起きませんよね。一本しか無いんだから」

「半分、正しい」

「半分」

「一本化は、やります。それで『二箇所に撃つ』は消える。足し忘れも消える。——だが、それだけだと、次の施策の日、あなたはその一本を開ける。呼び出しを一行、足しに来る。エラーをどうするか、どの順番に挟むか、また確定の本体の中で決める。今朝の return err と同じ判断を、また同じ急所でやる。開ける限り、撃てる」

半分褒められて、半分崩された。納得しかけて、でも、詰まった。

「……でも、じゃあ、どうするんですか。確定したらメールもポイントも通知もやるんだから、確定の関数がそれを呼ぶしか、ないじゃないですか。呼ばずに、やらせる方法なんて——」

言いながら、自分の言葉に引っかかった。呼ばずに、やらせる。

親方が、初めてかすかに頷いたように見えた。

本格整備——「起きた」と知らせるだけにする

まず、あなたの案をやります

親方はそう言って、Webとジョブの2つの確定関数を統合し、単一の Confirm にまとめはじめた。両方の入口は、これを呼ぶだけになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 中間形: 経路を一本にした Confirm。反応はまだ直書きのまま
func Confirm(o Order) error {
	if err := reserveStock(o); err != nil {
		return err
	}
	if err := saveConfirmed(o); err != nil {
		return err
	}
	// 経路の違いは、関数の違いから「データの違い」になった
	if o.IsSubscription {
		if err := sendSubscriptionMail(o); err != nil {
			log.Printf("mail: %v", err)
		}
	} else {
		if err := sendConfirmMail(o); err != nil {
			log.Printf("mail: %v", err)
		}
	}
	// …通知・ポイント・履歴は、直書きのまま続く…
	return nil
}

一つだけ、新しいものが増えている。o.IsSubscription だ。いままで「どっちの確定関数を呼んだか」で区別していた経路の情報は、関数を一本にすると行き場を失う。だから OrderIsSubscription(定期便かどうか)を一つ持たせて、データとして運ぶ。立てるのは注文を組み立てる側——定期便ジョブが作る注文だけ、true になる。

「これで撃つ先は一箇所。足し忘れは、もう構造的に起きない。……で、ここからが『残り半分』ですか」

「ここからが本題です。この関数から、メールもポイントも通知も、追い出します」

「追い出すって——確定したら、やるんですよ?」

「やります。だが、確定の関数が呼ぶのをやめる。確定は『確定した』と知らせるだけにする。やる側が、勝手に聞いて、勝手にやる」

——『呼ばずに、やらせる』。さっき自分で口にした言葉が、そのまま返ってきた。

出来事を、型にする

親方が最初に書いたのは、struct が一つだけだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package order

import "time"

// 「注文が確定した」という出来事そのもの。事実だけを運ぶ
type OrderConfirmed struct {
	OrderID        string
	CustomerID     string
	Total          int  // 円
	IsSubscription bool // 定期便かどうか
	ConfirmedAt    time.Time
}

struct を1つ作っただけだ。だがこれは設定でもデータの保存でもなく、「いま、これが起きた」という報せを運ぶための型。報せに必要な事実——どの注文が、誰の、いくらで——だけを持つ。

次に、その報せを受け取る側。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 購読者=OrderConfirmed を受け取って、自分の仕事をする関数
type Subscriber func(OrderConfirmed)

type Confirmer struct {
	subs []Subscriber // 報せを聞きたい人の名簿
}

func NewConfirmer() *Confirmer {
	return &Confirmer{}
}

// 名簿に載る。起動時に一度だけ呼ばれる想定
func (c *Confirmer) SubscribeConfirmed(s Subscriber) {
	c.subs = append(c.subs, s)
}

[]Subscriber……関数を、スライスで持つんですか。関数って、変数みたいに持てるんでしたっけ」

「Goでは、関数は値です。int をスライスに並べられるように、関数もスライスに並べられる」

type Subscriber func(OrderConfirmed) は、「OrderConfirmed を受け取って何も返さない関数」に名前を付けたものだ。intstring と同じように、変数に入り、スライスに並ぶ。

では、その名簿はどこに置くのか。登録された購読者をずっと覚えておく置き場が要る。だから Confirmer という struct を作って、subs に持たせる。メソッド定義の先頭にある (c *Confirmer) はレシーバと呼ばれる Go の書き方で、「この関数は Confirmer に属するメソッドだ」という意味になる。

そして、確定の本体がこうなった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (c *Confirmer) Confirm(o Order) error {
	// ここから——確定の成立条件。失敗したら確定そのものが失敗
	if err := reserveStock(o); err != nil {
		return err
	}
	if err := saveConfirmed(o); err != nil {
		return err
	}
	// ここまで——確定は成立した

	// あとは「起きた」と知らせるだけ
	ev := OrderConfirmed{
		OrderID:        o.ID,
		CustomerID:     o.CustomerID,
		Total:          o.Total,
		IsSubscription: o.IsSubscription,
		ConfirmedAt:    time.Now(),
	}
	for _, s := range c.subs {
		s(ev)
	}
	return nil
}

メールが消えた。通知も、ポイントも、履歴も。残っているのは在庫の引き当てと、確定の保存と、名簿への報せだけ。

線はどこに引くのか

「在庫は、残すんですね。メールと一緒に追い出すのかと思ってました」

「在庫が引けないのに確定しては駄目だ。在庫と保存は確定の成立条件——本体に残る。外へ出すのは、確定の“あと”でいい反応だけです」

分かってきた気がした。私は自分なりに基準を立ててみた。

「大事なものは中、軽いものは外、ですよね。じゃあポイントも中じゃないですか? お金に関わるし、今朝あれだけ事故になったんだから」

「基準は重要さじゃない」

親方は即答だった。

「——それが失敗したとき、確定ごと失敗にしたいか。ポイントが付かなかった注文を、無かったことにしたいですか」

「……したくない、です」言いながら、頭の中で整理がついていく。「注文は通ってほしい。ポイントは、あとからでも付け直せる。……そうか、メールも通知も同じだ。失敗しても、注文まで失敗にしたいものは、一個も無い」

「それが線です。重要かどうかじゃない。確定を止めたいかどうか」

今朝の事故が、ようやく一本の線でつながった。つまり——外に出すべき通知を、中に書いたから、通知の失敗が確定の失敗になったんだ。

購読者は、道具を抱えて生まれる

追い出された反応たちは、どこへ行くのか。親方は新しいパッケージを切った。Before では、確定処理の側が、メールや通知の道具をぜんぶ抱えていた——order が反応たちに依存していた。これからは逆だ。反応の側が、order の報せ(OrderConfirmed)を知りに来る。確定の本体は、もう反応たちの存在を知らない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package notify // 反応の側のパッケージ。order を import する(向きが逆になった)

// メール購読者を作る。クライアントは閉じ込める。
// 出し分け(確認メール/定期便メール)はメールの関心になったので、この中で分岐する
func NewMailSender(cli *mail.Client) order.Subscriber {
	return func(ev order.OrderConfirmed) {
		var err error
		if ev.IsSubscription {
			err = cli.SendSubscriptionMail(ev)
		} else {
			err = cli.SendCheckoutMail(ev)
		}
		if err != nil {
			log.Printf("mail: %v", err) // メールの失敗はメールの問題。誰も道連れにしない
		}
	}
}

// ポイント購読者・履歴購読者・周年祭通知購読者も同じ形
// func NewPointsGranter(store *points.Store) order.Subscriber {...}
// func NewHistoryWriter(rec *history.Recorder) order.Subscriber {...}
// func NewCampaignNotifier(cli *campaign.Client) order.Subscriber {...}

「待ってください」気になることがあった。「cli って、NewMailSender の中の変数ですよね。関数を返して NewMailSender を抜けたら、消えるんじゃないんですか。返ってきた関数が、あとで名簿から呼ばれたとき、cli はまだ使えるんですか」

「使えます。返した関数が抱えている限り、生きている。Goの関数は、自分が生まれた場所の変数を持ったまま外に出られる——クロージャと呼びます。さっき『関数は値だ』と言った。並べられて、しかも、荷物を持てる」

「道具を持たせたまま、名簿に載せるんだ」

それから、と親方はメール購読者の中の if を指した。

「Before では、確認メールか定期便メールかを、確定の関数が知っていた。一本化の if でもまだ知っていた。いまは、メールの購読者だけが知っている。メールの出し分けは、メールの関心。確定の本体は、もう知らない」

一つ、確かめておきたいことがあった。

「ポイントの購読者が失敗したら、誰が気づくんですか。確定はもう、エラーを受け取りませんよね」

「ポイントの失敗の面倒は、ポイントの購読者が自分で見る。ログを書く、付け直しの再試行をする、アラートを上げる——それはポイントの都合で決めることで、確定の本体で決めることじゃない。今朝のあなたは、通知のエラー方針を、確定の本体の中で決めるしかなかった。だから外した」

配線は、入口で一回

最後は、名簿への登録だ。アプリケーションの起動時、main で一回だけ配線する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
	// mailCli などの道具(クライアントや台帳)の初期化は省略
	confirmer := order.NewConfirmer()
	// 名簿への登録は、起動時にここで一回だけ
	confirmer.SubscribeConfirmed(notify.NewMailSender(mailCli))
	confirmer.SubscribeConfirmed(notify.NewPointsGranter(pointsStore))
	confirmer.SubscribeConfirmed(notify.NewHistoryWriter(historyRec))
	confirmer.SubscribeConfirmed(notify.NewCampaignNotifier(campCli))

	// Webハンドラも定期便ジョブも、同じ confirmer.Confirm を呼ぶだけ
	startServer(confirmer)
	startSubscriptionJob(confirmer)
}

「Webとジョブが同じ Confirm を呼ぶから、名簿も共通……」声に出して、気づいた。「登録の足し忘れ、起きようがない。経路ごとに足すんじゃなくて、名簿に一回載せるだけだから」

「次の施策の日、あなたが書くのは購読者一つと、この登録一行。確定の本体は、開けない」

親方がやって見せたこの組み付けには、名前がある。Observer(オブザーバー)——出来事の発生側と、それに反応する側を切り離すデザインパターンだ。発生側は「起きた」と知らせるだけで、誰が聞いているかを知らない。反応する側(購読者)は、あとから何人でも足せる。

親方は今度は別の色のマーカーで、新しい図を描いた。 それは、さっきの複雑に絡み合ったハンダ付けの山とはまったく違う、驚くほどすっきりとした図だった。

「注文確定のスイッチは、もう何も動かさない。ただ“ピストンが動いた”という信号を、一本の電線で分配器に送るだけだ。分配器の先には名簿があって、そこに登録された受信機たちが、流れてきた信号を自分で感知して動く」

頭の中で、新しい配線盤が組み上がっていくのが見えた。

そうか、これならスイッチの横に無理やり追加アームをボルト留めする必要はない。 スイッチ側は「動いた」という事実をブザーで鳴らすだけでいい。ブザーの音が聞こえる範囲に、メール係も、ポイント係も、そして新しく入った雨の日クーポン係も、それぞれ勝手に椅子を並べて座っていればいいのだ。

誰が聞いているかをスイッチが知る必要はないし、誰か一人が聞き耳を立てるのをサボったり、途中でつまずいて転んだりしても、ブザーそのものが止まることはない。

「これが、出来事と反応を分けるということか……」

私は、親方が描いた新しい配線の青写真を見つめながら、その美しさに息を呑んでいた。

Observer Pattern architecture: Checkout/Subscription clients invoke Confirmer.Confirm, which broadcasts an OrderConfirmed event to decoupled subscribers (Email, Points, History, Campaign) registered in a subscription registry

「この名簿って」ふと気になった。「名簿を管理する係の人がいて、内容を見て『これはメール係へ、これはポイント係へ』って振り分ける、みたいな話とは違うんですか」

「それは別の整備です。これは一方通行——確定が『起きた』と流すだけで、全員に同じ報せが届く。購読者同士は互いを知らないし、言い返しもしない。係どうしのやり取りを捌く話は、また別の機会に」

速くは、なっていない

「ただし」と、親方は付け加えた。「速くは、なっていません」

購読者は登録順に、その場で順に呼ばれる。通知サービスが今朝みたいに詰まれば、確定の応答はその分、遅いままだ。今日切り離したのは巻き添えであって、待ち時間ではない。どうしても待たせたくなくなったら、報せを channel——Goの、処理と処理のあいだで値を受け渡す道具——に流して、別の goroutine(並行して動く処理の単位)で受ける手がある。だがそれは、順序も取りこぼしもエラーの行き先も全部設計し直す第二段階で、今日はやらない。

もう一つ。購読者は登録した順に呼ばれるが、順序に意味を持たせはじめたら危険信号だ。「ポイントの後でないとメールが書けない」のような依存ができたら、それはもう独立した購読者ではなく手続きで、名簿から出して明示的に順に呼ぶ形に戻すべきだ——親方はそう言って、名簿のスライスを軽く指で叩いた。

「なぜこれで『次』が無くなるんですか?」最後に、まとめのつもりで訊いた。

「無くなるのは事故じゃない。確定の本体を開ける理由が無くなる。開けなければ、撃てない。新しい反応は、名簿に並ぶだけだ」

試運転——次の施策が、その場に届く

テストも、形が変わった。

Before のテストは、事故の記録だった。通知のスタブ(テスト用に差し替えた偽物の通知)に失敗を仕込むと、ConfirmCheckout 全体がエラーになり、ポイントと履歴が呼ばれない——今朝の巻き添えを、そのまま文書化したものだ。定期便側が通知を一度も呼ばないこと(足し忘れ)も、テストとして残した。

After のテストは、構造の証明になった。一つ目(①)は成立条件の検証——在庫が引けなければ確定が失敗し、購読者は誰も呼ばれない。イベントは「発行されない」のであって、「失敗する」のではない。そして二つ目(②)が、今朝の巻き添えの回帰テスト——同じ事故が再発しないことを、確かめ続けるテストだ。

 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
// ② 今朝の巻き添えの回帰テスト: 通知購読者が内部で失敗しても、
// ポイント購読者はちゃんと呼ばれる。Subscriber は func(OrderConfirmed)——
// エラーを返す配線が型から消えており、失敗が本体や他の購読者に伝わる道は無い。
func TestConfirm_FailingSubscriberDoesNotDragOthers(t *testing.T) {
	c := NewConfirmer()

	notifyAttempted := false
	c.SubscribeConfirmed(func(ev OrderConfirmed) {
		notifyAttempted = true
		// 今朝の通知のように内部で失敗する(が、外へは何も返せない)
		_ = errors.New("campaign: timeout")
	})
	pointsCalled := false
	c.SubscribeConfirmed(func(ev OrderConfirmed) { pointsCalled = true })

	if err := c.Confirm(Order{ID: "A-2002", CustomerID: "C-2", Total: 1800}); err != nil {
		t.Fatalf("購読者の失敗は確定を失敗にしない: %v", err)
	}
	if !notifyAttempted {
		t.Error("通知購読者が呼ばれていない")
	}
	if !pointsCalled {
		t.Error("通知購読者の失敗がポイント購読者を巻き添えにした(Before の事故の再来)")
	}
}

テストの中の _ = errors.New(...)_ は、値を使わずに捨てる Go の書き方だ。エラーは作った——だが、渡す先が無い。Subscriber はエラーを返せない型なので、購読者の失敗が本体に伝わる配線そのものが、型からもう消えている。あの一行は、「エラーは起きたが、行き先が無い」をそのまま書いたものだ。このテストは、それを文書化している。

1
2
3
4
$ go test ./before/order ./after/order ./after/notify
ok  	code-mechanic/shotgun-surgery-observer/before/order	0.591s
ok  	code-mechanic/shotgun-surgery-observer/after/order	0.174s
ok  	code-mechanic/shotgun-surgery-observer/after/notify	0.404s

試運転、合格。

雨の日クーポン

そのとき、また階段を上がってくる足音がした。企画担当だ。

「ねえ、来週なんだけど、雨の日クーポンやりたくて。雨の日に注文してくれた人に、ちょっとだけ——」

確定処理を開けて、と反射的に考えはじめて、やめた。隣で親方が、何も言わずにこっちを見ている。

私はその場で書いた。NewRainyDayCoupon(weatherCli, campCli)——雨なら、クーポンを発行する購読者を一つ。main に、登録を一行。go test。緑。

diff を見た。確定の本体、開けてない。触ったのは、新しい購読者のファイルと、名簿の一行だけ。

「で、できそう?」と企画担当。

「もう入りました」

「早っ」

朝からずっと頬に貼り付いていた苦笑いが、はじめてニヤリに変わったのが、自分でも分かった。

納車

親方は試運転の緑を見届けると、立ち上がって、整備記録簿——A4一枚の作業明細だった——を私の机に置いた。

「確定の本体は、もう開けない。施策は名簿に足すだけだ。——開けたくなったら、それは施策じゃなく、確定そのものが変わるときです」

それから、報酬の話をした。金銭ではなく、この人の言う「ちゃんとした整備」の約束だ。

「購読者には、必ず自分のテストを付けること。確定の本体のテストは、もう増やさなくていい。増えるのは名簿と、購読者と、そのテストだけだ」

約束します、と言いかけたところで、階下から焙煎機が低く唸る音がした。「終わったよー」という整備士さんの声が、ほぼ同時に届く。

機械とコード、二台分の整備が終わった日だった。


整備記録簿

こんな異音・症状が出たら入れるべき整備(Observer)まだ様子見でいい
「○○したら××する」の××が、○○の処理本体に直書きで並び、施策・関心が増えるたびに同じ関数を開けている
同じ反応の並びが複数の経路(Web・バッチ等)にコピペされ、足し忘れやドリフト(コピペ同士が少しずつ食い違っていくこと)が起きている
新しく足した反応のエラー処理が、既存の反応を巻き添えにした(今朝のそれ)
反応する相手が2つで固定・増える予定がない✓(直接呼び出し2行で足りる。登録の仕組みは過剰)
反応の結果(戻り値)が本体の続きに必要(審査結果で分岐する等)✓(それは報せではなく、ただの関数呼び出し)

整備手順

  1. 出来事の発生箇所を数える。複数あれば、まず一本化する。足し忘れは、ここで消える。
  2. 本体に並ぶ処理を仕分ける。基準は「それが失敗したとき、出来事ごと失敗にしたいか」——したいものは成立条件として本体に残す。したくないものが、外へ出す反応。
  3. 出来事を一つの型にする(OrderConfirmed のように、報せに必要な事実だけを持つ struct)。
  4. 反応を購読者(func(イベント) 型)に切り出す。依存する道具はクロージャ工場(NewXxx(道具) Subscriber)で閉じ込め、エラーの面倒は各購読者が自分で見る。
  5. 本体は、成立条件のあとに名簿へ報せを流すだけにする。名簿への登録は起動時(main)に一箇所。
  6. 新しい関心は、購読者1つ+登録1行で足す。本体は開けない。購読者には自分のテストを付ける。

親方より

「確定したら、あれもこれもやる」——その“あれもこれも”を本体に書き足していくと、関数の名前は確定のままで、中身は施策の置き場になる。開ける用事が増え、開けるたびに、関係ないものを撃てるようになる。今朝のは、それだ。

出来事と、反応を、分けろ。本体に残すのは「失敗したら出来事ごと失敗にしたいもの」だけ。あとは「起きた」と知らせて、受け手に任せる。受け手は名簿に足すだけ。開けなければ、撃てない。

——重い受け手が出てきて、報せを待たせたくなくなったら、channel で別の流れに逃がす手もある。だがそれは、順序も取りこぼしも設計し直す第二段階だ。まずは同期のまま、切り離せ。

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