Пишемо файл проєкту MSBuild з нуля

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Привіт! Мене звати Андрій, і я Lead Software Engineer у компанії SoftServe. Переважно я розробляю на платформі .NET і мові C#. Під час роботи іноді виникає потреба аналізувати та оптимізувати процес збірки проєктів і в багатьох розробників виникають труднощі з виконанням таких задач через недостатнє розуміння цієї теми.

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

Ця стаття буде найбільш корисною розробникам, які вже мають досвід роботи з C#-проєктами та хочуть поглибити свої знання системи MSBuild. Вважається, що читач вже знає як виконувати базові операції з простими C#-проєктами за допомогою графічного інтерфейсу будь-якого інтегрованого середовища розробки або текстового редактора.

Перш ніж почати розглядати структуру та синтаксис файлів проєкту MSBuild, треба розглянути більш загальну картину, щоб зрозуміти навіщо потрібен MSBuild і які функції він виконує.

MSBuild та Roslyn

MSBuild та Roslyn є компонентами в екосистемі .NET, що взаємно доповнюють один одного, проте виконують різні задачі.

Система MSBuild автоматизує процес збірки, керуючи перетворенням вихідного коду на виконувані файли шляхом управління залежностями, порядком та конфігурацією збірки. Компілятор Roslyn же виконує саме компіляцію та аналіз коду для мови C# (і Visual Basic) з урахуванням параметрів, які йому передає MSBuild.

Чи можна використовувати лише MSBuild, без Roslyn? Ні, адже MSBuild спирається на Roslyn для компіляції коду, сам він не є компілятором і не може виконувати функції Roslyn.

Чи можна використовувати лише Roslyn, без MSBuild? Теоретично, можна. В такому випадку розробнику буде необхідно самостійно передавати всі файли з вихідним кодом, залежності та параметри компіляції в Roslyn.

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

Що таке файл проєкту MSBuild

Файл проєкту MSBuild — це набір інструкцій для MSBuild, які вказують, які кроки треба виконати, щоб зібрати проєкт, і які параметри треба для цього використовувати. Для запису використовується мова XML.

Типовий файл проєкту

Розглянемо простий файл проєкту, в якому визначено конфігурацію для побудови програми на основі .NET SDK. Він містить один основний блок властивостей, де вказано цільову платформу як .NET 6.0, включено налаштування, за якого всі попередження розглядаються як помилки, активовано обробку Nullable-типів, а також автоматично встановлено копірайт з поточним роком.

Також присутні дві групи елементів: перша містить посилання на NuGet-пакети, що використовуються в цьому проєкті, друга — посилання на інший проєкт, що знаходиться у сусідній директорії. Переважна більшість C#-проєктів виглядає саме таким чином.

<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Copyright>Company $([System.DateTime]::UtcNow.Year)</Copyright>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.10.8" />
    <PackageReference Include="Microsoft.AspNetCore.HeaderPropagation" Version="6.0.15" />
    <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.20" />
    <PackageReference Include="Serilog.Sinks.Seq" Version="5.2.3" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
  </ItemGroup>
 
  <ItemGroup>
    <ProjectReference Include="..\Service1.Contracts\Service1.Contracts.csproj" />
  </ItemGroup>
 
</Project>

Файл проєкту за замовчуванням

Розгляньмо, який файл проєкту створює .NET SDK за замовчуванням при створенні нового проєкту.

<Project Sdk="Microsoft.NET.Sdk">

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

</Project>

Насправді це ще не найменший файл, адже налаштування ImplicitUsings та Nullable не є обов’язковими та мають значення за замовчуванням.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>
  </PropertyGroup>

</Project>

Цей файл не містить майже нічого, але працює завдяки Sdk="Microsoft.NET.Sdk". Атрибут Sdk каже MSBuild знайти інструкції для збірки за межами нашого проєкту. Вони можуть бути встановлені разом з .NET SDK, MSBuild, або можуть поширюватись незалежно як NuGet-пакети. Завдяки цьому механізму нам не доводиться кожного разу описувати всі кроки для збірки в файлах проєкту, а лише додавати деякі налаштування.

Пишемо самі

