当 DCOM 在若干年前登上历史舞台时,用于演示其功能的最常用示例之一就是远程剪贴板管理器。通过使用 DCOM 编程模型,一个组件能够读取和写入存储在另一台计算机上、但连接到同一网络的剪贴板内容。(当然,只有在安全设置允许的情况下才能生效。)
但是,当 DCOM 提供基础结构以构建到系统组件(例如剪贴板)的远程访问时,无论是 Windows 还是 DCOM 都无法提供能够直接对剪贴板进行远程访问的 API。开发人员可以利用的技巧是以本地代理与远程存根之间的交互为基础的。应用程序调入本地代理,进而在网络传输层上序列化该调用,并将其传送到远程主机。然后,该应用程序宿主将剪贴板处理程序组件的一个本地副本实例化,以对 Windows 本地副本的剪贴板进行读取和写入操作。
Microsoft.NET Framework 提供了 Clipboard 类来包装系统剪贴板上的主要操作。该 Clipboard 类作为 Windows 窗体基础结构的一部分,在 System.Windows.Forms 命名空间中进行声明。该类上的方法允许您在单个应用程序的上下文中获得并设置剪贴板的当前内容。
我的一位客户要构建一个不使用剪贴板的 ASP.NET 电子商务站点。然而,该团队中的一名开发人员意识到,负责填写后端表格的人员需要持续不断地在计算机间传送大量数据(大部分为纯文本)。他们找到的最快方法是创建能够跨网络共享的临时文本文件。尽管可以接受这个特定技巧,但整个过程都不太智能。特别是,文本首先会在 Microsoft Word 或 Microsoft Internet Explorer 中突出显示,然后被复制到剪贴板,接着再粘贴到一个新的 Notepad 文档中。最后,该文档被拖放到网络文件夹中。在具备了良好的意志和新的 DCOM 存储器后,有悟性的开发人员会想到,一个定制的远程剪贴板查看器和管理器可以更快、更有效地完成工作。
图 1 剪贴板查看器
剪贴板查看器(请参见图 1)是一个旧的 Windows 附件,它不再出现在开始菜单中,但是依然可以作为 clipbrd.exe 从 System32 文件夹中使用。剪贴板查看器充当所有连接到网络的计算机上的剪贴板的管理器。如果当前的安全设置允许,您就可以连接到 Windows 的一个远程实例,并监视计算机的剪贴板。虽然查看器只是一个查看器(它显示当前内容,并可让您删除内容),但是它不提供向剪贴板中输入新文本的用户界面。此外,剪贴板查看器基于分布式技术(十几年前的旧技术)— 网络动态数据交换(或缩写为 NetDDE)。所以,我的客户决定编写一个自定义版本的剪贴板查看器,以作为实际的分布式应用程序。因为他使用的是 .NET Framework,所以他在计划时就想到了使用 .NET Remoting 来设计实用工具。
安装远程组件
.NET Remoting 是一种机制,能够实现在不同 AppDomains 中运行的组件之间的通讯。所有基于 .NET Framework 的应用程序至少由一个(主)AppDomain 构成,但是更多的 AppDomains 可以通过编程方式创建。AppDomain 代表一个托管子进程,该子进程存在于由操作系统和 CPU 管理的物理进程上下文中。公共语言运行库 (CLR) 可确保不能从一个 AppDomain 访问包含在另一个 AppDomain 中的任何数据。无论两个应用程序域是位于同一个应用程序中、同一计算机的两个截然不同的应用程序中还是运行于物理分隔的计算机上,分离机制都是完全相同的。
从体系结构上讲,.NET Remoting 的角色取决于系统将调用上下文从客户端封送到服务器、然后将结果发送回客户端的能力。远程组件是一个具有公共方法的类,它可以直接或间接地来自 MarshalByRefObject。该类被编译为程序集,它部署在服务器计算机上,并通过宿主应用程序与客户端进行交互。宿主应用程序负责侦听传入调用的特定端口、将它接收到的参数转换为对象的本地调用,并将返回值封送回调用方。图 2 显示了访问网络计算机剪贴板的远程组件的体系结构。
图 2 访问剪贴板的远程组件
客户端(假设运行在 Machine1 上)发出一个对服务器端对象(假设驻留在 Machine2 上)的远程调用。该调用由侦听协议端口的应用程序宿主接收,然后再进行处理。该调用上下文包括要调用的远程方法的名称及其参数。通过图 3 中所示的代码,可以实现封装剪贴板函数的组件。marshal-by-ref 类将公开一个 Copy 方法,以使客户端能够将文本写入远程剪贴板,以及一个 Paste 方法,以将远程剪贴板的内容粘贴到本地上下文中。图 3中的代码和基本模式应该比较易于理解,特别是,如果您阅读了我在 2002 年 10 月刊上发表的 .NET Remoting 介绍文章(请参阅 .NET Remoting: Design and Develop Seamless Distributed Applications for the Common Language Runtime),则更是如此。截至目前,一切都还不错。然而,您可能已经注意到,Copy 和 Paste 方法的主体都是空的。起初,我认为这部分代码的内容无足轻重。但是,我大错特错了。我解决问题的方式只是对 Win32? 和 Visual Studio? 6.0 进行了一些改善。
.NET 剪贴板 API
正如先前提到的那样,基于 .NET Framework 的应用程序使用 System.Windows.Forms 命名空间中的 Clipboard 类来管理剪贴板。该类(不一定要实例化)包含一对静态方法 — GetDataObject 和 SetDataObject。GetDataObject 检索当前存储在系统剪贴板中的数据;SetDataObject 将指定的数据对象置于系统内存中。
剪贴板支持多种数据格式,包括用户定义的格式。Win32 剪贴板 API 定义一组预先定义的格式,并用助记键常量(一个整数值,例如 CF_TEXT 或 CF_BITMAP)对其进行标识。
由于该剪贴板是系统组件,因此表示它的 .NET Framework 类只为一组低级别的 API 函数提供包装。可能会使您感到惊讶的是,剪贴板的 .NET Framework 实现不是基于原始 Win32 函数和消息的。.NET Framework 中的 Clipboard 类通过 OLE 剪贴板协议执行操作。您是说 OLE 是的,没错!本专栏打算回顾一些早期技术,它们是 Windows 发展过程中的重要里程碑。
大约在 10 年前,Microsoft 引入了 OLE 作为一种全包含组件技术。引用 Kraig Brockschmidt 在 Inside OLE(Microsoft Press,1995 年)一书中的话,OLE 被定义为一个“基于对象服务的统一环境”。与使用 Win32 不同,您无法只使用 OLE(或者是它的继任者 COM)来编写一个完整的应用程序。但是,OLE 采用了某些现有的系统功能,并以一种更通用、更广泛的方式来公开它们。那么,什么是 OLE 剪贴板协议呢?
从本质上说,OLE 剪贴板是一组接口,旨在泛化基于 Windows 的应用程序与剪贴板之间发生的数据交换。Platform SDK 只定义几个基本的数据类型(多数为文本和位图)。OLE 剪贴板协议通过提供对任意数据格式和存储介质的支持来扩展模型。只要应用程序所需的数据格式未超出 Platform SDK 提供的范围,那么对于基于 Windows 的应用程序来说,OLE 剪贴板协议就不是必需的。基于 OLE 的协议比 Win32 剪贴板 API 的功能更强大,并且在 .NET Framework 中构建剪贴板支持时,OLE 是一个相当合理的选择。
在 OLE 和 .NET Framework 中执行剪贴板操作的关键接口是 IDataObject。这两个接口具有不同的方法集,但都扮演着同样的角色。IDataObject 提供一种独立于格式的机制以传输数据,并且由 Clipboard 类在拖放操作中使用。(在 .NET Framework 中,OLE IDataObject 接口被重命名为 IOleDataObject。)图 4 列出了在 IDataObject 接口上定义的方法。
以下代码片段显示了一个基于 .NET Framework 的应用程序如何将纯文本复制到剪贴板:
Clipboard.SetDataObject(text);
SetDataObject 方法提取一个对象并将它作为 IDataObject 实例复制到剪贴板中。如果该参数是一个已实现 IDataObject 接口的对象,则直接进行复制。否则,该方法将对象打包到一个动态创建的DataObject 类实例中,如图 5 所示。
DataObject 类可实现 IDataObject 和 IOleDataObject 接口。剪贴板的 SetDataObject 方法接受普通的对象参数:
public static void SetDataObject(object); public static void SetDataObject(object, bool);
这段代码允许您将简单数据(例如,文本和位图)作为原生对象传入,而将数据改写的重担转嫁给内置的基础结构。SetDataObject 方法还具有一个要求额外 Boolean 参数的重载。该参数指示,在当前应用程序终止之后,置于剪贴板中的数据是否应该保持可用。如果您使用一个参数的重载,则剪贴板的内容会在退出时刷新。
与将数据写入剪贴板相比,从剪贴板读取数据更明了些,这是因为数据推断不能委托给 .NET Framework。以下代码片段显示了托管应用程序如何从剪贴板中进行读取:
IDataObject data; data = Clipboard.GetDataObject();
GetDataObject 方法返回一个 IDataObject 数据包,应用程序必须将其解包:
// Verify that the data object contains plain text if (data.GetDataPresent(DataFormats.Text)) { // Extrapolate and display the text string text = data.GetData(DataFormats.Text); MessageBox.Show(text); }
IDataObject 接口上的 GetDataPresent 方法(请参见图 4)采用一个参数来识别数据类型(例如,纯文本)。如果该数据对象的内容与指定的类型相匹配,则该方法会返回真。请注意,无论是存储对象的原生类型相匹配,还是对象的类型能够转换为所需类型,该方法都会返回真。例如,如果数据对象包含 HTML 文本,但是用户要求纯文本,那么 GetDataPresent 会返回真。
从基于 .NET Framework 的应用程序中使用剪贴板 API 不费吹灰之力,但是如果您试图从远程对象来使用它,就不那么简单了。
构建远程剪贴板处理程序
Windows 窗体应用程序通常以单线程单元 (STA) 模式运行。通过将 [STAThread] 属性添加到应用程序的 Main 例程,C# 项目将这一模式清晰地呈现在您面前。在 Visual Basic .NET 中,该设置是隐式的。对于许多 GUI 应用程序而言,STA 模式绝对是必要的,因为这些应用程序依赖于由并不始终支持纯多线程环境的操作系统所公开的服务。这是典型的 OLE 和 COM 服务,例如剪贴板和拖放操作。
事实上,您不能在来自 MTA 池的线程中使用剪贴板对象。请试验下面的小型控制台应用程序:
using System.Windows.Forms; class Test { [MTAThread] static void Main() { Clipboard.SetDataObject("MSDNMag"); } }
这段代码会抛出一个线程状态异常,如图 6 所示。
图 6 线程状态异常
能够在基于 .NET Framework 的应用程序中进行 OLE 调用之前,程序员必须确保当前线程以 STA 模式运行。实际上,托管对象负责以一种线程安全的方式来公开它们的共享数据。.NET Framework 支持线程单元只为获得向后兼容性。相反,COM 组件使用线程单元。出于该原因,CLR 需要在与 COM 对象发生任何交互之前先创建一个线程单元。STAThread 和 MTAThread 属性都是声明性编程接口,用于为应用程序选择线程模型。在上面的代码片段中,MTAThread 属性设置了控制台应用程序,以创建一个托管线程并对其进行配置以输入一个 MTA 单元。只要应用程序调入 OLE/COM 的内容,就可以检测到单元冲突并引发一个异常。通常,控制台和 Windows 窗体应用程序以 STA 模式运行(除非指定其他模式)。
当我第一次构建 marshal-by-ref 对象以将文本复制到远程剪贴板时,我保留了默认设置。但是,只要执行流到达 Clipboard 类,就会引发线程异常。请记住,远程调用始终是通过 MTA 线程解决的。
在调入 Win32 OLE 方法之前,Clipboard 类会根据当前线程的单元模式执行预备检查。这通过发出一个对 Application.OleRequired 方法的调用来完成。该方法检验 OLE 是否在当前线程上进行初始化,或者亲自进行初始化(如果需要)。该方法从列出三种可行值(TA、STA 和 Unknown)的 ApartmentState 枚举中返回一个值。您可以通过下面的代码检查当前的线程模型:
Console.WriteLine(Application.OleRequired().ToString());
调入远程对象的客户端应用程序是一个单线程的 Windows 窗体应用程序。.NET Remoting 宿主是一个显式标记为 STA 的控制台应用程序。但是,用于远程调用的线程的单元状态是 MTA。您在图 6 中看到的错误消息其实是以前的结论。
对于如何解决 .NET Remoting 文档和可用参考资料(包括出色的 http://www.dotnetremoting.cc)中的问题,我还没有找到理想的解决方法。所以我采用了 Kraig Brockschmidt 在有关 OLE 剪贴板的章节中给出的建议。Kraig 指出,只有当 OLE 剪贴板能够为您带来附加值时,才应该使用它(如 .NET Framework 所做的那样)。在只需要交换文本和位图时,您可以坚持使用 Win32 剪贴板 API。作为回报,这样做会减少线程之争。图 7 显示了一个 Win32 DLL,它导出一对公共函数以将纯文本复制并粘贴到剪贴板。
当编码 Win32 方式时,您必须首先打开并清空剪贴板。您需要一个窗口句柄来打开剪贴板,这是因为剪贴板只能属于一个窗口对象。OpenClipboard 是要调用的 API 函数。EmptyClipboard 函数会释放存储在剪贴板中的全局数据的所有句柄。之后,当前让剪贴板打开的窗口便成为新的所有者。要将数据复制到剪贴板,您必须分配一块最好以 GHND 标志标记的共用内存,该标志表示其可移动,并可初始化为零。复制到剪贴板或从中读取的任何数据,都被打包到共用内存的句柄中。在 Win32 级别上,可以打包到存储介质中的数据格式种类限制为几种,例如 HTML、纯文本或独立于设备的位图。如果您使用 OLE,则可以使用更多格式和存储介质。
虽然将 Win32 DLL 和 P/Invoke 平台用于 Win32 交互操作会限制剪贴板只能使用几种格式,但是不需要更改线程模型,并且也可以通过远程使用。图 8 显示了远程剪贴板处理程序对象的最终代码。两个 DLL 公共函数映射到 marshal-by-ref 类的静态外部成员。CopyToClipboard 的签名不难转换为 CLR 类型系统。利用字符串更改 LPCTSTR 并完成操作。导入 PasteFromClipboard 函数需要一点技巧。在图 7中,通过引用声明该函数接受一个字符串,并返回一个布尔值。
将类似签名转换为 .NET 代码的有效方式涉及到 StringBuilder 对象的使用:
[DllImport("clip32.dll")] private static extern bool PasteFromClipboard(StringBuilder text);
您首先将对字符串的引用替换为初始化的 StringBuilder 对象。然后,使用 StringBuilder 类上的 ToString 方法来获取该字符串:
StringBuilder buf = new StringBuilder(""); PasteFromClipboard(buf); return buf.ToString();
应当注意的是,StringBuilder 对象与 String 对象不同,前者是可增长的对象,而后者是传统的字符串,它在本质上不会改变。换言之,在您串联两个字符串时,.NET Framework 会创建一个新的字符串,其大小为二者之和。在您将字符串添加到 StringBuilder 对象时,只是将输入文本追加到现有缓冲区而已。
远程剪贴板客户端
远程剪贴板客户端由两个元素组成,即客户端和远程组件的主服务器。要部署该客户端,您必须将主服务器(请参见图 9)和带有远程组件的程序集一起复制到要到达的任何计算机上。您必须将客户端应用程序复制到您希望在网络中从其进行复制或粘贴的所有计算机上。
图 9 远程剪贴板应用程序
.NET Remoting 基础结构不会自动启动服务器端主机,来接收对远程组件的传入调用。用户负责启动并运行这种 stub 程序。您可以对该任务使用 Microsoft Internet 信息服务 (IIS),或编写自己的应用程序。使用 IIS 会受到一些限制(需要 HTTP 通道),但是该方法可让您不必管理远程处理宿主。或者,您可以编写自定义应用程序,该应用程序只需注册一条服务器通道(TCP、HTTP 或自定义类型),并通过服务器 URI 将远程类型与已知类型相关联。
在图 10 中,宿主是为端口 2222 创建 TCP 通道并将 MsdnMag.ClipboardHandler 类型(请参见图 8)标记为已知的控制台应用程序。用于调用对象的 URI 是 ClipboardHandler。您必须手动启动和终止控制台应用程序。您也可以决定将其编写为 GUI 应用程序,并提供一个用户界面来暂停或终止端口监视。要暂停监视,您可以取消注册通道。如果您不希望编写控制台应用程序,则可以选择创建一个 Windows 服务,该服务提供相同的功能,而不需要手动处理启动/停止操作。
.NET Remoting 组件的客户端必须完成一项基本任务,即,使远程类型可由其余的应用程序识别。该任务通过使用 RemotingConfiguration 类的其中一个静态成员(RegisterWellKnownClientType 方法)来执行。以下代码片段显示了如何将类型注册为已知:
RemotingConfiguration.RegisterWellKnownClientType( typeof(MsdnMag.ClipboardHandler), "tcp://expo-two:2222/ClipboardHandler");
RegisterWellKnownClientType 方法使用两个参数。第一个是待定的类型。第二个是一个 URI,它包括用于封送的传输协议、服务器名称或 IP 地址、要使用的端口以及远程对象的昵称,如之前的代码所示。当然,该端口必须与远程主机在其上进行侦听的端口相匹配。虽然已知类型的概念易于理解,但不能在本地定义远程类型,并且对任何方法或属性的任何引用都必须以不同的方式进行处理。在每个调用前面,编译器都必须生成一些普通的代码,以将调用封送到远程服务器并返回。出于这个原因,必须识别并标记源代码中对远程对象的所有引用,以便实时 (JIT) 编译器可以为其正确生成动态代码。
RegisterWellKnownClientType 方法的内部实现并不复杂。它可缓存类型信息,并将其添加到已知类型的全局哈希表。它将类型用作密钥,而将 URI 字符串用作成对的值。已知类型的编程接口决不允许您取消注册类型。如果您试图将一个已知类型重定向到另一个 URI,则会引发一个异常。
正如您看到的那样,RegisterWellKnownClientType(以及类似方法,如 RegisterActivatedClientType)通过在类型和服务器 URI 之间建立一对一的关系来运行。如果应用程序需要从不同的服务器调用相同的类型,应该怎么做呢?好,这就是在构建远程剪贴板处理程序时要面对的下一个问题。
如图 10所示,客户端应用程序必须多次注册相同的远程类型,为每台连接的服务器注册一次。远程剪贴板客户端必须能够对网络上可用的所有计算机的剪贴板进行读取和写入操作。这些计算机都会运行同一宿主和同一类型的实例,即图 8中的 MsdnMag.ClipboardHandler 类。如何多次将同一类型注册到不同的 URI 您有两个选择。第一个要求跳过内置的配置机制。您不用将远程类型标记为已知,而只需使用 Activator.GetObject 方法的一个重载来创建并获取对象的远程实例:
string uri = @"tcp:\\expo-two:2222\ClipboardHandler"; object o = Activator.GetObject(typeof(MsdnMag.ClipboardHandler), uri); MsdnMag.ClipboardHandler clip; clip = (MsdnMag.ClipboardHandler) o;
GetObject 方法为指定类型和 URL 所指示的对象获取或创建代理。该解决方案为您提供了极大的灵活性,因为它并不依赖于服务器的数量及其位置。
第二个选择是,如果您明确知道客户端始终与之协同工作的服务器,则可使用更为严格的方法,将传统的已知类型方法扩展到多台服务器的情况。其思想是通过派生几乎完全相同的新类来重命名远程类型。在计算机名之后的命名空间中将新类设为根。(尽管这是任意的。)
图 11 中的代码显示了如何使用继承来重命名 MsdnMag.ClipboardHandler 类。如果重新编译,则 ExpoTwo 和 ExpoStar 命名空间中的新类将不会添加额外的代码,并且不需要系统开销。要支持新服务器,您必须添加新的命名空间声明。应当注意的是,该解决方案缺乏灵活性,因为它对服务器的名称进行硬编码,并且任何更改(例如,将新服务器添加到列表)都要求重新编译。以下代码说明了来自客户端的远程调用:
ExpoTwo.RemoteClipboardHandler rc; rc = new ExpoTwo.RemoteClipboardHandler(); rc.Copy(TextToCopy1.Text);
在调用已知类型上的方法时,.NET Remoting 基础结构会验证宿主应用程序是否已启动并处于运行状态。如果不是,则会引发一个套接字异常。引发的特定异常是 SocketException;该类属于 System.Net.Sockets 命名空间。客户端应用程序的示例项目引用了包含远程对象的组件。
尽管其功能不会引发异常,但该方法或许不是实际情况中的最佳选择。版本控制、类依赖项甚至大小都是确定备选方法的理由。例如,您可以通过为所有公共方法甚至接口定义带有空实现的基类,来避免链接原始程序集。尽管在后一种情况中,您不能在客户端上只使用 new operator 来获取远程对象的实例。实际上,按照设计,您无法在接口上调用 new。您可以使用 Activator.GetObject 来检索在指定 URI 处实现给定接口的对象的实例。有关该特定点的详细信息,以及与 .NET Remoting 相关的工作的有价值资源,请参阅 http://www.ingorammer.com/RemotingFAQ。
小结
图 12 中所示的客户端应用程序提供了在特定计算机上复制和粘贴纯文本的按钮。通过使用低级别 API 调用的 Win32 DLL 来完成系统剪贴板上的物理操作。对于跨网络中的计算机共享剪贴板这一问题,http://www.codeproject.com/dotnet/clipsend.asp 上的文章提供了更为有用的方法。
图 12 复制和粘贴到特定计算机
对于我的客户端用途而言,Win32 基本格式就足够了。希望将来没有这些限制。我的解决方案将模拟 ASP.NET 会话的分布式体系结构,引入充当宿主的 Windows 服务,并添加在 STA 线程上以远程方式操纵剪贴板的 marshal-by-ref 类。最后,我会将一对重载添加到现有 Clipboard 类的方法中。
请将给 Dino 的问题和意见发送至 cutting@microsoft.com。
Dino Esposito 是一位讲师兼顾问,现居住在意大利的罗马。他著有 Building Web Solutions with ASP.NET and ADO.NET 和 Applied XML Programming for .NET,这两本书均出版自 Microsoft Press。他的大部分时间都用于讲授有关 ASP.NET 的课程以及会议演讲。Dino 最近为 Microsoft Press 出版了 Programming ASP.NET 一书。您可以通过 dinoe@wintellect.com 与 Dino 取得联系。