2014/10/12

昨日10/11のPowerShell勉強会#4にお越しいただいた方、どうもありがとうございました。スタッフ一同、これからも定期的に開催していこうと考えておりますので、ぜひともよろしくお願い致します。

私のセッション資料を公開します。わんくま横浜で行ったものとほとんど同じですが、v5関係のスライドを少しだけ修正しています。

今回は高度な関数とコマンドレットの作り方を主に取り上げていますが、要するに、PowerShellコマンドはPowerShellスクリプトでもC#でもほぼ同じようにして同じようなものが書ける、ということなのです。なので、基本を押さえればどちらにも対応可能です。

両者は状況に応じて使い分ければOKかと思います。PowerShellよりC#が得意、というならばコマンドレットを書くと良いですし、スクリプト的なお手軽なコードで書きたい場合は高度な関数、とかですね。

速度が求められる部分とか、.NETアセンブリを多用する部分だけコマンドレットにして、他を高度な関数にする、あるいは、主要部はコマンドレットにして、その他はコマンドレットのラッパー的な高度な関数を用意する、のように両者を組み合わせたモジュールもよくあります。

以下はデモ用のサンプルスクリプトです。高度な関数の雛型的なものになります。使い方はスライド本文を参照してください。

function Get-Foo
{
    [CmdletBinding()]
    param([string[]]$Name)
    end
    {
        foreach($n in $name)
        {
            $out = [pscustomobject]@{
                Name = $n
                No = 0 
            }
            # PSCustomObjectのインスタンスに型名を付ける
            $out.PSTypeNames.Insert(0, "Winscript.Foo")
            $out
        }
    }
}

function Set-Foo
{
    [CmdletBinding()]
    param(
        [parameter(ValueFromPipeLine=$true, Mandatory=$true, Position=0)]
        [PSObject[]]
        $InputObject,
        [parameter(Position=1)]
        [string]
        $Property,
        [parameter(Position=2)]
        [PSObject]
        $Value,
        [switch]
        $PassThru
    )
    process
    {
        foreach($o in $InputObject)
        {
            $o.$Property = $Value
            if($PassThru)
            {
                $o
            }
        }
    }
}

# C#によるクラス定義
Add-Type -TypeDefinition @"
using System;
namespace Winscript
{
    public class Foo2
    {
        private string _name;
        private int _no;
        public Foo2(string name)
        {
            _name = name;
            _no = 0;
        }
        public string Name
        {
            get{
              return _name;  
            }
            set{
               _name = value; 
            }
        }
        public int No
        {
            get{
              return _no;  
            }
            set{
               _no = value; 
            }
        }
    }
}
"@

function Get-Foo2
{
    [CmdletBinding()]
    param([string[]]$Name)
    end
    {
        foreach($n in $name)
        {
           New-Object Winscript.Foo2 $n
        }
    }
}

function Set-Foo2
{
    [CmdletBinding()]
    param(
        [parameter(ValueFromPipeLine=$true, Mandatory=$true, Position=0)]
        [Winscript.Foo2[]]
        $InputObject,
        [parameter(Position=1)]
        [string]
        $Property,
        [parameter(Position=2)]
        [PSObject]
        $Value,
        [switch]
        $PassThru
    )
    process
    {
        foreach($o in $InputObject)
        {
            $o.$Property = $Value
            if($PassThru)
            {
                $o
            }
        }
    }
}

以下はデモで用いたC#のコードです。コマンドレットクラスの雛型的なものになっています。ビルドの際はSDKに含まれるPowerShell関係のdllを参照設定してください(詳しくはスライド)。また使用する際は、Import-Module ビルドで生成したdllのフルパス を実行してインポートして下さい。以下の例だとGet-Baz、Set-Bazの2コマンドレットがインポートされます。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Management.Automation;

namespace Winscript
{
    [Cmdlet(VerbsCommon.Get, "Baz")]
    public class GetBazCommand : Cmdlet
    {
        [Parameter(Mandatory = false, ValueFromPipeline = false, Position = 1)]
        public string[] Name { get; set; }

        protected override void ProcessRecord()
        {
            foreach (var n in Name)
            {
                WriteObject(new Baz(n));
            }
        }
    }

    [Cmdlet(VerbsCommon.Set, "Baz")]
    public class SetBazCommand : Cmdlet
    {
        [Parameter(Mandatory = true, ValueFromPipeline = true, Position = 0)]
        public Baz[] InputObject { get; set; }

        [Parameter(Mandatory = true, ValueFromPipeline = false, Position = 1)]
        public string Property { get; set; }

        [Parameter(Mandatory = true, ValueFromPipeline = false, Position = 2)]
        public PSObject Value { get; set; }

        [Parameter(Mandatory = false, ValueFromPipeline = false)]
        public SwitchParameter PassThru { get; set; }

        protected override void ProcessRecord()
        {
            foreach(var o in InputObject)
            {
                if (Property == "No")
                {
                    o.No = (int)Value.BaseObject;
                }
                else if(Property == "Name")
                {
                    o.Name = (string)Value.BaseObject;
                }
                if (PassThru)
                {
                    WriteObject(o);
                }
            }
        }
    }

    public class Baz
    {
        private string _name;
        private int _no;
        public Baz(string name)
        {
            _name = name;
            _no = 0;
        }
        public string Name
        {
            get
            {
                return _name;
            }
            set
            {
                _name = value;
            }
        }
        public int No
        {
            get
            {
                return _no;
            }
            set
            {
                _no = value;
            }
        }
    }
}

2014/09/09

8/23わんくま横浜勉強会で、PowerShellコマンドの書き方というセッションをしたのですが、その際、株式会社Codeerさんが公開されているFriendlyというライブラリを使ったコマンドレットを動作させるデモを行いました。

準備の時間がなくて、突貫工事で作ったサンプルで恐縮ですが、公開することにします。(一応動かしてみたら動いた、レベルのものなのであしからず…)

コード
using System;
using System.Diagnostics;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Management.Automation;
using Codeer.Friendly.Windows;
using Codeer.Friendly.Dynamic;
using System.Windows.Forms;

namespace Winscript
{
    [Cmdlet(VerbsCommon.Get, "FormControlText")]
    public class GetFormControlTextCommand : Cmdlet
    {
        [Parameter(Mandatory = false, ValueFromPipeline = false, Position = 1)]
        public string[] ControlName { get; set; }

        [Parameter(Mandatory = true, ValueFromPipeline = true, Position = 0)]
        public Process Process { get; set; }

        protected override void ProcessRecord()
        {
            var _app = new WindowsAppFriend(this.Process);
            dynamic form = _app.Type<Control>().FromHandle(this.Process.MainWindowHandle);
            foreach (var c in form.Controls)
            {
                if (ControlName == null || ControlName.Contains((string)c.Name))
                {
                    WriteObject((string)c.Text);
                }
            }
        }
    }

    [Cmdlet(VerbsCommon.Set, "FormControlText")]
    public class SetFormControlTextCommand : Cmdlet
    {
        [Parameter(Mandatory = true, ValueFromPipeline = false, Position = 1)]
        public string ControlName { get; set; }

        [Parameter(Mandatory = true, ValueFromPipeline = false, Position = 2)]
        public string Text { get; set; }

        [Parameter(Mandatory = true, ValueFromPipeline = true, Position = 0)]
        public Process Process { get; set; }

        protected override void ProcessRecord()
        {
            var _app = new WindowsAppFriend(this.Process);
            dynamic form = _app.Type<Control>().FromHandle(this.Process.MainWindowHandle);
            foreach (var c in form.Controls)
            {
                if (ControlName == (string)c.Name)
                {
                    c.Text = Text;
                }
            }
        }
    }
}
ビルド方法
  1. Windows 8.1 SDK をインストールする。
  2. Visual Studio 2010以降でC#のクラスライブラリを新規作成する。
  3. C:\Program Files (x86)\Reference Assemblies\Microsoft\WindowsPowerShell\3.0 にある.dllを参照設定する。
  4. 対象フレームワークを.NET 4.5、対象プラットフォームをx64にする。
  5. 上記のコードを貼り付ける。
  6. NuGetでFriendlyおよびFriendly.Windowsを追加する。
  7. ビルドする。
使用方法

