Targeting multiple versions of the .NET Framework from the same project

The new exception management library I've been working on was originally targeted for .NET 4.6, changing to .NET 4.5.2 when I found that Azure websites don't support 4.6 yet. Regardless of 4.5 or 4.6, this meant trouble when I tried to integrate it with WebCopy - this product uses a mix of 3.5 and 4.0 targeted assemblies, meaning it couldn't actually reference the new library due the higher framework version.

Rather than creating several different project files with the same source but different configuration settings, I decided that I would modify the library to target multiple framework versions from the same source project.

Bits you need to change

In order to get multi targeting working properly, you'll need to tinker a few things

  • The output path - no good having all your libraries compiling to the same location otherwise one compile will overwrite the previous
  • Reference paths - you may need to reference different versions of third party assemblies
  • Compile constants - in case you need to conditionally include or exclude lines of code
  • Custom files - if the changes are so great you might as well have separate files (or bridging files providing functionality that doesn't exist in your target platform)

Possibly there's other things too, but this is all I have needed to do so far in order to produce multiple versions of the library.

I wrote this article against Visual Studio 2015 / MSBuild 14.0, but it should work in at least some earlier versions as well

Conditions, Conditions, Conditions

The magic that makes multi-targeting work (at least how I'm doing it, there might be better ways) is by using conditions. Remember that your solution and project files are really just MSBuild files - so (probably) anything you can do with MSBuild, you can do in these files.

Conditions are fairly basic, but they have enough functionality to get the job done. In a nutshell, you add a Condition attribute containing an expression to a supported element. If the expression evaluates to true, then the element will be fully processed by the build.

As conditions are XML attribute values, this means you have to encode non-conformant characters such as < and > (use &lt; and &gt; respectively). If you don't, then Visual Studio will issue an error and refuse to load the project.

Getting Started

You can either edit your project files directly in Visual Studio, or with an external editor such as Notepad++. While the former approach makes it easier to detect errors (your XML will be validated against the relevant schema) and provides intellisense, I personally think that Visual Studio makes it unnecessarily difficult to directly edit project files as you have to unload the project, before opening it for editing. In order to reload the project, you have to close the editing window. I find it much more convenient to edit them in an external application, then allow Visual Studio to reload the project when it detects the changes.

Also, you probably want to settle on a "default" target version for when using the raw project. Generally this would either be the highest or lowest framework version you support. I choose to do the lowest, that way I can reference the same source library in WebCopy and other projects that are either .NET 4.0 or 4.5.2. (Of course, it would be better to use a NuGet package with the multi-targeted binaries, but that's the next step!)

Conditional Constants

To set up my multi-targeting, I'm going to define a dedicated PropertyGroup for each target, with a condition stating that the TargetFrameworkVersion value must match the version I'm targeting.

I'm doing this for two reasons - firstly to define a numerical value for the version (e.g. 3.5 instead of v3.5), which I'll cover in a subsequent section. The second reason is to define a new constant for the project, so that I can use conditional compilation if required.

  <!-- 3.5 Specific -->
  <PropertyGroup Condition=" '$(TargetFrameworkVersion)' == 'v3.5' ">
    <DefineConstants>$(DefineConstants);NET35</DefineConstants>
    <TargetFrameworkVersionNumber>3.5</TargetFrameworkVersionNumber>
  </PropertyGroup>

In the above XML block, we can see the condition expression '$(TargetFrameworkVersion)' == 'v3.5'. This means that the PropertyGroup will only be processed if the target framework version is 3.5. Well, that's not quite true but it will suffice for now.

Next, I change the constants for the project to include a new NET35 constant. Note however, that I'm also embedding the existing constants into the new value - if I didn't do this, then my new value would overwrite all existing properties (such as DEBUG or TRACE). You probably don't want that to happen!

Constants are separated with a semi-colon

The third line creates a new configuration value named TargetFrameworkVersionNumber with our numeric framework version.

If you are editing the project through Visual Studio, it will highlight the TargetFrameworkVersionNumber element as being invalid as it isn't part of the schema. This is a harmless error which you can ignore.

Conditional Compilation

With the inclusion of new constants from the previous section, it's quite easy to conditionally include or exclude code. If you are targeting an older version of the .NET Framework, it's possible that it doesn't have the functionality you require. For example, .NET 4.0 and above have Is64BitOperatingSystem and IsIs64BitProcess properties available on the Environment object, while previous versions do not.

bool is64BitOperatingSystem;
bool is64BitProcess;

#if NET20 || NET35
  is64BitOperatingSystem = NativeMethods.Is64BitOperatingSystem,
  is64BitProcess = NativeMethods.Is64BitProcess,
#else
  is64BitOperatingSystem = Environment.Is64BitOperatingSystem,
  is64BitProcess = Environment.Is64BitProcess,
#endif

The appropriate code will then be used by the compile process.

Including or Excluding Entire Source Files

Sometimes the code might be too complex to make good use of conditional compilation, or perhaps you need to include extra code to support the feature in one version that you don't in another such as bridging or interop classes. You can use condition attributes to conditionally include these too.

  <ItemGroup>
    <Compile Include="NativeMethods.cs" Condition=" '$(TargetFrameworkVersionNumber)' <= '3.5' " />
  </ItemGroup>

One of the limitations of MSBuild conditions is that the >, >=, < and <= operators only work on numbers, not strings. And it is much easier to say "greater than 3.5" than it is to say "is 4.0 or is 4.5 or is 4.5.1 or is 4.5.2" or "not 2.0 and not 3.5" and so on. By creating that TargetFrameworkVersionNumber property, we make it much easier to use greater / less than expressions in conditions.

Even if the source file is excluded by a specific configuration, it will still appear in the IDE, but unless the condition is met, it will not be compiled into your project, nor prevent compilation if it has syntax errors.

External References

If your library depends on any external references (or even some of the default ones), then you'll possibly need to exclude the reference outright, or include a different version of it. In my case, I'm using Newtonsoft's Json.NET library, which very helpfully comes in different versions for each platform - I just need to make sure I include the right one.

  <ItemGroup Condition=" '$(TargetFrameworkVersionNumber)' == '3.5' ">
    <Reference Include="Newtonsoft.Json, Version=7.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
      <HintPath>..\..\packages\Newtonsoft.Json.7.0.1\lib\net35\Newtonsoft.Json.dll</HintPath>
      <Private>True</Private>
    </Reference>
  </ItemGroup>

Here we can see an ItemGroup element which describes a single reference along with a now familiar Condition attribute to target a specific .NET version. By changing the HintPath element to point to the net35 folder of the Json package, I can be sure that I'm pulling out the right reference.

Even though these references are "excluded", Visual Studio will still display them, along with a warning that you cannot suppress. However, just like with the code file of the previous section, the duplication / warnings are completely ignored.

The non-suppressible warnings are actually really annoying - fortunately I aim to consume this library via a NuGet package eventually so it will become a moot point.

Core References

In most cases, if your project references .NET Framework assemblies such as System.Xml, you don't need to worry about them; they will automatically use the appropriate version without you lifting a finger. However, there are some special references such as System.Core or Microsoft.CSharp which aren't available in earlier versions and should be excluded. (Or removed if you aren't using them at all)

As Microsoft.CSharp is not supported by .NET 3.5, I change the Reference element for Microsoft.CSharp to include a condition to exclude it for anything below 4.0.

<Reference Condition=" '$(TargetFrameworkVersionNumber)' >= '4.0' " Include="Microsoft.CSharp" />

If I was targeting 2.0 then I would exclude System.Core in a similar fashion.

Output Paths

One last task to change in our project - the output paths. Fortunately we can again utilize MSBuild's property system to avoid having to create different platform configurations.

All we need to do is find the OutputPath element(s) and change their values to include the $(TargetFrameworkVersion) variable - this will then ensure our binaries are created in sub-folders named after the .NET version.

<OutputPath>bin\Release\$(TargetFrameworkVersion)\</OutputPath>

Generally, there will be at least two OutputPath elements in a project. If you have defined additional platforms (such as explicit targeting of x86 or x64 then there may be even more). You will need to update all of these, or at least the ones targeting Release builds.

Building the libraries

The final part of our multi-targeting puzzle is to compile the different versions of our project. Although I expect you could trigger MSBuild using the AfterBuild target, I decided not to do this as when I'm developing and testing in the IDE I only need one version. I'll save the fancy stuff for dedicated release builds, which I always do externally of Visual Studio using batch files.

Below is a sample batch file which will take a solution (SolutionFile.sln) and compile 3.5, 4.0 and 4.5.2 versions of a single project (AwesomeLibary).

@ECHO OFF

CALL :build 3.5
CALL :build 4.0
CALL :build 4.5.2

GOTO :eof

:build
ECHO Building .NET %1 client:
MSBUILD "SolutionFile.sln" /p:Configuration="Release" /p:TargetFrameworkVersion="v%1" /t:"AwesomeLibary:Clean","AwesomeLibary:Rebuild" /v:m /nologo
ECHO.

The /p:name=value arguments are used to override properties in the soltuion file, so I use /p:TargetFrameworkVersion to change the .NET version of the output library, and as I always want these to be release builds, I also use the /p:Configuration argument to force the Release configuration.

The /t argument specifies a comma separated list of targets. Generally, I just use Clean,Rebuild to do a full clean of the solution following by a build. However, by including a project name, I can skip everything but that one project, which avoids having to have a separate slimmed down solution file to avoid fully compiling a massive solution.

Note that you shouldn't include the project extension in the target, and if your project name includes any other periods, then you must change these into underscores instead. For example, Cyotek.Windows.Forms.csproj would be referenced as Cyotek_Windows_Forms. I also believe that if you have sited your project within a solution folder, you need to include the folder hierarchy too

A fuller example

This is a more-or-less complete C# project file that demonstrates multi targeting, and may help in a sort of "big picture way".

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
  <PropertyGroup>
    <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
    <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
    <ProjectGuid>{DA5D3442-D7E1-4436-9364-776732BD3FF5}</ProjectGuid>
    <OutputType>Library</OutputType>
    <AppDesignerFolder>Properties</AppDesignerFolder>
    <RootNamespace>Cyotek.ErrorHandler.Client</RootNamespace>
    <AssemblyName>Cyotek.ErrorHandler.Client</AssemblyName>
    <TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
    <FileAlignment>512</FileAlignment>
    <TargetFrameworkProfile />
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
    <DebugSymbols>true</DebugSymbols>
    <DebugType>full</DebugType>
    <Optimize>false</Optimize>
    <OutputPath>bin\Debug\$(TargetFrameworkVersion)\</OutputPath>
    <DefineConstants>DEBUG;TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <DebugType>pdbonly</DebugType>
    <Optimize>true</Optimize>
    <OutputPath>bin\Release\$(TargetFrameworkVersion)\</OutputPath>
    <DefineConstants>TRACE</DefineConstants>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>

  <!-- 3.5 Specific -->
  <PropertyGroup Condition=" '$(TargetFrameworkVersion)' == 'v3.5' ">
    <DefineConstants>$(DefineConstants);NET35</DefineConstants>
    <TargetFrameworkVersionNumber>3.5</TargetFrameworkVersionNumber>
  </PropertyGroup>
  <ItemGroup Condition=" '$(TargetFrameworkVersionNumber)' == '3.5' ">
    <Reference Include="Newtonsoft.Json, Version=7.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
      <HintPath>..\..\packages\Newtonsoft.Json.7.0.1\lib\net35\Newtonsoft.Json.dll</HintPath>
      <Private>True</Private>
    </Reference>
  </ItemGroup>
  <ItemGroup>
    <Compile Include="NativeMethods.cs" Condition=" '$(TargetFrameworkVersionNumber)' <= '3.5' " />
  </ItemGroup>

  <!-- 4.0 Specific -->
  <PropertyGroup Condition=" '$(TargetFrameworkVersion)' == 'v4.0' ">
    <DefineConstants>$(DefineConstants);NET40</DefineConstants>
    <TargetFrameworkVersionNumber>4.0</TargetFrameworkVersionNumber>
  </PropertyGroup>
  <ItemGroup Condition=" '$(TargetFrameworkVersionNumber)' == '4.0' ">
    <Reference Include="Newtonsoft.Json, Version=7.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
      <HintPath>..\..\packages\Newtonsoft.Json.7.0.1\lib\net40\Newtonsoft.Json.dll</HintPath>
      <Private>True</Private>
    </Reference>
  </ItemGroup>

  <!-- 4.5 Specific -->
  <PropertyGroup Condition=" '$(TargetFrameworkVersion)' == 'v4.5.2' ">
    <DefineConstants>$(DefineConstants);NET45</DefineConstants>
    <TargetFrameworkVersionNumber>4.0</TargetFrameworkVersionNumber>
  </PropertyGroup>
  <ItemGroup Condition=" '$(TargetFrameworkVersionNumber)' >= '4.5' ">
    <Reference Include="Newtonsoft.Json, Version=7.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
      <HintPath>..\..\packages\Newtonsoft.Json.7.0.1\lib\net45\Newtonsoft.Json.dll</HintPath>
      <Private>True</Private>
    </Reference>
  </ItemGroup>

  <ItemGroup>
    <Reference Include="System" />
    <Reference Include="System.Configuration" />
    <Reference Condition=" '$(TargetFrameworkVersionNumber)' > '2.0' " Include="System.Core" />
    <Reference Condition=" '$(TargetFrameworkVersionNumber)' > '3.5' " Include="Microsoft.CSharp" />
  </ItemGroup>

  <ItemGroup>
    <Compile Include="Client.cs" />
    <Compile Include="Utilities.cs" />
  </ItemGroup>

  <ItemGroup>
    <None Include="packages.config" />
  </ItemGroup>
  
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
  <!-- 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>
  -->
</Project>

Final Notes and Caveats

Unfortunately, Visual Studio doesn't really seem to support these conditions very gracefully - firstly you can't suppress reference warnings (that I know of), and secondly you have zero visibility of the conditions in the IDE.

Each time Visual Studio saves your project file, it will reformat the XML, removing any white space. It might also decide to insert elements between the elements you have created. For this reason, you might want to use XML comments to identify your custom condition blocks.

Visual Studio seems reasonably competent when you change your project, for example by adding new code files or references so that it doesn't break any of your conditional stuff. However, if you use the IDE to directly manipulate something that you have bound to a condition (for example the Json.NET references) then I imagine it will be less forgiving and may need to be manually resolved. I haven't tried this yet, I'll probably find out when I need to install an update to the Json.NET NuGet package!

This principle seems sound and not to difficult, at least for smaller libraries and I suspect I'll make more use of this for any independent libraries that I create in the future. It is a manual process to set up and maintain, and slightly unfriendly to Visual Studio though, so I would wait until a library was complete before doing this, and I probably would not do it to product assemblies (for example to make WebCopy work on Windows XP again) although it is feasible.

About The Author

Gravatar

The founder of Cyotek, Richard enjoys creating new blog content for the site. Much more though, he likes to develop programs, and can often found writing reams of code. A long term gamer, he has aspirations in one day creating an epic video game. Until that time, he is mostly content with adding new bugs to WebCopy and the other Cyotek products.

Leave a Comment

While we appreciate comments from our users, please follow our posting guidelines. Have you tried the Cyotek Forums for support from Cyotek and the community?

Styling with Markdown is supported

Comments

Gravatar

Benny Tordrup

# Reply

Hi

I've been looking at your post becuase I wanted to create a multi-framework targeting assembly. I have the most part working, but I cannot make the conditional compilation work. All assemblies are being build according to the release output paths defined per Release|FrameworkVersion condition, but the conditional defines (NET40 etc) are not respected when building for other than 4.5 Framework (which is the default target).

Do you have a sample project to download?

Gravatar

Tim Cartwright

# Reply

I found your article quite interesting, and decided to take it a step further by adding a build for each framework version with a single compile as an alternative to batching MSBuild. I wanted to do this so I could include these various builds into a nuget package. Here is how I did it:

In your default property group:

    <!--defaults, in case they add a build that does not match -->
    <AssemblyName>MultiBuild</AssemblyName>
    <TargetFrameworkVersion>v4.0</TargetFrameworkVersion>
    <!--4.0 build-->
    <AssemblyName Condition=" '$(Configuration)' == 'Debug' OR '$(Configuration)' == 'Release' ">MultiBuild.v40</AssemblyName>
    <TargetFrameworkVersion Condition=" '$(Configuration)' == 'Debug' OR '$(Configuration)' == 'Release' ">v4.0</TargetFrameworkVersion>
    <!--4.5 build-->
    <AssemblyName Condition=" '$(Configuration)' == 'Debug 4.51' OR '$(Configuration)' == 'Release 4.51' ">MultiBuild.v451</AssemblyName>
    <TargetFrameworkVersion Condition=" '$(Configuration)' == 'Debug 4.51' OR '$(Configuration)' == 'Release 4.51' ">v4.5.1</TargetFrameworkVersion>

Then either add a new AfterBuild event, or add the MSBuild elements to your existing AfterBuild:

Btw, don’t do this:

            <Target Name="AfterBuild">
                            <MSBuild Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' " Projects="$(MSBuildProjectFile)" Properties="Configuration=Debug 4.51;PlatForm=AnyCPU" RunEachTargetSeparately="true" />
                            <MSBuild Condition=" '$(Configuration)|$(Platform)' == 'Debug 4.51|AnyCPU' " Projects="$(MSBuildProjectFile)" Properties="Configuration=Debug;PlatForm=AnyCPU" RunEachTargetSeparately="true" />
            </Target> 

In this case when you build either one, it would build the other. Then build the other one, then build the other… rinse,, repeat and you’ve created a build circular reference. With this technique I suggest all other builds triggering off the base Debug and Release build.

Gravatar

Richard Moss

# Reply

Tim,

Thanks for the comment, that looks interesting. In the end I abandoned this approach after getting really annoyed with Visual Studio having multiple warnings about different references, although it became a moot point as I turned the library into a multi target nuget package after I decided to convert a great many of our libraries into private packages. I'm still a bit old school and really like my batch files too ;)

Thanks again for the comment!

Regards;
Richard Moss