The Griffin+ Serialization Library provides a serializer for almost all .NET objects.
- Serialized stream includes type metadata: The user does not need to know the type to deserialize
- Custom Serializers allow to provide serialization support for almost all .NET objects
- Internal Object Serializers allow to integrate serialization capabilities into own types
- External Object Serializers allow to add serialization capabilities to types that cannot be modified (e.g. framework types or types exported from 3rd party libraries)
- Support for versioning to address issues that occur when types evolve
- Support for generic types
- Custom Serializers are automatically detected when the application spins up
- Cross Platform
- Support for all common .NET frameworks allowing types to migrate between assemblies
- Support for interoperating with little endian and big endian machines
- Optimization: The serializer output can be optimized for size or for speed depending on purpose
- Deep Object Copy: The serializer supports to efficiently create deep copies of serializable objects, avoiding to duplicate immutable objects
- Built-In Support for serializing common types
- Boolean type:
System.Boolean
- Character type:
System.Char
- String type:
System.String
- 8-bit integer types:
System.SByte
andSystem.Byte
- 16-bit integer types:
System.Int16
andSystem.UInt16
- 32-bit integer types:
System.Int32
andSystem.UInt32
- 64-bit integer types:
System.Int64
andSystem.UInt64
- Floating point types:
System.Single
,System.Double
andSystem.Decimal
- Date/Time types:
System.DateTime
andSystem.DateTimeOffset
- Date-Only type:
System.DateOnly
(.NET 6+ only) - Time-Only type:
System.TimeOnly
(.NET 6+ only) - Type objects:
System.Type
- GUIDs:
System.Guid
- Custom Buffers (supports streams and pointers)
- Types provided by the
GriffinPlus.Lib.Common
packageGriffinPlus.Lib.Imaging.BitmapPalette
GriffinPlus.Lib.Imaging.Color
GriffinPlus.Lib.Imaging.NativeBitmap
GriffinPlus.Lib.Imaging.PixelFormat
GriffinPlus.Lib.NativeBuffer
- Generic collections shipped with the .NET Framework
System.Collection.Generic.Dictionary<TKey,TValue>
System.Collection.Generic.HashSet<T>
System.Collection.Generic.List<T>
System.Collection.Generic.LinkedList<T>
System.Collection.Generic.Queue<T>
System.Collection.Generic.SortedDictionary<TKey,TValue>
System.Collection.Generic.SortedList<TKey,TValue>
System.Collection.Generic.Stack<T>
- Collections that provide a parameterless constructor and implement at least one of the following interfaces (ordered by preference):
System.Collections.Generic.IDictionary<TKey,TValue>
System.Collections.Generic.IList<T>
System.Collections.Generic.ICollection<T>
System.Collections.IDictionary
System.Collections.IList
- Arrays of serializable objects
- Boolean type:
The library is entirely written in C# using .NET Standard 2.0.
More specific builds for .NET Standard 2.1, .NET Framework 4.6.1, .NET 5.0, .NET 6.0, .NET 7.0 and .NET 8.0 minimize dependencies to framework components and provide optimizations for the different frameworks.
Therefore it should work on the following platforms (or higher):
- .NET Framework 4.6.1
- .NET Core 2/3
- .NET 5/6/7/8
- Mono 5.4
- Xamarin iOS 10.14
- Xamarin Mac 3.8
- Xamarin Android 8.0
- Universal Windows Platform (UWP) 10.0.16299
The library is tested automatically on the following frameworks and operating systems:
- .NET Framework 4.6.1: Tests with library built for .NET Framework 4.6.1 (Windows Server 2022)
- .NET Framework 4.8: Tests with library built for .NET Framework 4.6.1 (Windows Server 2022)
- .NET Core 2.2: Tests with library built for .NET Standard 2.0 (Windows Server 2022 and Ubuntu 22.04)
- .NET Core 3.1: Tests with library built for .NET Standard 2.1 (Windows Server 2022 and Ubuntu 22.04)
- .NET 5.0: Tests with library built for .NET 5.0 (Windows Server 2022 and Ubuntu 22.04)
- .NET 6.0: Tests with library built for .NET 6.0 (Windows Server 2022 and Ubuntu 22.04)
- .NET 7.0: Tests with library built for .NET 7.0 (Windows Server 2022 and Ubuntu 22.04)
- .NET 8.0: Tests with library built for .NET 8.0 (Windows Server 2022 and Ubuntu 22.04)
Add the latest version of the GriffinPlus.Lib.Serialization
NuGet package to your project.
Add the namespace GriffinPlus.Lib.Serialization
to your source file.
using GriffinPlus.Lib.Serialization;
For best performance the Serializer
class provides the static Serialize(...)
and Deserialize(...)
methods to serialize and deserialize data. These methods use an internal serializer pool effectively reducing garbage collection pressure as serializer instances are reused.
// object to serialize
var obj = 42;
// serialize the object into a stream
var stream = new MemoryStream();
Serializer.Serialize(stream, obj, context: null, optimization: SerializationOptimization.Speed);
// rewind the stream and deserialize the object
stream.Position = 0;
var copy = Serializer.Deserialize(stream, context: null, useTolerantDeserialization: true);
Alternatively, you can create a new Serializer
instance, configure it to suit your needs and serialize/deserialize your object.
// object to serialize
var obj = 42;
// create a new serializer
var serializer = new Serializer
{
SerializationOptimization = SerializationOptimization.Speed,
UseTolerantDeserialization = false
};
// serialize the object into a stream
var stream = new MemoryStream();
serializer.Serialize(stream, obj);
// rewind the stream and deserialize the object
stream.Position = 0;
var copy = serializer.Deserialize(stream);
The property SerializationOptimization
allows to influence whether the serializer optimizes for Size
(most probably when serializing to a file or network connection) or for Speed
(most probably when staying in-process, e.g. to create a deep copy of an object). The default is to optimize for Speed
.
The property UseTolerantDeserialization
determines whether tolerant deserialization is in place. Tolerant deserialization allows to deserialize objects that were serialized on a machine with a different .NET framework. The serializer will try to exactly map to existing types when deserializing. If this fails, it will try to find the type in some other assembly. This enables the serializer to handle type migrations. Different .NET framework versions define even primitive types in different assemblies, so deserializing on some other .NET version would fail, if done without tolerance. As a side effect you can move your own types between assemblies as well. The full type name (namespace + type name) must not change. The default is false
to avoid unexpected behavior.
Very basic example of a class with an internal object serializer illustrating the parts that are relevant for serialization.
[InternalObjectSerializer(1)]
public class MyClass : IInternalObjectSerializer
{
public int Value { get; }
protected MyClass(DeserializationArchive archive)
{
if (archive.Version == 1)
{
Value = archive.ReadInt32();
}
else
{
throw new VersionNotSupportedException(archive);
}
}
void IInternalObjectSerializer.Serialize(SerializationArchive archive)
{
if (archive.Version == 1)
{
archive.Write(Value);
}
else
{
throw new VersionNotSupportedException(archive);
}
}
}
A type (class/struct) with an Internal Object Serializer has the following characteristics:
- Class annotation: The type is annotated with the
GriffinPlus.Lib.Serialization.InternalObjectSerializer
attribute specifying the maximum version supported by the serializer. The implemented internal object serializer must support all versions from1
up to the specified version number to allow deserializing older versions as well. - Interface implementation: The type implements the
GriffinPlus.Lib.Serialization.IInternalObjectSerializer
interface. - Deserialization constructor: The type provides a special constructor taking a
DeserializationArchive
which acts as an abstract userfriendly interface to the deserialization stream. The constructor is called by the serializer when deserializing an object of this type from a stream. If a serializer version is requested, but not supported, aGriffinPlus.Lib.Serialization.VersionNotSupportedException
should be thrown. The serializer also takes care of checking the maximum supported version as specified by theInternalObjectSerializer
attribute. It does not call the deserialization constructor if the requested serializer version is greater than the maximum version specified by the attribute. If you can be sure that the maximum supported version and the versions actually implemented are consistent, you can omit throwing the exception. - Serialization method: The type provides an implementation of the
IInternalObjectSerializer.Serialize()
method that takes care of writing an object of the type to a stream. TheSerialize()
method takes aSerializationArchive
exposing an userfriendly abstraction of the serialization stream. The method can be implemented public as well, but it is not recommended as this method is only meaningful in conjunction with the serializer. If a serializer version is requested, but not supported, aGriffinPlus.Lib.Serialization.VersionNotSupportedException
should be thrown. Same here, you can omit throwing the exception if you are sure that the announced maximum supported serializer version and the actually implemented versions are consistent.
Using an internal object serializer allows to serialize derived classes by delegating serialization/deserialization to the base class. The following example shows a very rudimentary class deriving from the example class above:
[InternalObjectSerializer(1)]
public class MyDerivedClass : MyClass, IInternalObjectSerializer
{
public int OtherValue { get; }
protected MyDerivedClass(DeserializationArchive archive) :
base(archive.PrepareBaseArchive())
{
if (archive.Version == 1)
{
OtherValue = archive.ReadInt32();
}
else
{
throw new VersionNotSupportedException(archive);
}
}
void IInternalObjectSerializer.Serialize(SerializationArchive archive)
{
archive.WriteBaseArchive();
if (archive.Version == 1)
{
archive.Write(OtherValue);
}
else
{
throw new VersionNotSupportedException(archive);
}
}
}
The basic outline is the same as for the base class. The only differences are the delegation to the deserialization constructor and the serialization logic of the base class.
The deserialization constructor of the base class is invoked by the deserialization constructor of the derived class passing a DeserializationArchive
that is returned by the PrepareBaseArchive()
method of the DeserializationArchive
of the derived class. The base class can evolve over time as it deserializes its own data only. It can handle versioning issues on its own, so derived classes do not need to consider them. Each and every class is only responsible for the data it encorporates.
The Serialize()
method delegates the serialization of base class members by calling SerializationArchive.WriteBaseArchive()
. This must be the first call in the Serialize()
method to avoid mixing up the order of serialized data in the output stream.
Internal object serializers do not need to be explicitly registered with the serializer as the serializer scans all assemblies for serializers when spinning up. You just need to implement it and it will be used automatically.
Another option to add serialization support for a type is to implement an external object serializer for it. This is the only way to add serialization support for types that are not under your control. Let's assume the class to serialize is the MyClass
from the example above, but without its internal object serializer.
public class MyClass
{
public int Value { get; set; }
}
An external object serializer for this class could look like the following:
[ExternalObjectSerializer(1)]
public class MyClassExternalObjectSerializer : ExternalObjectSerializer<MyClass>
{
public void Serialize(SerializationArchive archive, MyClass obj)
{
if (archive.Version == 1)
{
archive.Write(obj.Value);
}
else
{
throw new VersionNotSupportedException(archive);
}
}
public MyClass Deserialize(DeserializationArchive archive)
{
MyClass obj = new MyClass();
if (archive.Version == 1)
{
obj.Value = archive.ReadInt32();
}
else
{
throw new VersionNotSupportedException(archive);
}
return obj;
}
}
An External Object Serializer class has the following characteristics:
- Class annotation: The external object serializer is annotated with the
GriffinPlus.Lib.Serialization.ExternalObjectSerializer
attribute specifying the maximum supported version. The implemented external object serializer must support all versions from1
up to the specified version number to allow deserializing older versions as well. - Base Class: The external object serializer derives from the
GriffinPlus.Lib.Serialization.ExternalObjectSerializer<T>
class. This class is the common base class for all external object serializers. Its generic type argument determines the type the serializer will handle. The type can be a regular type, a constructed generic type, a generic type definition or an interface. While regular and constructed generic types provide support for only the specified type, a generic type definition allow you to implement an external object serializer for a generic type or a type that implements a generic interface. The serializer will take care of infering generic type arguments from the specified type when creating an instance of the external object serializer at runtime. - Serialization method: The external object serializer overrides the
ExternalObjectSerializer<T>.Serialize()
method taking care of writing an object of the supported type to the serialization stream. If a serializer version is requested, but not supported, aGriffinPlus.Lib.Serialization.VersionNotSupportedException
should be thrown. If you can be sure that the maximum supported version and the versions actually implemented are consistent, you can omit throwing the exception. - Deserialization method: The external object serializer overrides the
ExternalObjectSerializer<T>.Deserialize()
method that takes care of reading an object of the supported type from the deserialization stream. Same here, you can omit throwing the exception if you are sure that the announced maximum supported serializer version and the actually implemented versions are consistent.
By nature, an external object serializer has only access to public members of the object to serialize/deserialize, while an internal object serializer has access to all members. Therefore, an internal object serializer should preferred over an external object serializer, whereever possible.
External object serializers do not need to be explicitly registered with the serializer as the serializer scans all assemblies for serializers when spinning up. You just need to implement the external object serializer and it will be used automatically.
The serializer will throw a GriffinPlus.Lib.Serialization.CyclicDependencyDetectedException
if it detects a cyclic dependency when serializing. If you really need cyclic dependencies in your type you can use a context object to pass references downstream.
When serializing collections the serializer will not carry along comparers associated with them. This may lead to unexpected behavior, especially when expecting a deep copy to be identically. The copy of a collection will contain copies of the original collection's elements, but depending on the type of collection the behavior of the collection may differ. As long as collections are used with their default comparer everything will run as expected. Serializing a collection that has been created with a custom comparer is likely to behave differently.