ぷろじぇくと、みすじら。

.NET Core でスタックトレースからメソッド呼び出しを隠す

Created at:

このエントリーはC# その2 Advent Calendar 2019のエントリーです。

突然ですがスタックトレースから何らか自分の書いたメソッドを隠したいなと思ったことはないでしょうか?普段はそんなことを考える必要はないのですがアプリの下回りやミドルウェア的なものを作っているとたまにそういった場面に遭遇します。

例えば ASP.NET Core MVC の ActionFilter のようなフィルターチェーン的なものを作るといったパターンがあるとします。まず次のような感じのフィルター定義があって…

interface IFilter
{
    void Invoke(object context, Action<object> next);
}

class AFilter : IFilter
{
    public void Invoke(object context, Action<object> next)
    {
        Console.WriteLine("A Begin");
        next(context);
        Console.WriteLine("A End");
    }
}
class BFilter : IFilter
{
    public void Invoke(object context, Action<object> next)
    {
        Console.WriteLine("B Begin");
        next(context);
        Console.WriteLine("B End");
    }
}

そのフィルターをつなげて呼び出すというような仕組みです。

// フィルターの一覧を作る (後ろに行くほうが内側 = 外側が AFilter, 内側が BFilter)
var filters = new IFilter[] { new AFilter(), new BFilter() };

// フィルターに渡す次のアクションの変数(その際最後になるアクションを入れておく)
Action<object> next = (context) =>
{
    // 一番深いところでスタックトレースを取得する
    Console.WriteLine(Environment.StackTrace);
};

// フィルターチェーンを作る
// 内側から外に向かってつないでいく
foreach (var filter in filters.Reverse())
{
    // next をラムダにキャプチャーする必要がある
    var next_ = next;
    next = (context) => filter.Invoke(context, next_);
}

// 呼び出す
next(null);

このコードの実行結果はおおよそこんな感じになります。

A Begin
B Begin
   at System.Environment.get_StackTrace()
   at Program.<>c.<Main>b__4_0(Object context) in C:\Program.cs:line 9
   at Program.BFilter.Invoke(Object context, Action`1 next) in C:\Program.cs:line 44
   at Program.<>c__DisplayClass4_1.<Main>b__1(Object context) in C:\Program.cs:line 17
   at Program.AFilter.Invoke(Object context, Action`1 next) in C:\Program.cs:line 35
   at Program.<>c__DisplayClass4_1.<Main>b__1(Object context) in C:\Program.cs:line 17
   at Program.Main() in C:\Program.cs:line 21
   (略)
B End
A End

結果を見ていただくとわかるのですがスタックトレースに Program.<>c.<Main>b__4_0(Object context) といった呼び出しが現れていますが、単にキャプチャーして渡すだけのメソッドなのでユーザーにはほぼ意味のないものです。とはいえコード上でラムダを挟んでいるので出てくるのは当然です。

そこで何とかしてこのような些末な呼び出しをスタックトレースから隠すためのハックを今回ご紹介します。

案1. StackTraceHiddenAttribute を使う

.NET Core 2.0 には StackTraceHiddenAttribute という属性が追加されていて、その属性のついたメソッドはスタックトレースから除外されるようになっています。

ということはこれを使えば解決しそうですが残念ながらこの属性は internal です。効果を考えたらそんなカジュアルに使われても困るので内部向けに用意したというところでしょうか。

とはいえそれでも動的アセンブリ生成なら無理やり属性を引っ張り出してくっつけることができるはず…!イメージとしては次のようなヘルパークラスの InvokeNext の部分を動的に作ってうまいことするような感じです。

/// <summary>
/// 次のフィルター呼び出しと、フィルターのメソッドを保持するクラス。
/// 以前のラムダのキャプチャーと同等。
/// </summary>
public class InvokeHelper
{
    /// <summary>フィルターのメソッド</summary>
    public Action<object, Action<object>> Invoke;
    /// <summary>次のフィルターの呼び出しとなるデリゲート</summary>
    public Action<object> Next;

    public InvokeHelper(Action<object, Action<object>> invoke, Action<object> next)
    {
        Invoke = invoke;
        Next = next;
    }
    
    [StackTraceHidden]
    public void InvokeNext(object context)
    {
        Invoke(context, Next);
    }
}

ということで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);
}

そしてフィルターチェーンを作るところをヘルパー経由に書き換え。

foreach (var filter in filters.Reverse())
{
    next = new InvokeHelper<object, Action<object>>(filter.Invoke, next).GetDelegate();
}

そして実行すると…。

A Begin
B Begin
   at System.Environment.get_StackTrace()
   at Program.<>c.<Main>b__4_0(Object context) in C:\Program.cs:line 9
   at Program.BFilter.Invoke(Object context, Action`1 next) in C:\Program.cs:line 44
   at Program.AFilter.Invoke(Object context, Action`1 next) in C:\Program.cs:line 35
   at Program.Main() in C:\Program.cs:line 21
   (略)
B End
A End

ばっちり BFitlerAFitler の間にあった呼び出し行が消えました!めでたしめでたし。

案2. 動的メソッド生成

案1でめでたしめでたしとなるかと思いきや、実は案1を試している間に気づいたのですが StackTraceHiddenAttribute をつけなくても消えます。案1の下記の二行を削除してみても結果は変わりません。

var attrStacktraceHiddenAttribute = Type.GetType("System.Diagnostics.StackTraceHiddenAttribute");
method.SetCustomAttribute(new CustomAttributeBuilder(attrStacktraceHiddenAttribute.GetConstructor(Array.Empty<Type>()), Array.Empty<object>()));

どうも 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)] をつければ非表示になります。内容的にもインライン化されたところで困るものでもないですしよさそうです。

ヘルパークラスのイメージとしてはこんな感じです。

public class InvokeHelper
{
    /// <summary>フィルターのメソッド</summary>
    public Action<object, Action<object>> Invoke;
    /// <summary>次のフィルターの呼び出しとなるデリゲート</summary>
    public Action<object> Next;

    public InvokeHelper(Action<object, Action<object>> invoke, Action<object> next)
    {
        Invoke = invoke;
        Next = next;
    }
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void InvokeNext(object context)
    {
        Invoke(context, Next);
    }
}

まとめ

ということでこれをまとめると .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のフィルター周りに入れていてスタックトレースの見やすさを向上させています。

ハックなし

Before

ハックあり

After

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

なお、あくまでハックなのでいつまで効果があるかといった点は保証できませんのであらかじめご了承ください。