TypeSpec C# 生成器插件开发实战:用 GeneratorPlugin 轻量定制 http-client-csharp 的生成输出
【免费下载链接】typespec项目地址: https://gitcode.com/GitHub_Trending/ty/typespec
本文围绕@typespec/http-client-csharp官方文档中的 Generator plugins 机制展开,系统讲解如何在不编写完整自定义生成器的前提下,通过实现GeneratorPlugin挂接额外的 visitor、rewriter、元数据引用与共享源码目录,参与内置ScmCodeModelGenerator的常规生成流水线。读完本文,你将掌握插件从编写、构建、配置到被加载执行的全链路方法,并理解其底层 MEF 装配与路径解析原理。
什么是 Generator plugins:轻量定制 C# 生成器的方式
@typespec/http-client-csharp(本仓库位于 packages/http-client-csharp)的 C# 代码生成由 .NET 侧生成器完成,默认入口是内置的ScmCodeModelGenerator。官方文档 plugins.md 指出:插件是一种轻量级的定制手段,无需编写完整自定义生成器,只需注册额外的 visitors、rewriters、metadata references 或 shared source 目录,即可参与正常生成流水线。典型用途包括:
- 重命名生成的类型(rename types);
- 为生成的成员添加属性(add attributes);
- 对生成的模型做后置处理(post-process models);
- 引入额外的程序集引用或共享源码文件。
插件的存在让团队可以在不 fork 生成器、不修改仓库源码的前提下,对生成结果做精细的个性化调整,同时持续跟随上游ScmCodeModelGenerator的更新。
生成器如何发现插件:两种加载途径
按照 plugins.md 的说明,生成器通过两种方式发现插件:
- 自动发现:扫描项目
node_modules中列出的包的dist文件夹; - 显式指定:通过
pluginsemitter 选项提供的路径加载。
本文重点讨论plugins选项——它允许 emitter 作者将一个或多个插件程序集(或包含程序集的目录/工程)指向生成器。
从源码看,两种途径最终都汇入GeneratorHandler的 MEF 目录装配逻辑(GeneratorHandler.cs):
AddPluginDlls:自动发现路径。它从启动目录向上寻找node_modules根目录,读取根目录下package.json的dependencies,按依赖顺序依次检查每个包目录下的dist子目录,收集其中的*.dll;若某个依赖包没有预编译 DLL,则尝试通过BuildPluginIfNeeded就地构建其.csproj;AddConfiguredPluginDlls:处理plugins配置项,把用户显式给出的路径加入AggregateCatalog。
随后这些目录/程序集被组合进 MEFCompositionContainer,GeneratorPlugin子类即可被自动装配并发现。
编写插件:继承 GeneratorPlugin 并重写 Apply
一个插件就是一个继承GeneratorPlugin的 C# 类,重写其Apply方法。基类定义在 GeneratorPlugin.cs,它通过 MEF 的[InheritedExport]导出:
// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.ComponentModel.Composition; namespace Microsoft.TypeSpec.Generator { /// <summary> /// Base class for generator plugins. /// </summary> [InheritedExport] [Export(typeof(GeneratorPlugin))] public abstract class GeneratorPlugin { public abstract void Apply(CodeModelGenerator generator); } }关键点:[InheritedExport]意味着任何子类在程序集被加载后都会被自动发现,你不需要自己添加[Export]特性。一个最小插件如下(与官方文档示例一致):
using Microsoft.TypeSpec.Generator; public class MyPlugin : GeneratorPlugin { public override void Apply(CodeModelGenerator generator) { // Register a custom visitor that transforms the output library. generator.AddVisitor(new MyLibraryVisitor()); } }Apply收到的CodeModelGenerator参数(定义于 CodeModelGenerator.cs)暴露了插件可用的全部扩展点:
| 扩展点 | 签名 | 作用 |
|---|---|---|
| 注册访问器 | AddVisitor(LibraryVisitor visitor) | 添加一个遍历并修改输出库的 visitor |
| 注册重写器 | AddRewriter(LibraryRewriter rewriter) | 添加 rewriter 转换生成的成员 |
| 添加元数据引用 | AddMetadataReference(MetadataReference reference) | 让额外程序集对生成代码可见 |
| 添加共享源码目录 | AddSharedSourceDirectory(string sharedSourceDirectory) | 引入一个共享源码文件目录 |
从实现细节看,这些方法都是向生成器实例内部的集合追加元素:
AddVisitor追加到_visitors,AddRewriter追加到_rewriters,二者分别通过只读属性Visitors、Rewriters对外暴露;AddMetadataReference追加到_additionalMetadataReferences,AddSharedSourceDirectory追加到_sharedSourceDirectories。
值得注意的是,CodeModelGenerator还提供了配套的RemoveVisitor<T>()与RemoveVisitor(string visitorTypeName)方法,可按类型或类型名移除已注册的 visitor——后者尤其适合移除那些类型不可公开访问的内置 visitor,这一能力在定制内置生成器行为时非常实用。
编写 LibraryVisitor:挂接输出库的遍历钩子
visitor 让你能挂接到输出库的特定部分。官方文档给出的示例是转换每一个生成的类型:
using Microsoft.TypeSpec.Generator; using Microsoft.TypeSpec.Generator.Providers; public class MyLibraryVisitor : LibraryVisitor { protected override TypeProvider? VisitType(TypeProvider type) { // Inspect or modify the generated type here. return base.VisitType(type); } }LibraryVisitor的遍历机制(LibraryVisitor.cs)决定了你能钩住哪些环节。其核心流程是:
VisitLibrary(OutputLibrary library)作为入口,遍历library.TypeProviders;- 对每个
TypeProvider调用VisitTypeCore,其内部依次调用:VisitType(type)—— 对类型本身的钩子(返回null可移除该类型);- 遍历
typeProvider.Methods、Constructors、Properties、Fields,分别派发VisitMethod、VisitConstructor、VisitProperty、VisitField; - 递归处理
SerializationProviders与NestedTypes; - 最后调用
PostVisitType(type)收尾。
- 通过
type.Update(methods, constructors, properties, fields, serializations, nestedTypes)把修改后的成员写回类型。
此外,LibraryVisitor还提供一组PreVisit*钩子(如PreVisitModel、PreVisitProperty、PreVisitEnum),它们在TypeFactory创建对应 provider 时提前介入,适合在类型尚未完全构建前调整输入模型到输出的转换结果。
也就是说,一个 visitor 可以在「类型层」「成员层」「转换前」三个维度上修改输出,而VisitType返回null即可实现从生成结果中剔除指定类型的效果。
插件工程文件:如何引用生成器包并构建
插件应构建为类库工程,并引用生成器发布的 NuGet 包。根据 plugins.md,引用Microsoft.TypeSpec.Generator.ClientModel会传递性地引入Microsoft.TypeSpec.Generator(包含GeneratorPlugin基类的那个程序集)。使用的包版本应与生成所用的@typespec/http-client-csharp版本保持一致。一个最小.csproj如下:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.TypeSpec.Generator.ClientModel" Version="X.Y.Z" /> </ItemGroup> </Project>编译后得到插件程序集*.dll,供后续plugins选项加载。
配置 plugins 选项:在 tspconfig.yaml 中启用插件
有了插件程序集之后,用tspconfig.yaml的plugins选项把 emitter 指向它。根据官方文档与 options.ts 中的 schema 定义,plugins是string[]类型,每个路径可以是绝对路径,也可以是相对于解析后的emitter-output-dir的相对路径;路径可指向:
- 一个文件—— 预构建的插件程序集(
*.dll); - 一个目录—— 其中包含预构建的插件程序集(
*.dll)或一个.csproj;当目录中存在.csproj时,生成器会先自动执行dotnet build -c Release构建,再加载产物程序集。
官方示例(加载输出目录下codegen文件夹中的插件):
emit: - "@typespec/http-client-csharp" options: "@typespec/http-client-csharp": plugins: - "codegen/MyPlugin.dll" # file relative to emitter-output-dir - "codegen" # directory containing plugin assemblies - "/abs/path/to/MyPlugin.dll" # absolute path used as-is需要明确的规则(原文档要点,原文照录并加以印证):
- 绝对路径按原样使用;相对路径锚定到解析后的
emitter-output-dir; - 目录中存在
.csproj时自动构建;否则扫描其中的预构建*.dll文件; - 通过
plugins选项加载的插件,与通过node_modules自动发现的插件是叠加生效的,两者都会被应用。
在 emitter 侧,路径解析发生在 emitter.ts 中——options["plugins"]中的每一项都会先经resolvePath(outputFolder, p)转换为绝对路径,再写入生成器配置,这正是「相对路径相对emitter-output-dir解析」的具体实现。emitter 的单元测试 options.test.ts 也验证了plugins会被原样传入配置,未设置时不会出现在配置中。
底层加载原理:从 plugins 选项到 Apply 被调用
理解插件如何被真正执行,需要看GeneratorHandler的完整流程(GeneratorHandler.cs):
- 装配目录:
LoadGenerator首先用AppContext.BaseDirectory创建DirectoryCatalog,再调用AddPluginDlls处理node_modules自动发现,随后调用AddConfiguredPluginDlls处理plugins配置项,最终组合成AggregateCatalog并创建 MEFCompositionContainer; - 解析配置路径:
AddConfiguredPluginDlls读取Configuration.PluginPaths(对应 Configuration.cs 中的PluginPaths属性,配置键名为plugins)。对每个路径,若目录存在.csproj则BuildPluginIfNeeded后以AssemblyCatalog加载产物;否则枚举目录下所有*.dll逐个尝试作为 MEF 目录加载(无法作为 MEF catalog 加载的 DLL,例如原生 DLL,会被静默跳过); - 自动构建:目录含
.csproj时,FindPluginProject会优先选择src目录下的工程(避免误构建测试或示例工程),随后BuildPlugin将工程构建到进程隔离的输出目录(根目录常量typespec-generator-plugins),这样并发运行的不同进程不会共享同一份构建产物——源码注释明确说明这是为了避免并发构建互相覆盖、导致前一次运行的陈旧 DLL 被加载而静默丢失插件行为;若构建失败,则记录诊断码plugin-build-failed(见 DiagnosticCodes.cs)并警告,而不是让整个生成中断; - 触发 Apply:生成器选定(默认
ScmCodeModelGenerator)后,SelectGenerator遍历 MEF 装配出的全部Plugins,对每个插件调用plugin.Apply(CodeModelGenerator.Instance)——此时所有注册的 visitors、rewriters、metadata references、shared source 目录都会在正式生成开始前生效。
hosted 模式限制:当IsHosted为真时,AddPluginDlls直接返回(不自动发现),而AddConfiguredPluginDlls会抛出InvalidOperationException("Custom plugins are disabled in hosted mode."),即托管环境中不允许加载自定义插件。
用测试验证插件行为
仓库自带测试可作为插件正确性的参照。单元测试 GeneratorPluginTests.cs 验证了插件最核心的契约——Apply中注册的 visitor 会被加入生成器:
[Test] public void PluginAddsVisitorToGenerator() { var generator = MockHelpers.LoadMockGenerator(); var plugin = new TestGeneratorPlugin(); plugin.Apply(generator.Object); Assert.AreEqual(1, generator.Object.Visitors.Count); Assert.IsInstanceOf<TestLibraryVisitor>(generator.Object.Visitors[0]); }配套的 TestGeneratorPlugin.cs 展示了插件的最简形态——Apply中只做一件事:generator.AddVisitor(new TestLibraryVisitor())。而 TestLibraryVisitor.cs 则是空实现的LibraryVisitor子类,说明 visitor 的所有Visit*方法都是可选覆写的,默认行为是原样放行。
注意事项与最佳实践小结
综合文档与源码实现,使用插件时有几点值得留意:
- 版本对齐:插件引用的
Microsoft.TypeSpec.Generator.ClientModel版本应匹配生成所用@typespec/http-client-csharp版本,否则可能出现 API 不兼容; - 路径语义:
plugins相对路径相对emitter-output-dir解析,绝对路径原样使用;路径必须存在(指向目录时目录必须存在,否则抛异常); - 加载顺序:
plugins选项与node_modules自动发现是叠加关系,二者发现的插件都会被Apply; - 托管限制:hosted 模式下自定义插件不可用;
- 构建时机:目录模式下若检测到
.csproj会自动构建(dotnet build -c Release),src目录下的工程会被优先选中,构建产物放在进程隔离目录以避免并发冲突; - 移除内置行为:如需移除某个内置 visitor,可借助
RemoveVisitor<T>()或按类型名移除的RemoveVisitor(string)。
至此,从GeneratorPlugin编写、LibraryVisitor覆写、.csproj构建、tspconfig.yaml配置到 MEF 装配与Apply触发,插件定制的完整链路已经打通。相关细节可继续参考 emitter.md 的 plugins 一节 以及生成器源码 GeneratorHandler.cs 与 CodeModelGenerator.cs。
【免费下载链接】typespec项目地址: https://gitcode.com/GitHub_Trending/ty/typespec
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考