.NET Core技術研究-通過Roslyn程式碼分析技術規範提升程式碼品質

隨著團隊越來越多,越來越大,需求更迭越來越快,每天提交的程式碼變更由原先的2位數,暴漲到3位數,每天幾百次程式碼Check In,修補程式提交,大量的程式碼審查消耗了大量的資源投入。

如何確保提交程式碼的品質和提測產品的品質,這兩個是非常大的挑戰。

工欲善其事,必先利其器。在上述需求背景下,今年我們準備用工具和技術,全面把控並提升程式碼品質和產品提測品質。即:

1. 程式碼品質提升:通過自定義程式碼掃描規則,將有問題的程式碼、不符合編碼規則的程式碼掃描出來,禁止簽入

2. 產品提測品質:通過單元測試覆蓋率和執行通過率,嚴控產品提交品質,覆蓋率和通過率達不到標準,無法提交測試。

準備用2篇文章,和大家分享我們是如何提升程式碼品質和產品提測品質的。今天分享第一篇:通過Roslyn程式碼分析全面提升程式碼品質。

一、什麼是Roslyn

  Roslyn 是微軟開源的 .NET 編譯平台(.NET Compiler Platform)。  編譯平台支援 C# 和 Visual Basic 程式碼編譯,並提供豐富的程式碼分析 API。

  利用Roslyn可以生成程式碼分析器和程式碼修補程式,從而發現和更正編碼錯誤。 

  分析器不僅理解程式碼的語法和結構,還能檢測應更正的做法。 程式碼修補程式建議一處或多處修復,以修複分析器發現的編碼錯誤。

  我們寫下面一堆程式碼,Roslyn編譯器會有如下提示: 

  

 通過編寫分析器和程式碼修補程式,主要服務以下場景:  

  • 強制執行團隊編碼標準(Local)
  • 提供庫包方面的指導約束(Nuget)
  • 提供程式碼分析器相關的VSIX擴展插件(Visual Studio Marketplace)

 Roslyn是如何做到程式碼分析的呢?這背後依賴於一套強大的語法分析和API:

 

  上圖中:Language Service:語言層面的服務,可以簡單理解為我們在VS中編碼時,可以實現的語法高亮、查找所有引用、重命名、轉到定義、格式化、抽取方法等操作

  Compiler API:編譯器API,這裡提供了Syntax Tree API程式碼語法樹API,Symbol API程式碼符號API

  Binding and Flow Anllysis APIs綁定和流分析API(//joshvarty.com/2015/02/05/learn-roslyn-now-part-8-data-flow-analysis/),

  Emit API編譯反射發出API(//joshvarty.com/2016/01/16/learn-roslyn-now-part-16-the-emit-api/

  這裡我們詳細看一下語法樹、符號、語義模型、工作區:

  1. 語法樹是一種由編譯器 API 公開的基礎數據結構。 這些樹表示源程式碼的詞法和語法結構。其包含:  

  • 語法節點:是語法樹的一個主要元素。 這些節點表示聲明、語句、子句和表達式等語法構造。
  • 語法標記:表示程式碼的最小語法片段。 語法標記包含關鍵字、標識符、文本和標點。
  • 瑣碎內容:對正常理解程式碼基本上沒有意義的源文本部分,例如空格、注釋和預處理器指令。
  • 範圍:每個節點、標記或瑣碎內容在源文本內的位置和包含的字元數。
  • 種類:標識節點、標記或瑣碎內容所表示的確切語法元素。
  • 錯誤:表示源文本中包含的語法錯誤。

     看一張語法樹的圖:

  

  2. 符號:符號表示源程式碼聲明的不同元素,或作為元數據從程式集中導出。每個命名空間、類型、方法、屬性、欄位、事件、參數或局部變數都由符號表示。

  3. 語義模型:語義模型表示單個源文件的所有語義資訊。 可使用語義模型查找到以下內容:   

  • 在源中特定位置引用的符號。
  • 任何表達式的結果類型。
  • 所有診斷(錯誤和警告)。
  • 變數流入和流出源區域的方式。
  • 更多推理問題的答案。

  4. 工作區:工作區是對整個解決方案執行程式碼分析和重構的起點。相關的API可以實現:

     將解決方案中項目的全部相關資訊組織為單個對象模型,可讓用戶直接訪問編譯器層對象模型(如源文本、語法樹、語義模型和編譯),而無需分析文件、配置選項,或管理項目內依賴項。

   

  了解了Roslyn的大致情況之後,我們開始基於Roslyn做一些「不符合編程規範要求(團隊自定義的)」的程式碼分析。

二、基於Roslyn進行程式碼分析

  接下來講通過Show case的方法,通過實際的場景和大家分享。在我們編寫實際的程式碼分析器之前,我們先把開發環境準備好  :

    使用VS2017創建一個Analyzer with Code Fix工程

    因為我本機的VS2019找了好久沒找到對應的工程,這個章節,使用VS2017吧

    

    創建完成會有兩個工程:

    

    其中,TeldCodeAnalyzer.Vsix工程,主要用以生成VSIX擴展文件

   TeldCodeAnalyzer工程,主要用於編寫程式碼分析器。

    工程轉換好之後,我們開始編碼吧。

 1. catch 吞掉異常場景

  問題:catch吞掉異常後,線上很難排查問題,同時確定哪塊程式碼有問題

  示例程式碼:

try
{
     var logService = HSFService.Proxy<ILogService>();
     logService.SendMsg(new SysActionLog());
}
catch (Exception ex)
{
                
}

  需求:當開發人員在catch吞掉異常時,給與編程提示:異常吞掉時必須上報監控或者日誌

  明確了上述需要,我們開始編寫Roslyn程式碼分析器。ExceptionCatchWithMonitorAnalyzer

  

  我們詳細解讀一下:

  ① ExceptionCatchWithMonitorAnalyzer必須繼承抽象類DiagnosticAnalyzer

  ② 重寫方法SupportedDiagnostics,註冊程式碼掃描規則:DiagnosticDescriptor    

internal static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category,
            DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

 ③ 重寫方法Initialize,註冊Microsoft.CodeAnalysis.SyntaxNode完成Catch語句的語義分析後的事件Action

public override void Initialize(AnalysisContext context)
{           context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.None);
            context.EnableConcurrentExecution();
            context.RegisterSyntaxNodeAction(AnalyzeDeclaration, SyntaxKind.CatchClause);
}

 ④ 實現語法分析AnalyzeDeclaration,檢查對catch語句中程式碼實現   

private void AnalyzeDeclaration(SyntaxNodeAnalysisContext context)
{
            var catchClause = (CatchClauseSyntax)context.Node;
            var block = catchClause.Block;
            foreach (var statement in block.Statements)
            {
                if (statement is ThrowStatementSyntax)
                {
                    return;
                }
            }


            if (Common.IsReallyContains(block, "MonitorClient") == false)
            {
                context.ReportDiagnostic(Diagnostic.Create(Rule, block.GetLocation()));
            }
}

  程式碼實現後的效果(直接調試VSIX工程即可)

  

程式碼編譯後也有對應Warnning提示

 2. 在For循環中進行服務調用

  問題:for循環中調用RPC服務,每次訪問都會發起一次RPC請求,如果循環次數太多,性能很差,建議使用批量處理的RPC方法

  示例程式碼:

foreach (var item in items)
{
      var logService = HSFService.Proxy<ILogService>();
      logService.SendMsg(new SysActionLog());
}  

  需求:當開發人員在For循環中調用HSF服務時,給與編程提示:不建議在循環中調用HSF服務, 建議調用批量處理方法.

  明確了上述需要,我們開始編寫Roslyn程式碼分析器。HSFForLoopAnalyzer  

    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public sealed class HSFForLoopAnalyzer : DiagnosticAnalyzer
    {
        public const string DiagnosticId = "TA001";
        internal const string Title = "增加循環中HSF服務調用檢查";
        public const string MessageFormat = "不建議在循環中調用HSF服務, 建議調用批量處理方法.";
        internal const string Category = "CodeSmell";

        internal static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category,
            DiagnosticSeverity.Warning, isEnabledByDefault: true);

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

        public override void Initialize(AnalysisContext context)
        {
            context.RegisterSyntaxNodeAction(AnalyzeMethodForLoop, SyntaxKind.InvocationExpression);
        }

        private static void AnalyzeMethodForLoop(SyntaxNodeAnalysisContext context)
        {
            var expression = (InvocationExpressionSyntax)context.Node;
            string exressionText = expression.ToString();
            if (Common.IsReallyContains(expression, "HSFService.Proxy<"))
            {
                var loop = expression.Ancestors().FirstOrDefault(p => p is ForStatementSyntax || p is ForEachStatementSyntax || p is DoStatementSyntax || p is WhileStatementSyntax);
                if (loop != null)
                {
                    var diagnostic = Diagnostic.Create(Rule, expression.GetLocation());
                    context.ReportDiagnostic(diagnostic);
                    return;
                }

                if (Common.IsReallyContains(expression, ">.") == false)
                {
                    var syntax = expression.Ancestors().FirstOrDefault(p => p is LocalDeclarationStatementSyntax);
                    if (syntax != null)
                    {
                        var declaration = (LocalDeclarationStatementSyntax)syntax;
                        var variable = declaration.Declaration.Variables.SingleOrDefault();
                      

                        var method = declaration.Ancestors().First(p => p is MethodDeclarationSyntax);
                        var expresses = method.DescendantNodes().Where(p => p is InvocationExpressionSyntax);
                        foreach (var express in expresses)
                        {
                            loop = express.Ancestors().FirstOrDefault(p => p is ForStatementSyntax || p is ForEachStatementSyntax || p is DoStatementSyntax || p is WhileStatementSyntax);
                            if (loop != null)
                            {
                                var diagnostic = Diagnostic.Create(Rule, expression.GetLocation());
                                context.ReportDiagnostic(diagnostic);
                                return;
                            }
                        }
                    }
                }
            }
        }
    }

  基本的實現方式,和上一個差不多,唯一不同的邏輯是在實際的程式碼分析過程中,AnalyzeMethodForLoop。大家可以根據自己的需要寫一下。

       實際的效果:

       

       還有幾個程式碼檢查場景,基本都是同樣的實現思路,再次不一一羅列了。

       在這裡還可以自動完成代理修補程式,這個地方我們還在研究中,可能每個業務程式碼的場景不同,很難給出一個通用的改進程式碼,所以這個地方等後續我們完成後,再和大家分享。

三、通過Roslyn實現靜態程式碼掃描

  線上很多程式碼已經寫完了,發布上線了,對已有的程式碼進行程式碼掃描也是非常重要的。因此,我們對catch吞掉異常的程式碼進行了一次集中掃描和改進。

  那麼基於Roslyn如何實現靜態程式碼掃描呢?主要的步驟有:

  ① 創建一個編譯工作區MSBuildWorkspace.Create()

  ② 打開解決方案文件OpenSolutionAsync(slnPath);  

  ③ 遍歷Project中的Document

  ④ 拿到程式碼語法樹、找到Catch語句CatchClauseSyntax

  ⑤ 判斷是否有throw語句,如果沒有,收集數據進行通知改進

  看一下具體程式碼實現:

  先看一下Nuget引用:

  Microsoft.CodeAnalysis

  Microsoft.CodeAnalysis.Workspaces.MSBuild

  

  程式碼的具體實現:

      

 public async Task<List<CodeCheckResult>> CheckSln(string slnPath)
        {
            var slnFile = new FileInfo(slnPath);
            var results = new List<CodeCheckResult>();          
            var solution = await MSBuildWorkspace.Create().OpenSolutionAsync(slnPath);            

            if (solution.Projects != null && solution.Projects.Count() > 0)
            {
                foreach (var project in solution.Projects.ToList())
                {
                    var documents = project.Documents.Where(x => x.Name.Contains(".cs"));

                    foreach (var document in documents)
                    {
                        var tree = await document.GetSyntaxTreeAsync();
                        var root = tree.GetCompilationUnitRoot();
                        if (root.Members == null || root.Members.Count == 0) continue;
                        //member
                        var firstmember = root.Members[0];
                        //命名空間Namespace
                        var namespaceDeclaration = (NamespaceDeclarationSyntax)firstmember;

                        foreach (var classDeclare in namespaceDeclaration.Members)
                        {
                            var programDeclaration = classDeclare as ClassDeclarationSyntax;

                            foreach (var method in programDeclaration.Members)
                            {

                                //方法 Method
                                var methodDeclaration = (MethodDeclarationSyntax)method;

                                var catchNode = methodDeclaration.DescendantNodes().FirstOrDefault(i => i is CatchClauseSyntax);
                                if (catchNode != null)
                                {
                                    var catchClause = catchNode as CatchClauseSyntax;
                                    if (catchClause != null || catchClause.Declaration != null)
                                    {
                                        if (catchClause.DescendantNodes().OfType<ThrowStatementSyntax>().Count() == 0)
                                        {
                                            results.Add(new CodeCheckResult()
                                            {
                                                Sln = slnFile.Name,
                                                ProjectName = project.Name,
                                                ClassName = programDeclaration.Identifier.Text,
                                                MethodName = methodDeclaration.Identifier.Text,
                                            });
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }

            return results;
        }  

     以上是通過Roslyn程式碼分析全面提升程式碼品質的一些具體實踐,分享給大家。

 

周國慶

2020/5/2