PowerShell スクリプトのエラー処理の覚書

本記事は PowerShell Advent Calendar 2019 の 2 日目の記事です。
12月3日の0時を過ぎてから書いてます。すまん。
qiita.com

言わずもがなですが、PowerShell は処理の自動化を得意とした言語です。
そして、自動処理において、エラーへの対処は重要です。
何十万件というデータを処理してしまってから、実はエラーがあって、すべての処理結果が壊れているなんていうことになったら、目も当てられません。
ですから、(一般論としては)エラーが起きたら、可及的速やかに処理を中断し、報告すべきです。

別に何も目新しい話ではないのですが、自分でも時々「あれ、どうだったっけ?」と思うことがあるので、そのメモ書きです。

なお、以下、特記しない限り、PowerShell Core 6.2.3 + VSCode で検証しています。

中断されるエラーと中断されないエラー

以下のようなスクリプトを実行してみましょう。どうなるでしょうか。

Get-Item -Path 'no-such-item'

Write-Host 'Hello'

throw [System.Exception]::new('Oops!!')

Write-Host 'World'

結果はこんな感じになります。

Get-Item : Cannot find path 'C:\Users\aetos\no-such-item' because it does not exist.
At C:\Users\aetos\test.ps1:1 char:1
+ Get-Item -Path 'no-such-item'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo          : ObjectNotFound: (C:\Users\aetos\no-such-item:String) [Get-Item], ItemNotFoundException
+ FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand
 
Hello
Oops!!
At C:\Users\aetos\test.ps1:5 char:1
+ throw [System.Exception]::new("Oops!!")
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (:) [], Exception
    + FullyQualifiedErrorId : Oops!!

コンソールには「Hello」は出ますが「World」は出ません。
つまり、Get-Item コマンドに存在しないパスを渡しても処理は中断されませんが、Exception を throw すると処理は中断されます。
PowerShell にはこのように、コンソールに赤文字で表示されるエラーが出ていても、中断される状況とされない状況があります。

ErrorAction と ErrorActionPreference

Get-Item のところを、このように書き換えると、存在しないパスを渡した時にも中断されます。

Get-Item -Path 'no-such-item' -ErrorAction Stop

コマンド パラメーターの ErrorAction に Stop を指定するということは、そのコマンドの実行中だけ、特別な変数 $ErrorActionPreference に Stop を指定することと同義です。
ですから、以下のように書いても、Get-Item のエラーで止まるようになります。

$ErrorActionPreference = 'Stop'

Get-Item -Path 'no-such-item'

こちらの場合、$ErrorActionPreference の値を元に戻さなければ、Get-Item の後でもずっとそのままです。
安全側に倒したい自動処理スクリプトでは、こちらの方が向いていることもあるかもしれません。

なお、$ErrorActionPreference の型は ActionPreference 列挙型で、既定の「エラーメッセージを出して処理継続」は Continue です。

Write-Error

Write-Error コマンドは、コンソールに赤文字でエラー メッセージを出すだけではなく、実際に WriteErrorException という例外を発生させます。
つまり、Write-Error コマンドの ErrorAction パラメーターに Stop を指定したり、Write-Error コマンドの実行前に $ErrorActionPreference に Stop を指定したりしておけば、それ以上処理が進むことはありません。

try-catch

エラー処理と言えば try-catch ですね。
こう書いたらどうなるでしょうか。

$ErrorActionPreference = 'Continue'

Write-Host 'Hello'

try {
    Write-Error 'Oops!!'
}
catch {
    Write-Host 'Error!!'
}

Write-Host 'World'

結果はこうなります。

Hello
C:\Users\aetos\test.ps1 : Oops!!
+ CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
+ FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,caller1.ps1

World

「Error!!」は表示されていません。つまり、try-catch ではエラーを捕まえられていません。
ErrorAction パラメーターを設定すると挙動が変わります。

$ErrorActionPreference = 'Continue'

Write-Host 'Hello'

try {
    Write-Error 'Oops!!' -ErrorAction Stop
}
catch {
    Write-Host 'Error!!'
}

Write-Host 'World'

この場合の出力はこうなります。

Hello
Error!!
World

つまり、try-catch で捕まえられるのは、前述した「中断されるエラー」だけだということです。

他のスクリプトを呼び出す場合

こんなスクリプトだとどうでしょうか。

# caller.ps1
$ErrorActionPreference = 'Continue'

Write-Host 'Hello'

& .\callee.ps1

Write-Host 'World'
# callee.ps1
Write-Error 'Oops!!' -ErrorAction Stop

コンソールには「World」は出力されません。つまり、例外が呼び出し元まで伝播しているということですね。
$ErrorActionPreferenceスクリプト ファイルごとに保持されたりはしません。

外部アプリを呼び出す場合

先ほどのスクリプトを、こう書き換えると、挙動が変わります。

# caller.ps1
$ErrorActionPreference = 'Continue'

Write-Host 'Hello'

pwsh.exe -File .\callee.ps1

Write-Host 'World'

この場合は、callee.ps1 で ErrorAction に Stop を指定していても、コンソールには「World」まで出力されます。
エラーで中断されるのは、あくまで子プロセスとして呼び出した pwsh.exe だけであって、例外は親プロセスまでは伝播しません。

こういう場合にはどうやってエラーを判断するかというと、$LASTEXITCODE という変数を使います。
これには、実行した外部アプリの終了コードが入ってきます。

# caller.ps1
pwsh.exe -Command 'Write-Host "Hello"'

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

pwsh.exe -File .\callee.ps1

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

こうすると、2 回目の $LASTEXITCODE の出力では 1 が表示されます。
なお、先に「Hello」を表示するのに pwsh.exe を実行しているのは、確実に成功するコマンドを実行することで、$LASTEXITCODE を 0 にリセットするためです。

PowerShell スクリプト内で、別の PowerShell スクリプトを呼び出すのに、わざわざ pwsh.exe を実行することはそんなにないとは思いますが、他の exe ファイルなどを実行する場合でも同じことが言えます。

外部アプリに終了コードを返す場合

exit 文を使うことで、PowerShell スクリプトから呼び出し元に、任意の終了コードを返すことができます。
以下のように書けば、終了コードは 5 になります。

exit 5

ただし、ErrorAction パラメーターをはじめとして、「中断されるエラー」で終了した場合の終了コードは 1 になります。
従って、例えば、以下のように書いても、呼び出し元に伝えられる終了コードは 5 ではなく 1 になります(exit 5 の行は実行されません)。

# caller.ps1
pwsh.exe -Command 'Write-Host "Hello"'

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

pwsh.exe -File .\callee.ps1

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"
# callee.ps1
Write-Error 'Oops!!' -ErrorAction Stop
exit 5

中断されるエラーが発生せず、かつ、exit 文で終了コードを明示しなかった場合、中断されないエラーが発生していても、終了コードは 0 になります。

スクリプト ファイルを呼び出した場合の終了コード

以下のように書くと、終了コードは 5 になります。
callee.ps1 で ErrorAction を指定していないことに注意してください。

# caller.ps1
$ErrorActionPreference = 'Continue'

pwsh.exe -Command 'Write-Host "Hello"'

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

& .\callee.ps1

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"
# callee.ps1
Write-Error 'Oops!!'
exit 5

では、以下のようなスクリプトだとどうなるでしょうか。

# caller.ps1
$ErrorActionPreference = 'Continue'

pwsh.exe -Command 'Write-Host "Hello"'

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

try {
    & .\callee.ps1
}
catch {
    
}

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"
# callee.ps1
Write-Error 'Oops!!' -ErrorAction Stop
exit 5

この場合、$LASTEXITCODE は 0 です。これは事前にリセットしたものが残っているためです。
スクリプトの直接実行のため例外は呼び出し元の caller.ps1 まで伝播します。この場合の終了コードは 1 にはなりません。

以下のように、事前に $LASTEXITCODE を 0 にリセットせず、外部コマンドも呼び出さない場合、$LASTEXITCODE は未設定です。

# caller.ps1
$ErrorActionPreference = 'Continue'

Write-Host 'Hello'

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

try {
    & .\callee.ps1
}
catch {
    
}

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"
# callee.ps1
Write-Error 'Oops!!' -ErrorAction Stop
exit 5

Windows PowerShell の怪しい挙動

以下のようなスクリプトを実行してみましょう。
callee.ps1 を呼び出す際に、pwsh.exe ではなく powershell.exe を使っています。

# caller.ps1
$ErrorActionPreference = 'Continue'

pwsh.exe -Command 'Write-Host "Hello"'

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

powershell.exe -File .\callee.ps1

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"
# callee.ps1
Write-Error 'Oops!!'
exit 5

この場合、$LASTEXITCODE は 5 になります。いいですね。

では、こうすると?

# caller.ps1
$ErrorActionPreference = 'Continue'

pwsh.exe -Command 'Write-Host "Hello"'

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

powershell.exe .\callee.ps1

Write-Host "`$LASTEXITCODE = $LASTEXITCODE"

違いは、callee.ps1 を呼び出す際の -File パラメーターの有無だけです。
なんと、こう書くと、$LASTEXITCODE は 1 になります。なんで。

なお、PowerShell Core (pwsh.exe) の場合、(少なくとも v6.2.3 では)-File の有無にかかわらず 5 になります。

組み込み変数 $?

個人的に使ったことはないのですが、直前の実行の成否を示す特殊変数 $? があります。
この値は、直前に実行したコマンドが成功した場合は $true、失敗した場合は $false に設定されます。エラーが中断されるエラーか、中断されないエラーかに関わらず、失敗すれば $false になります。
Write-Host でも成功すれば $true になってしまうので、挙動を観察するときは要注意です。
また、外部コマンドを実行した直後の場合、そのコマンドの終了コードが 0 であれば $true、0 以外の場合は $false になるそうです。

PowerShell 7 の新機能

PowerShell 7 からは、Pipeline Chain Operator というのが搭載されるそうです。
他のスクリプト言語にある、「前段のコマンドが成功した場合のみ後段のコマンドを実行する」とか「前段のコマンドが失敗した場合のみ後段のコマンドを実行する」とかいう機能です。

こちらについて、詳しくはこちらのブログをご覧ください。
dev.classmethod.jp

おわりに

いろいろなケースを取り上げましたが、自分でも試していて意外な挙動をした点がありました。反省。
スクリプトを書く際には注意してください。

なお、PowerShell Advent Calendar 2019 は、寄稿していただける方を絶賛募集中です。よろしくお願いします。
qiita.com