Dynamic Environment Configuration for .NET

Publicado por Allan Oliveira
14/6/2012
Categoria:
Tags: , , ,

This article presents a solution to let the App.config and Web.config of .NET application to be dynamically configured for different environments based on a simple XML file like the following:

BuildConf.xml

<Configuration>
  <!-- Connection: Localhost, LocalDevServer, Dev, QA, Prod -->
  <Connection>LocalDevServer</Connection>
  <!-- External System 1: Dev, QA, Prod -->
  <System1>Prod</System1>
  <!-- External System 2: QA, Prod -->
  <System2>QA</System2>
  <!-- Server: Localhost, Dev, DevQA, QA, Prod -->
  <Server>Localhost</Server>
</Configuration>

To accomplish this, it uses MSBuild.

It also presents a sample script to create custom deployment packages.

Context

Consider a project with 5 components:

  • Client
  • Server
  • Database
  • External System 1
  • External System 2

Each component has zero or more settings depending on the environment. For example the Client needs the address of the server. The server needs the connection string for the Database and the URL to access the External Systems WebServices.

So the variables are:

Component Variables Environments Compilation
Client Release or Debug
Server Server Address Localhost, Dev, DevQA, QA or Prod Release or Debug
DataBase Connection String Localhost, LocalDevServer, Dev, QA or Prod
External System 1 WebService URL QA or Prod
External System 2 WebService URL Dev, QA or Prod

Note the environments are not tied to a particular deployment. For instance, on a development machine we could access the Dev environment of External System 2 on a first test, then the QA environment later for another test, without making any deployments.

Constraints

The solution was designed to meet the following requirements:

  • Just a simple file (BuildConf.xml) to configure the chosen environment for each component
  • The solution should work for both Windows Forms (App.config) and Web Forms (Web.config) application
  • No change should be made to App.config or Web.config to select the environment
  • The environment configuration should transparently work if the solution is being executed inside Visual Studio and after being Published
  • The environments configuration are orthogonal to a particular deployment
  • The environments configuration are orthogonal to the compilation type (Release or Debug)
  • Variables should be accessible by Spring.NET, if necessary

Alternative solutions exists for simplified cases (like “XML Transformations”). But given the above requirements, this is the simplest solution we could find.

Implementation

The example uses the following structure:

The diagram and the snippets below just show a subset of the possible environment variations. A more complete example would have the following files:

  • Solution\Config\App.Server.Localhost.config
  • Solution\Config\App.Server.Dev.config
  • Solution\Config\App.Server.DevQA.config
  • Solution\Config\App.Server.QA.config
  • Solution\Config\App.Server.Prod.config
  • Solution\Config\Web.Connection.Localhost.config
  • Solution\Config\Web.Connection.LocalDevServer.config
  • Solution\Config\Web.Connection.Dev.config
  • Solution\Config\Web.Connection.QA.config
  • Solution\Config\Web.Connection.Prod.config
  • Solution\Config\Web.System1.Dev.config
  • Solution\Config\Web.System1.QA.config
  • Solution\Config\Web.System1.Prod.config
  • Solution\Config\Web.System2.QA.config
  • Solution\Config\Web.System2.Prod.config

The Solution\Config\BuildConf.xml is the file that will be actually read on each build to configure the Solution. It is the same file shown on the beginning of this article:

<Configuration>
  <!-- Connection: Localhost, LocalDevServer, Dev, QA, Prod -->
  <Connection>LocalDevServer</Connection>
  <!-- External System 1: Dev, QA, Prod -->
  <System1>Prod</System1>
  <!-- External System 2: QA, Prod -->
  <System2>QA</System2>
  <!-- Server: Localhost, Dev, DevQA, QA, Prod -->
  <Server>Localhost</Server>
</Configuration>

We will modify the *.csproj for the Client and Server projects to make them parse this file automatically at each build.

The *.csproj are MSBuild files that are executed when the Project is run inside Visual Studio or Published (via msbuild command line or through Visual Studio menus).

Client

The Solution\Client\Client.csproj should be:

