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