using System.Collections.Concurrent; using System.Text; // ReSharper disable StaticMemberInGenericType namespace CSV_Metrics_Logger; /// /// The CSV storage class. /// public sealed class CSVStorage : IAsyncDisposable where T : struct, IConvertToCSV { private const char CSV_DEFAULT_DELIMITER = ';'; private static readonly TimeSpan WRITE_INTERVAL = TimeSpan.FromSeconds(8); private readonly object lockObject = new(); private readonly FileStream fileStream; private readonly TextWriter writer; private readonly char csvDelimiter; private readonly ConcurrentQueue dataQueue = new(); private readonly PeriodicTimer writeTimer = new(WRITE_INTERVAL); private bool isInitialized; private bool writerRunning; private CSVStorage(string filename, char csvDelimiter) { this.csvDelimiter = csvDelimiter; this.isInitialized = File.Exists(filename) && new FileInfo(filename).Length > 0; this.fileStream = new FileStream(filename, FileMode.Append, FileAccess.Write, FileShare.Read); this.writer = new StreamWriter(this.fileStream, Encoding.UTF8); _ = Task.Factory.StartNew(() => this.WriteDataWorker(), TaskCreationOptions.LongRunning); } /// /// Create a new CSV storage instance for the given type T and filename. /// /// /// When the file already exists, added data will be appended to the file. /// The header will be written only once. /// /// The filename to store the CSV data. /// The CSV delimiter to use. Default is a semicolon. /// The CSV storage instance. public static CSVStorage Create(string filename, char csvDelimiter = CSV_DEFAULT_DELIMITER) => new(filename, csvDelimiter); /// /// Write the given data to the CSV file. /// /// The data to write. /// The cancellation token. public void Write(T data, CancellationToken cancellationToken = default) { if(!this.isInitialized) { lock (this.lockObject) { if(!this.isInitialized) { this.dataQueue.Enqueue(this.CreateCSVLine(data.GetCSVHeaders(), cancellationToken)); this.isInitialized = true; } } } this.dataQueue.Enqueue(this.CreateCSVLine(data.ConvertToCSVDataLine(), cancellationToken)); } private async Task WriteDataWorker(CancellationToken cancellationToken = default) { this.writerRunning = true; while (await this.writeTimer.WaitForNextTickAsync(cancellationToken)) await this.WriteData(cancellationToken); this.writerRunning = false; } private async ValueTask WriteData(CancellationToken cancellationToken = default) { if(this.dataQueue.IsEmpty) return; while(this.dataQueue.TryDequeue(out var line)) { if(cancellationToken.IsCancellationRequested) break; await this.writer.WriteLineAsync(line); } } private string CreateCSVLine(IEnumerable elements, CancellationToken cancellationToken = default) { var sb = new StringBuilder(1_024); foreach (var element in elements) { if(cancellationToken.IsCancellationRequested) break; if (sb.Length > 0) sb.Append(this.csvDelimiter); if (this.QuotationNeeded(element)) { sb.Append('"'); sb.Append(element.Replace("\"", "\"\"")); sb.Append('"'); } else sb.Append(element); } return sb.ToString(); } private bool QuotationNeeded(string value) { // Rules: // - If the value contains a comma, we need to use double quotes. // - If the value contains the delimiter, we need to use double quotes. // - If the value contains the escape character, we need to use double quotes. // - If the value contains a newline character, we need to use double quotes. // - If the value contains a double quote, we need to escape it by doubling it. // - If the value contains spaces, we need to use double quotes. // if (value.Contains(',') || value.Contains(this.csvDelimiter) || value.Contains('"') || value.Contains('\n') || value.Contains(' ')) { return true; } return false; } #region Implementation of IAsyncDisposable /// public async ValueTask DisposeAsync() { // Stop the writer timer: this.writeTimer.Dispose(); // Now, we wait for the last scheduled write operation to finish, if any: while (this.writerRunning) await Task.Delay(TimeSpan.FromMilliseconds(100)); // Ensure that all data is written: await this.WriteData(); // Flush and dispose the writer: await this.writer.FlushAsync(); await this.writer.DisposeAsync(); // Dispose the file stream: await this.fileStream.DisposeAsync(); } #endregion }