一站式搞定单个实例应用程序的全部三个功能,.NET






4.94/5 (24投票s)
单个实例应用程序的行为只有在实现了所有三个功能时才算全面:检测第二个实例、传递命令行参数和激活第一个实例。
目录
1. 引言
很久以前,我发现应用程序的单个实例行为不是操作系统 API 提供的,因此需要开发一个特殊的处理程序,这会带来一些与 IPC 和原子唯一性相关的相当严重的问题。后来,我意识到我自己的实现虽然在大多数实际情况下都能工作,但远非令人满意。通过一步步的努力,我最终开发出一种我认为是全面的方法。我最近对类似解决方案的搜索仍然没有找到让我满意的,所以我希望我的解决方案能真正帮助到许多开发者。
问题在于,典型的解决方案只关注一个方面:找出应用程序实例是第二个实例,因此应该终止它。将此任务作为主要任务会导致不合适的实例间通信技术。一种方法是列出系统中的所有进程,然后将当前实例与进程列表进行比较。另一种,遗憾的是,非常流行的方法是使用共享内存。问题在于问题的陈述方式不当。如果将第一个功能,即识别第一个实例,视为主要功能,那么所有的思考都将走错方向。正确的想法应该是:“主要”并不意味着定义。具有单个实例行为的著名应用程序实际上表现出三个重要特征。
- 当一个实例启动时,它会找出是否已有实例正在运行。在这种情况下,第二个实例会自行终止。
- 可选但通常情况下,在终止之前,第二个实例会将命令行参数传递给第一个实例。在这种情况下,第二个实例会接收此信息并进行处理,通常会加载命令行中列出的文件。
- 可选但通常情况下,第一个实例会通过将自身显示在桌面上的其他应用程序窗口之上并聚焦其 UI 的相应元素来处理第一个实例的启动。
正确的方法应该以整体的方式考虑所有三个需求,将它们视为同等重要和典型。这不可避免地会得出关于 IPC 技术的正确结论:它应该基于数据量最大的需求,即传递命令行数据,而其他两个需求应作为这种面向消息的通信的副作用来实现。
因此,我邀请我的读者不仅要熟悉我的方法,还要跟随我的逻辑。
2. 主要思想
用几句话来解释这个想法其实很简单。让我们做一些逻辑推断。首先,操作系统被设计成所有应用程序的所有实例都是平等独立的进程。而且,现代操作系统中的所有进程都是高度隔离的,它们在独立的地址空间中运行,因此在一个应用程序中某个对象的地址的数值在不同进程中可能完全不同,即使该进程是同一个应用程序的另一个实例。
这意味着,要在这些实例(不同进程)之间引入通信,我们需要使用一些 IPC。有不同种类的 IPC,包括用于实现单个实例行为的常规 IPC,如共享内存,但也有命名互斥体、事件等待句柄等。该使用哪种?让我们看看我们三个功能中哪个是数据量最大的?当然是传递命令行。并且交互的性质是面向消息的:第二个实例将其命令行发送给第一个实例,然后在那里处理。因此,我们需要某种面向消息的 IPC。
其余步骤几乎是预先确定的。拥有一个单独的处理程序来检测第一个实例进程似乎是多余的。我们只需要尝试发送消息。失败将意味着第一个实例不存在。在这种情况下,当前实例应被视为第一个实例;并且应该创建消息接收机制。另一个实例将成功连接到第一行并发送一条或多条消息给第一个实例;然后它应该退出。
基本上就是这样。但现在,让我们选择面向消息的 IPC 处理程序的实现。当我从这个角度看待问题时,我立即选择了“经典”的 .NET Remoting。它所需的工作量最少,而且相当充分和可靠。同时,WCF 看起来有点大材小用,除了支持更少的 .NET 版本外,它无法带来任何比 Remoting 有用的东西。现在,通道。显然,需要通信的进程运行在同一台机器上。因此,它们可以通过套接字 API(如果 Remoting 不存在,这将是实现消息传递的第一个候选者)或命名管道进行通信,命名管道是一种跨平台功能。嗯,在 Remoting 中,有一个基于管道的预定义通道,它也被称为“IPC”,我会称之为“狭义上的 IPC”。这是完美的选择。
同时,该机制可以轻松升级到某种分布式机制,其中应用程序的角色可以由运行在任何网络节点集合上的某个进程来扮演,而无需预定义该应用程序的位置。但这又是另一回事了。
现在,让我们将讨论限制在单台机器上。以下是情况的总结:
我们开发了 Remoting IPC 功能,其中第一个实例设置了一个远程对象并充当服务器,第二个实例尝试充当客户端,连接到服务器的远程对象并向其发送一些消息。第一个实例,服务器部分,处理消息。
某个应用程序会检查其他进程实例。它会尝试连接到某个 Remoting 服务器,使用某种唯一的命名方案。它将注册通道并尝试通过 Remoting 激活连接到服务器的远程对象,并向其发送一条消息,实际上是调用一个远程方法。如果它是系统中的唯一实例,它将失败,因为远程对象从未创建。在这种情况下,当前实例是第一个实例,它应该静默处理异常,并创建远程对象并使用 Remoting Services 注册它。这样,它就准备好处理来自第二个实例的消息了。
如果成功连接到远程对象,则意味着当前应用程序实例不是第一个实例。然后它可以调用远程对象接口成员。可选地,它可以发送其命令行,请求激活第一个实例,或两者都执行。第一个实例,通过这些远程调用,应该以某种特定于应用程序的方式处理所有或部分请求。
就是这样。从此刻起,您无需再阅读,可以直接下载并查看本文提供的源代码(请参见文章顶部)。对于那些希望看到用法和关键实现细节解释的人,我将继续。
3. 用法
首先,单个实例处理程序不应依赖于应用程序类型。所有三个功能都应该是可选的;它们不应依赖于应用程序类型。此外,即使使用了命令行传递和第一个实例激活,这些请求的处理也应该是可定制的。
在一般情况下,这可能是这样的。主要操作发生在应用程序入口点方法 (Main
) 中。
static void Main(string[] commandLine) {
if (SingleInstanceManager.IsSecondInstance) {
SingleInstanceManager.HandleRemoteCommandLine(commandLine);
SingleInstanceManager.ActivateFirstInstance();
return;
} //if
// the usual Main method stuff here:
// ...
}
这段代码清楚地演示了可选的单个实例行为的所有三个功能。第一个功能是“if
”和“return
”的组合。当然,这是可选的:如果缺少此块,应用程序将运行任意数量的实例。另外两个功能 HandleRemoteCommandLine
和 ActivateFirstInstance
也是可选的。
但是,这还不是全部。当 SingleInstanceManager.IsSecondInstance
返回 false 时,第一个实例将准备并注册其远程对象以供以后处理来自第二个实例的请求。这已在此方法的实现中完成。但是,这两个请求的处理需要进行定制。默认情况下,处理的实现始终是空的,不做任何事情。要执行某些操作,应用程序代码需要订阅一个或两个静态事件,这些事件在第二个实例收到消息时被调用。
这是此类处理实现的骨架:
SingleInstanceManager.FilesLoading += (sender, eventArgs) => {
Dispatcher.Invoke(new System.Action(() => DoSomethingWithCommandLine(eventArgs.CommandLine) ));
};
// and
SingleInstanceManager.FirstInstanceShowing += (sender, eventArgs) => {
Dispatcher.Invoke(new System.Action(() => DoSomethingToActivate() ));
};
请注意 Dispatcher.Invoke
的使用。这是将某些处理从任何线程委托到应用程序 UI 线程的机制。它在 WPF 中引入,但也可以在 System.Windows.Forms
中使用,或者可以选择使用 System.Windows.Forms.Control.Invoke
。这一点至关重要,因为事件将在某个 Remoting 线程中调用,而不是 UI 线程。由于我们对 SingleInstanceManager
处理程序的实现旨在通用,独立于应用程序类型或使用的特定 UI 框架/库;它可以是仅控制台应用程序,或任何其他类型的应用程序。
我认为用法现在应该很清楚了,所以我可以解释一些有趣或更难的实现细节。
4. 实现
创建远程对象最方便可靠的方法是定义一个接口,然后在一个派生自 System.MarshalByRefObject
的类中实现它。
我将整个接口命名为 IRemoteFileLoader
,仅仅是因为加载第二个实例命令行参数传递的文件是其最基本的功能。
interface IRemoteFileLoader {
void HandleCommandLine(string[] commandLine);
void ActivateFirstInstance();
void TestInterface();
}
我希望这个接口的用途很清楚,除了我必须解释的一个成员 TestInterface
。这个方法的实现设计为完全不做任何事情,具有空体。它不是多余的吗?几乎。它唯一的目的是……在远程对象不存在、未注册时抛出异常。根据 Remoting 的设计,System.Activator
对象在所有情况下都不会抛出异常,并返回一个远程对象代理,即使连接不可能。异常仅在尝试调用任何接口方法时抛出。为什么不重用其他两个方法来实现这个目的?答案很简单:因为我们希望它们是可选的。
实现是非公共的(internal
),并在 SingleInstanceManager
处理程序内部使用。这是完整的实现:
internal class Server : System.MarshalByRefObject, IRemoteFileLoader {
internal event System.EventHandler<CommandLineEventArgs> CommandLineHandling;
internal event System.EventHandler FirstInstanceActivating;
void IRemoteFileLoader.TestInterface() { }
void IRemoteFileLoader.HandleCommandLine(string[] commandLine) {
if (CommandLineHandling != null)
CommandLineHandling.Invoke(this, new CommandLineEventArgs(commandLine));
} //IRemoteFileLoader.HandleCommandLine
void IRemoteFileLoader.ActivateFirstInstance() {
if (FirstInstanceActivating != null)
FirstInstanceActivating.Invoke(this, new EventArgs());
} //IRemoteFileLoader.ActivateFirstInstance
public override object InitializeLifetimeService() { return null; }
//to keep it alive by ignoring life time lease policy
} //class Server
如您所见,服务器远程对象不直接处理来自第二个实例的事件,它将实现委托给用户。但其事件成员是 internal
的。SingleInstanceManager
处理程序处理事件,然后将实现委托给应用程序代码,正如在 用法部分 中所示。
SingleInstanceManager
处理程序的接口/公共部分应从 用法部分 中显而易见:它有两个公共事件 FilesLoading
和 FirstInstanceShowing
,以及两个对应的使用方法,从客户端(第二个实例)调用它们在服务器(第一个实例)端。此外,这是启动所有这些的主要方法:
public static class SingleInstanceManager {
public static bool IsSecondInstance {
get {
bool secondInstance = DetectSingleInstance();
if (!secondInstance)
RegisterServerObject();
return secondInstance;
} //get IsSecondInstance
} //IsSecondInstance
// ...
// implementation:
static bool DetectSingleInstance() {
try {
var channel = new IpcClientChannel();
ChannelServices.RegisterChannel(channel, false);
RemoteFileLoader = (IRemoteFileLoader)Activator.GetObject(
typeof(Server),
RemoteObjectUrl);
RemoteFileLoader.TestInterface();
} catch { RemoteFileLoader = null; return false; }
return RemoteFileLoader != null;
} //DetectSingleInstance
// ...
}
真正需要关注的唯一细节是 Remoting 机制中使用的两个名称:通道名称和远程代理名称,它们共同创建了所需的远程对象 URL。它们具有特殊的唯一性要求。如果不是另一个因素:跨平台实现的正确性,这个问题将显得相当琐碎,但正是这个因素使整个问题变得相当棘手,需要特别的考虑和仔细处理。这是我下面要讨论的其余实现细节。
5. 唯一的 IPC 名称
基本上,有两个独立的名称,通道名称和单个远程对象代理名称。有了两个字符串 ChannelName
和 ProxyObjectName
,我们就应该得到以下形式的 URL:
string.Format("tcp://{0}/{1}", ChannelName and ProxyObjectName);
我的代码中没有这样的行;我不想有一个硬编码的“魔术字符串”;这会大大损害代码的可维护性和跨平台质量。请下载并查看完整代码,了解这些字符串的使用位置,这是通用且常见的 Remoting 问题。
对于唯一名称,我可以看到两种可能性。只有通道名称必须是系统唯一的。一种方法是生成一些每个应用程序的字符串,该字符串很有可能具有全局唯一性。它可以是 GUID,或者一个序列化为字符串的大型加密密钥,或者类似的东西。我们在这里实际上不需要全局唯一的东西;最好拥有系统唯一的东西,但具有 100% 的概率。此外,为每个应用程序添加硬编码字符串将是另一个开发步骤,逻辑上是不必要的(它与任何应用程序要求无关),因此,又是错误的来源。我们能否自动生成所有字符串?
因此,我的另一种方法是使用主可执行模块的文件名。这能满足我们的目的吗?显然,如果将应用程序保留在文件系统中的相同位置,则使用此文件的另一个进程启动将被检测为第二个进程,并且将按预期工作。如果可执行文件被复制到其他位置,两个不同的进程可以独立工作。但我们应该认为这是一个问题吗?有什么问题?通常,我们不需要复制任何合理开发位置的可执行文件。但如果我们这样做,我们是出于调试、测试或其他特殊目的而有意识地进行的,并且可以享受两个实例并行运行,但仅此而已。
首先,让我们获取这个字符串,可以通过查找入口程序集的 位置 来完成,System.Reflection.Assembly.GetEntryAssembly()
。
顺便说一句,还有许多其他方法可以获取此字符串。但是,我有一个严肃的警告,对于那些使用任何其他方法的人:并非所有这些方法都能在所有情况下都给出正确的结果;实际上,它取决于应用程序如何托管以及其他因素。从 Reflection 获取此字符串在所有情况下都给出相同的结果。请参阅我过去的回答以获取更多详细信息:
如何查找我的程序目录 (可执行目录),
如何查找我的程序目录 (当前目录,“特殊文件夹”)。
它会起作用吗?在 Microsoft 平台上,是的,它会立即起作用,但这仅仅是因为 Microsoft 文件系统和 URI 的路径字符串格式不同,首先是不同的路径分隔符,'\' 和 '/'。在 *NIX 系统上,两个分隔符都是 '/';这会搞乱事情。所以,我的解决方案是修改这个字符串,将 *目录分隔符* 替换为 *路径分隔符* ';' 为什么选择这个特定的字符?嗯,因为它在 .NET FCL 中声明过,并且它是一个已知的有效 URI 分隔符(查询参数的)。所以,解决方案是:
static string ChannelName {
get {
return System.Reflection.Assembly.GetEntryAssembly()
.Location.Replace(
System.IO.Path.DirectorySeparatorChar,
System.IO.Path.PathSeparator);
}
}
现在,另一个有趣的问题也与避免硬编码字符串的目标有关,而且有点有趣,因为 System.Uri
的实现并不完美(其他作者也指出过),并且不提供计算 URI 路径分隔符 '/' 的成员。它是通过组合 URI 的各个部分来构建 URI 的,但如果某个 URI *权限* 可以被解释为文件名,则它无法正确工作;它也不能与“ipc”方案一起使用,因此结果是“ipc:///*”,而不是所需的“ipc://*”。字符串“ipc”也没有明确定义,所以一个荒谬的获取方法是使用一个临时的 System.Runtime.Remoting.Channels.Ipc.IpcChannel
实例作为新的 IpcChannel().ChannelName
。
总而言之,这一切都给了我一个相当荒谬的 URI 名称实现:
static string RemoteObjectUrl {
get {
Func<string> getUriPathDelimiter = () => {
Uri uri = new Uri(Uri.UriSchemeFile + Uri.SchemeDelimiter + ProxyObjectName);
return uri.AbsolutePath;
}; //getUriPathDelimiter
string scheme = new IpcChannel().ChannelName;
System.Text.StringBuilder sb = new System.Text.StringBuilder(scheme);
sb.Append(Uri.SchemeDelimiter);
sb.Append(ChannelName);
sb.Append(getUriPathDelimiter());
sb.Append(ProxyObjectName);
return sb.ToString();
} //get RemoteObjectUrl
} //RemoteObjectUrl
另请参阅我的短文/技巧文章 “将临时方法隐藏在调用方法的正文中”。
这看起来可能非常人为,但同样,避免硬编码 *魔术字符串* 可以大大提高代码的可维护性和可移植性。
6. 单个实例处理程序和测试/演示应用程序
我将单个实例处理程序放在一个单独的程序集中,另外两个程序集,一个用于 System.Windows.Forms
,另一个用于 WPF,都在同一个解决方案中;这三个项目相互引用。每个演示应用程序创建一个选项卡控件,并在第二个实例启动时显示它,并将其命令行参数发送给第一个实例,即使命令行参数为空。如果命令行参数是实际存在的文件,第一个实例会加载它并在新的选项卡页面中显示它,并将其聚焦。通过在 Z 顺序中将应用程序显示在其他应用程序窗口之上来处理激活。
这些演示应用程序中只有一个功能可能需要解释:WPF 应用程序的入口点。显然,单个实例技术需要访问应用程序的入口点方法 Main
。并非所有 WPF 开发人员都理解如何显式创建 such method,因为 such developers 仅使用自动生成的入口点,甚至可能不知道在哪里找到它。Such developers 需要查看 WPF 演示项目的文件,“EntryPoint.cs”和“Main\TheApplication.cs”。TheApplication
类替换了自动生成的 Application,并添加了许多重要功能,特别是主事件循环中使用的通用异常处理程序。这是我的一个小奖励。
7. 构建和平台兼容性
所有项目都通过提供的单个批处理文件 build.bat 一键构建。代码以 .NET Framework v.3.5 为目标构建,作为“通用分母”,可以在大多数 Windows 机器上构建。该解决方案应该可以在任何更高版本的 .NET 上运行。
核心程序集 SingleInstance.dll 设计为跨平台。它应该可以在不同的 CLR 平台(首先是 Mono)的不同操作系统上运行,而无需重新编译。(当然,WPF 演示不能在非 Microsoft 平台上使用。)
但是,我还没有在任何非 Microsoft 平台上测试过。我至少有一台 Linux 机器用于测试,并且将在其他项目准备好后尝试进行测试。如果有人花时间测试此处理程序并报告任何问题,我将非常感激。
8. 跋
我计划了一个关于应用程序单个实例行为的短期系列。到目前为止,我只设想了两篇文章。下一篇文章 单个实例应用程序行为,现在针对 Free Pascal 专门介绍了基于 Free Pascal 的跨平台应用程序。这是一种出色的开源跨平台技术,用于在比 CLR 更广泛的目标操作系统集上开发原生应用程序和其他软件解决方案,并具有出色的 UI 跨平台模型。
如今,这是我所知的最好的原生代码跨平台开发技术。难怪,由于可用的跨平台消息传递单元,我的应用程序单个实例行为实现看起来非常简洁。