Skip to content

第 4 步:实用示例 - ToString 生成器

加载进度中...

📋 本步目标

  • 实现一个实用的 ToString 生成器
  • 理解特性驱动的代码生成
  • 掌握语法接收器和语义模型的使用
  • 学习如何处理属性和字段
  • 应用到实际开发场景

⏱️ 预计时间

约 2-3 小时


🎯 为什么需要 ToString 生成器?

在日常开发中,我们经常需要为类实现 ToString() 方法用于调试和日志记录。手动编写这些代码既繁琐又容易出错:

csharp
// 手动实现 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. 特性驱动生成

使用自定义特性标记需要生成代码的类:

csharp
[GenerateToString]  // 特性标记
public partial class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

2. Partial 类

生成器生成的代码必须使用 partial 关键字:

csharp
// 用户代码
public partial class Person { }

// 生成的代码(在另一个文件中)
partial class Person
{
    public override string ToString() { }
}

3. 增量生成器管道

输入 → 语法过滤 → 语义分析 → 代码生成 → 输出

🛠️ 实现步骤

步骤 1:创建项目结构

首先创建两个项目:

bash
# 创建解决方案
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

xml
<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

csharp
using System;

namespace ToStringGenerator
{
    /// <summary>
    /// 标记类以自动生成 ToString 方法
    /// </summary>
    [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
    public sealed class GenerateToStringAttribute : Attribute
    {
    }
}

为什么需要这个特性?

  • 让用户明确标记哪些类需要生成 ToString
  • 避免为所有类生成代码(性能考虑)
  • 提供清晰的 API

步骤 4:实现生成器(简单版本)

创建 ToStringGenerator.cs

csharp
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
    );
}

代码解析

  1. IsCandidateClass:快速语法过滤

    • 只检查语法,不使用语义模型(性能优化)
    • 检查是否是类、是否有特性、是否是 partial
  2. GetClassInfo:语义分析

    • 使用语义模型获取类型信息
    • 检查是否有目标特性
    • 提取所有公共属性
  3. GenerateToStringMethod:代码生成

    • 使用 StringBuilder 构建代码(性能优化)
    • 生成格式良好的代码
    • 处理多个属性的逗号分隔

步骤 5:配置使用者项目

编辑 ToStringGenerator.Consumer.csproj

xml
<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

csharp
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

csharp
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:构建和运行

bash
# 构建解决方案
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

查看生成的文件:

csharp
// <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. 两阶段过滤

csharp
// 阶段 1:快速语法过滤(不使用语义模型)
predicate: static (node, _) => IsCandidateClass(node)

// 阶段 2:语义分析(使用语义模型)
transform: static (ctx, _) => GetClassInfo(ctx)

为什么这样设计?

  • 第一阶段非常快,过滤掉大部分不相关的节点
  • 第二阶段只处理候选节点,减少语义分析开销
  • 提高编译性能

2. 使用 Record 类型

csharp
internal record ClassInfo(
    string Name,
    string Namespace,
    ImmutableArray<PropertyInfo> Properties
);

优势

  • 自动实现 IEquatable<T>(支持增量生成器缓存)
  • 不可变,线程安全
  • 简洁的语法

3. 使用 StringBuilder

csharp
var sb = new StringBuilder();
sb.Append("Person { ");
sb.Append("Name = ");
sb.Append(Name);

为什么不用字符串拼接?

  • StringBuilder 避免大量字符串分配
  • 提高运行时性能
  • 减少内存压力

💡 实践练习

练习 1:添加忽略特性

任务:实现 [ToStringIgnore] 特性,允许忽略某些属性。

提示

  1. 定义 ToStringIgnoreAttribute
  2. GetClassInfo 中检查属性是否有此特性
  3. 过滤掉被忽略的属性

示例用法

csharp
[GenerateToString]
public partial class User
{
    public string Username { get; set; }
    
    [ToStringIgnore]
    public string Password { get; set; }  // 不会包含在 ToString 中
}

练习 2:处理 Null 值

任务:生成的代码应该处理 null 值。

提示

csharp
sb.Append(Name ?? "null");

练习 3:支持字段

任务:除了属性,也支持公共字段。

提示

csharp
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:生成的代码有编译错误

症状:使用者项目编译失败。

调试步骤

  1. 查看生成的文件(在 obj/Debug/... 目录)
  2. 检查生成的代码语法
  3. 确保命名空间正确
  4. 确保类名匹配

✅ 检查点

✅ 检查点

完成以下任务后,可以进入下一步:


📝 小测验

测试你的理解:

  1. 为什么必须使用 partial 类?

    查看答案 因为生成器生成的代码需要与用户代码合并成一个类。C# 只允许通过 partial 关键字分割类定义。
  2. 为什么使用两阶段过滤?

    查看答案 第一阶段(语法过滤)非常快,可以过滤掉大部分不相关的节点。第二阶段(语义分析)只处理候选节点,减少开销,提高性能。
  3. 为什么使用 record 类型存储类信息?

    查看答案 record 自动实现 IEquatable,支持增量生成器的缓存机制。当输入数据没有变化时,生成器可以跳过重新生成。

🎯 下一步

恭喜!你已经完成了 ToString 生成器的实现。现在你掌握了:

  • ✅ 特性驱动的代码生成
  • ✅ 语法过滤和语义分析
  • ✅ 属性信息提取
  • ✅ 代码生成技巧

在下一步中,我们将学习如何优化生成器性能,使用增量生成器的高级特性。

⏭️ 继续学习


📚 扩展阅读

基于 MIT 许可发布