上記をビルドして生成したDLLにはGet-FormControlTextと、Set-FormControlTextの2つのコマンドレットが含まれます。

# コマンドレットのインポート
Import-Module "dllのフルパス"

# 操作対象のプロセスオブジェクト取得
$p = Get-Process WindowsFormsApplication1 

# プロセスを指定して、すべてのコントロールのテキストを取得
Get-FormControlText -Process $p

# プロセスとコントロール名を指定して、テキストを取得
Get-FormControlText -Process $p -ControlName textBox1

# 位置パラメータなので以下のようにも書ける
Get-FormControlText $p textBox1

# パイプラインからプロセスオブジェクトを入力することもできる
$p | Get-FormControlText -ControlName textBox1 

# プロセスとコントロール名を指定して、テキストを変更する
Set-FormControlText -Process $p -ControlName textBox1 -Text Wankuma
Set-FormControlText $p textBox1 Yokohama
$p | Set-FormControlText -ControlName textBox1 -Text 6
制限事項

フォーム直下に配置されたコントロールしか取得できない(と思います)。

Windows Formアプリケーションにしか対応していない(と思います)。

x64アプリしか操作できない(と思います。x86用はアセンブリを分ける必要がある??)。(←9/9追記)

コントロール名指定はCase sensitiveでワイルドカード不可です。(この辺ただの手抜きですが)

今後の方針?

上のコードをみてもらえればわかると思いますが、ちょっと触ってみたら一応動くものができるくらい、Friendlyは分かりやすいです。皆さんもぜひ使ってみてください。

本来は、テキストじゃなくてコントロールそのものをGetしたりSetしたりInvoke(クリックとか)したりできるようにしたかったんですが、Controlはシリアル化できないオブジェクトなので、Friendlyで実物を持ってきてコマンドレットに出力する、というのは無理でした。何らかのプロキシオブジェクトみたいなのでラップしたりすれば良いかと思います。

それにしてもPowerShellとFriendlyの組み合わせはものすごい可能性を秘めている予感がします。

システム管理方面では…

セッションでもやりましたが、PowerShellコマンドレットベースのアプリケーションを作ると、GUIとCUIのいいところどりが出来る、とはいうものの、コマンドインターフェースがなくGUIオンリーのアプリケーションはまだまだたくさんあるのが現実かと思います。そういうアプリケーションも、PowerShellデフォルトの機能のみではつらいですが、上記のようにFriendlyを併用すれば、GUI操作の自動化が容易になると思います。

開発方面では…

C#でFriendlyを使ったGUIのテストコードを書く以外に、上記のようなFriendlyの機能をラップしたコマンドレットを用意することで、PowerShellスクリプトでもテストコードを書くことができるようになると思います。その際、Pester等のPowerShell用テストフレームワークを併用すると、より一貫したテストコードが記述できるようになるんじゃないかと思います。

いずれは(いつ?)、Friendlyの機能をラップしたコマンドレット群をきちんと設計、実装して、モジュールとして公開したいですね!

2012/12/01

今日から、PowerShell Advent Calendar 2012が始まりました。初日は私が担当させていただきます。お題は旬の話題、PowerShell 3.0の新機能!…ではなく、初心に返って、PowerShellの「関数」ってどう書くのがいいのかというお話をします。PowerShell 3.0どころか、大部分はPowerShell 1.0から変わっていない基本の話です。

これは今までずっと書きたかったネタですがなかなか書く暇がなくて放置してたものです。3.0の話はきっと他の皆さんが書いて下さるはず!私もまた順番が回ってきたら書こうと思います。

PowerShellの関数は従来言語とだいぶ違う

PowerShellを使いこなすようになってくると、他の言語を使う時と同じで、定型処理は関数として一つにまとめたくなってきます。ところが他の言語と同じような感覚で関数を書くと、どうもうまくいかないのです。

たとえば引数にフォルダパスとフォルダ名を指定すると、指定フォルダが存在すればFalseを返し、存在しなければ作成してTrueを返す関数を書いてみました。

function MakeDir($path,$name)
{
    $newDirPath = Join-Path $path $name
    if((Test-Path $newDirPath))
    {
        return $false
    }
    else
    {
        New-Item -ItemType Directory -Path $newDirPath 
        return $true
    }
}

