7

PowerShell 模組開發流程最佳化

 3 years ago
source link: https://blog.darkthread.net/blog/better-ps-mod-dev/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

PowerShell 模組開發流程最佳化

2021-05-05 10:27 PM 2 881

每次接觸新語言、新工具或新平台,在正式投入生產前,我習慣先做好幾件事:確立專案通用框架並研究如何讓「修改程式 -> 編譯 -> 部署 -> 測試 -> 修改程式 -> ...」開發循環最佳化,消除無意義的重複手工,讓思緒專中在程式碼本身,才有機會進入神馳模式,充分享受 Coding 樂趣。就像玩 ESP 我會先找到安全且方便的 WiFi 密碼設定程序再開始寫專案、寫 CCW 專案也要寫個 Reg.bat/Unreg.bat 做到一鍵註冊跟反註冊,若每次改完程式要重複敲一大串指令才能看結果,我肯定會氣到把滑鼠丟到地上用腳踩,再放進嘴裡咬破吐掉。(謎之聲:不愧是現代王藍田)

前陣子學會把常用小工具寫成 PowerShell 模組,我的 PowerShell 開發進入新的境界,開始試著把之前已經成熟的小程式整理成模組,但馬上發現寫起來很不順手。首先,標準 PowerShell 模組架構是一個 .psd1 定義檔加一個 .psm1 放程式碼。不花腦筋的做法是將原本一個個工具 .ps1 的內容複製貼進 .psm1,再加上一行 Export-ModuleMember -Function FunctionName。接著建立 .psd1,填寫資料並宣告對外開放的函式清單:FunctionsToExport = @(Fucntion1, Function2, Function3)。

將全部的 .ps1 併成單一大檔讓 .psm1 異常肥大,未來要改程式得在幾千行裡穿梭,有違良好設計實務。不同函式依性質分散成多個 .ps1 有利於維護及管理,平時可針對不同 .ps1 開發測試,要發行時再組裝進模組,會較好的做法。

這是很自然的想法,網路上就有前輩分享一種做法:將每個 Function 寫成一個 .ps1,依是否對外公開放在 Public、Private 資料夾,並以 Funation 名稱做為檔名,另外開 bin 放 .exe 工具、lib 放要參照的 dll,.psm1 只是一個空殼,執行時跑迴圈動態載入 Public 下的所有 .ps1,並 Export-ModuleMember -Function .ps1檔名。(延伸閱讀:Building a PowerShell Module by Warren F)

只是這個做法有個缺點,當 .ps1 檔案數量很多時,模組載入時間慢到令人咋舌。依這篇實測 PowerShell – Single PSM1 file versus multi-file modules by Przemyslaw Klys,一個包含 123 個 .ps1 的模組,Import-Module 居然要耗時 15 秒,相較合併成單一 .psm1 只要 0.2 秒,慢了 75 倍。另外,強迫每個 Function 一個 .ps1 檔有點太死板,將性質相似的 Function 集中成一個 .ps1 更符合直覺。

綜合這些概念,我找到一套自己的做法,實際開發過幾個模組感覺不錯,分享給大家。

我的專案結構如下圖,src 下放 .ps1 跟它會用到的 dll、資料檔等等,.ps1 加上數字編號,決定合併進 .psm1 的順序;tests 放測試用的模擬資料以及測試用 .ps1。而整套做法的靈魂是 Merge-ModuleScripts.ps1,目前先寫成 .ps1 形式邊用邊改,待成熟穩定後再包成模組。

Merge-ModuleScripts.ps1 的工作原理如下:

  1. 以目前所在資料夾名稱做為模組名稱( $moduleName )。
  2. 在目前所在資料夾開一個名為 $moduleName 的子資料夾,用來存放要包入模組的檔案。
  3. 掃瞄 src 目錄下所有 *.ps1 檔案,讀取內容併入 $moduleName.psm1 放入 $moduleName 子資料夾,讀取 .ps1 內容時一併取出要 Export-ModuleMember 的函式名稱蒐集清單。 由於我允許 .ps1 包含多個 Function,要如何決定哪些要公開?我用的方法是加上特殊註解 ##MOD_EXEC## Export-ModuleMember -Function Out-GitDiffReport,Merge-ModuleScripts 藉此識別出要對外開放的方法。 使用註解格式的好處是直接執行 .ps1 時會忽略(Export-ModuleMember 只能在 .psm1 中執行,直接放在 .ps1 會出錯),而併入 .psm1 時 Merge-ModuleScripts 會將 ##MOD_EXEC##,Export-ModuleMember 便成為有效指令。
  4. 處理完 .ps1,src 下所有的 dll、資料檔也要複製到 $moduleName 子資料夾一起打包到模組裡,如此不管在 .ps1 或 .psm1 均可透過 "$PSScriptRoot\.." 存取這些資源。
  5. 第一次打包模組時,若還沒有 .psd1,Merge-ModuleScripts 會自動產生並自動填好版本 1.0.0 等必要資料,開發者只需輸入 Author、Description。之後則會沿用同一個 .psd1,改版時版號記得要改。
  6. Merge-ModuleScripts 會讀取 .psd1 將第 3 步驟蒐集到的公開函式清單填入 FunctionsToExport = @(Fucntion1, Function2, Function3),再存入 $moduleName 子資料夾,這樣 Publish-Module 所需的內容就備妥了。
  7. 若呼叫時加上 -publish 跟 -repository (若要發行到 NuGet 伺服器,還要給 -nugetApiKey),Merge-ModuleScripts 會一併呼叫 Publish-Module 將模組發佈出去。

Merge-ModuleScripts.ps1 程式碼如下:

param (
    [switch][bool]
    $publish,
    [switch][bool]
    $clear,
    [string]
    $repository = "",
    [string]
    $nugetApiKey = "NoKey"
)
$ErrorActionPreference = "STOP"
if ($publish -and [string]::IsNullOrWhiteSpace($repository)) {
    Write-Host "Repository parameter missing" -ForegroundColor Red
    Exit
}
$moduleName = [System.IO.Path]::GetFileName($PSScriptRoot.TrimEnd('\'))
$psm1Name = "$moduleName.psm1"
$outputPath = "$PSScriptRoot\$moduleName";
if ($clear) { # clear temp folder
    if (Test-Path -Path $outputPath) {
        Remove-item $outputPath -Recurse
        Write-Host "$outputPath deleted"
    }
    Exit
}
# prepare temp folder
[System.IO.Directory]::CreateDirectory($moduleName) | Out-Null
$functionsToExport = @()
"# Module $moduleName" | Out-File "$outputPath\$psm1Name" -Encoding utf8
# merge all .ps1 under scripts folder to create a single module_name.psm1
Get-ChildItem -Path "$PSScriptRoot\src" -Filter *.ps1 -ErrorAction SilentlyContinue | 
Sort-Object { $_.Name } | # order by ps1 filename
ForEach-Object {
    Get-Content $_.FullName | Select-String -Pattern "##MOD_EXEC## Export-ModuleMember -Function ([-_A-Za-z0-9]+)" -AllMatches |
    ForEach-Object {
        $functionsToExport += $_.Matches.Groups[1].Value
    }
    $scriptContent = Get-Content $_.FullName -Raw -Encoding utf8
    $scriptContent = $scriptContent.Replace("##MOD_EXEC## ", "")
    $scriptContent | Out-File "$outputPath\$psm1Name" -Append  -Encoding utf8
}
# copy all non-.ps1 files
Get-ChildItem -Path "$PSScriptRoot\src" | Where-Object { !$_.Name.EndsWith('.ps1') } | ForEach-Object { 
    Copy-Item -Path $_.FullName -Destination $outputPath 
}
$psd1Path = "$moduleName.psd1"
if (!(Test-Path $psd1Path -PathType Leaf)) {
    New-ModuleManifest -Path $psd1Path -RootModule $psm1Name -Author (Read-Host "Author of module") -ModuleVersion "1.0.0" -Description (Read-Host "Description of module")
}
$psd1 = Get-Content "$moduleName.psd1" -Raw -Encoding utf8
[System.Text.RegularExpressions.Regex]::Replace($psd1, "FunctionsToExport = ([-@()A-Za-z0-9 ,`"'*]+)", 'FunctionsToExport = @("' + ($functionsToExport -join '","') + '")') | 
Out-File "$outputPath\$moduleName.psd1" -Encoding utf8
if ($publish) {
    Publish-Module -Path $outputPath -Repository $repository -NuGetApiKey $nugetApiKey
    Write-Host "$moduleName published"
}

如此,一個流暢的開發測試循環就準備好了。

開發期間,將目錄切到 tests 下,呼叫 . ..\src\01-GitDiffFunctions.ps1 載入特定 .ps1 (其中包含 Function Out-GitDiffReport 可接收 git diff 輸入轉成 HTML 互動報表),然後呼叫 git diff --no-index orig new | Out-GitDiffReport 現場測試:

反覆修改測試,沒問題之後,執行 Merge-ModuleScripts.ps1 -publish -repository repoName 發行,若之前沒建立過 PSModeDemo.psd1,詢問作者與模組描述後,Merge-ModuleScripts.ps1 會呼叫 New-ModuleManifest 現場建立 .psd1 (未來換版時再記得編輯改版號),一氣喝成。

經過這番設計,我們專心寫好測完 .ps1,打包模組的瑣事全部交給 Merge-ModuleScripts 打理,這才是我心中理想的 PowerShelll 模組開發方式。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK