2017/12/24

この記事には「 独自研究 」に基づいた記述が含まれているおそれがあります。

この記事はPowerShell Advent Calendar 2017の24日目です。

一般的なプログラミング言語では、文(statement)と式(expression)の違いは、値を返すのが式で、返さないのが文、という説明がされることが多いと思います。しかし、PowerShellではこの説明は成り立たたず、文が値を返したりしてるように見えて良く分かりません。そこでPowerShellにおける文と式とはそもそも何なのかということを、仕様書(PowerShell 3.0のものですが)やAST(ShowPSAstモジュールが便利!)を眺めながら考えてみたので軽くまとめようと思います。

文(statement)と式(expression)の定義

PowerShellでは言語要素として、文(statement)と式(expression)が明確に定義されています。すなわち、言語要素の何が文であって、何が式であるかという定義は仕様できちんと決まっていて、ある言語要素が、状況によって文になったり式になったりと変化する、ということはありません。

パイプライン、代入、ifやforやfunctionなどは文です。

変数、数値/文字リテラル、オブジェクトのメンバ呼び出し、スクリプトブロック、単項または二項算術演算子で構成される式、カンマ演算子で構成される配列などは式です。

ただ、仕様書での定義と、実際に構築されるASTに齟齬があることはあります。

例えば「$a=1」のような代入については、ASTではAssignmentStatementAstとなります。一方、言語仕様上はassignment-expressionと書かれています。厳密には、言語仕様書のgrammer節によれば、assignment-expressionはexpressionではなくpipelineであるということになっています(お前は何を言っているんだ)。いずれにせよパイプラインは文であるので、AST通り、代入は文であるという解釈で良いと思います。

※仕様書にはassignment expressionとはっきり書いてあるんだから代入は式だろ!という意見を否定するものではないです。が、代入は文であると考えたほうが他の文法と整合性を取りやすいので、そういう立場をとりました。

しばたさんが9日目に書かれた記事で取り上げられているように、配列に関しても同様の齟齬があります。いずれにせよ「1,2,3」のような配列は、式と考えて良いと思います。

文と式の構造

PowerShellには文と式が存在することは分かりました。では文と式は何が違うのか? それを考察するために、具体的にいくつかの文と式の構造を取り上げて見ていきます。

パイプライン(文)

パイプラインといえば「Get-Process | Where-Object Handles -gt 100 | Select-Object ProcessName」みたいな|で繋ぐやつのことでしょ、と思われがちですが、言語仕様上は以下のようなものはすべてパイプラインです。

Get-Process -Name PowerShell # @コマンド1つだけ
1 # A数値リテラルだけ
1 + 1  # B算術演算子で構成される算術式
$a = 1 # C代入
gps | where Handles -gt 100 | select ProcessName # Dパイプ記号でコマンドを連結したもの
1 + 1 | Write-Host # E算術式をパイプラインでコマンドと繋げたもの

要はパイプラインというのは、一般的なプログラミング言語で、;で終わる一文に相当するものと考えればだいたい間違いないと思います。ただしPowerShellだと文末の;は必須ではなく、改行でもOKです。

パイプラインは以下のような構造を取ります。[]は省略可能を意味します。

パイプライン要素1 [ | パイプライン要素2] [ | パイプライン要素3] ...

または、

代入文

すなわち、代入文(後述)を除くパイプラインは1個以上のパイプライン要素から構成されており、複数のパイプライン要素が存在する場合はパイプ演算子|で連結されます。

パイプライン要素には式とコマンド(Get-Processとか)が存在します。ただし式は1つ目のパイプライン要素にのみ許可されます。

以上を踏まえると、@、Dはコマンドのみで構成されるパイプライン、A、Bは単独の式のみで構成されるパイプライン、Eは1つの式とコマンドで構成されるパイプライン、Cは代入文であることが分かります。

代入文

代入文はパイプラインなので、文です。代入文は以下のような構造を取ります。(ここでは+=などの複合代入演算子については省略)

式 = 文

ただし、左辺の式は、変数やプロパティなど、代入が可能な式である必要があります。

よって以下のような記述が可能です。

$a = $b # @変数
$a = 1 + 1 # A算術式
$a = Get-Process # Bパイプライン(コマンド1つ)
$a = gps | where Handles -gt 100 # Cパイプライン(コマンド複数)
$a = if($true){"a"}else{"b"} # Dif文
$a = $b = 1 # E代入文の結果を更に代入

言語仕様上、代入文の右辺には文であれば何でも書けるのですが、実際に代入が行われるのは、パイプライン、if文、for文、switch文といった、パイプラインに値を出力する文と代入文に限られます。ちなみにDのようにパイプラインと代入文以外の文を右辺に指定できるようになったのは、PowerShell2.0からです。

ところで上記@やAは右辺が式です。代入文の右辺は文じゃなかったの?と思われると思いますが、パイプラインの節で述べた通り、単独の式もパイプラインであり、パイプラインは文なので、三段論法でいくと式は文として扱われることになります。

※AST上では$a = $bの右辺はPipelineAst/CommandExpressionAst/VariableExpressionAstではなく、いきなりCommandExpressionAst/VariableExpressionAstとなっているので、この説明はASTの実装とはかみ合わないかもしれません。AssignmentStatementAst.Rightは確かにStatementAstを取るのですが、CommandExpressionAstはStatementAstから派生しているクラスなので、式の代入は問題なく行えます。

代入文は上記Eのようなことができることから分かる通り、値を返す文ですが、パイプラインには値を出力しません。値は返すがパイプライン出力がないものは、インクリメント演算子で構成される式($a++等)も同様です。

if文

パイプラインと代入文以外の文は色々あるわけですが、代表的なものとしてif文を取り上げます。一番シンプルなif文はこういう構造です。

if (パイプライン) {
    文1
    文2
....}

おそらく多くの人が誤解しているのではないかと思いますが、条件節に書くのは式ではなくパイプラインです。パイプラインを実行した結果、出力値がtrue、またはboolに型変換してtrueになる場合に、ブロック内の複数の文が実行されます。

よって以下のような記述が可能です。

if ($true) {} # @変数
if ($a -eq 1) {} # A論理演算子で構成された式
if ("a.txt" | Test-Path) {} # Bパイプライン
if ($a = 1) {} # C代入文

@とAは普通の書き方ですが、実際には、1つの式のみ有するパイプラインを実行し、出力される値が判定されています。

条件節はパイプラインなので、当然Bのような書き方もできるわけです。また、代入文もパイプラインであるので、Cの書き方もできてしまい、注意を要します。

条件節に指定できるのはパイプラインだけで他の文は許容されないので、
if (if($true){}){}
というような書き方はできません。

※といっても実はこう書くと文法上はvalidであり、条件節内は「ifコマンド、パラメータ値1($true)、パラメータ値2(スクリプトブロック)」という解釈になってしまいます。

また、条件節にはパイプラインは1つのみ指定可能で、複数文を書くことはできないので、
if ($a;$b) {}
という書き方はできません。(パーサーもエラーを出す)

丸括弧式

さて、普通のプログラミング言語だと、()はグループ化や演算子の優先順を変更するのに用いられるものの、別に文法そのものに影響を与えるものではないと思います。多くの場合、ASTでも()の情報はそぎ落とされます。

ところがPowerShellでの丸括弧()は文法的な意味を有しており、ASTにもParenExpressionAstとして存在する、立派な式です。丸括弧式の構造は以下の通りです。

(パイプライン)

これは要するに、「パイプラインに()を付けると式になる」、ということです。()内のパイプラインで出力された値が返される式となります。具体的にどういうところで使うのかを示します。

2 * (1 + 3) # @数値演算の優先順を変更する
($a = 1) # A値を返すがパイプラインには出力しない代入文の値を出力させる
$a[(Get-Hoge)] # B式は許容するがパイプラインは許容しない構文で、式に変換する
Get-Process -Name (Get-Hoge) # Cコマンドのパラメータにコマンド実行結果を指定する

@の使い方は普通です。ただし、()はパイプラインを生成するので、「(1+3)」は「1つの式のみ有するパイプラインを実行し、パイプラインに値を出力し、その値を返す」という見た目より複雑な処理になります。

※少なくともAST上はそうなりますが、実際は何らかの最適化処理が入ってる可能性はあります。

代入を重ねる場合にはAのような書き方は必要ないのですが、代入した結果をパイプラインの出力としたい場合は()を付ける必要があります。この場合、$aに1が代入され、コンソールにも1が出力されます。

Bで挙げている、式を許容するがパイプラインは許容しない言語要素というのは実はあまりないです。前述した通りif文の条件節は式じゃなくて、パイプラインを取るといった案配です。ただ、たとえば配列や連想配列の要素を取得するインデックス演算子[]は、式のみ許容されます。なのでコマンドなどのパイプラインの出力値を指定したい場合は()が必須となるわけです。

ちなみに、()内にはパイプライン以外の文(if文等)は指定できません。また、複数のパイプラインも指定できず、あくまで1つだけです。

※任意の文あるいは複数の文を式としたい場合には、部分式演算子$()または@()を用います。両者とも内部の文がパイプラインに出力した値を返す、「部分式」となります。両者とも複数値が出力されると配列になりますが、@()は出力値が1つでも要素数1の配列を返す点が異なります。

まとめ?

PowerShellの文と式は厳密に定義されています。文は複数の文と式で構成されるし、式は複数の文と式で構成されています。文や式の構成要素が取る文や式の種類についても、各々、きちんと定義されています。

ただし、PowerShellにおいて「値を返すか返さないか」、「パイプラインに出力されるかされないか」、「式であるか文であるか」という概念はすべて独立しています。そのため、PowerShellの文とは何である、式とは何である、ということを一言で説明することは難しいんじゃないかと思います。

なので、本記事でこれまで述べてきたとおり、「パイプラインは文で、要素として式やコマンドを取りますよ」とか、「ifは文で、条件節にはパイプラインを取りますよ」みたいな、各論でしか表現できないのではないかなぁと、私はそういう結論に至りました。

しかしここまで書いてちゃぶ台をひっくり返すようなことを言いますが、ある言語要素が文であるか式であるか、ここまで仕様書を読んだりASTを追ったりして把握するのは、まあ楽しくなくはないですが、知らなくても別に大丈夫だと思われます。別に、ifの条件節には条件式を書くのだと理解していても不都合は特にないかと。

効用があるとすれば、例えば「if ((Test-Path a.txt)) {}」とか「foreach ($i in (1..5)) {}」とかの、余分な()を取り除くのには文法の知識が役立ちます。それもまぁ、心配だから怪しい所には常に()付けておく or 何か変だったら()付けてみる とかでもそれ程問題にはならないかもしれません。

2016/04/23

Japan PowerShell User Group (JPPOSH) 主催の第 6 回 PowerShell 勉強会(4/9)には多数の方にお越しいただき、ありがとうございました。

PowerShell勉強会は今後も年2回くらいのペースで続けて行きたいと思っていますので、どうぞよろしくお願い致します。

さて、私のセッション「PowerShell 5.0 新機能と関連OSSのご紹介」のスライドを公開します。前半は以前のものとだいたい同じですが、正式版対応版にアップデートしています。

今回は去年から今年にかけて、PowerShell関連ソフトウェアとしてOSS化したものを、まとめて紹介しました。以下は今回紹介したもののリストです。

またデモで用いたサンプルファイルも公開します。

このzipにも同梱してますが、PSScriptAnalyzerのカスタムルールはこんな感じで作ります。作り方は、ASTを受け取って、中身をチェックして、ルールに該当するならDiagnosticRecordを返すというのが基本になります。

using namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic
using namespace System.Management.Automation.Language

Import-Module PSScriptAnalyzer

function Test-UsingVarsWithNonAsciiCharacter
{
    # 変数に半角英数字以外の文字種が含まれていると警告するカスタムルール。
    [CmdletBinding()]
    [OutputType([DiagnosticRecord[]])]
    Param
    (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [ScriptBlockAst]
        $ScriptBlockAst
    )

    Process
    {
        [Ast[]]$variableAsts = $ScriptBlockAst.FindAll({
            param([Ast]$ast)
            $ast -is [VariableExpressionAst]
        }, $true)

        $variableAsts | 
        where {
            $_.VariablePath.UserPath -notmatch '^[a-zA-z0-9_]+$'
        }|
        foreach {
            $result = [DiagnosticRecord[]]@{
                "Message"  = "変数 `$$($_.VariablePath.UserPath) に半角英数字以外の文字種が使われています。"
                "Extent"   = $_.Extent
                "RuleName" = "AvoidUsingVarsWithNonAsciiCharacter"
                "Severity" = "Warning"
            }
            $result
        }
    }
}
Export-ModuleMember Test-UsingVarsWithNonAsciiCharacter

ついでにPesterのサンプルコードも。2つのパラメータを足し算する関数、Invoke-Additionに対するテストコードの例となります。

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.'
. "$here\$sut"

Describe "Invoke-Addition" {   # テストの定義
    Context "足し算の実行" {   # テストのグループ化
        It "整数値を2個指定すると、足し算された結果が返る" {  # テストケース
            Invoke-Addition 3 5 | Should Be 8 # アサーション
        }

        It "小数値を2個指定すると、足し算された結果が返る" {
            Invoke-Addition 3.4 5.8 | Should Be 9.2
        }
    }

    Context "エラーの発生" {
        It "足し算できないものを指定するとエラー" {
            {Invoke-Addition 10 "x"} | Should Throw 
        }
    }
}

2016/03/13

昨日3/12(土)開催の、わんくま同盟大阪勉強会#66にお越しいただいた方、どうもありがとうございました。

私の行った「PowerShellコマンドとエラー処理」というセッションの資料を公開します。

あまりPowerShellのエラー体系についてまとまった資料がないように思いますので、参考にしていただければ幸いです。

2016/01/11

注:最後に書きましたが、この記事の内容には未解決問題が残っています。

はじめに

前回は、パイプラインを下流の関数内で打ち切る方法について説明しました。

今回は逆に、下流でパイプラインが打ち切られた場合、上流の関数ではどう対応すべきなのか、特にリソース解放処理を焦点にして考察してみます。

パイプラインが打ち切られるケース

前回も説明しましたが、パイプライン内の関数またはコマンドレットで何らかの例外が発生すると、パイプライン処理がその時点で打ち切られます。パイプライン処理が打ち切られると、後続のprocessブロックのみならず、パイプラインに含まれるすべてのendブロックの実行もスキップされてしまいます。

ところでコマンドレットや関数では何かエラーが発生した場合、コマンドレットではWriteErrorメソッド、関数ではWrite-Errorコマンドレットを使ってエラーストリームに、生の例外オブジェクトをラップしたErrorRecordを出力し、呼び出し側のErrorActionの設定にエラー時の処理を委ねるのが基本です。

呼び出し時のErrorAction指定がContinue、SilentlyContinue、Ignoreの場合は、パイプラインが中断することはありませんが、StopやInquireで中断した場合は例外(ActionPreferenceStopException)がthrowされ、パイプラインは打ち切られます。
(ちなみにv3からはStopで中断した場合は、コマンドレットがエラーストリームに書き出したErrorRecordに含まれるExceptionがthrowされる)

また、継続不能エラーが発生(ThrowTerminatingErrorメソッド)したり、.NETの生の例外がそのままthrowされたり(お行儀が悪いですが)、breakステートメント(FlowControlException)が実行されたり、Select-Object -First(StopUpstreamCommandsException)が実行された場合も、同様にパイプラインは打ち切られます。

つまり、下流のコマンドでパイプラインが打ち切られるケースというのは色々あり得るので、endブロックというのは実行が確約されたものでは全くない、ということに留意しておく必要があります。

関数内のリソース解放処理

確実に実行したい後処理というのは色々あると思いますが、特に確保したリソースの解放というのは確実に実行してもらわないと困ります。

しかし、前述のような背景があるので、何らかのリソースを用いる関数を書く際に、beginブロックでリソースを確保し、processブロックでリソースを利用し、endブロックでリソースを解放する、ということは基本はNGということになります。

この点、コマンドレットクラスであれば、IDisposableインターフェースを実装しておけば、コマンドレット終了時にDisposeメソッドをPowerShellが呼んでくれるので、その中にリソース解放処理を記述しておけばOKです。しかし関数ではこの手法が使えないので、代替案を考える必要があります。

パイプライン処理の後始末をしよう - 鷲ノ巣であえとす氏が考案した、beginブロックに後処理用の関数を定義しておく方法では、同じ関数内のprocessブロックで発生したエラーをcatchしてリソース解放処理を走らせることは可能です。が、残念ながら下流で発生した例外をcatchすることはできず、その場合は後処理がスキップされてしまいます。

リソース解放処理を含めた関数

あえとす氏の方法を若干アレンジして、下流で例外が発生した場合も確実にリソース解放の後処理を走らせる方法を考えてみました。以下にコードを示します。

※クラス構文を使ってますが、これは単に、Disposeできるオブジェクトのサンプルだと思ってください。

class SomeResource : IDisposable
{
    [void]Dispose()
    {
        # ここで何らかのリソース解放処理を行ったものとする
    }
}

