2

Trying to serialize/deserialize an Action<>.

Try #1 naive by me

JsonConvert.SerializeObject(myAction);
...
JsonConvert.Deserialize<Action>(json);

Deserialize fails saying it cannot serialize Action.

Try #2

JsonConvert.DeserializeObject<Action>(ctx.SerializedJob, new JsonSerializerSettings {ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor });

Same(ish) failure.

Try # 3 Then I found http://mikehadlow.blogspot.com/2011/04/serializing-continuations.html

This uses BinaryFormatter. I dropped this in (base64 encoding the binary to a string). Worked perfectly first time.

Try #4

I then found

https://github.com/DevrexLabs/Modules.JsonNetFormatter

Which is an IFormatter module for json.net. Wired that in, same failure - cannot deserialize.

So how come BinaryFormatter can do it but Json.net cannot?

EDIT:

The general reply is - "thats the most stupid thing to want to do". Let me show what I am trying to do

MyJobSystem.AddJob(ctx=>
{
   // code to do
   // ......
}, DateTime.UtcNow + TimeSpan.FromDays(2));

Ie - execute this lambda in 2 days time.

This works fine for me. Using BinaryFormatter. I was curious about why one serializing infrastructure could do it but the other could not. They both seem to have the same rules about what can and cannot be processed

pm100
  • 48,078
  • 23
  • 82
  • 145
  • 2
    Why would you even want to serialize an action? What would you expect the result to be? – Manfred Radlwimmer Mar 06 '18 at 19:16
  • If it's just out of curiosity how BinaryFormatter does it, you can probably find out by reading through the [Reference Implementation](https://referencesource.microsoft.com/#mscorlib/system/runtime/serialization/formatters/binary/binaryobjectwriter.cs,2948255041cfcd43). – Manfred Radlwimmer Mar 06 '18 at 19:20
  • And here is [the relevant part of Json.Net](https://github.com/JamesNK/Newtonsoft.Json/blob/master/Src/Newtonsoft.Json/Serialization/JsonSerializerInternalReader.cs#L66) – Manfred Radlwimmer Mar 06 '18 at 19:23
  • I would expect the result to be something I can deserialize and execute later. I have a job scheduling system that I can pass either a Type, and instance or an Action to. This now works using the BinaryFormatter sample I pointed at – pm100 Mar 06 '18 at 19:24
  • I tried reading the reference source, which is what I would always do. But its an extremely convoluted code base. – pm100 Mar 06 '18 at 19:25
  • 1
    Actions are delegates. It wouldn't make any sense to serialize them. You would basically just be left with a function pointer that could point to who knows what when you deserialize it again. – Manfred Radlwimmer Mar 06 '18 at 19:25
  • 3
    And why it should be able to? Json.NET is completely different from BinaryFormatter, and serializing delegates is certainly far out of use case for json.net. If you need - serialize with binary formatter, convert to base64 and store that in json together with other info you need. Though I'd better reconsider doing this (serializing delegates). – Evk Mar 06 '18 at 19:27
  • And the next step would be serialize in c#, then deserialize and execute in java :) – Eser Mar 06 '18 at 19:43
  • @Evk - thats exactly what I am doing, works perfectly – pm100 Mar 06 '18 at 19:47
  • @Eser - I am doing this to persist and reload into the same app, Not to pass between systems. – pm100 Mar 06 '18 at 19:48
  • *"thats the most stupid thing to want to do"* Close, but not quite. I'd rather call it *risky* and *probably* unrelyable in some cases. Try to serialize this action for example `int x = 1; Action a = new Action(()=>{Debug.WriteLine(x);});`. That won't work. – Manfred Radlwimmer Mar 06 '18 at 19:50
  • @ManfredRadlwimmer - you are correct. Capture doesnt work. My use cases dont need it – pm100 Mar 06 '18 at 20:00
  • `why one serializing infrastructure could do it but the other could not.` Because every serialization codes handle the process their way(see also xml, protobuf, yaml etc). All you need is writing a non-anonymous method and serialize(store) the method name and parameters.... When execution is needed, you have all the info you need – L.B Mar 06 '18 at 20:23

1 Answers1

14

The reason that BinaryFormatter is (sometimes) able to round-trip an Action<T> is that such delegates are marked as [Serializable] and implement ISerializable.

However, just because the delegate itself is marked as serializable doesn't mean that its members can be serialized successfully. In testing, I was able to serialize the following delegate:

Action<int> a1 = (a) => Console.WriteLine(a);

But attempting to serialize the following threw a SerializationException:

int i = 0;
Action<int> a2 = (a) => i = i + a;

The captured variable i apparently is placed in a non-serializable compiler-generated class thereby preventing binary serialization of the delegate from succeeding.

On the other hand, Json.NET is unable to round-trip an Action<T> despite supporting ISerializable because it does not provide support for serialization proxies configured via SerializationInfo.SetType(Type). We can confirm that Action<T> is using this mechanism with the following code:

var iSerializable = a1 as ISerializable;
if (iSerializable != null)
{
    var info = new SerializationInfo(a1.GetType(), new FormatterConverter());
    var initialFullTypeName = info.FullTypeName;
    iSerializable.GetObjectData(info, new StreamingContext(StreamingContextStates.All));
    Console.WriteLine("Initial FullTypeName = \"{0}\", final FullTypeName = \"{1}\".", initialFullTypeName, info.FullTypeName);
    var enumerator = info.GetEnumerator();
    while (enumerator.MoveNext())
    {
        Console.WriteLine("   Name = {0}, objectType = {1}, value = {2}.", enumerator.Name, enumerator.ObjectType, enumerator.Value);
    }
}

When run, it outputs:

Initial FullTypeName = "System.Action`1[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]", final FullTypeName = "System.DelegateSerializationHolder".
   Name = Delegate, objectType = System.DelegateSerializationHolder+DelegateEntry, value = System.DelegateSerializationHolder+DelegateEntry.
   Name = method0, objectType = System.Reflection.RuntimeMethodInfo, value = Void <Test>b__0(Int32).

Notice that FullTypeName has changed to System.DelegateSerializationHolder? That's the proxy, and it's not supported by Json.NET.

This begs the question, just what is written out when a delegate is serialized? To determine this we can configure Json.NET to serialize Action<T> similarly to how BinaryFormatter would by setting

If I serialize a1 using these settings:

var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.All,
    ContractResolver = new DefaultContractResolver
    {
        IgnoreSerializableInterface = false,
        IgnoreSerializableAttribute = false,
    },
    Formatting = Formatting.Indented,
};
var json = JsonConvert.SerializeObject(a1, settings);
Console.WriteLine(json);

