Skip to content
Open
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
86 changes: 86 additions & 0 deletions docs/01-basic/report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Report for Basic Functions

## Q1.1

### 按逗号分割与字段含义

代码框架并不是手动调用 `String.Split` 来按逗号分割日志,而是使用了 **CsvHelper** 库。在 `LogFileParser.Parse` 方法中:

```csharp
using var csv = new CsvReader(logFile, config);
csv.Context.RegisterClassMap<LogRecordMap>();
foreach (var logRecord in csv.GetRecords<LogRecord>())
{
yield return LineParser.ParseLine(logRecord);
}
```

- `new CsvReader(logFile, config)` 创建了 CSV 读取器,`csv.GetRecords<LogRecord>()` 负责按逗号把每一行拆分成若干字段。
- 每一行第几个字段代表什么含义,由 `LogRecordMap`(继承 `CsvHelper.Configuration.ClassMap<LogRecord>`)指定:

```csharp
Map(m => m.LineNo).Index(0); // 第 0 列是行号
Map(m => m.Timestamp).Index(1); // 第 1 列是时间戳
Map(m => m.PodName).Index(2); // 第 2 列是容器名
Map(m => m.Message).Index(3); // 第 3 列是 JSON 消息
```

并通过 `csv.Context.RegisterClassMap<LogRecordMap>()` 注册生效。

### 判断日志种类

在 `LineParser.ParseLine` 方法中,通过以下语句判断这一行日志的种类:

```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` 解析 `message`,再用 `root.TryGetProperty("event", ...)` 取出 `event` 字段,最后用 `switch` 表达式根据 `event` 的值(`"call"` / `"request"` / `"internal"`)分发到对应的创建方法。

### 解析 JSON 所用的库方法

确定日志种类后,调用的是 `System.Text.Json` 中的 `JsonSerializer.Deserialize<T>(json, options)`(例如 `JsonSerializer.Deserialize<CallMessage>(logRecord.Message, options)`)。

**防止字段缺失:** 每个 `Message` record 的字段都标注了 `[property: JsonRequired]` 特性(例如 `[property: JsonRequired] string RequestId`)。当 JSON 中缺少被标记的字段时,`JsonSerializer.Deserialize` 会抛出 `JsonException`;此外还通过 `?? throw new FormatException(...)` 处理反序列化结果整体为 `null` 的情况。

**命名法转换:** 通过 `JsonSerializerOptions` 的命名策略完成:

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

`PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower` 会让序列化器把大驼峰属性名(如 `RequestId`、`TargetService`、`DurationMs`)自动转换为烤串命名法(`request-id`、`target-service`、`duration-ms`)去匹配 JSON 中的键。

## Q1.2

以一个 Call 事件的解析结果为例,`Dump` 方法被调用后的方法调用链如下:

+ `Dictionary<string, string> KeyValueVisitor.Dump(LogEntry entry)`
+ `TResult CallLogEntry.Accept<TResult>(ILogEntryVisitor<TResult> visitor)`(经 `entry.Accept(this)` 多态调用)
+ `Dictionary<string, string> KeyValueVisitor.Visit(CallLogEntry entry)`

## Q1.3

(本问为个人反思题,请根据你的实际情况选择作答。下方以 Q1.3.b 为例。)

### Q1.3.b

本次作业我使用了 AI 辅助完成。我给予 AI 的提示词大致为:「帮我完成 01-basic 要求的所有作业」,并在此之前通过提问明确了当前分支、任务内容与需要改动的文件。

与完全依靠传统搜索引擎和自己能力写出的解答相比,AI 的解答好在:能快速梳理出代码框架中需要补全的 `TODO` 位置,并给出与已有 `Call` 实现风格一致的参考代码,节省了大量阅读与试错的时间。

但 AI 的解答也存在不足:例如它有时会忽略 `internal` 日志中 `exception` 字段需要按「冒号加空格」拆分成 `ExceptionName` 与 `ExceptionMessage` 这一细节,需要人工结合测试用例(`TestParseInternalLogExampleFailed`)来确认异常格式的处理方式。因此最终代码仍需要人工 review 与验证。
Binary file added docs/02-multithreading/assets/localcli-full.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
84 changes: 84 additions & 0 deletions docs/02-multithreading/report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Report for Multithreading

## 功能实现简介

本节在 `01-basic` 的基础上,实现了目录级别的并行日志分析器,共完成三个部分:

1. **线程安全队列 `WorkQueue<T>`**(`LogAnalyzer/WorkQueue.cs`):基于 `Queue<T>` + `lock` + `Monitor`(条件变量)实现的、支持"结束放入"操作的无限容量生产者-消费者队列。
2. **并行日志分析器 `LogFileAnalyzer`**(`LogAnalyzer/LogFileAnalyzer.cs`):扫描指定目录下所有 `.log` 文件,开多个工作线程并行解析,并缓存每个文件的分析结果。
3. **控制台交互界面 `LocalCli`**(`LocalCli/Program.cs`):提供展示文件、分析指定文件、分析全部文件、查看分析结果、切换目录等菜单,并对非法输入做了鲁棒性处理。

### 控制台界面截图

完整功能包括:输入目录 → 展示文件列表 → 分析指定文件 → 分析全部文件 → 查看单个文件的解析结果。

![完整功能截图](./assets/localcli-full.png)

### 鲁棒性测试截图

覆盖以下非法输入场景:不存在的目录、不存在的文件名、分析不存在的文件(抛出 `ArgumentException` 被捕获)、菜单选项输入非数字、以及查看尚未分析过的文件。

![鲁棒性测试截图](./assets/localcli-robustness.png)

---

## Q2.1

### `WorkQueue<T>` 中的共享变量及其保护

`WorkQueue<T>` 中有两个共享变量:

+ `_items`(`Queue<T>`):队列内部存储;
+ `_isCompleted`(`bool`):标记是否已结束放入元素。

这两个变量均通过 `lock (_items)` 保护,即以 `_items` 对象本身作为互斥量。`Enqueue`、`TryDequeue`、`CompleteAdding` 以及 `IsCompleted` 属性中所有对这两个共享变量的读写都在 `lock (_items)` 临界区内完成,从而避免数据竞争。

### `LogFileAnalyzer` 中的共享变量及其保护

`LogFileAnalyzer` 中的共享变量有:

+ `_currentDirectory`(`string?`):当前日志目录;
+ `_isAnalyzing`(`bool`):是否正在分析;
+ `_logFiles`(`Dictionary<string, FileInfo>`):目录中的日志文件映射;
+ `_analysisResults`(`Dictionary<string, AnalysisResult>`):各文件的解析结果。

这些变量统一通过一个专用的互斥对象 `_syncRoot` 的 `lock (_syncRoot)` 保护。所有方法(`ChangeDirectory`、`GetLogFiles`、`TryGetAnalysisResult`、`AnalyzeFiles`、`RunWorkers`、`WorkerMain` 等)在访问这些共享变量时都先进入 `lock (_syncRoot)` 临界区。尤其是 `_isAnalyzing` 的读写、`_analysisResults` 的读写(由多个工作线程同时写入),都必须加锁。

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

若将判断条件写成 `if`,当出现虚假唤醒(spurious wakeup)时:线程在没有人调用 `signal`/`broadcast` 的情况下从 `Monitor.Wait` 中醒来,但此时仓库(队列)可能仍然是空的。若用 `if`,线程醒来后不会再检查条件,而是直接越过等待、去执行取元素操作,这会导致:

+ 从空队列中执行 `Dequeue()`,抛出 `InvalidOperationException`(或读到非法数据);
+ 对应到无限容量生产者-消费者问题,就是消费者在 `buffer == 0` 时依然执行 `buffer -= 1`,造成"负库存"的逻辑错误。

因此必须用 `while`,让线程每次被唤醒后都重新检查"是否有元素"以及"是否已结束放入",只有在条件真正满足时才继续执行,从而保证正确性。

---

## Q2.2

扫描目录中全部 `.log` 后缀日志文件的代码位于 `LogFileAnalyzer.ChangeDirectory` 方法中:

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

它使用 `Directory.EnumerateFiles` 配合通配符 `"*.log"` 和 `SearchOption.TopDirectoryOnly` 枚举当前目录下的日志文件,再用 `Select` 取文件名、`OrderBy` 排序。

若要递归获取给定目录的全部子目录(及子子目录……)内的日志文件,只需把搜索选项 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories` 即可(可配合 `Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.AllDirectories)`)。

---

## Q2.3

### Q2.3.b

本次作业我使用了 AI 辅助完成。我给予 AI 的提示词大致是:"切换到 02-multithreading 分支,阅读 guidance 文档后完成 WorkQueue、LogFileAnalyzer、LocalCli 的实现"。

我对 AI 的使用主要是:让 AI 帮我梳理 `WorkQueue` 的条件变量写法(`Monitor.Wait`/`Pulse`/`PulseAll` 与 `while` 循环配合)、`LogFileAnalyzer` 中 `RunWorkers` 的线程生命周期管理(入队 → 开启线程 → `Join`),以及 `LocalCli` 的异常捕获结构。

AI 的解答基本正确,但存在一些需要人工修正的细节:例如 `WorkQueue.TryDequeue` 中 `item = _items.Dequeue()` 会触发可空性警告 CS8762,需要用空值宽容运算符 `!` 修正;又如 `RunWorkers` 中"结束放入"(`CompleteAdding`)必须在开启工作线程之前(或之后立刻)执行,并唤醒所有消费者,否则消费者会永久阻塞在 `TryDequeue` 上。这些都是需要人工理解并发语义后自行确认的点。

我认为本节的难度为适中。
78 changes: 74 additions & 4 deletions src/LocalCli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,22 +112,92 @@ 6. Exit.

private static void ShowLogFiles(LogFileAnalyzer analyzer)
{
throw new NotImplementedException("T2.3");
var logFiles = analyzer.GetLogFiles();
if (logFiles.Count == 0)
{
Console.WriteLine("No log files found in the directory.");
return;
}

Console.WriteLine("Log files:");
foreach (var fileName in logFiles)
{
Console.WriteLine($" {fileName}");
}
}

private static void AnalyzeFiles(LogFileAnalyzer analyzer)
{
throw new NotImplementedException("T2.3");
Console.WriteLine("Please input file names separated by commas (e.g., basic.log,basic-multiple.log):");
var input = Console.ReadLine();
if (input is null)
{
return;
}

var fileNames = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (fileNames.Length == 0)
{
Console.WriteLine("No file names provided, please try again.");
return;
}

try
{
analyzer.AnalyzeFiles(0, fileNames);
Console.WriteLine("Analysis completed.");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to analyze files: {ex.Message}");
}
}

private static void AnalyzeAll(LogFileAnalyzer analyzer)
{
throw new NotImplementedException("T2.3");
try
{
analyzer.AnalyzeAll(0);
Console.WriteLine("Analysis completed.");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to analyze all files: {ex.Message}");
}
}

private static void GetAnalysisResult(LogFileAnalyzer analyzer)
{
throw new NotImplementedException("T2.3");
Console.WriteLine("Please input file name:");
var fileName = Console.ReadLine();
if (fileName is null)
{
return;
}

if (!analyzer.TryGetAnalysisResult(fileName, out var result))
{
Console.WriteLine($"File '{fileName}' is not in the current directory, please try again.");
return;
}

switch (result!.State)
{
case AnalysisState.NotAnalyzed:
Console.WriteLine($"File '{fileName}' has not been analyzed yet.");
break;
case AnalysisState.Succeeded:
var visitor = new KeyValueVisitor();
foreach (var entry in result.Entries)
{
var kv = visitor.Dump(entry);
Console.WriteLine(string.Join(", ", kv.Select(pair => $"{pair.Key}={pair.Value}")));
}
break;
case AnalysisState.Failed:
Console.WriteLine($"File '{fileName}' failed to analyze: {result.ErrorMessage}");
break;
}
}
}
}
Loading
Loading