2016/03/13
わんくま同盟大阪勉強会#66「PowerShellコマンドとエラー処理」資料公開
昨日3/12(土)開催の、わんくま同盟大阪勉強会#66にお越しいただいた方、どうもありがとうございました。
私の行った「PowerShellコマンドとエラー処理」というセッションの資料を公開します。
あまりPowerShellのエラー体系についてまとまった資料がないように思いますので、参考にしていただければ幸いです。
Osaka ComCamp 2016「PowerShell DSCを用いたInfrastructure as Code の実践」資料公開
2/20開催のOsaka ComCamp 2016 powered by MVPsで行った、「PowerShell DSCを用いたInfrastructure as Code の実践」というセッションの資料を公開します。
また、セッション中に行ったデモスクリプトはこちら。Pullサーバーを自動構築する例です。
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/21
[v5]パラメータ検証属性をclass構文で作ってみる
この記事はPowerShell Advent Calendar 2015の21日目の記事です。
PowerShellの属性
PowerShellには2.0から言語機能として「属性」機能が追加されました。PowerShellの属性はC#の属性とほぼ同じものですが、2.0〜4.0の時点では、高度な関数(Advanced Function)を作成するために関数に付与するCmdletBinding属性(記述上ではparamブロックに付与する形になる)、関数のパラメータに付与するParameter属性やAlias属性等、関数のパラメータや変数に付与する各種検証属性(Validate〜、Allow〜系)くらいしか使うことはなかったかと思います。
このうちパラメータ検証属性については、あえとす氏が以前詳しく解説しておられます。:パラメーターの検証属性について/前編 - 鷲ノ巣
PowerShellで属性クラスを作る
さて話は変わって、つい先日、WMF 5.0 / PowerShell 5.0がRTMしました。Windows 7/8.1、Windows Server 2008R2/2012/2012R2用のインストーラーが公開されたので、もう使っておられる方もいると思います。ぎたぱそ氏が一晩で解説記事を書いてくれてますね。
PowerShell 5.0では言語機能としてclass構文がついに追加されました。これでついにPowerShell言語も真の意味でオブジェクト指向言語の要素を完備したと言えるかと思います。
このclass構文、.NET Frameworkの既存のクラスを基底クラスとして継承するなんてことも、普通にできてしまいます。そこで私が考えたのは、もしかしてこれで属性も作れるんじゃない?ということでした。
v5のclass構文の基本的な部分の解説は、また別の機会にということにしまして、今回はいきなり、属性を作る話をしていきます。
ちなみにC#の属性については、属性 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C が参考になります。PowerShellで属性を作る時も基本はだいたい同じかと思います。
属性クラスの例
属性パラメータ(コンストラクタ引数)にDescription、名前付きパラメータとしてNoを指定でき、classに適用可能なTest属性はこんな感じです。
[AttributeUsage([AttributeTargets]::Class)] class TestAttribute : Attribute { [string]$Description [int]$No TestAttribute($Description) { $this.Description = $Description } }
これを利用する際は、まずこのclassを含むスクリプトをドットソースで実行することで、グローバルに読みこむ必要があるようです(同一スクリプト内に属性定義と属性利用をまとめて書くとうまく動作しない)。
呼び出し側では以下のように指定します。
[TestAttribute("クラスの説明", No = 1)] class Foo { $X = 1 }
通常なら[Test()]のように"Attribute"の部分は省略できるはずなんですが、これも何故かフルネームで指定しないとダメなようでした。
クラスに属性が正しく指定されているかどうかはリフレクションで調べます。
[Attribute]::GetCustomAttributes([Foo])
結果は
Description No TypeId ----------- -- ------ クラスの説明 1 TestAttribute
こんな感じです。
パラメータ検証属性を作る
これだけでは面白くないので、少し実用になるかもしれない属性を書いてみましょう。
ところで前述のあえとす氏の記事の後編では、C#でPowerShellのパラメータ検証属性を作る方法について解説されています。ただ、C#でPowerShellの実行空間にアクセスして、操作を行ったり情報を取得したりするのは、少し知識が必要な部分かと思います。
そこで、PowerShellで使う属性なんだからPowerShellで書いたら楽になるんじゃない?という発想で、パラメータ検証属性をクラス構文で書いてみました。
このサンプルは、パラメータ(または変数)値の型が、指定の型セットに含まれているかどうかを検証するものです。複数型を取るようにするためにパラメータの型を[psobject]にすることが良くありますが、この属性を利用して、指定可能な型を絞り込めるようになります。
using namespace System.Management.Automation [AttributeUsage(([AttributeTargets]::Property) -bor ([AttributeTargets]::Field))] class ValidateTypeSetAttribute : ValidateEnumeratedArgumentsAttribute { [Type[]]$Types ValidateTypeSetAttribute($Types) { $this.Types = $Types } [void]ValidateElement([object]$element) { if(!($element.GetType() -in $this.Types)) { throw New-Object ValidationMetadataException ` "$($element.GetType().FullName) は許容されない型です。値は $($this.Types) のいずれかの型である必要があります。" } } }
使い方は以下の通り。パラメータ検証属性の場合は、定義と同一スクリプト内で呼び出しても、"Attribute"を省略しても問題ないようです。
function test { param( [ValidateTypeSet(,([int],[string],[double]))] [psobject] $obj ) } test "a" #OK test (Get-Process)[0] #NG
class構文のコンストラクタで可変長引数を定義する方法が分からなかったので、呼び出しがちょっと不格好ですが、そこはご容赦を。
2015/12/15
Web APIを利用する
この記事はPowerShell Advent Calendar 2015の15日目の記事です。
はじめに
前々回と前回は、PowerShellによるWebスクレイピングの具体的手法についてまとめました。ただ、スクレイピングはあくまで最後の手段であり、Webから何らかの文字列情報を取得するには、Web APIを用いるのが本道かと思います。
今回はPowerShellでWeb APIを用いるお話です。
Web APIとは
Web APIというのは、その名の通り、プログラムからWeb上のデータを取得したり、何らかのサービスの機能を実行したりするための、呼び出し方式を定めた規約です。
Web APIでは、HTTPリクエストに呼び出したい機能の内容を指定し、結果をHTTPレスポンスとして受け取るというのが一連の流れになります。
Web APIの主な実装方式としてはSOAPとRESTがありますが、このうち、XMLでリクエストを組み立てるSOAPは最近は廃れてきた感じです。
(PowerShellではSOAP APIはNew-WebServiceProxyコマンドレットで対応しています。が、今回は略。参考:PowerShell: ◆空港の場所と天気を調べる(New-WebServiceProxy))
最近はWeb APIといえばREST(REpresentational State Transfer) APIを指すことが殆どです。REST APIでは操作の対象となるリソース=URI(エンドポイントという)、呼び出し方式=HTTPメソッド(GET:データの取得, POST:データの作成, PUT:データの更新, DELETE:データの削除)、操作に対するパラメータ=クエリストリング(GETの場合)もしくはリクエストボディ(POSTの場合)、結果の返却=HTTPレスポンス(JSON、XML等)となるのが基本です。
また、RESTの呼び出しは基本的にステートレスなものとなります。要はセッション情報を持たない≒cookieを使わない、ってことです。
PowerShellではREST APIを簡便に利用するためにInvoke-RestMethodコマンドレットが用意されています。(ただしPowerShell 3.0から)
Invoke-RestMethodコマンドレットのパラメータ指定
Invoke-RestMethodコマンドレットのパラメータについては、実は前々回に取り上げたInvoke-WebRequestコマンドレットと同じです(IEのパーサーを使うことはないので、-UseBasicParsingも無いですが)。ただしREST APIの形式は前述の通りなので、利用するパラメータは限られてきます。具体的には
データ取得の場合
$response = Invoke-RestMethod -Uri エンドポイント(パラメータを含む) -Method GET
データ作成、更新の場合
$response = Invoke-RestMethod -Uri エンドポイント -Method POST -Body パラメータ(連想配列あるいはJSONやXML等)
となるかと思います。
その他にOAuth等の認証情報を指定する場合は、-Headers @{Authorization="認証情報"}のような指定も必要になることがあります。
Invoke-RestMethodコマンドレットのレスポンス
Invoke-RestMethodコマンドレットがInvoke-WebRequestコマンドレットと異なる最大のポイントは、レスポンス文字列の種類によって、自動的に出力オブジェクトの型が切り替わるところです。
私の調べた限りでは以下のような対応になっているようです。
レスポンス文字列の種類 | 出力型 |
XML | XmlDocument |
RSS/ATOM | XmlElement |
JSON | PSCustomObject |
プレーンテキスト | string |
利用の具体例
AED検索
Microsoft MVPのはつねさんが公開されている、AED検索はREST APIでAEDの所在地情報を検索し、JSONで結果を得ることができます。
例えば兵庫県芦屋市のAED一覧を取得するには、
$response = Invoke-RestMethod https://aed.azure-mobile.net/api/aedinfo/兵庫県/芦屋市/ $response | Format-Table Latitude, Longitude, LocationName, @{L = "Address"; E = { "$($_.Perfecture) $($_.City) $($_.AddressArea)" }} -AutoSize
のようにします。
ここで$responseには、JSON形式のデータをパースしてPSCustomObject化したデータが格納されるので、あとはFormat-Tableコマンドレットで見やすい形で出力してあげれば良いでしょう。
結果はこんな感じです。
AED検索APIと、去年のアドベントカレンダーで紹介した、Windows 位置情報プラットフォームを用いて現在位置を取得するGet-GeoCoordinate関数を併用して、「現在位置の最寄りにあるAEDをGoogle MAP上で表示する」なんてこともできます。
$location = Get-GeoCoordinate $response = Invoke-RestMethod "https://aed.azure-mobile.net/api/NearAED?lat=$($location.Latitude)&lng=$($location.Longitude)" Start-Process "http://maps.google.com/maps?q=$($response.Latitude),$($response.Longitude)"
ここではREST APIにQueryStringでパラメータ(経度、緯度)情報を渡しているところと、レスポンスから生成されたオブジェクトのプロパティ値をマップ表示の際のパラメータとして利用しているところに注目してください。
RSS取得
RSSやATOMもREST APIの一種と考えて良いと思います。
ここではこのブログのRSSを取得する例を示します。
$response = Invoke-RestMethod http://winscript.jp/powershell/rss2/ $response | select @{L = "Title"; E = "title"}, @{L = "Url"; E = "link"}, @{L = "PublishDate"; E = {[DateTime]::Parse($_.pubDate)}}, @{L = "Description"; E = { ($_.description -replace "<.+?>"). PadRight(50).Substring(0,50).TrimEnd() + "..." }}| Format-List
Descriptionの加工がやや適当(HTMLタグっぽいところを削除して50文字に切り詰めてるだけ)ですが、少し見やすくしています。結果は以下のように表示されます。
RSSの結果は、1エントリがXMLElement型のオブジェクトとして出力されるので、データの取扱いが比較的楽だと思います。
レスポンスがXMLなREST APIの良い例がなかったので省略してますが、基本的には前回取り上げた、XHTMLをXMLとしてパースする方法と同じやり方です。ただInvoke-RestMethodの場合は[xml]型アクセラレータによる変換は不要で、いきなりXmlDocumentオブジェクトが得られます。
現状の問題点
レスポンスがコールバック関数つきのJSONP形式であるとかで、JSON、XML、RSS/ATOMのいずれの形式にも適合しない場合はプレーンテキストとして出力されてしまいます。
その場合は、出力文字列を適宜加工した後に、[xml]型アクセラレータや、ConvertFrom-Jsonコマンドレット等により手動でオブジェクト化するようにしてください。もっとも、その場合は敢えてInvoke-RestMethodを使わずInvoke-WebRequestで充分ですが。
あとWeb APIというのは大抵(特にPOSTの場合)、認証を要するのですが、最近よくあるのはTwitter等でもおなじみのOAuth認証です。ところがOAuth認証は結構めんどくさい処理で、何らかのライブラリを使わないとしんどいです。残念ながらPowerShellの標準コマンドレットには存在しないので、自前で頑張って書くか、既存のライブラリやコマンドを利用することになるかと思います。
今回そこまで説明できませんでしたが、また機会があれば。
2015/12/10
PowerShellでスクレイピング 後編 HTMLをパースする
この記事は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でスクレイピング 前編 文字列を取得する
この記事は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/12/01
2015年のPowerShellを軽く振り返ってみる
この記事はPowerShell Advent Calendar 2015の1日目です。
アドベントカレンダーは今年で5回目ですが、例年よりだいぶ参加者が少ないので、敢えて完走を目指さずまったりいきましょう。
とはいえ今年から来年にかけては、PowerShellの変革の年といっても過言ではないかと思います。
今年7月にはWindows 10のリリースとともに、WMF (Windows Management Framework) 5.0 / PowerShell 5.0 (2012R2/8.1向けにはProduction Preview)が登場しました。(私の書いたPS5.0新機能のセッション資料はこちら。)
PowerShell 5.0では特に、"Infrastructure as Code"、すなわちインフラの構成をコードで記述可能にし自動化するための機能である、PowerShell DSC周りが格段にパワーアップしています。DSCの機能増強により、Microsoft Azure等のクラウド、オンプレミスのサーバーはともに大きな恩恵を受けることが期待されます。Azure DSC Extensionも追従する形で凄まじい勢いでバージョンアップしてますね。
PowerShell 5.0の登場に合わせて、各種のPowerShell関係のモジュールやアプリケーションが新登場していますが、これらはいずれもOSS(オープンソースソフトウェア)となりました。
一例を挙げると、DSCで用いるロジック本体となる「DSCリソース」を作製する際に有用なDSCリソースキット、対応リポジトリをプロバイダという形で拡張可能であるパッケージ管理システムPackageManagement、PowerShellモジュールを専用リポジトリであるPowerShell Galleryからコマンド1発で取得可能となるPowerShellGet、PowerShellスクリプトの静的解析を行うスクリプトアナライザー、Windowsのみならず他プラットフォームの一括管理を目指すDSC for Linux等々です。
先日OSS化したばかりの、マルチプラットフォーム対応のコードエディタであるVisual Studio Codeと、PowerShellスクリプトが記述可能となるPowerShell Extensionあたりもトピックとして熱いですね。
Visual Studio 2015には、VSでPowerShellスクリプト開発を行うためのOSSなプラグインであるPowerShell Tools for Visual Studioが標準搭載されたことも記憶に新しいですね。(12/1追記)
逆に、PowerShellのテストスクリプトを記述するためのフレームワーク(DSL)であるPesterや、コンソールの入出力をパワーアップさせるPSReadlineといった、OSSで開発されている既存のPowerShellモジュールが、Windows 10に標準機能として取り込まれるなど、OSSとの関係性については大きく変化したと言えるでしょう。
そして次期Windows Serverである、Windows Server 2016の足音も聞こえてきました。現在はTP4が公開されており、試すことができます。Windows Server 2016の目玉は何といっても、Windows ContainersとNano Serverでしょう。
アプリケーションをコンテナという単位で配置し、自由なすげ替え、あるいは使い捨てが可能な環境を構築するツールであるDockerというOSSに対し、インターフェースの互換性を持たせたWindows版コンテナがWindows Containersです。
そして、コンテナ機能を最大限に活用するためにフットプリントの最小化を目指し、GUIどころかコンソールすら廃した新しいWindows Serverの形態が、Nano Serverです。
Windows ContainersとNano Serverの管理は当然、PowerShellがメインとなります。また、ようやくWindowsにやってくる、SSHクライアントとサーバーはPowerShell上で動くようです。
PowerShellは5.0に進化することで、足回りを強化しました。そして各種OSSと連携して、クラウドとオンプレミスのサーバー群の基礎を支える存在として、ますます重要性を帯びていくことでしょう。
昨今のPowerShellを取り巻く状況は、私の理解ではざっとこんな感じです。この中に興味を持たれたテーマはありませんか? もちろん、新機能以外にも、まだまだ知られていない機能や利用法も埋もれていると思います。
もしそんなテーマがあったら、PowerShell Advent Calendar 2015で共有していただければ嬉しいなあと思います。
というわけで、例年にない感じの初日記事を書いてみました。今回のアドベントカレンダーもどうぞよろしくおねがいします。
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 "管理者権限がありません" }
Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー