diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..15cb63d --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,31 @@ +# 01-basic 问答报告 + +## Q1.1 + +日志的 CSV 字段不是通过手动调用字符串的 `Split` 方法进行分割的,而是由 CsvHelper 完成。`LogFileParser.Parse` 中的 `csv.GetRecords()` 逐条读取 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(logRecord.Message, options)`,将 JSON 反序列化为对应的消息记录。各消息记录的属性使用了 `[property: JsonRequired]`,因此必需字段缺失时,反序列化会抛出 `JsonException`。`options` 的 `PropertyNamingPolicy` 被设置为 `JsonNamingPolicy.KebabCaseLower`,所以 C# 中的 `RequestId`、`StatusCode` 等大驼峰属性能够分别对应 JSON 中的 `request-id`、`status-code` 等烤串命名字段。 + +## Q1.2 + +以 Call 事件为例,调用链如下: + +1. `Dictionary KeyValueVisitor.Dump(LogEntry entry)` +2. `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)` +3. `Dictionary 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 整理项目内信息更快;相比自己独立完成,它减少了探索过程,但也更容易让我在没有理解代码的情况下直接接受答案。 diff --git a/docs/02-multithreading/assets/t2.3-functional.png b/docs/02-multithreading/assets/t2.3-functional.png new file mode 100644 index 0000000..691b732 Binary files /dev/null and b/docs/02-multithreading/assets/t2.3-functional.png differ diff --git a/docs/02-multithreading/assets/t2.3-robustness.png b/docs/02-multithreading/assets/t2.3-robustness.png new file mode 100644 index 0000000..afe3350 Binary files /dev/null and b/docs/02-multithreading/assets/t2.3-robustness.png differ diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..2cbbbfc --- /dev/null +++ b/docs/02-multithreading/report.md @@ -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` 中需要在线程间共享的状态是 `_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` 和 `LocalCli` 的代码,并运行 `test-02-multithreading` 以及控制台正常/非法输入会话进行验证。使用方式不仅是查询接口,还包括让 AI 编写部分作业代码、解释同步设计,并根据测试结果修正实现。 + +AI 的第一版 `WorkQueue` 能通过功能测试,但对 `Dequeue` 的赋值触发了 `CS8762` 可空性警告;随后通过与 `[NotNullWhen(true)]` 返回约定一致的空宽容标注消除了警告。`LogFileAnalyzer` 首次运行 T2.2 测试时也因为当时尚未实现 T2.1 的 `WorkQueue.Enqueue` 而全部停止,这不是 T2.2 本身的测试结论,因此完成队列后重新运行了完整测试。最终 `test-02-multithreading` 的 8 项测试全部通过。 + +我认为本节整体难度偏高。单个 `lock`、`Monitor.Wait` 或 `Thread.Join` 接口并不复杂,主要难点是同时维护队列状态和完成状态、使用 `while` 应对虚假唤醒、保证异常时恢复 `_isAnalyzing`,以及区分某个测试失败究竟来自队列还是日志分析器。 diff --git a/docs/03-async-grpc/assets/remote-cli-functional-result.png b/docs/03-async-grpc/assets/remote-cli-functional-result.png new file mode 100644 index 0000000..4106ca3 Binary files /dev/null and b/docs/03-async-grpc/assets/remote-cli-functional-result.png differ diff --git a/docs/03-async-grpc/assets/remote-cli-robustness-result.png b/docs/03-async-grpc/assets/remote-cli-robustness-result.png new file mode 100644 index 0000000..9c3cc6c Binary files /dev/null and b/docs/03-async-grpc/assets/remote-cli-robustness-result.png differ diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..b954ad4 --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -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` 等调用不同。 diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..3934102 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -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)) @@ -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; } } @@ -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? 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; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..36747da 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -1,7 +1,5 @@ using LogParser.Models; using LogParser.Parser; -using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography.X509Certificates; namespace LogAnalyzer { @@ -137,11 +135,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } } fileList = fileNameList.Select(fileName => _logFiles[fileName]).ToList(); - - /* - * Set _isAnalyzing - */ - // TODO: T2.2 + _isAnalyzing = true; } try @@ -150,11 +144,10 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } finally { - /* - * Unset _isAnalyzing - * Remember to lock _syncRoot to prevent data race - */ - // TODO: T2.2 + lock (_syncRoot) + { + _isAnalyzing = false; + } } } @@ -165,11 +158,15 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis { foreach (var file in fileList) { - /* - * 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 result)) + { + throw new InvalidOperationException($"Unknown log file: {file.Name}."); + } + + if (result.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } } } @@ -179,11 +176,11 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis } var queue = new WorkQueue(); - - /* - * 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]; @@ -191,16 +188,18 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis { int workerId = i; string threadName = $"log-analyzer-worker-{workerId}"; - /* - * 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) @@ -212,20 +211,33 @@ private void WorkerMain(int workerId, WorkQueue queue) AnalysisResult result; try { - // Parse file - throw new NotImplementedException("TODO: T2.2"); + using var reader = new StreamReader(file.FullName); + var 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..40799db 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,45 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + if (_isCompleted) + { + throw new InvalidOperationException("Cannot enqueue items after adding has been completed."); + } + + _items.Enqueue(item); + Monitor.Pulse(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + 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; + Monitor.PulseAll(_items); + } } } } diff --git a/src/LogAnalyzerAgent/Applications/AgentSession.cs b/src/LogAnalyzerAgent/Applications/AgentSession.cs index 2531f22..e27f660 100644 --- a/src/LogAnalyzerAgent/Applications/AgentSession.cs +++ b/src/LogAnalyzerAgent/Applications/AgentSession.cs @@ -79,22 +79,291 @@ public Task GetLogFiles(Empty empty, CancellationToken canc public Task ChangeDirectory(ChangeDirectoryRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + static OperationStatusMessage Error(AgentErrorCode code, string message) => new() + { + Success = false, + Code = code, + Message = message, + }; + + var response = new ChangeDirectoryResponse(); + try + { + if (request is null || string.IsNullOrWhiteSpace(request.DirectoryPath)) + { + response.Status = Error( + AgentErrorCode.InvalidArgument, + "Directory path cannot be empty."); + return Task.FromResult(response); + } + + if (_analyzer.IsAnalyzing) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "Cannot change directory while analysis is in progress."); + return Task.FromResult(response); + } + + var directoryPath = request.DirectoryPath.Trim(); + if (!Directory.Exists(directoryPath)) + { + response.Status = Error( + AgentErrorCode.DirectoryNotFound, + $"Directory '{directoryPath}' does not exist."); + return Task.FromResult(response); + } + + if (!_analyzer.ChangeDirectory(directoryPath)) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "Cannot change directory while analysis is in progress."); + 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"); + static OperationStatusMessage Error(AgentErrorCode code, string message) => new() + { + Success = false, + Code = code, + Message = message, + }; + + var response = new AnalyzeAllResponse(); + try + { + if (request is null || request.DegreeOfParallelism < 0) + { + response.Status = Error( + AgentErrorCode.InvalidArgument, + "Degree of parallelism must be a non-negative integer."); + return Task.FromResult(response); + } + + if (!_analyzer.HasDirectory) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "A log directory must be selected before analysis."); + return Task.FromResult(response); + } + + if (_analyzer.IsAnalyzing) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "Analysis is already in progress."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentOutOfRangeException ex) + { + response.Status = Error(AgentErrorCode.InvalidArgument, ex.Message); + } + catch (InvalidOperationException ex) + { + response.Status = Error(AgentErrorCode.InvalidOperation, ex.Message); + } + 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"); + static OperationStatusMessage Error(AgentErrorCode code, string message) => new() + { + Success = false, + Code = code, + Message = message, + }; + + var response = new AnalyzeFilesResponse(); + try + { + if (request is null || request.DegreeOfParallelism < 0) + { + response.Status = Error( + AgentErrorCode.InvalidArgument, + "Degree of parallelism must be a non-negative integer."); + return Task.FromResult(response); + } + + if (!_analyzer.HasDirectory) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "A log directory must be selected before analysis."); + return Task.FromResult(response); + } + + var fileNames = request.FileNames + .Select(fileName => fileName?.Trim() ?? "") + .Where(fileName => fileName.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (fileNames.Count == 0 || fileNames.Count != request.FileNames.Count) + { + response.Status = Error( + AgentErrorCode.InvalidArgument, + "At least one non-empty log file name is required."); + return Task.FromResult(response); + } + + var availableFiles = _analyzer.GetLogFiles().ToHashSet(StringComparer.Ordinal); + var missingFile = fileNames.FirstOrDefault(fileName => !availableFiles.Contains(fileName)); + if (missingFile is not null) + { + response.Status = Error( + AgentErrorCode.FileNotFound, + $"File '{missingFile}' does not exist in the current directory."); + return Task.FromResult(response); + } + + if (_analyzer.IsAnalyzing) + { + response.Status = Error( + AgentErrorCode.InvalidOperation, + "Analysis is already in progress."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, fileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (ArgumentOutOfRangeException ex) + { + response.Status = Error(AgentErrorCode.InvalidArgument, ex.Message); + } + catch (ArgumentException ex) + { + response.Status = Error(AgentErrorCode.FileNotFound, ex.Message); + } + catch (InvalidOperationException ex) + { + response.Status = Error(AgentErrorCode.InvalidOperation, ex.Message); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while analyzing selected log files."); + } + return Task.FromResult(response); } public IReadOnlyList GetAnalysisResult(GetAnalysisResultRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + static OperationStatusMessage Error(AgentErrorCode code, string message) => new() + { + Success = false, + Code = code, + Message = message, + }; + + try + { + if (request is null || string.IsNullOrWhiteSpace(request.FileName)) + { + return new[] + { + new GetAnalysisResultResponse + { + Status = Error( + AgentErrorCode.InvalidArgument, + "Log file name cannot be empty.") + } + }; + } + + if (!_analyzer.HasDirectory) + { + return new[] + { + new GetAnalysisResultResponse + { + Status = Error( + AgentErrorCode.InvalidOperation, + "A log directory must be selected before retrieving analysis results.") + } + }; + } + + var fileName = request.FileName.Trim(); + if (!_analyzer.TryGetAnalysisResult(fileName, out var result) || result is null) + { + return new[] + { + new GetAnalysisResultResponse + { + Status = Error( + AgentErrorCode.FileNotFound, + $"File '{fileName}' does not exist in the current directory.") + } + }; + } + + 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; + } + + var responses = new List + { + new() + { + Status = CreateNoErrorOperationStatus(), + Header = header, + } + }; + if (result.State == AnalysisState.Succeeded) + { + responses.AddRange(result.Entries.Select(entry => new GetAnalysisResultResponse + { + Status = CreateNoErrorOperationStatus(), + LogEntry = GrpcTypeConverter.ConvertToGrpc(entry), + })); + } + + return responses; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while retrieving analysis result."); + return new[] + { + new GetAnalysisResultResponse + { + Status = CreateInternalErrorOperationStatus(ex) + } + }; + } } } } diff --git a/src/LogAnalyzerAgent/Services/AgentService.cs b/src/LogAnalyzerAgent/Services/AgentService.cs index 591dcad..d38d1cf 100644 --- a/src/LogAnalyzerAgent/Services/AgentService.cs +++ b/src/LogAnalyzerAgent/Services/AgentService.cs @@ -29,27 +29,31 @@ 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/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..7fbba26 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,68 @@ 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 call 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"); + // 1. 读取 message 中的 JSON + var internalMessage = + JsonSerializer.Deserialize( + logRecord.Message, + options + ); + + // 2. 如果 JSON 读取失败,就报告格式错误 + if (internalMessage is null) + { + throw new FormatException( + $"Failed to deserialize internal message: {logRecord.Message}" + ); + } + + // 3. 找到异常名称和异常信息之间的“冒号+空格” + var separatorIndex = internalMessage.Exception.IndexOf( + ": ", + StringComparison.Ordinal + ); + + // 4. 没有找到正确的分隔符,说明日志格式错误 + if (separatorIndex <= 0 || + separatorIndex + 2 >= internalMessage.Exception.Length) + { + throw new FormatException( + $"Invalid exception format: {internalMessage.Exception}" + ); + } + + // 5. 创建并返回解析结果 + return new InternalLogEntry( + LineNo: logRecord.LineNo, + Timestamp: DateTimeOffset.Parse(logRecord.Timestamp), + PodName: logRecord.PodName, + Severity: ParseSeverity(internalMessage.Severity), + ExceptionName: internalMessage.Exception.Substring( + 0, + separatorIndex + ), + ExceptionMessage: internalMessage.Exception.Substring( + separatorIndex + 2 + ) + ); } private static LogSeverity ParseSeverity(string severity) @@ -77,11 +133,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, + }; } } } diff --git a/src/RemoteCli/Program.cs b/src/RemoteCli/Program.cs index de0ac99..42a1bf3 100644 --- a/src/RemoteCli/Program.cs +++ b/src/RemoteCli/Program.cs @@ -116,32 +116,161 @@ 6. Exit. private static async Task ShowLogFiles(LogAnalyzerAgentServiceClient client) { - throw new NotImplementedException("TODO: T3.2"); + var response = await client.GetLogFilesAsync(new Empty()); + if (!response.Status.Success) + { + Console.WriteLine($"Error: {response.Status.Code}: {response.Status.Message}"); + return; + } + + Console.WriteLine($"[{string.Join(", ", response.FileNames)}]"); } private static int ReadDegreeOfParallelism() { - throw new NotImplementedException("TODO: T3.2"); + Console.WriteLine("Please input degree of parallelism (0 uses the logical processor count):"); + var input = Console.ReadLine(); + if (input is null) + { + return -1; + } + + if (!int.TryParse(input.Trim(), out var degreeOfParallelism) + || degreeOfParallelism < 0) + { + Console.WriteLine("Degree of parallelism must be a non-negative integer."); + return -1; + } + + return degreeOfParallelism; } private static List ReadFileNames() { - throw new NotImplementedException("TODO: T3.2"); + Console.WriteLine("Please input log file names (comma separated):"); + var input = Console.ReadLine(); + if (input is null) + { + return new List(); + } + + var fileNames = input + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (fileNames.Count == 0) + { + Console.WriteLine("At least one log file name is required."); + return new List(); + } + + return fileNames; } private static async Task AnalyzeFiles(LogAnalyzerAgentServiceClient client) { - throw new NotImplementedException("TODO: T3.2"); + var degreeOfParallelism = ReadDegreeOfParallelism(); + if (degreeOfParallelism < 0) + { + return; + } + + var fileNames = ReadFileNames(); + if (fileNames.Count == 0) + { + return; + } + + var request = new AnalyzeFilesRequest + { + DegreeOfParallelism = degreeOfParallelism, + }; + request.FileNames.AddRange(fileNames); + var response = await client.AnalyzeFilesAsync(request); + if (!response.Status.Success) + { + Console.WriteLine($"Unable to analyze files: {response.Status.Code}: {response.Status.Message}"); + return; + } + + Console.WriteLine($"Analysis completed: [{string.Join(", ", fileNames)}]"); } private static async Task AnalyzeAll(LogAnalyzerAgentServiceClient client) { - throw new NotImplementedException("TODO: T3.2"); + var degreeOfParallelism = ReadDegreeOfParallelism(); + if (degreeOfParallelism < 0) + { + return; + } + + var response = await client.AnalyzeAllAsync(new AnalyzeAllRequest + { + DegreeOfParallelism = degreeOfParallelism, + }); + if (!response.Status.Success) + { + Console.WriteLine($"Unable to analyze files: {response.Status.Code}: {response.Status.Message}"); + return; + } + + var filesResponse = await client.GetLogFilesAsync(new Empty()); + IEnumerable fileNames = filesResponse.Status.Success + ? filesResponse.FileNames + : Array.Empty(); + Console.WriteLine($"Analysis completed: [{string.Join(", ", fileNames)}]"); } private static async Task GetAnalysisResult(LogAnalyzerAgentServiceClient client) { - throw new NotImplementedException("TODO: T3.2"); + Console.WriteLine("Please input log file name:"); + var fileName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(fileName)) + { + Console.WriteLine("Log file name cannot be empty."); + return; + } + + using var call = client.GetAnalysisResult(new GetAnalysisResultRequest + { + FileName = fileName.Trim(), + }); + 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; + switch (header.State) + { + case AnalysisStateEnum.NotAnalyzed: + Console.WriteLine($"File {header.FileName} has not been analyzed yet."); + break; + case AnalysisStateEnum.Failed: + Console.WriteLine($"Analysis failed for {header.FileName}: {header.ErrorMessage}"); + break; + case AnalysisStateEnum.Succeeded: + Console.WriteLine($"Analysis result for {header.FileName}:"); + break; + default: + Console.WriteLine($"File {header.FileName} has an unknown analysis state."); + break; + } + break; + case GetAnalysisResultResponse.PayloadOneofCase.LogEntry: + var keyValues = visitor.Dump(GrpcTypeConverter.ConvertFromGrpc(response.LogEntry)); + Console.WriteLine(string.Join(", ", + keyValues.Select(keyValue => $"{keyValue.Key}: {keyValue.Value}"))); + break; + } + } } } }