一日見積もりの底
夕方六時を回って、フロアの照明が半分落ちた。残っているのは私を入れて三人。鳴っている電話もないし、警告灯が点いたわけでもない。緊急なんて、どこにもない。どこにもないのに、私は自分の席で、たった一つの関数の前から動けなくなっていた。
今朝のスタンドアップで、私はこう言った。「提携先フィードの一括取り込み、既存の登録処理を呼ぶだけなので、一日で出せます」。
見積もり、一日。……一日? 既存の登録処理を実際に開いた今、その「呼ぶだけ」の底が、思っていたよりずっと深いことに気づきはじめていた。
私は、自社で運営している不動産ポータルのバックエンドを書いている。前職はSIerで、テストを書く文化と、コピペを蛇蝎のごとく嫌う先輩たちの中で育った。だからこれは、ささやかな自慢でもある。この物件登録のAPIは、機能追加のたびに肥えてきたけれど、登録のロジックそのものは、ずっと前に registerProperty という一つの関数へ抜き出してある。同じコードをあちこちに貼ったりしていない。一箇所にまとめてある。几帳面にやってきたつもりだ。
問題の登録ハンドラーは、こうなっている。
| |
我ながら、悪くない。依存しているデータベースも、住所から緯度経度を引くジオコーディングAPIも、検索インデックスも、ぜんぶ interface——「この形のメソッドを持つ型なら差し込める」という接続規格——で受けている。本物でもテスト用でも、同じ口に差せるようにしてある。登録の中身は registerProperty に一本化されていて、HandlePostProperty はそれを呼ぶだけだ。
なのに、フィードの取り込み処理から registerProperty を呼ぼうとすると、手が止まる。あの関数は、第一引数に w http.ResponseWriter——HTTPの返事を書き込む相手——を要求してくる。取り込み処理はバッチだ。HTTPの返事を書く相手なんて、どこにもいない。
「共通化はしてある。なのに、なぜ呼べない」。声に出してみても、答えは出なかった。
藁にもすがる、というほど切羽詰まってはいなかった。ただ、前に同業の知人が言っていたのを思い出した。コードが本番で動かなくなって誰にも分からなくなったら、出張整備の「親方」と呼ばれる人がいる、と。今日のこれは、動かないわけじゃない。動いているのに、動かせない。半信半疑で、私は連絡を入れた。
親方は、思っていたより早く、静かにフロアへ入ってきた。作業着の上に、革の防護エプロン。金属のツールボックスを提げている。名乗りもしないし、世間話もない。私が「コードを見てもらえますか」と言うより先に、親方はモニタを少しだけ自分のほうへ傾けた。
そして、registerProperty の一行目——引数の w http.ResponseWriter——を、手にした細いドライバーの先で示した。
「この登録、HTTPの外から呼んだことは」。問いというより、確認のようだった。
ケーブルが一本、抜けない
「これから呼ぶんです」と私は答えた。「フィードの一括取り込みから。やり方は、もう書きました」。
正直、私はまだ困っていなかった。困りごとは解決済みだと思っていた。registerProperty は共通化してある。あとは呼ぶだけ。w を要求してくるのが少し邪魔だけれど、テスト用に偽のレスポンス記録係を渡せばいい。Goの標準ライブラリには、そういう道具がある。
| |
httptest.NewRecorder() は、本物のHTTPサーバを立てなくても http.ResponseWriter の役を務めてくれる、テスト用の記録係だ。それを w の穴に差し込んで、registerProperty を呼ぶ。返事の代わりに、記録係が受け取ったステータスコード(rec.Code)を見て、201(作成成功)でなければ取り込み失敗とみなす。
テストを走らせた。緑。通った。
「ほら、動きます」。私は少しほっとして言った。「これで、フィードも取り込めます」。
——けれど、自分で口にした言葉が、耳に残って引っかかった。動いた。確かに動いた。でも、私がいま書いたのは「取り込み処理」だろうか。よく見ると、これは取り込み処理に、HTTPのフリをさせている処理だ。バッチなのに、偽のリクエストの記録係を毎行ぶら下げて、返ってきたHTTPの番号を読んで成否を判断している。
親方は、緑になった画面を一瞥しただけだった。そして、さっきと同じ場所——registerProperty の引数 w ——を、もう一度ドライバーの先で示した。
「走る。だが、これは取り込みじゃない」。親方の声は、責める調子ではなかった。ただ事実を置くように言った。「運転席ごと偽物を組んで、その中で仕事をさせているだけだ」。
私は黙った。
「共通化したのは、仕事の置き場所だ」。親方は続けた。「仕事が喋る言葉は、まだHTTPのままだ。だから、HTTPの外に出すたびに、偽の運転席が要る」。
言われて、思わず次の手が口をついた。「……いっそ、ワーカー専用に、w を取らない版の registerProperty をもう一個——」。そこまで言って、自分で止まった。それはコピペだ。登録のロジックを二箇所に増やして、片方を直し忘れる未来を、自分の手で作る。私が、いちばん避けたかったやつだ。
口の中が乾いた。共通化したつもりだった関数は、共通化しきれていなかった。
作り付けの登録処理
「共通関数に抜き出したのは」と親方は言った。「半分は正しい。だが、半分だ」。
「半分……?」。私はその言い方に、虚を突かれた。全部やったつもりだった。
親方はドライバーの先を、registerProperty の中へ滑らせていった。バリデーションが失敗したときの http.Error(w, ...)。最後の w.WriteHeader(http.StatusCreated)。一つずつ、指していく。
「中で、HTTPのステータスを決めている。HTTPの記録係に書いている。物件を登録するという仕事の中身と、HTTPへの返事の仕方が、溶けて一体になっている」。
これが、その状態の名前だった。
Fat Handler(肥大化ハンドラー): Webの受付口(HTTPハンドラー)に、入力検証・業務ロジック・外部通信・レスポンス整形といった異なる関心事がすべて作り込まれ、HTTPの外から再利用したりテストしたりできなくなった状態。
「関数に切り出したから、てっきり……」と私は言いかけた。
「箱の形には、くり抜いた」。親方はうなずいた。「だが、箱はまだ壁から外れていない。w という釘で、運転席に打ち付けてある。釘が刺さったままの箱は、持ち出せない」。
親方がドライバーの先で示した箇所を見つめているうちに、私の脳裏には、今のいびつな配線図が浮かび上がってきた。

確かにそうだ。registerProperty という箱は、一見独立した共通の部品に見える。だけど、その裏側からは http.ResponseWriter という太い配線が伸びていて、HTTPという運転席の壁板にガッチリとハンダ付けされていた。
それをバッチワーカーという別の台車に載せようとしたから、わざわざバッチワーカーの側に偽物の運転席(httptest.NewRecorder)をこしらえて、配線の先をそこに繋ぎ込むしかなかったのだ。
「共通化したつもりで、私はHTTPの運転席ごと引きずり回していたわけですね」
私の言葉に、親方は無言で、だが深く頷いた。
私は、自分がどこで楽をしたのかを、ようやく思い出していた。「最初は、APIからしか呼ばないと思っていたんです。だから、入力を受けて、登録して、返事を返すところまで、一息に書きました。それで十分だと……」。
「走る場所を決める仕事と、走る仕事の中身は、別だ」。親方はドライバーを置いた。「HTTPに返事をするのは、走る場所の都合。物件を登録するのは、仕事の中身。いまは一つの関数が、両方を握っている」。
そこまで聞いて、私の中でようやく像が結んだ。「分かってきました。registerProperty は、置き場所こそ独立したけど、呼ばれ方がHTTPに縛られたままだった。w を渡せ、と要求してくる。だから、HTTPの外から呼ぶには、偽のHTTPを用意するしかなかった」。
「そうだ」。親方は短く言った。
ctx と error だけで話す部品
「外に持ち出す仕事は」と親方は言った。「運転席の言葉を、やめさせる。受け取るのは ctx だけ。返すのは error だけ。それなら、どの台車にも載る」。
「どの台車にも載る、共通の規格か……」
親方の言葉に引っ張られるように、私の頭の中で、新しい接続図が組み上がっていった。

