tech.guitarrapc.cóm

Technical updates

PowerShell Cmdlet を人道的に使いたいから頑張ってみるお話

この記事は、PowerShell Advent Calendar 2015 最終日の記事です。

https://atnd.org/events/72226atnd.org

最近はもっぱら C# を使っており、PowerShell も Cmdlet を書いてたりしてスクリプトあまり書いていません。*1 しかしながら、Cmdlet はただ読み込むならともかく、継続的デプロイを考えるとお世辞にも使いやすいとは言えません。むしろ鬼畜です。

そこで今回はPowerShell Cmdlet をもっと楽に頑張らず使えるようにするお話です。

目次

Cmdlet 概要

ここでいう Cmdlet(コマンドレット) とは、C# で書かれたPoweShell の処理です。普段よくググったりして見につくのは PowerShell で書いた関数です。この Cmdlet と関数についてはマイクロソフト自身表記揺れが見られますが、本ブログでは一貫してこの区分で説明しています。

そして、関数の定義されたファイル(.psm1) を読み込んだモジュールがスクリプトモジュール で、Cmdletの含まれるクラスライブラリ(.dll) を読み込んだモジュールがバイナリモジュールです。この辺の違いは2013年のアドベントカレンダーに書きました。

tech.guitarrapc.com

Cmdlet のメリット

Cmdletで書くメリットは数多くあります。最も強いのは書きやすさでしょう。PowerShell で書くよりも C# や F# で書くことが楽しいなら、Cmdlet はいいアプローチだと思います。

  1. C# や VB.NET、F# で書けることはそれ自体が大きなメリットです。いずれもPowerShell 関数よりも圧倒に処理を制御しやすく、非同期処理も書けます
  2. Nuget によるライブラリの組み込みも容易です
  3. バイナリモジュールは、スクリプトモジュール(平文)に比べて圧倒的なほど高速にモジュールが読み込まれます
  4. IL化されているため処理自体もスクリプトに比べて圧倒的に早くなることがほとんどです
  5. デバッグやテストなど開発シーンでは Visual Studio + C# の恩恵を受けられます。これは PowerShell + Visual Studio よりも多くの面で優位です

一見良いことだらけですが、スクリプトの方が楽なポイントもあります。

Cmdlet のデメリット

Cmdlet で書くデメリットは、アセンブリ化して発生するものです。つまり、基本的には Cmdlet は関数に比べてメリットが多くあります。

  1. 普通にCmdletを作っていると app.config が利用できない
  2. コンパイルされているため、スクリプトとは異なりモジュールを ISE で開いてオンザフライに修正などはできない
  3. バイナリモジュールを読み込むと、Cmdlet のクラスライブラリ(.dll) がファイルロックされてしまう

1は解決していますが記事にしていません。

2はどうしようもありません。が、そもそも継続的にデプロイする前提ではオンザフライな修正というのは本番環境ということで、必ずしもメリットにならない場合も多いでしょう。

今回は3 に関して解消する手段を考えてみましょう。 一度だけデプロイして以降は利用するだけなら「ファイルロック」はデメリットとなりません。いい例が、マイクロソフトの PowerShell Module 群です。しかし、こと継続的デプロイとなると話は別です。モジュールの利用中に .dll がロックされてしまうと、デプロイが失敗することになり、スムーズな継続的デプロイが実現できません。

バイナリモジュールの作成

今回のために、超簡易版のモジュール TestModuleを作成します。

gist.github.com

Cmdlet のクラスライブラリがファイルロックされるタイミング

PowerShell には、PowerShell module autoload という仕組みがあり、Import-Module <対象モジュール> を事前に実行せずとも $env:PSModulePath に配置されたモジュールを読み込んでくれます。これにより、インテリセンスや Get-Command で Function名/Cmdlet名が自動的に補完され、よりインタラクティブにモジュールが利用できるようになっています。

https://technet.microsoft.com/en-us/library/dd878284(v=vs.85).aspx

では、PowerShell module autoloadで クラスライブラリがファイルロックされるタイミングはいつでしょうか?Import-Module でモジュールを明示的読み込んだタイミングを除くと2つ考えられます。

  1. バイナリモジュールに含まれる Cmdlet がインテリセンスで候補に上がったタイミング
  2. バイナリモジュールに含まれる Cmdlet が実行されたタイミング
検証

Import-Module していない状態で、Get-Ho から Get-Hoge に自動的にインテリセンス補完されたタイミングでは、TestModule.dll は削除できています。

そしてGet-Hoge を実行するとファイルロックされました。このことから、Bのタイミングでバックグラウンドで Import-Module <対象モジュール> が実行され、クラスライブラリがファイルロックされていることがわかります。

ファイルロックを回避してモジュールを読み込ませる

あいにくと Import-Module に ファイルロックを回避して読み込んでくれる素敵機能はアリマセン。PowerShell からの支援はありません。

そこで考えられるのが、2つの手段です。

  1. 読み込ませるクラスライブラリの実体をデプロイパスから逃がす
  2. Assembly.Load(byte[]) を使ってバイナリとして読み込む

順に見ていきます。

事前準備 : モジュールの読み込み方法を工夫する

1,2 いずれの手段をとるにしても、Import-Module クラスライブラリ.dll ではモジュール読み込みの前後を制御できません。

そこで、マニフェスト(.psd1) と スクリプトモジュール(.psm1) を利用します。

読み込むモジュールのパスに、マニフェストモジュール(.psd1)、スクリプトモジュール(.psm1)、バイナリモジュール (.dll) が同時に存在した時の読み込み優先順位は次の通りになります。

  1. マニフェストモジュール(.psd1)
  2. スクリプトモジュール(.psm1)
  3. バイナリモジュール (.dll)

つまり、マニフェストモジュール(.psd1) でスクリプトモジュール(.psm1)を読み込むようにして、クラスライブラリ (.dll)をファイルロックしないようにモジュールとして読み込めばいいのです。簡単ですね。

.psd1 の生成

今回必要となる マニフェストTestModule.psd1 は、ビルド前イベントと連動して build.ps1 を実行することで生成してみましょう。

gist.github.com

うまくビルドできると、マニフェストファイル TestModule.psd1 が生成されます。

gist.github.com

.psd1のポイント

通常の バイナリモジュールでは、RootModule にクラスライブラリを指定しますが、今回はインポート処理自体をスクリプトモジュールでフックします。そのため、このマニフェストファイルでは、RootModule を TestModule.psm1 としています。

また、マニフェストファイルを利用した PowerShell module autoload への Cmdlet名のヒントとして、実際にユーザーが利用できる Cmdlet名を .psd1 にて明示するのが大事です。指定は CmdletToExport に配列でCmdlet名を入れましょう。これを忘れると、PowerShell module autoloadがクラスライブラリに含まれる Cmdlet を読み込めず、初回のモジュール読み込みでタブ補完が効きません。なお仕様として公開されていませんが、初回にモジュールからCmdletの一覧を読み込むと PoweShell はCmdlet一覧をキャッシュします。2回目以降は、例えクラスライブラリからCmdlet が読めなくてもこのキャッシュをタブ補完に利用するため、一見 Cmdlet が読めているように錯覚してしまいます。

では、.psm1 でクラスライブラリをロックしないように読み込む処理を書いてみましょう。

読み込ませるクラスライブラリの実態をデプロイパスから逃がす

あいにくとクラスライブラリのため、ShadowCopy は利用できません。%temp% パスに、 Import-Mopdule されるクラスライブラリをコピーするならこんな感じでしょうか。*2

gist.github.com

モジュール一式を$env:PSModulePath に含まれる %UserProfile%\Documents\WindowsPowerShell\Modules\TestModule にデプロイしました。さて動作をみてみましょう。

https://cloud.githubusercontent.com/assets/3856350/12003573/055e8954-ab68-11e5-8f53-f74baca1bd61.gif

モジュール読み込み後も$env:PSModulePath に配置したデプロイ対象のクラスライブラリは消せますね。これなら継続的デプロイに支障をきたしません。

モジュールが読み込まれる流れを説明します。実際のところ、モジュール読み込み時のログの通りです。*3

PS> Import-Module TestModule -Force -Verbose;
VERBOSE: Loading module from path 'C:\Users\UserName\Documents\WindowsPowerShell\Modules\TestModule\TestModule.psd1'.
VERBOSE: Loading module from path 'C:\Users\UserName\Documents\WindowsPowerShell\Modules\TestModule\TestModule.psm1'.
VERBOSE: Importing cmdlet 'Get-Hoge'.

以降の流れを追ってみましょう。

  1. モジュールが配置された状態でImport-Module TestModule を実行(Get-Hoge の実行でも一緒です)
  2. まず TestModule.psd1 が読み込まれる
  3. 続いてRootModule に指定した TestModule.psm1 が読み込まれる
  4. あとは、TestModule.psm1 で書いた通りに dll など一式をキャッシュパスにコピーして
  5. コピー先のクラスライブラリ TestModule.dll を直接 Import-Module する
  6. クラスライブラリをImport-Moduleしたことで、TestModule.psm1 のモジュール空間には Get-Hoge Cmdlet が含まれるので、それを含めて TestModule.psm1Export-ModuleMember を実行
  7. 最後に TestModule.psd1 がCmdlet を現在のスコープにインポートしている
課題

この方法には、毎回の Import-Module でクラスライブラリが %temp% にコピーされるという問題があります。

例えば、Remove-Module イベントと連動するModule.OnRemove にキャッシュを消すスクリプトブロックを仕込んで置くということも考えられますが、PowerShell ホストを直接 x 終了したらこの処理はスキップされてしまいます。

$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove

そこでゴミも出さずにきれいに読み込むことを目的に、Assembly.Load(byte[]) を利用してみましょう。

Assembly.Load(byte[]) を使ってバイナリとして読み込む

知られてませんが、PowerShell のクラスライブラリは、別に Import-Module でパスを指定せずとも、Assembly.LoadFrom() で取得したアセンブリを渡してもモジュールが読み込まれます。*4これを利用すれば、クラスライブラリをファイルロックせずにモジュール読み込みができます。

実装は次のサイトが参考になります。

Creating instance of a type outside the current AppDomain

あとはこの処理を PowerShell で実装して、Assembly.LoadFrom()Assembly.Load() にするだけだです。具体的には次のコードとなります。

gist.github.com

動作をみてみると問題なくモジュールが動作しますね。また、モジュールを読み込んでも、TestModule.dll はファイルロックされていません。

https://cloud.githubusercontent.com/assets/3856350/12003631/bdd7d952-ab6a-11e5-97ee-e5f61be6a094.gif

まとめ

Cmdlet の最大の問題である、Import-Module によるクラスライブラリのファイルロック問題をなんとかしてみました。本当は、app.config を使う方法や、スクリプトモジュール同様の .ps1 をコンフィグレーションファイルに利用する方法も書こうと思ったのですが、まずはここまでで。

なお、まだ諦めてない模様 (むしろ PoweRShell 5.0 で書こうと画策している。

www.adventar.org

*1:むしろ LinqPad の方が使ってます

*2:nuget の利用などを考慮すると面倒なのでまとめてコピーしている

*3:クラスライブラリからのモジュール読み込み部分は、Loading として表示されていません

*4:この時、クラスライブラリは通常バイナリモジュールとして読み込まれるところが、ダイナミックモジュールとして読み込まれます。