[译]立即了解Roslyn

作者 : Josh Varty Github 原文地址
译者: 西姆,2020-4-3 。 原文中所提及视频教程我已搬运至国内服务器。原文所涉及查询的API,原地址域名微软已经更改,由source.roslyn.codeplex.com 改为 sourceroslyn.codeplex.com

立即了解Roslyn是一个博客系列,探讨了Microsoft的Roslyn编译器API。 本系列的目的是通过一些独立的实例向人们介绍Rolsyn的能力。 我从Istvan Novak的系列“ LearnVSXNow”中汲取了灵感,该系列引导人们了解Visual Studio的可扩展性。


P1-安装Roslyn

Roslyn部署为NuGet软件包

导航到:工具> NuGet程序包管理器>程序包管理器控制台

粘贴以下内容: Install-Package Microsoft.CodeAnalysis

以下是Roslyn及其他工具的完整安装过程:


P2-使用LINQ分析语法树

注意:我还创建了一个十分钟的视频来探索语法树API。

我不会花太多时间来解释语法树。 有很多与此相关的帖子,包括《Roslyn白皮书》。 主要思想是,给定包含C#代码的字符串,编译器将创建该字符串的树表示形式(称为语法树)。 Roslyn的强大功能是,它使我们可以使用LINQ查询此语法树。

这是一个示例,其中我们使用Roslyn从字符串创建语法树。 我们必须添加对Microsoft.CodeAnalysis和Microsoft.CodeAnalysis.CSharp的引用。 可以使用第1部分“安装Roslyn”中的方法1进行操作。

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

