- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存
前回は、直す前に「壊れている」と判定できる手順を作りました。今回は、その判定で落ちた場所が、直すべき場所とは限らないことを見ます。
まず結論
このコースの芯は「『壊れていない』は、何と比べたかでしか言えない」でした。今回比べるのは、症状(外に見えた誤り)と原因(コードの誤り)です。
スタックトレースが指すのは、誤った値を読んだ行と、今そこに至っている呼び出しの連なりです。誤った値を入れた行は、実機では一度も出てきませんでした。原因へは、落ちた場所から時間を逆向きにたどり、正しい状態が誤った状態に変わった一歩を探して近づきます。
`load.mjs` で API 応答のキーを `json.adress` と綴り間違え、`undefined` の入ったオブジェクトをキャッシュに入れました。後で `setTimeout` のコールバックから `view.mjs` が `user.address.city` を読みます。Node のスタックトレースに出るファイル名と行はどれでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
外に見えた誤りと、コードの誤りのあいだ
前回は、直す前に「壊れている」と判定できる手順を作りました。判定で赤になった場所は分かります。では、そこを直せばよいのでしょうか。
debuggingbook は、ふだん「バグ」とひとまとめに呼ぶものを段に分けます。
A defect is an error in the program code. Also called bug.
A failure is an externally visible error in the program behavior. Also called malfunction.
間にもう1段あります。実行中の状態に入り込んだ誤りで、原文は infection とも呼びます。この回では3つを次の日本語で呼びます。
- 欠陥: コードに書かれた誤り(原典は「バグ」をこちらに当てる)
- 汚染された状態: 変数やオブジェクトの中に入った誤った値
- 故障: 外から見える誤り。例外、誤った表示、誤った応答
欠陥が実行されると状態が汚染され、汚染は実行に沿って別の変数・別の関数へ伝わり、どこかで初めて外に見えます。外に見えた場所が、落ちた場所です。 欠陥のある場所ではありません。
- ・原典は、すべての欠陥が故障を起こすわけではないとも書く
- ・たどる向きは下から上。落ちた場所から時間を逆向きに進む
スタックトレースに出てこない行
小さな3段を作りました。API 応答をアプリの形に写し、キャッシュに入れ、後で表示します。
// load.mjs(全文)
// API の応答をアプリの形に写す
export function toUser(json) {
return {
name: json.name,
address: json.adress, // 綴り違い。ここで undefined が入る(例外は出ない)
};
}
// view.mjs
export function label(user) {
return `${user.name}(${user.address.city})`; // ここで初めて落ちる
}
// main.mjs(抜粋)
put(1, toUser(response)); // 値が入るのはここ
setTimeout(() => {
console.log(label(get(1))); // 落ちるのはここ
}, 10);
$ node main.mjsfile:///…/l6/view.mjs:2return `${user.name}(${user.address.city})`; // ここで初めて落ちる^TypeError: Cannot read properties of undefined (reading 'city')at label (file:///…/l6/view.mjs:2:39)at Timeout._onTimeout (file:///…/l6/main.mjs:10:15)at listOnTimeout (node:internal/timers:685:17)at process.processTimers (node:internal/timers:618:7)Node.js v24.21.0
自分のファイルは view.mjs:2:39(読んだ行)と main.mjs:10:15(呼んだ行)だけです。綴りを間違えた load.mjs:5 も、キャッシュに入れた main.mjs:6 も、一度も出ません。
スタックトレースが持つのは、落ちた瞬間に続いていた呼び出しの連なりです。load.mjs:5 が出ないのは、toUser が undefined を入れてすでに正常に戻っているからです。さらに setTimeout を挟んだので、main.mjs:6 を含むモジュール最上位の呼び出しも残りません。確かめた版では最上位の main-checked.mjs:6:8 が出ます(確認したのは setTimeout のコールバックの場合だけで、await の場合は確かめていません)。
Angular コースの「エラーメッセージの解剖 — NGコードとスタックトレース」は、自分のファイル名が出ている行から調査を始めると書いています。その通りです。この回が足すのは、始めた場所から逆向きに進むことです。
上の実機で、綴りを間違えた `load.mjs:5` がスタックトレースに出なかった理由として正確なものはどれですか。
逆向きにたどるとき、何と比べているのか
debuggingbook はたどり方をこう書きます。
We start from the very end of the execution and then gradually progress backwards in time
探すのは、正しい状態が入って誤った状態が出る一歩です。これは比較です。各時点の状態を「ここではこうあるべき」と比べ、一致から不一致に変わる境目を探しています。
同じ節は難しさも挙げます。入力と出力には仕様があっても、中間結果には必ずしも無い。user.address が undefined なのは正しいのか。住所の無い利用者がありうるなら正しく、ありえないなら汚染です。比べる基準が書かれていない場所では、そこが壊れているとも壊れていないとも言えません。
言語を変えても同じです。
$ python3 app.pyTraceback (most recent call last):File "…/l6/app.py", line 12, in <module>print(apply(price, disc))~~~~~^^^^^^^^^^^^^File "…/l6/app.py", line 5, in applyreturn price * (1 - discount["rate"]) # ここで初めて落ちる~~~~~~~~^^^^^^^^TypeError: 'NoneType' object is not subscriptable
8行目で割引コードを "SPRNG" と打ち間違え、table.get(code) が例外を出さずに None を返しました。トレースバックに出るのは12行目と5行目です。
欠陥が、コードのどこにも無い場合
第7期『JSONとAPI — ブラウザ以外の相手に返す』では、Node が JSON の 9007199254740993 を 9007199254740992 として読みました。例外は出ません。
あの回は「どちらも壊れていません」と書いています。RFC 8259 は数値の精度を規定しておらず、Node も Python も準拠しています。
それでも食い違いは起きます。値が変わるのは受け取る側の数値型に写した瞬間で、それが問題として見えるのは、後でその id を比べたり表示したりするときです。落ちた場所から逆向きにたどると、パースの行に行き着きます。しかしその行は仕様どおりに動いています。
ここでは、比べる基準の側が誤っていました。「JSON で送れば同じ値が届く」という前提が、範囲の外では成り立たないのです。壊れた場所が、コードの行ではなく前提であることがあります。 逆向きにたどるときの比較の基準は、自分で書いた期待であり、その期待自体を疑う必要があります。
Node の `JSON.parse` が `9007199254740993` を `9007199254740992` として返し、例外は出ませんでした。この回の見方で正確なのはどれですか。
入れる時点で確かめない(main.mjs)
undefined はキャッシュを通って運ばれ、読んだ view.mjs で初めて落ちます。スタックトレースは読んだ側しか指しません。
行 役割view.mjs:2:39 値を読んだmain.mjs:10:15 読む関数を呼んだ(load.mjs は出ない) 値を入れた
入れる時点で確かめる(main-checked.mjs)
toUser が address の有無を確かめ、無ければその場で TypeError を投げます。落ちる場所が入れる場所と重なります。
行 役割load-checked.mjs:3:36 値を入れる前に確かめたmain-checked.mjs:6:8 入れる関数を呼んだ
2つの版で、欠陥(綴り違い)は同じです。では何が違い、それは原因探しの手間にどう効くでしょうか。
「汚染された状態が、例外になるまでに通った距離」を両方で数えてみてください。
この章のまとめ
- debuggingbook は 欠陥(コード) と 故障(外に見える) を分け、その間に 汚染された状態 を置く。原典は「バグ」を欠陥の側に当てる。
- スタックトレースが指すのは 読んだ行と、今の呼び出しの連なり。実機では、値を入れた行は一度も出なかった。
- 原因へは 落ちた場所から逆向きに 進み、正しい状態が誤った状態に変わる一歩を探す。これは各時点の状態と「あるべき状態」の比較。
- 中間の状態には基準が必ずしも無い。基準が無ければ、壊れているとも壊れていないとも言えない。
- 壊れた場所が 前提 のこともある。JSON の数値は、どの実装も壊れていないのに食い違う。
- 入れる時点で確かめると、落ちる場所が壊れた場所に近づく。欠陥は直らないが、たどる距離が縮む。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
debuggingbook の用語で、`A failure is an externally visible error in the program behavior.` と定義されるものに当たるのはどれですか。
用語集(4語)
- 欠陥
- 本教材での defect の訳。コードに書かれた誤り。debuggingbook は `A defect is an error in the program code. Also called bug.` と定義する。
- 汚染された状態
- 本教材での訳語。実行中の状態(変数・オブジェクトの中身)に入り込んだ誤り。debuggingbook は infection とも呼び、実行に沿って伝わると書く。
- 故障
- 本教材での failure の訳。外から見える振る舞いの誤り。例外・誤った表示・誤った応答など。原文は `Also called malfunction.`。
- スタックトレース
- 例外が起きた瞬間に続いていた呼び出しの連なり。すでに戻った関数や、非同期で切れた呼び出し元は含まない。