diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..3e85fe2 --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,60 @@ +# 报告 + +## Q1.1 的回答 + ++ 哪条语句或哪几条语句将日志按逗号进行分割?代码中,我们是如何指定每一行的第几个字段代表何种意义的? + - "分割"的实现位于 `LogFileParser.cs` 的 38 行 `foreach (var logRecord in csv.GetRecords())` 中实现,该行调用了 csv 库的 `GetRecords` 方法,将日志转化为我们所需要的 `LogRecord` 类。 + - 我们通过该文件 20-23 行 + + ```csharp + Map(m => m.LineNo).Index(0); + Map(m => m.Timestamp).Index(1); + Map(m => m.PodName).Index(2); + Map(m => m.Message).Index(3); + ``` + + 确定了:第一个字段是 `LineNo`,第二个字段是 `Timestamp`,以此类推。 + ++ 在对日志中 JSON 格式的 `message` 字段进行读取时,我们是在哪个方法内用哪几条语句判断这一行日志的种类(Call / Request / Internal)的? + - 我们在 `LineParser.cs` 的 `ParseLine` 方法中判断种类,具体来说,我们试图通过 + + ```csharp + 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($"Unknown event type: {eventElement.GetString()} in log message: {logRecord.Message}") + }; + } + ``` + + 检测传入 `LogRecord` 对象的 `message` 属性的 `event` 字段是三者中的哪一个,从而进行判断。 + ++ 在确定了日志种类后,我们是调用了哪个库方法对 JSON 进行解析的? + + 进一步,我们的框架代码是如何防止日志中有字段缺失的?(例如所给的 Call 日志的 `message` 中缺失 `request_id` 字段) + + 更进一步,日志中的 JSON 的键是 `abc-def` 命名法(称为烤串命名法),而我们的解析结果却是放在 `AbcDef` 命名法(称为大驼峰命名法)的属性里,我们的框架代码中是如何告诉 JSON 解析器完成这一命名法转换的? + - 我们调用了 `JsonSerializer` 的 `Deserialize` 方法解析 JSON。 + - 我们首先通过 `[property: JsonRequired]` 来确保:如果缺失字段,则抛出 `JsonException` 异常。此外,我们还通过 `??` 运算符检测 `JsonSerializer.Deserialize` 方法返回值是否为 `null`,如果在某些情况下该方法返回了 `null`,则抛出 `FormatException` 异常。 + - 我们在 `LineParser.cs` 的 31-34 行将 `options` 设为具有 `PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower` 的 `JsonSerializerOptions`,并在 `Deserialize` 时传入 `options` 参数,从而完成了命名法转换。 + +## Q1.2 的回答 + ++ `Dictionary KeyValueVisitor.Dump(LogEntry entry)` ++ `TResult Accept(ILogEntryVisitor visitor);` ++ `Dictionary Visit(CallLogEntry entry)` + +## Q1.3 的回答 + ++ 本次作业中,你是否使用了 AI? + - 我使用了 AI。 + +### Q1.3.b 的回答 + ++ 如果使用了 AI,你给予 AI 的提示词是什么?你认为 AI 给出的解答、你完全凭借传统搜索引擎以及自己的能力能够写出的解答之间,AI 的解答比你好在哪?AI 又有哪些解答是存在问题的,或者至少是不如你自己的解答的?给出你的理由。 + - 我主要使用的是 VS Code Copilot 自带的代码补全 AI,故没有给出提示词。 + - AI 的解答相比于自己给出的解答更为安全,且可读性更好,例如:`LineParser.cs` 的 72-75 这几行就是 AI 补充的,防止出现没有冒号的情况。 + - AI 仅仅看到了 `InternalMessage` 上文的两个格式就开始了 `private record InternalMessage` 的编写,但是它没有考虑到原始文本的 `ExceptionName` 和 `ExceptionMessage` 并不是由 JSON 解析给出的,我在测试未通过后,通过检查解决了该问题。 + - 本报告完全由我所写,但是由于我对于markdown的格式并不熟悉,所以我让AI调了一下格式 diff --git a/docs/02-multithreading/assets/localcli_func.png b/docs/02-multithreading/assets/localcli_func.png new file mode 100644 index 0000000..98e0500 Binary files /dev/null and b/docs/02-multithreading/assets/localcli_func.png differ diff --git a/docs/02-multithreading/assets/localcli_robust.png b/docs/02-multithreading/assets/localcli_robust.png new file mode 100644 index 0000000..4ae2496 Binary files /dev/null and b/docs/02-multithreading/assets/localcli_robust.png differ diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..972f7f0 --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,127 @@ +# Report: LocalCli Console Interface (T2.3) + +## 实现功能 + +根据 [guidance.md](./guidance.md) 中 Task 2.3 (S2.3) 的要求,完成了 `LocalCli/Program.cs` 中的控制台交互界面,包含以下功能: + +### 1. `InputDirectory` — 输入日志目录 + +提示用户输入日志文件所在目录,调用 `LogFileAnalyzer` 构造器扫描 `.log` 文件: + +- 目录不存在时调用 `analyzer.ChangeDirectory()` 返回 `false`,提示 "Directory not exists" 并要求重试 +- 目录路径非法(如空字符串)时捕获 `ArgumentException`,提示 "Directory illegal" 并要求重试 +- 用户输入 `Ctrl+C` / `Ctrl+Z`(`Console.ReadLine()` 返回 `null`)时安全退出 + +### 2. `ShowLogFiles` — 显示日志文件列表 + +调用 `analyzer.GetLogFiles()` 获取目录中所有 `.log` 文件,逐行打印文件名。 + +### 3. `AnalyzeFiles` — 分析指定日志文件 + +- 调用 `ReadDegreeOfParallelism()` 读取并行度(0 = 自动 / 逻辑处理器数),非数字或负数会提示重新输入 +- 调用 `ReadFileNames()` 读取逗号分隔的文件名列表(自动 `Trim` 并去除空项) +- 调用 `analyzer.AnalyzeFiles(degreeOfParallelism, fileNames)` 进行分析 +- 异常被捕获并以 "分析失败" 提示,程序不会崩溃 + +### 4. `AnalyzeAll` — 分析全部日志文件 + +- 读取并行度后调用 `analyzer.AnalyzeAll(degreeOfParallelism)` +- 同样做了异常捕获以保证鲁棒性 + +### 5. `GetAnalysisResult` — 获取分析结果 + +输入文件名,调用 `analyzer.TryGetAnalysisResult()`,分四种情况处理: + +| 情况 | 行为 | +|------|------| +| 文件不存在 | 提示 "File 'xxx' not found." | +| 尚未分析 (`NotAnalyzed`) | 提示 "File 'xxx' has not been analyzed." | +| 分析成功 (`Succeeded`) | 调用 `KeyValueVisitor.Dump` 逐行输出键值对 | +| 分析失败 (`Failed`) | 输出 `result.ErrorMessage` | + +### 6. 鲁棒性设计 + +对所有异常输入均有处理,程序不会崩溃: + +- 非法目录 → 提示重试 +- 非法菜单选项(非数字、超出范围)→ 提示 "Invalid choice/input" +- 非法并行度(负数、非数字)→ 提示重试 +- 空文件名列表 → 提示 "No file names input." 并返回主菜单 +- 不存在 / 未分析的文件查结果 → 给出明确提示 +- 切换目录后重新分析 → 结果正常重置 + +--- + +## 功能测试截图 + +### 完整功能演示 + +![完整功能演示](./assets/localcli_func.png) + +以上截图展示了完整的功能流程: +1. 输入目录 `dataset` +2. 显示日志文件列表(选项 1) +3. 分析指定文件 `basic.log, basic-fail.log`,并行度 2(选项 2) +4. 查看 `basic.log` 解析成功的 3 条记录(选项 4) +5. 查看 `basic-fail.log` 解析失败的错误信息(选项 4) +6. 分析全部文件(选项 3,并行度 0 = auto) +7. 查看 `basic-multiple.log` 200 条解析结果(选项 4) + +### 鲁棒性测试 + +![鲁棒性测试](./assets/localcli_robust.png) + +以上截图展示了各种非法输入的处理: +1. 输入不存在的目录 → 提示 "Directory not exists" 并重试 +2. 非法菜单选项 `0`, `abc`, `7` → 提示 "Invalid" +3. 非法并行度 `abc`, `-1` → 提示 "Invalid input" +4. 空文件名 → 提示 "No file names input." +5. 查不存在的文件的结果 → "File 'nonexistent.log' not found." +6. 查未分析文件的结果 → "has not been analyzed." +7. 分析全部 → 查看 `basic-fail.log` 的失败信息 +8. 切换目录后结果重置 → `basic-fail.log` 变回 "not been analyzed" +9. 重新分析后再次查看失败文件 → 正确输出错误信息 + +--- + +## 问答 + +### Q2.1 + +**`WorkQueue` 类中的共享变量有哪些?是通过什么保护其免于数据竞争(data race)呢?** + +`_items` 和 `_isCompleted` 都是共享变量,访问二者时提前打上 `_items` 的锁使得二者免于数据竞争。 + +**`LogFileAnalyzer` 类中的共享变量有哪些?是通过什么保护其免于数据竞争呢?** + +```csharp +private string? _currentDirectory = null; +private bool _isAnalyzing = false; +private readonly Dictionary _logFiles = new(); +private readonly Dictionary _analysisResults = new(); +``` + +以上都是共享变量,通过打上 `_syncRoot` 的锁避免数据竞争。 + +**如果条件变量的判断条件使用了 `if` 判断而非 `while` 判断,当出现了虚假唤醒现象时(在类 UNIX 系统中,由于 UNIX 信号等机制,即使没有人调用过 `signal` 或 `broadcast`,处于 `wait` 当中的条件变量也可能被唤醒),会出现什么后果?结合无限仓库容量的生产者消费者问题简单叙述一下。** + +以无限仓库容量的生产者消费者问题为例,如果采用 `if(queue.Count == 0)`,当内部 wait 虚假唤醒,则线程继续执行下方 `Dequeue`,试图出队空队列,从而导致抛出 `InvalidOperationException`。 + +### Q2.2 + +**那一段代码扫描了给定的目录中的全部 `.log` 后缀的日志文件?假使给定的需求是不但要扫描给定目录中的日志文件,还要递归地获取给定的目录的全部子目录、子子目录……内的日志文件,应当如何做(简要回答即可)?** + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +``` + +以上代码扫描全部 `.log` 后缀的日志文件。将上述 `TopDirectoryOnly` 改为 `AllDirectories` 即可递归获取所有子目录中的日志文件。 + +### Q2.3 + +- 我使用了AI工具辅助 +- 第一次:由于我不会写文件的流式读取,导致 `parser.Parse` 参数类型不匹配,因此我在 VS Code 的 CC 插件中询问如下问题:"这段代码中 `result = parser.Parse(file);` 并不正确,`parser.Parse` 需要 `TextReader` 类型,应当如何修改?" +- 第二次:我借助了AI完成CLI:提示词为"根据 `docs/02-multiheading/guidance.md` 中对于 T2.3 的要求,完成 `Program.cs`" +- 本文件的测试部分也由AI生成,经过核对与 `report.md` 中要求相符 diff --git a/docs/03-async-grpc/assets/image.png b/docs/03-async-grpc/assets/image.png new file mode 100644 index 0000000..46b4eb5 Binary files /dev/null and b/docs/03-async-grpc/assets/image.png differ diff --git a/docs/03-async-grpc/assets/image_1.png b/docs/03-async-grpc/assets/image_1.png new file mode 100644 index 0000000..5922a37 Binary files /dev/null and b/docs/03-async-grpc/assets/image_1.png differ diff --git a/docs/03-async-grpc/report.md b/docs/03-async-grpc/report.md new file mode 100644 index 0000000..4be8628 --- /dev/null +++ b/docs/03-async-grpc/report.md @@ -0,0 +1,33 @@ +# 异步 gRPC 实验报告 + +## T3.2 截图与功能实现 + +### 输入图片 + +![输入图片](./assets/image.png) + +### 输出图片 + +![输出图片](./assets/image_1.png) + +## Q3.1 + +我认为,在 gRPC 的帮助下,二者的区别被减小了很多。但是以往我“开发”的应用程序往往都是自己使用或者测试,不太需要考虑特别高度的稳定性与对于输入的鲁棒性,因为即使程序崩掉了,也不过是重启一下,但是在本次的作业里,我发现了大量的异常处理等等保证鲁棒性的措施。此外,以往的开发中,我面对的往往是 CPU 密集型任务,因此不太常用多线程,但是网络中,通信成为了一个障碍,所以我在本次作业中也学着使用了一些异步语法。 + +我认为,本次的网络开发的主要难点在于,之前我的思维中,“服务”与“客户”是耦合的,但是在本次作业中,我们需要对二者做高度的解耦,这给我的理解带来了困难。 + +我认为本次主要的复杂之处在于信息流的长度比以前长了很多,以前简单的类型现在可能需要从服务端编码为 Protobuf 再在客户端解码,我认为这是较为复杂的。 + +## Q3.2 + +本次我使用了 AI。 + +### Q3.2.b + +**提示词:** + +> 这段代码中的最后一个方法应当实现流式返回,但是没有传入流对象,我应当如何处理? + +这是因为我不理解为什么 `AgentSession.GetAnalysisResult` 没有传入流对象,但是教学里对于流式 gRPC 要求传入一个流对象,AI 为我解答:这个方法并不是 gRPC 服务方法本身,其本身在 `AgentService` 里,我又查看了 `AgentService` 的代码,之后我理解了其层级。 + +由于我不会 Markdown 语法,本文的 Markdown 格式由 AI 进行了修改。 diff --git a/docs/04-avalonia/assets/image-1.png b/docs/04-avalonia/assets/image-1.png new file mode 100644 index 0000000..c6f68dd Binary files /dev/null and b/docs/04-avalonia/assets/image-1.png differ diff --git a/docs/04-avalonia/assets/image-basic.png b/docs/04-avalonia/assets/image-basic.png new file mode 100644 index 0000000..4ac52c2 Binary files /dev/null and b/docs/04-avalonia/assets/image-basic.png differ diff --git a/docs/04-avalonia/assets/image-fail.png b/docs/04-avalonia/assets/image-fail.png new file mode 100644 index 0000000..3fc0cf9 Binary files /dev/null and b/docs/04-avalonia/assets/image-fail.png differ diff --git a/docs/04-avalonia/assets/image-minus1.png b/docs/04-avalonia/assets/image-minus1.png new file mode 100644 index 0000000..f7bb64b Binary files /dev/null and b/docs/04-avalonia/assets/image-minus1.png differ diff --git a/docs/04-avalonia/assets/image-multiple.png b/docs/04-avalonia/assets/image-multiple.png new file mode 100644 index 0000000..96e8cd4 Binary files /dev/null and b/docs/04-avalonia/assets/image-multiple.png differ diff --git a/docs/04-avalonia/assets/image-null.png b/docs/04-avalonia/assets/image-null.png new file mode 100644 index 0000000..f3d0aba Binary files /dev/null and b/docs/04-avalonia/assets/image-null.png differ diff --git a/docs/04-avalonia/assets/image-wrongport.png b/docs/04-avalonia/assets/image-wrongport.png new file mode 100644 index 0000000..c897746 Binary files /dev/null and b/docs/04-avalonia/assets/image-wrongport.png differ diff --git a/docs/04-avalonia/report.md b/docs/04-avalonia/report.md new file mode 100644 index 0000000..fd9e717 --- /dev/null +++ b/docs/04-avalonia/report.md @@ -0,0 +1,64 @@ +# T4.1 Avalonia 图形界面客户端实现报告 + +## 实现的功能 + +本次我 `LogAnalyzerClient` 中所有 `TODO: T4.1` 的功能: + +1. **刷新日志文件列表**:`RefreshAsync` 异步调用 `GetLogFiles` RPC,并使用返回的文件名更新 `LogFiles`。如果 Agent 返回失败状态,则通过消息框提示错误。 +2. **分析选中的多个文件**:`AnalyzeSelectedFilesAsync` 将 `SelectedFiles` 传入 `AnalyzeFiles` RPC +3. **目前支持分析全部文件** +4. **查看分析结果**,可以通过右键查看结果 + +## 功能截图 + +### 完整界面 + +客户端成功连接到 Agent,界面包含日志目录操作、刷新按钮、并行度输入框、`Selected` 和 `All` 分析按钮,以及分析结果列表。 + +![客户端完整界面](./assets/image-1.png) + +### 分析失败的日志文件 + +对于内容不符合日志格式的文件,客户端能够显示 Agent 返回的具体解析失败原因。 + +![日志分析失败结果](./assets/image-fail.png) + +### 显示多条日志的分析结果 + +分析结果中显示文件名、工作线程编号,并按序号展示每条日志的全部字段。 + +![多条日志分析结果](./assets/image-multiple.png) + +### 显示普通日志文件的分析结果 + +客户端能够正确显示包含 Call、Request 和 Internal 等不同事件类型的日志字段。 + +![普通日志分析结果](./assets/image-basic.png) + +## 鲁棒性测试 + +### 并行度为负数 + +输入 `-1` 时,请求不会发送到 Agent,客户端弹出消息框提示并行度必须为非负整数。 + +![负数并行度测试](./assets/image-minus1.png) + +### 并行度不是整数 + +输入 `null` 等非整数文本时,客户端能够识别非法输入并显示错误信息。 + +![非整数并行度测试](./assets/image-null.png) + +### Agent 地址不可用 + +当目标地址或端口没有 Agent 监听时,连接异常会被捕获,并通过消息框显示连接失败信息,程序不会崩溃。 + +![Agent 不可用测试](./assets/image-wrongport.png) + +## Q4.1 + +区别主要在于:GUI应用程序中,采用了MVVM的架构,分离了前端,后端与其中的传输处理,但是这也带来了清晰的好处:能够分别在model,view,veiwmodel中处理后端,前端与传输,所以相比于之前的开发,我认为主要的难点与复杂点是吧MVVM架构学习明白。 + +## Q4.2 +我使用了AI,提示词:详细地讲解MVVM架构,并具体地讲解这个项目中是如何使用这一架构的 +此外,我还询问了几处报错(都是因为接口使用错误导致的),并进行了修复。 \ No newline at end of file diff --git a/docs/05-advanced/report.md b/docs/05-advanced/report.md new file mode 100644 index 0000000..e2c5d85 --- /dev/null +++ b/docs/05-advanced/report.md @@ -0,0 +1,50 @@ +# T5 高级功能项目介绍 + +## 程序如何编译运行 + +本次的程序由提供日志分析服务的 Agent 和 Avalonia 桌面客户端两部分组成,需要分别启动。先在一个终端中启动 Agent: + +```shell +dotnet run --project src/LogAnalyzerAgent/LogAnalyzerAgent.csproj -c Release --no-build --launch-profile Server +``` + +终端显示 `Now listening on: http://localhost:5000` 后,保留该进程,再打开另一个终端启动桌面客户端: + +```shell +dotnet run --project src/LogAnalyzerClient/LogAnalyzerClient.Desktop/LogAnalyzerClient.Desktop.csproj -c Release --no-build +``` + +客户端启动后,按以下步骤完成初始化: + +1. 点击菜单栏中的 `File -> Connect...`,输入 Agent 地址 `http://localhost:5000`。如果 Agent 运行在其他主机上,则填写该主机的实际地址和端口。 +2. 在 `Directory Path` 中填写 **Agent 所在机器上的** 日志目录,建议使用绝对路径。例如,要使用仓库自带的样例日志,可填写仓库中 `src/dataset` 的绝对路径。 +3. 点击 `Change Directory`。文件列表会自动刷新;也可以随时点击 `Refresh` 手动刷新。 +4. 设置 `DoP`(并行度),选中一个或多个日志文件后点击 `Analyze Selected`,也可以点击 `Analyze All` 分析目录中的全部日志。后续的结果筛选和拓扑显示都需要相应文件已经分析成功。 + +## 我实现了哪些功能?每个功能如何使用? + +### 1. 控件布局自适应 + +我为 Avalonia 客户端增加了宽、窄两套方案。窗口宽度大于 640 像素时,日志文件和分析结果左右排列。宽度不大于 640 像素时,目录输入框与操作按钮改为分行显示,日志文件和分析结果改为上下排列。 + +这避免了当窗口过窄时,文字相互挤压的问题,同时也让比较宽的窗口下也能保持美观。 + +### 2. 云服务调用拓扑推断与可视化 + +Agent 会从分析成功的日志中提取 `Call` 类型记录,根据产生日志的 Pod 名称推断源服务,并使用 `TargetService` 作为目标服务。客户端收到拓扑数据后会按层排列节点,并绘制服务节点、有向边和边上的调用次数。 + +我对调用图含有环的情况进行了测试,能够正常运行。 + +这也为下一个功能提供了基础,允许每一个服务统计其被调用次数。 + +### 3. 日志类型筛选与调用次数排序 + +我在 `Analysis Result` 页签中增加了日志类型筛选器,支持 `All`、`Call`、`Request` 和 `Internal` 四种选项。选择 `Call` 时,每条日志还会显示 `CallCount` 字段,其值表示当前文件中以同一个 `TargetService` 为目标的调用总数。此外,`Call count` 下拉框可以按该数值升序或降序排列;相同调用次数的记录再按原始行号排列,以保证结果稳定。 + +本次开发中,我在程序整体架构的设计上采用了AI的设计,主要是因为我仍不熟悉如何设计架构是最优的。 + +我的提示词是:根据guidance.md,设计T5.1.a.d的功能的具体架构,注意:你的架构应尽可能保持简单。 + +本次开发,我的主要心得是:程序的“服务端-客户端-中间层”架构。具体来说,通过这种方式来实现了代码的高可读性与可维护性。 + +本markdown的格式由AI调整。 \ No newline at end of file diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..25412e2 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -112,22 +112,115 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var _logfiles = analyzer.GetLogFiles(); + foreach (var file in _logfiles) + { + Console.WriteLine(file); + } + } + + private static int ReadDegreeOfParallelism() + { + while (true) + { + Console.WriteLine("Please input the degree of parallelism (0 means auto):"); + Console.Write(">>> "); + Console.Out.Flush(); + var str = Console.ReadLine(); + if (str is null) + { + return 0; + } + if (int.TryParse(str, out var degree) && degree >= 0) + { + return degree; + } + Console.WriteLine("Invalid input, please try again."); + } + } + + private static List ReadFileNames() + { + Console.WriteLine("Please input file names to analyze, separated by commas:"); + Console.Write(">>> "); + Console.Out.Flush(); + var str = Console.ReadLine(); + if (str is null) + { + return []; + } + return [.. str.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)]; } private static void AnalyzeFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var degreeOfParallelism = ReadDegreeOfParallelism(); + var fileNames = ReadFileNames(); + if (fileNames.Count == 0) + { + Console.WriteLine("No file names input."); + return; + } + + try + { + analyzer.AnalyzeFiles(degreeOfParallelism, fileNames); + Console.WriteLine("Analysis finished."); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {ex.Message}"); + } } private static void AnalyzeAll(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var degreeOfParallelism = ReadDegreeOfParallelism(); + try + { + analyzer.AnalyzeAll(degreeOfParallelism); + Console.WriteLine("Analysis finished."); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input the file name:"); + Console.Write(">>> "); + Console.Out.Flush(); + var fileName = Console.ReadLine(); + if (fileName is null) + { + return; + } + + if (!analyzer.TryGetAnalysisResult(fileName, out var result)) + { + Console.WriteLine($"File '{fileName}' not found."); + return; + } + + switch (result!.State) + { + case AnalysisState.NotAnalyzed: + Console.WriteLine($"File '{fileName}' has not been analyzed."); + break; + case AnalysisState.Succeeded: + var dumper = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var kvPairs = dumper.Dump(entry); + Console.WriteLine(string.Join(", ", kvPairs.Select(kv => $"{kv.Key}: {kv.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"Analysis of file '{fileName}' failed: {result.ErrorMessage}"); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..47ea9d1 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -138,10 +138,8 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } fileList = fileNameList.Select(fileName => _logFiles[fileName]).ToList(); - /* - * Set _isAnalyzing - */ - // TODO: T2.2 + _isAnalyzing = true; + Monitor.PulseAll(_syncRoot); } try @@ -150,11 +148,11 @@ 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; + Monitor.PulseAll(_syncRoot); + } } } @@ -165,12 +163,20 @@ 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"); + AnalysisResult? result; + if(_analysisResults.TryGetValue(file.Name, out result)) + { + if(result != null && result.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } + } + else + { + throw new InvalidOperationException($"File '{file.Name}' is not in the current directory or does not exist."); + } } + Monitor.PulseAll(_syncRoot); } if (logFilesToParse.Count == 0) @@ -180,27 +186,34 @@ 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]; for (int i = 0; i < degreeOfParallelism; i++) { 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)) + { + Name = threadName, + IsBackground = true + }; } - /* - * Wait for (join) all threads to end - */ - // TODO: T2.2 + foreach (var worker in workers) + { + worker.Start(); + } + + foreach (var worker in workers) + { + worker.Join(); + } } private void WorkerMain(int workerId, WorkQueue queue) @@ -212,20 +225,34 @@ private void WorkerMain(int workerId, WorkQueue queue) AnalysisResult result; try { - // Parse file - throw new NotImplementedException("TODO: T2.2"); + using var reader = file.OpenText(); + 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; + Monitor.PulseAll(_syncRoot); + } } } } diff --git a/src/LogAnalyzer/ServiceTopology.cs b/src/LogAnalyzer/ServiceTopology.cs new file mode 100644 index 0000000..e2fc9e6 --- /dev/null +++ b/src/LogAnalyzer/ServiceTopology.cs @@ -0,0 +1,17 @@ +using LogParser.Models; + +namespace LogAnalyzer; + +public sealed record ServiceTopology( + IReadOnlyList Nodes, + IReadOnlyList Edges); + +public sealed record ServiceNode(string Name); + +public sealed record ServiceEdge( + string SourceService, + string TargetService, + IReadOnlyList Calls) +{ + public int CallCount => Calls.Count; +} diff --git a/src/LogAnalyzer/ServiceTopologyBuilder.cs b/src/LogAnalyzer/ServiceTopologyBuilder.cs new file mode 100644 index 0000000..2666002 --- /dev/null +++ b/src/LogAnalyzer/ServiceTopologyBuilder.cs @@ -0,0 +1,53 @@ +using LogParser.Models; + +namespace LogAnalyzer; + +public static class ServiceTopologyBuilder +{ + public static ServiceTopology Build(IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + + var edges = entries + .OfType() + .Select(entry => new + { + Entry = entry, + SourceService = GetSourceService(entry.PodName), + TargetService = entry.TargetService, + }) + .GroupBy(item => (item.SourceService, item.TargetService)) + .OrderBy(group => group.Key.SourceService, StringComparer.Ordinal) + .ThenBy(group => group.Key.TargetService, StringComparer.Ordinal) + .Select(group => new ServiceEdge( + group.Key.SourceService, + group.Key.TargetService, + group.Select(item => item.Entry).ToArray())) + .ToArray(); + + var nodes = edges + .SelectMany(edge => new[] { edge.SourceService, edge.TargetService }) + .Distinct(StringComparer.Ordinal) + .OrderBy(name => name, StringComparer.Ordinal) + .Select(name => new ServiceNode(name)) + .ToArray(); + + return new ServiceTopology(nodes, edges); + } + + public static string GetSourceService(string podName) + { + ArgumentNullException.ThrowIfNull(podName); + + var separatorIndex = podName.LastIndexOf('-'); + if (separatorIndex <= 0 || separatorIndex == podName.Length - 1) + { + return podName; + } + + var suffix = podName.AsSpan(separatorIndex + 1); + return suffix.IndexOfAnyExceptInRange('0', '9') < 0 + ? podName[..separatorIndex] + : podName; + } +} diff --git a/src/LogAnalyzer/WorkQueue.cs b/src/LogAnalyzer/WorkQueue.cs index 23055a5..c565ed5 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,41 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock(_items){ + if (_isCompleted){ + throw new InvalidOperationException("Cannot enqueue a completed queue."); + } + _items.Enqueue(item); + Monitor.PulseAll(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + while(_items.Count == 0) + { + if (_isCompleted) + { + item = default; + return false; + } + Monitor.Wait(_items); + } + item = _items.Dequeue() ?? throw new InvalidOperationException("Queue is empty."); + Monitor.PulseAll(_items); + return true; + } } 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..3eff7fa 100644 --- a/src/LogAnalyzerAgent/Applications/AgentSession.cs +++ b/src/LogAnalyzerAgent/Applications/AgentSession.cs @@ -24,7 +24,7 @@ private static OperationStatusMessage CreateInternalErrorOperationStatus(Excepti { Success = false, Code = AgentErrorCode.InternalError, - Message = $"An error occurred while retrieving agent status: {ex.Message}", + Message = $"An internal error occurred: {ex.Message}", }; } @@ -38,6 +38,49 @@ private static OperationStatusMessage CreateNoErrorOperationStatus() }; } + private static OperationStatusMessage CreateErrorOperationStatus( + AgentErrorCode code, + string message) + { + return new OperationStatusMessage + { + Success = false, + Code = code, + Message = message, + }; + } + + private bool TryGetSucceededAnalysisResult( + string fileName, + out AnalysisResult? result, + out OperationStatusMessage status) + { + if (!_analyzer.TryGetAnalysisResult(fileName, out result) || result is null) + { + status = CreateErrorOperationStatus( + AgentErrorCode.FileNotFound, + $"File '{fileName}' was not found."); + return false; + } + + if (result.State != AnalysisState.Succeeded) + { + var stateMessage = result.State switch + { + AnalysisState.NotAnalyzed => "has not been analyzed yet", + AnalysisState.Failed => $"failed to analyze: {result.ErrorMessage ?? "Unknown error."}", + _ => $"has unsupported analysis state '{result.State}'", + }; + status = CreateErrorOperationStatus( + AgentErrorCode.InvalidOperation, + $"File '{fileName}' {stateMessage}."); + return false; + } + + status = CreateNoErrorOperationStatus(); + return true; + } + public Task Ping(Empty empty, CancellationToken cancellationToken) { return Task.FromResult(new Empty()); @@ -79,22 +122,222 @@ 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 + { + _analyzer.ChangeDirectory(request.DirectoryPath); + response.Status = CreateNoErrorOperationStatus(); + response.CurrentDirectory = _analyzer.CurrentDirectory ?? ""; + response.FileNames.AddRange(_analyzer.GetLogFiles()); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while changing directory."); + } + return Task.FromResult(response); } public Task AnalyzeAll(AnalyzeAllRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var response = new AnalyzeAllResponse(); + try + { + _analyzer.AnalyzeAll(request.DegreeOfParallelism); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while analyzing all log files."); + } + return Task.FromResult(response); } public Task AnalyzeFiles(AnalyzeFilesRequest request, CancellationToken cancellationToken) { - throw new NotImplementedException("TODO: T3.1"); + var response = new AnalyzeFilesResponse(); + try + { + _analyzer.AnalyzeFiles(request.DegreeOfParallelism, request.FileNames); + response.Status = CreateNoErrorOperationStatus(); + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _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 = new OperationStatusMessage + { + Success = false, + Code = AgentErrorCode.FileNotFound, + Message = $"File '{request.FileName}' was not found." + } + }); + } + else + { + var header = new AnalysisResultHeaderMessage + { + FileName = result.FileName, + FullName = result.FullName, + State = GrpcTypeConverter.ConvertToGrpc(result.State), + WorkerId = result.WorkerId + }; + + if (result.ErrorMessage is not null) + { + header.ErrorMessage = result.ErrorMessage; + } + + responses.Add(new GetAnalysisResultResponse + { + Status = CreateNoErrorOperationStatus(), + Header = header + }); + + if (result.State == AnalysisState.Succeeded) + { + foreach (var entry in result.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + responses.Add(new GetAnalysisResultResponse + { + Status = CreateNoErrorOperationStatus(), + LogEntry = GrpcTypeConverter.ConvertToGrpc(entry) + }); + } + } + } + } + catch (Exception ex) + { + responses.Add(new GetAnalysisResultResponse + { + Status = CreateInternalErrorOperationStatus(ex) + }); + _logger.LogError(ex, "An error occurred while retrieving analysis result."); + } + return responses; + } + + public Task GetServiceTopology( + GetServiceTopologyRequest request, + CancellationToken cancellationToken) + { + var response = new GetServiceTopologyResponse(); + try + { + if (!TryGetSucceededAnalysisResult(request.FileName, out var result, out var status)) + { + response.Status = status; + return Task.FromResult(response); + } + + var topology = ServiceTopologyBuilder.Build(result!.Entries); + foreach (var node in topology.Nodes) + { + cancellationToken.ThrowIfCancellationRequested(); + response.Nodes.Add(new ServiceNodeMessage + { + Name = node.Name, + }); + } + + foreach (var edge in topology.Edges) + { + cancellationToken.ThrowIfCancellationRequested(); + response.Edges.Add(new ServiceEdgeMessage + { + SourceService = edge.SourceService, + TargetService = edge.TargetService, + CallCount = edge.CallCount, + }); + } + + response.Status = CreateNoErrorOperationStatus(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while retrieving service topology."); + } + + return Task.FromResult(response); + } + + public Task GetTopologyEdgeLogs( + GetTopologyEdgeLogsRequest request, + CancellationToken cancellationToken) + { + var response = new GetTopologyEdgeLogsResponse(); + try + { + if (string.IsNullOrWhiteSpace(request.SourceService) || + string.IsNullOrWhiteSpace(request.TargetService)) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + "Source service and target service must not be empty."); + return Task.FromResult(response); + } + + if (!TryGetSucceededAnalysisResult(request.FileName, out var result, out var status)) + { + response.Status = status; + return Task.FromResult(response); + } + + var topology = ServiceTopologyBuilder.Build(result!.Entries); + var edge = topology.Edges.FirstOrDefault(candidate => + string.Equals(candidate.SourceService, request.SourceService, StringComparison.Ordinal) && + string.Equals(candidate.TargetService, request.TargetService, StringComparison.Ordinal)); + + if (edge is null) + { + response.Status = CreateErrorOperationStatus( + AgentErrorCode.InvalidArgument, + $"Edge '{request.SourceService}' -> '{request.TargetService}' does not exist in file '{request.FileName}'."); + return Task.FromResult(response); + } + + foreach (var entry in edge.Calls) + { + cancellationToken.ThrowIfCancellationRequested(); + response.Entries.Add(GrpcTypeConverter.ConvertToGrpc(entry).CallLogEntry); + } + response.Status = CreateNoErrorOperationStatus(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + response.Status = CreateInternalErrorOperationStatus(ex); + _logger.LogError(ex, "An error occurred while retrieving topology edge logs."); + } + + return Task.FromResult(response); } } } diff --git a/src/LogAnalyzerAgent/Services/AgentService.cs b/src/LogAnalyzerAgent/Services/AgentService.cs index 591dcad..53e4960 100644 --- a/src/LogAnalyzerAgent/Services/AgentService.cs +++ b/src/LogAnalyzerAgent/Services/AgentService.cs @@ -29,27 +29,47 @@ 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); + } + } + + public override Task GetServiceTopology( + GetServiceTopologyRequest request, + ServerCallContext context) + { + return _session.GetServiceTopology(request, context.CancellationToken); + } + + public override Task GetTopologyEdgeLogs( + GetTopologyEdgeLogsRequest request, + ServerCallContext context) + { + return _session.GetTopologyEdgeLogs(request, context.CancellationToken); } } } diff --git a/src/LogAnalyzerClient/LogAnalyzerClient/Controls/TopologyGraphControl.cs b/src/LogAnalyzerClient/LogAnalyzerClient/Controls/TopologyGraphControl.cs new file mode 100644 index 0000000..145a1c4 --- /dev/null +++ b/src/LogAnalyzerClient/LogAnalyzerClient/Controls/TopologyGraphControl.cs @@ -0,0 +1,253 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using LogAnalyzerClient.Models; +using LogAnalyzerClient.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; +using ShapePath = Avalonia.Controls.Shapes.Path; + +namespace LogAnalyzerClient.Controls; + +public sealed class TopologyGraphControl : Canvas +{ + public static readonly StyledProperty?> NodesProperty = + AvaloniaProperty.Register?>(nameof(Nodes)); + + public static readonly StyledProperty?> EdgesProperty = + AvaloniaProperty.Register?>(nameof(Edges)); + + public static readonly StyledProperty EdgeSelectedCommandProperty = + AvaloniaProperty.Register(nameof(EdgeSelectedCommand)); + + public IEnumerable? Nodes + { + get => GetValue(NodesProperty); + set => SetValue(NodesProperty, value); + } + + public IEnumerable? Edges + { + get => GetValue(EdgesProperty); + set => SetValue(EdgesProperty, value); + } + + public ICommand? EdgeSelectedCommand + { + get => GetValue(EdgeSelectedCommandProperty); + set => SetValue(EdgeSelectedCommandProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == NodesProperty || + change.Property == EdgesProperty || + change.Property == EdgeSelectedCommandProperty) + { + RebuildGraph(); + } + } + + private void RebuildGraph() + { + Children.Clear(); + + var nodes = Nodes?.ToArray() ?? []; + var edges = Edges?.ToArray() ?? []; + Width = Math.Max(500, nodes.Select(node => node.X).DefaultIfEmpty(0).Max() + + TopologyLayout.NodeWidth + TopologyLayout.Margin); + Height = Math.Max(260, nodes.Select(node => node.Y).DefaultIfEmpty(0).Max() + + TopologyLayout.NodeHeight + TopologyLayout.Margin); + + if (nodes.Length == 0) + { + Children.Add(new TextBlock + { + Text = "No service calls were found in this file.", + Opacity = 0.72, + }); + SetLeft(Children[0], TopologyLayout.Margin); + SetTop(Children[0], TopologyLayout.Margin); + return; + } + + var nodeByName = nodes.ToDictionary(node => node.Name, StringComparer.Ordinal); + var badges = new List