function Write-OutputWithResource
{
    [CmdletBinding()]
    param([Parameter(ValueFromPipeline)][psobject[]]$InputObject)
    begin
    {
        $resource = New-Object SomeResource # 後で解放する必要のある何らかのリソース確保

        function Clear-Resource
        {
            # リソースの解放処理
            $resource.Dispose()
        }
    }
    process
    {
        try
        {
            $processing = $true
            Write-Verbose "output_start: $InputObject"
            $InputObject # パイプライン出力
            Write-Verbose "output_end: $InputObject"
            $processing = $false
        }
        catch
        {
            # try内で例外が発生した場合はそのまま再スロー
            throw
        }
        finally
        {
            if($processing)
            {
                Write-Verbose "resource_dispose (on error)"
                Clear-Resource
            }
            Write-Verbose "output_finally: $InputObject"
        }
    }
    end
    {
        Write-Verbose "resource_dispose (at end)"
        Clear-Resource
    }
}

processブロック内のtryブロックでパイプライン下流に値を出力したとき、下流で例外が出てもcatchブロックが実行されないので、代わりに、必ず実行されるfinallyブロックから後処理を呼び出すようにしてみました。

ただし、パイプライン下流で例外が発生しなかった場合には、processブロック内ではリソース解放処理はしたくないので、例外発生の有無を$processingという変数の値を見ることで確認しています。もしパイプライン出力したあと下流で例外が出ていれば、$processingの値は$trueのままになるので判断可能です。

パイプラインが中断することなく、最後まで実行される場合は、endブロック内でリソース解放処理を行います。

同じ関数内で例外が発生したときのリソース解放処理についても、$processing = $trueと$processing  = $falseの間に例外が発生する可能性のある処理を記述した上で、catchブロックで再throwすれば、併せて対応できるのではないかと思います。(それ用のtry..catchを記述してもいいですが)

関数の実行例
PS> 1..3 | Write-OutputWithResource -Verbose | Select-Object -First 2
詳細: output_start: 1
1
詳細: output_end: 1
詳細: output_finally: 1
詳細: output_start: 2
2
詳細: resource_dispose (on error)
詳細: output_finally: 2

このように、下流でパイプライン打ち切りがあるとendブロックは実行されませんが、リソース解放処理は、きちんとprocessブロック内のfinallyブロックから呼び出されています。

PS> 1..3 | Write-OutputWithResource -Verbose
詳細: output_start: 1
1
詳細: output_end: 1
詳細: output_finally: 1
詳細: output_start: 2
2
詳細: output_end: 2
詳細: output_finally: 2
詳細: output_start: 3
3
詳細: output_end: 3
詳細: output_finally: 3
詳細: resource_dispose (at end)

もちろんパイプラインが最後まで正常に実行された場合も、ちゃんと最後にendブロックでリソース解放が行えるようになっています。

おわりに

本当に、こうするしかないんですかね…?

PowerShellにもリソース利用のusingステートメント欲しいです、が、begin, process, endにまたがって機能するusingってどういう構文になるんでしょうね。

(14:22追記)と、ここまで書いておいて、この方法では下流で発生した例外には対処できますが、上流で例外が発生した場合はリソース解放が実行されないという問題に気付きました…。どうすればいいんだ…。

2016/01/03

PowerShellのパイプライン処理

まず、PowerShellのパイプライン処理について軽くおさらいします。

例えば、@、A、Bをそれぞれ何らかのコマンドとしたとき、

@|A|B

というパイプラインがあったら、処理の流れは、

@begin→Abegin→Bbegin→(@process→Aprocess→Bprocess→)×n→@end→Aend→Bend

の順に実行されます。(processブロックで「1入力に対し1出力する」場合以外は必ずしもこうならないですが)

さて、AかBのprocessブロック実行中に、何らかの条件を満たした時、パイプラインのprocessの後続処理を打ち切りたい場合はどうすれば良いでしょうか。

まずはbreakを使った駄目な例

ネットでよく見かける以下のようなコード、すなわち「パイプラインはbreakで処理を打ち切ることができる」というのは実は正しくないのです。

function Select-WhileTest
{
    [CmdletBinding()]             
    param (             
        [parameter(ValueFromPipeline=$true)] 
        [psobject[]]$InputObject,
        [parameter(Position=0)]           
        [scriptblock]$Predicate
    )

    process
    {
        if(!(&$Predicate))
        {
            break
        }
        $InputObject
    }
}

このコードはv2までではそもそも正しく動作しませんが、v3以降では条件によっては正しく動作しているように見えるのが、誤解の元なのかと思います。(というか私も誤解してました。)

例えば、

$result = "初期値"
$result = &{end{foreach($i in (1..5)){$i}}} | Select-WhileTest {$_ -lt 3}
Write-Host "`$resultは $result です。"

のようにすると、

$resultは 1 2 です。

のように、想定した通りの結果が得られます。このように、上流のスクリプトブロックのendブロック内にforeachなどループブロックが存在し、そのループブロック内で下流に値を出力している場合はうまくいきます。(ちなみに、スクリプトブロック直下に記述するのとendブロック内に記述するのは等価。)

しかし、上流にループブロックがない場合、例えば

$result = "初期値"
$result = 1..5 | Select-WhileTest {$_ -lt 3}
Write-Host "`$resultは $result です。"

とすると、コンソールに1と2が改行区切りで表示されますが、ホストに表示されるだけで$resultには値は格納されません。そしてスクリプト化して実行した場合は、Write-Hostが実行されることすらなく、スクリプトが終了してしまいます。

breakだとなぜうまくいかないのか

結局どういうことかというと、パイプライン下流のbreakは、パイプラインを打ち切る処理をするのではなく、単に一つ上流のブロックをbreakする処理に過ぎないのです。

パイプライン上流にループブロックがある場合は、そのループブロックをbreakしますが、それ以外の場合はスクリプトのbegin, process, endのいずれかのブロックがbreakされてしまい、結果としてスクリプトが終了してしまうわけですね。

そして、このSelect-WhileTest関数だと大丈夫ですが、processブロックの中にループブロックを記述し、その中でbreakを書くのは当然ダメです。単にそのループを抜けるだけなので、上流の出力は止まってくれません。

breakではなくcontinueを使う場合も基本は同じ結果です。しかもcontinueは所詮、その名の通り継続処理なので、上流に以下のような無限リストがあると無限ループに陥ってしまいます。

&{begin{$i = 0} process{while($true){$i++; $i}}}|Select-WhileTest {$_ -lt 3}

breakの代わりに、

throw (New-Object System.Management.Automation.PipelineStoppedException)

を実行する方法も見かけますが、これはループブロックがあっても強制的にスクリプトが終了するので余計ダメです。try...catchでエラートラップすればスクリプトの終了は回避できますが、「パイプラインが正常終了せずエラーを出した」扱いであることには代わりないので、やはり出力を変数に格納することができません。

ダミーループを用いる、取りあえずの解決策

前述のbreakを使った方法の問題点のうち、上流にループブロックがないとスクリプトが終了してしまい、出力を変数に代入することもできない問題は、とりあえず解決する方法があります。

以下のように、呼び出す時にパイプライン全体をダミーのループブロックでラップすれば良いのです。

$result = "初期値"
$result = do{
    1..5 | Select-WhileTest {$_ -lt 3}
}until($true)
Write-Host "`$resultは $result です。"

このようにしておけば、breakはパイプラインの外側のdo...untilを抜ける効果になるので、スクリプトが終了する心配も、値を出力しない問題も起こりません。

元々、パイプライン上流にループブロックが存在する場合でも、単にdoループ内の処理が1回走るだけなので、特に問題は起きません。1回だけ処理を実行するダミーループなら、for($i=0; $i -lt 1; $i++){}とかでも良いです。

ただ…この記述を美しいと思う人は多分いないですね。事情を知らないと意味不明ですし。そして、breakを記述する側の関数には、前述の通りのループブロック内では値を出力できないという制限は残ったままになります。

やはりbreakでパイプラインを打ち切るのは、本来想定された動作かと言われるとかなり微妙なところだと思います。(v3で一応動くようになったとはいえ)

この方法についての参考記事:Cancelling a Pipeline - Dreaming in PowerShell - PowerShell.com ? PowerShell Scripts, Tips, Forums, and Resources (ただしv2準拠の内容であることに注意)

ところで、Select-Object -Firstは…

さて、話は変わって、PowerShell 3.0からはSelect-Object -First の処理が変わったことについては、ご存知の方も多いかと思います。

具体的には、v2までは単にパイプライン処理をすべて終了してから、最初のn件を抽出する処理であったn件のパイプライン出力がされた後は、入力をすべてフィルタし出力に流さなくなる動作であったところが、v3からはn件のパイプライン出力がされた時点で、パイプラインの処理を打ち切るようになりました。(1/5修正)

つまり、

$result = 1..5| &{process{Write-Host "Process:$_"; $_}}| Select-Object -First 2
Write-Host "`$resultは $result です。"

