(翻译)LearnVSXNow! #10 创建我们第一个工具集-重用代码

  • 2019 年 10 月 5 日
  • 筆記

我们在第6和第7篇创建的Calculate小工具窗还有很多可以改进的地方,所以在这篇文章里,我们不会开发新的功能,而是重构我们的代码,封装出可以重用的类和方法。

VSX背后的对象模型是非常丰富的:有几百个类和几千个方法。但我们在开发VS add-in和package的时候,光记住类和方法的名字是不够的,我们还需要知道相应的GUID以及其他相关的常数。

我觉得在VSX的开发中最难的是开发者必须要把.NET和COM混着用。如果VSX的编程模型(对象模型)更简洁一点话,对开发人员是非常好的事情。

微软在interop程序集之上,开发了一些用于托管代码的层(其中一个叫做MPF,全称是Managed Package Framework)。我认为MPF里提供的类和方法是非常棒的,但它们只会涉及到VSX的某些方面,还不够。

所以在这篇文章里,我会告诉你如何把常用的功能封装出来,供我们以后开发VSX时使用。我希望你也能够在开发过程中,逐步创建你自己需要的工具集。

从这篇文章开始,我会创建一个叫做VsxTools的类库。这一次我仅仅出于演示目的来使用这个类库,但是既然我们是一起学习VSX的,所以我打算把这个类库弄成一个真正可用的工具。在这篇文章里我会做如下的重构:

  1. 改进活动日志的调用
  2. 简化output window的调用

CodePlex上的源码

