Зі сторони виглядає що багато програмістів побоюються MSBuild і намагаются ніколи його не чіпати. Це на мою думку не дуже продуктивно. Багато цих страхів щодо MSBuild через те що у нього є свою, і досить незвична для програміста, термінологія. Я спробую показати що MSBuild це лише дінамічна мова програмування, досить чудернацька, але лише мова програмування. Можливо це полегшить читачу шлях його вивчення.

PropertyGroup та ItemGroup

Почнемо із простих речей. теги які декларовані у PropertyGroup це будуть наші звичайні змінні.

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net7.0</TargetFramework>
  <ImplicitUsings>true</ImplicitUsings>
  <Nullable>enable</Nullable>
</PropertyGroup>

що можна представити як

$OutputType="Exe"
$TargetFramework="net7.0"
$ImplicitUsings=true
$Nullable="enable"

як ви бачите, ми маємо лише рядки, і як спеціальний випадок bool значення. Насправді навіть “true”/”false” значення це рядки, але операції порівняння будуть працювати із true, або із "true" і тому краще важати що це такий спеціальний тип.

Теперь йдемо до ItemGroup, вони трішечки складніші, але найближче приближення це масиви анонімних об’єктів.

<ItemGroup>
  <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>

<ItemGroup>
  <ProjectReference Include="..\OpenAlexNet\OpenAlexNet.csproj" />
</ItemGroup>

<ItemGroup Label="dotnet pack instructions">
  <Content Include="build\*.targets">
    <Pack>true</Pack>
    <PackagePath>build\</PackagePath>
  </Content>
</ItemGroup>

<ItemGroup>
  <Content Include="$(OutputPath)\*.dll;$(OutputPath)\*.json">
    <Pack>true</Pack>
    <PackagePath>build\</PackagePath>
  </Content>
</ItemGroup>

Якщо припустити що $(OutputPath) == 'some\path' то це буде виглядати на нашій умовній мові ось так:

@PackageReference.include({ "ItemSpec": "System.CommandLine", "Version": "2.0.0-beta4.22272.1" })
@ProjectReference.include({ "ItemSpec": "..\OpenAlexNet\OpenAlexNet.csproj" })

