Всем привет! Я хочу рассказать о таком инструменте как Cake (C# Make).
Итак, что такое Cake?
Cake — это кроссплатформенная система сборки, использующая DSL с синтаксисом C# для того, что осуществлять в процессе сборки такие вещи, как сборка бинарников из исходных кодов, копирование файлов, создание/очищение/удаление папок, архивация артефактов, упаковка nuget-пакетов, прогоны юнит-тестов и многое другое. Так же Cake имеет развитую систему аддонов (просто C# классы, зачастую упакованные в nuget). Стоит отметить, что большое количество полезных функций уже встроены в Cake, а еще больше, практически на все случаи жизни, написаны сообществом и довольно успешно распространяются.
Сake использует модель программирования называемую dependency based programming, аналогично другим подобным системам вроде Rake или Fake. Суть этой модели в том, что мы для исполнения нашей программы мы определяем задачи и зависимости между ними. Подробнее про данную модель можно почитать у Мартина Фаулера.
Подобная модель заставляет нас представлять наш процесс сборки как некоторые задачи (Task) и зависимости между ними. При этом логически исполнение идет в обратном порядке: мы указываем задачу, которую хотим выполнить и ее зависимости, Cake же определяет, какие задачи могут быть выполнены (для них разрешены или отсутствуют зависимости) и исполняет их.
Так, например, мы хотим исполнить A, однако она зависит от B и C, а B зависит от D. Таким образом Cake исполнит их в следующем порядке:
- С или D
- B
- A
Задача же (Task) в Cake обычно представляет собой законченный кусок работы по сборке/тестированию/упаковке. Объявляется следующим образом
Task("A") // Название
.Does(() =>
{
//Реализация Task A
});
Указать же, что задача A зависима от, например, задачи B можно с помощью метода IsDependentOn:
Task("A") // Название
.IsDependentOn("B")
.Does(() =>
{
//Реализация Task A
});
Также можно легко задавать условия, при которых задача будет или не будет выполняться с помощью метода WithCriteria:
Task("B") // Название
.IsDependentOn("C")
.WithCriteria(DateTime.Now.Second % 2 == 0)
.Does(() =>
{
//Реализация Task A
});
Если же какая-то задача зависит от задачи B, а критерий принимает значение false, то задача B не выполнится, однако поток исполнения пойдет дальше и исполнит задачи, от которых зависит B.
Существует также перегрузка метода WithCriteria, принимающая в качестве параметра функцию, которая возвращает bool. В этом случае выражение будет посчитано только тогда, когда до задачи дойдет очередь, а не в момент выстраивания дерева задач.
Cake также поддерживает некоторые специфичные препроцессорные директивы, среди которых load, reference, tool и break. Подбробнее о них можно почитать на соответствующей странице документации.
Думаю, что людей, которые собирают свои проекты руками в эру DevOps, уже не так уж много. Преимущество любой системы сборки в сравнении с ручной сборкой очевидно — автоматически настроенный процесс всегда лучше ручных манипуляций.
Преимущества Cake при разработке на C
Зачем использовать именно Cake, раз существует множество альтернатив? Если вы не разрабатываете на C#, то, скорее всего, не за чем. А если разрабатываете, то выглядит разумным писать скрипты сборки на тем же языке, на котором написан и основной проект, поскольку не нужно изучать еще один язык программирования и плодить их зоопарк в рамках одной кодовой базы. Потому и стали появляться системы сборки типа Rake (Ruby), Psake (Powershell) или Fake (F#).
Cake — безусловно не единственный способ собрать проект на C#. Как варианты, можно привести в пример чистый MSBuild, Powershell, Bat-скрипты или CI Server типа Teamcity или Jenkins, однако все они имеют как преимущества, так и недостатки:
- Скрипты на Powershell, равно как Bat/Bash довольно сложно поддерживать
- DSL на основе C# приятнее по синтаксису DSL на основе XML из MSBuild. К тому же поддержка MSBuild в Linux/Mac появилась не так давно.
- CI-сервер накладывает Vendor-lock и зачастую требует "программирования мышкой", следовательно и отвязывает версию процесса сборки от версии кода в репозитории, хотя некоторые CI системы и позволяют хранить файлы с описанием процесса сборки вместе с кодом.
- Использование CI не позволяет собирать код локально так же, как и на CI-сервере
Установка Cake
Теперь поговорим о том, как же исполнять скрипты с задачами. У cake есть загрузчик, который все сделает за вас. Скачать его можно либо вручную, либо следующей powershell командой:
Invoke-WebRequest http://cakebuild.net/download/bootstrapper/windows -OutFile build.ps1
Скачанный файл build.ps1 затем сам загрузит необходимый cake.exe, если он еще не загружен, и исполнит cake-скрипт (по-умолчанию это build.cake), если мы вызовем его следующей командой:
Powershell -File ".\build.ps1" -Configuration "Debug"
Мы можем также передать в build.ps1 аргументы командной строки, которые потом исполнятся. Они могут быть как встроенными, например, configuration, который обычно отвечает за конфигурацию сборки, так и заданные самостоятельно — в этом случае есть два способа:
- Передать как значение параметра ScriptArgs:
Powershell -File ".\build.ps1" -Script "version.cake" -ScriptArgs '--buildNumber="123"'
- Изменить build.ps1 таким образом, чтобы он пробрасывал переданный аргумент cake.exe.
Примеры
Что же, теперь перейдем к практике. Легко можно представить типичный цикл сборки nuget-пакета:
- Собираем с помощью MSBuild из исходников dll
- Прогоняем юнит-тесты
- Собираем все в nuget по nuspec-описанию
- Пушим в nuget feed
Сборка dll
Чтобы собрать из исходников наш solution, необходимо сделать 2 вещи:
- Восстановить nuget-пакеты, от которых зависит наш solution с помощью функциии NuGetRestore
- Собрать solution по умолчанию встроенной в cake функцией DotNetBuild, передав ей параметр configuration.
Опишем задачу по сборке solution на cake-dsl:
var configuration = Argument("configuration", "Debug");
Task("Build")
.Does(() =>
{
NuGetRestore("../Solution/Solution.sln");
DotNetBuild("../Solution/Solution.sln", x => x
.SetConfiguration(configuration)
.SetVerbosity(Verbosity.Minimal)
.WithTarget("build")
.WithProperty("TreatWarningsAsErrors", "false")
);
});
RunTarget("Build");
Конфигурация сборки, соответственно, считывается из аргументов командой строки с помощью функции Argument со значением по умолчанию "Debug". Функция RunTarget запускает указанную задачу, так что мы сразу можем проверить правильность работы нашего cake-скрипта.
Юнит-тесты
Чтобы запустить юнит-тесты, совместимые с nunit v3.x, нам нужна функция NUnit3, которая не входит в поставку Cake и поэтому требует подключения через препроцессорную директиву #tool. Директива #tool позволяет подключать инструменты из nuget-пакетов, чем мы и воспользуемся:
#tool "nuget:?package=NUnit.ConsoleRunner&version=3.6.0"
При этом сама задача оказывается предельно проста. Не забываем, конечно, что она зависит от задачи Build:
#tool "nuget:?package=NUnit.ConsoleRunner&version=3.6.0"
Task("Tests::Unit")
.IsDependentOn("Build")
.Does(()=>
{
NUnit3(@"..\Solution\MyProject.Tests\bin\" + configuration + @"\MyProject.Tests.dll");
});
RunTarget("Tests::Unit");
Пакуем все в nuget
Чтобы упаковать нашу сборку в nuget, воспользуемся следующей nuget-спецификацией:
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
<metadata>
<id>Solution</id>
<version>1.0.0</version>
<title>Test solution for demonstration purposes</title>
<description>
Test solution for demonstration purposes
</description>
<authors>Gleb Smagliy</authors>
<owners>Gleb Smagliy</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<tags></tags>
<references>
<reference file="MyProject.dll" />
</references>
</metadata>
<files>
<file src=".\MyProject.dll" target="lib\net45"/>
<file src=".\MyProject.pdb" target="lib\net45"/>
</files>
</package>
Положим ее в папку со скриптом build.cake. При исполнении задачи Pack перенесем все необходимые артефакты для упаковки в папку "..\.artefacts". Для этого убедимся, что она есть (а если нет — создадим) с помощью функции EnsureDirectoryExists и очистим ее с помощью функции CleanDirectory, встроенных в Cake. С помощью же функций по копированию файлов переместим нужные нам dll и pdb в папку с арефактами.
По умолчанию собранный nupkg попадет в текущую папку, поэтому укажем в качестве OutputDirectory папку "..\package", которую мы так же создали и очистили.
Task("Pack")
.IsDependentOn("Tests::Unit")
.Does(()=>
{
var packageDir = @"..\package";
var artefactsDir = @"..\.artefacts";
MoveFiles("*.nupkg", packageDir);
EnsureDirectoryExists(packageDir);
CleanDirectory(packageDir);
EnsureDirectoryExists(artefactsDir);
CleanDirectory(artefactsDir);
CopyFiles(@"..\Solution\MyProject\bin\" + configuration + @"\*.dll", artefactsDir);
CopyFiles(@"..\Solution\MyProject\bin\" + configuration + @"\*.pdb", artefactsDir);
CopyFileToDirectory(@".\Solution.nuspec", artefactsDir);
NuGetPack(new FilePath(artefactsDir + @"\Solution.nuspec"), new NuGetPackSettings
{
OutputDirectory = packageDir
});
});
RunTarget("Pack");
Публикуем
Для публикации пакетов используется функция NuGetPush, которая принимает путь до nupkg файла, а также настройки: ссылку на nuget feed и API key. Конечно же, мы не будем хранить API Key в репозитории, а передадим снаружи опять же с помощью функции Argument. В качестве же nupkg возьмем просто первый файл в директории package, подходящий по маске с помощью GetFiles. Мы можем так сделать, поскольку директория была предварительно очищена перед упаковкой. Итак, задача по публикации описывается следующим dsl:
var nugetApiKey = Argument("NugetApiKey", "");
Task("Publish")
.IsDependentOn("Pack")
.Does(()=>
{
NuGetPush(GetFiles(@"..\package\*.nupkg").First(), new NuGetPushSettings {
Source = "https://www.nuget.org/api/v2",
ApiKey = nugetApiKey
});
});
RunTarget("Publish");
Упрощаем себе жизнь
Во время отладки cake-скрипта, да и просто для отладки nuget-пакета, можно не публиковать его каждый раз в удаленный feed. Тут-то нам на помощью и придет функция WithCriteria, которую мы рассматривали. Будем передавать скрипту параметром флаг PublishRemotely (по-умолчанию выставленный в false), чтобы по значению этого флага определять, выложить ли пакет в удаленный feed. Однако cake не выполнит скрипт, если мы пропустим задачу, которую указали функции RunTarget. Поэтому заведем фиктивную пустую задачу BuildAndPublish, которая будет зависеть от Publish:
Task("BuildAndPublish")
.IsDependentOn("Publish")
.Does(()=>
{
});
RunTarget("BuildAndPublish");
И добавим условие к задаче Publish:
var nugetApiKey = Argument("NugetApiKey", "");
var publishRemotely = Argument("PublishRemotely", false);
Task("Publish")
.IsDependentOn("Pack")
.WithCriteria(publishRemotely)
.Does(()=>
{
NuGetPush(GetFiles(@"..\package\*.nupkg").First(), new NuGetPushSettings {
Source = "https://www.nuget.org/api/v2",
ApiKey = nugetApiKey
});
});
Скрипт для сборки и публикации nuget-пакета почти готов, осталось только совместить все задачи воедино. Окончательную версию кода можно найти в репозитории на github.
Заключение
Мы рассмотрели простейший пример использования cake. Сюда можно было бы добавить интеграцию со slack, мониторинг покрытия кода тестами и еще много всего. Имея богатую систему аддонов, активное сообщество, а также довольно неплохую документацию, cake явлляется весьма неплохой альтернативой CI-системам и MSBuild для сборки С# кода.