11

【.NET 深呼吸】.net core 中的轻量级 Composition

 3 years ago
source link: https://www.cnblogs.com/tcjiaan/p/9612593.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

【.NET 深呼吸】.net core 中的轻量级 Composition

记得前面老周写过在.net core 中使用 Composition 的烂文。上回老周给大伙伴们介绍的是一个“重量级”版本—— System.ComponentModel.Composition。应该说,这个“重量级”版本是.NET 框架中的“标配”。

很多东西都会有双面性,MEF 也一样,对于扩展组件灵活方便,同时也带来性能上的一些损伤。但这个损伤应只限于应用程序初始化阶段,一般来说,我们也不需要频繁地去组合扩展,程序初始化时执行一次就够了。所以,性能的影响应在开始运行的时候。

与“重量级”版本在同一天发布的,还有一个“轻量级”版本—— System.Composition。相对于“标配”,这个库简洁了许多,与标准 MEF 相比,使用方法差不多,只是有细微的不同,这个老周稍后会讲述的,各位莫急。还有一个叫 Microsoft.Composition 的库,这个是旧版本的,适用于 Windows 8/8.1 的应用。对于 Core,可以不考虑这个版本。

System.Composition 相对于标准的 MEF,是少了一些功能的,尤其是对组件的搜索途径,MEF 的常规搜索途径有:应用程序范围、程序集范围、目录(文件夹)范围等。而“轻量级”版本只在程序集范围中搜索。这也很适合.net core 程序,尤其是 Web 项目。

好了,以上内容皆是纸上谈 B,下面咱们说干货。

1、安装需要的 NuGet 包

虽然在官方 docs 上,.net core API 目录收录了 System.Composition ,但默认安装的 .net core 库中是不包含 System.Composition 的,需要通过 Nuget 来安装。在 Nuget 上搜索 System.Composition,你会看到有好几个库。

367389-20180909091927627-1781747120.png

那到底要安装哪个呢?很简单,选名字最短那个,其他几个因为存在依赖关系,会自动安装的。

这里老周介绍用命令来安装,很方便。在 VS 主窗体中,打开菜单【工具】-【NuGet 包管理器】-【程序包管理器控制台】,这样你就打开了一个命令窗口,然后输入:

 Install-Package System.Composition

需要说的,输入的内容是不区分大小写的,你可以全部输入小写。这风格是很 PowerShell 的,这个很好记,PS 风格的命令都是“动词 + 名词”,中间一“减号”,比如,Get-Help。所以,安装的单词是 Install,程序包是 Package,安装包就是 Install-Package,然后你可以猜一下,那么卸载 Nuget 包呢,Uninstall-Package,那更新呢,Update-Package,查找包呢,Find-Package……

你要是不信,可以执行一下 get-help Nuget 看看。

好了,执行完对 System.Composition 的安装,它会自动把依赖的库也安装。

367389-20180909092909692-1006794803.png

不带其他参数的 install-package ,默认会安装最新版本的库,所以说,执行这个来安装很方便。

2、导出类型

类型的导出方法与标准的 MEF 一样的,比如这样。

    [Export]
    public class FlyDisk
    {

    }

于是,这个 FlyDisk 类就被导出了。你也可以为导出设置一个协定名,在合并组件后方便挑选。

    [Export("fly")]
    public class FlyDisk
    {

    }

当然了,如果你的组件扩展模式是 接口 + 实现,通常为了兼容和规范,应该有个接口。这时候你标注 Export 特性时,要指明协定的 Type。

    [Export(typeof(IPerson))]
    public class BaiLei : IPerson
    {
        public string Name => "败类";
    }

如果你希望更严格地约束导入和导出协定,还可以同时指定 Name 和 Type。

    [Export("rz", typeof(IPerson))]
    public class RenZha : IPerson
    {
        public string Name => "人渣";
    }

3、构建容器

在组装扩展时,需要一个容器,用来导入或收集这些组件,以供代码调用。在“轻量级”版本中,容器的用法与标准的 MEF 区别较大,MEF 中用的是 CompositionContainer 类,但在 System.Composition 中,我们需要先创建一个 ContainerConfiguration,然后再创建容器。容器由 CompositionHost 类表示。

来,看个完整的例子。首先是导出类型。

    public interface IPerson
    {
        string Name { get; }
        void Work();
    }

    [Export(typeof(IPerson))]
    public class BaiLei : IPerson
    {
        public string Name => "败类";

        public void Work()
        {
            Console.WriteLine("影响市容。");
        }
    }

然后,创建 ContainerConfiguration。

   ContainerConfiguration config = new ContainerConfiguration().WithAssembly(Assembly.GetExecutingAssembly());

ContainerConfiguration  类的方法,调用风格也很像 ASP.NET Core,WithXXX 方法会把自身实例返回,以方便连续调用。上面代码是设置查找扩展组件的程序集,这里我设定为当前程序集,如果是其他程序集,可以用 Load 或 LoadFrom 方法先加载程序集,然后再调用 WithAssembly 方法,原理差不多。

随后,便可以创建容器了。

            using(CompositionHost host = config.CreateContainer())
            {

            }

调用 GetExport 方法可以直接获取到导出类型的实例。

            using(CompositionHost host = config.CreateContainer())
            {
                IPerson p = host.GetExport<IPerson>();
                Console.Write($"{p.Name},");
                p.Work();
            }

那,如果某个协定接口有多个实现类导出呢。咱们再看一例。

首先,定义公共的协定接口。

    public interface ICD
    {
        void Play();
    }

再定义两个导出类,都实现上面定义的接口。

    [Export(typeof(ICD))]
    public class DbCD : ICD
    {
        public void Play()
        {
            Console.WriteLine("正在播放盗版 CD ……");
        }
    }

    [Export(typeof(ICD))]
    public class BlCD : ICD
    {
        public void Play()
        {
            Console.WriteLine("正在播放蓝光 CD ……");
        }
    }

然后,跟前一个例子一样,创建 ContainerConfiguration 实例,再创建容器。

            Assembly curAssembly = Assembly.GetExecutingAssembly();
            ContainerConfiguration cfg = new ContainerConfiguration();
            cfg.WithAssembly(curAssembly);
            using(CompositionHost host = cfg.CreateContainer())
            {
                ……
            }

接下来就是区别了,因为实现 ICD 接口并且标记为导出的类有两个,所以要调用 GetExports 方法。

            using(CompositionHost host = cfg.CreateContainer())
            {
                IEnumerable<ICD> cds = host.GetExports<ICD>();
                foreach (ICD c in cds)
                    c.Play();
            }

返回来的是一个 ICD (实际是 ICD 的实现类,但以 ICD 作为约束)列表,然后就可以逐个去调用了。结果如下图所示。

367389-20180909102103919-417719974.png

4、导入类型

导入的时候,除了调用 GetExport 方法外,还可以定义一个类,然后把类中的某个属性标记为由导入的类型填充。

看例子。先上接口。

    public interface IAnimal
    {
        void Eating();
    }

然后上实现类,并标为导出类型。

    [Export(typeof(IAnimal))]
    public class Dog : IAnimal
    {
        public void Eating()
        {
            Console.WriteLine("狗吃 Shi");
        }
    }

定义一个类,它有一个 MyPet 属性,这个属性由 Composition 来导入类型实例,并赋给它。

    public class PeopleLovePets
    {
        [Import]
        public IAnimal MyPet { get; set; }
    }

注意有一点很重要,MyPet 属性上一定要加上 Import 特性,因为 Composition 在组装类型时会检测是否存在 Import 特性,如果你不加的话,扩展组件就不会导入到 MyPet 属性上的。

接着,创建容器的方法与前面一样。

            ContainerConfiguration cfg = new ContainerConfiguration()
                .WithAssembly(Assembly.GetExecutingAssembly());
            PeopleLovePets pvl = new PeopleLovePets();
            using(var host = cfg.CreateContainer())
            {
                host.SatisfyImports(pvl);
            }

但你会看到有差别的,这一次,要先创建 PeopleLovePets 实例,后面要调用 SatisfyImports 方法,在 PeopleLovePets 实例上组合导入的类型。

最后,你通过 MyPet 属性就能访问导入的对象了,以 IAnimal 为规范,实际类型是 Dog。

            IAnimal an = pvl.MyPet;
            an.Eating();

那,如果导出的类型是多个呢,这时就不能只用 Import 特性了,要用 ImportMany 特性,而且接收导入的 MyPet 属性要改为 IEnumerable<IAnimal>,表示多个实例。

    public class PeopleLovePets
    {
        [ImportMany]
        public IEnumerable<IAnimal> MyPet { get; set; }
    }

为了应对这种情形,我们再添加一个导出类型。

    [Export(typeof(IAnimal))]
    public class Cat : IAnimal
    {
        public void Eating()
        {
            Console.WriteLine("猫吃兔粮");
        }
    }

创建容器和执行导入的处理过程都不变,但访问 MyPet属性的方法要改了,因为现在它引用的不是单个实例了。

            foreach (IAnimal an in pvl.MyPet)
                an.Eating();

5、导出元数据

元数据不是类型的一部分,但可以作为类型的附加信息。有些时候是需要的,尤其是在实际使用时,Composition 组合它所找到的各种扩展组件,但在调用时,可能不会全部都调用,需要筛选出需要调用的那部分。

为导出类型添加元数据有两种方法。先说第一种,很简单,直接在导出类型上应用 ExportMetadata 特性,然后设置 Name 和 Value,每个 ExportMetadataAttribute 实例就是一条元数据,你会发现,它其实很像 key / value 结构。

看个例子,假设有这样一个公共接口。

    public interface IMail
    {
        void ReadBody(string from);
    }

然后有两个导出类型。

    [Export(typeof(IMail))]
    public class MailLoader1 : IMail
    {
        public void ReadBody(string from)
        {
            Console.WriteLine($"Pop3:来自{from}的邮件");
        }
    }

    [Export(typeof(IMail))]
    public class MailLoader2 : IMail
    {
        public void ReadBody(string from)
        {
            Console.WriteLine($"IMAP:来自{from}的邮件");
        }
    }

这两种类型所处理的逻辑是不同的,第一个是通过 POP3 收到的邮件,第二个是通过 IMAP 收到的邮件。为了在导入类型后能够进行判断和区分,可以为它们分别附加元数据。

    [Export(typeof(IMail))]
    [ExportMetadata("prot", "POP3")]
    public class MailLoader1 : IMail
    {
       ……
    }

    [Export(typeof(IMail))]
    [ExportMetadata("prot", "IMAP")]
    public class MailLoader2 : IMail
    {
       ……
    }

在导入带元数据的类型时,可以用到这个类——Lazy<T, TMetadata>,它是 Lazy<T> 的子类,类如其名,就是延迟初始化的意思。

定义一个 MailReader 类,公开一个 Loaders 属性。

    public class MailReader
    {
        [ImportMany]
        public IEnumerable<Lazy<IMail, IDictionary<string, object>>> Loaders { get; set; }
    }

注意这里,Lazy 的 TMetadata,默认的实现,通过 IDictionary<string, object> 是可以存储导入的元数据的。上面咱们也看到,元数据在导出时,是以 Name / Value 的方式指定的,相当类似于字典的结构,所以,用字典数据类型自然就能存放导入的元数据。

执行导入的代码就很简单了,跟前面的例子差不多。

            ContainerConfiguration cfg = new ContainerConfiguration()
                .WithAssembly(Assembly.GetExecutingAssembly());
            MailReader mlreader = new MailReader();
            using(CompositionHost host = cfg.CreateContainer())
            {
                host.SatisfyImports(mlreader);
            }

这时候,我们在访问导入的类型时,就可以根据元数据进行筛选了。

