Programming Field

バッチファイルにPowerShellスクリプトを埋め込む その2

「バッチファイルにPowerShellスクリプトを埋め込む」では、文字通りバッチファイルにPowerShellのスクリプトを含め、そのPowerShellスクリプトを実行するバッチファイルを作成する方法を紹介しました。この方法では大抵のPowerShellの処理をバッチファイルのように扱うことができますが、スクリプトに指定する引数をPowerShellに解析させることができませんでした。

そこで、大まかな概念はそのままに、引数をPowerShellに解析させてスクリプトを実行できるようにする方法を紹介します。

※ この項に記述されている内容を使うと比較的容易にPowerShellコードを実行することができるようになるため、セキュリティー上問題がある可能性があります。そのため、作成したデータの取り扱いにはご注意ください。

※ このページは「バッチファイルにPowerShellスクリプトを埋め込む」で紹介した内容を前提に記述しています。

※ 手っ取り早く方法を知りたい場合は、「まとめ」内の最後の例をご覧ください。

PowerShellの「Invoke-Expression」コマンド利用部分の改良

バッチファイルをPowerShellスクリプトとして実行させるために、以前のページではバッチファイル自身を「Get-Content」(gc)で読み込み、それをテキストとして「Invoke-Expression」(iex)で解析させるという方法を採りました。その実行部分は以下の通りです。

setlocal enableextensions
set "THIS_PATH=%~f0"
PowerShell.exe -Command "iex -Command ((gc '%THIS_PATH:'=''%') -join \"`n\")"

※ 「%THIS_PATH:'=''%」は「%」の置換処理を利用してファイル名内の「'」を「''」に置き換えています。
※ 「"」はファイル名に使用できませんが「'」は使用できるため、Get-Contentコマンドの引数を「'」で括る場合は THIS_PATH 変数内の「'」→「''」への置き換え行う必要があります(PowerShellでは「' '」の中に「'」文字を使用する場合「''」と記述します)。
※ 以前のページの最後では「%THIS_PATH:`=``%」としていましたが、これはGet-Contentコマンドの引数を「"」で括っていたためであり(「'」文字が使用できなかったため)、「'」文字を括りに使用する場合は「`」の置き換えを行う必要はありません。

ここで注目するのは、Get-Contentコマンドと「-join」演算子によって一度「すべてのスクリプトが記述された文字列」を生成している点です。つまり、この文字列を「Invoke-Expression」に渡す前に何らかの操作を入れることができます。

例えば以下のように変えてみます。

PowerShell.exe -Command "iex -Command ('#' + ((gc '%THIS_PATH:'=''%') -join \"`n\"))"

※ -Command に指定する引数に演算子を含める場合括弧「( )」で囲む必要があります。

この例はGet-Contentと「-join」によって生成された文字列の先頭に「#」文字を付加する(「#」文字列を先頭に結合する)変更を入れています。「#」文字はそれ以降の文字列をPowerShellのコメントとして扱わせるため、この「#」付加によりGet-Contentと「-join」によってできたスクリプトの1行目を無効化していることになります。すなわち、バッチファイルの1行目をPowerShellに扱わせないようにすることができます

これを利用すると、以下のようなPowerShell埋め込みバッチファイルを作ることができます。

@echo off & setlocal enableextensions & set "THIS_PATH=%~f0" & goto DoExec
# 上の行はバッチファイル、この行以降はPowerShellの扱い
Get-ChildItem -Path env:
if (0)
{
# ここまでPowerShell、以下の行からバッチファイル
:DoExec
PowerShell.exe -Command "iex -Command ('#' + ((gc '%THIS_PATH:'=''%') -join \"`n\"))"
exit /b %errorlevel%
# この1つ上の行までバッチファイル、この行から再びPowerShell
}
$currentTime = [System.DateTime]::Now
echo "Current time is $currentTime"

このコードでは1行目に最低限の下準備(「echo off」Setlocalなど)を行った上でGotoで「:DoExec」の行まで処理を飛ばしています。飛ばされた行はバッチファイルとして解析されないため、2行目から「:DoExec」の手前の行まで制約なくPowerShellを記述することができます。ただし「:DoExec」からExitの実行まではバッチファイルとして処理させるため、今度はPowerShell側において「if (0) { ... }」でバッチファイル処理を囲むことで、その処理をPowerShellとして解析させないようにします。

※ 厳密には、「if (0) { ... }」で囲んだ場合はその内部に対してもスクリプトの構文解析が行われるため、括弧のつじつまを合わせるなどの対応が必要になりますが、上記の「:DoExec」とその次の2行はPowerShellの構文上問題ない文字列になります。
※ PowerShellのコードは if 文よりも前にすべてまとめて記述することも可能です。

なお、PowerShell v2.0 以降では複数行コメントの「<# ... #>」が使用できるため、以下のように複数行コメントを「if (0) { ... }」の代わりに用いることができます(その場合バッチファイル部分に「#>」文字が入らないようにする必要はありますが、まず使用されないため問題ないと考えられます)。

@echo off & setlocal enableextensions & set "THIS_PATH=%~f0" & goto DoExec
# 上の行はバッチファイル、この行以降はPowerShellの扱い
Get-ChildItem -Path env:
<# ここまでPowerShell、以下の行からバッチファイル
:DoExec
PowerShell.exe -Command "iex -Command ('#' + ((gc '%THIS_PATH:'=''%') -join \"`n\"))"
exit /b %errorlevel%
 この1つ上の行までバッチファイル、この行から再びPowerShell #>
$currentTime = [System.DateTime]::Now
echo "Current time is $currentTime"

PowerShellのパラメーターを指定できるようにする

Paramステートメントの利用

上記で紹介した改良は、これから説明するパラメーター指定のために必要な改良となります。これは、PowerShellのスクリプトや関数においてより詳細な設定を与えた引数を受け取るために使用する「Param」ステートメントは、他のコマンドの実行よりも先に記述する必要があり、以前のページで記述した方法(先頭の「@」をPowerShellとして解釈させるもの)ではParamが使用できなくなるためです。

※ Paramステートメントを用いない場合は、以前のページで紹介した内容と次の項で説明する内容を組み合わせて「$args」経由で引数を扱うことができます。

上記の改良を用いると、Paramステートメントは以下のように使用することができます。

@echo off & setlocal enableextensions & set "THIS_PATH=%~f0" & goto DoExec
# 上の行はバッチファイル、この行以降はPowerShellの扱い
Param(
  [Mandatory=$true] [double] $x,
  [Mandatory=$true] [double] $y
)
<# ここまでPowerShell、以下の行からバッチファイル
:DoExec
PowerShell.exe -Command "iex -Command ('#' + ((gc '%THIS_PATH:'=''%') -join \"`n\"))"
exit /b %errorlevel%
 この1つ上の行までバッチファイル、この行から再びPowerShell #>
$result = [System.Math]::Pow($x, $y)
echo "Result = $result"

このコードを実行すると、「Mandatory」指定があるため、PowerShellが「x」と「y」に指定する値を尋ねるプロンプトを表示します。

Invoke-Expressionのコードに半ば強引に引数を与える

ParamステートメントをPowerShellスクリプトファイルの先頭で使用すれば、そのスクリプトファイルを実行する際に「-x 2 -y 4」のように引数を指定することができますが、上記のバッチファイル化したPowerShellスクリプトではまだ引数指定ができません。

そこで、バッチファイルの「%*」を使用してPowerShellが引数を受け取れるようにしたいところですが、

  • 「PowerShell.exe -Command (略) %*」と記述すると、PowerShell.exeの「-Command」パラメーターは「-Command 以降に指定されたパラメーターをすべてコマンドとして解釈する」ため、「%*」で展開される引数が「(略)」の内容と一体になったコマンドとして扱われてしまう
  • 「Invoke-Expression」は「-Command」で受け取れるのは文字列1つだけであり、その文字列をコマンドとして見たときのコマンドに対するパラメーターを指定することができない

という制約があり、さらに工夫を入れる必要があります。

そこで、「コマンドブロック」を生成してそれに対して引数を与えられるようにします。「コマンドブロック」をデータ化すると、「&」演算子を利用してそのブロックをコマンドのように呼び出すことができます。もちろんその際に引数を与えることもできます。

コマンドブロックと「&」演算子の例は以下の通りです。

$myCmd = {
    Param($arg1)
    Write-Host "arg1 = $arg1"
}
& $myCmd -arg1 12345

または

& {
    Param($arg1)
    Write-Host "arg1 = $arg1"
} -arg1 12345

また、Invoke-Expressionコマンドは「実行した結果をコマンドの戻り値とする」コマンドであることに注目すると、

$myCmd = iex -Command '{Param($arg1) echo "arg1 = $arg1"}'

というコマンドで「$myCmd」変数にコマンドブロックを入れることができます。すなわち、前述の例を踏まえると

& (iex -Command '{Param($arg1) echo "arg1 = $arg1"}') -arg1 12345

と記述することができます。ここに、『PowerShell.exeの「-Command」パラメーターは「-Command 以降に指定されたパラメーターをすべてコマンドとして解釈する」』という仕様を逆に利用すると、

PowerShell.exe -Command "& (iex -Command ('{#' + ((gc '%THIS_PATH:'=''%') -join \"`n\") + '}'))" %*

と書け、「-Command」後のパラメーター1つ目(下線で示したもの)でコマンドブロック生成 + そのコマンドの呼び出し、2つ目以降(「%*」部分)でコマンド呼び出しの引数を与えることができます。

以上により、バッチファイル内PowerShellスクリプトに引数を与えられるようになります。

まとめ

ここまでの内容をまとめると、以下のようにPowerShell埋め込みバッチファイルを記述することができます。

@echo off & setlocal enableextensions & set "THIS_PATH=%~f0" & goto DoExec
# 上の行はバッチファイル、この行以降はPowerShellの扱い
Param(
  [Mandatory=$true] [string] $file,
  [Mandatory=$true] [string] $name
)
<# ここまでPowerShell、以下の行からバッチファイル
:DoExec
PowerShell.exe -Command "& (iex -Command ('{#' + ((gc '%THIS_PATH:'=''%') -join \"`n\") + '}'))" %*
exit /b %errorlevel%
 この1つ上の行までバッチファイル、この行から再びPowerShell #>

$data = [xml](gc -Encoding UTF8 $file)
$data.SelectNodes("//*[@name='$name']")

これをバッチファイル「FindName.bat」として保存すると、

FindName.bat -file D:\Data\member.xml -name Nono

と引数付きで呼び出すことができます。(上記の例は、-file 引数で与えたXMLファイルにおいて「name」属性が -name 引数で与えた値になっている要素を探して出力します。)

※ 上記の例では「SelectNodes」メソッドで使用される「$name」の値の有効性をチェックしていないため、-name 引数の内容によっては問題が発生する可能性があります。

[2016/05/08 追記] さらに、バッチファイル処理部分は最初の1行にまとめることも可能です。1行目はPowerShellとしては完全に無視するようにしているため、1行にまとめることでPowerShell部分の途中にバッチファイル処理を挟む必要がなくなります。バッチファイルとしての処理が特に必要ないのであれば記述がすっきりします。なお、この場合「echo off」は不要になりますが、通常の環境変数の展開が1行単位で先に行われるため、環境変数の遅延展開を行うために「setlocal enabledelayedexpansion」を行う必要があります。

@setlocal enableextensions enabledelayedexpansion & set "THIS_PATH=%~f0" & PowerShell.exe -Command "& (iex -Command ('{#' + ((gc '!THIS_PATH:'=''!') -join \"`n\") + '}'))" %* & exit /b !errorlevel!
# 上の行はバッチファイル、この行以降はすべてPowerShellの扱い
Param(
  [Mandatory=$true] [string] $file,
  [Mandatory=$true] [string] $name
)

$data = [xml](gc -Encoding UTF8 $file)
$data.SelectNodes("//*[@name='$name']")

[2016/08/20 追記] 上記の追記における「exit /b !errorlevel!」は、Exitコマンドの仕様により「!errorlevel!」を省略してもPowerShellの終了コードをそのまま返すことができます。また、バッチファイル名(フルパス)に「'」(シングルクオーテーション)が入っておらずPowerShell内でそのファイル名を使用しないことが分かっている場合は、以下のように1行目を簡略化することができます。

@setlocal enableextensions & PowerShell.exe -Command "& (iex -Command ('{#' + ((gc '%~f0') -join \"`n\") + '}'))" %* & exit /b
(以下省略)

※ 一時的な環境変数も使用しなくなるので「setlocal enabledelayedexpansion」も不要になります。
※ フルパスの中に「'」文字が含まれているとPowerShellコマンドが不正な構文になってしまうため、別のコンピューターに任意にインストール可能なバッチファイルで使用するには適していません。
※ 「PowerShell内のスクリプトにおけるカレントディレクトリがバッチファイルのディレクトリになってよい」という場合は、以下のようにSetlocalとChdirを組み合わせることで「バッチファイル名(フルパス)に『'』(シングルクオーテーション)が入らない」という制約を「バッチファイル名(パス部分除く)に『'』(シングルクオーテーション)が入らない」という制約に緩和することができます。

@setlocal enableextensions & cd /d "%~dp0" & PowerShell.exe -Command "& (iex -Command ('{#' + ((gc '%~nx0') -join \"`n\") + '}'))" %* & exit /b
(以下省略)

以上により、バッチファイルに埋め込まれたPowerShellが引数を受け取ることができるようになったため、これを利用すると、PowerShellを実行するためのバッチファイルを別に作ったりショートカットファイルを作成したりせずに、PowerShellを単一のファイルにまとめてダブルクリックで実行することが可能になったり、PowerShellがバッチファイル化されることでエクスプローラー上でドラッグ&ドロップのドロップ先に(結果的に)PowerShellスクリプトを利用できるようになったりといった利点があります。

[2017/11/16 追記] 空白文字をファイル名に含むファイルをドラッグ&ドロップする場合など、ダブルクオーテーションマークを引数に含める場合は、PowerShellのダブルクオーテーションの解釈をうまく回避するために、コマンドを以下のように変更して「"」が「\"」となるようにする必要があります。(1行でまとめる場合は環境変数の遅延展開が必要になります。)

@setlocal enableextensions enabledelayedexpansion & cd /d "%~dp0" & set TEMP_ARGS=%*& PowerShell.exe -Command "& (iex -Command ('{#' + ((gc '%~nx0') -join \"`n\") + '}'))" !TEMP_ARGS:"=\"! & exit /b
(以下省略)

※ 引数に「&」文字を含んでいる場合などは上記のコマンドはうまく動作しません。ただし、これに限らずほぼすべてのバッチファイルで「%*」という形を用いる場合は引数に用いることのできる文字に制約が生まれます。