// dotnet pack instructions
@Content.include({ "ItemSpec": "build\mytarget.targets", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "build\othertarget.targets", Pack: true, PackagePath: "build\" })

@Content.include({ "ItemSpec": "some\path\myapp.dll", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\mylib.dll", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\myother.dll", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\3rdparty.dll", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\appsettings.json", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\appsettings.Development.json", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\config.json", Pack: true, PackagePath: "build\" })

І це усе. Уся магія MSBuild, така як рокриття * та змінних буде проходити під час декларування.

Імпортування інших файлів проектів

MSBuild дозволяє нам імпортувати інші файли де можуть бути задекларовані PropertyGroup/ItemGroup/Target елементи.

<Import Project="$(CommonLocation)\General.targets" />

це буде майже С-вставкою. Де усе що буде знаходитися у файлі вказанному у Project атрибуті, буде включено.

#include <$(CommonLocation)\General.targets>

якщо вказати атрибут Sdk то файл проекту буде шукатися у Nuget пакеті

<Import Project="General.targets" Sdk="MyNugetSdk" />

це аналогічно

#include <$(PkgMyNugetSdk)\Sdk\General.targets>

де PkgMyNugetSdk це шлях де буде лежати розпакований Nuget пакет MyNugetSdk. Назва властивості аналогічна назві яку би згенерував MSBuild якщо для пакету була би встановлені метадані GeneratePathProperty=true. Трішки більше можна почитати тут

Target та Task

Теперь коли ми навчилися більш менш створювати змінні треба якось виконувати код. За це відповідають у MSBuild два поняття Target та Task. І то і інше можна представляти собі як функції. Якщо ви спитаєте навіщо дві різні концепції? справа у тому що Target потрібен для дуже простих скриптових задач, а Task для більш складних, які пишуться на інших мовах програмування. Можна вважати їх вбудованими функціями. Звісно це не зовсім так, бо можна додавати свої таски, але давайте залишимо це за рамками. Також треба пам’ятати що ви не можете виконувати таски самостійно, лише через описані цілі. Тож почнемо з них

<Target Name="MyMessage">
  <Message Importance="High" Text="Hello MSBuild!" />
  <Message Text="Project File Name = $(MSBuildProjectFile)" />
  <RemoveDir Directories="$(OutputDirectory);$(DebugDirectory)" />
</Target>

що буде приблизно так:

void MyMessage()
{
  Message(Text: "Hello MSBuild!", Importance: "High")
  Message(Text: $"Project File Name = {$MSBuildProjectFile}", Importance: "High")
  RemoveDir(@($(OutputDirectory);$(DebugDirectory)))
}

Залежності

Тепер коли ми навчилися робити функції, було би непогано їх викликати одну із другої. І тут MSBuild дуже не звичний, тому що немає можливості викликати іншу ціль із своєї цілі. Це зроблено для того щоб мати можливісь виконувати цілі паралельно друг від друга. Замість явного виклика однієї цілі із іншої використовується механізм залежностей. DependsOnTargets, ‘AfterTargets’ та BeforeTargets дозволяють вказати після яких цілей треба викликати нашу ціль, або навпаки до яких цілей треба викликати нашу ціль.

Умовно кажучи

<Project DefaultTargets="Link" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
	<Target Name="Compile">
		<Message Text="Compiling" />
	</Target>
	<Target Name="AfterCompile" AfterTargets="Compile">
		<Message Text="After Compiling" />
	</Target>
	<Target Name="Link" DependsOnTargets="Compile">
		<Message Text="Linking" />
	</Target>
	<Target Name="Optimize" BeforeTargets="Link">
		<Message Text="Optimizing" />
	</Target>
</Project>

приблизно ви би написали десь так.

CompileIsNotRun = true;
void Compile()
{
  // Compile target
  Message(Text: "Compiling")

  // AfterTargets
  AfterCompile()

  CompileIsNotRun = false;
}

AfterCompileIsNotRun = true;
void AfterCompile()
{
  // AfterCompile target
  Message(Text: "After Compiling")
  AfterCompileIsNotRun = false;
}

LinkIsNotRun = true;
void Link()
{
  if (CompileIsNotRun)
    Compile();

  // BeforeTargets
  Optimize();

  // Link target
  Message(Text: "Linking")
  LinkIsNotRun = false;
}

OptimizeIsNotRun = true;
void Optimize()
{
  // Optimize target
  Message(Text: "Optimizing")
  OptimizeIsNotRun = false;
}

і якщо ви виконаєте dotnet build /v:n

Умовне виконання

Це найпростіша із усіх речей. Якщо ми маємо атрибут Condition, то MSBuild перевіряє умову, і якщо вона виконується, то присвоює значення, додає айтем, виконує задачу або ціль.

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <ImplicitUsings>true</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" Condition="$(OutputType) == 'Exe''" />
</ItemGroup>

<ItemGroup>
  <ProjectReference Include="..\OpenAlexNet\OpenAlexNet.csproj" />
</ItemGroup>

<ItemGroup Label="dotnet pack instructions" Condition="$(ImplicitUsings) == true">
  <Content Include="build\*.targets">
    <Pack>true</Pack>
    <PackagePath>build\</PackagePath>
  </Content>
</ItemGroup>

<ItemGroup>
  <Content Include="$(OutputPath)\*.dll;$(OutputPath)\*.json">
    <Pack>true</Pack>
    <PackagePath>build\</PackagePath>
  </Content>
</ItemGroup>

<Target Name="Compile" Condition="$(ImplicitUsings) == true">
  <Message Text="Compiling" />
</Target>

буде приблизно так.

$OutputType="Exe"
$TargetFramework="net7.0"
$ImplicitUsings=true
$Nullable="enable"

if ($OutputType == 'Exe')
{
  @PackageReference.include({ "ItemSpec": "System.CommandLine", "Version": "2.0.0-beta4.22272.1" })
}
  
@ProjectReference.include({ "ItemSpec": "..\OpenAlexNet\OpenAlexNet.csproj" })

if ($ImplicitUsings == true)
{
  // dotnet pack instructions
  @Content.include({ "ItemSpec": "build\mytarget.targets", Pack: true, PackagePath: "build\" })
  @Content.include({ "ItemSpec": "build\othertarget.targets", Pack: true, PackagePath: "build\" })
}

@Content.include({ "ItemSpec": "some\path\myapp.dll", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\mylib.dll", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\myother.dll", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\3rdparty.dll", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\appsettings.json", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\appsettings.Development.json", Pack: true, PackagePath: "build\" })
@Content.include({ "ItemSpec": "some\path\config.json", Pack: true, PackagePath: "build\" })

CompileIsNotRun = true;
void Compile()
{
  if ($ImplicitUsings == true)
    return;

  // Compile target
  Message(Text: "Compiling")

  CompileIsNotRun = false;
}

Більш про умовні конструкції та підтримувані операції краще почитати на сайті [Майкрософт].

Заключення

На мою думку треба не думати як підшаманити MSBuild файл, а краще зрозуміти як воно усе це працює. До речі якщо щось працює не так, і ви бачите помилку у MSbuild моя дефолтна порада, це додати до запуску dotnet build параметр /bl і подивитися що у там коїться у файлі msbuild.binlog через MSBuildLogViewer

PS. помилки української виправив Володимир Лишенко, дякую!