当你在看这篇文章的时候,我已经把所有的示例代码和文章放到了CodePlex上了(http://www.codeplex.com/LearnVSXNow)。如果下载了最新的源码,你会看到在PackageStartupSamples目录下有一个PackageStartupSamples.sln文件。它包含了这系列文章里的所有的例子。我会随着VS 2008 SDK版本的更新来相应的更新这些例子(当然如果发现了bug的话,我也会更新它们)。

创建VsxTools类库

我们最好把可重用的代码放到一个单独的类库里。所以,让我们创建一个名为VsxTools的C# class library项目,并把它添加到StartupToolsetRefactored项目所在的解决方案中。由于我们需要向这个VsxTools中添加VSX代码,所以我们要向这个项目中添加VS SDK interop和MPF程序集引用:

Microsoft.VisualStudio.OLE.InteropMicrosoft.VisualStudio.Shell.9.0Microsoft.VisualStudio.InteropMicrosoft.VisualStudio.Interop.8.0Microsoft.VisualStudio.Interop.9.0

接下来,我们可以向这个类库里添加功能了。

改进活动日志的调用

如果想往活动日志里写日志的话,我们需要写差不多半打行数的代码,例如:

private void LogCalculation(string firstArg, string secondArg, string operation,  string result){  string message = String.Format("Calculation executed: {0} {1} {2} = {3}",    firstArg, operation, secondArg, result);  IVsActivityLog log =    Package.GetGlobalService(typeof(SVsActivityLog)) as IVsActivityLog;  if (log == null) return;    log.LogEntry(    (result == "#Error")      ?(UInt32) __ACTIVITYLOG_ENTRYTYPE.ALE_ERROR      : (UInt32)__ACTIVITYLOG_ENTRYTYPE.ALE_INFORMATION,    "Calculation", message);}

但是这个方法有很多“噪音”:

  1. 为了使用这个服务对象,我必须记住IVsActivityLogSVsActivityLog这两个名字。
  2. 我必须在使用它之前判断它是不是null。
  3. 我必须知道__ACTIVITYLOG_ENTRYTYPE.ALE_ERROR__ACTIVITYLOG_ENTRYTYPE.ALE_INFORMATION这两个“魔术”常数。虽然从它们的名字上可以猜出它们代表的意思,但是很不直观,而且很难记住。我甚至还得把它们转换成System.UInt32类型。
  4. 在上面这个例子里,由于我们只需要记录日志类型、日志源和日志消息,所以我们调用了LogEntry方法。但是如果我们想记录其它信息,我们还得找另外一个方法才行。

所以必须得想办法去掉这些“噪音”。如果能用下面这段代码岂不是很好?

string message = String.Format("Calculation executed: {0} {1} {2} = {3}", firstArg, operation, secondArg, result);ActivityLog.Write(result == "#Error" ? ActivityLogType.Error : ActivityLogType.Information,  "Calculation", message);

在这段代码里,我们减少了如下“噪音”:

  1. 要想使用活动日志这个service的话,我们只需要记住一个直观的名字ActivityLog就行了。
  2. 用一个很直观的枚举来代表日志类型。
  3. 写日志的时候,只需要一个Write方法就够了。当然这个方法有很多个重载版本,可以覆盖所有的参数组合。
  4. 不需要转换类型,不需要空引用检查。

另外,这种非常简单的、带智能感知的方式可以提高我们敲代码的速度。

定义ActivityLog

改进的ActivityLog模式基于3个类型:

// --- Represents entry types instead of __ACTIVITYLOG_ENTRYTYPE constantspublic enum ActivityLogType{  Information,  Warning,  Error}  // --- Represents an entity holding all log entry propertiespublic sealed class ActivityLogEntry{  ...  public ActivityLogType Type { get; set; }  public string Source { get; set; }  public string Message { get; set; }  public Guid? Guid { get; set; }  public int? Hr { get; set; }  public string Path { get; set; }  ...}  // --- Provides log services through static Write methodspublic static class ActivityLog{  public static void Write(ActivityLogEntry entry);  public static void Write(string source, string message);  ...  public static void Write(string source, string message, Guid guid, int hr);  ...  public static void Write(ActivityLogType type, string source, string message);  ...}

ActivityLogType枚举的功能是显而易见的,所以就不说它了。静态类ActivityLog通过Write方法供外面调用,这个方法有很多重载版本,可以适应不同的参数组合。如果我们在编程的时候不能确定要记录日志的哪些属性,可以调用接收ActivityLogEntry类型的Write方法的重载版本。在这个方法内部判断应该调用IVsActivityLog的哪个方法,例如,如果只用到了Hr和Path属性,我们可以调用LogEntryHrPath方法。

ActivityLog的内部实现

VsxTools项目里添加一个ActivityLog.cs文件,并在里面添加上面的三个类型。在ActivityLogEntry类里,我弄了几个构造函数,每一个负责设置不同的属性。最主要的“逻辑”是写在ActivityLog静态类里的,在这个类里,我添加了一些私有属性和私有方法:

public static class ActivityLog{  ...  private static UInt32 MapLogTypeToAle(ActivityLogType logType)  {    switch (logType)    {      case ActivityLogType.Information:        return (UInt32)__ACTIVITYLOG_ENTRYTYPE.ALE_INFORMATION;      case ActivityLogType.Warning:        return (UInt32)__ACTIVITYLOG_ENTRYTYPE.ALE_WARNING;      default:        return (UInt32)__ACTIVITYLOG_ENTRYTYPE.ALE_ERROR;    }  }    private static IVsActivityLog Log  {    get     {       return Package.GetGlobalService(typeof (SVsActivityLog)) as IVsActivityLog;     }  }    private static void LogEntry(ActivityLogType type, string source,     string message)  {    IVsActivityLog log = Log;    if (log  != null)    {      log.LogEntry(MapLogTypeToAle(type), source, message);    }  }  ...}

这些方法都很简单,就不解释它们了。和LogEntry方法一样,我还添加了IVsActivityLog服务中其他的方法,例如LogEntryGuid。在Write方法里,可以调用这些私有方法:

public static void Write(string source, string message){  Write(ActivityLogType.Information, source, message);}  public static void Write(ActivityLogType type, string source, string message){  LogEntry(type, source, message);}

就这些就行了。通过实现这个东西,我们就拥有了一个非常简单并且容易记住的活动日志的模型。

在旧代码中使用新的ActivityLog模型

现在可以修改CalculationControl.cs文件中的LogCalculation方法了:

private void LogCalculation(string firstArg, string secondArg, string operation,  string result){  string message = String.Format("Calculation executed: {0} {1} {2} = {3}", firstArg, operation, secondArg, result);  ActivityLog.Write(result == "#Error" ? ActivityLogType.Error : ActivityLogType.Information, "Calculation", message); }

现在,你可以编译并运行一下StartupToolsetRefactored例子了。别忘了我们曾在第7章讲过怎样查看活动日志,还有,别忘了在项目属性的Debug页签里加上/log开关,这样它才能记录活动日志。

瞧一瞧output window的后台结构

在这篇文章开始的时候,我说过我要简化一下output window的使用,所以让我们开始吧。在第7篇文章中,我们已经用IVsOutputWindowIVsOutputWindowPane接口向VS的output window写了日志了:

private void LogCalculationToOutput(string firstArg, string secondArg,   string operation, string result){  string message = String.Format("Calculation executed: {0} {1} {2} = {3} ", firstArg, operation, secondArg, result);    IVsOutputWindow outWindow =    Package.GetGlobalService(typeof(SVsOutputWindow)) as IVsOutputWindow;  Guid generalWindowGuid = VSConstants.GUID_OutWindowGeneralPane;  IVsOutputWindowPane windowPane;  outWindow.GetPane(ref generalWindowGuid, out windowPane);  windowPane.OutputString(message);}

就像活动日志的调用方式那样,上面红色的代码也有着类似的“噪音”。但在我们去掉这些噪音之前,让我们先来瞧一瞧VS output window的结构和与它相关的服务。

Visual Studio只有一个output window,但是它却可以包含多个pane来隔离多种output。Visual Studio它自己定义了一些output window pane,VSPackage也可以定义他们自己的pane。一个package可以向任何已有的pane中(包括VS IDE定义的和第三方package定义的)输出消息。下图展示了VS IDE定义的“常规”pane和一个自定义的“My Debug”pane:

要使用output window,用到两个简单的服务:

  1. SVsOutputWindow服务用来管理(获取、创建和删除)output window pane,但它不能用来输出消息。不过它可以获取IVsOutputWindowPaneIVsOutputWindowPane2(第二个是第一个的扩展)的实例,这些接口用来把output信息输出到相应的window pane中。
  2. SVsGeneralOutputWindowPane服务用于取得General output window pane对应的IVsOutputWindowPane实例。

这些接口的功能如下:

服务接口

功能

IVsOutputWindow

这个接口只有3个方法,用来管理output window pane的实例,分别是: CreatePane, DeletePane, GetPane

IVsOutputWindow2

扩展IVsOutputWindow接口,添加了一个新的方法,用于获取当前在用的pane的ID:GetActivePaneGUID

IVSOutputWindowPane

这个接口用于管理对应的pane的内容和可见性。 可以调用Activate 方法显示一个pane,调用Hide方法来隐藏一个pane。每个pane都有一个名字,可以通过GetName和SetName 方法来获取名字或设置名字。pane里面的内容可以通过Clear、OutputString和OutputStringThreadSafe方法来管理。 发送到window pane里的信息也可以通过调用OutputTaskItemString、 OutputTaskItemStringEx和FlushToTaskList方法来放到任务列表中。

IVsOutputWindowPane2

扩展IVsOutputWindowPane接口,添加了OutputTaskItemStringEx2方法,可以把output信息和错误列表中的消息关联起来。

总结一下上述表格:用IVsOutputWindow来管理pane,用IVsOutputWindowPane来管理每个pane中的output信息。

window pane由GUID来标识。在Microsoft.VisualStudio.VSConstants类里,定义了3个VS IDE中常用的pane的GUID:、

Window Pane

GUID

General

GUID_OutWindowGeneralPane

Build

GUID_BuildOutputWindowPane

Debug

GUID_OutWindowDebugPane

如果一个package创建了一个window pane,必须有它自己的GUID。我们可以用这个GUID来获取这个pane的引用,就像其他VS IDE内置的pane一样。但如果这个package没有公开出这个GUID的话,我们也可以用IVSOutputWindow2GetActivePaneGUID来得到这个GUID。

负责管理pane的方法

通过SVsOutputWindow得到的IVsOutputWindow接口实例有3个用于管理pane的方法:

public interface IVsOutputWindow{  int GetPane(ref Guid rguidPane, out IVsOutputWindowPane ppPane);  int CreatePane(ref Guid rguidPane, string pszPaneName, int fInitVisible, int fClearWithSolution);  int DeletePane(ref Guid rguidPane);}

每一个方法的第一个参数都是pane的GUID。这3个方法的名字已经很清楚的告诉我们它们是干嘛的了。调用CreatePane方法的时候,你需要传递3个额外的参数:

  1. pszPaneName表示pane的初始名字(可以在创建后改变这个名字)
  2. fInitVisible用于设置pane的初始可见性。如果设成了true(即非0值),这个pane在创建后会立刻显示。当然,这里是说这个pane会显示在output window里,但output window是可以隐藏的。不过你可以通过视图|输出(View|Output)菜单来显示output window。
  3. fClearWithSolution参数如果设成true的话,pane里面的内容就会随着解决方案的关闭而自动清空。

你也许认为,如果我们对VS内置的output pane调用CreatePaneDeletePane的话,VS会报错。但是不是这样的,这两个方法也可以删除和重新创建原本已经内置的pane。所以在用的时候你必须意识到这一点。

最常用的方法是GetPane,它可以获取一个IVsOutputWindowPane的实例,从而向相应的pane中写消息。

把消息发送到pane中

IVsOutputWindowPane接口提供了往pane中写消息的功能。你可以把文本消息输出到pane中,也可以输出到任务列表中,但是在这篇文章中,我仅仅把消息直接输出到pane中(处理任务列表是以后的文章的主题)。通过调用OutputString或OutputStringThreadSafe这两个方法,你可以用线程安全或线程不安全的形式把消息输出到pane中。什么时候需要用线程安全的方法,什么时候不需要用,这个要搞清楚。如果你搞不清楚的话,那就用OutputStringThreadSafe吧。

简化output window的调用

正如你看到的那样,为了管理output pane并往里面写消息,我们需要写好几行有噪音的代码。现在让我告诉你一个去掉这些噪音的解决方案。我并不认为这是最好的方案,但这肯定是一个解决方案。如果你有更好的主意,请告诉我。

是什么方案

由于你们是开发人员,所以没有什么比直接看代码能够说的更清楚了。我的解决方案可以通过CalculationControl.cs文件里的这几行代码来描述清楚:

public partial class CalculationControl : UserControl{  ...  private void LogCalculationToOutput(string firstArg, string secondArg,     string operation, string result)  {    string message = String.Format("Calculation executed: {0} {1} {2} = {3}",      firstArg, operation, secondArg, result);      OutputWindowPane pane = OutputWindow.GetPane(typeof(MyDebugPane));    pane.WriteLine(message);  }  ...  [PaneName("My Debug")]  [InitiallyVisible(true)]  [ThreadSafe(true)]  private sealed class MyDebugPane: OutputPaneDefinition  {}  ...}

红色的代码创建了一个叫做“My Debug”的output window pane ,并且用线程安全的形式把消息输出进去。OutputWindow类的GetPane方法会在需要的时候创建pane。pane以一个简单的类的形式定义,并标记上一些属性。所有使轮子转起来的工作被放到了后台,调用这不用关心。

如果你想往“General”这个pane中写消息的话,上面的代码还可以更短:

OutputWindow.General.WriteLine(message);
这个方案的基础结构

这个解决方案的基础是3个类,如下:

类型

功能

OutputPaneDefinition

可以用这个类来继承output window pane definition(OWPD)。一个OWPD类型仅仅是一个定义,在它上面可以添加这个pane的特性的attribute。 OutputWindow和OutputWindowPane用这个类的属性去获取这些attribute的值。

OutputWindow

这个静态类负责管理output window pane,就像IVsOutputWindow接口那样。这个类也提供了静态属性,用这些属性可以直接访问到VS内置的pane。同时,这个类提供了一个异常处理机制,可以把消息转发到“Genernal”或“Debug” pane中,甚至转发到一个虚拟的pane中(Silent pane)。

OutputWindowPane

这个类负责把消息输出到它对应的pane中,和IVsOutputWindowPane接口一样(不过它不支持任务列表的处理)。它提供Write和WriteLine方法,类似System.Console类。 你可以把这个类看成IVsOutputWindowPane的包装类(Wrapper class)。

当用OutputPaneDefinition来定义一个pane时,我们可以把这个pane弄成默认线程安全的。这样的话,就会以线程安全的方式当向pane中输出消息。

定义一个pane类

如果我们需要用VS的标准pane,只需要用OutputWindow类中的GeneralDebugBuild静态属性就行了。

不过如果我们创建VSPackage的话,我们也许需要自己的output window pane。在“传统”方式下,我们用一个GUID来代表这个pane,但在我的方案下,我用一个继承自OutputWindowDefinition的类来代表这个pane,这个类上可以添加关于这个pane特性的attribute。在OutputWindowDefinition的默认构造函数里,通过读取这些attribute来设置属性值。 下面是这个类的定义:

public abstract class OutputPaneDefinition{  protected OutputPaneDefinition();  public virtual Guid GUID { get; }  public string Name { get; }  public bool InitiallyVisible { get; }  public bool ClearWithSolution { get; }  public bool ThreadSafe { get; }  public bool IsSilent { get; internal set; }}

我们可以把一个pane定义成安静的,也就说并没有物理上的pane,任何输出到这个pane上的消息都会以安静的模式处理掉。另外,为了定义一个已经存在的pane(例如VS内置的pane或由第三方package定义的pane),我们可以重写Guid属性。

为了演示这些属性的用法,让我们看一下OutputWindow类中的“Debug” pane和Silent pane是怎么定义的:

public static class OutputWindow{  ...  private sealed class DebugPane : OutputPaneDefinition  {    public override Guid GUID    {      get { return VSConstants.GUID_OutWindowDebugPane; }    }  }  ...  private sealed class SilentPane : OutputPaneDefinition  {    public SilentPane()    {      IsSilent = true;    }  }  ...}

OutputWindowDefinition可以识别如下attribute:

public sealed class PaneNameAttribute: StringAttribute {...}public sealed class InitiallyVisibleAttribute: BoolAttribute {...}public sealed class ClearWithSolutionAttribute: BoolAttribute {...}public sealed class ThreadSafeAttribute: BoolAttribute {...}

为了定义一个自己的pane,可以像下面的代码那样创建一个类:

[Guid("6D71C5F7-200C-4322-A264-65C78CF511AA")][PaneName("My Own Pane")][InitiallyVisible(false)][ClearWithSolution(true)][ThreadSafe(true)]private sealed class MyOwnPane: OutputPaneDefinition {}

更详细的代码细节,请参考OutputWindowDefinition.cs文件。

利用OutputWindow管理pane

我参考IVsOutputWindow提供的功能,创建了OutputWindow类,并额外添加了一些小的功能。我声明了一个OutputPaneHandling属性,是枚举类型的,代表当物理上的pane无法取得时,如何处理消息。这个枚举有如下的枚举值:

枚举值

含义

Silent

不产生任何异常,待输出的信息也不发送到任何pane中。

ThrowException

抛出WindowPaneNotFoundException异常。

RedirectToGeneral

输出信息转到General pane中。

RedirectToDebug

输出信息转到Debug pane中。

这个类的结构如下:

public static class OutputWindow{  public static OutputPaneHandling OutputPaneHandling { get; set; }  public static OutputWindowPane General { get; }  public static OutputWindowPane Build { get; }  public static OutputWindowPane Debug { get; }  public static OutputWindowPane Silent { get; }  public static OutputWindowPane CreatePane(Type type);  public static OutputWindowPane GetPane(Type type);  public static bool DeletePane(Type type); }

CreatePaneGetPaneDeletePane方法接受一个Type类型的参数,这个类型必须继承自WindowPaneDefinition类。代表内置的pane的类是OutputWindow类的私有嵌套类,你不能用它们的类型作为参数,所以你也不能创建或者删除它们。CreatePane方法只能够创建原本不存在的pane,如果这个pane已经创建了,就只会返回它的实例。调用GetPane的时候,如果某个pane不存在,GetPane方法会创建它。

具体细节,可以参考OutputWindow.cs文件。

向pane中写消息

我在前面提到过,OutputWindowPane类实际上是IVsOutputWindowPane实例的一个包装。我只不过在设计和实现这个包装类的时候做了一些小改动。

IVsOutputWindowPane用两个单独的方法分别以线程安全和不安全的方式写消息:OutputStringThreadSafeOutputString。我想隐藏这两个方法,这样使用者在用的时候,就不用关心该调用哪一个。在这个类里面,我加了一个布尔属性ThreadSafe,由它来决定该调用哪个方法。你还记得吧,WindowPaneDefinition类识别ThreadSafeAttribute,所以当创建了一个pane的实例之后,OutputWindowPaneThreadSafe属性值会设置成WindowPaneDefinitionThreadSafeAttribute指定的初始值。

另外,IVsOutputWindowPaneGetNameSetName方法被封装成Name属性。

OutputWindowPane类的成员如下:

public sealed class OutputWindowPane{   internal OutputWindowPane(OutputPaneDefinition paneDef, IVsOutputWindowPane pane);  public bool ThreadSafe { get; set; }  public string Name { get; set; }  public bool IsVirtual { get; }  public void Activate();  public void Hide();  public void Clear();  public void Write(string output);  public void Write(string format, params object[] parameters);   public void Write(IFormatProvider provider, string format, params object[] parameters);  public void WriteLine(string output);  public void WriteLine(string format, params object[] parameters);   public void WriteLine(IFormatProvider provider, string format, params object[] parameters) }

构造函数应该被弄成internal的,这样OutputWindow类就是OutputWindowPane的工厂类了,使用者没法自己new一个实例出来。在构造函数中,需要传入OutputPaneDefinition实例,同时也需要传入IVsOutputWindowPane的实例。IsVirtual属性可以用来设置这个pane到底是一个物理上的pane,还是一个虚拟的、安静的pane。

这个类提供了一些WriteWriteLine方法,用来代替原来的OutputStringOutputStringThreadSafe方法,并模仿System.Console中的声明方式。

上面这些方法的实现都很简单,具体你可以参考OutputWindowPane.cs文件。

试用一下这个方案

编译并运行StartupToolsetRefactored项目,并点击Calculate按钮,你会发现消息输出到了一个叫“My Debug”的output pane中。如果你有时间的话,可以试着对代码做些改动(在CalculationControl类的LogCalculationToOutput方法里),并看一下相应的变化:

// --- Original code lines:OutputWindowPane pane = OutputWindow.GetPane(typeof(MyDebugPane));pane.WriteLine(message);  // --- Change 1: Writing to two panesOutputWindowPane pane = OutputWindow.GetPane(typeof(MyDebugPane));pane.WriteLine(message);OutputWindow.General.WriteLine(message);  // --- Change 2: Changing the pane nameOutputWindowPane pane = OutputWindow.GetPane(typeof(MyDebugPane));pane.Name = "My Debug (modified)"; pane.WriteLine(message);  // --- Change 3: Reflecting to an invalid paneOutputWindowPane pane = OutputWindow.GetPane(typeof(int));pane.WriteLine(message);  // --- Change 4: Throwing an exception (VS 2008 will stop!)OutputWindow.OutputPaneHandling = OutputPaneHandling.ThrowException; OutputWindowPane pane = OutputWindow.GetPane(typeof(int));pane.WriteLine(message);  // --- Change 5: Silent exception (No output will be shown)OutputWindow.OutputPaneHandling = OutputPaneHandling.Silent;OutputWindowPane pane = OutputWindow.GetPane(typeof(int));pane.WriteLine(message);  // --- Change 6: Throwing and handling exceptionOutputWindow.OutputPaneHandling = OutputPaneHandling.ThrowException;try{  OutputWindowPane pane = OutputWindow.GetPane(typeof (int));  pane.WriteLine(message);}catch (WindowPaneNotFoundException ex){  OutputWindow.General.WriteLine(ex.Message);}

总结

在这篇文章里,我们修改了StartupToolsetRefactored项目,以VsxTools的形式提供helper类。这些helper类是托管的类型,减少了由VS 2008 SDK的interop类带来的“噪音”。我们为活动日志和output widow pane开发了这种可重用的类。

现在,所有的源代码(包括前几篇文章的例子)和文章可以在CodePlex(http://www.codeplex.com/LearnVSXNow)上找到。

我希望这些helper类能够对你有用。但是,我写这篇文章的本意并不是告诉你怎样去除掉代码中的“噪音”,而是希望告诉你:在VS interop类的基础上创建自己的托管类型是值得的。Microsoft在用MPF来实现这个目的,但依然还有很多地方可以使VSX的开发体验变得更有趣和更愉快!

当开始这个系列的时候,我还没有打算创建自己的VSX工具集,但现在我已经决定利用VSX社区的支持来做这些了…