Seriál Windows PowerShell: psake (část 16.)

Dnes se podíváme na modul, který se nechal inspirovat z jiných jazyků a používá se nejčastěji na automatizaci buildu (nebudu zde kostrbatě překládát anglické dobře známé pojmy), ale stejně tak dobře najde uplatnění v situacích, kdy potřebujeme provést sekvenci na sobě závislých kroků. Modul si můžete stáhnout z https://github.com/JamesKovacs/psake a již je určitě jasné, že se jmenuje psake.

Proč psake a ne msbuild?

Odpověď je jednoduchá. Pokud rádi editujete xml, pak vám nevadí práce s msbuildem. Pro ostatní psake (respektive PowerShell) nabízí komfort skriptovacího jazyka, kterého jste se v xml museli vzdát. Msbuild má samozřejmě také své přednosti, takže nejlepší volbou je kombinovat psake a msbuild dohromady – jak už to obyčejně na světě bývá.

Krom toho je psake možné použít i pro administrátorské účely. Vše, co nabízí PowerShell, je přístupné v psake. Psake je totiž napsané v PowerShellu. Příklady snad řeknou více.

Jak psake rozchodit

Na domovské stránce https://github.com/JamesKovacs/psake stačí po kliknutí na tlačítko Download zvolit poslední release číslo 4.00. Tento zip soubor pak rozbalte na disk (dále předpokládám adresář c:\psake). Spusťte PowerShell konzoli a pokračujte příkazy:

 PS> sl c:\psake
import-module .\psake.psm1
# psake modul je naimportovaný
 
Get-Help Invoke-psake -Full

Psake je dobře zdokumentovaný modul, takže poslední příkaz vypíše přehled parametrů použitelných při volání Invoke-Psake. Navíc ale obsahuje několik příkladů, na kterých je použití dobře patrné.

Jednoduchý příklad

Pro rychlé uvedení do problematiky si tu ukážeme jednoduchý příklad, jehož cílem je:

  1. Adresář d:\temp\code překopíruje do d:\temp\codebak (pro jednoduchost ne rekurzivně).
  2. Vylistuje seznam souborů z d:\temp\codebak uloží jej do d:\temp\codebak\files.txt.
  3. Spustí příkaz na commit do VCS.

Z popisu jednotlivých kroků je jasné, že po sobě následují. Za jistých okolností se ale může hodit spustit kterýkoliv z příkazů samostatně. V psake skriptu by pak kroky byly popsány pomocí tasks:

 task default -depends Full
task Full -depends Backup, ListFiles, Commit
task Backup {
  Write-Host Backup
  gci d:\temp\code | ? { !$_.PsIscontainer } | copy-Item -destination d:\temp\codebak 
}
task ListFiles {
  Write-Host Files list
  gci d:\temp\codebak | Select -exp FullName | sc d:\temp\codebak\files.txt
}
task Commit {
  Write-Host Commit
  start-Process GitExtensions.exe -ArgumentList commit, d:\temp\codebak
}

Tento skript pak uložíme jako psake-build.ps1 a spustíme. Pokud neuvedeme jméno tasku, psake použije task se jménem default:

 PS> Invoke-psake -buildFile d:\temp\psake\psake-build.ps1 
Executing task: Backup
Backup
Executing task: ListFiles
Files list
Executing task: Commit
Commit
 
Build Succeeded!
 
----------------------------------------------------------------------
Build Time Report
----------------------------------------------------------------------
Name      Duration
----      --------
Backup    00:00:00.1396781
ListFiles 00:00:00.0548712
Commit    00:00:00.3255901
Full      00:00:00.5288634
Total:    00:00:00.6099274

A po skončení "buildu" se otevře okno GitExtensions se změnami do VCS. Slovo build se prolíná celým psake, ale znovu podotýkám, že nejde pouze o build. V příkladu jsme nic nepřekládali a nevyvíjeli.

V případě, že bychom potřebovali zavolat pouze některé tasky, specifikujeme je pod parametrem -taskList:

 PS> Invoke-psake -buildFile d:\temp\psake\psake-build.ps1 -task Backup, Commit

Pokud dojde v některém kroku k chybě, následující kroky již nejsou vykonány. Jednoduše toho docílíme například kopírováním z neexistujícího adresáře: $codeDir = 'd:\temp\doesntexist':

 PS> Invoke-psake d:\temp\psake\psake-build.ps1
Executing task: Backup
Backup
psake-build.ps1:Cannot find path 'D:\temp\doesntexist' because it does not exist.

V případě, že bychom chtěli pokračovat navzdory chybám zaznamenaným při běhu tasku, můžeme toto u tasku určit parametrem -ContinueOnError:

 PS> Invoke-psake d:\temp\psake\psake-build.ps1
Executing task: Backup
Backup
----------------------------------------------------------------------
Error in Task [Backup] Cannot find path 'D:\temp\doesntexist' because it does not exist.
----------------------------------------------------------------------
Executing task: ListFiles
....

Parametrizace

Psake skript je samozřejmě pořád skript napsaný v PowerShellu. Proto můžeme jména adresářů uložit do proměnné, aby byl skript přehlednější. Když se ale podíváme do některých psake skriptů, uvidíme kontstrukci properties { $var1 = 'value'; ... }, co tedy použít?

Pokud jde o konstanty a nebudeme je chtít měnit, můžeme použít kteroukoliv z možností. Pokud bychom ale chtěli ve skriptu definovat default hodnotu nějaké proměnné, použijeme konstrukci properties {... }. Tak později můžeme default hodnotu přetížit pomocí parametru -properties. Soubor psake-build.ps1 by pak vypadal takto:

 #fixní proměnná, nedá se měnit jinak než zápisem zde ve skriptu
$codeDir = 'd:\temp\code'
properties {
  #měnitelná proměnná
  $backupDir = 'd:\temp\codebak'
}
 
task default -depends Full
task Full -depends Backup, ListFiles, Commit
task Backup {
  Write-Host Backup
  gci $codeDir | ? { !$_.PsIscontainer } | copy-Item -destination $backupDir 
}
...

A při volání bychom použili parametr -properties:

 PS> Invoke-psake -buildFile d:\temp\psake\psake-build.ps1 -properties @{ backupdir = 'd:\temp\otherbackdir' }

Všimněte si, že jako hodnotu předáváme hashtable, ne scripblock. Každá položka v hashtable specifikuje proměnnou, která bude vyhodnocena ve stejném scope jako properties {... } v psake skriptu (ale později).

Poznámka: výše uvedené není tak úplně pravda. I v případě, že do build skriptu napíšete $backupdir = 'nejaka default cesta' mimo blok properties { ... }, i této proměnné lze nastavit jiná hodnota z command line Invoke-Psake ... -properties @{backupdir= 'jina cesta'}. Spíše bych ji ale nedoporučil; v pozdějších verzích se může jiným způsobem pracovat se scope proměnných a skript by pak mohl přestat správně fungovat.

K čemu jsou Parameters

Psake ještě umožňuje specifikovat parametr funkce Invoke-Psake jménem -parameters. Jde opět o hashtable se stejnou strukturou jako -properties, tj. z každé dvojice key-value bude vytvořena proměnná. Tyto proměnné pak mohou být používány ve funkci properties { ... } v build skriptu – znamená to tedy, že tímto parametrizujeme skript. Z příkladu bude jasný rozdíl.

Předpokládejme, že build skript vypadá takto:

 properties { $s = get-service $services }
task default
task stop { $s | stop-service -whatif }
task start { $s | start-service -whatif }

A jako vstupní parametr skriptu bychom poslali jméno/jména servis, které bychom chtěli nastartovat/zastavit:

 PS> Invoke-psake -buildFile d:\temp\psake\psake-services.ps1 -task start -parameters @{ $services = 'W3SVC' }

