2016/12/12

この記事は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"を省略表記しています。)

PowerShell_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編はあと何回か続く予定です。

2015/12/10

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

はじめに

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

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

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

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

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

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

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

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

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

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

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

$objs| Format-List

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

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

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

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

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

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

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

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

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

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

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

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

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

その他のHTMLパース手法

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

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

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

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

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

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

SgmlReader

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

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

Add-Type -Path .\SgmlReaderDll.dll

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

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

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

正規表現等で自前パース

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

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

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

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

ConvertFrom-String

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

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

まとめ

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

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

2015/11/09

前編(前々回)中編(前回)の続きです。

分かち書きとは

中編で作った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/17

はじめに

がりっち氏がWindows10 UWPに日本語解析のAPIが備わっていた件 | garicchi.com というエントリを上げていました。

実はWin10に限らず、Win8.1 / Server 2012R2以降であれば、Windows ランタイム(WinRT)のWindows.Globalization名前空間に含まれるJapanesePhoneticAnalyzerクラスを用いた形態素解析ができます。

形態素解析とは要するに文字列を単語(正確には形態素という、文字列の最小構成要素)ごとに分割し、それぞれの単語の品詞を判別する処理になります。(JapanesePhoneticAnalyzerクラスだと分割までで、品詞の情報は取得できない?ようですが…)

またJapanesePhoneticAnalyzerでは分割した単語の読み仮名を取得することができます。

WinRTならPowerShellからも使えるんじゃないかなーと思ってやったらできたので、紹介します。

WinRTのPowerShellからの利用法

WinRTについての説明は他サイト様に譲りますが、要はWindowsストアアプリやUWP(ユニバーサルWindowsプラットフォーム)アプリを動作させる実行環境とAPI群です。

じゃあデスクトップアプリであるPowerShellは関係ないのかというとそうではなくて、例えばストアアプリのサイドローディングを行うAppxモジュールというものがあります。

つまりWinRTは従来のデスクトップアプリからの相互運用もできるようになっています。(すべてのコンポーネントではない)

このあたりの話は、荒井さんの記事が参考になるかと思います。:特集:デスクトップでもWinRT活用:開発者が知っておくべき、ライブラリとしてのWindowsランタイム (1/5) - @IT

PowerShellからWinRTを利用するには.NET Frameworkに含まれるクラスを利用するのと基本は同じです。

ただし注意点としては、クラス名を指定する時は、クラスの「アセンブリ修飾名」を指定する必要があります。

今回の例ではJapanesePhoneticAnalyzerクラスを使いますが、アセンブリ修飾名はWindows.Globalization.JapanesePhoneticAnalyzer, Windows.Globalization, Version=255.255.255.255, Culture=neutral, PublicKeyToken=null, ContentType=WindowsRuntime

となります。

このうち必須となるのは

Windows.Globalization.JapanesePhoneticAnalyzer:クラスの完全修飾名
Windows.Globalization:クラスの含まれる名前空間
ContentType=WindowsRuntime:WinRTのコンポーネントであること

の3つだけのようです。

つまり、PowerShellからは

[Windows.Globalization.JapanesePhoneticAnalyzer, Windows.Globalization, ContentType=WindowsRuntime]

とすればクラスを参照することができます。

ちなみに任意の型のアセンブリ修飾名を知るには、[型名].AssemblyQualifiedName のようにすればOKです。

なお、WinRTにはPowerShellからも利用価値の高いクラスが他にも色々あるようです。ぎたぱそ氏が認証系のクラスについて書かれていますので、参考にしてみてください。:PowerShell も Windows Store Apps 同様に Windows.Security.Credentials namespace を使って認証情報を管理できるようにしてみる - tech.guitarrapc.com

単語を分割する

早速、形態素解析をやってみましょう。具体的にはJapanesePhoneticAnalyzerのGetWordsメソッドを呼び出すだけです。

すべての基本になるので軽く関数としてラップしておきます。

function Get-JpWord
{
    param(
        [parameter(ValueFromPipeline=$true)]
        [ValidateLength(1,99)]
        [string[]]
        $Text,
        
        [switch]
        $MonoRuby
    )
    process
    {
        foreach($t in $Text)
        {
            [Windows.Globalization.JapanesePhoneticAnalyzer, Windows.Globalization, ContentType=WindowsRuntime]::GetWords($t, $MonoRuby)
        }
    }
}

GetWordsメソッドはスタティックメソッドなので、::演算子で呼び出します。戻り値はIReadOnlyList<JapanesePhoneme>というコレクションです。

GetWordsメソッドの第2引数にTrueを指定すると、漢字の含まれた単語をルビの振れる最小単位にまで分割する、Mono Rubyモードが有効になります。

なお、GetWordsメソッドはどうも文字数制限があるようです。だいたい100文字を超えると何も出力しない感じです。この制限値はリファレンスに書いてないようなので詳細不明ですが、一応関数では99文字までという制限を入れておきました。

例えば、Get-JpWord "最近急に寒くなってきました。" のようにすると結果は以下のように表示されます。

DisplayText          IsPhraseStart YomiText
-----------          ------------- --------
最近                          True さいきん
急に                          True きゅうに
寒くな                        True さむくな
って                         False って
き                            True き
ました                       False ました
。                            True 。

出力されるJapanesePhonemeオブジェクトは3つのプロパティを持ちます。

DisplayText 分割された単語
IsPhraseStart 単語が文節の開始であるかどうか
YomiText 単語の読み仮名

このように、入力した文章を単語単位に分割し、それぞれの単語の読み仮名を取得することができます。ただこれだけだと、「で、どうしろと」という感じなので、この出力結果を利用する、より実用的な関数を書いていきましょう。

長くなったので次回に続く。

2011/12/25

はじめに

PowerShell Advent Calendar 2011の25日目最終日の記事、そしてこれが私の記事では4回目となります。今回もバックグラウンドジョブについての話題です。今回はバックグラウンドジョブを使って並列処理をやってみようという試みです。

これまでの記事は以下になります。

2日目:バックグラウンドジョブの使い方・基本編

13日目:バックグラウンドジョブとの通信

19日目:PowerShell 3.0で追加されるバックグラウンドジョブ関係の新機能

ところでつい2日前、WMF3 CTP2 Windows PowerShell Workflow.pdfというpdfファイルが公開されました。これは19日目に書いたPS workflowについての詳しい説明(英語)です。構文だけでなくPSスクリプトとの違いやWFとの関係などが詳しく書かれています。ぜひ目を通しておくことをお勧めします。23日目のAhfさんの記事と併せて読むと理解が深まると思いますよ!

並列処理スクリプト

C#をご存知の方なら、PowerShellのバックグラウンドジョブ機能はC#4.0から使えるTaskオブジェクトとちょっと似てるかなーと思われるかもしれません。ではC#4.0でコレクションに対して並列処理でループを回すParallel.For()やParallel.Invoke()みたいなことはPowerShellでできないのか、という疑問が出てくるかと思います。

前回述べたようにPowerShell 3.0ならworkflowを使えば並列処理が可能で、for -parallelステートメントやparallelブロックでParallel.For()やParallel.Invoke()みたいなことが可能になります。しかしPowerShell 3.0がリリースされるのはまだ先ですし制限事項も多いので、なんとかPowerShell 2.0で、しかもworkflowのような制限なしで、並列処理のスクリプトは書けないものかと考えてみました。

function ParallelForEach-Object
{    
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true,Position=1)][scriptblock]$process,
        [scriptblock]$begin={},
        [scriptblock]$end={},
        [Parameter(ValueFromPipeline=$true)][psobject]$inputObject
    )
    begin
    {
        &$begin
        $jobs=@()
    }
    
    process
    {    
        $jobs|Receive-Job
        while(@($jobs|?{$_.State -eq "Running"}).Length -ge 5)
        {
            $jobs|Receive-Job
            start-sleep -Milliseconds 100
        }       
        
        $jobs += Start-Job $process -argumentList $inputObject
    }

    end
    {
        while(@($jobs |?{$_.State -eq "Running"}).Length -gt 0)
        {
            $jobs|Receive-Job
            start-sleep -Milliseconds 100
        }
        $jobs|Receive-Job
        $jobs|remove-job
        &$end
    }
}




$watch=new-object System.Diagnostics.Stopwatch

"ForEach-Object 開始"

$watch.Start()
1..10|ForEach-Object {
    "start: " + $_
    Start-Sleep -sec 5
    "end: " + $_
    
}
$watch.Stop()

"ForEach-Objectの場合:" + $watch.Elapsed.TotalSeconds + " sec"

$watch.Reset()

"ParallelForEach-Object 開始"

$watch.Start()
1..10|ParallelForEach-Object {
    "start: " + $args[0]
    Start-Sleep -sec 5
    "end: " + $args[0]
}
$watch.Stop()

"ParallelForEach-Objectの場合:" + $watch.Elapsed.TotalSeconds + " sec"

ParallelForEach-Object関数はパイプラインから渡されたコレクションの各要素について、並列にスクリプトブロックを実行させるものです。同等の処理をForEach-Objectを使って同期的に逐次処理した場合とかかる時間を比較しています。10個の要素があり、各要素につき5秒かかる処理なので、逐次的に処理すると当然50秒以上かかりますが、ParallelForEach-Object関数を使って並列処理させると環境にもよりますが20秒以内に完了します。

この関数では渡されたコレクション1要素に対し1つのジョブを割り当て、同時に5ジョブまで(呼び出し元を含めて同時稼働が6プロセスまで)を並列実行するようにしています。

ただこれはあくまでなんちゃって並列処理なので、並列化することで本当に処理が高速になるかどうかは環境次第かと思います。一応、うちのCore2Duo (2コアCPU)な環境だと、足し算を3万回ほどする処理を10回行う場合、逐次処理とこの関数を使った並列処理では54秒が39秒に短縮され、有意な実行時間差が出ました。

またジョブを開始するのに新しくプロセスを起動させるので、1ループあたりの実行時間がプロセス起動にかかる時間より短ければ、この関数による並列化で処理時間の短縮は見込めません。

