探索 C# 12 Interceptor 的魔力

探索 C# 12 Interceptor 的魔力

Intro

虽然这一特性目前仍然是一个实验性的特性,但是它的功能的强大,而且在 .NET 8 中正式版的代码中已经在使用了,让我觉得它值得分享,让我们一起探索 Interceptor 的魔力吧。

本次分享分为以下四部分

第一部分我们会介绍下什么是 Interceptor

第二部分会介绍为什么要用到 Interceptor

第三部分主要是怎么写 Interceptor

最后会分享一下 Interceptor 在 .NET 8 Minial API 里的实践

c286f7a16240eef4df35809580bd3454.png

Content

What's Interceptor

那么什么是 Interceptor 呢,微软 Roslyn 仓库的文档里提供了一个说明感觉还是比较准确和概括的

An interceptor is a method which can declaratively substitute a call to an interceptable method with a call to itself at compile time.

Interceptor 是一个方法,可以作为要 Interceptor 的方法的替代,在编译时把对应方法的调用变成对 Interceptor 方法的调用,并且拦截只发生在 Interceptor 声明的调用位置

4a159cf3afc4c6b805dff6942f0e3e81.png

What's Interceptor

首先 Interceptor 其实就是一个方法,这个方法是在编译时工作的,这个方法的工作方式是声明原方法调用的位置,最终实现的效果是以 Interceptor 方法的调用代替原方法调用。

This substitution occurs by having the interceptor declare the source locations of the calls that it intercepts.

这种替换是通过让拦截器声明它拦截的调用的源位置来实现的,什么意思呢?来看一个最简单的 Interceptor,下面是一个简单的 Interceptor 示例

ff2435ff8754273865b02a347f1fac75.png

Interceptor Sample

这个示例是一个拦截 Console.WriteLine 调用的一个 Interceptor ,这个方法的签名和我们实际调用的方法签名是保持一致的

然后方法上有声明一个 InterceptsLocationAttribute 这个声明指定了要拦截方法的位置,包含了要拦截的方法调用代码的文件路径,所在行和是第几个字符是方法调用

我们再来看下反编译之后的代码,来看下编译的过程中发生了什么

fa17513efa779d594ecbf9b1ef11f550.png

13d6bf4b46e7ff38a8722d8f46f23aa9.png

这两个分别是反编译之后的 C# 和 IL 代码,从反编译的结果可以看到我们原来代码里的 Console.WriteLine

的调用已经变成了我们 Interceptor 方法的调用了,是不是很神奇,that's amazing

Why Interceptor

我们为什么要引入这一特性呢?

4a16debd4c26f4d12332d25a4aed15bc.png

Why Interceptor

首先我们可以借助这一特性来实现某些方法的性能改进,比如我们引用了某一个类库的中的一个方法,但是我们

知道里面有一个 Thread.Sleep(TimeSpan.FromSeconds(10))  的 delay,我们可以自己写一个实现将 delay 去掉,性能瞬间提升

第二个方面就是 AOT,在 .NET 8 Minimal API AOT 的支持中 RequestDelegate 就是基于 Interceptor 这一特性进行了重写了一套,在 AOT 的编译发布的时候会使用基于 Interceptor 实现的代码,这个我们最后会再看下,这里不再花太多时间介绍了,主要就是我们可以借助编译时信息来代替反射等实现来实现 AOT 友好的目标

第三个方面是我们可以做一些临时测试,针对一些临时起意的一些不太确定的性能优化或重构,我们可以做一些不改变原有代码的临时测试,确定有优化效果之后再做进一步的代码修改或更进一步的测试

最后是我们可以实现 AOP 的需求,可以在某些调用之前或者之后添加一些自定义的处理逻辑

How to use Interceptor

Interceptor Sample

介绍完什么是 Interceptor 以及为什么要使用,我们来看看如何在项目里定义和使用 Interceptor

105689869aed5a6d89df5ff80925777e.png

Interceptor Sample

这里是一个简单的示例,就是前面我们看到的一个图,就是一个 Interceptor 的定义

b9368c9bf2942a444efea0cdd3e8be37.png

InterceptsLocationAttribute

当前这一特性还是处于一个实验性的状态,所以目前 InterceptsLocationAttribute 这个 Attribute 并未定义在 .NET 框架上,所以我们需要自己在代码里定义这个 Attribute,我们可以使用 file 来避免和其他的定义发生冲突

并且需要在项目文件里通过 InterceptorsPreviewNamespaces 指定我们 Interceptor 所在的命名空间,如果在网上搜索 Interceptor 的话,可能有的文章或视频使用的是 <Features>InterceptorsPreview</Features>

这是在之前的版本中,从 .NET 8 RC2 版本开始,变成了指定命名空间的方式,如下:

11d5544e365e475909fef3f54b02358d.png

InterceptorsPreviewNamespace

Must && Limitations

介绍完最基本的一个 Interceptor 让我们来谈谈它的一些使用条件和限制,并不是所有的地方都适合使用它

ae662cf085dec5a2da3a4de9a7b1725a.png

Limitation

首先 Interceptor 只支持对普通的方法调用(Ordinary)进行拦截,对于构造器、属性、自定义操作符、委托以及本地函数都是不支持的

380d7f38ae0f517c6a9eb474b6972303.png

not-supported method types

其次它只能支持对于本地代码的拦截,不支持类库内部的调用拦截,类库的代码已经是 dll 了,已经不需要再编译了,我们也没有办法指定类库内部方法调用的源位置

并且 Interceptor 方法的签名要拦截的方法调用对应的方法的签名保持一致,包括返回值和方法参数,参数名没有关系

最后 Interceptor 应该定义在非泛型类型中,并且目前只支持 C#,对于 VB 是不支持的

Intercept Instance method

前面的 Interceptor 示例是拦截了 Console.WriteLine 这个静态方法的调用,接着我们看一些别的方法的拦截,先来看下这个实例方法的调用,

下面这张图是要拦截的方法示例,我们要拦截的就是下面这个 Test 类型中的 Hello 方法

8799afcf02471deb7a5f89b7765cb9d5.png

Instance method definition

ebb33c2d3198058cde368c425d6dd6af.png

Instance method interceptor

这个是 Interceptor 的方法定义,对于实例方法的拦截,我们需要定义一个扩展方法,方法的签名仍需和原来的方法保持一致

Intercept extension method

我们再来看一个对扩展方法的拦截调用,这里这个示例是把原来的 AgePlusPlus 扩展方法里的实现从 age++ 改成了 ++age,原来输出的结果是 10, 拦截之后输出的结果是 11

1f5a46559234a381103c2ce7728fc9ee.png

Extension method definition

3f138e8dbfda049142d6888b1eeb5c65.png

Extension method interceptor

扩展方法的 Interceptor 方法也应该是一个扩展方法,并且方法签名同样需要和原来的方法保持一致

Multiple Interception

目前同一位置的方法调用只能被拦截一次,如果出现了多次拦截编译器会报错,目前不支持多个 Interceptor 拦截同一个调用,但是同一个 Interceptor 是可以拦截多个调用位置的,注意前面 InterceptsLocationAttribute  声明的话会注意到有一个 AllowMultiple=true,这里这个示例就是一个 Interceptor 拦截多个调用位置的示例

e901afb43bda70dceefad809c5970960.png

multi interception sample

输出结果如下:

4b92313f371f8d630125c5b3662da86d.png

multi interception output

Source Generator Integration

前面的简单使用,都是直接写死的位置,但是很难维护,一旦源位置变了就不能行了

比如说我们第一个示例我们如果多加了两个空格,就会发现编译就挂了

47a9b0f7c8db5f6b219482015952511f.png

Hello World with spacing

8c53508e2e686583dbbbbecdca67aac1.png

Interceptor error

实际使用我们可以结合 Source Generator 一起使用,Source Generator 是 .NET 5 开始引入的一个新的特性, .NET 6 支持了 Incremental Source Generator,改进和优化了原来 Generator 的性能

我们可以借助 Source Generator 特性在编译的时候获取到编译信息,并且可以新增更多的代码参与到编译的过程中,产生最终的编译结果,我们的 Interceptor 和 source generator 结合的时候就是作为补充的 source code 参与到编译中去

66feecfffd9f72146cbc438480d85097.png

source generator

我们可以根据编译信息中的方法调用信息拿到对应的源方法调用的位置来生成我们的 InterceptsLocationAttribute

这里的这个方法就是根据一个调用操作找到它对应的文件位置、行以及字符信息,需要注意的是:GetLineSpan() 方法返回的 StartLinePosition 信息中返回的是 LineCharacter 是基于 0 的,而我们 InterceptsLocationAttribute 中的 linecharacter 是基于 1 的,所以这里的代码会有一个 + 1

b5f465ad95c3485fc514ec91e4d63fce.png

afc2b7dcbd19981157487d748ce9ed6f.png

我们看一个简单的示例,这里是一个简单的 source generator 和 Interceptor 集成的示例,这里在 predict 里做了筛选,找出来 InterceptableMethod 方法的调用,然后转化成了一个 InterceptInvocation ,里面包含了我们源方法的调用位置信息,有了源调用信息之后我们就可以生成代码了

9b861427ffcd4a790ad14a05136446e8.png

logging interceptor sample code 1

a8fbd64ac8c3754a66c612344caf9579.png

logging interceptor sample code 2

这里的代码示例就是我们根据源调用的信息生成我们的 Interceptor 源代码, 这里在原来方法的调用之前和之后分别输出了一个 console log

b1a844b5aacea5b6d32eae50f0b4fd16.png

logging interceptor sample code 3

最后把我们的 Interceptor 和 InterceptsLocationAttribute 添加到 source 中去,这样我们的 Interceptor 就会一起参与编译了,对应的源方法调用的位置信息也不需要我们手动填了

注意的话会发现,这里的 InterceptsLocationAttribute 的修饰符是一个 file,表示这个类型只在当前文件可以访问,避免有多个 generator 的时候和别的定义时发生冲突,都定义成 file 就不会发生冲突了

前面的示例比较简单,只根据方法名称做了一个过滤,实际应用的话我们可能需要做更多的过滤比如说方法所在的应用程序集、命名空间以及类型,甚至可能需要根据方法参数来生成,我们可以通过 invocationOperation.TargetMethod 来获取更多 Method 相关的信息,比如源方法所在的程序集、命名空间、类型名称等等,这里是 InterceptInvocation 中的部分代码

c019fcbdccded3c73d653054ca19242c.png

InterceptInvocation screenshot

这里有一个根据 ContainingTypeNameMethodName 两个属性来做 GroupBy 根据不同的方法和类型生成不同的 Interceptor 代码,再在方法声明前添加调用的源位置信息,我们可以根据实际需要做一些调整

d81cd486f89a63f8c79957b87a870ceb.png

Interceptor sample 2

Minimal API AOT

最后我们来看下 .NET 8  Minimal API 中是如何应用 Interceptor 的,我们写了一个简单 Web API 项目来测试一下 .NET 8 的 AOT,这里使用了 WebApplication.CreateEmptyBuilder(), 然后我们自己来注册需要的服务来尽可能地保证我们的项目不引入过多的依赖,从而使得 app 尽可能的小

代码如下:

var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions());
builder.Services.AddRoutingCore();
builder.WebHost.UseKestrelCore();
// builder.Logging.AddConsole();

var app = builder.Build();

app.UseRouting();
app.UseEndpoints(endpoints => 
{
    endpoints.MapGet("/", () => "Hello World");
});

app.Run();

