第 4 步:实用示例 - ToString 生成器
加载进度中...
📋 本步目标
- 实现一个实用的 ToString 生成器
- 理解特性驱动的代码生成
- 掌握语法接收器和语义模型的使用
- 学习如何处理属性和字段
- 应用到实际开发场景
⏱️ 预计时间
约 2-3 小时
🎯 为什么需要 ToString 生成器?
在日常开发中,我们经常需要为类实现 ToString() 方法用于调试和日志记录。手动编写这些代码既繁琐又容易出错:
// 手动实现 ToString - 繁琐且容易遗漏属性
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string Email { get; set; }
public override string ToString()
{
return $"Person {{ Name = {Name}, Age = {Age}, Email = {Email} }}";
}
}问题:
- ❌ 添加新属性时容易忘记更新 ToString
- ❌ 大量重复代码
- ❌ 维护成本高
解决方案:使用源生成器自动生成 ToString 方法!
📚 核心概念回顾
在开始之前,让我们回顾一下 Step 2 学到的关键概念:
1. 特性驱动生成
使用自定义特性标记需要生成代码的类:
[GenerateToString] // 特性标记
public partial class Person
{
public string Name { get; set; }
public int Age { get; set; }
}2. Partial 类
生成器生成的代码必须使用 partial 关键字:
// 用户代码
public partial class Person { }
// 生成的代码(在另一个文件中)
partial class Person
{
public override string ToString() { }
}3. 增量生成器管道
输入 → 语法过滤 → 语义分析 → 代码生成 → 输出🛠️ 实现步骤
步骤 1:创建项目结构
首先创建两个项目:
# 创建解决方案
dotnet new sln -n ToStringGenerator
# 创建生成器项目
dotnet new classlib -n ToStringGenerator.Generator -f netstandard2.0
dotnet sln add ToStringGenerator.Generator
# 创建使用者项目
dotnet new console -n ToStringGenerator.Consumer -f net8.0
dotnet sln add ToStringGenerator.Consumer项目结构:
ToStringGenerator/
├── ToStringGenerator.sln
├── ToStringGenerator.Generator/
│ ├── ToStringGenerator.Generator.csproj
│ ├── ToStringGenerator.cs
│ └── GenerateToStringAttribute.cs
└── ToStringGenerator.Consumer/
├── ToStringGenerator.Consumer.csproj
├── Program.cs
└── Person.cs步骤 2:配置生成器项目
编辑 ToStringGenerator.Generator.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project>关键配置说明:
TargetFramework: netstandard2.0(生成器必须使用)EnforceExtendedAnalyzerRules: 启用增量生成器规则PrivateAssets="all": 不传递 Roslyn 依赖到使用者
步骤 3:定义特性
创建 GenerateToStringAttribute.cs:
using System;
namespace ToStringGenerator
{
/// <summary>
/// 标记类以自动生成 ToString 方法
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class GenerateToStringAttribute : Attribute
{
}
}为什么需要这个特性?
- 让用户明确标记哪些类需要生成 ToString
- 避免为所有类生成代码(性能考虑)
- 提供清晰的 API
步骤 4:实现生成器(简单版本)
创建 ToStringGenerator.cs:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
namespace ToStringGenerator.Generator
{
[Generator]
public class ToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 步骤 1:查找带有 GenerateToString 特性的类
var classDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
// 快速语法过滤
predicate: static (node, _) => IsCandidateClass(node),
// 语义分析和数据提取
transform: static (ctx, _) => GetClassInfo(ctx)
)
.Where(static info => info != null);
// 步骤 2:生成 ToString 方法
context.RegisterSourceOutput(
classDeclarations,
static (spc, classInfo) => GenerateToStringMethod(spc, classInfo!)
);
}
/// <summary>
/// 快速检查:是否是候选类
/// </summary>
private static bool IsCandidateClass(SyntaxNode node)
{
// 必须是类声明
if (node is not ClassDeclarationSyntax classDecl)
return false;
// 必须有特性
if (classDecl.AttributeLists.Count == 0)
return false;
// 必须是 partial 类
if (!classDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
return false;
return true;
}
/// <summary>
/// 提取类信息
/// </summary>
private static ClassInfo? GetClassInfo(GeneratorSyntaxContext context)
{
var classDecl = (ClassDeclarationSyntax)context.Node;
var symbol = context.SemanticModel.GetDeclaredSymbol(classDecl);
if (symbol == null)
return null;
// 检查是否有 GenerateToString 特性
var hasAttribute = symbol.GetAttributes()
.Any(a => a.AttributeClass?.Name == "GenerateToStringAttribute");
if (!hasAttribute)
return null;
// 获取所有公共属性
var properties = symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.Select(p => new PropertyInfo(
Name: p.Name,
Type: p.Type.ToDisplayString()
))
.ToImmutableArray();
return new ClassInfo(
Name: symbol.Name,
Namespace: symbol.ContainingNamespace.ToDisplayString(),
Properties: properties
);
}
/// <summary>
/// 生成 ToString 方法代码
/// </summary>
private static void GenerateToStringMethod(
SourceProductionContext context,
ClassInfo classInfo)
{
var sb = new StringBuilder();
// 文件头
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
// 命名空间
sb.AppendLine($"namespace {classInfo.Namespace}");
sb.AppendLine("{");
// 类声明
sb.AppendLine($" partial class {classInfo.Name}");
sb.AppendLine(" {");
// ToString 方法
sb.AppendLine(" public override string ToString()");
sb.AppendLine(" {");
sb.AppendLine(" var sb = new System.Text.StringBuilder();");
sb.AppendLine($" sb.Append(\"{classInfo.Name} {{ \");");
// 添加属性
for (int i = 0; i < classInfo.Properties.Length; i++)
{
var prop = classInfo.Properties[i];
var isLast = i == classInfo.Properties.Length - 1;
sb.AppendLine($" sb.Append(\"{prop.Name} = \");");
sb.AppendLine($" sb.Append({prop.Name});");
if (!isLast)
{
sb.AppendLine(" sb.Append(\", \");");
}
}
sb.AppendLine(" sb.Append(\" }\");");
sb.AppendLine(" return sb.ToString();");
// 关闭方法和类
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
// 添加源文件
context.AddSource(
$"{classInfo.Name}.ToString.g.cs",
sb.ToString()
);
}
}
/// <summary>
/// 类信息(必须实现 IEquatable 以支持缓存)
/// </summary>
internal record ClassInfo(
string Name,
string Namespace,
ImmutableArray<PropertyInfo> Properties
);
/// <summary>
/// 属性信息
/// </summary>
internal record PropertyInfo(
string Name,
string Type
);
}代码解析:
IsCandidateClass:快速语法过滤
- 只检查语法,不使用语义模型(性能优化)
- 检查是否是类、是否有特性、是否是 partial
GetClassInfo:语义分析
- 使用语义模型获取类型信息
- 检查是否有目标特性
- 提取所有公共属性
GenerateToStringMethod:代码生成
- 使用 StringBuilder 构建代码(性能优化)
- 生成格式良好的代码
- 处理多个属性的逗号分隔
步骤 5:配置使用者项目
编辑 ToStringGenerator.Consumer.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ToStringGenerator.Generator\ToStringGenerator.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>关键配置:
OutputItemType="Analyzer": 将生成器作为分析器引用ReferenceOutputAssembly="false": 不引用生成器的 DLL
步骤 6:使用生成器
创建 Person.cs:
using ToStringGenerator;
namespace ToStringGenerator.Consumer
{
[GenerateToString]
public partial class Person
{
public string Name { get; set; } = "";
public int Age { get; set; }
public string Email { get; set; } = "";
}
}创建 Program.cs:
using ToStringGenerator.Consumer;
var person = new Person
{
Name = "张三",
Age = 30,
Email = "zhangsan@example.com"
};
Console.WriteLine(person);
// 输出: Person { Name = 张三, Age = 30, Email = zhangsan@example.com }步骤 7:构建和运行
# 构建解决方案
dotnet build
# 运行使用者项目
dotnet run --project ToStringGenerator.Consumer预期输出:
Person { Name = 张三, Age = 30, Email = zhangsan@example.com }📚 相关 API
本步骤使用了以下 API:
IIncrementalGenerator
增量生成器接口,提供更好的性能和缓存机制。
ForAttributeWithMetadataName
高效查找带有特定特性的类型的方法(推荐使用)。
SemanticModel
提供代码的语义信息,包括类型、符号等。
GetDeclaredSymbol()
从语法节点获取符号信息的方法。
INamedTypeSymbol
表示命名类型(类、结构、接口等)的符号。
💡 下一步学习
完成本步骤后,建议:
🔍 查看生成的代码
生成的代码位于:
ToStringGenerator.Consumer/obj/Debug/net8.0/generated/
ToStringGenerator.Generator/
ToStringGenerator.Generator.ToStringGenerator/
Person.ToString.g.cs查看生成的文件:
// <auto-generated/>
#nullable enable
namespace ToStringGenerator.Consumer
{
partial class Person
{
public override string ToString()
{
var sb = new System.Text.StringBuilder();
sb.Append("Person { ");
sb.Append("Name = ");
sb.Append(Name);
sb.Append(", ");
sb.Append("Age = ");
sb.Append(Age);
sb.Append(", ");
sb.Append("Email = ");
sb.Append(Email);
sb.Append(" }");
return sb.ToString();
}
}
}🎓 关键技术点
1. 两阶段过滤
// 阶段 1:快速语法过滤(不使用语义模型)
predicate: static (node, _) => IsCandidateClass(node)
// 阶段 2:语义分析(使用语义模型)
transform: static (ctx, _) => GetClassInfo(ctx)为什么这样设计?
- 第一阶段非常快,过滤掉大部分不相关的节点
- 第二阶段只处理候选节点,减少语义分析开销
- 提高编译性能
2. 使用 Record 类型
internal record ClassInfo(
string Name,
string Namespace,
ImmutableArray<PropertyInfo> Properties
);优势:
- 自动实现
IEquatable<T>(支持增量生成器缓存) - 不可变,线程安全
- 简洁的语法
3. 使用 StringBuilder
var sb = new StringBuilder();
sb.Append("Person { ");
sb.Append("Name = ");
sb.Append(Name);为什么不用字符串拼接?
- StringBuilder 避免大量字符串分配
- 提高运行时性能
- 减少内存压力
💡 实践练习
练习 1:添加忽略特性
任务:实现 [ToStringIgnore] 特性,允许忽略某些属性。
提示:
- 定义
ToStringIgnoreAttribute - 在
GetClassInfo中检查属性是否有此特性 - 过滤掉被忽略的属性
示例用法:
[GenerateToString]
public partial class User
{
public string Username { get; set; }
[ToStringIgnore]
public string Password { get; set; } // 不会包含在 ToString 中
}练习 2:处理 Null 值
任务:生成的代码应该处理 null 值。
提示:
sb.Append(Name ?? "null");练习 3:支持字段
任务:除了属性,也支持公共字段。
提示:
var fields = symbol.GetMembers()
.OfType<IFieldSymbol>()
.Where(f => !f.IsStatic && !f.IsConst)
.Where(f => f.DeclaredAccessibility == Accessibility.Public);🐛 常见问题
问题 1:生成器没有运行
症状:没有生成任何代码。
检查清单:
- ✅ 类是否标记了
[GenerateToString] - ✅ 类是否使用了
partial关键字 - ✅ 项目引用配置是否正确(OutputItemType="Analyzer")
- ✅ 是否重新构建了项目
问题 2:找不到 GenerateToStringAttribute
症状:编译错误,找不到特性类型。
解决方案:
- 确保特性类在生成器项目中定义
- 使用者项目应该能看到这个特性(通过生成器传递)
问题 3:生成的代码有编译错误
症状:使用者项目编译失败。
调试步骤:
- 查看生成的文件(在 obj/Debug/... 目录)
- 检查生成的代码语法
- 确保命名空间正确
- 确保类名匹配
✅ 检查点
✅ 检查点
完成以下任务后,可以进入下一步:
📝 小测验
测试你的理解:
为什么必须使用 partial 类?
查看答案
因为生成器生成的代码需要与用户代码合并成一个类。C# 只允许通过 partial 关键字分割类定义。为什么使用两阶段过滤?
查看答案
第一阶段(语法过滤)非常快,可以过滤掉大部分不相关的节点。第二阶段(语义分析)只处理候选节点,减少开销,提高性能。为什么使用 record 类型存储类信息?
查看答案
record 自动实现 IEquatable,支持增量生成器的缓存机制。当输入数据没有变化时,生成器可以跳过重新生成。
🎯 下一步
恭喜!你已经完成了 ToString 生成器的实现。现在你掌握了:
- ✅ 特性驱动的代码生成
- ✅ 语法过滤和语义分析
- ✅ 属性信息提取
- ✅ 代码生成技巧
在下一步中,我们将学习如何优化生成器性能,使用增量生成器的高级特性。
⏭️ 继续学习
📚 扩展阅读
- ToString 生成器完整示例 - 查看更高级的实现
- 代码生成 API - 深入了解代码生成技巧
- 语义模型 API - 学习如何使用语义模型