var tree = CSharpSyntaxTree.ParseText(@"
    public class MyClass
    {
        public void MyMethod()
        {
        }
    }");

var syntaxRoot = tree.GetRoot();
var MyClass = syntaxRoot.DescendantNodes().OfType<ClassDeclarationSyntax>().First();
var MyMethod = syntaxRoot.DescendantNodes().OfType<MethodDeclarationSyntax>().First();

Console.WriteLine(MyClass.Identifier.ToString());
Console.WriteLine(MyMethod.Identifier.ToString());

我们首先从包含C#代码的字符串开始解析,然后获取此语法树的根root。 从这出发,使用LINQ检索元素非常容易。 给定树的根,我们查看所有后代对象并按其类型过滤它们。虽然我们仅使用了ClassDeclarationSyntax和MethodDeclarationSyntax两种类型,但对所有C#功能都有相应的Syntax类。

我们可以使用Visual Studio 的 Intellisense,这对于探索各种C#语法非常有价值。

我们可以组合更多 高级的LINQ表达式,像下面这个:

var tree = CSharpSyntaxTree.ParseText(@"
    public class MyClass
    {
        public void MyMethod()
        {
        }
        public void MyMethod(int n)
        {
        }
    }");

var syntaxRoot = tree.GetRoot();
var MyMethod = syntaxRoot.DescendantNodes().OfType<MethodDeclarationSyntax>()
    .Where(n => n.ParameterList.Parameters.Any()).First();

//Find the type that contains this method
var containingType = MyMethod.Ancestors().OfType<TypeDeclarationSyntax>().First();

Console.WriteLine(containingType.Identifier.ToString());
Console.WriteLine(MyMethod.ToString());

上述,我们首先找到所有方法,然后按接受参数的方法进行过滤。 然后,我们采用该方法,并使用Ancestors()方法向上遍历树,搜索包含该方法的第一个类型。

希望以上可以作为您研究和探索语法树API的基础。在纯粹的语法层面上您会发现 信息种类有一些限制,要克服这些限制,我们必须使用Roslyn的语义模型,这将是以后文章的主题。


P3-语法节点和标记 Syntax Node&Tokens

语法树由三部分组成:语法节点(Syntax Node),语法标记(Syntax Tokens)和Syntax Trivia。

Roslyn文档描述了语法节点和语法标记,如下所示:

  • 语法节点是语法树的主要元素之一。 这些节点表示语法构造,例如声明,语句,子句和表达式。 语法节点的每种类别都由从SyntaxNode派生的单独的类表示。
  • 语法标记是语言语法的终端,代表代码的最小语法片段。 他们永远不是其他节点或令牌的父母。 语法令牌由关键字,标识符,文字和标点组成。

虽然这两种定义都是准确的,但初学者并不能对它们两者之间的差异有更多的了解。

让我们看一下下面的类及其语法树。

class SimpleClass
{
    public void SimpleMethod()
    {

    }
}

使用Roslyn的Syntax Visualizer( 语法 展示器 ),我们可以看一下语法树:

语法展示器以蓝色显示语法节点,以绿色显示语法标记。

Syntax Nodes 包括:

  • ClassDeclaration 类型声明
  • MethodDeclaration 方法声明
  • ParamteterList 参数列表
  • Block 程序块

Syntax Tokens:

  • class (关键字)
  • SimpleClass (类名)
  • Punctuation (标点符号)
  • void (关键字)
  • SimpleMethod (方法名)

语法标记不能分成更简单的部分。 它们是组成C#程序的原子单位。 它们是语法树的叶子。 它们始终具有父语法节点(因为其父不能是语法标记)。

另一方面,语法节点是其他语法节点和语法标记的组合。 它们总是可以分成小块。 根据我的经验,在尝试推理语法树时,您对语法节点最感兴趣。


查看评论

P4-语法遍历器 CSharpSyntaxWalker

在第2部分:使用LINQ分析语法树中,我们探索了分离语法树的不同方法。当您只针对特定的语法片段(方法,类,throw语句等)感兴趣时,此方法非常有效。这对于筛选出语法树的某些部分来进一步研究非常有用。

但是,有时您希望对树中的所有节点和标记进行操作。另外,访问这些节点的顺序可能很重要,也许您正在尝试将C#转换为VB.Net,或者,您可能想分析C#文件并输出具有正确颜色的静态HTML文件。这两个过程都要求我们以正确的顺序访问语法树中的所有节点和标记。

抽象类CSharpSyntaxWalker允许我们构造自己的语法遍历器,该语法遍历器可以访问所有节点,标记和 Trivia 。我们可以简单地从CSharpSyntaxWalker继承并重写Visit()方法来访问树中的所有节点。

public class CustomWalker : CSharpSyntaxWalker
{
    static int Tabs = 0;
    public override void Visit(SyntaxNode node)
    {
        Tabs++;
        var indents = new String('\t', Tabs);
        Console.WriteLine(indents + node.Kind());
        base.Visit(node);
        Tabs--;
    }
}

static void Main(string[] args)
{
    var tree = CSharpSyntaxTree.ParseText(@"
        public class MyClass
        {
            public void MyMethod()
            {
            }
            public void MyMethod(int n)
            {
            }
       ");
    
    var walker = new CustomWalker();
    walker.Visit(tree.GetRoot());
}

这个简短的示例包含名为CustomWalker的CSharpSyntaxWalker的实现。 CustomWalker重载Visit()方法并打印当前正在访问的节点的类型。请务必注意,CustomWalker.Visit()也调用了base.Visit(SyntaxNode)方法。这允许CSharpSyntaxWalker访问当前节点的所有子节点。

该程序的输出:

我们可以清楚地看到语法树的各个节点以及它们之间的关系, 这里有两个同级MethodDeclaration共享相同父类ClassDeclaration。

public class DeeperWalker : CSharpSyntaxWalker
{
    static int Tabs = 0;
    //NOTE: Make sure you invoke the base constructor with 
    //the correct SyntaxWalkerDepth. Otherwise VisitToken()
    //will never get run.
    public DeeperWalker() : base(SyntaxWalkerDepth.Token)
    {
    }
    public override void Visit(SyntaxNode node)
    {
        Tabs++;
        var indents = new String('\t', Tabs);
        Console.WriteLine(indents + node.Kind());
        base.Visit(node);
        Tabs--;
    }

    public override void VisitToken(SyntaxToken token)
    {
        var indents = new String('\t', Tabs);
        Console.WriteLine(indents + token);
        base.VisitToken(token);
    }
}

上面的示例仅访问语法树的节点,但是我们可以修改CustomWalker来访问标记和Trivia。 抽象类CSharpSyntaxWalker具有一个构造函数,该构造函数允许我们指定要访问的深度(depth ) 。

我们可以修改上面的示例,在语法树的每个深度处打印出节点及其相应的标记。

注意:必须将适当的SyntaxWalkerDepth参数传递给CSharpSyntaxWalker(通过base调用)。 否则,将永远不会调用重载的的VisitToken()方法。 就我个人而言,我不认为CSharpSyntaxWalker的参数是可选的。 对我来说不清楚的是,在我学习如何使用这部分课程时, 这个方法最保守的深度会走到哪。

使用此CSharpSyntaxWalker时的输出:

上一个示例和该示例共享相同的语法树。输出包含相同的语法节点,但是我们为每个节点添加了相应的语法标记。

在以上示例中,我们访问了语法树中的所有节点和所有标记。但是,有时我们只想访问某些节点,但访问的顺序是CSharpSyntaxWalker提供的。值得庆幸的是,该API使我们能够根据其语法Syntax过滤要访问的节点。

而不是像以前的示例那样访问所有节点,以下仅访问ClassDeclarationSyntax和MethodDeclarationSyntax节点。这非常简单,只需将类名与方法名的并列打印出来即可。

public class ClassMethodWalker : CSharpSyntaxWalker
{
    string className = String.Empty;
    public override void VisitClassDeclaration(ClassDeclarationSyntax node)
    {
        className = node.Identifier.ToString();
        base.VisitClassDeclaration(node);
    }

    public override void VisitMethodDeclaration(MethodDeclarationSyntax node)
    {
        string methodName = node.Identifier.ToString();
        Console.WriteLine(className + '.' + methodName);
        base.VisitMethodDeclaration(node);
    }
}

static void Main(string[] args)
{
    var tree = CSharpSyntaxTree.ParseText(@"
    public class MyClass
    {
        public void MyMethod()
        {
        }
    }
    public class MyOtherClass
    {
        public void MyMethod(int n)
        {
        }
    }
   ");

    var walker = new ClassMethodWalker();
    walker.Visit(tree.GetRoot());
}

该示例仅输出:
MyClass.MyMethod
MyOtherClass.MyMethod

CSharpSyntaxWalker用作分析语法树的非常出色的API。它允许人们完成很多工作,而无需诉诸使用语义模型( semantic model  )并强制进行(可能)昂贵的编译。每当检查语法树和顺序很重要时,通常会使用CSharpSyntaxWalker。


查看评论

P5-语法重写器 CSharpSyntaxRewriter

在第4部分中,我们讨论了抽象的CSharpSyntaxWalker以及如何使用访问者模式浏览语法树。 今天,我们使用CSharpSyntaxRewriter进一步向前发展,并在遍历语法树时对其进行“修改”。 需要特别注意的是,由于Roslyn的语法树是不可变的,因此我们实际上并未在更改原始语法树。 取而代之的是,CSharpSyntaxRewriter根据我们的更改创建一个新的语法树。

CSharpSyntaxRewriter可以访问语法树中的所有节点,标记或Trivia。 像CSharpSyntaxVisitor一样,我们可以有选择地访问并选择想要的语法。 为此,我们将方法多次重载,使其返回以下其中一项:

  • 原始的未更改的节点, 标记或Trivia 。
  • Null,表示将删除节点,标记或Trivia。
  • 新的语法节点, 标记或Trivia 。

与大多数API一样,通过示例可以更好地理解CSharpSyntaxRewriter。最近有关Stack Overflow的问题问到如何使用SyntaxRewriter删除代码中的多余分号?

Roslyn将所有冗余分号视为EmptyStatementSyntax节点的一部分。下面,我们演示“在一行上的不必要的分号”这种基本情况如何解决。

public class EmtpyStatementRemoval : CSharpSyntaxRewriter
{
    public override SyntaxNode VisitEmptyStatement(EmptyStatementSyntax node)
    {
        //Simply remove all Empty Statements
        return null;
    }
}

public static void Main(string[] args)
{
    //A syntax tree with an unnecessary semicolon on its own line
    var tree = CSharpSyntaxTree.ParseText(@"
    public class Sample
    {
       public void Foo()
       {
          Console.WriteLine();
          ;
        }
    }");

    var rewriter = new EmtpyStatementRemoval();
    var result = rewriter.Visit(tree.GetRoot());
    Console.WriteLine(result.ToFullString());
}

该程序的输出产生一个简单的程序,没有任何多余的分号。

public class Sample
{
   public void Foo()
   {
      Console.WriteLine();
    }
}

但是,odulkanberoglu指出了这种方法的一些问题。如果存在前置或者后置的Trivia,则将删除此Trivia。这意味着,分号上方和下方的注释将被删除。

svick有一个非常聪明的解决方法。通过使用缺少的标记而不是分号构造一个EmptyStatementSyntax,我们可以设法从原始树中删除分号。他的方法如下所示:

public class EmtpyStatementRemoval : CSharpSyntaxRewriter
{
    public override SyntaxNode VisitEmptyStatement(EmptyStatementSyntax node)
    {
        //Construct an EmptyStatementSyntax with a missing semicolon
        return node.WithSemicolonToken(
            SyntaxFactory.MissingToken(SyntaxKind.SemicolonToken)
                .WithLeadingTrivia(node.SemicolonToken.LeadingTrivia)
                .WithTrailingTrivia(node.SemicolonToken.TrailingTrivia));
    }
}

public static void Main(string[] args)
{
    var tree = CSharpSyntaxTree.ParseText(@"
    public class Sample
    {
       public void Foo()
       {
          Console.WriteLine();
          #region SomeRegion
          //Some other code
          #endregion
          ;
        }
    }");

    var rewriter = new EmtpyStatementRemoval();
    var result = rewriter.Visit(tree.GetRoot());
    Console.WriteLine(result.ToFullString());
}

这种方法的输出是:

public class Sample
{
   public void Foo()
   {
      Console.WriteLine();
      #region SomeRegion
      //Some other code
      #endregion

    }
}

这种方法的副作用是,在有多余分号的地方都留下空白行。话虽如此,我认为这可能值得进行权衡,因为似乎没有其他方法可以保留Trivia。最终,Trivia只能通过将其附加到一个节点,然后返回该节点来保留。

旁白:我怀疑这将是将来删除任何语法节点的实际方法。人们可能希望删除的任何语法节点都可能具有关联的注释Trivia。在保留Trivia的同时删除节点的唯一方法是构造替换节点。替换的最佳候选者可能是带有分号缺失的EmptyStatementSyntax。

这也可能表明CSharpSyntaxRewriter受到限制。似乎在保留节点Trivia的同时删除节点应该更容易。


P6-使用工作空间 Workspace

对于 Workspace API 某些细节的解释,特别感谢@JasonMalinowski的帮助。 到目前为止,我们只是从字符串构造语法树。 这种方法在创建简短样本时效果很好,但通常我们希望使用整个解决方案,即输入:Workspace。 Workspace是C#层次结构的根节点,它由解决方案 (solution ),子项目(child projects)和子文档(child documents)组成。Roslyn的基本原则是,大多数对象都是不可变的。 这意味着我们无法保持解决方案的引用,且无法期望它永远都是最新的。一旦进行更改后,解决方案就会过时, 新解决方案将被创建。工作区是我们的根节点。 与解决方案,项目和文档不同,它们不会失效,并且始终包含对当前最新解决方案的引用。 工作空间有四个要考虑的变量:

Workspace

它是所有其他Workpace派生类的抽象基类。 称它是的变量并不恰当,因为您实际上永远不会有它的实例。 实际上,此类作为一种API来使用以便于创建 Workpace自身的实例(Instead, this class serves as a sort of API around which actual workspace implementations can be created.),让人 很容易想到工作空间(的概念)仅在于Visual Studio中。 毕竟,对于大多数C#开发人员而言,这是我们处理解决方案和项目的唯一方法。 但是,工作空间与其所代表的源文件( the physical source of the files )无关,每个实例可能是存储在本地文件系统的文件、或是在数据库内,甚至是在远程计算机上。一个简单的类继承自这个类,并根据需要重写Workspace的空实现(empty implementations)。

MSBuildWorkspace

这是用于处理MSBuild解决方案(.sln)和项目(.csproj,.vbproj)文件的工作区。 遗憾的是,它目前无法写入.sln文件,这意味着我们无法使用它来添加项目或创建新的解决方案。

以下示例说明了如何遍历解决方案中的所有文档:

string solutionPath = @"C:\Users\...\PathToSolution\MySolution.sln";
var msWorkspace = MSBuildWorkspace.Create();

var solution = msWorkspace.OpenSolutionAsync(solutionPath).Result;
foreach (var project in solution.Projects)
{
	foreach (var document in project.Documents)
	{
		Console.WriteLine(project.Name + "\t\t\t" + document.Name);
	}
}

有关更多信息,请参见立即了解Roslyn – E06 – MSBuildWorkspace。(视频)

AdhocWorkspace

这是一种允许您手动添加解决方案和项目文件的工作空间。 应该注意的是,与其他工作空间相比,AdhocWorkspace中用于添加和删除解决方案项的API是不同的。 在工作区级别提供了用于 添加项目文档 的方法,而不是调用TryApplyChanges()。 该工作区供那些只需要快速轻松地创建工作区并向其中添加项目和文档的人使用。

var workspace = new AdhocWorkspace();

string projName = "NewProject";
var projectId = ProjectId.CreateNewId();
var versionStamp = VersionStamp.Create();
var projectInfo = ProjectInfo.Create(projectId, versionStamp, projName, projName, LanguageNames.CSharp);
var newProject = workspace.AddProject(projectInfo);
var sourceText = SourceText.From("class A {}");
var newDocument = workspace.AddDocument(newProject.Id, "NewFile.cs", sourceText);

foreach (var project in workspace.CurrentSolution.Projects)
{
	foreach (var document in project.Documents)
	{
		Console.WriteLine(project.Name + "\t\t\t" + document.Name);
	}
}

有关更多信息,请参阅立即了解Roslyn – E08 – AdhocWorkspace (视频)

VisualStudioWorkspace

这是在Visual Studio程序包中使用的活动工作空间。 由于此工作空间与Visual Studio紧密集成,因此很难提供一个有关如何使用此工作空间的小例子。但尝试:

  1. 创建一个新的VSPackage。
  2. 添加对Microsoft.VisualStudio. LanguageServices.dll的引用,它可以在NuGet上获得。
  3. 导航到 <VSPackageName> Package.cs文件(其中 <VSPackageName> 是您的解决方案名称。)
  4. 找到Initalize()方法。
  5. 将接下来的代码放在Initialize()中。
protected override void Initialize()
{ 
    //Other stuff...
    ...
    
    var componentModel = (IComponentModel)this.GetService(typeof(SComponentModel));
    var workspace = componentModel.GetService<Microsoft.VisualStudio.LanguageServices.VisualStudioWorkspace>();
}
 
//Alternatively you can MEF import the workspace. MEF can be tricky if you're not familiar with it
//but here's how you'd import VisuaStudioWorkspace as a property.
 
[Import(typeof(Microsoft.VisualStudio.LanguageServices.VisualStudioWorkspace))]
public VisualStudioWorkspace myWorkspace { get; set; }

编写VSPackages时,工作区提供的最有用的功能之一就是WorkspaceChanged事件。 此事件使我们的VSPackage可以响应用户或其他VSPackage所做的任何更改,当然,熟悉工作区的最好方法就是使用它们。Roslyn的“不变性”可能会给学习带来一些困难,因此我们将在以后的文章中探索如何修改文档和项目。

有关更多信息,请参见立即了解Roslyn – E07 – Visual StudioWorkspace(视频)


查看评论

P7-语义模型介绍 Semantic Model

到目前为止,我们一直仅在的语法层面上使用C#代码。 我们可以找到属性声明,但无法在源代码中找到对该属性的引用。,我们可以识别调用,但是无法知道正在调用什么。 如果我们想尝试解决诸如 过载处理 之类的难题,只有上帝才能帮助我们了(放弃治疗)。

从开发人员看来,语义层才是Roslyn真正发挥作用的地方。 Roslyn的语义模型可以回答我们在编译时 可能遇到的所有困难问题。但是,这种能力是有代价的。 查询语义模型通常比查询语法树更昂贵(指效率) 。 这是因为请求语义模型通常会触发编译。

有3种不同的方法来请求语义模型:

  1. Document.GetSemanticModel()
  2. Compilation.GetSemanticModel (SyntaxTree)
  3. 各种诊断 AnalysisContexts 包括 CodeBlockStartAnalysisContext. SemanticModelSemanticModelAnalysisContext. SemanticModel

为了避免在设置我们自己的工作区时遇到麻烦,我们只需为各个语法树创建编译,如下所示:

var tree = CSharpSyntaxTree.ParseText(@"
	public class MyClass 
	{
		int MyMethod() { return 0; }
	}");

var Mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
var compilation = CSharpCompilation.Create("MyCompilation",
	syntaxTrees: new[] { tree }, references: new[] { Mscorlib });
//Note that we must specify the tree for which we want the model.
//Each tree has its own semantic model
var model = compilation.GetSemanticModel(tree);

符号 Symbols

在继续之前,我们值得花一点时间讨论符号。

C程序由独特的元素组成,如类型、方法、属性等。符号代表编译器所知道的关于这些独特元素的大部分信息。

在较高级别上,每个符号都包含以下信息:

  • 在源或元数据中声明此元素的位置(它可能来自外部程序集)
  • 此符号存在于什么名称空间和类型中
  • 关于符号是抽象的,静态的,密封的等各种信息。
  • 可以在ISymbol中找到更多信息。

另外,还可以揭示更多与上下文相关的信息。在处理方法时,IMethodSymbol允许我们确定:

  • 该方法是否隐藏了基方法。
  • 表示方法返回类型的符号。
  • 扩展方法从哪个符号减少的(reduced)。

请求符号 Requesting Symbols

语义模型是我们在语法世界和符号世界之间的桥梁。

SemanticModel.GetDeclaredSymbol()方法接受 声明语法 并提供相应的符号。

SemanticModel.GetSymbolInfo()方法接受 表达式语法 (例如InvocationExpressionSyntax)并返回符号。如果模型无法成功解析符号,它将提供可作为最佳猜测的候选符号。

下面,我们通过方法的声明语法检索该方法的符号。然后,我们检索相同的符号,但是通过调用(InvocationExpressionSyntax)取而代之。

var tree = CSharpSyntaxTree.ParseText(@"
	public class MyClass {
			 int Method1() { return 0; }
			 void Method2()
			 {
				int x = Method1();
			 }
		}
	}");

var Mscorlib = PortableExecutableReference.CreateFromAssembly(typeof(object).Assembly);
var compilation = CSharpCompilation.Create("MyCompilation",
	syntaxTrees: new[] { tree }, references: new[] { Mscorlib });
var model = compilation.GetSemanticModel(tree);

//Looking at the first method symbol
var methodSyntax = tree.GetRoot().DescendantNodes().OfType<MethodDeclarationSyntax>().First();
var methodSymbol = model.GetDeclaredSymbol(methodSyntax);

Console.WriteLine(methodSymbol.ToString());         //MyClass.Method1()
Console.WriteLine(methodSymbol.ContainingSymbol);   //MyClass
Console.WriteLine(methodSymbol.IsAbstract);         //false

//Looking at the first invocation
var invocationSyntax = tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>().First();
var invokedSymbol = model.GetSymbolInfo(invocationSyntax).Symbol; //Same as MyClass.Method1

Console.WriteLine(invokedSymbol.ToString());         //MyClass.Method1()
Console.WriteLine(invokedSymbol.ContainingSymbol);   //MyClass
Console.WriteLine(invokedSymbol.IsAbstract);         //false

Console.WriteLine(invokedSymbol.Equals(methodSymbol)); //true

关于性能的注意事项:

请注意 SemanticNode 文档以下内容:

SemanticModel 的实例缓存了本地符号和语义信息。 因此,在查询有关语法树的多个问题时使用单个 SemanticModel 实例的效率更高,因为来自第一个问题的信息可能会被重用。这也意味着长时间保留 SemanticModel 实例可能会阻止大量内存被垃圾回收。

本质上,Roslyn允许您在内存和计算之间进行权衡。 当重复查询语义模型时,最好保留它的一个实例,而不是从编译或文档中请求一个新模型。

下次

我们只是触及了语义模型的表面。 下次,我们将看一下控制流和数据流分析的API。


P8-数据流分析 Data Flow Analysis

写这篇博客文章确实很痛苦。 自从我上一次发布语义模型介绍以来,已经过去了三个月,而且我一直在推迟发布此文章。我开始了一个名为“立即了解Roslyn的快速提示”的新系列,我帮助构建了 Source Browser 网站,甚至提交了一个小的pull请求来整理分析API。 基本上,除了学习和编写这些API之外,我已经做了所有事情。

我一直努力撰写有关AnalyzeControlFlowAnalyzeDataFlow的两个原因是:

  1. 我一直在努力想象如何在分析器或扩展程序中使用它们。
  2. 它们太奇怪了,太不直观了,吓死宝宝了。

我发布了一条推文询问其他人如何使用它们,看来他们只是在微软内部真正用于实现“提取方法”功能。 关于Stack Overflow的几个问题都提到了这些API,因此我敢肯定有人在充分地使用它们。

数据流分析 Data Flow Analysis

在给定的代码块中,该API可用于检查如何读写变量。也许您想制作一个 Visual Studio 的扩展程序,用以捕获所有分配并将其记录到某个变量中。您可以使用数据流分析API查找语句,然后使用重写器记录这些语句。

为了演示该API的功能,我们将在Stack Overflow上研究发布的代码段经过修改,我已经对其进行了一些整理,但此API仍显示了一些使用者应该注意的许多有趣的行为。

我们可以通过以下代码分析for循环:

var tree = CSharpSyntaxTree.ParseText(@"
public class Sample
{
   public void Foo()
   {
        int[] outerArray = new int[10] { 0, 1, 2, 3, 4, 0, 1, 2, 3, 4};
        for (int index = 0; index < 10; index++)
        {
             int[] innerArray = new int[10] { 0, 1, 2, 3, 4, 0, 1, 2, 3, 4 };
             index = index + 2;
             outerArray[index - 1] = 5;
        }
   }
}");
 
var Mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
 
var compilation = CSharpCompilation.Create("MyCompilation",
    syntaxTrees: new[] { tree }, references: new[] { Mscorlib });
var model = compilation.GetSemanticModel(tree);
 
var forStatement = tree.GetRoot().DescendantNodes().OfType<ForStatementSyntax>().Single();
DataFlowAnalysis result = model.AnalyzeDataFlow(forStatement);

至此,我们已经可以访问DataFlowAnalysis对象。

在此对象上最重要的属性也许是 Succeeded,它告诉您数据流的分析是否成功完成了。以我的经验,该API非常擅长处理语义无效的代码,调用丢失的方法或使用未声明的变量似乎都不会使它崩溃。文档指出,如果分析的区域没有跨越单个表达式或语句,则分析很可能会失败。

DataFlowAnalysis对象提供了一个功能丰富的API予以使用。它通过匿名方法(anonymous methods)捕获并公开了不安全地址 (unsafe addresses)和局部变量(ocal variables)等信息。

就我们而言,我们对以下属性感兴趣:

DataFlowAnalysis.AlwaysAssigned –始终在区域内分配值的一组局部变量。
DataFlowAnalysis.ReadInside –在区域内读取的一组局部变量。
DataFlowAnalysis.WrittenOutside –写入区域之外的一组局部变量。
DataFlowAnalysis.WrittenInside –写入区域内的一组局部变量。
DataFlowAnalysis.VariablesDeclared –在一个区域内声明的一组局部变量。 请注意,该区域必须以方法的主体或字段的初始值设定项为边界,因此参数符号永远不会包含在结果中。

下面显示了我们分析过的代码。 我们宣布感兴趣的区域是for-loop。

public class Sample
{
   public void Foo()
   {
        int[] outerArray = new int[10] { 0, 1, 2, 3, 4, 0, 1, 2, 3, 4};
        for (int index = 0; index < 10; index++)
        {
             int[] innerArray = new int[10] { 0, 1, 2, 3, 4, 0, 1, 2, 3, 4 };
             index = index + 2;
             outerArray[index - 1] = 5;
        }
   }
}

分析结果如下:

AlwaysAssigned: index

index总是被分配的,因为它包含在for循环的初始值设定项中,该循环无条件运行。

WrittenInside: index, innerArray

index和innerArray被很明显的写在了循环内。

但重要的一点是,outerArray并没有。 当更改数组时,我们没有改变outerArray变量中所包含的引用。 因此,它不会显示在此列表中。

WrittenOutside: outerArray, this

可以看到outerArray被很明显的地写入for循环之外。

但令我惊讶的是 this 在WrittenOutside列表中显示为参数符号(a parameter symbol)。 似乎这是作为参数传递给类及其成员的,这意味着它也显示在这里。 这似乎是设计好的,尽管如此,我怀疑此API的大多数使用者都会感到惊讶,并且可能会忽略这一点。

ReadInside: index, outerArray

显然,index是在循环内读取的。

令我惊讶的是,由于我们没有直接读取值,outerArray 会被视为在循环内“读取”的。我想从技术上讲,我们必须首先读取outerArray 的值,以便计算偏移量并为数组的给定元素检索正确的地址。 因此,我们在这里的循环中执行了一种“隐式读取”。

VariablesDeclared: index, innerArray

这比较简单,在循环初始化器中声明index,在for循环主体中声明innerArray。

最后的想法

长期以来,奇怪的数据流分析API一直让我难以动笔,关于 this 的问题和关于“读、写”的考虑都令我感到不适。 我怀疑这些问题会阻止很多人使用此API,但我可能是错的。很难在刚入坑就这么说,而且我还没有看到太多关于此API和上述问题的讨论。


查看评论

P9-控制流分析 Control Flow Analysis

控制流分析用于了解代码块中的各种入口和出口点,并回答有关可达性的问题。 如果我们正在分析一个方法(method),我们可能会对所有可以return该方法的出口点感兴趣。 如果我们正在分析for循环,那么我们可能会对break或continue的所在位置感兴趣。

我们通过SemanticModel上的扩展方法触发控制流分析。 这将向我们返回一个ControlFlowAnalysis实例,该实例公开了以下属性:

EntryPoints –区域内的一组语句,它们是区域外部分支的目标。
ExitPoints –区域内跳转到区域外位置的一组语句。
EndPointIsReachable –指示区域是否正常完成。 当且仅当最后一条语句的末尾可访问或整个区域不包含任何语句时,才返回true。
StartPointIsReachable –指示区域是否可以正常开始。
ReturnStatements –区域内的return语句集。
Succeed –当且仅当分析成功时,才返回true。 如果该区域不能正确地跨越封闭块内的单个表达式,或者单个语句或一系列连续的语句,则分析可能会失败。

API的基本用法:

var tree = CSharpSyntaxTree.ParseText(@"
    class C
    {
        void M()
        {
            for (int i = 0; i < 10; i++)
            {
                if (i == 3)
                    continue;
                if (i == 8)
                    break;
            }
        }
    }
");

var Mscorlib = PortableExecutableReference.CreateFromAssembly(typeof(object).Assembly);
var compilation = CSharpCompilation.Create("MyCompilation",
    syntaxTrees: new[] { tree }, references: new[] { Mscorlib });
var model = compilation.GetSemanticModel(tree);

var firstFor = tree.GetRoot().DescendantNodes().OfType<ForStatementSyntax>().Single();
ControlFlowAnalysis result = model.AnalyzeControlFlow(firstFor.Statement);

Console.WriteLine(result.Succeeded);            //True
Console.WriteLine(result.ExitPoints.Count());    //2 - continue, and break

或者,我们可以指定两个语句并分析两者。下面的示例演示了此方法以及EntryPoints的用法:

var tree = CSharpSyntaxTree.ParseText(@"
class C
{
    void M(int x)
    {
        L1: ; // 1
        if (x == 0) goto L1;    //firstIf
        if (x == 1) goto L2;
        if (x == 3) goto L3;
        L3: ;                   //label3
        L2: ; // 2
        if(x == 4) goto L3;
    }
}
");

var Mscorlib = PortableExecutableReference.CreateFromAssembly(typeof(object).Assembly);
var compilation = CSharpCompilation.Create("MyCompilation",
syntaxTrees: new[] { tree }, references: new[] { Mscorlib });
var model = compilation.GetSemanticModel(tree);

//Choose first and last statements
var firstIf = tree.GetRoot().DescendantNodes().OfType<IfStatementSyntax>().First();
var label3 = tree.GetRoot().DescendantNodes().OfType<LabeledStatementSyntax>().Skip(1).Take(1).Single();

ControlFlowAnalysis result = model.AnalyzeControlFlow(firstIf, label3);
Console.WriteLine(result.EntryPoints);      //1 - Label 3 is a candidate entry point within these statements
Console.WriteLine(result.ExitPoints);       //2 - goto L1 and goto L2 and candidate exit points

在上面的示例中,我们看到了标签为L3入口点(可能的)。 据我所知,标签是唯一可能的切入点。

最后,我们将回答有关可达性的问题,在以下情况下,起点或终点均无法到达:

var tree = CSharpSyntaxTree.ParseText(@"
    class C
    {
        void M(int x)
        {
            return;
            if(x == 0)                                  //-+     Start is unreachable
                System.Console.WriteLine(""Hello"");    // |
            L1:                                            //-+    End is unreachable
        }
    }
");

var Mscorlib = PortableExecutableReference.CreateFromAssembly(typeof(object).Assembly);
var compilation = CSharpCompilation.Create("MyCompilation",
    syntaxTrees: new[] { tree }, references: new[] { Mscorlib });
var model = compilation.GetSemanticModel(tree);

//Choose first and last statements
var firstIf = tree.GetRoot().DescendantNodes().OfType<IfStatementSyntax>().Single();
var label1 = tree.GetRoot().DescendantNodes().OfType<LabeledStatementSyntax>().Single();

ControlFlowAnalysis result = model.AnalyzeControlFlow(firstIf, label1);
Console.WriteLine(result.StartPointIsReachable);    //False
Console.WriteLine(result.EndPointIsReachable);      //False

总体而言,控制流API似乎比数据流分析API直观得多,它需要的C#基础知识较少,并且操作简单。在脚本关联时,我们一直用它在重写和记录方法(methods)。 尽管似乎没什么人尝试过使用此API,但我真的很想知道别人会用来做什么。


查看评论

P10-分析仪简介 Analyzers

Rosly 的分析仪允许公司和个人在代码库中强制执行某些规则。 我的理解是分析仪有两个主要用途:

  • 扩展代码风格和更好的习惯(Broadly enforce coding styles and best practices)
  • 明确指导个人使用库

第一个用途是主要用于替换StyleCop和FxCop之类的工具。我们可以使用分析器来强制执行样式选择,例如“所有私有变量必须以小写字母开头”和“使用空格而不是制表符”。 实际上,您现在就可以开始使用StyleCop.Analyzers。在NuGet命令行中,只需输入: Install-Package StyleCop.Analyzers -Pre

第二个用途是发布库的特殊分析器用于以指导您的库的使用者。 例如,我们可能要确保没有人执行以下操作:

var dateTime = System.DateTime.UtcNow;
dateTime.AddDays(1);

System.DateTime是不可变的,因此上面的代码具有误导性。 相反,用户应编写以下内容:

var dateTime = System.DateTime.UtcNow;
dateTime = dateTime.AddDays(1);

分析器允许库的作者帮助指导他们的用户。 从从这个意义上说,我希望在新库的同时发布一组分析器成为标准。很难说这是否会真的发生,因为这需要库的作者的额外工作量。

下载 Roslyn SDK 模板

模板并不随Visual Studio 2015一起提供。要安装它们,请转到:
Tools > Extensions and Updates > Online.
工具>扩展和更新>在线。

搜索“ Roslyn SDK”并找到与您的版本相对应的模板。 我正在使用Visual Studio 2015 RC。 我选择了以下软件包:

安装模板后,必须重新启动Visual Studio。

创建您的第一个分析仪

导航到:
File > New Project > Extensibility > Analyzer with Code Fix
文件>新建项目>扩展性>具有代码修复的分析器

为您的分析仪命名,然后单击“确定”,我为刚创建的库命名为“ Analyzer1”。 从这开始,我们将看到一个自述文件,它说明了构建项目会同时为Visual Studio创建一个.vsix 文件 和一个要提交给NuGet的.nupkg文件。 还有关于如何将分析仪作为NuGet软件包正确分发的说明。

让我们来看看我们在盒子里得到了什么:

我们获得了三个项目:

  • Analyzer1 –分析仪的大脑。 这是所有代码分析完成并找出代码修复的地方。
  • Anylzer1.Test –一个默认的测试项目,带有一些帮助程序类,使测试更加容易。
  • Analyzer.Vsix –将部署到Visual Studio的启动项目。 .vsixmanifest告诉Visual Studio您要导出分析器和代码修补程序。

要运行该项目只需按F5。 Visual Studio的新实例将启动。 该Visual Studio称为“实验性配置单元”,并且在Windows注册表中具有自己的一组设置。 注意:最好为“实验性配置单元”选择一个不同的主题,以免混淆。

打开解决方案后,您会注意到Visual Studio提示出很多新的警告。 我们正在运行的分析器在看到名称中带有小写字母的任何类型时,只会发出警告。 显然,它不是很有用,但可以让我们演示在此示例中包含的代码修复:

现在,我们已经大致了解了每个项目的用途,我们将探索Analyzer1及其提供的内容。

DiagnosticAnalyzer.cs

首先要注意的是,我们的分析器继承自抽象类DiagnosticAnalyzer。 此类要求我们做两件事:

让我们看一下文件前半部分的属性和字段:

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class Analyzer1Analyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "Analyzer1";

    // You can change these strings in the Resources.resx file. If you do not want your analyzer to be localize-able, you can use regular strings for Title and MessageFormat.
    internal static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.AnalyzerTitle), Resources.ResourceManager, typeof(Resources));
    internal static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.AnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));
    internal static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.AnalyzerDescription), Resources.ResourceManager, typeof(Resources));
    internal const string Category = "Naming";

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

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }
    
    ...
}

乍一看似乎令人难以理解,但请忍受。首先请注意应用于该类的DiagnosticAnalyzer属性。这指定了将运行我们的分析仪的一种或多种语言。到目前为止,您只能指定C#和VB .Net。

在该类中,前五个属性只是描述我们的分析器并向用户列表提供消息的字符串。默认情况下,分析器设置为鼓励本地化,并允许您将title, message formatdescription( 标题,消息格式和描述 )定义为可本地化的字符串。但是,如果本地化工作让您望而却步,那么请使它们成为简单的字符串。

花一点时间看一下DiagnosticDescriptor规则。它定义了“警告”的DiagnosticSeverity。我怀疑您可能会坚持警告,但如果您想强制您的分析器的使用者,您可以将严重性升级为错误,并完全阻止编译。注意:我不建议这样做,如果您的分析仪行为异常并在没有错误的地方报告错误,用户会将其删除。

最后,让我们看一下两个生成的方法:

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class Analyzer1Analyzer : DiagnosticAnalyzer
{
    ...

    public override void Initialize(AnalysisContext context)
    {
        // TODO: Consider registering other actions that act on syntax instead of or in addition to symbols
        context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
    }

    private static void AnalyzeSymbol(SymbolAnalysisContext context)
    {
        // TODO: Replace the following code with your own analysis, generating Diagnostic objects for any issues you find
        var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;

        // Find just those named type symbols with names containing lowercase letters.
        if (namedTypeSymbol.Name.ToCharArray().Any(char.IsLower))
        {
            // For all such symbols, produce a diagnostic.
            var diagnostic = Diagnostic.Create(Rule, namedTypeSymbol.Locations[0], namedTypeSymbol.Name);

            context.ReportDiagnostic(diagnostic);
        }
    }
}

Initialize()方法通过注册AnalyzeSymbol方法来设置分析器,以在对NamedType符号进行语义分析时触发。这只是触发分析仪的几种方式中的一个例子。我们可以注册分析器以在各种触发器上运行,包括编译,代码块分析和语法树分析。我们将在后续帖子中清除AnalysisContext

我们实际要进行讨论的是AnalyzeSymbol() ,在这,我们将使用语法树和符号API来诊断和报告问题。对于此分析器,它仅使用提供的INamedTypSymbol并检查其名称中是否有任何小写字母。如果是这样,我们将使用我们先前定义的规则报告此诊断。

对于这样一个简单的分析仪来说,这似乎有很多模板。但是,一旦您开始构建复杂的分析仪,您会发现分析代码(的工作)很快开始占主导地位,模板还是不错的。

下次,我们将探讨CodeFixProvider,以及如何为用户代码中发现的问题提供解决方案。


P11-代码修复简介 Code Fixes

上次(三个月前,天啊),我们讨论了构建第一个分析仪以及使用默认分析仪模板的内容。 今天,我们将讨论分析器项目的下半部分:代码修复提供程序。

CodeFixProvider.cs

首先要注意的是,我们的类继承自CodeFixProvider。如果快速浏览CodeFixProvider,您会发现它希望您至少提供两件事:

FixableDiagnosticsIds –我们希望我们的代码修复处理的诊断ID的列表。我们将在原始分析器中定义这些ID。

RegisterCodeFixesAsync –在Visual Studio中注册我们的代码修补程序,以处理我们的诊断。

GetFixAllProvider –可选的FixAllProvider,可以将您的代码修复应用于所有诊断事件。

让我们看一下该文件的前半部分:

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(Analyzer1CodeFixProvider)), Shared]
public class Analyzer1CodeFixProvider : CodeFixProvider
{
  private const string title = "Make uppercase";

  public sealed override ImmutableArray<string> FixableDiagnosticIds
  {
      get { return ImmutableArray.Create(Analyzer1Analyzer.DiagnosticId); }
  }

  public sealed override FixAllProvider GetFixAllProvider()
  {
      return WellKnownFixAllProviders.BatchFixer;
  }
  
  ...
}

首先,我们看到我们正在导出名为“ Analyzer1CodeFixProvider”的C#代码修复提供程序。如果您要编写多语言代码修复程序,我们还可以指定其他语言,例如VB。请注意,我们必须在此处明确指定名称。 (Name是ExportCodeFixProvider中的一个属性。实际上,我以前从未遇到过这种特定于属性的语法。)

首先,我们要获得分析仪的标题,这是不言而喻的。注册我们的代码修复操作时,我们会将其标题公开给Visual Studio。

接下来,我们要公开一个诊断列表,我们要为其提供代码修复。在这种情况下,我们将介绍在分析器简介中创建的分析器。

最后,默认的代码修补程序模板将覆盖可选的GetFixAllProvider。在这种情况下,他们提供了一个BatchFixer。 BatchFixer并行计算所有必需的更改,然后一次将它们应用于解决方案。

现在,我们来看看CodeFixProvider.cs中提供给我们的最后两种方法

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(Analyzer1CodeFixProvider)), Shared]
public class Analyzer1CodeFixProvider : CodeFixProvider
{
  ...
  
  public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
  {
      var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);

      // TODO: Replace the following code with your own analysis, generating a CodeAction for each fix to suggest
      var diagnostic = context.Diagnostics.First();
      var diagnosticSpan = diagnostic.Location.SourceSpan;

      // Find the type declaration identified by the diagnostic.
      var declaration = root.FindToken(diagnosticSpan.Start).Parent.AncestorsAndSelf().OfType<TypeDeclarationSyntax>().First();

      // Register a code action that will invoke the fix.
      context.RegisterCodeFix(
          CodeAction.Create(
              title: title,
              createChangedSolution: c => MakeUppercaseAsync(context.Document, declaration, c),
              equivalenceKey: title),
          diagnostic);
  }

  private async Task<Solution> MakeUppercaseAsync(Document document, TypeDeclarationSyntax typeDecl, CancellationToken cancellationToken)
  {
      // Compute new uppercase name.
      var identifierToken = typeDecl.Identifier;
      var newName = identifierToken.Text.ToUpperInvariant();

      // Get the symbol representing the type to be renamed.
      var semanticModel = await document.GetSemanticModelAsync(cancellationToken);
      var typeSymbol = semanticModel.GetDeclaredSymbol(typeDecl, cancellationToken);

      // Produce a new solution that has all references to that type renamed, including the declaration.
      var originalSolution = document.Project.Solution;
      var optionSet = originalSolution.Workspace.Options;
      var newSolution = await Renamer.RenameSymbolAsync(document.Project.Solution, typeSymbol, newName, optionSet, cancellationToken).ConfigureAwait(false);

      // Return the new solution with the now-uppercase type name.
      return newSolution;
  }
}

