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
31 changes: 31 additions & 0 deletions docs/01-basic/report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 01-basic 问答报告

## Q1.1

日志的 CSV 字段不是通过手动调用字符串的 `Split` 方法进行分割的,而是由 CsvHelper 完成。`LogFileParser.Parse` 中的 `csv.GetRecords<LogRecord>()` 逐条读取 CSV 记录;`LogRecordMap` 通过 `Map(...).Index(...)` 指定字段含义,其中索引 0 到 3 依次对应 `LineNo`、`Timestamp`、`PodName` 和 `Message`。

JSON 格式的 `message` 在 `LineParser.ParseLine` 中被判断种类。代码先调用 `JsonDocument.Parse(logRecord.Message)` 得到 JSON 文档,再通过 `root.TryGetProperty("event", out var eventElement)` 读取 `event`,最后根据 `eventElement.GetString()` 的结果在 `switch` 表达式中选择 `CreateCall`、`CreateRequest` 或 `CreateInternal`。

确定日志种类后,代码调用 System.Text.Json 提供的 `JsonSerializer.Deserialize<T>(logRecord.Message, options)`,将 JSON 反序列化为对应的消息记录。各消息记录的属性使用了 `[property: JsonRequired]`,因此必需字段缺失时,反序列化会抛出 `JsonException`。`options` 的 `PropertyNamingPolicy` 被设置为 `JsonNamingPolicy.KebabCaseLower`,所以 C# 中的 `RequestId`、`StatusCode` 等大驼峰属性能够分别对应 JSON 中的 `request-id`、`status-code` 等烤串命名字段。

## Q1.2

以 Call 事件为例,调用链如下:

1. `Dictionary<string, string> KeyValueVisitor.Dump(LogEntry entry)`
2. `TResult CallLogEntry.Accept<TResult>(ILogEntryVisitor<TResult> visitor)`
3. `Dictionary<string, string> KeyValueVisitor.Visit(CallLogEntry entry)`

`Dump` 持有的是 `LogEntry` 基类引用,它先调用具体日志对象的 `Accept`。`CallLogEntry.Accept` 再执行 `visitor.Visit(this)`;由于此处 `this` 的类型是 `CallLogEntry`,最终会进入接收 `CallLogEntry` 参数的 `Visit` 重载。这使访问者能够针对不同日志类型执行不同的导出逻辑。

## Q1.3.b

本次作业使用了 AI。我使用过的主要提示词包括:

> 请你读dotnet-guidance,教我完成01-basic的作业

> 我还是不会T1.3,请给我修改,并完成问答报告

AI 的主要帮助是快速定位了 T1.2 和 T1.3 对应的文件、未实现的方法和测试要求,并结合已有的 Call 实现解释了 Request、Internal 的实现方式。对我不熟悉的访问者模式,AI 把 `Dump -> Accept -> Visit` 的调用关系具体对应到项目代码,比只搜索访问者模式的通用定义更直接。它还可以统一核对字典键名、时间格式和数值转换,减少因为字段拼写不一致造成的测试失败。

AI 的不足是,第一次说明仍然包含较多概念和步骤,没有立刻解决我对 T1.3 具体写法的疑问。AI 能生成能够通过当前测试的实现,但不能证明我已经理解访问者模式,也可能忽略课程测试之外的边界情况。问答报告中的个人体验也无法由 AI 代替,因此我仍需要阅读代码、运行 Release 测试,并根据自己的理解检查和修改生成的内容。相比完全依靠搜索引擎,AI 整理项目内信息更快;相比自己独立完成,它减少了探索过程,但也更容易让我在没有理解代码的情况下直接接受答案。
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.
57 changes: 57 additions & 0 deletions docs/02-multithreading/report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 02-multithreading 实现报告

## T2.3 功能说明

本节实现了一个目录级多线程日志分析器及其控制台交互界面,主要功能包括:

1. 输入或切换日志目录,并扫描目录第一层中的全部 `.log` 文件。
2. 显示当前目录中的日志文件名。
3. 根据指定并行度分析一组逗号分隔的日志文件,或者分析全部日志文件;并行度为 `0` 时使用逻辑处理器数量。
4. 查询文件的分析结果。未分析文件会显示提示;分析成功时使用 `KeyValueVisitor.Dump` 输出每条日志;分析失败时显示异常信息。
5. 检查空目录、不存在目录、非法菜单选项、非整数或负数并行度、空文件名以及不存在的日志文件,发生输入错误后返回交互流程,不会导致程序崩溃。

