diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..cefb067 --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,23 @@ + 01-basic 问答题 +Q1.1 +1. 分割与映射:使用 `CsvHelper` 库的 `LogRecordMap` 类,通过 `Map(m => m.LineNo).Index(0)` 指定第 0 列为行号,第 1 列时间戳,第 2 列 Pod 名,第 3 列为 Message。 + +2. 类型判断:在 `LineParser.ParseLine` 方法中,通过 `JsonDocument.Parse` 解析 `Message`,再用 `TryGetProperty("event", out var eventElement)` 获取事件字段,最后用 `switch` 匹配 `"call"`、`"request"`、`"internal"`。 + +3. JSON 解析方法:调用 `System.Text.Json.JsonSerializer.Deserialize()` 将 JSON 转为强类型对象。 + +4. 防止字段缺失:在 `CallMessage`、`RequestMessage` 等 record 的属性上标注 `[JsonRequired]`,确保字段存在。 + +5. 命名转换:通过 `JsonSerializerOptions` 设置 `PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower`,自动映射 `RequestId` ↔ `request-id`。 + +Q1.2 + +调用链: +1. `KeyValueVisitor.Dump(LogEntry entry)` +2. `CallLogEntry.Accept(ILogEntryVisitor visitor)`(多态匹配实际类型) +3. `KeyValueVisitor.Visit(CallLogEntry entry)` + +Q1.3.b +交互过程:提供了题目和代码文件,要求进行逐行讲解和代码实现指导,并在环境配置(WSL 安装 .NET 10 SDK)上获得了操作命令帮助。 +AI 优点:AI 相比我自行查阅文档,AI 能结合具体任务提供更加定制化方案,节省了寻找合适 API 的时间。同时对代码可以有更详细地讲解,降低了我的理解门槛。 +AI不足:存在幻觉没看到的文档/内容也会自己生成。 \ No newline at end of file diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..d79e115 --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,35 @@ +02-multithreading 问答题 + + Q2.1 + + 1. WorkQueue 类中的共享变量有哪些?是通过什么保护其免于数据竞争的? +- 共享变量有: + - `_items`(队列本身,存储元素) + - `_isCompleted`(布尔标志,表示是否已停止添加) +保护方式:使用 `lock (_lock)` 对上述变量的所有读写操作进行互斥。`_lock` 是一个专用锁对象,确保同一时刻只有一个线程能进入临界区。 + +2. LogFileAnalyzer 类中的共享变量有哪些?是通过什么保护其免于数据竞争? +- 共享变量有: + `_currentDirectory`(当前目录路径) + `_isAnalyzing`(是否正在分析) + `_logFiles`(文件字典,文件名→FileInfo) + `_analysisResults`(解析结果字典,文件名→AnalysisResult) +- 保护方式: + 使用 `lock (_syncRoot)` 保护所有对这些字段的读写操作 + 在 `WorkerMain` 中写入 `_analysisResults` 时也使用 `lock (_syncRoot)`,因为多个工作线程可能同时更新结果字典。 + + 3. 如果条件变量的判断条件使用了 `if` 判断而非 `while` 判断,当出现虚假唤醒现象时,会出现什么后果?结合无限仓库容量的生产者消费者问题简单叙述。 + 如果使用 `if`,线程被唤醒后不会再次检查条件,直接执行后续操作。可能出现: +仓库为空但被唤醒.消费者以为有商品,执行 `Dequeue`,但队列中实际为空,导致抛出异常或取出无效数据。使用 `while` 则可以确保被唤醒后重新检查条件。 + Q2.2 + +1. 那一段代码扫描了给定的目录中的全部 `.log` 后缀的日志文件? + 在 `LogFileAnalyzer.ChangeDirectory` 方法中,有以下代码: +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +2. 假使给定的需求是不仅要扫描给定目录中的日志文件,还要递归地获取给定的目录的全部子目录、子子目录……内的日志文件,应当如何做(简要回答即可)? +将 SearchOption.TopDirectoryOnly 改为 SearchOption.AllDirectories,即可递归扫描所有子目录。 +Q2.3.b +我AI提供了本节 guidance.md 的任务描述,并附上已有的WorkQueue.cs、LogFileAnalyzer.cs、Program.cs 等代码文件,特别是多线程中的lock、Monitor.Wait/Pulse操作希望获得指导。在最初给出的WorkerMain代码中,AI将保存结果的操作放在了 catch 块内部,导致成功解析的结果没有被保存。经测试发现后,AI 将错误并修正。难度中等偏上。 \ No newline at end of file diff --git a/docs/03-async-grpc/image-1.png b/docs/03-async-grpc/image-1.png new file mode 100644 index 0000000..f16d731 Binary files /dev/null and b/docs/03-async-grpc/image-1.png differ diff --git a/docs/03-async-grpc/image-2.png b/docs/03-async-grpc/image-2.png new file mode 100644 index 0000000..524d91e Binary files /dev/null and b/docs/03-async-grpc/image-2.png differ diff --git a/docs/03-async-grpc/image.png b/docs/03-async-grpc/image.png new file mode 100644 index 0000000..f16d731 Binary files /dev/null and b/docs/03-async-grpc/image.png differ diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..c25f76c --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -0,0 +1,74 @@ +## T3.2 RemoteCli 功能说明 + +本次 T3.2 实现了一个远程命令行客户端 `RemoteCli`,通过 gRPC 协议与 Agent 服务端通信,实现了以下功能: + +### 功能列表 + +| 菜单项 | 功能 | 对应 RPC 方法 | +|--------|------|---------------| +| 1 | 显示当前目录下的所有 `.log` 文件 | `GetLogFiles` | +| 2 | 分析指定的日志文件(支持多文件) | `AnalyzeFiles` | +| 3 | 分析当前目录下的所有日志文件 | `AnalyzeAll` | +| 4 | 查看指定文件的分析结果 | `GetAnalysisResult`(流式) | +| 5 | 切换日志文件目录 | `ChangeDirectory` | +| 6 | 退出程序 | - | + +### 关键实现 + +1. **异步调用**:所有 gRPC 调用均使用 `Async` 后缀的异步方法(如 `client.AnalyzeFilesAsync`),配合 `await` 关键字实现非阻塞等待。 + +2. **流式处理**:`GetAnalysisResult` 使用 `responseStream.ReadAllAsync()` 逐条读取服务端返回的 `LogEntry`,先接收 `Header`(文件信息),再逐条接收日志条目。 + +3. **鲁棒性设计**: + - 用户输入非法目录路径时,提示 `DirectoryNotFound` 并重新等待输入 + - 用户输入不存在的文件名时,提示 `FileNotFound` + - 用户输入空字符串时,提示重新输入 + - 并行度输入负数时,提示不能为负数 + - 所有可能抛出异常的代码都包裹了 `try-catch` + +4. **类型转换**:使用 `GrpcTypeConverter.ConvertFromGrpc` 将 Protobuf 消息转回 C# 对象,再用 `KeyValueVisitor` 输出键值对。 +![alt text](image-1.png) +![alt text](image-2.png) + +### Q3.1:网络应用程序开发与普通应用程序开发的区别 + +#### 开发区别 + +| 方面 | 普通应用程序 | 网络应用程序 | +|------|-------------|-------------| +| 运行环境 | 单机运行,只依赖本地资源 | 需要服务端和客户端同时运行 | +| 数据存储 | 本地内存或文件 | 远程服务 + 本地缓存 | +| 错误来源 | 代码逻辑错误、本地资源不足 | 网络超时、服务端错误、序列化错误等 | +| 调试方式 | 直接运行,单步调试 | 需要同时启动服务端和客户端 | + +#### 额外难点 + +1. **网络不可靠**:网络请求可能超时、失败、重发,需要处理各种异常情况。 +2. **数据序列化/反序列化**:数据需要在 C# 对象和 Protobuf 消息之间转换,类型必须匹配。 +3. **服务状态管理**:Agent 作为有状态服务,需要维护目录路径、分析结果等状态,多个 RPC 调用之间共享状态。 +#### 个人感受 +网络编程对于程序的鲁棒性要求更高,同时还要考虑与其他设备连接交互的问题,更加复杂。 +### Q3.2.b:AI 使用情况 + +**1. 我的提示词** + +我向 AI 提供了本节 `guidance.md` 的任务描述,并附上已有的 `GrpcLogEntryVisitor.cs`、`GrpcTypeConverter.cs`、`AgentService.cs`、`AgentSession.cs` 代码文件,要求逐行讲解每一段代码的含义,特别是异步编程中的 `async`/`await`、gRPC 的流式调用、Protobuf 类型转换等概念。 + +**2. AI 的使用方式** +- 让 AI 解释代码框架和设计思路(单例模式、依赖注入、gRPC 服务定义),遇到编译错误或测试失败时,将错误信息反馈给 AI,由 AI 分析原因并提出修改建议 + +**3. AI 解答的优点** +- 逐行解释了 Protobuf 消息结构、`oneof` 的使用方式、`Timestamp` 类型转换等 +- 在 T3.1 测试失败后,通过分析错误堆栈,指出 `GetAnalysisResult` 中 `Failed` 分支的状态应为 `Success` 而非 `Failure`(获取结果操作本身是成功的,只是分析结果状态为 `Failed`) + +**4. AI 解答的缺点** +- `GrpcTypeConverter.ConvertFromGrpc` 中新增 `Request` 和 `Internal` 分支时,AI 最初没有正确处理 `EventType` 字段的转换,需要我补充 + +**5. 从 AI 学到的新知识** +- Protobuf 的 `oneof` 类型:同一个消息中只能设置一个字段,通过 `EntryCase` 判断具体是哪种类型 +- gRPC 流式调用:`ReadAllAsync()` 逐条读取服务端返回的消息序列 + +**6. 本节难度评价** +个人认为本节**难度偏高**。 +- 调试时需要在两个终端之间切换,错误信息可能来自任何一方 +- 类型转换(C# ↔ Protobuf)容易出现不匹配问题 diff --git a/docs/04-avalonia/report.md b/docs/04-avalonia/report.md new file mode 100644 index 0000000..1505d9c --- /dev/null +++ b/docs/04-avalonia/report.md @@ -0,0 +1,64 @@ +### 任务截图 +![alt text](<屏幕截图 2026-08-31 093652-1.png>) +![alt text](<屏幕截图 2026-08-31 093801-1.png>) +![alt text](<屏幕截图 2026-08-31 093828.png>) + +### Q4.1:GUI 应用程序与控制台应用程序的开发区别 + +#### 一、开发体验的区别 + +控制台程序都是线性执行的:程序从 `Main` 方法开始,按顺序执行,用户输入什么程序就做什么,做完就结束。这种模式很直观,调试只要看输出和错误信息就行。 + +而 GUI 应用程序完全不同。它启动后不会主动结束,而是等待用户操作(点击按钮、输入文本、选择文件等)。每次用户操作都会触发一个事件(Event),程序需要响应这个事件并执行相应的逻辑。这意味着:程序是事件驱动的,代码不会主动运行,而是“等待被调用”;程序是有状态的,连接状态、目录路径等都需要在内存中持续保存,并在界面中实时更新;要考虑用户体验,程序不能因为用户的一个错误操作就崩溃,也不能因为网络请求慢就让界面“卡死”。 + +#### 二、GUI 开发的主要难点 + +**1. MVVM 模式** + GUI 开发要求使用 **MVVM(Model-View-ViewModel)** 模式,把界面(View)、数据(Model)和逻辑(ViewModel)严格分离。 +- **View**(界面层):只负责显示,不包含任何业务逻辑。在 Avalonia 中,`.axaml` 文件就是 View。 +- **ViewModel**(逻辑层):负责处理用户操作、调用 gRPC、更新数据。它不知道界面长什么样,只提供数据和命令。 +- **Model**(数据层):纯粹的数据容器,如 `LogFileItem`、`LogFields`。 + +**2. UI 线程阻塞问题** + + +在 GUI 程序中,所有界面渲染和用户交互都由 UI 线程负责。如果我在 UI 线程中执行耗时操作(比如同步调用 gRPC),界面就会“卡死”,用户无法点击任何按钮,甚至无法拖动窗口,体验极差。 + +因此, gRPC 调用必须使用异步方法(`client.XXXAsync` + `await`),在等待网络响应时 UI 线程可以继续处理其他事件(比如用户点击“取消”按钮)。这让我对 `async/await` 有了更深入的理解: + + `await` 不会阻塞线程,而是“让出”线程,等任务完成后再回到 UI 线程继续执行。 如果不小心在 UI 线程上使用了 `.Result` 或 `.Wait()`,就会导致死锁(Deadlock),程序永远卡死。 + +**3. 数据绑定(Binding)** + +Avalonia 中,ViewModel 的数据变化要自动反映到界面上,需要使用数据绑定。 +- `ObservableCollection`:当集合内容变化时(增加、删除、清空),界面会自动更新。 +- `[ObservableProperty]`:`CommunityToolkit.Mvvm` 自动生成属性变化通知代码,省去了手动实现 `INotifyPropertyChanged` 的麻烦。 +- `[RelayCommand]`:把方法自动包装成命令,可以直接绑定到按钮的 `Command` 属性。 + + +**4. 调试的复杂性** + + GUI 的调试更加复杂需要: +- **同时启动两个进程**:Agent 和 GUI 客户端,需要在两个终端之间切换。 +- **查看异步调用栈**:`await` 后的代码可能在另一个线程上执行,调试时需要注意线程上下文。 +- **处理事件绑定错误**:如果 `Command` 绑定的方法名写错了(比如 `AnalyzeAllCommand` 拼成 `AnalyzeAllCommnad`),编译不会报错,但按钮点击后没有反应,很难发现。 + +#### 三、对 async/await 的进一步理解 + +通过这次 GUI 开发,我对异步编程有了更深刻的理解: + +1. **异步是“非阻塞”的,不是“并行”的**:`await` 只是让出当前线程,让 UI 线程可以继续处理事件,而不是真的在并行执行。真正的并行需要 `Task.Run` 或 `Parallel.ForEach`。 + +2. **`async void` 要谨慎使用**:在事件处理中,`async void` 可以用于“触发即忘”的场景,但如果 `async void` 方法中抛出异常,程序会直接崩溃。因此我尽量使用 `async Task`,让调用者可以 `await`。 + + +#### 四、异步编程带来的困扰 + +异步编程确实带来了一些额外的复杂性: + +1. **调试更困难**:`await` 后的代码可能在另一个线程上执行,断点跳转顺序不如同步代码直观。 +2. **错误堆栈不完整**:有时异常抛出的位置和捕获的位置相距较远,需要查看 `InnerException` 才能定位。 +3. **`async` 会“传染”**:一旦一个方法用了 `async`,所有调用它的方法也要变成 `async`,否则就要用 `.Result`(容易死锁)。 + +### Q4.2.b +我的提示词主要是提供 guidance 和代码文件,要求 AI 逐行解释框架并指导 TODO 实现。AI 帮助我快理解了 MVVM 模式、异步 UI 编程和 Avalonia 的数据绑定机制。不过在项目结构上,AI 曾误判 LogAnalyzerClient.Desktop 为独立文件夹,导致运行命令出错。AI帮我更快掌握了 ObservableCollection、[RelayCommand] 和流式 gRPC 调用的使用,同时对 async/await 在 GUI 中的作用有了更深的理解。 \ No newline at end of file diff --git "a/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093652-1.png" "b/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093652-1.png" new file mode 100644 index 0000000..e8c3aee Binary files /dev/null and "b/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093652-1.png" differ diff --git "a/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093801-1.png" "b/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093801-1.png" new file mode 100644 index 0000000..9633817 Binary files /dev/null and "b/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093801-1.png" differ diff --git "a/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093828.png" "b/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093828.png" new file mode 100644 index 0000000..ea9c590 Binary files /dev/null and "b/docs/04-avalonia/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-31 093828.png" differ diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..42c09aa 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -112,22 +112,81 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var files = analyzer.GetLogFiles(); // 问分析器要文件列表 + if (files.Count == 0) + { + Console.WriteLine("目录下没有 .log 文件。"); + return; + } + Console.WriteLine("文件列表:"); + foreach (var file in files) // 遍历输出 + { + Console.WriteLine($" - {file}"); + } } private static void AnalyzeFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.Write("请输入要分析的文件名(多个用逗号分隔): "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) return; // 如果输入空,直接返回 + + var names = input.Split(',') + .Select(n => n.Trim()) + .Where(n => !string.IsNullOrEmpty(n)) + .ToList(); + + try + { + analyzer.AnalyzeFiles(0, names); // 0 表示自动用 CPU 核心数 + Console.WriteLine("分析任务已执行完毕。"); + } + catch (Exception ex) + { + Console.WriteLine($"分析过程中发生错误: {ex.Message}"); + } } private static void AnalyzeAll(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + try + { + analyzer.AnalyzeAll(0); + Console.WriteLine("全部分析任务已执行完毕。"); + } + catch (Exception ex) { /* 抓异常防止崩溃 */ } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.Write("请输入要查看的文件名: "); + string? fileName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(fileName)) return; + + if (analyzer.TryGetAnalysisResult(fileName, out var result)) + { + switch (result.State) + { + case AnalysisState.NotAnalyzed: + Console.WriteLine($"文件 {fileName} 尚未分析。"); + break; + case AnalysisState.Succeeded: + var visitor = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var dict = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", dict.Select(kv => $"{kv.Key}={kv.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"文件 {fileName} 分析失败: {result.ErrorMessage}"); + break; + } + } + else + { + Console.WriteLine($"未找到文件 {fileName} 的分析结果。"); + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..ddefd57 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -138,9 +138,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } fileList = fileNameList.Select(fileName => _logFiles[fileName]).ToList(); - /* - * Set _isAnalyzing - */ + _isAnalyzing = true; // TODO: T2.2 } @@ -150,10 +148,10 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } finally { - /* - * Unset _isAnalyzing - * Remember to lock _syncRoot to prevent data race - */ + lock (_syncRoot) + { + _isAnalyzing = false; + } // TODO: T2.2 } } @@ -165,11 +163,17 @@ 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 (!_logFiles.ContainsKey(file.Name)) + { + throw new InvalidOperationException($"Unknown file: {file.Name}"); + } + // 如果还没有分析结果,或者结果是“未分析” + if (!_analysisResults.TryGetValue(file.Name, out var result) || + result.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } } } @@ -180,9 +184,12 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis var queue = new WorkQueue(); - /* - * Enqueue log files - */ + foreach (var file in logFilesToParse) + { + queue.Enqueue(file); + } + queue.CompleteAdding(); + // TODO: T2.2 degreeOfParallelism = Math.Max(Math.Min(degreeOfParallelism, logFilesToParse.Count), 1); @@ -190,16 +197,17 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis for (int i = 0; i < degreeOfParallelism; i++) { int workerId = i; + var thread = new Thread(() => WorkerMain(workerId, queue)); string threadName = $"log-analyzer-worker-{workerId}"; - /* - * Create and start threads to run `WorkerMain` - */ + thread.Start(); + workers[i] = thread; // TODO: T2.2 } - /* - * Wait for (join) all threads to end - */ + foreach (var thread in workers) + { + thread.Join(); + } // TODO: T2.2 } @@ -213,20 +221,37 @@ private void WorkerMain(int workerId, WorkQueue queue) 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 + ); + } + // 把包裹放回仓库 + lock (_syncRoot) + { + _analysisResults[file.Name] = result; + } } - - /* - * Save parse result. - * [!Important] Remember to lock _syncRoot to prevent data race. - */ - throw new NotImplementedException("TODO: T2.2"); } - } - } + } } + diff --git a/src/LogAnalyzer/WorkQueue.cs b/src/LogAnalyzer/WorkQueue.cs index 23055a5..f92560d 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -6,12 +6,13 @@ public class WorkQueue { private readonly Queue _items = new(); private bool _isCompleted = false; + private readonly object _lock = new object(); public bool IsCompleted { get { - lock (_items) + lock (_lock) { return _isCompleted; } @@ -20,17 +21,42 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_lock) + { + _items.Enqueue(item); + Monitor.Pulse(_lock); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_lock) + { + while (_items.Count == 0 && !_isCompleted) + { + Monitor.Wait(_lock); + } + + if (_items.Count > 0) + { + item = _items.Dequeue(); + return true; + } + else + { + item = default(T); + return false; + } + } } public void CompleteAdding() { - throw new NotImplementedException("TODO: T2.1"); + lock (_lock) + { + _isCompleted = true; + Monitor.PulseAll(_lock); + } } } } diff --git a/src/LogAnalyzerAgent/Applications/AgentSession.cs b/src/LogAnalyzerAgent/Applications/AgentSession.cs index 2531f22..2a43a16 100644 --- a/src/LogAnalyzerAgent/Applications/AgentSession.cs +++ b/src/LogAnalyzerAgent/Applications/AgentSession.cs @@ -79,22 +79,309 @@ 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.IsNullOrWhiteSpace(request.DirectoryPath)) + { + response.Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InvalidArgument, + Message = "目录路径不能为空" + }; + return Task.FromResult(response); + } + + var success = _analyzer.ChangeDirectory(request.DirectoryPath); + if (!success) + { + response.Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.DirectoryNotFound, + Message = $"目录不存在: {request.DirectoryPath}" + }; + 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, "ChangeDirectory 失败: {DirectoryPath}", request.DirectoryPath); + } + 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 = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InvalidOperation, + Message = "尚未设置日志目录" + }; + return Task.FromResult(response); + } + + if (_analyzer.IsAnalyzing) + { + response.Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InvalidOperation, + Message = "正在分析中,请稍后" + }; + return Task.FromResult(response); + } + + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "AnalyzeAll 失败"); + } + 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 = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InvalidOperation, + Message = "尚未设置日志目录" + }; + return Task.FromResult(response); + } + if (_analyzer.IsAnalyzing) + { + response.Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InvalidOperation, + Message = "正在分析中,请稍后" + }; + return Task.FromResult(response); + } + + var fileNames = request.FileNames.ToList(); + if (fileNames.Count == 0) + { + response.Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InvalidArgument, + Message = "未指定任何文件" + }; + return Task.FromResult(response); + } + + var availableFiles = _analyzer.GetLogFiles(); + var validFileNames = new List(); + + foreach (var fileName in fileNames) + { + // 先精确匹配 + if (availableFiles.Contains(fileName)) + { + validFileNames.Add(fileName); + } + else + { + // 尝试不区分大小写匹配 + var matched = availableFiles.FirstOrDefault(f => + string.Equals(f, fileName, StringComparison.OrdinalIgnoreCase)); + if (matched != null) + { + validFileNames.Add(matched); // 使用实际存在的文件名 + } + else + { + response.Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.FileNotFound, + Message = $"文件不存在: {fileName}" + }; + return Task.FromResult(response); + } + } + } + + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, validFileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "AnalyzeFiles 失败"); + } + return Task.FromResult(response); + } + public IReadOnlyList GetAnalysisResult(GetAnalysisResultRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var results = new List(); + try + { + if (string.IsNullOrWhiteSpace(request.FileName)) + { + results.Add(new GetAnalysisResultResponse + { + Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InvalidArgument, + Message = "文件名不能为空" + } + }); + return results; + } + + if (!_analyzer.HasDirectory) + { + results.Add(new GetAnalysisResultResponse + { + Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InvalidOperation, + Message = "尚未设置日志目录" + } + }); + return results; + } + + // 获取实际存在的文件列表,匹配文件名 + var files = _analyzer.GetLogFiles(); + string? actualFileName = null; + + if (files.Contains(request.FileName)) + { + actualFileName = request.FileName; + } + else + { + actualFileName = files.FirstOrDefault(f => + string.Equals(f, request.FileName, StringComparison.OrdinalIgnoreCase)); + } + + if (actualFileName == null) + { + results.Add(new GetAnalysisResultResponse + { + Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.FileNotFound, + Message = $"文件不存在: {request.FileName}" + } + }); + return results; + } + + if (!_analyzer.TryGetAnalysisResult(actualFileName, out var result)) + { + results.Add(new GetAnalysisResultResponse + { + Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InternalError, + Message = $"获取分析结果失败: {request.FileName}" + } + }); + return results; + } + + if (result == null) + { + results.Add(new GetAnalysisResultResponse + { + Status = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.InternalError, + Message = $"分析结果为空: {request.FileName}" + } + }); + return results; + } + + var header = new AnalysisResultHeaderMessage + { + FileName = result.FileName, + State = GrpcTypeConverter.ConvertToGrpc(result.State), + WorkerId = result.WorkerId + }; + + if (result.State == AnalysisState.NotAnalyzed) + { + results.Add(new GetAnalysisResultResponse + { + Header = header, + Status = CreateNoErrorOperationStatus() + }); + } + else if (result.State == AnalysisState.Failed) + { + + results.Add(new GetAnalysisResultResponse + { + Header = header, + Status = CreateNoErrorOperationStatus() + // 注意:错误信息已经在 Header 的 State 中体现为 Failed + }); + } + else // Succeeded + { + results.Add(new GetAnalysisResultResponse + { + Header = header, + Status = CreateNoErrorOperationStatus() + }); + + foreach (var entry in result.Entries) + { + var entryMessage = GrpcTypeConverter.ConvertToGrpc(entry); + results.Add(new GetAnalysisResultResponse + { + LogEntry = entryMessage, + Status = CreateNoErrorOperationStatus() + }); + } + } + } + catch (Exception ex) + { + results.Clear(); + results.Add(new GetAnalysisResultResponse + { + Status = CreateInternalErrorOperationStatus(ex) + }); + _logger.LogError(ex, "GetAnalysisResult 失败: {FileName}", request.FileName); + } + return results; } } } diff --git a/src/LogAnalyzerAgent/Services/AgentService.cs b/src/LogAnalyzerAgent/Services/AgentService.cs index 591dcad..03e6a01 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/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs index 2ff1b64..14498ad 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs @@ -11,7 +11,60 @@ public sealed record LogFileItem(string FileName) public sealed record LogFields(int Index, IReadOnlyList Fields, string? ErrorMessage) { - public string Summary => "TODO: T4.1"; + public string Summary + { + get + { + // 如果是错误消息(非空),显示错误 + if (!string.IsNullOrEmpty(ErrorMessage)) + { + return $"❌ {ErrorMessage}"; + } + + // 如果是 Header(Index == -1),显示文件状态 + if (Index == -1) + { + if (Fields.Count == 0) + { + return "📋 文件信息"; + } + return "📋 文件信息 (无日志条目)"; + } + + // 正常日志条目:显示关键信息 + var dict = Fields.ToDictionary(f => f.Key, f => f.Value); + + // 尝试提取关键字段 + string lineNo = dict.TryGetValue("LineNo", out var ln) ? ln : "?"; + string eventType = dict.TryGetValue("EventType", out var et) ? et : "?"; + string severity = dict.TryGetValue("Severity", out var sev) ? sev : "?"; + + // 根据不同类型显示不同摘要 + if (eventType == "Call") + { + string target = dict.TryGetValue("TargetService", out var ts) ? ts : "?"; + string duration = dict.TryGetValue("DurationMs", out var dm) ? dm : "?"; + return $"#{lineNo} [Call] → {target} ({duration}ms)"; + } + else if (eventType == "Request") + { + string method = dict.TryGetValue("Method", out var m) ? m : "?"; + string path = dict.TryGetValue("Path", out var p) ? p : "?"; + string status = dict.TryGetValue("StatusCode", out var sc) ? sc : "?"; + return $"#{lineNo} [Request] {method} {path} → {status}"; + } + else if (eventType == "Internal") + { + string exName = dict.TryGetValue("ExceptionName", out var en) ? en : "?"; + return $"#{lineNo} [Internal] ⚠ {exName}"; + } + else + { + return $"#{lineNo} [{eventType}] {severity}"; + } + } + } + } public sealed record LogFieldItem(string Key, string Value); diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs index 91c05a8..c227539 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs @@ -132,14 +132,97 @@ private async Task RefreshAsync() { await WithClientNotNull(async () => { - throw new NotImplementedException("TODO: T4.1"); + // 1. 调用 gRPC 获取文件列表 + var response = await _client!.GetLogFilesAsync(new Empty()); + + // 2. 检查是否成功 + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + + // 3. 清空并重新填充文件列表 + LogFiles.Clear(); + foreach (var fileName in response.FileNames) + { + LogFiles.Add(new LogFileItem(fileName)); + } }); } [RelayCommand] private async Task AnalyzeSelectedFilesAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + // 1. 检查是否有选中的文件 + if (SelectedFiles.Count == 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", "请至少选择一个文件。"); + return; + } + + // 2. 解析并行度(从文本框读取) + if (!int.TryParse(DegreeOfParallelismText, out int dop) || dop < 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", "并行度必须是一个非负整数。"); + return; + } + + // 3. 构建请求 + var request = new AnalyzeFilesRequest + { + DegreeOfParallelism = dop + }; + request.FileNames.AddRange(SelectedFiles); + + // 4. 调用 gRPC + var response = await _client!.AnalyzeFilesAsync(request); + + // 5. 检查结果 + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + } + else + { + await DialogHelper.ShowMessageDialogAsync("Success", "分析任务已执行完毕。"); + } + }); + } + [RelayCommand] + private async Task AnalyzeAllAsync() + { + await WithClientNotNull(async () => + { + // 1. 解析并行度 + if (!int.TryParse(DegreeOfParallelismText, out int dop) || dop < 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", "并行度必须是一个非负整数。"); + return; + } + + // 2. 调用 gRPC + var request = new AnalyzeAllRequest + { + DegreeOfParallelism = dop + }; + var response = await _client!.AnalyzeAllAsync(request); + + // 3. 检查结果 + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + } + else + { + await DialogHelper.ShowMessageDialogAsync("Success", "全部分析任务已执行完毕。"); + } + }); } /* @@ -150,13 +233,134 @@ private async Task AnalyzeSelectedFilesAsync() [RelayCommand] private async Task AnalyzeRightClickedFileAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + // 1. 检查是否有选中的文件(右键选中) + if (SelectedLogFile == null) + { + await DialogHelper.ShowMessageDialogAsync("Error", "请先选择一个文件。"); + return; + } + + // 2. 解析并行度 + if (!int.TryParse(DegreeOfParallelismText, out int dop) || dop < 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", "并行度必须是一个非负整数。"); + return; + } + + // 3. 构建请求(只传一个文件) + var request = new AnalyzeFilesRequest + { + DegreeOfParallelism = dop + }; + request.FileNames.Add(SelectedLogFile.FileName); + + // 4. 调用 gRPC + var response = await _client!.AnalyzeFilesAsync(request); + + // 5. 检查结果 + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + } + else + { + await DialogHelper.ShowMessageDialogAsync("Success", $"文件 {SelectedLogFile.FileName} 分析完成。"); + } + }); } - [RelayCommand] + [RelayCommand] private async Task GetAnalysisResultAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + // 1. 检查是否有选中的文件 + if (SelectedLogFile == null) + { + await DialogHelper.ShowMessageDialogAsync("Error", "请先选择一个文件。"); + return; + } + + // 2. 清空之前的结果 + ResultEntries.Clear(); + + // 3. 调用 gRPC 流式方法 + var request = new GetAnalysisResultRequest + { + FileName = SelectedLogFile.FileName + }; + + using var call = _client!.GetAnalysisResult(request); + var responseStream = call.ResponseStream; + + bool hasHeader = false; + int entryIndex = 0; + + await foreach (var response in responseStream.ReadAllAsync()) + { + // 先检查状态 + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + + // 判断是 Header 还是 LogEntry + if (response.PayloadCase == GetAnalysisResultResponse.PayloadOneofCase.Header) + { + var header = response.Header; + hasHeader = true; + + // 根据分析状态显示不同的摘要 + string summary; + if (header.State == AnalysisStateEnum.NotAnalyzed) + { + summary = $"📋 {SelectedLogFile.FileName} - 尚未分析"; + } + else if (header.State == AnalysisStateEnum.Failed) + { + summary = $"❌ {SelectedLogFile.FileName} - 分析失败"; + } + else // Succeeded + { + summary = $"✅ {SelectedLogFile.FileName} - 分析成功 (等待接收日志条目...)"; + } + + ResultEntries.Add(new LogFields( + Index: -1, + Fields: new List(), + ErrorMessage: header.State == AnalysisStateEnum.Failed ? "分析失败" : null + )); + } + else if (response.PayloadCase == GetAnalysisResultResponse.PayloadOneofCase.LogEntry) + { + // 将 Protobuf 消息转回 C# 对象 + var entry = GrpcTypeConverter.ConvertFromGrpc(response.LogEntry); + + // 用 KeyValueVisitor 转成键值对 + var visitor = new KeyValueVisitor(); + var dict = visitor.Dump(entry); + + // 转成 LogFieldItem 列表 + var fields = dict.Select(kv => new LogFieldItem(kv.Key, kv.Value)).ToList(); + + ResultEntries.Add(new LogFields( + Index: entryIndex++, + Fields: fields, + ErrorMessage: null + )); + } + } + + if (!hasHeader) + { + await DialogHelper.ShowMessageDialogAsync("Error", "未收到有效响应。"); + } + }); } [RelayCommand] diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml index fffef7e..49425f4 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml @@ -73,24 +73,23 @@ to set the actual DataContext for runtime, set the DataContext property in code - - - - - - - - - - -