Drupal8-模块开发第二版-全-
Drupal8 模块开发第二版(全)
原文:
zh.annas-archive.org/md5/238ce4fcb4e64d80728acda19b2c6c65译者:飞龙
前言
Drupal 8 是一个强大的基于 Web 的内容管理系统(CMS),可以用来构建从简单网站到强大应用的一切。虽然它开箱即用很有用,但它是为开发者设计的。
本书的目的在于讨论扩展 Drupal 8 网站以提供新功能的最常见方式。在这样做的同时,本书将涵盖多个扩展点,并展示许多可以帮助您建模、结构和连接业务需求的子系统以及 API。
除了必要的理论解释外,它还将采用一种基于实例的实用方法,以便分解复杂主题并使其更容易理解。因此,请加入我,一起探索 Drupal 8 实际上有多么强大。
本书面向对象
本书的主要目标受众是希望学习如何在 Drupal 8 中编写模块和进行开发的 Drupal 7 开发者。它也面向具有基本面向对象编程技能的 Drupal 网站构建者,以及有少量 Drupal 经验的 PHP 程序员。
有一点 Symfony 经验将有所帮助,但不是强制性的。
本书涵盖内容
第一章,为 Drupal 8 开发,提供了对 Drupal 8 模块开发的介绍。在这样做的同时,它向读者介绍了各种子系统,并概述了运行 Drupal 8 应用程序的要求。
第二章,创建您的第一个模块,通过创建本书的第一个 Drupal 8 模块来启动。其主要重点是探索模块开发者从一开始就需要了解的常见事项。
第三章,日志和邮件,介绍了用于执行每个基于 Web 的应用程序都做和/或应该做的事情的工具;即发送电子邮件和记录事件。
第四章,主题化,从模块开发者的角度介绍了 Drupal 8 的主题系统。
第五章,菜单和菜单链接,探讨了 Drupal 8 中的菜单世界,并展示了如何通过编程方式创建和使用菜单链接。
第六章,数据建模和存储,探讨了 Drupal 8 中可用的各种存储类型,从状态系统到配置和实体。
第七章,您的自定义实体和插件类型,从创建自定义配置和内容实体类型以及用于连接实际功能示例的自定义插件类型的角度出发,采取了一种动手实践的方法。
第八章,数据库 API,介绍了数据库抽象层,并讨论了我们如何直接与存储在自定义表中的数据进行交互。
第九章,自定义字段,举例说明了创建用于在 Drupal 8 内容实体类型上使用的自定义字段所需的三个插件。
第十章,访问控制,探讨了 Drupal 8 中访问限制的世界,从角色和权限到路由和实体访问检查。
第十一章,缓存,探讨了模块开发者可用的各种缓存机制,以改善其功能性能。
第十二章,JavaScript 和 AJAX API,向模块开发者介绍了在 Drupal 8 中编写 JavaScript 的特定性,以及可以用来构建高级交互的强大 AJAX 系统。
第十三章,国际化与语言,处理 Drupal 8 模块开发者需要遵循的实践,以确保应用程序可以正确翻译。
第十四章,批处理、队列和 Cron,探讨了模块开发者可以以可靠的方式结构化他们的数据处理任务的各种方法。
第十五章,视图,探讨了模块开发者可以通过编程方式与视图交互的各种方法,甚至将自己的数据暴露给视图。
第十六章,处理文件和图像,探讨了允许模块开发者存储、跟踪和管理 Drupal 8 中文件的各个文件和图像 API。
第十七章,自动化测试,探讨了开发者可以为他们的 Drupal 8 应用程序编写的各种自动化测试模块,以确保代码的稳定性和弹性。
第十八章,Drupal 8 安全,探讨了在开发 Drupal 8 模块时需要遵循的最常见原则。
为了充分利用这本书
读者不需要太多东西就可以跟随这本书。一个能够安装和运行 Drupal 8 的本地环境(最好是带有 Composer)就足够了。
下载示例代码文件
您可以从www.packt.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择 SUPPORT 选项卡。
-
点击代码下载和勘误表。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 的 WinRAR/7-Zip
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Drupal-8-Module-Development-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/上找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781789612363_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“自定义 Drupal 8 模块通常位于根 Drupal 安装目录下的/modules文件夹中的/custom目录内。”
代码块设置如下:
name: Hello World
description: Hello World module
type: module
core: 8.x
package: Custom
任何命令行输入或输出应如下所示:
cd core
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“用户现在可以通过点击每个实现此钩子的模块的帮助链接,从模块管理页面访问此页面。”
警告或重要注意事项如下所示。
小贴士和技巧如下所示。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果你对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送邮件。
勘误表: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问www.packt.com/submit-errata,选择你的书,点击勘误表提交表单链接,并输入详细信息。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com。
评论
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packt.com.
第一章:为 Drupal 8 开发
Drupal 是一个基于 Web 的内容管理系统(CMS)。虽然它自带功能强大,但它是为开发者设计的。本书的目的是解释 Drupal 可以以多种方式扩展,用于许多目的。为此,我们将使用本书撰写时的最新版本——Drupal 8.7。
在这本书中,我们将涵盖一系列的开发主题。我们将讨论如何创建一个 Drupal 8 模块,随着我们阅读章节,许多概念和技巧将帮助你构建你所需要的内容。目标是不仅解释事物是如何工作的,还要通过一些示例来演示它们。由于没有一本书可以包含一切,我希望在阅读这本书之后,你能够利用我引用的资源以及查看 Drupal 核心代码本身来扩展这些知识。尽管这样的书对于学习任何类型的软件开发都非常有帮助,但如果你真的想进步,你需要应用你所学的知识并亲自探索源代码。只有通过这样做,你才能理解具有许多依赖和层的复杂系统。
本章介绍了开发 Drupal 8 所需的术语、工具和流程。虽然后续章节侧重于代码,但本章侧重于概念。我们将讨论 Drupal 的架构以及你如何在战略位置挂钩 Drupal 以扩展它以完成新任务。
在本章中,我们将涵盖以下主要主题:
-
Drupal 开发的简介
-
Drupal 8 架构
-
Drupal 的主要子系统
-
Drupal 开发工具
在本章结束时,你将了解 Drupal 的架构方面,并准备好开始编写代码。
Drupal 简介(面向开发者)
Drupal 传统上具有基于 Web 的内容管理系统(CMS)的所有标准功能:
-
访问者可以查看网站上发布的信息,浏览菜单,查看列表和单个页面等
-
用户可以创建账户并留下评论
-
管理员可以管理网站配置并控制用户的权限
-
编辑员可以在内容准备好后创建、预览并发布内容
-
内容可以同步到 RSS,其中新闻阅读器可以在发布时获取新文章
-
通过几个内置主题,甚至可以轻松更改网站的外观和感觉
然而,Drupal 8 在这些方面进行了改进并引入了一些更强大的功能。例如,高级多语言支持、内容审核、布局构建、REST API 以及许多其他功能现在都是自带功能。
为 Drupal 8 开发
虽然这些功能很棒,但它们肯定无法满足所有用户的需求。为此,Drupal 的功能可以通过模块、主题和安装配置文件轻松扩展。查看 Drupal 的主网站(drupal.org),您将找到数千个提供新功能的模块和数千个可以改变应用程序或网站外观和感觉的主题。
Drupal 可以通过模块和主题机制灵活扩展和转换的方式,导致许多人声称 Drupal 不仅仅是一个 CMS,而是一个内容管理框架(CMF),能够根据特定需求和功能要求进行重新配置。这尤其适用于 Drupal 8——这是 Drupal 的最新版本,也是本书的重点,因为在可扩展性方面取得了巨大进步。
判断 Drupal 是否正确地被称为 CMS 或 CMF 超出了我们在这里的目的,但可以肯定的是,Drupal 最巨大的资产是其可扩展性。想要使用目录服务器进行身份验证?有 Drupal 模块可以做到这一点。想要将数据导出到逗号分隔值(CSV)文件?有多个模块可以做到这一点(取决于您想要导出哪些数据)。对 Facebook 支持、与 Twitter 集成或添加“分享此内容”按钮感兴趣?是的,也有模块可以做到这些——所有这些模块都在 Drupal.org 上提供,由像您这样的开发者提供。
想要将 Drupal 与您编写的用于解决特殊业务需求的定制工具集成?可能没有现成的模块可以做到这一点,但通过一点代码,您可以自己编写。实际上,这正是本书的主题——为您提供知识和工具,以实现您自己的目标。
总结来说,本书的目的是让您尽快熟悉 Drupal 8 模块开发。随着我们逐章推进,我们将介绍您将用于构建自定义 Drupal 站点的 API 和工具,并且不会局限于理论。大多数章节都提供了实际操作、面向实践示例代码,旨在向您展示如何实现我们将要讨论的概念。我们将遵循 Drupal 编码规范,并利用 Drupal 设计模式,以展示在 Drupal 开发环境中编写代码的正确方式。
虽然我当然无法编写满足您需求的精确代码,但我希望这些章节中提到的代码可以作为您更大、更好的应用的基石。
让我们开始一些初步事项,以便更好地理解 Drupal。
推动 Drupal 发展的技术
在Drupal.org和安装目录中的INSTALL.txt文件中都有关于以传统方式安装 Drupal 8 的文档,所以这里我就不详细说明了。然而,我会提到,对于开发者来说,一种更好的安装 Drupal 8 的方式是使用 GitHub 上提供的 Drupal 8 项目的标准 Composer 模板(github.com/drupal-composer/drupal-project)。不过,设置网站的说明在那里也得到了很好的覆盖。
相反,让我们来谈谈推动(或需要)Drupal 8 的技术。
PHP
Drupal 是用 PHP 编程语言编写的。PHP 是一种广泛支持的、多平台和以网络为中心的脚本语言。由于 Drupal 是用 PHP 编写的,因此本书将主要介绍 PHP 代码,尽管会考虑到 Drupal 的标准实践。
非常重要的是要注意,Drupal 8 运行(以及通过 Composer 安装)所需的最小 PHP 版本是 7.1。因此,PHP 5 不再由 Drupal 或整个 PHP 社区支持。在你阅读这本书的时候,你可能会在 PHP 7.3 或至少 7.2 上运行 Drupal。
关于 PHP 的风格,与 Drupal 7 相比,一个非常重要的变化是大量使用面向对象的代码和设计模式。诚然,在 Drupal 8 代码库中仍然存在许多过程式风格的实现方法,但大量使用流行的外部库(如 Symfony 组件)已经推动了 Drupal 代码的整体现代化。因此,如果你想要进行 Drupal 8 开发,至少应该对面向对象编程(OOP),尤其是 PHP 相关的,有一些基本的了解。
数据库和 MySQL
在过去,Drupal 支持两种数据库——MySQL 和 PostgreSQL。Drupal 7 和 8 已经超越了这一点。现在,Drupal 使用的是 PHP 7 标准库中的强大PHP 数据对象(PDO)库。这个库是一个抽象层,允许开发者支持包括 MySQL、PostgreSQL、SQLite 和 MariaDB 在内的多种数据库。
Drupal 8.7 所需的最小数据库版本如下:
-
MySQL 5.5.3/MariaDB 5.5.20/Percona Server 5.5.8 或更高版本,需要 PDO 和兼容 InnoDB 的主存储引擎
-
PostgreSQL 9.1.2 或更高版本,配合 PDO SQLite 3.7.11 或更高版本
-
SQLite 3.7.11 或更高版本
此外,Drupal 提供了一个强大的数据库 API 以及 SQL 编码规范,这使得与数据库交互变得容易——结合这些,可以让你编写安全且可移植的 SQL。然而,在不同层面上已经做了越来越多的抽象,几乎完全消除了编写 SQL 的需求。但是,我们仍将看到一些示例,以确保你的工具箱不遗漏任何东西,同时也会介绍你可用于查询数据库的所有工具。
网络服务器
Apache 长期以来一直是主导的网络服务器,但绝非唯一的服务器。虽然 Drupal 最初是为 Apache 编写的,但许多其他网络服务器(包括 IIS、Lighttpd 和 NGINX)也可以运行 Drupal。
在本书中,我们没有明确涵盖任何关于网络服务器层的部分,主要是因为开发很少需要在这个低级别工作。然而,Drupal 期望网络服务器层进行相当多的处理,包括 URL 重写处理。有关您可以期待的内容的更多信息,您可以查阅相关文档页面www.drupal.org/docs/8/system-requirements/web-server。
HTML、CSS 和 JavaScript
实际上的网络数据格式是带有层叠样式表(CSS)的 HTML。客户端交互组件是用 JavaScript 编写的。作为 Drupal 开发者,我们将在本书中遇到这三种技术。虽然您不需要成为 JavaScript 忍者就能理解这里的代码,但如果您对这三种技术感到舒适,您将能从本书中获得最大收益。
Drupal 架构
在上一节中,我们介绍了驱动 Drupal 的技术。然而,它们是如何相互配合的呢?Drupal 代码是如何组织的?在本节中,我们提供了 Drupal 架构的概述,重点关注 Drupal 8。
Drupal 核心、模块和主题
从建筑学的角度来看,我们可以将 Drupal 分解为三个部分:其核心、模块和主题。
当我们讨论 Drupal 8 的核心时,我们可以有两种解释。一种较为严格的解释认为它是指它所附带的所有代码所覆盖的功能,不包括模块和主题。而更为普遍的解释则认为它是指它所附带的总代码库(即开箱即用)。
尽管最普遍的解释是后者(至少因为它区分了其标准安装包含的所有功能与所有其他由贡献的模块和主题提供的功能),但考虑第一种解释也很有趣,即使只是片刻。因为这样做,我们可以从架构上区分基本代码与提供各种功能和布局的模块和主题。这种区分为什么有趣呢?因为在这两者之间的桥梁上发挥作用的是钩子和事件,这也会允许我们注入对我们自己功能的联系。
核心库由属于 Drupal 项目及其从更广泛的 PHP 社区借用的代码组成,Drupal 在开源许可下借用这些代码。这种后一种方法在 Drupal 8 中是新的,并且被许多人视为朝着离开 Drupal 孤岛和拥抱外部库、框架和社区的积极转变。
从本质上讲,核心库提供了 Drupal 中使用的函数和服务。例如,与数据库交互的辅助工具、语言之间的翻译、用户数据清理、构建表单、编码数据和许多此类实用工具都可在 Drupal 的核心库中找到。
模块(包括核心模块和贡献模块)是实际业务逻辑封装的主要地方。如果启用,它们可以提供功能或扩展现有功能。大多数核心模块都是必需的,不能禁用,因为它们在标准 Drupal 安装中的重要性。然而,贡献模块可以根据需要安装和卸载。
主题(包括核心主题和贡献主题)是主题系统的重要组成部分,并由表示层使用。它们提供 HTML 模板,内容和数据可以在其中渲染给用户,以及 CSS 样式甚至一些客户端脚本,以实现一些美观的交互。主题可以扩展其他主题,也可以包含一些 PHP 逻辑来处理在渲染之前的数据。
钩子、插件和事件
现在我们已经看到了核心库、模块和主题的作用,让我们简要谈谈钩子和事件,以了解它们是如何相互关联的。
钩子是 Drupal 非常典型的过程式概念,允许 Drupal 核心和模块从其他模块和主题(或暴露它们)收集数据。通过这样做,后者可以提供新的功能或修改现有功能。使用钩子实现返回的内容的责任在于调用钩子的代码。后者需要返回的格式通常在钩子文档中描述。
具体来说,钩子通过扫描已安装的模块和主题,寻找遵循特定命名模式的函数(换句话说,一个钩子实现)。在大多数情况下,格式如下—module_name_hook_name。此外,还有alter钩子,它们在函数名末尾附加了“alter”一词,用于更改传递给钩子实现的引用数据。我们将在本书后面的章节中看到钩子的示例。
有 OOP 背景或对设计模式有深入了解的开发者可能会认识到这与被动观察者模式中捕获的事件处理范例相似。当某个特定事件发生时,Drupal 允许模块有机会对此事件做出响应。
在 Drupal 的早期版本中,钩子(hooks)是王者。是的,我用大写字母写了这句话;我的Caps Lock并没有卡住。这是因为它们是向模块中添加或扩展功能的方式。因此,它们是 Drupal 编程最重要的一个方面。然而,在 Drupal 8 中,尽管仍然很重要,但它们让位于新的概念,如插件和事件。
在 Drupal 8 中,我敢说插件是王。许多曾经通过钩子与 Drupal 关联的逻辑现在都通过插件(不要与 WordPress 插件混淆)添加进来。Drupal 8 插件是由管理者集中管理的功能块,用于执行特定任务和功能。我们将在本书的后面部分更多地了解插件并提供许多示例。
Drupal 8 引入的第三个扩展点是事件系统。然而,与前面两个不同,这并不是 Drupal 特有的,实际上它是实际的 Symfony EventDispatcher组件(symfony.com/doc/current/components/event_dispatcher.html)。事件主要用于 Drupal 中拦截某些动作或流程,以便停止或修改它们。许多过去通过钩子处理的请求到响应任务现在通过派发事件来处理,以检查是否有模块对,例如,向用户提供响应感兴趣。
服务和依赖注入容器
Drupal 8 的另一个重要的架构元素是 Symfony 依赖注入组件(symfony.com/doc/current/components/dependency_injection.html),具体由服务容器表示。
这个组件是现代 OOP PHP 编程的基石,因此已经成为 Drupal 8 的基础。它允许我们在代码的各个地方创建服务,以便处理某些功能(通常是可替换的)任务。此外,它们也可以用作扩展点,因为服务容器能够将具有非常特定职责的服务分组,并自动使用它们。换句话说,仅仅通过定义一个简单的服务,我们就可以提供自己的功能,甚至改变现有的逻辑。
我们将遇到许多服务,我们将在本书的后面部分看到我们如何声明自己的服务。
从请求到响应
现在我们已经列出了 Drupal 最重要的架构组件,让我们简要地看看这些组件是如何在处理用户在 Drupal 8 网站上提出的请求时被使用的。为此,我们将分析一个简化的请求示例,看看它在 Drupal 8 网站上的处理方式:
-
用户在网页浏览器中访问了
http://example.com/node/123URL。 -
浏览器联系
example.com上的 Web 服务器,并请求/node/123资源。 -
Web 服务器识别出请求必须由 PHP 处理,并启动(或联系)一个 PHP 环境来处理请求。
-
PHP 执行 Drupal 的前端控制器文件(
index.php),然后从请求的资源中创建一个新的Request对象。 -
Symfony 的 HTTPKernel 通过派发一系列事件来处理这个请求对象,例如
kernel.request、kernel.controller、kernel.response和kernel.view。 -
通过
kernel.request事件识别映射到该请求的路由。 -
路由控制器被识别,并使用
kernel.controller事件对负责的控制器进行任何修改,以及解决需要传递给它的参数。在我们的情况下,这个路由是通过主实体系统由节点模块注册的,该系统识别实体 ID,加载它,并构建作为响应一部分返回的标记。 -
如果相应的控制器(或处理器)返回的不是响应对象,则触发
kernel.view事件以检查是否有任何代码可以将它转换成响应对象。在 Drupal 8 中,在大多数情况下,我们通常返回渲染数组,这些数组被转换成响应对象。 -
一旦创建响应,前端控制器将其返回给浏览器并终止请求。
在这个背景下,作为 Drupal 8 模块开发者,我们大部分时间都在控制器和服务内部,试图弄清楚我们需要返回给页面的内容。然后我们依赖 Drupal 将我们的渲染数组转换成对用户适当的响应,但我们也可以直接返回一个响应。此外,主题系统和块系统也在这里发挥作用,因为我们的内容被包裹在一个块中,这个块被放置在由其他包含块的区域所环绕的区域中。如果现在听起来很复杂,请不要担心;我们将通过示例详细解释所有这些方面,很快就会变得清晰易懂。
Drupal 的主要子系统
在前面的内容中,我们以鸟瞰的方式了解了 Drupal 的架构。现在,我们将稍微调整我们的视角。我们将遍历 Drupal 8 提供的主要子系统。
路由
所有这一切都始于一个路由,不是吗?与 Drupal 8 网站的几乎所有交互都是从用户(或系统)访问某个路径(或资源)开始的。这转化为一个路由,将那个资源映射到一个流程,该流程(希望)返回一个成功的响应,或者至少是一个优雅的失败。
Drupal 8 的路由系统与之前版本相比发生了重大转变。在 Drupal 7 及之前版本中,路由系统是一个非常具有 Drupal 特色的特性(如果你愿意,可以称之为Drupal 主义)。我们许多人还记得hook_menu是一个每个 Drupal 开发者都必须非常熟悉的钩子。所有这些都已经在 Drupal 8 中被遗弃,转而采用 Symfony 路由组件(http://symfony.com/doc/current/components/routing.html)。此外,既然我提到了hook_menu,我还会提到它的其他主要功能也已经在 Drupal 8 中被其他子系统所取代,例如插件。
在第二章,创建您的第一个模块中,我们将了解如何定义我们自己的路由并将其映射到将渲染我们页面的控制器。我们将介绍一些更重要的路由选项,并查看我们如何控制对这些路由的访问。
实体
逐渐地,实体已经成为在 Drupal 中建模数据和内容的一种非常强大的方式。最著名的实体类型始终是节点,它一直是内容存储和显示的基础。在 Drupal 8 中,整个实体系统已经进行了全面改革,使其他实体类型可能同样重要。它们被推到了前台,并且与其他系统进行了适当的连接。
所有实体类型都可以有多个bundle,它们是同一实体类型的不同变体,并且可以在它们上具有不同的字段(同时共享一些基本字段)。
Drupal 核心仍然附带节点实体类型,包括在其标准安装配置文件中的几个 bundle,如基本页面和文章。此外,它还附带了一些其他实体类型,如User、Comment和File。然而,在 Drupal 8 中创建自己的实体类型已经比 Drupal 7 更加标准化,在 Drupal 7 中,需要引入贡献模块才能实现这一点。
这些并不是我们在 Drupal 8 中拥有的唯一类型的实体。前面提到的例子都是内容实体类型。然而,Drupal 8 还引入了配置实体类型。前者用于建模内容,但实际上,它们用于任何可以存储在数据库中并且特定于该环境的数据。尽管如此,它们并不用于存储配置。用户和内容是很好的例子,因为它们通常不需要(通常)从一个环境部署到另一个环境。另一方面,后者是可导出的配置项,可能不止一个。例如,内容实体包是一个很好的例子,因为对于某种实体类型,可能存在多个包;它们存储了一些可以不同包而不同的元数据和信息,并且需要在所有环境中部署。也就是说,它们是网站正确运行的基础。
在 Drupal 8 中进行开发时,理解实体系统是必不可少的,因为它提供了一种强大的方式来建模自定义数据和内容。节点并不是完成这项工作的唯一工具,在我看来,在之前的 Drupal 版本中,由于缺乏适当的实体架构,它们被过度使用了。
字段
现在我们已经了解了实体是什么,让我们来看看数据实际上是如何在这些实体上存储的。
在前面的章节中,我已经提到了某些实体包可以具有各种字段。这意味着每个实体类型包都可以有任意数量的字段来存储数据。此外,每个实体类型本身也可以有用于存储数据的字段。好吧,那么呢?让我们来分解一下。
Drupal 8 中有两种类型的字段——基本字段和可配置字段。前者是在代码中为每种实体类型定义的字段,而后者通常在 UI 中创建和配置,并附加到该实体类型的包(并通过配置导出)。
字段也可以有多种类型,这取决于它们存储的数据。您可以拥有字符串(或文本)字段、数字字段、日期字段、电子邮件字段等等。作为开发者,如果现有的字段类型不足以满足我们的数据需求,我们可以创建自己的字段类型。
在这本书中,我们将探讨如何定义特定实体类型上的基本字段,并创建我们自己的字段类型,它有自己的数据输入小部件和输出格式化程序。然后,网站构建者可以在任何实体类型上使用这种字段类型。
菜单
任何网站都需要某种形式的导航,对吧?Drupal 不仅维护内容,还提供了有关网站自身组织方式的详细信息。也就是说,它保持了一个内容之间关系结构的结构。
它主要通过菜单子系统来完成这项工作。后者提供了生成、检索和修改描述网站结构的元素的 API。用通俗的话来说,它处理系统的导航菜单。
菜单是分层的,也就是说,它们具有树状结构。一个菜单项可以有多个子项,每个子项也可以有自己的子项,依此类推。这样,我们可以使用菜单系统来将我们的网站结构化为章节和子章节。
在本书中,我们将看到如何以编程方式与菜单和菜单链接一起工作。
视图
列出内容和数据一直是内容管理系统所渴望的重要功能;这正是 Drupal 8 中视图所做的事情。而且它做得很好。
如果你以前使用过 Drupal 的早期版本来构建(甚至不一定开发)网站,那么你只需用这个简单的短语就能理解一切——视图现在已经是 Drupal 核心的一部分了。
如果你还没有这样做,视图一直是 Drupal 贡献模块中的基本模块,可能在所有 Drupal 安装(在一定程度上)上都得到了使用,并且是网站构建者和甚至开发者的不可或缺的工具。
视图模块的目的是以允许创建可配置列表的方式暴露数据和内容。它包括过滤器、排序、显示选项以及许多其他功能。作为开发者,我们经常发现需要编写自己的字段或过滤器插件来与视图一起工作,或者从我们的自定义实体或外部数据源中暴露数据。
视图是核心 Drupal 8 模块,与通用架构绑定,用于 Drupal 核心提供的多数列表页面(特别是管理页面)。尽管它是一个非常以网站构建为导向的工具,但在本书中,我们将探讨如何创建插件来扩展其功能,为网站构建者提供更多功能。
表单
除非你的网站只有三页和五段文字,否则你很可能需要通过某种形式的表单来捕获用户输入。此外,如果你曾经编写过 PHP 应用程序,你就会知道从安全有效地渲染和处理提交数据的角度来看,表单一直是个头疼的问题。一旦你使用像 Symfony 或 Laravel 这样的 PHP 框架,你就会注意到有一个 API 可以帮你减轻很多负担。
这同样适用于 Drupal 8 及其强大的表单 API。从历史上看,它是在输出自己的表单元素和处理提交值方面的一个很好的抽象。它允许你以面向对象的方式定义自己的表单定义,并以逻辑方式处理验证和提交。其渲染和处理由 Drupal 安全地处理,因此你不必担心任何这些问题。在 Drupal 8 中,主题化表单元素比以前版本要容易得多。
在本书中,我们将遇到一些表单,并了解它们在实际中的工作方式。
配置
Drupal 开发者(以及其他流行 CMS 的开发者)的一大烦恼始终是配置的处理和部署方式,从一种环境到另一种环境。Drupal 7 将大部分配置存储在数据库中,因此随着开发进程的推进,开发者不得不想出各种解决方案来提升这一过程。
在 Drupal 8 中,通过引入集中式配置系统,在这方面取得了重大进步。尽管它将所有配置存储在数据库中,但它允许所有配置导出到 YML 文件中(然后重新导入)。因此,从开发角度来看,如果某些功能依赖于配置(例如,一个新的字段),我们将拥有更好的体验。
配置也有两种类型——简单和复杂(我们在实体部分提到的配置实体)。两者的区别在于前者始终是单一的。换句话说,只有一个实例。例如,网站名称和电子邮件地址存储在这样一个配置项中。你不会期望需要超过一个实例。然而,在后者的情况下,你会。例如,视图定义就是这样一种配置实体,因为它遵循一定的模式,我们可以有多个视图定义。这说得通吗?
插件
插件是 Drupal 8 的新功能,是对一个重要问题的优雅解决方案——封装功能。一开始,你不应该将它们与 WordPress 插件等混淆,后者更像是 Drupal 模块。相反,你应该将插件视为可由中央系统使用和管理的可重用代码组件。通常,当系统以某种方式(插件 A)处理任务时,它允许其他模块提供不同的处理该任务的方式(插件 B 或 C)。
你也可以将插件视为与实体相反:不是用于数据存储,而是用于功能。你创建的不是一种存储的数据类型,而是一种使用的功能类型。这两种通常协同工作,尤其是在以不同方式操作数据时。
他们的工作方式的一个重要方面是它们的可发现性。大多数插件类型(但肯定不是全部)都是通过一种称为注释的东西来发现的。注释是一种 DocBlock 注释的形式,借鉴自 Doctrine 库(docs.doctrine-project.org/projects/doctrine-common/en/latest/reference/annotations.html),通过它我们可以用某些元数据来描述类、方法和甚至属性。然后读取这些元数据来确定该项是什么,而不需要实例化该类。在 Drupal 8 中,我们只在类级别使用注释来表示它是一个具有某些特性的插件实现。这就是大多数插件在 Drupal 8 中是如何被发现的。
插件的第二种最常见发现方法是通过 YAML 文件,其中最受欢迎的例子是菜单链接(正如我们将在本书后面看到的那样)。然而,现在你应该知道插件被非常广泛地使用,在这本书中我们将创建相当多的插件。
插件是开发者添加他们自己功能的一个很好的新扩展点,也是 Drupal 8 的一个关键子系统。每个 Drupal 8 开发者都需要熟悉插件系统。
主题系统
为特定数据主题化的责任分散在 Drupal 核心、模块和主题本身上。因此,作为一个模块开发者,了解模块和主题都可以主题化数据或内容是很重要的。
在本书中,我们将关注模块级别的方面。我们不会关注样式,但主要与模块内所需的主题定义和模板一起工作。通常,确保模块能够主题化其数据是最佳实践。如果做得正确,主题就可以发挥作用来样式化输出或覆盖主题以完全改变展示方式。
与旧版本相比,Drupal 8 的一大转变是转向开源的 Twig 模板系统 (https://twig.sensiolabs.org/)。这使得逻辑与展示的分离更加清晰,使得前端开发者的工作更加容易,更不用说更加安全了。
缓存
我将在下面包括的最后一个主要子系统是缓存层。Drupal 8 付出了巨大的努力来提高构建页面和渲染数据的表现力。为此,缓存系统已成为在执行复杂或重量级计算或渲染内容时必须考虑的重要部分。
从模块开发者的角度来看,缓存系统有两个主要支柱。第一个为开发者提供了一个缓存后端来存储复杂数据计算的结果。这些结果可以在后续请求中读取,以避免重新处理该任务的需要。这与当系统中发生变化需要重新计算时发生的缓存失效密切相关。第二个支柱是渲染缓存,它允许开发者用描述何时需要使该输出的缓存失效的元数据包装他们的输出。
我们将在后续的缓存章节中看到这些工具的实际应用。
其他子系统
Drupal 8 中还有其他不同重要性的子系统。我选择包括前面提到的那些,因为我认为它们是最重要的,尤其是在模块开发者的视角下。然而,随着我们继续阅读本书,我们肯定会遇到其他系统。
Drupal 开发工具
Drupal 是一个复杂的平台,从本章提供的窥视中,我们已能看出有许多系统和结构需要跟踪。在本节中,我将提供简化或优化开发过程的工具。
今后,我假设你已经有了自己的 Web 服务器堆栈和自己的 PHP 开发工具。然而,如果你是刚开始,你可能想看看 Acquia Dev Desktop(来自 Acquia acquia.com/)。它提供了整个应用程序堆栈,让你在 Windows、Linux 或 macOS X 上开始。或者,如果你更加先进一些,你可以考虑 Drupal VM(www.drupalvm.com/),这是一个基于 Vagrant 和 Ansible 的本地开发环境,适用于 Drupal。
最后,在我看来,最灵活的开发环境是基于 Docker 的。你可以很容易地从这里开始使用一个预先制作且文档齐全的堆栈:github.com/wodby/docker4drupal。
至于代码编辑器,我个人使用 PhpStorm(正如许多人一样),但你完全可以选择你想要的任何 IDE,因为 Drupal 本身并不需要任何特殊的东西。然而,确实使用某种 IDE,因为它会使你的生活更加容易。
此外,虽然运行 PHP 调试器绝对不是必要的,但你可能会发现运行 Xdebug 或 Zend Debugger 是有用的。我个人强烈推荐 PHP 调试器,不仅因为调试本身,还因为可以理解底层的运行过程。
版本控制
任何软件开发都需要通过版本控制的环境进行。到目前为止,Drupal 已经普遍使用 Git。因此,你应该确保你已经在本地安装了 Git,即使只是为了能够检出我们在这本书中编写的代码示例,这些代码示例托管在 GitHub 上。
Composer
如我之前所提到的,安装 Drupal 8 最好是使用 Composer 模板项目。然而,你也可以直接从 Git 安装,通过检出 Drupal.org Git 仓库中的最新标签或提交(www.drupal.org/project/drupal/git-instructions)。如果你这样做,你需要通过 Composer 安装其依赖项,而 Drupal 有很多依赖项。
为了达到这个目的,你需要在你的开发环境中安装 Composer,并且对如何使用它有一个基本的了解。
API 站点和编码标准
编写好的 Drupal 代码需要大量的背景知识。当然,这本书的目的是尽可能提供这些背景知识。然而,自我文档和调研仍然至关重要,Drupal 开发者应该手头上有一些资源。
第一份资源是官方的在线 API 文档。Drupal 中的几乎所有功能都使用内联代码文档进行记录。然后使用 Doxygen 程序提取这些文档并格式化。您可以在api.drupal.org在线访问完整的 API 文档。
除了使用 Drupal API,我们还努力遵守 Drupal 的编码规范。软件开发的最佳实践包括保持代码整洁、一致和可读。这其中的一个方面是通过遵循固定标准来消除代码格式的细微差别。
在像 Drupal 这样的平台上,这一点尤为重要,因为数千名开发者都在贡献代码。如果没有编码标准,代码将变成一团糟的样式混合体,宝贵的开发时间将浪费在仅仅解码代码而不是实际工作上。
Drupal 网站有一份关于编码标准的指南,每个 Drupal 开发者都需要熟悉(www.drupal.org/docs/develop/standards/coding-standards)。这不会一夜之间发生;您会随着经验而变得更好,但您也可以配置您的 IDE,例如,标记任何与您的代码格式相关的问题。
对于新接触 Drupal 8 但已有 Drupal 7 经验的开发者来说,第三个资源是变更记录数据库(www.drupal.org/list-changes/drupal)。在这个页面上,您可以找到最重要的 API 和用法变更清单,以及一些实用的解释,这将极大地帮助那些查找某些函数如何变更的 Drupal 7 开发者。
开发者(Devel)模块
在您的开发环境中,您可以安装一个名为 Devel(drupal.org/project/devel)的实用模块,它提供了一些旨在帮助开发者创建和调试 Drupal 代码的复杂工具。
以下是这个模块的一些功能:
-
用于将对象和数组输出到格式化 Drupal 输出的函数
-
分析数据库使用和性能的工具
-
用于快速填充测试内容的生成器
Drush(Drupal 外壳)
有时,在控制台中用一条命令运行一些任务要容易得多。Drush(drupal.org/project/drush)提供了一个命令行 Drupal 界面,可以在控制台中通过几个按键执行任务。
在开发过程中,我们经常需要清除缓存、运行特定任务或将数据部署到远程服务器。Drush 可以帮助完成这些任务。此外,我们还可以编写自己的 Drush 命令来执行各种自定义任务,例如用于 cron 作业的任务。因此,对于任何严肃的 Drupal 开发者来说,安装 Drush 是必须的。
Drupal 控制台
如果 Drush 是一个存在多年的工具,那么 Drupal Console (drupalconsole.com/) 项目对于 Drupal 8 来说则是新的。它的目的与 Drush 类似,并且以这种方式补充了它,有时甚至与之重叠。然而,有一点很清楚——它的范围要广泛得多,尤其是在它方便的命令生成样板代码方面,这些代码可能会相当长。
虽然我们在这本书中不会使用这个工具,但建议你在学习 Drupal 8 模块开发并开始快速生成某些代码结构时安装它。但在此前提下,我建议在使用它时要谨慎,实际上要理解它生成的代码实际上做什么。始终努力理解你在做什么,并且永远不要盲目复制粘贴 Stack Overflow 或其他资源中的代码,而不完全了解它的作用。
开发者设置
在进行本地开发时,禁用缓存(有时)以加快速度是有益的。Drupal 8 将缓存提升到了一个新的水平,因此许多钩子实现都得到了缓存。为了避免这种情况,我们可以使用一些本地设置来禁用缓存,防止 CSS 和 JavaScript 文件聚合,以及做类似的事情。
这些设置位于安装目录中/sites文件夹内的example.settings.local.php文件中。为了从中受益,你需要确保它们包含在你的主要settings.php文件中(通过复制它们或包含一个类似此文件的方式)。
警告——请务必记住,始终禁用缓存进行开发,你可能会忽略某些在启用缓存时无法正常工作的方面(例如无效化)。因此,请尝试切换这些设置以确保生产环境与开发条件下的工作效果一样好。
摘要
本章为开发者概述了 Drupal 8。我们了解了 Drupal 使用的技术。我们审视了 Drupal 的架构。我们简要地浏览了 Drupal 的几个突出子系统。我们还感受到了在处理 Drupal 时应该使用哪些面向开发者的工具。
从下一章开始,我们将开始处理代码。实际上,接下来的每一章都将侧重于与 Drupal 一起工作的实际方面。
在下一章中,我们将使用必选的 Hello World 示例创建我们的第一个 Drupal 8 模块。
第二章:创建您的第一个模块
现在我们已经了解了 Drupal 8 模块开发的一些入门方面,现在是时候深入探讨我们在这里所做的工作的核心——模块创建。
在本章中,我们将涵盖的一些重要主题包括:
-
创建新的 Drupal 8 模块——启动所需的文件
-
创建路由和控制器
-
创建和使用服务
-
创建表单
-
创建自定义块
-
与链接一起工作
-
使用事件调度器
具体来说,在本章中,我们将创建一个名为 Hello World 的新自定义模块。在这个模块中,我们将定义一个路由,它映射到一个控制器并输出古老的编程信息。所以,这将是我们第一次的成功。
接下来,我们将定义一个控制器将使用的服务来提升我们的消息。毕竟,我们不想整天向用户展示相同的信息。然而,这个简单的例子将说明服务是什么以及如何与服务容器交互以使用它们。
然后,我们将创建一个表单,管理员将能够覆盖我们页面上显示的消息。它将存储在配置中,我们将修改我们的服务以利用该配置。这里的关键收获将是 Form API 的使用。然而,我们还将讨论如何存储一些基本的配置值并添加依赖到我们现有的服务中。
最后,我们希望变得更加灵活。为什么用户只能在特定的页面上被问候呢?我们将创建一个自定义块,它可以放置在网站的任何位置并显示相同的信息。在这里,我们将看到如何定义块插件以及它们如何暴露自己的配置表单以实现更大的灵活性。
虽然这与我们的 Hello World 示例没有严格的关系,但我们还将探讨如何在 Drupal 8 中以编程方式处理链接。这对于任何 Drupal 8 开发者来说都是非常常见的任务,他们需要经常执行。此外,我们还将探讨使用事件调度器组件,更重要的是,订阅事件。我们将通过一个相当常见的例子来说明这一点——执行来自传入请求的重定向。
到本章结束时,你应该具备构建自己模块所需的基础知识。此外,你应该能够理解和实现 Drupal 8 模块开发中最常用的技术。
创建模块
创建一个简单的 Drupal 8 模块并不困难。你只需要一个文件就能让核心安装识别它,并能够启用它。在这个状态下,它不会做很多事情,但它将是可安装的。让我们首先看看如何做到这一点,然后我们将逐步添加内容,以达到本章开头设定的目标。
自定义 Drupal 8 模块通常位于根 Drupal 安装中/modules文件夹内的/custom目录中。您会将贡献模块放在一个/contrib目录中,以便有明确的区分。这是一个标准做法,所以我们将把我们的自定义模块放在那里,称为Hello World。
我们将首先创建一个名为hello_world的文件夹。这也将是模块在许多其他地方使用的机器名。在里面,我们需要创建一个信息文件来描述我们的模块。此文件命名为hello_world.info.yml。这种命名结构很重要——首先,模块名称,然后是info,最后是.yml扩展名。您经常会听到这个文件被称为模块的info文件(因为过去版本的 Drupal 中它有.info扩展名)。
在此文件中,我们需要添加一些最小信息来描述我们的模块。我们将采用如下方式:
name: Hello World
description: Hello World module
type: module
core: 8.x
package: Custom
其中一些是自解释的,但让我们看看这些行意味着什么:
-
前两个代表模块的可读名称和描述。
-
type关键字表示这是一个模块信息文件,而不是主题。在 Drupal 8 中,这已成为强制性的。 -
core关键字指定此模块与 Drupal 8 版本兼容,并且它无法在先前或未来的版本上安装。 -
最后,我们将它放在通用的
Custom包中,以便它在模块管理屏幕的这个组中分类。
大概就是这样。现在,您可以通过/admin/modules界面或使用drush en hello_world命令通过 Drush 启用此模块。
在我们继续之前,让我们看看您可以在信息文件中添加哪些其他选项(并且可能需要在某个时候添加):
模块依赖性: 如果您的模块依赖于其他模块,您可以在其信息文件中指定,如下所示:
dependencies:
- drupal:views
- ctools:ctools
依赖项应按project:module格式命名,其中project是 Drupal.org 上项目 URL 中出现的项目名称,module是模块的机器名。您甚至可以包括版本限制,例如,ctools:ctools (>=8.x-3.x)。
配置: 如果您的模块有一个集中配置选项的一般配置表单,您可以在信息文件中指定该表单的路由。这样做将在安装模块的admin/modules UI 页面上添加到该表单的链接。
您的第一个钩子实现
按照目前的状态,此模块并没有做什么。实际上,它什么也不做。然而,请给自己鼓掌,因为您已经创建了您的第一个 Drupal 8 模块。在我们继续到我们计划的有意思的内容之前,让我们实现我们的第一个钩子,它负责提供有关我们模块的一些有用信息。
如我们在第一章中暗示的那样,当 Drupal 遇到一个存在钩子的事件(并且有数百个这样的事件)时,它会遍历所有模块以查找匹配的钩子实现。那么,它是如何找到匹配的实现呢?它会寻找以module_name_hook_name格式命名的函数,其中hook_name被正在实现的钩子名称所替换。钩子的名称是hook_之后的内容。我们将在实现hook_help()时看到一个示例。然而,一旦找到实现,它将依次执行每个实现。一旦所有钩子实现都执行完毕,Drupal 将继续其处理。
根据模块的大小,建议你将所有的钩子实现放在一个.module文件中。然而,在某些情况下,你可能会将它们组织在其他文件中,要么是你自己将这些文件包含在.module文件中,要么是使用特定的文件命名约定,让 Drupal 包含它们。但是,目前我们坚持使用默认设置。
因此,让我们在我们的模块文件夹中创建一个名为hello_world.module的.module文件,并在顶部放置一个 PHP 标签。然后,我们可以在其中放置以下hook_help()实现(以及通常所有其他钩子实现):
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function hello_world_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.hello_world':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('This is an example module.') . '</p>';
return $output;
default:
}
}
如您所见,函数的名称遵循上述格式——module_name_hook_name——因为我们正在实现hook_help。所以,我们将hook替换为模块名称,将hook_name替换为help。此外,这个特定的钩子接受两个参数,我们可以在其中使用它们;尽管在我们的情况下,我们只使用一个,即路由名称。
此钩子的目的是为 Drupal 提供一些关于此模块做什么的帮助文本。你并不总是需要实现此钩子,但了解它是有好处的。它的工作方式是,每个新模块都在主模块内部获得自己的路由,用户可以浏览这些信息——我们的路由是help.page.hello_world。因此,在这个实现中,我们将告诉 Drupal(以及更具体地说,核心Help模块)以下内容:如果用户正在查看我们的模块的帮助路由(页面),则显示$output变量中包含的信息。就是这样。
根据 Drupal 编码标准,钩子实现上方的 DocBlock 消息需要保持简短和简洁,就像前面的例子一样。我们通常不会为 Drupal 核心钩子或流行的contrib模块钩子添加任何其他文档,因为它们应该在别处进行文档化。然而,如果你正在实现你模块中定义的自定义钩子,添加一个描述其功能的第二段是可以的。
用户现在可以通过点击每个实现此钩子的模块的帮助链接,从模块管理页面访问此页面。简单,对吧?

尽管我们并没有通过这个钩子提供任何有用的信息,但实现它帮助我们理解了钩子的工作原理以及使用它们的命名约定。此外,我们还看到了一个传统(过程式)的 Drupal 扩展点的示例,模块开发者可以使用它。通过这样做,我们实际上扩展了帮助模块的功能,允许它向用户提供更多信息。
现在,让我们继续创建我们自己的内容。
路由和控制器
我们最初着手创建的功能性内容是一个简单的 Drupal 页面,它输出那个古老的 Hello World 字符串。为了做到这一点,我们需要两样东西——一个路由和一个控制器。所以,让我们从第一个开始。
路由
在我们的模块中,我们需要创建一个包含所有静态定义的路由的路由文件。这个文件的名称将是 hello_world.routing.yml。到现在为止,我假设你已经理解了 Drupal 8 模块中的文件命名约定。然而,无论如何,这又是一个我们需要放入 YAML 格式数据的 YAML 文件:
hello_world.hello:
path: '/hello'
defaults:
_controller: '\Drupal\hello_world\Controller\HelloWorldController::helloWorld'
_title: 'Our first route'
requirements:
_permission: 'access content'
这是我们的第一个路由定义。它以路由名称(hello_world.hello)开始,然后在其下方以 YAML 格式的多维数组形式提供所有必要的信息。标准做法是让路由名称以它所在的模块名称开始,然后根据需要添加路由限定符。
那么,路由定义包含什么呢?这里可以有多个选项,但为了现在,我们将坚持使用那些为我们服务的简单选项。
想要了解更多关于所有路由配置选项的信息,请访问www.drupal.org/docs/8/api/routing-system/structure-of-routes的相关文档页面。这是一个很好的参考资料,可以随时查阅。
首先,我们有一个路径键,它表示我们希望这个路由在此路径上工作。然后,我们有一个 defaults 部分,它通常包含在访问此路由时需要传递给处理器的相关信息。在我们的例子中,我们设置了负责传递页面的控制器和方法,以及它的标题。最后,我们有一个 requirements 部分,它通常与使此路由可访问(或被触发)需要满足的条件有关——例如权限和格式。在我们的例子中,我们将要求用户拥有 access content 权限,这大多数访客都会有。不用担心;我们将在后面的章节中详细介绍关于访问的内容。
这就是我们的第一个路由定义所需的所有内容。现在,我们需要创建一个控制器,它将映射到这个路由,并向用户传递一些内容。
在我们这样做之前,让我们看看一个非常常见的路由要求示例,你很可能很快就会用到它。我们在这个章节中构建的功能不需要这个,所以我不会将其包含在最终的代码中。然而,了解它是如何工作的非常重要。
路由变量
一个非常常见的需求是拥有一个变量路由参数(或更多),该参数被映射到路由的代码所使用,例如,你想显示的页面的 ID 或路径别名。这些参数可以通过将某个路径元素包裹在花括号中来添加,如下所示:
path: '/hello/{param}'
在这里,{param} 将映射到一个 $param 变量,该变量作为参数传递给负责此路由的控制器或处理器。因此,如果用户访问 hello/jack 路径,$param 变量将具有 jack 值,控制器可以使用它。
此外,Drupal 8 还附带了一些参数转换器,可以将参数转换成更有意义的东西。例如,一个实体可以被自动加载并直接传递给控制器,而不是一个 ID。如果没有找到实体,路由将充当 404,从而节省了我们一些好的代码行。为了实现这一点,我们还需要描述该参数,以便 Drupal 知道如何自动加载它。我们可以通过为该参数添加路由选项来实现:
options:
parameters:
param:
type: entity:node
因此,我们现在已经将 {param} 参数映射到了节点实体类型。因此,如果用户访问 hello/1,将加载 ID 为 1 的节点(如果存在)。
我们可以做得更好。如果我们不是用 {param} 而是用 {node}(实体类型的机器名)来命名参数,我们就可以完全避免在路由中编写参数选项。Drupal 会推断出它是一个实体,并会尝试自行加载该节点。很酷,不是吗?
所以,下次你需要编写动态路由时,请记住这些要点。
命名空间
在我们着手编写控制器之前,让我们先分析一下 Drupal 8 中的命名空间情况以及模块内部文件夹结构应该如何组织。
Drupal 8 使用 PSR-4 命名空间自动加载标准。实际上,这意味着所有 Drupal 核心和模块类的命名空间都以 \Drupal 开头。对于模块,基本命名空间是 \Drupal\module_name,其中 module_name 是模块的机器名。然后它映射到模块目录内的 /src 文件夹(对于主要集成文件)。对于 PHPUnit 测试,我们有一个不同的命名空间,正如我们在本书后面将要看到的。
因此,本质上,我们将在我们的模块中需要一个 /src 文件夹来放置所有需要自动加载的类。因此,我们可以继续创建它。
控制器
现在我们已经大致知道了我们需要放置控制器的地方,让我们首先在我们的模块的 /src 文件夹内创建一个 Controller 文件夹。虽然这不是强制性的,但这是控制器放置的标准做法。在这个文件夹内,我们可以放置我们的第一个控制器类文件:HelloWorldController.php。
在文件内部,我们再次有了一些简单的内容(在 PHP 开头标签之后):
namespace Drupal\hello_world\Controller;
use Drupal\Core\Controller\ControllerBase;
/**
* Controller for the salutation message.
*/
class HelloWorldController extends ControllerBase {
/**
* Hello World.
*
* @return array
*/
public function helloWorld() {
return [
'#markup' => $this->t('Hello World')
];
}
}
如预期的那样,我们首先声明命名空间。如果你阅读了前面的部分,命名空间的选择将变得有意义。然后,我们有我们的控制器类,它扩展了 Drupal 8 的ControllerBase,它恰好提供了一些辅助工具(例如StringTranslationTrait,我将在本书后面讨论语言时解释)。如果你记得我们的路由定义,我们有一个返回数组的helloWorld方法。
如果你曾经使用过 Drupal 的早期版本,这个数组(称为渲染数组)将很熟悉。否则,你现在需要知道的是,我们正在返回简单的标记,其中包含在上一段中提到的翻译服务包裹的Hello World文本。在控制器返回这个数组后,将有一个EventSubscriber来接收这个数组,将其通过 Drupal 主题层运行,并返回作为响应的 HTML 页面。控制器实际返回的内容将被包裹在通常放置在主题主要内容区域的主页面内容块中。
现在,我们简单的控制器已经完成。如果我们清除缓存并访问/hello,我们应该会遇到一个新页面,该页面输出我们的第一个路由标题和 Hello World 内容。成功!

服务
为什么我不喜欢这种方法?
我不希望控制器决定如何问候我的用户。首先,因为控制器需要保持简洁。我希望我的用户能够根据一天中的时间动态地被问候,这将增加复杂性。其次,也许我还会希望这种问候在其他地方也能实现,而我绝对不想在其他地方复制粘贴这段逻辑,也不会为了能够调用该方法而滥用控制器。解决方案?我们将构建问候的逻辑委托给一个服务,并在我们的控制器中使用该服务来输出问候。
什么是服务?
服务是一个由服务容器实例化的对象,用于以可重用的方式处理操作,例如执行计算和与数据库、外部 API 或任何数量的东西交互。此外,它还可以接受依赖项(其他服务)并使用它们来提供帮助。服务是现代 PHP 应用程序和 Drupal 8 中常用的一种核心原则——依赖注入(DI)的一部分。
如果你对这些概念没有经验,需要注意的是,它们也是全局注册在服务容器中,并且每个请求只实例化一次。这意味着在你从容器中请求它们之后修改它们,它们会保持修改后的状态,即使你再次请求它们。本质上,它们是单例。因此,你应该以保持不可变的方式编写你的服务,并且它们需要处理的大部分数据要么来自依赖项,要么从使用它的客户端传递进来(并且不影响它)。
许多 Drupal 8 核心服务定义可以在位于根 /core 文件夹中的 core.services.yml 文件中找到。所以,如果你在寻找要使用的服务名称,最好的办法就是查看那里。此外,核心模块也有它们各自的 *.services.yml 文件中的服务定义。所以,请确保你也查看那里。
HelloWorldSalutation 服务
现在我们对服务有一个大致的了解,让我们创建一个服务来实际看看这一切。
如我之前提到的,我希望我的问候语更加动态,也就是说,我希望问候语取决于一天中的时间。因此,我们将创建一个 (HelloWorldSalutation) 类来负责这个功能,并将其放置在 /src 文件夹中(我们的模块命名空间根,文件自然命名为 HelloWorldSalutation.php):
namespace Drupal\hello_world;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Prepares the salutation to the world.
*/
class HelloWorldSalutation {
use StringTranslationTrait;
/**
* Returns the salutation
*/
public function getSalutation() {
$time = new \DateTime();
if ((int) $time->format('G') >= 00 && (int) $time->format('G') < 12) {
return $this->t('Good morning world');
}
if ((int) $time->format('G') >= 12 && (int) $time->format('G') < 18) {
return $this->t('Good afternoon world');
}
if ((int) $time->format('G') >= 18) {
return $this->t('Good evening world');
}
}
}
从现在起,我不再总是提及特定类所属的文件名。因此,你可以安全地假设每个类对应一个文件,文件名与类名相同。
到现在为止,我假设命名空间的问题已经清楚,所以我就不再解释了。让我们看看我们还做了什么。首先,我们使用了 StringTranslationTrait 来公开翻译函数(我稍后会解释)。其次,我们创建了一个基本的方法,根据一天中的时间返回不同的问候语。这也许可以做得更好,但就这个例子而言,它已经足够好了。
在这个例子中,我使用了原生的 PHP 函数 time() 来获取当前时间。这是可以的。但你应该知道 Drupal 有它自己的 Drupal\Component\Datetime\Time 服务,我们可以用它来获取当前时间。它还有额外的请求特定时间信息的方法,所以请确保你查看它并在适当的时候使用。
现在我们有了我们的类,是时候将其定义为服务了。我们不想在我们的代码库中到处使用 new HelloWorldSalutation(),而是将其注册到服务容器中,并从那里作为依赖项使用。我们如何做到这一点?
首先,我们还需要一个 YAML 文件:hello_world.services.yml。这个文件以 services 键开始,下面将包含我们模块的所有服务定义。所以,我们的文件现在看起来是这样的(目前):
services:
hello_world.salutation:
class: Drupal\hello_world\HelloWorldSalutation
这是你可能拥有的最简单的服务定义。你给它一个名字(hello_world.salutation),并将其映射到要实例化的类。通常的做法是让服务名称以你的模块名称开头。
一旦我们清除缓存,服务将注册到服务容器中,并可供使用。
如果有理由相信你将拥有多个问候服务,你应该创建一个这个类可以实现的接口。这样,你就可以始终使用该接口而不是类进行类型提示,并使实现可交换。
标签服务
服务定义也可以被标记,以便通知容器它们是否服务于特定目的。通常,这些会被一个收集服务拾取,用于特定的子系统。例如,如果我们想标记hello_world.salutation服务,它看起来会是这样:
hello_world.salutation:
class: Drupal\hello_world\HelloWorldSalutation
tags:
- {name: tag_name}
标签也可以获得优先级,正如我们将在本书后面的某些示例中看到的那样。
在 Drupal 8 中使用服务
在我们使用我们创建的控制器中的服务之前,让我们喘口气,回顾一下一旦服务注册后你可以如何利用服务。
实际上有两种方式——静态和注入。第一种是通过对服务容器的静态调用完成的,而第二种则使用依赖注入通过构造函数(或在某些罕见情况下,设置器方法)传递对象。然而,让我们来看看如何、为什么以及真正的区别是什么。
静态地,你会使用全局的Drupal类来实例化一个服务:
$service = \Drupal::service('hello_world.salutation');
这就是我们如何在.module文件和类中使用服务,这些类没有暴露给服务容器,也无法注入。后者的实例很少见,大多数时候我们只从静态上下文中使用静态调用。
一些流行的服务在\Drupal类上也有简写方法:例如,\Drupal::entityTypeManager()。我建议你检查\Drupal类,并查看具有简写方法的那些。
在控制器、服务、插件或其他任何依赖注入是选项的类中使用服务实例化的静态方法并不是最佳实践,对我来说,这是个人无法接受的。原因是它破坏了使用服务的大部分目的,因为它将两者耦合在一起,使得测试变得噩梦般。另一方面,在钩子实现和其他 Drupal 特定的过程代码中,我们没有选择,这样做是正常的。
只因为代码在.module文件中,并不意味着它应该在那里。一般来说,这些模块应仅包含诸如钩子实现或任何其他需要遵守特定命名约定的实现。它们也应该精简,并将工作委托给服务。
正确使用服务的方法是在需要的地方注入它们。诚然,这种方法稍微耗时一些,但随着你的进步,它将变得自然而然。此外,由于有几种不同的依赖注入方式(基于接收者),我们在这里不会涵盖它们。相反,我们将在本书中适当的时候查看它们是如何工作的。我们现在将在下一节中查看一个非常重要的例子。
将服务注入我们的控制器
让我们现在继续我们的模块,看看如何将新创建的服务注入到我们的控制器中。
我们需要在控制器(通常在类开始处,这样我们可以在查看时立即识别出这段代码的存在)中添加一些代码:
/**
* @var \Drupal\hello_world\HelloWorldSalutation
*/
protected $salutation;
/**
* HelloWorldController constructor.
*
* @param \Drupal\hello_world\HelloWorldSalutation $salutation
*/
public function __construct(HelloWorldSalutation $salutation) {
$this->salutation = $salutation;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('hello_world.salutation')
);
}
此外,确保在文件顶部包含相关的use语句:
use Drupal\hello_world\HelloWorldSalutation;
use Symfony\Component\DependencyInjection\ContainerInterface;
那么,这里发生了什么?首先,我们给控制器一个构造函数,它接受我们的服务作为参数并将其存储为一个属性。对我来说,这通常是类中的第一个方法。但是这个构造函数是如何获取它的参数的呢?它是通过create()方法获取的,这个方法接收服务容器作为参数,并且可以自由选择控制器构造函数所需的服务。这通常是我类中的第二个方法。我更喜欢这种顺序,因为它很容易检查它们是否存在。此外,它们的存在很重要,尤其是在继承和观察父类注入的内容时。
好的,但是这种注入业务在现实中是如何工作的呢?
简而言之,在找到路由并解析出负责的控制器之后,会检查后者是否实现了ContainerInjectionInterface。我们的控制器通过其父类ControllerBase做到了这一点。如果是的话,控制器将通过create()方法实例化,并将容器传递给它。从那里,它负责使用容器中的所需服务创建一个新的静态版本自己——实际上并不复杂!
create()方法是 Drupal 8 依赖注入模式的标准实践,所以你会经常看到它。然而,有一点需要注意,你不应该将整个容器传递给你用它实例化的类,因为那时你不再进行依赖注入了。
关于我们扩展的ControllerBase的一些说明——扩展它是标准实践。它提供了一些很好的特性,实现了所需接口,并立即显示了类的用途。然而,从依赖注入的角度来看,我建议不要使用返回服务的辅助方法(例如,entityTypeManager())。不幸的是,它们静态地加载服务,这在这种情况下不是最佳实践。你应该像我们刚才做的那样自己注入它们。
好吧,让我们回到我们的例子。现在我们已经注入了服务,我们可以使用它来渲染动态问候语:
return [
'#markup' => $this->salutation->getSalutation(),
];
就这样。现在,我们的问候语取决于一天中的时间,我们的控制器取决于我们的问候语服务。
关于我们的示例,我想特别说明的是,为了简单起见,我忽略了缓存。如果开启缓存,页面将被缓存,并可能使用错误的问候语提供服务。然而,在第十一章,“缓存”,我们将涵盖所有这些复杂性,所以现在没有必要使我们的示例复杂化。
调用的控制器
现在我们知道了路由、控制器和服务的概念,我还想快速指出,控制器可以被定义为服务,并由路由系统调用。换句话说,就像我们定义了 hello_world.salutation 服务一样,我们还可以定义另一个充当控制器的作用的服务,并在路由文件中引用该服务 ID 而不是完全限定的类名。然后,为了使 Drupal 知道当用户访问路由时在服务中调用哪个方法,我们需要在服务中实现神奇的 __invoke 方法。其余的将基本上以相同的方式工作。
这种功能是在 Drupal 8.7 中引入的,并且是 Action-Domain-Responder 架构模式的典型特征。我们将来不会使用它,但了解它的可用性是好的。
表单
我们的页面根据一天中的时间动态显示问候语。然而,我们现在希望管理员指定问候语的实际内容,换句话说,如果他们选择的话,可以覆盖我们问候语的默认行为。
实现这一点的要素如下:
-
一个路由(一个新页面),显示一个表单,管理员可以设置问候语
-
一个将存储问候语的配置对象
在构建这个功能时,我们还将看看如何向我们的现有服务添加依赖项。所以,让我们开始我们的新路由,它自然位于我们已创建的 hello_world.routing.yml 文件中:
hello_world.greeting_form:
path: '/admin/config/salutation-configuration'
defaults:
_form: '\Drupal\hello_world\Form\SalutationConfigurationForm'
_title: 'Salutation configuration'
requirements:
_permission: 'administer site configuration'
这条路由定义的大部分内容与我们之前看到的相同。不过,有一个变化,那就是它映射到一个表单而不是控制器。这意味着整个页面都是一个表单页面。此外,由于路径位于管理空间内,它将使用站点的管理主题。现在需要做的只是在我们命名空间中的 /Form 文件夹内创建我们的表单类(这是一个存储表单的标准实践目录,但不是强制性的)。
由于继承的力量,我们的表单实际上非常简单。然而,我将解释后台发生的事情,并指导你构建更复杂的表单的道路。所以,这里是我们的表单:
namespace Drupal\hello_world\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Configuration form definition for the salutation message.
*/
class SalutationConfigurationForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return ['hello_world.custom_salutation'];
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'salutation_configuration_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('hello_world.custom_salutation');
$form['salutation'] = array(
'#type' => 'textfield',
'#title' => $this->t('Salutation'),
'#description' => $this->t('Please provide the salutation you want to use.'),
'#default_value' => $config->get('salutation'),
);
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->config('hello_world.custom_salutation')
->set('salutation', $form_state->getValue('salutation'))
->save();
parent::submitForm($form, $form_state);
}
}
在进行解释之前,我应该说明的是,这就是全部内容。清除缓存并导航到 admin/config/salutation-configuration 将会通过一个简单的配置表单展示给你,你可以通过这个表单保存自定义问候消息:

之后,我们将使用这个值。然而,首先,让我们先谈谈表单的一般情况,然后再具体谈谈这个表单。
Drupal 8 中的表单由一个实现 FormInterface 的类表示。通常,我们根据其用途要么从 FormBase 扩展,要么从 ConfigFormBase 扩展。在这种情况下,我们创建了一个配置表单,因此我们从后者类扩展。
在此接口中,有四种主要方法被使用:
-
getFormId(): 返回表单的唯一、机器可读的名称。 -
buildForm(): 返回表单定义(一个表单元素定义的数组和一些额外的元数据,如有必要)。 -
validateForm(): 调用来验证表单提交的处理器。它接收表单定义和一个$form_state对象,其中包含提交的值等。你可以在相应的表单元素上标记无效的值,这意味着表单不会被提交,而是刷新(带有被标记的元素)。 -
submitForm(): 当表单提交时(如果验证通过且没有错误)会被调用的处理器。它接收与validateForm()相同的参数。你可以执行诸如保存提交的值或触发其他类型的流程等操作。
简而言之,定义一个表单意味着创建一个表单元素定义的数组。生成的表单与我们之前提到的渲染数组非常相似,我们将在第二章“创建您的第一个模块”中更深入地描述它。在创建表单时,你有大量的表单元素类型可供选择。关于它们是什么以及它们的选项(它们的定义特定性)的完整参考可以在 Drupal 表单 API 参考页面上找到(api.drupal.org/api/drupal/elements/8.7.x)。在整个 Drupal 8 开发过程中,请将此页面放在手边。
从依赖注入的角度来看,表单可以从服务容器接收参数,就像我们将问候服务注入到我们的控制器中一样。事实上,我们在前面的表单中扩展的 ConfigFormBase 注入了 config.factory 服务,因为它需要用它来读取和存储配置值。这就是我们为什么要从那个表单扩展的原因。Drupal 充满了我们可以扩展的这些有用的类,它们提供了一堆在 Drupal 生态系统中非常常用的有用样板代码。
如果你正在构建的表单不存储或处理你的配置,你通常会从FormBase扩展,它提供了一些静态方法和特性,并实现了某些接口。对于其辅助服务方法,与ControllerBase一样,同样的警告也适用:如果你需要服务,你应该始终注入它们。
让我们转向我们之前的形式类,现在既然我们对表单有了点了解,就稍微剖析一下它。
我们有getFormId()方法。检查。我们还有buildForm()和submitForm(),但没有validateForm()。后者不是必需的,而且在我们这个例子中实际上并不需要它,但如果需要,我们可以有类似这样的东西:
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
$salutation = $form_state->getValue('salutation');
if (strlen($salutation) > 20) {
$form_state->setErrorByName('salutation', $this->t('This salutation is too long'));
}
}
在这个验证处理程序中,我们基本上检查提交给salutation元素的值是否超过 20 个字符。如果是这样,我们在该元素上设置一个错误(通常使其变红)并指定一个针对此错误的表单状态特定错误消息。然后表单将被刷新,错误将被显示,在这种情况下,提交处理程序将不会被调用。
然而,对于我们的示例来说,这并不是必要的,所以我将不会将其包含在最终的代码中。
默认情况下,表单验证错误消息会在页面顶部打印。然而,使用核心内联表单错误模块,我们可以将表单错误直接打印在实际元素下方。这对于可访问性来说要好得多,而且在处理大型表单时也更为清晰。请注意,标准的 Drupal 8 安装没有启用此模块,所以如果你想使用它,你必须自己启用它。
如果我们回到我们的表单类,我们还会看到一个奇怪的getEditableConfigNames()方法。这是由我们扩展的ConfigFormBase类中使用的ConfigFormBaseTrait要求的,它需要返回一个配置对象名称数组,这个表单打算编辑。这是因为有两种加载配置对象的方式:用于编辑和用于读取(不可变)。通过这个方法,我们通知它我们想要编辑那个配置项。
正如我们在buildForm()的第一行看到的,我们正在使用上述特性中的config()方法从 Drupal 配置工厂加载我们的可编辑配置对象。这是为了检查其中当前存储的值。然后,我们定义我们的表单元素(在我们的例子中,只有一个——一个简单的文本字段)。作为#default_value(用户访问表单时元素中存在的值),我们放入配置对象中的任何内容。其余的元素选项都是不言自明的,并且在整个元素类型中都很标准。请参考表单 API 参考以了解其他可用的选项以及适用于哪些元素类型。最后,在方法末尾,我们也调用了父方法,因为这样提供了表单的提交按钮,这对于我们的目的来说已经足够了。
我们编写的最后一个方法是提交处理程序,它基本上加载可编辑的配置对象,将提交的值放入其中,然后保存它。最后,它还调用父方法,该方法简单地使用Messenger服务将成功消息设置到用户屏幕上——这是从代码上下文中向用户显示成功或错误消息的标准方式。
大概就是这样;这会正常工作。
在 Drupal 的大部分生命周期中,向用户输出此类消息的方式是通过drupal_set_message()全局函数。这种情况在 Drupal 8 中也是如此,但从版本 8.5 开始已经弃用,转而使用Messenger服务(通过messenger服务名称访问)。对于 Drupal 7 的老用户来说,这是一个很大的调整,但重要的是要理解,尽管使用drupal_set_message()仍然有效,但它将在 Drupal 9 中被移除。因此,最好已经开始使用正确的服务。向前看,我将使用旧版本以避免在书中编写大量样板代码。但你不应该在您的代码中使用它。
从配置的角度来看,我们使用了ConfigFormBase来使我们的生活更轻松,并将表单方面与配置存储结合起来。在后面的章节中,我们将更详细地讨论不同类型的存储方式,以及如何更详细地处理配置对象,以及这些包含的内容。
改变形式
在继续我们的提议功能之前,我想开一个括号,更详细地讨论一下表单。作为模块开发者,你将要做的一件重要的事情是更改其他模块或 Drupal 核心定义的表单。因此,我们最好早点讨论这个问题,什么时候比现在更好,当我们定义表单时,它仍然在我们脑海中清晰。
显然,我们刚刚创建的表单属于我们,我们可以随意更改它。然而,许多表单都是由其他模块定义的,你将有很多次想要更改它们。Drupal 为我们提供了一个非常灵活的、尽管仍然是程序化的方法来做这件事——一套alter钩子;但什么是alter钩子?
在本章中,我们首先实现了hook_help()。这是一个调用钩子的例子,调用者(Drupal 核心或任何模块)要求所有其他模块提供输入。然后以某种方式聚合这些输入并加以利用。Drupal 中我们还有另一种类型的钩子,即alter钩子,它用于允许其他模块在数组或对象被用于任何目的之前对其进行更改。因此,在表单的情况下,有一些 alter 钩子允许模块在表单被处理以进行渲染之前对其进行更改。
你可能想知道我为什么说,为了修改表单,我们有多于一个的alter钩子。让我通过一个例子来解释其他模块如何修改我们刚刚定义的表单:
/**
* Implements hook_form_alter().
*/
function my_module_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
if ($form_id == 'salutation_configuration_form') {
// Perform alterations.
}
}
在前面的代码中,我们在名为my_module的模块中实现了通用的hook_form_alter(),它在构建表单时对所有表单都会触发。前两个参数是表单和表单状态(与我们在表单定义中看到的一样),前者是通过引用传递的。这是典型的alter概念——我们修改一个现有的变量,不返回任何内容。第三个参数是表单 ID,我们在表单类的getFormId()方法中定义的。我们检查以确保表单是正确的,然后我们可以对表单进行修改。
然而,这几乎总是错误的方法,因为钩子会为所有表单无差别地触发。即使我们实际上对其中大多数没有做任何事情,这仍然是一个无用的函数调用,更不用说如果我们想在模块中修改 10 个表单,那么将会有很多if条件——这是我们为过程函数付出的代价。不过,我们可以这样做:
/**
* Implements hook_form_FORM_ID_alter().
*/
function my_module_form_salutation_configuration_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
// Perform alterations.
}
这里,我们正在实现hook_form_FORM_ID_alter(),这是一个动态的修改钩子,因为它的名字包含了我们要修改的实际表单 ID。所以,通过这种方法,我们确保这个函数只在我们需要修改表单的时候被调用,另一个好处是,如果我们需要修改另一个表单,我们可以为它实现同样的逻辑,并且使我们的逻辑整洁地分离。
自定义提交处理程序
到目前为止,我们已经看到了其他模块如何修改我们的表单。这意味着添加新的表单元素,更改现有的元素等。但是,关于我们的验证和提交处理程序(当表单提交时被调用的方法)怎么办?如何修改它们?
通常情况下,对于像我们这样定义的表单,这很简单。一旦我们修改了表单并检查了$form数组,我们就可以找到一个#submit键,它是一个包含一个项目的数组——::submitForm。这仅仅是表单类上的submitForm()方法。所以,我们可以做的是要么移除这个项目并添加我们自己的函数,或者简单地向那个数组中添加另一个项目:
/**
* Implements hook_form_FORM_ID_alter(). */ function my_module_form_salutation_configuration_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) { // Perform alterations. $form['#submit'][] = 'hello_world_salutation_configuration_form_submit'; }
我们添加到#submit数组中的回调函数可以看起来像这样:
/**
* Custom submit handler for the form_salutation_configuration form.
*
* @param $form
* @param \Drupal\Core\Form\FormStateInterface $form_state
*/
function my_module_salutation_configuration_form_submit(&$form, \Drupal\Core\Form\FormStateInterface $form_state) {
// Do something when the form is submitted.
}
所以,酷的地方在于你可以选择添加自己的回调函数或者替换现有的一个。记住,它们在数组中的顺序就是它们被执行的顺序。因此,如果你想的话,也可以改变这个顺序。
尽管如此,还有另一种情况。如果表单上的提交按钮具有指定其自身处理器的#submit属性,那么我们刚才看到的默认表单#submit处理器将不再触发。我们的表单并非如此。因此,在这种情况下,您需要将您自己的处理器添加到该数组中。因此,唯一的区别是您将提交处理器附加的位置。此类表单的一个显著例子是 Node 添加/编辑表单。
最后,当涉及到验证处理器时,它与提交处理器的工作方式完全相同,但它都发生在#validate数组键下。
随意尝试修改现有的表单并检查它们作为参数接收的变量。我强烈建议您熟悉常见的表单数据,并将表单元素的文档放在附近(api.drupal.org/api/drupal/elements/8.7.x)。
渲染表单
在表单上停留一段时间,让我们快速学习如何程序化地渲染表单。我们已经看到如何将表单映射到路由定义,以便在访问路由路径时构建的页面包含表单。然而,有时我们需要在控制器内部、块或其他任何地方程序化地渲染表单。我们可以使用FormBuilder服务来完成此操作。
可以使用form_builder服务键注入表单构建器,或者通过简写方式静态使用:
$builder = \Drupal::formBuilder();
一旦我们有了它,我们就可以构建一个表单,如下所示:
$form = $builder->getForm('Drupal\hello_world\Form\SalutationConfigurationForm');
在前面的代码中,$form将是我们可以返回的表单的渲染数组,例如,在控制器内部。我们稍后会更多地讨论渲染数组,您将了解它们是如何转换为实际表单标记的。然而,现在,您只需要了解关于程序化渲染表单的所有信息——您从表单构建器中获取表单,并使用表单类的完全限定名称从它请求表单。
通过这种方式,我们可以关闭表单的括号。
服务依赖
在前面的部分中,我们创建了一个允许管理员设置要在页面上显示的自定义问候消息的表单。此消息存储在一个配置对象中,我们现在可以在我们的HelloWorldSalutation服务中加载它。所以,让我们通过两步过程来完成这个操作。
首先,我们需要修改我们的服务定义,为我们提供服务一个参数——Drupal 8 配置工厂(负责加载配置对象的服务的服务)。这是我们的服务定义现在应该看起来像这样:
hello_world.salutation:
class: Drupal\hello_world\HelloWorldSalutation
arguments: ['@config.factory']
添加的是参数的键,它是一个以@开头的服务名称数组。在这种情况下,config.factory是负责的服务名称,如果我们检查core.services.yml文件,我们会注意到它映射到Drupal\Core\Config\ConfigFactory类。
因此,通过这个更改,HelloWorldSalutation类将传递一个ConfigFactory实例。我们现在需要做的就是调整我们的类以实际接收它:
/**
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* HelloWorldSalutation constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
*/
public function __construct(ConfigFactoryInterface $config_factory) {
$this->configFactory = $config_factory;
}
这里没有发生什么太复杂的事情。我们添加了一个构造函数,并在一个属性上设置了配置工厂服务。我们现在可以使用它来加载我们在表单中保存的配置对象。然而,在我们这样做之前,我们应该在文件顶部use``ConfigFactoryInterface类:
use Drupal\Core\Config\ConfigFactoryInterface;
现在,在getSalutation()方法的顶部,我们可以添加以下内容:
$config = $this->configFactory->get('hello_world.custom_salutation');
$salutation = $config->get('salutation');
if ($salutation != "") {
return $salutation;
}
通过这个添加,我们正在加载我们在表单中保存的配置对象,并从中请求salutation键,如果你记得,我们在这里存储了我们的消息。如果其中有一个值,我们将返回它。否则,代码将继续,并应用我们之前基于时间的问候逻辑。
因此,现在如果我们重新加载我们的初始页面,通过表单保存的消息应该会显示出来。如果我们然后返回到表单并删除消息,这个页面应该默认回到原始的动态问候。不错,对吧?
现在,让我们看看我们如何创建一个自定义块,我们可以将其放置在我们喜欢的任何位置,并且它将输出与我们的页面相同的内容。
块
Drupal 8 中的块是插件。然而,你在 UI 中创建的块是内容实体,而它们在块布局中的位置都是配置实体。因此,块系统是实体和插件如何在 Drupal 8 中协同工作的一个很好的例子。我们将在本书的后面更详细地讨论插件类型和实体。
Drupal 8 中的块系统与其前辈相比是一个巨大的转变。在此之前,如果你想使块具有配置,你必须实现两个必需的钩子以及两个可选的钩子,后者总是保存在与块本身无关的地方。在 Drupal 8 中,我们使用一个简单的插件类,它可以被容器感知(也就是说,我们可以将其依赖项注入其中),并且我们可以以逻辑的方式存储配置。
那么,我们如何创建一个自定义块插件?我们只需要一个类,放置在正确的命名空间中——Drupal\module_name\Plugin\Block。在这种情况下(与插件一起),文件夹命名很重要。插件的发现性取决于插件类型本身,而这个有一个Plugin\Block命名空间的部分。但别再说了,让我们创建一个简单的块,它只渲染与我们的控制器之前所做的相同,我会在过程中解释。
我们的第一个块插件
因此,这是我们插件类——HelloWorldSalutationBlock——它正是这样做的:
namespace Drupal\hello_world\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\hello_world\HelloWorldSalutation as HelloWorldSalutationService;
/**
* Hello World Salutation block.
*
* @Block(
* id = "hello_world_salutation_block",
* admin_label = @Translation("Hello world salutation"),
* )
*/
class HelloWorldSalutationBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* The salutation service.
*
* @var \Drupal\hello_world\HelloWorldSalutation
*/
protected $salutation;
/**
* Construct.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param string $plugin_definition
* The plugin implementation definition.
* @param \Drupal\hello_world\HelloWorldSalutation $salutation * The salutation service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, HelloWorldSalutationService $salutation) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->salutation = $salutation;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('hello_world.salutation')
);
}
/**
* {@inheritdoc}
*/
public function build() {
return [
'#markup' => $this->salutation->getSalutation(),
];
}
}
在进行解释之前,你应该知道,清除缓存并通过 UI 块管理器放置此块将完成我们想要的功能。然而,让我们首先了解这里发生了什么。
可能最奇怪的是,在类顶部的 DocBlock 注释。这被称为注解,表示这个类是一个Block插件。正如我在第一章中提到的,注解是 Drupal 核心中插件最常见的发现机制。在这种情况下,我们需要的是由 ID 和管理标签组成的插件定义。
正确定义的插件类型有一个AnnotationInterface实现,它描述了可以在注解中使用或应该使用的属性。所以如果你不确定需要有什么,可以查找这个特定插件类型的这个类。
然后,我们发现我们的类扩展了BlockBase并实现了ContainerFactoryPluginInterface。前者,类似于我们之前看到的 Controller 和 Form,为块插件提供了一系列有用的功能。然而,我们实际上无法绕过扩展这个类,因为块插件相当复杂,需要处理诸如上下文和配置等问题。所以,请确保您始终扩展这个类。然而,后者是可选的。这个接口使得这个块插件容器感知,也就是说,在实例化的那一刻,它使用create()方法通过容器来构建自身,并且确实,我们在下面有我们的create()方法。
在继续实际构建块之前,我们需要谈谈插件中的依赖注入。正如你所看到的,这个create()方法的签名与我们在 Controller 中看到的不同。这也是为什么我们使用了一个不同的容器感知接口。原因是插件是用一些额外的参数构建的:$configuration、$plugin_id和$plugin_definition。第一个包含与插件存储的任何配置值(或在构建时传递的),第二个是插件注解中设置的 ID(或其他发现机制),第三个是一个包含此插件元数据的数组(包括在注解中找到的所有信息)。然而,除了这个之外,当涉及到依赖注入时,一切照旧。如果一个插件类型的基类没有实现这个接口,你可以在你的插件中直接这样做。并且这适用于大多数插件,除了少数不能被容器感知的异常,但这非常罕见。
最后,我们有一个build()方法,它负责构建块内容。它需要返回一个渲染数组(就像我们的 Controller 一样),正如你所看到的,我们正在使用我们注入的服务并返回相同的问候语。这就是我们实现目标所需做的。关于块插件的其他重要方面,我们将在后面的章节中介绍,例如缓存和访问,但我们有专门章节来讨论这些主题。
块配置
在我们结束对自定义块插件的讨论之前,让我们看看如何向其添加配置表单。这样,我们可以练习使用更多的表单 API 元素,并了解如何存储和使用块配置。
尽管我们的功能(目前)已经完整,但让我们想象一下,我们需要在我们的块配置上添加一个类似于布尔的控制,以便当管理员放置该块时,他们可以切换某个选项,并且这个值可以在 build() 方法中使用。我们可以通过在我们的插件类中添加三个到四个方法来实现这一点。
首先,我们需要实现 defaultConfiguration() 方法,在其中我们描述了为这个块存储的配置项及其默认值。因此,我们可以有如下内容:
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'enabled' => 1,
];
}
我们返回一个键和值的数组,这些键和值将包含在配置中。由于我们说我们将使用布尔字段,我们使用数字 1 作为名为 enabled 的虚构键的值。
接下来,我们需要实现 blockForm() 方法,它为我们提供这个配置项的表单定义:
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state) {
$config = $this->getConfiguration();
$form['enabled'] = array(
'#type' => 'checkbox',
'#title' => t('Enabled'),
'#description' => t('Check this box if you want to enable this feature.'),
'#default_value' => $config['enabled'],
);
return $form;
}
在文件顶部添加适当的额外 use 语句:
use Drupal\Core\Form\FormStateInterface;
如您所见,这是一个典型的表单 API 定义,用于类型为 checkbox 的一个表单元素。此外,我们使用了方便的 getConfiguration() 方法
的父类来加载与这个块一起保存的配置值。如果没有保存任何内容,请注意,enabled 键将包含在其中,其默认值为我们上面设置的(1)。
最后,我们需要一个提交处理程序来完成“存储”配置所需的操作。我使用了引号,因为我们实际上不需要做任何与存储相关的操作,只需将表单中提交的值映射到配置中的相关键即可。块系统会为我们完成这个操作:
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state) {
$this->configuration['enabled'] = $form_state->getValue('enabled');
}
这一点再简单不过了。现在如果我们将我们的自定义块放置在某个位置,我们看到的表单将包含我们的表单元素,允许我们切换 enabled 键。剩下要做的就是在这个 build() 方法中使用这个值。我们可以像在 buildForm() 方法中加载配置值那样做:
$config = $this->getConfiguration();
唉,在我们的示例块中我们实际上并不需要这个配置,所以我们不会将其添加到我们的代码中。然而,了解如何进行这一操作对你来说很重要,因此我们在这里进行了说明。此外,在继续之前,我还想明确指出,你可以使用一个可选的方法来处理配置表单的验证。该方法名为 blockValidate(),与 blockSubmit() 具有相同的签名,并且与我们在构建独立表单时看到的验证处理程序工作方式相同。所以,这里我就不再重复了。
处理链接
网络应用的一个主要特征是其资源之间错综复杂的链接。实际上,它们是将其粘合在一起的东西。因此,在本节中,我想向你展示一些在 Drupal 8 中以编程方式处理链接时常用的技术。
当谈论 Drupal 中的链接构建时,有两个主要方面——URL 和实际的链接标签本身。因此,创建一个链接涉及两个步骤,但也可以通过一些辅助方法缩短为一个调用。
URL
Drupal 8 中的 URL 由 Drupal\Core\Url 类表示,它有几个静态方法,允许你创建一个实例。其中最重要的是 ::fromRoute(),它接受一个路由名称、路由参数(如果该路由需要这些参数),以及一个选项数组来创建一个新的 Url 实例。还有其他这样的方法可以将各种其他东西转换为 Url,最值得注意的是 ::fromUri() 方法,它接受一个内部或外部 URI。这些方法非常有帮助,尤其是在处理动态获取的数据时。然而,在硬编码时,始终最好使用路由名称,因为这允许你在以后更改该路由背后的实际路径,而不会影响你的代码。
在实例化 Url 时,可以向 $options 数组传递许多选项。你可以传递查询参数数组、片段等。这些将帮助你构建一个复杂到你需要而没有必要自己处理字符串的 URL。我建议你查看 ::fromUri() 方法上面的文档,因为它描述了所有这些选项。此外,请注意,无论你使用哪种方法创建 Url 对象,选项基本上都是相同的。
链接
现在我们有了 Url 对象,我们可以用它来生成链接。我们可以通过两种方式来做这件事:
-
使用名为
link_generator的LinkGenerator服务,并通过传递链接文本和我们获得的Url对象来调用其generate()方法。这将返回一个GeneratedLink对象,它包含链接的实际字符串表示以及一些缓存元数据。 -
使用
\Drupal\Core\Link类,它包装一个渲染元素(我们将在“主题”章节中更多地讨论渲染元素)来表示链接。
让我们从头到尾看看这两个示例。
考虑这个使用服务生成链接的示例:
$url = Url::fromRoute('my_route', ['param_name' => $param_value]);
$link = \Drupal::service('link_generator')->generate('My link', $url);
我们可以直接打印 $link,因为它实现了 __toString() 方法。
现在,考虑这个使用 Link 类生成链接的示例:
$url = Url::fromRoute('my_other_route');
$link = Link::fromTextAndUrl('My link', $url);
现在 $link 是一个 Link 对象,其 toRenderable() 方法返回一个包含 #type => 'link' 的渲染数组。在幕后,在渲染时,它也会使用链接生成器将其转换为链接字符串。
如果我们有一个 Link 对象,我们也可以自己使用链接生成器根据其自身数据生成链接:
$link = \Drupal::service('link_generator')->generateFromLink($linkObject);
链接方式有哪些?
正如我们所见,我们有多种方式来创建链接和 URL 表示,但当涉及到创建链接时,我们应该使用哪种方法呢?每种方法都有其优缺点。
当涉及到 URL 时,如前所述,坚持使用硬编码路由而不是 URI 是一个好主意。然而,如果你正在处理动态数据,例如用户输入或存储的字符串,其他方法也是完全有效的。我建议你详细查看Url类,因为你开发 Drupal 8 模块时将会大量使用它。
关于实际的链接,使用服务生成链接意味着你在代码的该点创建了一个字符串。这意味着在后续过程中无法更改。然而,使用Link类很好地符合整个渲染数组的理由,即在最后可能的时间点延迟实际生成。我们将在稍后更多地讨论渲染数组。因此,你做出的选择取决于你需要生成的链接以及你对以下问题的回答:这个链接是否可能需要由其他模块/主题进行更改?如果是这样,请使用渲染数组。否则,你可能考虑如果可以正确注入服务的话生成链接。
当涉及到实体时,始终最好使用基类实体上的辅助方法来生成指向这些实体的链接和 URL。我们将在本书的后面部分更多地讨论实体。
事件调度器和重定向
作为模块开发者,你必须经常执行的一个常见操作是拦截一个给定的请求并将其重定向到另一个页面,而且通常这将是动态的,取决于当前用户或其他上下文信息。Drupal 7 开发者非常清楚这始终是一个简单的任务。只需实现hook_init(),它在每个请求上被调用,然后使用著名的drupal_goto()函数。然而,在 Drupal 8 中,情况不再是这样了。我们现在必须订阅kernel.request事件(记得上一章提到的吗?)然后直接更改响应。然而,在看到示例之前,让我们看看我们如何在控制器内部执行一个更简单的重定向。你知道,既然我们在讨论这个话题。
从控制器进行重定向
在本章中,我们编写了一个返回渲染数组的控制器。我们从上一章知道,这是由主题系统拾取并转换为响应的。在第四章“主题”中,我们将更详细地探讨这个过程。然而,如果控制器直接返回响应,也可以绕过这个渲染管道。让我们考虑以下示例:
return new \Symfony\Component\HttpFoundation\Response('my text');
这将绕过大部分处理,并返回一个只有“my text”字符串的空白白页。我们使用的Response类来自 Symfony HTTP 基础组件。
然而,我们还有一个方便的RedirectResponse类可以使用,它将浏览器重定向到另一个页面:
return new \Symfony\Component\HttpFoundation\RedirectResponse('node/1')
第一个参数是我们想要重定向的 URL。通常,这应该是一个绝对 URL;然而,现在的浏览器足够智能,可以处理相对路径。因此,在这种情况下,控制器将重定向我们到那个路径。
通常,当你返回重定向响应时,你会想使用RedirectResponse的子类。例如,我们有LocalRedirectResponse和TrustedRedirectResponse类,这两个类都扩展自SecuredRedirectResponse。这些实用工具的目的是确保重定向的安全性。
从订阅者中进行重定向
许多时候,我们的业务逻辑要求我们在各种条件匹配的情况下,从某个页面重定向到另一个页面。对于这些情况,我们可以订阅请求事件并简单地更改响应,本质上绕过正常的流程,该流程会通过 Drupal 的所有层。然而,在我们看到示例之前,让我们简要谈谈事件调度器。
这个系统中的核心角色是event_dispatcher服务,它是一个ContainerAwareEventDispatcher类的实例。这个服务允许分发命名事件,这些事件以Event对象的形式传递负载,该对象封装了需要传递的数据。通常,在分发事件时,你会创建一个带有一些方便方法访问需要传递数据的Event子类。最后,EventSubscriberInterface的实例监听具有特定名称的事件,并可以修改已传递的Event对象。本质上,这个系统允许订阅者在业务逻辑使用数据之前更改数据。在这方面,它是 Drupal 8 中扩展点的典型例子。最后,注册事件订阅者只需创建一个带有event_subscriber标签并实现该接口的服务即可。
现在我们来看一个示例事件订阅者,它监听kernel.request事件,如果具有特定角色的用户尝试访问我们的Hello World页面,则会重定向到主页。这将演示如何订阅事件以及如何执行重定向。它还将展示我们如何使用当前路由匹配服务来检查当前路由。
让我们首先编写这个订阅者的服务定义来创建这个订阅者:
hello_world.redirect_subscriber:
class: \Drupal\hello_world\EventSubscriber\HelloWorldRedirectSubscriber
arguments: ['@current_user']
tags:
- { name: event_subscriber }
如您所见,我们有一个常规的服务定义,有一个参数,并且带有event_subscriber标签。依赖关系实际上是服务,它指向当前用户(无论是登录还是匿名)的形式为AccountProxyInterface。这是一个对AccountInterface的包装,它代表实际的当前用户。此外,当我提到用户时,我指的是具有某些用户数据的对象,而不是具有所有字段数据的实际实体对象(即用户会话)。然而,用户的一些信息可以通过AccountInterface访问,例如 ID、姓名、角色和电子邮件。我建议您查看该接口以获取更多信息。然而,在我们的示例中,我们将使用它来检查用户是否具有non_grata角色,这将触发我提到的重定向。
接下来,让我们看看事件订阅者类本身:
namespace Drupal\hello_world\EventSubscriber;
use Drupal\Core\Session\AccountProxyInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Subscribes to the Kernel Request event and redirects to the homepage
* when the user has the "non_grata" role.
*/
class HelloWorldRedirectSubscriber implements EventSubscriberInterface {
/**
* @var \Drupal\Core\Session\AccountProxyInterface
*/
protected $currentUser;
/**
* HelloWorldRedirectSubscriber constructor.
*
* @param \Drupal\Core\Session\AccountProxyInterface $currentUser
*/
public function __construct(AccountProxyInterface $currentUser) {
$this->currentUser = $currentUser;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events['kernel.request'][] = ['onRequest', 0];
return $events;
}
/**
* Handler for the kernel request event.
*
* @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
*/
public function onRequest(GetResponseEvent $event) {
$request = $event->getRequest();
$path = $request->getPathInfo();
if ($path !== '/hello') {
return;
}
$roles = $this->currentUser->getRoles();
if (in_array('non_grata', $roles)) {
$event->setResponse(new RedirectResponse('/'));
}
}
}
如预期的那样,我们将当前用户存储为类属性,以便我们可以在以后使用它。然后,我们实现EventSubscriberInterface::getSubscribedEvents()方法。此方法需要返回一个多维数组,这基本上是事件名称与如果拦截该事件要调用的类方法的映射。这就是我们实际上注册方法以监听一个事件或另一个事件的方式,如果我们想的话,我们可以在同一个订阅者类中监听多个事件。然而,通常将它们分开到不同的、更专题的类中是一个好主意。回调方法名称在一个数组中,其第二个值表示与其他您或其他模块可能定义的回调相比的优先级。数字越高,优先级越高,它在过程中的运行越早。请检查该接口的文档,以获取有关您可以如何订阅事件的良好描述。
在我们的示例中,我们监听上一章中提到的kernel.request事件。此事件由 Symfony 的HttpKernel分发,传递一个GetResponseEvent实例,它基本上包装了Request对象。Event类的名称通常很好地描述了事件的目的。在这种情况下,它正在寻找要发送到浏览器的Response对象。如果我们检查该类,我们可以注意到它上面有一个setResponse()方法,我们可以使用它来设置响应。如果订阅者提供了一个,它将停止事件传播(没有其他具有较低优先级的监听器被给予机会)并且返回响应。
因此,在我们的onRequest()回调方法中,我们检查当前请求的路径,如果它是我们的,并且当前用户具有non_grata角色,我们将RedirectResponse设置到事件中,将其重定向到主页。这将完成我们设定的任务。如果你以具有该角色的用户身份访问/hello页面,你应该被重定向到主页。话虽如此,我不喜欢这个实现中的许多方面。所以,让我们来修复它们。
首先,我们硬编码了kernel.request事件名称(是我做的,不能怪你)。任何派发事件的合格代码都会使用类常量来定义事件名称,订阅者也应该引用该常量。Symfony 有一个KernelEvents类就是为了这个目的。查看它并看看 HttpKernel 派发了哪些其他事件,因为它们都在那里被引用。
因此,我们不再硬编码字符串,而是可以这样做:
$events[KernelEvents::REQUEST][] = ['onRequest', 0];
第二点,我们在onRequest()方法中处理路径的方式全是错误的。我们在这种情况下硬编码了/hello路径。如果我们因为老板想要路径为/greeting而更改路由路径怎么办?我也不喜欢我们传递路径到RedirectResponse的方式。同样的事情也适用(尽管在主页的情况下不是那么严重):如果我们想要重定向的路径发生变化怎么办?让我们使用路由而不是路径来修复这些问题。它们是系统特定的,并且不太可能因为业务需求而更改。
问题是我们无法从Request对象中理解正在访问哪个路由。因此,我们可以使用current_route_match服务——一个非常流行的服务,你经常会用到——它提供了关于当前路由的大量信息。所以,让我们将其注入到我们的事件订阅者中。到现在为止,你应该知道如何自己做到这一点(如果还有困难,请查看最终代码)。一旦完成,我们可以这样做:
public function onRequest(GetResponseEvent $event) {
$route_name = $this->currentRouteMatch->getRouteName();
if ($route_name !== 'hello_world.hello') {
return;
}
$roles = $this->currentUser->getRoles();
if (in_array('non_grata', $roles)) {
$url = Url::fromUri('internal:/');
$event->setResponse(new LocalRedirectResponse($url->toString()));
}
}
从CurrentRouteMatch服务中,我们可以找出当前路由的名称、整个路由对象、URL 中的参数以及其他有用信息。请查看该类以获取更多关于你可以做什么的信息,因为我保证它们会很有用。
我们现在检查的是路由名称而不是路径名称。所以,如果我们更改路由定义中的路径,我们的代码仍然会工作。然后,我们不再只是将路径添加到RedirectResponse中,而是可以使用我们在上一节中了解到的Url类先构建它。当然,在我们的例子中,这可能有点过度,但如果我们将其重定向到已知的路由,我们就可以基于它构建,我们的代码就会更加健壮。此外,使用Url类,我们还可以检查其他事情,例如访问权限,它的toString()方法简单地将它转换成一个可以用于RedirectResponse的字符串。最后,我们不再使用简单的RedirectResponse,而是使用LocalRedirectResponse类,因为我们正在重定向到本地(安全)路径。
这样,我们将得到相同的重定向,但方式更加干净和健壮。当然,这需要在顶部调整use语句,通过删除对RedirectResponse的引用并添加以下内容:
use Drupal\Core\Routing\CurrentRouteMatch;
use Drupal\Core\Routing\LocalRedirectResponse;
use Symfony\Component\HttpKernel\KernelEvents;
use Drupal\Core\Url;
事件分发
既然我们已经讨论了如何在 Drupal 8 中订阅事件,我们也应该看看我们如何可以分发我们自己的事件。毕竟,Symfony 事件调度组件是 Drupal 8 中扩展性的主要途径之一。
为了演示这一点,我们将创建一个事件,每当我们的 HelloWorldSalutation::getSalutation() 方法被调用时,该事件将被触发。其目的是通知其他模块这一事件的发生,并可能允许它们更改配置对象输出的消息——这并不是一个真正的用例,但足以演示我们如何分发事件。
我们首先需要做的是创建一个将被分发的事件类。它可以放在我们模块命名空间的最顶层:
namespace Drupal\hello_world;
use Symfony\Component\EventDispatcher\Event;
/**
* Event class to be dispatched from the HelloWorldSalutation service.
*/
class SalutationEvent extends Event {
const EVENT = 'hello_world.salutation_event';
/**
* The salutation message.
*
* @var string
*/
protected $message;
/**
* @return mixed
*/
public function getValue() {
return $this->message;
}
/**
* @param mixed $message
*/
public function setValue($message) {
$this->message = $message;
}
}
此事件类的主要目的是使用它的一个实例来传输我们的问候消息的值。这就是为什么我们在类中创建了 $message 属性,并添加了获取器和设置器方法。此外,我们用它来定义将要分发的事件的实际名称的常量。最后,该类按照标准实践扩展了事件调度组件提供的基类 Event。我们也可以直接使用那个类,但我们的数据将不会像现在这样存储在其中。
接下来,是时候将事件调度器服务注入到我们的 HelloWorldSalutation 服务中。我们已经有 config.factory 的注入,所以我们只需要在服务定义中添加一个新的参数:
arguments: ['@config.factory', '@event_dispatcher']
当然,我们也会在构造函数中接收它,并将其存储为类属性:
/**
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* HelloWorldSalutation constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher
*/
public function __construct(ConfigFactoryInterface $config_factory, EventDispatcherInterface $eventDispatcher) {
$this->configFactory = $config_factory;
$this->eventDispatcher = $eventDispatcher;
}
我们还将在文件顶部添加对 EventDispatcherInterface 的强制 use 语句:
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
现在,我们可以使用调度器了。所以,在 getSalutation() 方法内部,我们不再需要以下代码:
if ($salutation != "") {
return $salutation;
}
我们可以有以下几种情况:
if ($salutation != "") {
$event = new SalutationEvent();
$event->setValue($salutation);
$event = $this->eventDispatcher->dispatch(SalutationEvent::EVENT, $event);
return $event->getValue();
}
因此,根据以上内容,我们决定,如果我们需要从配置对象返回一个问候消息,我们希望通知其他模块并允许它们更改它。我们首先创建我们的事件类的一个实例,并给它提供相关数据(消息)。然后,我们分发命名事件,并传递事件对象。事件调度器返回已分发的事件,以及可能由订阅者对其应用过的任何更改。最后,我们从该实例中获取数据并返回它。
非常简单,不是吗?订阅者能做什么?这与我们在上一节中关于重定向示例中看到的情况非常相似。订阅者需要做的只是监听 SalutationEvent::EVENT 事件并根据它执行某些操作。它能做的最主要的事情是使用接收到的事件对象上的 setValue() 方法来更改问候消息。它还可以使用基类 Event 中的 stopPropagation() 方法来通知事件调度器不再触发已订阅此事件的其它监听器。
摘要
在本章中,我们详细介绍了在开发 Drupal 8 模块时你需要了解的大量信息。我们首先创建了一个可以安装在 Drupal 8 网站上的自己的模块骨架。然后,我们看到了如何在特定的路径(路由)上创建一个新页面,并在该页面上显示一些基本数据。这并不复杂,但足以说明你作为模块开发者将要执行的最常见任务之一。然后,我们将这一概念提升到新的水平,并将数据计算逻辑抽象成一个服务。不仅如此,我们还看到了如何使用该服务,更重要的是,如何 应该 使用它。接下来,我们看到了如何使用 Drupal 8 中的表单 API 与管理员一起添加一些配置到网站中。在这里的一个重要收获是,Drupal 8 中的表单 API 页面将非常有价值,因为你有很多不同类型的表单元素可供使用。所以,请将其放在手边。此外,既然我们谈到了表单,我们还看到了如何修改其他模块定义的现有表单——这对于任何模块开发者来说都是一项有用的技术。
接下来,我们创建了第一个自定义块,这使得我们可以重用我们的服务,并在显示数据的位置上更加灵活。
然后,我们探讨了如何在 Drupal 8 中以编程方式创建 URL 和链接。在我们构建的这个模块的功能中,我们目前不需要任何链接。然而,与它们一起工作是一种常见的做法,因此我们不得不早期学习如何在 Drupal 8 中正确地生成链接和处理 URL。
在最后一节中,我们探讨了 Symfony 的 事件分发器 组件,这个组件允许我们分发和订阅事件。我们看到了一些如何订阅主要内核事件以重定向页面的例子,但也看到了如何分发我们自己的事件。后者旨在允许订阅者对我们的数据进行更改。
本章中我们讨论的大部分主题都是为了给你一个初步的推动,并提供在 Drupal 8 中开发模块的工具。它们代表了——我相信——任何新的 Drupal 开发者都会遇到并需要完成的最常见的事情。
在下一章中,我们将探讨大多数应用程序将需要使用的两个重要方面。一个是日志记录——你的网站记录错误和重要操作得越好,调试和追踪问题就会越容易。另一个是邮件发送——网站通常需要以某种方式向用户发送电子邮件,因此了解 Drupal 8 中它是如何工作的非常重要。
第三章:记录和邮件
在上一章中,我们学习了大多数 Drupal 8 模块开发者必须了解的一些更常见的事情,从基础知识开始,即创建一个 Drupal 模块。
在本章中,我们将进一步探讨一些开发者必须执行的其他重要任务:
-
我们将探讨 Drupal 8 中记录的工作原理。为此,我们将通过扩展我们的 Hello World 模块来涵盖一些示例。
-
我们将探讨 Drupal 8 中的邮件 API,即如何使用默认设置(PHP 邮件)发送电子邮件。然而,不仅如此,我还会向你展示如何创建自己的电子邮件系统,以便与你的(可能是外部的)邮件服务集成;还记得插件吗?这将是使用插件扩展现有功能的另一个好例子。
-
在本章末尾,我们还将探讨 Drupal 8 的令牌系统。我们将在此背景下,用上下文数据替换某些 令牌,以便我们发送的电子邮件更具动态性。
到本章结束时,你应该能够向你的 Drupal 8 模块添加记录,并能够舒适地以编程方式发送电子邮件。此外,你将了解令牌的工作原理,并且作为额外奖励,将了解如何定义你自己的令牌。
记录
Drupal 中的主要记录机制是通过数据库记录,客户端代码可以使用 API 将消息保存到 watchdog 表中。当消息达到一定数量后,这些消息会被清除,但与此同时,可以通过一个方便的界面(在 admin/reports/dblog)在浏览器中查看:

或者,一个默认禁用的核心模块,Syslog,可以用来补充/替换此记录机制,使用运行该网站的服务器的 Syslog。为了本书的目的,我们将关注任何机制下的记录工作原理,但也会探讨如何在 Drupal 8 中实现我们自己的记录系统。
Drupal 7 开发者非常熟悉他们用于记录消息的 watchdog() 函数。这是一个用于记录的进程式 API,它暴露了一个简单的函数,该函数接受一些参数:$type(消息的类别)、$message、$variables(一个值数组,用于替换消息中找到的占位符)、$severity(一个常量)和 $link(一个链接,从 UI 中链接到消息)。很明显,这个解决方案非常特定于 Drupal,并不真正适用于更广泛的 PHP 社区。
在 Drupal 8 中,这已经改变。数据库记录模块仍然存在,存储消息的表仍然称为 watchdog,但这个记录目的地只是可以完成的一种可能实现。这是因为 Drupal 8 的记录框架已经被重构为面向对象和 PSR-3 兼容。在这种情况下,数据库记录只是默认实现。
Drupal 8 记录理论
在我们继续我们的例子之前,让我们先了解一下 Drupal 8 中日志框架的一些理论概念。这样做时,我们将尝试理解我们需要与之交互的关键参与者。
首先,我们有LoggerChannel,它代表一组日志消息。它们类似于 Drupal 7 的watchdog()函数之前的$type参数。然而,一个关键的区别是,它们是通过日志插件本身进行实际日志记录的对象。在这方面,它们被我们的第二个主要参与者LoggerChannelFactory使用,这是一个服务,通常作为客户端代码,我们与日志框架的主要接触点。
为了更好地理解这些内容,让我们考虑以下简单使用的例子:
\Drupal::logger('hello_world')->error('This is my error message');
就这些了。我们只是使用了可用的已注册的日志记录器通过hello_world通道记录了一个错误消息。这是我们刚刚即兴想出的自定义通道,它只是将这条消息分类为属于hello_world类别(我们在上一章中开始的模块)。此外,您会看到我使用了静态调用。在底层,加载了日志工厂服务,从它那里请求了一个通道,并在该通道上调用error()方法:
\Drupal::service('logger.factory')->get('hello_world')->error('This is my error message');
当您从LoggerChannelFactory请求一个通道时,您给它一个名字,然后根据这个名字,它创建一个LoggerChannel的新实例,这是默认的通道类。然后它会将所有可用的日志记录器传递给这个通道,这样当我们调用它上面的任何RfcLoggerTrait日志方法时,它就会委托给它们。
我们也有创建我们自己的通道的选项。这样做的一个优点是我们可以直接将其注入到我们的类中,而不是整个工厂,我们可以从那里请求通道。此外,我们可以以一种甚至不需要创建新类的方式来做这件事,而是从默认的一个继承。我们将在下一节中看到如何做到这一点。
第三大主要参与者是LoggerInterface的实现,它遵循 PSR-3 标准。如果我们看看我们之前提到的数据库日志实现DbLog类,我们会注意到它也使用了RfcLoggerTrait,这个特质负责所有必要的函数,使得实际的LoggerInterface实现只需处理主要的log()方法。然后这个类被注册为一个带有logger标签的服务,它反过来又注册到LoggerChannelFactory(它也充当服务收集器)。
正如我们在第二章中看到的,创建您的第一个模块,标签可以用来对服务定义进行分类,并且我们可以让它们被另一个服务为了特定目的收集。在这种情况下,所有带有logger标签的服务都有一个目的,并且它们被LoggerChannelFactory收集和使用。
我知道已经讲了很多理论,但这些都是需要理解的重要概念。然而,不用担心;像往常一样,我们将通过一些示例来讲解。
我们自己的日志通道
我之前提到过,我们可以定义自己的日志通道,这样我们就不必总是注入整个工厂。那么,让我们看看如何为现在正在编写的 Hello World 模块创建一个。
大多数时候,我们只需要在服务定义文件中添加这样的定义:
hello_world.logger.channel.hello_world:
parent: logger.channel_base
arguments: ['hello_world']
在讨论实际的日志通道之前,让我们看看这个奇怪的服务定义实际上意味着什么,因为这不是我们之前见过的。我的意思是,类在哪里?
parent 键表示我们的服务将继承另一个服务的定义。在我们的例子中,parent 键是 logger.channel_base,这意味着使用的类将是 Drupal\Core\Logger\LoggerChannel(默认)。如果我们仔细查看 core.services.yml 中的 logger.channel_base 服务定义,我们也会看到一个 factory 键。这意味着这个服务类不是由服务容器实例化,而是由另一个服务,即 logger.factory 服务的 get() 方法实例化。
arguments 键也有所不同。首先,我们没有 @ 符号。这是因为这个符号用来表示服务名称,而我们的参数是一个简单的字符串。作为额外的小贴士,如果字符串前后有 % 符号,它表示可以在任何 *.services.yml 文件中定义的参数。
回到我们的例子,如果你还记得日志理论,这个服务定义意味着请求这个服务将执行以下任务:
\Drupal::service('logger.factory')->get('hello_world');
它使用日志工厂加载一个带有特定参数的通道。因此,现在我们可以注入我们的 hello_world.logger.channel.hello_world 服务,并在客户端代码中直接调用任何 LoggerInterface 方法。
我们自己的日志记录器
现在我们已经有了模块的通道,假设我们还想在其他地方记录消息。它们可以存储在数据库中,但每当遇到错误日志时,我们还想发送电子邮件。在本节中,我们将只涵盖为此所需的日志架构,并将实际的邮件实现推迟到本章的第二部分,当我们讨论邮件时再进行。
我们首先需要创建的是 LoggerInterface 实现,这通常放在我们命名空间的 Logger 文件夹中。所以,让我们称它为 MailLogger。它可以看起来像这样:
namespace Drupal\hello_world\Logger;
use Drupal\Core\Logger\RfcLoggerTrait;
use Psr\Log\LoggerInterface;
/**
* A logger that sends an email when the log type is "error".
*/
class MailLogger implements LoggerInterface {
use RfcLoggerTrait;
/**
* {@inheritdoc}
*/
public function log($level, $message, array $context = array()) {
// Log our message to the logging system.
}
}
首先要注意的是,我们正在实现 PSR-3 LoggerInterface。这将需要很多方法,但我们将通过 RfcLoggerTrait 处理大部分方法。唯一剩下要实现的是 log() 方法,它将负责实际的日志记录。目前,我们将保持它为空。
仅凭这个类本身并没有什么作用。我们需要将其注册为一个标记服务,以便 LoggingChannelFactory 能够捕获它,并在需要记录日志时将其传递给日志通道。让我们看看这个定义是什么样的:
hello_world.logger.hello_world:
class: Drupal\hello_world\Logger\MailLogger
tags:
- { name: logger }
就目前而言,我们的日志记录器不需要任何依赖项。然而,请注意名为 tags 的属性,我们使用 logger 标签标记这个服务。这将使其成为一个特定的服务,另一个服务(称为收集器)会寻找这个服务。就像我们在上一章讨论的那样。在这种情况下,收集器是 LoggingChannelFactory。
清除缓存应该能够启用我们的日志记录器。这意味着当通过任何通道记录消息时,我们的日志记录器也会被使用,连同任何其他启用的日志记录器(默认情况下是数据库日志记录器)。所以,如果我们想让我们的日志记录器是唯一的,我们需要从 Drupal 核心中禁用 DB Log 模块。
我们将在本章后面继续对这个类进行工作,那时我们将介绍如何以编程方式发送电子邮件。
Hello World 的日志记录
现在我们已经拥有了所有工具,更重要的是,我们理解了 Drupal 8 中的日志记录工作原理,让我们在我们的模块中添加一些日志记录。
有一个地方我们可以记录一个可能有用的操作。当管理员通过我们编写的表单更改问候消息时,让我们记录一条信息消息。这应该在 SalutationConfigurationForm 的提交处理程序中自然发生。
如果您还记得我在上一章中的抱怨,如果我们能够注入服务而不是静态使用服务,我们就应该避免使用静态服务,并且我们可以轻松地将服务注入到我们的表单中。所以,让我们现在就做这个。
首先,FormBase 已经实现了 ContainerInjectionInterface,因此我们不需要在我们的类中实现它,因为我们从它那里继承。其次,我们直接继承的 ConfigFormBase 类已经注入了 config.factory,这使得事情对我们来说有点复杂——好吧,其实并不复杂。我们只需要复制构造函数和 create() 方法,添加我们自己的服务,将其存储在一个属性中,并将父类所需的服务传递给父构造函数调用。它看起来是这样的:
/**
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;
/**
* SalutationConfigurationForm constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The factory for configuration objects.
* @param \Drupal\Core\Logger\LoggerChannelInterface $logger
* The logger.
*/
public function __construct(ConfigFactoryInterface $config_factory, LoggerChannelInterface $logger) {
parent::__construct($config_factory);
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get('hello_world.logger.channel.hello_world')
);
}
并且在顶部相关的 使用 声明:
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
如您所见,我们通过 create() 方法获取了所有父类需要的所有服务,以及我们想要的(日志通道)。此外,在我们的构造函数中,我们将通道存储为一个属性,然后将父参数传递给父构造函数。现在,我们在配置表单类中有了 hello_world 日志通道。所以,让我们使用它。
在 submitForm() 方法的末尾,让我们添加以下行:
$this->logger->info('The Hello World salutation has been changed to @message.', ['@message' => $form_state->getValue('salutation')]);
我们正在记录一条常规信息消息。然而,由于我们还想记录已经设置的消息,我们使用了第二个参数,它代表一个上下文值数组。在底层,数据库日志记录器将提取以 @、! 或 % 开头的上下文变量,并从整个上下文数组中获取其值。这是通过使用 LogMessageParser 服务来完成的,但我们将在讨论国际化时看到更多关于它的内容。如果你实现自己的日志插件,你也将必须自己处理这一点——但我们很快就会看到这一点。
现在我们已经完成了在问候配置表单保存时记录消息的工作。
日志总结
在本节的第一部分,我们看到了 Drupal 8 中日志记录是如何工作的。具体来说,我们介绍了一些理论,以便你理解事物是如何相互作用的,并且你不会无意识地使用日志工厂,而不真正了解底层发生了什么。
作为例子,我们创建了自己的日志通道,这使得我们可以在需要的地方注入它,而不必总是通过工厂。我们将从现在开始使用这个通道来处理 Hello World 模块。此外,我们还创建了自己的日志实现。目前它不会做很多事情,除了注册之外,但我们在下一节中会使用它来在错误被记录到网站上时发送电子邮件。
最后,我们在问候配置表单中使用了日志框架(以及我们的通道)来记录消息,每当表单被提交时都会记录消息。在这个过程中,我们还传递了保存的消息,以便它也被包含在日志中。这应该已经与数据库日志一起工作,所以请继续保存配置表单,然后检查日志用户界面以获取该信息消息。
邮件 API
现在我们已经知道了如何在我们的应用程序中记录事物,让我们将注意力转向 Drupal 8 的邮件 API。本节的目标是了解我们如何在 Drupal 8 中以编程方式发送电子邮件。为了实现这一目标,我们将探索核心安装中附带的标准邮件系统(它使用 PHP 邮件),并创建我们自己的系统,理论上可以使用外部 API 来发送邮件。我们不会深入探讨后者,因为它超出了本书的范围。我们将停止在从 Drupal 视角介绍需要完成的事情之后。
在下一节和最后一节中,我们将研究令牌,以便我们可以使我们的邮件发送更加动态。然而,在我们这样做之前,让我们先深入了解 Drupal 8 的邮件 API。
邮件 API 的理论
如前所述,让我们首先从理论角度介绍这个 API。在深入研究示例之前,了解架构是很重要的。
在 Drupal 中程序化发送电子邮件是一个两步的工作。我们首先需要做的是在我们的模块中定义电子邮件的某种模板。这并不是传统意义上的模板,而是一个用于你想要发送的电子邮件的过程性数据包装器。在代码中,它被称为键或消息 ID,但我认为模板是一个更好的词来描述它。而且,不出所料,它是通过实现钩子来工作的。
我们接下来需要做的是使用 Drupal 邮件管理器,通过定义的其中一个模板发送电子邮件,并指定定义它的模块。如果你觉得这很困惑,不要担心,随着后面解释的例子,它将会变得清晰。
模板是通过实现hook_mail()创建的。这个钩子是一个特殊的钩子,因为它不像大多数其他钩子那样工作。它是由邮件管理器在客户端(一些代码)尝试为实现它的模块发送电子邮件时被调用的。
MailManager实际上是一个插件管理器,它还负责使用邮件系统(插件)发送电子邮件。默认的邮件系统是PhpMail,它使用 PHP 的本地mail()函数发送电子邮件。如果我们创建自己的邮件系统,那就意味着创建一个新的插件。此外,插件本身是实际发送电子邮件的,管理器只是简单地委托给它。正如你所见,我们甚至不能不创建插件就前进到下一章。
每个邮件插件都需要实现MailInterface,它公开了两个方法——format()和mail()。第一个方法负责邮件内容的初始准备(消息连接等),而后者负责最终化和发送。
然而,邮件管理器是如何知道使用哪个插件的?它检查一个名为system.mail的配置对象,该对象存储默认插件(PhpMail),还可以存储每个单独模块以及任何模块和模板 ID 组合的覆盖。因此,我们可以有多个邮件插件,每个插件用于不同的事情。这个配置对象的一个奇特之处在于,没有管理员表单可以指定哪个插件做什么。你需要根据需要程序化地调整这个配置对象。你可以通过hook_install()和hook_uninstall()钩子来操作这个。这些钩子用于在模块安装/卸载时执行一些任务。所以,这就是我们稍后更改配置对象以添加我们自己的邮件插件的地方。
然而,现在我们已经看了一些理论知识,让我们看看我们如何使用默认的邮件系统来程序化地发送电子邮件。你还记得上一节中未完成的记录器吗?这就是我们将在记录的消息是错误时发送电子邮件的地方。
实现hook_mail()
如我之前提到的,在 Drupal 8 中发送邮件的第一步是实现hook_mail()。在我们的例子中,它可以看起来像这样:
/**
* Implements hook_mail().
*/
function hello_world_mail($key, &$message, $params) {
switch ($key) {
case 'hello_world_log':
$message['from'] = \Drupal::config('system.site')->get('mail');
$message['subject'] = t('There is an error on your website');
$message['body'][] = $params['message'];
break;
}
}
此钩子接收三个参数:
-
用于发送邮件的消息键(模板)
-
需要填写在内的电子邮件消息
-
从客户端代码传递的参数数组
如您所见,我们正在定义一个名为hello_world_log的键(或模板),它有一个简单的静态主题,正文将包含来自$parameters数组中的消息键中的任何内容。由于电子邮件From始终相同,我们将使用可在system.site配置对象中找到的全局电子邮件地址。您会注意到,我们不在可以像构建表单时那样注入配置工厂的上下文中。相反,我们可以使用静态辅助函数来加载它。
此外,您会注意到正文本身也是一个数组。这是因为我们可以构建(如果我们想的话)该数组中的多个项目,这些项目可以在邮件插件的format()方法中作为段落 imploded。无论如何,这是默认邮件插件所做的事情,所以在这里我们需要构建一个数组。
$message数组中另一个有用的键是header键,您可以使用它向邮件添加一些自定义头。在这种情况下,我们不需要这样做,因为默认的PhpMail插件已经添加了所有必要的头。所以如果我们编写自己的邮件插件,我们也可以在那里添加我们的头——以及$message数组中的所有其他键。这是因为后者作为引用传递,所以它从客户端调用到hook_mail()实现再到插件的过程中不断构建。
这就是我们需要对hook_mail()做的所有事情。现在让我们看看如何使用它来发送电子邮件。
发送电子邮件
我们希望使用我们的MailLogger在记录错误时发送电子邮件。所以让我们回到我们的类中并添加这个逻辑。
这就是我们的log()方法现在可能的样子:
/**
* {@inheritdoc}
*/
public function log($level, $message, array $context = array()) {
if ($level !== RfcLogLevel::ERROR) {
return;
}
$to = $this->configFactory->get('system.site')->get('mail');
$langcode = $this->configFactory->get('system.site')->get('langcode');
$variables = $this->parser->parseMessagePlaceholders($message, $context);
$markup = new FormattableMarkup($message, $variables);
\Drupal::service('plugin.manager.mail')->mail('hello_world', 'hello_world_log', $to, $langcode, ['message' => $markup]);
}
首先,我们说我们只想发送错误邮件,所以在前几行中,我们检查尝试记录的级别是否为该级别,如果不是则提前返回。换句话说,如果我们不处理错误,则不执行任何操作,并依赖其他已注册的记录器来处理这些错误。
接下来,我们确定要发送电子邮件的对象以及发送的语言代码(这两个都是邮件管理器mail()方法的必填参数)。我们选择使用全局电子邮件地址(正如我们在From值中所做的那样)。我们还使用与之前在hook_mail()实现中使用的相同配置对象。不用担心,我们很快就会将配置工厂注入到类中。
当我们提到 langcode 时,我们指的是语言对象的机器名。在这种情况下,这就是为网站默认语言存储的内容。此外,我们将默认使用它来发送电子邮件。在后面的章节中,我们将介绍有关 Drupal 8 国际化的更多方面。
然后,我们准备要发送的消息。为此,我们使用FormattableMarkup辅助类,我们传递消息字符串和一个可以用来替换我们消息中占位符的变量值数组。我们可以像DbLog记录器一样使用LogMessageParser服务来检索这些值。所以,我们基本上是从记录消息的整个上下文字符数组中提取占位符变量。
最后,我们使用邮件管理器插件来发送邮件。它的mail()方法的第一个参数是我们想要用于邮件的模块。第二个是我们想要使用的密钥(或模板),我们已经在hook_mail()中定义了它。第三个和第四个是显而易见的,而第五个是我们在hook_mail()中遇到的$params数组。如果你回顾一下,你会注意到我们使用了message键作为正文。在这里,我们用我们的标记对象填充这个键,这个对象有一个_toString()方法,它会用所有占位符替换后渲染它。
你可能想知道为什么我没有像对其他依赖项那样注入 Drupal 邮件管理器。不幸的是,核心邮件管理器本身使用记录器通道工厂,这反过来又依赖于我们的MailLogger服务。所以如果我们让邮件管理器成为后者的依赖项,我们会发现自己陷入了一个循环。所以当容器重建时,会抛出一个大错误。它可能仍然可以工作,但这并不好。所以我选择静态地使用它,因为无论如何,这种方法非常小,并且由于其预期的结果很难断言(发送电子邮件)而难以测试。有时,你必须做出这些选择,因为替代方案是注入整个服务容器来欺骗它。然而,这是一个代码味道,而且即使我想为这个类编写测试,也不会有帮助。
即使我没有注入邮件管理器,我也注入了其余的部分。所以,让我们看看我们现在在类顶部需要什么:
/**
* @var \Drupal\Core\Logger\LogMessageParserInterface
*/
protected $parser;
/**
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* MailLogger constructor.
*
* @param \Drupal\Core\Logger\LogMessageParserInterface $parser
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
*/
public function __construct(LogMessageParserInterface $parser, ConfigFactoryInterface $config_factory) {
$this->parser = $parser;
$this->configFactory = $config_factory;
}
最后,所有我们缺失的相关use语句:
use Drupal\Core\Logger\LogMessageParserInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Logger\RfcLogLevel;
最后,让我们快速调整我们的邮件记录器服务定义:
hello_world.logger.hello_world:
class: Drupal\hello_world\Logger\MailLogger
arguments: ['@logger.log_message_parser', '@config.factory']
tags:
- { name: logger }
我们只是有两个新的参数——对你来说现在没有什么新鲜的。
清除缓存并记录错误应该会将带有替换占位符的记录消息发送到网站电子邮件地址(并从同一地址发送)使用 PHP 原生的mail()函数。恭喜!你刚刚在 Drupal 8 中程序化地发送了第一封电子邮件。
修改他人的电子邮件
Drupal 之所以强大,不仅因为它允许我们添加自己的功能,还因为它允许我们修改现有功能。实现这一目标的一个重要途径是alter钩子系统。还记得第二章中的这些内容吗,创建您的第一个模块?这些是在使用之前用于更改数组或对象值的钩子。当涉及到发送邮件时,我们有一个 alter 钩子,允许我们在邮件定义发出之前更改内容:hook_mail_alter()。对于我们的模块,我们不需要实现此钩子。然而,为了使其完整,让我们看看我们如何可以使用此钩子来更改现有发出的电子邮件的标题:
/**
* Implements hook_mail_alter().
*/
function hello_world_mail_alter(&$message) {
switch ($message['key']) {
case 'hello_world_log':
$message['headers']['Content-Type'] = 'text/html; charset=UTF-8; format=flowed; delsp=yes';
break;
}
}
那么,这里发生了什么?首先,这个钩子实现被调用在每个实现它的模块中。在这方面,它与hook_mail()不同,因为它允许我们修改来自任何模块发出的邮件。然而,在我们的示例中,我们只会修改我们之前定义的邮件。
唯一参数(作为它通常与 alter 钩子一起传递的引用)是$message数组,它包含我们在hook_mail()中构建的所有内容,以及邮件管理器本身添加的键(模板)和其他事物,例如标题。因此,在我们的示例中,我们正在设置一个 HTML 标题,以便发送出去的内容可以被渲染为 HTML。在此钩子被调用后,邮件系统格式化器也会被调用,在PhpMail插件的情况下,它将所有 HTML 标签转换为纯文本,本质上取消了我们的标题。然而,如果我们实现自己的插件,我们可以防止这种情况发生,并成功发送带有适当标签的 HTML 电子邮件。
因此,这就是修改现有发出的邮件的全部内容。接下来,我们将看看我们如何创建自己的邮件插件,该插件使用自定义的外部邮件系统。我们不会在这里详细介绍,但我们将准备一个架构,这将使我们能够引入所需的 API 并轻松使用它。
自定义邮件插件
在上一节中,我们看到了如何使用 Drupal 8 邮件 API 在 Drupal 8 中程序化发送电子邮件。在这样做的时候,我们使用了默认的 PHP 邮件发送器,尽管对于我们的示例来说足够好,但可能不适合我们的应用程序。例如,我们可能想通过 API 使用外部服务。
在本节中,我们将了解这是如何工作的。为此,我们将编写自己的邮件插件,使其仅执行此操作,然后简单地告诉 Drupal 使用该系统而不是默认系统。这又是一个基于插件、非侵入性的扩展点。
在我们开始之前,我想提到,我们不会深入探讨任何与潜在的外部 API 相关的细节。相反,我们将停留在 Drupal 8 特定的部分,所以你将在仓库中找到的代码不会做很多事情——它只作为示例使用。如果你需要,使用这项技术取决于你。
邮件插件
因此,让我们首先创建我们的Mail插件类,如果你记得,插件应该放在我们的模块命名空间中的Plugin文件夹里。邮件插件应该放在一个Mail文件夹里。所以一个简单的邮件插件类骨架可能看起来是这样的:
namespace Drupal\hello_world\Plugin\Mail;
use Drupal\Core\Mail\MailFormatHelper;
use Drupal\Core\Mail\MailInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines the Hello World mail backend.
*
* @Mail(
* id = "hello_world_mail",
* label = @Translation("Hello World mailer"),
* description = @Translation("Sends an email using an external API specific to our Hello World module.")
* )
*/
class HelloWorldMail implements MailInterface, ContainerFactoryPluginInterface {
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static();
}
/**
* {@inheritdoc}
*/
public function format(array $message) {
// Join the body array into one string.
$message['body'] = implode("\n\n", $message['body']);
// Convert any HTML to plain-text.
$message['body'] = MailFormatHelper::htmlToText($message['body']);
// Wrap the mail body for sending.
$message['body'] = MailFormatHelper::wrapMail($message['body']);
return $message;
}
/**
* {@inheritdoc}
*/
public function mail(array $message) {
// Use the external API to send the email based on the $message array
// constructed via the `hook_mail()` implementation.
}
}
如你所见,我们有一个相对简单的插件注释;没有不寻常的参数。然后,你会注意到我们实现了强制性的MailInterface,它包含在类中实现的两个方法。
我之前提到过format()方法,并说过它负责在消息准备好发送之前进行某些处理。之前的实现是从PhpMail插件复制过来的,以展示那里可能进行的任务类型。然而,你可以在这里做任何你想做的事情,例如,允许 HTML 标签。将正文压缩成数组是你在hook_mail()中可能想要做的,因为通常期望邮件正文是由hook_mail()构建为一个数组。
另一方面,mail()方法留空。这是因为使用外部 API 发送电子邮件取决于你。为此,你可以使用我们在hook_mail()实现中遇到的$message数组。
最后,请注意,ContainerFactoryPluginInterface是我们类实现的另一个接口。如果你记得,这就是插件需要实现以便它们成为容器感知的(以便依赖项可以被注入)。由于这只是一个示例代码,它没有任何依赖项,所以我没有包含构造函数,并留空了create()方法。很可能会需要注入某些东西,比如一个与你的外部 API 一起工作的 PHP 客户端库。所以,再次查看这没有坏处。
对于我们的插件类来说,这就差不多了。现在,让我们看看我们如何使用它,因为到目前为止,我们的hello_world_log电子邮件仍然是通过默认的 PHP 邮件发送器发送的。
使用邮件插件
如我之前提到的,Drupal 中没有 UI 可以选择邮件管理器在发送电子邮件时应该使用哪个插件。它通过检查system.mail配置对象来在getInstance()方法中自行确定,更具体地说,是检查那个对象中的interface键(它是一个数组)。
默认情况下,这个数组只包含一条记录,即'default' => 'php_mail'。这意味着默认情况下,所有邮件都使用php_mail插件 ID 发送。为了在我们的插件中混入,我们有几种选择:
-
我们可以用我们的插件 ID 替换这个值,这意味着所有邮件都将通过我们的插件发送
-
我们可以使用
module_name_key_name格式的键来添加一个新的记录,这意味着所有发送给具有特定键(或模板)的模块的电子邮件都将使用该插件 -
我们可以使用
module_name格式的键添加一个新的记录,这意味着所有发送给模块的电子邮件都将使用该插件(无论它们的键是什么)
对于我们的示例,我们将设置从hello_world模块发送的所有电子邮件都使用我们新的插件。我们可以通过使用模块安装时运行的hook_install()实现来完成这项工作。
安装(和卸载)钩子需要放在我们模块根目录下的.install PHP 文件中。因此,下一个函数将放在一个新的hello_world.install文件中。此外,如果我们的模块已经被启用,我们需要首先卸载它,然后再次安装它,以便触发此函数:
/**
* Implements hook_install().
*/
function hello_world_install() {
$config = \Drupal::configFactory()->getEditable('system.mail');
$mail_plugins = $config->get('interface');
if (in_array('hello_world', array_keys($mail_plugins))) {
return;
}
$mail_plugins['hello_world'] = 'hello_world_mail';
$config->set('interface', $mail_plugins)->save();
}
如您所见,我们以可编辑的方式加载配置对象(因此我们可以更改它),如果我们还没有在设置的邮件插件数组中包含hello_world的记录,我们将设置它并将我们的插件 ID 映射到它。最后,我们保存对象。
与此函数相反的是hook_uninstall(),它位于同一文件中,并且预期在模块卸载时被触发。由于我们不希望更改全局配置对象并将其绑定到我们的模块插件,我们应该实现此钩子。否则,如果我们的模块被卸载,邮件系统将失败,因为它将尝试使用不存在的插件。所以,让我们把我们的尾巴系好:
/**
* Implements hook_uninstall().
*/
function hello_world_uninstall() {
$config = \Drupal::configFactory()->getEditable('system.mail');
$mail_plugins = $config->get('interface');
if (!in_array('hello_world', array_keys($mail_plugins))) {
return;
}
unset($mail_plugins['hello_world']);
$config->set('interface', $mail_plugins)->save();
}
如您所见,我们在这里所做的基本上是相反的。如果之前设置的记录存在,我们将取消设置它并保存配置对象。
因此,现在,任何以编程方式发送给hello_world模块的邮件都将使用此插件。简单,对吧?然而,由于我们编写的插件尚未准备好,您在存储库中找到的代码将从hook_install()实现中的相关行注释掉,这样我们实际上就不会使用它。
Token
本章我们将要讨论的最后一件事是 Drupal 8 中的 Token API。我们将介绍一些理论知识,并像往常一样,通过在我们现有的Hello World模块代码中的示例来演示。我们将在发送错误日志邮件的上下文中这样做。
如果我们能够在不硬编码模块代码或配置的情况下在邮件文本中包含一些个性化信息,那将很棒。例如,在我们的情况下,我们可能想在电子邮件中包含触发错误日志的当前用户的用户名。
在进入我们的Hello World模块之前,让我们首先了解 Token API 是如何工作的。
Token API
Drupal 中的 Token 是一个标准格式的占位符,可以在字符串内部找到,并可以由从相关对象中提取的真实值替换。Token 使用的格式是type:token,其中type是 Token 类型的机器可读名称(一组相关 Token),而token是此组中 Token 的机器可读名称。
Drupal 中 Token API 的力量不仅在于其灵活性,还在于它已经是一个流行的 API。它的灵活性在于你可以定义包含相关标记的组,这些标记通过包含它们值的对象(例如,节点对象或用户对象)相互链接。它的流行是因为在 Drupal 的早期版本中,它是许多其他模块依赖的模块来定义自己的标记,而现在它已经包含在 Drupal 8 的核心中,并且已经预定义了许多标记。因此,你会在你的代码中找到许多可以使用的现有标记,如果没有,你也可以定义自己的标记。
从 Drupal 8 模块开发者的角度来看,这个 API 有三个主要组件。这些组件是两个钩子——hook_token_info()和hook_tokens()——以及用于执行替换的Token服务。
第一个钩子用于定义一个或多个标记类型和标记。它本质上是在系统中注册它们。第二个钩子在找到字符串中的标记(服务尝试替换)时触发,并用于根据从服务传递给它的数据替换标记。例如,用户模块在user_token_info()中定义了两种标记类型和多个标记。通过user_tokens(),它检查标记是否是其自己的标记,并尝试用上下文数据(要么是用户对象,要么是当前登录的用户对象)替换它。要详细了解每个相关的文档并查看扩展示例,你可以在 Drupal.org API 页面或token.api.php文件中找到它们。在那里,你还可以找到与这两个相对应的alter钩子,可以用来修改定义的标记信息或替换其他模块或 Drupal 核心中编写的标记的逻辑。
Token 服务是我们作为模块开发者可以使用来替换字符串中找到的标记的。我们将在下一节中看到它是如何使用的。
使用标记
为了快速演示我们如何使用标记,让我们在我们的hello_world_log邮件中包含一些关于在发送电子邮件时当前用户的信息。这自然会与在记录错误时登录的用户相一致。
为了做到这一点,我们需要修改我们的hook_mail()实现。在那里,我们将请求current_user服务当前用户的AccountProxy,向我们的邮件正文添加另一个字符串,当然,替换一个标记:
/**
* Implements hook_mail().
*/
function hello_world_mail($key, &$message, $params) {
switch ($key) {
case 'hello_world_log':
$message['from'] = \Drupal::config('system.site')->get('mail');
$message['subject'] = t('There is an error on your website');
$message['body'][] = $params['message'];
$user_message = 'The user that was logged in: [current-user:name].';
$message['body'][] = \Drupal::token()->replace($user_message, ['current-user' => \Drupal::currentUser()]);
break;
}
}
正如你所见,我们正在向我们的电子邮件添加一个新的“段落”。这是一个简单的字符串,告诉我们登录的用户。然而,在这样做的时候,我们使用token服务(静态地)将这段字符串替换为标记值。服务的replace()方法接受一个字符串,并可选地接受一个按标记(组)类型键控的数据对象数组。
在这种情况下,标记和类型的选择很重要。用户模块定义了user和current-user类型。如果你检查user_tokens()函数,两者的区别在于后者在加载完整的用户实体后,简单地委托给前者。我们也可以自己这样做,然后传递user类型,但为什么要这样做呢?如果有人已经为我们做了,我们就没必要再做了。而且我们传递给current-user标记类型作为替换过程中的数据对象的是AccountProxy(当前用户会话)。
所以,就是这样。现在,电子邮件消息将多一行,包含在发生错误时当前登录用户的动态生成的用户名。在底层,标记服务扫描字符串,提取标记,并调用所有hook_tokens()实现。用户模块是能够根据接收到的用户对象返回此标记替换的那个模块。
定义新标记
我们刚刚看到了如何在字符串中程序化地使用现有标记,并轻松地将它们替换。我们需要的只是标记服务和可以用来替换标记的数据对象。记住,有些标记由于其全局性质甚至不需要任何数据对象。hook_tokens()实现将负责这一点——让我们看看。
在上一章中,我们为动态的Hello World消息创建了功能:要么即时计算,要么从配置对象中加载。我们是否可以将该消息作为标记公开?这将使其使用更加灵活,因为我们的字符串现在可以暴露给整个标记系统。
如前所述,我们将从hook_token_info()实现开始:
/**
* Implements hook_token_info().
*/
function hello_world_token_info() {
$type = [
'name' => t('Hello World'),
'description' => t('Tokens related to the Hello World module.'),
];
$tokens['salutation'] = [
'name' => t('Salutation'),
'description' => t('The Hello World salutation value.'),
];
return [
'types' => ['hello_world' => $type],
'tokens' => ['hello_world' => $tokens],
];
}
在这里,我们需要定义两件事——类型和标记。在我们的例子中,我们定义了一个类型和一个标记。类型是hello_world,如果需要在 UI 中渲染,它还带有可读的名称和描述。标记是salutation,属于hello_world类型。它也获得一个名称和描述。最后,我们返回一个包含两者的数组。
接下来是hook_tokens()函数的实现,其中我们处理了我们的标记替换:
/**
* Implements hook_tokens().
*/
function hello_world_tokens($type, $tokens, array $data, array $options, \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata) {
$replacements = [];
if ($type == 'hello_world') {
foreach ($tokens as $name => $original) {
switch ($name) {
case 'salutation':
$replacements[$original] = \Drupal::service('hello_world.salutation')->getSalutation();
$config = \Drupal::config('hello_world.custom_salutation');
$bubbleable_metadata->addCacheableDependency($config);
break;
}
}
}
return $replacements;
}
这里还有更多的事情在进行中,但我将解释一切。这个钩子会在尝试在字符串上替换标记时被触发。并且它为字符串中找到的每个类型触发,$type是第一个参数。在$tokens中,我们得到一个数组,其中包含字符串中的标记,属于$type。$data数组包含替换标记所需的对象(并传递给replace()方法),按类型键控。这个数组可以是空的(在我们的情况下就是这样)。
在函数内部,我们遍历这个组中的每个标记并尝试替换它。我们只知道一个,我们使用我们的HelloWorldSalutation服务来确定替换字符串。
最后,函数需要返回所有找到的替换项的数组(如果字符串中找到多个相同类型的标记,则可能有多个)。
bubbleable_metadata参数是一个特殊的缓存元数据对象,它描述了在缓存系统中这个标记。这是必需的,因为标记会被缓存,所以如果任何依赖对象发生变化,缓存也需要为此标记进行失效。默认情况下,$data数组中的所有对象都被读取并包含在这个对象中。然而,在我们的情况下,它是空的,但我们仍然依赖于一个可能会改变的配置对象——存储覆盖问候信息的那个对象。因此,即使我们计算问候信息的实际值使用的是之前使用的相同的HelloWorldSalutation服务,我们仍然需要添加对这个配置对象的依赖。所以,这里有一个简单的例子,但有一个复杂的转折。我们将在本书的后面部分更多地讨论缓存。
那就是定义我们的标记的全部内容。现在它也可以在字符串内部使用,并使用Token服务进行替换。例如:
$final_string = \Drupal::token()->replace('The salutation text is: [hello_world:salutation]');
如您所见,我们没有传递其他参数。如果我们的标记依赖于实体对象,例如,我们将在第二个参数数组中传递它,并在hook_tokens()内部使用它来计算替换。
标记摘要
标记系统是 Drupal 的重要组成部分,因为它允许我们使用占位符字符串轻松地将原始数据转换为有用的值。它是一个广泛使用且灵活的系统,许多贡献模块都是基于它(并将基于它)构建的。标记的伟大之处在于 UI 组件。有一些模块将允许用户在 UI 中定义字符串,但使其能够填充各种标记,这些标记将进行替换。此外,这也是作为模块开发者您可以做的事情。
摘要
在本章中,我们讨论了许多内容。我们看到了在 Drupal 8 中日志是如何工作的,如何编程使用邮件 API(以及如何扩展),以及如何使用标记系统使我们的文本更加动态。
在阅读本章的过程中,我们还丰富了我们的Hello World模块。因此,除了理解关于日志的理论外,我们还创建了自己的日志通道服务和日志插件。对于后者,我们决定当日志消息类型为error时发送电子邮件。在这个过程中,我们查看了一下邮件 API 以及我们如何编程使用它。我们看到,默认情况下,PHP 的本地mail()函数用于发送电子邮件,但我们可以非常容易地创建自己的插件来使用我们想要的任何外部服务——这是通过插件实现扩展性的另一个绝佳例子。
最后,我们探讨了 Drupal 8 中的标记(tokens)。我们了解了构成 API 的组件,如何程序化地使用现有的标记(通过上下文数据将它们替换),以及如何定义自己的标记供他人使用。这些都是可扩展性(和共享)的主要原则——使用他人向你暴露的东西,并为他人提供使用的东西。
在下一章中,我们将探讨另一个重要主题——主题化。尽管你可能认为这属于前端开发者的范畴,模块开发者也扮演着重要的角色。是的,大部分的样式、客户端脚本和视觉架构都可以,并且确实是由我们所说的主题开发者完成的。然而,模块开发者需要理解和使用主题化工具,以确保他们的数据以正确的方式呈现。因此,在下一章中,我们将专注于这一点。
第四章:主题化
Drupal 主题系统最明显的部分是位于admin/appearance的“外观”管理页面,它列出了您网站上安装的所有主题:

当你在外观页面上选择一个主题时,你实际上是在为你的网站数据和功能应用一个特定的图形设计。然而,实际应用的主题只是整个主题层的一小部分。
本书主要关注构建封装功能块模块。然而,由于我们最终是在构建一个网络应用程序,我们功能输出的所有内容都需要用 HTML 进行标记。在 Drupal 中,将数据包裹在 HTML 和 CSS 中的这个过程被称为主题化。
在本章中,我们将讨论我们的模块如何与主题层集成。我们将讨论系统的架构、主题模板、钩子、渲染数组等内容,并提供一些实际示例。
业务逻辑与展示逻辑
我们从讨论现代应用程序做出的一个重要架构选择开始这一章:如何将数据转换为展示。
那么,获取我们的数据和功能标记的最佳方法是什么?我们是否简单地用 HTML 包裹每一块数据,然后返回一个巨大的字符串,如下面的示例所示?
return '<div class="wrapper">' . $data . '</div>';
不,我们不是这样做的。像所有其他设计良好的应用程序一样,Drupal 将业务逻辑与其展示逻辑分开。确实,Drupal 的早期版本曾使用过这种方法,尤其是在主题函数方面,但即便如此,它们也容易被覆盖。因此,这些结构并不是直接位于业务逻辑的中间,而是封装在由客户端代码调用的特殊主题函数中。因此,业务逻辑与展示逻辑的分离是明显的,尽管有时 PHP 和 HTML 代码之间的界限并不那么清晰。
传统上,这种关注点分离的主要动机如下:
-
为了使代码更容易维护
-
为了能够轻松地替换一层实现,而无需重写其他层
正如我们将看到的,Drupal 在“可替换性”方面做得相当彻底。你可能认为你在外观页面上选择的主题负责应用网站的 HTML 和 CSS。这是真的,但只到一定程度。Drupal.org 上有数千个贡献模块。你也可以编写大量的自定义模块。主题是否应该负责标记所有这些模块的数据?显然不是。
由于模块最熟悉自己的数据和功能,因此它有责任提供默认的主题实现——这种独立于设计的初始外观和感觉,并且无论主题如何都应该正确显示数据。然而,只要模块正确使用主题系统,主题就可以通过交换模块的实现与自己的实现来覆盖任何 HTML 和/或 CSS。
换句话说,在模块(业务逻辑)内部检索和处理数据之后,它需要提供默认的主题实现来将其包裹在其标记内。有时,特定的主题可能需要覆盖这个实现以达到特定的设计目标。如果主题提供了自己的实现,Drupal 将使用该主题实现而不是模块的默认实现。这通常被称为覆盖。否则,默认的回退仍然存在。主题还提供了仅通过 CSS 应用样式并保持模块提供的标记不变的选择。
Twig
主题引擎负责通过模板文件进行实际输出。尽管 Drupal 的早期版本能够使用不同的主题引擎,但有一个引擎脱颖而出,并且被 99.9%的时间使用(我现场编造的统计数据):PHPTemplate。这个主题引擎使用具有.tpl.php扩展名的 PHP 文件,并包含标记和 PHP。经验丰富的 Drupal 开发者习惯了这种做法,但这对前端开发者来说使用和主题化总是更困难。
在 Drupal 8 中,它被放弃,转而使用由 SensioLabs(负责 Symfony 项目的人)创建的 Twig 模板引擎。正如所提到的,主题函数也被弃用,转而通过Twig文件输出所有内容。这为主题系统带来了许多改进,也让前端社区感到非常高兴。例如,它提高了安全性,增强了可读性,并且使得实际上掌握 PHP 知识以参与 Drupal 站点的主题设计变得不那么重要。
Drupal 8 中所有的 Twig 模板文件都有.html.twig扩展名。
主题钩子
由于我们已经介绍了一些 Drupal 主题系统背后的原则——最值得注意的是,关注点的分离——让我们更深入地了解一下它们是如何实际应用的。这一切都始于主题钩子。是的,Drupal 总是喜欢把事物称为钩子。
主题钩子定义了特定数据应该如何渲染。它们通过使用hook_theme()由模块(和主题)注册到主题系统中。这样做时,它们获得一个名称,一个它们输出的变量列表(需要用标记包裹的数据),以及其他选项。
注册主题钩子的模块和主题还需要提供一个实现(默认情况下将使用的实现)。在 Drupal 7 中,这是通过以下两种方式完成的:要么是通过返回字符串(标记)的 PHP 函数,要么是通过PHPTemplate模板文件。两者都很重要,但后者在我的(以及许多人的)观点中总是更“正确”。这也得到了事实的支持,即函数方法在 Drupal 8 中被完全抛弃,转而使用Twig模板。此外,与主题系统的全面重写相结合,现在几乎所有输出都是通过 Twig 模板文件完成的,这真是太好了。
作为例子,让我们看看两种常见的注册主题钩子的方式,我们经常能找到。为此,我们将使用 Drupal 核心中已经存在的示例:
function hook_theme($existing, $type, $theme, $path) {
return [
'item_list' => array(
'variables' => array('items' => array(), 'title' => '', 'list_type' => 'ul', 'wrapper_attributes' => array(), 'attributes' => array(), 'empty' => NULL, 'context' => array()),
),
'select' => array(
'render element' => 'element',
),
];
}
在先前的hook_theme()示例中,我包括了 Drupal 核心中的两个主题钩子。一个是基于变量的,而另一个是基于渲染元素的。当然,这里可以定义的选项还有很多,我强烈建议你阅读 Drupal.org API 文档页面以了解这个钩子。
然而,一开始你就能看到注册主题钩子是多么简单。在第一种情况下,我们有item_list,默认情况下(如果没有其他指定),它将映射到item-list.html.twig文件以输出变量。在其定义中,我们可以找到它使用的变量,如果客户端没有传递,还有一些方便的默认值。第二个主题钩子是select,它不使用变量,而是一个渲染元素(我们很快会讨论)。此外,其模板文件很容易确定,基于名称:select.html.twig。我鼓励你检查这两个模板文件在核心代码(由系统模块提供)中的情况。
除了实际实现之外,注册主题钩子的模块和主题还可以提供一个默认模板预处理器。这个责任是“预处理”(即准备)在发送到模板之前的数据。例如,如果一个主题钩子只接收一个实体(一个复杂的数据对象)作为其唯一变量,预处理器可以用来将这个实体分解成在模板中需要输出的小块(如标题和描述)。
模板预处理器是简单的程序性函数,遵循命名模式,并在模板渲染之前由主题系统调用。正如我之前提到的,注册主题钩子的模块和主题也可以提供一个默认预处理器。因此,对于一个名为component_box的主题钩子,默认预处理器函数看起来可能如下所示:
function template_preprocess_component_box(&$variables) {
// Prepare variables.
}
函数名以单词template开头,表示它是此主题钩子的原始预处理器,然后跟随传统的preprocess单词,并以主题钩子的名称结尾。参数始终是一个作为引用传递的数组,包含有关该主题钩子的某些信息,更重要的是,与主题钩子一起定义并从调用代码传递给它的数据变量。这就是我们通常在这个函数中处理的内容。由于它是通过引用传递的,所以在这个函数中我们不返回任何内容,但我们总是直接在$variables数组中操作值。最后,模板文件可以打印出以该数组键命名的变量。当然,这些值将是映射到这些键的值。
另一个模块(或主题)可以通过实现自己的来覆盖此预处理器函数。然而,在其命名中,它需要用模块名称替换单词template(以避免冲突)。如果存在这样的覆盖,则将按特定顺序调用两个预处理器。第一个始终是默认的,然后是模块定义的,然后是主题定义的。这是 Drupal 的另一个优秀扩展点,因为更改预处理器内部发现的数据或选项可以在很大程度上定制现有功能以满足你的需求。
作为遵循先前命名约定的替代方案,你还可以在注册hook_theme()时注册预处理器函数名称。然而,我建议你坚持使用默认的命名约定,因为它更容易发现函数的目的。随着你变得更加高级,你也会反过来欣赏能够快速理解这些约定函数的能力。
我之前提到过,模块和主题也可以覆盖其他模块和主题定义的主题钩子。这有两种方法。最常见的一种是一个主题覆盖主题钩子。这是因为之前提到的理由——模块为其数据定义了一个默认实现,但主题可以轻松地接管其展示。此外,主题覆盖主题钩子的方式是简单地创建一个与原始文件同名的新的 Twig 文件,并将其放置在其templates文件夹中的某个位置。如果启用该主题,它将被使用。一个不太常见但绝对有效的用例是一个模块覆盖另一个模块定义的主题钩子。例如,这可能是因为你需要更改一个流行的贡献模块的数据渲染方式。为了实现这一点,你需要实现hook_theme_registry_alter()并更改现有主题钩子使用的模板文件。还值得一提的是,如果你想改变整个主题钩子定义,可以使用这个钩子,而不仅仅是模板。此外,既然我们提到了这个钩子,请注意,主题钩子一旦定义,就会存储和缓存在一个主题注册表中,以优化性能,而这个注册表就是我们通过这个钩子来更改的。这也意味着当我们对主题注册表进行更改时,我们需要定期清理缓存。
所有这些都很好,但是业务逻辑仍然需要与主题系统交互,以告诉它使用哪个特定的主题钩子。在 Drupal 7 中,我们有一个theme()函数,它接受钩子名称作为参数,并负责一切:确定使用哪个模板文件(或函数),调用预处理器、处理器等等。在 Drupal 8 中,theme()函数不再存在,已被一个基于渲染数组的更健壮的系统所取代,该数组包含主题钩子信息、变量以及任何其他关于如何渲染该组件的元数据。我们也会在本章中讨论渲染数组。
主题钩子建议
主题钩子的一个优点是它们是可重用的。然而,你可能会遇到的一个问题是,当主题钩子被重用时,主题钩子模板会丢失上下文。例如,我们在上一节中看到的item_list主题钩子,它不知道它正在为主题哪个列表。这使得根据内容的不同来不同地样式化变得困难。幸运的是,我们可以通过使用主题钩子模式而不是原始的主题钩子名称来为主题系统提供上下文,这个模式看起来像这样:
base_theme_hook__some_context
模式的一部分由双下划线分隔,合在一起称为主题钩子建议。但它是如何工作的呢?
客户端代码(即将看到的渲染数组),当使用主题钩子渲染数据片段时,可以将上下文附加到主题钩子,将其转换为建议。主题系统随后将检查以下内容:
-
如果存在匹配该建议的模板文件(在主题内部),则使用它而不是原始的主题钩子模板
-
或者,如果已注册具有该实际名称的主题钩子,则使用该钩子
-
或者,它会检查基本主题钩子并使用它(回退)
在这种情况下,调用者(渲染数组)负责“提出”一个建议。例如,考虑以下渲染数组:
return [
'#theme' => 'item_list__my_list',
'#items' => $items,
];
基本主题钩子是item_list,它使用 Drupal 核心提供的item-list.html.twig模板文件进行渲染。如果主题中没有item-list—my-list.html.twig模板文件,并且没有注册item_list__my_list主题钩子,则将使用默认的item_list主题钩子。否则,我们将遵循之前提到的顺序。一个模块可以将该建议注册为钩子,然后将其用作替代。然而,主题可以通过仅创建具有该名称的模板文件来进一步覆盖它。
所有这些操作都是为了在渲染可重用的主题钩子时,给主题设计师和操作者提供确定具体主题内容的机会。然而,我们刚才看到的例子在某种程度上是静态的,因为我们硬编码了my_list作为主题钩子建议。我们可以做得更好。
注册主题钩子的模块还可以提供与该主题钩子自动关联的建议列表。它是通过实现hook_theme_suggestions_HOOK()来实现的,其中HOOK是主题钩子名称。此钩子在主题系统运行时触发,试图确定某个渲染数组需要如何渲染。它接收与模板预处理器相同的$variables数组作为参数。这意味着我们可以利用这些变量并动态提供主题钩子建议。我们将在本章后面看到这个例子。
此外,作为模块开发者,我们还可以为其他模块或 Drupal 核心注册的主题钩子提供一系列主题钩子建议。我们可以通过实现hook_theme_suggestions_HOOK_alter()来实现这一点,在那里我们除了接收变量外,还接收该主题钩子的可用建议。
总结来说,主题钩子建议是向负责渲染多个内容的通用主题钩子添加一些上下文的一种强大方式。
渲染数组
渲染数组也存在于 Drupal 的先前版本中,并且对于主题系统来说非常重要。然而,在 Drupal 8 中,它们已经成为了核心——渲染 API 的核心部分,负责将标记表示转换为实际的标记。
承认我作为一个作家的局限性,我将参考 Drupal.org 文档中的定义,该定义最好地描述了渲染数组是什么:
...一个包含要渲染的数据及其渲染方式的属性的分层关联数组。
简单,但强大。
拥有渲染数组的一个主要原因是它们允许 Drupal 将实际将某物渲染为标记的过程推迟到最后一刻。我这是什么意思呢?例如,在 Drupal 7 中,我们作为模块开发者通常会调用实际的渲染服务(theme() 函数)在预处理器内部来“渲染”一些数据,以便在模板中打印出结果字符串(标记)。然而,这使得在管道中的后续步骤中更改这些数据变得不可能,例如,在执行此渲染的预处理器之后的另一个预处理器。
因此,在 Drupal 8 中,我们不再需要/应该手动渲染任何内容(除非在非常特定的情况下)。我们始终与渲染数组一起工作。Drupal 将知道如何将它们转换为标记。这样,模块和主题可以在处理过程中的不同级别拦截渲染数组并进行修改。
我们现在将讨论渲染数组以及与之一起工作的不同方面。
渲染数组的结构
渲染数组由 renderer 服务(RendererInterface)渲染,它遍历数组并递归渲染每个级别。数组的每个级别可以有一个或多个元素,这些元素可以是两种类型之一:属性或子元素。属性是键前面带有 # 符号的那些,而子元素则不是。子元素本身也可以是一个包含属性和子元素的数组。但是,每个级别至少需要有一个属性才能被视为一个级别,因为它负责告诉渲染系统该级别应该如何渲染。因此,属性名称特定于 Render API 和它们需要渲染的实际事物,而子元素的名称可以灵活。除了这两种类型(是的,我撒谎了,可以有多于两种)之外,我们还可以有由主题钩子定义的变量,它们也以 # 符号开头。它们本身不是属性,但主题系统知道它们,因为它们已在 hook_theme() 内部注册。
Render API 使用了许多属性来处理渲染数组。其中一些属性非常重要,例如 #cache 和 #attached。然而,还有一些属性是强制性的,因为它们定义了渲染数组的核心职责。以下是一些描述渲染数组应该做什么的属性,每个渲染数组都应该具有其中之一。
#type
#type 属性指定数组包含需要使用特定 渲染元素 渲染的数据。渲染元素是封装了定义的可渲染组件的插件(是的,插件)。它们本质上包装了另一个渲染数组,该数组可以使用主题钩子或更复杂的渲染数组来处理它们负责渲染的数据。你可以把它们看作是标准化的渲染数组。
渲染元素有两种类型:通用和表单输入元素。它们各自都有相应的插件类型、注解和接口。它们在渲染标准化的 HTML 片段方面相似;然而,表单输入元素需要处理表单处理、验证、数据映射等问题。记住,当我们定义我们的表单在第二章中时——创建你的第一个模块,我们遇到了带有 # 符号的数组。这些是(表单)具有不同选项(属性)的渲染元素。
要找到这两种类型渲染元素的示例,请查找实现了 ElementInterface 和 FormElementInterface 接口的插件。
#theme
#theme 属性与本章前面讨论的主题紧密相关——主题钩子。它指定渲染数组需要使用定义的主题钩子之一来渲染某种类型的数据。与这个属性一起,你通常会遇到其他映射到主题钩子在 hook_theme() 中注册的变量名称的属性。这些是主题系统用来渲染模板的变量。
这是你在业务逻辑中使用的属性,用于传达你的数据需要使用特定的主题钩子进行渲染。如果你认为你只能使用你注册的主题钩子,那么你就错了。有许多主题钩子已经被 Drupal 核心和贡献的模块注册,这使得 Drupal 开发者的生活变得更加容易。只需查看 drupal_common_theme(),你可能会发现一些常见的主题钩子可以使用。
#markup
有时,注册一个主题钩子和一个用于输出某些数据的模板可能是过度设计。想象一下,你只有一段需要用 <span> 标签包裹的字符串。在这种情况下,你可以使用 #markup 属性,该属性指定数组直接提供需要输出的 HTML 字符串。注意,然而,提供的 HTML 字符串会通过 \Drupal\Component\Utility\Xss::filterAdmin 进行清理(主要是 XSS 保护)。这是完全可以接受的,因为如果你试图在这里包含的 HTML 被删除,这通常意味着你过度使用了 #markup 属性,而应该注册一个主题钩子。
除了简单的标记之外,还有#plain_text属性,你可以通过它指定由这个渲染数组提供的文本需要完全转义。所以基本上,如果你需要输出一些简单的文本,你可以在这两个之间选择,以实现非常快速的输出。
现在,如果你记得在第二章,创建你的第一个模块中,我们的控制器在某个时候返回了这个数组:
return [
'#markup' => $this->t('Hello World')
];
这是你将见到的最简单的渲染数组。它只有一个元素,使用#markup属性输出的一个小字符串。在本章的后面部分,我们将调整这个数组,并使用我们的HelloWorldSalutation服务提供的渲染数组,以便使事情更具主题性。那将是我们将在这里学到的许多东西付诸实践的部分。
然而,尽管这个数组看起来很小,但它只是更大层次渲染数组的一部分,它构建了整个 Drupal 页面,并包含所有各种块和其他组件。同时,负责构建这个整个大东西的是 Drupal 渲染管道。
渲染管道
在第一章,为 Drupal 8 开发中,当我们概述了 Drupal 8 如何处理用户请求并将其转换为响应的高级示例时,我们提到了渲染管道的概念。所以让我们看看这个是什么,因为实际上有两个渲染管道需要讨论:Symfony 渲染管道和 Drupal 渲染管道。
如你所知,Drupal 8 使用了许多 Symfony 组件,其中之一就是 HTTPKernel 组件(symfony.com/doc/current/components/http_kernel.html)。它的主要作用是将用户请求(从 PHP 超级全局变量构建成请求对象)转换成一个标准化的响应对象,并将其发送回用户。这些对象定义在 Symfony HTTP Foundation 组件(http://symfony.com/components/HttpFoundation)中。为了协助这个过程,它使用了事件分发器组件来分发旨在处理多层工作负载的事件。正如我们所看到的,这在 Drupal 8 中也是如此。
Drupal 8 中的控制器可以返回两种东西之一——直接返回一个响应对象,或者返回一个渲染数组。如果它们返回第一个,那么工作几乎就完成了,因为 Symfony 渲染管道知道如何处理这个响应(假设响应是正确的)。然而,如果它们返回一个渲染数组,Drupal 渲染管道就会在较低级别启动,试图将其转换为响应。我们始终需要一个响应。
为了确定谁可以处理这个渲染数组,会触发kernel.view事件。Drupal 8 附带了一个MainContentViewSubscriber,它监听这个事件并检查请求格式以及控制器是否返回了一个渲染数组。根据前者,它实例化一个MainContentRendererInterface对象(默认情况下,并且大多数时候,这将是一个基于 HTML 的HtmlRenderer),并要求它将渲染数组转换为响应。然后,它将响应设置到事件上,以便 Symfony 渲染管道可以继续其愉快的旅程。
除了 HTML 渲染器之外,Drupal 8 还附带了一些其他需要处理不同类型请求的渲染器:
-
AjaxRenderer处理 Ajax 请求并与 Ajax 框架集成。我们将在本书的后面看到 Ajax 功能的示例。 -
DialogRenderer处理旨在在屏幕上打开对话框的请求。 -
ModalRenderer处理旨在在屏幕上打开模态的请求。
回到 HTML 渲染器,让我们看看它是如何将我们的渲染数组转换为实际相关的 HTML 并在响应对象上实现的。不深入细节,以下是它所做的高层次概述:
-
它的第一个目标是构建一个具有
#type => 'page'属性的渲染数组,因为这是负责整个页面的渲染元素。这意味着如果控制器返回它,它就不需要做太多。然而,通常控制器不包括这一点,因此它触发一个事件来确定谁可以构建这个渲染数组。 -
默认情况下,使用
SimplePageVariant插件来构建页面数组,但启用 Block 模块后,将使用BlockPageVariant插件,将渲染管道中的某些级别进一步向下。主要内容区域被侧边栏、页眉、页脚等处的块所包裹。 -
一旦有了页面渲染数组,它就会将其包装成另一个渲染元素,即
#type => 'html'(负责诸如<head>元素等事物)。 -
一旦它有了整个页面的主要渲染数组,它就使用
Renderer服务来遍历它并在每个级别(可能有多个)进行实际渲染。它是通过将渲染元素(#type)、主题钩子(#theme)、简单标记的文本片段(#markup)或纯文本片段(#plain_text)转换为它们各自的 HTML 表示来做到这一点的。
因此,正如你所看到的,渲染管道从 Symfony 级别开始,当遇到渲染数组时进入 Drupal 领域,但继续向下构建控制器返回的页面上找到的每个组件。然后,它回到这些级别,直到创建了一个巨大的渲染数组并可以将其转换为 HTML。此外,在它返回的过程中,各种元数据可以冒泡到主渲染数组中。
我故意省略了缓存这个因素,尽管它非常重要,我们将在后面的章节中介绍。但是,可以说,缓存元数据就是这样一种从底层向上冒泡的例子,一直到达顶层,并用于确定页面级缓存。但关于这一点,我们稍后再说。
资产和库
现在我们对渲染数组有了更多的了解,包括它们的结构以及它们所经过的流程,我们可以从模块开发的角度谈谈资产管理的相关内容。因为尽管这通常是一个主题责任,模块开发者通常需要向他们的模块中添加和使用 CSS 和 JS 文件,而这些操作都是在渲染数组中完成的。
与 CSS 和 JS 文件一起工作在 Drupal 8 中已经标准化了,与之前的版本相比,我们当时有不止一种方法可以做到这一点。库现在是关键,让我们通过一些使用 CSS 或 JS 文件的示例来了解它们是如何工作的。
将资产添加到你的页面有三个步骤:
-
创建你的 CSS/JS 文件
-
创建包含它们的库
-
将该库附加到渲染数组
库
假设你已经有了 CSS/JS 文件,库在模块根文件夹中的 module_name.libraries.yml 文件内定义。这个文件中库定义的一个简单例子如下:
my-library:
version: 1.x
css:
theme:
css/my_library.css: {}
js:
js/my_library.js: {}
这是一个标准的 YAML 表示法,通过它我们定义了一个名为 my-library 的库,并提供了有关它的信息。我们可以指定一个版本号,然后添加尽可能多的 CSS 和 JS 文件引用。文件路径相对于包含此库定义的模块文件夹,我们可以在大括号之间添加一些选项(更高级,但我们将稍后看到一个示例)。
此外,你还会注意到 CSS 文件有一个额外的层级键名为 theme。这是为了指明要包含的 CSS 类型,可以是以下之一(基于 SMACSS (smacss.com/) 标准):
-
base:通常包含 CSS 重置/标准化和 HTML 元素样式 -
layout:高级页面样式,例如网格系统 -
component:UI 元素和可重用组件 -
state:用于组件客户端更改的样式 -
theme:组件的视觉样式
这里的选择也反映在 CSS 文件包含的权重上,后者是“最重”的——它将被最后包含。
在任何应用程序中使用库的一个重要方面是能够包含外部托管文件(通常来自 CDN),以获得更好的性能。让我们看看一个使用外部托管文件的示例库定义:
angular.angularjs:
remote: https://github.com/angular/angular.js
version: 1.4.4
license:
name: MIT
url: https://github.com/angular/angular.js/blob/master/LICENSE
gpl-compatible: true
js:
https://ajax.googleapis.com/ajax/libs/angularjs/1.4.4/angular.min.js: { type: external, minified: true }
这个例子是从 Drupal.org (www.drupal.org/docs/8/creating-custom-modules/adding-stylesheets-css-and-javascript-js-to-a-drupal-8-module) 上关于在 Drupal 8 中定义库的内容中摘取的。然而,正如你所看到的,结构与我们之前的例子相同,只是它有一些关于外部库的更多元信息。而且,我们有一个远程 URL 到实际资源的引用,而不是本地路径引用。此外,我们还在大括号内看到了一些选项,我们可以通过这些选项指定文件实际上是外部定位和压缩的。
当涉及到 Drupal 8 中的 JS 时,一个重要的变化是 Drupal 不再默认包含所有库,如 jQuery。它只在需要时才这样做。因此,这使得库依赖的概念变得突出,因为某些脚本需要加载其他库才能工作。
假设 my-library 依赖于 jQuery,并将其指定为依赖项。我们只需要添加到我们的库定义中的是以下内容:
dependencies:
- core/jquery
请记住,dependencies 键与 css 和 js 处于相同的 YML 级别。
通过这种方式,我们声明 Drupal 核心 jQuery 库是我们库所需的。这意味着如果我们使用我们的库某处,而 jQuery 没有被包含,Drupal 将处理依赖关系并将它们全部包含。这个好处是依赖关系总是在我们脚本之前包含,因此我们也可以控制这一点。
core/jquery 表示法表明定义 jquery 库的扩展(模块或主题)是 Drupal 核心。如果它是一个模块或主题,core 将会被模块或主题的机器名所替换。因此,例如,要在某处使用我们的新库,它将被引用为 module_name/my-library。
附加库
你最常见的方式是将库附加到你的渲染数组上。这种方法意味着库对于该组件的渲染是必需的,因此如果该组件从页面中缺失,库资源将不再被包含。
这里是一个渲染数组的样子,其中我们之前定义的库被附加到它上面:
return [
'#theme' => 'some_theme_hook',
'#some_variable' => $some_variable,
'#attached' => [
'library' => [
'my_module/my-library',
],
],
];
#attached 属性在这里很重要,它表示我们实际上正在将某些内容附加到渲染数组上,在我们的例子中,这恰好是一个库。在 Drupal 7 中,我们可以直接附加 CSS 和 JS 文件,但现在我们有一个标准化的库 API 来以更稳健的方式这样做。
然而,你可能会有这样的情况,你需要的库并没有链接到特定的渲染数组(页面上的组件),而是链接到整个页面——所有页面或子集。要在整个页面上附加库,你可以实现 hook_page_attachments()。考虑以下示例:
function hook_page_attachments(array &$attachments) {
$attachments['#attached']['library'][] = 'my_module/my-library';
}
这个钩子在每一页上都会被调用,因此您也可以根据上下文附加库(例如,如果用户具有特定的角色或类似的东西)。此外,还有一个 hook_page_attachments_alter() 钩子,您可以使用它来修改任何现有的附件(例如,从页面上删除附件)。
另一种附加库的方法是在预处理器函数内部。我们在这章的早期讨论了预处理器函数;实现起来很简单:
function my_module_preprocess_theme_hook(&$variables) {
$variables['#attached']['library'][] = 'my_module/my_library';
}
您需要做的只是将 #attached 键(如果它已经存在)添加到变量数组中。
这三种附加库的方法是最常见的方法,您可能会遇到并使用。然而,还有其他一些方法和地方可以添加附件——您可以修改现有的渲染元素定义,您也可以直接在 Twig 文件中附加库。我建议您阅读 Drupal.org 文档(www.drupal.org/docs/8/creating-custom-modules/adding-stylesheets-css-and-javascript-js-to-a-drupal-8-module)以获取有关这些方法的更多信息。
常见主题钩子
在本节中,我们将探讨 Drupal 核心附带的三种常见主题钩子,您可能会非常频繁地使用。当然,理解它们最好的方法是通过参考如何使用它们的示例。所以,让我们开始吧。
列表
最常见的 HTML 构造之一是列表(有序或无序列表),任何网络应用程序最终都会有很多这样的列表,无论是用于列出项目还是用于看起来根本不像列表的组件,但在标记的目的上,ul 或 ol 是最合适的。幸运的是,Drupal 一直都有 item_list 主题钩子,它足够灵活,可以让我们在几乎所有情况下使用它。
item_list 主题钩子定义在 drupal_common_theme() 中,默认情况下在 template_preprocess_item_list() 中进行预处理,默认使用 item-list.html.twig 模板,并且没有默认的主题钩子建议(因为它非常通用,并且是在任何业务逻辑之外注册的)。如果我们检查其定义,我们会注意到它接受许多变量,这些变量构建了其灵活性。让我们看看如何使用它的一个示例。
假设我们有一个以下的项目数组:
$items = [
'Item 1',
'Item 2'
];
我们可以将这个渲染为 <ul> 的最简单方法是以下这样:
return [
'#theme' => 'item_list',
'#items' => $items
];
请注意,相应的 <ul> 被包装在 <div class="item_list"> 中,并且我们数组中的项目也可以渲染数组本身。
如果我们要将列表转换为 <ol>,我们需要将 #list_type 变量设置为 ol。我们甚至可以在列表之前设置一个标题标题(<h3>),如果我们设置 #title 变量。此外,我们还可以在 <div> 包装器上添加更多属性。关于其他选项如何工作的更多信息,我建议您检查模板文件和预处理器函数。然而,这些是您最常使用的。
链接
在第二章 创建您的第一个模块 中,我们简要地探讨了如何以编程方式处理链接,以及如何以两种不同的方式构建和渲染它们。我们还指出,如果我们希望链接在将来可更改,最好使用#link渲染元素(我们现在也理解了这是什么)。现在,让我们看看如何使用有用的links主题钩子构建链接列表。
links主题钩子接受要渲染的链接数组、可选属性、可选标题以及一个用于动态设置活动类的标志。然后它使用links.html.twig模板来构建一个<ul>,就像item_list钩子一样。
在这里最重要的变量是链接数组,因为它需要包含具有以下键的单独数组:title(链接文本)、url(一个Url对象)和attributes(要添加到每个链接项的属性数组)。如果您查看template_preprocess_links预处理器内部,您会看到它接受这些项目并将它们转换为一个带有#type => 'link'(渲染元素)的渲染数组。
除了链接数组之外,我们还可以传递一个标题(就像item_list一样)和一个用于设置活动类的标志—set_active_class。后者将使它向列表中的<li>项和链接本身添加is-active类,如果链接与当前路由匹配。这很方便,不是吗?然而,对于更多信息,请查看template_preprocess_links()实现上方的文档。现在,让我们看看一个快速示例,看看如何在实践中使用它:
$links = [
[
'title' => 'Link 1',
'url' => Url::fromRoute('<front>'),
],
[
'title' => 'Link 1',
'url' => Url::fromRoute('hello_world.hello'),
]
];
return [
'#theme' => 'links',
'#links' => $links,
'#set_active_class' => true,
];
那就是全部了。我们构建了一个链接数据数组,然后使用links主题钩子来构建渲染数组。我们还只是为了好玩使用了set_active_class选项。这意味着如果这个渲染在主页上,is-active类将出现在第一个链接上;如果渲染在Hello World页面上,则出现在第二个链接上。就这么简单。
表格
我们现在将要查看的最后一个常见主题钩子将帮助您构建表格。在 Drupal 中,使用主题钩子构建表格而不是自己创建标记一直是最佳实践。这也部分是因为它一直非常灵活。所以,让我们看看。
table主题钩子接受许多变量,其中许多是可选的。然而,最重要的是header(一个标题定义数组)和rows(一个行定义的多维数组)。在这里重复所有可能的表格构建选项是没有意义的,因为它们都在template_preprocess_table()预处理器函数上方有很好的文档说明。所以,请查看那里以获取更多信息。相反,我们将专注于渲染表格的简单用例,并通过一个示例来实现:
$header = ['Column 1', 'Column 2'];
$rows = [
['Row 1, Column 1', 'Row 1, Column 2'],
['Row 2, Column 1', 'Row 2, Column 2']
];
return [
'#theme' => 'table',
'#header' => $header,
'#rows' => $rows,
];
因此,正如你所见,我们有两个关键变量。我们有标题项的列表和行(其单元格的顺序与标题中的顺序相同)。当然,你还有很多其他选项,包括表格所有级别的属性、方便的排序功能,这使得它很容易与数据库查询集成,还有更多。我强烈建议你在文档中探索这些选项。
属性
在我们遇到的之前三个主题钩子示例中,我们遇到了在渲染 HTML 元素时使用attributes的概念。这里的属性理解方式与 HTML 中相同。例如,class、id、style和href都是 HTML 元素属性。这为什么很重要?
主题钩子的可重用性使得我们无法在 Twig 模板文件中硬编码所有的 HTML 属性。我们可以有一些,包括类,但我们始终需要允许业务逻辑通知主题钩子它需要在 HTML 元素上打印的某些属性值。例如,一个链接上的active类。这就是为什么我们有这个属性概念。
你会看到的绝大多数主题钩子都有某种形式的属性,变量通常被称为$attributes、$wrapper_attributes或类似的东西。此外,这个变量始终需要是一个多维数组,包含你想要传递的属性数据。这个数组中的键是属性的名称,而值是属性值。如果值可以有多个项目,例如类,它也将是一个数组。考虑以下示例:
$attributes = [
'id' => 'my-id',
'class' => ['class-one', 'class-two'],
'data-custom' => 'my custom data value'
];
正如你所见,我们有一些常见的属性,但根据需要你也可以自己创建(通常以数据属性的形式)。然而,这并不是强制性的,你只能添加你实际需要的属性。不过,始终要阅读有关主题钩子的文档,以了解它们的使用方式和哪些元素实际上会获得它们。
从 API 的角度来看,Drupal 通过一个名为Attribute的便捷类来处理属性。你会注意到,许多模板预处理程序都会使用那个数组,并构建一个新的Attribute对象,以便更轻松地操作它们。此外,这样的对象也是可渲染的,因为它实现了MarkupInterface接口,并且 Twig 将直接知道如何将其转换为字符串。
因此,如果你在编写自己的主题钩子并需要处理具有更多类的属性(有意为之),请记住这一点。
布局
作为 Drupal 8 发布周期的一部分,为了为贡献模块提供一个统一的定义布局的方法,引入了布局 API。例如,像 Panels 和 Layout Builder 这样的模块就利用这个 API 来定义包含区域并可以渲染内容以及各种事物的布局。
在 Drupal 8.3 版本中引入了布局功能作为一个实验性模块(称为布局发现),并在 8.4 版本中将其标记为稳定。同时,还引入了一个新的实验性模块,称为布局构建器,它使用此 API 为网站构建者提供了一种构建常规内容布局的方法。
在本书接下来的内容中,我们不会使用布局,但了解如何使用它们是很重要的,以防你需要它们。所以让我们快速讨论一下,作为模块开发者,你如何定义和程序化地使用布局。
定义布局
简而言之,布局是插件。但与之前见过的插件不同,这些是在 YAML 文件中定义的,而不是在类之上的注释中。这样做的一个原因是因为布局更多的是定义而不是功能,因此它们不一定需要类。它们可以简单地定义在YAML文件中的几行内。
虽然不一定,但基于 YAML 的插件通常定义在模块根目录下名为module_name.plugin_type_name.yml的文件中。因此,在布局的情况下,这将是在module_name.layouts.yml。但定义包含什么内容呢?
让我们假设我们想要定义一个包含左右区域的两列布局。我们的简单定义可能如下所示:
two_column:
label: 'Two column'
category: 'My Layouts'
template: templates/two-column
regions:
left:
label: Left region
right:
label: Right region
那么,我们从这个定义中学到了什么呢?
-
首先,我们有一个名称和类别,这是强制性的。这些可以在任何 UI 中用来显示有关布局的信息。
-
其次,我们指定应该渲染此布局的模板。相应的主题钩子将在幕后定义。在上面的例子中,模板文件将在
templates文件夹中,并被称为two-column.html.twig。 -
最后,我们使用标签定义布局的区域,其中
left和right键很重要,因为它们是区域的机器名称。 -
作为额外的好处,如果我们想要附加一个库,我们可以在定义中添加另一行,如下所示:
library: my_module/my_library
在布局注册完成之前,我们还需要创建我们引用的模板文件。它可能看起来像这样:
<div class="two-column">
<div class="left-region">
{{ content.left }}
</div>
<div class="right-region">
{{ content.right }}
</div>
</div>
在模板中,我们可以访问content变量,我们可以从中获取可以打印的区域值。
就这样了。清除缓存(并启用布局发现模块)将把此布局注册到系统中。
渲染布局
好的,但注册布局并不能帮我们太多。除非,当然,我们使用布局构建器或某些使用布局进行各种操作的贡献模块。在这种情况下,我们已经在提供很大的价值。但如果我们想自己使用这个布局怎么办?换句话说,用这个布局渲染内容。
使用此布局渲染内容的简单方法可能如下所示:
$layoutPluginManager = \Drupal::service('plugin.manager.core.layout');
$layout = $layoutPluginManager->createInstance('two_column');
$regions = [
'left' => [
'#markup' => 'my left content',
],
'right' => [
'#markup' => 'my right content',
],
];
return $layout->build($regions);
在不深入太多关于插件系统(目前)的细节的情况下,但使用上述内容,我们使用布局插件管理器创建了一个新实例的布局(其机器名为two_column)。然后我们在$regions数组中准备要打印在布局内的数据。正如你所看到的,数组结构反映了布局中的区域。最后,我们通过传递区域数据来构建布局。就是这样。生成的渲染数组将渲染模板,并在相应的区域打印内容。
主题化我们的 Hello World 模块
我们在第二章“创建您的第一个模块”中构建的HelloWorldController目前使用一个服务来检索用作问候语的字符串,然后返回一个包含该字符串的简单标记渲染数组。现在让我们假设我们想要输出这条消息,但将其包裹在我们自己的特定标记中。为了使事情变得复杂,我们想要将问候语字符串拆分成几个部分,以便它们可以稍微不同地样式化。此外,我们希望允许其他人通过配置表单是否覆盖了问候语来使用建议覆盖我们的主题。那么,让我们看看我们如何做这些事情。
为了开始,这是我们想要的标记:
<div class="salutation">
Good morning <span class="salutation—target">world</span>
</div>
我们需要做的第一件事是定义一个能够输出此内容的自定义主题钩子。为此,我们实现了hook_theme()函数:
/**
* Implements hook_theme().
*/
function hello_world_theme($existing, $type, $theme, $path) {
return [
'hello_world_salutation' => [
'variables' => ['salutation' => NULL, 'target' => NULL, 'overridden' => FALSE],
],
];
}
目前,我们只返回一个名为hello_world_salutation的主题钩子,它接受你可以看到的变量。每个变量都有一个默认值,以防客户端(渲染数组)没有传递。前两个很明显,但我们还希望有一个标志来表示问候语是否被覆盖。这将有助于主题钩子建议。
默认情况下,如果我们没有指定模板文件名,这个主题钩子将在我们的模块的/templates文件夹中查找名为hello-world-salutation.html.twig的 Twig 模板。由于这对我们来说已经足够好了,让我们继续创建它:
<div {{ attributes }}>
{{ salutation }}
{% if target %}
<span class="salutation—target">{{ target }}</span>
{% endif %}
</div>
Twig 语法易于理解。{{ }}表示我们正在打印一个具有该名称的变量(这甚至可以是一个渲染数组),而{% %}指的是控制结构,例如if语句或循环。如果你不确定,请查看 Twig 文档(twig.symfony.com/)以获取更多信息。
有一些很好的方法可以调试在 Twig 模板中最终打印的值。你可以使用原生的 Twig dump()函数,它将使用 PHP 的var_dump()输出内容,或者你可以安装 Devel 模块并使用kint()函数,它将以更可读的方式格式化内容。
我们在target变量中包裹了一个if语句,这样如果它缺失,我们就不打印一个空的 span 标签。最好是将你的模板与主题钩子使用默认值调用的可能性相匹配。
最后,我们还有一个打印在包装器上的attributes数组。我们没有定义它,但每个主题钩子都自带它。这个变量是一个Attribute对象,正如我们之前讨论的,它被打印成单个属性的字符串。
现在,我们不再直接在模板中打印我们想要的类,而是使用预处理器使事情更加动态。
所以,让我们接下来实现预处理器:
/**
* Default preprocessor function for the hello_world_salutation theme hook.
*/
function template_preprocess_hello_world_salutation(&$variables) {
$variables['attributes'] = [
'class' => ['salutation'],
];
}
如我之前提到的,在这个阶段,我们仍在使用一个属性数组。主题系统将在渲染模板之前将其转换为Attribute对象,然后它将知道如何处理它。
其他模块或主题现在可以自己实现这个预处理器,并根据需要更改类(或任何其他包装属性)。如果我们直接在模板文件中硬编码类,它们将不得不覆盖整个模板——尽管这仍然是一个可行的选项,但如果只需要添加一个类,这将是过度杀鸡用牛刀。
现在,让我们允许主题设计师根据问候语是否被管理员覆盖,为我们的问候语消息有不同的实现。我知道这个特定的例子在实用性方面相当牵强,但它允许我们展示这种方法。这非常有用。
因此,正如我们之前讨论的,我们可以为我们的主题钩子定义一个建议:
/**
* Implements hook_theme_suggestions_HOOK().
*/
function hello_world_theme_suggestions_hello_world_salutation($variables) {
$suggestions = [];
if ($variables['overridden'] == TRUE) {
$suggestions[] = 'hello_world_salutation__overridden';
}
return $suggestions;
}
如果你还记得,我们的主题钩子有一个overridden变量,它可以用来设置这个标志。所以,在我们的主题钩子建议实现中,我们检查它,如果它是真的,我们就添加我们的建议。这个函数在渲染时即时调用,如果问候语被覆盖,则使用遇到的特定建议。如果是这种情况,它将尝试hello_world_salutation__overridden,如果没有找到,它将回退到hello_world_salutation,后者是存在的。
现在主题可以有两组不同的模板,根据消息是否被覆盖,以两种不同的方式呈现问候语:
-
hello-world-salutation.html.twig -
hello-world-salutation—overridden.html.twig
好的,我们的主题钩子现在可以使用了。让我们使用它。
由于我们的主题模板将问候语消息拆分成片段,甚至可以接收overridden标志,仅仅在HelloWorldController中使用这个主题钩子是不够的。相反,我们需要回到我们的服务中,让它返回负责输出问候语的渲染数组。毕竟,业务逻辑知道某个组件需要如何渲染的结构方面。主题只需要根据良好的功能实现提供的灵活性来样式化和修改它。
然而,我们不要在服务中覆盖getSalutation()方法,而是创建一个新的方法,称为getSalutationComponent()。这将返回可以输出整个内容的渲染数组:
/**
* Returns the Salutation render array.
*/
public function getSalutationComponent() {
$render = [
'#theme' => 'hello_world_salutation',
];
$config = $this->configFactory->get('hello_world.custom_salutation');
$salutation = $config->get('salutation');
if ($salutation != "") {
$render['#salutation'] = $salutation;
$render['#overridden'] = TRUE;
return $render;
}
$time = new \DateTime();
$render['#target'] = $this->t('world');
if ((int) $time->format('G') >= 00 && (int) $time->format('G') < 12) {
$render['#salutation'] = $this->t('Good morning');
return $render;
}
if ((int) $time->format('G') >= 12 && (int) $time->format('G') < 18) {
$render['#salutation'] = $this->t('Good afternoon');
return $render;
}
if ((int) $time->format('G') >= 18) {
$render['#salutation'] = $this->t('Good evening');
return $render;
}
}
这就是它将呈现的样子。我们首先创建一个使用我们新主题钩子的渲染数组。然后,我们在配置对象中查找,如果其中存储有消息,我们就使用它,将overridden标志设置为 true,并返回渲染数组。你会注意到我们没有设置target,这意味着它不会在模板文件中打印出来(正如预期的那样)。然而,如果它没有被覆盖,我们就继续使用我们之前的逻辑,动态设置消息,同时保持target不变。你可以很容易地看到现在它如何映射到主题钩子和模板对不同情况的要求。
在继续之前,有几个要点需要说明。首先,我想重申由于缓存等问题,动态问候消息实际上不会按预期工作。我们需要设置一些缓存元数据来防止这个渲染数组被缓存,以便它能够工作。然而,我们将在第十一章缓存中看到更多关于这个内容。其次,你会注意到我们在主题钩子中定义的变量前面有一个#符号,好像它们是渲染系统已知属性一样。正如我之前所说的,它们实际上不是属性,但主题系统将它们视为变量,因为我们这样定义了它们。因此,在阅读你未编写的代码时能够区分这些是很重要的。当然,有很多属性你并不知道(我当然不知道大多数),但通过经验,你将能够阅读代码,找出源代码,并理解它的含义。在这方面,优秀开发者和杰出开发者之间的区别在于后者能够通过阅读源代码来解决问题,而不是依赖于文档。
现在,我们有一个可以返回我们消息的字符串表示形式和完整的可渲染组件的服务。因此,我们编辑我们的控制器,让它返回这个组件而不是它自己的渲染数组:
/**
* Hello World.
*
* @return array
*/
public function helloWorld() {
return $this->salutation->getSalutationComponent();
}
你会注意到我们不再需要#markup属性了,因为我们已经有了自己的渲染数组。对于salutation标记和我们所创建的块,我们不要使用这个组件,而是依赖字符串版本。这样我们可以在代码中保留两种选项供你查看。
摘要
Drupal 8 的主题系统既复杂又灵活,因此在模块开发书籍的一章中完全覆盖是不可能的。然而,我们确实介绍了让你入门的基础知识——理解主题系统的核心原则,其中一些最重要的 Drupal 特定性和实际用例。
我们本章开始时讨论了将业务逻辑与展示逻辑分离的抽象原则——这是许多现代 Web 应用所使用的原则。我们看到了为什么这对于灵活和动态的主题化是至关重要的。接下来,我们讨论了 Drupal 如何实现这种分离——强大的主题钩子,它们在两层之间充当桥梁。在这里,我们还涵盖了围绕它们的一些高度使用的实践——预处理函数和主题钩子建议,以增加灵活性。然后,我们讨论了业务逻辑如何实际使用主题钩子——渲染数组(可能是 Drupal 最重要的结构之一)。由于我们正在讨论这个主题,我们还概述了 Drupal 和 Symfony 渲染管道,以更好地理解构建整个页面渲染数组的流程。接下来,我们讨论了库以及我们如何将它们“附加”到渲染数组上。在本书后面讨论 JavaScript 时,我们肯定会看到更多示例。
最后,我们通过举例说明 Drupal 8 核心中的一些常见主题钩子,开始过渡到模块主题化的实际方面。在这个过程中,我们也遇到了属性这一主题,这是在使主题钩子更加动态时需要理解的一个重要概念。我们以对Hello World问候信息的全面改写结束本章,以创建一个可主题化的组件。我们这样做是通过实践之前学到的关于主题钩子的许多知识:我们定义了一个主题钩子和相应的模板、一个预处理函数,以及一个主题钩子建议,并动态构建了一个渲染数组来触发所有这些。对于 Drupal 8 模块开发者来说,这真是一个不错的一天。
在下一章中,我们将探讨菜单和 Drupal 8 中不同类型的菜单链接。没有菜单链接的 Web 应用会是什么样子呢?
第五章:菜单和菜单链接
导航是任何网络应用的重要组成部分。能够轻松创建菜单和链接以连接页面是任何内容管理系统的一个核心方面。Drupal 8 完全配备了网站构建能力和开发者 API,可以轻松构建和操作菜单和链接。
在本章中,我们将从 Drupal 8 模块开发者的角度讨论菜单和菜单链接。在这样做的时候,我们将涉及几个关键方面:
-
Drupal 8 中菜单系统的总体架构
-
操作和渲染菜单
-
定义各种类型的菜单链接
到本章结束时,你应该能够理解什么是菜单和菜单链接,如何在你的代码中使用它们,以及如何在你的模块中定义菜单链接。那么,让我们开始吧。
菜单系统
在我们动手操作菜单和菜单链接之前,让我们简要谈谈菜单系统背后的总体架构。为此,我想谈谈其主要组件,一些关键参与者以及你应该查看的类。就像往常一样,没有一个伟大的开发者仅仅依靠书籍或文档来弄清楚复杂的系统。
菜单
菜单是由以下类表示的配置实体:Drupal\system\Entity\Menu。我在第一章,为 Drupal 8 开发中提到,Drupal 8 中有一种称为配置实体,我们将在本书的后面部分详细探讨。然而,目前来说,理解菜单可以通过 UI 创建并成为可导出的配置就足够了。此外,这个导出的配置也可以包含在一个模块中,以便在模块首次安装时导入。这样,一个模块可以附带自己的菜单。当我们谈到 Drupal 8 中不同类型的存储时,我们将看到这个后者的工作方式。现在,我们将使用与 Drupal 8 核心一起提供的菜单进行工作。
每个菜单可以有多个菜单链接,这些链接以树状结构组织,最大深度为 9。菜单链接的顺序可以通过 UI 或通过在代码中定义的菜单链接权重轻松完成。
菜单链接
在最基本层面上,菜单链接是基于 YAML 的插件(就像我们在上一章中看到的布局插件)。为此,常规菜单链接在module_name.links.menu.yml文件中定义,并且可以通过实现hook_menu_links_discovered_alter()由其他模块进行修改。当我提到常规时,我指的是那些进入菜单的链接。我们很快就会看到还有一些其他类型。
尽管在这个架构中有很多重要的类你应该检查:MenuLinkManager(插件管理器)和MenuLinkBase(菜单链接插件基类,并实现了MenuLinkInterface)。
菜单链接也可以是内容实体。通过 UI 创建的链接被存储为实体,因为它们被视为内容。其工作原理是,对于每个创建的MenuLinkContent实体,都会创建一个插件派生版本。我们正越来越接近高级主题,而这些主题可能还太早讨论。但简而言之,通过这些派生版本,对于每个MenuLinkContent实体,就好像创建了一个新的菜单链接插件,使后者表现得像任何其他菜单链接插件。这是一个非常强大的、特定于 Drupal 8 的系统。
菜单链接有许多属性,其中之一是路径或路由。当通过 UI 创建时,路径可以是外部或内部,也可以引用现有资源。当通过编程创建时,你通常会使用一个路由。
多种类型的菜单链接
我们之前讨论的菜单链接是显示在菜单中的链接。还有一些不同类型的链接出现在其他地方,但仍然被视为菜单链接,并且工作方式类似。
本地任务
本地任务,也称为标签页,是一组链接,通常显示在页面主要内容上方(取决于标签块放置的区域)。它们通常用于将处理当前页面的相关链接分组在一起。例如,在一个实体页面上,如节点详情页,你可以有两个标签页——一个用于查看节点,一个用于编辑它(也许还有一个用于删除它);换句话说,本地任务:

本地任务会考虑访问规则,因此如果当前用户没有访问给定标签页的路由权限,则不会渲染该链接。此外,如果这意味着集合中只剩下一个链接可访问,那么该链接也不会被渲染,因为没有意义。所以,对于标签页,至少需要两个链接才能显示出来。
模块可以在module_name.links.task.yml文件中定义本地任务链接,而其他模块可以通过实现hook_menu_local_tasks_alter()来修改它们。
本地操作
本地操作是与给定路由相关的链接,通常用于操作。例如,在一个列表页面上,你可能有一个用于创建新列表项的本地操作链接,这将带你到相关的表单页面。
在以下屏幕截图中,我们可以看到一个用于在主要用户管理页面上创建新用户的本地操作链接:

模块可以在module_name.links.action.yml文件中定义本地操作链接,而其他模块可以通过实现hook_menu_local_actions_alter()来修改它们。
上下文链接
上下文链接由上下文模块用于在给定组件(一个渲染数组)旁边提供便捷的链接。你可能遇到过这种情况,比如在悬停于一个块上时,会看到一个带有下拉菜单的小图标,其中包含配置块的链接:

上下文链接与渲染数组相关联。实际上,任何渲染数组都可以显示之前定义的一组上下文链接。
模块可以在module_name.links.contextual.yml文件中定义上下文链接,而其他模块可以通过实现hook_contextual_links_alter()来修改它们。
菜单链接树
正如我在关于菜单的部分提到的,菜单链接是按层次存储在菜单中的。这个层次结构通过菜单链接树来表示。这里有几个关键角色我们需要了解。
我们有MenuLinkTree服务,它是用来加载和准备某个菜单树界面的接口。加载操作被延迟到MenuTreeStorage服务,该服务基于一个包含对加载的菜单链接应用某些限制的元数据的MenuTreeParameters对象来执行。我们稍后会看到一些这方面的例子。
MenuLinkTree服务输出的是一个MenuLinkTreeElement对象的数组。这些实际上是封装了MenuLinkInterface插件的值对象,并提供了一些关于它们在加载的树中的位置额外数据。其中一条重要信息是子树(位于其下的MenuLinkTreeElement对象的数组)。
菜单链接树操作符
当加载菜单链接树时,你会得到符合指定参数的整个树。然而,当使用该树时,你可能想要执行一些检查并删除某些项目。一个常见的例子是删除用户无权访问的菜单链接。这就是操作符发挥作用的地方。
MenuLinkTree服务有一个transform()方法,它根据一个操作符数组来改变树。后者以可调用对象的形式出现,通常是具有特定方法的服务名称。因此,实际的操作符是遍历树并对树项、它们的顺序等进行修改的服务。
菜单活动路径
菜单路径是一个菜单链接插件列表(数组),这些插件是菜单链接的父级。对于活动路径,特定的菜单链接代表当前路由(如果存在该路由的菜单链接)。
Drupal 8 的菜单系统还有一个服务,可以用来确定当前路由的活动路径,如果使用菜单链接的话。通过传递一个要查找的菜单名称,MenuActiveTrail服务返回一个包含所有父级插件 ID 的数组,直到菜单根,如果当前路由确实是一个活动链接。还有一个方法可以用来检查这一点:getActiveLink()。
渲染菜单
现在我们已经介绍了一些关于菜单系统的理论,是时候动手写一些代码了。我们将首先查看如何以在模块中渲染菜单的方式程序化地处理菜单。为此,我们将使用 Drupal 核心中默认的 管理 菜单,它包含许多链接,位于不同的级别。请注意,本节中编写的代码将不会包含在代码库中。
Drupal 核心提供了一个名为 SystemMenuBlock 的块,可以用来在块中渲染任何菜单。然而,让我们看看我们如何自己来做这件事。
我们需要做的第一件事是获取 MenuLinkTree 服务。我们可以注入它,或者,如果不可能,可以通过辅助 \Drupal 类静态地获取它:
$menu_link_tree = \Drupal::menuTree();
接下来,我们需要创建一个 MenuTreeParameters 对象,以便我们可以使用它来加载我们的菜单树。我们可以以两种方式做到这一点。我们可以自己创建它并设置自己的选项,或者我们可以基于当前路由获取一个默认值:
$parameters = $menu_link_tree->getCurrentRouteMenuTreeParameters('admin');
提供菜单的名称(在我们的情况下,是“admin”),这个方法给我们一个设置了以下选项的 MenuTreeParameters 实例:
-
当前路由活动路径中的链接被标记为展开,即它们将显示在我们加载的结果树中。
-
活动路径中具有“展开”属性的链接的子链接也包括在结果树中。
实际上,这组参数给我们提供了一个在当前路由上下文中的树。换句话说,它将加载菜单中的所有根链接以及当前路由活动路径中的根链接的子链接。它将省略其他根链接的子链接。
你当然可以进一步自定义这组参数或从头开始创建一个。例如,如果我们只想加载菜单中根链接的树,我们可以这样做:
$parameters = new MenuTreeParameters();
$parameters->setRoot($plugin_id);
在这个例子中,$plugin_id 是应该位于树根处的菜单链接的 ID(在 YAML 文件中定义或通过派生得到)。
我鼓励你查看 MenuTreeParameters 类,并探索你用于加载树的其他选项。
对于我们的示例,我们想要处理整个“管理”菜单的整个菜单树,因此只需实例化一个新的 MenuTreeParameters 对象就足够了,因为我们想要加载菜单中的所有链接。我们可以这样做:
$tree = $menu_link_tree->load('admin', $parameters);
现在,我们在 $tree 变量中有一个 MenuLinkTreeElement 对象的数组,其中包含,但不仅限于以下内容:
-
链接属性,即菜单链接插件
-
子树属性,它是一个包含
MenuLinkTreeElement对象的数组,沿着树向下延伸 -
树中链接的各种元数据(深度、是否在活动路径中、是否有子链接、访问权限等)
然而,重要的是要注意,尽管我们可能有一些MenuTreeParameters,但我们现在位于该菜单的所有菜单链接的顶部,无论任何访问检查。确保我们不会渲染用户无权访问的链接是我们的责任(因为他们到达那里时会收到 403 错误)。为此,我们使用之前讨论过的操作器,这些操作器是服务上的简单方法。
Drupal 8 的菜单系统自带了一些默认操作器,可以在DefaultMenuLinkTreeManipulators服务中找到。大多数情况下,它们将对你足够用:
-
访问(由
checkAccess()方法处理):检查用户是否有访问树中链接的权限。如果没有,链接将变成InaccessibleMenuLink的一个实例,并且其子树中的任何链接都将被清除。 -
节点访问(由
checkNodeAccess()方法处理):检查用户是否有访问由菜单链接链接的节点实体的权限。如果你知道菜单有链接到节点,你可以在常规访问检查之前使用这个方法,因为它性能更好。 -
索引和排序(由
generateIndexAndSort()方法处理):在树中创建唯一的索引并按它们排序。 -
扁平化(由
flatten()方法处理):将菜单树扁平化到一级。
如果这些还不够,你可以根据需要添加自己的操作器。你所要做的就是定义一个具有公共方法的服务,然后在转换树时引用它。然而,说到转换,让我们继续使用访问检查操作器来确保当前用户可以访问我们的树形链接:
$manipulators = [
['callable' => 'menu.default_tree_manipulators:checkAccess']
];
$tree = $menu_link_tree->transform($tree, $manipulators);
如我之前提到的,我们在服务上使用transform()方法,并传递一个可调用对象的数组。后者不过是服务名称,后面跟着:和要使用的方法名称(如上面代码所示)。因此,如果你创建了自己的服务,你可以用同样的方式引用它。
现在,树中剩余的每个MenuLinkTreeElement都具有其access属性填充了AccessResultInterface的一个实例(这是我们将在稍后章节中更多讨论的访问表示系统)。如果链接不可访问,它将变成InaccessibleMenuLink的一个实例,因此我们知道我们无法渲染它,即使我们渲染了它,它也会跳转到主页而不是 403。
现在,为了渲染树形结构,我们只需将这个树形结构转换成一个渲染数组:
$menu = $menu_link_tree->build($tree);
在$menu中,我们现在有一个使用menu主题钩子的渲染数组,其主题钩子建议基于菜单名称。在我们的例子中,它是menu__admin。记得这些是从上一章学到的吗?
menu主题钩子将使用menu.html.twig(或如果它位于主题中,则为menu--admin.html.twig)文件来渲染简单的、尽管是分层结构的 HTML 列表中的菜单链接。
作为对主题章节的快速回顾,到现在为止,你有几个选项可以完全控制菜单的输出:
-
创建一个新的主题钩子并模仿
build()方法来构建渲染数组 -
修改主题注册表以替换模板
-
在主题内部覆盖模板
-
实现主题钩子的预处理器并更改那里的变量
因此,正如你所见,你有许多选项。你的选择取决于你需要实现什么,你对默认标记的满意度如何,等等。
与菜单链接一起工作
现在我们知道了如何加载和操作菜单链接树,让我们再谈谈常规的菜单链接。在本节中,我们将探讨我们的模块如何定义菜单链接,以及一旦我们从树或其他地方获取它们后,我们如何以编程方式与它们一起工作。
定义菜单链接
在我们的 Hello World 模块中,我们定义了一些路由,其中之一映射到 /hello 路径。现在让我们创建一个链接到该路径,该链接位于与 Drupal 核心一起提供的菜单中。
正如我提到的,菜单链接是在 *.links.menu.yml 文件中定义的。所以,让我们为我们的模块创建该文件,并在其中添加我们的菜单链接定义:
hello_world.hello:
title: 'Hello'
description: 'Get your dynamic salutation.'
route_name: hello_world.hello
menu_name: main
weight: 0
在典型的 YAML 表示法中,我们有机器名(在这种情况下,也是插件 ID)hello_world.hello,然后是其下面的相关信息。这些是你为菜单链接定义的最常见内容:
-
title是菜单链接的标题,而description默认设置为结果链接标签的title属性。 -
route_name指示此链接背后的路由。 -
menu_name指示该菜单应位于其中;这是菜单的机器名。 -
weight可以用来对菜单中的链接进行排序。
另一个常见的属性是 parent,它可以用来指示另一个菜单链接,当前链接应该是其子链接。因此,你可以构建层次结构。
一旦设置好,你应该清除缓存并检查菜单中的链接。你会注意到你可以编辑它,但由于它们在代码中定义,因此一些内容无法通过 UI 进行更改。
注意,由插件衍生创建的链接,例如在 UI 中创建的链接,具有以下格式的机器名(插件 ID):
main_plugin_id:plugin_derivative_id
main_plugin_id 是负责派生多个链接的菜单链接插件的 ID,而 plugin_derivative_id 是分配给每个单独的衍生品的 ID。例如,在 MenuLinkContent 实体的情况下,格式如下:
menu_link_content:867c544e-f1f7-43aa-8bf7-22fcb08a4b50
之前代码中的 UUID 实际上是菜单链接内容实体的 UUID,碰巧也是插件衍生 ID。
与菜单链接一起工作
我之前提到 MenuLinkTreeElement 对象封装了单个菜单链接,但如果你选择自己处理这些数据而不是依赖于 menu 主题钩子,你可以对这些对象进行哪些编程操作呢?让我们了解一下你可以做的几个常见操作。
首先,最重要的事情是访问菜单链接插件。你可以直接这样做,因为它是 MenuLinkTreeElement 的公共属性:
$link = $data->link;
现在,你可以使用 $link 变量,它是一个 MenuLinkInterface 的实例,而且通常是一个扩展了 MenuLinkBase 类的 MenuLinkDefault 实例。
因此,如果我们检查该接口,我们可以看到许多方便的方法。其中最常见的是我们之前在定义插件时看到的菜单链接定义的获取器。getUrlObject() 也是一个重要的方法,它将菜单链接的路径转换为我们已经知道如何使用的 Url 对象。如果菜单链接是在 UI 中创建的,它可能没有路由,只有路径,在这种情况下,此方法仍然可以根据该路径构建一个通用的 Url 对象。
如果你有一个不是来自你已经处理过访问的树的菜单链接,你可以在实际使用之前要求 Url 对象检查访问权限:
$access = $url->access()
如果链接没有路由,访问将始终返回 TRUE,因为这表示链接是外部的,或者在任何情况下都无法进行访问检查。我们将在单独的章节中详细讨论访问系统。
定义本地任务
现在,让我们通过回到我们的 Hello World 模块来查看如何定义本地任务链接的示例。在 /hello 页面上,让我们添加两个本地任务——一个用于常规的 /hello 页面,另一个用于可以更改问候语的配置表单。这是一个使用本地任务(标签页)的好例子,因为配置表单严格相关于页面上的内容,并用于对其进行更改。
正如我提到的,本地任务位于 *.links.task.yml 文件中。所以,让我们为我们的模块创建一个包含两个链接的文件:
hello_world.page:
route_name: hello_world.hello
title: 'Hello World'
base_route: hello_world.hello
hello_world.config:
route_name: hello_world.greeting_form
title: 'Configuration'
base_route: hello_world.hello
weight: 100
如常,最上面的行是链接的机器名(插件 ID),我们下面有它们的定义。我们再次有一个 route_name 属性来指定这些链接应该去的路由,一个 title 属性用于链接标题,以及一个 base_route。后者是本地任务应该显示的路由。正如你所看到的,我们的两个链接都将显示在 /hello 页面上。weight 属性可以用来对标签页进行排序。
如果你清除缓存并访问那个页面(作为一个可以访问这两个路由的用户),你将能够看到以下两个标签页:

如果你以匿名用户身份访问,前面提到的原因会导致它们都不会显示。
定义本地操作
我们在 Hello World 模块中没有必要定义一个局部操作链接。所以,我们不妨看看一个真正有意义的例子。如果你导航到admin/content屏幕,你会看到+ Add content按钮。它看起来和我们在用户管理页面上看到的例子完全一样。这是一个针对这个路由的局部操作链接。+样式表示这些链接主要用于添加或创建与当前路由相关的新项目。
这个特定的局部操作链接定义在node模块内部的node.links.action.yml文件中,其外观如下:
node.add_page:
route_name: node.add_page
title: 'Add content'
appears_on:
- system.admin_content
再次,我们有机器名(插件 ID)和定义。希望到现在为止,route_name和title对你来说已经很清晰了。不过,这里有一个新东西,那就是appears_on键,它用来指示这个操作链接应该显示在哪些路由(复数)上。所以,一个关键特性是,一个操作链接可以存在于多个页面上。
定义上下文链接
上下文链接比我们之前看到的链接类型要复杂一些,但对我们来说,没有什么太具挑战性的。让我们看看如何将上下文链接添加到我们的问候组件中,以便用户可以通过上下文链接导航到配置表单。
首先,我们需要创建.links.contextual.yml文件并定义链接:
hello_world.override:
title: 'Override'
route_name: hello_world.greeting_form
group: hello_world
这里没有太多复杂的东西。再次强调,我们有一个title链接和一个route_name。此外,我们还有一个group键,它表示这个链接将属于哪个组名。我们稍后会引用这个键。
接下来,我们需要修改我们的主题钩子模板文件,因为上下文链接是在所有主题钩子中可用的title_suffix变量中打印的,并且被各种模块用来向模板添加杂项数据。上下文模块就是这样一个例子。因此,我们需要打印出这个内容。现在它看起来是这样的:
<div {{ attributes }}>
{{ title_prefix }}
{{ salutation }}
{% if target %}
<span class="salutation--target">{{ target }}</span>
{% endif %}
{{ title_suffix }}
</div>
你会注意到我们包括了title_prefix变量来保持事情的一致性。通常,这些变量会是空的,所以无需担心。
最后,是更复杂的一部分——这部分可能会在未来发生变化,但就目前而言,这是我们必须要走的步骤。
我们的hello_world_salutation主题钩子定义的是单个变量,而不是渲染元素。在这种情况下,在通用预处理器中,上下文模块会查看第一个定义的变量,以检查是否有定义上下文链接。对于使用渲染元素的主题钩子,它会检查那个元素。
这就是上下文链接定义在渲染数组中的样子,也是我们需要为我们的用例添加的内容:
'#contextual_links' => [
'hello_world' => [
'route_parameters' => []
],
]
在这里,我们定义了hello_world组上下文链接应该在这里渲染。我们还指定了一个路由参数数组,在我们的情况下,它是空的。这是因为,通常,上下文链接就是这样的——上下文相关的,意味着它们通常与一个实体或具有 ID 的东西一起工作,并且其路由需要一个参数。因此,这就是我们可以提供的地方,因为我们已经看到,*.links.contextual.yml定义是静态和通用的。
#contextual_links属性实际上是一个渲染元素,它会被另一个渲染元素(contextual_links_placeholder)替换。后者在 HTML 中输出一个简单的文本占位符,它会被 JavaScript 替换为正确的链接。
因此,现在我们知道了如何使用上下文链接,让我们修改我们的 Hello World 问候组件以利用这一点。这就是它现在的样子:
public function getSalutationComponent() {
$render = [
'#theme' => 'hello_world_salutation',
'#salutation' => [
'#contextual_links' => [
'hello_world' => [
'route_parameters' => []
],
]
]
];
$config = $this->configFactory->get('hello_world.custom_salutation');
$salutation = $config->get('salutation');
if ($salutation != "") {
$render['#salutation']['#markup'] = $salutation;
$render['#overridden'] = TRUE;
return $render;
}
$time = new \DateTime();
$render['#target'] = $this->t('world');
if ((int) $time->format('G') >= 00 && (int) $time->format('G') < 12) {
$render['#salutation']['#markup'] = $this->t('Good morning');
return $render;
}
if ((int) $time->format('G') >= 12 && (int) $time->format('G') < 18) {
$render['#salutation']['#markup'] = $this->t('Good afternoon');
return $render;
}
if ((int) $time->format('G') >= 18) {
$render['#salutation']['#markup'] = $this->t('Good evening');
return $render;
}
}
主要变化如下。首先,我们在顶部已经定义了#salutation变量并将其转换为渲染数组。正如你记得的那样,这些是高度可嵌套的。在这个渲染数组中,我们添加了我们的#contextual_links渲染元素。其次,每次我们需要设置下面问候字符串的值时,这次我们在#markup元素中这样做,因为,正如我们在上一章中看到的,我们需要一个属性来定义渲染数组如何渲染。
因此,现在如果你清除缓存并导航到/hello页面,你应该能够悬停在问候上并看到上下文链接图标弹出并包含我们的Override链接。当你点击链接时,你应该会跳转到问候配置表单,并注意 URL 中的destination查询参数:

目标查询参数被 Drupal 用来在用户在页面上提交表单后返回他们之前所在的页面。这是一个值得记住的技巧,因为它是一个非常流行的用户体验技术。
摘要
在这一章中,我们为使用菜单和菜单链接做了很多工作。我们首先对 Drupal 8 中菜单系统的架构进行了概述。我向你扔了很多类和钩子,因为我坚信,最好的学习方法是深入代码。
我们还看到了在 Drupal 8 中存在哪些类型的菜单链接。我们不仅有属于实际菜单的常规链接,还有各种其他实用链接系统,例如本地任务、本地操作和上下文链接。
然后,我们亲自动手,通过一个实际示例来了解如何在树中加载菜单链接,如何操作它们,最后将它们转换为渲染数组。在那之后,我们看了如何定义所有这些类型的菜单链接,以及如果我们需要以编程方式处理它们时如何理解它们。
在下一章中,我们将探讨任何内容管理系统框架最重要的一个方面——在 Drupal 8 中我们可以拥有的不同类型的数据存储以及如何与它们协同工作。
第六章:数据建模和存储
我们在这本书中已经完成了五个章节,但我们还没有涵盖与 CMS 主要目的之一——数据存储相关的话题。好吧,我们在上一章中提到了它,也在第二个示例中看到了配置对象的例子。然而,我们只是触及了可能性的表面。现在是时候深入探讨与如何在 Drupal 8 中存储数据相关的所有内容了。
在本章和下一章中,我们将讨论许多与存储和数据操作相关的内容,并在过程中查看许多示例。然而,本章的重点将更加理论化。有许多内容需要覆盖,因为有许多 API 和概念你需要理解。不过,我们仍将看到许多代码示例,以展示在实践中我们讨论的内容。然而,在下一章中,为了弥补这一点,我们将几乎完全使用代码,并构建一些功能。
然而,更具体地说,本章将分为三个主要逻辑部分(不一定由标题表示)。
首先,我们将讨论你的数据存储选项。我们将讨论状态系统及其键/值存储、tempstore、用户数据、配置,最后是实体——这个大问题。我们将不讨论缓存,因为它将在单独的章节中介绍。我们将看到所有这些选项的示例,并深入了解理解它们如何工作的必要架构细节。
第二,我们将深入探讨 Drupal 8 实体 API,以了解其背后的架构——数据是如何存储的,更重要的是,是如何建模的。在这里,我指的是TypedData系统。
最后,我们将探讨如何操作实体;换句话说,如何与它们一起工作并提取数据——基本上,是日常与实体的工作。这里的一个主要话题当然是查询和加载实体。此外,我们还将涵盖此过程的验证方面。
到本章结束时,你应该能够对 Drupal 8 中的存储有很好的理解,并能够根据你的需求做出选择哪个选项的决定。你会了解不同选项之间的差异以及使用一个而不是另一个的原因。此外,你将对实体 API 有一个很好的理解,这将反过来让你更容易地导航 Drupal 代码并与实体系统集成。最后,可能是 Drupal 开发者最常做的事情之一,你将能够与实体一起工作:执行 CRUD 操作、读取和写入字段值以及更多类似的事情。
那么,让我们开始吧。
不同类型的数据存储
存储和使用数据是任何(网络)应用的关键部分。如果没有某种方式持久化数据,我们就无法构建很多东西。然而,不同的数据用途需要不同的存储和操作系统。在本章的目的上,我将使用“数据”一词来表示几乎任何需要持久化到某个地方的东西,无论持续多长时间。
如果你已经在 Drupal 7 中进行了开发,你已经知道了几种存储数据的方法。我们有过实体(主要是节点实体类型,但也可以定义其他类型);variables 表,这是一个相对简单的键/值存储;以及一个与数据库交互并执行我们想要的任何操作的 API。这导致了许多问题,如 API 之间缺乏一致性,以及过度依赖数据库进行配置存储。
在 Drupal 8 中,引入了各种分层 API 来处理数据存储的常见用例。这些新系统的优势体现在我们很少,如果有的话,甚至需要使用所有存储 API 的母亲——数据库 API。这是因为一切都被抽象成不同的层,帮助我们处理我们需要的绝大多数事情。因此,创建一个自定义表可能不再是存储你的数据的正确做法,尽管在 Drupal 7 中这确实是一种常见的做法。
状态 API
状态 API 是一种键/值数据库存储,是你在 Drupal 8 中存储数据的简单方式之一。其主要目的是允许开发者存储与系统状态(因此得名)相关的信息。由于系统的状态可以有多种解释,可以将此视为与当前环境(Drupal 安装)相关的简单信息,这些信息不是编辑性的(内容)。一个例子是 Cron 上次运行的时间戳或系统设置的任何标志或标记,以跟踪其任务。它与缓存不同,因为它不需要经常清除,并且只有设置它的代码负责更新它。
这个系统的主要特点之一是它并不是为人类交互而设计的。我的意思是,应用程序本身需要利用它。人类的选择是我们在稍后章节中将详细讨论的配置系统。
既然我们已经了解了状态 API,让我们深入技术细节,看看它是由什么构成的以及我们如何使用它。
状态系统围绕着 Drupal\Core\State\StateInterface,它提供了你与之交互所需的所有方法。这个接口由 State 服务实现,我们可以将其注入到你的类中,或者通过 \Drupal::state() 简写方式静态地使用。一旦我们有了这个接口,事情就变得非常简单,因为它会告诉我们确切可以做什么。
我们可以设置一个值:
\Drupal::state()->set('my_unique_key_name', 'value');
或者我们可以获取一个值:
$value = \Drupal::state()->get('my_unique_key_name');
我们还可以一次设置/获取多个值(多么方便!):
\Drupal::state()->setMultiple(['my_unique_key_one' => 'value', 'my_unique_key_two' => 'value']);
$values = \Drupal::state()->getMultiple(['my_unique_key_one', 'my_unique_key_two']);
难道不是很容易吗?我们还可以消除它们:
\Drupal::state()->delete('my_unique_key_name');
\Drupal::state()->deleteMultiple(['my_unique_key_one', 'my_unique_key_two']);
这里有几个需要注意的地方:
-
首先,你选择的键名位于单个命名空间中,因此建议你在它们前面加上你的模块名称——
my_module.my_key。这样你就可以避免冲突。 -
其次,你存储的值也可以比简单的字符串更复杂。你可以存储任何标量值,也可以存储对象,因为它们会自动进行序列化和反序列化。不过,请注意你打算存储的对象。
确保你放入那里的任何类化对象都能正确地进行序列化和反序列化。
到现在为止,你可能想知道这些值最终会去哪里。它们会进入 key_value 表,位于 state 集合的命名空间下。此外,这也很好地过渡到讨论支撑 State API 的底层系统:键/值存储。
注意,State 系统只是底层键/值存储框架的一个实现。如果你查看 State 服务,你会注意到它使用 KeyValueFactoryInterface(默认情况下由 KeyValueDatabaseFactory 实现)。这反过来又创建了一个键/值存储实例(默认情况下是 DatabaseStorage),它实现了与存储进行交互的公共 API。如果你查看数据库中的 key_value 表,你会注意到除了 state 之外还有其他集合。这些是针对各种子系统的特定实现,例如实体 API 和系统模式。猜猜看?你可以轻松编写自己的并根据自己的需求进行定制。然而,State API 被创建的原因是让模块开发者可以使用它。此外,它的有效用途涵盖了像键/值存储这样的需求的大部分。所以,你很可能不需要自己实现。
TempStore
我们接下来要查看的系统是 TempStore(临时存储)。
TempStore 是一个键/值、会话类似的存储系统,用于在多个请求之间保持临时数据。想象一下多步骤表单或具有多个页面的向导,这些都是 tempstore 使用案例的绝佳例子。你甚至可以考虑“工作进度”,即尚未永久保存但保存在 tempstore 中,以便某个用户可以继续工作直到完成。TempStore 的另一个关键特性是条目可以有一个过期日期,届时它们会自动清除。所以用户最好快点。
存储 API 有两种类型:私有和共享。这两种之间的区别在于,第一种中条目严格属于单个用户,而第二种中它们可以在用户之间共享。例如,填写多步骤表单的过程属于单个用户,因此相关的数据必须对他们来说是私有的。然而,该表单也可以对多个用户开放,在这种情况下,数据可以在用户之间共享(相当不常见)或者用来触发一个锁定机制,阻止用户 B 在用户 A 编辑时进行更改(更常见)。所以,有很多选择,但我们很快就会看到一些示例。
首先,让我们看看这个系统中的一些关键参与者。
我们从PrivateTempStore类开始,它提供了处理私有临时存储的 API。它不是一个服务,因为为了使用它,我们必须通过PrivateTempStoreFactory来实例化它。因此,如果我们想使用它,我们必须将其注入到我们的类中。后者有一个get($collection)方法,它接受我们决定的集合名称,并为其创建一个新的PrivateTempStore对象。如果你仔细看,它使用的存储基于KeyValueStoreExpirableInterface,这与 State API 使用的KeyValueStoreInterface非常相似。唯一的区别是前者有一个过期日期,这允许自动删除旧条目。默认情况下,Drupal 8 中使用的存储是DatabaseStorageExpirable,它使用key_value_expire表来存储条目。
到目前为止,SharedTempStore与私有存储非常相似。它是通过SharedTempStoreFactory服务实例化的,默认使用相同的底层数据库存储。主要区别在于key_value_expire表中占用的命名空间,它由user.shared_tempstore.collection_name组成,而不是user.private_tempstore.collection_name。
此外,当请求工厂的SharedTempStore时,我们有传递一个所有者来检索它的选项。否则,它默认为当前用户(登录用户 ID 或匿名会话 ID)。此外,我们与之交互的方式及其目的,与任何其他事物相比,都存在差异。
那么,让我们看看我们如何与私有和共享的临时存储一起工作。
私有 TempStore
下面是我们刚才讨论的简单示例:
/** @var \Drupal\Core\TempStore\PrivateTempStoreFactory $factory */
$factory = \Drupal::service('user.private_tempstore');
$store = $factory->get('my_module.my_collection');
$store->set('my_key', 'my_value');
$value = $store->get('my_key');
首先,我们获取PrivateTempStoreFactory服务,并请求它为我们选择的集合名称提供的存储。总是建议在前面加上你的模块名称以避免冲突。如果另一个模块将它们的集合命名为my_collection,那么这看起来可能不太美观(即使存储是私有的)。
接下来,我们使用非常简单的设置器和获取器来设置值,这与我们使用 State API 的方式相似。
如果你以用户 1(主要管理员用户)的身份运行此代码,你会在key_value_expire数据库表中注意到一个新的条目。集合将是user.private_tempstore.my_module.my_collection,而名称将是1:my_key。这是私有临时存储的核心原则:每个条目名称都以前缀形式包含创建条目时登录用户的 ID。如果你是一个匿名用户,它可能看起来像这样:4W2kLm0ovYlBneHMKPBUPdEM8GEpjQcU3_-B3X6nLh0:my_key,其中那个长字符串是用户的会话 ID。
条目值将比使用状态 API 更复杂。这次它将始终是一个序列化的stdClass对象,它包含我们设置的实际值(这本身可以是任何可以正确序列化的标量值或对象),所有者(用户或会话 ID),以及最后更新时间戳。
最后,我们有expire列,默认情况下,将从条目创建的那一刻起持续一周。这是一个“全局”时间范围,作为参数设置在user.services.yml定义文件中,如果你想要的话,可以在你自己的服务定义文件中更改它。然而,它仍然是全局的。
我们也可以这样删除条目:
$store->delete('my_key');
我们还可以读取关于条目的信息(最后更新日期,所有者):
$metadata = $store->getMetadata('my_key');
这将返回包装条目值的stdClass对象,但不包含实际值。
共享 TempStore
现在我们已经看到了私有临时存储的工作方式,让我们看看共享存储。为了与之交互,我们首先需要使用工厂创建一个新的共享存储:
/** @var \Drupal\Core\TempStore\SharedTempStoreFactory $factory */
$factory = \Drupal::service('user.shared_tempstore');
$store = $factory->get('my_module.my_collection');
然而,与私有临时存储不同,我们可以将用户标识符(ID 或会话 ID)作为get()方法的第二个参数传递,以检索特定所有者的共享存储。如果我们不这样做,它默认为当前用户(登录或匿名)。
然后,我们存储/读取条目的最简单方法与之前相同:
$store->set('my_key', 'my_value');
$value = $store->get('my_key');
现在,如果我们快速跳转到数据库,我们可以看到值列与之前相同,但集合反映了这是一个共享存储,并且键不再以前缀形式包含所有者。这是因为其他用户如果愿意,也应该能够检索条目。并且原始所有者可以通过检查条目的元数据来确定:
$metadata = $store->getMetadata('my_key');
此外,我们可以像删除私有存储一样删除它:
$store->delete('my_key');
好的。然而,我们还能用共享存储做些什么,而其他存储做不到的呢?
首先,我们有两种额外的方式可以设置一个条目。如果我们还没有设置它,我们可以设置它:
$store->setIfNotExists('my_key', 'my_value');
或者,如果它不存在或属于当前用户(即用户拥有它),我们可以设置它:
$store->setIfOwner('my_key', 'my_value');
这两种方法都将返回一个布尔值,指示操作是否成功。本质上,它们很方便用于检查冲突。例如,如果你有一个多个用户都可以编辑的大块配置,你只能在它不存在的情况下创建存储正在进行的工作的条目,或者如果它存在且当前用户拥有它(虚拟地覆盖他们自己的先前工作,这可能是可以接受的)。
然后,你还有getIfOwner()和deleteIfOwner()方法,你可以使用这些方法来确保你只使用或删除属于当前用户的条目。
所有这些麻烦,究竟是为了什么?为什么不直接使用私有存储呢?这是因为,在许多情况下,一个流程只能由一个人同时处理。所以,如果有人开始处理它,你需要知道这一点,以防止其他人同时处理,但更重要的是,你可以允许某些用户在“没有完成就回家”的情况下“踢出”先前的用户从流程中。然后他们可以继续或者清除所有更改。这一切都取决于你的使用场景。
此外,作为一个最后的要点,共享的临时存储也使用与私有存储相同的过期系统。
Tempstore 结论
因此,我们有两种不同但相似的临时存储,你可以用于各种情况。如果你需要存储在多个请求中可供用户使用但对他们来说是私有的会话数据,你可以使用PrivateTempStore。或者,如果这些数据需要同时被多个用户使用,或者相反,防止多个用户同时工作,你可以使用SharedTempStore。
它们两者都有一个易于理解的 API,具有简单的方法,你可以根据自己的需求灵活地创建自己的集合。
UserData
现在,我想简要地谈谈另一个由用户模块提供的特定用户存储选项,称为UserData。
UserData API 的目的在于允许存储与特定用户相关的某些信息片段。其概念与 State API 类似,即存储的信息类型不是应该导出的配置。换句话说,它是特定于当前环境的(但属于特定用户,而不是系统或子系统)。
用户是内容实体,可以具有各种数据类型的字段。这些字段通常用于与用户相关的结构化信息,例如,一个名字和一个姓氏。然而,如果你需要存储更不规则的东西,比如用户偏好或标记一个特定用户已经做了某事,UserData 是一个很好的地方来存储这些信息。这是因为信息要么不是结构化的,要么不是打算由用户自己管理的。那么,让我们看看它是如何工作的。
UserData API 由两部分组成——UserDataInterface,其中包含我们可以用来与之交互的方法(以及开发者文档),以及UserData服务,它实现了它并且可以被客户端代码(我们)使用:
/** @var \Drupal\user\UsedDataInterface $userData */
$userData = \Drupal::service('user.data');
现在,我们已经准备好在界面上使用这三个方法:
-
get() -
set() -
delete()
所有这些方法的第一个三个参数都是相同的:
-
$module: 在我们模块名称的特定命名空间中存储数据,从而防止冲突 -
$uid: 将数据与特定用户关联——不一定是当前用户 -
$name: 正在存储的条目名称
自然地,set() 方法也有 $value 参数,这是要存储的数据,它可以是一个标量值或可序列化的对象。
这些参数共同构成了一个非常灵活的存储系统,与 Drupal 7 选项相比有了很大的改进。对于单个模块,我们可以为特定用户存储多个条目,而且这还远不止于此。由于这是可能的,许多这些参数都是可选的。例如,我们可以一次性获取特定模块的所有条目,或者一次性获取特定模块和用户组合的所有条目。删除条目也是如此。但所有这些数据都去哪里了呢?
用户模块定义了 users_data 数据库表,其列基本上映射到这些方法的参数。额外的 serialized 列用于指示存储的数据是否已序列化。此外,在这个表中,可以为特定用户存在多个记录。
关于 UserData API 就这么多要说的。明智地使用它。现在,是时候转向配置 API 了,它是 Drupal 8 中最大的子系统之一。
配置
配置 API 是 Drupal 8 开发者需要理解的最重要的话题之一。它有许多方面与其他子系统相关联,因此能够正确地使用和理解它是至关重要的。
在这一小节中,我们将详细介绍配置系统。我们首先了解什么是配置以及它通常用于什么。然后,我们将探讨在 Drupal 8 中管理配置的不同选项,无论是作为网站构建者还是使用 Drush 命令的开发者。接下来,我们将讨论配置是如何存储的、它属于哪里以及如何在系统中定义。我们还将介绍一些可以在不同级别覆盖配置的方法。最后,我们将探讨如何以编程方式与简单的配置进行交互。那么,让我们从简介开始。
简介
配置是应用程序正常运行所依赖的数据。它是那些描述事物应该如何行为的信息片段,有助于控制代码的执行。换句话说,它配置系统以特定方式运行,同时期望它也可以以不同的方式运行。为此,配置可以简单到只是一个开关(打开或关闭某个功能),也可以复杂到包含数百个参数,描述整个流程。
Drupal 8 的配置系统在 Drupal 世界中可以说是一场革命。这不仅仅是一个改进——这是一种全新的思考管理配置的方式。在此之前,几乎可以说没有配置管理可言。所有内容都以一种使得无法正确和一致地部署 Drupal 所知的多项配置选项的方式存储在数据库中。是的,有 Features 模块和 Ctools 可导出功能,但它们的存在恰恰凸显了缺乏一致性,这也给许多 Drupal 开发者带来了不少麻烦。
在 Drupal 8 中,整个系统已经被彻底改造成一个定义明确且一致的子系统,任何需要配置的小事都可以依赖它。我绝不敢称它完美;它仍然有其不足之处,并且正在进行改进,以使其更好,并创建处理特定配置流程的工具。然而,它已经使得管理和部署配置变得容易得多。
配置有什么用?
在 Drupal 8 中,配置用于存储需要在不同环境之间同步的所有内容(例如,从开发到生产)。因此,它与迄今为止我们所看到的其他类型的数据存储不同,它们是特定于一个环境的。
另一种看待配置的方式是通过考察传统网站构建者的角色。他们通常会导航用户界面并配置网站以特定方式运行——在主页上显示这个标题,使用这个标志,在主页上显示这种类型的内容,等等。正如我们提到的,他们交互的结果会转化为网站构建者期望能够轻松迁移到验收环境的配置,最终到达生产环境。
一些配置实际上对应用程序的正确运行至关重要。某些代码在没有使用其值的参数的情况下可能会出错。例如,如果没有设置全局电子邮件地址,系统将使用什么电子邮件发送自动邮件给用户?因此,许多这些配置参数都带有合理的默认值(在安装时)。然而,这也表明配置是应用程序的一部分,与实际代码一样重要。
管理配置
正如我们很快就会看到的,Drupal 为了性能原因将配置数据存储在数据库中,但它使所有数据都可以导出到 YAML 文件。因此,管理它的典型流程将包括您在 UI 中进行更改,导出配置,将其添加到 Git 中,并将代码部署到下一个环境。在那里,只需导入代码中的内容即可。
导入、导出和同步可以通过 Drush 和 UI 中的 admin/config/development/configuration 进行:

典型的流程是将活动网站配置与 YAML 文件中的配置同步。这意味着将所有与数据库中配置不同的 YAML 文件中的配置导入到数据库中。这些 YAML 文件位于配置 sync 文件夹中,应提交到 Git(您可以在 settings.php 文件中配置哪个目录应该是 sync 文件夹),相反,是将活动配置导出到 YAML 文件中,以便将其提交到代码中。
UI 只允许第一个选项(将 YAML 文件中的内容与数据库同步),但它提供了一个很好的 Diff 界面,以查看 YAML 与数据库相比有什么不同:

在此屏幕截图中,我们可以看到 YAML 文件包含站点名称配置的一小部分更改。点击“导入全部”将使数据库与 YAML 文件保持一致。
第一次安装 Drupal 8 网站时,配置 sync 文件夹将是空的。您需要手动导出所有活动配置并将其放置在那里。您可以通过 UI 手动导出或通过 Drush 来完成此操作:
drush config-export
每当您通过 UI 进行配置更改并希望将其导出到 YAML 文件时,您都会执行此步骤。
然后,您可以在 UI 中同步,就像我们看到的,或者通过以下命令通过 Drush 进行同步:
drush config-import
作为 Drupal 开发者,您将主要使用这两个 Drush 命令。
除了整个配置项集之外,您还可以通过复制粘贴来导入/导出单个配置项。但请注意,某些依赖项可能不允许您这样做。然而,如果您想快速在其他环境中看到某些内容生效,这很有用,但如果滥用这种方法,它不会为基于版本控制的流程提供良好的解决方案。
不同的配置类型
Drupal 8 提供两种不同的配置类型——简单配置和配置实体。让我们看看它们之间的区别。
简单配置是存储基本数据的一种类型,通常由整数或字符串等标量值表示。另一方面,配置实体更复杂,并使用与内容实体相同的 CRUD API。
通常,简单的配置项是独一无二的。例如,一个模块可以创建和管理一个配置项,该配置项可以启用或禁用其功能之一。很可能,这个模块需要这个配置来知道它应该对这个功能做什么。然而,即使它不需要,它仍然是一个与该功能相关的单一项目。
配置实体,另一方面,是同一配置类型的多个实例。例如,视图是一个配置实体,一个特定的站点可以有无限数量的视图。它甚至可以没有视图。当我们讨论一般实体时,我们将更详细地讨论配置实体。
配置存储
配置基本上存储在两个地方:
-
默认情况下,活动存储(在数据库中)
-
同步存储(默认为 YAML 文件)
这里是一个简单的配置 YAML 文件的示例:
my_string: 'Hello!'
my_int: 10
my_boolean: true
my_array:
my_deep_string: 'Yes, hello!'
这个文件的名称是由你需要与配置 API 一起使用的 ID 给出的。
除了实际数据之外,你可以在dependencies键下列出这个配置项所依赖的内容:
dependencies:
module:
- views
theme:
- bootstrap
config:
- system.site
有三种类型的依赖项:模块、主题和其他配置项。
如果你记得在第二章,创建您的第一个模块,我们创建了一个具有hello_world.custom_salutation ID 的配置对象,并在其中存储了一个简单的值:
salutation: 'Whatever the user set in the form'
我们通过表单程序化地这样做,并没有提供 YAML 文件。这意味着我们用于显示问候语的代码不依赖于这个配置项的存在或具有某种类型的值。如果我们的代码必须工作,我们可以在模块安装时创建它。有两种方法可以实现这一点。
最常见的方式是静态的。在一个模块的config/install文件夹中,我们可以有在模块安装时被导入的 YAML 配置文件。然而,如果我们需要设置的配置值是未知的(它们需要动态检索),我们可以在hook_install()实现中这样做(还记得第三章,日志和邮件?)。在那里,我们可以尝试获取我们的值并创建包含它的配置对象。
注意,如果模块的config/install文件夹中的配置存在未满足的依赖项,即它们所依赖的任何内容在系统中不存在,那么在安装模块时,这些配置将不会被导入;也就是说,模块本身将无法安装。
作为额外的好处,您还可以提供配置文件与模块一起,只有当它们的依赖项满足时才导入。换句话说,这是可选的配置。如果这些配置的依赖项未满足,模块将正确安装,但不会包含这些配置。此外,如果后来依赖项得到满足,这些可选配置也会自动导入。然而,请记住,可选配置仅限于配置实体,因为它与简单配置无关。
模式
为了让各种系统能够正确地与配置项交互,引入了配置模式。模式是一种定义配置项并指定它们存储的数据类型的方式,无论是字符串、布尔值、整数等等。当然,它们以 YAML 格式表示,并位于模块的 config/schema 文件夹中。
配置需要模式定义有三个主要原因:
-
多语言支持:正如我们稍后将要看到的,Drupal 8 中的配置是可翻译的。然而,为了知道配置的哪些部分需要或可以翻译,引入了模式系统以提供这一额外的层。这样,与贡献模块一起提供的配置项可以在 localize.drupal.org 网站上获得自己的翻译。此外,模式识别哪些配置位可以翻译,这使用户能够在用户界面中提供翻译。
-
配置实体:配置实体需要模式定义,以便在持久化层中正确识别需要与其一起导出的数据类型。此外,模式也用于验证配置实体。
-
类型转换:配置模式确保配置 API 能够始终正确地将值转换为它们正确的数据类型。
让我们看看 Drupal 核心提供的配置示例,以了解模式是如何工作的,即由 System 模块提供的 system.mail 配置。记住在 第三章,日志和邮件,我们讨论了如何控制用于发送电子邮件的邮件插件?默认情况下,它看起来是这样的:
interface:
default: 'php_mail'
它是一个非常简单的多维数组。因此,如果我们现在查看 system.schema.yml 文件中的模式定义,我们将找到与系统模块一起提供的所有配置项的定义。顶级行代表配置项的名称,因此如果我们向下滚动,我们将找到 system.mail:
system.mail:
type: config_object
label: 'Mail system'
mapping:
interface:
type: sequence
label: 'Interfaces'
sequence:
type: string
label: 'Interface'
如果我们忽略模式比实际配置大五倍这一讽刺之处,我们可以很好地理解这个配置项的实质。更重要的是,Drupal 本身也能做到这一点。
我们可以看到 system.mail 配置是 config_object 类型。这是配置的两种主要类型之一,另一种是 config_entity。label 键用于表示此项目的可读名称,而 mapping 键包含其单个元素的定义。我们可以看到 interface 有标签“接口”和类型 sequence。后者是一个特定的类型,表示一个数组,其中键不重要。每当我们要考虑键时,我们将使用 mapping(如在此架构定义的最高级别所做的那样)。由于我们正在查看 sequence 类型,因此它内部的单个项目也被定义为具有自己标签的字符串类型。
现在我们为之前看到的示例配置文件编写我们自己的架构定义:
my_string: 'Hello!'
my_int: 10
my_boolean: true
my_array:
my_deep_text: 'Yes, hello, is anybody there?!'
如果这个配置位于名为 my_module.settings.yml 的文件中,这将是对应的架构定义:
my_module.settings:
type: config_object
label: 'Module settings'
mapping:
my_string:
type: string
label: 'My string that can also be of type text if it was longer'
my_boolean:
type: boolean
label: 'My boolean'
my_array:
type: mapping
label: 'My array in which the keys are also important, hence not a sequence'
mapping:
my_deep_text:
type: text
label: 'My hello string'
作为额外信息,任何 config_object 类型的配置都继承了以下属性:
langcode:
type: string
label: 'Language code'
这有助于多语言系统,并邀请我们为每个配置项添加一个 langcode 属性。
我们迄今为止看到的大多数属性都是 type,label,mapping 和 sequence。还有两个你应该注意的:
-
translatable:非常重要,因为它表示一个类型是否可以被翻译。默认情况下,text和label类型已经设置为可翻译,所以你不需要自己进行设置。 -
nullable:表示值是否可以留空。如果缺失,则被视为必需。
这里有一些你可以用来定义配置的类型:
-
标量类型:
string,integer,boolean,email,float,uri,path -
列表:
mapping,sequence -
复杂(扩展标量类型):
label,path,text,date_format等。
确保你查看 core.data_types.schema.yml 文件,其中定义了所有这些内容。
在我们继续之前,让我们确保我们为我们在第二章,“创建你的第一个模块”中程序化创建的配置项创建配置架构,即存储覆盖问候信息的那个。因此,在 Hello World 模块的 /config/schema 文件夹中,我们可以有 hello_world.schema.yml 文件,内容如下:
hello_world.custom_salutation:
type: config_object
label: 'Salutation settings'
mapping:
salutation:
type: string
label: 'The salutation message'
这解决了我们之前在不知道配置架构的情况下引入的一些技术债务。
覆盖
我们看到配置存在于配置文件中,但实际上应该属于组织良好且描述清晰的 YAML 文件中。为了使 YAML 文件中的配置能够使用,它们需要被导入——无论是通过同步还是对于由模块提供的配置,在模块安装时进行导入。因此,这意味着数据库仍然保留着活动的配置。
为了使事情更加动态,配置 API 还提供了一个覆盖系统,我们可以通过这个系统在各个级别上动态覆盖活动配置。在 Drupal 7 中,这是通过全局$conf变量完成的,但这也是不幸地将覆盖泄露到实际配置池中的方法。在 Drupal 8 中,情况不再是这样,我们还可以在三个不同的层级上覆盖配置(全局、模块和语言覆盖)。
配置 API 会考虑到这些覆盖,以防止它们意外地泄露到活动配置中。当我们讨论如何与配置 API 交互时,我们将看到示例。
全局覆盖
在 Drupal 8 中,我们仍然可以通过全局变量来获得这种可能性,这次称为$config。这个变量在settings.php文件中可用于网站范围的覆盖,但你也可以在模块内部使用它(如果你真的需要的话!)以覆盖特定的配置:
global $config;
$config['system.maintenance']['message'] = 'Our own message for the site maintenance mode';
在这个例子中,我们动态地更改了用于网站维护模式的消息。你为什么要这样做并不重要,但你可能有一些其他配置可以从这种覆盖中受益。无论如何,你注意到了我们使用的数组表示法。第一个键是配置项的名称(文件名减去.yml扩展名),然后是我们配置文件中个别元素的键。如果它是嵌套的,我们将进一步遍历。
全局配置覆盖是一个很好的地方,你可以使用特定环境或敏感数据,例如 API 密钥。这类信息绝不应该导出到同步存储中。相反,你可以在模块中定义一个配置对象,并在没有值的情况下安装它。然后,使用全局覆盖,你可以提供针对相关环境的特定值。
模块覆盖
虽然你可以简单地使用全局$config数组,但这并不是模块应该修改的地方。首先,因为它是一个全局变量,改变全局变量从来不是一个好主意,它应该留给settings.php文件。其次,因为没有办法控制多个模块以相同方式修改它的优先级。相反,我们有模块覆盖系统可以使用。
通过模块覆盖,我们可以创建一个带有config.factory.override标签的服务(记得标签化服务是什么吗?)在这个服务中处理我们的覆盖。为了举例,让我们使用这个系统来覆盖维护模式消息。在我们的 Hello World 模块中,我们可以有以下服务类:
namespace Drupal\hello_world;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryOverrideInterface;
use Drupal\Core\Config\StorageInterface;
/**
* Overrides configuration for the Hello World module.
*/
class HelloWorldConfigOverrides implements ConfigFactoryOverrideInterface {
/**
* {@inheritdoc}
*/
public function loadOverrides($names) {
$overrides = [];
if (in_array('system.maintenance', $names)) {
$overrides['system.maintenance'] = ['message' => 'Our own message for the site maintenance mode.'];
}
return $overrides;
}
/**
* {@inheritdoc}
*/
public function getCacheSuffix() {
return 'HelloWorldConfigOverrider';
}
/**
* {@inheritdoc}
*/
public function createConfigObject($name, $collection = StorageInterface::DEFAULT_COLLECTION) {
return NULL;
}
/**
* {@inheritdoc}
*/
public function getCacheableMetadata($name) {
return new CacheableMetadata();
}
}
在这里,我们必须实现ConfigFactoryOverrideInterface接口,该接口包含四个方法:
-
在
loadOverrides()中,我们提供我们的覆盖配置值。 -
在
getCacheSuffix()中,我们返回一个简单的字符串,用于我们覆盖的静态缓存标识符。 -
在
createConfigObject()方法中,我们实际上并没有做任何事情,但我们可以创建一个配置 API 对象,该对象将在安装或同步过程中使用。 -
在
getCacheableMetadata()方法中,我们返回与我们的覆盖相关的任何缓存元数据。我们没有这样的元数据,所以我们返回一个空对象。
由于这是一个服务,我们可以注入依赖项并在需要计算覆盖时使用它们。根据这个计算,设置一些适当的缓存元数据可能变得很重要,但我们将另章介绍缓存。
接下来,我们将此注册为带标签的服务:
hello_world.config_overrider:
class: \Drupal\hello_world\HelloWorldConfigOverrides
tags:
- {name: config.factory.override, priority: 5}
我们将优先级设置为 5,这样我们就可以控制模块覆盖配置的顺序。优先级高的将优先于优先级低的。
就这些了。清除缓存将注册此服务并更改我们的配置。如果你现在将站点置于维护模式,你会注意到显示的消息就是我们在这里设置的。然而,如果你转到 admin/config/development/maintenance 的维护模式管理页面,你仍然会看到原始消息。这是为了防止管理员意外地将覆盖值保存到配置存储中。
语言覆盖
尽管我们还会进一步讨论 Drupal 8 的多语言特性,但让我们简要地提一下语言覆盖的可能性。
如果我们启用配置翻译并添加更多语言到我们的站点,我们可以翻译可翻译的配置项(如它们的模式所描述)。在这样做的时候,我们正在覆盖特定语言的默认配置,这个覆盖将被存储在配置存储中,并可以导出为 YAML 文件。所以这是一种可导出的覆盖类型。
即使不在特定的语言环境中,我们也可以编程式地使用这个覆盖。以下是一个示例代码,假设我们有一个针对维护模式消息的法语覆盖,并且我们想使用它:
$language_manager = \Drupal::service('language_manager');
$language = $language_manager->getLanguage('fr');
$original_language = $language_manager->getConfigOverrideLanguage();
$language_manager->setConfigOverrideLanguage($language);
$config = \Drupal::config('system.maintenance');
$message = $config->get('message');
$language_manager->setConfigOverrideLanguage($original_language);
这看起来有点复杂,但实际上并不复杂。首先,我们加载语言管理器服务并获取我们语言的 Language 对象(我们想要获取覆盖值的语言)。然后,我们跟踪原始配置覆盖语言(这基本上是当前语言),但同时也将法语设置为后续要使用的语言。最后,我们加载 system.maintenance 配置对象,在语言管理器上恢复原始语言之前,读取其法语消息。这是一种快速展示我们可以通过临时切换语言环境来实现配置覆盖的方法。而且这将是我们以不同于当前语言的方式加载配置实体的方式。
优先级
我们有三个配置覆盖层:全局、模块和语言。这实际上是它们实际优先级的顺序。全局覆盖优先于其他所有内容,而模块覆盖优先于语言覆盖。这就是为什么如果我们已经在模块中覆盖了system.maintenance配置,我们无法在我们的代码中使用语言覆盖。所以,请记住这一点。
与简单配置交互
既然我们已经讨论了 Drupal 8 配置 API 是什么,它用于什么,如何管理和存储它,以及一些覆盖它的选项,现在是时候讨论 API 本身以及我们如何与之交互了。在本节中,我们将仅关注简单配置,因为我们将在介绍所有实体时更多地讨论配置实体。
在第二章“创建您的第一个模块”中,我们已经通过SalutationConfigurationForm对配置 API 有所了解,我们在其中存储和读取了一个简单的配置值。现在是时候深入理解 API 并查看一些更多示例,了解我们如何使用它。
表示简单配置的类是Drupal\Core\Config,它围绕单个配置项中找到的数据进行包装。此外,它还处理了之前提到的所有必要的与底层存储系统交互操作,以便持久化配置(默认情况下存储到数据库中)。此外,它自动处理我们之前讨论的覆盖。
我们经常与之打交道的Config的一个重要子类是ImmutableConfig。它的目的是防止对配置对象进行更改,因此它适用于只读用途。
我们获取这些类实例的方式是通过ConfigFactory服务,该服务有两个方便的方法用于获取配置对象:
/** @var \Drupal\Core\Config\ConfigFactoryInterface $factory */
$factory = \Drupal::service('config.factory');
$read_only_config = $factory->get('hello_world.custom_salutation');
$read_and_write_config = $factory->getEditable('hello_world.custom_salutation');
get()方法返回一个只读的ImmutableConfig对象,而getEditable()方法返回一个Config对象,也可以用于更改配置值。我们通过set()和save()方法来完成这项操作:
$read_and_write_config->set('salutation', 'Another salutation');
$read_and_write_config->save();
非常简单。我们还有setData()方法,它允许我们一次性更改配置项的全部数据。作为参数,它期望一个值关联数组。
提示:如果您无法注入ConfigFactory但必须依赖静态调用,Drupal类有一个直接加载配置对象的快捷方式:$config = \Drupal::config('system.maintenance');。config()方法接受配置名称作为参数,并返回一个ImmutableConfig对象。
读取数据时,我们有多种选择。我们可以从配置中读取一个元素:
$value = $read_and_write_config->get('salutation');
如果元素是嵌套的,我们可以通过点(.)表示法向下遍历:
$config = $factory->get('system.site');
$value = $config->get('page.403');
这将返回为 system.site 配置中的 403 页面设置的值。我们也可以通过不向 get() 方法传递任何参数来获取所有值,这将返回一个关联数组。
如果你还记得我们关于配置覆盖的讨论,默认情况下,get() 方法将返回通过模块或全局(或如果语言管理器为配置设置了不同的语言)覆盖的值。然而,如果我们想,我们也可以检索原始值:
$config = $factory->get('system.maintenance');
$value = $config->getOriginal('message', FALSE);
getOriginal() 方法的第二个参数表示是否应用覆盖,默认情况下它是 TRUE。因此,这样我们就能获取到在活动存储中设置的配置值。
最后,我们还可以清除配置值或整个对象本身。例如,考虑以下代码:
$config->clear('message')->save();
它将移除配置对象中的 message 键并保存不带该值的配置。或者,我们也可以完全移除它:
$config->delete();
大概就是这样。这个 API 的强大之处也源于其简单性。
实体
我们终于到了讨论 Drupal 8 中最复杂、最稳健、最强大的数据建模和内容建模系统的点——实体 API。
实体自 Drupal 7 以来就存在了,当时它包含了一些类型,如节点、分类术语、用户、评论、文件等。然而,Drupal 核心只为定义实体和一致地加载它们提供了基本的 API。实体 API 贡献模块填补了很大的差距,并为实体提供了很多功能,使实体变得更加强大。但在 Drupal 8 中,这些原则(以及更多)作为稳健的数据建模系统的一部分包含在核心中。
实体 API 无缝集成到多语言系统中,以提供完全可翻译的内容和配置实体。这意味着你存储的大多数数据都可以轻松地翻译成多种语言。在 Drupal 7 中,这始终是一项艰巨的任务,需要超过 10 个贡献模块才能实现现在所拥有的功能。
因为关于实体有很多要讲的内容,所以在本节中,我们将从对实体系统的概述开始。但不用担心,在下一节,以及本章的结尾,我们将将其分解并讨论所有重要的方面。
内容与配置实体类型
让我们先确立一些基本术语,以避免后续的混淆:
-
实体是给定实体类型的实例。因此,我们可以有一个或多个某种类型的实体,后者是单个实体的蓝图。
-
实体类型可以分为两种:内容和配置。
在上一节中,我们简要讨论了配置实体。在那里,我们看到它们是某种特定类型配置的多个实例,而不是简单的配置,后者只有一组配置值。本质上,配置实体是可导出的配置值集合,它们继承了与内容实体相同的大量处理 API。
一些配置实体类型的示例:
-
视图:构成视图的一组配置值
-
图片样式:定义了在给定样式下图像需要如何被操作
-
角色:定义可以分配给用户的角色
内容实体,另一方面,是不可导出的,并且是我们可以在 Drupal 8 中建模和持久化数据的最重要方式。这些可以用于内容以及所有其他在您的业务逻辑中需要持久化但不需要部署到其他环境中的结构化数据。
一些内容实体类型的示例:
-
节点
-
评论
-
用户
-
分类术语
除了可导出方面,内容和配置实体之间的主要区别在于它们使用的字段类型。后者使用更简单的字段,这些字段的组合存储为数据库中的一个实体“记录”(并导出到 YAML)。内容实体字段在代码建模和持久化层(数据库)中都是复杂和结构化的。
此外,配置实体还缺少捆绑。捆绑是实体分类的另一种形式,位于内容实体类型之下。这意味着每个内容实体类型都可以有(但不一定有)一个或多个捆绑,可配置字段可以附加到这些捆绑上。而且为了避免给您带来更多困惑,捆绑实际上是配置实体本身,因为它们需要被导出,并且可以有多个。
实体 API 在存储数据类型方面非常灵活。内容实体类型包含多种不同的字段类型,用于各种形式的数据,从原始值到更复杂的数据,如日期或引用。
内容实体也可以被设置为可修订的。这意味着内容实体类型可以被配置为存储与变更过程相关的额外元数据的同一实体的旧版本。
在本节以及接下来的内容中,我将通过举例说明两种实体类型来展示实体的最常见功能:
-
节点:Drupal 核心中最丰富的内容实体类型,通常用作主要的内容建模实体类型
-
节点类型:定义节点捆绑的配置实体类型
在下一章中,我们将学习如何创建自己的。但有了这里的一切,那将是一件轻而易举的事情。
实体类型插件
实体类型以插件的形式注册到 Drupal 中。是的,又是这样。Drupal\Core\Entity\Annotation\EntityType 类是这些插件的基础注解类,你将主要看到两个子类(注解):ContentEntityType 和 ConfigEntityType。这些用于分别注册内容和配置实体类型。
注解类映射到用于表示实体类型的插件类。这些插件的基础类是 Drupal\Core\Entity\EntityType,然后由另一个 ContentEntityType 和 ConfigEntityType 扩展。这些插件类用于在系统中表示实体类型,并且是查看我们可以在这些插件的注解中使用哪些数据的良好资源。快速浏览一下,我们就可以看到这两种类型之间的差异并不大。
实体类型的插件管理器是 EntityTypeManager,这是一个作为 Drupal 开发者你可能会与之交互最多的重要服务。除了我们稍后会看到的各种实用功能外,它还负责使用基于常规注解的发现方法管理实体类型插件。
节点实体类型定义在 Drupal\node\Entity\Node 中,在那里你会在类的顶部看到一个巨大的注解。另一方面,节点类型配置实体类型位于 Drupal\node\Entity\NodeType 中。你可以看到它们使用的注解之间的差异。
标识符
实体类型注解以一些关于它们的基本信息开始:ID、标签等。例如,考虑节点实体:
* id = "node",
* label = @Translation("Content"),
* label_singular = @Translation("content item"),
* label_plural = @Translation("content items"),
* label_count = @PluralTranslation(
* singular = "@count content item",
* plural = "@count content items"
* ),
这些在系统中的多个地方被用来通过机器和可读名称正确地引用实体类型。
束
节点实体类型恰好有束,这也是为什么我们还有一个 bundle_label 属性的原因:
bundle_label = @Translation("Content type"),
我们可以通过它引用定义束配置实体类型的插件 ID 来推断出节点有束。
bundle_entity_type = "node_type",
哇,这就是节点类型的 ConfigEntityType 插件 ID。在其插件注解中,我们可以找到反向的 bundle_of 属性,它引用了节点实体类型。不用说,这并不是所有配置实体类型的强制要求,但用于那些作为内容实体束的实体类型。例如,View 配置实体类型就没有这个属性。
此外,我们还在节点插件注解中找到了配置束的路径:
field_ui_base_route = "entity.node_type.edit_form",
这是一个为节点类型配置实体定义的路径。
如我之前提到的,束对于配置实体来说是不存在的。
数据库表
对于内容实体来说,另一个重要的信息是它们将用于存储的数据库表名:
base_table = "node",
data_table = "node_field_data",
在这个例子中,node 表存储了关于实体(如 ID、uuid 或 bundle)的原始信息,而node_field_data 表则存储了单一且不可翻译的字段数据。否则,这些字段会自动拥有自己的数据库表。我将在稍后解释字段数据是如何存储的。
实体键
实体 API 定义了一套键,这些键在所有实体类型中都是一致的,并且可以通过这些键检索常见的实体信息。由于并非所有实体类型都需要存储这些数据的相同字段,因此可以在注释中进行映射:
* entity_keys = {
* "id" = "nid",
* "revision" = "vid",
* "bundle" = "type",
* "label" = "title",
* "langcode" = "langcode",
* "uuid" = "uuid",
* "status" = "status",
* "published" = "status",
* "uid" = "uid",
* "owner" = "uid",
* },
节点实体类型有一个相对全面的实体键示例。正如你所见,节点的唯一标识字段一直是nid。然而,系统内实体的通用标识符是id。因此,这里的映射有助于简化这个过程。
链接
每个实体类型都需要一系列系统需要了解的链接。例如,规范 URL、编辑 URL、创建 URL 等。对于节点实体,我们有以下链接:
* links = {
* "canonical" = "/node/{node}",
* "delete-form" = "/node/{node}/delete",
* "delete-multiple-form" = "/admin/content/node/delete",
* "edit-form" = "/node/{node}/edit",
* "version-history" = "/node/{node}/revisions",
* "revision" = "/node/{node}/revisions/{node_revision}/view",
* "create" = "/node",
* }
与实体键一样,这些链接在所有实体类型中都是通用的(取决于它们启用的功能)。例如,所有实体类型都有一个规范 URL,API 允许根据定义快速找到它。
关于这些路径有一点需要注意,它们需要定义为路由。因此,你可以在node.routing.yml文件中找到它们(在那里你也可以找到由 NodeType 配置实体类型使用的路由)。不过,这些路由也可以动态定义,以防止重复。这可以通过使用路由提供者处理器来实现。我们将在稍后讨论处理器,并在下一章中看到一个具体的示例。如果你想知道节点链接缺失的路由在哪里,请查看注册它们的NodeRouteProvider。
实体翻译
实体在全局范围内都是可翻译的——就像 Drupal 8 中的大多数其他内容一样。为了标记一个实体类型为可翻译,我们只需要在插件注释中包含以下内容:
translatable = TRUE,
这使得实体类型能够利用所有多语言功能。然而,正如我们稍后将看到的,各个字段也需要声明为可翻译。
实体修订
在 Drupal 8 中,所有内容实体类型都可以通过最小努力实现可修订(并可发布)。由于节点是一个很好的例子,我们可以检查其构建方式以更好地理解这一点。
首先,注释需要包含存储修订的数据库表信息。这正好与之前看到的原始表相对应:
revision_table = "node_revision",
revision_data_table = "node_field_revision",
其次,注释需要包含我们之前看到的修订 ID 和发布状态的实体键:
* entity_keys = {
* "revision" = "vid",
* "published" = "status",
* },
第三,在注释中还需要引用修订元数据键:
* revision_metadata_keys = {
* "revision_user" = "revision_uid",
* "revision_created" = "revision_timestamp",
* "revision_log_message" = "revision_log"
* },
这些映射到修订表中的表列。为了确保创建所有必要的列,实体类型类应该扩展 EditorialContentEntityBase,它提供了为此所需的字段定义。但也要知道,这个基类已经实现了 EntityPublishedInterface,这使得实体类型可发布。
最后,实体字段本身不是自动可修订的,因此也需要在它们上设置一个标志。我们将在讨论字段时再次看到这一点。
配置导出
配置实体类型在其插件定义中有一些额外的选项,这些选项与实体的可导出性相关。默认情况下,许多配置实体字段都会被持久化和导出。然而,需要使用 config_export 属性来声明应包含在导出中的其他字段。例如,节点类型配置实体类型定义了以下内容:
* config_export = {
* "name",
* "type",
* "description",
* "help",
* "new_revision",
* "preview_mode",
* "display_submitted",
* }
请记住,如果没有这个定义,配置模式将用作后备来确定哪些字段需要持久化。如果配置实体类型没有模式(尽管它应该有),则不会持久化任何额外字段。
此外,配置实体类型有一个用于配置系统中命名空间的名称前缀。这也在插件注解中定义:
config_prefix = "type",
处理器
在实体类型插件注解中找到的最后一个主要设置组是处理器。处理器是实体 API 用来管理与实体相关的各种任务的对象。节点实体类型是一个很好的例子,因为它定义了相当多的处理器,给我们提供了一个学习的机会:
* handlers = {
* "storage" = "Drupal\node\NodeStorage",
* "storage_schema" = "Drupal\node\NodeStorageSchema",
* "view_builder" = "Drupal\node\NodeViewBuilder",
* "access" = "Drupal\node\NodeAccessControlHandler",
* "views_data" = "Drupal\node\NodeViewsData",
* "form" = {
* "default" = "Drupal\node\NodeForm",
* "delete" = "Drupal\node\Form\NodeDeleteForm",
* "edit" = "Drupal\node\NodeForm",
* "delete-multiple-confirm" = "Drupal\node\Form\DeleteMultiple"
* },
* "route_provider" = {
* "html" = "Drupal\node\Entity\NodeRouteProvider",
* },
* "list_builder" = "Drupal\node\NodeListBuilder",
* "translation" = "Drupal\node\NodeTranslationHandler"
* },
如我们立即可以注意到的,这些都是对类的简单引用。所以,当有疑问时,总是一个好的主意去查看它们做什么以及它们是如何工作的。但让我们简要地谈谈所有这些,看看它们的主要责任是什么。
-
storage处理器是最重要的之一。它处理所有与 CRUD 操作和与底层存储系统交互相关的事情。它始终是EntityStorageInterface的一个实现,有时是ContentEntityStorageBase或ConfigEntityStorage类的父类。如果实体类型没有声明一个,它将默认使用SqlContentEntityStorage(因为我们大多数时候使用 SQL 数据库)或ConfigEntityStorage用于配置实体。 -
storage_schema处理器不是你将过多接触的东西。它的目的是处理存储处理器的模式准备。如果没有提供,它将默认使用SqlContentEntityStorageSchema,并负责为实体类型定义所需的数据库表。 -
view_builder处理器是一个EntityViewBuilderInterface实现,负责从实体创建一个渲染数组,目的是为显示做准备。如果没有指定,则默认为EntityViewBuilder。 -
access处理器是一个EntityAccessControlHandlerInterface实现,负责检查给定类型的实体上任何 CRUD 操作的访问权限。如果没有提供,则使用默认的EntityAccessControlHandler;它还会触发模块可以实现的访问钩子,以便在给定实体的访问规则中发表意见。我们将在后面的专门章节中详细讨论访问问题。 -
views_data处理器是一个EntityViewsDataInterface实现,负责将相应的实体类型暴露给 Views API。这是为了让 Views 能够正确理解实体和字段。如果没有提供,则默认使用EntityViewsData。 -
form处理器是用于各种实体操作(如创建、编辑和删除)的EntityFormInterface实现。所引用的类是用于管理实体的表单。 -
route_provider处理器是负责为相应实体类型动态提供必要路由的EntityRouteProviderInterface实现。节点实体类型定义了一个用于 HTML 页面的路由,但也可以为其他类型的 HTTP 格式定义。 -
list_builder处理器是一个EntityListBuilderInterface实现,负责构建相应类型的实体列表。这个列表通常用于管理实体的管理屏幕。这是非常重要的,因为没有它,管理员列表将无法工作。默认实现是EntityListBuilder。 -
translation处理器是一个ContentTranslationHandlerInterface实现,负责将此类型的实体暴露给翻译 API。
字段
实体通过字段建模数据的主要方式。实体本身基本上只是一个不同类型字段的集合,这些字段持有各种类型的数据。
Drupal 7 开发者会记得,在 D7 中,实体有两种类型的字段,通常被称为属性和 Field UI 字段。前者实际上是实体类上的简单属性,存储在实体表中。后者是通过 UI 附属于捆绑的,并且有单独的数据库表。
在 Drupal 8 中,情况有些相似,但也非常不同。首先,内容实体和配置实体所属的字段之间存在很大差异。然后,就像在 D7 中一样,我们仍然区分两种内容实体字段:基本字段和可配置字段。然而,这并不像 D7 中那样大,因为它们都有相同的基础。
配置实体字段
由于它们的存储处理,配置实体具有相对简单的字段。我们可以存储复杂的配置,但没有复杂的数据库模式来反映这一点。相反,我们有配置模式层,它描述了配置实体,以便实体 API 可以理解它们存储和表示的数据类型。我们之前在查看配置系统时讨论了这一点。但让我们检查 NodeType 配置实体类型,以更好地理解其字段。
配置实体的字段基本上被声明为类属性。因此,我们可以看到 NodeType 具有诸如$description、$help等字段。正如我之前提到的,插件注解包括对要持久化和导出的类属性的引用。正如你可以想象的那样,一个类应该允许有一些属性,这些属性实际上不是需要导出的字段值。
配置实体类也可以为其字段提供一些特定的获取器和设置器方法,但也可以依赖于ConfigEntityBase父类的set()和get()方法来设置和访问字段值。事情相对简单易懂。
现在,让我们检查一下在node.schema.yml中找到的 NodeType 配置模式,看看它究竟是什么:
node.type.*:
type: config_entity
label: 'Content type'
mapping:
name:
type: label
label: 'Name'
type:
type: string
label: 'Machine-readable name'
....
new_revision:
type: boolean
label: 'Whether a new revision should be created by default'
...
这只是一个没有一些字段的模式定义示例,因为我们已经知道如何读取那些。然而,也有一些新事物。
我们可以看到通配符表示法,这表明此模式应适用于以该前缀开头的所有配置项。所以,本质上,适用于所有特定类型的实体。在这种情况下,实体类型名称是type,正如在 NodeType 注解的config_prefix属性中所表示的。当然,命名空间由模块名称作为前缀。
接下来,我们看到类型是config_entity,这是除了用于表示简单配置的config_object之外的另一个主要复杂类型。这些基本上是mapping类型的扩展,包含一些额外信息。在配置实体的情况下,这些是自动导出的字段定义——uuid、langcode、status、dependencies和third_party_settings。也就是说,这些字段存在于任何类型的所有配置实体上,并且始终被持久化和导出。
最后,我们有每个单独字段的模式定义,例如name、type等。因此,现在系统知道new_revision字段应该被视为布尔值,或者name字段是可翻译的(因为它是一种类型标签,它扩展了简单的string类型,并带有翻译标志)。
因此,正如你所看到的,配置实体类型的字段矩阵并不复杂,容易理解。内容实体要复杂得多,我们将在下一节讨论这些。
内容实体字段
与 Drupal 7 类似,D8 中的内容实体有两种类型的字段:基础字段和可配置字段。对于 Drupal 7 开发者来说,前者实际上是旧的“属性”字段,而后者是“字段 UI”字段。然而,正如我们一会儿将看到的,它们现在在实现上非常不同,因为它们非常相似。
首先,Drupal 8 中的内容实体字段是建立在低级的 TypedData API 之上的。后者是一个用于在代码中建模数据的复杂系统,并且在 Drupal 8 中被广泛使用。不幸的是,它也是开发者最不理解的 API 之一。不用担心,在下一节中,我会为您分解它。由于我们对此一无所知,我们现在将从更高级的角度来讨论字段。
基础字段
基础字段是离给定实体类型最近的字段,例如标题、创建/修改日期、发布状态等。它们作为 BaseFieldDefinition 实现定义在实体类型类中,并基于这些定义安装到数据库中。一旦安装,它们就不再可以通过 UI 从存储的角度进行配置(除了在某些情况下,可以覆盖某些方面)。此外,仍然可以做出一些显示和表单小部件配置更改(也取决于个别定义是否允许这样做)。
让我们来看看节点实体类型的 baseFieldDefinitions() 方法,并查看一个基础字段定义的示例:
$fields['title'] = BaseFieldDefinition::create('string')
->setLabel(t('Title'))
->setRequired(TRUE)
->setTranslatable(TRUE)
->setRevisionable(TRUE)
->setSetting('max_length', 255)
->setDisplayOptions('view', [
'label' => 'hidden',
'type' => 'string',
'weight' => -5,
])
->setDisplayOptions('form', [
'type' => 'string_textfield',
'weight' => -5,
])
->setDisplayConfigurable('form', TRUE);
这是节点 title 字段的定义。我们可以推断出它属于 string 类型,因为这是传递给 BaseFieldDefinition 类的 create() 方法的参数。后者是建立在 TypedData API 之上的一个复杂的数据定义类。
可以定义的其他常见字段类型包括 boolean、integer、float、timestamp、datetime、entity_reference、text_long 以及许多其他类型。您可以通过检查 Drupal 核心和任何其他模块提供的可用 FieldType 插件来找出您可以使用哪些字段类型。这些是 UI 中可配置字段可以使用的相同类型的字段。在后面的章节中,我们将看到如何编写我们自己的自定义字段类型。
字段定义可以包含多个选项,这些选项可能也取决于正在定义的字段类型。在这里,我将跳过显而易见的选项,直接跳到 setTranslatable() 和 setRevisionable() 方法,并请您记住我们之前看到节点实体类型插件注解表明节点将是可翻译和可修订的。这就是字段本身被配置为这种效果的地方。如果没有这些设置,它们将无法使用翻译功能和修订。
如果你查看baseFieldDefinitions()方法是如何开始的,你会看到它也从父类继承了一些字段。这就是常见字段定义被继承的地方,这允许实体类型可修订和可发布。
setSetting()方法用于向字段提供各种选项。在这种情况下,它用于指示最大长度,这也在数据库的表列中得到了反映。然后,我们有显示选项,这些选项配置了字段应使用的视图格式化和表单小部件。它们分别引用了FieldFormatter(string)和FieldWidget(string_textfield)插件类型。在后面的章节中,我们将看到如何定义我们自己的字段插件,这些插件可以用于基础字段和可配置字段。
最后,我们有setDisplayConfigurable()方法,它用于通过 UI 启用/禁用表单小部件或显示的配置更改。在这种情况下,只有表单小部件会暴露给更改。
并非所有这些选项和配置总是被使用或强制要求。这取决于我们正在定义的字段类型,我们希望字段如何配置,以及默认值是否对我们来说可以接受。一个可以在所有字段类型上使用的选项是基数——字段是否可以具有多个相同类型的值。这允许字段存储多个遵循同一数据定义的实体字段值。
如果我们创建自己的实体类型,并希望在以后添加或修改基础字段,我们可以在最初定义它们的地方进行——在实体类中。然而,对于不属于我们的实体,我们需要实现一些钩子,以便贡献我们自己的更改。为了向现有实体类型提供一个新的基础字段定义,我们可以在我们的模块中实现hook_entity_base_field_info(),并返回一个BaseFieldDefinition项目数组,就像我们在 Node 实体类型中看到的那样。或者,我们可以实现hook_entity_base_field_info_alter()并修改现有的基础字段定义以满足我们的需求。但请记住,这个后置钩子可能会在未来发生变化,尽管在撰写本文时,并没有给予它很高的优先级。
可配置字段
可配置字段通常通过 UI 创建,附加到实体类型包,并导出到代码中。加粗的部分是这些字段与基础字段之间一个关键的区别,即基础字段存在于实体类型的所有包中。你应该已经熟悉创建可配置字段的 UI:

它们还使用 TypedData API 进行定义,以及我们之前提到的相同的字段类型、小部件和格式化插件。从架构上来说,基本字段和可配置字段之间的主要区别在于后者由两部分组成:存储配置(FieldStorageConfig)和字段配置(FieldConfig)。这些都是配置实体类型,它们的实体共同构成一个可配置字段。前者定义了与字段存储相关的字段设置。这些选项适用于可能附加到其实体类型包上的所有包中的特定字段(如基数、字段类型等)。后者定义了特定于附加包的字段选项。在某些情况下,这些选项可以是存储配置的覆盖,也可以是新的设置(如字段描述、是否必需等)。
创建可配置字段最简单的方法是通过 UI。同样容易,您可以将它们导出到代码中。您也可以自己编写字段存储配置和字段配置,并将其添加到模块的 config/install 文件夹中,但您也可以通过 UI 导出它们来实现相同的效果。
此外,您可以使用几个钩子来修改现有字段。例如,通过实现 hook_entity_field_storage_info_alter(),您可以修改字段存储配置,而使用 hook_entity_bundle_field_info_alter(),您可以修改字段配置,这些配置是附加到实体类型包上的。
字段存储
我们之前看到配置实体是根据配置架构和插件定义进行持久化和导出的。让我们快速谈谈内容实体上使用的字段在数据库中的存储方式。
默认情况下,基本字段最终会出现在实体基表中(在插件注解中定义为 base_table)。这使得它们比在单独的表中更高效。然而,也有一些例外。
如果实体类型是可翻译的,则会创建一个“数据”表,用于存储同一实体基字段在不同语言中的记录。这是 Node 实体类型插件注解中声明了具有属性 data_table 的表。如果此属性不存在,则默认表名为 [base_table]_field_data。
此外,如果给定字段的字段基数大于 1,则会为该字段创建一个名为 [entity_type_name]__[field_name] 的新表,其中可以存储同一字段的多个记录。
如果实体和字段启用了翻译,并且相应的字段基数大于一,则“数据”表包含实体在所有翻译语言中的记录,而 [entity_type_name]__[field_name] 表包含给定字段在所有语言中的所有值记录。
可配置字段,另一方面,总是有一个名为 [entity_type_name]__[field_name] 的单独字段数据表,其中可以存储同一字段在多种语言中的多个值。
实体类型摘要
Entity API 非常复杂。我们刚刚开始了解不同类型的实体类型、捆绑、字段等。到目前为止,我们已经讨论了配置和内容实体类型之间的区别以及它们究竟由什么组成。为此,我们还简要提到了它们可以使用的不同类型的字段以及这些字段中的数据是如何存储的。
然而,关于实体还有很多东西需要理解,尤其是内容实体,这是我们将在下一节中关注的重点。我们将首先查看 TypedData API,以便更好地理解内容实体字段数据是如何建模的。到目前为止,这仍然是一个黑盒;我说的对吗?接下来,我们将看看如何实际使用 API 来查询、创建和操作实体(内容和配置)。最后,我们将简要谈谈内容实体和字段使用的一致性验证 API,以确保它们持有适当的数据。那么,让我们开始吧。
TypedData
为了真正理解实体数据是如何建模的,我们需要了解 TypedData API。不幸的是,这个 API 对许多人来说仍然是一个谜。但你有幸,因为在本节中,我们将深入探讨这个问题。
为什么需要 TypedData?
如果我们首先谈谈为什么需要这个 API,这有助于更好地理解事物。这一切都与 PHP 作为一种语言 是 的方式有关,与其他语言相比,它是松散类型的。这意味着在 PHP 中,很难使用原生语言结构来依赖于某些数据类型或了解更多关于该数据的信息。
字符串 "1" 和整数 1 之间的区别是一个非常常见的例子。我们常常害怕使用 === 符号来比较它们,因为我们永远不知道它们实际上从数据库或其他地方返回的是什么。所以,我们要么使用 ==(这其实并不好),要么强制将它们转换为相同的类型,并希望 PHP 能够正确处理。
在 PHP 7 中,我们在函数参数中对标量值有类型提示,这是好的,但仍然不够。如果你考虑 1495875076 和 2495877076 之间的区别,标量值本身是不够的。第一个是一个时间戳,而第二个是一个整数。更重要的是,第一个有含义,而第二个则没有。至少表面上是这样。也许我想让它有某种含义,因为它是我的包裹跟踪应用中 ID 的特定格式。
Drupal 并没有免除 PHP 这种松散类型特性可能造成的问题。Drupal 7 开发者非常清楚以这种方式处理字段值意味着什么。但不再是了,因为我们现在在 Drupal 8 中有了 TypedData API。
什么是 TypedData?
TypedData API 是一个低级和通用 API,本质上执行两项主要任务,从中衍生出许多强大和灵活的功能。
首先,它包装了任何类型的“值”。更重要的是,它形成了“值”。这可以是一个简单的标量值,也可以是一个多维映射,其中包含不同类型的关联值,这些值共同被视为一个值。以纽约车牌为例:405-307。这是一个简单的字符串,但我们用 TypedData 包装它以赋予它意义。换句话说,我们知道它是一个车牌,而不仅仅是一个随机的 PHP 字符串。但是等等,这个车牌号码也可能在其他州找到(可能,我不知道)。因此,为了更好地定义车牌,我们还需要一个州代码:NY。这是另一个简单的字符串,用 TypedData 包装以赋予它意义——州代码。结合起来,它们可以成为一个稍微复杂一点的 TypedData:美国车牌,它有自己的意义。
其次,正如你可能推断的那样,它为其包装的数据赋予了意义。如果我们继续之前的例子,美国的车牌数据类型现在具有丰富的意义。因此,我们可以通过编程方式询问它是什么,以及关于它的各种其他信息,例如车牌的州代码是什么。API 简化了与数据的这种交互。
正如我之前提到的,从这个灵活性中可以构建很多功能。例如,数据验证在 Drupal 8 中非常重要,并且依赖于 TypedData。正如我们将在本章后面看到的那样,验证是在 TypedData 层通过底层数据的约束来进行的。
低级 API
现在我们对 TypedData 的原理及其必要性有了基本的了解,让我们开始探索 API,从最小的部分开始,逐步深入。
该 API 的两个主要支柱是 DataType 插件和数据定义。
数据类型插件
数据类型插件负责定义系统中可用的数据类型。例如,StringData 插件用于模拟简单的原始字符串。此外,它们还负责与数据本身进行交互;例如设置和访问相应的值。
数据类型插件由 TypedDataManager 管理,并由 DataType 注解类进行注解。它们实现了 TypedDataInterface 接口,通常扩展了 TypedData 基类或其子类之一。
根据它们实现的接口,存在三种主要的 DataType 插件类型:
-
首先,就是我之前提到的
TypedDataInterface;这通常用于简单的原始值,如字符串或整数。 -
其次,是
ListInterface,它用于形成其他TypedData元素的集合。它提供了特定于与元素列表交互的方法。 -
第三,有
ComplexDataInterface,它用于由多个具有名称并可相应访问的属性组成的更复杂的数据。向前看,我们将看到所有这些类型的示例。
理解这些插件如何使用的最好方法是首先谈谈数据定义。
数据定义
数据定义是用于存储我们之前讨论的底层数据的所有含义的对象。它们定义它们可以持有的数据类型(使用现有的 DataType 插件)以及关于该数据的任何其他有意义的信息。因此,与插件一起,数据定义是一个强大的数据建模机器。
在最低级别,它们实现了DataDefinitionInterface,通常扩展DataDefinition类(或其子类之一)。DataDefinition的重要子类是ListDefinition和ComplexDefinitionBase,它们用于定义更复杂的数据类型。正如你所期望的,它们与之前提到的ListInterface和ComplexDataInterface插件相关联。
让我们通过模拟一个简单的字符串my_value来查看数据定义和 DataType 插件的一个简单用法示例。
所有这一切都始于定义:
$definition = DataDefinition::create('string');
我们传递给create()方法的参数是我们想要定义数据的 DataType 插件 ID。在这种情况下,它是StringData插件。
我们已经有一些现成的选项来定义我们的字符串数据。例如,我们可以设置一个标签:
$definition->setLabel('Defines a simple string');
我们也可以将其标记为只读或设置我们想要的任何“设置”到定义上。然而,我们不做的一件事是处理实际值。这就是 DataType 插件发挥作用的地方。这种方式是,我们必须基于我们的定义和值创建一个新的插件实例:
/** @var \Drupal\Core\TypedData\TypedDataInterface $data */
$data = \Drupal::typedDataManager()->create($definition, 'my_value');
我们使用TypedDataManager创建了一个新的实例,该实例包含我们实际的字符串值。我们得到的是一个插件,我们可以用它来与我们的数据交互,更好地理解它,更改其值,等等:
$value = $data->getValue();
$data->setValue('another string');
$type = $data->getDataDefinition()->getDataType();
$label = $data->getDataDefinition()->getLabel();
我们可以看到我们正在处理什么类型的数据,其标签以及其他信息。
让我们看看一个稍微复杂一点的例子,并模拟我们之前讨论过的车牌使用案例。
我们首先定义数字:
$plate_number_definition = DataDefinition::create('string');
$plate_number_definition->setLabel('A license plate number.');
然后,我们定义状态代码:
$state_code_definition = DataDefinition::create('string');
$state_code_definition->setLabel('A state code');
我们保留这些通用性,因为没有人说我们不能在其他地方重用这些;我们可能需要处理状态代码。
接下来,我们创建我们的完整定义:
$plate_definition = MapDataDefinition::create();
$plate_definition->setLabel('A US license plate');
我们在这里使用MapDataDefinition,它默认使用Map DataType 插件。本质上,这是一个定义良好的属性关联数组。因此,让我们将我们的定义添加到其中:
$plate_definition->setPropertyDefinition('number', $plate_number_definition);
$plate_definition->setPropertyDefinition('state', $state_code_definition);
此映射定义获得两个命名的属性定义:number和state。你现在可以看到 TypedData API 的层次结构方面。
最后,我们实例化插件:
/** @var \Drupal\Core\TypedData\Plugin\DataType\Map $plate */
$plate = \Drupal::typedDataManager()->create($plate_definition, ['state' => 'NY', 'number' => '405-307']);
我们传递给这种类型数据的值是一个数组,其键应映射到属性名称,值映射到单个属性定义(在这种情况下是字符串)。
现在,我们可以从 TypedData API 的所有优点中受益:
$label = $plate->getDataDefinition()->getLabel();
$number = $plate->get('number');
$state = $plate->get('state');
$number和$state变量是StringData插件,然后可以用来访问内部的单个值:
$state_code = $state->getValue();
它们的相应定义可以通过与我们之前相同的方式进行访问。因此,我们在这些几行代码中成功地定义了一个美国车牌结构,并使其余的代码能够理解。接下来,我们将查看更复杂的示例,并检查内容实体数据是如何使用 TypedData 进行建模的。正如我们所见,配置实体依赖于配置模式来定义数据类型。在底层,模式类型本身引用了 TypedData API 数据类型插件。因此,在幕后,使用的是相同的低级 API。为了使事情更简单一些,我们将查看内容实体,其中此 API 更为明确,并且你实际上必须处理它。
内容实体
让我们现在检查实体和字段,看看它们是如何使用 TypedData API 来建模它们存储和管理的数据的。这也有助于你更好地理解在调试实体及其字段时数据的组织方式。
数据存储和建模的主要地方是字段。正如我们所见,我们有两种类型:基字段和可配置字段。然而,当涉及到 TypedData 时,它们并没有很大的区别。它们两者都使用FieldItemList数据类型插件(直接或其子类)。在定义方面,基字段使用BaseFieldDefinition实例,而可配置字段使用FieldConfig实例。后者稍微复杂一些,因为它们实际上是配置实体本身(用于存储字段配置),但最终实现了DataDefinitionInterface。因此,它们结合了两个任务。此外,基字段还可以使用BaseFieldOverride定义实例,这些实例本质上也是配置实体,用于存储通过 UI 对作为基字段定义的字段所做的更改。就像FieldConfig定义一样,这些扩展了FieldConfigBase类,因为它们具有相同的可导出特性。
除了字段之外,实体本身还有一个 TypedData 插件,可以用来包装实体并将其直接暴露给 API——EntityAdapter。这些使用EntityDataDefinition实例,它基本上包括所有单个字段定义。使用插件派生,每个实体类型动态地获得一个EntityAdapter插件实例。
让我们现在检查一个简单的基字段,并了解在字段上下文中 TypedData API 的使用。BaseFieldDefinition类扩展了ListDataDefinition,它负责在列表中定义多个数据项。列表中的每个项也是DataDefinitionInterface的一个实例,因此你可以看到与我们的车牌示例相同的类型层次结构。但为什么一个字段是项的列表呢?
你可能知道,当你创建一个字段时,你可以选择这个字段可以包含多少项——它的基数。你通常选择一个,但也可以选择多个。所有类型的字段都是如此。无论你选择什么基数,数据都被建模为一个列表。如果一个字段有一个基数为 1,列表将只有一个项。就这么简单。所以,如果基本字段定义是定义列表,那么单个项定义是什么呢?答案是FieldItemDataDefinition的实现。
在数据类型插件方面,正如我提到的,我们有FieldItemList类,它实现了我之前提到的ListInterface,作为更复杂的数据类型之一。内部的项目是FieldItemBase的子类(它扩展了我们之前遇到的Map数据类型)。所以我们有相同类型的数据结构。但为了使事情稍微复杂一些,这里又出现了一种插件类型——FieldType。单个字段项实际上是这种插件类型的实例(它扩展了某种DataType插件)。所以,例如,一个文本字段将使用StringItem``FieldType插件,它从Map数据类型继承了许多功能。所以,你可以看到 TypedData API 处于一个非常低级的位置,并且可以在其之上构建东西。
所以现在,如果我们结合我们所学的知识,并观察一个基本字段,我们会看到以下内容:一个使用BaseFieldDefinition(或BaseFieldOverride)数据定义的FieldItemList数据类型。在内部,每个项目都是一个FieldItemBase实现(一个扩展某种DataType插件的FieldType插件)使用FieldItemDataDefinition。所以,实际上并没有那么复杂。当我们看到如何与实体和字段数据交互时,我们将在本章的最后部分将这一知识运用到实际中。我并不是为了这些概念而向你抛出这些概念。
可配置的字段几乎以完全相同的方式工作,只是对应于FieldItemList的定义是一个FieldConfig实例(它也是一个存储此字段设置的配置实体,类似于BaseFieldOverride)。然而,它也是一种列表定义,其中单个列表项与基本字段相同。
类型化数据摘要
因此,正如我们所看到的,在 Drupal 8 中理解 TypedData API 的范围相当广泛。我们可以使事情非常简单,就像我们的第一个例子一样,但随后在实体系统中的应用会进入一些非常复杂的领域。本节的目的就是要让你了解这个 API,理解其推理,看到几个简单的例子,并分解在实体 API 中使用到的所有组件。
然而,我必须承认,这一定是一个很难理解的章节。所有这些术语和理论可能相当令人畏惧。但如果你没有完全理解一切,那没关系。它在那里供你在我们进入下一节时参考,因为我们将应用所有这些知识,你将看到了解它的有用之处。换句话说,我们现在将专注于与实体(内容和配置)交互,并在这样做的时候,大量使用由 TypedData API 提供的功能。
与实体 API 交互
在本章的最后部分,我们将介绍你将最常与内容配置实体一起做的事情。这些是我们接下来将要讨论的主要主题:
-
查询和加载实体
-
读取实体
-
操作实体(更新/保存)
-
创建实体
-
渲染实体
-
验证实体数据
因此,让我们开始吧。
查询实体
作为程序员,你将要做的一件最常见的事情就是查询东西,比如数据库中的数据。这正是我们在 Drupal 7 中大量做的事情来获取我们的数据。很多。我们要么使用数据库 API,要么使用简单的查询字符串来加载数据。然而,在 Drupal 8 中,实体 API 已经变得更加健壮,并提供了减少直接查询数据库需求的一层。在后面的章节中,我们将看到当事情变得更加复杂时,我们如何仍然可以做到这一点。现在,由于我们的大部分结构化数据都属于实体,我们将使用实体查询系统来检索实体。
如果你记得我们之前讨论实体类型处理器时,其中之一是提供实体 CRUD 操作 API 的存储处理器。这就是我们将用来访问实体查询的处理程序。我们通过entity_type.manager服务(EntityTypeManager)来做这件事:
$query = \Drupal::entityTypeManager()->getStorage('node')->getQuery();
我们请求存储处理器,然后它可以给我们该实体类型的查询工厂。在这个例子中,我使用了静态调用,但,就像往常一样,你应该在可能的地方注入服务。
构建查询
现在我们手头有一个实体查询工厂,我们可以构建一个由条件和各种典型查询元素组成的查询。以下是一个查询最后 10 篇已发布文章节点的简单示例:
$query
->condition('type', 'article')
->condition('status', TRUE)
->range(0, 10)
->sort('created', 'DESC');
$ids = $query->execute();
你首先可以看到的是,工厂上的方法是可以链式的。我们有设置条件、范围、排序等预期的方法。正如你所能推断出的,第一个参数是字段名,第二个是值。可选的第三个参数也可以是条件的运算符。
我强烈建议你查看\Drupal\Core\Entity\Query\QueryInterface类中关于这些方法的文档,特别是condition()方法,这是最复杂的。
这里是一个稍微复杂一些的条件,它将返回两种不同类型的节点:
->condition('type', ['article', 'page'], 'IN')
此外,你还可以使用条件组,使用 OR 或 AND 连接词:
$query
->condition('status', TRUE);
$or = $query->orConditionGroup()
->condition('title', 'Drupal', 'CONTAINS')
->condition('field_tags.entity.name', 'Drupal', 'CONTAINS');
$query->condition($or);
$ids = $query->execute();
在之前的查询中,我们看到了一些新事物。首先,我们创建了一个类型为 OR 的条件组,在其中添加了两个条件。其中一个检查节点标题字段是否包含字符串"Drupal"。另一个检查由field_tags字段(在这种情况下为分类术语)引用的任何实体是否有其名称中包含字符串"Drupal"。因此,你可以看到我们在遍历引用实体方面的强大能力。最后,我们将这个条件组用作查询的condition()方法的第一个参数(而不是字段名和值)。
节点实体的实体查询会考虑到访问限制,因为它们是在当前用户的上下文中运行的。这意味着,例如,如果一个匿名用户访问的页面触发了对未发布节点的查询,那么它不会返回结果,但如果是由管理员触发的,则会返回结果。如果你确定结果不会向用户暴露不受欢迎的内容,你可以通过向查询添加->accessCheck(FALSE)指令来禁用此功能。我们将在后面的章节中更多地讨论节点访问。
配置实体以相同的方式工作。我们获取该实体类型的查询工厂并构建一个查询。在底层,由于存储的扁平化特性,查询当然会以不同的方式运行。
每个配置实体在数据库中都有一个记录,因此它们需要被加载并检查。此外,条件也可以编写来匹配配置实体字段数据的嵌套特性。例如:
$query = \Drupal::entityTypeManager()->getStorage('view')->getQuery();
$query
->condition('display.*.display_plugin', 'page');
$ids = $query->execute();
这个查询搜索所有具有类型为"page"的显示插件的视图配置实体。条件本质上是在display数组中查找任何元素(因此有*通配符)。如果这些元素中的任何一个有display_plugin键,其值为"page",则匹配。以下是一个示例视图实体在 YAML 格式中的样子:
...
base_field: nid
core: 8.x
display:
default:
display_options:
...
display_plugin: default
display_title: Master
...
page_1:
display_options:
...
display_plugin: page
display_title: Page
我从该实体中删除了大量数据,只是为了使其更简洁。但正如你所见,我们有display数组,其中包含default和page_1元素,每个元素都有一个display_plugin键,其中包含插件 ID。
加载实体
现在我们已经通过查询找到了实体 ID,是时候加载它们了。这样做非常简单。我们只需使用该实体类型的存储处理程序(并且我们从实体类型管理器中获取它):
$nodes = \Drupal::entityTypeManager()->getStorage('node')->loadMultiple($ids);
这将返回一个EntityInterface对象数组(在这种情况下为NodeInterface)。或者如果我们只有一个 ID 要加载:
$nodes = \Drupal::entityTypeManager()->getStorage('node')->load($id);
这些将返回一个单独的NodeInterface对象。
实体类型存储处理程序还有一个快捷方法,允许你一次性执行简单查询并加载结果实体。例如,如果我们想加载所有文章节点:
$nodes = \Drupal::entityTypeManager()->getStorage('node')->loadByProperties(['type' => 'article']);
loadByProperties() 方法接受一个参数:一个包含需要匹配的简单字段值条件的关联数组。幕后,它根据这些条件构建一个查询并加载返回的实体。请记住,这里不能有复杂的查询,并且底层构建的查询将考虑访问检查。因此,为了完全控制,请自行构建查询。
读取实体
因此,我们已经加载了实体,现在可以读取其数据。对于内容实体,这是 TypedData 知识发挥作用的地方。在我们查看这一点之前,让我们快速看看如何从配置实体中获取数据。为了这个目的,让我们检查 Article 的NodeType:
/** @var \Drupal\node\Entity\NodeType $type */
$type = \Drupal::entityTypeManager()->getStorage('node_type')->load('article');
我们可以做的第一件事和最简单的事是检查实体类型类上的单个方法。例如,NodeType 有一个 getDescription() 方法,这是一个方便的辅助工具来获取描述字段:
$description = $type->getDescription();
这始终是尝试获取配置实体字段值的最佳方式,因为您可能会得到返回类型文档,这在您的 IDE 中可能很有用。作为替代,ConfigEntityBase 类有一个 get() 方法,可以用来访问任何字段:
$description = $type->get('description');
这将执行相同的事情,并且这是任何字段跨不同配置实体类型访问的常见方式。结果值是原始字段值,在这种情况下是一个字符串。所以,这很简单。
除了典型的字段数据外,我们还有实体键(如果您还记得实体类型插件定义)。这些对于配置和内容实体都是通用的,相关的访问器方法可以在 EntityInterface 上找到。以下是一些更常见的例子:
$id = $type->id();
$label = $type->label();
$uuid = $type->uuid();
$bundle = $type->bundle();
$language = $type->language();
结果信息自然取决于实体类型。例如,配置实体没有捆绑包或某些内容实体类型也没有。因此,bundle() 方法在没有捆绑包的情况下将返回实体类型的名称。迄今为止最重要的一个是 id(),但您通常会使用 label() 作为到用作实体类型标签的字段原始字段值的快捷方式。还有其他实体键,各个实体类型可以声明。例如,扩展 EditorialContentEntityBase 的实体类型,如节点实体,有一个 published 实体键和相应的 isPublished() 方法。因此,对于任何其他实体键,请检查相应的实体类型,看看您是否可以使用它们。
你可以使用的一些额外的检查任何类型实体的方法:
-
isNew()检查实体是否已经被持久化。 -
getEntityTypeId()返回实体的实体类型的机器名。 -
getEntityType()返回给定实体的EntityTypeInterface插件。 -
getTypedData()返回包装实体的EntityAdapterDataType 插件实例。它可以用于进一步的检查以及验证;
此外,我们还可以检查它们是内容实体还是配置实体:
$entity instanceof ContentEntityInterface
$entity instanceof ConfigEntityInterface
同样,我们也可以检查它们是否是特定类型的实体:
$entity instanceof NodeInterface
这与使用 $entity->getEntityTypeId === 'node' 类似,但它更加明确和清晰,IDE 也能在许多情况下从中受益。
现在,让我们转向内容实体,看看我们如何读取它们的字段数据。
与配置实体类型类似,许多内容实体类型可以在它们的类(或父类)上具有辅助方法,以便更容易地访问某些字段。例如,节点实体类型有 getTitle() 方法,它获取其标题字段的第一个原始值。然而,让我们看看我们如何在 TypedData 部分学到的知识,并像专业人士一样导航字段值。为了举例说明,我们将检查一个简单的文章节点。
内容实体也有 get() 方法,但与配置实体不同,它不返回原始字段值。相反,它返回 FieldItemList 的实例:
/** @var \Drupal\node\NodeInterface $node */
$node = Node::load(1);
/** @var \Drupal\Core\Field\FieldItemListInterface $title */
$title = $node->get('title');
对于快速原型设计,在这个例子中,我使用了内容实体类上的静态 load() 方法通过 ID 加载实体。在底层,这将委托给相关的存储类。这是一个使用实体管理器的快速替代方案,但你只应该在无法注入依赖的情况下依赖它。
这里有一些关于标题 FieldItemList 的我们可以了解的事情:
$parent = $title->getParent();
这是它的父级(它所属的数据类型插件,在这种情况下,是 EntityAdapter):
$definition = $title->getFieldDefinition();
这是列表的 DataDefinitionInterface。在这种情况下,它是一个 BaseFieldDefinition 实例,但可以是 BaseFieldOverride 或 FieldConfig,用于完全可配置的字段:
$item_definition = $title->getItemDefinition();
这是列表中单个项目的 DataDefinitionInterface,通常是 FieldItemDataDefinition:
$total = $title->count();
$empty = $title->isEmpty();
$exists = $title->offsetExists(1);
这些是一些方便的方法来检查列表。我们可以看到列表中有多少项,它是否为空,以及给定偏移量是否有任何值。请记住,值键从 0 开始,所以如果字段的基数是 1,值将在键 0 处。
要从列表中检索值,我们有多种选择。你最终最常做的事情如下:
$value = $title->value;
这是一个指向列表中第一个原始值的魔法属性。然而,非常重要的一点是,尽管大多数字段使用 value 属性,但一些字段有不同的属性名。例如,实体引用字段使用 target_id:
$id = $field->target_id;
这返回引用实体的 ID。作为额外的好处,如果你使用魔法 entity 属性,你将得到完全加载的实体对象:
$entity = $field->entity;
但关于这种魔法般做事的方式就到这里吧;让我们看看我们还有哪些其他选择:
$value = $title->getValue();
getValue() 方法存在于所有 TypedData 对象上,并返回它存储的原始值。在我们的例子中,它将返回一个包含单个项的数组(因为我们列表中只有一个项),该数组包含单个项的原始值。在这种情况下,它是一个键为 value 并以标题字符串作为其实际值的数组。我们稍后将看到为什么这是键 value。
在某些情况下,我们可能希望返回这个值并且觉得它很有用。然而,在其他情况下,我们可能只想获取单个字段的值。为此,我们可以请求列表中的某个特定项:
$item = $title->get(0);
$item = $title->offsetGet(0);
这两个都做同样的事情并返回一个 FieldType 插件,正如我们所看到的,它扩展了 FieldItemBase,这不过是一个花哨的 Map 数据类型插件。一旦我们有了这个,我们又有几个选择:
$value = $item->getValue();
这再次返回一个包含原始值的数组,在这种情况下,有一个键名为 value,字符串标题作为实际值。所以,就像我们在列表上调用 getValue() 一样,但这次返回的是单个项的原始值,而不是多个项的原始值数组。
我们之所以使用 value 作为键的实际标题字符串,是因为我们正在从 StringItem 字段类型插件请求原始值,在这种情况下,它恰好定义了值列名为 value。其他可能不同(例如,存储名为 target_id 的值的实体引用字段)。
或者,再次,我们可以进一步导航:
$data = $item->get('value');
我们知道这个字段使用 value 作为其属性名,因此我们可以使用 Map 数据类型(如果你记得,它是 StringItem 字段类型的子类)的 get() 方法通过名称检索其属性。这与我们处理车牌地图和请求号码或州代码时所做的完全相同。在 StringItem 字段类型的情况下,这将是一个 StringData 数据类型插件。
就像我们之前做的那样,我们可以从这个最终插件请求其值:
$value = $data->getValue();
现在我们有了标题的最终字符串。当然,从顶部到底部,我们都有机会检查每个插件的定义并了解更多关于它们的信息。
通常,在日常使用中,你会根据基数使用两种方法来从字段中检索值,如果字段只有一个值,你最终会使用类似这样的方法:
$title = $node->get('title')->value;
$id = $node->get('field_referencing_some_entity')->target_id;
$entity = $node->get('field_referencing_some_entity')->entity;
如果字段可以有多个值,你最终会使用类似这样的方法:
$names = $node->get('field_names')->getValue();
$tags = $node->get('field_tags')->referencedEntities();
referencedEntities() 方法是由 EntityReferenceFieldItemList(它是 FieldItemList 的一个子类)提供的辅助方法,它加载所有引用的实体并将它们以字段(即 delta)中的位置为键返回到一个数组中。
实体操作
现在我们知道了如何以编程方式读取字段数据,让我们看看我们如何更改这些数据并将其持久化到存储中。所以,让我们看看相同的 Node 标题字段并对其进行程序化更新。
你可以更改内容实体字段值的最常见方法是这个:
$node->set('title', 'new title');
这对于只有一个值(基数 = 1)的字段效果很好,幕后实际上发生的是:
$node->get('title')->setValue('new title');
由于我们正在处理项目列表,这个值将转换为一个包含一个值的原始数组。如果字段有更高的基数,并且我们传递一个这样的值,我们实际上会删除两个值并替换为只有一个。所以,如果我们想确保我们不是删除项目而是在列表中添加,我们可以这样做:
$values = $node->get('field_multiple')->getValue();
$values[] = ['value' => 'extra value'];
$node->set('field_multiple', $values);
如果我们要更改列表中的特定项,我们可以这样做:
$node->get('field_multiple')->get(1)->setValue('changed value');
这将改变列表中第二个项目的值。你只需确保在链式操作之前先设置它:
$node->get('field_test')->offsetExists(1);
我们对字段值所做的所有这些修改都保留在内存中(它们不会被持久化)。为了将它们保存到数据库,我们必须做一些极其复杂的事情:
$node->save();
就这些。我们也可以通过实体类型管理器实现相同的功能:
\Drupal::entityTypeManager()->getStorage('node')->save($node);
由于我们正在谈论保存,删除实体可以通过与实体对象上的delete()方法相同的方式进行,我们也在存储处理程序上拥有这个方法。然而,它接受一个要删除的实体数组,因此你可以一次性删除更多实体。
配置实体更容易一些,因为它们的字段不处理 TypedData。这就是我们可以轻松更改配置实体字段值的方法:
/** @var \Drupal\node\Entity\NodeType $type */
$type = \Drupal::entityTypeManager()->getStorage('node_type')->load('article');
$type->set('name', 'News');
$type->save();
这里没有太多复杂的事情。我们加载实体,设置属性值,并使用相同的 API 保存它。
创建实体
以编程方式创建新实体也不是什么难事,我们再次使用实体类型存储处理程序来完成:
$values = [
'type' => 'article',
'title' => 'My title'
];
/** @var \Drupal\node\NodeInterface $node */
$node = \Drupal::entityTypeManager()->getStorage('node')->create($values);
$node->set('field_custom', 'some text');
$node->save();
存储处理程序有create()方法,它接受一个形式为字段值关联数组的参数。键代表字段名,值代表值。这就是你可以设置一些初始简单值的地方,对于更复杂的字段,我们仍然有之前提到的 API。
如果实体类型有捆绑包,例如上面的节点示例,则在create()方法中需要指定捆绑包。它对应的键是捆绑包的实体键。如果你记得节点实体类型插件,那就是type。
大概就是这样。再次强调,我们需要保存它以便将其持久化到我们的存储中。
渲染内容实体
现在,让我们看看我们可以对实体做什么来在页面上渲染它。在这样做的时候,我们将坚持现有的视图模式,并尽量避免通过我们自己的主题钩子将其拆分成片段进行自定义模板渲染。如果你想这样做,你可以。你应该已经具备所有这方面的知识:
-
定义带有变量的主题钩子
-
查询和加载实体
-
读取实体的值
-
创建一个使用主题钩子的渲染数组
相反,我们将依赖实体默认的构建方法,这允许我们根据 UI 中配置的显示模式渲染它,例如,作为预告或完整显示模式。一如既往,我们将继续以节点为例。
我们需要做的第一件事是获取实体类型的 视图构建器 处理器。记得从实体类型插件定义中提到的这一点吗?就像存储处理器一样,我们可以从 EntityTypeManager 中请求它:
/** @var \Drupal\node\NodeViewBuilder $builder */
$builder = \Drupal::entityTypeManager()->getViewBuilder('node');
现在我们有了这个,将我们的实体转换为渲染数组的最简单方法就是使用 view() 方法:
$build = $builder->view($node);
默认情况下,这将使用完整视图模式,但我们可以传递第二个参数并指定另一个,例如预告或我们已配置的任何内容。第三个可选参数是我们想要渲染的翻译的 langcode(如果有的话)。
$build 变量现在是一个使用由 Node 模块定义的 node 主题钩子构建的渲染数组。你还会注意到一个 #pre_render 主题属性,它指定了一个在渲染此数组之前要运行的调用。这实际上是对 NodeViewBuilder(节点实体类型视图构建器)的引用,它负责准备所有字段值和所有其他我们现在不会覆盖的处理。但由 *_preprocess_node() 预处理器预处理的 node.twig.html 模板文件,在提供一些额外变量以供模板使用或渲染方面也起着重要作用。
如果我们想,我们还可以一次构建多个实体的渲染数组:
$build = $builder->viewMultiple($node);
这仍然会返回一个包含每个渲染实体的多个子元素的渲染数组。然而,我之前提到的 #pre_render 属性将保持在顶级,这次将负责构建多个实体。
实际上,从加载实体到将其转换为渲染数组的过程非常简单。你有很多不同的地方可以控制输出。正如我说的,你可以编写自己的主题钩子并将实体分解为变量。你还可以实现其默认主题函数的预处理器并更改那里的某些变量。你甚至可以更改使用的主题钩子,并向其中添加一个建议,然后从那里继续,就像我们在主题章节中看到的那样:
$build = $builder->view($node);
$build['#theme'] = $build['#theme'] . '__my_suggestion';
我们控制输出的另一种重要方式是通过实现一个在实体被构建用于渲染时触发的钩子:hook_entity_view() 或 hook_ENTITY_TYPE_view()。那么,让我们通过一个示例来看看我们如何在我们所有节点实体在 full 视图模式下显示时,在底部添加一条免责声明信息。我们可以这样做:
function module_name_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
if ($entity->getEntityTypeId() == 'node' && $view_mode == 'full') {
$build['disclaimer'] = [
'#markup' => t('The content provided is for general information purposes only.'),
'#weight' => 100
];
}
}
我们处理的三项重要参数是通过引用传递的$build数组,它包含整个实体的渲染数组,$entity对象本身,以及后者正在渲染的$view_mode。所以我们只需在$build数组中添加我们自己的渲染部分。作为额外的好处,我们尝试通过在渲染数组上使用#weight属性来确保消息打印在底部。
伪字段
从我们实现hook_entity_view()的示例中,有一个巧妙的小技巧我们可以使用,以进一步增强我们的站点构建者对那条免责声明消息的控制。这就是通过将其转换为伪字段。通过这样做,站点构建者将能够选择它应该在哪些包上显示,以及相对于其他字段的相对位置,所有这些都可以通过管理显示部分中的 UI 来完成:

因此,我们需要做两件事。首先,我们需要实现hook_entity_extra_field_info()并定义我们的伪字段:
/**
* Implements hook_entity_extra_field_info().
*/
function module_name_entity_extra_field_info() {
$extra = [];
foreach (NodeType::loadMultiple() as $bundle) {
$extra['node'][$bundle->id()]['display']['disclaimer'] = [
'label' => t('Disclaimer'),
'description' => t('A general disclaimer'),
'weight' => 100,
'visible' => TRUE,
];
}
return $extra;
}
正如您所看到的,我们遍历所有可用的节点类型,并在node实体显示列表中添加我们的disclaimer定义和一些默认值以供使用。权重和可见性当然可以被用户覆盖,每个节点包各不相同。
接下来,我们需要回到我们的hook_entity_view()实现并做一些修改。因为我们知道我们只想将此应用于节点实体,所以我们可以实现更具体的钩子:
/**
* Implements hook_ENTITY_TYPE_view().
*/
function module_name_node_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
if ($display->getComponent('disclaimer')) {
$build['disclaimer'] = [
'#markup' => t('The content provided is for general information purposes only.'),
];
}
}
在这种情况下,我们不需要检查视图模式或实体类型,而是使用实体视图显示配置对象来检查是否存在这个额外的disclaimer字段(技术上称为组件)。如果找到,我们只需将我们的标记添加到$build数组中。Drupal 将负责处理诸如权重和可见性等方面的事情,以匹配用户通过 UI 设置的任何内容,就这样了。清除缓存后,我们应该仍然看到我们的免责声明消息,但现在我们可以从 UI 中稍微控制它。
实体验证
在本章的最后,我们将讨论实体验证以及我们如何确保字段和实体数据作为一个整体包含有效数据。当我提到有效时,我并不是指它是否遵守严格的 TypedData 定义,而是在那个定义中,它是否遵守我们对其施加的某些限制(约束)。因此,大多数情况下,实体验证适用于内容实体。然而,我们也可以对配置实体进行验证,但仅限于确保字段值符合配置模式中描述的正确数据类型。在这方面,我们谈论的是底层的 TypedData 定义。
Drupal 8 使用 Symfony 验证器组件来应用约束,然后验证实体、字段和任何其他数据是否与这些约束相符。我确实建议你查看有关此组件的 Symfony 文档页面,以更好地理解其原理。现在,让我们快速看看它在 Drupal 8 中的应用。
验证有三个主要部分:约束插件、验证器类和潜在的违规。第一个主要负责定义它可以应用于哪种类型的数据,它应该显示的错误信息,以及哪个验证器类负责验证它。如果它省略了后者,验证器类名称将默认为约束类名称,并在其后附加 Validator 字样。另一方面,验证器是由验证服务调用来验证约束并构建违规列表的。最后,违规是提供有关验证中发生错误的有用信息的数据对象:例如约束的错误信息、违规值以及失败属性的路径。
为了更好地理解这些内容,我们必须回到 TypedData 并查看一些简单的示例,因为验证是在这个级别发生的。
因此,让我们看看我在本章前面介绍的与 TypedData 相同的示例:
$definition = DataDefinition::create('string');
$definition->addConstraint('Length', ['max' => 20]);
数据定义有应用和读取约束的方法。如果你还记得,我们需要这个 API 的一个原因是为了能够用元信息丰富数据。约束就是这样的信息。在这个例子中,我们正在应用一个名为 Length(约束的插件 ID)的约束,并使用该约束期望的一些任意参数(在这种情况下是一个最大长度,但也可以使用最小长度)。应用了这个约束之后,我们本质上是在说,这段字符串数据只有在它的长度小于 20 个字符时才是有效的。我们可以这样使用它:
/** @var \Drupal\Core\TypedData\TypedDataInterface $data */
$data = \Drupal::typedDataManager()->create($definition, 'my value that is too long');
$violations = $data->validate();
DataType 插件在其上有一个 validate() 方法,它使用验证服务来验证其底层数据定义与对其应用的任何约束。结果是 ConstraintViolationList 迭代器的一个实例,它包含每个验证失败的一个 ConstraintViolationInterface 实例。在这个例子中,我们应该有一个违规项,我们可以从中获取一些信息,如下所示:
/** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
foreach ($violations as $violation) {
$message = $violation->getMessage();
$value = $violation->getInvalidValue();
$path = $violation->getPropertyPath();
}
$message 是来自失败约束的错误信息,$value 是实际的不正确值,而 $path 是表示到失败值所在层级路径的字符串表示。如果你还记得我们的车牌示例或内容实体字段,TypedData 可以嵌套,这意味着你可以在不同级别拥有各种值。在我们的上一个例子中,$path 将会是 ""(一个空字符串),因为数据定义只有一个级别。
让我们回顾一下我们的车牌例子,看看这样的约束在那里是如何工作的。想象一下,我们想要向状态码定义添加一个类似的约束:
$state_code_definition = DataDefinition::create('string');
$state_code_definition->addConstraint('Length', array('max' => 2));
// The rest of the set up code we saw earlier.
/** @var Map $plate */
$plate = \Drupal::typedDataManager()->create($plate_definition, ['state' => 'NYC', 'number' => '405-307']);
$violations = $plate->validate();
如果你仔细观察,我用一个超过两个字符的状态码实例化了该盘子。现在,如果我们为单个违规行为请求属性路径,我们会得到state,因为这是我们在大地图定义中称呼状态定义属性的内容。
内容实体
现在我们来看一个验证实体约束的例子。首先,我们可以在整个实体上运行validate()方法,然后它将使用其 TypedData 包装器(EntityAdapter)对实体上的所有字段以及任何实体级别的约束进行验证。后者可以通过EntityType插件定义(注解)添加。例如,评论实体类型有如下内容:
* constraints = {
* "CommentName" = {}
* }
这意味着约束插件 ID 是CommentName,它不接受任何参数(因为括号是空的)。我们甚至可以通过实现hook_entity_type_alter()将约束添加到不属于我们的实体类型,例如:
function my_module_entity_type_alter(array &$entity_types) {
$node = $entity_types['node'];
$node->addConstraint('ConstraintPluginID', ['option']);
}
下一级,我们知道内容实体字段是基于 TypedData API 构建的,因此所有这些级别都可以有约束。我们可以将约束常规地添加到字段定义中,或者,在字段不是“我们自己的”或可配置字段的情况下,我们可以使用钩子来添加约束。使用hook_entity_base_field_info_alter()我们可以添加到基本字段上的约束,而使用hook_entity_bundle_field_info_alter()我们可以添加到可配置字段(以及重写的基字段)上的约束。让我们看看如何将约束添加到节点 ID 字段的例子:
function my_module_entity_base_field_info_alter(&$fields, EntityTypeInterface $entity_type) {
if ($entity_type->id() === 'node') {
$nid = $fields['nid'];
$nid->addPropertyConstraints('value', ['Range' => ['mn' => 5, 'max' => 10]]);
}
}
如您所见,我们仍在处理数据定义。然而,值得注意的是,当涉及到基本字段和可配置字段(它们是项目列表)时,我们也有addPropertyConstraints()方法可用。这仅仅确保我们添加的任何约束都是针对列表中的实际项目(指定哪个属性),而不是整个列表,如果我们使用主要的addConstraint() API,就会发生这种情况。这个方法与另一个区别是,约束被包装进ComplexDataConstraint插件中。然而,你不必过于担心这一点;只需在你看到它时意识到即可。
我们甚至可以检查数据定义对象上找到的约束。例如,这是读取节点 ID 字段上找到的约束的方法:
$nid = $node->get('nid');
$constraints = $nid->getConstraints();
$item_constraints = $nid->getItemDefinition()->getConstraints();
其中getConstraints()方法返回一个约束插件实例数组。
现在我们来看一下我们如何验证实体:
$node_violations = $node->validate();
$nid = $node->get('nid');
$nid_list_violations = $nid->validate();
$nid_item_violations = $nid->get(0)->validate();
实体级别的 validate() 方法返回一个 EntityConstraintViolationList 实例,这是之前提到的 ConstraintViolationList 的一个更具体版本。然而,后者是由上述其他情况的 validate() 方法返回的。但对于所有这些情况,我们内部都有一个 ConstraintViolationInterface 实例的集合,我们可以从中了解一些关于哪些没有通过验证的信息。
实体级别的验证会遍历所有字段并对它们进行验证。接下来,列表将包含列表中任何项目的违规行为,而项目将只包含列表中单个项目的违规行为。属性路径是值得关注的一个点。以下是在上述示例中从三个结果违规列表中找到的违规的 getPropertyPath() 调用结果:
nid.0.value
0.value
value
如你所见,这反映了 TypedData 层次结构。当我们验证整个实体时,它给我们一个属性路径,一直到底部的值:字段名 -> delta(列表中的位置)-> 属性名。一旦我们验证了字段,我们就已经知道我们正在验证哪个字段,所以这部分被省略了。同样,对于单个项目也是如此(我们也知道项目的 delta)。
关于可以按包覆盖的基本字段(如节点标题字段)的警告。如我之前提到的,这些字段的基本定义使用 BaseFieldOverride 实例,这允许通过 UI 对定义进行某些更改。在这方面,它们与可配置字段非常相似。这个问题在于,如果我们尝试像刚才对 nid 应用约束一样,对节点 title 字段应用约束,我们在验证时不会得到任何违规。这是因为验证器在 BaseFieldOverride 定义上执行验证,而不是在 BaseFieldDefinition 上。
这并不是问题,因为我们可以使用 hook_entity_bundle_field_info_alter() 并执行与之前相同的事情,这样就会将约束应用到覆盖的定义上。在这个过程中,我们还可以考虑我们想要应用此约束的包。这与你在 UI 中创建的可配置字段应用约束的方式相同。
配置实体
在数据定义方面,配置实体字段并未暴露给 TypedData API。如果你还记得,我们确实有配置模式,它描述了在实体中认为有效的数据类型。目前,这就是我们可以验证配置实体的范围,因为它们尚未(尚未)暴露给约束验证器系统。
但在我们结束这一章之前,让我们快速看看我们如何验证一个配置实体。以下是一个快速示例:
$config_entity = View::load('content');
$config_entity->set('status', 'not a boolean');
$typed_config_entity = ConfigEntityAdapter::createFromEntity($config_entity);
$violations = $typed_config_entity->validate();
我们首先加载一个配置实体。在这种情况下,它是一个视图,但这并不重要,因为它由一个模式定义支持(在views.schema.yml中找到)。默认情况下,实体是有效的,所以在本例中,我将status字段改为字符串(而不是布尔值)。然后,对于实际的验证,我们创建一个新的ConfigEntityAdapter实例(类似于我们之前看到的用于内容实体的EntityAdapter)。现在我们可以像以前一样调用validate()。结果将是一个违规列表,在本例中,将包含一个说我们为status字段使用了不正确的原始值。这就是全部内容。
验证摘要
正如我们所见,Drupal 8 将 Symfony 验证组件应用于其自己的 TypedData 和插件 API,既为了可发现性,也为了数据验证处理。这样做,我们得到了一个低级 API,可以应用于任何类型的数据,从简单的原始数据定义到复杂的实体和字段。我们在这里没有涉及这一点,但如果提供的不够,我们也可以轻松创建自己的约束和验证器。
此外,我们还看到我们也可以将模式验证应用于配置实体。这种功能在 8.6 版本中已经提供。目前正在进行工作,以将配置实体暴露给完整的验证系统。
摘要
你难道以为你永远不会看到这个标题了吗?这一章内容非常长,但非常理论化。我们没有构建任何有趣的东西,我们看到的唯一代码只是为了举例说明我们讨论的大部分内容。这是一个很难的章节,因为它涵盖了数据存储和处理的大量复杂方面。但是相信我,了解这些事情很重要,这一章既可以作为深入挖掘代码的起点,也可以作为在不确定某些方面时的参考。
我们看到了在 Drupal 8 中存储数据的主要选项。从 State API 到实体,你有一系列替代方案。在介绍了更简单的方法,如 State API、私有和共享 tempstores 以及 UserData API 之后,我们更深入地了解了配置系统,这是一个非常重要的系统需要理解。在那里,我们看到了我们有哪些配置类型,如何处理简单的配置,它是如何管理和存储的,等等。最后,在可能是本章最复杂的一部分,我们看了实体,包括内容和配置。正当你从阅读有关实体类型是具有许多选项的插件的全部内容中恢复过来时,我向你介绍了 TypedData API。但就在之后,我们立即将其用于实际操作,并看到了我们如何与实体交互:查询、加载、操作和基于 TypedData 验证数据。
在下一章中,我们将非常实际地应用在这一章中学到的许多知识,特别是与内容实体和配置实体相关,还包括插件类型等等。因此,这应该会更有趣,因为我们将创建一个真正有用的新模块。
第七章:您自己的自定义实体和插件类型
我相信你正在期待应用从上一章中获得的一些知识,做一些实际而有趣的事情。正如承诺的那样,在本章中,我们将这样做。此外,除了实现我们自己的实体类型外,我们还将介绍一些新内容。所以,这就是我们的计划。
前提是我们希望在网站上拥有包含一些基本产品信息的产品,例如 ID、名称和产品编号。然而,这些产品需要以某种方式出现在我们的网站上。一种方式是手动输入。另一种,更重要的一种方式是通过从多个外部来源(如 JSON 端点)导入。现在,事情将保持简单。从所有目的和意图来看,这些产品不会做太多,所以不要期待为你提供一个电子商务解决方案。相反,我们将练习在 Drupal 8 中建模数据和功能。
首先,我们将创建一个简单的内容实体类型来表示我们的产品。在这样做的时候,我们将确保通过利用许多现成的实体 API 优势,我们可以轻松地使用 UI 创建、编辑和删除这些产品。
其次,我们将建模我们的导入功能。一枚硬币的一面将是一个简单的配置实体类型,用于表示我们各种导入器所需的配置。同样,我们将利用实体 API 进行快速脚手架和实体管理。另一面将是一个自定义插件类型,它将根据在实体中找到的配置实际执行导入。因此,这些将链接到配置实体的方向,配置实体将选择使用一个插件或另一个。
所以,这些都是重点。在构建所有这些时,我们将看到定义内容、配置实体类型以及用于封装逻辑的插件类型所需的大部分内容,这些类型具有用于存储数据和配置的字段。在定义这些内容时,我们将采取手动、更繁琐的路线,以确保我们理解每个组件的作用,并且对我们所做的事情感到舒适。一旦你了解了所有这些,你将能够使用 Drupal Console 自动生成大量样板代码来大大加快这些过程。
本章中我们编写的代码将放入一个名为 products 的新模块中。由于我们已经学会了从头创建模块的方法,因此我将不会涵盖启动该模块所需的初始步骤。
自定义内容实体类型
如前一章所见,当查看节点和节点类型实体类型时,实体类型定义属于我们模块命名空间中的 Entity 文件夹。在那里,我们将创建一个名为 Product 的类,该类顶部将有一个注解来告诉 Drupal 这是一个内容实体类型。这是定义新实体类型最重要的部分:
namespace Drupal\products\Entity;
use Drupal\Core\Entity\ContentEntityBase;
/**
* Defines the Product entity.
*
* @ContentEntityType(
* id = "product",
* label = @Translation("Product"),
* handlers = {
* "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
* "list_builder" = "Drupal\products\ProductListBuilder",
*
* "form" = {
* "default" = "Drupal\products\Form\ProductForm",
* "add" = "Drupal\products\Form\ProductForm",
* "edit" = "Drupal\products\Form\ProductForm",
* "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm",
* },
* "route_provider" = {
* "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider"
* }
* },
* base_table = "product",
* admin_permission = "administer site configuration",
* entity_keys = {
* "id" = "id",
* "label" = "name",
* "uuid" = "uuid",
* },
* links = {
* "canonical" = "/admin/structure/product/{product}",
* "add-form" = "/admin/structure/product/add",
* "edit-form" = "/admin/structure/product/{product}/edit",
* "delete-form" = "/admin/structure/product/{product}/delete",
* "collection" = "/admin/structure/product",
* }
* )
*/
class Product extends ContentEntityBase implements ProductInterface {}
在上面的代码块中,我省略了类的实际内容,首先关注注解和一些其他方面。我们很快就会看到其余的部分。然而,整个工作代码可以在附带的存储库中找到。
如果您还记得上一章的内容,我们有ContentEntityType注解和实体类型插件定义。与 Node 等相比,我们的示例相对简单,因为我想保持事情简单。它没有捆绑包,也不可修订,也不可翻译。此外,对于其中的一些处理器,我们回退到实体 API 默认值。
实体类型 ID 和标签立即可见,因此无需解释;我们可以直接跳到“处理器”部分。
对于视图构建器处理器,我们选择默认使用基本的EntityViewBuilder,因为没有我们产品需要渲染的特定内容。很多时候,这已经足够了,但您也可以扩展这个类并创建自己的。
对于列表构建器,尽管我们仍然保持简单,但我们需要自己的实现来处理列表标题等问题。我们很快就会看到这个类。创建和编辑产品的表单处理器是我们模块Form命名空间中的自定义实现,我们很快就会看到它以获得更好的理解。尽管如此,我们仍然依赖 Drupal 8 来帮助我们处理删除表单。
最后,对于路由提供者,我们使用了默认的AdminHtmlRouteProvider,它负责管理在管理 UI 中管理实体类型所需的所有路由。这意味着我们不再需要为注释中links部分的链接进行路由。说到链接,将它们放在我们示例的admin/structure部分下是有意义的,但您可以选择其他位置。
我们产品将存储的数据库表是products,用户管理它们的权限是administer site configuration。我故意省略了创建特定于此实体类型的权限,因为我们将在一个专门讨论访问的章节中介绍这个话题。所以我们将使用随 Drupal 核心提供的这个权限。
最后,我们还有一些基本的实体键可以映射到相应的字段。
我们的Product类扩展了ContentEntityBase类,以从 API 继承所有必要的功能,并实现了我们自己的ProductInterface,它将包含所有用于访问相关字段值的方法。让我们快速在这个Entity文件夹中创建这个接口:
namespace Drupal\products\Entity;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityChangedInterface;
/**
* Represents a Product entity.
*/
interface ProductInterface extends ContentEntityInterface, EntityChangedInterface {
/**
* Gets the Product name.
*
* @return string
*/
public function getName();
/**
* Sets the Product name.
*
* @param string $name
*
* @return \Drupal\products\Entity\ProductInterface
* The called Product entity.
*/
public function setName($name);
/**
* Gets the Product number.
*
* @return int
*/
public function getProductNumber();
/**
* Sets the Product number.
*
* @param int $number
*
* @return \Drupal\products\Entity\ProductInterface
* The called Product entity.
*/
public function setProductNumber($number);
/**
* Gets the Product remote ID.
*
* @return string
*/
public function getRemoteId();
/**
* Sets the Product remote ID.
*
* @param string $id
*
* @return \Drupal\products\Entity\ProductInterface
* The called Product entity.
*/
public function setRemoteId($id);
/**
* Gets the Product source.
*
* @return string
*/
public function getSource();
/**
* Sets the Product source.
*
* @param string $source
*
* @return \Drupal\products\Entity\ProductInterface
* The called Product entity.
*/
public function setSource($source);
/**
* Gets the Product creation timestamp.
*
* @return int
*/
public function getCreatedTime();
/**
* Sets the Product creation timestamp.
*
* @param int $timestamp
*
* @return \Drupal\products\Entity\ProductInterface
* The called Product entity.
*/
public function setCreatedTime($timestamp);
}
如您所见,我们正在扩展必填的ContentEntityInterface,同时也扩展了EntityChangedInterface,后者提供了一些方便的方法来管理实体的最后更改日期。这些方法实现将通过EntityChangedTrait添加到我们的Product类中:
use EntityChangedTrait;
ProductInterface 上的方法相对比较直观。我们将有一个产品名称、编号、远程 ID 和来源字段,因此为这些字段提供 getter 和 setter 方法是很不错的。如果你还记得,实体 API 提供了get()和set()方法,我们可以通过这些方法一致地访问和存储所有实体类型的字段值。然而,我发现使用具有明确定义方法的接口可以使代码更加清晰,更不用说 IDE 自动补全是一个节省时间的伟大功能了。我们还有一个created日期字段的 getter 和 setter,这是一个内容实体通常具有的典型字段。
现在,我们可以看看我们的Product实体类型的baseFieldDefinitions()方法,看看我们实际上是如何定义我们的字段的:
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields['name'] = BaseFieldDefinition::create('string')
->setLabel(t('Name'))
->setDescription(t('The name of the Product.'))
->setSettings([
'max_length' => 255,
'text_processing' => 0,
])
->setDefaultValue('')
->setDisplayOptions('view', [
'label' => 'hidden',
'type' => 'string',
'weight' => -4,
])
->setDisplayOptions('form', [
'type' => 'string_textfield',
'weight' => -4,
])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['number'] = BaseFieldDefinition::create('integer')
->setLabel(t('Number'))
->setDescription(t('The Product number.'))
->setSettings([
'min' => 1,
'max' => 10000
])
->setDefaultValue(NULL)
->setDisplayOptions('view', [
'label' => 'above',
'type' => 'number_unformatted',
'weight' => -4,
])
->setDisplayOptions('form', [
'type' => 'number',
'weight' => -4,
])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['remote_id'] = BaseFieldDefinition::create('string')
->setLabel(t('Remote ID'))
->setDescription(t('The remote ID of the Product.'))
->setSettings([
'max_length' => 255,
'text_processing' => 0,
])
->setDefaultValue('');
$fields['source'] = BaseFieldDefinition::create('string')
->setLabel(t('Source'))
->setDescription(t('The source of the Product.'))
->setSettings([
'max_length' => 255,
'text_processing' => 0,
])
->setDefaultValue('');
$fields['created'] = BaseFieldDefinition::create('created')
->setLabel(t('Created'))
->setDescription(t('The time that the entity was created.'));
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Changed'))
->setDescription(t('The time that the entity was last edited.'));
return $fields;
}
首先,我们需要继承父类的基字段。这包括诸如 ID 和 UUID 字段等。
第二,我们定义自己的字段,从产品名称字段开始,它是string类型。这种string类型不过是我在上一章中提到的FieldType插件。如果你还记得,这个插件本身扩展了TypedData类。除了明显的标签和描述外,它还有一些设置,最值得注意的是值的最大长度,为 255 个字符。view和form显示选项分别引用FieldFormatter和FieldWidget插件,这两个插件与FieldType一起构成了一个字段。最后,通过setDisplayConfigurable(),我们指定这个字段的一些选项可以通过 UI 进行配置。例如,我们可以在 UI 中更改标签。
然后,我们有number字段,它是integer类型,在这个例子中,限制在 1 到 10,000 之间。这个限制设置在底层变成了约束。其余的选项与名称字段类似。
接下来,我们有remote_id字符串字段,但它没有任何小部件或显示设置,因为我们不一定想显示或编辑这个值。它主要用于内部使用,以跟踪来自远程源的产品 ID。同样,source字符串字段也不显示或可配置,因为我们想用它来存储产品的来源,它从哪里导入,以及程序化跟踪它。
最后,created和changed字段是特殊的字段,用于存储实体创建和修改的时间戳。除了这些,不需要做更多的事情,因为这些字段会自动将当前时间戳设置为字段值。
到现在为止,我们也可以看到类内容的其余部分,这主要是ProductInterface所需的方法:
use EntityChangedTrait;
/**
* {@inheritdoc}
*/
public function getName() {
return $this->get('name')->value;
}
/**
* {@inheritdoc}
*/
public function setName($name) {
$this->set('name', $name);
return $this;
}
/**
* {@inheritdoc}
*/
public function getProductNumber() {
return $this->get('number')->value;
}
/**
* {@inheritdoc}
*/
public function setProductNumber($number) {
$this->set('number', $number);
return $this;
}
/**
* {@inheritdoc}
*/
public function getRemoteId() {
return $this->get('remote_id')->value;
}
/**
* {@inheritdoc}
*/
public function setRemoteId($id) {
$this->set('remote_id', $id);
return $this;
}
/**
* {@inheritdoc}
*/
public function getSource() {
return $this->get('source')->value;
}
/**
* {@inheritdoc}
*/
public function setSource($source) {
$this->set('source', $source);
return $this;
}
/**
* {@inheritdoc}
*/
public function getCreatedTime() {
return $this->get('created')->value;
}
/**
* {@inheritdoc}
*/
public function setCreatedTime($timestamp) {
$this->set('created', $timestamp);
return $this;
}
正如我们所承诺的,我们正在使用EntityChangedTrait来处理changed字段,并为我们在基础字段中定义的字段值实现简单的 getter 和 setter。如果你还记得TypedData部分,我们访问值的方式(因为这些字段的基数始终为 1)是通过运行以下命令:
$this->get('field_name')->value
在我们完成产品实体类的编写之前,让我们确保使用顶部剩余的所有类:
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
现在让我们通过实体类型插件注解来遍历,并创建我们在此处引用的处理程序。我们可以从列表构建器开始,我们可以将其放置在我们的命名空间根目录下:
namespace Drupal\products;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\Link;
use Drupal\Core\Url;
/**
* EntityListBuilderInterface implementation responsible for the Product entities.
*/
class ProductListBuilder extends EntityListBuilder {
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['id'] = $this->t('Product ID');
$header['name'] = $this->t('Name');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
/* @var $entity \Drupal\products\Entity\Product */
$row['id'] = $entity->id();
$row['name'] = Link::fromTextAndUrl(
$entity->label(),
new Url(
'entity.product.canonical', [
'product' => $entity->id(),
]
)
);
return $row + parent::buildRow($entity);
}
}
此处理器的目的是构建一个列出可用实体的管理页面。在这个页面上,我们将了解一些关于它们的信息,以及编辑、删除和其他可能需要的操作链接。对于我们的产品,我们简单地从默认的EntityListBuilder类扩展,但覆盖了buildHeader()和buildRow()方法来添加一些特定于我们产品的信息。这些方法的名称是自解释的,但需要注意的一点是,我们从$header数组返回的键需要与从$row数组返回的键匹配。当然,数组需要具有相同数量的记录,以便表头与单个行匹配。如果你查看EntityListBuilder内部,你可以注意一些你可能想要覆盖的其他实用方法,例如构建查询和加载实体的方法。对我们来说,这已经足够了。
目前,我们的产品列表构建器将只有两列:ID 和名称。对于后者,每一行实际上都是一个链接到产品规范 URL(该实体在 Drupal 中的主要 URL)。最后,你还记得从第二章中,创建您的第一个模块,如何使用Link类构建链接吗?
实体规范路由的构造格式为:entity.[entity_type].canonical。其他有用的实体链接可以通过将links定义中实体类型插件注解的键替换为canonical来构建。
对于列表构建器来说,这就差不多了,我们可以继续到表单处理器。由于创建和编辑实体在表单需求方面有很多相似之处,我们使用相同的ProductForm来处理这两个操作。现在让我们在模块命名空间的Form目录中创建这个表单类:
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Form\FormStateInterface;
/**
* Form for creating/editing Product entities.
*/
class ProductForm extends ContentEntityForm {
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$entity = &$this->entity;
$status = parent::save($form, $form_state);
switch ($status) {
case SAVED_NEW:
drupal_set_message($this->t('Created the %label Product.', [
'%label' => $entity->label(),
]));
break;
default:
drupal_set_message($this->t('Saved the %label Product.', [
'%label' => $entity->label(),
]));
}
$form_state->setRedirect('entity.product.canonical', ['product' => $entity->id()]);
}
}
我们扩展了ContentEntityForm,这是一个专门用于内容实体的表单类。它本身扩展了EntityForm,而EntityForm又继承了我们在第二章中遇到的FormBase。然而,前两个类为我们提供了许多管理实体所需的功能,而无需我们编写太多代码。
我们实际上想要做的只是覆盖save()方法,以便向用户发送消息,告知他们产品已被创建或更新。我们知道发生了什么,因为EntityInterface::save()方法返回一个特定的常量来表示发生的操作类型。
当保存发生时,我们还想重定向到产品实体的规范 URL。我们通过FormStateInterface对象上的一个非常方便的方法来完成这个操作,我们可以指定一个路由(以及任何必要的参数),并且它将确保当表单提交时,用户将被重定向到该路由。这不是很酷吗?
您可以看到我们使用了已弃用的drupal_set_message()全局函数来向用户打印消息。我故意这样做是为了让事情变得简单。然而,正如我们在第二章,“创建您的第一个模块”中看到的,您应该注入Messenger服务并使用它。如果您不确定如何注入服务,请参阅该章节以回顾如何注入服务。
正如我提到的,对于删除操作,我们只需使用ContentEntityDeleteForm,它就完成了我们所需的所有工作:它呈现一个确认表单,我们提交并触发删除操作。这是在 Drupal 中删除资源的典型流程。正如我们将在稍后看到,对于配置实体,我们将需要编写一些自己的方法来完成相同的过程。
我们的所有处理程序都已完成,我们的产品实体类型现在是可操作的。然而,为了能够使用它,让我们在管理菜单中创建一些链接,以便能够轻松地管理它们。
首先,创建products.links.menu.yml文件:
# Product entity menu items
entity.product.collection:
title: 'Product list'
route_name: entity.product.collection
description: 'List Product entities'
parent: system.admin_structure
weight: 100
这定义了一个位于产品列表(使用我们的列表构建器处理程序构建的页面)结构链接下的菜单链接。
接下来,让我们创建一些本地任务(标签页),以便我们在产品页面上获得方便的链接来编辑和删除产品实体。因此,在products.links.task.yml文件中:
# Product entity task items
entity.product.canonical:
route_name: entity.product.canonical
base_route: entity.product.canonical
title: 'View'
entity.product.edit_form:
route_name: entity.product.edit_form
base_route: entity.product.canonical
title: 'Edit'
entity.product.delete_form:
route_name: entity.product.delete_form
base_route: entity.product.canonical
title: Delete
weight: 10
您还记得第五章,“菜单和菜单链接”,不是吗?基本路由始终是实体的规范路由,这本质上将标签分组在一起。然后,我们用于其他两个任务的路由是实体类型的edit_form和delete_form链接。您可以参考实体类型插件注释中的links部分来了解这些链接的来源。我们之所以不需要在这里指定任何参数(因为这些路由确实需要产品 ID),是因为基本路由已经在 URL 中包含了该参数。因此,任务将使用该参数。这非常方便。
最后,我们还想在产品列表页面上添加一个创建新产品实体的操作链接。因此,在products.links.action.yml文件中:
entity.product.add_form:
route_name: entity.product.add_form
title: 'Add Product'
appears_on:
- entity.product.collection
再次强调,这些内容不应该陌生,因为我们已经在第五章中详细介绍了,菜单和菜单链接。我们终于完成了。
如果在编写所有实体代码之前您的网站上启用了products模块,您需要运行drush entity-updates命令,以便在数据库中创建所有必要的表。否则,安装模块将自动完成此操作。然而,请注意,当您添加新的内容实体类型和字段或更改实体类型上的现有字段时,需要记住第一点。底层存储可能需要更改以适应您的修改。此外,还需要注意的是,在某些情况下,更改已包含数据的字段可能不符合 Drupal 的要求,并阻止您进行这些更改。因此,您可能需要删除现有实体。
在撰写本文时,entity-update Drush 命令正在被淘汰,转而使用更新钩子来更新实体。请参阅Drupal.org上的相关变更记录。这意味着当您尝试使用该命令时,它可能不再有效。如果是这种情况,请查看变更记录以获取有关在开发期间可以在哪个贡献模块中找到此命令的信息。
现在我们已经完成了这些,我们可以去admin/structure/product查看我们的(空)产品实体列表:

我们现在可以创建新产品,编辑它们,最后,删除它们。记住,由于我们的字段配置,手动创建/编辑产品不允许管理remote_id和source字段。对于我们的目的,我们希望这些字段只能通过程序访问,因为任何手动创建的产品都将被视为不需要这些数据。例如,如果我们想使源字段以表单小部件的形式显示,我们只需要将其基本字段定义更改为以下内容:
$fields['source'] = BaseFieldDefinition::create('string')
->setLabel(t('Source'))
->setDescription(t('The source of the Product.'))
->setSettings([
'max_length' => 255,
'text_processing' => 0,
])
->setDefaultValue('')
->setDisplayOptions('form', [
'type' => 'string_textfield',
'weight' => -4,
]);
此外,我们还需要清除缓存。这将使源字段的表单元素显示出来,但值仍然不会在实体的规范页面上显示,因为我们没有设置任何view显示选项。换句话说,我们还没有选择一个格式化器。
然而,在我们的情况下,产品实体已经准备好存储数据,我们在上一章中与节点实体类型一起练习的所有 TypedData API 都将与这个实体一样正常工作。因此,我们现在可以转向编写我们的导入逻辑,将一些远程产品导入到我们的网站上。
自定义插件类型
由于这本书的几乎第二页都在讲述插件的重要性以及它们在 Drupal 8 中的广泛使用。我通过在基本上每一章中引用“这个或那个”是插件来支持这个说法。然而,我并没有真正解释如何创建自己的自定义插件类型。然而,由于我们的导入逻辑是插件的完美候选者,我将在这里这样做,并且为了说明理论,我们将实现一个Importer插件类型。
插件类型最需要的是管理服务。它负责将插件的两个关键方面(但不仅限于此)结合起来:发现和工厂(实例化)。对于这两个任务,它委托给专门的对象。最常见的方法是通过注解(AnnotatedClassDiscovery),最常见的工厂是容器感知的——ContainerFactory。所以,本质上,管理器是中央玩家,它找到并处理所有插件定义并实例化插件。此外,它还借助那些其他人的帮助来完成这些工作。
Drupal 8 中许多插件类型,因为它们遵循我之前提到的默认设置,使用DefaultPluginManager,或者说,它们扩展了这个类。它为它们提供了注解发现和容器感知的工厂。所以这就是我们将要做的,并且看看创建插件类型管理器有多简单。
通常,它位于模块的Plugin命名空间中,所以我们的可以看起来像这样:
namespace Drupal\products\Plugin;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
/**
* Provides the Importer plugin manager.
*/
class ImporterManager extends DefaultPluginManager {
/**
* ImporterManager constructor.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Importer', $namespaces, $module_handler, 'Drupal\products\Plugin\ImporterInterface', 'Drupal\products\Annotation\Importer');
$this->alterInfo('products_importer_info');
$this->setCacheBackend($cache_backend, 'products_importer_plugins');
}
}
除了扩展DefaultPluginManager之外,我们还需要重写构造函数并使用一些特定于我们插件的参数重新调用父构造函数。这是最重要的部分,按照顺序,以下是这样(省略了那些只是传递的):
-
这种类型的插件将被找到的相对命名空间——在这个例子中,是在
Plugin/Importer文件夹中 -
这种类型的每个插件都需要实现接口——在我们的例子中,是
Drupal\products\Plugin\ImporterInterface(我们必须创建它) -
我们插件类型使用的
annotation类(其类属性映射到在插件类上方的 DocBlock 中找到的可能注解属性)——在我们的例子中,是Drupal\products\Annotation\Importer(我们必须创建)
除了使用这些选项调用父构造函数之外,我们还需要提供“alter”钩子,用于可用的定义。这将使其他模块能够实现此钩子并修改找到的插件定义。在我们的情况下,结果是hook_products_importer_info_alter。
最后,我们还为负责缓存插件定义的后端提供了一个特定的缓存键。这是为了提高性能:正如你现在应该已经知道的,创建一个新的插件需要清除缓存。
我们的经理就到这里。然而,由于这是一个服务,我们还需要在products.services.yml文件中将其注册为服务:
services:
products.importer_manager:
class: Drupal\products\Plugin\ImporterManager
parent: default_plugin_manager
如您所见,我们继承自default_plugin_manager服务中的依赖(参数),而不是在这里再次复制它们。如果您还记得第三章中的内容,日志和邮件,这是 Drupal 8 中的一个巧妙的小技巧。
现在,由于我们在管理器中引用了一些类,我们需要创建它们。让我们从注解类开始:
namespace Drupal\products\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines an Importer item annotation object.
*
* @see \Drupal\products\Plugin\ImporterManager
*
* @Annotation
*/
class Importer extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The label of the plugin.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $label;
}
这个类需要扩展Drupal\Component\Annotation\Plugin,这是注解的基类,并且已经实现了AnnotationInterface。
对于我们的目的,我们保持简单。我们需要的只是一个插件 ID 和一个标签。如果我们愿意,我们可以向这个类添加更多属性并描述它们。这样做是一个标准实践,因为否则就没有明确的方式来知道插件注解可以包含哪些属性。
接下来,让我们也编写插件必须实现的接口:
namespace Drupal\products\Plugin;
use Drupal\Component\Plugin\PluginInspectionInterface;
/**
* Defines an interface for Importer plugins.
*/
interface ImporterInterface extends PluginInspectionInterface {
/**
* Performs the import. Returns TRUE if the import was successful or FALSE otherwise.
*
* @return bool
*/
public function import();
}
同样,我们保持简单。目前,我们的导入器将只有一个特定的方法:import()。然而,它将会有其他特定的插件方法,这些方法可以在我们扩展的PluginInspectionInterface中找到。这些是getPluginId()和getPluginDefinition(),它们也非常重要,因为系统期望能够从插件中获取这些信息。
接下来,任何类型的插件都需要扩展PluginBase,因为它包含了一系列必须实现的方法(例如我之前提到的那些)。然而,对于引入插件类型的模块来说,提供一个插件基类供插件扩展也是一个最佳实践。它的目标是扩展PluginBase,并提供所有此类插件所需的所有必要逻辑。例如,当我们创建一个新的块时,我们扩展BlockBase,而BlockBase在某个地方会扩展PluginBase。
在我们的情况下,这个基类(抽象)可以看起来像这样:
namespace Drupal\products\Plugin;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Component\Plugin\PluginBase;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\products\Entity\ImporterInterface;
use Drupal\products\Plugin\ImporterInterface as ImporterPluginInterface;
use GuzzleHttp\Client;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for Importer plugins.
*/
abstract class ImporterBase extends PluginBase implements ImporterPluginInterface, ContainerFactoryPluginInterface {
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* @var \GuzzleHttp\Client
*/
protected $httpClient;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManager $entityTypeManager, Client $httpClient) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entityTypeManager;
$this->httpClient = $httpClient;
if (!isset($configuration['config'])) {
throw new PluginException('Missing Importer configuration.');
}
if (!$configuration['config'] instanceof ImporterInterface) {
throw new PluginException('Wrong Importer configuration.');
}
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('http_client')
);
}
}
我们实现ImporterInterface(重命名以避免冲突)来要求子类必须有一个import()方法。然而,我们也使插件容器知道,并已经注入了一些有用的服务。一个是EntityTypeManager,因为我们预计所有导入器都需要它。另一个是我们用于 Drupal 8 中向外部资源发起 PSR-7 请求的 Guzzle HTTP 客户端。
在这里添加它是一个判断性的选择。我们可以想象不止一个插件需要外部请求,但如果最终证明它们不需要,我们当然应该将其从插件中移除,并仅在特定插件中添加它。相反的情况也成立。如果在第三个插件实现中我们识别出另一个通用服务,我们可以将其从插件中移除,并在这里注入它。同时,我们还要注意向后兼容性。
在讨论我们在构造函数中抛出的那些异常之前,了解插件管理器如何创建插件的新实例是很重要的。它使用其createInstance()方法,该方法将插件 ID 作为第一个参数,将插件配置的可选数组作为第二个参数。相关的工厂然后将该配置数组传递给插件构造函数本身作为第二个参数。通常情况下,这是空的。然而,对于我们的插件类型,我们需要将配置以配置实体(我们接下来必须创建)的形式传递给插件。如果没有这样的实体,我们希望插件失败,因为它们没有这个实体中找到的说明就无法工作。因此,在构造函数中,我们检查$configuration['config']是否是Drupal\products\Entity\ImporterInterface的实例,这将是我们配置实体将要实现的接口。如果不是,我们抛出异常,因为此插件没有它无法工作。
我们现在完成了插件类型的创建。显然,我们还没有任何插件,在我们创建一个之前,让我们首先创建配置实体类型。
自定义配置实体类型
如果你还记得上一章中的NodeType,你就知道创建自定义配置实体类型的基本要素。因此,让我们现在创建我们的Importer类型。像之前一样,我们从注解部分开始,这次是ConfigEntityType:
namespace Drupal\products\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
/**
* Defines the Importer entity.
*
* @ConfigEntityType(
* id = "importer",
* label = @Translation("Importer"),
* handlers = {
* "list_builder" = "Drupal\products\ImporterListBuilder",
* "form" = {
* "add" = "Drupal\products\Form\ImporterForm",
* "edit" = "Drupal\products\Form\ImporterForm",
* "delete" = "Drupal\products\Form\ImporterDeleteForm"
* },
* "route_provider" = {
* "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
* },
* },
* config_prefix = "importer",
* admin_permission = "administer site configuration",
* entity_keys = {
* "id" = "id",
* "label" = "label",
* "uuid" = "uuid"
* },
* links = {
* "add-form" = "/admin/structure/importer/add",
* "edit-form" = "/admin/structure/importer/{importer}/edit",
* "delete-form" = "/admin/structure/importer/{importer}/delete",
* "collection" = "/admin/structure/importer"
* },
* config_export = {
* "id",
* "label",
* "url",
* "plugin",
* "update_existing",
* "source",
* "bundle"
* }
* )
*/
class Importer extends ConfigEntityBase implements ImporterInterface {}
与Product实体一样,我们需要创建一个列表构建器处理程序,以及表单处理程序。然而,在这种情况下,我们还需要为delete操作创建一个表单处理程序,因为我们很快就会看到原因。最后,由于我们有一个配置实体,我们还指定了用于导出的config_export和config_prefix键。如果你还记得上一章,第一个表示应该持久化的字段名称(我们很快就会看到它们),而第二个表示配置名称在存储时应获得的名称前缀。你会注意到我们没有规范链接,因为我们实际上并不需要——我们的实体不需要详情页面,因此不需要定义指向它的规范链接。
现在,是时候创建实体实现所需的ImporterInterface了。它的名称与之前创建的插件接口相同,但它位于不同的命名空间中:
namespace Drupal\products\Entity;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Url;
/**
* Importer configuration entity.
*/
interface ImporterInterface extends ConfigEntityInterface {
/**
* Returns the Url where the import can get the data from.
*
* @return Url
*/
public function getUrl();
/**
* Returns the Importer plugin ID to be used by this importer.
*
* @return string
*/
public function getPluginId();
/**
* Whether or not to update existing products if they have already been imported.
*
* @return bool
*/
public function updateExisting();
/**
* Returns the source of the products.
*
* @return string
*/
public function getSource();
}
在这些配置实体中,我们目前想要存储的是可以从中检索产品的资源 URL、要使用的导入器插件 ID、是否希望更新已导入的现有产品,以及产品的来源。对于所有这些字段,我们创建了一些获取方法。你会注意到getUrl()需要返回一个Url实例。同样,我们为实体类型的公共 API 创建了一个定义良好的接口,就像我们为产品实体类型所做的那样。
这是实现此接口的Importer类体:
/**
* The Importer ID.
*
* @var string
*/
protected $id;
/**
* The Importer label.
*
* @var string
*/
protected $label;
/**
* The URL from where the import file can be retrieved.
*
* @var string
*/
protected $url;
/**
* The plugin ID of the plugin to be used for processing this import.
*
* @var string
*/
protected $plugin;
/**
* Whether or not to update existing products if they have already been imported.
*
* @var bool
*/
protected $update_existing = TRUE;
/**
* The source of the products.
*
* @var string
*/
protected $source;
/**
* {@inheritdoc}
*/
public function getUrl() {
return $this->url ? Url::fromUri($this->url) : NULL;
}
/**
* {@inheritdoc}
*/
public function getPluginId() {
return $this->plugin;
}
/**
* {@inheritdoc}
*/
public function updateExisting() {
return $this->update_existing;
}
/**
* {@inheritdoc}
*/
public function getSource() {
return $this->source;
}
如果你记得上一章的内容,定义配置实体类型的字段就像在类本身上定义属性一样简单。此外,你可能还记得注解上的config_export键,它列出了哪些属性需要导出和持久化。我们省略了它,因为我们将简单地依赖于配置方案(我们很快就会创建)。最后,实现接口方法,这并不涉及任何火箭科学。正如预期的那样,getUrl()将尝试从值中创建一个Url实例。
我们不要忘记在顶部添加它的use语句:
use Drupal\Core\Url;
由于我们讨论了配置模式,让我们也定义一下。如果你记得,它位于我们模块的config/schema文件夹中,在一个*.schema.yml文件中。这可以以模块的名称命名,包含模块中所有配置的方案定义。或者,它可以以单个配置实体类型的名称命名,在我们的情况下是importer.schema.yml(以保持事情整洁有序):
products.importer.*:
type: config_entity
label: 'Importer config'
mapping:
id:
type: string
label: 'ID'
label:
type: label
label: 'Label'
uuid:
type: string
url:
type: uri
label: Uri
plugin:
type: string
label: Plugin ID
update_existing:
type: boolean
label: Whether to update existing products
source:
type: string
label: The source of the products
如果你记得,通配符用于将模式应用于所有匹配前缀的配置项。因此,在我们的情况下,它将匹配所有importer配置实体。接下来,我们有config_entity模式,它映射了我们定义的字段。除了每个实体类型都有的默认字段外,我们还使用了一个uri、string和boolean模式类型(在底层映射到相应的TypedData数据类型插件)。这个模式现在帮助系统理解我们的实体。
现在,让我们继续创建列表处理程序,它将负责管理实体列表:
namespace Drupal\products;
use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides a listing of Importer entities.
*/
class ImporterListBuilder extends ConfigEntityListBuilder {
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Importer');
$header['id'] = $this->t('Machine name');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
$row['label'] = $entity->label();
$row['id'] = $entity->id();
return $row + parent::buildRow($entity);
}
}
这次我们扩展了ConfigEntityListBuilder,它提供了一些特定于配置实体的功能。然而,我们基本上与产品列表做的是同样的事情——设置表头和单个行数据,没有太大的不同。我建议你检查ConfigEntityListBuilder,看看你可以在子类中做些什么。
现在,我们终于可以处理表单处理程序,并开始使用默认的创建/编辑表单:
namespace Drupal\products\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Url;
use Drupal\products\Plugin\ImporterManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form for creating/editing Importer entities.
*/
class ImporterForm extends EntityForm {
/**
* @var \Drupal\products\Plugin\ImporterManager
*/
protected $importerManager;
/**
* ImporterForm constructor.
*
* @param \Drupal\products\Plugin\ImporterManager $importerManager
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
*/
public function __construct(ImporterManager $importerManager, MessengerInterface $messenger) {
$this->importerManager = $importerManager;
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('products.importer_manager'),
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/** @var \Drupal\products\Entity\Importer $importer */
$importer = $this->entity;
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Name'),
'#maxlength' => 255,
'#default_value' => $importer->label(),
'#description' => $this->t('Name of the Importer.'),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $importer->id(),
'#machine_name' => [
'exists' => '\Drupal\products\Entity\Importer::load',
],
'#disabled' => !$importer->isNew(),
];
$form['url'] = [
'#type' => 'url',
'#default_value' => $importer->getUrl() instanceof Url ? $importer->getUrl()->toString() : '',
'#title' => $this->t('Url'),
'#description' => $this->t('The URL to the import resource'),
'#required' => TRUE,
];
$definitions = $this->importerManager->getDefinitions();
$options = [];
foreach ($definitions as $id => $definition) {
$options[$id] = $definition['label'];
}
$form['plugin'] = [
'#type' => 'select',
'#title' => $this->t('Plugin'),
'#default_value' => $importer->getPluginId(),
'#options' => $options,
'#description' => $this->t('The plugin to be used with this importer.'),
'#required' => TRUE,
];
$form['update_existing'] = [
'#type' => 'checkbox',
'#title' => $this->t('Update existing'),
'#description' => $this->t('Whether to update existing products if already imported.'),
'#default_value' => $importer->updateExisting(),
];
$form['source'] = [
'#type' => 'textfield',
'#title' => $this->t('Source'),
'#description' => $this->t('The source of the products.'),
'#default_value' => $importer->getSource(),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\products\Entity\Importer $importer */
$importer = $this->entity;
$status = $importer->save();
switch ($status) {
case SAVED_NEW:
$this->messenger->addMessage($this->t('Created the %label Importer.', [
'%label' => $importer->label(),
]));
break;
default:
$this->messenger->addMessage($this->t('Saved the %label Importer.', [
'%label' => $importer->label(),
]));
}
$form_state->setRedirectUrl($importer->toUrl('collection'));
}
}
在这种情况下,我们直接扩展了EntityForm,因为配置实体没有像内容实体那样的特定表单类。因此,我们还需要在form()方法中实现所有字段的表单元素。
但首先,我们知道我们希望配置实体选择一个插件来使用,因此,出于这个原因,我们注入了我们之前创建的ImporterManager。我们将使用它来获取所有现有的定义。我们还注入了Messenger服务,以便稍后将其用于向用户打印消息。
在form()方法内部,我们定义了所有字段的表单元素。我们使用textfield来设置标签,使用machine_name字段来设置实体的 ID。后者是一个特殊的由 JavaScript 驱动的字段,它从“源”字段(如果没有指定,则默认为label字段)获取其值。如果我们正在编辑表单,它也会被禁用,并使用动态回调尝试通过提供的 ID 加载实体,如果存在则验证失败。这有助于确保 ID 不会重复。接下来,我们有一个url表单元素,它执行一些 URL 特定的验证和处理,以确保添加了正确的 URL。然后,我们创建一个包含所有可用导入插件定义的select元素选项数组。为此,我们使用插件管理器的getDefinitions(),从中我们可以获取 ID 和标签。插件定义主要包含在注释中找到的数据以及由管理器(在我们的情况下,只有默认值)处理和添加的一些其他数据。在这个阶段,我们的插件尚未实例化。然后,我们在选择列表中使用这些选项。最后,我们有简单的checkbox和textfield元素用于最后两个字段,因为我们想将update_existing字段存储为布尔值,将source字段存储为字符串。
save()方法几乎和产品实体表单中的相同;我们只是显示一条消息并将用户重定向到实体列表页面(使用实体上的方便的toUrl()方法构建 URL)。由于我们命名表单元素与字段完全相同,我们不需要对表单值到字段名称进行映射。这已经由系统处理了。
现在我们来编写删除表单处理器:
namespace Drupal\products\Form;
use Drupal\Core\Entity\EntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form for deleting Importer entities.
*/
class ImporterDeleteForm extends EntityConfirmFormBase {
/**
* ImporterDeleteForm constructor.
*
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
*/
public function __construct(MessengerInterface $messenger) {
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('entity.importer.collection');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->entity->delete();
$this->messenger->addMessage($this->t('Deleted @entity importer.', ['@entity' => $this->entity->label()]));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}
如我之前提到的,对于配置实体,我们需要自己实现这个表单处理器。然而,这并不是什么大问题,因为我们可以扩展EntityConfirmFormBase并仅实现一些简单的方法:
-
在
getQuestion()函数中,我们返回用于确认表单的问题字符串。 -
在
getConfirmText()函数中,我们返回删除按钮的标签。 -
在
getCancelUrl()函数中,我们提供用户在取消或成功删除后的重定向 URL。 -
在
submitForm()函数中,我们删除实体,打印成功消息,并重定向到我们在getCancelUrl()中设置的 URL。
就这样,我们完成了我们的配置实体类型。我们可能还想做的最后一件事是创建一些菜单链接,以便能够导航到相关页面(和我们对产品实体类型所做的一样)。对于实体列表页面,我们可以在我们的products.links.menu.yml文件中这样写:
# Importer entity menu items
entity.importer.collection:
title: 'Importer list'
route_name: entity.importer.collection
description: 'List Importer entities'
parent: system.admin_structure
weight: 99
这里没有什么新内容。我们也可以在products.links.action.yml文件中创建添加新实体的操作链接:
entity.importer.add_form:
route_name: 'entity.importer.add_form'
title: 'Add Importer'
appears_on:
- entity.importer.collection
我们在这里做的和之前对产品所做的相同。然而,我们不会创建本地任务,因为我们没有配置实体的规范路由,所以我们实际上不需要它。
现在,如果我们清除我们的缓存并转到admin/structure/importer,我们应该看到空的导入实体列表:

导入插件
好吧,既然所有设置都已经到位,我们现在可以继续创建我们的第一个导入插件。正如我们在管理器中定义的那样,这些需要放在模块的Plugin/Importer命名空间中。所以,让我们从一个简单的JsonImporter开始,它将使用远程 URL 资源导入产品。这是一个示例 JSON 文件,它将由这个插件处理,仅用于测试目的:
{
"products" : [
{
"id" : 1,
"name": "TV",
"number": 341
},
{
"id" : 2,
"name": "VCR",
"number": 123
},
{
"id" : 3,
"name": "Stereo",
"number": 234
}
]
}
我知道,VCR 对吧?我们有一个 ID,一个名称和一个产品编号。这些都是关于产品的完全虚构的信息,只是为了说明这个过程。所以,让我们创建我们的JsonImporter:
namespace Drupal\products\Plugin\Importer;
use Drupal\products\Plugin\ImporterBase;
/**
* Product importer from a JSON format.
*
* @Importer(
* id = "json",
* label = @Translation("JSON Importer")
* )
*/
class JsonImporter extends ImporterBase {
/**
* {@inheritdoc}
*/
public function import() {
$data = $this->getData();
if (!$data) {
return FALSE;
}
if (!isset($data->products)) {
return FALSE;
}
$products = $data->products;
foreach ($products as $product) {
$this->persistProduct($product);
}
return TRUE;
}
/**
* Loads the product data from the remote URL.
*
* @return \stdClass
*/
private function getData() {
/** @var \Drupal\products\Entity\ImporterInterface $config */
$config = $this->configuration['config'];
$request = $this->httpClient->get($config->getUrl()->toString());
$string = $request->getBody()->getContents();
return json_decode($string);
}
/**
* Saves a Product entity from the remote data.
*
* @param \stdClass $data
*/
private function persistProduct($data) {
/** @var \Drupal\products\Entity\ImporterInterface $config */
$config = $this->configuration['config'];
$existing = $this->entityTypeManager->getStorage('product')->loadByProperties(['remote_id' => $data->id, 'source' => $config->getSource()]);
if (!$existing) {
$values = [
'remote_id' => $data->id,
'source' => $config->getSource()
];
/** @var \Drupal\products\Entity\ProductInterface $product */
$product = $this->entityTypeManager->getStorage('product')->create($values);
$product->setName($data->name);
$product->setProductNumber($data->number);
$product->save();
return;
}
if (!$config->updateExisting()) {
return;
}
/** @var \Drupal\products\Entity\ProductInterface $product */
$product = reset($existing);
$product->setName($data->name);
$product->setProductNumber($data->number);
$product->save();
}
}
你可以立即看到插件注解,我们指定了一个 ID 和一个标签。接下来,通过扩展ImporterBase,我们继承了依赖的服务并确保实现了所需的接口。说到这一点,我们基本上只需要实现import()方法。所以,让我们分解我们在做什么:
-
在
getData()方法内部,我们从远程资源检索产品信息。我们通过从Importer配置实体获取 URL 并使用 Guzzle 向该 URL 发出请求来做到这一点。我们期望它是 JSON 格式,所以我们只需将其解码为这种格式。当然,在这个例子中,错误处理几乎不存在,这是不好的。 -
我们遍历生成的产品数据,并对每个项目调用
persistProduct()方法。在那里,我们首先检查我们是否已经有了产品实体。我们通过在产品实体存储上使用简单的loadByProperties()方法来做到这一点,并尝试找到具有特定来源和远程 ID 的产品。如果不存在,我们就创建它。这一切都应该在上一章中熟悉,当时我们讨论了实体的操作。如果产品已经存在,我们首先检查根据配置,我们是否可以更新它,并且只有在这样做允许的情况下才这样做。loadByProperties()方法始终返回一个实体数组,但由于我们只期望有一个具有相同远程 ID 和来源组合的单个产品,我们只需简单地reset()这个数组以获取那个实体。然后,我们只需在实体上设置名称和产品编号。
正如你所见,我们不是使用实体 API/类型数据set()方法来更新实体字段值,而是使用我们自己的接口方法。我发现这要干净得多,更现代,并且是 IDE 友好的方式,因为一切都是非常明确的。
您可能会注意到,在这个导入过程中存在错误处理,或者更确切地说,是缺乏错误处理。这是因为我为了专注于当前主题,故意保持了简单。通常,您可能想要抛出并捕获一些异常,并且肯定要记录一些消息(无论是错误还是成功)。您知道如何从第三章,日志和邮件中做后者。
大概就是这样。我们现在可以创建我们的第一个导入实体,并使其使用这个导入插件(当然是在清除缓存之后):

上一张截图中的 URL 只是一个本地 URL,其中包含了示例 JSON 文件的位置,我们可以看到可选择的唯一插件,以及其他我们为表单元素创建的实体字段。通过保存这个新实体,我们可以程序化地使用它(假设 URL 中引用的products.json文件存在):
$config = \Drupal::entityTypeManager()
->getStorage('importer')
->load('my_json_product_importer');
$plugin = \Drupal::service('products.importer_manager')
->createInstance($config->getPluginId(), ['config' => $config]);
$plugin->import();
我们首先通过 ID 加载导入实体。然后,我们使用ImporterManager服务通过createInstance()方法创建一个插件的新实例。只需要一个参数——插件的 ID——但正如我之前所说的,我们希望传递配置实体给它,因为它依赖于它。所以我们就这样做了。然后,我们在插件上调用import()方法。运行此代码后,产品实体列表将显示一些闪亮的新产品。
然而,让我们改进一下。由于配置实体和插件紧密相连,让我们使用插件管理器来完成整个操作,而不是首先加载一个实体,然后从它请求插件。换句话说,让我们在插件管理器中添加一个方法,我们可以传递配置实体 ID,它返回相关插件的实例;类似于这样:
/**
* Creates an instance of ImporterInterface plugin based on the ID of a
* configuration entity.
*
* @param $id
* Configuration entity ID
*
* @return null|\Drupal\products\Plugin\ImporterInterface
*/
public function createInstanceFromConfig($id) {
$config = $this->entityTypeManager->getStorage('importer')->load($id);
if (!$config instanceof \Drupal\products\Entity\ImporterInterface) {
return NULL;
}
return $this->createInstance($config->getPluginId(), ['config' => $config]);
}
在这里,我们基本上与之前做的是同一件事,但如果找不到配置实体,我们返回NULL。您可以选择抛出异常,如果您愿意的话。然而,正如您可能正确注意到的,我们还需要将EntityTypeManager注入到这个类中,所以我们的构造函数也改变了,将其作为最后一个参数传递,并将其设置为类属性。您应该能够自己做到这一点。但我们还需要修改插件管理器的服务定义,以添加EntityTypeManager作为依赖项:
products.importer_manager:
class: Drupal\products\Plugin\ImporterManager
parent: default_plugin_manager
arguments: ['@entity_type.manager']
如您所见,我们保留了parent继承键,以便接受所有父参数。然而,我们还在上面添加了我们自己的常规arguments键,它将附加来自父级的参数。
通过这种方式,我们简化了客户端代码:
$plugin = \Drupal::service('products.importer_manager')
->createInstanceFromConfig('my_json_product_importer');
$plugin->import();
我们只需要与插件管理器交互,就可以直接运行导入。这在某些方面是更好的,因为我们的配置实体不是我们为其他人使用而设计的。它们是简单的配置存储,由我们的导入插件使用。
内容实体包
我们已经编写了一个小巧的功能。我们还可以,并将会在后面的章节中做出改进,但那些改进将在我们覆盖其他需要了解的主题时进行。现在,然而,让我们退一步回到我们的内容实体类型,通过启用捆绑包来扩展我们的产品。我们希望有不止一种类型的产品可以被导入。这将是一个捆绑包,它将在创建导入器配置时作为一个选项来选择。然而,首先,让我们将产品实体类型设置为“可捆绑”。
我们首先调整我们的产品实体插件注解:
/**
* Defines the Product entity.
*
* @ContentEntityType(
* ...
* label = @Translation("Product"),
* bundle_label = @Translation("Product type"),
* handlers = {
* ...
* entity_keys = {
* ...
* "bundle" = "type",
* },
* ...
* bundle_entity_type = "product_type",
* field_ui_base_route = "entity.product_type.edit_form"
* )
*/
我们为我们的捆绑包添加一个 bundle_label,一个实体键,它将映射到 type 字段,bundle_entity_type 引用将指向作为产品捆绑包的配置实体类型,以及 field_ui_base_route。这个后者选项是我们之前可以添加的,但不是必需的。现在,我们可以(并且应该)添加它,因为我们需要一个路由,我们可以从管理 UI 字段和捆绑包的角度来配置我们的产品实体。我们将在稍后看到这些。
此外,我们还需要对链接做一些更改。首先,我们需要修改 add-form 链接:
"add-form" = "/admin/structure/product/add/{product_type}",
现在将通过 URL 中的产品类型来识别我们正在创建哪个捆绑包。如果你还记得上一章中我们通过编程创建实体时的情况,如果实体类型有捆绑包,那么捆绑包是一个从一开始就必需的值。
然后,我们添加一个新的链接,如下所示:
"add-page" = "/admin/structure/product/add",
这将导航到初始的 add-form 路径,但会列出可用于创建新产品的可用捆绑包选项。点击其中一个选项将带我们到 add-form 链接。
由于我们进行了这些更改,我们还需要快速修改产品实体动作链接,使用 add-page 路由而不是 add-form 路由:
entity.product.add_page:
route_name: entity.product.add_page
title: 'Add Product'
appears_on:
- entity.product.collection
这是因为,在产品实体列表页面(集合 URL)上,我们没有上下文中的产品类型,因此我们无法构建到 add-form 的路径;而且这样做也不合逻辑,因为我们不知道用户想要创建哪种类型的产品。作为快速奖励,如果只有一个捆绑包,Drupal 将将用户重定向到该特定捆绑包的 add-form 链接。
好事是,由于我们为捆绑包指定了一个实体键,我们不必定义将引用捆绑配置实体的字段。这将由父 ContentEntityType::baseFieldDefinitions() 方法为我们完成。所以,剩下要做的就是创建一个 ProductType 配置实体类型,它将作为产品捆绑包使用。我们或多或少已经知道它是如何工作的。在我们的 Entity 命名空间中,我们这样开始我们的类:
namespace Drupal\products\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBundleBase;
/**
* Product type configuration entity type.
*
* @ConfigEntityType(
* id = "product_type",
* label = @Translation("Product type"),
* handlers = {
* "list_builder" = "Drupal\products\ProductTypeListBuilder",
* "form" = {
* "add" = "Drupal\products\Form\ProductTypeForm",
* "edit" = "Drupal\products\Form\ProductTypeForm",
* "delete" = "Drupal\products\Form\ProductTypeDeleteForm"
* },
* "route_provider" = {
* "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
* },
* },
* config_prefix = "product_type",
* admin_permission = "administer site configuration",
* bundle_of = "product",
* entity_keys = {
* "id" = "id",
* "label" = "label",
* "uuid" = "uuid"
* },
* links = {
* "canonical" = "/admin/structure/product_type/{product_type}",
* "add-form" = "/admin/structure/product_type/add",
* "edit-form" = "/admin/structure/product_type/{product_type}/edit",
* "delete-form" = "/admin/structure/product_type/{product_type}/delete",
* "collection" = "/admin/structure/product_type"
* },
* config_export = {
* "id",
* "label"
* }
* )
*/
class ProductType extends ConfigEntityBundleBase implements ProductTypeInterface {
/**
* The Product type ID.
*
* @var string
*/
protected $id;
/**
* The Product type label.
*
* @var string
*/
protected $label;
}
这大部分与创建导入配置实体类型时完全相同。唯一的区别是我们在注解中有了 bundle_of 键,它表示这个内容实体类型作为捆绑所服务的内容类型。而且,我们实际上不需要其他字段。正因为如此,ProductTypeInterface 可以看起来像这样简单:
namespace Drupal\products\Entity;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Product bundle interface.
*/
interface ProductTypeInterface extends ConfigEntityInterface {}
让我们快速看一下单个处理器,现在它们看起来也应该非常熟悉。列表构建器看起来几乎与导入器相同:
namespace Drupal\products;
use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
use Drupal\Core\Entity\EntityInterface;
/**
* List builder for ProductType entities.
*/
class ProductTypeListBuilder extends ConfigEntityListBuilder {
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Product type');
$header['id'] = $this->t('Machine name');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
$row['label'] = $entity->label();
$row['id'] = $entity->id();
return $row + parent::buildRow($entity);
}
}
创建/编辑表单处理器看起来也非常相似,但由于配置实体类型上没有很多字段,所以它要简单得多:
namespace Drupal\products\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
/**
* Form handler for creating/editing ProductType entities
*/
class ProductTypeForm extends EntityForm {
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/** @var \Drupal\products\Entity\ProductTypeInterface $product_type */
$product_type = $this->entity;
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#maxlength' => 255,
'#default_value' => $product_type->label(),
'#description' => $this->t('Label for the Product type.'),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $product_type->id(),
'#machine_name' => [
'exists' => '\Drupal\products\Entity\ProductType::load',
],
'#disabled' => !$product_type->isNew(),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$product_type = $this->entity;
$status = $product_type->save();
switch ($status) {
case SAVED_NEW:
drupal_set_message($this->t('Created the %label Product type.', [
'%label' => $product_type->label(),
]));
break;
default:
drupal_set_message($this->t('Saved the %label Product type.', [
'%label' => $product_type->label(),
]));
}
$form_state->setRedirectUrl($product_type->toUrl('collection'));
}
}
再次强调,在这个表单中,我使用了全局的 drupal_set_message() 函数来节省空间。你应该注入 Messenger 服务来向用户打印消息。
由于我们创建了保存字段值的表单,所以我们不能忘记此实体类型的配置模式:
products.product_type.*:
type: config_entity
label: 'Product type config'
mapping:
id:
type: string
label: 'ID'
label:
type: label
label: 'Label'
uuid:
type: string
接下来,我们也应该快速编写删除产品类型的表单处理器:
namespace Drupal\products\Form;
use Drupal\Core\Entity\EntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form handler for deleting ProductType entities.
*/
class ProductTypeDeleteForm extends EntityConfirmFormBase {
/**
* ProductTypeDeleteForm constructor.
*
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
*/
public function __construct(MessengerInterface $messenger) {
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('messenger')
);
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('entity.product_type.collection');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->entity->delete();
$this->messenger->addMessage($this->t('Deleted @entity product type.', ['@entity' => $this->entity->label()]));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}
你应该已经熟悉我们在这里所做的事情,因为它与导入实体类型相同。
最后,我们应该创建指向 ProductType 实体列表 URL 的菜单链接,就像我们在 products.links.menu.yml 内部为其他两个实体类型所做的那样:
# Product type entity menu items
entity.product_type.collection:
title: 'Product types'
route_name: entity.product_type.collection
description: 'List Product bundles'
parent: system.admin_structure
weight: 99
同样,对于创建新产品捆绑所使用的操作链接,在 products.links.action.yml 内部:
entity.product_type.add_form:
route_name: 'entity.product_type.add_form'
title: 'Add Product type'
appears_on:
- entity.product_type.collection
现在,我们已经完成了。我们可以清除缓存并运行 drush entity-updates 命令,因为 Drupal 需要在产品实体上创建 type 字段。一旦完成,我们就可以在 admin/structure/product_type 的 UI 中查看我们的更改。
现在我们有一个产品类型实体列表,我们可以在这里创建产品捆绑。此外,我们还有一些额外的操作,因为此实体类型用作捆绑:我们可以为每个单独的捆绑管理字段和显示(包括查看和表单):

如果我们向产品实体类型提供了 field_ui_base_route 并为其创建了一个菜单链接,那么在创建捆绑之前管理字段和显示是可能的。
现在,我们可以向我们的单个捆绑添加字段,并区分我们的产品类型——例如,我们可以有一个商品捆绑和一个服务捆绑。我们可以想象这两种类型可能需要不同的字段集,或者它们可能来自不同的外部资源。所以,让我们更新我们的导入逻辑,允许选择一个捆绑,因为现在在尝试创建产品时实际上必须指定一个。
我们首先向导入实体类型添加一个新字段。首先,对于接口更改:
/**
* Returns the Product type that needs to be created.
*
* @return string
*/
public function getBundle();
然后,我们将查看类中的实现:
/**
* The product bundle.
*
* @var string
*/
protected $bundle;
...
/**
* {@inheritdoc}
*/
public function getBundle() {
return $this->bundle;
}
接下来,我们必须在配置模式中包含新的字段:
...
bundle:
type: string
label: The product bundle
在导入实体类型上,我们最后需要做的是添加选择捆绑的表单元素:
$form['bundle'] = [
'#type' => 'entity_autocomplete',
'#target_type' => 'product_type',
'#title' => $this->t('Product type'),
'#default_value' => $importer->getBundle() ? $this->entityTypeManager->getStorage('product_type')->load($importer->getBundle()) : NULL,
'#description' => $this->t('The type of products that need to be created.'), '#required' => TRUE,
];
在这里,我们使用一个entity_autocomplete表单元素,它为我们提供了使用自动完成文本字段查找现有实体并选择找到的其中一个的选项。所选实体的 ID 将被作为值提交到表单中。这个字段定义需要选择一个#target_type,这是我们想要自动完成的实体类型。有一点需要注意,即使提交的值只有 ID(在我们的例子中是一个字符串),#default_value也需要完整的实体对象本身(或实体对象的数组)。这是因为该字段显示的关于引用实体的信息比仅仅 ID 更多。
为了加载默认值所引用的实体,我们需要注入EntityTypeManger。你应该已经知道如何进行这种注入,所以在这里我不会再次展示。我们只需将依赖项附加到已经注入的Messenger服务上。
对于导入器实体类型修改来说,这就足够了。我们最后需要做的是处理我们编写的JsonImporter插件内部的包。然而,这就像在创建产品实体时添加type值一样简单:
if (!$existing) {
$values = [
'remote_id' => $data->id,
'source' => $config->getSource(),
'type' => $config->getBundle(),
];
/** @var \Drupal\products\Entity\ProductInterface $product */
$product = $this->entityTypeManager->getStorage('product')->create($values);
...
我们就到这里。运行导入代码现在将创建在导入器配置中指定的包的产品。
Drush 命令
因此,我们的逻辑已经到位,但我们需要创建一种方便的方式来触发导入。一个选项是创建一个管理表单,我们可以在那里按按钮。然而,一个更典型的例子是可以在 crontab 中添加的命令,它可以在特定的时间间隔自动运行。所以这就是我们现在要做的,我们将使用 Drush 来完成。
我们将要编写的 Drush 命令将接受一个可选参数,用于处理我们想要处理的导入器配置实体 ID。这将允许命令用于不止一个导入器。作为替代,不传递任何选项将处理每个导入器(如果这是我们稍后想要做的)。有一点需要注意,我们不会在这个例子中关注性能。这意味着命令对于较小的数据集(大到可以处理一个请求的大小)将运行得很好,但对于较大的数据集,最好使用队列和/或批量处理。此外,我们将在稍后的章节中专门介绍这些子系统,但现在,让我们继续我们的例子。
在我们实际编写新的 Drush 命令之前,让我们对我们的逻辑做一些修改,因为它们将在我们想要做的上下文中变得有意义。
首先,让我们给导入器插件添加一个获取器方法来检索相应的配置实体。我们以这样的接口开始:
/**
* Returns the Importer configuration entity.
*
* @return \Drupal\products\Entity\ImporterInterface
*/
public function getConfig();
然后,我们可以向ImporterBase类添加实现(它将适用于所有单个插件实例):
/**
* {@inheritdoc}
*/
public function getConfig() {
return $this->configuration['config'];
}
如你所见,这并不是什么高难度的科学。
其次,让我们向ImporterManager添加一个createInstanceFromAllConfigs()方法,该方法将为每个现有的Importer配置实体返回一个插件实例数组:
/**
* Creates an array of importer plugins from all the existing Importer
* configuration entities.
*
* @return \Drupal\products\Plugin\ImporterInterface[]
*/
public function createInstanceFromAllConfigs() {
$configs = $this->entityTypeManager->getStorage('importer')->loadMultiple();
if (!$configs) {
return [];
}
$plugins = [];
foreach ($configs as $config) {
$plugin = $this->createInstanceFromConfig($config->id());
if (!$plugin) {
continue;
}
$plugins[] = $plugin;
}
return $plugins;
}
在这里,我们使用实体存储处理器的loadMultiple()方法,如果我们不带任何参数使用它,它将加载所有现有实体。如果我们得到任何结果,我们使用现有的createInstanceFromConfig()方法根据每个配置实体实例化插件。就是这样;我们现在可以继续创建我们的 Drush 命令。
在 Drush 的新版本(9 及以上)中,命令不再在过程代码中声明。那么,让我们看看我们如何使用 OOP 创建我们的命令。我们需要采取几个步骤。
我们需要为我们模块创建一个*composer.json*文件。它可以看起来非常简单:
{
"name": "drupal/products",
"description": "Importing products like a boss.",
"type": "drupal-module",
"autoload": {
"psr-4": {
"Drupal\\products\\": "src/"
}
},
"extra": {
"drush": {
"services": {
"drush.services.yml": "⁹"
}
}
}
}
除了正常的包和自动加载信息之外,我们还有一个extras部分,其中我们指定一个 YAML 文件,Drush 可以在其中找到包含命令的服务定义。由于撰写本文时 Drush 的最新版本是 9,我们也指定了这一点。
在 Drush 9 中,这实际上不是必需的。只需在模块根目录中有一个drush.services.yml文件就足够 Drush 加载它了。然而,在 Drush 10 中,这变得强制性的,所以你最好现在就使用正确的方法。
现在我们已经引用了 Drush 特定的服务文件,让我们继续创建它。它看起来就像我们习惯的其他服务文件一样:
services:
products.commands:
class: Drupal\products\Commands\ProductCommands
arguments: ['@products.importer_manager']
tags:
- { name: drush.command }
如您所见,我们还有一个标记的服务(drush.command),其类应该包含一些 Drush 命令。而且我已经知道我们需要插件管理器,所以我们已经将其作为参数添加。
那么,让我们看看我们如何启动命令类,它应该放在我们模块的*Commands*命名空间中:
namespace Drupal\products\Commands;
use Drush\Commands\DrushCommands;
use Symfony\Component\Console\Input\InputOption;
use Drupal\products\Plugin\ImporterManager;
/**
* Drush commands for products.
*/
class ProductCommands extends DrushCommands {
/**
* @var \Drupal\products\Plugin\ImporterManager
*/
protected $importerManager;
/**
* ProductCommands constructor.
*
* @param \Drupal\products\Plugin\ImporterManager $importerManager
*/
public function __construct(ImporterManager $importerManager) {
$this->importerManager = $importerManager;
}
/**
* Imports the Products
*
* @option importer
* The importer config ID to use.
*
* @command products-import-run
* @aliases pir
*
* @param array $options
* The command options.
*/
public function import($options = ['importer' => InputOption::VALUE_OPTIONAL]) {
// ... add the logic here.
}
}
我们正在扩展DrushCommands基类以继承所有对 Drush 命令必要或有用的东西。我们有一个映射到单个命令的单个方法。使这成为一个实际命令的是顶部注释,它描述了与之相关的所有事情:
-
@command是最重要的,它指定了实际的 Drush 命令名称。 -
@alias指定了命令的其他别名。 -
@param是对命令输入参数的简单文档。在我们的例子中,我们没有强制性的参数。但我们确实有可选参数。如果我们想要强制性的参数,我们可以在没有默认值的情况下简单地添加更多方法参数。 -
@option指定了可以传递的选项名称;这是在$options数组参数中的一个键。由于它是强制性的,我们使用一个常量来表示它。
使用这个定义,我们目前已经可以使用这个命令了。在我们清除缓存后,我们可以像以下示例那样运行命令:
drush products-import-run
drush products-import-run —importer=my_json_product_importer
显然,如果我们运行这些代码,因为回调方法为空,所以不会发生任何事。所以让我们完善它:
$importer = $options['importer'];
if (!is_null($importer)) {
$plugin = $this->importerManager->createInstanceFromConfig($importer);
if (is_null($plugin)) {
$this->logger()->log('error', t('The specified importer does not exist.'));
return;
}
$this->runPluginImport($plugin);
return;
}
$plugins = $this->importerManager->createInstanceFromAllConfigs();
if (!$plugins) {
$this->logger()->log('error', t('There are no importers to run.'));
return;
}
foreach ($plugins as $plugin) {
$this->runPluginImport($plugin);
}
这里发生了什么?首先,我们检查导入器 ID,如果命令中传递了一个 ID,我们就简单地使用我们的导入器管理器来创建相应插件的实例,并将任务委托给辅助方法在该插件上运行导入。如果没有传递导入器 ID,我们就使用内置的 Drush 记录器记录错误。相反,如果没有传递导入器 ID,我们使用插件管理器上的新createInstanceFromAllConfigs()方法从所有现有的配置实体创建插件实例。然后我们遍历每个实例,再次委托给我们的辅助方法来运行它们。
在我们总结之前,让我们也看看这个辅助方法:
/**
* Runs an individual Importer plugin.
*
* @param \Drupal\products\Plugin\ImporterInterface $plugin
*/
protected function runPluginImport(\Drupal\products\Plugin\ImporterInterface $plugin) {
$result = $plugin->import();
$message_values = ['@importer' => $plugin->getConfig()->label()];
if ($result) {
$this->logger()->log('status', t('The "@importer" importer has been run.', $message_values));
return;
}
$this->logger()->log('error', t('There was a problem running the "@importer" importer.', $message_values));
}
这个方法主要用于记录插件导入的结果:根据过程的成功与否显示不同的消息。在这个过程中,我们使用实际的导入器标签而不是传递的 ID,这样读起来更方便。
现在如果我们清除缓存,我们可以再次运行命令(带有或没有导入器 ID)并看到它正确地导入了产品,并将消息打印到终端。更好的是,我们现在可以将其添加到我们的 crontab 中,使其在特定的时间间隔运行,例如每天一次。
摘要
在这一章中,我们实现了有趣的东西。我们创建了我们的内容实体类型和配置实体类型,以及一个自定义插件类型来处理我们的逻辑。
我们构建了一个产品实体类型,它包含一些类似产品的数据,并在各种类型的字段中。我们甚至创建了一个捆绑配置实体类型,这样我们就可以有多个类型的产品,并且每个捆绑包都有可能有不同的字段——一个很好的数据模型。
我们希望能够从各种外部资源导入产品。因此,我们创建了导入器插件类型,它负责执行实际的导入——一个很好的功能模型。然而,这些插件仅基于一组配置工作,我们通过配置实体类型来表示这些配置。然后可以在 UI 中创建它们,并像其他任何配置一样导出为 YAML 文件。
最后,为了使用导入器,我们创建了一个 Drush 命令,它可以处理单个导入器或所有现有的导入器。这可以用于 crontab 中的自动导入。
在我们构建导入功能的方式上还有一些不足之处。例如,我们在导入器配置实体上添加了 URL 字段,好像所有的导入都需要从一个外部资源发生。如果我们想从 CSV 文件导入呢?URL 字段就变得多余了,我们需要在配置实体上添加一个文件上传字段。这非常指向通用导入器配置值和插件特定值之间的差异。在未来的章节中,我们将回到我们的模块,并在这个方面做一些调整。
在下一章中,我们将探讨数据库 API 以及我们如何直接与底层存储引擎交互。
第八章:数据库 API
在前两章中,我们详细讨论了作为 Drupal 8 模块开发者,我们在 Drupal 8 中建模和存储数据的选项。我们还看到了一些如何使用诸如状态、配置和实体 API 的例子,通过使用后者构建有用的东西来更详细地介绍后者。从那些章节中,一个关键的收获是需要自定义数据库表和/或直接对它们和数据库进行查询的需求已经变得很小。
实体系统更加灵活和健壮,配置和内容实体的组合提供了存储数据的大部分需求。此外,实体查询和加载机制也使得查找它们变得容易。很可能,这已经足够满足大多数用例。
此外,诸如状态 API(键/值)和 UserData 之类的存储子系统也已经消除了创建自定义表来存储那种“一次性”数据的大部分需求。此外,配置 API 提供了一个统一的方式来建模可导出数据,从而不再需要其他任何东西。
然而,除了这些特性之外,Drupal 还有一个强大的数据库 API,实际上在幕后为它们提供动力。这个 API 在我们需要时提供给我们。例如,我们可以创建自己的数据库表,然后以我们想要的方式对它们进行查询,所有这些都在一个可以工作在多种数据库之上的安全层中完成。
创建自定义数据库表并不是你经常要做的事情——也许永远不会——但在本章中,你仍然会学习如何使用 API 来做到这一点。有些贡献的模块确实有合法用途,而且谁知道呢,你可能也会有。因此,理解这个系统仍然很重要。然而,更重要的是运行查询的 API(尤其是选择查询),因为你可能需要运行这些查询,甚至针对实体。有时实体查询并不能提供你需要的一切,因此基于复杂查询查找实体实际上可能更常见。因此,我们将在本章中介绍如何做到这一点。
更具体地说,在本章中,我们将首先创建几个数据库表,以便我们可以看到 Schema API 在 Drupal 8 中的工作方式。对于 D7 开发者来说,这看起来非常熟悉。然后,我们将看到我们可以通过使用数据库抽象层对这些表执行查询的各种方式。我们可以执行两种不同的选择查询,我们将练习这两种。对于其他(INSERT、UPDATE和DELETE),有标准的方法来做。接下来,我们将看看如何修改查询以及如何为它们标记以实现更好的定位。最后,我们将探讨数据库更新钩子,这是在 Drupal 的前几个版本中配置部署的主要方式之一。实际上,这些钩子的目的是在表已经创建后进行数据库更新。
模式 API
Schema API 的目的是允许在 PHP 中定义数据库表结构,并让 Drupal 与数据库引擎交互,将这些定义转化为现实。除了我们永远不需要看到诸如 CREATE TABLE 这样的语句之外,我们还确保我们的表结构可以应用于多种类型的数据库。如果您还记得在 第一章,为 Drupal 8 开发 中,我提到 Drupal 可以与 MySQL、PostgreSQL、SQLite 等数据库一起工作,如果它们支持 PDO,那么 Schema API 确保了这种跨兼容性。
Schema API 的核心组件是 hook_schema()。这个钩子用于提供给定模块的初始表定义。此钩子的实现应位于模块的 *.install 文件中,并在模块首次安装时触发。如果需要对现有数据库表进行修改,可以在更新钩子内部使用多种方法来执行这些更改。
在本节中,我们将创建一个名为 sports 的新模块,我们希望在其中定义两个表:players 和 teams。前者的记录可以引用后者的记录,因为每个球员一次只能属于一个团队。这是一个简单的例子,并且可以也应该使用实体来实现。然而,为了演示数据库 API,我们将坚持手动设置。
因此,在我们的 sports.install 文件中,我们可以这样实现 hook_schema():
/**
* Implements hook_schema().
*/
function sports_schema() {
$schema = [];
$schema['teams'] = [
'description' => 'The table that holds team data.',
'fields' => [
'id' => [
'description' => 'The primary identifier.',
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
],
'name' => [
'description' => 'The team name.',
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
'description' => [
'description' => 'The team description.',
'type' => 'text',
'size' => 'normal',
],
],
'primary key' => ['id'],
];
$schema['players'] = [
'description' => 'The table that holds player data.',
'fields' => [
'id' => [
'description' => 'The primary identifier.',
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
],
'team_id' => [
'description' => 'The ID of the team it belongs to.',
'type' => 'int',
'unsigned' => TRUE,
],
'name' => [
'description' => 'The player name.',
'type' => 'varchar',
'length' => 255,
'not null' => TRUE,
],
'data' => [
'description' => 'Arbitrary data about the player.',
'type' => 'blob',
'size' => 'big',
],
],
'primary key' => ['id'],
];
return $schema;
}
此钩子的实现需要返回一个关联数组,以表名为键,其值是一个定义相应表的数组。表定义由各种类型的信息组成,特别是各个列的定义(字段),以及哪些字段代表主键、外键(严格来说仅用于文档目的)、唯一键和索引等。要查看所有可用选项的完整参考,请查阅 Drupal.org (www.drupal.org/) 的 Schema API 文档页面。
在我们的示例中,我们定义了之前提到的两个表,并在 fields 数组中定义了它们的字段。primary key 指示哪个字段将用于此目的,我们选择了标准的 id 字段。说到这一点,后者是一个 serial 类型的字段,这意味着它是一个具有自增选项的整数。对于整数、浮点数和数值等数字字段,unsigned 选项意味着数字不能低于 0。not null 也很容易理解——它防止列始终为空。
对于团队和玩家名称,我们选择了一个简单的varchar字段,它最多可以接受 255 个字符(这是一个相当标准的表列定义),这些字段也不能为空。另一方面,描述字段是text类型,大小为normal(与tiny、small、medium或big相对)。在这里,我们希望存储超过 255 个字符的字符串。在撰写这本书的时候,关于 Drupal 8 可用数据类型(及其选项)的完整文档还不存在;然而,D7 版本(www.drupal.org/docs/7/api/schema-api/data-types)是一个很好的指标,并且将几乎完全相同地工作。
最后,对于玩家表,我们还有一个team_id,它是一个简单的整数字段,还有一个data列,我们想在其中存储一些任意序列化的数据。这是一个blob类型,也可以是big或normal。
对于我们的模式定义,这基本上就结束了。安装sports模块将根据这些定义自动为我们创建这些表。同样重要的是,卸载模块也会删除这些表,所以我们不需要进行任何处理。然而,如果我们的模块已经启用,并且之后添加了这个实现,它将不会被触发。相反,我们需要实现一个更新钩子并使用drupal_install_schema()函数,这将触发它。如下所示:
drupal_install_schema('sports');
我们很快就会看到更多关于更新钩子的内容。
执行查询
现在我们有一些表可以操作了,让我们看看如何对它们执行查询。如果你在跟随,为了测试目的,你可以通过你选择的数据库管理工具向表中添加一些虚拟数据。我们很快就会看到INSERT语句,但在那之前,我们需要讨论你将运行更常见的查询类型——SELECT。
使用 Drupal 8 数据库抽象层的查询是通过一个中央数据库连接服务——database来执行的。静态地,可以通过快捷方式访问它:
$database = \Drupal::database();
与我们之前看到的那些服务相比,这个服务是特殊的一个,因为它实际上是使用工厂创建的。这是它的定义,以更好地帮助你理解我的意思:
database:
class: Drupal\Core\Database\Connection
factory: Drupal\Core\Database\Database::getConnection
arguments: [default]
这是一个定义,其中将实例化的责任委托给之前提到的工厂,而不是像之前那样委托给容器。因此,生成的类不一定需要与class键指定的类匹配。然而,在这种情况下,Drupal\Core\Database\Connection是一个抽象基类,生成的服务会扩展它。再次强调,在这种情况下,arguments负责指定它必须创建的连接类型。使用的是“site-default”类型(通常是 MySQL),这意味着生成的服务将是一个Drupal\Core\Database\Driver\mysql\Connection的实例。
从这个连接服务中,我们可以请求相关的对象,从而构建查询。那么,让我们看看这些是如何工作的。
选择查询
在 Drupal 8 中,我们可以有两种方式运行选择查询,它们的工作方式与 Drupal 7 中的方式相似。我们拥有db_query()和db_query_range()的 D8 等价函数,以及db_select()的等价函数。这对于 D7 开发者来说应该很熟悉。在 Drupal 8 中,这些过程式函数仍然存在,但处于弃用状态。这意味着我们不应该使用旧函数,而应该使用我接下来要提到的连接服务。
第一种选择查询通常性能更好,因为我们通过编写 SQL 语句(当然,包括占位符)来构建它们,而db_select()类型的查询是一个面向对象的查询构建器,它仍然需要将链式对象结构转换为 SQL 语句。然而,不要让这种性能成为真正的决定因素,因为正如你可以想象的那样,影响微乎其微。此外,查询构建器是运行查询的正确方式,因为它们是可更改的(可以分解)。
第一种选择查询通常用于更简单的查询,但如果你是 SQL 大师,实际上使用该方法编写复杂查询可能会更快、更简单。此外,它们依赖于开发者确保 SQL 语句与底层数据库兼容。因此,在选择这两种类型时,需要考虑所有这些因素。
让我们先看看如何使用类似db_query()的方法运行对表格的基本查询。然后,我们将看到同样的查询如何以其他方式运行:
$database = \Drupal::database();
$result = $database->query("SELECT * FROM {players} WHERE id = :id", [':id' => 1]);
这是一个简单的 SQL 语句,尽管如果你没有进行过任何 D7 开发,它可能有点奇怪。我们将查询字符串作为第一个参数传递给连接对象的query()方法。第二个参数是查询字符串的占位符值数组。这些占位符值在整个 SQL 字符串中由冒号(:id)引导,稍后会被与占位符值数组中相同键对应的值所替换。另外,需要注意的是,查询中的表名被大括号包围。这是因为实际上,当站点安装时,表名可能会被添加前缀,而我们的代码不应该关心这个前缀。Drupal 会自动添加它。
现在,让我们看看如何使用查询构建器运行相同的查询:
$result = $database->select('players', 'p')
->fields('p')
->condition('id', 1)
->execute();
这次,我们将使用连接对象上的 select() 方法来获取一个 SelectInterface 实例,我们可以用它来构建我们的查询。我们需要传递我们想要查询的表,以及该表的别名。这在执行连接操作时尤其重要。然后,我们使用 fields() 方法来指定我们想要检索的表列。第一个参数是表别名,而第二个(可选)是一个列名数组。所有列都将被包括(*)。接下来,我们有一个单一的条件应用于查询的 id 列和值 1。第三个可选参数是默认为 = 的运算符。最后,我们执行查询并得到与前面示例相同的结果。
如果你记得的话,你会立即注意到,这个查询构建器的结构非常类似于实体查询,并且组件在某种程度上也是可链式的,正如我们将看到的。
处理结果
之前的两个查询都返回一个 StatementInterface,它是可迭代的。因此,要访问其数据,我们可以这样做:
foreach ($result as $record) {
$id = $record->id;
$team_id = $record->team_id;
$name = $record->name;
$data = $record->data;
}
循环中的每个项目都是一个 stdClass,它们的属性名是返回的实际列名,而它们的值是列值。
或者,StatementInterface 也有一些可以以不同方式为我们准备结果的获取器方法。这些方法大多来自父类 \PDOStatement,它是原生 PHP。最简单的是 fetchAll():
$records = $result->fetchAll();
这返回了一个 stdClass 对象数组,正如我们之前看到的,所以它为我们做了所有的循环来提取记录。如果我们想按记录中字段的值来键控这个数组,我们可以执行以下操作:
$records = $result->fetchAllAssoc('id');
这将使用 id 字段中的值作为数组的键。
如果我们期望单条记录,我们也可以使用 fetch() 方法,它只返回这样一个对象(结果集中的下一个对象);fetchObject() 做的是同样的事情。
更复杂的查询选择
现在我们创建一个更复杂的查询,将我们的团队表和球员信息连接起来,并在同一记录中检索团队信息:
$result = $database->query("SELECT * FROM {players} p JOIN {teams} t ON t.id = p.team_id WHERE p.id = :id", [':id' => 1]);
这将返回与之前相同的记录,但包括匹配的团队记录的值。注意,由于我们有一个连接,所以我们在这里也必须使用表别名。然而,这个查询有一个问题——由于两个表都有 name 列,我们不能使用 * 来包含所有字段,因为它们将被覆盖。相反,我们需要手动包含它们:
$result = $database->query("SELECT p.id, p.name as player_name, t.name as team_name, t.description as team_description, p.data FROM {players} p JOIN {teams} t ON t.id = p.team_id WHERE p.id = :id", [':id' => 1]);
如你所见,我们指定了想要包含的两个表中的字段,并在存在名称冲突的地方使用了不同的别名。现在,让我们使用查询构建器来编写相同的查询:
$query = $database->select('players', 'p');
$query->join('teams', 't');
$query->addField('p', 'name', 'player_name');
$query->addField('t', 'name', 'team_name');
$query->addField('t', 'description', 'team_description');
$result = $query
->fields('p', ['id', 'data'])
->condition('p.id', 1)
->execute();
$records = $result->fetchAll();
首先,查询构建器上的不是所有方法都是可链式的。join()方法(以及其他类型的连接方法,如innerJoin()、leftJoin()和rightJoin())以及addField()方法是一些突出的例子。后者是一种我们可以通过指定别名来向查询中添加字段的方法(我们无法通过fields()方法来做)。此外,condition()字段也带有它需要的表别名前缀(在我们之前没有使用连接时这不是必要的)。
关于构建查询的所有其他有用方法的更多信息,请访问SelectInterface和ConditionInterface。它们在那里通常有很好的文档说明。
范围查询
由于将查询限制到特定范围取决于底层数据库引擎,我们还在数据库连接服务上有一个queryRange()方法,我们可以用它来编写包含范围的查询:
$result = $database->queryRange("SELECT * FROM {players}", 0, 10);
在这个例子中,我们查询所有球员并将结果集限制为前 10 条记录(从 0 到 10)。因此,使用这种方法,占位符值数组是$from和$count之后的第四个参数。
或者,使用SELECT查询构建器,我们在SelectInterface上有一个方法,可以指定一个范围。因此,按照这种格式,之前的查询将看起来像这样:
$result = $database->select('players', 'p')
->fields('p')
->range(0, 10)
->execute();
如你所见,我们有range()方法,它接受这些参数并限制查询。
关于在实体表上运行选择查询的注意事项:如果你可以使用实体查询来完成,请使用它。如果不能,请随意使用数据库 API。然而,坚持使用查询来确定所需实体的 ID,然后使用实体存储处理程序正确加载这些实体。这与 Drupal 7 中的许多情况不同,那时我们直接从这样的查询中使用了字段值。在 Drupal 8 中,这被高度不建议。
分页器
现在我们已经看到了如何进行各种类型的SELECT查询,让我们看看如何使用 Drupal 内置的分页功能以及 Drupal 8 中的分页器是如何工作的。我们将通过运行一些查询并在表格中渲染结果来展示这些。如果你不记得输出表格的主题方面,请参阅第四章,主题化。
我们的游乐场将位于一个新的控制器方法中(SportsController::players()),它映射到具有/players路径的路由。如果你不记得如何创建路由,请参阅第二章,创建您的第一个模块,以刷新记忆。
我们首先要做的是创建一个简单的查询,该查询加载所有球员并在表格中输出它们。为了简单起见,我们只显示球员名字:
/**
* Renders a table of players.
*/
public function players() {
$query = $this->database->select('players', 'p')
->fields('p');
$result = $query->execute()->fetchAll();
$header = [$this->t('Name')];
$rows = [];
foreach ($result as $row) {
$rows[] = [
$row->name
];
}
$build = [];
$build[] = [
'#theme' => 'table',
'#header' => $header,
'#rows' => $rows,
];
return $build;
}
所有这些对你来说应该都很熟悉。我们正在运行查询并准备表格数据,使用table主题钩子来渲染它。你会注意到我们正在创建一个$build数组,这样我们就可以在最终输出中包含更多内容。
通过导航到/players,我们现在应该已经看到了一个包含我们玩家名称的表格。这将成为我们探索分页器的基线。
分页器通过在全局状态中存储有关查询的一些信息来工作,即要分页的总项目数、每页的项目限制以及相应分页器的标识符(这样我们就可以同时拥有多个分页器)。所有这些信息都使用以下代码设置(你现在不需要在任何地方添加此代码):
pager_default_initialize($total, $limit, $element = 0);
此外,当前页码由 URL 中的查询参数确定,名称为page。
一旦分页器初始化,我们就有一个pager渲染元素,我们可以用它轻松渲染一个使用这些信息并构建所有必要链接以在页面之间移动的主题分页器。作为查询构建者,我们接下来必须读取当前页码并在我们的查询中使用它。
然而,处理分页器有一个更简单的方法,那就是使用选择扩展器。这些是针对我们之前看到的SELECT查询类的装饰器类,它们允许我们通过额外的功能对其进行装饰,例如分页或排序;它们封装了处理查询中分页所需的所有功能。让我们看看它是如何工作的。
下面是我们的玩家查询将如何使用PagerSelectExtender来显示:
$limit = 5; // The number of items per page.
$query = $this->database->select('players', 'p')
->fields('p')
->extend('\Drupal\Core\Database\Query\PagerSelectExtender')
->limit($limit);
$result = $query->execute()->fetchAll();
如您所见,我们在SELECT查询构建器上有一个extend()方法,它允许我们传递将装饰结果SELECT查询类的类名。这也为我们提供了一个名为limit()的新方法,通过它我们可以指定每页要加载的记录数。在底层,它使用我们之前看到的range()方法。此外,在运行查询时,它使用pager_default_initialize()为我们初始化分页器,甚至自己确定当前页码。所以通常你会直接使用扩展器。
装饰器模式是一种面向对象的编程设计模式,它允许我们静态或动态地向现有对象添加行为,而不会改变它或同一类其他对象的行为。装饰器本质上是对现有对象的包装,以提供从外部额外的功能。
因此,我们现在需要做的就是渲染以下分页器(在表格下方):
$build[] = [
'#type' => 'pager'
];
这不是正火箭科学吗?其实不是。如果我们刷新页面,现在我们应该只看到表格中的五个玩家,以及它下面的分页器。
分页渲染元素(api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Render%21Element%21Pager.php/class/Pager/8.2.x)有一些有趣的属性,我们可以使用它们来进一步定制它。我们可以向生成的链接中添加查询元素,或者如果我们想的话,甚至可以指定另一个路由。当然,我们可以控制分页链接的标签,甚至输出的链接数量。查看该元素的文档以获取更多信息。
此外,为了实现完全定制,我们还可以选择通过实现自己的预处理器(例如template_preprocess_page)来预处理这些变量,或者覆盖pager.twig.html模板文件。我们在第四章,主题化中学习了如何做这些事情。
插入查询
为了将数据插入到我们的自定义数据库表中,我们有一个可用的INSERT查询构建器。对于这种和其他类型的查询,强烈建议不要使用db_query()方法,因为 Drupal 无法保证它在不同的数据库引擎类型上都能正常工作。相反,我们可以使用连接服务上的insert()方法,并使用返回的Insert对象来构建我们的查询。那么,让我们看看我们如何向我们的players表中添加一条记录:
$database->insert('players');
$fields = ['name' => 'Diego M', 'data' => serialize(['known for' => 'Hand of God'])];
$id = $database->insert('players')
->fields($fields)
->execute();
插入查询的主要问题是fields()方法。它期望一个键/值对的数组,其中键是列名,值是需要添加到相应列的数据。或者,第一个参数可以是一个列名的数组,第二个是一个与第一个数组中列名顺序相同的值的数组。
我们也可以使用多组值(记录)运行一个INSERT查询:
$values = [
['name' => 'Novak D.', 'data' => serialize(['sport' => 'tennis'])],
['name' => 'Micheal P.', 'data' => serialize(['sport' => 'swimming'])]
];
$fields = ['name', 'data'];
$query = $database->insert('players')
->fields($fields);
foreach ($values as $value) {
$query->values($value);
}
$result = $query->execute();
在这个例子中,fields()方法只接收需要插入的列名的数组,我们使用values()方法调用来添加单个值。
execute()方法通常返回最后插入的记录的 ID(主键)。这很有用,尤其是如果你只插入一条记录。然而,对于多个插入,它也可能具有误导性。所以,请根据自己的不同用例进行实验。
更新查询
现在我们已经看到了INSERT查询,让我们看看我们如何更新现有记录。假设我们想要更新我们的玩家记录之一;我们将这样做:
$result = $database->update('players')
->fields(['data' => serialize([
'sport' => 'swimming',
'feature' => 'This guy can swim'
])])
->condition('name', 'Micheal P.')
->execute();
UPDATE查询类似于INSERT查询,不同之处在于它们需要一个condition()来确定要更新的记录(所有符合该条件的记录)。省略这一点将自然地更新所有记录。使用fields()方法,我们将简单地指定哪些列正在更新,以及更新为什么。如果我们省略一个列,它将保持不变。最后,这个查询的结果是受影响的记录总数。
删除查询
最后,我们还可以使用 DELETE 查询删除我们的记录:
$result = $database->delete('players')
->condition('name', 'Micheal P.')
->execute();
所有匹配条件的记录都将被删除。请注意这一点,因为与更新查询一样,省略条件基本上会截断你的表。查询将返回受影响的记录数,即被删除的记录数。
虽然你可以针对实体和字段表编写 SELECT 查询以找到你想要加载的实体的 ID,但你绝不应该对这些表执行 INSERT、UPDATE 或 DELETE 查询。你面临很高的风险会损坏你的数据。
事务
Drupal 数据库 API 还提供了一种表示和处理数据库事务(对于支持这些数据库类型的数据库)的方法。事务是一种将数据库操作包装并分组在一起,并以“全部或无”的方式提交它们的方式。例如,如果你有多个相关的记录,可能你只想在其中一个记录的 INSERT 操作因某些原因失败时写入其中一些。这可能会导致你得到损坏或不完整的数据,从而使你的应用程序陷入混乱。
在开启事务之后执行多个数据库更改操作,只有在事务关闭时才会最终确定(提交)这些更改到数据库。如果出现问题,它也可以回滚,这将防止数据被提交。
在 Drupal 8 中,事务由一个 Transaction 对象表示(每个数据库类型都有一个特定的子类)。一旦对象被销毁(不再在作用域内),操作就会被提交到数据库。然而,如果我们得到我们的操作中出错的指示(通常是通过捕获异常),我们可以回滚事务,这将阻止这些操作被提交。此外,事务可以嵌套,因此 Drupal 会跟踪在另一个事务的作用域内打开的事务。
让我们看看如何使用事务的示例:
$transaction = $database->startTransaction();
try {
$database->update('players')
->fields(['data' => serialize(['sport' => 'tennis', 'feature' => 'This guy can play tennis'])])
->condition('name', 'Novak D.')
->execute();
}
catch (\Exception $e) {
$transaction->rollback();
watchdog_exception('my_type', $e);
}
我们首先使用我们的连接服务启动了一个事务。然后,我们将操作包装在一个 try/catch 块中,以捕获在执行过程中可能抛出的任何异常。如果抛出了异常,我们将回滚事务,因为我们不想将任何内容提交到数据库,因为我们不知道什么失败了,我们的数据处于什么状态。最后,我们使用了 watchdog_exception() 辅助函数将异常记录到数据库日志中。请注意,在回滚之前记录这个异常将防止异常被写入数据库。
如果没有异常,操作会在$transaction变量被移除且不再在作用域内(通常在函数末尾)时立即提交。值得注意的是,如果在这次事务中我们调用另一个执行数据库操作的功能,这些操作将默认成为同一事务的一部分。因此,如果我们回滚,它们也会回滚;如果我们不回滚,它们会提交。这就是为什么在回滚之前调用数据库看门狗日志不会被保存的原因。
查询修改
在 Drupal 中,许多东西都可以通过各种钩子进行修改;查询也不例外。这意味着如果一个模块编写了之前看到的查询,其他模块可以通过实现hook_query_alter()来修改它。所以让我们考虑一个例子,看看它是如何工作的。
让我们假设以下查询,它简单地返回所有球员记录:
$result = $database->select('players', 'p')
->fields('p')
->execute();
假设另一个模块想要修改这个查询并限制结果只找到特定队伍的球员。有一个问题。我们的查询没有标记可以指示另一个模块需要修改的是这个查询。正如你可以想象的那样,在任何请求中都会运行大量的查询,因此识别查询变得不可能。这时就出现了查询标签。
之前的查询无法修改,因为它无法识别,因此hook_query_alter()甚至不会在其上触发。为了使其可修改,我们需要添加一个查询标签并使其可识别。查询构建器上有一个简单的方法可以做到这一点:addTag():
$result = $database->select('players', 'p')
->fields('p')
->addTag('player_query')
->execute();
查询标签是简单的字符串,可以从hook_query_alter()实现内部读取。因此,我们可以这样修改查询:
/**
* Implements hook_query_alter().
*/
function module_name_query_alter(Drupal\Core\Database\Query\AlterableInterface $query) {
if (!$query->hasTag('player_query')) {
return;
}
// Alter query
}
这个钩子的唯一参数是我们可以对其应用更改的查询对象。它还有读取标签的方法,例如hasTag()、hasAnyTag()或hasAllTags()。在之前的例子中,我们采取了防御性方法,如果查询不是关于我们的player_query标签查询,就简单地退出。我稍后会回到这个问题。
现在,让我们看看我们如何修改这个查询以实现我们的目标:
$query->join('teams', 't', 't.id = p.team_id');
$query->addField('t', 'name', 'team_name');
$query->condition('t.name', 'My Team');
如你所见,我们正在做与之前构建联合查询时类似的事情。我们将队伍表连接起来,添加其名称字段(作为额外奖励),并设置一个条件,只返回特定队伍的球员。轻而易举。
让我们再次回到我对这个钩子实现采取的防御性方法的评论。我个人更喜欢保持方法简短并尽早返回,而不是有一堆难以理解的嵌套条件。在面向对象的环境中,这通常很容易做到。然而,在过程式代码中,这会变得有点繁琐,因为你需要许多难以命名的私有函数,而且在钩子实现中,你可能需要添加多个代码块。例如,在我们的hook_query_alter()实现中,我们可能需要在稍后添加对另一个查询的修改。此外,由于我们尽早返回,我们需要添加另一个条件来检查两个标签,然后是一些更多的条件和if语句,以及更多的条件(好吧,发牢骚结束了)。从 PHP 的角度来看,在这种情况下,你会根据查询的标签将实际逻辑委托给另一个函数,要么使用简单的 switch 块,要么使用if条件。这样,如果出现新的标签,可以为其创建一个新的函数,并从 switch 块中调用它。然而,在这种情况下,我们可以做得更好。
有几个钩子,尤其是修改钩子,具有一般的目标,但也具有更具体的目标。在这个例子中,我们还有一个针对特定标签的hook_query_TAG_alter()钩子。因此,我们不是将任务委托给其他函数,而是可以实施更具体的:
/**
* Implements hook_query_TAG_alter().
*/
function module_name_query_player_query_alter(Drupal\Core\Database\Query\AlterableInterface $query) {
// Sure to alter only the "player_query" tagged queries.
}
因此,本质上,标签本身成为了函数名的一部分,我们不需要任何额外的函数。
更新钩子
在本章的开头,我们使用hook_schema()定义了两个表格,该函数与模块一起安装。为了重申,如果模块已经安装,我们可以通过使用drupal_install_schema()函数来触发模式安装。然而,如果我们稍后需要向teams表添加另一列,那该怎么办呢?我们的模块已经安装,模式也是如此;所以我们不能在生产环境中完全卸载它来再次触发模式创建,更不用说丢失数据了。幸运的是,有一个系统可以处理这种情况,即更新钩子——hook_update_N()——其中N代表模式版本。这些是按顺序命名的钩子实现,它们位于模块*.install文件中,并且在运行更新时被触发,无论是通过访问/update.php还是使用drush updatedb命令。
这些更新钩子的主要目的是对现有的数据库表进行模式更改。然而,部分原因是早期版本的 Drupal 中配置管理系统较弱,它们已经通过开发者的创造力发展成为一种更新各种类型配置或执行部署到下一个环境时的任务(甚至与内容相关的任务)的机制。帮助实现这一点的是传递给钩子实现的$sandbox参数,它可以用来批量这些操作(以防止执行超时)。我们在这里不会涉及这个方面,而是在未来的章节中讨论独立的批量 API,从中学到的经验你将能够在这里应用。相反,我们将看到如何实现这样的钩子以执行模式更新。
如前所述,这些钩子实现会放入*.install文件中。让我们看看一个例子:
/**
* Update hook for performing an update task.
*/
function my_module_update_8001(&$sandbox) {
// Do stuff
}
此钩子实现的 DocBlock 应该包含对其功能的描述。当运行更新时(无论是通过 UI 还是使用 Drush)会显示出来。
函数的名称是其最重要的方面之一。它以模块名称开头,然后是update,最后是模块的模式版本(如果我们希望此更新钩子实际运行,则是下一个版本);但模块的模式版本是什么?
安装后,Drupal 为每个模块设置一个模式版本:8000。在 Drupal 7 中,它是 7000,在 6 中是 6000。你得到了 Drupal 主要版本的差异。当更新钩子运行时,Drupal 将该模块的模式版本设置为更新钩子中找到的编号。所以,在前面的例子中,它将是 8001。这是为了跟踪所有更新钩子,并且不运行它们超过一次。按照惯例,但不是必需的,模式版本从左数第二个数字代表模块本身的主要版本号。例如,对于8.x-1.x版本,它将是 8101。
现在我们来看看如何通过更新钩子来修改我们的teams数据库表,并添加一个用于存储location字符串字段的列。我们首先想要做的是更新我们的hook_schema()实现,并将这些信息也添加进去。在我们的情况下,这不会做任何事情;然而,由于更新钩子的工作方式,我们仍然需要将其添加进去。我的意思是,如果一个模块首先安装并且其中已经存在更新钩子,那么这些更新钩子不会运行,但模块的模式版本会被设置为在模块中找到的最后一个更新钩子的编号。所以,如果我们不在hook_schema()内部添加我们的新列,那么在另一个站点(甚至是在卸载后当前站点)安装此模块将不会得到我们的新列。因此,我们需要考虑这两种情况。
在我们的teams表模式定义的字段定义中,我们可以添加以下列定义:
'location' => [
'description' => 'The team location.',
'type' => 'varchar',
'length' => 255,
],
就这么简单。接下来,我们可以实现一个更新钩子并将此字段添加到表中:
/**
* Adds the "location" field to the teams table.
*/
function sports_update_8001(&$sandbox) {
$field = [
'description' => 'The team location.',
'type' => 'varchar',
'length' => 255,
];
$schema = \Drupal::database()->schema();
$schema->addField('teams', 'location', $field);
}
在这里,我们使用了相同的字段定义,加载了数据库连接服务,并使用其模式对象将此字段添加到表中。代码本身相当直观,但还值得一提的是,这是一个我们不能注入服务的示例,因此我们必须静态地使用它。所以,对于这种情况,请不要感到难过。
接下来,我们可以使用 Drush 来运行更新:

当然,teams表现在有一个新列。如果您再次尝试运行更新,您会注意到没有可运行的更新,因为 Drupal 已将sports模块的模式版本设置为 8001。因此,下一个要运行的更新必须在末尾有 8002(或者,在任何情况下,大于 8001 且小于 9000 的任何数字)。
在前面的示例中,我们向一个现有的表中添加了一个新字段。然而,我们可能需要完全创建一个新表,甚至删除一个。数据库连接服务上的模式对象提供了相关方法来完成这些操作。以下是一些示例,但我建议您查看Drupal\Core\Database\Schema基类以了解可用的方法:
$schema->createTable('new_table', $table_definition);
$schema->addField('teams', 'location', $field);
$schema->dropTable('table_name');
$schema->dropField('table_name', 'field_to_delete');
$schema->changeField('table_name', 'field_name_to_change', 'new_field_name', $new_field_definition);
在使用更新钩子时,有几个注意事项需要考虑。例如,在钩子实际运行之前,您无法确定环境的状况,因此请确保您考虑到这一点。我建议您查看关于hook_update_N()的文档(api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Extension%21module.api.php/function/hook_update_N/8.2.x),并仔细阅读关于函数体的部分。
摘要
在本章中,我们探讨了与数据库 API 交互的基础知识。尽管在日常 Drupal 模块开发中,它在重要性上已经退步了很多,但了解它并能够与之工作是很重要的。
我们以创建我们自己的数据库表开始本章,以关系方式存储玩家和团队信息。我们这样做是使用一个 API,该 API 将定义转换为实际的表,而无需我们了解太多关于 MySQL 的知识。然而,SQL 术语和基本操作是每个开发者都应该熟悉的,尽管他们在 Drupal 的实际日常应用中可能并不常用。
然后,我们查看了一些示例,展示了如何使用更偏向 SQL 的语句编写方式以及查询构建器方法来运行SELECT、INSERT、UPDATE和DELETE查询。我们还看到了这些查询如何被封装到事务中(在支持的情况下),以便在最小化数据不完整或损坏的潜在风险的同时提交数据更改。最后,我们看到了如何使用查询标签来修改这些查询,这为其他模块通过贡献提供了另一个小的扩展点。然而,无论我们如何构建我们的查询,一个关键要点是使用此 API 对于与数据库的安全交互至关重要。此外,它还考虑了与 Drupal 可以工作的不同数据库类型的跨兼容性。
最后,我们探讨了更新钩子及其如何被用来对数据库表进行更改。不仅如此,它们还可以被用来执行一些可能需要编码并部署到下一个环境以运行一次的其他任务。然而,由于 Drupal 8 配置 API,这种需求已经显著减少。
在下一章中,我们将探讨自定义 Drupal 8 实体字段,并了解我们如何定义自己的字段;是的,我们将玩一些更多的插件。
第九章:自定义字段
在第六章《数据建模和存储》和第七章《您的自定义实体和插件类型》中,我们广泛地讨论了内容实体以及它们如何使用字段来存储它们应该表示的实际数据。然后,我们看到了这些字段如何除了与存储层交互以持久化数据之外,还扩展了 Typed Data API 类,以便在代码级别更好地组织这些数据。例如,我们看到了在实体上使用的BaseFieldDefinition实例实际上是数据定义(FieldConfig也是)。此外,我们还看到了起作用的 DataType 插件,即FieldItemList及其各自的项,这些项最终扩展了一个基本的 DataType 插件(在大多数情况下是Map)。此外,如果你还记得,当我们讨论这些项时,我提到了它们实际上是另一种插件的实例——FieldType。所以本质上,它们是一种插件类型,其插件扩展了另一种类型的插件。我建议如果你对此事不太清楚,请重新阅读那一节。
这些概念中的大多数都隐藏在实体 API 内部,只有开发人员才能看到和理解。然而,FieldType插件(以及它们对应的FieldWidget和FieldFormatter插件)脱颖而出,是网站构建者和内容编辑人员在 UI 中实际操作的主要事物之一。它们允许用户输入结构化数据并将其保存到数据库中。如果你还记得,我在第六章和第七章中提到了它们几次,并承诺会有一章介绍我们如何创建网站构建者可以添加到实体类型并用于输入数据的字段类型。好吧,这就是那一章,但首先,让我们快速回顾一下我们对它们的了解。
字段类型插件回顾
字段类型插件扩展了低级别的 TypedData API,以创建一种独特的方式,不仅表示数据(在实体的上下文中),而且将其存储到数据库中(以及其他一些内容)。它们主要被用作网站构建者可以添加到实体类型包中的字段类型。例如,一个纯文本字段或具有多个选项的选择列表。在 CMS 中,没有什么比这更常见了。
然而,它们也被用作实体基本字段类型。如果你还记得我们的产品实体类型的name字段定义,我们实际上确实使用了这些插件类型:
$fields['name'] = BaseFieldDefinition::create('string')
->setLabel(t('Name'))
->setDescription(t('The name of the Product.'))
->setSettings([
'max_length' => 255,
'text_processing' => 0,
])
->setDefaultValue('')
->setDisplayOptions('view', [
'label' => 'hidden',
'type' => 'string',
'weight' => -4,
])
->setDisplayOptions('form', [
'type' => 'string_textfield',
'weight' => -4,
])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
定义类的create()方法接受一个FieldType插件 ID。此外,下面代码中提供的view显示选项的type是一个FieldFormatter插件 ID,而下面代码中提供的form显示选项的type是一个FieldWidget插件 ID。
从这个回顾中,我坚持要你记住的一个关键教训:当你定义自定义实体时,要考虑你需要哪些类型的字段。如果有需要不同字段集的捆绑包,可配置字段是你的选择。否则,基础字段可能更合适。它们紧密地与你的实体类型类相关联,出现在所有捆绑包上(如果你需要的话),并鼓励你探索 Drupal 代码库,更好地理解现有的字段类型、小部件和格式化器(以及它们附带的相关设置)。
此外,当你定义基础字段时,要以你通过 UI 添加它们的方式相同地思考——我想使用哪种字段类型(找到一个FieldType插件),我希望用户如何与之交互(找到一个FieldWidget插件),以及我希望如何显示其值(找到一个FieldFormatter插件)。然后,检查相关的类以确定将与之一起使用的正确设置。
在本章中,我们将探讨如何创建我们自己的自定义字段类型,包括其默认的小部件和格式化器。为了保持一定的连贯性,我将要求你回顾我们在讨论 TypedData API 时使用的更复杂的例子——车牌。我们将创建一个专门用于存储车牌的字段类型,其格式如下:代码编号(正如我们在纽约车牌的例子中所见)。为什么?
目前,还没有字段类型能够准确表示这一点。当然,我们有简单的文本字段,但这意味着必须将构成车牌的两个数据部分都添加到同一个字段中,从而剥夺了它们的意义。当我们讨论 TypedData API 时,我们看到了其核心原则之一就是能够将意义应用于数据片段,以便理解$license_plate(例如)实际上是一张车牌,我们可以从中获取其代码和编号(以及如果我们想的话,一个一般性的描述)。类似于这一点(或者实际上是在这一点之上构建的),字段也是关于存储这些数据的。因此,除了在代码中理解它之外,我们还需要以相同的方式持久化它。也就是说,将单个数据片段放置在具有意义的单独表列中,以便也持久化那种意义。
Drupal 核心中有一个例子也做了同样的事情,那就是Text (formatted)字段。除了其字符串值外,此字段还存储每个值的格式,该格式在渲染时使用。如果没有这个格式,字符串值就会失去其意义,Drupal 也无法可靠地按照创建时的意图进行渲染。因此,你现在可以明白,字段从 TypedData 中吸取了“意义”的概念,并在需要时将其应用于存储。所以,在本章中,你将通过创建自己的车牌类型字段来学习这三种插件类型是如何工作的。让我们开始吧。
字段类型
创建字段的主要插件类型,正如我们讨论的那样,是 FieldType。它负责定义字段结构、如何在数据库中存储以及各种其他设置。此外,它还定义了一个默认的 widget 和 formatter 插件,当我们在 UI 中创建字段时将自动选择。您可以看到,单个字段类型可以与多个 widget 和 formatter 一起工作。如果存在更多,网站构建者可以在创建字段并将其添加到实体类型包时选择一个。
否则,它将是默认的;每个字段都需要一个,因为没有 widget,用户无法添加数据,没有 formatter,他们无法看到数据。同样,如您所预期的那样,widgets 和 formatters 也可以与多个字段类型一起工作。
我们在本节中创建的字段是用于车牌数据的,正如我们所看到的,它需要两个独立的信息片段:一个代码(例如州代码)和一个数字。世界各地的车牌比这更复杂,但我选择这个例子是为了保持简单。
我们新开发的 FieldType 插件需要放置在我们即将创建的新模块 license_plate 的 Plugin/Field/FieldType 命名空间内。虽然这不是强制性的,但类的名称应该以单词 Item 结尾。这在 Drupal 核心中是一个相当标准的事情,我们将遵循这一做法。因此,让我们来看看我们的 LicensePlateItem 插件实现,然后讨论代码:
namespace Drupal\license_plate\Plugin\Field\FieldType;
use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Plugin implementation of the 'license_plate_type' field type.
*
* @FieldType(
* id = "license_plate",
* label = @Translation("License plate"),
* description = @Translation("Field for creating license plates"),
* default_widget = "default_license_plate_widget",
* default_formatter = "default_license_plate_formatter"
* )
*/
class LicensePlateItem extends FieldItemBase {
use StringTranslationTrait;
}
我省略了类的内容,因为我们将会逐个添加方法并分别讨论。然而,首先,我们有插件注释,这非常重要。我们有了典型的插件元数据,如 ID、标签和描述,以及默认情况下将与该字段类型一起使用的 widget 和 formatter 插件的插件 ID。请记住这些,因为我们很快就会创建它们。
从经验来看,在创建字段类型时,通常会扩展一个已经存在的字段类型插件类,例如文本字段或实体引用。这是因为 Drupal 核心已经提供了一套很好的可用类型,通常你只需要对现有的一种类型进行一些调整,也许将它们组合起来或添加额外的功能。这使得事情变得更容易,你不必复制粘贴代码或自己再次想出它。然而,自然地,在某个时候,你将需要从 FieldItemBase 扩展,因为这是所有字段类型都需要扩展的基类。
然而,在我们的例子中,我们将直接从 FieldItemBase 抽象类扩展,因为我们希望我们的字段能够独立存在。此外,在这种情况下从任何现有的类扩展并不十分实用。但这并不意味着它与其他字段类型没有共性,例如 TextItem。
现在让我们来看看我们类中的第一个方法:
/**
* {@inheritdoc}
*/
public static function defaultStorageSettings() {
return [
'number_max_length' => 255,
'code_max_length' => 5,
] + parent::defaultStorageSettings();
}
在我们的类中,我们首先重写 defaultStorageSettings() 方法。父类方法返回一个空数组;然而,将父类方法返回的内容包含到我们自己的数组中仍然是一个好主意。如果父类方法在以后更改并返回某些内容,我们就会更加健壮。
这个方法的目的有两个:指定这个字段有哪些存储设置,并为它们设置一些默认值。此外,请注意,这是一个静态方法,这意味着我们不在插件实例内部。然而,你可能想知道什么是存储设置?
存储设置是应用于字段在其使用的任何地方的配置。正如你所知,一个字段可以被添加到实体类型的多个捆绑包中。在 Drupal 7 中,你可以跨实体类型重用字段,但现在这不再可能,因为字段现在只能在单个实体类型的捆绑包中重用。如果你需要在其他内容实体类型上使用它,你需要创建另一个该类型的字段。因此,存储设置是应用于这个字段在其附加的每个捆绑包中的那些设置。
它们通常处理与架构相关的事情——如何为该字段构建数据库表列——但它们也处理许多其他事情。更重要的是,要知道一旦字段表中有了数据,它们就不能更改。当你无法轻松更改包含数据的数据库表时,这很有意义。这种限制是我们强制执行的,正如我们稍后将看到的那样。
在我们的例子中,我们只有两个存储设置:number_max_length 和 code_max_length。这些设置将在定义存储车牌数据的两个表列的架构时使用(作为那些表字段可以存储的最大长度)。默认情况下,我们将使用在数字列上常用的 255 个字符的最大长度,以及代码列的 5 个字符,但这些只是默认值。用户在创建字段或编辑时可以更改它们,只要还没有数据。
接下来,我们可以编写我们的存储设置表单,允许用户在创建字段时提供实际设置:
/**
* {@inheritdoc}
*/
public function storageSettingsForm(array &$form, FormStateInterface $form_state, $has_data) {
$elements = [];
$elements['number_max_length'] = [
'#type' => 'number',
'#title' => $this->t('Plate number maximum length'),
'#default_value' => $this->getSetting('number_max_length'),
'#required' => TRUE,
'#description' => $this->t('Maximum length for the plate number in characters.'),
'#min' => 1,
'#disabled' => $has_data,
];
$elements['code_max_length'] = [
'#type' => 'number',
'#title' => $this->t('Plate code maximum length'),
'#default_value' => $this->getSetting('code_max_length'),
'#required' => TRUE,
'#description' => $this->t('Maximum length for the plate code in characters.'),
'#min' => 1,
'#disabled' => $has_data,
];
return $elements + parent::storageSettingsForm($form, $form_state, $has_data);
}
这个方法由主字段配置表单调用,我们需要返回一个表单元素数组,可以用来设置我们之前定义的存储设置值。我们有权访问嵌入此表单的 $form 和 $form_state 的主要部分,以及一个方便的布尔值 $has_data,它告诉我们这个字段中是否已经有数据。我们使用这个布尔值来禁用如果字段中有数据我们不希望更改的元素(在我们的例子中,是两个)。
因此,基本上,我们的表单由两个数字表单元素组成(都是必填项),其值默认为我们之前指定的长度。number表单元素还带有#min和#max属性,我们可以使用这些属性来限制数字的范围。显然,我们希望我们的最小长度是一个正数,即大于 1。如果你现在已经掌握了表单 API 的基础,这个方法相对容易理解。
最后,对于我们的存储处理,我们需要实现模式方法并定义我们的表列:
/**
* {@inheritdoc}
*/
public static function schema(FieldStorageDefinitionInterface $field_definition) {
$schema = [
'columns' => [
'number' => [
'type' => 'varchar',
'length' => (int) $field_definition->getSetting('number_max_length'),
],
'code' => [
'type' => 'varchar',
'length' => (int) $field_definition->getSetting('code_max_length'),
],
],
];
return $schema;
}
这是一个静态方法,但它接收当前字段的FieldStorageDefinitionInterface实例。从那里,我们可以访问用户在创建字段时保存的设置,并根据这些设置定义我们的模式。如果你在上一章讨论hook_schema()时注意到了,这应该对你来说已经很清晰了。我们需要返回的是一个按名称键控的列定义数组。因此,我们定义了两个varchar类型的列,其最大长度与用户配置的长度相同。当然,如果我们想有更多的存储设置,并使这个模式定义更加可配置,我们也可以做到。
使用这三个方法,我们的存储处理就完成了;然而,我们的字段类型还没有完成。我们还有几件事情要处理。
除了存储之外,正如我们讨论的,字段还通过 TypedData 结构在代码级别处理数据表示。因此,我们的字段类型需要定义其单个属性,为这些属性创建存储。为此,我们有两个主要方法:首先,实际定义属性,然后对它们设置一些潜在的约束:
/**
* {@inheritdoc}
*/
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
$properties['number'] = DataDefinition::create('string')
->setLabel(t('Plate number'));
$properties['code'] = DataDefinition::create('string')
->setLabel(t('Plate code'));
return $properties;
}
之前的代码与第六章中的代码非常相似,数据建模与存储,我们在讨论 TypedData 时提到了。再次强调,这是一个静态方法,需要为单个属性返回DataDefinitionInterface实例。我们选择分别称它们为number和code,并设置一些合理的标签——不会太复杂。
之前的代码实际上足以定义属性,但如果你还记得,我们的存储有一些最大长度限制,这意味着表列的长度是有限的。因此,如果进入我们字段的数据过长,数据库引擎将以不太优雅的方式抛出异常。换句话说,它会抛出一个大异常,我们无法接受这种情况。所以,为了防止这种情况,我们可以做两件事:在表单小部件上设置相同的最大长度,以防止用户输入过多的内容,并在我们的数据定义上添加约束。
第二个更重要,因为它确保数据在任何情况下都是有效的,而第一个只处理表单。然而,由于 Drupal 8 比其前一个版本更加面向 API,如果我们以编程方式创建实体并设置其字段值,我们将完全绕过表单。但是,不用担心;我们也会处理表单,这样我们的用户就可以有一个更好的体验,并且知道他们需要输入的值的最大大小。
因此,让我们添加以下约束:
/**
* {@inheritdoc}
*/
public function getConstraints() {
$constraints = parent::getConstraints();
$constraint_manager = \Drupal::typedDataManager()->getValidationConstraintManager();
$number_max_length = $this->getSetting('number_max_length');
$code_max_length = $this->getSetting('code_max_length');
$constraints[] = $constraint_manager->create('ComplexData', [
'number' => [
'Length' => [
'max' => $number_max_length,
'maxMessage' => $this->t('%name: may not be longer than @max characters.', [
'%name' => $this->getFieldDefinition()->getLabel() . ' (number)',
'@max' => $number_max_length
]),
],
],
'code' => [
'Length' => [
'max' => $code_max_length,
'maxMessage' => $this->t('%name: may not be longer than @max characters.', [
'%name' => $this->getFieldDefinition()->getLabel() . ' (code)',
'@max' => $code_max_length
]),
],
],
]);
return $constraints;
}
由于我们的领域类实际上实现了TypedDataInterface,因此它也必须实现getConstraints()方法(TypedData父类已经启动)。然而,我们可以覆盖它并提供基于我们字段值的自己的约束。
我们在这里采取的方法与我们在第六章中看到的添加约束的方法略有不同,数据建模和存储。我们不会直接将它们添加到数据定义中,而是会手动使用验证约束管理器(这是我们在第六章中看到的Constraint插件类型的插件管理器,数据建模和存储)来创建它们。这是因为字段使用一个特定的ComplexDataConstraint插件,它可以组合多个属性(数据定义)的约束。请注意,即使在这个字段中只有一个属性,我们仍然会使用这个约束插件。
在 Drupal 8 中,有很多类类型你不能注入依赖项,但FieldType插件就是其中之一。这是因为这些插件实际上建立在Map TypedData 插件之上,并且它们的经理不使用容器感知工厂进行实例化,而是将其委托给TypedDataManger服务,正如我们所看到的,它也不是容器感知的。因此,我们必须静态地请求我们需要的服务。
创建此约束插件所需的数据是一个多维数组,以属性名为键,包含每个属性的约束定义。因此,我们为两个属性都设置了Length约束,其选项表示最大长度以及如果超过该长度将显示的消息。如果我们愿意,我们也可以以相同的方式设置最小长度:min和minMessage。至于实际长度,我们将使用用户在创建字段时选择的值(存储最大值)。现在,无论表单小部件如何,我们的字段只有在遵守最大长度的情况下才会进行验证。
是时候使用以下两种方法来完成这门课程了:
/**
* {@inheritdoc}
*/
public static function generateSampleValue(FieldDefinitionInterface $field_definition) {
$random = new Random();
$values['number'] = $random->word(mt_rand(1, $field_definition->getSetting('number_max_length')));
$values['code'] = $random->word(mt_rand(1, $field_definition->getSetting('code_max_length')));
return $values;
}
/**
* {@inheritdoc}
*/
public function isEmpty() {
// We consider the field empty if either of the properties is left empty.
$number = $this->get('number')->getValue();
$code = $this->get('code')->getValue();
return $number === NULL || $number === '' || $code === NULL || $code === '';
}
使用generateSampleValue(),我们创建一些适合我们字段的随机单词。就是这样。这可以在配置文件或站点构建时用于填充字段的演示值。可以说,这不会是你的首要任务,但了解这一点是好的。
最后,我们有isEmpty()方法,用于确定字段是否有值。这看起来可能非常明显,但它是一个重要的方法,特别是对我们来说,您可能可以从实现中推断出原因。在 UI 中创建字段时,用户可以指定是否为必填项。然而,通常这适用于字段中的整个值集。另外,如果字段不是必填项,并且用户只输入车牌代码而没有号码,这种有用的值是什么?因此,我们想要确保两者在考虑此字段作为有值(非空)之前都有内容,这正是我们在该方法中检查的。
自从我们开始编写类以来,我们已经引用了一堆我们应该在顶部使用它们之前就引用的类:
use Drupal\Component\Utility\Random;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\TypedData\DataDefinition;
现在我们已经完成了实际的插件类,还有最后一件事需要我们注意,这是我们往往容易忘记的,包括我自己:配置模式。我们的新字段是一个可配置的字段,其设置被存储。猜猜在哪里?在配置中。此外,如您所记得的,所有配置都需要通过模式来定义。Drupal 已经处理了来自父级的存储设置。然而,我们需要包含我们自己的。因此,让我们创建典型的license_plate.schema.yml(在config/schema内部),我们将在这里放置我们在这个模块中需要的所有模式定义:
field.storage_settings.license_plate_type:
type: mapping
label: 'License plate storage settings'
mapping:
number_max_length:
type: integer
label: 'Max length for the number'
code_max_length:
type: integer
label: 'Max length for the code'
实际的定义已经熟悉,所以唯一需要解释的是其命名。模式是field.storage_settings.[field_type_plugin_id]。Drupal 将动态读取模式并将其应用于正在导出的实际FieldStorageConfig实体设置。
这就是我们的FieldType插件的全部内容。在创建此类新字段时,我们可以配置两个存储设置(如果数据库中已有实际字段数据,则编辑时将禁用):

除非我们仅通过编程或通过 API 来管理使用此字段的实体,否则它实际上将没有用处,因为没有与之协同工作的小部件或格式化工具。因此,我们还需要创建这些小部件。实际上,在我们可以创建此类字段之前,我们需要确保我们已经有小部件和格式化插件。
字段小部件
我们的新车牌字段类型可以添加到实体类型中,但用户将无法使用它。为此,我们至少需要一个小部件。然而,给定字段类型可以与多个小部件协同工作。因此,让我们创建我们在字段类型注释中引用的默认车牌小部件插件,它属于我们模块的Plugin/Field/FieldWidget命名空间:
namespace Drupal\license_plate\Plugin\Field\FieldWidget;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Plugin implementation of the 'default_license_plate_widget' widget.
*
* @FieldWidget(
* id = "default_license_plate_widget",
* label = @Translation("Default license plate widget"),
* field_types = {
* "license_plate"
* }
* )
*/
class DefaultLicensePlateWidget extends WidgetBase {
use StringTranslationTrait;
}
再次强调,我们首先检查了注释和类父级,只是稍微看了看。你会发现没有什么特别复杂的地方,除了可能有点复杂的field_types键,它指定了此小部件可以与之协同工作的FieldType插件 ID。就像字段类型可以有多个小部件一样,小部件也可以与多个字段类型协同工作。此外,我们在这里指定它很重要,否则网站构建者将无法使用我们的车牌字段类型来使用这个小部件。
我们扩展了WidgetBase,它实现了必需的WidgetInterface,并为所有子类提供了一些常见默认值。
在类内部,我们首先处理设置。首先,我们将定义这个小部件有哪些设置,并设置这些设置的默认值:
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'number_size' => 60,
'code_size' => 5,
'fieldset_state' => 'open',
'placeholder' => [
'number' => '',
'code' => '',
],
] + parent::defaultSettings();
}
我们有一些特定于如何为我们的字段配置表单小部件的设置。我们将使用前一段代码中提到的前两个设置来限制表单元素的尺寸。这实际上并不能阻止用户填写较长的值,但会为他们提供一个关于值应该有多长的良好指示。然后,我们有fieldset_state设置,我们将用它来指示用于组合两个车牌文本字段的表单字段集默认是打开还是关闭。我们将在下一分钟看到这一点。最后,每个文本字段都可以有一个占位符值(可能)。因此,我们也设置了该设置。请注意,这些都是我们编写的并且对我们字段有意义的设置。如果您想的话,可以添加自己的设置。
接下来,我们有用于配置这些设置的表单(作为小部件配置的一部分):
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$elements = [];
$elements['number_size'] = [
'#type' => 'number',
'#title' => $this->t('Size of plate number textfield'),
'#default_value' => $this->getSetting('number_size'),
'#required' => TRUE,
'#min' => 1,
'#max' => $this->getFieldSetting('number_max_length'),
];
$elements['code_size'] = [
'#type' => 'number',
'#title' => $this->t('Size of plate code textfield'),
'#default_value' => $this->getSetting('code_size'),
'#required' => TRUE,
'#min' => 1,
'#max' => $this->getFieldSetting('code_max_length'),
];
$elements['fieldset_state'] = [
'#type' => 'select',
'#title' => $this->t('Fieldset default state'),
'#options' => [
'open' => $this->t('Open'),
'closed' => $this->t('Closed')
],
'#default_value' => $this->getSetting('fieldset_state'),
'#description' => $this->t('The default state of the fieldset which contains the two plate fields: open or closed')
];
$elements['placeholder'] = [
'#type' => 'details',
'#title' => $this->t('Placeholder'),
'#description' => $this->t('Text that will be shown inside the field until a value is entered. This hint is usually a sample value or a brief description of the expected format.'),
];
$placeholder_settings = $this->getSetting('placeholder');
$elements['placeholder']['number'] = [
'#type' => 'textfield',
'#title' => $this->t('Number field'),
'#default_value' => $placeholder_settings['number'],
];
$elements['placeholder']['code'] = [
'#type' => 'textfield',
'#title' => $this->t('Code field'),
'#default_value' => $placeholder_settings['code'],
];
return $elements;
}
我们必须返回我们的小部件设置元素,这些元素将被添加到一个更大的表单中(作为参数传递)。前三个表单元素没有什么特别之处。我们有两个number字段和一个select列表来控制我们在默认值中看到的第一个三个设置。对于前两个设置,我们希望数字是正数,并且最大长度与我们在存储中设置的相同。我们不希望小部件超过这个长度。然而,如果我们想的话,我们可以缩短元素的尺寸。
两个占位符值的文本字段被包裹在一个details表单元素中。后者是一个可以打开或关闭的字段集,可以包含其他表单元素。我们将使用它来包裹用户将输入车牌数据的实际文本字段。
当用户配置小部件时,之前的格式将看起来像这样:

最后,我们有小部件设置的摘要,它将在我们的字段的“管理表单显示页面”上显示:
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$summary[] = $this->t('License plate size: @number (for number) and @code (for code)', ['@number' => $this->getSetting('number_size'), '@code' => $this->getSetting('code_size')]);
$placeholder_settings = $this->getSetting('placeholder');
if (!empty($placeholder_settings['number']) && !empty($placeholder_settings['code'])) {
$placeholder = $placeholder_settings['number'] . ' ' . $placeholder_settings['code'];
$summary[] = $this->t('Placeholder: @placeholder', ['@placeholder' => $placeholder]);
}
$summary[] = $this->t('Fieldset state: @state', ['@state' => $this->getSetting('fieldset_state')]);
return $summary;
}
此方法需要返回一个字符串数组,这些字符串将组成设置摘要。这就是我们现在所做的事情:读取我们所有的设置值并以人性化的方式列出它们。最终结果将看起来像这样:

接下来,我们将必须实现字段小部件插件的内核——用于输入字段数据的实际表单:
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$element['details'] = [
'#type' => 'details',
'#title' => $element['#title'],
'#open' => $this->getSetting('fieldset_state') == 'open' ? TRUE : FALSE,
'#description' => $element['#description'],
] + $element;
$placeholder_settings = $this->getSetting('placeholder');
$element['details']['code'] = [
'#type' => 'textfield',
'#title' => $this->t('Plate code'),
'#default_value' => isset($items[$delta]->code) ? $items[$delta]->code : NULL,
'#size' => $this->getSetting('code_size'),
'#placeholder' => $placeholder_settings['code'],
'#maxlength' => $this->getFieldSetting('code_max_length'),
'#description' => '',
'#required' => $element['#required'],
];
$element['details']['number'] = [
'#type' => 'textfield',
'#title' => $this->t('Plate number'),
'#default_value' => isset($items[$delta]->number) ? $items[$delta]->number : NULL,
'#size' => $this->getSetting('number_size'),
'#placeholder' => $placeholder_settings['number'],
'#maxlength' => $this->getFieldSetting('number_max_length'),
'#description' => '',
'#required' => $element['#required'],
];
return $element;
}
乍一看,这似乎有些复杂,但我们会将其分解,你会发现它实际上与你在前几章中学到的内容相符。
传递给此方法的第一个参数是此字段的全部值列表。请记住,每个字段可以有多个值,因此使用FieldItemListInterface实例来持有它们。因此,我们可以从列表中获取任何项目的值。第二个参数是列表中项目的实际增量,我们可以使用它来定位正在构建表单的那个项目(以便检索默认值)。然后,我们有一个应该实际返回的$element数组,但它包含了一些基于字段配置已经为我们准备好的数据。例如,在创建字段时,如果我们将其设置为必填项,那么这个$element已经包含了表单属性#required => TRUE。同样,它包含了字段的权重(与其他实体类型上的字段相比)、#title属性以及许多其他属性。我建议你调试这个数组,看看里面有什么。你还可以查看WidgetBase::formMultipleElements()和WidgetBase::formSingleElement(),看看这个数组是如何准备的。最后,我们获取了字段元素嵌入的较大表单的表单定义和表单状态信息。
因此,我们在方法内部对所拥有的数据进行了一些创造性的处理。对于单值(列)字段,通常会将其添加到$element数组中,然后简单地返回。然而,我们有两个值想要包裹在一个漂亮的可折叠字段集中,所以我们为它创建了一个details元素。
就在这个元素上,我们复制了用户在创建字段时指定的字段标题和描述,这些信息已经以$element数组的形式为我们准备好了。这是因为这些信息与整个字段相关,而不仅仅是其中一个值。此外,我们还设置了默认的#open状态,使其与小部件设置中存储的内容一致。最后,我们将$elements数组中找到的其他值也添加进来,因为我们希望继承它们。
注意,我本可以将#title和#description也设置为继承,但我故意添加了它们,以便让你更容易看到。
接下来,在我们的 details 元素内部,我们可以添加车牌代码和编号的两个文本字段。对于这两个字段,我们使用小部件设置来设置元素大小和占位符值,以及一个等于字段项存储的最大长度值。这将防止用户提供比数据库列可以处理的更长的值。两个表单元素的默认值将被设置为这些属性的实际情况字段值,通过使用当前 delta 键从项目列表中检索。最后,我们将 #required 属性设置为用户为该字段配置的任何内容。这个属性在父 details 元素上将是无用的,所以我们必须将其移动到实际的文本字段。就这样了。
我们可以实施,并且在我们这个案例中,必须实施的最后一个方法是在提交时对字段值进行一些准备:
/**
* {@inheritdoc}
*/
public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
foreach ($values as &$value) {
$value['number'] = $value['details']['number'];
$value['code'] = $value['details']['code'];
unset($value['details']);
}
return $values;
}
下面是会发生什么。从我们的属性定义中,我们的字段期望有两个属性:编号和代码。然而,提交此表单将只显示一个名为 "details" 的属性,因为这是我们任意命名的字段集表单元素(它包含属性)。由于我们做出了这个选择,我们现在需要稍微调整提交的值以匹配预期的属性。换句话说,我们必须将编号和代码属性带到 $values 数组的顶层,并取消 details 元素的设置,因为它在提交后不再需要。因此,现在,字段以以下格式接收数组:
$values = [
'number' => 'My number',
'code' => 'My code'
];
如果你还记得,这也是如果我们想在字段上设置此值时,会传递给字段 set() 方法的。看看下面的例子:
$node->set('field_license_plate', ['code' => 'NY', 'number' => '63676']);
这样,我们的小部件就完成了;嗯,还不完全。我们应该确保我们使用所有新引用的类在顶部:
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
此外,我们又忘记了配置模式。让我们不要再犯同样的错误。在写入字段存储模式的同一文件中,我们可以添加小部件设置的定义:
field.widget.settings.default_license_plate_widget:
type: mapping
label: 'Default license plate widget settings'
mapping:
number_size:
type: integer
label: 'Number size'
code_size:
type: integer
label: 'Code size'
fieldset_state:
type: string
label: 'The state of the fieldset which contains the two fields: open/closed'
placeholder:
type: mapping
label: 'The placeholders for the two fields'
mapping:
number:
type: string
label: 'The placeholder for the number field'
code:
type: string
label: 'The placeholder for the code field'
它的工作方式与之前相同:一个以 field.widget.settings. 开头的动态模式名称,并在末尾包含实际的插件 ID;并且内部,我们有我们之前看到的属性映射。有了这个,我们真的就完成了。
字段格式化器
好吧,所以我们的字段现在也有了一个用户可以输入数据的工具。让我们创建默认字段格式化器,使字段完整。
在实际编码之前,让我们确定我们想要的格式化器看起来和表现如何。默认情况下,我们希望车牌数据被渲染如下:
<span class="license-plate—code">{{ code }}</span> <span class="license-plate—number">{{ number }}</span>
因此,每个组件都被包裹在其自己的 span 标签内,并应用了一些方便的类。或者,我们可能希望将两个值连接到一个单独的 span 标签中:
<span class="license-plate">{{ code }} {{ number }}</span>
这可以在格式化器中作为一个设置,允许用户选择首选的输出。那么,我们就这么做吧。
字段格式化器位于我们模块的 Plugin/Field/FieldFormatter 命名空间内,所以让我们继续创建我们自己的:
namespace Drupal\license_plate\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Plugin implementation of the 'default_license_plate_formatter' formatter.
*
* @FieldFormatter(
* id = "default_license_plate_formatter",
* label = @Translation("Default license plate formatter"),
* field_types = {
* "license_plate"
* }
* )
*/
class DefaultLicensePlateFormatter extends FormatterBase {
use StringTranslationTrait;
}
再次,我们首先检查注解,它看起来非常不出所料。它看起来几乎与之前我们的小部件注解一样,因为格式化器也可以用于多种字段类型。
该类扩展了FormatterBase,它本身实现了必需的FormatterInterface。到现在为止,你应该已经认识到了插件使用的模式——它们都必须实现一个接口,并且通常扩展一个基类,这为所有这些类型的插件提供了一些共同的帮助功能。字段也不例外。
在这个格式化器类内部,我们首先再次处理其自己的设置(如果我们需要的话)。碰巧的是,我们有一个可配置的格式化器设置,让我们定义它并提供一个默认值:
/**
* {@inheritdoc}
*/
public static function defaultSettings() {
return [
'concatenated' => 1,
] + parent::defaultSettings();
}
这与之前的插件类似。concatenated设置将用于确定此字段的输出,根据我们之前讨论的两个选项。
接下来,不出所料,我们需要表单来管理这个设置:
/**
* {@inheritdoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
return [
'concatenated' => [
'#type' => 'checkbox',
'#title' => $this->t('Concatenated'),
'#description' => $this->t('Whether to concatenate the code and number into a single string separated by a space. Otherwise the two are broken up into separate span tags.'),
'#default_value' => $this->getSetting('concatenated'),
]
] + parent::settingsForm($form, $form_state);
}
再次强调,这并没有什么特别之处;我们有一个复选框,我们用它来管理布尔值(用 1 或 0 表示)。最后,就像小部件一样,我们还可以为格式化器定义一个摘要显示:
/**
* {@inheritdoc}
*/
public function settingsSummary() {
$summary = [];
$summary[] = t('Concatenated: @value', ['@value' => (bool) $this->getSetting('concatenated') ? 'Yes' : 'No']);
return $summary;
}
在这里,我们只是打印出配置的任何内容的可读名称,当在 UI 中管理字段显示时,它将看起来与小部件一样。一致性是件好事。
现在,我们已经到达了任何字段格式化器的最关键部分——实际的显示:
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($items as $delta => $item) {
$elements[$delta] = $this->viewValue($item);
}
return $elements;
}
/**
* Generate the output appropriate for one field item.
*
* @param \Drupal\Core\Field\FieldItemInterface $item
* One field item.
*
* @return array
*/
protected function viewValue(FieldItemInterface $item) {
$code = $item->get('code')->getValue();
$number = $item->get('number')->getValue();
return [
'#theme' => 'license_plate',
'#code' => $code,
'#number' => $number,
'#concatenated' => $this->getSetting('concatenated')
];
}
用于此的方法是viewElements(),但对于列表中的每个元素,我们只是简单地委托给一个辅助方法,因为正如你记得的那样,字段本身是一个值项的列表(取决于字段的基数),即使字段中只有一个值。这些项通过一个增量来标识,我们也用它来标识从方法返回的$elements数组。
对于列表中的每个单独的项目,我们随后使用之前看到的 TypedData 访问器检索车牌代码和数字的值。记住,在这个阶段,我们正在处理一个FieldItemInterface,其get()方法返回代表实际值的 DataType 插件,在我们的例子中是StringData。因为这就是我们的字段属性定义:
$properties['number'] = DataDefinition::create('string')
->setLabel(t('Plate number'));
此外,这些插件中的实际值是用户实际提供的字符串表示。我们使用这些值与设置一起确定是否要连接并将它们传递给一个自定义主题函数(我们尚未定义)。需要记住的重要事情是,我们需要为每个项目返回一个渲染数组。这可以是任何东西;考虑以下示例:
return [
'#markup' => $code . ' ' . $number,
];
然而,这看起来并不美观,也不可配置或可覆盖。因此,我们选择了一个干净的新主题函数,它接受这三个参数:
/**
* Implements hook_theme().
*/
function license_plate_theme($existing, $type, $theme, $path) {
return [
'license_plate' => [
'variables' => ['code' => NULL, 'number' => NULL, 'concatenated' => TRUE],
],
];
}
我们将concatenated的值默认设置为TRUE,因为我们也在defaultSettings()内部使用了它。我们必须保持一致。与这个设置一起的模板文件license-plate.html.twig也非常简单:
{% if concatenated %}
<span class="license-plate">{{ code }} {{ number }}</span>
{% else %}
<span class="license-plate—code">{{ code }}</span> <span class="license-plate—number">{{ number }}</span>
{% endif %}
根据我们的设置,我们以不同的方式输出标记。现在,其他模块和主题有许多选项可以改变这种输出:
-
他们可以完全创建一个新的格式化插件。
-
他们可以在主题内部覆盖模板。
-
他们可以改变由这个主题钩子使用的模板。
这就是格式化插件本身的内容,但这次我们并没有忘记配置方案。尽管我们只有一个微不足道的布尔值来定义,但仍然需要这样做:
field.formatter.settings.default_license_plate_formatter:
type: mapping
label: 'Default license plate formatter settings'
mapping:
concatenated:
type: boolean
label: 'Whether to concatenate the two fields into one single span tag'
这与其他的运作方式相同,但前缀不同:field.formatter.settings。
这样,我们就有了字段格式化器。然而,我们不应该忘记,格式化插件类顶部的缺失use语句:
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
现在在清除缓存后,新的字段类型可以用来创建字段。
然而,我认为我们还可以做得更好。由于我们正在处理涉及某些已知格式的车牌,如果我们使我们的字段可配置以提供在输入数据时可以使用的车牌代码列表,会怎么样?这将带来额外的益处,即我们了解有关字段的新知识——字段设置。
字段设置
当我们创建字段类型时,我们指定了一些存储设置,并看到这些通常与底层存储相关联,一旦字段中有数据,就不能更改。这是因为数据库在它们中有数据时很难更改表列。然而,除了存储设置之外,我们还有称为字段设置的东西,它是特定于某个实体捆绑的字段实例的。更重要的是,它们(或应该)可以在字段创建后并具有数据的情况下进行更改。这样一个字段设置的例子,它可以从 Drupal 核心在所有字段类型中获取,是“必需”选项,它标记一个字段为必需或不必需。那么,让我们看看我们如何添加自己的字段设置来配置我们想要做的事情。
回到我们的LicensePlateItem插件类,我们首先添加默认字段设置:
/**
* {@inheritdoc}
*/
public static function defaultFieldSettings() {
return [
'codes' => '',
] + parent::defaultFieldSettings();
}
这是我们一直在看到的相同模式,我们指定了设置是什么以及它们的默认值是什么。然后,正如预期的那样,我们需要表单,用户可以在其中指定每个字段实例的设置值:
/**
* {@inheritdoc}
*/
public function fieldSettingsForm(array $form, FormStateInterface $form_state) {
$element = [];
$element['codes'] = [
'#title' => $this->t('License plate codes'),
'#type' => 'textarea',
'#default_value' => $this->getSetting('codes'),
'#description' => t('If you want the field to be have a select list with license plate codes instead of a textfield, please provide the available codes. Each code on a new line.')
];
return $element;
}
所以我们在这里提供的是一个textarea表单元素,管理员可以通过它添加多个车牌代码,每行一个。在我们的小工具中,我们将使用这些代码并将它们转换成一个选择列表。然而,在我们这样做之前,我们需要提供这个新设置的配置方案:
field.field_settings.license_plate_type:
type: mapping
label: 'License plate field settings'
mapping:
codes:
type: string
label: 'Codes'
在这个基础上,我们可以转向我们的字段小工具并做出必要的更改。
在formElement()方法中,让我们用以下内容替换我们定义代码表单元素的部分:
$this->addCodeField($element, $items, $delta, $placeholder_settings);
由于确定该元素的逻辑取决于配置,所以它稍微复杂一些,因此最好将其重构为单独的方法。现在让我们把它写出来:
/**
* Adds the license plate code field to the form element.
*
* @param $element
* @param \Drupal\Core\Field\FieldItemListInterface $items
* @param $delta
* @param $placeholder_settings
*/
protected function addCodeField(&$element, FieldItemListInterface $items, $delta, $placeholder_settings) {
$element['details']['code'] = [
'#title' => t('Plate code'),
'#default_value' => isset($items[$delta]->code) ? $items[$delta]->code : NULL,
'#description' => '',
'#required' => $element['#required'],
];
$codes = $this->getFieldSetting('codes');
if (!$codes) {
$element['details']['code'] += [
'#type' => 'textfield',
'#placeholder' => $placeholder_settings['code'],
'#maxlength' => $this->getFieldSetting('code_max_length'),
'#size' => $this->getSetting('code_size'),
];
return;
}
$codes = explode("\r\n", $codes);
$element['details']['code'] += [
'#type' => 'select',
'#options' => array_combine($codes, $codes),
];
}
我们首先定义代码表单元素的默认值,例如标题、默认值和值。然后,我们获取我们刚刚创建的codes设置的字段设置。请注意,getFieldSetting()和getFieldSettings()委托给实际的字段类型,并返回存储和字段设置的组合。因此,我们不需要使用单独的方法。然而,这意味着你可能应该坚持为两个类别使用不同的设置名称。
然后,如果我们在这个特定的字段实例中没有配置任何代码,我们就像以前一样构建我们的文本字段表单元素。否则,我们将它们拆分成一个数组,并在选择列表表单元素中使用它们。此外,请注意,在这种情况下,我们不再需要应用任何长度限制,因为选择列表固有的验证已经足够。不在原始选项列表中的值将被视为无效。
这就是全部了。现在,该字段可以被配置为默认为开放文本字段以添加车牌代码,或者为预定义的选择列表。同样,同一个字段可以以这两种方式在两个不同的包中使用,这很方便。
使用我们的自定义字段类型作为基本字段
在本章的开头,我强调了理解字段(类型、小部件和格式化器)的构成的重要性,以便轻松地在自定义实体类型上定义基本字段。这种理解使您能够导航到 Drupal 核心代码,发现它们的设置并在基本字段中使用它们。因此,让我们通过查看我们的新字段如何在自定义实体类型上定义为一个基本字段来巩固这种理解。
这里有一个示例,我们实际上使用了为每个插件定义的所有可用设置。请注意,任何未指定的设置都将默认为我们已在相关默认方法中指定的值,如下所示:
$fields['plate'] = BaseFieldDefinition::create('license_plate')
->setLabel(t('License plate'))
->setDescription(t('Please provide your license plate number.'))
->setSettings([
'number_max_length' => 255,
'code_max_length' => 5,
'codes' => implode("\r\n", ['NY', 'FL', 'IL']),
])
->setDisplayOptions('view', [
'label' => 'above',
'type' => 'default_license_plate_formatter',
'weight' => 5,
'settings' => [
'concatenated' => 0,
]
])
->setDisplayOptions('form', [
'type' => 'default_license_plate_widget',
'weight' => 5,
'settings' => [
'number_size' => 60,
'code_size' => 5,
'fieldset_state' => 'open',
'placeholder' => [
'number' => '',
'code' => '',
],
]
])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
这与我们之前看到的情况非常相似。对于create()方法,我们使用FieldType插件 ID。在setSettings()方法中,我们传递存储和字段设置。然后,它们将被适当地使用。请注意,由于codes设置以字符串形式存储,代码之间由换行符分隔,因此我们需要相应地添加它。
类似地,对于view和form显示选项,我们分别使用格式化器和小部件插件 ID,并在settings数组中传递我们定义的任何设置。最后,setDisplayConfigurable()表示所有这些格式化器和小部件的设置也可以通过 UI 进行配置。这样做将BaseFieldDefinition转换为BaseFieldOverride,因为它需要存储配置的覆盖。
这应该是对你的回顾,因为我们已经在前面的章节中涵盖了所有这些概念。
摘要
在本章中,我们探讨了如何创建自定义字段,这些字段可以被网站构建者(和开发者)添加到实体类型中。这暗示了定义三种插件类型:FieldType、FieldWidget和FieldFormatter,每种类型都有其自己的职责。第一种定义了实际的字段,其存储和单个数据属性,使用 TypedData API。第二种定义了用户在创建或编辑使用该字段的实体时可以输入字段数据的表单。第三种定义了在查看实体时如何显示该字段内的值。
我们还看到,这些插件都可以有任意数量的可配置设置,这些设置可以用来自定义字段——既包括小部件的工作方式,也包括值的显示方式。此外,这些设置是导出字段配置的一部分,因此我们看到了如何定义它们各自的配置模式。
最后,我们还看到了除了通过 UI 创建我们的新字段外,开发者还可以将其作为基础字段添加到实体类型中,使其在该实体类型的所有捆绑包中可用。
在下一章中,我们将讨论访问控制,这是一个非常重要的主题,因为我们需要确保我们的数据和功能只在我们需要的时候,向想要的用户暴露。
第十章:访问控制
在前面的章节中,我们已经讨论了许多主题,但我们故意在许多主题中省略了一个重要的方面——访问控制。我们涵盖的大部分内容以某种方式或另一种方式涉及访问,但我们将其排除在我们的讨论之外,以使事情更加直接。然而,访问控制对于 Drupal 开发来说是一个极其重要的主题,因为它几乎影响我们做的每一件事。因此,为此目的,我们有一个专门的章节,我们将涵盖你需要知道的最重要的事情,以便保持你的应用程序安全。
当我说“安全”时,我并不是指以安全的方式编写代码来防止你的网站被黑客攻击。为此,我们在书的末尾有一个附录,为你提供一些指导。相反,我的意思是程序化地处理访问控制,以确保你的页面和任何其他资源只对正确的用户可访问。
在本章中,除了介绍一些独立的概念之外,我们还将回顾一些先前的主题,并了解我们如何在那个环境中应用访问控制。我们将从讨论 Drupal 在高级别如何看待访问限制开始,然后深入到更具体和复杂的例子。同样,我们还将看到代码,以便更好地理解我们在谈论什么。
然而,我们将在本章中具体学习什么呢?
首先,我们将介绍 Drupal 的角色和权限访问系统,并了解我们如何在代码中创建它们。对我们作为模块开发者来说,更重要的是,我们将了解我们如何以编程方式检查用户是否有权限。这仍然是在保持事情一般性的同时进行的。
接下来,我们将通过查看路由权限来深入了解更多有趣的内容。在这里,我们有巨大的灵活性,我们将探讨我们可以用来限制对自定义和现有路由的访问的多种方法——从简单的基于权限的访问控制到动态面向服务的访问处理器。
在覆盖了路由之后,我们将查看实体以及访问控制如何与它们一起工作。在这个过程中,我们将对我们在第七章中创建的“你自己的自定义实体和插件类型”的 Product 实体进行一些工作。第七章。此外,我们还将讨论节点访问权限系统,这是一个针对节点实体类型的特定访问控制的有力方式。
最后,我们还将查看块插件,并了解我们如何控制访问以及确保它们在页面上渲染。块可以有一些上下文规则,这些规则决定了它们是否在添加到的区域中的某个页面上显示。因此,我们也会就此进行一些讨论。
本章的目的是汇集您作为 Drupal 8 模块开发者开始所需的所有与访问控制相关的方面。然而,您可以期待更多,因此,本章也可以作为资源,用于返回阅读您可能在自己的项目中想要使用的某些访问控制方法,而不是让它们散布在书中。
Drupal 访问系统简介
如果您已经在 Drupal 8 中做过一些站点构建或对之前的 Drupal 版本有经验,您可能已经对角色和权限有所了解。如果没有,无需担心,因为我们将简要讨论这些是如何工作的。
实际上,使 Drupal 特别的一个因素是其开箱即用的灵活访问系统,该系统基于用户角色和权限。角色是可以赋予用户的属性。后者可以分配多个角色,但始终至少有默认的认证用户角色。权限是可以分配给角色的个别访问指示器。通过传递属性,用户拥有分配给他们的所有角色的权限。因此,最终结果是按角色划分的权限矩阵,这正是它在 UI 中的admin/people/permissions视图中所展示的:

Drupal 核心默认包含三个角色——匿名用户、认证用户和管理员。此外,默认情况下,Drupal 核心(和贡献)模块已经定义了大量权限,可供分配给各种角色。
匿名用户角色相当直观,可以用作所有匿名用户应拥有的权限的集合——即未认证的用户。同样,认证用户角色在登录时自动分配给所有用户(且不能被移除)。因此,它可以用作所有认证用户应拥有的权限的集合。
超级管理员用户(ID 为 1 的那个用户)实际上拥有该网站的所有权限,无需显式分配角色或权限。大多数情况下,它绕过了任何给定子系统中大部分的访问控制。
内部角色和权限
角色是配置实体(user_role),由Role实体类型类表示。它们可以通过 UI 创建,并作为配置导出,以便在所有环境中可用。因此,在您的代码中定义角色时,您不需要做很多事情,只需在 UI 中按需创建它们并将它们导出到配置即可。如您所记得的,如果您想由您的模块提供角色,请将导出的 YAML 文件添加到config/install文件夹中(并删除 UUID)。有关更多信息,请参阅第六章,数据建模和存储。
另一方面,权限是一个自定义构造。在 Drupal 7 中,它们通过实现 hook_permissions() 来定义,但现在是通过 YAML 文件创建的(与我们定义菜单链接的方式非常相似)。然而,它们不是插件,而是由核心用户模块创建的自定义构造。PermissionHandler 服务负责读取所有 YAML 文件并确定网站上所有现有的权限。这不是你需要担心的事情,因为你不会与这个服务交互。你主要会对定义新权限、检查用户是否拥有这些权限,或在各种访问上下文中设置这些权限感兴趣。
定义权限
在自定义模块中创建权限的方式是创建一个 *.permissions.yml 文件,并在其中添加定义。考虑以下示例:
administer my feature:
title: 'Administer my feature'
restrict access: true
在这个例子中,administer my feature 是权限的机器名称,实际上是最重要的部分。这就是你将在代码中用它来引用的部分。然后,我们有一个标题,它会在我们之前看到的权限管理页面上显示。最后,我们有一个 restrict access 键,通过它可以指定我们是否需要在权限管理页面上输出有关安全影响的警告,如下所示:警告:仅授予可信角色;此权限具有安全影响:

这是为了表明我们的权限更加敏感,管理员应该注意将它们分配给谁。然而,这个选项可以省略(实际上在大多数情况下都是这样)。
你可能已经注意到这种定义权限的静态性质。换句话说,我们硬编码了权限名称,并且只有一个权限。在大多数情况下,这将是可行的。然而,有时你可能需要根据应用程序中的其他因素动态定义多个权限。为此,我们可以使用权限回调。
例如,节点模块为管理其每个捆绑包定义了单独的权限,这是有意义的。一些角色应该有权访问某些捆绑包,而其他角色应该有权访问其他捆绑包。然而,它无法知道在任何给定时刻它将有哪些捆绑包。因此,它使用权限回调:
permission_callbacks:
- \Drupal\node\NodePermissions::nodeTypePermissions
这可以在 node.permissions.yml 文件中找到,就像静态定义的那样,但它将获取权限的责任委托给 NodePermissions 类的 nodeTypePermissions 方法。这是我们用来在路由中定义控制器相同的符号。事实上,相同的类解析器被用来实例化它。
检查用户凭据
只要你有那个用户账户,你就可以轻松地检查一个给定的用户是否应该访问某个特定资源。在这里,你可能会遇到两种情况:
-
你想“调查”当前用户。
-
您想“调查”的是特定用户,而不一定是当前用户。
正如我们在第二章,“创建您的第一个模块”中看到的,当前用户由一个实现AccountProxyInterface接口的服务表示。这个服务可以通过current_user键或使用以下简写静态访问:
$accountProxy = \Drupal::currentUser();
从这个账户代理中,我们可以请求代表实际已登录用户账户的AccountInterface(即UserSession对象)。它持有对用户实体的引用,以及一些与其账户相关的数据,但基本上就是这样。如果我们需要访问其实体字段,我们需要像通常那样加载实体:
$user = \Drupal::entityTypeManager()
->getStorage('user')
->load($accountProxy->id());
顺便说一句,生成的UserInterface也实现了相同的AccountInterface,因此这些常用方法可以用于这两个对象。因此,User实体类型基本上是代表浏览网站的用户的AccountInterface的存储设施。然而,目前,用户实体并不那么相关,所以我们将回到账户,我们可以从代理中检索它,如下所示:
$account = $accountProxy->getAccount();
该接口上的方法允许我们“调查”账户(无论是当前用户账户还是由给定用户实体表示的账户)的凭据。其中许多也存在于AccountProxy中,这意味着您可以直接询问这些。
以下两种非常通用但经常很有帮助的方法是:
$account->isAnonymous();
$account->isAuthenticated();
这些检查账户是否匿名,不考虑任何角色或权限。有时,您的访问控制仅基于这种区分。
我们还可以获取账户拥有的角色列表,如下所示:
$account->getRoles();
乃至更重要的是,检查用户是否具有特定的权限:
$account->hasPermission($permission)
其中$permission是一个字符串(权限的机器名,正如我们之前所定义的)。这个方法非常有用,因为它检查用户对指定权限的所有角色。
当您需要检查用户是否应该访问您功能的一部分时,您可以在代码的任何地方使用这些方法。
路由访问
现在我们已经看到了 Drupal 8 中访问系统在基本层面的工作原理以及我们如何定义权限和检查用户凭据,是时候讨论路由了。
正如我们从本书第一次编写代码时所见,路由是进入您应用程序的入口点。此外,作为一名开发者,这也是您将主要处理的事情之一,因此控制谁可以访问这些路由是访问系统的责任。
我们有几种方法可以确保路由只能被正确的用户访问,所以让我们看看这些方法。
最简单的方法是检查权限。我们在第二章,“创建您的第一个模块”中实际上就是这样做的,当我们定义我们的hello_world.hello路由时:
hello_world.hello:
path: '/hello'
defaults:
_controller: '\Drupal\hello_world\Controller\HelloWorldController::helloWorld'
_title: 'Our first route'
requirements:
_permission: 'access content'
路由定义中的requirements键包含请求尝试到达此路由必须具有的所有数据。这主要包含类似访问的信息,但也包括诸如请求格式之类的信息。
之前示例中的要求是_permission(所有这些选项通常以下划线开头)。它用于指定访问此路由的用户需要拥有该权限,类似于我们之前检查用户是否拥有它的方式:
$account->hasPermission($permission).
访问内容权限是由 Drupal 核心定义的,基本上是在限制非常宽松时使用的,这意味着所有用户都应该能够访问资源。默认情况下,此权限也存在于匿名用户角色中。
说到宽松的限制,还有一个选项甚至更加开放,完全开放:
_access: "TRUE"
这实际上是在任何情况下都将路由向几乎所有的人开放——你可能不会经常使用,但在某些情况下很有用。
回到权限方面,我们还可以将多个权限包含在这个要求中。例如,为了检查用户是否拥有两个权限中的任何一个,我们用逗号将它们分开:
_permission: "my custom permission,administer site configuration"
为了检查用户是否拥有所有给定的权限,我们用加号(+)将它们分开:
_permission: "my custom permission+my other permission"
因此,我们已能看到相当大的灵活性。
管理网站配置是 Drupal 核心的另一个基本权限,我们可以用它来确保用户是管理员;这通常是一个敏感权限,仅授予这些用户。
接下来,我们还有一个要求,可以用来检查用户是否具有特定的角色。以类似的方式,我们可以包含多个角色进行检查,具体取决于我们想要进行 AND 或 OR 检查:
_role: "administrator"
_role: "editor,administrator"
_role: "editor+administrator"
这种方法不如使用权限灵活,它有点“硬编码”。我的意思是,你正在根据网站配置(因为角色是配置实体)硬编码一个访问规则。如果该配置被删除,你的代码可能会出错。另一方面,权限也是代码,因为它们是在模块(或 Drupal 核心)中定义的。然而,如果你需要,这个选项是存在的。
我们接下来应该在这里介绍的要求类型是_entity_access。然而,理解这一点需要我们首先了解实体级别的访问,所以我们现在将跳过它;我们肯定会在本章的后面回到它。相反,我们将讨论所有路由访问方法之母——自定义方法。
路由访问要求也可以堆叠,这意味着我们可以向路由添加多个访问要求,并且只有当所有这些要求都允许访问时,才会授予访问权限。如果其中一个拒绝,则拒绝访问该路由。这是通过简单地向路由添加多个要求来完成的。
自定义路由访问
之前控制路由的方法功能强大且相对灵活,但它们是静态的。我们将规则硬编码到文件中,并期望来宾用户遵守这些规则。然而,如果事情比这更复杂,我们需要一个更动态的方法怎么办?相信我,事情会很快变得复杂。我们可以使用路由要求的_custom_access选项。
在本小节中,我们将看到这些是如何工作的,以及我们如何创建我们的自定义访问检查器;只是简单演示一下这个过程。然后,我们将看到一种更高级的实现,这将让我们在程序上对路由进行一些操作。
创建和使用自定义访问检查器与路由结合的方式有两种,它们都涉及到创建一个类。这个类是如何被使用的决定了区别:我们既可以直接(静态地)引用它,也可以将其变成一个服务并这样引用。我们将在本章后面看到这两个示例。
为了演示,假设我们想要确保我们的 Hello World 路由只能对没有特定角色——editor——的用户可访问。这听起来不太合理,但这是一个我们可以运行的简单示例。
静态方法
静态方法涉及到在我们的控制器(或其他地方)创建一个方法,通常称为access(),并从路由定义中引用它。因此,在我们的控制器中我们可以有如下代码:
/**
* Handles the access checking.
*
* @param \Drupal\Core\Session\AccountInterface $account
*
* @return \Drupal\Core\Access\AccessResultInterface
*/
public function access(AccountInterface $account) {
return in_array('editor', $account->getRoles()) ? AccessResult::forbidden() : AccessResult::allowed();
}
以及新的use语句:
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
这个方法接收当前用户的AccountInterface,我们可以用它来确定角色。此外,如果我们对一些额外的参数进行类型提示,Drupal 也会将它们传递给这个方法:
-
\Symfony\Component\Routing\Route $route -
\Drupal\Core\Routing\RouteMatch $route_match
我们已经在第二章“创建您的第一个模块”中讨论了CurrentRouteMatch服务,我们了解到我们可以用它来了解刚刚访问的路由的信息。实际上,这个服务只是简单地使用了RouteMatch对象。因此,如果我们的路由访问规则依赖于与路由相关的东西,这个参数可能非常重要。很快,我将更详细地演示这一点。
同样,我们也可以对包含路由数据的实际Route对象进行类型提示。这与我刚才提到的观点一致,我们也可以在逻辑中使用它。但是,不幸的是,对于我们的用例,这些将不是必需的,所以我们将坚持使用AccountInterface。
在这个方法中我们返回的内容非常重要,因为它需要是一个AccessResultInterface的实例。这是 Drupal 8 中访问系统使用的标准接口。以下是你经常会遇到的三种主要接口实现:
-
AccessResultAllowed -
AccessResultNeutral -
AccessResultForbidden
然而,这些对象的入口通常是AccessResult抽象基类(所有这些实现都扩展了它)及其静态方法。正如你在前面的示例中看到的,我们使用了allowed()和forbidden()方法来实例化这些对象。当然,我们还有一个相应的neutral()方法,可以用来表示我们没有在事情上发言。通常,这用于涉及多个决定访问特定资源的参与者的情况,其中一个参与者遇到了他们不需要控制访问的资源。
在 Drupal 8.3 中,中性和禁止的访问结果也支持原因。这通常用于 REST 场景,以显示为什么访问被拒绝或跳过。例如,当我们拒绝访问时,我们可以返回如下内容:
return AccessResult::forbidden('Editors are not allowed');
AccessResult基类的一些其他内置功能与缓存性相关,但它也有方便的方法来实现更复杂的访问逻辑。例如,以下方法可能很有用:
-
allowedIf($condition) -
forbiddenIf($condition)
你只需传递一个布尔值给这些方法,它们就会返回正确的访问对象。当然,请记住,如果条件评估为 FALSE,这些方法返回一个AccessResultNeutral对象。所以,如果你需要将布尔值映射到显式允许或显式拒绝的结果,你不能使用这些方法。
此外,我们还有以下类似的方法:
-
allowedIfHasPermission() -
allowedIfHasPermissions()
这将检查给定的账户是否具有一个或多个权限,并根据情况返回正确的访问对象。
最后,我们还有orIf()和andIf()方法,我们可以用它们构建更复杂的访问结构,这些结构结合了多个AccessResultInterface结果。
在AccessResultInterface的括号上关闭,让我们在我们的路由中引用这个方法,以便真正使用它。这就是现在的路由定义看起来像这样:
hello_world.hello:
path: '/hello'
defaults:
_controller: '\Drupal\hello_world\Controller\HelloWorldController::helloWorld'
_title: 'Our first route'
requirements:
_custom_access: '\Drupal\hello_world\Controller\HelloWorldController::access'
我们不再使用_permission需求,而是使用带有对我们控制器方法引用的_custom_access。在清除缓存后,我们新的访问检查器将“踢出”那些讨厌的editor用户。
如你所想,这种静态方法比使用基于权限或角色的访问检查更强大,因为它允许你编写 PHP 逻辑来确定访问。然而,它在许多方面都存在不足,这就是基于服务的方法可以发挥作用的地方。
服务方法
服务方法涉及创建一个标记的服务,并在路由定义中将其作为需求进行引用。与我们所看到的方法相比,这种方法有许多优点:
-
允许你将复杂的访问逻辑封装在其自己的类中
-
允许你在计算访问时注入依赖项并使用它们
-
允许你在多个路由上重用访问检查器
让我们看看我们如何为我们的 Hello World 路由实现这一点。我们将替换之前的方法,但保持拒绝编辑访问的目标。然而,为了增加一点复杂性,如果 Hello World 问候没有被配置表单覆盖,编辑将被允许。如果你还记得,在第二章,创建你的第一个模块中,我们创建了一个表单,其中问候消息可以被覆盖并存储在配置对象中。
首先,让我们创建我们的类。通常,与访问相关的类放在模块命名空间的Access文件夹中——这并不一定是这样,但将其放在那里是有意义的。然后,我们可以有如下内容:
namespace Drupal\hello_world\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Access handler for the Hello World route.
*/
class HelloWorldAccess implements AccessInterface {
/**
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* HelloWorldAccess constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
*/
public function __construct(ConfigFactoryInterface $configFactory) {
$this->configFactory = $configFactory;
}
/**
* Handles the access checking.
*
* @param AccountInterface $account
*
* @return AccessResult
*/
public function access(AccountInterface $account) {
$salutation = $this->configFactory->get('hello_world.custom_salutation')->get('salutation');
return in_array('editor', $account->getRoles()) && $salutation != "" ? AccessResult::forbidden() : AccessResult::allowed();
}
}
一开始,我想提到,我们正在实现的AccessInterface目前还处于不确定的状态。如果你查看内部结构,你会发现它没有任何方法。这是因为我们之前讨论过的动态参数解析,通过它可以获取路由和路由匹配,如果我们对它们进行类型提示的话。在撰写本书时,有一个关于将其标记为已弃用并可能最终完全移除(或找到另一种解决方案)的持续讨论。因此,这是长期值得关注的点。
此外,由于没有接口,access()方法命名不是强制性的。然而,我们仍然需要它,因为这是访问系统在调用服务时寻找的名称。和之前一样,我们获取发起请求的用户,从而可以获取角色。此外,我们注入了配置工厂并检查问候文本是否已被覆盖。只有在这种情况发生时,编辑才会被拒绝访问。对我们来说,这并不复杂。
现在,让我们看看我们如何定义它作为一个服务,供我们的路由作为访问检查器使用:
hello_world.access_checker:
class: \Drupal\hello_world\Access\HelloWorldAccess
arguments: ['@config.factory']
tags:
- { name: access_check, applies_to: _hello_world_access_check }
如你所见,标记服务在 Drupal 8 中非常重要,它是我们可以贡献自己的代码到现有功能集的一个很好的扩展点示例。在这个例子中,除了为访问检查标记它之外,我们还看到了这个标记的另一个选项:applies_to。相应的字符串是我们现在可以在我们的路由定义中使用以针对这个特定的访问检查器的字符串。所以,而不是以下这一行:
_custom_access: '\Drupal\hello_world\Controller\HelloWorldController::access'
我们有这个:
_hello_world_access_check: 'TRUE'
我们设置的TRUE值并没有太大的影响。如果我们愿意,我们可以添加一个字符串值,这个值实际上可以被访问检查器内部使用。然而,我们将稍后使用不同的方法来处理。所以,现在,标准做法就是只使用TRUE。
清除缓存后,我们新的访问检查器将启动,这就是全部。
对路由进行程序化访问检查
如果我们定义了路由,并且用户访问这些路由,Drupal 会自动为我们检查访问权限(根据路由定义中设定的要求)。然而,我们可能经常需要以编程方式检查特定路由的访问权限,例如,确定是否应该向当前用户显示该链接。
在 第二章,创建您的第一个模块 中,我们看到了如何使用 Url 对象来创建链接,并且我们可以使用这些 Url 对象来检查给定路由的访问权限;考虑以下示例:
$url = Url::fromRoute('hello_world.hello');
if ($url->access()) {
// Do something.
}
Url 对象上的 access() 方法仅与 路由 URL 一起工作,即那些已经确定后面有路由的 URL。显然,它不会与外部 URL 等事物一起工作,因此在这些情况下,它总是返回 TRUE。此外,如果我们想检查特定用户是否有权访问该路由,我们可以向此方法传递一个 AccountInterface。如果没有参数,它默认为当前用户。
在底层,Url 类使用 AccessManager 服务静态地检查路由的访问权限。这是静态完成的,所以如果您愿意,您可以自己注入服务(access_manager)并检查路由访问权限:
$access = $accessManager()->checkNamedRoute('hello_world.hello', [], $account)
我们传递的空数组作为第二个参数是路由需要的参数数组。您还记得从 第二章,创建您的第一个模块 中如何使用路由参数,对吧?
我之前提到,如果您需要使用账户、路由和路由匹配来计算访问逻辑,那么使用这些动态参数而不是注入当前用户或当前路由匹配服务非常重要。也许现在,您可以开始理解为什么。让我来解释一下。
我之前提到的一个优点是,基于服务的访问检查方法允许我们在多个路由上使用相同的服务。这意味着我们可以拥有高度动态的访问规则,通过这些规则我们可以在访问检查器中检查路由选项并基于这些选项计算访问权限,这非常强大。
然而,如果您注入当前路由匹配服务并使用它,您的访问规则只有在浏览器请求该路由时才会生效,所以基本上,当用户试图访问该路径时。这是因为当前路由恰好与访问检查器使用的路由(注入的那个)相同。然而,如果您从另一个页面(正如我们刚才看到的)以编程方式检查该路由的访问权限,当前路由匹配将是那个其他页面的路由,而不是您实际想要检查访问权限的路由。
即使您没有手动检查带有菜单链接的路由的访问权限,您也会看到这种情况发生。如果一个给定的路由被用于菜单链接并在页面上打印出来,Drupal 将会自动进行访问检查,以确保用户可以访问该链接。此外,回想一下第五章,菜单和菜单链接,如果您想以编程方式渲染菜单链接,您通常会执行的操作之一是通过一系列操作器运行菜单树。一个重要的操作器是检查当前用户是否有权访问该路由。
在这些情况下,您会遇到相同的问题。所以,请记住为您的访问检查器使用路由和/或路由匹配对象进行类型提示,并且不要注入它们。当然,也不要注入当前用户服务(除非您有非常具体的原因这样做)。
奖励——动态路由选项用于访问控制
我们已经看到了如何创建一个基于服务的访问检查器,我们可以在我们的路由上使用它。使用这种技术,我想展示在多个路由上使用服务的灵活性。想象一下,我们有多条路由用于显示一些用户信息。然而,这些路由是特定于用户类型的,因此只有该用户类型可以访问。在这个例子中,用户类型将基于用户实体上的一个简单文本字段的值来定义,我们希望在路由定义中指定它应该对哪种用户类型可访问。我们为这个演示编写的代码将放在一个新的user_types模块中。
对于这个例子,检查路由内部访问的另一种方法是简单地验证控制器中的当前用户是否有权访问它。如果没有,在控制器方法中抛出AccessDeniedHttpException将请求转换为 403(访问拒绝)。然而,这几乎总是错误的方法,因为路由将无法再进行访问验证,我们最终会在网站上放置可能导向 403 页面的链接。我们不希望这样。因此,如果页面有访问规则,它们属于访问系统,而不是控制器。
在这个例子中,我们假设用户实体上已经有一个名为field_user_type的字段;我们有三种类型的用户:board_member、manager和employee;并且我们有以下四个路由定义:
user_types.board_members:
path: '/board-member'
defaults:
_controller: '\Drupal\user_types\Controller\UserTypesController::boardMember'
_title: 'Board member'
user_types.manager:
path: '/manager'
defaults:
_controller: '\Drupal\user_types\Controller\UserTypesController::manager'
_title: 'Manager'
user_types.employee:
path: '/employee'
defaults:
_controller: '\Drupal\user_types\Controller\UserTypesController::employee'
_title: 'Employee'
user_types.leadership:
path: '/leadership'
defaults:
_controller: '\Drupal\user_types\Controller\UserTypesController::leadership'
_title: 'Leadership'
这些路由目前还没有任何访问要求,因为我们的任务是现在创建它们。然而,您已经可以理解哪些类型的用户应该能够访问这些路由。user_types.board_members路由是为董事会成员设计的,user_types.manager是为经理设计的,user_types.employee是为员工和经理(因为两者都是真正的员工)设计的,而user_types.leadership是为董事会成员和经理设计的。所以,一些混合匹配来强调我们访问检查器中灵活性的需求。
显然,我们不想为处理这里的每种用户类型的组合编写一个服务。使用静态方法也不合适,因为我们需要注入一个依赖项,而且我们也不想使用不同的可调用项重复逻辑。
因此,让我们为这个访问检查器定义我们的服务定义:
user_types.access_checker:
class: \Drupal\user_types\Access\UserTypesAccess
arguments: ['@entity_type.manager']
tags:
- { name: access_check, applies_to: _user_types_access_check }
我们注入实体类型管理器服务,以便我们可以加载与正在检查访问的用户对应的用户实体。正如你所记得的,AccountInterface不足以从该用户读取字段数据。
现在,我们可以更新我们的路由要求(针对所有四个路由)以利用这个访问检查器:
requirements:
_user_types_access_check: 'TRUE'
之前,我们看到静态访问检查器是通过_custom_access要求引用的。这与我们现在创建的是同一个,但由 Drupal 核心提供,映射到CustomAccessCheck服务(而不是我们现在编写的自定义服务)。这反过来又委托了责任到定义中设置的类方法。
现在,是时候根据应该有权访问它们的用户类型来区分我们的四个路由了,我们可以使用路由选项来做这件事。选项是一组任意的数据片段,我们可以将其放在路由定义上并在以后程序化地检索。如果你还记得,在第二章,创建您的第一个模块中,参数转换器就是一个可以作为选项在路由中定义的例子。
让我们以一个路由为例,全面地看一下,然后你将推断出其他路由将是什么样的:
hello_world.employee:
path: '/employee'
defaults:
_controller: '\Drupal\hello_world\Controller\UserTypesController::employee'
_title: 'Employee'
requirements:
_user_types_access_check: 'TRUE'
options:
_user_types:
- manager
- employee
路由选项放在options键下,并且传统上以一个下划线开头(然而,这并非强制要求)。在标准的 YAML 表示法中,我们在_user_types选项下有一个字符串值的序列,当将其读入路由对象时,将转换为 PHP 数组。
现在,我们可以创建我们的访问检查器服务,并利用所有这些来控制访问:
namespace Drupal\user_types\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\Routing\Route;
/**
* Access handler for the User Types routes.
*/
class UserTypesAccess implements AccessInterface {
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* UserTypesAccess constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManager $entityTypeManager
*/
public function __construct(EntityTypeManager $entityTypeManager) {
$this->entityTypeManager = $entityTypeManager;
}
/**
* Handles the access checking.
*
* @param AccountInterface $account
* @param \Symfony\Component\Routing\Route $route
*
* @return \Drupal\Core\Access\AccessResult
*/
public function access(AccountInterface $account, Route $route) {
$user_types = $route->getOption('_user_types');
if (!$user_types) {
return AccessResult::forbidden();
}
if ($account->isAnonymous()) {
return AccessResult::forbidden();
}
$user = $this->entityTypeManager->getStorage('user')->load($account->id());
$type = $user->get('field_user_type')->value;
return in_array($type, $user_types) ? AccessResult::allowed() : AccessResult::forbidden();
}
}
根据服务定义,我们注入实体类型管理器作为依赖项。这是使用静态方法无法做到的。然后,在我们的access()方法中,我们还对使用此服务进行访问评估的路由进行类型提示。现在,有趣的部分来了。
我们检查路由并尝试通过名称检索我们的选项。作为一个安全措施,如果选项缺失,我们将拒绝访问。这种情况不应该发生,因为我们只在这个具有选项的路由上使用这个访问检查器,但你永远不知道。此外,如果用户是匿名用户,我们也会拒绝访问。匿名用户肯定不会有任何用户类型字段值。
然后,我们加载当前账户的用户实体,简单地检查该字段值,并根据它是否在路由允许的范围内返回访问权限。我建议你检查Route类,看看你可以利用哪些其他方便的数据。
就这样。现在我们有一个灵活的访问检查服务,我们可以在需要此 用户类型 访问控制的任何数量的路由上使用它。
从这个附加技术中得出的一个关键教训是,您可以使用路由上的选项构建极其灵活的架构。在这个例子中,我们使用了它们来处理访问,但您也可以将它们用于其他与路由相关联并可以从路由控制的功能。
路由的 CSRF 保护
Drupal 配备了各种处理 CSRF 保护的工具。
跨站请求伪造(CSRF)是一种攻击,它强制最终用户在当前已认证的 Web 应用程序中执行他们不希望执行的操作。
——(OWASP)
其中一个工具是用于处理将 CSRF 令牌添加到使用 Drupal API 自动构建的路由。让我们看看一个例子。
想象一下,您有一个用作某种回调的路由。击中此路由会触发一个过程(对于已登录用户),因此您需要确保用户只能从他们应该来的地方到达这个路由(需要触发该过程的流程的一部分)。令牌可以用于此,Drupal 8 已经提供了这个功能。
我们需要做两件事:为 CSRF 保护添加路由要求,然后使用我们在第二章创建您的第一个模块中看到的常规 Drupal API 构建该链接。以下是要求:
_csrf_token: 'TRUE'
此外,请注意,这可以与其他基于访问的要求一起使用,例如我们在本节中讨论的那些。
现在添加 CSRF 令牌要求后,如果仅通过在浏览器中导航到路径来访问路由,则该路由将不可访问。为了使其可访问,我们需要使用 Drupal API 在某处打印出指向它的链接:
$url = Url::fromRoute('my_module.my_route');
$link = [
'#type' => 'link',
'#url' => $url,
'#title' => 'Protected callback'
];
这是一种方法,但我们也可以使用 LinkGenerator 服务或 Link 类,就像我们在第二章创建您的第一个模块中看到的那样。它们都会将带有附加到 URL 作为查询参数的令牌的链接渲染出来。然后 Drupal 将评估该令牌作为访问控制的一部分,并确保它是有效的。事实上,链接构建实际上不起作用。处理它的是 URL 生成器。因此,如果您以这种方式获取字符串 URL,则它将自动包含令牌:
$path = $url->toString();
在底层,为了管理令牌的创建和验证,Drupal 使用 CsrfTokenGenerator 服务,我们也可以使用它。例如,在获得服务(csrf_token)之后,我们可以创建一个令牌:
$token = $generator->get('my_value');
在这里,my_value 是生成器可以用来使令牌唯一的可选字符串。它还使用当前用户会话和私有站点密钥。请注意,如果用户是匿名用户且未启动会话,则令牌在每个请求上都是唯一的。
我们可以按以下方式验证此令牌:
$valid = $generator->validate($token, 'my_value');
这里,$generator 是我们用于创建它的相同服务。
使用令牌生成器手动操作可能很方便,但正如我们所见,只需在路由上添加一个要求,让 Drupal 完成其余的工作就非常简单了。此外,CSRF 保护已经嵌入到表单 API 中,因此当涉及到需要额外保护的形式时,我们根本不需要做任何事情。
修改路由
我们已经看到了如何在我们自己的路由上创建访问规则。然而,如果修改现有路由并更改它们的访问规则不是那么容易,那么它就不是 Drupal 了。这是我们的自定义模块可以贡献给现有功能的另一个小的扩展点。
通过修改路由本身来修改路由访问。当然,访问不是修改路由的唯一原因,因为你可以更改定义中的几乎所有其他内容。那么,让我们看看你如何根据需要修改路由。
通过订阅一个事件,可以修改路由,正如我们在第二章“创建您的第一个模块”中看到的,当时我们订阅了kernel.request事件。此事件在所有路由正在构建并且它们被缓存之前被触发。因此,修改不会动态发生(当有人访问路由时),而只会在它们全部重建时发生。让我们看看我们如何订阅这个事件。
与大多数其他订阅者不同,路由的EventSubscriberInterface类通常位于模块的Routing命名空间中,所以我们将把它放在那里。此外,我们正在监听的事件是RoutingEvents::ALTER。然而,路由系统为我们提供了一个基类订阅者,我们可以扩展它,它包含所有这些样板代码,只留下我们进行修改。
这些修改可能看起来像这样:
namespace Drupal\hello_world\Routing;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
/**
* Subscribes to route events for the Hello World module.
*/
class HelloWorldRouteSubscriber extends RouteSubscriberBase {
/**
* {@inheritdoc}
*/
protected function alterRoutes(RouteCollection $collection) {
$route = $collection->get('user.register');
if (!$route) {
return;
}
// Example 1:
// We deny access to the Register page in all cases. With this requirement,
// it doesn't matter anymore what other access requirements exist or if they
// evaluate positively.
$route->setRequirement('_access', 'FALSE');
// Example 2:
// We check for the presence of a specific access requirement and if it exists,
// we clear all the access requirements on the route and set our own.
if ($route->hasRequirement('_access_user_register')) {
$route->setRequirements([]);
$route->setRequirement('_user_types_access_check', 'TRUE');
}
}
}
我们扩展了RouteSubscriberBase,它订阅了该事件,并为我们提供了alterRoutes()方法和网站上所有路由的集合。我鼓励你研究一下RouteCollection类,因为它在处理路由时非常有用。一个重要的特性是我们可以根据名称检索路由,就像我们在上一个例子中所做的那样。
然后,我们将像之前稍微早些时候做的那样处理Route对象。我们可以看到两个示例,所有这些注释我都不会在这里重复。第二个示例在现实世界的场景中没有任何意义,因为我们不能让已登录的用户注册新账户。然而,它有助于说明我们如何向现有路由添加自己的访问检查器。
与我们如何操作访问要求类似,我们可以改变很多其他事情:选项、参数、控制器,甚至实际的路由路径。为此,我鼓励您熟悉Route类的各种方法,看看您可以在新路由上设置什么。结合关于您可以在路由上添加的所有内容的文档(www.drupal.org/docs/8/api/routing-system/structure-of-routes),以获得更好的理解。
要使这项工作起作用,唯一剩下的事情就是将订阅者注册为带标签的服务,就像我们在第二章,“创建您的第一个模块”中所做的那样:
hello_world.route_subscriber:
class: Drupal\hello_world\Routing\HelloWorldRouteSubscriber
tags:
- { name: event_subscriber }
这样,我们就完成了对路由的修改。
实体访问
现在我们已经介绍了如何在路由上实现访问控制,让我们深入了解实体访问系统,看看我们如何确保只有正确的用户与我们的实体进行交互。为了演示这些,我们将使用我们在第七章,“您自己的自定义实体和插件类型”中创建的产品实体类型。
当我们创建产品实体类型时,我们写的注解中有一个admin_permission属性,其中我们引用了用于与该类型实体进行任何交互的通用权限。由于我们没有引用并实现访问控制处理程序,这是对产品进行的唯一访问检查。在许多情况下,这已经足够了。毕竟,实体类型可以仅用于结构化一些数据,甚至没有人需要在 UI 中与之交互。然而,许多其他情况需要对操作实体进行更细粒度的访问控制,尤其是面向内容的,如节点。
在实体访问方面,我们可以控制访问的四个操作是:view、create、update和delete。第一个显然是最常见的,但我们始终需要考虑到其余的操作。让我们首先为所有这些操作定义权限(你还记得吗?):
view product entities:
title: 'View Product entities'
edit product entities:
title: 'Edit Product entities'
delete product entities:
title: 'Delete Product entities'
add product entities:
title: 'Create new Product entities'
这些是四个简单的权限,它们映射到可以在产品实体上执行的操作。
现在,让我们继续为我们的产品实体类型创建一个访问控制处理程序。你还记得从第六章,“数据建模和存储”中了解到的这些处理程序吗?
首先,我们将引用我们在产品注解上构建的类:
"access" = "Drupal\products\Access\ProductAccessControlHandler",
我选择将此处理程序放在模块的Access命名空间中,但请随意将其放在您想要的位置。
其次,我们需要实际的类:
namespace Drupal\products\Access;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\products\Entity\ProductInterface;
/**
* Access controller for the Product entity type.
*/
class ProductAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
/** @var ProductInterface $entity */
switch ($operation) {
case 'view':
return AccessResult::allowedIfHasPermission($account, 'view product entities');
case 'update':
return AccessResult::allowedIfHasPermission($account, 'edit product entities');
case 'delete':
return AccessResult::allowedIfHasPermission($account, 'delete product entities');
}
return AccessResult::neutral();
}
/**
* {@inheritdoc}
*/
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
return AccessResult::allowedIfHasPermission($account, 'add product entities');
}
}
如我在第六章“数据建模和存储”中提到的,实体访问控制处理程序需要扩展 EntityAccessControlHandler 基类。如果没有提供特定的实现,那么实际上就是实体类型默认的处理程序。此外,我们还需要在这里实现两个方法(重写):
-
checkAccess()方法,用于控制视图、更新和删除操作上的访问 -
checkCreateAccess()方法,用于控制创建操作上的访问
这些是分开的原因是,对于创建操作,我们在这个过程中没有可以检查的实体。
我们对产品实体类型的访问规则非常简单。对于每个操作,如果用户拥有相关的权限,我们就允许访问;否则,访问是中性的。然而,在这种情况下会发生什么呢?
值得研究的是 EntityAccessControlHandler 基类,并了解其中发生的情况。主要的访问入口点是 access() 和 createAccess() 方法。我们永远不应该重写这些方法,因为那里的逻辑相当标准化,并且是每个人的预期行为。相反,我们的规则应该放在我们自己的处理程序子类中看到的两个方法内部。
access() 和 createAccess() 方法会调用实体访问钩子(我们稍后会讨论这些)。如果它们没有返回访问拒绝的消息,它们会调用我们自己在子类中重写的相应访问方法,并将这些方法的输出与访问钩子内部的 orIf() 访问结果结合起来。还记得我们之前提到的 AccessResult 基类及其方便的 orIf() 和 andIf() 方法吗?
重要的是要注意如何通过所有这些因素来确定访问权限。如果至少有一个钩子实现授予访问权限且没有拒绝访问,则用户将有权访问,除非我们在访问处理程序中拒绝访问。中性访问在这个等式中不起作用,除非所有钩子实现和访问处理程序都返回中性访问(即没有授予特定的访问权限),那么访问将被拒绝。
在我们的示例中,我们定义了权限,处理程序只是简单地检查这些权限。这已经相当灵活,因为管理员现在可以将这些权限分配给角色,并控制哪些用户可以执行这些操作中的任何一项。然而,我们并没有阻止在这些方法中添加更多逻辑。例如,我们甚至可以检查实体(以及/或用户账户)并根据一些给定的值确定访问权限。此外,我们可以将服务注入访问处理程序,并在这些计算中使用它们。
将服务注入实体处理程序
使用访问处理程序的一个优点是,我们可以使其了解服务容器,并注入我们可能需要的任何服务以确定访问权限。然而,这并不立即清楚如何做到这一点,所以我们将在这里分解它。
我们首先需要确保我们的访问处理器实现了\Drupal\core\Entity\EntityHandlerInterface接口。请注意,这同样适用于其他类型的处理器,而不仅仅是与访问相关的处理器。此接口有一个方法,它将接收容器和实体类型定义:createInstance()。
了解这一点后,其余部分与使用create()方法将服务注入到控制器和表单中非常相似,该方法只接受容器作为参数,或者注入到插件中,后者也接受一些插件信息:
/**
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* ProductAccessControlHandler constructor.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* @param \Drupal\Core\Entity\EntityTypeManager $entityTypeManager
*/
public function __construct(EntityTypeInterface $entity_type, EntityTypeManagerInterface $entityTypeManager) {
parent::__construct($entity_type);
$this->entityTypeManager = $entityTypeManager;
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('entity_type.manager')
);
}
以及新的使用语句:
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
通过这种方式,我们已经将实体类型管理器注入到访问处理器中,如果我们需要,我们可以使用它。当然,如果我们不需要它,我们最初就不应该注入它。
实体访问钩子
正如我提到的,核心实体访问处理器会调用模块实现的访问钩子,这些模块不拥有实体类型,以便它们可以在实体的访问中发表意见。有两个访问钩子集要讨论。第一组涵盖了创建操作,如下所示:
-
hook_entity_create_access() -
hook_[entity_type]_create_access()
第二组涵盖了view、update和delete操作:
-
hook_entity_access() -
hook_[entity_type]_access()
对于每一组,我们同时调用两个钩子,从通用到实体类型特定。例如,当尝试查看一个节点时,被调用的第二个钩子是hook_node_access()。
如您从我们之前的讨论中记得,实体访问钩子实现还必须返回一个AccessResultInterface。这是因为结果在orIf()组合中使用,与访问处理器的访问结果一起使用。
因此,让我们看看我们如何实现这些访问钩子,特别是它们的签名。因此,我们从第一组开始:
/**
* Implements hook_entity_create_access().
*/
function my_module_entity_create_access(\Drupal\Core\Session\AccountInterface $account, array $context, $entity_bundle) {
// Perform access check and return an AccessResultInterface instance.
}
这是通用的实体创建访问钩子。为了使其特定于一个实体类型,我们将函数名中的单词entity替换为实际的目标实体类型 ID。然而,参数保持不变——被检查访问的用户账户,一个上下文(一个包含实体类型 ID 和正在创建的实体 langcode 的数组),以及正在创建的实体的捆绑包。
第二组看起来像这样:
function
my_module_entity_access(\Drupal\Core\Entity\EntityInterface $entity, $operation, \Drupal\Core\Session\AccountInterface $account)
{
// Perform access check and return an AccessResultInterface instance.
}
再次强调,为了使它特定于一个实体类型,我们只需将单词entity替换为我们想要的目标实体类型的 ID。再次强调,参数在本质上保持一致——被访问的实体(如果实现了更具体的钩子,则使用相关实体接口进行类型提示),尝试进行的操作(三个字符串之一:view、update和delete),以及被检查访问的用户账户。
大致就是这样。这些钩子会在检查实体给定操作的访问时动态调用。让我们来谈谈一些这方面的例子。
首先,默认的实体路由会检查这些操作,所以无需担心。因此,如果我们导航到规范、表单或删除 URL,将会检查访问权限。
第二,如果我们以编程方式加载一个实体,并像在第六章“数据建模和存储”中看到的那样,使用视图构建器处理器渲染它,那么view操作的实体访问会被调用。然而,如果我们加载实体并简单地从中检索一些数据,并在我们自己的模板中打印它,我们将绕过访问控制。如果我们这样做,我们需要确保我们始终手动检查访问权限:
$access = $entity->access('view', $account);
这将返回一个布尔值,除非你指定第三个参数为 TRUE,这将返回一个AccessResultInterface对象;具体情况取决于环境。
第三,如果我们以编程方式加载一个在表单构建器内部使用的实体,并想要渲染表单,我们再次绕过访问检查。因此,我们应该再次手动使用update操作来执行它。
当涉及到以编程方式处理与实体有 CRUD 关联的页面 URL 和菜单链接时,我们需要自己执行访问检查,但我们将稍后讨论路由中的实体访问;首先,提醒一下。
之前,我提到了关于提取实体数据并简单地渲染字段值的问题。当运行实体查询时,同样的问题会出现——结果将包含当前用户可能没有访问权限的实体。因此,我们必须意识到这一点,并适当地处理它。随着视图的出现,这个问题变得更加突出,视图可以执行自定义数据库查询,并将可能无法访问的实体包含在结果集中。加上视图渲染字段值的可能性,这可能会导致非常意外的行为。因此,请记住,在这种情况下,实体访问钩子和访问控制处理器不会触发。然而,Node 模块有一个复杂的授权系统,负责处理所有这些,但遗憾的是,这仅适用于节点实体。我们很快也会讨论这些内容。
字段访问
到目前为止,我们已经看到了实体级别访问是如何工作的。然而,对于实体内部的字段,也存在一个非常类似的系统。如果你查看EntityAccessControlHandler内部,你会注意到有一个fieldAccess()方法。这个方法会在需要检查给定字段的访问权限时被调用。例如,FieldItemList::access()方法就是这样做,并将任务委托给实体处理器。在这个内部,会调用checkFieldAccess(),这是我们可以在我们的访问处理器子类中实现以自定义访问规则的地方。
以类似的方式,我们有多个操作可以检查访问权限,但 view 将是您最常见的一个。例如,当手动使用实体构建处理程序渲染实体时,就像我们之前看到的,每个字段都会被检查是否有 view 操作的访问权限。同样,这次,当为编辑实体而构建实体表单时,每个在表单中渲染的字段都会首先使用 edit 操作进行检查访问权限。
再次强调,我们也有访问钩子,其他模块可以实现这些钩子,以便在字段是否可访问的问题上有发言权:
-
hook_entity_field_access() -
hook_entity_field_access_alter()
在这种情况下,我们没有实体类型或字段类型特定的钩子可以实施。然而,我们有一个可以用来修改其他模块提出的访问规则的修改钩子。
与实体级访问处理程序类似,字段级处理程序从多个来源获取输入——子类和钩子实现。然而,它们的顺序和组合是不同的。首先,调用访问处理程序子类(通过 checkFieldAccess() 方法)。然后,所有 hook_entity_field_access() 钩子被调用以提供它们的输入。这两个钩子随后都可以通过实现 hook_entity_field_access_alter() 来修改。最后,生成的访问规则被组合成一个 orIf() 并返回。所以,与我们在实体级别看到的相同原则可用,但顺序不同。
路由中的实体访问
现在我们已经了解了实体级访问控制是如何工作的,让我们暂时回到路由上。如果你还记得,我提到了 _entity_access 路由要求,以及在我们覆盖了实体访问之后我们将如何讨论它。
_entity_access 路由要求不过是一个基于服务的访问检查器,就像我们自己编写的那个一样。然而,它是实体系统创建的,以便根据那些路由中的动态实体参数来控制对这些路由的访问。让我们看看一个可以使用 _entity_access 要求的快速路由定义示例:
products.view_product:
path: '/our-products/{product}'
defaults:
_controller: '\Drupal\products\Controller\ProductsController::showProduct'
requirements:
_entity_access: 'product.view'
options:
parameters:
product:
type: 'entity:product'
这个路由有一个名为 product 的动态参数。在选项中,我们将这个参数映射到产品实体类型,这样我们的控制器方法(showProduct())就已经接收到了加载的产品实体,而不仅仅是 ID。这个附加的好处是,如果找不到产品,它会为我们抛出一个 404 错误。由于这个路由显然依赖于那个特定的产品,我们还想确保只有当用户有权查看该产品时,它才能被访问。
我们可以确保访问的一种方式是添加一个与查看产品实体权限相匹配的权限要求。然而,出于两个原因,这并不是一个好主意:
-
如果我们更改产品实体的权限,我们也必须在这个定义中更改它。
-
更重要的是,如果实体访问逻辑依赖于更多内容,比如来自用户或实体的动态数据,那么这将不再有效。
另一种解决这些问题的方法是实现一个访问检查器服务,并在该服务内部检查实体的访问权限:
$access = $entity->access('view', $account);
然而,仅仅为了这一行代码,就需要大量的样板设置。我们必须为所有实体类型和操作都这样做。
相反,我们使用内置的_entity_access访问检查器,就像在示例路由定义中那样。与TRUE(我们一直在使用的访问检查器)不同,这个实际上期望一个它将使用的值,这是一个由点(.)分隔的两个部分组成的字符串。第一部分是实体类型,而第二部分是操作。在底层,EntityAccessCheck将检查路由参数,并使用提供的操作检查找到的实体的访问权限。简单易懂。
节点访问权限
之前我警告过,我们一直在讨论的实体访问控制在我们编写的查询(无论是我们自己编写的还是通过 Views 编写的)中并没有被考虑。这是需要注意的一点。例如,如果你要列出实体,你需要在打印结果之前确保用户有权访问这些实体。这里的问题在于使用实体查询或数据库 API 内置的分页功能。这是因为分页信息将反映所有查询结果。所以,如果你不打印不可访问的实体,分页信息和可见结果之间将出现不匹配。
如果你记得,在第六章《数据建模与存储》中,我提到当涉及到节点时,实体查询会考虑访问权限。如果你想避免这种情况,你应该在查询构建器上使用accessCheck(FALSE)方法。让我们对此进行详细说明。
首先,这个方法适用于所有实体类型,而不仅仅是节点。然而,它真正有用的地方仅限于那些定义了status字段以表示实体可以是已发布或未发布的实体(或/off,启用/禁用,根据你的喜好)。查询将简单地添加一个条件到该字段,并且只返回状态等于 1 的实体。将 FALSE 传递给这个方法简单地移除这个条件。
其次,节点实体类型有一个更强大的内置访问系统,称为访问权限。这些功能从 Drupal 的早期版本就已经存在,这也是为什么我们在 D8 版本中也提供了它。不幸的是,它并不适用于其他实体类型。然而,如果你确实需要它,现在你了解了实体访问系统的工作原理,你可以自己编写它,并且可以研究节点访问权限是如何构建的。但这个系统究竟是什么呢?
节点访问权限系统是我们控制对节点上任何操作访问的一种细粒度方式。这是通过结合领域和权限来实现的。当一个节点被保存时,我们有创建该节点访问记录的机会,这些记录包含以下信息:
-
realm(字符串):我们访问记录的类别。通常,这用于表示访问控制发生的特定功能。
-
gid(权限 ID)(整数):通过它可以验证尝试访问节点的用户的权限 ID。通常,这会映射到一个角色或用户所属的自定义定义的“组”。例如,一个经理用户类型(从之前的例子中)可以映射到权限 ID 1。你很快就会明白这一点。
-
grant_view,grant_update,grant_delete(整数):布尔值,表示此访问记录是否用于此操作。
-
langcode(字符串):此访问记录应应用的节点语言。
然后,当用户尝试访问节点时,我们可以为给定的用户返回权限记录。对于给定的用户,我们可以作为多个领域的一部分返回多个权限。
节点访问记录存储在node_access表中,在你开发和准备访问记录时,检查该表是个好主意。默认情况下,如果没有提供访问记录的模块,该表中将只有一行,引用节点 ID 0 和领域all。这意味着基本上节点访问权限系统没有被使用,所有节点在所有领域中都可以查看。也就是说,默认访问规则适用。一旦模块创建记录,正如我们将看到的,这一行将被删除。
为了更好地理解这个系统是如何工作的,让我们看看一个实际的代码示例。为此,我们将回到我们的用户类型模块,并基于这些用户类型创建一些节点访问限制。我们将从一个简单的例子开始,然后扩展它使其更复杂(也更实用)。
首先,我们想要确保文章节点只能由所有三种类型的用户查看(因此仍然有一些限制,因为用户需要有一个类型)。另一方面,页面节点仅限于经理和董事会成员。所以让我们完成它。
我们现在所做的所有工作都在模块的.module文件中进行。首先,让我们创建一个基本的映射函数,我们可以向它提供一个用户类型字符串(就像我们之前看到的),然后返回相应的权限 ID。然后我们将一致地使用它来获取给定用户类型的权限 ID:
/**
* Returns the access grant ID for a given user type.
*
* @param $type
*
* @return int
*/
function user_types_grant_mapping($type) {
$map = [
'employee' => 1,
'manager' => 2,
'board_member' => 3
];
if (!isset($map[$type])) {
throw new InvalidArgumentException('Wrong user type provided');
}
return $map[$type];
}
这并不复杂。我们有三种用户类型,它们对应简单的整数。如果传递了错误的用户类型,我们会抛出一个异常。现在到了有趣的部分。
与节点访问权限限制一起工作涉及两个钩子的实现:一个用于创建节点的访问记录,另一个用于提供当前用户的权限。让我们首先实现hook_node_access_records():
/**
* Implements hook_node_access_records().
*/
function user_types_node_access_records(\Drupal\node\NodeInterface $node) {
$bundles = ['article', 'page'];
if (!in_array($node->bundle(), $bundles)) {
return [];
}
$map = [
'article' => [
'employee',
'manager',
'board_member',
],
'page' => [
'manager',
'board_member'
]
];
$user_types = $map[$node->bundle()];
$grants = [];
foreach ($user_types as $user_type) {
$grants[] = [
'realm' => 'user_type',
'gid' => user_types_grant_mapping($user_type),
'grant_view' => 1,
'grant_update' => 0,
'grant_delete' => 0,
];
}
return $grants;
}
这个钩子会在节点保存时被调用,并且需要返回该节点的访问记录数组。正如预期的那样,参数是节点实体。
我们首先做的事情是,如果节点不是我们感兴趣的节点之一,就简单地返回一个空数组。如果我们不返回任何访问记录,这个节点将为all领域分配一个带有 ID 为 1 的view操作的单一记录。这意味着它将按照默认的节点访问规则可访问。
然后,我们将创建一个简单的映射,映射出我们希望查看我们的节点包的用户类型。对于与当前包对应的每个用户类型,我们为user_type领域创建一个访问记录,其授权 ID 映射到该用户类型,并且具有查看此节点的权限。
我们有两种方法可以触发这个钩子并持久化访问记录。我们可以编辑并保存一个节点,这将为此节点创建记录。或者我们可以重建权限,这将为此站点的所有节点执行此操作。执行此操作的链接可以在状态报告页面上找到。
在开发过程中重建权限是个好主意,以确保您的更改应用于所有节点。一旦我们这样做,我们的节点现在基本上对任何人(除了 ID 为 1 的超级用户)都不可访问。这是因为我们需要通过实现hook_node_grants()来指定给定用户应拥有的授权:
/**
* Implements hook_node_grants().
*/
function user_types_node_grants(\Drupal\Core\Session\AccountInterface $account, $op) {
if ($account->isAnonymous()) {
return [];
}
if ($op !== 'view') {
return [];
}
$user = \Drupal::entityTypeManager()->getStorage('user')->load($account->id());
$user_type = $user->get('field_user_type')->value;
if (!$user_type) {
return [];
}
try {
$gid = user_types_grant_mapping($user_type);
}
catch (InvalidArgumentException $e) {
return [];
}
return ['user_type' => [$gid]];
}
每当在给定节点(针对给定操作)上进行访问检查时,节点访问系统都会调用这个钩子。此外,当对节点实体类型运行实体查询且未禁用访问检查时,它也会被调用。最后,当在数据库 API 查询中使用node_access标签时,它也会被调用。记得我们之前在第八章“数据库 API”中讨论过的基于标签的查询会改变吗?
作为参数,它接收需要检查访问的用户账户(它在给定操作的节点访问授权系统中的授权)。所以我们在这里首先返回一个空数组(没有授权),如果用户是匿名用户或者他们尝试执行的操作不是view——他们没有被授予访问权限。如果用户实体在field_user_type字段中没有任何值,也会发生同样的事情。然而,如果他们有值,我们就获取相应的授权 ID,并返回一个按领域键控的访问授权数组。对于每个领域,我们可以包括多个授权 ID。但在这个情况下,只有一个,因为用户只能属于一种类型。如果需要,我们也可以返回多个领域,当然,其他模块也可以这样做,结果将被集中并用于访问逻辑。
在此基础上,我们所有的页面节点现在仅供董事会成员和管理员用户查看,而文章则可供员工查看。如果用户没有任何类型,他们将无法访问。好事是,这些限制现在在运行查询时也被考虑在内。因此,我们可以自动从查询结果中排除用户无法访问的节点。这也适用于视图。
让我们现在通过以下更改来增强这个解决方案:
-
未发布的文章节点仅对管理者和管理委员会成员可用。
-
管理者也有权更新和删除文章和页面。
第一件事很简单。在我们定义user_types_node_access_records()内部的内部映射之后,我们可以从数组中取消设置employee,以防节点未发布:
if (!$node->isPublished()) {
unset($map['article'][0]);
}
这是一个非常简单的例子,但目的是引起你注意一个重要但经常被遗忘的点。如果你为节点创建访问记录,你需要自己考虑节点状态。这意味着,如果你授予某人查看节点的访问权限,他们将能够查看该节点,无论其状态如何。这种情况通常不是你想要的。所以,请确保在实现访问权限时考虑这一点。
现在,让我们看看我们如何修改我们的逻辑,以便让管理者能够更新和删除节点(包括文章和页面)。这就是现在的user_types_node_access_records()看起来像这样:
$bundles = ['article', 'page'];
if (!in_array($node->bundle(), $bundles)) {
return [];
}
$view_map = [
'article' => [
'employee',
'manager',
'board_member',
],
'page' => [
'manager',
'board_member'
]
];
if (!$node->isPublished()) {
unset($view_map['article'][0]);
}
$manage_map = [
'article' => [
'manager',
],
'page' => [
'manager',
]
];
$user_types = $view_map[$node->bundle()];
$manage_user_types = $manage_map[$node->bundle()];
$grants = [];
foreach ($user_types as $user_type) {
$grants[] = [
'realm' => 'user_type',
'gid' => user_types_grant_mapping($user_type),
'grant_view' => 1,
'grant_update' => in_array($user_type, $manage_user_types) ? 1 : 0,
'grant_delete' => in_array($user_type, $manage_user_types) ? 1 : 0,
];
}
return $grants;
我们所做不同的地方是,首先,我们将$map变量重命名为$view_map,以便反映实际的授权关联。然后,我们创建一个$manage_map来保存可以编辑和删除节点的用户类型。基于这个映射,我们可以为允许的用户类型设置grant_update和grant_delete值为 1。否则,它们保持不变。
现在我们需要做的就是回到hook_node_grants()实现中,并删除以下内容:
if ($op !== 'view') {
return [];
}
我们现在对所有操作都感兴趣,因此用户应该被提供所有可能的权限。在重建权限后,管理用户类型将能够更新和删除文章和页面,而其他用户类型则不会有这些权限。这对查询的影响不大,因为那些使用view操作。
在关闭节点访问权限的主题之前,你还应该知道,有一个可用的 alter 钩子,可以用来修改由其他模块创建的访问记录——hook_node_access_records_alter()。这将在所有模块为给定节点提供其记录之后调用,并且你可以用它来修改在存储之前他们提供的任何内容。
如前所述,访问权限系统仅限于节点实体类型。它自 Drupal 的早期版本以来一直存在,并且并没有成为实体系统的标准。然而,有人提到要这样做,但这还处于初级阶段。
为了更好地理解其内部工作原理,以便你在需要编写自己的系统时,我鼓励你探索NodeAccessControlHandler。你会注意到它的checkAccess()方法将委托给负责调用我们之前看到的授权钩子的NodeGrantDatabaseStorage服务。此外,你还可以查看node_query_node_access_alter实现,这是在hook_query_QUERY_TAG_alter()中实现的,节点模块使用相同的授权服务来修改查询,以便考虑访问记录。这不是一个最容易分解的系统,尤其是如果你是初学者,但它非常值得深入研究以了解更多。
块访问
另一个你需要处理访问权限的主要领域是在尝试控制自定义块访问权限时。如果你还记得在第二章,“创建你的第一个模块”,我们创建了HelloWorldSalutationBlock插件,以便我们的问候语也可以通过块来渲染。现在这个块可以被放置在区域中,甚至可以配置为仅在特定页面上显示,针对特定用户角色,甚至可以限制在特定捆绑包的节点页面上显示。所有这些操作都在 UI 中完成:

然而,这通常是不够的,你可能希望将块放置在区域中,并自行控制它在什么情况下应该显示。这就是块访问的用武之地。
在BlockBase插件基类中,有一个blockAccess()方法,它总是返回正值。这是因为,默认情况下,一旦块被放置在区域中,它们就会被渲染。除非,当然,它们被配置为仅在特定情况下显示,在这种情况下,基于可用上下文的可见性系统就会启动来控制这一点。然而,如果我们在这个块插件类中重写这个方法,我们就可以控制块是否显示。因此,当我们把块放置在区域中时,我们可以留空可见性选项,然后在blockAccess()方法中处理关于其可见性的所有操作。这不是很酷吗?
此外,正如预期的那样,该方法有一个参数,即被检查的账户,并需要返回一个AccessResultInterface。由于我们可以在我们的块插件中注入服务(通过实现我们在第二章,“创建你的第一个模块”中看到的ContainerFactoryPluginInterface),我们可以使用我们想要的来检查给定用户是否应该看到该块。如果我们拒绝访问,块将简单地不会被渲染。
这就是块访问控制的主要内容。
摘要
在本章中,我们讨论了许多与访问相关的主题和技术。在这个过程中,我们涵盖了在开始 Drupal 8 模块开发时你需要了解的内容。当然,随着你的进步,你将更深入地研究代码,并学习更多细微的方面和高级概念,这些你可以在你的模块中使用。然而,我们所涵盖的内容应该能让你顺利地开始。那么,我们究竟讨论了什么呢?
我们首先介绍了由角色和权限之间的矩阵组成的 Drupal 8 高级访问系统。在这个过程中,我们看到了如何在代码中定义权限,以及如何检查用户是否有这些权限。当然,我们还探讨了其他检查用户凭据的方法,并看到了如何使用AccountInterface来完成这项工作。
然后,我们转向路由,并看到了确保这些路由上的访问控制的各种方法。在这个过程中,我们涵盖了简单的检查,如权限和角色,但也探讨了使用自定义访问检查器的更高级示例。我们看到这些可以是静态的,也可以是基于服务的,以使访问检查完全动态。为了展示这些概念,我们还研究了一个案例研究,即使用路由选项来基本配置在一系列类似路由上使用的访问检查器。
我们还讨论了实体的访问问题。我们看到了如何创建自己的访问控制处理器并检查针对实体的所有特定操作的访问权限。基础访问处理器调用的访问钩子也与这一点密切相关,这允许其他模块对给定实体的访问发表意见。此外,我们还看到了如何在具有实体参数的路由上使用实体访问检查。
最后,我们简要介绍了通过规则控制块可见性的块访问,包括用户凭据。
将这些课程应用到你的代码中,不要轻视访问问题。如果你从一开始就应该非常了解的一件事,那就是访问。因此,本章也作为你在开发时的参考点;请随时多次查阅。
在下一章中,我们将探讨缓存以及如何确保我们的应用程序性能良好。
第十一章:缓存
应用性能始终是使用 Drupal 进行开发时的一个痛点,这有很多原因。例如,PHP 并不是最快的语言。许多初学者 Drupal 开发者会陷入众多模块的诱惑,过度启用不必要的模块。确实,Drupal 架构也不是最高效的。然而,为了辩护,一个非常复杂的架构,它提供了很多开箱即用的功能,将有一些速度上的权衡。
在这个游戏中,一个关键组件却是缓存。对于那些不熟悉这个术语的人来说,缓存是一种存储处理过的代码副本(或其结果)的应用策略,以便在后续请求时更快地将它们提供给用户。例如,当你访问一个网站时,你的浏览器很可能会在电脑上本地缓存(存储)某些资源,以便下次访问该网站时可以更快地显示它们。
尽管缓存在 Drupal 最近版本中一直在稳步改进,但它仍然存在显著不足。尤其是当涉及到为注册用户提供服务时。然而,Drupal 8 却是一个完全不同的游戏。系统已经被彻底翻新,并融入 Drupal 架构的各个方面。不幸的是,这又给 Drupal 7 开发者需要学习的内容增加了另一个大项目。因为这个系统很复杂,我们根本不能(也不应该)回避它。但幸运的是,在本章中,我们将将其分解,看看我们正在处理什么。所以,当你正在 Drupal 8 中进行模块开发时,你的代码将更高效,你的网站将运行得更快,最终你的用户将更满意。
那么,我们将在本章中具体讨论些什么呢?
首先,我们将介绍一些关于 Drupal 8 中缓存系统的基本概念,并查看可用的主要缓存类型。在这里,我们还将了解在开发过程中如何禁用缓存以提高我们的生产力。
接下来,我们将讨论缓存性元数据。当涉及到缓存时,这是作为 Drupal 8 模块开发者你需要了解的最重要的事情之一。它与以 Drupal 能够正确缓存(并相应地使缓存失效)的方式声明渲染数组(和其他对象)有关。我们将讨论诸如缓存标签、上下文和最大存活时间(max-age)等问题,同时也会看到如何将它们应用于渲染数组、块插件和访问结果。
之后,我们将探讨如何处理那些不能或不应缓存的动态组件(渲染数组)。Drupal 8 有一个强大的自动占位符系统,它使用懒加载构建器将渲染推迟到更晚的阶段,这可以大大提高缓存性和感知性能。
最后,我们将探讨如何我们自己与缓存 API 交互,以创建、读取和使我们的缓存条目失效。有时我们需要执行昂贵的计算或在我们的网站上显示外部数据,这些都可以从缓存中受益。
那么,让我们开始吧。
缓存简介
在深入探讨缓存 API 之前,我想先提到的是,这个子系统是文档最完善的之一(截至撰写本文时)。您可以查看主入口页面(www.drupal.org/docs/8/api/cache-api/cache-api),我建议在开发时将其放在手边。
Drupal 8 中的缓存系统提供了处理缓存数据的创建、存储和失效所需的 API。从存储的角度来看,它是可扩展的,允许我们编写自己的自定义缓存后端(CacheBackendInterface)。然而,默认情况下,缓存数据存储在数据库中,因此默认后端是 DatabaseBackend。向前发展,我们将只关注这一实现,因为它是最常用的,尤其是在启动新项目时。尽管如此,一旦网站变得更加复杂,可以采用替代的缓存后端来提高性能——例如 Memecache 或 Redis。
Drupal 8 中最简单的缓存类型是所谓的 内部页面缓存,其功能位于页面缓存核心模块内部。这个缓存层的目的是为匿名用户提供完全缓存的响应。主要假设是某些页面一旦缓存就可以为所有匿名用户提供相同的响应——这与我们在 Drupal 7 中所做的方法类似。然而,与之前的版本不同,这个版本在(不)提供过时内容方面要聪明得多,因为它使用了所谓的 缓存标签 来使缓存页面在页面上的某些内容发生变化时失效。我们很快就会详细介绍缓存标签。
在安装 Drupal 8 时,此模块默认启用,可以通过访问 admin/config/development/performance 来进行配置,其配置方式与 Drupal 7 大致相同:

尽管在 Drupal 7 中,为不太复杂的网站中的匿名用户提供服务并不那么糟糕,但当涉及到认证用户时,情况则完全相反。对于动态和细粒度的缓存,Authcache 扩展模块是最佳解决方案,但它极难使用和实现。然而,其中的一些核心原则已被用于 Drupal 8 中 Dynamic Page Cache 模块的开发,这使得事情变得更加简单(且稳健)。
此核心模块默认启用,并为所有类型的用户提供缓存页面所需的所有必要功能。也就是说,可以依赖于某些缓存上下文的页面。简而言之,该模块的方法是将可以服务于所有用户的页面部分一起缓存,并单独处理依赖于上下文的动态内容。它可以这样做,因为这些部分被标准化为渲染数组和可以提供缓存性元数据的其他组件。后者被收集并用于缓存和使最终结果失效。我们将在本章中讨论缓存上下文和所有这些元数据,并更好地理解它们。
在继续之前,我建议你回顾一下第一章的开发者设置部分,为 Drupal 8 开发,我在那里建议你在开发时使用开发者设置。其中一个原因是缓存,主要是动态页面缓存,你可以在settings.php文件中禁用它:
$settings['cache']['bins']['dynamic_page_cache'] = 'cache.backend.null';
在启用缓存的情况下进行实际开发是困难的,但与此同时,经常启用缓存并确保你的代码仍然能够正确运行是很重要的。很容易忘记某些依赖于上下文或应在某些操作后失效的代码片段,有时只有在启用缓存的情况下测试时才会发现这些问题。
话虽如此,让我们来谈谈缓存性元数据以及它是如何与渲染数组一起工作的。
缓存性元数据
缓存性元数据用于描述与它的动态性相关的事物。大多数时候,作为 Drupal 8 模块开发者,我们将在处理渲染数组时使用这些元数据。我们稍后会看到这些元数据在其他地方的应用,但现在,让我们看看实际的属性以及它们在渲染数组上下文中的用途。
在创建渲染数组时,我们需要考虑一些与缓存相关的事项。而且我们始终需要考虑这些事项。
缓存标签
我们首先需要考虑的是我们的渲染数组依赖于什么。我们是渲染某些实体数据吗?我们是使用某些配置值?或者任何可能在其他地方改变并影响我们必须渲染的内容的东西?如果答案是肯定的,我们需要使用缓存标签。如果我们不使用它们,我们的渲染数组将按原样缓存,如果底层数据发生变化,我们最终会向用户展示过时的内容或数据。
从另一个角度来看,想象一个简单的文章节点。这种内容可以在其主详情页、文章摘要列表或文章标题列表(以及许多其他潜在的地方)显示。由于无法知道它将在哪里使用,因此标记此节点实体为依赖项的责任在于显示此内容的渲染数组使用缓存标签。这样,当节点更新时,所有依赖于它的渲染数组也会被无效化。
缓存标签是简单的字符串,我们可以为单个渲染数组声明多个缓存标签。它们具有以下特殊形式:thing:identifier,或者在某些情况下,仅仅是thing(如果只有一个这样的thing)。例如,给定节点的缓存标签将是node:1的格式,其中标识符是实际的节点 ID。或者对于配置对象,它将是config:hello_world.custom_salutation。
我之前已经暗示过,例如,某些节点内容可以出现在列表中,因此我们可以使用缓存标签来确保当节点更新时,该节点的渲染数组也会得到更新。由于渲染数组非常细粒度,这可能会带来一个小问题,因为列表本身可能就是一个渲染数组,甚至可能不知道它渲染了哪些节点。或者更严重的是,它不知道何时创建了新的节点,应该将其包含在内。为了解决这个问题,我们有一个特殊的列表缓存标签,可以在渲染实体时使用。例如,node_list缓存标签可以用于节点实体,而product_list缓存标签可以用于产品实体。这些标签会被 Drupal 缓存系统自动理解,所以我们只需要适当地使用它们即可。
然而,为了让生活更简单,所有实体和配置对象都可以被“查询”以提供它们各自的缓存标签。例如:
$tags = $node->getCacheTags();
$tags将是一个包含一个标签的数组——node:[nid]。
同样适用于配置对象,这很方便,因为它可以防止拼写错误和错误。这是由于它们实现的通用CacheableDependencyInterface定义了检索缓存元数据属性的方法。实际上,任何需要成为缓存依赖的值都可以也应该实现这个接口。你会发现,Drupal 核心中有相当多的类实现了这个接口。
你还会遇到RefinableCacheableDependencyInterface,它在底层对象的缓存性可以在运行时改变的情况下使用。例如,添加了一个实体翻译,这意味着需要为该语言添加一个新的缓存上下文。
我们还可以确定特定实体类型的“列表”缓存标签。例如,而不是硬编码product_list标签,我们可以使用EntityTypeInterface上的getListCacheTags()方法。
如果你的渲染数组依赖于某些自定义内容,你可以使用自定义缓存标签,但当你更改底层数据时,你也必须负责使它们失效。当我们直接与缓存 API 交互时,我们将看到这是如何完成的。始终一致地使用 CacheableDependencyInterface 对于任何自定义值对象来说都是一件好事。
缓存上下文
一旦我们考虑了渲染数组的依赖关系,接下来要考虑的第二件最重要的事情是它有什么不同之处。换句话说,有没有什么理由让这个渲染数组有时以一种方式显示,有时以另一种方式显示?
让我们以一个简单的例子来说明一个渲染数组,它会打印出当前用户的名称。这再简单不过了。现在忽略缓存标签,我们立刻意识到我们不能向所有用户展示相同的用户名,对吧?所以,用户 Danny 应该看到“Hi Danny”,而用户 John 应该看到“Hi John”。我们谈论的是同一个渲染数组,但它在上下文中有所不同。换句话说,这个渲染数组的变体需要为每个遇到的上下文单独缓存。这就是我们使用前面提到的 缓存上下文 的地方。
与缓存标签类似,缓存上下文是简单的字符串,渲染数组可以定义多个。例如,user 上下文将为每个用户缓存给定渲染数组的变体。
此外,它们在本质上具有层次性,因为某些上下文可以包含其他上下文。例如,让我们继续我们之前的例子。假设具有 editor 角色的用户应该看到问候信息,而具有 contributor 角色的用户应该看到不同的、更复杂的信息。在这种情况下,缓存上下文将基于用户拥有的角色。但由于它已经因为需要显示其用户名而依赖于实际用户,因此甚至没有必要考虑角色上下文,因为前者已经包含了后者。此外,Drupal 足够智能,足以在组合构成页面的所有渲染数组的缓存上下文时删除多余的上下文。但如果我们的渲染数组仅在用户角色上有所不同,而不一定是用户本身,我们应该使用特定的上下文——user.roles。正如你可能注意到的,层次性体现在上下文的点(.)分隔上。
Drupal 核心已经定义了许多缓存上下文。虽然你可能不需要,至少在开始时不需要,但你也可以定义其他上下文。我建议你查看文档页面(www.drupal.org/docs/8/api/cache-api/cache-contexts),了解开箱即用的可用缓存上下文。
Max-age
在创建渲染数组时,我们需要考虑的最后一件主要事情是它们应该在缓存中存储多长时间,除非底层数据发生变化而使它们失效。这通常是你很少设置的事情,默认情况下它将是永久的。然而,更常见的情况是,你会将这个缓存属性设置为 0,以表示这个渲染数组永远不应该被缓存。这就是当你渲染一些高度动态的内容,根本不值得缓存的时候。
使用缓存元数据
现在我们已经查看过了三个主要的缓存属性,我们需要考虑创建渲染数组,所以让我们回顾一下我们之前的工作,并在需要时将其应用于实践中。
很常见,你会在 Drupal 8 核心代码中看到CacheableMetadata对象被使用和传递。这仅仅用于表示缓存元数据,并提供了一些方便的方法来将此元数据应用于渲染数组,从其中静态实例化自己,或者从一个CacheableDependencyInterface对象中实例化,以及与另一个CacheableMetadata对象合并。
我们将要查看的渲染数组位于HelloWorldSalutation::getSalutationComponent()服务中,用于渲染问候消息。我们构建它相当动态,但简化版本看起来像这样(省略了一些内容):
$render = [
'#theme' => 'hello_world_salutation',
'#salutation' => [
'#markup' => $salutation
]
];
在这里,$salutation要么是来自配置对象的消息,要么是基于一天中的时间生成的。
马上要提到的是,这是那种我们由于其高度动态的特性而无法真正缓存渲染数组的情况之一。这是由于对一天中的时间的依赖造成的。当然,我们可以设置几秒或一小时的最高年龄,但这值得吗?而且我们还冒着显示错误问候的风险。
因此,在这种情况下,我们可以做的是添加一个最大年龄为 0:
$render = [
'#theme' => 'hello_world_salutation',
'#salutation' => [
'#markup' => $salutation
],
'#cache' => [
'max-age' => 0
]
];
如上所示,缓存元数据位于#cache渲染数组属性下。
指定最大年龄基本上告诉 Drupal 永远不要缓存这个渲染数组。关于这一点,重要的是要知道,这个声明将冒泡到顶级渲染数组,从而阻止整个内容被缓存。因此,不要轻易做出阻止缓存的决定。在我们的例子中,这基本上是整个控制器响应,而且实际上是一个非常简单的计算,所以我们没问题。在章节的后面,我们将讨论如何减轻这种情况。
在这个例子中,我们将max-age设置为 0 仍然存在问题。尽管它将与动态页面缓存(max-age将冒泡)一起工作,但为匿名用户服务的内部页面缓存将不会得到这个信息。因此,匿名用户每次都会看到相同的内容。可能在未来的 Drupal 8 版本中,这个问题将会得到解决。我们不会考虑这个问题,因为它是一个很好的例子,说明了使用自动化测试时出现的错误,我们将在本书的最后一章中看到这一点——当然,还有解决方案。
让我们暂时假设我们的问候组件只是渲染配置对象中存储的消息,并且不显示特定时间的内容。如果你还记得:
$config = $this->configFactory->get('hello_world.custom_salutation');
$salutation = $config->get('salutation');
在这种情况下,我们可以缓存渲染数组,但正如我们之前讨论的,我们还需要考虑依赖关系以及它可能有的潜在变化。依赖关系已经很明显了——配置对象。因此,我们会这样做:
$render = [
'#theme' => 'hello_world_salutation',
'#salutation' => [
'#markup' => $salutation
],
'#cache' => [
'tags' => $config->getCacheTags()
]
];
基本上,我们正在请求这个特定配置对象的缓存标签,并将这些标签设置到渲染数组上。如果我们有更多来自多个对象的缓存标签集要设置,我们就必须合并它们。我们可以使用一个工具来确保我们正确地做到这一点。例如:
$tags = Cache::mergeTags($config_one->getCacheTags(), $config_two->getCacheTags());
这将简单地合并两个缓存标签数组。Drupal\Core\Cache\Cache类也有静态辅助方法用于合并缓存上下文和最大年龄(以及其他事情,我鼓励你在学习过程中查看这些)。
幸运的是,我们的渲染数组很简单且不变化,因此我们不需要缓存上下文。然而,如果我们已经将当前用户名附加到问候语中,我们就必须将user上下文添加到渲染数组中,如下所示:
'#cache' => [
'tags' => $config->getCacheTags(),
'contexts' => ['user']
]
这将为每个访问页面的用户缓存不同的渲染数组,并在后续访问时相应地为他们提供服务。
块插件的缓存
我们之前看到的渲染数组被用作控制器响应的一部分。后者也被称为主要内容,因为它构成了页面的主要输出。在一个普通的 Drupal 安装中,它使用块模块,包含在主页面内容块中。我们还提到,将 max-age 设置为 0 将冒泡到顶级渲染数组,导致整个页面不会被缓存。就控制器响应而言,这是正确的。其他块仍然根据它们自己的元数据独立缓存。
在这本书中,你已经学习了我们可以如何创建自定义块,我们看到了它们也是使用渲染数组构建的。既然是这样,缓存元数据也可以应用于这些数组以正确缓存它们。然而,由于我们在创建块插件时扩展了BlockBase类,所以我们实际上是在实现CacheableDependencyInterface,因为BlockPluginInterface扩展了它。
因此,我们不应该在渲染数组上设置元数据,而应该尽可能使用该接口上的方法,通过覆盖默认的父级实现来实现。例如:
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return Cache::mergeContexts(parent::getCacheContexts(), ['user']);
}
我们应该始终将我们自己的值与父级的值合并。
然而,在某些情况下,尤其是在声明缓存标签时,将它们设置在build()方法的渲染数组中更有意义。这是因为你可能已经做了一些工作来获取依赖对象,重复在另一个方法中做这些工作是没有意义的。这是完全可以的。
缓存访问结果
需要考虑缓存元数据的重要地方之一是在AccessResultInterface对象上。如果你还记得上一章的内容,实现此接口的对象被一致地用来表示对某个资源的访问。除此之外,它们还可以包含缓存性元数据。这是因为访问可能依赖于某些可能影响访问结果本身的数据。由于 Drupal 试图缓存访问,我们需要通知它这些依赖关系。
一个很好的例子是HelloWorldAccess服务,我们在其中动态检查对hello_world.hello路由的访问。因此,我们不是简单地返回AccessResultInterface,而是在这样做之前向其添加缓存依赖项。重写的access()方法现在可以看起来像这样:
$config = $this->configFactory->get('hello_world.custom_salutation');
$salutation = $config->get('salutation');
$access = in_array('editor', $account->getRoles()) && $salutation != "" ? AccessResult::forbidden() : AccessResult::allowed();
$access->addCacheableDependency($config);
$access->addCacheableDependency($account);
return $access;
addCacheableDependency()方法通常接受CacheableDependencyInterface对象来读取它们的缓存元数据。如果传递了其他内容,则认为访问结果不可缓存。因此,在我们的情况下,由于访问依赖于问候配置对象和用户账户,我们将它们两者都添加为缓存依赖项。
占位符和延迟构建
现在我们已经了解了一些关于如何在更常见的场景中使用缓存性元数据的信息,让我们转换一下话题,讨论那些具有高度动态数据的页面组件。
当我们将 Hello World 问候的最大年龄设置为 0 秒(不缓存)时,我提到过有方法可以改进这一点以帮助性能。这涉及到通过占位符将相应的渲染推迟到最后一刻。但首先,让我们了解一下背景。
我们讨论的每个缓存属性都可能具有使缓存渲染数组变得无意义的值。我们已经讨论了将最大年龄设置为 0 的情况,但你也可以非常低地设置过期时间以达到相同的效果。此外,某些缓存标签可能被频繁地失效,再次使依赖于它们表示的渲染数组变得无意义。最后,某些缓存上下文可能提供许多变体,这会显著限制缓存的有效性,甚至可能适得其反(高存储成本)。
缓存标签是我们正在构建的应用程序中非常具体的东西,因此不能对哪些具有高失效率做出一般假设。然而,有两个缓存上下文默认被认为具有过高基数,以至于无法有效:session和user。是的,我们之前已经讨论了user上下文作为一个好例子,但在现实中——默认情况下——将此上下文添加到渲染数组的效果几乎与将最大存活时间设置为 0 相同——它将不会被缓存。对于session上下文也是如此,因为网站上可能有如此多的会话和用户,你可能不希望为每个单独的会话或用户保留缓存记录。
由于这些规则并非必须适用于所有应用,Drupal 将这些值配置为服务参数,以便在需要时进行更改。在core.services.yml文件(其中列出了大多数核心服务)中,我们还可以找到一些参数定义,包括这个:
renderer.config:
auto_placeholder_conditions:
max-age: 0
contexts: ['session', 'user']
tags: []
如您所见,包括 0 的最大存活时间值和之前提到的缓存上下文,但没有标签。我们也可以更改这些值。例如,如果我们知道在我们的应用程序中用户不会太多,并且实际上按用户上下文进行缓存是有意义的,或者我们知道某些缓存标签具有高失效频率,那么更改这些值是有意义的。我们可以通过两种方式来实现:要么使用我们网站范围的services.yml文件并复制这些声明(同时进行适当的更改),要么我们可以以相同的方式使用给定模块的服务文件。这两种方法都会覆盖 Drupal 核心设置的默认参数。
既然我们已经清楚为什么某些事物不可缓存,让我们看看如何使用自动占位符来解决这个问题。
自动占位符过程是 Drupal 识别那些由于我们之前提到的原因不能或不应缓存的渲染数组,并用占位符替换它们的过程。然后,在最后可能的一刻替换占位符,同时允许页面的其余部分进行缓存。这也被称为懒惰构建。
Drupal 通过适合我们之前看到的条件的缓存元数据和渲染数组上#lazy_builder属性的存在来识别需要懒惰构建的部分。后者映射到一个返回其自己的渲染数组的回调函数,该渲染数组也可以包含上述缓存元数据。并且,无论哪个渲染数组包含后者都无关紧要。
懒惰的构建者
懒构建器不过是渲染数组上的回调,Drupal 可以使用它来在后续阶段构建渲染数组。回调可以是静态的(对类和方法的引用)或动态的(对服务和方法的引用)。使用后者方法更为灵活,因为我们可以从容器中注入依赖,就像我们通常对服务所做的那样。此外,回调可以接受参数,这意味着它可以在已经拥有至少部分所需数据的情况下构建渲染数组。
理解这一点最好的方式是看一个例子。由于我们决定我们的问候组件应该有 0 秒的缓存生命周期,这是一个使用懒构建器构建的好机会。
我们需要做的第一件事是将我们的helloWorld控制器方法中的直接调用问候服务替换为以下内容:
return [
'#lazy_builder' => ['hello_world.lazy_builder:renderSalutation', []],
'#create_placeholder' => TRUE,
];
回到第四章,关于主题化,当我提到渲染数组需要至少包含四个属性(#type、#theme、#markup或#plain_text)时,我说谎了。我们还可以使用这样的懒构建器来延迟构建渲染数组到后续阶段。
#lazy_builder需要是一个数组,其第一个元素是回调函数,第二个元素是要传递给它的参数数组。在我们的例子中,我们不需要后者的任何内容。我们可以传递问候服务,但我们将将其注入到我们将在下一分钟创建的新的hello_world.lazy_builder服务中。回调引用的格式为service_name:method(使用一个冒号进行分隔)或者对于静态调用class_name::method(使用两个冒号)。我们还明确声明了#create_placeholder,以明确指出这个渲染数组应该被占位符替换。最后,正如我之前提到的,缓存元数据可以应用于这个渲染数组,也可以应用于懒构建器生成的结果数组。因此,在这种情况下,我们将选择后者方法。
让我们现在定义我们的服务:
hello_world.lazy_builder:
class: Drupal\hello_world\HelloWorldLazyBuilder
arguments: ['@hello_world.salutation']
这里没有什么特别之处,但我们正在将HelloWorldSalutation服务作为依赖项注入,这样我们就可以请求我们的问候组件。实际的服务类看起来是这样的:
namespace Drupal\hello_world;
/**
* Lazy builder for the Hello World salutation.
*/
class HelloWorldLazyBuilder {
/**
* @var \Drupal\hello_world\HelloWorldSalutation
*/
protected $salutation;
/**
* HelloWorldLazyBuilder constructor.
*
* @param \Drupal\hello_world\HelloWorldSalutation $salutation
*/
public function __construct(HelloWorldSalutation $salutation) {
$this->salutation = $salutation;
}
/**
* Renders the Hello World salutation message.
*/
public function renderSalutation() {
return $this->salutation->getSalutationComponent();
}
}
这一切都非常简单。由于我们在懒构建器中引用了renderSalutation()方法,所以需要这个方法。这就是我们必须要做的。但是,这究竟会发生什么呢?
当 Drupal 渲染我们的控制器时,它会找到懒加载构建器并将其与占位符注册,然后使用占位符代替实际的最终渲染数组。然后,在页面构建过程的后期,懒加载构建器被调用,实际输出被渲染以替换占位符。这种方法有几个优点和影响。首先,它允许 Drupal 绕过这个高度动态的输出部分,并将动态页面缓存中的其余组件缓存起来。这是为了防止缓存不可用的问题影响到整个页面。其次,有几种不同的策略(到目前为止)可以处理占位符。默认情况下,使用所谓的“单次刷新”方法,占位符替换被推迟到最后一刻,但在完成之前不会将响应发送回浏览器。因此,动态页面缓存确实改善了事情(缓存了它能缓存的内容),但响应仍然依赖于占位符处理完成。根据这需要多长时间,页面的加载通常可能会受到影响。然而,当使用BigPipe(www.facebook.com/notes/facebook-engineering/bigpipe-pipelining-web-pages-for-high-performance/389414033919)方法时,在替换占位符之前,响应就被发送回浏览器。随着后者的完成,替换也被流式传输到浏览器。这大大提高了网站的感知性能,因为用户可以在较慢的部分出现之前看到页面的大部分内容。
BigPipe 技术是由 Facebook 发明的一种处理高度动态页面的方法,并逐渐被引入 Drupal 8 作为实验性核心模块。在版本 8.3 中,它已被标记为稳定,并准备好在生产网站上使用。我强烈建议您保持此模块启用,因为它包含标准安装配置文件。
如您现在可能已经猜到的,懒加载构建器方法仅在动态页面缓存时有用。也就是说,当我们为认证用户缓存时。它不会与用于匿名用户的内部页面缓存一起工作。
使用缓存 API
到目前为止,在这一章中,我们主要关注渲染数组和如何将它们暴露给缓存 API 以获得更好的性能。现在是时候谈谈默认情况下 Drupal 如何存储缓存条目,以及我们如何在代码中与它们交互了。
如前所述,缓存系统的中心接口是CacheBackendInterface,这是任何缓存系统都需要实现的接口。它基本上提供了创建、读取和使缓存条目无效的方法。
正如我们所预期的,当我们想要与缓存 API 交互时,我们使用一个服务来检索CacheBackendInterface的一个实例。然而,我们使用的服务名称取决于我们想要与之工作的缓存bin。缓存bin是根据它们的类型将缓存条目组合在一起的存储库。因此,上述实现包装了一个单独的缓存区,每个区都有一个机器名。服务名称将是以下格式:cache.[bin]。这意味着对于每个缓存区,我们都有一个单独的服务。
获取此服务的静态简写如下:
$cache = \Drupal::cache();
这将返回由CacheBackendInterface实现表示的default缓存区。如果我们想要请求特定的缓存区,我们传递名称作为参数:
$cache = \Drupal::cache('render');
这将返回render缓存区。
当然,如果我们需要在某处注入一个缓存区包装器,我们只需使用前面提到的格式中的服务机器名。
尽管我们为每个缓存区都有一个单独的服务,但它们基本上都做同样的事情,那就是使用CacheFactory为该区实例化正确的缓存后端类型。单个缓存后端可以注册并设置为全局或特定缓存区的默认后端。
如我在本章开头提到的,Drupal 中的默认缓存后端——这个工厂将为所有缓存区实例化——是DatabaseBackend。每个缓存区由一个数据库表表示。这与 Drupal 7 中的概念类似。
既然我们已经知道了如何加载缓存后端服务,让我们看看我们如何使用它来读取和缓存东西。当涉及到这一点时,你的首要参考点是CacheBackendInterface,它记录了所有方法。然而,由于它不强化返回值,我们接下来要看到的例子将使用数据库缓存后端。它们可能与其他缓存后端实现不同。
我们将要讨论的第一个方法是get(),它接受我们想要检索的缓存条目的 ID($cid)和一个可选的$allow_invalid参数。第一个参数已经很明确了,但第二个参数用于在条目已过期或已失效的情况下检索条目。这在那些宁愿选择过时数据而不是多个并发请求的计算成本的情况下可能很有用:
$data = $cache->get('my_cache_entry_cid');
结果的$data变量是一个包含data键(已缓存的资料)和关于缓存条目的各种元数据的 PHP 标准类:过期时间、创建时间戳、标签、有效状态等等。
当然,还有一个getMultiple()方法,你可以用它一次性检索多个条目。
更有趣的是,set()方法允许我们在缓存中存储东西。此方法有四个参数:
-
$cid:用于检索条目的缓存 ID。 -
$data:一个可序列化的数据结构,如数组或对象(或简单的标量值)。 -
$expire:UNIX 时间戳,在此时间戳之后,此条目被认为是无效的,或者使用CacheBackendInterface::CACHE_PERMANENT来表示此条目永远不会无效,除非明确使其无效。后者是默认值。 -
$tags:一个缓存标签数组,如果这个条目依赖于其他东西(缓存元数据,基本上),则将使用这些标签来使该条目无效。
因此,要使用它,我们会这样做:
$cache->set('my_cache_entry_cid', 'my_value');
通过这个语句,我们在选择的 bin 中创建了一个简单的非序列化缓存条目,除非明确使其无效(或删除),否则不会过期。随后的相同缓存 ID 调用将简单地覆盖条目。如果缓存值是数组或对象,它将自动序列化。
当涉及到删除时,有两种简单的方法:delete()和deleteMultiple(),它们分别以$cid(或缓存 ID 数组)作为参数,并将条目从 bin 中完全删除。如果我们想删除 bin 中的所有项目,我们可以使用deleteAll()方法。
与删除条目相比,很多时候使它们无效是一个好主意。我们仍然可以使用$allow_invalid参数检索数据,并在新条目被重新计算时使用条目。这几乎可以像删除一样完成,但使用以下方法代替:invalidate()、invalidateMultiple()和invalidateAll()。
好的,但那些我们可以与条目一起存储的缓存标签是什么?我们多少已经知道了它们的作用,那就是在多个 bin 中使用某些数据标记来标记缓存条目,这样当数据更改时可以轻松使它们无效。就像渲染数组一样。那么,我们如何做到这一点呢?
假设我们存储以下缓存条目:
$cache->set('my_cache_entry_cid', 'my_value', CacheBackendInterface::CACHE_PERMANENT, ['node:10']);
我们基本上使其依赖于 ID 为 10 的节点更改。这意味着当该节点更改时,我们的条目(以及所有其他 bin 中的所有其他条目,这些条目具有相同的标签)将变得无效。就这么简单。
但我们也可以有自己的标签,使其依赖于我们自己的某些自定义内容,如数据值(正如我们在本章前面讨论的,应该实现CacheableDependencyInterface)或某种过程。在这种情况下,我们还需要负责使所有带有我们标签的缓存条目无效。我们可以通过以下最简单的方式静态地做到这一点,使用我们在合并元数据时遇到的Cache类:
Cache::invalidateTags(['my_custom_tag']);
这将使所有带有数组中传递的任何标签的缓存条目无效。在底层,此方法使用对缓存无效化服务器的静态调用,因此,尽可能的情况下,最好实际注入该服务——cache_tags.invalidator。
创建我们自己的缓存 bin
通常,现有的缓存存储,尤其是默认的,足以存储我们的缓存条目。然而,有时我们需要为同一功能创建多个条目,在这种情况下,有一个专门的存储会很有帮助。那么,让我们看看如何创建它。
这相当简单,因为我们只需要定义一个服务:
cache.my_bin:
class: Drupal\Core\Cache\CacheBackendInterface
tags:
- { name: cache.bin }
factory: cache_factory:get
arguments: [my_bin]
在这个服务定义中使用的类实际上是一个接口。这是因为我们使用工厂来实例化服务而不是直接使用容器。这意味着我们不知道将实例化哪个类。在这种情况下,相关的工厂是名为cache_factory的服务及其get()方法。在第三章“日志和邮件”中,我们看到了一个例子,当我们讨论日志通道时,发生了类似的事情。
cache.bin标签用于让 Drupal 理解这个服务的功能,即它是一个缓存存储。确保这个存储获得其存储的责任属于实际的后端。因此,在我们的例子中,DatabaseBackend根据需要创建和删除缓存表。
最后,静态参数是传递给工厂并用于为这个特定存储创建缓存后端的存储名称。就是这样。如果我们清除缓存,我们已经在数据库中看到了我们存储的新缓存表。
摘要
在本章中,我们介绍了 Drupal 8 中任何模块开发者都需要熟悉的缓存的主要方面。我们介绍了一些关键概念,并讨论了两种主要的缓存类型——内部页面缓存(用于匿名用户)和动态页面缓存(用于认证用户)。
我们更深入地研究了缓存性元数据,这可能是我们最需要理解和最常见的事情。正确使用它是强制性的,以确保我们构建的所有渲染数组都被正确缓存和使失效。我们还看到了如何使用特定的方法来定义块插件的缓存性元数据,以及访问结果应该如何根据需要接收缓存性依赖。在此基础上,我们还探讨了懒加载构建器和允许我们在保持整体良好缓存性的同时处理高度动态组件的自动占位符策略。
最后,我们探讨了如何自己使用 Cache API 来存储、读取和使我们的缓存条目失效。我们还看到了如何创建我们自己的自定义缓存存储。
缓存是 Drupal 8 模块开发的一个重要方面。在之前的版本中,它甚至无法接近,我们经常能够忽略它而无需特别注意。现在,我们有一个强大的系统来提高渲染性能,我们应该充分利用它。
在下一章中,我们将讨论 JavaScript 以及我们如何在 Drupal 环境中使用它,以及强大的 Ajax API。
第十二章:JavaScript 和 Ajax API
到目前为止,在这本书中,我们只讨论了可以被认为是与后端开发相关的话题。这意味着大量的 PHP 与 API 和数据库一起工作,等等。这是因为这本书面向的是模块开发者,而不是“主题开发者”。此外,这本书的作者承认自己不是 JavaScript 或任何前端开发者。
尽管如此,在本章中,我们将转换方向,简要讨论一下 前端开发,即如何在 Drupal 8 应用程序中与 JavaScript 一起工作。这是因为开发者可以在他们的模块中做很多事情,这些事情需要前端技术。在添加和使用 JavaScript 文件方面,Drupal 有一些特定的方法和技巧,我们将在本章中讨论这些内容。此外,我们还将证明 Drupal 8 在允许我们进行大量的 JavaScript 工作方面是多么强大,而实际上我们甚至不需要编写一行 JavaScript 代码。
因此,在本章中我们将介绍一些内容。
首先,我们将讨论在 Drupal 中编写 JavaScript 的方法。你已经在第四章,“主题”中学习了如何创建库并将它们附加到渲染数组、元素或页面上。基本上,通过使用库,我们可以在需要的时候加载我们的 JavaScript 文件。如果你不记得库是如何工作的,我建议你查看第四章,“主题”中的“资源和库”部分。因为在本章中,我们将从这里继续,并简要讨论那些 JavaScript 文件中实际包含的内容。
一个有用的资源是文档页面(www.drupal.org/node/172169),它列出了 Drupal 8 中 JavaScript 的编码标准,我们应该遵守这些标准。
在本章的第一部分,我们实际上不会编写很多 JavaScript 代码——只是足够让你开始。在第二部分,我们则一个字也不会写。相反,我们将讨论与 Drupal 一起提供的强大 Ajax API,它允许我们构建一些非常动态的功能,这些功能依赖于 JavaScript。为了展示事物是如何工作的,我们将回顾我们在第七章,“你的自定义实体和插件类型”中开始的功能导入,并使用 Ajax 来改进它。
最后,我们还将讨论表单 API 的状态系统,它允许我们以声明的方式使我们的表单元素动态化并依赖于其他元素。同样,我们甚至不需要了解任何 JavaScript 就可以完成相当复杂的客户端行为。
Drupal 中的 JavaScript
Drupal 8 依赖于许多 JavaScript 库和插件来执行一些前端任务。例如,使用 Backbone.js 是 Drupal 相较于之前版本在采用现有库而不是重新发明轮子方面的进步。当然,正如我们已经看到的,无处不在的 jQuery 库在 Drupal 8 中仍然被使用。但当然,还有其他库。
另一件我已经提到过,但再次提及是有帮助的,那就是 Drupal 现在不再在所有页面上无谓地加载诸如 jQuery 或其 Ajax 框架之类的东西。例如,许多为匿名用户提供服务且不需要 jQuery 的页面甚至不会加载它。这可以大大提高性能。但这也意味着,当我们定义库以包含我们自己的 JavaScript 文件时,我们必须始终将这些声明为依赖项(如果我们需要它们)。例如,jQuery 是你经常依赖的东西。
Drupal 行为
当你在 Drupal 中编写 JavaScript 文件时,你需要知道的一个重要概念是行为。但为了理解这一点,让我们先了解一下背景。
当使用 jQuery 编写 JavaScript 代码时,通常的标准是将我们的代码包裹在一个 ready() 方法语句中,如下所示:
$(document).ready(function () {
// Essentially the entirety of your javascript code.
});
这确保了您的代码只有在浏览器加载了整个 文档对象模型(DOM)之后才会运行。此外,使用 jQuery 来实现这一点在很大程度上有助于跨浏览器兼容性,并允许我们在页面上任何我们想要的位置放置此代码(例如头部或尾部)。
然而,在 Drupal 中,我们有一个不同的解决方案,这个方案在编写与 Drupal 一起工作的 JavaScript(而不仅仅是与 DOM 一起工作)的上下文中更好。这个解决方案就是 Drupal 行为。简而言之,行为是我们声明的、在 DOM 完全加载时被调用的方法,也就是说,当文档准备就绪时。然而,除此之外,它们还会在 Ajax 框架将新数据加载到页面上时被调用。即使在使用 BigPipe 和占位符替换时也是如此。
任何 Drupal 网站都有一个全局的 Drupal 对象,用于许多我们现在不会深入讨论的事情。然而,Drupal.behaviours 对象是我们声明行为的地方,通常我们想要运行的任何 JavaScript 代码都应该放在行为内部。所以,让我们看看一个例子,这样会更容易理解。
我们想要展示一个动态的 JavaScript 时钟,位于“Hello World”问候语旁边,如果消息不是来自配置而是依赖于一天中的时间。在编写我们功能性的代码时,我们将讨论 Drupal 行为以及它们是如何被使用的。
我们的库
为了使我们的 JavaScript 文件能够加载,它需要位于一个库中,并附加到某个东西上。正如你在第四章中学习的主题化,库文件名为 hello_world.libraries.yml,位于我们模块的根目录中:
hello_world_clock:
version: 1.x
js:
js/hello_world_clock.js: {}
dependencies:
- core/jquery
- core/drupal
- core/jquery.once
我们只需要一个 JavaScript 文件,它位于我们模块的 js 目录中,用于我们的目的。但我们确实有一些依赖项。首先,我们希望加载 jQuery,因为我们将会使用它。其次,我们希望有通用的 Drupal JavaScript 库,它处理许多事情,包括行为。我们将很快讨论最后一个依赖项,那时它会有更多的意义。
如果没有声明这些依赖项,在某些情况下(尤其是对于匿名用户),Drupal 不会在页面上加载它们,我们的 JavaScript 功能将无法工作。
现在,让我们将这个库附加到位于 HelloWorldSalutation 服务内部的 salutation 组件上。
在这两行之后:
$time = new \DateTime();
$render['#target'] = $this->t('world');
我们可以添加以下内容:
$render['#attached'] = [
'library' => [
'hello_world/hello_world_clock'
]
];
这对我们来说并不新鲜,但关键是我们只在我们需要显示的组件上附加库,这个动态问候消息依赖于一天中的时间。如果这个消息已被覆盖,我们甚至不想加载这些库,这就是全部。我们可以深入进去创建我们的 hello_world_clock.js 文件。
JavaScript
在 JavaScript 文件中,我们首先需要做的是将我们在这个文件中编写的整个代码包裹在一个立即调用的函数表达式(IIFE)中。通过这样做,我们保护了我们编写的代码的作用域,使其不会与全局作用域冲突,甚至可以在我们自己的作用域中使用与变量名更常见的全局变量。这就是它的样子:
(function (Drupal, $) {
"use strict";
// Our code here.
}) (Drupal, jQuery);
这里最重要的事情是,现在我们可以在函数内部使用美元符号($)作为对全局 jQuery 对象的引用,而不会干扰可能使用相同变量名的其他库。此外,我们还添加了 use strict 声明,以确保我们编写的代码语义正确(这也是 Drupal 8 的 JavaScript 编码标准的一部分)。
让我们现在添加我们功能的核心部分,并解释它是如何工作的:
Drupal.behaviors.helloWorldClock = {
attach: function (context, settings) {
function ticker() {
var date = new Date();
$(context).find('.clock').html(date.toLocaleTimeString());
}
var clock = '<div>The time is <span class="clock"></span></div>';
$(document).find('.salutation').append(clock);
setInterval(function() {
ticker();
}, 1000);
}
};
首先,我们正在定义一个新的行为,这是一个位于 Drupal.behaviours 对象上的对象,并且需要有一个唯一的名字。你可以把一个行为看作是一块功能。我们只需要在这个对象上有一个名为 attach 的函数,它接收两个参数:context(正在加载的页面或页面的一部分)和 settings(包含从 PHP 传递的数据的变量)。
这个函数会在 Drupal 需要附加行为时被调用——Drupal.attachBehaviors()。这发生在页面首次加载时(在这种情况下,context 是整个 DOM),或者在 Ajax 请求或 BigPipe 替换之后(在这种情况下,context 只包含页面新加载的部分)。因此,使用 context 而不是整个文档来查找元素有时会更高效(尤其是在 Ajax 请求之后),并且可以防止其他副作用。
在 attach 函数内部,我们有创建时钟的逻辑。首先,我们定义一个简单的函数,用于查找具有 .clock 类的元素并将当前时间放入其中。你会注意到我们使用了 context 来查找元素。接下来,我们自行创建这个元素并将其附加到我们的问候信息元素上。最后,我们每秒设置一个间隔来持续调用我们的 ticker() 函数,本质上每秒更新一次时间,从而产生时钟的错觉。这一切都很标准。
请注意,我们通过 JavaScript 打印给用户的字符串没有经过翻译系统,这不是一个好的做法(即使网站不是多语言的)。在后面的章节中,我们将看到我们如何需要处理它。
清除缓存并导航到我们的 /hello 页面,我们就可以看到新的时钟已经出现(如果我们没有覆盖问候信息)。所以,我们完成了,对吧?嗯,其实并没有。
如果我们打开浏览器的开发者工具,即控制台,并尝试再次附加行为:
Drupal.attachBehaviors();
我们注意到我们的时钟元素被再次附加(它已经被复制了)。这显然是不对的,因为如果我们有 Ajax 请求,我们就有风险发生这种情况。这就是 jQuery.once 发挥作用的地方。
jQuery.once 库是 jQuery 的一个插件,它允许我们跟踪并确保我们只执行一次某个操作。实际上,它非常简单易用。我们只需替换这一行:
$(context).find('.salutation').append(clock);
通过这种方式:
$(context).find('.salutation').once('helloWorldClock').append(clock);
所以基本上,在执行实际操作之前,我们使用一个 ID 调用 .once() 方法来跟踪。这将确保链中接下来的任何操作只应用于尚未应用过该操作的元素。现在你也看到了为什么我们希望我们的库依赖于 core/jquery.once。
有了这个,我们的时钟就准备好了。
Drupal 设置
我们还可以做另一件强大而常见的事情(我们经常需要做),那就是将值从 PHP 代码传递到 JavaScript 层。在自定义 PHP 应用程序中,这可能会变得很复杂,但 Drupal 有一个强大的 API,可以将 PHP 数组转换为 JavaScript 对象。这些对象可以在传递给行为 attach() 函数的 settings 对象中找到。
再次,理解这一点最简单的方法是通过一个例子。所以,假设我们想在问候语之后打印一条额外的消息,如果它是下午的话。当然,我们也可以使用 JavaScript 来确定这一点,但到目前为止,这一直是我们的 PHP 代码的责任,所以让我们保持这种方式。因此,我们需要一种方法来告诉我们的 JavaScript 它是下午,如果那样的话,我们可以通过设置一个标志来实现,如下所示:
if ((int) $time->format('G') >= 12 && (int) $time->format('G') < 18) {
$render['#salutation']['#markup'] = $this->t('Good afternoon');
$render['#attached']['drupalSettings']['hello_world']['hello_world_clock']['afternoon'] = TRUE;
return $render;
}
新的是 if 条件 中的第二行,即我们将其附加到渲染数组中的那一行。然而,在这种情况下,它不是一个库,而是一个大型的多维数组中的 drupalSettings。最佳实践是按照以下方式分层命名我们的设置:我们的模块名称 -> 设置所属的功能 -> 设置名称。在 JavaScript 中,这个数组将被转换成一个对象。
要使 drupalSettings 生效,我们需要确保已加载 core/drupalSettings 库。在我们的例子中,这是因为 core/drupal 库将其列为依赖项。
现在我们传递了这个标志(如果需要,它可能更加复杂),我们就可以在 JavaScript 中使用它:
var clock = '<div>The time is <span class="clock"></span></div>';
if (settings.hello_world != undefined && settings.hello_world.hello_world_clock.afternoon != undefined) {
clock += 'Are you having a nice day?';
}
就这样了。我们成功地轻松地将值从 PHP 传递到 JavaScript 并在客户端逻辑中使用它们。
Ajax API
现在您已经准备好编写您应用程序所需的任何 JavaScript 代码,并且能够将其与 Drupal 后端 API 集成,让我们来看看 Ajax 框架。在不编写任何 JavaScript 代码的情况下,我们可以在客户端做很多事情。
Drupal Ajax API 是一个强大的系统,它允许我们通过 PHP 定义客户端交互。我们最常使用 Ajax 与表单交互——触发某些动作,改变 DOM 而无需重新加载页面。我们将通过扩展我们在 第七章,您的自定义实体和插件类型 中构建的导入功能来演示这一切是如何工作的。在深入探讨之前,让我们快速看一下 Drupal 8 中 Ajax 的简单用法。
Ajax 链接
与 Drupal 的 Ajax API 交互的最简单方法是向任何链接添加 use-ajax 类。这将导致链接向链接的路径发送 Ajax 请求,而不是将浏览器移动到那里。类似的事情可以使用表单的提交按钮通过 use-ajax-submit 类来完成。这使得表单通过 Ajax 提交到表单的 action 中定义的路径。
然而,最重要的是我们在流程的另一端所做的工作。点击一个触发 Ajax 请求的链接,如果我们没有相应地处理该请求,那么什么也不会发生。我们必须做的是返回一个包含一些 jQuery 命令 的 AjaxResponse 对象,这些命令指导浏览器对 DOM 所需进行的更改。所以,让我们看看一个例子。
记得在第二章,创建你的第一个模块中,当我们创建第一个仅渲染服务中的问候消息的块时?它没有使用我们在第四章,主题化中创建的主题钩子,而是简单地委托给HelloWorldSalutation服务的getSalutation()方法。假设我们想在消息后添加一个可以点击的链接,并且可以完全隐藏块。我们需要采取几个简单的步骤来实现这一点。
首先,我们需要修改块中的build()方法,使其看起来像这样:
/**
* {@inheritdoc}
*/
public function build() {
$build = [];
$build[] = [
'#theme' => 'container',
'#children' => [
'#markup' => $this->salutation->getSalutation(),
]
];
$url = Url::fromRoute('hello_world.hide_block');
$url->setOption('attributes', ['class' => 'use-ajax']);
$build[] = [
'#type' => 'link',
'#url' => $url,
'#title' => $this->t('Remove'),
];
return $build;
}
以及新的use声明:
use Drupal\Core\Url;
我们首先做的事情是将我们的原始基于#markup的简单数组包装到 Drupal 核心的container主题钩子中,这样它就会用一些 div 将其包裹起来,我们就不必创建自己的主题钩子。毕竟,我们在这里做的是概念验证工作。接下来,在消息下方,我们打印出一个指向我们必须要定义的新路由的链接。正如我们之前讨论的,我们给这个链接添加了use-ajax类。你会注意到,我们可以直接将属性(参考第四章,主题化,了解更多关于这些属性的信息)添加到Url对象中,它们将被添加到渲染的链接元素中。
第二,我们需要定义这个新路由。这再简单不过了:
hello_world.hide_block:
path: '/hide-block'
defaults:
_controller: '\Drupal\hello_world\Controller\HelloWorldController::hideBlock'
requirements:
_permission: 'access content'
我们将其映射到我们一直在使用的相同控制器类的新方法上,并允许所有用户访问它。
第三(也是最后),我们需要定义控制器方法:
/**
* Route callback for hiding the Salutation block.
* Only works for Ajax calls.
*
* @param \Symfony\Component\HttpFoundation\Request $request
*
* @return \Drupal\Core\Ajax\AjaxResponse
*/
public function hideBlock(Request $request) {
if (!$request->isXmlHttpRequest()) {
throw new NotFoundHttpException();
}
$response = new AjaxResponse();
$command = new RemoveCommand('.block-hello-world');
$response->addCommand($command);
return $response;
}
以及顶部的新use声明:
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\RemoveCommand;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
你首先会注意到这个方法的$request参数,你可能想知道它从哪里来。Drupal 将当前请求对象传递给任何仅通过该类类型提示参数的控制器方法。因此,我们不需要将其注入到我们的控制器中。我们需要它的原因是我们可以检查对这条路由的请求是否是通过 Ajax 发起的。因为如果不是,我们不想处理它。也就是说,我们抛出一个NotFoundHttpException,这会导致常规的 404 错误。
然后是关于 Ajax API 的有趣内容,即构建一个充满命令的AjaxResponse,这些命令将返回到浏览器。在我们的例子中,只有一个命令,它指示浏览器在匹配传递给它的选择器的元素上运行 jQuery 的remove()方法。在我们的情况下,这是块包装器的类。有了这个,我们的功能就到位了。我们可以清除缓存,现在块应该会打印出一个通过 Ajax 删除块的链接。
你可能会想:为什么我们需要回到服务器去完成本可以在客户端独立完成的工作?答案是——实际上我们不需要。然而,这作为一个很好的例子,说明了 Ajax 响应是如何工作的。我鼓励你查看 Ajax API 的文档页面(api.drupal.org/api/drupal/core!core.api.php/group/ajax/8.6.x ),在那里你可以找到所有可用命令的列表。例如,我们可以使用ReplaceCommand来用从服务器返回的另一个块替换块,或者使用HtmlCommand在页面上插入一些数据,甚至使用AlertCommand触发一个带有来自服务器的数据的 JavaScript 警告。酷的地方在于响应可以处理多个命令,所以我们不受仅使用一个的限制。
表单中的 Ajax
在 Drupal 中,Ajax 最常用的用途是通过表单 API,我们可以轻松地在服务器和客户端之间创建动态交互。为了演示这是如何工作的,我们将通过一个示例来展示。这将是对我们在第七章,“你自己的自定义实体和插件类型”中创建的导入器配置实体表单的重构。
如果你还记得,我们说过将某些配置值绑定到通用实体是没有意义的,因为导入插件可能不同。我们编写的第一个导入器从远程 URL 加载 JSON 文件。因此,可以合理地认为 URL 的配置值绑定到插件而不是配置实体(即使后者实际上存储它)。因为如果我们想创建一个 CSV 导入器,例如,我们不需要 URL。所以,让我们重构我们的工作来实现这一点。
这是我们需要采取的步骤的概述,以进行此重构:
-
导入器插件需要提供它们自己的配置表单元素。
-
导入器配置表单需要根据所选的插件读取这些元素(这就是 Ajax API 发挥作用的地方)。
-
我们需要更改特定于插件的值的数据存储和配置架构。
让我们从为ImporterInterface插件类型提供一个新方法开始:
/**
* Returns the form array for configuring this plugin.
*
* @param \Drupal\products\Entity\ImporterInterface $importer
*
* @return array
*/
public function getConfigurationForm(\Drupal\products\Entity\ImporterInterface $importer);
这负责获取此插件所需的表单元素。作为一个参数,它接收导入器配置实体,可以检查默认值。
接下来,在配置实体的ImporterInterface上,我们需要移除getUrl()方法(因为这是针对JsonImporter插件的特定方法)并替换为用于检索与实体选择的插件相关的所有配置值的通用方法:
/**
* Returns the configuration specific to the chosen plugin.
*
* @return array
*/
public function getPluginConfiguration();
当然,在导入器实体类中,我们也反映了这一变化(通过替换$url属性):
/**
* The configuration specific to the plugin.
*
* @var array
*/
protected $plugin_configuration;
实际的 getter 方法,与接口保持一致:
/**
* {@inheritdoc}
*/
public function getPluginConfiguration() {
return $this->plugin_configuration;
}
到目前为止,一切顺利,没有复杂的事情发生。我们正在用通用配置值替换特定于插件的配置值,其中将存储特定于所选插件的值。然而,由于我们的实体类型不再有$url字段,而是有一个$plugin_configuration字段,因此我们还需要调整注解中的config_export键以反映这一变化:
* config_export = {
* "id",
* "label",
* "plugin",
* "update_existing",
* "source",
* "bundle",
* "plugin_configuration"
* }
现在,让我们转向ImporterForm并对其进行所有调整。但在我们这样做之前,让我们将url字段的表单元素移动到JsonImporter中,在那里我们必须实现新的getConfigurationForm()方法:
/**
* {@inheritdoc}
*/
public function getConfigurationForm(\Drupal\products\Entity\ImporterInterface $importer) {
$form = [];
$config = $importer->getPluginConfiguration();
$form['url'] = [
'#type' => 'url',
'#default_value' => isset($config['url']) ? $config['url'] : '',
'#title' => $this->t('Url'),
'#description' => $this->t('The URL to the import resource'),
'#required' => TRUE,
];
return $form;
}
你会注意到在获取默认值时有一些差异。我们不再在配置实体上调用已删除的getUrl()方法,而是使用新的getPluginConfiguration()方法并在结果数组内部进行检查。另外,由于我们使用$this->t()方法来确保字符串的翻译,我们还应该使用StringTranslationTrait(它可以放在父基类中,因为它是一个特质):
use StringTranslationTrait;
让我们不要忘记,我们实际上在导入时使用了 URL,因此我们还需要对getData()方法做一些调整:
/**
* Loads the product data from the remote URL.
*
* @return \stdClass
*/
private function getData() {
/** @var ImporterInterface $importer_config */
$importer_config = $this->configuration['config'];
$config = $importer_config->getPluginConfiguration();
$url = isset($config['url']) ? $config['url'] : NULL;
if (!$url) {
return NULL;
}
$request = $this->httpClient->get($url);
$string = $request->getBody();
return json_decode($string);
}
在此基础上,我们可以继续调整我们的ImporterForm(其中我们不再有 URL 字段的表单元素)。
我们需要做的主要有两件事:
-
将插件选择元素暴露给 Ajax,即当用户做出选择时触发 Ajax 请求
-
根据选择的插件添加额外的元素到表单中
新的plugin元素看起来是这样的:
$form['plugin'] = [
'#type' => 'select',
'#title' => $this->t('Plugin'),
'#default_value' => $importer->getPluginId(),
'#options' => $options,
'#description' => $this->t('The plugin to be used with this importer.'),
'#required' => TRUE,
'#empty_option' => $this->t('Please select a plugin'),
'#ajax' => array(
'callback' => [$this, 'pluginConfigAjaxCallback'],
'wrapper' => 'plugin-configuration-wrapper'
),
];
有两个显著的变化:我们添加了一个#empty_option键(用于在用户未做出选择时显示的选项)和一个#ajax键(我们将在下面更详细地讨论)。
我们所做的是相当简单的。我们声明了一个回调方法,当用户更改此表单元素时将被触发,并声明了应该用 Ajax 回调的结果替换的元素的 HTML ID。在后者(这是同一类的一个简单方法)中,我们只需做以下操作:
/**
* Ajax callback for the plugin configuration form elements.
*
* @param $form
* @param \Drupal\Core\Form\FormStateInterface $form_state
*
* @return array
*/
public function pluginConfigAjaxCallback($form, FormStateInterface $form_state) {
return $form['plugin_configuration'];
}
我们返回一个表单元素(我们仍然需要定义)。在这里的一个重要教训是,表单中的 Ajax 响应可以返回内容(以渲染数组或字符串的形式),这将用于替换 Ajax 声明中wrapper键指定的 ID 找到的 HTML。或者,也可以返回一个充满命令的AjaxResponse以执行更复杂的事情,就像我们在上一节中看到的那样。
在我们查看这个新的plugin_configuration表单元素之前,让我们看看可以在#ajax数组内部使用的其他一些选项:
-
method:这表示与wrapper元素交互时要使用的 jQuery 方法(如果指定)。默认是replaceWith(),但您也可以使用append()、html()等。 -
event: 这表示应该使用哪个事件来触发 Ajax 调用。默认情况下,相关的表单元素会做出这个决定。例如,当在选择元素中选择一个选项或向文本字段中输入内容时。 -
progress: 这定义了在 Ajax 请求进行时使用的指示器。 -
url: 如果未指定callback,则触发 Ajax 请求的 URL。通常,使用后者更强大,因为整个$form和$form_state作为参数传递,并可用于处理。
我建议你查看文档页面(api.drupal.org/api/drupal/core%21core.api.php/group/ajax/8.7.x),以获取有关这些选项和其他可用选项的更多信息。
在处理完这些之后,我们可以回到我们的表单定义,并在plugin元素之后添加我们缺失的部分:
$form['plugin_configuration'] = [
'#type' => 'hidden',
'#prefix' => '<div id="plugin-configuration-wrapper">',
'#suffix' => '</div>',
];
$plugin_id = NULL;
if ($importer->getPluginId()) {
$plugin_id = $importer->getPluginId();
}
if ($form_state->getValue('plugin') && $plugin_id !== $form_state->getValue('plugin')) {
$plugin_id = $form_state->getValue('plugin');
}
if ($plugin_id) {
/** @var \Drupal\products\Plugin\ImporterInterface $plugin */
$plugin = $this->importerManager->createInstance($plugin_id, ['config' => $importer]);
$form['plugin_configuration']['#type'] = 'details';
$form['plugin_configuration']['#tree'] = TRUE;
$form['plugin_configuration']['#open'] = TRUE;
$form['plugin_configuration']['#title'] = $this->t('Plugin configuration for <em>@plugin</em>', ['@plugin' => $plugin->getPluginDefinition()['label']]);
$form['plugin_configuration']['plugin'] = $plugin->getConfigurationForm($importer);
}
首先,我们将plugin_configuration表单元素定义为hidden类型。这意味着当页面首次加载时,用户将看不到它。然而,我们确实使用了#prefix和#suffix选项(与 Drupal 表单 API 的常见做法)来用具有我们指示的 ID 的 div 包装这个元素,作为我们的 Ajax 声明的包装器。所以,目标是每次进行 Ajax 请求时(即每次选择插件时)替换这个元素。
接下来,我们尝试获取所选插件的 ID。首先,如果我们在查看编辑表单,我们会从配置实体中加载它。然而,我们也会检查表单状态,看看是否已经选择了一个(并且与实体中的不同)。如果你想知道我们如何在表单状态中拥有插件,答案是:在用户选择插件后触发 Ajax 调用后,表单会被重建。现在,我们可以看到表单状态中的内容,并检索所选的插件 ID。
更重要的是,如果我们获得了插件 ID,我们可以完全更改plugin_configuration元素,这反过来又会被 Ajax 回调返回,用于替换我们的包装器。所以总结一下:
-
页面首次加载(在新的表单中)。元素被隐藏。
-
用户选择一个插件并触发 Ajax 请求,这会重建表单。
-
当表单重建时,我们会检查所选插件,并修改
plugin_configuration元素以反映所选插件。 -
Ajax 响应会用新的、可能已更改的元素替换旧的元素。
新的plugin_configuration元素变成了一个details元素(一个可折叠的容器,用于多个元素),默认打开,并且有一个名为plugin的键,我们将所有来自插件的元素添加到这个键上。此外,我们使用#tree属性来指示,当表单提交时,元素的值会被发送并存储在一个反映表单元素的树中(基本上是一个多维数组)。否则,提交的表单状态值会被扁平化,我们就会失去它们与plugin_configuration元素(这也是我们想要存储数据的导入器配置实体字段名称)的关联。
我们几乎完成了。我们可以创建一个导入器实体,并且当我们选择 JSON 导入器时,包含 URL 字段的新的字段集应该会显示在下面。但我们仍然有一个问题。如果我们保存表单,URL 值将存储在plugin_configuration字段中,以plugin为键的数组内。因此,我们需要稍微整理一下,我们可以在save()方法中这样做。
在保存实体之前,我们可以这样做:
$importer->set('plugin_configuration', $importer->getPluginConfiguration()['plugin']);
因此,我们基本上将值向上移动一个数组,从数组中移除多余的plugin级别(这只是为了整齐地组织表单树)。
这样,我们就完成了。嗯,实际上还没有,因为我们仍然需要处理配置模式方面。是的,还记得第六章数据建模和存储和第七章自定义实体和插件类型中的那些吗?我们现在将看到我们如何处理自己的动态配置模式,类似于我们在第九章自定义字段中处理字段插件所需的方式。但为什么我们需要一个动态配置模式呢?
在这次重构之前,我们知道导入器配置实体的确切字段,并且可以轻松地为每个字段声明模式(就像我们做的那样)。然而,现在插件可以带有它们自己的单个字段,因此我们需要确保它们可以为相应数据提供自己的模式定义。那么我们如何做到这一点呢?
首先,在我们的importer.schema.yml文件中,我们需要移除url字段模式定义,因为它已经不存在了。然而,我们用我们创建的新字段替换它,即来自插件的plugin_configuration值数组:
plugin_configuration:
type: products.importer.plugin.[%parent.plugin]
这里事情变得有趣。我们不知道里面会有哪些字段,所以我们引用了另一个类型(我们自己的)。此外,类型的名称是动态的。我们有一个前缀(products.importer.plugin.)后面跟着由父插件字段(主要配置实体)的值给出的变量名。所以基本上,如果一个给定的配置实体使用了json插件,模式定义的类型将是products.importer.plugin.json。因此,现在,创建新插件的人也有责任为其自己的字段提供自己的模式定义(就像我们在第九章,自定义字段)中定义字段插件时做的那样)。
但在那之前,我们需要定义我们创建的新类型:
products.importer.plugin.*:
type: mapping
label: 'Plugin configuration'
所以,本质上,我们的新类型扩展自mapping并有一个简单的标签。当然,它适用于所有以该名称开头的东西(这就是我们之前遇到的通配符的原因)。
现在,我们可以为我们的单个json导入插件添加模式定义:
products.importer.plugin.json:
type: mapping
label: Plugin configuration for the Json importer plugin
mapping:
url:
type: uri
label: Uri
如您所见,我们现在有了products.importer.plugin类型的第一个实例,它包含url字段,位于配置实体的plugin_configuration字段中——反映了一个简单的数组层次结构。
但这个动态声明的目的是,其他定义新插件的模块现在也可以定义自己的products.importer.plugin.*模式定义的实例来映射它们自己的字段。不再是由配置实体(模式)来“猜测”每个插件正在使用哪些字段类型。
这样,我们的重构就完成了。Drupal 清楚地知道配置实体正在保存的数据类型,即使它部分与外部输入(选定的插件)相关。这意味着我们可以创建(如果我们想的话)另一个使用 CSV 文件的产品数据的导入插件。但我们将如何在后面的章节中讨论文件处理时看到如何做。
状态(表单)系统
在本章中,我们将最后探讨 Form API 的状态系统(不要与我们在第六章,数据建模和存储)混淆)。这允许我们根据用户与表单的交互动态地定义我们的表单元素。它不使用 Ajax,而是依赖于 JavaScript 来处理操作。这是另一个客户端行为的绝佳例子,我们不需要写一行 JavaScript。所以,让我们看看这是什么。
#states是我们可以添加到表单元素中的简单属性,它们根据其他元素的状态来改变它们。理解这个概念最好的方式是通过一些例子。想象这两个表单元素:
$form['kids'] = [
'#type' => 'checkbox',
'#title' => $this->t('Do you have kids?'),
];
$form['kid_number'] = [
'#type' => 'textfield',
'#title' => $this->t('How many kids do you have?'),
];
在第一种情况下,我们询问用户是否有孩子(使用简单的复选框),而在第二种情况下,我们询问他们有多少孩子。但为什么如果用户没有孩子,他们实际上应该看到第二个元素呢?这就是#states属性发挥作用的地方,它的作用是根据另一个元素的状态来操纵一个元素。所以,我们可以这样:
$form['kid_number'] = [
'#type' => 'textfield',
'#title' => $this->t('How many kids do you have?'),
'#states' => [
'visible' => [
'input[name="kids"]' => ['checked' => TRUE],
],
],
];
现在,指定孩子数量的元素只有在kid元素的状态被选中时才会可见。
#states属性是一个数组,其键是如果条件内部满足,需要应用到当前元素的实际情况。条件可以变化,但它们都依赖于一个 CSS 选择器(在我们的例子中是input[name="kids"]匹配另一个元素)。
我们的例子也可以用这种逆向逻辑来写:
'#states' => array(
'invisible' => array(
'input[name="kids"]' => array('checked' => FALSE),
),
),
除了visible和invisible之外,以下状态也可以应用到表单元素上:enabled、disabled、required、optional、checked、unchecked、expanded和collapsed。至于可以“触发”这些状态的条件,我们可以有以下几种(除了我们之前看到的checked):empty、filled、unchecked、expanded、collapsed和value。
例如,我们甚至可以根据用户在另一个元素上选择的值来控制一个元素的状态。结合这些可能性可以极大地改善我们的表单,在用户体验、整理甚至构建逻辑表单树方面。
摘要
在本章中,我们关注了客户端,并讨论了 Drupal 8 中的 JavaScript 和客户端功能。我们首先讨论了在 Drupal 环境中编写 JavaScript 时需要采取的方法。我们学习了行为、为什么它们很重要以及如何使用它们。我们还看到了如何从服务器(Drupal)传递数据到客户端,并在 JavaScript 中使用它。
很有趣的是,我们随后将本章的其余部分改为不允许使用 JavaScript 的策略。我们这样做是为了证明 Drupal Ajax API 的强大功能,即使我们不是能够编写 JavaScript 代码的前端开发者,我们也能使用它来执行复杂的客户端到服务器的交互。为了展示这个 API,我们首先看了如何将简单的链接转换为 Ajax 请求。接着,我们对之前的产品导入功能进行了重要的重构,该功能依赖于 Ajax 来使导入配置实体表单动态化(依赖于所选的插件)。别忘了另一个信息亮点——动态配置模式,它允许我们将配置实体数据定义与其所选插件的数据定义解耦。
最后,我们通过查看表单 API 的 States 系统来结束讨论,该系统允许我们将客户端操作声明性地编码到我们的表单元素上,本质上使它们依赖于用户的表单交互。
在下一章中,我们将讨论国际化与翻译,以确保我们的应用程序可以在全球任何地方使用。
第十三章:国际化和语言
尽管在各方面都取得了巨大进步,但与前辈相比,Drupal 8 有几项几乎是革命性的发展。其中最值得注意的是配置 API 和缓存系统,它们在 Drupal 7 中已经遥遥领先。另一个是旨在使 Drupal 完全多语言化的多语言倡议,而不是需要使用 20 个贡献模块才能达到类似的效果。这还包括国际化(i18n:www.w3.org/standards/webdesign/i18n)方面,它允许网站翻译成任何已安装的语言。
在本章中,我们将从模块开发者的角度讨论 Drupal 8 中的国际化和多语言功能。该系统的许多内置功能都是针对网站构建者的——启用语言、翻译内容和配置实体,以及 Drupal 界面(适用于管理员和访客)。我们的重点将是我们作为模块开发者需要做什么来确保网站构建者和编辑者可以使用上述功能。为此,本章将更多地作为参考指南,包含各种提示、技巧,甚至我们在编写代码时需要遵循的规则。尽管如此,我们也会简要谈谈我们如何以编程方式与语言一起工作。
然而,首先,我们将从开箱即用的多语言生态系统及其负责各个部分的模块的介绍开始。
多语言生态系统的介绍
多语言和国际化系统基于四个 Drupal 核心模块。让我们快速浏览一下它们,看看它们的作用:
-
语言
-
内容翻译
-
配置翻译
-
界面翻译
语言
语言模块负责处理网站上的可用语言。网站构建者可以从广泛的选项中选择安装一种或多种语言。如果需要,他们甚至可以创建自己的自定义语言。安装的语言可以添加到实体和菜单链接等项目中,以便根据当前语言控制它们的可见性。除了已安装的语言外,Drupal 8 还附带两种额外的特殊语言:未指定和不适用。
该模块还根据各种标准处理上下文语言选择,并提供语言切换器以更改网站的当前语言:

内容翻译
内容翻译模块负责实现用户翻译内容的功能。内容实体是内容的主要载体,并且通过此模块,数据可以在字段级别进行翻译(并对其进行细粒度配置)。换句话说,用户可以控制哪些字段和哪些实体类型包应该可翻译:

配置翻译
配置翻译模块负责通过接口提供用户翻译配置值的功能。这些可以是简单的配置对象或配置实体。我们已经在之前的章节中看到如何确保我们的配置值可以被翻译,所以在这里我们不再深入探讨。
我建议您参考第六章中关于配置架构的部分,数据建模和存储:

接口翻译
接口翻译模块负责提供一个界面,允许用户将网站上安装的所有语言中的任何字符串或文本输出进行翻译。此外,它提供了一个连接到localize.drupal.org平台,从中可以下载许多常见接口字符串的翻译:

这四个模块并不是在多语言系统中独立存在的,而是依赖于一个跨应用标准,以确保所有编写的代码都能很好地与之协同工作。换句话说,整个 Drupal 代码库在多个层面上与国际化系统交织在一起,并且以这种方式编写,即任何应该可翻译或可本地化的内容都可以。这意味着我们编写的所有代码都需要遵守相同的标准。
国际化
国际化的理念是确保网站上输出的所有内容都可以通过一个共同的机制翻译成已启用的语言——在这种情况下,使用接口翻译模块。这指的是内容、可见的配置值以及来自模块和主题的字符串和文本。但是,这可以通过许多不同的方式实现,所以让我们看看在每个这些情况下我们如何确保我们的信息可以被翻译。
在编写 Drupal 模块或主题时,一个主要的规则是始终使用英语作为代码语言。这是为了确保一致性,并保持其他开发者可能不会说特定语言的情况下,其他开发者可以在此代码库上工作的可能性。这也适用于用于在 UI 中显示的文本。代码输出翻译文本的责任不应落在代码上,而应始终保持一致性,即使用英语。
当然,这取决于正确执行,以便允许通过界面翻译进行翻译。根据具体情况,有多种方式可以确保这一点。
我们需要特别注意的最常见情况是我们必须向用户打印出一段 PHP 文本字符串。Drupal 7 开发者应该已经通过 t() 函数熟悉了这些字符串的处理方式。这个函数仍然存在,并且在我们不在类上下文内部时应该使用它:
return t('The quick brown fox');
然而,当我们处于类内部时,我们应该检查是否有任何父类使用了 StringTranslationTrait。如果没有,我们应该在我们的类中使用它,然后我们就能这样做:
return $this->t('The quick brown fox');
更好的是,我们应该将 TranslationManager 服务注入到我们的类中,因为上述特性正是使用了它。
之前给出的所有示例对我们来说都不应该陌生,因为我们一直在本书编写的代码中使用这些示例。但是,幕后实际上发生了什么?
t() 和 StringTranslationTrait::t() 函数都会创建并返回一个 TranslatableMarkup 实例(本质上是将职责委托给其构造函数),在渲染(被转换为字符串)时,将返回格式化和翻译后的字符串。实际的翻译责任被委托给 TranslationManager 服务。这个过程分为两个部分。静态分析器会检测这些文本字符串并将它们添加到数据库中,作为需要本地化的字符串列表。然后,用户可以通过用户界面进行翻译。其次,在运行时,根据当前的语言上下文,字符串会被格式化,并显示翻译后的版本。由于第一部分,我们永远不应该这样做:
return $this->t($my_text);
原因在于静态分析器无法再检测到需要翻译的字符串。此外,如果文本来自用户输入,在输出到用户之前如果没有得到适当的清理,可能会导致 XSS 攻击。
话虽如此,我们仍然可以使用这种方法来输出动态的、即格式化的文本,我们已经在实际操作中看到了这一点:
$count = 5;
return $this->t('The quick brown fox jumped @count times', ['@count' => $count]);
在这种情况下,我们有一个动态变量,它将被用来替换文本中的 @count 占位符。Drupal 会负责在输出字符串到用户之前清理这个变量。或者,我们也可以使用 % 前缀来定义一个 Drupal 应该用 <em class="placeholder"> 包裹的占位符。酷的地方在于,在进行翻译时,用户可以根据语言特定性调整句子中的占位符。
静态分析器挑选并存储需要翻译的字符串的一个预期后果是,默认情况下,每个单独的字符串只翻译一次。这在许多情况下是好的,但也可能在同一英语字符串具有不同含义(映射到其他语言中的不同翻译)时带来一些问题。为了解决这个问题,我们可以为需要翻译的字符串指定一个上下文,以便我们可以识别我们实际上想要翻译的含义。这就是我们在前几段中看到的t()函数(和方法)的第三个参数发挥作用的地方。
例如,让我们考虑单词Book,它默认以其名词的含义进行翻译。但我们在表单上可能有一个值为 Book 的提交按钮,这显然有不同作为行动呼吁的含义。所以在后一种情况下,我们可以这样做:
t('Book', [], ['context' => 'The verb "to book"']);
现在在界面翻译中,我们将有这两个版本可供选择:

另一个有用的提示是,我们还可以在字符串翻译中考虑复数。StringTranslationTrait::formatPlural()方法通过创建一个类似于TranslatableMarkup的PluralTranslatableMarkup对象来帮助解决这个问题,但它有一些额外的参数来处理复数时的差异。这在我们的前一个例子中非常有用,关于棕色狐狸跳了几次,因为如果狐狸只跳了一次,结果字符串将不再语法正确。所以,我们可以这样做:
$count = 5;
return $this->formatPlural($count, 'The quick brown fox jumped 1 time', 'The quick brown fox jumped @count times')];
第一个参数是实际计数(区分单数和复数的区别)。第二个和第三个参数分别是单数和复数形式。你也会注意到,由于我们已经指定了计数,所以我们不需要在参数数组中再次指定它。需要注意的是,如果想让渲染器理解其目的,字符串内部的占位符名称需要是@count。
我们之前讨论的字符串翻译技术也适用于其他地方——不仅仅是 PHP 代码。例如,在 JavaScript 中,我们会这样做:
Drupal.t('The quick brown fox jumped @count times', {'@count': 5});
Drupal.formatPlural(5, 'The quick brown fox jumped 1 time', 'The quick brown fox jumped @count times');
因此,基于这些知识,我鼓励你回去修复上一章中 JavaScript 字符串输出的错误使用。
在 Twig 中,我们会有类似这样的内容(用于简单翻译):
{{ 'Hello World.'|trans }}
{{ 'Hello World.'|t }}
上述两行做的是同样的事情。为了处理复数(和占位符),我们可以使用{% trans %}块:
{% set count = 5 %}
{% trans %}
The quick brown fox jumped 1 time.
{% plural count %}
The quick brown fox jumped {{ count }} times.
{% endtrans %}
最后,字符串上下文也是可能的,如下所示:
{% trans with {'context': 'The verb "to book"'} %}
Book
{% endtrans %}
在注解中,我们有@Translation()包装器,就像我们在创建插件或定义实体类型时已经看到几次那样。
最后,在 YAML 文件中,一些字符串默认可翻译(因此我们不需要做任何事情):
-
在
.info.yml文件中的模块名称和描述。 -
在
.routing.yml文件的默认部分下,_title(以及可选的_title_context)键值。 -
.links.action.yml、.links.task.yml和.links.contextual.yml文件中title(连同可选的title_context)键值
日期在本地化方面也可能存在潜在问题,因为不同的地区显示日期的方式不同。幸运的是,Drupal 提供了 DateFormatter 服务,它为我们处理这个问题。例如:
\Drupal::service('date.formatter')->format(time(), 'medium');
此格式化器的第一个参数是我们想要格式化的日期的 UNIX 时间戳。第二个参数表示要使用的格式(现有格式之一或 custom)。Drupal 自带一些预定义的日期格式,但网站构建者也可以定义其他格式,这些格式也可以在这里使用。然而,如果格式是自定义的,第三个参数是一个适合输入到 date() 的 PHP 日期格式字符串。第四个参数是我们想要格式化日期的时间区域标识符,最后一个参数可以用来直接指定要本地化的语言(无论网站的当前语言是什么)。
内容实体和翻译 API
到目前为止,在本章中,我们主要讨论了如何确保我们的模块只输出可以翻译的文本。Drupal 的最佳实践是无论网站是否是多语言的,都要始终使用这些技术。你永远不知道你是否需要添加新语言。
在本节中,我们将简要讨论如何以编程方式与语言系统交互并处理实体翻译。
你经常会想要做的一件重要的事情是检查网站的当前语言。根据现有的语言协商,这可以由浏览器语言、域名、URL 前缀或其他因素确定。LanguageManager 是我们用来确定这个的服务。我们可以使用 language_manager 键注入它或通过静态简写使用它:
$manager = \Drupal::languageManager();
要获取当前语言,我们这样做:
$language = $manager->getCurrentLanguage();
其中 $language 是 Language 类的一个实例,它包含有关给定语言的一些信息(例如语言代码和名称)。语言代码可能是最重要的,因为它在各个地方都用来指示某个事物的语言。
此服务还有其他有用的方法可供使用。例如,我们可以使用 getLanguages() 获取所有已安装的语言列表,或使用 getDefaultLanguage() 获取网站默认语言。我鼓励您查看 LanguageManager 以获取所有可用的 API 方法。
当涉及到内容实体时,我们可以使用一个 API 来以不同的语言与之交互其内部的数据。例如,我们已通过之前的方法确定了当前语言,因此现在我们可以获取该语言下的一些字段值。这种方式的工作原理是,我们请求相应语言的实体副本:
$translation = $node->getTranslation($language->getId());
$translation现在几乎与$node相同,但默认语言设置为请求的语言。从那里,我们可以正常访问字段值。然而,并非所有节点都必须有翻译,因此最好首先检查是否存在:
if ($node->hasTranslation($language->getId())) {
$translation = $node->getTranslation($language->getId());
}
由于我们可以在字段级别配置实体的可翻译性(只允许翻译有意义的字段),我们还可以检查哪些字段可以具有翻译值:
$fields = $node->getTranslatableFields();
最后,我们还可以检查哪些语言有翻译:
$languages = $node->getTranslationLanguages();
由于添加翻译的责任在于编辑者,所以我们无法在代码中保证其存在。
在程序上,我们也可以非常容易地创建一个实体的翻译。例如,让我们想象我们想要翻译一个节点实体,并指定其标题为法语:
$node->addTranslation('fr', ['title' => 'The title fr']);
第二个参数是一个数组,需要将其映射到实体字段,就像创建新实体时一样。现在相应的节点具有原始语言(比如说 EN),但也有一份法语翻译。应注意的是,除了标题之外的所有其他字段的值,即使在法语翻译中,也保持原始语言,因为我们没有在创建翻译时传递任何翻译值。
正如我们添加翻译一样,我们也可以删除一个:
$node->removeTranslation('fr');
如果我们想要持久化添加或删除翻译,我们需要像往常一样保存实体。否则,它只存储在内存中。并且从 Drupal 8.3 开始,内容实体实现了Drupal\Core\TypedData\TranslationStatusInterface,这允许我们检查翻译的状态。例如,我们可以这样做:
$status = $node->getTranslationStatus('fr');
其中$status是TranslationStatusInterface类中三个常量之一的值:
-
TRANSLATION_REMOVED -
TRANSLATION_EXISTING -
TRANSLATION_CREATED
摘要
在这一简短的章节中,我们从模块开发者的角度讨论了 Drupal 8 的多语言和国际化系统。我们从一个介绍开始,介绍了负责语言和内容翻译、配置实体以及界面文本的四个主要模块。
然后,我们关注了我们需要遵守的规则和技术,以确保我们的输出文本可以被翻译。我们看到了如何在 PHP 代码、Twig 和 YAML 文件中以及 JavaScript 中实现这一点,最后我们还简要地看了看语言管理器和翻译 API,看看我们如何与已翻译的内容实体一起工作。
本章的主要收获应该是,即使在我们的网站只使用一种语言的情况下,语言在 Drupal 8 中也很重要。因此,在开发模块时,尤其是如果我们想将其贡献给社区,我们需要确保我们的功能可以根据需要被翻译。
在下一章中,我们将讨论使用批处理和队列进行数据处理,以及随 Drupal 一起提供的 cron 系统。
第十四章:批处理、队列和 Cron
如果在前一章中我们保持了一些理论性,让我向你抛出“规则”,那么在这一章中,我将弥补这一点,我们将有一些乐趣。这意味着我们将编写一些代码来展示与数据处理相关的概念,特别是大量数据处理。在这个过程中,我们将涵盖几个主题。
首先,我们将回顾在第八章中看到的hook_update_N()钩子,数据库 API。更具体地说,我们将看看如何使用&$sandbox参数来处理需要处理一些可能需要较长时间且应跨多个请求分批进行的数据更新。接下来,我们将查看独立批处理(基本上使用相同的系统)以跨多个请求批量处理数据。那么,用我们的需要处理未定义数量产品的导入器来展示这个技术不是更好吗?
我们将查看一个相关的子系统,它允许我们为后续处理(无论是批量、cron 还是简单请求)排队事物。由于我们正在谈论 cron,我们也将深入探讨这个系统在 Drupal 中的工作方式。最后,我们将通过查看 Drupal 8 中的 Lock API 来结束本章,这是一个允许我们确保多个请求不会同时运行一个进程的 API。
到本章结束时,你将变成一个精简、高效的数据处理机器。所以,让我们开始吧。
批量驱动的更新钩子
我们将要首先查看的是更新钩子,回顾我们在第八章中创建的之前的 Sports 模块,数据库 API。我们将关注当时我们没有使用的&$sandbox参数。目标是运行对players表中的每个记录的更新,并将它们标记为退役。目的是说明我们如何逐个处理这些记录,以单个请求的形式来防止 PHP 超时。如果我们有很多记录,这会很有用。
为了让我们开始,这里有一切代码,我们将在之后看到每一部分的意义:
/**
* Update all the players to mark them as retired.
*/
function sports_update_8002(&$sandbox) {
$database = \Drupal::database();
if (empty($sandbox)) {
$results = $database->query("SELECT id FROM {players}")->fetchAllAssoc('id');
$sandbox['progress'] = 0;
$sandbox['ids'] = array_keys($results);
$sandbox['max'] = count($results);
}
$id = $sandbox['ids'] ? array_shift($sandbox['ids']) : NULL;
$player = $database->query("SELECT * FROM {players} WHERE id = :id", [':id' => $id])->fetch();
$data = $player->data ? unserialize($player->data) : [];
$data['retired'] = TRUE;
$database->update('players')
->fields(['data' => serialize($data)])
->condition('id', $id)
->execute();
$sandbox['progress']++;
$sandbox['#finished'] = $sandbox['progress'] / $sandbox['max'];
}
如果你还记得,函数名包含模块的新架构版本,这个版本将在运行时设置。有关更多信息,请参阅第八章,数据库 API。
当这个钩子被触发时,$sandbox参数(通过引用传递)是空的。它的目标是作为在函数内部处理所有内容所需的请求之间的临时存储。我们可以用它来存储任意数据,但我们应该注意其大小,因为它必须适合LONGBLOB表列。
我们首先要做的是获取数据库服务以对players表进行查询。但更重要的是,我们正在检查$sandbox变量是否为空,这表明这是过程的开始。如果是的话,我们会添加一些特定于我们过程的数据。在这种情况下,我们想要存储进度(这很常见),需要更新的玩家的 ID,以及记录总数(也很常见)。为了做到这一点,我们执行一个简单的查询。
一旦设置了 sandbox,我们就可以获取列表中的第一个 ID,同时移除它,这样,迭代地,我们处理的记录就越来越少。基于这个 ID,我们加载相关的玩家,将我们的数据添加到其中,并在数据库中更新它。一旦完成,我们就将进度增加 1(因为我们处理了一条记录)。最后,sandbox 中的#finished键是 Drupal 用来确定过程是否完成的关键。它期望一个介于 0 和 1 之间的整数,后者表示我们已经完成。如果发现任何小于 1 的值,函数将被再次调用,$sandbox数组将包含我们离开时的数据(增加的进度和少一个待处理的 ID)。在这种情况下,函数的主体将再次运行,处理下一条记录,依此类推,直到进度除以最大记录数等于 1。如果我们有 100 条记录,当进度达到 100 时,以下是真的:100 / 100 = 1。然后,Drupal 知道完成过程,不再调用该函数。
这个过程在 Drupal 术语中也被称作批处理,非常有用,因为 Drupal 会根据需要发出尽可能多的请求来完成它。我们可以控制每个请求需要完成的工作量。前一个例子可能有点过度,因为一个请求完全能够处理多个玩家。我们实际上在浪费时间,因为像这样,Drupal 需要为每个请求重新启动自己。所以,这取决于我们找到那个甜蜜点。在我们之前的例子中,我们本可以将 ID 数组分成大约五块的块,并允许一个请求处理五条记录而不是一条。这肯定会提高速度,但我鼓励你在理解了使用$sandbox进行批处理的原则后,现在就自己尝试一下。
批量操作
现在我们已经对 Drupal 的多请求处理能力有了基本的了解,让我们转换一下,看看 Batch API。
为了演示这是如何工作的,我们将重建我们的产品JsonImporter插件处理它检索到的产品数据的方式。目前,我们只是将所有产品加载到一个对象数组中,然后遍历每个对象,将它们保存到数据库中。所以,如果 JSON 响应中有 100,000 个产品,我们可能会遇到麻烦。公平地说,如果远程提供者有这么多产品,它通常会提供一个分页的方式通过传递偏移量和限制来请求它们。这保持了负载更小(这对通信服务器双方都有好处),并且使处理更容易。在我们的这一边,我们可以将其视为我们处理数据库的方式。但就目前而言,我们将假设返回的产品数量很大,但不是大到足以引起通信问题或 PHP 存储在内存中的能力问题。
此外,在演示批次 API 的同时,我们还将执行在第七章“您自己的自定义实体和插件类型”中“忘记”的操作。在导入过程中,我们还想删除任何之前已导入但不再在 JSON 响应中的产品。如果你愿意,这是一种两种数据源之间的同步。所以,让我们开始吧。
创建批次
在JsonImporter::import()方法内部,一旦我们获得了$products数组,让我们用以下代码替换循环:
$batch_builder = (new BatchBuilder())
->setTitle($this->t('Importing products'))
->setFinishCallback([$this, 'importProductsFinished']);
$batch_builder->addOperation([$this, 'clearMissing'], [$products]);
$batch_builder->addOperation([$this, 'importProducts'], [$products]);
batch_set($batch_builder->toArray());
以及顶部的新use语句:
use Drupal\Core\Batch\BatchBuilder;
创建批次涉及多个步骤,第一步是创建一个批次定义,这只是一个包含一些数据的数组。在 Drupal 8.6 版本之前,批次定义是通过实际定义一个数组来创建的。现在我们使用一个专门的批次构建器对象,但最终结果是一样的。
批次可以有一个标题,用于设置进度页面上使用的标题。同样,它还可以有一个可选的初始化、进度和错误消息,这些可以通过相应的方法设置,但同时也提供了合理的默认值。有关您可以使用它们做什么以及您还有哪些其他选项的更多信息,请确保查看BatchBuilder类和batch_set全局函数。
批次定义最重要的部分是操作列表,其中我们指定在批次中需要发生什么。这些定义为任何有效的 PHP 回调函数和一个传递给这些回调函数的参数数组。如果后者位于尚未加载的文件中,可以使用setFile()方法指定文件路径以包含。每个操作都在其自己的 PHP 请求上运行,按照它们定义的顺序。此外,每个操作还可以跨多个请求运行,类似于我们之前编写的更新钩子。
我们的第一个操作将负责从 Drupal 中删除在 JSON 响应中不再存在的产品,而后者将执行导入。这两个操作都只接收一个参数——产品数组。
定义数组中的finished键(使用setFinishCallback()方法设置)是另一个在批处理结束时触发的回调,在所有操作完成后。
最后,我们调用全局的batch_set()方法,它静态地设置批处理定义并标记它为准备好运行。触发批处理只需再走一步,那就是调用batch_process()。但我们没有使用它的原因是,如果导入作为表单提交的一部分运行,表单 API 会自动触发它。所以如果我们在这里也触发它,它将不起作用。表单 API 为我们这样做的原因是,大多数情况下我们希望批处理只在采取行动的结果下运行。通常,这是通过表单完成的。然而,另一个主要可能性是通过 Drush 命令触发批处理(我们实际上可以这样做)。在这种情况下,我们需要使用drush_backend_batch_process()函数。
因此,我们首先会检查我们是否处于命令行环境(即 Drush),并且只在那种情况下触发它:
if (PHP_SAPI == 'cli') {
drush_backend_batch_process();
}
否则,我们将其留给表单 API。通过这样做,我们可以从表单提交处理程序和通过 Drush 触发导入,并且我们可以拥有不必要使用批处理的插件。
批处理操作
现在我们已经设置了批处理定义,但我们缺少在定义中引用的那三个回调方法。所以,让我们看看第一个:
/**
* Batch operation to remove the products which are no longer in the list of
* products coming from the JSON file.
*
* @param $products
* @param $context
*/
public function clearMissing($products, &$context) {
if (!isset($context['results']['cleared'])) {
$context['results']['cleared'] = [];
}
if (!$products) {
return;
}
$ids = [];
foreach ($products as $product) {
$ids[] = $product->id;
}
$ids = $this->entityTypeManager->getStorage('product')->getQuery()
->condition('remote_id', $ids, 'NOT IN')
->execute();
if (!$ids) {
$context['results']['cleared'] = [];
return;
}
$entities = $this->entityTypeManager->getStorage('product')->loadMultiple($ids);
/** @var \Drupal\products\Entity\ProductInterface $entity */
foreach ($entities as $entity) {
$context['results']['cleared'][] = $entity->getName();
}
$context['message'] = $this->t('Removing @count products', ['@count' => count($entities)]);
$this->entityTypeManager->getStorage('product')->delete($entities);
}
这是批处理过程中的第一个操作。作为一个参数,它接收我们在批处理定义中定义的所有变量(在我们的情况下,是产品数组)。但它还通过引用接收一个$context数组变量,我们可以像在更新钩子中使用$sandbox一样使用它(具有一些额外的功能)。
当前任务相当简单。我们准备一个包含所有产品 ID 的列表,基于这些 ID,我们查询我们的产品实体以获取不在该列表中的那些。如果找到任何,我们将删除它们。你可能会注意到,在这个操作中,我们并不依赖于 Drupal 批处理 API 的实际多请求功能,因为我们预计工作负载将是最低的。毕竟,在任何给定时间可能会有多少产品缺失并需要被删除?我们将假设在我们的用例中不会很多。
但在我们做所有这些的同时,我们也在某种程度上与批处理进行交互。你会注意到$context数组有一个results键。这个键用于存储与批处理中每个操作结果相关的信息。我们不应该用它来管理进度,而是要跟踪已经完成的工作,以便在最后,我们可以向用户提供一些有用的信息,说明发生了什么。所以,在我们的例子中,我们创建了一个以cleared为键的数组(为了为这个特定的操作命名空间数据),并向其中添加了已删除的每个产品的名称。
此外,我们还有一个message键,我们用它来在动作发生时打印消息。这些消息会实时打印出来,以向用户指示当前正在处理的内容。如果通过 UI 表单运行批处理,由于处理速度的原因,你可能会看不到所有消息。然而,如果由 Drush 触发(正如我们的情况),每个这些消息都会打印到终端屏幕上。
这样,我们的第一个操作就完成了。现在是时候看看第二个,更复杂的操作了:
/**
* Batch operation to import the products from the JSON file.
*
* @param $products
* @param $context
*/
public function importProducts($products, &$context) {
if (!isset($context['results']['imported'])) {
$context['results']['imported'] = [];
}
if (!$products) {
return;
}
$sandbox = &$context['sandbox'];
if (!$sandbox) {
$sandbox['progress'] = 0;
$sandbox['max'] = count($products);
$sandbox['products'] = $products;
}
$slice = array_splice($sandbox['products'], 0, 3);
foreach ($slice as $product) {
$context['message'] = $this->t('Importing product @name', ['@name' => $product->name]);
$this->persistProduct($product);
$context['results']['imported'][] = $product->name;
$sandbox['progress']++;
}
$context['finished'] = $sandbox['progress'] / $sandbox['max'];
}
它接收的参数与我们之前的操作完全相同,因为我们以相同的方式定义了它们。
在这里,我们再次确保我们有了一些产品,并启动了我们的results数组,这次是为了跟踪导入的记录。但这次我们也与$context数组的sandbox键一起工作,以便使用多请求处理功能。方法与我们在更新钩子中做的是相似的——我们保持一个进度计数,存储产品数量的最大值,然后根据这两个值计算$context['finished']键。然而,在这种情况下,我们选择一次处理三个产品。同样,就像我们之前的操作一样,我们使用message键来通知用户正在发生的事情,并使用results键来编译已导入的产品列表。
在继续之前,让我们谈谈我们导入产品的方式。如果 JSON 资源能够返回分页结果,我们就需要改变我们的方法。首先,我们无法以相同的方式删除缺失的产品。相反,我们必须跟踪导入产品的 ID,然后才能删除缺失的产品。因此,两个操作的顺序将会颠倒。其次,产品的检索将是在importProducts操作内部完成的,使用存储在沙盒中的偏移量和限制。因此,每个 Drupal 批处理请求都会向 JSON 资源发出新的请求。当然,我们必须跟踪所有已处理的产品,以便我们知道哪些是可以被删除的。
最后,让我们看看批处理完成时使用的回调函数:
/**
* Callback for when the batch processing completes.
*
* @param $success
* @param $results
* @param $operations
*/
public function importProductsFinished($success, $results, $operations) {
if (!$success) {
drupal_set_message($this->t('There was a problem with the batch'), 'error');
return;
}
$cleared = count($results['cleared']);
if ($cleared == 0) {
drupal_set_message($this->t('No products had to be deleted.'));
}
else {
drupal_set_message($this->formatPlural($cleared, '1 product had to be deleted.', '@count products had to be deleted.'));
}
$imported = count($results['imported']);
if ($imported == 0) {
drupal_set_message($this->t('No products found to be imported.'));
}
else {
drupal_set_message($this->formatPlural($imported, '1 product imported.', '@count products imported.'));
}
}
此回调接收三个参数:一个布尔值,指示处理是否成功,我们用于在 $context 中跟踪已完成工作的结果数组,以及操作数组。我们实际做的事情实际上非常简单。我们首先打印一个通用的消息,如果批量处理失败。在这种情况下,我们也提前返回。否则,我们使用 $results 数组打印有关我们已执行的操作的相关消息。注意使用你在上一章中学到的 t() 和 formatPlural() 方法。更重要的是,注意使用全局的 drupal_set_message() 来打印消息。正如我们已经学到的,这种方法现在已被弃用,你应该改为注入 Messenger 服务(在父类中最为有益)。我省略了这部分以节省空间并保持内容集中。
我们重构的 JSON 导入器现在使用批量处理来使过程更加稳定,以防需要处理的记录数量太大。在我们尝试之前,我们需要做最后一步,那就是在 ImporterBase 插件类中使用 DependencySerializationTrait:
use DependencySerializationTrait;
原因是当批量运行时,Drupal 存储有关运行该对象的一些信息。为了做到这一点,它需要对其进行序列化。然而,由于它具有如 EntityTypeManager 这样的依赖项,Drupal 需要一种在序列化过程中处理这些依赖项的方法。这个特性有助于解决这个问题。此外,我们可以在基类中使用它,这样所有插件类都可以轻松地使用批量处理,而无需担心这一步骤。
但现在如果我们运行我们在第七章第七章中编写的 Drush 命令来触发我们的导入器,我们会得到类似以下输出:

注意在导入每条记录时设置的消息,以及我们在处理结束时设置的消息,这提供了一种对发生情况的总结。
当调用 batch_process() 时,我们还可以传入一个 URL,以便在处理完成后进行重定向。然而,更好的方式是在 finished 回调中返回一个 RedirectResponse。不用说,如果我们从 Drush 触发批量操作,将不会有实际的重定向。然而,在表单上下文中它将正常工作。
Cron
在上一节中,我们创建了一个出色的多请求批量处理我们的 JSON 产品导入。在下一节中,我们将跳入 Queue API,看看我们如何在稍后阶段计划多个项目的处理。然而,在我们深入之前,让我们谈谈 Drupal 8 的 cron 的工作原理以及我们可以用它做什么。这是因为我们关于 Queue API 的讨论与它密切相关。
首先,Drupal 实际上并没有一个完整的 cron 系统。这是因为它是一个应用程序,不是一个能够安排在一天中指定时间间隔运行的任务的服务器。然而,它确实有一个类似 cron 的系统,这在繁忙的网站上可以非常接近。通常,它被亲切地称为穷人版的 cron。为什么?因为 Drupal 本身没有某种推动力就无法做任何事情,它依赖于访客访问网站来触发 cron 任务。所以,即使我们可以配置 Drupal cron 的频率,我们也依赖于访客访问网站并无意中触发它。Drupal 会跟踪 cron 运行的时间,并确保下一次运行是在配置的时间过去之后。所以本质上,如果 cron 被设置为每小时运行一次,但下一个访客在三个小时后到来,它将只在那时运行:

Drupal cron 对于维护任务和相对较小的任务非常有用,这些任务不会从网站访客那里消耗太多资源。它可以手动从 UI、外部脚本或通过以下命令使用 Drush 触发:
drush cron
有许多 Drupal 核心和贡献模块依赖于这个系统来执行各种任务,作为模块开发者,我们也可以通过实现hook_cron()来做同样的事情。后者每次 cron 运行时都会被触发,所以基本上 Drupal 的 cron 是一系列对各个模块的函数调用。因此,我们必须避免在请求中加载过重的处理,否则请求可能会崩溃。但正如我们将在下一节中看到的,如果我们有这类工作要运行,我们可以做些事情来控制这一点。
首先,让我们看看一个示例实现,看看它是如何工作的。我们想要实现的是,每当 cron 运行时,我们删除teams表中所有不再被任何球员引用的记录(我们在第八章,数据库 API中创建的)。本质上,如果队伍没有球员,它们就需要被删除。因此,我们可以做些简单的事情,如下所示:
/**
* Implements hook_cron().
*/
function sports_cron() {
$database = \Drupal::database();
$result = $database->query("SELECT id FROM {teams} WHERE id NOT IN (SELECT team_id FROM {players} WHERE team_id IS NOT NULL)")->fetchAllAssoc('id');
if (!$result) {
return;
}
$ids = array_keys($result);
$database->delete('teams')
->condition('id', $ids, 'IN')
->execute();
}
我们正在实现hook_cron(),在内部,我们基本上确定哪些队伍没有球员,并将它们删除。你会注意到,执行前者的查询实际上更复杂,因为我们使用了子查询,但这还不是火箭科学。请随意查看第八章,数据库 API,以复习 Drupal 8 数据库 API。
此函数将在我们的 Drupal cron 每次运行时被触发,我们可以争论说执行此任务对我们的资源并没有太大的压力。然而,在下一节中,我们将看到如何处理类似的情况。此外,我们还将看到为什么这种方法可能甚至比这种方法更好,无论资源密集程度如何。
队列
现在是时候谈谈队列 API 了,它是如何工作的,以及它的主要组件是什么;基本上是理论。在我们深入代码示例之前,我们将这样做,我们所有人都非常喜欢这些示例。
队列 API 简介
队列 API 的主要目的是为我们提供一个将项目添加到队列中的方法,以便稍后处理。负责处理这些项目的有队列工作进程插件,这些插件可以通过 Drupal cron 自动调用,或者通过我们手动(程序化地)调用,或者通过 Drush 调用。我们将查看这三个示例。
在此 API 中,中心角色是QueueInterface的实现,这是我们实际放入项目的队列。Drupal 可以处理两种类型的队列:可靠和不可靠。第一种保留了项目处理的顺序(先进先出)并保证每个项目至少被处理一次。在本章中,我们将只关注这种类型的队列。但还有与不可靠队列一起工作的可能性,它们在保持项目顺序方面尽力而为,但不保证所有项目都会被处理。
默认情况下,当我们使用 Drupal 8 中的队列时,我们使用一个基于数据库表存储项目的可靠队列。这由DatabaseQueue实现表示。实际上,批处理 API 使用的是从 Drupal 自带默认队列扩展出来的队列类型。好吧,那么队列有什么作用呢?
队列有三个主要角色:
-
它创建项目(将东西添加到需要在某个时间点处理的列表中)。
-
它声明项目(在工作进程处理期间暂时保留它们)。
-
它会删除项目(一旦项目完成处理,就从队列中移除)。或者,如果另一个工作进程需要处理它们,或者出了点问题需要稍后恢复,它也可以释放它们。
我们很快就会看到一个实际示例,说明它是如何工作的。但首先,让我们看看队列是如何产生的。
QueueInterface实现是通过QueueFactory服务创建的,命名为queue。工厂将委托给另一个特定于创建的队列类型的工厂服务。默认情况下,这是QueueDatabaseFactory服务(命名为queue.database),它预期返回DatabaseQueue类的实例。后者使用的表简单地称为queue。
最后,对于我们这些模块开发者来说,队列 API 的核心是负责处理队列中单个项目的QueueWorker插件系统。这些插件可以以两种方式编写。最简单的方法是让它们由 cron 触发。在这种情况下,插件 ID 需要与它需要处理的项目队列的名称匹配。这样,我们就不必担心声明、释放或删除项目。cron 系统会为我们处理这些。然而,一个更灵活的方法是我们实际上自己执行这些操作。我们不依赖于 cron,而是在我们想要的时候自行处理项目。此外,这两种类型的队列工作员都可以通过 Drush 使用一个命令来注册,该命令触发具有给定名称的队列的处理。
基于 cron 的队列
在上一节中,我们编写了sports_cron()的实现,每次运行时都会查找不再有球员的球队并将它们从数据库中删除。然而,如果我们每小时运行一次 Drupal 的 cron 任务,即使我们相当确信球队不会频繁地失去所有球员,我们也会继续执行那个查询。此外,我们还基于一个简单的假设(我们尚未编写的功能)认为有一些代码负责从球队中移除球员。这实际上是一个检查该球队是否失去了所有球员的理想位置。因此,我们的想法是检查球队是否被留空,并将其添加到队列中以便稍后删除(无论 cron 何时运行)。
我们不会深入探讨特定于球员和球队管理的代码,而是专注于将需要删除的球队添加到队列的部分。
我们需要做的第一件事是获取QueueFactory服务:
/** @var \Drupal\Core\Queue\QueueFactory $queue_factory */
$queue_factory = \Drupal::service('queue');
然后,我们需要创建一个默认的QueueInterface(数据库)实例,其名称为我们的未来工作插件 ID:
/** @var \Drupal\Core\Queue\QueueInterface $queue */
$queue = $queue_factory->get('team_cleaner');
这显然是加载服务的静态方法,你应该尽可能地将它们注入。但如果不能这样做,也有以下简写方法,可以在一行中实现相同的效果:
$queue = \Drupal::queue('team_cleaner');
$queue是一个名为team_cleaner的DatabaseQueue实例。
我们需要做的下一件事是将项目添加到其中(假设我们已经识别出一个没有球员的球队):
$item = new \stdClass();
$item->id = $team_id;
$queue->createItem($item);
创建一个 PHP 对象来封装队列项的数据是标准做法。在里面,我们可以放置任何可以正确序列化的东西,仅此而已。现在我们可以转向我们的TeamCleaner工作插件,它自然位于我们模块的Plugin/QueueWorker命名空间中:
namespace Drupal\sports\Plugin\QueueWorker;
use Drupal\Core\Database\Connection;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Queue\QueueWorkerBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* A worker plugin that removes a team from the database. Normally used to clear
* teams that have run out of players.
*
* @QueueWorker(
* id = "team_cleaner",
* title = @Translation("Team Cleaner"),
* cron = {"time" = 10}
* )
*/
class TeamCleaner extends QueueWorkerBase implements ContainerFactoryPluginInterface {
/**
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* Constructs a TeamCleaner worker.
*
* @param array $configuration
* @param string $plugin_id
* @param mixed $plugin_definition
* @param \Drupal\Core\Database\Connection $database
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->database = $database;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('database')
);
}
/**
* {@inheritdoc}
*/
public function processItem($data) {
$id = isset($data->id) && $data->id ? $data->id : NULL;
if (!$id) {
throw new \Exception('Missing team ID');
return;
}
$this->database->delete('teams')
->condition('id', $id)
->execute();
}
}
由于我们已经习惯了,我们的插件扩展了其类型的基插件类以继承任何潜在的基本功能。在我们的例子中,这仅限于实现QueueWorkerInterface,它有一个方法名称很容易描述其职责:processItem($data)。同样,我们也不陌生于ContainerFactoryPluginInterface的实现,它允许我们将database服务注入到我们的插件中。我们使用它来删除队列中的队伍。
实际上,所有的动作都发生在processItem()方法中,我们只是查看$data对象并删除具有指定 ID 的队伍。如果出现问题,我们也会抛出一个简单的异常。我们将在稍后讨论队列处理中的异常。
然而,对于队列 API 来说,插件注解更有趣。除了标准的预期插件定义外,我们还遇到了以下内容:
cron = {"time" = 10}
这仅仅表明这个插件应该由 cron 系统使用。换句话说,当 Drupal 的 cron 运行时,它会加载所有工作插件定义,并且具有该信息的任何一个都会被处理。这里的关键是时间信息,我们将其设置为 10 秒。这实际上意味着当 cron 运行时,我们说的是:在 10 秒内尽可能多地处理队列项目;一旦时间限制到达,停止并继续处理剩余的 cron 任务。 这实际上非常强大,因为我们从 PHP 请求中分配了一段时间并专门用于我们的队列。这意味着我们不需要猜测为请求分配多少项目(就像我们在批处理中做的那样)。然而,这也意味着剩余的时间必须足够用于其他所有事情。因此,我们需要仔细调整。至于那些无法在 10 秒内处理完的队列项目,它们将在下一次 cron 运行时简单地被处理。
这种方法比我们之前的方法更好,因为我们自己实现了hook_cron(),因为我们不想总是检查队伍中的玩家,而是可以创建队列项目并在需要时推迟删除。
对于队列 API 来说,有一点更有趣的是插件注解。除了标准的预期插件定义外,我们还遇到了以下内容:
处理队列的程序化
现在我们有了我们的队列工作进程,它可以删除团队(据它所知,团队甚至不需要有任何玩家),如果我们不想使用 cron 选项,我们可以探索如何自己处理这个队列。如果我们想使用 Drush 命令来处理它,我们就不必自己编写。Drush 自带一个,它的工作方式如下:
drush queue-run team_cleaner
然而,我们可能想创建一个管理界面,某种形式的表单,允许用户触发队列处理。在这种情况下,我们可以这样做:
$queue = \Drupal::queue('team_cleaner');
/** @var \Drupal\Core\Queue\QueueWorkerInterface $queue_worker */
$queue_worker = \Drupal::service('plugin.manager.queue_worker')->createInstance('team_cleaner');
while($item = $queue->claimItem()) {
try {
$queue_worker->processItem($item->data);
$queue->deleteItem($item);
}
catch (SuspendQueueException $e) {
$queue->releaseItem($item);
break;
}
catch (\Exception $e) {
// Log the exception.
}
}
在这个例子中,我们获取我们的QueueInterface对象,就像之前做的那样。然后,我们还创建了我们自己的QueueWorker插件的一个实例。接下来,我们在一个while循环中使用claimItem()方法,该方法返回一个包含要传递给队列工作进程的数据的对象。此外,它会在一个(租约)时间周期内阻止该项目被另一个工作进程使用(默认为一个小时)。
然后,我们尝试使用工作进程来处理项目,如果没有抛出异常,我们就删除该项目。完成了!然而,如果我们捕获到SuspendQueueException,这意味着我们预计整个队列可能存在问题。这种异常类型是在预期所有其他项目也可能失败的情况下抛出的,在这种情况下,我们释放项目并退出循环。释放项目意味着其他工作进程现在可以自由地使用claimItem()方法来处理它。或者更好,我们的工作进程可以在稍后尝试。最后,我们还捕获任何其他异常,在这种情况下,我们只是记录错误,但不释放项目以防止无限循环。目前,该特定项目无法处理,因此我们需要跳到下一个;它需要保持阻塞,直到我们的循环完成。后者只能发生在$queue->claimItem()不再返回任何内容时。
这基本上是我们自己处理队列的逻辑:我们声明一个项目,将其传递给一个工作进程并删除它。如果出现问题,我们通过异常来确定队列是否可以继续,或者是否应该完全跳过。
锁定 API
当我们定期处理数据时,尤其是如果它需要一段时间才能完成,我们可能会遇到一个情况,即并行请求想要在第一个仍在运行时再次触发该过程。大多数时候,这不是一个好主意,因为它可能导致冲突和/或数据损坏。一个很好的例子是 Drupal 核心中的 cron,如果启动它,整个过程可能需要几秒钟。记住,它需要收集hook_cron()实现并运行它们。所以当这个过程在进行时,如果我们再次触发 cron 运行,它会给我们一个友好的消息,让我们冷静下来,因为 cron 已经在运行了。它是通过锁定 API 来做到这一点的。
锁 API 是 Drupal 的一个低级解决方案,用于确保进程不会相互践踏。由于在本章中我们讨论的是批处理操作、队列和其他可能耗时的进程,让我们看看锁 API,看看我们如何利用它来为我们的自定义代码。但首先,让我们了解这种锁定是如何工作的。
这个概念非常简单。在开始一个进程之前,我们根据给定的名称获取一个锁。这意味着我们检查这个进程是否已经被启动。如果我们得到绿灯(我们获取了锁),我们就继续开始进程。在这个时候,API 锁定这个命名的进程,这样其他请求就不能再次获取它,直到最初的请求释放它。这通常发生在进程完成时,其他请求可以再次启动它。在此之前,我们得到红灯,这告诉我们不能启动它——为了保持交通灯的类比。说到类比,Drupal 中主要的锁 API 实现,即使用数据库的那个,非常重视这个类比,因为它将存储锁的表命名为semaphore。
API 实际上非常简单。我们有一个锁服务,它是LockBackendInterface的一个实现。默认情况下,Drupal 8 自带两个:DatabaseLockBackend和PersistentDatabaseLockBackend。通常,前者被使用。这两个之间的区别在于后者可以用于在多个请求之间保持锁。前者实际上在请求结束时释放所有锁。我们将使用这个来演示 API 的工作原理,因为这也是 Drupal 核心主要使用的方法。
如果您还记得第七章中的内容,即您的自定义实体和插件类型,我们创建了一个 Drush 命令,该命令将运行我们所有的产品导入器。当然,到目前为止,我们只创建了一个插件。但我们想要确保,如果这个 Drush 命令在几乎相同的时间(在实际导入完成之前)多次执行,我们不会同时运行导入。这可能不是一个最现实的例子,因为 Drush 命令必须由某人实际运行,因此对其时间有很好的控制。然而,正如我们将看到的,同样的方法可以应用于由不可预测的请求触发的进程。
我们定义了ProductCommands::runPluginImport()辅助方法,用于运行特定插件的导入。我们可以用锁块包装这个触发器。不过,首先我们需要注入这个服务,我们可以使用lock键(或者如果我们不能注入它,可以使用静态简写:\Drupal::lock())。到现在你应该知道如何注入一个新的服务,所以这里我不会重复那个步骤。
因此,我们不仅可以在插件上运行import()方法,我们还可以先做这个:
if (!$this->lock->acquire($plugin->getPluginId())) {
$this->logger()->log('notice', t('The plugin @plugin is already running.', ['@plugin' => $plugin->getPluginDefinition()['label']]));
return;
}
我们尝试通过传递一个任意的名称(在这种情况下,我们的插件 ID)来获取锁。我们在这里一次只处理一个插件,所以实际上多个插件应该能够同时运行。如果acquire()方法返回FALSE,这意味着我们遇到了红灯,锁已经被获取。在这种情况下,我们打印一条相应的消息并退出。然而,如果没有,这意味着我们有绿灯,我们可以继续执行我们的代码。acquire()方法已经锁定,其他请求在我们不释放它之前无法获取它。说到这里,我们需要在最后添加一件事情(在导入之后):
$this->lock->release($plugin->getPluginId());
我们需要释放锁,以便其他请求可以在喜欢的时候再次运行它。基本上就是这样。如果我们同时运行我们的 Drush 命令两次,终端上会有类似以下内容:

正如你所见,只有一次调用 Drush 命令是真正成功的。正如预期的那样。
但我们也可以稍微不同一点。假设我们想要在第二个请求等待第一个请求完成后再运行它。毕竟,我们不希望错过任何更新。我们可以使用LockBackendInterface的wait()方法来实现这一点。重做的工作很小:
if (!$this->lock->acquire($plugin->getPluginId())) {
$this->logger()->log('notice', t('The plugin @plugin is already running. Waiting for it to finish.', ['@plugin' => $plugin->getPluginDefinition()['label']]));
if ($this->lock->wait($plugin->getPluginId())) {
$this->logger()->log('notice', t('The wait is killing me. Giving up.'));
return;
}
}
所以基本上,如果我们没有获取锁,我们会打印一条消息,表示我们在等待批准。然后,我们使用wait()方法,这个方法会将请求暂停最多 30 秒。在这段时间内,它将每 25 毫秒(直到达到 500 毫秒,此时它开始每 500 毫秒检查一次)连续检查锁是否可用。如果可用,它将跳出循环并返回FALSE(这意味着我们可以继续,因为锁已经可用)。否则,如果 30 秒已经过去,它将返回TRUE,这意味着我们仍然需要等待。此时我们放弃。猜猜看:wait()方法的第二个参数是最大等待秒数,因此我们也可以控制这一点。我建议你查看代码以更好地理解它是如何工作的。
就像这样,我们可以并行运行我们的两个 Drush 命令,并确保请求的第二个命令只在第一个完成后运行。如果它花费的时间超过 30 秒,我们就放弃,因为可能出了点问题。这样我们就有了锁 API。
摘要
在本章中,我们探讨了作为模块开发者,我们可以以任何我们想要的时间设置简单和复杂的数据处理任务的一些方法。
我们首先开始探索使用更新钩子的多请求功能。这延续自第八章,数据库 API,在那里我们首次介绍了这些功能,并且我们现在已经看到了如何扩展它们的能力。然后,我们转向了更复杂的批处理 API,它使用类似但更复杂的技术。这个系统允许我们构建一系列利用 Drupal 多请求能力的操作。我们的游乐场是 JSON 产品导入器,现在它可以处理大量数据而不用担心 PHP 内存超时。接下来,我们研究了 Drupal 的 cron 系统是如何工作的以及为什么它存在,甚至看到了一个例子,即作为模块开发者,我们如何可以将其挂钩并处理我们自己的任务,无论它何时运行。但是,然后,我们通过引入队列 API 将事情提升到了下一个层次,这个 API 允许我们将项目添加到队列中,以便它们可以在稍后阶段进行处理。正如我们所看到的,这种处理可以由 cron 触发,或者我们可以自己动手逐个处理。更不用说 Drush 选项也可以使事情变得简单。最后,我们研究了锁 API,它允许我们控制某些耗时较长的过程的触发。所有这些都是在防止它们同时多次运行,从而造成错误或数据损坏的情况下完成的。
在下一章中,我们将讨论视图以及作为模块开发者,我们如何可以以编程方式与之交互。
第十五章:视图
视图一直是任何 Drupal 网站的标准模块。它如此受欢迎且必需,以至于最终被纳入 Drupal 8 核心。因此,每个新的 Drupal 网站都自带视图,完全集成到系统中,并支持大量核心功能。
实质上,视图是一个用于创建和显示数据列表的工具。这些数据可以是几乎任何东西,但我们主要使用 Drupal 实体,因为它们现在非常健壮。它提供了通过 UI 构建和操纵复杂查询的架构,以及许多不同的输出结果数据的方式。从模块开发者的角度来看(是的,这里有一个双关语),大部分这种力量已经被分解成多个构建块层,抽象为插件。此外,按照传统,还有许多钩子在各个阶段被触发,我们可以通过编程方式对其进行贡献或影响视图。
在本章中,我们将从模块开发者的角度来审视视图生态系统。因此,我们不会花太多时间在其网站构建功能上,因为你可以很容易地认为整本书都可以只针对这一点。相反,我们将专注于我们作为模块开发者可以做些什么来赋予网站构建者更多能力,以及如何操纵视图以符合我们功能的需求。
那么,我们实际上在本章中会做什么呢?我们首先将开始将我们的产品实体类型与视图集成。实体系统和视图可以非常紧密地协同工作,我们只需要将它们指向对方。然后,我们将转换方向,将我们自己的自定义玩家和团队数据(来自第八章,数据库 API)暴露给视图,以便我们的网站构建者可以构建列出这些信息的视图,包括过滤器、排序、参数和所有一切。从那里,我们将探讨如何修改其他模块暴露给视图的数据,如实体数据,例如节点。
接下来,我们将学习如何创建自己的ViewsField、ViewsFilter和ViewsArgument插件,以应对那些现有插件略显不足的偶尔需求。最后,我们将简要讨论主题化视图及其在其中的主要组件,以便你能够正确地开始,并应用第四章,主题化中的教训。
到本章结束时,你将很好地理解如何利用视图来处理自己的数据,以及修改或贡献其他模块如何利用它。你还应该对视图插件生态系统有一个相当好的理解,即使你将不得不自己做一些工作,研究所有类型的可用插件。
那么,让我们开始吧。
视图中的实体
即使在 Drupal 7 中,视图与实体系统的集成也相当不错。但由于当时没有强大的实体 API,这种集成并不那么自然。它需要更多的贡献模块和一些自定义代码才能使实体类型与视图协同工作。
然而,在 Drupal 8 中,两者非常紧密地联系在一起,将新的内容实体暴露给视图变得非常容易。如果你已经跟随了第七章,“你自己的自定义实体和插件类型”,并且已经设置了产品实体类型,你会注意到,如果你尝试创建一个视图,你将没有选项基于产品创建它。这是因为,在实体类型定义中,我们没有指定它应该被暴露给视图。实际上就是这样。我们只需要引用一个新的处理器:
"views_data" = "Drupal\views\EntityViewsData"
就这些。清除缓存后,我们现在能够创建可以显示任何字段、可以按其过滤和排序、甚至可以使用视图模式渲染产品的视图。所有这些都与其他实体类型(至少在基本层面上,我们将在下一刻看到)保持一致。
你会注意到我们引用了EntityViewsData数据处理器,它确保了所有类型实体的基本逻辑。如果我们想,我们可以扩展这个类,并为暴露给视图的数据添加一些我们自己的特定性(或者改变现有的)。这是在getViewsData()方法中完成的,我们将在稍后看到示例。但如果你已经想看到示例,可以查看NodeViewsData处理器,因为对于节点实体类型,它里面有很多额外的东西。其中很多可能现在还没有太多意义,所以让我们通过将我们自己的自定义数据暴露给视图来慢慢了解视图是如何工作的。
将自定义数据暴露给视图
为了更好地理解视图是如何工作的,我们将查看一个完全自定义的数据示例以及我们如何将其暴露给视图。基于这一点,我们将开始理解各种插件的作用,并开始创建我们自己的。此外,我们还将能够扩展我们的产品实体类型数据,以丰富其视图交互。
为了说明这一切,我们将重新访问我们的运动模块,其中我们声明了players和teams数据表,并且我们现在将它们暴露给视图。目标是允许网站构建者创建符合他们需求的动态数据列表。从这个例子中学到的经验可以应用于其他数据源,甚至是一些远程 API(需要一些额外的工作)。
查看数据
每当我们想要将数据暴露给视图时,我们需要以视图可以理解的方式定义这些数据。这正是EntityViewsData::getViewsData()对内容实体所做的事情。然而,由于我们正在处理一些自定义的,我们可以通过实现hook_views_data()来实现这一点。它可以包含很多东西,但我们将从简单开始。
让我们实现这个钩子,并简单地描述我们的第一个表(玩家的表)以及仅一个字段,即玩家 ID,作为开始。
在视图术语中,术语field不必必然与实体字段或类似的东西相关,而是与数据源(真实或非真实)中的单个数据片段相关。一个典型的例子是表中的一列,但它也可以是远程 API 资源中的属性。此外,相同的术语也用来描述该数据片段的责任,即以某种方式输出。它还可以有的其他责任包括filter、sort、relationship等。每个这些责任都由特定类型的视图插件(在视图的旧版本中也称为处理器)处理。
因此,基本实现可以看起来像这样:
/**
* Implements hook_views_data().
*/
function sports_views_data() {
$data = [];
// Players table
$data['players'] = [];
$data['players']['table']['group'] = t('Sports');
$data['players']['table']['base'] = array(
'field' => 'id',
'title' => t('Players'),
'help' => t('Holds player data.'),
);
// Player fields
$data['players']['id'] = array(
'title' => t('ID'),
'help' => t('The unique player ID.'),
'field' => array(
'id' => 'numeric',
),
);
return $data;
}
这个钩子需要返回一个多维关联数组,描述各种事物,其中最重要的是表及其字段。表不必是实际的数据库表,也可以指类似外部资源的东西。当然,视图已经知道如何查询数据库表,这使得事情对我们来说变得容易。否则,我们还需要创建查询该外部资源的逻辑(通过实现一个ViewsQuery插件)。
因此,我们首先定义players表,它属于Sports组。这个标签可以在视图管理员的字段前缀中找到,我们想要添加的字段的前缀。接下来,我们定义我们的第一个基础表,称为players(映射到具有相同名称的实际数据库表)。基础表是在创建视图时用于基于的表。换句话说,在以下屏幕文本中你选择的任何内容:

基础表定义包含一些信息,例如field,它指的是包含记录唯一标识符的列。title和help都是必填项,在 UI 中使用。此外,它还可以包含query_id,它引用负责以可理解的方式从源返回数据的ViewsQuery插件的插件 ID。由于在我们的案例中,我们使用的是数据库(因此是 SQL),省略此属性将使其默认为views_query插件(如果你想查看,是Sql类)。
视图字段
但为了实际使用这个表,我们需要定义一个或多个可以输出其部分数据的字段。因此,我们从简单的一个开始:玩家 ID。任何在$data['table_name']数组中(如我们所见,不是以table为键)的内容都负责定义视图字段。键是它们的机器名。title和help再次出现,并在我们尝试添加相应字段时在 UI 中使用:

然而,这个定义中最重要的部分是field键,它基本上表示,对于这块数据,我们想要一个使用ViewsField插件且 ID 为numeric(NumericField)的视图字段。因此,我们实际上不需要编写自己的插件,因为视图已经为我们提供了一个很好的插件,并且它会根据数据的类型来处理我们的 ID。当然,在定义视图字段(或任何其他类型的数据责任,即插件或处理器)时,我们可以有比仅使用插件 ID 更多的选项。
你可以通过查看模块本身定义的所有现有视图插件(这些插件相当多,适用于许多用例)来检查Drupal\views\Plugin\views命名空间。有许多插件类型处理不同的责任,但了解你可以查找的地方是很好的,因为很多时候,一个插件已经存在来满足你的需求。
通过这种方式,我们就完成了。清除缓存后,我们现在可以进入视图 UI 并创建我们的第一个显示球员数据的视图。我们可以向其中添加 ID 字段,然后它将自然地只显示 ID 列表。没有更多,因为我们没有定义其他任何内容。所以,让我们继续以同样的方式公开球员名字:
$data['players']['name'] = array(
'title' => t('Name'),
'help' => t('The name of the player.'),
'field' => array(
'id' => 'standard',
),
);
这次,我们使用的是standard插件,这是我们可以使用的最简单的插件。它本质上只是将数据以它在数据源中的形式输出(并实施适当的清理)。在我们的球员名字的情况下,这已经足够了。现在我们可以将这个新字段添加到视图中。
如果你记得,我们players表中的另一列可以以序列化的方式存储任意数据。显然,这不能用于过滤或排序,但我们仍然可以将其中的一些数据作为字段输出。根据我们的数据和想要实现的目标,我们可以有两种方法来做这件事。首先,我们可以使用现有的Serialized插件,它允许我们显示序列化的数据或结果数组中的给定键(取决于字段配置)。但对于更复杂的情况(尤其是当数据是任意的时候),我们可以编写自己的字段插件。
让我们先创建一个简单的data字段,它可以输出序列化数据的打印版本,因为我们不能依赖于实际存储的数据:
$data['players']['data'] = array(
'title' => t('Data'),
'help' => t('The player data.'),
'field' => array(
'id' => 'serialized',
),
);
在字段配置中,我们有以下选项可供选择:

通过这种方式,你应该已经对如何在视图中定义输出字段有了大致的了解。现在让我们看看如何将我们的团队纳入循环,并展示一些关于球员所属团队的资料。
视图关系
我们玩家所属的团队信息存储在不同的表中。这意味着,在数据库级别,必须创建一个连接来将它们拉在一起。在 Views 术语中,这是一个 relationship,意味着一个表与另一个表相关联,并且这些声明是从连接表中的一个字段指向另一个字段的。所以,让我们看看我们如何定义 players 表中的 team_id 字段,以便与 teams 表的 id 字段连接:
$data['players']['team_id'] = array(
'title' => t('Team ID'),
'help' => t('The unique team ID of the player.'),
'field' => array(
'id' => 'numeric',
),
'relationship' => array(
'base' => 'teams',
'base field' => 'id',
'id' => 'standard',
'label' => t('Player team'),
),
);
首先,我们将它定义为视图中的一个字段。然后,因为我们可能还想显示团队 ID,我们可以使用 numeric 插件将其定义为 field,就像我们定义玩家记录的 ID 一样。但这里这个字段在形式上又多了一个 relationship 的责任,这需要四条信息:
-
base: 我们要连接的表名 -
base field: 我们要连接的表上用于连接的字段名称 -
id: 用于关系的ViewsRelationship插件 ID -
label: 这个关系在 UI 中的标签
通常,standard 关系插件就足够了,但如果我们需要,我们总是可以自己创建一个。不过,你几乎不需要这么做。
这个定义现在允许我们在 Views 中添加一个到 teams 表的关系。然而,即使数据库引擎连接了两个表,我们也没有达到目的,因为我们还想要输出新表的一些字段。所以,为了这个,我们首先必须定义这个表本身,就像我们为玩家所做的那样:
// Teams table
$data['teams'] = [];
$data['teams']['table']['group'] = t('Sports');
注意,如果我们不想创建基于这个表的视图,就不必将其定义为 base 表。在我们的情况下,它可以作为玩家信息的辅助。然后,就像我们之前所做的那样,我们可以定义几个团队字段:
// Teams fields
$data['teams']['name'] = array(
'title' => t('Name'),
'help' => t('The name of the team.'),
'field' => array(
'id' => 'standard',
),
);
$data['teams']['description'] = array(
'title' => t('Description'),
'help' => t('The description of the team.'),
'field' => array(
'id' => 'standard',
),
);
这里没有什么新的内容,只是我们两个列的基本数据输出。但现在,我们可以进入 UI 中的视图,将一个关系添加到团队表中,然后包括我们玩家所属的团队名称和描述。真不错。
视图排序和筛选
让我们继续丰富团队名称字段的 responsibilities,通过使我们的玩家列表可按团队名称筛选和排序;例如,只显示特定团队的玩家或按团队名称字母顺序排序玩家。这简直太简单了。我们只需将这些添加到团队名称字段定义中(就像我们添加到玩家的 team_id 字段中的 relationship 一样):
'sort' => array(
'id' => 'standard',
),
'filter' => array(
'id' => 'string',
),
所以基本上,我们使用 Standard 排序插件进行排序(这基本上默认为 MySQL 可以做的任何事情)。至于筛选,我们使用 StringFilter 插件,它可以从 Views UI 中进行相当多的配置。它甚至允许我们各种筛选可能性,如部分匹配。有了这个,我们现在可以按团队名称进行排序和筛选。
视图参数
视图字段可以拥有的最后一种责任是作为参数(或 Drupal 老手的上下文过滤器)使用。换句话说,配置视图以便可以通过动态传递给它的参数进行过滤。让我们面对现实;大多数时候,如果我们想按团队过滤,我们不会依赖于实际的字符串名称,因为这可能会改变。相反,我们将一切与记录(通过其 ID)联系起来。这意味着我们将向players表的team_id字段添加argument键(这也意味着查询不需要连接,因此性能会更好):
'argument' => array(
'id' => 'numeric',
),
在这种情况下,我们使用NumericArgument插件,它几乎为我们所需的数据类型做了所有事情——它通过预期的数值数据类型进行过滤。这样我们就完成了。现在我们可以通过球员所属的团队 ID 动态过滤我们的球员视图。
修改视图数据
我们看到了如何将我们自己的完全定制的数据暴露给视图。然而,我们也可以通过实现hook_views_data_alter()来修改由 Drupal 核心或其他模块提供的现有数据定义。通过引用传递的$data参数将包含所有已定义的内容,可以根据需要进行更改。
此外,我们还可以使用这种实现来创建一些新的视图字段或过滤器,这些字段或过滤器位于不属于我们的其他表上。这实际上比暴露完全定制的表或其他类型的资源更常见。例如,我们可能想创建一个新的视图字段,显示与节点相关的某些内容。所以,让我们看看一个例子。
你还记得在第六章“数据建模和存储”中,我们是如何创建一个伪字段,在节点的底部输出免责声明消息的吗?如果我们的视图配置为渲染节点实体,那么这将有效。然而,如果它使用字段,则无法做到这一点。所以,让我们看看我们如何将此消息也暴露为视图字段。我们不会将其包含在最终代码中,但让我们看看如果我们想完成它,我们应该怎么做。
首先,我们需要实现hook_views_data_alter()并在节点实体类型数据表上定义一个新的字段:
/**
* Implements hook_views_data_alter().
*/
function module_name_views_data_alter(&$data) {
$data['node_field_data']['disclaimer'] = [
'title' => t('Disclaimer'),
'help' => t('Shows a disclaimer message'),
'field' => [
'id' => 'custom',
],
];
}
在这个例子中,我们将我们的新视图字段添加到节点数据表(node_field_data)上。但是,我们有一个选择,即使用哪个插件来渲染我们的消息。我们当然可以自己创建一个(正如我们将在下一节中做的那样)。这实际上非常简单,尤其是因为它甚至不需要使用任何来自结果节点的信息。然而,如果是这样的话,我们不妨使用现有的Custom插件,它有两个主要优点。一方面,我们不需要再写任何代码。第二,它允许网站构建者通过 UI 指定(并根据需要修改)免责声明消息。因为基本上,这个插件暴露了一个配置表单,我们可以用它来添加我们想要显示的每行的文本:

当然,这种方法也有一些缺点。如果我们想确保这里的信息和我们在伪字段中使用的信息之间的一致性,我们可能需要编写自己的插件并从这个独特的地方获取信息。同样,如果我们想确保信息严格在代码中,特别是如果我们需要从视图结果中的节点获取某种数据时,这也适用。因此,选择取决于实际用例,但在创建自己的插件之前,查看现有的视图插件并了解它们已经存在的内容是很好的。
自定义视图字段
现在我们已经看到了数据是如何暴露给视图的,我们可以开始更好地理解我之前提到的NodeViewsData处理器(即使不是全部)。但这同时也提供了一个很好的过渡,回到我们的Product实体类型的views_data处理器,我们现在可以看看getViewsData()方法的职责是什么。它需要返回所有表和字段的定义,以及它们能做什么。幸运的是,基类已经为我们提供了将我们的产品数据转换为视图字段、过滤器、排序、参数以及可能的关系所需的一切,一切现成。
但假设我们想在产品相关功能上下文中添加一些对我们有意义的更多视图字段。例如,每个产品都有一个source字段,该字段由导入实体从其自身的source字段填充。这只是为了跟踪它们来自哪里。因此,我们可能想要创建一个视图字段,它简单地渲染导入产品的导入者名称。
您可能会问:但是,那不是产品表上的一个列!怎么回事?正如我们将看到的,我们可以定义渲染我们想要的任何数据的视图字段(无论这些数据是否与记录相关)。当然,这也意味着,由于 MySQL 在构建查询时无法访问这些数据,因此结果数据不能用于排序或过滤。因此,我们在这一点上灵活性略低,但这是有道理的。
在本节中,您将学习两件事。首先,我们将看到如何为我们的产品实体类型创建自己的views_data处理器。到目前为止,您应该对这个过程相当熟悉。更重要的是,我们将使用这个处理器为我们的产品创建一个新的视图字段,该字段渲染现有ViewsField插件无法提供的功能:相关导入实体名称。这意味着我们的自定义插件。多么令人兴奋,让我们开始吧!
创建我们自己的views_data处理器有两个快速步骤。首先,我们需要这个类:
namespace Drupal\products\Entity;
use Drupal\views\EntityViewsData;
/**
* Provides Views data for Product entities.
*/
class ProductViewsData extends EntityViewsData {
/**
* {@inheritdoc}
*/
public function getViewsData() {
$data = parent::getViewsData();
// Add stuff.
return $data;
}
}
如您所见,我们正在扩展之前在产品实体类型注解中引用的基EntityViewsData类。在内部,我们正在重写getViewsData()方法以添加我们自己的定义(这些定义将放在您可以看到注释的地方)。
第二,我们需要在实体类型注解中更改处理器引用到这个新类:
"views_data" = "Drupal\products\Entity\ProductViewsData",
就这些了。我们现在可以定义自己的自定义字段,并且我们可以从视图数据定义开始:
$data['product']['importer'] = [
'title' => t('Importer'),
'help' => t('Information about the Product importer.'),
'field' => array(
'id' => 'product_importer',
),
];
简单的事情,就像我们处理玩家时做的那样。只不过在这个案例中,我们要将它添加到product表中,并且我们正在使用一个还不存在的ViewsField插件。所以,让我们创建它。
正如你可能注意到的,如果你检查了一些现有的,Views 插件位于模块的Plugin\views\[plugin_type]命名空间中,其中[plugin_type]在这个案例中是field,因为我们正在创建一个ViewsField插件。所以,我们可以从插件类脚手架开始:
namespace Drupal\products\Plugin\views\field;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;
/**
* Field plugin that renders data about the Importer that imported the Product.
*
* @ViewsField("product_importer")
*/
class ProductImporter extends FieldPluginBase {
/**
* {@inheritdoc}
*/
public function render(ResultRow $values) {
// Render something more meaningful.
return '';
}
}
就像任何其他字段插件一样,我们正在扩展FieldPluginBase类,它提供了字段所需的所有常见默认值和基本功能。当然,你注意到的一个不可否认的小注释,它只包含插件 ID。我们的主要工作是工作在render()方法中并输出一些内容,最好使用包含所有数据的相应行的$values对象。
在ResultRow对象内部,我们可以找到来自 Views 行的值,这些值可能包含多个字段。如果是一个列出实体的视图,我们还有一个_entity键,它引用实体对象本身。
清除缓存后,我们现在能够将新的产品导入器字段添加到产品视图。但如果我们这样做,我们会注意到一个错误。Views 试图将我们定义的product_importer字段添加到查询中,但实际上这个字段并不存在于表中。这是不正确的!这是因为,尽管 Views 可以与任何数据源一起工作,但它仍然偏好 SQL 数据库,所以我们有时会遇到这些问题。不过不用担心,因为我们可以简单地告诉我们的插件不要在查询中包含该字段——它将显示完全定制的数据。我们通过覆盖query()方法来实现这一点:
/**
* {@inheritdoc}
*/
public function query() {
// Leave empty to avoid a query on this field.
}
就这些了。现在,我们的字段将渲染一个空字符串:''。让我们将其改为查找相关的导入实体并显示其标签。但为了做到这一点,我们需要使用EntityTypeManager服务进行查询。让我们注入它:
/**
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $entityTypeManager;
/**
* Constructs a ProductImporter object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entityTypeManager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entityTypeManager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager')
);
}
由于我们正在插件内部操作,我们需要确保我们实现了ContainerFactoryPluginInterface,以便使用create()方法。但幸运的是,一个父类已经这样做了,即Drupal\views\Plugin\views\PluginBase,所以我们没问题。
然而,我们确实需要在顶部添加新的use语句:
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
现在,我们可以继续使用render()方法:
public function render(ResultRow $values) {
/** @var \Drupal\products\Entity\ProductInterface $product */
$product = $values->_entity;
$source = $product->getSource();
$importers = $this->entityTypeManager->getStorage('importer')->loadByProperties(['source' => $source]);
if (!$importers) {
return NULL;
}
// We'll assume one importer per source.
/** @var \Drupal\products\Entity\ImporterInterface $importer */
$importer = reset($importers);
return $this->sanitizeValue($importer->label());
}
我们简单地获取当前行的产品实体,然后查询具有在产品上引用的源的产品导入器配置实体。我们假设只有一个(即使我们没有做好确保这一点的工作以节省空间),然后简单地返回其标签。我们还通过 sanitizeValue() 辅助方法传递它,该方法负责确保输出对 XSS 攻击等安全。因此,现在我们的产品视图可以显示每个产品的导入器名称,该导入器将它们引入应用程序。
如果我们退后一步,试图理解正在发生的事情,一个警告变得明显。视图执行了一个大查询,返回了一个产品实体列表和一些数据。但是,当这些数据输出时,我们对结果集中的每个产品对应的导入器实体执行查询(并加载这些实体)。所以如果我们返回了 100 个产品,这意味着还有 100 个额外的查询。在创建自定义字段时,请记住这一点,以确保您不会得到巨大的性能损失,这往往甚至不值得。
字段配置
我们已经让字段工作正常了,但假设我们想要让它更加动态。目前它被称为 产品导入器,我们正在显示导入器实体的标题。但让我们使其可配置,以便我们可以在 UI 中选择要显示的标题——实体的标题或实际的导入器插件的标题。
使字段插件可配置有几个简单的步骤。它们与其他视图插件类型的工作方式类似。在概念上,它们也与我们在第九章,“自定义字段”中做的类似,当时我们使实体字段可配置。
首先,我们需要通过覆盖方法来定义一些默认选项:
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['importer'] = array('default' => 'entity');
return $options;
}
如您所见,我们向父类定义的选项(相当多)中添加了自己的 importer 选项。并将其默认值设置为字符串 entity。这是我们的选择。
其次,我们需要为新选项定义表单元素,我们可以通过另一个方法覆盖来实现:
/**
* {@inheritdoc}
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
$form['importer'] = array(
'#type' => 'select',
'#title' => $this->t('Importer'),
'#description' => $this->t('Which importer label to use?'),
'#options' => [
'entity' => $this->t('Entity'),
'plugin' => $this->t('Plugin')
],
'#default_value' => $this->options['importer'],
);
parent::buildOptionsForm($form, $form_state);
}
以及 use 语句:
use Drupal\Core\Form\FormStateInterface;
这里没有特别之处;我们只是在主选项表单上定义了一个选择列表表单元素。我们可以看到 $options 类属性包含所有插件选项,在那里我们可以检查我们 importer 的默认值。最后,我们当然将父定义中的所有其他元素添加到表单中。
接下来,在 render() 方法内部,一旦我们掌握了导入器实体,我们可以进行以下更改:
// If we want to show the entity label.
if ($this->options['importer'] == 'entity') {
return $this->sanitizeValue($importer->label());
}
// Otherwise we show the plugin label.
$definition = $this->importerManager->getDefinition($importer->getPluginId());
return $this->sanitizeValue($definition['label']);
非常简单。我们要么显示实体标签,要么显示插件的标签。但当然——我们跳过了这一点——导入器插件管理器也需要注入到类中。我将让您自己处理这个问题,因为您已经知道如何做到这一点。
最后,我们还需要做的一件事是定义配置模式。由于我们的视图(它是一个配置实体)现在正带着一个额外的选项被保存,我们需要定义后者的模式。我们可以在一个新文件products.schema.yml(位于我们模块的config/schema文件夹中)内完成这项工作:
views.field.product_importer:
type: views_field
label: 'Product Importer'
mapping:
importer:
type: string
label: 'Which importer label to use: entity or plugin'
这应该已经让你很熟悉了,包括定义配置模式的动态性。我们在第九章,自定义字段中,为我们的字段类型、小部件和格式化插件上的选项做了同样的事情。不过,这次类型是views_field,我们从其中基本继承了大量的定义,并添加了我们自己的(importer字符串)。就是这样。如果我们配置我们的新视图字段,我们应该会看到这个新选项:

自定义视图过滤器
在前面的部分中,我们将我们的players和teams表暴露给了视图,以及将团队名称作为一个可能的字符串过滤器来限制通过团队筛选出的球员。但这并不是我们能达到的最佳方式,因为站点构建者可能不一定知道数据库中所有的团队,也不知道它们的准确名称。因此,我们可以创建自己的ViewsFilter,将其转换为用户可以选择的团队选择。有点像分类术语过滤器。那么,让我们看看它是如何实现的。
首先,我们需要修改团队名称字段的定义,以更改用于过滤的插件 ID(在hook_views_data()内部):
'filter' => array(
'id' => 'team_filter',
),
现在,我们只需要创建那个插件。自然地,它位于我们模块的Plugin/views/filter命名空间中:
namespace Drupal\sports\Plugin\views\filter;
use Drupal\Core\Database\Connection;
use Drupal\views\Plugin\views\filter\InOperator;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Filter class which filters by the available teams.
*
* @ViewsFilter("team_filter")
*/
class TeamFilter extends InOperator {
/**
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* Constructs a TeamFilter plugin object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Database\Connection $database
* The database connection.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->database = $database;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('database')
);
}
/**
* {@inheritdoc}
*/
public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL) {
parent::init($view, $display, $options);
$this->valueTitle = t('Teams');
$this->definition['options callback'] = [$this, 'getTeams'];
}
/**
* Generates the list of teams that can be used in the filter.
*/
public function getTeams() {
$result = $this->database->query("SELECT name FROM {teams}")->fetchAllAssoc('name');
if (!$result) {
return [];
}
$teams = array_keys($result);
return array_combine($teams, $teams);
}
}
首先,我们看到注释已经到位,使其成为一个插件。类似于视图字段。然后,我们使用依赖注入来获取数据库连接服务。到目前为止没有什么新东西。然而,你会注意到我们扩展了InOperator类,它为允许IN类型过滤的视图过滤器提供了基本功能。例如,... WHERE name IN(name1, name2)。所以我们从那里扩展以继承适用于视图的大部分逻辑。
然后,我们覆盖了init()方法(该方法用于初始化插件),以便设置站点构建者可以选择的可用值(团队名称)以及结果表单元素的标题。但我们通过指定一个options callback来实现,该回调将在正确的时间点检索选项。这个回调是我们类上的一个方法,名为getTeams(),它返回所有团队名称的数组。这个数组需要根据查询过滤器中使用的值进行键控。就是这样。我们不需要担心选项表单或类似的东西。基类为我们处理了一切。
现在,站点构建者可以添加这个过滤器,并选择一个团队(或多个)进行过滤,以包容性的方式。例如,要显示属于相应团队的球员:

而不是使用options callback,我们也可以直接覆盖父类的getValueOptions()方法(实际上它会调用选项回调)。这里的唯一注意事项是,为了防止性能泄漏,值应该存储在局部的valueOptions类属性中。这样,它们可以被多次读取。
即使这不是那么明显,我们还需要做的一件事是为我们的过滤器定义配置模式。你可能想知道为什么我们不创建任何自定义选项。答案是,当用户添加过滤器并选择一个团队进行过滤时,Drupal 不知道该值的数据类型。因此,我们需要告诉它它是一个字符串。在我们的sports.schema.yml文件中,我们可以有如下内容:
views.filter.team_filter:
type: views_filter
label: 'The teams to filter by'
mapping:
value:
type: sequence
label: 'Teams'
sequence:
type: string
label: 'Team'
与视图字段类似,我们有一个动态的views_filter类型模式定义。在映射中,我们覆盖了value字段(该字段已经被views_filter数据类型定义)。在我们的情况下,这是一个序列(一个带有不重要键的数组),其个别值是字符串。
另一种实现相同(或类似)效果的方法是这样的:
views.filter_value.team_filter:
type: sequence
label: 'Teams'
sequence:
type: string
label: 'Team'
这是因为,在views_filter模式中找到的value键的定义中,类型被设置为views.filter_value.[%parent.plugin_id]。这意味着我们可以简单地为自己定义views.filter_value.team_filter数据类型,以便它可以使用。如果你还记得,这与我们在第十二章中自己做的非常相似,即JavaScript 和 Ajax API。因此,我们只需定义那缺失的部分作为我们的序列,而不是覆盖整个结构来改变一小部分。
现有的视图过滤器类提供了大量的能力,既可以直接用于自定义数据,也可以扩展以补充我们自己的特定需求。因此,我建议你查看所有现有的过滤器插件。然而,过滤器的主要概念是改变视图执行的查询,这可以在插件类的query()方法中完成。在那里,我们可以根据需要向查询添加额外的条件。你可以在FilterPluginBase类上查看这个方法,它简单地根据配置的值和操作符使用查询对象的addWhere()方法添加一个条件。
自定义视图参数
当我们最初将玩家和团队数据暴露给视图时,我们使用了一个参数插件,这样我们就可以对玩家所属的团队 ID 进行上下文过滤。为此,我们使用了现有的numeric插件在players表的team_id实际字段上。但如果我们想要一个在更多层面上工作的参数呢?例如,我们并不确切知道我们会收到什么类型的数据,但我们希望能够很好地处理一个数值型(团队 ID)和一个文本型(团队名称)。全部在一个参数中。为了实现这一点,我们可以创建一个简单的ViewsArgument插件来为我们处理这个问题。
首先,就像往常一样,是定义这个字段。我们不希望干扰我们之前添加了参数的team_id字段,因为这个仍然可以使用。相反,我们将创建一个新的字段,这次是在teams表上,我们将简单地称之为team:
$data['teams']['team'] = array(
'title' => t('Team'),
'help' => t('The team (either an ID or a team name).'),
'argument' => array(
'id' => 'team',
),
);
这次,我们不会为它创建一个字段,因为我们不需要显示任何内容。相反,我们只坚持argument责任,这将由我们新的team插件处理。你也许也会注意到,team列实际上并不存在于数据库表中。
那么,让我们看看这个插件:
namespace Drupal\sports\Plugin\views\argument;
use Drupal\views\Plugin\views\argument\ArgumentPluginBase;
/**
* Argument for filtering by a team.
*
* @ViewsArgument("team")
*/
class Team extends ArgumentPluginBase {
/**
* {@inheritdoc}
*/
public function query($group_by = FALSE) {
$this->ensureMyTable();
$field = is_numeric($this->argument) ? 'id' : 'name';
$this->query->addWhere(0, "$this->tableAlias.$field", $this->argument);
}
}
如同往常,我们扩展了其类型的基插件类并添加了适当的注解。在内部,我们只处理query()方法,我们重写了它。参数与过滤器在意义上非常相似,因为它们旨在通过查询来限制结果集。主要区别在于实际用于过滤的值,在这种情况下,它是动态的,可以在(父)类的$argument属性中找到。我们所做的是简单地给teams表(因为那是基表)的相应字段添加一个查询条件,这取决于我们处理的数据类型。但在我们这样做之前,我们调用ensureMyTable()方法,这个方法简单地确保我们的插件需要的表被 Views 查询包含。
就这些了。我们现在可以将我们新创建的参数添加到视图中,无论我们传递了什么上下文过滤器(ID 或名称),它都会相应地过滤。当然,我们也可以像大多数其他 Views 插件类型一样有选项,但我会让你自己探索那些。我们还可以从父类中重写更多内容以与 Views 集成。但这有点更高级,而且你不太可能需要处理很长时间。我确实建议探索其背后的代码。
视图主题
前端开发者曾在 Drupal 7 中感到很多痛苦,其中很大一部分也与视图主题有关。幸运的是,Drupal 8 使事情处理起来容易得多。我们将在这里看看一些内容,以便在你应用在第四章,主题化中学到的知识时,能给你正确的方向。
Views 非常复杂,由许多可插拔层组成。一个视图有一个显示(如页面或块),可以使用给定的样式(如无格式列表或表格)来渲染其内容。样式可以决定是否控制给定结果项(行)的渲染,或者委托给行插件(如字段或实体)。实际上,大多数都这样做。使用行插件的最常见场景是使用EntityRow,它使用指定的视图模式渲染结果实体,或者使用Fields插件,它使用单个ViewField插件来渲染添加到视图中的每个字段。
如果我们想要为主题化视图,我们可以查看所有这些点。想要视图输出幻灯片吗?也许创建一个新的样式插件。想要对结果集中的每个实体进行疯狂的操作?也许创建一个新的行插件,或者甚至只创建一个新的字段插件(就像我们做的那样)以以任何你想要的方式渲染一块数据。这些技术更多地面向模块开发者对视图的控制。但我们也有可以玩转的主题方面。
再次从顶部开始,样式插件不过是主题钩子的美化包装。例如,未格式化列表插件使用views_view_unformatted主题钩子,这意味着几件事情:它可以被主题(甚至模块)覆盖,并且可以被主题或模块预处理。查看默认的template_preprocess_views_view_unformatted()预处理程序和views-view-unformatted.html.twig模板文件以获取更多信息。不要忘记主题钩子建议,因为视图定义了相当多的它们。你所需要做的就是启用主题(Twig)调试,你将看到每个视图层正在使用哪个模板。
然而,样式主题只能让我们到达所有结果的包装层。要深入一点,我们需要知道它使用的是哪种行插件。如果实体正在渲染,那么这与控制实体构建的方式相同。参见第六章,数据建模与存储,以复习相关内容。如果行插件使用字段插件,我们有一些选项。首先,这同样是一个主题钩子的包装,即views_view_fields,它渲染了添加到视图中的所有字段插件。
因此,我们可以使用已知的主题方法来覆盖它。但我们也可以覆盖每个字段插件的默认主题钩子,即views_view_field,它负责包装插件的输出。这带我们到字段插件本身以及它们最终渲染的内容,这可能会因插件而异。所以,请确保检查这一点。
视图钩子
视图还附带了许多钩子。我们已经看到了一个重要的钩子,它允许我们将自己的数据暴露给视图。但还有更多,你应该查看views.api.php文件以获取更多信息。
许多用于更改各种插件类型的插件信息。但也有一些重要的钩子,它们在运行时处理视图的执行。其中最值得注意的是hook_views_query_alter(),它允许我们对即将运行的最终查询进行修改。还有hook_views_post_render()和hook_views_pre_render(),它们允许我们对视图结果进行修改。例如,更改项目顺序或类似的事情。
我建议您查看他们各自的文档,并了解您可以使用这些钩子做什么。有时它们可能会有所帮助,尽管在 Drupal 8 中,大部分动作都发生在插件中,您现在可以轻松地编写自己的插件来处理您的特定需求。这就是为什么我们不会对这些内容进行详细说明。
摘要
在本章中,我们从各种面向模块开发者的角度探讨了视图。我们看到了如何将我们的产品实体类型暴露给视图。这很简单。但是,我们也看到了如何将来自第八章《数据库 API》的定制播放器和团队数据暴露给视图。即使我们必须为此编写一些代码,其中大部分都是相当模板化的,因为我们能够利用现有的视图插件生态系统来处理我们想要的几乎所有事情。然而,由于这些都是插件,我们也看到了如何创建我们自己的字段、筛选和参数插件来处理那些现有内容可能不足以应对的异常情况。
与此密切相关,我们还简要讨论了如何更改其他模块向视图暴露数据的方式。这里最显著的例子是能够轻松地向基于实体的视图中添加更多字段(和插件),以丰富其自定义功能。
最后,我们简要讨论了如何处理视图的主题方面。我们看到了构成视图的不同层次,从显示层一直到字段。我们以对视图模块在各个时间点调用的现有钩子的提及来结束本章,通过这些钩子我们也可以对其正常操作进行更改。
在下一章中,我们将看到如何在 Drupal 8 中处理文件和图像。
第十六章:处理文件和图像
Drupal 内置了许多处理和操作文件和图像的能力,并且随着最近版本的更新,其工具集也在不断增加。当然,这并不是说媒体管理从未是 Drupal 开发者的痛点。在 Drupal 7 中,需要一套复杂的贡献模块来实现基本的功能,而像 WordPress 这样的“竞争对手”用户可以开箱即用享受这些功能。在 Drupal 8 中,对媒体管理的重视程度更高,并且随着每个版本的发布,媒体功能已经集成到核心中。媒体(实体)模块,包括其支持图像、文件、远程视频(Oembed)和音频的基本源插件,以及实验性的媒体库,在该领域取得了重大进步。此外,与流行的贡献模块如实体浏览器一起,许多差距已经得到填补。
在本章中,我们将探讨如何在 Drupal 中使用核心功能处理文件和图像。尽管媒体模块允许开发者提供新的源插件来将媒体实体暴露给各种类型的媒体,但我们不会深入探讨这个相当高级的话题。相反,我们将专注于可以用于处理文件的底层工具,并在过程中展示一些示例。那么,我们将讨论什么呢?
首先,我们将了解 Drupal 的文件系统。来自先前版本的 Drupal 的开发者应该已经在理论上熟悉这些,我们将看到这些在 Drupal 8 中的工作方式。与此相关,我们将讨论流包装器以及 Drupal 如何处理原生 PHP 文件操作。我们甚至将在本章稍后创建我们自己的自定义流包装器。
然后,我们将简要讨论在 Drupal 中处理文件的不同方式,即管理(跟踪)和非管理文件。在举例说明如何处理管理文件时,我们将向我们的产品实体类型添加一个图像字段,并将图像从虚构的远程环境中导入。我们还将创建一个全新的基于 CSV 的导入器,通过该导入器从我们读取的 CSV 文件中导入产品数据。在这个过程中,我们将注意实体 CRUD 钩子,这是 Drupal 8 中非常重要的扩展点,并看看我们如何在示例上下文中使用它们。
我们将本章的结尾放在如何使用专门处理图像的各种 API 上,特别是通过图像工具包进行图像操作以及处理图像样式。让我们开始吧。
文件系统
Drupal 为任何给定站点定义了四种主要的文件存储类型:公共、私有、临时和翻译文件系统。在安装 Drupal 时,映射到这些文件系统的文件夹会自动创建。如果失败——最可能是由于权限问题——我们必须自己创建它们并赋予它们正确的权限。Drupal 会处理其余部分(例如,出于安全原因添加相关的 .htaccess 文件)。如果您不确定如何操作,请确保查看 Drupal.org 上的文档,了解如何成功安装 Drupal 8。
公共文件对所有人公开,可供查看或下载。这是存储图像内容、标志以及任何可以下载的文件的地方。您的公共文件目录必须位于 Drupal 根目录下的某个位置,并且必须可由您的 web 服务器运行的用户读取和写入。公共文件没有访问限制。任何人,在任何时候,都可以直接导航到公共文件并查看或下载它。这也意味着访问这些文件不需要 Drupal 引导。
我们可以在 settings.php 文件中配置公共文件系统的路径:
$settings['file_public_path'] = 'sites/default/files';
相反,私有文件对公众不可用,不能用于一般下载。因此,私有文件目录不能通过网络访问。然而,它仍然必须可由 web 服务器用户写入。通过这种方式隔离私有文件允许开发者控制谁可以访问它们。例如,我们可以编写一个模块,只允许具有特定角色的用户访问私有文件系统中的 PDF 文件。
我们可以在 settings.php 文件中配置私有文件系统的路径:
$settings['file_private_path'] = 'sites/default/private';
临时文件存储通常仅由 Drupal 用于内部操作。当文件首次由 Drupal 保存时,它们最初会写入临时文件系统,以便检查安全问题。一旦被认为安全,它们就会被写入最终位置。
我们可以通过 UI 配置临时文件系统的路径:

在相同的配置屏幕上,我们还可以指定站点的默认文件下载方法。默认情况下,这设置为公共文件系统。
最后,Drupal 使用翻译文件存储来存储包含可以批量导入系统的字符串翻译值的 .po 文件。与临时文件存储一样,我们可以通过 UI 配置翻译文件的位置。
流包装器
如果你长期编写 PHP,你可能在某个时候需要处理本地或远程文件。以下 PHP 代码是将文件读入变量的常见方式,你可以对它进行一些操作:
$contents = '';
$handle = fopen("/local/path/to/file/image.jpg", "rb");
while (!feof($handle)) {
$contents .= fread($handle, 8192);
}
fclose($handle);
这相当直接。我们使用 fopen() 获取本地文件的句柄,并使用 fread() 读取文件的 8 KB 数据块,直到 feof() 指示我们已到达文件末尾。在那个点上,我们使用 fclose() 关闭句柄。文件的内容现在存储在 $$contents 变量中。
除了本地文件外,我们还可以通过 fopen() 以相同的方式访问远程文件,但需要指定实际的远程路径而不是之前看到的本地路径(以 http(s):// 开头)。
我们可以通过这种方式访问的数据是可流式的,这意味着我们可以打开它、关闭它,或者定位到文件中的特定位置。
流包装器 是在这些流之上的一个抽象层,告诉 PHP 如何处理特定类型的数据。当使用流包装器时,我们就像引用传统 URL 一样引用文件——scheme://target。事实上,之前的例子使用了 PHP 的内置流包装器之一:用于访问本地存储文件的 file:// 包装器。实际上,这是默认方案,如果没有指定,所以我们可以省略它,只添加文件路径。如果文件位于远程位置,我们会使用类似 http://example.com/file/path/image.jpg 的东西。这是另一个 PHP 内置流包装器:http://(用于 HTTP 协议)。
如果这还不够,PHP 还允许我们为 PHP 默认不处理的方案定义自己的包装器;Drupal 文件 API 就是构建来利用这一点的。这就是我们回溯到之前讨论的不同类型的文件存储的地方,因为它们都有自己的流包装器,由 Drupal 定义。
公共文件系统使用相当知名的 public:// 流包装器,私有文件系统使用 private://,临时文件系统使用 temporary://,翻译文件系统使用 translations://。这些映射到我们在 settings.php(或用户界面)中定义的本地文件路径。在章节的后面部分,我们将看到如何定义我们自己的流包装器以及其中包含的一些内容。不过,首先让我们谈谈在 Drupal 8 中管理文件的不同方式。
管理文件与未管理文件
Drupal 文件 API 允许我们以两种不同的方式处理文件。文件本质上归结为两类:它们要么是 管理的,要么是 未管理的。这两种文件之间的区别在于它们的使用方式。
管理文件与实体系统紧密协作,实际上是与文件实体相关联。因此,每当我们创建一个管理文件时,都会为其创建一个实体,我们可以以各种方式使用它。这些记录存储的表称为file_managed。此外,管理文件的一个关键方面是它们的用法是可跟踪的。这意味着如果我们在一个实体上引用它们或甚至手动指示我们使用它们,这种使用情况将在名为file_usage的辅助表中跟踪。这样,我们可以看到每个文件在哪里被使用以及使用次数,Drupal 甚至提供了一个在特定时间后删除“孤儿”文件的方法,以防它们不再需要。
使用管理文件的一个显著例子是我们可以添加到实体类型的简单Image字段类型。使用这些字段,我们可以上传一个文件并将其附加到相应的实体。这种附件仅仅是两个实体之间的一种特殊(跟踪)实体引用。
通过了解管理文件的使用方式,预测非管理文件并不困难。后者是我们出于各种原因上传的文件,但当然不需要将其附加到任何实体或跟踪其使用情况。
使用文件和图像字段
为了演示如何与管理文件一起工作,我们将回到我们的产品实体导入器,并为每个产品引入一些图像。然而,为了存储它们,我们需要在产品实体上创建一个字段。这将是一个图像字段。
我们不通过 UI 创建此字段并将其附加到包中,而是以编程方式完成,使其成为一个基础字段(在所有包上可用)。我们现在不需要做任何复杂的事情;目前我们只对可以用来存储从远程 API 引入的图像的基本字段感兴趣。它可以看起来像这样:
$fields['image'] = BaseFieldDefinition::create('image')
->setLabel(t('Image'))
->setDescription(t('The product image.'))
->setDisplayOptions('form', array(
'type' => 'image_image',
'weight' => 5,
));
如果您还记得第六章“数据建模与存储”和第七章“自定义实体和插件类型”,我们正在创建一个基础字段定义,在这种情况下,它是image类型。这是ImageItem字段类型的FieldType插件 ID。因此,我们需要查看并了解可能有哪些字段和存储选项。例如,我们可以设置文件扩展名限制(默认包含png、gif、jpg和jpeg)以及alt和title属性,以及图像尺寸配置。请检查ImageItem以了解可能的存储和字段设置。然而,在这种情况下,我们使用默认设置,因此甚至没有任何字段设置。
另一个需要注意的有趣之处是ImageItem扩展了FileItem字段类型,这是一个独立的FieldType插件,我们可以使用。然而,它更通用,适用于任何类型的文件上传情况。由于我们处理的是图像,我们不妨利用特定的字段类型。
目前,我们不需要配置我们的图像字段以显示任何类型。我们稍后再来看这个问题。然而,我们确实指定了在实体表单上它应该使用的部件,即 ID 为image_image的FieldWidget插件。这映射到默认的ImageWidget字段部件。但同样,我们对默认设置感到满意,所以没有指定任何额外的内容。
使用这个,我们的字段定义就完成了。为了让 Drupal 创建必要的数据库表,我们需要运行 Drush 命令:
drush entity-update
现在,让我们创建接口方法,以便轻松访问和设置图像:
/**
* Gets the Product image.
*
* @return \Drupal\file\FileInterface
*/
public function getImage();
/**
* Sets the Product image.
*
* @param int $image
*
* @return \Drupal\products\Entity\ProductInterface
* The called Product entity.
*/
public function setImage($image);
获取方法应该返回一个FileInterface对象(这是实际的文件实体),而设置方法应该接收要保存的文件实体的 ID(fid)。至于实现,对我们来说不应该有任何新内容:
/**
* {@inheritdoc}
*/
public function getImage() {
return $this->get('image')->entity;
}
/**
* {@inheritdoc}
*/
public function setImage($image) {
$this->set('image', $image);
return $this;
}
这样,我们就准备好从远程 API 导入图像了。
为了利用 Drupal 8 中的媒体管理功能,而不是使用图像或文件字段,我们会创建指向媒体实体的实体引用字段。在后者上创建这些字段。因此,媒体实体基本上封装了文件实体,以提供一些额外的功能,并将它们暴露给所有媒体管理的优点。目前,我们直接与这些字段类型一起工作,以了解低级文件处理,而不需要媒体的开销。
与管理文件一起工作
在本节中,我们将查看两个与管理工作文件一起工作的示例。首先,我们将看到如何从我们的虚构远程基于 JSON 的 API 导入产品图像。其次,我们将看到如何创建一个自定义表单元素,允许我们上传文件并在全新的基于 CSV 的导入器中使用它。
将管理文件附加到实体
现在我们已经设置了产品图像字段并且可以存储图像,让我们重新审视包含产品数据的 JSON 响应,并假设它现在看起来像这样:
{
"products" : [
{
"id" : 1,
"name": "TV",
"number": 341,
"image": "tv.jpg"
},
{
"id" : 2,
"name": "VCR",
"number": 123,
"image": "vcr.jpg"
}
]
}
新增的是为每个产品添加了image键,它简单地引用了与相应产品一起的图像文件名。图像的实际位置在代码中需要包含的其他路径上。
回到我们的JsonImporter::persistProduct()方法,让我们将图像导入的处理委托给一个名为handleProductImage()的辅助方法。如果我们正在创建一个新的产品实体或者更新一个现有的实体(在保存之前),我们都需要调用这个方法:
$this->handleProductImage($data, $product);
实际的方法看起来是这样的:
/**
* Imports the image of the product and adds it to the Product entity.
*
* @param $data
* @param \Drupal\products\Entity\ProductInterface $product
*/
private function handleProductImage($data, ProductInterface $product) {
$name = $data->image;
// This needs to be hardcoded for the moment.
$image_path = '';
$image = file_get_contents($image_path . '/' . $name);
if (!$image) {
// Perhaps log something.
return;
}
/** @var \Drupal\file\FileInterface $file */
$file = file_save_data($image, 'public://product_images/' . $name, FileSystemInterface::EXISTS_REPLACE);
if (!$file) {
// Something went wrong, perhaps log it.
return;
}
$product->setImage($file->id());
}
以及顶部的新use语句:
use Drupal\products\Entity\ProductInterface;
use Drupal\Core\File\FileSystemInterface;
首先,我们获取图像的名称。然后我们构建产品图像存储路径。在这个例子中,它是空的,但如果示例要工作,我们必须在那里添加一个真实路径。我现在把这个留给你。如果你想测试它,创建一个包含一些图像的本地文件夹并引用它。
使用本地的file_get_contents()函数,我们将图像数据从远程环境加载到字符串中。然后我们将这个字符串传递给file_save_data()函数,该函数将新的托管文件保存到公共文件系统中。此函数接受三个参数:要保存的数据、目标 URI 以及一个标志,指示如果存在同名文件将执行什么操作。你会注意到我们使用了 Drupal 的public://流包装器来构建 URI,并且我们已经知道它映射到哪个文件夹。
至于第三个参数,我们选择在文件已存在的情况下替换该文件。另一种选择是使用相同接口的EXISTS_RENAME或EXISTS_ERROR常量。前者会创建一个新文件,其名称会附加一个数字,直到名称变得唯一。后者将简单地不执行任何操作并返回 FALSE。
如果一切顺利,此函数将返回一个File实体(实现了FileInterface),我们可以使用其 ID 在产品图像设置方法中。有了这个,我们还可以同步单个产品图像。
如果在此之后遇到问题,请确保创建目标文件夹,并在公共文件系统中拥有所有必要的权限,以便正确地执行复制。在下一节中,你将了解一些可以帮助你更好地准备目标文件夹的辅助函数。
此外,在我们的数据库中,file_usage表中会创建一个记录,以指示该文件正在被相应的产品实体使用。
处理托管文件的有用函数
除了主要的file_save_data()函数外,我们还有一些其他函数,在处理托管文件时可能会很有用。以下是一些例子。
如果我们想从一个地方复制文件到另一个地方,同时确保创建一个新的数据库记录,我们可以使用file_copy()。它接受三个参数:
-
需要复制的
FileInterface实体 -
它应该放置的目标 URI
-
指示在同名文件存在时如何处理的标志
参数与file_save_data()相同。
除了实际的复制操作外,此函数还会调用hook_file_copy(),允许模块对正在复制的文件做出响应。
与file_copy()非常相似,我们还有一个file_move(),它接受相同的参数集,但执行文件移动操作。文件实体的数据库条目会更新以反映新的文件路径。并且会调用hook_file_move()以允许模块对此操作做出响应。
虽然与 管理 文件不是严格相关,但在所有情况下都很有用,我们还有 \Drupal\Core\File\FileSystem 服务(通过 file_system 服务名称访问),它包含处理文件的各种有用方法。当我们谈到 非管理 文件时,我们会看到其中的一些。但其中一个对 管理 文件也很有用的是 ::prepareDirectory() 方法,我们可以用它来确保文件目标正确。它接受两个参数:目录(路径的字符串表示或流 URI)和一个标志,指示对文件夹的操作(接口上的常量):
-
FileSystemInterface::CREATE_DIRECTORY:如果目录不存在,则创建目录 -
FileSystemInterface::MODIFY_PERMISSION:如果发现目录是只读的,则使其可写
这个函数如果文件夹可以作为目标使用则返回 TRUE,如果出现错误或文件夹不存在则返回 FALSE。
管理文件上传
接下来,我们将探讨如何使用自定义表单元素来处理 管理 文件。为了演示这一点,我们最终将创建另一个产品导入插件。这次,我们将允许用户上传包含产品数据的 CSV 文件,并将其导入到产品实体中。这就是示例 CSV 数据的样子:
id,name,number
1,Car,45345
2,Motorbike,54534
它基本上与我们迄今为止一直在查看的 JSON 资源具有相同类型的数据,但没有图像引用。所以让我们开始使用我们的新插件类。
这里是我们的起点:
namespace Drupal\products\Plugin\Importer;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\products\Plugin\ImporterBase;
/**
* Product importer from a CSV format.
*
* @Importer(
* id = "csv",
* label = @Translation("CSV Importer")
* )
*/
class CsvImporter extends ImporterBase {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public function import() {
$products = $this->getData();
if (!$products) {
return FALSE;
}
foreach ($products as $product) {
$this->persistProduct($product);
}
return TRUE;
}
}
我们首先从 ImporterBase 类扩展,并实现必需的 import() 方法。像之前一样,我们将委托给 getData() 来检索产品信息,但在这个案例中,我们只是遍历结果记录,并使用 persistProduct() 方法来保存产品实体。所以没有批处理操作。除了不再保存图像外,这个后者的方法看起来与 JsonImporter 中的方法完全一样,所以我不打算再次复制它。但这是一个很好的家庭作业,尝试将其移动到基类并抽象出动态部分。
管理文件表单元素
我们还需要实现的其他必需方法是 getConfigurationForm(),通过它我们定义配置此特定插件所需的表单元素。在这里,我们将创建 file 字段,允许用户上传 CSV 文件:
/**
* {@inheritdoc}
*/
public function getConfigurationForm(\Drupal\products\Entity\ImporterInterface $importer) {
$form = [];
$config = $importer->getPluginConfiguration();
$form['file'] = [
'#type' => 'managed_file',
'#default_value' => isset($config['file']) ? $config['file'] : '',
'#title' => $this->t('File'),
'#description' => $this->t('The CSV file containing the product records.'),
'#required' => TRUE,
];
return $form;
}
表单元素类型称为 managed_file(由 ManagedFile 表单元素类实现)。其余的定义很简单。然而,有几个问题。
首先,默认情况下,使用这个表单元素,文件会被上传到 Drupal 的 temporary:// 文件系统。由于我们不希望这样,我们需要指定一个上传位置:
'#upload_location' => 'public://'
在这个例子中,我们公共文件文件夹的根目录就足够了,因为我们假设文件不包含任何敏感信息。如果是这样,我们可以将其上传到 private://,并控制谁可以访问。我们将在本章后面讨论它是如何工作的。
其次,默认情况下,使用此表单元素,允许上传的文件扩展名限于 jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp。因此,如果我们想允许 CSV 文件,我们需要在允许上传的扩展名列表中指定扩展名。我们通过覆盖默认上传验证器来完成此操作:
'#upload_validators' => [
'file_validate_extensions' => ['csv'],
],
这是一个验证器回调数组,我们希望 Drupal 在文件上传时运行。仅允许 CSV 文件就足够我们使用了。但另一个我们可能使用的实用验证器是 file_validate_size()。此外,我们可以实现 hook_file_validate() 并对上传的文件执行任何自定义验证。所以这也是处理不属于您模块的验证文件时需要记住的事情。
使用这个,我们的插件配置表单已经就绪;它看起来像这样:

然而,我们还需要做一些事情,以便正确地管理上传的文件。当使用此表单元素时,文件会被正确上传,并在 file_managed 表中添加一条记录。因此,我们得到了 File 实体。然而,它的状态不是永久的,因为它没有任何用途。在 file_usage 表中没有关于它的记录。怎么会这样呢?所以我们需要自己处理这个问题,基本上告诉 Drupal,在此表单中上传的文件是由相应的导入器配置实体所使用的。为此,我们需要知道文件何时被保存到实体中、何时更改以及何时被删除。
通过这种方式,我们还可以了解我们在第六章,数据建模和存储,以及第七章,您的自定义实体和插件类型: 实体 CRUD 钩子中跳过的一些非常重要的内容。但在我们深入之前,我们不要忘记这个新配置项的配置方案——插件配置中的 file 键:
products.importer.plugin.csv:
type: mapping
label: Plugin configuration for the CSV importer plugin
mapping:
file:
type: sequence
label: File IDs
sequence:
type: integer
label: CSV File ID
我们正在做与 JSON 导入器的 url 键相同的事情,但在这个情况下,我们需要考虑到 file 实际上是一个数组。因此,我们将其定义为一个序列,其单个项是整数。如果您需要提醒,随时查看第六章,数据建模和存储,了解更多关于配置方案的信息。
实体 CRUD 钩子
无论何时实体被创建、更新或删除,都会触发一系列钩子,使我们能够对此信息采取行动。我们可以简单地使用这些钩子在发生这种情况时执行一些操作,甚至可以更改正在保存的实体。那么,让我们看看我们有什么。
一个非常有用的是hook_entity_presave(),它在实体保存过程中触发(无论是内容还是配置)。这适用于实体首次创建时,以及当它被更新时。此外,它允许我们检查原始实体并检测对其所做的更改。最后,由于实体尚未持久化,它允许我们自行对其进行更改。所以这是一些非常强大的功能。
由于 Drupal 8 非常灵活,我们还有hook_ENTITY_TYPE_presave()版本,它允许我们特别针对我们想要的任何实体类型。我们之前已经讨论了使用更具体的钩子来保持代码更组织化以及提高性能的好处。这一点适用于我们接下来要讨论的所有实体 CRUD 钩子。
然后我们有hook_entity_insert()和hook_entity_update(),分别是在实体首次创建后和实体更新后触发的。由于实体已经被保存,我们无法对其本身进行更改,但它们在其他时候可能会很有用。后者还允许我们访问原始实体,以便比较任何更改。同样,我们还有hook_entity_delete(),它在实体被删除时触发。
最后,我们还有hook_entity_load(),它允许我们在实体被加载时执行操作。例如,如果我们想添加额外的信息,我们可以这样做。所以请记住这些钩子,因为它们将成为你模块开发者工具箱中的非常重要的工具。
管理文件使用服务
现在我们已经了解了可用的实体 CRUD 钩子,我们可以实现其中的三个来处理我们的管理文件问题。因为,如果你记得的话,管理文件实际上是由File实体类型表示的,所以实体 CRUD 钩子也会为这些文件触发。
为了标记一个文件被某物使用,我们可以使用DatabaseFileUsageBackend服务(file.usage),它是对FileUsageInterface的一个实现。这个服务有几个方便的方法,允许我们添加或删除使用记录。这正是我们接下来要做的。
我们首先想要做的是,每当创建新的导入器实体(以及与之一起上传的文件)时,添加文件使用记录:
/**
* Implements hook_ENTITY_TYPE_insert() for the Importer config entity type.
*/
function products_importer_insert(\Drupal\Core\Entity\EntityInterface $entity) {
if ($entity->getPluginId() != 'csv') {
return;
}
// Mark the current File as being used.
$fid = _products_importer_get_fid_from_entity($entity);
$file = Drupal::entityTypeManager()->getStorage('file')->load($fid);
\Drupal::service('file.usage')->add($file, 'products', 'config:importer', $entity->id());
}
我们正在为我们的实体类型实现特定的hook_entity_insert()版本,我们首先检查的是是否正在使用 CSV 插件查看。我们不对没有 CSV 文件上传的任何导入器感兴趣。如果是的话,我们使用一个私有辅助函数从导入器中获取文件实体 ID:
/**
* Given an Importer entity using the CSV plugin, return the File ID of the CSV
* file.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
*
* @return int
*/
function _products_importer_get_fid_from_entity(\Drupal\Core\Entity\EntityInterface $entity) {
$fids = $entity->getPluginConfiguration()['file'];
$fid = reset($fids);
return $fid;
}
你会注意到,在我们的插件配置数组中,file键是一个文件 ID 数组,即使我们只上传了一个单个文件。这只是我们需要在这里考虑的事情(我们也在我们的配置架构中这样做过)。
然后,我们根据这个 ID 加载文件实体,并使用 file.usage 服务向其添加使用情况。add() 方法的第一个参数是文件实体本身,第二个是标记此使用的模块名称,第三个是文件被使用的 东西 的类型,第四个是此 东西 的 ID。后两个取决于用例;我们选择使用自己的符号(config:importer)来清楚地表明我们正在谈论类型为 importer 的配置实体。当然,我们使用了实体的 ID。
这样,每当第一次保存此类导入器实体时,file_usage 表中都会创建一条新记录。现在让我们处理删除此实体的情况——我们不希望这个文件使用情况继续存在,对吧?
/**
* Implements hook_ENTITY_TYPE_delete() for the Importer config entity type.
*/
function products_importer_delete(\Drupal\Core\Entity\EntityInterface $entity) {
if ($entity->getPluginId() != 'csv') {
return;
}
$fid = _products_importer_get_fid_from_entity($entity);
$file = Drupal::entityTypeManager()->getStorage('file')->load($fid);
\Drupal::service('file.usage')->delete($file, 'products', 'config:importer', $entity->id());
}
在这个特定版本的 hook_entity_delete() 中,我们做的许多事情与之前相同。然而,我们正在使用 file.usage 服务的 delete() 方法,但传递相同的参数。这些 $type 和 $id 参数实际上是可选的,因此我们可以一次性“取消使用”多个文件。此外,我们还有一个可选的第五个参数(计数),我们可以通过它来特别选择从该文件中删除多个使用情况。默认情况下,这是 1,这对我们来说是有意义的。
最后,我们还想考虑用户编辑导入实体并更改 CSV 文件的情况。我们想要确保旧的文件不再被标记为用于此导入器。我们可以通过 hook_entity_update() 来实现这一点:
/**
* Implements hook_ENTITY_TYPE_update() for the Importer config entity type.
*/
function products_importer_update(\Drupal\Core\Entity\EntityInterface $entity) {
if ($entity->getPluginId() != 'csv') {
return;
}
/** @var \Drupal\products\Entity\ImporterInterface $original */
$original = $entity->original;
$original_fid = _products_importer_get_fid_from_entity($original);
if ($original_fid !== _products_importer_get_fid_from_entity($entity)) {
$original_file = Drupal::entityTypeManager()->getStorage('file')->load($original_fid);
\Drupal::service('file.usage')->delete($original_file, 'products', 'config:importer', $entity->id());
}
}
我们正在使用这个钩子的特定变体,它只为导入器实体触发。就像我们到目前为止所做的那样。正如我提到的,我们可以这样访问原始实体(在对其做出更改之前):
$original = $entity->original;
如果原始实体上的文件 ID 与我们当前保存的文件 ID 不相同(这意味着文件已更改),我们可以删除该旧文件 ID 的使用情况。
处理 CSV 文件
现在插件配置工作正常——上传的文件得到适当管理并标记为已使用——是时候实现 getData() 方法了,通过该方法我们处理导入器实体的 CSV 文件。结果需要是一个数组,其中包含我们之前看到的 import() 方法期望的产品信息。所以我们可以有如下内容:
/**
* Loads the product data from the remote URL.
*
* @return array
*/
private function getData() {
/** @var \Drupal\products\Entity\ImporterInterface $importer_config */
$importer_config = $this->configuration['config'];
$config = $importer_config->getPluginConfiguration();
$fids = isset($config['file']) ? $config['file'] : [];
if (!$fids) {
return NULL;
}
$fid = reset($fids);
/** @var \Drupal\file\FileInterface $file */
$file = $this->entityTypeManager->getStorage('file')->load($fid);
$wrapper = $this->streamWrapperManager->getViaUri($file->getFileUri());
if (!$wrapper) {
return NULL;
}
$url = $wrapper->realpath();
$spl = new \SplFileObject($url, 'r');
$data = [];
while (!$spl->eof()) {
$data[] = $spl->fgetcsv();
}
$products = [];
$header = [];
foreach ($data as $key => $row) {
if ($key == 0) {
$header = $row;
continue;
}
if ($row[0] == "") {
continue;
}
$product = new \stdClass();
foreach ($header as $header_key => $label) {
$product->{$label} = $row[$header_key];
}
$products[] = $product;
}
return $products;
}
首先,正如预期的那样,我们检查导入实体中文件 ID 的存在,并根据该 ID 加载相应的文件实体。为此,我们使用注入到插件基类中的实体管理器。但接下来出现了一些新情况。
一旦我们有了文件实体,我们可以询问它的 URI,它将返回类似这样的内容:public://products.csv。这就是存储在数据库中的内容。但是,为了将其转换为有用的东西,我们需要使用定义此文件系统的流包装器。为了获取它,我们使用StreamWrapperManager服务(stream_wrapper_manager),它有一个方便的方法可以返回负责给定 URI 的流包装器实例——getViaUri()。一旦我们有了StreamWrapperInterface,我们就可以使用它的realpath()方法来获取资源的本地路径。我们将在本章稍后回到流包装器,这将更有意义。但就目前而言,我们只需要理解我们将scheme://target格式的 URI 转换为我们可以用来创建新的 PHP 原生SplFileObject实例的有用路径,然后我们可以使用这个实例轻松地处理 CSV 文件。
在创建SplFileObject时,我们使用了文件的外部 URL。这工作得很好,我们还能展示如果需要,我们如何获取外部 URL。但是,正如我们将在下一章中看到的,它也可以直接与流 URI 一起工作,我们将切换到这种方法。
用三行代码,我们基本上就完成了将所有行从 CSV 文件中提取到$data数组中的工作。然而,我们还想让这些数据看起来更像是 JSON 资源的样子——一个键是字段名,值是相应产品数据的映射。我们还想让这个映射包含 PHP 标准对象而不是数组。因此,我们遍历数据,建立 CSV 标题值,并将这些值用作新$products数组中每一行的键。我们的最终结果将完全类似于从解码的 JSON 响应中获取的产品信息。
就这样,我们就完成了。嗯,还不完全是。我们仍然需要将StreamWrapperManager服务注入到我们的插件中。为此,我们需要确保我们注入了父类需要的所有东西,并将它们传递下去:
/**
* @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
*/
protected $streamWrapperManager;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entityTypeManager, ClientInterface $httpClient, StreamWrapperManagerInterface $streamWrapperManager) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $entityTypeManager, $httpClient);
$this->streamWrapperManager = $streamWrapperManager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('http_client'),
$container->get('stream_wrapper_manager')
);
}
以及顶部的新的use语句:
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use GuzzleHttp\ClientInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
我们还没有学到的东西。然而,这里有一件事我想指出。在第七章“你自己的自定义实体和插件类型”中,我提到了当时我认为 Guzzle HTTP 客户端是一个对所有导入插件都很有用的服务。显然,我是错的,因为我们刚刚创建的基于 CSV 的插件不需要它。所以没有必要将其注入其中。我们需要在这里做的是从基础插件类中删除这个依赖关系,并且只在 JSON 导入器中使用它。然而,我将这个任务留给你作为作业。
我们现在完成了 CSV 导入器插件。如果我们一切都做得正确,我们现在可以创建一个新的导入器实体,使用它,上传一个正确的 CSV 文件,并通过我们的 Drush 命令导入一些产品实体。多么方便。
我们的流包装器
在本章的开头,我们简要地讨论了流包装器及其用途。我们了解到 Drupal 自带了四个主流包装器,它们映射到它所需的各类文件存储。现在,让我们看看我们如何创建自己的包装器。而我们想要实现一个包装器的主要原因,是为了将特定位置的资源暴露给 PHP 的本地文件系统函数。
在这个例子中,我们将创建一个非常简单的流包装器,它基本上只能从资源中读取数据。只是为了保持简单。数据资源将是远程托管的产品图片(我们通过 JSON 导入器导入的图片)。因此,我们需要进行一些修改,以使用新的流包装器而不是绝对 URL。此外,我们还将学习如何使用全局设置服务,通过它可以设置特定环境的配置在settings.php文件中,然后由我们的代码读取。
在 PHP 中注册流包装器的本地方式是使用stream_wrapper_register()函数。然而,在 Drupal 8 中,我们有一个抽象层,以服务的形式存在。因此,流包装器是一个简单的标记服务,尽管它具有许多潜在的方法。让我们看看它的定义,我们将将其添加到products.services.yml文件中:
products.images_stream_wrapper:
class: Drupal\products\StreamWrapper\ProductsStreamWrapper
tags:
- { name: stream_wrapper, scheme: products }
没有什么太复杂的。服务被标记为stream_wrapper,我们使用scheme键来指示包装器的方案。因此,URI 将采用以下格式:
products://target
关于流包装器服务的一个重要注意事项是,我们不能向它们传递依赖项。原因是它们不是通过常规方式(通过容器)实例化的,而是在 PHP 需要调用其某些方法时任意实例化的。因此,如果我们需要使用某些服务,我们必须使用静态方式加载它们。
流包装器服务类需要实现StreamWrapperInterface接口,该接口包含许多方法。PHP 可以执行许多可能的文件系统交互,这些方法需要考虑所有这些交互。然而,我们只会关注与读取数据相关的一些特定方法。毕竟,我们的资源是远程的,我们甚至不知道如何在那里对其进行更改。因此,对于其余的方法,我们将返回 FALSE 以表示无法执行该操作。
让我们看看这个庞大的类:
namespace Drupal\products\StreamWrapper;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Stream wrapper for the remote product image paths used by the JSON Importer.
*/
class ProductsStreamWrapper implements StreamWrapperInterface {
use StringTranslationTrait;
/**
* The Stream URI
*
* @var string
*/
protected $uri;
/**
* @var \Drupal\Core\Site\Settings
*/
protected $settings;
/**
* Resource handle
*
* @var resource
*/
protected $handle;
/**
* ProductsStreamWrapper constructor.
*/
public function __construct() {
// Dependency injection does not work with stream wrappers.
$this->settings = \Drupal::service('settings');
}
/**
* {@inheritdoc}
*/
public function getName() {
return $this->t('Product images stream wrapper');
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('Stream wrapper for the remote location where product images can be found by the JSON Importer.');
}
/**
* {@inheritdoc}
*/
public static function getType() {
return StreamWrapperInterface::HIDDEN;
}
/**
* {@inheritdoc}
*/
public function setUri($uri) {
$this->uri = $uri;
}
/**
* {@inheritdoc}
*/
public function getUri() {
return $this->uri;
}
/**
* Helper method that returns the local writable target of the resource within the stream.
*
* @param null $uri
*
* @return string
*/
public function getTarget($uri = NULL) {
if (!isset($uri)) {
$uri = $this->uri;
}
list($scheme, $target) = explode('://', $uri, 2);
return trim($target, '\/');
}
/**
* {@inheritdoc}
*/
public function getExternalUrl() {
$path = str_replace('\\', '/', $this->getTarget());
return $this->settings->get('product_images_path') . '/' . UrlHelper::encodePath($path);
}
/**
* {@inheritdoc}
*/
public function realpath() {
return $this->getTarget();
}
/**
* {@inheritdoc}
*/
public function stream_open($path, $mode, $options, &$opened_path) {
$allowed_modes = array('r', 'rb');
if (!in_array($mode, $allowed_modes)) {
return FALSE;
}
$this->uri = $path;
$url = $this->getExternalUrl();
$this->handle = ($options && STREAM_REPORT_ERRORS) ? fopen($url, $mode) : @fopen($url, $mode);
return (bool) $this->handle;
}
/**
* {@inheritdoc}
*/
public function dir_closedir() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function dir_opendir($path, $options) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function dir_readdir() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function dir_rewinddir() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function mkdir($path, $mode, $options) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function rename($path_from, $path_to) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function rmdir($path, $options) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_cast($cast_as) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_close() {
return fclose($this->handle);
}
/**
* {@inheritdoc}
*/
public function stream_eof() {
return feof($this->handle);
}
/**
* {@inheritdoc}
*/
public function stream_flush() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_lock($operation) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_metadata($path, $option, $value) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_read($count) {
return fread($this->handle, $count);
}
/**
* {@inheritdoc}
*/
public function stream_seek($offset, $whence = SEEK_SET) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_set_option($option, $arg1, $arg2) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_stat() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_tell() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_truncate($new_size) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_write($data) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function unlink($path) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function url_stat($path, $flags) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function dirname($uri = NULL) {
return FALSE;
}
}
首先需要关注的是构造函数,我们在其中静态加载Settings服务并将其存储为类属性。说到这里,我们还定义了一个$uri属性来保存这个包装器实际封装的 URI,以及一个$handle属性来保存通用的 PHP 资源句柄。
getName()和getDescription()方法相当直接,用于识别流包装器,而getType()方法返回流的类型。我们将使用隐藏类型,因为我们不希望在 UI 中可见。它是严格用于程序性使用的,这样我们就可以读取我们的产品图片。请通过查看StreamWrapperInterface常量来检查可用的类型及其含义。
然后,我们有$uri属性的 getter 和 setter,通过它 Drupal 的StreamWrapperManager可以根据给定的 URI 创建我们包装器的实例。getTarget()方法实际上不在接口中,但它是一个辅助方法,用于从 URI 中提取干净的标靶(标靶是 URI 的第二部分,位于scheme://之后)。我们在getExternalUrl()中使用此方法,这是一个相当重要的方法,负责返回资源的绝对 URL。但在这里,我们也使用我们的Settings服务来获取product_images_path键。如果你记得在章节开头,我们看到公共文件系统的路径是在settings.php文件中定义的,如下所示:
$settings['file_public_path'] = 'sites/default/files';
$settings变量是由Settings服务包装的数据数组。因此,我们希望为定义自己的产品图片远程路径做同样的事情:
$settings['product_images_path'] = 'http://path/to/the/remote/product/images';
这样我们就不需要在 Git 中提交实际的远程 URL,我们也可以在需要时更改它。这正是我们在getExternalUrl()方法中读取的 URL。
我们只读流包装器的另一个支柱是能够打开资源文件句柄,并允许我们从其中读取数据。stream_open()方法就是这样做的,当我们对 URI 运行file_get_contents()或fopen()时,它会调用。使用$mode参数,我们确保操作是只读的,否则返回FALSE——我们不支持写入或其他标志。
任何模式都可以附加b来表示文件应以二进制模式打开。所以,r表示只读,rb表示以二进制模式只读。
第三个参数是由 PHP 定义的选项掩码。我们这里处理的是STREAM_REPORT_ERRORS,它表示是否应该抑制 PHP 错误(例如,如果文件未找到)。第二个是STREAM_USE_PATH,它表示如果文件未找到,是否应该检查 PHP 的包含路径。这与我们无关,所以我们忽略它。如果在包含路径上找到文件,那么第四个参数($opened_url),应该设置为文件的实际路径。
我们所做的是将 URI 转换为外部资源的绝对 URL,以便我们可以打开文件句柄。在这个过程中,我们使用STREAM_REPORT_ERRORS选项来决定是否在fopen()函数前添加@符号(这样做会抑制错误)。最后,我们存储资源句柄的引用,并根据它返回一个布尔值来指示操作是否成功。
最后,我们还实现了stream_read()、stream_eof()和stream_close()方法,这样我们实际上也可以流式传输资源。至于其他方法,如前所述,我们返回FALSE。
我们现在需要做的就是清除缓存并利用我们的流。只要我们在settings.php文件中声明了一个有效的 URL,我们的流应该可以正常工作。以下是我们可以使用此类 URI 进行的一些操作:
$uri = 'products://tv.jpg';
要将整个文件内容放入字符串中,我们可以这样做:
$contents = file_get_contents($uri);
或者我们可以使用本章开头的例子,逐比特流文件:
$handle = fopen($uri, 'r');
$contents = '';
while (!feof($handle)) {
$contents .= fread($handle, 8192);
}
fclose($handle);
所有这些文件操作,如打开、读取、检查文件末尾和关闭,都是由于我们在包装器中实现的stream_*()方法。
最后,也许现在写 CSV 导入器和使用StreamWrapperManager来识别给定 URI 负责的流包装器,以及基于此,URI 的真实路径,也变得更清晰了。
为了结束关于流包装器的部分,让我们通过重构一下我们的JsonImporter::handleProductImage()方法来做一些清理工作。我们那里的逻辑涉及硬编码远程 API 的 URL,这实际上并不是一个好主意。现在,既然我们有流包装器,我们可以继续使用它。我们可以替换这个:
// This needs to be hardcoded for the moment.
$image_path = '';
$image = file_get_contents($image_path . '/' . $name);
用这个:
$image = file_get_contents('products://' . $name);
这很简单。现在我们可以从 Git 仓库外部控制远程 URL,如果它发生变化,我们甚至不需要更改我们的代码。当然,仅为此目的,实现流包装器似乎有点过度。毕竟,你可以简单地注入Settings服务,并在导入插件本身中使用 URL,从而实现相同的灵活性。但我们利用这个机会学习了流包装器以及如何创建自己的包装器。我们甚至在过程中找到了一个小型的用例。
与非管理文件一起工作
与非管理文件一起工作实际上与处理管理文件非常相似,只是它们不是使用文件实体类型在数据库中跟踪。有一组类似于我们之前看到的管理文件的辅助函数,可以通过我之前提到的FileSystem服务访问。让我们看看一些例子。
要保存新文件,我们几乎和之前处理管理文件时一样:
$image = file_get_contents('products://tv.jpg');
// Load the service statically for quick demonstration.
$file_system = \Drupal::service('file_system');
$path = $file_system->saveData($image, 'public://tv.jpg', FileSystemInterface::EXISTS_REPLACE);
我们从任何地方加载文件数据,并在服务上使用saveData()方法,就像我们使用file_save_data()一样。区别在于文件将被保存,但不会创建数据库记录。因此,唯一使用它的方法就是依赖于它保存的路径,或者尝试从浏览器访问它,或者用于我们需要的任何目的。此方法返回文件现在保存的 URI 或如果操作有问题则返回FALSE。所以如果前面的示例一切顺利,$path现在将是public://tv.jpg。
就像管理文件一样,我们也在该服务中提供了一些其他有用的方法,例如move()、copy()和delete()。我建议您检查该服务以获取有关这些方法如何工作的更多详细信息。
私有文件系统
当我们想要控制下载文件的访问权限时,会使用私有文件系统。使用默认的公共存储,用户只需在浏览器中指向文件即可访问它们,从而绕过 Drupal。然而,.htaccess规则阻止用户直接访问私有存储中的任何文件,因此有必要创建一个提供请求文件的路径。不言而喻,后者性能要差得多,因为每次需要加载 Drupal 才能访问每个文件。因此,只有在基于某些标准需要限制文件访问时,才重要地使用它。
Drupal 已经自带了一个用于下载私有文件的路径和控制器,但如果我们真的需要,我们也可以创建一个。例如,图像模块就是这样做的,以便控制图像样式的创建和下载——ImageStyleDownloadController。
默认 Drupal 路径的路由定义看起来是这样的:
system.files:
path: '/system/files/{scheme}'
defaults:
_controller: 'Drupal\system\FileDownloadController::download'
scheme: private
requirements:
_access: 'TRUE'
这是一个有点奇怪的路径定义。我们有一个{scheme}参数,但将是实际请求下载的文件路径。URI 方案本身默认为private,如FileDownloadController::download()的签名所示。此外,始终允许访问,因为 Drupal 将此检查委托给其他模块——我们将在下一分钟看到。
如果我们查看FileDownloadController::download()内部,我们可以看到它实际上并没有做很多。然而,我们也注意到在第一行,它寻找名为file的查询参数,以便获取请求文件的 URI:
$target = $request->query->get('file');
但根据路由定义,我们甚至没有这个参数。这就是路径处理器发挥作用的地方,更具体地说,是InboundPathProcessorInterface的实现。这些是带有标签的服务,当路由系统根据请求的路径构建路由时会被调用。本质上,它们允许在路径到来时对其进行修改。对于 Drupal 7 的老手来说,这些可以比作hook_url_inbound_alter()的实现。
核心的系统模块实现了自己的路径处理器,用于处理私有文件的下载:
path_processor.files:
class: Drupal\system\PathProcessor\PathProcessorFiles
tags:
- { name: path_processor_inbound, priority: 200 }
这是一个简单的标记服务定义,其类需要实现一个正确接口,该接口有一个方法。在PathProcessorFiles的情况下,它看起来是这样的:
/**
* {@inheritdoc}
*/
public function processInbound($path, Request $request) {
if (strpos($path, '/system/files/') === 0 && !$request->query->has('file')) {
$file_path = preg_replace('|^\/system\/files\/|', '', $path);
$request->query->set('file', $file_path);
return '/system/files';
}
return $path;
}
这种方法的目的是返回一条路径,这条路径可以是请求的路径,或者由于任何原因而改变。Drupal 在这里所做的是检查路径是否是之前定义的路径(以/system/files/开头)并提取作为第一个参数的请求文件路径。然后,它将这个路径添加到当前请求参数中,以file作为键。最后,它返回一个简单的路径,即/system/files。这就是为什么FileDownloadController::download()方法会在那里查找文件路径的原因。
回到控制器,我们看到它基本上检查文件是否存在,如果找不到,则抛出 404(NotFoundHttpException)。否则,它调用hook_file_download(),允许所有模块控制文件的访问。它们可以通过两种方式做到这一点:要么返回-1,拒绝访问,要么返回一个包含头部的数组来控制特定文件的下载。默认情况下,私有文件系统中的文件不能下载,除非特定的模块允许这样做。
这意味着什么?如果我们有一个在私有文件系统中的文件,我们需要实现hook_file_download()并控制对其的访问。让我们通过假设我们有一个名为/pdfs的文件夹,我们希望让具有administer site configuration权限的用户可以访问其中的文件来查看这个例子可能的工作方式:
/**
* Implements hook_file_download().
*/
function module_name_file_download($uri) {
$file_system = \Drupal::service('file_system');
$dir = $file_system->dirname($uri);
if ($dir !== 'private://pdfs') {
return NULL;
}
if (!\Drupal::currentUser()->hasPermission('administer site configuration')) {
return -1;
}
return [
'Content-type' => 'application/pdf',
];
}
这个钩子接收一个参数,即请求的文件的 URI。基于这个 URI,我们尝试获取它所在的文件夹名称。为此,我们再次使用file_system服务。
如果文件不在/pdfs文件夹内的私有文件系统中,我们简单地返回NULL以表示我们不控制对这个文件的访问。其他模块可能这样做(如果没有模块这样做,则访问被拒绝)。如果是我们的文件,我们检查所需的权限,如果用户没有这个权限,则返回-1。这将拒绝访问。最后,如果允许访问,我们返回一个包含我们希望在文件传输中使用的头部的数组。在我们的例子中,我们简单地使用 PDF 特定的头部,这些头部有助于在浏览器中显示 PDF 文件。如果我们想触发文件下载,我们可以这样做:
$name = $file_system->basename($uri);
return [
'Content-Disposition' => "attachment;filename='$name'"
];
我们使用文件系统服务来确定请求的文件名,并相应地调整我们的头部,将其视为必须下载的附件。
这就是全部内容。如果我们想要更多的控制(或者不同的文件下载路径),我们可以实现自己的路由并遵循相同的方法。当然,不需要调用钩子,只需在控制器方法中处理下载即可。例如,这就是FileDownloadController::download()如何处理实际响应的:
return new BinaryFileResponse($uri, 200, $headers, $scheme !== 'private');
当我们希望将文件发送到浏览器且文件直接来自 Symfony 时,会使用此类响应。
图像
在本节中,我们将深入探讨 Drupal 8 中的图像世界,同时保持对模块开发者的关注。
图像工具包
Drupal 8 图像工具包为处理图像的常见操作提供了一个抽象层。默认情况下,Drupal 使用包含在 PHP 中的 GD 图像管理库。然而,它还提供了通过使用ImageToolkit插件来切换到不同库的能力:

例如,一个贡献的模块可以为需要支持 GD 不支持的其他图像类型(如 TIFF)的开发者实现ImageMagick库。然而,一次只能使用一个库,因为它需要全局配置。
使用工具包程序化地处理图像涉及实例化一个包装图像文件的ImageInterface对象。此接口(由Image类实现)包含应用常见操作到图像的所有所需方法,以及将结果图像保存到文件系统。要获取此类对象,我们使用ImageFactory服务:
$factory = \Drupal::service('image.factory');
此工厂的作用是使用给定的工具包创建Image实例。它的工作方式如下:
$image = $factory->get($uri);
此方法的第二个参数是我们希望Image对象与之一起工作的ImageToolkit插件 ID。默认情况下,它使用为整个应用程序配置的默认工具包。
现在我们可以使用ImageInterface上的操作方法来更改文件:
$image->scale(50, 50);
$image->save('public://thumbnail.jpg');
在这个例子中,我们将图像缩放到 50 x 50 像素,并将其保存到新的路径。在save()方法中省略目标路径意味着将更改后的版本覆盖原始文件。如果您需要手动执行此类操作,我鼓励您探索ImageInterface提供的所有可用选项。
图像样式
尽管我们已经看到我们可以自己程序化处理图像操作,但通常这是作为Image Styles的一部分完成的,这些样式可以通过 UI 创建和配置。它们的工作方式与 Drupal 7 中类似,涉及应用几个可能的Image Effects以创建在不同地方使用的图像变体。Drupal 8 提供了与 Drupal 7 相同的三个默认图像样式:

图像样式本身是存储与它们所工作的ImageEffect插件相关的特定配置的配置实体。一旦在 UI 中创建,我们就可以以各种方式使用它们。最典型的方式是使用图像样式在实体字段的显示配置中,甚至在渲染图像字段时使用视图。
如果您还记得,在本章的开头,我们在产品实体上创建了图像字段,但我们没有配置显示。因此,目前,导入的图像不会显示在主产品页面上。但我们可以向我们的基本字段定义中添加一些显示配置,以便以特定的图像样式显示图像:
->setDisplayOptions('view', array(
'type' => 'image',
'weight' => 10,
'settings' => [
'image_style' => 'large'
]
))
在这个例子中,我们正在使用默认的image字段格式化插件,该插件可以配置为使用图像样式。因此,在settings键下,我们引用了large图像样式配置实体,它实际上是 Drupal 核心的一部分。省略此选项将简单地渲染原始图像。确保您回顾了第七章,您自己的自定义实体和插件类型,以及第九章,自定义字段,如果您对基本字段定义有些模糊。
渲染图像
在第四章,主题化中,我们讨论了主题钩子以及我们如何在渲染数组中使用它们来构建输出。我们还看到了一些 Drupal 核心提供的主题钩子示例,这些钩子可以用于常见事物(如链接或表格)。但图像也是我们经常会渲染的东西,我们可以通过两种方式做到这一点(两种都使用由 Drupal 核心定义的主题钩子)。
首先,我们可以使用image主题钩子简单地渲染一个图像。使用它相当简单:
return [
'#theme' => 'image',
'#uri' => 'public://image.jpg',
];
这样就会以原始形式渲染图像。我们还可以传递一些其他选项,如alt、title、width或height,所有这些都将作为属性应用于图像标签,以及我们可能想要的任何其他类型的属性数组。有关如何工作的更多信息,请查看template_preprocess_image()。
或者,Image模块定义了image_style主题钩子,我们可以使用它来使用给定的图像样式渲染图像:
return [
'#theme' => 'image_style',
'#uri' => 'public://image.jpg',
'#style_name' => 'large',
];
这个主题钩子的工作方式几乎相同,但它有一个额外的参数,用于我们想要使用的ImageStyle实体的 ID。其余的参数我们可以在image主题钩子中找到。实际上,image_style在底层委托给image主题钩子。
最后,我们可能会发现自己处于这样一种情况:需要使用给定的图像样式获取图像的 URL。为此,我们需要与ImageStyle配置实体一起工作:
$style = \Drupal::entityTypeManager()->getStorage('image_style')->load('thumbnail');
$url = $style->buildUrl('public://image.jpg');
一旦我们加载了想要的图像样式,我们只需调用它的buildUrl()方法,并将我们想要 URL 的文件的 URI 传递给它。第一次访问此 URL 时,图像变体会被创建并存储到磁盘上。未来的请求将直接从那里加载,以提高性能。
摘要
我们在涵盖了与 Drupal 8 中文件操作相关的大量不同主题后关闭了这一章。
我们从几个介绍性章节开始,概述了一些一般概念,例如 Drupal 8 使用的各种文件系统(存储),以及流包装器如何用于处理它们。我们还介绍了处理文件的不同方式:管理与非管理。
接下来,我们深入探讨了如何使用管理文件,并在我们的产品实体类型上创建了一个图像字段,以便我们可以将其导入其中。另一个使用管理文件的例子是,我们基于数据 CSV 文件创建了一个新的产品导入器,我们还看到了如何上传、读取和处理此类文件,以及如何手动跟踪其使用情况。作为一个旁注,我们介绍了一个非常强大的 Drupal 8 功能,它允许我们挂钩到实体 CRUD 操作,并在这些操作被触发时执行操作。这是模块开发者通常在 Drupal 中使用的关键技术。
然后,我们转换了方向,并实现了我们自己的流包装器来服务于我们的虚拟远程 API,该 API 存储产品图像。此外,我们还讨论了如何使用非管理文件以及我们可以用于此的一些函数——与管理文件类似的东西,但函数名称不同,并且没有文件实体或使用跟踪。
我们接着讨论了私有文件系统,并探讨了它的用途以及如何用它来控制对我们自己文件的访问。这与允许用户绕过 Drupal 并从公共文件系统中下载文件相反。
最后,我们通过查看围绕图像的 API 以及如何使用工具包来处理图像(无论是手动还是作为图像样式的一部分)来结束这一章。更有用的一点是,我们看到了如何在 Drupal 8 中以各种方式渲染图像,并获取图像样式 URL。
在下一章和最后一章中,我们将探讨自动化测试以及如何确保我们的代码正常工作,并在过程中不引入回归。
第十七章:自动化测试
自动化测试是一个过程,我们依靠特殊的软件来持续运行预定义的测试,以验证我们应用程序的完整性。为此,自动化测试是一系列覆盖应用程序功能并比较触发结果与预期结果的步骤。
手动测试是确保所编写的功能按预期工作的一种很好的方法。大多数采用这种策略的人遇到的主要问题是回归。一旦某个功能被测试,他们唯一能保证没有引入回归(或错误)的方法是重新测试它。随着应用程序的增长,这变得难以处理。这就是自动化测试发挥作用的地方。
自动化测试使用具有 API 的特殊软件,该 API 允许我们自动化测试功能中涉及的步骤。这意味着我们可以依赖机器运行这些测试,次数不限,阻止我们拥有一个完全工作的应用程序的唯一因素是缺乏适当的测试覆盖率以及定义良好的测试。
有很多不同的软件可用于执行此类测试,并且通常针对特定类型的自动化测试。例如,Behat 是一个基于 PHP 的强大开源行为测试框架,允许编写与手动测试人员所做非常相似的测试脚本——通过浏览器与应用程序交互并测试其行为。还有其他测试框架在测试目标的级别上更低。例如,PHP 行业标准工具 PHPUnit 广泛用于执行单元测试。这种类型的测试专注于尽可能低级别的实际代码;通过提供不同的输入来验证类方法是否正常工作,并检查它们的输出。这种测试的强烈论点是它鼓励更好的代码架构,这可以通过编写单元测试的容易程度(部分地)来衡量。
我们还有功能测试或集成测试,它们介于上述两种示例之间。这些测试高于代码级别,将应用程序子系统纳入其中,以测试更全面的功能集,而不必考虑浏览器行为和用户交互。
同意一个经过良好测试的应用程序应结合不同的测试方法并不困难。例如,测试应用程序的各个架构单元并不能保证整个子系统正常工作,就像只测试子系统并不能保证其各个组件在所有情况下都能正常工作。同样,对于依赖于用户交互的某些子系统也是如此——这些子系统也需要测试覆盖率。
在本章中,我们将了解 Drupal 8 中自动化测试的工作原理。更具体地说,我们将逐一解释所有可用的测试方法,并以每个测试为例进行说明。到本章结束时,您将准备好编写自己的测试,并对代码足够熟悉,以便进一步探索可用的测试功能。
Drupal 8 的测试方法
与许多其他开发方面一样,在 Drupal 8 中,自动化测试得到了极大的改进。在之前的版本中,测试框架是一个专门为测试 Drupal 应用程序定制的自定义框架——Simpletest。其主要测试能力集中在功能测试上,并强调用户与伪浏览器的交互。然而,它相当强大,允许测试广泛的功能。
Drupal 8 的开发也是从 Simpletest 开始的。然而,随着 PHPUnit 的采用,Drupal 正在远离它,并正在逐步淘汰它。为了替代它,有一系列不同类型的测试——所有这些测试都由 PHPUnit 运行——可以覆盖更多的测试方法。那么,让我们看看这些是什么。
Drupal 8 包含以下类型的测试:
-
Simpletest:由于遗留原因而存在,但不再用于创建新的测试。这将在 Drupal 9 中被移除。
-
单元测试:使用最小依赖(通常为模拟)的低级别类测试。
-
内核测试:使用启动的内核、数据库访问以及仅加载的几个模块进行的功能测试。
-
功能测试:使用启动的 Drupal 实例、一些已安装的模块以及基于 Mink 的浏览器模拟器(Goutte 驱动器)进行的功能测试。
-
功能 JavaScript:与之前的类似,使用 Selenium 驱动器进行 Mink 测试,允许测试由 JavaScript 驱动的功能。
除了 Simpletest 之外,所有这些测试套件都是建立在 PHPUnit 之上的,因此由它运行。根据测试类所在的命名空间以及目录位置,Drupal 可以发现这些测试并知道它们的类型。
在本章中,我们将随着测试我们在本书中编写的某些功能,看到所有这些示例(除了 Simpletest)。
PHPUnit
Drupal 8 使用 PHPUnit 作为所有类型测试的测试框架。在本节中,我们将了解如何与之合作来运行测试。
在您的开发环境(或您想要运行测试的任何地方),请确保您已使用 --dev 标志安装了 composer 依赖项。这将包括 PHPUnit。请记住,永远不要在生产环境中这样做,因为这可能会危及您应用程序的安全性。
虽然 Drupal 有一个用于运行测试的用户界面,但 PHPUnit 与该界面集成得并不好。因此,建议我们使用命令行来运行它们。实际上,这样做非常简单。要运行整个测试套件(某种类型的),我们必须导航到 Drupal 核心文件夹:
cd core
然后运行以下命令:
../vendor/bin/phpunit —testsuite=unit
这个命令通过供应商目录回退一个文件夹,并使用安装的phpunit可执行文件。作为一个选项,在之前的例子中,我们指定了只想运行单元测试。省略这一点将运行所有类型的测试。然而,对于大多数其他测试,将需要一些配置,正如我们将在相应的部分中看到的那样。
如果我们想运行特定的测试,我们可以将其作为参数传递给phpunit命令(文件的路径):
../vendor/bin/phpunit tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php
在这个例子中,我们运行了一个 Drupal 核心测试,该测试用于测试UrlGenerator类。
或者,我们可以运行属于同一组的多个测试(我们很快就会看到如何将测试添加到组中):
../vendor/bin/phpunit —group=Routing
这将运行来自Routing组的所有测试,实际上它包含了我们之前看到的UrlGeneratorTest。如果我们用逗号分隔它们,我们也可以运行来自多个组的测试。
此外,为了检查可用的组,我们可以运行以下命令:
../vendor/bin/phpunit —list-groups
这将列出所有已注册到 PHPUnit 的组。
最后,我们还可以通过使用—filter参数来运行测试中的特定方法:
../vendor/bin/phpunit —filter=testAliasGenerationUsingInterfaceConstants
这是之前看到的同一个UrlGeneratorTest中的一个测试方法,并且是唯一会运行的方法。
注册测试
在不同的测试套件类型之间,关于我们需要做什么以便 Drupal(以及 PHPUnit)能够发现和运行它们,有一些共同之处。
首先,我们需要确定测试类应该放在哪个目录中。模式是这样的:tests/src/[suite_type],其中[suite_type]是这个测试套件类型的名称,这个测试应该属于这个类型。它可以有以下几种:
-
单元
-
核心库
-
功能测试
-
功能 JavaScript 测试
例如,单元测试应该放在我们模块的tests/src/Unit文件夹中。
第二,测试类还需要遵循一个命名空间结构:
namespace Drupal\Tests\[module_name]\[suite_type]
这一点也很容易理解。
第三,我们需要在测试类的 PHPDoc 中包含某些元数据。每个类都必须有一行总结,描述测试类的用途。只有使用@coversDefaultClass属性的类可以省略总结行。此外,所有测试类都必须有@group PHPDoc 注释,指出它们所属的组。这就是 PHPUnit 如何只运行属于某些组的测试。
既然我们已经知道了如何注册和运行测试,让我们来看看单元测试,看看我们如何编写自己的测试。
单元测试
如开头简要提到的,单元测试用于测试构成代码架构的单个单元。在实践中,这意味着测试单个类,特别是它们包含的方法以及它们应该做什么。由于测试发生在如此低级别,它们是目前可以运行的最快的测试。
单元测试背后的逻辑相当简单:在提供输入后,测试断言方法输出是正确的。通常,它覆盖的输入 -> 输出场景越多,测试的代码就越稳定。例如,测试还应涵盖意外场景,以及练习测试方法中包含的所有代码(例如由if/else语句创建的分支)。
依赖注入的编程模式——对象应该接收它们可能需要的其他对象作为依赖项——在单元测试中变得至关重要。原因是如果类方法与全局作用域一起工作或实例化其他对象,我们就无法干净地测试它们。相反,如果它们需要依赖项,我们可以模拟它们,并在执行测试的上下文中传递这些依赖项。我们很快就会看到一些示例。但在我们这样做之前,让我们创建一个可以轻松使用单元测试进行测试的简单类。
一个典型的例子是一个简单的计算器类。它将接受两个数字作为构造函数的参数,并具有四个方法来对这些数字执行基本算术运算。我们将把它放入我们的Hello World模块中:
namespace Drupal\hello_world;
/**
* Class used to demonstrate a simple Unit test.
*/
class Calculator {
private $a;
private $b;
public function __construct($a, $b) {
$this->a = $a;
$this->b = $b;
}
public function add() {
return $this->a + $this->b;
}
public function subtract() {
return $this->a - $this->b;
}
public function multiply() {
return $this->a * $this->b;
}
public function divide() {
return $this->a / $this->b;
}
}
这里没有这么复杂。你可以争论说计算器类不应该有任何依赖,而是应该将数字传递给实际的算术方法。然而,这在我们示例中也能很好地工作,并且稍微少一些重复。
现在,让我们创建第一个单元测试,以确保这个类表现如我们所期望。在上一个部分中,我们看到了这些测试需要放入哪个目录。所以,在我们的情况下,它将是/tests/src/Unit。测试类看起来像这样:
namespace Drupal\Tests\hello_world\Unit;
use Drupal\hello_world\Calculator;
use Drupal\Tests\UnitTestCase;
/**
* Tests the Calculator class methods.
*
* @group hello_world
*/
class CalculatorTest extends UnitTestCase {
/**
* Tests the Calculator::add() method.
*/
public function testAdd() {
$calculator = new Calculator(10, 5);
$this->assertEquals(15, $calculator->add());
}
/**
* Tests the Calculator::subtract() method.
*/
public function testSubtract() {
$calculator = new Calculator(10, 5);
$this->assertEquals(5, $calculator->subtract());
}
/**
* Tests the Calculator::multiply() method.
*/
public function testMultiply() {
$calculator = new Calculator(10, 5);
$this->assertEquals(50, $calculator->multiply());
}
/**
* Tests the Calculator::divide() method.
*/
public function testDivide() {
$calculator = new Calculator(10, 5);
$this->assertEquals(2, $calculator->divide());
}
}
首先,你会注意到命名空间与我们在上一章中看到的模式相对应。其次,PHPDoc 包含所需的信息:摘要和@group标签。第三,类名以单词Test结尾。最后,该类扩展了UnitTestCase,这是我们为所有单元测试需要扩展的基类。
在 Drupal 8 中,所有类型的测试类名称都需要以单词Test结尾,并扩展提供该类型测试特定代码的相关基类。
然后,我们有实际的方法来测试Calculator类的各个方面,并且这些方法总是必须以单词test开头。这就是告诉 PHPUnit 它们需要被执行。这些方法是实际的独立测试本身,这意味着CalculatorTest类有四个测试。此外,这些测试都是独立于其他测试运行的。
由于计算器的算术运算非常简单,理解我们如何测试它并不困难。对于每种方法,我们使用一些数字实例化一个新的实例,然后我们断言算术运算的结果等于我们预期的结果。基类提供了许多不同的断言方法,我们可以在测试中使用。由于它们有很多,我们在这里不会全部涵盖。随着我们编写更多的测试,我们将看到更多。但我强烈建议您检查各种测试套件的基类,看看是否有以单词assert开头的方法。一个很好的方法也是使用一个在你输入方法名时自动补全的 IDE。这可以非常方便。
这样,我们就可以运行测试并查看它是否通过。通常情况下,它应该会通过,因为我们可以在脑海中做数学运算,并且我们知道它是正确的:
../vendor/bin/phpunit ../modules/custom/hello_world/tests/src/Unit/CalculatorTest.php
结果应该是绿色的:
OK (4 tests, 4 assertions)
然而,我之前提到,一个好的测试也应该考虑到意外情况和负面响应。然而,在我们的例子中,我们并没有做得很好。如果我们看看testAdd(),我们可以看到使用这两个数字时断言是正确的。但如果我们后来不小心将Calculator::add()方法改为这样:
return 15;
测试仍然会通过,但它实际上是一个真正的阳性吗?并不一定,因为如果我们传递不同的数字,计算结果将不再匹配。因此,我们应该使用不止一组数字来测试这些方法,以实际证明计算器类背后的数学是有效的。
因此,我们可以做类似这样的事情:
$calculator = new Calculator(10, 5);
$this->assertEquals(15, $calculator->add());
$calculator = new Calculator(10, 6);
$this->assertEquals(16, $calculator->add());
这样,我们就可以确保加法操作是正确的。在这个方法中,一个权衡是,我们有一些重复的代码,特别是如果我们必须对所有其他操作也这样做的话。
通常,在编写测试时,重复性比编写实际代码时更被接受。很多时候,你对此无能为力,因为代码看起来非常重复。然而,在我们的情况下,我们可以通过使用setUp()方法来实际做些事情,这个方法是在 PHPUnit 运行每个测试方法之前被调用的。它的目的是执行对类中所有测试都通用的各种准备任务。然而,不要认为它只运行一次然后被所有测试使用。实际上,它在每个单独的测试方法运行之前都会运行。
因此,我们可以做的是类似这样的事情:
/**
* @var \Drupal\hello_world\Calculator
*/
protected $calculatorOne;
/**
* @var \Drupal\hello_world\Calculator
*/
protected $calculatorTwo;
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$this->calculatorOne = new Calculator(10, 5);
$this->calculatorTwo = new Calculator(10, 2);
}
我们创建两个类属性,并在setUp()方法中将它们分配给我们的计算器对象。需要记住的一个重要事情是始终调用此方法的父调用,因为它为环境设置做了非常重要的事情。特别是当我们转向 Kernel 和功能测试时。
现在,testAdd()方法可以看起来像这样:
public function testAdd() {
$this->assertEquals(15, $this->calculatorOne->add());
$this->assertEquals(12, $this->calculatorTwo->add());
}
更加简洁,重复性更低。基于此,你可以自己推断并应用相同的更改到其他方法。
模拟依赖
被测试的类很少像我们的计算器类那样简单。大多数情况下,它们将具有依赖关系,而这些依赖关系反过来也有依赖关系。因此,单元测试变得稍微复杂一些。事实上,编写单元测试的容易程度已经成为被测试代码质量的一个试金石——单元测试越简单,代码质量越好。
作为编写单元测试的第二个示例,让我们进入“现实世界”,并测试我们在这本书中编写的其中一个类,即 UserTypesAccess 类。如果你记得从第十章 访问控制,我们创建了这个服务,用于在路由上作为访问检查器。虽然我们可以编写功能测试来验证它作为访问系统一部分的工作情况,但我们也可以编写一个单元测试来检查 access() 方法中的实际代码。所以让我们开始吧。
我们需要做的第一件事是创建类(同时尊重目录放置以及类命名空间):
namespace Drupal\Tests\user_types\Unit;
use Drupal\Tests\UnitTestCase;
/**
* Tests the UserTypesAccess class methods.
*
* @group user_types
*/
class UserTypesAccessTest extends UnitTestCase {}
到目前为止,事情看起来像我们之前的例子——我们有 PHPDoc 信息,并且正在扩展 UnitTestCase 类。所以让我们为 UserTypesAccess 类的 access() 方法编写一个测试。然而,如果你记得,这个方法接受两个参数(一个用户账户和一个路由对象),并且还使用了注入到类中的实体类型管理器。这就是我们复杂性的大部分所在。我们需要测试的是方法返回值取决于这些参数。基本上,如果用户账户在路由上具有某些值,它将允许或拒绝访问。
在单元测试中,依赖项通常被模拟。这意味着 PHPUnit 将创建空的外观对象,它们的行为就像我们描述的那样,我们可以将这些用作依赖项。创建简单模拟对象的方法如下:
$user = $this->createMock('Drupal\user\Entity\User');
$user 对象现在将成为 Drupal 8 User 实体类的模拟。当然,它不会做任何事情,但它可以用作依赖项。但是,为了使其真正有用,我们需要根据测试代码对它的使用来指定一些行为。例如,如果它调用了其 id() 方法,我们需要指定这种行为。我们可以通过 预期 来做到这一点:
$user->expects($this->any())
->method('id')
->will($this->returnValue(1));
这告诉模拟对象,对于对它的 id() 方法的每次调用,它应该返回值 1。expects() 方法接受一个匹配器,它可以更加限制性。例如,我们不仅可以使用 $this->any(),还可以使用 $this->once(),这意味着模拟对象的 id() 方法只能被调用一次。查看基类以获取其他可用选项,以及可以传递给 will() 方法的选项——尽管 $this->returnValue() 将是最常见的一个。最后,如果 id() 方法接受一个参数,我们还可以使用 with() 方法,将预期的参数值传递给匹配器。
创建模拟对象的一种更复杂的方式是使用模拟构建器:
$user = $this->getMockBuilder('Drupal\user\Entity\User')
->getMock();
这将获取相同的模拟对象,但将允许在它的构建中拥有更多选项。我建议查看 PHPUnit 文档以获取更多信息,因为这是我们在这本书中关于模拟对象将要深入探讨的深度。
现在我们对模拟有了些了解,我们可以继续编写我们的测试。为此,我们需要考虑最终目标,并从所有需要模拟的方法调用回溯。提醒一下,这是我们需要测试的代码:
public function access(AccountInterface $account, Route $route) {
$user_types = $route->getOption('_user_types');
if (!$user_types) {
return AccessResult::forbidden();
}
if ($account->isAnonymous()) {
return AccessResult::forbidden();
}
$user = $this->entityTypeManager->getStorage('user')->load($account->id());
$type = $user->get('field_user_type')->value;
return in_array($type, $user_types) ? AccessResult::allowed() : AccessResult::forbidden();
}
因此,乍一看,我们需要模拟EntityTypeManager。我们将手动使用一些虚拟数据实例化其方法参数。然而,模拟EntityTypeManager将会相当复杂。对其getStorage()方法的调用需要返回一个UserStorage对象。这也需要被模拟,因为对其load()方法的调用需要返回一个User实体对象。最后,我们也需要模拟它,因为对其get()方法的调用预期也将返回一个值对象。
正如我提到的,我们将从我们的最终目标开始回溯。因此,我们可以从实例化我们想要传递的AccountInterface对象类型以及路由对象开始:
/**
* Tests the UserTypesAccess::access() method.
*/
public function testAccess() {
// User accounts
$anonymous = new UserSession(['uid' => 0]);
$registered = new UserSession(['uid' => 2]);
// Route definitions.
$manager_route = new Route('/test_manager', [], [], ['_user_types' => ['manager']]);
$board_route = new Route('/test_board', [], [], ['_user_types' => ['board']]);
$none_route = new Route('/test_board');
}
以及顶部的新的use语句:
use Drupal\Core\Session\UserSession;
use Symfony\Component\Routing\Route;
基本上,我们想要测试两种用户类型的情况:匿名用户和注册用户。在实例化UserSession对象(这些对象实现了AccountInterface接口)时,我们传递一些与它一起存储的数据。在我们的情况下,我们需要用户uid,因为测试代码在检查用户是否匿名时将请求它。
然后,我们创建三个路由:一个管理器应该可以访问的路由,一个董事会成员应该可以访问的路由,以及一个没有人可以访问的路由(如路由上的_user_types选项所示)。如果你不记得这个功能是什么,请回顾第十章,访问控制。
一旦完成这个步骤,接下来就是实例化我们的UserTypesAccess类,以便使用各种组合的账户和路由对象调用其access()方法:
$access = new UserTypesAccess($entity_type_manager);
以及顶部的新的use语句:
use Drupal\user_types\Access\UserTypesAccess;
然而,我们还没有实体类型管理器,因此我们需要对其进行模拟。以下是我们需要模拟实体类型管理器以使其为测试代码工作所需的所有代码(这些代码在我们迄今为止编写的测试代码之前):
// User entity mock.
$type = new \stdClass();
$type->value = 'manager';
$user = $this->getMockBuilder('Drupal\user\Entity\User')
->disableOriginalConstructor()
->getMock();
$user->expects($this->any())
->method('get')
->will($this->returnValue($type));
// User storage mock
$user_storage = $this->getMockBuilder('Drupal\user\UserStorage')
->disableOriginalConstructor()
->getMock();
$user_storage->expects($this->any())
->method('load')
->will($this->returnValue($user));
// Entity type manager mock.
$entity_type_manager = $this->getMockBuilder('Drupal\Core\Entity\EntityTypeManager')
->disableOriginalConstructor()
->getMock();
$entity_type_manager->expects($this->any())
->method('getStorage')
->will($this->returnValue($user_storage));
首先,你会注意到实体类型管理器只是在最后才被模拟。我们首先需要启动调用链,该链以用户实体对象字段值为结束。因此,第一个块模拟了用户实体对象,它期望对其get()方法进行任意次数的调用,并将始终返回一个具有value属性等于manager字符串的stdClass()对象。这样我们就模拟了实体字段系统访问器。
在使用模拟构建器创建模拟时,我们可以使用 disableOriginalConstructor() 方法来防止 PHPUnit 调用原始类的构造函数。这很重要,以防止需要所有各种其他依赖项,而这些依赖项实际上并不影响被测试的代码。
现在我们有了用户实体模拟,我们可以将其用作 UserStorage 模拟的 load() 方法的返回值。这反过来又是实体类型管理模拟的 getStorage() 方法的返回值。因此,我们编写的所有代码意味着我们已经模拟了以下链:
$this->entityTypeManager->getStorage('user')->load($account->id());
我们传递给 load() 方法的参数实际上并不重要,因为我们始终有一个具有 manager 用户类型的用户实体。
现在一切都已模拟,我们可以使用之前创建的 $access 对象,并根据对其 access() 方法的调用进行断言:
// Access denied due to lack of route option.
$this->assertInstanceOf('Drupal\Core\Access\AccessResultForbidden', $access->access($registered, $none_route));
// Access denied due to user being anonymous on any of the routes
$this->assertInstanceOf('Drupal\Core\Access\AccessResultForbidden', $access->access($anonymous, $manager_route));
$this->assertInstanceOf('Drupal\Core\Access\AccessResultForbidden', $access->access($anonymous, $board_route));
// Access denied due to user not having proper field value
$this->assertInstanceOf('Drupal\Core\Access\AccessResultForbidden', $access->access($registered, $board_route));
// Access allowed due to user having the proper field value.
$this->assertInstanceOf('Drupal\Core\Access\AccessResultAllowed', $access->access($registered, $manager_route));
返回值始终是一个实现接口的对象——要么是 AccessResultAllowed,要么是 AccessResultForbidden,因此这是我们需要断言的内容。我们正在检查四个不同的用例:
-
如果没有路由选项,则拒绝访问
-
任何路由上的匿名用户访问被拒绝
-
对于具有错误用户类型的注册用户,拒绝访问
-
对于注册用户且具有正确用户类型的情况,允许访问
因此,我们可以运行测试,并希望得到一个绿色的结果:
../vendor/bin/phpunit ../modules/custom/user_types/tests/src/Unit/UserTypesAccessTest.php
这就是编写单元测试的基础。在 Drupal 8 中,还有更多类型的断言,你最终会模拟很多依赖项。但不要因为一开始遇到的缓慢速度而气馁,随着经验的积累,事情会变得更快。
内核测试
内核测试是 Drupal 8 中我们可以拥有的直接高级测试方法,实际上是集成测试,专注于测试各种组件。它们比常规功能测试更快,因为它们不执行完整的 Drupal 安装,而是使用一个内存中的伪安装,启动速度更快。因此,它们也不处理任何浏览器交互,并且不会自动安装任何模块。
除了代码本身之外,内核测试还与数据库一起工作,并允许我们加载运行测试所需的模块。然而,与下一节中我们将看到的函数测试不同,内核测试还要求我们手动触发所需数据库模式的安装。但我们将看到在本节中涵盖的两个示例中如何做到这一点。
然而,在我们能够进行内核测试之前,我们需要确保我们有一个数据库连接,并且 PHPUnit 意识到这一点。在我们的 Drupal 安装 core 文件夹中,我们找到一个 phpunit.xml.dist 文件,我们需要将其复制并重命名为 phpunit.xml。这是 PHPUnit 的配置文件。通常,这个文件应该已经被 Git 忽略,因此无需担心将其提交到仓库。
在此文件中,我们找到一个名为SIMPLETEST_DB的环境变量,我们可以使用以下注释代码中展示的格式来指定数据库连接:
mysql://username:password@localhost/databasename#table_prefix
一旦设置好,PHPUnit 将能够连接到数据库,以便为内核测试以及功能测试和功能 JavaScript 测试安装 Drupal。
根据经验法则,当不涉及浏览器交互且内核测试足以完成任务时,你应该始终选择内核测试而不是功能测试。这是因为充满测试的套件可能会花费很长时间运行,所以你应该尽可能提高其性能。
TeamCleaner 测试
现在我们已经覆盖了这些内容,是时候编写我们的第一个内核测试了。一个简单的好例子可以是对我们在第十四章中创建的TeamCleaner和QueueWorker插件进行测试,批处理、队列和 Cron。如果你想知道为什么不能使用超快的单元测试方法进行测试,答案是它的单一方法不返回任何内容。相反,它改变了我们需要访问以检查其是否正确发生的数据库值。
测试类自然位于我们的模块的tests/src/Kernel文件夹中,可以开始如下:
namespace Drupal\Tests\sports\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Test the TeamCleaner QueueWorker plugin.
*
* @group sports
*/
class TeamCleanerTest extends KernelTestBase {}
命名空间与我们之前看到的保持一致,并且我们有正确的 PHPDoc 注释来注册测试。此外,这次,我们是从KernelTestBase扩展的。请注意这个类的实际版本,因为来自旧 Simpletest 框架的版本也称为KernelTestBase。所以请确保你扩展的是在use语句中看到的正确版本。
我们需要做的第一件事是指定在运行此测试时要加载哪些模块。在我们的例子中,这是sports模块,因此我们可以添加一个包含此名称的类属性:
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['sports'];
在此处指定模块列表实际上并不安装它们,只是将它们加载并添加到服务容器中。所以是的,我们可以访问模块和代码以及容器。但这也意味着这些模块定义的模式实际上并没有创建,因此我们需要手动完成。同样,对于模块附带配置也是如此。但我们可以将这些事情处理在setUp()方法中或在实际的测试方法本身中。我们将选择后者,因为在这种情况下,我们只有一个测试方法在类中。整个事情可以看起来像这样:
/**
* Tests the TeamCleaner::processItem() method.
*/
public function testProcessItem() {
$this->installSchema('sports', 'teams');
$database = $this->container->get('database');
$fields = ['name' => 'Team name'];
$id = $database->insert('teams')
->fields($fields)
->execute();
$records = $database->query("SELECT id FROM {teams} WHERE id = :id", [':id' => $id])->fetchAll();
$this->assertNotEmpty($records);
$worker = new TeamCleaner([], NULL, NULL, $database);
$data = new \stdClass();
$data->id = $id;
$worker->processItem($data);
$records = $database->query("SELECT id FROM {teams} WHERE id = :id", [':id' => $id])->fetchAll();
$this->assertEmpty($records);
}
以及use语句:
use Drupal\sports\Plugin\QueueWorker\TeamCleaner;
由于TeamCleaner插件会删除团队,因此只需安装该表就足够了。我们可以使用父installSchema()方法来做到这一点,我们将模块名称和要安装的表传递给它。我们实际上不处理玩家,因此我们应该避免进行不必要的操作,如创建players表。
然后,非常类似于我们在实际代码中这样做,我们从容器中获取 database 服务并向 teams 表添加一条记录。这将是我们将要删除的测试记录,这样我们就能记住它的 $id。但在测试之前,我们想确保我们的记录确实被保存了。因此,我们查询它并断言结果不为空。assertNotEmpty() 方法是我们在处理数组时可以使用的另一个有用的断言。
现在我们确定记录已经在数据库中,我们可以使用我们的插件来“处理”它。因此,我们实例化一个 TeamCleaner 对象,传递所有其所需的依赖项——最重要的是数据库服务。然后我们创建一个简单的对象,模拟 processItem() 方法所期望的,并调用后者,同时将前者传递给它。在这个时候,如果我们的插件正确执行了其任务,团队记录应该已经被从数据库中删除。因此,我们可以查询它,这次断言与之前相反:查询结果为空。
有了这个,我们的测试就结束了。像往常一样,我们应该实际运行它并确保它通过:
../vendor/bin/phpunit ../modules/custom/sports/tests/src/Kernel/TeamCleanerTest.php
这是一个非常简单的例子,展示了如何使用内核测试来测试一个组件,特别是那些与数据库集成的组件。我们也可以使用功能测试,但这会有些过度——它会运行得更慢,并且无法利用它相对于内核测试的优势,比如浏览器集成。
CsvImporter 测试
在这个简单的例子之后,让我们再写一个测试,来展示一个更复杂的场景。我们将写一个测试来测试我们在上一章中创建的 CsvImporter 插件。
这个插件中包含了很多功能,与之一起工作——我们有实际的导入、插件和配置实体创建、用户界面等等。这是一个很好的例子,说明了可以从多方法测试覆盖中受益的功能。在这方面,我们从测试其基本目的开始,即产品导入,这不需要浏览器交互。这意味着我们可以使用内核测试。
与我们编写的前一个测试类似,我们可以从类开始(这次在 products 模块中):
namespace Drupal\Tests\products\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests the CSV Product Importer
*
* @group products
*/
class CsvImporterTest extends KernelTestBase {}
到目前为止,没有新的内容。
接下来,我们需要指定需要加载的模块。这里有一个更长的列表:
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['system', 'csv_importer_test', 'products', 'image', 'file', 'user'];
只有 products 模块可能对你来说很明显,但所有其他模块也都是必需的。system、image、file 和 user 模块都是处理 CsvImporter 插件所需的文件上传和存储过程所必需的。
并非总是那么容易确定需要哪些模块,所以这可能会涉及一些试错,至少在开始时是这样。一个典型的场景是运行测试并注意到由于缺少功能而导致的失败。将此功能追踪到模块并在列表中指定此模块通常是您获得完整模块列表的方法,尤其是当测试复杂且需要广泛的子系统及其依赖项时。
但你可能想知道csv_importer_test模块在那里有什么用。通常,你可能需要创建仅用于测试的模块——通常是因为它们包含一些你希望在测试中使用的配置。在我们的案例中,我们这样做是为了演示这些模块将放在哪里,并添加一个products.csv测试文件,我们可以在测试中使用它。
测试模块位于包含使用它们的测试的模块的tests/modules文件夹中。因此,在我们的案例中,我们有csv_importer_test及其info.yml文件:
name: CSV Importer Test
description: Used for testing the CSV Importer
core: 8.x
type: module
package: Testing
我们将要使用的提到的 CSV 文件就在它的旁边:
id,name,number
1,Car,45345
2,Motorbike,54534
现在我们已经讨论了这一点,我们可以编写测试方法:
/**
* Tests the import of the CSV based plugin.
*/
public function testImport() {
$this->installEntitySchema('product');
$this->installEntitySchema('file');
$this->installSchema('file', 'file_usage');
$manager = $this->container->get('entity_type.manager');
$products = $manager->getStorage('product')->loadMultiple();
$this->assertEmpty($products);
$csv_path = drupal_get_path('module', 'csv_importer_test') . '/products.csv';
$csv_contents = file_get_contents($csv_path);
$file = file_save_data($csv_contents, 'public://simpletest-products.csv', FileSystemInterface::EXISTS_REPLACE);
$config = $manager->getStorage('importer')->create([
'id' => 'csv',
'label' => 'CSV',
'plugin' => 'csv',
'plugin_configuration' => [
'file' => [$file->id()]
],
'source' => 'Testing',
'bundle' => 'goods',
'update_existing' => true
]);
$config->save();
$plugin = $this->container->get('products.importer_manager')->createInstanceFromConfig('csv');
$plugin->import();
$products = $manager->getStorage('product')->loadMultiple();
$this->assertCount(2, $products);
$products = $manager->getStorage('product')->loadByProperties(['number' => 45345]);
$this->assertNotEmpty($products);
$this->assertCount(1, $products);
}
以及顶部的use语句:
use Drupal\Core\File\FileSystemInterface;
这里的初始设置稍微复杂一些,部分原因是因为内核测试没有安装模块模式。使用父installEntitySchema()方法,我们可以安装产品实体和文件内容实体所需的所有必要表。然而,由于我们正在处理管理文件,我们还需要手动安装file_usage表。从技术上讲,它不是一个实体表。再次强调,使用试错法到达这些步骤并不丢脸。
现在我们已经设置了基础,我们进行一次合理性检查,确保数据库中没有产品实体。我们没有理由应该有任何,但确保这一点并无害处。这保证了测试的有效性,因为我们的目标将是后来断言产品的存在。
然后,我们通过使用来自csv_importer_test模块的products.csv文件创建一个管理的文件实体。drupal_get_path()函数是一种非常常见的检索模块或主题的相对路径的方法,无论它实际上位于何处。我们将此文件的 内容保存到测试环境的public://文件系统中。但请记住,一旦测试成功运行,此文件将被删除,因为 Drupal 会自行清理。
接下来,我们需要创建一个使用基于 CSV 的插件来运行导入的导入器配置实体。而不是通过 UI 进行,我们以编程方式完成。使用存储管理器,我们创建实体,就像我们在第六章“数据建模和存储”中学到的那样。一旦我们有了这个,我们就使用导入器插件管理器根据这个配置实体(我们给它分配了 ID csv)创建一个实例。最后,我们运行产品的导入。
现在,对于断言,我们进行双重检查。由于我们的测试 CSV 文件包含两行,我们再次加载所有产品实体并断言总数为两个。不多也不少。在这里,我们看到了另一种用于处理数组的实用断言方法:assertCount()。但接下来我们更加具体,尝试加载一个字段值(即number)等于测试 CSV 文件中预期数值的产品。并断言它确实被找到。
我们甚至可以进行更多的断言。例如,我们可以检查所有产品字段值是否已正确设置。我将让您探索如何进行此操作——要么基于这些值进行查询,要么断言字段值与其预期值之间的相等性。但重要的是不要过度,因为这会影响速度,在某些情况下,还会给测试覆盖率增加不足的价值,以补偿它。诀窍是找到正确的平衡。
最后,随着我们的测试已经就绪,我们实际上可以运行它:
../vendor/bin/phpunit ../modules/custom/products/tests/src/Kernel/CsvImporterTest.php
这个测试也应该通过。
功能测试
在上一节中,我们讨论了内核测试,并表示它们基本上是集成测试,侧重于组件而不是与浏览器的交互。在本节中,我们将上升一个层次,讨论全面的功能测试,也称为浏览器测试(从我们需要扩展的基本类名称)。
Drupal 8 中的功能测试使用模拟浏览器(使用流行的 Mink 模拟器),允许用户点击链接、导航到页面、处理表单以及就页面上的 HTML 元素做出断言。它们不允许我们测试基于 JavaScript 的交互(有关这些内容,请参阅下一节)。
在 Drupal 7 中,功能测试是最常用的测试类型,大多数类都扩展了 Simpletest 的WebTestBase类。但在 Drupal 8 中,我们有Drupal\Tests\BrowserTestBase类,它像之前看到的那些一样与 PHPUnit 集成。基类包含大量用于断言的方法以及执行 Drupal(和 Web)相关任务的快捷方式:创建用户、实体、导航到页面、填写和提交表单、登录等。就像之前一样,每个测试(类方法)都是独立运行的,因此像内容和用户这样的东西不能在多个测试之间共享,而必须重新创建(可能使用我们之前看到的setUp()方法)。
浏览器测试使用具有最少数量的模块(使用Testing安装配置文件)执行完整的 Drupal 安装。这意味着我们可以指定安装其他模块,这些模块的架构也会被安装。此外,重要的是要理解,生成的安装与我们的当前开发站点没有任何共同之处。我们需要的所有配置,我们都需要创建。没有用户,没有内容,也没有文件。因此,这是一个全新的、并行的安装,它在单个测试的持续时间内运行,并在完成后被清理。
功能测试的配置
在编写我们的功能测试之前,我们需要回到我们的phpunit.xml文件并更改一些环境变量。除了我们之前调整的SIMPLETEST_DB变量外,我们还有SIMPLETEST_BASE_URL和BROWSERTEST_OUTPUT_DIRECTORY。第一个用于知道应用程序在浏览器中可以访问的位置。后者是 PHPUnit 可以保存输出数据的目录,需要是一个绝对本地路径(例如,本地files文件夹中的一个文件夹):
/var/www/sites/default/files/browser-output
此外,确保运行测试的用户有权限写入sites/simpletest文件夹,因为虚拟文件系统就是在这里为每个测试创建的。最简单的方法是将文件夹的所有权更改为运行该进程的 Web 服务器用户。在 Apache 的情况下,这通常是www-data。
Hello World 页面测试
我们将要编写的第一个功能测试是针对我们创建的Hello World页面及其背后的功能。我们将测试页面是否显示了正确的Hello World消息,这也取决于配置中找到的值。所以让我们创建一个类,自然是在hello_world模块中,在tests/src/Functional文件夹内:
namespace Drupal\Tests\hello_world\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Basic testing of the main Hello World page.
*
* @group hello_world
*/
class HelloWorldPageTest extends BrowserTestBase {}
你真的可以看到与其他测试类型的连贯性。但在这个案例中,正如之前提到的,我们扩展了BrowserTestBase。
同样,就像之前一样,我们可以配置我们想要安装的模块数量:
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['hello_world', 'user'];
我们将需要在运行的第二个测试中使用用户模块,它将和这个模块属于同一类。但让我们先进行第一个,更简单的测试:
/**
* Tests the main Hello World page.
*/
public function testPage() {
$expected = $this->assertDefaultSalutation();
$config = $this->config('hello_world.custom_salutation');
$config->set('salutation', 'Testing salutation');
$config->save();
$this->drupalGet('/hello');
$this->assertSession()->pageTextNotContains($expected);
$expected = 'Testing salutation';
$this->assertSession()->pageTextContains($expected);
}
如果你记得,我们的/hello页面会根据一天中的时间显示问候语,除非管理员通过配置表覆盖了该消息。因此,我们从这个测试开始,断言在一个没有覆盖的新安装中,我们看到基于时间的问候语。为此,我们创建了一个单独的断言消息,因为它有点长,我们将重用它:
/**
* Helper function to assert that the default salutation is present on the page.
*
* Returns the message so we can reuse it in multiple places.
*/
private function assertDefaultSalutation() {
$this->drupalGet('/hello');
$this->assertSession()->pageTextContains('Our first route');
$time = new \DateTime();
$expected = '';
if ((int) $time->format('G') >= 00 && (int) $time->format('G') < 12) {
$expected = 'Good morning';
}
if ((int) $time->format('G') >= 12 && (int) $time->format('G') < 18) {
$expected = 'Good afternoon';
}
if ((int) $time->format('G') >= 18) {
$expected = 'Good evening';
}
$expected .= ' world';
$this->assertSession()->pageTextContains($expected);
return $expected;
}
我们在这里做的第一件事是使用drupalGet()方法导航到网站上的一个路径。请检查方法签名,看看你可以传递给它所有选项。我们做的第一个断言是页面包含文本我们的第一个路由(这是页面标题)。父assertSession()方法返回一个WebAssert实例,它包含所有 sorts of 方法,用于断言在 Mink 会话中当前页面上元素的存在。其中一种方法是通用的pageTextContains(),我们只需检查给定的文本是否可以在页面的任何地方找到。
虽然在许多情况下断言文本字符串的存在就足够了,但你可能想确保它确实是正确的(以避免误报)。例如,在我们的案例中,我们可以检查它是否真的是在<h1>标签内渲染的页面标题。我们可以这样做:
$this->assertSession()->elementTextContains('css', 'h1', 'Our first route');
elementTextContains()方法可以根据定位器(CSS 选择器或 xpath)在页面上找到元素,并断言它包含指定的文本。在我们的例子中,我们使用 CSS 选择器定位器,并尝试找到<h1>元素。
如果所有这些都正常,我们就继续断言实际的问候消息出现在页面上。不幸的是,我们必须重复相当多的代码,因为它是依赖于一天中的时间的。对你来说,一个好的家庭作业就是将这个逻辑提取到一个确定消息的服务中,并在这个服务和实际代码中使用这个服务。而且由于我们稍后需要这个消息,我们也返回它。
回到我们的实际测试方法,我们可以继续进行,知道消息已经在页面上正确显示。接下来,我们想要测试的是以下内容:如果存在一个具有salutation值的hello_world.custom_salutation配置对象,那么应该显示的就是这个。因此,我们程序性地创建它。接下来,我们再次导航到相同的路径(我们实际上是在重新加载页面)并检查旧消息不再显示,而新消息则显示出来。
所以如果我们实际运行这个测试:
../vendor/bin/phpunit ../modules/custom/hello_world/tests/src/Functional/HelloWorldPageTest.php
...该死。我们得到了一个错误:
Behat\Mink\Exception\ResponseTextException: The text "Good evening world" appears in the text of this page, but it should not.
就好像我们没有覆盖问候消息一样。但我们确实覆盖了。
问题在于缓存。请记住,我们作为匿名用户在这些页面上导航,并且网站上启用了缓存,就像在正常情况下一样。在第十一章“缓存”中,我记录了这个问题——max-age属性只对动态页面缓存(登录用户)的页面级别有效,而不是对匿名用户。
这是一个自动测试如何揭示我们在开发过程中引入的错误,而这些错误我们没有注意到的绝佳例子。我们很可能在禁用缓存和/或始终以登录用户身份访问页面时编写了我们的功能。所以这是一个很容易犯的错误。幸运的是,自动测试来救命。
这个问题的解决方案可以通过使用全出缓存关闭开关来实现。这意味着我们需要稍微修改我们的逻辑,告诉 Drupal 永远不缓存显示我们的问候语组件的页面。这是我们必须付出的代价,因为我们功能的高度动态性,而且始终是一个好的练习来评估这是否值得。
关闭开关实际上很容易使用。它是一个我们需要注入到我们的HelloWorldSalutation服务中的服务:
/**
* @var \Drupal\Core\PageCache\ResponsePolicy\KillSwitch
*/
protected $killSwitch;
/**
* HelloWorldSalutation constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher
* @param \Drupal\Core\PageCache\ResponsePolicy\KillSwitch $killSwitch
*/
public function __construct(ConfigFactoryInterface $config_factory, EventDispatcherInterface $eventDispatcher, KillSwitch $killSwitch) {
$this->configFactory = $config_factory;
$this->eventDispatcher = $eventDispatcher;
$this->killSwitch = $killSwitch;
}
以及顶部适当的 use 声明:
use Drupal\Core\PageCache\ResponsePolicy\KillSwitch;
在getSalutation()和getSalutationComponent()方法的开始处,我们只需添加这一行:
$this->killSwitch->trigger();
这将告诉 Drupal 的内部页面缓存永远不要缓存这个页面。但在我们再次运行测试之前,我们别忘了将page_cache_kill_switch服务作为依赖项添加到hello_world.services.yml中的HelloWorldSalutation服务中。现在如果我们运行这个测试,我们应该得到一个绿色结果。
Hello World 表单测试
我们将要编写的第二个功能测试应该测试问候语覆盖表单本身。在上一个测试中,我们直接与配置 API 交互来更改配置值。现在我们将看到执行此操作的表单是否真的起作用。但由于我们可以从上一个测试中重用很多内容,并且它们非常紧密相关,我们可以将其添加到同一个类中:
/**
* Tests that the configuration form for overriding the message works.
*/
public function testForm() {
$expected = $this->assertDefaultSalutation();
$this->drupalGet('/admin/config/salutation-configuration');
$this->assertSession()->statusCodeEquals(403);
$account = $this->drupalCreateUser(['administer site configuration']);
$this->drupalLogin($account);
$this->drupalGet('/admin/config/salutation-configuration');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Salutation configuration');
$this->assertSession()->elementExists('css', '#edit-salutation');
$edit = [
'salutation' => 'My custom salutation',
];
$this->drupalPostForm(NULL, $edit, 'op');
$this->assertSession()->pageTextContains('The configuration options have been saved');
$this->drupalGet('/hello');
$this->assertSession()->pageTextNotContains($expected);
$this->assertSession()->pageTextContains('My custom salutation');
}
我们以同样的方式开始这个测试,断言显示基于小时的问候消息。这也证明了每个测试都在自己的独立环境中运行,一个测试中的配置更改不会影响另一个。它们都是从一张白纸开始的。
然后我们导航到配置表单页面并断言我们没有访问权限。为此,我们使用statusCodeEquals()断言方法来检查响应代码。这是好的,因为我们需要用具有特定权限的用户登录。
配置表单的访问限制允许任何具有特定权限的用户。因此,我们的测试应该专注于那个权限,而不是可能间接包含这个权限的其他东西。例如,它不应该假设具有管理员角色的用户有那个权限。
因此,我们使用方便的 drupalCreateUser() 方法创建一个新的用户账户,其第一个参数是一个数组,包含用户应该拥有的权限。然后我们可以使用 drupalLogin() 方法使用生成的用户实体进行登录。在底层,这将导航到用户登录页面,提交表单,并断言一切顺利。现在我们可以回到配置表单页面,并且应该有访问权限——这也是我们断言的内容。此外,我们断言页面上有页面标题,并且有问候文本字段的 HTML 元素。我们使用 elementExists() 方法这样做,使用 CSS 选择器定位符,就像我们在之前的测试中所做的那样。再次提醒,查看 WebAssert 了解各种断言方法,这些方法可以帮助你识别页面上的内容。
现在是时候提交表单并覆盖问候消息了。我们使用 drupalPostForm() 来完成这项任务,其最重要的参数是一个数组,包含要填充表单元素的值,以单个表单 HTML 元素的 name 参数为键。在我们的例子中,我们只有一个。务必查看该方法的文档,以获取有关你可以用它做什么的所有信息。一旦表单提交,页面将重新加载,我们可以断言确认消息的存在。最后,我们可以回到 /hello 路径并断言旧消息不再显示,而是显示新的覆盖消息。
再次运行测试类时,应该包括这个新测试,并且所有内容都应该显示为绿色。但速度明显慢得多,因为已经完成了两个完整的 Drupal 安装。在下一节中,我们将引入 JavaScript,以便我们可以测试更动态的浏览器集成。但已经可以注意到,如果你不需要与浏览器交互,内核测试的运行速度会快得多。
功能性 JavaScript 测试
在 Drupal 8 中,我们可以编写的最后一种测试类型是 JavaScript 驱动的功能测试。当我们需要测试更动态的客户端功能,如 JavaScript 行为或 Ajax 交互时,功能 JavaScript 测试非常有用。
它们是常规功能测试的扩展,但使用 WebDriver。后者是一个 API,允许像 Selenium 这样的工具控制 Chrome 或 Firefox 等浏览器。Drupal 使用 Chrome 来完成这项任务,所以请确保你已经安装并配置了 Selenium 和 Chrome 驱动程序。我们在这里不会涉及这部分内容,因为它取决于你的本地环境和当前最新版本。
假设你已经运行了 Selenium,我们可以编写一些测试。但只有在我们将另一个环境变量添加到 PHPUnit 配置文件之后:
<env name="MINK_DRIVER_ARGS_WEBDRIVER" value='["chrome", null, "http://localhost:4444/wd/hub"]'/>
时间测试
如果你记得从第十二章 [JavaScript 和 Ajax API],我们向 Hello World 问候组件添加了一个小的时间小部件,如果问候没有被覆盖,它会实时显示当前的小时。这个组件由 JavaScript 驱动,更重要的是,使用 JavaScript 添加到页面中。
此外,在前一节中,我们为 Hello World 页面编写了一个功能测试,其中我们断言了问候信息的存在。然而,实际的时间小部件永远不会出现在那里,因为在这些类型测试中使用的 Mink 驱动程序不支持 JavaScript。所以如果我们想测试这一点,我们需要编写一个功能 JavaScript 测试。
如预期的那样,这些类型的测试遵循相同的目录放置和命名空间模式。所以我们的第一个测试类可以开始如下:
namespace Drupal\Tests\hello_world\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Testing the simple Javascript timer on the Hello World page.
*
* @group hello_world
*/
class TimeTest extends WebDriverTestBase {}
到现在,上述代码中的大部分应该已经很清晰了。然而,我们这次扩展的基类是 WebDriverTestBase 类,它本身是 BrowserTestBase 的子类。有趣的是,它实际上并没有添加很多功能,除了配置测试使用 Selenium Web Driver 和添加一些特定的 JavaScript 辅助方法。这是为了说明,功能测试和功能 JavaScript 测试之间的大部分差异是由实际的 Mink 驱动程序造成的。
注意,直到 Drupal 8.1,JavaScript 测试的默认驱动程序是 Phantom.js,因此你可能会找到对这个的过时引用。但我们仍然走在曲线的前面,使用最新的 Web Driver API 与 Selenium 和 Chrome 来运行我们的测试。
然而,一个极其方便的添加功能是截图功能。很多时候在测试前端交互时,事情并不像我们想象的那样进行,我们也不理解为什么。父 createScreenshot() 方法允许我们在任何给定时刻保存整个页面的截图,我们可以用来进行调试。我们只需要传入我们想要保存的文件名。所以请务必检查一下。
继续进行我们的测试,让我们添加我们想要启用的模块:
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['hello_world'];
如预期的那样,Hello World 模块就足够了。非常简单的测试方法可以看起来像这样:
/**
* Tests the time component.
*/
public function testTime() {
$this->drupalGet('/hello');
$this->assertSession()->pageTextContains('The time is');
$config = $this->config('hello_world.custom_salutation');
$config->set('salutation', 'Testing salutation');
$config->save();
$this->drupalGet('/hello');
$this->assertSession()->pageTextNotContains('The time is');
}
我们正在使用与之前完全相同的断言技术,但由于启用了 JavaScript,时间小部件文本现在应该会显示出来。而且,就像之前一样,我们也测试了如果覆盖了问候方法,时间小部件不会显示。
CsvImporter 测试
当学习关于内核测试时,我们为 CsvImporter 编写了一个测试,该测试侧重于在现有的导入器配置实体(我们通过编程创建)的情况下导入功能。然而,这个功能的重要角度之一是创建这个配置实体的过程,因为我们依赖于 Ajax 来动态注入与所选导入器插件相关的表单元素。所以,让我们也为此编写一个测试。
就像之前一样,测试类可以从以下内容开始:
namespace Drupal\Tests\products\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Testing the creation/edit of Importer configuration entities using the CSV importer
*
* @group products
*/
class ImporterFormTest extends WebDriverTestBase {}
像往常一样,让我们启用一些模块:
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['image', 'file', 'node'];
你可能想知道为什么,例如,products模块不在那个列表中。在撰写本文时,它不起作用,因为启用它时抛出了一个与依赖项相关的错误(缺少由image模块定义的插件)。因此,我们也可以直接在我们的测试或setUp()方法中启用模块。这正是我们将要做的。
节点模块已启用,因为它定义了access content权限,该权限被核心machine_name表单元素使用。该元素用于导入实体表单,因此我们需要它才能使测试真正工作。
尽管我们只编写了一个测试方法,但为此进行的准备工作相当多,我们可能希望在其他地方重用。此外,将其与实际测试方法分离看起来也更整洁。因此,我们可以将其添加到setUp()方法中:
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$this->container->get('module_installer')->install(['products', 'csv_importer_test']);
$csv_path = drupal_get_path('module', 'csv_importer_test') . '/products.csv';
$csv_contents = file_get_contents($csv_path);
$this->file = file_save_data($csv_contents, 'public://simpletest-products.csv', FileSystemInterface::EXISTS_REPLACE);
$this->admin = $this->drupalCreateUser(['administer site configuration']);
$this->bundle = ProductType::create(['id' => 'goods', 'label' => 'Goods']);
$this->bundle->save();
}
以及新的使用语句:
use Drupal\products\Entity\ProductType;
use Drupal\Core\File\FileSystemInterface;
如预期的那样,我们首先安装products和csv_importer_test模块。我们使用ModuleInstaller服务来这样做。然后,我们像先前的测试中那样做——从csv_importer_test模块加载测试 CSV 文件并将其“上传”到 Drupal,创建一个新的管理文件实体。
然后,我们创建一个具有创建导入配置实体所需权限的管理员用户账户,以及一个用于产品实体的包,以便我们实际上可以创建产品。在先前的测试中,我们不需要担心包,因为我们以编程方式创建了导入配置。但现在,通过 UI,必须存在一个包才能选择它。
我们存储在类属性上的结果文件实体、管理员用户账户和 ProductType 配置实体,因此我们也应该定义这些:
/**
* @var \Drupal\file\FileInterface
*/
protected $file;
/**
* @var \Drupal\Core\Session\AccountInterface
*/
protected $admin;
/**
* @var \Drupal\products\Entity\ProductType
*/
protected $bundle;
使用这个,我们就准备好编写我们的空测试方法并逐步填充它:
/**
* Tests the importer form.
*/
public function testForm() {}
我们可以从基础知识开始:
$this->drupalGet('/admin/structure/importer/add');
$assert = $this->assertSession();
$assert->pageTextContains('Access denied');
我们导航到创建导入配置实体的表单,并断言用户没有访问权限。这是因为默认情况下,我们是以匿名用户身份浏览的。接下来,我们需要登录并再次尝试:
$this->drupalLogin($this->admin);
$this->drupalGet('/admin/structure/importer/add');
$assert->pageTextContains('Add importer');
$assert->elementExists('css', '#edit-label');
$assert->elementExists('css', '#edit-plugin');
$assert->elementExists('css', '#edit-update-existing');
$assert->elementExists('css', '#edit-source');
$assert->elementExists('css', '#edit-bundle');
$assert->elementNotExists('css', 'input[name="files[plugin_configuration_plugin_file]"]');
我们使用相同的drupalLogin()方法并导航回表单。这次我们断言我们有标题以及各种 HTML 元素——用于创建实体的表单元素。此外,我们还断言我们没有上传 CSV 文件的元素,因为那应该只在我们选择使用 CSV 导入插件时显示。
因此,我们确实这样做了:
$page = $this->getSession()->getPage();
$page->selectFieldOption('plugin', 'csv');
$this->assertSession()->assertWaitOnAjaxRequest();
$assert->elementExists('css', 'input[name="files[plugin_configuration_plugin_file]"]');
使用getSession()方法,我们获取当前的 Mink 会话,从中我们可以获取代表我们正在查看的实际页面的对象。这是一个DocumentElement对象,可以通过各种方式遍历、检查和操作。我建议您查看TraversableElement类以了解所有可用方法。
其中一种方法是selectFieldOption(),我们可以指定 HTML 选择元素的定位器(ID、名称或标签)和一个值,它将触发选择。正如你所知,这应该会触发一个 Ajax 请求,引入我们的新表单元素。通过在JSWebAssert对象上使用assertWaitOnAjaxRequest(),我们可以等待它完成。最后,我们可以断言文件上传字段出现在页面上。
接下来,我们继续填写表单:
$page->fillField('label', 'Test CSV Importer');
$this->assertJsCondition('jQuery(".machine-name-value").html() == "test_csv_importer"');
$page->checkField('update_existing');
$page->fillField('source', 'testing');
$page->fillField('bundle', $this->bundle->id());
$wrapper = $this->container->get('stream_wrapper_manager')->getViaUri($this->file->getFileUri());
$page->attachFileToField('files[plugin_configuration_plugin_file]', $wrapper->realpath());
$this->assertSession()->assertWaitOnAjaxRequest();
$page->pressButton('Save');
$assert->pageTextContains('Created the Test CSV Importer Importer.');
通用fillField()方法对于文本字段等很有用,而checkField()方法则预期对复选框很有用。两者的定位器都是元素的 ID、名称或标签。
我们还使用assertJsCondition方法让执行等待页面上的 JavaScript 发生变化。我们这样做是为了确保实体机器名字段已被当前填写。
接下来,借助我们上传的文件的流包装器,更具体地说,是其realpath()方法,我们使用attachFileToField()方法将文件附加到字段上。这触发了一个 Ajax 请求,我们再次等待其完成。最后,我们使用pressButton()方法点击提交按钮,并断言打印出了确认消息(表单已保存且页面已刷新)。
现在来检查操作是否真正正确完成:
$config = Importer::load('test_csv_importer');
$this->assertInstanceOf('Drupal\products\Entity\ImporterInterface', $config);
$fids = $config->getPluginConfiguration()['file'];
$fid = reset($fids);
$file = File::load($fid);
$this->assertInstanceOf('Drupal\file\FileInterface', $file);
以及新的使用语句:
use Drupal\file\Entity\File;
use Drupal\products\Entity\Importer;
我们使用给定的 ID 加载配置实体,然后断言生成的对象是正确接口的实例。这检查我们实际上是否保存了实体。接下来,我们根据在导入器配置实体中找到的 ID 加载文件实体,并断言它本身也实现了正确的接口。这证明了文件实际上已保存且配置正确。
而不是以相同的方式程序化地检查其余字段值,我们选择导航到导入器实体的编辑表单并断言值已正确预填充:
$this->drupalGet('admin/structure/importer/test_csv_importer/edit');
$assert->pageTextContains('Edit Test CSV Importer');
$assert->fieldValueEquals('label', 'Test CSV Importer');
$assert->fieldValueEquals('plugin', 'csv');
$assert->checkboxChecked('update_existing');
$assert->fieldValueEquals('source', 'testing');
$page->hasLink('products.csv');
$bundle_field = $this->bundle->label() . ' (' . $this->bundle->id() . ')';
$assert->fieldValueEquals('bundle', $bundle_field);
fieldValueEquals()和checkboxChecked()方法对于检查字段值很有用。此外,我们还使用hasLink()方法检查页面上是否存在具有该名称的链接。这实际上是为了证明上传的文件显示正确:

最后,由于捆绑字段是一个引用字段而不是简单的文本字段,我们需要构建测试框架实际看到的值,其模式如下:标签 (ID)。
有了这个,我们的测试就完成了,我们可以运行整个测试:
../vendor/bin/phpunit ../modules/custom/products/tests/src/Kernel/CsvImporterTest.php
摘要
在本章中,我们简要讨论了 Drupal 8 中的自动化测试。我们首先介绍了编写自动化测试为什么有用且实际上很重要,然后简要概述了几种流行的软件开发测试方法。
Drupal 8 通过集成 PHPUnit 框架,在测试方面相对于其前辈具有优势。正如我们所看到的例子,它具有相当多的方法论能力。我们有单元测试——这是测试中最低级别的形式,它专注于单个架构单元,并且是所有测试中运行速度最快的。然后我们有内核测试,这是集成测试,专注于较低级别的组件及其交互。接下来,我们有功能测试,这是较高级别的测试,专注于与浏览器的交互。最后,我们有功能 JavaScript 测试,它扩展了后者,并将 Selenium 和 Chrome 纳入其中,以便测试依赖于 JavaScript 的功能。
我们还看到,所有这些不同类型的测试都集成了 PHPUnit,因此我们可以使用这个工具运行它们。这意味着所有不同类型的测试在注册到 Drupal 时都遵循相同的“规则”,即目录放置、命名空间和 PHPDoc 信息。
自动化测试的世界非常庞大,一本书中不可能涵盖所有不同的测试方式。因此,特别是对于初学者来说,在阅读 Drupal 和 PHPUnit 代码和文档时,通往良好测试覆盖率的道路充满了试错,甚至偶尔会有挫败感。但从中,我们得到了始终稳定且不受回归影响的代码。
第十八章:Drupal 8 安全性
编写安全的代码是任何网络应用的重要方面。预防各种创意十足的黑客技术可能非常令人畏惧,这也是我们作为开发者有时选择一个具有稳固且最新的安全措施框架的原因之一。
Drupal 是一个非常重视安全的 CMS。社区有一个专门的安全团队,他们始终在寻找漏洞,并就修复潜在攻击向量向核心贡献者和模块开发者提供建议。他们还负责快速缓解任何此类问题,并向受影响的各方传播正确的信息。
当涉及到创新安装时,Drupal 8 在解决先前版本中存在的许多安全问题上已经取得了长足的进步,以至于 Drupal 7 开发者曾经需要担心的大部分问题现在都可以视为理所当然。因此,在本附录中,我们将讨论一些 Drupal 8 默认提供的最突出的安全特性,这些特性与我们作为模块开发者的工作直接相关。此外,我们还将探讨一些确保我们编写的模块遵守 Drupal 自豪的安全标准的技巧。
跨站脚本(XSS)
Drupal 7 本身并不容易受到 XSS 攻击,但它使新手开发者容易打开此类漏洞。特别是基于 PHP 的模板系统,使得开发者容易忘记在输出之前对用户输入和其他类型的数据进行适当的清理。此外,它允许新手开发者直接在模板中执行所有类型的业务逻辑。除了没有保持关注点的分离(业务逻辑与表示)之外,这也意味着第三方主题的验证更加困难,并且很容易包含安全漏洞。
这些担忧中的大部分在 Drupal 8 中都已得到解决,主要是通过采用 Twig 作为模板系统。这一采用的两个主要后果是:第一个解决了将表示与业务逻辑分离的需求。换句话说,主题和开发者不能再直接访问 Drupal 的 API,也不能从模板中运行 SQL 查询。为了公开任何此类功能,可以使用 Twig 扩展和过滤器,但它们要求逻辑封装在模块内部。
第二个后果是以 Twig 自动转义的形式出现。这意味着任何未特别标记为安全的字符串都将由 Twig 使用原生的 PHP htmlspecialchars() 函数进行转义。这提供了一种安全性,以前需要主题和开发者通过如 check_plain() 函数等手动积极寻求。
Drupal 8 中的清理方法
Twig 会自动转义使用常规表示法输出的任何字符串,如下所示:
{{ variable_name }}
然而,有些情况下变量已经被标记为安全,Twig 不再对其进行转义。这通常是在MarkupInterface对象的情况下,例如FilteredMarkup或FormattableMarkup。在这些情况下,Twig 假设它们包裹的字符串已经过清理,并且可以原样输出。当然,作为模块开发者,我们必须确保我们不会使用包含未清理用户输入的字符串的此类对象。
让我们看看我们经常使用的此类对象的流行示例,然后我们将讨论我们可以用来清理用户输入的不同方法。
如果你还记得,在这本书的整个过程中,我们使用了t()函数(以及StringTranslationTrait方法),它返回一个用于翻译字符串的TranslatableMarkup对象。在 Twig 中打印此类对象将防止自动转义,因为 Twig 已经认为它是安全的。此外,如果你还记得,这仅适用于主字符串,因为我们使用的任何占位符都会被转义:
$object = t('This does not get escaped but this does: @safe', ['@safe' => 'This can be unsafe as it will be escaped'])
即使没有安全影响,我们也不应该将用户输入或变量传递给TranslatableMarkup,因为这阻碍了这些对象的实际目的——即翻译字符串。然而,对于其他MarkupInterface对象,我们有几种方法可以处理用户输入或可疑来源的字符串,以便为 Twig 做准备:
-
Drupal\Component\Utility\Html::escape():这是用于打印纯文本的最严格的清理函数。它使用 PHP 的htmlspecialchars()将特殊字符转换为 HTML 实体。 -
Drupal\Component\Utility\Xss::filter():这个函数过滤 HTML 以防止 XSS 攻击。它允许一些基本的 HTML 元素。 -
Drupal\Component\Utility\Xss::filterAdmin():这是一个非常宽容的 XSS 过滤器,除了像<script>或<style>这样的元素之外,它允许通过大多数 HTML 元素。它应该仅用于已知和安全的输入来源。 -
Drupal\Component\Utility\UrlHelper::filterBadProtocol():这个函数从 URL 中移除危险的协议。在从用户输入或不受信任的来源获取 URL 并打印 HTML 属性值之前应该使用它。
因此,根据情况,使用上述清理方法之一可以防止处理 Twig 不转义的标记时的 XSS 攻击。
双重转义
由于 Twig 已经为我们做了很多工作,因此我们也不应该过度转义。经验丰富的 Drupal 7 开发者可能会倾向于过度转义,但这可能会有意想不到的后果。例如,想象以下场景:
return [
'#theme' => 'my_custom_theme',
'#title' => 'The cow\'s got milk.',
];
由于 Twig 是自动转义的,以下字符串将被打印:
The cow's got milk.
因此,字符串是安全的,没有可见的变化。然而,想象一下,如果我们过于热衷于清理并做了以下操作:
return [
'#theme' => 'my_custom_theme',
'#title' => Html::escape('The cow\'s got milk.'),
];
然后,我们会得到以下标题:
The cow's got milk.
这是因为第一次转义时,Drupal 将撇号转换为 HTML 实体(')。然而,浏览器正确地渲染它,所以我们实际上看不到它。第二次转义将那个 HTML 实体中的个别字符转换为它们各自的 HTML 实体。在这种情况下,&字符被转换为&。因此,整个字符串不再被浏览器正确读取。
我现在将您的注意力暂时引向第四章,主题化。在那章中,我们了解到#markup和#plain_text属性已经足以对通过它们传递的用户输入进行清理。前者使用Xss::filterAdmin()方法,而后者使用Html::escape()方法。因此,请记住,如果您将它们作为渲染数组的一部分使用,可能不需要进一步的清理。
SQL 注入
SQL 注入仍然是对使用数据库驱动程序不当的易受攻击应用程序的一种非常流行的向量攻击。幸运的是,通过使用 Drupal 8 数据库抽象层,我们大大提高了确保对这些漏洞的保护。我们只需正确使用它即可。
当涉及到实体查询时,我们很难出错。然而,当我们直接使用数据库 API,就像我们在第八章,数据库 API中所做的那样时,我们必须注意。
大多数时候,漏洞与不正确的占位符管理有关。例如,我们永远不应该这样做:
$database->query('SELECT column FROM {table} t WHERE t.name = ' . $variable);
这与$variable是什么无关——直接用户输入或其他。因为通过使用那种直接连接,恶意用户可能会注入他们自己的指令,并以不同于预期的不同方式完成语句。相反,我们应该使用我们在第八章,数据库 API中所使用的代码:
$database->query("SELECT column FROM {table} t WHERE t.name = :name", [':name' => $variable]);
换句话说,使用占位符,然后由 API 进行清理,以确保不允许任何字符形成恶意语句。
在 SQL 注入漏洞方面,Drupal 8 带来了额外的安全改进——单条语句执行。直到最近,PHP PDO 驱动程序(自 Drupal 7 以来 Drupal 进行了扩展)没有设置标志来通知 MySQL 一次只执行一条语句。理论上,由附加多条语句引起的漏洞是可能的(有一个痛苦的攻击例子,它永远标记了 Drupal 社区——SA-CORE-2014-005)。但是,这已经改变了,Drupal 现在通过 PDO 将此标志发送到数据库引擎,以防止一次执行多条语句。因此,我们得到了额外的保护。
跨站请求伪造(CSRF)
CSRF 攻击是应用程序被接管的一种流行方式,通过强制具有提升权限的用户在自己的网站上执行不受欢迎的操作。通常这发生在应用程序的某些 URL 通过浏览器访问(并通过认证)触发一个过程时:例如,删除资源。
在这方面最重要的考虑因素是,绝不能仅仅通过访问 URL 就执行此类操作。为了帮助解决这个问题,我们拥有强大的表单 API,它已经从 Drupal 的早期版本中嵌入了基于令牌的 CSRF 保护。因此,基本上您可以创建提交处理程序执行潜在有害操作的表单(正如我们在第二章中学习的,创建您的第一个模块)或者甚至添加一个第二层使用确认表单(正如我们在第六章中看到的,数据建模和存储和第七章中看到的,您的自定义实体和插件类型,当时我们讨论实体)。后者实际上在操作不可逆或具有更大影响时是推荐的。
尽管表单 API 应该涵盖大多数用例,但我们可能也会遇到需要声明一个直接处理过程的回调 URL 的需求。此外,为了保护我们免受 CSRF 攻击,我们可以使用我们在第十章中看到的 CSRF 令牌系统,访问控制,当时我们讨论了各种访问控制类型。我建议您查看该章节以获取更多关于此主题的信息。
摘要
Drupal 8 在锁定其 API 以防止攻击漏洞方面已经取得了长足的进步。当然,这并不意味着它是完美的,也不意味着一个糟糕的开发者不能创建安全漏洞。因此,密切关注您所编写代码的安全影响,遵循标准(包括 OWASP 清单),并了解您使用的贡献模块(至少要由 Drupal 安全团队覆盖)非常重要。此外,保持与 Drupal 安全团队的安全公告同步也非常重要,因为可能会发现新的漏洞并需要更新来修复它们。在某些情况下,这些更新比其他更新更具有时间敏感性,但尽快保持最新状态总是好的(通过关注 Drupal 安全团队的信息)。幸运的是,从历史的角度来看,Drupal 并没有经历很多安全危机——至少与其他开源框架相比是这样。因此,从安全角度来看,它享有良好的声誉。然而,不要认为您作为模块开发者,不需要承担保持应用程序安全的重任。
在本章中,我们讨论了网络应用程序通常面临的三种过渡性漏洞,Drupal 8 如何应对这些漏洞,以及作为模块开发者,我们可以和应该做些什么来保护自己免受其影响:跨站脚本(XSS)、SQL 注入和跨站请求伪造(CSRF)。当然,从应用程序和服务器维护的角度来看,我们还可以做很多事情。然而,这些内容超出了本书关注的范围。尽管如此,我强烈建议你阅读所有关于 Drupal 8 安全性的可用文档,并保持信息更新。
哇。你能相信你刚刚完成了这本书的最后一章,终于可以去打乒乓球了吗?是的,确实需要休息一下,因为这是一段不容易的旅程,尽管我希望它是有成效的。一旦完成,回到键盘前,我强烈建议你重新审视那些对你来说更复杂的部分。在做这件事的同时,检查并导航 Drupal 核心代码,以理解和亲自看到这些概念在实际中的应用。没有任何资源会比代码本身更好,这本书的主要目标就是为你指明正确的方向。还有许多更多有趣的事情要学习,这个过程永远不会停止。如果你感兴趣,你将每天都会学到新东西。我也是。


浙公网安备 33010602011771号