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 CSV_Metrics_Logger;
 | 
			
		||||
 | 
			
		||||
namespace Tests;
 | 
			
		||||
 | 
			
		||||
@ -81,4 +82,39 @@ public sealed class Tests
 | 
			
		||||
            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