我们使用 dotnet build -p PublishAot=false 来 build 非 AOT 的版本

使用 dotnet build -p PublishAot=true 来 build AOT 版本,之后我们来反编译看一下结果

425d9b3d47735d0f232ae9dc2e957d12.png

非 AOT 版本

cef0e0ec94123937db34068d85248ad2.png

AOT 版本

这里第一个版本是非 AOT 版本,第二个版本是 AOT 版本

第一个版本中 MapGet 对应的是 Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions 中的 MapGet 扩展方法

而第二个版本中 MapGet 反编译之后变成了 MapGet0,对应的方法是 Microsoft.AspNetCore.Http.Generated.xxx 的一个扩展方法,这个是动态生成的一个类型,我们在反编译的文件里可以找到它

不想反编译的话,我们可以使用 dotnet build -p PublishAot=true -p EmitCompilerGeneratedFiles=true 来生成编译器生成的代码,在项目的 obj 目录下可以找到生成的代码,可以看到类似下面这样的代码

b5c6ef7bd91bd43fc26c86098e1dfe48.png

Minimal API Generated code

从生成的代码可以清晰的看到也是用到了 Interceptor,也是声明了 InterceptsLocationAttribute 并且在下面的方法有在使用这个 attribute 来声明调用的位置,而这个位置就是我们源代码里的 MapGet 对应的位置

我们再来看一个 library 内部代码的会不会发生变化,前面其实我们已经提到了答案,这里可以作为一个验证

来写一个简单的 API 来测试一下,这里使用了一个 library,MapRuntimeInfo 就是 library 中的代码,build 之后我们反编译看下 Map 已经变成了 Map0 已经是 Interceptor 的代码了,而且 MapRuntimeInfo 内部的 MapGet 还是我们原来的 MapGet 并没有被拦截,说明外部引用 library 的内部调用是不会被拦截的,这也说明了一个问题就是还有非常非常多的 library 需要很大的努力才能达到 AOT 友好的目标,这需要我们大家一起努力了

c82ccc0e7c9b23f0cd2df726b02df134.png

Minimal API Sample 2

84dcdbabd228ef1a49bc26d314a61b67.png

MinimalAPI Sample 2 de-compile result

e4847a5440c27f99f27420919d57493f.png

MapRuntimeInfo de-compile result

最后我们来测试一下 .NET 8 AOT 的,我这个示例是在 Windows 上跑的,publish 的 AOT,然后再跑起来这个 exe,最后请求一下我们的 AOT 的 API,可以看到 HTTP response 是 200 是成功的,再看一下文件大小,此时的文件大小只有 6.6 M左右,不到 7M,powered by AOT, powered by Interceptor ~~

55bd657579d6e3537e030b662d69f6c6.png

Minimal API AOT test

More

最后有一些更多 Interceptor 相关内容的参考链接分享给大家,有需要的话可以进一步学习和探索 Interceptor 的功能~

de97e3d2403b4c89f792c955470e5641.png

Thank You

References

  • https://github.com/dotnet/roslyn/blob/main/docs/features/interceptors.md

  • https://github.com/dotnet/csharplang/issues/7009

  • https://github.com/WeihanLi/SamplesInPractice/blob/main/CSharp12Sample/InterceptorSample.cs

  • https://github.com/WeihanLi/SamplesInPractice/blob/main/InterceptorSamples

  • https://andrewlock.net/exploring-the-dotnet-8-preview-changing-method-calls-with-interceptors/

  • https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http.Extensions/gen/RequestDelegateGenerator.cs

  • https://github.com/DapperLib/DapperAOT

  • https://code-maze.com/how-to-use-interceptors-in-c-12/

  • C# 12 中的 Interceptor 特性

  • 借助 Interceptor 实现拦截 IServiceProvider.CreateScope

-

技术群:添加小编微信并备注进群

小编微信:mm1552923   

公众号:dotNet编程大全    

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值