Added extensions

This commit is contained in:
2026-02-03 20:46:51 +01:00
parent 60cad30645
commit 9e44051677
7 changed files with 658 additions and 60 deletions

View File

@@ -60,6 +60,7 @@ public static class FileLoggerFactoryExtensions
options.Mask = fileLoggerOptions.Mask; options.Mask = fileLoggerOptions.Mask;
options.UseDefaultMasking = fileLoggerOptions.UseDefaultMasking; options.UseDefaultMasking = fileLoggerOptions.UseDefaultMasking;
options.MaskedKeywords = fileLoggerOptions.MaskedKeywords; options.MaskedKeywords = fileLoggerOptions.MaskedKeywords;
options.IncludeCorrelationId = fileLoggerOptions.IncludeCorrelationId;
} }
); );
return builder; return builder;

View File

@@ -24,7 +24,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider
private string _logFile; private string _logFile;
private long _currentFileSize; private long _currentFileSize;
private Timer _flushTimer;
private readonly ConcurrentDictionary<string, StringBuilder> _buffers = new(); private readonly ConcurrentDictionary<string, StringBuilder> _buffers = new();
private readonly ConcurrentDictionary<string, long> _fileSizes = new(); private readonly ConcurrentDictionary<string, long> _fileSizes = new();
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
@@ -35,6 +35,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider
public string LogFile => _logFile ?? string.Empty; public string LogFile => _logFile ?? string.Empty;
public event EventHandler<ErrorMessage> OnError; public event EventHandler<ErrorMessage> OnError;
public event EventHandler<string> OnRollOver;
public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options) public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options)
{ {
@@ -48,6 +49,15 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider
Directory.CreateDirectory(_path); Directory.CreateDirectory(_path);
InitializeCurrentFile(); InitializeCurrentFile();
_flushTimer = new Timer(_ => FlushAllAsync().GetAwaiter().GetResult(), null, 5000, 5000);
}
private async Task FlushAllAsync()
{
foreach (var kv in _buffers)
{
await FlushAsync(kv.Key, kv.Value, CancellationToken.None);
}
} }
public bool IncludeCorrelationId { get; } public bool IncludeCorrelationId { get; }
@@ -179,17 +189,17 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider
for (int i = _maxRolloverFiles - 1; i >= 1; i--) for (int i = _maxRolloverFiles - 1; i >= 1; i--)
{ {
var src = Path.Combine(dir, $"{name}.{i}{ext}"); var source = Path.Combine(dir, $"{name}.{i}{ext}");
var dst = Path.Combine(dir, $"{name}.{i + 1}{ext}"); var destination = Path.Combine(dir, $"{name}.{i + 1}{ext}");
if (File.Exists(dst)) if (File.Exists(destination))
{ {
File.Delete(dst); File.Delete(destination);
} }
if (File.Exists(src)) if (File.Exists(source))
{ {
File.Move(src, dst); File.Move(source, destination);
} }
} }
@@ -204,6 +214,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider
finally finally
{ {
_rolloverLock.Release(); _rolloverLock.Release();
OnRollOver?.Invoke(this, file);
} }
} }
@@ -213,13 +224,10 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider
_buffers.TryAdd(file, new StringBuilder(4096)); _buffers.TryAdd(file, new StringBuilder(4096));
} }
private (int Year, int Month, int Day) GetGrouping(LogMessage m) =>
(m.Timestamp.Year, m.Timestamp.Month, m.Timestamp.Day);
private string GetFullName((int Year, int Month, int Day) g) => private string GetFullName((int Year, int Month, int Day) g) =>
string.IsNullOrWhiteSpace(_fileNamePrefix) string.IsNullOrWhiteSpace(_fileNamePrefix)
? Path.Combine(_path, $"{g.Year:0000}{g.Month:00}{g.Day:00}.log") ? Path.Combine(_path, $"{Environment.MachineName}_{g.Year:0000}{g.Month:00}{g.Day:00}.log")
: Path.Combine(_path, $"{_fileNamePrefix}_{g.Year:0000}{g.Month:00}{g.Day:00}.log"); : Path.Combine(_path, $"{_fileNamePrefix}_{Environment.MachineName}_{g.Year:0000}{g.Month:00}{g.Day:00}.log");
private void DeleteOldLogFiles() private void DeleteOldLogFiles()
{ {

View File

@@ -19,10 +19,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private readonly Task _processingTask; private readonly Task _processingTask;
private const int MaxQueueSize = 1000; private const int MaxQueueSize = 5000;
private const int MaxBatchSize = 20; // send logs in batches to reduce TCP writes
public bool IncludeCorrelationId { get; set; } public bool IncludeCorrelationId { get; set; }
public event EventHandler<Exception> OnException; public event EventHandler<Exception> OnException;
public event EventHandler<string> OnLogDropped;
public TcpLogger(string categoryName, TcpLoggerOptions options) public TcpLogger(string categoryName, TcpLoggerOptions options)
{ {
@@ -37,6 +39,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger
SingleWriter = false SingleWriter = false
}); });
// Start background processing task
_processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token), _cts.Token); _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token), _cts.Token);
} }
@@ -51,9 +54,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger
Exception exception, Func<TState, Exception, string> formatter) Exception exception, Func<TState, Exception, string> formatter)
{ {
if (!IsEnabled(logLevel) || formatter == null) if (!IsEnabled(logLevel) || formatter == null)
{
return; return;
}
try try
{ {
@@ -65,7 +66,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger
string message = formatter(state, exception); string message = formatter(state, exception);
// Build message
var sb = new StringBuilder(256); var sb = new StringBuilder(256);
sb.Append('[').Append(DateTime.UtcNow.ToString("u")).Append("] | "); sb.Append('[').Append(DateTime.UtcNow.ToString("u")).Append("] | ");
sb.Append('[').Append(logLevel).Append("] | "); sb.Append('[').Append(logLevel).Append("] | ");
@@ -77,17 +77,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger
{ {
sb.Append(" | Context: "); sb.Append(" | Context: ");
foreach (var kvp in contextData) foreach (var kvp in contextData)
{
sb.Append(kvp.Key).Append("=").Append(kvp.Value).Append("; "); sb.Append(kvp.Key).Append("=").Append(kvp.Value).Append("; ");
}
} }
if (exception != null) if (exception != null)
{
sb.Append(" | Exception: ").Append(exception); sb.Append(" | Exception: ").Append(exception);
}
_logChannel.Writer.TryWrite(sb.ToString()); if (!_logChannel.Writer.TryWrite(sb.ToString()))
OnLogDropped?.Invoke(this, sb.ToString()); // notify if log is dropped
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -100,51 +97,146 @@ namespace EonaCat.Logger.EonaCatCoreLogger
TcpClient client = null; TcpClient client = null;
StreamWriter writer = null; StreamWriter writer = null;
try while (!token.IsCancellationRequested)
{ {
client = new TcpClient(); try
await client.ConnectAsync(_options.Host, _options.Port).ConfigureAwait(false);
writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true };
await foreach (var log in _logChannel.Reader.ReadAllAsync(token))
{ {
try // Attempt to connect with exponential backoff
int attempt = 0;
while (client == null || !client.Connected)
{ {
await writer.WriteLineAsync(log).ConfigureAwait(false); try
{
client?.Dispose();
client = new TcpClient();
await ConnectWithRetryAsync(client, _options.Host, _options.Port, token);
writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true };
break; // connected
}
catch (Exception ex)
{
OnException?.Invoke(this, ex);
attempt++;
int delay = Math.Min(1000 * (int)Math.Pow(2, attempt), 30000); // max 30s backoff
await Task.Delay(delay, token).ConfigureAwait(false);
}
} }
catch (IOException ioEx)
{
OnException?.Invoke(this, ioEx);
// Attempt to reconnect // Process logs in batches
writer.Dispose(); var batch = new List<string>(MaxBatchSize);
client.Dispose(); await foreach (var log in _logChannel.Reader.ReadAllAsync(token))
client = new TcpClient();
await client.ConnectAsync(_options.Host, _options.Port).ConfigureAwait(false);
writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true };
}
catch (Exception ex)
{ {
OnException?.Invoke(this, ex); batch.Add(log);
if (batch.Count >= MaxBatchSize)
{
await SafeWriteBatch(writer, batch, client, token);
batch.Clear();
}
} }
// Flush any remaining logs
if (batch.Count > 0)
await SafeWriteBatch(writer, batch, client, token);
}
catch (OperationCanceledException)
{
break; // normal shutdown
}
catch (Exception ex)
{
OnException?.Invoke(this, ex);
await Task.Delay(1000, token); // prevent tight loop
} }
} }
catch (OperationCanceledException)
// Cleanup
writer?.Dispose();
client?.Dispose();
}
private async Task ConnectWithRetryAsync(TcpClient client, string host, int port, CancellationToken token)
{
int attempt = 0;
while (!token.IsCancellationRequested)
{ {
// normal shutdown try
{
var connectTask = client.ConnectAsync(host, port);
var delayTask = Task.Delay(Timeout.Infinite, token);
var completed = await Task.WhenAny(connectTask, delayTask);
if (completed == connectTask)
{
await connectTask;
return;
}
else
{
token.ThrowIfCancellationRequested();
}
}
catch (Exception ex)
{
attempt++;
OnException?.Invoke(this, ex);
int delayMs = Math.Min(1000 * (int)Math.Pow(2, attempt), 30000);
await Task.Delay(delayMs, token);
client.Dispose();
client = new TcpClient();
}
}
}
private async Task SafeWriteBatch(StreamWriter writer, List<string> batch, TcpClient client, CancellationToken token)
{
try
{
if (writer != null && client?.Connected == true)
{
await writer.WriteLineAsync(string.Join(Environment.NewLine, batch)).ConfigureAwait(false);
}
else
{
// Optionally write to local file if TCP server is down
await FallbackWriteAsync(batch).ConfigureAwait(false);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
OnException?.Invoke(this, ex); OnException?.Invoke(this, ex);
} await FallbackWriteAsync(batch).ConfigureAwait(false);
finally
{
writer?.Dispose();
client?.Dispose();
} }
} }
private async Task FallbackWriteAsync(List<string> batch)
{
try
{
if (!_options.EnableFallbackLogging)
{
return;
}
string fallbackFile = _options.FallbackLogFilePath ?? Path.Combine(AppContext.BaseDirectory, "tcp_logger_fallback.log");
Directory.CreateDirectory(Path.GetDirectoryName(fallbackFile)!);
using (var writer = new StreamWriter(fallbackFile, append: true, encoding: Encoding.UTF8))
{
foreach (var line in batch)
{
await writer.WriteLineAsync(line).ConfigureAwait(false);
}
}
}
catch
{
// Do nothing
}
}
public void Dispose() public void Dispose()
{ {
_cts.Cancel(); _cts.Cancel();
@@ -152,7 +244,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger
try try
{ {
_processingTask.Wait(); _processingTask.Wait(TimeSpan.FromSeconds(5));
} }
catch catch
{ {

View File

@@ -13,5 +13,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger
public int Port { get; set; } public int Port { get; set; }
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
public bool IncludeCorrelationId { get; set; } public bool IncludeCorrelationId { get; set; }
public string FallbackLogFilePath { get; set; }
public bool EnableFallbackLogging { get; set; }
} }
} }

View File

@@ -23,7 +23,9 @@ namespace EonaCat.Logger.EonaCatCoreLogger
private readonly Task _processingTask; private readonly Task _processingTask;
public bool IncludeCorrelationId { get; set; } public bool IncludeCorrelationId { get; set; }
public event EventHandler<Exception> OnException; public event EventHandler<Exception> OnException;
public event EventHandler<string> OnLogDropped;
public UdpLogger(string categoryName, UdpLoggerOptions options) public UdpLogger(string categoryName, UdpLoggerOptions options)
{ {
@@ -93,7 +95,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger
string fullMessage = string.Join(" | ", logParts); string fullMessage = string.Join(" | ", logParts);
// Drop oldest if full // Drop oldest if full
_logChannel.Writer.TryWrite(fullMessage); if (!_logChannel.Writer.TryWrite(fullMessage))
{
OnLogDropped?.Invoke(this, fullMessage);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -105,16 +110,42 @@ namespace EonaCat.Logger.EonaCatCoreLogger
{ {
try try
{ {
await foreach (var message in _logChannel.Reader.ReadAllAsync(token)) if (_options.EnableBatching && _options.BatchSize > 1)
{ {
try // batching mode
var batch = new List<string>(_options.BatchSize);
await foreach (var message in _logChannel.Reader.ReadAllAsync(token))
{ {
byte[] bytes = Encoding.UTF8.GetBytes(message); batch.Add(message);
await _udpClient.SendAsync(bytes, bytes.Length, _options.Host, _options.Port);
if (batch.Count >= _options.BatchSize)
{
await SendBatchAsync(batch, token);
batch.Clear();
}
} }
catch (Exception ex)
// send remaining messages
if (batch.Count > 0)
{ {
OnException?.Invoke(this, ex); await SendBatchAsync(batch, token);
}
}
else
{
// non-batching mode: send messages one by one
await foreach (var message in _logChannel.Reader.ReadAllAsync(token))
{
try
{
byte[] bytes = Encoding.UTF8.GetBytes(message);
await _udpClient.SendAsync(bytes, bytes.Length, _options.Host, _options.Port);
}
catch (Exception ex)
{
OnException?.Invoke(this, ex);
}
} }
} }
} }
@@ -122,6 +153,30 @@ namespace EonaCat.Logger.EonaCatCoreLogger
{ {
// normal shutdown // normal shutdown
} }
catch (Exception exception)
{
OnException?.Invoke(this, exception);
}
}
/// <summary>
/// Sends a batch of log messages as a single UDP packet.
/// </summary>
private async Task SendBatchAsync(List<string> batch, CancellationToken token)
{
try
{
string combinedMessage = string.Join(Environment.NewLine, batch);
const int maxUdpSize = 60_000;
if (combinedMessage.Length > maxUdpSize)
{
combinedMessage = combinedMessage.Substring(0, maxUdpSize);
}
byte[] bytes = Encoding.UTF8.GetBytes(combinedMessage);
await _udpClient.SendAsync(bytes, bytes.Length, _options.Host, _options.Port);
}
catch (Exception ex) catch (Exception ex)
{ {
OnException?.Invoke(this, ex); OnException?.Invoke(this, ex);

View File

@@ -15,6 +15,8 @@ namespace EonaCat.Logger.EonaCatCoreLogger
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
public bool IncludeCorrelationId { get; set; } public bool IncludeCorrelationId { get; set; }
public bool EnableBatching { get; set; }
public int BatchSize { get; set; } = 10;
} }
} }

View File

@@ -26,6 +26,59 @@ namespace EonaCat.Logger.Extensions
public static class ObjectExtensions public static class ObjectExtensions
{ {
/// <summary>
/// Executes an action on the object if it satisfies a predicate.
/// </summary>
public static T If<T>(this T obj, Func<T, bool> predicate, Action<T> action)
{
if (obj != null && predicate(obj))
{
action(obj);
}
return obj;
}
/// <summary>
/// Executes an action on the object if it does NOT satisfy a predicate.
/// </summary>
public static T IfNot<T>(this T obj, Func<T, bool> predicate, Action<T> action)
{
if (obj != null && !predicate(obj))
{
action(obj);
}
return obj;
}
/// <summary>
/// Executes a function on an object if not null, returns object itself.
/// Useful for chaining.
/// </summary>
public static T Tap<T>(this T obj, Action<T> action)
{
if (obj != null)
{
action(obj);
}
return obj;
}
/// <summary>
/// Returns true if object implements a given interface.
/// </summary>
public static bool Implements<TInterface>(this object obj)
{
if (obj == null)
{
return false;
}
return typeof(TInterface).IsAssignableFrom(obj.GetType());
}
/// <summary> /// <summary>
/// Dumps any object to a string in JSON, XML, or detailed tree format. /// Dumps any object to a string in JSON, XML, or detailed tree format.
/// </summary> /// </summary>
@@ -61,6 +114,178 @@ namespace EonaCat.Logger.Extensions
} }
} }
/// <summary>
/// Returns a default value if the object is null.
/// </summary>
public static T OrDefault<T>(this T obj, T defaultValue = default) =>
obj == null ? defaultValue : obj;
/// <summary>
/// Returns the object if not null; otherwise executes a function to get a fallback.
/// </summary>
public static T OrElse<T>(this T obj, Func<T> fallback) =>
obj != null ? obj : fallback();
/// <summary>
/// Converts an object to JSON string with optional formatting.
/// </summary>
public static string ToJson(this object obj, bool indented = false)
{
try
{
return obj == null
? string.Empty
: !indented ? Json.JsonHelper.ToJson(obj, Formatting.None) : Json.JsonHelper.ToJson(obj, Formatting.Indented);
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Converts an object to string safely, returns empty string if null.
/// </summary>
public static string SafeToString(this object obj) =>
obj?.ToString() ?? string.Empty;
/// <summary>
/// Checks if an object is null or default.
/// </summary>
public static bool IsNullOrDefault<T>(this T obj) =>
EqualityComparer<T>.Default.Equals(obj, default);
/// <summary>
/// Safely casts an object to a specific type, returns default if cast fails.
/// </summary>
public static T SafeCast<T>(this object obj)
{
if (obj is T variable)
{
return variable;
}
return default;
}
/// <summary>
/// Safely tries to convert object to integer.
/// </summary>
public static int ToInt(this object obj, int defaultValue = 0)
{
if (obj == null)
{
return defaultValue;
}
return int.TryParse(obj.ToString(), out var val) ? val : defaultValue;
}
/// <summary>
/// Safely tries to convert object to long.
/// </summary>
public static long ToLong(this object obj, long defaultValue = 0)
{
if (obj == null)
{
return defaultValue;
}
return long.TryParse(obj.ToString(), out var val) ? val : defaultValue;
}
/// <summary>
/// Safely tries to convert object to double.
/// </summary>
public static double ToDouble(this object obj, double defaultValue = 0)
{
if (obj == null)
{
return defaultValue;
}
return double.TryParse(obj.ToString(), out var val) ? val : defaultValue;
}
/// <summary>
/// Safely tries to convert object to bool.
/// </summary>
public static bool ToBool(this object obj, bool defaultValue = false)
{
if (obj == null)
{
return defaultValue;
}
return bool.TryParse(obj.ToString(), out var val) ? val : defaultValue;
}
/// <summary>
/// Checks if an object is of a specific type.
/// </summary>
public static bool IsType<T>(this object obj) => obj is T;
/// <summary>
/// Executes an action if the object is not null.
/// </summary>
public static void IfNotNull<T>(this T obj, Action<T> action)
{
if (obj != null)
{
action(obj);
}
}
/// <summary>
/// Executes an action if the object is null.
/// </summary>
public static void IfNull<T>(this T obj, Action action)
{
if (obj == null)
{
action();
}
}
/// <summary>
/// Wraps the object into a single-item enumerable.
/// </summary>
public static IEnumerable<T> AsEnumerable<T>(this T obj)
{
if (obj != null)
{
yield return obj;
}
}
/// <summary>
/// Safely returns a string representation with max length truncation.
/// </summary>
public static string ToSafeString(this object obj, int maxLength)
{
string str = obj.SafeToString();
return str.Length <= maxLength ? str : str.Substring(0, maxLength);
}
/// <summary>
/// Returns object hash code safely (0 if null).
/// </summary>
public static int SafeHashCode(this object obj) =>
obj?.GetHashCode() ?? 0;
/// <summary>
/// Returns the object or throws a custom exception if null.
/// </summary>
public static T OrThrow<T>(this T obj, Func<Exception> exceptionFactory)
{
if (obj == null)
{
throw exceptionFactory();
}
return obj;
}
private static string DumpJson(object currentObject, bool isDetailed) private static string DumpJson(object currentObject, bool isDetailed)
{ {
var settings = new JsonSerializerSettings var settings = new JsonSerializerSettings
@@ -421,5 +646,218 @@ namespace EonaCat.Logger.Extensions
} }
return dict; return dict;
} }
/// <summary>
/// Converts any object to a human-readable log string, including collections and nested objects.
/// </summary>
public static string ToLogString(this object obj, int maxDepth = 3, int currentDepth = 0)
{
if (obj == null)
{
return "null";
}
if (currentDepth >= maxDepth)
{
return "...";
}
// Handle strings separately
if (obj is string str)
{
return str;
}
// Handle IEnumerable
if (obj is IEnumerable enumerable)
{
var items = new List<string>();
foreach (var item in enumerable)
{
items.Add(item.ToLogString(maxDepth, currentDepth + 1));
}
return "[" + string.Join(", ", items) + "]";
}
// Handle primitive types
var type = obj.GetType();
if (type.IsPrimitive || obj is decimal || obj is DateTime || obj is Guid)
{
return obj.ToString();
}
// Handle objects with properties
try
{
var props = type.GetProperties();
var sb = new StringBuilder("{");
bool first = true;
foreach (var p in props)
{
if (!first)
{
sb.Append(", ");
}
var val = p.GetValue(obj);
sb.Append($"{p.Name}={val.ToLogString(maxDepth, currentDepth + 1)}");
first = false;
}
sb.Append("}");
return sb.ToString();
}
catch
{
return obj.ToString();
}
}
/// <summary>
/// Checks if an object is considered "empty": null, empty string, empty collection.
/// </summary>
public static bool IsEmpty(this object obj)
{
if (obj == null)
{
return true;
}
if (obj is string str)
{
return string.IsNullOrWhiteSpace(str);
}
if (obj is ICollection col)
{
return col.Count == 0;
}
if (obj is IEnumerable enumerable)
{
return !enumerable.Cast<object>().Any();
}
return false;
}
/// <summary>
/// Executes an action if the object is not null and not empty.
/// </summary>
public static void IfNotEmpty<T>(this T obj, Action<T> action)
{
if (!obj.IsEmpty())
{
action(obj);
}
}
/// <summary>
/// Returns a default value if the object is null or empty.
/// </summary>
public static T OrDefaultIfEmpty<T>(this T obj, T defaultValue)
{
return obj.IsEmpty() ? defaultValue : obj;
}
/// <summary>
/// Returns true if the object is numeric (int, float, double, decimal, long, etc.).
/// </summary>
public static bool IsNumeric(this object obj)
{
if (obj == null)
{
return false;
}
return double.TryParse(obj.ToString(), out _);
}
/// <summary>
/// Converts an object to a numeric double, returns default if conversion fails.
/// </summary>
public static double ToDoubleSafe(this object obj, double defaultValue = 0)
{
if (obj == null)
{
return defaultValue;
}
return double.TryParse(obj.ToString(), out var d) ? d : defaultValue;
}
/// <summary>
/// Converts an object to a numeric int, returns default if conversion fails.
/// </summary>
public static int ToIntSafe(this object obj, int defaultValue = 0)
{
if (obj == null)
{
return defaultValue;
}
return int.TryParse(obj.ToString(), out var i) ? i : defaultValue;
}
/// <summary>
/// Returns the type name of an object safely.
/// </summary>
public static string GetTypeName(this object obj) =>
obj?.GetType().Name ?? "null";
/// <summary>
/// Executes a function if object is not null and returns a fallback value otherwise.
/// </summary>
public static TResult Map<T, TResult>(this T obj, Func<T, TResult> mapper, TResult fallback = default)
{
if (obj == null)
{
return fallback;
}
return mapper(obj);
}
/// <summary>
/// Masks sensitive strings (like passwords, tokens). Keeps first and last 2 characters visible.
/// </summary>
public static string MaskSensitive(this string str)
{
if (string.IsNullOrEmpty(str) || str.Length <= 4)
{
return "****";
}
int len = str.Length - 4;
return str.Substring(0, 2) + new string('*', len) + str.Substring(str.Length - 2, 2);
}
/// <summary>
/// Masks sensitive data in any object property that matches a keyword.
/// </summary>
public static void MaskProperties(this object obj, params string[] keywords)
{
if (obj == null || keywords == null || keywords.Length == 0)
{
return;
}
var props = obj.GetType().GetProperties();
foreach (var p in props)
{
if (!p.CanRead || !p.CanWrite)
{
continue;
}
if (keywords.Any(k => p.Name.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0))
{
var val = p.GetValue(obj) as string;
if (!string.IsNullOrEmpty(val))
{
p.SetValue(obj, val.MaskSensitive());
}
}
}
}
} }
} }