Thursday, January 26, 2012

Live like you were web.config


Question: what is the most interesting ingredient of a web project’s code generated by Visual Studio? I can bet the first thing you named is “web.config” magic. Wonderful file which can auto-adjust its content according to debug/release build mode, pretty good example of XSLT power enhanced with some internals of Visual Studio... And one day you can quiz yourself with that question: how to do like web.config? How to introduce your own set of files and auto-adjust them according to debug/release/whatever? Let’s give a try...

Under the hood: tasks and targets

Let start Visual Studio and create C# web project (Web Application or MVC, it doesn’t matter here). Here we go: web.config file, debug/release transformations as child nodes etc, nothing special. So, how to add our own XSLT-powered set of files?
First of all, we must accept one sad thing: there is no UI-friendly way to achieve our goal in whole piece. Project properties editor is limited and restricted, at least in current version (VS2010). Maybe, next versions will be step closer.
OK, let back to our business. Our dream is to introduce a set of files and ask Visual Studio to treat them special way, i.e. to affect build process somehow. Or, it’s better to say as “instruct” it to use some special treatment. To achieve this, we should understand how that magic works.

Build process in Visual Studio uses two main abstractions: tasks and targets.
  • Task – apparently, some tiny piece of work pursuing one and only one type of action across all project files (if they match the nature of action, of course).
  • Target – set of tasks dedicated to build our project in the way we want.

If you click “Build” in the menu, Visual Studio may run one or more targets to build your Debug or Release binaries (for example, build could include such targets as code analysis, core compilation etc). Giving that, the journey of our dream will take two steps:
  • Find appropriate target
  • Expand the target with our custom step inside (namely, to transform our files like web.config magic)

Do you remember about “no UI-friendly way” fact? So, let open csproj file in text editor and investigate. Very soon you should run across something like this:
  ...
  <!-- To modify your build process, add your task inside one of the targets below and uncomment it.
       Other similar extension points exist, see Microsoft.Common.targets.
  <Target Name="BeforeBuild">
  </Target>
  <Target Name="AfterBuild">
  </Target>
  -->
  ...
Thanks for the tip, Microsoft! As this comment says, there are two most popular targets for custom tasks hosting: first one occurs before a build, second one – right after a build (successful build, of course).

Let move “BeforeBuild” target out of enclosing comment (if not yet) and put the following task inside:
  ...
  <Target Name="BeforeBuild">
    <Message Importance="high" Text="Solution name '$(SolutionName)', project name '$(ProjectName)', configuration '$(Configuration)'" />
    <TransformXml Source="Web.sitemap" Transform="Web.$(Configuration).sitemap" Destination="$(OutDir)\Web.sitemap" />
    <Message Importance="high" Text="End of TransformXml step" />
  </Target>
  ...
Here we add three custom steps. Sure thing, you should preserve any existing step if present (for example, MVC project could employ AfterBuild target with one custom task by default). Regarding to our own tasks inserted, they’re pretty self-explanatory. First and last are just informational (may be suitable for logging purposes, especially for Continuous Integration environment), middle one is real-work item, TransformXml. Parameters are obvious:
  • Source – put name of your base file here (like Web.config). We will try to modify sitemap file as our example, later it will be explained reason.
  • Transform – name of XSLT transformation file applied. As you see, it is worth to include configuration name substitution (to address names like Web.Debug.config, Web.Release.config etc)
  • Destination – where to put resulting file.

Side-note, optional (skip this part if not interested yet).
Remember, you can’t use Destination value set to the same as Source. Try this once and “cannot open a file” system error will answer immediately because source file is locked. Moreover, the locking persists until end of build so you have to provide unique temp names if transformations chain required (fictional example below, do not place it in our workshop code):
  ...
  <TransformXml Source="Original.File" Transform="Step1.$(Configuration).File" Destination="$(OutDir)\tmp1.file" />
  <TransformXml Source="="$(OutDir)\tmp1.file " Transform="Step2.$(Configuration).File" Destination="$(OutDir)\tmp2.file" />
  <TransformXml Source="="$(OutDir)\tmp2.file " Transform="Step3.$(Configuration).File" Destination="$(OutDir)\Result.File" />
  ...
You see, a destination of a previous step becomes a source of corresponding next step. Each time we employ another name to evade writing to a locked file.
End of side-note :)
Add files
As long as we have custom task written in deeps of our project, we have to provide appropriate files (original one and necessary transformations then). As decided before, we will manage Web.sitemap file so let add one to the project. Let’s bogus it with something like that:

  <?xml version="1.0" encoding="utf-8" ?>
  <siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
    <siteMapNode url="http://localhost/root1" title="root1" description="Just root #1"/>
  </siteMap>

Next step: add another sitemap, name it as Web.Debug.sitemap and bogus it with something like that (it excludes “description” attribute from original sitemap file, other content passed as is):

  <?xml version="1.0" encoding="utf-8" ?>
  <siteMap xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform" xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
    <siteMapNode xdt:Transform="RemoveAttributes(description)" xdt:Locator="Condition(@title='root1')"/>
  </siteMap>

Here is the explanation why we use sitemap file as our example. This may be obvious for someone, maybe not. Let point your attention on first file (original Web.sitemap). Unlike web.config, it employs namespaces, you see? And XML namespaces is one of most underestimated features (should I say “bug reasons” instead?):

  Web.sitemap
  <?xml version="1.0" encoding="utf-8" ?>
  <siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
  ...

Now let take a look at transformation file Web.Debug.sitemap:

  Web.Debug.sitemap
  <?xml version="1.0" encoding="utf-8" ?>
  <siteMap xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform" xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
  ...

There are two namespaces here. First one is internal schema for transformation machinery (without that, compilation error will claim about xdt:Transform attribute). Second one just repeats sitemap feature namespace. From the first glance it may be recognized as optional but don’t do wiping it out. Compilation could be successful but warning appears:

  warning : No element in the source document matches '/siteMap'

Indeed, our Debug transformation file could be useless (zero transformation effect) without second namespace in place.
Recap: do not loose namespaces; they’re as important as other content of your XML. This is why we’re with sitemap file as our example.


Get things done
Now it’s time to test our mixture in action. Build the project in Debug mode first. If everything is in position, you should get successful compilation, with following statements in Output view:

  Solution name is 'CustomCfgTransformsWebApp', project name 'CustomCfgTransformsWebApp', configuration 'Debug'
  End of TransformXml step

Sure thing, solution name may differ from mentioned one.
Then, let inspect \bin folder:

  <?xml version="1.0" encoding="utf-8"?>
  <siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
    <siteMapNode url="http://localhost/root1" title="root1"/>
  </siteMap>

As we asked in our transformation, “description” attribute is gone, other content still untouched. Bingo!

Some polishing and presales
We did nearly whole journey now but it is not completed yet. There are few points for our attention. First thing you should address is Release mode build. If you try to build Release instead of Debug you end up with error:

  error : Could not open Transform file: Could not find file

Reason is simple. We used to care about Debug-mode transformation, now it’s time to repeat the same regarding Release: let create a file, name it as Web.Release.sitemap and fulfill with the following content (it updates value of “description” attribute instead of hiding it):

  <?xml version="1.0" encoding="utf-8" ?>
  <siteMap xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform" xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
    <siteMapNode xdt:Transform="SetAttributes" xdt:Locator="Condition(@title='root1')" description="Release root #1"/>
  </siteMap>

Build the project in Release mode then evaluate resulting sitemap in \obj folder... Transformation is correct!
Second thing you should address (or maybe should not, because it’s cosmetic) is visual difference between web.config representation and our hand-made sitemap example. Web.config file is like a folder in Solution Explorer since sitemap family is represented as flat list, one by one. Fix is easy. Again, there is no UI-friendly way to achieve sub-files folded into main one. Let go back to csproj file and look for DependentUpon tag.

  ...
  <Content Include="Web.config">
    <SubType>Designer</SubType>
  </Content>
  <Content Include="Web.Debug.config">
    <DependentUpon>Web.config</DependentUpon>
  </Content>
  <Content Include="Web.Release.config">
    <DependentUpon>Web.config</DependentUpon>
  </Content>
  ...

You got it, right? We have to re-group our sitemap family as the following:

  ...
  <Content Include="Web.sitemap" />
  <Content Include="Web.Debug.sitemap">
    <DependentUpon>Web.sitemap</DependentUpon>
    <SubType>Designer</SubType>
  </Content>
  <Content Include="Web.Release.sitemap">
    <DependentUpon>Web.sitemap</DependentUpon>
    <SubType>Designer</SubType>
  </Content>
  ...

Switch back to Visual Studio, reload the project... Aha! Correct picture!
Then let me mark third thing to address (or at least possible to address). In default case, VS2010 will automatically provide you with all necessary equipment in right pockets. In less lucky case, you may face errors about unknown “TransformXml” task. To resolve such issues, just add “UsingTask” description somewhere nearby our “BeforeBuild” target:

  ...
  <UsingTask TaskName="TransformXml" AssemblyFile="$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v10.0\Web\Microsoft.Web.Publishing.Tasks.dll" />
  ...
  <Target Name="BeforeBuild">
    <Message Importance="high" Text="Solution name is '$(SolutionName)', configuration '$(Configuration)'" />
    <TransformXml Source="Web.sitemap" Transform="Web.$(Configuration).sitemap" Destination="$(OutDir)\Web.sitemap" />
    <Message Importance="high" Text="End of TransformXml step" />
  </Target>
  ...

It’s actually the end of the journey but fourth thing to address is in sight, let me show. In Solution Explorer, do right-click on Web.config original file. Context menu appears, with “Add config transforms” menu item there. If clicked, it automatically creates that transformation files for Debug/Release/etc (if some of the files not present yet). Can we clone this behavior too? Yes, we can. But this is another story...

No comments:

Post a Comment