Environmental Config Transforms, Part 2

Update 29 July 2013: Refer to this link for updated code supporting Visual Studio 2013 Preview.


In the previous article we updated the build process to support environmental config transforms in projects and to have the transforms run on any build.  In this article we are going to package up the implementation to make it very easy to add to any project.  There are a couple of things we want to wrap up.


  • Place the .targets file into a shared location
  • Add an import into the current project to the .targets file
  • Add environmental config transforms into the project
  • Remove the default config transforms generated by Visual Studio

Installing the Shared Targets File


The default location for storing shared .targets files is MSBuildExtensionsPath.  This property is set automatically during builds and will, by default, be pointing to C:\Program Files (x86)\MSBuild.  If you look under this directory you will see that the standard .targets file shipped by .NET are already there.  We need to place our .targets file under there as well but we should create a subdirectory for our file.  This prevents us from colliding with other files and provides a convenient place to store additional files if we need them.  For this article I’m going to call the directory P3Net.


For a single machine you can easily just create the directory and copy the file into it.  But for several machines a better approach is to provide an installation script.  I’ve attached a simple one called Install.ps1 to this article.  One issue that the installation has is that you have to be an administrator to write to the directory so either the script has to be run using an elevated account or it will need to be manually done using Windows Explorer. 


Irrelevant of how the file ultimately gets installed we now need to update the project file that we used last time to use the new path. 


<Import Project="$(MSBuildExtensionsPath32)\P3Net\P3Net.targets"/>

Reloading the project and rebuilding the solution should result in no errors.  In the future if we need to modify the .targets file then we’ll have to redeploy it.  We could, if we wanted, move this to a VS extension but the extension would need to be installed using administrator privileges. 


Creating the Item Template


Now that we have the .targets file in a shared location we can set about creating an item template to generate the config transforms for any project we want.  In previous articles we went over how to create item templates using T4 and how to deployment.  In this case we’ll be creating an item template but it won’t use T4.  We will however use the same deployment process that we used in the previous articles.  If you have not yet done so then download the version from the final article.  We will add the template to the solution so it is deployed like the others.


Before we can create the template we need to decide what config transforms we need for our environments and what they should contain by default.  To keep it simple we will stick with the environments we defined in the last article (Production, Test) and our transforms will simply contain the environment name.  For your environments you’ll want to set up any additional, standard transforms you’ll need.  It is important to note that the actual environment transform files isn’t relevant to the build as it builds all the transforms. 


Following the instructions for adding new item templates that were discussed in the template article we do the following to the ItemTemplates project.


  1. Create a new directory called EnvConfigs
  2. Add the environmental configs that we want into the folder.  Since we do not know whether this a web or Windows project rename the files to base.environment.config.  We’ll see later how to rename them.
  3. For each transform set the following properties:
    • Build Action = Content
    • Copy to Output = Do Not Copy
    • Include in VSIX = True
  4. Add the .vstemplate file to the project and update it accordingly.
  5. Set the following properties for the .vstemplate file
    • Build Action = Content
    • Copy to Output = Do Not Copy
    • Include in VSIX = True
    • (Optional) Category = My Templates

Here’s what my .vstemplate looks like.


<?xml version="1.0" encoding="utf-8"?>
<VSTemplate Version="3.0.0" Type="Item" xmlns="http://schemas.microsoft.com/developer/vstemplate/2005" xmlns:sdk="http://schemas.microsoft.com/developer/vstemplate-sdkextension/2010">
  <TemplateData>
    <Name>Environmental Configuration Transforms</Name>
    <Description>Template for generating basic environmental config transforms.</Description>
    <Icon Package="{FAE04EC1-301F-11d3-BF4B-00C04F79EFBC}" ID="4600" />
    <TemplateID>f7a423ac-7948-42a7-b9b9-cba719569106</TemplateID>
    <ProjectType>CSharp</ProjectType>
    <RequiredFrameworkVersion>4.0</RequiredFrameworkVersion>    
    <DefaultName>Environment</DefaultName>
  </TemplateData>
  <TemplateContent>
      <ProjectItem SubType="Code" ReplaceParameters="true" ItemType="None" TargetFileName="web.config\web.Test.config">base.Test.config</ProjectItem>
      <ProjectItem SubType="Code" ReplaceParameters="true" ItemType="None" TargetFileName="web.config\web.Production.config">base.Production.config</ProjectItem>
  </TemplateContent>
</VSTemplate>

There are a couple of interesting points about this file.  Notice the Icon element.  Since VS already ships with icons for config transforms I’m using the same values for my icon.  This gives the user a consistent icon for both versions.  The DefaultName element is not a full file name and won’t actually be used anyway.


The most interesting part of this is the template contents.  There is a project item for each environment transform.  The target file name is a partial path starting with the web.config file.  This causes VS to insert the transforms as subfiles under the base file.  This mimics the behavior that you see in VS today.


At this point we have a working item template but there are still a couple of issues.  The first issue is that the template is keyed to a web project.  If we try to use it on a Windows project it will be using the wrong config.  We need to fix that but we cannot using the existing .vstemplate file.  Instead we need to modify the project item entry to use a template property that we can replace when the template runs.  Here’s the updated project item elements.


<ProjectItem SubType="Code" ReplaceParameters="true" ItemType="None" TargetFileName="$BaseConfigFileName$.config\$BaseConfigFileName$.Test.config">base.Test.config</ProjectItem>
<ProjectItem SubType="Code" ReplaceParameters="true" ItemType="None" TargetFileName="$BaseConfigFileName$.config\$BaseConfigFileName$.Production.config">base.Production.config</ProjectItem>

Now all we need to do is replace the template property with the actual config file name when it is inserted.  But to do that we need to use a template wizard.


Creating a Template Wizard


Most templates do not need any code to support them but some do.  For those that need custom code you have to write a template wizard.  The documentation makes lots of restrictions on template wizards but based upon the current behavior of VS and building on the existing template deployment architecture we can simply create a new template wizard project as part of our template project and deploy it along with the item templates.


  1. Create a new class library called P3Net.MyTemplateWizards.
  2. Add references to the following assemblies
    • EnvDTE (set Embed Interop Types to false)
    • Microsoft.Build
    • Microsoft.VisualStudio.TemplateWizardInterface
    • System.Windows.Forms
    • TemplateLib (contains some extension methods)
  3. Add a new class called EnvironmentConfigsWizard that implements IWizard.

The code for the wizard is too long to post so I’ll only mention the RunStartedCore method.  This method is called when the template runs.  It will be responsible for doing the heavily lifting.  Here’s the code.


private void RunStartedCore ( EnvDTE.DTE dte, 
                Dictionary<string, string> replacementsDictionary, 
                WizardRunKind runKind, 
                object[] customParams )
{
    //Get the current project                
    var project = dte.GetCurrentProject();

    //Get the configuration file
    var configFile = GetConfigurationFile(project);
    if (configFile == null)
        ReportErrorAndCancel("No configuration file could be found.");

    //Set the template parameters
    replacementsDictionary.Add("$IsWebProject$", configFile.ProjectType == ProjectType.Web ? "1" : "0");
    replacementsDictionary.Add("$BaseConfigFileName$", configFile.BaseConfigFileName);
}

The method first finds the config file in the project.  Based upon the config file name it knows whether this is a web or Windows project.  It then updates the template property accordingly so that the .vstemplate file will generate the correct target information.


Now that the template wizard is defined we need to update the .vstemplate file to reference the wizard.  Add the following to the end of the .vstemplate file.


<WizardExtension>
    <Assembly>P3Net.MyTemplateWizards, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</Assembly>
    <FullClassName>P3Net.MyTemplateWizards.EnvironmentConfigsWizard</FullClassName>
</WizardExtension>

The above elements identify the assembly to load and the class within the assembly that will be used to support adding the template to the project.  Now we need to hook it up to the deployment project.



Adding Wizard to Setup


To add the template wizard library to the setup we do the following.


  1. Open the .vsixmanifest file in the designer
  2. Go to the Assets tab and click New
  3. Select Assembly as the type
  4. Select A project in the current solution as the source
  5. Select the project
  6. Click OK

The template wizard will now be deployed as part of the extension.  We have just a couple of more issues to resolve before we are done.


Removing the Default Config Transforms


The next issue we need to resolve is the removal of the default transforms that are most likely in the project.  This is a simple matter of removing the files from the project so we update the RunStartedCore method from earlier to find and remove any existing transforms before we add our new ones.


//Remove the default config transforms if they exist            
RemoveProjectItem(configFile.ConfigurationItem, configFile.BaseConfigFileName + ".Debug.config");
RemoveProjectItem(configFile.ConfigurationItem, configFile.BaseConfigFileName + ".Release.config");

The above code only removes the default transforms.  If you wanted to remove them all you would need to update the code.


Importing the Targets File


The final issue to solve is getting the shared .targets file into the project.  We don’t want to manually have to do that so we will modify the template method to check for the import of the .targets file.  If it hasn’t been imported yet then the template will add it.  Here’s the code.


private void EnsureStandardTargetIsImported ( EnvDTE.Project project )
{
    var buildProject = ProjectCollection.GlobalProjectCollection.GetLoadedProjects(project.FullName).First();

    //Check for the import
    var hasImport = (from i in buildProject.Xml.Imports
                     where String.Compare(Path.GetFileName(i.Project), SharedTargetsFileName, true) == 0
                     select i).Any();
    if (hasImport)
        return;

    //Make sure it exists first
    var extensionsPath = buildProject.GetPropertyValue("MSBuildExtensionsPath32");
    var targetsPath = Path.Combine(SharedTargetsFilePath, SharedTargetsFileName);
    var fullPath = Path.Combine(extensionsPath, targetsPath);
    if (!File.Exists(fullPath))
        ReportErrorAndCancel("The standard .targets file could not be located.");

    //Add it             
    buildProject.Xml.AddImport(@"$(MSBuildExtensionsPath32)\" + targetsPath);
}

The above method gets the imports from the project and looks for the shared .targets file.  If it isn’t found then an import is added to it otherwise nothing happens.  A call to this method is placed in the template method shown earlier.


Conclusion


We are done.  We have a new item template that generates the environmental transforms that we need and cleans up the existing version that VS uses.  Whenever the project is built all the environmental transforms are generated and stored so we can easily build once and deploy to any of our environments.


Additionally we have added a template wizard to our T4 template project that we can use as a basis for more advanced templates in the future.  Finally we created a shared .targets file that we can use to add new build tasks to any project.