无操作框架






4.80/5 (25投票s)
了解如何创建完全可配置的框架,让您的应用程序在不产生瓶颈的情况下演进。
背景
在写完上一篇文章后,我决定修订我的个人库,并对一些尚未为将来用途做好准备,或者通常需要太多实例配置的地方进行标准化。
我开始写一篇关于演进框架的文章,但考虑到我正在比较的事物类型,我决定写一篇新文章。我将这个标准命名为“无行为”(actionless),以与WPF的“无外观”(lookless)控件进行比较。
重要提示
目前,这篇文章非常理论化。它没有示例代码,但其大部分思想可以在我的上一篇文章《转换器》中找到。
这次我尝试探索一种可以用于许多不同事物的方法。上一篇文章中的代码可以证明这里解释的一些观点,但与本文中的思想相比,它仍然不完整。
导论 - 什么是无行为框架?
在WPF中,“无外观”(lookless)控件意味着一个没有强制外观的控件。它通常有一个默认外观,但该控件仍然被认为是“无外观”的,因为它不依赖于其外观,实际上,在不创建其默认外观模板的情况下制作一个控件是完全可能的,将这项工作留给控件的用户。
根据同样的原则,“无行为”(actionless)框架是任何应该做某事的框架,但同时,这种“某事”可以在运行时完全改变。从这个意义上说,WPF是一个“无行为”的UI框架。它当然执行了呈现UI的动作,但如果你移除所有模板,它就是“无行为”的。所以,它的基本动作(呈现事物)是可配置的(无论是默认还是用户在运行时配置),而不是固定的(从这个意义上说,是“有行为”的)。
思考无行为框架
这个想法高度抽象,但我会尝试提供一些例子。
想一想你的框架将做什么,以及它完成工作需要哪种信息。现在,与其编写实际的实现代码,不如考虑提供一种在运行时注册所需信息和要执行的动作的方法。
如果你也提供一个默认动作,那会很好,但不要以此为起点。让它的基本动作完全可替换。
例子?
- WPF数据模板。数据模板的目的是以视觉方式呈现数据。但如果你不创建数据模板,则会显示数据的 ToString()。因此,从某种意义上说,WPF数据模板可以被视为无行为数据可视化框架。
- 记录编辑器。你可能想要编辑整个记录。这与数据模板不同,因为数据模板已经存在,而且只显示数据。你可能只是有一个像 RecordEditor.Edit(someRecord) 这样的方法。但是,根据记录的类型,实际的编辑器是完全不同的。
- 序列化。您对当前的序列化机制不满意(我也不满意),因此您决定创建自己的序列化机制。但是,您没有编写一个能够处理所有现有类型的序列化器,而是简单地允许在运行时注册这些序列化器。如前所述,您可以创建自己的默认值。因此,您可以添加默认值来序列化基本类型。但是,如果用户愿意,甚至可以替换这些默认值。
- 转换。嗯,转换是我写上一篇文章的原因,它给了我遵循这种模式的灵感,然后才有了这篇文章。我能说什么呢?寻找转换所需的信息不像其他情况那样是单一类型,而是输入和输出类型。
- 可能还有很多,但我希望我传达了我的想法。
无行为框架的优势
简单来说,无行为框架为未来的变化做好了充分准备,并且可以通过新的配置使您的应用程序表现更好/外观更好。
如果你用它来编辑记录,那么将来如果你创建了一个更好的编辑器,你只需替换编辑器的注册,新的(我想象中更好的)编辑器就会在所有地方使用。无需搜索所有对旧编辑器的调用来替换为新的编辑器。
如果您正在使用它来转换数据,并且现在有更快的算法,只需注册更快的算法即可受益于更好的性能。
对于序列化?也是一样。在这种特殊情况下,您需要注意不要注册不兼容的序列化器,如果它应该读取旧数据,但这是我们在更改格式时始终需要担心的问题。
创建您自己的无行为框架
无行为框架可以看作是IoC(控制反转)、松耦合、SRP(单一职责原则)以及可能其他原则的极端应用。
从技术细节上看,它通常会以“所需信息类型”和“能够执行动作的项”的字典形式开始。
- 所需信息类型: 重要的是要理解“信息类型”不是实际信息。例如,对于WPF数据模板或记录编辑器,信息类型是数据或记录的
Type
。typeof(string)
本身就是信息。 - 能够执行动作的项: 对于控件,我们通常会创建它们,所以
Func
就足够了。创建后,您可能想要了解一些关于控件的信息,并希望它们遵循某个接口。但是对于序列化过程,该项可以直接是序列化或反序列化委托。在我的个人实现中,我将序列化和反序列化绑定在一起。毕竟,所有被序列化的东西都应该由兼容的算法反序列化。但这只是一个个人决定。也可以将它们解耦,以获得更小的对象,并将兼容性问题留给用户。
所以,有了那个字典,以及允许用户注册他们的信息和能够执行动作的项的方法,我们就完成了。
完成了...?
嗯……是不是有什么遗漏了?
好吧。这足以在非专业级别上完成。它适用于特定项目,但它还不是一个功能齐全的无行为框架。
当然,你可以实现一些默认动作,使其尽管有“无行为”的名称但仍能做一些事情,但这并非我所说的它不完整的原因。我们必须思考这种“无行为”框架可以在何处使用,以及它可能面临的潜在问题。
我将再次回到我的上一篇文章。在那里我谈到了全局和局部配置,多线程问题,我甚至添加了一个Searching
事件。所以,让我们了解这些点,这样您就不需要去阅读上一篇文章了。
全局和局部配置
我们再来看看 WPF。您可以在 App.xaml 中放置一个数据模板,它将对您的整个应用程序有效。然后,您可以在 Window.Resources 中为相同的数据类型放置一个不同的数据模板,该数据模板将在此窗口中使用,而不是应用程序级别的那个。
您可以使用一个不带任何模板的第三方库中的对话框,并且该对话框将使用您的应用程序模板,而无需您修改该第三方库。
也就是说:您有全局和局部配置。
还有什么遗漏吗?
好吧,我来问你:如果你想要打开那个第三方库的对话框,并让它使用不同的模板,但库本身没有提供访问对话框资源或特定方法来这样做,你该怎么做?
对我来说,这是一个很好的问题,我不知道在WPF中是否可能。我们稍后会再回到这个问题,届时我会给出我的答案。
多线程
在WPF中这很简单。只有创建对象的线程才能更改它。这使得大多数事情都是单线程的。但是,如果情况并非如此,全局配置是否应该是线程安全的呢?
搜索
由于某种原因,最初配置的框架缺少某些要执行的操作(例如,在 WPF 中,缺少一些数据模板)。我们应该简单地给出错误/执行默认操作,还是应该给一个最后的机会来获取正确的操作来执行?
我的个人答案
努力使框架完整。
这与敏捷方法论的方向相反,但如果我们希望我们的框架长期存在而无需更改,我们就应该让它在运行时适应最不同的情况。
事实上,我尝试制作一个通用的框架,它能满足所有需求,并能作为任何想要创建自己的“无行为”框架的人的基础。但通用框架存在使用通用名称的问题,而且从用户可见的框架重定向到通用框架并没有减少多少代码,所以我最终制作了副本并调整了所需项(目前没有下载,但我已经在编写和测试其中一些框架,将来我应该会为这篇文章提供下载)。
但让我们看看如何实现这个想法
全局配置
对我来说,它们应该存在,而且应该始终是线程安全的。
也许你认为按实例配置是最好的解决方案。但用户最终会创建自己的 Create
方法,通常会复制代码来初始化对象(这可能会在初始化更改时产生 bug,但并非在所有地方都如此),或者更糟的是,会直接放弃该库。
另一方面,你可能会认为应用程序初始化后没有人会更改全局配置(也许你只是忽略了多线程或使配置只读)。
但是您的用户可能会在他们的静态构造函数中放置一些代码来初始化框架(例如,为实际类型注册编辑器),但是他们的类只有在应用程序运行后才加载(这是正常的 .Net 延迟加载),而且,如果您已经有线程在运行,那么,嘣,您会因为您的代码不是线程安全的或者因为对象不再可更改而得到异常。
本地配置
我在本地配置上的方法是使用线程静态实例来保存本地配置。由于它可以被替换,因此它可以成为上下文特定的配置。在这种情况下,我可以在调用方法之前替换配置,然后恢复到原始配置。
不过,这并不完整。看看一个窗口。我改变了实际的配置,我创建了窗口,然后我恢复了旧的配置。但是窗口仍然在那里。它现在可能需要创建一个新的控件,它将使用错误的配置。
所以,是的,拥有一个线程特定的配置作为线程的默认配置仍然是好的,但它也应该允许层次结构。在一个窗口中,我可能希望一个面板使用一种配置,而另一个面板使用另一种配置。
我的解决方案是允许创建新的“配置”,并将其与父配置、全局配置关联,或者使其独立。这并不会使它们成为线程默认配置,但简单地调用UseAsLocalInstance
会使它们成为默认配置。我选择“Use”这个名称是为了提醒你可以用using语句调用它,这样在执行上下文结束后,旧的配置就会恢复。
失败前的最后机会 - 搜索事件
预配置应用程序可能很困难,甚至耗时。也许用户创建了一个对给定类型所有子类型都有效的模板,但该给定类型的子类型并非在加载时都可用。它们甚至可能在使用Reflection.Emit
的应用程序执行期间创建。
那么,用户将如何配置框架所有有效的动作呢?
和许多事物一样,总会有变通方法,但为什么不作为框架的一部分,给一个最后的机会来注册一个有效的模板呢?
框架的创建者无需了解启动时无法完成配置的所有可能原因。但通过提供一个事件,我们可以解决这个问题。那么,为什么不呢?
此外,如果你认为在框架中直接处理子类型很容易,那么,我已经写了一大段代码来处理接口、子接口、泛型等等。这会增加另一个复杂性级别,可能无法解决所有问题,并且会使事情变慢。所以,让用户处理这些额外的特殊情况,并避免框架中的这种复杂性。
在这一点上,我认为 WPF 遗漏了一些东西,或者我不明白如何在 WPF 中完成这些事情。我真的很想为任何 IEnumerable
创建一个数据模板,它使用 ListBox
来显示项目。由于每个项目实际上都会使用其自身的数据模板,因此只要该项目有数据模板,它就能工作。但是,如果我为 IEnumerable
类型创建数据模板,它将不起作用。为所有可能的 IEnumerable
最终类型(如 string[]
、List
、int[]
、List
等等)预注册相同的数据模板将花费很长时间并消耗大量内存。
我目前的解决方法是使用数据模板选择器,但每次我都要填充该数据模板选择器。
局部-全局交互
在本文的第一个版本中,我说局部/全局交互并不难。嗯,我错了。
我这样解释道:如果在本地找不到特定项,则搜索父级。如果不存在父级但配置未隔离,则全局搜索。
这个想法很好,并且在我们没有Searching
事件时工作得很好。有了这样的事件,我们必须决定事情发生的顺序。
例如,我们应该按这个顺序执行吗?
- 搜索本地注册的动作;
- 搜索在父级中注册的动作;
- 搜索全局注册的动作;
- 执行本地搜索;
- 执行父级搜索;
- 执行全局搜索。
还是应该按这个顺序执行?
- 搜索本地注册的动作;
- 执行本地搜索;
- 搜索在父级中注册的动作;
- 执行父级搜索;
- 搜索全局注册的动作;
- 执行全局搜索。
一开始,我并没有想到这一点,但我的实现就像第二种情况。原因是每个级别都尝试查找已注册的动作,如果未找到则执行Searching
事件,如果仍然未找到,则请求父级别执行相同的操作。这还使得全局锁只在最后一种情况下发生,这有利于性能。
但我有一个问题,我想要注册一个新的本地Searching
,如果没有找到其他值,它会生成“默认”值。但是,由于本地Searching
在父级之前执行,它最终会生成太多的默认值。
我甚至考虑过将顺序改为第一种情况,但这并不能真正解决问题。我是否应该完全改变Searching
逻辑,首先从局部到全局搜索直接注册的项目,然后从全局到局部执行Searching
事件?
即使那个解决方案可以解决我当前的问题,它也不会是一个完整的解决方案,因为一个本地配置可能会添加一个Searching
处理程序来替换GlobaSearching
的结果。如果我做出这样的更改,只有不是由父Searching
生成的结果才能起作用。
我随后想到在 Searching
事件上设置优先级,然后按以下方式执行:
- 本地搜索;
- 搜索父级;
- 全局搜索;
- 对所有搜索句柄进行排序,然后按顺序执行。
这再次解决了我的实际问题,但又制造了另一个问题。如果一个具有最高优先级的本地 Searching
想要替换在父级上注册的固定动作怎么办?由于动作会在父级上找到,它就不需要调用事件,优先级也就没用了。
搜索后(AfterSearching)
我考虑的另一个选项是创建一个AfterSearching
事件,这样我就可以保持实际的逻辑,并在执行GlobalSearching
之后,以相反的顺序(从父级到子级)调用它。
这无疑适用于我的情况,我也没有看到它会产生新的问题。但我个人不喜欢“After”事件,特别是当“After”与另一个事件而非方法相关时。
我的解决方案?
在尝试了一个又一个解决方案,并试图找出可能的问题之后,我决定做一件非常简单的事情。
以前,Searching
事件参数只提供了启动操作的Manager
(配置),而不管Searching
处理程序是否在父级中。通过在参数中放入实际的Manager
,处理程序可以自由地检查父级中是否存在有效结果,如果不存在,则给出自己的结果。
在这种情况下,我没有额外的事件。本地Searching
仍然会先执行,但是当它执行时,如果它不应该替换父配置中生成的动作,它可以检查父级是否有有效结果,并且只有当父级没有结果时才给出自己的结果。这消除了优先级的复杂性和问题,保持了从局部到全局的顺序,并解决了我的问题。事实上,我发现它比AfterSearching
更强大一些,因为处理程序获得了更多信息。
无行为框架可以在哪里使用?
我会说几乎无处不在。如果你的类正在实现一个接口来赋予它们额外的动作,那么也许是时候使用一个无行为框架了。
说真的,如果你的一个类实现了两个或更多的接口,你很可能可以创建这种解决方案,并允许存在两个类,其中一个为另一个类提供更多的操作。
太抽象了?好的,我们来看一些例子
ICloneable
您在类中实现ICloneable
,以便它们的实例以标准化方式支持深层克隆。
但现在想想所有那些可以深度克隆但不支持它的类。可能如果你想有一个通用的解决方案来深度克隆对象,寻找那个接口将只是选项之一。
所以,我的解决方案是创建一个无行为的克隆框架。由于ICloneable
已经存在,其中一个实际的Searchers
可以调用这个接口。但这个框架将允许您为其他已存在的类添加深层克隆支持。
事实上,如果你默认不支持ICloneable
,你甚至可以修复由该接口引起的bug。如果类A
实现了ICloneable
,然后类B
继承自A
但没有覆盖Clone
方法,会发生什么?你最终会在类B
中得到一个Clone
方法,它将生成一个A
实例!这是一个大缺陷。
IConvertible
再次转换。转换更进一步深入了问题。与ICloneable
不同,后者类型A
生成类型A
实例,类型B
生成类型B
实例,IConvertible
是一个单一接口,试图生成所有类型的转换……而且它肯定总会遗漏一些。
一个像IConvertible
这样的接口,其中T
是目标类型,会更有意义,因为这样你就能准确地知道你的类型支持哪种转换。
但是,为什么不把这些逻辑放到别处,并让它们在运行时添加,甚至针对您以前不知道的类型呢?
ISerializable
我总是回到旧观点。我已经谈过序列化和转换。
但仔细想想。如果您的类型只使用已经可序列化的类型,那么只需将其标记为[Serializable]
即可使用默认的序列化逻辑。但如果您想提供自定义逻辑,则应实现ISerializable
接口。
把这段逻辑放到另一个类中不是更好吗?只需通过属性或在静态构造函数中指明默认序列化器类在哪里?
此外,这样做我们已经让代码可以与单例一起工作(这在当前的 .NET 序列化过程中是一团糟)。
相等比较
在我看来,这是最糟糕的一个。
object
类型,所有 .Net 类型的基础,都有Equals
方法。由于它将参数接收为object
,因此它会对值类型进行装箱。
后来,添加了IEquatable
接口。有了它,可以避免类型转换或装箱/拆箱,但你需要知道对象的真实类型才能使用它。
为了使其更完善,还有EqualityComparer
委托和Default
实例,如果IEqualityComparer
可用,它将使用它,否则将调用普通的Equals
。
所有这些都很棒,但它们只有一个目标:使相等比较功能化、标准化,并尽可能快速。
现在创建两个相同类型的列表(例如List
),你可以让它们为空。使用Equals
方法检查它们的相等性。它们相等吗?
答案是不。LINQ 有 SequenceEqual
方法来尝试解决此问题。
但考虑到Equals
的目标是比较内容相等性,我认为任何集合的Equals
都应该起作用。
或者,更好的是,Equals
不应该放在object
类型中。如果我们只有IEquatable
接口,那么将更容易识别哪些类型支持相等比较,哪些类型不支持。我们还将解决继承问题。类型A
可以实现与类型A
的相等比较。但类型B
,如果它继承自A
并且没有实现IEquatable
,将不支持B
实例之间的相等性,即使它仍然可以与A
进行比较(它只会比较其A
部分……这可能没问题……也可能不行,但使用默认的Equals
我们一无所知)。
但是,无论如何,List
不支持与其他List
的相等比较。数组也是如此(我想所有标准集合都是如此)。
过去,在进行最通用的比较时,我通常使用object.Equals
。然后,我创建了一个方法来检查项目是否为IEnumerable
,如果是,则使用SequenceEqual
,否则使用旧的object.Equals
。我还创建了EquatableLists
、数组等等。
但是,为什么不创建一个可以支持所有内容相等性的解决方案,甚至是那些默认情况下未实现的方案呢?
在我看来,object
类型已经过于污染。它试图赋予不属于每种类型职责的职责,这就是为什么许多类型没有实现Equals
、GetHashCode
和ToString
的原因。
我们无法直接修正 .Net 本身,但我们可以创建一种解决方案,每次都使用它而不是 .Net 的解决方案。而且,如果需要,这种解决方案可以扩展,以捕捉 .Net 本身无法捕捉到的情况。
IComparable 和 IComparable
我将不再过于挑剔。我对此没有问题。
好吧,我停不下来。对它们没有问题并不意味着我认为它们完全正确。它们仍然给类增加了职责,特别是对于string
类型来说,这是一个很大的职责,因为本地化会影响它的工作方式。
但由于更改比较规则是极其常见的,我只将IComparable
视为默认情况,而非唯一情况,因此其他接口引起的大问题在这里并不真正存在。
IDrawable, IPrintable, IRemotable 或任何仅添加方法而不改变对象状态的接口
即使这些是虚构的接口,任何仅仅向对象添加方法以使其在另一个环境中可用的接口,都可以利用无行为框架。
与其强制实现者思考对象可能在所有地方的使用方式,不如让他们偷懒工作,如果需要对对象执行其他操作,则允许稍后创建该操作。
添加属性或改变对象状态的接口
即使在某些情况下仍然可以创建一些“混入”,我还是要说这不是无行为框架的工作。
在这些情况下,我们可以看到诸如IDisposable
(立即释放对象资源并通常使其无法使用)、IList
(可以更改列表内容)等接口。
这些接口对我来说是好的。我们提供了抽象的方式来访问同一个对象,而且其中许多通常与类型的主要动作相关。例如,即使IEnumerable
接口不改变集合的内容,我也无法想象有人会创建一个集合类型而忘记实现该接口。第一次他用foreach
测试他的代码时,他就会注意到问题。
适配器无行为框架
我之前发表过一篇关于在运行时创建适配器的文章,名为《DelegatedTypeBuilder - 在运行时创建新类型和适配器》。其思想是获取任何类型的实例,并要求将其作为特定接口类型接收。
该实现使用Reflection.Emit
来创建实现该接口的类,并将所有方法重定向到真实实例上同名的方法,如果需要(再次,转换……)则尝试进行任何类型转换。
那也太具体了,我认为它可以成为适配器框架使用的替代方案之一。
但你猜怎么着,转换框架也是创建适配器的好地方。如果你想要一个类型X与接口I兼容,但它没有实现它,如果你尝试转换它会发生什么?有了正确的转换器,它最终可以“适配”该类型。
工厂
也许你认为我所说的都只是工厂。
嗯,无行为框架可以是优秀的工厂,但工厂不一定要是无行为框架,而且无行为框架也不像工厂那样只局限于创建实例。
工厂不需要在运行时可配置,不需要具有本地/全局交互,也不需要有最后机会事件。所有这些都可以添加到工厂中,在这种情况下,它也将成为一个无行为框架。但是,在初始阶段添加这些特性比以后添加它们并修正可能在其他地方发现的所有变通方法要容易得多。
想看看它们实际运行吗?
目前我还在努力完成所有这些框架,并且我还在修正我的旧应用程序,以便使用新框架而不是旧框架。这也能让我测试这些框架,看看它们是否仍然存在实现错误或甚至一些概念上的限制。
我不知道何时,但我会尝试将这些框架的代码和一些使用示例放在这里。目前我只能说《转换器》一文对此事有近乎完整的实现。
结论
我得出的结论是,我太挑剔了。我热爱 .Net,我知道它的许多资源过去和现在都具有革命性,即便如此,我仍然批评它。不过,我希望我的观点是有效的。
版本历史
- 2012年9月28日。修正了局部-全局交互主题;
- 2012年9月27日。初始版本。