tech.guitarrapc.cóm

Technical updates

PowerShellコマンドレットを人道的に使いたいから頑張ってみるお話

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

https://atnd.org/events/72226

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

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

コマンドレット概要

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

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

https://tech.guitarrapc.com/entry/2013/12/03/014013

コマンドレットのメリット

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

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

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

コマンドレットのデメリット

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

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

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

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

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

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

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

https://gist.github.com/guitarrapc/fdf9ad919bef1960e264

コマンドレットのクラスライブラリがファイルロックされるタイミング

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

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

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

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

検証

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

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

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

あいにくと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を実行することで生成してみましょう。

https://gist.github.com/guitarrapc/b00f1a3c56bb99bf5487

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

https://gist.github.com/guitarrapc/2905ac3667d49a21bf14

.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

https://gist.github.com/guitarrapc/3690876dbfe9c1e3c2b2

モジュール一式を$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 コマンドレット'Get-Hoge'.

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

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

課題

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

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

$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove

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

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

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

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

https://theraneman.blogspot.jp/2010/04/creating-instance-of-type-outside.html

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

https://gist.github.com/guitarrapc/72bffd1e1c12bec100f9

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

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

まとめ

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

なお、まだ諦めてない模様。

https://www.adventar.org/calendars/579

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

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

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

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