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?

ダウンロードを数える行を、中身を読む前に置いていた — 404 も1件。数える位置を後ろへ動かしても、ファイル全体を1回の res.end() で書く形では、途中で切られた回をサーバ側のどの印からも区別できなかった

0
Posted at

自分のサーバで、配布しているファイルのダウンロード件数を数えています。その件数を『取られた数』として読んでいましたが、数える1行の位置を見直したら、中身を読み出す前に置いてありました。

数える位置を4か所に置き、同じ要求を3通り投げて、それぞれの位置がどう数えるかを測りました。

数えていた位置

実際のサーバのコードです(該当箇所だけを抜き出しています。間の行は省いています)。

  // 外部からの公開ファイルへのアクセスを数える(自分のPCからの表示は含めない)。
  // ページ表示だけでなく、配布ファイルのダウンロードも数える(効果測定の要)。
  if (!isLocalhost(req) && PUBLIC_FILES.has(file) && !file.endsWith(".png")) {
    recordVisit(file, req);
  }
  fs.readFile(full, (err, data) => {
    if (err) { res.writeHead(404, SEC_HEADERS); return res.end("not found"); }

(2つ目の抜粋は、この2行の間にある別件のコメント2行を省いています)

recordVisit は fs.readFile より前にあります。公開一覧に載っている名前なら、**読み出しに失敗して 404 を返す回も1件と数えます。**記録の側にも『ファイルが送られた』と書いていましたが、言えるのは『要求が届いた』までです。

4つの位置・3通りの要求

位置 どの時点で数えるか
A 中身を読み出す前(要求が届いた時点。上のコードと同じ位置。ただし再現スクリプトでは自分のPCからの要求を除く条件などは省いています)
B 中身を読み出せて res.end() を呼んだ時点
C 応答の 'finish' が来た時点
D 応答の 'close' の時点で res.writableFinished が真か

要求は、普通に取る / 公開一覧にはあるが中身が無い(404)/ 8,388,608 バイトのファイルを、最初のまとまりを受け取った時点で受け取り側が接続を切る、の3通りです。それぞれ 20 回投げました。

結果

要求 A B C D
普通に取る 20 20 20 20
一覧にあるが中身が無い(404) 20 0 20 20
受け取りの途中で切る 20 20 20 20

受け取り側の記録: 全部受け取った 20 回 / 404 を受け取った 20 回 / 途中で切った 20 回。途中で切った回に受け取り側が読んだのは、20 回とも 65,436 バイトでした(全体は 8,388,608 バイト)。

  • 404 を数えなかったのは B だけです。A・C・D は 404 の応答も数えました(C と D は『404 という応答を書き終えた』を数えているので、そうなります)
  • **途中で切った回は、4つの位置すべてが 20 回とも数えました。**サーバ側でそのソケットに書けたと Node が数えたバイト数(socket.bytesWritten)も 8,388,708 で、ファイル全体とヘッダの分でした

大きさを変えて各5回ずつ測り直しても、同じでした。

ファイルの大きさ(バイト) 回数 受け取り側が読んだバイト C D bytesWritten(5回の最大値)
1,048,576 5 65,436 5 5 1,048,676
67,108,864 5 65,435 5 5 67,108,965
268,435,456 5 65,434 5 5 268,435,558

つまり、この測り方(ファイル全体をメモリに読み、1回の res.end() で書く形。実際のサーバも fs.readFile のあと同じ形で書いています)では、**『途中で切られた回』と『最後まで受け取られた回』をサーバ側の4つの印のどれからも区別できませんでした。**なぜ 'finish' と bytesWritten が全部を書けたと言うのか(どこまで OS の送信側に入ったのか、受け取り側の OS がどこまで受け取っていたのか)は、確かめていません。受け取り側のアプリが読んだのは最初のまとまりだけ、というところまでしか見ていません。

直したこと・直さなかったこと

  • 記録に書く言葉を『ファイルが送られた』から『要求が届いた』に直しました。数える位置は動かしていません。動かすと、過去の件数と意味が変わって比べられなくなるからです
  • 404 を数えないだけなら B の位置へ動かせば足ります。ただし上の結果のとおり、B に動かしても『受け取られた数』にはなりません
  • 1回の res.end() で書くかぎり、サーバ側の印からは受け取られたかを確かめられませんでした。少しずつ書く形に変えれば印が変わるかもしれませんが、私は試していません。受け取った側から知らせてもらう方法もありますが、私のサーバは配布物に通信の処理を入れない方針なので、それはしていません

測っていないこと

  • 測ったのはこのPC1台(Windows 11)・Node v24.18.1・127.0.0.1 の上だけです。インターネット越し・別の OS・プロキシを挟んだ場合は測っていません
  • 途中で切ったのは『最初のまとまりを受け取った直後』の1通りだけです
  • Content-Length を付けた応答だけを試しました。チャンク転送やストリームで少しずつ送る形は試していません。その形ではサーバ側の印が変わる可能性があり、この記事の『区別できなかった』はそこへは広げられません
  • 404 は、公開一覧にはあるが中身が無い場合を、メモリ上の表で真似したものです。本物のファイルが消えた場合は試していません

再現スクリプト

依存0・ファイルを読み書きしない・通信は 127.0.0.1 の使い捨てサーバだけです。動かしたのは Node v24.18.1 だけです。

// repro-count-position.mjs — ダウンロードを「どこで数えるか」で、数が何を意味するかが変わることを測る。
// 依存0・ファイルを読み書きしない・通信は 127.0.0.1 の使い捨てサーバだけ。
//
// 同じ要求を4か所で数える:
//   A: 中身を読み出す前(要求が届いた時点)
//   B: 中身を読み出せて res.end() を呼んだ時点
//   C: 応答の 'finish' が来た時点
//   D: 応答の 'close' の時点で writableFinished が真か(あわせてソケットの bytesWritten を記録する)
// 試す要求は3通り: 普通に取る / 公開一覧にはあるが中身が無い(404) / 受け取りの途中で切る。
// 中身はメモリ上の Map に置き、無い名前は読み出しの失敗として扱う(本物のファイルは使わない)。
//
// 使い方: node scripts/repro-count-position.mjs [--json] [--trials N]
import http from "node:http";

const args = process.argv.slice(2);
const asJson = args.includes("--json");
const ti = args.indexOf("--trials");
const TRIALS = ti >= 0 ? Number(args[ti + 1]) : 20;
if (!Number.isInteger(TRIALS) || TRIALS < 1) { console.error("--trials は1以上の整数"); process.exit(1); }

const BIG = 8 * 1024 * 1024; // 途中で切るために、1回の書き込みで送り切れない大きさにする
const store = new Map([["small.txt", Buffer.from("hello\n")], ["big.bin", Buffer.alloc(BIG, 0x61)]]);
const PUBLIC = new Set(["small.txt", "big.bin", "gone.txt"]); // gone.txt は一覧にあるが中身が無い

const count = { A: {}, B: {}, C: {}, D: {}, status: {}, bytesWritten: {} };
const inc = (o, k) => { o[k] = (o[k] || 0) + 1; };

function readLater(name, cb) {
  setImmediate(() => store.has(name) ? cb(null, store.get(name)) : cb(Object.assign(new Error("ENOENT"), { code: "ENOENT" })));
}

const server = http.createServer((req, res) => {
  const name = decodeURIComponent(req.url.slice(1));
  if (!PUBLIC.has(name)) { res.writeHead(403); return res.end(); }
  inc(count.A, name);
  const sock = res.socket;
  res.on("finish", () => inc(count.C, name));
  // D: 'close' の時点で Node が「書き終えた」と言っているか。あわせて、そのソケットに書けたと Node が数えたバイト数
  res.on("close", () => {
    if (res.writableFinished) inc(count.D, name);
    (count.bytesWritten[name] = count.bytesWritten[name] || []).push(sock.bytesWritten);
  });
  readLater(name, (err, data) => {
    if (err) { inc(count.status, name + " 404"); res.writeHead(404); return res.end("not found"); }
    inc(count.status, name + " 200");
    res.writeHead(200, { "Content-Length": data.length });
    inc(count.B, name);
    res.end(data);
  });
});

function get(port, name, abortAfterFirstChunk) {
  return new Promise((resolve) => {
    const req = http.get({ host: "127.0.0.1", port, path: "/" + name, agent: false }, (res) => {
      let got = 0;
      res.on("data", (c) => {
        got += c.length;
        if (abortAfterFirstChunk) { req.destroy(); resolve({ status: res.statusCode, got, aborted: true }); }
      });
      res.on("end", () => resolve({ status: res.statusCode, got, aborted: false }));
    });
    req.on("error", () => {}); // destroy() 後の ECONNRESET 等は想定内
  });
}

server.listen(0, "127.0.0.1", async () => {
  const port = server.address().port;
  const client = { normal: 0, notFound: 0, aborted: 0, abortedBytes: [] };
  for (let i = 0; i < TRIALS; i++) {
    const a = await get(port, "small.txt", false); if (a.status === 200 && a.got === 6) client.normal++;
    const b = await get(port, "gone.txt", false); if (b.status === 404) client.notFound++;
    const c = await get(port, "big.bin", true); if (c.aborted && c.got < BIG) { client.aborted++; client.abortedBytes.push(c.got); }
  }
  await new Promise((r) => setTimeout(r, 300)); // サーバ側の 'finish' / 'close' が出揃うのを待つ
  server.close();
  const row = (name) => ({ file: name, A_beforeRead: count.A[name] || 0, B_atEnd: count.B[name] || 0, C_onFinish: count.C[name] || 0, D_closeFinished: count.D[name] || 0,
    socketBytesWrittenMin: Math.min(...(count.bytesWritten[name] || [NaN])), socketBytesWrittenMax: Math.max(...(count.bytesWritten[name] || [NaN])) });
  const out = {
    node: process.version, trials: TRIALS, bigBytes: BIG,
    client,
    abortedBytesMin: Math.min(...client.abortedBytes), abortedBytesMax: Math.max(...client.abortedBytes),
    rows: [row("small.txt"), row("gone.txt"), row("big.bin")],
    serverStatus: count.status
  };
  if (asJson) { console.log(JSON.stringify(out, null, 2)); return; }
  console.log(`Node ${out.node} / 各 ${TRIALS} 回`);
  console.log("要求         | A 読む前 | B end() | C finish | D close時に書き終え | ソケットに書けたバイト");
  for (const r of out.rows) console.log(`${r.file.padEnd(12)} | ${String(r.A_beforeRead).padStart(8)} | ${String(r.B_atEnd).padStart(7)} | ${String(r.C_onFinish).padStart(8)} | ${String(r.D_closeFinished).padStart(18)} | ${r.socketBytesWrittenMin}〜${r.socketBytesWrittenMax}`);
  console.log(`受け取り側: 全部受け取った ${client.normal} / 404 ${client.notFound} / 途中で切った ${client.aborted}(切るまでに受け取ったバイト ${out.abortedBytesMin}〜${out.abortedBytesMax} / 全体 ${BIG})`);
});

Windows で AI や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを、次のページの後半に置いています。

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?