admin管理员组

文章数量:1122832

So to be clear I am going to present a working solution to that problem using Newtonsoft.Json 13.0.3. Said solution does not look very good and I am looking for a better one.

Some context, this is Godot so the files on disk are of type "Resource" and they are loaded by calling ResourceLoader.Load(string path)

The idea is to to serialize a type holding a loaded Resource as parameter and deserialize it by loading the resource instead of creating a new one.

public class KnownTypesBinder : ISerializationBinder
{
    public string CurrentResourcePath { get; private set; }

    public Type BindToType(string assemblyName, string typeName)
    {
        Type prospectType = Type.GetType(typeName);
        if (prospectType == null)
        {
            // When the typeName contains an '@' it means it is a resource type with its path encoded after the '@' character
            int strIndex = typeName.Find('@');
            Assert.IsTrue(strIndex != -1);

            // Storing the path of the currently deserialized resource for future use by the contract 
            CurrentResourcePath = typeName.Substring(strIndex + 1);
            string realTypeName = typeName.Substr(0, strIndex);
            return Type.GetType(realTypeName);
        }
        else
        {
            return Type.GetType(typeName);
        }
    }

    public void BindToName(Type serializedType, out string assemblyName, out string typeName)
    {
        if (typeof(Resource).IsAssignableFrom(serializedType))
        {
            // What is done here is that for resource types we want to bake the ResourcePath after the resource type info
            // like this: "$type": "Godot.Resource@res://MyPath/MyResource.tres"
            // The "Godot.Resource" part is the type info
            // The "res://MyPath/MyResource.tres" is the godot path of the file

            // BindToName does not provide with the currently serialized object, so it is grabed from the contract resolver who is the one knowing that.
            // It is then cast to Resource type

            assemblyName = null;
            Resource currentlySerializedResource = (CoreLogic.Instance.JsonSerializingSettings.ContractResolver as ResourceContractResolver).CurrentlySerializedObject as Resource;
            Assert.IsTrue(currentlySerializedResource != null);
            string path = currentlySerializedResource.ResourcePath;
            typeName = serializedType.FullName + "@" + path;
        }
        else
        {
            string id = serializedType.FullName;
            
            assemblyName = null; // That should probably be filled, but is outside the scope of this discussion
            typeName = id;
        }
    }
}

class ResourceContractResolver : DefaultContractResolver
{
    // Points to the object that is about to be serialized, as soon as it will start serializing properties
    // within that object CurrentlySerializedObject will change to that property's value.
    public object CurrentlySerializedObject { get; private set; }

    protected override JsonObjectContract CreateObjectContract(Type objectType)
    {
        JsonObjectContract contract = base.CreateObjectContract(objectType);
        if (typeof(Resource).IsAssignableFrom(objectType))
        {
            // Instance is not created right away: 
            contract.DefaultCreator = () =>
            {
                string currentResourcePath = (CoreLogic.Instance.JsonSerializingSettings.SerializationBinder as KnownTypesBinder).CurrentResourcePath;
                return ResourceLoader.Load(currentResourcePath);
            };
        }
        return contract;
    }

    protected override List<MemberInfo> GetSerializableMembers(Type objectType)
    {
        if (typeof(Resource).IsAssignableFrom(objectType))
        {
            // Do not serialized anything for resources
            return new List<MemberInfo>();
        }
        else
        {
            return base.GetSerializableMembers(objectType);
        }
    }
    protected override JsonContract CreateContract(Type objectType)
    {
        JsonContract contract = base.CreateContract(objectType);
        contract.OnSerializingCallbacks.Add(OnSerializingCallback);
        return contract;
    }

    private void OnSerializingCallback(object o, StreamingContext context)
    {
        CurrentlySerializedObject = o;
    }
}

For that sample to be complete I would have to hook both those classes to a JsonSerializationSettings and more but I hope you get the idea.

The obvious problem with that solution is that it is essentially a hack based on the knowledge that certain things happen in a certain order. It is prone to fail if I upgrade my newtonsoft.json version and makes my code harder to maintain.

I would be grateful for a more elegant solution.

Edit1: Edited code comments on the type+path concatenation for clarity Edit2: Bottom comment is also pointing to potential thread safety issue with that solution.

Edit3: Adding what the serialized json looks like in this example:

public class ContainerType
{
    public int ValueInt = -1;
    public float ValueFloat = 3.14f;
    public Resource Resource = null; // set to loaded resource file at path res://MyPath/MyResource.tres before serialization
}
{
  "$type": "ContainerType",
  "ValueInt": -1,
  "ValueFloat": 3.14,
  "Resource": {
            "$type": "Godot.Resource@res://MyPath/MyResource.tres"
  },
}

So to be clear I am going to present a working solution to that problem using Newtonsoft.Json 13.0.3. Said solution does not look very good and I am looking for a better one.

Some context, this is Godot so the files on disk are of type "Resource" and they are loaded by calling ResourceLoader.Load(string path)

The idea is to to serialize a type holding a loaded Resource as parameter and deserialize it by loading the resource instead of creating a new one.

