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

构建 Snap-In 应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (42投票s)

2003年8月24日

CPOL

17分钟阅读

viewsIcon

106464

downloadIcon

3153

本文详细介绍了如何构建一个类似 MMC 的 Snap-In 可用应用程序。

引言

几年前,我曾被要求创建一个灵活且开放的应用程序框架:在交付时,我知道它必须支持功能 A、B 和 C,但明年,它还需要执行功能 X,而 X 可以是与项目相关的任何内容。我看到了 Microsoft 已经找到了解决方案:Microsoft 管理控制台 (MMC)。我使用 MMC 及其 Snap-Ins 来管理 SQL Server、IIS、P&M Server 等,显然 Microsoft 在发布 MMC 1.0 时并未考虑到所有可能的 Snap-In。这正是我所需要的:一个简单的外壳程序,暴露通用的 GUI 元素——工具栏和菜单——以及通用组件——TCP/IP 套接字、数据加密器和用户身份验证器。

信不信由你,经过一点研究,我发现可以在 Visual Basic 6.0 中编写 Snap-In 应用程序,而无需诉诸于将 ODL 破解成 VB 友好的 TLB(我曾是一个 VB 受虐狂),就像我如果要编写 Explorer Shell Extension 一样。不久之后,我便有了一个可工作的原型,随后产品也随之发布。

正如您可能已经猜到的,C# 和 VB.NET 提供了面向对象的语言特性,可以开箱即用地创建 Snap-In 类型程序,并且编码量相对较少(而且不再有二进制兼容性问题!)。我接下来的目标是在以下讨论中,从头开始解释使用 C# 构建 Snap-In 应用程序的架构和实现。

OO 回顾:多态

在我解释解决方案的工作原理之前,您需要对多态有一个清晰的认识(这个词既笨拙又重要,就像“正交性”一样)。如果您已经了解了关于多态的一切,请随意跳过——这只是复习和引入。

您很可能非常熟悉(甚至能向您的祖母解释)什么是程序化继承。但如果您和我遇到过的许多程序员一样,您可能会含糊不清地谈论多态,就好像它是 OO 语言中无用的丑小鸭一样。程序员和多态之间的这种误解是很不幸的——我认为它是 OO 编程中强大的特性,并且可以带来最高的代码重用率。

我认为这种脱节是因为继承感觉很自然,并且有相当明显的应用:假设我编写了一个 Animal,并且 CatAnimal 继承,那么它可以通过简单地(继承自)Animal 来完成 Animal 所做的一切——Walk()Eat()。从概念上讲,多态可能不如继承那样容易理解。

多态在某种程度上与继承是相反的。继承是从一般到特殊的沿着树向下行走,而多态是从特殊到一般地向上传播。假设我有一个函数,比如 Move(Animal a),它的实现将简单地调用传入的任何动物的 a.Walk()。正是多态允许我将 Animal 派生的 Cat whiskers 传递给它,像这样:Move(whiskers)。不必编写特定的函数 Move(Dog d)Move(Cat c),我可以编写通用的 Move(Animal a)。强大的东西!将多态视为按需将您的特定派生类转换为其父类抽象类。

很好,这对我有何好处?嗯,Snap-In 程序将只包含一组任意的 Snap-In“模块”,它们都必须响应相似的请求:例如 Initialize()Activate()。反过来,每个模块都会期望从容器“shell”那里获得某些东西,例如 CurrentUserIdSendData()。将这个 ShellModulesZookeeperAnimals 进行比较。

  动物园管理员 Shell
饲养一套.. Animal[] Module[]
..并且可以期望能够.. 喂养动物 启用模块
..通过调用实例的接口 animal.Eat() module.Activate()
反过来,一个实例知道它可以满足它的需求,通过调用包含的 keeper.DemandAffection() shell.UpdateStatusBar()

因此,只要我拥有满足 AnimalModule 契约的对象,我的 ZookeeperShell 就知道它们可以以相同的方式与它们交互,使用相同的函数调用。这是双向通信,因此,Animal 对象知道它们可以请求 Zookeeper 的抚摸,而 Module 对象可以请求 Shell 更改 StatusBar。这正是 MMC Snap-Ins 和 Windows Explorer Shell Extensions 的实现方式,希望这种有些省略的机制视图能帮助您开始玩转 PIDL。

解决方案架构

即使您跳过了前面部分,正如您在上表中所看到的,解决此问题的方法显然在于定义和实现 Shell 程序与 Module 之间的接口契约,并使用多态函数调用。好吧,也许那并不那么明显,但现在您有了一些可以给老板留下深刻印象的东西可以说。

契约式编程(我不是指工作合同!)是一种确保程序按其承诺执行的好方法,因为语言强制执行规则。我的同事们实际上已经厌烦了我不断地说:“我们应该使用接口编程”。我想我就像那个手里拿着锤子的人,处处看见钉子。但话又说回来,我意识到回报是巨大的,并热衷于协调一群淘气的程序员的方法。使用接口编程可以确保至少在两个编码人员编写的代码之间具有一定程度的一致性。

C# 提供了关键字 interface,它只做一件事:定义一个类接口(契约),以便进行实现编程。根据设计,接口没有实现,也不能有实现。因此,如果您创建一个继承接口的对象,您必须确保您已准备好实现它——编译器会要求您这样做。

通过接口实现和多态的奇迹,我将要开发的 shell 可以维护一个 IModule 对象的数组,它知道可以调用它们,对于任何有效的 i,都可以使用 _modules[i].Initialize(this)。反过来,每个模块都可以将其作为成员字段保留 Initialize(IShell shell) 调用传递给它的引用,并使用诸如 _shell.ToolBar.Buttons.Add(_myModuleButton) 之类的功能。请注意,“I”前缀是命名接口的一种相当标准的约定——它不是必需的,但强烈建议使用。

以下内容比您最终在解决方案中实现的要简单,但其中包含足够的信息,可以帮助您入门——至少让您了解双向通信的基本原理。总而言之,这是源代码 ZIP 中的Interfaces.cs

using System;
using System.Drawing;
using System.Windows.Forms;

namespace SnapIn
{
    public interface IShell
    {
        ToolBar TheToolBar {get; set;}
        StatusBar TheStatusBar {get; set;}
    }

    public interface IModule
    {
        void Initialize(IShell shell);
        void Activate();
        void Deactivate();
        void Move(Point topLeft, Point bottomLeft);
    }
}

请注意,这些接口定义中没有实现,也不可能有实现。我只是为 shell 和一个或多个模块的到来奠定了基础。

警告:这里演示了一个糟糕的实践。虽然我犹豫发布有问题的代码,因为我知道我违反了诸如良好的代码封装等原则,但我请求您的宽容,并相信我知道得更好,但现在我更感兴趣的是让您能够运行,而不会带来任何额外的令人困惑的负担(除了我的写作方式)。因此,当您接手时,请务必将 IShell 接口锁定得更“隐蔽”一些,例如 SetStatusText(string text)。您可能不想给一些未知的模块指向 StatusBar 的指针——它们可能会添加 942 个带有亮粉色闪烁文本的面板,然后您将身处何地?我想他们称之为市场部。

谁在和谁说话

此解决方案中从接口规范中不清楚的一个方面是 shell 和模块如何启动并协同工作。基本上,应用程序的入口点是无处不在的 Main(),它也是 IShell 的实现者。在我的程序中,这将是一个名为 Shell 的 C# 项目,它构建了Shell.EXE。模块被编译成 DLL,因为它们将被加载到 Shell 的进程中。DLL 中的内容可能是以下两种情况之一。对于我的程序,我决定每个模块都是一个 UserControl;另一种选择是使每个模块成为 MDI 子 Form。请注意,您的 shell 程序可能只会尝试支持其中一种——尝试处理两者可能会让您的用户感到困惑,更不用说您的程序员了。

