自分のサーバで、配布しているファイルのダウンロード件数を数えています。その件数を『取られた数』として読んでいましたが、数える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 や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを、次のページの後半に置いています。