初识 C# Interceptors

Roslyn analyzer, interceptors, and compile-time jsonpath interpreter

2026 年来讲这个特性可能有点火星了,不过 C# Interceptors 确实给了我一些惊喜,故想写篇文章介绍一下。

Interceptors 在 .NET 8 上面是实验性功能,.NET 9.0.2xx 之后成为稳定性功能。 在 .NET 10 LTS 之后的版本开始流行使用这个特性。 一上来就看 Interceptors 是比较难以理解的,它通过 Analyzer hook 进编译管线,这里就先从 Analyzer 讲起。

为什么要用它?

我们使用编译器魔法,本质上就是元编程,可以将反射开销全部优化掉,速度变快,也能 AOT (Ahead of time) 友好,AOT 是近年 C# 快速发展的一个特性。

Roslyn Analyzer

理解 C# 编译器管线之前,需要知道 Roslyn 起到一个什么样的作用。

类似于 Python ast,它们起到的作用都是 解析 (Parse)分析 (Analysis) 语法树。 Parse 的部分大差不差,在分析部分 Python ast 仅仅提供了一个简易的 NodeVisitor,而 Roslyn 提供了一整套的 Semantic Model,无需像 Python 那样写复杂的代码分析。

Semantic Model 就是一个获取类型推断信息的语义模型,最直观的就是 IDE 的 Go to Definition。 Python 写 Semantic Model 的痛苦程度看看 mypy 就知道了(以及我写的 nb-autodoc)。 Roslyn 相当于是微软提供的轮椅,它包含了一整套分析静态代码产生完整类型推断的工具链,这些信息都是和 Ast 相关联的,Linter 可以从中拿到类型推断,产生分析结果,最终反馈在源码的具体每个字符上。

现代,将各种编译器诊断接入到 IDE 用 LSP (Language Server Protocol) 已然是标准做法,而各种不同编程语言如何编写 Linter 则产生了非常多的方法。

  • Go (gopls)、C++ (clangd) 等用的是官方编译器 API。
  • Python (pyright) 则比较别具一格地用 Typescript 来解析 Python 的源码。
  • TypeScript (tsc/eslint) 则是类型检查 -> 类型擦除 -> JS Linter。

C# 是其中最灵活的一种插件式系统,它定义了一种叫做 Analyzer 的项目类型,通过将 Analyzer 定义在待分析项目依赖里面,编译器进程去读取并加载,无需用户去引入第三方工具、兼容 IDE 什么的。

从 IDE 的角度来看执行流程:

1
2
3
4
5
6
7
IDE
Microsoft.CodeAnalysis.LanguageServer.dll
Project Analyzers (.csproj)
Diagnostics

需要注意的是 Roslyn 和 Analyzer 本身都是一个包,它们的宿主是编译器。 为了兼容各个版本 dotnet SDK 或者甚至不同编译器的 TargetFrameworks 支持列表,Analyzer 项目版本必须要和各种编译器进程兼容,即公认 netstandard2.0

Roslyn Analyzer 相当于是一个用 C# 实现的 C# 分析器罢了,本质上也可以用其他语言来实现,例如 F#,前提是兼容 netstandard2.0 标准的 dll 程序集。

回顾一下 C# 进行一次编译的时候,输入是多个源代码文件 + .csproj package reference + project reference + OutputType 等多个编译选项,输出就是 <assemblyName>.dll

加入代码诊断 Diagnostics Analyzer 的编译流程大概上就是这样:

1
2
3
4
5
6
7
AST → Semantic Model → Analyzer -> Diagnostics
Compile (Symbols + Definitions)
Lowering + Optimization
Assembly (.dll)

Source Generator

Source Generator (SG) 大约在 2020 年推出,一种在不修改源码基础上新增代码的方式,在最终编译的时候才将这些新文件一起编译进去。

SG 一般会配合 attribute / partial 这两个 C# 特性一起用,例如先用声明式写一段代码:

1
2
3
4
5
6
public partial class Foo
{
    [Name("Guru")]
    [Say("Hello!")]
    public partial void Bar();
}

通过在项目定义一个 SG Analyzer(这个具体怎么写就不展开了)并开启 <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> 调试选项,就会在 obj/Debug/net10.0/generated 下面看到一个新文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Same namespace
partial class Foo
{
    partial void Bar()  // behind implementation
    {
        var name = "Guru";
        var saying = "Hello!";
        System.Console.WriteLine($"{name} says {saying}");
    }
}

SG 的特点是在完全不改变用户代码语义(包括类型检查)的情况下,用 partial / extension method 等方法去拓展源码。

SG 也是 Analyzer,上面说的诊断 Analyzer 继承 DiagnosticAnalyzer 基类,而 SG 基于 IIncrementalGenerator 接口实现。

加入了 Source Generator 的编译流程是这样的:

1
2
3
4
5
6
7
AST → Semantic Model → Analyzer (Source Generator) -> New Files
Compile (Symbols + Definitions + New Files)
Lowering + Optimization
Assembly (.dll)

Interceptors

Interceptors 大约在 2025 年底成为稳定特性,可以拦截函数调用并替换为另一个调用。

理解了 Analyzer 之后,再来阅读 Interceptors 官方文档 就很清晰易懂了。 Interceptors 本质和 SG 一样,都是基于 IIncrementalGenerator 接口实现,都是 Generate New Files。

Interceptors 特性就是不需要通过魔改代码,实现拦截并替换项目内任何地方的 Invocation(函数调用)。

原理:Interceptors 和 SG 同样是产生新文件,区别在于 Interceptors 扫描代码时可以根据 Invocation 创建一个拦截点,将其重定向到新文件里的新函数,交给 Compiler 去处理拦截点。

编译流程在 Compile 之后多了一个 Interceptors Replace 的步骤,指导 Compiler 去接管 指定位置 代码的函数调用:

1
2
3
4
5
6
7
8
9
AST → Semantic Model → Analyzer (Source Generator) -> New Files
Compile (Symbols + Definitions + New Files)
Interceptors Replace
Lowering + Optimization
Assembly (.dll)

文档介绍的 GetInterceptableLocation(this SemanticModel, InvocationExpressionSyntax, CancellationToken) 便是用于生成位置编码的函数。

整体看下来还是比较简单的,做 ast 本身就有很多 polyfill 这里就不讲了,实现的时候就知道了。

Iyume.JsonPath 是我根据 Interceptors 特性 vibe coding 出来的,可以在编译时就确定 JsonPath 的路径生成静态代码,感兴趣的也可以看看。 (有些细节还没空去调整,比如生成的 Location 与函数如何复用)

CC BY-NC-SA 4.0 License