我不确定 UserControl 相对于 MDI 子 Form 是否有最佳选择——这主要取决于您(和您的用户)期望模块之间的交互方式。我认为 MDI 及其平铺窗口显示将向用户表明,可以在一个模块中工作,然后“切换”到(甚至拖放到)另一个模块。如果您的应用程序是这种情况,将我编写的 shell 和模块转换为使用 MDI 非常简单。但是,我采取的方法是模块不知道或不假定存在另一个模块,因此一次只有一个活动的 UserControl 被加载到 Shell 的主应用程序区域。

代码演练

如果您还没有这样做,请务必从上面的链接中下载所有代码。我将假设您已下载并可以轻松查看它。

接口

解决方案的第一步是创建接口规范。这真的很简单,因为您所要做的就是定义您希望 shell 和模块如何通信。您不必(事实上,您也不能)提供任何实现细节。Interfaces.CSPROJ 包含单个Interfaces.CS,您上面已经看到过它。

Interfaces.CS 中最有趣的是 IModule.Initialize(IShell shell)。这是实现 shell 和模块之间双向通信的关键。没有它,Shell 将能够与它加载的任何模块通信,但模块将不知道它被加载的上下文的任何信息。在初始化过程中将 `this` 指针传递给模块,使模块能够了解它们在世界中的位置,从而为它们提供客户端矩形以外的额外 I/O 机会。

否则,正如您所看到的,IShell 公开了两个属性,允许访问实现者的 ToolBarStatusBar。您最终可能会在这里扩展 IShell 的接口,以允许模块从内存、网络或数据库请求额外数据。如果您选择 MDI 路线,公开允许模块请求访问其他模块的方法也可能很有用。也许您会添加类似 IModule GetModule(Type moduleType, int index) 的内容,以便模块可以通过 Typeindex 来定位彼此,如果存在多个实例。

重要提示:模块应该是独立的。如果您发现自己需要连接 IShellIModule 之间的调用超过十几个,您可能犯了一个错误。模块运行所需要的东西就是一个容器:一个 Form 或 MDI 父窗口。Shell 仅用于提供操作系统进程、感知统一性和所有模块的通用功能集。此外,模块需要在其接口中公开的所有内容是,高层级的方法,用于通知它何时被实例化、置于最前面、关闭、移动等。

Shell

Shell 上的 GUI 是最基本的:一个 MainMenu、一个 ToolBar 和一个 StatusBar。您不限于这些,但添加 GUI 组件到 Shell 时,请务必始终问自己,是否所有模块都需要它们:TCP/IP 套接字,是;ProgressBar,否。很可能,您在处理模块可用组件的事件等方面的实现会最少。如果您要为模块传输文件,您需要将所有压缩和线程逻辑封装到 SendFile(string path) 接口中。您可能还希望有一些默认的 ToolBarButtonsMenuItems 供所有模块使用。这取决于您和您的程序要求。请记住,经验法则:如果只有一个模块要使用它,代码应该放在模块中;如果许多模块要使用它,请保持代码整洁并将其包装在 IShell 接口中供所有模块使用。

要告诉 C# 您打算实现 IShell 接口,您首先需要添加对 Interfaces.DLL(如果您还没有构建它,则为 Interfaces.CSPROJ)的引用。然后,您可以声明您希望继承 Form(假设您不想重新发明窗口)并实现 IShell,如下所示:

public class FrmMain : System.Windows.Forms.Form, IShell
{
    ...
}

如果我现在尝试在没有实现的情况下编译,我会收到 CS0535 的构建错误:“'class' does not implement interface 'member'”,针对两个缺失的属性。编译器正在做好它的工作,检查我是否兑现了我承诺的契约。通过这种方式,可以保证 IModule 的实现者能够与 IShell 的实现者进行可靠的交互。

因此,您唯一需要编写的代码就是接口契约。在 C# 中,这只需编写与接口指定的方法名称和签名相同的方法即可。(对于 VB.NET 程序员,您实际上可以将方法命名为任何您喜欢的名字,因为您必须使用 Implements 关键字来指定哪个实现的方法满足每个接口方法的要求。此外,VS 2003 在您编写 Implements IShell 并按 [Enter] 后,可以自动为您编写方法存根,这非常方便。)所以,我最基本的 Shell 看起来像这样:

public class Shell : System.Windows.Forms.Form, IShell
{
    private System.Windows.Forms.StatusBar SbrMain;
    private System.Windows.Forms.ToolBar TbrMain;

    [STAThread] static void Main()
    {
        Application.Run(new Shell());
    }

    public ToolBar TheToolBar
    {
        get
        {
            return this.TbrMain;
        }
        set
        {
            this.TbrMain = value;
        }
    }

    public StatusBar TheStatusBar
    {
        get
        {
            return this.SbrMain;
        }
        set
        {
            this.SbrMain = value;
        }
    }
}

任何想要与实现 IShell 的 shell 通信的模块都可以始终依赖于 shell 拥有 TheToolBarTheStatusBar 属性。不过,我在这里措辞很谨慎。IShell 的实现者必须拥有这些属性,但它们不一定必须拥有 ToolBarStatusBar!它们可以返回 null 或在它们没有这些对象时抛出 NotImplementedException。合同编程为您提供的只是一个保证,即您对 MyShellReference.TheStatusBar 的调用不会因 CS0117:“'type' does not contain a definition for 'function'”而失败。实现者从那里做什么就完全开放了。

细节

Shell 的其余实现是学术性的,我相信您可以阅读并弄清楚发生了什么。因此,您大致了解了我如何看待处理这种开放式架构的最佳方法,我将只提出几个关键点。

您希望在 shell 启动时加载尽可能少的内容:感知至关重要,而缓慢的应用程序感知很差。由于模块数量未知,在 shell 加载时创建每个模块的实例可能会花费很长时间并惹恼您的用户。由于您必须将模块配置外部化(您现在应该已经意识到了这一点),因此考虑如何设计您的App.config 以及模块需要提供什么才能正确加载,这可能是值得考虑的。本质上,每个想要加载到您的 IShell 实现者中的 IModule 实现者都需要在 App.config(或自制的 XML 配置器)中有一个节点或一组节点,指示诸如程序集位置和它包含的类型等重要信息。

对于生产环境,您可能还需要添加实用的东西,例如 MenuItem 文本、快捷键和图标资源位置。但由于我没有将其投入生产,所以我采取了捷径,仅从 App.config 加载了路径和类型,并硬编码了它们的 MenuItems(我总得留点工作给您,不是吗?)

因此,剩下的有趣部分是关于从磁盘加载模块并在适当的时间和地点显示它们。ActivateModule 中没有火箭科学,只有一些状态管理和对使用 System.Activator 的方法的调用。当用户单击其中一个 MenuItem 时,我希望适当的模块加载到主显示区域。由于我使用的是一个简单的 IModule 数组,我可以按索引跟踪哪个模块处于活动状态,并且我检查活动模块是否是用户刚刚单击的那个,以避免重新初始化或重新激活已加载的模块。此外,我可以保持模块常驻或使用 releaseActive 参数销毁它们。如果设置为 false,则一个模块一旦加载就会保持加载状态,因此如果它正在进行后台处理,即使另一个模块处于前台,它也会继续处理;否则,模块将被置空并被 GC 回收。为了演示的目的,我在“Null Inactive Mods”(空闲模块)ToolBarButton 上公开了一个 UI。

但让我重申一下——如果您从这个部分只学到一点——请尽快使用配置文件或其他模块元数据加载您的应用程序,并在之后按需加载模块。如果您在最后一刻从磁盘加载模块——当用户单击关联的 MenuItem 时——用户等待的时间将似乎是适当且分散的。

模块

现在是好玩的部分!模块当然是您的应用程序实际执行操作的地方,并且没有正确编程模块的方法,因为根据我们的定义,模块很大程度上是未知的。不过,希望您、您的同事或您的客户在为您的 shell 开发时,至少能坚持您的应用程序的主题,而不是将一个“每日笑话”模块添加到严肃的会计套件中。当您将接口暴露给世界时,这确实是一个风险——所以请牢记这一点。

Shell 的开头非常相似,MyModule 声明了它打算实现 IModule 接口。如前所述,我的 shell 的模块将是 UserControls,因此它们继承该类,但如果我选择将 Shell 实现为 MDI 父窗口,它们也可以同样合理地是 Forms。以下是我为 MyModule 实现 IModule 的版本。

public class MyModule : System.Windows.Forms.UserControl, IModule
{
    private IShell _shell = null;

    public void Initialize(IShell shell)
    {
        _shell = shell;
        this.Visible = false;
        this.Parent = (Form)shell;
    }

    public void Activate()
    {
        AddShellButtons();
        _shell.TheToolBar.ButtonClick += new 
                        ToolBarButtonClickEventHandler(OnToolBarButtonClick);
        _shell.TheToolBar.MouseEnter += new EventHandler(OnToolBarMouseEnter);
        _shell.TheToolBar.MouseLeave += new EventHandler(OnToolBarMouseLeave);
        this.Visible = true;
    }

    public void Deactivate()
    {
        RemoveShellButtons();
        _shell.TheToolBar.ButtonClick -= new 
                         ToolBarButtonClickEventHandler(OnToolBarButtonClick);
        _shell.TheToolBar.MouseEnter -= new EventHandler(OnToolBarMouseEnter);
        _shell.TheToolBar.MouseLeave -= new EventHandler(OnToolBarMouseLeave);
        this.Visible = false;
    }

    public new void Move(Point topLeft, Point bottomLeft)
    {
        this.Top = topLeft.Y;
        this.Left = topLeft.X;
        this.Height = bottomLeft.Y - topLeft.Y;
        this.Width = bottomLeft.X - topLeft.X;
    }
}

这个模块需要被通知的重要事件是它的实例化、激活或启用、相应的去激活或禁用,以及移动或调整大小的事件。您的模块可能需要通过 IShell 获取其他消息,但这取决于您来确定。如上所述,我鼓励您尝试将 IShellIModule 的通信限制在所有 IModule 实现者都需要的内容,或者所有 IModules 都拥有的内容。我知道从经验来看,这并不总是 100% 可能,但这是一个值得称赞的目标。

我的其余模块只是为了好玩,但它们是您赚钱的地方。MyModule 在不同的颜色下将一些莎士比亚的作品写入一个 RichTextBox,具体取决于您单击哪个按钮。YourModule 启动了一个指数级减速的 ProgressBar——不要等它完成!——它显示了后台处理可以在不同模块中进行(当“Null Inactive Mods”禁用时)。请记住,当您适当封装 IShell 时,您将需要以略微不同的方式连接事件:考虑在您的 IShell 接口中公开事件;这曾是我的首选方法。

文档

如果可能在接口中放置一些默认实现,我想添加的是 Initialize(IShell shell) 中的代码。我想依赖所有模块都记住这个 shell 是它们的父级并变得不可见。根据设计,这在接口中是不可能的,因此,我强烈建议在您的代码附近留下一些文档类型的构件。

此外,有些事情可能对为您的 shell 编码的开发人员来说并不明显……例如,可能不容易看出程序员负责在模块停用时进行清理(尽管程序员当然会记得在激活时向 ToolBar 添加按钮!)。当然,编写 FunkyModule 的人最终会意识到 ToolBar 变得有点混乱,因为它添加了模块按钮,但为什么不为开发人员节省调试时间并记录下来呢?更不容易想到的是,模块可能需要从事件消息队列中取消订阅!当然,出于礼貌,模块在停用时应该隐藏自己。如果您将这些写成一个简短的待办事项列表,您会让很多人更开心。

摘要

一旦您知道了窍门——合同(接口)编程——这真的不难。只要您能提前知道一个对象将要对另一个对象说什么做什么,您就可以将其放入接口定义中,并根据需要实现它。最终,使用这种模型您可以做的事情没有限制,而且我已经在比 shell 和模块低得多的级别上应用了它。希望这将成为您工具包中一个非常有用的未来工具。

如有任何问题、评论和批评,请发送电子邮件至 todd.sprang@cox.net。祝您好运,编码愉快!

更改历史

  • 08.12.03, 08.18.03 - 初稿
© . All rights reserved.