rpgmaker / NetJSON

Faster than Any Binary? Benchmark: http://theburningmonk.com/2014/08/json-serializers-benchmarks-updated-2/
MIT License
225 stars 29 forks source link

Feature Request: Immutable Struct (Get-Only Properties) #204

Closed ghost closed 5 years ago

ghost commented 5 years ago

Following on from #116 , please could you kindly add support for immutable structs with constructor initialisation?

After testing a few scenarios (v1.2.7 on .Net Core 2.1), it appears that this currently only works for classes with private setters (and doesn't seem to support some of the scenarios mentioned in #116):

The tests and models to follow - happy to post the IL if you wish to take this on...

ghost commented 5 years ago

Models:

public struct EventStruct
{
    public EventStruct(Guid id, PayloadStruct payload, int version, DateTimeOffset created) { Id = id; Payload = payload; Version = version; Created = created; }

    public Guid Id { get; }
    public PayloadStruct Payload { get; }
    public int Version { get; }
    public DateTimeOffset Created { get; }
}

public struct PayloadStruct
{
    public PayloadStruct(string value) { Value = value; } 

    public string Value { get; }
}

public struct EventStructWithPrivateSetters
{
    public EventStructWithPrivateSetters(Guid id, PayloadStructWithPrivateSetter payload, int version, DateTimeOffset created) { Id = id; Payload = payload; Version = version; Created = created; }

    public Guid Id { get; private set; }
    public PayloadStructWithPrivateSetter Payload { get; private set; }
    public int Version { get; private set; }
    public DateTimeOffset Created { get; private set; }
}

public struct PayloadStructWithPrivateSetter
{
    public PayloadStructWithPrivateSetter(string value) { Value = value; }

    public string Value { get; private set; }
}

public struct EventStructWithReadOnlyBackingFields
{
    private readonly Guid _id;
    private readonly PayloadStructWithReadOnlyBackingField _payload;
    private readonly int _version;
    private readonly DateTimeOffset _created;

    public EventStructWithReadOnlyBackingFields(Guid id, PayloadStructWithReadOnlyBackingField payload, int version, DateTimeOffset created) { _id = id; _payload = payload; _version = version; _created = created; }

    public Guid Id => _id;
    public PayloadStructWithReadOnlyBackingField Payload => _payload;
    public int Version => _version;
    public DateTimeOffset Created => _created;
}

public struct PayloadStructWithReadOnlyBackingField
{
    private readonly string _value;

    public PayloadStructWithReadOnlyBackingField(string value) { _value = value; }

    public string Value => _value;
}

public class EventClass
{
    public EventClass(Guid id, PayloadClass payload, int version, DateTimeOffset created) { Id = id; Payload = payload; Version = version; Created = created; }

    public Guid Id { get; }
    public PayloadClass Payload { get; }
    public int Version { get; }
    public DateTimeOffset Created { get; }
}

public class PayloadClass
{
    public PayloadClass(string value) { Value = value; }

    public string Value { get; }
}

public class EventClassWithPrivateSetters
{
    public EventClassWithPrivateSetters(Guid id, PayloadClassWithPrivateSetter payload, int version, DateTimeOffset created) { Id = id; Payload = payload; Version = version; Created = created; }

    public Guid Id { get; private set; }
    public PayloadClassWithPrivateSetter Payload { get; private set; }
    public int Version { get; private set; }
    public DateTimeOffset Created { get; private set; }
}

public class PayloadClassWithPrivateSetter
{
    public PayloadClassWithPrivateSetter(string value) { Value = value; }

    public string Value { get; private set; }
}

public class EventClassWithReadOnlyBackingFields
{
    private readonly Guid _id;
    private readonly PayloadClassWithWithReadOnlyBackingField _payload;
    private readonly int _version;
    private readonly DateTimeOffset _created;

    public EventClassWithReadOnlyBackingFields(Guid id, PayloadClassWithWithReadOnlyBackingField payload, int version, DateTimeOffset created) { _id = id; _payload = payload; _version = version; _created = created; }

    public Guid Id => _id;
    public PayloadClassWithWithReadOnlyBackingField Payload => _payload;
    public int Version => _version;
    public DateTimeOffset Created => _created;
}

public class PayloadClassWithWithReadOnlyBackingField
{
    private readonly string _value;

    public PayloadClassWithWithReadOnlyBackingField(string value) { _value = value; }

    public string Value => _value;
}

And tests:

[TestClass]
public class NetJSONTests
{
    private static readonly NetJSONSettings Settings = new NetJSONSettings{ DateFormat = NetJSONDateFormat.ISO, TimeZoneFormat = NetJSONTimeZoneFormat.Utc };
    private static readonly Random Random = new Random();

    [TestMethod]  // Fails: not deserialised
    public void EventStructTest()
    {
        var e = new EventStruct(Guid.NewGuid(), new PayloadStruct(Guid.NewGuid().ToString("n")), Random.Next(), DateTimeOffset.UtcNow);
        var s = NetJSON.NetJSON.Serialize(e, Settings);
        var d = NetJSON.NetJSON.Deserialize<EventStruct>(s, Settings);

        Assert.AreEqual(e.Id, d.Id);
        Assert.AreEqual(e.Payload.Value, d.Payload.Value);
        Assert.AreEqual(e.Version, d.Version);
        Assert.AreEqual(e.Created, d.Created);
    }

    [TestMethod] // Fails: System.AccessViolationException at NetJSON.SetterPropertyValue<EventStructWithPrivateSetters>(EventStructWithPrivateSetters instance, object value, System.Reflection.MethodInfo methodInfo)
    public void EventStructWithPrivateSettersTest()
    {
        var e = new EventStructWithPrivateSetters(Guid.NewGuid(), new PayloadStructWithPrivateSetter(Guid.NewGuid().ToString("n")), Random.Next(), DateTimeOffset.UtcNow);
        var s = NetJSON.NetJSON.Serialize(e, Settings);
        var d = NetJSON.NetJSON.Deserialize<EventStructWithPrivateSetters>(s, Settings);

        Assert.AreEqual(e.Id, d.Id);
        Assert.AreEqual(e.Payload.Value, d.Payload.Value);
        Assert.AreEqual(e.Version, d.Version);
        Assert.AreEqual(e.Created, d.Created);
    }

    [TestMethod] // Fails: not deserialised
    public void EventStructWithReadOnlyBackingFieldsTest()
    {
        var e = new EventStructWithReadOnlyBackingFields(Guid.NewGuid(), new PayloadStructWithReadOnlyBackingField(Guid.NewGuid().ToString("n")), Random.Next(), DateTimeOffset.UtcNow);
        var s = NetJSON.NetJSON.Serialize(e, Settings);
        var d = NetJSON.NetJSON.Deserialize<EventStructWithReadOnlyBackingFields>(s, Settings);

        Assert.AreEqual(e.Id, d.Id);
        Assert.AreEqual(e.Payload.Value, d.Payload.Value);
        Assert.AreEqual(e.Version, d.Version);
        Assert.AreEqual(e.Created, d.Created);
    }

    [TestMethod]  // Fails: not deserialised
    public void EventClassTest()
    {
        var e = new EventClass(Guid.NewGuid(), new PayloadClass(Guid.NewGuid().ToString("n")), Random.Next(), DateTimeOffset.UtcNow);
        var s = NetJSON.NetJSON.Serialize(e, Settings);
        var d = NetJSON.NetJSON.Deserialize<EventClass>(s, Settings);

        Assert.AreEqual(e.Id, d.Id);
        Assert.AreEqual(e.Payload.Value, d.Payload.Value);
        Assert.AreEqual(e.Version, d.Version);
        Assert.AreEqual(e.Created, d.Created);
    }

    [TestMethod] // Passes
    public void EventClassWithPrivateSettersTest()
    {
        var e = new EventClassWithPrivateSetters(Guid.NewGuid(), new PayloadClassWithPrivateSetter(Guid.NewGuid().ToString("n")), Random.Next(), DateTimeOffset.UtcNow);
        var s = NetJSON.NetJSON.Serialize(e, Settings);
        var d = NetJSON.NetJSON.Deserialize<EventClassWithPrivateSetters>(s, Settings);

        Assert.AreEqual(e.Id, d.Id);
        Assert.AreEqual(e.Payload.Value, d.Payload.Value);
        Assert.AreEqual(e.Version, d.Version);
        Assert.AreEqual(e.Created, d.Created);
    }

    [TestMethod] // Fails: not deserialised
    public void EventClassWithReadOnlyBackingFieldsTest()
    {
        var e = new EventClassWithReadOnlyBackingFields(Guid.NewGuid(), new PayloadClassWithWithReadOnlyBackingField(Guid.NewGuid().ToString("n")), Random.Next(), DateTimeOffset.UtcNow);
        var s = NetJSON.NetJSON.Serialize(e, Settings);
        var d = NetJSON.NetJSON.Deserialize<EventClassWithReadOnlyBackingFields>(s, Settings);

        Assert.AreEqual(e.Id, d.Id);
        Assert.AreEqual(e.Payload.Value, d.Payload.Value);
        Assert.AreEqual(e.Version, d.Version);
        Assert.AreEqual(e.Created, d.Created);
    }
}
rpgmaker commented 5 years ago

Thanks for the details. I will look into it.

rpgmaker commented 5 years ago

Sorry, I have not had time to look much into it. I will try to find some time this coming week to analyze it. Thanks,

ghost commented 5 years ago

Thanks for the update, happy that you're looking at it!

rpgmaker commented 5 years ago

Sorry been busy with other things that came up. I will look at it soon and get back to you.

rpgmaker commented 5 years ago

Hi, besides the auto generated backing fields for properties. Could I assume the naming of fields used when there is no setter? E.x. fields will always end with name similar to property while ignoring the casing of name.

Thanks,

wmjordan commented 5 years ago

@rpgmaker Instead of relying on naming conventions, it may be possible to parse the IL byte array of the property get method body. Usually it is as simple as just a few bytes, if it is compiler generated, then you can find the field token and manipulate its values.

rpgmaker commented 5 years ago

When it is compiler generated, it will use backing fields. But the scenario above uses a field returned by the get. I guess I could consider parsing the first few bytes and use it for field token assuming it is faster than manually calling get field and fields. At the moment, I need to do ldtoken and pass the field itself to the opcodes call and then resolve the runtime handle of the field in order to use dynamic method for it. So not sure yet if it would help till I test it.

Thanks for the idea either way.

rpgmaker commented 5 years ago

Please test using the code from the repo. I will create nuget package once i get another item completed. @wmjordan, i will look into the IL code that can detect the field later because it requires parsing which i think might be too heavy for this logic itself. I am open if you will like to do it too :)

wmjordan commented 5 years ago

Pal, use this code. I wrote this quite some time before. Below code finds the underlying static or instance field from a read-only property like:

? Property { get; } or ? Property => _UnderlyingField;

public static FieldInfo ReflectUnderlyingField(this PropertyInfo property) {
    var m = property.GetGetMethod(true);
    if (m == null) {
        return null;
    }
    var ilBytes = m.GetMethodBody().GetILAsByteArray();
    var l = ilBytes.Length;
    if (l != 6 && l != 7) {
        return null;
    }
    var gta = m.ReflectedType.IsGenericType ? m.ReflectedType.GetGenericArguments() : null;
    var gma = m.IsGenericMethod ? m.GetGenericArguments() : null;
    if (m.IsStatic) {
        if (l == 6 && ilBytes[0] == OpCodes.Ldsfld.Value && ilBytes[5] == OpCodes.Ret.Value) {
            return property.Module.ResolveField(BitConverter.ToInt32(ilBytes, 1), gta, gma);
        }
    }
    else {
        if (l == 7 && ilBytes[0] == OpCodes.Ldarg_0.Value && ilBytes[1] == OpCodes.Ldfld.Value && ilBytes[6] == OpCodes.Ret.Value) {
            return property.Module.ResolveField(BitConverter.ToInt32(ilBytes, 2), gta, gma);
        }
    }
    return null;
}

The problem about property parsing is that it requires an individual DynamicMethod to access the underlying private field.

rpgmaker commented 5 years ago

Thanks, I will take a look at it. Note, this does not work for netstandard 1.6. since method body function is not available.

Thanks,

wmjordan commented 5 years ago

netstandard 1.* is a headache. It seemed to me that things from MS was not so good, until their 2.0 versions were released, such as, .NET Framework 2.0, .NET Core 2.0, .NET Standard 2.0...