diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..60d2765 --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,113 @@ +# (Q1.1) + +## 1. 按逗号分割日志的语句,以及如何指定每个字段的意义 + +本框架借助第三方 CSV 解析库 **CsvHelper** 来完成按逗号分列的工作: + +```csharp +using var csv = new CsvReader(logFile, config); +csv.Context.RegisterClassMap(); +foreach (var logRecord in csv.GetRecords()) { ... } +``` + +其中真正「按逗号把一行切成多个字段」的工作由 `CsvReader` / `csv.GetRecords()` 在库内部完成(它还能正确处理 `message` 字段两端的双引号以及 JSON 内部出现的逗号)。 + +「每一行的第几个字段代表何种意义」是通过一个继承自 `ClassMap` 的映射类 `LogRecordMap` 来指定的,使用 `Map(...).Index(n)` 把 CSV 的第 `n` 列绑定到 `LogRecord` 的对应属性上: + +```csharp +internal class LogRecordMap : ClassMap +{ + public LogRecordMap() + { + 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 + } +} +``` + +即:`Index(0)` 对应 `lineno`、`Index(1)` 对应 `timestamp`、`Index(2)` 对应 `pod-name`、`Index(3)` 对应 `message`。随后通过 `csv.Context.RegisterClassMap()` 让 CsvHelper 读取时按这个映射把每列填入 `LogRecord`。 + +## 2. 在哪个方法内、用哪几条语句判断日志种类 + +在 `Parser/LineParser.cs` 的 `ParseLine(LogRecord logRecord)` 方法内判断。先用 `JsonDocument` 把 `message` 当作 JSON 解析,再读取其中的 `event` 字段,用 `switch` 表达式根据其取值分流到不同的工厂方法: + +```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(...) + }; + } + ... +} +``` + +也就是说,判断种类的语句是 `root.TryGetProperty("event", out var eventElement)` 配合 `eventElement.GetString() switch { "call" => ..., "request" => ..., "internal" => ... }`。 + +## 3. 确定种类后调用哪个库方法解析 JSON + +确定种类后,在对应的工厂方法(如 `CreateCall` / `CreateRequest` / `CreateInternal`)中调用 `System.Text.Json` 提供的: + +```csharp +JsonSerializer.Deserialize(logRecord.Message, options) +``` + +把 JSON 字符串反序列化成一个强类型的 `record`(如 `CallMessage`)。 + +### 3.1 如何防止日志中字段缺失 + +通过在反序列化目标 `record` 的每个属性上标注 `[property: JsonRequired]` 特性,例如: + +```csharp +private record CallMessage( + [property: JsonRequired] string Severity, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string TargetService, + [property: JsonRequired] int DurationMs +); +``` + +`[JsonRequired]` 告诉 JSON 序列化器这些属性是必需的:当 JSON 中缺少对应键时,`JsonSerializer.Deserialize` 会抛出 `JsonException`,从而把「字段缺失」这一异常情况暴露出来。此外,反序列化结果后还跟了一个 `?? throw new FormatException(...)`,用于在结果为 `null` 时也抛出异常,进一步兜底: + +```csharp +var callMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException(...); +``` + +### 3.2 如何让 JSON 解析器完成「烤串命名法 → 大驼峰命名法」的转换 + +通过配置 `JsonSerializerOptions` 的 `PropertyNamingPolicy`: + +```csharp +private static JsonSerializerOptions options = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower, +}; +``` + +`JsonNamingPolicy.KebabCaseLower` 作为命名策略,会在反序列化时把 C# 属性名(大驼峰,如 `RequestId`、`TargetService`、`DurationMs`)转换成小写烤串形式(`request-id`、`target-service`、`duration-ms`)再去和 JSON 中的键匹配。这样就在不修改 C# 属性名的前提下,完成了 `abc-def` 与 `AbcDef` 两种命名法之间的映射。 + +# (Q1.2) + +以一个 Call 事件为例,调用 `KeyValueVisitor.Dump(entry)`(其中 `entry` 的静态类型是 `LogEntry`,实际运行时类型是 `CallLogEntry`)后,方法调用链如下(.NET 内置库方法略): + ++ `Dictionary KeyValueVisitor.Dump(LogEntry entry)` + - 内部执行 `return entry.Accept(this);`,由于 `entry` 的运行时类型是 `CallLogEntry`,发生多态分派,调用 `CallLogEntry` 中被 override 的 `Accept` ++ `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)` + - 内部执行 `return visitor.Visit(this);`,此处 `this` 的编译时类型是 `CallLogEntry`,于是通过重载分派选中 `KeyValueVisitor.Visit(CallLogEntry entry)` ++ `Dictionary KeyValueVisitor.Visit(CallLogEntry entry)` + - 构造并返回保存了 `LineNo`、`Timestamp`、`PodName`、`Severity`、`EventType`、`RequestId`、`TargetService`、`DurationMs` 的 `Dictionary` + +这里正是访问者模式的「双重分派(double dispatch)」:第一重由 `entry.Accept(this)` 按 `entry` 的**运行时类型**分派到 `CallLogEntry.Accept`;第二重由 `visitor.Visit(this)` 按 `this` 的**编译时类型**(`CallLogEntry`)分派到 `KeyValueVisitor.Visit(CallLogEntry)` 的重载,从而对外部屏蔽了具体子类,却仍能对每种日志执行不同的行为。 + +# (Q1.3.b) +根据TODO框架和guidance.md完成任务××.AI能给出达成任务要求的代码并自行测试验证。有时候AI会有过度、无效兜底的问题,在这次作业中基本没有出现 \ No newline at end of file diff --git a/docs/02-multithreading/0b487ce620ea8067dee251d1968508c4.png b/docs/02-multithreading/0b487ce620ea8067dee251d1968508c4.png new file mode 100644 index 0000000..a6e8c52 Binary files /dev/null and b/docs/02-multithreading/0b487ce620ea8067dee251d1968508c4.png differ diff --git a/docs/02-multithreading/QQ_1785484373806.png b/docs/02-multithreading/QQ_1785484373806.png new file mode 100644 index 0000000..e48e4d8 Binary files /dev/null and b/docs/02-multithreading/QQ_1785484373806.png differ diff --git a/docs/02-multithreading/QQ_1785484396152-1.png b/docs/02-multithreading/QQ_1785484396152-1.png new file mode 100644 index 0000000..2fdd35c Binary files /dev/null and b/docs/02-multithreading/QQ_1785484396152-1.png differ diff --git a/docs/02-multithreading/QQ_1785484396152-2.png b/docs/02-multithreading/QQ_1785484396152-2.png new file mode 100644 index 0000000..2fdd35c Binary files /dev/null and b/docs/02-multithreading/QQ_1785484396152-2.png differ diff --git a/docs/02-multithreading/QQ_1785484396152-3.png b/docs/02-multithreading/QQ_1785484396152-3.png new file mode 100644 index 0000000..2fdd35c Binary files /dev/null and b/docs/02-multithreading/QQ_1785484396152-3.png differ diff --git a/docs/02-multithreading/QQ_1785484396152.png b/docs/02-multithreading/QQ_1785484396152.png new file mode 100644 index 0000000..2fdd35c Binary files /dev/null and b/docs/02-multithreading/QQ_1785484396152.png differ diff --git a/docs/02-multithreading/QQ_1785484923024.png b/docs/02-multithreading/QQ_1785484923024.png new file mode 100644 index 0000000..16e9353 Binary files /dev/null and b/docs/02-multithreading/QQ_1785484923024.png differ diff --git a/docs/02-multithreading/QQ_1785485348097.png b/docs/02-multithreading/QQ_1785485348097.png new file mode 100644 index 0000000..7e5730b Binary files /dev/null and b/docs/02-multithreading/QQ_1785485348097.png differ diff --git a/docs/02-multithreading/QQ_1785485430494.png b/docs/02-multithreading/QQ_1785485430494.png new file mode 100644 index 0000000..513a656 Binary files /dev/null and b/docs/02-multithreading/QQ_1785485430494.png differ diff --git a/docs/02-multithreading/QQ_1785485472946.png b/docs/02-multithreading/QQ_1785485472946.png new file mode 100644 index 0000000..aa12f07 Binary files /dev/null and b/docs/02-multithreading/QQ_1785485472946.png differ diff --git a/docs/02-multithreading/QQ_1785485519059.png b/docs/02-multithreading/QQ_1785485519059.png new file mode 100644 index 0000000..c67ddb1 Binary files /dev/null and b/docs/02-multithreading/QQ_1785485519059.png differ diff --git a/docs/02-multithreading/e87b65691415cb6e03e2e985cded5bfb.png b/docs/02-multithreading/e87b65691415cb6e03e2e985cded5bfb.png new file mode 100644 index 0000000..80d3df7 Binary files /dev/null and b/docs/02-multithreading/e87b65691415cb6e03e2e985cded5bfb.png differ diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..00bc2ac --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,188 @@ +# 02-multithreading 实验报告 + +## 一、功能介绍 + +本节在 `01-basic` 的单文件日志解析基础上,实现了一个**目录级别的并行日志分析器**,并配有一个简易的交互式控制台界面。整体由三部分组成: + +| 文件 | 任务 | 作用 | +| :--- | :--- | :--- | +| `LogAnalyzer/WorkQueue.cs` | T2.1 | 基于非线程安全 `Queue` 自造的**线程安全阻塞队列** | +| `LogAnalyzer/LogFileAnalyzer.cs` | T2.2 | 扫描目录、调度多线程并行解析、保存结果 | +| `LocalCli/Program.cs` | T2.3 | 与用户交互的控制台菜单,串联上述能力 | + +### 1. 线程安全队列 `WorkQueue`(T2.1) + +共享变量为内部的 `Queue _items` 与「是否结束放入」标记 `_isCompleted`,两者统一用 `lock(_items)` 这同一个互斥量保护。这是一个带「结束放入」语义的无限容量生产者—消费者问题: + +- `Enqueue`:加锁后入队,并 `Monitor.Pulse`(signal)唤醒一个等待中的消费者;若已 `CompleteAdding` 则抛 `InvalidOperationException`。 +- `CompleteAdding`:置位 `_isCompleted`,并 `Monitor.PulseAll`(broadcast)唤醒**全部**正在等待的消费者,使其能够正常退出而不是永远阻塞。 +- `TryDequeue`:加锁后用 **`while`** 循环判断「队列空 且 未结束」才 `Monitor.Wait`;被唤醒后重新检查条件。队列非空则取出返回 `true`,否则(空且已结束)返回 `false` 并把 `item` 置为 `default`。 + +### 2. 并行日志分析 `LogFileAnalyzer`(T2.2) + +- **目录扫描**:`ChangeDirectory` 中 `Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly)` 扫描当前目录下所有 `.log` 文件,并把每个文件以 `NotAnalyzed` 状态登记进 `_analysisResults`。 +- **状态位 `_isAnalyzing`**:`AnalyzeFiles` 进入分析前置 `true`,在 `try/finally` 的 `finally` 里**加锁**复位为 `false`,保证异常时也能复位。该标志保证同一时刻只允许一个分析任务进行,其余并发请求抛 `InvalidOperationException`。 +- **`RunWorkers`**:先把 `State == NotAnalyzed` 的文件筛选出来,跳过已 `Succeeded`/`Failed` 的文件以节省计算资源,未知文件抛 `InvalidOperationException`,用主线程作为生产者把待解析文件 `Enqueue` 进 `WorkQueue` 后 `CompleteAdding`;再开启 `degreeOfParallelism` 个 worker 线程(入口方法 `WorkerMain`),最后 `Join` 等待全部 worker 结束。 + - `degreeOfParallelism == 0` 表示取 `Environment.ProcessorCount`;并按 `[1, 文件数]` 夹取,避免开多余空转线程。 +- **`WorkerMain`**:每个消费者循环 `TryDequeue` 取文件,用各自的 `LogFileParser` + `StreamReader` 解析;解析成功 → `Succeeded`,解析抛异常 → `Failed`(写 `ErrorMessage`、空 `Entries`)。最后**加 `_syncRoot` 锁**把结果写回共享的 `_analysisResults` 字典。 + - `parser.Parse(reader).ToList()` 中的 `ToList()` 用于强制立即求值:`Parse` 是 `yield return` 的惰性迭代器,若不立刻物化,异常会推迟到 `try` 之外才发生而无法被捕获。 +- **`TryGetAnalysisResult`**:加锁查 `_analysisResults`,存在则返回 `true` 并输出结果,否则返回 `false`。 + +### 3. 控制台交互 `LocalCli/Program.cs`(T2.3) + +菜单提供 6 个功能,`LogFileAnalyzer` 对错误输入,CLI 层把这些异常兜住并提示用户重新输入,保证非法输入不会让程序崩溃。 + +| 选项 | 功能 | 实现 | +| :--: | :--- | :--- | +| —— | `InputDirectory` | 输入目录构造 `analyzer`;目录不存在→提示重输(`ChangeDirectory` 返回 `false`),路径非法→捕获 `ArgumentException` 提示重输 | +| 1 | `ShowLogFiles` | 调用 `GetLogFiles()` 列出目录中全部 `.log` 文件名 | +| 2 | `AnalyzeFiles` | 输入逗号分隔的文件名,`Split` 时去空白,调用 `AnalyzeFiles(0, ...)`;捕获 `ArgumentException`/`InvalidOperationException` | +| 3 | `AnalyzeAll` | 调用 `AnalyzeAll(0)` 分析全部;捕获 `InvalidOperationException` | +| 4 | `GetAnalysisResult` | 输入文件名查结果:不存在→提示;`NotAnalyzed`→提示先分析;`Succeeded`→用 `KeyValueVisitor.Dump` 逐条输出;`Failed`→输出 `ErrorMessage` | +| 5 | ChangeDirectory | 重新输入目录(复用 `InputDirectory`) | +| 6 | Exit | 退出 | + +非法的菜单输入(非数字 / 超出范围)会被 `int.Parse` 的异常捕获或 `default` 分支拦截,提示重输,不会崩溃。 + +--- + +## 二、功能演示 + +### 启动 + 查看日志文件列表 + +![alt text](./0b487ce620ea8067dee251d1968508c4.png) + +### 分析指定文件 + +![alt text](./QQ_1785484396152-3.png) + +### 查看分析成功的结果,查询尚未分析的文件 + +![alt text](./e87b65691415cb6e03e2e985cded5bfb.png) + +### 分析全部 + 查看失败文件的错误信息 + +![alt text](./QQ_1785484923024.png) + + +## 三、鲁棒性测试 + +### 不存在的目录 + +![alt text](./QQ_1785485348097.png) + +### 非法的菜单输入 + +![alt text](./QQ_1785485472946.png) + +### 分析不存在的文件 + +![alt text](./QQ_1785485519059.png) + +### 查询不存在 / 未分析的文件 + +![alt text](./QQ_1785485430494.png) + +--- + +## 四、问答题 + +### (Q2.1) + +共享变量有两个: + +- `Queue _items`:真正存放元素的内部队列; +- `bool _isCompleted`:标记是否已结束放入(`CompleteAdding` 是否被调用过)。 + +两者都通过**以 `_items` 这个引用对象本身作为互斥量**来保护——所有对它们的读写都放在 `lock(_items)` 临界区内: + +```csharp +public void Enqueue(T item) +{ + lock (_items) + { + if (_isCompleted) throw new InvalidOperationException(...); + _items.Enqueue(item); + Monitor.Pulse(_items); + } +} +``` +同步关系(消费者等待 / 生产者唤醒)也建立在同一个 `_items` 上:消费者 `Monitor.Wait(_items)` 释放锁并休眠,生产者用 `Monitor.Pulse`(signal)/ `Monitor.PulseAll`(broadcast)唤醒。这是 C# `Monitor` 实现的 MESA 模型条件变量。 + + `LogFileAnalyzer` 中的共享变量有: + +- `string? _currentDirectory`:当前日志目录; +- `bool _isAnalyzing`:是否正在分析; +- `Dictionary _logFiles`:文件名到 `FileInfo` 的映射; +- `Dictionary _analysisResults`:文件名到分析结果的映射。 + +它们统一由一个专用的互斥量对象 `private readonly object _syncRoot = new();` 保护,所有访问都放在 `lock(_syncRoot)` 内(`ChangeDirectory`、`GetLogFiles`、`TryGetAnalysisResult`,以及 worker 写回结果时): + +```csharp +lock (_syncRoot) +{ + _analysisResults[file.Name] = result; +} +``` + +`RunWorkers` 中还有一个局部构造的 `WorkQueue` 实例,被主线程(生产者)和各 worker(消费者)共享,但它由 `WorkQueue` **内部自己的 `_items` 锁**保护,属于另一套独立的互斥机制,与 `_syncRoot` 无关。`IsAnalyzing`、`IsCompleted` 等属性的 getter 也都通过加锁读取,避免读到未同步的值。 + +用 `if` 而非 `while` 在虚假唤醒下的后果: + +以无限容量生产者—消费者为例,若消费者写成: + +```csharp +lock (mtx) +{ + if (buffer == 0) // 用 if + { + Monitor.Wait(mtx); + } + buffer -= 1; // 直接取用商品 +} +``` + +当发生虚假唤醒(`Wait` 在没有人 `Pulse` 的情况下自行返回)时: + +- 线程被唤醒后**不再重新检查** `buffer == 0`,直接执行 `buffer -= 1`; +- 但此时 `buffer` 仍为 0(根本没生产出商品),于是「取走了一个不存在的商品」,`buffer` 变成 −1,状态不变量被破坏,出现逻辑错误。 + +更严重的是多消费者下的竞争:MESA 模型中,线程被 `Pulse` 唤醒后并不会立即拿到锁,而要重新去抢锁;在它重新拿到锁之前,另一个消费者可能已经把唯一的商品取走了(也可能是纯粹的虚假唤醒)。若用 `if`,这个被唤醒的消费者不会再检查条件,照样去取商品,于是出现「一个商品被消费两次」或「消费了空仓库」的错误。 + +而用 `while`: + +```csharp +while (buffer == 0) { Monitor.Wait(mtx); } +``` + +被唤醒后会**再次判断条件**,若仓库仍为空(无论是虚假唤醒还是被别的消费者抢先),就继续 `Wait`,只有确实非空时才取用——这才是正确的。 + + +### (Q2.2) + +在 `ChangeDirectory` 方法中,这段代码完成了扫描: + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +foreach (var fileName in logFiles) +{ + _logFiles.Add(fileName, new FileInfo(Path.Join(_currentDirectory, fileName))); + _analysisResults.Add(fileName, new AnalysisResult(...)); +} +``` + +其中真正「扫描目录里所有 `.log` 文件」的是 `Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly)`:第一个参数是目录、第二个是匹配模式 `*.log`、第三个 `SearchOption.TopDirectoryOnly` 表示只扫描当前目录(不进入子目录);后面的 `Select`/`OrderBy` 只是把得到的路径取出文件名并排序。 + +若要递归扫描所有子目录,把第三个参数改为 `SearchOption.AllDirectories`,即可让 `EnumerateFiles` 递归遍历所有子目录、子子目录……: + +```csharp +Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.AllDirectories) +``` + +一个需要注意的细节:当前代码用 `Path.GetFileName(filePath)`(仅文件名)作为 `_logFiles` / `_analysisResults` 的键。非递归时文件名不会重复,没有问题;但改为递归后,不同子目录下可能存在同名文件(例如两个子目录里都有 `20260701.log`),会造成字典键冲突、后扫到的覆盖先扫到的。因此递归版本更适合改用「相对路径」作为键,例如 `Path.GetRelativePath(directoryPath, filePath)`,以避免重名冲突。 + +### (Q2.3) + +根据TODO框架和guidance.md完成任务××.AI能给出达成任务要求的代码并自行测试验证。有时候AI会有过度、无效兜底的问题,在这次作业中基本没有出现 \ No newline at end of file diff --git a/docs/03-async-grpc/QQ_1785571614496.png b/docs/03-async-grpc/QQ_1785571614496.png new file mode 100644 index 0000000..0af46bb Binary files /dev/null and b/docs/03-async-grpc/QQ_1785571614496.png differ diff --git a/docs/03-async-grpc/QQ_1785571631493.png b/docs/03-async-grpc/QQ_1785571631493.png new file mode 100644 index 0000000..b11feea Binary files /dev/null and b/docs/03-async-grpc/QQ_1785571631493.png differ diff --git a/docs/03-async-grpc/QQ_1785571665044.png b/docs/03-async-grpc/QQ_1785571665044.png new file mode 100644 index 0000000..05ce81d Binary files /dev/null and b/docs/03-async-grpc/QQ_1785571665044.png differ diff --git a/docs/03-async-grpc/QQ_1785571809037.png b/docs/03-async-grpc/QQ_1785571809037.png new file mode 100644 index 0000000..2e36b15 Binary files /dev/null and b/docs/03-async-grpc/QQ_1785571809037.png differ diff --git a/docs/03-async-grpc/QQ_1785572331363.png b/docs/03-async-grpc/QQ_1785572331363.png new file mode 100644 index 0000000..9a450c7 Binary files /dev/null and b/docs/03-async-grpc/QQ_1785572331363.png differ diff --git a/docs/03-async-grpc/QQ_1785572349080.png b/docs/03-async-grpc/QQ_1785572349080.png new file mode 100644 index 0000000..09585e3 Binary files /dev/null and b/docs/03-async-grpc/QQ_1785572349080.png differ diff --git a/docs/03-async-grpc/QQ_1785572368644.png b/docs/03-async-grpc/QQ_1785572368644.png new file mode 100644 index 0000000..dad6c44 Binary files /dev/null and b/docs/03-async-grpc/QQ_1785572368644.png differ diff --git a/docs/03-async-grpc/QQ_1785572407446.png b/docs/03-async-grpc/QQ_1785572407446.png new file mode 100644 index 0000000..d10de54 Binary files /dev/null and b/docs/03-async-grpc/QQ_1785572407446.png differ diff --git a/docs/03-async-grpc/QQ_1785572418459.png b/docs/03-async-grpc/QQ_1785572418459.png new file mode 100644 index 0000000..5935949 Binary files /dev/null and b/docs/03-async-grpc/QQ_1785572418459.png differ diff --git a/docs/03-async-grpc/QQ_1785762240394.png b/docs/03-async-grpc/QQ_1785762240394.png new file mode 100644 index 0000000..15347e0 Binary files /dev/null and b/docs/03-async-grpc/QQ_1785762240394.png differ diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..7359b1f --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -0,0 +1,127 @@ +# 03-async-grpc 实验报告 + +## 一、T3.2 功能说明 + +`RemoteCli` 是一个对接 Agent gRPC 服务的远程控制台客户端,其交互逻辑与上一节的 `LocalCli` 基本一致,区别在于:所有对 `LogFileAnalyzer` 的本地函数调用都被替换为对应的 **异步 gRPC 调用**,即调用方法名带 `Async` 后缀的版本。 + +### 1. 实现的功能 + +| 菜单选项 | 功能 | 对应的 gRPC 异步调用 | +| :------: | :--- | :--- | +| 启动时 | 输入并切换日志目录 | `ChangeDirectoryAsync` | +| 1 | 列出当前目录中的日志文件 | `GetLogFilesAsync` | +| 2 | 解析指定的日志文件(可指定并行度) | `AnalyzeFilesAsync` | +| 3 | 解析当前目录中的全部日志文件 | `AnalyzeAllAsync` | +| 4 | 查询指定文件的分析结果(**流式**返回) | `GetAnalysisResult`(`ResponseStream.ReadAllAsync`) | +| 5 | 切换日志目录 | `ChangeDirectoryAsync` | +| 6 | 退出 | —— | + +### 2. 关键实现要点 + +1. **全异步调用**:按照本节要求,所有 gRPC 调用均使用 `Async` 版本。对于流式接口 `GetAnalysisResult`,使用 `await foreach (var response in call.ResponseStream.ReadAllAsync())` 逐条读取服务端流式返回的 `GetAnalysisResultResponse`。 + +2. **流式结果的解析**:根据响应中的 `PayloadCase` 分别处理: + - `Header`:根据 `State`(`NotAnalyzed` / `Failed` / `Succeeded`)给出对应的提示;只有 `Succeeded` 时才继续接收后续的日志条目。 + - `LogEntry`:通过 `GrpcTypeConverter.ConvertFromGrpc` 转回内部的 `LogEntry` 类型,再用 `KeyValueVisitor` 以键值对形式打印。 + +3. **切换目录后即时反馈**:利用 `ChangeDirectoryResponse` 中额外返回的 `current_directory` 与 `file_names` 字段,在切换目录成功后立刻打印 Agent 的完整路径与目录中的全部日志文件,方便确认。 + +4. **可配置并行度**:`AnalyzeFiles` / `AnalyzeAll` 在执行前会通过 `ReadDegreeOfParallelism` 让用户输入并行度(`0` 表示使用 `ProcessorCount`),通过 `ReadFileNames` 读取待解析的文件名列表。 + +5. **健壮性(重点)**:Agent 作为常驻服务,绝不应因用户非法输入或内部错误而崩溃。客户端同样做了充分的容错: + - 所有 gRPC 调用均捕获 `RpcException`(网络层错误),输出友好提示而非崩溃。 + - 服务端返回的 `OperationStatusMessage` 中 `success == false` 时,将错误码与错误信息展示给用户,并允许其重新输入。 + - 菜单输入、并行度输入、文件名输入均做了非法值校验与重试。 + +### 3. 运行方式 + +需要**同时**运行 Agent(服务端)与 RemoteCli(客户端)两个程序: + +1. **启动 Agent**:将 `LogAnalyzerAgent` 设为启动项目并运行(或在 `LogAnalyzerAgent` 目录下执行 `dotnet run`),它会在 `http://localhost:5000` 上监听 gRPC 服务。 +2. **启动 RemoteCli**:运行 `RemoteCli`,默认连接 `http://localhost:5000`;也可通过命令行参数或环境变量 `LOG_ANALYZER_AGENT_ADDRESS` 指定 Agent 地址: + + ```bash + dotnet run --project RemoteCli -- http://localhost:5000 + ``` + +--- + +## 二、功能演示截图 + +### 1. 连接 Agent 并切换目录 + +![alt text](./QQ_1785571614496.png) + +### 2. 列出日志文件(选项 1) + +![alt text](./QQ_1785571631493.png) + +### 3. 解析指定文件并查看结果(选项 2 + 选项 4) + +![alt text](./QQ_1785571665044.png) + +### 4. 解析全部文件并查看多日志文件结果(选项 3 + 选项 4) + +![alt text](./QQ_1785571809037.png) + +--- + +## 三、鲁棒性测试截图 + +### 1. 非法目录名 + +![alt text](./QQ_1785572331363.png) + +### 2. 非法菜单选项 + +![alt text](./QQ_1785572349080.png) + +### 3. 查询不存在的文件 + +![alt text](./QQ_1785572368644.png) + +### 4. 解析不存在的文件 + +### 5. 非法并行度输入 +![alt text](./QQ_1785572407446.png) + + +### 6. 空文件名输入 + +![alt text](./QQ_1785572418459.png) + +--- + +## 四、问答题 + +### (Q3.1) + +**区别:** + +1. **调用方式的本质变化**:非网络程序中,函数调用都是同进程内的本地调用,传参、返回都直接在内存中进行;而网络应用中,跨机器/跨进程的交互变成了远程过程调用(RPC)。表面上 `client.GetLogFilesAsync()` 看起来像普通方法,但背后实际上经历了一次完整的网络往返。 +2. **数据需要序列化**:本地调用直接传递对象引用;网络调用则必须把数据序列化为字节流(本节中是 Protobuf),到达对端再反序列化。这就要求两端有一套共同的接口描述(IDL,即 `.proto` 文件),并且在内部 C# 类型与 Protobuf 消息类型之间编写转换层(本节的 `GrpcTypeConverter` / `GrpcLogEntryVisitor`)。 +3. **必须采用异步编程模型**:网络 I/O 是典型的 I/O 密集场景,若用同步调用,线程会在等待网络响应时被白白阻塞。本节的 `RemoteCli` 因此全部使用 `async` / `await`,在等待响应时让出线程,这正是异步编程相比多线程的优势所在。 +4. **需要同时启动、联合调试两个程序**:以往调试单个可执行文件即可;网络应用必须同时跑起服务端(Agent)和客户端(RemoteCli),且 Visual Studio 一次只能调试一个,另一个要手动启动,调试方式发生了变化。 +5. **状态分布在多个进程**:Agent 是有状态的单例服务(保存当前目录、分析结果),客户端只是远程地读取/修改这些状态,状态不再集中在一个进程内。 + +**额外的难点:** + +1. **网络不可靠**:连接可能失败、请求可能超时、对端可能宕机。必须捕获 `RpcException` 并做容错处理,而非像本地调用那样假定“调用了一定会返回”。本节的鲁棒性测试就体现了这一点。 +2. **错误定位困难**:一次调用失败,可能是客户端参数错、可能是网络层、可能是序列化/反序列化、也可能是服务端逻辑。错误来源横跨两端,排查时需要分别查看客户端与服务端的输出,调试成本显著上升。 +3. **类型系统的割裂与一致性维护**:两端可能用不同语言、不同类型表示同一概念。一旦 `.proto` 改动,两端的生成代码与转换逻辑都要同步更新,否则会出现字段对不上的隐蔽 bug。 +4. **并发与共享状态的同步**:Agent 作为常驻服务,可能同时收到多个客户端请求,对共享状态(当前目录、分析结果)的访问需要加锁(本节的 `LogFileAnalyzer` 用 `_syncRoot` 保护),还要处理“分析进行中再次请求分析”这类并发冲突。 +5. **部署与环境配置**:要关心端口、监听地址(`localhost` 仅本机、`0.0.0.0` 对外)、HTTP/2 协议、CORS、防火墙等,这些在非网络程序里几乎不存在。 +6. **安全性**:网络服务暴露在外,需要考虑鉴权、传输加密、防止恶意请求与 DDoS 等,而本地程序一般无需考虑。 + +**额外的复杂之处:** + +1. 需要额外学习并理解一整套协议栈知识:Protobuf 的消息定义与 `oneof`、gRPC 的四种调用模式(本节用到了服务端流式)、HTTP/2 等。 +2. 需要理解依赖注入、服务注册等框架级概念(本节用 ASP.NET 的 `AddSingleton` 注册有状态服务),这对初学者是不小的认知负担。 +3. 流式 RPC 的处理比一次性返回更复杂:要逐条读取、区分 `header` 与 `log_entry`、处理“文件不存在/未分析/失败/成功”等多种情形。 +4. 调试反馈链路变长:改一处接口往往要重新生成代码、重启服务端、再重启客户端,迭代效率比单机程序低。 + +总的来说,网络应用的核心复杂度来自于**“分布”**二字——计算与状态被分散到了通过网络连接的不同节点上,由此衍生出序列化、异步、容错、并发、安全等一系列非网络程序所没有的问题。 + +### (Q2.2) + +根据TODO框架和guidance.md完成任务××.AI能给出达成任务要求的代码并自行测试验证。有时候AI会有过度、无效兜底的问题,在这次作业中基本没有出现 diff --git a/docs/04-avalonia/QQ_1785813088042.png b/docs/04-avalonia/QQ_1785813088042.png new file mode 100644 index 0000000..6b40279 Binary files /dev/null and b/docs/04-avalonia/QQ_1785813088042.png differ diff --git a/docs/04-avalonia/QQ_1785817655622.png b/docs/04-avalonia/QQ_1785817655622.png new file mode 100644 index 0000000..e22cfce Binary files /dev/null and b/docs/04-avalonia/QQ_1785817655622.png differ diff --git a/docs/04-avalonia/QQ_1785817741740.png b/docs/04-avalonia/QQ_1785817741740.png new file mode 100644 index 0000000..c1b6506 Binary files /dev/null and b/docs/04-avalonia/QQ_1785817741740.png differ diff --git a/docs/04-avalonia/QQ_1785817782805.png b/docs/04-avalonia/QQ_1785817782805.png new file mode 100644 index 0000000..0ffee3c Binary files /dev/null and b/docs/04-avalonia/QQ_1785817782805.png differ diff --git a/docs/04-avalonia/QQ_1785817816941.png b/docs/04-avalonia/QQ_1785817816941.png new file mode 100644 index 0000000..268f941 Binary files /dev/null and b/docs/04-avalonia/QQ_1785817816941.png differ diff --git a/docs/04-avalonia/QQ_1785818118027.png b/docs/04-avalonia/QQ_1785818118027.png new file mode 100644 index 0000000..decf909 Binary files /dev/null and b/docs/04-avalonia/QQ_1785818118027.png differ diff --git a/docs/04-avalonia/QQ_1785818173900.png b/docs/04-avalonia/QQ_1785818173900.png new file mode 100644 index 0000000..489f0c8 Binary files /dev/null and b/docs/04-avalonia/QQ_1785818173900.png differ diff --git a/docs/04-avalonia/QQ_1785819031379.png b/docs/04-avalonia/QQ_1785819031379.png new file mode 100644 index 0000000..b242ea5 Binary files /dev/null and b/docs/04-avalonia/QQ_1785819031379.png differ diff --git a/docs/04-avalonia/QQ_1785827003500.png b/docs/04-avalonia/QQ_1785827003500.png new file mode 100644 index 0000000..4ab6dd5 Binary files /dev/null and b/docs/04-avalonia/QQ_1785827003500.png differ diff --git a/docs/04-avalonia/QQ_1785827109255.png b/docs/04-avalonia/QQ_1785827109255.png new file mode 100644 index 0000000..2409aea Binary files /dev/null and b/docs/04-avalonia/QQ_1785827109255.png differ diff --git a/docs/04-avalonia/QQ_1785827490152-1.png b/docs/04-avalonia/QQ_1785827490152-1.png new file mode 100644 index 0000000..0b06b4b Binary files /dev/null and b/docs/04-avalonia/QQ_1785827490152-1.png differ diff --git a/docs/04-avalonia/QQ_1785827490152.png b/docs/04-avalonia/QQ_1785827490152.png new file mode 100644 index 0000000..0b06b4b Binary files /dev/null and b/docs/04-avalonia/QQ_1785827490152.png differ diff --git a/docs/04-avalonia/QQ_1785827569651.png b/docs/04-avalonia/QQ_1785827569651.png new file mode 100644 index 0000000..1e025bd Binary files /dev/null and b/docs/04-avalonia/QQ_1785827569651.png differ diff --git a/docs/04-avalonia/QQ_1785827606708.png b/docs/04-avalonia/QQ_1785827606708.png new file mode 100644 index 0000000..97ffadf Binary files /dev/null and b/docs/04-avalonia/QQ_1785827606708.png differ diff --git a/docs/04-avalonia/report.md b/docs/04-avalonia/report.md new file mode 100644 index 0000000..1963f28 --- /dev/null +++ b/docs/04-avalonia/report.md @@ -0,0 +1,159 @@ +# 04-avalonia 实验报告 + +## 一、T4.1 功能说明 + +`LogAnalyzerClient` 是一个基于 [Avalonia UI]的跨平台图形界面客户端,用于替代上一节的控制台客户端 `RemoteCli`。它包含 `RemoteCli` 的全部功能:连接 Agent、切换日志目录、刷新文件列表、(按并行度)分析选中 / 全部 / 右键单个文件、以及流式查看分析结果。整个客户端采用 MVVM 模式编写,借助 `CommunityToolkit.Mvvm` 的 `[ObservableProperty]` 与 `[RelayCommand]` 源生成器大幅减少了样板代码。 + +### 1. 实现的功能 + +| 入口 | 功能 | 对应的 gRPC 异步调用 | 说明 | +| :--- | :--- | :--- | :--- | +| `File → Connect...` | 连接到 Agent | `PingAsync` | 通过工厂 `AppService.ClientFactory.CreateClient` 创建 gRPC Client,再 `Ping` 验证连通性 | +| `Change Directory` 按钮 | 切换 Agent 的日志目录 | `ChangeDirectoryAsync` | 框架已实现,切换成功后自动刷新文件列表 | +| `Refresh` 按钮 / `File → Refresh` | 刷新日志目录文件列表 | `GetLogFilesAsync` | 用返回的 `file_names` 重建 `LogFiles` | +| `Analyze → Selected` 按钮 | 分析多选的若干文件 | `AnalyzeFilesAsync` |参数为 `SelectedFiles`(由 `LogFileListBox_SelectionChanged` 维护) | +| `Analyze → All` 按钮 | 分析当前目录全部文件 | `AnalyzeAllAsync` | 在 XAML 中新增 `All` 按钮并绑定 `AnalyzeAllCommand` | +| 右键菜单 `Analyze File` | 分析右键选中的单个文件 | `AnalyzeFilesAsync` | 参数为当前 `SelectedLogFile` | +| 右键菜单 `View Analysis Results` | 查看所选文件分析结果 | `GetAnalysisResult`(`ResponseStream.ReadAllAsync`) | 逐条接收并填充 `ResultEntries` | +| Analysis Result 列表 | 展示分析结果 | —— | `LogFields.Summary` 负责每行的文本格式 | + +### 2. 关键实现要点 + +1. **全异步调用**:图形界面程序只有单一的 UI 线程负责渲染与响应,任何阻塞都会让程序看起来「卡死」。因此所有的 gRPC 调用都使用 `Async` 版本并 `await`;对于服务端流式接口 `GetAnalysisResult`,使用 `await foreach (var response in call.ResponseStream.ReadAllAsync())` 逐条读取。 + +2. **统一的异常兜底 `WithClientNotNull`**:所有需要客户端的命令都套在 `WithClientNotNull` 中。它先检查是否已连接(`_client is null` 时弹出提示),再用 `try/catch (Exception)` 兜住一切 gRPC 网络异常与内部错误,转为消息框,保证「GUI 程序绝不应因用户非法输入或内部错误而崩溃」。 + +3. **输入校验前置**: + - 并行度通过 `TryGetDegreeOfParallelism` 校验,必须是**非负整数**(`0` 表示使用 `ProcessorCount`),非法时弹框提示并不发起请求。 + - 「分析选中文件」会检查 `SelectedFiles.Count == 0`;「右键分析 / 查看结果」会检查 `SelectedLogFile is null`,避免空引用。 + +4. **服务端返回状态二次检查**:除了捕获网络层的 `RpcException`,还对每个响应的 `OperationStatusMessage.Success` 做检查,失败时将 `Code: Message` 通过消息框展示给用户。 + +5. **流式结果解析**(`GetAnalysisResultAsync`):根据 `PayloadCase` 分别处理: + - `Header`:按 `State` 分三种情况——`NotAnalyzed` 显示「尚未分析」提示;`Failed` 显示 `Analysis failed: {ErrorMessage}`;`Succeeded` 继续接收后续条目。 + - `LogEntry`:经 `GrpcTypeConverter.ConvertFromGrpc` 转回内部 `LogEntry`,再用 `KeyValueVisitor` 转成键值对,包装成 `LogFields` 加入 `ResultEntries`。每次查看前先 `ResultEntries.Clear()`,避免与上次结果混淆。 + +6. **`LogFields.Summary` 的显示格式**: + - 普通日志条目:`序号 | Key: Value, Key: Value, ...`(与示例截图一致,序号取日志的 `LineNo`)。 + - 错误 / 未分析:直接展示 `ErrorMessage` 文本(如 `File 'xxx' has not been analyzed yet.`)。 + +7. **新增 `All` 按钮**:在 `MainView.axaml` 的分析操作 `Grid` 中,把列定义从 `Auto,*,Auto,Auto` 扩展为 `Auto,*,Auto,Auto,Auto`,在 `Selected` 之后追加 `All` 按钮并绑定 `AnalyzeAllCommand`。 + +### 3. 运行方式 + +GUI 客户端需要**同时**运行 Agent(服务端)与客户端两个程序。下面所有截图均按以下命令启动: + +```bash +# 终端 1:启动 Agent(gRPC 服务端,监听 http://localhost:5000) +dotnet run --project src/LogAnalyzerAgent + +# 终端 2:启动 Avalonia 桌面客户端 +dotnet run --project src/LogAnalyzerClient/LogAnalyzerClient.Desktop +``` + +> 客户端启动后,点击菜单 `File → Connect...`,在弹窗中输入 `http://localhost:5000` 即可连接。 +> 测试所用日志目录(任选其一填入 *Directory Path* 输入框): +> - `src/dataset`(含 `basic.log`、`basic-fail.log`、`basic-multiple.log`) +> - `src/dataset/multiple-logs`(含 30 个 `20260701.log` ~ `20260730.log`) + +--- + +## 二、功能演示截图(命令 / 操作标注) + +### 1. 启动并连接到 Agent + +![alt text](./QQ_1785817655622.png) + +### 2. 切换目录并刷新出文件列表 + +在 *Directory Path* 输入框输入绝对路径 → 点击 `Change Directory` +![alt text](./QQ_1785813088042.png) + +### 3. 多选文件并分析(Analyze → Selected) + +![alt text](./QQ_1785817741740.png) + +### 4. 分析全部文件(Analyze → All) + +![alt text](./QQ_1785817782805.png) + +### 5. 右键分析单个文件(右键菜单 Analyze File) + +![alt text](./QQ_1785817816941.png) + +### 6. 查看分析结果(右键菜单 View Analysis Results)—— 成功 + +选中已分析成功的文件(如 `basic-multiple.log`)→ 右键 → 点击 `View Analysis Results(V)`(调用流式 `GetAnalysisResult`) + +![alt text](./QQ_1785818118027.png) + +### 7. 查看分析结果 —— 失败 / 尚未分析 + +失败:选中 `basic-fail.log`,用 `All` 或 `Selected` 分析它(会解析失败)→ 右键 → `View Analysis Results(V)` +未分析:连接并切换目录后,**不**进行分析,直接选中某文件 → 右键 → `View Analysis Results(V)` + +![alt text](./QQ_1785818173900.png) +![alt text](./QQ_1785819031379.png) +--- + +## 三、鲁棒性测试截图(命令 / 操作标注) + +### 1. 未连接 Agent 就执行操作 + +**不**点击 `Connect...`,直接点击 `Refresh` / `Change Directory` / `Selected` 等任意按钮 +![alt text](./QQ_1785827003500.png) + +### 2. 连接到不存在的 Agent 地址 + +![alt text](./QQ_1785827109255.png) + +### 3. 非法的目录路径 + +![alt text](./QQ_1785827490152-1.png) + +### 4. 非法的并行度输入 + +![alt text](./QQ_1785827569651.png) + +### 5. 未选中文件就点击 Selected + +![alt text](./QQ_1785827606708.png) + +--- + +## 四、问答题 + +### (Q4.1) + +**GUI 应用与控制台应用的区别:** + +1. **交互范式不同**:控制台应用是「线性的一问一答」——程序主动 `Console.ReadLine` 等待输入,流程是预先确定的;GUI 应用是「事件驱动」——用户可以在任意时刻点击任意按钮、输入任意内容,程序必须随时响应,控制流不再线性。本次实现中,每个按钮 / 菜单项都被绑定到一个独立的 `ICommand`,由用户决定何时触发、以何种顺序触发。 +2. **关注点分离的要求不同**:控制台应用里输入、逻辑、输出往往混在一个 `Main` 里;GUI 应用要求把**界面(View)**、**状态与逻辑(ViewModel)**、**数据(Model)**分层,即 MVVM。本次中 `MainView.axaml` 只管展示与绑定,`MainViewModel` 持有所有状态与命令逻辑,`Models` 定义纯数据结构,三者通过数据绑定协作。 +3. **状态展示方式不同**:控制台靠 `Console.WriteLine` 顺序打印;GUI 靠**数据绑定**——只要 `ObservableProperty` 的值变化,界面自动刷新(如状态栏的 `ConnectStatus`、文件列表 `LogFiles`、结果列表 `ResultEntries`),无需手动「重绘」。 +4. **用户输入的不可控性**:控制台输入基本是字符串;GUI 中用户可能在不该空的输入框留空、输入非法字符、在不该点击时点击、未连接就操作等。必须处处做输入校验与异常兜底。 + +**额外的难点 / 复杂之处:** + +1. **必须理解并正确使用 MVVM 与数据绑定**:要搞清 `OneWay` / `OneWayToSource` / `TwoWay` 等绑定模式。例如文件列表用 `SelectedItem="{Binding SelectedLogFile, Mode=OneWayToSource}"`,而多选时 ListBox 无法把「全部选中项」直接绑定到 ViewModel,必须借助 `MainView.axaml.cs` 里的 `LogFileListBox_SelectionChanged` 回调手动维护 `SelectedFiles`——这是 View 与 ViewModel 边界上一个很别扭的地方。 +2. **UI 线程模型与线程安全**:UI 控件只能由 UI 线程访问,而异步 gRPC 调用的延续可能在别的线程上。`ObservableCollection` 的变更必须回到 UI 线程,否则会抛异常。Avalonia 的绑定机制帮我们处理了大部分,但理解其原理是额外的心智负担。 +3. **异常处理策略完全不同**:控制台里一个未捕获异常最多让程序退出;GUI 里一个未捕获异常会让整个窗口崩溃,体验极差。因此必须用 `WithClientNotNull` 这类统一的兜底,把所有异常转成**消息框**而非崩溃。 +4. **调试与反馈链更长**:除了要同时启动 Agent 与 Client 两个程序(上一节的痛点依然存在),GUI 的状态分布在绑定、ViewModel、控件回调等多处,定位「为什么这一项没更新」往往要检查绑定路径、`Mode`、`x:DataType`、属性通知等多个环节。 + +**对异步 `async` / `await` 的进一步理解:** + +通过编写 GUI 客户端,我对异步的理解确实更深了一层。在控制台里,`await` 更多是「写法上的要求」;但在 GUI 里,`await` 有了**肉眼可见的意义**——如果没有 `await` 而是同步阻塞,UI 线程会被网络 I/O 占住,整个窗口会「卡死」(拖不动、按钮无响应)。`await` 让 UI 线程在等待网络响应时返回消息循环去处理用户的其他操作(比如拖动窗口、点击别的按钮),等结果回来再继续。这让我真正体会到「异步是为了不阻塞调用线程」这句话的含义。 + +**异步带来的额外困扰:** + +1. **「异步传染」**:一旦底层是异步的(gRPC 调用),上层调用链就得一路 `async`/`await` 到底,方法签名都要带 `Async` 后缀和 `Task`,这是无法回避的传播。 +2. **异常捕获的位置变了**:异步方法的异常不会在调用处直接抛出,而是藏在返回的 `Task` 里,必须 `await` 才能观察到,漏 `await` 会导致异常被「吞掉」,排查很困难。 +3. **多选回调与异步命令的时序**:`LogFileListBox_SelectionChanged` 在 UI 线程更新 `SelectedFiles`,而分析命令异步读取它,两者之间没有显式同步——靠的是 UI 单线程模型保证的串行性,理解这一点需要额外的思考。 + +总的来说,GUI 开发相对控制台,本质上是从「**线性流程**」转向「**事件驱动 + 数据绑定 + 多线程协作**」,复杂度显著上升;而异步编程既是 GUI 不卡死的必需品,也确实带来了一些新的心智负担。 + +### (Q4.2) + +根据TODO框架和guidance.md完成任务××.AI能给出达成任务要求的代码并自行测试验证。有时候AI会有过度、无效兜底的问题,在这次作业中基本没有出现 +--- + + diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..a0afc33 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -1,4 +1,5 @@ using LogAnalyzer; +using LogParser.Models; using LogParser.Visitors; namespace LocalCli @@ -112,22 +113,106 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var files = analyzer.GetLogFiles(); + if (files.Count == 0) + { + Console.WriteLine("No log files found in the current directory."); + return; + } + + Console.WriteLine($"Log files in '{analyzer.CurrentDirectory}' ({files.Count}):"); + foreach (var file in files) + { + Console.WriteLine($" {file}"); + } } private static void AnalyzeFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input file names separated by ',' to analyze:"); + var line = Console.ReadLine(); + if (line is null) + { + return; + } + + var fileNames = line.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (fileNames.Length == 0) + { + Console.WriteLine("No file names provided."); + return; + } + + try + { + Console.WriteLine($"Analyzing {fileNames.Length} file(s) with parallelism 0 (= ProcessorCount = {Environment.ProcessorCount})..."); + analyzer.AnalyzeFiles(0, fileNames); + Console.WriteLine("Analysis completed."); + } + catch (ArgumentException ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } } private static void AnalyzeAll(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + try + { + Console.WriteLine($"Analyzing all log files with parallelism 0 (= ProcessorCount = {Environment.ProcessorCount})..."); + analyzer.AnalyzeAll(0); + Console.WriteLine("Analysis completed."); + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input the file name to get analysis result:"); + var fileName = Console.ReadLine(); + if (fileName is null) + { + return; + } + + fileName = fileName.Trim(); + if (string.IsNullOrEmpty(fileName)) + { + Console.WriteLine("No file name provided."); + return; + } + + if (!analyzer.TryGetAnalysisResult(fileName, out var result)) + { + 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. Please analyze it first."); + break; + case AnalysisState.Succeeded: + Console.WriteLine($"Analysis result for '{fileName}' (parsed by worker {result.WorkerId}, {result.Entries.Count} entries):"); + var visitor = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var dump = visitor.Dump(entry); + Console.WriteLine($" [{entry.EventType}] {string.Join(", ", dump.Select(kv => $"{kv.Key}={kv.Value}"))}"); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"Failed to analyze '{fileName}': {result.ErrorMessage}"); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..35a1a84 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -1,7 +1,6 @@ using LogParser.Models; using LogParser.Parser; using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography.X509Certificates; namespace LogAnalyzer { @@ -141,7 +140,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) /* * Set _isAnalyzing */ - // TODO: T2.2 + _isAnalyzing = true; } try @@ -154,7 +153,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 +171,15 @@ 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 existing)) + { + throw new InvalidOperationException($"Unknown file '{file.Name}'."); + } + // 跳过已经分析过且保存了分析结果的文件(Succeeded / Failed),只保留未分析的文件 + if (existing.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } } } @@ -183,7 +193,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 +208,22 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis /* * Create and start threads to run `WorkerMain` */ - // TODO: T2.2 + var thread = new Thread(() => WorkerMain(workerId, queue)) + { + IsBackground = true, + Name = threadName, + }; + workers[i] = thread; + thread.Start(); } /* * Wait for (join) all threads to end */ - // TODO: T2.2 + foreach (var thread in workers) + { + thread.Join(); + } } private void WorkerMain(int workerId, WorkQueue queue) @@ -213,19 +236,37 @@ private void WorkerMain(int workerId, WorkQueue queue) try { // Parse file - throw new NotImplementedException("TODO: T2.2"); + using var reader = new StreamReader(file.FullName); + // 使用 ToList() 强制立即求值,确保解析过程中的异常在本 try 块内被捕获 + 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..e6b42a3 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,50 @@ 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); + // 唤醒一个正在等待的消费者(signal 操作) + Monitor.Pulse(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + // 用 while 而非 if:避免虚假唤醒(spurious wakeup)带来的错误判断 + while (_items.Count == 0 && !_isCompleted) + { + // 队列为空且尚未结束放入:解锁互斥量并等待(wait 操作) + Monitor.Wait(_items); + } + + if (_items.Count > 0) + { + item = _items.Dequeue(); + return true; + } + + // 队列为空且已结束放入:返回 false + item = default; + return false; + } } public void CompleteAdding() { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + _isCompleted = true; + // 唤醒全部正在等待的消费者(broadcast 操作),使其能正常退出 + Monitor.PulseAll(_items); + } } } } diff --git a/src/LogAnalyzerAgent/Applications/AgentSession.cs b/src/LogAnalyzerAgent/Applications/AgentSession.cs index 2531f22..23b35e1 100644 --- a/src/LogAnalyzerAgent/Applications/AgentSession.cs +++ b/src/LogAnalyzerAgent/Applications/AgentSession.cs @@ -18,13 +18,13 @@ public AgentSession(LogFileAnalyzer analyzer, ILoggerFactory loggerFactory) _logger = loggerFactory.CreateLogger(); } - private static OperationStatusMessage CreateInternalErrorOperationStatus(Exception ex) + private static OperationStatusMessage CreateInternalErrorOperationStatus(Exception ex, string operation) { return new OperationStatusMessage() { Success = false, Code = AgentErrorCode.InternalError, - Message = $"An error occurred while retrieving agent status: {ex.Message}", + Message = $"An error occurred while {operation}: {ex.Message}", }; } @@ -38,6 +38,16 @@ private static OperationStatusMessage CreateNoErrorOperationStatus() }; } + private static OperationStatusMessage CreateErrorOperationStatus(AgentErrorCode code, string message) + { + return new OperationStatusMessage() + { + Success = false, + Code = code, + Message = message, + }; + } + public Task Ping(Empty empty, CancellationToken cancellationToken) { return Task.FromResult(new Empty()); @@ -55,7 +65,7 @@ public Task GetAgentStatus(Empty empty, CancellationToke } catch (Exception ex) { - response.Status = CreateInternalErrorOperationStatus(ex); + response.Status = CreateInternalErrorOperationStatus(ex, "retrieving agent status"); _logger.LogError(ex, "An error occurred while retrieving agent status."); } return Task.FromResult(response); @@ -71,7 +81,7 @@ public Task GetLogFiles(Empty empty, CancellationToken canc } catch (Exception ex) { - response.Status = CreateInternalErrorOperationStatus(ex); + response.Status = CreateInternalErrorOperationStatus(ex, "retrieving log files"); _logger.LogError(ex, "An error occurred while retrieving log files."); } return Task.FromResult(response); @@ -79,22 +89,187 @@ 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 (_analyzer.IsAnalyzing) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + "Cannot change directory while analysis is in progress."); + return Task.FromResult(response); + } + + bool changed; + try + { + changed = _analyzer.ChangeDirectory(request.DirectoryPath); + } + catch (ArgumentException) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + $"Invalid directory path: '{request.DirectoryPath}'."); + return Task.FromResult(response); + } + + if (!changed) + { + 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, "changing directory"); + _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 log directory has been set. Please change directory first."); + return Task.FromResult(response); + } + + if (request.DegreeOfParallelism < 0) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "Degree of parallelism must be non-negative."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + catch (InvalidOperationException ex) + { + response.Status = CreateErrorOperationStatus(AgentErrorCode.InvalidOperation, ex.Message); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex, "analyzing all log files"); + _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 log directory has been set. Please change directory first."); + return Task.FromResult(response); + } + + if (request.DegreeOfParallelism < 0) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "Degree of parallelism must be non-negative."); + return Task.FromResult(response); + } + + if (request.FileNames.Count == 0) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "No file names provided."); + return Task.FromResult(response); + } + + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, request.FileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (InvalidOperationException ex) + { + response.Status = CreateErrorOperationStatus(AgentErrorCode.InvalidOperation, ex.Message); + } + catch (ArgumentException ex) + { + // LogFileAnalyzer throws ArgumentException when a requested file is not in the directory. + response.Status = CreateErrorOperationStatus(AgentErrorCode.FileNotFound, ex.Message); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex, "analyzing log files"); + _logger.LogError(ex, "An error occurred while analyzing 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}' does not exist in the current directory."), + }); + return responses; + } + + // First, return the header describing the analysis state of the file. + responses.Add(new GetAnalysisResultResponse() + { + Header = new AnalysisResultHeaderMessage() + { + FileName = result.FileName, + FullName = result.FullName, + State = GrpcTypeConverter.ConvertToGrpc(result.State), + ErrorMessage = result.ErrorMessage ?? "", + WorkerId = result.WorkerId, + }, + Status = CreateNoErrorOperationStatus(), + }); + + // Only stream log entries when the analysis succeeded. + if (result.State == AnalysisState.Succeeded) + { + foreach (var entry in result.Entries) + { + responses.Add(new GetAnalysisResultResponse() + { + LogEntry = GrpcTypeConverter.ConvertToGrpc(entry), + Status = CreateNoErrorOperationStatus(), + }); + } + } + } + catch (Exception ex) + { + responses.Add(new GetAnalysisResultResponse() + { + Status = CreateInternalErrorOperationStatus(ex, "retrieving analysis result"), + }); + _logger.LogError(ex, "An error occurred while retrieving analysis result."); + } + return responses; } } } 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/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs index 2ff1b64..68bb6c2 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Models/RemoteModels.cs @@ -11,7 +11,20 @@ public sealed record LogFileItem(string FileName) public sealed record LogFields(int Index, IReadOnlyList Fields, string? ErrorMessage) { - public string Summary => "TODO: T4.1"; + // 当 ErrorMessage 非空时(如「尚未分析」「分析失败」),直接展示该提示信息; + // 否则把日志条目的字段按 `序号 | Key: Value, Key: Value, ...` 的形式汇总成一行。 + public string Summary + { + get + { + if (ErrorMessage is not null) + { + return ErrorMessage; + } + var fields = string.Join(", ", Fields.Select(f => $"{f.Key}: {f.Value}")); + return $"{Index} | {fields}"; + } + } } 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..1e7efaa 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs +++ b/src/LogAnalyzerClient/LogAnalyzerClient/ViewModels/MainViewModel.cs @@ -24,6 +24,9 @@ public partial class MainViewModel : ViewModelBase private LogAnalyzerAgentServiceClient? _client = null; + // 用于把 gRPC 返回的日志条目转成键值对,便于在 Analysis Result 中展示。 + private readonly KeyValueVisitor _visitor = new(); + public IReadOnlyList SelectedFiles { get; set; } = new List(); [ObservableProperty] @@ -127,36 +130,193 @@ await DialogHelper.ShowMessageDialogAsync("Error", }); } + /// + /// 解析并行度输入框中的文本。合法(非负整数)时返回对应值,否则返回 null。 + /// + private int? TryGetDegreeOfParallelism() + { + if (int.TryParse(DegreeOfParallelismText?.Trim(), out int degree) && degree >= 0) + { + return degree; + } + return null; + } + [RelayCommand] private async Task RefreshAsync() { await WithClientNotNull(async () => { - throw new NotImplementedException("TODO: T4.1"); + var response = await _client!.GetLogFilesAsync(new Empty()); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + 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 () => + { + if (SelectedFiles.Count == 0) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "No file selected. Please select at least one log file (hold Ctrl to multi-select)."); + return; + } + var degree = TryGetDegreeOfParallelism(); + if (degree is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "Invalid degree of parallelism. Please input a non-negative integer."); + return; + } + var request = new AnalyzeFilesRequest() + { + DegreeOfParallelism = degree.Value, + }; + request.FileNames.AddRange(SelectedFiles); + var response = await _client!.AnalyzeFilesAsync(request); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + await DialogHelper.ShowMessageDialogAsync("Analyze", + $"Successfully analyzed {SelectedFiles.Count} file(s)."); + }); } - /* - * TODO: T4.1 - * Add AnalyzeAllAsync ReplayCommand - */ + [RelayCommand] + private async Task AnalyzeAllAsync() + { + await WithClientNotNull(async () => + { + var degree = TryGetDegreeOfParallelism(); + if (degree is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "Invalid degree of parallelism. Please input a non-negative integer."); + return; + } + var request = new AnalyzeAllRequest() + { + DegreeOfParallelism = degree.Value, + }; + var response = await _client!.AnalyzeAllAsync(request); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + await DialogHelper.ShowMessageDialogAsync("Analyze", "Successfully analyzed all log files."); + }); + } [RelayCommand] private async Task AnalyzeRightClickedFileAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "No file selected. Please select a log file first."); + return; + } + var degree = TryGetDegreeOfParallelism(); + if (degree is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "Invalid degree of parallelism. Please input a non-negative integer."); + return; + } + var request = new AnalyzeFilesRequest() + { + DegreeOfParallelism = degree.Value, + }; + request.FileNames.Add(SelectedLogFile.FileName); + var response = await _client!.AnalyzeFilesAsync(request); + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + await DialogHelper.ShowMessageDialogAsync("Analyze", + $"Successfully analyzed '{SelectedLogFile.FileName}'."); + }); } [RelayCommand] private async Task GetAnalysisResultAsync() { - throw new NotImplementedException("TODO: T4.1"); + await WithClientNotNull(async () => + { + if (SelectedLogFile is null) + { + await DialogHelper.ShowMessageDialogAsync("Error", + "No file selected. Please select a log file first."); + return; + } + var fileName = SelectedLogFile.FileName; + var request = new GetAnalysisResultRequest() + { + FileName = fileName, + }; + + // 每次查看结果前先清空,避免与上一次的结果混淆。 + ResultEntries.Clear(); + using var call = _client!.GetAnalysisResult(request); + await foreach (var response in call.ResponseStream.ReadAllAsync()) + { + if (!response.Status.Success) + { + await DialogHelper.ShowMessageDialogAsync("Error", + $"{response.Status.Code}: {response.Status.Message}"); + return; + } + + switch (response.PayloadCase) + { + case GetAnalysisResultResponse.PayloadOneofCase.Header: + switch (response.Header.State) + { + case AnalysisStateEnum.NotAnalyzed: + ResultEntries.Add(new LogFields(0, Array.Empty(), + $"File '{fileName}' has not been analyzed yet.")); + return; + case AnalysisStateEnum.Failed: + ResultEntries.Add(new LogFields(0, Array.Empty(), + $"Analysis failed: {response.Header.ErrorMessage}")); + return; + case AnalysisStateEnum.Succeeded: + // 头部状态为成功,继续接收后续逐条日志条目。 + break; + } + break; + case GetAnalysisResultResponse.PayloadOneofCase.LogEntry: + var entry = GrpcTypeConverter.ConvertFromGrpc(response.LogEntry); + var dump = _visitor.Dump(entry); + var fields = dump + .Select(kv => new LogFieldItem(kv.Key, kv.Value)) + .ToList(); + ResultEntries.Add(new LogFields(entry.LineNo, fields, null)); + break; + } + } + }); } [RelayCommand] diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml index fffef7e..4bd6821 100644 --- a/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Views/MainView.axaml @@ -79,7 +79,7 @@ to set the actual DataContext for runtime, set the DataContext property in code - +