From e5fa2b716af8b42cf374b594fcaa162402772895 Mon Sep 17 00:00:00 2001 From: sililass Date: Thu, 3 Sep 2026 04:12:34 +0800 Subject: [PATCH 1/3] feat(ch01): implement LogParser entries, line parser, and KeyValueVisitor --- docs/01-basic/report.md | 139 ++++++++++++++++++++++ src/LogParser/Models/LogEntries.cs | 4 +- src/LogParser/Parser/LineParser.cs | 47 +++++++- src/LogParser/Visitors/KeyValueVisitor.cs | 24 +++- 4 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 docs/01-basic/report.md diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..6e8fedd --- /dev/null +++ b/docs/01-basic/report.md @@ -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(); + +foreach (var logRecord in csv.GetRecords()) +{ + yield return LineParser.ParseLine(logRecord); +} +``` + +`CsvReader.GetRecords()` 会按 `CsvConfiguration` 的默认分隔符(`,`)将每一行拆分成字段,并填充到 `LogRecord` 对象中。 + +而“每一行的第几个字段代表何种意义”是通过 `LogRecordMap : ClassMap` 中按列下标(`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(...)` 方法进行强类型解析,例如: + +```csharp +var callMessage = JsonSerializer.Deserialize(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` 会抛出 `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 KeyValueVisitor.Dump(LogEntry entry)` ++ `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)`(对 `LogEntry` 上抽象方法 `Accept` 的动态绑定实现) ++ `Dictionary 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 极大地提升了定位与落地的效率,但最终对代码语义与设计模式的理解、以及对生成结果的审查仍然需要由我本人完成。 diff --git a/src/LogParser/Models/LogEntries.cs b/src/LogParser/Models/LogEntries.cs index 69edbc0..e4e9bbc 100644 --- a/src/LogParser/Models/LogEntries.cs +++ b/src/LogParser/Models/LogEntries.cs @@ -54,7 +54,7 @@ public sealed record RequestLogEntry( { public override TResult Accept(ILogEntryVisitor visitor) { - throw new NotImplementedException("TODO: T1.2"); + return visitor.Visit(this); } } @@ -69,7 +69,7 @@ public sealed record InternalLogEntry( { public override TResult Accept(ILogEntryVisitor visitor) { - throw new NotImplementedException("TODO: T1.2"); + return visitor.Visit(this); } } diff --git a/src/LogParser/Parser/LineParser.cs b/src/LogParser/Parser/LineParser.cs index 0475f6b..1098fdf 100644 --- a/src/LogParser/Parser/LineParser.cs +++ b/src/LogParser/Parser/LineParser.cs @@ -16,8 +16,8 @@ public static LogEntry ParseLine(LogRecord logRecord) return eventElement.GetString() switch { "call" => LineParser.CreateCall(logRecord), - "request" => throw new NotImplementedException("TODO: T1.2"), - "internal" => throw new NotImplementedException("TODO: T1.2"), + "request" => LineParser.CreateRequest(logRecord), + "internal" => LineParser.CreateInternal(logRecord), _ => throw new FormatException($"Unknown event type: {eventElement.GetString()} in log message: {logRecord.Message}") }; } @@ -50,12 +50,42 @@ private static LogEntry CreateCall(LogRecord logRecord) private static LogEntry CreateRequest(LogRecord logRecord) { - throw new NotImplementedException("TODO: T1.2"); + var requestMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException($"Failed to deserialize request message: {logRecord.Message}"); + return new RequestLogEntry( + LineNo: logRecord.LineNo, + Timestamp: DateTimeOffset.Parse(logRecord.Timestamp), + PodName: logRecord.PodName, + Severity: ParseSeverity(requestMessage.Severity), + RequestId: requestMessage.RequestId, + Method: requestMessage.Method, + Path: requestMessage.Path, + StatusCode: requestMessage.StatusCode + ); } private static LogEntry CreateInternal(LogRecord logRecord) { - throw new NotImplementedException("TODO: T1.2"); + var internalMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException($"Failed to deserialize internal message: {logRecord.Message}"); + + // The exception field is formatted as ": ". + // Split it by the first colon. + var exception = internalMessage.Exception; + var colonIndex = exception.IndexOf(':'); + if (colonIndex <= 0) + { + throw new FormatException($"Exception message is in an invalid format: {exception}"); + } + + return new InternalLogEntry( + LineNo: logRecord.LineNo, + Timestamp: DateTimeOffset.Parse(logRecord.Timestamp), + PodName: logRecord.PodName, + Severity: ParseSeverity(internalMessage.Severity), + ExceptionName: exception[..colonIndex].Trim(), + ExceptionMessage: exception[(colonIndex + 1)..].Trim() + ); } private static LogSeverity ParseSeverity(string severity) @@ -77,11 +107,16 @@ private record CallMessage( ); private record RequestMessage( - // TODO: T1.2 + [property: JsonRequired] string Severity, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Method, + [property: JsonRequired] string Path, + [property: JsonRequired] int StatusCode ); private record InternalMessage( - // TODO: T1.2 + [property: JsonRequired] string Severity, + [property: JsonRequired] string Exception ); } } diff --git a/src/LogParser/Visitors/KeyValueVisitor.cs b/src/LogParser/Visitors/KeyValueVisitor.cs index e5ceba2..f70bcc2 100644 --- a/src/LogParser/Visitors/KeyValueVisitor.cs +++ b/src/LogParser/Visitors/KeyValueVisitor.cs @@ -26,12 +26,32 @@ public Dictionary Visit(CallLogEntry entry) public Dictionary Visit(RequestLogEntry entry) { - throw new NotImplementedException("TODO: T1.3"); + return new Dictionary + { + ["LineNo"] = entry.LineNo.ToString(), + ["Timestamp"] = entry.Timestamp.ToString("O"), + ["PodName"] = entry.PodName, + ["Severity"] = entry.Severity.ToString(), + ["EventType"] = entry.EventType.ToString(), + ["RequestId"] = entry.RequestId, + ["Method"] = entry.Method, + ["Path"] = entry.Path, + ["StatusCode"] = entry.StatusCode.ToString(), + }; } public Dictionary Visit(InternalLogEntry entry) { - throw new NotImplementedException("TODO: T1.3"); + return new Dictionary + { + ["LineNo"] = entry.LineNo.ToString(), + ["Timestamp"] = entry.Timestamp.ToString("O"), + ["PodName"] = entry.PodName, + ["Severity"] = entry.Severity.ToString(), + ["EventType"] = entry.EventType.ToString(), + ["ExceptionName"] = entry.ExceptionName, + ["ExceptionMessage"] = entry.ExceptionMessage, + }; } } } From 77c723d404961feb7d2e12c3b5eb780a3386d8b3 Mon Sep 17 00:00:00 2001 From: sililass Date: Thu, 3 Sep 2026 04:12:50 +0800 Subject: [PATCH 2/3] feat(ch02): implement WorkQueue, LogFileAnalyzer, and LocalCli --- docs/02-multithreading/report.md | 161 +++++++++++++++++++++++++++++ src/LocalCli/Program.cs | 86 ++++++++++++++- src/LogAnalyzer/LogFileAnalyzer.cs | 67 ++++++++++-- src/LogAnalyzer/WorkQueue.cs | 38 ++++++- 4 files changed, 336 insertions(+), 16 deletions(-) create mode 100644 docs/02-multithreading/report.md diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..34729a8 --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,161 @@ +# 02-multithreading 实验报告 + +## 实现的功能介绍 + +本节完成了三个部分: + +### T2.1 线程安全队列 `WorkQueue`(`src/LogAnalyzer/WorkQueue.cs`) + +基于 C# 非线程安全的 `Queue` 实现了一个线程安全队列,作为"无限仓库容量"生产者消费者模型: + ++ `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` 类中的共享变量与保护方式 + ++ 共享变量: + - `_items`:`Queue`,队列本身; + - `_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 主要是把已有接口串起来并做好异常捕获,难度不大。总体属于需要认真思考并发同步、但逻辑并不复杂的程度。 + diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..a8ce119 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -112,22 +112,100 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var logFiles = analyzer.GetLogFiles(); + if (logFiles.Count == 0) + { + Console.WriteLine("There are no log files in the current directory."); + return; + } + + Console.WriteLine("Log files in the current directory:"); + 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 to analyze (separated by commas):"); + Console.Write(">>> "); + Console.Out.Flush(); + var input = Console.ReadLine(); + if (string.IsNullOrEmpty(input)) + { + return; + } + + var fileNames = input.Split(',') + .Select(name => name.Trim()) + .Where(name => !string.IsNullOrEmpty(name)) + .ToList(); + if (fileNames.Count == 0) + { + Console.WriteLine("Invalid input, please try again."); + return; + } + + try + { + analyzer.AnalyzeFiles(0, fileNames); + Console.WriteLine("Analysis completed."); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {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($"Analysis failed: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input a file name to query:"); + Console.Write(">>> "); + Console.Out.Flush(); + var fileName = Console.ReadLine(); + if (string.IsNullOrEmpty(fileName)) + { + return; + } + + if (!analyzer.TryGetAnalysisResult(fileName, out var result) || result is null) + { + Console.WriteLine($"No analysis result found for '{fileName}'."); + 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 kvResult = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", kvResult.Select(pair => $"{pair.Key}={pair.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"Analysis of '{fileName}' failed: {result.ErrorMessage}"); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..847585a 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -141,7 +141,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) /* * Set _isAnalyzing */ - // TODO: T2.2 + _isAnalyzing = true; } try @@ -154,7 +154,10 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) * Unset _isAnalyzing * Remember to lock _syncRoot to prevent data race */ - // TODO: T2.2 + lock (_syncRoot) + { + _isAnalyzing = false; + } } } @@ -169,7 +172,18 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis * Filter unparsed files. * If there is an unknown file, throw System.InvalidOperationException. */ - throw new NotImplementedException("TODO: T2.2"); + if (!_analysisResults.TryGetValue(file.Name, out var existingResult)) + { + throw new InvalidOperationException($"File '{file.Name}' is unknown to the analyzer."); + } + + // Skip files that have already been analyzed. + if (existingResult.State != AnalysisState.NotAnalyzed) + { + continue; + } + + logFilesToParse.Add(file); } } @@ -183,7 +197,11 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis /* * Enqueue log files */ - // TODO: T2.2 + foreach (var file in logFilesToParse) + { + queue.Enqueue(file); + } + queue.CompleteAdding(); degreeOfParallelism = Math.Max(Math.Min(degreeOfParallelism, logFilesToParse.Count), 1); var workers = new Thread[degreeOfParallelism]; @@ -194,13 +212,21 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis /* * Create and start threads to run `WorkerMain` */ - // TODO: T2.2 + workers[i] = new Thread(() => WorkerMain(workerId, queue)) + { + IsBackground = true, + Name = threadName, + }; + workers[i].Start(); } /* * Wait for (join) all threads to end */ - // TODO: T2.2 + foreach (var worker in workers) + { + worker.Join(); + } } private void WorkerMain(int workerId, WorkQueue queue) @@ -213,19 +239,42 @@ private void WorkerMain(int workerId, WorkQueue queue) try { // Parse file - throw new NotImplementedException("TODO: T2.2"); + List entries; + using (var reader = new StreamReader(file.FullName)) + { + entries = parser.Parse(reader).ToList(); + } + + result = new AnalysisResult( + FileName: file.Name, + FullName: file.FullName, + State: AnalysisState.Succeeded, + Entries: entries, + ErrorMessage: null, + WorkerId: workerId + ); } catch (Exception ex) { // Save exception message to result - throw new NotImplementedException("TODO: T2.2"); + result = new AnalysisResult( + FileName: file.Name, + FullName: file.FullName, + State: AnalysisState.Failed, + Entries: Array.Empty(), + ErrorMessage: ex.Message, + WorkerId: workerId + ); } /* * Save parse result. * [!Important] Remember to lock _syncRoot to prevent data race. */ - throw new NotImplementedException("TODO: T2.2"); + lock (_syncRoot) + { + _analysisResults[file.Name] = result; + } } } } diff --git a/src/LogAnalyzer/WorkQueue.cs b/src/LogAnalyzer/WorkQueue.cs index 23055a5..97b9d72 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,49 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + if (_isCompleted) + { + throw new InvalidOperationException("Cannot enqueue after CompleteAdding has been called."); + } + + _items.Enqueue(item); + // Wake up one (or all) waiting consumer(s). + Monitor.Pulse(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + // Wait while the queue is empty and adding is not yet complete. + // A while loop is required to handle spurious wakeups (MESA model). + while (_items.Count == 0 && !_isCompleted) + { + Monitor.Wait(_items); + } + + if (_items.Count > 0) + { + item = _items.Dequeue()!; + return true; + } + + item = default; + return false; + } } public void CompleteAdding() { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + _isCompleted = true; + // Wake up all consumers so they can notice completion and exit. + Monitor.PulseAll(_items); + } } } } From d03dce6c952f6d411dff48ab2f96a9366924909a Mon Sep 17 00:00:00 2001 From: sililass Date: Thu, 3 Sep 2026 04:13:01 +0800 Subject: [PATCH 3/3] feat(ch03): implement gRPC Agent, RemoteCli, and type converters --- docs/03-async-grpc/report.md | 133 ++++++++++++++ .../Applications/AgentSession.cs | 155 +++++++++++++++- src/LogAnalyzerAgent/Services/AgentService.cs | 17 +- src/LogAnalyzerRpc/GrpcLogEntryVisitor.cs | 30 +++- src/LogAnalyzerRpc/GrpcTypeConverter.cs | 52 +++++- src/RemoteCli/Program.cs | 170 +++++++++++++++++- 6 files changed, 533 insertions(+), 24 deletions(-) create mode 100644 docs/03-async-grpc/report.md diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..9243b29 --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -0,0 +1,133 @@ +# 03-async-grpc 实验报告 + +## 实现的功能介绍 + +本节完成了 Agent(gRPC 服务端)与 RemoteCli(gRPC 客户端)两部分。 + +### T3.1 gRPC Agent(`src/LogAnalyzerRpc`、`src/LogAnalyzerAgent`) + ++ `GrpcLogEntryVisitor`:实现访问者模式,将 `RequestLogEntry`、`InternalLogEntry` 分别转换为 Protobuf 的 `RequestLogEntryMessage` / `InternalLogEntryMessage`,填入 `LogEntryMessage` 的 oneof 字段。 ++ `GrpcTypeConverter`:补全 `LogSeverity` ↔ `LogSeverityEnum`、`LogEventType` ↔ `LogEventTypeEnum` 的双向转换,以及 `ConvertFromGrpc(LogEntryMessage)` 对 Request / Internal 两种消息的反转换(重新构造 `RequestLogEntry` / `InternalLogEntry`,其中 Timestamp 用 `ToDateTimeOffset()` 还原)。 ++ `AgentSession`:实现四个处理逻辑: + - `ChangeDirectory`:空路径返回 `INVALID_ARGUMENT`;目录不存在返回 `DIRECTORY_NOT_FOUND`;成功后返回当前目录与全部 `.log` 文件名。 + - `AnalyzeAll` / `AnalyzeFiles`:未设置目录返回 `INVALID_OPERATION`;`AnalyzeFiles` 对空文件列表返回 `INVALID_ARGUMENT`,对包含不存在文件名的输入(`ArgumentException`)返回 `FILE_NOT_FOUND`,对其他非法操作(`InvalidOperationException`)返回 `INVALID_OPERATION`,其余异常统一转为 `INTERNAL_ERROR`,保证 Agent 永不因用户非法输入而崩溃。 + - `GetAnalysisResult`:文件不存在返回单条 `FILE_NOT_FOUND` 响应;否则返回「header(`AnalysisResultHeaderMessage`,含状态 / 错误信息 / worker id)+ 每条日志一个 `LogEntryMessage`」的响应列表。 ++ `AgentService`:作为 gRPC 服务入口,将每个 RPC 转交给 `AgentSession`;`GetAnalysisResult` 使用服务端流式返回,逐个 `WriteAsync`。 + +### T3.2 RemoteCli(`src/RemoteCli/Program.cs`) + +参照上一节 `LocalCli` 改造为全异步 gRPC 调用(全部使用 `...Async` 方法): + ++ `ShowLogFiles`:调用 `GetLogFilesAsync` 列出文件。 ++ `ReadDegreeOfParallelism`:读取并发度(`0` 表示自动),非法输入循环重试。 ++ `ReadFileNames`:读取逗号分隔的文件名列表,非法输入循环重试。 ++ `AnalyzeFiles` / `AnalyzeAll`:先读并发度(及文件名),再异步调用分析接口,捕获 RPC 异常与业务错误并提示。 ++ `GetAnalysisResult`:用 `client.GetAnalysisResult(request)` 发起服务端流式调用,通过 `ResponseStream.ReadAllAsync()` 逐条读取;打印 header 后用 `KeyValueVisitor.Dump` 输出每条日志的键值对。 + +## 功能记录(终端输出) + +以下为 `src/` 目录下先启动 `LogAnalyzerAgent`(监听 `http://localhost:7777`),再运行 `RemoteCli` 的完整功能流程记录(命令行环境无法截取位图,故以终端文本记录代替截图): + +``` +Connecting to agent at http://localhost:7777... + +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. +>>> 5 <- 功能5:设置日志目录 +Please input directory containing log files: +>>> dataset + +Please choose: ... +>>> 1 <- 功能1:列出日志文件 +Log files in the current directory: +- basic-fail.log +- basic-multiple.log +- basic.log + +Please choose: ... +>>> 3 <- 功能3:分析全部文件 +Please input degree of parallelism (0 for auto): +>>> 0 +Analysis completed. + +Please choose: ... +>>> 4 <- 功能4:查询分析结果 +Please input a file name to query: +>>> basic.log +Analysis result for 'basic.log': + State: Succeeded, WorkerId: 2 + Entries: + 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: ... +>>> 4 +Please input a file name to query: +>>> nonexist.log <- 查询不存在的文件 +Error: FileNotFound: File 'nonexist.log' is not found. + +Please choose: ... +>>> 2 +Please input degree of parallelism (0 for auto): +>>> 0 +Please input file names to analyze (separated by commas): +>>> basic.log, no-such-file.log <- 分析列表中含不存在的文件 +Analysis failed: FileNotFound: 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 <- 切换到不存在的目录 +Error: DirectoryNotFound: Directory 'nonexist-dir' does not exist., please try again: +Please input directory containing log files: +>>> dataset <- 重新输入有效目录,恢复正常 +``` + +可以看到,查询不存在的文件、分析包含不存在文件名的列表、切换到不存在的目录等非法输入,都会被 Agent 以业务错误码(`FILE_NOT_FOUND` / `DIRECTORY_NOT_FOUND`)返回并由 RemoteCli 友好提示,程序与 Agent 都不会崩溃。 + + +## (Q3.1) + +我认为开发网络应用程序与开发非网络应用程序的主要区别有以下几点: + +1. **程序结构从「单机函数调用」变为「分布式服务」**:非网络程序在同一个进程内直接调用函数、共享内存;网络程序则需要把逻辑拆分为服务端与客户端,通过 RPC 在网络上交换数据。因此业务逻辑被拆成「服务端如何提供能力」和「客户端如何请求能力」两个面,开发时必须在两端同时考虑。 +2. **数据不再是本地的对象,而是需要「序列化 / 反序列化」**:本地程序直接引用对象即可;网络程序传输的是字节流(本作业中是 Protobuf 消息),因此需要额外编写类型转换层(`GrpcTypeConverter`、`GrpcLogEntryVisitor`),并维护 C# 类型与 Protobuf 类型、以及两边命名法(PascalCase ↔ snake_case)的一致性。 +3. **必须考虑网络的不确定性**:网络请求可能超时、被中断、服务端不可达;返回的错误除了「业务错误」(如文件不存在)还有「传输层错误」(RpcException)。因此客户端需要做异常捕获,服务端需要保证对任何输入都不会崩溃(这直接影响服务的可用性)。 +4. **并发与状态管理更复杂**:Agent 作为常驻服务是「有状态」的(保存当前目录、分析结果),且可能同时被多个客户端调用,必须通过依赖注入将 `LogFileAnalyzer` / `AgentSession` / `AgentService` 注册为单例,并注意内部共享状态的线程安全;非网络程序则没有「并发请求」这个维度。 +5. **调试与部署方式不同**:需要同时启动服务端与客户端两个进程来联调,难以像单机程序那样一步打断点;启动参数(监听地址、端口)、环境变量(`ASPNETCORE_URLS`)等也引入了额外复杂度。 + +额外的难点与复杂之处总结为:类型系统的跨语言/跨进程映射、错误处理的多层化(传输错误 + 业务错误)、有状态服务的并发安全、以及「服务端永不因非法输入崩溃」这一硬性要求。 + +## (Q3.2.b) + +本次作业使用了 AI(Cline 编码助手)。 + +### 我给予 AI 的提示词 + +> 阅读 `docs/03-async-grpc` 的说明文档、`src/test-03-async-grpc` 测试代码以及 `Protos/log_analyzer.proto`,补齐 `GrpcTypeConverter`、`GrpcLogEntryVisitor`、`AgentSession`、`AgentService` 与 `RemoteCli/Program.cs` 中的 TODO 实现,使 `dotnet test test-03-async-grpc -c Release` 全部通过;随后启动 Agent 并运行 RemoteCli 做端到端验证,最后撰写 `docs/03-async-grpc/report.md`。 + +### 对 AI 的使用方式 + +主要是让 AI 编写一部分作业代码(T3.1 / T3.2 的 TODO 实现),同时也向 AI 询问了一些 gRPC 客户端流式调用的具体写法(例如如何用 `ResponseStream.ReadAllAsync()` 读取服务端流),以及 Protobuf oneof 字段的 C# 使用方式。 + +### AI 的解答是否出现过错误 + +在初版实现中没有出现功能性错误,测试一次通过;但 AI 在端到端联调时一度没有注意到 `RemoteCli` 与 `LocalCli` 的不同——`RemoteCli` 启动后**不会自动提示输入目录**,需要先通过菜单选项 5 切换目录,导致最初的联调输入序列里目录设置没有生效。经分析 `Program.cs` 框架后修正了联调流程。 + +### 从 AI 那里得知的新知识 + ++ gRPC 服务端流式返回在 C# 中通过在 `IServerStreamWriter` 上反复 `WriteAsync` 实现,客户端则通过 `call.ResponseStream.ReadAllAsync()` 逐条读取。 ++ Protobuf 的 `oneof` 字段在 C# 中表现为 `PayloadCase` 枚举(如 `GetAnalysisResultResponse.PayloadOneofCase.Header`)与对应的 `Header` / `LogEntry` 属性,只能设置其中一个。 ++ `optional string` 字段会生成 `HasErrorMessage` 属性用于判断字段是否被显式设置。 ++ 有状态 gRPC 服务需要把相关类注册为单例(`AddSingleton`),否则每次请求都会新建状态导致相互覆盖。 + diff --git a/src/LogAnalyzerAgent/Applications/AgentSession.cs b/src/LogAnalyzerAgent/Applications/AgentSession.cs index 2531f22..5836485 100644 --- a/src/LogAnalyzerAgent/Applications/AgentSession.cs +++ b/src/LogAnalyzerAgent/Applications/AgentSession.cs @@ -3,6 +3,7 @@ using LogAnalyzer; using LogAnalyzerRpc.Protos; using LogAnalyzerRpc; +using LogParser.Models; using LogParser.Visitors; namespace LogAnalyzerAgent.Applications @@ -79,22 +80,168 @@ public Task GetLogFiles(Empty empty, CancellationToken canc public Task ChangeDirectory(ChangeDirectoryRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var response = new ChangeDirectoryResponse(); + try + { + if (string.IsNullOrEmpty(request.DirectoryPath)) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "Directory path cannot be empty."); + return Task.FromResult(response); + } + + if (!_analyzer.ChangeDirectory(request.DirectoryPath)) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.DirectoryNotFound, + $"Directory '{request.DirectoryPath}' does not exist."); + return Task.FromResult(response); + } + + response.CurrentDirectory = _analyzer.CurrentDirectory ?? ""; + response.FileNames.AddRange(_analyzer.GetLogFiles()); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while changing directory."); + } + return Task.FromResult(response); } public Task AnalyzeAll(AnalyzeAllRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var response = new AnalyzeAllResponse(); + try + { + if (!_analyzer.HasDirectory) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + "No directory has been set yet."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while analyzing all log files."); + } + return Task.FromResult(response); } public Task AnalyzeFiles(AnalyzeFilesRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var response = new AnalyzeFilesResponse(); + try + { + if (!_analyzer.HasDirectory) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + "No directory has been set yet."); + return Task.FromResult(response); + } + + if (request.FileNames.Count == 0) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "No file names are specified."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, request.FileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentException ex) + { + response.Status = CreateErrorOperationStatus(AgentErrorCode.FileNotFound, ex.Message); + _logger.LogError(ex, "An error occurred while analyzing specified log files."); + } + catch (InvalidOperationException ex) + { + response.Status = CreateErrorOperationStatus(AgentErrorCode.InvalidOperation, ex.Message); + _logger.LogError(ex, "An error occurred while analyzing specified log files."); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while analyzing specified log files."); + } + return Task.FromResult(response); } public IReadOnlyList GetAnalysisResult(GetAnalysisResultRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var responses = new List(); + try + { + if (!_analyzer.TryGetAnalysisResult(request.FileName, out var result) || result is null) + { + responses.Add(new GetAnalysisResultResponse() + { + Status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, + $"File '{request.FileName}' is not found.") + }); + return responses; + } + + var header = new AnalysisResultHeaderMessage() + { + FileName = result.FileName, + FullName = result.FullName, + State = GrpcTypeConverter.ConvertToGrpc(result.State), + WorkerId = result.WorkerId, + }; + if (result.ErrorMessage is not null) + { + header.ErrorMessage = result.ErrorMessage; + } + + responses.Add(new GetAnalysisResultResponse() + { + Header = header, + Status = CreateNoErrorOperationStatus() + }); + + foreach (var entry in result.Entries) + { + responses.Add(new GetAnalysisResultResponse() + { + LogEntry = GrpcTypeConverter.ConvertToGrpc(entry), + Status = CreateNoErrorOperationStatus() + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while retrieving analysis result."); + return new List() + { + new GetAnalysisResultResponse() + { + Status = CreateInternalErrorOperationStatus(ex) + } + }; + } + return responses; + } + + private static OperationStatusMessage CreateErrorOperationStatus(AgentErrorCode code, string message) + { + return new OperationStatusMessage() + { + Success = false, + Code = code, + Message = message, + }; } } } diff --git a/src/LogAnalyzerAgent/Services/AgentService.cs b/src/LogAnalyzerAgent/Services/AgentService.cs index 591dcad..1bc03eb 100644 --- a/src/LogAnalyzerAgent/Services/AgentService.cs +++ b/src/LogAnalyzerAgent/Services/AgentService.cs @@ -29,27 +29,32 @@ public override Task GetAgentStatus(Empty empty, ServerC public override Task ChangeDirectory(ChangeDirectoryRequest request, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + return _session.ChangeDirectory(request, context.CancellationToken); } public override Task GetLogFiles(Empty empty, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + return _session.GetLogFiles(empty, context.CancellationToken); } public override Task AnalyzeAll(AnalyzeAllRequest request, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + return _session.AnalyzeAll(request, context.CancellationToken); } public override Task AnalyzeFiles(AnalyzeFilesRequest request, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + return _session.AnalyzeFiles(request, context.CancellationToken); } public override async Task GetAnalysisResult(GetAnalysisResultRequest request, IServerStreamWriter responseStream, ServerCallContext context) { - throw new NotImplementedException("TODO: T3.1"); + var responses = _session.GetAnalysisResult(request, context.CancellationToken); + foreach (var response in responses) + { + await responseStream.WriteAsync(response); + } + } + } - } } diff --git a/src/LogAnalyzerRpc/GrpcLogEntryVisitor.cs b/src/LogAnalyzerRpc/GrpcLogEntryVisitor.cs index eb69232..3196aac 100644 --- a/src/LogAnalyzerRpc/GrpcLogEntryVisitor.cs +++ b/src/LogAnalyzerRpc/GrpcLogEntryVisitor.cs @@ -30,12 +30,38 @@ public LogEntryMessage Visit(CallLogEntry entry) public LogEntryMessage Visit(RequestLogEntry entry) { - throw new NotImplementedException("TODO: T3.1"); + return new LogEntryMessage() + { + RequestLogEntry = new RequestLogEntryMessage + { + LineNo = entry.LineNo, + Timestamp = Timestamp.FromDateTimeOffset(entry.Timestamp), + PodName = entry.PodName, + Severity = GrpcTypeConverter.ConvertToGrpc(entry.Severity), + EventType = GrpcTypeConverter.ConvertToGrpc(entry.EventType), + RequestId = entry.RequestId, + Method = entry.Method, + Path = entry.Path, + StatusCode = entry.StatusCode, + } + }; } public LogEntryMessage Visit(InternalLogEntry entry) { - throw new NotImplementedException("TODO: T3.1"); + return new LogEntryMessage() + { + InternalLogEntry = new InternalLogEntryMessage + { + LineNo = entry.LineNo, + Timestamp = Timestamp.FromDateTimeOffset(entry.Timestamp), + PodName = entry.PodName, + Severity = GrpcTypeConverter.ConvertToGrpc(entry.Severity), + EventType = GrpcTypeConverter.ConvertToGrpc(entry.EventType), + ExceptionName = entry.ExceptionName, + ExceptionMessage = entry.ExceptionMessage, + } + }; } } } diff --git a/src/LogAnalyzerRpc/GrpcTypeConverter.cs b/src/LogAnalyzerRpc/GrpcTypeConverter.cs index 029122e..aa31134 100644 --- a/src/LogAnalyzerRpc/GrpcTypeConverter.cs +++ b/src/LogAnalyzerRpc/GrpcTypeConverter.cs @@ -20,12 +20,24 @@ public static AnalysisStateEnum ConvertToGrpc(AnalysisState state) public static LogSeverityEnum ConvertToGrpc(LogSeverity severity) { - throw new NotImplementedException("TODO: T3.1"); + return severity switch + { + LogSeverity.Info => LogSeverityEnum.Info, + LogSeverity.Warning => LogSeverityEnum.Warning, + LogSeverity.Error => LogSeverityEnum.Error, + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null) + }; } public static LogEventTypeEnum ConvertToGrpc(LogEventType eventType) { - throw new NotImplementedException("TODO: T3.1"); + return eventType switch + { + LogEventType.Call => LogEventTypeEnum.Call, + LogEventType.Request => LogEventTypeEnum.Request, + LogEventType.Internal => LogEventTypeEnum.Internal, + _ => throw new ArgumentOutOfRangeException(nameof(eventType), eventType, null) + }; } public static LogEntryMessage ConvertToGrpc(LogEntry entry) @@ -46,12 +58,24 @@ public static AnalysisState ConvertFromGrpc(AnalysisStateEnum state) public static LogSeverity ConvertFromGrpc(LogSeverityEnum severity) { - throw new NotImplementedException("TODO: T3.1"); + return severity switch + { + LogSeverityEnum.Info => LogSeverity.Info, + LogSeverityEnum.Warning => LogSeverity.Warning, + LogSeverityEnum.Error => LogSeverity.Error, + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null) + }; } public static LogEventType ConvertFromGrpc(LogEventTypeEnum eventType) { - throw new NotImplementedException("TODO: T3.1"); + return eventType switch + { + LogEventTypeEnum.Call => LogEventType.Call, + LogEventTypeEnum.Request => LogEventType.Request, + LogEventTypeEnum.Internal => LogEventType.Internal, + _ => throw new ArgumentOutOfRangeException(nameof(eventType), eventType, null) + }; } public static LogEntry ConvertFromGrpc(LogEntryMessage entryMessage) @@ -67,8 +91,24 @@ public static LogEntry ConvertFromGrpc(LogEntryMessage entryMessage) TargetService: entryMessage.CallLogEntry.TargetService, DurationMs: entryMessage.CallLogEntry.DurationMs ), - LogEntryMessage.EntryOneofCase.RequestLogEntry => throw new NotImplementedException("TODO: T3.1"), - LogEntryMessage.EntryOneofCase.InternalLogEntry => throw new NotImplementedException("TODO: T3.1"), + LogEntryMessage.EntryOneofCase.RequestLogEntry => new RequestLogEntry( + LineNo: entryMessage.RequestLogEntry.LineNo, + Timestamp: entryMessage.RequestLogEntry.Timestamp.ToDateTimeOffset(), + PodName: entryMessage.RequestLogEntry.PodName, + Severity: ConvertFromGrpc(entryMessage.RequestLogEntry.Severity), + RequestId: entryMessage.RequestLogEntry.RequestId, + Method: entryMessage.RequestLogEntry.Method, + Path: entryMessage.RequestLogEntry.Path, + StatusCode: entryMessage.RequestLogEntry.StatusCode + ), + LogEntryMessage.EntryOneofCase.InternalLogEntry => new InternalLogEntry( + LineNo: entryMessage.InternalLogEntry.LineNo, + Timestamp: entryMessage.InternalLogEntry.Timestamp.ToDateTimeOffset(), + PodName: entryMessage.InternalLogEntry.PodName, + Severity: ConvertFromGrpc(entryMessage.InternalLogEntry.Severity), + ExceptionName: entryMessage.InternalLogEntry.ExceptionName, + ExceptionMessage: entryMessage.InternalLogEntry.ExceptionMessage + ), _ => throw new ArgumentException($"Unknown entry type: {entryMessage.EntryCase}", nameof(entryMessage)) }; } diff --git a/src/RemoteCli/Program.cs b/src/RemoteCli/Program.cs index de0ac99..cdf85ff 100644 --- a/src/RemoteCli/Program.cs +++ b/src/RemoteCli/Program.cs @@ -116,32 +116,190 @@ 6. Exit. private static async Task ShowLogFiles(LogAnalyzerAgentServiceClient client) { - throw new NotImplementedException("TODO: T3.2"); + GetLogFilesResponse response; + try + { + response = await client.GetLogFilesAsync(new Empty()); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to get log files: {ex.Message}"); + return; + } + + if (!response.Status.Success) + { + Console.WriteLine($"Error: {response.Status.Code}: {response.Status.Message}"); + return; + } + + if (response.FileNames.Count == 0) + { + Console.WriteLine("There are no log files in the current directory."); + return; + } + + Console.WriteLine("Log files in the current directory:"); + foreach (var fileName in response.FileNames) + { + Console.WriteLine($"- {fileName}"); + } } private static int ReadDegreeOfParallelism() { - throw new NotImplementedException("TODO: T3.2"); + while (true) + { + Console.WriteLine("Please input degree of parallelism (0 for auto):"); + Console.Write(">>> "); + Console.Out.Flush(); + var input = Console.ReadLine(); + if (int.TryParse(input, out var degree) && degree >= 0) + { + return degree; + } + Console.WriteLine("Invalid input, please try again."); + } } private static List ReadFileNames() { - throw new NotImplementedException("TODO: T3.2"); + while (true) + { + Console.WriteLine("Please input file names to analyze (separated by commas):"); + Console.Write(">>> "); + Console.Out.Flush(); + var input = Console.ReadLine(); + if (string.IsNullOrEmpty(input)) + { + Console.WriteLine("Invalid input, please try again."); + continue; + } + + var fileNames = input.Split(',') + .Select(name => name.Trim()) + .Where(name => !string.IsNullOrEmpty(name)) + .ToList(); + if (fileNames.Count == 0) + { + Console.WriteLine("Invalid input, please try again."); + continue; + } + return fileNames; + } } private static async Task AnalyzeFiles(LogAnalyzerAgentServiceClient client) { - throw new NotImplementedException("TODO: T3.2"); + var degree = ReadDegreeOfParallelism(); + var fileNames = ReadFileNames(); + var request = new AnalyzeFilesRequest() + { + DegreeOfParallelism = degree, + }; + request.FileNames.AddRange(fileNames); + + AnalyzeFilesResponse response; + try + { + response = await client.AnalyzeFilesAsync(request); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {ex.Message}"); + return; + } + + if (!response.Status.Success) + { + Console.WriteLine($"Analysis failed: {response.Status.Code}: {response.Status.Message}"); + return; + } + Console.WriteLine("Analysis completed."); } private static async Task AnalyzeAll(LogAnalyzerAgentServiceClient client) { - throw new NotImplementedException("TODO: T3.2"); + var degree = ReadDegreeOfParallelism(); + var request = new AnalyzeAllRequest() + { + DegreeOfParallelism = degree, + }; + + AnalyzeAllResponse response; + try + { + response = await client.AnalyzeAllAsync(request); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {ex.Message}"); + return; + } + + if (!response.Status.Success) + { + Console.WriteLine($"Analysis failed: {response.Status.Code}: {response.Status.Message}"); + return; + } + Console.WriteLine("Analysis completed."); } private static async Task GetAnalysisResult(LogAnalyzerAgentServiceClient client) { - throw new NotImplementedException("TODO: T3.2"); + Console.WriteLine("Please input a file name to query:"); + Console.Write(">>> "); + Console.Out.Flush(); + var fileName = Console.ReadLine(); + if (string.IsNullOrEmpty(fileName)) + { + return; + } + + var request = new GetAnalysisResultRequest() + { + FileName = fileName, + }; + + try + { + using var call = client.GetAnalysisResult(request); + var visitor = new KeyValueVisitor(); + await foreach (var response in call.ResponseStream.ReadAllAsync()) + { + if (!response.Status.Success) + { + Console.WriteLine($"Error: {response.Status.Code}: {response.Status.Message}"); + return; + } + + switch (response.PayloadCase) + { + case GetAnalysisResultResponse.PayloadOneofCase.Header: + var header = response.Header; + Console.WriteLine($"Analysis result for '{header.FileName}':"); + Console.WriteLine($" State: {header.State}, WorkerId: {header.WorkerId}"); + if (header.HasErrorMessage) + { + Console.WriteLine($" ErrorMessage: {header.ErrorMessage}"); + } + if (header.State == AnalysisStateEnum.Succeeded) + { + Console.WriteLine(" Entries:"); + } + break; + case GetAnalysisResultResponse.PayloadOneofCase.LogEntry: + var entry = GrpcTypeConverter.ConvertFromGrpc(response.LogEntry); + var kvResult = visitor.Dump(entry); + Console.WriteLine($" {string.Join(", ", kvResult.Select(pair => $"{pair.Key}={pair.Value}"))}"); + break; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to get analysis result: {ex.Message}"); + } } } }