完整功能的自动化控制台会话如下。该会话覆盖文件列表、未分析状态、指定文件分析、成功结果、失败结果和全部文件分析,程序最终以退出码 `0` 结束。

![T2.3 完整功能](./assets/t2.3-functional.png)

鲁棒性测试会话如下。输入序列包含空目录、不存在目录、非数字菜单、越界菜单、非数字和负数并行度、空文件列表、未知文件、空查询文件名等情况,程序均给出提示并继续运行。

![T2.3 鲁棒性测试](./assets/t2.3-robustness.png)

## Q2.1

`WorkQueue<T>` 中需要在线程间共享的状态是 `_items` 队列中的内容和 `_isCompleted` 完成标志。它们都使用 `_items` 对象作为同一把管程锁,通过 `lock (_items)` 保护。消费者发现队列为空且尚未完成添加时调用 `Monitor.Wait(_items)`,等待期间会释放锁;生产者入队后调用 `Monitor.Pulse(_items)` 唤醒一个消费者;完成添加后调用 `Monitor.PulseAll(_items)` 唤醒全部消费者,使其在队列排空后退出。

`LogFileAnalyzer` 中的共享状态包括 `_currentDirectory`、`_isAnalyzing`、`_logFiles` 和 `_analysisResults`。目录切换、分析状态检查和修改、文件列表快照、结果查询以及工作线程写回结果等操作主要通过 `lock (_syncRoot)` 互斥。`AnalysisResult` 使用不可变记录表示,构造后不再修改,而是整体替换字典中的结果。需要注意,当前框架的 `CurrentDirectory` 和 `HasDirectory` 属性直接读取 `_currentDirectory`,没有获得 `_syncRoot`;现有 CLI 使用方式不会在切换目录时并发读取它们,但如果要求任意公开接口都支持并发调用,这两个 getter 也应加锁。

如果等待条件只使用 `if`,线程从 `Monitor.Wait` 返回后就会继续执行,不会重新检查队列是否仍为空。发生虚假唤醒,或者另一个消费者先取走了刚加入的元素时,当前消费者可能对空队列执行 `Dequeue` 并抛出异常。对应无限容量生产者消费者问题,就是消费者在仓库仍为空时错误地取走不存在的产品,可能使产品计数变为负数。因此必须使用 `while`,每次醒来并重新取得锁后再次检查“队列为空且添加未完成”这一条件。

## Q2.2

`LogFileAnalyzer.ChangeDirectory` 中以下调用负责扫描给定目录第一层的 `.log` 文件:

```csharp
Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly)
```

如果需要递归扫描全部子目录,可以把 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories`。递归后不同子目录可能存在同名文件,因此不能继续只用 `Path.GetFileName` 作为字典键;可以改用相对于根目录的路径或完整路径来区分文件。

## Q2.3.b

本次作业使用了 AI。主要提示词包括:

> 请阅读我更新的内容,确定第二节要完成的多线程的任务

> 请你帮我完善这个文件,并告诉我每个地方为什么这样做。我已经在 visual studio 2026 里打开了这个文件

> 请继续完善这个文件中的代码

> 现在请你继续阅读 guidance.md 完成 S2.3 和 T2.3,文件已在 visual studio 2026 中打开

我既让AI帮我解释代码,又让AI在我无法完善代码的情况下帮我完善代码,并给我解释为什么要这样做。

AI 首先解释了代码框架中类、对象、方法和共享变量的作用,随后帮助实现了 `LogFileAnalyzer`、`WorkQueue<T>` 和 `LocalCli` 的代码,并运行 `test-02-multithreading` 以及控制台正常/非法输入会话进行验证。使用方式不仅是查询接口,还包括让 AI 编写部分作业代码、解释同步设计,并根据测试结果修正实现。

AI 的第一版 `WorkQueue<T>` 能通过功能测试,但对 `Dequeue` 的赋值触发了 `CS8762` 可空性警告;随后通过与 `[NotNullWhen(true)]` 返回约定一致的空宽容标注消除了警告。`LogFileAnalyzer` 首次运行 T2.2 测试时也因为当时尚未实现 T2.1 的 `WorkQueue.Enqueue` 而全部停止,这不是 T2.2 本身的测试结论,因此完成队列后重新运行了完整测试。最终 `test-02-multithreading` 的 8 项测试全部通过。

我认为本节整体难度偏高。单个 `lock`、`Monitor.Wait` 或 `Thread.Join` 接口并不复杂,主要难点是同时维护队列状态和完成状态、使用 `while` 应对虚假唤醒、保证异常时恢复 `_isAnalyzing`,以及区分某个测试失败究竟来自队列还是日志分析器。
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.
54 changes: 54 additions & 0 deletions docs/03-async-grpc/report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 03-async-grpc 实现报告

## T3.1 Agent

本节完成了日志分析系统与 Protobuf 类型之间的转换,以及 gRPC Agent 的请求处理逻辑。

`GrpcLogEntryVisitor` 使用访问者模式把 `CallLogEntry`、`RequestLogEntry` 和 `InternalLogEntry` 转换为对应的 `LogEntryMessage`。`GrpcTypeConverter` 负责分析状态、日志级别、事件类型和三种日志记录的双向转换。

`AgentSession` 实现了目录切换、全部日志分析、指定日志分析和分析结果查询。请求参数或当前状态不合法时,Agent 会返回对应的 `INVALID_ARGUMENT`、`DIRECTORY_NOT_FOUND`、`FILE_NOT_FOUND` 或 `INVALID_OPERATION`,其他异常转换为 `INTERNAL_ERROR`,避免请求异常导致服务退出。分析成功的结果首先返回 header,随后按原顺序返回每条日志记录。

`AgentService` 只负责把 gRPC 请求和取消令牌转交给 `AgentSession`。对于分析结果,它使用服务器流逐条写出 `GetAnalysisResultResponse`。

在 Release 配置运行 `test-03-async-grpc`,T3.1.1 至 T3.1.3 共 3 项测试全部通过。

## T3.2 RemoteCli

`RemoteCli` 实现了以下功能:

1. 切换 Agent 使用的日志目录。
2. 获取当前目录中的日志文件列表。
3. 按指定并行度分析部分或全部日志文件,其中并行度 `0` 使用逻辑处理器数量。
4. 查询未分析、分析成功和分析失败三种状态。
5. 异步读取 gRPC 服务器流,并把每条 Protobuf 日志转换回日志模型后输出。
6. 处理空目录、无效目录、非法菜单、非法并行度、空文件列表和不存在的日志文件。

正常会话覆盖了目录切换、文件列表、未分析状态、指定文件分析、三种日志类型的成功结果、失败结果和全部文件分析。程序最终以退出码 `0` 结束。

![RemoteCli 完整功能](./assets/remote-cli-functional-result.png)

鲁棒性会话连续输入多种非法内容。每次错误后程序都会给出原因并返回菜单或重新提示输入,没有因客户端参数错误退出。

![RemoteCli 鲁棒性测试](./assets/remote-cli-robustness-result.png)

## Q3.1

网络应用与以前本地程序最直接的区别是调用跨越了进程边界。调用方不能直接共享 Agent 中的对象,只能使用 `.proto` 约定的消息,因此需要维护内部模型和 Protobuf 模型之间的转换。网络调用还可能遇到服务未启动、地址错误、连接中断和请求取消;这些情况在普通本地方法调用中通常不需要考虑。

本节另一个明显区别是调试时必须同时运行 Agent 和客户端。客户端创建 channel 并不表示连接已经成功,第一次 RPC 才真正建立连接,所以程序先调用 `PingAsync`。查询分析结果又不能假定响应可以一次装入内存,而是要先处理 header,再异步读取服务器流中的日志记录。

服务端的输入也不能被信任。目录、文件名和并行度都需要检查,分析器当前是否已有目录或正在分析也会影响请求是否合法。Agent 是常驻服务,因此这些错误必须转换为协议状态,而不能让异常终止服务。

## Q3.2.b

本次作业使用了 AI。主要提示词包括:

> 请你阅读 guidance 中的更新内容,帮助我完成 03-async-grpc 中的任务。

> 我在 Visual Studio 2026 中已经打开了这个项目,请你帮助我完成第 3 节全部要求。

> 请告诉我为什么要这样做

AI 用于阅读任务说明、解释代码框架、补全 T3.1/T3.2 的 TODO、运行测试和组织控制台验证。过程中 AI 一开始为了增强鲁棒性改动了 `RemoteCli` 已有的入口和菜单流程,这不符合工程只修改 TODO 区域的规则。指出问题后,AI 阅读了 `00-prepare/guidance.md`,撤回了 TODO 之外的改动,并通过 `git diff` 重新检查修改范围。

通过本次实现,我更清楚地理解了 gRPC 的惰性连接、普通异步 RPC 与服务器流的区别,以及 `oneof` 消息如何承载 header 或日志记录。服务器流客户端方法本身没有 `Async` 后缀,但响应通过 `ReadAllAsync` 异步读取;这与一次返回单个响应的 `ChangeDirectoryAsync` 等调用不同。
134 changes: 128 additions & 6 deletions src/LocalCli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ public static void Main(string[] args)
{
return null;
}

if (string.IsNullOrWhiteSpace(directory))
{
Console.WriteLine("Directory cannot be empty, please try again:");
continue;
}

try
{
if (!analyzer.ChangeDirectory(directory))
Expand All @@ -36,9 +43,12 @@ public static void Main(string[] args)
}
break;
}
catch (ArgumentException)
catch (Exception ex) when (ex is ArgumentException
or IOException
or NotSupportedException
or UnauthorizedAccessException)
{
Console.WriteLine("Directory illegal, please try again:");
Console.WriteLine($"Directory illegal: {ex.Message}, please try again:");
continue;
}
}
Expand Down Expand Up @@ -112,22 +122,134 @@ 6. Exit.

private static void ShowLogFiles(LogFileAnalyzer analyzer)
{
throw new NotImplementedException("T2.3");
var fileNames = analyzer.GetLogFiles();
Console.WriteLine($"[{string.Join(", ", fileNames)}]");
}

private static int? ReadDegreeOfParallelism()
{
Console.WriteLine("Please input degree of parallelism (0 uses the logical processor count):");
var input = Console.ReadLine();
if (input is null)
{
return null;
}

if (!int.TryParse(input.Trim(), out var degreeOfParallelism)
|| degreeOfParallelism < 0)
{
Console.WriteLine("Degree of parallelism must be a non-negative integer.");
return null;
}

return degreeOfParallelism;
}

private static List<string>? ReadFileNames()
{
Console.WriteLine("Please input log file names (comma separated):");
var input = Console.ReadLine();
if (input is null)
{
return null;
}

var fileNames = input
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Distinct()
.ToList();
if (fileNames.Count == 0)
{
Console.WriteLine("At least one log file name is required.");
return null;
}

return fileNames;
}

private static void AnalyzeFiles(LogFileAnalyzer analyzer)
{
throw new NotImplementedException("T2.3");
var degreeOfParallelism = ReadDegreeOfParallelism();
if (degreeOfParallelism is null)
{
return;
}

var fileNames = ReadFileNames();
if (fileNames is null)
{
return;
}

try
{
analyzer.AnalyzeFiles(degreeOfParallelism.Value, fileNames);
Console.WriteLine($"Analysis completed: [{string.Join(", ", fileNames)}]");
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
Console.WriteLine($"Unable to analyze files: {ex.Message}");
}
}

private static void AnalyzeAll(LogFileAnalyzer analyzer)
{
throw new NotImplementedException("T2.3");
var degreeOfParallelism = ReadDegreeOfParallelism();
if (degreeOfParallelism is null)
{
return;
}

try
{
analyzer.AnalyzeAll(degreeOfParallelism.Value);
Console.WriteLine($"Analysis completed: [{string.Join(", ", analyzer.GetLogFiles())}]");
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
Console.WriteLine($"Unable to analyze files: {ex.Message}");
}
}

private static void GetAnalysisResult(LogFileAnalyzer analyzer)
{
throw new NotImplementedException("T2.3");
Console.WriteLine("Please input log file name:");
var fileName = Console.ReadLine();
if (string.IsNullOrWhiteSpace(fileName))
{
Console.WriteLine("Log file name cannot be empty.");
return;
}

fileName = fileName.Trim();
if (!analyzer.TryGetAnalysisResult(fileName, out var result) || result is null)
{
Console.WriteLine($"File {fileName} does not exist in the current directory.");
return;
}

switch (result.State)
{
case AnalysisState.NotAnalyzed:
Console.WriteLine($"File {fileName} has not been analyzed yet.");
break;
case AnalysisState.Succeeded:
Console.WriteLine($"Analysis result for {fileName}:");
var visitor = new KeyValueVisitor();
foreach (var entry in result.Entries)
{
var keyValues = visitor.Dump(entry);
Console.WriteLine(string.Join(", ",
keyValues.Select(keyValue => $"{keyValue.Key}: {keyValue.Value}")));
}
break;
case AnalysisState.Failed:
Console.WriteLine($"Analysis failed for {fileName}: {result.ErrorMessage}");
break;
default:
Console.WriteLine($"File {fileName} has an unknown analysis state.");
break;
}
}
}
}
Loading
Loading