One of the first things we noticed when we began to use version control to manage our source code is that configuration settings across multiple machines became unmanageable. For starters, different people checked out projects to different locations on their hard-drives, had different connection string settings depending on whether or not they were in the office or at home, and other people wanted different email settings than other developers, amongst a few other things.
What we needed was a per-machine configuration solution which could allow different settings for different developers, which should fall-back to reading the standard web.config if a setting wasn’t available in the custom file. This would effectively allow the developer to override settings as they see fit without affecting other developers. The following class to handle this is what we came up with. Most developers have the proverbial “toolbox” of code which they will utilise in most projects, and this is a good one for your collection.
The requirements that we would like to fulfil with our implementation are:
- Allows the developer to request an application setting or connection string by key
- Should try to read the setting from an XML file from the root of the website. The file name should include the local machine name.
- If the setting or connection string cannot be found, the class should attempt to read it from the web.config using the standard ConfigurationManager framework.
- Because the machine-specific settings and connection strings should override the common settings from the web.config, an exception should be thrown if a setting which is defined in the host settings file does not also exist in the common settings file.
- For performance reasons, the settings file should be cached in memory to keep from reading from disk every time a setting is read.
Download the source code to accompany this article
For starters then, I’ve called my implementation the ConfigSettingsProxy. Here’s the model we’ll end up with by the time we’ve finished this article:
Let’s just go through each of the objects and define their purpose.
ConfigSettingsProxy
This is the main public interface for our configuration framework. The two methods GetAppSetting and GetConnectionString simply allow us to retrieve application settings and connection strings respectively. It knows about two instances of ISettingsReader – a HostSettingsReader, which reads settings and connection strings specific to the host machine, and a CommonSettingsReader, which reads settings and connection strings which are common to all hosts (read: all developers). In actual fact, ConfigSettingsProxy only knows about the interface ISettingsReader, but it’s how it deals with the idea of host settings and common settings which is the real purpose, as we shall see in the next section.
ISettingsReader
This is the interface which defines what a settings reader can do. In this case, it can at least read settings and connection strings.
HostSettingsReader
This is the first implementation class which implements ISettingsReader, and this deals with the reading of host-specific settings and connection strings. It knows to look for a specific file name containing the host machine’s name and it knows about the format the file is in. It also performs caching and cache invalidation for us.
CommonSettingsReader
This is the second implementation of ISettingsReader, which knows how to read settings which are common to all machines. This simply reads from the appropriate sections from the web.config via the standard ConfigurationManager.
Lets dive into some code, starting with ConfigSettingsProxy:
public class ConfigSettingsProxy { ISettingsReader _commonSettingsReader = null; ISettingsReader _hostSettingsReader = null; public ConfigSettingsProxy() : this(new HostSettingsReader(), new CommonSettingsReader()) { } public ConfigSettingsProxy(ISettingsReader hostSettingsReader, ISettingsReader commonSettingsReader) { if (commonSettingsReader == null) throw new ArgumentNullException("commonSettingsReader"); if (hostSettingsReader == null) throw new ArgumentNullException("hostSettingsReader"); _commonSettingsReader = commonSettingsReader; _hostSettingsReader = hostSettingsReader; } public string GetAppSetting(string key) { var hostValue = _hostSettingsReader.GetSetting(key); var commonValue = _commonSettingsReader.GetSetting(key); if (hostValue == null) return commonValue; else { if (commonValue == null) throw new InvalidOperationException("Settings key '" + key + "' does not override a common setting"); return hostValue; } } public string GetConnectionString(string name) { var hostValue = _hostSettingsReader.GetConnectionString(name); var commonValue = _commonSettingsReader.GetConnectionString(name); if (hostValue == null) { return commonValue; } else { if(commonValue == null) throw new InvalidOperationException("Connection string name '" + name + "' does not override a common connection string"); return hostValue; } } }
This class takes care of calling into the correct settings reader for us. For both GetAppSetting and GetConnectionString methods, it reads the value from both the host settings and the common settings readers. If the host setting has not been specified, it returns the common setting. Otherwise, it returns the host setting only if the common setting has also been specified. This check is in place to reinforce the mechanism of overriding common settings. Imagine what would happen if a developer was able to program against his own host settings without the same keys also being available to the other developers who didn’t know about these settings?
Just to finish this class off, the default constructor just initialises the class with the default implementations for host and common settings, but also allows you to specify new implementations if your solution requires it, or for unit testing. I haven’t covered it here but the sample project for this post contains unit tests which demonstrates this.
This class only knows about the ISettingsReader interface, so lets define exactly what that is:
public interface ISettingsReader { string GetSetting(string key); string GetConnectionString(string connectionStringName); }
Simple stuff; it merely defines a contract for a class to retrieve settings and connection strings. The real power is in the following two implementation classes; HostSettingsReader and CommonSettingsReader.
HostSettingsReader
public class HostSettingsReader : ISettingsReader { public string SettingsFilePath { get; private set; } public HostSettingsReader() { string machineName = Environment.MachineName; string fileName = string.Format("~/Settings_{0}.xml", machineName); string path = HostingEnvironment.MapPath(fileName); this.SettingsFilePath = path; } public HostSettingsReader(string path) { if (string.IsNullOrEmpty(path)) throw new ArgumentNullException("path"); this.SettingsFilePath = path; } public string GetSetting(string key) { XDocument settingsDocument = GetDocument(); var query = from n in settingsDocument.Descendants("appSettings") from k in n.Elements() where k.Attribute("key").Value == key select k.Attribute("value").Value; return query.FirstOrDefault(); } public string GetConnectionString(string name) { XDocument settingsDocument = GetDocument(); var query = from n in settingsDocument.Descendants("connectionStrings") from k in n.Elements() where k.Attribute("name").Value == name select k.Attribute("connectionString").Value; return query.FirstOrDefault(); } private XDocument GetDocument() { XDocument doc = MemoryCache.Default["LocalSettingsDocument"] as XDocument; if (doc == null) { if (!File.Exists(this.SettingsFilePath)) { XElement element = new XElement("settings", new XElement("appSettings"), new XElement("connectionStrings")); doc = new XDocument(element); doc.Save(SettingsFilePath); } else { doc = XDocument.Load(this.SettingsFilePath); } var policy = new CacheItemPolicy(); policy.AbsoluteExpiration = DateTime.Now.AddYears(1); policy.ChangeMonitors.Add(new HostFileChangeMonitor(new string[] { this.SettingsFilePath })); MemoryCache.Default.Add("LocalSettingsDocument", doc, policy); } return doc; } }
The main things to note about this implementation are:
- By default, it looks for an Xml file in the root of the site called “Settings_<machine name>.xml”
- It does assume you’re using a web site. For other project types where a web context is unavailable, a new implementation could be provided to get the file from elsewhere (or use the second constructor which takes a path name)
- Settings are retrieved by simply opening up the file and looking for specific Xml nodes with the specified keys as attributes. If you think about it, the settings and connection strings in this file are specified in exactly the same way as you would in the web.config normally, meaning that they can just be copied out and pasted without further change in order to override a setting or connection string.
- Caching is provided by the new caching objects in the .Net Framework since version 4.0. Here the Xml setting files are cached for a year, but also specify a HostFileChangeMonitor so that when the settings are changed, the cache is invalidated and the new settings read on the next request.
The other implementation we have is for common settings. These are simply read from the configuration file as normal, but wrapped up in an implementation of ISettingsReader:
public class CommonSettingsReader : ISettingsReader { public string GetSetting(string key) { return ConfigurationManager.AppSettings[key]; } public string GetConnectionString(string connectionString) { if (ConfigurationManager.ConnectionStrings[connectionString] != null) return ConfigurationManager.ConnectionStrings[connectionString].ConnectionString; else return null; } }
As you can see, it’s nothing more complex than the standard code you would normally write in order to retrieve settings from the configuration file.
That then is the complete implementation. A sample host settings file would look like the following. As mentioned, the Xml has been formatted deliberately so that keys can be copied in and out of the web.config file without change.
<?xml version="1.0" encoding="utf-8"?> <settings> <appSettings> <add key="TestSetting" value="This is a test setting from the local settings file!" /> </appSettings> <connectionStrings> <add name="TestConnectionString" connectionString="This is a test connection string from the local settings file!"/> </connectionStrings> </settings>
Included in the downloadable source code for this project is a test website which contains the previous Xml settings file, plus this web page which demonstrates it working:
<%@ Page Language="C#" %> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <script runat="server"> public void Page_Load(object sender, EventArgs e) { var proxy = new ConfigurationProxy.ConfigSettingsProxy(); ConfigurationSettingLiteral.Text = proxy.GetAppSetting("TestSetting"); ConnectionStringLiteral.Text = proxy.GetConnectionString("TestConnectionString"); } </script> <html xmlns="http://www.w3.org/1999/xhtml"> <head runat="server"> <title></title> </head> <body> <form id="form1" runat="server"> <div> <p>Config setting: <asp:Literal runat="server" ID="ConfigurationSettingLiteral" /></p> <p>Connection string: <asp:Literal runat="server" ID="ConnectionStringLiteral" /></p> </div> </form> </body> </html>
In our production environment, we would create a class with static properties to match the configuration keys we had in our web.config file, using ConfigSettingsProxy to actually read the settings. This gave us hard-typed access to configuration whilst making them effectively override-able to match our own individual environments.