PowerShellに潜む罠3選

# はじめに
PowerShellはコマンドプロンプトよりも多機能で、少し触っただけでもその便利さが実感できると思います。
一方で、batスクリプトやshellスクリプトと同じ感覚でpsスクリプトを書くと思いもよらない動作をすることが多々あります。
意識していてもそのような罠にハマることが多々あるため、これまでにハマった罠を3つ紹介します。

# 環境情報
<table>
<tr><th>OS</th><td>Windows 11 Pro 22H2</td></tr>
<tr><th>Powershell バージョン</th><td>5.1.22621.4249</td></tr>
</table>

# PowerShellは動的型付け言語である
よくあるパターンが以下の例です。
```Powershell:sample1-1.ps1
# CSVファイルのレコード件数分繰り返す
(Import-Csv $Args[0] -Header @(1..3)).ForEach({
    Write-Host $_.1 "/" $_.2 "/" $_.3
})
```

## パターン1
先ほどのスクリプトに以下のCSVファイルを食わせます。
```text:test1-1.csv
val1-1,val1-2,val1-3
val2-1,val2-2,val2-3
val3-1,val3-2,val3-3
```
実行結果は以下の通りです。きちんとCSVファイルの中身が標準出力されます。
```text:標準出力
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行だけに書き換えて処理します。
```text:test1-2.csv
val1-1,val1-2,val1-3
```
この状態で全く同じスクリプトを実行すると...
```text:標準出力
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`コマンドが何を返しているかチェックしてみましょう。
```Powershell:sample1-2.ps1
Write-Host (Import-Csv $Args[0] -Header @(1..3)).getType()
```
先ほどの`test1-1.csv`と`test1-2.csv`を食わせると、以下のように返ってきます。
```text:標準出力
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`には以下のような修正を入れると、期待通り動いてくれます。
```diff_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
})
```
```text:標準出力
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 変数は緩やかに型指定されるため、特定の種類のオブジェクトに限定されません。

https://learn.microsoft.com/ja-jp/powershell/module/microsoft.powershell.core/about/about_variables?view=powershell-5.1#types-of-variables

メソッドを呼び出す際やコマンドをパイプラインで繋げる際には、型の暗黙変換が起こりえないかを確認しながら実装しましょう。

# 例外が発生しても後続処理に進む事がある
一例として、`Move-Item`コマンドで検証します。
実行するコードは以下の通り。
```powershell:sample2.ps1
$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`コマンドは失敗に終わり、例外をキャッチしそうに見えますがいざ実行してみると以下の結果が得られます。
```text:標準出力
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`できない例外を設けたのかについてまでは言及されていません。

https://learn.microsoft.com/ja-jp/powershell/scripting/learn/deep-dives/everything-about-exceptions?view=powershell-5.1

## 対策
すべての例外を`catch`したい場合は`ErrorAction`パラメータに`Stop`を設定することで対応可能です。
このパラメータはすべての標準のコマンドレットで使用できるので、厳密な処理を行いたいプログラムを書く際には逐一設定することが無難です。
```diff_Powershell:sample2.ps1
$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()
```
実行結果
```text:標準出力
PS C:\ps> .\sample2.ps1
ファイルの移動に失敗！
```
と、ここで新たな問題。
失敗したことはコンソールからわかりますが、今度は標準エラー出力が表示されません。
これは例外により終了しないコマンドの特性で、エラー発生時に暗黙的に`Write-Error`コマンドを呼び出しているためです。
発生した例外をコンソールに出力したい場合は、さらに以下の修正が必要です。
```diff_Powershell:sample2.ps1
$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()
```
実行結果
```text:標準出力
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`するなりログファイルへ出力するなりしましょう。

:::note warn
上記標準エラー出力では、発生場所が修正前後で異なっている点がわかると思います。
`-ErrorAction Stop`でキャッチした例外は、標準エラー出力だけでなく、個別に「どのコマンドでエラーが発生したか」がわかるように標準出力等で表示するとより良いでしょう。
:::

# Add-Contentがファイルに対し読取ロックする
以下のような3つのスクリプトを用意します。
```powershell:sample3-1.ps1
$filepath = ".\test3.log"

for ($i = 0; $i -le 10000; $i++) {
    Add-Content -Path $filepath -Value "aaa bbb ccc" -ErrorAction Stop
}

Write-Host 処理が正常終了しました。
```
```powershell:sample3-2.ps1
$filepath = ".\test3.log"

$content = Get-Content -Path $filepath

Write-Host 処理が正常終了しました。
```
```powershell:sample3-3.ps1
$filepath = ".\test3.log"

Add-Content -Path $filepath -Value "xxx yyy zzz" -ErrorAction Stop

Write-Host 処理が正常終了しました。
```
## パターン1
`sample3-1.ps1`を実行中に、`sample3-2.ps1`を実行すると以下のようなエラーが表示されます。
```text:sample3-2 標準出力
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`側で以下エラーが発生します。
```text:sample3-1 標準出力
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`を実行すると以下のようなエラーが表示されます。
```text:sample3-3 標準出力
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?](https://2pintsoftware.com/news/details/why-is-add-content-bad-in-powershell-51)

## 対策
ファイルへ追記したい場合は`Out-File -Append`を使いましょう。
よほどのことがない限り`Add-Content`コマンドレットを使用すべきではありません。