На работе я много лет работаю над большим-пребольшим проектом на платформе C#/.NET. В проекте используется ряд сторонних библиотек. Но поскольку я — программист-любитель, я не до сих пор использовал для управления ими package-manager, а просто тупо скачивал архивы с нужными библиотеками с оф. сайтов. Но вот, я решил-таки все решение перевести на использование NuGet package-manager, и в процессе столкнулся с рядом проблем, которые решил при помощи скриптов PowerShell, о чем и расскажу в этой заметке.
Почему я решил начать использовать NuGet. Причин две:
1) Чтобы библиотеки можно было легко и быстро обновлять.
2) Чтобы не хранить сами библиотеки в репозитории, что я был вынужден делать до сих пор (поскольку, естественно, хотел, чтобы репозиторий содержал все необходимое для построения и запуска программы).
NuGet скачивает библиотеки в определенную папку (по-умолчанию это — папка packages, которая создается в папке вашего решения). Вопрос: где в конечном итоге должен лежать файл сторонней библиотеки, которую я устанавливаю при помощи NuGet? Если в вашем решении (Visual Studio) всего один проект, то файл библиотеки по-умолчанию копируется в выходную папку вашего проекта (Output Directory), поскольку по-умолчанию в свойствах ссылке на сборку в Visual Studio устанавливается свойство Copy Local = True. Это логично. Но у меня был другой случай. Мое решение состоит из очень большого количества проектов, т. е. сборок (есть основная программа, а куча сборок — это типа плагины для этой программы). Поэтому дистрибутив моей программы имеет немножко более сложную структуру папок, которую я приведу здесь в урезанном виде:
└── Release
├── AutomationTools.exe
├── AutomationTools.exe.config
├── Devices
└── ThirdPartyNETAssemblies
├── SuperPuperLibrary.dll
├── VeryAwesomeLibary.dll
└── ...
AutomationTools — так называется сама программа. AutomationTools.exe.config — это ее конфигурационный файл, в котором есть такая строка:
… которая говорит, где CLR должна искать всякие сборки, на которые ссылаются другие сборки. В папке Devices живут те самые «плагины», о которых речь шла выше. А в папку ThirdPartyNETAssemblies я и помещаю все сторонние библиотеки. Таким образом, мне нужно было копировать все сторонние библиотеки из папки packages в папку ThirdPartyNETAssemblies. Но. Папка packages тоже имеет хитрую структуру, и копировать из нее нужно далеко не все. Структура ее следующая:
├── SuperPuperLibrary.1.2.3
| └── lib
| ├── net35
| | └── SuperPuperLibrary.dll
| ├── net40
| | └── SuperPuperLibrary.dll
| └── net45
| └── SuperPuperLibrary.dll
├── VeryAwesomeLibary.4.5.6
| └── lib
| └── VeryAwesomeLibary.dll
└── ...
Оказалось, что есть два вида package’й: одни содержат много сборок, каждая из которых предназначена для определенной версии .NET Framework; другие содержат только одну сборку. В последнем случае все просто — копируем эту сборку в папку ThirdPartyNETAssemblies и все. А вот когда сборок много под разные версии фреймворка, надо как-то выбрать, какую именно сборку копировать. К счастью у меня в решении все проекты target’ят одну и ту же версию .NET Framework, поэтому логично копировать сторонии библиотеки, который target’ят ту же самую версию фреймворка. Но проблема в том, что может получиться так: все мои проекты target’ят .NET Framework 4.6, а версии сторонней библиотеки, которая бы target’ила .NET Framework 4.6 просто нету, а есть только, скажем, 4.0. Тогда я решил, что буду задавать приоритеты: если нет версии для net46, то берем net45, нет net45 — берем net40, нет net40 — берем net35 и т. д.
Итак, я решил написать скрипт, который будет все вышеописанное делать. На чем писать? Можно написать скрипт для bash, но. Я работаю в Windows, поэтому чтобы запустить этот скрипт, надо ставить Cygwin — лишнее телодвижение. Поэтому я взял оболочку, которая присутствует в любой Windows по-умолчанию — PowerShell (есть еще такой приятный момент, что можно отлаживать скрипты в среде PowerShell ISE). Еще интересный момент в том, что PowerShell — «объектно-ориентированная» штука, т. е. там есть понятие «объект», есть понятие «тип объекта», у объекта есть «свойства» и т. д., все как в ООП.
Интерфейс скрипта должен был состоять из трех аргументов командной строки: откуда копировать библиотеки (папка packages), куда копировать (папка ThirdPartyNETAssemblies) и приоритеты версий .net-фреймворков, о которых говорилось выше. Скрипт должен был запускаться автоматически при построении решения, поэтому я поместил его запуск в postbuild event главного проекта (AutomationTools.exe). При этом в команде, которая запускала скрипт можно (и нужно) использовать макросы, например $(SolutionDir) и $(ConfigurationName). Вот как выглядит команда запуска скрипта в postbuild event:
postbuild.ps1 — это имя файла скрипта для PowerShell. Смысл аргументов source, destination и frameworks очевиден. А вот аргумент -ExecutionPolicy RemoteSigned появился не сразу. Когда я пытался запустить пробный тестовый скрипт, он не запускался, а в окне ошибок Visual Studio появлялось сообщение «выход из команды <тут идет то, что в postbuild event> с кодом 1». Оказывается, что по-умолчанию выполнение скриптов в PowerShell запрещено, и чтобы его разрешить, надо запустить PowerShell с ключом -ExecutionPolicy RemoteSigned.
Кстати, для проверки, что скрипты в приинципе запускаются, удобно использовать в качестве тестового скрипт, который выводит на экран message box:
[void][System.Windows.Forms.MessageBox]::Show("It works!")
Теперь давайте разберем сам скрипт PowerShell по частям. Начнем с интерфейса, т. е. с параметров командной строки. Они задаются в начале скрипта:
[String]$source,
[String]$destination,
[String[]]$frameworks
)
Как видите, параметр $frameworks является массивом. В PowerShell параметр может быть массивом — когда вы вызываете скрипт, вы указываете элементы массива через запятую (можно с пробелом, можно без), например так:
Но фишка в том, что при запуске скрипта непосредственно из PowerShell это работает, а при запуске из postbuild event Visual Studio — ни в какую: скрипту передается не массив строк, а одна строка из аргументов, разделенных запятой. И это не зависит от наличия/отсутствия кавычек или пробелов в строке аргументов. Пришлось смириться и сделать $frameworks обычной строкой, с тем, чтобы потом «вручную» разбивать ее на массив строк:
[String]$source,
[String]$destination,
[String]$frameworks
)
$dotnets = $frameworks.Split(',')
Далее приведу скрипт целиком, в нем можно разобраться без особых пояснений, а потом коснусь только моментов, показавшихся мне «необычными».
param(
[Parameter(Mandatory = $true)]
[String]$source,
[Parameter(Mandatory = $true)]
[String]$destination,
[Parameter(Mandatory = $true)]
[String]$frameworks
)
$dotnets = $frameworks.Split(',')
Write-Host ("Copying assemblies from '$source' to '$destination'")
Write-Host ("target framework preferences = $dotnets")
Write-Host "--------------------------------------------"
#iterate over the subfolders of the $source folder
Get-ChildItem $source |
Foreach-Object {
#we are now within a certain package
$lib = ($_.FullName) + "\lib"
#copy dll-files which are right in the lib folder
Get-ChildItem $lib -Filter *.dll |
Foreach-Object {
Write-Host $_.FullName
Copy-Item $_.FullName -Destination $destination
}
#copy dll-files which are in folders that correspond to specific .net framework versions
:loop ForEach ($net in $dotnets) {
$netfolders = Get-ChildItem $lib -Filter $net
if(($netfolders | Measure-Object | %{$_.Count}) -gt 0) {
Get-ChildItem $netfolders.FullName -Filter *.dll |
Foreach-Object {
Write-Host $_.FullName
Copy-Item $_.FullName -Destination $destination
}
Break loop
}
}
}
Теперь «необычные» моменты.
Есть два варианта организации цикла foreach. Вот пример. Есть массив каких-то объектов, и надо вывести их на экран:
#1
$list | ForEach-Object {
Write-Host $_
}
#2
foreach ($elem in $list) {
Write-Host $elem
}
В 1-ом варианте используется концепция pipeline — результат предыдущей команды передается в следующую (конмады разделяет значок |). Там же для обозначения элемента используется фиговина «$_». 2-й вариант мне милее, но 1-й иногда получается короче.
Иногда, если не помещать вычисляемые выражения в скобки, можно сесть в калошу:
# bad: prints "C:\Users\Дмитрий\Documents + \lib"
Write-Host $path.Path + "\lib"
# good: prints "C:\Users\Дмитрий\Documents\lib"
Write-Host ($path.Path + "\lib")
Если вы находитесь внутри нескольких вложенных циклов и хотите break’нуть один из них, который при этом не самый вложенный, то надо добавить к циклу метку и использовать ее имя в операторе break (все как в обычных языках программирования типа C#):
For ($j=0; $j -le 5; $j++) {
Write-Host ($i * $j)
if ($i * $j -gt 6) {
break outer_loop
}
}
}
Операторы сравнения. Вместо привычных < и > в PowerShell придется использовать -lt и -gt (см. предыдущий пример).
Чтобы получить количество объектов в коллекции вам (в зависимости от версии PowerShell) возможно придется использовать заковыристую конструкцию. Следующий скрипт выводит на экран количество файлов и папок в текущей директории:
$subfolders = Get-ChildItem $path
# works in all versions of powershell
Write-Host ($subfolders | Measure-Object | %{$_.Count})
# works not in all versions of powershell
Write-Host $subfolders.Count
Вот и все. В следующей заметке расскажу об еще нескольких проблемах с переходом на NuGet и скриптах, которые помогли мне их решить.