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

ChartPoints

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.67/5 (2投票s)

2017年12月17日

CPOL

14分钟阅读

viewsIcon

7690

MSVS2015 图表视图跟踪扩展

背景 (动机部分)

这是ChartPoints MSVC 2015 扩展的技术描述。

所有用户信息可以在 此处 找到。

源代码: https://github.com/alekseymt74/ChartPoints

公理 #1:“跟踪是有帮助的”。

我们都会不时地使用它。我也一样。

为了实现这一目标,通常会使用预定义的 IDE 宏/类、第三方库、自己的技术。主要问题之一是我们需要在代码中手动进行。

这导致了公理 #2:“所有可以自动化的都必须自动化。”

公理 #3: “有时从远处快速一瞥有助于比深入细节更容易地找到问题区域”。

结论

  1. 让用户能够从 IDE 添加跟踪点 (受公理 #2 启发)
  2. 提供具有跟踪数据的交互式图表 (受公理 #3 启发)
  3. 最小化开销

重要说明 (论证部分 #1)

说明 #1

这只是第一个实验阶段(周期),有很多限制。有些部分需要重构/更改,例如,COM EXE 服务器作为传输的选择,但我没有放弃使用它,以便更清楚地理解它。J

说明 #2

我对 C# 的了解远非专业水平。使用它有两个原因:

  • 使用 C# 开发 MSVS 扩展比使用原生 C++ 开发更容易。
  • 这对我很有趣。

因此结论:请宽容。

说明 #3

该项目包含许多独立的部分,本文档不会完全涵盖。我将提供一个通用的架构概述,然后专注于最重要方面。如果这不够,请在评论中提及。如果有兴趣,我会收集所有问题并撰写“第 2 部分”。

说明 #4

我不会描述如何创建工具窗口、上下文菜单处理程序等。这些在许多示例中都有详细描述。首先,在 VSSDK-Extensibility-Samples 中,我将专注于最困难( IMHO)的部分。

说明 #5

我的英语不如我希望的那么好。抱歉。

限制 (论证部分 #2)

  • 支持的语言:仅原生 C++
  • 支持的 IDE:MSVC 2015
  • 跟踪变量:仅 C++ 基本类型的类成员 + 一些 typedefs,如 std::uint32_t (因为它们很有用)

使用的技术/语言

  • C++ (注入代码, COM 进程外服务器)
  • C# (MSVS 2015 扩展, MSBuild 任务)

使用的架构模式

该项目使用了两种主要的通用架构模式:SOA (面向服务的架构)EDA (事件驱动架构)。这两种模式都广为人知且描述充分,因此我将只关注当前实现的细节。

SOA (面向服务的架构)

声明 (ICPServiceProvider.cs)
  // base interface for all ChartPoints services (maybe extended in the future)

  public interface ICPService
  {
  }

  //public delegate void OnCPServiceCreated<T>(T args);
  // singleton service provider
  public abstract partial class ICPServiceProvider
  {
    public abstract bool RegisterService<T>(T obj) where T : ICPService;
    public abstract bool GetService<T>(out T obj) where T : class;
    //public abstract bool GetService<T>(out T obj, OnCPServiceCreated<T> cb) 
    //                                   where T : class;
    public static ICPServiceProvider GetProvider()
    {
      return impl.ICPServiceProvider.GetProviderImpl();
    }
}
实现 (CPServiceProvider.cs)

服务提供程序存储已注册的服务,以便在调用 RegisterService 后通过 GetService 返回。第二个(注释掉的)GetService 版本允许提供一个回调,如果服务尚未注册,则使用该回调。现在没有理由实现它,所以我将其注释掉了。

这种方法允许管理服务的创建顺序,并从任何地方查询服务,而无需担心它们的构造。

EDA (事件驱动架构)

声明 (ICPEventService.cs)
  public delegate void OnCPEvent<T>( T args );
  
  public abstract class ICPEvent<T>
  {
    protected abstract ICPEvent<T> Add(OnCPEvent<T> cb);
    public static ICPEvent<T> operator +(ICPEvent<T> me, OnCPEvent<T> cb)
    {
      return me.Add(cb);
    }

    protected abstract ICPEvent<T> Sub(OnCPEvent<T> cb);

    public static ICPEvent<T> operator -(ICPEvent<T> me, OnCPEvent<T> cb)
    {
      return me.Sub(cb);
    }

    public abstract void Fire(T args);
  }
实现 (CPEventService.cs)

ICPEvent 提供常规的 +/- 运算符。在当前实现中,所有事件都存储起来(事件数量不多,所以我们不用担心)。当新客户端订阅时,它会收到所有先前生成的事件。如果需要,将添加一个没有历史记录的简单特化。当前实现提供了所有必需的功能。

这样做有两个原因:

  1. 某些对象并非按预定义顺序初始化(例如,从 MSVS 的事件处理程序),这保证了所有事件都会传递给接收者。
    示例:当 Tagger(负责在代码编辑器中渲染字形的对象)创建并订阅 ChartPoints 事件时,它会成功接收所有先前触发的事件,并拥有渲染字形的所有实际信息。
  2. 它提供了添加时间标记的可能性,使它们成为日志记录系统的一部分(目前未实现,但可以轻松添加)。

类工厂

当前类工厂实现的基本目标

  1. 允许基于现有类工厂实现类工厂(部分扩展),并提供 moqs 和 stubs 用于测试。
  2. 通过 DI 实现的策略模式。允许实现同一接口的不同策略。由于所有对象都通过工厂方法查询实例,因此只需更改相应的类工厂方法即可实现新的对象。
  3. 隐藏通过类工厂显式创建对象的可能性(即使在同一程序集中)。这很难实现。在 C++ 中很容易做到,但在 C# 中,需要添加冗余实体来模拟 friend 关键字。也许可以更容易地做到。
类工厂声明 (IClassFactory.cs)
  // class factory interface
  public abstract partial class IClassFactory
  {
    // set custom class factory instance for DI purposes
    public static void SetInstance(IClassFactory inst)
    {
      ClassFactory.SetInstanceImpl(inst);
    }

    // returns singleton instance
    public static IClassFactory GetInstance()
    {
      return ClassFactory.GetInstanceImpl();
    }

    // factory methods
    public abstract IChartPointsProcessor CreateCPProc();

    <..>

  }

旨在通过类工厂声明构造的类

接口声明
  public interface IChartPointsProcessor
  {
    <..>
  }
实现

重要:标记为抽象以隐藏显式构造它的可能性。只有派生类才能这样做。

  public abstract class ChartPointsProcessor : IChartPointsProcessor
  {
    <..>
}
类工厂实现 (ClassFactory.cs)
  public abstract partial class IClassFactory
  {
    // implementation of ordinal class factory
    // is accessible only by IClassFactory and class factory implementations 
    // (for DI purposes)
    private partial class ClassFactory : IClassFactory
    {
      public ClassFactory()
      {
        <..>
      }

      private static IClassFactory Instance;

      public static void SetInstanceImpl(IClassFactory inst)
      {
        Instance = inst;
      }

      public static IClassFactory GetInstanceImpl()
      {
        if (Instance == null)
          Instance = new ClassFactory();
        return Instance;
      }

      // IChartPointsProcessor factory
      // Opens the back-door to construct ChartPointsProcessor object
      // To  access non-default constructors 
      // appropriate delegating ones need to be added
      private class ChartPointsProcImpl : ChartPointsProcessor { }

      // IChartPointsProcessor factory method implementation
      public override IChartPointsProcessor CreateCPProc()
      {
        return new ChartPointsProcImpl();
      }

      <..>
  }
扩展的类工厂(示例)
  // IChartPointsProcessor implementation for dependency injection
  namespace impl
  {
    namespace DI
    {
      // Can be fully implemented from IChartPointsProcessor 
      // or extend any existing implementation
      public class DIChartPointsProcessor : ChartPointsProcessor
      {
        // methods to override
      }
    } // namespace DI
  } // namespace impl

  // dependency injection class factory implementation
  public abstract partial class IClassFactory
  {
    // Can be fully implemented from IClassFactory or extend existing one
    // In this case ordinal class factory used 
    // to override IChartPointsProcessor factory method only
    class DIClassFactory_01 : ClassFactory
    {
      private class ChartPointsProcImpl : DIChartPointsProcessor { }
   
      // IChartPointsProcessor factory method implementation
      public override IChartPointsProcessor CreateCPProc()
      {
        return new ChartPointsProcImpl();
      }
  }

  // instantiate di class factory. Needed because 
  // IClassFactory.DIClassFactory_01 declaration is inaccessible explicitly
  public static IClassFactory GetInstanceDI_01()
  {
    return new DIClassFactory_01();
  }
}

在某个地方(在开始构造其他类工厂对象之前),调用

  Utils.IClassFactory diCF = Utils.IClassFactory.GetInstanceDI_01();

  // after calling this all objects will be constructed via this class factory
  Utils.IClassFactory.SetInstance(diCF);

工作原理

快速视图

ChartPoints
  • 用于添加 ChartPoints(又名断点)的用户友好界面
  • 代码编辑器中的 Taggers 指示它们的位置
  • 特殊工具窗口中的 ChartPoints 列表
  • 简单的代码文本更改监听器(又名断点)
  • 保存/加载定义的 ChartPoints
  • ChartPoints 模式与常规构建分开
  • 在构建前、保存/加载前后验证 ChartPoints
  • 用户交互式图表视图
  • 基于生成时间的 ChartPoints 值表格视图
代码生成
跟踪库 (发布方 – 被跟踪程序)

最小化被跟踪代码和未被跟踪代码执行之间的开销。这非常重要,因为如果它们差异很大,将导致行为不同,跟踪将变得无意义。

跟踪库 (使用者端 - 主机)

此端的要求不像发布方那样严格。此项目的主要目标是执行后续分析。因此,允许在运行时出现一些延迟。

跟踪传输

如前所述,我决定使用 COM EXE 服务器作为被跟踪程序和主机之间的传输层。在我看来,这不是一个好主意,将来需要改变。我计划以后更改传输层。因此,我将不详细描述它。

描述

第 1 步 (选择 ChartPoints)

Visual Studio 包含一组用于操作语言代码模型的接口。

正如我在限制部分中指出的,只能跟踪 C++ 基本类型的类变量。因此,唯一可以进行的地方是类方法定义。

在代码编辑器中显示上下文菜单之前,会执行检查是否可以添加 ChartPoint 的操作。这是在 CP.Code.Model.CheckCursorPos() 方法中完成的。从 EnvDTE.ActiveDocument 获取当前光标位置 (EnvDTE.ActiveDocument.Selection.ActivePoint) 和 FileCodeModel (EnvDTE.ActiveDocument.ProjectItem.FileCodeModel)。使用 FileCodelModel.CodeElementFromPoint 方法,执行光标位置检查:位于方法体内部。如果是,则返回的 CodeElementParent 属性指向 VCCodeClass 对象,该对象用于获取所有类变量。未来的注入点将位于行首,或者如果光标位于包含它的行上,则紧跟在方法大括号之后。

一个 ChartPoint 可以包含多个可跟踪变量。

所有设置好的 ChartPoints 都添加到“ChartPoints:design”工具窗口。

简要的 ChartPoints 类架构

所有 ChartPoints 数据都存储在树形结构中。这些对象提供事件,用于订阅它们的 Add/Move/Remove/Status 更改。这种对象组合使得可以轻松地从前向后(从根到叶)和从后向前(基于事件通知)操作它们。这两种方法都将被积极使用。

第 2 步 (Taggers)

VSSDK-Extensibility-Samples 包含展示 Taggers 基本用法的示例。MSDN 也有几篇描述它的文章。起点在这里:Inside the Editor

但我想要更多

  1. 强制 Taggers 外观/更改
  2. 优化性能 (排除冗余更新)
Taggers 简要概述

每次打开/更改新文档时,MSVS 都会调用自定义的 IViewTaggerProvider 实现(如果有)的 CreateTagger 方法来创建 ITagger 对象。之后,MSVS 环境会调用其 GetTags 方法来确定是否存在(以及在哪里存在)标签。

问题

同一个文档会多次调用 IViewTaggerProvider.CreateTagger。看起来它会为每个可以包含标签的窗口调用一次:代码编辑器、查找结果(???)。据我所知,最后一个是为代码编辑器窗口调用的。是的,它有效,但我不完全理解。因此,这需要更清楚地研究。

自定义 Taggers 实现

所有创建的 tagger 都存储在关联数组中,以文件名作为键。ChartPoints tagger provider 订阅 IFileChartPointsAdd/Remove 事件,然后将 IFileChartPoints 对象提供给存储的 taggers。这使它们能够订阅 ILineChartPoints 事件通知。

当首次打开或更改文档时,会调用 ChartPoints tagger 的 GetTags 方法。在此方法中,计算 SnapshotSpan 中的行与存储的包含 ChartPoints 的行号之间的交集。

如果需要从外部手动更新 tag,则会触发 IChartPointsTagger.RaiseTagChangedEvent,其参数包含行号。ITagger<ChartPointTag>.TagsChanged 事件会触发,其中 SnapshotSpan 只包含 ChartPoint 所在的 1 行。这有助于排除冗余的 tag 创建检查,并提供了强制(重新)绘制 tag 的可能性。

重要:这里使用的所有索引(行/字符编号)都是从 0 开始的。EnvDTE 索引(用于计算 ChartPoints 位置)从 1 开始。这就是导致持续头痛的原因。

第 3 步 (保存/加载 ChartPoints)

有关包含的 ChartPoints 的所有信息都根据解决方案的基础保存在 *.suo (Solution User Options) 文件中。

为了做到这一点,我使用了实现 IVsPersistSolutionOpts 接口,该接口提供了重载的方法和一个指向 Microsoft.VisualStudio.OLE.Interop.IStream 对象的引用。加载时,该对象会被克隆并存储起来,以便在解决方案加载后使用。

第 4 步 (文本更改跟踪器)

目前,代码更改仅通过简单的文本更改来跟踪。这可能已经足够了。我稍微尝试了 VCCodeModel,但认为它过于复杂和昂贵。

跟踪系统分为两部分:UI (MSVS 端) 和 Model (ChartPoints)。这样做是因为我当时认为我将同时使用文本更改监听器和代码模型更改。也许有一天,我会回到这一点。

UI

用于监听文本更改的 MSVS 服务是:IWpfTextViewCreationListenerIWpfTextView。第一个的实现提供了 TextViewCreated(IWpfTextView) 事件的句柄。第二个提供了订阅 ITextBuffer.Changed 事件的可能性。

模型

ICPTrackService 跟踪 ChartPoints Add/Remove 事件,并提供小的包装器对象,隐藏了 ChartPoints 对象引用。该服务和一些事件绑定了 UI 和 Model。

ChartPoints 跟踪服务序列图

每次打开文档时都会调用 IWpfTextViewCreationListener.TextViewCreated(IWpfTextView)。如果在 ICPTrackerService 中没有为该文件注册 FileTracker 对象,TextChangeListener 将在文件名中存储 IWpfTextView 对象。之后,如果收到 Model.FileTracker create 事件,将创建带有 IWpfTextView FileTracker 引用的 FileChangeTracker 对象。它将订阅 buffer 更改事件,并从 FileTracker 查询验证。

第 5 步 (代码插桩)

代码插桩通过位于 CPInstrBuildTask.dllMSBuild 任务 执行。

MSVS 主机

启动构建时(Globals.dte.Events.BuildEvents.OnBuildProjConfigBegin 事件处理程序),将执行以下操作:

  1. 检查当前项目中是否存在 ChartPoints
  2. 禁用调试信息生成(不需要,因为插桩后,执行的代码将与原始代码不同)。

这是执行此操作的代码。

EnvDTE.Project proj = ..
<...>
VCProject vcProj = (VCProject)proj.Object;
VCConfiguration vcConfig = vcProj.Configurations.Item(projConfig);
IVCCollection tools = vcConfig.Tools as IVCCollection;
VCLinkerTool tool = tools.Item("VCLinkerTool") as VCLinkerTool;
tool.GenerateDebugInformation = false;
  1. 验证 ChartPoints
  2. MSVS 主机和 MSBuild 任务 之间的通信传输已打开。使用 ServiceHostNetNamedPipeBinding。原因?这是我开始深入研究 C# 功能时看到的第一种方法,:).作为地址项目文件,使用了完整名称。这看起来很丑,但提供了唯一的地址。也许有一天有人会决定从多个 MSVS 实例同步构建同一个项目,问题将是明确的。但我相信理性的力量。

MSVS 主机提供 IPCChartPoint 接口(以及同一文件中的其他几个接口),其中包含用于计算 ChartPoints 注入点布局的方法。

  1. 跟踪变量初始化
  2. 跟踪点
  3. 附加包含文件注入
  4. ...等等
MSBuild 任务
  1. 使用相同的地址打开 ServiceHost
  2. 获取 IPCChartPoint 对象。
  3. 计算注入点布局 IPCChartPoint.GetInjectionData(<project name>)。
  4. 将所需源文件复制到 %TEMP% 目录。
  5. 对它们进行插桩(详见下一节第 6 步)。
  6. 将插桩后的文件传递给 MSBuild(将其添加到构建中,并从构建中移除原始文件)。

第 6 步 (C++ 跟踪库)

为了组织正确的变量跟踪,需要以下数据:

  1. 被跟踪变量的标识符。
    变量地址强制转换为无符号 64 位值,用于此目的 (*)
  2. 变量名
  3. 类型 ID
    供以后使用。目前未使用。
  4. 变量值
  5. 时间戳
    它在跟踪时获取,以提供可靠的信息。

(*) 保证在当前时刻,地址值是唯一的。但关键短语是“在当前时刻”。相同的地址可以被使用多次。这取决于变量的生命周期。此问题的解决方法将在后面的“部分唯一标识符”部分讨论。

代码插桩使用的预定义实体

  1. cpti(64).dll 库包含跟踪逻辑。由注入的代码显式加载。
  2. __cp__.tracer.h
    声明了类型 ID 包装类 type_id 和类 tracer,其方法由插桩代码使用。它实现在 cpti(64).dll 库中。
  3. __cp__.tracer.cpp
    type_id 类的模板特化,适用于跟踪中使用的所有支持的类型。
    实现 tracer_ptr tracer::instance() 方法,该方法根据插桩模块使用的平台(x86/64)加载 cpti(64).dll
插桩详情

在每个插桩文件的开头添加了 #include "__cp__.tracer.h"。由于插桩文件是原始文件的副本,因此它们的所有 include 出现都已更改为使用新的。

__cp__.tracer.cpp 添加到项目中。

注册
tracer::pub_reg_elem("test_01::d_01",d_01);

类型和变量标识符是即时生成的。第一种是通过 type_id 类的特化版本。第二种是通过获取并强制转换变量地址。

跟踪
tracer::pub_trace(d_01);
幕后花絮 (tracer_impl.cpp)

如前所述,主要要求之一是最小化开销。

为此目的使用了以下类:

  template<typename TData>
  class data_queue
  {
  public:
    typedef std::queue< TData > data_cont;
  private:
    data_cont data_1;
    data_cont data_2;
  public:
    data_cont *in_ptr;
    data_cont *out_ptr;

    data_queue()
    {
      in_ptr = &data_1;
      out_ptr = &data_2;
    }

    void swap()
    {
      std::swap( in_ptr, out_ptr );
    }
  };

当调用 tracer::pub_trace 时,访问 data_queue 对象会被锁定,并将带有 idtimestamp 的新值添加到 queue::in_ptr 队列中。

将存储的值传递给传输层是在一个单独的线程中执行的,该线程仅锁定对 data_queue 对象的访问以进行 data_queue::swap(),该操作会交换 queue::in_ptrqueue::out_ptr 指针。然后,该线程从 queue::out_ptr 指向的队列中选择所有数据,并将其传递给传输层。

这使得调用(跟踪源)线程阻塞的时间最小化。

部分唯一标识符

正如我所说,“相同的地址可以被使用多次”,这使得它作为标识符的使用复杂化。但是“在当前时刻”的陈述很有帮助。

这是通过以下方式解决的:

创建另一个线程(tracer_impl::reg_proc)和 data_queue。它与数据跟踪的工作方式相同,只有一个重要区别:在执行任何注册之前,所有累积的数据都必须发送

为此,使用了实用类 notifier。它的工作方式类似于 boost::barrier/semaphore(都基于等待期望的计数器值)。每次调用 tracer_impl::reg_elem 时,都会存储注册信息(包含注册时间戳的 sic),增加 notifier 对象的计数器,并通知等待的注册线程。注册线程会发布当前注册实体时间戳之前的所有累积跟踪数据。完成此操作后,它会减少 notifier 对象的计数器。在这段时间里,数据发送线程会休眠,等待 notifier 计数器变为零。这提供了 reg/trace 消息传递的当前顺序。
注意:主机(MSVS ChartPoints 扩展)知道这一点,这有助于它正确处理接收到的消息。

第 7 步 (COM EXE 服务器, CPTracer.exe)

选择 COM 进程外服务器作为传输层是暂时的实验,以评估其功能。但众所周知,“没有什么比暂时的更持久”©。因此,它陪伴了我开发第一个版本的所有时间,:)。我会抓住机会放弃使用它。这就是为什么我只说几句关于它的用途。

用于将数据发送给客户的 data_queue 类实例相同。这样做是为了减少 COM 事件传递调用。发送线程在每次迭代中休眠 500ms,然后将所有数据组合成数组,一次性发送给客户。

未来计划 (不分先后顺序)

  • 放弃 COM 服务器的使用。迁移到某个网络协议。
  • 将第 6 步(C++ 跟踪库)中描述的逻辑移至客户端。
  • 添加本地变量的跟踪功能。
  • 删除“<..> [ChartPoints]”配置。手动使用 MSBuild,而不更改原始的 *.sln*.vcxproj 文件。
  • ChartPoints 存储迁移到单独的配置文件。
  • 放松……这似乎是优先事项列表中的第一项。

历史

  • 2017年12月17日:初始版本
© . All rights reserved.