2
\$\begingroup\$

I have a use case where I need to deserialize some settings from a JSON file and override their values at runtime but when then configuration is saved, I want to keep the original value.

Imagine a simple JSON like this:

var json = @"{ 'WorkingDirectory': '\\bin' }";

You specify a relative path in the config but at runtime you resolve the path and use it in the entire application: @"c:\temp\bin".

Later when you save the configuration (because there are other saveable setting) you don't want to change this particular relative path but leave it untouched.


I solved this problem by creating a new type that I call Overrideable<T>. It has two properties. Value always returns the original value and gets serialized. Current returns the updated value and can be used at runtime. Current has no public setters. You use Value to actually override it and under the hood it sets the new value to Current.

public class Overrideable<T>
{
    private T _value;

    [JsonConstructor]
    public Overrideable(T value)
    {
        _value = value;
        Value = value;
    }

    public T Value { get => _value; set => Current = value; }

    [JsonIgnore]
    public T Current { get; private set; }

    public static implicit operator T(Overrideable<T> overridable) => overridable.Current;
}

To support this new type a new JsonConverter was necessary. It takes care of deserializing the value and creating an instance of the Overridable<T> and of serializing it by extracting the Value from it.

public class JsonOverridableConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        var genericArguments = objectType.GetGenericArguments();
        if (genericArguments.Length == 1)
        {
            var overridable = typeof(Overrideable<>).MakeGenericType(genericArguments[0]);
            return (objectType == overridable);
        }
        return false;
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        // .Single is safe here because CanConvert will prevent invalid types.
        var overridableGeneringArgument = objectType.GetGenericArguments().Single();
        var value = serializer.Deserialize(reader, overridableGeneringArgument);
        return Activator.CreateInstance(objectType, new[] { value });
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        // .Single is safe here because CanConvert will prevent invalid types.
        var valueType = value.GetType().GetGenericArguments().Single();
        var actualValue = value.GetType().GetProperty(nameof(Overrideable<object>.Value)).GetValue(value);
        serializer.Serialize(writer, actualValue);
    }
}

Example:

Here's some example code in case someone wanted to run it:

void Main()
{
    var json = @"{ 'WorkingDirectory': '\\bin' }";
    var config = JsonConvert.DeserializeObject<Configuration>(json, new JsonSerializerSettings
    {
        Converters = { new JsonOverridableConverter() }
    });

    config.WorkingDirectory.Value = @"c:\temp\bin";

    config.Dump();

    json = JsonConvert.SerializeObject(config, new JsonSerializerSettings
    {
        Converters = { new JsonOverridableConverter() }
    });

    json.Dump();
}

public class Configuration
{
    public Overrideable<string> WorkingDirectory { get; set; }
}

Can you think of a better solution or of any improvements?

\$\endgroup\$
2
\$\begingroup\$

As far as the actual code goes, the implementation is clean and I don't really have any comments :)

As for the idea, I don't think it's quite right. Settings should be that - just settings. As soon as you start adding behaviour to them, they're not settings any more and you should look at what you're missing. Taking your example:

public interface IFilePathService
{
    string GetPath(string file);
}

public class LocalFilePathService : IFilePathService
{
    private readonly string resolvedDirectory;

    public LocalFilePathService(
        string baseDirectory, 
        string workingDirectory)
    {
        // validation ignored.
        resolvedDirectory = Path.Combine(baseDirectory, workingDirectory);
    }

    public string GetPath(string file)
    {
        // validation ignored.
        return Path.Combine(resolvedDirectory, file);
    }
}

Now you create your service with 2 parts, one is your setting (working directory) and the other is the path to your base directory:

new LocalFilePathService(@"C:\temp", Settings.WorkingDirectory);

You just inject this service into all the places you need to get files in the working directory. You don't have any magic that you need to worry about. If you change the setting and save it, then you expect (and want) it to be saved.

\$\endgroup\$
  • 1
    \$\begingroup\$ Oh, I think this might really be a better idea ;-) \$\endgroup\$ – t3chb0t Nov 6 '17 at 10:16

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.