Хоча ми і отримали дуже простий файл проєкту, але, на жаль, не дізнались нічого про те, як він працює через використання вбудованого SDK, який ховає від розробників всю складність системи збірки. Тож в дидактичних цілях я вирішив написати файл проєкту без використання вбудованого SDK.

Файл проєкту потрібен, щоб щось збирати. Тож я буду збирати ось такий простий C#-застосунок, який використовує оператори верхнього рівня:

using System;
Console.WriteLine("Привіт, Світе!");

Створюємо заготовку

Почнемо наш проєкт з нуля. Створимо порожній файл myapp.proj. Хоча деякі інтегровані середовища розробки підтримують лише файли з розширенням csproj та іноді деякими іншими (fsproj, vbproj, sqlproj, vcxproj, та інші), для MSBuild розширення файлу не є істотним. З урахуванням цього, я назвав цей поки що порожній файл проєкту myapp.proj.

Спробувавши зібрати цей проєкт за допомогою команди dotnet build pyapp.proj в оболонці bash, я отримав наступну помилку:

error MSB4025: The project file could not be loaded. Root element is missing.

З цього повідомлення і загальнодоступної документації Microsoft можна зробити висновок, що кожен проєкт має містити кореневий XML-елемент Project.

<Project>
</Project>

Знову спробуємо зібрати проєкт.

error MSB4040: There is no target in the project.

В нашому проєкті відсутній таргет, але що це таке? Про таргет MSBuild можна думати як про функцію в інших мовах програмування. Це іменований набір команд, який можна викликати з інших частин проєкту MSBuild. Додамо до нашого проєкту таргет Build, який в майбутньому буде збирати наш проєкт.

<Project>

  <Target Name="Build">
  </Target>

</Project>

На відміну від C#, де метод Main є точкою входу програми, в проєкті за замовчуванням виконання починається з першого за порядком таргету незалежно від його імені, але можна викликати будь-який таргет таким чином: dotnet build myapp.proj -t:targetname. Ім’я Build нашого таргету не має ніякого значення для MSBuild, воно лише потрібно нам для розуміння того, що цей таргет робить.

Хоча наш проєкт ще не робить нічого корисного, тепер він хоча б інтерпретується MSBuild без помилок.

MSBuild version 17.9.4+90725d08d for .NET
 
Build succeeded.
	0 Warning(s)
	0 Error(s)
 
Time Elapsed 00:00:00.21

Додаємо компіляцію

Мови загального призначення, як-от C#, призначені для широкого спектра програмних завдань. Вони надають різноманітні можливості, включаючи різні типи даних та велику екосистему бібліотек для різних потреб і дозволяють розробникам будувати додатки у різних доменах.

На відміну від них, спеціалізовані мови призначені для конкретних завдань або предметних сфер. Проєкти (сценарії) MSBuild, є прикладом спеціалізованої мови. Вона спеціально розроблена для автоматизації процесу збірки програмних проєктів і містить вбудовані команди для цього.

Команди MSBuild називаються тасками. Це атомарні операції, які виконують певні конкретні дії. Для виклику компілятора C# використаємо таск Csc і передамо йому наш файл з C#-кодом.

<Project>

  <Target Name="Build">
	<Csc Sources="Program.cs" />
  </Target>

</Project>
Program.cs(1,7): error CS0246: The type or namespace name 'System' could not be found (are you missing a using directive or an assembly reference?)
Program.cs(3,7): error CS0518: Predefined type 'System.Object' is not defined or imported
Program.cs(5,12): error CS0518: Predefined type 'System.Void' is not defined or imported

Отримуємо помилку і згадуємо, що в проєкті ми імпортували простір імен System і використовували типи System.Object та System.Void (так, це справжній тип learn.microsoft.com/...​us/dotnet/api/system.void). Нам потрібно додати посилання на бібліотеки, що містять ці типи. Для цього використаємо атрибут References.

<Project>

  <Target Name="Build" >
    <Csc Sources="Program.cs" References="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Runtime.dll; C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Console.dll" />
  </Target>

</Project>

Рефакторимо

Хоча ця мова є спеціалізованою, але вона дозволяє оголошувати скалярні та векторні змінні. Список залежностей нашого застосунку є гарним кандидатом для винесення в змінну типу списку. Це робиться наступним чином:

