Featured image of post コードメカニック【Command】一日見積もりが、底なしだった〜ハンドラーに作り付けた仕事を、一個の命令に切り出す〜

コードメカニック【Command】一日見積もりが、底なしだった〜ハンドラーに作り付けた仕事を、一個の命令に切り出す〜

共通関数に抽出しても、引数に http.ResponseWriter を握っている限りHTTPの外からは呼べない。物件登録のロジックを Execute(ctx) error だけで話すCommandに切り出し、HTTPハンドラーからもフィード取り込みワーカーからも、同じ部品を組み立てて実行できるようにした整備記録。

一日見積もりの底

夕方六時を回って、フロアの照明が半分落ちた。残っているのは私を入れて三人。鳴っている電話もないし、警告灯が点いたわけでもない。緊急なんて、どこにもない。どこにもないのに、私は自分の席で、たった一つの関数の前から動けなくなっていた。

今朝のスタンドアップで、私はこう言った。「提携先フィードの一括取り込み、既存の登録処理を呼ぶだけなので、一日で出せます」。

見積もり、一日。……一日? 既存の登録処理を実際に開いた今、その「呼ぶだけ」の底が、思っていたよりずっと深いことに気づきはじめていた。

私は、自社で運営している不動産ポータルのバックエンドを書いている。前職はSIerで、テストを書く文化と、コピペを蛇蝎のごとく嫌う先輩たちの中で育った。だからこれは、ささやかな自慢でもある。この物件登録のAPIは、機能追加のたびに肥えてきたけれど、登録のロジックそのものは、ずっと前に registerProperty という一つの関数へ抜き出してある。同じコードをあちこちに貼ったりしていない。一箇所にまとめてある。几帳面にやってきたつもりだ。

問題の登録ハンドラーは、こうなっている。

 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// agents/code-mechanic/tests/fat-handler-command/before/handler.go
package before

import (
	"encoding/json"
	"fmt"
	"net/http"
)

// Geocodeなどのメソッドを持つ型なら入る接続規格。本物のAPIでもテスト用モックでも同じ口で差せる
type Geocoder interface {
	Geocode(address string) (float64, float64, error)
}

type DB interface {
	Save(name, address string, price int, area, lat, lng float64) error
}

type SearchIndex interface {
	Update(name, address string) error
}

// json:"name" などの構造体タグ: JSONキー名とフィールド名のマッピングをコンパイラに伝える注釈
type PropertyRequest struct {
	Name    string  `json:"name"`
	Address string  `json:"address"`
	Price   int     `json:"price"`
	Area    float64 `json:"area"`
}

type App struct {
	Geocoder Geocoder
	DB       DB
	Index    SearchIndex
}

func NewApp(g Geocoder, db DB, idx SearchIndex) *App {
	return &App{Geocoder: g, DB: db, Index: idx}
}

// (a *App) ポインタレシーバ: a はApp本体へのポインタ。メソッド内でフィールドを読み書きできる
func (a *App) HandlePostProperty(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	var req PropertyRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	a.registerProperty(w, req)
}