<Project>
<!-- Existing code here -->
  <Target Name="AfterCompile">
    <PropertyGroup>
      <ConfigParamXML>..\Config\BuildConf.xml</ConfigParamXML>
    </PropertyGroup>
    <XmlPeek XmlInputPath="$(ConfigParamXML)" Query="/Configuration/Server/text()">
      <Output TaskParameter="Result" ItemName="serverItemName" />
    </XmlPeek>
    <PropertyGroup>
      <serverProperty>@(serverItemName)</serverProperty>
    </PropertyGroup>
    <Copy SourceFiles="..\Config\App.Server.$(serverProperty).config" DestinationFiles="Config\App.Server.config" OverwriteReadOnlyFiles="true" />
  </Target>
</Project>

Here “/Configuration/Server/text()” on the Query attribute refers to the structure of the BuildConf.xml we’ve created. We are reading the <Server> tag inside the <Configuration> tag. Change the values according to your configuration structure.

Also serverProperty and serverItemName are temporary variables to hold the value read from the BuildConf.xml, you can use any name for them provided you use the names consistently.

Note that this new Target should be added at the end of the file. More specifically it should follow this Import statement:

<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />

On each Build the AfterCompile target will be automatically invoked by the build system. It will copy the file from Solution\Config\App.Server.$(serverProperty).config to Solution\Client\Config\App.Server.config. In the given example $(serverProperty) will be “Localhost”.

The content of Solution\Config\App.Server.Localhost.config is, for example:

<ServerConfiguration>
  <add key="server.env.url" value="http://localhost:21286/" />
  <add key="server.env.url.filetransfer" value="http://localhost:21286/" />
  <add key="server.env.name" value="DEV"/>
</ServerConfiguration>

In the same way we could add a App.Server.Dev.config, App.Server.DevQA.config, etc with different values for the same variables.

A important step to guarantee that Publish will work is to add the Solution\Client\Config\App.Server.config to the Solution (first a Rebuild will be necessary to automatically copy the file) and on File Properties set the Build Action to “Content” and Copy to Output Directory to “Copy Always”, like in the following figure:

Now the Solution\Client\App.config should be modified to import the variables defined on Solution\Client\Config\App.Server.config:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
     <!-- Existing sections, if any -->
    <sectionGroup name="EnvironmentConfiguration">
      <section name="ServerConfiguration" type="System.Configuration.NameValueSectionHandler"/>
    </sectionGroup>
  </configSections>
  <!-- Existing configuration here -->
  <EnvironmentConfiguration>
    <ServerConfiguration configSource="Config\App.Server.config" />
  </EnvironmentConfiguration>
</configuration>

The EnvironmentConfiguration is an arbitrary defined name. The “ServerConfiguration” tag name should match the one defined on App.Server.config.

Server

The Server project will be configured analogously. The main difference is that in this example it contains more configuration files, there is a connectionString and the config files will be copied to App_Data instead of a custom Config directory.

Solution\Server\Server.csproj

<Project>
<!--Existing code here -->
  <Target Name="AfterCompile">
    <PropertyGroup>
      <ConfigParamXML>..\Config\BuildConf.xml</ConfigParamXML>
    </PropertyGroup>
    <XmlPeek XmlInputPath="$(ConfigParamXML)" Query="/Configuration/System1/text()">
      <Output TaskParameter="Result" ItemName="system1" />
    </XmlPeek>
    <XmlPeek XmlInputPath="$(ConfigParamXML)" Query="/Configuration/System2/text()">
      <Output TaskParameter="Result" ItemName="system2" />
    </XmlPeek>
    <XmlPeek XmlInputPath="$(ConfigParamXML)" Query="/Configuration/Connection/text()">
      <Output TaskParameter="Result" ItemName="connection" />
    </XmlPeek>
    <PropertyGroup>
      <system1>@(system1)</system1>
      <system2>@(system2)</system2>
      <connection>@(connection)</connection>
    </PropertyGroup>
    <Copy SourceFiles="..\Config\Web.System1.$(system1).config" DestinationFiles="$(ProjectDir)App_Data\Web.System1.config" OverwriteReadOnlyFiles="true" />
    <Copy SourceFiles="..\Config\Web.System2.$(system2).config" DestinationFiles="$(ProjectDir)App_Data\Web.System2.config" OverwriteReadOnlyFiles="true" />
    <Copy SourceFiles="..\Config\Web.Connection.$(connection).config" DestinationFiles="$(ProjectDir)App_Data\Web.Connection.config" OverwriteReadOnlyFiles="true" />
  </Target>