実行は

MakeDir("C:\test","NewFolder")

と、メソッド風に呼び出すことはできないので、コマンドレット風に

MakeDir C:\test NewFolder

と呼び出せばいいんですが(まあ最初はここもつまづきポイントではありますが)、この実行結果は以下のようになります。

    ディレクトリ: C:\test

Mode                LastWriteTime     Length Name 
----                -------------     ------ ----
d----        2012/12/01      7:51            NewFolder
True

フォルダが作成されてTrueが返却されることを想定していたのに、なんか余計な出力が混じってしまっています。なんでしょうこれは?

実はPowerShell関数内で値が出力されると、returnキーワードがついてなくてもすべて呼び出し元に出力されるという仕様なのです。そしてPowerShellにおけるreturnキーワードの効果は「後続処理を打ち切って呼び出し元に戻る。ただしreturnの後に値が指定してあればそれを最後の値として戻す」となります。そのため、呼び出し元に返したくない出力が関数内にある場合は、すべて[void]にキャストしたり|Out-Nullとしてリダイレクトするなどして出力を破棄する必要があるのです。このMakeDir関数の場合はNew-Itemコマンドレットが作成したフォルダのFolderInfoオブジェクトを出力するので、これをNew-Item -ItemType Directory -Path $newDirPath | Out-Null のように破棄してやる必要があるわけです。

パイプラインの動作

先ほどの例を見ると、「いやいやなんでそんな訳のわからない仕様なんだよ、returnあるときだけ値返せよ」とお思いかと思います。しかしこれはPowerShellの特長の一つである、コマンドのパイプラインによる連携を行うための仕様なんです。

ここでコマンドを繋ぐパイプラインがどういう動作をしてるか、おさらいします。

Get-Process | where {$_.Handles -ge 500} | foreach {$_.Path}

これはハンドル数が500以上のプロセスのメインモジュールファイルのパスを取得するというコマンドで、別に何の変哲もありません。ところが、このコマンドがやっている処理を、次のように誤解してませんでしょうか?

@ 稼働中のすべてのプロセスの一覧を配列として取得する。
A @で取得した配列を走査して、Handlesプロパティの値を調べる。Handlesが500以上のオブジェクトだけ抽出した配列を生成する。
B Aで生成した配列を列挙して、{}内のスクリプトをそれぞれ実行する。

しかし、これは間違いです。

正しくは

@ 稼働中の1つのプロセスオブジェクトを取得して次のコマンドへ送る。
A そのプロセスのハンドル数が500以上なら、次のコマンドへ送る。そうでないなら@に戻る。
B そのプロセスに対して{}内のスクリプトを実行する。まだ未取得のプロセスが残っていれば@に戻る。

という動きをしています。つまり、パイプラインの手前で一旦すべての処理を終えてから、出力オブジェクトがまとめて配列という形で次のコマンドに送られるのではなく、オブジェクトがパイプラインの先頭から末尾に向けて1つずつ通過していき、それが先頭コマンドの出力オブジェクト数だけ繰り返される、という動作をしているのです。

これがPowerShellのパイプライン処理が、従来の処理系での関数と決定的に違うところで、パイプラインによって複数のコマンドが、あたかももとからあった単一のコマンドのように密に連携するわけです。

(この処理、.NETのLINQにちょっと似てると思う方もいらっしゃると思います。しかしLINQとは全然違うものです。なんせPowerShellはLINQより先に世に出てますし! しかし類似点も多いのでいずれ比較なんかを書きたいと思ってます)

パイプラインで連携可能な関数の書き方

さて、先ほどのパイプラインの話ではコマンドレットを連携させていました。しかしPowerShellにおいてはコマンドレットも関数も、それが.NETのクラスかPowerShellのスクリプトなのかの違いがあるだけで、基本は同じ「コマンド」です。なので、関数もコマンドレットと同様、適切な記述をおこなえば、パイプラインでコマンド同士を連携させることが可能です。

以下に、Get-Repeatという関数の例を挙げます。この関数は-Textパラメータに文字列を指定し、-Countパラメータに回数を指定すると、指定文字列を指定回数分連結した文字列を出力する、という何の変哲もない関数です。しかしパイプラインからの入力を受け付け、次のパイプラインへ出力することを想定した作りになっています。

