diff --git a/docfx/docs/custom-converters.md b/docfx/docs/custom-converters.md index 912bed23..1c328058 100644 --- a/docfx/docs/custom-converters.md +++ b/docfx/docs/custom-converters.md @@ -70,7 +70,6 @@ The @PolyType.GenerateShapeAttribute`1 is what enables `FooConverter` to be a "p Arrays of a type require a shape of their own. So even if you define your type `MyType` with @PolyType.GenerateShapeAttribute`1, serializing `MyType[]` would require a witness type and attribute. For example: - # [.NET](#tab/net) [!code-csharp[](../../samples/CustomConverters.cs#ArrayWitnessOnFormatterNET)] @@ -119,6 +118,41 @@ The following sample demonstrates using the @Nerdbank.MessagePack.MessagePackStr [!code-csharp[](../../samples/CustomConverters.cs#MessagePackStringUser)] +### Stateful converters + +Converters are usually stateless, meaning that they have no fields and serialize/deserialize strictly on the inputs provided them via their parameters. + +When converters have stateful fields, they cannot be used concurrently with different values in those fields. +Creating multiple instances of those converters with different values in those fields requires creating unique instances of @Nerdbank.MessagePack.MessagePackSerializer which each incur a startup cost while they create and cache the rest of the converters necessary for your data model. + +For higher performance, configure one @Nerdbank.MessagePack.MessagePackSerializer instance with one set of converters. +Your converters can be stateful by accessing state in the @Nerdbank.MessagePack.SerializationContext parameter instead of fields on the converter itself. + +For example, suppose your custom converter serializes data bound for a particular RPC connection and must access state associated with that connection. +This can be achieved as follows: + +1. Store the state in the @Nerdbank.MessagePack.SerializationContext via its @Nerdbank.MessagePack.SerializationContext.Item(System.Object)?displayProperty=nameWithType indexer. +1. Apply that @Nerdbank.MessagePack.SerializationContext to a @Nerdbank.MessagePack.MessagePackSerializer by setting its @Nerdbank.MessagePack.MessagePackSerializer.StartingContext property. +1. Your custom converter can then retrieve that state during serialization/deserialization via that same @Nerdbank.MessagePack.SerializationContext.Item(System.Object)?displayProperty=nameWithType indexer. + +# [.NET](#tab/net) + +[!code-csharp[](../../samples/CustomConverters.cs#StatefulNET)] + +# [.NET Standard](#tab/netfx) + +[!code-csharp[](../../samples/CustomConverters.cs#StatefulNETFX)] + +--- + +When the state object stored in the @Nerdbank.MessagePack.SerializationContext is a mutable reference type, the converters *may* mutate it such that they or others can observe those changes later. +Consider the thread-safety implications of doing this if that same mutable state object is shared across multiple serializations that may happen on different threads in parallel. + +Converters that change the state dictionary itself (by using @"Nerdbank.MessagePack.SerializationContext.Item(System.Object)?displayProperty=nameWithType") can expect those changes to propagate only to their callees. + +Strings can serve as convenient keys, but may collide with the same string used by another part of the data model for another purpose. +Make your strings sufficiently unique to avoid collisions, or use a `static readonly object MyKey = new object()` field that you expose such that all interested parties can access the object for a key that is guaranteed to be unique. + ### Async converters @Nerdbank.MessagePack.MessagePackConverter`1 is an abstract class that requires a derived converter to implement synchronous @Nerdbank.MessagePack.MessagePackConverter`1.Write* and @Nerdbank.MessagePack.MessagePackConverter`1.Read* methods. diff --git a/samples/CustomConverters.cs b/samples/CustomConverters.cs index 5732bc31..fd6339af 100644 --- a/samples/CustomConverters.cs +++ b/samples/CustomConverters.cs @@ -454,3 +454,94 @@ public override void Write(ref MessagePackWriter writer, in MyCustomType? value, } #endregion } + +namespace Stateful +{ +#if NET + #region StatefulNET + class Program + { + static void Main() + { + MessagePackSerializer serializer = new() + { + StartingContext = new SerializationContext + { + ["ValueMultiplier"] = 3, + }, + }; + SpecialType original = new(5); + Console.WriteLine($"Original value: {original}"); + byte[] msgpack = serializer.Serialize(original); + Console.WriteLine(MessagePackSerializer.ConvertToJson(msgpack)); + SpecialType deserialized = serializer.Deserialize(msgpack); + Console.WriteLine($"Deserialized value: {deserialized}"); + } + } + + class StatefulConverter : MessagePackConverter + { + public override SpecialType Read(ref MessagePackReader reader, SerializationContext context) + { + int multiplier = (int)context["ValueMultiplier"]!; + int serializedValue = reader.ReadInt32(); + return new SpecialType(serializedValue / multiplier); + } + + public override void Write(ref MessagePackWriter writer, in SpecialType value, SerializationContext context) + { + int multiplier = (int)context["ValueMultiplier"]!; + writer.Write(value.Value * multiplier); + } + } + + [GenerateShape] + [MessagePackConverter(typeof(StatefulConverter))] + partial record struct SpecialType(int Value); + #endregion +#else + #region StatefulNETFX + class Program + { + static void Main() + { + MessagePackSerializer serializer = new() + { + StartingContext = new SerializationContext + { + ["ValueMultiplier"] = 3, + }, + }; + SpecialType original = new(5); + Console.WriteLine($"Original value: {original}"); + byte[] msgpack = serializer.Serialize(original, Witness.ShapeProvider); + Console.WriteLine(MessagePackSerializer.ConvertToJson(msgpack)); + SpecialType deserialized = serializer.Deserialize(msgpack, Witness.ShapeProvider); + Console.WriteLine($"Deserialized value: {deserialized}"); + } + } + + class StatefulConverter : MessagePackConverter + { + public override SpecialType Read(ref MessagePackReader reader, SerializationContext context) + { + int multiplier = (int)context["ValueMultiplier"]!; + int serializedValue = reader.ReadInt32(); + return new SpecialType(serializedValue / multiplier); + } + + public override void Write(ref MessagePackWriter writer, in SpecialType value, SerializationContext context) + { + int multiplier = (int)context["ValueMultiplier"]!; + writer.Write(value.Value * multiplier); + } + } + + [MessagePackConverter(typeof(StatefulConverter))] + partial record struct SpecialType(int Value); + + [GenerateShape] + partial class Witness; + #endregion +#endif +} diff --git a/src/Nerdbank.MessagePack/ConverterCache.cs b/src/Nerdbank.MessagePack/ConverterCache.cs new file mode 100644 index 00000000..766bd1bc --- /dev/null +++ b/src/Nerdbank.MessagePack/ConverterCache.cs @@ -0,0 +1,404 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft; +using PolyType.Utilities; + +namespace Nerdbank.MessagePack; + +/// +/// Tracks all inputs to converter construction and caches the results of construction itself. +/// +/// +/// This type offers something of an information barrier to converter construction. +/// The only gets a reference to this object, +/// and this object does not have a reference to . +/// This ensures that properties on cannot serve as inputs to the converters. +/// Thus, the only properties that should reset the are those declared on this type. +/// +internal record class ConverterCache +{ + private readonly ConcurrentDictionary userProvidedConverters = new(); + private readonly ConcurrentDictionary userProvidedKnownSubTypes = new(); + + private MultiProviderTypeCache? cachedConverters; + +#if NET + private MultiDimensionalArrayFormat multiDimensionalArrayFormat = MultiDimensionalArrayFormat.Nested; +#endif + + private bool preserveReferences; + private bool serializeEnumValuesByName; + private bool serializeDefaultValues; + private bool internStrings; + private bool disableHardwareAcceleration; + private MessagePackNamingPolicy? propertyNamingPolicy; + +#if NET + + /// + /// Gets the format to use when serializing multi-dimensional arrays. + /// + internal MultiDimensionalArrayFormat MultiDimensionalArrayFormat + { + get => this.multiDimensionalArrayFormat; + init => this.ChangeSetting(ref this.multiDimensionalArrayFormat, value); + } +#endif + + /// + /// Gets a value indicating whether to preserve reference equality when serializing objects. + /// + /// The default value is . + /// + /// + /// When , if an object appears multiple times in a serialized object graph, it will be serialized at each location. + /// This has two outcomes: redundant data leading to larger serialized payloads and the loss of reference equality when deserialized. + /// This is the default behavior because it requires no msgpack extensions and is compatible with all msgpack readers. + /// + /// + /// When , every object is serialized normally the first time it appears in the object graph. + /// Each subsequent type the object appears in the object graph, it is serialized as a reference to the first occurrence. + /// This reference requires between 3-6 bytes of overhead per reference instead of whatever the object's by-value representation would have required. + /// Upon deserialization, all objects that were shared across the object graph will also be shared across the deserialized object graph. + /// Of course there will not be reference equality between the original and deserialized objects, but the deserialized objects will have reference equality with each other. + /// This option utilizes a proprietary msgpack extension and can only be deserialized by libraries that understand this extension. + /// There is a small perf penalty for this feature, but depending on the object graph it may turn out to improve performance due to avoiding redundant serializations. + /// + /// + /// Reference cycles (where an object refers to itself or to another object that eventually refers back to it) are not supported in either mode. + /// When this property is , an exception will be thrown when a cycle is detected. + /// When this property is , a cycle will eventually result in a being thrown. + /// + /// + internal bool PreserveReferences + { + get => this.preserveReferences; + init + { + if (this.ChangeSetting(ref this.preserveReferences, value)) + { + // Extra steps must be taken when this property changes because + // we apply this setting to user-provided converters as they are added. + this.ReconfigureUserProvidedConverters(); + } + } + } + + /// + /// Gets a value indicating whether enum values will be serialized by name rather than by their numeric value. + /// + /// + /// + /// Serializing by name is a best effort. + /// Most enums do not define a name for every possible value, and flags enums may have complicated string representations when multiple named enum elements are combined to form a value. + /// When a simple string cannot be constructed for a given value, the numeric form is used. + /// + /// + /// When deserializing enums by name, name matching is case insensitive unless the enum type defines multiple values with names that are only distinguished by case. + /// + /// + internal bool SerializeEnumValuesByName + { + get => this.serializeEnumValuesByName; + init => this.ChangeSetting(ref this.serializeEnumValuesByName, value); + } + + /// + /// Gets a value indicating whether to serialize properties that are set to their default values. + /// + /// The default value is . + /// + /// + /// By default, the serializer omits properties and fields that are set to their default values when serializing objects. + /// This property can be used to override that behavior and serialize all properties and fields, regardless of their value. + /// + /// + /// This property currently only impacts objects serialized as maps (i.e. types that are not using on their members), + /// but this could be expanded to truncate value arrays as well. + /// + /// + /// Default values are assumed to be default(TPropertyType) except where overridden, as follows: + /// + /// Primary constructor default parameter values. e.g. record Person(int Age = 18) + /// Properties or fields attributed with . e.g. [DefaultValue(18)] internal int Age { get; set; } + /// + /// + /// + internal bool SerializeDefaultValues + { + get => this.serializeDefaultValues; + init => this.ChangeSetting(ref this.serializeDefaultValues, value); + } + + /// + /// Gets a value indicating whether to intern strings during deserialization. + /// + /// + /// + /// String interning means that a string that appears multiple times (within a single deserialization or across many) + /// in the msgpack data will be deserialized as the same instance, reducing GC pressure. + /// + /// + /// When enabled, all deserialized are retained with a weak reference, allowing them to be garbage collected + /// while also being reusable for future deserializations as long as they are in memory. + /// + /// + /// This feature has a positive impact on memory usage but may have a negative impact on performance due to searching + /// through previously deserialized strings to find a match. + /// If your application is performance sensitive, you should measure the impact of this feature on your application. + /// + /// + /// This feature is orthogonal and complementary to . + /// Preserving references impacts the serialized result and can hurt interoperability if the other party is not using the same feature. + /// Preserving references also does not guarantee that equal strings will be reused because the original serialization may have had + /// multiple string objects for the same value, so deserialization would produce the same result. + /// Preserving references alone will never reuse strings across top-level deserialization operations either. + /// Interning strings however, has no impact on the serialized result and is always safe to use. + /// Interning strings will guarantee string objects are reused within and across deserialization operations so long as their values are equal. + /// The combination of the two features will ensure the most compact msgpack, and will produce faster deserialization times than string interning alone. + /// Combining the two features also activates special behavior to ensure that serialization only writes a string once + /// and references that string later in that same serialization, even if the equal strings were unique objects. + /// + /// + internal bool InternStrings + { + get => this.internStrings; + init => this.ChangeSetting(ref this.internStrings, value); + } + + /// + /// Gets the transformation function to apply to property names before serializing them. + /// + /// + /// The default value is null, indicating that property names should be persisted exactly as they are declared in .NET. + /// + internal MessagePackNamingPolicy? PropertyNamingPolicy + { + get => this.propertyNamingPolicy; + init => this.ChangeSetting(ref this.propertyNamingPolicy, value); + } + + /// + /// Gets a value indicating whether hardware accelerated converters should be avoided. + /// + internal bool DisableHardwareAcceleration + { + get => this.disableHardwareAcceleration; + init => this.ChangeSetting(ref this.disableHardwareAcceleration, value); + } + + /// + /// Gets all the converters this instance knows about so far. + /// + private MultiProviderTypeCache CachedConverters + { + get + { + if (this.cachedConverters is null) + { + this.cachedConverters = new() + { + DelayedValueFactory = new DelayedConverterFactory(), + ValueBuilderFactory = ctx => + { + StandardVisitor standardVisitor = new StandardVisitor(this, ctx); + if (!this.PreserveReferences) + { + return standardVisitor; + } + + ReferencePreservingVisitor visitor = new(standardVisitor); + standardVisitor.OutwardVisitor = visitor; + return standardVisitor; + }, + }; + } + + return this.cachedConverters; + } + } + + /// + /// Registers a converter for use with this serializer. + /// + /// The convertible type. + /// The converter. + /// + /// If a converter for the data type has already been cached, the new value takes its place. + /// Custom converters should be registered before serializing anything on this + /// instance of . + /// + internal void RegisterConverter(MessagePackConverter converter) + { + Requires.NotNull(converter); + this.OnChangingConfiguration(); + this.userProvidedConverters[typeof(T)] = this.PreserveReferences + ? ((IMessagePackConverterInternal)converter).WrapWithReferencePreservation() + : converter; + } + + /// + /// Registers a known sub-type mapping for a base type. + /// + /// + /// The mapping. + /// + /// + /// This method provides a runtime dynamic alternative to the otherwise simpler but static + /// , enabling scenarios such as sub-types that are not known at compile time. + /// + /// + /// This is also the only way to force the serialized schema to support sub-types in the future when + /// no sub-types are defined yet, such that they can be added later without a schema-breaking change. + /// + /// + /// A mapping provided for a given will completely replace any mapping from + /// attributes that may be applied to that same . + /// + /// + internal void RegisterKnownSubTypes(KnownSubTypeMapping mapping) + { + Requires.NotNull(mapping); + this.OnChangingConfiguration(); + this.userProvidedKnownSubTypes[typeof(TBase)] = mapping; + } + + /// + /// Gets a converter for the given type shape. + /// An existing converter is reused if one is found in the cache. + /// If a converter must be created, it is added to the cache for lookup next time. + /// + /// The data type to convert. + /// The shape of the type to convert. + /// A msgpack converter. + internal MessagePackConverter GetOrAddConverter(ITypeShape shape) + => (MessagePackConverter)this.CachedConverters.GetOrAdd(shape)!; + + /// + /// Gets a converter for the given type shape. + /// An existing converter is reused if one is found in the cache. + /// If a converter must be created, it is added to the cache for lookup next time. + /// + /// The shape of the type to convert. + /// A msgpack converter. + internal IMessagePackConverterInternal GetOrAddConverter(ITypeShape shape) + => (IMessagePackConverterInternal)this.CachedConverters.GetOrAdd(shape)!; + + /// + /// Gets a converter for the given type shape. + /// An existing converter is reused if one is found in the cache. + /// If a converter must be created, it is added to the cache for lookup next time. + /// + /// The type to convert. + /// The type shape provider. + /// A msgpack converter. + internal MessagePackConverter GetOrAddConverter(ITypeShapeProvider provider) + => (MessagePackConverter)this.CachedConverters.GetOrAddOrThrow(typeof(T), provider); + + /// + /// Gets a converter for the given type shape. + /// An existing converter is reused if one is found in the cache. + /// If a converter must be created, it is added to the cache for lookup next time. + /// + /// The type to convert. + /// The type shape provider. + /// A msgpack converter. + internal IMessagePackConverterInternal GetOrAddConverter(Type type, ITypeShapeProvider provider) + => (IMessagePackConverterInternal)this.CachedConverters.GetOrAddOrThrow(type, provider); + + /// + /// Gets a user-defined converter for the specified type if one is available. + /// + /// The data type for which a custom converter is desired. + /// Receives the converter, if the user provided one (e.g. via . + /// A value indicating whether a customer converter exists. + internal bool TryGetUserDefinedConverter([NotNullWhen(true)] out MessagePackConverter? converter) + { + if (this.userProvidedConverters.TryGetValue(typeof(T), out object? value)) + { + converter = (MessagePackConverter)value; + return true; + } + + converter = default; + return false; + } + + /// + /// Gets the runtime registered sub-types for a given base type, if any. + /// + /// The base type. + /// If sub-types are registered, receives the mapping of those sub-types to their aliases. + /// if sub-types are registered; otherwise. + internal bool TryGetDynamicSubTypes(Type baseType, [NotNullWhen(true)] out IReadOnlyDictionary? subTypes) + { + if (this.userProvidedKnownSubTypes.TryGetValue(baseType, out IKnownSubTypeMapping? mapping)) + { + subTypes = mapping.CreateSubTypesMapping(); + return true; + } + + subTypes = null; + return false; + } + + /// + /// Gets the property name that should be used when serializing a property. + /// + /// The original property name as given by . + /// The attribute provider for the property. + /// The serialized property name to use. + internal string GetSerializedPropertyName(string name, ICustomAttributeProvider? attributeProvider) + { + if (this.PropertyNamingPolicy is null) + { + return name; + } + + // If the property was decorated with [PropertyShape(Name = "...")], do *not* meddle with the property name. + if (attributeProvider?.GetCustomAttributes(typeof(PropertyShapeAttribute), false).FirstOrDefault() is PropertyShapeAttribute { Name: not null }) + { + return name; + } + + return this.PropertyNamingPolicy.ConvertName(name); + } + + /// + /// Throws if this object should not be mutated any more + /// (because serializations have already happened, so mutating again can lead to unpredictable behavior). + /// + private void OnChangingConfiguration() + { + // Once we start building converters, they may read from any properties set on this object. + // If the properties on this object are changed, we necessarily must drop all cached converters and rebuild. + // Even if this cache had a Clear method, we do *not* use it since the cache may still be in use by other + // instances of this record. + this.cachedConverters = null; + } + + private void ReconfigureUserProvidedConverters() + { + foreach (KeyValuePair pair in this.userProvidedConverters) + { + IMessagePackConverterInternal converter = (IMessagePackConverterInternal)pair.Value; + this.userProvidedConverters[pair.Key] = this.PreserveReferences ? converter.WrapWithReferencePreservation() : converter.UnwrapReferencePreservation(); + } + } + + private bool ChangeSetting(ref T location, T value) + { + if (!EqualityComparer.Default.Equals(location, value)) + { + this.OnChangingConfiguration(); + location = value; + return true; + } + + return false; + } +} diff --git a/src/Nerdbank.MessagePack/JsonSchemaContext.cs b/src/Nerdbank.MessagePack/JsonSchemaContext.cs index 8bb1ca3b..548af465 100644 --- a/src/Nerdbank.MessagePack/JsonSchemaContext.cs +++ b/src/Nerdbank.MessagePack/JsonSchemaContext.cs @@ -12,7 +12,7 @@ namespace Nerdbank.MessagePack; /// public class JsonSchemaContext { - private readonly MessagePackSerializer serializer; + private readonly ConverterCache cache; private readonly Dictionary schemaReferences = new(); private readonly Dictionary schemaDefinitions = new(StringComparer.Ordinal); private readonly HashSet recursionGuard = new(); @@ -20,10 +20,10 @@ public class JsonSchemaContext /// /// Initializes a new instance of the class. /// - /// The object from which the JSON schema is being retrieved. - internal JsonSchemaContext(MessagePackSerializer serializer) + /// The object from which the JSON schema is being retrieved. + internal JsonSchemaContext(ConverterCache cache) { - this.serializer = serializer; + this.cache = cache; } /// @@ -54,7 +54,7 @@ public JsonObject GetJsonSchema(ITypeShape typeShape) return CreateReference(qualifiedReference); } - IMessagePackConverter converter = this.serializer.GetOrAddConverter(typeShape); + IMessagePackConverter converter = this.cache.GetOrAddConverter(typeShape); if (converter.GetJsonSchema(this, typeShape) is not JsonObject schema) { schema = MessagePackConverter.CreateUndocumentedSchema(converter.GetType()); diff --git a/src/Nerdbank.MessagePack/MessagePackSerializer.GetSchema.cs b/src/Nerdbank.MessagePack/MessagePackSerializer.GetSchema.cs index 86d83389..52f1a8ea 100644 --- a/src/Nerdbank.MessagePack/MessagePackSerializer.GetSchema.cs +++ b/src/Nerdbank.MessagePack/MessagePackSerializer.GetSchema.cs @@ -55,16 +55,16 @@ public JsonObject GetJsonSchema(ITypeShape typeShape) throw new NotSupportedException($"Schema generation is not supported when {nameof(this.PreserveReferences)} is enabled."); } - return new JsonSchemaGenerator(this).GenerateSchema(typeShape); + return new JsonSchemaGenerator(this.converterCache).GenerateSchema(typeShape); } private sealed class JsonSchemaGenerator : ITypeShapeFunc { private readonly JsonSchemaContext context; - internal JsonSchemaGenerator(MessagePackSerializer serializer) + internal JsonSchemaGenerator(ConverterCache cache) { - this.context = new JsonSchemaContext(serializer); + this.context = new JsonSchemaContext(cache); } object? ITypeShapeFunc.Invoke(ITypeShape typeShape, object? state) => this.context.GetJsonSchema(typeShape); diff --git a/src/Nerdbank.MessagePack/MessagePackSerializer.cs b/src/Nerdbank.MessagePack/MessagePackSerializer.cs index 83b5ace9..60b34a7b 100644 --- a/src/Nerdbank.MessagePack/MessagePackSerializer.cs +++ b/src/Nerdbank.MessagePack/MessagePackSerializer.cs @@ -29,142 +29,54 @@ namespace Nerdbank.MessagePack; /// public partial record MessagePackSerializer { - private readonly object lazyInitCookie = new(); - - private readonly ConcurrentDictionary userProvidedConverters = new(); - - private readonly ConcurrentDictionary userProvidedKnownSubTypes = new(); - - private bool configurationLocked; - - private MultiProviderTypeCache? cachedConverters; - private bool preserveReferences; + private ConverterCache converterCache = new(); private int maxAsyncBuffer = 1 * 1024 * 1024; #if NET - /// - /// Gets the format to use when serializing multi-dimensional arrays. - /// - public MultiDimensionalArrayFormat MultiDimensionalArrayFormat { get; init; } = MultiDimensionalArrayFormat.Nested; + /// + public MultiDimensionalArrayFormat MultiDimensionalArrayFormat + { + get => this.converterCache.MultiDimensionalArrayFormat; + init => this.converterCache = this.converterCache with { MultiDimensionalArrayFormat = value }; + } #endif - /// - /// Gets the transformation function to apply to property names before serializing them. - /// - /// - /// The default value is null, indicating that property names should be persisted exactly as they are declared in .NET. - /// - public MessagePackNamingPolicy? PropertyNamingPolicy { get; init; } + /// + public MessagePackNamingPolicy? PropertyNamingPolicy + { + get => this.converterCache.PropertyNamingPolicy; + init => this.converterCache = this.converterCache with { PropertyNamingPolicy = value }; + } - /// - /// Gets a value indicating whether enum values will be serialized by name rather than by their numeric value. - /// - /// - /// - /// Serializing by name is a best effort. - /// Most enums do not define a name for every possible value, and flags enums may have complicated string representations when multiple named enum elements are combined to form a value. - /// When a simple string cannot be constructed for a given value, the numeric form is used. - /// - /// - /// When deserializing enums by name, name matching is case insensitive unless the enum type defines multiple values with names that are only distinguished by case. - /// - /// - public bool SerializeEnumValuesByName { get; init; } + /// + public bool SerializeEnumValuesByName + { + get => this.converterCache.SerializeEnumValuesByName; + init => this.converterCache = this.converterCache with { SerializeEnumValuesByName = value }; + } - /// - /// Gets a value indicating whether to serialize properties that are set to their default values. - /// - /// The default value is . - /// - /// - /// By default, the serializer omits properties and fields that are set to their default values when serializing objects. - /// This property can be used to override that behavior and serialize all properties and fields, regardless of their value. - /// - /// - /// This property currently only impacts objects serialized as maps (i.e. types that are not using on their members), - /// but this could be expanded to truncate value arrays as well. - /// - /// - /// Default values are assumed to be default(TPropertyType) except where overridden, as follows: - /// - /// Primary constructor default parameter values. e.g. record Person(int Age = 18) - /// Properties or fields attributed with . e.g. [DefaultValue(18)] public int Age { get; set; } - /// - /// - /// - public bool SerializeDefaultValues { get; init; } + /// + public bool SerializeDefaultValues + { + get => this.converterCache.SerializeDefaultValues; + init => this.converterCache = this.converterCache with { SerializeDefaultValues = value }; + } - /// - /// Gets a value indicating whether to preserve reference equality when serializing objects. - /// - /// The default value is . - /// - /// - /// When , if an object appears multiple times in a serialized object graph, it will be serialized at each location. - /// This has two outcomes: redundant data leading to larger serialized payloads and the loss of reference equality when deserialized. - /// This is the default behavior because it requires no msgpack extensions and is compatible with all msgpack readers. - /// - /// - /// When , every object is serialized normally the first time it appears in the object graph. - /// Each subsequent type the object appears in the object graph, it is serialized as a reference to the first occurrence. - /// This reference requires between 3-6 bytes of overhead per reference instead of whatever the object's by-value representation would have required. - /// Upon deserialization, all objects that were shared across the object graph will also be shared across the deserialized object graph. - /// Of course there will not be reference equality between the original and deserialized objects, but the deserialized objects will have reference equality with each other. - /// This option utilizes a proprietary msgpack extension and can only be deserialized by libraries that understand this extension. - /// There is a small perf penalty for this feature, but depending on the object graph it may turn out to improve performance due to avoiding redundant serializations. - /// - /// - /// Reference cycles (where an object refers to itself or to another object that eventually refers back to it) are not supported in either mode. - /// When this property is , an exception will be thrown when a cycle is detected. - /// When this property is , a cycle will eventually result in a being thrown. - /// - /// + /// public bool PreserveReferences { - get => this.preserveReferences; - init - { - if (this.preserveReferences != value) - { - this.preserveReferences = value; - this.ReconfigureUserProvidedConverters(); - } - } + get => this.converterCache.PreserveReferences; + init => this.converterCache = this.converterCache with { PreserveReferences = value }; } - /// - /// Gets a value indicating whether to intern strings during deserialization. - /// - /// - /// - /// String interning means that a string that appears multiple times (within a single deserialization or across many) - /// in the msgpack data will be deserialized as the same instance, reducing GC pressure. - /// - /// - /// When enabled, all deserialized are retained with a weak reference, allowing them to be garbage collected - /// while also being reusable for future deserializations as long as they are in memory. - /// - /// - /// This feature has a positive impact on memory usage but may have a negative impact on performance due to searching - /// through previously deserialized strings to find a match. - /// If your application is performance sensitive, you should measure the impact of this feature on your application. - /// - /// - /// This feature is orthogonal and complementary to . - /// Preserving references impacts the serialized result and can hurt interoperability if the other party is not using the same feature. - /// Preserving references also does not guarantee that equal strings will be reused because the original serialization may have had - /// multiple string objects for the same value, so deserialization would produce the same result. - /// Preserving references alone will never reuse strings across top-level deserialization operations either. - /// Interning strings however, has no impact on the serialized result and is always safe to use. - /// Interning strings will guarantee string objects are reused within and across deserialization operations so long as their values are equal. - /// The combination of the two features will ensure the most compact msgpack, and will produce faster deserialization times than string interning alone. - /// Combining the two features also activates special behavior to ensure that serialization only writes a string once - /// and references that string later in that same serialization, even if the equal strings were unique objects. - /// - /// - public bool InternStrings { get; init; } + /// + public bool InternStrings + { + get => this.converterCache.InternStrings; + init => this.converterCache = this.converterCache with { InternStrings = value }; + } /// /// Gets the extension type codes to use for library-reserved extension types. @@ -210,88 +122,17 @@ public int MaxAsyncBuffer /// /// Gets a value indicating whether hardware accelerated converters should be avoided. /// - internal bool DisableHardwareAcceleration { get; init; } - - /// - /// Gets all the converters this instance knows about so far. - /// - private MultiProviderTypeCache CachedConverters + internal bool DisableHardwareAcceleration { - get - { - if (this.cachedConverters is null) - { - lock (this.lazyInitCookie) - { - this.cachedConverters ??= new() - { - DelayedValueFactory = new DelayedConverterFactory(), - ValueBuilderFactory = ctx => - { - StandardVisitor standardVisitor = new StandardVisitor(this, ctx); - if (!this.PreserveReferences) - { - return standardVisitor; - } - - ReferencePreservingVisitor visitor = new(standardVisitor); - standardVisitor.OutwardVisitor = visitor; - return standardVisitor; - }, - }; - } - } - - return this.cachedConverters; - } + get => this.converterCache.DisableHardwareAcceleration; + init => this.converterCache = this.converterCache with { DisableHardwareAcceleration = value }; } - /// - /// Registers a converter for use with this serializer. - /// - /// The convertible type. - /// The converter. - /// - /// If a converter for the data type has already been cached, the new value takes its place. - /// Custom converters should be registered before serializing anything on this - /// instance of . - /// - /// Thrown if serialization has already occurred. All calls to this method should be made before anything is serialized. - public void RegisterConverter(MessagePackConverter converter) - { - Requires.NotNull(converter); - this.VerifyConfigurationIsNotLocked(); - this.userProvidedConverters[typeof(T)] = this.PreserveReferences - ? ((IMessagePackConverterInternal)converter).WrapWithReferencePreservation() - : converter; - } + /// + public void RegisterConverter(MessagePackConverter converter) => this.converterCache.RegisterConverter(converter); - /// - /// Registers a known sub-type mapping for a base type. - /// - /// - /// The mapping. - /// - /// - /// This method provides a runtime dynamic alternative to the otherwise simpler but static - /// , enabling scenarios such as sub-types that are not known at compile time. - /// - /// - /// This is also the only way to force the serialized schema to support sub-types in the future when - /// no sub-types are defined yet, such that they can be added later without a schema-breaking change. - /// - /// - /// A mapping provided for a given will completely replace any mapping from - /// attributes that may be applied to that same . - /// - /// - /// Thrown if serialization has already occurred. All calls to this method should be made before anything is serialized. - public void RegisterKnownSubTypes(KnownSubTypeMapping mapping) - { - Requires.NotNull(mapping); - this.VerifyConfigurationIsNotLocked(); - this.userProvidedKnownSubTypes[typeof(TBase)] = mapping; - } + /// + public void RegisterKnownSubTypes(KnownSubTypeMapping mapping) => this.converterCache.RegisterKnownSubTypes(mapping); /// /// Serializes an untyped value. @@ -311,7 +152,7 @@ public void SerializeObject(ref MessagePackWriter writer, object? value, ITypeSh Requires.NotNull(shape); using DisposableSerializationContext context = this.CreateSerializationContext(shape.Provider, cancellationToken); - this.GetOrAddConverter(shape).Write(ref writer, value, context.Value); + this.converterCache.GetOrAddConverter(shape).Write(ref writer, value, context.Value); } /// @@ -326,7 +167,7 @@ public void Serialize(ref MessagePackWriter writer, in T? value, ITypeShape @@ -340,7 +181,7 @@ public void Serialize(ref MessagePackWriter writer, in T? value, ITypeShape(ref MessagePackWriter writer, in T? value, ITypeShapeProvider provider, CancellationToken cancellationToken = default) { using DisposableSerializationContext context = this.CreateSerializationContext(provider, cancellationToken); - this.GetOrAddConverter(provider).Write(ref writer, value, context.Value); + this.converterCache.GetOrAddConverter(provider).Write(ref writer, value, context.Value); } /// @@ -358,7 +199,7 @@ public void Serialize(ref MessagePackWriter writer, in T? value, ITypeShapePr Requires.NotNull(shape); using DisposableSerializationContext context = this.CreateSerializationContext(shape.Provider, cancellationToken); - return this.GetOrAddConverter(shape).Read(ref reader, context.Value); + return this.converterCache.GetOrAddConverter(shape).Read(ref reader, context.Value); } /// @@ -373,7 +214,7 @@ public void Serialize(ref MessagePackWriter writer, in T? value, ITypeShapePr { Requires.NotNull(shape); using DisposableSerializationContext context = this.CreateSerializationContext(shape.Provider, cancellationToken); - return this.GetOrAddConverter(shape).Read(ref reader, context.Value); + return this.converterCache.GetOrAddConverter(shape).Read(ref reader, context.Value); } /// @@ -391,7 +232,7 @@ public void Serialize(ref MessagePackWriter writer, in T? value, ITypeShapePr public T? Deserialize(ref MessagePackReader reader, ITypeShapeProvider provider, CancellationToken cancellationToken = default) { using DisposableSerializationContext context = this.CreateSerializationContext(provider, cancellationToken); - return this.GetOrAddConverter(provider).Read(ref reader, context.Value); + return this.converterCache.GetOrAddConverter(provider).Read(ref reader, context.Value); } /// @@ -412,7 +253,7 @@ public async ValueTask SerializeAsync(PipeWriter writer, T? value, ITypeShape #pragma warning disable NBMsgPackAsync MessagePackAsyncWriter asyncWriter = new(writer); using DisposableSerializationContext context = this.CreateSerializationContext(shape.Provider, cancellationToken); - await this.GetOrAddConverter(shape).WriteAsync(asyncWriter, value, context.Value).ConfigureAwait(false); + await this.converterCache.GetOrAddConverter(shape).WriteAsync(asyncWriter, value, context.Value).ConfigureAwait(false); asyncWriter.Flush(); #pragma warning restore NBMsgPackAsync } @@ -434,7 +275,7 @@ public async ValueTask SerializeAsync(PipeWriter writer, T? value, ITypeShape #pragma warning disable NBMsgPackAsync MessagePackAsyncWriter asyncWriter = new(writer); using DisposableSerializationContext context = this.CreateSerializationContext(provider, cancellationToken); - await this.GetOrAddConverter(provider).WriteAsync(asyncWriter, value, context.Value).ConfigureAwait(false); + await this.converterCache.GetOrAddConverter(provider).WriteAsync(asyncWriter, value, context.Value).ConfigureAwait(false); asyncWriter.Flush(); #pragma warning restore NBMsgPackAsync } @@ -448,7 +289,7 @@ public async ValueTask SerializeAsync(PipeWriter writer, T? value, ITypeShape /// A cancellation token. /// The deserialized value. public ValueTask DeserializeAsync(PipeReader reader, ITypeShape shape, CancellationToken cancellationToken = default) - => this.DeserializeAsync(Requires.NotNull(reader), Requires.NotNull(shape).Provider, this.GetOrAddConverter(shape), cancellationToken); + => this.DeserializeAsync(Requires.NotNull(reader), Requires.NotNull(shape).Provider, this.converterCache.GetOrAddConverter(shape), cancellationToken); /// /// Deserializes a value from a . @@ -459,7 +300,7 @@ public async ValueTask SerializeAsync(PipeWriter writer, T? value, ITypeShape /// A cancellation token. /// The deserialized value. public ValueTask DeserializeAsync(PipeReader reader, ITypeShapeProvider provider, CancellationToken cancellationToken = default) - => this.DeserializeAsync(Requires.NotNull(reader), Requires.NotNull(provider), this.GetOrAddConverter(provider), cancellationToken); + => this.DeserializeAsync(Requires.NotNull(reader), Requires.NotNull(provider), this.converterCache.GetOrAddConverter(provider), cancellationToken); /// public static string ConvertToJson(ReadOnlyMemory msgpack) => ConvertToJson(new ReadOnlySequence(msgpack)); @@ -616,107 +457,6 @@ static void WriteJsonString(string value, TextWriter builder) } } - /// - /// Gets a converter for the given type shape. - /// An existing converter is reused if one is found in the cache. - /// If a converter must be created, it is added to the cache for lookup next time. - /// - /// The data type to convert. - /// The shape of the type to convert. - /// A msgpack converter. - internal MessagePackConverter GetOrAddConverter(ITypeShape shape) - => (MessagePackConverter)this.CachedConverters.GetOrAdd(shape)!; - - /// - /// Gets a converter for the given type shape. - /// An existing converter is reused if one is found in the cache. - /// If a converter must be created, it is added to the cache for lookup next time. - /// - /// The shape of the type to convert. - /// A msgpack converter. - internal IMessagePackConverterInternal GetOrAddConverter(ITypeShape shape) - => (IMessagePackConverterInternal)this.CachedConverters.GetOrAdd(shape)!; - - /// - /// Gets a converter for the given type shape. - /// An existing converter is reused if one is found in the cache. - /// If a converter must be created, it is added to the cache for lookup next time. - /// - /// The type to convert. - /// The type shape provider. - /// A msgpack converter. - internal MessagePackConverter GetOrAddConverter(ITypeShapeProvider provider) - => (MessagePackConverter)this.CachedConverters.GetOrAddOrThrow(typeof(T), provider); - - /// - /// Gets a converter for the given type shape. - /// An existing converter is reused if one is found in the cache. - /// If a converter must be created, it is added to the cache for lookup next time. - /// - /// The type to convert. - /// The type shape provider. - /// A msgpack converter. - internal IMessagePackConverterInternal GetOrAddConverter(Type type, ITypeShapeProvider provider) - => (IMessagePackConverterInternal)this.CachedConverters.GetOrAddOrThrow(type, provider); - - /// - /// Gets a user-defined converter for the specified type if one is available. - /// - /// The data type for which a custom converter is desired. - /// Receives the converter, if the user provided one (e.g. via . - /// A value indicating whether a customer converter exists. - internal bool TryGetUserDefinedConverter([NotNullWhen(true)] out MessagePackConverter? converter) - { - if (this.userProvidedConverters.TryGetValue(typeof(T), out object? value)) - { - converter = (MessagePackConverter)value; - return true; - } - - converter = default; - return false; - } - - /// - /// Gets the property name that should be used when serializing a property. - /// - /// The original property name as given by . - /// The attribute provider for the property. - /// The serialized property name to use. - internal string GetSerializedPropertyName(string name, ICustomAttributeProvider? attributeProvider) - { - if (this.PropertyNamingPolicy is null) - { - return name; - } - - // If the property was decorated with [PropertyShape(Name = "...")], do *not* meddle with the property name. - if (attributeProvider?.GetCustomAttributes(typeof(PropertyShapeAttribute), false).FirstOrDefault() is PropertyShapeAttribute { Name: not null }) - { - return name; - } - - return this.PropertyNamingPolicy.ConvertName(name); - } - - /// - /// Gets the runtime registered sub-types for a given base type, if any. - /// - /// The base type. - /// If sub-types are registered, receives the mapping of those sub-types to their aliases. - /// if sub-types are registered; otherwise. - internal bool TryGetDynamicSubTypes(Type baseType, [NotNullWhen(true)] out IReadOnlyDictionary? subTypes) - { - if (this.userProvidedKnownSubTypes.TryGetValue(baseType, out IKnownSubTypeMapping? mapping)) - { - subTypes = mapping.CreateSubTypesMapping(); - return true; - } - - subTypes = null; - return false; - } - /// /// Creates a new serialization context that is ready to process a serialization job. /// @@ -729,26 +469,7 @@ internal bool TryGetDynamicSubTypes(Type baseType, [NotNullWhen(true)] out IRead protected DisposableSerializationContext CreateSerializationContext(ITypeShapeProvider provider, CancellationToken cancellationToken = default) { Requires.NotNull(provider); - this.configurationLocked = true; - return new(this.StartingContext.Start(this, provider, cancellationToken)); - } - - /// - /// Throws if this object should not be mutated any more - /// (because serializations have already happened, so mutating again can lead to unpredictable behavior). - /// - private void VerifyConfigurationIsNotLocked() - { - Verify.Operation(!this.configurationLocked, "This operation must be done before (de)serialization occurs."); - } - - private void ReconfigureUserProvidedConverters() - { - foreach (KeyValuePair pair in this.userProvidedConverters) - { - IMessagePackConverterInternal converter = (IMessagePackConverterInternal)pair.Value; - this.userProvidedConverters[pair.Key] = this.PreserveReferences ? converter.WrapWithReferencePreservation() : converter.UnwrapReferencePreservation(); - } + return new(this.StartingContext.Start(this, this.converterCache, provider, cancellationToken)); } /// diff --git a/src/Nerdbank.MessagePack/SerializationContext.cs b/src/Nerdbank.MessagePack/SerializationContext.cs index 28759da0..8463d80d 100644 --- a/src/Nerdbank.MessagePack/SerializationContext.cs +++ b/src/Nerdbank.MessagePack/SerializationContext.cs @@ -1,6 +1,7 @@ // Copyright (c) Andrew Arnott. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Collections.Immutable; using System.Diagnostics; using Microsoft; @@ -17,6 +18,8 @@ namespace Nerdbank.MessagePack; [DebuggerDisplay($"Depth remaining = {{{nameof(MaxDepth)}}}")] public record struct SerializationContext { + private ImmutableDictionary specialState = ImmutableDictionary.Empty; + /// /// Initializes a new instance of the struct. /// @@ -58,7 +61,7 @@ public SerializationContext() /// /// Gets the that owns this context. /// - internal MessagePackSerializer? Owner { get; private init; } + internal ConverterCache? Cache { get; private init; } /// /// Gets the reference equality tracker for this serialization operation. @@ -71,6 +74,25 @@ public SerializationContext() /// 0 when no skip operation was suspended and is still incomplete. internal int MidSkipRemainingCount { get; set; } + /// + /// Gets or sets special state to be exposed to converters during serialization. + /// + /// Any object that can act as a key in a dictionary. + /// The value stored under the specified key, or if no value has been stored under that key. + /// + /// A key-value pair is removed from the underlying dictionary by assigning a value of for a given key. + /// + /// Strings can serve as convenient keys, but may collide with the same string used by another part of the data model for another purpose. + /// Make your strings sufficiently unique to avoid collisions, or use a static readonly object MyKey = new object() field that you expose + /// such that all interested parties can access the object for a key that is guaranteed to be unique. + /// + /// + public object? this[object key] + { + get => this.specialState.TryGetValue(key, out object? value) ? value : null; + set => this.specialState = value is not null ? this.specialState.SetItem(key, value) : this.specialState.Remove(key); + } + /// /// Decrements the depth remaining and checks the cancellation token. /// @@ -93,8 +115,8 @@ public void DepthStep() public MessagePackConverter GetConverter() where T : IShapeable { - Verify.Operation(this.Owner is not null, "No serialization operation is in progress."); - MessagePackConverter result = this.Owner.GetOrAddConverter(T.GetShape()); + Verify.Operation(this.Cache is not null, "No serialization operation is in progress."); + MessagePackConverter result = this.Cache.GetOrAddConverter(T.GetShape()); return this.ReferenceEqualityTracker is null ? result : result.WrapWithReferencePreservation(); } @@ -111,8 +133,8 @@ public MessagePackConverter GetConverter() public MessagePackConverter GetConverter() where TProvider : IShapeable { - Verify.Operation(this.Owner is not null, "No serialization operation is in progress."); - MessagePackConverter result = this.Owner.GetOrAddConverter(TProvider.GetShape()); + Verify.Operation(this.Cache is not null, "No serialization operation is in progress."); + MessagePackConverter result = this.Cache.GetOrAddConverter(TProvider.GetShape()); return this.ReferenceEqualityTracker is null ? result : result.WrapWithReferencePreservation(); } #endif @@ -133,8 +155,8 @@ public MessagePackConverter GetConverter() /// public MessagePackConverter GetConverter(ITypeShapeProvider? provider) { - Verify.Operation(this.Owner is not null, "No serialization operation is in progress."); - MessagePackConverter result = this.Owner.GetOrAddConverter(provider ?? this.TypeShapeProvider ?? throw new UnreachableException()); + Verify.Operation(this.Cache is not null, "No serialization operation is in progress."); + MessagePackConverter result = this.Cache.GetOrAddConverter(provider ?? this.TypeShapeProvider ?? throw new UnreachableException()); return this.ReferenceEqualityTracker is null ? result : result.WrapWithReferencePreservation(); } @@ -149,8 +171,8 @@ public MessagePackConverter GetConverter(ITypeShapeProvider? provider) /// public IMessagePackConverter GetConverter(ITypeShape shape) { - Verify.Operation(this.Owner is not null, "No serialization operation is in progress."); - IMessagePackConverterInternal result = this.Owner.GetOrAddConverter(shape); + Verify.Operation(this.Cache is not null, "No serialization operation is in progress."); + IMessagePackConverterInternal result = this.Cache.GetOrAddConverter(shape); return this.ReferenceEqualityTracker is null ? result : result.WrapWithReferencePreservation(); } @@ -166,8 +188,8 @@ public IMessagePackConverter GetConverter(ITypeShape shape) /// public IMessagePackConverter GetConverter(Type type, ITypeShapeProvider? provider) { - Verify.Operation(this.Owner is not null, "No serialization operation is in progress."); - IMessagePackConverterInternal result = this.Owner.GetOrAddConverter(type, provider ?? this.TypeShapeProvider ?? throw new UnreachableException()); + Verify.Operation(this.Cache is not null, "No serialization operation is in progress."); + IMessagePackConverterInternal result = this.Cache.GetOrAddConverter(type, provider ?? this.TypeShapeProvider ?? throw new UnreachableException()); return this.ReferenceEqualityTracker is null ? result : result.WrapWithReferencePreservation(); } @@ -175,15 +197,16 @@ public IMessagePackConverter GetConverter(Type type, ITypeShapeProvider? provide /// Starts a new serialization operation. /// /// The owning serializer. + /// The converter cache. /// /// A cancellation token to associate with this serialization operation. /// The new context for the operation. - internal SerializationContext Start(MessagePackSerializer owner, ITypeShapeProvider provider, CancellationToken cancellationToken) + internal SerializationContext Start(MessagePackSerializer owner, ConverterCache cache, ITypeShapeProvider provider, CancellationToken cancellationToken) { return this with { - Owner = owner, - ReferenceEqualityTracker = owner.PreserveReferences ? ReusableObjectPool.Take(owner) : null, + Cache = cache, + ReferenceEqualityTracker = cache.PreserveReferences ? ReusableObjectPool.Take(owner) : null, TypeShapeProvider = provider, CancellationToken = cancellationToken, }; diff --git a/src/Nerdbank.MessagePack/StandardVisitor.cs b/src/Nerdbank.MessagePack/StandardVisitor.cs index 52105fe3..ab139715 100644 --- a/src/Nerdbank.MessagePack/StandardVisitor.cs +++ b/src/Nerdbank.MessagePack/StandardVisitor.cs @@ -63,7 +63,7 @@ internal class StandardVisitor : TypeShapeVisitor, ITypeShapeFunc private static readonly InterningStringConverter InterningStringConverter = new(); private static readonly MessagePackConverter ReferencePreservingInterningStringConverter = InterningStringConverter.WrapWithReferencePreservation(); - private readonly MessagePackSerializer owner; + private readonly ConverterCache owner; private readonly TypeGenerationContext context; /// @@ -71,7 +71,7 @@ internal class StandardVisitor : TypeShapeVisitor, ITypeShapeFunc /// /// The serializer that created this instance. Usable for obtaining settings that may influence the generated converter. /// Context for a generation of a particular data model. - internal StandardVisitor(MessagePackSerializer owner, TypeGenerationContext context) + internal StandardVisitor(ConverterCache owner, TypeGenerationContext context) { this.owner = owner; this.context = context; diff --git a/src/Nerdbank.MessagePack/net8.0/PublicAPI.Unshipped.txt b/src/Nerdbank.MessagePack/net8.0/PublicAPI.Unshipped.txt index 6cbbdb27..9c780384 100644 --- a/src/Nerdbank.MessagePack/net8.0/PublicAPI.Unshipped.txt +++ b/src/Nerdbank.MessagePack/net8.0/PublicAPI.Unshipped.txt @@ -402,6 +402,8 @@ Nerdbank.MessagePack.SerializationContext.GetConverter(PolyType.ITypeShapePro Nerdbank.MessagePack.SerializationContext.MaxDepth.get -> int Nerdbank.MessagePack.SerializationContext.MaxDepth.set -> void Nerdbank.MessagePack.SerializationContext.SerializationContext() -> void +Nerdbank.MessagePack.SerializationContext.this[object! key].get -> object? +Nerdbank.MessagePack.SerializationContext.this[object! key].set -> void Nerdbank.MessagePack.SerializationContext.TypeShapeProvider.get -> PolyType.ITypeShapeProvider? Nerdbank.MessagePack.SerializationContext.UnflushedBytesThreshold.get -> int Nerdbank.MessagePack.SerializationContext.UnflushedBytesThreshold.init -> void diff --git a/src/Nerdbank.MessagePack/netstandard2.0/PublicAPI.Unshipped.txt b/src/Nerdbank.MessagePack/netstandard2.0/PublicAPI.Unshipped.txt index be783c2b..99e459e1 100644 --- a/src/Nerdbank.MessagePack/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Nerdbank.MessagePack/netstandard2.0/PublicAPI.Unshipped.txt @@ -363,6 +363,8 @@ Nerdbank.MessagePack.SerializationContext.GetConverter(PolyType.ITypeShapePro Nerdbank.MessagePack.SerializationContext.MaxDepth.get -> int Nerdbank.MessagePack.SerializationContext.MaxDepth.set -> void Nerdbank.MessagePack.SerializationContext.SerializationContext() -> void +Nerdbank.MessagePack.SerializationContext.this[object! key].get -> object? +Nerdbank.MessagePack.SerializationContext.this[object! key].set -> void Nerdbank.MessagePack.SerializationContext.TypeShapeProvider.get -> PolyType.ITypeShapeProvider? Nerdbank.MessagePack.SerializationContext.UnflushedBytesThreshold.get -> int Nerdbank.MessagePack.SerializationContext.UnflushedBytesThreshold.init -> void diff --git a/test/Nerdbank.MessagePack.Tests/CustomConverterTests.cs b/test/Nerdbank.MessagePack.Tests/CustomConverterTests.cs index 3a259394..c7621fea 100644 --- a/test/Nerdbank.MessagePack.Tests/CustomConverterTests.cs +++ b/test/Nerdbank.MessagePack.Tests/CustomConverterTests.cs @@ -17,10 +17,17 @@ public void AttributedTypeWorksAfterFirstSerialization() } [Fact] - public void RegisterThrowsAfterFirstSerialization() + public void RegisterWorksAfterFirstSerialization() { this.AssertRoundtrip(new Tree(3)); - Assert.Throws(() => this.Serializer.RegisterConverter(new NoOpConverter())); + + // Registering a converter after serialization is allowed (but will reset the converter cache). + TreeConverter treeConverter = new(); + this.Serializer.RegisterConverter(treeConverter); + + // Verify that the converter was used. + this.AssertRoundtrip(new Tree(3)); + Assert.Equal(2, treeConverter.InvocationCount); } [Fact] @@ -37,6 +44,29 @@ public void UseNonGenericSubConverters_Shape() this.AssertRoundtrip(new CustomType { InternalProperty = "Hello, World!" }); } + [Fact] + public void StatefulConverters() + { + SerializationContext modifiedStarterContext = this.Serializer.StartingContext; + modifiedStarterContext["ValueMultiplier"] = 3; + this.Serializer = this.Serializer with + { + StartingContext = modifiedStarterContext, + }; + ReadOnlySequence msgpack = this.AssertRoundtrip(new TypeWithStatefulConverter(5)); + + // Assert that the multiplier state had the intended impact. + MessagePackReader reader = new(msgpack); + Assert.Equal(5 * 3, reader.ReadInt32()); + + // Assert that state dictionary changes made by the converter do not impact the caller. + Assert.Null(this.Serializer.StartingContext["SHOULDVANISH"]); + } + + [GenerateShape] + [MessagePackConverter(typeof(StatefulConverter))] + internal partial record struct TypeWithStatefulConverter(int Value); + [GenerateShape] public partial record Tree(int FruitCount); @@ -99,4 +129,55 @@ private class NoOpConverter : MessagePackConverter public override void Write(ref MessagePackWriter writer, in CustomType? value, SerializationContext context) => throw new NotImplementedException(); } + + /// + /// A converter that may be optionally applied at runtime. + /// It should not be referenced from via . + /// + private class TreeConverter : MessagePackConverter + { + public int InvocationCount { get; private set; } + + public override Tree? Read(ref MessagePackReader reader, SerializationContext context) + { + this.InvocationCount++; + if (reader.TryReadNil()) + { + return null; + } + + return new Tree(reader.ReadInt32()); + } + + public override void Write(ref MessagePackWriter writer, in Tree? value, SerializationContext context) + { + this.InvocationCount++; + if (value is null) + { + writer.WriteNil(); + return; + } + + writer.Write(value.FruitCount); + } + } + + private class StatefulConverter : MessagePackConverter + { + public override TypeWithStatefulConverter Read(ref MessagePackReader reader, SerializationContext context) + { + int multiplier = (int)context["ValueMultiplier"]!; + int serializedValue = reader.ReadInt32(); + return new TypeWithStatefulConverter(serializedValue / multiplier); + } + + public override void Write(ref MessagePackWriter writer, in TypeWithStatefulConverter value, SerializationContext context) + { + int multiplier = (int)context["ValueMultiplier"]!; + writer.Write(value.Value * multiplier); + + // This is used by the test to validate that additions to the state dictionary do not impact callers (though it may impact callees). + context["SHOULDVANISH"] = new object(); + } + } } diff --git a/test/Nerdbank.MessagePack.Tests/MessagePackSerializerTests.cs b/test/Nerdbank.MessagePack.Tests/MessagePackSerializerTests.cs index 49ed46f1..22f30373 100644 --- a/test/Nerdbank.MessagePack.Tests/MessagePackSerializerTests.cs +++ b/test/Nerdbank.MessagePack.Tests/MessagePackSerializerTests.cs @@ -10,6 +10,21 @@ public enum SomeEnum C, } + /// + /// Verifies that properties are independent on each instance of + /// of properties on other instances. + /// + [Fact] + public void PropertiesAreIndependent() + { + this.Serializer = this.Serializer with { SerializeEnumValuesByName = true }; + MessagePackSerializer s1 = this.Serializer with { InternStrings = true }; + MessagePackSerializer s2 = this.Serializer with { InternStrings = false }; + + s1 = s1 with { SerializeEnumValuesByName = false }; + Assert.True(s2.SerializeEnumValuesByName); + } + [Fact] public void SimpleNull() => this.AssertRoundtrip(null); diff --git a/test/Nerdbank.MessagePack.Tests/SerializationContextTests.cs b/test/Nerdbank.MessagePack.Tests/SerializationContextTests.cs index 5c032a15..aaa6e4f6 100644 --- a/test/Nerdbank.MessagePack.Tests/SerializationContextTests.cs +++ b/test/Nerdbank.MessagePack.Tests/SerializationContextTests.cs @@ -36,6 +36,47 @@ public void DepthStep_ThrowsOnStackDepth() Assert.Throws(context.DepthStep); } + [Fact] + public void StateDictionary_Add_Remove() + { + SerializationContext context = new() + { + ["first"] = "FIRST", + }; + Assert.Equal("FIRST", context["first"]); + + // Test key removal. + context["first"] = null; + Assert.Null(context["first"]); + } + + [Fact] + public void StateDictionary_NonExistent() + { + SerializationContext context = new(); + Assert.Null(context["DOESnotEXIST"]); + } + + [Fact] + public void StateDictionary_PersistentCollection() + { + SerializationContext original = new() + { + ["first"] = "FIRST", + }; + + SerializationContext derived = original; + derived["second"] = "SECOND"; + + // Both contexts have the original key. + Assert.Equal("FIRST", original["first"]); + Assert.Equal("FIRST", derived["first"]); + + // Only the derived context has the second. + Assert.Null(original["second"]); + Assert.Equal("SECOND", derived["second"]); + } + [GenerateShape] public partial class MyType; }