第一个是RegisterCodeFixesAsync,它接受CodeFixContext。CodeFixContext包含有关可在何处应用我们的代码修补程序以及可用于注册我们的代码修补程序的诊断信息。CodFixContext提供了一系列诊断信息,供我们根据在FixableDiagnosticIds中公开的内容进行选择。

根据我的实验,由于我们声明感兴趣的诊断,每次Visual Studio灯泡出现时,都会运行RegisterCodeFixesAsync。在这一点上,我们可以注册一个运行操作,如果用户选择了我们的代码,我们希望将其应用固定。我们使用context.RegisterCodeFix()完成此操作。我们传入一个标题,一个函数返回带有更改的解决方案和一个可选的等效键。标题就是用户将我们的修订作为选项时将显示给用户的内容。在默认模板中,其为“大写”,如下所示:

单击代码修复运行MakeUppercaseAsync。虽然这似乎是微不足道的变更,这里有很多开销,但真正的工作发生在Renamer.RenameSymbolAsync()API中,该API可在整个解决方案中为我们快速轻松地重命名符号。请记住,Roslyn对象是不可变的,因此我们得到了一个全新的解决方案(newSolution),该解决方案从我们的方法中返回。现在,Visual Studio将用我们的更新副本替换以前的解决方案。

最后要说明的是关于equivalenceKey (等价密钥),它用于将我们的代码修订与其他代码修订进行匹配,并查看它们是否相同。据我所知,这些密钥没有通用的格式,但是,看起来像StyleCopAnalyzers之类的项目正在使用与微软类似的方法,并用两个字母的代码和一个数字(例如SA1510CodeFixProvider)命名它们。

现在你学会了,这是Visual Studio附带的基本案例分析器。显然,我们可以构建功能更强大的分析器和代码修复程序,但是对于大多数人来说,这个项目应该是一个不错的起点。有关更高级的分析器,请查看StyleCopAnalyzersCode CrackerRoslyn分析器


P12-文件编辑器 DocumentEditor

Roslyn“不变性”的一个缺点是,有时对文档或语法树进行多项更改可能会很棘手。 不变性意味着,每当我们对语法树进行更改时,我们都会得到一个全新的语法树。 默认情况下,我们无法跨树比较节点,因此,当我们要对语法树进行多次更改时,我们该怎么办?

Roslyn为我们提供了四个选择:

  • 使用CSharpSyntaxRewriter并从下往上重写(请参阅第5部分)
  • 使用注释(请参阅第13部分)
  • 使用TrackNodes()
  • 使用DocumentEditor

DocumentEditor允许我们对文档进行多次更改,并在应用更改后获得结果文档。在后台,DocumentEditor是SyntaxEditor上的一个薄层。

我们将使用DocumentEditor进行更改:

char key = Console.ReadKey();
if(key == 'A')
{
    Console.WriteLine("You pressed A");
}
else
{
    Console.WriteLine("You didn't press A");
}

至:

char key = Console.ReadKey();
if(key == 'A')
{
    LogConditionWasTrue();
    Console.WriteLine("You pressed A");
}
else
{
    Console.WriteLine("You didn't press A");
    LogConditionWasFalse();
}

我们将使用DocumentEditor在第一个Console.WriteLine()之前同时插入一个调用,并在第二个Console.WriteLine()之后插入另一个。

不幸的是,从头开始创建Document文档时,会有很多样板。通常,您会从工作区Workspace 获得文档,所以它应该不会太糟:

var mscorlib = MetadataReference.CreateFromAssembly(typeof(object).Assembly);
var workspace = new AdhocWorkspace();
var projectId = ProjectId.CreateNewId();
var versionStamp = VersionStamp.Create();
var projectInfo = ProjectInfo.Create(projectId, versionStamp, "NewProject", "projName", LanguageNames.CSharp);
var newProject = workspace.AddProject(projectInfo);
var sourceText = SourceText.From(@"
class C
{
    void M()
    {
        char key = Console.ReadKey();
        if (key == 'A')
        {
            Console.WriteLine(""You pressed A"");
        }
        else
        {
            Console.WriteLine(""You didn't press A"");
        }
    }
}");
var document = workspace.AddDocument(newProject.Id, "NewFile.cs", sourceText);
var syntaxRoot = await document.GetSyntaxRootAsync();
var ifStatement = syntaxRoot.DescendantNodes().OfType<IfStatementSyntax>().Single();

var conditionWasTrueInvocation =
SyntaxFactory.ExpressionStatement(
    SyntaxFactory.InvocationExpression(SyntaxFactory.IdentifierName("LogConditionWasTrue"))
    .WithArgumentList(
                    SyntaxFactory.ArgumentList()
                    .WithOpenParenToken(
                        SyntaxFactory.Token(
                            SyntaxKind.OpenParenToken))
                    .WithCloseParenToken(
                        SyntaxFactory.Token(
                            SyntaxKind.CloseParenToken))))
            .WithSemicolonToken(
                SyntaxFactory.Token(
                    SyntaxKind.SemicolonToken));

var conditionWasFalseInvocation =
SyntaxFactory.ExpressionStatement(
    SyntaxFactory.InvocationExpression(SyntaxFactory.IdentifierName("LogConditionWasFalse"))
    .WithArgumentList(
                    SyntaxFactory.ArgumentList()
                    .WithOpenParenToken(
                        SyntaxFactory.Token(
                            SyntaxKind.OpenParenToken))
                    .WithCloseParenToken(
                        SyntaxFactory.Token(
                            SyntaxKind.CloseParenToken))))
            .WithSemicolonToken(
                SyntaxFactory.Token(
                    SyntaxKind.SemicolonToken));

//Finally... create the document editor
var documentEditor = await DocumentEditor.CreateAsync(document);
//Insert LogConditionWasTrue() before the Console.WriteLine()
documentEditor.InsertBefore(ifStatement.Statement.ChildNodes().Single(), conditionWasTrueInvocation);
//Insert LogConditionWasFalse() after the Console.WriteLine()
documentEditor.InsertAfter(ifStatement.Else.Statement.ChildNodes().Single(), conditionWasFalseInvocation);

var newDocument = documentEditor.GetChangedDocument();

所有熟悉的SyntaxNode方法都在这里。我们可以根据需要Insert插入,Replace替换和Remove删除节点,所有这些都基于原始语法树中的节点。许多人发现此方法比构建整个CSharpSyntaxRewriter更直观。

出错时调试起来可能有些困难。在撰写本文时,我错误地尝试在ifStatement.Else之后而不是ifStatement.Else.Statement之后插入节点。我收到了InvalidOperationException 异常,但该消息不是很有用,我花了很多时间才能弄清楚我做错了什么。 InsertNodeAfter上的文档说:

This node must be of a compatible type to be placed in the same list containing the existing node.

该节点必须是兼容类型,才能放入包含现有节点的同一列表中。

我们如何知道哪些类型的节点相互兼容? 我认为这里没有很好的答案。 我们本质上必须学习自己兼容哪些节点。 像往常一样,Syntax VisualizerRoslyn Quoter是确定您应该创建哪种节点的最佳工具。

值得注意的是,DocumentEditor公开了原始文档的SemanticModel。 在编辑原始文档并决定要更改的内容时,可能需要使用此功能。

还值得注意的是,底层的SyntaxEditor公开了一个SyntaxGenerator,您可以使用它来构建语法节点,而不必依赖更冗长的SyntaxFactory。



P13-语法注释 SyntaxAnnotation

在对语法树应用更改时,跟踪节点可能会很棘手。每次我们“更改”一棵树时,我们都会创建一个副本,并将更改应用到新树上。当我们这样做的时候,我们先前引用的任何语法片段在新树的上下文中都将无效。

在实践过程中这是为何?这是因为更改语法树时,很难跟踪语法节点。

Stack Overflow上最近出现了一个问题。我们如何获取刚添加到文档中的类的符号?我们可以创建一个新的类声明,但是当我们将它添加到文档中时,我们就失去了对节点的跟踪。那么,我们如何跟踪类,以便在将其添加到文档中后可以获取该类的符号呢?

答案:使用SyntaxAnnotation(语法注释)

SyntaxAnnotation是基本的元数据,我们可以将其附加到一段语法上。当我们处理树时,注释会遵循该语法,因此很容易找到。

AdhocWorkspace workspace = new AdhocWorkspace();
Project project = workspace.AddProject("SampleProject", LanguageNames.CSharp);

//Attach a syntax annotation to the class declaration
var syntaxAnnotation = new SyntaxAnnotation();
var classDeclaration = SyntaxFactory.ClassDeclaration("MyClass")
	.WithAdditionalAnnotations(syntaxAnnotation);

var compilationUnit = SyntaxFactory.CompilationUnit().AddMembers(classDeclaration);

Document document = project.AddDocument("SampleDocument.cs", compilationUnit);
SemanticModel semanticModel = document.GetSemanticModelAsync().Result;

//Use the annotation on our original node to find the new class declaration
var changedClass = document.GetSyntaxRootAsync().Result.DescendantNodes().OfType<ClassDeclarationSyntax>()
	.Where(n => n.HasAnnotation(syntaxAnnotation)).Single();
var symbol = semanticModel.GetDeclaredSymbol(changedClass);

创建SyntaxAnnotation时,有几个重载可用。我们可以指定Kind和Data附加到语法片段上。 Data 用于将额外的信息附加到我们稍后要检索的语法中。 Kind 是我们可以用来搜索语法注释的字段。

因此,我们不必在每个节点上查找注释的确切实例,而是可以根据它们的类型搜索注释:

AdhocWorkspace workspace = new AdhocWorkspace();
Project project = workspace.AddProject("Test", LanguageNames.CSharp);

string annotationKind = "SampleKind";
var syntaxAnnotation = new SyntaxAnnotation(annotationKind);
var classDeclaration = SyntaxFactory.ClassDeclaration("MyClass")
	.WithAdditionalAnnotations(syntaxAnnotation);

var compilationUnit = SyntaxFactory.CompilationUnit().AddMembers(classDeclaration);

Document document = project.AddDocument("Test.cs", compilationUnit);
SemanticModel semanticModel = await document.GetSemanticModelAsync();
var newAnnotation = new SyntaxAnnotation("test");

//Just search for the Kind instead
var root = await document.GetSyntaxRootAsync();
var changedClass = root.GetAnnotatedNodes(annotationKind).Single();

var symbol = semanticModel.GetDeclaredSymbol(changedClass);

这只是处理Roslyn“不可变”树的几种不同方法之一。如果您要进行多项更改并且需要跟踪多个语法节点,那么使用它可能并不容易。 (如果是这种情况,我建议您使用DocumentEditor)。就是说,意识到这一点很好,这样您就可以在它有意义的时候使用它。


显示评论

P14-脚本API简介

脚本API终于来了!自从Roslyn的1.0版本中被删除后,现在可以在NuGet上以预发布的格式使用它(适用于C#)。要安装到项目中,您只需运行:

Install-Package Microsoft.CodeAnalysis.Scripting -Pre

注意:您需要目标为.NET 4.6(框架),否则在运行脚本时会遇到以下异常:

Could not load file or assembly 'System.Runtime, Version=4.0.20.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a' or one of its dependencies. The system cannot find the file specified.

注意:今天(2015年10月15日),脚本AP依赖 1.1.0-beta1版本,因此如果您想将所有Roslyn都与脚本一起使用,则必须更新Microsoft.CodeAnalysis的引用才能匹配。

有几种使用脚本API的方法。

异步评估 EvaluateAsync

CSharpScript.EvaluateAsync可能是最简单进行表达式评估的方法。简单地将任何表达式传递给此方法,它将为您求值并返回一个结果的。

var result = await CSharpScript.EvaluateAsync("5 + 5");
Console.WriteLine(result); // 10

result = await CSharpScript.EvaluateAsync(@"""sample""");
Console.WriteLine(result); // sample

result = await CSharpScript.EvaluateAsync(@"""sample"" + "" string""");
Console.WriteLine(result); // sample string

result = await CSharpScript.EvaluateAsync("int x = 5; int y = 5; x"); //Note the last x is not contained in a proper statement
Console.WriteLine(result); // 5

异步运行 RunAsync

并非每个脚本都返回一个值。对于更复杂的脚本,我们可能希望跟踪状态或检查不同的变量。CSharpScript.RunAsync创建并返回一个ScriptState对象,该对象可以使我们执行此操作。来看一下:

var state = CSharpScript.RunAsync(@"int x = 5; int y = 3; int z = x + y;""");
ScriptVariable x = state.Variables["x"];
ScriptVariable y = state.Variables["y"];

Console.Write($"{x.Name} : {x.Value} : {x.Type} "); // x : 5
Console.Write($"{y.Name} : {y.Value} : {y.Type} "); // y : 3

我们还可以维护脚本的状态,并继续使用ScriptState.ContinueWith()对它进行更改:

var state = CSharpScript.RunAsync(@"int x = 5; int y = 3; int z = x + y;""").Result;
state = state.ContinueWithAsync("x++; y = 1;").Result;
state = state.ContinueWithAsync("x = x + y;").Result;

ScriptVariable x = state.Variables["x"];
ScriptVariable y = state.Variables["y"];

Console.Write($"{x.Name} : {x.Value} : {x.Type} "); // x : 7
Console.Write($"{y.Name} : {y.Value} : {y.Type} "); // y : 1

脚本选项 ScriptOptions

通过添加对我们要使用的DLL的引用,我们可以开始研究更有趣的代码。我们使用ScriptOptions为脚本提供适当的MetadataReferences(元数据引用)。

ScriptOptions scriptOptions = ScriptOptions.Default;

//Add reference to mscorlib
var mscorlib = typeof(System.Object).Assembly;
var systemCore = typeof(System.Linq.Enumerable).Assembly;
scriptOptions = scriptOptions.AddReferences(mscorlib, systemCore);
//Add namespaces
scriptOptions = scriptOptions.AddNamespaces("System");
scriptOptions = scriptOptions.AddNamespaces("System.Linq");
scriptOptions = scriptOptions.AddNamespaces("System.Collections.Generic");

var state = await CSharpScript.RunAsync(@"var x = new List(){1,2,3,4,5};", scriptOptions);
state = await state.ContinueWithAsync("var y = x.Take(3).ToList();");

var y = state.Variables["y"];
var yList = (List)y.Value;
foreach(var val in yList)
{
  Console.Write(val + " "); // Prints 1 2 3
}

这个东西出乎意料地的广泛(常见)。 Microsoft.CodeAnalysis.Scripting命名空间内充满了我完全不熟悉的公共类型,还有很多东西需要学习。我很高兴看到人们将以此为基础,以及他们如何将脚本整合到他们的应用程序中。

Roslyn 团队的Kasey Uhlenhuth编制了一系列代码片段,以帮助您使用Scripting API。请在GitHub上查看它们!

如果您对脚本API有一些不错的计划,请在下面的评论告诉我!


显示评论

P15-符号查看器 SymbolVisitor

前几天我遇到一个问题,最后直接在Roslyn上提交了issues:如何获得可用于编译的所有类型的列表? Schabse Laks(@Schabse)和David Glick(@daveaglick)向我介绍了我之前从未接触的很酷的课程:SymbolVisitor

在以前的文章中,我们谈到了CSharpSyntaxWalker和CSharpSyntaxRewriter。 SymbolVisitor是SyntaxVisitor的类似物,但它适用于符号级别。不过有点遗憾,与SyntaxWalker和CSharpSyntaxRewriter不同,使用SymbolVisitor时,我们必须构造”脚手架代码”(scaffolding code)才能访问所有节点。

为了简单列出编译可用的所有类型,我们可以使用以下内容。

public class NamedTypeVisitor : SymbolVisitor
{
    public override void VisitNamespace(INamespaceSymbol symbol)
    {
        Console.WriteLine(symbol);
        
        foreach(var childSymbol in symbol.GetMembers())
        {
            //We must implement the visitor pattern ourselves and 
            //accept the child symbols in order to visit their children
            childSymbol.Accept(this);
        }
    }

    public override void VisitNamedType(INamedTypeSymbol symbol)
    {
        Console.WriteLine(symbol);
        
        foreach (var childSymbol in symbol.GetTypeMembers())
        {
            //Once againt we must accept the children to visit 
            //all of their children
            childSymbol.Accept(this);
        }
    }
}

//Now we need to use our visitor
var tree = CSharpSyntaxTree.ParseText(@"
class MyClass
{
    class Nested
    {
    }
    void M()
    {
    }
}");

var mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
var compilation = CSharpCompilation.Create("MyCompilation",
    syntaxTrees: new[] { tree }, references: new[] { mscorlib });

var visitor = new NamedTypeVisitor();
visitor.Visit(compilation.GlobalNamespace);

在给定的编译中,为了访问可用的所有方法(methods),我们可以使用以下方法:

public class MethodSymbolVisitor : SymbolVisitor
{
    //NOTE: We have to visit the namespace's children even though
    //we don't care about them. :(
    public override void VisitNamespace(INamespaceSymbol symbol)
    {
        foreach(var child in symbol.GetMembers())
        {
            child.Accept(this);
        }
    }
    
    //NOTE: We have to visit the named type's children even though
    //we don't care about them. :(
    public override void VisitNamedType(INamedTypeSymbol symbol)
    {
        foreach(var child in symbol.GetMembers())
        {
            child.Accept(this);
        }
    }

    public override void VisitMethod(IMethodSymbol symbol)
    {
        Console.WriteLine(symbol);
    }
}

重要的是要知道你必须如何构造你的代码,以便访问所有你感兴趣的符号。现在你可能已经注意到,直接使用这个API会让我有点难受。如果我对访问方法符号感兴趣,我可不想编写访问名称空间和类型的代码。

希望在某个时候我们能获得一个SymbolWalker类,我们可用它将实现与遍历的代码分开。我已经在Roslyn上提交了一个请求此功能的 open issue。 (这看起来很难实现,需要同时使用语法和符号)。

查找所有 Named Type Symbols

最后,您可能想知道我如何回答我最初的问题:“我们如何获得可用于编译的所有类型的列表?”我的实现如下:

public class CustomSymbolFinder
{
    public List<INamedTypeSymbol> GetAllSymbols(Compilation compilation)
    {
        var visitor = new FindAllSymbolsVisitor();
        visitor.Visit(compilation.GlobalNamespace);
        return visitor.AllTypeSymbols;
    }

    private class FindAllSymbolsVisitor : SymbolVisitor
    {
        public List<INamedTypeSymbol> AllTypeSymbols { get; } = new List<INamedTypeSymbol>();

        public override void VisitNamespace(INamespaceSymbol symbol)
        {
            Parallel.ForEach(symbol.GetMembers(), s => s.Accept(this));
        }

        public override void VisitNamedType(INamedTypeSymbol symbol)
        {
            AllTypeSymbols.Add(symbol);
            foreach (var childSymbol in symbol.GetTypeMembers())
            {
                base.Visit(childSymbol);
            }
        }
    }
}

我应该指出,实施此解决方案后,我得出的结论是,对于我们的目的而言,它太慢了。我们只是访问了源代码中定义的名称空间中的符号,这大大提高了性能,但仍然比通过SymbolFinder这种简单搜索类型慢了一个数量级。

尽管如此,SymbolVisitor类可能适合在编译期间一次性使用或访问可用符号的子集。至少,这是值得一提。


显示评论

P16-发射 Emit API

到目前为止,我们主要研究如何使用Roslyn来分析和操作源代码。现在,我们来看看通过将它发送到磁盘或内存来完成编译过程。首先,我们将尝试向磁盘发送一个简单的编译并检查它是否成功。

运行此代码后,我们可以看到我们的可执行文件和.pdb已被发送到Debug/bin/。我们可以双击output.exe,查看程序是否按预期运行。请记住,.pdb文件是可选的。我选择在这里发布它只是为了展示API。将.pdb文件写入磁盘可能需要相当长的时间,除非确实需要,否则省略此参数通常是值得的。

var tree = CSharpSyntaxTree.ParseText(@"
using System;
public class C
{
    public static void Main()
    {
        Console.WriteLine(""Hello World!"");
        Console.ReadLine();
    }   
}");

var mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
var compilation = CSharpCompilation.Create("MyCompilation",
    syntaxTrees: new[] { tree }, references: new[] { mscorlib });

//Emitting to file is available through an extension method in the Microsoft.CodeAnalysis namespace
var emitResult = compilation.Emit("output.exe", "output.pdb");

//If our compilation failed, we can discover exactly why.
if(!emitResult.Success)
{
    foreach(var diagnostic in emitResult.Diagnostics)
    {
        Console.WriteLine(diagnostic.ToString());
    }
}

有时我们可能不想发射到磁盘。我们可能只想编译代码,将其发射到内存中,然后从内存中执行它。请记住,在大多数情况下,我们希望这样做使得脚本API可能更有意义。尽管如此,了解我们的选择还是值得的。

var tree = CSharpSyntaxTree.ParseText(@"
using System;
public class MyClass
{
    public static void Main()
    {
        Console.WriteLine(""Hello World!"");
        Console.ReadLine();
    }   
}");

var mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
var compilation = CSharpCompilation.Create("MyCompilation",
    syntaxTrees: new[] { tree }, references: new[] { mscorlib });

//Emit to stream
var ms = new MemoryStream();
var emitResult = compilation.Emit(ms);

//Load into currently running assembly. Normally we'd probably
//want to do this in an AppDomain
var ourAssembly = Assembly.Load(ms.ToArray());
var type = ourAssembly.GetType("MyClass");

//Invokes our main method and writes "Hello World" :)
type.InvokeMember("Main", BindingFlags.Default | BindingFlags.InvokeMethod, null, null, null);

最后,如果我们想影响代码的编译方式,该怎么办?我们可能希望允许使用不安全的代码,将“警告”标记为“ 错误 ”,或者延迟对程序集进行签名。通过将CSharpCompilationOptions对象传递给CSharpCompilation.Create(),就可以自定义所有这些选项。我们将看看如何与下面这些属性进行交互。

var tree = CSharpSyntaxTree.ParseText(@"
using System;
public class MyClass
{
    public static void Main()
    {
        Console.WriteLine(""Hello World!"");
        Console.ReadLine();
    }   
}");

//We first have to choose what kind of output we're creating: DLL, .exe etc.
var options = new CSharpCompilationOptions(OutputKind.ConsoleApplication);
options = options.WithAllowUnsafe(true);                                //Allow unsafe code;
options = options.WithOptimizationLevel(OptimizationLevel.Release);     //Set optimization level
options = options.WithPlatform(Platform.X64);                           //Set platform

var mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
var compilation = CSharpCompilation.Create("MyCompilation",
    syntaxTrees: new[] { tree },
    references: new[] { mscorlib },
    options: options);                                   

总共大约有25种不同的选项可用于自定义,基本上在Visual Studio项目属性页中拥有的任何选项,此处您都应在可以使用。

高级选项
Compilation.Emit()中有一些可选参数值得讨论,我熟悉的其中一些,但从未使用过。

  • xmlDocPath–根据类,方法,属性等上的文档注释自动生成XML文档。
  • manifestResources–允许您在发射的程序集中手动嵌入资源,例如字符串和图像。 电池(?)不包含在此API中,如果要将.resx资源嵌入到程序集中,则需要进行一些繁重的工作,我们将在以后的博客文章中探讨这种重载。
  • win32ResourcesPath–从中读取编译的Win32资源的文件的路径(RES格式)。不幸的是,我还没有使用过该API,而且我对Win32资源一点也不熟悉。
  • EmitDifference–两个编译进行比较。我不熟悉这个API,也不熟悉如何将这些delta应用于磁盘或内存中的现有程序集。我希望在接下来的几个月中进一步了解此API。

以上这些就构成了Emit API。 如果您有任何疑问,请随时在下面的评论中提问。


显示评论

发表评论

邮箱地址不会被公开。 必填项已用*标注

13 − 5 =