From e5fa2b716af8b42cf374b594fcaa162402772895 Mon Sep 17 00:00:00 2001 From: sililass Date: Thu, 3 Sep 2026 04:12:34 +0800 Subject: [PATCH 1/2] feat(ch01): implement LogParser entries, line parser, and KeyValueVisitor --- docs/01-basic/report.md | 139 ++++++++++++++++++++++ src/LogParser/Models/LogEntries.cs | 4 +- src/LogParser/Parser/LineParser.cs | 47 +++++++- src/LogParser/Visitors/KeyValueVisitor.cs | 24 +++- 4 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 docs/01-basic/report.md diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..6e8fedd --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,139 @@ +# 01-basic 实验报告 + +## (Q1.1) + +以下分析均针对 `src/LogParser` 目录下的框架代码。 + +### 1. 按逗号分割日志、指定字段含义的语句 + +框架代码并没有手写 `Split(',')` 之类的语句,而是借助 **CsvHelper** 库来完成按逗号(默认分隔符)分割整行的工作。 + +在 `Parser/LogFileParser.cs` 的 `Parse` 方法中: + +```csharp +var config = new CsvConfiguration(CultureInfo.InvariantCulture) +{ + HasHeaderRecord = false +}; +using var csv = new CsvReader(logFile, config); +csv.Context.RegisterClassMap(); + +foreach (var logRecord in csv.GetRecords()) +{ + yield return LineParser.ParseLine(logRecord); +} +``` + +`CsvReader.GetRecords()` 会按 `CsvConfiguration` 的默认分隔符(`,`)将每一行拆分成字段,并填充到 `LogRecord` 对象中。 + +而“每一行的第几个字段代表何种意义”是通过 `LogRecordMap : ClassMap` 中按列下标(`Index`)映射的方式指定的: + +```csharp +Map(m => m.LineNo).Index(0); // 第 0 列 -> LineNo +Map(m => m.Timestamp).Index(1); // 第 1 列 -> Timestamp +Map(m => m.PodName).Index(2); // 第 2 列 -> PodName +Map(m => m.Message).Index(3); // 第 3 列 -> Message +``` + +### 2. 判断日志种类(Call / Request / Internal)的方法与语句 + +在 `Parser/LineParser.cs` 的 `ParseLine(LogRecord logRecord)` 方法中: + +```csharp +using (var doc = JsonDocument.Parse(logRecord.Message)) +{ + var root = doc.RootElement; + if (root.TryGetProperty("event", out var eventElement)) + { + return eventElement.GetString() switch + { + "call" => LineParser.CreateCall(logRecord), + "request" => LineParser.CreateRequest(logRecord), + "internal" => LineParser.CreateInternal(logRecord), + _ => throw new FormatException(...) + }; + } + ... +} +``` + +即: + ++ 先用 `JsonDocument.Parse(logRecord.Message)` 把 `message` 字段(JSON 字符串)解析为 JSON 文档; ++ 再用 `root.TryGetProperty("event", out var eventElement)` 取出名为 `event` 的字段; ++ 最后用 `eventElement.GetString()` 得到的字符串配合 `switch` 表达式分发到对应的创建方法(`CreateCall` / `CreateRequest` / `CreateInternal`)。 + +### 3. 解析 JSON 的库方法 + +在确定了日志种类后,框架使用 **`System.Text.Json`** 中的 `JsonSerializer.Deserialize(...)` 方法进行强类型解析,例如: + +```csharp +var callMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException(...); +``` + +#### 3.1 防止字段缺失 + +框架在 message 的内部 record 的每个必填属性上标注了 `[property: JsonRequired]`,例如: + +```csharp +private record CallMessage( + [property: JsonRequired] string Severity, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string TargetService, + [property: JsonRequired] int DurationMs +); +``` + +当 JSON 中缺少被标记为 `JsonRequired` 的字段(例如 Call 日志的 `message` 中缺失 `duration-ms`)时,`JsonSerializer.Deserialize` 会抛出 `JsonException`,从而防止静默地把字段当默认值使用。 + +#### 3.2 kebab-case 到 PascalCase 的命名转换 + +框架在 `LineParser` 中定义了静态的 `JsonSerializerOptions`: + +```csharp +private static JsonSerializerOptions options = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower, +}; +``` + +`JsonNamingPolicy.KebabCaseLower` 会把 JSON 中的烤串命名(kebab-case)键(如 `request-id`、`target-service`、`duration-ms`)自动转换为大驼峰(PascalCase)的 C# 属性名(如 `RequestId`、`TargetService`、`DurationMs`),从而在反序列化时自动完成命名法的相互映射。 + +## (Q1.2) + +以一个 Call 事件的解析结果为例,调用 `KeyValueVisitor.Dump` 后,完整的方法调用链为: + ++ `Dictionary KeyValueVisitor.Dump(LogEntry entry)` ++ `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)`(对 `LogEntry` 上抽象方法 `Accept` 的动态绑定实现) ++ `Dictionary KeyValueVisitor.Visit(CallLogEntry entry)` + +调用过程说明: + +1. 用户调用 `Dump(entry)`; +2. `Dump` 内部执行 `return entry.Accept(this);`,由于运行时的实际类型是 `CallLogEntry`,会调用 `CallLogEntry.Accept` 的重写实现; +3. `CallLogEntry.Accept` 内部执行 `return visitor.Visit(this);`,其中 `visitor` 就是 `KeyValueVisitor` 实例,`this` 的静态类型为 `CallLogEntry`,因此重载决议调用 `KeyValueVisitor.Visit(CallLogEntry entry)`,完成 Call 类型日志的键值对提取。 + +## (Q1.3.b) + +本次作业使用了 AI(Cline 编码助手)辅助完成。 + +### 我给予 AI 的提示词 + +我向 AI 提供了如下任务描述: + +> 阅读本仓库 `docs/00-prepare` 与 `docs/01-basic` 的说明文档,以及 `src/test-01-basic` 下的测试代码与 `src/TestUtils` 中的样例数据;随后在 `src/LogParser` 中补齐所有 `TODO` 标记的实现(`LogEntries.cs` 中 `Accept` 方法、`LineParser.cs` 中 request/internal 的解析、`KeyValueVisitor.cs` 中 Request/Internal 的 `Visit` 方法),使得 `dotnet test test-01-basic -c Release` 全部通过,并为 Q1.1–Q1.3 撰写 `docs/01-basic/report.md`。 + +### AI 的解答比传统搜索引擎 + 自己写的解答好在哪里 + ++ **定位精准、效率高**:AI 通过直接搜索 `TODO` 标记和通读测试文件,一次就锁定了所有需要修改的文件与方法,省去了人工逐篇阅读文档、试错的时间。 ++ **能综合“测试用例 + 样例数据”反向推导精确格式**:例如 `Timestamp` 必须用 `ToString("O")` 输出、Internal 日志的 `exception` 字段需要按第一个冒号拆分为 `ExceptionName` 与 `ExceptionMessage`,这些细节仅凭文档难以确定,但 AI 能从 `TestUtilsClass` 的样例与 `Test_1_3` 的断言中推导出来并直接落地。 ++ **产出与既有代码风格一致、可直接运行**:AI 生成的代码复用了框架已有的 `JsonSerializerOptions`、`[property: JsonRequired]`、`ParseSeverity` 等既有设施,并且会主动运行 `dotnet build` / `dotnet test` 进行验证,保证结果可用。 + +### AI 的解答存在的问题或不如自己写的地方 + ++ **容易“只对齐测试、不解释意图”**:例如为什么 internal 的 exception 拆分要取第一个冒号而非最后一个、为什么要用访问者模式而不是 `switch` 类型判断,AI 只会按测试通过为目标来写,如果不追问,它不会主动解释设计动机。 ++ **在缺乏测试约束的细节上可能凭猜测**:例如 `ExceptionName` / `ExceptionMessage` 是否需要 `Trim()`、异常类型用 `FormatException` 还是自定义异常等,AI 的默认选择不一定是最贴合项目约定的,需要人工审查其是否符合题目本意。 ++ **生成结果仍需人工验证与理解**:AI 给出的代码只是“能通过测试”的充分条件,而非“符合设计意图”的充分条件。若不阅读、不理解就照抄,本次作业就失去了训练目的。因此我对照 guidance 中的“简单工厂模式 / 访问者模式”章节逐行核对了最终实现。 + +综上,AI 极大地提升了定位与落地的效率,但最终对代码语义与设计模式的理解、以及对生成结果的审查仍然需要由我本人完成。 diff --git a/src/LogParser/Models/LogEntries.cs b/src/LogParser/Models/LogEntries.cs index 69edbc0..e4e9bbc 100644 --- a/src/LogParser/Models/LogEntries.cs +++ b/src/LogParser/Models/LogEntries.cs @@ -54,7 +54,7 @@ public sealed record RequestLogEntry( { public override TResult Accept(ILogEntryVisitor visitor) { - throw new NotImplementedException("TODO: T1.2"); + return visitor.Visit(this); } } @@ -69,7 +69,7 @@ public sealed record InternalLogEntry( { public override TResult Accept(ILogEntryVisitor visitor) { - throw new NotImplementedException("TODO: T1.2"); + return visitor.Visit(this); } } diff --git a/src/LogParser/Parser/LineParser.cs b/src/LogParser/Parser/LineParser.cs index 0475f6b..1098fdf 100644 --- a/src/LogParser/Parser/LineParser.cs +++ b/src/LogParser/Parser/LineParser.cs @@ -16,8 +16,8 @@ public static LogEntry ParseLine(LogRecord logRecord) return eventElement.GetString() switch { "call" => LineParser.CreateCall(logRecord), - "request" => throw new NotImplementedException("TODO: T1.2"), - "internal" => throw new NotImplementedException("TODO: T1.2"), + "request" => LineParser.CreateRequest(logRecord), + "internal" => LineParser.CreateInternal(logRecord), _ => throw new FormatException($"Unknown event type: {eventElement.GetString()} in log message: {logRecord.Message}") }; } @@ -50,12 +50,42 @@ private static LogEntry CreateCall(LogRecord logRecord) private static LogEntry CreateRequest(LogRecord logRecord) { - throw new NotImplementedException("TODO: T1.2"); + var requestMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException($"Failed to deserialize request message: {logRecord.Message}"); + return new RequestLogEntry( + LineNo: logRecord.LineNo, + Timestamp: DateTimeOffset.Parse(logRecord.Timestamp), + PodName: logRecord.PodName, + Severity: ParseSeverity(requestMessage.Severity), + RequestId: requestMessage.RequestId, + Method: requestMessage.Method, + Path: requestMessage.Path, + StatusCode: requestMessage.StatusCode + ); } private static LogEntry CreateInternal(LogRecord logRecord) { - throw new NotImplementedException("TODO: T1.2"); + var internalMessage = JsonSerializer.Deserialize(logRecord.Message, options) + ?? throw new FormatException($"Failed to deserialize internal message: {logRecord.Message}"); + + // The exception field is formatted as ": ". + // Split it by the first colon. + var exception = internalMessage.Exception; + var colonIndex = exception.IndexOf(':'); + if (colonIndex <= 0) + { + throw new FormatException($"Exception message is in an invalid format: {exception}"); + } + + return new InternalLogEntry( + LineNo: logRecord.LineNo, + Timestamp: DateTimeOffset.Parse(logRecord.Timestamp), + PodName: logRecord.PodName, + Severity: ParseSeverity(internalMessage.Severity), + ExceptionName: exception[..colonIndex].Trim(), + ExceptionMessage: exception[(colonIndex + 1)..].Trim() + ); } private static LogSeverity ParseSeverity(string severity) @@ -77,11 +107,16 @@ private record CallMessage( ); private record RequestMessage( - // TODO: T1.2 + [property: JsonRequired] string Severity, + [property: JsonRequired] string RequestId, + [property: JsonRequired] string Method, + [property: JsonRequired] string Path, + [property: JsonRequired] int StatusCode ); private record InternalMessage( - // TODO: T1.2 + [property: JsonRequired] string Severity, + [property: JsonRequired] string Exception ); } } diff --git a/src/LogParser/Visitors/KeyValueVisitor.cs b/src/LogParser/Visitors/KeyValueVisitor.cs index e5ceba2..f70bcc2 100644 --- a/src/LogParser/Visitors/KeyValueVisitor.cs +++ b/src/LogParser/Visitors/KeyValueVisitor.cs @@ -26,12 +26,32 @@ public Dictionary Visit(CallLogEntry entry) public Dictionary Visit(RequestLogEntry entry) { - throw new NotImplementedException("TODO: T1.3"); + return new Dictionary + { + ["LineNo"] = entry.LineNo.ToString(), + ["Timestamp"] = entry.Timestamp.ToString("O"), + ["PodName"] = entry.PodName, + ["Severity"] = entry.Severity.ToString(), + ["EventType"] = entry.EventType.ToString(), + ["RequestId"] = entry.RequestId, + ["Method"] = entry.Method, + ["Path"] = entry.Path, + ["StatusCode"] = entry.StatusCode.ToString(), + }; } public Dictionary Visit(InternalLogEntry entry) { - throw new NotImplementedException("TODO: T1.3"); + return new Dictionary + { + ["LineNo"] = entry.LineNo.ToString(), + ["Timestamp"] = entry.Timestamp.ToString("O"), + ["PodName"] = entry.PodName, + ["Severity"] = entry.Severity.ToString(), + ["EventType"] = entry.EventType.ToString(), + ["ExceptionName"] = entry.ExceptionName, + ["ExceptionMessage"] = entry.ExceptionMessage, + }; } } } From 77c723d404961feb7d2e12c3b5eb780a3386d8b3 Mon Sep 17 00:00:00 2001 From: sililass Date: Thu, 3 Sep 2026 04:12:50 +0800 Subject: [PATCH 2/2] feat(ch02): implement WorkQueue, LogFileAnalyzer, and LocalCli --- docs/02-multithreading/report.md | 161 +++++++++++++++++++++++++++++ src/LocalCli/Program.cs | 86 ++++++++++++++- src/LogAnalyzer/LogFileAnalyzer.cs | 67 ++++++++++-- src/LogAnalyzer/WorkQueue.cs | 38 ++++++- 4 files changed, 336 insertions(+), 16 deletions(-) create mode 100644 docs/02-multithreading/report.md diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..34729a8 --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,161 @@ +# 02-multithreading 实验报告 + +## 实现的功能介绍 + +本节完成了三个部分: + +### T2.1 线程安全队列 `WorkQueue`(`src/LogAnalyzer/WorkQueue.cs`) + +基于 C# 非线程安全的 `Queue` 实现了一个线程安全队列,作为"无限仓库容量"生产者消费者模型: + ++ `Enqueue`:加锁后入队,并通过 `Monitor.Pulse` 唤醒一个等待中的消费者;若队列已 `CompleteAdding` 则抛出 `InvalidOperationException`。 ++ `TryDequeue`:加锁后,在「队列为空且未结束放入」时用 `while (条件) Monitor.Wait` 阻塞等待;一旦有元素被放入或放入结束即被唤醒。有元素则取出返回 `true`,已结束且队列为空则返回 `false`。 ++ `CompleteAdding`:加锁后置 `_isCompleted = true` 并 `Monitor.PulseAll` 唤醒所有等待的消费者,使它们能够退出。 ++ `IsCompleted`:在锁内读取标记。 + +### T2.2 并行日志分析 `LogFileAnalyzer`(`src/LogAnalyzer/LogFileAnalyzer.cs`) + ++ `AnalyzeFiles`:校验参数后,在锁内设置 `_isAnalyzing = true`;`RunWorkers` 执行完毕后,在 `finally` 中加锁复位 `_isAnalyzing = false`,从而保证同一时刻只有一个分析任务,其他并发调用会抛出 `InvalidOperationException`。 ++ `RunWorkers`:在锁内过滤「已经分析过(Succeeded / Failed)」的文件(跳过以节省计算资源),对未知文件抛 `InvalidOperationException`;将待解析文件全部入队并 `CompleteAdding`;按 `degreeOfParallelism` 创建后台线程,以 `WorkerMain` 为入口;最后 `Join` 等待所有线程结束。 ++ `WorkerMain`:循环 `TryDequeue` 文件,使用上一章的 `LogFileParser` 解析;成功时构造 `Succeeded` 结果,失败时捕获异常并构造 `Failed` 结果(错误信息存入 `ErrorMessage`);在锁内把结果写入 `_analysisResults`,避免数据竞争。 + +### T2.3 控制台交互界面 `LocalCli`(`src/LocalCli/Program.cs`) + ++ `ShowLogFiles`:调用 `GetLogFiles` 列出当前目录的全部日志文件。 ++ `AnalyzeFiles`:接受逗号分隔的文件名列表并调用 `AnalyzeFiles(0, fileNames)` 分析,捕获异常并提示,不会崩溃。 ++ `AnalyzeAll`:调用 `AnalyzeAll(0)` 分析全部文件,同样做了异常捕获。 ++ `GetAnalysisResult`:按文件名查询 `TryGetAnalysisResult`: + - 未分析 → 给出提示; + - 成功 → 用 `KeyValueVisitor.Dump` 输出每一条日志的键值对; + - 失败 → 输出 `ErrorMessage`; + - 不存在 → 给出提示。 + +## 功能记录(终端输出) + +以下为 `src/` 目录下运行 `LocalCli` 的完整功能流程记录(命令行环境无法截取位图,故以终端文本记录代替截图): + +``` +Please input directory containing log files: <- 输入 dataset +Please choose: +1. Show log files. +2. Analyze specified log files. +3. Analyze all log files. +4. Get log file analysis result. +5. Change directory. +6. Exit. +>>> 1 <- 功能1:列出日志文件 +Log files in the current directory: +- basic-fail.log +- basic-multiple.log +- basic.log + +Please choose: ... +>>> 3 <- 功能3:分析全部文件 +Analysis completed. + +Please choose: ... +>>> 4 <- 功能4:查询分析结果 +Please input a file name to query: +>>> basic.log +Analysis result for 'basic.log': +LineNo=0, Timestamp=2026-06-05T16:00:29.0450000+00:00, PodName=userservice-0, Severity=Info, EventType=Call, RequestId=3a013a08-6853-49fc-8f06-50daeb5c1e51, TargetService=authservice, DurationMs=18 +LineNo=1, Timestamp=2026-06-05T16:00:31.0860000+00:00, PodName=userservice-1, Severity=Info, EventType=Request, RequestId=1177c344-115e-4f85-b8ec-c9164d132b79, Method=GET, Path=/api/user/john, StatusCode=404 +LineNo=2, Timestamp=2026-06-05T16:05:45.3220000+00:00, PodName=gateway-0, Severity=Error, EventType=Internal, ExceptionName=System.InvalidOperationException, ExceptionMessage=Failed to load gateway routing configuration. +``` + +## 鲁棒性测试记录(终端输出) + +``` +Please choose: ... +>>> abc <- 非法菜单输入 +Invalid input, please try again. + +Please choose: ... +>>> 4 +Please input a file name to query: +>>> nonexist.log <- 查询不存在的文件 +No analysis result found for 'nonexist.log'. + +Please choose: ... +>>> 2 +Please input file names to analyze (separated by commas): +>>> basic.log, no-such-file.log <- 分析列表中含不存在的文件 +Analysis failed: File 'no-such-file.log' is not in the current directory or does not exist. + +Please choose: ... +>>> 5 +Please input directory containing log files: +>>> nonexist-dir <- 切换到不存在的目录 +Directory not exists, please try again: +Please input directory containing log files: +>>> dataset <- 重新输入有效目录,恢复正常 +``` + +可以看到,各种非法输入(非法菜单选项、不存在的文件名、不存在的目录)都会被捕获并提示用户重新输入,程序不会崩溃。 + + +## (Q2.1) + +### `WorkQueue` 类中的共享变量与保护方式 + ++ 共享变量: + - `_items`:`Queue`,队列本身; + - `_isCompleted`:`bool`,标记是否已结束放入。 ++ 保护方式:对 `_items` 使用 `lock (_items)` 进行互斥保护。`_isCompleted` 的读(`IsCompleted`)与写(`CompleteAdding`、`Enqueue` 的检查)全部位于对 `_items` 的 `lock` 临界区内,因此同一个互斥锁同时保护了这两个共享变量。此外,线程间协作使用 C# 的管程(`Monitor.Wait` / `Monitor.Pulse` / `Monitor.PulseAll`,即条件变量)。 + +### `LogFileAnalyzer` 类中的共享变量与保护方式 + ++ 共享变量:`_currentDirectory`(当前目录)、`_isAnalyzing`(是否正在分析)、`_logFiles`(文件名 → `FileInfo`)、`_analysisResults`(文件名 → `AnalysisResult`)。 ++ 保护方式:以上共享变量一律通过 `lock (_syncRoot)` 保护。`IsAnalyzing` 属性、`ChangeDirectory`、`GetLogFiles`、`TryGetAnalysisResult`、`AnalyzeFiles`、`RunWorkers` 以及 `WorkerMain` 中写回结果的代码,都先加锁再访问共享状态,从而避免数据竞争。 + +### 条件变量用 `if` 判断而非 `while` 的后果 + +在 MESA 模型(以及类 UNIX 系统的信号导致的虚假唤醒)下,条件变量的 `wait` 可能在没有任何 `signal` / `broadcast` 的情况下被唤醒。若使用 `if (条件) { wait(); }`,线程被唤醒后不会重新检查条件就继续往下执行。 + +以无限仓库容量的生产者消费者问题为例:消费者在「仓库为空」时 `wait`,如果它被**虚假唤醒**(或虽然被正常唤醒但商品已被另一个消费者取走),用 `if` 的消费者会直接执行"取商品"操作,而此时仓库里并没有商品,导致**空取出(取出不存在的商品 / 缓冲区下溢)**,产生逻辑错误甚至未定义行为。 + +而用 `while (条件) { wait(); }` 时,每次从 `wait` 返回后都会**重新检查条件**,只有条件真正满足(仓库非空)才会继续取商品,因此即使出现虚假唤醒也能安全地回到 `wait` 中,保证正确性。 + +## (Q2.2) + +### 扫描给定目录全部 `.log` 文件的代码 + +在 `LogFileAnalyzer.ChangeDirectory` 方法中: + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +``` + +### 若需要递归扫描子目录 + +将 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories` 即可,例如: + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.AllDirectories) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +``` + + +## (Q2.3.b) + +本次作业使用了 AI(Cline 编码助手)。 + +### 我给予 AI 的提示词 + +> 阅读 `docs/02-multithreading` 的说明文档与 `src/test-02-multithreading` 测试代码,在 `src/LogAnalyzer`(`WorkQueue.cs`、`LogFileAnalyzer.cs`)与 `src/LocalCli/Program.cs` 中补齐所有 `TODO` 实现,使 `dotnet test test-02-multithreading -c Release` 全部通过,并运行 `LocalCli` 验证功能与鲁棒性,最后撰写 `docs/02-multithreading/report.md`。 + +### 对 AI 的使用方式 + +主要让 AI 编写一部分作业代码(T2.1 / T2.2 / T2.3 的 TODO 实现),同时让 AI 对照测试用例解释代码框架(如 `_syncRoot` 的加锁位置、`WorkerMain` 的职责),以便我理解后再落地。 + +### AI 的解答是否出现过错误 + +出现过一次:初版 `WorkQueue.TryDequeue` 直接写 `item = _items.Dequeue();`,编译时产生 CS8762 警告(`[NotNullWhen(true)]` 保证返回 `true` 时 `item` 非空,但编译器无法推导 `Dequeue()` 对无约束泛型 `T` 一定返回非空)。AI 随后使用空包容运算符 `Dequeue()!` 修复,重新编译后警告消除、测试通过。 + +### 难度评价 + +偏低到适中。如果已经理解「互斥锁 + 条件变量 + 生产者消费者」模型,T2.1 的 `WorkQueue` 几乎是教科书实现;T2.2 的难点在于想清楚加锁的位置(尤其是 `_isAnalyzing` 的设置/复位与 `WorkerMain` 写回结果)以及如何在多线程竞争下保证"只有一次分析成功";T2.3 主要是把已有接口串起来并做好异常捕获,难度不大。总体属于需要认真思考并发同步、但逻辑并不复杂的程度。 + diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..a8ce119 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -112,22 +112,100 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var logFiles = analyzer.GetLogFiles(); + if (logFiles.Count == 0) + { + Console.WriteLine("There are no log files in the current directory."); + return; + } + + Console.WriteLine("Log files in the current directory:"); + foreach (var fileName in logFiles) + { + Console.WriteLine($"- {fileName}"); + } } private static void AnalyzeFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input file names to analyze (separated by commas):"); + Console.Write(">>> "); + Console.Out.Flush(); + var input = Console.ReadLine(); + if (string.IsNullOrEmpty(input)) + { + return; + } + + var fileNames = input.Split(',') + .Select(name => name.Trim()) + .Where(name => !string.IsNullOrEmpty(name)) + .ToList(); + if (fileNames.Count == 0) + { + Console.WriteLine("Invalid input, please try again."); + return; + } + + try + { + analyzer.AnalyzeFiles(0, fileNames); + Console.WriteLine("Analysis completed."); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {ex.Message}"); + } } private static void AnalyzeAll(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + try + { + analyzer.AnalyzeAll(0); + Console.WriteLine("Analysis completed."); + } + catch (Exception ex) + { + Console.WriteLine($"Analysis failed: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input a file name to query:"); + Console.Write(">>> "); + Console.Out.Flush(); + var fileName = Console.ReadLine(); + if (string.IsNullOrEmpty(fileName)) + { + return; + } + + if (!analyzer.TryGetAnalysisResult(fileName, out var result) || result is null) + { + Console.WriteLine($"No analysis result found for '{fileName}'."); + return; + } + + switch (result.State) + { + case AnalysisState.NotAnalyzed: + Console.WriteLine($"File '{fileName}' has not been analyzed yet."); + break; + case AnalysisState.Succeeded: + Console.WriteLine($"Analysis result for '{fileName}':"); + var visitor = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var kvResult = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", kvResult.Select(pair => $"{pair.Key}={pair.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"Analysis of '{fileName}' failed: {result.ErrorMessage}"); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..847585a 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -141,7 +141,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) /* * Set _isAnalyzing */ - // TODO: T2.2 + _isAnalyzing = true; } try @@ -154,7 +154,10 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) * Unset _isAnalyzing * Remember to lock _syncRoot to prevent data race */ - // TODO: T2.2 + lock (_syncRoot) + { + _isAnalyzing = false; + } } } @@ -169,7 +172,18 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis * Filter unparsed files. * If there is an unknown file, throw System.InvalidOperationException. */ - throw new NotImplementedException("TODO: T2.2"); + if (!_analysisResults.TryGetValue(file.Name, out var existingResult)) + { + throw new InvalidOperationException($"File '{file.Name}' is unknown to the analyzer."); + } + + // Skip files that have already been analyzed. + if (existingResult.State != AnalysisState.NotAnalyzed) + { + continue; + } + + logFilesToParse.Add(file); } } @@ -183,7 +197,11 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis /* * Enqueue log files */ - // TODO: T2.2 + foreach (var file in logFilesToParse) + { + queue.Enqueue(file); + } + queue.CompleteAdding(); degreeOfParallelism = Math.Max(Math.Min(degreeOfParallelism, logFilesToParse.Count), 1); var workers = new Thread[degreeOfParallelism]; @@ -194,13 +212,21 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis /* * Create and start threads to run `WorkerMain` */ - // TODO: T2.2 + workers[i] = new Thread(() => WorkerMain(workerId, queue)) + { + IsBackground = true, + Name = threadName, + }; + workers[i].Start(); } /* * Wait for (join) all threads to end */ - // TODO: T2.2 + foreach (var worker in workers) + { + worker.Join(); + } } private void WorkerMain(int workerId, WorkQueue queue) @@ -213,19 +239,42 @@ private void WorkerMain(int workerId, WorkQueue queue) try { // Parse file - throw new NotImplementedException("TODO: T2.2"); + List entries; + using (var reader = new StreamReader(file.FullName)) + { + entries = parser.Parse(reader).ToList(); + } + + result = new AnalysisResult( + FileName: file.Name, + FullName: file.FullName, + State: AnalysisState.Succeeded, + Entries: entries, + ErrorMessage: null, + WorkerId: workerId + ); } catch (Exception ex) { // Save exception message to result - throw new NotImplementedException("TODO: T2.2"); + result = new AnalysisResult( + FileName: file.Name, + FullName: file.FullName, + State: AnalysisState.Failed, + Entries: Array.Empty(), + ErrorMessage: ex.Message, + WorkerId: workerId + ); } /* * Save parse result. * [!Important] Remember to lock _syncRoot to prevent data race. */ - throw new NotImplementedException("TODO: T2.2"); + lock (_syncRoot) + { + _analysisResults[file.Name] = result; + } } } } diff --git a/src/LogAnalyzer/WorkQueue.cs b/src/LogAnalyzer/WorkQueue.cs index 23055a5..97b9d72 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,49 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + if (_isCompleted) + { + throw new InvalidOperationException("Cannot enqueue after CompleteAdding has been called."); + } + + _items.Enqueue(item); + // Wake up one (or all) waiting consumer(s). + Monitor.Pulse(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + // Wait while the queue is empty and adding is not yet complete. + // A while loop is required to handle spurious wakeups (MESA model). + while (_items.Count == 0 && !_isCompleted) + { + Monitor.Wait(_items); + } + + if (_items.Count > 0) + { + item = _items.Dequeue()!; + return true; + } + + item = default; + return false; + } } public void CompleteAdding() { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + _isCompleted = true; + // Wake up all consumers so they can notice completion and exit. + Monitor.PulseAll(_items); + } } } }