Roslyn 源生成器与预处理编程(一)

发表于 2026-02-09 20:03 2208 字 12 min read

ruattd avatar

ruattd

🍰 / .NET / BE / INTJ / ACG / 🍏 / 米游玩家 / 下落式狂魔

昨晚与 Roslyn 源生成器大战三百回合,做了一个基于元注解的源生成器 IoC(inversion of control, 控制反转)实现。在做的过程中基本把 Roslyn 源生成器的大部分特性和坑踩了一遍,遂决定写一系列文章系统介绍一下源生成器的基本用法,预处理编程的思想,以及如何运用它们解决项目的实际问题。 这一篇是这一系列的第一篇文章,简单介绍一下什么是源生成器以及为什么我们要用它。...

前言

昨晚与 Roslyn 源生成器大战三百回合,做了一个基于元注解的源生成器 IoC(inversion of control, 控制反转)实现。在做的过程中基本把 Roslyn 源生成器的大部分特性和坑踩了一遍,遂决定写一系列文章系统介绍一下源生成器的基本用法,预处理编程的思想,以及如何运用它们解决项目的实际问题。

这一篇是这一系列的第一篇文章,简单介绍一下什么是源生成器以及为什么我们要用它。

何为源生成器

源生成器,何意味,能帮我写代码吗?

正如它的名字 source generator(源代码生成器),这是一个在已有源代码的基础上根据各种定义好的规则生成源代码的东西。它并不能完全帮你写代码(怎么可能呢,连 AI 都代替不了程序员),但是可以减少你的一些重复、无聊的工作,转而让注意力集中在更核心、更重要的功能实现上。

一个简单的源生成器
using System;
// ...
// 此处省略亿点 using

namespace PCL.Core.SourceGenerators;

[Generator(LanguageNames.CSharp)]
public class EnvironmentInteropGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var secretProvider = context.CompilationProvider.Select(static (_, _) =>
        {
#pragma warning disable RS1035
            var envs = Environment.GetEnvironmentVariables();
#pragma warning restore RS1035
            var secretPairs = envs.Contains("PCL_WRITE_SECRET") ? (
                from key in (
                    from key in envs.Keys.Cast<string>()
                    where !string.IsNullOrWhiteSpace(key) && key.StartsWith("PCL_") && key != "PCL_WRITE_SECRET"
                    select key
                )
                let value = envs[key]?.ToString()
                where !string.IsNullOrWhiteSpace(value)
                select (key.Substring(4), value)
            ) : [];
            return secretPairs;
        });

        // 注册源代码输出
        context.RegisterSourceOutput(secretProvider, _Execute);
    }

    private static void _Execute(SourceProductionContext context, IEnumerable<(string, string)> secretPairs)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated />");
        sb.AppendLine("// 此文件由 Source Generator 自动生成,请勿手动修改");
        sb.AppendLine();
        sb.AppendLine("#nullable enable");
        sb.AppendLine();
        sb.AppendLine("namespace PCL.Core.Utils.OS;");
        sb.AppendLine();
        sb.AppendLine("partial class EnvironmentInterop");
        sb.AppendLine("{");
        sb.AppendLine("    private static readonly System.Collections.Generic.Dictionary<string, string?> SecretDictionary = new()");
        sb.AppendLine("    {");

        foreach (var (key, value) in secretPairs)
            sb.AppendLine($"        [\"{key}\"] = {_ToVerbatimString(value)},");

        sb.AppendLine("    };");
        sb.AppendLine("}");

        context.AddSource("EnvironmentInterop.g.cs", sb.ToString());
    }

    private static string _ToVerbatimString(string text)
    {
        return "@\"" + text.Replace("\"", "\"\"") + "\"";
    }
}

这段代码来自 PCL.Core.SourceGenerators,作用是读取环境变量中的 secrets 并生成一个字典在代码中存放它们。这个源生成器为 CI 添加了一个方便快捷的内嵌 secrets 途径,省去了原本写在 bash 脚本中大量重复的替换逻辑。

不要被这段看似很复杂的 Initialize() 逻辑和成吨的 StringBuilder 调用吓到,有关源生成器的用法会在这一系列之后的文章中具体讲述。

稍观察下代码不难发现,源生成器实际上就是一组跑在 Roslyn Analyzer 中的程序(有关 Roslyn 是什么下文会提到),它会从项目源代码或是任何它能触及的环境中收集必要的信息,然后根据这些信息生成新的代码,最后新的代码会参与到整个项目的编译中,与项目原本的代码共同成为程序集的一部分。

“提前处理”的思想

预处理编程

事实上,这类由编译器或构建系统相关组件来事先对代码动点手脚的编程思想,很多人应该并不陌生。

早在 C 语言中,就存在像是宏定义、宏展开这样迷倒众人挥之不去的蜜汁特性,这实际上就是预处理编程,或者另一个可能更多人熟悉的名字:宏编程

预处理编程即尽可能将编译期能做的事情提前到编译期来做,而运行期只需要去调用编译期处理好的现成产物,无需即时进行处理工作,以此在某些方面极大提升程序的运行性能。

Roslyn 源生成器并不完全是预处理编程,因为它不对已有代码进行任何修改,仅仅是生成新的代码。但配合 partial 这样的语言特性,它也一样可以做到为已存在的声明填充具体的实现,这从某种意义上讲也算是修改代码了。

那什么场景能用到预处理编程呢?

场景需求

我们从项目开发的一个经典需求——控制反转(即文初提到的 IoC)说起。

控制反转要求我们事先写一个组件,例如一个类或一个方法,通过一些标记手段(例如 attribute)让 bootstrap 组件或固定的服务项自动发现这个组件(收集依赖),并在合适的地方使用它。这与传统的由我们手动向服务项注册组件的流程恰恰是相反的,它不再需要“调用注册方法”这一过程,将控制权转交给服务项,也就少了对服务项的直接依赖,因此极大程度上降低了代码的耦合度,可以明显提升项目可维护性。

最典型的控制反转实现就是依赖注入(dependency injection)模式,通过声明依赖、收集依赖、注入依赖三大要素,可以直接砍掉两个模块间的依赖关系,实现解耦。

依靠依赖注入模式工作的标本就是 Spring Boot,传说中的“面向注解编程”也是由其首创的。Spring Boot 的依赖注入依靠启动时对整个字节码的深度扫描(收集依赖),通过 JVM 的运行时反射特性完成注入过程。

很显然,这种工作方式对程序运行速度尤其是启动速度有非常大的影响——每次启动都需要把整个 JAR 包的内容扫一遍,包内容越多,这个过程就越慢,且运行时反射实现的注入本身也会拖慢速度,由反射执行的代码必然不如直接调用效率更高。因此,Spring Boot 几乎只会在一些大型服务端看到,就是由于它实在太重太慢了,任何轻量服务和客户端开发几乎都不可能用它。

那么有没有什么办法可以解决这个性能问题呢?

有的兄弟,有的。我们不妨从构建工具的角度,用预处理思想看看。

引入源生成器

在编译期,构建工具直接面对源代码,很显然它能得到的信息比运行期多得多,且编译期通常不怎么需要考虑性能问题——本身编译就已经是个很吃性能的活了。那 Spring Boot 在运行期对字节码进行深度扫描,为什么不能把这一过程提前到编译期进行呢?

很好,思路有了:在构建工具中添加一个小插件,编译期预先收集所有依赖,然后把它硬编码到编译产物里,运行的时候直接调用就好了。

这个插件是什么呢,很显然——源生成器。

源生成器与这种预处理编程思想在 .NET 各类库中已经得到广泛应用,例如之前一篇文章提到的 GeneratedRegex 特性,就是利用源生成器将运行期的正则表达式翻译提前到编译期。这样的用法还有不少,像是 CommunityToolkit.MvvmObservableObject 自动生成可观察属性,节省大量手动构建事件的代码;标准库的 LibraryImport 预处理 unmanaged 内存 marshal 过程,提升运行期的 P/Invoke 初始化性能,等等。

简要工作流程

从上文其实已经可以比较清晰地看出源生成器产出代码的简要流程了,这里总结一下。

  1. 代码分析工具启动,加载项目引用的源生成器
  2. 源生成器初始化,定义信息收集和筛选流程,注册代码生成上下文
  3. 监视代码更改
  4. 分析代码,收集信息,并生成需要的内容
  5. 将生成的内容传给代码分析工具,经由该工具传给具体需求方,如 IDE 或构建工具
  6. 重复步骤 3 ~ 6

上述流程中的“代码分析工具”即 Roslyn,学名 .NET 编译器平台,是微软为 .NET 体系打造的一组编译与代码分析工具库。简单来讲,它就是 .NET SDK 的一部分,专门负责编译和分析代码。

结语

本文讲述了 Roslyn 源生成器的概念、预处理编程的思想及源生成器的简要工作流程。

那么如何自己写一个源生成器呢?

同时,源生成器在处理源码时免不了跟语法结构打交道,在 .NET 开发最常见的 C# 中,各类语法在编译器眼中的结构都是什么样子的呢?

欲知后事如何,且看下回分解。

喜欢的话,留下你的评论吧~