164 lines
5.4 KiB
C#
164 lines
5.4 KiB
C#
|
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
|
||
|
}
|