外部から集めたデータを既存のレコードへ反映する処理を、取得と適用の2コマンドに分けている。取得はネットワークを叩くので失敗しうる。適用は手元のファイルを書くだけなので確実に成功する。分けておけば、取得だけやり直せる。
設計としては素直である。ただしこの形には、取得も適用も成功したのに何も起きない状態がある。
アニメ海外の反応まとめ の更新処理で実際に起きた。掲示板から実在の投稿を52件取得し、適用コマンドを通し、結果は次のようになった。
1 2 3
| applied: 0 skipped: 0 errors: []
|
エラーは無い。スキップも無い。52件はどこへも行かなかった。
原因は「写像先が無い」
適用コマンドは、取得したデータを既存の episodes[] の該当する話数へ書き込む。ところが取得したのは、まだレコードとして作られていない話数だった。
- 既存データ: 第1話〜第6話
- 取得したもの: 第7話の投稿52件
- 適用の動き:
episodes から第7話を探す → 無い → 何もしない
「無いものには書けない」という動きそのものは正しい。問題は、その結果が applied: 0 としか表現されなかったことである。0件は「入れるものが無かった」でも起こる。2つの違う状態が同じ出力になっていた。
skipped にも積まれていないのは、スキップの判定がレコード側から始まるためである。既存の話を1件ずつ見て、対応する取得データがあるかを調べる。存在しない話は、そもそもループに入らない。
最小の再現
apply.js として保存する。Node 18 以降で動く。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| "use strict";
function apply(record, incoming) { let applied = 0; let skipped = 0; for (const ep of record.episodes) { const hit = incoming.find((x) => x.num === ep.num); if (!hit) continue; if (ep.sourced) { skipped++; continue; } ep.items = hit.items; ep.sourced = true; applied++; } return { applied, skipped }; }
module.exports = { apply };
|
呼んでみる。
1 2 3 4 5 6 7
| const { apply } = require("./apply");
const record = { episodes: [{ num: 1, sourced: true }, { num: 2, sourced: true }] }; const incoming = [{ num: 7, items: new Array(52).fill("post") }];
console.log(apply(record, incoming));
|
52件渡して { applied: 0, skipped: 0 } である。呼び出し側がこの戻り値を見ても、何が起きたか分からない。
直し方: 写せなかった入力を数える
レコード側だけを見ているのが原因なので、入力側からも突き合わせる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| "use strict";
function apply(record, incoming) { const byNum = new Map(record.episodes.map((ep) => [ep.num, ep])); let applied = 0; let skipped = 0; const unknown = [];
for (const x of incoming) { const ep = byNum.get(x.num); if (!ep) { unknown.push(x.num); continue; } if (ep.sourced) { skipped++; continue; } ep.items = x.items; ep.sourced = true; applied++; } return { applied, skipped, unknown }; }
module.exports = { apply };
|
ループを入力側から回すように変えただけである。これで、写せなかった入力が unknown に残る。
終了コードも分ける。「入れるものが無かった」と「入れる先が無かった」は、呼び出し側の対処が違うからである。
1 2 3 4 5 6 7
| const r = apply(record, incoming); if (r.unknown.length) { console.error(`写像先の無い話数: ${r.unknown.join(", ")}`); console.error("先に雛形を作ってから適用し直すこと"); process.exit(4); } if (r.applied === 0) process.exit(3);
|
退行ガードを置く。
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
| "use strict"; const { test } = require("node:test"); const assert = require("node:assert/strict"); const { apply } = require("./apply");
test("写像先が無い入力は unknown に出る", () => { const record = { episodes: [{ num: 1, sourced: true }] }; const r = apply(record, [{ num: 7, items: ["a"] }]); assert.deepEqual(r.unknown, [7]); assert.equal(r.applied, 0); });
test("写像先があれば適用される", () => { const record = { episodes: [{ num: 7, sourced: false }] }; const r = apply(record, [{ num: 7, items: ["a", "b"] }]); assert.equal(r.applied, 1); assert.deepEqual(r.unknown, []); assert.deepEqual(record.episodes[0].items, ["a", "b"]); });
test("既に埋まっている話は skipped になる", () => { const record = { episodes: [{ num: 7, sourced: true }] }; const r = apply(record, [{ num: 7, items: ["a"] }]); assert.equal(r.skipped, 1); assert.equal(r.applied, 0); });
|
運用側の順序も変える
コードを直しても、正しい順序を知らないと使えない。この処理の正しい順序は次のとおりである。
- 対象の話がレコードに存在するかを確かめる
- 無ければ雛形を作る(レコードの行を先に用意する)
- 取得する
- 適用する
2を飛ばすと4が空振りする。手順書側にもこの順序を書いておく必要がある。実際、同じ失敗を繰り返さないための対処は「exit code を分ける」と「手順に2を明記する」の両方だった。
一般化
取得と適用を分けた設計では、両者のあいだに「対象の存在」という前提が挟まる。この前提は片方のコマンドだけを見ていると見えない。
チェックすべきことは1つである。
適用処理は、渡された入力のうち処理しなかったものを数えているか
数えていなければ、入力が全部捨てられても 0件・エラーなし・終了コード0 で終わる。ログを見返しても、何も起きなかった日と区別が付かない。