導入
私はSasaki。Windowsクライアントの設計レビューと改修を長くやっている、少し口うるさい先輩だ。
ある日の午後、後輩の Go(22歳)が、管理ツールのデモを見せてくれた。
C# の GUI からサーバーのサービス一覧を取って表示する、社内向けの小さなツールだ。
「これ、中で powershell.exe を起動して Get-Service の標準出力を読んでるんです。
出力は正規表現でパースすればいけます。もう動いてますし」
実際、Go のマシンでは動いていた。
だが翌週、検証サーバーに置いた途端に報告が来た。
- サービス名が途中で
...になって、別サービスとして扱われる - 列の位置がズレて、Status に DisplayName の切れ端が入る
- たまに行が丸ごと欠ける
Go のコードは、Get-Service の 表形式の画面出力 を Substring と正規表現で切り出していた。
つまり Go は、自社のツールから、自社のシェルの画面を スクレイピング していたことになる。
そして画面というやつは、コンソール幅が変わるだけで平気でレイアウトを変える。
何が起きていたのか
PowerShell の出力は、本来テキストではない。
Get-Process の結果はプロセスの オブジェクト で、Get-Service の結果はサービスの オブジェクト だ。
コンソールに表が出るのは、最後の最後に表示用フォーマッタが人間向けに整形しているからにすぎない。
Go がやっていたのは、こういう流れだ。
PowerShell の出力(オブジェクト)
↓ 表示用に整形される(列幅はコンソール依存、長い値は "..." に省略)
文字列
↓ Split / 正規表現 / Substring
C# の値(たまに壊れている)
この方法は 表示形式に依存 する。列幅、ロケール、改行、値の中の空白や区切り文字。
どれか 1 つ変わるだけで、パーサーごと壊れる。Go のツールが検証サーバーで壊れたのは、ネットワークでも権限でもなく、コンソールの都合だった。
「えぇ……じゃあどうすれば」
オブジェクトのまま受け取ればいい。そのための SDK がある。
そこで読んでもらった記事
私は Go に、次の記事を送った。
C#(CSharp)でPowerShellを実行して、オブジェクトとして受け取る方法
https://comcomponent.com/blog/2026/06/08/001-csharp-run-powershell-receive-objects/
「正規表現を直す前にこれを読め。
パーサーを強化する話じゃない。パーサーを不要にする 話だ」
夕方、Go が言った。
「……Get-Service の結果って、最初からオブジェクトだったんですね。
わざわざテキストに落としてたのは、俺のほうだった」
そういうことだ。
PowerShell SDK でオブジェクトのまま受け取る
C# から PowerShell を呼ぶ方法は大きく 2 つある。
| 方法 | 特徴 | 向いている場面 |
|---|---|---|
ProcessStartInfo で powershell.exe / pwsh.exe を起動 |
標準出力を文字列として読む | 既存バッチの単純実行、ログを残すだけの処理 |
System.Management.Automation.PowerShell(PowerShell SDK) |
結果を PSObject として受け取れる |
C# 側で結果を加工する処理、管理ツール、業務アプリ |
結果を C# で使うなら後者だ。NuGet で SDK を入れる。
dotnet add package Microsoft.PowerShell.SDK --version 7.4.16
(SDK のバージョンは対象 .NET に合わせて選び、固定しておく。.NET 8 なら 7.4 系が使いやすい)
最小コードはこうなる。
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Management.Automation;
using PowerShell ps = PowerShell.Create();
Collection<PSObject> results = ps
.AddCommand("Get-Process")
.AddParameter("Id", Environment.ProcessId)
.Invoke();
foreach (PSObject item in results)
{
if (item.BaseObject is Process process)
{
Console.WriteLine($"{process.Id}: {process.ProcessName}");
}
}
Invoke() の戻り値は文字列ではなく Collection<PSObject>。
PSObject は PowerShell の出力値を包むラッパーで、BaseObject を見れば元の .NET オブジェクト(この例なら System.Diagnostics.Process)がそのまま取れる。
正規表現の出番は、最初から無い。
パイプラインも C# から組める
AddCommand を続けて呼ぶと、前のコマンドの出力が次へ渡る。つまりパイプラインになる。
Collection<PSObject> rows = ps
.AddCommand("Get-Process")
.AddCommand("Sort-Object")
.AddParameter("Property", "CPU")
.AddParameter("Descending", true)
.AddCommand("Select-Object")
.AddParameter("First", 10)
.AddParameter("Property", new[] { "Name", "Id", "CPU" })
.Invoke();
foreach (PSObject row in rows)
{
Console.WriteLine($"{row.Properties["Id"]?.Value}: {row.Properties["Name"]?.Value}");
}
これは PowerShell で書くこれと等価だ。
Get-Process | Sort-Object -Property CPU -Descending |
Select-Object -First 10 -Property Name, Id, CPU
値の取り出し方は 2 通りあって、使い分けはこうなる。
| 取り出し方 | 使う場面 |
|---|---|
BaseObject |
コマンドが返した元の .NET オブジェクトをそのまま使う |
Properties["列名"] |
Select-Object や [pscustomobject] で整形した列を読む |
そして 1 つ、絶対のルールがある。
C# に渡す前に Format-Table を使わない。
Format-Table を通すと、結果はサービスのオブジェクトではなく「表示用のフォーマット情報」に化ける。Go が画面でスクレイピングしていたアレが、オブジェクトの世界に出張してくるわけだ。
画面で人間が見る → Format-Table / Format-List
C# で後続処理に使う → Select-Object / [pscustomobject]
Goがやらかしかけた第二の罠
直している途中で、Go のコードにもう 1 つ見つけた。検索パスをユーザーが指定できる機能だ。
// Goが書いていたコード(避ける例)
string script = $"Get-ChildItem -Path '{userInputPath}'";
ps.AddScript(script).Invoke();
「何かまずいですか? ちゃんとクォートしてますよ」
まずい。ユーザー入力を PowerShell のコードとして 文字列連結している。
入力にクォートやサブ式が混ざれば、入力が「値」ではなく「コード」として解釈される余地が生まれる。シェルに対する SQL インジェクションと同じ構図だ。
値は AddParameter で渡す。
Collection<PSObject> files = ps
.AddCommand("Get-ChildItem")
.AddParameter("Path", userInputPath)
.AddParameter("File", true)
.Invoke();
AddParameter で渡した値は、コード文字列に連結されるのではなく パラメーター値 として扱われる。
| 書き方 | 使いどころ |
|---|---|
AddCommand / AddParameter
|
C# から安全にコマンドを組み立てる。基本これ |
AddScript |
固定の短いスクリプト、既存スクリプトの読み込み |
文字列連結した AddScript
|
原則避ける |
エラーは戻り値の外を流れている
もう 1 つ、Go が見落としていたのがエラーだ。
PowerShell では出力とエラーは 別ストリーム なので、Invoke() の戻り値だけ見ていると、失敗が静かに素通りする。
Collection<PSObject> output = ps
.AddCommand("Get-Item")
.AddParameter("Path", @"C:\no-such-file.txt")
.Invoke();
if (ps.HadErrors)
{
foreach (ErrorRecord error in ps.Streams.Error)
{
Console.WriteLine($"Error: {error.Exception.Message}");
}
}
「一部失敗しても一覧は出したい」管理ツールなら、こうしてエラーストリームを回収して表示する。
「失敗したら全体を止めたい」なら、AddParameter("ErrorAction", "Stop") を付けて RuntimeException を catch し、例外として扱う。どちらにするかはツールの性格で決める。決めずに握りつぶすのだけが無い。
Goがどう直したか
-
powershell.exeの起動と標準出力のパースを全部捨て、PowerShell SDK に置き換えた - 正規表現パーサーを削除した。
Select-Objectで列を絞り、Propertiesで読むようにした -
PSObjectは PowerShell との境界でだけ扱い、すぐに C# のrecordに変換して内部に渡すようにした - ユーザー入力の埋め込み
AddScriptをやめ、AddCommand/AddParameterに統一した -
HadErrorsとStreams.Errorを確認し、失敗をログに残して画面にも出すようにした
「パーサーのテスト、20本くらい書いてたんですよ。
列幅が変わるケース、... に省略されるケース、って。全部要らなくなりました」
壊れやすいものを頑張ってテストするより、壊れようがない形にするほうが早い。
以後、C#×PowerShell のレビューで見る表
| 観点 | 確認すること |
|---|---|
| 受け取り方 | 標準出力の文字列パースをしていないか。SDK で PSObject として受けているか |
| Format | C# に渡す前に Format-Table / Format-List を使っていないか |
| 入力 | ユーザー入力を AddScript の文字列に連結していないか。AddParameter か |
| 境界 |
PSObject がアプリ内部まで漏れていないか。早めに C# の型へ変換しているか |
| エラー |
HadErrors / Streams.Error を見ているか。方針(続行か停止か)を決めているか |
| 環境 | 実行ユーザーの権限、必要モジュールの有無を確認したか |
まとめ
- PowerShell の出力はテキストではなく、最初からオブジェクト
- 標準出力の正規表現パースは「表示のスクレイピング」であり、列幅や省略で壊れる
- PowerShell SDK を使えば
Collection<PSObject>として受け取れて、パーサー自体が不要になる - C# に渡すなら
Format-TableではなくSelect-Object/[pscustomobject] - ユーザー入力は
AddScriptへの文字列連結ではなくAddParameterで渡す - エラーは別ストリーム。
Invoke()の戻り値だけ見ていると失敗を見落とす
頑丈なパーサーは要らない。
パースしなくていい形で受け取る のが、いちばん頑丈だ。
PSObject → record 変換のヘルパー実装、繰り返し実行用ラッパーの作り方、SDK バージョンの選び方、権限・32bit/64bit・モジュール配置・UI スレッド・配布サイズといった実務の注意点まで含めた詳細版はこちら。
https://comcomponent.com/blog/2026/06/08/001-csharp-run-powershell-receive-objects/
(了)