Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions docs/01-basic/report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# 01-basic 实验报告

## (Q1.1)

以下分析均针对 `src/LogParser` 目录下的框架代码。

### 1. 按逗号分割日志、指定字段含义的语句

框架代码并没有手写 `Split(',')` 之类的语句,而是借助 **CsvHelper** 库来完成按逗号(默认分隔符)分割整行的工作。

在 `Parser/LogFileParser.cs` 的 `Parse` 方法中:

```csharp
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = false
};
using var csv = new CsvReader(logFile, config);
csv.Context.RegisterClassMap<LogRecordMap>();

foreach (var logRecord in csv.GetRecords<LogRecord>())
{
yield return LineParser.ParseLine(logRecord);
}
```

`CsvReader.GetRecords<LogRecord>()` 会按 `CsvConfiguration` 的默认分隔符(`,`)将每一行拆分成字段,并填充到 `LogRecord` 对象中。

而“每一行的第几个字段代表何种意义”是通过 `LogRecordMap : ClassMap<LogRecord>` 中按列下标(`Index`)映射的方式指定的:

```csharp
Map(m => m.LineNo).Index(0); // 第 0 列 -> LineNo
Map(m => m.Timestamp).Index(1); // 第 1 列 -> Timestamp
Map(m => m.PodName).Index(2); // 第 2 列 -> PodName
Map(m => m.Message).Index(3); // 第 3 列 -> Message
```

### 2. 判断日志种类(Call / Request / Internal)的方法与语句

在 `Parser/LineParser.cs` 的 `ParseLine(LogRecord logRecord)` 方法中:

```csharp
using (var doc = JsonDocument.Parse(logRecord.Message))
{
var root = doc.RootElement;
if (root.TryGetProperty("event", out var eventElement))
{
return eventElement.GetString() switch
{
"call" => LineParser.CreateCall(logRecord),
"request" => LineParser.CreateRequest(logRecord),
"internal" => LineParser.CreateInternal(logRecord),
_ => throw new FormatException(...)
};
}
...
}
```

即:

+ 先用 `JsonDocument.Parse(logRecord.Message)` 把 `message` 字段(JSON 字符串)解析为 JSON 文档;
+ 再用 `root.TryGetProperty("event", out var eventElement)` 取出名为 `event` 的字段;
+ 最后用 `eventElement.GetString()` 得到的字符串配合 `switch` 表达式分发到对应的创建方法(`CreateCall` / `CreateRequest` / `CreateInternal`)。

### 3. 解析 JSON 的库方法

在确定了日志种类后,框架使用 **`System.Text.Json`** 中的 `JsonSerializer.Deserialize<T>(...)` 方法进行强类型解析,例如:

```csharp
var callMessage = JsonSerializer.Deserialize<CallMessage>(logRecord.Message, options)
?? throw new FormatException(...);
```

#### 3.1 防止字段缺失

框架在 message 的内部 record 的每个必填属性上标注了 `[property: JsonRequired]`,例如:

```csharp
private record CallMessage(
[property: JsonRequired] string Severity,
[property: JsonRequired] string RequestId,
[property: JsonRequired] string TargetService,
[property: JsonRequired] int DurationMs
);
```

当 JSON 中缺少被标记为 `JsonRequired` 的字段(例如 Call 日志的 `message` 中缺失 `duration-ms`)时,`JsonSerializer.Deserialize<T>` 会抛出 `JsonException`,从而防止静默地把字段当默认值使用。

#### 3.2 kebab-case 到 PascalCase 的命名转换

框架在 `LineParser` 中定义了静态的 `JsonSerializerOptions`:

```csharp
private static JsonSerializerOptions options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower,
};
```

`JsonNamingPolicy.KebabCaseLower` 会把 JSON 中的烤串命名(kebab-case)键(如 `request-id`、`target-service`、`duration-ms`)自动转换为大驼峰(PascalCase)的 C# 属性名(如 `RequestId`、`TargetService`、`DurationMs`),从而在反序列化时自动完成命名法的相互映射。

## (Q1.2)

以一个 Call 事件的解析结果为例,调用 `KeyValueVisitor.Dump` 后,完整的方法调用链为:

+ `Dictionary<string, string> KeyValueVisitor.Dump(LogEntry entry)`
+ `TResult CallLogEntry.Accept<TResult>(ILogEntryVisitor<TResult> visitor)`(对 `LogEntry` 上抽象方法 `Accept` 的动态绑定实现)
+ `Dictionary<string, string> KeyValueVisitor.Visit(CallLogEntry entry)`

调用过程说明:

