框架模式

2021-08-07 22:55:28

软件框架是调用您的(应用程序)代码的代码。这就是我们如何将框架与库区分开来。图书馆有框架的方面,所以有一个灰色区域。我的朋友 Christian Theune 是这样说的:框架是一种文本,您可以在其中填补空白。框架定义了语法,你带上一些词。文字就是您带入其中的代码。如果你作为一个开发者使用一个框架,你需要告诉它你的代码。你需要告诉框架调用什么,什么时候调用。我们称之为配置框架。有很多方法可以配置框架。每种方法都有其自身的权衡。我将在此处描述其中一些框架配置模式,并提供简短示例并提及一些权衡。许多框架使用多个模式。我不认为这个列表是详尽无遗的——还有更多的模式。我描述的模式通常与语言无关,尽管有些取决于特定的语言特征。其中一些模式在面向对象的语言中更有意义。与另一种语言相比,有些语言更容易用一种语言完成。一些语言具有丰富的运行时自省能力,这使得某些模式更容易实现。具有强大宏功能的语言将使其他模式更容易实现。在我给出示例代码的地方,我将使用 Python。我给出了一些抽象的代码示例,并尝试提供一些实际示例。这些示例从应用程序开发人员的角度展示了框架。这是一个 Form 类,您可以在其中传入一个函数,该函数实现了保存表单时应该发生的事情。

from framework import Form def my_save ( data ): ... 将数据保存在某处的应用程序代码 ... my_form = Form ( save = my_save ) 您可以使用这种方法走得很远。函数式语言可以。如果您以某种方式浏览 React,它配置了一大堆称为 React 组件的回调函数,以及更多称为事件处理程序的回调函数。我是这种方法的忠实粉丝,因为在许多情况下权衡都是有利的。在面向对象的语言中,这种模式有时会被忽略,因为人们觉得他们需要更复杂的东西,比如传入一些花哨的对象或进行继承,但我认为回调函数实际上应该是你首先考虑的。函数易于理解和实现。合约很简单,因为它可以用于代码。实现功能所需的任何内容都由框架作为参数传入,这限制了使用框架所需的知识量。回调函数的配置在运行时可以是非常动态的——例如,您可以根据存储在数据库中的一些配置动态地组装或创建函数并将它们传递到框架中。带有回调函数的配置并没有真正突出,这可能是一个缺点——更容易看到有人子类化一个基类或实现一个接口,并且语言集成的配置方法可以更突出。有时您想一次配置多个相关功能,在这种情况下,实现接口的对象可能更有意义——我将在下面描述这种模式。

如果您的语言支持函数闭包,它会有所帮助。当然,您的语言需要实际支持您可以传递的一流功能——Java 很长一段时间都没有。该框架提供了一个基类,您作为应用程序开发人员可以对其进行子类化。您实现了框架将调用的一个或多个方法。 from framework import FormBase class MyForm (FormBase): def save ( self , data ): ... 应用程序代码将数据保存在某处... class AccountViewSet ( viewsets . ModelViewSet ): """ 一个用于查看和编辑帐户的简单视图集。 """ 查询集 = 帐户。对象。 all () serializer_class = AccountSerializer permission_classes = [ IsAccountAdminOrReadOnly ] ModelViewSet 做了很多事情:它实现了很多 URL 和请求方法来与它们交互。它与 Django 的 ORM 集成,以便您获得可用于创建和更新数据库对象的 REST API。当你重写一个方法时,你是否可以在 self (this) 上调用其他方法?是否有特定的顺序允许您调用这些方法?如果基类已经提供了一个实现,您需要知道它是要补充还是重写,或两者兼而有之。