public class KnownTypesBinder : ISerializationBinder
{
    public string CurrentResourcePath { get; private set; }

    public Type BindToType(string assemblyName, string typeName)
    {
        Type prospectType = Type.GetType(typeName);
        if (prospectType == null)
        {
            // When the typeName contains an '@' it means it is a resource type with its path encoded after the '@' character
            int strIndex = typeName.Find('@');
            Assert.IsTrue(strIndex != -1);

            // Storing the path of the currently deserialized resource for future use by the contract 
            CurrentResourcePath = typeName.Substring(strIndex + 1);
            string realTypeName = typeName.Substr(0, strIndex);
            return Type.GetType(realTypeName);
        }
        else
        {
            return Type.GetType(typeName);
        }
    }

    public void BindToName(Type serializedType, out string assemblyName, out string typeName)
    {
        if (typeof(Resource).IsAssignableFrom(serializedType))
        {
            // What is done here is that for resource types we want to bake the ResourcePath after the resource type info
            // like this: "$type": "Godot.Resource@res://MyPath/MyResource.tres"
            // The "Godot.Resource" part is the type info
            // The "res://MyPath/MyResource.tres" is the godot path of the file

            // BindToName does not provide with the currently serialized object, so it is grabed from the contract resolver who is the one knowing that.
            // It is then cast to Resource type

            assemblyName = null;
            Resource currentlySerializedResource = (CoreLogic.Instance.JsonSerializingSettings.ContractResolver as ResourceContractResolver).CurrentlySerializedObject as Resource;
            Assert.IsTrue(currentlySerializedResource != null);
            string path = currentlySerializedResource.ResourcePath;
            typeName = serializedType.FullName + "@" + path;
        }
        else
        {
            string id = serializedType.FullName;
            
            assemblyName = null; // That should probably be filled, but is outside the scope of this discussion
            typeName = id;
        }
    }
}

class ResourceContractResolver : DefaultContractResolver
{
    // Points to the object that is about to be serialized, as soon as it will start serializing properties
    // within that object CurrentlySerializedObject will change to that property's value.
    public object CurrentlySerializedObject { get; private set; }

    protected override JsonObjectContract CreateObjectContract(Type objectType)
    {
        JsonObjectContract contract = base.CreateObjectContract(objectType);
        if (typeof(Resource).IsAssignableFrom(objectType))
        {
            // Instance is not created right away: 
            contract.DefaultCreator = () =>
            {
                string currentResourcePath = (CoreLogic.Instance.JsonSerializingSettings.SerializationBinder as KnownTypesBinder).CurrentResourcePath;
                return ResourceLoader.Load(currentResourcePath);
            };
        }
        return contract;
    }

    protected override List<MemberInfo> GetSerializableMembers(Type objectType)
    {
        if (typeof(Resource).IsAssignableFrom(objectType))
        {
            // Do not serialized anything for resources
            return new List<MemberInfo>();
        }
        else
        {
            return base.GetSerializableMembers(objectType);
        }
    }
    protected override JsonContract CreateContract(Type objectType)
    {
        JsonContract contract = base.CreateContract(objectType);
        contract.OnSerializingCallbacks.Add(OnSerializingCallback);
        return contract;
    }

    private void OnSerializingCallback(object o, StreamingContext context)
    {
        CurrentlySerializedObject = o;
    }
}

For that sample to be complete I would have to hook both those classes to a JsonSerializationSettings and more but I hope you get the idea.

The obvious problem with that solution is that it is essentially a hack based on the knowledge that certain things happen in a certain order. It is prone to fail if I upgrade my newtonsoft.json version and makes my code harder to maintain.

I would be grateful for a more elegant solution.

Edit1: Edited code comments on the type+path concatenation for clarity Edit2: Bottom comment is also pointing to potential thread safety issue with that solution.

Edit3: Adding what the serialized json looks like in this example:

public class ContainerType
{
    public int ValueInt = -1;
    public float ValueFloat = 3.14f;
    public Resource Resource = null; // set to loaded resource file at path res://MyPath/MyResource.tres before serialization
}
{
  "$type": "ContainerType",
  "ValueInt": -1,
  "ValueFloat": 3.14,
  "Resource": {
            "$type": "Godot.Resource@res://MyPath/MyResource.tres"
  },
}
Share Improve this question edited Nov 22, 2024 at 10:21 OeilDeLance asked Nov 21, 2024 at 17:49 OeilDeLanceOeilDeLance 1051 silver badge8 bronze badges 5
  • 1 What do you have in the JSON file when you are deserializing objects of type Resource? And what information do you want to use from the file? Do you want to populate the resource returned by ResourceLoader.Load(currentResourcePath); with the contents of the JSON file? Can you share a minimal reproducible example? – dbc Commented Nov 21, 2024 at 18:39
  • 1 Incidentally, ResourceContractResolver.CurrentlySerializedObject is not thread safe. That looks to be the biggest risk with this design. – dbc Commented Nov 21, 2024 at 18:41
  • "Do you want to populate the resource returned by ResourceLoader.Load(currentResourcePath);" Nope I just want to load the file "What do you have in the JSON file when you are deserializing objects of type Resource?" Nothing, this is the purpose of overriding GetSerializableMembers and returning nothing. The only thing that is serialized from the file is it's type and path: "$type": "Godot.Resource@res://MyPath/" "Incidentally, ResourceContractResolver.CurrentlySerializedObject is not thread safe. That looks to be the biggest risk with this design." True I'll add it to the description – OeilDeLance Commented Nov 22, 2024 at 9:48
  • Sorry path is more like: "res://MyPath/MyResource.tres". Also type and path a concatenated like that: "$type": "Godot.Resource@res://MyPath/MyResource.tres" – OeilDeLance Commented Nov 22, 2024 at 9:57
  • Maybe a custom JsonConverter<Resource> would be the way to go? – Richard Deeming Commented Nov 22, 2024 at 10:16
Add a comment  | 

1 Answer 1

Reset to default 1

If all you need to do during deserialization is to load a Resource from disk given a path string, it will be much easier to create a custom JsonConverter<Resource> that does just that, writing and reading the ResourcePath to JSON instead of the resource itself.

First define the following converter:

public class ResourceConverter : JsonConverter<Resource>
{
    public override void WriteJson(JsonWriter writer, Resource? value, JsonSerializer serializer) =>
        writer.WriteValue(value?.ResourcePath);

    public override Resource? ReadJson(JsonReader reader, Type objectType, Resource? existingValue, bool hasExistingValue, JsonSerializer serializer) =>
        reader.MoveToContentAndAssert().TokenType switch
        {
            JsonToken.Null => null,
            JsonToken.String => reader.Value is string s ? ResourceLoader.Load(s) : throw new JsonSerializationException("Invalid reader.Value for resource."),
            var t => throw new JsonSerializationException($"Invalid reader.TokenType {t} for resource."),
        };
}

public static partial class JsonExtensions
{
    public static JsonReader MoveToContentAndAssert(this JsonReader reader)
    {
        ArgumentNullException.ThrowIfNull(reader);
        if (reader.TokenType == JsonToken.None)       // Skip past beginning of stream.
            reader.ReadAndAssert();
        while (reader.TokenType == JsonToken.Comment) // Skip past comments.
            reader.ReadAndAssert();
        return reader;
    }

    public static JsonReader ReadAndAssert(this JsonReader reader)
    {
        ArgumentNullException.ThrowIfNull(reader);
        if (!reader.Read())
            throw new JsonReaderException("Unexpected end of JSON stream.");
        return reader;
    }
}

Then eliminate ResourceContractResolver and remove all Resource related logic from KnownTypesBinder:

public class KnownTypesBinder : ISerializationBinder
{
    public Type BindToType(string? assemblyName, string typeName)
    {
        // TODO: replace with a binder that actually checks known types
        // For why, see https://stackoverflow.com/questions/39565954/typenamehandling-caution-in-newtonsoft-json
        return Type.GetType(typeName)!; 
    }

    public void BindToName(Type serializedType, out string? assemblyName, out string? typeName)
    {
        var id = serializedType.FullName;
        assemblyName = null; // That should probably be filled, but is outside the scope of this discussion
        typeName = id;
    }
}

Then serialize and deserialize your Container like so:

var container = new ContainerType
{
    Resource = ResourceLoader.Load("res://MyPath/MyResource.tres"),
};

var settings = new JsonSerializerSettings
{
    Converters = { new ResourceConverter() },
    // Add other settings as required, e.g.:
    TypeNameHandling = TypeNameHandling.Objects, // Be sure this is really necessary.
    TypeNameAssemblyFormatHandling = TypeNameAssemblyFormatHandling.Simple,
    SerializationBinder = new KnownTypesBinder(),
};

var json = JsonConvert.SerializeObject(container, Formatting.Indented, settings);

var container2 = JsonConvert.DeserializeObject<ContainerType>(json, settings);

And the values of your resources will be round-tripped as their ResourcePath strings like so, and loaded using ResourceLoader.Load() during deserialization:

{
  "$type": "ContainerType",
  "ValueInt": -1,
  "ValueFloat": 3.14,
  "Resource": "res://MyPath/MyResource.tres"
}

Notes:

  • Your current code prepends the resource type to the resource path. It doesn't seem like you actually need this during deserialization, but if you do, you can modify ReadJson() and WriteJson() to include it.

  • Please be aware that there are security risks associated with TypeNameHandling. As explained in the docs:

    TypeNameHandling should be used with caution when your application deserializes JSON from an external source. Incoming types should be validated with a custom SerializationBinder when deserializing with a value other than None.

    The KnownTypesBinder shown in your question doesn't actually do this, but I would suggest that it should. For why, see TypeNameHandling caution in Newtonsoft Json and External json vulnerable because of Json.Net TypeNameHandling auto?.

Mockup fiddle here.

本文标签: cLoading a file from disk and use that as value during json deserializationStack Overflow