探索 C# 12 Interceptor 的魔力
Intro
虽然这一特性目前仍然是一个实验性的特性,但是它的功能的强大,而且在 .NET 8 中正式版的代码中已经在使用了,让我觉得它值得分享,让我们一起探索 Interceptor 的魔力吧。
本次分享分为以下四部分
第一部分我们会介绍下什么是 Interceptor
第二部分会介绍为什么要用到 Interceptor
第三部分主要是怎么写 Interceptor
最后会分享一下 Interceptor 在 .NET 8 Minial API 里的实践
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 声明的调用位置
首先 Interceptor 其实就是一个方法,这个方法是在编译时工作的,这个方法的工作方式是声明原方法调用的位置,最终实现的效果是以 Interceptor 方法的调用代替原方法调用。
This substitution occurs by having the interceptor declare the source locations of the calls that it intercepts.
这种替换是通过让拦截器声明它拦截的调用的源位置来实现的,什么意思呢?来看一个最简单的 Interceptor,下面是一个简单的 Interceptor 示例
这个示例是一个拦截 Console.WriteLine
调用的一个 Interceptor ,这个方法的签名和我们实际调用的方法签名是保持一致的
然后方法上有声明一个 InterceptsLocationAttribute
这个声明指定了要拦截方法的位置,包含了要拦截的方法调用代码的文件路径,所在行和是第几个字符是方法调用
我们再来看下反编译之后的代码,来看下编译的过程中发生了什么
这两个分别是反编译之后的 C# 和 IL 代码,从反编译的结果可以看到我们原来代码里的 Console.WriteLine
的调用已经变成了我们 Interceptor 方法的调用了,是不是很神奇,that's amazing
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
这里是一个简单的示例,就是前面我们看到的一个图,就是一个 Interceptor 的定义
当前这一特性还是处于一个实验性的状态,所以目前 InterceptsLocationAttribute
这个 Attribute
并未定义在 .NET 框架上,所以我们需要自己在代码里定义这个 Attribute
,我们可以使用 file
来避免和其他的定义发生冲突
并且需要在项目文件里通过 InterceptorsPreviewNamespaces
指定我们 Interceptor 所在的命名空间,如果在网上搜索 Interceptor 的话,可能有的文章或视频使用的是 <Features>InterceptorsPreview</Features>
这是在之前的版本中,从 .NET 8 RC2 版本开始,变成了指定命名空间的方式,如下:
Must && Limitations
介绍完最基本的一个 Interceptor 让我们来谈谈它的一些使用条件和限制,并不是所有的地方都适合使用它
首先 Interceptor 只支持对普通的方法调用(Ordinary
)进行拦截,对于构造器、属性、自定义操作符、委托以及本地函数都是不支持的
其次它只能支持对于本地代码的拦截,不支持类库内部的调用拦截,类库的代码已经是 dll 了,已经不需要再编译了,我们也没有办法指定类库内部方法调用的源位置
并且 Interceptor 方法的签名要拦截的方法调用对应的方法的签名保持一致,包括返回值和方法参数,参数名没有关系
最后 Interceptor 应该定义在非泛型类型中,并且目前只支持 C#,对于 VB 是不支持的
Intercept Instance method
前面的 Interceptor 示例是拦截了 Console.WriteLine
这个静态方法的调用,接着我们看一些别的方法的拦截,先来看下这个实例方法的调用,
下面这张图是要拦截的方法示例,我们要拦截的就是下面这个 Test
类型中的 Hello
方法
这个是 Interceptor 的方法定义,对于实例方法的拦截,我们需要定义一个扩展方法,方法的签名仍需和原来的方法保持一致
Intercept extension method
我们再来看一个对扩展方法的拦截调用,这里这个示例是把原来的 AgePlusPlus
扩展方法里的实现从 age++
改成了 ++age
,原来输出的结果是 10, 拦截之后输出的结果是 11
扩展方法的 Interceptor 方法也应该是一个扩展方法,并且方法签名同样需要和原来的方法保持一致
Multiple Interception
目前同一位置的方法调用只能被拦截一次,如果出现了多次拦截编译器会报错,目前不支持多个 Interceptor 拦截同一个调用,但是同一个 Interceptor 是可以拦截多个调用位置的,注意前面 InterceptsLocationAttribute
声明的话会注意到有一个 AllowMultiple=true
,这里这个示例就是一个 Interceptor 拦截多个调用位置的示例
输出结果如下:
Source Generator Integration
前面的简单使用,都是直接写死的位置,但是很难维护,一旦源位置变了就不能行了
比如说我们第一个示例我们如果多加了两个空格,就会发现编译就挂了
实际使用我们可以结合 Source Generator 一起使用,Source Generator 是 .NET 5 开始引入的一个新的特性, .NET 6 支持了 Incremental Source Generator,改进和优化了原来 Generator 的性能
我们可以借助 Source Generator 特性在编译的时候获取到编译信息,并且可以新增更多的代码参与到编译的过程中,产生最终的编译结果,我们的 Interceptor 和 source generator 结合的时候就是作为补充的 source code 参与到编译中去
我们可以根据编译信息中的方法调用信息拿到对应的源方法调用的位置来生成我们的 InterceptsLocationAttribute
这里的这个方法就是根据一个调用操作找到它对应的文件位置、行以及字符信息,需要注意的是:GetLineSpan()
方法返回的 StartLinePosition
信息中返回的是 Line
和 Character
是基于 0 的,而我们 InterceptsLocationAttribute
中的 line
和 character
是基于 1 的,所以这里的代码会有一个 + 1
我们看一个简单的示例,这里是一个简单的 source generator 和 Interceptor 集成的示例,这里在 predict 里做了筛选,找出来 InterceptableMethod
方法的调用,然后转化成了一个 InterceptInvocation
,里面包含了我们源方法的调用位置信息,有了源调用信息之后我们就可以生成代码了
这里的代码示例就是我们根据源调用的信息生成我们的 Interceptor 源代码, 这里在原来方法的调用之前和之后分别输出了一个 console log
最后把我们的 Interceptor 和 InterceptsLocationAttribute
添加到 source 中去,这样我们的 Interceptor 就会一起参与编译了,对应的源方法调用的位置信息也不需要我们手动填了
注意的话会发现,这里的 InterceptsLocationAttribute
的修饰符是一个 file
,表示这个类型只在当前文件可以访问,避免有多个 generator 的时候和别的定义时发生冲突,都定义成 file
就不会发生冲突了
前面的示例比较简单,只根据方法名称做了一个过滤,实际应用的话我们可能需要做更多的过滤比如说方法所在的应用程序集、命名空间以及类型,甚至可能需要根据方法参数来生成,我们可以通过 invocationOperation.TargetMethod
来获取更多 Method
相关的信息,比如源方法所在的程序集、命名空间、类型名称等等,这里是 InterceptInvocation
中的部分代码
这里有一个根据 ContainingTypeName
和 MethodName
两个属性来做 GroupBy
根据不同的方法和类型生成不同的 Interceptor 代码,再在方法声明前添加调用的源位置信息,我们可以根据实际需要做一些调整
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 版本,之后我们来反编译看一下结果
这里第一个版本是非 AOT 版本,第二个版本是 AOT 版本
第一个版本中 MapGet
对应的是 Microsoft.AspNetCore.Builder.EndpointRouteBuilderExtensions
中的 MapGet
扩展方法
而第二个版本中 MapGet
反编译之后变成了 MapGet0
,对应的方法是 Microsoft.AspNetCore.Http.Generated.xxx
的一个扩展方法,这个是动态生成的一个类型,我们在反编译的文件里可以找到它
不想反编译的话,我们可以使用 dotnet build -p PublishAot=true -p EmitCompilerGeneratedFiles=true
来生成编译器生成的代码,在项目的 obj
目录下可以找到生成的代码,可以看到类似下面这样的代码
从生成的代码可以清晰的看到也是用到了 Interceptor,也是声明了 InterceptsLocationAttribute
并且在下面的方法有在使用这个 attribute 来声明调用的位置,而这个位置就是我们源代码里的 MapGet
对应的位置
我们再来看一个 library 内部代码的会不会发生变化,前面其实我们已经提到了答案,这里可以作为一个验证
来写一个简单的 API 来测试一下,这里使用了一个 library,MapRuntimeInfo
就是 library 中的代码,build 之后我们反编译看下 Map
已经变成了 Map0
已经是 Interceptor 的代码了,而且 MapRuntimeInfo
内部的 MapGet
还是我们原来的 MapGet
并没有被拦截,说明外部引用 library 的内部调用是不会被拦截的,这也说明了一个问题就是还有非常非常多的 library 需要很大的努力才能达到 AOT 友好的目标,这需要我们大家一起努力了
最后我们来测试一下 .NET 8 AOT 的,我这个示例是在 Windows 上跑的,publish 的 AOT,然后再跑起来这个 exe,最后请求一下我们的 AOT 的 API,可以看到 HTTP response 是 200 是成功的,再看一下文件大小,此时的文件大小只有 6.6 M左右,不到 7M,powered by AOT, powered by Interceptor ~~
More
最后有一些更多 Interceptor 相关内容的参考链接分享给大家,有需要的话可以进一步学习和探索 Interceptor 的功能~
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/
-
技术群:添加小编微信并备注进群
小编微信:mm1552923
公众号:dotNet编程大全