2

用 Roslyn APIs 解析 C# 實現程式碼產生器

 1 year ago
source link: https://blog.darkthread.net/blog/syntax-tree-analysis/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

用 Roslyn APIs 解析 C# 實現程式碼產生器-黑暗執行緒

我很愛用程式產生器節省無謂手工,其中有個經典應用是用程式產生器將服務元件轉成 WebAPI/MVC Controller + 客戶端呼叫程式庫,原理不難,程式產生器參照 C# DLL,用 Reflection 找出方法及所需參數,再從 XML Documentation 註解檔(.xml)取出對應說明,不費吹灰之力,包含完整說明的 WebAPI 及客戶端元件就完成了。而每次增加或修改 API 時,重跑程式產生器就行了,十分省事。

2014 年微軟推出開源 Roslyn 編譯平台,讓開發人員更容易參與及控制程式碼編輯及編譯作業,一般開發者也能實現像 Visual Studio 更名變數、IntelliSense 即時提示程式碼等神奇效果。Roslyn 提供的 APIs 及功能如下:

Fig2_637881772384937681.png
圖片來源

我對其中的 Syntax Tree API 很感興趣。想知道 Class 有哪些方法,傳統做法都要先編譯出 DLL,分析程式載入 DLL 再透過 Reflection .GetMethods() 查詢方法,註解說明則要分析 .xml 檔才能取得。使用 Syntax Tree API,要分析的程式碼不需編譯,有原始碼文字檔即可解析,從 .cs 檔即可得到結果,而最棒的是,XML Documentation 註解可由 .cs 擷取,不需額外載入及解析 .xml 檔。

聽起來蠻棒的,我決定來玩玩看。

要處理 Syntax Tree (語法樹) 最好先建立基本概念。可以把 Syntax Tree 想成像 XML、HTML DOM 之類的樹狀結構,每個節點包著零到多個子節點,子節點又可以包含自己的子節點。組成 Syntax Tree 的元素區分為三大類:

  1. Syntax Node
    包含宣告、Statement、子句 (Clauses)、表達式 (Expressions),Node 一定不是最末端,其下包含其他 Node 或 Token,可透過 ChildNodes() 取得集合。
  2. Syntax Token
    包含 Keyword、Identifier、Literal、Punctuation(標點),是最小的語意分割單位,不會再包含其他 Node 或 Token。
  3. Syntax Trivia
    對語意解析較無關的文字(換言之,移掉也不影響語意),例如:空白、註解、Preprocessor Directive 等。

最快搞懂 Syntax Tree 結構的方法,是在 Visual Studio 安裝 .NET Compiler Platform SDK,然後用 Syntax Visualizer 實地觀察。

Fig1_637881772386156592.png

裝好 .NET Compiler Platform SDK 後,由 View / Other Windows 打開 Syntax Visualizer,它會即時顯示編輯中的 C# 的 Syntax Tree,點選樹狀結構其中的 Node 或 Token,對映的程式碼片段會反白。Syntax Visualizer 的藍色項目是 Syntax Node、綠色項目是 Syntax Token、橘色項目是 Syntax Trivia。PublicKeyword、Predefined Type、IdentifierToken... 這些名稱則是 Node 或 Token 的 Kind,這些資訊將是後續寫程式的重要參考。延伸閱讀

Fig3_637881772388551822.gif

Syntax Tree 分析程式的原理是從根元素開始,一層層向下展開,尋找或識別特定 Kind 的 Node 或 Token,一點一點拼湊出語意。由於 Node、Token 的組合無窮無盡,不可能把所有組合規則都考慮進去,這樣等於在寫 C# Compiler 了,太困難也沒必要,一般只需涵蓋應用情境會遇到的組合就好。以我想做的應用「依據服務型別產生對映 WebAPI/MVC Controller 及客戶端呼叫元件」來說,服務型別通常有固定的樣式,會出現哪些 Node、Token 及結構都差不多,開發時用 Syntax Visualize 觀察寫成固定邏輯,不需花太多力氣就能寫好程式產生器投入生產,等遇到沒考慮到的狀況再持續擴充、增加彈性。

想自己寫程式做語意分析,可先從官方文件入門教學開始。在 VS2019+,先裝好 Visual Studio extension development workload,開專案參照 Microsoft.CodeAnalysis.CSharp.Workspaces。

接著 CSharpSyntaxTree.ParseText(code) 將 .cs 內容解析成 SyntaxTree、.GetCompilationUnitRoot() 取得根元素,再來就參考 Syntax Visualizer 的分析結果,依你要分析的程式碼結構,找到 Namespace、Interface/Class 等 Node,再從 ChildNodes() 或 ChildNodesAndTokens() 一層層剝下去,程式碼樣式愈固定,分析程式愈好寫。

我做了一個簡單練習,要對以下 Interface 進行解析:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;

namespace SyntaxAnalysisTest
{
    /// <summary>
    /// 為賦新詞強說愁
    /// </summary>
    public interface IMyService
    {
        /// <summary>
        /// 隨機產生 GUID
        /// </summary>
        /// <returns>GUID</returns>
        Guid GetGuid();
        /// <summary>
        /// 產生指定數量的隨機數
        /// </summary>
        /// <param name="count">數量</param>
        /// <returns>隨機數陣列</returns>
        int[] GenRandNumbers(int count);
        /// <summary>
        /// 複雜參數
        /// </summary>
        /// <param name="ints">整數陣列</param>
        /// <param name="mapping">對照表</param>
        /// <param name="args">不定數量參數</param>
        [Description("TEST")]
        void Complex(List<int> ints, Dictionary<string, string> mapping = null,
            params string[] args);

    }
}

目的是得到以下結果:

Fig4_637881772389391523.png

解析程式範例:

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

namespace SyntaxAnalysisTest
{
    public static class Analyzer
    {
        public static IEnumerable<SyntaxNodeOrToken> FilterByKind(this ChildSyntaxList list, SyntaxKind kind)
            => list.Where(o => o.IsKind(kind));
        public static SyntaxNodeOrToken FindKind(this ChildSyntaxList list, SyntaxKind kind)
            => FilterByKind(list, kind).FirstOrDefault();
        public static SyntaxNodeOrToken FindKind(this SyntaxNodeOrToken nodeOrToken, SyntaxKind kind)
            => FindKind(nodeOrToken.ChildNodesAndTokens(), kind);
        public static SyntaxNodeOrToken FindKind(this SyntaxNode node, SyntaxKind kind)
            => FindKind(node.ChildNodesAndTokens(), kind);
        public static string GetDocComments(this SyntaxNodeOrToken node)
        {
            var first = node.ChildNodesAndTokens().FirstOrDefault();
            if (first != null && first.HasLeadingTrivia) return first.GetLeadingTrivia().ToString();
            return null;
        }

        static void Print(string msg, ConsoleColor? color = null)
        {
            Console.ForegroundColor = color ?? ConsoleColor.White;
            if (color == null) Console.Write("\t");
            Console.WriteLine(msg);
            Console.ResetColor();
        }

        public static void Parse(string code)
        {
            var tree = CSharpSyntaxTree.ParseText(code);
            var root = tree.GetCompilationUnitRoot();
            Print($"共 {root.Usings.Count} 筆 using", ConsoleColor.Yellow);
            foreach (var elem in root.Usings)
                Print($" * {elem.ToString()}");
            var ns = root.Members.FirstOrDefault(o => o.Kind() == SyntaxKind.NamespaceDeclaration);
            if (ns != null)
            {
                Print("命名空間", ConsoleColor.Yellow);
                Print(ns.FindKind(SyntaxKind.IdentifierName).ToString());
                var intf = ns.FindKind(SyntaxKind.InterfaceDeclaration);
                if (intf != null)
                {
                    Print("介面名稱", ConsoleColor.Yellow);
                    Print(intf.FindKind(SyntaxKind.IdentifierToken).ToString());
                    var comment = intf.GetDocComments();
                    if (comment != null)
                        Print(comment.ToString(), ConsoleColor.Green);
                    foreach (var method in intf.ChildNodesAndTokens().FilterByKind(SyntaxKind.MethodDeclaration))
                    {
                        Print($"方法: {method.FindKind(SyntaxKind.IdentifierToken).ToString()}", ConsoleColor.Cyan);
                        comment = method.GetDocComments();
                        if (comment != null)
                            Print(comment, ConsoleColor.Green);
                        var paramList = new List<string>();
                        var def = new List<string>() { "public" };
                        var attrs = string.Empty;
                        foreach (var n in method.ChildNodesAndTokens())
                        {
                            if (n.IsKind(SyntaxKind.AttributeList))
                            {
                                attrs = n.ToString();
                                Print($"Attribute: {attrs}", ConsoleColor.Magenta);
                                continue;
                            }
                            if (n.IsKind(SyntaxKind.PublicKeyword) || n.IsKind(SyntaxKind.SemicolonToken))
                                continue;
                            if (n.IsKind(SyntaxKind.ParameterList))
                            {
                                foreach (var p in n.ChildNodesAndTokens().FilterByKind(SyntaxKind.Parameter))
                                    Print($"參數: {p.ToString()}", ConsoleColor.Cyan);
                            }
                            def.Add(n.ToString());
                        }
                    
                        Print("實作程式範例:", ConsoleColor.DarkYellow);
                        if (!string.IsNullOrEmpty(attrs)) Print(attrs);
                        Print(string.Join(" ", def.ToArray()));
                        Print(" { throw new NotImplementedException(); }");
                    }
                }
            }
        }
    }
}

掌握以上技術,寫個「把 .cs 拖進去會吐出對映程式碼」的小工具,就不再是遙不可及的夢想了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK