From 406ae08c17784d0e63a60f142300dc6d3a25f898 Mon Sep 17 00:00:00 2001 From: JKorf Date: Sun, 5 Apr 2026 20:04:52 +0200 Subject: [PATCH] Updated EnumConverter to remove allocation in best case path --- .../SystemTextJson/EnumConverter.cs | 113 +++++++++++++----- .../Testing/EnumValueTraceListener.cs | 6 + 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs b/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs index 4586a7a..4eafc67 100644 --- a/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs +++ b/CryptoExchange.Net/Converters/SystemTextJson/EnumConverter.cs @@ -82,13 +82,21 @@ namespace CryptoExchange.Net.Converters.SystemTextJson #if NET8_0_OR_GREATER private static FrozenSet? _mappingToEnum = null; private static FrozenDictionary? _mappingToString = null; + + private static bool RunOptimistic => true; #else private static List? _mappingToEnum = null; private static Dictionary? _mappingToString = null; + + // In NetStandard the `ValueTextEquals` method used is slower than just string comparing + // so only bother in newer frameworks + private static bool RunOptimistic => false; #endif private NullableEnumConverter? _nullableEnumConverter = null; + private static Type _enumType = typeof(T); private static T? _undefinedEnumValue; + private static bool _hasFlagsAttribute = _enumType.IsDefined(typeof(FlagsAttribute)); private static ConcurrentBag _unknownValuesWarned = new ConcurrentBag(); internal class NullableEnumConverter : JsonConverter @@ -153,10 +161,16 @@ namespace CryptoExchange.Net.Converters.SystemTextJson private T? ReadNullable(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options, out bool isEmptyStringOrNull) { isEmptyStringOrNull = false; - var enumType = typeof(T); if (_mappingToEnum == null) CreateMapping(); + if (RunOptimistic) + { + var resultOptimistic = GetValueOptimistic(ref reader); + if (resultOptimistic != null) + return resultOptimistic.Value; + } + var stringValue = reader.TokenType switch { JsonTokenType.String => reader.GetString(), @@ -173,8 +187,9 @@ namespace CryptoExchange.Net.Converters.SystemTextJson return null; } - if (!GetValue(enumType, stringValue, out var result)) + if (!GetValue(stringValue, out var result)) { + // Note: checking this here and before the GetValue seems redundant but it allows enum mapping for empty strings if (string.IsNullOrWhiteSpace(stringValue)) { isEmptyStringOrNull = true; @@ -185,13 +200,22 @@ namespace CryptoExchange.Net.Converters.SystemTextJson if (!_unknownValuesWarned.Contains(stringValue)) { _unknownValuesWarned.Add(stringValue!); - LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]. If you think {stringValue} should added please open an issue on the Github repo"); + LibraryHelpers.StaticLogger?.LogWarning($"Cannot map enum value. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]. If you think {stringValue} should be added please open an issue on the Github repo"); } } return null; } + if (RunOptimistic) + { + if (!_unknownValuesWarned.Contains(stringValue)) + { + _unknownValuesWarned.Add(stringValue!); + LibraryHelpers.StaticLogger?.LogTrace($"Enum mapping sub-optimal. EnumType: {_enumType.FullName}, Value: {stringValue}, Known values: [{string.Join(", ", _mappingToEnum!.Select(m => $"{m.StringValue}: {m.Value}"))}]"); + } + } + return result; } @@ -202,18 +226,40 @@ namespace CryptoExchange.Net.Converters.SystemTextJson writer.WriteStringValue(stringValue); } - private static bool GetValue(Type objectType, string value, out T? result) + /// + /// Try to get the enum value based on the string value using the Utf8JsonReader's ValueTextEquals method. + /// This is an optimization to avoid string allocations when possible, but can only match case insensitively + /// + private static T? GetValueOptimistic(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.String) + return null; + + foreach (var item in _mappingToEnum!) + { + if (reader.ValueTextEquals(item.StringValue)) + return item.Value; + } + + return null; + } + + private static bool GetValue(string value, out T? result) { if (_mappingToEnum != null) { EnumMapping? mapping = null; - // Try match on full equals - foreach (var item in _mappingToEnum) + // If we tried the optimistic path first we already know its not case match + if (!RunOptimistic) { - if (item.StringValue.Equals(value, StringComparison.Ordinal)) + // Try match on full equals + foreach (var item in _mappingToEnum) { - mapping = item; - break; + if (item.StringValue.Equals(value, StringComparison.Ordinal)) + { + mapping = item; + break; + } } } @@ -237,10 +283,10 @@ namespace CryptoExchange.Net.Converters.SystemTextJson } } - if (objectType.IsDefined(typeof(FlagsAttribute))) + if (_hasFlagsAttribute) { var intValue = int.Parse(value); - result = (T)Enum.ToObject(objectType, intValue); + result = (T)Enum.ToObject(_enumType, intValue); return true; } @@ -262,8 +308,12 @@ namespace CryptoExchange.Net.Converters.SystemTextJson try { // If no explicit mapping is found try to parse string - result = (T)Enum.Parse(objectType, value, true); - if (!Enum.IsDefined(objectType, result)) +#if NET8_0_OR_GREATER + result = Enum.Parse(value, true); +#else + result = (T)Enum.Parse(_enumType, value, true); +#endif + if (!Enum.IsDefined(_enumType, result)) { result = default; return false; @@ -280,11 +330,10 @@ namespace CryptoExchange.Net.Converters.SystemTextJson private static void CreateMapping() { - var mappingToEnum = new List(); - var mappingToString = new Dictionary(); + var mappingStringToEnum = new List(); + var mappingEnumToString = new Dictionary(); - var enumType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - var enumMembers = enumType.GetFields(); + var enumMembers = _enumType.GetFields(); foreach (var member in enumMembers) { var maps = member.GetCustomAttributes(typeof(MapAttribute), false); @@ -292,23 +341,29 @@ namespace CryptoExchange.Net.Converters.SystemTextJson { foreach (var value in attribute.Values) { - var enumVal = (T)Enum.Parse(enumType, member.Name); - mappingToEnum.Add(new EnumMapping(enumVal, value)); - if (!mappingToString.ContainsKey(enumVal)) - mappingToString.Add(enumVal, value); +#if NET8_0_OR_GREATER + var enumVal = Enum.Parse(member.Name); +#else + var enumVal = (T)Enum.Parse(_enumType, member.Name); +#endif + + mappingStringToEnum.Add(new EnumMapping(enumVal, value)); + if (!mappingEnumToString.ContainsKey(enumVal)) + mappingEnumToString.Add(enumVal, value); } } } #if NET8_0_OR_GREATER - _mappingToEnum = mappingToEnum.ToFrozenSet(); - _mappingToString = mappingToString.ToFrozenDictionary(); + _mappingToEnum = mappingStringToEnum.ToFrozenSet(); + _mappingToString = mappingEnumToString.ToFrozenDictionary(); #else - _mappingToEnum = mappingToEnum; - _mappingToString = mappingToString; + _mappingToEnum = mappingStringToEnum; + _mappingToString = mappingEnumToString; #endif } + // For testing purposes only, allows resetting the static mapping and warnings internal static void Reset() { _undefinedEnumValue = null; @@ -336,7 +391,6 @@ namespace CryptoExchange.Net.Converters.SystemTextJson /// public static T? ParseString(string value) { - var type = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); if (_mappingToEnum == null) CreateMapping(); @@ -369,8 +423,11 @@ namespace CryptoExchange.Net.Converters.SystemTextJson try { - // If no explicit mapping is found try to parse string - return (T)Enum.Parse(type, value, true); +#if NET8_0_OR_GREATER + return Enum.Parse(value, true); +#else + return (T)Enum.Parse(_enumType, value, true); +#endif } catch (Exception) { diff --git a/CryptoExchange.Net/Testing/EnumValueTraceListener.cs b/CryptoExchange.Net/Testing/EnumValueTraceListener.cs index 9dc30e8..60b4cca 100644 --- a/CryptoExchange.Net/Testing/EnumValueTraceListener.cs +++ b/CryptoExchange.Net/Testing/EnumValueTraceListener.cs @@ -15,6 +15,9 @@ namespace CryptoExchange.Net.Testing if (message.Contains("Received null or empty enum value")) throw new Exception("Enum null error: " + message); + + if (message.Contains("Enum mapping sub-optimal.")) + throw new Exception("Enum mapping error: " + message); } public override void WriteLine(string? message) @@ -27,6 +30,9 @@ namespace CryptoExchange.Net.Testing if (message.Contains("Received null or empty enum value")) throw new Exception("Enum null error: " + message); + + if (message.Contains("Enum mapping sub-optimal.")) + throw new Exception("Enum mapping error: " + message); } } }