Thursday, 17 July 2014

Multiple project configurations

Problem

  1. I want different 'app.config's for debug and release builds.
  2. I want to be able to define a boiler-plate app.config and 'tweak' it depending on the project's configuration
  3. I want to have some global (solution-wide) configuration parameters (e.g. WCF addresses which need to match up between my servers and various client apps).

Example config file

<?xml version="1.0" encoding="utf-8" ?>
<configuration >
  <system.diagnostics>
    <trace autoflush="false" indentsize="4">
      <listeners>
        <add name="configConsoleListener"
          type="System.Diagnostics.ConsoleTraceListener" />
      </listeners>
    </trace>
  </system.diagnostics>

  <system.serviceModel>
    <bindings>
      <netTcpBinding>
        <binding name="netTcpBindingEndpoint" transferMode="Streamed"
          maxReceivedMessageSize="2147483648" />
      </netTcpBinding>
    </bindings>

    <client>
      <endpoint address="${tcpWCFServiceAddress}"
                binding="netTcpBinding"
                bindingConfiguration="netTcpBindingEndpoint"
                contract="ServiceReference.IDfaToXmlService"
                name="defaultNetTcpBindingEndpoint">
        <identity>
          <dns value="localhost" />
        </identity>
      </endpoint>
    </client>

  </system.serviceModel>
</configuration>


I want to change the address from the keyword '${tcpWCFServiceAddress}' to a debug or release address depending on the flavour of build I'm doing.

