使用 WCF 装备的注入式组件自动化 Windows 应用程序






4.96/5 (47投票s)
一个集成了 WCF 的 .NET 组件注入到自动化进程中,允许本地和远程客户端控制该进程并接收其异步事件。

- 引言
- 背景
- 注入步骤
- Components
- 客户端通知
- 多线程
- 示例如何工作?
- 运行示例
- 讨论
- 结论
引言
本文介绍了一种将不导出任何程序接口的 Windows 应用程序转换为自动化服务器的方法。许多非常好的出版物已经深入地介绍和讨论了该问题的各种方面。其中大多数工作涉及代码注入到要自动化的进程(目标进程)和 API 挂钩的技术。本文演示了使用远程线程将代码注入到目标进程,并重点介绍了注入代码与外部世界之间的通信。本文介绍了使用 Windows Communication Foundation (WCF) 服务进行此类通信。
背景
实际上,本文是我之前文章《自动化 Windows 应用程序》的续篇,该文章描述了将 COM 对象注入到目标进程。众所周知的记事本文本编辑器被选为自动化示例目标应用程序。提出了以下方法。NotepadPlugin.dll 使用远程线程技术注入到 Notepad.exe 目标进程中。该插件代码通过子类化记事本应用程序的外部(框架)和内部(视图)窗口,并为多个 Windows 消息(例如 WM_CHAR
)安装自定义处理程序。在退出之前,远程线程过程会将用户注册的消息 WM_CREATE_OBJECT
发送到(例如)外部窗口。该消息由子类窗口过程处理。该处理程序创建一个 COM 对象,该对象会通过运行对象表 (ROT) 注册自身,从而有效地将其直接接口暴露给目标进程外部,覆盖整个机器。客户端绑定到该对象,使用其直接接口来操作目标应用程序,并通过连接点机制实现其传出(宿主)接口以订阅目标应用程序触发的事件。
此技术工作良好,但有一个限制:客户端和目标应用程序应运行在同一台机器上,因为 ROT 仅在本地可用。本文提出了一种克服此限制的可能方法。
为此,上述技术进行了一项显著的修改:这次不是将非托管的真正 COM 对象嵌入到目标进程中,而是将一个可以通过 WCF 服务与外部世界通信的托管 .NET 对象嵌入到目标进程中。该托管对象被封装在 COM 可调用包装器 (CCW) 中,下面也简称为 COM 包装器。本文基于代码示例解释了所提出的技术。
注入步骤
与上一篇文章相比,注入本身基本保持不变。为简单起见,我们假设目标应用程序是单线程的,并且具有外部(框架)和内部(视图)窗口。为自动化目标应用程序,执行以下步骤:
- NotepadPlugin.dll 通过 Injector COM 对象使用远程线程技术注入到目标进程中。这是一个工作线程,其函数是 NotepadPlugin.dll 的
DllMain()
。远程线程在DllMain()
返回后结束。Injector COM 对象由作为本地客户端运行的 AutomationClientNET 应用程序实例化。其目的是查找正在运行的目标记事本进程(如果不存在正在运行的记事本,则启动一个新的),并将插件注入到目标进程中。在注入 NotepadPlugin.dll 后,Injector 不再使用,可以释放。Injector 仅由一个本地客户端使用,下面称为客户端注入器。 - NotepadPlugin.dll 分别通过
FrameWndProc()
和ViewWndProc()
窗口过程对目标应用程序的外部和内部窗口进行子类化。这些窗口过程包含具有新功能的函数处理程序,用于目标应用程序。NotepadPlugin.dll 的PluginProc()
函数(由DllMain()
调用)实际上执行了子类化。NotepadPlugin.dll 和 Injector 都使用辅助的 WindowFinder COM 对象来查找记事本窗口。 - 在远程线程中运行的 NotepadPlugin.dll 的
PluginProc()
函数将用户注册的WM_CREATE_OBJECT
Windows 消息发送到记事本框架窗口。FrameWndProc()
窗口过程处理WM_CREATE_OBJECT
消息。该处理程序通过其 COM 包装器创建托管的 NotepadHandlerNET 对象。
Components
嵌入到记事本目标进程和 AutomationClientNET 应用程序中的主要组件如下图所示。