というスクリプトは、v2までは

Process:1
Process:2
Process:3
Process:4
Process:5
$resultは 1 2 です。

のようにパイプライン出力は指示通り2件であるものの、上流の処理は結局、全部実行されてしまっています。

一方v3以降では、

Process:1
Process:2
$resultは 1 2 です。

のように、きちんと上流の処理を打ち切ってくれています。

つまり、ここまで述べてきたパイプライン処理の打ち切りは、実はv3以降のSelect-Object -Firstでは実現できているということです。これと同じことを我々も自作関数の中でやりたいわけです。

ではSelect-Object -Firstは具体的にどういう処理をしているかというと、StopUpstreamCommandsExceptionという例外をthrowすることでパイプライン処理の打ち切りを実現しています。この例外はまさに名前の通り、パイプライン上流の処理を中止するための例外です。この例外を自作関数でthrowしてやればうまくいきそうです。

しかし、この例外は非publicな例外クラスであることから、New-Objectコマンドレットなどでインスタンス化することはできません。リフレクションを駆使する必要がでてきます。
参考:PowerShell 3.0からはじめるTakeWhile - めらんこーど地階

(1/5追記)参考2:パイプラインの処理を途中で打ち切る方法のPowerShell版 - tech.guitarrapc.cóm(Add-TypeでC#経由でリフレクションしてます。)

頑張ればできなくはないですが、もっと楽な方法はないものか…と思ってあきらめかけたところ、いい方法を思いついたので紹介します。

Select-Object -Firstを利用する方法

Select-Object -Firstでできることが我々には(簡単には)できない。ならばどうするか。Select-Object -Firstを使えばいいじゃない。という発想です。

function Select-While
{
    [CmdletBinding()]             
    param (             
        [parameter(ValueFromPipeline=$true)] 
        [psobject[]]$InputObject,
        [parameter(Position=0)]           
        [scriptblock]$Predicate
    )

    begin
    {
        $steppablePipeline = {Select-Object -First 1}.GetSteppablePipeline()
        $steppablePipeline.Begin($true)
    }

    process
    {
        if(!(&$Predicate))
        {
            $steppablePipeline.Process($InputObject)
        }
        $InputObject
    }

    end
    {
        $steppablePipeline.End()
    }
}

scriptblockにはGetSteppablePipelineというメソッドが存在し、このメソッドによりSteppablePipelineオブジェクトが取得できます。これは何かというと、要は「スクリプトブロック内のbegin, process, endを個別に実行する」ための機能です。
参考:PowerShell: ◆パイプライン入力・パラメータ入力対応のGridView出力関数を作る(私自身も以前この記事で知りました。)

{Select-Object -First 1}というスクリプトブロックは、1回目に実行するprocessブロック内でStopUpstreamCommandsExceptionを出してくれます。

よって、自作関数のprocessブロック内のパイプライン処理を打ち切りたい箇所で、SteppablePipelineオブジェクトのProcessメソッドを使うことで、{Select-Object -First 1}のprocessブロックの処理を呼び出してやればいいわけです。

このようにして作成したSelect-While関数を以下のように実行してみると、

# 上流にループあり
$result1 = &{end{foreach($i in (1..5)){$i}}} | Select-WhileTest {$_ -lt 2}
Write-Host "`$result1は $result1 です。"

# 上流にループなし
$result2 =  1..5 | Select-While {$_ -lt 3}
Write-Host "`$result2は $result2 です。"

# 上流に無限リスト
$result3 = &{begin{$i = 0} process{while($true){$i++; $i}}} | Select-While {$_ -lt 4}
Write-Host "`$result3は $result3 です。"

結果は

$result1は 1 です。
$result2は 1 2 です。
$result3は 1 2 3 です。

となり、少なくとも今まで述べてきた諸問題はすべて解消していることが分かると思います。

このSelect-While関数は、スクリプトブロックで指定した条件を満たさなくなったときに、パイプライン処理を打ち切ってくれるものですが、この「Steppable Select -First 方式」を使えば他の自作関数でも、割と気楽に呼べるんじゃないかなと思います。ループブロック内で呼び出すことももちろん可能です。

ただし問題点はある

これはSelect-Object -FirstというかStopUpstreamCommandsExceptionあるいはPowerShellのパイプライン機構の仕様に由来する問題であると思われるので、どうにもならないことではあるんですが、一点だけ注意事項があります。

$result = 1..5| &{
    begin
    {
        Write-Host "Begin"
    }
    process
    {
        Write-Host "Process:$_"
        $_
    }
    end
    {
        Write-Host "End"
    }
}| Select-While {$_ -lt 2}

Write-Host "`$resultは $result です。"

これの結果は

Begin
Process:1
Process:2
$resultは 1 です。

となり、なんとendブロックが実行されていません。Select-While {$_ -lt 2} の代わりに Select-Object -Firstを使っても、同様にendは実行されません。

つまり、StopUpstreamCommandsExceptionというのはパイプライン処理を打ち切って、そこまでの出力値を正しくパイプライン出力として出してくれますが、やってくれるのはそこまでで、最後のendブロック処理はしてくれません。

これは十分注意が必要な点で、自作関数内でbeginブロックで確保したリソースをprocessブロックで利用して、endブロックで解放する…という、いかにも書いてしまいそうなパターンは、実はNGなんですね。何も上のようにマニアックなことをしなくても、単に下流でSelect-Object -Firstを使うだけでアウトです。

じゃあ、リソースの取り回しはどうするのが良いの、って話もありますが、それはまたの機会にしましょう。

(1/5追記)あえとすさんの記事が参考になります。:パイプライン処理の後始末をしよう - 鷲ノ巣 ただ、この方法ではパイプライン下流でthrowされた場合はトラップできないぽいですね。コマンドレットクラスの場合はIDisposable実装で良さそうです。

ここからは私見ですが、StopUpstreamCommandsExceptionが後付けかつ非パブリックなところとか、パイプラインを合法的に脱出するステートメントが今に至るまで用意されていないところとか、パイプラインを何とかして途中で打ち切っても、endは実行されないところとかを見ていると、そもそもPowerShellではパイプライン処理の中断というのは、あまり想定してない操作なのかなぁ、という気がしてきています。

上記のような裏技を使って回避するのも一案ではあるとは思いますが、そもそも「パイプライン処理の中断はイレギュラー」と考えて、そういう処理は避けて、必要に応じて別のアプローチを取ることも考えた方がいいのかもしれません。

2015/12/10

この記事はPowerShell Advent Calendar 2015の10日目の記事です。

はじめに

前編では、Invoke-WebRequestコマンドレットやWebClientクラスを用いて、WebページからHTMLの文字列を取得するところまで説明しました。

後編の今回は、取得したHTML文字列をパースして、オブジェクトとして利用可能しやすい形に変換する話です。

IEエンジンによるHTMLパース(DOM)

前編でも触れましたが、Invoke-WebRequestコマンドレットは、レスポンス文字列を取得すると同時に、HTMLをパース(構文解析)し、結果をオブジェクトとして構造化してくれます。

実はこのHTMLパース、内部的にInternet Explorerのエンジンを呼び出すことで実現されています。(ちなみに後で説明しますが、-UseBasicParsingパラメータを付与すると、IEエンジンを使わずごく基本的なパースのみ行うようになります。)

Invoke-WebRequestコマンドレットの出力であるHtmlWebResponseObjectオブジェクトのParsedHtmlプロパティを経由することで、HTMLパースされたオブジェクトを、DOM(Document Object Model)に従ってアクセスすることができます。(-UseBasicParsing指定時は不可)

HTMLのtable要素を切り出し、table各行を1オブジェクト、各セルをプロパティとして、オブジェクト配列化する例を以下に示します。

$response = Invoke-WebRequest http://winscript.jp/powershell/301

# DOMを利用して1つ目のtable要素を取得
$table = $response.ParsedHtml.getElementsByTagName("table")| select -First 1

# tableの1行目をプロパティ名として取得
$properties = ($table.rows| select -first 1).Cells| foreach {$_.innerText}

# tableの残りの行に対して、各セルのinnerTextをプロパティ値としてオブジェクト化
$objs = foreach($row in ($table.rows| select -skip 1))
{
    $row.Cells| foreach -Begin {
        $index = 0
        $obj = [ordered]@{}
    } -Process {
        $obj += @{$properties[$index] = $_.innerText}
        $index++
    } -End {
        [pscustomobject]$obj
    }
}

$objs| Format-List

ところで前編で軽く触れましたが、IEエンジンによるパースは、Invoke-WebRequestコマンドレットを用いずとも、以下のようにして直接IEのCOMインターフェースを呼ぶことで利用可能です。

$client = New-Object System.Net.WebClient
$content = $client.DownloadString("http://winscript.jp/powershell/301")
$parsedHtml = New-Object -com "HTMLFILE"
$parsedHtml.IHTMLDocument2_write($content)
$parsedHtml.Close()
$table = $parsedHtml.getElementsByTagName("table")| select -First 1
# 以下同様…

というより実際に試すと直接IEエンジンを呼び出す方がずっと速いです。理由はよく分かりませんが…。

HTML要素コレクションの取得

Invoke-WebRequestコマンドレットを用いると、DOMとは別に、すべての要素(AllElementsプロパティ)、input要素(InputFieldsプロパティ)、img要素(Imagesプロパティ)、a要素(Linksプロパティ)、script要素(Scriptsプロパティ)を含むコレクションを、HtmlWebResponseObjectオブジェクトの対応するプロパティからそれぞれ取得することができます。

コレクションに含まれる各要素は、innerText(タグ内の文字列)、innerHTML(タグ内のHTML)、tagName(タグ名)等のプロパティが共通して利用可能です。また要素の属性(たとえばa要素ならリンク先を示すhref属性)に、プロパティとしてアクセス可能となります。

以下はBingでWeb検索した結果から、ページタイトルとURLを抜き出す例です。HtmlWebResponseObjectのLinksプロパティでa要素の配列を取ってきて、次に検索結果では無いっぽいURLを、hrefプロパティの値を見てwhereで除外し、最後にinnerTextプロパティとhrefプロパティをTitle、Urlとリネームしてから値を出力しています。泥臭い処理が混じってますが、この泥臭さがスクレイピングなのかもなぁと思います。

$searchWord = "PowerShell 配列"
$notSearchResults = "/","#","javascript:","http://go.microsoft.com/"
$response = Invoke-WebRequest "https://www.bing.com/search?q=$([Uri]::EscapeDataString($searchWord))"
$response.Links | 
    where {
        $href = $_.href
        !($notSearchResults|? {$href.StartsWith($_)})
    }|
    select @{L = "Title"; E = "innerText"}, @{L = "Url"; E = "href"}|
    Format-List

form要素についてもほぼ同様にFormsプロパティからコレクションを取得できますが、このコレクションにはFormObjectという特別なオブジェクトが含まれます。FormObjectのFieldsプロパティは、Key=パラメータ名、Value=パラメータ値が格納された連想配列となっています。この連想配列は書き替えが可能なので、前編で説明した、ログオンを要するWebサイト等で用いると便利かと思います。

以下に、HtmlWebResponseObjectオブジェクトのプロパティをまとめます。(×印は使用不可を表す)

プロパティ名 説明 -UseBasicParsing
指定時
AllElements 本文に含まれるすべての要素のコレクション ×
Forms フォーム(form要素)のコレクション ×
InputFields 入力フィールド(input要素)のコレクション  
Images 画像(img要素)のコレクション  
Links リンク(a要素)のコレクション  
Scripts スクリプト(script要素)のコレクション ×

このように、一部のプロパティについては-UseBasicParsing指定時でも利用可能です。サーバーOS等でIEエンジンが利用できない場合には-UseBasicParsingパラメータが必須となりますが、その場合でも最低限のパースはしてくれるわけです。

HTML要素のコレクションを利用する方法は、DOMを使う方法に比べると自由度は少ないですが、「ページから画像のリストを取得したい」等の処理は簡便に行うことができます。

その他のHTMLパース手法

最後に、Invoke-WebRequestコマンドレットとIEエンジン以外のHTMLパース手法について軽くご紹介します。

XMLとしてパース(XHTML限定)

XHTMLというのはごくかいつまんで言うと、HTMLをXMLで定義したものです。XHTMLはXMLなので、XMLとしてパースして用いることができます。

PowerShellは[xml](XmlDocument)型アクセラレータと型アダプタにより、XML要素への簡便なアクセス手段を提供しています。以下のように、[xml]型アクセラレータを用い、取得したXHTML文字列を[xml]型に変換すると、以降は型アダプタの機能により、ドット演算子で要素を辿っていくことができます。

$client = New-Object System.Net.WebClient
$content = $client.DownloadString("XHTMLなページ")
$xml = [xml]$content
$xml.html.body.h2.'#text'

ただ世の中のWebページ上のXHTML文書が、すべてXML文書としてvalidなものであるかと言われると、現実はかなり厳しいです。そしてXML文書としてエラーがある場合は、型アクセラレータの処理は容赦なく失敗します。なのでこの手法は「使えたら強いが、大抵使えない」レベルのものと思って頂ければいいと思います。

SgmlReader

標準機能にこだわらなければ、.NET製のHTMLパーサーを使うのが楽かと思います。SgmlReaderは通常のHTML文書(当然、XHTMLに限らず)をXmlDocumentへとパースしてくれるので、PowerShellと相性が良いのではないかと思います。

以下にサンプルを載せておきます。

Add-Type -Path .\SgmlReaderDll.dll

function Get-HTMLDocument
{
    param([uri]$Uri)
    $sgmlReader = New-Object Sgml.SgmlReader -Property @{
        Href = $Uri.AbsoluteUri
        CaseFolding = [Sgml.CaseFolding]::ToLower
    }
    $doc = New-Object System.Xml.XmlDocument
    $doc.Load($sgmlReader)
    $doc
}

$xml = Get-HTMLDocument http://winscript.jp/
$xml.html.body.div|? id -eq outer|% div|? id -eq main|% {$_.p.innerText}

ぎたぱそ氏も以前SgmlReaderを取り上げておられるので、そちらも参考にして下さい。:Html Agility Pack と SgmlReader を使って PowerShell でスクレイピングしてみる - tech.guitarrapc.cóm

正規表現等で自前パース

これまではHTMLパースを既存のコマンドやライブラリを用いて行ってきましたが、対象のHTMLが非常にシンプルである場合とか、HTMLですらなく単なるテキストの場合だとか、対象ページは分量が多いものの必要箇所はごくわずかで、かつピンポイントに取得可能な場合等々は、むしろ自前でパースするコードを書いた方が手っ取り早いこともあります。

例えばYAMAHAのルーターで、管理Webのシステム情報レポートからグローバルIPアドレスを取ってくる、みたいなことは、

$response = Invoke-WebRequest http://サーバー/detail/status.html -UseBasicParsing -Credential $credential
if($response.Content -match "PP IP Address Local\: (.+)\,")
{
    $ipAddress = $Matches[1]
}

のようなコードで十分かと思います。

ConvertFrom-String

これはまだ検証してないんですが、PowerShell5.0の新機能、Auto-Generated Example-Driven Parsingの実装であるConvertFrom-Stringコマンドレットを用いて、HTMLパースができないかな、と考えています。

ConvertFrom-Stringについては過去記事参照:[v5] Auto-Generated Example-Driven Parsing について - PowerShell Scripting Weblog

まとめ

前後編に渡って、PowerShellでのWebスクレイピングの手法について解説しました。スクレイピングはWeb APIが用意されていない場合の苦肉の策ですが、背に腹は代えられない場合というのは稀によくあると思います。そういうときに今回の記事が参考になれば幸いです。

次回あたりには、Web APIがちゃんと用意されてる場合に、PowerShellから利用する話をやろうかと思います。

2015/12/04

この記事はPowerShell Advent Calendar 2015の4日目の記事です。

はじめに

今回はPowerShellでWebページのスクレイピングをする際の、ちょっとしたノウハウ集を前後編に分けて紹介したいと思います。

スクレイピングというのは、Webページから文字列を取ってきて、スクリプトから利用可能な形に加工する処理です。昨今は多くのWebサイトやサービスでWeb APIが公開されていて、スクレイピングをせずとも比較的簡単にデータを取得できます。PowerShellだとInvoke-RestMethodコマンドレット等が使えます(その話はまた次回とかにやります)。

しかし現実には、APIが公開されていない等の理由で、HTMLを取ってきて自前で解釈せざるを得ないケースが多々あります。さて、PowerShellではどうやりましょうか、というのが今回の話。様々な方々によってもう色々と語られている分野ではあるのですが、結構細かいハマりどころがあるのでちょっとまとめてみようと思いました。

前編ではまず、Webページからの文字列の取得方法ついてまとめます。

なお、スクレイピングには技術的な問題以外の、微妙な問題(著作権の問題とか、Webサイトへの攻撃と見なされる可能性とか)を含むものなので、その辺りは各自どうかご留意ください。この辺りの話はPowerShellに限った問題ではないので、ここでは詳説いたしません。参考記事:Webスクレイピングの注意事項一覧 - Qiita

Invoke-WebRequestコマンドレットで文字列を取得する

PowerShellでのスクレイピング、基本は何はなくともInvoke-WebRequestコマンドレットです。ただしこのコマンドレットはPowerShell 3.0で追加されたものなので、2.0環境にはないことに注意です。その場合は.NETのWebClientクラス等を使う方法があり、後で述べます。

基本は、

$response = Invoke-WebRequest -Uri "http://winscript.jp/"

のように、Invoke-WebRequestコマンドレットを実行する、だけです。「-Uri」は省略可能です。

このとき$responseにはHtmlWebResponseObjectオブジェクトが格納されています。このうち、指定URLのWebページに含まれているHTMLなどの文字列データは、Contentプロパティに格納されます。つまり、$response.Content に欲しいデータが格納されているので、あとはそれをよしなに利用すればいいわけです。

実はInvoke-WebRequestは、文字列データを取得すると同時に、HTMLの場合はパースしてタグの構造をオブジェクト化までしてくれます。が、それについては次回。

なお、Invoke-WebRequestコマンドレットでは文字列を取得する他、バイナリデータをダウンロードしてファイルとして保存する機能もあります。それについては過去記事をご参照ください。

リクエストにパラメータを付与する(GET)

GETメソッドを用いてクエリを指定する場合、要はhttps://www.google.co.jp/search?q=PowerShell のようなURLのデータを取得する場合は、Invoke-WebRequest "https://www.google.co.jp/search?q=PowerShell" のようにQueryStringを含んだURLをそのまま指定するだけでOKです。

ただし、動的にクエリを組み立てる場合は、URIエンコード(URIエスケープ)を考慮する必要があります。もっとも簡単なのは

$searchWord = "PowerShell 配列"
$response = Invoke-WebRequest "https://www.google.co.jp/search?q=$([Uri]::EscapeDataString($searchWord))"

のように、Uri.EscapeDataStringメソッドを使う方法かと思います。

リクエストにパラメータを付与する(POST)

POSTメソッドでリクエストボディにパラメータを付与するには、Invoke-WebRequestコマンドレットの-Methodパラメータに"Post"を指定し、-Bodyパラメータにリクエストボディに付与するデータを連想配列で指定します。

たとえばブログのトラックバックを手動で撃つにはこんな感じでいけます。

$body = @{title="テスト";url="http://example.com/";excerpt="テスト";blog_name="test"}
Invoke-WebRequest http://ご自分のブログのトラックバックpingURL -Method POST -Body $body

なお、リクエストボディに含めるパラメータの各値(連想配列の値)は、自動でURIエンコードしてくれます。

(12/16追記)
また、-Bodyには連想配列のみならず、任意の文字列(URIエンコード要)やバイト配列(バイナリを送信する場合)を指定することも可能です。

標準認証が必要なページを取得する

ページの取得に標準認証が必要な場合は、-Credentialパラメータにユーザー名とパスワードを指定したPSCredentialオブジェクトを指定すればOKです。

セキュリティのことは取りあえず置いておき、簡易的にスクリプトに生パスワードを直書きしてもいいかな、という場合には以下のように書くことができます。

$userName = "user"
$password = "pass"
$credential = New-Object PSCredential $userName, (ConvertTo-SecureString $password -AsPlainText -Force)
Invoke-WebRequest 認証が必要なページのURL -Credential $credential

しかしこの方法はもちろんお勧めできないので、スクリプトとして保存する場合は通常はパスワードを暗号化しておきます。

まず、Get-Credential ユーザー名 | Export-Clixml cred.xmlを、スクリプトを実行するコンピュータ上で、スクリプトを実行するアカウントと同じアカウントで実行します。パスワードを入力するダイアログが出るので、Webサイトにログオンする際のパスワードを入力します。すると、ユーザー名と暗号化されたパスワードがcred.xmlに出力されます。

スクリプトからは

$credential = Import-Clixml cred.xml
Invoke-WebRequest 認証が必要なページのURL -Credential $credential

のようにすると、cred.xmlからユーザー名と復号したパスワードを、そのまま-Credentialパラメータに渡すことが可能です。

なおcred.xmlに含まれる暗号化パスワードは、ConvertFrom-SecureStringコマンドレットと同様、Windows Data Protection API(DPAPI)を用いてWindowsアカウントのパスワードをキーに利用して暗号化されているので、他のユーザーが復号することはできません。

ちなみに同一スクリプトファイルに暗号化パスワードを含めておくこともできなくはないです。過去記事参照。あと本当は資格情報マネージャーを使うのがいいんですが、…略。参考:PowerShell で Windows の 資格情報マネージャー を利用する (Jenkins などでの Git Credentialなど) - tech.guitarrapc.com

セッション情報を引き継ぐ

多くのWebアプリケーションは、同一クライアントからの連続したアクセスを、セッションという単位で管理します。

サーバーはクライアント(普通はWebブラウザ)の初回アクセス時にセッションIDを含むcookieを返し、クライアントからの2回目のアクセス時に、サーバーはcookieにセッションIDが含まれているかどうかを確認し、同一クライアントからのアクセスかどうかを判断するわけです。(ざっくりした説明ですが)

WebブラウザではなくInvoke-WebRequestを使ったアクセスでも同様に、以下のようにすれば受けとったcookie等のセッション情報を次回アクセスに引き継ぐことができます。

$url = "https://ログオンが必要なサイト"
$body = @{リクエストボディ(例えばユーザー名とかパスワードとか)}
$response = Invoke-WebRequest $url -SessionVariable sv -Method POST -Body $body
Invoke-WebRequest $url -WebSession $sv

初回アクセス時に-SessionVariableパラメータに指定した変数名(sv)の変数($sv)にはWebRequestSessionオブジェクトが格納されます。この中に、サーバーから受け取ったcookie等の情報が格納されています。

次回アクセス時には、-WebSessionパラメータに、初回アクセス時に得られたWebRequestSessionオブジェクト($sv)を指定します。

さて、実際のWebアプリケーションではcookie以外にも、Formのhiddenフィールドの値などもセッション管理に用いていることがあります。その場合は、初回アクセスのレスポンスからFormに含まれるinput type="hidden"なフィールドを抽出し、次回アクセスのリクエストボディに含ませる必要が出てきます。この辺りの話は後編で述べるパースが必須になってくる(し、長くなる)ので今回は詳説しません。Invoke-WebRequestコマンドレットのリファレンスのExample2に、Facebookにログオンする例なんてのがあるので、そちらで雰囲気をつかんでください。(今でも動作するかは確認してないですが)

エラートラップ

さて、Invoke-WebRequestは、タイムアウトになった、名前解決ができなかった、ページが無かった(404エラー)等々、正常にWebページを取得できなかった場合は、System.Net.WebExceptionというエラーを出します。

コマンドレットの出すエラー(Errorストリームに出力されるErrorRecord)は、try...catchステートメントでは捕捉できない、というのが原則ですが、Invoke-WebRequestコマンドレットのエラーは一般的なコマンドレットと異なり、普通の.NETの例外(System.Net.WebException)なので、try...catchステートメントでエラートラップを行います。

とは言え、Invoke-WebRequestコマンドレットの仕様上、エラートラップをして適切な処理を行うのは非常にめんどいです。何故かというと、Invoke-WebRequestがエラーを出した時点で、HtmlWebResponseObjectオブジェクトの出力は行われないので、このオブジェクトから得られる様々な情報(レスポンス文字列、ステータスコード等々)が取得できないからです。

じゃあどうすればいいのかという話なんですけど、どうもWebExceptionオブジェクトのResponseプロパティを見るしかないようです。具体的にはこんな感じ。

try
{
    $response = Invoke-WebRequest http://存在しないページなど
}
catch [System.Net.WebException]
{
    # HTTPステータスコード取得
    $statusCode = $_.Exception.Response.StatusCode.value__

    # レスポンス文字列取得
    $stream = $_.Exception.Response.GetResponseStream()
    $reader = New-Object System.IO.StreamReader $stream
    $reader.BaseStream.Position = 0
    $reader.DiscardBufferedData()
    $responseBody = $reader.ReadToEnd()
}

せっかくInvoke-WebRequestコマンドレットは、生のレスポンスを利用しやすくHtmlWebResponseObjectという形で返してくれるのに、エラー発生時はその恩恵を受けることができず、泥臭い処理が必要になります。これはかなりいけてないですし、どうせここまで書かないといけないのであれば最初からWebClientクラスを使った方がいいと思います。

httpsで無効な証明書が使われている場合

(12/16追記)
Invoke-WebRequestコマンドレット(およびWebClient)では、httpsで始まるURLからもダウンロード可能ですが、サイトで用いられている証明書に問題がある場合(期限が切れている、暗号化形式に問題がある、いわゆるオレオレ証明書である等)には、「要求は中止されました。SSL/TLSセキュリティで保護されているチャネルを作成できませんでした」というエラーが出てしまいます。

これを回避するには、Invoke-WebRequestコマンドレット実行前に、

[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}

という1文を記述しておきます。

ただし、証明書に問題があるということは、その通信相手が正当かどうか、通信内容が正しく秘匿されているかどうか、保証がされなくなるということですから、その点は念頭においてください。

文字化けの問題

Invoke-WebRequestコマンドレットのもう一つの悩ましい問題、それは文字コードです。実はInvoke-WebRequestコマンドレットには、Webページの文字コードを指定する方法がありません。(多分)

ではレスポンス文字列の文字コードがどのように決まるかというと、サーバーが返すレスポンスヘッダのContent-Typeフィールドで指定されているcharsetです。具体的には、$response.Headers["Content-Type"]の値が例えば"text/html; charset=UTF-8"であれば、$response.Contentの文字コードはUTF-8になります。

このときページ(HTML)を記述している文字コードと、レスポンスヘッダで指定されている文字コードが一致すれば全く問題はないのですが、異なる場合は容赦なく文字化けします。

異なる場合だけでなく、レスポンスヘッダのContent-Typeフィールドに文字コードの指定がない場合はASCIIと見なされるので、日本語のページの場合はやはり文字化けします。

この問題を回避する方法は、私はまだ見つけていません。よって文字化けが起きる場合は、諦めてWebClientを使って文字コードを指定するようにしています…。

WebClientを用いる

以上で述べてきたとおり、Invoke-WebRequestコマンドレットは、ページをさくっと取得して、さくっとパースするのには重宝するのですが、細かい所で融通が利かない印象があります。

そこで細かい処理が必要な場合(と、PowerShell 2.0環境)は、素直にWebClientクラスを用いるのがいいと思います。今回WebClientの使い方も入れようかと思いましたが、長くなったので詳しくは省略します。

基本は以下のような感じでDownloadStringメソッドを使って文字列を取得します。文字コードも指定できます。

$client = New-Object System.Net.WebClient
$client.Encoding = [System.Text.Encoding]::UTF8
$content = $client.DownloadString("http://アドレス")

なお、WebClientを用いた場合でも、Invoke-WebRequestと同等のHTMLパースを行う方法は存在するので、それは次回に。

おわりに

今回はまず、Webページから文字列データを取得する部分にフォーカスしてみました。といっても、Invoke-WebRequestの機能を全部網羅したわけではなく、使用頻度が高そうなものと個人的ハマリポイントがあるところだけです。なので詳しくはリファレンスを見て下さい。というかハマリポイントたぶんまだまだ一杯あると思います。

後編では、とってきた文字列データを「パース」して、扱いやすいデータ形式に変換する方法についてまとめようかと思います。

2015/11/14

既出ですが、まとめておきます。

今回登場する関数は、elevate.ps1という名前で一つにまとめて保存しておくと便利です。

起動中のスクリプトが管理者権限で実行されているかどうかの確認
function Test-Admin
{
     (
        [Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::
        GetCurrent()
     ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

スクリプト中でこのTest-Admin関数を実行してTrueが返れば、スクリプト(もしくはコンソール)は管理者権限で実行されている。

管理者権限で任意のスクリプトを起動
function Start-ScriptAsAdmin
{
    param(
        [string]
        $ScriptPath,
        [object[]]
        $ArgumentList
    )
    if(!(Test-Admin))
    {
        $list = @($ScriptPath)
        if($null -ne $ArgumentList)
        {
             $list += @($ArgumentList)
        }
        Start-Process powershell -ArgumentList $list -Verb RunAs -Wait
    }
}

Start-ScriptAsAdmin "任意のps1ファイルパス" を実行すると、指定のスクリプトが管理者権限で実行される。(実行前にUACダイアログが表示される)

なお、"powershell"の部分を、今起動しているホストの実行ファイルと同じにするには、(Get-Process -Id $pid).Path としても良い。ただ、ISEの場合だと単にスクリプトファイルが開かれた状態になるだけで実行まではしてくれない。

管理者権限がない場合、昇格してスクリプトを再実行する

管理者権限を要する処理の直前で、以下のようにStart-ScriptAsAdmin関数を実行すると、仮に管理者権限がない場合はスクリプトを再起動し、昇格して再実行する。(管理者権限がある場合は再実行せずそのまま継続する。)

. .\elevate.ps1
Start-ScriptAsAdmin -ScriptPath $PSCommandPath
if(Test-Admin)
{
    "昇格を要する処理"
}

v3未満の場合は、実行中のスクリプトパスを示すシェル変数$PSCommandPath の代わりに &{$MyInvocation.ScriptName}が使える

再起動するまでに実行した処理は再実行しないようにするには、以下のようにスクリプトファイルにパラメータを定義すれば良い。

param([switch]$SkipNormalTask)
. .\elevate.ps1

if(!$SkipNormalTask)
{
    "昇格不要な処理1"
}

Start-ScriptAsAdmin -ScriptPath $PSCommandPath -ArgumentList "-SkipNormalTask"

if(Test-Admin)
{
    "昇格を要する処理"
}

if(!$SkipNormalTask)
{
    "昇格不要な処理2"
}
UACダイアログを出さずに管理者権限でスクリプトを実行する

一般的な方法と同様、タスクスケジューラを利用する。

PowerShellからタスクスケジューラにスクリプトを登録するには、PowerShell3.0以上に同梱されているPSScheduledJobモジュールが必要。

function Register-ScriptAsAdmin
{
    param(
        [string]
        $ScriptPath,
        [object[]]
        $ArgumentList
    )

    $jobArgs=@{
        FilePath = $ScriptPath
        ScheduledJobOption = New-ScheduledJobOption -RunElevated
        Name = "RunAsAdmin $(Split-Path -Leaf $ScriptPath)"
    }
    if($null -ne $ArgumentList){$jobArgs+=@{ArgumentList = $ArgumentList}}

    Register-ScheduledJob @jobArgs 
}

function Invoke-ScriptAsAdmin
{
    param(
        [string]
        $ScriptPath
    )
    $job = Get-ScheduledJob -Name "RunAsAdmin $(Split-Path -Leaf $ScriptPath)"
    $job.RunAsTask()
}

function Unregister-ScriptAsAdmin
{
    param(
        [string]
        $ScriptPath
    )
    Unregister-ScheduledJob -Name "RunAsAdmin $(Split-Path -Leaf $ScriptPath)"   
}

まず、管理者権限で実行したいスクリプトをRegister-ScriptAsAdmin "スクリプトのフルパス"としてタスクスケジューラに登録。

この操作には当然ながら管理者権限を要するので、もし登録用スクリプトから登録をするには前述のStart-ScriptAsAdminを併用しても良い。(さすがにUACダイアログを1回も表示させない方法はなさげ…)

登録が済んだら後は、Invoke-ScriptAsAdmin "スクリプトのフルパス"とすれば、UACダイアログを表示させずに管理者権限でスクリプトを実行できる。

スクリプトの登録を解除するには、Unregister-ScriptAsAdmin "スクリプトのフルパス"を実行する。(この操作には管理者権限不要)

管理者権限がない場合はスクリプトを実行しない

逆に、管理者権限がない時は、一切処理を実施させたくない場合。

v4以上の場合は、以下をスクリプトの一行目に定義しておくだけでOK。管理者権限がない場合はエラーになってそのままスクリプトは終了する。

#Requires -RunAsAdministrator

v4未満の場合は、前掲のTest-Admin関数を以下のようにスクリプト先頭で呼び出せば良い。

if(!(Test-Admin))
{
    throw "管理者権限がありません"
}

2015/09/07

Twitterでこんな問題を出してみました。

以下、解答になります。

@ &{}
結果

何も出力されません。

解説

空のスクリプトブロック{}を実行演算子&で実行しています。空なので何も出力はありません。

A &{process}
結果

「'process' の後にステートメント ブロックがありません。」というパーサーのエラーになります。

解説

PowerShellのスクリプトブロックは、beginブロック、processブロック、endブロックを内包します。スクリプトブロック直下にparamブロック、DynamicParamブロック、beginブロック、processブロック、endブロック(他にもあったかも)以外のステートメントを記述すると、Endブロック内に記述されたものと暗黙的に解釈されます。

この場合、スクリプトブロック直下にprocess…と書き始めたので、パーサーはprocessブロックが開始されたと判断しますが、続くステートメントブロック{}(≠スクリプトブロック)の記述がないため、構文エラーとなります。

B &{process{}}
結果

何も出力されません。

解説

パーサーはAのように解釈しますが、今回はステートメントブロック{}がきちんと記述されているので、エラーなく解釈されます。

processブロックは、パイプライン入力がない場合でも1回実行されますが、この場合、中身は空なので、@と同様、何も出力はありません。

C &{process{process}}
結果

Get-Processコマンドレットが実行され、プロセス一覧が表示されます。

解説

・パーサーの挙動

Bまでの解説の通り、&{process{…}}とすると、…の部分が1回実行されます。今回はprocessブロック内に「process」と記述しているので、Aのようなパーサーエラーは発生せず、「process」がステートメントとして実行されます。

さて、PowerShellのステートメント(文)には「For」とか「If」とかと並列して、「パイプライン」が存在します。「パイプライン」には1つの「式」もしくは複数の「コマンド」が含まれます。

たとえば、「Get-ChildItem | Select-Object Name」というパイプラインには「Get-ChildItem」と「Select-Object Name」という2つのコマンドが含まれます。

(ちなみに、「式」とは「$x+1」とかの、値を返すもののことです。PowerShellではパイプラインの最初の要素にのみ、「コマンド」ではなく「式」を記述することができます。)

今回のお題では、「process」はprocessブロック下に記述されており、ForやIf等のステートメントではないのでパイプラインとして扱われます。このパイプラインには1つの要素のみ含まれていますが、式ではないので、コマンドとして解釈されます。

・コマンド探索の挙動

PowerShellの「コマンド」は、関数、コマンドレット、ワークフロー、Configuration、ファイル(実行ファイル、スクリプトファイルを含む)、&演算子で実行するスクリプトブロック等が挙げられます。

コマンドの探索は、まずコマンドへのエイリアスを探します。ない場合は、関数名orコマンドレット名を探します。それでもない場合は、実行ファイルやスクリプトファイルの拡張子(.exe、.ps1等)を付与してパスの通ったディレクトリを探します(ちなみにカレントディレクトリにあったとしても、相対パスor絶対パス表記でない場合は実行しません)。

さて、ここからが「本当は怖い」ところなんですが、ここまで探索してコマンドがなかった場合、与えられたコマンド名に"Get-"を付与してもう一度探索します。

今回のお題では、processという名前のコマンドを探して、もしパスが通ったフォルダにprocess.exeとかがあればそれが実行されますが、ない場合はGet-Processというコマンド名を探します。

もちろん、Get-Processというコマンドレットは標準で存在するので、それが実行されてしまう、というわけでした。

(ちなみにPowerShell 3.0以降なら、Get-付与で見つからない場合、さらにCmdlet Auto Discoveryにより未ロードのモジュールを探します。)

コマンド探索の詳細な挙動は、Trace-Command -Expression {コマンド}  -Name CommandDiscovery -PSHost とすると調べられるので、見てみるのもいいかもしれません。

D &{process{process{}}}
結果

「Get-Process : パラメーター 'Name' を評価できません。その引数がスクリプト ブロックとして指定され、入力が存在しないためです。スクリプト ブロックは、入力を使用せずに評価できません。」というParameterBindingExceptionが発生します。

解説

・パーサーの挙動

Get-Processが実行され(ようとす)る理由についてはCまでの理解でOKでしょう。

さて、Get-Processコマンドレットには-Nameという、プロセス名を指定する位置パラメータが存在します。位置パラメータは、パラメータ名を指定せずパラメータ値のみを指定しても、指定順にパラメータにバインドしてくれる機能を持ちます。

たとえば、Get-Process powershell とすると、「Get-Process -Name powershell」が実行されます。

今回のお題「process{}」は、パーサーによってまず、コマンド名「process」と、パラメータ値「{}」(空のスクリプトブロック)に分割されます。

(ちなみにコマンド名に「{}」を含めることができないわけではなく、そういうコマンドを実行したい場合は、`でエスケープするか、&"command{}name"のように&演算子を用いれば可能です。)

今回の場合、パラメータ名の指定はありませんが、位置パラメータ-Nameに空のスクリプトブロックがバインドされることになるわけです。

・コマンドパラメータバインドの挙動

さて、-Nameパラメータの型は、System.String[]であり、scriptblockではありません。もちろんscriptblockからSystem.String[]への暗黙の型変換はありません。でもエラーメッセージ的には、スクリプトブロックを与えたこと自体は咎めていないように思えますね。

実はこれ、スクリプトブロックパラメータと呼ばれてる機能です。詳しくはスクリプトブロックパラメータのススメを見ていただくとして、要はコマンドへのパイプライン入力を、指定のスクリプトブロックで処理し、その出力結果をパラメータ値としてバインドする機能ですね。

今回エラーになった理由は、スクリプトブロックパラメータとして解釈しようとしたが、そもそも入力がなかったから、ということになります。

あまり意味はないですが、以下のように入力を与えてやれば、スクリプトブロックパラメータとして動作します。

"powershell" | Get-Process -Name {$_}

この場合パイプライン入力が追加されるので、-Nameパラメータの指定位置がずれることになるので、パラメータ名が必要になります。また、スクリプトブロックが空だと、「パラメーター 'Name' を評価できません。その引数の入力によって出力が作成されなかったためです。」というエラーをご丁寧に出してくれます。Trace-CommandでParameterBindingソースをトレースしてみるのも一興でしょう。

ちなみにあまり関係ない余談ですが、-NameパラメータにはValueFromPipelineByPropertyName属性が付いているので、実は以下のような指定もできます。

[PSCustomObject]@{Name="PowerShell"} | Get-Process

まとめ

PowerShellパーサーと飲むとき、話の肴にどうですかね。

See also: 本当は怖いPowerShell その1

2015/08/10

C#6.0のnameof演算子(じんぐるさんによる解説岩永さんによる解説)が羨ましかったので、PowerShellでも似たようなことができるようにしてみました。

function nameof
{
    param([scriptblock]$s)
    $element=@($s.Ast.EndBlock.Statements.PipelineElements)[0]
    if($element -is [System.Management.Automation.Language.CommandExpressionAst])
    {
        switch($element.Expression)
        {
            {$_ -is [System.Management.Automation.Language.TypeExpressionAst]}
                {$_.TypeName.Name}
            {$_ -is [System.Management.Automation.Language.MemberExpressionAst]}
                {$_.Member.Value}
            {$_ -is [System.Management.Automation.Language.VariableExpressionAst]}
                {$_.VariablePath.UserPath}
        }
    }
    elseif($element -is [System.Management.Automation.Language.CommandAst])
    {
        $element.CommandElements[0].Value
    }
}

nameof{$PSHOME}                      # 変数名 : PSHOME
nameof{$PSHOME.Length}               # プロパティ名 : Length 
nameof{[System.Diagnostics.Process]} # クラス名 : System.Diagnostics.Process
nameof{[string]::Empty}              # フィールド名 : Empty
nameof{[DayOfWeek]::Friday}          # 列挙体メンバー名 : Friday
nameof{Get-Command}                  # コマンド名 : Get-Command

原理的には、変数やプロパティ等をスクリプトブロックに格納し、生成されるAST(抽象構文木、abstract syntax tree)を解析して、含まれる変数名やプロパティ名を抽出しています。(なので、PowerShell 3.0以上でないと動作しないと思います)

そもそも、どういうシチュエーションで使うの?という話ですが、実はあえとすさんのPowerShell コマンドを C# で書くときに便利な拡張メソッド - 鷲ノ巣という記事を見て、じゃあPSでコマンド(高度な関数)を書く時にも同じことが出来るといいかな?と思ったのがきっかけです。

function Get-Test
{
    [CmdletBinding()]
    param([int]$Number)
    if($PSBoundParameters.ContainsKey((nameof{$Number})))
    {
        "-$(nameof{$Number})パラメータが指定された"
    }
}

こういう風に、"Number"という文字列をコード中に書かずに、-Numberパラメータ指定の有無を確認できるようになる、というわけです。

(この例の場合だと、Get-Test -Number 12 のようにすると、if文の中身が実行されます。)

ただ作ってはみたものの、使う意味がどれほどあるのか疑問に思えてきました。一応、ISEでは変数名やメンバ名に入力補完が効くので、実際の変数名を文字列で手打ちしなくて済むというメリットはなきにしもあらず、ですか。

しかし所詮は動的言語なので、存在しない変数名やメンバ名を入れても実行前にエラーは出ないですからね。(Set-StrictModeによるストリクトモードは編集時ではなくあくまで実行時(正確には変数やメンバを参照した瞬間)にエラーを出すためのもの)

それとISEのリファクタリング機能は弱い(というか無い)ので、リファクタリングに追従できるという本家nameof演算子に存在するメリットは、現状のところISEを使っている限りは享受できません。

PowerShell 5.0からの新要素、Script Analyzerによる静的解析を組み合わせればあるいは意味が出てくるのかもしれないですが、まだ確認できてないです。

というわけで、書いてはみたもののなんか微妙ですが、せっかくなんで公開しときます。


古い記事のページへ |


Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー

Twitter

Books