字串 -is [PSCustomObject] 回 True、空物件轉不出來、逗號後一個空白讓參數靜默綁錯、scriptblock 看不到定義它的檔案。
發布於 2026-09-05・約 6 分鐘
我寫了一個模組化的部署工具:功能寫成一個個 .ps1 丟進 Tasks\ 資料夾就會自動出現在清單裡,勾選狀態跟參數存成一份 JSON。
.ps1
Tasks\
架構很單純,坑全部在型別跟作用域上。
"字串" -is [PSCustomObject]
ConvertFrom-Json 產生的是巢狀的 PSCustomObject,我要把它轉成 ordered hashtable 才好逐鍵改寫。遞迴函式的第一版:
ConvertFrom-Json
PSCustomObject
function ConvertTo-AsHashtable { param($InputObject) if ($null -eq $InputObject) { return $null } if ($InputObject -is [PSCustomObject]) { # ← 這裡就錯了 ... } }
症狀是設定檔裡的字串陣列全毀。原本 ["Microsoft.BingNews", "Microsoft.People"] 存回去變成一堆奇怪的物件。
["Microsoft.BingNews", "Microsoft.People"]
原因是 Windows PowerShell 5.1 裡 [PSCustomObject] 實際上是 [PSObject] 的別名,而所有東西都被 PSObject 包過。所以字串是 PSCustomObject、整數是 PSCustomObject、陣列也是。這個判斷等於永遠成立,字串就被當成物件拆掉了。
[PSCustomObject]
[PSObject]
兩件事一起改。純量先判掉:
if ($InputObject -is [string] -or $InputObject -is [ValueType]) { return $InputObject }
型別要寫全名:
if ($InputObject -is [System.Management.Automation.PSCustomObject]) { ... }
System.Management.Automation.PSCustomObject 才是 ConvertFrom-Json 實際產生的那個型別。
System.Management.Automation.PSCustomObject
同一個函式,我為了效率加了一個條件:屬性數大於 0 才進去展開。
if ($InputObject.PSObject.Properties.Name.Count -gt 0) { ... } # ← 埋雷
當下沒事。埋的雷是這樣爆的:某個原本沒有參數的功能項目,後來加了參數。舊的設定檔裡那一項長這樣:
"System.DisableFastStartup": { "enabled": true, "params": {} }
"params": {} 轉出來是一個沒有屬性的 PSCustomObject,被那個條件擋掉,原封不動回傳。後面的同步邏輯對它呼叫 .Contains(),直接爆炸。
"params": {}
.Contains()
症狀是「舊設定檔再也開不起來」,而且是在改完程式的幾天後才發作,因為要有人拿舊檔案來跑才會遇到。空物件必須也轉成空的 ordered hashtable:
if (($InputObject -is [System.Management.Automation.PSCustomObject]) -or ($InputObject.PSObject -and $InputObject.PSObject.Properties.Name.Count -gt 0)) { $h = [ordered]@{} foreach ($p in $InputObject.PSObject.Properties) { $h[$p.Name] = ConvertTo-AsHashtable $p.Value } return $h }
順帶一提,讀參數值的時候不能用 if (-not $value) 判斷「有沒有填」。$false 跟 0 都會進去,一個布林參數只要關掉就會被當成沒填,然後被預設值蓋回去。要判 $null:
if (-not $value)
$false
0
$null
if ($Params.Contains($Name) -and $null -ne $Params[$Name]) { return $Params[$Name] }
-File 模式下執行帶陣列參數的指令碼:
-File
.\AutoSet.ps1 -Only Power.MonitorAC,Taskbar.SetPins # 正確 .\AutoSet.ps1 -Only Power.MonitorAC, Taskbar.SetPins # 錯,但不會報錯
第二種寫法裡 Taskbar.SetPins 是一個獨立的引數,PowerShell 會依位置去找一個還沒被填的參數塞給它。我的 param() 區塊裡 -Only 後面就是 -ConfigPath,於是那個字串變成設定檔路徑。
Taskbar.SetPins
param()
-Only
-ConfigPath
結果:指令碼拿一份不存在的設定檔去跑,把它當成「還沒設定過」,套用一整份預設值。全程沒有警告。
解法是關掉位置繫結,強制全部參數具名:
# PositionalBinding = $false:全部參數都必須具名。 # 不關掉的話,像 -Only A, B 這種寫法(逗號後面有空白)會把 B 靜默繫結到後面的 # ConfigPath,結果是拿錯設定檔卻不會有任何警告。寧可直接報錯。 [CmdletBinding(PositionalBinding = $false)] param( [switch]$Configure, [string[]]$Only, [string]$ConfigPath )
任何會被人手打在命令列上的指令碼,這一行都該加。
外掛式架構的核心:每個 Tasks\*.ps1 回傳一個雜湊表陣列,Run 欄位是一個 scriptblock。主程式載入之後這樣呼叫:
Tasks\*.ps1
Run
& $t.Run $ctx
我在某個模組裡寫了一個 helper 函式給同檔案的三個項目共用,執行時全部炸掉,說找不到那個函式。
因為主程式是用 & $f.FullName 執行那個檔案的,檔案裡的變數與函式住在那次執行的作用域裡,執行完就消失了。回傳出來的 scriptblock 之後在別的地方被呼叫,看得到的只有 $Ctx 和主程式 dot-source 進來的共用函式庫。
& $f.FullName
$Ctx
兩種共用方式:
同一個檔案裡的多個項目 — 把 scriptblock 存成變數,再指給每個項目的 Run。這樣三個項目共用的是同一份程式碼,差別只在參數:
$powerRunner = { param($Ctx) $null = & powercfg.exe /change $Ctx.Params.PowerCfgSetting ([int]$Ctx.Params.Minutes) if ($LASTEXITCODE -ne 0) { throw "powercfg 失敗,結束碼 $LASTEXITCODE。" } } $defs = @() foreach ($i in $items) { $defs += @{ Id = $i.Id; Name = $i.Name; Run = $powerRunner; ... } } $defs
跨檔案共用 — 加進 Lib\Common.ps1,那份是主程式 dot-source 載入的,所有 scriptblock 都看得到。
Lib\Common.ps1
PowerShell 7.3 之後原生指令的非零結束碼會變成終止錯誤。 像 gpupdate 這種「部分成功也回非零」的指令會讓整支指令碼死掉。我選擇關掉這個行為,自己檢查 $LASTEXITCODE:
gpupdate
$LASTEXITCODE
if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -Scope Global -ErrorAction SilentlyContinue) { $global:PSNativeCommandUseErrorActionPreference = $false }
外面包一層 Get-Variable 是因為 5.1 沒有這個變數,直接設會出錯。
Get-Variable
-like 拿來比對開頭要小心方括號。 我用 [SKIP] 前綴當「這個項目主動略過」的訊號,判斷寫成 $msg -like "[SKIP]*" 完全不會中——萬用字元裡的方括號是「字元集合」的意思,[SKIP] 代表「S、K、I、P 其中一個字元」。改用 StartsWith:
-like
[SKIP]
$msg -like "[SKIP]*"
StartsWith
if ($msg.StartsWith("[SKIP]")) { ... }
四個坑加起來,共通點是 PowerShell 很少直接說「你錯了」。型別判斷永遠成立、參數綁到別的地方、函式找不到、萬用字元比對不到,全部都是安靜地做出一個不是你要的結果。
寫這類工具的時候,我後來的習慣是每個「應該會成功」的地方都回讀一次確認,而不是相信沒有例外就是對的。
標籤:PowerShell、JSON、設定檔、型別