C#での動的なメソッド選択における定形高速化パターン

動的なメソッド選択、といってもなんのこっちゃというわけですが、身近な例だと、ようするにURLをルーティングして何のコントローラーの何のメソッドを呼ぶのか決めるって奴です、ASP.NET MVCとかの。ようするにLightNodeはいかにして速度を叩きだしているのか、のお話。自慢。嘘本当。

以前にExpression Treeのこね方・入門編 - 動的にデリゲートを生成してリフレクションを高速化という記事を書いたのですが(2011/04ですって!もうすっかり大昔!)、その実践編です。Real World メタプログラミング。

とあるController/Action

とあるControllerのとあるActionを呼び出したいとするじゃろ?あ、ちなみに別に例として手元にあるのがちょーどこれだったというだけで、別に同様なシチュエーション(動的にメソッドを選択する)では、うぇぶに限らず何にでも応用効きます。というわけでウェブ興味ね、という人も帰らないで!!!このサイトはC#のサイトですから!

// 何の変哲もない何か
public class HogeController
{
    public string HugaAction(int x, int y)
    {
        return (x + y).ToString();
    }
}
// コード上で静的に解決できるならこう書くに決まってるじゃろ?
var result = new HogeController().HugaAction(1, 10);

ただたんにnewして実行するならこうなります。が、これが動的にControllerやActionを選ばなきゃいけないシチュエーションではどうなりますん?ルーティング処理が済んで、呼び出すクラス名・メソッド名が確定できたというところから行きましょう。

// コントローラー名・アクション名が文字列で確定出来た場合
var controllerName = "ConsoleApplication.HogeController";
var actionName = "HugaAction";

var instance = Activator.CreateInstance(Type.GetType(controllerName));
var result = (string)Type.GetType(controllerName).GetMethod(actionName).Invoke(instance, new object[] { 1, 10 });

一番単純なやり方はこんなものでしょう。Activator.CreateInstanceでインスタンスを生成し、MethodInfoのInvokeを呼ぶことでメソッドを実行する。基本はこれ。何事も素直が一番良いですよ。おしまい。

動的コード生成事始め

あけましておめでとうございます。素直が一番、おしまい、で済まない昨今、リフレクションは実行速度がー、という亡霊の声が聞こえてくるのでshoganaiから対処しましょう。基本的にC#でリフレクションの速度を高める手段は一択です、デリゲート作ってキャッシュする。というわけでデリゲート作りましょう。

// ここから先のコードでは↓4つの変数は省略します
var controllerName = "ConsoleApplication.HogeController";
var actionName = "HugaAction";
var type = Type.GetType(controllerName);
var method = type.GetMethod(actionName);
            
// インスタンスが固定されちゃう
var instance = Activator.CreateInstance(type);
var methodDelegate = (Func<int, int, string>)Delegate.CreateDelegate(typeof(Func<int, int, string>), instance, method);

Delegate作ると言ったらDelegate.CreateDelegate。なのですが、静的メソッドならいいんですが、インスタンスメソッドだと、それも込み込みで作られちゃうので些かイケてない。今回は毎回インスタンスもnewしたいので、これはダメ。

が、Delegate.CreateDelegateの面白いところは、「オープンなインスタンス メソッド デリゲート (インスタンス メソッドの隠れた第 1 引数を明示的に指定するデリゲート) を作成することもできます」ことです。どういうことか、というと

// 第一引数にインスタンスの型が渡せる
var methodDelegate = (Func<HogeController, int, int, string>)Delegate.CreateDelegate(
    typeof(Func<HogeController, int, int, string>), method);

// だからこう呼べる(キャストしてたりしてActivator.CreateInstanceの意味がほげもげ)
var result = methodDelegate((HogeController)Activator.CreateInstance(type), 10, 20);

あら素敵。素敵ではあるのですが、面白いだけで今回では使い道はなさそうです、Activator.CreateInstance消せてないし、HogeControllerにキャストって、ほげほげですよ。

というわけで、ちょっと込み入った生成をしたい場合はExpressionTreeの出番です。new生成まで内包したものを、以下のように捏ね捏ねしましょう。

