# 流式日志分析器

Leon | 为 COMP2017 编写的原创教学项目。[English](README.md)。

Leon 正在测试课堂用的小机器人模拟器。每完成一个模拟动作，它就记录一个
级别和耗时，例如 `WARN 40`。靠眼睛看完整份记录，很容易漏掉慢动作。因此我们
制作一个工具：统计各级别的次数，再计算耗时总和、最小值、最大值和平均值。
有些记录可能损坏，报告必须说明丢弃了多少行。所有示例数据均为人工构造。
这是原创工程项目，不是课程提供的作业或答案。

## 编译、运行和测试

在 macOS 或 Linux 上需要 Clang、make 和 Python 3。从仓库根目录执行：

```sh
cd projects/comp2017-log-analyzer
make
./log_analyzer fixtures/demo.log
make test
```

如果下载的是独立项目文件夹，请在该文件夹内打开终端，从 `make` 开始。
Makefile 分别编译四个 C 文件，然后链接。默认参数为
`-std=c11 -Wall -Wextra -Werror -pedantic -pthread`；这个项目本身没有创建线程。
`make clean` 只删除可执行文件和它的目标文件。

如果从标准输入读取，把唯一的参数写成 `-`：

```sh
printf 'INFO 12\n' | ./log_analyzer -
```

程序输出十行 `key=value`。`total_ms` 是有效记录的耗时总和；
`mean_ms = total_ms / valid`，显示时保留三位小数。最小值和最大值也只使用有效行。
没有有效记录时，三个耗时指标显示 `n/a`。没有观测值与观测到零毫秒是两回事。

## 先约定输入，再编写循环

每条记录先写大写的 `INFO`、`WARN` 或 `ERROR`，再写至少一个空格或制表符，
最后写表示 **0 到 60000 毫秒** 的十进制数字。允许前后空格、制表符以及数字前导零。
不接受正负号、小数、单位、多余字段、注释或未知级别。空白行算一条错误记录。
`ERROR` 是合法的事件级别，并不表示解析器出错。

LF 字节结束一条物理行。读取器去掉最后一个 CR，因此支持 CRLF，也接受文件末尾
换行缺失时的最后一个 CR。最后一条记录可以没有 LF。每行在 LF 之前最多有
**127 字节**，末尾的 CR 也占一个字节。出现嵌入的 NUL 或超长行时，整条物理行
作废。读取器继续吃掉这一行剩余的字节，避免把尾部误当成下一行。整个输入最多
**1,048,576 字节**，包含换行。为区分恰好到达文件末尾与超限，可能多读取一个字节。

| 退出码 | 含义 | stdout |
|---|---|---|
| 0 | 完成读取；所有行有效，或输入为空 | 完整报告 |
| 3 | 完成读取；至少有一条错误记录 | 有效行统计及错误行数 |
| 2 | 参数数量不正确 | 空 |
| 1 | 打开、读取、关闭失败，输入超限，或输出函数报告失败 | 输入失败时不输出报告；输出失败可能已写出一部分 |

警告写入 stderr。前五条错误显示物理行号，之后只提醒一次省略了更多警告。
错误行仍全部计数。要查看退出码，在一条命令执行后立刻运行 `echo $?`。

## 五个学习阶段

1. **让一行数据走完整条路径。** 运行上面的 `INFO 12` 命令。找到 `main`、
   `parse_record`、`stats_accept` 和 `stats_print`。文本先变成 `struct record`，
   再修改 `struct stats`，最后变成输出文本。先画出这四个方框，再阅读错误分支。
   检查点是 `valid=1`、`INFO=1`、`total_ms=12` 和 `mean_ms=12.000`。
2. **给解析器写清楚承诺。** 先读 `record.h`，再读 `record.c`。调用成功才写入调用者
   的记录，失败则保持它不变。局部变量 `candidate` 实现了这个承诺。手动跟踪
   `INFO 60000` 和 `INFO 60001`。计算 `value * 10 + digit` 前，先检查
   `value <= (60000 - digit) / 10`，同时控制范围和避免中间结果溢出。
   确认 `INFO 12ms` 会被拒绝，即使前面看起来像整数。检查点是能够区分合法的
   `ERROR` 级别和不合法的文本。
3. **连续读行，同时保住边界。** 先读 `reader.h`，再读 `reader.c`。缓冲区需要
   128 字节：127 个数据字节加一个 `\0`。`fgetc` 的返回值用 `int` 保存，才能把
   EOF 与普通字节区分开。坏行要先读到行尾，再读取下一条。测试 127 字节、128
   字节，以及超长行之后的合法行。检查点是恰好拒绝一行，后续有效记录仍然保留。
   读取器只是借用 `FILE *`，不负责关闭它。
4. **累积出有意义的报告。** 阅读 `stats.h` 和 `stats.c`。用 `{0}` 初始化结构体。
   第一条有效记录要同时设置最大值和最小值；如果最小值只初始化为零，所有耗时
   都为正数时就会算错。计数和总和用 `uint64_t`，有限范围的耗时用 `unsigned`，
   计算平均值时才使用 `double`。手算示例：总和 200，数量 6，最小值 0，最大值
   100。检查点是得到下方完全一致的示例输出，并让空输入显示 `n/a`。
5. **完成命令行规则，并用测试证明。** 回到 `main.c`，标出从 `fopen` 到 `fclose`
   的每条路径。在这里打开的文件，在这里关闭；借来的 stdin 由运行时收尾。
   输入错误时不能把不完整的统计伪装成完整报告。运行 `make test`，再读一个故意
   输入错误数据的测试。它必须同时检查退出码、报告和诊断。检查点是全部 16 个
   测试通过，其中包括 1 MiB 上限和混合数据的退出码 3。

网站提供更详细的双语步骤和来自这些最终文件的代码摘录。摘录用于阅读，不能单独
编译；请编译完整项目。这些检查点是原创概念练习，不是现成作业的答案。

## 文件阅读顺序

| 文件 | 作用 |
|---|---|
| `record.h`、`record.c` | 定义记录形状，约定并实现完整文本校验 |
| `reader.h`、`reader.c` | 控制字节总量、识别行边界并处理流错误 |
| `stats.h`、`stats.c` | 维护状态约束，计算统计并格式化报告 |
| `main.c` | 处理参数、资源归属、流水步骤和退出规则 |
| `Makefile` | 重新编译需要更新的编译单元，再链接 |
| `fixtures/demo.log` | 六行有效记录，可以手算 |
| `fixtures/mixed.log` | 三行有效记录和三行错误记录 |
| `fixtures/empty.log` | 零字节文件，不是一个空白行 |
| `test.py` | 在临时目录中测试真实程序，共十六项测试 |

头文件是接口，先读它的承诺；源文件负责实现承诺。解析和统计函数都不保存行缓冲区
的指针，因此下一次循环可以安全地重用缓冲区。主函数拥有整个运行期间的统计结构体。
应用程序自身不需要堆分配。

## 三次真实运行

以下 stdout 均来自实际编译后的程序。请先用 `make` 编译一次。stderr 单独说明。

### 完整的机器人轨迹

```sh
./log_analyzer fixtures/demo.log
```

```text
lines=6
valid=6
malformed=0
INFO=3
WARN=2
ERROR=1
total_ms=200
min_ms=0
max_ms=100
mean_ms=33.333
```

退出码 0，stderr 为空。`ERROR=1` 统计一次模拟事件，并非程序运行失败。

### 含有损坏记录的轨迹

```sh
./log_analyzer fixtures/mixed.log
```

```text
lines=6
valid=3
malformed=3
INFO=1
WARN=1
ERROR=1
total_ms=30
min_ms=5
max_ms=15
mean_ms=10.000
```

退出码 3。stderr 警告第 2、4、5 行：分别是未知级别 `DEBUG`、负数 `-1`，以及
超出范围的 `60001`。这些行的耗时不会进入统计。

### 没有观测记录

```sh
./log_analyzer fixtures/empty.log
```

```text
lines=0
valid=0
malformed=0
INFO=0
WARN=0
ERROR=0
total_ms=0
min_ms=n/a
max_ms=n/a
mean_ms=n/a
```

退出码 0，stderr 为空。空文件没有错误行。

## 先作预测，再调试

如果耗时 1、2、2 的平均值从 `1.667` 变成 `1.000`，检查除法两侧的类型。
整数除法结束后再转浮点数已经来不及。如果超长行后面的有效行丢失或重复，检查
读取器是否真正消费到行尾。如果全是正耗时却报告最小值零，检查第一条记录的
初始化。如果读取失败后仍报告成功，检查 `ferror`，不要只检查 EOF。
先制作能复现问题的最小输入，再把它留下作为回归测试。

测试覆盖有效、混合和空输入；数字和级别语法；0 和 60000；CRLF、制表符、
前导零和缺失的末尾 LF；127/128 字节行；NUL 和 0xff；警告数量限制；小数平均值；
恰好达到和超过字节上限；参数错误；不存在的文件和目录。预期总和是手算的常量，
并非把 C 算法再写一遍。每次程序调用都有五秒测试超时。生成的输入只保存在每项
测试自己的临时目录中，结束后删除。测试没有模拟所有文件系统、输出或关闭故障。

## 取舍、限制与后续练习

我们维护的状态使用 O(1) 内存，处理 B 个输入字节的时间为 O(B)。标准 I/O 自己
还会维护由实现管理的缓冲区。字节上限控制工作量，也给出总和不会溢出的证明：
即使最多 1,048,576 条记录，每条 60,000 毫秒，总和也只有 62,914,560,000，
远小于 `uint64_t` 的上限。实际能容纳的记录更少，因为每条记录占多个字节。
改变上限时要重新检查这个证明；静态断言会检查乘法边界。

小而明确的语法和固定缓冲区便于理解接口。这不是通用真实日志格式解析器，
也没有时间戳、百分位数、实时跟踪文件或各级别独立耗时统计。字节限制不是运行
时间限制，stdin 仍可能等待数据生产者。第一个项目建议读取已经写完的普通文件。
输出管道断开时，POSIX 默认的 SIGPIPE 信号可能终止程序，而不经过退出码 1 的
路径。C 库返回的读、写和关闭错误会检查；向诊断流 stderr 写入失败时不会重试。

后续可以增加每个级别的耗时总和与均值。验收：示例中 INFO 为 30/3 = 10.000，
WARN 为 70/2 = 35.000，ERROR 为 100/1 = 100.000；未出现的级别显示 `n/a`。
也可以增加 `--strict` 模式。验收：首次遇到坏行就停止，退出码 3，stdout 为空；
默认模式保持不变。先写描述新行为的测试，再修改实现。

## 官方原始参考资料

- [WG14 N1570：C11 委员会草案](https://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf)：7.20、7.21.7.1 和 7.21.10 节分别介绍整数类型、`fgetc` 与流状态。
- [Clang 用户手册](https://clang.llvm.org/docs/UsersManual.html)：语言标准与诊断选项。
- [Python subprocess 文档](https://docs.python.org/3/library/subprocess.html)：参数列表、捕获输出、返回码与超时。
