C#(CSharp)でPowerShellを実行して、オブジェクトとして受け取る方法

· 更新日: · · C#, CSharp, PowerShell, Windows, .NET, 自動化, 既存資産活用

更新履歴(9件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
非同期で停止したパイプラインを、完了を待たずに破棄していたのを直しました。`await running`が返るのと`BeginStop`で始めた停止が終わるのは別々に起きるため、`using PowerShell`のまま抜けると`Dispose`のあとで`EndStop`が破棄済みのインスタンスに触ります。しかもそこはスレッドプールのコールバックで、投げた例外を受け止める場所がありません。`using`をやめて`try`/`finally`にし、停止を`Task.Factory.FromAsync`で`Task`として持ってから`await`する形にしました(`EndStop`の例外もその`Task`に入るため、スレッドプールから漏れません)。7.x向けと5.1向けの両方の例を同じ形に揃えています。
Windows PowerShell 5.1向けのフォールバック例で、キャンセルの登録がパイプラインの起動より先になっていたのを直しました。前回`CancellationToken`を足した際、`Task.Run`の中で「弾く→登録する→`Invoke()`」の順に書いたため、登録と起動の間に来たキャンセルが空振りします。まだ走っていないパイプラインへ`BeginStop`が飛び、そのあと`Invoke()`が起動して、止める合図はもう消費済みという状態です。同じ節の`InvokeAsync`の例で避けている罠を再現させてしまっていました。同期の`Invoke()`では「走らせてから登録する」が書けないため、`BeginInvoke`/`EndInvoke`に分け、`Task.Factory.FromAsync`で包む形に変更しています。
Windows PowerShell 5.1向けのフォールバック例に、キャンセルの口を追加しました。`Task.Run`で同期版の`Invoke()`を包むだけでは、UIスレッドは自由になりますが走っているパイプラインには誰も触れません。利用者がキャンセルを押しても、ハングしたコマンドは裏で走り続けます。同じ節の4点目で「止める口を必ず用意する」と書きながら、この例だけが`PowerShell`インスタンスをメソッド内に隠したままでした。`CancellationToken`を受け取り、`Register`で`BeginStop`を呼ぶ形に変更しています(同期の`Stop()`はパイプラインが止まるまで返らないため、Cancelを押したUIスレッドを待たせません)。
キャンセルの登録をパイプラインの起動より先に置いていたのを直しました。`Register`は既にキャンセル済みのトークンならその場でコールバックを実行するため、まだ始まっていないパイプラインに`BeginStop`が飛んで空振りし、そのあと`InvokeAsync`がコマンドを起動して、止める合図はもう消費済みという状態になります。ウィンドウを閉じてもコマンドが走り続けます。先にキャンセル済みかを確認し、起動してから登録する順に直しました。
画面アプリの例で、`PowerShell`インスタンスをメソッドのローカル変数に隠したままにしていたため、呼び出し側が`Stop()`を呼ぶ手段を持てませんでした。すぐ下に「途中でやめたい場合は`Stop()`を呼ぶ」と書いていながら、サンプルではそれが実装できない状態です。コマンドがハングしたときや利用者がウィンドウを閉じたときに、ボタンを無効にしたまま待ち続ける画面になります。`CancellationToken`を受け取り、キャンセル時に`BeginStop`でパイプラインを止める形にし、中止ボタンとウィンドウを閉じたときの呼び出し側も追加しました。`PipelineStoppedException`は失敗ではなく正常な経路として捕まえる点も明記しています。
`Where-Object`の簡易構文をパラメーターの割り当てで組み立てる例を直しました。`-EQ`は値を取らないスイッチで、比べる値は`-Value`に渡します。`AddParameter("EQ", "Running")`と書くと割り当ての段階で失敗し、絞り込みが動きません。
画面アプリでUIスレッドを止めない実装例を`InvokeAsync`で書き直しました(二重実行の防止と`Stop()`でのキャンセルを含みます)。同時実行と初回の起動コストの節を新設し、実行中のインスタンスに再度Invokeすると例外になること、`RunspacePool`での並行実行を追加しました。.NET Framework向けに書く場合の違いも表に整理しています。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589840)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「C#(CSharp)でPowerShellを実行して、オブジェクトとして受け取る方法」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589840 https://staging.comcomponent.com/blog/2026/06/08/001-csharp-run-powershell-receive-objects/

DOI(最新版)
10.5281/zenodo.21589840
DOI(この版)
10.5281/zenodo.21732850

C# から PowerShell を実行したい場面は、業務アプリや社内ツールではよくあります。たとえば、次のような処理です。

  • Windows のサービス一覧を取得する
  • プロセスやイベントログを調べる
  • 既存の PowerShell スクリプトを C# アプリから呼び出す
  • 管理者向けの小さな GUI ツールから PowerShell コマンドを実行する
  • PowerShell 側にある既存の自動化資産を、.NET アプリに少しずつ取り込む

単純に実行するだけなら、powershell.exepwsh.exe を外部プロセスとして起動し、標準出力を文字列として読む方法でも動きます。ただ、その方法では PowerShell の良さである「オブジェクトのパイプライン」が失われます。

PowerShell の結果は、本来はただのテキストではありません。Get-Process の結果はプロセスのオブジェクトであり、Get-Service の結果はサービスのオブジェクトです。C# 側でもその構造を保ったまま受け取れると、文字列のパースが不要になり、処理がかなり安全になります。

この記事では、C# から PowerShell を実行し、結果を PSObject として受け取る基本を整理します。

なお、この記事に登場するコードは、ビルド・実行できるサンプル一式(実行ラッパーと変換処理のライブラリ、記事の各章を実演するコンソールデモ、PSObject の受け取りとエラー処理を検証するユニットテスト)として GitHub で公開しています。

csharp-run-powershell-receive-objects - komurasoft-blog-samples (GitHub)

1. 外部プロセス起動ではなく、PowerShell SDK を使う

C# から PowerShell を呼び出す方法は、大きく分けると2つあります。

方法 特徴 向いている場面
ProcessStartInfopowershell.exe / pwsh.exe を起動する 標準出力・標準エラーを文字列として読む 既存バッチの単純実行、ログを残すだけの処理
System.Management.Automation.PowerShell を使う 結果を PSObject として受け取れる C# 側で結果を加工する処理、管理ツール、業務アプリ

この記事で扱うのは後者です。System.Management.Automation.PowerShell を使うと、PowerShell のパイプラインを C# のコードから組み立てて実行できます。重要なのは、戻り値が文字列ではなく、基本的には Collection<PSObject> になることです。

つまり、次のような考え方になります。

PowerShell コマンドを実行する
  ↓
結果を PSObject のコレクションとして受け取る
  ↓
BaseObject や Properties から値を取り出す
  ↓
必要なら C# の DTO / record / class に変換する

PowerShell の出力を文字列で分解するのではなく、最初からオブジェクトとして扱うのがポイントです。

この記事の知識マップ

C#からPowerShellを呼ぶ場合、外部プロセスを起動し標準出力を文字列で読む方法は表示形式に依存し壊れやすいのに対し、PowerShell SDKを使うと結果をPSObjectのコレクションとして受け取れ、BaseObjectで元の.NETオブジェクトを、Propertiesで列名を指定した値を取り出せる。C#に渡すならFormat-Tableではなく、Select-Objectや[pscustomobject]で列を整えるべきで、ユーザー入力はAddScriptへ文字列連結せずAddCommand/AddParameterでパラメーター値として渡しコード注入を避ける。画面アプリではInvokeAsyncで非同期実行しつつ、CancellationTokenの登録はパイプライン起動より後に行わないと停止要求が空振りする。実行中の同一インスタンスへの再Invokeは例外になるため、並行実行にはRunspacePoolを使う。

C#からPowerShellをPSObjectとして実行する知識マップPowerShell SDKを使うとPowerShellの実行結果を文字列ではなくPSObjectとして受け取れること、BaseObjectとPropertiesの使い分け、AddParameterによるコード注入の回避、Select-ObjectとFormat-Tableの使い分け、InvokeAsyncでのキャンセルとRunspacePoolによる並行実行の要点を示す図利用する利用する利用する利用する利用する用いるのは非推奨推奨される対応推奨される対応推奨される対応原因になり得る防止する利用する両立しない利用する利用する利用する原因になり得る推奨される対応用いるのは非推奨で構成できる用いるのは非推奨推奨される対応利用する利用する原因になり得る防止するPowerShell SDK(System.Management.Automation.PowerShell)PSObjectAddCommand/AddParameterAddScriptC#側で結果を加工する処理PSObject.BaseObjectPSObject.PropertiesFormat-Table/Format-ListSelect-Object[pscustomobject]PowerShellへユーザー入力を渡す要件スクリプト注入リスクPowerShell.InvokeAsyncWindows PowerShell 5.1BeginInvoke/EndInvoke.NET Frameworkパイプライン停止API(Stop/BeginStop/EndStop)PipelineStoppedExceptionRunspacePool複数のPowerShell処理を並行実行したい要件ErrorAction Stop外部プロセスとしてのPowerShell起動CancellationTokenでパイプラインを安全に止める実装パターンCancellationToken(.NET)パイプライン起動より前のキャンセル登録登録と起動の間のキャンセル空振り

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全26件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. 前提環境

この記事では、.NET 8 のコンソールアプリを例にします。PowerShell SDK はバージョンによって対象の .NET が違うため、プロジェクトのターゲットフレームワークに合わせて選びます。

2026年6月時点では、たとえば次のように考えると分かりやすいです。

C# アプリのターゲット 例として使う PowerShell SDK 備考
.NET 8 Microsoft.PowerShell.SDK 7.4 系 .NET 8 アプリで使いやすい
.NET 10 Microsoft.PowerShell.SDK 7.6 系 新しい PowerShell SDK を使う場合の候補
.NET Framework Microsoft.PowerShell.5.1.ReferenceAssemblies Windows PowerShell 5.1 向け。新規開発では要件を確認する

ここでは .NET 8 の例として、Microsoft.PowerShell.SDK 7.4.16 を使います。

dotnet new console -n PowerShellObjectSample
cd PowerShellObjectSample
dotnet add package Microsoft.PowerShell.SDK --version 7.4.16

.csproj は、たとえば次のようになります。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.PowerShell.SDK" Version="7.4.16" />
  </ItemGroup>

</Project>

バージョンは固定しておくのがおすすめです。PowerShell SDK は便利ですが、アプリの実行環境、対象 .NET、PowerShell モジュールの互換性の影響を受けます。業務アプリでは「開発環境で動いた最新バージョン」をそのまま使うよりも、動作確認したバージョンを明示しておく方が安全です。

.NET Framework 向けに書く場合、何が違うか

保守対象が .NET Framework の Windows Forms アプリや WPF アプリで、そこから PowerShell を呼びたい、という場合もあります。この記事のコード例は現行 .NET を前提にしていますが、C# 側の書き方はほとんど同じです。違うのは、次の点です。

観点 現行 .NET + Microsoft.PowerShell.SDK .NET Framework + Microsoft.PowerShell.5.1.ReferenceAssemblies
実行される PowerShell パッケージに含まれる PowerShell 7 系 Windows に同梱されている Windows PowerShell 5.1
NuGet パッケージの役割 実体を含む 参照アセンブリのみ。実行時のアセンブリは OS 側
使える構文と cmdlet PowerShell 7 系の範囲 5.1 の範囲。ForEach-Object -Parallel??、三項演算子などは使えない
モジュールの探索先 PowerShell 7 の $env:PSModulePath Windows PowerShell の $env:PSModulePath
非同期実行 InvokeAsync が使える BeginInvoke / EndInvoke、または Task.RunInvoke() を包む
C# 側の書き方 PowerShell.Create()AddCommandAddParameterInvoke()Collection<PSObject> 同じ
配布サイズ SDK 一式を同梱するので大きい OS 側を使うので小さい

.csproj は、たとえば次のようになります。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.PowerShell.5.1.ReferenceAssemblies" Version="1.0.0" />
  </ItemGroup>

</Project>

実務上いちばん効いてくるのは、実行されるのが Windows PowerShell 5.1 だという点です。既存の PowerShell スクリプトを取り込む場合、そのスクリプトが PowerShell 7 で書かれていると、5.1 では構文エラーになることがあります。逆に、Windows PowerShell 5.1 でしか動かない古いモジュールを使いたい、という理由でこちらを選ぶこともあります。

なお、この記事の 3章から12章までのコードは、InvokeAsync を使っていないため、そのまま .NET Framework 側でも通用します。非同期化まわりだけ、13章で改めて触れます。

3. 最小コード:PowerShell を実行して PSObject を受け取る

まずは、現在の C# アプリ自身のプロセスを PowerShell から取得してみます。

using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Management.Automation;

int currentProcessId = Environment.ProcessId;

using PowerShell ps = PowerShell.Create();

Collection<PSObject> results = ps
    .AddCommand("Get-Process")
    .AddParameter("Id", currentProcessId)
    .Invoke();

foreach (PSObject item in results)
{
    Console.WriteLine($"PSObject type: {item.GetType().FullName}");
    Console.WriteLine($"BaseObject type: {item.BaseObject.GetType().FullName}");

    if (item.BaseObject is Process process)
    {
        Console.WriteLine($"Id: {process.Id}");
        Console.WriteLine($"Name: {process.ProcessName}");
        Console.WriteLine($"Memory: {process.WorkingSet64:N0} bytes");
    }
}

ここで押さえたい点は3つあります。PowerShell.Create() で PowerShell の実行オブジェクトを作ること、AddCommand("Get-Process")AddParameter("Id", currentProcessId) でコマンドとパラメーターを組み立てていること、そして Invoke() の戻り値が Collection<PSObject> であることです。

PSObject は、PowerShell が出力する値を包むラッパーです。中にある元の .NET オブジェクトを見たい場合は、BaseObject を見ます。この例では、Get-Process の結果の中身が System.Diagnostics.Process として取り出せます。

4. BaseObject と Properties の使い分け

C# で PowerShell の結果を扱うとき、最初に迷うのが次の2つです。

item.BaseObject
item.Properties["Name"]?.Value

どちらを使うかの目安は次の通りです。

取り出し方 使う場面
BaseObject PowerShell が返した元の .NET オブジェクトをそのまま使いたい場合
Properties["..."] Select-Object[pscustomobject] で作った列を取り出したい場合

Get-Process のようなコマンドをそのまま実行した場合、BaseObject には元の .NET オブジェクトが入っていることがあります。一方で、PowerShell 側で Select-Object を使って列を整形した場合、結果は PowerShell のカスタムオブジェクトとして返ってくることが多くなります。その場合は、Properties から列名で値を取る方が自然です。

5. Select-Object した結果を C# で読む

実務では、PowerShell から返ってくる全プロパティが必要なことはあまりありません。必要な列だけ C# 側に渡したい場合は、PowerShell のパイプラインで Select-Object を使います。

using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;

using PowerShell ps = PowerShell.Create();

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", "WorkingSet" })
    .Invoke();

foreach (PSObject row in rows)
{
    string name = Convert.ToString(row.Properties["Name"]?.Value, CultureInfo.InvariantCulture) ?? "";
    int id = Convert.ToInt32(row.Properties["Id"]?.Value, CultureInfo.InvariantCulture);
    double? cpu = row.Properties["CPU"]?.Value is null
        ? null
        : Convert.ToDouble(row.Properties["CPU"]!.Value, CultureInfo.InvariantCulture);
    long workingSet = Convert.ToInt64(row.Properties["WorkingSet"]?.Value, CultureInfo.InvariantCulture);

    Console.WriteLine($"{id}: {name}, CPU={cpu}, WorkingSet={workingSet:N0}");
}

このコードは、PowerShell では次のようなパイプラインに相当します。

Get-Process |
  Sort-Object -Property CPU -Descending |
  Select-Object -First 10 -Property Name, Id, CPU, WorkingSet

C# から見ると、AddCommand を続けて呼ぶことで、PowerShell のパイプラインを作っています。

.AddCommand("Get-Process")
.AddCommand("Sort-Object")
.AddCommand("Select-Object")

このように書くと、前のコマンドの出力が次のコマンドへ渡されます。

Select-Object で列を絞った後は、row.Properties["Name"]?.Value のように列名で値を取り出します。

6. C# の record に変換する

PSObject のままアプリ全体に渡すと、後続のコードが PowerShell に依存しすぎます。画面表示や業務処理に使うなら、C# 側の型に変換した方が扱いやすくなります。

たとえば、プロセス情報を次の record に変換します。

public sealed record ProcessSummary(
    string Name,
    int Id,
    double? Cpu,
    long WorkingSet);

変換処理は次のように分けておくと、見通しが良くなります。

using System.Globalization;
using System.Management.Automation;

