🧱

[PowerShell]Begin, Process, Endブロックでより高度な関数にしよう

に公開

PowerShellで関数(Function)を自作する際に「引数で配列を渡し、関数内でforeachする」というシチュエーションが頻繁にあります。

例)引数で配列を渡し、関数内でforeachした場合のコード
function Invoke-GreetingWithForeach {
    param(
        # 引数として、文字列配列を受け取る
        [Parameter(ValueFromPipeline=$true)]
        [string[]]$NameList
    )
    
    # 1. さいしょに1度だけ実行
    Write-Host "<--- 挨拶処理の開始 --->"

    # 2. 繰り返し処理
    foreach ($personName in $NameList) {
        Write-Host ("こんにちは、" + $personName + "さん!")
    }

    # 3. さいごに1度だけ実行
    Write-Host "<--- 挨拶処理が終了 --->"
}

この関数に対し、BeginProcessEndブロックを使って高度な関数にすることで、

  1. 処理性能の向上(大量データで処理する時)
  2. PowerShellのコマンドレットとの連携(パイプライン連携など)
  3. 呼び出し元でエラー制御が可能

というような恩恵があるらしい……
今回は、高度な関数にするための「Begin / Process / End ブロック」について検証・調べたことを共有します!

この記事のターゲット

  • PowerShellでエコシステム(再利用)を意識した自作関数を作成したい方
  • 大容量データの処理を自作関数で行う方
  • PowerShellの真骨頂?を味わいたい方

foreachではなくBeginやProcess、Endブロックする場合のコード

まず、パイプライン実行の検証用とするため、引数を文字列配列($NameList)から文字列変数($Name)に変更。

1の最初に1度だけ実行する箇所を Beginブロック で囲み、2の繰り返し実行する処理を Processブロック で囲む。さいごに 3で最後に1度だけ実行する箇所を Endブロック で囲む という変更になります。

BeginやProcess、Endブロックのコード例

例)BeginやProcess、Endブロックのコード
function Invoke-GreetingEx {
    [CmdletBinding()]
    param(
        # パイプラインからの入力を受け付けるための属性
        [Parameter(ValueFromPipeline=$true)]
        [string]$Name
    )

    Begin {
        # 1. さいしょに1度だけ実行
        Write-Host "<--- 挨拶処理の開始 --->"
    }

    Process {
        # 2. 繰り返し処理
        Write-Host ("こんにちは、" + $Name + "さん!")
    }

    End {
        # 3. さいごに1度だけ実行
        Write-Host "<--- 挨拶処理が終了 --->"
    }
}

これで各ブロックごとに分離した関数になりました!つぎはこの関数の実行方法です。

使い方と実行結果

Begin/Process/Endブロックで定義した関数は、基本的にパイプラインを使って実行します。

  • 実行例

    例)パイプラインで高度な関数を実行
    "Taro", "Ichiro", "Jiro" | Invoke-GreetingEx
    
  • 実行結果

    実行結果
    <--- 挨拶処理の開始 --->
    こんにちは、Taroさん!
    こんにちは、Ichiroさん!
    こんにちは、Jiroさん!
    <--- 挨拶処理が終了 --->
    

BeginEndがそれぞれ1回だけ、Processは名前の数分(3回)が実行されています。
結果だけみるとforeachの処理との違いがわかりにくいと思うので、内部的な流れの例を交えて関数を高度化するメリットについて触れていきます。

BeginやProcess、Endブロックで関数を高度化するメリット

冒頭にも紹介していた下記3つのメリットについてまとめます。

  1. 処理性能の向上(大量データで処理する時)
  2. PowerShellコマンドレットとの連携(パイプライン連携)
  3. 呼び出し元でエラー制御(引数による柔軟な制御)

メリット1. 処理性能の向上

下記が冒頭にも紹介していたforeachを使ったコード。

例)配列の引数を渡し、関数内でforeachした場合のコード
function Invoke-GreetingWithForeach {
    [CmdletBinding()] # 関数の高度な機能(共通パラメータなど)を使用
    param(
        # 引数として、文字列配列を受け取る
        [Parameter(ValueFromPipeline=$true)]
        [string[]]$NameList
    )
    
    # 1. さいしょに1度だけ実行
    Write-Host "<--- 挨拶処理の開始 --->"

    # 2. 繰り返し処理
    foreach ($personName in $NameList) {
        Write-Host ("こんにちは、" + $personName + "さん!")
    }

    # 3. さいごに1度だけ実行
    Write-Host "<--- 挨拶処理が終了 --->"
}

繰り返しになりますが、下記がBegin, Process, Endブロックを使ったコード。

例)BeginやProcess、Endブロックのコード
function Invoke-GreetingEx {
    [CmdletBinding()]
    param(
        # パイプラインからの入力を受け付けるための属性
        [Parameter(ValueFromPipeline=$true)]
        [string]$Name
    )

    Begin {
        # 1. さいしょに1度だけ実行
        Write-Host "<--- 挨拶処理の開始 --->"
    }

    Process {
        # 2. 繰り返し処理
        Write-Host ("こんにちは、" + $Name + "さん!")
    }

    End {
        # 3. さいごに1度だけ実行
        Write-Host "<--- 挨拶処理が終了 --->"
    }
}

これら2つのコードを比較すると「繰り返し処理がProcessブロックなのか、foreachなのかの違いで本質的に同じではないか?」という疑問が生まれます。

結果だけを見れば間違いはないですが、結果を実行するまでの過程、PowerShellの内部的な動作に大きな違いがあり大容量データを取り扱うとき、パフォーマンスに反映されるとのこと。

つまりは2つの関数を比較すると、

\mathrm{Processブロックを使った関数の結果} = \mathrm{foreachを使った関数の結果}

\mathrm{Processブロックを使った関数の内部処理} \neq \mathrm{foreachを使った関数の内部処理}

という関係性となります。

つぎは、Processブロックを実装することで実現できる「本質的なストリーム処理」について触れていきます。

本質的なストリーム処理とは

はい、承知いたしました。
これまでの議論を踏まえ、ご提示いただいた元のMarkdownの文章を、より正確で誤解のない表現に修正し、全体を一つにまとめた完成版として出力します。


Processブロックを使った関数の場合(本質的なストリーム処理)

特徴: データが1つ来るたびに、Processブロックがその都度実行されます。メモリには常に一度に1つのデータしかロードされないため、巨大なデータセットでも効率良く処理できます。

下記を実行した際の内部的な動作を見ていきます。

Processブロックを使った関数の場合の実行例
"データA", "データB", "データC" | Invoke-GreetingEx

内部的な動きをシーケンス図に表すと下記のとおり。

シーケンス図を箇条書きにすると……

  1. "データA"がパイプラインに投入される。
  2. Processブロックが起動し、"データA"を処理する。
  3. "データB"がパイプラインに投入される。
  4. Processブロックが再び起動し、"データB"を処理する。
  5. "データC"がパイプラインに投入される。
  6. Processブロックが再び起動し、"データC"を処理する。

上記のとおりProcessブロックを使う事で、データを1つずつメモリ効率良く、逐次的に処理することが可能です。


foreachを使った関数の場合

特徴: すべてのデータを一度にメモリ上に展開してから処理を開始。そのため、扱うデータセットが非常に大きい場合(ファイルサイズが数GBのデータなど)は、メモリを大量に消費する可能性があります。

下記を実行した際の内部的な動作を見ていきます。

foreachを使った関数の場合の実行例
# 処理対象のデータをすべて配列としてメモリ上に用意する
$nameListData = @(
    "データA",
    "データB",
    "データC"
)

# 関数に配列変数を直接渡す
Invoke-GreetingWithForeach $nameListData

この内部的な動きもシーケンス図に表すと下記のとおり。

このシーケンス図も箇条書きにすると……

  1. 【準備】変数の定義
    PowerShellは、$nameListData という変数に @("データA", "データB", "データC") という3つの要素を持つ配列をメモリ上に格納。
  2. 【呼び出し】関数に配列データを一括で渡す
    Invoke-GreetingWithForeach $nameListData の行が実行。
    PowerShellは、$nameListData 変数が保持している配列全体を、関数のパラメーター($InputData)に一括で渡す。
  3. 【実行】関数内の処理
    関数 Invoke-GreetingWithForeach が起動する。
    関数は、受け取った配列に対して foreach ループを開始し、各要素を順番に処理する。

このように、foreach パターンでは、処理を開始する前にすべてのデータをメモリ上に用意する必要があるという点が、ストリーム処理との大きな違いです。

補足情報:foreachの関数をパイプラインで実行すると期待する動きになりません。

ちなみにforeachを使った関数を実行する際にパイプラインを配列で渡して実行すると期待する動きになりません。
(PowerShellの不具合ということではなく、パイプラインの使い方を間違えているため、このような挙動に。)

foreach関数をパイプラインで実行した場合の実行例
$nameListData = @(
    "データA",
    "データB",
    "データC"
)

$nameListData | Invoke-GreetingWithForeach

これで実行すると、下記のとおり データC のみが出力するのみとなります。

実行結果
<--- 挨拶処理の開始 --->
こんにちは、データCさん!
<--- 挨拶処理が終了 --->

高度化していない関数にパイプラインでデータを渡すと、PowerShellは関数全体を「Endブロック」のような扱いになります。つまり、「パイプラインにすべての配列データが流れ終わった後に、最後の要素だけが実行される」という流れになってしまいます。

シーケンス図で説明すると下記のとおり。

下記がシーケンス図を箇条書きにしたもの。

  • 【関数実行前】パイプラインからのデータ投入(上書きされて最後の要素だけが有効)
    1. "データA"の処理
      • "データA"がパイプラインに投入され、関数の引数$NameListの値が"データA"となる
      • 関数はまだ実行されない
    2. "データB"の処理
      • つぎに"データB"が投入されるが、関数の引数$NameListの値が"データB"上書きされてしまう
      • 関数は実行されない
    3. "データC"の処理
      • 最後に"データC"が投入されて上記と同様に関数の引数$NameListの値が"データC"さらに上書きされてしまう
      • 関数がやっと実行される
  • 【関数実行】引数を元に関数内の処理が実行(最後の要素だけが実行)
    1. パイプラインのデータがすべて流れ終わった時点で、関数本体が1度だけ実行

    2. foreach ($personName in $NameList)が実行されるが、$NameListには"データC"のみのためループしない

このとおりパイプラインで複数の要素を渡し実行する場合は、Processブロックは必須となります。

つぎは2つ目のメリットであるPowerShellコマンドレットとの連携について触れていきます。

メリット2. PowerShellコマンドレットとの連携

Processブロックを使った関数は、PowerShellコマンドレットとパイプラインを使うことで柔軟な連携が可能です。

実行例

実行する前に氏名が入ったテキストファイルを作っておき、Get-Contentで参照したデータをパイプラインで渡して、
高度化している関数Invoke-GreetingExに渡すという流れです。

例)他のコマンドレットと連携してパイプラインで関数を実行
$nameListData = @(
    "Saburo",
    "Shiro",
    "Goro",
    "Rokuro",
    "Shichiro"
)

# 配列の内容をテキストファイルに書き出す
$filePath = "D:\Downloads\NameList.txt"
Set-Content -Path $filePath -Value $nameListData -Encoding UTF8

# テキストファイルを読み込んだ後、パイプラインで実行
Get-Content -Path $filePath | Invoke-GreetingEx

実行結果

期待通りの動きをしてくれました。

実行結果
# テキストファイルの準備
PS C:\Users\XXXX> $nameListData = @(
>>     "Saburo",
>>     "Shiro",
>>     "Goro",
>>     "Rokuro",
>>     "Shichiro"
>> )
>>
>> # 配列の内容をテキストファイルに書き出す
>> $filePath = "D:\Downloads\NameList.txt"
>> Set-Content -Path $filePath -Value $nameListData -Encoding UTF8
PS C:\Users\XXXX>

# テキストファイル確認
PS C:\Users\XXXX> Get-Content -Path $filePath
Saburo
Shiro
Goro
Rokuro
Shichiro
PS C:\Users\XXXX>

# 実行
PS C:\Users\XXXX> Get-Content -Path $filePath | Invoke-GreetingEx
<--- 挨拶処理の開始 --->
こんにちは、Saburoさん!
こんにちは、Shiroさん!
こんにちは、Goroさん!
こんにちは、Rokuroさん!
こんにちは、Shichiroさん!
<--- 挨拶処理が終了 --->
PS C:\Users\XXXX>

このように高度な関数は、パイプライン連携が可能、かつ本質的なストリーム処理が可能という点でさまざまなシチュエーションで活躍してくれるでしょう。

メリット3.呼び出し元でエラー制御

関数の利用者が、その場の状況に応じて-ErrorActionパラメーターを渡すだけで、「停止(Stop)」「続行(Continue)」「無視して続行(SilentlyContinue)」などの挙動を自由に選択できます。これにより、関数が非常に柔軟で再利用性の高いツールとなります。

ここではErrorActionによる挙動の違いを、よりわかりやすく検証するために使用するコードを変更します。

ErrorAction検証用のコード「対象ファイルの先頭行を表示する関数」
function Get-FirstLineEx {
    [CmdletBinding()] # ErrorActionなどの共通パラメータを使用できるように
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]$Path
    )

    process {
        # Get-Contentでファイルの先頭1行だけを取得
        # → ここでファイルが存在しない場合、「非終了エラー」が発生
        $firstLine = (Get-Content -Path $Path -TotalCount 1)

        # 結果を整形して出力
        # (ファイルが存在しない、または空の場合は、$firstLineは$nullになる)
        Write-Output "[$Path] -> $firstLine"
    }
}

このようにパイプラインでファイルパスを渡し、対象ファイルの先頭行を表示する関数を使っていきます。

つぎは、検証する入力ファイルを準備。下記がそのコードです。

検証用のファイルを準備
# --- 実行前の準備 ---

# 1. テスト用のファイルを作成
Set-Location "D:\Downloads"
"1件目となるFILE-Aファイルの先頭、1行目です。" | Out-File ./FILE-A.txt -Encoding utf8
"2件目となるFILE-Bファイルの先頭、1行目です。" | Out-File ./FILE-B.txt -Encoding utf8

# 2. 処理対象のファイルパスを【配列】に格納
#    存在しないファイルもリストに含める
$filePathList = @(
    "./FILE-A.txt",             # 存在する1件目のファイル
    "./non_existent_file.txt",  # 存在しないファイル
    "./FILE-B.txt"              # 存在する2件目のファイル
)

以降の検証では下記のようなイメージで実行します。

実行するコマンドのイメージ
# 配列変数をパイプラインで関数に渡す
$filePathList | Get-FirstLineEx -ErrorAction (Stop / Continue / SilentlyContinue)

呼び出し側でエラー時に中断(Stop)するよう指定して実行

呼び出し側でエラーが発生した時に処理を中断するよう、-ErrorAction Stop を指定し実行してみます。

コピー用
$filePathList | Get-FirstLineEx -ErrorAction Stop
実行結果
PS D:\Downloads> $filePathList | Get-FirstLineEx -ErrorAction Stop
[./FILE-A.txt] -> 1件目となるFILE-Aファイルの先頭、1行目です。
Get-Content : パス 'D:\Downloads\non_existent_file.txt' が存在しないため検出できません。
発生場所 行:11 文字:22
+         $firstLine = Get-Content -Path $Path -TotalCount 1
+                      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (D:\Downloads\non_existent_file.txt:String) [Get
   -Content], ItemNotFoundException
    + FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand

PS D:\Downloads>

結果、呼び出し側の指定方法によりエラー発生時に停止することができました。

呼び出し側でエラー時に続行(Continue)するよう指定して実行

つづいて呼び出し側でエラーが発生しても続行するようにしてみます。

コピー用
$filePathList | Get-FirstLineEx -ErrorAction Continue

下記が実行結果。

実行結果
PS D:\Downloads> $filePathList | Get-FirstLineEx -ErrorAction Continue
[./FILE-A.txt] -> 1件目となるFILE-Aファイルの先頭、1行目です。
Get-Content : パス 'D:\Downloads\non_existent_file.txt' が存在しないため検出できません。
発生場所 行:11 文字:22
+         $firstLine = Get-Content -Path $Path -TotalCount 1
+                      ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (D:\Downloads\non_existent_file.txt:String) [Get
   -Content], ItemNotFoundException
    + FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand

[./non_existent_file.txt] ->
[./FILE-B.txt] -> 2件目となるFILE-Bファイルの先頭、1行目です。
PS D:\Downloads>

上記のとおり、エラーメッセージを表示しつつ最後まで処理が完走しました。

ちなみに-ErrorActionを引数で指定していなくても、Continueで処理されます。これは Continue が規定値であるため、引数で指定していなくても続行するからです。

補足情報:ErrorActionの規定値(デフォルトの値)を厳密に解説すると……

より厳密に解説します。

じつは-ErrorActionを指定していない場合は、自動変数「$ErrorActionPreference」を参照しています。
その自動変数のデフォルトの値が Continue であるため、-ErrorActionが指定されていない場合は Continue と同じ挙動となり続行されるというのが厳密な解説でした。

ErrorAction パラメーターは、現在のコマンドの$ErrorActionPreference変数の値をオーバーライドします。
$ErrorActionPreference変数の既定値は Continue であるため、ErrorAction パラメーターを使用しない限り、エラー メッセージが表示され、実行が続行されます。

参考情報:-ErrorActionについて - Microsoft公式

呼び出し側でエラー時に無視して続行(SilentlyContinue)するよう指定して実行

つづいてエラーを無視して続行したい場合は……

コピー用
$filePathList | Get-FirstLineEx -ErrorAction SilentlyContinue

結果、エラーメッセージすら表示されず正常に処理できるものだけが表示。

実行結果
PS D:\Downloads> $filePathList | Get-FirstLineEx -ErrorAction SilentlyContinue
[./FILE-A.txt] -> 1件目となるFILE-Aファイルの先頭、1行目です。
[./non_existent_file.txt] ->
[./FILE-B.txt] -> 2件目となるFILE-Bファイルの先頭、1行目です。
PS D:\Downloads>
補足情報:ErrorActionパラメーターとは?

-ErrorActionは、[CmdletBinding()]を追加した高度な関数や標準コマンドレットで使える共通パラメーター
コマンド実行中に「非終了エラー(Non-Terminating Error)」が発生した際にPowerShellの挙動を指定できます。

  • 重要な前提:エラーの2種類
    PowerShellには大きく2種類のエラーがあります。

    1. 終了エラー (Terminating Error)
      例: 存在しないコマンドの実行、構文エラー。

      • スクリプトの続行が不可能な深刻なエラーを意味する。
      • デフォルトでスクリプトを即座に停止させる。
    2. 非終了エラー (Non-Terminating Error)
      例: Get-Contentでファイルが見つからない、Remove-Itemで削除対象が存在しない。

      • 処理の続行が可能な、比較的軽微なエラー。
      • デフォルトではエラーメッセージを表示するだけで、スクリプトは続行する。

繰り返しになりますが、この2種類のうち-ErrorActionで制御できるのが「非終了エラー (Non-Terminating Error)」です。


-ErrorActionオプション一覧表

パラメーター値 動作 (Action) 主な使用用途・シナリオ
Stop 🔴 停止
・非終了エラーを終了エラーに格上げし、スクリプトの実行を即座に停止させる。
try-catchブロックでエラーを捕捉できるようになる。
・エラー記録は、自動変数$Error追加
・そのエラーが処理全体にとって致命的な場合。
try-catchを使って、特定のエラー発生時に回復処理や代替処理を実装したい場合。
Continue 🟡 続行(報告あり)
デフォルトの動作
・エラーメッセージをコンソールに出力する。
・スクリプトの実行は続行される。
・エラー記録は、自動変数$Error追加
・エラーの発生は把握したいが、処理全体は止めずに最後まで実行したい場合。
・対話的な操作や、エラーをログに記録したい場合。
SilentlyContinue 🟢 続行(報告なし)
・エラーメッセージを抑制し、画面には何も表示させない。
・スクリプトの実行は続行される。
・エラー記録は、自動変数$Error追加
・エラーが発生することが想定済みで、それが問題ではない場合。
 例:「ファイルがあれば削除する(なければ何もしないでOK)」というような処理。
Inquire 問い合わせ
・エラー発生時に処理を中断し、ユーザーにどうするかを尋ねるプロンプトを表示する。
[Y] はい(Y) [A] すべて続行(A) [H] コマンドの中止(H) [S] 中断(S) [?] ヘルプ (既定値は "Y"):などの選択肢が表示。
・ユーザーの選択にかかわらず、エラー記録は自動変数$Error追加
対話的なスクリプトで、実行者がその場で判断を下せるようにしたい場合。
・不向きなのは自動化されたバッチ処理など、無人実行されるスクリプト。
Ignore 完全に無視
・エラーメッセージを抑制し、スクリプトの実行を続行する。
SilentlyContinueとの違いは、$Error変数にすらエラー記録を追加しない点。
・非常に稀なケース。エラーが発生したという事実を、ログを含めどこにも残したくない場合。

Suspend は、PowerShell 6 以降でサポートされていないワークフロー機能に関連したオプションのため、一覧表から除外。


参考情報:-ErrorActionについて - Microsoft公式

これで、ひととおり関数を高度化するメリットについて解説しました。
つぎは、もうすこし実践的な例を題材に検証していきましょう。

もう少し実践的な例で検証

ここまではシンプルな例で検証してきましたが、Begin/Process/Endブロックは、より複雑なタスクで真価を発揮するでしょう。

すこし実践的な例として、「複数のサーバーに接続し、それぞれのサーバーで特定のサービスの状態を確認して、結果をCSVファイルに出力する」というシナリオを想定します。

実践的なコード

LAN内のWindowsサーバー・コンピューターのサービス状態を確認する関数
# PowerShellのパイプライン処理の思想に沿った、より実践的な関数
function Get-ServiceStatusOnServers {
    [CmdletBinding()] # これにより -ErrorAction, -Verbose などの共通パラメータが使用可能となる
    param(
        [Parameter(ValueFromPipeline=$true)]
        [string]$ComputerName,

        [string]$ServiceName = "BITS",

        [string]$OutputPath
    )

    Begin {
        # [1.Begin] 最初に一度だけ実行
        $results = [System.Collections.Generic.List[object]]::new()
        Write-Verbose "[Begin] 処理を開始します。"

         # 実践的な例として、CSV出力先の親ディレクトリが存在するかチェック
        if ($OutputPath) {
            $parentDir = Split-Path -Path $OutputPath -Parent
            if (-not (Test-Path -Path $parentDir)) {
                # ★ 呼び出し元のErrorActionにより動作が変化する箇所
                #   CSV出力先の親ディレクトリが存在しなかった場合にWrite-Errorで「終了しないエラー」を発生させる。
                Write-Error "出力先の親ディレクトリが存在しません: $parentDir"
            }
        }
    }

    Process {
        # [2.Process] パイプラインから渡されたサーバー1台ずつ処理を繰り返す
        Write-Verbose "[Process] サーバー '$ComputerName' を処理中..."

        # 呼び出し元の-ErrorActionに関係なく、サービス取得の失敗は必ずcatchに飛んで結果を記録するロジック
        try {
            $service = Get-Service -Name $ServiceName -ComputerName $ComputerName -ErrorAction Stop
            $statusObject = [PSCustomObject]@{
                Computer = $ComputerName
                Service  = $service.Name
                Status   = $service.Status
                Message  = "成功"
            }
        }
        catch {
            # エラーメッセージから改行を削除して整形
            $errorMessage = $_.Exception.Message -replace "(`r`n|`n|`r)"," "
            $statusObject = [PSCustomObject]@{
                Computer = $ComputerName
                Service  = $ServiceName
                Status   = "Unknown"
                Message  = "エラー: $errorMessage"
            }
        }
        $results.Add($statusObject)
    }

    End {
        # [3.End] すべてのProcessブロックの処理が終了後に一度だけ実行
        Write-Verbose "[End] 最終処理を開始します。"
        
        if ($results.Count -gt 0) {
            if ($OutputPath) {
                try {
                    $results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
                    Write-Host "✅ 結果をCSVファイルに出力しました: $OutputPath" -ForegroundColor Green
                }
                catch {
                    # CSV出力に失敗した場合の代替え処理(フォールバック)
                    Write-Warning "CSVファイルへの出力に失敗しました。エラー: $($_.Exception.Message)"
                    Write-Host "`n"
                    Write-Host "--- CSV出力が失敗したため、結果をコンソールに表示します ---"
                    $results | Format-Table
                }
            } else {
                # 出力パスが指定されていなければ、結果をコンソールに表示
                $results | Format-Table
            }
        }

        # ★ 呼び出し元のErrorActionにより動作が変化する箇所
        #   実践的な例として、外部システムに処理完了を通知する関数が失敗したと仮定してエラーを発生させる
        Write-Error "完了通知をログサーバーに送信できませんでした。(テスト用のエラー)"

        Write-Verbose "[End] すべての処理が完了しました。"
    }
}

実践的なコードの説明

この関数は、前述したとおり「複数のサーバーに接続し、それぞれのサーバーで特定のサービスの状態を確認して、結果をCSVファイルに出力する」という内容でそれぞれの役割をブロックでわけて定義。

  • Beginブロック
    最初の1度だけ、全サーバーの結果をまとめるための「空のリスト」を準備します。
    なお、呼び出し側のErrorActionを検証するため、出力先の親ディレクトリーを対象に存在チェックを行っています。
  • Processブロック
    サーバー1台ずつに接続を試み、その結果を整形して$resultsリストに溜めていきます。
  • Endブロック
    最後に一度だけ、まとまった結果をCSVファイルに出力(Export-Csvを使って書き出し)。
    なお、呼び出し側のErrorActionを検証するため、ログサーバーに接続できなかったものとして意図的にエラー(非終了エラー)を発生させています。

以上のようにBeginProcessEndのそれぞれがしっかりと役割を果たしています。

コピー用
$serverList = "localhost", "127.0.0.1", "8.8.8.8", "Dev-WinOS", "169.254.130.39"

$serverList | Get-ServiceStatusOnServers -ServiceName "TermService" -OutputPath "D:\Downloads\ServiceStatus_TermService.csv" -Verbose
通常の実行イメージ
PS D:\Downloads> $serverList = "localhost", "127.0.0.1", "8.8.8.8", "Dev-WinOS", "169.254.130.39"
PS D:\Downloads>
PS D:\Downloads> $serverList | Get-ServiceStatusOnServers -ServiceName "TermService" -OutputPath "D:\Downloads\ServiceStatus_TermService.csv" -Verbose
詳細: [Begin] 処理を開始します。
詳細: [Process] サーバー 'localhost' を処理中...
詳細: [Process] サーバー '127.0.0.1' を処理中...
詳細: [Process] サーバー '8.8.8.8' を処理中...
詳細: [Process] サーバー 'Dev-WinOS' を処理中...
詳細: [Process] サーバー '169.254.130.39' を処理中...
詳細: [End] 最終処理を開始します。
✅ 結果をCSVファイルに出力しました: D:\Downloads\ServiceStatus_TermService.csv

# ★意図的にエラーを発生させているので、実際には下記のエラーが表示されるが無視。
# Get-ServiceStatusOnServers : 完了通知をログサーバーに送信できませんでした。(テスト用のエラー)
# 発生場所 行:1 文字:15
# + ... erverList | Get-ServiceStatusOnServers -ServiceName "TermService" -Ou ...
# +                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#     + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
#     + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Get-ServiceSta
#    tusOnServers

詳細: [End] すべての処理が完了しました。

下記が出力されたCSVファイル(ServiceStatus_TermService.csv)の内容。

Server Service Status Message
localhost TermService Running 成功
127.0.0.1 TermService Running 成功
8.8.8.8 TermService Unknown エラー: サービス名 'TermService' のサービスが見つかりません。
Dev-WinOS TermService Running 成功
169.254.130.39 TermService Running 成功

このような構成にすることで「準備各個処理と結果収集最終的な出力」という流れが明確に分離できていることがわかります。

呼び出し元でErrorAtionを指定して実行してみる

  • エラーで即停止(ErrorAction:Stop
    実行する際に-ErrorActionで「Stop」を指定して実行。

    コピー用
    $serverList = "localhost", "127.0.0.1", "8.8.8.8", "Dev-WinOS", "169.254.130.39"
    
    # ErrorAction「Stop」を指定
    $serverList | Get-ServiceStatusOnServers -ServiceName "TermService" -OutputPath "X:\NO_EXISTS_FOLDER\ServiceStatus_TermService.csv" -Verbose -ErrorAction Stop
    
    実行結果
    PS D:\Downloads> $serverList | Get-ServiceStatusOnServers -ServiceName "TermService" -OutputPath "X:\NO_EXISTS_FOLDER\ServiceStatus_TermService.csv" -Verbose -ErrorAction Stop
    詳細: [Begin] 処理を開始します。
    Get-ServiceStatusOnServers : 出力先の親ディレクトリが存在しません: X:\NO_EXITS_FOLDER
    発生場所 行:1 文字:15
    + ... erverList | Get-ServiceStatusOnServers -ServiceName "TermService" -Ou ...
    +                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
        + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Get-ServiceSta
    tusOnServers
    
    PS D:\Downloads>
    

    想定通りBeginブロック内で発生したエラーで処理が中断されました。
    なお、途中で処理がとまったのでCSVファイル(ServiceStatus_TermService.csv)は出力されていません。

  • 初期値の動作(ErrorAction:Continue
    $ErrorActionPreferenceの規定値がContinueのため、-ErrorActionで何も指定せずに実行。

    コピー用
    $serverList = "localhost", "127.0.0.1", "8.8.8.8", "Dev-WinOS", "169.254.130.39"
    
    # ErrorActionの指定なしで規定値の「Continue」で動く
    $serverList | Get-ServiceStatusOnServers -ServiceName "TermService" -OutputPath "X:\NO_EXISTS_FOLDER\ServiceStatus_TermService.csv" -Verbose
    
    実行結果
    PS D:\Downloads> $ErrorActionPreference
    Continue
    PS D:\Downloads> 
    PS D:\Downloads> $serverList | Get-ServiceStatusOnServers -ServiceName "TermService" -OutputPath "X:\NO_EXISTS_FOLDER\ServiceStatus_TermService.csv" -Verbose
    詳細: [Begin] 処理を開始します。
    Get-ServiceStatusOnServers : 出力先の親ディレクトリが存在しません: X:\NO_EXITS_FOLDER
    発生場所 行:1 文字:15
    + ... erverList | Get-ServiceStatusOnServers -ServiceName "TermService" -Ou ...
    +                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
        + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Get-ServiceSta
    tusOnServers
    
    詳細: [Process] サーバー 'localhost' を処理中...
    詳細: [Process] サーバー '127.0.0.1' を処理中...
    詳細: [Process] サーバー '8.8.8.8' を処理中...
    詳細: [Process] サーバー 'Dev-WinOS' を処理中...
    詳細: [Process] サーバー '169.254.130.39' を処理中...
    詳細: [End] 最終処理を開始します。
    警告: CSVファイルへの出力に失敗しました。エラー: ドライブが見つかりません。名前 'X'
    のドライブが存在しません。
    
    --- CSV出力が失敗したため、結果をコンソールに表示します ---
    
    Computer       Service      Status Message
    --------       -------      ------ -------
    localhost      TermService Running 成功
    127.0.0.1      TermService Running 成功
    8.8.8.8        TermService Unknown エラー: サービス名 'TermService' のサービスが見つかりませ...
    Dev-WinOS      TermService Running 成功
    169.254.130.39 TermService Running 成功
    
    
    Get-ServiceStatusOnServers : 完了通知をログサーバーに送信できませんでした。(テスト用のエラー)
    発生場所 行:1 文字:15
    + ... erverList | Get-ServiceStatusOnServers -ServiceName "TermService" -Ou ...
    +                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
        + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Get-ServiceSta
    tusOnServers
    
    詳細: [End] すべての処理が完了しました。
    PS D:\Downloads>
    

    途中で処理は止まっていないですが、親フォルダーが存在しなかったため、結果はコンソールに出力されました。
    CSVファイル(ServiceStatus_TermService.csv)は出力されていません。

    コンソール出力結果
    Computer       Service      Status Message
    --------       -------      ------ -------
    localhost      TermService Running 成功
    127.0.0.1      TermService Running 成功
    8.8.8.8        TermService Unknown エラー: サービス名 'TermService' のサービスが見つかりませ...
    Dev-WinOS      TermService Running 成功
    169.254.130.39 TermService Running 成功
    
補足情報:Get-Serviceで確認する主要なサービス一覧
表示名 (DisplayName) サービス名 (Name) チェックする理由・役割
Background Intelligent Transfer Service BITS 【ネットワーク/安定性】
Windows Updateなどで使用される、ネットワークのアイドル帯域を利用してファイルを賢く転送するサービス。これが停止していると、Windows Updateなどが失敗する原因になる。
※サンプルコードの規定値として使用
Windows Update wuauserv 【セキュリティ/安定性】
Windowsのセキュリティ更新プログラムや機能更新を管理する最重要サービス。これが停止していると、PCが脆弱な状態のままとなる。BITSと密接に関連している。
DNS Client Dnscache 【ネットワーク】
Webサイトのドメイン名をIPアドレスに変換した結果を一時保存(キャッシュ)する。「インターネットに繋がらない」「特定のサイトが開けない」といった問題の際に確認。
DHCP Client Dhcp 【ネットワーク】
ルーターなどからIPアドレスを自動的に取得・管理。これが停止しているとIPアドレスが取得できないため、ネットワークに接続できなくなる。
Print Spooler Spooler 【基本機能】
印刷ジョブを管理。「印刷ができない」「印刷キューからジョブが消えない」といった印刷関連のトラブルの際に、まず確認・再起動する対象。
Windows Time W32Time 【安定性/セキュリティ】
コンピューターの時刻をインターネット上の時刻サーバーと同期させる。時刻のズレは、Webサイトの証明書エラーや、企業環境での認証エラーの原因となる。
Remote Procedure Call (RPC) RpcSs 【システム基盤】
多くのWindowsサービスやアプリケーションが互いに通信するための基盤となり、非常に重要なサービス。これが停止すると、Windows自体が正常に動作しない。依存関係が極めて多いのが特徴。
Windows Audio AudioSrv 【基本機能】
Windowsの音声機能を管理。「PCから音が出ない」という問題の際に確認。AudioEndpointBuilderという関連サービスも重要。
Windows Defender Firewall MpsSvc 【セキュリティ/ネットワーク】
Windows標準のファイアウォール機能。「特定のアプリケーションが通信できない」「ネットワーク共有にアクセスできない」といった問題が、この設定に起因することがある。
Remote Desktop Services TermService 【リモート接続】
他のコンピューターからリモートデスクトップ接続を受け付けるために必要。「リモートデスクトップで接続できない」という場合に確認。
※サンプルコードを実行する際の引数として実際に使用
Cryptographic Services CryptSvc 【セキュリティ/安定性】
ファイルの署名検証や証明書の管理など、Windowsのセキュリティの根幹を支えるサービスの一つ。Windows Updateが正常に動作するためにも必要。

これで具体的な活用方法のイメージができたのではないでしょうか。ツール化やシステム化する対象が大規模であればあるほど、関数の高度化の恩恵を受けるでしょう。

まとめ

この記事では、PowerShellの関数をより強力で再利用性の高いツールにするため、Begin, Process, Endブロックを使った高度な関数について検証・解説しました。

最後に、foreachを使った関数と比較し重要なポイントをまとめてみます。

  • foreach関数は「バッチ処理(一括実行)」:

    • シンプルで直感的だが、最初に全データをメモリに読み込むため、巨大なデータには不向き。
    • パイプラインで渡すと、最後のデータしか処理できない。
  • Begin/Process/End関数は「ストリーム処理(逐次実行)」:

    • データを1つずつ効率的に処理するため、メモリ効率が非常に高い
    • Get-Contentなどの標準コマンドレットとパイプライン(|)でシームレスな連携が可能。
    • -ErrorActionパラメーターとの連携により、呼び出し側で柔軟にエラー制御が可能。

「PowerShellらしい」堅牢で効率的なスクリプトを目指すなら、パイプラインを意識し、Begin/Process/Endブロックを使いこなすことが重要そうです。
実行するときにパイプラインを使用するというのが、最初は慣れなそうですが今後は意識的に使っていこうと思います!

参考文献

https://qiita.com/mkht/items/24da4850f9d000b35fc4

https://learn.microsoft.com/ja-jp/powershell/module/microsoft.powershell.core/about/about_commonparameters#-erroraction

関連記事

https://haretokidoki-blog.com/pasocon_powershell-startup/
https://zenn.dev/haretokidoki/articles/7e6924ff0cc960
https://zenn.dev/haretokidoki/articles/fb6830f9155de5

GitHubで編集を提案

Discussion