PowerShell, NuGet & Visual Studio postbuild event

На работе я много лет работаю над большим-пребольшим проектом на платформе C#/.NET. В проекте используется ряд сторонних библиотек. Но поскольку я — программист-любитель, я не до сих пор использовал для управления ими package-manager, а просто тупо скачивал архивы с нужными библиотеками с оф. сайтов. Но вот, я решил-таки все решение перевести на использование NuGet package-manager, и в процессе столкнулся с рядом проблем, которые решил при помощи скриптов PowerShell, о чем и расскажу в этой заметке.

Почему я решил начать использовать NuGet. Причин две:
1) Чтобы библиотеки можно было легко и быстро обновлять.
2) Чтобы не хранить сами библиотеки в репозитории, что я был вынужден делать до сих пор (поскольку, естественно, хотел, чтобы репозиторий содержал все необходимое для построения и запуска программы).

NuGet скачивает библиотеки в определенную папку (по-умолчанию это — папка packages, которая создается в папке вашего решения). Вопрос: где в конечном итоге должен лежать файл сторонней библиотеки, которую я устанавливаю при помощи NuGet? Если в вашем решении (Visual Studio) всего один проект, то файл библиотеки по-умолчанию копируется в выходную папку вашего проекта (Output Directory), поскольку по-умолчанию в свойствах ссылке на сборку в Visual Studio устанавливается свойство Copy Local = True. Это логично. Но у меня был другой случай. Мое решение состоит из очень большого количества проектов, т. е. сборок (есть основная программа, а куча сборок — это типа плагины для этой программы). Поэтому дистрибутив моей программы имеет немножко более сложную структуру папок, которую я приведу здесь в урезанном виде:

Distributive
└── Release
    ├── AutomationTools.exe
    ├── AutomationTools.exe.config
    ├── Devices
    └── ThirdPartyNETAssemblies
        ├── SuperPuperLibrary.dll
        ├── VeryAwesomeLibary.dll
        └── ...

AutomationTools — так называется сама программа. AutomationTools.exe.config — это ее конфигурационный файл, в котором есть такая строка:

<probing privatePath="ThirdPartyNETAssemblies" />

… которая говорит, где CLR должна искать всякие сборки, на которые ссылаются другие сборки. В папке Devices живут те самые «плагины», о которых речь шла выше. А в папку ThirdPartyNETAssemblies я и помещаю все сторонние библиотеки. Таким образом, мне нужно было копировать все сторонние библиотеки из папки packages в папку ThirdPartyNETAssemblies. Но. Папка packages тоже имеет хитрую структуру, и копировать из нее нужно далеко не все. Структура ее следующая:

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:

powershell -ExecutionPolicy RemoteSigned -file "$(SolutionDir)postbuild.ps1" -source "$(SolutionDir)packages" -destination "$(SolutionDir)Distributive\$(ConfigurationName)\ThirdPartyNETAssemblies" -frameworks "net46,net45,net40,net35"

postbuild.ps1 — это имя файла скрипта для PowerShell. Смысл аргументов source, destination и frameworks очевиден. А вот аргумент -ExecutionPolicy RemoteSigned появился не сразу. Когда я пытался запустить пробный тестовый скрипт, он не запускался, а в окне ошибок Visual Studio появлялось сообщение «выход из команды <тут идет то, что в postbuild event> с кодом 1». Оказывается, что по-умолчанию выполнение скриптов в PowerShell запрещено, и чтобы его разрешить, надо запустить PowerShell с ключом -ExecutionPolicy RemoteSigned.

Кстати, для проверки, что скрипты в приинципе запускаются, удобно использовать в качестве тестового скрипт, который выводит на экран message box:

[void][Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
[void][System.Windows.Forms.MessageBox]::Show("It works!")

Теперь давайте разберем сам скрипт PowerShell по частям. Начнем с интерфейса, т. е. с параметров командной строки. Они задаются в начале скрипта:

param(
    [String]$source,
    [String]$destination,
    [String[]]$frameworks
)

Как видите, параметр $frameworks является массивом. В PowerShell параметр может быть массивом — когда вы вызываете скрипт, вы указываете элементы массива через запятую (можно с пробелом, можно без), например так:

PS ./postbuild.ps1 -source "packages" -destination "Distributive\Release\ThirdPartyNETAssemblies" -frameworks net45, net40, net35

Но фишка в том, что при запуске скрипта непосредственно из PowerShell это работает, а при запуске из postbuild event Visual Studio — ни в какую: скрипту передается не массив строк, а одна строка из аргументов, разделенных запятой. И это не зависит от наличия/отсутствия кавычек или пробелов в строке аргументов. Пришлось смириться и сделать $frameworks обычной строкой, с тем, чтобы потом «вручную» разбивать ее на массив строк:

param(
    [String]$source,
    [String]$destination,
    [String]$frameworks
)

$dotnets = $frameworks.Split(',')

Далее приведу скрипт целиком, в нем можно разобраться без особых пояснений, а потом коснусь только моментов, показавшихся мне «необычными».

#PowerShell script copying dll-files from insides of the $source folder to the $destination folder

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. Вот пример. Есть массив каких-то объектов, и надо вывести их на экран:

$list = 1, 2, 3, "x", "y", "z"

#1
$list | ForEach-Object {
    Write-Host $_
}

#2
foreach ($elem in $list) {
    Write-Host $elem
}

В 1-ом варианте используется концепция pipeline — результат предыдущей команды передается в следующую (конмады разделяет значок |). Там же для обозначения элемента используется фиговина «$_». 2-й вариант мне милее, но 1-й иногда получается короче.

Иногда, если не помещать вычисляемые выражения в скобки, можно сесть в калошу:

$path = Get-Location

# 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#):

:outer_loop For ($i=0; $i -le 3; $i++) {
    For ($j=0; $j -le 5; $j++) {
        Write-Host ($i * $j)
        if ($i * $j -gt 6) {
            break outer_loop
        }
    }
}

Операторы сравнения. Вместо привычных < и > в PowerShell придется использовать -lt и -gt (см. предыдущий пример).

Чтобы получить количество объектов в коллекции вам (в зависимости от версии PowerShell) возможно придется использовать заковыристую конструкцию. Следующий скрипт выводит на экран количество файлов и папок в текущей директории:

$path = Get-Location
$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 и скриптах, которые помогли мне их решить.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *