3 年前から続くこのシリーズもようやく中間報告ができる段階まで来ました。
構想も含めると 5 年以上・・・最初はどうにかマネージコードの世界だけで収めようと、式木こねくりまわして、AppDomain の Load/Unload 繰り返してたのですが、パフォーマンスの壁にぶち当たり、C++ とアンマネージ API の世界へ降りて行ったことを覚えています。しかし、今見返すと相当高い要件出してますね、自分・・・。これは中間報告と言いつつ、実はまだ第 1 四半期報告ぐらいなものなのかもしれません ((((;゚Д゚))))
ですが、Stack Overflow で昨年の中ごろまで編集が続いていたこのスレッドでも、結局は OSS で、私が作っているようなものが出てくることはありませんでしたので、現状でも少しは価値を提供できるかなと思い、見えるところに置いた次第。「世界初!」みたいなのは自分だけで思っていても痛い人ですし、もし万が一仮に本当にそうだった場合、私一人が知っているという状況が怖くなっただけ、とも言えます。「C# 動的メソッド入れ替え」で、(^q^) キョウユウ♪キョウユウ♪
まあ実際、私の観測する範囲では、「.NET Profilers aren’t scary(怖くない .NET プロファイラ)」っていう怖い資料を公開されている方や、「uMock」っていう Microsoft Moles/Fakes の OSS 代替実装になるべく進められている実証プロジェクトもあるようですので、遅かれ早かれ似たようなものは出てくると思います。
また、Premium エディション以上とは言え、Fakes が Visual Studio に標準搭載されたことも大きいでしょう。Microsoft 自身が、CLR 上の静的言語の世界に対し、冒頭にあるような問いかけをしているようなものですから(標準搭載したことについては、やはり否定的な意見が見られますね。中の人も必要悪だと言っているようですし、私もリリース時にはなるべく誤解のないようにしたいとは思います)。
さて、今回の記事では現状できる簡単な使い方の紹介と、気になるであろうその仕組みについて、短めに解説させていただこうと思います。それでは、どうぞ!
※現状、.NET 3.5 + x86 の組み合わせと、IL 全体の 3 割強しか対応ができていませんので、それを念頭に置いていただき、温かく見守っていただければ幸いです (`・ω・́)ゝ
以下の記事、ライブラリを使用/参考にさせていただいています。この場を借りて、改めてお礼させていただきます m(_ _)m
.net - What C# mocking framework to use? - Stack Overflow
.NET Profilers and IL Rewriting - DDD Melbourne 2
myarichuk-uMock
VS11 Fakes Framework Considered Harmful
VS11 Fakes Framework Harmful (Part Two)
Visual Studio Fakes Part 2 - Shims - Peter Provost's Geek Noise
c# - How Moles Isolation framework is implemented? - Stack Overflow
目次
Prig とは
今までもこの Blog で何度か単語としては登場していますが、このライブラリ、名前を Prig(PRototyping jIG) と言い、不完全ではありますが、ぼちぼち動き始めています。
はじめは公開リポジトリでプロトタイピングなどもしていたのですが、設計の失敗や基盤の再構成など、あまりにもイチから作り直すことが多かったため、途中からずっと GitHub のプライベートリポジトリに籠って開発をしていました。さすがに、このところだいぶ落ち着いてきたため、また本流にマージし公開リポジトリでの開発に戻ってきた次第です:
urasandesu/Prig - GitHub
もし、.NET プロセスの JIT をフックして、メソッドの中身を入れ替えるライブラリのソースコードを参考にしてみたい!という奇特な方がいらっしゃるのであれば、クローンしていただき、中身をご確認いただければと思います。
ちなみに、"Prig" を辞書で引くと、スリとかコソ泥とか自惚れ屋とか、あまり良い印象の単語ではないのですが、おこがましくも "Git"(日本語訳:ばか、間抜け)にあやかり、将来的に .NET 開発基盤の 1 つとして使われるようになればなー、みたいな願いも込めてたりします。まあ、私が作るライブラリ群自体、どれも名前の付け方にセンスの無さというか悪趣味さが漂っていますががが (^_^;)
さて、README.md にもあるのですが、どのような感じで使うのかを見てみましょう。
よくある例で恐縮ですが、以下のようなコードをテストしたいとします:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
namespace program1.MyLibrary | |
{ | |
public static class LifeInfo | |
{ | |
public static bool IsLunchBreak() | |
{ | |
var now = DateTime.Now; | |
return 12 <= now.Hour && now.Hour < 13; | |
} | |
} | |
} |
しかしながら、相手は、static なプロパティであり、かつ mscorlib という Microsoft が提供する外部ライブラリにあるものですので、そちらに手を入れることは不可能です。このようなテストをしにくい API を使う際は、それを吸収する層を設け、モックに入れ替え可能にしたり、対象の情報を引数などから取るようにしたりする等、プロダクトコードを設計・実装する時点で、工夫をする必要がありました・・・そう、プロダクトコードを設計・実装する時点というのがミソですね。もし、このままのコードがリリースされた後、ここに新しい機能が追加されるなどしてだんだんコードが複雑になっていった時に、さてそろそろリファクタリングしないと・・・となってからでは大変な労力が必要になってしまうでしょう。
このような場面で、Prig を使うと、プロダクトコードに全く手を加えずに、対象の API をモックに入れ替えることができるようになります。どのような感じになるかを紹介したいと思います。
まず、以下のようなスタブを、「入れ替えたいメソッドを含む Assembly の dll 名」 + 「.Prig.dll」という名前で作成します(例えば、DateTime.Now は mscorlib.dll にありますので、ここで作成するスタブは mscorlib.Prig.dll に作成するようにします):
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Urasandesu.Prig.Framework; | |
// IndirectableAttribute を使い、スタブに入れ替えたい Assembly にメタデータトークンを付与します。 | |
// このメタデータトークンですが、問題になっている DateTime.Now の getter メソッドは get_Now という | |
// 名前の静的メソッドですので、そのトークンである 0x060002D2 を指定します。 | |
// ※メタデータトークンは、対象の dll を ildasm 等で逆コンパイルしたり、MethodInfo.MetadataToken を | |
// 参照することによって確認することができます。 | |
[assembly: Indirectable(0x060002D2)] | |
namespace System.Prig | |
{ | |
public class PDateTime | |
{ | |
public static class NowGet | |
{ | |
// 対象のメソッドと同じシグネチャを持つデリゲートを選択します。 | |
// デリゲートは、IndirectionDelegateAttribute 属性が付与されたものを選択することができます。 | |
public static IndirectionFunc<DateTime> Body | |
{ | |
set | |
{ | |
// 以下で構成されるキーにデリゲートを登録します: | |
// - 対象のメソッドを含む Assembly の完全表示名。 | |
// - 対象のメソッドのメタデータトークン。 | |
var info = new IndirectionInfo(); | |
info.AssemblyName = "mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"; | |
info.Token = 0x060002D2; | |
var holder = LooseCrossDomainAccessor.GetOrRegister<IndirectionHolder<IndirectionFunc<DateTime>>>(); | |
holder.AddOrUpdate(info, value); | |
} | |
} | |
} | |
} | |
} | |
テストコードでは、このスタブにダミー情報を返すモックを設定することで、テストが可能になるのです!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using NUnit.Framework; | |
using program1.MyLibrary; | |
using System; | |
using System.Prig; | |
using Urasandesu.Prig.Framework; | |
namespace Test.program1.MyLibraryTest | |
{ | |
[TestFixture] | |
public class LifeInfoTest | |
{ | |
[Test] | |
public void IsLunchBreak_ShouldReturnTrue_When12OClock() | |
{ | |
using (new IndirectionsContext()) | |
{ | |
// Arrange | |
PDateTime.NowGet.Body = () => new DateTime(2013, 12, 13, 12, 00, 00); | |
// Act | |
var result = LifeInfo.IsLunchBreak(); | |
// Assert | |
Assert.IsTrue(result); | |
} | |
} | |
[Test] | |
public void IsLunchBreak_ShouldReturnFalse_When13OClock() | |
{ | |
using (new IndirectionsContext()) | |
{ | |
// Arrange | |
PDateTime.NowGet.Body = () => new DateTime(2013, 12, 13, 13, 00, 00); | |
// Act | |
var result = LifeInfo.IsLunchBreak(); | |
// Assert | |
Assert.IsFalse(result); | |
} | |
} | |
} | |
} |
さあ、テストができればしめたもの。忘れずにプロダクトコードのリファクタリングを行い、Prig のようなものを今後使わなくても良いように、うまい感じの構造に変更しておきましょう!
Prig の仕組み
アンマネージ API を使った処理の入れ替えは以前解説させていただいた通りですので、最後の部分となる、対象のメソッドへどのようにして迂廻路を埋め込んでいるかを見てみたいと思います・・・と言っても、これまでにも紹介させていただいたスレッド c# - How Moles Isolation framework is implemented? - Stack Overflow ほぼそのままなのですががが (^_^;)
手順としては以下のような感じになります:
1. モジュールロード時:スタブ Assembly の存在確認
2. JIT 前:迂廻路の埋め込み対象かのチェック
3. JIT 前:迂廻路の埋め込み
4. 実行時:スタブ設定有無の確認
では、順に見ていきましょう。肝な部分はソースコードも合わせて解説したいと思います。なお、メインの処理は、Weaver.cpp に集まっています。より詳細な動きを知りたい場合は、ここを起点として辿ると良いでしょう。
1. モジュールロード時:スタブ Assembly の存在確認
ModuleLoadFinished メソッドで通知されるモジュールロード完了のイベントで、スタブ Assembly の存在確認を行います。このメソッドは、アンマネージ プロファイリング API のインターフェースである ICorProfilerCallback2 に定義されたもので、第一引数に渡されてくる ModuleID から、そのモジュールのざっくりとした情報を取得することができます。
ここでは後述の通り、あまり詳細な情報を得ることはできませんので、モジュール名からスタブ Assembly が同一ディレクトリにあるかどうかだけをチェックします。
2. JIT 前:迂廻路の埋め込み対象かのチェック
JIT の開始を知らせる JITCompilationStarted メソッドで、現在のメソッドが迂廻路埋め込み対象化どうかをチェックします。あ。その前に、先ほどのモジュールロード時のチェックを通っているかの確認ですね。
ちなみに、私はネストが深くなるのが嫌なので、条件に合わなかったらすぐ return する派です。合わない人はごめんなさいね ★(ゝω・)
モジュールロード時のチェックを通っているということは、スタブ Assembly が存在するということですので、付与されている IndirectableAttribute を列挙し、そこに指定されたメタデータトークンをキャッシュしておきます。
気を付けなければならないことが 1 点。アンマネージ プロファイリング API は、開発者が「CoInitialize を呼び出さないようにする必要があります」と決められており、CoInitialize の呼び出しタイミングは CLR に委ねられています。つまり、これを呼び出さないと使えない API は、いつ呼べるようになるのかがわかりません。最初の if 文の条件の 1 つに指定されている pDisp->IsCOMMetaDataDispenserPrepared() はそんな状況に対応するための苦肉の策だったりします。
また、この苦肉の策を行ったとしても、私が試した限り、ModuleLoadFinished メソッドのタイミングで CoInitialize が行われていることはありませんでした。本当はキャッシュ制御みたいなことはせずに、ModuleLoadFinished メソッドで同時にできると良かったのですが・・・仕方無いね (´・ω・`)
【2014/03/21 追記】この制限は、.NET 2.0 までのものでした。。。.NET 4 以降は、.NET Framework 4 および 4.5 で追加された CLR ホスト インターフェイスにある通り、「CoCreateInstance 関数を使用するアパートメント モデル、集約、およびレジストリのアクティブ化はいずれも存在しません。」ので、特にチェックすることなく処理が進められるはずです。私のライブラリも最終的にはそうなる見込みです ☆(ゝω・)【2014/03/21 追記】
メタデータトークンの列挙まで終わっていれば、あとは JIT が始まった現在のメソッドのメタデータトークンが、その中にあるかどうかをチェックするだけです。
3. JIT 前:迂廻路の埋め込み
迂廻路の埋め込み処理です。
ローカル変数は、既存の順番が変わると大変面倒なことになりますので、初めにコピーします(pBody->GetLocals() で回しているところですね)。その後、迂廻路となる部分の IL ストリームとローカル変数をどばっと埋め込み(EmitIndirectMethodBody)、続いて元の IL ストリームを流し込みます(pBody->GetInstructions() で回しているところです)。あとは、迂廻路分のオフセットに気を付けながら、例外ハンドラを再設定するようにします(pBody->GetExceptionClauses() で回しています)。EmitIndirectMethodBody の中身は、スタブでやっていたことの逆を IL でガリガリ書いているだけですので特に解説はしません。
4. 実行時:スタブ設定有無の確認
DateTime.Now (.NET 3.5 まで)は、ildasm や ILSpy で確認すると、以下のような処理になっていますが:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace System | |
{ | |
public struct DateTime | |
{ | |
public static DateTime Now | |
{ | |
get | |
{ | |
return DateTime.UtcNow.ToLocalTime(); | |
} | |
} | |
} | |
} |
Prig を通すとこうなります:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace System | |
{ | |
public struct DateTime | |
{ | |
public static DateTime Now | |
{ | |
get | |
{ | |
// スタブでやっていたことの逆が挿入される。 | |
var holder = default(IndirectionHolder<IndirectionFunc<DateTime>>); | |
if (LooseCrossDomainAccessor.TryGet(out holder)) | |
{ | |
var info = new IndirectionInfo(); | |
info.AssemblyName = "mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"; | |
info.Token = 0x060002D2; | |
var get_Now = default(IndirectionFunc<DateTime>); | |
if (holder.TryGet(info, out get_Now)) | |
{ | |
return get_Now(); | |
} | |
} | |
// 元の処理がそのままコピーされる。 | |
return DateTime.UtcNow.ToLocalTime(); | |
} | |
} | |
} | |
} |
Moles の仕組みをほぼ踏襲した形になっていると思いますが、これにより、スタブから設定したデリゲートが存在すれば、それを使ってダミー情報を返し、存在しなければ元の処理を実行する、ということができるようになります。
ちなみに、LooseCrossDomainAccessor は、こちらで解説させていただいた、「同一プロセス内であれば AppDomain の仮想的な境界をそげぶできるアレ」ですね。mscorlib のようなドメイン中立として読み込まれる Assembly は、テストコード側から見ると AppDomain を 1 つ跨ぐ形になりますので、この仕組みが無いと、入れ替えできる処理に大きな足かせができてしまうのです。
次はリリース!?
はい!年初に立てた目標通り、何とか残りの IL と .NET バージョン、プラットフォームとサポートし、2014 年中のリリースに持っていきたい・・・といっても、最初に書いた要件を考えるとそれでもまだ半分ですね。先は長い・・・ ...( = =)
ですが、やっと一つの区切りが見えてきたところではあります。引き続き .NET の低レイヤーな技術を追い、また折を見て情報共有させていただければ思いますので、今後ともよろしくお願いしますね!