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 何か変だったら()付けてみる とかでもそれ程問題にはならないかもしれません。
2016/12/20
AST Visitorを使った静的解析
この記事はPowerShell Advent Calendar 2016の20日目です。
はじめに
前々回はASTの概要について述べ、最後にAST.FindAllメソッドを使って、ASTから指定のASTノードを検索する方法について説明しました。
前回はASTを再帰的に検索して、木構造を視覚化してみました。
今回もASTを検索する話なのですが、静的解析機能を実装するためのAST Visitorを用いる方法について説明します。が、あらかじめお断りしておきますが、静的解析の実装までは今回はたどり着きません。静的解析ツールをどう作るかorどう作られているか、ということを雰囲気で味わっていただければと。
Visitorパターン
AST Visitorの説明をする前に、まず、Visitorパターンについて簡単に。
Visitorパターン[Wikipedia]というのは、オブジェクト指向言語におけるデザインパターンの1つで、対象オブジェクトを巡回する「訪問者」クラスを定義するものです。Visitorクラスでは、対象クラスごとに行う処理を、個別にvisitメソッドをオーバーロードさせることで定義します。共通のVisitor抽象クラスを継承することで、異なる機能を持ったVisitorクラスを作ることができます。
一方、処理対象クラスには、Visitorオブジェクトを引数に受け取る、acceptメソッドを定義します。acceptメソッドでは、引数として受け取ったVisitorオブジェクトのvisitメソッドを呼ぶことで、処理を実行させます。
なお、処理対象クラスが子要素クラスを持つ場合には、acceptメソッド内で、子要素クラスのacceptメソッドを呼ぶようにします。こうしておくことで、Visitorは処理対象を再帰的に巡回できるようになります。
このように処理対象クラスから、実際に処理を行う機能をVisitorクラスとして分離することで、処理対象クラスに手を加えることなく、Visitorクラスを追加して、処理内容を増やしたりすることが可能になります。
AST Visitorの呼び出し
Visitorパターンを念頭において、AST Visitorの呼び出し方を見ていきましょう。Ast抽象クラスには、以下の2つのVisitメソッドが定義されています。
説明に入る前に注意点。メソッド名は"Visit"となっていますが、Visitorパターンでいうところの"accept"メソッドのことです。なぜメソッド名がAcceptじゃないのかは不明ですが…。
ともかく、AstクラスのVisitメソッドは、AstVisitor抽象クラスを継承したクラスのオブジェクトか、ICustomAstVisitorインターフェースを実装したクラスのいずれかを引数に取ることで、ASTに対する処理を実施します。
AstVisitor抽象クラスを継承、もしくはICustomAstVisitorインターフェースを実装することで、ASTの種類に応じた巡回処理を行うクラスを、自分で定義していきます。
AstVisitor抽象クラス
AstVisitor抽象クラスは、Visitorとしての基本的な機能があらかじめ実装されています。具体的には既に以下の機能は用意されています。
- ASTの種類に応じたVisitメソッドの定義
すべての種類のASTに対応するVisitメソッド(50個以上)がVirtualメソッドとして定義されています(※)。例えば、IfStatementAstに対する処理を行うための、VisitIfStatementメソッドがあります。※一般的なVisitorパターンでは、Visitメソッドを対象クラス分オーバーロードさせますが、PowerShellのAstVisitorは対象クラスに応じた別名のメソッドを定義する方式です。これも理由は分かりませんが、オーバーロードにするには多すぎるからかもしれません。
- 子ノードの再帰的な巡回
各Visitメソッドには、ASTの子ノードに対し、再帰的にVisitメソッドを呼ぶ仕掛けがあらかじめ備わっています。 - ノード巡回の停止
各VisitメソッドはAstVisitAction列挙型を返却します。以下のように返却する値によって、ノード巡回の継続、停止を制御できます。- Continue:ノード巡回を継続(デフォルト)
- SkipChildren:子ノードの巡回を行わない
- StopVisit:巡回を終了する
カスタムAstVisitorクラスを作成する
以上の基本的な機能を踏まえて、AstVisitor抽象クラスを実装したカスタムVisitorクラスを作ります。C#で書くのが一般的ですが、せっかくなのでPowerShell v5で追加された、クラス構文を使って書いてみましょう。
例えば、「利用しているコマンドのリストを取得する。ただし、コマンドのパラメータ内で別コマンドを呼び出している場合は除く。」というお題を解くことを考えます。
ASTのFindAllメソッドだと、配下に含まれるすべてのCommandAstを取得してしまうので、単純にはいきません。そこでカスタムAstVisitorクラスの出番です。
このお題を実現するVisitorクラスは以下のようになるでしょう。
using namespace System.Management.Automation.Language class GetCommandNamesVisitor : AstVisitor { [string[]]$CommandNames = @() [AstVisitAction]VisitCommand([CommandAst]$commandAst) { $this.CommandNames += $commandAst.CommandElements[0].Extent.Text return [AstVisitAction]::SkipChildren } }
PowerShellのクラス構文において、Virtualメソッドのオーバーライドは、単に同名のメソッドを定義するだけですので、ここではVisitCommandメソッドをオーバーライドします。
プロパティとフィールドの区別はないので、コマンド名の一覧を格納するCommandNamesプロパティは上記のような定義になります。メソッド内でクラスメンバを参照する際には$thisを用います。
作成したGetCommandNamesVisitorクラスをインスタンス化し、解析対象スクリプトブロックのASTのVisitメソッドに引数として渡します。
$scriptBlock = { $files = Get-ChildItem -Path (Get-Location | Split-Path -Parent) -File $files | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 5 } $visitor = New-Object GetCommandNamesVisitor $scriptBlock.Ast.Visit($visitor) $visitor.CommandNames
実行すると、結果は
Get-ChildItem Sort-Object Select-Object
のようになるかと思います。
AstVisitorクラスの具体的な実装については、PSReadLineやPowerShellEditorServicesにありますので、参考にしてみてください。
ICustomAstVisitorの実装
前項で述べた、AstVisitor抽象クラスを継承したカスタムAstVisitorクラスの場合、基本的な処理を実装する必要はないですし、目的とするASTクラスに対するVisitメソッドだけオーバーライドすればいいので、非常に簡便です。
ただ、本格的にPowerShellの構文解析を行いたい場合、ノードの巡回順だとか、その他もろもろをもっと細かく自分で実装したいケースが出てきます。
そういった場合にはICustomAstVisitorインターフェースを実装したクラスを作って対応します。ICustomAstVisitorインターフェースも、AstVisitor抽象クラス同様、各ASTクラスに応じたVisitメソッドが定義されているのですが、各VisitメソッドはAstVisitAction列挙体ではなく、object型のオブジェクトを返します。つまり、自分で好きなオブジェクトを返すように定義できるわけです。
Ast.Visit(ICustomAstVisitor)はAstVisitor抽象クラスを引数に取る場合と異なり、objectを返却するのですが、このとき返却されるのは、最初に実行されたVisitメソッドの戻り値になります。
ICustomAstVisitorはインターフェースですので、処理はすべて自分で定義しなくてはなりません(※)。ノードの再帰的探索も、必要ならもちろん自前で実装する必要があります(前回紹介した、JSON化スクリプトのような処理になるかと思います)。
※ISEだとインターフェースの実装を一発で行うリファクタリング機能はないので、今回みたく実装すべきメンバがたくさんある場合は、こんな感じのひな形を作るスクリプトを使うと良いでしょう。
今回はICustomAstVisitorインターフェースを実装したクラスの実例まではご紹介できませんでしたが、興味のある方は、PSScriptAnalyzerで用いられているので参考にしてみてください。
まとめ
PowerShellのASTについてきちんと解説している記事が英語圏を含めてもあまりないようでしたので、3回に渡って、一通りの基礎知識をまとめてみました。
普通にPowerShellを使っている分には、滅多に使うことはないと思いますが、たとえばPSScriptAnalyzerのカスタムルールを自分で作る場合には、ASTの知識は必須になってきますので、必要に応じて参考にしていただければ幸いです。
2016/12/15
ASTをツリービューで表示する
この記事はPowerShell Advent Calendar 2016の15日目です。
前回はPowerShellのASTの概要を解説しました。今回は前回の補足というか応用的な内容になります。
前回、スクリプトブロックからどのようなASTが生成されるのか、図で書きました。そもそもあの図を作るにあたって、ASTの構造を視覚的に把握したかったので、そのためのスクリプトを書きました。
PowerShellで木構造を展開表示する方法は色々ある(※)かと思いますが、今回はJSONとして出力して、表示については他のアプリに任せることにしました。
※Format-Customのデフォルトビューは意外と使える
ただし、ASTオブジェクトをそのままConvertTo-Jsonコマンドレットに渡すわけにはいきません。というのも、AST構造を再帰的に展開するには、探索の深さ(-Depth)を大きくしなければいけませんが、そうするとASTではないオブジェクトも逐一展開してしまい、現実的な時間内で終わらなくなってしまいます。
そこで、ASTオブジェクトそのものをJSONにするのではなく、必要なプロパティのみ再帰的に取得したカスタムオブジェクトを生成し、それをJSONにする方針を取りました。その成果が以下のコードです。(using namespace節を使っているので、v5以上必須です。)
using namespace System.Management.Automation.Language function GetAstInner { param([Ast]$ast) end { $base = [ordered]@{ ExtentText = $ast.Extent.Text AstName = $ast.GetType().Name } $children = [ordered]@{} $leaves = [ordered]@{} $ast.psobject.Properties | ? Name -notin Extent, Parent | %{ $type = [type]($_.TypeNameOfValue) $propValue = $ast.($_.Name) if($type.IsSubclassOf([ast])) { if($null -ne $propValue) { $children[$_.name] = GetAstInner $propValue } } elseif($type.IsGenericType -and $null -ne ($type.GetGenericArguments() | where{$_.Name -eq "Tuple``2"})) { $asts = @() foreach($next in $propValue) { if($null -ne $next) { $asts += [pscustomobject]@{ Item1 = $( if($null -ne $next.Item1 -and $next.Item1 -is [ast]) { GetAstInner $next.Item1 } ) Item2 = $( if($null -ne $next.Item2 -and $next.Item2 -is [ast]) { GetAstInner $next.Item2 } ) } } } if($asts.length -ne 0) { $children[$_.Name] = $asts } } elseif($type.IsGenericType -and $null -ne ($type.GetGenericArguments() | where{$_.IsSubclassOf([ast])}) ) { $asts = @() foreach($next in $propValue) { if($null -ne $next) { $asts += GetAstInner $next } } if($asts.length -ne 0) { $children[$_.Name] = $asts } } else { if($null -ne $propValue) { $leaves[$_.Name] += $propValue.Tostring() } } } [pscustomobject]($base + $leaves + $children) } } function Get-Ast { param([scriptblock]$ScriptBlock) end { GetAstInner $ScriptBlock.Ast } }
本来なら、50種以上あるAstクラスに応じてきちんと場合分けすべきなのですが、コードが長くなるだけなので、動的言語の強みを生かしてダックタイピング的な方法で下位ノードを再帰的に展開しています。
途中、IfStatementAstのClausesプロパティなどで用いられている、ReadOnlyCollection<Tuple<Ast, Ast>>型であることを確認するのに苦労してますが、多分もっといい方法があると思います…。他はAstオブジェクトそのものか、ReadOnlyCollection<Ast>を返すだけなのでそんなに苦労はないです。Ast抽象クラスに含まれているExtent、Parentプロパティ以外で、Astを要素に含まないプロパティに関しては、ASTの葉として解釈しています。
次にこのスクリプトを使って、スクリプトブロックをJSONとして出力します。
$scriptBlock = { param([int]$x,[int]$y) end { $out = $x + $y $out | Write-Host -ForegroundColor Red } } Get-Ast $scriptBlock | ConvertTo-Json -Depth 100 | Set-Content ast.json
サンプルとして用いるスクリプトブロックは、前回のものと同じです。これを先ほど書いたGet-Ast関数に渡して、結果をConvertTo-JsonでJSON化しています。この際、探索の深さを100としていますが、ネストが深いスクリプトブロックなどでは、もっと大きくする必要も出てくるかもしれません。
出力されたast.jsonを、JSON Viewerを使って表示してみたのが、以下のスクリーンショットになります。
色んなスクリプトのASTを表示して、楽しんでみてください。
ASTシリーズはもう少し続きます。次回はAST Visitorと静的解析のお話です。
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とかの形態素解析エンジンの代替にするのはちょっと厳しいかもしれないです。まあ、標準機能のみでちょっとしたものを作れるのは大きいかと思います。何か日本語の文章を解析する必要があるときには使ってみてはいかがでしょうか。
2014/04/26
Get-Commandでダイナミックパラメータを表示する方法
小ネタですが。
まず、ダイナミックパラメータについてはぎたぱそ先生の記事を参照してください。要は、その名の通り動的に定義されるパラメータのことです。
ダイナミックパラメータは他の(静的な)パラメータの指定状態によってリアルタイムに定義されます。ValidateSet(値の候補リスト)だけではなく、パラメータの有無、パラメータ値の型やパラメータ名ですら変わり得ます。
一方、Get-Commandコマンドレットを使うとコマンドのパラメータや構文等の情報を取得する事ができます。しかし、ダイナミックパラメータは前述のような特性があるため、パラメータの有無、パラメータ名、パラメータ値の型が一意に定まりません。
この問題を解決するため、Get-Commandコマンドレットには-ArgumentListパラメータが用意されています。指定コマンドに与えるパラメータ値を-ArgumentListパラメータに指定すると、指定コマンド実行時にそのパラメータ値を指定した場合に定義されるダイナミックパラメータに関する情報が、出力結果に含まれるようになります。
例を挙げましょう。Get-Contentコマンドレットの-Pathパラメータは「位置パラメータ」、つまりパラメータ名を省略できるパラメータであり、パラメータ名なしで指定された一つめの値がバインドされます。つまり、 Get-Content C:\ とするとC:\(FileSystemプロバイダのパス)が-Pathパラメータにバインドされるわけです。(Get-Content -Path C:\ と同じ意味となる)
ところでGet-Contentコマンドレットは、FileSystemプロバイダでのみ有効となる-Encodingというダイナミックパラメータを持っています。「FileSystemプロバイダでのみ」というのはつまり、「カレントディレクトリがFileSystemプロバイダのパスであるか、-PathパラメータにFileSystemプロバイダのパスが指定されたとき」ということになります。
すなわち、 カレントディレクトリがC:\であったり、Get-Content C:\ と入力した瞬間、-Encodingダイナミックパラメータが定義されて利用できるようになります。
ではGet-CommandコマンドレットでGet-Contentコマンドレットの-Encodingダイナミックパラメータの情報を得るにはどうすればよいか。答えは、このダイナミックパラメータが定義されるトリガーとなるパラメータ値である「C:\」を-ArgumentListパラメータに指定してやればいいわけです。つまり 例えば
Get-Command -Name Get-Content -ArgumentList C:\ -Syntax
としてやると、Get-Contentコマンドレットの構文が
Get-Content [-Path] <string[]> [-ReadCount <long>] [-TotalCount <long>] [-Tail <int>] [-Filter <string>] [-Include <string[]>] [-Exclude <string[]>] [-Force] [-Credential <pscredential>] [-UseTransaction] [-Delimiter <string>] [-Wait] [-Raw] [-Encoding <FileSystemCmdletProviderEncoding>] [-Stream <string>] [<CommonParameters>]
のように表示され(※注:一部抜粋)、Get-Content C:\を実行するときに定義される-Encodingダイナミックパラメータも含まれていることがわかります。仮にHKLM:\等のFileSystemプロバイダ以外のパスを-ArgumentListに指定すると、
Get-Content [-Path] <string[]>[-ReadCount <long>] [-TotalCount <long>] [-Tail <int>] [-Filter <string>] [-Include <string[]>] [-Exclude <string[]>] [-Force] [-Credential <pscredential>] [-UseTransaction] [<commonparameters>]
のように表示され、ダイナミックパラメータが表示されないことが分かるかと思います。
さて、-ArgumentListは配列指定もでき、複数パラメータ値を指定した時の構文を調べることもできます。しかし位置パラメータ以外の場合、つまりダイナミックパラメータ定義のトリガーとなるパラメータにパラメータ名を指定する必要があるコマンドの場合はどうやら無理のようです。
以下はおまけです。ダイナミックパラメータのリストを表示します。-ArgumentListは指定してないので、カレントディレクトリのプロバイダの種類でのみ結果が変わります。
$cmds = Get-Command -CommandType Cmdlet,Function foreach($cmd in $cmds) { $params = $cmd.Parameters $dynamicParamNames = @() if($null -ne $params) { $dynamicParamNames = @($params.Values|?{$_.IsDynamic}|%{$_.Name}) } if($dynamicParamNames.Length -ne 0) { foreach($dynamicParamName in $dynamicParamNames) { [pscustomobject]@{ Cmdlet=$cmd Module=$cmd.ModuleName Parameter=$dynamicParamName } } } }
まあ本題とあんまり関係なくなってしまったんですが、せっかく作ったのでということで。こういうのを見ると、PowerShellはコマンドもオブジェクトなんだ、ということが分かると思います。
2014/04/15
本当は怖いPowerShell その1 暗黙の型変換の闇
PowerShellはゆるふわな言語ですが、そのゆるふわさがたまによく牙を剥きます。今日はそんなお話。
あえとすさんがこんなツイートをされていました。
$x = 'A' + @('B', 'C') ってしたとき、何故 $x.Length -eq 4 で $x[2] -eq ' ' なのだろうか。 #PowerShell
? アエトス・トリスメギストス (@aetos382) April 14, 2014
直観的には、$xには'A', 'B', 'C'の3要素が格納された配列となるのでLengthは3、$x[2]は最後の要素である'C'が入っていそうです。
さて、何故だかわかりますか。シンキングタイム3分。
…では解説です。
まず、'A' + @('B', 'C')というのは実は3要素の配列を返さず、単一の文字列を返します。というのもPowerShellは+, -等の二項演算子を利用する際、左辺と右辺の型が異なる場合は、まず右辺の型を左辺の型に暗黙の型変換を行ってから演算を行います。この場合だと右辺は@('B', 'C')なので文字列配列(厳密にはobject[])、左辺は文字列型なので、文字列配列が文字列に型変換されるわけです。
さて、ここで配列→文字列の型変換がどうやって行われるかという話なのですが、まず配列要素がそれぞれ文字列型に変換されます。この変換は型によってそれぞれ挙動が違いますが、特にPowerShell上で定義がない場合はToString()されたものが返されます。今回のは配列要素が元々文字列なので変換はありません。
次に、文字列同士がユーザー定義$OFSに格納されている文字列で連結されます。$OFSはデフォルトではnull(定義なし)なのですが、nullの場合は" "(半角スペース)として扱われます。
※ちなみにOFSとはOutput Field Separatorの略です。awkとかPerlとかにも同様の変数があり、PowerShellのはそれらを参考にしたものと思います。
よって、@('B', 'C')が文字列に変換されると、'B'と'C'が$OFSのデフォルトの" "で連結され、'B C'となります。変換の後+演算子が実行されて、'A'と'B C'が連結されるので、'AB C'となります。この値が$xに格納されるわけです。
$xには配列ではなく単一の文字列が格納されているので、Lengthプロパティはstringクラスのものが参照されるので、文字数を返却します。$xの中身はA,B,半角スペース,Cの4文字なので$x.Lengthは4になります。
また文字列変数に数値でのインデックスアクセスをすると、該当文字位置に格納されたchar型の文字が返されるので、$x[2]は$xに格納された3番目の文字(インデックスは0から始まるので)、' '(半角スペース)を返すわけですね。
これであえとすさんの疑問は解消したわけですが、じゃあ本来の目的である、「単一の値と配列を連結して配列を得る」にはどうするか、というと…
@('A', 'B', 'C') が欲しければ @('A') + @('B', 'C') とすればよい。 ('A`)ヴァー
? アエトス・トリスメギストス (@aetos382) April 14, 2014
となるわけです。こうやって非配列値をあらかじめ@()により要素数1の配列にしておくと、+演算子の左辺と右辺がどちらも配列型となるため型変換は行われず、配列同士の+演算、すなわち配列の連結処理が行われるわけですね。
その1とありますがその2があるかは不明。なお、闇が沢山あるのは事実です。('A`)ヴァー
2013/12/05
プロパティ値が変化するまで待機する関数 Wait-State を書いてみた
はじめに
この記事はPowerShell Advent Calendar 2013の5日目の記事です。
突然ですが、PowerShellにはJobが完了するまで待機するWait-Jobコマンドレットというのがあります。これはその名の通り、パイプラインから入力したJobオブジェクトがすべて(あるいはどれか一つが)完了状態になるまでスクリプトの実行を待機する効果があります。
当然ながらWait-JobはJobオブジェクトにしか利用できませんが、任意の入力オブジェクトに対して待機条件を指定してやれば、その条件を満たすまで実行を停止するコマンドがあると便利なんじゃないかな?と常々思っていたので書いてみました。
Wait-State関数
function Wait-State { [CmdletBinding(DefaultParameterSetName="ByProperty")] param( [Parameter(ValueFromPipeline=$true)] [PSObject]$InputObject, [Parameter(Position=1,Mandatory=$true,ParameterSetName="ByProperty")] [string]$Property, [Parameter(Position=2,ParameterSetName="ByProperty")] [object]$Value, [Parameter(Position=1,Mandatory=$true,ParameterSetName="ScriptBlock")] [Alias("Script")] [ScriptBlock]$FilterScript, [Parameter()] [switch] $Any, [Parameter()] [switch] $IgnoreImmutable, [Parameter()] [switch] $PassThru, [Parameter()] [switch] $AllOutput, [Parameter()] [int] $IntervalSec=1, [Parameter()] [int] $TimeoutSec=60 ) begin { $objects = @() $watch = New-Object System.Diagnostics.StopWatch $watch.Start() $firstChecked = $false } process { foreach($o in $InputObject) { $objects += $o } } end { while($true) { $remains = @() foreach($o in $objects) { if($firstChecked) { if($o.Refresh) { $o.Refresh() } } if($null -ne $FilterScript) { if($o|&{process{&$FilterScript}}) { if($PassThru) { if((!$IgnoreImmutable -or ($IgnoreImmutable -and $firstChecked))) { $o } } } else { $remains += $o } } else { if($Value -eq $o.$Property -and (!$IgnoreImmutable -or ($IgnoreImmutable -and $firstChecked))) { if($PassThru) { if((!$IgnoreImmutable -or ($IgnoreImmutable -and $firstChecked))) { $o } } } else { $remains += $o } } } if($remains.Length -eq 0) { break } elseif($Any -and $remains.Length -lt $objects.Length) { if($AllOutput -and $PassThru) { $remains } break } elseif($watch.Elapsed.TotalSeconds -ge $TimeoutSec) { if($AllOutput -and $PassThru) { $remains } break } $objects = @($remains) $remains = @() $firstChecked = $true Start-Sleep -Seconds $IntervalSec } } }
コマンド構文
Wait-State [-Property] <string> [[-Value] <Object>] [-InputObject <psobject>] [-Any] [-IgnoreImmutable] [-PassThru] [-AllOutput] [-IntervalSec <int>] [-TimeoutSec <int>] [<CommonParameters>] Wait-State [-FilterScript] <scriptblock> [-InputObject <psobject>] [-Any] [-IgnoreImmutable] [-PassThru] [-AllOutput] [-IntervalSec <int>] [-TimeoutSec <int>] [<CommonParameters>]
パラメータ
-InputObject:入力オブジェクト。パイプライン入力可。
-Property:変更を確認するプロパティ名。
-Value:-Propertyで指定のプロパティ値が、このパラメータに指定する値になるまで待機する。
-FilterScript:プロパティを指定する代わりに待機条件をスクリプトブロックで指定する。
-Any:入力のどれか一つが条件を満たすまで待機するようにする。(省略時は入力が全部条件を満たすまで待機)
-PassThru:入力オブジェクトが待機条件を満たした時点で、そのオブジェクトを出力する。省略時は出力なし。
-IgnoreImmutable:最初から条件を満たしている場合は出力しない。-PassThruと併用。
-AllOutput:タイムアウトした場合や-Any指定時に一部のオブジェクトしか出力していない場合でも、最終的に未出力のすべてのオブジェクトを出力してから終了する。-PassThruと併用。
-IntervalSec:プロパティ値のチェック、もしくは待機条件スクリプトの実行の間隔秒数を指定。デフォルト1秒。
-TimeoutSec:最大待機秒数。デフォルト60秒。この時間を過ぎると条件を満たしていなくても待機を終了する。
使用例
# 停止しているサービスがすべて開始するまで待機する。 Get-Service |? Status -eq Stopped | Wait-State -Property Status -Value Running # 上記と同じだが、開始したサービスを逐次表示する。 Get-Service |? Status -eq Stopped | Wait-State -Property Status -Value Running -PassThru # 停止しているサービスが少なくとも1つ開始するまで待機する。 Get-Service |? Status -eq Stopped | Wait-State -Property Status -Value Running -Any # プロセスのワーキングセットが100MBを超えた段階で逐次表示する。 Get-Process | Wait-State {$_.WorkingSet -ge 100MB} -PassThru # 上記と同じだが、最初から100MBを超えてるものは出力しない。 Get-Process | Wait-State {$_.WorkingSet -ge 100MB} -PassThru -IgnoreImmutable # ディレクトリ内のファイル容量がすべて50KBを超えるまで待機し、出力のFileInfo配列を変数に代入。 $files = Get-ChildItem | Wait-State {$_.Length -ge 50KB} -PassThru -AllOutput -TimeoutSec 3600
問題点
プロパティ値を取得するときにリアルタイムに値が反映されないオブジェクト(要するにGetした時点のプロパティ値がずっと固定されてるもの)に対しては正しく動作しません。というか、PowerShellで扱うオブジェクトはほとんどそうなんじゃないかと思います(汗
ServiceControllerオブジェクト、Processオブジェクト、FileInfoオブジェクト、DirectoryInfoオブジェクトについては、Refreshメソッドを実行すると、プロパティ値を現在の値に更新してくれるので、それを利用してプロパティ値を監視できるようにはしています。
それ以外についても監視できるようにするには、たぶんそれぞれのオブジェクトに応じた監視方法を地道に調査して実装していくしかないんじゃないかなあと思います。
INotifyPropertyChangedインターフェースを実装したクラスについては、PropertyChangedイベントをSubscribeしてプロパティ値の変更を追跡できるようにしてみようとちょっと思ったんですが、PowerShellで扱うオブジェクトにINotifyPropertyChangedを実装したクラスのものってそんなにあるんだろうか?と疑問を覚えたのでやめました。
WMIオブジェクトについては何か共通の方法でプロパティ値変更を監視できないかなあと思ったんですが、結局IntervalSec間隔でクエリを発行する方法になってしまい、低コストで行う方法がちょっと思いつきませんでした。
ただ、-FilterScriptパラメータをサポートしているので、ここに書くことでいかようにも待機条件をカスタマイズできるので、極端な話、条件スクリプトブロックに{(Get-Hoge -Name $_.Name).Property -eq “ほげ”}みたいなコードを書いてゴリ押しすることもできるかと思います。
感想
というわけで、なんだか微妙な成果になって恐縮ですが、なんで無いんだろうと思っていた関数を実際に書いてみると、無い理由が分かったりするものなんだなあ、と思ったりした次第です。
スクリプトの解説を何もしてないですが、あえて解説する程のものでもないこともないですが、まあ長くなるのでやめときます。
ただ、入力オブジェクトを一旦全部取得してから、後続パイプラインに流し込む例としていくらか参考になるかもしれません。(beginで入れ物を用意して、processで詰めて、endでメインの処理を書くだけですけど)
あとはフィルタースクリプトブロックの実装方法の一例としても参考になるかも? スクリプトブロックを二重にして$_に対象オブジェクトがきちんと格納されるようにする方法、若干トリッキーな気もしますが正式にはどう書くのが良いのか不明なのでこうしてみました。
2011/12/19
PowerShell 3.0で追加されるバックグラウンドジョブ関係の新機能 [PS Advent Calendar '11]
はじめに
PowerShell Advent Calendar 2011の19日目の記事、そしてこれが私の記事では3回目となります。今回も前々回、前回からの引き続きでバックグラウンドジョブについての話題です。前回までは現行バージョンであるPowerShell 2.0におけるバックグラウンドジョブの機能の使い方を解説してきましたが、今回はPowerShellの次期バージョンである3.0に追加される予定の機能のうち、ジョブ関係のものをピックアップしてみます。現在PowerShell 3.0を含むWindows Management Framework(WMF)3.0のCTP2が公開されています。またWindows 8 Developer Preview / Windows Server 8 Developer PreviewにはWMF3.0 CTP1相当のPowerShell 3.0が含まれています。
注意:本記事で取り上げた内容は製品のプレビュー版をもとに記述しています。そのためリリース版では内容が一致しない可能性があることをご承知おきください。
using:ラベル
前回、ジョブに値を渡す方法について解説しましたが、-argumentListに引数として渡すというのは正直めんどうです。呼び出し元のグローバル変数を直接ジョブ側から参照したいですよね。そこでPowerShell v3では新たに変数に付けるusing:ラベルというのが追加されました。このラベルをジョブのスクリプトブロック内で使うと、呼び出し元の変数を参照することができます。具体例。
$test="PowerShell 3.0" Start-Job {$using:test}|Wait-Job|Receive-Job
とすると、「PowerShell 3.0」と表示され、たしかにジョブのスクリプトブロックから呼び出し元の変数を参照できていることがわかります。これは便利ですね。ただし残念ながらこの方法を使ってもスクリプトブロックをジョブに渡すことはできないようです。相変わらず文字列にキャストされてしまいました。
Receive-Jobコマンドレットの変更点
前々回に、Invoke-Command -asJobで複数リモートコンピュータに対してジョブを走らせた場合、そのジョブに対して$job|Receive-Jobがなぜか機能しない、と書きましたがこの問題が解決されています。そもそもなんでこの問題が発生していたのか、面白いのでちょっと解説します。
実はReceive-Jobコマンドレットの-locationパラメータに「パイプライン入力を許可する true (ByPropertyName)」フラグがついていたのが原因でした。複数コンピュータに対して実行したジョブは子ジョブを複数持ちますが、親ジョブ自体は配列ではありません。そしてそのLocationプロパティには子ジョブが実行されているコンピュータ名が"remote01,remote02,remote03"のようなカンマ区切りの文字列として格納されています。よってこのジョブオブジェクトをパイプラインを通じてReceive-Jobコマンドレットに渡すと、ValueFromPipelineByPropertyName属性が付いている-locationパラメータにジョブオブジェクトのLocationプロパティの値が渡されますが、その値はカンマ区切りの文字列なので正しく解釈されず、結果として期待の動作をしなかったわけです。
v3ではReceive-Job -locationのValueFromPipelineByPropertyName属性が取り除かれ、問題なく動作するようになりました。
他の変更点としてはReceive-Jobにジョブが完了するまで待つための-waitパラメータが追加されました。が、$job|Wait-Job|Receive-Jobと違いが分からないかも…。
Get-Jobコマンドレットの変更点
Get-Jobに-filterパラメータが追加されました。連想配列でジョブにフィルタをかけられるものです。
Get-Job -filter @{State="Completed";Location="localhost"}
where-objectを使わずともフィルタできるので便利、かも。しかし個人的には-filterパラメータはいろんなコマンドレットで定義されているものの、使い方がそれぞれ異なるのがとてもとてもイヤです。まず覚えられないのでヘルプを引くところから始まっちゃいますので。パフォーマンスの関係上、Where-Objectを使うよりコマンドレット内部でフィルタしたほうが速くなるというのはわかるのですが、もう少しフィルタ方式に統一性を持たせられなかったんだろうかとか思いますね。
Get-Jobにはほかに-afterと-beforeというパラメータが追加されています。これは後述するPSScheduledJobの完了時刻をDateTimeで範囲指定し、フィルタするものです。
PowerShell Workflow
PowerShell3.0というかWMF3.0のおそらく目玉機能の一つがPowerShell Workflowです。文字通り、PowerShellでワークフローが記述できるようになります。
Workflowは関数の一種なのですが、長時間を要するタスクやリモート実行や並列実行などで使うことを主目的としているようです。functionキーワードの代わりにworkflowキーワードでワークフローを定義すると、自動的に実行対象コンピュータ名や資格情報といったパラメータが複数定義されるので、これらのパラメータを特に定義なしで利用することができます。またworkflow内ではparallelブロックを定義でき、その中に記述された各行は並列に実行されます。またfor/foreachステートメントで-parallelパラメータが利用可能になり、繰り返し処理やコレクションの列挙を並列して行うことができるようになります。
自動定義されるパラメータに-asJobがあり、これを利用するとworkflowをジョブとして実行できます。このジョブは通常のジョブとは違い、新たに追加されたSuspend-JobコマンドレットとResume-Jobを使うことによって、ジョブの一時中断と再開ができます。このジョブの中断と再開は、リモートコンピュータ上でワークフローを走らせてるときでも可能ですし、中断後リモートセッションが切断されたあとに再開することもできますし、リモートコンピュータがシャットダウンしても再起動後にジョブを再開することまでできてしまいます。これらはWMFにおけるリモート基盤を支えているWinRMの最新バージョン、WinRM3.0が実現している機能です。このようにセッションを再接続してもタスクを継続できるような接続をrobust(堅牢な), resilient(弾力性のある、障害から容易に回復する) connectionと称しているようです。
PowerShell WorkflowはWindows Workflow Foundation(WF)と密接な関係があり、WFのデザイナで作ったxamlをPS Workflowに変換したり(逆もできる?)、Invoke-Expressionでxamlを実行したりできるらしいです。WF側でもPowerShellの多くの機能がアクティビティとして使用できたりして、WFとPowerShellがWMFというシステム管理フレームワークの主要なパーツとして密に連携していくようです。このあたりの話はWFの専門家であるAhfさんがPSアドベントカレンダーの23日目にしてくださる予定なので、楽しみですね!
なおPS Workflowは従来のPSスクリプトとは異なった利用状況を想定しているため、あるいはWFの機能と合わせるため、PSスクリプトではできるのにPS Workflowではできないことがとてもたくさんあります。forの中でbreakやcontinueステートメントが使えないとかStart-Sleepは-Secondパラメータしか指定できない(ミリ秒単位でスリープかけられない)とか色々あります。そのうちPS WorkflowとPSスクリプトの違いというドキュメントが公開されるんじゃないかと思います。
ちなみにWinRM3.0のおかげでワークフローではない通常のリモートジョブでも、New-PSSessionで作成したセッションの中でジョブを実行した場合、そのジョブが動作しているコンピュータへのセッションを切断(Disconnect-PSSession)したあと、セッションに再接続(Connect-PSSessionやReceive-PSSession)すればジョブの結果を取得したりすることができます。またセッションを作製したインスタンス(powershell.exe)でそのセッションを切断すると、それ以降は別のインスタンスやコンピュータからそのセッションにConnect-PSSessionで接続することができます。
ScheduledTasksモジュール
PowerShell3.0が含まれる次期Windowsでは大量のモジュールが追加され、それらのモジュールに含まれるコマンドレットの総数はWindows 8でも2000を超える膨大な量になります。これはWindows 8やWindows Server 8では従来のコマンドプロンプトから実行するコンソールexeコマンドのほとんどすべてをPowerShellコマンドレットに置き換える措置のためです。もちろん従来のコマンドは互換性のために残されますが、netsh.exeなど一部のコマンドではPowerShellへの移行を促すメッセージが表示されたりするようになるようです。参考:Window 8の機能の概要 − @IT
ScheduledTasksモジュールというタスクスケジューラを扱うモジュールもWindows 8 / Windows Server 8に新しく追加されるモジュールの一つで、schtasks.exeを置き換えるものとなります。これまでPowerShellでタスクスケジューラを扱うにはschtasks.exeを使うか、WMIのWin32_ScheduledJobを使う必要があり面倒でしたが、このモジュールに含まれるコマンドレットを用いるとそれが容易に行えるようになります。たとえば「notepad.exeを毎日朝10:00に起動する。バッテリ駆動のときでも実行」というタスクを「test」という名前で登録するには、
$action = New-ScheduledTaskAction -Execute "notepad.exe" $trigger = New-ScheduledTaskTrigger -At "10AM" -Daily $setting = New-ScheduledTaskSettings -AllowStartIfOnBatteries New-ScheduledTask -action $action -trigger $trigger -setting $setting|Register-ScheduledTask -TaskName test
とすれば可能であるはずです。実はServer 8 Developer Preview版ではこのコードは機能しません。タスクのトリガを作成するNew-ScheduledTaskTriggerコマンドレットが正しいオブジェクトを作ってくれないのです。これは将来のバージョンできっと修正されるかと思います。ただトリガを定義する部分をはずせば(あんまり意味はないですが)このコードは動作するので、やり方はたぶんあってると思います。
Register-ScheduledTaskコマンドレットには-asJobパラメータがあり、タスクスケジューラへの登録をジョブとしてバックグラウンドで行うことができます。ScheduledTasksモジュールはWMIを利用してタスクスケジューラを操作するので、ほかのWMI関係のコマンドレットと同様ですね。
なおScheduledTasksモジュールはデフォルトでは読み込まれていないので、使用するには本来Import-Moduleコマンドレットを使用しなければならないところですが、PowerShell3.0のCmdlet Discoveryという機能によりImport-Moduleは実行しなくてもScheduledTasksモジュールに含まれるコマンドレットを利用することができます。Cmdlet Discoveryとは現在読み込まれていて実行可能なコマンドレットの中にない、未知のコマンドレットを実行しようとしたとき、Modulesフォルダに存在するモジュールから同名のコマンドレットが定義されているものを探し出し、発見できたらそのモジュールを読み込んだうえでコマンドレットを実行するという優れた機能です。初回だけモジュールの検索とロードの手順が実行されるので待たされますが、一度Cmdlet Discoveryによってモジュールがシェルに読み込まれればあとは快適にコマンドレットを実行できるようになります。
PSScheduledJobモジュール
ScheduledTasksモジュールは-asJobパラメータが定義されているくらいで実はそれほどPowerShellのジョブとは関係ないのですが、ScheduledTasksモジュールが内包しているPSScheduledJobモジュールはPowerShellのジョブ機能と大いに関係があります。
従来PowerShellスクリプトをタスクスケジューラに登録するにはコマンドラインに"powershell.exe"を、引数に"-file hoge.ps1"を指定して、みたいなまわりくどいことをする必要がありました。しかし新しく追加されるPSScheduledJobモジュールに含まれるコマンドレット群はこの問題を解消します。PowerShellスクリプト(.ps1)あるいはスクリプトブロックをPSScheduledJobとして直接タスクスケジューラに登録できるようになり、PowerShellとタスクスケジューラのシームレスな連携を実現します。こちらはWindows 8/Server 8に付属のモジュールではなく、PowerShell 3.0に付属のモジュールなので、Win7などでも使用可能になる予定です。
使用例を見ていきましょう。
$triggers = @() $triggers += New-JobTrigger -at "2012/01/01 11:11:10" -Once $triggers += New-JobTrigger -at "10:00" -Daily $sb = { "This is Scheduled Job." Get-Date } Register-ScheduledJob -ScriptBlock $sb -Trigger $triggers -Name ScheduledJobTest1
まずNew-JobTriggerコマンドレットによってトリガー(具体的には実行時刻など)を定義します。ここでは決められた時刻に1回実行するものと、毎日同じ時刻に実行するものの2つを定義してみました。そしてこれらの時刻に実行したい内容をスクリプトブロックに記述し、これらをRegister-ScheduledJobコマンドレットで登録してやります。
するとこのスクリプトブロックはタスクスケジューラに登録され、指定時刻になると指定したスクリプトブロックの内容が実行されます。このタスクは「タスクスケジューラ― ライブラリ\Microsoft\Windows\PowerShell\ScheduledJobs」に登録されています。
このタスクのアクションは具体的には次のようになっています。
powershell.exe -NoLogo -NonInteractive -WindowStyle Hidden -Command "Import-Module PSScheduledJob; Start-Job -DefinitionName 'ScheduledJobTest2' -DefinitionPath 'C:\Users\Administrator\AppData\Local\WindowsPowerShell\ScheduledJobs' -WriteToStore | Wait-Job"
これによると、指定時刻に実際にタスクスケジューラによって実行されるのはpowershell.exeであり、Start-Jobコマンドレットを使って登録したスケジュールをPowerShellのジョブとして実行していることがわかります。Start-Jobコマンドレットの-DefinitionNameパラメータなどはPSScheduledJobのために追加されたもので、これによりRegister-ScheduledJobが出力したPSScheduledJob定義をファイルから読み込んでジョブとして実行できるようになっています。PSScheduledJob定義とジョブの出力は-DefinitionPathで指定されているフォルダの下にxmlファイルとして保存されているので興味がある方は覗いてみるといいかもしれません。
さて、スケジュールしたジョブの実行結果はどうやって受け取ればいいのでしょうか。実はこれはすごく簡単で、PSScheduledJob(ここではScheduledJobTest1という名前で定義しました)がタスクスケジューラによって一度以上実行された後は、
$job=Get-Job -name ScheduledJobTest1
とすることでJobオブジェクトとして取得することができるようになります。あとは通常のジョブと同じ取り扱いができるので、
$job|Receive-Job
などで実行結果を取得できます。
ちなみにPSScheduledJobはそれを定義したインスタンス以外でも参照することができます。具体的にはpowershell.exeでジョブをスケジューリングして終了→また別のpowershell.exeを立ち上げてimport-module PSScheduledJobしたあとGet-Job|Receive-JobしてPSScheduledJobの結果を参照、みたいなことができます。
ここで紹介した一連の操作ではスクリプトブロックをPSScheduledJobにしましたが、Register-ScheduledJobコマンドレットの-FilePathパラメータを用いれば.ps1ファイルをPSScheduledJobとして登録することも可能です。
現行バージョンのPowerShellはとにかく起動が遅いため、タスクスケジューラにスクリプトを登録しても実行が始まるまで何十秒も待たされるなどはざらでしたが、PSv3は起動がずいぶん速くなり、スペックや状況にもよるとは思いますがpowershell.exeの起動後ほんの数秒でスクリプトが走り始めます。この速度のおかげもあってPSScheduledJobはきっととても有効に機能するんじゃないかと思います。
おわりに
今回はPowerShell 3.0で増強されるバックグラウンドジョブ関係の機能をまとめてみました。これらの新機能のおかげで、時間のかかる処理や定期実行する処理を扱うのが飛躍的にやりやすくなりそうです。PowerShell 3.0で追加される機能は他にもたくさんあって、このブログでもいつか全部紹介したいと思ってるのですが、今回取り上げたジョブ関係はその中でもかなり重要な機能増加を多く含んでいると言えるでしょう。PowerShell 3.0やWindows 8/Server 8のリリースに備えてジョブ関係から予習しておくのは悪くないと思いますよ。
なんか25日のアドベントカレンダーのうち3回もバックグラウンドジョブネタをやって、PSアドベントカレンダーというより私だけ一人でPSジョブアドベントカレンダーをやってる感じでちょっと申し訳ないんですが、どうか許してください。そして前回は今回で終了するって言ってたんですが、実はまだジョブ関係の小ネタが残ってるので最終日25日にさせてください。では今日のところはこのへんで。明日はwaritohutsuさんの登場です。よろしくお願いします。
2011/05/16
連想配列を変換してユーザー定義オブジェクトを簡単に作成する
昨日の記事で取り上げたJavaScriptSerializerを用いると、連想配列から楽にJSONを作成できることが分かったのですが、この記事を書いていて思ったのは、「PowerShellの連想配列って意外に使えるな」という点でした。
現在のところ、PowerShellは独自のクラスを記述する方法がありません(Add-Typeコマンドレットを用いてC#などでクラスを書いて利用することはできますが)。Add-Memberコマンドレットを用いると、既存のオブジェクトに対し、任意のプロパティやPowerShellスクリプトで記述したメソッドを追加することはできます。素のオブジェクトであるPSObjectをNew-Objectして作ったオブジェクトでもこれは可能なので、一応ユーザー定義オブジェクトを作ることは可能です。ですが、Add-Memberコマンドレットを使うのはちょっとめんどくさいです。
「Windows PowerShellインアクション」ではAdd-Memberを使いPowerShellの関数を駆使してクラス定義構文のようなものを実装した例はありますが、いささか大仰な感は否めません。
しかし連想配列をユーザー定義オブジェクト代わりに使うと、簡単にできますしそこそこ便利に使えます。
連想配列をオブジェクトの代わりにすることのメリットとデメリット
連想配列をオブジェクトの代わりにすることのメリットは以下の三点があるかと思います。
- 簡単な記述(連想配列のリテラル)でオブジェクトが作成できる
PowerShellの連想配列リテラル@{}を使うことで、簡単に記述できます。またそれを配列化するのも@()を使うと容易です。$pcItems= @( @{ code=25; name="ハードディスク2TB"; price=7000; }, @{ code=56; name="メモリ8GB"; price=8000; } )
- ドット演算子で値の参照、設定ができる
PowerShellの連想配列は「連想配列[キー名]」のほかに、「連想配列.キー名」でもアクセスできる。Write-Host $pcItems[1].name # 値の参照 $pcItems[1].name = "test" # 値の設定 $pcItems[0].maker = "Seagate" # 要素の追加
- 連想配列の配列に対してWhere-Objectコマンドレットでフィルタをかけることができる
これは2とも関係しているのですが、通常のオブジェクトと同様にWhere-Objectコマンドレットでのフィルタ、ForEach-Objectコマンドレットでの列挙が可能です。$pcItems|?{$_.price -gt 7000}|%{Write-Host $_.name}
このようにメリットはあるのですが、本物のオブジェクトではないのでそれに起因するデメリットがいくつかあります。
- 要素(プロパティ)をいくらでも自由に追加できてしまう
これはメリットではあるのですが、デメリットでもある点です。後述するデメリットのせいで、同じキーをもつ連想配列の配列を作ったつもりでも、どれかのキー(プロパティ名)を間違えていた場合、それを検出するのが困難です。 - メソッドがうまく記述できない
連想配列要素にスクリプトブロックを指定し、&演算子で実行することでメソッド的なことはできます。しかしこのスクリプトブロック内では$thisが使えず、オブジェクトのプロパティにアクセスすることができないのでいまいちです。$pcItem= @{ name="ハードディスク2TB"; price=7000; getPrice={Write-Host $this.price}; } &$pcItem.getPrice # 何も表示されない。$thisが使えない # getPrice={Write-Host $pcItem.price}ならOKだが…
- Get-Member、Format-List、Format-Tableなどが使えない
これらのコマンドレットはあくまで連想配列オブジェクト(Hashtable)に対して行われるので、意図した結果になりません。たとえば$pcItems|Format-Listした場合、Name : name Value : ハードディスク2TB Name : code Value : 25 Name : price Value : 7000 Name : name Value : メモリ8GB Name : code Value : 56 Name : price Value : 8000
こんな表示になってしまいます。
連想配列をユーザー定義オブジェクトに変換する関数ConvertTo-PSObject
このように、連想配列の記述のお手軽さは捨てがたいものの、いくつかの問題点もあるのが現実です。そこで連想配列のお手軽さを生かしつつ、ユーザー定義オブジェクトの利便性も取るにはどうすればいいか考えました。結論は、「連想配列を変換してユーザー定義オブジェクトにする関数を書く」というものでした。それが以下になります。
#requires -version 2 function ConvertTo-PSObject { param( [Parameter(Mandatory=$true, ValueFromPipeline=$true)] [System.Collections.Hashtable[]]$hash, [switch]$recurse ) process { foreach($hashElem in $hash) { $ret = New-Object PSObject foreach($key in $hashElem.keys) { if($hashElem[$key] -as [System.Collections.Hashtable[]] -and $recurse) { $ret|Add-Member -MemberType "NoteProperty" -Name $key -Value (ConvertTo-PSObject $hashElem[$key] -recurse) } elseif($hashElem[$key] -is [scriptblock]) { $ret|Add-Member -MemberType "ScriptMethod" -Name $key -Value $hashElem[$key] } else { $ret|Add-Member -MemberType "NoteProperty" -Name $key -Value $hashElem[$key] } } $ret } } }
ご覧のようにコード的には割にシンプルなものが出来ました。連想配列またはその配列をパラメータにとり、またはパイプラインから渡し、連想配列要素をプロパティまたはメソッドに変換してPSObjectにAdd-Memberしてるだけです。-recurseパラメータを付けると連想配列内に連想配列がある場合に再帰的にすべてPSObjectに変換します。
それでは実際の使用例を挙げます。
# 一番単純な例。パラメータに連想配列を渡すとPSObjectに変換する。 $book = ConvertTo-PSObject @{name="Windows PowerShell ポケットリファレンス";page=300;price=2000} Write-Host $book.name # 「Windows PowerShell ポケットリファレンス」と表示 $book.name="test" # プロパティに値をセットする Write-Host $book.name # 「test」と表示 #$book.size="A5" # 存在しないプロパティに値を代入しようとするとエラーになる # 連想配列をコードで組み立てていく例。 $mutaHash=@{} # 空の連想配列を作る $mutaHash.name="mutaguchi" # キーと値を追加 $mutaHash.age=32 $mutaHash.introduce={Write-Host ("私の名前は" + $this.name + "です。")} # スクリプトブロックを追加 $mutaHash.speak={Write-Host ($args[0])} # パラメータを取るスクリプトブロックを追加 $muta = $mutaHash|ConvertTo-PSObject # 連想配列はパイプラインで渡すことができる $muta.introduce() # 「私の名前はmutaguchiです。」と表示 $muta.speak("こんにちは。") # 「こんにちは。」と表示 # 連想配列の配列→PSObjectの配列に変換 $stationeryHashes=@() $stationeryHashes+=@{name="鉛筆";price=100} $stationeryHashes+=@{name="消しゴム";price=50} $stationeryHashes+=@{name="コピー用紙";price=500} $stationeryHashes+=@{name="万年筆";price=30000} $stationeries = ConvertTo-PSObject $stationeryHashes # "200円以上の文具を列挙" $stationeries|?{$_.price -ge 200}|%{Write-Host $_.name} # 「コピー用紙」と「万年筆」が表示 # 連想配列の配列をリテラルで一気に記述する $getPrice={Write-Host $this.price} # 共通のメソッドを定義 $pcItems= @( @{ code=25; name="ハードディスク2TB"; price=7000; getPrice=$getPrice }, @{ code=56; name="メモリ8GB"; price=8000; getPrice=$getPrice }, @{ code=137; name="23インチ液晶ディスプレイ"; price=35000; getPrice=$getPrice } )|ConvertTo-PSObject $pcItems[1].getPrice() # 「8000」と表示 $pcItems|Format-List <# 表示: name : ハードディスク2TB code : 25 price : 7000 name : メモリ8GB code : 56 price : 8000 name : 23インチ液晶ディスプレイ code : 137 price : 35000 #> # 連想配列の中に連想配列を含めたもの→PSObjectをプロパティの値に持つPSObject $blog= @{ utl="http://winscript.jp/powershell/"; title="PowerShell Scripting Weblog"; date=[datetime]"2011/05/16 00:25:31"; keywords=@("スクリプト","PowerShell","WSH"); # 配列を含めることもできる author=@{name="mutaguchi";age=32;speak={Write-Host "ようこそ私のブログへ"}} # 連想配列を含める }|ConvertTo-PSObject -recurse # -recurseパラメータを指定すると再帰的にすべての連想配列をPSObjectに変換する $blog.author.speak() # 「ようこそ私のブログへ」と表示 Write-Host $blog.keywords[1] # 「PowerShell」と表示 # ※配列要素に連想配列以外の値が含まれている場合は展開しない
このように簡単な関数一つで、連想配列にあった問題点をすべて解消しつつ簡単な記述で独自のオブジェクトを記述できるようになりました。おそらくかなり便利だと思いますのでぜひ使ってみてください。
余談:ScriptPropertyを使う場合
余談ですが、今回使用したNotePropertyはプロパティに代入できる型を指定したり、リードオンリーなプロパティを作ったりすることができません。そういうのを作りたい場合はScriptPropertyを使います。Add-Memberコマンドレットの-valueパラメータにゲッターを、-secondValueパラメータにセッターをそれぞれスクリプトブロックで記述します。
しかしこいつはあまりいけてないです。これらのスクリプトブロック内で参照するフィールドを別途Add-MemberでNotePropertyを使って作成する必要があるのですが、これをprivateにすることができません。よってGet-Memberでもばっちり表示されてしまいますし、フィールドを直接書き換えたりもできてしまいます。
また今回のように連想配列をPSObjectに変換する場合はprivateフィールド名も自動生成する必要があるのですが、それをScriptProperty内のゲッター、セッターから取得する方法がなく、たぶんInvoke-Expressionを使うしかありません。
これらを踏まえて元の連想配列要素の値の型を引き継ぎ、それ以外の型を代入できないようにしたScriptPropertyバージョンも一応書いてみました。ConvertTo-PSObject関数のelse句の部分を以下に置き換えます。
#$ret|Add-Member -MemberType "NoteProperty" -Name ("_" +$key) -Value $hash[$key] "`$ret|Add-Member -MemberType ScriptProperty -Name $key -Value {[" + $hash[$key].gettype().fullname + "]`$this._" + $key + "} -SecondValue {`$this._" + $key + "=[" + $hash[$key].gettype().fullname + "]`$args[0]}"|iex
まあこれはいまいちなんで参考程度に。
2012/08/23追記
この記事を書いた時は知らなかったのですが、実は単にNotePropertyだけを持つユーザー定義オブジェクトを作成するのであれば、もっと簡単な方法があります。
# PowerShell 1.0 $o=New-Object PSObject|Add-Member noteproperty Code 137 -pass|Add-Member noteproperty Name 23インチ液晶ディスプレイ -pass # PowerShell 2.0 $o=New-Object PSObject -Property @{Code=137;Name="23インチ液晶ディスプレイ"} # PowerShell 3.0 $o=[pscustomobject]@{Code=137;Name="23インチ液晶ディスプレイ"}
3つのコードはほぼ等価です。PowerShell 2.0と3.0では連想配列リテラルを用いて簡単にカスタムオブジェクトを作れるようになりました。
元記事:http://blogs.wankuma.com/mutaguchi/archive/2011/05/16/199086.aspx2011/05/04
if($array -eq $null) には要注意!
配列変数がnullかそうでないかを調べることはたまにあるかと思います。しかし、
if($array -eq $null) { '$arrayはnull!' } else { '$arrayはnullじゃない!' }
とするのはダメです。たとえば$array=@($null,$null,121,123)というような配列を渡すと「$arrayはnull!」と表示されてしまいます。
なぜこんなことが起きてしまうかというと、-eq演算子は比較演算子であると同時に、配列をフィルタする演算子でもあるからです。たとえば、1,2,2,3,3,3,4,5という要素を持つ配列のうち、2と一致する要素を持つ配列だけを抽出するのはこんな感じです。
PS > $array=@(1,2,2,3,3,3,4,5) PS > $array -eq 2 2 2
-eq以外にも各種比較演算子が同様に使えます。3以上の要素のみ返すなら$array -ge 3となります。
つまり最初に挙げた例の場合、左辺の$arrayが配列であれば、TrueかFalseを返すのではなく、右辺の値(ここでは$null)と一致するものを抽出して配列として返すのです。よってもし$arrayに複数の$nullが要素として含まれていると、
if($array -eq $null)
は
if(@($null,$null))
と解釈され、2要素を持つ配列なのでこれは条件文中でTrueに評価されて結果、ifステートメントの中が実行されてしまうわけです。
$nullが一要素しか含まれない配列、すなわち$array=@($null,121,123)のような配列ではまた挙動が変わり、「$arrayはnullじゃない!」と表示されます。これはなぜかというと、
if($array -eq $null)
は
if(@($null))
と解釈されるためです。@($null)はFalseと解釈されるので、結果elseが実行されるわけです。
$nullを要素に含まない配列$array=@(1,2,3)とかだと問題が起きないのは、$array -eq $nullが長さ0の配列@()を返し、これはFalseと評価されるからです。(これも良く考えると理由がよくわかりませんが)
このように-eq演算子が配列フィルタとして働いてしまうのを防ぎつつ、配列変数が$nullではないかを確認するには次のようにすると良いでしょう
if($null -eq $array) { '$arrayはnull!' } else { '$arrayはnullじゃない!' }
右辺と左辺を入れ替えただけですが、問題なく動きます。
これが気持ち悪いならば
if($array -isnot [array] -and $array -eq $null)
のようにして変数が配列かどうかまず判断するのでもいいかもです。
これ、案外ハマりどころだと思います。「配列要素に二つ以上nullが含まれるときだけ結果がおかしくなる」のも気づきにくい原因。ぜひ気を付けてください。
ちなみに
if($array) { '$arrayはnullじゃない!' } else { '$arrayはnull!' }
なんてのも駄目です。$array=@($false)や$array=@(0)など、要するに、要素数1でその要素がFalseと解釈される配列が来ると「$arrayはnull!」と表示されてしまいます。
元記事:http://blogs.wankuma.com/mutaguchi/archive/2011/05/04/198780.aspxCopyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー