I have a few complex app.configs that I constantly manage to break. This often costs me a lot of time until I find the error/typo. To prevent such situations in future, I wrote a simple utility that should merge parts of other app.configs into the main app.config. This way I can test each config separately and merge into the final config if necessary.
It is called on post-build event, where the first file name is the target app.config in the output-dir and any other file names are sources.
The interface contains only one method:
public interface IXMerge
{
void Merge(XDocument source, XDocument target);
}
and is implemented by the XMerge
class.
public class XMerge
{
// Specifies which paths require merging by key.
// <Path, KeyGetter>
private readonly IReadOnlyDictionary<IEnumerable<string>, Func<XElement, string>> _keyMap;
public XMerge(IEnumerable<KeyValuePair<string, string>> keyMap)
{
_keyMap = keyMap.ToDictionary(
x => x.Key.Split('/'),
x => new Func<XElement, string>(e => e.Attribute(x.Value).Value),
RelayEqualityComparer<IEnumerable<string>>.Create(
(left, right) => left.SequenceEqual(right),
(obj) => obj.CalcHashCode()
)
);
}
// Copies elements from source to target one by one. Attributes are copied all at once.
public void Merge(XDocument sourceDocument, XDocument targetDocument)
{
var mode = GetRootMode(sourceDocument);
var source = sourceDocument.Root;
var target = targetDocument.Root;
// Using a stack because not a fan of recursion.
var stack = new Stack<(XElement Source, XElement Target, IEnumerable<string> Path, Mode Mode)>
{
(source, target, Enumerable.Empty<string>(), mode)
};
while (stack.Any())
{
var current = stack.Pop();
var directives = new HashSet<Directive>();
foreach (var node in current.Source.Nodes())
{
switch (node)
{
case XComment comment:
if (Directive.TryParse(comment, out var directive))
{
directives.AddOrUpdate(directive);
}
break;
case XElement element:
// Update mode if new one is available.
mode = directives.ModeOrDefault() ?? current.Mode;
// Include mode requires elements to be explicitly marked as included in order to be copied.
var canCopyIncluded =
mode.Value == MergeMode.Include &&
directives.Copy().Mode == MergeMode.Include &&
directives.Copy().Scope.In(MergeScope.Single, MergeScope.Begin);
// Exclude mode requires elements to be explicitly marked as excluded in order to NOT be copied.
var canCopyNotExcluded =
mode.Value == MergeMode.Exclude &&
directives.Copy().Mode != MergeMode.Exclude &&
directives.Copy().Scope.In(MergeScope.Single, MergeScope.Begin);
// Indicates stop copying at the end of a scope.
var isEnd = directives.Copy().Scope.In(MergeScope.End);
if ((canCopyIncluded || canCopyNotExcluded) && !isEnd)
{
// If element is key-copyable then evaluate its existence.
var copyByKey = _keyMap.TryGetValue(Enumerable.Append(current.Path, element.Name.LocalName), out var getKey);
// This works only in Visual Studio.
//var copyByKey = _keyMap.TryGetValue(current.Path.Append(element.Name.LocalName), out var getKey);
var elementExists = copyByKey && current.Target.Elements().Any(x => getKey(x) == getKey(element));
if (copyByKey && !elementExists)
{
ConsumeLabels(current.Target, directives);
current.Target.Add(element);
}
else
{
target = current.Target.Element(element.Name.LocalName);
if (target is null)
{
ConsumeLabels(current.Target, directives);
// Without default namespace it'll fail for some xmlns elements.
var defaultNamespace = element.GetDefaultNamespace();
// Child elements should be evaluated separately so copy only the current element.
target = new XElement(defaultNamespace + element.Name.LocalName, element.Attributes());
current.Target.Add(target);
}
// Due to a bug in LINQPad the Append extension from .net 4.7.1 conflicts with my own with the same signature.
stack.Push((element, target, Enumerable.Append(current.Path, element.Name.LocalName), mode));
// This works correctly in Visual Studio.
//stack.Push((element, target, current.Path.Append(element.Name.LocalName), mode));
}
}
// In order to stop copying after a single-copy this directive needs to be removed.
if (directives.Copy().Scope.In(MergeScope.Single))
{
directives.Remove(directives.Copy());
}
break;
}
}
}
}
private static Mode GetRootMode(XDocument xDoc)
{
foreach (var node in xDoc.Nodes())
{
// It's not possible to use a helper variable here
// because it won't compile due to an unitialized 'directive' error.
if (node is XComment comment && Directive.TryParse(comment, out var directive))
{
// Mode must be the first directive.
return
directive is Mode mode
? mode
: throw (
$"InvalidDirective{nameof(Exception)}",
$"The first directive must be {nameof(Mode)} ({Directive.Prefix}mode: <incl|excl>)."
).ToDynamicException();
}
}
// Mode directive must be specified explicitly. There is no default value.
throw (
$"ModeDirectiveNotFound{nameof(Exception)}",
$"You must specify the {nameof(Mode)} ({Directive.Prefix}mode: <incl|excl>)."
).ToDynamicException();
}
// Adds labesl to the element and removes them from directives.
private static void ConsumeLabels(XElement target, HashSet<Directive> directives)
{
var labels = directives.OfType<Label>();
foreach (var label in labels)
{
target.Add(new XComment(label.Text.EncloseWith(" ")));
}
directives.RemoveWhere(x => labels.Contains(x));
}
}
This class runs over the xml-tree and compares the source with the target element by element and copies them if required. Attributes are copied all at once and are not compared.
It supports two modes:
xm-mode: incl
- elements must be marked withxm-incl
in order to be copiedxm-mode: excl
- elements must be marked withxm-excl
in order to NOT be copied
Modes are set via directives which are xml-comments. Other directives are:
xm-incl[: begin|end]
- begins/ends a copy scope where multiple elements can be copiedxm-excl[: begin|end]
- begins/end a copy scope where multiple elements should not be copiedxm-label: <text>
- allow to add comments to copied elements
A source file may look like this:
<?xml version="1.0" encoding="utf-8"?>
<!-- xm-mode: incl -->
<configuration>
<!-- xm-incl -->
<configSections>
<!-- xm-incl -->
<section name="bar" />
</configSections>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7"/>
</startup>
<!-- xm-incl -->
<appSettings>
<add key="not" value="copied" />
<!-- xm-incl -->
<add key="bar" value="qux" />
<!-- xm-label: Credentials -->
<!-- xm-incl: begin -->
<add key="UserName" value="abc" />
<add key="Password" value="xyz" />
<!-- xm-incl: end -->
<add key="not2" value="copied2" />
<!-- xm-incl -->
<add key="qux" value="quux"/>
<add key="not3" value="copied3" />
</appSettings>
<configuration>
Because almost all elements require different merge rules (some of them use name
while others use key
or id
attributes to disambiguate their children). I use a dictionary with their paths to map them to the attribute that is used as a key:
var keyMap = new Dictionary<string, string>
{
["configSections/section"] = "name",
["appSettings/add"] = "key",
["connectionStrings/add"] = "name",
["system.runtime.remoting/channelSinkProviders/clientProviders/provider"] = "id",
["system.runtime.remoting/application/channels/channel"] = "ref",
["system.runtime.remoting/application/channels/channel/clientProviders/provider"] = "ref",
};
This is passed to the constructor.
Directives are implemented as classes derived from the Directive
base type. Each of them has a static factory method TryParse
that I use to parse the XML comments.
public abstract class Directive : IEquatable<Directive>
{
public const string Prefix = "xm-";
private delegate bool TryParseFunc(XComment comment, out Directive directive);
private static readonly IEnumerable<TryParseFunc> _factories = new TryParseFunc[]
{
Mode.TryParse,
Label.TryParse,
Copy.TryParse
};
// By default there can be only one directive of each type name.
[AutoEqualityProperty]
public virtual string Name => GetType().Name;
public static bool TryParse(XComment comment, out Directive directive)
{
foreach (var factory in _factories)
{
if (factory(comment, out directive))
{
return true;
}
}
directive = default;
return false;
}
public bool Equals(Directive other) => AutoEquality<Directive>.Comparer.Equals(this, other);
public override bool Equals(object obj) => obj is Directive d && Equals(d);
public override int GetHashCode() => AutoEquality<Directive>.Comparer.GetHashCode(this);
}
// <!-- xm-mode: <incl|excl> -->
public class Mode : Directive
{
public static readonly Mode None = new Mode { Value = MergeMode.None };
public MergeMode Value { get; private set; }
public static new bool TryParse(XComment comment, out Directive mode)
{
var match = Regex.Match(comment.Value, $"{Prefix}mode:(?<args>.+)");
if (match.Success)
{
Enum.TryParse<MergeMode>(match.Groups["args"].Value.Trim(), true, out var value);
mode = new Mode
{
Value = value
};
return true;
}
else
{
mode = default;
return false;
}
}
}
// <!-- xm-label: <"text"> -->
public class Label : Directive
{
public string Text { get; private set; }
// Labels are allowed to be defined multiple times.
public override string Name => Text;
public static new bool TryParse(XComment comment, out Directive label)
{
var match = Regex.Match(comment.Value, $"{Prefix}label:(?<args>.+)");
if (match.Success)
{
label = new Label
{
Text = match.Groups["args"].Value.Trim()
};
return true;
}
else
{
label = default;
return false;
}
}
}
// <!-- xm-<include|exclude>[: begin|end] -->
public class Copy : Directive
{
public static readonly Copy None = new Copy { Mode = MergeMode.None, Scope = MergeScope.None };
public MergeMode Mode { get; internal set; }
public MergeScope Scope { get; internal set; }
public static new bool TryParse(XComment comment, out Directive copy)
{
var directiveMatch = Regex.Match(comment.Value, $"{Prefix}(?<mode>incl|excl(ude)?)(:(?<args>.+))?");
if (directiveMatch.Success)
{
var mode = directiveMatch.Groups["mode"].Value;
var args = directiveMatch.Groups["args"].Value.Split(',').Select(x => x.Trim());
var ignoreCase = true;
copy = new Copy
{
Mode = (MergeMode)Enum.Parse(typeof(MergeMode), mode, ignoreCase),
Scope = Enum.TryParse<MergeScope>(args.FirstOrDefault(), ignoreCase, out var scope) ? scope : MergeScope.Single
};
return true;
}
else
{
copy = default;
return false;
}
}
}
They are supported by two enumes and a couple of extensions for common operations:
public enum MergeMode
{
None,
Include,
Incl = Include,
Exclude,
Excl = Exclude
}
public enum MergeScope
{
None,
Single,
Begin,
End,
}
public static class HelperExtensions
{
public static HashSet<T> AddOrUpdate<T>(this HashSet<T> set, T item)
{
set.Remove(item);
set.Add(item);
return set;
}
public static Mode ModeOrDefault(this HashSet<Directive> directives)
{
return directives.OfType<Mode>().SingleOrDefault();
}
public static Copy Copy(this HashSet<Directive> directives)
{
return directives.OfType<Copy>().SingleOrDefault() ?? new Copy { Mode = MergeMode.None, Scope = MergeScope.Single }; ;
}
public static bool In<T>(this T value, params T[] others)
{
return others.Contains(value);
}
}
This works and does its job but do you see anything that could be done better?
I'm not going to create any super-sophisticated merge-utility with overwrites and hierarchy or specificity, just merge unique parts from various app.configs to avoid copy/paste/edit mistakes.
appSettings
from three or more files seems to be impossible. \$\endgroup\$ – t3chb0t Dec 4 '17 at 17:02<appSettings configSource="appSettings.config" />
, which means thatappSettings
comes from a separate file. The catch is that you can still only have one of each section. So if you want multiple files, each containing key/value pairs, you would need to define them as new configuration sections. If you have tons and tons of settings, splitting them into separate sections might not be bad even if they're in the same file. But then you also have the option of breaking them into separate files. \$\endgroup\$ – Scott Hannen Dec 15 '17 at 18:56