如果打算对其进行补充,则需要确保在实现中的超类上调用此方法。如果您可以完全覆盖一个方法,您可能需要知道要使用哪些方法在框架中发挥作用 —— 可能是其他可以被覆盖的方法。基类是否继承自其他也允许您覆盖方法的类?当你实现一个方法时,它可以与这些其他类上的其他方法交互吗?许多面向对象的语言都支持继承作为语言特性。你可以让子类实现多个相关的方法。使用继承作为让应用程序使用和配置框架的一种方式似乎很明显。因此,这种设计在框架中非常普遍也就不足为奇了。但是我尽量在我自己的框架中避免它,当框架强迫我进行子类化时,我经常感到沮丧。这样做的原因是您作为应用程序开发人员必须开始担心上述许多问题。如果你很幸运,他们可以通过文档来回答,尽管理解它仍然需要一些努力。但你经常不得不自己猜测或阅读代码。然后即使有一个设计良好的基类和合理的可覆盖方法,你仍然很难做你真正需要的事情,因为基类的契约不适合你的用例。

Java 和 TypeScript 等语言为框架实现者提供了一种指导方式(私有/受保护/公共、最终)。框架设计者可以对允许覆盖的方法进行严格限制。这消除了其中的一些问题,因为框架设计者付出了足够的努力,语言工具可以强制执行契约。即便如此,这样的 API 对您来说可能很复杂,而且框架设计人员也难以维护。许多语言,例如 Python、Ruby 和 JavaScript,都没有提供此类指导的工具。您可以对任何基类进行子类化。您可以覆盖任何方法。唯一的指导是文档。您可能会因此而感到有些失落。框架往往会随着时间的推移而发展,让您可以覆盖更多类中的更多方法,从而增加复杂性。这种复杂性不会随着方法的添加而线性增长,因为您还必须担心它们的交互。一个必须处理覆盖各种方法的各种子类的框架对它们的期望会降低。太多的灵活性会使框架更难提供有用的功能。基类也不太适合运行时动态——某些语言(如 Python)确实允许您使用自定义方法动态生成子类,但这种代码难以理解。我认为子类化的缺点超过了框架外部 API 的优点。我有时仍然在库或框架内部使用基类——基类是在那里重用的轻量级方式。在这种情况下,许多缺点都消失了:您自己控制基类合同,并且您可能理解它。我有时也使用一个空基类来定义接口,但这确实是我接下来要讨论的另一种模式。该框架提供了一个您作为应用程序开发人员可以实现的接口。您实现了框架调用的一种或多种方法。

from framework import Form , IFormBackend class MyFormBackend ( IFormBackend ): def load ( self ): ... 应用程序代码在这里加载数据... def save ( self , data ): ... 应用程序代码将数据保存在某处.. .my_form = Form(MyFormBackend()) Python中的iterable/iterator协议就是一个接口的例子。如果你实现它,框架(在这种情况下是 Python 语言)将能够用它做各种各样的事情——打印它的内容,把它变成一个列表,把它反转等等。 class RandomIterable : def __iter__ ( self ) : return self def next ( self ): if random 。 choice ([ "go" , "stop" ]) == "stop" : raise StopIteration return 1 许多类型语言都提供对接口的本机支持。但是如果你的语言不这样做呢?在动态类型语言中,您实际上不需要做任何事情:anyobject 可以实现任何接口。只是你没有真正从语言中得到很多指导。如果你想要多一点怎么办?在 Python 中,您可以使用标准库 abc 模块或 zope.interface。您还可以使用 Typing 模块并实现基类以及 Python3.8、PEP-544 协议。但是,假设您没有所有这些,或者还不想打扰,因为您只是在进行原型设计。你可以使用一个简单的 Python 基类来描述一个接口:

class IFormBackend : def load ( self ): “从后端加载数据。应该返回一个带有数据的字典。” raise NotImplementedError () def save ( self , data ): “将数据字典保存到后端。” raise NotImplementedError () 它什么都不做,这才是重点——它只是描述了应用程序开发人员应该实现的方法。您可以提供一两个简单的默认实现,但仅此而已。您可能很想在其上实现框架行为,但这会将您带入基类领域。权衡与回调函数的权衡非常相似。如果您想在单个包中定义相关功能,这是一个有用的模式。如果我的框架提供了一个应用程序需要实现的更广泛的契约,我会选择接口,特别是如果应用程序需要维护自己的内部状态。接口的使用可以导致干净的面向组合的设计,您可以将一个对象适应另一个对象。您可以像使用函数一样使用运行时动态,在这些函数中您可以组装动态实现接口的对象。许多语言提供接口作为语言特性,任何面向对象的语言都可以伪造它们。或者有太多的方法来做到这一点,比如 Python。

当您有一个框架可以调度范围广泛的输入,并且您需要插入处理它的特定于应用程序的代码时,您将调整某种类型的注册表。注册的可以是回调或实现接口的对象——因此它建立在这些模式上。框架可以有特定的方式来配置他们的注册表,这些注册表建立在这个基本模式之上——我稍后会详细说明。 from framework import form_save_registry def save ( data ): ... 将数据保存在某处的应用程序代码 ... # 我们配置用于名为'my_form' form_save_registry 的表单的保存函数。 register ( 'my_form' , save ) URL 路由器(例如 Web 框架中)使用某种类型的注册表。这是 Falcon Web 框架中的一个示例:在此示例中,您可以看到两种模式结合在一起: QuoteResource 实现了一个(隐式)接口,并且您将它注册到了一个特定的路由。应用程序代码可以为各种路由注册处理程序,然后框架使用注册表将请求的 URL 与路由匹配,然后可以全部写入用户代码以生成响应。

我经常使用这种模式,因为它很容易实现并且对于许多用例来说已经足够好了。它有一个小缺点:当您阅读代码时,您无法轻易看到正在发生的配置。有时我会在它上面公开一个更复杂的配置 API:一个 DSL 或语言集成的注册或声明,我稍后会讨论。但这是基础。在注册表上调用方法是注册事物的最简单和直接的形式。它很容易实现,通常基于哈希映射,但您也可以使用其他数据结构,例如树。注册顺序可能很重要。如果您进行两次相同的注册会怎样?也许注册表拒绝第二次注册。也许它允许它,默默地覆盖前一个。与我稍后描述的模式不同,没有通用系统来处理这个问题。注册可以在应用程序的任何地方完成,这使得动态配置框架成为可能。但这也会导致复杂性,并且如果其配置可以随时更新,则框架可以提供较少的保证。在支持导入时副作用的语言中,您可以在导入时进行注册。这使得声明更加突出。这很容易实现,但也很难控制和理解导入的顺序。这使得应用程序开发人员难以覆盖。通常在导入期间做大量工作会导致难以预测行为。该框架会根据您在应用程序代码中使用的约定自动配置自身。配置通常由特定的名称、前缀和后缀驱动,但框架也可以检查代码的其他方面,例如函数签名。 Ruby on Rails 使之出名。 Rails 将通过匹配名称自动配置数据库模型、视图和控制器。

# 框架查找前缀为 form_save_ 的内容。它将这个 # 与 `myform` 挂钩,后者在名为 `forms` 的模块的别处定义 def form_save_myform ( data ): ... 将数据保存在某处的应用程序代码 ... pytest 使用约定优于配置来查找测试。它查找以 test_ 为前缀的模块和函数。 def test_ehlo ( smtp_connection ): response , msg = smtp_connection 。 ehlo() assert response == 250 assert 0 # 用于演示目的 在这个例子中,pytest 知道 test_ehlo 是一个测试,因为它以 test_ 为前缀。它还知道参数 smtp_connection 是 afixture 并在同一模块(或其包中)寻找一个。 Django 在某些地方使用约定优于配置,例如当它在一个特殊命名的模块中查找变量 urlpatterns 以找出应用程序提供的 URL 路由时。约定优于配置可能很棒。它允许用户输入代码并让它在没有任何仪式的情况下工作。它可以强制执行有用的规范,使代码更易于阅读——无论如何,在测试前加上 test_ 是有意义的,因为这样可以让人类读者识别它们。对于某些用例,我喜欢适度的约定而不是配置。对于更复杂的用例,我更喜欢其他模式,这些模式允许通过使用集成到语言中的功能(例如注释或装饰器语法)以最少的仪式进行注册。

框架的约定越多,缺点就越多。你必须学习规则,它们的相互作用,并记住它们。即使您不想,有时也可能会意外调用它们,只是使用了错误的名称。您可能希望以一种非常有用的方式构建应用程序的代码,但实际上并不符合约定。如果您希望您的注册是动态的,例如基于数据库状态怎么办?约定优于配置在这里是一个障碍,而不是帮助。开发人员可能需要回退到不同的命令式注册 API,这可能定义不明确且难以使用。框架更难实现某些模式——例如,如果注册需要参数化,该怎么办?这对函数和对象来说很容易,但这里的框架可能需要更多特殊的命名约定来让您影响它。这可能会导致框架设计者使用类而不是函数,因为在许多语言中,这些可以具有具有特定名称的属性。静态类型检查对于配置约定几乎没有用——我不知道一个类型系统可以强制你实现各种方法,例如,如果你用名称 View 后缀你的类。如果您有一种语言具有足够的运行时内省功能,例如 Ruby、Python 或 JavaScript,则实现约定过度配置非常容易。对于不提供这些功能的语言来说要困难得多,但是如果有足够的编译器魔法,它仍然是可能的。但是那些相同的语言通常在显式方面很重要,而对配置魔法的约定并不真正适合这一点。许多编程语言为使用元数据注释函数、类等提供了一些语法帮助。 Java 有注释。 Rust 有属性。Python 有装饰器,也可以用于这个目的。 from framework import form_save_registry # 我们同时定义和配置函数@form_save_registry.register('my_form') def save(data): ...应用程序代码将数据保存在某处...

我有时会使用这种配置软件的方法,但我也知道它的局限性——我倾向于使用语言集成声明,如下所述,它看起来与最终用户相同,但更具可预测性。我比大多数人更谨慎地将其作为 API 公开给应用程序开发人员,但我很高兴在库或代码库中使用它,就像基类一样。导入时副作用的特殊性质使我能够接触到更复杂的模式当我必须构建可靠的 API 时的配置。这种模式至少在 Python 中实现起来是轻量级的——它并不比注册表难多少。您的里程将因语言而异。与配置上的约定不同,配置是明确的并且在代码中脱颖而出,但仪式的数量保持在最低限度。配置信息与正在注册的代码位于同一位置。在像 Python 这样的语言中,这被实现为可能显着的导入时间副作用,并且可能具有令人惊讶的导入顺序依赖性。在像 Rust 这样的语言中,这是通过编译器宏魔法来完成的——我认为 Rocketweb 框架就是一个例子,但我仍在努力理解它是如何工作的。您使用 DSL(域特定语言)来配置框架。这个 DSL 提供了一些挂钩自定义代码的方法。 DSL 可以是完全自定义的语言,但您也可以利用 JSON、YAML 或(颤抖)XML。您还可以组合这些:我帮助实现了一个使用 JSON 配置的工作流引擎,其中的表达式是带有自定义解析器和解释器的 Python 表达式的子集。我们有一种自定义语言(在本例中使用 JSON 完成),可以让我们配置系统的工作方式。在这里,我们通过引用某些 Python 模块 my_module 中的函数 save 来插入 my_form 的保存行为。

Pyramid 和 Plone 都是 Zope 的后代,您可以将 ZCML,一种 XML 派生的配置语言与它们一起使用。 <configure xmlns="http://namespaces.zope.org/zope" xmlns:browser="http://namespaces.zope.org/browser" i18n_domain="my.package" > <!-- override folder_contents -- > <configure package="plone.app.content.browser" > <browser:page for="Products.CMFCore.interfaces._content.IFolderish" class="my.package.browser.foldercontents.MyFolderContentsView" name="folder_contents" template="folder_contents.pt" layer="my.package.interfaces.IMyPackageLayer" permission="cmf.ListFolderContents" /> &l ......