注入后,目标进程中会放置两段“外部”代码,即非托管的 NotepadPlugin.dll 和托管的 NotepadHandlerNET 对象(使用 Mark Russinovich 的著名工具 Process Explorer 可以轻松看到这一点,如下所示)。

托管的 NotepadHandlerNET 对象被封装在 COM 包装器中。该包装器使用 RegAsm.exe 工具准备。在构建 NotepadHandlerNET 后,作为“生成后事件”执行以下命令:
RegAsm NotepadHandlerNET.dll /tlb
此调用生成的文件 NotepadHandlerNET.tlb 用于 NotepadPlugin.dll(文件 NotepadPlugin.cpp)中的 #import 指令。非托管的 NotepadPlugin.dll 通过 Windows 过程 FrameWndProc()
和 ViewWndProc()
对目标应用程序的外部和内部窗口进行子类化。这些过程会拦截和预处理目标应用程序窗口的一些 Windows 消息。这些函数的处理程序通过 IHandler
接口的 CCW 调用托管的 NotepadHandlerNET 对象,该接口由 Handler
类实现。IHandler
接口只有一个方法 Do()
。该方法的第一个参数包含所需的处理类型(例如,“SetText”),第二个参数提供处理数据,并在处理后包含结果数据。此方法为任何调用提供统一的接口。出现新调用时无需重建 COM 包装器。NotepadHandlerNET 对象通过 P/Invoke 技术调用 NotepadPlugin.dll 函数。要调用的函数应该是全局的且已导出(在本例中为函数 ActivateFindDialog()
和 AppendMainMenu()
)。
如上所述,将托管组件嵌入非托管目标应用程序的原因是支持 WCF 服务通信。双工 WCF 通信通过两个单向服务实现。用于构造服务主机和客户端代理的通用类位于 SvcHost.dll 中。NotepadHandlerNET 对象托管 ICommander
ServiceContract(接口)
[ServiceContract]
public interface ICommander
{
[OperationContract]
object Command(string sender, string commandName, object commandData);
}
客户端应用程序 ApplicationClientNET.exe 托管实现 INotification
ServiceContract 的 Notification
服务
[ServiceContract]
public interface INotification
{
[OperationContract]
object Notify(string sender, string eventName, object eventData);
}
服务合同非常相似。两者都只有一个操作合同(方法),用于统一的命令/通知,并带有描述发送者、命令/事件名称以及要传输的适当数据的参数。通常,操作合同可以返回任何对象。这种服务合同非常灵活,无需在每次出现新命令或事件时更改其客户端代理。
在 NotepadHandlerNET 嵌入式对象类中,Commander
实现 ICommander
ServiceContract。每次调用时,其 Command()
方法会使用 Activator.CreateInstance()
静态方法构造一个名称与 commandName 参数(小写)相同的类的实例
// Command method is called by client. It creates actual command
// class by the command name and calls its DoCommand method.
public object Command(string sender, string commandName, object commandData)
{
object ret = null;
try
{
ICommandImpl comImpl =
Activator.CreateInstance(GetCommandTypeByName(commandName))
as ICommandImpl;
if (null != comImpl)
ret = comImpl.DoCommand(sender, commandData);
}
catch (Exception e)
{
ret = e;
}
return ret;
}
这些命令类实现 ICommandImpl
接口,该接口只有一个 DoCommand()
方法。这种设计提供了通用的命令调用机制。在本例中,NotepadHandlerNET 在 Commands.cs 文件中包含命令类。在实际应用程序中,命令可以放置在单独的程序集中,在运行时加载,从而使其真正独立且易于替换。为了提高性能,还可以对命令类实例进行池化。命令类的 DoCommand()
方法实际执行客户端命令在目标应用程序中的操作。这尤其通过 P/Invoke 技术调用非托管 NotepadPlugin.dll 的函数来实现。
客户端通知
DoCommand()
方法的一个重要任务是客户端通知。通知机制设计如下。为了接收 NotepadHandlerNET 的通知,每个客户端都会托管实现 INotification
ServiceContract 的 WCF Notification
服务。基于此服务,专门的 WCF 工具 SvcUtil.exe 会生成(参见 NotificationProxyGenerator.cmd 文件)相应的类,供 NotepadHandlerNET 创建适当的代理。客户端通过 register 命令开始与嵌入式 NotepadHandlerNET 组件的协作。其 DoCommand()
方法会通过 sender 构造函数参数指定的客户端实例化一个 AddNotificationSubscriber
类,并调用 AddNotificationSubscriber.Do()
方法,创建客户端的通知代理(借助 SvcHost.Client.ConnectTo<>()
静态方法)。该代理被放置在 NotificationBase
类中的特殊字典对象中,用于客户端通知。
多线程
通知机制中存在一个线程问题。默认情况下,WCF 服务在单个线程中同步运行。这意味着 NotepadHandlerNET 中的所有命令以及 AutomationClientNET.Notification
类中的所有通知都是顺序执行的。每个命令和通知调用都会阻塞调用线程,直到返回。因此,尝试直接从 DoCommand()
方法通知客户端会导致死锁(尝试从接收通知的 AutomationClientNET.Notification.Notify()
方法直接将命令发送到 NotepadHandlerNET 会产生类似效果)。当然,可以通过适当的服务属性来实现 WCF 服务的多线程行为。但在我们的代码示例中,此问题通过 ThreadPool
类解决。DoCommand()
方法不直接通知客户端。相反,当需要通知时,它会实例化辅助类 NotifyAll
并调用其 NotifyAll.Do()
方法。它为每个通知代理调用 ThreadPool.QueueUserWorkItem()
静态方法,从而从不同的线程发送通知。死锁得以避免。客户端也不会在通知处理程序内部发送命令。
当命令类的 DoCommand()
方法调用记事本窗口函数时,目标进程中可能会出现另一个与线程相关的线程问题。DoCommand()
方法在 Command 服务线程中执行,该线程可能不是创建记事本窗口的记事本主线程。由于大多数窗口函数应该从创建窗口的同一线程调用,因此从不同线程调用可能会导致崩溃。在本代码示例中,函数 ActivateFindDialog()
和 AppendMainMenu()
不区分线程,因此调用它们不需要采取特殊预防措施。但一般情况下,应特别注意从窗口的本地线程调用窗口函数。例如,这可以通过在主线程中创建一个隐藏窗体(在 NotepadHandlerNET.Handler
类的构造函数中)来实现,使用隐藏窗体的 InvokeRequired
属性检查线程,如果线程不是本地线程,则使用适当的委托来 Invoke()
调用窗口函数。
示例如何工作?
在描述完主要组件后,我们可以讨论工作示例。首先,我们需要调整客户端应用程序 AutomationClientNET.exe 的配置文件。这是 Visual Studio 中 AutomationClientNET 项目的 App.config 文件,在构建后转换为 AutomationClientNET.exe.config 文件。该文件包含以下应用程序设置:
<appSettings>
<!-- Replace "localhost" with Notepad Machine IP Address -->
<add key ="EndpointAddressUri" value="https://:29700" />
<add key ="Client-Injector" value="true" />
</appSettings>
如果使用特定的远程客户端,则参数 EndpointAddressUri(代表命令服务(嵌入记事本中)的 IP 地址)应进行更改:需要将“localhost”替换为记事本机器的 IP 地址。参数 Client-Injector 设置为“true”,表示此客户端在本地运行并注入代码到目标记事本进程(客户端注入器)。只有一个本地客户端可以是客户端注入器。所有其他客户端的参数 Client-Injector 应设置为“false”,无论它们是本地的还是远程的。客户端注入器的大按钮显示“AUTOMATE NOTEPAD”标题,而其他客户端的按钮显示“Bind to Notepad”。
当然,绑定只能到已自动化的目标应用程序。非 GAC 注册的 COM 包装的 .NET 组件被非托管调用应用程序调用时,应位于与调用应用程序相同的目录中。在我们的例子中,这意味着 NotepadHandlerNET 程序集(dll)和 Notepad.exe 目标应用程序位于同一个目录中。通常,Notepad.exe 位于 <SystemRoot>\system32 目录中。
由于我无意将 NotepadHandlerNET.dll 放在 system32 目录中,我决定执行相反的操作;在其大按钮被按下后,客户端注入器会将 Notepad.exe 复制到包含 NotepadHandlerNET.dll 的目录并启动它(当然,如果 Notepad.exe 尚未被复制并从那里运行)。因此,客户端注入器 AutomationClientNET.exe、Notepad.exe 和 NotepadHandlerNET.dll 应共享同一目录。
尝试从其他目录启动的 Notepad.exe 进行自动化会导致错误。客户端主窗体的 AutomationClientNET.NotepadClientForm.Inject()
方法执行此记事本复制任务。仅当客户端是客户端注入器时,才会调用该方法。相同的 Inject()
方法会操作 NotepadHandlerNET 配置文件。为了让嵌入的 NotepadHandlerNET 组件在无需额外代码的情况下使用其配置文件,配置文件被重命名为 Notepad.exe.config 以匹配目标应用程序。然后,Inject()
方法实例化 Injector COM 进程内对象,并调用其唯一的 InjHelperClass.Run()
方法,从而有效地将 NotepadPlugin.dll 注入到目标 Notepad.exe 应用程序中。注入已在“注入步骤”段中进行了描述。
AutomationClientNet.exe 客户端应用程序通过 WCF Commander 服务将命令传输到嵌入式 NotepadHandlerNET 组件。其 ServiceContract 已讨论过。客户端应用程序使用 WCF SvcUtil.exe 工具生成的 Commander 服务代理(参见 CommanderProxyGenerator.cmd 文件)来执行命令调用。该服务由重命名为 Notepad.exe.config 的 NotepadHandlerNET 配置文件配置。下面显示了 Notepad.exe.config 的 service XML 标签的内容:
<service behaviorConfiguration="CommanderBehavior"
name="NotepadHandlerNET.Commander">
<endpoint address="Commander" binding="basicHttpBinding"
contract="NotepadHandlerNET.ICommander" />
<endpoint address="Mex" binding="mexHttpBinding"
contract="IMetadataExchange" />
<host>
<baseAddresses>
<!-- Replace "localhost" with Notepad Machine IP Address -->
<add baseAddress="https://:29700" />
</baseAddresses>
</host>
</service>
服务配置为 HTTP 访问并启用元数据交换。要允许远程客户端访问,baseAddress 参数中的“localhost”需要替换为记事本机器的 IP 地址。Commander.Command()
方法会实例化接收到的命令类,并运行新创建的命令类实例的 DoCommand()
方法。根据命令任务,其 DoCommand()
方法可能会或可能不会调用 NotepadPlugin.dll 导出的非托管函数。如果涉及 NotepadPlugin.dll,则在任务完成后,其方法会通过 CCW 调用 Handler 组件 Do()
方法。如果需要客户端通知,则派生自 NotificationBase
类的通知类 AddNotificationSubscriber
和 NotifyAll
会使用如“线程”段所述的基于 ThreadPool
的机制创建并激活通知代理。
AutomationClientNET.exe 客户端应用程序托管由 AutomationClientNET.exe.config 文件配置的 Notification 服务。下面显示了 AutomationClientNET.exe.config 的 service XML 标签的内容:
<service behaviorConfiguration="NotificationBehavior"
name="AutomationClientNET.Notification">
<endpoint address="Commander" binding="basicHttpBinding"
contract="AutomationClientNET.INotification" />
<endpoint address="Mex" binding="mexHttpBinding"
contract="IMetadataExchange" />
<host>
<baseAddresses>
<!-- Replace "localhost" with Client Machine IP Address -->
<add baseAddress="https://:29701" />
</baseAddresses>
</host>
</service>
服务配置为 HTTP 访问并启用元数据交换。要允许远程客户端访问,baseAddress 参数中的“localhost”需要替换为该客户端机器的 IP 地址。当同一台机器上同时运行多个客户端时,每个客户端在此机器上应具有唯一的端口。
运行示例
请确保在您计划运行服务的[_所有_]计算机上安装了 WCF(.NET 3.0)。在您的 ClientInjector 目录中,运行 COMRegistration.cmd 命令文件以注册 WindowFinder.dll 和 Injector.dll COM 进程内服务器,并为托管的 NotepadHandlerNET.dll 准备 CCW。然后启动 AutomationClientNET.exe 客户端注入器应用程序,并按其“AUTOMATE NOTEPAD”(大按钮)。这将导致 Notepad.exe 被复制到 ClientInjector 目录并出现 Notepad.exe.config 文件。Notepad.exe 被启动并被自动化。其标题已相应更改,AutomationClientNET.exe 的大按钮将被禁用,而其他按钮将启用。成功自动化记事本后,您可以按客户端应用程序上的相应按钮来打开其查找对话框、添加自定义菜单项。您可以向记事本输入一些文本,并通过按客户端的“Copy Text”按钮将其复制到所有客户端。要测试异步操作,在记事本中输入一些文本,然后输入一个句子结束符(.、? 或 !)。输入的文本将在客户端应用程序的编辑框中显示。
现在您可以为多个客户端测试此示例。从您的 OrdinaryClient 目录启动第二个 AutomationClientNET.exe 应用程序,并按其“Bind to Notepad”大按钮。成功绑定后,每个客户端都可以独立操作记事本。当记事本中输入的文本以 .、? 或 ! 结尾时,两个客户端都会收到异步事件。
最后,测试远程访问的示例。为此,请将您的 OrdinaryClient 目录复制到另一台机器上。您需要更改所有配置文件:所有 AutomationClientNET.exe.config 和一个 Notepad.exe.config。您需要将“localhost”替换为相应的 IP 地址(请参阅“示例如何工作?”段落和配置文件本身的注释)。然后启动客户端注入器,自动化记事本,在远程机器上运行普通客户端并将其绑定到已自动化的记事本。初次绑定到远程机器可能需要一些时间(可能需要 30 秒左右)。但绑定完成后,通信速度很快。您将获得与本文开头描述的结果类似的结果(图片是使用远程桌面连接访问远程机器制作的)。
讨论
本文仅为 WCF 服务在进程自动化中的应用提供说明。显然,这种方法的主要目标是使用相同的代码支持本地和远程客户端。对于企业分布式应用程序,使用 WCF 服务及其各种传输通道和高度可配置性而不更改代码,看起来非常有前景。所提出的机制适用于不同类型的自动化应用程序和注入方式。例如,托管的浏览器帮助对象 (BHO) 可用于浏览器自动化,并作为 WCF 服务的主机,与浏览器自动化客户端进行通信。
结论
本文介绍了一种带有注入对象的 Windows 应用程序自动化技术。目标进程中的一个集成了 WCF 的 .NET 组件允许本地和远程客户端应用程序与注入对象建立双工通信,从而有效地控制目标进程并获取其异步事件。WCF 服务使得能够为本地和远程客户端使用相同的代码,并提供一系列传输通道(HTTP、TCP、命名管道和 MSMQ)供客户端与嵌入到自动化进程中的组件之间进行通信。所提出的技术在自动化应用程序由运行在本地和远程机器上的多个客户端操作时尤其有用。