<Project>

  <ItemGroup>
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Runtime.dll" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Console.dll" />
  </ItemGroup>

  <Target Name="Build" >
    <Csc Sources="Program.cs" References="@(Reference)" />
  </Target>

</Project>

До елементу ItemGroup додаємо декілька дочірніх елементів з однією назвою (в нашому випадку Reference). Значення атрибутів Include цих елементів будуть зібрані у список, який ми можемо отримати за допомогою синтаксису @(Reference). Reference в цьому контексті — це просто назва змінної, вона може бути будь-якою.

Можна думати про Include як про метод, що додає ще один або більше елементів до списку Reference. Також підтримуються шаблони заміщення як *. Скомпілюємо всі файли з розширенням *.cs в поточній директорії.

<Project>

  <ItemGroup>
    <Compile Include="*.cs" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Runtime.dll" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Console.dll" />
  </ItemGroup>

  <Target Name="Build" >
    <Csc Sources="@(Compile)" References="@(Reference)" />
  </Target>

</Project>

Даємо назву нашому застосунку

Таск Csc має багато параметрів. Наприклад, можна вказати назву застосунку, який буде скомпільовано.
<Project>

  <ItemGroup>
    <Compile Include="*.cs" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Runtime.dll" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Console.dll" />
  </ItemGroup>

  <Target Name="Build" >
    <Csc Sources="@(Compile)" References="@(Reference)" OutputAssembly="bin/MyApp.dll" />
  </Target>

</Project>

На жаль, при спробі зібрати цей проєкт ми отримаємо помилку через відсутність директорії bin.

CSC : error CS2012: Cannot open 'C:\Users\...\bin\MyApp.dll' for writing -- 'Could not find a part of the path 'C:\Users\...\bin\MyApp.dll'.'

Для створення цієї директорії треба використати таск MakeDir.

<Project>

  <ItemGroup>
    <Compile Include="*.cs" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Runtime.dll" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Console.dll" />
  </ItemGroup>

  <Target Name="Build" >
    <MakeDir Directories="bin/" />
    <Csc Sources="@(Compile)" References="@(Reference)" OutputAssembly="bin/MyApp.dll" />
  </Target>

</Project>

Знову рефакторимо

В нас з’явилось дублювання назви директорії в декількох місцях, використаємо змінну, щоб його прибрати. На відміну від векторних змінних, скалярні оголошуються в елементі PropertyGroup таким чином:

<Project>

  <PropertyGroup>
    <AssemblyName>MyApp</AssemblyName>
    <OutputPath>bin/</OutputPath>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="*.cs" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Runtime.dll" />
    <Reference Include="C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Console.dll" />
  </ItemGroup>

  <Target Name="Build" >
    <MakeDir Directories="$(OutputPath)" />
    <Csc Sources="@(Compile)" References="@(Reference)" OutputAssembly="$(OutputPath)$(AssemblyName).dll" />
  </Target>

</Project>

Якщо декілька разів оголосити скалярну змінну, то вона прийме останнє присвоєне їй значення, адже за визначенням вона може мати лише одне значення. Також її значення можна отримати за допомогою іншого синтаксису $(AssemblyName).

Висновок

В цій статті ми розглянули такі елементи файлу проєкту MSBuild як атрибут Sdk, таргети, таски, скалярні і векторні змінні. За допомогою цих примітивів ми змогли написати простий файл проєкту, який зібрав наш С#-застосунок.

MSBuild також підтримує просунуті сценарії, як-от інкрементальна збірка та написання користувацьких SDK для роботи з іншими типами проєктів (як от MSBuild.Sdk.SqlProj для .sqlproj-проєктів).

Тепер ви готові детально розбиратись з іншими SDK, адже вони так само є набором примітивів MSBuild, які були розглянуті в цій статті.

👍ПодобаєтьсяСподобалось19
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую ! Нарешті хтось ще цієї теми торкнувся. Ось ще доповідь по темі доречі
www.youtube.com/watch?v=oixD2vhBMII

Дякую за статтю!
А можна якось подивитись що конкретно (який набір команд) виконується для того чи іншого SDK?

Можна використати msbuildlog.com щоб подивитись які команди виконувались для конкретної збірки і, змінюючи SDK в проєкті, можна буде побачити відмінності між ними.

Стаття база. Хоч взагальному дізнався як то все під капотом відбувається

“..\Service1.Contracts\Service1.Contracts.csproj”
C:/Program Files/dotnet/packs/Microsoft.NETCore.App.Ref/7.0.14/ref/net7.0/System.Console.dll"

Є якісь плавила, як писати шляхи?
Як воно обробляє \ та . у імені файла/директорії?

\ та / є спеціальними символами, що розділяють директорії у шляху до файла, тому ім’я файлу не може їх містити. На Windows використовують \, а на Linux — /, але MSBuild на обох платформах розуміє і /, і \.
Щодо крапки спеціальних правил немає, це звичайний символ. Якщо ж питання було саме про використання «..» як імені дерикторії, то це означає батьківську директорію.

\ та / є спеціальними символами, що розділяють директорії у шляху до файла, тому ім’я файлу не може їх містити.

Чому не можуть, хтось забороняє?

touch 'my\text\file.txt'
touch '..\here\..\file.txt'
mkdir \\
touch \\\\..my\\file.txt
> find .
.
./\
./my\text\file.txt
./..\here\..\file.txt
./\\..my\file.txt
на обох платформах розуміє і /, і \

Розуміє, що . (крапка) .. (двокрапка) та \ (зворотній слеш) можуть бути частиною файлу ?
Як воно розуміє, де \ та .. — це частина файлу, а де частина шляха?

Провідник Windows не дозволяє використовувати символи /\:*?"<>| в імені файлу.
На Linux / не можна використовувати в імені файлу оскільки він розділяє директорії.

Провідник Windows

До чого тут «Провідник Windows»?
Це невідємна частина msbuild?

На Linux / не можна використовувати в імені файлу

Питання було про «\», «.» та «..», це допустимі частини імені файлу у Linux та macos

Майже непотрібний навик у часи робити солюшинами через IDE та SDK(писати конфігурацію сбірки с нуля), найчастіше потрібно щось відкрити і підредагувати мінімально, версію або ще щось недоступне з UI, чи якийсь контент файли кудись перекинути при пабліші.
Використовувати для сбірки якось складних речей дуже не зручно, не зрозуміло чому доступні і задокументовані хуки(targets) не працють, профілювання і structured verbose log(типу msbuildlog) теж не допомогає. Окрема печаль — жонглювання і сумісність версії, теж їще той трешак з виходом dotnet sdk, частими оновленнями і breaking changes. полотно xml для сбірки в 2024 році — це жах.

MSBuild також підтримує просунуті сценарії, як-от інкрементальна збірка та написання користувацьких SDK для роботи з іншими типами проєктів (як от MSBuild.Sdk.SqlProj для .sqlproj-проєктів).

і що там цю пропріетарну хрінь вже можно запустити нормально без windows білд машини і встановленого SSDT?

ну я не згоден з тим, що це неважливо. є багато сценаріїв коли це буде працювати. але ж автор не зміг показати нащо він потрібен взагалі.

Стаття — огинь! Чекаю продовження від автора

як же люди жили с msbuild коли ще не було roslin...

Коли ще не було Roslyn, MSBuild використовував старий C# компілятор csc.exe, який був частиною інструментів .NET Framework. Пізніше його замінили на Roslyn, а назву csc.exe залишили.

Думаю, тут скоріше про те, що MSBuild сам по собі ніяк не залежить від Roslyn :)
Навіть у рамках .NET є сценарії, в яких Roslyn не використовується для компіляції (той самий F# компілюється власним компілятором)
А є ще й C++ та й інші сценарії, як от повністю написаний з нуля Project SDK для інших мов або типів проектів
Тож трішки дивним виглядає тейк про те що «MSBuild не можна використовувати без Roslyn», хоча я і розумію який контекст мався на увазі.

саме так. огляд автора дуже однобічний з купою ложних висновків.

купою ложних висновків.

яких саме?

Дякую за статтю, цікаво було прочитати. За весь час роботи якось не доводилось заглиблюватися в MSBuild — було цікаво!

Підписатись на коментарі