# 日志统计器:逐行输入到确定性报告
学习目标:把文件读取、格式解析、计数、排序和失败报告组合成一个可运行工具,理解“流式读取”与“结果可以立即输出”不是一回事。
前置知识:函数、字典或 Map、异常、排序;JavaScript 只需理解 await 等待 I/O 和异步迭代。选择 Python 与 JavaScript,是为了比较同步文件迭代和 Node.js 异步行流;两者都无需第三方依赖,不为凑语言数量引入另一套日志生态。
# 问题与边界
输入是一个真实文件,每行格式为 级别 服务 消息,两个分隔位置各一个 ASCII 空格:
- 级别只能是
INFO、WARN、ERROR;服务名匹配[a-z][a-z0-9-]{0,15};消息为 1~120 个可打印 ASCII 字符,消息内部可有空格。 - 接受 LF、CRLF、CR 换行,以及末行没有换行。空文件成功;空白行、未知级别、非 ASCII 内容和格式错误均失败。UTF-8 的 ASCII 子集是合法文件内容,不接受 BOM。
- 按“级别 + 服务”统计;次数降序,相同次数按级别、服务的 ASCII 字典序排列。仅输出实际出现的组合,不补零。
- 首个坏行报告
line N: invalid record;文件无法读取报告file read failed。失败不输出部分统计,也不跳过坏行假装完整。 - 命令行只读用户显式指定的文件;无参数时在临时目录自建输入,展示成功、空文件、坏行、缺失文件。不会写入用户日志目录。
这里只统计记录数,不解析时间戳,不做日志轮转追踪、压缩文件、并行归并或跨文件聚合。消息长度上限是格式约束,不是抵御超长行内存消耗的防护。
# 关键差异
| 维度 | Python | JavaScript |
|---|---|---|
| 输入 | 文件上下文中的同步迭代 | 文件流上的异步行迭代 |
| 计数键 | 二元组 | 用空格拼接已校验的两个字段 |
| 排序 | 元组排序键 | 显式比较器,不用本地化排序 |
| 失败与清理 | 异常传播,with 关闭文件 |
Promise 拒绝,finally 关闭接口和流 |
先消费完整文件再输出,是为了保证“有报告就代表全部输入已接受”。内存保存的是组合计数和当前行,不是完整文件;组合数量仍可能随服务名增长。Python 的文件迭代及编码控制见文件对象使用;Node.js 的逐行流用法见 readline 示例。
# 各语言实现
# Python
执行 python main.py 运行隔离演示;执行 python main.py "日志路径" 处理指定文件。Windows 若使用 Python Launcher,也可执行 py -3 main.py。正常 CLI 返回 0,输入失败返回 1,参数错误返回 2。
Pythonimport re
import sys
from collections import Counter
from pathlib import Path
from tempfile import TemporaryDirectory
RECORD = re.compile(r"(INFO|WARN|ERROR) ([a-z][a-z0-9-]{0,15}) ([ -~]{1,120})")
def summarize(path):
counts = Counter()
try:
with open(path, encoding="utf-8", newline=None) as source:
for number, raw in enumerate(source, 1):
line = raw.removesuffix("\n")
match = RECORD.fullmatch(line)
if match is None:
raise ValueError(f"line {number}: invalid record")
counts[(match[1], match[2])] += 1
except (OSError, UnicodeError) as error:
raise ValueError("file read failed") from error
rows = sorted(counts.items(), key=lambda row: (-row[1], row[0]))
return "".join(f"{level} {service} {count}\n"
for (level, service), count in rows)
def demo():
with TemporaryDirectory(prefix="log-demo-") as directory:
path = Path(directory) / "app.log"
path.write_bytes(b"INFO api ready\r\nERROR db failed\n"
b"INFO api next\rWARN api slow")
report = summarize(path)
assert report == "INFO api 2\nERROR db 1\nWARN api 1\n"
print(report, end="")
path.write_bytes(b"")
assert summarize(path) == ""
print("empty: 0 rows")
for raw in [b"\n", b"DEBUG api x", b"INFO API x", b"INFO api ",
b"INFO api " + b"x" * 121, "INFO api 好".encode()]:
path.write_bytes(raw)
try:
summarize(path)
except ValueError as error:
assert str(error) == "line 1: invalid record"
else:
raise AssertionError("invalid record accepted")
for size in [1, 120]:
path.write_bytes(b"INFO a " + b"x" * size)
assert summarize(path) == "INFO a 1\n"
path.write_bytes(b"INFO api ok\nBROKEN\n")
try:
summarize(path)
except ValueError as error:
assert str(error) == "line 2: invalid record"
print(error)
else:
raise AssertionError("bad second line accepted")
try:
summarize(Path(directory) / "missing.log")
except ValueError as error:
assert str(error) == "file read failed"
print(error)
else:
raise AssertionError("missing file accepted")
def main():
if len(sys.argv) == 1:
demo()
return 0
if len(sys.argv) != 2:
print("usage: python main.py [FILE]", file=sys.stderr)
return 2
try:
report = summarize(sys.argv[1])
except ValueError as error:
print(f"error: {error}", file=sys.stderr)
return 1
print(report, end="")
return 0
if __name__ == "__main__":
sys.exit(main())
fullmatch 要求整行匹配,不会把合法前缀当成完整记录;Counter 的元组键避免字段分隔冲突。上下文退出后才返回统计,读失败不会把已累计的数据暴露给 CLI。临时目录由 TemporaryDirectory 清理;清理失败仍传播,不宣称异常退出时一定能删除文件。
# JavaScript
放在没有 "type": "module" 的独立目录,使用 CommonJS。执行 node main.js;指定文件为 node main.js "日志路径"。返回码与 Python 相同。
JavaScriptconst assert = require('node:assert/strict');
const fs = require('node:fs');
const { mkdtemp, writeFile, rm } = require('node:fs/promises');
const { tmpdir } = require('node:os');
const { join } = require('node:path');
const { createInterface } = require('node:readline');
const record = /^(INFO|WARN|ERROR) ([a-z][a-z0-9-]{0,15}) ([ -~]{1,120})$/;
async function summarize(path) {
const input = fs.createReadStream(path, { encoding: 'utf8' });
const lines = createInterface({ input, crlfDelay: Infinity });
const counts = new Map();
let number = 0;
let readError;
// 输入流错误需显式接收;异步行迭代不是输入错误处理的替代品。
input.on('error', error => { readError = error; lines.close(); });
try {
for await (const line of lines) {
number++;
const match = record.exec(line);
if (!match) throw new Error(`line ${number}: invalid record`);
const key = `${match[1]} ${match[2]}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
if (readError) throw new Error('file read failed', { cause: readError });
} catch (error) {
if (readError) throw new Error('file read failed', { cause: readError });
throw error;
} finally {
lines.close();
input.destroy();
}
return [...counts].sort(([a, x], [b, y]) =>
y - x || (a < b ? -1 : a > b ? 1 : 0)
).map(([key, count]) => `${key} ${count}\n`).join('');
}
async function demo() {
const directory = await mkdtemp(join(tmpdir(), 'log-demo-'));
const path = join(directory, 'app.log');
try {
await writeFile(path, 'INFO api ready\r\nERROR db failed\n' +
'INFO api next\rWARN api slow', 'utf8');
const report = await summarize(path);
assert.equal(report, 'INFO api 2\nERROR db 1\nWARN api 1\n');
process.stdout.write(report);
await writeFile(path, '');
assert.equal(await summarize(path), '');
console.log('empty: 0 rows');
for (const raw of ['\n', 'DEBUG api x', 'INFO API x', 'INFO api ',
'INFO api ' + 'x'.repeat(121), 'INFO api 好']) {
await writeFile(path, raw, 'utf8');
await assert.rejects(summarize(path), { message: 'line 1: invalid record' });
}
for (const size of [1, 120]) {
await writeFile(path, 'INFO a ' + 'x'.repeat(size));
assert.equal(await summarize(path), 'INFO a 1\n');
}
await writeFile(path, 'INFO api ok\nBROKEN\n');
try {
await summarize(path);
assert.fail('bad second line accepted');
} catch (error) {
assert.equal(error.message, 'line 2: invalid record');
console.log(error.message);
}
try {
await summarize(join(directory, 'missing.log'));
assert.fail('missing file accepted');
} catch (error) {
assert.equal(error.message, 'file read failed');
console.log(error.message);
}
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) return demo();
if (args.length !== 1) {
console.error('usage: node main.js [FILE]');
process.exitCode = 2;
return;
}
try {
process.stdout.write(await summarize(args[0]));
} catch (error) {
console.error(`error: ${error.message}`);
process.exitCode = 1;
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });
crlfDelay: Infinity 把 CRLF 视为一个行结束符;不使用 localeCompare,避免排序随语言环境改变。流的 UTF-8 解码会把非法字节替换为非 ASCII 字符,随后被记录语法拒绝;Python 则在解码阶段报告读取失败。两者都拒绝坏字节,但该类输入的诊断类别不强求一致。Node.js 的错误事件和异步迭代限制见 rl[Symbol.asyncIterator]。
# 迁移误区与适用限制
- 把
read().split()迁移到大日志,会同时保留全文和分割结果;本例逐行读取,但仍需保存每个不同组合的计数。 - 用
.strip()/.trim()清洗整行,会把不合法的空消息或边界空白悄悄改成另一份输入。这里只删除行结束符,消息空格本身属于内容。 - 在循环内立即打印累计值,遇到第 100 行损坏时已经输出不完整报告;本例将输出推迟到读取成功后,不等于能回滚 stdout 自身的写失败。
- Node.js 计数使用
number,契约还要求记录总数不超过安全整数范围;超出这一规模需要 BigInt 或外部聚合。Python 整数没有相同的固定精度边界。 - 超长未换行内容仍会占用内存。若输入来自不可信上传,需要另外设计按字节限制的分行器,不能把本例当作任意文件的资源隔离器。
# 运行与验证
两种无参数命令的预期 stdout 均为:
纯文本INFO api 2
ERROR db 1
WARN api 1
empty: 0 rows
line 2: invalid record
file read failed
内置场景先创建真实文件再调用与 CLI 相同的 summarize,不是用数组冒充文件。消息长度 1、120 成功,0、121 失败;同时覆盖混合换行、末行无换行、空文件、非法级别、服务名、非 ASCII、坏第二行和缺失文件。CLI 的错误送 stderr 且返回 1;演示捕获预期错误并打印,所以演示整体应返回 0。临时目录清理错误不是业务输入错误,会交给最外层报告。
版本基线见主题首页。以上是预期行为,不是运行通过声明;实测工具链、命令和未覆盖项统一记入验证记录。尚需父级统一执行两种完整程序并检查链接渲染;超大文件、读权限错误和输出端断开不在内置场景中。
# 相关阅读
- 将报告写入不同目标:把已经生成的报告交给写入能力。
- 配置加载器:外部输入的严格契约与优先级。
- HTTP JSON 客户端:把读取失败与内容校验失败分开。