I eventually hit on using the MSBuild XslTransformation built in Task.

    1. create an app.<project configuration>.config.xslt file for each project configuration.
 
    2. Add each .xslt to your project and make them all dependent on your main app.config (this groups them nicely and makes it slightly more obvious that there's some magic going on) by manually updating your .csproj.
       e.g.    
  <Content Include="app.release.config.xslt">
    <DependentUpon>app.config</DependentUpon>
  </Content>
  <Content Include="app.debug.config.xslt">
    <DependentUpon>app.config</DependentUpon>
  </Content>
    3. add the following Target to the end of your .csproj file.
 <Target Name="AfterCompile">
    <XslTransformation XmlInputPaths="app.config" OutputPaths="$(IntermediateOutputPath)$(TargetFileName).config" XslInputPath ="app.$(Configuration).config.xslt" />
    <ItemGroup>
      <AppConfigWithTargetPath Remove="app.config" />
      <AppConfigWithTargetPath Include="$(IntermediateOutputPath)$(TargetFileName).config">
        <TargetPath>$(TargetFileName).config</TargetPath>
      </AppConfigWithTargetPath>
    </ItemGroup>
  </Target>

My journey included...

Use a pre-build step to copy app.debug.config over the app.config.

Doing this meant duplicating swathes of configuration settings and remembering that changing the project's settings needed duplicating across the config files.

I did end up with the following PowerShell script which did some simple keyword substitution (thus satisfying point 3 above).  See below for the full source.

Use XML Document Transform

This appeared to work fine but didn't satisfy (3) above.

It required the hand modification of the .csproj to perform the transformation.
e.g.
<Project>
  ...
  <UsingTask TaskName="TransformXml" AssemblyFile="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v10.0\Web\Microsoft.Web.Publishing.Tasks.dll" />
  <Target Name="AfterCompile">
    <!-- Generate transformed app config in the intermediate directory -->
    <TransformXml Source="app.config" Destination="$(IntermediateOutputPath)$(TargetFileName).config" Transform="app.$(Configuration).config" />
  </Target>
</Project>

I then created app.debug.config and app.release.config files to do the transform.
e.g.
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
  <!-- See http://msdn.microsoft.com/en-us/library/dd465326(VS.100).aspx -->
  <system.serviceModel>
    <client>
      <endpoint address="net.tcp://localhost:8000/andy-test-service/"
                xdt:Locator="Condition(@address='${tcpWCFServiceAddress}')"
                xdt:Transform="SetAttributes">
      </endpoint>
    </client>
  </system.serviceModel>
</configuration>

PowerShell Script to perform keyword substitution and generate a config file.


<#
.SYNOPSIS
  Called by Visual Studio pre-build steps to update a project's app.config with a project configuration specific version.

.DESCRIPTION
  Copy App.<configuration name>.config to App.Config
  Use the associated CSV file to translate any entries in the config file.
  The CSV file is the same names as this script with .ps1 replaces with .csv
  The CSV file contains Name and Value entries.
  The user updates the App.<configuration name>.config to contain entries with ${}s around these named entries.
  $Id: update-config.ps1 86696 2014-07-17 09:10:01Z andyr $

.EXAMPLE

  Add the following to the project's pre-build event:
    powershell -file "$(ProjectDir)..\tools\update-config.ps1" "$(ProjectDir)app.config" "$(ConfigurationName)"

  Assuming a $(ConfigurationName) of 'test' and an app.test.config with the following fragment:

    <client>
      <endpoint address="${tcpTestServiceAddress}"
        binding="netTcpBinding" bindingConfiguration="netTcpBindingEndpoint"
        contract="ServiceReference.IDfaToXmlService" name="runtimeTimeNetTcpBindingEndpoint">
        <identity>
          <dns value="localhost" />
        </identity>
      </endpoint>
    </client>

  A .csv with the contents:
    #TYPE Selected.System.Collections.DictionaryEntry
    "Name","Value"
    "tcpWindowsServiceAddress","net.tcp://localhost:8000/andy-test-service/"
    "tcpDevelopmentAddress","net.tcp://localhost:8000/Design_Time_Addresses/andy-test-service/"
    "tcpTestAddress","net.tcp://localhost:8000/Design_Time_Addresses/andy-test-service/"

  The resultant App.config will be:

    <client>
      <endpoint address="net.tcp://localhost:8000/Design_Time_Addresses/andy-test-service/"
        binding="netTcpBinding" bindingConfiguration="netTcpBindingEndpoint"
        contract="ServiceReference.IDfaToXmlService" name="runtimeTimeNetTcpBindingEndpoint">
        <identity>
          <dns value="localhost" />
        </identity>
      </endpoint>
    </client>

#>

Param(
  [Parameter()]
  [ValidateNotNullOrEmpty()]
  [string]
  $configurationFilename = $(throw "ConfigurationFilename is mandatory, please provide a value."),
  [Parameter()]
  [ValidateNotNullOrEmpty()]
  [string]
  $configurationName = $(throw "ConfigurationName is mandatory, please provide a value.")
  )
#$configurationFilename = Join-Path $PSScriptRoot "..\DfaToXml\app.config"
#$configurationName="Debug-Service"
# workaround for errors exiting scripts with an exit code of 0 if invoked with -File
trap { 
  Write-Host $_ 
  Write-Host $_.ScriptStackTrace
  exit 1 
} 
if ($PSBoundParameters['Verbose']) {
  Set-PSDebug -Trace 1
}
Set-StrictMode -Version 3 
function Convert-Line {
  [CmdletBinding()]
  Param(
    [ValidateNotNullOrEmpty()]
    [string]$filename,
    
    [Parameter(ValueFromPipeline=$True, ValueFromPipelinebyPropertyName=$True)]
    [string]$line)
  begin {
    $lineNumber = 0
  }
  
  PROCESS {
    $lineNumber++
    $translations.GetEnumerator() |% { $line=$line -creplace "\`${$($_.Name)}", $_.Value }
    
    if($line -match "\`${.*}") {
      throw "{0} ({1}): Unrecognised keyword '{2}' in application configuration file!" -f $filename, $lineNumber, $Matches[0]
    }
    
    $line
  }
}
function Get-TemplateConfigFName {
  [CmdletBinding()]
  Param(
    [ValidateNotNullOrEmpty()]
    [string]$projectDir,
    
    [ValidateNotNullOrEmpty()]
    [string]$configurationName)
  #Write-Verbose ("Get-TemplateConfigFName: {0}, {1}" -f $projectDir, $configurationName)
  Get-Item (Join-Path $projectDir ("app.{0}.config" -f $configurationName)) -ErrorAction SilentlyContinue
}
function Get-FallbackTemplateConfigFile {
  [CmdletBinding()]
  Param(
    [ValidateNotNullOrEmpty()]
    [string]$projectDir,
    
    [ValidateNotNullOrEmpty()]
    [string]$configurationName)
  $templateConfigFName = "app.{0}.config" -f $configurationName
  $templateConfigFile = Get-TemplateConfigFName $projectDir $configurationName
  if ($templateConfigFile -eq $null) {
    # A configuration of "Debug-XYZ" falls back to using "App.Debug.config"
    if($configurationName -match "(.*)\-(.*)") {
      $templateConfigFile = Get-TemplateConfigFName $projectDir $Matches[1]
    }
      
    if ($templateConfigFile -eq $null) {
      
      # Use the the default config if we can
      $templateConfigFile = Get-TemplateConfigFName $projectDir $defaultConfigurationName
      
      if ($templateConfigFile -eq $null) {
        throw "Unable to open template configuration file ($templateConfigFName)!"
      }
    }
  }
  $templateConfigFile
}
$configurationFilename = [System.IO.Path]::GetFullPath($configurationFilename)
$projectDir = [System.IO.Path]::GetDirectoryName($configurationFilename)
$ErrorActionPreference = "stop"
$defaultConfigurationName = "default"
$constantsCsvFilename = [io.path]::ChangeExtension($MyInvocation.MyCommand.Path, "csv")
#$translations = @{
#  tcpWindowsServiceAddress = "net.tcp://localhost:8000/andy-test-service/";
#  tcpDevelopmentAddress = "net.tcp://localhost:8000/Design_Time_Addresses/andy-test-service/"
#  }
#$translations.GetEnumerator() | Select-Object Name, Value | ConvertTo-Csv | Out-File $constantsCsvFilename
$translations = Import-Csv $constantsCsvFilename
# "Project dir: {0}" -f $projectDir
# "Configuration: {0}" -f $configurationName
$templateConfigFile = Get-FallbackTemplateConfigFile $projectDir $configurationName
# Write-Verbose ("Using config file template: {0}" -f $templateConfigFile)
$tmpConfigFilename=[System.IO.Path]::GetTempFileName()
try {
  Get-Content $templateConfigFile | Convert-Line $templateConfigFile | Out-File -Encoding UTF8 $tmpConfigFilename
  if (Test-Path -PathType Leaf $configurationFilename) {
    $newContents = Get-Content $tmpConfigFilename
    $oldContents = Get-Content $configurationFilename
    
    if((Compare-Object $oldContents $newContents -PassThru) -eq $null) {
      "{0}: Configuration file left unchanged ({1})" -f $configurationName, $templateConfigFile #.Name
      return
    }
    Set-ItemProperty $configurationFilename -Name IsReadOnly -Value $false
  }
  
  Move-Item -Force $tmpConfigFilename $configurationFilename
  
  "{0}: Application configuration file updated based on '{1}'" -f $configurationName, $templateConfigFile #.Name
} finally {
  Set-ItemProperty -ErrorAction SilentlyContinue $configurationFilename -Name IsReadOnly -Value $true
  Remove-Item -ErrorAction SilentlyContinue $tmpConfigFilename
}

No comments: