Open1

eval的なものの必要性とInvoke-Expressionについて

Otogawa Katsutoshi(oto)Otogawa Katsutoshi(oto)

powershellのeval。
eval的な処理はプログラミング入門書的なものでもさらりとしか書かれておらず、
入門書でちらっとこういうことできるよという知識だけ見て実際のソースコード
いまいちピンとこないと人も多いと思う。

こういうメタプログラミング的なものは、動的にソースコードを書きたいか、ソースコードの変更を少なくするために使う。

ライブラリをそれなりに書く人は使ったり、使うことを検討することはあるだろう。

例えば、下のソースコードを考えてみよう。

下はMavenのcentral repositoryへ検索apiを投げるロジックの一部抜粋だ。

    ...
    # MaVen Central Repository Api
    $Uri = "https://search.maven.org/solrsearch/select"

    $queryParameters = @{
        q="$($query -join '+AND+')";
        wt="json";
        rows=100;
    }

    [BasicHtmlWebResponseObject] $response = $null
    if (5 -eq $PSVersion.Major -and 1 -ge $PSVersion.Minor) {
        $response =  Invoke-WebRequest -Body $queryParameters -Uri $Uri -UseBasicParsing
    } elseif (7 -ge $PSVersion.Major) {
        $response =  Invoke-WebRequest -Body $queryParameters -Uri $Uri
    } else {
        # $PSVersion 5.0 before version is not supported.
        Write-Error "Not supported Powershell Version."
        # throw Error
    }
  if ($response.statusCode -eq @(400, 404)) {
    Write-Error ""
     return
  }
   ...

この処理は何をしているかというと、
Powelrshell 5.1なら-UseBasicParsingというswitchParameterを渡して、
Powershell 7以降なら-UseBasicParsingと使わずにそのまま実行するというコードだ。

なぜ、このようにPowershellのバージョンによって、パラメータを変える必要があるかというと、
Powershell5.1系はInvoke-WebRequestはhtmlパーサーとしてIEを使っているため、IEが無くなった今はIEではないParserを指定する必要があるため、UseBasicParsingという引数を渡す必要があるということだ。
ただし、core系のInvoke-WebRequestは最初からIEのパーサーを使っていないため、引数を渡す必要がないということ。
ではcore系も-UseBasicParsing渡せばええやんと思うかもしれないが、-UseBasicParsingはPowershell6.0以降は非推奨のパラメータになっており、新たにソースコードを書くときに使うべきではない。

これらの処理を分けるために、PowershellのバージョンごとにInvoke-WebRequestを書いているわけだが、これだと下記の問題が発生する。

  1. UseBasicParsingが必要かどうかと判定ロジックとInvoke-WebRequestによる検索ロジックが制御文により密結合。
  2. Invoke-WebRequestの戻り値を制御文の中で受け取る必要があるため、変数$responseをimmutableにできない。

1も2もソースコードを長く管理することにおいて気になる点のため、できるだけ避けたいと思う。

1と2を同時に避けるためにはInvoke-WebRequestを動的に処理するということでしか回避できない。
単純にUseBasicParsingが必要かどうかの判定ロジックを他の関数に移動させても、結局if文を他の関数に追い出せないし、Invoke ~WebRequestが2回書くことに変わりは無いため、あまりソースコードの管理は楽にならない。

ということで、Powershellのevalに相当するInvoke-Expressiontを使うことになる。

Invoke-Expression

Invoke-Expressionは文字列を渡すとそれをソースコード、スクリプトとして実行することができるコマンドレットだ。

動作確認

簡単に動作を見ていく。

#=> Get-Help Invoke-Expression が動的に実行される。
Invoke-Expression "Get-Help Invoke-Expression"

ややこしいのは下の二つの例の違いだ。
変数を渡したときに差ができる。

$fileName =  test.txt
#=> New-Item -Type File test.txtが動的に実行される。
Invoke-Expression "New-Item -Type File $fileName"

# バックスペースで$をエスケープして変数名を文字列で渡す。
#=> New-Item -Type File $fileNameが動的に実行される。
Invoke-Expression "New-Item -Type File `$fileName"

Invoke-Expression に変数を渡すときにはエスケープしないとPowershell側で文字列に変数を展開した後にInvoke-Expressionに渡すことになる。
エスケープした場合はInvoke~ExpressionがNew-Item -Type File fileNameという処理を実行するときに、fileNameがtest.txtと展開されることになる。

これはPowershellがそういう仕様というより、他の言語もeval系は普通そういう動きになる。

そしてこの動作は$fileNameが文字列のため、結果が同じのため特に問題は生じない。

eval系で問題になるのはオブジェクトを渡すときだ。
文字列に埋め込んで変数として展開するのと、直接変数を渡すので全く値が変わる。

例えば、下のソースコードを見て欲しい。

配列の場合

$Property = @("ProcessName", "Id", "WS")
Write-Output "${property}"
#=>111 222 333

Get-Process | Select-Object -Property $Property

Get-Process | Select-Object -Property ProcessName, Id, WS

#= 111 222 333 と展開された値が実行されるため、エラーになる。
Invoke-Expression "Get-Process | Select-Object -Property $PROperty"

# 正しくは下のように実行する。
Invoke-Expression "Get-Process | Select-Object -Property `$PROperty"

連想配列の場合


#=> HashTableという文字列を渡すため、エラーになる。

xmlなどのオブジェクトも同様にオブジェクトの名前を渡す。

ということで、 数字と文字列以外を渡す場合はエスケープが必須と考えると良い。

以上を踏まえて上のInvoke-WebRequestのソースコードは下のようになる。

    $UseBasicParsing = ""
    if ($PSVersion.Major -eq 5 -and $PSVersion.Minor -ge 1) {
        $UseBasicParsing = "-UseBasicParsing"
    } elseif (7 -ge $PSVersion.Major) {
        # Default parsing

    } else {
        # $PSVersion 5.0 before version is not supported.
        Write-Error 
        # throw Error
    }
    #=> Powershell 5.1系列の時 Invoke-WebRequest -Body $queryParameters -Uri "" -UseBasicParsing
    #=> Powershell 7以降のとき Invoke-WebRequest -Body $queryParameters -Uri "" 
    $response = Invoke-Expression "Invoke-WebRequest -Body `$queryParameters -Uri $Uri $UseBasicParsing"

デメリット

  1. eval的な処理はインジェクションに脆い。特にプログラミング言語やシェルスクリプトのインジェクションは安全にエスケープする方法がない(気になる人はosコマンドインジェクションでググる)。
  2. 言語のパーサーを信用することになる。
  3. 速度が遅い
  4. いつのタイミングで文字列に展開されるかがややこしい。
  5. エスケープしてeval系の処理に渡しているため、linterに変数が使われていないと怒られる。

1は他人からの入力をInvoke-Expressionに渡さないということで回避できる。
2はパースに癖やバグに近い挙動がある言語(VBAとかcmdとか)では使わないということにするしかない。
3はあきらめましょう。トレードオフです。
4は慣れです。気をつけないとややこし過ぎて禿げます。
5はlinterに定義されているignore的なコメントを書くことで回避できる。

「うちのプロジェクトだとデメリットの方が多いじゃん!」と感じるなら使うのをやめること。
そう感じることも多いから、使わないこと多いのよ。

まとめ

プロジェクトによっては脳死で嫌がられる。
メタプログラミングはかなりプログラミングスキルの差が出るから、書くときよく考えてね。
他人に説明するのメンドイなら使わないのも手。
でも、変更コストとソースコードの再利用性考えるとメタプログラミング強要されることそこそこあるのよね...

技術力とコミュニケーションコストをどう見積もるか?ですな。
少人数のプロジェクトなら、割と問題にならんのですが、使いたいプロジェクトに限って大抵規模デカいのがね...