CloudNotes之桌面客户端篇:插件系统的实现

CloudNotes版本更新历史与各版本下载地址请点击此处

CloudNotes中文系列文章汇总列表请点击此处

查看CloudNotes源代码请点击此处

有时候,同一个名词,针对不同的人群,应该采用不同的表达方式。比如插件的概念,对于程序员而言,可以将其称为插件,或者扩展。对于用户而言,或许“扩展功能”一词会更加贴切。本文还是脱离不了码农的气质,继续讨论技术问题,因此,我会以“插件”一词进行描述。

概述

1.0.5504.38654版本开始,CloudNotes桌面客户端可以支持插件了。不仅该版本默认附带了三个插件,而且开发人员还能非常方便地使用Visual Studio 2013/2015,基于.NET Framework 4.5.1,为CloudNotes开发自己的插件。本文将首先介绍插件系统的设计。需要注意的是,目前插件部分的实现,仅仅是针对CloudNotes的桌面客户端,今后随着CloudNotes的不断更新和改进,可能会添加诸如Windows Phone、Web等客户端,这些客户端不在本文讨论范围之内。

有图有真相。先看几张新版本的截图吧。首先是在“工具”菜单中多出了一个“从网页导入”的菜单项。通过该菜单项,用户可以直接输入需要导入的页面的URL地址,然后CloudNotes会将此页面保存为当前用户的一条笔记。笔记标题即为页面的标题,如果无法识别页面标题,或者标题已经存在,该功能还会提示用户指定一个新的标题:

image

其次,在“文件”菜单中出现了“另存为”菜单项。别小看这个普普通通的“另存为”,它可是以插件的形式实现的。

image

点击此菜单项,会打开标准的“另存为”对话框,用来将当前打开的笔记保存到本地磁盘文件。至于以何种格式的文件进行保存,就是由插件来实现了。在1.0.5504.38654版本中,默认自带的两个导出型插件,可以分别将当前笔记保存为纯文本格式和HTML格式。

此外,CloudNotes桌面客户端还为插件配置提供了丰富的界面,比如用户可以自己决定如何在“工具”菜单中显示插件菜单项,还可以对每个插件进行单独配置:

image

接下来就让我们一起看看,在CloudNotes中,插件系统是如何设计实现的。

设计与实现

首先可以考虑到的是,既然我们要为应用程序开发插件,那么我们就需要能够通过一套机制,更确切地说是一套接口,将一部分应用程序的功能委托给插件进行处理,这样做的理由是显而易见的。比如,在CloudNotes桌面客户端中,会为插件提供导入笔记的接口,插件只需要从另一些途径获得了笔记的内容,就可以使用这个接口将笔记导入到CloudNotes中;其次,插件是可以动态加载的,这也是插件的基本特性之一,那么插件的加载和管理,就成为插件系统实现的另一个话题;再次,插件是可以配置的,我们的设计需要对插件的可配置性进行考虑,这就需要包含以下三个内容:1、要能够方便地提供插件配置界面,2、要能够方便地打开或保存插件的配置信息,3、要能够为插件的开发提供配置系统的应用程序接口。下面就从这三个方面出发,详细讲解CloudNotes桌面客户端中插件系统的设计与实现。

插件的分类

相信你已经从上面的截图中了解到,在CloudNotes桌面客户端中,插件分为两种类型:工具型和导出型。导出型功能相对单一:只负责将当前笔记导出成一个本地文件,而工具型插件所能实现的功能就比较多了。

image

技术上看,两种插件类型都是Extension抽象类的子类,在后续的系列文章中,我会详细介绍如何使用CloudNotes桌面客户端的扩展框架,分别开发工具型和导出型插件。

Shell

Shell是CloudNotes桌面客户端插件系统所引入的第一个概念,Shell在计算机领域中的英文本意是“外壳程序”的意思,在此表述了这样一种机制:它提供了应用程序的基础功能,却隐含了另一部分的具体实现。这个概念听起来有点抽象,然而在我们的插件系统中,所有的插件操作对象,就是这个Shell。

CloudNotes.DesktopClient.Extensibility命名空间下,定义了IShell接口,它表述所有实现了该接口的类,均是CloudNotes桌面客户端的外壳程序。在该接口中,我们定义了能够提供给插件所使用的各种属性与方法,比如ImportNote方法,它可以实现笔记的导入,还有Note属性,它包含了当前选中的笔记的内容。下面的类图描述了IShell接口以及与之相关的类之间的关系:

image

由此可见,CloudNotes桌面客户端的主窗体FrmMain就是一个壳(Shell),而插件的Execute方法会使用IShell接口进行所需的操作,也就是说,插件会调用FrmMain中由IShell接口定义的属性和方法。

每当FrmMain窗体初始化的时候,它会调用InitializeExtensions方法对所有插件进行初始化,主要工作就是生成菜单项,如果当前所加载的插件是导出型插件的话,在该方法中就会针对“另存为”对话框生成相应的文件格式过滤器。从下面这段代码可以看到,当某个插件菜单项被点击的时候,与该菜单项关联的插件将被执行,而当前FrmMain窗体的实例,则会以Execute方法的参数形式传递给插件执行逻辑。

extensionToolStrip.Click +=
    (s, e) => { SafeExecutionContext.Execute(this, () => toolExtension.Execute(this)); };

Shell其实是一个比较老的概念,根据维基百科中所述,计算机中的Shell是指操作系统提供的一组用户界面(可以是文本的,也可以是图形化的),用户通过这些界面与操作系统交互。在CloudNotes桌面客户端中,借用了这个概念,为插件提供了与CloudNotes桌面客户端的交互界面。

插件管理器(Extension Manager)

在CloudNotes桌面客户端中,插件是由插件管理器负责加载并管理的。插件管理器的功能其实很简单,主要部分就是插件的装载:扫描指定路径下所有的DLL文件,发现如果是合理的.NET程序集,并且其中包含插件类型定义时,就会使用反射,获取插件类型并实例化插件,最后将其保存在本地的一个字典集合里以备使用。这部分代码被定义在CloudNotes.DesktopClient.Extensibility命名空间下的ExtensionManager类中,相对还是比较简单的:

public void Load()
{
    var extensionFiles = Directory.EnumerateFiles(this.path, Constants.ExtensionFileSearchPattern,
        SearchOption.AllDirectories);
    foreach (var extensionFile in extensionFiles)
    {
        try
        {
            var assembly = Assembly.LoadFrom(extensionFile);
            foreach (var type in assembly.GetExportedTypes())
            {
                try
                {
                    if (type.IsDefined(typeof (ExtensionAttribute)) &&
                        type.IsSubclassOf(typeof (Extension)))
                    {
                        var extensionLoaded = (Extension) Activator.CreateInstance(type);
                        this.OnExtensionLoaded(extensionLoaded.Name);
                        this.extensions.Add(extensionLoaded.ID, extensionLoaded);
                    }
                }
                catch
                {
                }
            }
        }
        catch
        {
        }
    }
}

在此就不对这部分代码作过多解释了。引入插件管理器的一个最大好处就是,可以保证插件在整个CloudNotes桌面客户端的生命周期中只被装载一次,并且能够很方便地在多个组件之间共享:

  • FrmMain窗体:需要通过插件管理器将所加载的插件显示到用户界面,并触发插件的执行
  • FrmAbout窗体:需要通过插件管理器获取所加载的插件的详细信息,并显示给用户
  • FrmSettings窗体:需要通过插件管理器获取所加载插件的配置信息,并为用户提供插件配置的功能
  • SingleInstanceController:在该类型中初始化插件管理器,并加载插件

接下来的话题就是,何时启动插件的加载过程?插件的加载其实有很多种方式,相对而言,以下两种方式最为简单常见:

  • 提供一个启动界面,在应用程序启动的时候加载插件,并在启动界面上显示加载情况:这种方式最为常见,实现也很简单。很多应用程序都使用这种方式来完成应用程序初始化和插件加载的过程。然而对于CloudNotes来说,并没有采取这种方式。首先,单独设定一个启动界面会让用户感觉到CloudNotes桌面客户端很“大”(或者说很“重”),显得并不轻量,因为往往都是一些大型的应用程序(Word、Excel、Photoshop、AutoCAD等等)才会有这样一个专业的启动界面;其次,老版本的CloudNotes桌面客户端没有提供启动界面,突然出现一个启动界面会让老用户感觉突兀(别笑我,估计也没几个老用户,但这也是做应用程序设计和开发的时候必须考虑的一个因素);再次,对于CloudNotes桌面客户端而言,插件的加载过程还是相当快速的,一方面并没有成千上万的插件需要加载,另一方面,插件的加载逻辑也相对简单,所以插件加载过程应该是一个秒级的操作,引入启动界面倒还显得多余。因此,CloudNotes桌面客户端采用了下面一条所述的方式
  • 在某个长时操作的同时加载插件:关键是选择一个长时操作的时间点,从该时间点开始,异步地将插件加载到内存中。通过简单分析,不难发现在CloudNotes桌面客户端中,登录界面就是一个长时操作,在这个界面下,CloudNotes桌面客户端会等待用户输入用户名和密码,在点击“确定”按钮后,会联系服务器进行登录认证。整个操作过程所花费的时间将远远大于插件的加载时间,因此,在登录界面启动时,异步加载插件是顺理成章的事

CloudNotes桌面客户端登录界面的启动是由LoginProvider负责的,而SingleInstanceController使用LoginProvider完成用户登录功能。SingleInstanceController确保在系统中仅有一个CloudNotes桌面客户端的实例在运行,这在今后我会介绍。SingleInstanceController的OnCreateMainForm重载方法实现了扩展加载的逻辑:

protected override void OnCreateMainForm()
{
    var extensionManager = new ExtensionManager();
    var settings = DesktopClientSettings.ReadSettings();
    var loadExtensionTask = Task.Factory.StartNew(() =>
    {
        // As the extensions are loaded in another thread, setting that thread's ui culture
        // to the one read from the setting preference.
        Thread.CurrentThread.CurrentUICulture = new CultureInfo(settings.General.Language);
        extensionManager.Load();
    });


    Thread.CurrentThread.CurrentUICulture = new CultureInfo(settings.General.Language);

    var credential = LoginProvider.Login(Application.Exit, settings);
    if (credential != null)
    {
        Task.WaitAll(loadExtensionTask);
        // Instantiate your main application form
        this.MainForm = new FrmMain(credential, settings, extensionManager);
    }
}

在上面的代码中,使用了.NET并行库TPL的Task Factory来启动一个并行任务,在任务的执行体中,调用extensionManager的Load方法,开始加载插件。接下来,就会由LoginProvider负责用户的登录过程,当登录过程结束之后,当前线程会阻塞在Task.WaitAll这行代码,等待插件完全加载完成,最后就会初始化并启动FrmMain。根据上面的分析,通常情况下插件加载过程是很快的,因此,事实上99%的情况下,此处Task.WaitAll调用并不会阻塞,用户体验仍然那么流畅,不耽误事儿。

通过这部分内容,我们可以了解到,软件开发过程需要综合性地考虑很多事情,不仅仅是将关注点放在功能上,那些非功能性需求也无时不刻地需要我们的“关怀”,并且,这些看似不起眼的非功能性需求,往往又是开发的难点(比如高性能需求、严格的安全认证机制等),甚至直接影响项目和产品的成败。

插件配置

为CloudNotes桌面客户端插件提供灵活的、可扩展的插件配置系统,是插件这个课题的难点。因为不仅需要考虑到最终用户的体验,而且还要考虑到插件开发人员的感受。因此,CloudNotes特别提供了插件配置框架,保证能够体现插件配置系统的上述两种职能。image

也正如上文截图中所示,在CloudNotes桌面客户端的标准配置界面中,新增了“扩展功能”选项卡,它列出了目前加载的所有插件,当用户单击左边的插件时,与之相关的配置界面会显示在对话框的右边部分。由此分析,配置界面应该是插件的一个属性。既然有了配置界面,就需要有与之对应的配置数据,更进一步,插件开发人员还应该能够为插件提供默认的配置数据,例如我们可以让用户选择笔记保存所使用的编码(Encoding),但默认应该使用UTF-8的Encoding。讨论到此处,MVC模式慢慢浮现出来,或许我们可以借用MVC模式的概念,并提供一个机制,能够将配置数据绑定到配置界面,或者从配置界面把配置数据收集起来以便保存到配置文件。为了将“配置”的关注点从插件上分离开来,CloudNotes插件配置框架引入了“插件配置供应器”(Extension Setting Provider)的概念,它包含了配置界面、配置数据、默认配置的信息,并且提供从配置界面收集配置数据和将配置数据绑定到配置界面的方法。

CloudNotes.DesktopClient.Extensibility命名空间下ExtensionSettingProvider类就是插件配置供应器,它的代码在此就不再重复了。

ExtensionAttribute特性中,包含了指定ExtensionSettingProvider的构造函数重载,当ExtensionAttribute被应用到Extension的子类时,Extension的SettingProvider属性就会根据ExtensionAttribute特性中指定的ExtensionSettingProvider类型来获取它的实例,进而完成了插件对配置框架的聚合,也为CloudNotes桌面客户端访问插件配置提供了桥梁。不过值得一提的是,Extension类的SettingProvider属性采用了一种类似缓存的机制,仅做一次ExtensionSettingProvider的初始化,这是因为ExtensionSettingProvider有可能会保持那些用户改变过但没有保存的配置数据。

接下来的事情就比较简单了,在FrmSettings窗体代码中,BindExtension方法会判断当前选中的插件是否指定了插件配置供应器,如果没有指定,则简单地初始化一个NoSettingsControl实例,将其显示在界面右侧,告知用户“该扩展功能未提供任何可供设置的选项”。否则,将插件配置供应器所提供的用户界面控件显示在界面上,并查看本地字典缓存中是否有已经更改过的配置数据。若有,则将该数据绑定到界面上,否则就将从配置文件中读入配置信息(若无,则取默认配置信息),并绑定到用户界面上。

private void BindExtension(Guid extensionId)
{
    var extension = this.extensionManager.GetByKey(extensionId);
    pnlSettings.Controls.Clear();
    if (extension.SettingProvider == null)
    {
        var noSettingsControl = new NoSettingsControl();
        noSettingsControl.Dock = DockStyle.Fill;
        pnlSettings.Controls.Add(noSettingsControl);
    }
    else
    {
        pnlSettings.Controls.Add(extension.SettingProvider.SettingControl);
        if (cachedSettings.ContainsKey(extensionId))
        {
            extension.SettingProvider.BindSetting(cachedSettings[extensionId]);
        }
        else
        {
            extension.SettingProvider.BindSetting(extension.SettingProvider.ExtensionSetting);
        }
    }
}

上面代码中cachedSettings字典保存了用户此次打开系统配置窗体后,对插件所做的配置更改,这是为了能够在用户浏览各个插件配置的过程中,保持每个插件之前更改过的配置值。当界面左边所选中的插件发生变化时,窗体会清除之前所选插件的配置界面,并显示当前所选插件的配置界面。而在清除之前所选插件的配置界面时,该插件的相关配置信息将会被缓存下来:

private void lvExtensions_SelectedIndexChanged(object sender, EventArgs e)
{
    if (this.lvExtensions.SelectedItems.Count > 0)
    {
        var item = this.lvExtensions.SelectedItems[0];
        var extensionId = (Guid) item.Tag;
        this.BindExtension(extensionId);
    }
}

private void pnlSettings_ControlRemoved(object sender, ControlEventArgs e)
{
    if (e.Control.Tag != null)
    {
        var extension = e.Control.Tag as Extension;
        if (extension != null && extension.SettingProvider != null)
        {
            var setting = extension.SettingProvider.CollectedSetting;
            this.cachedSettings[extension.ID] = setting;
        }
    }
}

最后,当用户单击“确定”按钮时,所有插件的配置数据将被保存下来:

foreach (var extension in this.extensionManager.AllExtensions)
{
    var settingProvider = extension.Value.SettingProvider;
    if (settingProvider != null)
    {
        settingProvider.PersistSettings();
    }
}

在下一篇关于插件的开发文章中,我还会详细介绍如何为自定义的插件设计并实现配置功能。相信到那时读者朋友应该对插件系统的实现会有个更好的了解。

总结

本文首先展示了CloudNotes桌面客户端新版本对插件系统的支持,然后简单介绍了CloudNotes桌面客户端中插件的分类,并通过Shell、插件管理器和插件配置三个部分,对插件系统的设计与实现进行了必要的介绍。篇幅有限,没有办法在文章中对技术实现的每个细节进行完美解释,读者可以在参考源代码的同时阅读本文,如有问题可以直接留言。在接下来的文章中,我会介绍如何使用Visual Studio 2013/2015开发和调试CloudNotes桌面客户端的插件。相信到那时候,读者对插件系统的设计与实现会有更深的认识。

posted @ 2015-02-25 22:06  dax.net  阅读(2735)  评论(9编辑  收藏  举报