function Get-Repeat
{
    param(
        [Parameter(ValueFromPipeline=$true,Mandatory=$true)]
        [string[]]
        $Text,
        
        [int]
        $Count=2
    )

    begin
    {
    }

    process
    {
        foreach($s in $Text)
        {
            $s * $count
        }
    }

    end
    {
    }
}

以下は実行例です。

PS> Get-Repeat -Text ab -Count 2
abab
PS> "ab" | Get-Repeat -Count 2
abab
PS> Get-Repeat -Text ab,cd -Count 2
abab
cdcd
PS> "ab","cd" | Get-Repeat -Count 2
abab
cdcd

このように、パラメータに値を指定してもパイプラインから入力しても、スカラー値(配列ではない単一のオブジェクト)でも配列でも、正しく処理されています。

この関数をポイントごとに見ていきましょう。

PowerShellの正式な関数はparam節、beginブロック、processブロック、endブロックに分かれます。param節にはパラメータを指定します。beginブロックにはパイプラインで連携した際、最初の1回だけ実行される初期化処理、endブロックには最後の1回だけ実行される後始末処理を記述します。beginとendは今回の例では内容を省略しています。processブロックには、パイプラインから入力された1つのオブジェクトに対してその都度実行される処理を記述します。

ちなみに、

コマンド@|コマンドA|コマンドB

とある場合、各コマンドにおけるbegin,process,endブロックは次のような順番で呼び出されます。

コマンド@begin→コマンドAbegin→コマンドBbegin→{コマンド@process→コマンドAprocess→コマンドBprocess→コマンド@process…}→コマンド@end→コマンドAend→コマンドBend

processブロックでの処理は、通常はパイプラインだけではなくパラメータからも値を入力できるようにしておきます。そのためにはparam節に記述するパラメータに「このパラメータはパイプラインから値を入力することもできる」を意味する[Parameter(ValueFromPipeline=$true)]という属性を指定します(この属性はPowerShell 2.0から利用可)。今回のパラメータには「このパラメータは必須である」を意味するMandatory=$trueもあわせて指定しています。

先述の通り、パイプラインから入力される場合は配列ではなくオブジェクトが単体で渡されるのですが、パラメータから入力される場合はスカラー値と配列値、どちらの可能性もあるため、[string[]] のようにパラメータの型を配列型にしておくことで、どちらを指定しても処理できるようにしています。

processブロックではパラメータ経由で配列値が渡された場合に、各要素に対して処理を行うためforeachループを設けています。ちなみにスカラー値が渡された場合もforeachは問題なく処理します。

processブロック内では、returnは記述しません。returnするとその時点で関数が終了してしまうので正しくすべての出力ができなくなってしまいます。

特にこの例の関数のように入力型と出力型が同一の場合は、processブロックでは1オブジェクトの入力に対して、1オブジェクトを出力するようにしておくと、他のコマンドと連携させやすくなります。ただしWhere-Objectコマンドレットのようにフィルタ処理を行う関数の場合は、条件によっては何も出力しないようにします(空の配列とか$nullを返すのではないことに注意)。もちろん入力オブジェクトから何らかの配列値を出力する場合もありえます。

最低限、これらのポイントを押さえて関数を記述すると、他のコマンドとパイプラインで連携しやすい、PowerShellらしい関数を書くことができると思います。

まとめ

PowerShellでは従来言語と同じ感覚で関数を書くと、うまくいかないことが多いです。もっとも単に処理をひとまとめにしたいというニーズだけならばそれでも問題ないのですが、関数同士を組み合わせたいときに問題が顕在化します。

パイプラインの真の動作を理解し、パイプラインの中に組み込んで動作させることを想定した関数を記述すると、他のコマンドレットあるいは自作関数と連携しやすくなり、PowerShellの真の力を解放することができると思います。

PowerShell Advent Calendar 2012の1日目にしてはえらい固いネタかもですが、基本をおさらいするのも大事ですよね。

さて、明日は@jsakamotoさんの番です。よろしくお願いします。



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

Books

Twitter