65.9K
CodeProject 正在变化。 阅读更多。
Home

CodeStash - 深入 Visual Studio 的阴暗面,或者我是如何掉头发的

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (76投票s)

2012 年 3 月 20 日

CPOL

47分钟阅读

viewsIcon

242878

深入了解 CodeStash Visual Studio 扩展。

CodeStash 源代码

Codestash Codeplex 站点:http://codestash.codeplex.com/

CodeStash 文章列表

第一部分:CodeStash 网站概述 / 高层架构 / 如何安装 CodeStash
第二部分:CodeStash 网站低层架构
第三部分:(本文)CodeStash 插件

请谅解

本文原计划与 Sacha 的两篇文章一同发布。不幸的是,由于他无法控制的客观原因,Sacha 今晚无法发布他的文章。尽管如此,我曾承诺会有关于 CodeStash 的发布,所以我将上传这部分内容,供那些对我的扩展开发方式感兴趣的人阅读。一旦 Sacha 发布了他的文章,我将回来更新上面的部分,链接到那些文章。

我承诺会有公告,我不会食言。当 Sacha 和我开发 CodeStash(包括一个网站和一个 Visual Studio 扩展)时,我们一直在考虑如何托管这个网站。我们决定最初提供 CodeStash 作为自助托管站点,以便衡量人们对这个想法的实用性,并收集他们希望看到的特性的反馈。

显然,对于一个旨在成为代码片段集中存储库的东西来说,这不是一个理想的状况。那么,谁来帮助我们,并主动提出为我们托管 CodeStash 呢?正是我们自己的 Chris Maunder,他好心主动为我们托管了这个网站。为此,我们将与 Code Project 团队合作,将 CodeStash 打造成一个 Code Project 打包的代码片段管理器。我们需要你的帮助。我们需要你,Code Project 社区,来试用 CodeStash。我们需要你的要求和反馈。我们需要你的热情和 boundless creativity(无限的创造力)。

简史

大约一年前,Code Project 的天才 Sacha Barber 提到他有一个项目想启动,并且需要一个懂得如何编写 Visual Studio 插件的人来帮忙。这实在是一个不容错过的绝佳机会;任何读过他文章的人都知道他是一位超级明星,而这给了我一个与开发界超级明星合作的绝佳机会。

时间就这样过去,但项目却一直没有出现——这完全是因为 Sacha 在这段时间里忙于撰写大量的文章。终于,在去年九月/十月左右,Sacha 发给了我一份名为 CodeStash 的项目规格说明,并问我是否还有兴趣——我不会拒绝这样的事情。于是,在十一月下旬,我们开始着手开发 CodeStash

这个项目是一项爱的劳动。与 Sacha 合作非常愉快,他非常有耐心等待我完成我这边的工作,即使我彻底改变了我要使用的内部 MVVM 框架,重写了核心并重新开始(显然,我决定使用 Cinch 可能有助于说服他)。

现在,Sacha 的愿景已经实现,而且效果很好。我卑微的贡献只是建立在 Sacha 奠定的优秀基础之上,这使得合作非常愉快,而且充满乐趣。所以,我真心感谢你,Sacha。你无疑是那个男人。

对于那些耐心等待我 Windows Phone 系列文章的读者,我深表歉意。我没有忘记你们,也没有对这个系列失去兴趣。CodeStash 在过去的几个月里几乎占据了我全部的精力,我希望你们觉得这次延迟是值得的。

那么,CodeStash 到底是什么呢?既然这是 Sacha 的心血结晶,我想我应该用他的话来描述 CodeStash。

嗯,CodeStash 是一个面向单个开发者或开发团队(可能是同一团队的成员)的生产力工具。

这个工具本身可以被视为一个基于 Web 的集中式代码片段存储库,适用于单个开发者(或开发团队),开发者可以管理他们日常任务中可能用到的有用代码片段。

该网站将提供以下功能:

• OpenId 授权,将与标准的 ASP .NET 表单身份验证结合使用
• 标签云,显示最常见的代码片段类型,以便快速搜索这些类型的代码片段
• 能够通过关键字标签、组、语言、代码内容来搜索已存储的代码片段
• 能够创建/删除/编辑现有代码片段
• 能够将代码片段分组,以便在搜索单个代码片段时显示所有相关片段。例如,如果我搜索 INPC,我会得到一个用于声明 INPC 模型/ViewModel 的 C# 代码片段,并且可能会得到一个 XAML 代码片段作为搜索结果的一部分。

该网站主要关注代码片段的 CRUD(创建/检索/更新/删除)操作,这本身并不复杂,甚至不算什么革命性的(尽管现有的解决方案都没有涉及到分组的概念)。

然而,这个项目还有另一个角度,那就是它旨在与 Visual Studio(2010 及更高版本)实现无缝集成,它将包含一个托管的 VS 插件,通过特定的内容菜单和属性页与 VS 集成,允许 VS 用户将代码上传到 CodeStash,并允许 VS 用户通过一套标准的 ASP .NET MVC 控制器操作来搜索 CodeStash,这些操作将向从 VS 内部启动的定制 UI 公开/接受 RESTful 数据。

当用户搜索一个之前存储在 CodeStash 中的代码片段时,他们可以选择将匹配的 CodeStash 代码片段插入到 VS 编辑面板中(前提是 CodeStash 代码片段类型与当前 VS 编辑器文件类型匹配)。

现有解决方案

正如我所说,网站本身并不是什么新颖的东西,市面上已经有一些现有解决方案在功能上与 CodeStash 的网站部分类似,毕竟这最终归结为 CRUD 操作,但实际上,伟大的想法最终不都是这样吗?即使是 Facebook 也只是 CRUD 加上一些营销包装。

问题是,一旦你查看了所有这些现有解决方案,它们似乎在某些功能方面都有所欠缺,例如,一些现有解决方案在以下方面存在不足:

  1. 搜索功能非常有限,即使有,也很难在上传代码片段后找到它。
  2. 没有分组概念,我觉得这很糟糕。如今的代码由许多元素组成,你可能有一个 HTML 文件、一个 CSS 文件、一个 JavaScript 文件,所有这些都可能构成一个逻辑片段。当然,也可能只有一个文件,但当存在多个相关文件时,能够在同一搜索中找回它们对于提高生产力至关重要。
  3. 没有 Visual Studio 集成

为完整起见,这里列出了一些我们在着手创建 CodeStash 之前研究过的现有解决方案:

这显然不是一个详尽的列表,但它们是我们研究现有产品时发现的最好的几个。你知道,如果别人已经造出了法拉利,就没有必要再发明轮子了。

简而言之,这就是 CodeStash 的全部内容。现在,如果你点击了链接,你会注意到它只是带你到一个 codeplex 网站,除了源代码之外,并没有太多东西。在我们继续这篇文章之前,重要的是要认识到插件只是这个项目的一部分,所以我强烈建议你去阅读 Sacha 的文章,并学习如何设置网站——在插件工作之前,你需要完成这些。

“没有我们也能做到”部分

首先,我必须感谢 Sacha。与他合作是一次非常棒的经历,我认为这个项目意义重大。

我还想感谢 Chris 和 Code Project 的团队给了我们未来的希望。老实说,他们的参与是巨大的鼓舞,让我们在写文章时保持了高昂的精神。

我们将涵盖什么

在本文中,我们将探讨编写一个相对复杂的、多层 Visual Studio 扩展背后的部分思维过程。我们将了解命令是如何组合的,以及如何在插件内部与定义良好的 REST 接口进行交互。我们将看到如何利用 MEF 来减轻处理共享代码的痛苦,如何与内部 Visual Studio 服务交互,以及如何提供我们自己的属性页。

本文不教授 XAML 代码的编写,也不教授如何使用 MVVM。我们不会涵盖如何使用 Cinch(我强烈建议阅读 Sacha 关于 Cinch 框架的系列文章以获得背景知识)。它也不旨在深入探讨 MEF 开发,因为所有这些概念都与理解 CodeStash 插件旨在实现的目标无关。 CodeStash 还利用了 Daniel Grunwald 出色的 AvalonEdit 控件来显示代码片段,该控件的深度意味着我必须推荐阅读他撰写的支持它的原始文章;我现在只想对 Dan 编写这个控件并免费提供表示感谢。

必备组件

要编写 Visual Studio 2010 扩展,你需要 Visual Studio 2010 Professional(或更高版本)、Visual Studio 2010 Service Pack 1 和 Visual Studio 2010 SP1 SDK

提前说明

