.NET Core技術研究-通過Roslyn程式碼分析技術規範提升程式碼品質
- 2020 年 5 月 6 日
- 筆記
- .NET, .NET Core, .NET Framework, .NET Research, c#, 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