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<
and>
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.
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.
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.
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.
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.
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.
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).
The /p:name=value
arguments are used to override properties in
the solution 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 asCyotek_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".
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.
Update History
- 2015-08-18 - First published
- 2020-11-21 - Updated formatting
Like what you're reading? Perhaps you like to buy us a coffee?
# Benny Tordrup
# Tim Cartwright
# Richard Moss
# Kjara