2017/12/24
PowerShellにおける「文」と「式」についての考察
この記事には「 独自研究 」に基づいた記述が含まれているおそれがあります。
この記事は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 何か変だったら()付けてみる とかでもそれ程問題にはならないかもしれません。
2017/12/10
PowerShellで利用するテキストデータ形式の比較
この記事はPowerShell Advent Calendar 2017の10日目です。
PowerShellはオブジェクトを扱うシェルですが、別にテキストデータを扱えない訳ではありません。むしろ、PowerShellで取得したデータをテキストファイルとして保存したり、スクリプトで用いるデータをテキストファイルで保存しておくことは日常的に行われることだと思います。
ただし、PowerShellで扱うデータはオブジェクトであり、テキストファイルは文字通り文字列であることから、コマンドレットを用いる等、何らかの手段で変換が必要になります。また、テキストデータ形式にも様々な種類があり、それぞれメリット、デメリットが存在します。今回の記事では、PowerShellで用いるデータを保持しておく際のテキストデータ形式について比較をしてみます。
プレーンテキスト
プレーンテキスト、すなわち書式なしのテキストファイルです。もっともシンプルな使い方をする場合、文字列配列の各要素に含まれる文字列が、テキストファイルの1行と対応します。
書き出し
$lines | Set-Content -LiteralPath file.txt -Encoding UTF8
$linesは文字列変数です。
特に理由がなければ文字コードはUTF-8で良いと思います。
追記
$lines | Add-Content -LiteralPath file.txt -Encoding UTF8
Add-Contentは実行のたびにファイルを開いて、書き込んでから閉じるという動作をするので、1行ずつforeachで実行するのはNGです。
読み込み
$lines = @(Get-Content -LiteralPath file.txt -Encoding UTF8)
メリット
- 文字列配列をテキストファイルに書き出すのは多分これが一番楽だと思います。
- 書き出したデータは人間にも読みやすい。 編集もしやすい。
デメリット
- 文字列だけを保存しておきたいというケースがそもそも少ない。
CSV
コンマ等の特別な文字で区切り、1行あたりに複数のデータを保存できる形式です。
PowerShellのコマンドレットで扱う場合、オブジェクトが持つプロパティがヘッダ列名に対応します。各行にオブジェクト配列1要素のプロパティ値が、カンマ区切りで保持されます。
書き出し
$objects | Export-Csv -LiteralPath file.csv -NoTypeInformation -Encoding Default
$objectsは任意のオブジェクト配列です。必要であればSelect-Objectコマンドレットを併用して、プロパティを絞り込みます。
文字コードはExcelでそのまま読み込み/書き出しができるDefault(日本語環境ではShift_JIS)がお勧めです。(最近のExcel2016ならUTF8も一応読めますが)
追記
$objects | Export-Csv -LiteralPath file.csv -Append -NoTypeInformation -Encoding Default
読み込み
$objects = Import-Csv -LiteralPath file.csv -Encoding Default
メリット
- オブジェクトのプロパティ値が、すべて数値あるいは文字列で表現できる値を持つ場合に最も適合する。
- 人間にも読みやすく、ある程度は編集もできる。
- Excelで開ける。
デメリット
- オブジェクトのプロパティが、数値と文字列以外のオブジェクトである場合、すなわち、階層構造を持つデータの保存には適さない。
- 数値も文字列として読み込まれてしまうので、数値として扱いたい場合は変換が必要になる。
- Export-CsvとImport-Csvで扱うCSVファイルはヘッダが必須。つまり、ヘッダなしのCSVファイルが既にあって、それを読み書きするという用途には適さない。(できなくはないが)
- 書き出し時の列順を制御することができない。つまり、PowerShellで書き出したCSVを、列順が固定であるとの想定である他のプログラムで読み込むことは基本NG。
- 書き出し時、1つ目の要素に存在しないプロパティは、2つ目以降では存在しないものとして扱われる。同種のオブジェクトで構成される配列なら通常は問題ないのだが、要素によって動的に追加されるプロパティがあったりなかったりすると厄介。(ADでありがち)
JSON
JavaScriptのような表記でデータを保持するデータ形式です。データの受け渡しに様々な言語で利用できます。Web APIでもよく利用されます。
PowerShellではv3からJSONを扱うコマンドレットが提供されています。
書き出し
$objects | ConvertTo-Json | Set-Content -LiteralPath file.json -Encoding UTF8
読み込み
$objects = Get-Content -LiteralPath file.json -Encoding UTF8 -Raw | ConvertFrom-Json
メリット
- CSVと異なり、階層構造を持ったデータでも扱える。
- CSVと異なり、数値は数値型のまま読み書き可能。 (整数値はint、小数値はdecimal)
- 人間にもまぁまぁ読めるし、頑張れば編集できなくもない。
デメリット
- -Depthパラメータによりプロパティを展開する階層の深さを指定はできるが、プロパティに応じて深さ指定を変化させるというようなことはできない。基本的には、自分で構築したPSCustomObjectを使うか、JSON化する前に自分で元オブジェクトを整形しておく必要がある。
- 直接ファイルに書き出し、追記、ファイルから読み込みするコマンドレットはない。
- 実は細かい話をしだすと色々と罠があります…。
CLIXML
PowerShellではPSリモーティング等、プロセス間でオブジェクトのやり取りを行う際に、CLIXML形式を介してシリアライズ/デシリアライズが実行されます。シリアライズ対象によっては、完全に元のクラスのオブジェクトに復元されます。(復元されないオブジェクトにはクラス名にDeserialized.との接頭辞が付与され、プロパティ値のみ復元される)
ユーザーもコマンドレットを用いて、任意のデータをCLIXML形式でシリアライズし、XMLファイルとして保存することができます。
書き出し
$objects | Export-Clixml -LiteralPath file.xml
読み込み
$objects = Import-Clixml -LiteralPath file.xml
メリット
- 元のオブジェクトの構造、プロパティ値と型情報を含めてほぼ完全にテキストファイルに保存できる。
- 復元したオブジェクトはプロパティ値を参照できるのはもちろん、オブジェクト全体が完全にデシリアライズされ、元の型に戻った場合には、メソッドを実行することも可能。
- 例え元の型に戻らず、Deserialized.との接頭辞が付いた状態でも、コンソールに表示する場合は元の型のフォーマットが使われるので見やすい。
デメリット
- すべてのオブジェクトが元の型に戻せるわけではない。戻せるかどうかは確認が必要。
- 人間が読み書きするようなものではない。
ちなみに、ConvertTo-Xmlという似たようなコマンドレットがありますが、出力形式はCLIXMLではない上、復元の手段もなく、かといって別に読みやすいXMLというわけでもなく、正直何のために使うのかよく分かりません(適切なxsltでも用意すればいいのかな?)。まだConvertTo-Htmlの方が使えそうです。
psd1
psd1は「PowerShellデータファイル」で、モジュールマニフェストやローカライズデータに使われるファイル形式です。スクリプトファイルの1種ですが、数値や文字列リテラル、配列、連想配列、コメントなど基本的な言語要素のみ使用可能です。PowerShell 5.0以降ではImport-PowerShellDataFileコマンドレットを用いて、任意のpsd1ファイルのデータを読み込み、変数に格納することが可能です。
書き出し
書き出し用のコマンドレットはありません。
読み込み
例えば以下のような内容をbackup_setting.psd1として保存しておきます。ルート要素は必ず連想配列にします。
@{ Directories = @( @{ From = "C:\test1" # コピー元 To = "D:\backup\test1" # コピー先 Exclude = @("*.exe", "*.dll") Recurse = $true }, @{ From = "C:\test2" To = "D:\backup\test2" Exclude = @("*.exe") LimitSize = 50MB }, @{ From = "C:\test3" To = "D:\backup\test4" } ) Start = "0:00" }
なお、dataセクションで全体を括ってもいいですが、psd1で許容される言語要素はdataセクションより更に制限がきついので、敢えてしなくてもいいんじゃないかと思います。
このファイルは以下のように読み込めます。
$setting = Import-PowerShellDataFile -LiteralPath backup_setting.psd1
$settingには連想配列が格納され、以下のように値が参照できます。
$setting.Directories | foreach {Copy-Item -Path $_.From -Destination $_.To}
メリット
- PowerShellの構文でデータを記述できる。
- 通常のps1ファイルを呼び出すのとは異なり、式の評価やコマンド実行などはされない分、セキュアである。
- 配列と連想配列の組み合わせにより、JSONライクな階層構造を持てる。型情報も保持される。
- JSONとは違い、コメントが入れられる。
デメリット
- 記述できるデータはプリミティブなものだけ。
- スクリプトから書き出すためのコマンドレットがない。こういうアプローチで頑張ればできるかも?
- 利用できるのはPowerShell 5.0以降のみ。一応、下位バージョンでやる方法はあります。
まとめ
PowerShellで扱うデータをテキストファイルとして保存する際には、各テキストデータ形式の特性を理解し、メリット、デメリットを踏まえて選定する必要があります。
また、当然ながらテキストファイルに保持することが不適切なデータもありますので、そこは注意してください。(画像データを敢えてBase64とかでエンコードしてテキストファイル化する意味があるのか、とかですね)
個人的には…
ちょっとした作業ログ等を記録しておきたい→プレーンテキスト
.NETオブジェクトの一部のプロパティだけ抜き出してファイル化したい→CSV
自分で構築したPSCustomObjectをファイル化したい→JSON
.NETオブジェクト全体をファイル化したい→CLIXML
スクリプトで使う設定データを用意したい→psd1
みたいな感じでなんとなく使い分けていると思います。psd1はまだ採用例はないですが…。
今回はビルトインのコマンドレットで扱えるもののみ取り上げましたが、他にもyaml等のテキストデータ形式が存在し、有志によるモジュールを用いて扱うことが可能です。
2016/12/12
PowerShellのAST入門
この記事はPowerShell Advent Calendar 2016の11日目です。遅刻してごめんなさい!
ASTとは
ASTとはAbstract Syntax Treeの略で、日本語では「抽象構文木」といいます。コードをパーサーが構文解析した結果から、言語の意味に関係のない要素(空白等)を除外し、木構造として構築したものです。
PowerShellでは3.0からASTの仕組みが取り入れられました。スクリプト実行時にはまずパーサーがスクリプトブロックからASTを生成し、コンパイラによってASTが解釈され、実行されるようになっています。
ASTを直接的に扱うのはコンパイラですが、実はPowerShellではパーサーが構築したASTを、PowerShellスクリプトから扱うことができます。
ASTの具体的な使い道としては、構文の静的解析が挙げられますが、その話は後でするとして、今回はまず、ASTの構成要素と構造を見ていきます。
ASTの構成要素
具体的には、{スクリプトブロック}.Astとして、ScriptblockオブジェクトのAstプロパティから、ScriptBlockAstオブジェクトにアクセスできます。このオブジェクトがASTのルートとなるノード(分岐点)を表します。このScriptBlockAstから、スクリプトブロック内部の構文要素が木構造として展開されていきます。
式(Expression)、文(Statement)といった構文要素は、各々対応したAstクラスが対応し、木構造における分岐点を形成します。また、分岐点の末端の葉では、当該の構文要素を構成するデータを示すオブジェクトが格納されます。
すべてのAstクラスは、Ast抽象クラス(System.Management.Automation.Language.Ast)を継承したクラスです。PowerShellでは50個程のAstクラスが存在します。各Astクラスは、抽象クラスで定義されている以下の2つのプロパティを持っています。
- Parent
親ノードを示すAstオブジェクトを返す - Extent
当該のASTノードに含まれるコード文字列や、スクリプト全体から見たコード文字列の位置等の情報を持つ、IScriptExtentインターフェースを実装したクラスのオブジェクトを返す
また各Astクラスは、対象の構文要素に応じて、それぞれ異なったプロパティを持ちます。たとえばScriptBlockAstは以下のプロパティを持ちます。
子の分岐点を返すもの
- UsingStatements
Using節を表す、UsingStatementAstのコレクションを返す - Attributes
スクリプトブロックに付与された属性を表す、AttributeAstのコレクションを返す - ParamBlock
paramブロックを表す、ParamBlockAstを返す - BeginBlock、ProcessBlock、EndBlock、DynamicParamBlock
各々、beginブロック、processブロック、endブロック、DynamicParamブロックを示すNamedBlockAstを返す
葉を返すもの
- ScriptRequirements
#Requires節の内容を表す、ScriptRequirementsを返す
ASTの構造
たとえば、
$scriptBlock = { param([int]$x,[int]$y) end { $out = $x + $y $out | Write-Host -ForegroundColor Red } }
という、二つの整数値の和を赤字で表示するというスクリプトブロックならば、以下のようなASTが構築されます。(一部分岐点、葉は省略しています。また、分岐点のASTクラス名は、末尾の"Ast"を省略表記しています。)
このスクリプトブロックのASTから、例えば「Red」というパラメータ値を表す、StringConstantExpressionAstまで辿るには、
$scriptBlock.Ast.EndBlock.Statements[1].PipelineElements[1].CommandElements[2]
StringConstantType : BareWord Value : Red StaticType : System.String Extent : Red Parent : Write-Host -ForegroundColor Red
のようにします。
基本的なASTの構造が頭に入っていれば、タブ補完を併用することで比較的簡単に目的のノードまで辿れますが、ASTノードの子に対し、ノード検索をかける方法もあります。
例えば、すべてのVariableExpressionAstを列挙するには、
$scriptBlock.Ast.FindAll({ param($ast) $ast -is [System.Management.Automation.Language.VariableExpressionAst] }, $true)
のように、FindAllメソッドを用います。
AST編はあと何回か続く予定です。
2016/04/23
第 6 回 PowerShell 勉強会「PowerShell 5.0 新機能と関連OSSのご紹介」資料公開
Japan PowerShell User Group (JPPOSH) 主催の第 6 回 PowerShell 勉強会(4/9)には多数の方にお越しいただき、ありがとうございました。
PowerShell勉強会は今後も年2回くらいのペースで続けて行きたいと思っていますので、どうぞよろしくお願い致します。
さて、私のセッション「PowerShell 5.0 新機能と関連OSSのご紹介」のスライドを公開します。前半は以前のものとだいたい同じですが、正式版対応版にアップデートしています。
今回は去年から今年にかけて、PowerShell関連ソフトウェアとしてOSS化したものを、まとめて紹介しました。以下は今回紹介したもののリストです。
- WMFやWindowsの標準機能として取り込まれたもの
- PackageManagement / PowerShellGet (WMF5)
- TabExpansion++(WMF5)
- PSReadline(Win10, Server 2016)
- Pester(Win10 , Server 2016)
- 追加してインストール可能なもの
またデモで用いたサンプルファイルも公開します。
この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/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/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/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/11/09
JapanesePhoneticAnalyzerを使ってPowerShellで形態素解析(後編)
分かち書きとは
中編で作ったGet-JpYomi関数は、JapanesePhoneticAnalyzerクラスの読み仮名取得機能にフォーカスを当てたラッパー関数でした。
今回は、JapanesePhoneticAnalyzerクラスの最大の使用目的と思われる、「分かち書き」を目的とした関数を作成します。分かち書きとは、文章を文節単位で分割することと考えて頂いて良いかと思います。
前々回作ったGet-JpWordは単語単位の分割を行うものでしたが、読みやすさや発音のしやすさを目的として文章を分割表記する場合は、単語単位では細かすぎると言えます。
よって単語単位ではなく、文を意味のあるまとまりとして区切ることのできる最小の単位である、文節単位で分割する方法を考えてみます。
Split-JpText関数
前編で作ったGet-JpWord関数をラップし、分かち書きに特化した関数Split-JpTextを作成しました。まずは以下にコードを示します。
function Split-JpText { param( [parameter(ValueFromPipeline=$true)] [PSObject[]] $InputObject, [ValidateSet("Text", "Yomi", "Detail")] [string] $Format = "Text", [ValidateSet("ByWord", "ByPhrase")] [string] $SplitMode = "ByPhrase", [string] $Separator = " ", [switch] $ToArray ) begin { if($Format -eq "Detail"){$ToArray = $true} } process { foreach($o in $InputObject) { $o.ToString() | Get-JpWord | foreach -Begin { $phrases = @() $phrase = $null } -Process { if($_.IsPhraseStart) { if($phrase){$phrases += $phrase} $phrase = New-Object psobject | Add-Member -MemberType ScriptProperty -Name Text -Value { -join $this.Words.DisplayText} -PassThru | Add-Member -MemberType ScriptProperty -Name Yomi -Value { -join $this.Words.YomiText} -PassThru | Add-Member -MemberType NoteProperty -Name Words -Value @() -PassThru } $phrase.Words += $_ } -End { if($phrase){$phrases += $phrase} if($SplitMode -eq "ByPhrase") { $out = switch($Format) { "Text" {$phrases.Text} "Yomi" {$phrases.Yomi} "Detail" {$phrases} } } else { $out = switch($Format) { "Text" {$phrases.Words.DisplayText} "Yomi" {$phrases.Words.YomiText} "Detail" {$phrases.Words} } } if($ToArray) { $out } else { $out -join $Separator } } } } }
パラメータの説明
パラメータ名 | 型 | 説明 |
InputObject | 任意の型 | 入力テキスト。文字列以外の型の場合は文字列に変換して評価される。パイプライン入力可能。 |
Format | string | Text(デフォルト):文字列のみ出力する。 Yomi:文字列ではなく読みを出力する。 Detail:文節の文字列、読み、各文節に含まれる単語の配列を含んだオブジェクトの配列を出力する。(SplitMode=ByPhraseの時のみ) |
SplitMode | string | ByPhrase(デフォルト):文を文節単位で分割する。 ByWord:文を単語単位で分割する。 |
Separator | string | 分割文字を指定。デフォルトは" "(半角スペース)。(Format=DetailもしくはToArray指定時には無効) |
ToArray | switch | 指定すると、単一の文字列ではなく、文字列の配列を出力する。 |
使用法
- 分かち書き(文節)
例:Split-JpText "今日はいい天気ですね。"
出力:今日は いい 天気ですね 。 - 分割文字指定
例:Split-JpText "今日はいい天気ですね。" -Separator /
出力:今日は/いい/天気ですね/。 - 分かち書き(単語)
例:Split-JpText "今日はいい天気ですね。" -SplitMode ByWord
出力:今日 は いい 天気 です ね 。 - 文節単位で読み仮名を表示
例: Split-JpText "今日はいい天気ですね。" -Separator / -Format Yomi
出力:きょうは/いい/てんきですね/。 - 分かち書きした文節を文字列配列として変数に格納
例:$phrases = Split-JpText "今日はいい天気ですね。" -ToArray
解説
ちょっと長めの関数ですが、ポイントはJapanesePhoneticAnalyzerクラスのGetWordsメソッドが返すJapanesePhonemeオブジェクトのIsPhraseStartプロパティです。
IsPhraseStartプロパティは、当該単語(Phoneme)が文節(Phrase)の開始部分にあたる単語であればTrueを返します。すなわち、JapanesePhonemeコレクションを文頭から文末まで列挙していったとき、IsPhraseStartプロパティがFalseからTrueに変わる部分が文節の境界になるわけです。
Split-JpText関数では、単語を列挙していき、文頭もしくは文節の境界に遭遇すると、文節に含まれる文字列(Textプロパティ)とその読み(Yomiプロパティ)と単語の配列(Wordsプロパティ)を格納するオブジェクトを新たに作成し、$phrase変数に代入します。一方で$phrase変数に元々入っていたオブジェクトは、$phrases配列に追加します。
$phraseオブジェクトのWordsプロパティには、列挙中の単語を都度、追加していきます。
なお、$phraseオブジェクトのTextプロパティとYomiプロパティはスクリプトプロパティとして定義しておき、必要時に値を取得するようにしてあります。
まとめ
3回に渡って、JapanesePhoneticAnalyzerクラスの使用法を具体的なラッパー関数を作成して紹介しました。
個人的には、PowerShellからなら中編で挙げた、読みの取得が一番使いでがあるかな?と思いました。今回取り上げた分かち書きは、意外と応用例が思いつきませんでした。
前編のGet-JpWord関数を使って、何らかの文書群の単語リストをあらかじめインデックスとして出力しておき、単語検索コマンドを実装するのも面白そうですね。
ただ、残念ながら品詞情報が取れないので、JapanesePhoneticAnalyzerをmecabとかの形態素解析エンジンの代替にするのはちょっと厳しいかもしれないです。まあ、標準機能のみでちょっとしたものを作れるのは大きいかと思います。何か日本語の文章を解析する必要があるときには使ってみてはいかがでしょうか。
2015/10/18
JapanesePhoneticAnalyzerを使ってPowerShellで形態素解析(中編)
前回の続きです。今回は、前回作ったGet-JpWord関数を使って取得した、読み仮名を利用する話になります。
文章の読み仮名を表示する
単に指定文章の読み仮名を表示するという用途には、前回作製したGet-JpWord関数はそのままだと使いにくいので、さらにラップした関数を用意しました。
function Get-JpYomi { param( [parameter(ValueFromPipeline=$true)] [PSObject[]] $InputObject, [string] $Separator = "", [ValidateSet("YomiOnly", "Furigana", "RubyTag")] [string] $Format = "YomiOnly" ) process { foreach($o in $InputObject) { $phenemes = if($o -is [Windows.Globalization.JapanesePhoneme]) { @($o) } else { $o.ToString() | Get-JpWord -MonoRuby:$($Format -eq "RubyTag") } ($phenemes | foreach { if($Format -eq "YomiOnly") { $_.YomiText } else { if($_.DisplayText -match "\p{IsCJKUnifiedIdeographs}") { if($Format -eq "RubyTag") { "<ruby><rb>$($_.DisplayText)</rb><rt>$($_.YomiText)</rt></ruby>" } elseif($Format -eq "Furigana") { "$($_.DisplayText)($($_.YomiText))" } } else { $_.DisplayText } } }) -join $Separator } } }
Get-JpYomi "読み仮名を表示したい文章をここに書きます。"
のようにすると、以下のような結果が表示されます。
よみがなをひょうじしたいぶんしょうをここにかきます。
また、Separatorパラメータを指定すると、読み仮名に区切り文字を挿入することもできます。例えば、
Get-JpYomi "読み仮名を表示したい文章をここに書きます。" -Separator " "
とすると結果は、
よ み がな を ひょうじ し たい ぶんしょう を ここ に か き ます 。
のようになります。
単に読み仮名を知りたい時に便利かと思います。漢字の読み方を知りたい時にさくっと使うのもいいかも。(読み方が複数あっても1つしか表示されませんが)
文章に振り仮名を付けたい時には、以下のようにします。
Get-JpYomi "読み仮名を表示したい文章をここに書きます。" -Format Furigana
結果はこうなります。
読(よ)み仮名(がな)を表示(ひょうじ)したい文章(ぶんしょう)をここに書(か)きます。
ここでポイントとなるのは、漢字部分は振り仮名を付けるが、それ以外には必要ないので付けないという処理です。それには正規表現"\p{IsCJKUnifiedIdeographs}"を用いています。\p{}は正規表現の「名前付き文字クラス」と呼ばれるもので、IsCJKUnifiedIdeographsというのはUnicodeにおける漢字を表すブロック名になります。ちなみにIsHiraganaでひらがな、IsKatakanaでカタカナにマッチさせることもできます。
Formatパラメータは、デフォルトのYomiOnly(読み仮名のみ出力)、Furigana(入力に振り仮名を付けて出力)の他に、RubyTag(HTMLのrubyタグを生成して出力)というのを用意しています。これを使うと、
<ruby><rb>読</rb><rt>よ</rt></ruby>み<ruby><rb>仮</rb><rt>が</rt></ruby><ruby><rb>名</rb><rt>な</rt></ruby>を<ruby><rb>表</rb><rt>ひょう</rt></ruby><ruby><rb>示</rb><rt>じ</rt></ruby>したい<ruby><rb>文</rb><rt>ぶん</rt></ruby><ruby><rb>章</rb><rt>しょう</rt></ruby>をここに<ruby><rb>書</rb><rt>か</rt></ruby>きます。
という出力が得られます。実際にHTMLとして表示させると以下のようになります。
50音順ソート&グループ化
Get-JpYomi関数の応用例的なものになります。漢字の読みが分かるということは、50音順にソートしたり、頭文字でグループ化できるようになるということですね。やってみましょう。
ディレクトリ名を50音順でソートするには、
dir -Directory | sort {$_.Name | Get-JpYomi}
のようにします。例えば私のミュージックフォルダ(アーティスト名ごとにフォルダが作られている)に対して実行すると以下のようになります。
うーん、アニソンばっかりだな…。それはともかく、ちゃんと50音順ソートされていることがお分かりいただけるかと思います。中には読み方間違ってるやつもありますが。
頭文字でグループ化して表示するというのもやってみましょう。
dir -Directory | select Name, @{Name = "NameYomi"; Expression = {$_.Name | Get-JpYomi}}, @{Name = "FolderCount"; Expression = {@(dir $_.FullName -Directory).Length}} | group {$_.NameYomi.Substring(0,1)}| sort Name | foreach { Write-Host "【$($_.Name)】($($_.Count))" $_.Group|%{ Write-Host "$($_.Name)($($_.NameYomi)) $($_.FolderCount)" } Write-Host }
まずファイル名、読み、サブフォルダの数(当方環境ではアルバム数に対応)をそれぞれプロパティとして抽出し、読みの頭文字(SubString(0,1)で取得)でグループ化し、頭文字グループを50音順ソートし、結果を表示しています。
結果はこんな感じです。
まあ頑張って読んでいる方だと思います。むらかわなしぎぬ…。
また長くなったので次回に続きます。次回は「分かち書き」の話。
Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー