Share via



2017 年 5 月

第 32 卷,第 5 期

此文章由机器翻译。

.NET Core - Roslyn 及 .NET Core 提供的跨平台程式碼產生

Alessandro Del Del

.NET core 是模組化中,開啟來源和跨平台工具組,可讓您建置下一代.NET 應用程式,而在 Windows、 Linux 和 macOS 上執行 (microsoft.com/net/core/platform)。它也可以安裝在 Windows 10 for IoT 發佈和 Raspberry PI 等裝置上執行。.NET core 是功能強大的平台,包含執行階段、 程式庫及編譯器,而 C#、 F # 和 Visual Basic 等語言的完整支援。這表示您可以撰寫程式碼在 C# 不只在 Windows 中,但也不同作業系統上因為.NET 編譯器平台 (github.com/dotnet/roslyn),也稱為"Roslyn 專案 」,提供開放原始碼、 跨平台編譯器具有豐富的程式碼分析 Api。為重要的含意,您可以利用 Roslyn Api 來執行許多程式碼相關的作業,例如程式碼分析、 程式碼產生和編譯的不同作業系統上。本文逐步解說將.NET Core 上的 C# 專案設定為使用 Roslyn Api 的必要步驟,並解釋一些有趣的程式碼產生和編譯的案例。它也會討論一些基本的反映技術來叫用,並執行以.NET Core 上的 Roslyn 編譯程式碼。如果您熟悉 Roslyn,您會想要先閱讀下列文章︰

安裝.NET 核心 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.visualstudio.com)。後者是我將在本文中討論的案例,因為 Visual Studio 程式碼是跨平台本身。因此,它是.NET core 的好夥伴。此外,請務必安裝適用於 Visual Studio 程式碼的 C# 延伸模組 (bit.ly/29b1Ppl)。安裝.NET Core 的步驟是作業系統而有所不同,因此遵循bit.ly/2mJArWx。請確定安裝最新版本。值得一提的是.NET Core 的最新版本不再支援的 project.json 檔案格式,但改為支援使用 MSBuild 較常見的.csproj 檔案格式。

Scaffolding 在 C#.NET 核心應用程式

.NET core,您可以建立主控台應用程式和 Web 應用程式。Web 應用程式,Microsoft 正在進行.NET Core 移至下一頁藍圖上更多範本可供使用,除了 ASP.NET Core 範本。Visual Studio 程式碼是一個輕量型的編輯器,因為它並不提供專案範本,因為 Visual Studio 會執行。這表示您要從命令列在其名稱也會在應用程式名稱的資料夾內建立的應用程式。下列範例根據指示適用於 Windows,但相同的概念套用到 macOS 及 Linux。若要開始,開啟命令提示字元,並在磁碟上移動到資料夾。比方說,假設您有一個名為 C:\Apps 資料夾,移至此資料夾,並建立新的子資料夾,稱為 RoslynCore,使用下列命令︰

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

因此,RoslynCore 會是本文所討論的範例應用程式的名稱。它會是主控台應用程式,這非常適合教學說明之用,並簡化使用 Roslyn 撰寫程式碼的方法。您也可以使用相同的技術,在 ASP.NET 核心 Web 應用程式。若要建立新的空白專案的主控台應用程式時,只要輸入下列命令列︰

> dotnet new console

如此一來,.NET Core scaffolds 稱為 RoslynCore 的主控台應用程式的 C# 專案。現在您可以使用 Visual Studio 程式碼來開啟專案的資料夾。最簡單的方法輸入下列命令列︰

> code .

當然,可以從 [開始] 功能表開啟 Visual Studio 程式碼,然後手動開啟專案資料夾。一旦您輸入任何 C# 程式碼檔案時,系統會詢問您的權限以產生一些必要的資產,以及還原某些 NuGet 封裝 (請參閱**[圖 1**)。

Visual Studio 程式碼需要更新專案
[圖 1 的 Visual Studio 程式碼需要更新專案

下一個步驟新增使用 Roslyn 所需的 NuGet 封裝。

新增 Roslyn NuGet 套件

您可能已經知道,Roslyn Api 可供從 Microsoft.CodeAnalysis 階層安裝某些 NuGet 封裝。安裝這些封裝之前, 是釐清 Roslyn Api 如何配合.NET 核心系統。如果您曾經用過 Roslyn 在.NET Framework 上,您可用於利用完整的 Roslyn Api。.NET core 依賴.NET 標準的程式庫,這表示可以使用僅支援.NET 標準的 Roslyn 程式庫,.NET core。在撰寫本文時,大部分的 Roslyn Api 既有.NET core 相同,包括 (但不是限於) 編譯器 Api (使用 Emit 和診斷 Api) 和工作區 Api。只有少數 Api 還沒有可攜性,但 Microsoft 投入發展已大幅 Roslyn 和.NET Core 假以時日完整.NET 標準的相容性在未來的版本。.NET Core 執行的跨平台應用程式的真實世界範例是 OmniSharp (bit.ly/2mpcZeF),它會利用 Roslyn Api,可讓大部分的程式碼編輯器功能,例如完成清單和語法反白顯示。

在本文中,您將了解如何運用編譯器和診斷 Api。若要達成此目的,您需要將 Microsoft.CodeAnalysis.CSharp NuGet 封裝新增至您的專案。使用 MSBuild 為基礎的新.NET Core 專案系統、 NuGet 套件清單現在會包含在.csproj 專案檔。在 Visual Studio 2017,您可以使用用戶端 UI NuGet 的下載、 安裝和管理封裝,但在 Visual Studio 程式碼中並沒有對等選項。幸運的是,您可以直接開啟.csproj 檔,找出 < p > 表示的節點,包含 < PackageReference > 項目,每個必要的 NuGet 套件。修改節點,如下所示︰

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

請注意,將參考加入 Microsoft.CodeAnalysis.CSharp 封裝可讓您存取 C# 編譯器的 Api,而且是為了反映 System.Runtime.Loader 封裝,並將在本文稍後使用。

當您儲存您的變更時,Visual Studio 程式碼將會偵測遺漏的 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 的父屬性轉型成 NamespaceDeclarationSyntax 物件。因為無法巢狀類別,為另一個類別,程式碼第一次檢查驗證屬於父型別的 NamespaceDeclarationSyntax 這種可能性。最終程式碼片段會將檢視模型類別的新語法節點加入至傳回此語法節點的父命名空間。如果您現在按下 F5,您會看到在偵錯主控台中,程式碼產生的結果中所示**[圖 4**。

已正確產生檢視模型類別
[圖 4 已經正確產生檢視模型類別

產生的檢視模型類別是 C# 編譯器可以使用,以便進行進一步操作、 分析的診斷資訊,編譯成組件與發出的 Api 並利用反映 SyntaxNode。

取得診斷資訊

原始程式文字是否會來自字串、 檔案或使用者輸入,您可以利用診斷 Api 來擷取診斷資訊的程式碼問題,例如錯誤和警告。請記住,診斷 Api 不僅能擷取錯誤和警告,它們也允許寫入分析器和程式碼重整作業。繼續前述的範例,最好檢查之前嘗試產生檢視模型類別中的原始來源文字的語法錯誤。若要這麼做,您可以叫用 SyntaxNode.GetDiagnostics 方法,它會傳回 IEnumerable < Microsoft.CodeAnalysis.Diagnostic > 物件,如果有的話。看看**[圖 5**,提供 ViewModelGeneration 類別的擴充的版本。程式碼會檢查叫用 GetDiagnostics 的結果是否包含任何診斷。如果沒有,程式碼會產生檢視模型類別。相反地,結果會包含一系列診斷,如果程式碼會顯示每一個診斷資訊,則傳回 null。診斷的類別會提供非常細微的資訊,每個程式碼問題;例如,Id 屬性會傳回診斷的識別碼。GetMessage 方法會傳回完整的診斷訊息。GetLineSpan 傳回原始程式碼中的診斷位置和 [嚴重性] 屬性會傳回診斷的嚴重性,例如錯誤、 警告或資訊。

[圖 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;
      }
    }
  }
}

現在,如果您引入內部 GenerateSampleViewModel 方法在 Program.cs 中,相同的變數模型中所包含之原始程式文字的某些刻意設計的錯誤,並執行應用程式,您可以看到如何在 C# 編譯器會傳回有關每個程式碼問題的完整詳細資料。圖 6 顯示了一個範例。

偵測與診斷 Api 的程式碼問題
[圖 6 偵測與診斷 Api 的程式碼問題

值得注意的是 C# 編譯器會產生語法樹狀目錄,即使它包含診斷。不僅沒有完整不失真的原始程式文字中的這個結果,它也讓開發人員若要以新的語法節點修正這些問題的選項。

執行程式碼︰ 發出 Api

發出 Api 可讓來源的程式碼編譯成組件。然後,使用反映叫用並執行程式碼。下一個範例是產生程式碼的組合,發出和診斷的偵測。加入新檔案加入專案中,呼叫 EmitDemo.cs,那麼請考慮所示的程式碼片段**[圖 7**。如您所見,從原始程式文字定義包含靜態方法,以計算圓的面積的 Helper 類別產生的公司 SyntaxTree。目標是產生自這個類別的.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 物件能讓您建立的組件和 WithOptions 可讓您指定的輸出來產生,在此案例的 DynamicallyLinkedLibrary 種類。AddReferences 用來新增您的程式碼可能需要與不再需要的任何參考若要這麼做,您必須提供具有所需的程式碼的參考相同的類型。在此案例中,您只需要相同的物件類型所依賴的參考。使用 GetTypeInfo()。Assembly.Location 擷取參考的組件名稱,然後 MetadataReference.CreateFromFile 會建立內部編譯的組件的參考。最後,在語法樹狀目錄加入至 AddSyntaxTrees 進行編譯。

在第二個部分的程式碼中,CSharpCompilation.Emit 在引動過程會嘗試產生二進位檔,並傳回 EmitResult 型別的物件。後者是非常有趣︰ 它會公開成功屬性的型別 bool,指出成功編譯,而且它還會公開一個稱為診斷,它會傳回不可變的陣列,可以是很適合用來了解編譯失敗原因的診斷物件的屬性。在**[圖 7**,您可以輕鬆地查看如何逐一查看診斷屬性如果編譯會失敗。請務必提到,輸出組件是.NET 標準程式庫,因此編譯原始程式文字以 Roslyn 剖析程式碼需要在隨附於.NET 標準的 Api 時,才會成功。

現在讓我們看看如果編譯成功,會發生什麼事。System.Runtime.Loader 命名空間,包含在匯入的文件開頭的名稱相同的 NuGet 封裝會公開單一類別,稱為 AssemblyLoadContext 會公開一個稱為 LoadFromAssemblyPath 的方法。這個方法傳回的組件類別,可讓您使用反映來取得第一個執行個體的協助程式類別,然後取得 CalculateCircleArea 方法,您可以藉由傳遞 radius 參數的值叫用參考的參考。MethodInfo.Invoke 方法會接收第一個引數為 null CalculateCircleArea 是靜態方法因為因此,您不需要傳遞任何型別執行個體。如果您現在可以呼叫 GenerateAssembly 方法從 「 主要 Program.cs 中,您會看到這項工作的結果,如所示**[圖 8**,其中是偵錯主控台中顯示計算的結果。

透過反映 Roslyn 產生的程式碼的引動過程的結果
[圖 8 透過 Roslyn 產生的程式碼反映引動過程的結果

您可以想像,發出 Api 加上.NET Core 中的反映提供您絕佳的功能與彈性,因為您可以產生、 分析及執行 C# 程式碼無論 OS 為何。事實上,所有本文所討論的範例肯定會執行不只在 Windows 中,還會依據 macOS 和大部分的 Linux 散發套件。此外,叫用從程式庫的程式碼即可使用 Roslyn 指令碼的 Api,因此您不限於反映。

總結

.NET core 可讓您撰寫 C# 程式碼來建立多個作業系統和裝置執行的跨平台應用程式,這是因為編譯器會跨平台本身。Roslyn,.NET 編譯器平台,可讓.NET Core 上的 C# 編譯器,並可讓開發人員運用豐富的程式碼分析 Api 來執行程式碼產生、 分析和編譯。這表示您可以自動產生,並立即執行程式碼執行工作、 分析程式碼問題的來源文字以及在原始程式檔執行大量的活動,在 Windows、 macOS 及 Linux 上。


Alessandro Del Sole2008年之後已經是 Microsoft MVP。獲得一年五倍的 MVP,他著有許多書籍、 電子書,說明影片和使用 Visual Studio.NET 開發相關的文件。Del Sole 擔任資深.NET 開發人員,將焦點放在.NET 和行動裝置應用程式開發訓練和諮詢。您也可以關注他的 Twitter: @progalex

感謝下列 Microsoft 技術專家來檢閱這份文件︰ Dustin Campbell
Dustin Campbell 是首席工程師,Microsoft C# 語言設計團隊的成員。Dustin Roslyn 處理從開始,目前負責 Visual Studio 程式碼的 C# 副檔名。