拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用 .NET SyntaxTree 源码生成器生成代码:TaoToken 配置骨架与语法简化实践

用 .NET SyntaxTree 源码生成器生成代码:TaoToken 配置骨架与语法简化实践

1. 为什么要在 .NET 里手写 SyntaxTree 生成器

如果你写过 T4 模板或者字符串拼接生成代码,大概都经历过这种痛苦:模板里少一个逗号,生成出来的 C# 文件编译报错,但报错行号指向的是生成后的文件,你根本不知道是哪段模板逻辑出了问题。字符串拼接更糟,缩进、命名空间、using 全靠手写,改一个字段名要全局替换,稍不留神就漏掉一处。

Roslyn 的 SyntaxTree 就是来解决这个问题的。它把 C# 代码当成结构化数据来处理,你操作的是语法节点而不是文本。生成出来的代码天然带格式,编译期就能发现语法错误,而且可以用 SyntaxFactory 精确控制每一个 token 的写法。简单说,SyntaxTree 让代码生成从「拼字符串」变成了「搭积木」。

这套方案适合几类人:需要为重复性业务逻辑批量生成 CRUD 代码的后端开发者、要给内部框架生成强类型 API 客户端的工具作者、以及想把领域模型自动转成代码的架构师。我试过在一个中型项目里用 SyntaxTree 生成 DTO 和 Mapping 代码,原本两天的手工活压缩到写一次生成器、后续每次改模型跑一遍就行。

不过代码生成器本身只是链路的一半。生成出来的代码如果要调用大模型能力,或者你想让生成器在运行过程中调用 AI 做语义补全,就需要一个稳定的 API 通道。TaoToken 在这里的角色是统一 Key 和 API 入口,让你不用在生成器里硬编码各家模型的地址和密钥。下面我会先讲清楚 SyntaxTree 生成器的完整写法,再给出 TaoToken 的 config.toml 配置骨架和验证动作,最后把两者串起来。

先明确一个概念:SyntaxTree 不是用来「解析已有代码然后改」的,虽然它也能做。它的核心场景是「从零构造语法节点,然后输出成文本」。你构造一个 CompilationUnitSyntax,往里塞 NamespaceDeclaration、ClassDeclaration、MethodDeclaration,每个节点用 SyntaxFactory 的工厂方法创建,最后调用 NormalizeWhitespace() 和 ToFullString() 就能拿到格式化好的 C# 代码。整个过程不需要你手动处理大括号和分号,Roslyn 会帮你补全。

2. TaoToken 前置:统一 Key 与 API 通道的 config.toml 骨架

在写生成器之前,先把 API 通道配好。TaoToken 的定位是统一 Key 和 API 入口,你可以在一个地方管理多个模型的访问凭证,生成器代码里只引用一个 Base URL 和一个 Key。官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数。

配置文件用 config.toml,放在项目根目录或者用户目录下都行。我习惯放在项目根目录的.config/taotoken/config.toml,这样团队协作时可以直接提交到仓库(Key 用环境变量引用,不写明文)。骨架长这样:

# .config/taotoken/config.toml [default] base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" timeout_seconds = 60 max_retries = 3 [models.codegen] model_id = "claude-sonnet-4-20250514" temperature = 0.2 max_tokens = 4096 [models.review] model_id = "gpt-4.1" temperature = 0.1 max_tokens = 2048 [generator] output_dir = "./Generated" namespace_root = "MyApp.Generated" nullable_enable = true file_scoped_namespace = true

几个关键点说明一下。base_url固定指向 TaoToken 的 API 入口,所有模型请求都走这一个地址。api_key_env指定从哪个环境变量读取 Key,这样配置文件可以安全地提交。models下面按用途分组,codegen 用于生成代码,review 用于生成后的审查。generator段是生成器自己的配置,控制输出目录、根命名空间、是否启用可空引用类型、是否用文件作用域命名空间。

环境变量设置方式,Windows 用setx TAOTOKEN_API_KEY "你的Key",macOS/Linux 在.zshrc或.bashrc里加export TAOTOKEN_API_KEY="你的Key"。Key 的获取入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite ,登录后创建一个新 Key,复制出来填到环境变量里。

这里要提醒一句:不要把 Key 直接写进 config.toml 然后提交到公开仓库。我见过有人这么干,结果 Key 泄露被刷了几百万 token。用环境变量引用是最低成本的防护。

配置写好后,先别急着写生成器,用 curl 验证一下通道是否通:

curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "回复 OK 两个字母"}], "max_tokens": 10 }'

如果返回的 JSON 里有choices数组且内容包含 OK,说明通道正常。如果返回 401,检查 Key 是否正确、环境变量是否生效。如果返回local proxy failed,说明网络层有问题,检查 base_url 是否写成了带 UTM 的地址(应该用不带参数的 https://taotoken.net/api )。

3. 可复制配置:SyntaxTree 生成器项目结构与核心片段

现在进入正题。先建项目结构:

CodeGen/ ├── CodeGen.csproj ├── Program.cs ├── Generators/ │ ├── DtoGenerator.cs │ └── MappingGenerator.cs ├── Models/ │ └── EntityDefinition.cs ├── .config/taotoken/config.toml └── Generated/ # 输出目录,自动创建

csproj 需要引用 Roslyn 的包:

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" /> <PackageReference Include="Tomlyn" Version="0.17.0" /> </ItemGroup> </Project>

Tomlyn 用来解析 config.toml,Microsoft.CodeAnalysis.CSharp 提供 SyntaxFactory 和 SyntaxTree。

先定义实体模型,这是生成器的输入:

// Models/EntityDefinition.cs namespace CodeGen.Models; public record PropertyDefinition(string Name, string Type, bool IsNullable = false); public record EntityDefinition( string ClassName, string Namespace, IReadOnlyList<PropertyDefinition> Properties);

核心生成器用 SyntaxFactory 构造语法树。下面这个 DtoGenerator 接收 EntityDefinition,输出一个完整的 C# 文件:

// Generators/DtoGenerator.cs using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using CodeGen.Models; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace CodeGen.Generators; public static class DtoGenerator { public static string Generate(EntityDefinition entity, bool fileScopedNs = true) { // 1. 构造属性列表 var properties = entity.Properties.Select(p => { var typeSyntax = ParseTypeName(p.Type); if (p.IsNullable && typeSyntax is not NullableTypeSyntax) { typeSyntax = NullableType(typeSyntax); } return PropertyDeclaration(typeSyntax, Identifier(p.Name)) .AddModifiers(Token(SyntaxKind.PublicKeyword)) .AddAccessorListAccessors( AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)), AccessorDeclaration(SyntaxKind.SetAccessorDeclaration) .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)) ); }).ToArray(); // 2. 构造类声明 var classDecl = ClassDeclaration(entity.ClassName) .AddModifiers(Token(SyntaxKind.PublicKeyword)) .AddMembers(properties); // 3. 构造命名空间 MemberDeclarationSyntax nsDecl = fileScopedNs ? FileScopedNamespaceDeclaration(ParseName(entity.Namespace)) .AddMembers(classDecl) : NamespaceDeclaration(ParseName(entity.Namespace)) .AddMembers(classDecl); // 4. 构造编译单元 var compilationUnit = CompilationUnit() .AddUsings(UsingDirective(ParseName("System"))) .AddMembers(nsDecl) .NormalizeWhitespace(); return compilationUnit.ToFullString(); } }

这段代码的关键在于NormalizeWhitespace(),它会把所有节点重新格式化,缩进、换行、空格全部自动处理。你不需要在构造节点时关心格式,只管结构。

Program.cs 把配置读取、实体定义、生成、写文件串起来:

// Program.cs using CodeGen.Generators; using CodeGen.Models; using Tomlyn; var configPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".config", "taotoken", "config.toml"); var config = Toml.ToModel(File.ReadAllText(configPath)); var outputDir = config["generator"]?["output_dir"]?.ToString() ?? "./Generated"; var nsRoot = config["generator"]?["namespace_root"]?.ToString() ?? "Generated"; var fileScoped = bool.Parse(config["generator"]?["file_scoped_namespace"]?.ToString() ?? "true"); Directory.CreateDirectory(outputDir); var entities = new[] { new EntityDefinition("UserDto", $"{nsRoot}.Users", new[] { new PropertyDefinition("Id", "long"), new PropertyDefinition("UserName", "string", IsNullable: true), new PropertyDefinition("Email", "string", IsNullable: true), new PropertyDefinition("CreatedAt", "DateTime"), }), new EntityDefinition("OrderDto", $"{nsRoot}.Orders", new[] { new PropertyDefinition("OrderId", "long"), new PropertyDefinition("UserId", "long"), new PropertyDefinition("Amount", "decimal"), new PropertyDefinition("Status", "string", IsNullable: true), }), }; foreach (var entity in entities) { var code = DtoGenerator.Generate(entity, fileScoped); var filePath = Path.Combine(outputDir, $"{entity.ClassName}.g.cs"); File.WriteAllText(filePath, code); Console.WriteLine($"Generated: {filePath}"); }

跑dotnet run,Generated 目录下会出现 UserDto.g.cs 和 OrderDto.g.cs。打开看一眼,格式和手写的没区别。

4. 验证请求与成功结果:生成代码 + API 通道双验证

生成器跑通后,验证分两步。第一步验证生成的代码能编译,第二步验证 TaoToken 通道能正常调用。

先看生成的 UserDto.g.cs 内容:

using System; namespace MyApp.Generated.Users { public class UserDto { public long Id { get; set; } public string? UserName { get; set; } public string? Email { get; set; } public DateTime CreatedAt { get; set; } } }

注意string?的可空标记正确生成了,这是 NullableType 包装的结果。文件作用域命名空间如果开启,会变成namespace MyApp.Generated.Users;这种写法。

把 Generated 目录加入项目编译,dotnet build应该零警告零错误。如果报 CS8618(不可空属性未初始化),说明你的项目开了可空引用类型但 DTO 属性没给默认值,这是预期行为,DTO 通常用对象初始化器赋值,可以加= default!;或者用required修饰符。生成器里可以加一个配置项控制是否输出required。

第二步验证 TaoToken 通道。写一个简单的 C# 调用,用 HttpClient 发请求:

using System.Net.Http.Headers; using System.Text; using System.Text.Json; var apiKey = Environment.GetEnvironmentVariable("TAOTOKEN_API_KEY") ?? throw new InvalidOperationException("TAOTOKEN_API_KEY not set"); using var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); var payload = new { model = "claude-sonnet-4-20250514", messages = new[] { new { role = "user", content = "用一句话说明什么是 DTO" } }, max_tokens = 100 }; var json = JsonSerializer.Serialize(payload); var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://taotoken.net/api/v1/chat/completions", content); var body = await response.Content.ReadAsStringAsync(); Console.WriteLine($"Status: {response.StatusCode}"); Console.WriteLine(body);

运行后如果看到Status: OK和包含choices的 JSON,说明通道正常。把这段调用集成到生成器里,就可以实现「生成代码后自动让模型审查一遍」的流程。比如生成完 DTO 后,把代码内容作为 prompt 发给模型,让它检查是否有命名不规范或类型不匹配的问题。

实测下来,从零到跑通整个链路大概 20 分钟,主要时间花在配环境和调 SyntaxFactory 的 API 上。一旦跑通,后续加新实体就是改一行数组的事。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth

这一节列几个我踩过的坑,按报错信息对照排查。

401 Unauthorized。最常见的原因是 Key 没设置或设置错了。先确认环境变量:echo $TAOTOKEN_API_KEY(macOS/Linux)或echo %TAOTOKEN_API_KEY%(Windows)。如果输出为空,说明没设置成功。另一个原因是 Key 复制时带了空格或换行,用trim()处理一下。还有一种情况是 Key 被禁用或额度用完,去 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 看下用量和状态。

local proxy failed。这个报错通常出现在 base_url 写错的时候。检查 config.toml 里的base_url是不是https://taotoken.net/api,不要带 UTM 参数,不要带尾部斜杠。如果你在代码里拼接路径,确认拼出来的是https://taotoken.net/api/v1/chat/completions而不是https://taotoken.net/api//v1/...。另外检查系统代理设置,有些公司网络会强制走代理导致连接失败。

reading choices 相关报错。这个一般出现在解析响应 JSON 的时候。如果模型返回的内容为空或者格式不对,choices[0].message.content可能取不到值。加一层判空:

using var doc = JsonDocument.Parse(body); if (!doc.RootElement.TryGetProperty("choices", out var choices) || choices.GetArrayLength() == 0) { Console.WriteLine("No choices in response: " + body); return; } var content = choices[0].GetProperty("message").GetProperty("content").GetString();

还有一种情况是 max_tokens 设得太小,模型还没输出完就被截断了,choices 里会有finish_reason: "length",把 max_tokens 调大即可。

OAuth 相关报错。如果你用的是 Claude Code 或者某些 CLI 工具,可能会遇到 OAuth token 过期的问题。这类工具通常有自己的认证流程,和 API Key 是两套体系。如果你在 Claude Code 里配置 TaoToken,需要设置ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY两个环境变量,Base URL 指向 https://taotoken.net/api ,Key 用你的 TaoToken Key。配置文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 有详细说明。

SyntaxTree 相关报错。如果NormalizeWhitespace()之后代码格式还是乱的,检查是不是在构造节点时混用了SyntaxFactory.ParseXxx和手动构造。Parse 出来的节点带 trivia(空白和注释),Normalize 时可能保留。统一用工厂方法构造,或者对 Parse 的结果调用.WithoutTrivia()。另一个常见问题是命名空间重复,FileScopedNamespaceDeclaration和NamespaceDeclaration不能混用,一个文件只能有一个。

6. 把生成器接入日常流程:从手动跑到自动化

生成器跑通后,下一步是让它融入日常开发。我目前的做法是在项目里加一个 MSBuild target,每次编译前自动跑生成器:

<Target Name="RunCodeGen" BeforeTargets="BeforeCompile"> <Exec Command="dotnet run --project $(MSBuildProjectDirectory)/../CodeGen/CodeGen.csproj" /> </Target>

这样改完实体定义,直接编译就会重新生成代码。注意生成器的输出目录要加入.gitignore,生成物不进版本库,只提交生成器源码和实体定义。

如果生成逻辑比较复杂,比如需要根据数据库 schema 动态生成,可以把实体定义改成从 JSON 或数据库读取。TaoToken 的模型对话入口 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 可以用来做语义映射,比如把数据库字段名转成 C# 属性名时,让模型判断单复数、缩写展开等。

对于长期维护的代码生成项目,建议把生成器本身也纳入 CI。每次 PR 跑一遍生成器,检查生成结果是否有 diff,有 diff 就说明实体定义改了但生成物没更新,提醒开发者提交。这个检查用git diff --exit-code就能实现。

最后说一个实用技巧:SyntaxFactory 的 API 很多,记不住很正常。我通常先用SyntaxFactory.ParseCompilationUnit解析一段手写的目标代码,然后用DescendantNodes()遍历看每个节点是什么类型,再照着用工厂方法构造。这样比翻文档快得多。Roslyn 的 Syntax Visualizer 工具也能在 VS 里实时看语法树结构,调试生成器时很有用。

整套流程跑下来,代码生成从「容易出错的体力活」变成了「写一次就一劳永逸的基础设施」。配合 TaoToken 的统一通道,生成器里调用模型做代码审查或语义补全也不用到处配 Key。如果你还没试过 SyntaxTree,建议从一个简单的 DTO 生成器开始,跑通后再逐步加复杂度。

返回列表