Коль захотел ты сборки передать
И с ними пламенный привет
Нугетом не забудь запаковать
В пакет!
Сразу оговоримся, что в этой статье речь пойдёт о стеке технологий Microsoft .NET.
Часто так бывает, что какое-то подмножество проектов начинает использоваться в разных решениях.
Как правило, программисты, разглядев в соседнем проекте что-то полезное, первое время не заморачиваются — создают папку lib (dll, assemblies и т.п.) и складывают туда скомпилированные сборки из оригинального решения. Со временем становится понятно, что это не самый удобный вариант и вот почему:
- оригинальное решение начинает развиваться в свою собственную сторону, без учёта «потребителей»: добавляются новые зависимости, обновляются версии .net и т.п. «приколы»;
- если даже о «потребителях» задумываются, то забывают обновить сборки у них, когда выходит критическое обновление или просто новая версия, а потом всё становится ещё хуже, когда сборок становится больше одной и между ними возникают некоторые зависимости — обновляя одну сборку, получаем проблемы в момент исполнения, т.к. другая сборка может оказаться не той версии;
- оригинальное решение перестаёт дальше разрабатываться.
Ответом на все эти неприятности может служить вынесение проектов в отдельное решение и создание NuGet-пакета, включающего общие сборки, и смена парадигмы развития этих сборок. По большому счёту, всё это можно сделать и без NuGet, но удовольствия в этом гораздо меньше.Как сделать так, чтобы NuGet-пакет собирался сам автоматически вместе с компиляцией проекта на сервере построения и включал все необходимые свистелки и гуделки — об этом и будет наш рассказ.
Изготовление NuGet-пакетов
Процесс изготовления NuGet-пакетов довольно прост. Вся общая теоретическая часть доступна и, в целом, понятна. В пакеты можно упаковывать различный контент, не только скомпилированные сборки, но и отладочные символы, картинки и т.п. ресурсы, и даже исходный код.
В данном описании мы ограничимся наиболее насущным вопросом упаковки скомпилированных сборок.
Подготовка первого NuGet-пакета
Для того, чтобы наладить автоматизированное создание NuGet-пакетов на сервере построения, надо «состряпать» первую версию пакета. Самый простой и понятный способ создания пакета – это использование NuSpec-файла, который описывает, что это будет за пакет. Получить данный NuSpec-файл можно разными способами:
- Взять чужой пример и исправить.
- Сгенерировать утилитой NuGet.exe (команда «NuGet.exe spec»).
- Создать новый пакет или открыть существующий чужой пакет GUI-утилитой NuGet Package Explorer, исправить и сохранить командой «Save Metadata As…».
В принципе, можно полностью всё создание NuSpec-файла выполнить в GUI, но понимать то, как устроен NuSpec, всё же будет полезно.
Для примера, один из наших NuSpec-файлов с сокращениями выглядит как-то так:
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
<metadata>
<id>NewPlatform.Flexberry.ORM</id>
<version>2.1.0-alpha1</version>
<title>Flexberry ORM</title>
<authors>New Platform Ltd</authors>
<!-- ... -->
<description>Flexberry ORM package.</description>
<releaseNotes>
...
</releaseNotes>
<copyright>Copyright New Platform Ltd 2015</copyright>
<tags>Flexberry ORM</tags>
<dependencies>
<dependency id="NewPlatform.Flexberry.LogService" version="1.0.2" />
<!-- ... -->
<dependency id="SharpZipLib" version="0.86.0" />
</dependencies>
</metadata>
<files>
<!-- ... -->
<file src="Debug-Net45ICSSoft.STORMNET.DataObject.dll" target="libnet45ICSSoft.STORMNET.DataObject.dll" />
<file src="Debug-Net45ICSSoft.STORMNET.DataObject.xml" target="libnet45ICSSoft.STORMNET.DataObject.xml" />
<!-- ... -->
</files>
</package>
Вот небольшие пояснения, касающиеся некоторых секций:
- Id должен быть уникальным в рамках общего пространства имён всех пакетов, чтобы не допускать коллизий. Кто-то указывает в названии пакета название компании, потом название проекта и конкретного продукта, а кто-то не заморачивается.
- По поводу версий: хорошей практикой считается использование принципов семантического версионирования. Небольшое правило, которое мы выработали у себя в команде – все пререлизные версии (у которых кроме 3-х чисел есть ещё что-то в конце, например, alpha1) мы публикуем со сборками, собранными в Debug-конфигурации, а релизы, соответственно, в Release.
- Заметки к релизу (releaseNotes) – очень полезная вещь, обязательно пишите там, что поменялось с прошлой версии. Пользователи должны понимать, что они получают с каждым обновлением.
- Зависимости (dependencies). При описании зависимостей надо думать о том, как ваш пакет будет устанавливаться: если пользователю достаточно будет только вашего пакета и ничего больше, значит, никаких зависимостей нет. Если же ваши сборки будут работать только при наличии другого пакета, например, SharpZipLib, то обязательно надо прописать эту зависимость. Важно понимать, что SharpZipLib, в свою очередь, может иметь свои зависимости, и они тоже «прилетят» пользователю при установке, даже если вы их не указываете у себя.
Установка происходит рекурсивно, так что пользователь в одной из гипотетических ситуаций может начать устанавливать один пакет, а ему установится больше сотни – как раз через зависимости. Во время установки пакетов выбор версии зависимого пакета устроена весьма хитро. Если номер версии не указать, то будет устанавливаться последняя релизная версия, иначе та, которая явно указана в зависимости. Кстати, если вы используете несколько не связанных между собой пакетов из раза в раз, то вы можете создать пустой пакет с зависимостями от нужных вам пакетов и устанавливать этот свой пакет – остальные установятся вслед за ним сами. - Описание файлов может включать указание конкретных имён или масок. Крайне рекомендуем соблюдать правильную структуру пакетов, когда в target пишется тип контента, версия .net framework и другие вещи, в соответствии с соглашением. Важно понимать, что в атрибуте src при указании пути до файла надо отталкиваться от текущего каталога, в контексте которого будет выполняться команда упаковки пакета.
После того, как NuSpec-файл готов, можно приступить к пробному созданию пакета. Для этого выполняется простая команда утилиты NuGet.exe: nuget pack MyAssembly.nuspec.
Таким образом мы должны получить заветный «первый пакет», или «опытный образец пакета», то есть nupkg-файл, который можно использовать для установки в проекты через NuGet Package Manager или через NuGet.exe.
Выставка готовых пакетов
Итак, у нас есть пакет, который надо как-то доставлять пользователям через какой-нибудь «канал сбыта пакетов». Считаем, что большинство пользователей будут устанавливать пакеты через Visual Studio. Встроенный в неё NuGet Package Manager понимает два варианта размещения пакетов:
- Галерея пакетов, доступная через сеть;
- Папка Windows (локальная либо сетевая).
В настройках можно добавлять собственные источники пакетов, они будут перебираться по очереди при установке или восстановлении пакетов, пока нужный id не будет найден. Вариант, когда один и тот же одинаковый(!) пакет лежит в нескольких источниках – вполне приемлем.
Самый простой вариант для распространения пакетов – создать сетевую папку и складывать пакеты туда.
Стоит отметить, что NuGet позволяет работать не только с общей галереей пакетов https://nuget.org, но и создавать собственные галереи, для этого можно развернуть где-то у себя тот же движок, что используется на https://nuget.org. Наша команда предпочитает этот вариант, поскольку в этом случае появляется возможность отслеживания статистики загрузок, управление полномочиями через сайт, в конце концов, это просто красиво.
Установка галереи может потребовать небольших танцев с бубном, как минимум, в вопросе авторизации, но ничего сложного в этом нет. Публикация пакетов происходит точно так же, как и на NuGet.org, важно при обновлении сайта галереи не потерять архив с уже загруженными пакетами – они хранятся в каталоге узла. Настройка NuGet Package Manager для пользователей в этом случае будет выглядеть как-то так:
Если локальный источник пакетов находится где-то рядом с пользователями, например, в одной локальной сети, то рекомендуется закачать в него все пакеты с зависимостями – это сократит время скачивания пакетов для новых пользователей. Найти nupkg-файлы от зависимых пакетов очень легко – они всегда есть в папке packages, в которую устанавливаются эти самые пакеты (обычно в каталоге с sln-файлом). Также в окне настроек источников пакетов важен порядок – студия будет перебирать источники в случае восстановления пакетов в том порядке, который указан в настройках. Следовательно, если ваш пакет доступен только локально, то первым поставьте свой источник, чтобы не было лишних запросов на nuget.org.
Фабрика по производству NuGet-пакетов
После того, как «опытный образец пакета» сделан и «канал сбыта пакетов» налажен, можно приступать к автоматизации сборки пакетов, чтобы по первому же щелчку мышки мы могли получить горячий и самый свежий NuGet-пакет.
Рассмотрим, как это делается в случае с Team Foundation Server 2013/2015. Для других подобных CI-систем процесс будет похожим.
В свойствах Build Definition (XAML) можно указать PowerShell-скрипт, который выполнится в случае успешного выполнения построения. Именно в этом скрипте и будем вызывать наш «упаковщик», передавая в качестве параметра путь до NuSpec-файла.
Есть несколько моментов, которые следует прояснить для себя: где будет лежать сам NuGet.exe и все необходимые ему файлы (как минимум, конфигурационный файл), где будет находиться NuSpec-файл? С одной стороны, можно положиться на то, что на сервере построения будет в определённом месте расположен NuGet.exe, но если серверов построения несколько и их администрированием заниматься нет желания, то проще всего положить NuGet.exe в Source Control и добавить каталог с его расположением в Workspace, с которым будет выполняться построение. Что касается NuSpec, то его удобно держать рядом с sln-файлом и даже включить в Solution Items для быстрого доступа к нему через Solution Explorer.
Если имеется несколько солюшенов и планируется создавать несколько пакетов, то рекомендуется реализовать один общий PowerShell-скрипт, который будет в качестве параметра получать путь до NuSpec-файла.
Ниже представлены выдержки из такого скрипта:
# Create NuGet Package after successfully server build.
# Enable -Verbose option for this script call.
[CmdletBinding()]
Param(
# Disable parameter.
# Convenience option so you can debug this script or disable it in
# your build definition without having to remove it from
# the 'Post-build script path' build process parameter.
[switch] $Disable,
# This script used NuGet.exe from current directory by default.
# You can change this path to meet your needs.
[String] $NuGetExecutablePath = (Get-Item -Path "." -Verbose).FullName + "NuGet.exe",
$BinariesDirectoryPostfixes = @("Debug", "Release"),
# Path to the nuspec file. Path relative TFS project root directory.
[Parameter(Mandatory=$True)]
[String] $NuspecFilePath,
# Disable Doxygen.
[switch] $NoDoxygen
# ...
# Go, go, go!
$nugetOutputLines = & $NuGetExecutablePath pack $realNuspecFilePath -BasePath $basePath
-OutputDirectory $outputDirectory -NonInteractive;
ForEach ($outputLine in $nugetOutputLines) {
Write-Verbose $outputLine;
}
# ...
В скрипте выполняются операции по преобразованию относительных путей в абсолютные (можно без труда найти описание доступных переменных, которые означаются CI-системой при запуске скрипта). В некоторых случаях требуется модификация NuSpec-файла в этом скрипте. Например, таким образом можно обработать создание пакетов для различных конфигураций (Any CPU, x86).
На этом, собственно, настройка автоматического механизма создания NuGet-пакетов заканчивается. Запускаем сборку на сервере построения, проверяем, что всё сработало. Для получения отладочной информации, если что-то пошло не так, не забываем писать –Verbose в параметрах скрипта в настройках определения построения. Готовые пакеты заливаем в общий ресурс или галерею и приглашаем первых пользователей.
Тонкости процесса
Как говорится, «главная задача программиста – убить в себе перфекциониста». Если внутренний перфекционист ещё не сдался, то ему должны пригодиться следующие пункты.
Кроме возможностей по созданию NuGet-пакетов, скрипт для сервера построения для каждого из пакетов может запускать утилиту генерации автодокументации на основе XML-комментариев в коде. Данная возможность удобна в том плане, что для каждой версии пакета у нас появляется своя версия автодокументации, это удобно, если пользователи применяют разные версии NuGet-пакетов. Для генерации автодокументации у нас применяется Doxygen. Вот раздел скрипта, посвящённый автодокументации:
if($NoDoxygen)
{
Write-Verbose "Doxygen option is disabled. Skip generation of the project documentation.";
}
else
{
Write-Verbose "Doxygen option is enabled. Start documentation generation.";
# Copy doxygen config file.
$doxyConfigSourcePath = Join-Path -Path $toolsFolderPath -ChildPath "DoxyConfig" -Resolve;
$doxyConfigDestinationPath = Join-Path -Path $Env:TF_BUILD_BINARIESDIRECTORY -ChildPath "DoxyConfig";
# Modify doxigen config file according with given nuspec.
$nuspecXml = [xml](Get-Content $NuspecFilePath);
$doxyConfig = Get-Content -Path $doxyConfigSourcePath;
$projectName = $nuspecXml.GetElementsByTagName("title").Item(0).InnerText + " " +
$nuspecXml.GetElementsByTagName("version").Item(0).InnerText;
$doxyConfig = $doxyConfig -replace "FlexberryProjectName", $projectName;
$projectLogoPath = Join-Path -Path $toolsFolderPath -ChildPath "logo.png" -Resolve;
$doxyConfig = $doxyConfig -replace "FlexberryProjectLogo", $projectLogoPath -replace "\", "/";
$doxyConfig = $doxyConfig -replace "FlexberryOutputDirectory", $Env:TF_BUILD_BINARIESDIRECTORY -replace "\", "/";
$doxyConfig = $doxyConfig -replace "FlexberryInputDirectory", $Env:TF_BUILD_SOURCESDIRECTORY -replace "\", "/";
$doxyWarnLogFilePath = Join-Path -Path $Env:TF_BUILD_BINARIESDIRECTORY -ChildPath "doxygen_log.txt";
$doxyConfig = $doxyConfig -replace "FlexberryWarnLogFile", $doxyWarnLogFilePath -replace "\", "/";
$doxyConfig | Out-File $doxyConfigDestinationPath default;
# Run doxygen.
$doxygenExecutablePath = Join-Path -Path $toolsFolderPath -ChildPath "doxygen.exe" -Resolve;
$doxygenOutputLines = & $doxygenExecutablePath $doxyConfigDestinationPath
ForEach ($outputLine in $doxygenOutputLines) {
Write-Verbose $outputLine;
}
Write-Verbose "Documentation generation done. Packing to the archive.";
# Do archive.
$archiveSourceFolder = Join-Path -Path $Env:TF_BUILD_BINARIESDIRECTORY -ChildPath "html" -Resolve;
$archiveFileName = $nuspecXml.GetElementsByTagName("id").Item(0).InnerText + "." +
$nuspecXml.GetElementsByTagName("version").Item(0).InnerText;
$archiveDestinationFolder = Join-Path -Path $Env:TF_BUILD_BINARIESDIRECTORY -ChildPath ($archiveFileName + ".zip");
Add-Type -assembly "system.io.compression.filesystem";
[io.compression.zipfile]::CreateFromDirectory($archiveSourceFolder, $archiveDestinationFolder);
# Remove html documentation files.
Remove-Item $archiveSourceFolder -recurse;
Write-Verbose "Done.";
}
Второй пункт будет касаться сборки проекта в случае, если в один пакет упаковываются разные версии сборок под разные версии .net framework.
Хитрости начинаются с того, чтобы заставить сервер построений собирать сборки под разные версии .net framework. Рассмотрим, проекты, которые будут собираться, в формате csproj, а не новым json-форматом файла проекта (ASP.NET5). В Visual Studio поддерживается механизм конфигурации сборок. Обычно применяется 2 конфигурации – Debug и Release, но этот же механизм позволяет настроить переключение версий .net.
Можно создавать свои конфигурации, что мы и делаем. К сожалению, чтобы выполнить «тонкую» настройку всех необходимых параметров, придётся открыть csproj-файл и, как минимум, прописать там TargetFrameworkVersion в каждой из секций конфигурации.
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug-Net35|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>binDebug-Net35</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
<PlatformTarget>AnyCPU</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<CodeAnalysisRuleSet>AllRules.ruleset</CodeAnalysisRuleSet>
<DocumentationFile>binDebug-Net35LogService.XML</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release-Net35|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>binRelease-Net35</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
<PlatformTarget>AnyCPU</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<DocumentationFile>binRelease-Net35LogService.XML</DocumentationFile>
<CodeAnalysisRuleSet>AllRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug-Net40|AnyCPU'">
<DebugSymbols>true</DebugSymbols>
<OutputPath>binDebug-Net40</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DocumentationFile>binDebug-Net40LogService.XML</DocumentationFile>
<DebugType>full</DebugType>
<TargetFrameworkVersion>v4.0</TargetFrameworkVersion>
<PlatformTarget>AnyCPU</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>AllRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug-Net45|AnyCPU'">
<DebugSymbols>true</DebugSymbols>
<OutputPath>binDebug-Net45</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DocumentationFile>binDebug-Net45LogService.XML</DocumentationFile>
<DebugType>full</DebugType>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<PlatformTarget>AnyCPU</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>AllRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release-Net40|AnyCPU'">
<OutputPath>binRelease-Net40</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<DocumentationFile>binRelease-Net40LogService.XML</DocumentationFile>
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<TargetFrameworkVersion>v4.0</TargetFrameworkVersion>
<PlatformTarget>AnyCPU</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>AllRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release-Net45|AnyCPU'">
<OutputPath>binRelease-Net45</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<DocumentationFile>binRelease-Net45LogService.XML</DocumentationFile>
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<PlatformTarget>AnyCPU</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>AllRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
Конфигурации в Visual Studio переключаются в основном тулбаре, в определении сборки на сервере можно выбрать одновременно несколько конфигураций, которые будут компилироваться последовательно.
Стоит отметить, если у вас код под разные версии .net framework начинает различаться, то это можно обрабатывать при помощи директив:
#if NETFX_35
for (int i = 0; i < resValueLength; i++)
#else
System.Threading.Tasks.Parallel.For(0, resValueLength, i =>
#endif
При этом константы должны быть определены в соответствующей секции csproj-файла:
<DefineConstants>DEBUG;TRACE;NETFX_35</DefineConstants>
Когда у нас есть готовые скомпилированные сборки, давайте разберёмся, как правильно настроить nuspec. В nuspec задаются специальные каталоги под конкретные версии .net framework.
Пример секции files в NuSpec-файле:
<files>
<file src="Debug-Net35LogService.dll" target="libnet35LogService.dll" />
<file src="Debug-Net35LogService.XML" target="libnet35LogService.XML" />
<file src="Debug-Net40LogService.dll" target="libnet40LogService.dll" />
<file src="Debug-Net40LogService.XML" target="libnet40LogService.XML" />
<file src="Debug-Net45LogService.dll" target="libnet45LogService.dll" />
<file src="Debug-Net45LogService.XML" target="libnet45LogService.XML" />
</files>
Ещё одна проблема, с которой можно часто столкнуться при использовании (даже не при создании) NuGet-пакетов — проблема подключения одного проекта в несколько солюшенов. Дело в том, что в csproj-файле ссылки на сборки проставляются вплоть до конкретных dll, которые по умолчанию восстанавливаются Visual Studio в папку packages рядом с sln-файлом. Отсюда возникает проблема, когда один и тот же проект включён в несколько солюшенов, располагающихся в разных папках. Для решения этой проблемы можно воспользоваться NuGet-пакетом, который включает в себя специальный Target, который переписывает ссылки перед билдом: https://www.nuget.org/packages/NuGetReferenceHintPathRewrite.
Ещё одной особенностью использования NuGet-пакетов является тема восстановление пакетов при сборке. Дело в том, что до некоторых пор Visual Studio не имела встроенных средств восстановления пакетов, поэтому в csproj дописывался специальный Target, который отвечал за восстановление. В современных Visual Studio (2013+) это уже не актуально, следите за чистотой своих csproj-файлов, никаких Target-ов для восстановления NuGet-пакетов больше не требуется.
Ну и напоследок можно рассказать о том, что при использовании TFS папка packages по умолчанию лезет в Source Control и кто-нибудь периодически может проморгать и всё-таки зачекинить все сборки в TFS. Чтобы такого не случилось (мы уверены, что для тех, кто чекинит сборки в TFS в аду должен быть отдельный котёл), можно использовать файл .tfignore, который должен спасти от этой напасти.
Результат
Итак, выполнив всё, что описано в предложенной нами инструкции, вы можете получить готовый механизм упаковки пакетов, который работает без участия человека. Наши пакеты собираются именно так. Разве что, сама публикация требует некоторого внимания.
Полезные ссылки:
- Что такое NuGet;
- Спецификация nuspec-файла;
- Утилита NuGet.exe;
- NuGet Package Explorer;
- Top 10 NuGet (Anti-) Patterns;
- Дистрибутив движка галереи пакетов;
- Генератор автодокументации Doxygen;
- Решение проблемы с проектом в нескольких солюшенах;
- Решение проблемы с папкой packages, которая попадает в Pending changes.
Автор: seregamatin