func (a *App) registerProperty(w http.ResponseWriter, req PropertyRequest) {
	if req.Name == "" {
		http.Error(w, "name is required", http.StatusBadRequest)
		return
	}
	if req.Address == "" {
		http.Error(w, "address is required", http.StatusBadRequest)
		return
	}
	if req.Price <= 0 {
		http.Error(w, "price must be positive", http.StatusBadRequest)
		return
	}
	if req.Area <= 0 {
		http.Error(w, "area must be positive", http.StatusBadRequest)
		return
	}

	// ジオコーディングは外部API。混雑で失敗することがあるので最大3回まで試す
	var lat, lng float64
	var geoErr error
	for attempt := 1; attempt <= 3; attempt++ {
		lat, lng, geoErr = a.Geocoder.Geocode(req.Address)
		if geoErr == nil {
			break
		}
	}
	if geoErr != nil {
		http.Error(w, fmt.Sprintf("geocoding failed: %v", geoErr), http.StatusInternalServerError)
		return
	}

	if err := a.DB.Save(req.Name, req.Address, req.Price, req.Area, lat, lng); err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	if err := a.Index.Update(req.Name, req.Address); err != nil {
		http.Error(w, fmt.Sprintf("index error: %v", err), http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
	fmt.Fprintf(w, `{"status":"ok"}`)
}

我ながら、悪くない。依存しているデータベースも、住所から緯度経度を引くジオコーディングAPIも、検索インデックスも、ぜんぶ interface——「この形のメソッドを持つ型なら差し込める」という接続規格——で受けている。本物でもテスト用でも、同じ口に差せるようにしてある。登録の中身は registerProperty に一本化されていて、HandlePostProperty はそれを呼ぶだけだ。

なのに、フィードの取り込み処理から registerProperty を呼ぼうとすると、手が止まる。あの関数は、第一引数に w http.ResponseWriter——HTTPの返事を書き込む相手——を要求してくる。取り込み処理はバッチだ。HTTPの返事を書く相手なんて、どこにもいない。

「共通化はしてある。なのに、なぜ呼べない」。声に出してみても、答えは出なかった。

藁にもすがる、というほど切羽詰まってはいなかった。ただ、前に同業の知人が言っていたのを思い出した。コードが本番で動かなくなって誰にも分からなくなったら、出張整備の「親方」と呼ばれる人がいる、と。今日のこれは、動かないわけじゃない。動いているのに、動かせない。半信半疑で、私は連絡を入れた。

親方は、思っていたより早く、静かにフロアへ入ってきた。作業着の上に、革の防護エプロン。金属のツールボックスを提げている。名乗りもしないし、世間話もない。私が「コードを見てもらえますか」と言うより先に、親方はモニタを少しだけ自分のほうへ傾けた。

そして、registerProperty の一行目——引数の w http.ResponseWriter——を、手にした細いドライバーの先で示した。

「この登録、HTTPの外から呼んだことは」。問いというより、確認のようだった。


ケーブルが一本、抜けない

「これから呼ぶんです」と私は答えた。「フィードの一括取り込みから。やり方は、もう書きました」。

正直、私はまだ困っていなかった。困りごとは解決済みだと思っていた。registerProperty は共通化してある。あとは呼ぶだけ。w を要求してくるのが少し邪魔だけれど、テスト用に偽のレスポンス記録係を渡せばいい。Goの標準ライブラリには、そういう道具がある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// agents/code-mechanic/tests/fat-handler-command/before/worker.go
package before

import (
	"fmt"
	"net/http/httptest" // wを埋めるためだけの偽のレスポンス記録係。本物のHTTPサーバなしにhttp.ResponseWriterを用意するテスト用の道具
)

func RunFeedImport(app *App, rows []PropertyRequest) error {
	for i, row := range rows {
		rec := httptest.NewRecorder()
		app.registerProperty(rec, row)
		if rec.Code != 201 {
			return fmt.Errorf("行 %d (%s): 取り込み失敗: %s", i, row.Name, rec.Body.String())
		}
	}
	return nil
}

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 という釘で、運転席に打ち付けてある。釘が刺さったままの箱は、持ち出せない」。

親方がドライバーの先で示した箇所を見つめているうちに、私の脳裏には、今のいびつな配線図が浮かび上がってきた。

Architecture diagram showing Fat Handler coupling problem with http.ResponseWriter

確かにそうだ。registerProperty という箱は、一見独立した共通の部品に見える。だけど、その裏側からは http.ResponseWriter という太い配線が伸びていて、HTTPという運転席の壁板にガッチリとハンダ付けされていた。

それをバッチワーカーという別の台車に載せようとしたから、わざわざバッチワーカーの側に偽物の運転席(httptest.NewRecorder)をこしらえて、配線の先をそこに繋ぎ込むしかなかったのだ。

「共通化したつもりで、私はHTTPの運転席ごと引きずり回していたわけですね」

私の言葉に、親方は無言で、だが深く頷いた。

私は、自分がどこで楽をしたのかを、ようやく思い出していた。「最初は、APIからしか呼ばないと思っていたんです。だから、入力を受けて、登録して、返事を返すところまで、一息に書きました。それで十分だと……」。

「走る場所を決める仕事と、走る仕事の中身は、別だ」。親方はドライバーを置いた。「HTTPに返事をするのは、走る場所の都合。物件を登録するのは、仕事の中身。いまは一つの関数が、両方を握っている」。

そこまで聞いて、私の中でようやく像が結んだ。「分かってきました。registerProperty は、置き場所こそ独立したけど、呼ばれ方がHTTPに縛られたままだった。w を渡せ、と要求してくる。だから、HTTPの外から呼ぶには、偽のHTTPを用意するしかなかった」。

「そうだ」。親方は短く言った。


ctx と error だけで話す部品

「外に持ち出す仕事は」と親方は言った。「運転席の言葉を、やめさせる。受け取るのは ctx だけ。返すのは error だけ。それなら、どの台車にも載る」。

「どの台車にも載る、共通の規格か……」

親方の言葉に引っ張られるように、私の頭の中で、新しい接続図が組み上がっていった。

Architecture diagram showing Command Pattern decoupling HTTP Handler and Batch Worker via Command Interface

そうか。HTTPのことは外側のハンドラーに任せてしまえばいい。登録処理自身は、ただ与えられたデータと道具を使って仕事を片付け、成否(error)を返すだけの「持ち運び可能な部品」になる。

接続規格が Execute(ctx) error という極限まで単純なものになれば、HTTPハンドラーからも、バッチワーカーからも、同じように差し込める。

「仕事を一個の『命令(Command)』という独立したユニットにして、接続口を統一する。だから、どちらからも同じように組み立てて実行できるんですね」

「そうだ。余計なケーブルは一切ない。差して、動かす。それだけだ」

親方はそう言って、キーボードを引き寄せた。

その「どの台車にも載る」やり方には、名前がついていた。

Command(コマンド): 「実行する一単位の仕事」を、必要なデータと手順ごと一つの独立した値(オブジェクト)にまとめ、呼び出し側から「組み立てて実行するだけ」で扱えるようにする設計パターン。

親方はまず、着脱の規格そのものを定義した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// agents/code-mechanic/tests/fat-handler-command/after/command.go
package after

import "context"

// context.Context: タイムアウト/キャンセルの合図を関数から関数へ運ぶGoの標準型
// Execute(ctx) error だけを持つ型は自動でこの規格を満たす(implementsと書く必要はない)
type Command interface {
	Execute(ctx context.Context) error
}

context.Context——ここで初めて出てくる——は、タイムアウトやキャンセルの合図を関数から関数へ運ぶ、Goの標準の型だ。呼び出しの起点が何であれ、必ず一つ用意できる。

「これが、着脱の口(クイックヒッチ)です」と私は確認した。「Execute を持つものなら、何でもここに差さる、と。でも、待ってください。Javaなら implements Command と書いて『この規格を満たします』と宣言しますよね。Goは、どこで宣言するんですか」。

「書かない」。親方は答えた。「Execute(ctx context.Context) error というメソッドを持っていれば、それだけで、この規格を満たしたと見なされる。宣言は要らない。それがGoのインターフェースだ」。

そして親方は、登録という仕事を、その規格に沿った一個の部品へ移した。

 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// agents/code-mechanic/tests/fat-handler-command/after/property_command.go
package after

import (
	"context"
	"errors"
	"fmt"
)

type Geocoder interface {
	Geocode(address string) (float64, float64, error)
}

type DB interface {
	Save(name, address string, price int, area, lat, lng float64) error
}

type SearchIndex interface {
	Update(name, address string) error
}

// あとでハンドラーが「入力不正か」を見分けるための目印エラー
var ErrInvalid = errors.New("invalid property")

type PostPropertyCommand struct {
	Name     string
	Address  string
	Price    int
	Area     float64
	Geocoder Geocoder
	DB       DB
	Index    SearchIndex
}

func (c *PostPropertyCommand) Execute(ctx context.Context) error {
	// 呼び出し元がもう処理を打ち切っていたら、何もせず即座に抜ける入口チェック
	if err := ctx.Err(); err != nil {
		return err
	}

	if c.Name == "" {
		return fmt.Errorf("%w: name is required", ErrInvalid) // %w: エラーをラップしerrors.Isで原因を取り出せる
	}
	if c.Address == "" {
		return fmt.Errorf("%w: address is required", ErrInvalid)
	}
	if c.Price <= 0 {
		return fmt.Errorf("%w: price must be positive", ErrInvalid)
	}
	if c.Area <= 0 {
		return fmt.Errorf("%w: area must be positive", ErrInvalid)
	}

	// ジオコーディングは外部API。混雑で失敗することがあるので最大3回まで試す(Beforeと同一)
	var lat, lng float64
	var geoErr error
	for attempt := 1; attempt <= 3; attempt++ {
		lat, lng, geoErr = c.Geocoder.Geocode(c.Address)
		if geoErr == nil {
			break
		}
	}
	if geoErr != nil {
		return fmt.Errorf("geocoding failed: %w", geoErr)
	}

	if err := c.DB.Save(c.Name, c.Address, c.Price, c.Area, lat, lng); err != nil {
		return fmt.Errorf("db error: %w", err)
	}

	if err := c.Index.Update(c.Name, c.Address); err != nil {
		return fmt.Errorf("index error: %w", err)
	}

	return nil
}

見比べて、私は思わず声が出た。中身は、さっきの registerProperty とほとんどそっくりだ。頭に一行、ctx.Err() でキャンセルの確認——呼び出し元がもう処理を打ち切っていたら、何もせず即座に抜ける入口——が増えているが、それを除けば、バリデーションの四つ、ジオコーディングの三回リトライ、保存、インデックス更新。順番も、回数も同じ。登録のロジックそのものは、何も変えていない。

変わったのは、外側だけだった。w http.ResponseWriter が消えている。失敗したときは http.Error(w, ...) で番号を書く代わりに、error を返すだけ。成功すれば nil を返すだけ。登録に必要なデータ(物件名や住所)と、道具(ジオコーダーやDB)は、命令自身がフィールドとして抱えている。

エラーの返し方には、fmt.Errorf("%w: ...", ErrInvalid) という書き方が使われていた。%w は、エラーを別のエラーで包む(ラップする)ための書式で、こうしておくと、受け取った側が後で「これは入力の不正だったのか」を判定して取り出せる。これが、次のハンドラーで効いてくる。

親方は、肥大化していたハンドラーを組み直した。

 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// agents/code-mechanic/tests/fat-handler-command/after/handler.go
package after

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
)

type PropertyRequest struct {
	Name    string  `json:"name"`
	Address string  `json:"address"`
	Price   int     `json:"price"`
	Area    float64 `json:"area"`
}

type App struct {
	Geocoder Geocoder
	DB       DB
	Index    SearchIndex
}

func NewApp(g Geocoder, db DB, idx SearchIndex) *App {
	return &App{Geocoder: g, DB: db, Index: idx}
}

func (a *App) HandlePostProperty(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	var req PropertyRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	cmd := &PostPropertyCommand{
		Name:     req.Name,
		Address:  req.Address,
		Price:    req.Price,
		Area:     req.Area,
		Geocoder: a.Geocoder,
		DB:       a.DB,
		Index:    a.Index,
	}

	// r.Context(): リクエストが持つcontext(クライアント切断などの合図)を渡す
	if err := cmd.Execute(r.Context()); err != nil {
		if errors.Is(err, ErrInvalid) { // errors.Is: ラップされたエラーに指定のエラーが含まれるか判定
			http.Error(w, err.Error(), http.StatusBadRequest)
		} else {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
		return
	}

	w.WriteHeader(http.StatusCreated)
	fmt.Fprintf(w, `{"status":"ok"}`)
}

ハンドラーは、見違えるほど痩せた。やることは三つだけだ。リクエストをパースして、命令(PostPropertyCommand)を組み立て、Execute を呼ぶ。返ってきた error を見て、HTTPのステータスへ翻訳する。さっき %w で包んでおいたおかげで、errors.Is(err, ErrInvalid) で「入力の不正」だけを 400、それ以外を 500 に振り分けられる。errors.Is は、ラップされたエラーの中に目的のエラーが入っているかを判定してくれる関数だ。

「HTTPの返事をどう返すかは、運転席の仕事だ」と親方は言った。「だから、それはハンドラーに残す。仕事の中身は、命令へ出した」。

最後に、私がつまずいていたフィードの取り込み処理を、親方は差し替えた。

 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
// agents/code-mechanic/tests/fat-handler-command/after/worker.go
package after

import (
	"context" // キャンセルもタイムアウトも持たない空のctx。ワーカーの起点はcontext.Background()
	"fmt"
)

func RunFeedImport(app *App, rows []PropertyRequest) error {
	for i, row := range rows {
		cmd := &PostPropertyCommand{
			Name:     row.Name,
			Address:  row.Address,
			Price:    row.Price,
			Area:     row.Area,
			Geocoder: app.Geocoder,
			DB:       app.DB,
			Index:    app.Index,
		}
		if err := cmd.Execute(context.Background()); err != nil { // context.Background(): キャンセルもタイムアウトも持たない空のctx。ワーカーの起点
			return fmt.Errorf("行 %d (%s): %w", i, row.Name, err)
		}
	}
	return nil
}

httptest が、消えていた。偽のレスポンス記録係も、HTTPの番号を読んで成否を判断するくだりも、まるごと無くなっている。取り込み処理は、命令を組み立てて、Execute を呼ぶだけになった。context.Background() は、キャンセルもタイムアウトも持たない空の ctx で、バッチやワーカーの起点として使う、いわば「白紙の合図」だ。

私は、自分の言葉で確かめたくなった。「そうか……外に出す部品が、ctxerror だけで話すなら、HTTPサーバーからでも、ワーカーからでも、テストからでも、同じように組み立てて Execute を呼べる。呼ばれ方を規格に合わせたから、置き場所を選ばなくなったんだ」。

「それが、部品にするということだ」。親方は言った。

ひとつだけ、気になっていたことを聞いた。「これ、前に勉強した Strategy と、似ているような……」。

「狙いが別だ」。親方は即答した。「Strategy は、やり方を後から差し替える技法。同じ仕事の手順を、付け替える。Command は、仕事そのものを、持ち運べる一個の値にする。差し替えたいのか、持ち出したいのか。要るものが違う」。


載せ替えの効く部品

親方は、テストを順に流した。ハンドラー経由の登録、命令を単体で実行する登録、そしてワーカー経由の取り込み。三つとも、緑のランプを点けた。

1
2
3
4
5
6
7
8
=== RUN   TestHandlePostProperty
--- PASS: TestHandlePostProperty (0.00s)
=== RUN   TestPostPropertyCommand_Execute
--- PASS: TestPostPropertyCommand_Execute (0.00s)
=== RUN   TestRunFeedImport
--- PASS: TestRunFeedImport (0.00s)
PASS
ok  	code-mechanic/fat-handler-command/after	0.358s

真ん中の TestPostPropertyCommand_Execute を、私はしばらく見ていた。これは、HTTPの偽装を一切せずに、登録の仕事だけを直接呼んで検証している。httptest も、偽のリクエストもない。命令を組み立てて、Execute を呼んで、保存されたかを確かめるだけ。業務のロジックが、HTTPから完全に離れたから、こんなに素直なテストになった。

「載せ替えが効く」。親方は、それだけ言った。「これが、部品だ」。

「ワーカーのテストから、httptest が丸ごと消えました」。私は言った。「登録の仕事が、HTTPから独立したからですね。……一日見積もりは、嘘にならずに済みそうです」。

親方はツールボックスの蓋を閉じた。金属の重い音がした。そして、報酬の代わりに、と言った。

「礼はいい。代わりに、約束を一つ」。親方はまっすぐ私を見た。「一度クイックヒッチにした仕事を、二度と運転席に作り付けるな。次に同じ登録を別の場所で使うときも、ハンドラーの中に書き戻すな。命令を組み立てて、差せ。それだけだ」。

「はい」。私はうなずいた。「着脱の口は、錆びさせません」。

親方は、それ以上は何も言わなかった。私の画面で、ワーカーのテストがもう一度緑を出すのを確かめると、ツールボックスを提げて、来たときと同じ静けさでフロアを出ていった。

私は、httptest の消えたワーカーのコードを、もう一度上から下まで読み直した。短かった。短いのに、どこからでも呼べる。明日のスタンドアップで、私はもう一度「一日で出せます」と言える気がした。今度は、底のある一日だ。


整備記録簿

症状と整備の対応表

こんな異音・症状が出たら入れるべき整備(パターン)まだ様子見でいい
共通関数に抽出したのに、別の場所から呼ぶと httptest で偽のリクエストを組む羽目になる 仕事を Execute(ctx) error の Command に切り出し、w/r を境界から追い出す
同じ業務ロジックを、HTTPハンドラーとワーカー(バッチ)の両方から呼びたい Command として実行単位を共通化し、両方から組み立てて実行する
業務ロジックを、HTTPの偽装なしにそのままユニットテストしたい Command の Execute を直接呼ぶ純粋なテストにする
仕事の「手順(アルゴリズム)」を後から差し替えたいだけ それは Strategy の領分。Command ではない

整備手順

  1. type Command interface { Execute(ctx context.Context) error } のように、実行できる仕事の接続規格(インターフェース)を定義する。
  2. HTTPハンドラー(あるいは w を引きずった共通関数)に作り込まれた業務ロジックと、その依存(DB・外部API)を、Command を満たす構造体に移す。依存は具体型でなくインターフェースで受け、本物とテスト用を同じ口で差せるようにする。
  3. Executecontext.Context だけを受け取り、結果は error で返す。http.ResponseWriter*http.Request を引数から消す——これが「HTTPの外から呼べる」ことの条件。
  4. HTTPハンドラーは、リクエストをパースして Command を組み立て、Execute を呼び、返ってきた error をHTTPステータスへ翻訳するだけの薄い受付口にする。HTTPステータスを決める責務は、ハンドラー側に残す。
  5. ワーカー(バッチ)は、同じ Command を組み立てて Execute を呼ぶだけにする。httptest でリクエストを偽装する必要は、もう無い。

親方より

共通関数に抜き出したことを、再利用できる証だと思い込むな。置き場所を変えても、その仕事が w を握っている限り、呼べる場所はHTTPの中だけだ。外に出す仕事は、ctxerror だけで話させろ。そうすれば、ハンドラーにもワーカーにも、テストにも、同じ部品が一個で差さる。クイックヒッチにした口は、そのまま保て。同じ登録を三つ目の場所から呼ぶ日が来ても、命令を一個、組み立てれば済む。

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