MSBuild / *.csproj: Framework Version Conditional Compilation

I’m working on a project to bridge the gap between some legacy infrastructure code with newer infrastructure code based on a newer .NET framework code. The legacy infrastructure code is .NET 3.5, while the newer infrastructure code is a mixture of .NET 4.0 and 4.5. v4.5 framework usage is currently limited to modules implementing Windows Identity Foundation (WIF) v4.5. Thankfully, the bridging code doesn’t require those features so we can isolate just the relevant infrastructure code.

While writing the bridging code, I needed to compile the relevant portions of the newer infrastructure with 3.5 for compatibility. A couple of problems were encountered with backporting:

Usage of .NET 4.0 specific methods

There were two compilation problems:

String.IsNullOrWhiteSpace()

The first step was to fix the missing IsNullOrWhiteSpace() function. To do this, I simply created my own extension methods and related tests.

  /// <summary>
  /// Extensions to the string class.
  /// </summary>
  public static class StringExtensions
  {
    /// <summary>
    /// Indicates whether a specified string is null, empty, or consists only of white-space characters.
    /// </summary>
    /// <param name="value">The string to test.</param>
    /// <returns>true if the value parameter is null or String.Empty, or if value consists exclusively of white-space characters. </returns>
    public static bool IsNullOrWhiteSpace(this string value)
    {
#if NET35
      /// The IsNullOrWhiteSpace function was added in NET40
      return (String.IsNullOrEmpty(value) || (value.Trim().Length == 0));
#else
      return (String.IsNullOrWhiteSpace(value));
#endif
    }
  }

Inside of our *.csproj file, I set a CONSTANT based on the framework. There are lots of ways to do this, but the simplest is to just hard code it in the relevant PropertyGroup setting.

  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug-Net35|AnyCPU'">
    <DebugSymbols>true</DebugSymbols>
    <OutputPath>bin\Debug-Net35\</OutputPath>
    <DefineConstants>DEBUG;TRACE;NET35</DefineConstants>
    <DebugType>full</DebugType>
    <PlatformTarget>AnyCPU</PlatformTarget>
    <ErrorReport>prompt</ErrorReport>
    <TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
    <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
  </PropertyGroup>

The line of interest is:

<DefineConstants>DEBUG;TRACE;NET35</DefineConstants>

An lastly we have our tests to make sure our implementation functions correctly:

  [TestClass]
  public class StringExtensionTests
  {
    [TestMethod]
    public void IsNullOrWhiteSpace_With_Null_Returns_True()
    {
      Assert.IsTrue(((string) null).IsNullOrWhiteSpace());      
    }

    [TestMethod]
    public void IsNullOrWhiteSpace_With_EmptyString_Returns_True()
    {
      Assert.IsTrue(String.Empty.IsNullOrWhiteSpace());
    }

    [TestMethod]
    public void IsNullOrWhiteSpace_With_Spaces_Returns_True()
    {
      Assert.IsTrue("   ".IsNullOrWhiteSpace());
    }

    [TestMethod]
    public void IsNullOrWhiteSpace_With_Linefeeds_Returns_True()
    {
      Assert.IsTrue("\r\n".IsNullOrWhiteSpace());
    }

    [TestMethod]
    public void IsNullOrWhiteSpace_With_Whitepace_Returns_True()
    {
      Assert.IsTrue(" \r\n   \t ".IsNullOrWhiteSpace());
    }

    [TestMethod]
    public void IsNullOrWhiteSpace_With_Nonwhitespace_Returns_False()
    {
      Assert.IsFalse("   NO WHITESPACE HERE  ".IsNullOrWhiteSpace());
    }
  }

Fixing the code is pretty simple. We add a reference to our namespace to enable the extension methods and then reverse the parameters for the code:

Before
String.IsNullOrWhiteSpace(where)
After
where.IsNullOrWhiteSpace()

String.Join()

In .NET 3.5, the String.Join() method has 2 overloads:

Join(String, String[])
Join(String, String[], Int32, Int32)

In .NET 4.0, there are now five overloads. The overloads mostly allow for an object instead of a string to be specified, along with the ability to use an IEnumerable.

Join(String, IEnumerable<String>)
Join<T>(String, IEnumerable<T>)
Join(String, Object[])
Join(String, String[])
Join(String, String[], Int32, Int32)

Unfortunately, the newer framework contained a number of LINQ statements which passed the statement as an IEnumerable. The solution was to simply add .ToArray() to the end of the IEnumerable.

Before
IEnumerable<T> items;
Func<T, object> output;
String.Join(",", items.Select((item) => (output != null) ? output.Invoke(item) : item))
After
String.Join(",", items.Select((item) => (output != null) ? output.Invoke(item) : item).ToArray())

Unfortunately, that isn’t enough since we need to convert our type of T to a string. Based on the documentation, and viewing the decompiled source for the method, we can see that the type of T is converted by simply calling the .ToString() method on the item. So we can simply fix that by adding .ToString() to each of the ternary results:

String.Join(",", items.Select((item) => (output != null) ? output.Invoke(item).ToString() : item.ToString()).ToArray())

BUT… what about if the invoked output or item are null? We can handle that all by simply using the Convert.ToString() method. And instead of doing it for each ternary result, we’ll just convert the final result from the ternary, making our code more readable and maintainable.

String.Join(",", items.Select((item) => Convert.ToString((output != null) ? output.Invoke(item) : item)))

External library dependencies

Now, the next step is getting conditional compilation working for our 3rd party libraries. Many of the tutorials you’ll find will show string matching against the $(TargetFrameworkVersion) build variable.

<PropertyGroup Condition=" '$(TargetFrameworkVersion)' == 'v3.5' ">
    <DefineConstants>NET35</DefineConstants>
</PropertyGroup>

Ideally, we’d like to be able to do a simple number comparison (i.e. $(TargetFrameworkVersion) > 3.5). Unfortunately, because $(TargetFrameworkVersion) is prefixed with a ‘v’ it’s not interpreted as a number. Not to worry though, in newer versions of MSBuild, we can actually make calls to the .NET library.

To start, we’re going to create a PropertyGroup AFTER the existing property groups. Any variables declared AFTER your property group are invalid and empty strings inside of your PropertyGroup. Then we’re going to create our own custom variable called TargetFrameworkVersionNumber that we can use in our conditional expressions. The simplest option is to simply hard code this value.

<PropertyGroup>
  <TargetFrameworkVersionNumber>2.0</TargetFrameworkVersionNumber>
</PropertyGroup>

While hard coding the value might work in simple scenarios, it can be tedious to maintain. Ideally, we’d like to dynamically set that value based on the $TargetFrameworkVersion value. While there are a number of ways to exclude the ‘v’ from the preceding value, such as using Double.Parse(), what fun would that be? We’re going to use a regular expression because they unlock the possibilities of what you can do.

<PropertyGroup>
  <TargetFrameworkVersionNumber>$([System.Text.RegularExpressions.Regex]::Replace($(TargetFrameworkVersion), '[^\d\.]+', '', System.Text.RegularExpressions.RegexOptions.IgnoreCase))</TargetFrameworkVersionNumber>
</PropertyGroup>

The above regular expression “[^\d\.]+”, simply says to match anything that isn’t a digit or a decimal point (period) and then replace those matches with an empty string / nothing. If you’re not already using regular expressions in your code, they’re worth their weight in gold to learn and most modern languages include support for them including JavaScript.

At this point, if we add a Target element with a Message to our *.csproj file, we can output and inspect the values.

<Target Name="BeforeBuild">
  <Message Text="$(TargetFrameworkVersionNumber)" Importance="High" />
</Target>

You should be able to see the output now during your compilation. Let’s create our conditional compilation statement now.

<ItemGroup Condition=" $(TargetFrameworkVersionNumber) >= 3.5 ">
  <Reference Include="Newtonsoft.Json, Version=4.5.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
    <SpecificVersion>False</SpecificVersion>
    <HintPath>..\packages\Newtonsoft.Json.4.5.7\lib\net35\Newtonsoft.Json.dll</HintPath>
  </Reference>
</ItemGroup>

Unfortunately, if you run or open this build file, you’ll get an error that you can’t compare the string “” with a number. Even though our number looks like a number, internally the build it treating it like a string. MSBuild is supposed to automatically convert from string to number, and vice versa, but “my mileage varied”… Not to worry, we can use some additional .NET library function calls to convert that string to a number for us.

<ItemGroup Condition=" $([System.Single]::Parse($(TargetFrameworkVersionNumber))) <= 3.5 ">
  <Reference Include="Newtonsoft.Json, Version=4.5.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
    <SpecificVersion>False</SpecificVersion>
    <HintPath>..\packages\Newtonsoft.Json.4.5.7\lib\net35\Newtonsoft.Json.dll</HintPath>
  </Reference>
</ItemGroup>

Here you see we made another call to $([System.Single]::Parse( )).

Unfortunately, now you’ll get an error the “The project file could not be loaded. ‘&lt’, hexadecimal value 0x3C, is an invalid attribute character.’. Thankfully, the fix is simple, you just need to encode the ‘<‘ as ‘&lt;’ as follows:

<ItemGroup Condition=" $([System.Single]::Parse($(TargetFrameworkVersionNumber))) &lt;= 3.5 ">
  <Reference Include="Newtonsoft.Json, Version=4.5.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
    <SpecificVersion>False</SpecificVersion>
    <HintPath>..\packages\Newtonsoft.Json.4.5.7\lib\net35\Newtonsoft.Json.dll</HintPath>
  </Reference>
</ItemGroup>

Now, under each ItemGroup, you can add the custom references that are framework version dependent. You don’t need to repeat the ItemGroup for each Reference you want to add for each framework. Just add additional Reference items accordingly.

<Choose>
  <When Condition=" $([System.Single]::Parse($(TargetFrameworkVersionNumber))) &lt;= 3.5 ">
    <ItemGroup>
      <Reference Include="Newtonsoft.Json, Version=4.5.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
        <SpecificVersion>False</SpecificVersion>
        <HintPath>..\packages\Newtonsoft.Json.4.5.7\lib\net35\Newtonsoft.Json.dll</HintPath>
      </Reference>
    </ItemGroup>
  </When>
  <Otherwise>
    <ItemGroup>
      <Reference Include="Newtonsoft.Json, Version=4.5.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
        <SpecificVersion>False</SpecificVersion>
        <HintPath>..\packages\Newtonsoft.Json.4.5.7\lib\net35\Newtonsoft.Json.dll</HintPath>
      </Reference>
    </ItemGroup>
  </Otherwise>
</Choose>

Hopefully that helps you when attempting to backport a library or do conditional compilation in your projects.

References / Additional Information

Leave a Reply

Your email address will not be published. Required fields are marked *