Saving .NET configuration to a JSON file.
I was developing a piece of software that, as usual, required configuration information.
I decided to use the JSON configuration, both because of the ease-of-use format and for native support in .NET 7.0 with the JsonConfigurationProvider
.
However, to my surprise, this provider could read the configuration, no problem, but it could not save any changes done at run-time to the configuration file.
Searching online I stumbled upon this answer on StackOverflow.
Implementing Paweł Górszczak solution yielded a provider that could save any alteration done at run-time to the JSON file.
As he alluded in his comment:
//... It requires modification if you need to support change multi level json structure.
The saved JSON file is a flat file in the dictionary form, that is, key-value pairs. Which is how the configuration is stored internally by the ConfigurationProvider
.
That being said, though, I decided that it was too unseemly to have a JSON format and use it simply to store key-value pairs of a more complex format. So, I decide to write a way for the ConfigurationProvider
to write proper JSON files.
And this was the result:
using Microsoft.Extensions.Configuration.Json;
using System.Dynamic;
namespace ManifestPrinter.Configuration
{
/// <summary>
/// Represents a property provider that uses a JSON file.
/// </summary>
public class WritableJsonConfigurationProvider : JsonConfigurationProvider
{
/// <summary>
/// Creates a new instance with the <see cref="WritableJsonConfigurationProvider"/>.
/// </summary>
/// <param name="source">The source settings.</param>
public WritableJsonConfigurationProvider(JsonConfigurationSource source) : base(source)
{
SaveTimer = new()
{
Interval = 500,
Enabled = false,
};
SaveTimer.Elapsed += (s, e) =>
{
SaveInternal();
SaveTimer.Stop();
};
}
/// <summary>
/// Allows access to the underlying data dictionary.
/// </summary>
/// <returns>An <see cref="IDictionary{TKey, TValue}"/> that contains the data.</returns>
internal IDictionary<string, string?> GetData() => Data;
/// <inheritdoc/>
public override void Set(string key, string? value)
{
if (Data.TryGetValue(key, out string? currValue) && currValue == value) return;
base.Set(key, value);
Save();
}
/// <summary>
/// A lock object.
/// </summary>
private readonly static object lockObj = new();
/// <summary>
/// Saves the configuration to the file.
/// </summary>
/// <exception cref="FileNotFoundException">Thrown when the configuration file was not found.</exception>"
private void SaveInternal()
{
/*
* It was done this way because for some reason
* the program was cutting some keys from the configuration
* when saving the configuration to the JSON file.
*
* This way we gurantee that all the keys are saved.
*/
try
{
SaveTimer.Stop();
/*
* Read the old condiguration.
*/
var fileFullPath = Source.FileProvider.GetFileInfo(Source.Path).PhysicalPath;
if (!File.Exists(fileFullPath))
throw new FileNotFoundException("The configuration file was not found.", fileFullPath);
using var stream = File.OpenRead(fileFullPath);
var data = JsonConfigurationFileParser.Parse(stream);
/*
* Update the old configuration with the new one.
*/
foreach (var key in Data.Keys.Where(x => !data.ContainsKey(x) || Data[x] != data[x]))
data[key] = Data[key];
/*
* Generate the ExpandoObject, convert it to a JSON string and save it to the configuration file.
*/
var output = Utils.ConvertToJson(GetExpandoObject(data));
lock (lockObj)
while (true)
try
{
File.WriteAllText(fileFullPath, output);
break;
}
catch (IOException ex)
{
/*
* If the file is being used by another process,
* we wait a little bit and try again.
*/
if (ex.Message.Contains("is being used by another process"))
Thread.Sleep(100);
else
throw;
}
}
catch (InvalidDataException ex)
{
/*
* Something went wrong, so we log it, but ignore it.
* And we will try again later.
*/
Log.Write(ex);
Log.Write("Attempting to save configuration file again in a few moments.");
Save();
}
}
/// <summary>
/// Saves the current configuration to the file.
/// </summary>
public void Save()
{
SaveTimer.Stop();
SaveTimer.Start();
}
/// <summary>
/// A timer that is used to save the configuration to the file.
/// </summary>
private readonly System.Timers.Timer SaveTimer;
/// <summary>
/// Returns an <see cref="ExpandoObject"/> that represents the configuration.
/// </summary>
/// <param name="data">An <see cref="IDictionary{TKey, TValue}"/> that contains the configuration.</param>
/// <returns>An <see cref="ExpandoObject"/> that represents the configuration.</returns>
private static ExpandoObject GetExpandoObject(IDictionary<string, string?> data)
{
var exp = new ExpandoObject();
/*
* We use a stack to keep track of the hierarchy of ExpandoObjects.
*/
var stk = new Stack<object>();
/*
* The keys of the data property are separated by a colon (:),
* we split the keys by the colon and create a hierarchy of ExpandoObjects.
* Finally the ExpandoObject at the top of the hierarchy is the one that will be returned.
*/
foreach (var key in data.Keys)
{
/*
* If the key contains a colon (:), it means that it is a hierarchical key.
*/
var hier = key.Split(':');
if (hier.Length == 1)
{
((IDictionary<string, object?>)exp)[key] = data[key];
continue;
}
/*
* Create the hierarchy of ExpandoObjects.
*/
var myKey = "";
object obj = exp;
IDictionary<string, object?> curr;
stk.Clear();
for (int i = 0; i < hier.Length; i++)
{
var root = (i == 0);
var leaf = (i == hier.Length - 1);
myKey += (i == 0 ? "" : ":") + hier[i];
if (!(root || leaf) && data.ContainsKey(myKey))
{
/*
* This handles a very special case:
* When someone physically change the
* JSON so that one of the keys is an
* empty object.
*
* We remove that entry from the data
* and add a new ExpandoObject in its
* place.
*/
data.Remove(myKey);
curr = stk.Pop() as IDictionary<string, object?>;
curr ??= stk.Pop() as IDictionary<string, object?>;
curr[hier[i]] = new ExpandoObject();
}
if (int.TryParse(hier[i], out int ndx))
{
/*
* A special case, if the key is something like "key:0", it means that it's an array.
* So, we create a list of ExpandoObjects that represents the array.
*/
var list = obj as List<object>;
if (list.Count == ndx)
list.Add(leaf ? data[key] : obj = new ExpandoObject());
else
obj = list[ndx];
}
else
{
/*
* Insert the value or another ExpandoObject in the current ExpandoObject.
*/
curr = obj as IDictionary<string, object?>;
if (!curr.ContainsKey(hier[i]))
curr.Add(hier[i], leaf ? data[key] : hier[i + 1].IsNumeric() ? new List<object>() : new ExpandoObject());
obj = curr[hier[i]];
}
stk.Push(obj);
}
}
return exp;
}
/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (disposed) return;
base.Dispose(disposing);
SaveTimer?.Dispose();
disposed = true;
}
private bool disposed;
}
}
I tried to use override the Get method to retrieve default information from attributes in the underlying configuration classes, but it was too much of a hassle.
If anyone wants to tackle this, let me know.
Comments
Post a Comment