はじめに
PowerShellはコマンドプロンプトよりも多機能で、少し触っただけでもその便利さが実感できると思います。
一方で、batスクリプトやshellスクリプトと同じ感覚でpsスクリプトを書くと思いもよらない動作をすることが多々あります。
意識していてもそのような罠にハマることが多々あるため、これまでにハマった罠を3つ紹介します。
環境情報
OS | Windows 11 Pro 22H2 |
---|---|
Powershell バージョン | 5.1.22621.4249 |
PowerShellは動的型付け言語である
よくあるパターンが以下の例です。
# CSVファイルのレコード件数分繰り返す
(Import-Csv $Args[0] -Header @(1..3)).ForEach({
Write-Host $_.1 "/" $_.2 "/" $_.3
})
パターン1
先ほどのスクリプトに以下のCSVファイルを食わせます。
val1-1,val1-2,val1-3
val2-1,val2-2,val2-3
val3-1,val3-2,val3-3
実行結果は以下の通りです。きちんとCSVファイルの中身が標準出力されます。
PS C:\ps> .\sample1-1.ps1 .\test1-1.csv
val1-1 / val1-2 / val1-3
val2-1 / val2-2 / val2-3
val3-1 / val3-2 / val3-3
パターン2
次は、CSVファイルの中身を1行だけに書き換えて処理します。
val1-1,val1-2,val1-3
この状態で全く同じスクリプトを実行すると...
PS C:\ps> .\sample1-1.ps1 .\test1-2.csv
[System.Management.Automation.PSCustomObject] に 'ForEach' という名前のメソッドが含まれないため、メソッドの呼び出しに失
敗しました。
発生場所 C:\Users\yoda\Desktop\work\sample1-1.ps1:1 文字:1
+ (Import-Csv $Args[0] -Header @(1..3)).ForEach({
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (ForEach:String) []、RuntimeException
+ FullyQualifiedErrorId : MethodNotFound
めっちゃ怒られます。
何が起こっているのか
以下のスクリプトでImport-Csv
コマンドが何を返しているかチェックしてみましょう。
Write-Host (Import-Csv $Args[0] -Header @(1..3)).getType()
先ほどのtest1-1.csv
とtest1-2.csv
を食わせると、以下のように返ってきます。
PS C:\ps> .\sample1-2.ps1 .\test1-1.csv
System.Object[]
PS C:\ps> .\sample1-2.ps1 .\test1-2.csv
System.Management.Automation.PSCustomObject
test1-1.csv
を食わせた際には配列が、test1-2.csv
を食わせた際にはPSCustomObject
型のデータが返っています。
この挙動はPowerShellがコマンドレットの結果を暗黙的に変換していることを示しています。
対策
PowerShellが勝手にデータ型を変換しないよう、適宜変数を宣言することでこの事象は回避できます。
先の例であげたsample1-1.ps1
には以下のような修正を入れると、期待通り動いてくれます。
# CSVファイルのレコード件数分繰り返す
-(Import-Csv $Args[0] -Header @(1..3)).ForEach({
+[Object[]]$csv = Import-Csv $Args[0] -Header @(1..3)
+$csv.ForEach({
Write-Host $_.1 "/" $_.2 "/" $_.3
})
PS C:\ps> .\sample1-1.ps1 .\test1-1.csv
val1-1 / val1-2 / val1-3
val2-1 / val2-2 / val2-3
val3-1 / val3-2 / val3-3
PS C:\ps> .\sample1-1.ps1 .\test1-2.csv
val1-1 / val1-2 / val1-3
このことは公式サイトでも以下のように明言されています。
PowerShell 変数は緩やかに型指定されるため、特定の種類のオブジェクトに限定されません。
メソッドを呼び出す際やコマンドをパイプラインで繋げる際には、型の暗黙変換が起こりえないかを確認しながら実装しましょう。
例外が発生しても後続処理に進む事がある
一例として、Move-Item
コマンドで検証します。
実行するコードは以下の通り。
$filepath = ".\test2.log"
# テスト用にファイルをロック
$file = [System.IO.File]::Open($filepath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read)
try {
Move-Item -Path $filepath -Destination ".\log"
Write-Host ファイルの移動に成功
}
catch {
# ファイルがロックされているのでここを通るはず
Write-Host ファイルの移動に失敗!
# テスト用のロックを解除
$file.Close()
exit
}
Write-Host 処理が正常終了しました。
# テスト用のロックを解除
$file.Close()
移動しようとしているファイルに対し、事前にロックがかかっているためMove-Item
コマンドは失敗に終わり、例外をキャッチしそうに見えますがいざ実行してみると以下の結果が得られます。
PS C:\ps> .\sample2.ps1
Move-Item : ファイルが別のプロセスで使用されているため、プロセスはファイルにアクセスできません。
発生場所 C:\ps\sample2.ps1:7 文字:5
+ Move-Item -Path $filepath -Destination ".\log"
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (C:\ps\test2.log:FileInfo) [Move-Item], IOException
+ FullyQualifiedErrorId : MoveFileInfoItemIOError,Microsoft.PowerShell.Commands.MoveItemCommand
ファイルの移動に成功
処理が正常終了しました。
標準エラー出力されているのにcatch
節を通ってくれません。なんでやねん!
何が起こっているのか
Powershellでは「終了エラー」と「終了しないエラー」の2種類があり、多くのコマンドでは失敗した際に「終了しないエラー」を返します。
本例のMove-Item
でも「終了しないエラー」が発生しているため、例外はキャッチされることなく後続まであたかも異常がなかったかのように処理が進みました。
公式サイトでは「例外について知りたかったことのすべて」と謳っていますが、なぜcatch
できない例外を設けたのかについてまでは言及されていません。
対策
すべての例外をcatch
したい場合はErrorAction
パラメータにStop
を設定することで対応可能です。
このパラメータはすべての標準のコマンドレットで使用できるので、厳密な処理を行いたいプログラムを書く際には逐一設定することが無難です。
$filepath = ".\test2.log"
# テスト用にファイルをロック
$file = [System.IO.File]::Open($filepath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read)
try {
- Move-Item -Path $filepath -Destination ".\log"
+ Move-Item -Path $filepath -Destination ".\log" -ErrorAction Stop
Write-Host ファイルの移動に成功
}
catch {
# ファイルがロックされているのでここを通るはず
Write-Host ファイルの移動に失敗!
# テスト用のロックを解除
$file.Close()
exit
}
Write-Host 処理が正常終了しました。
# テスト用のロックを解除
$file.Close()
実行結果
PS C:\ps> .\sample2.ps1
ファイルの移動に失敗!
と、ここで新たな問題。
失敗したことはコンソールからわかりますが、今度は標準エラー出力が表示されません。
これは例外により終了しないコマンドの特性で、エラー発生時に暗黙的にWrite-Error
コマンドを呼び出しているためです。
発生した例外をコンソールに出力したい場合は、さらに以下の修正が必要です。
$filepath = ".\test2.log"
# テスト用にファイルをロック
$file = [System.IO.File]::Open($filepath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read)
try {
Move-Item -Path $filepath -Destination ".\log" -ErrorAction Stop
Write-Host ファイルの移動に成功
}
catch {
+ Write-Error $_
# ファイルがロックされているのでここを通るはず
Write-Host ファイルの移動に失敗!
# テスト用のロックを解除
$file.Close()
exit
}
Write-Host 処理が正常終了しました。
# テスト用のロックを解除
$file.Close()
実行結果
PS C:\ps> .\sample2.ps1
C:\ps\sample2.ps1 : ファイルが別のプロセスで使用されているため、プロセスはファイルにアクセスできま
せん。
発生場所 行:1 文字:1
+ .\sample2.ps1
+ ~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [Write-Error], WriteErrorException
+ FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,sample2.ps1
ファイルの移動に失敗!
catch
ブロック内の例外情報は、$_
変数に設定されているので、それをWrite-Error
するなりログファイルへ出力するなりしましょう。
上記標準エラー出力では、発生場所が修正前後で異なっている点がわかると思います。
-ErrorAction Stop
でキャッチした例外は、標準エラー出力だけでなく、個別に「どのコマンドでエラーが発生したか」がわかるように標準出力等で表示するとより良いでしょう。
Add-Contentがファイルに対し読取ロックする
以下のような3つのスクリプトを用意します。
$filepath = ".\test3.log"
for ($i = 0; $i -le 10000; $i++) {
Add-Content -Path $filepath -Value "aaa bbb ccc" -ErrorAction Stop
}
Write-Host 処理が正常終了しました。
$filepath = ".\test3.log"
$content = Get-Content -Path $filepath
Write-Host 処理が正常終了しました。
$filepath = ".\test3.log"
Add-Content -Path $filepath -Value "xxx yyy zzz" -ErrorAction Stop
Write-Host 処理が正常終了しました。
パターン1
sample3-1.ps1
を実行中に、sample3-2.ps1
を実行すると以下のようなエラーが表示されます。
PS C:\ps> .\sample3-2.ps1
Get-Content : 別のプロセスで使用されているため、プロセスはファイル 'C:\ps\test3.log' にアクセスで
きません。
発生場所 C:\ps\sample3-2.ps1:3 文字:12
+ $content = Get-Content -Path $filepath
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ReadError: (C:\ps\test3.log:String) [Get-Content], IOException
+ FullyQualifiedErrorId : GetContentReaderIOError,Microsoft.PowerShell.Commands.GetContentCommand
処理が正常終了しました。
あるいは、sample3-2.ps1
は正常終了し、sample3-1.ps1
側で以下エラーが発生します。
C:\ps> .\sample3-1.ps1
Add-Content : 別のプロセスで使用されているため、プロセスはファイル 'C:\ps\test3.log' にアクセスで
きません。
発生場所 C:\ps\sample3-1.ps1:4 文字:5
+ Add-Content -Path $filepath -Value "aaa bbb ccc" -ErrorAction sto ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (C:\ps\test3.log:String) [Add-Content], IOException
+ FullyQualifiedErrorId : GetContentWriterIOError,Microsoft.PowerShell.Commands.AddContentCommand
パターン2
sample3-1.ps1
を実行中に、sample3-3.ps1
を実行すると以下のようなエラーが表示されます。
C:\ps> .\sample3-3.ps1
Add-Content : ストリームを読み取れませんでした。
発生場所 C:\ps\sample3-3.ps1:3 文字:1
+ Add-Content -Path $filepath -Value "xxx yyy zzz" -ErrorAction stop
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidArgument: (C:\ps\test3.log:String) [Add-Content]、ArgumentExc
eption
+ FullyQualifiedErrorId : GetContentWriterArgumentError,Microsoft.PowerShell.Commands.AddContentCommand
あるいは、sample3-1.ps1
の標準出力で同様のエラー、またはケース1と同じエラーが発生します。
何が起こっているのか
本節のタイトルにある通り、Add-Content
コマンドレットは追記先のファイルに対し読み取りロックを掛けてしまうためにエラーが発生します。
詳しくは以下のサイトにも記載されています。
Why is ”Add-Content” bad in PowerShell 5.1?
対策
ファイルへ追記したい場合はOut-File -Append
を使いましょう。
よほどのことがない限りAdd-Content
コマンドレットを使用すべきではありません。