1. 用户调用 `Dump(entry)`;
2. `Dump` 内部执行 `return entry.Accept(this);`,由于运行时的实际类型是 `CallLogEntry`,会调用 `CallLogEntry.Accept` 的重写实现;
3. `CallLogEntry.Accept` 内部执行 `return visitor.Visit(this);`,其中 `visitor` 就是 `KeyValueVisitor` 实例,`this` 的静态类型为 `CallLogEntry`,因此重载决议调用 `KeyValueVisitor.Visit(CallLogEntry entry)`,完成 Call 类型日志的键值对提取。

## (Q1.3.b)

本次作业使用了 AI(Cline 编码助手)辅助完成。

### 我给予 AI 的提示词

我向 AI 提供了如下任务描述:

> 阅读本仓库 `docs/00-prepare` 与 `docs/01-basic` 的说明文档,以及 `src/test-01-basic` 下的测试代码与 `src/TestUtils` 中的样例数据;随后在 `src/LogParser` 中补齐所有 `TODO` 标记的实现(`LogEntries.cs` 中 `Accept` 方法、`LineParser.cs` 中 request/internal 的解析、`KeyValueVisitor.cs` 中 Request/Internal 的 `Visit` 方法),使得 `dotnet test test-01-basic -c Release` 全部通过,并为 Q1.1–Q1.3 撰写 `docs/01-basic/report.md`。

### AI 的解答比传统搜索引擎 + 自己写的解答好在哪里

+ **定位精准、效率高**:AI 通过直接搜索 `TODO` 标记和通读测试文件,一次就锁定了所有需要修改的文件与方法,省去了人工逐篇阅读文档、试错的时间。
+ **能综合“测试用例 + 样例数据”反向推导精确格式**:例如 `Timestamp` 必须用 `ToString("O")` 输出、Internal 日志的 `exception` 字段需要按第一个冒号拆分为 `ExceptionName` 与 `ExceptionMessage`,这些细节仅凭文档难以确定,但 AI 能从 `TestUtilsClass` 的样例与 `Test_1_3` 的断言中推导出来并直接落地。
+ **产出与既有代码风格一致、可直接运行**:AI 生成的代码复用了框架已有的 `JsonSerializerOptions`、`[property: JsonRequired]`、`ParseSeverity` 等既有设施,并且会主动运行 `dotnet build` / `dotnet test` 进行验证,保证结果可用。

### AI 的解答存在的问题或不如自己写的地方

+ **容易“只对齐测试、不解释意图”**:例如为什么 internal 的 exception 拆分要取第一个冒号而非最后一个、为什么要用访问者模式而不是 `switch` 类型判断,AI 只会按测试通过为目标来写,如果不追问,它不会主动解释设计动机。
+ **在缺乏测试约束的细节上可能凭猜测**:例如 `ExceptionName` / `ExceptionMessage` 是否需要 `Trim()`、异常类型用 `FormatException` 还是自定义异常等,AI 的默认选择不一定是最贴合项目约定的,需要人工审查其是否符合题目本意。
+ **生成结果仍需人工验证与理解**:AI 给出的代码只是“能通过测试”的充分条件,而非“符合设计意图”的充分条件。若不阅读、不理解就照抄,本次作业就失去了训练目的。因此我对照 guidance 中的“简单工厂模式 / 访问者模式”章节逐行核对了最终实现。

综上,AI 极大地提升了定位与落地的效率,但最终对代码语义与设计模式的理解、以及对生成结果的审查仍然需要由我本人完成。
161 changes: 161 additions & 0 deletions docs/02-multithreading/report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# 02-multithreading 实验报告

## 实现的功能介绍

本节完成了三个部分:

### T2.1 线程安全队列 `WorkQueue<T>`(`src/LogAnalyzer/WorkQueue.cs`)

基于 C# 非线程安全的 `Queue<T>` 实现了一个线程安全队列,作为"无限仓库容量"生产者消费者模型:

+ `Enqueue`:加锁后入队,并通过 `Monitor.Pulse` 唤醒一个等待中的消费者;若队列已 `CompleteAdding` 则抛出 `InvalidOperationException`。
+ `TryDequeue`:加锁后,在「队列为空且未结束放入」时用 `while (条件) Monitor.Wait` 阻塞等待;一旦有元素被放入或放入结束即被唤醒。有元素则取出返回 `true`,已结束且队列为空则返回 `false`。
+ `CompleteAdding`:加锁后置 `_isCompleted = true` 并 `Monitor.PulseAll` 唤醒所有等待的消费者,使它们能够退出。
+ `IsCompleted`:在锁内读取标记。

### T2.2 并行日志分析 `LogFileAnalyzer`(`src/LogAnalyzer/LogFileAnalyzer.cs`)

+ `AnalyzeFiles`:校验参数后,在锁内设置 `_isAnalyzing = true`;`RunWorkers` 执行完毕后,在 `finally` 中加锁复位 `_isAnalyzing = false`,从而保证同一时刻只有一个分析任务,其他并发调用会抛出 `InvalidOperationException`。
+ `RunWorkers`:在锁内过滤「已经分析过(Succeeded / Failed)」的文件(跳过以节省计算资源),对未知文件抛 `InvalidOperationException`;将待解析文件全部入队并 `CompleteAdding`;按 `degreeOfParallelism` 创建后台线程,以 `WorkerMain` 为入口;最后 `Join` 等待所有线程结束。
+ `WorkerMain`:循环 `TryDequeue` 文件,使用上一章的 `LogFileParser` 解析;成功时构造 `Succeeded` 结果,失败时捕获异常并构造 `Failed` 结果(错误信息存入 `ErrorMessage`);在锁内把结果写入 `_analysisResults`,避免数据竞争。

### T2.3 控制台交互界面 `LocalCli`(`src/LocalCli/Program.cs`)

+ `ShowLogFiles`:调用 `GetLogFiles` 列出当前目录的全部日志文件。
+ `AnalyzeFiles`:接受逗号分隔的文件名列表并调用 `AnalyzeFiles(0, fileNames)` 分析,捕获异常并提示,不会崩溃。
+ `AnalyzeAll`:调用 `AnalyzeAll(0)` 分析全部文件,同样做了异常捕获。
+ `GetAnalysisResult`:按文件名查询 `TryGetAnalysisResult`:
- 未分析 → 给出提示;
- 成功 → 用 `KeyValueVisitor.Dump` 输出每一条日志的键值对;
- 失败 → 输出 `ErrorMessage`;
- 不存在 → 给出提示。

## 功能记录(终端输出)

以下为 `src/` 目录下运行 `LocalCli` 的完整功能流程记录(命令行环境无法截取位图,故以终端文本记录代替截图):

```
Please input directory containing log files: <- 输入 dataset
Please choose:
1. Show log files.
2. Analyze specified log files.
3. Analyze all log files.
4. Get log file analysis result.
5. Change directory.
6. Exit.
>>> 1 <- 功能1:列出日志文件
Log files in the current directory:
- basic-fail.log
- basic-multiple.log
- basic.log

Please choose: ...
>>> 3 <- 功能3:分析全部文件
Analysis completed.

Please choose: ...
>>> 4 <- 功能4:查询分析结果
Please input a file name to query:
>>> basic.log
Analysis result for 'basic.log':
LineNo=0, Timestamp=2026-06-05T16:00:29.0450000+00:00, PodName=userservice-0, Severity=Info, EventType=Call, RequestId=3a013a08-6853-49fc-8f06-50daeb5c1e51, TargetService=authservice, DurationMs=18
LineNo=1, Timestamp=2026-06-05T16:00:31.0860000+00:00, PodName=userservice-1, Severity=Info, EventType=Request, RequestId=1177c344-115e-4f85-b8ec-c9164d132b79, Method=GET, Path=/api/user/john, StatusCode=404
LineNo=2, Timestamp=2026-06-05T16:05:45.3220000+00:00, PodName=gateway-0, Severity=Error, EventType=Internal, ExceptionName=System.InvalidOperationException, ExceptionMessage=Failed to load gateway routing configuration.
```

## 鲁棒性测试记录(终端输出)

```
Please choose: ...
>>> abc <- 非法菜单输入
Invalid input, please try again.

Please choose: ...
>>> 4
Please input a file name to query:
>>> nonexist.log <- 查询不存在的文件
No analysis result found for 'nonexist.log'.

Please choose: ...
>>> 2
Please input file names to analyze (separated by commas):
>>> basic.log, no-such-file.log <- 分析列表中含不存在的文件
Analysis failed: File 'no-such-file.log' is not in the current directory or does not exist.

Please choose: ...
>>> 5
Please input directory containing log files:
>>> nonexist-dir <- 切换到不存在的目录
Directory not exists, please try again:
Please input directory containing log files:
>>> dataset <- 重新输入有效目录,恢复正常
```

可以看到,各种非法输入(非法菜单选项、不存在的文件名、不存在的目录)都会被捕获并提示用户重新输入,程序不会崩溃。


## (Q2.1)

### `WorkQueue<T>` 类中的共享变量与保护方式

+ 共享变量:
- `_items`:`Queue<T>`,队列本身;
- `_isCompleted`:`bool`,标记是否已结束放入。
+ 保护方式:对 `_items` 使用 `lock (_items)` 进行互斥保护。`_isCompleted` 的读(`IsCompleted`)与写(`CompleteAdding`、`Enqueue` 的检查)全部位于对 `_items` 的 `lock` 临界区内,因此同一个互斥锁同时保护了这两个共享变量。此外,线程间协作使用 C# 的管程(`Monitor.Wait` / `Monitor.Pulse` / `Monitor.PulseAll`,即条件变量)。

### `LogFileAnalyzer` 类中的共享变量与保护方式

+ 共享变量:`_currentDirectory`(当前目录)、`_isAnalyzing`(是否正在分析)、`_logFiles`(文件名 → `FileInfo`)、`_analysisResults`(文件名 → `AnalysisResult`)。
+ 保护方式:以上共享变量一律通过 `lock (_syncRoot)` 保护。`IsAnalyzing` 属性、`ChangeDirectory`、`GetLogFiles`、`TryGetAnalysisResult`、`AnalyzeFiles`、`RunWorkers` 以及 `WorkerMain` 中写回结果的代码,都先加锁再访问共享状态,从而避免数据竞争。

### 条件变量用 `if` 判断而非 `while` 的后果

在 MESA 模型(以及类 UNIX 系统的信号导致的虚假唤醒)下,条件变量的 `wait` 可能在没有任何 `signal` / `broadcast` 的情况下被唤醒。若使用 `if (条件) { wait(); }`,线程被唤醒后不会重新检查条件就继续往下执行。

以无限仓库容量的生产者消费者问题为例:消费者在「仓库为空」时 `wait`,如果它被**虚假唤醒**(或虽然被正常唤醒但商品已被另一个消费者取走),用 `if` 的消费者会直接执行"取商品"操作,而此时仓库里并没有商品,导致**空取出(取出不存在的商品 / 缓冲区下溢)**,产生逻辑错误甚至未定义行为。

而用 `while (条件) { wait(); }` 时,每次从 `wait` 返回后都会**重新检查条件**,只有条件真正满足(仓库非空)才会继续取商品,因此即使出现虚假唤醒也能安全地回到 `wait` 中,保证正确性。

## (Q2.2)

### 扫描给定目录全部 `.log` 文件的代码

在 `LogFileAnalyzer.ChangeDirectory` 方法中:

```csharp
var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly)
.Select(filePath => Path.GetFileName(filePath))
.OrderBy(fileName => fileName);
```

### 若需要递归扫描子目录

将 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories` 即可,例如:

```csharp
var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.AllDirectories)
.Select(filePath => Path.GetFileName(filePath))
.OrderBy(fileName => fileName);
```


## (Q2.3.b)

本次作业使用了 AI(Cline 编码助手)。

### 我给予 AI 的提示词

> 阅读 `docs/02-multithreading` 的说明文档与 `src/test-02-multithreading` 测试代码,在 `src/LogAnalyzer`(`WorkQueue.cs`、`LogFileAnalyzer.cs`)与 `src/LocalCli/Program.cs` 中补齐所有 `TODO` 实现,使 `dotnet test test-02-multithreading -c Release` 全部通过,并运行 `LocalCli` 验证功能与鲁棒性,最后撰写 `docs/02-multithreading/report.md`。

### 对 AI 的使用方式

主要让 AI 编写一部分作业代码(T2.1 / T2.2 / T2.3 的 TODO 实现),同时让 AI 对照测试用例解释代码框架(如 `_syncRoot` 的加锁位置、`WorkerMain` 的职责),以便我理解后再落地。

### AI 的解答是否出现过错误

出现过一次:初版 `WorkQueue.TryDequeue` 直接写 `item = _items.Dequeue();`,编译时产生 CS8762 警告(`[NotNullWhen(true)]` 保证返回 `true` 时 `item` 非空,但编译器无法推导 `Dequeue()` 对无约束泛型 `T` 一定返回非空)。AI 随后使用空包容运算符 `Dequeue()!` 修复,重新编译后警告消除、测试通过。

### 难度评价

偏低到适中。如果已经理解「互斥锁 + 条件变量 + 生产者消费者」模型,T2.1 的 `WorkQueue` 几乎是教科书实现;T2.2 的难点在于想清楚加锁的位置(尤其是 `_isAnalyzing` 的设置/复位与 `WorkerMain` 写回结果)以及如何在多线程竞争下保证"只有一次分析成功";T2.3 主要是把已有接口串起来并做好异常捕获,难度不大。总体属于需要认真思考并发同步、但逻辑并不复杂的程度。

Loading