V bloku properties jsme do proměnné uložili seznam servis odpovídajících vstupnímu parametru $services. Zde jsme mohli dodat jakoukoliv (složitější) inicializační logiku.
Někoho jistě napadne, jestli bychom mohli stejného efektu dosáhnout tím, že si nadefinujeme inicializační task a ten budeme volat před ostatními tasky, které na inicializaci závisí. Kód upraveného skriptu:

 task default
properties { $services = "noservice" }
task init { $s = get-service $services }
task stop -depends init { Write-Host stop service $s; $s | stop-service -whatif }
task start -depends init { Write-Host start service $s; $s | start-service -whatif }

A skript bychom volali bez použití -parameters:

 PS> Invoke-psake -buildFile d:\temp\psake\psake-services2.ps1 -task start -properties @{ $services = 'W3SVC' }
Executing task: init
Executing task: start
start service
psake-services2.ps1:Cannot bind argument to parameter 'Name' because it is null.

Z uvedeného je patrné, že myšlenka byla dobrá, ale psake na toto nebylo uzpůsobeno. Každý task (respektive jeho scriptblock) běží ve svém vlastním scope a proto proměnné přežívají pouze v rámci daného tasku. Mohli bychom sice takové chování obejít pomocí modifikátoru script:, ale taková úprava mění vnitřní stav modulu a proto zcela jistě není vhodná.

Psake pro vývojáře

Doposud jsme se bavili o psake pouze obecně a řekli jsme si většinu věcí, které může člověk potřebovat v případě, že si chce práci zautomatizovat a svázat úlohy pravidly.
Programátor ocení funkci exec, která ukončí psake skript v případě, že program uvnitř skončí s chybou. Chyba je indikována návratovým kódem. Tělo je velmi jednoduché, můžeme se na něj podívat pomocí příkazu Get-Content function:\exec.

Task pro kompilaci solution vypadá s použitím exec velmi jednoduše:

 $framework = '4.0' 
...
task Build { 
  exec { msbuild $slnPath '/t:Build' }
}

Msbuildu můžeme samozřejmě podstrčit další parametry, ale je vidět, že psake nám velmi usnadňuje práci s jeho zavoláním. Na začátku skriptu se říká, že se má použít msbuild pro verzi .NET 4.0. Psake si samo najde příslušný adresář a zajistí, že se spustí ten náš správný msbuild.

Jednoduchá programátorská automatizace buildu by pak mohla zahrnovat clean, build, spuštění testů, vytvoření databáze a nakopírování do release adresáře:

 $framework = '4.0'
$sln = 'c:\dev\.....sln'
$outDir = 'c:\dev\...'
 
task default -depends Rebuild,Test,Out
task Rebuild -depends Clean,Build
task Clean { 
  #exec { msbuild $slnPath '/t:Clean' }
  Write-Host Clean....
}
task Build { 
  #exec { msbuild $slnPath '/t:Build' }
  Write-Host Build....
}
task Test { 
  # run nunit console or whatever tool you want
  Write-Host Test....
}
task out {
  #gci somedir -include *.dll,*.config | copy-item -destination $outDir
  Write-Host Out....
}

Dá se toto ještě nějak zkrášlit? Ano – pro ty, kteří si rádi klikají (a mnohdy je to rychlejší, než vypisovat příkaz na příkazovou řádku) si můžeme vytvořit jednoduché GUI.

Psake do GUI

Pro vytvoření GUI použijeme WinForms. Nepůjde o krásu, ale o funkčnost, přizpůsobím tedy tomuto kód a maximálně jej zestručním.
PowerShell musí běžet v režimu -STA.

 Add-type -assembly System.Windows.Forms
Add-type -assembly System.Drawing
if (! (get-module psake)) {
  sl D:\temp\psake\JamesKovacs-psake-b0094de\
  ipmo .\psake.psm1
}
 
$form = New-Object System.Windows.Forms.Form
$form.Text = 'Build'
$form.ClientSize = New-Object System.Drawing.Size 70,100
 
('build',10), ('test',30), ('out', 50) | % { 
  $cb = new-object Windows.Forms.CheckBox
  $cb.Text = $_[0]
  $cb.Size = New-Object System.Drawing.Size 60,20
  $cb.Location = New-Object System.Drawing.Point 10,$_[1]
  $form.Controls.Add($cb)
  Set-Item variable:\cb$($_[0]) -value $cb
}
$go = New-Object System.Windows.Forms.Button
$go.Text = "Run!"
$go.Size = New-Object System.Drawing.Size 60,20
$go.Location = New-Object System.Drawing.Point 10,70
$go.add_Click({
  $form.Close()
  if ($cbbuild.Checked) { $script:tasks += 'Rebuild' }
  if ($cbtest.Checked) { $script:tasks += 'Test' }
  if ($cbout.Checked) { $script:tasks += 'Out' }
})
$form.Controls.Add($go)
 
$script:tasks = @()
$form.ShowDialog() | Out-Null
if ($script:tasks) {
  Invoke-psake -buildFile d:\temp\psake\psake-devbuild.ps1 -task $tasks
}

Na několika řádcích kódu jsme schopni si naklikat konfiguraci buildu. Nenechme se ale unést – build by měl být především automatický, pokud možno na jeden klik. Komplexní GUI s mnoha nastaveními nemusí být žádoucí!

Kód s importem modulu kontroluje, zda je psake již naimportované. Pokud bychom importovali modul podruhé, následující spuštění Invoke-Psake by skončilo chybou. Jde o problém samotného psake. Obecně by mělo jít moduly importovat vícekrát bez problémů.

Poznámka: práce se $script:tasks mimo handler události vypadá těžkopádně. Proč nevolám Invoke-Psake přímo v handleru a předávám si seznam tasků ve zvláštní proměnné? Psake výsledek svého běhu (tabulku, časy, výpisy o běžícím tasku) posílá do pipeline, aby bylo možné výstup přesměrovat do souboru. V handleru je tento výstup zpracováván jinak, neposílá se do hlavní pipeline a proto o výstup přijdeme. Jediné viditelné jsou výstupy z Write-Host, které jsou samozřejmě vypsány do konzole.

Změňte psake

Krátce si tu ukážeme, jak bez změny souboru s modulem můžeme změnit chování modulu. Jde o techniku používanou spíše zřídka. Mění totiž prostředí modulu. Bez dobrých znalostí vnitřní struktury modulu, může samozřejmě přestat pracovat korektně. Přesto – proč si nerozšířit znalosti?

Řekněme, že chceme v závěrečném souhrnu také zobrazovat aktuálního uživatele a jméno stroje. Postup je jednoduchý – je potřeba změnit funkci Write-TaskTimeSummary. Ve skutečnosti ji ale nezměníme:

 PS> $module = Get-Module psake
PS> & $module { ${function:script:Write-TaskTimeSummaryBak} = (gi function:\Write-TaskTimeSummary).ScriptBlock }
PS> & $module { ${function:script:Write-TaskTimeSummary} = {
  . Write-TaskTimeSummaryBak
  "$env:USERNAME @$env:COMPUTERNAME"
}}

Čeho jsme tím dosáhli? Ve skriptu jsme vytvořili novou funkci Write-TaskTimeSummaryBak a do ní jsme zazálohovali aktuální obsah funkce Write-TaskTimeSummary. Poté jsme změnili definici funkce Write-TaskTimeSummary tak, aby volala zálohovanou funkci a na konec připojila řetězec se jménem uživatele a počítače.

Tento trik zřejmě často používat nebudete. Hodí se ale určitě znát obrat použitelný s jakýmkoliv modulem:

 & (Get-Module mujmodul) { prikaz, ktery chci provest ve scope modulu }

Popsali jsme si základ práce s psake. Více informací lze najít především na https://github.com/JamesKovacs/psake/wiki. Namátkou se můžete těšit na tipy, jak nastavit akce před každým taskem a po každém tasku, podmínky nutné ke spuštění tasku, jak spustit psake z psake a další.

Ať se vám s psake dobře pracuje!

- Josef Štefan