この記事は、PowerShell Advent Calendar 2015 最終日の記事です。
最近はもっぱら C# を使っており、PowerShell も Cmdlet を書いてたりしてスクリプトあまり書いていません。*1 しかしながら、Cmdlet はただ読み込むならともかく、継続的デプロイを考えるとお世辞にも使いやすいとは言えません。むしろ鬼畜です。
そこで今回はPowerShell Cmdlet をもっと楽に頑張らず使えるようにするお話です。
目次
- 目次
- Cmdlet 概要
- バイナリモジュールの作成
- Cmdlet のクラスライブラリがファイルロックされるタイミング
- ファイルロックを回避してモジュールを読み込ませる
- 事前準備 : モジュールの読み込み方法を工夫する
- 読み込ませるクラスライブラリの実態をデプロイパスから逃がす
- Assembly.Load(byte[]) を使ってバイナリとして読み込む
- まとめ
Cmdlet 概要
ここでいう Cmdlet(コマンドレット) とは、C# で書かれたPoweShell の処理です。普段よくググったりして見につくのは PowerShell で書いた関数です。この Cmdlet と関数についてはマイクロソフト自身表記揺れが見られますが、本ブログでは一貫してこの区分で説明しています。
そして、関数の定義されたファイル(.psm1) を読み込んだモジュールがスクリプトモジュール で、Cmdletの含まれるクラスライブラリ(.dll) を読み込んだモジュールがバイナリモジュールです。この辺の違いは2013年のアドベントカレンダーに書きました。
Cmdlet のメリット
Cmdletで書くメリットは数多くあります。最も強いのは書きやすさでしょう。PowerShell で書くよりも C# や F# で書くことが楽しいなら、Cmdlet はいいアプローチだと思います。
- C# や VB.NET、F# で書けることはそれ自体が大きなメリットです。いずれもPowerShell 関数よりも圧倒に処理を制御しやすく、非同期処理も書けます。
- Nuget によるライブラリの組み込みも容易です。
- バイナリモジュールは、スクリプトモジュール(平文)に比べて圧倒的なほど高速にモジュールが読み込まれます。
- IL化されているため処理自体もスクリプトに比べて圧倒的に早くなることがほとんどです。
- デバッグやテストなど開発シーンでは Visual Studio + C# の恩恵を受けられます。これは PowerShell + Visual Studio よりも多くの面で優位です。
一見良いことだらけですが、スクリプトの方が楽なポイントもあります。
Cmdlet のデメリット
Cmdlet で書くデメリットは、アセンブリ化して発生するものです。つまり、基本的には Cmdlet は関数に比べてメリットが多くあります。
- 普通にCmdletを作っていると app.config が利用できない
- コンパイルされているため、スクリプトとは異なりモジュールを ISE で開いてオンザフライに修正などはできない
- バイナリモジュールを読み込むと、Cmdlet のクラスライブラリ(.dll) がファイルロックされてしまう
1は解決していますが記事にしていません。
2はどうしようもありません。が、そもそも継続的にデプロイする前提ではオンザフライな修正というのは本番環境ということで、必ずしもメリットにならない場合も多いでしょう。
今回は3 に関して解消する手段を考えてみましょう。 一度だけデプロイして以降は利用するだけなら「ファイルロック」はデメリットとなりません。いい例が、マイクロソフトの PowerShell Module 群です。しかし、こと継続的デプロイとなると話は別です。モジュールの利用中に .dll がロックされてしまうと、デプロイが失敗することになり、スムーズな継続的デプロイが実現できません。
バイナリモジュールの作成
今回のために、超簡易版のモジュール TestModule
を作成します。
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つ考えられます。
- バイナリモジュールに含まれる Cmdlet がインテリセンスで候補に上がったタイミング
- バイナリモジュールに含まれる Cmdlet が実行されたタイミング
検証
Import-Module していない状態で、Get-Ho
から Get-Hoge
に自動的にインテリセンス補完されたタイミングでは、TestModule.dll は削除できています。
そしてGet-Hoge
を実行するとファイルロックされました。このことから、Bのタイミングでバックグラウンドで Import-Module <対象モジュール>
が実行され、クラスライブラリがファイルロックされていることがわかります。
ファイルロックを回避してモジュールを読み込ませる
あいにくと Import-Module
に ファイルロックを回避して読み込んでくれる素敵機能はアリマセン。PowerShell からの支援はありません。
そこで考えられるのが、2つの手段です。
- 読み込ませるクラスライブラリの実体をデプロイパスから逃がす
Assembly.Load(byte[])
を使ってバイナリとして読み込む
順に見ていきます。
事前準備 : モジュールの読み込み方法を工夫する
1,2 いずれの手段をとるにしても、Import-Module クラスライブラリ.dll
ではモジュール読み込みの前後を制御できません。
そこで、マニフェスト(.psd1) と スクリプトモジュール(.psm1) を利用します。
読み込むモジュールのパスに、マニフェストモジュール(.psd1)、スクリプトモジュール(.psm1)、バイナリモジュール (.dll) が同時に存在した時の読み込み優先順位は次の通りになります。
- マニフェストモジュール(.psd1)
- スクリプトモジュール(.psm1)
- バイナリモジュール (.dll)
つまり、マニフェストモジュール(.psd1) でスクリプトモジュール(.psm1)を読み込むようにして、クラスライブラリ (.dll)をファイルロックしないようにモジュールとして読み込めばいいのです。簡単ですね。
.psd1 の生成
今回必要となる マニフェストTestModule.psd1
は、ビルド前イベントと連動して build.ps1 を実行することで生成してみましょう。
うまくビルドできると、マニフェストファイル TestModule.psd1
が生成されます。
.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
モジュール一式を$env:PSModulePath に含まれる %UserProfile%\Documents\WindowsPowerShell\Modules\TestModule にデプロイしました。さて動作をみてみましょう。
モジュール読み込み後も$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'.
以降の流れを追ってみましょう。
- モジュールが配置された状態で
Import-Module TestModule
を実行(Get-Hoge の実行でも一緒です) - まず
TestModule.psd1
が読み込まれる - 続いてRootModule に指定した
TestModule.psm1
が読み込まれる - あとは、
TestModule.psm1
で書いた通りに dll など一式をキャッシュパスにコピーして - コピー先のクラスライブラリ
TestModule.dll
を直接Import-Module
する - クラスライブラリを
Import-Module
したことで、TestModule.psm1
のモジュール空間にはGet-Hoge
Cmdlet が含まれるので、それを含めてTestModule.psm1
はExport-ModuleMember
を実行 - 最後に
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これを利用すれば、クラスライブラリをファイルロックせずにモジュール読み込みができます。
実装は次のサイトが参考になります。
あとはこの処理を PowerShell で実装して、Assembly.LoadFrom()
を Assembly.Load()
にするだけだです。具体的には次のコードとなります。
動作をみてみると問題なくモジュールが動作しますね。また、モジュールを読み込んでも、TestModule.dll
はファイルロックされていません。
まとめ
Cmdlet の最大の問題である、Import-Module
によるクラスライブラリのファイルロック問題をなんとかしてみました。本当は、app.config を使う方法や、スクリプトモジュール同様の .ps1 をコンフィグレーションファイルに利用する方法も書こうと思ったのですが、まずはここまでで。
なお、まだ諦めてない模様 (むしろ PoweRShell 5.0 で書こうと画策している。