2

Preamble: I know about ISerializable deprecation. We have to use it because we have massive legacy codebase for IPC (that uses this interface). We are migrating from .NET Remoting to a crossplatform solution. And we planned to keep existing codebase setup if it's possible.

I have a class library (with no extra dependencies) targeting .NET Standard 2.0, with some classes that implement ISerializable:

using System; using System.Runtime.Serialization; namespace DataClasses { [Serializable] internal sealed class User : ISerializable { public string Name { get; set; } public UserGroup Group { get; set; } internal User() {} private User(SerializationInfo info, StreamingContext context) { Name = (string)info.GetValue(nameof(Name), typeof(string)); Group = (UserGroup)info.GetValue(nameof(Group), typeof(UserGroup)); } public void GetObjectData(SerializationInfo info, StreamingContext context) { info.AddValue(nameof(Name), Name); info.AddValue(nameof(Group), Group); } } [Serializable] internal sealed class UserGroup : ISerializable { public string Name { get; set; } public User DefaultUser { get; set; } internal UserGroup() { } private UserGroup(SerializationInfo info, StreamingContext context) { Name = (string)info.GetValue(nameof(Name), typeof(string)); DefaultUser = (User)info.GetValue(nameof(DefaultUser), typeof(User)); } public void GetObjectData(SerializationInfo info, StreamingContext context) { info.AddValue(nameof(Name), Name); info.AddValue(nameof(DefaultUser), DefaultUser); } } } 

And I have a .NET 8.0 console application which references Newtonsoft.JSON and serializes/deserializes those classes (I use InternalsVisibleTo in class library project to make it work):

using Newtonsoft.Json; namespace NewtonsoftJSONTest { internal class Program { static void Main(string[] args) { var user = new DataClasses.User { Name = "User" }; var group = new DataClasses.UserGroup { Name = "Group", DefaultUser = user }; user.Group = group; var settings = new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore }; var json = JsonConvert.SerializeObject(user, settings); var userRestored = JsonConvert.DeserializeObject<DataClasses.User>(json); } } } 

With that config I get an exception on the line:

DefaultUser = (User)info.GetValue(nameof(DefaultUser), typeof(User)); 

System.Runtime.Serialization.SerializationException: Member 'DefaultUser' was not found.

Newtonsoft.JSON obtains SerializationInfo for an object but then doesn't include into json properties that are a reference loop.

The json is

{ "Name": "User", "Group": { "Name": "Group" } } 

so the code on line

DefaultUser = (User)info.GetValue(nameof(DefaultUser), typeof(User)); 

fails to find DefaultUser. I can implement a workaround extension method SerializationInfo.TryGetValue like that (since MS doesn't plan to add it), but is uses try catch and hence is slow.

Is there a way to get JSON with DefaultUser=null? Something like:

{ "Name": "User", "Group": { "Name": "Group", "DefaultUser": null } } 

A solution that does not use Newtonsoft.JSON attributes is highly appreciated as I don't want my class library to have external dependencies. Maybe some custom ContractResolver can help me?

Sample project repo.

UPDATE: Possible solution with PreserveReferencesHandling.Objects setting.

PS: with

ReferenceLoopHandling = ReferenceLoopHandling.Serialize 

flag it get this exception:

System.StackOverflowException: Exception of type 'System.StackOverflowException' was thrown.

PPS: looks like System.Text.Json with ReferenceHandler.IgnoreCycles option produces json that I need but it doesn't support ISerializable so is not suitable for me. Moreover System.Text.Json has its own drawbacks, for example it has no automatic type handling. I have to mark my base data classes and interfaces with JsonDerivedTypeAttribute. That 1) adds an external dependency to my class library (I try to avoid it) 2) is painfull as codebase is massive and hence inheritance tree also

8
  • 1
    "but it doesn't support ISerializable so is not suitable for me." why would you care about that if the result is what you want? Commented Jul 11 at 14:01
  • 1
    ISerializable is obsolete in .NET 8, see SYSLIB0051: Legacy serialization support APIs are obsolete. So continuing with this serialization strategy is likely to be quite painful for you as BinaryFormatter-related APIs and types are either removed or made to throw exceptions. Why are you using ISerializable? Commented Jul 11 at 15:25
  • Is your requirement that you need a JSON serialization strategy that 1) Supports custom formatting for your models, and in addition 2) Supports reference loop handling? Commented Jul 11 at 15:36
  • @Fildor @dbc I know about ISerializable deprecation. We have to use it because we have massive legacy codebase for IPC (that uses this interface). We are migrating from .NET Remoting to a crossplatform solution. And we planned to keep existing codebase setup if it's possible. Commented Jul 12 at 4:56
  • 1
    Then your problem just got 2 orders of magnitude bigger. I don't think the solution to this detail (in the question) will get you very far. Commented Jul 12 at 11:21

2 Answers 2

1