为了让这篇文章不那么枯燥,更生动一些,我故意在句子格式化方式上进行了调整,并在代词的使用上前后跳跃。我向英语纯粹主义者们道歉,但我希望将一篇可能非常枯燥的技术文章变得不那么正式,并且更容易阅读。这就是为什么我会在“Visual Studio”和“VS”这两个词之间切换使用,它们都代表 Visual Studio。为了弥合开发者习惯于在早期版本 Visual Studio 中开发插件与当前版本之间的差距,我互换使用了“addin”和“extensions”这两个词,但它们都指的是可以通过扩展管理器安装的扩展。

插件实际上分为三个部分。第一部分是用于显示我们 CodeStash 功能的菜单。第二部分是构成保存和插入代码片段的视图,第三部分是用户实际使用 CodeStash 所需的设置。在本文中,我们将看看这些部分是如何组合在一起的,学习一些用于制作很酷的 Visual Studio 插件技巧,以及如何与 CodeStash 网站进行交互。本文不会逐行分析插件的每个部分——坦白说,内容太多了,这篇文章最终会变成一个巨大的代码转储,让你的求生意志一点点流失。此外,文章也不会涉及我们添加到代码库中的每一个巧妙小技巧;希望一旦你看到代码运行起来,你就会想自己去探索代码。本文应该能提供足够的信息,让你能够独自继续 Visual Studio 包的探索之旅。

插件实战

The CodeStash menu in action 

当编辑器窗口中的文本被选中时,“保存代码片段”选项会启用,以便我们勇敢的用户保存他们的代码片段。

Saving a snippet

保存代码片段就像填写一个简单的对话框并点击“保存”一样简单。

Searching for snippets 

此对话框显示了代码片段检索的实际情况。

Displaying snippets to check what they contain.

在代码片段视图中,可以很容易地看到代码片段包含的内容。

CodeStash in the About Visual Studio dialog. 

在这里,我们可以看到 CodeStash 列在 Visual Studio 的“关于”对话框中。

运行 CodeStash

关于调试插件的简要说明。调试 Visual Studio 扩展时,以 Debug 模式启动项目可能需要很长时间。我喜欢使用“无调试启动”来启动项目,然后将调试器附加到正在运行该扩展的 Visual Studio 上。自己试试,你会发现这能为你节省大量时间。

配置文件包含以下设置:

  1. EncryptionEnabled。此值必须与 CodeStash 网站 web.config 文件中的设置值相同。
  2. RestAddress。这是 RESTful 服务所在的位置。默认情况下,它设置为 https://:8300/Rest/。请将 localhost:8300 更改为你部署应用程序的网站地址。
  3. CodeSnippetAddress。这是代码片段地址 RESTful 服务的位置。默认情况下,它设置为 https://:8300/CodeSnippet/。请将 localhost:8300 更改为你部署应用程序的网站地址。

在插件能够与 CodeStash 网站通信之前,必须先运行该网站,所以我建议在尝试运行插件之前先启动它。当你在编辑器中运行该插件时,会打开一个 Visual Studio 的实验实例——你可能需要根据你使用的是 32 位还是 64 位 Windows 版本 Visual Studio,在“调试”选项卡(CodeStash.Addin 属性窗口)中调整其启动方式。

当你运行 CodeStash 时,你第一次真正与之交互的可能是通过上下文菜单命令,所以让我们从看看它们是如何工作的开始。

连接命令

我们在创建插件时面临的最大问题之一是它必须在多个不同的编辑器窗口中工作,并且有多个菜单项被分组到一个子菜单中。尽管微软尽了最大的努力,但关于如何做到这一点(实现这一功能)的文档却分散在很多不同的地方,而且有些地方简直是晦涩难懂。

为了建立我们将命令连接起来的方式,我们不得不与 Visual Studio 命令表 (VSCT) 的内部机制进行斗争。当你创建一个 Visual Studio 插件时,你会看到一个以 .vsct 扩展名结尾的文件;这就是支持此功能的文件。当你打开它时,你会发现它是一个 XML 文件,它简单地描述了 VSPackage 中命令项的布局和外观。嗯,我说简单,但要理解它绝非易事。在这里,我将展示这个文件是什么样的,然后对其进行分解,以便命令结构的其他部分能够更好地理解(希望如此)。

<?xml version="1.0" encoding="utf-8"?>
<CommandTable xmlns="http://schemas.microsoft.com/VisualStudio/2005-10-18/CommandTable" xmlns:xs="http://www.w3.org/2001/XMLSchema">

  <!--This header contains the command ids for the menus provided by the shell. -->
  <Extern href="vsshlids.h"/>

  <Commands package="guidCodeStash_AddinPkg">
    <Groups>

      <Group guid="CodeStashGrouping" id="CodeStashGroupedMenus" priority="0x0600">
        <Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_CODEWIN"/>
      </Group>

      <Group guid="CodeStashGrouping" id="SubMenuGroup" priority="0x0602">
        <Parent guid="CodeStashGrouping" id="SubMenu" />
      </Group>

    </Groups>

    <Menus>
      <!-- The Code Stash submenu. -->
      <Menu guid="CodeStashGrouping" id="SubMenu" priority="0x0200" type="Menu">
        <Parent guid="CodeStashGrouping" id="CodeStashGroupedMenus" />
        <Strings>
          <ButtonText>Code Stash</ButtonText>
          <CommandName>CodeStash</CommandName>
        </Strings>
      </Menu>
    </Menus>
    <Buttons>
      <!-- Code editor menus -->
      <Button guid="CodeStashGrouping" id="SaveSnippetId" priority="0x0100" type="Button">
        <Parent guid="CodeStashGrouping" id="SubMenuGroup" />
        <CommandFlag>DefaultDisabled</CommandFlag>
          <Strings>
            <CommandName>SaveSnippetId</CommandName>
            <ButtonText>Save snippet</ButtonText>
        </Strings>
      </Button>

      <Button guid = "CodeStashGrouping" id="InsertSnippetId" priority="0x101" type="Button">
        <Parent guid="CodeStashGrouping" id="SubMenuGroup"/>
        <CommandFlag>DynamicVisibility</CommandFlag>
        <Strings>
          <CommandName>InsertSnippetId</CommandName>
          <ButtonText>Insert snippet</ButtonText>
          <ToolTipText>Insert the snippet from CodeStash into the editor window.</ToolTipText>
        </Strings>
      </Button>
      <!-- End code editor menus -->
    </Buttons>

  </Commands>

  <CommandPlacements>

    <CommandPlacement guid="CodeStashGrouping" id="CodeStashGroupedMenus" priority="0x0600">
      <Parent guid="HtmlEditorWindows" id="IDMX_HTM_SOURCE_BASIC"/>
    </CommandPlacement>
    <CommandPlacement guid="CodeStashGrouping" id="CodeStashGroupedMenus" priority="0x0600">
      <Parent guid="HtmlEditorWindows" id="IDMX_HTM_SOURCE_HTML"/>
    </CommandPlacement>
    <CommandPlacement guid="CodeStashGrouping" id="CodeStashGroupedMenus" priority="0x0600">
      <Parent guid="HtmlEditorWindows" id="IDMX_HTM_SOURCE_SCRIPT"/>
    </CommandPlacement>
    <!-- Similar CommandPlacements removed for brevity -->
    <CommandPlacement guid="CodeStashGrouping" id="CodeStashGroupedMenus" priority="0x0600">
      <Parent guid="CssEditorWindows" id="IDMX_HTM_SOURCE_CSS"/>
    </CommandPlacement>
    <CommandPlacement guid="CodeStashGrouping" id="CodeStashGroupedMenus" priority="0x0600">
      <Parent guid="XamlEditorWindows" id="IDMX_XAML_SOURCE_BASIC"/>
    </CommandPlacement>
  </CommandPlacements>

  <Symbols>
    <!-- This is the package guid. -->
    <GuidSymbol name="guidCodeStash_AddinPkg" value="{857c13ce-c509-4244-9216-59b112462c5f}" />

    <!-- This is the guid used to group the menu commands together -->
    <GuidSymbol name="CodeStashGrouping" value="{4f6378f6-4249-474b-bd22-d5ecf4996156}">
      <IDSymbol name="SubMenu" value="0x1001"/>
      <IDSymbol name="SubMenuGroup" value="0x1000"/>
      <IDSymbol name="InsertSnippetId" value="0x0101"/>
      <IDSymbol name="CodeStashGroupedMenus" value="0x1020" />
      <IDSymbol name="SaveSnippetId" value="0x0100" />
    </GuidSymbol>

    <!-- These are the IDs for the various HTML style editors that VS uses. -->
    <GuidSymbol name="HtmlEditorWindows" value="{d7e8c5e1-bdb8-11d0-9c88-0000f8040a53}">
      <IDSymbol name="IDMX_HTM_SOURCE_BASIC" value="0x32" />
      <IDSymbol name="IDMX_HTM_SOURCE_HTML" value="0x33" />
      <IDSymbol name="IDMX_HTM_SOURCE_SCRIPT" value="0x34" />
      <!-- IDSymbol definitions removed for brevity -->
      <IDSymbol name="IDMX_HTM_SOURCE_ASMX_CODE_VB" value="0x39" />
    </GuidSymbol>

    <GuidSymbol name="CssEditorWindows" value="{A764E896-518D-11D2-9A89-00C04F79EFC3}">
      <IDSymbol name="IDMX_HTM_SOURCE_CSS" value="0x0102"/>
    </GuidSymbol>

    <GuidSymbol name="XamlEditorWindows" value="{4C87B692-1202-46AA-B64C-EF01FAEC53DA}">
      <IDSymbol name="IDMX_XAML_SOURCE_BASIC" value="0x0103"/>
    </GuidSymbol>
  </Symbols>
</CommandTable>

我承认这里有很多内容,看起来很吓人,但希望到本节结束时,你会觉得它有意义得多。

在此文件顶部附近,插件会链接到一个外部资源。它通过以下行来实现:

  <Extern href="vsshlids.h"/>

正如我们所见,这一行看起来像是导入了一个 C/C++ 头文件。嗯,不出所料,它实际上就是这么做的。就像我们在 C/C++ 中使用头文件提供定义一样,你也可以为 Visual Studio 扩展这样做。这暴露了你曾经必须用 C 或 C++ 编写扩展的事实,所以这是一种方便的方式来访问标准的 Visual Studio shell ID(vsshlids 的缩写)。你不需要使用这个文件——我们可以完全在代码中做到这一点,正如我们稍后将看到的,但既然它存在并且方便,我们就使用它。

如果你在项目中查找,实际上看不到这个文件;它存储在编译器知道从中检索包含文件的预定义位置(*program files directory*\Microsoft Visual Studio 2010 SDK\VisualStudioIntegration\Common\Inc)。

在这个文件中,我们感兴趣的是以下内容:

 // Guid for Shell's group and menu ids
DEFINE_ Guid (guidSHLMainMenu,
0xd309f791,0xd309f791, 0x903f, 0x11d0, 0x9e, 0xfc, 0x00, 0xa0, 0xc9, 0x11, 0x00, 0x4f);
#define IDM_VS_CTXT_CODEWIN 0x040D

这两部分代表了将上下文菜单连接到代码编辑器窗口所需的 Guid 和命令 ID。这是了解如何向 Visual Studio 窗口添加命令的重要部分——每个窗口都由一个 Guid 表示,每个命令都由一个 Id 表示。你添加到插件中的每个命令都将由一个 Guid 和一个 Id 表示。正如我之前所说,你可以不用导入这个命令文件——你只需要删除 Extern 元素,然后在底部的 Symbols 部分添加以下内容:

<GuidSymbol name="guidSHLMainMenu" value="{D309f791-903f-11d0-9efc-00a0c911004f}">
  <IDSymbol name="IDM_VS_CTXT_CODEWIN" value="0x040D" />
</GuidSymbol>

我现在将跳过 vsct 文件的一些部分,因为我们需要查看 Buttons 以及它们是如何发挥作用的。这将有助于使该文件的其他部分更加清晰。

CodeStash 将在编辑器窗口的上下文菜单中添加两个菜单选项(我稍后会回来讨论这个问题,因为它不像你希望的那么容易)。为了添加这些菜单,我们必须创建 Button 元素。让我们再看看这一部分:

    <Buttons>
      <!-- Code editor menus -->
      <Button guid="CodeStashGrouping" id="SaveSnippetId" priority="0x0100" type="Button">
        <Parent guid="CodeStashGrouping" id="SubMenuGroup" />
        <CommandFlag>DefaultDisabled</CommandFlag>
          <Strings>
            <CommandName>SaveSnippetId</CommandName>
            <ButtonText>Save snippet</ButtonText>
        </Strings>
      </Button>

      <Button guid = "CodeStashGrouping" id="InsertSnippetId" priority="0x101" type="Button">
        <Parent guid="CodeStashGrouping" id="SubMenuGroup"/>
        <CommandFlag>DynamicVisibility</CommandFlag>
        <Strings>
          <CommandName>InsertSnippetId</CommandName>
          <ButtonText>Insert snippet</ButtonText>
          <ToolTipText>Insert the snippet from CodeStash into the editor window.</ToolTipText>
        </Strings>
      </Button>
      <!-- End code editor menus -->
    </Buttons>

将出现在我们 CodeStash 菜单中的每个项都表示为一个 Button 元素。同样,你可以看到我们已经用 Guid 和 Id 唯一地标识了按钮,但它们从哪里来?答案在于 vsct 文件中的 Symbols 部分。在此部分,我们添加了几个 GuidSymbol 元素。GuidSymbol 使用符号名称和一个 Guid 作为值字段来创建。符号名称是我们稍后在要引用 Guid 时使用的;本质上,你可以认为它是一个别名,所以当你看到任何其他元素中的 CodeStashGrouping 时,它的实际意思是使用 Guid {4f6378f6-4249-474b-bd22-d5ecf4996156}。(当我们查看实际连接按钮的代码时,我们将看到这个 Guid 如何变得相关)。

id 指的是包含在 GuidSymbol 组中的 IDSymbol。因此,对于我们的按钮,我们查看存在于 CodeStashGrouping 元素内的 IDSymbol。同样,这是一个别名,所以当你看到 id="..." 时,实际使用的是该 id 引用值。请注意,当你使用来自 GuidSymbol 的 Guid 时,使用的 id 必须列在该特定的 GuidSymbol 元素内。下图应该能更清楚地说明这一点。

CodeStash grouping overlaid on Button element. 

每个 Button 都有一个 Parent。我们稍后会回到这一点,因为它有助于我们确定按钮布局的层次结构。

Strings 部分是我们实际设置菜单中显示的文本(ButtonText)以及我们想要显示的任何可选工具提示文本(ToolTipText)的部分。

此时,我认为我们需要从 vsct 文件中短暂休息一下。我们需要看看这个文件是如何融入更大的图景的,毕竟里面没有真正的代码——我们只有关于我们可以使用的命令的描述。

核心命令文件

当你创建一个 Visual Studio 插件时,插件模板会创建很多文件。现在我们要重点关注三个文件。

Core files of interest.

“天哪 Pete,他们甚至有一个名为 Guids 的文件。他们一定对 Guids 很着迷,”我听到你在说。嗯,是的,我想你是对的。Guid.cs 文件代表了我们在 vsct 文件中的 Symbols 部分必须创建的相同 Guids。看,我告诉过你我们会回来的。它看起来是这样的:

using System;

namespace CodeStash.Addin
{
  static class GuidList
  {
    public const string guidCodeStash_AddinPkgString = "857c13ce-c509-4244-9216-59b112462c5f";
    public const string CodeStashGroupingString = "4f6378f6-4249-474b-bd22-d5ecf4996156";

    public static readonly Guid CodeStashGrouping = new Guid(CodeStashGroupingString);

    public const string PropertyPageGuid = "D6B8D576-8A4F-402A-8D20-B1FD98322EEC";
}

因为这是存储 Guids 的一个很方便的地方,所以我们偷偷地在这里添加了一个与命令无关的 Guid。底部的 Guid 映射到我们添加到 Visual Studio 设置对话框中的一个属性页。添加自己的属性页是 Visual Studio 插件可以做的许多很酷的事情之一。

你可能会惊讶于我们在这个文件中实际拥有的 Guids 数量很少。毕竟,我们在 vsct 文件中添加了更多的 GuidSymbol 元素。原因是并非每个 GuidSymbol 条目都与我们的 CodeStash 命令相关——事实上,我们只需要两个 Guids。其他 Guids 与内部 Visual Studio 元素相关,我们将在稍后将命令连接到各种编辑器窗口时使用它们。

此时,你已经领先我一步,意识到 PkgCmdID.cs 文件实际上映射到了 IDSymbol 元素中定义的命令 ID。

namespace CodeStash.Addin
{
  static class PkgCmdIDList
  {
    public const uint SaveSnippetId = 0x100;
    public const uint InsertSnippetId = 0x101;
  };
}

那里没什么好担心的。同样,这个列表中的 ID 数量远少于 IDSymbol 元素数量也无关紧要,到目前为止,你已经推断出这些其他 ID 与命令在 Visual Studio 中的放置方式有关。

你可能会注意到这两者文件的其中一件事是它们只定义了常量。这表明其他代码将使用它们,在这种情况下,我们感兴趣的文件是 CodeStash.AddinPackage.cs。我将重点介绍我们如何创建 CodeStash 菜单,并让它们实际显示在屏幕上,而不是列出整个文件。

如果你查看这个类,你会发现它继承自 Package。你可以将它看作是插件的入口点,因此,我们将在文件中使用它将代码连接到命令表。幸运的是,有一个初始化例程我们可以用来做这件事:

protected override void Initialize()
{
  base.Initialize();

  GetDTE();

  // Add our command handlers for menu (commands must exist in the .vsct file)
  OleMenuCommandService mcs = GetService(typeof(IMenuCommandService)) as OleMenuCommandService;
  if (null != mcs)
  {
    // Create the command for the menu item.
    CommandID menuCommandID = new CommandID(GuidList.CodeStashGrouping, PkgCmdIDList.SaveSnippetId);
    CommandID searchSnippetCommandID = new CommandID(GuidList.CodeStashGrouping, PkgCmdIDList.InsertSnippetId);

    saveSnippetMenu = new OleMenuCommand(AddSnippetMenuItemCallback, menuCommandID);
    saveSnippetMenu.BeforeQueryStatus += OnQuerySaveSnippet;
    mcs.AddCommand(saveSnippetMenu);

    MenuCommand searchMenuItem = new MenuCommand(SearchSnippetsMenuItemCallback, searchSnippetCommandID);
    mcs.AddCommand(searchMenuItem);
  }
}

我们稍后会回到对 GetDTE 的调用。在此之前,我们将讨论实际的菜单创建代码。

Package 类通过调用 GetService 来获取 Visual Studio 提供的任何服务的访问权限。只需传入所需服务的类型,它就可以供我们使用。因此,在添加菜单的情况下,我们需要获取 IMenuCommandService 的实例(GetService 返回一个对象,所以我们必须将其转换为适当的类型)。

现在我们可以看到我们创建了两个 CommandID 实例,此时之前讨论过的部分应该会变得明显。每个 CommandID 都使用来自 Guids.cs 的 Guid 和来自 PkgCmdIDList.cs 的 id 进行实例化,正如我们已经讨论过的,它们与 vsct 文件中定义的元素匹配。看,我告诉过你这一切都会开始整合起来。

无论如何,仅仅创建 CommandID 实例是不够的。我们实际上需要用它们做些事情,我们所做的是使用它们来创建实际的 MenuCommandOleMenuCommand 实例(每个实例都有一个接受回调例程的参数,我们用它来实际执行我们想要从菜单命令中执行的操作)。

现在,你可能想知道为什么我们有两个不同类型的菜单。原因其实很简单;如果我们不需要 Visual Studio 自动启用和禁用菜单以响应事件,那么你应该使用 MenuCommand。但是,如果我们确实需要这个功能,那么我们必须使用 OleMenuCommand(我们使用 OleMenuCommand.BeforeQueryStatus 事件来挂钩我们的启用/禁用逻辑)。一旦我们创建了菜单命令,就可以使用 AddCommand 将命令添加到菜单命令服务中。

如果我们根据我刚才描述的代码运行,我们会得到两个菜单项,但它们会出现在主编辑器窗口的上下文菜单中,但这并不是我们想要的。我们真正想要的是在上下文菜单旁边有一个 CodeStash 子菜单,这是让我们的命令在众多其他命令中脱颖而出的好方法。现在,关于如何做到这一点(实现这一点)的文档简直是一场噩梦,很难找到和理解——对不起微软,但如果你想让人们理解这一切是如何协同工作的,你必须养成提供体面样本和体面命名约定的习惯(这就是为什么我们在 CodeStash 中不使用 guid..... 作为我们的菜单名称)。

那么,我们实际上是如何创建子菜单的呢?唉,恐怕又要回到 vsct 文件了。别担心,那里没有太多内容需要介绍。到目前为止,我已经提供了足够的信息,可以介绍一些更“高级”的概念,而不会引起头痛。我将一点一点地分解它,首先从我们将菜单连接到编辑器窗口上下文菜单的方式开始。

<Group guid="CodeStashGrouping" id="CodeStashGroupedMenus" priority="0x0600">
  <Parent guid="guidSHLMainMenu" id="IDM_VS_CTXT_CODEWIN"/>
</Group>

好了,我们创建了一个组,我们将使其成为编辑器上下文窗口的子项。它没有物理表示,它只是作为一种将菜单挂入的方式。

<Menu guid="CodeStashGrouping" id="SubMenu" priority="0x0200" type="Menu">
  <Parent guid="CodeStashGrouping" id="CodeStashGroupedMenus" />
  <Strings>
    <ButtonText>CodeStash</ButtonText>
  </Strings>
</Menu>

正如你所见,Menu 元素通过 Parent 元素链接到上面列出的 Group。好的,我们在命令表中有一个 Menu,但我们如何将我们的菜单连接到它呢?你可能会猜到,我们需要添加另一个 Group,并将这个 Menu 作为 Parent

<Group guid="CodeStashGrouping" id="SubMenuGroup" priority="0x0602">
  <Parent guid="CodeStashGrouping" id="SubMenu" /> 
</Group>

最后,我们的 Buttons 的父项是这个 Group。当你需要添加子菜单时,这种通过子/父关系链接 GroupMenu 的机制就是实现方式。

好的,如果我们现在运行这个扩展,它应该会显示我们的 CodeStash 子菜单和下面的按钮,对吗?嗯,这实际上取决于你在 Visual Studio 中打开的是哪个编辑器。没有一个标准的编辑器,它们都有不同的上下文菜单,所以我们的菜单只会显示在标准的文本编辑器窗口中,例如 C# 编辑器窗口。嗯,这对于一个旨在与 Visual Studio 中几乎任何语言一起工作的插件来说没什么用。一定有办法能将我们的菜单添加到其他编辑器窗口。嗯,幸运的是,有。

如果你查看 Symbols 部分,你会看到我们创建了其他的 GuidSymbol 元素。每个编辑器窗口都有一个关联的 Guid 和命令 ID,所以我们将它们添加到了这个部分。有了这些信息,我们可以通过一个叫做 CommandPlacement 的 vsct 功能将我们的菜单放置在其他编辑器窗口上。

<CommandPlacement guid="CodeStashGrouping" id="CodeStashGroupedMenus" priority="0x0600">
  <Parent guid="HtmlEditorWindows" id="IDMX_HTM_SOURCE_BASIC"/>
</CommandPlacement>

正如你在这里看到的,我们实际上说的是“将具有 CodeStashGrouping Guid 和 CodeStashGroupedMenus 的 Menu 放置在具有由 HtmlEditorWindows 标识的 Guid 和 IDMC_HTM_SOURCE_BASIC 的 id 的窗口的上下文菜单中”。我知道这有点啰嗦,但这正是内部发生的事情。对我们可以找到的所有不同的编辑器窗口和 ID 都这样做,我们的菜单应该就会出现。

再次,微软,你们能否让这方面的文档更清晰一些?找到这些信息应该更容易,而不必在寒冷的十二月夜晚,只穿着一条丁字裤和一条由口袋绒制作的围巾,献祭一根黄瓜。

Visual Studio 体验

虽然创建 Visual Studio 插件很有用,但如果无法实际与 Visual Studio 本身进行交互,插件的价值就会很小。幸运的是,我们可以通过 Package 类访问 Visual Studio 功能,使用 GetService 来检索提供这些功能的服务。如果你还记得,在上一节中,我说 CodeStash 有一个名为 GetDTE 的方法;这就是我们选择我们感兴趣的功能的时候。在我们的例子中,我们想访问DesignTime环境 (DTE),它让我们能够访问编辑器中的文档,以及用于提供状态信息的任务栏。

private void GetDTE()
{
  EnvDTE.DTE provider = (EnvDTE.DTE)GetService(typeof(EnvDTE.DTE));
  MEFPartsResolver.Instance.Resolve<IDocumentService>().SetDTE(provider);

  IVsStatusbar statusBar = (IVsStatusbar)GetService(typeof(SVsStatusbar));
  MEFPartsResolver.Instance.Resolve<IStatusBarService>().SetStatusBar(statusBar);
}

该插件大量使用了接口,所以我们使用 MEF 来解析这些接口的物理实现。我们稍后会更详细地介绍这一点。

插件开发者可以使用的更友好的功能之一是能够将自己的设置页面添加到标准的 Visual Studio 设置对话框中。有了这个能力,就可以轻松地提供无缝的设计师体验,让插件看起来像是属于 VS 内部。我们在 CodeStash 中使用它来存储当前用户的登录信息。

添加页面包括三个部分:创建属性页的 UI,添加一个显示该页面的 DialogPage 类,以及将其注册到包中。我不会介绍 UI 的创建,因为那只是一个用户控件。有趣的部分是 DialogPage 和注册部分。DialogPage 在 CodeStash.Addin.CodeStashSettingsPropertyPage 中实现(在 CodeStash.Addin 的 PropertyPage 文件夹中查找)。

[Guid(GuidList.PropertyPageGuid)]
public class CodeStashSettingsPropertyPage : DialogPage
{
  [Browsable(false), DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
  protected override System.Windows.Forms.IWin32Window Window
  {
    get
    {
      CodeStashSettingsView settings = new CodeStashSettingsView();
      settings.Location = new System.Drawing.Point(0, 0);
      return settings;
    }
  }
}

现在看到属性页有一个 Guid 与之关联,可能不会让你感到惊讶。是的,Visual Studio 在查找此页面时以及我们在注册页面时都使用该 Guid 作为 ID。这个 Guid 是我们创建的,我选择将其放在 GuidList.cs 中。

“啊哈,Pete。这都很简单,但 Visual Studio 怎么会知道我们的设置页面呢?”我听到你这样问(或者那可能只是我脑子里的声音)。嗯,将设置注册到插件就像用一个简单的属性来装饰包类一样简单。

[ProvideOptionPageAttribute(typeof(CodeStashSettingsPropertyPage), "CodeStash", "Settings", 101, 1000, true)]

如果你运行 CodeStash,打开 VS 设置对话框(通过 **工具 > 选项...**),你会看到 CodeStash 设置在那里,作为 **CodeStash > 设置**(设置页面的名称以及它们出现的键都在属性中指定)。

CodeStash settings page. 

显然,如果用户没有输入凭据,并且他们尝试运行 CodeStash,他们将无法保存或检索代码片段,所以我们需要一种方法来引导他们输入凭据。我们不能依赖他们阅读文档,我们也不希望他们只在打开其中一个代码片段窗口后才知道他们无法访问 CodeStash。这意味着我们需要引导他们到设置页面,以便他们输入详细信息。幸运的是,以编程方式访问我们的设置页面非常容易。我们所要做的就是获取 DTE 的引用,然后打开 **选项** 窗口,传入我们属性页的 Guid。

private void ShowCodeStashSettings()
{
  EnvDTE.DTE dte = (EnvDTE.DTE)GetGlobalService(typeof(SDTE));
  dte.ExecuteCommand("Tools.Options", GuidList.PropertyPageGuid);
}}

就这样,如果你知道设置页面的 Guid,你就可以使用这个简单的命令来访问它(尽管这个功能文档很少,同样,需要大量挖掘才能找到它)。

那么,我们到底在哪里存储这些登录信息呢?嗯,不出所料,我们使用隔离存储来存储这些数据。这里有两个类值得我们关注。第一个类 LoginDetails 是包含登录信息的“模型”。第二个类 CredentialManager 负责将设置保存到隔离存储,并从中加载回来。出于安全考虑,代码使用 DESCryptoProvider 来编码和解码文件。(要找到这些文件,请在 **CodeStash.Addin.Core.dll** 的 Login 目录中查找)。

保存文件涉及序列化 LoginDetails,然后将其转换为字节数组,最后保存到磁盘。

public static void Save(LoginDetails login)
{
  login.Validate();

  XmlSerializer serializer = new XmlSerializer(typeof(LoginDetails));
  using (StringWriter sw = new StringWriter())  
  {
    serializer.Serialize(sw, login);
    SaveFile(ASCIIEncoding.ASCII.GetBytes(sw.ToString()));
  }
}

private static void SaveFile(byte[] fileData)
{
  using (IsolatedStorageFile store = GetStore())
  {
    using (IsolatedStorageFileStream ifs = GetStream(store, FileMode.OpenOrCreate))  
    {
      using (DESCryptoServiceProvider des = GetCryptoProvider())
      {
        using (CryptoStream crypt = new CryptoStream(ifs, des.CreateEncryptor(), CryptoStreamMode.Write))
        {
          crypt.Write(fileData, 0, fileData.Length);
          crypt.Close();
        }
      }
    }
  }
}

加载文件只是简单地将文件读回,解密文件流,并将数据反序列化回 LoginDetails 实例。如果详细信息以前未保存,则会创建一个新的 LoginDetails 实例。

public static LoginDetails Load()
{
  XmlSerializer serializer = new XmlSerializer(typeof(LoginDetails));
  string fileContents = ReadFile();
  if (!string.IsNullOrWhiteSpace(fileContents))
  {
    using (StringReader sr = new StringReader(fileContents))
    {
      return (LoginDetails)serializer.Deserialize(sr);
    }
  }
  // If we get here, we haven't previously created the file.
  return new LoginDetails();
}

private static string ReadFile()
{
  using (IsolatedStorageFile store = GetStore())
  {
    if (!store.FileExists(FileName))
    {
      return string.Empty;
    }

    using (IsolatedStorageFileStream ifs = GetStream(store, FileMode.Open))
    {
      using (DESCryptoServiceProvider des = GetCryptoProvider())
      {
        using (CryptoStream crypt = new CryptoStream(ifs, des.CreateDecryptor(), CryptoStreamMode.Read))
        {
          using (StreamReader reader = new StreamReader(crypt))
          {
            string retVal = reader.ReadToEnd();
            crypt.Close();

            return retVal;
          }
        }
      }
    }
  }
}

CodeStash - 连接两个世界

现在似乎是时候谈谈凭据管理了,以及插件如何与实际的 CodeStash 服务协同工作。如果你阅读了 Sacha 关于 CodeStash 网站的文章(如果没有,为什么不呢?去读读吧,我等你回来),你就知道 CodeStash 使用 REST 来处理几乎所有的 CodeStash 功能。嗯,插件大量使用了这些相同的 REST 服务——这意味着它需要使用与网站完全相同的凭据,并且它遵循与网站中指定的相同的凭据加密规则。

好的,这有点啰嗦。它到底是什么意思呢?嗯,既然你已经读了 Sacha 的文章,你就知道有一个设置说明是否加密了凭据信息。这个设置在两个地方都必须相同,而我们访问它的途径是通过 CodeStash.Common 程序集中的 EncryptionHelper 类读取配置文件中的 EncryptionEnabled 键。这个设置决定了用户凭据是否被加密,并且应该在插件的配置文件以及 web.config 中进行设置。

如果你查看 EncryptionHelper 类的构造函数,你会发现它看起来是这样的:

/// <summary>
/// Static constructor reads the EncryptionEnabled from the App.Config or Web.Config
/// </summary>
static EncryptionHelper()
{
  encryptionEnabled = CodeStash.Common.Helpers.ConfigurationSettings.EncryptionEnabled;
}

看起来很简单,不是吗?嗯,这一行代码在后台需要进行一些复杂的处理,以确保插件和网站都能使用配置文件来检索该值。

正如你所知,你可以将一个配置文件与一个 .NET 可执行文件关联起来,只要它被复制到输出路径等位置,你就可以访问其中的值。我不会在这里重复你已经了如指掌的内容,除了说关键在于配置文件与可执行文件相关联,而不是 DLL。现在,这可能会给我们带来一些问题,因为我们无法将配置文件分配给 Visual Studio 并开始使用它。但是,正如你所见,我们只是使用一个属性来获取我们需要的配置值。里面一定还有别的东西,不是吗?

嗯,是的,有。为了将配置文件与 DLL 关联起来,我们将它命名为 **CodeStash.Addin.Dll.Config**,模仿 exe 配置文件命名约定。然后,我们在 **CodeStash.Common.Helpers.ConfigurationSettings** 文件中使用一些技巧来从配置文件中提取值。

private static Configuration configuration;

private static string RetrieveSetting(string setting)
{
  string value = ConfigurationManager.AppSettings[setting];
  if (string.IsNullOrWhiteSpace(value))
  {
    GetConfiguration();
    KeyValueConfigurationElement ret = configuration.AppSettings.Settings[setting];
    if (ret != null)
    {
      value = ret.Value;
    }
  }
  return value;
}

private static void GetConfiguration()
{
  if (configuration != null) return;
  string location = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "CodeStash.Addin.Core.dll");
  configuration = ConfigurationManager.OpenExeConfiguration(location);
}

好的,所以我们的配置设置属性调用 RetrieveSetting。如果它要找的设置有一个值,这意味着我们正在从 web.config 文件中获取值。如果它是空的,我们需要更深入地挖掘,所以扩展会调用 GetConfiguration,它使用标准的 .NET ConfigurationManager.OpenExeConfiguration 来打开我们的插件配置文件。一旦我们打开了配置文件,就可以轻松地检索设置并返回它。为了避免每次都获取配置文件,我们在打开它时就将其缓存起来。

MEF、Sacha 和大勒布斯基

我已经说过,导致插件交付的一个重大延迟是因为我改变了对支撑它的 MVVM 框架的想法。起初,我没有打算使用第三方框架——我编写了大量的功能来支持它。然而,我内心总有一种挥之不去的疑虑,觉得这样做不对,所以在经过一些自我反省、冥想和脚趾摔跤之后,我决定使用一个现有的框架。我考虑过使用我自己的 Goldlight 框架,但最终决定,现在是使用 Sacha 的 Cinch 框架的好时机。

关于 Cinch 的一个重要之处在于它与 MEF 兼容,并且与我参与开发的一个项目 MEFedMVVM 兼容得很好。Visual Studio 大量使用 MEF,所以看起来将它们连接起来应该很简单,并且进展会突飞猛进。哈!我说道。哈!无论我怎么尝试,我都无法让 MEFedMVVM 在 Visual Studio 中正常工作——Sacha 主动提出帮我看看,我欣然接受了他的提议。是的,你猜对了,Sacha 也遇到了和我同样的问题,我们得出结论,我们的 MEF 加载器与 Visual Studio 大量使用 MEF 的事实发生了冲突,所以它有自己的 MEF 加载基础设施。嗯,Sacha 的座右铭是“永不言弃”(现在是我给他安上的)。

最终,Sacha 决定亲自处理此事,并为 CodeStash 编写了一个新的 MEF 导出提供程序,该程序可以与 Cinch 一起工作。你可以通过查看 **CodeStash.Addin** 文件夹中的 **MEF** 文件来了解他是如何实现这一点的。从 CodeStash 的角度来看,最有趣的点可以在 MEFPartsResolver 文件中看到。在这里,我们想要在 CodeStash 中使用的 MEFed 服务被解析并连接起来。然后,当代码的其他部分需要访问其中一个服务时,就会调用这个类。

是时候休息一下了

嗯,为插件进行所有这些底层工作固然很好,但我们必须能够与网站进行通信,而 Sacha 提供了一些非常方便的 RESTful 服务供插件连接。幸运的是,连接到这些服务非常容易。其核心在于,插件使用一个名为 CodeStashRestBase 的类,该类提供了我们将使用的核心实现。让我们来看一个插件调用的例子,看看它是如何实际调用 REST 代码的。

 protected byte[] Search(string searchText, SearchType searchType, CodeSnippetVisibility visibility, string[] tags, int pageSize, int pageNumber)
{
  JSONSearchInput input = new JSONSearchInput(
    openId, // If not specified, will be an empty string but the password must be set.
    emailAddress, // Must always be present.
    password, // If not specified, will be an empty string, but the OpenID must be set.
    searchType,
    searchText,
    pageNumber,
    pageSize,
    visibility,
    tags
  );
  return CallService(input, CodeStash.Common.Helpers.ConfigurationSettings.RestAddress, "Search");
}

private byte[] CallService<T>(T input, string address, string methodToCall)
{
  values = new NameValueCollection();
  Utilities.AddValue(values, "input", input);

  WebClient client = new WebClient();
  return client.UploadValues(string.Format("{0}{1}", address, methodToCall), values);
}

当我们尝试从插件中进行搜索时,我们会构建一个 json 类型(json 类型在 CodeStash.Common 程序集中定义),然后将其传递给一个非常方便的小方法,该方法实际上会调用服务,并从配置文件中获取服务的地址。所有 CodeStash json 类型都将 open id、电子邮件地址和密码作为前三个参数。

当代码进行搜索时,我们得到一个字节数组,我们需要将其转换回我们可以在插件中使用的类型,毕竟,字节数组不容易阅读。为了获取类型,我们使用了一个方便的小转换例程,所以在搜索的情况下,我们的代码转换如下:

return Utilities.GetValue<JSONPagesSearchResultCodeSnippet>(base.Search(searchText, searchType, visibility, tags, PageSize, pageNumber));

而这个方法很简单,就像这样:

internal static T GetValue<T>(Byte[] results) where T : class
{
  using (MemoryStream ms = new MemoryStream(results))
  {
    jss = new DataContractJsonSerializer(typeof(T));
    return (T)jss.ReadObject(ms);
  }
}

正如这清楚地展示的,我们所做的就是创建一个 MemoryStream 来封装从 REST 服务返回的流。然后,我们使用它从流中重建一个正确的 json 类型。代码相当直接,对于任何有经验的 .NET 开发人员来说,都应该相当熟悉。

注意:REST 服务设计为通过 MEF 访问,所以当我们查看 ViewModel 代码时,你会看到它通过接口访问。所有与 REST 服务的交互都在接口中实现。

public interface IRestService
{
  int PageSize { get; set; }

  JSONGroupingResult RetrieveGroups();

  JSONLanguagesResult RetrieveLanguages();

  JSONCodeSnippetAddSingleResult AddCodeSnippet(
    string actualCode,
    string categoryName,
    int languageId,
    string language,
    string tags,
    string description,
    string title,
    int? groupId,
    string groupName,
    CodeSnippetVisibility visibility);

  JSONPagesSearchResultCodeSnippet Search(
    string searchText, 
    SearchType searchType, 
    CodeSnippetVisibility visibility, 
    string[] tags, 
    int pageNumber
  );
}

正如你所见,接口并不复杂。插件实际上不需要做太多事情就可以与网站通信,所以接口非常简单。

“够了,Pete,给我看屏幕。如果你不看,我就用我的 blurglecruncheon 撕烂你。”好的,我明白我们不得不涵盖很多底层功能,而且可能看起来有点零散。这与显示屏幕、与 REST 服务交互以及实际在 Visual Studio 窗口中进行操作有什么关系呢?坐好,放松,享受旅程。我们将开始将这一切整合起来。

你可能想知道 WPF 的部分在哪里。毕竟,Sacha 和我都是 WPF 的忠实粉丝,而且我已经说过我们在这里使用 MVVM 框架,但我还没有谈论过它。让我们看看我们用来将代码片段实际保存到 CodeStash 数据库的功能。

保存代码片段

当用户在 Visual Studio 的编辑器窗口中选择一些文本时,**保存代码片段**菜单会通过 BeforeQueryStatus 事件启用。点击 **保存代码片段** 会创建一个模型窗口,使用以下命令:

new AddSnippetView().ShowModal();

然而,如果你打开视图窗口,你会发现我们没有创建简单的 WPF 对话框。由于我们希望我们的应用程序与 Visual Studio 的行为保持一致,因此我们使用了基于 DialogWindow 的不同基类。

<ui:DialogWindow x:Name="Window"
  x:Class="CodeStash.Addin.Views.AddSnippetView"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  xmlns:enum="clr-namespace:CodeStash.Common.Enums;assembly=CodeStash.Common"
  xmlns:extension="clr-namespace:CodeStash.Addin.Extensions"
  xmlns:local="clr-namespace:CodeStash.Addin"
  xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  xmlns:ui="clr-namespace:Microsoft.VisualStudio.PlatformUI;assembly=Microsoft.VisualStudio.Shell.10.0"
  xmlns:vsfx="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.10.0"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:core="clr-namespace:CodeStash.Addin.Core;assembly=CodeStash.Addin.Core"
  xmlns:PresentationOptions="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
  SizeToContent="Height"
  Width="500"
  Background="{DynamicResource {x:Static vsfx:VsBrushes.ToolWindowBackgroundKey}}"
  Foreground="{DynamicResource {x:Static vsfx:VsBrushes.ToolWindowTextKey}}"
  ShowInTaskbar="False"
  Title="Save Snippet"
  WindowStartupLocation="CenterScreen"
  mc:Ignorable="d">
...
</ui:DialogWindow>

如果你不熟悉 XAML,请不要介意,我会快速浏览一下这段代码的意思,然后我会介绍 BackgroundForeground 行。如果你是 XAML 专家,请随时在此处休息片刻,当我们快速浏览这段代码时。

有效地,这段代码告诉编译器创建一个继承自 DialogWindow 的类。创建的类名为 AddSnippetView,然后添加一些 XML 命名空间 (xmlns),这与我们在标准 .cs 文件中添加 using 语句的方式类似。以 SizeToContentWidth 开头的行设置了对话框的大小——WPF 提供了将窗口大小调整到子元素大小的能力,或者你可以显式设置大小。我将跳过接下来的两行,因为我想回来为可能跳过此段的 XAML 专家进行讲解。

然后,对话框被设置为不出现在任务栏中,“保存代码片段”出现在标题栏中,窗口在屏幕中央启动。呼,这么多事情都处理好了,而且不像第一眼看上去那么吓人。

XAML 专家们,你们可以回到房间里了。

现在,轮到我上面加粗的那些行了。它们到底做了什么?嗯,它们将背景和前景颜色设置为预定义的 Visual Studio 颜色。通过使用 DynamicResource,我们让我们的对话框响应底层 VS 主题的变化,所以如果用户更改他们的 VS 主题,我们的对话框将使用更新的主题。

我不再详细介绍剩余的 XAML 了——它相当直观,而且实际上也没有做什么巧妙的事情。它纯粹是为了展示 UI。有趣的部分都在后面的 ViewModel 中,以及我们在扩展中用于连接 RESTful 服务的代码。让我们从构造函数开始。

[ImportingConstructor]
public AddSnippetViewModel(
  IViewAwareStatus viewAwareStatusService,
  IMessageBoxService messageBoxService,
  IUIVisualizerService uiVisualizer,
  IDocumentService documentService,
  IRestService restService,
  IStatusBarService statusBarService)
  {


  // Default to the user choosing a group.
  chooseGroup = true;
  SnippetVisibility = CodeSnippetVisibility.JustMe;

  this.viewAwareStatusService = viewAwareStatusService;
  this.messageBoxService = messageBoxService;
  this.uiVisualizer = uiVisualizer;
  this.documentService = documentService;
  this.restService = restService;
  this.statusBarService = statusBarService;

  RetrieveLanguages();
  RetrieveGroups();

  SaveSnippetCommand = new SimpleCommand<object, object>(x => isValid, ExecuteSaveSnippet);

  Validator = new AddSnippetViewModelValidator(this);

  statusBarService.SetText(Messages.Ready);
}

ImportingConstructor 属性用于 MEF,以允许我们连接到与构造函数参数列表中的参数匹配的服务。前三个参数由 Cinch 提供,而后三个参数是我们扩展特有的。注意:你不会在代码库中找到任何使用此参数列表调用此 ViewModel 的代码;这由 MEF 为我们处理,所以我们不必担心。

构造函数内部相当直接,代码指定了一些默认值并将成员连接到参数列表。这里有趣的一行是 statusBarService.SetText(Messages.Ready);,它清楚地表明,即使我们向这个构造函数传递了一个接口,并且还没有添加任何代码来实际连接到状态栏服务实现,MEF 解析意味着我们正在这里使用已解析的实例。

在构造函数中,我们调用了两个使用 REST 服务的方法;一个用于检索语言列表,另一个用于检索组以显示在组选择下拉列表中。让我们看看检索语言的代码是什么样的。

private void RetrieveLanguages()
{
  statusBarService.SetText(Messages.GetLanguage);
  JSONLanguagesResult languages = restService.RetrieveLanguages();
  Languages = languages.Languages;
  statusBarService.Clear();
}

在此方法中,状态栏服务在 Visual Studio 状态栏中写入 CodeStash 正在检索语言列表(VS 提供的这种向用户提供小反馈的能力有助于为你的扩展增添一丝专业的润色)。REST 服务用于检索语言列表,返回的语言存储在 ObservableCollection 中。最后,该方法清除了状态栏文本。

Pete 的快速绕道

我们在这里提到了几次状态栏服务,而且我们还没有真正看到多少与 Visual Studio 本身交互的代码。现在似乎是一个绝佳的机会来展示如何轻松地连接到这些 Visual Studio 服务。

CodeStash 中使用状态栏服务分为两个部分。第一部分涉及获取状态栏服务本身。这在 CodeStash_AddinPackage 中通过以下代码处理:

IVsStatusbar statusBar = (IVsStatusbar)GetService(typeof(SVsStatusbar));
MEFPartsResolver.Instance.Resolve<IStatusBarService>().SetStatusBar(statusBar);

在那里,我们只是获取 Visual Studio 状态栏服务的引用,然后将这个引用保存在我们的具体状态栏服务中。这就引出了第二部分,即状态栏服务的实际实现。

[Export(typeof(IStatusBarService))]
[PartCreationPolicy(CreationPolicy.Shared)]
public class StatusBarService : IStatusBarService
{
  private IVsStatusbar statusBar;

  public void SetStatusBar<T>(T statusBar)
  {
    this.statusBar = statusBar as IVsStatusbar;
  }

  public void SetText(string text)
  {
    if (!IsFrozen)
    {
      statusBar.SetText(text);
    }
  }

  public void Clear()
  {
    if (!IsFrozen)
    {
      statusBar.Clear();
    }
  }

  private bool IsFrozen
  {
    get
    {
      int frozen;
      statusBar.IsFrozen(out frozen);
      return frozen != 0;
    }
  }
}

这些属性由 MEF 用于识别此类作为 MEFable“契约”的实现,并声明在插件中遇到该服务时将使用相同的实例。大部分功能都相当不言自明,可能唯一需要讨论的区域是 IsFrozen 属性。此属性用于确定我们是否可以与状态栏交互,或者是否有其他服务将其冻结以供其使用。如果你在自己的扩展中使用 VS 状态栏,我强烈建议你确保你也这样做——扩展之间进行协作很重要,不要给其他人的扩展带来任何意外的副作用。

回到正题

由于我们使用 Cinch 作为底层框架,代码大量使用了那里提供的功能和概念。其中一个对我们来说更有趣的功能是提供验证——通常在 ViewModel 内部实现。CodeStash 没有像这样将代码合并在一起,而是将验证移到了可以从 View 中使用的单独类中。这意味着验证代码易于测试,并且独立于 ViewModel。

当用户成功填写完所有需要保存代码片段的信息并点击“保存”后,应用程序将这些信息传递给 REST 服务。有一个小技巧是,我们必须对传递的任何文本进行 HTML 编码,以便能够传递。当文本被编码时,通常会使 REST 服务瘫痪的字符被转换,这样服务就不会因为我们尝试使用 < 字符(在这种情况下,该字符将被编码为 &lt;)而崩溃。这意味着我们必须在从服务接收代码时解码文本,以便插入的内容与我们最初选择的文本相同,而不是 HTML 编码的版本;为了在搜索结果对话框中显示文本,我们使用了一个转换器来实时解码编码的数据。

保存代码片段的代码如下:

private void ExecuteSaveSnippet(object parameter)
{
  try
  {
    statusBarService.SetText(Messages.Save);

    int? groupId = null;
    string groupName = Encode(NewGroup);
    if (SelectedGroup != null)
    {
      groupId = SelectedGroup.GroupId;
      groupName = Encode(SelectedGroup.Description);
    }

    restService.AddCodeSnippet(
      Encode(this.documentService.SelectedText), 
      Encode(Category), 
      SelectedLanguage.LanguageId, 
      SelectedLanguage.Language, 
      Encode(Tag), 
      Encode(Description), 
      Encode(Title), 
      groupId, 
      groupName, 
      SnippetVisibility);
    ((DialogWindow)this.viewAwareStatusService.View).Close();
    statusBarService.SetText(Messages.Ready);
    messageBoxService.ShowInformation(Messages.SnippetSaved);
  }
  catch (Exception ex)
  {
    messageBoxService.ShowError(Messages.SnippetFailed);
  }
}

private string Encode(string text)
{
  return WebUtility.HtmlEncode(text);
}

实际的代码片段文本是从文档服务中检索的。这个简单的小服务只是连接到 Visual Studio DTE 并像这样获取选定的文本:

public string SelectedText
{
  get 
  {
    TextSelection selection = GetSelectedText();
    if (selection == null) return string.Empty;
    return selection.Text; 
  }
}

private TextSelection GetSelectedText()
{
  if (provider == null || provider.ActiveDocument == null) return null;
  return (TextSelection)provider.ActiveDocument.Selection;
}

好了,这涵盖了一些将代码片段保存到数据库的有趣部分,这都很好,但是检索代码片段呢?嗯,这里事情变得稍微复杂一些。我们将开始看看搜索是如何发生的,然后转到显示结果并向代码编辑器插入代码片段时会发生什么。这是实际执行搜索的代码:

private void DoSearch(int pageNumber, bool fetchingDueToPaging)
{
  AsyncState = AsyncType.Busy;
  WaitText = "Searching for snippets";
  ApplicationHelper.DoEvents();

  CancellationToken cancellationToken = cancellationTokenSource.Token;
  Task<bool> cancellationDelayTask = TaskHelper.CreateDelayTask(searchTimeOutMilliSeconds);
    cancellationDelayTask.ContinueWith(dt =>
    {
      cancellationTokenSource.Cancel();
    }, TaskContinuationOptions.OnlyOnRanToCompletion);

  try
  {
    Task<JSONPagesSearchResultCodeSnippet> searchTask = Task.Factory.StartNew<JSONPagesSearchResultCodeSnippet>(() =>
    {
      string searchText = GetSearchText();
      string[] tagsArray = !String.IsNullOrEmpty(this.Tags) && this.SelectedSearchType == SearchType.ByTag
        ? this.Tags.Split(new string[] { ";", ",", ":" }, StringSplitOptions.RemoveEmptyEntries)
        : new string[] { };

      if ((!string.IsNullOrEmpty(searchText) && this.SelectedSearchType != SearchType.ByTag) ||
        (tagsArray.Any() && this.SelectedSearchType == SearchType.ByTag))
      {
        JSONPagesSearchResultCodeSnippet results = restService.Search(
          searchText,
          this.SelectedSearchType,
          this.SelectedVisibility,
          tagsArray,
          pageNumber);
        return results;
      }
      else
      {
        return null;
      }
    }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);

    searchTask.ContinueWith(ant =>
    {
      if (!fetchingDueToPaging)
      {
        Mediator.Instance.NotifyColleagues<JSONPagesSearchResultCodeSnippet>("DisplaySearchResults", ant.Result);
      }
      else
      {
        Mediator.Instance.NotifyColleagues<JSONPagesSearchResultCodeSnippet>("DisplayPagedSearchResults", ant.Result);
      }
      AsyncState = AsyncType.Content;
      ApplicationHelper.DoEvents();
    }, cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.FromCurrentSynchronizationContext());

    searchTask.ContinueWith(ant =>
    {
      AsyncState = AsyncType.Error;
      ErrorMessage = "Search failed to run to completion";
    }, cancellationToken, TaskContinuationOptions.NotOnRanToCompletion, TaskScheduler.FromCurrentSynchronizationContext());
  }
  catch (AggregateException aggEx)
  {
    ErrorMessage = "Search failed to run correctly";
    AsyncState = AsyncType.Error;
    ApplicationHelper.DoEvents();
  }
}

哇。这里有很多内容,看起来确实很吓人,不是吗?首先需要注意的是,搜索结果是分页的,所以这个方法只会检索显示一页所需数量的项目,而实际搜索的繁重工作是通过 REST 服务完成的。到目前为止,我们对这个概念已经很熟悉了,所以我们可以将其从复杂性中排除。接下来要考虑的是,搜索是通过 Task Parallel Library (TPL) 异步执行的。要了解 TPL 的优秀系列文章,我强烈建议阅读 Sacha 的系列文章,从 这篇文章 开始。

那么,为什么我们要停在这个方法呢,如果一切都这么简单?嗯,我想讨论一个 Sacha 在这里放进来的一个很酷的小技巧。我们不希望搜索无限期地运行,等待服务器超时,我们也不想在用户界面中引入阻塞,所以 Sacha 实现了一个非常巧妙的、可取消的任务,它不会阻塞 UI。此方法中影响它的代码是这样的:

Task<bool> cancellationDelayTask = TaskHelper.CreateDelayTask(searchTimeOutMilliSeconds);
    cancellationDelayTask.ContinueWith(dt =>
    {
      cancellationTokenSource.Cancel();
    }, TaskContinuationOptions.OnlyOnRanToCompletion);

后面的代码简洁而优雅:

public static Task<bool> CreateDelayTask(int milliSecondsTimeout)
{
  TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
  new Timer(self => 
  {
    ((IDisposable)self).Dispose();
    tcs.TrySetResult(true);
  }).Change(milliSecondsTimeout, -1);
  return tcs.Task;
}

无论如何,一旦你完成了搜索,你显然想要显示你的结果,并让用户选择和显示单个代码片段。嗯,UI 使用数据网格和简单的绑定来显示数据,但你还记得我之前提到的有一个小麻烦吗?我之前说过文本元素在存储时必须解码,因为它们使用了 HTML 编码。有两种方法可以实现这一点。第一种方法是遍历所有返回的数据,在显示之前解码文本;这可以做到,但不高效。第二种方法,而且有趣的是,我选择的方法是使用一个转换器在运行时为我们解码。这是一段简单的代码,但我希望它能说明 XAML 应用程序有时只需要简单的解决方案来解决看似复杂的问题。

public class DecodeConverter : IValueConverter
{
  public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
  {
    if (value == null) return string.Empty;

    return WebUtility.HtmlDecode(value.ToString());
  }

  public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
  {
    throw new NotImplementedException();
  }
}

插入代码片段是一个非常简单的操作(或者说是简单的操作),所以我们现在来看看。文档服务提供了以下方法来实际将文本插入到编辑器中:

public void InsertText(string textToInsert)
{
  if (provider == null || provider.ActiveDocument == null) return;

  if (provider.UndoContext.IsOpen)
    provider.UndoContext.Close();

  provider.UndoContext.Open(Messages.UndoContext);

  try
  {
    TextSelection sel = (TextSelection)provider.ActiveDocument.Selection;
    EditPoint startPoint = sel.TopPoint.CreateEditPoint();
    EditPoint endPoint = sel.BottomPoint.CreateEditPoint();

    if (sel.Text.Length == 0)
    {
      endPoint.Insert(textToInsert);
    }
    else
    {
      endPoint.ReplaceText(startPoint, textToInsert, (int)EnvDTE.vsEPReplaceTextOptions.vsEPReplaceTextAutoformat);
    }

    Autoformat(startPoint, endPoint);
  }
  finally
  {
    provider.UndoContext.Close();
  }
}

此方法首先确保我们在编辑器中有打开的文档可以插入,然后它会关闭任何打开的撤销上下文,然后打开一个特定于 CodeStash 的新上下文(不用担心 UndoContext 是什么,我们稍后会讲到)。接下来,我们创建两个 EditPoint 元素。它们代表代码编辑器中当前编辑器的位置。然后我们确定是否有任何文本被选中;如果有,我们就知道需要用代码片段替换它,否则我们就插入代码片段。一旦代码片段被插入,文档会自动格式化,以便代码片段整洁,最后关闭撤销上下文。

那么,撤销上下文是什么呢?嗯,它代表了一个可以被 Visual Studio 撤销或重做的原子性操作。这允许我们将许多内部操作组合成一个 Visual Studio 可以撤销或重做的单个项,就像这样:

The UndoContext in action, displaying CodeStash context information at the top of the undo stack. 

撤销上下文在行动。

所以,就这样,从服务中检索到的代码片段被添加到 Visual Studio 中——所有这些都在一个方便的“可撤销”操作中管理。正如我之前所说,我没有介绍代码片段查看器;它基于 Daniel Grunwald 出色的 AvalonEdit 控件,你需要阅读与该控件相关的文章才能了解它是如何工作的。

Pete,我有一个问题。为什么插件要分成两个项目?

我很高兴你问这个问题。当我们查看代码时,我们会看到我们可以轻松地将所有代码移到一个项目中,而且接口的工作方式似乎很奇怪。原因是我在开发早期做出了一个决定,即为未来的功能奠定基础。虽然这个版本的 CodeStash 已经完成了代码,但距离完成还很远——事实上,我认为它永远不会完成。Sacha 和我一直设想第一个版本是为了评估人们对 CodeStash 的反应,看看它是否能引起人们的兴趣。然而,开发并没有就此停止。我们希望人们能反馈他们希望看到的功能,并且我们有一些很棒的计划来以一些非常酷和令人兴奋的方式增强 CodeStash。如果我们实现了我们想要的所有功能,这些文章将会在一年后交付,所以我们宁愿先发布一个具备所有基本功能,并提供支持新功能的底层结构的第一版。

所以,回答这个问题,插件被分成两个项目是为了提供我们下一版本 CodeStash 所需的基础,例如客户端缓存组、代码片段自动完成和其他很棒的功能。

我们现在到底学到了什么?

嗯,希望你读完这篇文章后,觉得 CodeStash 是一个很酷的扩展,是你希望每天使用的扩展。你还了解到 Pete O'Hanlon 在方便的时候会随意处理英语规则。你应该对如何向 Visual Studio 包添加命令,以及如何将同一命令关联到多个窗口有了了解。你知道如何利用 Visual Studio 服务,以及如何在不导入服务合同的情况下连接到外部 REST 服务。最后,你还知道了一种将配置文件与 DLL 关联的快捷方式。

值得思考的一点。本文花了很长时间介绍 Visual Studio 内部的命令表是如何工作的,因为它的文档非常零散,并且在某些方面有所欠缺,我想借此机会记录下我的学习过程,这样你就不用经历我所经历的痛苦了。我希望你能觉得有所收获。

请下载代码并阅读。让我们知道你在未来版本的 CodeStash 中希望看到哪些功能。它只有通过反馈才能成长。

© . All rights reserved.