2017 年 5 月

第 32 卷,第 5 期

.NET Core - 使用 Roslyn 和 .NET Core 生成跨平台代码

作者 Alessandro Del Del

.NET Core 是一组开放源代码的模块式跨平台工具,可方便你生成在 Windows、Linux 和 macOS 上运行的下一代 .NET 应用程序 (microsoft.com/net/core/platform)。它还可以安装在 Windows 10 上以供 IoT 分发,并可在 Raspberry PI 等设备上运行。作为功能强大的平台,.NET Core 包含运行时、库和编译器,完全支持 C#、F# 和 Visual Basic 等语言。也就是说,不仅可以在 Windows 上编写 C# 代码,还可以在其他 OS 上编写,因为 .NET 编译器平台 (github.com/dotnet/roslyn) 亦称为“项目 Roslyn”,提供包含丰富代码分析 API 的开放源代码跨平台编译器。重要意义在于,可以使用 Roslyn API 在不同 OS 上执行许多与代码相关的操作,如代码分析、代码生成和编译。本文逐一介绍了在 .NET Core 上创建使用 Roslyn API 的 C# 项目所需执行的步骤,并介绍了一些有趣的代码生成和编译方案。此外,还介绍了一些基本反射技巧,用于在 .NET Core 上调用和运行使用 Roslyn 编译的代码。如果对 Roslyn 不熟悉,不妨先阅读下列文章:

安装 .NET Core SDK

第一步是安装 .NET Core 和 SDK。如果使用的是 Windows 并且已安装 Visual Studio 2017,那么 .NET Core 已包含在内,但前提是在安装期间在 Visual Studio 安装程序中选择了 .NET Core 跨平台开发工作负载。否则,只需打开 Visual Studio 安装程序,然后选择此工作负载并单击“修改”即可。如果使用的是 Windows 但不依赖 Visual Studio 2017,或使用的是 Linux 或 macOS,可以手动安装 .NET Core,并将 Visual Studio Code 用作开发环境 (code.visualstudio.com)。我将在本文中介绍后一种情况,因为 Visual Studio Code 本身就是跨平台产品;所以也是 .NET Core 的绝佳伴侣。此外,请务必安装适用于 Visual Studio Code 的 C# 扩展 (bit.ly/29b1Ppl)。由于 .NET Core 的安装步骤因 OS 而异,因此,请按 bit.ly/2mJArWx 上的说明操作。请务必安装最新版本。值得一提的是,.NET Core 的最新版本不再支持 project.json 文件格式,改为支持 MSBuild 内更通用的 .csproj 文件格式。

搭建 .NET Core C# 应用程序的基架

借助 .NET Core,可以创建控制台应用程序和 Web 应用程序。对于 Web 应用程序,在 .NET Core 按照路线图不断发展的过程中,Microsoft 会发布除 ASP.NET Core 模板以外的更多模板。由于 Visual Studio Code 是轻型编辑器,因此不会像 Visual Studio 一样提供项目模板。也就是说,需要在与应用程序同名的文件夹内通过命令行创建应用程序。下面的示例是以适用于 Windows 的操作说明为依据,但相同的概念也适用于 macOS 和 Linux。首先,打开命令提示符,然后转到磁盘上的文件夹。例如,假设文件夹名为 C:\Apps。请转到此文件夹,然后使用下面的命令新建子文件夹 RoslynCore:

> cd C:\Apps
> md RoslynCore
> cd RoslynCore

因此,RoslynCore 就是本文中介绍的示例应用程序的名称。这是一个控制台应用程序,不仅非常适用于说明用途,并且简化了 Roslyn 编码方式。还可以对 ASP.NET Core Web 应用程序使用相同的技术。若要为控制台应用程序新建空项目,只需键入下面的命令行即可:

> dotnet new console

这样一来,.NET Core 可以为 RoslynCore 控制台应用程序搭建 C# 项目基架。现在可以使用 Visual Studio Code 打开项目的文件夹。最简单的方法是键入下面的命令行:

> code .

当然,也可以从 Windows“开始”菜单打开 Visual Studio Code,然后手动打开项目文件夹。进入任意 C# 代码文件后,文件便会请求获取生成一些必需资产并还原一些 NuGet 包的权限(见图 1)。

Visual Studio Code 需要更新项目
图 1:Visual Studio Code 需要更新项目

下一步是添加使用 Roslyn 所需的 NuGet 包。

添加 Roslyn NuGet 包

正如你可能知道的,可以通过从 Microsoft.CodeAnalysis 层次结构安装一些 NuGet 包来使用 Roslyn API。安装这些包之前,请务必说明 Roslyn API 在 .NET Core 系统中的作用。如果曾在 .NET Framework 上使用过 Roslyn,可能会习惯于使用全套 Roslyn API。.NET Core 依赖 .NET Standard 库。也就是说,只能在 .NET Core 中使用支持 .NET Standard 的 Roslyn 库。截至本文撰写之时,大多数 Roslyn API 已对 .NET Core 可用,包括(但不限于)编译器 API(包含发出和诊断 API)和工作区 API。只有少数 API 尚不可移植,但由于 Microsoft 对 Roslyn 和 .NET Core 的投资巨大,因此有望在今后推出的版本中实现完整的 .NET Standard 兼容性。在 .NET Core 上运行的跨平台应用程序真实示例是 OmniSharp (bit.ly/2mpcZeF),其使用 Roslyn API 强力驱动代码编辑器的大部分功能,如完成列表和语法突出显示。

本文将介绍如何使用编译器和诊断 API。为此,需要将 Microsoft.CodeAnalysis.CSharp NuGet 包添加到项目中。借助基于 MSBuild 的新 .NET Core 项目系统,NuGet 包列表现已包含在 .csproj 项目文件中。在 Visual Studio 2017 中,可以使用 NuGet 的客户端 UI 来下载、安装和管理包,但 Visual Studio Code 中却没有等效选项。幸运的是,可以直接打开 .csproj 文件并查找包含 <PackageReference> 元素的 <ItemGroup> 节点,每个元素分别表示一个必需的 NuGet 包。修改节点,如下所示:

<ItemGroup>
  ...
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp"
    Version="2.0.0 " />
  <PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
</ItemGroup>

请注意,添加对 Microsoft.CodeAnalysis.C­Sharp 包的引用可以访问 C# 编译器的 API,System.Runtime.Loader 包是执行反射所必需,将在本文后面用到。

保存更改后,Visual Studio Code 会检测缺少的 NuGet 包,并提议还原它们。

代码分析: 分析源代码文本和生成语法节点

第一个示例与代码分析有关,展示了如何分析源代码文本和生成新语法节点。例如,假设你有以下简单业务对象,并且希望根据此对象生成视图模型类:

namespace Models
{
  public class Item
  {
    public string ItemName { get; set }
  }
}

此业务对象的文本可能来自不同的源,如 C# 代码文件、代码中的字符串或用户输入。借助代码分析 API,可以分析源代码文本,并生成编译器可以理解和控制的新语法节点。例如,假设代码如图 2 中所示,用于分析包含类定义的字符串,获取其对应的语法节点,并调用新的静态方法以通过语法节点生成视图模型。

图 2:分析源代码和检索语法节点

using System;
using RoslynCore;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
class Program
{
  static void Main(string[] args)
  {
    GenerateSampleViewModel();
  }
  static void GenerateSampleViewModel()
  {
    const string models = @"namespace Models
{
  public class Item
  {
    public string ItemName { get; set }
  }
}
";
    var node = CSharpSyntaxTree.ParseText(models).GetRoot();
    var viewModel = ViewModelGeneration.GenerateViewModel(node);
    if(viewModel!=null)
      Console.WriteLine(viewModel.ToFullString());
    Console.ReadLine();
  }
}

由于将在 ViewModelGeneration 静态类中定义 GenerateViewModel 方法,因此,请向项目添加新的 ViewModelGeneration.cs 文件。此方法将在输入语法节点(为了方便本文演示,即 ClassDeclarationSyntax 对象的第一个实例)中查找类定义,然后根据类名和类成员构造新的视图模型。图 3 展示了此过程。

图 3:生成新的语法节点

using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis;
using System.Linq;
using Microsoft.CodeAnalysis.CSharp;
namespace RoslynCore
{
  public static class ViewModelGeneration
  {
    public static SyntaxNode GenerateViewModel(SyntaxNode node)
    {
      // Find the first class in the syntax node
      var classNode = node.DescendantNodes()
       .OfType<ClassDeclarationSyntax>().FirstOrDefault();
      if(classNode!=null)
      {
        // Get the name of the model class
        string modelClassName = classNode.Identifier.Text;
        // The name of the ViewModel class
        string viewModelClassName = $"{modelClassName}ViewModel";
        // Only for demo purposes, pluralizing an object is done by
        // simply adding the "s" letter. Consider proper algorithms
        string newImplementation =
          $@"public class {viewModelClassName} : INotifyPropertyChanged
{{
public event PropertyChangedEventHandler PropertyChanged;
// Raise a property change notification
protected virtual void OnPropertyChanged(string propname)
{{
  PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propname));
}}
private ObservableCollection<{modelClassName}> _{modelClassName}s;
public ObservableCollection<{modelClassName}> {modelClassName}s
{{
  get {{ return _{modelClassName}s; }}
  set
  {{
    _{modelClassName}s = value;
    OnPropertyChanged(nameof({modelClassName}s));
  }}
}}
public {viewModelClassName}() {{
// Implement your logic to load a collection of items
}}
}}
";
          var newClassNode =
            CSharpSyntaxTree.ParseText(newImplementation).GetRoot()
            .DescendantNodes().OfType<ClassDeclarationSyntax>()
            .FirstOrDefault();
          // Retrieve the parent namespace declaration
          if(!(classNode.Parent is NamespaceDeclarationSyntax)) return null;
          var parentNamespace = (NamespaceDeclarationSyntax)classNode.Parent;
          // Add the new class to the namespace and adjust the white spaces
          var newParentNamespace =
            parentNamespace.AddMembers(newClassNode).NormalizeWhitespace();
          return newParentNamespace;
        }
      }
      else
      {
        return null;
      }
    }
  }
}

图 3 中的第一部分代码展示了如何将视图模型表示为字符串(使用字符串内插以轻松根据原始类名指定对象和成员名称)。在此示例方案中,只需向对象/成员名称添加“s”即可生成复数;在真实的代码中,应使用更为明确的复数算法。

图 3 的第二部分中,代码调用 CSharpSyntaxTree.ParseText,以在 SyntaxTree 中分析源代码文本。将调用 GetRoot 来检索新树的 SyntaxNode;使用 DescendantNodes().OfType<ClassDeclarationSyntax>(),代码只会检索表示类的语法节点,同时仅使用 FirstOrDefault 选择第一个类。检索语法节点中的第一个类就足以获取其中插入了新视图模型类的父级命名空间。可以通过将 ClassDeclarationSyntax 的 Parent 属性转换成 NamespaceDeclarationSyntax 对象来获取命名空间。由于可以将一个类嵌套到另一个类中,因此代码首先会验证 Parent 是否属于 NamespaceDeclarationSyntax 类型,从而检查是否存在这种可能性。最后一部分代码将视图模型类的新语法节点添加到父级命名空间中,同时将此结果作为语法节点返回。如果现在按 F5,则会在调试控制台中看到生成的代码结果,如图 4 中所示。

视图模型类已正确生成
图 4:视图模型类已正确生成

生成的视图模型类是可与 C# 编译器结合使用的 SyntaxNode,因此可以进一步控制它,并能通过分析它来获取诊断信息,同时还可以使用发出 API 将它编译到程序集中,并通过反射加以利用。

获取诊断信息

无论源代码文本的源是字符串、文件还是用户输入,均可使用诊断 API 检索代码问题(如错误和警告)的相关诊断信息。请注意,使用诊断 API,不仅可以检索错误和警告,还可以编写分析器和重构代码。继续以前面的示例为例,最好先检查原始源代码文本中是否有语法错误,然后再生成视图模型类。为此,可以调用 SyntaxNode.GetDiagnostics 方法,该方法返回 IEnumerable<Microsoft.CodeAnalysis.Diagnostic> 对象(若有)。有关 ViewModelGeneration 类的扩展版,请参阅图 5。代码会检查 GetDiagnostics 的调用结果是否包含任何诊断信息。如果不包含,代码会生成视图模型类。如果调用结果包含一系列诊断信息,代码会显示所有诊断信息并返回 null。诊断类可提供所有代码问题的详细信息。例如,Id 属性返回诊断 ID;GetMessage 方法返回完整的诊断消息;GetLineSpan 返回在源代码中的诊断位置;Severity 属性返回诊断严重级别,如 Error、Warning 或 Information。

图 5:使用诊断 API 检查是否存在代码问题

using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis;
using System.Linq;
using Microsoft.CodeAnalysis.CSharp;
using System;
namespace RoslynCore
{
  public static class ViewModelGeneration
  {
    public static SyntaxNode GenerateViewModel(SyntaxNode node)
    {
      // Find the first class in the syntax node
      var classNode =
        node.DescendantNodes().OfType<ClassDeclarationSyntax>().FirstOrDefault();
      if(classNode!=null)
      {
        var codeIssues = node.GetDiagnostics();
        if(!codeIssues.Any())
        {
          // Get the name of the model class
          var modelClassName = classNode.Identifier.Text;
          // The name of the ViewModel class
          var viewModelClassName = $"{modelClassName}ViewModel";
          // Only for demo purposes, pluralizing an object is done by
          // simply adding the "s" letter. Consider proper algorithms
          string newImplementation =
            $@"public class {viewModelClassName} : INotifyPropertyChanged
{{
public event PropertyChangedEventHandler PropertyChanged;
// Raise a property change notification
protected virtual void OnPropertyChanged(string propname)
{{
  PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propname));
}}
private ObservableCollection<{modelClassName}> _{modelClassName}s;
public ObservableCollection<{modelClassName}> {modelClassName}s
{{
  get {{ return _{modelClassName}s; }}
  set
  {{
    _{modelClassName}s = value;
    OnPropertyChanged(nameof({modelClassName}s));
  }}
}}
public {viewModelClassName}() {{
// Implement your logic to load a collection of items
}}
}}
";
            var newClassNode =
              SyntaxFactory.ParseSyntaxTree(newImplementation).GetRoot()
              .DescendantNodes().OfType<ClassDeclarationSyntax>()
              .FirstOrDefault();
            // Retrieve the parent namespace declaration
            if(!(classNode.Parent is NamespaceDeclarationSyntax)) return null;
            var parentNamespace = (NamespaceDeclarationSyntax)classNode.Parent;
            // Add the new class to the namespace
            var newParentNamespace =
              parentNamespace.AddMembers(newClassNode).NormalizeWhitespace();
            return newParentNamespace;
          }
          else
          {
            foreach(Diagnostic codeIssue in codeIssues)
          {
            string issue = $"ID: {codeIssue.Id}, Message: {codeIssue.GetMessage()},
              Location: {codeIssue.Location.GetLineSpan()},
              Severity: {codeIssue.Severity}";
            Console.WriteLine(issue);
          }
          return null;
        }
      }
      else
      {
        return null;
      }
    }
  }
}

现在,如果故意将一些错误引入模型变量(位于 Program.cs 中的 GenerateSampleViewModel 方法内)中包含的源代码文本,然后运行应用程序,就可以看到 C# 编译器返回所有代码问题的完整详情。图 6 展示了相关示例。

使用诊断 API 检测代码问题
图 6:使用诊断 API 检测代码问题

值得注意的是,即使 C# 编译器包含诊断信息,仍会生成语法树。这样不仅可以完全忠实于源代码文本,还可以让开发者视需要使用新语法节点修复这些问题。

执行代码:发出 API

使用发出 API,可以将源代码编译到程序集中。然后,可以使用反射来调用并执行代码。下一个示例结合了代码生成、发出和诊断检测。将新文件 EmitDemo.cs 添加到项目中,然后假设代码列表如图 7 中所示。如你所见,SyntaxTree 通过定义 Helper 类的源代码文本生成,此类包含用于计算圆面积的静态方法。我们的目标是通过此类生成 .dll,然后将半径作为自变量传递,从而执行 CalculateCircleArea 方法。

图 7:使用发出 API 和反射来编译和执行代码

using System;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
namespace RoslynCore
{
  public static class EmitDemo
  {
    public static void GenerateAssembly()
    {
      const string code = @"using System;
using System.IO;
namespace RoslynCore
{
 public static class Helper
 {
  public static double CalculateCircleArea(double radius)
  {
    return radius * radius * Math.PI;
  }
  }
}";
      var tree = SyntaxFactory.ParseSyntaxTree(code);
      string fileName="mylib.dll";
      // Detect the file location for the library that defines the object type
      var systemRefLocation=typeof(object).GetTypeInfo().Assembly.Location;
      // Create a reference to the library
      var systemReference = MetadataReference.CreateFromFile(systemRefLocation);
      // A single, immutable invocation to the compiler
      // to produce a library
      var compilation = CSharpCompilation.Create(fileName)
        .WithOptions(
          new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
        .AddReferences(systemReference)
        .AddSyntaxTrees(tree);
      string path = Path.Combine(Directory.GetCurrentDirectory(), fileName);
      EmitResult compilationResult = compilation.Emit(path);
      if(compilationResult.Success)
      {
        // Load the assembly
        Assembly asm =
          AssemblyLoadContext.Default.LoadFromAssemblyPath(path);
        // Invoke the RoslynCore.Helper.CalculateCircleArea method passing an argument
        double radius = 10;
        object result = 
          asm.GetType("RoslynCore.Helper").GetMethod("CalculateCircleArea").
          Invoke(null, new object[] { radius });
        Console.WriteLine($"Circle area with radius = {radius} is {result}");
      }
      else
      {
        foreach (Diagnostic codeIssue in compilationResult.Diagnostics)
        {
          string issue = $"ID: {codeIssue.Id}, Message: {codeIssue.GetMessage()},
            Location: {codeIssue.Location.GetLineSpan()},
            Severity: {codeIssue.Severity}";
          Console.WriteLine(issue);
        }
      }
    }
  }
}

在第一部分中,代码新建编译命令,表示一个对 C# 编译器的不可变调用。使用 CSharpCompilation 对象,可以通过 Create 方法创建程序集;使用 WithOptions,可以指定生成哪种输出(在此示例中是 DynamicallyLinkedLibrary)。AddReferences 用于添加代码可能需要的任何引用;为此,必须提供包含代码需要的相同引用的类型。在此特定示例中,只需包含对象类型依赖的相同引用。使用 Get­TypeInfo().Assembly.Location,可以检索引用的程序集名称,然后 MetadataReference.CreateFromFile 会在编译命令中创建对程序集的引用。最后,使用 AddSyntaxTrees 将语法树添加到编译命令中。

在第二部分代码中,对 CSharpCompilation.Emit 的调用尝试生成二进制文件,并返回 EmitResult 类型的对象。后一种结果非常有趣: 它会公开 bool 类型的 Success 属性,用于指明编译是否成功;还会公开 Diagnostics 属性,用于返回 Diagnostic 对象的不可变数组,这些对象对于了解编译失败原因非常有帮助。在图 7 中,很容易就可以看出在编译失败后 Diagnostics 属性是如何进行循环访问的。必须提及的是,由于输出程序集是 .NET Standard 库,因此只有当使用 Roslyn 分析的代码依赖 .NET Standard 中包含的 API 时,编译源代码文本才会成功。

现在,让我们来了解一下编译成功后会出现什么情况。文章开头部分导入的同名 NuGet 包中包含的 System.Runtime.Loader 命名空间会公开 Assembly­LoadContext 单独类,此类会公开 LoadFromAssemblyPath 方法。此方法会返回 Assembly 类的实例,以便你可以使用反射先获取对 Helper 类的引用,然后获取对 CalculateCircleArea 方法(可以通过传递 radius 参数的值来调用)的引用。MethodInfo.Invoke 方法会收到 null 作为第一个自变量,因为 CalculateCircleArea 是静态方法;所以无需传递任何类型实例。如果现在通过 Program.cs 中的 Main 调用 GenerateAssembly 方法,这项工作的结果如图 8 中所示,即计算结果显示在调试控制台中。

通过反射 Roslyn 生成的代码进行调用的结果
图 8:通过反射 Roslyn 生成的代码进行调用的结果

正如你想象的那样,.NET Core 中的发出 API 及反射提供了强大的功能和极大的灵活性,因为你可以生成、分析和执行 C# 代码,无论 OS 如何。实际上,本文中介绍的所有示例不仅一定可以在 Windows 上运行,还一定可以在 macOS 和大多数 Linux 发行版本上运行。此外,还可以使用 Roslyn 脚本 API 完成调用库中代码,因此不仅限于使用反射。

总结

使用 .NET Core,可以编写 C# 代码来创建能够在多个 OS 和设备上运行的跨平台应用程序,因为编译器本身就是跨平台产品。Roslyn(即 .NET 编译器平台)强力驱动了 .NET Core 上的 C# 编译器,可方便开发者使用丰富的代码分析 API 执行代码生成、分析和编译。这意味着,可以通过便捷地生成和执行代码来自动化执行任务,并能分析源代码文本中存在的代码问题,同时还可以在 Windows、macOS 和 Linux 上对源代码执行大量活动。


Alessandro Del Sole自 2008 年起被评为 Microsoft MVP。他已经 5 次获得年度 MVP 这一殊荣,发表过很多关于 Visual Studio .NET 开发的书籍、电子书、指导视频和文章。Del Sole 是专门从事 .NET 和移动应用开发、培训和咨询的资深 .NET 开发者。你可以关注他的 Twitter @progalex

衷心感谢以下 Microsoft 技术专家对本文的审阅: Derick Campbell
Dustin Campbell 是 Microsoft 的首席工程师及 C# 语言设计团队的成员。自 Roslyn 诞生之初 Dustin 便从事其研究,现负责 Visual Studio Code 的 C# 扩展。