Search code examples
c#jsonunity-game-enginejson.netjsonconverter

Json.Net How to Serialize and Deserialize custom types in custom way


I use the R3 reactive programming library and Newtonsoft. I'm trying to save object data to a file so that I can later read from it. The recording just works without problems. But when reading the file I get an error Newtonsoft.Json.JsonReaderException: Unexpected character encountered while parsing value: {. Path 'resourceList[0].Count', line 5, position 16.

{
  "resourceList": [
    {
      "Name": "banana",
      "Count": {
        "EqualityComparer": {},
        "CurrentValue": 10,
        "HasObservers": false,
        "IsCompleted": false,
        "IsDisposed": false,
        "IsCompletedOrDisposed": false,
        "Value": 10
      }
    },
    {
      "Name": "apple",
      "Count": {
        "EqualityComparer": {},
        "CurrentValue": 3,
        "HasObservers": false,
        "IsCompleted": false,
        "IsDisposed": false,
        "IsCompletedOrDisposed": false,
        "Value": 3
      }
    }
  ],
  "observableResourceList": [
    {
      "Name": "oBanana",
      "Count": {
        "EqualityComparer": {},
        "CurrentValue": 5,
        "HasObservers": false,
        "IsCompleted": false,
        "IsDisposed": false,
        "IsCompletedOrDisposed": false,
        "Value": 5
      }
    },
    {
      "Name": "oApple",
      "Count": {
        "EqualityComparer": {},
        "CurrentValue": 2,
        "HasObservers": false,
        "IsCompleted": false,
        "IsDisposed": false,
        "IsCompletedOrDisposed": false,
        "Value": 2
      }
    }
  ],
  "number": {
    "EqualityComparer": {},
    "CurrentValue": 9,
    "HasObservers": false,
    "IsCompleted": false,
    "IsDisposed": false,
    "IsCompletedOrDisposed": false,
    "Value": 9
  },
  "word": {
    "EqualityComparer": {},
    "CurrentValue": "word string",
    "HasObservers": false,
    "IsCompleted": false,
    "IsDisposed": false,
    "IsCompletedOrDisposed": false,
    "Value": "word string"
  }
}

Also, a lot of unnecessary information from ReactiveProperty ends up in the file, I try to save only the part that I need. I was able to write part of the file using JsonConverter, but I still can't read it correctly. I'm getting the same error

{
  "resourceList": [
    {
      "Name": "banana",
      "Count": {
        "Value": 10
      }
    },
    {
      "Name": "apple",
      "Count": {
        "Value": 3
      }
    }
  ],
  "observableResourceList": [
    {
      "Name": "oBanana",
      "Count": {
        "Value": 5
      }
    },
    {
      "Name": "oApple",
      "Count": {
        "Value": 2
      }
    }
  ],
  "number": {
    "Value": 9
  },
  "word": {
    "Value": "word string"
  }
}

Also, I don’t understand how to describe a generic type definition like ReactiveProperty<> in Json.NET, it’s not clear how to interpret JsonConverter, ContractResolver or something like that.

public class Test : MonoBehaviour
{
    private void Start()
    {
        TestData testData = new TestData();

        DataStorageAgent dataStorageAgent = new DataStorageAgent();
        string savePath = Application.dataPath + "/Saves/Test.json";

        dataStorageAgent.Save(testData, savePath);
        testData = dataStorageAgent.Load<TestData>(savePath);
    }
}
public class TestData
{
    public List<Resource> resourceList;
    public ObservableList<Resource> observableResourceList;
    public ReactiveProperty<int> number;
    public ReactiveProperty<string> word;

    public TestData()
    {
        resourceList = new List<Resource>();
        resourceList.Add(new Resource("banana", 10));
        resourceList.Add(new Resource("apple", 3));

        observableResourceList = new ObservableList<Resource>();
        observableResourceList.Add(new Resource("oBanana", 5));
        observableResourceList.Add(new Resource("oApple", 2));

        number = new ReactiveProperty<int>(9);
        word = new ReactiveProperty<string>("word string");
    }
}
public class Resource
{
    public readonly string Name;
    public ReactiveProperty<int> Count;

    public Resource(string name, int count)
    {
        Name = name;
        Count = new ReactiveProperty<int>(count);
    }
}
public class DataStorageAgent
{
    public T Load<T>(string saveFilePath) where T : class
    {
        if (File.Exists(saveFilePath))
        {
            string jsonString = File.ReadAllText(saveFilePath);

            try
            {
                return JsonConvert.DeserializeObject<T>(jsonString, new ReactivePropertyJsonConverter());
            }
            catch (Exception e)
            {
                Debug.LogError(e);
            }
        }
        else
        {
            Debug.LogError($"File does not exist - {saveFilePath}");
        }

        return default(T);
    }

    public void Save(object saveData, string saveFilePath)
    {
        string jsonString = JsonConvert.SerializeObject(saveData, Formatting.Indented, new ReactivePropertyJsonConverter());
        File.WriteAllText(saveFilePath, jsonString);
    }
}
public class ReactivePropertyJsonConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return objectType.IsGenericType && (objectType.GetGenericTypeDefinition() == typeof(ReactiveProperty<>));
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        //JObject jsonObject = JObject.Load(reader);
        //return existingValue;
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        writer.WriteStartObject();
        writer.WritePropertyName("Value");
        serializer.Serialize(writer, value.GetType().GetProperty("Value").GetValue(value));
        writer.WriteEndObject();
    }
}

Solution

  • You are trying to create a single custom JsonConverter for ReactiveProperty<T> for every possible type T which will serialize the reactive property as its value. I'm going to suggest breaking this down into two steps. First, create a generic JsonConverter<ReactiveProperty<T>> that can be used for any specific value type T. Secondly, create a general converter for all possible value types that uses reflection to manufacture and invoke converters for each type.

    A generic JsonConverter<ReactiveProperty<T>> would look like the following:

    public class ReactivePropertyJsonConverter<T> : JsonConverter<ReactiveProperty<T>>
    {
        public override ReactiveProperty<T>? ReadJson(JsonReader reader, Type objectType, ReactiveProperty<T>? existingValue, bool hasExistingValue, JsonSerializer serializer)
        {
            var isNull = reader.MoveToContentAndAssert().TokenType == JsonToken.Null;
            if (isNull && typeof(T).IsValueType && Nullable.GetUnderlyingType(typeof(T)) == null)
                return null;
            var innerValue = !isNull ? serializer.Deserialize<T>(reader) : default(T);
            if (hasExistingValue && existingValue != null)
                existingValue.Value = (innerValue ?? default(T))!;
            else if (objectType == typeof(ReactiveProperty<T>))
                existingValue = innerValue == null ? new() : new(innerValue!);
            else
                // ReactiveProperty<T> was subclassed, use parameterized constructor.
                existingValue = innerValue == null 
                    ? (ReactiveProperty<T>)Activator.CreateInstance(objectType)!
                    // Here we assume the subclass has a parameterized constructor taking the value as a a single argument.
                    : (ReactiveProperty<T>)Activator.CreateInstance(objectType, innerValue)!;
            return existingValue;
        }
    
        public override void WriteJson(JsonWriter writer, ReactiveProperty<T>? value, JsonSerializer serializer) => 
            serializer.Serialize(writer, value == null ? default(T?) : value.Value);
    }
    
    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;
        }
    }
    

    The WriteJson() method is straightforward, but there are a couple things to note regarding ReadJson():

    • When a pre-allocated existingValue is passed in, I replace its value rather than create a new reactive property.

    • ReactiveProperty<T> is not sealed, so objectType may be a subclass of some ReactiveProperty<T>. In that case we have to use Activator.CreateInstance() to manufacture instances of the type.

    Now for the general converter, we can use Type.MakeGenericType(Type[]) to manufacture the concrete type JsonConverter<ReactiveProperty<T>> for each value type T:

    public class ReactivePropertyJsonConverter : JsonConverter
    {
        static readonly ConcurrentDictionary<Type, JsonConverter> Converters = new();
    
        static JsonConverter CreateReactivePropertyConverterOfT(Type valueType) =>
            (JsonConverter)Activator.CreateInstance(typeof(ReactivePropertyJsonConverter<>).MakeGenericType(valueType))!;
    
        static JsonConverter GetReactivePropertyConverterOfT(Type valueType) =>
            Converters.GetOrAdd(valueType, CreateReactivePropertyConverterOfT);
    
        static Type? GetReactivePropertyValueType(Type objectType) =>
            objectType.BaseTypesAndSelf().Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ReactiveProperty<>)).FirstOrDefault()?.GetGenericArguments()[0];
        
        public override bool CanConvert(Type objectType) => GetReactivePropertyValueType(objectType) != null;
    
        public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) => 
            GetReactivePropertyConverterOfT(GetReactivePropertyValueType(objectType)!).ReadJson(reader, objectType, existingValue, serializer);
        
        public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
        {
            if (value == null)
                writer.WriteNull();
            else
                GetReactivePropertyConverterOfT(GetReactivePropertyValueType(value.GetType())!).WriteJson(writer, value, serializer);
        }
    }
    
    public static partial class JsonExtensions
    {
        public static IEnumerable<Type> BaseTypesAndSelf(this Type type)
        {
            while (type != null)
            {
                yield return type;
                type = type.BaseType!;
            }
        }
    }
    

    Now you can modify DataStorageAgent to use the converter e.g. as follows:

    public class DataStorageAgent
    {
        static UTF8Encoding DefaultEncoding { get; } = new UTF8Encoding(false, true);   
        
        static JsonSerializerSettings DefaultSettings { get; } = new ()
        {
            Converters = { new ReactivePropertyJsonConverter() },
            Formatting = Formatting.Indented,
        };
    
        public T? Load<T>(string saveFilePath) where T : class
        {
            try
            {
                using var textReader = new StreamReader(saveFilePath, DefaultEncoding);
                using var jsonReader = new JsonTextReader(textReader);
                return JsonSerializer.CreateDefault(DefaultSettings).Deserialize<T>(jsonReader);
            }
            catch (Exception ex)
            {
                Debug.LogError(ex);
                return default(T);
            }
        }
    
        public void Save(object saveData, string saveFilePath)
        {
            using var textWriter = new StreamWriter(saveFilePath, false, DefaultEncoding);
            JsonSerializer.CreateDefault(DefaultSettings).Serialize(textWriter, saveData);
        }
    }
    

    And also modify TestData not to populate its lists in the constructor as follows:

    public class TestData
    {
        public List<Resource> resourceList = new();
        public ObservableList<Resource> observableResourceList = new();
        public ReactiveProperty<int> number = new ();
        public ReactiveProperty<string> word = new ();
    
        public TestData()  { }
    
        public static TestData Create() =>
            new ()
            {
                resourceList = { new Resource("banana", 10), new Resource("apple", 3) },
                observableResourceList = { new Resource("oBanana", 5), new Resource("oApple", 2) },
                number = new ReactiveProperty<int>(9),
                word = new ReactiveProperty<string>("word string"),
            };
    }
    

    And you should be all set.

    Notes:

    • Type.MakeGenericType() and Activator.CreateInstance() can be a little slow, so in ReactivePropertyJsonConverter I used a concurrent dictionary to cache and reuse converter instances.

    • I seem to recall that, on some platforms (e.g. IOS), Type.GenericMakeGenericType() is not guaranteed to work. If you are running on those platforms ReactivePropertyJsonConverter will not work so you will need to manually construct all required ReactivePropertyJsonConverter<T> converters and add them to the JsonSerializerSettings.Converters list.

    • By default, when deserializing a pre-allocated List<T> property, Json.NET will append the deserialized items to the list. Thus if the list was already populated in some constructor, the contents of the list may get doubled.

      To avoid that I modified your TestData to populate the lists in a Create() method and did var testData = TestData.Create(); in the test method, but if you need to populate your lists inside constructors you will need to use ObjectCreationHandling.Replace as shown in JSON.NET Why does it Add to List instead of Overwriting?.

    • In Performance Tips: Optimize Memory Usage Newtonsoft recommends deserializing JSON files directly from file streams rather than loading the contents into strings then deserializing the strings.

    Demo fiddle here.