static ProcessSummary ToProcessSummary(PSObject row)
{
    string name = GetString(row, "Name");
    int id = GetInt32(row, "Id");
    double? cpu = GetNullableDouble(row, "CPU");
    long workingSet = GetInt64(row, "WorkingSet");

    return new ProcessSummary(name, id, cpu, workingSet);
}

static string GetString(PSObject row, string propertyName)
{
    return Convert.ToString(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture) ?? "";
}

static int GetInt32(PSObject row, string propertyName)
{
    return Convert.ToInt32(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}

static long GetInt64(PSObject row, string propertyName)
{
    return Convert.ToInt64(row.Properties[propertyName]?.Value, CultureInfo.InvariantCulture);
}

static double? GetNullableDouble(PSObject row, string propertyName)
{
    object? value = row.Properties[propertyName]?.Value;
    return value is null ? null : Convert.ToDouble(value, CultureInfo.InvariantCulture);
}

使う側はこうなります。

List<ProcessSummary> processes = rows
    .Select(ToProcessSummary)
    .ToList();

foreach (ProcessSummary process in processes)
{
    Console.WriteLine($"{process.Id}: {process.Name}");
}

PSObject は PowerShell との境界で扱い、アプリ内部では ProcessSummary のような通常の C# の型に変換する。

この分離をしておくと、後で PowerShell コマンドを変更しても、影響範囲を小さくできます。

7. PSCustomObject を返すと C# 側で扱いやすい

PowerShell 側で複数の値をまとめて返したい場合は、[pscustomobject] を使うと便利です。

using System.Collections.ObjectModel;
using System.Management.Automation;

string script = @"
[pscustomobject]@{
    MachineName       = [System.Environment]::MachineName
    PowerShellVersion = $PSVersionTable.PSVersion.ToString()
    CurrentDirectory  = (Get-Location).Path
}
";

using PowerShell ps = PowerShell.Create();

Collection<PSObject> rows = ps
    .AddScript(script, useLocalScope: true)
    .Invoke();

foreach (PSObject row in rows)
{
    Console.WriteLine($"MachineName: {row.Properties["MachineName"]?.Value}");
    Console.WriteLine($"PowerShell:  {row.Properties["PowerShellVersion"]?.Value}");
    Console.WriteLine($"Directory:   {row.Properties["CurrentDirectory"]?.Value}");
}

PowerShell スクリプトの最後で [pscustomobject] を返すと、C# 側では Properties から名前で値を取り出せます。複雑な文字列を返して C# 側で分割するよりも、かなり安全です。

避けたい例は、次のような出力です。

"$MachineName,$PowerShellVersion,$CurrentDirectory"

この方法は一見簡単ですが、値の中にカンマや改行が入ると壊れます。

PowerShell 側ではオブジェクトを返し、C# 側ではプロパティとして読む。この形にしておくと、後から列が増えても対応しやすくなります。

8. AddScript にユーザー入力を直接埋め込まない

PowerShell SDK を使う場合でも、文字列としてスクリプトを組み立てると危険です。たとえば、次のようなコードは避けた方がよいです。

// 避ける例
string userInputPath = GetPathFromUser();
string script = $"Get-ChildItem -Path '{userInputPath}'";

using PowerShell ps = PowerShell.Create();
ps.AddScript(script).Invoke();

この書き方では、ユーザー入力が PowerShell のコードとして解釈される余地があります。PowerShell コマンドに値を渡す場合は、できるだけ AddCommandAddParameter を使います。

string userInputPath = GetPathFromUser();

using PowerShell ps = PowerShell.Create();

Collection<PSObject> files = ps
    .AddCommand("Get-ChildItem")
    .AddParameter("Path", userInputPath)
    .AddParameter("File", true)
    .Invoke();

AddParameter で渡した値は、PowerShell のコード文字列として連結されるのではなく、パラメーター値として扱われます。

実務では、次のように使い分けるのが無難です。

書き方 使いどころ
AddCommand / AddParameter C# 側から安全にコマンドを組み立てたい場合
AddScript 固定の短いスクリプトを実行する場合、既存スクリプトを読み込む場合
文字列連結した AddScript 原則避ける。使うなら入力値の検証とエスケープを慎重に行う

PowerShell を C# に組み込むと、アプリの機能として強い操作ができるようになります。便利な反面、ユーザー入力をそのままスクリプト化しない、という一線だけは守る必要があります。

9. Format-Table は最後の画面表示用。C# に渡す前には使わない

PowerShell の結果を C# でオブジェクトとして受け取りたい場合、Format-TableFormat-List は基本的に使いません。

たとえば、次のような PowerShell は人間が画面で見るには便利です。

Get-Service | Format-Table Name, Status

しかし、C# 側で受け取る前に Format-Table を使うと、結果はサービスのオブジェクトではなく、表示用のフォーマット情報になってしまいます。C# で扱いたい場合は、Select-Object を使います。

Get-Service | Select-Object Name, Status

C# から書くなら、次のようになります。

using PowerShell ps = PowerShell.Create();

Collection<PSObject> services = ps
    .AddCommand("Get-Service")
    .AddCommand("Select-Object")
        .AddParameter("Property", new[] { "Name", "Status" })
    .Invoke();

考え方はシンプルです。

画面で見やすくするだけ → Format-Table / Format-List
C# で後続処理に使う    → Select-Object / PSCustomObject

これは PowerShell 単体で使うときにも言えることですが、C# と連携する場合は特に効いてきます。

10. エラーを受け取る

PowerShell では、出力とエラーは別のストリームです。Invoke() の戻り値だけを見ていると、エラーを見落とすことがあります。基本形は次の通りです。

using System.Management.Automation;

using PowerShell ps = PowerShell.Create();

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}");
        Console.WriteLine($"Category: {error.CategoryInfo.Category}");
        Console.WriteLine($"Target: {error.TargetObject}");
    }
}

PowerShell のコマンドレットには、処理を止めるエラーと、処理を続けるエラーがあります。C# 側で例外として扱いたい場合は、ErrorActionStop を指定する方法があります。

using System.Management.Automation;

try
{
    using PowerShell ps = PowerShell.Create();

    Collection<PSObject> output = ps
        .AddCommand("Get-Item")
        .AddParameter("Path", @"C:\no-such-file.txt")
        .AddParameter("ErrorAction", "Stop")
        .Invoke();
}
catch (RuntimeException ex)
{
    Console.WriteLine($"PowerShell failed: {ex.Message}");
}

どちらが良いかは、アプリの性質によります。管理ツールで「一部失敗しても一覧は出したい」場合は、エラーストリームを回収して画面に表示する方が向いていますし、「失敗したら処理全体を止めたい」場合は、ErrorAction Stop で例外として扱う方が分かりやすくなります。

11. 小さな実行ラッパーを作る

何度も PowerShell を呼び出すアプリでは、毎回同じエラー処理を書くと散らかります。簡単なラッパーを用意しておくと便利です。

using System.Management.Automation;

public sealed record PowerShellRunResult(
    IReadOnlyList<PSObject> Output,
    IReadOnlyList<ErrorRecord> Errors);

public static class PowerShellRunner
{
    public static PowerShellRunResult Run(Action<PowerShell> build)
    {
        using PowerShell ps = PowerShell.Create();

        build(ps);

        List<PSObject> output;

        try
        {
            output = ps.Invoke().ToList();
        }
        catch (RuntimeException ex)
        {
            throw new InvalidOperationException($"PowerShell execution failed: {ex.Message}", ex);
        }

        return new PowerShellRunResult(
            Output: output,
            Errors: ps.Streams.Error.ToList());
    }
}

使う側は、コマンドの組み立てだけに集中できます。

PowerShellRunResult result = PowerShellRunner.Run(ps => ps
    .AddCommand("Get-Service")
    .AddCommand("Where-Object")
        .AddParameter("Property", "Status")
        .AddParameter("EQ", "Running")
    .AddCommand("Select-Object")
        .AddParameter("First", 10)
        .AddParameter("Property", new[] { "Name", "DisplayName", "Status" }));

foreach (PSObject row in result.Output)
{
    Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
}

foreach (ErrorRecord error in result.Errors)
{
    Console.Error.WriteLine(error.Exception.Message);
}

ただし、この例の Where-Object のように、PowerShell 特有の条件指定を C# から組み立てると少し読みにくくなることがあります。単純なコマンドとパラメーターは AddCommand / AddParameter でよいですが、複雑なフィルターや集計は、固定の PowerShell スクリプトとして用意した方が読みやすい場合もあります。その場合でも、外部入力をスクリプト文字列へ直接連結しない方針は変わりません。

12. 複雑な処理は PowerShell 側でオブジェクトに整える

C# と PowerShell を組み合わせるときは、どちらで何を担当するかを分けると設計しやすくなります。

おすすめは次の分担です。

担当 やること
PowerShell Windows やモジュールに近い操作、既存スクリプト、管理コマンドの実行
C# UI、入力検証、型変換、業務ロジック、保存、API 連携

PowerShell 側では、最終的な出力を [pscustomobject] に整えます。

Get-Service |
  Where-Object Status -eq 'Running' |
  Select-Object Name, DisplayName, Status

または、明示的に [pscustomobject] を作ります。

$services = Get-Service | Where-Object Status -eq 'Running'

[pscustomobject]@{
    Count = $services.Count
    Names = $services.Name
}

C# 側では、返ってきた PSObject のプロパティを読んで、自分のアプリの型に変換します。

この形にしておくと、PowerShell の細かい実装を C# 側に漏らしすぎずに済みます。

13. 実務でよくある注意点

C# から PowerShell を実行する場合、コードが動くだけでは不十分です。実務では、次の点を早めに確認しておくと安全です。

実行ユーザーの権限

PowerShell は、C# アプリを実行しているユーザーの権限で動きます。管理者権限が必要なコマンドは、通常ユーザーで実行しても失敗します。サービス操作、イベントログ、証明書、レジストリ、Hyper-V、Microsoft 365 管理系のモジュールなどでは、権限の切り分けが必要になります。

32bit / 64bit の違い

Windows では、32bit プロセスと 64bit プロセスで見えるレジストリやモジュールが変わることがあります。Windows 管理用ツールとして作るなら、基本的には x64 で動かす前提にした方がトラブルを減らせます。

実行環境にモジュールがあるか

C# アプリに PowerShell SDK を入れても、すべての PowerShell モジュールが自動的に入るわけではありません。たとえば、特定製品の管理モジュールや社内モジュールを使う場合は、実行環境にそのモジュールが存在するか、どのパスから読み込むかを確認する必要があります。

画面アプリでは UI スレッドを止めない

WinForms や WPF から PowerShell を実行する場合、重い処理を UI スレッドで直接実行すると画面が固まります。その場合は、バックグラウンド処理として実行し、完了後に UI を更新する設計にします。

PowerShell SDK には非同期実行の API があるので、それを使うのがいちばん素直です。PowerShell.InvokeAsyncTask<PSDataCollection<PSObject>> を返すので、そのまま await できます。

まず、PowerShell を呼ぶ部分を、UI に触れないメソッドとして切り出します。

using System.Management.Automation;

// 以下の2つのメソッドは、ウィンドウやフォームのクラスの中に置く想定です。
//
// cancellationToken を必ず受け取ります。PowerShell インスタンスをこのメソッドの
// ローカル変数に隠したままにすると、呼び出し側は Stop() を呼ぶ手段を持てません。
// コマンドがハングしたり、利用者が画面を閉じたりしたときに、await している
// ハンドラーはボタンを無効にしたまま待ち続けることになります
private static async Task<IReadOnlyList<PSObject>> GetRunningServicesAsync(
    CancellationToken cancellationToken)
{
    // using にしません。停止の完了を待ってから破棄する必要があるためです(後述)
    PowerShell ps = PowerShell.Create();
    Task? stopping = null;

    try
    {
        // `-EQ` は値を取らないスイッチで、比べる値は `-Value` に渡します
        // (簡易構文は `-Property <String> -EQ -Value <Object>`)。
        // AddParameter("EQ", "Running") と書くとパラメーターの割り当てで失敗します
        ps.AddCommand("Get-Service")
          .AddCommand("Where-Object")
              .AddParameter("Property", "Status")
              .AddParameter("EQ")
              .AddParameter("Value", "Running")
          .AddCommand("Select-Object")
              .AddParameter("Property", new[] { "Name", "DisplayName", "Status" });

        // 起動より先に登録しないこと。Register は、渡されたトークンが既に
        // キャンセル済みなら、その場でコールバックを実行します。まだ始まって
        // いないパイプラインに BeginStop が飛んで空振りし、そのあと InvokeAsync が
        // コマンドを起動して、止める合図はもう消費済み ── という形で、
        // ウィンドウを閉じても走り続けるコマンドができあがります。
        // 先に弾いてから、走らせて、それから止める口を付けます
        cancellationToken.ThrowIfCancellationRequested();

        Task<PSDataCollection<PSObject>> running = ps.InvokeAsync();

        // キャンセルされたらパイプラインを止めます。Stop() は停止し終わるまで戻らず、
        // キャンセルは UI スレッドから呼ばれることがあるので、非同期版を使います。
        // 停止そのものを Task として受け取り、下の finally で完了を待ちます
        using (cancellationToken.Register(
            state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
        {
            PSDataCollection<PSObject> output = await running;
            return output.ToList();
        }
    }
    finally
    {
        // 停止を要求したなら、その完了を待ってから破棄します。
        // await running が返るのと停止が終わるのは別々に起きるので、ここで
        // 待たずに Dispose すると、あとから走る EndStop が破棄済みの
        // PowerShell に触ります。しかもそれはスレッドプールのコールバックなので、
        // 投げた例外を受け止める場所がありません(プロセスごと落ちます)
        Task? pending = Volatile.Read(ref stopping);
        if (pending is not null)
        {
            try { await pending; }
            catch { /* 停止側の失敗で、本来の結果や例外を覆い隠さない */ }
        }

        ps.Dispose();
    }
}

// BeginStop / EndStop を Task に包みます。EndStop をコールバックの中で
// 直接呼ぶと、その例外がスレッドプールから漏れます
private static Task StopAsync(PowerShell ps) =>
    Task.Factory.FromAsync(ps.BeginStop, ps.EndStop, null);

呼び出し側は、ウィンドウやフォームのイベントハンドラーです。ここは async void で構いません。イベントハンドラーは async void が許される数少ない場所です。

// WPF のウィンドウクラスに置く想定です。
// WinForms なら RoutedEventArgs を EventArgs に、
// IsEnabled を Enabled に、ItemsSource を DataSource に
// 読み替えてください。
// 実行中のキャンセル用。中止ボタンとウィンドウを閉じたときの両方で使う
private CancellationTokenSource? _running;

private async void RunButton_Click(object sender, RoutedEventArgs e)
{
    RunButton.IsEnabled = false;

    using var cts = new CancellationTokenSource();
    _running = cts;

    try
    {
        IReadOnlyList<PSObject> services = await GetRunningServicesAsync(cts.Token);

        ResultList.ItemsSource = services
            .Select(row => row.Properties["Name"]?.Value?.ToString() ?? "")
            .ToList();
    }
    catch (PipelineStoppedException)
    {
        // 中止ボタンやウィンドウを閉じたときの正常な経路。何も出さない
    }
    catch (RuntimeException ex)
    {
        MessageBox.Show($"PowerShell failed: {ex.Message}");
    }
    finally
    {
        _running = null;
        RunButton.IsEnabled = true;
    }
}

private void CancelButton_Click(object sender, RoutedEventArgs e) => _running?.Cancel();

protected override void OnClosed(EventArgs e)
{
    _running?.Cancel();   // 閉じたのに裏でパイプラインが走り続ける状態を作らない
    base.OnClosed(e);
}

押さえておきたい点が4つあります。

  1. await から戻ったところは、WinForms でも WPF でも UI スレッドです。同期コンテキストが戻してくれるので、InvokeDispatcher.Invoke は要りません。
  2. 実行中はボタンを無効にします。無効にしないと、同じ処理を二重に走らせてしまいます。
  3. 例外は RuntimeException で受けます。ErrorActionStop にしていない場合はエラーストリームも確認します(10章)。
  4. 止める口を必ず用意します。PowerShell インスタンスをメソッドのローカル変数に隠したままにすると、呼び出し側は Stop() を呼べません。コマンドがハングしたときや利用者がウィンドウを閉じたときに、ボタンを無効にしたまま待ち続ける画面になります。

停止すると、待っている InvokeAsyncPipelineStoppedException を投げます。上のように、これは失敗ではなく正常な経路として捕まえてください。PowerShell の停止 API には同期の Stop()、非同期の BeginStop / EndStopStopAsync があります(末尾の参考情報を参照)。UI スレッドから止めることがあるので、待たせない非同期版を選びます。

そして、非同期で止めたなら、破棄する前にその完了を待ってください。await running が返るのと、BeginStop で始めた停止が終わるのは、別々に起きますusing PowerShell ps のまま抜けると、Dispose のあとで EndStop が走り、破棄済みのインスタンスに触ります。しかもそこはスレッドプールのコールバックで、投げた例外を受け止める try がどこにもありません ── アプリが終了間際に、原因不明で落ちます。上のコードで using をやめて try / finally にし、停止を Task として持ってから await しているのはこのためです。Task.Factory.FromAsync で包むことで、EndStop の例外もその Task に入り、スレッドプールから漏れなくなります。

Windows PowerShell 5.1 向けに書いていて InvokeAsync が使えない場合は、BeginInvoke / EndInvoke に分けます。Task.Run で同期版の Invoke() を包む書き方もありますが、それはキャンセルを捨てるとき限定です。Task.Run は UI スレッドを解放するだけで、走っているパイプラインには誰も触れません。利用者が「キャンセル」を押しても、ハングしたコマンドは裏で走り続けます。

using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;

private static async Task<IReadOnlyList<PSObject>> GetRunningServicesLegacyAsync(
    CancellationToken cancellationToken)
{
    PowerShell ps = PowerShell.Create();
    Task? stopping = null;

    try
    {
        ps.AddCommand("Get-Service")
          .AddCommand("Select-Object")
              .AddParameter("Property", new[] { "Name", "Status" });

        // 順序も後始末も、上の InvokeAsync の例とまったく同じです。
        // 先に弾いて、走らせて、それから止める口を付けます。
        // 同期の Invoke() では「走らせてから登録する」が書けないので、
        // BeginInvoke / EndInvoke に分ける必要があります
        cancellationToken.ThrowIfCancellationRequested();

        // BeginInvoke / EndInvoke を Task に包む。スレッドプールの1本を
        // 待たせ続ける Task.Run と違い、完了は PowerShell 側から通知されます
        Task<PSDataCollection<PSObject>> running =
            Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);

        using (cancellationToken.Register(
            state => Volatile.Write(ref stopping, StopAsync((PowerShell)state!)), ps))
        {
            // 停止されると PipelineStoppedException
            PSDataCollection<PSObject> output = await running;
            return output.ToList();
        }
    }
    finally
    {
        Task? pending = Volatile.Read(ref stopping);
        if (pending is not null)
        {
            try { await pending; } catch { }
        }

        ps.Dispose();
    }
}

Task.Run で包んでから Register する形にはしないでください。Task.Run の中で「弾く → 登録する → Invoke()」と書くと、登録と起動の間に来たキャンセルが空振りします。まだ走っていないパイプラインへ BeginStop が飛び、そのあと Invoke() がコマンドを起動して、止める合図はもう消費済み ── 上の InvokeAsync の例で避けたのと同じ罠が、そのまま再現します。

いずれの形でも、守るべき方針は同じです。UI スレッドで長時間の Invoke() をしないPowerShell を呼ぶメソッドから UI を直接触らない、そして呼び出し側が止められる口を、パイプラインを起動したあとに開ける、の3点です。

同時実行と初回の起動コスト

管理ツールでは、「1台ずつ処理していると遅いので並行にしたい」という要求がすぐ出てきます。ここで詰まりやすい点を先に挙げておきます。

まず、最初の Invoke() は遅いという性質があります。Runspace の初期化やモジュールの探索が最初の実行時に走るためです。起動直後の1回だけ時間がかかり、2回目以降は速い、というのは異常ではありません。計測するなら、1回目を除いて比較します。画面から呼ぶアプリでは、起動時に軽いコマンドを1回流してウォームアップしておく、という手もあります。

次に、1つの PowerShell インスタンスを複数スレッドで共有しないことです。実行中のインスタンスに対してもう一度 InvokeInvokeAsync を呼ぶと、「コマンドが既に開始されている」という理由で InvalidOperationException になります。並行して動かしたいなら、操作ごとに PowerShell.Create() します。

ただし、操作ごとに PowerShell.Create() すると、そのたびに Runspace が作られます。数が増えるなら RunspacePool を用意して、そこから使い回します。

using System.Management.Automation;
using System.Management.Automation.Runspaces;

static async Task<IReadOnlyList<PSObject>> GetServiceAsync(
    RunspacePool pool,
    string serviceName)
{
    using PowerShell ps = PowerShell.Create();
    ps.RunspacePool = pool;

    ps.AddCommand("Get-Service")
      .AddParameter("Name", serviceName)
      .AddCommand("Select-Object")
          .AddParameter("Property", new[] { "Name", "Status" });

    PSDataCollection<PSObject> output = await ps.InvokeAsync();

    return output.ToList();
}

使う側では、プールを開いてから並行に投げます。

using System.Management.Automation;
using System.Management.Automation.Runspaces;

string[] serviceNames = { "Spooler", "W32Time", "EventLog" };

using RunspacePool pool = RunspaceFactory.CreateRunspacePool(1, 4);
pool.Open();

IReadOnlyList<PSObject>[] results = await Task.WhenAll(
    serviceNames.Select(name => GetServiceAsync(pool, name)));

foreach (IReadOnlyList<PSObject> rows in results)
{
    foreach (PSObject row in rows)
    {
        Console.WriteLine($"{row.Properties["Name"]?.Value}: {row.Properties["Status"]?.Value}");
    }
}

ここでは説明のために Get-Service を使っていますが、実際には対象ごとに時間のかかるコマンドを想定してください。ポイントは、ps.Runspace ではなく ps.RunspacePool に設定することと、PowerShell インスタンス自体は操作ごとに作って破棄することです。

最後に、プールのサイズを上げれば速くなるとは限りません。実際の上限は、対象サーバーの負荷、認証処理、ネットワーク、対象モジュールの並行実行への耐性で決まります。まず小さい値で動かし、実測してから上げてください。

アプリ配布時のサイズ

Microsoft.PowerShell.SDK は便利ですが、アプリに含まれる依存関係も増えます。小さなユーティリティでは許容できても、配布形式や更新方式によってはサイズが気になることがあります。ClickOnce、MSIX、単体 exe 配布、社内配布ツールなど、実際の配布方式で早めに検証しておくと安心です。

14. 文字列ではなくオブジェクトで受け取る利点

最後に、なぜここまで PSObject にこだわるのか。外部プロセス起動で PowerShell を実行し、標準出力を読む方法は簡単です。

PowerShell の出力
  ↓
文字列
  ↓
Split / 正規表現 / Substring
  ↓
C# の値

ただし、この方法は表示形式に依存します。列幅、ロケール、改行、空白、エラーメッセージ、値の中の区切り文字によって壊れやすくなります。

一方で、PowerShell SDK を使うと次の流れになります。

PowerShell の出力
  ↓
PSObject
  ↓
Properties / BaseObject
  ↓
C# の型

こちらは、表示形式ではなくデータ構造に基づいて値を取り出せます。業務アプリや管理ツールでは、後者の方が保守しやすくなります。

15. まとめ

C# から PowerShell を実行して結果を扱うなら、単に powershell.exe を起動して標準出力を読むだけでなく、PowerShell SDK を使う方法を検討する価値があります。

基本の流れは次の通りです。

Microsoft.PowerShell.SDK を追加する
  ↓
PowerShell.Create() で実行オブジェクトを作る
  ↓
AddCommand / AddParameter / AddScript で処理を組み立てる
  ↓
Invoke() で実行する
  ↓
Collection<PSObject> として受け取る
  ↓
BaseObject または Properties から値を取り出す
  ↓
C# の DTO / record / class に変換する

実務で特に重要なのは、次の3点です。

  • C# で後続処理に使うなら、Format-Table ではなく Select-Object[pscustomobject] を使う
  • ユーザー入力を AddScript の文字列へ直接埋め込まず、できるだけ AddParameter で渡す
  • PSObject は境界で扱い、アプリ内部では C# の型に変換する

PowerShell は Windows 管理や既存資産活用に強く、C# はアプリ化、画面化、型安全な業務処理に強いです。両方をうまくつなぐと、既存の PowerShell スクリプトを捨てずに、少しずつ .NET アプリとして整備できます。

参考情報

  • この記事のサンプルコード一式(ライブラリ、デモ、ユニットテスト) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-run-powershell-receive-objects
  • Microsoft Learn: Windows PowerShell ホストのクイック スタート
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/windows-powershell-host-quickstart
  • Microsoft Learn: コマンドを追加し、呼び出す
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/adding-and-invoking-commands
  • Microsoft Learn: PowerShell Class
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell
  • Microsoft Learn: PSObject Class
    https://learn.microsoft.com/ja-jp/dotnet/api/system.management.automation.psobject
  • Microsoft Learn: PowerShell.InvokeAsync Method
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.invokeasync
  • Microsoft Learn: PowerShell.BeginStop Method(実行中のコマンドを非同期に停止する。返る IAsyncResultEndStop で受け取る)
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.beginstop
  • Microsoft Learn: PowerShell.Stop Method(同期版。停止し終わるまで戻らない)
    https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.powershell.stop
  • Microsoft Learn: 複数の実行空間を作成する
    https://learn.microsoft.com/ja-jp/powershell/scripting/developer/hosting/creating-multiple-runspaces
  • NuGet Gallery: Microsoft.PowerShell.SDK
    https://www.nuget.org/packages/Microsoft.PowerShell.SDK/
  • NuGet Gallery: Microsoft.PowerShell.5.1.ReferenceAssemblies
    https://www.nuget.org/packages/Microsoft.PowerShell.5.1.ReferenceAssemblies/

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

C#からPowerShellを実行するにはどの方法がよいですか?
大きく分けて、ProcessStartInfoでpowershell.exe/pwsh.exeを外部プロセスとして起動する方法と、System.Management.Automation.PowerShell(PowerShell SDK)を使う方法があります。標準出力を文字列で読むだけなら前者でも動きますが、C#側で結果を加工する管理ツールや業務アプリでは、結果をPSObjectのコレクションとして受け取れる後者が向いています。文字列のパースが不要になり、表示形式に依存しない安全な処理が書けます。
PSObjectのBaseObjectとPropertiesはどう使い分けますか?
BaseObjectは、PowerShellが返した元の.NETオブジェクトをそのまま使いたい場合に使います。たとえばGet-Processをそのまま実行した結果はSystem.Diagnostics.Processとして取り出せます。一方、Select-Objectや[pscustomobject]で列を整形した結果はPowerShellのカスタムオブジェクトとして返ってくることが多いため、Properties["列名"]?.Valueで列名を指定して取り出す方が自然です。
C#からPowerShellにユーザー入力を渡すとき何に注意すべきですか?
ユーザー入力を文字列連結でAddScriptのスクリプトに埋め込むのは避けるべきです。入力がPowerShellのコードとして解釈される余地があり危険だからです。値を渡す場合はAddCommandとAddParameterを使うと、値はコード文字列としてではなくパラメーター値として扱われます。AddScriptは固定の短いスクリプトや既存スクリプトの読み込みに限定するのが無難です。
PowerShellの結果をC#で受け取るときFormat-Tableを使ってはいけないのはなぜですか?
Format-TableやFormat-Listを通すと、結果が元のオブジェクトではなく表示用のフォーマット情報になってしまい、C#側でプロパティとして値を取り出せなくなるからです。C#で後続処理に使う場合はSelect-Objectで列を絞るか、PowerShell側で[pscustomobject]を返す形に整えます。「画面で見るだけならFormat系、C#に渡すならSelect-Object」という使い分けです。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る