Then the following JSON is generated:

{
  "$type": "System.Action`1[[System.Int32, mscorlib]], mscorlib",
  "Delegate": {
    "$type": "System.DelegateSerializationHolder+DelegateEntry, mscorlib",
    "type": "System.Action`1[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]",
    "assembly": "mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089",
    "target": null,
    "targetTypeAssembly": "Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "targetTypeName": "Question49138328.TestClass",
    "methodName": "<Test>b__0",
    "delegateEntry": null
  },
  "method0": {
    "$type": "System.Reflection.RuntimeMethodInfo, mscorlib",
    "Name": "<Test>b__0",
    "AssemblyName": "Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "ClassName": "Question49138328.TestClass",
    "Signature": "Void <Test>b__0(Int32)",
    "MemberType": 8,
    "GenericArguments": null
  }
}

The replacement FullTypeName is not included but everything else is. And as you can see, it's not actually storing the IL instructions of the delegate; it's storing the full signature of the method(s) to call, including the hidden, compiler-generated method name <Test>b__0 mentioned in this answer. You can see the hidden method name yourself just by printing a1.Method.Name.

Incidentally, to confirm that Json.NET is really saving the same member data as BinaryFormatter, you can serialize a1 to binary and print any embedded ASCII strings as follows:

var binary = BinaryFormatterHelper.ToBinary(a1);
var s = Regex.Replace(Encoding.ASCII.GetString(binary), @"[^\u0020-\u007E]", string.Empty);
Console.WriteLine(s);
Assert.IsTrue(s.Contains(a1.Method.Name)); // Always passes

Using the extension method:

public static partial class BinaryFormatterHelper
{
    public static byte[] ToBinary<T>(T obj)
    {
        using (var stream = new MemoryStream())
        {
            new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter().Serialize(stream, obj);
            return stream.ToArray();
        }
    }
}

Doing so results in the following string:

????"System.DelegateSerializationHolderDelegatemethod00System.DelegateSerializationHolder+DelegateEntry/System.Reflection.MemberInfoSerializationHolder0System.DelegateSerializationHolder+DelegateEntrytypeassemblytargettargetTypeAssemblytargetTypeNamemethodNamedelegateEntry0System.DelegateSerializationHolder+DelegateEntrylSystem.Action`1[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]Kmscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=nullQuestion49138328.TestClass<Test>b__0/System.Reflection.MemberInfoSerializationHolderNameAssemblyNameClassNameSignatureMemberTypeGenericArgumentsSystem.Type[]Void <Test>b__0(Int32)

And the assert never fires, indicating that the compiler-generated method name <Test>b__0 is indeed present in the binary also.

Now, here's the scary part. If I modify my c# source code to create another Action<T> before a1, like so:

// I inserted this before a1 and then recompiled: 
Action<int> a0 = (a) => Debug.WriteLine(a);

Action<int> a1 = (a) => Console.WriteLine(a);

Then re-build and re-run, a1.Method.Name changes to <Test>b__1:

{
  "$type": "System.Action`1[[System.Int32, mscorlib]], mscorlib",
  "Delegate": {
    "$type": "System.DelegateSerializationHolder+DelegateEntry, mscorlib",
    "type": "System.Action`1[[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]",
    "assembly": "mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089",
    "target": null,
    "targetTypeAssembly": "Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "targetTypeName": "Question49138328.TestClass",
    "methodName": "<Test>b__1",
    "delegateEntry": null
  },
  "method0": {
    "$type": "System.Reflection.RuntimeMethodInfo, mscorlib",
    "Name": "<Test>b__1",
    "AssemblyName": "Tile, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
    "ClassName": "Question49138328.TestClass",
    "Signature": "Void <Test>b__1(Int32)",
    "MemberType": 8,
    "GenericArguments": null
  }
}

Now if I deserialize binary data for a1 saved from the earlier version, it comes back as a0! Thus, adding another delegate somewhere in your code base, or otherwise refactoring your code in an apparently harmless way, may cause previously serialized delegate data to be corrupt and fail or even possibly execute the wrong method when deserialized into the new version of your software. Further, this is unlikely to be fixable other than by reverting all changes out of your code and never making such changes again.

To sum up, we have found that serialized delegate information is incredibly fragile to seemingly-unrelated changes in one's code base. I would strongly recommend against persisting delegates through serialization with either BinaryFormatter or Json.NET. Instead, consider maintaining a table of named delegates and serializing the names, or following the command pattern and serialize command objects.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • excellent answer - thank you very much. So I just needed to add a couple of extra flags to json.net deserializer – pm100 Mar 06 '18 at 21:47
  • In your last example delegate data is much worse than meaningless or invalid. It now points to _another_ method, because now `a0` is `b__0`. – Evk Mar 07 '18 at 07:55
  • @Evk - yes that's true. Maybe I could express it more strongly. – dbc Mar 07 '18 at 07:55