0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSONを台帳にしたら本文の引用符で全履歴が消えた — 読み込み失敗を空配列で握りつぶすな

0
Posted at

チャットの履歴を1つのJSONファイルに保存する、よくある構成で履歴を全消失させました。原因はコードのバグではなく、壊れたデータの扱い方という設計判断の誤りです。同じ形のコードは大量にあると思うので共有します。

構成

  • messages.json に配列で全メッセージを保存
  • 追記は「全部読む → 配列に push → 全部書く」
  • 読み込みは JSON.parse
function readMessages() {
  try { return JSON.parse(fs.readFileSync(MSG_FILE, "utf8")); }
  catch { return []; }   // ← これが致命傷だった
}

function writeMessages(list) {
  fs.writeFileSync(MSG_FILE, JSON.stringify(list, null, 2), "utf8");
}

catch { return [] } は一見親切です。ファイルがまだ無い初回起動でも落ちません。

何が起きたか

私はプログラムからメッセージを追記する際、本文にエスケープしていない二重引用符を混ぜてしまいました。これで messages.json が壊れます。

その後の流れが問題でした。

  1. readMessages() が JSON.parse に失敗する
  2. catch が握りつぶして [] を返す
  3. 呼び出し側は「メッセージは0件」と判断し、新しい1件だけを push する
  4. writeMessages()その1件だけでファイルを上書きする

結果、それまでの全履歴が消え、IDが1から振り直された状態になりました。壊れたのは1文字なのに、失われたのは全データです。

何が設計として間違っていたか

「読めなかった」には2種類あります。

状況 正しい振る舞い
ファイルが存在しない(初回起動) 空配列で開始してよい
ファイルは在るが壊れている 書き込みを中止すべき

このコードは両方を同じ [] に潰していました。**後者は「データが無い」ではなく「データを読めていない」**であり、その状態で上書きするのは、内容を確認せずに書類を捨てる行為に相当します。

修正

読み込み失敗を2つに分け、壊れている場合は退避してから例外を投げるようにしました。

function readMessages({ strict = false } = {}) {
  let raw;
  try {
    raw = fs.readFileSync(MSG_FILE, "utf8");
  } catch {
    return []; // ファイル自体が無い初回のみ、空で開始してよい
  }

  try {
    return JSON.parse(raw);
  } catch (e) {
    // 壊れた中身は必ず退避してから扱う(捨てない)
    const bak = MSG_FILE + ".corrupt-" + new Date().toISOString().replace(/[:.]/g, "-");
    try { fs.writeFileSync(bak, raw, "utf8"); } catch {}
    console.error("messages.json が壊れています。退避先: " + bak);
    if (strict) throw e;   // 書き込み経路では中止する
    return [];
  }
}

呼び出し側では、書き込み前の読み込みだけ strict にします。

// 追記するとき: 壊れていたら書かずに中止する
const list = readMessages({ strict: true });
list.push(msg);
writeMessages(list);

あわせて、書き込み自体も途中で壊れないようにしました。書き込み中にプロセスが落ちると、中途半端なJSONが残って同じ事故を招きます。

function writeMessages(list) {
  const tmp = MSG_FILE + ".tmp";
  fs.writeFileSync(tmp, JSON.stringify(list, null, 2), "utf8");
  fs.renameSync(tmp, MSG_FILE); // 同一ボリューム内のrenameは原子的
}

教訓

「読めなかった」を「空だった」に変換してはいけない。 この2つは意味が正反対です。空なら書いてよく、読めないなら書いてはいけません。

catch { return [] }catch { return null } は、書き込み経路の手前にあると破壊的になります。同じ形のコードが自分のリポジトリにないか、以下の観点で探してみてください。

  • 読み込みの失敗を既定値で握りつぶしていないか
  • その既定値が、直後の上書きに使われていないか
  • 壊れた入力を退避せずに捨てていないか
  • 書き込みが一時ファイル+renameになっているか

私の場合、幸いバージョン管理から履歴を復元できました。復元手段がなければ、1文字の引用符で全部消えていたことになります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?