そうか。HTTPのことは外側のハンドラーに任せてしまえばいい。登録処理自身は、ただ与えられたデータと道具を使って仕事を片付け、成否(error)を返すだけの「持ち運び可能な部品」になる。
接続規格が Execute(ctx) error という極限まで単純なものになれば、HTTPハンドラーからも、バッチワーカーからも、同じように差し込める。
「仕事を一個の『命令(Command)』という独立したユニットにして、接続口を統一する。だから、どちらからも同じように組み立てて実行できるんですね」
「そうだ。余計なケーブルは一切ない。差して、動かす。それだけだ」
親方はそう言って、キーボードを引き寄せた。
その「どの台車にも載る」やり方には、名前がついていた。
Command(コマンド): 「実行する一単位の仕事」を、必要なデータと手順ごと一つの独立した値(オブジェクト)にまとめ、呼び出し側から「組み立てて実行するだけ」で扱えるようにする設計パターン。
親方はまず、着脱の規格そのものを定義した。
| |
context.Context——ここで初めて出てくる——は、タイムアウトやキャンセルの合図を関数から関数へ運ぶ、Goの標準の型だ。呼び出しの起点が何であれ、必ず一つ用意できる。
「これが、着脱の口(クイックヒッチ)です」と私は確認した。「Execute を持つものなら、何でもここに差さる、と。でも、待ってください。Javaなら implements Command と書いて『この規格を満たします』と宣言しますよね。Goは、どこで宣言するんですか」。
「書かない」。親方は答えた。「Execute(ctx context.Context) error というメソッドを持っていれば、それだけで、この規格を満たしたと見なされる。宣言は要らない。それがGoのインターフェースだ」。
そして親方は、登録という仕事を、その規格に沿った一個の部品へ移した。
| |
見比べて、私は思わず声が出た。中身は、さっきの registerProperty とほとんどそっくりだ。頭に一行、ctx.Err() でキャンセルの確認——呼び出し元がもう処理を打ち切っていたら、何もせず即座に抜ける入口——が増えているが、それを除けば、バリデーションの四つ、ジオコーディングの三回リトライ、保存、インデックス更新。順番も、回数も同じ。登録のロジックそのものは、何も変えていない。
変わったのは、外側だけだった。w http.ResponseWriter が消えている。失敗したときは http.Error(w, ...) で番号を書く代わりに、error を返すだけ。成功すれば nil を返すだけ。登録に必要なデータ(物件名や住所)と、道具(ジオコーダーやDB)は、命令自身がフィールドとして抱えている。
エラーの返し方には、fmt.Errorf("%w: ...", ErrInvalid) という書き方が使われていた。%w は、エラーを別のエラーで包む(ラップする)ための書式で、こうしておくと、受け取った側が後で「これは入力の不正だったのか」を判定して取り出せる。これが、次のハンドラーで効いてくる。
親方は、肥大化していたハンドラーを組み直した。
| |
ハンドラーは、見違えるほど痩せた。やることは三つだけだ。リクエストをパースして、命令(PostPropertyCommand)を組み立て、Execute を呼ぶ。返ってきた error を見て、HTTPのステータスへ翻訳する。さっき %w で包んでおいたおかげで、errors.Is(err, ErrInvalid) で「入力の不正」だけを 400、それ以外を 500 に振り分けられる。errors.Is は、ラップされたエラーの中に目的のエラーが入っているかを判定してくれる関数だ。
「HTTPの返事をどう返すかは、運転席の仕事だ」と親方は言った。「だから、それはハンドラーに残す。仕事の中身は、命令へ出した」。
最後に、私がつまずいていたフィードの取り込み処理を、親方は差し替えた。
| |
httptest が、消えていた。偽のレスポンス記録係も、HTTPの番号を読んで成否を判断するくだりも、まるごと無くなっている。取り込み処理は、命令を組み立てて、Execute を呼ぶだけになった。context.Background() は、キャンセルもタイムアウトも持たない空の ctx で、バッチやワーカーの起点として使う、いわば「白紙の合図」だ。
私は、自分の言葉で確かめたくなった。「そうか……外に出す部品が、ctx と error だけで話すなら、HTTPサーバーからでも、ワーカーからでも、テストからでも、同じように組み立てて Execute を呼べる。呼ばれ方を規格に合わせたから、置き場所を選ばなくなったんだ」。
「それが、部品にするということだ」。親方は言った。
ひとつだけ、気になっていたことを聞いた。「これ、前に勉強した Strategy と、似ているような……」。
「狙いが別だ」。親方は即答した。「Strategy は、やり方を後から差し替える技法。同じ仕事の手順を、付け替える。Command は、仕事そのものを、持ち運べる一個の値にする。差し替えたいのか、持ち出したいのか。要るものが違う」。
載せ替えの効く部品
親方は、テストを順に流した。ハンドラー経由の登録、命令を単体で実行する登録、そしてワーカー経由の取り込み。三つとも、緑のランプを点けた。
| |
真ん中の TestPostPropertyCommand_Execute を、私はしばらく見ていた。これは、HTTPの偽装を一切せずに、登録の仕事だけを直接呼んで検証している。httptest も、偽のリクエストもない。命令を組み立てて、Execute を呼んで、保存されたかを確かめるだけ。業務のロジックが、HTTPから完全に離れたから、こんなに素直なテストになった。
「載せ替えが効く」。親方は、それだけ言った。「これが、部品だ」。
「ワーカーのテストから、httptest が丸ごと消えました」。私は言った。「登録の仕事が、HTTPから独立したからですね。……一日見積もりは、嘘にならずに済みそうです」。
親方はツールボックスの蓋を閉じた。金属の重い音がした。そして、報酬の代わりに、と言った。
「礼はいい。代わりに、約束を一つ」。親方はまっすぐ私を見た。「一度クイックヒッチにした仕事を、二度と運転席に作り付けるな。次に同じ登録を別の場所で使うときも、ハンドラーの中に書き戻すな。命令を組み立てて、差せ。それだけだ」。
「はい」。私はうなずいた。「着脱の口は、錆びさせません」。
親方は、それ以上は何も言わなかった。私の画面で、ワーカーのテストがもう一度緑を出すのを確かめると、ツールボックスを提げて、来たときと同じ静けさでフロアを出ていった。
私は、httptest の消えたワーカーのコードを、もう一度上から下まで読み直した。短かった。短いのに、どこからでも呼べる。明日のスタンドアップで、私はもう一度「一日で出せます」と言える気がした。今度は、底のある一日だ。
整備記録簿
症状と整備の対応表
| こんな異音・症状が出たら | 入れるべき整備(パターン) | まだ様子見でいい |
|---|---|---|
共通関数に抽出したのに、別の場所から呼ぶと httptest で偽のリクエストを組む羽目になる | ✓ 仕事を Execute(ctx) error の Command に切り出し、w/r を境界から追い出す | |
| 同じ業務ロジックを、HTTPハンドラーとワーカー(バッチ)の両方から呼びたい | ✓ Command として実行単位を共通化し、両方から組み立てて実行する | |
| 業務ロジックを、HTTPの偽装なしにそのままユニットテストしたい | ✓ Command の Execute を直接呼ぶ純粋なテストにする | |
| 仕事の「手順(アルゴリズム)」を後から差し替えたいだけ | ✓ それは Strategy の領分。Command ではない |
整備手順
type Command interface { Execute(ctx context.Context) error }のように、実行できる仕事の接続規格(インターフェース)を定義する。- HTTPハンドラー(あるいは
wを引きずった共通関数)に作り込まれた業務ロジックと、その依存(DB・外部API)を、Command を満たす構造体に移す。依存は具体型でなくインターフェースで受け、本物とテスト用を同じ口で差せるようにする。 Executeはcontext.Contextだけを受け取り、結果はerrorで返す。http.ResponseWriter・*http.Requestを引数から消す——これが「HTTPの外から呼べる」ことの条件。- HTTPハンドラーは、リクエストをパースして Command を組み立て、
Executeを呼び、返ってきたerrorをHTTPステータスへ翻訳するだけの薄い受付口にする。HTTPステータスを決める責務は、ハンドラー側に残す。 - ワーカー(バッチ)は、同じ Command を組み立てて
Executeを呼ぶだけにする。httptestでリクエストを偽装する必要は、もう無い。
親方より
共通関数に抜き出したことを、再利用できる証だと思い込むな。置き場所を変えても、その仕事が w を握っている限り、呼べる場所はHTTPの中だけだ。外に出す仕事は、ctx と error だけで話させろ。そうすれば、ハンドラーにもワーカーにも、テストにも、同じ部品が一個で差さる。クイックヒッチにした口は、そのまま保て。同じ登録を三つ目の場所から呼ぶ日が来ても、命令を一個、組み立てれば済む。
