Implemented CSVStorage
This commit is contained in:
		
							parent
							
								
									07ffe92067
								
							
						
					
					
						commit
						951858a99e
					
				
							
								
								
									
										164
									
								
								CSV Metrics Logger/CSVStorage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								CSV Metrics Logger/CSVStorage.cs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,164 @@
 | 
				
			|||||||
 | 
					using System.Collections.Concurrent;
 | 
				
			||||||
 | 
					using System.Text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ReSharper disable StaticMemberInGenericType
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace CSV_Metrics_Logger;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// <summary>
 | 
				
			||||||
 | 
					/// The CSV storage class.
 | 
				
			||||||
 | 
					/// </summary>
 | 
				
			||||||
 | 
					public sealed class CSVStorage<T> : 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<string> 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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Create a new CSV storage instance for the given type T and filename.
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <remarks>
 | 
				
			||||||
 | 
					    /// When the file already exists, added data will be appended to the file.
 | 
				
			||||||
 | 
					    /// The header will be written only once.
 | 
				
			||||||
 | 
					    /// </remarks>
 | 
				
			||||||
 | 
					    /// <param name="filename">The filename to store the CSV data.</param>
 | 
				
			||||||
 | 
					    /// <param name="csvDelimiter">The CSV delimiter to use. Default is a semicolon.</param>
 | 
				
			||||||
 | 
					    /// <returns>The CSV storage instance.</returns>
 | 
				
			||||||
 | 
					    public static CSVStorage<T> Create(string filename, char csvDelimiter = CSV_DEFAULT_DELIMITER) => new(filename, csvDelimiter);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Write the given data to the CSV file.
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <param name="data">The data to write.</param>
 | 
				
			||||||
 | 
					    /// <param name="cancellationToken">The cancellation token.</param>
 | 
				
			||||||
 | 
					    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<string> 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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <inheritdoc />
 | 
				
			||||||
 | 
					    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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,4 +1,5 @@
 | 
				
			|||||||
using System.Diagnostics.CodeAnalysis;
 | 
					using System.Diagnostics.CodeAnalysis;
 | 
				
			||||||
 | 
					using CSV_Metrics_Logger;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Tests;
 | 
					namespace Tests;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -81,4 +82,39 @@ public sealed class Tests
 | 
				
			|||||||
            Assert.That(dataLine, Is.EquivalentTo(new[] { "New York", "35", "1.85" }));
 | 
					            Assert.That(dataLine, Is.EquivalentTo(new[] { "New York", "35", "1.85" }));
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Test]
 | 
				
			||||||
 | 
					    public async Task TestWritingCSV()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        List<TestDataOneLine> testData =
 | 
				
			||||||
 | 
					        [
 | 
				
			||||||
 | 
					            new TestDataOneLine("Name 1", 14),
 | 
				
			||||||
 | 
					            new TestDataOneLine("Name 2", 25),
 | 
				
			||||||
 | 
					            new TestDataOneLine("Name 3", 36),
 | 
				
			||||||
 | 
					            new TestDataOneLine("Name 4", 47),
 | 
				
			||||||
 | 
					            new TestDataOneLine("Name 5", 58),
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Get a random file:
 | 
				
			||||||
 | 
					        var fileName = Path.GetTempFileName();
 | 
				
			||||||
 | 
					        await using (var storage = CSVStorage<TestDataOneLine>.Create(fileName))
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            foreach (var data in testData)
 | 
				
			||||||
 | 
					                storage.Write(data);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var lines = await File.ReadAllLinesAsync(fileName);
 | 
				
			||||||
 | 
					        Assert.Multiple(() =>
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            Assert.That(lines, Has.Length.EqualTo(6));
 | 
				
			||||||
 | 
					            Assert.That(lines[0], Is.EqualTo("Name;Age"));
 | 
				
			||||||
 | 
					            Assert.That(lines[1], Is.EqualTo("\"Name 1\";14"));
 | 
				
			||||||
 | 
					            Assert.That(lines[2], Is.EqualTo("\"Name 2\";25"));
 | 
				
			||||||
 | 
					            Assert.That(lines[3], Is.EqualTo("\"Name 3\";36"));
 | 
				
			||||||
 | 
					            Assert.That(lines[4], Is.EqualTo("\"Name 4\";47"));
 | 
				
			||||||
 | 
					            Assert.That(lines[5], Is.EqualTo("\"Name 5\";58"));
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        File.Delete(fileName);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user