Dev Study
← 開発の実務
レッスン 6 / 12·約 9 分·第16期 開発の実務

落ちた場所と、壊れた場所は違う

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. 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 v24.21.0(macOS、2回実行して同一。パスの前半を …/l6/ に短縮)コマンド出力
$ node main.mjs
file:///…/l6/view.mjs:2
return `${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 なのは正しいのか。住所の無い利用者がありうるなら正しく、ありえないなら汚染です。比べる基準が書かれていない場所では、そこが壊れているとも壊れていないとも言えません。

言語を変えても同じです。

Python 3.14.6(2回実行して同一。パスを …/l6/ に短縮)。None を入れたのは8行目コマンド出力
$ python3 app.py
Traceback (most recent call last):
File "…/l6/app.py", line 12, in <module>
print(apply(price, disc))
~~~~~^^^^^^^^^^^^^
File "…/l6/app.py", line 5, in apply
return 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` として返し、例外は出ませんでした。この回の見方で正確なのはどれですか。

⇄ 2つを見比べる
具体例 A

入れる時点で確かめない(main.mjs)

undefined はキャッシュを通って運ばれ、読んだ view.mjs で初めて落ちます。スタックトレースは読んだ側しか指しません。

実機のスタックトレースに出た自分のファイル表
行 役割
view.mjs:2:39 値を読んだ
main.mjs:10:15 読む関数を呼んだ
(load.mjs は出ない) 値を入れた
具体例 B

入れる時点で確かめる(main-checked.mjs)

toUser が address の有無を確かめ、無ければその場で TypeError を投げます。落ちる場所が入れる場所と重なります。

実機のスタックトレースに出た自分のファイル表
行 役割
load-checked.mjs:3:36 値を入れる前に確かめた
main-checked.mjs:6:8 入れる関数を呼んだ

2つの版で、欠陥(綴り違い)は同じです。では何が違い、それは原因探しの手間にどう効くでしょうか。

「汚染された状態が、例外になるまでに通った距離」を両方で数えてみてください。

この章のまとめ

  • debuggingbook は 欠陥(コード) と 故障(外に見える) を分け、その間に 汚染された状態 を置く。原典は「バグ」を欠陥の側に当てる。
  • スタックトレースが指すのは 読んだ行と、今の呼び出しの連なり。実機では、値を入れた行は一度も出なかった。
  • 原因へは 落ちた場所から逆向きに 進み、正しい状態が誤った状態に変わる一歩を探す。これは各時点の状態と「あるべき状態」の比較。
  • 中間の状態には基準が必ずしも無い。基準が無ければ、壊れているとも壊れていないとも言えない。
  • 壊れた場所が 前提 のこともある。JSON の数値は、どの実装も壊れていないのに食い違う。
  • 入れる時点で確かめると、落ちる場所が壊れた場所に近づく。欠陥は直らないが、たどる距離が縮む。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

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.`。
スタックトレース
例外が起きた瞬間に続いていた呼び出しの連なり。すでに戻った関数や、非同期で切れた呼び出し元は含まない。