Using custom configuration section and custom actions to update and deploy App.config for different regions dynamically with Msi package






3.63/5 (5 votes)
Article attempts to solve hassle of maintaining and deploying multiple configuration files for different testing/deployment stages/environments.
Introduction
Article attempts to solve hassle of maintaining and deploying multiple configuration files for different testing/deployment stages/environments.
Target Audience
This article is for those who are in Winform application development/deployment and deploy application(s) using MSIs on different testing stages till production.
What Problem Does This Article Solve?
If you maintain multiple App.config
files per testing/deployment stages, you would have different value(s) for keys in appSettings
section per testing/deployment stage. For an example :
//Development environment
<add key="useURL" value="http://developmentURL/useURL" />
//Test/Deployment stage 1
<add key="useURL" value="http://Stage1URL/useURL" />
//Test/Deployment stage 2
<add key="useURL" value="http://Stage2URL/useURL" />
In addition to this for a single new line and you need to go through the pain of finding and updating all App.config
files. This article attempts to solve this problem by keeping single App.config
file and updating the file dynamically for each deployment region during installation of application.
Prerequisites
The article uses custom configuration section and custom actions (i.e. Installer class) with windows application setup projects (i.e. MSI) to update and deploy App.config
files for different deployment regions at installation time. Basic knowledge of these is required. If you don't know how to create and use custom configuration classes Click here. If you don't know how to create and use custom actions (i.e. Installer class) Click here.
Background
I have been involved in Winform application development from last few years and recently got the deployment responsibilities. During this time I realized the various pain areas of updating configuration files for different regions i.e development, Testing, production etc. For every region configuration files are required to be updated which is quite error prone activity.
Now here comes the destiny to save me from this pain. I recently got training (God bless my company :)) on creating custom configuration classes and the next moment I knew the best place to utilize this knowledge.
I'll be calling testing/deployment stage/environment Deployment Region throughout this article from now.
What's the Idea?
The problem is mostly with the keys' values in appSettings section specific to deployment region. The idea to solve this is to keep a separate section for deployment regions and add key-value pair(s) per specific region. Create a custom action (i.e. Installer) class and override Install method. Add logic to overridden Install method to read custom configuration from App.config
, update key values, remove deployment region section detail and save it.
See below how the App.config will look in development:
<deploymentRegionSection> <DeploymentRegions> <DeploymentRegion RegionName="RegionOne"> <AppKeys> <AppKey Key="targetURLFirst" Value="http://RegionOne/targetURLFirst"/> </AppKeys> </DeploymentRegion> <DeploymentRegion RegionName="RegionTwo"> <AppKeys> <AppKey Key="targetURLFirst" Value="http://RegionTwo/targetURLFirst"/> <AppKey Key="targetURLSecond" Value="http://RegionTwo/targetURLSecond"/> </AppKeys> </DeploymentRegion> </DeploymentRegions> </deploymentRegionSection> <appSettings> <add key="targetURLFirst" value="http://DevelopmentRegion/targetURLFirst"/> <add key="targetURLSecond" value="http://DevelopmentRegion/targetURLSecond"/> </appSettings>
Now when I deploy application in deployment region RegionOne it should look like:
<appSettings> <add key="targetURLFirst" value="http://RegionOne/targetURLFirst"/> <add key="targetURLSecond" value="http://DevelopmentRegion/targetURLSecond"/> </appSettings>
And when I deploy application in deployment region RegionTwo it should look like:
<appSettings> <add key="targetURLFirst" value="http://RegionTwo/targetURLFirst"/> <add key="targetURLSecond" value="http://RegionTwo/targetURLSecond"/> </appSettings>
Solution step-by-step
Here are the steps how we'll achieve the above (assuming that you already have a windows application and a setup project):
- Create a class library project which will contain classes to handle custom deployment regions section.
- Add classes to read deployment region section/elements from configuration file to above project.
- Add a installer class to winform application. We'll call it
SetupHelper
. - Add code to
SetupHelper
class to fetch target directory, read custom configuration section and update config file. - Update
App.config
file to containdeploymentRegionSection
. - Add winform application assembly to
Install
custom action in setup project - Modify
CustomActionData
property of above action to pass value ofTARGETDIR
totargetDirectory
parameter
Solution in detail
Creating a class library project to read deploymentRegionSection
Add a new class library project to the solution and name it CustomConfigSectionAndActions.DeploymentRegionSectionHandler
Add classes to read deployment region section/elements to CustomConfigSectionAndActions.DeploymentRegionSectionHandler
Add following 5 classes to the project:
DeploymentRegionConfigurationSection.cs
- To read the configuration section and a property of element collection of deployment regions.DeploymentRegionElementCollection.cs
- Collection of deployment region elements.DeploymentRegionElement.cs
- Element to contain region specific app keys and a property to define region's name.AppKeyValueElementCollection.cs
- Collection of AppKey value elements for a specific region.AppKeyValueElement.cs
- Key-value pair element to have region specific key's value.
I'm not showing any code here as it's pretty straightforward and you've source code to download.
Adding SetupHelper
class to winform application (i.e.CustomConfigSectionAndActions.WinformApplication
)
1. Right click on CustomConfigSectionAndActions.WinformApplication
project node and click on Add > New Item select Installer Class
and provide SetupHelper
as class name
2. Add a private string variable _targetDirectory
to this class
3. override Install method
public override void Install(IDictionary stateSaver) { base.Install(stateSaver); _targetDirectory = Context.Parameters["targetDirectory"]; if (!string.IsNullOrEmpty(_targetDirectory)) { UpdateDeploymentRegions(); } }
Context.Parameters["targetDirectory"]
will give target directory path which will be passed by MSI to SetupHelper
.
4. Add method to read and update configuration file
private void UpdateDeploymentRegions()
{
string sourceExePath = Path.Combine(_targetDirectory, "CustomConfigSectionAndActions.WinformApplication.exe");
string deploymentRegionSectionHandlerDllPath = Path.Combine(_targetDirectory, "CustomConfigSectionAndActions.DeploymentRegionSectionHandler.dll");
//Load configuration
Configuration sourceConfiguration = ConfigurationManager.OpenExeConfiguration(sourceExePath);
if (sourceConfiguration != null)
{
//Load deployment region configuration section
DeploymentRegionConfigurationSection deploymentRegionSectionHandler = GetCustomConfiguration<DeploymentRegionConfigurationSection>(deploymentRegionSectionHandlerDllPath, sourceExePath + ".config", "deploymentRegionSection");
if (deploymentRegionSectionHandler != null)
{
//Find current stage/region
string currentRegion = CurrentDeploymentRegion();
//Check if deployment config section contains key to be updated for current region otherwise
//default app.config keys will be used
foreach (DeploymentRegionElement deploymentRegion in deploymentRegionSectionHandler.DeploymentRegions)
{
//Found current region in deployment configuration section
if (deploymentRegion.RegionName == currentRegion)
{
//Find each key and update it
foreach (AppKeyValueElement appKey in deploymentRegion.AppKeys)
{
//Find key in appSettings section of source App.config file
KeyValueConfigurationElement sourceKeyValue = sourceConfiguration.AppSettings.Settings[appKey.Key];
if (sourceKeyValue != null)
{
//Update the value
sourceKeyValue.Value = appKey.Value;
}
}
//Remove the deployment region section from App.config during deployment
//we certainly don't require this
sourceConfiguration.Sections.Remove("deploymentRegionSection");
//Save all changes
sourceConfiguration.Save();
break;
}
}
}
else
{ //Deployment region configuration could not be loaded
}
}
}
The above method uses ConfigurationManager.OpenExeConfiguration
function to load configuration file and passes winform exe's file name to it. If you don't know, App.config
file gets renamed when deployed with exe file name, in current sample it will be CustomConfigSectionAndActions.WinformApplication.exe.config.Then it calls GetCustomConfiguration<>
generic function to load deploymentRegionSection
.
To read a config section ConfigurationManager.GetSection("Section Name")
can simply be used but due to some reason CLR
doesn't find CustomConfigSectionAndActions.DeploymentRegionSectionHandler.dll
assembly (why? I'll address this in Points of Interest section) from target/installation directory and throws "cannot load assembly.." exception.
To resolve this, the code must handle AppDomain.CurrentDomain.AssemblyResolve
event and return CustomConfigSectionAndActions.DeploymentRegionSectionHandler.dll
assembly. Following is definition of GetCustomConfiguration<>
generic function.
protected TConfig GetCustomConfiguration<TConfig>(string configDefiningAssemblyPath, string configFilePath, string sectionName) where TConfig : ConfigurationSection { //Add event handler to resolve deployment region section handler assembly loading AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(ConfigResolveEventHandler); //Load deployment region section handler assembly _configurationDefiningAssembly = Assembly.LoadFrom(configDefiningAssemblyPath); //Create exe configuration file map var exeFileMap = new ExeConfigurationFileMap(); exeFileMap.ExeConfigFilename = configFilePath; //Open configuration file var customConfig = ConfigurationManager.OpenMappedExeConfiguration(exeFileMap, ConfigurationUserLevel.None); //Load deployment region section var returnConfig = customConfig.GetSection(sectionName) as TConfig; //Remove event handler AppDomain.CurrentDomain.AssemblyResolve -= ConfigResolveEventHandler; return returnConfig; } protected Assembly ConfigResolveEventHandler(object sender, ResolveEventArgs args) { return _configurationDefiningAssembly; }
After loading deploymentRegionSection
it's time to find current region in which the application is being deployed. Add CurrentDeploymentRegion
function:
private string CurrentDeploymentRegion() { //Add logic here to find the current deployment region return "RegionTwo"; }
For sample purpose I'm returning hard-coded string value. Mostly it's the logged-in domain name from registry but you can add whatever suits your application.
Updating winform appilcation's App.config
file
Add sectionGroup
to configSection
section. Pass deploymentRegionSection
value to name
property. Pass deployment region configuration section handler's class name with full namespace to type
property. see below:
<section name="deploymentRegionSection" type="CustomConfigSectionAndActions.DeploymentRegionSectionHandler.DeploymentRegionConfigurationSection, CustomConfigSectionAndActions.DeploymentRegionSectionHandler"/>
Finally add deploymentRegionSection
to configuration file.
<deploymentRegionSection> <DeploymentRegions> <DeploymentRegion RegionName="RegionOne"> <AppKeys> <AppKey Key="targetURLFirst" Value="http://RegionOne/targetURLFirst"/> </AppKeys> </DeploymentRegion> <DeploymentRegion RegionName="RegionTwo"> <AppKeys> <AppKey Key="targetURLFirst" Value="http://RegionTwo/targetURLFirst"/> <AppKey Key="targetURLSecond" Value="http://RegionTwo/targetURLSecond"/> </AppKeys> </DeploymentRegion> </DeploymentRegions> </deploymentRegionSection>
Make sure you've same key(s) in appSettings
section:
<appSettings> <add key="targetURLFirst" value="http://DevelopmentRegion/targetURLFirst"/> <add key="targetURLSecond" value="http://DevelopmentRegion/targetURLSecond"/> </appSettings>
Add winform application to Install
custom action in setup project
Go to Custom Actions by right clicking on CustomConfigSectionAndActions.CustomConfigSectionAndActionSetup
project node and goto View>Custom Actions
Right click on Install
node and select "Add Custom Action"
Select Primary output from CustomConfigSectionAndActions.WinformApplication
as action and set CustomActionData
property value to /targetDirectory="[TARGETDIR]\". With CustomActionData
we can pass parameters to installer class (i.e. SetupHelper
).
Congratulations! We're done it's time to build your setup project. Go ahead... build the MSI, install it on target machine and check out deployed configuration file.
Points of Interest
Why CLR couldn't load DeploymentRegionSectionHandler.dll assembly when it could load WinformApplicaiton.exe from same target directory?
Initially I created a sample winform application as a POC to update the configuration file and added App.config with deploymentRegionSection
. Finally I added UpdateDeploymentRegions()
function using ConfigurationManager.GetSection(...)
to read deploymentRegionSection
. Everything produced expected result. But same code in SetupHelper
threw "cannot load ... DeploymentRegionSectionHandler
... assembly" exception.
The dll was already being copied to the target directory during installation so I thought probably I should put it in folder where the Msi file is running. Again same result. I knew the logic how CLR finds an assembly to load but based on that theory either i should've put my DeploymentRegionSectionHandler.dll
in GAC or in my application's base directory. I didn't want to add it to GAC and I was assuming that either Msi folder or target directory is application's base directory.
The assumption was wrong. When we double click any Msi file, system passes this file to msiexec.exe (i.e. Windows Installer) because Msi is a package file it's not executable. The system keeps registry of File Extension - Application mapping and for any request, it checks the file extension, then the mapping list. If it finds any application for that extension it passes the file location to that application to process otherwise it prompts user for application he/she want to select to open/process that specific file. To confirm this you can go to Tools>Folder Options>File Types in window explorer and search for MSI entry.
When I extracted AppDomain.CurrentDomain.BaseDirectory
it returned C:\WINDOWS\system32\
because msiexec.exe
resides there. I copied DeploymentRegionSectionHandler.dll
file to system32 and it worked like charm. But we can't do that too.
The only option left was to handle AppDomain's AssemblyResolve
event and return assembly's reference. The only problem was to have full path to the dll assembly during installation but as Msi itself was passing targetDirectory
it became easy to build the full path by appending CustomConfigSectionAndActions.DeploymentRegionSectionHandler.dll