処理の対象が複数のリモートPCである場合などは割と有効なのかなと思います。たとえば複数サーバーから別々のファイルを同時にダウンロードするときなど。

ここではParallel.For()やParallel.ForEach()相当の関数を書きましたが、Parallel.Invoke()のような関数も書けるかと思います。スクリプトブロックの配列をStart-Jobで順に走らせ、Wait-Job, Receive-Jobする感じですね。

あとここではやりませんでしたが、Start-Jobの代わりにInvoke-Commandを使い複数のリモートPCに処理を振り分ければ、なんちゃって分散処理もできるのかなあと思いました。

おわりに

実はこのスクリプトを書いたのはPS Workflowの調査前のことで、Workflowで同様のことが可能になることを知って少々愕然としたのですが、それなりに面白いスクリプトかと思ったので公開することにしました。ともあれ、これからのマルチコア、メニーコアの時代、非同期処理や並列処理はますます重要になるかと思います。管理スクリプトにおいてもこれらの概念を意識しないわけにはいかなくなるでしょう。全4回にわたってPowerShellのバックグラウンド機能を解説してきましたが、これらがあなたの非同期&並列スクリプトライフ(?)の一助になれば幸いです。

さてさて、これでPSアドベントカレンダー2011もおしまいです。楽しんでいただけたでしょうか? 私自身も自分で記事を書いていて楽しかったですし、他の方の記事を読むのも色々な発見があり、とても有意義な25日間でした。記事を書いて参加していただいた方々、そして読者の方々に厚く御礼申し上げます。これからもぜひ、PowerShellを活用し、楽しんでくださいませ。

それでは皆様、良いクリスマスをお過ごしください!

2011/12/19

はじめに

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さんの登場です。よろしくお願いします。

2006/07/14

.NET Framework 2.0にはタスクトレイにアイコンを表示させるためのクラス、NotifyIconクラスがあります。これを使ってみましょう。

function CreateNotifyIconMenu()
{
 # menuItem1オブジェクトの作成
 $menuItem1 = new-object System.Windows.Forms.MenuItem("終了(&X)")
 
 # Clickイベント
 $menuItem1.Add_Click({Form_Closing})
 
 # contextMenuオブジェクトの作成
 $contextMenu = new-object System.Windows.Forms.ContextMenu
 
 # menuItemオブジェクトをcontextMenuオブジェクトのMenuItemsコレクションに追加
 [void]$contextMenu.MenuItems.Add($menuItem1)
 #↑ここの[void]を忘れるとAddメソッドの戻り値がreturnされてしまう。
 
 return ($contextMenu)
}
function Form_Closing()
{
 # フォームとシステムトレイアイコンを非表示に
 $form.Visible = $false
 $notifyIcon.Visible = $false
 
 # PowerShellの終了
 [System.Environment]::Exit(0)
}
[void] [Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
[void] [System.Windows.Forms.Application]::EnableVisualStyles()
# notifyIconオブジェクトの作成とプロパティの設定
$notifyIcon = new-object System.Windows.Forms.NotifyIcon
# System.Drawing.Iconクラスのコンストラクタにはicoファイルのパスを指定する
$notifyIcon.Icon = new-object System.Drawing.Icon C:\script\test\a.ico
$notifyIcon.Text = "PowerShell実行中"
$notifyIcon.ContextMenu = CreateNotifyIconMenu
$notifyIcon.Visible = $true
# formオブジェクトの作成
$form = new-object System.Windows.Forms.Form
# Closingイベント
$form.Add_Closing({Form_Closing})
# フォームの表示
[void]$form.showDialog()

理論上はフォームを表示させなくても良いはずなんですが、ループをまわすいい方法が思いつきませんでした(start-sleepを使うとその間イベントが実行されない)。あと、showDialogで表示したフォームを閉じるいい方法も思いつかなかったので[System.Environment]::Exit(0)
としてPowerShell自体のプロセスを終了させています(exitとやるとエラーが出るんですよね。なんでしょうこれは)。これらに対する良い解決策をお持ちの方は教えてください。

PowerShellのfunctionって引数がなしのとき、呼び出す際()を使うとエラーになるんですよね。あと、文頭に持ってこないといけないのも気に入りません。何とかならなかったのでしょうか…。

せっかくタスクトレイが使えるのに、フォームとコンソールまで表示されてしまいます。そこでWSHを併用してごまかす方法を。

Set WshShell=CreateObject("WScript.Shell")
WshShell.Run "powershell test.ps1",0

このようなvbsファイルを作ってps1ファイルをキックしてやります。Runメソッドの第二引数に0を指定すると、コンソールおよびフォームが非表示になりますが、タスクトレイは表示されます。この手を使うとまるでタスクトレイだけが表示されているかのような状態を作り出せます。ちなみに、MessageBoxも同じようにそれだけを表示させることができます。

元記事:http://blogs.wankuma.com/mutaguchi/archive/2006/07/14/32470.aspx

Copyright © 2005-2016 Daisuke Mutaguchi All rights reserved

mailto: mutaguchi at roy.hi-ho.ne.jp

Awards

Books

Twitter