在这个例子中,咱们只调用带 IMAP 的邮件阅读器。

            IMail m = (from o in mlreader.Loaders
                       let t = o.Metadata["prot"] as string
                       where t == "IMAP"
                       select o).First().Value;
            m.ReadBody("[email protected]");

最后调用的结果如下

IMAP:来自[email protected]的邮件

当然了,元数据还有更高级的玩法,你要是觉得附加 N 条 ExportMetadata 特性太麻烦,你还可以自己定义一个类来包装,注意在这个类上要标记  MetadataAttribute 特性,而且从 Attribute 类派生。为啥呢?因为元数据是不参与类型逻辑的,你要把它附加到类型上,只能作为 特性 来处理。

    [AttributeUsage(AttributeTargets.Class)]
    [MetadataAttribute]
    public class ExtMetadataInfoAttribute : Attribute
    {
        public string Remarks { get; set; }
        public string Author { get; set; }
        public string PublishTime { get; set; }
    }

之后,就可以直接应用到导出类型上面了。

    public interface ITest
    {
        void RunTask();
    }

    [Export(typeof(ITest))]
    [ExtMetadataInfo(Author = "单眼明", PublishTime = "2018-9-18", Remarks = "已 debug 了 71125 次")]
    public class DemoComp : ITest
    {
        public void RunTask()
        {
            Console.WriteLine("Demo 组件被调用");
        }
    }

    [Export(typeof(ITest))]
    [ExtMetadataInfo(Author = "大神威", PublishTime = "2018-10-5", Remarks = "预览版")]
    public class PlainComp : ITest
    {
        public void RunTask()
        {
            Console.WriteLine("Plain 组件被调用");
        }
    }

导入时,同样可以 import 到一个属性中。

    public class MyAppPool
    {
        [ImportMany]
        public IEnumerable<Lazy<ITest, IDictionary<string, object>>> Components { get; set; }
    }

创建容器的方法一样。

            ContainerConfiguration cfg = new ContainerConfiguration()
                .WithAssembly(Assembly.GetExecutingAssembly());
            MyAppPool pool = new MyAppPool();
            using(var host = cfg.CreateContainer())
            {
                host.SatisfyImports(pool);
            }

尝试枚举出导入类型的元数据。

            foreach (var ext in pool.Components)
            {
                var metadata = ext.Metadata;
                Console.WriteLine($"{ext.Value.GetType()} 的元数据:");
                foreach (var kv in metadata)
                {
                    Console.WriteLine($"{kv.Key}: {kv.Value}");
                }
                Console.WriteLine();
            }

执行结果如下图。

367389-20180909115433735-1686897093.png

要是你觉得用 IDictionary<string, object> 类型来存放导入的元数据也很麻烦,那你也照样可以定义一个类来存放,但这个类要符合两点:a、带有无参数的公共构造函数,因为它是由 Composition 内部来实例化的;b、属性必须是公共并且有 get 和 set 访问器,即可写的,不然没法设置值了,而且属性名必须与导出时的元数据名称相同。

现在我们改一下刚刚的例子,定义一个类来存放导入的元数据。

    public class ImportedMetadata
    {
        public string Author { get; set; }
        public string Remarks { get; set; }
        public string PublishTime { get; set; }
    }

然后,MyAppPool 类也可以改一下。

    public class MyAppPool
    {
        //[ImportMany]
        //public IEnumerable<Lazy<ITest, IDictionary<string, object>>> Components { get; set; }
        [ImportMany]
        public IEnumerable<Lazy<ITest, ImportedMetadata>> Components { get; set; }
    }

最后,枚举元数据的代码也改一下。

            foreach (var ext in pool.Components)
            {
                var metadata = ext.Metadata;
                Console.WriteLine($"{ext.Value.GetType()} 的元数据:");
                Console.WriteLine($"Author: {metadata.Author}\nRemarks: {metadata.Remarks}\nPublishTime: {metadata.PublishTime}");
                Console.WriteLine();
            }

====================================================================

好了,关于 System.Composition,今天老周就介绍这么多,内容也应该覆盖得差不多了。肚子饿了,准备开饭。 


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK