AI Library · 知识库

# 日志统计器:逐行输入到确定性报告

学习目标:把文件读取、格式解析、计数、排序和失败报告组合成一个可运行工具,理解“流式读取”与“结果可以立即输出”不是一回事。

前置知识:函数、字典或 Map、异常、排序;JavaScript 只需理解 await 等待 I/O 和异步迭代。选择 Python 与 JavaScript,是为了比较同步文件迭代和 Node.js 异步行流;两者都无需第三方依赖,不为凑语言数量引入另一套日志生态。

# 问题与边界

输入是一个真实文件,每行格式为 级别 服务 消息,两个分隔位置各一个 ASCII 空格:

这里只统计记录数,不解析时间戳,不做日志轮转追踪、压缩文件、并行归并或跨文件聚合。消息长度上限是格式约束,不是抵御超长行内存消耗的防护。

# 关键差异

维度 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]。

# 迁移误区与适用限制

# 运行与验证

两种无参数命令的预期 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。临时目录清理错误不是业务输入错误,会交给最外层报告。

版本基线见主题首页。以上是预期行为,不是运行通过声明;实测工具链、命令和未覆盖项统一记入验证记录。尚需父级统一执行两种完整程序并检查链接渲染;超大文件、读权限错误和输出端断开不在内置场景中。

# 相关阅读