Your serialization requirements seem to include:

  • Convenient support for serialization of complex polymorphic type hierarchies in a "massive legacy codebase".

  • Convenient support for round-tripping of cyclic object graphs.

  • Ability to inject custom serialization logic for certain types without disturbing automatic serialization of remaining types.

  • No tight coupling of your legacy code base to any particular serializer.

  • Good performance without excessive throwing and catching of exceptions.

Your current strategy to meet these requirements seems to depend on technologies that are either obsoleted or in legacy support, and do not provide the necessary APIs:

  • All BinaryFormatter related technologies including [Serializable] and ISerializable have been obsoleted and will be removed entirely.

  • Json.NET seems no longer to be under active development.

  • Json.NET's JsonSerializerInternalWriter does not offer public API access to CheckForCircularReference() or its _serializeStack.

  • SerializationInfo does not does not offer public API access to GetValueNoThrow() or its IFormatterConverter _converter.

    (Json.NET sets SerializationInfo_converter to an instance of its private type FormatterConverter so you would need access to _converter if you wanted to manually loop through the serialization entries and deserialize each one manually without making direct calls to Json.NET from within your streaming constructor.)

  • The replacement for Json.NET, System.Text.Json, has its own limitations; for instance its custom converters do not work with its built-in handling of cycles, and API access is not present to make them work together easily.

Given that you seem to be at an impasse here, I would recommend solving your specific problem with minimal code: simply catch and ignore the exception inside your streaming constructors. To make it a bit simpler, introduce the following extension method:

public static class SerializationInfoExtensions { public static object? GetValueNoThrow(this SerializationInfo info, string name, Type type) { ArgumentNullException.ThrowIfNull(info); ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(type); try { return info.GetValue(name, type); } catch (SerializationException) { return null; } } } 

Then use it in your streaming constructors:

[Serializable] internal sealed class User : ISerializable { private User(SerializationInfo info, StreamingContext context) { Name = (string)info.GetValue(nameof(Name), typeof(string)); Group = (UserGroup)info.GetValueNoThrow(nameof(Group), typeof(UserGroup)); } // Remainder omitted [Serializable] internal sealed class UserGroup : ISerializable { private UserGroup(SerializationInfo info, StreamingContext context) { Name = (string)info.GetValue(nameof(Name), typeof(string)); DefaultUser = (User)info.GetValueNoThrow(nameof(DefaultUser), typeof(User)); } // Remainder omitted 

Now, as you correctly note in your question, throwing and catching exceptions is not performant, so profile your application. If performance is good enough, move on.

Demo fiddle #1 here.

But if performance is not good enough, you could use the dark arts of reflection to call the internal method SerializationInfo.GetValueNoThrow() like so:

public static class SerializationInfoExtensions { static Lazy<Func<SerializationInfo, string, Type, object?>?> GetValueNoThrowFunc = new( () => { var method = typeof(SerializationInfo).GetMethod("GetValueNoThrow", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, new [] { typeof(string), typeof(Type)}); if (method == null) return null; return (Func<SerializationInfo, string, Type, object?>)Delegate.CreateDelegate(typeof(Func<SerializationInfo, string, Type, object?>), null, method); } ); public static object? GetValueNoThrow(this SerializationInfo info, string name, Type type) { ArgumentNullException.ThrowIfNull(info); ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(type); var func = GetValueNoThrowFunc.Value; if (func != null) return func(info, name, type); // Fallback in case GetValueNoThrow() is removed by MSFT. try { return info.GetValue(name, type); } catch (SerializationException) { return null; } } } 

By caching a delegate created from the MethodInfo for GetValueNoThrow(), repeated calls should be nearly as fast as direct method calls.

Then rerun your profiles. If performance is better, use this as a stopgap while you re-evaluate your serialization architecture. Sure, this approach is fragile, but it's very unlikely that MSFT will modify SerializationInfo and remove the internal method you are calling. (Rather, it's more likely that they will remove the type entirely.)

Demo fiddle #2 here.

Sign up to request clarification or add additional context in comments.

3 Comments

Thank you. I will profile both approaches. BTW: Json.NET seems no longer to be under active development - is not fully correct. Latest version is from 2023, but there are commits in repo and new version is promised soon.
@bairog - new version is promised soon -- thanks, good to know!
0

If I change ReferenceLoopHandling = ReferenceLoopHandling.Ignore setting to PreserveReferencesHandling = PreserveReferencesHandling.Objects json becomes:

{ "$id": "1", "Name": "User", "Group": { "$id": "2", "Name": "Group", "DefaultUser": { "$ref": "1" } } } 

After that all required keys are included into SerializationInfo and there is no need to write SerializationInfo.TryGetValue extension. But as documented here for ISerializable classes references are not preserved:

References cannot be preserved when a value is set via a non-default constructor. With a non-default constructor, child values must be created before the parent value so they can be passed into the constructor, making tracking reference impossible. ISerializable types are an example of a class whose values are populated with a non-default constructor and won't work with PreserveReferencesHandling.

So DefaultUser becomes null after deserialization (which is expected tradeoff for me). But I can get rid of slow extension and later (when I also get rid of ISerializable) references will be preserved (as System.Text.Json with ReferenceHandler.IgnoreCycles setting does).

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.