Thursday, October 29, 2009

Multiple Web.config Management Utility

Something we all face on a regular basis is managing our Web.config files across multiple deployment environments each with their own specific set of options. The trivial, but repetitive task of managing multiple config files can become quite tedious during development and in order to take some of that stress away I have built some utilities into my own build process that automatically maintain my config files for me, allowing me to worry about more important things. Using a simple batch script which executes some basic NAnt commands I am able to rapidly generate multiple web.config files from a single template with unique values in each.

To do this, I make use of NAnt’s built-in file merging functionality which takes two files and substitutes values from one into specific locations in the other and produces a combined output. Applying this concept to creating web.config files, I have a single “web.config.format” file which looks like a normal configuration file, but contains ${Property.Name} markers in areas where a value with a given name should be substituted. I then create multiple “{deployment-type}.property” XML files which contain elements indicating each property name and its value. Finally, this is all tied together by a batch script which calls the appropriate merge commands on each file in a directory I specify.

default.build (NAnt Script)
<?xml version="1.0"?>
<project default="configMerge">
 <property name="destinationfile"
   value="web.config" overwrite="false" />
 <property name="propertyfile"
   value="invalid.file" overwrite="false" />
 <property name="sourcefile"
   value="web.format.config" overwrite="false" />

 <include buildfile="${propertyfile}" failonerror="false"
   unless="${string::contains(propertyfile, 'invalid.file')}" />

 <target name="configMerge">
   <copy file="${sourcefile}"
       tofile="${destinationfile}" overwrite="true">
     <filterchain>
       <expandproperties />
     </filterchain>
   </copy>
 </target>
</project>
Sample .Property File
<?xml version="1.0"?>
<project>
 <property name="compilationDebug" value="true" />
 <property name="customErrorsMode" value="Off" />
 <property name="smtpServer" value="mail.website.com" />
 <property name="smtpFrom" value="admin@website.com" />
 <property name="googleAnalyticsKey" value="UA-12345678-9" />
</project>
Sample Web.config.format File
<?xml version="1.0"?>
<configuration>
 <configSections>
   <sectionGroup name="applicationSettings" type="System.Configuration.ApplicationSettingsGroup, System, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
     <section name="Project.Properties.Settings" type="System.Configuration.ClientSettingsSection, System, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false"/>
   </sectionGroup>
 </configSections>
 <appSettings>
   <add key="GoogleAnalyticsKey" value="${googleAnalyticsKey}" />
 </appSettings>
 <system.web>
   <customErrors mode="${customErrorsMode}" defaultRedirect="AppError.aspx">
     <error statusCode="404" redirect="404.aspx"/>
   </customErrors>
   <compilation debug="${compilationDebug}" />
 </system.web>
 <system.net>
   <mailSettings>
     <smtp from="${smtpFrom}" deliveryMethod="Network">
       <network host="${smtpServer}" port="25" defaultCredentials="true" />
     </smtp>
   </mailSettings>
 </system.net>
</configuration>
ConfigMerge.bat (NAnt script invoker)
@ECHO OFF
SET formatFile=%2
SET outputDir=%1

IF "%2"=="" SET formatFile=%~dp0web.config.format

ECHO USING: %formatFile%
ECHO OUTPUT DIR: %outputDir%
ECHO.
IF NOT EXIST "%formatFile%" (
   ECHO Could not locate format file. Please provide a valid filename.
) ELSE (

   FOR /f %%p in ('dir /b "%~dp0*.property"') DO (
       ECHO GENERATING: web.%%~np.config FROM: %%p
       START /B /WAIT "NAnt" "%~dp0nant\nant.exe" -buildfile:"%~dp0default.build" configMerge -q+ -nologo+ -D:sourcefile="%formatFile%" -D:propertyfile="%~dp0%%p" -D:destinationfile=%outputDir%\web.%%~np.config
   )
)

To further simplify things, I built an additional batch script that invokes ConfigMerge.bat with it's first argument- output directory. By calling BuildWebConfigs.bat ..\Web I can tell the NAnt script to output the generated config files in my website project's "Web" directory (and additionally replace Web.config with my Web.debug.config).

BuildWebConfigs.bat (ConfigMerge.bat invoker)
@ECHO OFF
call "%~dp0build\ConfigMerge.bat" ..\Web
copy "%~dp0Web\web.debug.config" "%~dp0Web\web.config" /y
echo.
echo ********* BuildWebConfigs Complete *********

In the interest of convenience I added a call to the second batch script into my website project's build events and included it as an external tool which I can easily invoke from Visual Studio's UI:

 

Since Visual Studio doesn't let you define per-configuration build events in C# projects (as it does in C++ projects- or so I'm told) I manually edited the CSProj file and added an instruction at the very bottom to call my batch script on all non-Debug builds:

Partial .CSProj File
<Project>
 ......
 .......
 ........
 <PropertyGroup Condition=" '$(ConfigurationName)' != 'Debug' ">
   <PostBuildEvent>
     call $(SolutionDir)BuildWebConfigs.bat
   </PostBuildEvent>
 </PropertyGroup>
</Project>

I have configured all of my solutions that use this utility to have BuildWebConfigs.bat and a folder called "build" in the $(SolutionDir). The "build" folder is laid out like so:

Folder Structure
------------------------------
+ nant
  - nant.exe
  - <related nant files>
- ConfigMerge.bat
- default.build
- {somename}.property
- {somename}.property
- {somename}.property
- web.config.format
------------------------------

I have also built a NAnt script which performs the same actions as the ConfigMerge batch script, but I find it runs significantly slower (about 4.5 seconds longer) than the batch because the NAnt script has to spawn additional instances of NAnt to perform the required actions. I've included it here in case you'd like to play with it:

Alternative NAnt-based NAnt script invoker
<project name="generate configs" default="generate ">
 <property name="destinationfile"   value="web.config" overwrite="false" />
 <property name="propertyfile"  value="invalid.file" overwrite="false" />
 <property name="sourcefile"   value="Web.format.config" overwrite="false" />

 <include buildfile="${propertyfile}"   failonerror="false"   unless="${string::contains(propertyfile, 'invalid.file')}" />

 <target name="configMerge">
   <echo message="GENERATING: ${path::get-file-name(destinationfile)} FROM: ${path::get-file-name(propertyfile)}." />
   <copy file="${sourcefile}" tofile="${destinationfile}" overwrite="true">
     <filterchain>
       <expandproperties />
     </filterchain>
   </copy>
 </target>

 <target name="generate ">
   <foreach item="File" property="file">
     <in>
       <items>
         <include name="*.property" />
       </items>
     </in>
     <do>
       <property name="propertyfile" value="${path::get-file-name(file)}" overwrite="true"/>
       <property name="destinationfile" value="web.${path::get-file-name-without-extension(propertyfile)}.config" overwrite="true"/>
       <property name="sourcefile" value="Web.format.config" overwrite="true"/>
       <echo message="Generating: ${destinationfile} from ${propertyfile}."/>

       <exec program="nant\nant">
         <arg value="configMerge"/>
         <arg value="-nologo+"/>
         <arg value="-q"/>
         <arg value="-D:sourcefile=${sourcefile}"/>
         <arg value="-D:propertyfile=${propertyfile}"/>
         <arg value="-D:destinationfile=${destinationfile}"/>
       </exec>
     </do>
   </foreach>
 </target>
</project>

Downloads:

Sources:

No comments: