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

可扩展和可移植的框架

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.77/5 (11投票s)

2015 年 2 月 27 日

CPOL

18分钟阅读

viewsIcon

24672

downloadIcon

441

演示三个作为可移植类库构建的可扩展框架。

背景

多年来,我撰写了一些文章,讨论了架构问题,这些问题非常普遍,以至于经常被当作“良好实践”,尤其因为它们存在于大型且知名的框架中。

在这些文章中,我通常会选取一个知名的框架来解释问题并提出解决方案,但我很少能让这些解决方案真正起作用,原因是我无法更改第三方闭源库。当无法将解决问题的代码行放在正确的位置时,即使问题可以通过一行代码解决,也没有什么意义。

我认为大多数框架最大的缺陷是缺乏委托或委托使用不当,这直接影响了它们的扩展性和与外部对象交互的能力。

例如,.NET 中的 Convert 类似乎是解决对象类型转换问题的方案,但实际上它只能执行一些基本的转换。它的“扩展”支持需要类型自己实现 IConvertible 来提供转换,而无法“附加”转换到已有的类型。这限制了我们可以依赖 Convert 的地方,因为我们可能需要将枚举转换为其他类型(枚举不能实现接口),或者我们拿到的是易于转换但未实现 IConvertible 且我们无法更改其源代码的实例。

[TypeConverter] 在这方面有所改进,但本质上类似。转换由另一个类型完成,因此将被转换的对象不需要了解任何关于可能转换的信息,这是一个优点。[TypeConverter] 可以由类型本身选择(类似于直接在类型上实现转换,但仍允许每个类只有一个职责,并适用于枚举),或者可以在需要不同 [TypeConverter] 的属性上设置/替换。然而,[TypeConverter] 也需要知道所有有效的转换,实际上,大多数 [TypeConverters] 只将它们的单个目标类型与 strings 之间进行转换。仅此而已。

如果应用程序可以根据需要简单地注册类型之间的转换,那不是更好吗?例如,我想到 boolVisibility(在 WPF 中经常使用)这样的转换,我希望将其注册为默认转换。

好吧,这就是 可扩展框架 的目的。它们主要是某种操作的容器,并且可以由应用程序完全配置。

引言

本文档随附了三个 可扩展框架。这些框架分别用于 二进制序列化数据类型转换控制反转 (IoC)。它们都拥有非常相似且可扩展的架构。

我之前在其他文章中发布过它们的早期版本,但新版本是作为 可移植类库 构建的,目标平台是 .NET Framework 4.5、Windows 8、Windows Phone 8.1 和 Windows Phone Silverlight 8。它们还遵循易于理解和通过查看主要接口即可重实现的理念。

框架架构概览

考虑到框架的架构非常相似,我将只介绍一次。

所有这些框架都采用“接口优先,实现后置”的模式。字面意义上就是如此。

因此,您会看到每个框架有 2 个库。框架的真正目的是几乎完全抽象的,由一个主接口(根据每个框架是 IIocContainerIConversionContainerIBinarySerializationContainer)、支持的委托类型、特定项的接口(如特定类型的转换器或序列化器)和一个全局入口点表示。这是基础库,只想使用这些框架(而非实例化它们)的代码不需要引用“实现”库,只需要引用这些库(实际上,默认实现根本不需要被使用,完全可以替换……非常适合测试/模拟)。

然后,是包含框架默认实现的库。这些库实际包含一个线程安全的(读操作并发且无锁)容器的实现,一个带有装饰器的线程不安全版本,因此您可以将线程不安全版本变为线程绑定,甚至是线程隔离(每个线程都有自己的实例)。除了由用户应用程序完全配置的控制反转容器外,它们还附带一些默认操作,例如大多数常见类型的项序列化器或最基本的转换。这不是错误,只是最常见和可移植的转换/项序列化器是默认提供的。目的是并非拥有所有转换/序列化器,而是支持为特定项注册新的转换/序列化器,并通过通用 API 访问它们。

所有这些实现库都引用另一个库。特别是,它们需要 ThreadSafeGetOrCreateValueDictionary,这是一种 ConcurrentDictionary 的简化且更安全版本,以及 (ThreadSafe)PriorizableCollection,它用于根据优先级运行搜索器。

注意: 稍后我将解释搜索器的目的。

容器及相关说明

这并不神秘:这些框架之所以可扩展,是因为它们是可以在运行时配置的容器。也就是说,它们本身不执行操作,它们只是允许注册所需的操作/项,这些操作/项可以由完全不相关的类和程序集实现。

然而,当我过去展示可扩展解决方案时,我收到了一些批评,例如

  • “哦,如果我们必须自己编写每一个转换,那将是一场噩梦。拥有具有默认转换的类是很好的。”
  • “你可能从未在大项目里工作过。想象一下,为应用程序中新增的每个可序列化类型注册一个序列化器会有多困难。最好是简单地用 [Serializable] 标记类,让框架为我们完成工作。”

好吧,我从未说过不能使用属性、默认转换,甚至其他提供类型提示的简单方法。我只是指出框架不应该依赖这些。目标类型可能已经准备好被这些框架使用,并具有框架期望的所有特性,或者它们可能根本不知道这些框架,但那并不意味着它们与这些框架不兼容。说我们总是可以创建适配器是远远不够的,因为我们可能会收到“未知”对象,并且需要某种可扩展的适配器生成器来处理它们,所以我们总会需要这种解决方案(而且处理非常大的对象树也会很糟糕)。

无论如何,我提供的默认实现实际上能够序列化带有 [Serializable] 属性的类,或者使用 [TypeConverter]s 进行类型转换,但这并非它们的强项。我可以将它们作为独立的库提供,因为它们与容器的实现方式无关。然而,我确实相信大多数用户会同时需要默认容器和默认操作,因此它们被打包在一起提供。

使用所有默认功能初始化框架

转换和序列化框架附带了一些默认的转换器和项序列化器,但您仍然可以通过以下方式创建一个空容器:

var container = new ThreadSafeConversionContainer();

然后,当然,您可以手动注册默认转换器和搜索器(它们都是命名空间如 Pfz.ExpandableContainers.BinarySerialization.ItemSerializersPfz.ExpandableContainers.BinarySerialization.Searchers 中的公共类)。

当然,我猜这也不是大多数开发人员想要的。因此,如果您的目的是创建一个完全配置好的容器,请使用 ConversionContainerFactory。可以这样完成:

var factory = new ConversionContainerFactory();

//Maybe a call like:
// factory.AvoidRegistering(StringToDecimalConverter.Instance);

GlobalConversionContainer.Instance = factory.CreateThreadSafeContainer();

通过这样做,您无需手动注册所有转换器和搜索器,而是默认选择所有这些,并可以选择排除某些特定的。

我不会展示如何为 二进制序列化 框架执行此操作,但它非常相似。IoC 容器 没有这种工厂,因为 IoC 容器没有默认操作。

直接注册与搜索器

您可能已经注意到,在上一节中我谈到了注册转换和搜索器。

实际上,从 stringint 的转换可以直接注册,如下所示:

container.Register(StringToInt32Converter.Instance);

或者,如果您没有类并且更喜欢通过委托编写,可以这样做:

container.Register<string, int>((str) => int.Parse(str));

但是,如何注册从 IEnumerable<T>T[] 类型的转换,考虑到许多类是 IEnumerable<T> 并且 T 泛型参数可以是任何类型?

没有方法可以注册泛型类型,当查找从 List<int>int[] 的转换器时,注册到 IEnumerable<int> 的内容不会被使用。虽然可以创建专门的方法来注册和搜索抽象或泛型类型,但这可能会使搜索逻辑复杂化并且不完整。如何处理来自外部 DLL 的懒加载转换?是否也需要为此创建专门的方法?

好吧,所有这些都可以通过使用 Searchers 来实现。每次找不到特定输入/输出类型的转换器时,容器会按照优先级顺序(在优先级相同的情况下,按照注册顺序)运行搜索器,以请求给定输入/输出类型的转换器。如果 Searcher 能够加载/查找/创建该特定请求的转换器,只需在搜索器参数中设置该转换器,一切都会正常工作。因此,搜索器可以识别它支持类实现的接口并提供转换器。

我知道,针对泛型类型的特定注册可能比编写搜索器更容易使用。然而,这些可以构建在搜索器之上。例如,无法在泛型注册方法之上编写外部 DLL 搜索器。因此,更具扩展性的解决方案是提供的方案,而其他方案可以在不进行新框架更改的情况下创建。

仅仅为了展示不同的情况,所有 string 到可空类型的转换都由一个 Searcher 支持,该搜索器实际上会查找从 string 到非可空类型的转换器,然后生成一个搜索器,该搜索器首先检查是否为 null,如果不为 null,则使用找到的转换器。请注意此特殊情况,因为它不是简单地从 string 到所有可空类型的注册。只有当存在非可空类型的转换器时,搜索器才会提供结果。

容器的方法

考虑到我们已经看到的关于容器的内容,已知所有容器都有 RegisterRegisterSearcher 方法。

IoC 容器Register 方法允许您指定将在未来搜索中使用的 Type 以及用于创建/返回已注册值的委托。

二进制序列化容器Register 方法允许您注册一个特定的项序列化器(如 intstring 的序列化器)。项序列化器应处理单一类型,而不支持多种类型。

转换容器Register 方法允许您注册一个 Converter,它实际上只处理特定的输入类型和特定的输出类型。

RegisterSearcher 接收一个用于搜索的委托和一个优先级。在这种情况下,委托都接收一个包含所有必要信息(IoC 容器和序列化的请求类型,转换器的输入和输出类型)的 args,并有一个用于设置结果的属性。设置结果不仅会停止搜索(因此较低优先级的搜索器将不会运行),还会缓存结果,这样搜索器就不会因相同的输入参数而运行两次。将 null 设置为结果是有效的,它将强制停止搜索并缓存没有可用结果。

最后,还有容器特有的方法

  • IoC 容器

    Get<T>: 我还能说什么呢?您请求获取一个 T 类型的项。如果已注册委托,则会执行该委托来创建请求类型的新实例或返回可能的单例实例。

    当然,如果未注册,则会运行 Searchers,并且如果提供了委托,则会缓存它并执行它来生成结果。

    如果无法生成结果,将抛出异常。请注意,如果提供了返回 null 的委托,则可以返回 null。可以使用一个非常低的优先级搜索器在找不到值时返回 null,这样就可以避免应用程序中出现异常(如果需要的话)。

  • 转换容器

    TryGetConverter(泛型和非泛型): 目前没有全局的 Convert.ChangeType 等效方法(但很容易在另一个类中创建一个,然后重定向到此调用)。用户期望做的是请求一个从某个类型到另一个类型的转换器,然后使用它来执行一个或多个转换。

    该方法同时有泛型和非泛型版本,因为这两种情况都很常见。我们要么请求将 string 转换为 int(例如),要么通过在运行时提供 Type 对象来请求将此未知类型的对象转换为其他类型。

  • 二进制序列化容器

    这是最复杂的容器。也许一个只有 CreateSerializer 的版本会更容易理解,但考虑到性能甚至安全性,它具有以下方法:

    • CreateSerializer: 创建一个与流绑定的序列化器/反序列化器。请注意,创建序列化器并调用序列化 2 次或多次,其结果可能与每次都创建一个新序列化器不同,因为连续调用之间会发生一种“压缩”。
    • RegisterDefaultDataType: 序列化器和反序列化器必须就默认数据类型达成一致,并按相同顺序注册它们。要么 Type 本身是一个默认数据类型,允许序列化处理任何类型对象所需类型信息,要么所有将要被序列化的类型都必须注册为默认类型。默认类型通过索引发送,而不是通过序列化其类型信息,从而减少代码大小并避免潜在的不安全类型搜索。
    • TryGetItemSerializer(泛型和非泛型): 类似于转换容器中发生的情况,您可能希望获取一个特定的项序列化器,以便可以多次重用它。多次调用特定的项序列化器比总是再次调用 Serialize 要快,后者需要搜索要序列化的类型。

二进制序列化安全

避免使用二进制序列化作为共享机制(无论是本地还是远程)有很多原因。第一个原因是它依赖于版本。向对象添加新字段,任何先前序列化的该类型数据都将被视为损坏,因为数据大小不匹配(也许项序列化器可以处理这种情况,但这取决于项序列化器)。例如,这不会发生在 XML 序列化 中,因为它只会将新字段/属性保留其默认值。

但最糟糕的是,如果您使用它来加载外部数据或进行远程通信,通常存在安全问题,至少在我们不采取必要预防措施的情况下是这样。

Type 注册为默认类型使我们能够毫无问题地序列化非常复杂的对象,因为任何非默认类型的对象的类型信息都会被序列化,以便反序列化能够找到它。这里的风险是,在加载时,流中识别出的任何 Type(以及因此的程序集)都会被加载。这可能被用来强制应用程序并行加载同一 DLL 的冲突版本,加载未知数量的库,以及谁知道随机类和程序集的静态构造函数可能在做什么?

因此,安全的第一步是避免将 Type 信息本身注册为默认类型。但是,在这种情况下,所有可能被序列化的类型都必须由序列化器和反序列化器以相同的顺序注册。这可能非常困难(在某些情况下甚至不可能)。

这还不是全部。仍然可能攻击序列化器,至少是为了造成拒绝服务,通过为任何可变大小的数据提供非常大的大小。字符串、数组和集合通常属于这种情况。如果您查看默认实现,这些对象的大小没有限制。这意味着我们可以序列化高达 2gb 的字符串。但是,在通信场景中,这真的就是应该发生的吗?

请注意,即使您构建了自己的远程处理体系结构,将接收到的数据包的大小限制为(例如)4kb,反序列化器很可能仍会读取大小值(即使是 2gb),分配该内存量,然后尝试读取剩余内容(此时会失败)。为时已晚,因为允许分配 2gb 是服务器的问题。

因此,可以使用二进制序列化来共享数据和进行通信,但要意识到它天生不安全。因此,仅用于本地通信,而不是通过互联网,或者要非常小心每一个细节。提供的任何可变大小数据的项序列化器都不适合通过互联网使用。

没有注销方法

那些已经阅读过我过去文章的读者,例如 可扩展 IoC 容器无操作框架,可能会发现实际实现并不像我提出的那样完整。

可扩展 IoC 容器 文章展示了与我当前提供的代码非常相似的代码,但有两个主要区别:它不支持搜索器的优先级,而是按注册顺序执行它们(因此新版本更好),并且搜索器和直接注册都可以被注销。我现在提供的版本没有任何 Unregister 方法。

无操作框架 中,我走得更远,讨论了全局和本地配置及其交互、通知事件,并说框架越完整越好。那么,为什么我现在做的事情不那么完整呢?

答案是我必须做出选择。最完整的解决方案实际上更难理解,导致难以实现、使用、调试,并且总体上可预测性较低。

例如,在注册一个复合搜索器(一个使用容器本身来查找基本项的搜索器)时,会创建一个新对象来保存有关要使用的容器的信息。由于此对象从未返回给用户,用户如何要求注销该特定复合搜索器?

即使您知道答案(将该特定实例返回给用户或使辅助对象可比较),那些由于搜索器被注销而找到的项应该怎么办?这些项也应该被注销吗?

在相反的情况下,如果我们进行搜索并找到一个项,我们能否注销该特定项而不注销搜索器?这次注销调用是否会避免通过再次运行搜索器来找到该项?如果它会,它是否会避免从不同的搜索器(例如较低优先级的)找到结果?

所有这些都可以决定和完成(而且我确实做到了),但这些复杂性将使新的实现更加难以实现,或者很可能在实现之间引入大量兼容性问题。因此,为了避免所有这些复杂性并使容器代码易于阅读,我选择避免 Unregister 方法,并使实现变得更加、更加简单。

用户还将确信,一旦找到结果,该结果不会改变,除非进行了明确的调用来替换它。仅此而已。因此,结果的可预测性大大提高。

代码质量

这些框架在转换为 可移植类库 **之前** 已经进行了大量测试。作为 可移植类库,我需要调整很多地方,并且只进行了基本测试。因此,可能存在某些特定于平台的功能无法正常工作,或者我可能使用了完全错误的命名空间等。

关于可能不兼容的问题,我依赖于这样一个事实:如果您收到同一个类型或方法信息的两个引用,那么它实际上将是同一个实例(或者至少可以使用默认比较器进行相等比较)。如果不是这种情况,那么直接注册将不起作用,而基于搜索器的注册将一直创建新实例并注册它们,消耗更多内存并导致许多其他相关问题。

在我的测试中,使用 .NET 和 Windows 应用商店应用程序一切顺利。我知道如果我针对 Silverlight 5,事情将不会奏效(这是避免在这些库中使用 Silverlight 5 的主要原因之一),而且我根本没有用 Windows Phone Silverlight 8 进行测试,但我希望它仍然可以正常工作。

因此,在信任这些库之前,请自行进行测试,并自行承担使用风险。

示例应用程序

下载库时,您还将收到一个示例控制台应用程序。该应用程序唯一的作用是使用其默认配置实例化容器,添加一个额外的项(即一个额外的转换、二进制项序列化器或实际的 IoC 项),然后请求它。

也就是说,该应用程序运行起来没有用,其唯一目的是展示如何设置框架并为最基本的操作使用它们。因此,请查看其源代码,不要期望它在其他方面有用。

© . All rights reserved.