C#中的拦截器与AOP

作者:微信公众号:【架构师老卢】
4-19 14:12
50

概述:.NET 8 引入了一个名为 Interceptors 的新功能,这是一个实验性概念,允许我们以一种非常有趣的方式重写现有代码。using System;using System.Runtime.CompilerServices;var c = new C();c.InterceptableMethod(1); // (L1,C1): prints interceptor 1c.InterceptableMethod(1); // (L2,C2): prints other interceptor 1c.InterceptableMethod(2); // (L3,C3): prints ot

.NET 8 引入了一个名为 Interceptors 的新功能,这是一个实验性概念,允许我们以一种非常有趣的方式重写现有代码。

using System;
using System.Runtime.CompilerServices;

var c = new C();
c.InterceptableMethod(1); // (L1,C1): prints "interceptor 1"
c.InterceptableMethod(1); // (L2,C2): prints "other interceptor 1"
c.InterceptableMethod(2); // (L3,C3): prints "other interceptor 2"
c.InterceptableMethod(1); // prints "interceptable 1"

class C
{
  public void InterceptableMethod(int param)
  {
    Console.WriteLine($"interceptable {param}");
  }
}

// generated code
static class D
{
  // refers to the call at (L1, C1)
  [InterceptsLocation("Program.cs", line: /*L1*/, character: /*C1*/)] 
  public static void InterceptorMethod(this C c, int param)
  {
    Console.WriteLine($"interceptor {param}");
  }

  // refers to the call at (L2, C2)
  [InterceptsLocation("Program.cs", line: /*L2*/, character: /*C2*/)] 
  // refers to the call at (L3, C3)
  [InterceptsLocation("Program.cs", line: /*L3*/, character: /*C3*/)] 
  public static void OtherInterceptorMethod(this C c, int param)
  {
    Console.WriteLine($"other interceptor {param}");
  }
}

我从官方Microsoft文档中复制了这段代码,我知道它乍一看看起来很奇怪。不幸的是,经过仔细检查,它似乎仍然很不寻常。您必须正确设置行(L1,L2,L3)和列(C1,C2,C3)才能使其工作。尽管该功能非常强大,但代码似乎很脆弱并且容易破解。如果将行的位置或变量名称更改为更短或更长的位置,则整个拦截将停止工作,并且会出现错误:InterceptsLocationc

\[CS9147\] The provided line and character number does not refer to   
the start of token 'InterceptableMethod'.   
Did you mean to use line '5' and character '3'?

它_很脆弱_,但它的脆弱是有原因的。C# 团队不鼓励在日常编码任务中使用拦截器。老实说,他们似乎根本不希望您使用它。拦截器似乎主要与最小 API 源生成器一起使用,因为拦截器的功能目前非常有限。

此外,根据拦截器的文档,拦截器当前处于预览模式,不应在生产或已发布的应用程序中使用。实际上,C# 团队最初反对允许源代码生成器重写代码。正如您在他们的常见问题解答中看到的那样:

源代码生成器与其他元编程功能(如宏或编译器插件)相比如何?

“关键的区别在于源代码生成器不允许你重写用户代码。我们认为这种限制是一个显著的好处,因为它使用户代码在运行时的实际操作具有可预测性。我们认识到重写用户代码是一项非常强大的功能,但我们不太可能让源代码生成器做到这一点。

与他们的声明相反,拦截器引入了这种特殊类型的重写:)。

由于拦截器使我们能够涵盖更多面向方面的编程 (AOP) 用例,因此我有一个想法,可以在 2024 年仔细研究 C# 中的 AOP。

什么是面向方面的编程?

AOP 是一种编程范式,旨在通过允许分离跨领域关注点来提高模块化。它主要通过向现有代码添加其他行为来实现,而无需_修改代码本身_。

简单来说,AOP 允许您将其他代码注入到程序的各个部分,而无需直接更改这些部分中的代码。AOP 的好处涉及多个计划领域,例如日志记录、安全性或事务管理。

C# 中的 AOP 并不是什么新鲜事。C# 获得属性后,可以使用 .NET 反射向代码添加_方面_。当时出现了几个库来支持更复杂的 AOP 场景。最著名的工具之一是 PostSharp

另一件大事是在 .NET 5 中引入了源生成器。但是,源代码生成器主要不是专注于提供全功能的 AOP,因为缺少重写代码的直接机制。

我们现在有了这个缺失的拼图。在博客文章的其余部分,我将介绍实现最直接的 AOP 概念之一的各种方法——根据给定代码片段中的需要修改方法的功能:

class User  
{  
  public void SignUp(string email, string password)  
  {  
    // Write to the console: "User is being created"  
    var newUser = CreateUser(email, password);  
    // Write to the console: "User has been created"  
  }  
}

我们的目标是通过添加通过 AOP 实现的简单日志记录来增强该方法的功能。让我们从_最简单的_开始。SignUp

带反射的 AOP

带反射的 AOP 是一个简单的概念。你可能以前看过它。除了反射和属性,你什么都不需要。

代码:

public class LoggingAspect  
{  
  public void RunAndApplyAspect(object target, object[] args)  
  {  
    var targetType = target.GetType();  
    var methods = targetType.GetMethods();  
  
    foreach (var method in methods)  
    {  
      if (method.IsDefined(typeof(LogAttribute), false))  
      {  
        Console.WriteLine("User is being created");  
        method.Invoke(target, args);  
        Console.WriteLine("User has been created");  
      }  
    }  
  }  
}  
  
// Program.cs  
var user = new User();  
  
var myAspect = new LoggingAspect();  
myAspect.RunAndApplyAspect(user, new []  
{  
    "example@email.com", "123"  
});

让我在这里说得非常清楚。我的实现是实现目标的一种糟糕方式。它有效,但它不是我写过的最好的代码。你可以想出一个更优雅的解决方案,使用委托来强制参数的类型安全或制作一个通用包装器。但我希望你有一个想法。

带反射的 AOP 是一种清晰且众所周知的模式。归根结底,自 2002 年以来,反思已成为我们生活的一部分。如果你是历史爱好者,你可能会喜欢阅读一篇 2002 年发表的关于反思的文章

尽管它很受欢迎并且经过了实战考验,但反射有许多缺点。使用反射会造成巨大的性能成本,并且容易在运行时出现类似或引发的错误。反射还可以通过允许类的私有成员访问来破坏封装。TypeNotFoundExceptionMethodNotFoundException

具有功能组合的 AOP

第二种选择涉及使用标准函数组合。这是一个简单的概念,可用于任何支持高阶函数的编程语言。

//LoggingAspect.cs  
public static class LoggingAspect  
{  
  public static void Log(Action func)  
  {  
    Console.WriteLine("User is being created");  
    func();  
    Console.WriteLine("User has been created");  
  }  
}  
  
//Program.cs  
var user = new User();  
var loggedSignUp = (string email, string password) =>  
{  
  LoggingAspect.Log(() => user.SignUp(a,b));  
};  
  
loggedSignUp("example@email.com", "123");

你可以看到我们有两个功能。第一个是原始的,第二个代表我们要添加的。我们创建一个新函数(),它调用原始函数,然后使用组合调用第二个函数。这种模式存在许多变化,但概念保持不变。loggedSignUp

功能组合的简单性消除了对特殊要求的需要,使其成为一种多功能的模式。你可以用 Javascript 或 C# 编写类似的代码,每个人都会立即理解其中的原理。

另一方面,它不如其他选项强大。您无法更改现有代码。您需要做大量的手动工作,有些人甚至可能会质疑它是否是真正的 AOP。

具有手动拦截功能的 AOP

另一种选择是使用我在这篇博文开头讨论过的新拦截器功能。

//User.cs
public class User
{
  public async Task SignUp(string email, string password)
  {
    var newUser = await CreateUser(email, password);
  }
}

//Anywhere in your solution
static class InterceptionExtensions
{
  [InterceptsLocation("""C:\YourFolder\Sample\Interceptors\Program.cs""", 
  line: 4,  character: 13)]
   public static void InterceptorMethod(this User user, 
     string email, string password)
  {
    Console.WriteLine($"User is being created");
    user.SignUp(email, password);
    Console.WriteLine($"User has been created");
  }
}
//Program.cs
var user = new User();
user.SignUp("email@examle.com", "pass");

代码非常脆弱。如果修改变量的名称或位置,它将停止工作。它有效,但我强烈建议不要使用这种模式。也许在极端情况下使用它,但要非常小心。将此类功能合并到您的日常编程中不是一个好主意。

带有源生成器的 AOP — 没有拦截器的版本

让我们尝试使用没有新拦截器功能的源生成器来满足我们的要求。

值得一提的是,有两个 API 可用于源生成器:旧 API 和新的增量源生成器 API。新的增量 API 提供更好的性能,但比旧 API 更复杂。下面提供的示例使用了这个新的增量 API。

为了简化代码,我使用该方法的名称来查找其所有匹配项,并使用常量字符串生成代码。这只是一个例子;不要在生产代码中使用它!在下一节中,我将向您展示一个更健壮的实现。

// The source generator project:
[Generator]
public class SuperSimpleIncrementalSourceGenerator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    context.RegisterSourceOutput(context.CompilationProvider, (ctx, _) =>
    {
      var source = @"// <auto-generated/>
using System;
using System.Runtime.CompilerServices;

namespace SourceGeneratorsExample.Sample.NoInterception; 

public partial class User
{
  partial void InterceptHere(string email, string password) 
  {         
    Console.WriteLine(""User is being created"");
  }
}
";
      ctx.AddSource($@"UserSimpleExtension.g.cs", source);
    });
  }
}


// the client project:
public partial class User
{
  public void SignUp(string email, string password)
  {
    InterceptHere(email, password); 
    var newUser = CreateUser(email, password)
  }
  // the source generator generates the implementation for this method
  partial void InterceptHere(string email, string password);
}


//generated code:
// <auto-generated/>
using System;

namespace SourceGeneratorsExample.Sample.NoInterception; 

public partial class User
{
  partial void InterceptHere(string email, string password) 
  {         
    Console.WriteLine("User is being created");
  }
}

正如你所看到的,源代码生成器只是生成了partial method()的实现。但是,此方法存在几个问题:InterceptHere

  • 您需要使用分部类和方法。
  • 您无法重写任何功能。
  • 您只能将一段代码添加到明确定义的位置。

虽然我已经看到了这种模式的一些用法,但我并不觉得它太吸引人。我相信它只是_证明了_旧版本的 SG 不支持我们的用例。

带源生成器和拦截器的 AOP

让我们看看当我们将源生成器与新的拦截器组合在一起时会发生什么。以下是代码的编辑版本,用于演示源生成器和拦截器如何有效地协同工作。完整的代码在这里

using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace SourceGeneratorsExample;

[Generator(LanguageNames.CSharp)]
public class SourceGeneratorWithInterceptors : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    var providerInvocationExp = context.SyntaxProvider.CreateSyntaxProvider(
    // we are looking only for the member access expressions
    (n, _) => n is MemberAccessExpressionSyntax, 
    (n, cp) =>
    {
      // omitted for brevity...
      // get all information via semantic model
      ...
       });

       var compilationWithProvider = context.CompilationProvider
         .Combine(providerInvocationExp.Collect());

       context.RegisterSourceOutput(compilationWithProvider, (ctx, t) =>
       {
         var foundedMethods = t.Right;
         // filter out methods without an attribute 
         var extensions = foundedMethods.Where...
           .Select(method =>
           {
            // create InterceptsLocation & InterceptorMethod 
            //using information from MethodInfoToIntercept.
             var str = $@"
[System.Runtime.CompilerServices.InterceptsLocation(@""{method.FilePath}"",
  line: {method.Line}, character: {method.Column})]
public static void InterceptorMethod(this {method.ClassNameWithNamespace} obj,
 string email, string password)
{{
  Console.WriteLine($""User is being created"");
  obj.SignUp(email, password);
  Console.WriteLine($""User has been created"");
  return;
}}";
             return str; });                   
             var extensionCode = string.Join("\r\n", extensions);
             // add InterceptsLocationAttribute and 
             //put all generated "interceptors" 
             //to the InterceptionExtensions class
             var source = $@"
// <auto-generated/>
using System;
using System.Runtime.CompilerServices;

namespace System.Runtime.CompilerServices
{{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
sealed class InterceptsLocationAttribute(string filePath, 
int line, int character) : Attribute
{{
}}

static class InterceptionExtensions
{{
{extensionCode}
}}
}}";
    ctx.AddSource("SampleIntercepting.g.cs", source);
    });
  }
}

// A custom attribute to mark the method
public class InterceptAttribute : Attribute
{
}

//User.cs
public class User
{
  public static void Test()
  {
    var user = new User();
    user.SignUp("email", "pass");
  }

  [Intercept]
  public void SignUp(string email, string password)
  {
    var newUser = CreateUser(email, password);
  }  
}

我写了一个比前面的例子更详细的版本,但它仍然不是完全万无一失的。在第一部分中,必须使用 .我收集了有关类、命名空间等的所有信息。在第二部分中,我利用这些信息来创建和_替换_方法调用。Intercept SyntaxProviderInterceptsLocation InterceptorMethod

拦截器与 AOP

该示例演示了使用拦截器的简单 AOP 方案。尽管工作正常,但它仍无法支持标准的 AOP 用例。拦截器的局限性:

写有两种类型

  • 呼叫站点
  • 实现

拦截器只能在调用站点拦截方法。为了说明这种差异,请考虑以下示例:

// User.cs
public class User
{
  // Rewriting of implementation 
  // Interceptor DOESN'T support replacing code here. 
  //You CAN'T replace the entire body of the SignUp method 
  [Intercept] 
  public void SignUp(string email, string password)
  {
  }
}

// Program.cs
var user = new User();
// Call-site interception
// Interceptor DOES support replacing code here.
user.SignUp("email@examle.com", "pass");

局限性相当大。除非在代码中使用方法,否则无法修改方法的行为! 这也意味着,如果你的方法在更多地方被调用,你还需要在所有地方注册拦截器来重写所有的用法。

实际上,拦截器不支持的内容有一_长串_。它仅支持拦截方法,不支持属性。无法拦截构造函数、泛型方法、带有 ref 或 out 参数的方法等。

在 GitHub 的讨论中,这种观点经常传达出当前有限版本的拦截器只是启动了从社区收集反馈的过程我在上一篇文章中讨论的主要构造函数的情况有些相似。

遗憾的是,与其他语言和生态系统不同,C# 目前没有任何可用的原生 AOP 开源库。此外,这种情况似乎不太可能在不久的将来发生变化。

AOP 与 Metalama

在意识到 C# 团队没有计划支持 AOP 后,我搜索了其他选项。其中一个选项是 Metalama 框架。它是一款非常易于使用的商业产品,也是 PostSharp 的继任者。

class User  
{  
  [Log]  
  public async void SignUp(string email, string password)  
  {  
    var user = await CreateUser ...  
  }  
}  
  
//OverrideMethodAspect comes from Metalama  
public class LogAttribute : OverrideMethodAspect  
{  
  public override dynamic? OverrideMethod()  
  {  
    Console.WriteLine("User is being created");  
    var result =  meta.Proceed();  
    Console.WriteLine("User has been created");  
    return result;  
   }  
}  
  
//Program.cs  
var user = new User();  
await user.SignUp("example@mail.com", "123");

这就是您需要的所有代码。您可以轻松更改现有代码,而无需麻烦地使用行、列和源生成器。Metalama 还提供了广泛的功能以及 Visual Studio CodeLens 插件,使重写的代码尽可能流畅地使用。

另一方面,看起来 Metalama 仍然不支持_呼叫站点方面_(即拦截器)。

Metalama 的主要缺点是价格。这对我来说似乎有点高

注意 Metalama 与拦截器:
需要注意的是,Metalama 目前不能与拦截器一起使用。如果你尝试组合它,你会得到编译错误:

Interceptors and Metalama can’t currently be used together.

Metalama 等

如果我没有提到 PostSharp/Metalama 的任何替代品,那将是不公平的。C# 中有多个用于 AOP 的库。值得一提的是 MrAdvice,它仍然很活跃。

据作者介绍:

Mr. Advice 是 PostSharp(仍然更先进)的开源(免费)替代品。

我看过 API,认为你仍然可以用它做很多事情。如果您了解 PostSharp/Metalama,它的 API 看起来很熟悉。

public class User  
{  
    [Log]  
    public void SignUp(string email, string password)  
    {  
    }  
}  
  
public class LogAttribute : Attribute, IMethodAdvice  
{  
    public void Advise(MethodAdviceContext context)  
    {  
        Console.WriteLine("User is being created");  
        context.Proceed();   
        Console.WriteLine("User has been created");  
    }  
}

源生成器改变了游戏规则,并且具有拦截的新功能,它可以让您做以前不可能的事情。但是,请记住,它并不完美,仍然只是实验性的

相关留言评论
昵称:
邮箱:
阅读排行