diff --git a/docs/01-basic/report.md b/docs/01-basic/report.md new file mode 100644 index 0000000..9d35a21 --- /dev/null +++ b/docs/01-basic/report.md @@ -0,0 +1,86 @@ +# Report for Basic Functions + +## Q1.1 + +### 按逗号分割与字段含义 + +代码框架并不是手动调用 `String.Split` 来按逗号分割日志,而是使用了 **CsvHelper** 库。在 `LogFileParser.Parse` 方法中: + +```csharp +using var csv = new CsvReader(logFile, config); +csv.Context.RegisterClassMap(); +foreach (var logRecord in csv.GetRecords()) +{ + yield return LineParser.ParseLine(logRecord); +} +``` + +- `new CsvReader(logFile, config)` 创建了 CSV 读取器,`csv.GetRecords()` 负责按逗号把每一行拆分成若干字段。 +- 每一行第几个字段代表什么含义,由 `LogRecordMap`(继承 `CsvHelper.Configuration.ClassMap`)指定: + + ```csharp + Map(m => m.LineNo).Index(0); // 第 0 列是行号 + Map(m => m.Timestamp).Index(1); // 第 1 列是时间戳 + Map(m => m.PodName).Index(2); // 第 2 列是容器名 + Map(m => m.Message).Index(3); // 第 3 列是 JSON 消息 + ``` + + 并通过 `csv.Context.RegisterClassMap()` 注册生效。 + +### 判断日志种类 + +在 `LineParser.ParseLine` 方法中,通过以下语句判断这一行日志的种类: + +```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` 解析 `message`,再用 `root.TryGetProperty("event", ...)` 取出 `event` 字段,最后用 `switch` 表达式根据 `event` 的值(`"call"` / `"request"` / `"internal"`)分发到对应的创建方法。 + +### 解析 JSON 所用的库方法 + +确定日志种类后,调用的是 `System.Text.Json` 中的 `JsonSerializer.Deserialize(json, options)`(例如 `JsonSerializer.Deserialize(logRecord.Message, options)`)。 + +**防止字段缺失:** 每个 `Message` record 的字段都标注了 `[property: JsonRequired]` 特性(例如 `[property: JsonRequired] string RequestId`)。当 JSON 中缺少被标记的字段时,`JsonSerializer.Deserialize` 会抛出 `JsonException`;此外还通过 `?? throw new FormatException(...)` 处理反序列化结果整体为 `null` 的情况。 + +**命名法转换:** 通过 `JsonSerializerOptions` 的命名策略完成: + +```csharp +private static JsonSerializerOptions options = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower, +}; +``` + +`PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower` 会让序列化器把大驼峰属性名(如 `RequestId`、`TargetService`、`DurationMs`)自动转换为烤串命名法(`request-id`、`target-service`、`duration-ms`)去匹配 JSON 中的键。 + +## Q1.2 + +以一个 Call 事件的解析结果为例,`Dump` 方法被调用后的方法调用链如下: + ++ `Dictionary KeyValueVisitor.Dump(LogEntry entry)` ++ `TResult CallLogEntry.Accept(ILogEntryVisitor visitor)`(经 `entry.Accept(this)` 多态调用) ++ `Dictionary KeyValueVisitor.Visit(CallLogEntry entry)` + +## Q1.3 + +(本问为个人反思题,请根据你的实际情况选择作答。下方以 Q1.3.b 为例。) + +### Q1.3.b + +本次作业我使用了 AI 辅助完成。我给予 AI 的提示词大致为:「帮我完成 01-basic 要求的所有作业」,并在此之前通过提问明确了当前分支、任务内容与需要改动的文件。 + +与完全依靠传统搜索引擎和自己能力写出的解答相比,AI 的解答好在:能快速梳理出代码框架中需要补全的 `TODO` 位置,并给出与已有 `Call` 实现风格一致的参考代码,节省了大量阅读与试错的时间。 + +但 AI 的解答也存在不足:例如它有时会忽略 `internal` 日志中 `exception` 字段需要按「冒号加空格」拆分成 `ExceptionName` 与 `ExceptionMessage` 这一细节,需要人工结合测试用例(`TestParseInternalLogExampleFailed`)来确认异常格式的处理方式。因此最终代码仍需要人工 review 与验证。 diff --git a/docs/02-multithreading/assets/localcli-full.png b/docs/02-multithreading/assets/localcli-full.png new file mode 100644 index 0000000..8923760 Binary files /dev/null and b/docs/02-multithreading/assets/localcli-full.png differ diff --git a/docs/02-multithreading/assets/localcli-robustness.png b/docs/02-multithreading/assets/localcli-robustness.png new file mode 100644 index 0000000..88a5443 Binary files /dev/null and b/docs/02-multithreading/assets/localcli-robustness.png differ diff --git a/docs/02-multithreading/report.md b/docs/02-multithreading/report.md new file mode 100644 index 0000000..d8636b8 --- /dev/null +++ b/docs/02-multithreading/report.md @@ -0,0 +1,84 @@ +# Report for Multithreading + +## 功能实现简介 + +本节在 `01-basic` 的基础上,实现了目录级别的并行日志分析器,共完成三个部分: + +1. **线程安全队列 `WorkQueue`**(`LogAnalyzer/WorkQueue.cs`):基于 `Queue` + `lock` + `Monitor`(条件变量)实现的、支持"结束放入"操作的无限容量生产者-消费者队列。 +2. **并行日志分析器 `LogFileAnalyzer`**(`LogAnalyzer/LogFileAnalyzer.cs`):扫描指定目录下所有 `.log` 文件,开多个工作线程并行解析,并缓存每个文件的分析结果。 +3. **控制台交互界面 `LocalCli`**(`LocalCli/Program.cs`):提供展示文件、分析指定文件、分析全部文件、查看分析结果、切换目录等菜单,并对非法输入做了鲁棒性处理。 + +### 控制台界面截图 + +完整功能包括:输入目录 → 展示文件列表 → 分析指定文件 → 分析全部文件 → 查看单个文件的解析结果。 + +![完整功能截图](./assets/localcli-full.png) + +### 鲁棒性测试截图 + +覆盖以下非法输入场景:不存在的目录、不存在的文件名、分析不存在的文件(抛出 `ArgumentException` 被捕获)、菜单选项输入非数字、以及查看尚未分析过的文件。 + +![鲁棒性测试截图](./assets/localcli-robustness.png) + +--- + +## Q2.1 + +### `WorkQueue` 中的共享变量及其保护 + +`WorkQueue` 中有两个共享变量: + ++ `_items`(`Queue`):队列内部存储; ++ `_isCompleted`(`bool`):标记是否已结束放入元素。 + +这两个变量均通过 `lock (_items)` 保护,即以 `_items` 对象本身作为互斥量。`Enqueue`、`TryDequeue`、`CompleteAdding` 以及 `IsCompleted` 属性中所有对这两个共享变量的读写都在 `lock (_items)` 临界区内完成,从而避免数据竞争。 + +### `LogFileAnalyzer` 中的共享变量及其保护 + +`LogFileAnalyzer` 中的共享变量有: + ++ `_currentDirectory`(`string?`):当前日志目录; ++ `_isAnalyzing`(`bool`):是否正在分析; ++ `_logFiles`(`Dictionary`):目录中的日志文件映射; ++ `_analysisResults`(`Dictionary`):各文件的解析结果。 + +这些变量统一通过一个专用的互斥对象 `_syncRoot` 的 `lock (_syncRoot)` 保护。所有方法(`ChangeDirectory`、`GetLogFiles`、`TryGetAnalysisResult`、`AnalyzeFiles`、`RunWorkers`、`WorkerMain` 等)在访问这些共享变量时都先进入 `lock (_syncRoot)` 临界区。尤其是 `_isAnalyzing` 的读写、`_analysisResults` 的读写(由多个工作线程同时写入),都必须加锁。 + +### 条件变量使用 `if` 而非 `while` 的后果 + +若将判断条件写成 `if`,当出现虚假唤醒(spurious wakeup)时:线程在没有人调用 `signal`/`broadcast` 的情况下从 `Monitor.Wait` 中醒来,但此时仓库(队列)可能仍然是空的。若用 `if`,线程醒来后不会再检查条件,而是直接越过等待、去执行取元素操作,这会导致: + ++ 从空队列中执行 `Dequeue()`,抛出 `InvalidOperationException`(或读到非法数据); ++ 对应到无限容量生产者-消费者问题,就是消费者在 `buffer == 0` 时依然执行 `buffer -= 1`,造成"负库存"的逻辑错误。 + +因此必须用 `while`,让线程每次被唤醒后都重新检查"是否有元素"以及"是否已结束放入",只有在条件真正满足时才继续执行,从而保证正确性。 + +--- + +## Q2.2 + +扫描目录中全部 `.log` 后缀日志文件的代码位于 `LogFileAnalyzer.ChangeDirectory` 方法中: + +```csharp +var logFiles = Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.TopDirectoryOnly) + .Select(filePath => Path.GetFileName(filePath)) + .OrderBy(fileName => fileName); +``` + +它使用 `Directory.EnumerateFiles` 配合通配符 `"*.log"` 和 `SearchOption.TopDirectoryOnly` 枚举当前目录下的日志文件,再用 `Select` 取文件名、`OrderBy` 排序。 + +若要递归获取给定目录的全部子目录(及子子目录……)内的日志文件,只需把搜索选项 `SearchOption.TopDirectoryOnly` 改为 `SearchOption.AllDirectories` 即可(可配合 `Directory.EnumerateFiles(directoryPath, "*.log", SearchOption.AllDirectories)`)。 + +--- + +## Q2.3 + +### Q2.3.b + +本次作业我使用了 AI 辅助完成。我给予 AI 的提示词大致是:"切换到 02-multithreading 分支,阅读 guidance 文档后完成 WorkQueue、LogFileAnalyzer、LocalCli 的实现"。 + +我对 AI 的使用主要是:让 AI 帮我梳理 `WorkQueue` 的条件变量写法(`Monitor.Wait`/`Pulse`/`PulseAll` 与 `while` 循环配合)、`LogFileAnalyzer` 中 `RunWorkers` 的线程生命周期管理(入队 → 开启线程 → `Join`),以及 `LocalCli` 的异常捕获结构。 + +AI 的解答基本正确,但存在一些需要人工修正的细节:例如 `WorkQueue.TryDequeue` 中 `item = _items.Dequeue()` 会触发可空性警告 CS8762,需要用空值宽容运算符 `!` 修正;又如 `RunWorkers` 中"结束放入"(`CompleteAdding`)必须在开启工作线程之前(或之后立刻)执行,并唤醒所有消费者,否则消费者会永久阻塞在 `TryDequeue` 上。这些都是需要人工理解并发语义后自行确认的点。 + +我认为本节的难度为适中。 diff --git a/src/LocalCli/Program.cs b/src/LocalCli/Program.cs index 17b30db..f291fed 100644 --- a/src/LocalCli/Program.cs +++ b/src/LocalCli/Program.cs @@ -112,22 +112,92 @@ 6. Exit. private static void ShowLogFiles(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + var logFiles = analyzer.GetLogFiles(); + if (logFiles.Count == 0) + { + Console.WriteLine("No log files found in the directory."); + return; + } + + Console.WriteLine("Log files:"); + 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 separated by commas (e.g., basic.log,basic-multiple.log):"); + var input = Console.ReadLine(); + if (input is null) + { + return; + } + + var fileNames = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (fileNames.Length == 0) + { + Console.WriteLine("No file names provided, please try again."); + return; + } + + try + { + analyzer.AnalyzeFiles(0, fileNames); + Console.WriteLine("Analysis completed."); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to analyze files: {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($"Failed to analyze all files: {ex.Message}"); + } } private static void GetAnalysisResult(LogFileAnalyzer analyzer) { - throw new NotImplementedException("T2.3"); + Console.WriteLine("Please input file name:"); + var fileName = Console.ReadLine(); + if (fileName is null) + { + return; + } + + if (!analyzer.TryGetAnalysisResult(fileName, out var result)) + { + Console.WriteLine($"File '{fileName}' is not in the current directory, please try again."); + return; + } + + switch (result!.State) + { + case AnalysisState.NotAnalyzed: + Console.WriteLine($"File '{fileName}' has not been analyzed yet."); + break; + case AnalysisState.Succeeded: + var visitor = new KeyValueVisitor(); + foreach (var entry in result.Entries) + { + var kv = visitor.Dump(entry); + Console.WriteLine(string.Join(", ", kv.Select(pair => $"{pair.Key}={pair.Value}"))); + } + break; + case AnalysisState.Failed: + Console.WriteLine($"File '{fileName}' failed to analyze: {result.ErrorMessage}"); + break; + } } } } diff --git a/src/LogAnalyzer/LogFileAnalyzer.cs b/src/LogAnalyzer/LogFileAnalyzer.cs index c3e7691..80d0f41 100644 --- a/src/LogAnalyzer/LogFileAnalyzer.cs +++ b/src/LogAnalyzer/LogFileAnalyzer.cs @@ -138,10 +138,7 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } fileList = fileNameList.Select(fileName => _logFiles[fileName]).ToList(); - /* - * Set _isAnalyzing - */ - // TODO: T2.2 + _isAnalyzing = true; } try @@ -150,11 +147,10 @@ public void AnalyzeFiles(int degreeOfParallelism, IEnumerable fileNames) } finally { - /* - * Unset _isAnalyzing - * Remember to lock _syncRoot to prevent data race - */ - // TODO: T2.2 + lock (_syncRoot) + { + _isAnalyzing = false; + } } } @@ -165,11 +161,14 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis { foreach (var file in fileList) { - /* - * Filter unparsed files. - * If there is an unknown file, throw System.InvalidOperationException. - */ - throw new NotImplementedException("TODO: T2.2"); + if (!_analysisResults.TryGetValue(file.Name, out var analysisResult)) + { + throw new InvalidOperationException($"Unknown file: {file.Name}."); + } + if (analysisResult.State == AnalysisState.NotAnalyzed) + { + logFilesToParse.Add(file); + } } } @@ -180,10 +179,11 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis var queue = new WorkQueue(); - /* - * Enqueue log files - */ - // TODO: T2.2 + foreach (var file in logFilesToParse) + { + queue.Enqueue(file); + } + queue.CompleteAdding(); degreeOfParallelism = Math.Max(Math.Min(degreeOfParallelism, logFilesToParse.Count), 1); var workers = new Thread[degreeOfParallelism]; @@ -191,16 +191,19 @@ private void RunWorkers(int degreeOfParallelism, IReadOnlyList fileLis { int workerId = i; string threadName = $"log-analyzer-worker-{workerId}"; - /* - * Create and start threads to run `WorkerMain` - */ - // TODO: T2.2 + var worker = new Thread(() => WorkerMain(workerId, queue)) + { + IsBackground = true, + Name = threadName, + }; + workers[i] = worker; + worker.Start(); } - /* - * Wait for (join) all threads to end - */ - // TODO: T2.2 + foreach (var worker in workers) + { + worker.Join(); + } } private void WorkerMain(int workerId, WorkQueue queue) @@ -212,20 +215,37 @@ private void WorkerMain(int workerId, WorkQueue queue) AnalysisResult result; 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..948c434 100644 --- a/src/LogAnalyzer/WorkQueue.cs +++ b/src/LogAnalyzer/WorkQueue.cs @@ -20,17 +20,40 @@ public bool IsCompleted public void Enqueue(T item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + _items.Enqueue(item); + Monitor.Pulse(_items); + } } public bool TryDequeue([NotNullWhen(true)] out T? item) { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + while (_items.Count == 0 && !_isCompleted) + { + Monitor.Wait(_items); + } + + if (_items.Count > 0) + { + item = _items.Dequeue()!; + return true; + } + + item = default; + return false; + } } public void CompleteAdding() { - throw new NotImplementedException("TODO: T2.1"); + lock (_items) + { + _isCompleted = true; + Monitor.PulseAll(_items); + } } } } diff --git a/src/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..6b19485 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,39 @@ 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}"); + var separatorIndex = internalMessage.Exception.IndexOf(": "); + if (separatorIndex < 0) + { + throw new FormatException($"Invalid exception format: {internalMessage.Exception}"); + } + var exceptionName = internalMessage.Exception[..separatorIndex]; + var exceptionMessage = internalMessage.Exception[(separatorIndex + 2)..]; + return new InternalLogEntry( + LineNo: logRecord.LineNo, + Timestamp: DateTimeOffset.Parse(logRecord.Timestamp), + PodName: logRecord.PodName, + Severity: ParseSeverity(internalMessage.Severity), + ExceptionName: exceptionName, + ExceptionMessage: exceptionMessage + ); } private static LogSeverity ParseSeverity(string severity) @@ -77,11 +104,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, + }; } } }