</Project>

Solution\Config\Web.System1.Prod.config

<System1Configuration>
  <add key="system1.env.url" value="http://system1.com/ws/start?WSSOAP=1"/>
  <add key="system1.env.url.products" value="http://system1.com/ws/start?WSSOAP=1"/>
  <add key="system1.env.name" value="Production"/>
  <add key="jhost.dir" value="\\system1.com\Files\TemplateDownload\"/>
  <add key="timeout" value="80000"/>
  <add key="timeout.write" value="500000"/>
</System1Configuration>

Solution\Config\Web.System2.QA.config

<System2Configuration>
  <add key="system2Management.url" value="https://system2.com:8899/ManagementModule.asmx"/>
  <add key="system2Maintenance.url" value="https://system2.com:8899/MaintenanceService.asmx"/>
  <add key="system2Products.url" value="https://system2.com:8899/ProductService.asmx"/>
  <add key="system2.env.name" value="QA"/>
</System2Configuration>

Solution\Config\Web.Connection.LocalDevServer.config

<?xml version="1.0" encoding="utf-8"?>
<connectionStrings>
  <add name="DbContext" providerName="System.Data.SqlClient" connectionString="Data Source=192.168.1.54; Initial Catalog=MyDataBase; User Id=myuser; Password=mypassword; MultipleActiveResultSets=True;"/>
</connectionStrings>

Like in the Client, the following files should be added to the solution, then the File Properties should be set: Build Action to “Content” and Copy to Output Directory to “Copy Always”

  • Solution\Server\App_Data\Web.System1.config
  • Solution\Server\App_Data\Web.System2.config
  • Solution\Server\App_Data\Web.Connection.config

Finally, we will set the Solution\Server\Web.config:

<?xml version="1.0"?>
<configuration>
  <configSections>
    <sectionGroup name="EnvironmentConfiguration">
      <section name="Server1Configuration" type="System.Configuration.NameValueSectionHandler"/>
      <section name="Server2Configuration" type="System.Configuration.NameValueSectionHandler"/>
    </sectionGroup>
  </configSections>
  <connectionStrings configSource="App_Data\Web.Connection.config" />

  <!-- Existing configuration here -->

  <EnvironmentConfiguration>
    <Server1Configuration configSource="App_Data\Web.Server1.config" />
    <Server2Configuration configSource="App_Data\Web.Server2.config" />
  </EnvironmentConfiguration>
</configuration>

Acessing the variables

To access these variables, we can use the following C# code for the Client:

var serverProperties = (System.Collections.Specialized.NameValueCollection)System.Configuration.ConfigurationManager.GetSection("EnvironmentConfiguration/ServerConfiguration");
string serverEnvironmentName = serverProperties["server.env.name"];

For the Server the code will be the same, just changing the name of the section.

Spring

To load the variables on Spring context, it’s necessary to change the App.config or Web.config.

For the Server, the Web.config would be:

<?xml version="1.0"?>
<configuration>
  <!-- (...) -->
  <spring>
    <context>
        <!-- (...) -->
      <resource uri="~/App_Data/Environment.xml"/>
        <!-- (...) -->
    </context>
  </spring>
  <!-- (...) -->
<configuration>

Where Solution\Server\App_Data\Environment.xml is:

<?xml version="1.0" encoding="utf-8"?>
<objects xmlns="http://www.springframework.net" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.net http://www.springframework.net/xsd/spring-objects.xsd">
  <object name="appConfigPropertyHolder" type="Spring.Objects.Factory.Config.PropertyPlaceholderConfigurer, Spring.Core">
    <property name="configSections" value="EnvironmentConfiguration/System1Configuration,EnvironmentConfiguration/System2Configuration" />
  </object>
</objects>

In this way all the variables like $(system1.env.url), $(system2.env.name), etc will be available on other XMLs.

Using the Command Line (optional)

If you like to use the command to automatize tasks, here are two tips to publish the applications from the command line. Note that no additional setup is needed to let the BuildConf.xml be used.

To publish the Client (a Windows Forms application), use:

msbuild /m /t:publish /p:Configuration=Release Solution\Client\Client.csproj

The published files will be placed on Solution\Client\bin\Release\app.publish

To publish the Server (A ASP.NET application):

msbuild /m /t:PipelinePreDeployCopyAllFilesToOneFolder /p:Configuration=Release;AutoParameterizationWebConfigConnectionStrings=False Solution\Server\Server.csproj

The published files will be placed on Solution\Server\obj\Release\Package\PackageTmp

To be able to use the msbuild command you need to call inside the “Visual Studio Command Prompt”, or access it directly (or put on the PATH). For example, it may be found at C:\Windows\Microsoft.NET\Framework\v4.0.30319

For a more complete solution that makes a checkout from SVN and picks a pre-configured BuildConf.xml, see the Appendix.

Appendix

Generating Packages

The following is an utility script that makes a checkout from SVN and copy a pre-configured xml over BuildConf.xml to create a 7-Zip package with the published Client and Server.

Call it like:

GeneratePackages QA Release /svn/project

Where QA refers to a file Solution\Config\BuildQA.xml that needs to be inside the solution and commited to SVN.

File GeneratePackages.cmd:

@echo OFF
REM DEVQA,QA,PROD
set ConfigName=%1
REM Debug or Release
set Configuration=%2
REM SVN path
set SVN_path=%3

REM set local variables
set SVNURL=http://yoursvndress/
set CheckoutDir=%CD%\SVNCheckout
set ZipExe=C:\Program Files\7-Zip\7z.exe
set MSBuildExe=C:\Windows\Microsoft.NET\Framework\v4.0.30319\msbuild.exe

REM hide commands being executed
@echo ON

REM create target dir
set TargetDir=%CD%\%ConfigName%
mkdir "%TargetDir%"

REM delete previous checkout directory
rd "%CheckoutDir%" /s /q

REM checkout from SVN
svn export %SVNURL%%SVN_path% "%CheckoutDir%"
@if ERRORLEVEL 1 goto :failed

REM verify existance of Build[ConfigName]Conf.xml
@if not exist "%CheckoutDir%\Config\Build%ConfigName%.xml" (
    @echo The Config name %ConfigName% does not exists
    @goto :failed
)

REM overwrite BuildConf.xml
xcopy /y "%CheckoutDir%\Config\Build%ConfigName%.xml" "%CheckoutDir%\Config\BuildConf.xml"

REM Build solution
"%MSBuildExe%" /m /t:Rebuild /p:Configuration=%Configuration% "%CheckoutDir%\DynamicEnvironments.sln"
@if ERRORLEVEL 1 goto :failed

REM Publish projects
"%MSBuildExe%" /m /t:publish /p:Configuration=%Configuration% "%CheckoutDir%\Client\Client.csproj"
@if ERRORLEVEL 1 goto :failed
REM Instead of the following, it is possible to use webdeploy, like:
REM msdeploy.exe -verb:sync -source:contentPath=c:\webApp -dest:contentPath=c:\DeployedWebApp
REM But it needs to be installed from: http://www.iis.net/download/WebDeploy
"%MSBuildExe%" /m /t:PipelinePreDeployCopyAllFilesToOneFolder /p:Configuration=%Configuration%;AutoParameterizationWebConfigConnectionStrings=False "%CheckoutDir%\Server\Server.csproj"
@if ERRORLEVEL 1 goto :failed

REM delete previous deployed files
del /f "%TargetDir%\Client.7z"
del /f "%TargetDir%\Server.7z"

REM create zip files
cd /D "%CheckoutDir%\Client\bin\%Configuration%\app.publish"
"%ZipExe%" a "%TargetDir%\Client.7z" *
cd /D "%CheckoutDir%\Server\obj\%Configuration%\Package\PackageTmp"
"%ZipExe%" a "%TargetDir%\Server.7z" *

REM remove checkout directory
rd "%CheckoutDir%" /s /q

@goto :end
:failed

@echo Aborted. Command Failed.
:end




Desenvolvido por hacklab/ com WordPress