.NET Core でスタックトレースからメソッド呼び出しを隠す
Created at:
このエントリーはC# その2 Advent Calendar 2019のエントリーです。
突然ですがスタックトレースから何らか自分の書いたメソッドを隠したいなと思ったことはないでしょうか?普段はそんなことを考える必要はないのですがアプリの下回りやミドルウェア的なものを作っているとたまにそういった場面に遭遇します。
例えば ASP.NET Core MVC の ActionFilter のようなフィルターチェーン的なものを作るといったパターンがあるとします。まず次のような感じのフィルター定義があって…
|
そのフィルターをつなげて呼び出すというような仕組みです。
|
このコードの実行結果はおおよそこんな感じになります。
|
結果を見ていただくとわかるのですがスタックトレースに Program.<>c.<Main>b__4_0(Object context) といった呼び出しが現れていますが、単にキャプチャーして渡すだけのメソッドなのでユーザーにはほぼ意味のないものです。とはいえコード上でラムダを挟んでいるので出てくるのは当然です。
そこで何とかしてこのような些末な呼び出しをスタックトレースから隠すためのハックを今回ご紹介します。
案1. StackTraceHiddenAttribute を使う
.NET Core 2.0 には StackTraceHiddenAttribute という属性が追加されていて、その属性のついたメソッドはスタックトレースから除外されるようになっています。
ということはこれを使えば解決しそうですが残念ながらこの属性は internal です。効果を考えたらそんなカジュアルに使われても困るので内部向けに用意したというところでしょうか。
とはいえそれでも動的アセンブリ生成なら無理やり属性を引っ張り出してくっつけることができるはず…!イメージとしては次のようなヘルパークラスの InvokeNext の部分を動的に作ってうまいことするような感じです。
|
ということでILをペコペコ書きます。
| /// <summary> | |
| /// 次のフィルター呼び出しと、フィルターのメソッドを保持するクラス。 | |
| /// 以前のラムダのキャプチャーと同等。 | |
| /// </summary> | |
| public class InvokeHelper<TArg1, TDelegateNext> | |
| where TDelegateNext : Delegate | |
| { | |
| /// <summary>フィルターのメソッド</summary> | |
| public Action<TArg1, TDelegateNext> Invoke; | |
| /// <summary>次のフィルターの呼び出しとなるデリゲート</summary> | |
| public TDelegateNext Next; | |
| private static readonly Func<InvokeHelper<TArg1, TDelegateNext>, TDelegateNext> InvokeNextFactory; | |
| public InvokeHelper(Action<TArg1, TDelegateNext> invoke, TDelegateNext next) | |
| { | |
| Invoke = invoke; | |
| Next = next; | |
| } | |
| static InvokeHelper() | |
| { | |
| var fieldInvoke = typeof(InvokeHelper<TArg1, TDelegateNext>).GetField("Invoke"); | |
| var fieldNext = typeof(InvokeHelper<TArg1, TDelegateNext>).GetField("Next"); | |
| var methodInvoke = typeof(Action<TArg1, TDelegateNext>).GetMethod("Invoke"); | |
| // 動的にアセンブリを作る | |
| var asmBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName($"InvokeHelper-{typeof(TArg1).FullName}"), AssemblyBuilderAccess.RunAndCollect); | |
| var module = asmBuilder.DefineDynamicModule("DynamicModule"); | |
| var type = module.DefineType("InvokeHelper"); | |
| // メソッドを定義する | |
| // 内容は (arg1) => helper.Invoke(arg1, helper.Next); と同じになる | |
| var method = type.DefineMethod("InvokeNext", MethodAttributes.Static | MethodAttributes.Public, typeof(void), new[] { typeof(InvokeHelper<TArg1, TDelegateNext>), typeof(TArg1) }); | |
| // StackTraceHiddenAttribute を引っ張り出してきてメソッドにくっつけておく | |
| var attrStacktraceHiddenAttribute = Type.GetType("System.Diagnostics.StackTraceHiddenAttribute"); | |
| method.SetCustomAttribute(new CustomAttributeBuilder(attrStacktraceHiddenAttribute.GetConstructor(Array.Empty<Type>()), Array.Empty<object>())); | |
| { | |
| var il = method.GetILGenerator(); | |
| // invoke = arg0.Invoke; | |
| il.Emit(OpCodes.Ldarg_0); | |
| il.Emit(OpCodes.Ldfld, fieldInvoke); | |
| // next = arg0.Next; | |
| // invoke(arg1, next); | |
| il.Emit(OpCodes.Ldarg_1); | |
| il.Emit(OpCodes.Ldarg_0); | |
| il.Emit(OpCodes.Ldfld, fieldNext); | |
| il.Emit(OpCodes.Callvirt, methodInvoke); | |
| il.Emit(OpCodes.Ret); | |
| } | |
| // 保持している Invoke/Next のペアから次の next のデリゲートを生成するファクトリーを作る | |
| InvokeNextFactory = (helper) => (TDelegateNext)type.CreateType().GetMethod("InvokeNext").CreateDelegate(typeof(TDelegateNext), helper); | |
| } | |
| /// <summary>保持した値を使用するフィルターのnextに渡すためのデリゲートを生成する</summary> | |
| public TDelegateNext GetDelegate() => InvokeNextFactory(this); | |
| } |
そしてフィルターチェーンを作るところをヘルパー経由に書き換え。
|
そして実行すると…。
|
ばっちり BFitler と AFitler の間にあった呼び出し行が消えました!めでたしめでたし。
案2. 動的メソッド生成
案1でめでたしめでたしとなるかと思いきや、実は案1を試している間に気づいたのですが StackTraceHiddenAttribute をつけなくても消えます。案1の下記の二行を削除してみても結果は変わりません。
|
どうも CoreCLR の中も少し調べてみたのですがよくわからず、動的生成されたメソッドがスタックトレース的に何らかの特別扱いされることがあるようです。
というわけで動的にメソッドを定義できれば理屈は謎ですが消えますし、アセンブリを生成しなくとも DynamicMethod で事足ります。
| /// <summary> | |
| /// 次のフィルター呼び出しと、フィルターのメソッドを保持するクラス。 | |
| /// 以前のラムダのキャプチャーと同等。 | |
| /// </summary> | |
| class InvokeHelper<TArg1, TDelegateNext> | |
| where TDelegateNext : Delegate | |
| { | |
| /// <summary>フィルターのメソッド</summary> | |
| public Action<TArg1, TDelegateNext> Invoke; | |
| /// <summary>次のフィルターの呼び出しとなるデリゲート</summary> | |
| public TDelegateNext Next; | |
| private static readonly Func<InvokeHelper<TArg1, TDelegateNext>, TDelegateNext> InvokeNextFactory; | |
| public InvokeHelper(Action<TArg1, TDelegateNext> invoke, TDelegateNext next) | |
| { | |
| Invoke = invoke; | |
| Next = next; | |
| } | |
| static InvokeHelper() | |
| { | |
| var fieldInvoke = typeof(InvokeHelper<TArg1, TDelegateNext>).GetField("Invoke"); | |
| var fieldNext = typeof(InvokeHelper<TArg1, TDelegateNext>).GetField("Next"); | |
| var methodInvoke = typeof(Action<TArg1, TDelegateNext>).GetMethod("Invoke"); | |
| var method = new DynamicMethod("InvokeNext", typeof(void), new[] { typeof(InvokeHelper<TArg1, TDelegateNext>), typeof(TArg1) }, restrictedSkipVisibility: true); | |
| { | |
| var il = method.GetILGenerator(); | |
| // invoke = arg0.Invoke; | |
| il.Emit(OpCodes.Ldarg_0); | |
| il.Emit(OpCodes.Ldfld, fieldInvoke); | |
| // next = arg0.Next; | |
| // invoke(arg1, next); | |
| il.Emit(OpCodes.Ldarg_1); | |
| il.Emit(OpCodes.Ldarg_0); | |
| il.Emit(OpCodes.Ldfld, fieldNext); | |
| il.Emit(OpCodes.Callvirt, methodInvoke); | |
| il.Emit(OpCodes.Ret); | |
| } | |
| InvokeNextFactory = (helper) => (TDelegateNext)method.CreateDelegate(typeof(TDelegateNext), helper); | |
| } | |
| /// <summary>保持した値を使用するフィルターのnextに渡すためのデリゲートを生成する</summary> | |
| public TDelegateNext GetDelegate() => InvokeNextFactory(this); | |
| } |
案3. [MethodImpl(MethodImplOptions.AggressiveInlining)] を使う
StackTraceHiddenAttribute のあたりのコードを見ていて気が付いたのですが .NET Core 3.0 以降では [MethodImpl(MethodImplOptions.AggressiveInlining)] がついているかどうかもチェックしています。
これは通常JITでインライン化されるとスタックトレースから消えてしまうけれども、Tiered Compilation の Tier 0 ではインライン化されないので表示されてしまったりして一貫性がないので属性がついているものは丸ごと非表示にしてしまおうということのようです。
ということで裏技っぽいですが [MethodImpl(MethodImplOptions.AggressiveInlining)] をつければ非表示になります。内容的にもインライン化されたところで困るものでもないですしよさそうです。
ヘルパークラスのイメージとしてはこんな感じです。
|
まとめ
ということでこれをまとめると .NET Core 3.0 以前では動的メソッド定義、それ以外では AggressiveInlining をつけておくというのがよさそうです。シンプルですし、もし挙動が変わってスタックトレースが出るようになっても AggressiveInlining がついているだけなのでプログラムの挙動には影響を与えないのもよいですね。
| /// <summary> | |
| /// 次のフィルター呼び出しと、フィルターのメソッドを保持するクラス。 | |
| /// 以前のラムダのキャプチャーと同等。 | |
| /// </summary> | |
| class InvokeHelper<TArg1, TDelegateNext> | |
| where TDelegateNext : Delegate | |
| { | |
| /// <summary>フィルターのメソッド</summary> | |
| public Action<TArg1, TDelegateNext> Invoke; | |
| /// <summary>次のフィルターの呼び出しとなるデリゲート</summary> | |
| public TDelegateNext Next; | |
| private static readonly Func<InvokeHelper<TArg1, TDelegateNext>, TDelegateNext> InvokeNextFactory; | |
| public InvokeHelper(Action<TArg1, TDelegateNext> invoke, TDelegateNext next) | |
| { | |
| Invoke = invoke; | |
| Next = next; | |
| } | |
| static InvokeHelper() | |
| { | |
| // .NET Core 2.x は .NET Core 4.0.xxxx を返してくる | |
| if (RuntimeInformation.FrameworkDescription.StartsWith(".NET Core 4")) | |
| { | |
| // 動的メソッド定義 | |
| var fieldInvoke = typeof(InvokeHelper<TArg1, TDelegateNext>).GetField("Invoke"); | |
| var fieldNext = typeof(InvokeHelper<TArg1, TDelegateNext>).GetField("Next"); | |
| var methodInvoke = typeof(Action<TArg1, TDelegateNext>).GetMethod("Invoke"); | |
| var method = new DynamicMethod("InvokeNext", typeof(void), new[] { typeof(InvokeHelper<TArg1, TDelegateNext>), typeof(TArg1) }, restrictedSkipVisibility: true); | |
| { | |
| var il = method.GetILGenerator(); | |
| // invoke = arg0.Invoke; | |
| il.Emit(OpCodes.Ldarg_0); | |
| il.Emit(OpCodes.Ldfld, fieldInvoke); | |
| // next = arg0.Next; | |
| // invoke(arg1, next); | |
| il.Emit(OpCodes.Ldarg_1); | |
| il.Emit(OpCodes.Ldarg_0); | |
| il.Emit(OpCodes.Ldfld, fieldNext); | |
| il.Emit(OpCodes.Callvirt, methodInvoke); | |
| il.Emit(OpCodes.Ret); | |
| } | |
| InvokeNextFactory = (helper) => (TDelegateNext)method.CreateDelegate(typeof(TDelegateNext), helper); | |
| } | |
| else | |
| { | |
| // MethodInlining | |
| InvokeNextFactory = (helper) => | |
| { | |
| var invokeNext = new Action<TArg1>(helper.InvokeNext); | |
| return (TDelegateNext)Delegate.CreateDelegate(typeof(TDelegateNext), invokeNext.Target, invokeNext.Method); | |
| }; | |
| } | |
| } | |
| [MethodImpl(MethodImplOptions.AggressiveInlining)] | |
| private void InvokeNext(TArg1 context) | |
| { | |
| Invoke(context, Next); | |
| } | |
| /// <summary>保持した値を使用するフィルターのnextに渡すためのデリゲートを生成する</summary> | |
| public TDelegateNext GetDelegate() => InvokeNextFactory(this); | |
| } |
このハックは実際にMagicOnionのフィルター周りに入れていてスタックトレースの見やすさを向上させています。
ハックなし

ハックあり

スタックトレースは開発において重要な情報なのでノイズは少なければ少ないほうが望ましいですし、アプリケーションの基盤に近い部分ではそういった開発体験を考慮しておくのは重要だと思います。今回のようなハックも機会があればお役立てください。
なお、あくまでハックなのでいつまで効果があるかといった点は保証できませんのであらかじめご了承ください。