每次接觸新語言、新工具或新平台,在正式投入生產前,我習慣先做好幾件事:確立專案通用框架並研究如何讓「修改程式 -> 編譯 -> 部署 -> 測試 -> 修改程式 -> ...」開發循環最佳化,消除無意義的重複手工,讓思緒專中在程式碼本身,才有機會進入神馳模式,充分享受 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 模組開發方式。

Use a utitlity script to covert anonying PS module packing and publishing works.


Comments

# by jasonlhy

好像不錯 請問有興趣放上 nuget 嗎

# by Jeffrey

to jasonlhy, 前陣子註冊好 PS Gallery 帳號了,未來待發展成熟會上傳。

Post a comment