0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FodyでCaller Info属性を実現してみる

0
Posted at

ILの編集ツール Fody を使ってCaller Info属性と同じような機能を実現してみました。

はじめに

Caller Info属性はコンパイラが引数値を自動的に補完する事でメソッド呼び出し元の情報(メソッド名、ファイルパス、行番号など)を取得する機能です。

例えば、Caller Info属性を使用するとこのようになります。

Program.cs
using System.Runtime.CompilerServices;

var svc = new ItemService();
svc.Create("check1");

var traceInfo = (string msg) => TraceLogger.Info(msg);
traceInfo("check2");

public static class TraceLogger
{
    public static void Info(string msg,
        [CallerMemberName] string memberName = "",
        [CallerFilePath] string filePath = "",
        [CallerLineNumber] int lineNumber = 0)
    {
        Console.WriteLine($"message='{msg}', method='{memberName}', file='{Path.GetFileName(filePath)}', lineNo={lineNumber}");
    }
}

public class ItemService
{
    public bool Create(string id)
    {
        TraceLogger.Info($"Create:{id}");
        return true;
    }
}

実行結果は次のようになり、TraceLogger.Info の呼び出し元の情報(メソッド名など)を引数の値として取得できます。

実行結果
$ dotnet run
message='Create:check1', method='Create', file='Program.cs', lineNo=24
message='check2', method='<Main>$', file='Program.cs', lineNo=6

このようにCaller Info属性は便利ですが、現時点では呼び出し元の型名を取得したり、任意の情報を取得するようなカスタマイズ機能は無さそうなので、Fodyを使って代用できないか試してみました。

FodyによるIL編集の実装

ここでは、次の要件を実現する事にします。
任意の位置の引数へ適用しようとすると考慮すべき事が増えるので、単純化のために最後の引数に限定しています。1

  • 最後の引数が CustomAttributes.CallerTypeAttribute 属性を適用した string 型のメソッドを呼び出す際にその引数の値を型名で置き換える

FodyでIL編集を実装するには、基本的に以下を実施すれば良さそうです。

  • FodyHelpers を依存パッケージへ追加
  • BaseModuleWeaver を継承したクラスを定義
    • static や abstract ではない public クラスにする
    • 空コンストラクタを持たせる
CustomWeaver.csproj
<Project Sdk="Microsoft.NET.Sdk">
  ...省略

  <ItemGroup>
    <PackageReference Include="FodyHelpers" Version="6.9.3" />
  </ItemGroup>
</Project>

BaseModuleWeaver.Execute メソッドをオーバーライドしてIL編集を実装すると、このようになりました。

CallerTypeWeaver.cs
using Fody;
using Mono.Cecil;
using Mono.Cecil.Cil;

namespace CustomWeaver;

public class CallerTypeWeaver : BaseModuleWeaver
{
    private const string TARGET_ATTRIBUTE = "CustomAttributes.CallerTypeAttribute";

    public override void Execute()
    {
        var filter = (TypeDefinition x) => x.BaseType != null && !x.IsEnum && !x.IsInterface;

        var ts = ModuleDefinition.GetTypes().Where(filter);

        foreach (var t in ts)
        {
            ProcessType(t);

            // 入れ子になったクラスへ適用
            foreach (var nt in t.NestedTypes.Where(filter))
            {
                ProcessType(nt);
            }
        }
    }

    private void ProcessType(TypeDefinition t)
    {
        foreach (var m in t.Methods)
        {
            foreach (var inst in m.Body.Instructions)
            {
                if (inst.OpCode == OpCodes.Call && inst.Operand is MethodReference mr )
                {
                    // 最後の引数を取得
                    var lastParam = mr.Parameters.LastOrDefault();

                    if (lastParam != null && lastParam.CustomAttributes.Any(x => x.AttributeType.FullName == TARGET_ATTRIBUTE))
                    {
                        // 最後の引数へ値を設定している命令コードを取得
                        var prevInst = inst.Previous;

                        if (prevInst.OpCode == OpCodes.Ldstr)
                        {
                            // ldstr命令コードの値を型名で変更
                            prevInst.Operand = t.FullName;
                        }
                    }
                }
            }
        }
    }

    public override IEnumerable<string> GetAssembliesForScanning() => [];
}

上述した要件を満たすために次のような処理を実装しています。

  1. 複数の型定義の中から通常のクラスを抽出
  2. クラス定義の各メソッドの命令コードからメソッドを呼び出しているものを抽出
  3. 呼び出しているメソッドの最後の引数へ CustomAttributes.CallerTypeAttribute 属性を適用しているか否かを判定
  4. 最後の引数へ値を割り当てている命令コードを取得して値を型名へ変更

基本的に、最後の引数へ値を割り当てている命令コードは対象とするメソッド呼び出しの一つ手前になるはずなので、Previous を使って取得しています。

また、入れ子になったクラスへの対応も考慮し、NestedTypes も処理しています。

ビルド

ビルドしておきます。

ビルド例
$ dotnet build

動作確認

確認用のプロジェクトを作成して、動作確認してみます。

まずは、Fodyを依存パッケージへ追加して、WeaverFiles でIL編集処理のビルド結果(CustomWeaver.dllCallerTypeWeaver)を適用する設定を行います。

SampleProject.csproj
<Project Sdk="Microsoft.NET.Sdk">
  ...省略
  <ItemGroup>
    <PackageReference Include="Fody" Version="6.9.3">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>

    <WeaverFiles Include="../CustomWeaver/bin/$(Configuration)/$(TargetFramework)/CustomWeaver.dll" WeaverClassNames="CallerTypeWeaver" />
  </ItemGroup>
</Project>

FodyWeavers.xml ファイルが必要になりますが、初回ビルド時にこの設定から以下のファイルが自動的に作成されるため、予め用意する必要はありませんでした。

FodyWeavers.xml
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <CallerTypeWeaver />
</Weavers>

次に、[CallerType] のカスタム属性を定義します。2

CallerTypeAttribute.cs
namespace CustomAttributes;

[AttributeUsage(AttributeTargets.Parameter)]
public class CallerTypeAttribute : Attribute
{
}

動作確認の処理は例えばこのようになります。

Program.cs
using SampleProject;
using CustomAttributes;

var svc = new Service();
svc.Create("check1");

TraceLogger.Info("check2");

var traceInfo = (string msg) => TraceLogger.Info(msg);
traceInfo("check3");

TraceLogger.Info2("check4");

namespace SampleProject
{
    public class TraceLogger
    {
        public static void Info(string msg, [CallerType] string callerType = "")
        {
            Console.WriteLine($"message='{msg}', caller-type='{callerType}'");
        }
        // 最後の引数が CallerType ではないので、このメソッド呼び出しには適用しない
        public static void Info2(string msg, [CallerType] string callerType = "", string opt = "")
        {
            Console.WriteLine($"message='{msg}', caller-type='{callerType}', opt='{opt}'");
        }
    }

    public class BaseService
    {
        protected void Before(string id)
        {
            TraceLogger.Info($"Before:{id}");
        }
    }

    public class Service : BaseService
    {
        public bool Create(String id)
        {
            base.Before(id);

            TraceLogger.Info($"Create:{id}");

            new InnerService().After(id);

            return true;
        }

        class InnerService
        {
            public void After(string id)
            {
                TraceLogger.Info($"After:{id}");
            }
        }
    }
}

実行結果は以下の通りで、呼び出し元のクラス名を取得できました。

実行結果
$ dotnet run
MSBUILD : warning : Fody: Could not find a FodyWeavers.xml file at the project level ...省略
message='Before:check1', caller-type='SampleProject.BaseService'
message='Create:check1', caller-type='SampleProject.Service'
message='After:check1', caller-type='SampleProject.Service/InnerService'
message='check2', caller-type='Program'
message='check3', caller-type='Program/<>c'
message='check4', caller-type='', opt=''
  1. 更に、string? 型を使うと命令コードが ldnull となり、ILProcessor.Replace 等で ldstr への置換が上手くいかなかったため、引数の型も string で限定しています

  2. CallerTypeWeaver.TARGET_ATTRIBUTE で定義した型名と同じにする必要があります

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?