I’m currently working in a team which is building a new website for a client comprising of a couple of ASP.NET MVC/Web API applications, a handful of Windows services and some SQL Server databases. The application is being developed in an enterprise environment, so naturally various aspects of application configuration need to be adjusted depending on whether we’re releasing to a development environment, or something further down the line (i.e. staging, production etc). The team uses a relatively large number of different environments, which is not uncommon in this sort of scenario, and we needed a way of being able to adjust configuration easily without the need for manual intervention. Everything is being pushed out to the various environments using MSDeploy/Web Deploy, so it was important to find a solution that played well together.
Because this is a system built predominately using a Microsoft stack we had the ability to make use of the extremely handy Web.config file transformation syntax built into the Web Application project type in Visual Studio. If you haven’t used this before; essentially it allows you to define a sort of delta (i.e. the transform file) which gets applied to your Web.config file at compile time and allows you to change/add attributes and elements. Each transform file is tied to the project configuration, so essentially you can have as many transform files as you do project configurations. Sound good? It is; but – and this is a big but – it’s only supported by the Web Application project type! Meaning any other project type (Windows Service for example) can’t make use of the feature. Although other project types also use configuration files using the same .NET config file schema, they seem to have been ignored. Because the system being developed consists of a number of Windows services, this presented a problem because it meant we would need an alternate approach for modifying configuration for non-Web Application projects.
Before we continue; it’s important to note that this actually isn’t a new problem and a number of people have already posted solutions online. One of the notable ones being from the .NET Web Development and Tools Blog with their post XDT (web.config) Transforms in non-web projects. There are some gaps (noted by the author) however, and in general I wanted a solution more in-keeping with the approach taken for the Web Application project type, which also includes some handy features such as auto parameterisation of connection strings etc.
Almost all project types in Visual Studio are created and processed as MSBuild project files. Because of this, it actually makes them relatively easy to unload, edit and hook into to adjust the behavior your project exhibits when building and packaging etc. All it takes is a simple import of a custom targets file:
<!-- Snip --> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(SolutionDir)\.nuget\nuget.targets" /> <!-- Pull in our targets file here. --> <Import Project="..\..\..\Targets\WindowsService.Package.targets" /> </Project>
Web Application projects make use of Microsoft’s Web Publishing Pipeline targets which – like most of the Visual Studio targets files – are extremely complicated and more than a little overwhelming at first. The nice thing about all the Microsoft targets files however, is that they are very extensible. Almost everything has hooks so you can execute your own targets at pretty much any point in the pipeline, this is very good from our perspective because it means we can adjust properties and item-groups at the right time to achieve our goal.
So what exactly is our goal here? Well, we want to essentially be able to import the Web Application targets and make use of the WPP to transform our configuration file in exactly the same way as a Web Application would. The first step in this process it to add our App.config file (the non-Web Application equivalent of the Web.config file) to the list of files to be processed:
<PropertyGroup> <ProjectConfigFileName>App.config</ProjectConfigFileName> <DeployAsIisApp>false</DeployAsIisApp> </PropertyGroup> <!-- The renamed App.Config file is not included as a file for packaging by default, so we need to add it. --> <ItemGroup> <FilesForPackagingFromProject Include="$(ProjectConfigFileName)"> <DestinationRelativePath>%(RecursiveDir)$(TargetFileName)%(Extension)</DestinationRelativePath> <FromTarget>WindowsService.targets</FromTarget> </FilesForPackagingFromProject> </ItemGroup>
By default transform files are picked up by the WPP from the bin directory, however since we’re not dealing with a Web Application, there is no bin directory, and the transform files will exist in the same root directory. Because of this, we need to adjust the paths a little to allow the transforms to be processed:
<!-- By default the transform will be looked for in the bin directory, so this needs to be picked up from the project root instead. --> <Target Name="AdjustTransformFilePaths"> <ItemGroup> <WebConfigsToTransform> <TransformFile>$(WebPublishPipelineProjectDirectory)\$(ProjectConfigTransformFileName)</TransformFile> </WebConfigsToTransform> </ItemGroup> </Target>
Obviously this is just a naked target at the moment, it won’t get hooked into the process yet, but we’ll deal with that later.
Now for the meat and potatoes of the process:
<!-- Parameterisation will only occur for the App.Config file, so we need to copy the transformed file to this first. --> <Target Name="AdjustFilesForPackagingFromProject"> <PropertyGroup> <TransformedRenamedConfigFile>$(TransformWebConfigIntermediateLocation)\transformed\$(ProjectConfigFileName)</TransformedRenamedConfigFile> </PropertyGroup> <!-- Move the transformed config file (through a copy and delete) to match the project config file name so it's picked up by the parameterisation process down the pipeline. --> <Copy SourceFiles="%(FilesForPackagingFromProject.FullPath)" DestinationFiles="$(TransformedRenamedConfigFile)" Condition="'%(FilesForPackagingFromProject.Filename)%(FilesForPackagingFromProject.Extension)'=='$(TargetFileName)$(_ProjectConfigFileExtension)'" /> <Delete Files="%(FilesForPackagingFromProject.FullPath)" Condition="'%(FilesForPackagingFromProject.Filename)%(FilesForPackagingFromProject.Extension)'=='$(TargetFileName)$(_ProjectConfigFileExtension)'" /> <ItemGroup> <!-- Include the transformed (and now renamed) config file so it will be picked up for parameterisation. --> <TransformedRenamedConfigFiles Include="$(TransformedRenamedConfigFile)"> <DestinationRelativePath>%(RecursiveDir)$(TargetFileName)$(_ProjectConfigFileExtension)</DestinationRelativePath> <FromTarget>WindowsService.targets</FromTarget> </TransformedRenamedConfigFiles> <FilesForPackagingFromProject Include="@(TransformedRenamedConfigFiles)" /> <!-- Now remove the incorrectly named config file from the pacakge list. --> <ConfigFilesToRemoveFromPackage Include="@(FilesForPackagingFromProject)" Condition="'%(FilesForPackagingFromProject.Filename)%(FilesForPackagingFromProject.Extension)'=='$(TargetFileName)$(_ProjectConfigFileExtension)'" /> <FilesForPackagingFromProject Remove="@(ConfigFilesToRemoveFromPackage)" /> <!-- Move all the packaged files (excluding the configuration file) back from the bin directory for packaging. --> <FilesForPackagingFromProject Condition="'%(Extension)'!='$(_ProjectConfigFileExtension)'"> <DestinationRelativePath>%(RecursiveDir)%(Filename)%(Extension)</DestinationRelativePath> <FromTarget>WindowsService.targets</FromTarget> </FilesForPackagingFromProject> </ItemGroup> </Target>
To break down what this target is doing, it:
- Moves our transformed App.config file to the transformed folder so it’s picked up for auto-parameterisation later on in the pipeline
- Includes the newly transformed config file in the list of files to be packaged
- Removes any incorrectly name config files (i.e. App.Debug.config) from the files to be packaged
- Alters the path for each file to be packaged to pull it back from the bin directory which the WPP insists on using
Finally; we need to import the Web Application targets and hook in our targets at the correct points in the pipeline:
<Import Project="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\WebApplications\Microsoft.WebApplication.targets" /> <PropertyGroup> <PreTransformWebConfigDependsOn> $(PreTransformWebConfigDependsOn) AdjustTransformFilePaths; </PreTransformWebConfigDependsOn> </PropertyGroup> <PropertyGroup> <PreAutoParameterizationWebConfigConnectionStringsDependsOn> $(PreAutoParameterizationWebConfigConnectionStringsDependsOn) AdjustFilesForPackagingFromProject; </PreAutoParameterizationWebConfigConnectionStringsDependsOn> </PropertyGroup>
And there we have it. A project file which will now apply config transformation files as if it’s a Web Application.
A word of warning; at the moment this implementation is very geared towards applying configuration prior to packaging an application for deployment using Web/MS Deploy. As such, you’ll only actually see these config transformations taking place when executing the Package target of your project. While I admit this might be a limitation for some people, in this instance we make heavy use of MS Deploy to push the applications out to our various environments, so it doesn’t actually cause a problem. I’ve made the targets file available for download, so please feel free to use it and let me know if it helps.