// インスタンスへのnewを行う部分まで生成する、つまり以下の様なラムダを作る
// (x, y) => new HogeController().HugaAction(x, y)
var x = Expression.Parameter(typeof(int), "x");
var y = Expression.Parameter(typeof(int), "y");
var lambda = Expression.Lambda<Func<int, int, string>>(
    Expression.Call( // .HugaAction(x, y)
        Expression.New(type), // new HogeController()
        method,
        x, y),
    x, y) // (x, y) => 
    .Compile();

ExpressionTreeの文法とかは3年前だか4年前だかのExpression Treeのこね方・入門編 - 動的にデリゲートを生成してリフレクションを高速化を参照してください、というわけでここでは解説はスルー。ExpressionTreeの登場はVS2008, .NET 3.5, C# 3.0から。もう5年以上前なのですねー。

さて、Compileは非常に重たい処理なので(カジュアルに呼びまくるようなコード例をたまに見ますが、マヂヤヴァイのでやめましょう)、作ったらキャッシュします。

// Compile済みのラムダ式はキャッシュするのが基本!
// 以下の様なものに詰めときゃあいいでしょう
// .NET 4以降はキャッシュ系はConcurrentDictionaryのGetOrAddに入れるだけで済んで超楽
var cache = new ConcurrentDictionary<Tuple<string, string>, Func<int, int, string>>();

ConcurrentDictionaryのKeyは場合によりけり。Typeの場合もあればStringの場合もあるし、今回のようなCotrollerName/ActionNameのペアの場合はTupleを使うと楽ちんです。Tupleは辞書のキーなどに使った場合、全値の一致を見てくれるので、適当に文字列連結して代用、なんかよりも正確で好ましい結果をもたらしてくれます。簡易的に作る場合は、私も、とても多用しています。

大文字小文字比較とかの比較まではやってくれないので、そこまでやりたければ、ちゃんとGetHashCode/Equalsを実装したクラスを作りましょう(LightNodeではそれらを実装したRequestPathというクラスを作っています)

そういえばPCLはWindows Phoneを対象に含めるとConcurrentDictionary使えなくてイラ壁なのでWindows Phoneは見なかったことにしてPCLの対象に含めないのが最善だと思います!どうせ端末出てないし!

: Delegate

ところでFunc<int, int, string>という型をLambdaに指定しているけれど、これもまた決め打ちで、動的じゃあない。んで、そもそもこの手の作る場合、メソッドの型って色々あるのね。

// メソッドの型は色々ある!
public class HogeController
{
    public string HugaAction(int x, int y)
    {
        return (x + y).ToString();
    }

    public double TakoAction(string s, float f)
    {
        return double.Parse(s) * f;
    }
}

こんな風にActionが増えたら、というか普通は増えるというか違うに決まってるだろという話なわけで、問題色々でてきちゃいます。 まずデリゲートの型が違うのでキャッシュ不可能。Func<int, int, string>のConcurrentDictionaryにFunc<string, float, double>は入りません。そして"HugaAction"や"TakoAction"という動的に来る文字列から、コンパイル時にデリゲートの型は決められない。ていうかそもそもパラメータのほうもxだのyだのって不明じゃないですかー?ゼロ引数かもしれないし10引数かもしれないし。

どーするか。型名指定ができないなら指定しなければいいじゃない。

// Expression.Lambdaに型名指定をやめ、CacheはDelegateを取ってみる
var cache = new ConcurrentDictionary<Tuple<string, string>, Delegate>();
var dynamicDelegate = cache.GetOrAdd(Tuple.Create(controllerName, actionName), _ =>
{
    // パラメータはMethodInfoから動的に作る
    var parameters = method.GetParameters().Select(x =>
            Expression.Parameter(x.ParameterType, x.Name))
        .ToArray();

    return Expression.Lambda(
            Expression.Call(Expression.New(type), method, parameters),
        parameters).Compile();
});

やった、大解決!

// 但しDelegateのキャッシュは呼び出しがDynamicInvokeでイミナイ。
// もちろん別に速くない。
var result = dynamicDelegate.DynamicInvoke(new object[] { 10, 20 });

はい、意味ありません。全然意味ないです。Funcなんちゃらの共通型としてDelegateで統一しちゃうと、DynamicInvokeしか手がなくて、ほんとほげもげ!

Everything is object

Cacheに突っ込むためには、あらゆるメソッドシグネチャの共通項を作らなきゃあならない。でもDelegateじゃあダメ。じゃあどうするか、というと、objectですよ!なんでもobjectに詰めればいいんです!

// 解決策・最も汎用的なFuncの型を作ること
// オブジェクトの配列という引数を受け取り、オブジェクトを返す関数である
// Func<object[], object>
// これを作ることによりDynamicInvokeを回避可能!(Boxingはあるけどそこは諦める)

// このキャッシュならなんでも入りそうでしょう
var cache = new ConcurrentDictionary<string, Func<object[], object>>();

良さそうな感じ、というわけで、object[]の配列を受け取りobjectを返すデリゲートを作っていきましょう。

// 作る形をイメージしよう、以下の様な形にするのを狙う
// (object[] args) => (object)new HogeController().HugaAction((int)object[0], (int)object[1])

// 引数はオブジェクトの配列
var args = Expression.Parameter(typeof(object[]), "args");

// メソッドに渡す引数はオブジェクト配列をインデクサでアクセス+キャスト => (cast)args[index]
var parameters = method.GetParameters()
    .Select((x, index) =>
        Expression.Convert(
            Expression.ArrayIndex(args, Expression.Constant(index)),
        x.ParameterType))
    .ToArray();

// あとは本体作るだけ、但し戻り値にもobjectでキャストを忘れず
var lambda = Expression.Lambda<Func<object[], object>>(
    Expression.Convert(
        Expression.Call(Expression.New(type), method, parameters),
        typeof(object)),
    args).Compile();

// これでふつーのInvokeで呼び出せるように!
var result = lambda.Invoke(new object[] { 1, 10 });

これは完璧!若干Boxingが気になりますが、そこは贅沢は敵ってものです。というか、実際はパラメータ作る前工程の都合でobjectになってたりするので、そこのコストはかかってないと考えて構わない話だったりします。ちなみにvoidなメソッドに関しては、戻り値だけちょっと弄った別デリゲートを作ればOKですことよろし。その辺の小さな分岐程度は管理するが吉。

Task & Task<T>

Extra Stage。今どきのフレームワークはTaskへの対応があるのが当たり前です。(いい加減ASP.NET MVCもフィルターのTask対応してください、LightNodeのフィルターは当然対応してるよ!)。というわけでTaskへの対応も考えていきましょう。

TaskだってobjectなのだからobjectでOK!ではないです。Taskなメソッドに求めることって、awaitすることなのです。Taskで受け取って、awaitするまでが、戻り値を取り出す工程に含まれる。

var result = new Hoge().HugaAsync();
await result; // ここで実行完了される、的なイメージ 

といってもTaskは簡単です、ようは戻り値をTaskに変えればいいだけで、↑のと変わらないです。Func<object[], Task>ということです。Taskに変えるといっても、元から戻り値がTaskのもののデリゲートを作るわけですから、ようするところObjectへのConvertを省くだけ。簡単。

// もちろん、実際にはキャッシュしてね
var lambda = Expression.Lambda<Func<object[], Task>>(
        Expression.Call(Expression.New(type), method, parameters),
    args).Compile();

var task = lambda.Invoke(new object[] { 1, 10 }); // 戻り値はTask
await task; // 待機

そう、Taskはいいんです、Taskは。簡単です。でもTask<T>が問題。というかTが。Tってなんだよ、という話になる。例によってTは静的に決まらないのですねえ……。こいつはストレートには解決できま、せん。

少しだけ周りっくどい道を通ります。まず、メソッド呼び出しのためのデリゲートはTaskと共通にします。Task<T>はTaskでもあるから、これはそのままで大丈夫。で、取り出せるTaskから、更にTを取り出します。どういうこっちゃ?というと、.Resultですよ、Result!

Task task = new Hoge().HugaAsync(); // Taskとして受け取る
await task; // ここで実行完了される
var result = ((Task<T>)task).Result; // ↑で実行完了しているので、Resultで取り出せる

こういう風なコードが作れればOK。イメージつきました?

// (Task task) => (object)((Task<>).Result)
// キャッシュする際は、キーはTypeでTを取って、ValueにFunc<Task, object>が省エネ
var taskParameter = Expression.Parameter(typeof(Task), "task");
var extractor = Expression.Lambda<Func<Task, object>>(
    Expression.Convert(
        Expression.Property(
            Expression.Convert(taskParameter, method.ReturnType), // method.ReturnType = Task<T>
            "Result"),
        typeof(object)),
    taskParameter).Compile();

// これで以下のように値が取れる!
await task;
var result = extractor(task);

ここまでやれば、非同期にも対応した、モダンな俺々フレームワーク基盤が作れるってものです。

C# is not LightWeight in Meta Programming

そんなわけでこれらの手法は、特にOwinで俺々フレームワークを作る時などは覚えておくと良いかもです。そして、定形高速化パターンと書いたように、この手法は別に全然珍しくなくて、実のところASP.NET MVCやASP.NET Web APIの中身はこれやってます。ほとんど全くこのとーりです。(べ、べつに中身見る前からコード書いてたしその後に答え合わせしただけなんだから!!!)。まぁ、このぐらいやるのが最低水準ってことですね。

で、しかし、簡単ではありません。ExpressionTreeの取り扱いとか、ただのパターンなので慣れてしまえばそう難しくもないのですけれど、しかし、簡単とはいいません。この辺がね、C#ってメタプログラミングにおいてLightWeightじゃないよねっ、ていう事実。最初の例のようにActivator.CreateInstanceで済ませたり、dynamicだけで済む範囲ならそうでもないんですが、そこを超えてやろうとするとねーっ、ていう。

ExpressionTreeの登場のお陰で、今どきだとIL弄りの必要はほぼほぼなく(とはいえゼロじゃあないですけどね)、楽になったとはいえ、もっと、もっとじゃもん、と思わないこともない。そこでRoslynなら文字列でソースコードベタベタ書いてEvalでDelegateが生成できて楽ちんぽん!な未来は間違いなくありそうです。ですがまぁ、あと1~2年であったり、あとPortable Class Libraryに落ちてくるのはいつかな?とかっていった事情を鑑みる、まだまだExpressionTree弄りスキルの重要性は落ちなさそうなので、学ぶならイマノウチ!損はしません!

メタプログラミング.NETは、満遍なく手法が紹介された良書だと思いますので、読んだことない人は是非是非読むといいかと思います。参考資料へのリンクが充実してたりする(ILのOpCodeとか)のも嬉しい点でした。

まとめ

ExpresionTreeは優秀なIL Builder(Code as Dataとしての側面は残念ながらあまり活用できそうにもないですが、こちらの側面で活躍しているのでいいじゃないですか、いいの、か???)なんでも生成頑張ればいいってものではなく頻度によりけり。頻度少なけりゃDynamicで全然OK。ん……?

ちゃぶ台返しますと、ウェブのリクエスト処理的な、リクエストの度に1回しか呼ばれない場合だと、別にここがDynamicInvokeで実行されるかどーかなんて、ハイパー誤差範囲なんですねぇ、実は!クライアントアプリとか、O/R Mapperとかシリアライザとか、テンプレートエンジンのプロパティ評価とか、凄く呼び出されまくるとかじゃなければ、割とどうでもいい範囲になってしまいます。0.01msが0.001msになって10倍高速!なのは事実ですが、そもそもウェブは1回叩いて10msかかったりするわけで誤差範囲としか言い様がない次元になってしまう。

でも、ちゃんと頑張ったほうが格好はつくので、頑張ってみると良いです、みんなもやってるし!こんなのチキンレースですから。LightNodeの速さの秘訣はこれだけ、ではないですが、大事な一端には変わりないです。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive