In my current project I solved some business and technical requirements with MSBuild rather than C# code a couple of times.
Additionally, I employed MSBuild-based techniques to make life in a big repo less repetitive and more consistent.
The above involved among others:
- maintaining compatibility to an ever changing set of up to six different versions of a 3rd party API with almost no code duplication and minimal maintenance effort
- keeping a sibling project using
ProjectReference
to a project usingPackageReference
, for development purposes without code duplication - using the project file as the single source of truth for information needed at both build time and run time
- compiling an application as part of the build process of another project
- automating test project set-up, including InternalsVisibleTo from the project under test, referencing NUnit, NSubstitute and FluentAssertions, referencing the project under test from the test project etc.
- a project building its own package dependency
MSBuild crash course
To follow the posts in this series, it is necessary to understand a few core concepts of MSBuild.
MSBuild is essentially a declarative programming language with horrible syntax.
Fortunately, it is well-documented these days.
That is why I will only briefly touch the most important concepts.
For anything more please refer to the documentation.
Properties
You can create global string variables.
In MSBuild they are called properties and have to be declared inside a property group.
<PropertyGroup> <MyOwnProperty>Hello</MyOwnProperty> </PropertyGroup>
You can then use the global string variable like this:
<PropertyGroup> <MyOtherProperty>$(MyOwnProperty) World</MyOtherProperty> </PropertyGroup>
Items
You can create arrays of objects.
In MSBuild they are called items and have to be declared inside an item group.
<ItemGroup> <MyItem Include="Hello" /> <MyItem Include="World" /> </ItemGroup>
You can then use the item in the declaration of other items or properties.
<ItemGroup> <!-- creates a new array with an exclamation mark appended to each item of the source array --> <ShoutingEachWord Include="@(MyItem->'%(Identity)!')" /> </ItemGroup> <PropertyGroup> <AllWordsWithDefaultSemicolonSeparator>@(MyItem)</AllWordsWithDefaultSemicolonSeparator> <AllWordsWithSpaceSeparator>@(MyItem, ' ')</AllWordsWithSpaceSeparator> </PropertyGroup>
Items can be treated as a key-value store by declaring metadata.
Most of the showcased techniques will only use items as strings.
Both properties and items can be defined conditionally using the Condition
attribute in the PropertyGroup
or ItemGroup
respectively.
Tasks
A task is something that can be executed and is usually written in C#.
A task has properties and items as its inputs and outputs.
Some tasks are shipped along with MSBuild.
A task is executed as part of a target.
Targets
A target is a group of properties, items and tasks.
A target can declare dependencies on other targets.
At build-time MSBuild will resolve the dependency trees of targets for the given project and then execute all targets in the order required by the trees.
Imports
A project file can import all of the above from another file.
The other file may not define an SDK (see below).
Files that contain things that are supposed to be imported early in the project file have the file extension .props
by convention.
Files that contain things that are supposed to be imported late in the project file have the file extension .targets
by convention.
This is only a convention.
All files can be imported at any time and are treated equally.
This is how such an imported file can look like:
<Project> <PropertyGroup> <MyImportedProperty>Imported</MyImportedProperty> </PropertyGroup> </Project>
This is how it is imported assuming the file is named “SomeProps.props” and lives in the same directory as the importing file:
<Import Project="SomeProps.props" />
Import files can import other files themselves.
SDKs
A modern SDK-style project is based on exactly one so-called SDK.
An SDK is a collection of predefined properties, items, targets and tasks.
An SDK also responds to certain properties, items and item-metadata declared by the consuming project.
Those are called “well-known”.
Some of them have SDK-specific default values.
Some of them are hooks for the consuming project to define.
You can think of them as the API of the SDK.
This is a minimal project file using the .NET SDK.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net6.0</TargetFramework> </PropertyGroup> </Project>
The TargetFramework
property is a well-known property that is used by the .NET SDK.
It has no default value and needs to be defined by the project.
Note that using the Sdk
attribute above is just shorthand for importing the SDK’s props and targets files.
It is equivalent to this.
<Project> <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" /> <PropertyGroup> <TargetFramework>net6.0</TargetFramework> </PropertyGroup> <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" /> </Project>
In an average project file based on a .NET SDK there are project dependencies and package dependencies.
They are declared via an item group containing ProjectReference
and PackageReference
items.
There is nothing special about those items other than that they are well-known to the .NET SDK and used by the SDK to determine how to build the project.
The items can be manipulated just like any other items.
Bringing it all together
The following defines a custom target Print
that will be triggered before the well-known Build
target that is defined by the .NET SDK.
It uses the Message
task that comes with MSBuild to print the values of all the sample properties and items defined above.
<Target Name="Print" BeforeTargets="Build"> <Message Importance="high" Text="MyOtherProperty: $(MyOtherProperty)" /> <Message Importance="high" Text="ShoutingEachWord: @(ShoutingEachWord, ' ')" /> <Message Importance="high" Text="AllWordsWithDefaultSemicolonSeparator: $(AllWordsWithDefaultSemicolonSeparator)" /> <Message Importance="high" Text="AllWordsWithSpaceSeparator: $(AllWordsWithSpaceSeparator)" /> <Message Importance="high" Text="MyImportedProperty: $(MyImportedProperty)" /> </Target>
It will lead to the following build output
1>MyOtherProperty: Hello World 1>ShoutingEachWord: Hello! World! 1>AllWordsWithDefaultSemicolonSeparator: Hello;World 1>AllWordsWithSpaceSeparator: Hello World 1>MyImportedProperty: Imported
I hope this suffices to follow the posts of this series.
Each post will cover the concepts necessary for the described technique in appropriate detail.