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

SppMk - Visual Studio 6.0 的单元测试和 GNU make 支持插件

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2002 年 6 月 4 日

10分钟阅读

viewsIcon

160882

downloadIcon

1092

所述的 DevStudio 插件提供了两个有趣的 IDE 集成功能:在 VC WorkspaceView 窗口中添加一个新选项卡,以及在 IDE 中运行任意进程并将输出发送到 VC Output 窗口的“Build”选项卡。

引言

为 Visual Studio 6.0 开发功能齐全的插件从来都不是一件容易的事。提供给插件开发者的自动化接口相当糟糕,几乎总是需要利用特殊的技巧来实现所需的功能。幸运的是,关于这个主题有一些信息来源,例如 Nick Hodapp 的优秀文章 Undocumented Visual C++。事实上,我的插件利用了其中提到的一些想法。

插件描述

SppMk 插件将一个基于 make 的构建/单元测试系统与 VC IDE 集成。构建系统基于 GNU make 工具。使用 make,项目构建方式如下:一个名为 Makefile 的特殊文件用于配置编译器和链接器等不同工具,并描述源文件-目标依赖关系,然后调用 make 可执行文件即可完成生成输出二进制文件的所有工作。单元测试的运行方式类似 - 通过调用具有特定参数的 make 来完成。在内部,构建系统使用 CppUnit 测试框架来执行测试。

因此,通过构建系统构建/单元测试项目仅仅是调用项目文件夹中的正确参数的 make 的问题。

SppMk 具有标准的工具栏按钮,用于启动当前项目的 make。也可以从 WorkspaceView 窗口的单独选项卡中的上下文菜单发出 make 命令,如下图所示。

make 的输出始终发送到 VC Output 窗口的“Build”选项卡。顺便说一句,如果以适当的方式(即 file(line):text)记录单元测试失败,这也使得查找它们变得容易。

插件实现

SppMk 可能过于特殊,无法不加修改地立即使用。至少,它需要正确配置的 make,以及一些其他 Unix 工具存在于目标机器上。尽管如此,我认为 SppMk 的一些功能可能对插件开发者感兴趣。我将很快详细描述这些功能。

在 VC IDE 中运行进程

将构建系统与 Visual Studio IDE 集成的第一件事是能够运行一个具有适当 make 命令行参数的进程,并将其输出发送到 VC 的“Build”选项卡。我还希望能够中断正在运行的进程。这正是 VC 在构建项目时所做的。因此,在尝试了不同的进程运行 VC 命令(例如,构建本身或工具菜单中的项目)一段时间后,我意识到实现此功能的最简单方法是挂钩 VC 调用的 CreateProcess 并替换我自己的命令行。

挂钩由 kernel32.dll 导出的 CreateProcess(准确地说,是 CreateProcessA)是通过修补导入模块的导入表来完成的。我为此使用了 John Robbins 编写的 HookImportedFunctionsByName 例程。我从他的 BugslayerUtil 库中提取了必要的代码,您可以在 此处找到它。通过在调试器下运行 VC 并设置 CreateProcess 地址的断点,可以轻松找到导入模块。找到的模块是著名的旧 DevShl.dll。到那时,我就可以调用我自己的函数而不是“真正的” CreateProcess API 了。

我必须处理的第二个问题是,当 SppMk 的某个工具栏命令被激活时,让 VC 调用 CreateProcess。插件可以通过调用 IApplication::ExecuteCommand() 方法来发出 IDE 命令,例如 "BuildToggleBuild""BuildRebuildAll" 等。在可用的命令中,"BuildRebuildAll" 是唯一保证始终调用 CreateProcess 的命令,而 "BuildToggleBuild" 如果当前项目是最新的,可能什么也不做。所以,"BuildRebuildAll" 似乎是唯一的选择。但是,此命令会尝试删除当前项目的所有输出文件,这对于这种情况绝对是不可接受的。因此,我也必须挂钩 DeleteFile,这是 DevBld.pkg 导入的。实际上,我也尝试过通过挂钩 GetFileAttributesEx API 来欺骗 "BuildToggleBuild",为源文件提供更新的日期,但这没有帮助。

现在来看源代码。ProcessRunner 类(可在 ProcessRunner.hProcessRunner.cpp 文件中找到)提供了所需的功能。它的方法是从插件接口实现(Commands.hCommands.cpp)内部调用的。下面所示的 ProcessRunner::Run 会启动 VC 构建。

void ProcessRunner::Run(const SetupParams &Params, IApplication *pApp)
{
    LOG(Logger::cDebug, "ProcessRunner::Run(%p){", pApp);
    
    TEST_BOOL(!GetRunning());
    m_pOrigCreateProcess = NULL;
    m_pOrigDeleteFile = NULL;
    m_bFirst = true;
    Hook(cCreateProcessA, true); 
    Hook(cDeleteFileA, true);
    m_SetupParams = Params;
    TEST_HR(pApp->ExecuteCommand(L"BuildRebuildAll"));

    LOG(Logger::cDebug, "ProcessRunner::Run()}");
}

它从插件的一个命令处理程序中调用,后者负责创建正确的命令行。

STDMETHODIMP CCommands::SppMkMakeMethod()
{
    MK_TRY;
    RunMake(SupportedCommands::cMake);
    MK_MSGRETURN;
}

//...

void CCommands::RunMake(SupportedCommands::CmdId cmd)
{
    LOG(Logger::cDebug, "CCommands::RunMake(%d){", cmd);
    do
    {
        if(ProcessRunner::GetInstance().GetRunning())
        {
            LOG(Logger::cDebug, "CCommands::RunMake(): already running");
            break;
        }
        bool bMakeable = MkUtils(m_spApplication).IsProjectMakeable(
                        DSUtils(m_spApplication).GetActiveProjectName());
        if(!bMakeable)
            MK_THROW(SppMkError::eFileNotFound, 
            "CCommands::CheckCanBuild(): cannot start make: "
            "check if project Makefile exists");

        ProcessRunner::SetupParams Params(
            MkUtils(m_spApplication).CreateMakeCommand(cmd), false);
        ProcessRunner::GetInstance().Run(Params, m_spApplication);
    }while(false);
    LOG(Logger::cDebug, "CCommands::RunMake()}");
}

被挂钩的 CreateProcess 版本只是调用原始版本,替换新的命令行。它还会检查这是否是 VC 第一次调用 CreateProcess,因为某些构建配置可能会多次调用它。

BOOL WINAPI ProcessRunner::CreateProcess(
    LPCTSTR lpApplicationName,                 // name of executable module
    LPTSTR lpCommandLine,                      // command line string
    LPSECURITY_ATTRIBUTES lpProcessAttributes, // SD
    LPSECURITY_ATTRIBUTES lpThreadAttributes,  // SD
    BOOL bInheritHandles,                      // handle inheritance option
    DWORD dwCreationFlags,                     // creation flags
    LPVOID lpEnvironment,                      // new environment block
    LPCTSTR lpCurrentDirectory,                // current directory name
    LPSTARTUPINFO lpStartupInfo,               // startup information
    LPPROCESS_INFORMATION lpProcessInformation // process information
)
{
    LOG(Logger::cDebug, 
        "ProcessRunner::CreateProcess(%s, %s, %p, %p, %d, %d, %p, %s, %p, %p){",
        lpApplicationName, lpCommandLine, lpProcessAttributes, lpThreadAttributes,
        bInheritHandles, dwCreationFlags, lpEnvironment, lpCurrentDirectory, 
        lpStartupInfo, lpProcessInformation);
    MK_TRY;    
    
    GetInstance().Hook(cDeleteFileA, false);
    long lEvent = 0;
    char *p = strstr(lpCommandLine, "-e ");
    if(NULL != p)
    {
        p += 3;
        char *q = strstr(p, " ");
        if(NULL != q)
        {
            char buf[100]; 
            memset(buf, 0, sizeof buf);
            memcpy(buf, p, q-p); 
            lEvent = atoi(buf);
        }
    }
    
    _bstr_t sNewCommadLine = GetInstance().CreateCommandLine(lEvent);
    
    BOOL bRet = GetInstance().m_pOrigCreateProcess(lpApplicationName,
        static_cast<LPTSTR>(sNewCommadLine),
        lpProcessAttributes,
        lpThreadAttributes,
        bInheritHandles,
        dwCreationFlags,
        lpEnvironment,
        lpCurrentDirectory,
        lpStartupInfo,
        lpProcessInformation);
    GetInstance().m_bFirst = false;
    MK_CATCH;
    ::SetLastError(MK_VAR.GetErrorCode());
    return FAILED(MK_VAR.GetErrorCode()) ? FALSE : TRUE;
}

VC 用于构建项目的所有命令行都调用相同的二进制文件,即 vcspawn.exe。将事件标识符传递给它,以便 IDE 能够在用户请求时停止构建过程。ProcessRunner::CreateProcess 函数开头的代码会尝试提取 -e 参数的值,该参数存储了上述事件标识符。

最后一步是取消挂钩 CreateProcess,因为我们不想干扰它的所有调用。显而易见的地方是在处理 "BuildFinish" 事件的处理器中。

HRESULT CCommands::BuildFinish(long nNumErrors, long nNumWarnings)
{
    MK_TRY;
    LOG(Logger::cDebug, "CCommands::BuildFinish(%d, %d){", nNumErrors, 
        nNumWarnings);
    ProcessRunner::GetInstance().TearDown();
    ProcessRunner::GetInstance().SetRunning(false);
    LOG(Logger::cDebug, "CCommands::BuildFinish()}");
    MK_RETURN;
}

//...

void ProcessRunner::TearDown()
{
    LOG(Logger::cDebug, "ProcessRunner::TearDown(){");
    if(m_SetupParams.bDeleteAfterRun)
    {
        LOG(Logger::cDebug, "ProcessRunner::TearDown(): deleting file '%S'",
            static_cast<WCHAR*>(m_SetupParams.sCommandLine));
        ::DeleteFileW(m_SetupParams.sCommandLine);
    }
    m_SetupParams = SetupParams();
    SetRunning(false);
    Hook(cCreateProcessA, false);
    LOG(Logger::cDebug, "ProcessRunner::TearDown()}");
}

ProcessRunner::Hook 例程本身相当简单,因为它完全依赖于 John Robbins 的代码。唯一剩下的工作是指定适当的导入模块。

向 VC WorkspaceView 窗口添加选项卡

能够为当前项目运行 make 已经取得了很大的进展,但还需要更方便、更实用的东西。Visual Studio 提供了一种方法,可以从 WorkspaceView 窗口的文件选项卡中的上下文菜单中构建/清理项目。我的第一个想法是尝试将我自己的命令添加到该上下文菜单中。不幸的是,我没有成功。此外,我还计划为显示项目列表提供略有不同的功能,例如隐藏工作区中的所有单元测试。所有这些都导致了添加我自己的选项卡到 VC WorkspaceView 窗口的想法。

我立即放弃了逆向工程 VC 代码以确定实现新选项卡的“正确”方法的想法。问题是,WorkspaceView 不是标准的 Windows 选项卡控件,而是完全不同的东西。因此,我决定采用一种简单的“强力”方法:在 VC WorkspaceView 窗口的上方创建一个标准的选项卡控件。当然,这种方法不能保证完整的 UI 兼容性,因为标准选项卡的显示和行为在很多方面都不同。但是,这种方法奏效了(您很快就会看到),如果您需要更接近的匹配,您可以随时使用所有者绘制选项卡控件。

您可能已经猜到,实现同样基于挂钩,但是这次是窗口过程挂钩(或窗口子类化)。要考虑的窗口当然是 WorkspaceView 窗口。我开发了一个 WsViewHook 类(WsViewHook.hWsViewHook.cpp),它封装了定位窗口和处理其某些消息的细节。该类继承自 Paul DiLascia 编写的 SubclassWnd 类(您可以在 此处找到它)。

我需要获取 WorkspaceView 窗口的窗口句柄才能挂钩它。通过枚举主 VC 窗口的子窗口来定位 WorkspaceView 窗口。枚举是在插件的 OnConnection 方法中执行的。

HRESULT CCommands::OnConnection(IApplication* pApp, VARIANT_BOOL bFirstTime, 
                                long dwAddInID, VARIANT_BOOL* bOnConnection)
{
    
    //...
    
    if(cfg.GetInstallTab())
    {
        LOG(Logger::cDebug, 
            "CCommands::OnConnection(): installing WsView hook");
        static WsViewHook hook(m_spApplication);
        ::EnumChildWindows(AfxGetApp()->m_pMainWnd->m_hWnd,
            WsViewHook::FindWorkspaceProc, 
            reinterpret_cast<LPARAM>(&hook));
    }
    
    //...
}

//...

BOOL CALLBACK WsViewHook::FindWorkspaceProc(HWND hwnd, LPARAM lParam)
{
    WsViewHook* pThis = reinterpret_cast<WsViewHook*>(lParam);
    CWnd* pWnd = CWnd::FromHandle(hwnd);
    if (NULL != pWnd && 
       "CWorkspaceView" == CString(pWnd->GetRuntimeClass()->m_lpszClassName))
    {
        pThis->HookWindow(pWnd);
        LOG(Logger::cInfo, 
            "WsViewHook::FindWorkspaceProc(): hooked WorkspaceView, hwnd = 0x%x", 
             hwnd);
    }
    return TRUE;
}

WorkspaceView 窗口的生命周期与 VC 本身一样长,并且在工作区关闭时不会被销毁。因此,WorkspaceView 只能挂钩一次,并在销毁时取消挂钩。OnConnection 是执行此操作的便捷位置。挂钩窗口的另一个好地方可能是 InitPackage 方法,因为它比 OnConnection 更早被调用。但这要求插件也成为一个 Visual Studio 包,即导出 InitPackageExitPackage 方法,并放置在正确的位置。

下面的代码显示了 WsViewHook 类的“窗口过程”。

LRESULT WsViewHook::WindowProc(UINT msg, WPARAM wp, LPARAM lp)
{
    LRESULT ret = 0;
    MK_TRY;
    ret = CSubclassWnd::WindowProc(msg, wp, lp);
    
    switch(msg)
    {
    case WM_SIZE:
        m_Tab.PostMessage(WsTabRepl::WM_ADJUSTSIZE);
        break;
    
    case WM_PARENTNOTIFY:
        {
            WORD wEvent = LOWORD(wp);
            switch(wEvent)
            {
                case WM_CREATE:
                    if(NULL == m_Tab.m_hWnd)
                    {
                        LOG(Logger::cDebug, "WsViewHook::WindowProc(): creating WsTabRepl");
                        m_Tab.Create(m_pWndHooked);
                    }
                    LOG(Logger::cDebug, "WsViewHook::WindowProc(): registering 0x%x", lp);
                    m_Tab.PostMessage(WsTabRepl::WM_REGISTERCHILD, lp);
                    break;

                case WM_DESTROY:
                    LOG(Logger::cDebug, "WsViewHook::WindowProc(): deregistering 0x%x", lp);
                    m_Tab.SendMessage(WsTabRepl::WM_DEREGISTERCHILD, lp);
                    break;
            }
            break;
        }
 
    case WM_DESTROY:
         m_Tab.DestroyWindow();
         m_spApp = NULL;
         break;
    
    default:
        break;
    }
    MK_CATCH;
    return ret;
}

对于 WorkspaceView 窗口,有 3 个重要的消息需要处理,即 WM_SIZEWM_PARENTNOTIFYWM_DESTROY

WM_PARENTNOTIFY 有助于确定 VC 何时创建和销毁 WorkspaceView 的子窗口,例如 "FileView""ClassView"m_Tab(类型为 WsTabRepl)是替换的选项卡控件窗口。它在第一个 WM_PARENTNOTIFY/WM_CREATE 消息时创建,并在 WorkspaceView 销毁时销毁。所有创建/销毁的子窗口都会被注册到 WsTabRepl 实例,后者会重建其选项卡以匹配 WorkspaceView 窗口中显示的那些。

WM_REGISTERCHILDWM_DEREGISTERCHILD 的消息处理程序只是调用 RebuildTabs 方法,该方法负责同步选项卡。

void WsTabRepl::RebuildTabs()
{
    LOG(Logger::cDebug, "WsTabRepl::RebuildTabs(){");
    
    DeleteAllItems();
    const CPtrList *pList = GetInternalTabList();

    TCITEM item;
    memset(&item, 0, sizeof(item));
    item.mask = TCIF_TEXT | TCIF_IMAGE;
    int nItem = 0;
    bool bSeenFileView = false;
    for(POSITION pos = pList->GetHeadPosition(); NULL != pos; ++nItem)
    {
        CWnd *pTabWnd = reinterpret_cast<CWnd*>(pList->GetNext(pos));
        TEST_BOOL(NULL != pTabWnd);

        CString strTitle;
        pTabWnd->GetWindowText(strTitle);
        LOG(Logger::cInfo, "WsTabRepl::RebuildTabs(): adding %s",
            static_cast<LPCTSTR>(strTitle));

        item.pszText
             = const_cast<LPTSTR>(static_cast<LPCTSTR>(strTitle));
        item.iImage = ImageFromId(strTitle);
        InsertItem(nItem, &item);
        if(GetInternalCurSel() == nItem)
            SetCurSel(nItem);
        if("FileView" == strTitle)
            bSeenFileView = true;
    }
    if(bSeenFileView && DSUtils(m_pApp).IsNormalDsw())
    {
        if(NULL == m_MkView.m_hWnd)
        {
            LOG(Logger::cInfo, "WsTabRepl::RebuildTabs(): creating MkView");
            m_MkView.Create(GetParent());
            m_MkView.SetWindowText("MkView");
        }
        LOG(Logger::cInfo, "WsTabRepl::RebuildTabs(): adding MkView as tab %d",
            nItem);
        item.pszText = "MkView";
        item.iImage  = ImageFromId("MkView");
        InsertItem(nItem, &item);
    }
    else if(!bSeenFileView && NULL != m_MkView.m_hWnd)
    {
        LOG(Logger::cInfo, "WsTabRepl::RebuildTabs(): destroying MkView");
        m_MkView.DestroyWindow();
    }
    PostMessage(WM_ADJUSTSIZE);
    LOG(Logger::cDebug, "WsTabRepl::RebuildTabs()}");    
}

我的“MkView”选项卡在“FileView”选项卡注册后创建。然后将其作为最后一个选项卡添加到选项卡控件中。IsNormalDsw() 例程检查当前工作区是否为“正常”工作区,即不是为调试进程创建的。IsNormalDsw() 仅检查工作区文件扩展名是否为 "dsw"

为了能够同步我的选项卡和 VC 的选项卡,我必须知道 VC 选项卡的列表和当前活动的 VC 选项卡。在调试器下花费一些时间后,我确定了 WorkspaceView 类中负责此功能的两个成员。它们是(推测)偏移量为 0xA4int,它存储当前选项卡索引(-1 表示无),以及偏移量为 0x88CPtrList,其中包含指向选项卡窗口的指针。以下 2 个方法从 WorkspaceView 实例中提取此数据。

int WsTabRepl::GetInternalCurSel()
{
    LOG(Logger::cDebug, "WsTabRepl::GetInternalCurSel(){");
    CWnd *pWsView = GetParent();
    int nIndex = *reinterpret_cast(
                  reinterpret_cast(pWsView)+0xA4);
    LOG(Logger::cDebug, "WsTabRepl::GetInternalCurSel()}, nIndex = %d", nIndex);
    return nIndex;
}

const CPtrList* WsTabRepl::GetInternalTabList()
{
    LOG(Logger::cDebug, "WsTabRepl::GetInternalTabList(){");
    CWnd *pWsView = GetParent();
    CPtrList *pList = reinterpret_cast<CPtrList*>(
                      reinterpret_cast(pWsView)+0x88);
    TEST_BOOL(NULL != pList);
    LOG(Logger::cDebug, "WsTabRepl::GetInternalTabList()}, pList = %p", pList);
    return pList;
}

切换选项卡

您可能已经注意到,上面描述的 2 个方法仅提供对 VC 内部数据的只读访问,因此它们不允许修改当前选项卡索引。这是故意的,以保持 WorkspaceView 实例的完整性。

我的第一个选项卡切换方法是直接修改当前索引,手动显示相应的子窗口并隐藏不活动的窗口。但是,仅仅更改索引是不够的,因为当 VC 本身切换选项卡时,更多的数据也会发生变化。仅更改索引会导致与标准行为的一些偏差,例如,Ctrl-PgUp/Ctrl-PgDown 快捷键并不总是按正确的顺序切换选项卡。

