MOOSSin
選單

全部文章 · 開源工具

兩代 PowerShell 的差別,都是備份腳本教我的

Windows 內建的 PowerShell 5.1 和開源的 PowerShell 7 看起來像同一個東西,行為不一樣的地方剛好都在無人值守的腳本會碰到的地方:原生程式的 stderr、腳本檔的編碼、還有一種「exit 0 但什麼都沒做」的失敗。開頭先講「藍色的視窗和黑色的視窗」。

Windows 上有兩個 PowerShell,很多人用了好幾年都不知道。一個是藍色底的視窗,內建的,開始功能表搜尋「PowerShell」出來的那個;另一個要自己裝,黑色底,圖示長得差不多,執行檔叫 pwsh.exe。它們的語法幾乎相同,你在其中一個裡寫的東西大部分能在另一個裡跑,所以大家以為它們是同一個東西的新舊版本。

不完全是。內建的 PowerShell 5.1 是閉源的,跟著作業系統走,2016 年之後只修不加。PowerShell 7 是另一個專案,MIT 授權,跨平台,要自己裝。工作排程器預設呼叫的是 5.1。我寫備份腳本時被兩者的差異咬過幾次,每一次都是半夜,每一次都以為是自己的程式有問題。整理如下。

原生程式的 stderr

在 5.1 裡,如果你把 $ErrorActionPreference 設成 'Stop',原生程式寫到 stderr 的每一行都會被包成 NativeCommandError 並中止腳本。即使那個程式的退出碼是 0,即使你已經把 stderr 導向 $null,一樣中止。

我第一次碰到這個是在一支等虛擬機開機的腳本裡。ssh 在對方剛開機、sshd 還沒起來時會印一行「Connection timed out during banner exchange」,這是正常的診斷輸出,意思是「還沒好,等一下」。在 5.1 裡它殺掉了我的腳本,而腳本的下一步是確認服務有沒有恢復。於是虛擬機開好了,服務也好了,但腳本已經死了,沒有人確認,早上我看到的是一個失敗的排程紀錄和一台其實好好的機器。

PowerShell 7 預設不把原生程式的 stderr 當成錯誤,只看退出碼。通則是:在 5.1 裡,'Stop' 和呼叫原生程式不能天真地混用。那個設定對 cmdlet 是保護,對原生程式是誤判。就像一個很盡責的保全,聽到任何聲音都拉警報,包括你自己開門的聲音。

腳本檔的編碼

5.1 讀 .ps1 時,沒有 BOM 的檔案會被當成系統的 ANSI 碼頁,繁體中文環境下是 cp950,檔案裡的 UTF-8 中文字會變成亂碼,字串比對全部失敗。你的腳本裡寫「如果檔名包含『備份』」,5.1 看到的是一串火星文,永遠不會相等。PowerShell 7 預設 UTF-8。要讓同一支腳本在兩邊都對,存成帶 BOM 的 UTF-8 最穩。

BOM 是檔案開頭三個看不見的位元組,用來告訴讀的人「我是 UTF-8」。很多編輯器預設不加,因為在 Linux 世界它會惹麻煩;在 Windows 的 5.1 世界,沒有它會惹更大的麻煩。這是那種「兩邊都對,只是不一樣」的事。

exit 0,但什麼都沒做

這是最危險的一種,危險到我要用整節講。

我在一個受限的執行環境裡碰過:對外接磁碟做 Rename-Item、New-Item、Set-Content 這類 PowerShell 原生的寫入時,行程在那一行直接終止,沒有例外、沒有輸出,退出碼卻是 0;而 robocopy、cmd /c move 這類子程序的寫入正常。

想像一下這個情境:排程回報成功,日誌看起來正常,你以為備份做好了。三個星期後你需要那份備份,打開磁碟,只有一個暫存目錄,正式的目錄從來沒有出現過。這個現象不一定在你的環境重現,但它的失敗形式值得防,因為「安靜地什麼都沒做」比「大聲地失敗」可怕一百倍。

對策是兩條。重要的搬移用子程序做,robocopy 或 cmd /c move;做完之後明確檢查目的地是否真的存在,不存在就 throw。不要相信「沒有錯誤」等於「做了」。這條原則後來擴散到我所有的腳本裡:做完一件事,去看一眼結果,不要只看回傳值。

小劇場:那我該用哪個

「所以我應該把 5.1 刪掉?」

「刪不掉,它是 Windows 的一部分。而且很多系統工具還在用它。」

「那我怎麼確定我在用哪個?」

「藍色的是 5.1,黑色的是 7。或者打 $PSVersionTable,它會告訴你。」

「我一直以為顏色只是主題。」

「很多人都這樣以為,然後在某個半夜被 stderr 殺掉腳本。」

只有 7 才有的語法

&& 和 || 管線鏈、三元運算子、?? 空值合併,這些只在 7 裡有。腳本要在兩邊跑,就不能用;只在 7 跑,就在檔案開頭 #requires -Version 7 讓 5.1 直接拒絕,而不是跑到一半才在語法上炸。「一開始就拒絕」永遠比「跑到一半炸」好,因為跑到一半的時候你可能已經改了一些東西。

參數預設值裡的 $PSScriptRoot

5.1 的一個小坑:在參數的預設值運算式裡用 $PSScriptRoot,某些啟動方式下它是空字串,Join-Path 會在第一行日誌寫出來之前就失敗。你甚至看不到它失敗,因為日誌還沒開始寫。把預設路徑的解析移到參數繫結之後再做。這個 bug 我找了一個晚上,最後是把每一行都加上 Write-Host 才發現它根本沒有跑到第一行。

我現在的做法

新腳本一律寫給 PowerShell 7,工作排程器明確指向 pwsh.exe,不讓它用預設的那個。非得在 5.1 跑的舊腳本,原生程式呼叫外面包一層,先把 $ErrorActionPreference 暫時改回 Continue,再依退出碼自己判斷。

兩代 PowerShell 的差異不多,但每一個都藏在半夜三點沒人看的那種腳本裡。藍色和黑色,看起來只是主題不同,實際上是兩個會在不同地方咬你的東西。

一點關於 PowerShell 的身世

PowerShell 的設計者 Jeffrey Snover 在 2002 年寫了一份文件叫 Monad Manifesto,講為什麼 Windows 需要一個真正的命令列環境,而且它應該傳遞物件而不是文字。Unix 的 shell 靠文字管線,你 grep 完再 awk,每一步都在重新解析字串;PowerShell 的管線傳的是 .NET 物件,Get-Process | Where-Object CPU -gt 100 裡的 CPU 是一個屬性,不是第幾欄。這個想法在 2006 年變成 PowerShell 1.0,之後跟著 Windows 走了十年。

2016 年微軟把它開源,同時開始做跨平台的版本,先叫 PowerShell Core 6,2020 年的 7.0 把 Core 這個字拿掉,就叫 PowerShell 7。它可以在 Linux 和 macOS 上跑,執行檔叫 pwsh,跟內建的 powershell.exe 並存。這就是為什麼今天的 Windows 上有兩個:一個是十年前的定版,一個是還在演進的開源專案。

物件管線這件事在 5.1 和 7 裡都一樣好用,這篇講的差異都在邊角,但無人值守的腳本剛好活在邊角。

常見的三個誤會

第一個誤會是「裝了 7 之後 5.1 就被取代了」。沒有,兩個並存,工作排程器、右鍵選單、很多系統工具預設還是叫 5.1。你要明確指定 pwsh.exe,它才會用 7。

第二個誤會是「PowerShell 就是 Windows 的 bash」。它的哲學不同,物件管線、動詞名詞的命令名稱、對 .NET 的直接存取,這些 bash 都沒有。硬把 bash 的習慣搬過來會一直撞牆,把它當成一個新東西學比較快。

第三個誤會是「把 $ErrorActionPreference 設成 'Stop' 讓腳本更安全」。對 cmdlet 是,對原生程式在 5.1 裡是反效果。安全來自於你知道每一種錯誤會怎麼被處理,不是來自一個全域開關。

一個週末可以試的小練習

寫一個五行的腳本:第一行把 $ErrorActionPreference 設成 'Stop',第二行 ssh -o BatchMode=yes nonexistent.invalid(一個不存在的主機,可以再加一個很短的連線逾時免得等太久),第三行 Write-Host "我還活著"。存成帶 BOM 的 UTF-8。

用內建的藍色視窗跑一次,再用 pwsh 跑一次。你會看到藍色視窗裡「我還活著」沒有印出來,腳本在 ssh 那行就死了;黑色視窗裡它印出來了。同一支腳本,兩個結果。然後把 BOM 拿掉再存一次,兩邊各跑一次,看那句中文變成什麼樣子。這二十分鐘涵蓋了這篇講的兩個最常見的坑,而且是在你的電腦上、用你的眼睛看到的。

後記

藍色和黑色的視窗,我後來養成一個習慣:開終端機的時候先看一眼顏色,像出門前看一眼天氣。這個習慣很小,但它擋掉了很多半夜的錯誤。有時候工程上最有效的防護不是什麼精巧的機制,就是「看一眼」。看一眼顏色,看一眼退出碼,看一眼目的地有沒有真的出現檔案。那些安靜地失敗的東西,只有看一眼才抓得到。

專案頁面:github.com/PowerShell/PowerShell

閱讀偏好

返回全部文章作者與內容更正

留言(0)

還沒有留言。讀完有想法,歡迎留下第一則。

留言經站長審核後才會顯示。暱稱和留言會公開,請不要寫下電話、地址等個人資料;資料怎麼處理,請見隱私權政策。