c#12 實驗特性Interceptor如何使用的一個簡單但完整的示例

victor.x.qu發表於2024-08-06

一直有很多轉載dotnet對Interceptor說明文件的,但鮮有說明Interceptor如何使用的,這裡寫一篇簡單示例來展示一下

c# 12 實驗特性Interceptor 是什麼?

官方解釋如下(其實簡單說就是語言特性中內建的靜態編織方式的aop功能,不同於其他il修改程式碼的方式,使用上得結合source generater 來生成程式碼 )

攔截器是一種方法,該方法可以在編譯時以宣告方式將對可攔截方法的呼叫替換為對其自身的呼叫。 透過讓攔截器宣告所攔截呼叫的源位置,可以進行這種替換。 攔截器可以向編譯中(例如在源生成器中)新增新程式碼,從而提供更改現有程式碼語義的有限能力。

在源生成器中使用攔截器修改現有編譯的程式碼,而非向其中新增程式碼。 源生成器將對可攔截方法的呼叫替換為對攔截器方法的呼叫。

如果你有興趣嘗試攔截器,可以閱讀功能規範來了解詳細資訊。 如果使用該功能,請確保隨時瞭解此實驗功能的功能規範中的任何更改。 最終確定功能後將在微軟文件站點上新增更多指導。

示例

示例目的

這裡我們用一個簡單的 static method 作為 我們改寫方法內容的目標

public static partial class DBExtensions
{
  public static string TestInterceptor<T>(object o)
  {
      return o.GetType().ToString();
  }
}

這樣的靜態方法,我們假設改寫的目標為 返回 o 引數的其中一個string型別的屬性值

所以應該可以透過如下的 UT 方法

[Fact]
public void CallNoError()
{
    Assert.Equal("sss", DBExtensions.TestInterceptor<AEnum>(new { A = "sss", C= "ddd" }));
}

如何實現

第一步 建立類庫

建立一個 netstandard2.0 的類庫並設定如下

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
	  <LangVersion>preview</LangVersion>
	  <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
	  <!-- Generates a package at build -->
	  <IncludeBuildOutput>false</IncludeBuildOutput>
	  <!-- Do not include the generator as a lib dependency -->
  </PropertyGroup>
	
	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0"  PrivateAssets="all"/>
	</ItemGroup>

	<ItemGroup>
		<!-- Package the generator in the analyzer directory of the nuget package -->
		<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
	</ItemGroup>

</Project>

第二步 設定 UT 專案開啟 Interceptor 功能

Generated 目錄生成程式碼檔案其實是非必須的,但是為了方便大家看到 source generater 生成的程式碼檔案內容,對於我們初次嘗試source generater很有幫助

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
	  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
	  <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
	  <InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Test.AOT</InterceptorsPreviewNamespaces>
  </PropertyGroup>


  <ItemGroup>
    <ProjectReference Include="..\..\src\SlowestEM.Generator2\SlowestEM.Generator2.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="true" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>
	<Target Name="CleanSourceGeneratedFiles" BeforeTargets="BeforeBuild" DependsOnTargets="$(BeforeBuildDependsOn)">
		<RemoveDir Directories="Generated" />
	</Target>

	<ItemGroup>
		<Compile Remove="Generated\**" />
		<Content Include="Generated\**" />
	</ItemGroup>
</Project>

第三步 實現 InterceptorGenerator

[Generator(LanguageNames.CSharp)]
public class InterceptorGenerator : IIncrementalGenerator
{
}

這裡的 IIncrementalGenerator 為source generater 更強設計的一代介面,有更強的效能和更方便的能力, 感興趣可以參考incremental-generators.md

接著我們來實現介面

[Generator(LanguageNames.CSharp)]
public class InterceptorGenerator : IIncrementalGenerator
{
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            var nodes = context.SyntaxProvider.CreateSyntaxProvider(FilterFunc, TransformFunc)   // FilterFunc 為遍歷語法節點時提供給我們過濾語法節點範圍 ,TransformFunc 為我們轉換為語法處理資料
                .Where(x => x is not null)
                    .Select((x, _) => x!);
            var combined = context.CompilationProvider.Combine(nodes.Collect());
            context.RegisterImplementationSourceOutput(combined, Generate);  // Generate 是最終實際轉換程式碼檔案的方法
        }
}

接著我們來實現 FilterFunc

private bool FilterFunc(SyntaxNode node, CancellationToken token)  // 這裡我們只過濾 呼叫 TestInterceptor 方法的地方
{
    if (node is InvocationExpressionSyntax ie && ie.ChildNodes().FirstOrDefault() is MemberAccessExpressionSyntax ma)
    {
        return ma.Name.ToString().StartsWith("TestInterceptor");
    }

    return false;
}

// 可以看出比之前的 ISyntaxContextReceiver 更為簡單

接著我們來實現 TransformFunc

private TestData TransformFunc(GeneratorSyntaxContext ctx, CancellationToken token)
{
    try
    {
        // 再次過濾確保只需要處理 方法呼叫的場景
        if (ctx.Node is not InvocationExpressionSyntax ie
            || ctx.SemanticModel.GetOperation(ie) is not IInvocationOperation op)
        {
            return null;
        }
        
        // 由於我們測試使用的是 匿名類初始化 語句,引數是 object,所以生成時實際有隱式轉換
        var s = op.Arguments.Select(i => i.Value as IConversionOperation).Where(i => i is not null)
            .Select(i => i.Operand as IAnonymousObjectCreationOperation)  // 查詢匿名類的第一個 為 string 的屬性
            .Where(i => i is not null)
            .SelectMany(i => i.Initializers)
            .Select(i => i as IAssignmentOperation)
            .FirstOrDefault(i => i.Target.Type.ToDisplayString() == "string");

// 生成 返回 第一個 為 string 的屬性的 方法
        return new TestData { Location = op.GetMemberLocation(), Method = @$"
internal static {op.TargetMethod.ReturnType} {op.TargetMethod.Name}_test({string.Join("", op.TargetMethod.Parameters.Select(i => @$"{i.Type} {i.Name}"))})
{{
{(s == null ? "return null;" : $@"
dynamic c = o;
return c.{(s.Target as IPropertyReferenceOperation).Property.Name};
") }
}}
" };
    }
    catch (Exception ex)
    {
        Debug.Fail(ex.Message);
        return null;
    }
}

// 這裡我們隨意建立一個類來方便我們處理中間資料
public class TestData
{
    public Location Location { get; set; }
    public string Method { get; set; }
}

public static class TypeSymbolHelper
{
    // 獲取 語法節點所在檔案物理路徑
    internal static string GetInterceptorFilePath(this SyntaxTree? tree, Compilation compilation)
    {
        if (tree is null) return "";
        return compilation.Options.SourceReferenceResolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath;
    }

    public static Location GetMemberLocation(this IInvocationOperation call)
        => GetMemberSyntax(call).GetLocation();

    // 很不幸,由於攔截器 替換必須程式碼檔案物理檔案位置,行號 列號都必須準確, 比如 xxx.TestInterceptor, 比如要 TestInterceptor 的準確位置, 如果從 xxx. 開始都不正確,編譯無法透過
    // 所以這裡有一個比較繁瑣的方法來幫助我們準確找到 位置
    public static SyntaxNode GetMemberSyntax(this IInvocationOperation call)
    {
        var syntax = call?.Syntax;
        if (syntax is null) return null!; // GIGO

        foreach (var outer in syntax.ChildNodesAndTokens())
        {
            var outerNode = outer.AsNode();
            if (outerNode is not null && outerNode is MemberAccessExpressionSyntax)
            {
                // if there is an identifier, we want the **last** one - think Foo.Bar.Blap(...)
                SyntaxNode? identifier = null;
                foreach (var inner in outerNode.ChildNodesAndTokens())
                {
                    var innerNode = inner.AsNode();
                    if (innerNode is not null && innerNode is SimpleNameSyntax)
                        identifier = innerNode;
                }
                // we'd prefer an identifier, but we'll allow the entire member-access
                return identifier ?? outerNode;
            }
        }
        return syntax;
    }
}

接著我們來實現 Generate

private void Generate(SourceProductionContext ctx, (Compilation Left, ImmutableArray<TestData> Right) state)
{
    try
    {
      //  這裡主要是生成 InterceptsLocation 
        var s = string.Join("", state.Right.Select(i => 
        {
            var loc = i.Location.GetLineSpan();
            var start = loc.StartLinePosition;
            return @$"[global::System.Runtime.CompilerServices.InterceptsLocationAttribute({SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(i.Location.SourceTree.GetInterceptorFilePath(state.Left)))},{start.Line + 1},{start.Character + 1})]
{i.Method}";
        }));
        var ss = $@"
namespace Test.AOT 
{{
file static class GeneratedInterceptors
{{
{s}
}}
}}


namespace System.Runtime.CompilerServices
{{
// this type is needed by the compiler to implement interceptors - it doesn't need to
// come from the runtime itself, though

[global::System.Diagnostics.Conditional(""DEBUG"")] // not needed post-build, so: evaporate
[global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)]
sealed file class InterceptsLocationAttribute : global::System.Attribute
{{
public InterceptsLocationAttribute(string path, int lineNumber, int columnNumber)
{{
    _ = path;
    _ = lineNumber;
    _ = columnNumber;
}}
}}
}}
";
        ctx.AddSource((state.Left.AssemblyName ?? "package") + ".generated.cs", ss);
    }
    catch (Exception ex)
    {
        Debug.Fail(ex.Message);
    }
}

目前需要自定義InterceptsLocationAttribute, 所以需要生成一個,

這樣做的目前主要是目前還是實驗特性 ,api 設計還在變化,並且其實物理檔案位置現在已被認可非常不方便,已設計新的方式,但是相關設計還不太方便使用,所以這裡我們也還是使用物理位置的方式

感興趣的童鞋可以參考interceptors.md

最後一步 編譯試試

如果我們編譯程式,就會看見生成了這樣的檔案程式碼


namespace Test.AOT 
{
    file static class GeneratedInterceptors
    {
        [global::System.Runtime.CompilerServices.InterceptsLocationAttribute("D:\\code\\dotnet\\SlowestEM\\test\\UT\\GeneratorUT\\StartMethod.cs",26,35)]

internal static string TestInterceptor_test(object o)
{
    
    dynamic c = o;
    return c.A;

}

    }
}


namespace System.Runtime.CompilerServices
{
    // this type is needed by the compiler to implement interceptors - it doesn't need to
    // come from the runtime itself, though

    [global::System.Diagnostics.Conditional("DEBUG")] // not needed post-build, so: evaporate
    [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)]
    sealed file class InterceptsLocationAttribute : global::System.Attribute
    {
        public InterceptsLocationAttribute(string path, int lineNumber, int columnNumber)
        {
            _ = path;
            _ = lineNumber;
            _ = columnNumber;
        }
    }
}

如果執行ut ,結果也正確, debug 逐行除錯也可看到斷點能進入我們 生成的程式碼檔案中

相關文章