所以我决定让 VC 来切换选项卡,通过调用 SendInput API 来模拟必要的键盘事件(Ctrl-PgUpCtrl-PgDown)。以下代码执行切换。

void WsTabRepl::SwitchInternalTab(int to)
{
    LOG(Logger::cDebug, "WsTabRepl::SwitchInternalTab(%d){", to);

    int from = GetInternalCurSel();
    if(-1 != from && to != from)
    {
        bool bLeftToRight = from < to;

        INPUT input[4];    
        memset(input, 0, sizeof(input));

        input[0].type = input[1].type = input[2].type = 
            input[3].type = INPUT_KEYBOARD;
        input[0].ki.wVk  = input[2].ki.wVk = VK_CONTROL;
        input[1].ki.wVk  = input[3].ki.wVk = static_cast<WORD>(
                            bLeftToRight ? VK_NEXT : VK_PRIOR);

        input[2].ki.dwFlags = input[3].ki.dwFlags = KEYEVENTF_KEYUP;
        input[0].ki.time = input[1].ki.time = 
            input[2].ki.time = input[3].ki.time = GetTickCount();

        LOG(Logger::cInfo, "WsTabRepl::SwitchInternalTab(): "
                           "switching from %d to %d", from , to);
        for(int i = 0; i < abs(from - to); ++i)
        {
            GetParent()->SetFocus();
            SendInput(4, input, sizeof(INPUT));
        }
    }
    LOG(Logger::cDebug, "WsTabRepl::SwitchInternalTab()}");
}

void WsTabRepl::SwitchTab(int from, int to)
{
    LOG(Logger::cDebug, "WsTabRepl::SwitchTab(%d, %d){", from, to);
    CString sFrom = GetTabId(from);
    CString sTo   = GetTabId(to);
    if("MkView" != sTo)
    {
        HideChild(&m_MkView);
        SwitchInternalTab(to);
    }
    else
    {
        ResizeChild(&m_MkView, GetInternalCurTabWnd());
    }
    if("MkView" == sFrom || "MkView" == sTo)
        DisplayChild(GetCurTabWnd());
    LOG(Logger::cDebug, "WsTabRepl::SwitchTab()}");
}

//...

void WsTabRepl::OnSelchange(NMHDR* pNMHDR, LRESULT* pResult) 
{
    LOG(Logger::cDebug, "WsTabRepl::OnSelchange(%p, %p){", pNMHDR, pResult);
    SwitchTab(m_nPrevTab, GetCurSel());
    *pResult = 0;
    LOG(Logger::cDebug, "WsTabRepl::OnSelchange()}");
}

我选择以选项卡的窗口标题作为唯一的选项卡标识符。

窗口大小和位置

WsTabRepl 窗口的大小和位置始终与 WorkspaceView 窗口相同。WM_SIZE 消息的处理程序负责这一点。至于子窗口,我决定确定它们大小的最佳方法是让 VC 来完成这项工作。由于 VC 认为其中一个窗口始终是“活动的”,VC 会将其大小/位置设置为正确的值。所以我跟踪 VC“活动”选项卡的 id,并在需要时使用它的尺寸。

跟踪内部选项卡索引更改

当前选项卡可以通过除使用 WsTabRepl 选项卡控件之外的其他方式更改。为了跟踪这些更改,会使用一个计时器,该计时器会定期检查内部选项卡更改并根据需要切换 WsTabRepl 选项卡。

结论

为了进一步提高 SppMk 与 Visual Studio IDE 的集成水平,还有很多工作要做。仅举几例,工具栏按钮在构建过程中应禁用,选项卡的显示和行为与 VC 原生选项卡大相径庭,通过 "BuildRebuildAll" 命令运行进程的方式并不完美,因为它会在“Build”选项卡中打印误导性消息(例如 "Deleting intermediate files and output files for project Project")。它还会导致 VC 认为当前项目未构建,如果启动的进程返回错误,这通常发生在某些单元测试失败时。

尽管如此,这两种技术,即能够从 VC IDE 中运行进程和向 WorkspaceView 窗口添加选项卡,使得为基于控制台的构建/单元测试系统提供了非常方便的用户界面。

© . All rights reserved.