在Roslyn中对外部资源的 "转到 "定义的改进详细解释

146 阅读12分钟

在Visual Studio 17.1中,"转到定义"(以及 "转到实现 "和 "转到基础 "等)有很多改进,允许你导航到不在你当前解决方案中的源代码。在以前的Visual Studio版本中,当对一个符号调用Go To Definition时,Roslyn会检查该符号是否在你当前的项目或任何引用的项目中被定义,如果找到了引用,我们会把你导航到该符号。如果没有找到引用,我们将使用ILSpy对引用的DLL的一部分进行反编译,并引导你到该符号的反编译源。

反编译是了解你所引用的API形状的一个好方法,但它也有一些缺点,其中最大的缺点是反编译只能从被引用的DLL中的IL创建。这意味着像注释、变量名和其他没有用IL表示的源代码部分根本无法被看到。有时,由于编译器的降低,甚至你看到的代码也是不同的。例如,当一个简单的插值字符串如$"{items.Count}"被编译时,IL与你写的string.Format("{0}", items.Count)是无法区分的。这些类型的差异不会改变行为,但它们可能对理解代码很重要。

在最新的版本中,我们在这个过程中增加了几个步骤,所以在可能的情况下,我们现在会向你展示符号的真正源代码,与编译器用来创建DLL的内容完全匹配。

寻找PDB文件

找到东西的真正来源的第一步是找到PDB文件。PDB是 "程序数据库 "的意思,它是用来存储库的额外信息的文件格式,以帮助调试和其他情况。

找到PDB文件的最简单的位置,也是第一个被检查的位置,就在磁盘上的DLL旁边。虽然PDB文件的内容不会影响运行时发生的任何事情,但对于调试构建来说,它们的存在是非常普遍的,对于发布构建来说也不是太少见。

PDB文件的下一个最简单的位置是嵌入到DLL本身中。对于可移植的PDB格式,可以在你的csproj文件中指定embedded,而不是把PDB文件写入磁盘,PDB文件本身将被嵌入DLL中。这样做的好处是容易分发调试信息,但代价是文件大小略有增加。

幸运的是,使用.NET中System.Reflection.Metadata.PEReader中的TryOpenAssociatedPortablePdb方法,磁盘上和嵌入的PDB相对容易找到。

然而,如果这个方法失败了,那么我们就必须更努力地工作,并尝试在符号服务器上找到PDB。符号服务器是一个存储和索引PDB的系统,供以后下载和使用,通常由调试器使用。有多种方法来控制符号服务器的搜索,这些方法可以在Visual Studio的工具>选项>调试器>符号页面中配置。默认情况下,你应该看到微软符号服务器和NuGet符号服务器的条目,但你可能需要启用它们。你也可以从Azure DevOps实例中添加符号服务器,或任何其他可能对你有用的私有符号服务器。

对于被引用的DLL的特定构建,下载正确的PDB是很重要的,因此搜索使用从DLL中提取的各种信息。这些信息可以帮助调试器在符号服务器上找到正确的PDB,并确认它是与DLL匹配的。

那么,Roslyn是如何找到这些信息来告诉调试器的呢? 为此,我们需要稍微转移一下注意力,进入Metadata的世界。

元数据

简而言之,一个DLL是由两部分组成的:首先是用IL编写的可执行代码,其次是元数据,这是关于IL和DLL的一般信息,它需要帮助运行时理解IL。元数据存储了,例如,DLL中每个字段和方法的名称,它们属于哪种类型,或返回哪种类型,或作为参数使用等等。你可以把元数据看作是DLL中间的一个小数据库,里面有一些描述DLL来源的代码的表格。我们可以在ILSpy中看到这些表格(如图),或者你可以使用像PeNet这样的在线工具,它能以更 "原始 "的格式显示东西。

Screenshot of ILSpy showing the Debug Directory metadata table

在上面的截图中,你可以看到Debug Directory表被高亮显示,其中有一行的类型为 "CodeView"。 这是我们需要的大部分信息的来源地。使用PeNet并点击左边的 "Debug "按钮也会显示CodeView条目本身,然而,并不对Type列进行解码,所以选择你喜欢的工具。

另一个查看元数据的有用工具是mdv,它是一个控制台应用程序,你可以从这里的源码构建:github.com/dotnet/meta…

Roslyn遍历调试目录,从CodeView和PdbChecksum条目中收集调试器需要的所有信息,将其传递给调试器,然后它就会发挥它的魔力。调试器实现了符号服务器协议(见github.com/dotnet/syms…,从符号服务器上检索PDB。它还使用在调试配置中配置的本地缓存,以便在下次被要求查找相同的PDB时加快查找速度。当调试器处于激活状态时,你可以在模块工具窗口中看到搜索的输出,方法是右击一个条目并选择显示符号加载信息,或者在使用Go To Definition后,你可以在输出窗口中找到 "Navigate to External Sources "类别,那里将显示一些信息。注意,Roslyn只有在搜索失败时才会记录深度符号搜索信息,而模块窗口中总是有这些信息。

Screenshot of the Output pane of Visual Studio showing the Navigate to External Sources category

寻找源代码

现在我们有了PDB文件,我们可以获得更多关于这个DLL的来源的信息,我们可以继续尝试找到原始的源代码。和PDB文件本身一样,源代码最直接的位置是在本地磁盘上,或者是嵌入的,所以这两个地方是首先要检查的,如果这两个地方都没有结果,那么我们就使用调试器提供的另一个API,尝试通过Source Link下载源代码。

在我们使用这些方法找到源代码之前,我们需要知道我们要找的是哪个源文件,为此我们需要回到元数据表,既要从DLL文件中找,也要从PDB中找,因为我们现在有了这些信息。

寻找文档记录

当Roslyn试图为Go To Definition命令寻找源时,它本质上是在说 "寻找这个ISymbol的源"。 符号是我们要去的东西的代表,无论是类型、方法、属性等。每个来自元数据的符号都有一个MetadataToken,你可以把它看作是元数据中数据库表的钥匙,该表拥有关于该符号的信息。

假设我们试图导航到一个叫做 "ReadEntities "的方法的定义,这个方法的MetadataToken是0x06000038。为了节省空间,就像元数据中的很多东西一样,这些令牌将两块信息打包成一个四字节的数字。第一个字节,06,意味着这个令牌是针对方法表的,剩下的三个字节,000038,意味着它是针对该表的第56^行(因为十六进制的38是十进制的56)。

在ILSpy中我们可以看到这个方法所存储的信息:

Screenshot of ILSpy showing the Method metadata table

这个截图还揭示了元数据的另一个细节,那就是它描述的是运行时概念而不是语言概念。例如,C#构造函数是一个语言概念,但是对于运行时来说,它们和方法是一样的,尽管方法碰巧被称为.ctor。 同样,你可以看到get_Notes,它是一个类型上Notes属性的获取器,但是对于运行时来说,它又只是另一个方法。

现在我们已经找到了方法行,我们可以继续挖掘元数据以找到相关的Document行,它来自于PDB元数据。我们到底是如何做到这一点的,很直接,但很详细。如果你有兴趣,你可以阅读代码,但总的想法是,同样为了节省空间,我们只为一个特定的文档存储一次信息。在过去,这意味着只有那些可以放置断点的方法,因为在可移植的PDB中有一个预先存在的概念来存储这些信息,这已经被使用了。但对于导航来说,我们需要更多的信息,所以我们为那些没有文档信息记录的类型增加了存储文档信息的能力,比如接口。这意味着在实践中,如果我们找不到一个方法的文档信息,或者我们正在寻找一个字段等,我们会检查包含的类型,如果我们找不到一个类型的文档信息,我们会检查它所包含的一个方法(反之亦然!)。

对于方法,我们在MethodDebugInformation表中寻找相应的行,该表链接到Document表中:

Screenshot of ILSpy showing the MethodDebugInformation metadata table

与普通的关系型数据库不同,没有一个字段来指代该行所谈论的方法,该表只是使用与方法表本身相同的ID,所以MethodDebugInformation的第56行是针对方法表的第56行。这里我们可以看到一个Document字段,其值为0x3000000D。再一次,这是一个元数据标记,指向Document表(30)第13行(十进制的00000D),我们有了我们的结果。

对于类型,除了我们使用CustomDebugInformation表,这是一个更通用的表,所以找到记录意味着不仅要查找它所指的ID,还要查找记录的类型,这是同样的理论。然后它同样指向Document表中的一行,我们就得到了我们需要的信息。

读取文档记录

文档记录是我们最终找到加载原始源文件所需信息的地方,它有三种不同的格式。 首先,也是最简单的情况,它可能包含源文件的完整路径,就像DLL最初被编译时那样,而且源代码可能在磁盘上的那个位置。这可能不是很有帮助,但是对于非常受控制的企业环境,或者引用他们自己软件包的独立开发者来说,这可能就足够了。

其次,文档记录可以包含一个指向源文件的相对路径,以及一个相关的CustomDebugInformation记录,该记录存储了实际的源文件,被压缩并嵌入到PDB中。 在这种情况下,Roslyn会读取、解压并将其写入一个临时文件中,这样就可以被导航到。在项目中启用源代码嵌入可以像在.csproj中添加true一样简单,同样可以牺牲文件的大小来方便发布。

最后,Document记录可以包含一个路径,CustomDebugInformation可以包含一个JSON格式的Source Link map,它告诉系统如何将路径映射到一个可以从源控制库下载源文件的URL。

源链接

Source Link存储了一个从相对文件夹路径到绝对资源库URL的映射。对于古老的NewtonSoft.Json库,可以看到下面的Source Link信息:

Screenshot of ILSpy showing the CustomDebugInformation metadata table

这将任何以"/_/"开头的路径映射到所示的 URL。请注意,这个URL包含一个提交哈希值,这确保了正确的源代码将被显示在被引用的精确构建中。有许多源链接包可以被库引用,每个包都理解不同的资源库提供者(例如,GitHub、Azure DevOps、GitLab等)。

来自这个PDB的文件信息包含一个规范化的路径,其中常见的目录前缀已经被替换为"/_/",如上图所示:

Screenshot of ILSpy showing the Document metadata table

一旦知道了相对的文件路径和基本的URL,下载文件就很简单了,尽管我们再次依靠Visual Studio调试器提供的服务来完成。这确保了为导航目的而下载的任何源代码都可自动用于调试,因此像断点等功能将如你所期望的那样工作。这也意味着认证和缓存是集中处理的。

导航

现在我们知道了符号的来源,而且我们在磁盘上有一个文件,就可以进行导航了。所有的符号和源文件的下载可能需要一些时间,所以现在有各种超时,以确保当你想看一些源代码时不会被挂起。如果你看到一些你认为应该有源码链接支持的东西的反编译,或者你注意到输出窗口的超时,你总是可以稍后再试,下载可能已经在后台完成了

当然,所有的PDB和源文件都有各种校验和、哈希值和其他检查,以确保你看到的源文件是用来编译DLL的实际源文件,所以还有其他原因可能会阻止这个工作,但它们会在输出窗口中被指出。在一天结束的时候,我们正在努力追求准确性,所以即使是反编译也比一个不正确的原始源码副本要好,即使它确实有变量名称和注释。

给我们的反馈

这并不是这个领域改进的终点,我们已经有了更多改进的想法,比如:

我们希望得到您对新的 "转到定义 "行为的反馈,所以请试一试,并让我们知道您的想法。你可以通过在GitHub上的Roslyn开源仓库创建一个问题来与我们分享你的反馈。我们感谢您的反馈!另外,如果你喜欢这种深入的技术类型的博文,或者你在看了一半的时候看花了眼,请务必让我们知道。