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

软件架构

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (59投票s)

2013年11月10日

CPOL

26分钟阅读

viewsIcon

93878

本文讨论了应用程序开发中的架构问题,也讨论了自制框架及其优点。

引言

我是一名软件架构师/系统架构师,很多时候在面试时,人们似乎对我所做的工作一无所知。我不能责怪他们,因为这些术语有很多含义,如果你查看维基百科的链接软件架构师系统架构师,你会发现其中一些看起来是完全不同的任务。

事实上,整个问题在于,几乎所有在实际编写代码之前做出的决策都可以被视为架构。如果我决定开发一个游戏,那么决定开发哪种类型的游戏就已经是一个架构决策了。它本身并不是软件架构,但这样的初始决策会影响后续的编程,因为不同类型的游戏需要不同类型的决策。

但我们通常在开始选择要使用的技术时才谈论软件架构。也就是说,我们会使用 XNA 吗?我们会使用 C# 吗?我们会使用 Javascript 吗?

如果我们决定使用 C# 而不是 XNA,那会是 Windows Forms 吗? WPF 吗? Silverlight 吗?

而且,如果是一个多人游戏,我们会使用纯 TCP/IP (或 UDP) 编写所有通信层/细节,还是会使用像 WCF 这样的高级框架?

这是一个非常重要的决策时刻,因为应用程序的整个演变可能会因这些初始决策而变得更好或更糟。然而,除非我们决定自己编写整个通信,否则我们正处于“选择”现有技术而不是思考如何创建它们的时候。

而最糟糕的事实是:通常,无论我们在这个阶段做出什么选择,应用程序仍然可以开发。正如我刚才所说,应用程序的整个演变可能会变得更好或更糟,但它将是“可能”的。

显然,这就是大多数架构师所做的:他们选择技术来编写新的应用程序。让我感到悲哀的是,他们通常根本不考虑问题,他们只是使用极其基本的条件作为决策的参数,例如:

  • 如果是游戏,使用 XNA,因为它针对游戏进行了优化;
  • 如果是本地应用程序,使用 WPF (如果他们喜欢新技术) / 使用 Windows Forms (如果他们喜欢旧技术);
  • 如果应用程序需要相互通信,使用 WCF

在这些决策(即最初的“架构”)之后,他们继续工作,不得不为他们在初始阶段做出的通常糟糕的决策(或缺乏决策)寻找“变通方法”。毕竟,如果最初的决策都正确无误,并且他们不打算开发自己的框架,那他们为什么要继续在这个项目上工作呢?

框架

我刚才只是说,初始阶段通常是选择技术,比如 WCFWPF 等等。对于网站来说,这可能类似于 ASP.NET + MVCWeb Forms、缓存技术等等。所有这些技术都可以被视为完成某类工作的“框架”。

嗯,作为一名架构师,我的工作通常是创建这样的框架。我的目的很少是选择最好的现有框架,而是做出正确的决策来创建这样能够正确运行(具有良好的性能、内存消耗、易用性,最重要的是:真正可扩展)的框架。

但我认为你可能已经感到害怕了:如果我想创建一个游戏,我会浪费时间创建所有技术吗?这太疯狂了!

我同意,对于一个小项目来说,当已经有其他技术可用时,编写整个技术可能看起来很疯狂。但是,首先,那是我的专长。也许这不是公司正在寻找的。其次,在许多大型项目中,创建技术,即使它开始重定向到另一个技术,也会开辟新的可能性。事实上,我开始创建框架是因为大多数时候我只是认为现有框架的架构太糟糕了。这并不意味着它们不起作用。它只是意味着它们并没有真正提供帮助或使事情变得更容易,并且在许多情况下,它们限制了可以实现的目标。

但在解释问题或解决方案之前,我将尝试解释我对“框架”的看法。

什么是框架?

我经常看到一个定义:“你调用库,框架调用你”,即使从你使用框架时必须“服从”其规则,通常填写事件或实现由框架调用的虚方法的意义上来说这是可以的,但从某些类可以直接使用(像“库”)或继承(因此虚方法将像“框架”一样被调用)的意义上来说,它存在很大问题。

此外,任何DLL都是一个库(这就是最后一个L的含义),它可以包含一个或多个“框架”。

所以,我更喜欢说框架有广义和狭义之分。也就是说,一个为包含框架而创建的 DLL “就是一个框架”,但事实上这样的库可以包含独立的类,可供任何应用程序使用,主要框架,甚至“次要框架”。

也就是说,任何针对某一类问题的解决方案,无论是构建一个非常有用的单个类,还是由多个类组成的集合,都可以被视为一个框架。一个框架通常包含许多类,但在你最初使用时,可能只使用单个类提供的基本方法,稍后才可能使用额外的功能。

例如:当你使用 BinarySerializer 类时,你正在使用 .NET 提供的基本序列化功能。但是你可以通过使用 [Serializable] 属性甚至实现 ISerializable 接口来创建你自己的可序列化类。所以,存在一个完整的框架,但在你最初的情况下,你可能只是将其作为一个简单的“库”类来使用。

一个常见的错误:创建自己的框架是糟糕的

我看到的反对框架的常见论点有:

  • 框架强制您的应用程序以特定方向工作,禁止您做任何不同的事情;
  • 解决问题的框架代码比直接解决问题更困难,因此在大多数情况下,创建框架只会增加项目的复杂性;
  • 使用前面的定义,有些人说:“创建库,而不是框架”;
  • 您的框架永远不会像专门从事此工作的公司所制作的框架那样功能完善;
  • 如果您辞职了,谁来维护这个框架?从公司购买框架,我们就能获得支持的保证。

我必须说,我大部分同意所有论点。但事实是:任何大型项目最终都会有一个框架,无论是架构良好的框架,还是在其他框架之上搭建的混乱框架(这正是某些讨厌框架的开发人员通常会做的)。

也就是说,避免创建框架而购买外部框架的开发人员通常最终会拥有一个基于外部框架的自有框架,并且它通常具有原始限制加上他们可能添加的限制。

整个想法是,通过使用公司制作的软件,我们可以获得更好的支持、更好的质量等。但是专门创建技术的公司不了解我们的具体需求,所以他们会给我们一些“通用”的解决方案。不熟练的程序员可能会尝试做同样的事情,并且他们可能会做得非常糟糕。经验丰富的开发人员可能会为公司制作更好的解决方案,即使它不像从其他公司购买的解决方案那样功能完善。

所以,如果你是一个真正有经验的开发者(或者你拥有真正有经验的开发者为你工作),那么让他们为公司的需求创建一个特定的框架是值得的。

架构——不好的那些

当我思考一个项目时,我通常会先思考我想做什么,然后思考完成这项工作所需的东西(技术的概念,而不是技术本身),最后才思考可能有助于我实现这一目标的现有技术。

但因为我已经考虑了可能需要的技术,却没有考虑任何具体的技术,所以我没有考虑任何限制、任何特定技术的数据或任何变通方法。然后,当我看到现有技术时,通常促使我决定自己创建框架的原因是,这些技术期望应用程序“使用它们”来完成,即使在这种情况下使用一种技术是可以接受的,我也无法让两种外部技术(框架)协同工作,因为一个不知道另一个的存在,而这是另一个框架的要求。

我说的“它们期望应用程序使用它们”是什么意思?

嗯,它们期望你的代码是这样编写的:

  • 继承自它们的基类;
  • 实现它们的接口;
  • 使用它们的属性;
  • 或者任何类似的东西,这要求代码在编译时引用它们。

因此,如果您使用框架 A(它不知道框架 B)的对象,如果您不创建适配器,就无法将这些对象与框架 B 一起使用。

创建适配器确实有效,但在某些情况下,这是浪费时间。当我们使用像序列化这样的框架时,我们希望“将对象实例转换为字节”,而不关心如何实现。但是如果我们需要创建一个可序列化的适配器,为什么不手动编写序列化呢?更糟糕的是,您可能有一个非常大的对象图,其中只有一个对象可能没有被标记为 [Serializable],即使它非常容易使其可序列化。但您仍然需要重新创建整个“适配”图来解决这个问题,因为您无法更改外部框架的源代码。

而且,如果你认为你可以创建一些东西来自动化整个适配图,那么你将创建一个“框架”来创建适配器。那么,为什么不直接创建正确的框架呢?

注:我已经在《Attributes vs. Single Responsibility Principle》一文中讨论过属性如何违反“单一职责原则”。有人在那里争论说 [Attribute] 不是代码。它们附加到类/属性上,而不是它们的一部分。然而,考虑第三方库的问题。你无法修改它们的源代码来添加属性,你也无法在运行时添加属性(好吧,至少在 .NET 4 之前是这样...我不确定 .NET 4.5 中是否可能)。

此外,还有其他类型的问题。通常它们并没有那么糟糕,但我认为它们非常烦人。这发生在那些期望直接在配置文件中找到某些配置的框架上,而没有给你机会通过代码设置这些配置,或者发生在那些自动执行某种操作但又不允许你扩展这种操作,只能替换它的框架上(更糟糕的是,通常必须逐个实例地进行,而一个全局的扩展点会更好)。

那么,哪些框架我认为是有问题的呢?大多数,即使它们是全球广泛使用的。

这包括:

  • WPF “转换”绑定;
  • 通常的 TypeConverter
  • .NET 的默认二进制序列化;
  • .NET 的默认 XML 序列化;
  • WCF 属性驱动的架构;
  • MarshalByRefObject 以及所有已经继承或依赖于它的类;
  • 大多数 ORM 框架,它们通常基于属性、基于配置文件,并受限于数据库类型,如果我们要使用应用程序特定的数据类型来呈现数据,则需要创建适配器。

我并不是说这些框架不起作用。它们确实有效。但它们可以做得更好。

所以,解释每一点:

  • 如果您在 WPF 绑定中没有指定 Converter,它能够进行一些自动转换。我认为它能够使用 [TypeConverter],而 [TypeConverter] 已经受到限制。但是,如果类型来自不相关的程序集,您就不能注册一个从类型 A 到 B 的转换器作为应用程序的全局转换器;
  • 理论上,[TypeConverter] 可以将任何类型转换为任何类型,但它们要求属性用于两种类型之一(无论是源类型还是目标类型),并且单个类型转换器必须知道所有可能的转换。因此,考虑到我们可能有一些类型很容易相互转换,但它们来自不相关的库,我们就束手无策了。所以这些类型转换器最终只能用于字符串或其他一些基本类型之间的转换;
  • .NET 二进制序列化无法序列化未标记为 [Serializable] 的类型。您是否知道如何序列化它并不重要。如果一个大型图的深层只有非可序列化对象,情况会更糟;
  • .NET XML 序列化不共享二进制序列化属性,所以如果你创建一个可以同时被两者序列化的类,你需要记住同时使用两者的属性;
  • 我能说什么呢,如果一个组件(例如,无状态且只使用基本数据类型)没有使用 WCF 所期望的所有“契约”,你就无法让它作为一个服务工作。我稍后会对此进行更多解释;
  • MarshalByRefObject 的全部思想是所有调用都变为虚调用,即使你将类标记为 sealed,这样它们就可以被“替换”。好吧,接口是纯虚的,但有了接口,你可以选择将调用作为纯虚调用使用,或者继续使用正确类型的类,避免任何虚调用。而使用 MarshalByRefObject,你总是会进行虚调用,即使你不想。那么,告诉我你打开一个文件(FileStream)并真正期望它被另一个类替换过多少次?当你想要任何流时,为什么不使用 Stream(或者更好,如果存在的话,IStream),当你确切知道其类型时,为什么不使用对 FileStream 的非虚调用?不幸的是,由于它是 MarshalByRefObject,你可以用真实的密封类型声明变量,但虚调用仍将继续进行。

IoC - 控制反转

也许我在这里有点离题,但另一件让我恼火的事情是现在流行的“控制反转”理念。事实上,我已经认为这是一个糟糕的名字。如果正确的架构是“反转控制”并且人们尊重它,那么它就变成了“正常”的,而不是“反转”的架构。

为了实现这种控制反转,建议您只依赖“接口”而不依赖“实现”,但这种解决方案并非总是最佳解决方案。某些组件可能期望只与它们的“家族”组件一起工作,而不是与任何其他组件一起工作。因此,如果您对它们使用 IoC,则必须对整个家族使用 IoC,从而有效地能够用一个家族替换另一个家族,而不是替换单个组件。

ADO.NET 的连接、命令、参数等就是这种情况。你可以用整个 Oracle 家族替换整个 SQL Server 家族,但你不能在不替换其他组件的情况下只替换连接。所以,如果你不是编写使用这类组件的应用程序,而是编写这类组件(我的意思是,任何组件家族或框架),你并不真正需要让一个组件只通过接口与其他组件通信。拥有接口对于避免在你想替换整个“家族”时需要适配器是有好处的,但组件可以通过它们正确的类型相互通信。

事实上,在这种情况下最好的架构是让接口在公共程序集 (DLL) 中声明,并在其他 DLL 中实现特定的“家族”。然后,用户将能够只依赖于公共程序集,并通过 IoC 容器在运行时选择要使用的“家族”。但是每个家族都可以直接依赖于其家族组件编写,从而避免了接口、虚调用、访问 internal 字段、属性和方法,并且完全避免了 IoC

所以,如果你认为你应该让每个类只通过接口与其他类通信,那么,再考虑一下。

关于“组件家族”的说明

我刚才提到了 ADO.NET 来解释组件家族,我经常看到的一种解决方案是使用“僵硬”规则“加载”驱动程序。

也就是说,如果你使用 DbProviderFactories.GetFactory() 方法,ADO.NET 会在配置文件(和 Machine.config)中使用条目按名称搜索数据库驱动程序。这是一个极其僵硬的规则。应用程序无法以不同的方式搜索驱动程序。

即使您可以在不使用 DbProviderFactories 的情况下加载驱动程序,但如果您创建自己的能够加载驱动程序的“基本解决方案”,请记住这个问题。您可以本地搜索驱动程序或使用像那样的僵硬规则,但是,如果找不到驱动程序,允许一个事件来执行搜索。 AppDomain.AssemblyResolve 就是一个例子,说明如何创建事件来解决“缺失信息”问题,并且它已经允许了一些巧妙的用法,例如将库嵌入到应用程序中,同时只在请求时才允许找到它们。

架构——第一个“修复”

我知道我们大多数人根本无法解决现有框架的架构问题。但是,如果你从事某个框架的创建工作,大多数问题都有一个“简单修复”,它与 AssemblyResolve 事件非常相似:在失败之前调用一个事件来尝试完成工作。

如果我们看一下大多数情况下的发生情况,它是这样的:一个框架需要更多信息来完成其工作,并且为了找到这些信息,它可能会:

  • 读取配置文件;
  • 读取属性;
  • 将您的实例强制转换为接口。

而且,如果它无法做到这一点,它就会简单地失败/抛出异常。

那么,为什么不在那个时候调用一个事件,提供你已经拥有的所有信息(即你正在处理的实例、你想要的操作以及你已经拥有的参数,比如从值 X 到特定类型的转换),并让事件告诉你它是否能够完成工作呢?

只有在事件未能完成工作的情况下,您才生成错误/异常。

这将解决 .NET 二进制序列化、XML 序列化的问题,并可能使 WCF 能够使用没有正确属性的类型等等。最棒的是:由于它不是对现有方法的更改,而是一个新事件,因此不会导致破坏性更改,因为旧代码将简单地忽略此类事件的存在。

为了结束对修复方案与前面提出的问题的比较解释,MarshalByRefObject 属于另一种类型,可以通过使用接口来解决。至于 ORM,嗯,有很多 ORM 存在不同类型的问题,其中一些将受益于这种事件调用。

第一个修复的改进。

请注意,第一个修复已经具有主观性。例如,事件可以用于判断一个类型是否是 [Serializable],即使它没有该属性。这可以解决具有可序列化结构但没有该属性的类型的问题(如果使用不当,甚至可能被视为 bug 的来源),但它对没有有效结构但可以通过用户自定义算法序列化的类型没有帮助。

因此,调用事件请求序列化一个不可序列化的类型(而不是尝试将其视为 [Serializable])会更合适。然而,MulticastDelegate 并没有优化为单一答案。也就是说,可能连接了 30 个(甚至 300 个)事件处理程序,每个都专用于一个类型。我们应该一直执行所有处理程序吗?

这意味着我们可能需要另一种解决方案(好吧,至少如果我们想要一个最优化的解决方案,因为仅仅拥有这个事件就已经可以在其之上构建一个更好的解决方案)。我针对序列化问题的解决方案是尝试为该类型找到一个序列化器,然后将该序列化器注册到一个字典中。也就是说,我不是要求序列化一个给定的实例,而是询问是否存在该类型的序列化器,如果存在,我就知道我可以序列化同一类型的其他实例,而无需再次调用事件(是的,我编写了自己的序列化框架)。

嗯,关于整个概念,我写了一篇文章,名为《无操作框架》,所以如果你感兴趣,可以查看那篇文章。

架构——第二个“修复”

第一个修复本身可能会遇到另一个问题:过于局部化。

也就是说,对于序列化问题,我们可能会创建一个解决方案,其中序列化器有一个事件,用于序列化其本身无法序列化的类型。但是我们会为每个实例添加处理程序吗?

即使你认为这是合适的(通常也是如此),避免重复也很重要,因此,就像一个带有 [Serializable] 属性的类型不需要作为每个序列化器实例的有效类型“添加”一样,拥有全局解决方案也非常重要。

事实上,我们可以说第一个“修复”应该以静态解决方案的形式存在,以便它可以在全局范围内工作。您是否添加局部和全局解决方案,或者只添加全局解决方案并不那么重要,因为如果您的代码用户编写得很好,全局解决方案也可以正确地用于局部情况。不幸的是,反过来则不然。

架构 - 服务

现在我将不再关注我喜欢创建框架或现有框架的问题,因为你可能是那种说你不会创建框架并会接受现有框架限制的人。

所以我将讨论 SOA(面向服务架构)。现在普遍认为我们应该使用 SOA,因为这种架构允许每个服务作为独立的应用程序创建,甚至在必要时使用不同的语言,并允许许多优势,如分布式处理、故障点的真正隔离等等。

SOA 真正唯一需要的是通信。而且,即使 SOA 已经意味着架构,每个服务也需要一个内部架构,至少在 .NET 中,最被接受的让 SOA 工作起来的技术是 WCF。

嗯,我刚才抱怨 WCF 太基于属性了,但你可能会觉得没关系,因为你会创建一个新的 WCF 服务并从一开始就将其实现为 WCF。所以,它使用 WCF 特定的属性根本不是问题……对吧?

而这正是我认为许多应用程序缺乏架构的地方。

服务的创建是为了完成某种工作/解决某种问题。这种解决方案作为[网络]服务可能运作得很好。然而,该解决方案可以(我敢说在大多数情况下应该)独立于所使用的通信框架而存在。

其中一个可能的原因是:想象一下,您决定在特定应用程序中嵌入该服务,甚至决定使用一种完全不同的服务技术。如果这样的“服务”只是一个简单的“库”,没有任何 WCF 特定数据,难道不是更好吗?

所以,WCF 部分可以毫无问题地完全剥离。也就是说,基本架构可以是:“将任何服务创建为一个库”。然后,如果你想让它作为一个真正的网络服务来访问,你创建一个绑定到该服务的另一个应用程序,并只填写暴露该库作为服务所需的信息。

这就是我对 WCF 的问题。虽然可以将一个已经无状态的普通库通过简单地将类型“注册”为服务(旧的、几乎过时的 .NET 远程处理支持这一点)来转换为服务,但在 WCF 中,我们应该有一个充满属性的类,在像我所描述的这种情况中,这意味着每个服务类都需要有一个“适配器”类,只用于添加所需的属性并重定向到原始的、无属性的库。

但我看到的最糟糕的问题是,许多人会直接在服务内部编写所有代码,并且在需要时,会导入带有大量不必要属性的服务,以便将服务“嵌入”到应用程序中。

注意:我没有讨论 WCF 可以使用不同的传输协议以及所有这些事实。我自己创建了一个框架,允许通过 内存映射文件 进行本地通信,其速度比我找到的本地通信的最佳 WCF 配置快近 30 倍。对我来说,WCF 对远程通信进行了大量优化,并且表现出色,但对于本地通信来说,无论其对二进制通信和管道的支持如何,它都远非理想。

架构 - 面向接口编程 - 实际情况

我经常听到和看到的一个常见说法是,我们应该“面向接口编程,而不是面向实现编程”。这通常是为了 IoC、测试和许多“神奇”的东西而辩护。然而,正如我在 IoC 主题中解释的那样,仅仅让每个组件都通过接口与其他组件通信是糟糕的。组件家族期望与其亲属一起工作。

然而,我经常看到的一种非常常见的情况是,人们试图通过直接使用资源来实现全球化。也就是说,代码依赖于资源 API,并且无法与非资源解决方案一起工作。

我可以更进一步说,全球化是一种我们应该考虑使用“框架”或“服务”(甚至两者,取决于某种配置)的特性。

那么,我们如何才能实现这种对两者的支持呢?我刚才已经回答了。接口。

将所有“服务”在本地视为接口,允许这些服务以不同方式实现而不会破坏您的代码。也就是说,一个基本的应用程序可能会实现该服务以响应它找不到任何翻译(我已经假设程序默认使用某种语言,如英语),一个稍好的实现可能会使用文本文件来查找翻译,其他一些实现可能会使用特定的资源文件,而其他一些实现可能会重定向到外部服务,甚至使用数据库查找这些翻译。

所以,面向 SOA 编程的一个好处是,对其他服务的引用通常已经通过使用接口实现。因此,如果您有一个接口,您可以毫无问题地更改实际实现。

架构——应用

在谈到 SOA 时,我说过其中一个优点是服务通常以接口形式呈现,因此代码已经准备好被另一种实现“替换”。

但这只是一半的真相。当然,通过使用接口,我们可以替换一个实例。但是我们如何获取实例呢?

SOA 消费者常见的一个架构问题是,它们直接调用服务库来创建服务实例,因此,即使有允许替换实现的接口,它们也完全绑定到实现这些接口的技术。代码根本无法用一个实现替换另一个实现,因为“起点”已经是服务库(无论是 WCF 还是其他)。

因此,遵循我们应该将服务作为库创建,然后在需要时才创建服务(作为使用该库的独立程序)的相同原则,我们应该以一种应用程序不直接看到通信层/技术的方式来编写应用程序。也就是说,当您编写应用程序时,它不能向 WCF 请求 IMyService 服务的实例(因此,您的应用程序不应该直接看到 ChannelFactoryClientBase 或 System.ServiceModel.dll)。

也就是说,您可以使用 IoC 容器,或者您可以创建自己的类作为您的“工厂”,它可以利用事件来创建您将请求的接口(服务)的实现。然后您的代码应该只将此类 IoC 容器或工厂作为起点。有了这个额外的“层”,您将能够将特定库中的服务创建替换为“通用”的,这样您就能够在任何时刻替换实现(包括本地服务而不是远程服务),而不会破坏所有实例化服务的地方。

外观

既然我已经提出了调用服务时不应直接请求服务库来创建服务实例的观点,那么我将谈论一些有点反直觉的东西。

我刚才只是说我们应该使用接口,这样代码可以很容易地被替换。但是,在许多情况下,最好提供一些 sealed 解决方案。特别是谈到 Web 服务时,因为按调用传递所有所需参数是一种常见做法。

将其与正常对象进行比较,这些对象被创建,其属性被填充,然后才执行一个或多个调用,不带任何参数或带有一个非常精简的参数列表。

因此,为了实现这一点,我们应该使用外观模式(façades)。我们应该创建具有“本地方法”的本地对象来使用服务,即使它们内部重定向到那些具有许多参数的接口(并且您可能希望使用一些默认值)。

正如我所说,这可能看起来反直觉,因为我刚才还在说面向接口编程,避免适配器等等,而这将是一个使用 sealed 甚至 static 类的“适配器”。但这并不意味着你将受限于某个实现,因为这样的外观仍将使用你的 IoC 容器或可配置工厂。这只意味着用户不会一直看到接口和工厂。创建服务和配置工厂的开发人员会看到这些,但只使用服务的开发人员将只会看到能够正确完成工作的本地类,而无需担心密集的函数调用和接口。

结论

我希望在阅读本文后,您能看到自制(或公司制作)的框架并没有那么糟糕。全球知名的框架不一定比您自己编写的框架更能帮助您的应用程序发展,并且您可以看到自制框架可以受益于使用实际存在的框架,同时保留完全用新框架替换旧外部框架的能力,而无需更改应用程序本身,只需要在这些框架尚未准备好适应您的代码时填充一些适配器。

最重要的一点结论是,如果您编写了一个框架,请允许该框架在已经引用了其他不会改变的库的应用程序中使用,因此,允许您的代码所需的所有信息通过创建事件来填充这些信息,如果这些信息尚未通过其他方式提供给您的框架,从而通过不同的方法找到。

© . All rights reserved.