2016 年 6 月

第 31 卷,第 6 期

.NET 编译器平台 - 使用 Roslyn 生成与语言无关的代码

作者 Alessandro Del Del

Roslyn 代码库提供了功能强大的 API,你可以利用这些 API 对你的源代码执行丰富的代码分析。例如,分析器和代码重构可以遍历一段源代码并用你使用 Roslyn API 生成的新代码替换一个或多个语法节点。执行代码生成的一种常见方式是使用 SyntaxFactory 类,该类提供了一些工厂方法用于以编译器可以理解的方式生成语法节点。SyntaxFactory 类的功能确实非常强大,因为它允许生成任何可能的语法元素,但有两个不同的 SyntaxFactory 实现: Microsoft.CodeAnalysis.CSharp.SyntaxFactory 和 Microsoft.Code­Analysis.VisualBasic.SyntaxFactory。这具有一个重要的暗示,如果你想用同时针对 C# 和 Visual Basic 的代码修补程序编写分析器,你必须使用 SyntaxFactory 的两个实现分别以不同的方法编写两个不同的分析器,分别用于 C# 和 Visual Basic,因为这两种语言处理某些构造的方式有所不同。这可能意味着要浪费时间编写两次分析器,而且对其进行维护变得更加困难。幸运的是,Roslyn API 还提供了 Microsoft.CodeAnalysis.Editing.SyntaxGenerator,这允许生成与语言无关的代码。换句话说,通过使用 Syntax­Generator,你可以编写一次同时针对 C# 和 Visual Basic 的代码生成逻辑。在本文中,我将为你展示如何使用 SyntaxGenerator 生成与语言无关的代码,并且我将给出一些关于 Roslyn Workspaces API 的提示。

从代码开始

我们从将使用 SyntaxGenerator 生成的一些源代码开始。考虑分别使用 C#(图 1)和 Visual Basic(图 2)实现 ICloneable 接口的简单 Person 类。

图 1 C# 中的简单 Person 类

public abstract class Person : ICloneable
{
  // Not using auto-props is intentional for demo purposes
  private string _lastName;
  public string LastName
  {
    get
    {
      return _lastName;
    }
    set
    {
      _lastName = value;
    }
  }
  private string _firstName;
  public string FirstName
  {
    get
    {
      return _firstName;
    }
    set
    {
      _firstName = value;
    }
  }
  public Person(string LastName, string FirstName)
  {
    _lastName = LastName;
    _firstName = FirstName;
  }
  public virtual object Clone()
  {
    return MemberwiseClone();
  }
}

图 2 Visual Basic 中的简单 Person 类

Public MustInherit Class Person
  Implements ICloneable
  'Not using auto-props is intentional for demo purposes
  Private _lastName As String
  Private _firstName As String
  Public Property LastName As String
    Get
      Return _lastName
    End Get
    Set(value As String)
      _lastName = value
    End Set
  End Property
  Public Property FirstName As String
    Get
      Return _firstName
    End Get
    Set(value As String)
      _firstName = value
    End Set
  End Property
  Public Sub New(LastName As String, FirstName As String)
    _lastName = LastName
    _firstName = FirstName
  End Sub
  Public Overridable Function Clone() As Object Implements ICloneable.Clone
    Return MemberwiseClone()
  End Function
End Class

你可能会认为,在本示例中声明自动实现的属性会有相同的效果,而且会使代码更简洁,但稍后你会明白我为什么要使用扩展的形式。

Person 类的此实现非常简单,但它包含大量语法元素,从而使其有助于理解如何使用 Syntax­Generator 执行代码生成。让我们使用 Roslyn 生成此类。

创建代码分析工具

首先要在 Visual Studio 2015 中创建一个新项目来引用 Roslyn 库。为了便于本文说明,我不会创建分析器或重构,而是在“新建项目”对话框的“扩展性”节点中选择可在 .NET 编译器平台 SDK 中使用的另一个项目模板“独立代码分析工具”(参见图 3)。

“独立代码分析工具”项目模板
图 3“独立代码分析工具”项目模板

实际上,此项目模板生成一个控制台应用程序并针对你选择的语言自动添加适合于 Roslyn API 的 NuGet 包。因为我们的想法是同时针对 C# 和 Visual Basic,所以首先要为第二种语言添加 NuGet 包。例如,如果你最初创建了一个 C# 项目,你将需要从 NuGet 下载并安装以下 Visual Basic 库:

  • Microsoft.CodeAnalysis.VisualBasic.dll
  • Microsoft.CodeAnalysis.VisualBasic.Workspaces.dll
  • Microsoft.CodeAnalysis.VisualBasic.Workspaces.Common.dll

你可以从 NuGet 只安装后者,这将自动解析其他所需库的依赖项。解析依赖项在你计划使用 SyntaxGenerator 类时至关重要,无论你要使用哪个项目模板。忘记执行此操作将导致在运行时出现异常。

认识一下 SyntaxGenerator 和工作区 API

SyntaxGenerator 类提供了一个称为 GetGenerator 的静态方法,该方法返回 SyntaxGenerator 的实例。你可以使用返回的实例执行代码生成。GetGenerator 具有以下三个重载:

public static SyntaxGenerator GetGenerator(Document document)
public static SyntaxGenerator GetGenerator(Project project)
public static SyntaxGenerator GetGenerator(Workspace workspace, string language)

前两个重载分别使用 Document 和 Project。Document 类代表项目中的代码文件,而 Project 类代表一个完整的 Visual Studio 项目。这些重载自动检测 Document 或 Project 目标的语言(C# 或 Visual Basic)。Document、Project 和 Solution(另外一个类,代表 Visual Studio .sln 解决方案)是 Workspace 的一部分,该 Workspace 提供了一种托管方式,用于通过项目、代码文件、元数据和对象与构成 MSBuild 解决方案的一切组成部分进行交互。工作区 API 提供了几个类,你可以使用这些类管理工作区,例如,MSBuildWorkspace 类(它允许使用 .sln 解决方案)或 AdhocWorkspace 类(在你不使用现有 MSBuild 解决方案而想要一个代表解决方案的内存中工作区时非常有用)。在分析器和代码重构的示例中,你已经有一个 MSBuild 工作区,允许你使用 Document、Project 和 Solution 类的实例来使用代码文件。当前的示例项目中没有工作区,那么让我们使用 SyntaxGenerator 的第三个重载来创建一个。若要获取一个新的空工作区,你可以使用 AdhocWorkspace 类:

// Get a workspace
var workspace = new AdhocWorkspace();

现在,你可以获取 SyntaxGenerator 的实例,从而传递作为参数的工作区实例和所需语言:

// Get the SyntaxGenerator for the specified language
var generator = SyntaxGenerator.GetGenerator(workspace, LanguageNames.CSharp);

语言名称可以是 CSharp 或 VisualBasic,两者均是来自 LanguageNames 类的常数。我们先从 C# 开始;稍后你会看到如何将语言名称更改为 VisualBasic。现在你已拥有需要的所有工具,而且已准备好生成语法节点。

生成语法节点

SyntaxGenerator 类提供了一些实例工厂方法,用于以符合 C# 和 Visual Basic 的语法和语义的方式生成对应的语法节点。例如,名称以 Expression 为后缀的方法生成与表达式;名称以 Statement 为后缀的方法生成语句;名称以 Declaration 为后缀的方法生成声明。每个类别都有生成特定语法节点的专用方法。例如,MethodDeclaration 生成方法块、PropertyDeclaration 生成属性、FieldDeclaration 生成字段等等(像往常一样,IntelliSense 是你最好的朋友)。这些方法的特点是,每个方法都会返回 SyntaxNode,而不是派生自 SyntaxNode 的专用类型,就像使用 SyntaxFactory 类发生的情况一样。这提供了很大的灵活性,特别是在生成复杂的节点时。

基于示例 Person 类,首先要生成的是 System 命名空间的 using/Imports 指令,该命名空间提供了 ICloneable 接口。这可以使用 NamespaceImportDeclaration 方法完成,如下所示:

// Create using/Imports directives
var usingDirectives = generator.NamespaceImportDeclaration("System");

此方法采用一个字符串参数,表示你要导入的命名空间。让我们接着声明两个字段,这将通过 FieldDeclaration 方法完成:

// Generate two private fields
var lastNameField = generator.FieldDeclaration("_lastName",
  generator.TypeExpression(SpecialType.System_String),
  Accessibility.Private);
var firstNameField = generator.FieldDeclaration("_firstName",
  generator.TypeExpression(SpecialType.System_String),
  Accessibility.Private);

FieldDeclaration 采用字段名称、字段类型和可访问性级别作为参数。若要提供适当的类型,你需要调用 TypeExpression 方法,该方法采用来自 SpecialType 枚举的值,本示例中为 System_String(不要忘记使用 IntelliSense 发现其他值)。可访问性级别设置为来自 Accessibility 枚举的值。当调用 SyntaxGenerator 的方法时,嵌套调用同一类的其他方法是很常见的,如本示例中的 TypeExpression。下一步是生成两个属性,这通过调用 PropertyDeclaration 方法完成,如图 4 中所示。

图 4 通过 PropertyDeclaration 方法生成两个属性

// Generate two properties with explicit get/set
var lastNameProperty = generator.PropertyDeclaration("LastName",
  generator.TypeExpression(SpecialType.System_String), Accessibility.Public,
  getAccessorStatements:new SyntaxNode[]
  { generator.ReturnStatement(generator.IdentifierName("_lastName")) },
  setAccessorStatements:new SyntaxNode[]
  { generator.AssignmentStatement(generator.IdentifierName("_lastName"),
  generator.IdentifierName("value"))});
var firstNameProperty = generator.PropertyDeclaration("FirstName",
  generator.TypeExpression(SpecialType.System_String),
  Accessibility.Public,
  getAccessorStatements: new SyntaxNode[]
  { generator.ReturnStatement(generator.IdentifierName("_firstName")) },
  setAccessorStatements: new SyntaxNode[]
  { generator.AssignmentStatement(generator.IdentifierName("_firstName"),
  generator.IdentifierName("value")) });

正如你所看到的,为属性生成语法节点更为复杂。在本示例中,你仍然传递属性名称字符串、TypeExpression 属性类型以及可访问性级别。关于属性,你通常还需要提供 Get 和 Set 访问器,尤其是在你需要执行代码而不设置或返回属性值的情况下(如实现 INotifyPropertyChanged 接口时引发 OnPropertyChanged 事件)。Get 和 Set 访问器均由一系列 SyntaxNode 对象表示。在 Get 中,你通常返回属性值,因此这里的代码调用 ReturnStatement 方法,它代表返回指令及其返回的值或对象。在这种情况下,返回的值是字段的标识符。标识符的语法节点是通过调用 IdentifierName 方法获取的,该方法采用字符串类型的参数,并且仍然返回 SyntaxNode。相反,Set 访问器通过赋值将属性值存储在字段中。赋值由 AssignmentStatement 方法表示,该方法采用两个参数,分别为赋值的左侧和右侧。在当前示例中,赋值介于两个标识符之间,因此代码调用了 IdentifierName 两次,一次用于赋值的左侧(字段名称),另一次用于赋值的右侧(属性值)。因为属性值在 C# 和 Visual Basic 中均由值标识符表示,所以可以对其进行硬编码。

下一步是 Clone 方法的代码生成,该方法是 ICloneable 接口实现所必需的。一般来说,一个方法由声明(其中包括签名和程序块分隔符)和大量语句(用于组成方法主体)构成。在当前示例中,Clone 也必须实现 ICloneable.Clone 方法。因此,简便的方法是将该方法的代码生成划分为三个较小的语法节点。第一语法节点是方法主体,如下所示:

// Generate the method body for the Clone method
var cloneMethodBody = generator.ReturnStatement(generator.
  InvocationExpression(generator.IdentifierName("MemberwiseClone")));

在本示例中,Clone 方法返回调用继承自 System.Object 的 MemberwiseClone 方法的结果。因此,方法主体只是调用之前遇到的 ReturnStatement。此处,ReturnStatement 的参数是 InvocationExpression 方法的调用,这表示方法调用而且其参数是表示所调用方法的名称的标识符。因为 InvocationExpression 参数的类型是 SyntaxNode,提供该标识符的简便方法是使用 IdentifierName 方法,并传递表示要调用方法的标识符的字符串。如果你有一个具有更复杂的方法主体的方法,则需要生成一个 SyntaxNode 类型的数组,同时每个节点代表方法主体中的一些代码。

下一步是生成 Clone 方法声明,如下所示:

// Generate the Clone method declaration
var cloneMethoDeclaration = generator.MethodDeclaration("Clone", null,
  null,null,
  Accessibility.Public,
  DeclarationModifiers.Virtual,
  new SyntaxNode[] { cloneMethodBody } );

使用 MethodDeclaration 方法生成一个方法。此方法采用了大量参数,例如:

  • 方法名称,字符串类型
  • 方法参数,IEnumerable<SyntaxNode> 类型(本示例中为 null)
  • 泛型方法的类型参数,IEnumerable<SyntaxNode> 类型(本示例中为 null)
  • 返回类型,SyntaxNode 类型(本示例中为 null)
  • 可访问性级别,具有来自 Accessibility 枚举的值
  • 声明修饰符,具有来自 DeclarationModifiers 枚举的一个或多个值;在本示例中,修饰符是虚拟的(在 Visual Basic 中可重写)
  • 方法主体的语句,SyntaxNode 类型;在本示例中,该数组包含一个元素,这是先前定义的返回语句

稍后,你将看到一个如何使用更专业的 ConstructorDeclaration 方法添加方法参数的示例。Clone 方法必须从 ICloneable 接口实现其对应部分,所以必须进行处理。你现在需要的是一个表示接口名称的语法节点,在将接口实现添加到 Person 类时,此语法节点也非常有用。这可以通过调用 IdentifierName 方法完成,该方法从指定的字符串返回一个适当的名称:

// Generate a SyntaxNode for the interface's name you want to implement
var ICloneableInterfaceType = generator.IdentifierName("ICloneable");

如果你曾想导入完全限定的名称 System.ICloneable,你会用 DottedName 代替 IdentifierName,以便生成一个适当的限定名称,但在当前示例中,已为 System 添加 NamespaceImportDeclaration。此时,你可以把它们组合在一起。SyntaxGenerator 具有 AsPublicInterfaceImplementation 和 AsPrivateInterfaceImplementation 方法,你可以使用这些方法告知编译器方法定义正在实现接口,如下所示:

// Explicit ICloneable.Clone implemenation
var cloneMethodWithInterfaceType = generator.
  AsPublicInterfaceImplementation(cloneMethoDeclaration,
  ICloneableInterfaceType);

这在使用 Visual Basic 时尤为重要,Visual Basic 明确需要 Implements 子句。AsPublicInterfaceImplementation 等效于 C# 中的隐式接口实现,而 AsPrivateInterfaceImplementation 等效于显式接口实现。两者均使用方法、属性和索引器。

下一步即将生成构造函数,这是通过 ConstructorDeclaration 方法完成的。与使用 Clone 方法一样,构造函数的定义应被拆分成更小的部分,以便获得更易于理解且更简洁的代码。如图 1图 2 中所述,构造函数采用了两个字符串类型的参数,这是属性初始化所必需的。因此,首先为两个参数生成语法节点是个不错的想法:

// Generate parameters for the class' constructor
var constructorParameters = new SyntaxNode[] {
  generator.ParameterDeclaration("LastName",
  generator.TypeExpression(SpecialType.System_String)),
  generator.ParameterDeclaration("FirstName",
  generator.TypeExpression(SpecialType.System_String)) };

每个参数均使用 ParameterDeclaration 方法生成,该方法采用一个表示参数名称的字符串和一个表示参数类型的表达式。如你所知,这两个参数均为字符串类型,因此代码仅使用 TypeExpression 方法。将两个参数打包到 SyntaxNode 中的原因是 ConstructorDeclaration 希望用这种类型的对象表示参数。

现在你需要构造方法主体,这将利用你前面看到的 AssignmentStatement 方法,如下所示:

// Generate the constructor's method body
var constructorBody = new SyntaxNode[] {
  generator.AssignmentStatement(generator.IdentifierName("_lastName"),
  generator.IdentifierName("LastName")),
  generator.AssignmentStatement(generator.IdentifierName("_firstName"),
  generator.IdentifierName("FirstName"))};

本示例中有两个语句,均分组到 Syntax­Node 对象中。最后,你可以生成构造函数,并将参数和方法主体组合在一起:

// Generate the class' constructor
var constructor = generator.ConstructorDeclaration("Person",
  constructorParameters, Accessibility.Public,
  statements:constructorBody);

ConstructorDeclaration 类似于 MethodDeclaration,但是,ConstructorDeclaration 专门设计用于在 C# 中生成 .ctor 方法以及在 Visual Basic 中生成 Sub New 方法。

生成 CompilationUnit

到目前为止,你已经看到了如何为 Person 类中的每个成员生成代码。现在你需要将这些成员组合在一起,并为该类生成一个适当的 SyntaxNode。类成员必须以 SyntaxNode 形式提供,下面演示了如何将之前创建的所有成员组合在一起:

// An array of SyntaxNode as the class members
var members = new SyntaxNode[] { lastNameField,
  firstNameField, lastNameProperty, firstNameProperty,
  cloneMethodWithInterfaceType, constructor };

现在,你终于可以利用 ClassDeclaration 方法来生成 Person 类,如下所示:

// Generate the class
var classDefinition = generator.ClassDeclaration(
  "Person", typeParameters: null,
  accessibility: Accessibility.Public,
  modifiers: DeclarationModifiers.Abstract,
  baseType: null,
  interfaceTypes: new SyntaxNode[] { ICloneableInterfaceType },
  members: members);

与使用其他类型的声明一样,此方法需要指定名称、泛型类型(本示例中为 null)、可访问性级别、修饰符(本示例中为 Abstract,在 Visual Basic 中则为 MustInherit)、基本类型(本示例中为 null)以及实现的接口(本示例中为包含之前创建为语法节点的接口名称的 SyntaxNode)。你还可能想要将该类封装在一个命名空间中。SyntaxGenerator 包括 NamespaceDeclaration 方法,用于接受命名空间名称及其包含的 SyntaxNode。你可以按如下方式使用:

// Declare a namespace
var namespaceDeclaration = generator.NamespaceDeclaration("MyTypes", classDefinition);

编译器已经知道如何处理为完整的命名空间和嵌套的成员生成的语法节点,以及如何对语法执行代码分析,但是有时候你需要以 CompilationUnit(代表代码文件的类型)形式返回此结果。这通常用于分析器和代码重构。以下是你编写的用于返回 CompilationUnit 的代码:

// Get a CompilationUnit (code file) for the generated code
var newNode = generator.CompilationUnit(usingDirectives, namespaceDeclaration).
  NormalizeWhitespace();

此方法接受一个或多个 SyntaxNode 实例作为参数。

C# 和 Visual Basic 中的输出

完成所有工作之后,你就可以看到结果了。图 5 显示为 Person 类生成的 C# 代码。

为 Person 类生成的 C# Roslyn 代码
图 5 为 Person 类生成的 C# Roslyn 代码

现在,只需在创建新 AdhocWorkspace 的代码行中将语言更改为 VisualBasic:

generator = SyntaxGenerator.GetGenerator(workspace, LanguageNames.VisualBasic);

如果你重新运行代码,将会得到 Visual Basic 类定义,如图 6 中所示。

为 Person 类生成的 Visual Basic Roslyn 代码
图 6 为 Person 类生成的 Visual Basic Roslyn 代码

此处的关键点是,你使用 SyntaxGenerator 编写一次代码,就能够生成 C# 和 Visual Basic 代码,Roslyn 分析 API 可以使用此代码。完成之后,不要忘记对 AdhocWorkspace 实例调用 Dispose 方法,或者只是将你的代码附在正在使用的语句中。人无完人,生成的代码也可能包含错误,你也可以检查 ContainsDiagnostics 属性查看代码中是否存在任何诊断,并通过 GetDiagnostics 方法获取有关代码问题的详细信息。

与语言无关的分析器和重构

每当你需要对源代码执行丰富的分析时,你都可以使用 Roslyn API 和 SyntaxGenerator 类,但在使用分析器和代码重构时这种方法也非常有用。事实上,分析器、代码修补程序和重构分别具有 DiagnosticAnalyzer、ExportCodeFixProvider 和 ExportCodeRefactoringProvider 属性,各自接受主要和次要的受支持的语言。通过使用 SyntaxGenerator 代替 SyntaxFactory,你可以同时使用 C# 和 Visual Basic。

总结

来自 Microsoft.CodeAnalysis.Editing 命名空间的 SyntaxGenerator 类提供了与语言无关的生成语法节点的方法,从而使一个代码库中可以同时存在 C# 和 Visual Basic。使用这个功能强大的类,你能够以符合两个编译器的方式生成任何可能的语法元素,从而节省时间并提高代码的可维护性。


Alessandro Del Sole自 2008 年起被评为 Microsoft MVP。他已经 5 次获得年度 MVP 这一殊荣,发表过很多关于 Visual Studio .NET 开发的书籍、电子书、指导视频和文章。Del Sole 是 Brain-Sys 的解决方案开发专家,专注于 .NET 开发、培训和咨询。你可以关注他的 Twitter @progalex

衷心感谢以下 Microsoft 技术专家对本文的审阅: Anthony D. Green 和 Matt Warren
Anthony D. Green 是 Visual Basic 的项目经理。Anthony 也曾从事 Roslyn 方面的工作长达 5 年之久。他来自芝加哥,你可以在 Twitter @ThatVBGuy 上找到他