Magento2-开发者指南-全-
Magento2 开发者指南(全)
原文:
zh.annas-archive.org/md5/22e3fb2c2e59e824ce1774e2331a1019译者:飞龙
前言
构建基于 Magento 的商店可能是一项具有挑战性的任务。它需要一系列与 PHP/JavaScript 编程语言、开发和生产环境以及众多 Magento 特定功能相关的技术技能。本书将提供关于 Magento 构建块所需的知识。
到这本书的结尾,你应该熟悉配置文件、依赖注入、模型、集合、块、控制器、事件、观察者、插件、定时任务、配送方式、支付方式以及一些其他内容。所有这些都应该为你后续的开发旅程打下坚实的基础。
本书涵盖内容
第一章, 理解平台架构,对技术堆栈、架构层、顶级系统结构和单个模块结构进行了高级概述。
第二章, 管理环境,介绍了 VirtualBox、Vagrant 和 Amazon AWS 作为设置开发和生产环境的平台。它还提供了设置/脚本 Vagrant 和 Amazon EC2 盒子的实践示例。
第三章, 编程概念和约定,向读者介绍了几个看似不相关但重要的 Magento 部分,如 composer、服务合约、代码生成、var 目录,以及最终的编码标准。
第四章, 模型和集合,探讨了模型、资源、集合、架构和数据脚本。它还展示了应用于实体的实际 CRUD 操作以及过滤集合。
第五章, 使用依赖注入,引导读者了解依赖注入机制。它解释了对象管理器的角色,如何配置类偏好,以及如何使用虚拟类型。
第六章, 插件,深入探讨了名为插件的新概念。它展示了如何通过 before/after/around 监听器轻松扩展或添加现有功能。
第七章, 后端开发,通过实践方法介绍通常被认为是后端相关开发的部分。这些包括定时任务、通知消息、会话、Cookies、日志、性能分析器、事件、缓存、小部件等。
第八章 前端开发 采用更高级的方法,引导读者了解大多数被认为是前端相关开发的内容。它涉及到在 Magento 中渲染流程、视图元素、块、模板、布局、主题、CSS 和 JavaScript。
第九章 Web API 详细介绍了 Magento 提供的强大 Web API。它提供了实际操作示例,以创建和使用 REST 和 SOAP,无论是通过 PHP cURL 库还是从控制台。
第十章 主要功能区域 采用了高级方法,向读者介绍 Magento 中一些最常见的部分。这包括 CMS、目录和客户管理、以及产品和客户导入。它甚至展示了如何创建自定义产品类型和运输及支付方式。
第十一章 测试 概述了在 Magento 中可用的测试类型。它进一步展示了如何编写和执行自定义测试。
第十二章 从头开始构建模块 展示了开发模块的整个过程,该模块使用了前几章中介绍的大多数功能。最终结果是具有管理后台和店面界面、管理配置区域、电子邮件模板、已安装的架构脚本、测试等的模块。
你需要这本书的内容
为了成功运行本书中提供的所有示例,你需要自己的网络服务器或第三方网络托管解决方案。高级技术堆栈包括 PHP、Apache/Nginx 和 MySQL。Magento 2 社区版平台本身附带了一份详细的系统要求列表,可以在devdocs.magento.com/guides/v2.0/install-gde/system-requirements.html找到。实际的环境设置在第二章 管理环境 中有详细说明。
这本书面向的对象
本书主要面向对 Magento 2 开发感兴趣的初级到专业 PHP 开发者。对于后端开发者,本书涵盖了多个主题,将帮助你修改和扩展你的 Magento 店铺。前端开发者也会找到一些关于如何在前端定制网站外观的覆盖内容。
由于代码和结构发生了大量变化,Magento 2.x 版本可以描述为一个与其前身显著不同的平台。考虑到这一点,本书将不会假设或要求读者具备对 Magento 1.x 的先验知识。
惯例
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称显示如下:“AbstractProductPlugin1类不需要从另一个类扩展,插件才能工作。”
代码块设置如下:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework: ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Block\Product\AbstractProduct">
<plugin name="foggyPlugin1" type="Foggyline\Plugged\Block\Catalog\Product\ AbstractProductPlugin1" disabled="false" sortOrder="100"/>
<plugin name="foggyPlugin2" type="Foggyline\Plugged\Block\Catalog\Product\ AbstractProductPlugin2" disabled="false" sortOrder="200"/>
<plugin name="foggyPlugin3" type="Foggyline\Plugged\Block\Catalog\Product\ AbstractProductPlugin3" disabled="false" sortOrder="300"/>
</type>
</config>
任何命令行输入或输出都按如下方式编写:
php bin/magento setup:upgrade
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“在商店视图下拉字段中,我们选择要应用主题的商店视图。”
注意
警告或重要注意事项以如下框显示。
小贴士
小贴士和技巧显示如下。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。
要向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。
如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请参阅我们的作者指南,网址为 www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从购买中获得最大收益。
下载示例代码
您可以从您在 www.packtpub.com 的账户下载示例代码文件,适用于您购买的所有 Packt 出版物。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。
海盗行为
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。
第一章:理解平台架构
Magento是一个强大、高度可扩展和高度可定制的电子商务平台,可用于构建网店,如果需要,还可以用于一些非电子商务网站。它提供了大量开箱即用的电子商务功能。
产品库存、购物车、支持多种支付和运输方式、促销规则、内容管理、多种货币、多种语言、多个网站等功能使其成为商家的绝佳选择。另一方面,开发者享受与商人相关的完整功能集以及与实际开发相关的所有事物。本章将涉及强大的 Web API 支持、可扩展的管理界面、模块、主题、嵌入式测试框架等内容。
在本章中,以下部分提供了对 Magento 的高级概述:
-
技术栈
-
架构层
-
最高级别的文件系统结构
-
模块文件系统结构
技术栈
Magento 高度模块化的结构是几个开源技术嵌入到堆栈中的结果。这些开源技术由以下组件组成:
-
PHP:PHP 是一种服务器端脚本语言。本书假设您对 PHP 的面向对象方面有深入的了解,这通常被称为PHP OOP。
-
编码规范:Magento 非常重视编码规范。这包括PSR-0(自动加载标准)、PSR-1(基本编码规范)、PSR-2(编码风格指南)、PSR-3和PSR-4。
-
Composer:Composer 是一个 PHP 的依赖管理包。它用于拉取所有供应商库的要求。
-
HTML:HTML5 是开箱即支持的。
-
CSS:Magento 通过其内置的LESS CSS预处理器支持 CSS3。
-
jQuery:jQuery 是一个成熟的跨平台 JavaScript 库,旨在简化 DOM 操作。它是当今最受欢迎的 JavaScript 框架之一。
-
RequireJS:RequireJS 是一个 JavaScript 文件和模块加载器。使用如 RequireJS 这样的模块化脚本加载器有助于提高代码的速度和质量。
-
第三方库:Magento 内置了许多第三方库,其中最显著的是Zend Framework和Symfony。值得注意的是,Zend Framework 有两个不同的主要版本,即 1.x 版本和 2.x 版本。Magento 内部使用这两个版本。
-
Apache 或 Nginx:Apache 和 Nginx 都是 HTTP 服务器。每个都有自己的优缺点。说一个比另一个好是不公平的,因为它们的性能广泛取决于整个系统的设置和使用。Magento 与 Apache 2.2 和 2.4 以及 Nginx 1.7 兼容。
-
MySQL:MySQL 是一个成熟且广泛使用的关系数据库管理系统(RDBMS),它使用结构化查询语言(SQL)。MySQL 既有免费社区版本,也有商业版本。Magento 至少需要MySQL 社区版5.6 版本。
-
MTF:Magento 测试框架(MTF)提供了一套自动化测试套件。它涵盖了各种类型的测试,如性能测试、功能测试和单元测试。整个 MTF 都可在 GitHub 上找到,可以通过访问
github.com/magento/mtf作为一个独立的项目进行查看。
不同的技术可以粘合到各种架构中。从模块开发者、系统集成商或商家,或者从其他角度看待 Magento 架构的方式有很多。
架构层
从上到下,Magento 可以分为四个架构层,即表示层、服务层、领域层和持久层。
表示层是我们通过浏览器直接与之交互的那一层。它包含布局、块、模板,甚至控制器,这些控制器处理用户界面的命令。jQuery、RequireJS、CSS 和 LESS 等客户端技术也是这一层的一部分。通常,三种类型的用户与这一层交互,即网络用户、系统管理员和进行 Web API 调用的用户。由于 Web API 调用可以通过与用户使用浏览器相同的方式进行 HTTP 调用,因此两者之间有一条很细的界限。虽然网络用户和 Web API 调用按原样消耗表示层,但系统管理员有权对其进行更改。这种更改以设置活动主题和更改CMS(即内容管理系统)页面、块和产品本身的内容的形式体现。
当与表示层的组件进行交互时,它们通常会调用底层的服务层。
服务层是表示层和领域层之间的桥梁。它包含服务合约,这些合约定义了实现行为。服务合约基本上是一个 PHP 接口的别称。这一层是我们可以找到 REST/SOAP API 的地方。大多数用户在店面上的交互都是通过服务层路由的。同样,进行 REST/SOAP API 调用的外部应用程序也与这一层进行交互。
当与服务层的组件进行交互时,它们通常会调用底层的领域层。
域名层实际上是 Magento 的业务逻辑。这一层完全是关于组成业务逻辑的通用数据对象和模型。域名层模型本身不参与数据持久化,但它们包含一个指向资源模型的引用,该模型用于从 MySQL 数据库检索和持久化数据。一个模块的域名层代码可以通过使用事件观察者、插件和di.xml定义与另一个模块的域名模块代码进行交互。我们将在后面的章节中探讨这些细节。鉴于插件和 di.xml 的强大功能,重要的是要注意,这种交互最好通过服务合同(PHP 接口)来建立。
当与域名层的组件进行交互时,它们通常会调用底层的持久化层。
持久化层是数据被持久化的地方。这一层负责所有的CRUD(即创建、读取、更新和删除)请求。Magento 使用持久化层的活动记录模式策略。模型对象包含一个资源模型,它将一个对象映射到一个或多个数据库行。在这里,区分简单资源模型和实体-属性-值(EAV)资源模型的情况很重要。简单资源模型映射到单个表,而 EAV 资源模型将它们的属性分散在多个 MySQL 表中。例如,Customer 和 Catalog 资源模型使用 EAV 资源模型,而新闻通讯的 Subscriber 资源模型使用简单资源模型。
顶级文件系统结构
以下列表描述了根 Magento 文件系统结构:
-
.htaccess -
.htaccess.sample -
.php_cs -
.travis.yml -
CHANGELOG.md -
CONTRIBUTING.md -
CONTRIBUTOR_LICENSE_AGREEMENT.html -
COPYING.txt -
Gruntfile.js -
LICENSE.txt -
LICENSE_AFL.txt -
app -
bin -
composer.json -
composer.lock -
dev -
index.php -
lib -
nginx.conf.sample -
package.json -
php.ini.sample -
phpserver -
pub -
setup -
update -
var -
vendor
app/etc/di.xml 文件是我们可能在开发过程中经常查看的最重要文件之一。它包含各种类映射或单个接口的偏好设置。
var/magento/language-* 目录是注册语言所在的位置。尽管每个模块都可以在 app/code/{VendorName}/{ModuleName}/i18n/ 下声明自己的翻译,但如果在自定义模块或主题目录中找不到翻译,Magento 最终会回退到其自己的名为 i18n 的单独模块。
bin目录是我们可以找到magento文件的地方。magento文件是一个旨在从控制台运行的脚本。一旦通过php bin/magento命令触发,它将运行Magento\Framework\Console\Cli应用程序的一个实例,向我们提供相当多的控制台选项。我们可以使用magento脚本来启用/禁用缓存,启用/禁用模块,运行索引器,以及执行许多其他操作。
dev目录是我们可以找到 Magento 测试脚本的地方。我们将在后面的章节中了解更多关于这些脚本的内容。
lib目录包含两个主要子目录,即位于lib/internal下的服务器端 PHP 库代码和位于lib/web中的客户端 JavaScript 库。
pub目录是公开文件所在的位置。这是我们在设置 Apache 或 Nginx 时应将其设置为根目录的目录。当在浏览器中打开店面时,会触发pub/index.php文件。
var目录是动态生成的文件类型,如缓存、日志等文件创建的地方。我们应该能够随时删除此文件夹的内容,并且让 Magento 自动重新创建它。
vendor目录是大多数代码所在的地方。这是我们可以找到各种第三方供应商代码、Magento 模块、主题和语言包的地方。进一步查看vendor目录,你会看到以下结构:
-
.htaccess -
autoload.php -
bin -
braintree -
composer -
doctrine -
fabpot -
justinrainbow -
league -
lusitanian -
magento -
monolog -
oyejorge -
pdepend -
pelago -
phpmd -
phpseclib -
phpunit -
psr -
sebastian -
seld -
sjparkinson -
squizlabs -
symfony -
tedivm -
tubalmartin -
zendframework
在供应商目录中,我们可以找到来自各种供应商的代码,例如phpunit、phpseclib、monolog、symfony等。Magento 本身也可以在这里找到。Magento 代码位于vendor/magento目录下,部分列表如下:
-
composer -
framework -
language-en_us -
magento-composer-installer -
magento2-base -
module-authorization -
module-backend -
module-catalog -
module-customer -
module-theme -
module-translation -
module-ui -
module-url-rewrite -
module-user -
module-version -
module-webapi -
module-widget -
theme-adminhtml-backend -
theme-frontend-blank -
theme-frontend-luma
你会发现目录的进一步结构遵循某种命名模式,其中theme-*目录存储主题,module-*目录存储模块,而language-*目录存储已注册的语言。
模块文件系统结构
Magento 将自己定位为一个高度模块化的平台。这意味着模块被放置的目录位置是实际存在的。现在让我们看一下单个模块的结构。以下结构属于一个较简单的核心 Magento 模块——可以在vendor/magento/module-contact中找到的Contact模块:
-
Block -
composer.json -
Controller -
etc-
acl.xml -
adminhtmlsystem.xml
-
config.xml -
email_templates.xml -
frontend-
di.xml -
page_types.xml -
routes.xml
-
-
module.xml
-
-
Helper -
i18n -
LICENSE_AFL.txt -
LICENSE.txt -
Model -
README.md -
registration.php -
Test-
Unit-
Block -
Controller -
Helper -
Model
-
-
-
view-
adminhtml -
frontend-
layout -
contact_index_index.xml -
default.xml
-
-
templatesform.phtml
-
尽管前面的结构是针对一个较简单的模块,但你可以看到它仍然相当广泛。
Block目录是存储与视图相关的 PHP 类的地方。
Controller目录是存储与控制器相关的 PHP 类的地方。这是响应店面POST和GET HTTP操作的代码。
etc目录是模块配置文件所在之处。在这里,我们可以看到诸如module.xml、di.xml、acl.xml、system.xml、config.xml、email_templates.xml、page_types.xml、routes.xml等文件。module.xml文件是一个实际的模块声明文件。我们将在后面的章节中查看这些文件的内容。
Helper目录是各种辅助类所在之处。这些类通常用于将各种商店配置值抽象到获取方法中。
i18n目录是存储模块翻译包 CSV 文件的地方。
Module目录是实体、资源实体、集合和各种其他业务类可以找到的地方。
测试目录存储模块单元测试。
view目录包含所有模块管理员和店面模板文件(.phtml和.html)和静态文件(.js和.css)。
最后,registration.php是一个模块注册文件。
摘要
在本章中,我们快速浏览了在 Magento 中使用的技术栈。我们讨论了作为一个开源产品的 Magento 如何广泛使用其他开源项目和服务,如 MySQL、Apache、Nginx、Zend Framework、Symfony、jQuery 等。然后我们学习了这些库是如何组织到目录中的。最后,我们探索了一个现有的核心模块,并简要地查看了一个模块结构的示例。
在下一章中,我们将处理环境设置,以便我们可以安装并准备开发 Magento。
第二章:管理环境
在本章中,我们将探讨设置我们的开发和生产环境。我们的想法是拥有一个完全自动化的开发环境,可以通过单个控制台命令启动。对于生产环境,我们将关注可用的云服务之一,看看设置 Magento 进行更简单的生产项目有多容易。我们不会涵盖任何强大的环境设置,如自动扩展、缓存服务器、内容分发网络等。这些实际上是系统管理员或 DevOps 角色的工作。我们在这里的关注点是启动我们的 Magento 商店所需的最基本条件;在接下来的章节中,我们将实现以下里程碑:
-
设置开发环境
-
VirtualBox
-
Vagrant
-
Vagrant 项目
-
配置 PHP
-
配置 MySQL
-
配置 Apache
-
配置 Magento 安装
-
-
-
设置生产环境
-
Amazon Web Services(AWS)简介
-
设置 S3 使用权限
-
创建 IAM 用户
-
创建 IAM 组
-
-
设置 S3 以备份数据库和媒体文件
-
自动化 EC2 设置的 Bash 脚本
-
设置 EC2
-
设置弹性 IP 和 DNS
-
-
设置开发环境
在本节中,我们将使用VirtualBox和Vagrant构建一个开发环境。
注意
Magento 官方要求 Apache 2.2 或 2.4,PHP 5.6.x 或 5.5.x(PHP 5.4 不受支持),以及 MySQL 5.6.x。我们在设置环境时需要记住这一点。
VirtualBox
VirtualBox是一款功能强大且丰富的 x86 和 AMD64/Intel64 虚拟化软件。它是免费的,可以在大量平台上运行,并支持大量客户操作系统。如果我们日常开发中使用 Windows、Linux 或 OS X,我们可以使用 VirtualBox 启动一个虚拟机,在其中安装运行 Magento 所需的服务器软件。这意味着使用 MySQL、Apache 以及一些其他东西。
Vagrant
Vagrant是一个用于虚拟化软件管理的软件包装器。我们可以用它来创建和配置开发环境。Vagrant 支持多种类型的虚拟化软件,如 VirtualBox、VMware、基于内核的虚拟机(KVM)、Linux 容器(LXC)等。它甚至支持服务器环境,如 Amazon EC2。
注意
在开始之前,我们需要确保已经安装了 VirtualBox 和 Vagrant。我们可以从它们的官方网站下载并按照以下说明进行安装:www.virtualbox.org 和 www.vagrantup.com。
Vagrant 项目
我们首先在主机操作系统中某个地方手动创建一个空目录,比如说 /Users/branko/www/B05032-Magento-Box/。这是我们将会拉取 Magento 代码的目录。我们希望 Magento 源代码位于 Vagrant 虚拟机外部,这样我们就可以在我们的 IDE 中轻松地与之一起工作。
然后我们创建一个 Vagrant 项目目录,比如说 /Users/branko/www/magento-box/。
在 magento-box 目录中,我们运行控制台命令 vagrant init。这会产生如下输出:
A 'Vagrantfile' has been placed in this directory. You are now ready to 'vagrant up' your first virtual environment! Please read the comments in the Vagrantfile as well as documentation on 'vagrantup.com' for more information on using Vagrant.
Vagrantfile 实际上是一个 Ruby 语言源文件。如果我们去掉注释,其原始内容看起来如下:
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure(2) do |config|
config.vm.box = "base"
end
如果我们现在在 magento-box 目录下运行 vagrant up,这将启动 VirtualBox 的无头(无 GUI)模式并运行基础操作系统。然而,现在让我们暂缓运行该命令。
目标是创建一个更健壮的 Vagrantfile,它涵盖了运行 Magento 所需的所有内容,从 Apache、MySQL、PHP、PHPUnit、composer 到带有性能基准数据的完整 Magento 安装。
虽然 Vagrant 本身没有单独的配置文件,但我们将创建一个,因为我们想在其中存储配置数据,如 MySQL 用户名和密码。
让我们创建一个名为 Vagrantfile.config.yml 的文件,与同一目录下的 Vagrantfile 一起,内容如下:
ip: 192.168.10.10
s3:
access_key: "AKIAIPRNHSWEQNWHLCDQ"
secret_key: "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"
bucket: "foggy-project-dhj6"
synced_folder:
host_path: "/Users/branko/www/B05032-Magento-Box/"
guest_path: "/vagrant-B05032-Magento-Box/"
mysql:
host: "127.0.0.1"
username: root
password: user123
http_basic:
repo_magento_com:
username: a8adc3ac98245f519ua0d2v2c8770ec8
password: a38488dc908c6d6923754c268vc41bc4
github_oauth:
github_com: "d79fb920d4m4c2fb9d8798b6ce3a043f0b7c2af6"
magento:
db_name: "magento"
admin_firstname: "John"
admin_lastname: "Doe"
admin_password: "admin123"
admin_user: "admin"
admin_email: "email@change.me"
backend_frontname: "admin"
language: "en_US"
currency: "USD"
timezone: "Europe/London"
base_url: "http://magento.box"
fixture: "small"
这里没有 Vagrant 强制的结构。这可以是任何有效的 YAML 文件。所提供的值仅是我们可以放入其中的示例。
Magento 使我们能够生成一对 32 字符的认证令牌,可用于访问 Git 仓库。这是通过使用用户名和密码登录到 Magento Connect,然后转到我的账户 | 开发者 | 安全密钥来完成的。然后,公钥和私钥就成为了我们访问 Magento GitHub 仓库的用户名和密码。
有一个单独的配置文件意味着我们可以将 Vagrantfile 提交到版本控制中,同时将 Vagrantfile.config.yml 排除在版本控制之外。
我们现在通过替换其内容为以下内容来编辑 Vagrantfile:
# -*- mode: ruby -*-
# vi: set ft=ruby :
require 'yaml'
vagrantConfig = YAML.load_file 'Vagrantfile.config.yml'
Vagrant.configure(2) do |config|
config.vm.box = "ubuntu/vivid64"
config.vm.network "private_network", ip: vagrantConfig['ip']
# Mount local "~/www/B05032-Magento-Box/" path into box's "/vagrant-B05032-Magento-Box/" path
config.vm.synced_folder vagrantConfig['synced_folder']['host_path'], vagrantConfig['synced_folder']['guest_path'], owner:"vagrant", group: "www-data", mount_options:["dmode=775, fmode=664"]
# VirtualBox specific settings
config.vm.provider "virtualbox" do |vb|
vb.gui = false
vb.memory = "2048"
vb.cpus = 2
end
# <provisioner here>
end
上述代码首先包含了 yaml 库,然后读取 Vagrantfile.config.yml 文件的内容到 vagrantConfig 变量中。然后我们有一个 config 块,在其中我们定义了虚拟机类型、固定 IP 地址、主机和客户操作系统之间的共享文件夹,以及一些 VirtualBox 特定的细节,如分配的 CPU 和内存。
我们使用的是 ubuntu/vivid64 虚拟机,它代表 Ubuntu 15.04(Vivid Vervet)的服务器版本。原因是这个 Ubuntu 版本为我们提供了 MySQL 5.6.x 和 PHP 5.6.x,这些是安装 Magento 的要求之一。
我们进一步有一个配置条目为我们的虚拟机分配一个固定 IP。让我们现在在我们的主机操作系统的 hosts 文件中添加一个条目,如下所示:
192.168.10.10 magento.box
注意
我们将固定 IP 地址分配给我们的虚拟机的原因是,我们可以在主机操作系统内直接打开类似 http://magento.box 的 URL,然后访问客户操作系统内 Apache 提供的页面。
上一段代码的另一个重要部分是我们定义 synced_folder 的地方。除了源和目标路径外,这里的关键部分是 owner、group 和 mount_options。我们将这些设置为 vagrant 用户、www-data 用户组,以及目录和文件权限的 774 和 664,以便与 Magento 顺利配合。
让我们继续编辑 Vagrantfile,向其中添加几个配置器,一个接一个。我们通过将前一个示例中的 # <provisioner here> 替换为以下内容来实现:
config.vm.provision "file", source: "~/.gitconfig", destination: ".gitconfig"
config.vm.provision "shell", inline: "sudo apt-get update"
在这里,我们指示 Vagrant 将主机的 .gitconfig 文件传递到客户操作系统。这样做是为了让客户操作系统的 Git 设置继承自主机操作系统的 Git。然后我们调用 apt-get update 以更新客户操作系统。
配置 PHP
在 Vagrantfile 中进一步添加,我们运行多个配置器以安装 PHP、所需的 PHP 模块和 PHPUnit,如下所示:
config.vm.provision "shell", inline: "sudo apt-get -y install php5 php5-dev php5-curl php5-imagick php5-gd php5-mcrypt php5-mhash php5-mysql php5-xdebug php5-intl php5-xsl"
config.vm.provision "shell", inline: "sudo php5enmod mcrypt"
config.vm.provision "shell", inline: "echo \"xdebug.max_nesting_level=200\" >> /etc/php5/apache2/php.ini"
config.vm.provision "shell", inline: "sudo apt-get -y install phpunit"
注意
这里有一点值得指出——我们将 xdebug.max_nesting_level=200 写入 php.ini 文件的行。这样做是为了排除 Magento 不会启动并抛出 Maximum Functions Nesting Level of '100' reached... 错误的可能性。
配置 MySQL
在 Vagrantfile 中进一步添加,我们运行配置器以安装 MySQL 服务器,如下所示:
config.vm.provision "shell", inline: "sudo debconf-set-selections <<< 'mysql-server mysql-server/root_password password #{vagrantConfig['mysql']['password']}'"
config.vm.provision "shell", inline: "sudo debconf-set-selections <<< 'mysql-server mysql-server/root_password_again password #{vagrantConfig['mysql']['password']}'"
config.vm.provision "shell", inline: "sudo apt-get -y install mysql-server"
config.vm.provision "shell", inline: "sudo service mysql start"
config.vm.provision "shell", inline: "sudo update-rc.d mysql defaults"
MySQL 安装有趣的地方在于,它要求在安装过程中提供密码和密码确认。这使得它成为配置过程中的一个麻烦部分,该过程期望 shell 命令简单地执行而不需要输入。为了绕过这个问题,我们使用 debconf-set-selections 来存储输入参数。我们从 Vagrantfile.config.yml 文件中读取密码,并将其传递给 debconf-set-selections。
安装完成后,update-rc.d mysql 默认设置将 MySQL 添加到操作系统启动过程中,从而确保在重启虚拟机时 MySQL 正在运行。
配置 Apache
在 Vagrantfile 中进一步添加,我们按照以下方式运行 Apache 配置器:
config.vm.provision "shell", inline: "sudo apt-get -y install apache2"
config.vm.provision "shell", inline: "sudo update-rc.d apache2 defaults"
config.vm.provision "shell", inline: "sudo service apache2 start"
config.vm.provision "shell", inline: "sudo a2enmod rewrite"
config.vm.provision "shell", inline: "sudo awk '/<Directory \\/>/,/AllowOverride None/{sub(\"None\", \"All\",$0)}{print}' /etc/apache2/apache2.conf > /tmp/tmp.apache2.conf"
config.vm.provision "shell", inline: "sudo mv /tmp/tmp.apache2.conf /etc/apache2/apache2.conf"
config.vm.provision "shell", inline: "sudo awk '/<Directory \\/var\\/www\\/>/,/AllowOverride None/{sub(\"None\", \"All\",$0)}{print}' /etc/apache2/apache2.conf > /tmp/tmp.apache2.conf"
config.vm.provision "shell", inline: "sudo mv /tmp/tmp.apache2.conf /etc/apache2/apache2.conf"
config.vm.provision "shell", inline: "sudo service apache2 stop"
上一段代码安装了 Apache,将其添加到启动序列中,启动它,并打开重写模块。然后我们对 Apache 配置文件进行了更新,因为我们想将 AllowOverride None 替换为 AllowOverride All,否则我们的 Magento 将无法工作。一旦完成更改,我们停止 Apache 以避免后续进程。
配置 Magento 安装
在 Vagrantfile 中进一步添加,我们现在将注意力转向 Magento 安装,我们将它分为几个步骤。首先,我们使用 Vagrant 的同步文件夹功能将主机的 /vagrant-B05032-Magento-Box/ 文件夹链接到客户,即 /var/www/html:
config.vm.provision "shell", inline: "sudo rm -Rf /var/www/html"
config.vm.provision "shell", inline: "sudo ln -s #{vagrantConfig['synced_folder']['guest_path']} /var/www/html"
然后,我们使用 composer create-project 命令从官方 repo.magento.com 源将 Magento 2 文件拉取到 /var/www/html/ 目录:
config.vm.provision "shell", inline: "curl -sS https://getcomposer.org/installer | php"
config.vm.provision "shell", inline: "mv composer.phar /usr/local/bin/composer"
config.vm.provision "shell", inline: "composer clearcache"
config.vm.provision "shell", inline: "echo '{\"http-basic\": {\"repo.magento.com\": {\"username\": \"#{vagrantConfig ['http_basic']['repo_magento_com']['username']}\",\"password\": \"#{vagrantConfig['http_basic']['repo_magento_com']['password']} \"}}, \"github-oauth\": {\"github.com\": \"#{vagrantConfig['github_oauth']['github_com']}\"}}' >> /root/.composer/auth.json"
config.vm.provision "shell", inline: "composer create-project -- repository-url=https://repo.magento.com/ magento/project- community-edition /var/www/html/"
然后,我们创建一个数据库,稍后将在其中安装 Magento:
config.vm.provision "shell", inline: "sudo mysql -- user=#{vagrantConfig['mysql']['username']} -- password=#{vagrantConfig['mysql']['password']} -e \"CREATE DATABASE #{vagrantConfig['magento']['db_name']};\""
我们随后从命令行运行 Magento 安装程序:
config.vm.provision "shell", inline: "sudo php /var/www/html/bin/magento setup:install --base- url=\"#{vagrantConfig['magento']['base_url']}\" --db- host=\"#{vagrantConfig['mysql']['host']}\" --db- user=\"#{vagrantConfig['mysql']['username']}\" --db- password=\"#{vagrantConfig['mysql']['password']}\" --db- name=\"#{vagrantConfig['magento']['db_name']}\" --admin- firstname=\"#{vagrantConfig['magento']['admin_firstname']}\" -- admin-lastname=\"#{vagrantConfig['magento']['admin_lastname']}\" --admin-email=\"#{vagrantConfig['magento']['admin_email']}\" -- admin-user=\"#{vagrantConfig['magento']['admin_user']}\" -- admin-password=\"#{vagrantConfig['magento']['admin_password']}\" --backend- frontname=\"#{vagrantConfig['magento']['backend_frontname']}\" - -language=\"#{vagrantConfig['magento']['language']}\" -- currency=\"#{vagrantConfig['magento']['currency']}\" -- timezone=\"#{vagrantConfig['magento']['timezone']}\""
config.vm.provision "shell", inline: "sudo php /var/www/html/bin/magento deploy:mode:set developer"
config.vm.provision "shell", inline: "sudo php /var/www/html/bin/magento cache:disable"
config.vm.provision "shell", inline: "sudo php /var/www/html/bin/magento cache:flush"
config.vm.provision "shell", inline: "sudo php /var/www/html/bin/magento setup:performance:generate-fixtures /var/www/html/setup/performance-toolkit/profiles/ce/small.xml"
上述代码显示我们还在安装 fixtures 数据。
在配置 Vagrantfile.config.yml 文件时,我们需要小心。Magento 安装对提供的数据非常敏感。我们需要确保我们为像邮件和密码这样的字段提供有效的数据,否则安装将失败,并显示类似于以下错误的错误:
SQLSTATE[28000] [1045] Access denied for user 'root'@'localhost' (using password: NO)
User Name is a required field.
First Name is a required field.
Last Name is a required field.
'magento.box' is not a valid hostname for email address 'john.doe@magento.box'
'magento.box' appears to be a DNS hostname but cannot match TLD against known list
'magento.box' appears to be a local network name but local network names are not allowed
Password is required field.
Your password must be at least 7 characters.
Your password must include both numeric and alphabetic characters.
有了这个,我们就完成了 Vagrantfile 的内容。
现在,在 Vagrantfile 所在的同一目录下运行 vagrant up 命令将触发盒子创建过程。在这个过程中,之前列出的所有命令都将被执行。这个过程本身可能需要一个小时左右。
一旦 vagrant up 完成,我们可以发出另一个控制台命令,vagrant ssh,以登录到盒子。
同时,如果我们在我们浏览器中打开类似 http://magento.box 的 URL,我们应该看到 Magento 商店首页正在加载。
上述 Vagrantfile 简单地从官方 Magento Git 仓库中提取并从头开始安装 Magento。Vagrantfile 和 Vagrantfile.config.yml 可以进一步扩展和定制,以满足我们各自项目的需求,例如从私有 Git 仓库中提取代码、从共享驱动器中恢复数据库等。
这为我们提供了一个简单而强大的脚本过程,通过这个过程我们可以为团队中的其他开发者准备完全就绪的项目机器,以便他们能够快速启动。
设置生产环境
生产环境是面向客户的、专注于良好性能和可用性的环境。设置生产环境并不是我们开发者倾向于做的事情,尤其是当有关于扩展、负载均衡、高可用性等方面的稳健要求时。然而,有时我们可能需要设置一个简单的生产环境。有许多云服务提供商提供快速简单的解决方案。为了本节的目的,我们将转向 Amazon Web Services。
亚马逊网络服务简介
亚马逊网络服务(AWS)是一组远程计算服务,通常被称为网络服务。AWS 提供云中的按需计算资源和服务,具有 按使用付费 的定价。亚马逊提供了一个很好的 AWS 资源比较,说使用 AWS 资源而不是自己的资源,就像从电力公司购买电力而不是运行自己的发电机一样。
如果我们停下来思考一下,这不仅仅对系统操作角色,对我们这样的开发者来说也很有趣。我们(开发者)现在能够在几分钟内启动各种数据库、Web 应用服务器,甚至复杂的基础设施。我们可以运行这些服务几分钟、几小时或几天,然后关闭它们。同时,我们只需为实际使用付费,而不是像大多数托管服务那样支付全额的月费或年费。尽管 AWS 某些服务的整体定价可能不是最便宜的,但它确实提供了许多其他服务所不具备的商品化和易用性。商品化来自于诸如自动扩展资源这样的特性,与等效的本地基础设施相比,它通常可以提供显著的成本节约。
质量培训和认证计划是 AWS 生态系统的重要方面之一。认证适用于解决方案架构师、开发者和系统操作管理员,涵盖副高级和专业级别。尽管认证不是强制性的,但如果我们经常处理 AWS,我们被鼓励去参加。获得认证将证明我们在 AWS 平台上设计、部署和运行高度可用、成本效益和安全的应用的专长。
我们可以通过一个简单直观的基于 Web 的用户界面,即 AWS 管理控制台来管理我们的 AWS,该界面可在aws.amazon.com/console找到。登录 AWS 后,我们应该能看到以下类似的屏幕:

上一张图片显示了 AWS 管理控制台如何将 AWS 服务视觉上分组为几个主要组,如下所示:
-
计算
-
开发者工具
-
移动服务
-
存储与内容分发
-
管理工具
-
应用服务
-
数据库
-
安全与身份
-
网络
-
分析
-
企业应用
作为本章的一部分,我们将探讨计算组下的EC2服务和存储与内容分发组下的S3服务。
亚马逊弹性计算云(Amazon EC2)是一种提供可伸缩计算容量的云服务。我们可以将其视为云中的虚拟计算机,我们可以在任何时间打开和关闭它,只需几分钟。我们可以同时部署一台、数百台甚至数千台这样的机器实例。这使得计算容量具有可伸缩性。
S3 提供安全、持久且高度可扩展的对象存储。它旨在提供 99.99%的对象持久性。该服务提供了一个网络服务接口,可以从网络上的任何地方存储和检索任何数量的数据。S3 仅按实际使用的存储收费。S3 可以单独使用,也可以与其他 AWS 服务如 EC2 一起使用。
设置 S3 使用的访问权限
作为我们生产环境的一部分,拥有可靠的存储空间,我们可以存档数据库和媒体文件,这是很好的。Amazon S3 是一个可能的解决方案。
为了正确设置对 S3 可扩展存储服务的访问权限,我们需要快速了解一下 AWS 的身份和访问管理(简称IAM)。IAM 是一种网络服务,帮助我们安全地控制用户对 AWS 资源的访问。我们可以使用 IAM 来控制身份验证(谁可以使用我们的 AWS 资源)和授权(他们可以使用哪些资源以及如何使用)。更具体地说,正如我们很快将看到的,我们感兴趣的是用户和组。
创建 IAM 用户
本节描述了如何创建 IAM 用户。IAM 用户是我们创建在 AWS 中,用于代表使用它的人或服务在与 AWS 交互时的实体。
登录 AWS 控制台。
在用户菜单下,点击如下截图所示的安全凭证:

这将打开安全仪表板页面。
点击用户菜单应该打开一个类似于以下屏幕的界面:

在用户菜单下,我们点击创建新用户,这将打开一个类似于以下页面:

在这里,我们填写一个或多个用户的所需用户名,例如foggy_s3_user1,然后点击创建按钮。
我们现在应该看到一个类似于以下屏幕的界面:

在这里,我们可以点击下载凭证来启动 CSV 格式文件的下载,或者手动复制粘贴我们的凭证。
注意
访问密钥 ID和秘密访问密钥是我们将用于访问 S3 存储的两条信息。
点击关闭链接将我们带回到用户菜单,显示我们刚刚创建的用户,如下截图所示:

创建 IAM 组
本节描述了如何创建 IAM 组。组是我们可以将它们作为一个单一单元管理的 IAM 用户的集合。因此,让我们开始:
-
登录 AWS 控制台。
-
在用户菜单下,点击如下截图所示的安全凭证:
![创建 IAM 组]()
-
这将打开安全仪表板页面。点击组菜单应该打开一个类似于以下屏幕的界面:
![创建 IAM 组]()
-
在组菜单下,我们点击创建新组,这将打开一个类似于以下页面:
![创建 IAM 组]()
-
在这里,我们填写所需的组名,例如
FoggyS3Test。 -
我们现在应该看到一个类似于以下屏幕的界面,我们需要选择策略类型组,然后点击下一步按钮:
![创建 IAM 组]()
-
我们选择AmazonS3FullAccess策略类型,并点击下一步按钮。现在将显示审查屏幕,要求我们审查提供的信息:
![创建 IAM 组]()
-
如果提供的信息正确,我们通过点击创建组按钮来确认。现在,我们应该能够在组菜单下看到我们的组,如下面的截图所示:
![创建 IAM 组]()
-
打开组名左侧的复选框,点击组操作下拉菜单,然后选择如下截图所示的添加用户到组:
-
这将打开如下截图所示的添加用户到组页面:
-
打开用户名左侧的复选框,并点击添加用户按钮。这应该会将所选用户添加到组中,并返回到组列表。
用户和组创建过程的结果是一个具有访问密钥 ID、秘密访问密钥和分配有AmazonS3FullAccess策略的用户组。我们将在演示将数据库备份到 S3 时使用这些信息。
设置 S3 用于数据库和媒体文件备份
S3 由存储桶组成。我们可以将存储桶视为我们 S3 账户中的第一级目录。然后,我们在该目录(存储桶)上设置权限和其他选项。在本节中,我们将创建自己的存储桶,包含两个名为database和media的空文件夹。在后续的环境设置过程中,我们将使用这些文件夹来备份我们的 MySQL 数据库和媒体文件。
我们首先登录到 AWS 管理控制台。
在存储与内容分发组下,我们点击S3。这将打开一个类似于以下截图的屏幕:

点击创建存储桶按钮。这将打开一个类似于以下截图的弹出窗口:

让我们提供一个独特的存储桶名称,最好是一个可以识别我们将要备份的database和media文件的项目的名称,然后点击创建按钮。为了本章节的目的,让我们假设我们选择了类似foggy-project-dhj6的东西。
我们的存储桶现在应该在所有存储桶列表中可见。如果我们点击它,将打开一个类似于以下截图的新屏幕:

在这里,我们点击创建文件夹按钮,并添加必要的database和media文件夹。
在根存储桶目录内,点击属性按钮,并填写如下截图所示的权限部分:

在这里,我们基本上将所有权限分配给了认证用户。
现在我们应该有一个 S3 存储桶,我们可以使用s3cmd控制台工具(我们很快会提到)将数据库和媒体备份存储到其中。
Bash 脚本用于自动化 EC2 设置
与Vagrantfile shell provisioners 类似,让我们继续创建一系列 bash shell 命令,我们可以使用这些命令进行生产环境设置。
第一组命令如下:
#!/bin/bash
apt-get update
apt-get -y install s3cmd
在这里,从#!/bin/bash表达式开始。这指定了我们正在执行的脚本类型。然后我们有一个系统更新和s3cmd工具的安装。s3cmd是一个免费命令行工具和客户端,用于上传、检索和管理 Amazon S3 中的数据。我们可以稍后使用它进行数据库和媒体文件的备份和恢复。
然后,我们使用以下命令安装postfix邮件服务器。由于 postfix 安装会在控制台触发图形界面,要求输入mailname和main_mailer_type,我们使用sudo debconf-set-selections绕过这些步骤。安装完成后,我们重新加载postfix。
sudo debconf-set-selections <<< "postfix postfix/mailname string magentize.me"
sudo debconf-set-selections <<< "postfix postfix/main_mailer_type string 'Internet Site'"
sudo apt-get install -y postfix
sudo /etc/init.d/postfix reload
在 EC2 盒子上直接使用邮件服务器对于小型生产网站来说是可以的,我们预计不会有高流量或大量客户。对于更密集的生产网站,我们需要注意 Amazon,可能需要在端口25上设置节流,从而导致发出的电子邮件超时。在这种情况下,我们可以要求 Amazon 取消我们账户的限制,或者转向更健壮的服务,如Amazon Simple Email Service。
接下来,我们安装所有与 PHP 相关的组件。注意我们甚至安装了xdebug,尽管立即将其关闭。这可能在那些非常罕见的我们需要真正调试实时网站的时刻派上用场,然后我们可以将其关闭并尝试远程调试。我们进一步下载并设置 composer 到用户路径:
apt-get -y install php5 php5-dev php5-curl php5-imagick php5-gd php5- mcrypt php5-mhash php5-mysql php5-xdebug php5-intl php5-xsl
php5enmod mcrypt
php5dismod xdebug
service php5-fpm restart
apt-get -y install phpunit
echo "Starting Composer stuff" >> /var/tmp/box-progress.txt
curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer
然后,我们继续进行 MySQL 的安装。在这里,我们也使用debconf-set-selections来自动化控制台部分提供安装输入参数。安装完成后,MySQL 被启动并添加到启动过程中。
debconf-set-selections <<< 'mysql-server mysql-server/root_password password RrkSBi6VDg6C'
debconf-set-selections <<< 'mysql-server mysql-server/root_password_again password RrkSBi6VDg6C'
apt-get -y install mysql-server
service mysql start
update-rc.d mysql defaults
除了 MySQL 之外,另一个主要组件是 Apache。我们使用以下命令来安装它。在使用 Apache 时,我们需要注意其apache2.conf文件。我们需要将 Magento 目录的AllowOverride None更改为AllowOverride All:
apt-get -y install apache2
update-rc.d apache2 defaults
service apache2 start
a2enmod rewrite
awk '/<Directory \/>/,/AllowOverride None/{sub("None", "All",$0)}{print}' /etc/apache2/apache2.conf > /tmp/tmp.apache2.conf
mv /tmp/tmp.apache2.conf /etc/apache2/apache2.conf
awk '/<Directory \/var\/www\/>/,/AllowOverride None/{sub("None", "All",$0)}{print}' /etc/apache2/apache2.conf > /tmp/tmp.apache2.conf
mv /tmp/tmp.apache2.conf /etc/apache2/apache2.conf
service apache2 restart
现在我们已经安装了 MySQL 和 Apache,我们继续将源代码文件放置到位。接下来,我们从官方的 Magento Git 仓库中拉取代码。这不同于我们在设置 vagrant 时使用的repo.magento.com。尽管在这种情况下,Magento Git 仓库是公开的,但我们的想法是能够从私有 GitHub 仓库中拉取代码。根据我们倾向于设置的生产环境,我们可以轻松地将下一部分替换为从任何其他私有 Git 仓库中拉取。
sudo rm -Rf /var/www/html/*
git clone https://github.com/magento/magento2.git /var/www/html/.
sudo composer config --global github-oauth.github.com 7d6da6bld50dub454edc27db70db78b1f8997e6
sudo composer install --working-dir="/var/www/html/"
mysql -uroot -pRrkSBi6VDg6C -e "CREATE DATABASE magento;"
PUBLIC_HOSTNAME="'wget -q -O - http://instance-data/latest/meta- data/public-hostname'"
小贴士
要从私有 git 仓库拉取代码,我们可以使用以下形式的命令,Git 克隆:https://<user>:<OAuthToken>@github.com/<user>/<repo>.git。
PUBLIC_HOSTNAME 变量存储了调用 http://instance-data/latest/meta-data/public-hostname URL 的 wget 命令的响应。这个 URL 是 AWS 的一个功能,允许我们获取当前的 EC2 实例元数据。然后我们在 Magento 安装期间使用 PUBLIC_HOSTNAME 变量,将其作为 --base-url 参数传递:
php /var/www/html/bin/magento setup:install --base- url="http://$PUBLIC_HOSTNAME" --db-host="127.0.0.1" --db- user="root" --db-password="RrkSBi6VDg6C" --db-name="magento" -- admin-firstname="John" --admin-lastname="Doe" --admin- email="john.doe@change.me" --admin-user="admin" --admin- password="pass123" --backend-frontname="admin" -- language="en_US" --currency="USD" --timezone="Europe/London"
前面的命令包含了许多 项目特定 的配置值,所以我们需要确保在简单地复制粘贴之前,适当地粘贴我们自己的信息。
现在我们确保 Magento 模式设置为生产,并且缓存已开启并刷新,以便重新生成:
php /var/www/html/bin/magento deploy:mode:set production
php /var/www/html/bin/magento cache:enable
php /var/www/html/bin/magento cache:flush
最后,我们重置 /var/www/html 目录的权限,以确保我们的 Magento 能够正常运行:
chown -R ubuntu:www-data /var/www/html
find /var/www/html -type f -print0 | xargs -r0 chmod 640
find /var/www/html -type d -print0 | xargs -r0 chmod 750
chmod -R g+w /var/www/html/pub
chmod -R g+w /var/www/html/var
chmod -R g+w /var/www/html/app
chmod -R g+w /var/www/html/vendor
我们需要对前面提到的 Git 和 Magento 安装示例保持谨慎。这里的想法是展示我们如何自动从公共或私有仓库设置 Git pull。对于这个特定案例,Magento 安装部分只是一个小小的额外奖励,并不是我们会在生产机器上实际做的事情。这个脚本的整个目的就是作为启动新 AMI 图像的蓝图。所以理想情况下,一旦代码被拉取,我们通常会从一些私有存储(如 S3)恢复数据库,并将其附加到我们的安装上。这样,一旦脚本完成,就可以完成文件、数据库和媒体的完整恢复。
把这个想法放在一边,让我们回到我们的脚本,进一步添加以下命令的每日数据库备份:
CRON_CMD="mysql --user=root --password=RrkSBi6VDg6C magento | gzip -9 > ~/database.sql.gz"
CRON_JOB="30 2 * * * $CRON_CMD"
( crontab -l | grep -v "$CRON_CMD" ; echo "$CRON_JOB" ) | crontab -
CRON_CMD="s3cmd --access_key="AKIAINLIM7M6WGJKMMCQ" -- secret_key="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" put ~/database.sql.gz s3://foggy-project-ghj7/database/database_'date +"%Y-%m-%d_%H-%M"'.sql.gz"
CRON_JOB="30 3 * * * $CRON_CMD"
( crontab -l | grep -v "$CRON_CMD" ; echo "$CRON_JOB" ) | crontab -
在这里,我们添加了一个凌晨 2:30 的 cron 作业,用于将数据库备份到名为 database.sql.gz 的家目录文件中。然后我们添加了另一个凌晨 3:30 执行的 cron 作业,将数据库备份推送到 S3 存储。
与数据库备份类似,我们可以使用以下命令集将媒体备份指令添加到我们的脚本中:
CRON_CMD="tar -cvvzf ~/media.tar.gz /var/www/html/pub/media/"
CRON_JOB="30 2 * * * $CRON_CMD"
( crontab -l | grep -v "$CRON_CMD" ; echo "$CRON_JOB" ) | crontab -
CRON_CMD="s3cmd --access_key="AKIAINLIM7M6WGJKMMCQ" -- secret_key="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" put ~/media.tar.gz s3://foggy-project-ghj7/media/media_'date +"%Y-%m- %d_%H-%M"'.tar.gz"
CRON_JOB="30 3 * * * $CRON_CMD"
( crontab -l | grep -v "$CRON_CMD" ; echo "$CRON_JOB" ) | crontab -
前面的命令中包含了几条编码信息。我们需要确保相应地粘贴我们的访问密钥、秘密密钥和 S3 桶名称。为了简化,我们在这里没有讨论将访问令牌硬编码到 cron 作业中的安全问题。亚马逊提供了一个广泛的 AWS 安全最佳实践 指南,可以通过官方 AWS 网站下载。
现在我们对自动化 EC2 设置的 bash 脚本可能的样子有了些了解,让我们继续设置 EC2 实例。
设置 EC2
按照以下步骤完成设置:
-
登录 AWS 控制台
-
在 计算 组下,点击 EC2,应该会打开一个类似于以下屏幕的界面:
![设置 EC2]()
-
点击 启动实例 按钮,应该会打开一个类似于以下屏幕的界面:
![设置 EC2]()
-
点击左侧的 社区 AMI 选项卡,并在搜索框中输入
Ubuntu Vivid,如图所示:![设置 EC2]()
小贴士
默认情况下,Ubuntu 15.x(Vivid Vervet)服务器支持 MySQL 5.6.x 和 PHP 5.6.x,这使得它成为安装 Magento 的良好候选者。
我们现在应该看到一个类似于以下屏幕的界面:
![设置 EC2]()
-
选择一个实例类型,然后点击下一步:配置实例详情按钮。我们现在应该看到一个类似于以下屏幕的界面:
![设置 EC2]()
备注
我们不会深入到每个选项的细节。简单来说,如果我们正在处理较小的生产站点,那么我们很可能可以将大多数这些选项保留为默认值。
-
确保将关机行为设置为停止。
-
在仍然位于步骤 3:配置实例详情屏幕的情况下,向下滚动到底部的高级详情区域并展开它。我们应该看到一个类似于以下屏幕的界面:
![设置 EC2]()
-
用户数据输入是我们将复制并粘贴上一节中描述的
auto-setup bash脚本的地方,如下截图所示:![设置 EC2]()
-
一旦我们复制并粘贴了用户数据,点击下一步:添加存储按钮。这应该会显示以下截图所示的屏幕:
![设置 EC2]()
-
在步骤 4:添加存储中,我们可以选择一个或多个卷来附加到我们的 EC2 实例。最好选择 SSD 类型的存储以获得更快的性能。一旦设置了卷,点击下一步:标记实例。我们现在应该看到一个类似于以下屏幕的界面:
![设置 EC2]()
-
标记实例屏幕允许我们分配标签。标签使我们能够根据目的、所有者、环境或其他方式对 AWS 资源进行分类。一旦我们分配了一个或多个标签,我们点击下一步:配置安全组按钮。我们现在应该看到一个类似于以下屏幕的界面:
![设置 EC2]()
-
配置安全组界面允许我们设置入站和出站流量的规则。我们希望能够访问该设备上的 SSH、HTTP、HTTPS 和 SMTP 服务。一旦我们添加了所需的规则,点击审查和启动按钮。这将打开一个类似于以下屏幕的界面:
![设置 EC2]()
-
审查实例启动屏幕是我们可以查看到目前为止配置的盒子的总结的地方。如果需要,我们可以返回并编辑单个设置。一旦我们对总结满意,我们点击启动按钮。这将打开一个类似于以下弹出窗口:
![设置 EC2]()
-
在这里,我们可以选择一个现有的安全密钥或创建一个新的密钥。密钥以 PEM 格式提供。一旦我们选择了密钥,我们点击启动实例按钮。
我们现在应该看到一个类似于以下屏幕的启动状态界面:
![设置 EC2]()
-
点击实例名称链接应该会带我们回到EC2 仪表板,如下截图所示:
![设置 EC2]()
关于前面的图像,我们现在应该能够通过以下任一控制台命令连接到我们的 EC2 服务器:
ssh -i /path/to/magento-box.pem ubuntu@ec2-52-29-35-49.eu-central-1.compute.amazonaws.com
ssh -i /path/to/magento-box.pem ubuntu@52.29.35.49
我们的 EC2 服务器执行传递给它的所有 shell 命令可能需要一些时间。我们可以方便地 SSH 到服务器,然后执行以下命令来获取当前进度的概述:
sudo tail -f /var/tmp/box-progress.txt
通过这样,我们完成了实例启动过程。
设置弹性 IP 和 DNS
现在我们已经有一个 EC2 服务器,让我们继续创建所谓的弹性 IP。弹性 IP 地址是为动态云计算设计的静态 IP 地址。它与 AWS 账户相关联,而不是某个特定的实例。这使得它很容易从一个实例重新映射到另一个实例。
让我们继续创建一个弹性 IP,如下所示:
-
登录到 AWS 控制台。
-
在计算组下,点击EC2,这将带我们到EC2 仪表板。
-
在EC2 仪表板下,在“网络和安全”分组下的左侧区域,点击弹性 IP。这将打开一个类似于以下屏幕的界面:
![设置弹性 IP 和 DNS]()
-
点击分配新地址按钮,这将打开一个类似于以下弹出窗口的界面:
![设置弹性 IP 和 DNS]()
-
点击是,分配按钮,这将打开另一个类似于以下弹出窗口的界面:
![设置弹性 IP 和 DNS]()
-
现在弹性 IP 地址已创建,在表格列表中右键单击它应该会弹出如下截图所示的下拉菜单:
![设置弹性 IP 和 DNS]()
-
点击关联地址链接。这将打开一个类似于以下弹出窗口的界面:
![设置弹性 IP 和 DNS]()
-
在关联地址弹出窗口中,我们选择要分配弹性 IP 地址的实例,然后点击关联按钮。
到目前为止,我们的 EC2 服务器已分配了一个静态(弹性 IP)地址。现在我们可以登录到我们的域名注册商,并将 DNS 的 A 记录指向我们刚刚创建的弹性 IP。
在我们等待 DNS 更改生效之前,还有一件事需要处理。我们需要 SSH 到我们的服务器并执行以下命令集:
mysql -uroot -pRrkSBi6VDg6C -e "USE magento; UPDATE core_config_data SET value = 'http://our-domain.something/' WHERE path LIKE "%web/unsecure/base_url%";"
php /var/www/html/bin/magento cache:flush
这将更新 Magento 的 URL,因此一旦 DNS 更改生效,我们就可以通过 Web 浏览器访问它。通过一些前期规划,我们本可以轻松地将这部分内容作为我们 EC2 实例的用户数据的一部分,只需在最初提供正确的--base-url参数值即可。
摘要
在本章中,我们主要关注了两件事:设置开发和生产环境。
作为开发环境的一部分,我们采用了如 VirtualBox 和 Vagrant 等免费软件来管理我们的环境设置。仅设置本身就归结为一个单独的Vagrantfile脚本,该脚本包含了安装从 Ubuntu 服务器、PHP、Apache、MySQL 甚至包括 Magento 本身的必要命令集。我们绝不应该将此脚本视为最终的,而仅仅将其视为设置我们开发环境的有效脚本。在使开发环境更接近项目特定需求上投入时间,从团队生产力的角度来看是值得的。
然后,我们转向生产环境。在这里,我们研究了 Amazon Web Services,沿途使用了 S3 和 EC2。生产环境也附带了自己的脚本安装过程,该过程设置了大多数东西。同样,这个脚本也绝不是最终的,而仅仅是一种设置事物的有效方式;它更多的是一个如何操作的基例。
在下一章中,我们将更详细地探讨一些编程概念和约定。
第三章. 编程概念和约定
凭借多年的经验,Magento 平台发展起来,实现了许多行业概念、标准和约定。在本章中,我们将探讨几个在日常与 Magento 开发互动中突出的独立部分。
在本章中,我们将讨论以下部分:
-
Composer
-
服务合同
-
代码生成
-
var目录 -
编码标准
Composer
Composer是一个处理 PHP 依赖管理的工具。它不像 Linux 系统中的Yum和Apt那样是一个包管理器。尽管它处理库(包),但它是在每个项目级别上处理的。它不会全局安装任何东西。Composer 是一个多平台工具。因此,它在 Windows、Linux 和 OS X 上运行得同样好。
在机器上安装 Composer 就像在项目目录中运行安装程序一样简单,使用以下命令即可:
curl -sS https://getcomposer.org/installer | php
更多关于 Composer 安装的信息可以在其官方网站上找到,可以通过访问getcomposer.org查看。
Composer 用于获取 Magento 及其使用的第三方组件。如前一章所见,以下composer命令是将所有内容拉入指定目录的命令:
composer create-project --repository-url=https://repo.magento.com/ magento/project-enterprise-edition <installation directory name>
一旦下载并安装了 Magento,你可以在其目录中找到许多composer.json文件。假设<安装目录名称>是magento2,如果我们执行如find magento2/ -name 'composer.json'之类的快速搜索命令,那么将产生超过 100 个composer.json文件。其中一些文件(部分)如下所示:
/vendor/magento/module-catalog/composer.json
/vendor/magento/module-cms/composer.json
/vendor/magento/module-contact/composer.json
/vendor/magento/module-customer/composer.json
/vendor/magento/module-sales/composer.json
/...
/vendor/magento/theme-adminhtml-backend/composer.json
/vendor/magento/theme-frontend-blank/composer.json
/vendor/magento/theme-frontend-luma/composer.json
/vendor/magento/language-de_de/composer.json
/vendor/magento/language-en_us/composer.json
/...
/composer.json
/dev/tests/...
/vendor/magento/framework/composer.json
最相关的文件可能是位于magento目录根目录下的composer.json文件。其内容看起来像这样:
{
"name": "magento/project-community-edition",
"description": "eCommerce Platform for Growth (Community Edition)",
"type": "project",
"version": "2.0.0",
"license": [
"OSL-3.0",
"AFL-3.0"
],
"repositories": [
{
"type": "composer",
"url": "https://repo.magento.com/"
}
],
"require": {
"magento/product-community-edition": "2.0.0",
"composer/composer": "@alpha",
"magento/module-bundle-sample-data": "100.0.*",
"magento/module-widget-sample-data": "100.0.*",
"magento/module-theme-sample-data": "100.0.*",
"magento/module-catalog-sample-data": "100.0.*",
"magento/module-customer-sample-data": "100.0.*",
"magento/module-cms-sample-data": "100.0.*",
"magento/module-catalog-rule-sample-data": "100.0.*",
"magento/module-sales-rule-sample-data": "100.0.*",
"magento/module-review-sample-data": "100.0.*",
"magento/module-tax-sample-data": "100.0.*",
"magento/module-sales-sample-data": "100.0.*",
"magento/module-grouped-product-sample-data": "100.0.*",
"magento/module-downloadable-sample-data": "100.0.*",
"magento/module-msrp-sample-data": "100.0.*",
"magento/module-configurable-sample-data": "100.0.*",
"magento/module-product-links-sample-data": "100.0.*",
"magento/module-wishlist-sample-data": "100.0.*",
"magento/module-swatches-sample-data": "100.0.*",
"magento/sample-data-media": "100.0.*",
"magento/module-offline-shipping-sample-data": "100.0.*"
},
"require-dev": {
"phpunit/phpunit": "4.1.0",
"squizlabs/php_codesniffer": "1.5.3",
"phpmd/phpmd": "@stable",
"pdepend/pdepend": "2.0.6",
"sjparkinson/static-review": "~4.1",
"fabpot/php-cs-fixer": "~1.2",
"lusitanian/oauth": "~0.3 <=0.7.0"
},
"config": {
"use-include-path": true
},
"autoload": {
"psr-4": {
"Magento\\Framework\\": "lib/internal/Magento/Framework/",
"Magento\\Setup\\": "setup/src/Magento/Setup/",
"Magento\\": "app/code/Magento/"
},
"psr-0": {
"": "app/code/"
},
"files": [
"app/etc/NonComposerComponentRegistration.php"
]
},
"autoload-dev": {
"psr-4": {
"Magento\\Sniffs\\": "dev/tests/static/framework/Magento/Sniffs/",
"Magento\\Tools\\": "dev/tools/Magento/Tools/",
"Magento\\Tools\\Sanity\\": "dev/build/publication/sanity/ Magento/Tools/Sanity/",
"Magento\\TestFramework\\Inspection\\": "dev/tests/static/framework/Magento/ TestFramework/Inspection/",
"Magento\\TestFramework\\Utility\\": "dev/tests/static/framework/Magento/ TestFramework/Utility/"
}
},
"minimum-stability": "alpha",
"prefer-stable": true,
"extra": {
"magento-force": "override"
}
}
Composer 的 JSON 文件遵循一定的模式。你可以在getcomposer.org/doc/04-schema.md找到这个模式的详细文档。遵循该模式确保了 composer 文件的正确性。我们可以看到,所有列出的键,如name、description、require、config等,都是由该模式定义的。
让我们看看单个模块的composer.json文件。一个具有最少依赖的简单模块是Contact模块,其vendor/magento/module-contact/composer.json内容如下所示:
{
"name": "magento/module-contact",
"description": "N/A",
"require": {
"php": "~5.5.0|~5.6.0|~7.0.0",
"magento/module-config": "100.0.*",
"magento/module-store": "100.0.*",
"magento/module-backend": "100.0.*",
"magento/module-customer": "100.0.*",
"magento/module-cms": "100.0.*",
"magento/framework": "100.0.*"
},
"type": "magento2-module",
"version": "100.0.2",
"license": [
"OSL-3.0",
"AFL-3.0"
],
"autoload": {
"files": [
"registration.php"
],
"psr-4": {
"Magento\\Contact\\": ""
}
}
}
你会看到模块定义了对 PHP 版本和其他模块的依赖。此外,你还会看到 PSR-4 用于自动加载以及直接加载registration.php文件。
接下来,让我们看看en_us语言模块中的vendor/magento/language-en_us/composer.json文件的内容:
{
"name": "magento/language-en_us",
"description": "English (United States) language",
"version": "100.0.2",
"license": [
"OSL-3.0",
"AFL-3.0"
],
"require": {
"magento/framework": "100.0.*"
},
"type": "magento2-language",
"autoload": {
"files": [
"registration.php"
]
}
}
最后,让我们看看luma主题中的vendor/magento/theme-frontend-luma/composer.json文件的内容:
{
"name": "magento/theme-frontend-luma",
"description": "N/A",
"require": {
"php": "~5.5.0|~5.6.0|~7.0.0",
"magento/theme-frontend-blank": "100.0.*",
"magento/framework": "100.0.*"
},
"type": "magento2-theme",
"version": "100.0.2",
"license": [
"OSL-3.0",
"AFL-3.0"
],
"autoload": {
"files": [
"registration.php"
]
}
}
如前所述,在 Magento 中散布着许多更多的 composer 文件。
服务合同
服务合约是一组由模块定义的 PHP 接口。此合约包括数据接口和服务接口。
数据接口的作用是保持数据完整性,而服务接口的作用是隐藏业务逻辑细节,使其不被服务消费者看到。
数据接口定义了各种功能,如验证、实体信息、搜索相关功能等。它们定义在单个模块的Api/Data目录中。为了更好地理解其真正含义,让我们看看Magento_Cms模块的数据接口。在vendor/magento/module-cms/Api/Data/目录中,定义了四个接口,如下所示:
BlockInterface.php
BlockSearchResultsInterface.php
PageInterface.php
PageSearchResultsInterface.php
CMS模块实际上处理两个实体,一个是Block,另一个是Page。查看前面代码中定义的接口,我们可以看到我们有一个针对实体的单独数据接口和针对搜索结果的单独数据接口。
让我们更仔细地看看BlockInterface.php文件的内容(已去除),其定义如下:
namespace Magento\Cms\Api\Data;
interface BlockInterface
{
const BLOCK_ID = 'block_id';
const IDENTIFIER = 'identifier';
const TITLE = 'title';
const CONTENT = 'content';
const CREATION_TIME = 'creation_time';
const UPDATE_TIME = 'update_time';
const IS_ACTIVE = 'is_active';
public function getId();
public function getIdentifier();
public function getTitle();
public function getContent();
public function getCreationTime();
public function getUpdateTime();
public function isActive();
public function setId($id);
public function setIdentifier($identifier);
public function setTitle($title);
public function setContent($content);
public function setCreationTime($creationTime);
public function setUpdateTime($updateTime);
public function setIsActive($isActive);
}
前面的接口定义了当前实体的所有获取器和设置器方法,以及表示实体字段名的常量值。这些数据接口不包括管理操作,如delete。这个特定接口的实现可以在vendor/magento/module-cms/Model/Block.php文件中看到,其中这些常量被使用,如下(部分)所示:
public function getTitle()
{
return $this->getData(self::TITLE);
}
public function setTitle($title)
{
return $this->setData(self::TITLE, $title);
}
服务接口包括管理、仓库和元数据接口。这些接口直接定义在模块的Api目录中。回顾Magento Cms模块,其vendor/magento/module-cms/Api/目录中有两个服务接口,定义如下:
BlockRepositoryInterface.php
PageRepositoryInterface.php
快速查看BlockRepositoryInterface.php的内容,揭示以下(部分)内容:
namespace Magento\Cms\Api;
use Magento\Framework\Api\SearchCriteriaInterface;
interface BlockRepositoryInterface
{
public function save(Data\BlockInterface $block);
public function getById($blockId);
public function getList(SearchCriteriaInterface $searchCriteria);
public function delete(Data\BlockInterface $block);
public function deleteById($blockId);
}
在这里,我们看到用于保存、检索、搜索和删除实体的方法。
这些接口随后通过 Web API 定义实现,正如我们将在第九章中看到的,结果是定义良好且持久的 API,其他模块和第三方集成者可以消费这些 API。
代码生成
Magento 应用程序的一个巧妙特性是代码生成。正如其名称所暗示的,代码生成会生成不存在的类。这些类在 Magento 的var/generation目录中生成。
var/generation目录内的目录结构在一定程度上类似于核心的vendor/magento/module-*和app/code目录。更精确地说,它遵循模块结构。代码是为所谓的Factory、Proxy和Interceptor类生成的。
工厂类创建一个类型的实例。例如,由于在vendor/magento目录及其代码中某处调用了Magento\Catalog\Model\ProductFactory类,因此已创建了一个包含Magento\Catalog\Model\ProductFactory类的var/generation/Magento/Catalog/Model/ProductFactory.php文件。在运行时,当代码中调用{someClassName}Factory时,如果不存在,Magento 会在var/generation目录下创建一个工厂类。以下代码是ProductFactory类的(部分)示例:
namespace Magento\Catalog\Model;
/**
* Factory class for @see \Magento\Catalog\Model\Product
*/
class ProductFactory
{
//...
/**
* Create class instance with specified parameters
*
* @param array $data
* @return \Magento\Catalog\Model\Product
*/
public function create(array $data = array())
{
return $this->_objectManager->create($this->_instanceName, $data);
}
}
注意create方法,它创建并返回Product类型实例。还要注意生成的代码是如何类型安全的,为集成开发环境(IDEs)提供@return注解以支持自动完成功能。
工厂用于将对象管理器与业务代码隔离开来。与业务对象不同,工厂可以依赖于对象管理器。
代理类是对某些基类的包装。代理类比基类提供更好的性能,因为它们可以在不实例化基类的情况下被实例化。基类仅在调用其方法时才被实例化。这对于将基类用作依赖项且实例化耗时且仅在执行路径的某些部分使用其方法的情况非常方便。
与工厂类似,代理类也生成在var/generation目录下。
如果我们查看包含Magento\Catalog\Model\Session\Proxy类的var/generation/Magento/Catalog/Model/Session/Proxy.php文件,我们会看到它实际上扩展了Magento\Catalog\Model\Session。包装的代理类在过程中实现了几个魔法方法,例如__sleep、__wakeup、__clone和__call。
拦截器是另一种由 Magento 自动生成的类类型。它与插件功能相关,将在第六章中详细讨论,插件。
为了触发代码生成,我们可以使用控制台上可用的代码编译器。我们可以运行单租户编译器或多租户编译器。
单租户意味着一个网站和商店,并且通过以下命令执行。
magento setup:di:compile
多租户意味着不止一个独立的 Magento 应用程序,并且通过以下命令执行。
magento setup:di:compile-multi-tenant
代码编译生成工厂、代理、拦截器和setup/src/Magento/Setup/Module/Di/App/Task/Operation/目录中列出的其他几个类。
变量目录
Magento 执行大量的缓存和某些类类型的自动生成。这些缓存和生成的类都位于 Magento 根目录的var目录中。var目录的常规内容如下:
cache
composer_home
generation
log
di
view_preprocessed
page_cache
在开发过程中,我们很可能需要定期清除这些,以便我们的更改能够生效。
我们可以按照以下方式发出控制台命令以清除单个目录:
rm -rf {Magento root dir}/var/generation/*
或者,我们可以使用内置的 bin/magento 控制台工具来触发命令,自动删除相应的目录,如下所示:
-
bin/magento setup:upgrade: 这将更新 Magento 数据库模式和数据。在此过程中,它将截断var/di和var/generation目录。 -
bin/magento setup:di:compile: 这将清除var/generation目录。在此之后,它将再次编译其中的代码。 -
bin/magento deploy:mode:set {mode}: 这将模式从开发模式更改为生产模式,反之亦然。在此过程中,它将截断var/di、var/generation和var/view_preprocessed目录。 -
bin/magento cache:clean {type}: 这将清理var/cache和var/page_cache目录。
在开发过程中,始终要记住 var 目录。否则,代码可能会遇到异常并无法正常工作。
编码标准
代码文档。其想法是统一所有文件中代码 DocBlocks 的使用,无论使用哪种编程语言。然而,特定语言的 DocBlock 标准可能会覆盖它。
这是 Google JavaScript 风格指南和 JSDoc 的一个子集,可以在 [`usejsdoc.org`](http://usejsdoc.org) 找到。`
`The **LESS**` **编码标准定义了在处理 LESS 和 CSS 文件时的格式化和编码风格。**
**注意**
**您可以在 [devdocs.magento.com](http://devdocs.magento.com) 上阅读有关每个标准的实际细节,因为它们过于广泛,无法在本书中涵盖。**
``# 摘要 在本章中,我们探讨了 Composer,这是我们安装 Magento 时将首先与之交互的东西之一。然后,我们转向服务合同,它是 Magento 架构中最强大的部分之一,结果是我们使用的是老式的 PHP 接口。此外,我们还介绍了一些关于 Magento 代码生成功能的内容。因此,我们对 Factory 和 Proxy 类有了基本的了解。然后,我们查看 var 目录并探讨了其在开发过程中的作用。最后,我们简要介绍了 Magento 中使用的编码标准。 在下一章中,我们将讨论依赖注入,这是 Magento 最重要的架构部分之一。```
第四章。模型和集合
如同大多数现代框架和平台,如今 Magento 采用 对象关系映射(ORM)方法而非原始 SQL 查询。尽管底层机制仍然归结为 SQL,我们现在严格处理对象。这使得我们的应用程序代码更易读、更易于管理,并从供应商特定的 SQL 差异中隔离出来。模型、资源和集合是三种类型的类协同工作,使我们能够全面管理实体数据,从加载、保存、删除和列出实体。我们的大部分数据访问和管理将通过名为 Magento 模型的 PHP 类来完成。模型本身不包含与数据库通信的任何代码。
数据库通信部分被解耦成其自身的 PHP 类,称为资源类。然后每个模型都被分配一个资源类。在模型上调用 load、save 或 delete 方法会被委托给资源类,因为它们是实际从数据库读取、写入和删除数据的地方。理论上,有了足够的知识,可以编写针对各种数据库供应商的新资源类。
除了模型和资源类之外,我们还有集合类。我们可以将集合视为单个模型实例的数组。在基本层面上,集合扩展自 \Magento\Framework\Data\Collection 类,该类实现了来自 标准 PHP 库(SPL)的 \IteratorAggregate 和 \Countable 以及一些其他 Magento 特定的类。
更多的时候,我们将模型和资源视为一个单一统一的事物,因此简单地称之为模型。Magento 处理两种类型的模型,我们可以将它们分类为简单和 EAV 模型。
在本章中,我们将涵盖以下主题:
-
创建一个微型模块
-
创建一个简单模型
-
EAV 模型
-
理解模式和数据脚本流程
-
创建安装模式脚本(
InstallSchema.php) -
创建升级模式脚本(
UpgradeSchema.php) -
创建安装数据脚本(
InstallData.php) -
创建升级数据脚本(
UpgradeData.php) -
实体 CRUD 操作
-
管理集合
创建一个微型模块
为了本章的目的,我们将创建一个名为 Foggyline_Office 的小型模块。
该模块将定义两个实体,如下所示:
-
Department:一个具有以下字段的简单模型:-
entity_id:主键 -
name:部门的名称,字符串值
-
-
Employee:一个具有以下字段和属性的 EAV 模型:-
字段:
-
entity_id:主键 -
department_id:外键,指向Department.entity_id -
email:员工的唯一电子邮件,字符串值 -
first_name:员工的姓名,字符串值 -
last_name:员工的姓氏,字符串值
-
-
属性:
-
service_years:员工的工龄,整数值 -
dob:员工的出生日期,日期时间值 -
salary– 月薪,十进制值 -
vat_number:增值税号,(短)字符串值 -
note:关于员工的可能注释,(长)字符串值
-
-
每个模块都以 registration.php 和 module.xml 文件开始。为了我们本章模块的目的,让我们创建一个包含以下内容的 app/code/Foggyline/Office/registration.php 文件:
<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Foggyline_Office',
__DIR__
);
registration.php 文件可以看作是我们模块的入口点。
现在,让我们创建一个包含以下内容的 app/code/Foggyline/Office/etc/module.xml 文件:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/ etc/module.xsd">
<module name="Foggyline_Office" setup_version="1.0.0">
<sequence>
<module name="Magento_Eav"/>
</sequence>
</module>
</config>
我们将在后面的章节中更详细地介绍 module.xml 文件的结构。现在,我们只关注 sequence 中的 setup_version 属性和 module 元素。
setup_version 的值很重要,因为我们可能会在我们的模式安装脚本(InstallSchema.php)文件中使用它,有效地将安装脚本转换为更新脚本,正如我们很快将展示的那样。
sequence 元素是 Magento 为我们的模块设置依赖关系的方式。鉴于我们的模块将使用 EAV 实体,我们列出 Magento_Eav 作为依赖项。
创建一个简单的模型
根据要求,Department 实体被建模为一个简单的模型。我们之前提到,每当谈到模型时,我们隐含地想到 model 类、resource 类和 collection 类形成一个单元。
让我们先从创建一个 model 类开始,部分定义在 app/code/Foggyline/Office/Model/Department.php 文件中,如下所示:
namespace Foggyline\Office\Model;
class Department extends \Magento\Framework\Model\AbstractModel
{
protected function _construct()
{
$this-> _init('Foggyline\Office\Model \ResourceModel\Department');
}
}
这里发生的一切就是,我们正在扩展 \Magento\Framework\Model\AbstractModel 类,并在 _construct 方法中触发 $this->_init 方法,传递我们的 resource 类。
AbstractModel 进一步扩展了 \Magento\Framework\Object。我们的 model 类最终从 Object 继承的事实意味着我们不需要在 model 类上定义属性名称。Object 为我们做的事情是,它使我们能够神奇地获取、设置、取消设置和检查属性上的值存在。为了给出一个比 name 更健壮的例子,想象我们的实体在以下代码中有一个名为 employee_average_salary 的属性:
$department->getData('employee_average_salary');
$department->getEmployeeAverageSalary();
$department->setData('employee_average_salary', 'theValue');
$department->setEmployeeAverageSalary('theValue');
$department->unsetData('employee_average_salary');
$department->unsEmployeeAverageSalary();
$department->hasData('employee_average_salary');
$department->hasEmployeeAverageSalary();
这之所以有效,是因为 Object 实现了 setData、unsetData、getData 和魔法 __call 方法。魔法 __call 方法实现的美丽之处在于,它理解 getEmployeeAverageSalary、setEmployeeAverageSalary、unsEmployeeAverageSalary 和 hasEmployeeAverageSalary 这样的方法调用,即使它们不存在于 Model 类上。然而,如果我们选择在我们的 Model 类中实现一些这些方法,我们可以自由地这样做,并且当调用它们时,Magento 会捕获它们。
这是 Magento 的一个重要方面,有时对新来者来说可能有些令人困惑。
一旦我们有一个 model 类,我们创建一个模型 resource 类,部分定义在 app/code/Foggyline/Office/Model/ResourceModel/Department.php 文件中,如下所示:
namespace Foggyline\Office\Model\ResourceModel;
class Department extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb
{
protected function _construct()
{
$this->_init('foggyline_office_department', 'entity_id');
}
}
我们继承自\Magento\Framework\Model\ResourceModel\Db\AbstractDb的resource类在_construct方法中触发$this->_init方法的调用。$this->_init接受两个参数。第一个参数是表名foggyline_office_department,我们的模型将在此表中持久化其数据。第二个参数是表中的主键列名entity_id。
AbstractDb进一步扩展了Magento\Framework\Model\ResourceModel\AbstractResource。
注意
资源类是向数据库通信的关键。我们只需要命名表及其主键,我们的模型就可以保存、删除和更新实体。
最后,我们创建我们的collection类,部分定义在app/code/Foggyline/Office/Model/ResourceModel/Department/Collection.php文件中,如下所示:
namespace Foggyline\Office\Model\ResourceModel\Department;
class Collection extends \Magento\Framework\Model\ResourceModel \Db\Collection\AbstractCollection
{
protected function _construct()
{
$this->_init(
'Foggyline\Office\Model\Department',
'Foggyline\Office\Model\ResourceModel\Department'
);
}
}
collection类继承自\Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection,并且类似于model和resource类,在_construct方法中调用$this->_init方法。这次,_init接受两个参数。第一个参数是完整的model类名Foggyline\Office\Model\Department,第二个参数是完整的资源类名Foggyline\Office\Model\ResourceModel\Department。
AbstractCollection实现了Magento\Framework\App\ResourceConnection\SourceProviderInterface,并扩展了\Magento\Framework\Data\Collection\AbstractDb。AbstractDb进一步扩展了\Magento\Framework\Data\Collection。
值得花些时间研究这些collection类的内部结构,因为这是我们处理获取符合某些搜索标准实体列表时的首选地方。
创建一个 EAV 模型
根据要求,Employee实体被建模为一个 EAV 模型。
让我们先从创建一个 EAV model类开始,部分定义在app/code/Foggyline/Office/Model/Employee.php文件中,如下所示:
namespace Foggyline\Office\Model;
class Employee extends \Magento\Framework\Model\AbstractModel
{
const ENTITY = 'foggyline_office_employee';
public function _construct()
{
$this-> _init('Foggyline\Office \Model \ResourceModel\Employee');
}
}
在这里,我们继承自\Magento\Framework\Model\AbstractModel类,这与之前描述的简单模型相同。这里唯一的区别是我们定义了一个ENTITY常量,但这只是为了后面的语法糖;它对实际的model类没有实际意义。
接下来,我们创建一个 EAV 模型resource类,部分定义在app/code/Foggyline/Office/Model/ResourceModel/Employee.php文件中,如下所示:
namespace Foggyline\Office\Model\ResourceModel;
class Employee extends \Magento\Eav\Model\Entity\AbstractEntity
{
protected function _construct()
{
$this->_read = 'foggyline_office_employee_read';
$this->_write = 'foggyline_office_employee_write';
}
public function getEntityType()
{
if (empty($this->_type)) {
$this->setType(\Foggyline\Office\Model \Employee::ENTITY);
}
return parent::getEntityType();
}
}
我们的resource类继承自\Magento\Eav\Model\Entity\AbstractEntity,并通过_construct方法设置$this->_read和$this->_write类属性。这些可以自由分配为我们想要的任何值,最好遵循我们模块的命名模式。读取和写入连接需要命名,否则当使用我们的实体时,Magento 会产生错误。
getEntityType 方法内部将 _type 值设置为 \Foggyline\Office\Model\Employee::ENTITY,即字符串 foggyline_office_employee。这个相同的值存储在 eav_entity_type 表的 entity_type_code 列中。此时,eav_entity_type 表中没有这样的条目。这是因为安装模式脚本将会创建一个,正如我们很快将要演示的那样。
最后,我们创建我们的 collection 类,部分定义在 app/code/Foggyline/Office/Model/ResourceModel/Employee/Collection.php 文件中,如下所示:
namespace Foggyline\Office\Model\ResourceModel\Employee;
class Collection extends \Magento\Eav\Model\Entity\Collection\AbstractCollection
{
protected function _construct()
{
$this->_init('Foggyline\Office\Model\Employee', 'Foggyline\Office\Model\ResourceModel\Employee');
}
}
collection 类继承自 \Magento\Eav\Model\Entity\Collection\AbstractCollection,并且与模型类类似,在 _construct 中调用 $this->_init 方法。_init 接受两个参数:完整的模型类名 Foggyline\Office\Model\Employee 和完整的资源类名 Foggyline\Office\Model\ResourceModel\Employee。
AbstractCollection 与简单模型集合类具有相同的父树,但自身实现了很多 EAV 集合特定的方法,如 addAttributeToFilter、addAttributeToSelect、addAttributeToSort 等。
注意
如我们所见,EAV 模型看起来与简单模型非常相似。区别主要在于 resource 类和 collection 类的实现及其一级父类。然而,我们需要记住,这里给出的例子是最简单的一种。如果我们查看数据库中的 eav_entity_type 表,我们可以看到其他实体类型使用了 attribute_model、entity_attribute_collection、increment_model 等。这些都是我们可以定义在 EAV 模型旁边的先进属性,使其更接近 catalog_product 实体类型的实现,这可能是 Magento 中最健壮的一种。这种高级 EAV 使用超出了本书的范围,因为它可能值得一本自己的书。
现在我们已经设置了简单和 EAV 模型,是时候考虑安装必要的数据库模式和可能预先填充一些数据了。这是通过模式和数据脚本完成的。
理解模式和数据脚本流程
简而言之,模式脚本的作用是创建支持您模块逻辑的数据库结构。例如,创建一个表,我们的实体将在此表中持久化其数据。数据脚本的作用是管理现有表中的数据,通常以在模块安装期间添加一些示例数据的形式。
如果我们回顾几步,我们可以注意到数据库中的 schema_version 和 data_version 与我们的 module.xml 文件中的 setup_version 数字相匹配。它们都意味着相同的事情。如果我们现在更改 module.xml 文件中的 setup_version 数字并再次运行 php bin/magento setup:upgrade 控制台命令,我们的数据库 schema_version 和 data_version 将更新到这个新版本号。
这是通过模块的 install 和 upgrade 脚本来实现的。如果我们快速查看 setup/src/Magento/Setup/Model/Installer.php 文件,我们可以看到一个名为 getSchemaDataHandler 的函数,其内容如下:
private function getSchemaDataHandler($moduleName, $type)
{
$className = str_replace('_', '\\', $moduleName) . '\Setup';
switch ($type) {
case 'schema-install':
$className .= '\InstallSchema';
$interface = self::SCHEMA_INSTALL;
break;
case 'schema-upgrade':
$className .= '\UpgradeSchema';
$interface = self::SCHEMA_UPGRADE;
break;
case 'schema-recurring':
$className .= '\Recurring';
$interface = self::SCHEMA_INSTALL;
break;
case 'data-install':
$className .= '\InstallData';
$interface = self::DATA_INSTALL;
break;
case 'data-upgrade':
$className .= '\UpgradeData';
$interface = self::DATA_UPGRADE;
break;
default:
throw new \Magento\Setup\Exception("$className does not exist");
}
return $this->createSchemaDataHandler($className, $interface);
}
这告诉 Magento 从单个模块的 Setup 目录中挑选和运行哪些类。目前我们将忽略重复的情况,因为只有 Magento_Indexer 模块使用它。
第一次运行 php bin/magento setup:upgrade 命令针对我们的模块;尽管它仍然在 setup_module 表下没有条目,但 Magento 将按照以下顺序执行模块 Setup 文件夹中的文件:
-
InstallSchema.php -
UpgradeSchema.php -
InstallData.php -
UpgradeData.php
注意,这与 getSchemaDataHandler 方法中从上到下的顺序相同。
每次后续模块版本号的变化,随后是控制台命令 php bin/magento setup:upgrade,都会导致以下文件按照列表中的顺序运行:
-
UpgradeSchema.php -
UpgradeData.php
此外,Magento 还会在 setup_module 数据库下记录升级后的版本号。只有当数据库中的版本号小于 module.xml 文件中的版本号时,Magento 才会触发安装或升级脚本。
小贴士
如果需要,我们不必总是提供这些安装或升级脚本。只有在需要添加或编辑数据库中的现有表或条目时才需要它们。
如果我们仔细查看适当脚本中 install 和 update 方法的实现,我们可以看到它们都接受 ModuleContextInterface $context 作为第二个参数。由于升级脚本是在每次升级版本号时触发的,我们可以使用 $context->getVersion() 来针对特定于模块版本的更改。
创建安装模式脚本(InstallSchema.php)
现在我们已经了解了模式和数据脚本及其与模块版本号的关系,让我们继续组装我们的 InstallSchema。我们首先定义 app/code/Foggyline/Office/Setup/InstallSchema.php 文件,其(部分)内容如下:
namespace Foggyline\Office\Setup;
use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
class InstallSchema implements InstallSchemaInterface
{
public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
{
$setup->startSetup();
/* #snippet1 */
$setup->endSetup();
}
}
InstallSchema 遵循 InstallSchemaInterface,这要求实现接受两个类型为 SchemaSetupInterface 和 ModuleContextInterface 的参数的 install 方法。
在这里只需要安装方法。在这个方法中,我们会添加任何可能需要的代码来创建所需的表和列。
在代码库中查看,我们可以看到 Magento\Setup\Module\Setup 是扩展 \Magento\Framework\Module\Setup 并实现 SchemaSetupInterface 的一个。前面代码中看到的两个方法 startSetup 和 endSetup 用于在我们代码之前和之后运行额外的环境设置。
进一步来说,让我们将 /* #snippet1 */ 部分替换为创建我们的 Department 模型实体表的代码,如下所示:
$table = $setup->getConnection()
->newTable($setup->getTable('foggyline_office_department'))
->addColumn(
'entity_id',
\Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
null,
['identity' => true, 'unsigned' => true, 'nullable' => false, 'primary' => true],
'Entity ID'
)
->addColumn(
'name',
\Magento\Framework\DB\Ddl\Table::TYPE_TEXT,
64,
[],
'Name'
)
->setComment('Foggyline Office Department Table');
$setup->getConnection()->createTable($table);
/* #snippet2 */
在这里,我们指示 Magento 创建一个名为foggyline_office_department的表,向其中添加entity_id和name列,并设置表的注释。假设我们使用的是 MySQL 服务器,当代码执行时,以下 SQL 将在数据库中执行:
CREATE TABLE 'foggyline_office_department' (
'entity_id' int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'Entity ID',
'name' varchar(64) DEFAULT NULL COMMENT 'Name',
PRIMARY KEY ('entity_id')
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='Foggyline Office Department Table';
addColumn方法是这里最有趣的一个。它接受五个参数,从列名、列数据类型、列长度、附加选项数组到列描述。然而,只有列名和列数据类型是必须的!可接受的列数据类型可以在Magento\Framework\DB\Ddl\Table类下找到,如下所示:
boolean smallint integer bigint
float numeric decimal date
timestamp datetime text blob
varbinary
附加选项数组可能包含以下一些键:unsigned、precision、scale、unsigned、default、nullable、primary、identity、auto_increment。
在了解了addColumn方法之后,让我们继续创建foggyline_office_employee_entity表,用于Employee实体。我们通过替换前面代码中的/* #snippet2 */部分来实现这一点:
$employeeEntity = \Foggyline\Office\Model\Employee::ENTITY;
$table = $setup->getConnection()
->newTable($setup->getTable($employeeEntity . '_entity'))
->addColumn(
'entity_id',
\Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
null,
['identity' => true, 'unsigned' => true, 'nullable' => false, 'primary' => true],
'Entity ID'
)
->addColumn(
'department_id',
\Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
null,
['unsigned' => true, 'nullable' => false],
'Department Id'
)
->addColumn(
'email',
\Magento\Framework\DB\Ddl\Table::TYPE_TEXT,
64,
[],
'Email'
)
->addColumn(
'first_name',
\Magento\Framework\DB\Ddl\Table::TYPE_TEXT,
64,
[],
'First Name'
)
->addColumn(
'last_name',
\Magento\Framework\DB\Ddl\Table::TYPE_TEXT,
64,
[],
'Last Name'
)
->setComment('Foggyline Office Employee Table');
$setup->getConnection()->createTable($table);
/* #snippet3 */
根据良好的数据库设计实践,我们可能会注意到这里的一个问题。如果我们同意每个员工可以分配一个单一的部门,我们应该向该表的department_id列添加一个外键。目前,我们将故意跳过这一部分,因为我们想通过稍后的更新模式脚本来演示这一点。
EAV 模型将数据分散在多个表中,至少有三个。我们刚刚创建的foggyline_office_employee_entity表就是其中之一。另一个是核心的 Magento eav_attribute表。第三个表不是一个单独的表,而是一系列多个表;每个 EAV 类型一个表。这些表是我们安装脚本的结果。
存储在核心 Magento eav_attribute表中的信息不是属性值或类似的东西;存储在那里的信息是属性的元数据。那么,Magento 是如何知道我们的Employee属性(service_years、dob、salary、vat_number、note)的呢?它不知道;还没有。我们需要自己将属性添加到该表中。我们将在稍后通过InstallData演示这样做。
根据 EAV 属性数据类型,我们需要创建以下表:
-
foggyline_office_employee_entity_datetime -
foggyline_office_employee_entity_decimal -
foggyline_office_employee_entity_int -
foggyline_office_employee_entity_text -
foggyline_office_employee_entity_varchar
这些属性值表的名字来自一个简单的公式,即{实体表名称}+{_}+{eav_attribute.backend_type 值}。如果我们看salary属性,我们需要它是一个小数值,因此它将被存储在foggyline_office_employee_entity_decimal中。
由于定义属性值表背后的代码量很大,我们将只关注一个单一的小数类型表。我们通过替换前面代码中的/* #snippet3 */部分来实现这一点:
$table = $setup->getConnection()
->newTable($setup->getTable($employeeEntity . '_entity_decimal'))
->addColumn(
'value_id',
\Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
null,
['identity' => true, 'nullable' => false, 'primary' => true],
'Value ID'
)
->addColumn(
'attribute_id',
\Magento\Framework\DB\Ddl\Table::TYPE_SMALLINT,
null,
['unsigned' => true, 'nullable' => false, 'default' => '0'],
'Attribute ID'
)
->addColumn(
'store_id',
\Magento\Framework\DB\Ddl\Table::TYPE_SMALLINT,
null,
['unsigned' => true, 'nullable' => false, 'default' => '0'],
'Store ID'
)
->addColumn(
'entity_id',
\Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
null,
['unsigned' => true, 'nullable' => false, 'default' => '0'],
'Entity ID'
)
->addColumn(
'value',
\Magento\Framework\DB\Ddl\Table::TYPE_DECIMAL,
'12,4',
[],
'Value'
)
//->addIndex
//->addForeignKey
->setComment('Employee Decimal Attribute Backend Table');
$setup->getConnection()->createTable($table);
注意上述代码中的 //->addIndex 部分。让我们将其替换为以下内容。
->addIndex(
$setup->getIdxName(
$employeeEntity . '_entity_decimal',
['entity_id', 'attribute_id', 'store_id'],
\Magento\Framework\DB\Adapter\AdapterInterface::INDEX_TYPE_UNIQUE
),
['entity_id', 'attribute_id', 'store_id'],
['type' => \Magento\Framework\DB\Adapter\AdapterInterface::INDEX_TYPE_UNIQUE]
)
->addIndex(
$setup->getIdxName($employeeEntity . '_entity_decimal', ['store_id']),
['store_id']
)
->addIndex(
$setup->getIdxName($employeeEntity . '_entity_decimal', ['attribute_id']),
['attribute_id']
)
前面的代码在 foggyline_office_employee_entity_decimal 表上添加了三个索引,结果生成以下 SQL:
-
UNIQUE KEY 'FOGGYLINE_OFFICE_EMPLOYEE_ENTT_DEC_ENTT_ID_ATTR_ID_STORE_ID' ('entity_id','attribute_id','store_id') -
KEY 'FOGGYLINE_OFFICE_EMPLOYEE_ENTITY_DECIMAL_STORE_ID' ('store_id') -
KEY 'FOGGYLINE_OFFICE_EMPLOYEE_ENTITY_DECIMAL_ATTRIBUTE_ID' ('attribute_id')
同样,我们将前面代码中的 //->addForeignKey 部分替换为以下内容:
->addForeignKey(
$setup->getFkName(
$employeeEntity . '_entity_decimal',
'attribute_id',
'eav_attribute',
'attribute_id'
),
'attribute_id',
$setup->getTable('eav_attribute'),
'attribute_id',
\Magento\Framework\DB\Ddl\Table::ACTION_CASCADE
)
->addForeignKey(
$setup->getFkName(
$employeeEntity . '_entity_decimal',
'entity_id',
$employeeEntity . '_entity',
'entity_id'
),
'entity_id',
$setup->getTable($employeeEntity . '_entity'),
'entity_id',
\Magento\Framework\DB\Ddl\Table::ACTION_CASCADE
)
->addForeignKey(
$setup->getFkName($employeeEntity . '_entity_decimal', 'store_id', 'store', 'store_id'),
'store_id',
$setup->getTable('store'),
'store_id',
\Magento\Framework\DB\Ddl\Table::ACTION_CASCADE
)
前面的代码将外键关系添加到 foggyline_office_employee_entity_decimal 表中,结果生成以下 SQL:
-
CONSTRAINT 'FK_D17982EDA1846BAA1F40E30694993801' FOREIGN KEY ('entity_id') REFERENCES 'foggyline_office_employee_entity' ('entity_id') ON DELETE CASCADE, -
CONSTRAINT 'FOGGYLINE_OFFICE_EMPLOYEE_ENTITY_DECIMAL_STORE_ID_STORE_STORE_ID' FOREIGN KEY ('store_id') REFERENCES 'store' ('store_id') ON DELETE CASCADE, -
CONSTRAINT 'FOGGYLINE_OFFICE_EMPLOYEE_ENTT_DEC_ATTR_ID_EAV_ATTR_ATTR_ID' FOREIGN KEY ('attribute_id') REFERENCES 'eav_attribute' ('attribute_id') ON DELETE CASCADE
注意我们如何将 store_id 列添加到我们的 EAV 属性值表中。尽管我们的示例不会使用它,但使用 store_id 与 EAV 实体一起定义数据范围是一个好习惯。为了进一步说明,想象我们有一个多店铺设置,并且像前面的 EAV 属性表那样设置,我们就能为每个店铺存储不同的属性值,因为表中的唯一条目定义为 entity_id、attribute_id 和 store_id 列的组合。
小贴士
为了性能和数据完整性的原因,根据良好的数据库设计实践定义索引和外键非常重要。我们可以在定义新表时在 InstallSchema 中这样做。
创建升级模式脚本(UpgradeSchema.php)
在第一次模块安装期间,安装模式之后立即运行升级模式。我们在 app/code/Foggyline/Office/Setup/UpgradeSchema.php 文件中定义升级模式,其部分内容如下:
namespace Foggyline\Office\Setup;
use Magento\Framework\Setup\UpgradeSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
class UpgradeSchema implements UpgradeSchemaInterface
{
public function upgrade(SchemaSetupInterface $setup, ModuleContextInterface $context)
{
$setup->startSetup();
/* #snippet1 */
$setup->endSetup();
}
}
UpgradeSchema 遵循 UpgradeSchemaInterface,这要求实现接受两个参数 SchemaSetupInterface 和 ModuleContextInterface 的 upgrade 方法。
这与 InstallSchemaInterface 非常相似,除了方法名。当此模式被触发时,会运行 update 方法。在这个方法中,我们会添加任何我们可能想要执行的代码。
进一步来说,让我们将前面代码中的 /* #snippet1 */ 部分替换为以下代码:
$employeeEntityTable = \Foggyline\Office\Model\Employee::ENTITY. '_entity';
$departmentEntityTable = 'foggyline_office_department';
$setup->getConnection()
->addForeignKey(
$setup->getFkName($employeeEntityTable, 'department_id', $departmentEntityTable, 'entity_id'),
$setup->getTable($employeeEntityTable),
'department_id',
$setup->getTable($departmentEntityTable),
'entity_id',
\Magento\Framework\DB\Ddl\Table::ACTION_CASCADE
);
在这里,我们指示 Magento 在 foggyline_office_employee_entity 表上创建一个外键,更确切地说是在其 department_id 列上,指向 foggyline_office_department 表及其 entity_id 列。
创建安装数据脚本(InstallData.php)
安装数据脚本是在升级架构后立即运行的。我们在app/code/Foggyline/Office/Setup/InstallData.php文件中定义安装数据架构,其内容如下(部分):
namespace Foggyline\Office\Setup;
use Magento\Framework\Setup\InstallDataInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;
class InstallData implements InstallDataInterface
{
private $employeeSetupFactory;
public function __construct(
\Foggyline\Office\Setup\EmployeeSetupFactory $employeeSetupFactory
)
{
$this->employeeSetupFactory = $employeeSetupFactory;
}
public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
{
$setup->startSetup();
/* #snippet1 */
$setup->endSetup();
}
}
InstallData符合InstallDataInterface,这要求实现接受两个类型为ModuleDataSetupInterface和ModuleContextInterface的参数的install方法。
当此脚本被触发时,将运行install方法。在这个方法中,我们会添加任何可能想要执行的代码。
进一步来说,让我们用以下代码替换前面代码中的/* #snippet1 */部分:
$employeeEntity = \Foggyline\Office\Model\Employee::ENTITY;
$employeeSetup = $this->employeeSetupFactory->create(['setup' => $setup]);
$employeeSetup->installEntities();
$employeeSetup->addAttribute(
$employeeEntity, 'service_years', ['type' => 'int']
);
$employeeSetup->addAttribute(
$employeeEntity, 'dob', ['type' => 'datetime']
);
$employeeSetup->addAttribute(
$employeeEntity, 'salary', ['type' => 'decimal']
);
$employeeSetup->addAttribute(
$employeeEntity, 'vat_number', ['type' => 'varchar']
);
$employeeSetup->addAttribute(
$employeeEntity, 'note', ['type' => 'text']
);
使用\Foggyline\Office\Setup\EmployeeSetupFactory实例上的addAttribute方法,我们指示 Magento 向其实体添加多个属性(service_years、dob、salary、vat_number、note)。
我们很快就会进入EmployeeSetupFactory的内部,但现在请注意对addAttribute方法的调用。在这个方法中,有一个对$this->attributeMapper->map($attr, $entityTypeId)方法的调用。attributeMapper符合Magento\Eav\Model\Entity\Setup\PropertyMapperInterface,查看vendor/magento/module-eav/etc/di.xml,它对Magento\Eav\Model\Entity\Setup\PropertyMapper\Composite类有优先权,该类进一步初始化以下映射类:
-
Magento\Eav\Model\Entity\Setup\PropertyMapper -
Magento\Customer\Model\ResourceModel\Setup\PropertyMapper -
Magento\Catalog\Model\ResourceModel\Setup\PropertyMapper -
Magento\ConfigurableProduct\Model\ResourceModel\Setup\PropertyMapper
由于我们正在定义自己的实体类型,我们主要感兴趣的映射类是Magento\Eav\Model\Entity\Setup\PropertyMapper。快速查看它,我们发现map方法中的以下映射数组:
[
'backend_model' => 'backend',
'backend_type' => 'type',
'backend_table' => 'table',
'frontend_model' => 'frontend',
'frontend_input' => 'input',
'frontend_label' => 'label',
'frontend_class' => 'frontend_class',
'source_model' => 'source',
'is_required' => 'required',
'is_user_defined' => 'user_defined',
'default_value' => 'default',
'is_unique' => 'unique',
'note' => 'note'
'is_global' => 'global'
]
通过查看前面的数组键和值字符串,我们可以了解正在发生的事情。键字符串与eav_attribute表中的列名匹配,而值字符串与我们在InstallData.php中通过addAttribute方法传递给数组的键匹配。
让我们看看app/code/Foggyline/Office/Setup/EmployeeSetup.php文件中的EmployeeSetupFactory类,其定义如下(部分):
namespace Foggyline\Office\Setup;
use Magento\Eav\Setup\EavSetup;
class EmployeeSetup extends EavSetup
{
public function getDefaultEntities()
{
/* #snippet1 */
}
}
这里发生的事情是我们从Magento\Eav\Setup\EavSetup类扩展,从而有效地告诉 Magento 我们即将创建自己的实体。我们通过重写getDefaultEntities,用以下内容替换/* #snippet1 */来实现这一点:
$employeeEntity = \Foggyline\Office\Model\Employee::ENTITY;
$entities = [
$employeeEntity => [
'entity_model' => 'Foggyline\Office\Model\ResourceModel\Employee',
'table' => $employeeEntity . '_entity',
'attributes' => [
'department_id' => [
'type' => 'static',
],
'email' => [
'type' => 'static',
],
'first_name' => [
'type' => 'static',
],
'last_name' => [
'type' => 'static',
],
],
],
];
return $entities;
getDefaultEntities方法返回一个我们想要与 Magento 注册的实体数组。在我们的$entities数组中,键$employeeEntity成为eav_entity_type表中的一个条目。鉴于我们的$employeeEntity的值为foggyline_office_employee,运行以下 SQL 查询应该会产生结果:
SELECT * FROM eav_entity_type WHERE entity_type_code = "foggyline_office_employee";
为了使我们的新实体类型能够正常工作,只需要少量元数据值。entity_model 值应指向我们的 EAV 模型 resource 类,而不是 model 类。表值应等于数据库中我们的 EAV 实体表名称。最后,属性数组应列出我们想要在此实体上创建的任何属性。属性及其元数据在 eav_attribute 表中创建。
如果我们回顾一下我们创建的所有那些 foggyline_office_employee_entity_* 属性值表,它们并不是真正创建属性或注册新实体类型的。创建属性和新实体类型的是我们在 getDefaultEntities 方法下定义的数组。一旦 Magento 创建了属性并注册了新实体类型,它就会将实体保存过程路由到适当的属性值表,具体取决于属性类型。
创建升级数据脚本(UpgradeData.php)
升级数据脚本是在最后执行的。我们将使用它来演示为我们的 Department 和 Employee 实体创建示例条目的示例。
我们首先创建 app/code/Foggyline/Office/Setup/UpgradeData.php 文件,其(部分)内容如下:
namespace Foggyline\Office\Setup;
use Magento\Framework\Setup\UpgradeDataInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;
class UpgradeData implements UpgradeDataInterface
{
protected $departmentFactory;
protected $employeeFactory;
public function __construct(
\Foggyline\Office\Model\DepartmentFactory $departmentFactory,
\Foggyline\Office\Model\EmployeeFactory $employeeFactory
)
{
$this->departmentFactory = $departmentFactory;
$this->employeeFactory = $employeeFactory;
}
public function upgrade(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
{
$setup->startSetup();
/* #snippet1 */
$setup->endSetup();
}
}
UpgradeData 遵循 UpgradeDataInterface,该接口要求实现接受两个参数(类型为 ModuleDataSetupInterface 和 ModuleContextInterface)的升级方法。我们进一步添加了自己的 __construct 方法,将 DepartmentFactory 和 EmployeeFactory 传递给它,因为我们将在下一个示例中在升级方法中使用它们,通过将 /* #snippet1 */ 替换为以下代码:
$salesDepartment = $this->departmentFactory->create();
$salesDepartment->setName('Sales');
$salesDepartment->save();
$employee = $this->employeeFactory->create();
$employee->setDepartmentId($salesDepartment->getId());
$employee->setEmail('john@sales.loc');
$employee->setFirstName('John');
$employee->setLastName('Doe');
$employee->setServiceYears(3);
$employee->setDob('1983-03-28');
$employee->setSalary(3800.00);
$employee->setVatNumber('GB123456789');
$employee->setNote('Just some notes about John');
$employee->save();
上述代码创建了一个部门实体的实例并将其保存。然后创建了一个员工实例并保存,传递给它新创建的部门 ID 和其他属性。
小贴士
保存实体的一种更方便、更专业的做法可以是以下这样:
$employee->setDob('1983-03-28')
->setSalary(3800.00)
->setVatNumber('GB123456789')
->save();
在这里,我们利用了每个实体设置方法都返回 $this(实体对象本身的实例)的事实,因此我们可以链式调用方法。
实体 CRUD 操作
到目前为止,我们已经学习了如何创建一个简单的模型、一个 EAV 模型,以及安装和升级类型的数据脚本。现在,让我们看看我们如何创建、读取、更新和删除我们的实体,这些操作通常被称为 CRUD。
虽然这一章是关于模型、集合和相关内容的,但为了演示的目的,让我们稍微偏离一下路由和控制器。想法是创建一个简单的 Test 控制器,我们可以通过 URL 触发其 Crud 动作。然后,在 Crud 动作中,我们将输出我们的 CRUD 相关代码。
要使 Magento 对应于我们在浏览器中输入的 URL,我们需要定义路由。我们通过创建包含以下内容的 app/code/Foggyline/Office/etc/frontend/routes.xml 文件来实现:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:App/ etc/routes.xsd">
<router id="standard">
<route id="foggyline_office" frontName="foggyline_office">
<module name="Foggyline_Office"/>
</route>
</router>
</config>
路由定义需要唯一的 ID 和frontName属性值,在我们的案例中这两个都等于foggyline_office。frontName属性值成为我们 URL 结构的一部分。简单来说,访问Crud操作的 URL 公式如下:{magento-base-url}/index.php/{route frontName}/{controller name}/{action name}
注意
例如,如果我们的基础 URL 是http://shop.loc/,完整的 URL 将是http://shop.loc/index.php/foggyline_office/test/crud/。如果我们启用了 URL 重写,我们可以省略index.php部分。
一旦定义了路由,我们就可以继续创建Test控制器,它在app/code/Foggyline/Office/Controller/Test.php文件中定义(部分)代码如下:
namespace Foggyline\Office\Controller;
abstract class Test extends \Magento\Framework\App\Action\Action
{
}
这真的是我们能够定义的最简单的控制器。这里唯一值得注意的事情是,控制器类需要定义为抽象类,并扩展\Magento\Framework\App\Action\Action类。控制器动作位于控制器外部,可以在同一级别的子目录下找到,命名为控制器。由于我们的控制器名为Test,我们将我们的Crud动作放在app/code/Foggyline/Office/Controller/Test/Crud.php文件中,内容如下:
namespace Foggyline\Office\Controller\Test;
class Crud extends \Foggyline\Office\Controller\Test
{
protected $employeeFactory;
protected $departmentFactory;
public function __construct(
\Magento\Framework\App\Action\Context $context,
\Foggyline\Office\Model\EmployeeFactory $employeeFactory,
\Foggyline\Office\Model\DepartmentFactory $departmentFactory
)
{
$this->employeeFactory = $employeeFactory;
$this->departmentFactory = $departmentFactory;
return parent::__construct($context);
}
public function execute()
{
/* CRUD Code Here */
}
}
Controller动作类基本上是控制器定义execute方法的扩展。当我们在浏览器中点击 URL 时,会运行execute方法中的代码。此外,我们还有一个__construct方法,我们将EmployeeFactory和DepartmentFactory类传递给它,我们将在我们的 CRUD 示例中使用这些类。请注意,EmployeeFactory和DepartmentFactory不是我们创建的类。Magento 将在var/generation/Foggyline/Office/Model文件夹中的DepartmentFactory.php和EmployeeFactory.php文件下自动生成它们。这些是我们Employee和Department模型类的工厂类,在请求时生成。
有了这个,我们就结束了这个小插曲,重新关注我们的实体。
创建新实体
如果我们可以称它们为不同的风味,我们有三种方式可以设置实体(字段和属性)的属性值。它们都会产生相同的结果。以下几个代码片段可以复制粘贴到我们的Crud类的execute方法中进行测试,只需将/* CRUD Code Here */替换为以下代码片段之一:
//Simple model, creating new entities, flavour #1
$department1 = $this->departmentFactory->create();
$department1->setName('Finance');
$department1->save();
//Simple model, creating new entities, flavour #2
$department2 = $this->departmentFactory->create();
$department2->setData('name', 'Research');
$department2->save();
//Simple model, creating new entities, flavour #3
$department3 = $this->departmentFactory->create();
$department3->setData(['name' => 'Support']);
$department3->save();
前面代码中的flavour #1方法可能是设置属性的首选方式,因为它使用了我们之前提到的魔术方法方法。flavour #2和flavour #3都使用了setData方法,只是方式略有不同。一旦在object实例上调用save方法,这三个示例都应该产生相同的结果。
现在我们知道了如何保存简单的模型,让我们快速看一下如何使用 EAV 模型做同样的事情。以下是对应的代码片段:
//EAV model, creating new entities, flavour #1
$employee1 = $this->employeeFactory->create();
$employee1->setDepartment_id($department1->getId());
$employee1->setEmail('goran@mail.loc');
$employee1->setFirstName('Goran');
$employee1->setLastName('Gorvat');
$employee1->setServiceYears(3);
$employee1->setDob('1984-04-18');
$employee1->setSalary(3800.00);
$employee1->setVatNumber('GB123451234');
$employee1->setNote('Note #1');
$employee1->save();
//EAV model, creating new entities, flavour #2
$employee2 = $this->employeeFactory->create();
$employee2->setData('department_id', $department2->getId());
$employee2->setData('email', 'marko@mail.loc');
$employee2->setData('first_name', 'Marko');
$employee2->setData('last_name', 'Tunukovic');
$employee2->setData('service_years', 3);
$employee2->setData('dob', '1984-04-18');
$employee2->setData('salary', 3800.00);
$employee2->setData('vat_number', 'GB123451234');
$employee2->setData('note', 'Note #2');
$employee2->save();
//EAV model, creating new entities, flavour #3
$employee3 = $this->employeeFactory->create();
$employee3->setData([
'department_id' => $department3->getId(),
'email' => 'ivan@mail.loc',
'first_name' => 'Ivan',
'last_name' => 'Telebar',
'service_years' => 2,
'dob' => '1986-08-22',
'salary' => 2400.00,
'vat_number' => 'GB123454321',
'note' => 'Note #3'
]);
$employee3->save();
如我们所见,用于持久化数据的 EAV 代码与简单模型相同。这里有一件事值得注意。Employee实体定义了一个指向部门的关联。忘记在新的employee实体保存时指定department_id会导致类似于以下错误信息的错误:
SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails ('magento'.'foggyline_office_employee_entity', CONSTRAINT 'FK_E2AEE8BF21518DFA8F02B4E95DC9F5AD' FOREIGN KEY ('department_id') REFERENCES 'foggyline_office_department' ('entity_id') ON), query was: INSERT INTO 'foggyline_office_employee_entity' ('email', 'first_name', 'last_name', 'entity_id') VALUES (?, ?, ?, ?)
Magento 将其中的这些类型错误保存在其var/report目录下。
读取现有实体
根据提供的实体 ID 值读取实体归结为实例化实体,并使用传递实体 ID 的load方法,如下所示:
//Simple model, reading existing entities
$department = $this->departmentFactory->create();
$department->load(28);
/*
\Zend_Debug::dump($department->toArray());
array(2) {
["entity_id"] => string(2) "28"
["name"] => string(8) "Research"
}
*/
如下所示,加载简单模型或 EAV 模型之间实际上没有真正的区别:
//EAV model, reading existing entities
$employee = $this->employeeFactory->create();
$employee->load(25);
/*
\Zend_Debug::dump($employee->toArray());
array(10) {
["entity_id"] => string(2) "25"
["department_id"] => string(2) "28"
["email"] => string(14) "marko@mail.loc"
["first_name"] => string(5) "Marko"
["last_name"] => string(9) "Tunukovic"
["dob"] => string(19) "1984-04-18 00:00:00"
["note"] => string(7) "Note #2"
["salary"] => string(9) "3800.0000"
["service_years"] => string(1) "3"
["vat_number"] => string(11) "GB123451234"
}
*/
注意到 EAV 实体加载了所有的字段和属性值,这在我们通过 EAV 集合获取实体时并不总是如此,我们将在稍后展示。
更新现有实体
更新实体归结为使用load方法读取现有实体,重置其值,并在最后调用save方法,如下例所示:
$department = $this->departmentFactory->create();
$department->load(28);
$department->setName('Finance #2');
$department->save();
无论实体是简单模型还是 EAV,代码都是相同的。
删除现有实体
在已加载的实体上调用delete方法将删除该实体从数据库中,或者在失败时抛出Exception。删除实体的代码如下所示:
$employee = $this->employeeFactory->create();
$employee->load(25);
$employee->delete();
删除简单实体和 EAV 实体之间没有区别。我们在删除或保存实体时应该始终使用 try/catch 块。
管理集合
让我们从 EAV 模型集合开始。我们可以通过以下方式通过实体factory类实例化集合:
$collection = $this->employeeFactory->create()
->getCollection();
或者我们可以使用对象管理器来实例化集合,如下所示:
$collection = $this->_objectManager->create(
'Foggyline\Office\Model\ResourceModel\Employee\Collection's
);
还有第三种方法,这可能是一个更受欢迎的方法,但它要求我们定义 API,所以我们暂时跳过这个方法。
一旦我们实例化了集合对象,我们就可以遍历它并对单个$employee实体进行一些变量转储,以查看其内容,如下所示:
foreach ($collection as $employee) {
\Zend_Debug::dump($employee->toArray(), '$employee');
}
前面的操作会产生如下结果:
$employee array(5) {
["entity_id"] => string(2) "24"
["department_id"] => string(2) "27"
["email"] => string(14) "goran@mail.loc"
["first_name"] => string(5) "Goran"
["last_name"] => string(6) "Gorvat"
}
注意到单个$employee只有字段,没有属性。让我们看看当我们想要通过使用addAttributeToSelect来指定要添加到集合中的单个属性时会发生什么,如下所示:
$collection->addAttributeToSelect('salary')
->addAttributeToSelect('vat_number');
前面的操作会产生如下结果:
$employee array(7) {
["entity_id"] => string(2) "24"
["department_id"] => string(2) "27"
["email"] => string(14) "goran@mail.loc"
["first_name"] => string(5) "Goran"
["last_name"] => string(6) "Gorvat"
["salary"] => string(9) "3800.0000"
["vat_number"] => string(11) "GB123451234"
}
虽然我们在取得进步,但想象一下如果我们有数十个属性,并且我们希望每个属性都包含在集合中。多次使用addAttributeToSelect会导致代码杂乱。我们可以通过将'*'作为参数传递给addAttributeToSelect,让集合获取每个属性,如下所示:
$collection->addAttributeToSelect('*');
这会产生如下结果:
$employee array(10) {
["entity_id"] => string(2) "24"
["department_id"] => string(2) "27"
["email"] => string(14) "goran@mail.loc"
["first_name"] => string(5) "Goran"
["last_name"] => string(6) "Gorvat"
["dob"] => string(19) "1984-04-18 00:00:00"
["note"] => string(7) "Note #1"
["salary"] => string(9) "3800.0000"
["service_years"] => string(1) "3"
["vat_number"] => string(11) "GB123451234"
}
虽然代码的 PHP 部分看起来似乎很简单,但在 SQL 层面上发生的事情相对复杂。虽然 Magento 在获取最终集合结果之前执行了几个 SQL 查询,但让我们关注下面显示的最后三个查询:
SELECT COUNT(*) FROM 'foggyline_office_employee_entity' AS 'e'
SELECT 'e'.* FROM 'foggyline_office_employee_entity' AS 'e'
SELECT
'foggyline_office_employee_entity_datetime'.'entity_id',
'foggyline_office_employee_entity_datetime'.'attribute_id',
'foggyline_office_employee_entity_datetime'.'value'
FROM 'foggyline_office_employee_entity_datetime'
WHERE (entity_id IN (24, 25, 26)) AND (attribute_id IN ('349'))
UNION ALL SELECT
'foggyline_office_employee_entity_text'.'entity_id',
'foggyline_office_employee_entity_text'.' attribute_id',
'foggyline_office_employee_entity_text'.'value'
FROM 'foggyline_office_employee_entity_text'
WHERE (entity_id IN (24, 25, 26)) AND (attribute_id IN ('352'))
UNION ALL SELECT
'foggyline_office_employee_entity_decimal'.' entity_id',
'foggyline_office_employee_entity_decimal'.' attribute_id',
'foggyline_office_employee_entity_decimal'.'value'
FROM 'foggyline_office_employee_entity_decimal'
WHERE (entity_id IN (24, 25, 26)) AND (attribute_id IN ('350'))
UNION ALL SELECT
'foggyline_office_employee_entity_int'.'entity_id',
'foggyline_office_employee_entity_int'.'attribute_id',
'foggyline_office_employee_entity_int'.'value'
FROM 'foggyline_office_employee_entity_int'
WHERE (entity_id IN (24, 25, 26)) AND (attribute_id IN ('348'))
UNION ALL SELECT
'foggyline_office_employee_entity_varchar'.' entity_id',
'foggyline_office_employee_entity_varchar'.' attribute_id',
'foggyline_office_employee_entity_varchar'.'value'
FROM 'foggyline_office_employee_entity_varchar'
WHERE (entity_id IN (24, 25, 26)) AND (attribute_id IN ('351'))
注意
在我们继续之前,了解这些查询不能直接复制粘贴是很重要的。原因是 attribute_id 值肯定会在不同的安装中有所不同。这里给出的查询是为了让我们在 PHP 应用程序级别使用 Magento 集合时,在 SQL 层面上获得对后台发生的事情的高级理解。
第一个查询只是简单地计算实体表中的条目数量,然后将这个信息传递到应用层。第二个查询从 foggyline_office_employee_entity 中检索所有条目,然后将这些信息传递到应用层,以便在第三个查询中将实体 ID 作为 entity_id IN (24, 25, 26) 的一部分传递。如果我们的实体和 EAV 表中有大量条目,这里的第二个和第三个查询可能会非常消耗资源。为了防止可能出现的性能瓶颈,我们应该始终在集合上使用 setPageSize 和 setCurPage 方法,如下所示:
$collection->addAttributeToSelect('*')
->setPageSize(25)
->setCurPage(5);
这会导致第一个 COUNT 查询仍然保持不变,但第二个查询现在看起来如下所示:
SELECT 'e'.* FROM 'foggyline_office_employee_entity' AS 'e' LIMIT 25 OFFSET 4
如果我们有成千上万或数万条条目,这将使得数据集更小,从而性能更轻。这里的要点是始终使用 setPageSize 和 setCurPage。如果我们需要处理一个非常大的集合,那么我们需要分页浏览它,或者逐个遍历它。
现在我们知道了如何限制结果集的大小并获取正确的页面,让我们看看我们如何进一步过滤集合以避免过度使用 PHP 循环来完成同样的目的。因此,有效地将过滤传递到数据库而不是应用层。为了过滤 EAV 集合,我们使用它的 addAttributeToFilter 方法。
让我们实例化一个干净的新集合,如下所示:
$collection = $this->_objectManager->create(
'Foggyline\Office\Model\ResourceModel\Employee\Collection'
);
$collection->addAttributeToSelect('*')
->setPageSize(25)
->setCurPage(1);
$collection->addAttributeToFilter('email', array('like'=>'%mail.loc%'))
->addAttributeToFilter('vat_number', array('like'=>'GB%'))
->addAttributeToFilter('salary', array('gt'=>2400))
->addAttributeToFilter('service_years', array('lt'=>10));
注意我们现在正在集合上使用 addAttributeToSelect 和 addAttributeToFilter 方法。我们已经看到了 addAttributeToSelect 对 SQL 查询的数据库影响。addAttributeToFilter 做的事情则完全不同。
使用 addAttributeToFilter 方法,计数查询现在被转换成以下 SQL 查询:
SELECT COUNT(*)
FROM 'foggyline_office_employee_entity' AS 'e'
INNER JOIN 'foggyline_office_employee_entity_varchar' AS 'at_vat_number'
ON ('at_vat_number'.'entity_id' = 'e'.'entity_id') AND ('at_vat_number'.'attribute_id' = '351')
INNER JOIN 'foggyline_office_employee_entity_decimal' AS 'at_salary'
ON ('at_salary'.'entity_id' = 'e'.'entity_id') AND ('at_salary'.'attribute_id' = '350')
INNER JOIN 'foggyline_office_employee_entity_int' AS 'at_service_years'
ON ('at_service_years'.'entity_id' = 'e'.'entity_id') AND ('at_service_years'.'attribute_id' = '348')
WHERE ('e'.'email' LIKE '%mail.loc%') AND (at_vat_number.value LIKE 'GB%') AND (at_salary.value > 2400) AND
(at_service_years.value < 10)
我们可以看到这比之前的计数查询要复杂得多,现在有 INNER JOIN 介入。注意我们如何有四个 addAttributeToFilter 方法调用,但只有三个 INNER JOIN。这是因为其中四个调用之一是用于电子邮件的,它不是一个属性,而是 foggyline_office_employee_entity 表中的一个字段。这就是为什么不需要 INNER JOIN,因为该字段已经存在。然后三个 INNER JOIN 简单地将所需信息合并到查询中,以获取选择。
第二个查询也变得更加健壮,如下所示:
SELECT
'e'.*,
'at_vat_number'.'value' AS 'vat_number',
'at_salary'.'value' AS 'salary',
'at_service_years'.'value' AS 'service_years'
FROM 'foggyline_office_employee_entity' AS 'e'
INNER JOIN 'foggyline_office_employee_entity_varchar' AS 'at_vat_number'
ON ('at_vat_number'.'entity_id' = 'e'.'entity_id') AND ('at_vat_number'.'attribute_id' = '351')
INNER JOIN 'foggyline_office_employee_entity_decimal' AS 'at_salary'
ON ('at_salary'.'entity_id' = 'e'.'entity_id') AND ('at_salary'.'attribute_id' = '350')
INNER JOIN 'foggyline_office_employee_entity_int' AS 'at_service_years'
ON ('at_service_years'.'entity_id' = 'e'.'entity_id') AND ('at_service_years'.'attribute_id' = '348')
WHERE ('e'.'email' LIKE '%mail.loc%') AND (at_vat_number.value LIKE 'GB%') AND (at_salary.value > 2400) AND
(at_service_years.value < 10)
LIMIT 25
这里,我们还可以看到 INNER JOIN 的使用。我们也有三个而不是四个 INNER JOIN,因为其中一个条件是对 email 字段的。查询的结果是一个扁平化的行块,其中包含 vat_number、salary 和 service_years 等属性。我们可以想象如果没有使用 setPageSize 限制结果集,性能会受到怎样的影响。
最后,第三个查询也受到影响,现在看起来类似于以下内容:
SELECT
'foggyline_office_employee_entity_datetime'.'entity_id',
'foggyline_office_employee_entity_datetime'.'attribute_id',
'foggyline_office_employee_entity_datetime'.'value'
FROM 'foggyline_office_employee_entity_datetime'
WHERE (entity_id IN (24, 25)) AND (attribute_id IN ('349'))
UNION ALL SELECT
'foggyline_office_employee_entity_text'.'entity_id',
'foggyline_office_employee_entity_text'.' attribute_id',
'foggyline_office_employee_entity_text'.'value'
FROM 'foggyline_office_employee_entity_text'
WHERE (entity_id IN (24, 25)) AND (attribute_id IN ('352'))
注意这里 UNION ALL 已经减少到单个出现,从而有效地形成了两个选择。这是因为我们总共有五个属性(service_years、dob、salary、vat_number、note),其中三个是通过第二个查询获取的。在前面的三个查询示例中,Magento 主要从第二个和第三个查询中提取集合数据。这看起来像是一个相当优化和可扩展的解决方案,尽管我们真的应该仔细思考在创建集合时正确使用 setPageSize、addAttributeToSelect 和 addAttributeToFilter 方法。
在开发过程中,如果正在处理具有大量属性、过滤器和可能的大型数据集的集合,我们可能希望使用 SQL 日志记录实际击中数据库服务器的 SQL 查询。这可能会帮助我们及时发现可能的性能瓶颈并及时做出反应,无论是通过向 setPageSize 或 addAttributeToSelect 添加更多限制值,还是两者都添加。
在前面的示例中,使用 addAttributeToSelect 导致 SQL 层上的 AND 条件。如果我们想使用 OR 条件来过滤集合怎么办?如果 $attribute 参数以以下方式使用,addAttributeToSelect 也可以导致 SQL OR 条件:
$collection->addAttributeToFilter([
['attribute'=>'salary', 'gt'=>2400],
['attribute'=>'vat_number', 'like'=>'GB%']
]);
这次不深入实际 SQL 查询的细节,只需说它们几乎与之前的示例相同,使用了 addAttributeToFilter 的 AND 条件。
使用 addExpressionAttributeToSelect、groupByAttribute 和 addAttributeToSort 等集合方法,集合提供了进一步的梯度过滤,甚至可以将一些计算从 PHP 应用层转移到 SQL 层。深入了解这些和其他集合方法超出了本章的范围,可能需要一本单独的书籍。
集合过滤器
回顾前面的 addAttributeToFilter 方法调用示例,人们可能会问在哪里可以看到所有可用的集合过滤器的列表。如果我们快速查看 vendor/magento/framework/DB/Adapter/Pdo/Mysql.php 文件,我们可以看到名为 prepareSqlCondition 的方法(部分)定义如下:
public function prepareSqlCondition($fieldName, $condition)
{
$conditionKeyMap = [
'eq' => "{{fieldName}} = ?",
'neq' => "{{fieldName}} != ?",
'like' => "{{fieldName}} LIKE ?",
'nlike' => "{{fieldName}} NOT LIKE ?",
'in' => "{{fieldName}} IN(?)",
'nin' => "{{fieldName}} NOT IN(?)",
'is' => "{{fieldName}} IS ?",
'notnull' => "{{fieldName}} IS NOT NULL",
'null' => "{{fieldName}} IS NULL",
'gt' => "{{fieldName}} > ?",
'lt' => "{{fieldName}} /* AJZELE */ < ?",
'gteq' => "{{fieldName}} >= ?",
'lteq' => "{{fieldName}} <= ?",
'finset' => "FIND_IN_SET(?, {{fieldName}})",
'regexp' => "{{fieldName}} REGEXP ?",
'from' => "{{fieldName}} >= ?",
'to' => "{{fieldName}} <= ?",
'seq' => null,
'sneq' => null,
'ntoa' => "INET_NTOA({{fieldName}}) LIKE ?",
];
$query = '';
if (is_array($condition)) {
$key = key(array_intersect_key($condition, $conditionKeyMap));
...
}
这种方法是在 SQL 查询构建过程中的某个时刻最终被调用的。期望 $condition 参数具有以下(部分列出)形式之一:
-
array("from" => $fromValue, "to" => $toValue) -
array("eq" => $equalValue) -
array("neq" => $notEqualValue) -
array("like" => $likeValue) -
array("in" => array($inValues)) -
array("nin" => array($notInValues)) -
array("notnull" => $valueIsNotNull) -
array("null" => $valueIsNull) -
array("gt" => $greaterValue) -
array("lt" => $lessValue) -
array("gteq" => $greaterOrEqualValue) -
array("lteq" => $lessOrEqualValue) -
array("finset" => $valueInSet) -
array("regexp" => $regularExpression) -
array("seq" => $stringValue) -
array("sneq" => $stringValue)
如果$condition作为整数或字符串传递,则将过滤确切值('eq'条件)。如果没有匹配任何条件,则期望参数为一个顺序数组,并将使用前面的结构构建OR条件。
上述示例涵盖了 EAV 模型集合,因为它们稍微复杂一些。尽管过滤的方法在简单模型集合中也大致适用,但最显著的区别是没有addAttributeToFilter、addAttributeToSelect和addExpressionAttributeToSelect方法。简单模型集合使用addFieldToFilter、addFieldToSelect和addExpressionFieldToSelect等方法,以及其他细微的区别。
摘要
在本章中,我们首先学习了如何创建简单的模型、其资源以及集合类。然后我们对 EAV 模型也进行了同样的操作。一旦我们有了所需的模型、资源和集合类,我们就详细地研究了模式和数据脚本的类型和流程。动手实践,我们涵盖了InstallSchema、UpgradeSchema、InstallData和UpgradeData脚本。一旦脚本运行,数据库最终拥有了所需的表和样本数据,这些数据是我们基于实体 CRUD 示例的。最后,我们快速但专注地查看集合管理,这主要涉及过滤集合以获取所需的结果集。
完整的模块代码可以从github.com/ajzele/B05032-Foggyline_Office下载。
第五章. 使用依赖注入
依赖注入是一种软件设计模式,通过该模式,一个或多个依赖项被注入或通过引用传递到对象中。这在实际层面上究竟意味着什么,以下两个简单的示例将展示:
public function getTotalCustomers()
{
$database = new \PDO( … );
$statement = $database->query('SELECT …');
return $statement->fetchColumn();
}
在这里,您将看到一个简化的 PHP 示例,其中$database对象是在getTotalCustomers方法中创建的。这意味着对数据库对象的依赖被锁定在对象实例方法中。这导致了紧密耦合,具有诸如可重用性降低和由于对代码某些部分的更改可能引起的系统级影响等几个缺点。
解决这个问题的方法是通过将依赖注入到方法中来避免具有这些依赖关系的方法,如下所示:
public function getTotalCustomers($database)
{
$statement = $database->query('SELECT ...');
return $statement->fetchColumn();
}
在这里,一个$database对象被传递(注入)到方法中。这就是依赖注入的全部内容——一个简单的概念,它使得代码松散耦合。虽然这个概念很简单,但在像 Magento 这样的大型平台上实现它可能并不容易。
Magento 有其自己的对象管理器和依赖注入机制,我们将在以下部分详细探讨:
-
对象管理器
-
依赖注入
-
配置类偏好
-
使用虚拟类型
注意
要跟踪和测试以下部分给出的代码示例,我们可以使用在github.com/ajzele/B05032-Foggyline_Di可用的代码。要安装它,我们只需下载并将其放入app/code/Foggyline/Di目录。然后,在 Magento 根目录的命令行中运行以下命令集:
php bin/magento module:enable Foggyline_Di
php bin/magento setup:upgrade
php bin/magento foggy:di
在以下部分提供的代码片段测试时,最后一个命令可以重复使用。当运行php bin/magento foggy:di时,它将在DiTestCommand类的execute方法中运行代码。因此,我们可以从DiTestCommand类内部以及di.xml文件本身作为DI的游乐场使用__construct和execute方法。
对象管理器
在 Magento 中,对象的初始化是通过所谓的对象管理器来完成的。对象管理器本身是Magento\Framework\ObjectManager\ObjectManager类的实例,该类实现了Magento\Framework\ObjectManagerInterface接口。ObjectManager类定义了以下三个方法:
-
create($type, array $arguments = []): 这将创建一个新的对象实例 -
get($type): 这将检索一个缓存的实例对象 -
configure(array $configuration): 这将配置di实例
对象管理器可以实例化一个 PHP 类,这可能是一个模型、助手或块对象。除非我们正在工作的类已经接收到了对象管理器的实例,否则我们可以通过将ObjectManagerInterface传递到类构造函数中来接收它,如下所示:
public function __construct(
\Magento\Framework\ObjectManagerInterface $objectManager
)
{
$this->_objectManager = $objectManager;
}
通常,在 Magento 中我们不需要关心构造函数参数的顺序。以下示例也将使我们能够获取对象管理器的一个实例:
public function __construct(
$var1,
\Magento\Framework\ObjectManagerInterface $objectManager,
$var2 = []
)
{
$this->_objectManager = $objectManager;
}
尽管我们仍然可以使用普通的 PHP 来实例化一个对象,例如$object = new \Foggyline\Di\Model\Object(),但通过使用对象管理器,我们可以利用 Magento 的高级对象特性,如自动构造函数依赖注入和对象代理。
这里有一些使用对象管理器的create方法创建新对象的示例:
$this->_objectManager->create('Magento\Sales\Model\Order')
$this->_objectManager->create('Magento\Catalog\Model\Product\Image')
$this->_objectManager->create('Magento\Framework\UrlInterface')
$this->_objectManager->create('SoapServer', ['wsdl' => $url, 'options' => $options])
下面是一些使用对象管理器的get方法创建新对象的示例:
$this->_objectManager->get('Magento\Checkout\Model\Session')
$this->_objectManager->get('Psr\Log\LoggerInterface')->critical($e)
$this->_objectManager->get('Magento\Framework\Escaper')
$this->_objectManager->get('Magento\Sitemap\Helper\Data')
对象管理器的create方法总是返回一个新的对象实例,而get方法返回一个单例。
注意传递给create和get的一些字符串参数实际上是接口名称,而不是严格的类名称。我们很快就会看到为什么它既适用于类名称也适用于接口名称。现在,只需说它之所以有效,是因为 Magento 的依赖注入实现。
依赖注入
到目前为止,我们已经看到了对象管理器如何控制依赖项的实例化。然而,按照惯例,对象管理器不应该在 Magento 中直接使用。相反,它应该用于系统级别的初始化工作。我们被鼓励使用模块的etc/di.xml文件来实例化对象。
让我们分析现有的di.xml条目之一,例如在vendor/magento/module-admin-notification/etc/adminhtml/di.xml文件下为Magento\Framework\Notification\MessageList类型找到的条目:
<type name="Magento\Framework\Notification\MessageList">
<arguments>
<argument name="messages" xsi:type="array">
<item name="baseurl" xsi:type="string"> Magento\AdminNotification\Model\System \Message\Baseurl</item>
<item name="security" xsi:type="string"> Magento\AdminNotification\Model\System\ Message\Security</item>
<item name="cacheOutdated" xsi:type="string"> Magento\AdminNotification\Model\System\ Message\CacheOutdated</item>
<item name="media_synchronization_error" xsi:type="string">Magento\AdminNotification\Model\ System\Message\Media\Synchronization\Error</item>
<item name="media_synchronization_success" xsi:type="string">Magento\AdminNotification\Model\ System\Message\Media\Synchronization\Success</item>
</argument>
</arguments>
</type>
基本上,这意味着每当创建Magento\Framework\Notification\MessageList的实例时,messages参数都会传递给构造函数。messages参数被定义为数组,该数组进一步由其他字符串类型项组成。在这种情况下,这些字符串类型属性的值是类名称,如下所示:
-
Magento\Framework\ObjectManager\ObjectManager -
Magento\AdminNotification\Model\System\Message\Baseurl -
Magento\AdminNotification\Model\System\Message\Security -
Magento\AdminNotification\Model\System\Message\CacheOutdated -
Magento\AdminNotification\Model\System\Message\Media\Synchronization\Error -
Magento\AdminNotification\Model\System\Message\Media\Synchronization\Success
如果你现在查看MessageList的构造函数,你会看到它是以下方式定义的:
public function __construct(
\Magento\Framework\ObjectManagerInterface $objectManager,
$messages = []
)
{
//Method body here...
}
如果我们按照以下方式修改MessageList的构造函数,代码将能够正常工作:
public function __construct(
\Magento\Framework\ObjectManagerInterface $objectManager,
$someVarX = 'someDefaultValueX',
$messages = []
)
{
//Method body here...
}
修改后:
public function __construct(
\Magento\Framework\ObjectManagerInterface $objectManager,
$someVarX = 'someDefaultValueX',
$messages = [],
$someVarY = 'someDefaultValueY'
)
{
//Method body here...
}
然而,如果我们将MessageList的构造函数更改为以下变体之一,代码将无法正常工作:
public function __construct(
\Magento\Framework\ObjectManagerInterface $objectManager,
$Messages = []
)
{
//Method body here...
}
另一种变体如下:
public function __construct(
\Magento\Framework\ObjectManagerInterface $objectManager,
$_messages = []
)
{
//Method body here...
}
在 PHP 类的构造函数中,$messages参数的名称必须与di.xml中参数列表中的参数名称完全匹配。构造函数中参数的顺序并不像它们的命名那样重要。
在进一步查看MessageList构造函数时,如果我们在其内部某处执行func_get_args,则$messages参数中的项目列表将与vendor/magento/module-admin-notification/etc/adminhtml/di.xml中显示的列表相匹配并超过它。这是因为列表不是最终的,因为 Magento 从整个平台收集 DI 定义并将它们合并。因此,如果另一个模块正在修改MessageList类型,修改将反映出来。
如果我们在整个 Magento 代码库的所有di.xml文件中进行字符串搜索,搜索<type name="Magento\Framework\Notification\MessageList">,这将产生一些额外的di.xml文件,它们对MessageList类型有自己的添加,如下所示:
//vendor/magento/module-indexer/etc/adminhtml/di.xml
<type name="Magento\Framework\Notification\MessageList">
<arguments>
<argument name="messages" xsi:type="array">
<item name="indexer_invalid_message" xsi:type="string">Magento\Indexer\Model\Message \Invalid</item>
</argument>
</arguments>
</type>
//vendor/magento/module-tax/etc/adminhtml/di.xml
<type name="Magento\Framework\Notification\MessageList">
<arguments>
<argument name="messages" xsi:type="array">
<item name="tax" xsi:type="string">Magento \Tax\Model\System\Message\Notifications</item>
</argument>
</arguments>
</type>
这意味着Magento\Indexer\Model\Message\Invalid和Magento\Tax\Model\System\Message\Notifications字符串项被添加到messages参数中,并在MessageList构造函数中可用。
在前面的 DI 示例中,我们只定义了$messages参数作为array类型的一个参数,其余的都是其数组项。
让我们看看另一个类型定义的 DI 示例。这次是在vendor/magento/module-backend/etc/di.xml文件下找到的,定义如下:
<type name="Magento\Backend\Model\Url">
<arguments>
<argument name="scopeResolver" xsi:type="object"> Magento\Backend\Model\Url\ScopeResolver</argument>
<argument name="authSession" xsi:type="object"> Magento\Backend\Model\Auth\Session\Proxy</argument>
<argument name="formKey" xsi:type="object"> Magento\Framework\Data\Form\FormKey\Proxy</argument>
<argument name="scopeType" xsi:type="const"> Magento\Store\Model\ScopeInterface::SCOPE_STORE </argument>
<argument name="backendHelper" xsi:type="object"> Magento\Backend\Helper\Data\Proxy</argument>
</arguments>
</type>
在这里,您将看到传递给Magento\Backend\Model\Url类构造函数的几个不同参数的类型。如果您现在查看Url类的构造函数,您将看到它是以以下方式定义的:
public function __construct(
\Magento\Framework\App\Route\ConfigInterface $routeConfig,
\Magento\Framework\App\RequestInterface $request,
\Magento\Framework\Url\SecurityInfoInterface $urlSecurityInfo,
\Magento\Framework\Url\ScopeResolverInterface $scopeResolver,
\Magento\Framework\Session\Generic $session,
\Magento\Framework\Session\SidResolverInterface $sidResolver,
\Magento\Framework\Url\RouteParamsResolverFactory $routeParamsResolverFactory,
\Magento\Framework\Url\QueryParamsResolverInterface $queryParamsResolver,
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
$scopeType,
\Magento\Backend\Helper\Data $backendHelper,
\Magento\Backend\Model\Menu\Config $menuConfig,
\Magento\Framework\App\CacheInterface $cache,
\Magento\Backend\Model\Auth\Session $authSession,
\Magento\Framework\Encryption\EncryptorInterface $encryptor,
\Magento\Store\Model\StoreFactory $storeFactory,
\Magento\Framework\Data\Form\FormKey $formKey,
array $data = []
) {
//Method body here...
}
这里的__construct方法显然比di.xml文件中定义的参数要多。这意味着di.xml中的类型参数条目并不一定涵盖所有类的__construct参数。在di.xml中定义的参数只是简单地强加了 PHP 类本身中定义的各个参数的类型。只要di.xml中的参数类型相同或为同一类型的子类型,这种方法就可以工作。
理想情况下,我们不会将类类型而是接口传递给 PHP 构造函数,然后在di.xml中设置类型。这就是type、preference和virtualType在di.xml中发挥重要作用的地方。我们已经看到了type的作用。现在,让我们继续看看preference是如何工作的。
配置类偏好
许多 Magento 的核心类在构造函数中传递接口。这种做法的好处是,对象管理器在di.xml的帮助下可以决定为给定的接口实际实例化哪个类。
让我们想象一个具有构造函数的Foggyline\Di\Console\Command\DiTestCommand类,如下所示:
public function __construct(
\Foggyline\Di\Model\TestInterface $myArg1,
$myArg2,
$name = null
)
{
//Method body here...
}
注意$myArg1是如何被指定为\Foggyline\Di\Model\TestInterface接口的。对象管理器知道它需要在整个di.xml中查找可能的preference定义。
我们可以在模块的di.xml文件中定义preference,如下所示:
<preference
for="Foggyline\Di\Model\TestInterface"
type="Foggyline\Di\Model\Cart"/>
在这里,我们基本上是在说,当有人请求 Foggyline\Di\Model\TestInterface 的实例时,给它一个 Foggyline\Di\Model\Cart 对象的实例。为了使这可行,Cart 类必须自己实现 TestInterface。一旦 preference 定义到位,先前的例子中显示的 $myArg1 就变成了 Cart 类的对象。
此外,preference 元素不仅限于指出某些接口的首选类。我们可以用它来设置某些其他类的首选类。
现在,让我们看看带有构造函数的 Foggyline\Di\Console\Command\DiTestCommand 类:
public function __construct(
\Foggyline\Di\Model\User $myArg1,
$myArg2,
$name = null
)
{
//Method body here...
}
注意 $myArg1 现在已作为 \Foggyline\Di\Model\User 类进行了类型提示。就像在先前的例子中一样,对象管理器会在 di.xml 中查找可能的 preference 定义。
让我们在模块的 di.xml 文件中定义 preference 元素,如下所示:
<preference
for="\Foggyline\Di\Model\User"
type="Foggyline\Di\Model\Cart"/>
这个 preference 定义的意思是,每当请求 User 类的实例时,传递一个 Cart 对象的实例。这只有在 Cart 类继承自 User 类的情况下才会工作。这是一种重写类的便捷方式,其中类直接传递到另一个类的构造函数中,而不是接口。
由于 __construct 参数可以类型提示为类或接口,并且可以通过 di.xml 的 preference 定义进一步操作,因此会引发一个问题:使用接口还是具体类更好?虽然答案可能并不完全明确,但始终更倾向于使用接口来指定我们注入到系统中的依赖项。
使用虚拟类型
除了 type 和 preference,di.xml 还有一个我们可用的强大功能。virtualType 元素使我们能够定义虚拟类型。创建虚拟类型就像创建现有类的子类一样,只是它是在 di.xml 中而不是在代码中完成的。
虚拟类型 是一种在不影响其他类的情况下将依赖项注入到一些现有类中的方法。为了通过实际例子解释这一点,让我们看看在 app/etc/di.xml 文件中定义的以下虚拟类型:
<virtualType name="Magento\Framework\Message\Session\Storage" type="Magento\Framework\Session\Storage">
<arguments>
<argument name="namespace" xsi:type="string"> message</argument>
</arguments>
</virtualType>
<type name="Magento\Framework\Message\Session">
<arguments>
<argument name="storage" xsi:type="object"> Magento\Framework\Message\Session\Storage</argument>
</arguments>
</type>
先前的例子中的 virtualType 定义是 Magento\Framework\Message\Session\Storage,它继承自 Magento\Framework\Session\Storage 并覆盖了 namespace 参数到 message 字符串值。在 virtualType 中,name 属性定义了虚拟类型的全局唯一名称,而 type 属性与虚拟类型基于的实际 PHP 类相匹配。
现在,如果你查看 type 定义,你会看到其 storage 参数被设置为 Magento\Framework\Message\Session\Storage 对象。Session\Storage 文件实际上是一个虚拟类型。这允许 Message\Session 被定制,而不会影响也声明了对 Session\Storage 依赖的其他类。
虚拟类型允许我们在特定类中使用依赖项时有效地改变其行为。
摘要
在本章中,我们探讨了对象管理器和依赖注入,它们是 Magento 对象管理的基础。我们学习了依赖注入中type和preference元素的含义以及如何使用它们来操作类构造参数。尽管关于 Magento 中的依赖注入还有很多可以说的,但所提供的信息应该足够,并帮助我们了解 Magento 的其他方面。
在下一章中,我们将通过插件的概念扩展我们的旅程到di.xml。
第六章。插件
在本章中,我们将探讨 Magento 中的一个称为 插件 的功能。在我们开始使用插件之前,我们首先需要了解拦截这个术语,因为在处理 Magento 时,这两个术语有时是互换使用的。
拦截 是一种软件设计模式,当我们要动态地插入代码而不必改变原始类的行为时使用。这是通过在调用代码和目标对象之间动态插入代码来实现的。
Magento 中的拦截模式是通过插件实现的。它们提供了 before、after 和 around 监听器,这些监听器帮助我们扩展观察方法的行为。
在本章中,我们将涵盖以下主题:
-
创建一个插件
-
使用
before监听器 -
使用
after监听器 -
使用
around监听器 -
插件排序
在我们开始创建插件之前,值得注意它们的限制。插件不能为任何类或方法创建,因为它们不适用于以下情况:
-
最终类
-
最终方法
-
没有依赖注入创建的类
让我们继续创建一个名为 Foggyline_Plugged 的简单模块的插件。
创建一个插件
首先,创建包含部分内容的 app/code/Foggyline/Plugged/registration.php 文件,如下所示:
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Foggyline_Plugged',
__DIR__
);
然后,创建包含部分内容的 app/code/Foggyline/Plugged/etc/module.xml 文件,如下所示:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/ etc/module.xsd">
<module name="Foggyline_Plugged" setup_version="1.0.0">
<sequence>
<module name="Magento_Catalog"/>
</sequence>
</module>
</config>
前面的文件只是一个新的模块声明,其依赖项针对 Magento_Catalog 模块,因为我们将会观察其类。我们现在不会深入讨论模块声明,因为这将在接下来的章节中介绍。
现在,创建包含部分内容的 app/code/Foggyline/Plugged/etc/di.xml 文件,如下所示:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework: ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Block\Product\AbstractProduct">
<plugin name="foggyPlugin1" type="Foggyline\Plugged\Block\Catalog\Product\ AbstractProductPlugin1" disabled="false" sortOrder="100"/>
<plugin name="foggyPlugin2" type="Foggyline\Plugged\Block\Catalog\Product\ AbstractProductPlugin2" disabled="false" sortOrder="200"/>
<plugin name="foggyPlugin3" type="Foggyline\Plugged\Block\Catalog\Product\ AbstractProductPlugin3" disabled="false" sortOrder="300"/>
</type>
</config>
插件定义在模块 di.xml 文件中。要定义一个插件,我们首先使用 type 元素及其 name 属性映射我们想要观察的类。在这种情况下,我们正在观察 Magento\Catalog\Block\Product\AbstractProduct 类。请注意,尽管文件和类名暗示了一个抽象类型的类,但 AbstractProduct 类并不是抽象的。
在 type 元素中,我们随后使用 plugin 元素定义一个或多个插件。
plugin 元素分配了以下四个属性:
-
name: 使用此属性,您可以提供一个独特且易于识别的名称值,该值特定于插件 -
sortOrder: 此属性确定当多个插件观察同一方法时的执行顺序 -
disabled: 此属性的默认值设置为false,但如果设置为true,则将禁用插件 -
type: 此属性指向我们将要使用的类来实现before、after或around监听器
在完成此操作后,创建包含部分内容的 app/code/Foggyline/Plugged/Block/Catalog/Product/AbstractProductPlugin1.php 文件,如下所示:
namespace Foggyline\Plugged\Block\Catalog\Product;
class AbstractProductPlugin1
{
public function beforeGetAddToCartUrl(
$subject,
$product, $additional = []
)
{
var_dump('Plugin1 - beforeGetAddToCartUrl');
}
public function afterGetAddToCartUrl($subject)
{
var_dump('Plugin1 - afterGetAddToCartUrl');
}
public function aroundGetAddToCartUrl(
$subject,
\Closure $proceed,
$product,
$additional = []
)
{
var_dump('Plugin1 - aroundGetAddToCartUrl');
return $proceed($product, $additional);
}
}
根据di.xml文件中的类型定义,该插件观察Magento\Catalog\Block\Product\AbstractProduct类,并且这个类有一个名为getAddToCartUrl的方法,其定义如下:
public function getAddToCartUrl($product, $additional = [])
{
//method body here...
}
AbstractProductPlugin1类不需要从另一个类扩展,我们可以通过使用命名约定定义before、after和around监听器来为getAddToCartUrl方法工作,如下所示:
<before> + <getAddToCartUrl> => beforeGetAddToCartUrl
<after> + <getAddToCartUrl> => afterGetAddToCartUrl
<around> + <getAddToCartUrl> => aroundGetAddToCartUrl
我们将在稍后详细说明每个监听器。现在,我们需要通过创建AbstractProductPlugin2.php和AbstractProductPlugin3.php文件来完成模块,这些文件是AbstractProductPlugin1.php的副本,并且简单地将其代码中的所有数字值从1更改为2或3。
将监听器组织成与被观察类位置结构相匹配的文件夹是一个好习惯。例如,如果一个模块名为Foggyline_Plugged,并且我们在Magento\Catalog\Block\Product\AbstractProduct类中观察方法,我们应该考虑将插件类放入Foggyline/Plugged/Block/Catalog/Product/AbstractProductPlugin.php文件中。这并不是一个要求。相反,这是一个好的约定,以便其他开发者可以轻松地管理代码。
一旦模块到位,我们需要在控制台执行以下命令:
php bin/magento module:enable Foggyline_Plugged
php bin/magento setup:upgrade
这将使模块对 Magento 可见。
如果我们现在在浏览器中打开一个分类页面的店面,我们将看到所有var_dump函数调用的结果。
让我们详细查看每个监听器方法。
使用before监听器
before监听器用于我们想要更改原始方法的参数或在原始方法被调用之前添加一些行为时。
回顾beforeGetAddToCartUrl监听方法定义,你会看到它按顺序分配了三个属性——$subject、$product和$additional。
使用before方法监听器,第一个属性总是$subject属性,它包含被观察的对象类型的实例。在$subject属性之后的属性按照顺序匹配被观察的getAddToCartUrl方法的属性。
用于转换的简单规则如下:
getAddToCartUrl($product, $additional = [])
beforeGetAddToCartUrl($subject, $product, $additional = [])
before监听方法不需要有返回值。
如果我们在之前看到的beforeGetAddToCartUrl监听方法中运行get_class($subject),我们将得到以下结果:
\Magento\Catalog\Block\Product\ListProduct\Interceptor
extends \Magento\Catalog\Block\Product\ListProduct
extends \Magento\Catalog\Block\Product\AbstractProduct
这表明,尽管我们正在观察AbstractProduct类,但$subject属性并不是直接那种类型。相反,它是ListProduct\Interceptor类型。这是你在开发过程中应该记住的事情。
使用after监听器
after监听器用于我们想要更改原始方法返回的值或在原始方法被调用后添加一些行为时。
回顾一下 afterGetAddToCartUrl 拦截器方法定义,你会看到它只分配了一个 $subject 属性。
使用 after 方法拦截器时,第一个且唯一的属性始终是 $subject 属性,它包含被观察的对象类型的实例,而不是被观察方法的返回值。
用于转换的简单规则如下:
getAddToCartUrl($product, $additional = [])
afterGetAddToCartUrl($subject)
after 拦截器方法不需要有返回值。
与 before 拦截器方法类似,在这种情况下,$subject 属性不是直接属于 AbstractProduct 类型。相反,它是父类 ListProduct\Interceptor 类型。
使用 around 拦截器
当我们想要更改原始方法的参数和返回值,或者在调用原始方法前后添加一些行为时,使用 around 拦截器。
回顾一下 aroundGetAddToCartUrl 拦截器方法定义,你会看到它按顺序分配了四个属性——$subject、$proceed、$product 和 $additional。
使用 after 方法拦截器时,第一个属性始终是 $subject 属性,它包含被观察的对象类型的实例,而不是被观察方法的返回值。第二个属性始终是 \Closure 的 $proceed 属性。在 $subject 和 $proceed 之后跟随的属性与被观察的 getAddToCartUrl 方法的属性顺序相匹配。
用于转换的简单规则如下:
getAddToCartUrl($product, $additional = [])
aroundGetAddToCartUrl(
$subject,
\Closure $proceed,
$product,
$additional = []
)
around 拦截器方法必须有一个返回值。返回值以这种方式形成,即 around 拦截器方法定义中 $closure 参数之后的参数按顺序传递给 $closure 函数调用,如下所示:
return $proceed($product, $additional);
//or
$result = $proceed($product, $additional);
return $result;
插件排序顺序
回顾一下,当我们定义 di.xml 文件中的插件时,为每个插件定义设置的属性之一是 sortOrder。它被设置为 100,200 到 300 分别为 foggyPlugin1、foggyPlugin2 和 foggyPlugin3。
上述插件代码执行的流程如下:
-
Plugin1 - beforeGetAddToCartUrl -
Plugin1 - aroundGetAddToCartUrl -
Plugin2 - beforeGetAddToCartUrl -
Plugin2 - aroundGetAddToCartUrl -
Plugin3 - beforeGetAddToCartUrl -
Plugin3 - aroundGetAddToCartUrl -
Plugin3 - afterGetAddToCartUrl -
Plugin2 - afterGetAddToCartUrl -
Plugin1 - afterGetAddToCartUrl
换句话说,如果有多个插件监听同一个方法,将使用以下执行顺序:
-
按照排序顺序从低到高的
before插件功能 -
具有最低
sortOrder值的around插件功能 -
按照排序顺序从低到高的
before插件功能 -
around插件功能遵循sortOrder值从低到高 -
具有最高
sortOrder值的after插件功能 -
after插件函数按照sortOrder值从高到低执行
注意
在处理 around 监听器时需要特别注意,因为它是唯一需要返回值的监听器。如果我们省略返回值,可能会以这种方式破坏执行流程,导致同一方法的其它 around 插件无法执行。
摘要
在本章中,我们探讨了 Magento 中的一个强大功能——插件。我们创建了一个包含三个插件的模块;每个插件都有不同的排序顺序。这使得我们能够追踪观察同一方法的多个插件的执行流程。我们详细探讨了 before、after 和 around 监听器方法,同时特别强调了参数顺序。本章中使用的最终模块可以在github.com/ajzele/B05032-Foggyline_Plugged找到。
在下一章中,我们将深入探讨后端开发。
第七章。后端开发
后端开发 是一个最常用来描述与服务器端紧密相关的工作的术语。这通常意味着实际的服务器、应用程序代码和数据库。例如,如果我们打开一个网络商店的店面,向购物车添加几个产品,然后结账,应用程序将存储提供的信息。这些信息由一个使用服务器端语言(如 PHP)的服务器管理,然后保存在数据库中。在 第四章,模型和集合中,我们探讨了后端开发的框架。在本章中,我们将探讨其他与后端相关的方面。
在我们探讨以下主题时,我们将使用在第几章中定义的 Foggyline_Office 模块:
-
cron作业 -
通知消息
-
会话和 cookies
-
记录日志
-
分析器
-
事件和观察者
-
缓存
-
小部件
-
自定义变量
-
i18n(国际化)
-
索引器
这些功能独立的单元通常用于日常后端相关开发。
cron 作业
谈到 cron 作业,有一点很重要。Magento cron 作业与操作系统的 cron 作业不同。操作系统的 cron 由一个 crontab(即 cron 表)文件驱动。crontab 文件是一个配置文件,它指定了需要在给定的时间表上定期运行的 shell 命令。
Magento cron 作业由定期执行处理 cron_schedule 表条目的 PHP 代码驱动。cron_schedule 表是 Magento cron 作业从单个 crontab.xml 文件中提取后排队的地方。
Magento 的 cron 作业无法在没有将操作系统的 cron 作业设置为执行 php bin/magento cron:run 命令的情况下执行。理想情况下,应该设置一个操作系统的 cron 作业,使其每分钟触发 Magento 的 cron:run。然后,Magento 将根据在 crontab.xml 文件中定义的每个单独的 cron 作业的方式内部执行其 cron 作业。
要在 Magento cron 中定义一个新的 cron 作业,我们首先需要在模块中定义一个 crontab.xml 文件。让我们创建一个 app/code/Foggyline/Office/etc/crontab.xml 文件,内容如下:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation= "urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="default">
<job name="foggyline_office_logHello" instance= "Foggyline\Office\Model\Cron" method="logHello">
<schedule>*/2 * * * *</schedule>
</job>
</group>
</config>
注意,XSD 架构位置指向 Magento_Cron 模块内的 crontab.xsd。
group 元素的 id 属性被设置为 default 默认值。在其模块中,Magento 定义了两个不同的组,即默认和索引。我们使用了 default 值,因为这是在控制台触发标准的 php bin/magento cron:run 命令时执行的那个值。
在group元素内,我们在job元素下定义了单独的作业。job元素要求我们指定name、instance和method属性。name属性必须在group元素内是唯一的。instance和method属性的值应指向将被实例化的类以及需要在该类中执行的方法。
在cron作业中嵌套的schedule元素指定了作业期望的执行时间。它使用与操作系统crontab文件中的条目相同的时间表达式。我们将查看的特定示例定义了一个每两分钟执行一次的表达式(*/2 * * * *)。
一旦我们定义了crontab.xml文件,我们需要定义Foggyline\Office\Model\Cron类文件,如下所示:
namespace Foggyline\Office\Model;
class Cron
{
protected $logger;
public function __construct(
\Psr\Log\LoggerInterface $logger
)
{
$this->logger = $logger;
}
public function logHello()
{
$this->logger->info('Hello from Cron job!');
return $this;
}
}
上述代码简单地定义了一个由cron作业使用的logHello方法。在logHello方法中,我们使用了通过构造函数实例化的logger方法。一旦执行,logger方法将在var/log/system.log文件中创建一个日志条目。
一旦命令执行,你将在控制台看到按计划运行的Ran作业消息。此外,cron_schedule表应该填充所有已定义的Magento cron作业。
在这一点上,我们应该在控制台触发php bin/magento cron:run命令。
cron_schedule表包含以下列:
-
schedule_id:自增primary字段。 -
job_code:在crontab.xml文件中定义的作业name属性的值,在我们的例子中等于foggyline_office_logHello表。 -
status:默认为表中新创建条目的待处理值,允许有pending、running、success、missed或error值。其值会随着cron作业在其生命周期中的变化而改变。 -
messages:存储在作业执行过程中发生的异常错误消息(如果发生异常)。 -
created_at:表示作业创建的时间戳值。 -
scheduled_at:表示作业计划执行的时间戳值。 -
executed_at:表示作业执行开始的时间戳值。 -
finished_at:表示作业执行完成的时间戳值。
除非我们已经设置了操作系统的cron以触发php bin/magento cron:run命令,否则我们需要每隔两分钟自己触发它几次,以便实际执行作业。第一次运行命令时,如果作业不在cron_schedule表中,Magento只会将其排队,但不会执行它。随后的cron运行将执行命令。一旦我们确认cron_schedule表中的cron作业条目已填充finished_at列值,我们将在var/log/system.log文件中看到一个类似[2015-11-21 09:42:18] main.INFO: Hello from Cron job! [] []的条目。
小贴士
在Magento中开发和测试cron作业时,我们可能需要截断cron_schedule表,删除Magento的var/cache值,并重复执行php bin/magento cron:run命令,直到测试并通过。
通知消息
Magento通过Messages模块实现通知消息机制。Messages模块符合\Magento\Framework\Message\ManagerInterface接口。尽管接口本身不强制任何会话关系,但实现会添加接口定义的消息类型到会话中,并允许稍后访问这些消息。在app/etc/di.xml文件中,为\Magento\Framework\Message\ManagerInterface定义了一个针对Magento\Framework\Message\Manager类的偏好设置。
Message\ManagerInterface指定了四种消息类型,即error、warning、notice和success。消息类型后面跟着Message\Manager类中的几个关键方法,如addSuccess、addNotice、addWarning、addError和addException。addException方法基本上是addError的包装,它接受一个exception对象作为参数。
让我们在app/code/Foggyline/Office/Controller/Test/Crud.php的execute方法中尝试运行以下代码:
$resultPage = $this->resultPageFactory->create();
$this->messageManager->addSuccess('Success-1');
$this->messageManager->addSuccess('Success-2');
$this->messageManager->addNotice('Notice-1');
$this->messageManager->addNotice('Notice-2');
$this->messageManager->addWarning('Warning-1');
$this->messageManager->addWarning('Warning-2');
$this->messageManager->addError('Error-1');
$this->messageManager->addError('Error-2');
return $resultPage;
一旦此代码执行,结果,如以下截图所示,将在浏览器页面中显示:

通知消息在前端和后台区域都会出现。
前端布局文件vendor/magento/module-theme/view/frontend/layout/default.xml定义如下:
<page layout="3columns" xsi:noNamespaceSchemaLocation= "../../../../../../../lib/internal/Magento/Framework /View/Layout/etc/page_configuration.xsd">
<update handle="default_head_blocks"/>
<body>
<!-- ... -->
<referenceContainer name="columns.top">
<container name="page.messages" htmlTag="div" htmlClass="page messages">
<block class="Magento\Framework\View\Element \Messages" name="messages" as="messages" template="Magento_Theme::messages.phtml"/>
</container>
</referenceContainer>
<!-- ... -->
</body>
</page>
用于渲染消息的template文件位于Magento_Theme模块中的view/frontend/templates/messages.phtml。通过查看Magento\Framework\View\Element\Messages类,你会看到_toHtml方法根据模板是否设置而分支到if-else语句。如果模板未设置,_toHtml方法内部会调用_renderMessagesByType方法,该方法以类型分组渲染 HTML 格式的消息。
Magento_AdminNotification模块中的view/adminhtml/layout/default.xml管理布局文件如下定义:
<page xsi:noNamespaceSchemaLocation="urn:magento: framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="notifications">
<block class="Magento\AdminNotification\Block \System\Messages" name="system_messages" as="system_messages" before="-" template= "Magento_AdminNotification::system/messages.phtml"/>
</referenceContainer>
</body>
</page>
用于渲染消息的template文件位于Magento_AdminNotification模块中的view/adminhtml/templates/system/messages.phtml。当你查看Magento\AdminNotification\Block\System\Messages类时,你会看到它的_toHtml正在调用父方法_toHtml,其中父类属于\Magento\Framework\View\Element\Template类。这意味着输出依赖于Magento_AdminNotification模块中的view/adminhtml/templates/system/messages.phtml文件。
会话和 cookies
Magento中的会话符合Magento\Framework\Session\SessionManagerInterface。在app/etc/di.xml文件中,有一个对SessionManagerInterface类的定义偏好设置,它指向Magento\Framework\Session\Generic类类型。Session\Generic类只是一个空的类,它扩展了Magento\Framework\Session\SessionManager类,而后者又实现了SessionManagerInterface类。
在SessionManager实例中,有一个重要的对象被实例化,它符合\Magento\Framework\Session\Config\ConfigInterface。查看app/etc/di.xml文件,我们可以看到对ConfigInterface的偏好设置指向Magento\Framework\Session\Config类类型。
小贴士
要完全理解Magento中的会话行为,我们应该研究SessionManager和Session\Config类的内部工作原理。
Magento使用 cookie 来跟踪会话。这些 cookie 的默认生存期为 3,600 秒。当会话建立时,在浏览器中创建一个名为PHPSESSID的 cookie。cookie 的值等于会话名称。默认情况下,会话存储在Magento根安装的var/session目录中的文件中。
如果您查看这些会话文件,您将看到会话信息被存储在序列化的字符串中,这些字符串被分为如_session_validator_data, _session_hosts, default, customer_website_1, 和checkout等分组,如下面的截图所示:

这不是分组列表的终结。实现自己会话处理部分的模块可以添加自己的组。
我们可以通过简单地使用以下表达式在会话中存储和检索信息:
$this->sessionManager->setFoggylineOfficeVar1('Office1');
$this->sessionManager->getFoggylineOfficeVar1();
上述表达式将在默认组下创建和获取会话条目。
我们可以通过使用$this->sessionManager->getData()表达式简单地获取默认会话组的全部内容,这将返回一个类似于以下的数据数组:
array(3) {
["_form_key"] => string(16) "u3sNaa26Ii21nveV"
["visitor_data"] => array(14) {
["last_visit_at"] => string(19) "2015-08-19 07:40:03"
["session_id"] => string(26) "8p82je0dkqq1o00lanlr6bj6m2"
["visitor_id"] => string(2) "35"
["server_addr"] => int(2130706433)
["remote_addr"] => int(2130706433)
["http_secure"] => bool(false)
["http_host"] => string(12) "magento2.loc"
["http_user_agent"] => string(121) "Mozilla/5.0 …"
["http_accept_language"] => string(41) "en-US,en;"
["http_accept_charset"] => string(0) ""
["request_uri"] => string(38) "/index.php/foggyline_office/test/crud/"
["http_referer"] => string(0) ""
["first_visit_at"] => string(19) "2015-08-19 07:40:03"
["is_new_visitor"] => bool(false)
}
["foggyline_office_var_1"] => string(7) "Office1"
}
如您所见,foggyline_office_var_1值就在其他会话值之中。
ConfigInterface有几个有用的方法,我们可以使用这些方法来获取会话配置信息;以下是一些方法:
-
getCookieSecure -
getCookieDomain -
getCookieHttpOnly -
getCookieLifetime -
getName -
getSavePath -
getUseCookies -
getOptions
下面是一个在Session\Config实例上调用getOptions方法的结果示例:
array(9) {
["session.save_handler"] => string(5) "files"
["session.save_path"] => string(39) "/Users/branko/www/magento2/var/session/"
["session.cookie_lifetime"] => int(3600)
["session.cookie_path"] => string(1) "/"
["session.cookie_domain"] => string(12) "magento2.loc"
["session.cookie_httponly"] => bool(true)
["session.cookie_secure"] => string(0) ""
["session.name"] => string(9) "PHPSESSID"
["session.use_cookies"] => bool(true)
}
cookie 通常与会话一起使用。除了用于链接到某个会话外,cookie 还常用于在客户端存储一些信息,从而跟踪或识别回头用户和客户。
除了使用setcookie函数的纯 PHP 方法外,我们还可以通过Magento\Framework\Stdlib\CookieManagerInterface实例在Magento中管理 cookie。当你查看app/etc/di.xml文件时,你会看到对CookieManagerInterface的偏好设置指向了Magento\Framework\Stdlib\Cookie\PhpCookieManager类型的类。
当涉及到Magento的 cookie 时,以下限制值得关注:
-
我们可以在系统中设置最多 50 个 cookie。否则,
Magento将抛出Unable to send the cookie. Maximum number of cookies would be exceeded异常。 -
我们可以存储最大大小为 4096 字节的 cookie。否则,
Magento将抛出Unable to send the cookie. Size of 'name' is size bytes异常。
通过实施这些限制,Magento确保我们与大多数浏览器兼容。
CookieManagerInterface类,以及其他一些功能,指定了setSensitiveCookie方法的要求。此方法使用给定的$name $value配对在私有 cookie 中设置一个值。敏感 cookie 将HttpOnly设置为 true,因此不能通过 JavaScript 访问。
正如我们将在以下示例中很快展示的,要设置公共或私有 cookie,我们可以通过使用以下实例来帮助自己:
-
\Magento\Framework\Stdlib\Cookie\CookieMetadataFactory -
\Magento\Framework\Stdlib\CookieManagerInterface -
\Magento\Framework\Session\Config\ConfigInterface
我们可以按以下方式设置公共 cookie:
$cookieValue = 'Just some value';
$cookieMetadata = $this->cookieMetadataFactory
->createPublicCookieMetadata()
->setDuration(3600)
->setPath($this->sessionConfig->getCookiePath())
->setDomain($this->sessionConfig->getCookieDomain())
->setSecure($this->sessionConfig->getCookieSecure())
->setHttpOnly($this->sessionConfig->getCookieHttpOnly());
$this->cookieManager
->setPublicCookie('cookie_name_1', $cookieValue, $cookieMetadata);
前面的代码将生成一个 cookie,如下面的截图所示:

我们可以按以下方式设置私有 cookie:
$cookieValue = 'Just some value';
$cookieMetadata = $this->cookieMetadataFactory
->createSensitiveCookieMetadata()
->setPath($this->sessionConfig->getCookiePath())
->setDomain($this->sessionConfig->getCookieDomain());
$this->cookieManager
->setSensitiveCookie('cookie_name_2', $cookieValue, $cookieMetadata);
前面的代码将生成一个 cookie,如下面的截图所示:

有趣的是,前面示例中的公共和私有 cookie 都显示HttpOnly被勾选,因为默认情况下,Magento管理员在Stores | Settings | Configuration | General | Web | Default Cookie Settings | Use HTTP Only设置为Yes。由于我们在公共 cookie 示例中使用setHttpOnly方法,我们只需通过$this->sessionConfig->getCookieHttpOnly()获取config值并传递即可。如果我们取消注释该行,我们将看到公共 cookie 实际上并没有默认设置HttpOnly。
记录
Magento通过其\Psr\Log\LoggerInterface类支持消息记录机制。LoggerInterface类在app/etc/di.xml文件中为Magento\Framework\Logger\Monolog类类型定义了偏好设置。实现的核心实际上在名为Monolog\Logger的Monolog父类中,它来自Monolog供应商。
LoggerInterface类使用以下八种方法将日志写入八个 RFC 5424 级别:
-
debug -
info -
notice -
warning -
error -
critical -
alert -
emergency
要使用记录器,我们需要将LoggerInterface类传递给一个构造函数,该构造函数位于我们想要使用的类内部,然后简单地调用以下方法之一:
$this->logger->log(\Monolog\Logger::DEBUG, 'debug msg');
$this->logger->log(\Monolog\Logger::INFO, 'info msg');
$this->logger->log(\Monolog\Logger::NOTICE, 'notice msg');
$this->logger->log(\Monolog\Logger::WARNING, 'warning msg');
$this->logger->log(\Monolog\Logger::ERROR, 'error msg');
$this->logger->log(\Monolog\Logger::CRITICAL, 'critical msg');
$this->logger->log(\Monolog\Logger::ALERT, 'alert msg');
$this->logger->log(\Monolog\Logger::EMERGENCY, 'emergency msg');
或者,通过单独的日志级别类型方法,首选的较短的版本如下:
$this->logger->debug('debug msg');
$this->logger->info('info msg');
$this->logger->notice('notice msg');
$this->logger->warning('warning msg');
$this->logger->error('error msg');
$this->logger->critical('critical msg');
$this->logger->alert('alert msg');
$this->logger->emergency('emergency msg');
这两种方法都会在Magento中创建相同的两个日志文件,如下所示:
-
var/log/debug.log -
var/log/system.log
debug.log文件只包含日志的调试级别类型,其余的都保存在system.log中。
这些日志条目将看起来像这样:
[2015-11-21 09:42:18] main.DEBUG: debug msg {"is_exception":false} []
[2015-11-21 09:42:18] main.INFO: info msg [] []
[2015-11-21 09:42:18] main.NOTICE: notice msg [] []
[2015-11-21 09:42:18] main.WARNING: warning msg [] []
[2015-11-21 09:42:18] main.ERROR: error msg [] []
[2015-11-21 09:42:18] main.CRITICAL: critical msg [] []
[2015-11-21 09:42:18] main.ALERT: alert msg [] []
[2015-11-21 09:42:18] main.EMERGENCY: emergency msg [] []
这些logger方法中的每一个都可以接受一个名为context的任意数据数组,如下所示:
$this->logger->info('User logged in.', ['user'=>'Branko', 'age'=>32]);
上述表达式将在system.log中产生以下条目:
[2015-11-21 09:42:18] main.INFO: User logged in. {"user":"Branko","age":32} []
小贴士
我们可以手动从var/log目录中删除任何.log文件,当需要时,Magento会自动重新创建它。
Magento还实施了一种其他的日志记录机制,它在数据库的log_*表中记录以下操作:
-
log_customer -
log_quote -
log_summary -
log_summary_type -
log_url -
log_url_info -
log_visitorz -
log_visitor_info -
log_visitor_online
值得注意的是,这种数据库日志记录与之前描述的Psr记录器没有任何关系。虽然Psr记录器在代码中为开发者提供了一种根据Psr标准对某些消息进行分组和记录的方法,但数据库日志记录记录的是浏览器中用户/客户交互产生的实时数据。
默认情况下,Magento保留数据库日志大约 180 天。这是一个可配置的选项,可以在Magento管理区域商店 | 设置 | 配置 | 高级 | 系统 | 日志清理选项卡中控制,如以下截图所示:

配置选项,如前一张截图所示,仅表示操作系统cron正在触发Magento cron。
小贴士
我们可以在终端执行两个命令:php bin/magento log:status以获取日志表当前状态信息,以及php bin/magento log:clean以强制清除表。
剖析器
Magento有一个内置的剖析器,可以用来识别服务器端的性能问题。简而言之,剖析器可以告诉我们某些代码块执行的时间。它的行为并没有什么特别之处。我们只能获取被剖析器的开始和停止方法包裹的代码块或单个表达式的执行时间。Magento在其代码中广泛地调用剖析器。然而,我们看不到它的效果,因为剖析器的输出默认是禁用的。
Magento支持三种剖析器输出,即html、csvfile和firebug。
要启用剖析器,我们可以编辑.htaccess并添加以下表达式之一:
-
SetEnv MAGE_PROFILER "html" -
SetEnv MAGE_PROFILER "csvfile" -
SetEnv MAGE_PROFILER "firebug"
分析器的 HTML 类型将输出到我们在浏览器中打开的页面的页脚区域,如下面的截图所示:

分析器的csv文件类型将输出到var/log/profiler.csv,如下面的截图所示:

分析器的 firebug 类型将输出到var/log/profiler.csv,如下面的截图所示:

分析器输出了以下信息:
-
Time分析器显示从Profiler::start到Profiler::stop所花费的时间。 -
Avg分析器显示对于Cnt大于一的情况,从Profiler::start到Profiler::stop所花费的平均时间。 -
Cnt分析器显示我们使用相同的计时器名称启动分析器的次数的整数值。例如,如果我们已经在代码的某个地方调用了\Magento\Framework\Profiler::start('foggyline:office')两次,那么Cnt将显示值为2。 -
Emalloc分析器代表分配给 PHP 的内存量。它是核心 PHPmemory_get_usage函数(没有传递带有 true 参数的它)和计时器值的组合。 -
RealMem分析器也代表分配给 PHP 的内存量,其最终值也是通过memory_get_usage函数减去计时器值获得的,但这次传递了带有 true 参数的它。
我们可以在代码的任何地方轻松地添加自己的Profiler::start调用。每个Profiler::start都应该跟随一些代码表达式,然后通过一个Profiler::stop调用结束,如下所示:
\Magento\Framework\Profiler::start('foggyline:office');
sleep(2); /* code block or single expression here */
\Magento\Framework\Profiler::stop('foggyline:office');
根据我们在代码中调用分析器的位置,生成的输出应该类似于以下截图所示:

事件和观察者
Magento通过\Magento\Framework\Event\ManagerInterface实现观察者模式。在app/etc/di.xml中,有一个指向Magento\Framework\Event\Manager\Proxy类类型的ManagerInterface偏好设置。Proxy类进一步扩展了\Magento\Framework\Event\Manager类,该类实现了实际的事件分发方法。
事件通过在Event\Manager类的实例上调用分发方法,并将名称和一些数据(可选)传递给它来分发。以下是一个Magento核心事件的示例:
$this->eventManager->dispatch(
'customer_customer_authenticated',
['model' => $this->getFullCustomerObject($customer), 'password' => $password]
);
$this->eventManager是之前提到的Event\Manager类的实例。在这种情况下,事件名称等于customer_customer_authenticated,而传递给事件的数据是包含两个元素的数组。前一个事件是在调用\Magento\Customer\Model\AccountManagement上的 authenticate 方法时触发的,即当客户登录时。
仅当我们期望有人观察事件并在事件被触发时执行他们的代码时,触发事件才有意义。根据我们想要观察事件的区域,我们可以在以下 XML 文件之一中定义观察者:
-
app/code/{vendorName}/{moduleName}/etc/events.xml -
app/code/{vendorName}/{moduleName}/etc/frontend/events.xml -
app/code/{vendorName}/{moduleName}/etc/adminhtml/events.xml
让我们定义一个观察者,它将认证用户的电子邮件地址记录到var/log/system.log文件中。我们可以使用Foggyline_Office模块并向其中添加一些代码。由于我们对店面感兴趣,将观察者放在etc/frontend/events.xml模块中是有意义的。
让我们定义包含以下内容的app/code/Foggyline/Office/etc/frontend/events.xml文件:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework: Event/etc/events.xsd">
<event name="customer_customer_authenticated">
<observer name="foggyline_office_customer_authenticated" instance="Foggyline\Office\Observer\LogCustomerEmail" />
</event>
</config>
在这里,我们指定了一个foggyline_office_customer_authenticated观察者用于customer_customer_authenticated事件。观察者定义在放置在Observer模块目录中的LogCustomerEmail类中。Observer类必须实现Magento\Framework\Event\ObserverInterface类。Observer接口定义了一个单一的execute方法。execute方法包含观察者代码,并在customer_customer_authenticated事件被触发时执行。
让我们继续在app/code/Foggyline/Office/Observer/LogCustomerEmail.php文件中定义Foggyline\Office\Observer\LogCustomerEmail类,如下所示:
namespace Foggyline\Office\Observer;
use Magento\Framework\Event\ObserverInterface;
class LogCustomerEmail implements ObserverInterface
{
protected $logger;
public function __construct(
\Psr\Log\LoggerInterface $logger
)
{
$this->logger = $logger;
}
/**
* @param \Magento\Framework\Event\Observer $observer
* @return self
*/
public function execute(\Magento\Framework\Event\Observer $observer)
{
//$password = $observer->getEvent()->getPassword();
$customer = $observer->getEvent()->getModel();
$this->logger->info('Foggyline\Office: ' . $customer-> getEmail());
return $this;
}
}
execute方法接受一个名为$observer的单个参数,其类型为\Magento\Framework\Event\Observer。我们正在观察的事件在数组中传递两份数据,即model和password。我们可以通过使用$observer->getEvent()->get{arrayKeyName}表达式来访问这些数据。$customer对象是Magento\Customer\Model\Data\CustomerSecure类的一个实例,它包含诸如email、firstname、lastname等属性。因此,我们可以从中提取电子邮件地址并将其传递给记录器的info方法。
现在我们知道了如何观察现有的事件,让我们看看我们如何可以触发我们自己的事件。我们可以在代码的几乎任何地方触发事件,无论是有数据还是无数据,如下面的示例所示:
$this->eventManager->dispatch('foggyline_office_foo');
// or
$this->eventManager->dispatch(
'foggyline_office_bar',
['var1'=>'val1', 'var2'=>'val2']
);
值得注意的是,存在两种类型的事件;我们可以根据它们名称的分配方式将它们分组如下:
-
静态:
$this->eventManager->dispatch('event_name', ...) -
动态:
$this->eventManager->dispatch({expression}.'_event_name', ...)
静态事件有一个固定的字符串名称,而动态事件则有一个在运行时确定的名称。以下是一个很好的核心Magento功能示例,来自定义在lib/internal/Magento/Framework/Data/AbstractSearchResult.php下的afterLoad方法,展示了如何使用这两种类型的事件:
protected function afterLoad()
{
$this->eventManager->dispatch ('abstract_search_result_load_after', ['collection' => $this]);
if ($this->eventPrefix && $this->eventObject) {
$this->eventManager->dispatch($this->eventPrefix . '_load_after', [$this->eventObject => $this]);
}
}
我们可以看到一个静态事件(abstract_search_result_load_after)和一个动态事件($this->eventPrefix . '_load_after')。$this->eventPrefix 是一个在运行时被评估的表达式。在使用动态事件时我们应该小心,因为它们在多种情况下被触发。一些有趣的动态事件是在以下类中定义的:
-
Magento\Framework\Model\AbstractModel-
$this->_eventPrefix . '_load_before' -
$this->_eventPrefix . '_load_after' -
$this->_eventPrefix . '_save_commit_after' -
$this->_eventPrefix . '_save_before' -
$this->_eventPrefix . '_save_after' -
$this->_eventPrefix . '_delete_before' -
$this->_eventPrefix . '_delete_after' -
$this->_eventPrefix . '_delete_commit_after' -
$this->_eventPrefix . '_clear'
-
-
\Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection-
$this->_eventPrefix . '_load_before' -
$this->_eventPrefix . '_load_after'
-
-
\Magento\Framework\App\Action\Action-
'controller_action_predispatch_' . $request-> getRouteName() -
'controller_action_predispatch_' . $request-> getFullActionName() -
'controller_action_postdispatch_' . $request-> getFullActionName() -
'controller_action_postdispatch_' . $request-> getRouteName()
-
-
Magento\Framework\View\Result\Layout -
'layout_render_before_' . $this->request-> getFullActionName()
这些事件在 model、collection、controller 和 layout 类上触发,这些类可能是最常用的后端元素,经常需要观察和交互。尽管我们可以说在运行时,我们知道完整的事件名称以及动态事件,但这也可以在运行前就假设。
例如,假设我们想要观察 Foggyline_Office 模块的 Crud 控制器动作的 'controller_action_predispatch_' . $request->getFullActionName(),那么实际的全事件名称将是 'controller_action_predispatch_foggyline_office_test_crud',因为在运行时 $request->getFullActionName() 将解析为 foggyline_office_test_crud。
缓存(s)
根据以下列表,Magento 提供了十一种内置缓存类型,这些类型在系统内部多个层级中使用:
-
配置:跨模块收集并合并的各种 XML 配置
-
布局:布局构建指令
-
块 HTML 输出:页面块 HTML
-
集合数据:集合数据文件
-
反射数据:API 接口反射数据
-
数据库 DDL 操作:DDL 查询的结果,例如描述表或索引
-
EAV 类型和属性:实体类型声明缓存
-
页面缓存:完整页面缓存
-
翻译:翻译文件
-
集成配置:集成配置文件
-
集成 API 配置:集成 API 配置文件
-
Web 服务配置:REST 和 SOAP 配置,生成的 WSDL 文件
此外,还有 附加缓存管理,它管理以下文件的缓存:
-
之前生成的产品图片文件
-
主题 JavaScript 和 CSS 文件合并为一个文件
-
预处理视图文件和静态文件
这些缓存中的每一个都可以单独清除。
我们可以轻松地定义自己的缓存类型。我们可以通过首先创建一个 app/code/Foggyline/Office/etc/cache.xml 文件并包含以下内容来实现:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Cache/etc/ cache.xsd">
<type name="foggyline_office"
instance="Foggyline\Office\Model\Cache">
<label>Foggyline Office Example</label>
<description>Example cache from Foggyline Office module.</description>
</type>
</config>
在定义新的缓存类型时,我们需要指定其 name 和 instance 属性。type 元素的 name 属性应设置为 foggyline_office,并且在整个 Magento 中应该是唯一的。这个值应该与 Foggyline\Office\Model\Cache 类中的 TYPE_IDENTIFIER 常量值相匹配,该类将很快创建。instance 属性包含我们将用于缓存的类名。
然后,我们将在 app/code/Foggyline/Office/Model/Cache.php 文件中定义 Foggyline\Office\Model\Cache 类,内容如下:
namespace Foggyline\Office\Model;
class Cache extends \Magento\Framework\Cache\Frontend\Decorator\TagScope
{
const TYPE_IDENTIFIER = 'foggyline_office';
const CACHE_TAG = 'OFFICE';
public function __construct(
\Magento\Framework\App\Cache\Type\FrontendPool $cacheFrontendPool
)
{
parent::__construct(
$cacheFrontendPool->get(self::TYPE_IDENTIFIER), self::CACHE_TAG
);
}
}
Cache 类继承自 TagScope 并为其自己的 TYPE_IDENTIFIER 和 CACHE_TAG 指定值,在 __construct 方法中将它们传递给父构造函数。有了这两个文件(cache.xml 和 Cache),我们基本上定义了一种新的缓存类型。
一旦我们指定了 cache.xml 文件和引用的 cache 类,我们应该能够在 Magento 管理后台的 系统 | 工具 | 缓存管理 菜单下看到我们的缓存类型,如下截图所示:

仅通过定义一个新的缓存,并不意味着它会被 Magento 填充和使用。
如果你想在代码的任何地方使用缓存,你可以通过首先将缓存类的实例传递给构造函数来实现,如下所示:
protected $cache;
public function __construct(
\Foggyline\Office\Model\Cache $cache
)
{
$this->cache = $cache;
}
然后,你可以执行一段代码,如下所示:
$cacheId = 'some-specific-id';
$objInfo = null;
$_objInfo = $this->cache->load($cacheId);
if ($_objInfo) {
$objInfo = unserialize($_objInfo);
} else {
$objInfo = [
'var1'=> 'val1',
'var2' => 'val2',
'var3' => 'val3'
];
$this->cache->save(serialize($objInfo), $cacheId);
}
上述代码显示了我们首先尝试从现有的缓存条目中加载值,如果没有,则保存它。如果缓存类型在 缓存管理 菜单下设置为 disabled,则上述代码将永远不会保存并从缓存中拉取数据,因为它不起作用。
现在,如果你查看 Magento 的 var/cache 文件夹,你将看到以下截图所示的内容:

Magento 为我们创建了两个缓存条目,分别是 var/cache/mage-tags/mage---a8a_OFFICE 和 var/cache/mage--f/mage---a8a_SOME_SPECIFIC_ID。在这个特定情况下,mage---a8a_OFFICE 文件只有一行条目,条目是 a8a_SOME_SPECIFIC_ID 字符串,显然指向另一个文件。mage---a8a_SOME_SPECIFIC_ID 文件包含实际的序列化 $objInfo 数组。
a8a_ 前缀和其他 cache 文件名中的前缀对我们来说并不真正相关;这是 Magento 自己添加的。对我们来说,相关的是向我们要缓存的块或变量传递适当的单个缓存标签,就像前面的例子中那样,以及我们为 Cache 类设置的 TYPE_IDENTIFIER 和 CACHE_TAG 标签。
小部件
Magento 提供了对小部件的支持。尽管“小部件”这个词可能意味着前端开发技能和活动,但我们将把它们视为后端开发流程的一部分,因为创建有用且健壮的小部件需要大量的后端知识。
Magento 提供了几个开箱即用的小部件;其中一些如下:
-
CMS 页面链接
-
CMS 静态块
-
目录分类链接
-
目录新产品列表
-
目录产品链接
-
目录产品列表
-
订单和退货
-
最近比较的产品
-
最近查看的产品
要创建一个完全自定义的小部件,我们首先定义 app/code/Foggyline/Office/etc/widget.xml 并添加内容,如下所示:
<widgets xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Widget:etc/widget.xsd">
<widget id="foggyline_office"
class="Foggyline\Office\Block\Widget\Example"
placeholder_image="Magento_Cms::images/ widget_block.png">
<label translate="true">Foggyline Office</label>
<description translate="true">Example Widget</description>
<parameters>
<parameter name="var1" xsi:type="select" visible="true" source_model="Magento\Config\Model \Config\Source\Yesno">
<label translate="true">Yes/No var1</label>
</parameter>
<parameter name="var2" xsi:type="text" required="true" visible="true">
<label translate="true">Number var2</label>
<depends>
<parameter name="var1" value="1"/>
</depends>
<value>5</value>
</parameter>
</parameters>
</widget>
</widgets>
id 小部件已被设置为 foggyline_office,而驱动小部件的类已被设置为 Foggyline\Office\Block\Widget\Example。widget 类基本上是一个扩展自 \Magento\Framework\View\Element\AbstractBlock 并实现 \Magento\Widget\Block\BlockInterface 的 block 类。当我们选择小部件进行使用时,label 和 description 元素设置的值将显示在 Magento 管理后台。
小部件的参数是其可配置选项,这些选项根据我们选择的 type 和 source_model 选项转换为 HTML 表单元素。在下面的例子中,我们将演示使用 select 和 text 元素从用户那里获取输入,如图所示:

让我们通过在 app/code/Foggyline/Office/Block/Widget/Example.php 文件中创建实际的 WidgetExample 类并添加内容来继续,如下所示:
namespace Foggyline\Office\Block\Widget;
class Example extends \Magento\Framework\View\Element\Text implements \Magento\Widget\Block\BlockInterface
{
protected function _beforeToHtml()
{
$this->setText(sprintf(
'example widget: var1=%s, var2=%s',
$this->getData('var1'),
$this->getData('var2')
));
return parent::_beforeToHtml();
}
}
这里发生的情况是,我们正在使用 Element\Text 作为块类型,而不是 Element\Template,因为我们想简化示例,因为 Element\Template 还需要定义 phtml 模板。通过使用 Element\Text,我们可以简单地定义 _beforeToHtml 并调用 setText 方法来设置块的输出文本字符串。我们将通过拾取作为参数传递给块的 var1 和 var2 变量来构建输出字符串。
现在,如果我们打开 Magento 管理区域,转到 内容 | 元素 | 页面,并选择 主页 进行编辑,我们应该能够点击 插入前端应用 按钮并将我们的小部件添加到页面中。或者,如果我们不是在 WYSIWYG 模式下编辑页面内容,我们也可以使用以下表达式手动将小部件添加到页面中:
{{widget type="Foggyline\\Office\\Block\\Widget\\Example" var1="1" var2="5"}}
最后,当我们访问店面主页时,我们应该在浏览器中看到示例小部件:var1=1, var2=5 字符串。
我们可以使用前端应用创建高度可配置和可嵌入的组件,用户可以轻松地将它们分配到 CMS 页面或块。
自定义变量
变量是Magento_Variable核心模块的一个便捷小功能。Magento允许你创建自定义变量,然后可以在电子邮件模板、WYSIWYG编辑器或甚至代码表达式中使用它们。
以下步骤概述了如何手动创建新变量:
-
在
Magento管理区域,导航到系统 | 其他设置 | 自定义变量。 -
点击添加新变量按钮。
-
在考虑商店视图切换器的同时,填写所需的变量代码和变量名称选项,并最好选择一个可选选项,即变量 HTML 值或变量纯文本值。
-
点击保存按钮。
现在我们已经创建了自定义变量,我们可以通过以下表达式在电子邮件模板或WYSIWYG编辑器中使用它:
{{customVar code=foggyline_hello}}
上述表达式将调用代码foggyline_hello中custom变量的值。
变量可以在各种代码表达式中使用,尽管不建议依赖于单个变量的存在,因为管理员用户可以在任何时间点删除它。以下示例演示了如何在代码中使用现有变量:
$storeId =0;
$variable = $this->_variableFactory->create()->setStoreId(
$storeId
)->loadByCode(
'foggyline_hello'
);
$value = $variable->getValue(
\Magento\Variable\Model\Variable::TYPE_HTML
);
$this->_variableFactory是\Magento\Variable\Model\VariableFactory的一个实例。
如果使用得当,变量可以很有用。存储诸如电话号码或用于 CMS 页面、博客和电子邮件模板中的专用标签等信息是使用自定义变量的好例子。
i18n
i18n是国际化的缩写。Magento默认添加 i18n 支持,因此无需应用更改即可适应各种语言和地区。在app/functions.php中,有一个__()翻译函数,其定义如下:
function __()
{
$argc = func_get_args();
$text = array_shift($argc);
if (!empty($argc) && is_array($argc[0])) {
$argc = $argc[0];
}
return new \Magento\Framework\Phrase($text, $argc);
}
这个translation函数接受可变数量的参数,并将它们传递给\Magento\Framework\Phrase类的构造函数,并返回其实例。Phrase类有__toString方法,然后返回翻译后的字符串。
这里有一些如何使用__()函数的示例:
-
__('Translate me') -
__('Var1 %1, Var2 %2, Var %3', time(), date('Y'), 32) -
__('版权 %1 <a href="%2">Magento</a>', date('Y'), 'http://magento.com')
通过translation函数传递的字符串预计可以在本地 CSV 文件中找到,例如app/code/{vendorName}/{moduleName}/i18n/{localeCode}.csv。让我们暂时想象一下,我们在Magento管理区域的商店 | 设置 | 所有商店下定义了两个不同的商店视图。一个商店将商店 | 设置 | 配置 | 常规 | 区域选项 | 区域设置为英语(英国)和另一个设置为德语(德国)。英语(英国)的本地代码是en_GB,而德语(德国)的本地代码是de_DE。
对于de_DE区域设置,我们将在app/code/Foggyline/Office/i18n/de_DE.csv文件中添加翻译条目,如下所示:
"Translate me","de_DE Translate me"
"Var1 %1, Var2 %2, Var %3","de_DE Var1 %1, Var2 %2, Var %3"
"Copyright %1 <a href=""%2"">Magento</a>","de_DE Copyright %1 <a href=""%2"">Magento</a>"
对于en_GB区域设置,我们将在app/code/Foggyline/Office/i18n/en_GB.csv文件中添加翻译条目,如下所示:
"Translate me","en_GB Translate me"
"Var1 %1, Var2 %2, Var %3", "en_GB Var1 %1, Var2 %2, Var %3"
"Copyright %1 <a href=""%2"">Magento</a>","en_GB Copyright %1 <a href=""%2"">Magento</a>"
观察两个 CSV 文件,出现了一个模式。我们可以看到 CSV 文件按以下方式工作:
-
根据 CSV 的每一行提供单独的翻译字符串
-
每一行进一步包含两个由逗号分隔的独立字符串
-
两个独立字符串都被引号包围
-
如果一个字符串包含引号,它将通过双引号转义,以便它不会破坏翻译
-
%1、%2、%3...%n模式用于标记我们在应用程序运行时通过代码提供的变量占位符
Magento支持与其bin/magento控制台工具相关的几个命令:
i18n
i18n:collect-phrases Discovers phrases in the codebase
i18n:pack Saves language package
i18n:uninstall Uninstalls language packages
如果我们执行以下控制台命令,Magento 将递归地查找 PHP、PHTML 或 XML 文件中的可翻译表达式,这些文件包含要翻译的短语:
php bin/magento i18n:collect-phrases -o "/Users/branko/www/magento2/app/code/Foggyline/Office/i18n/en_GB.csv" /Users/branko/www/magento2/app/code/Foggyline/Office
上述命令的输出将基本上覆盖app/code/Foggyline/Office/i18n/en_GB.csv文件,该文件包含所有Foggyline/Office模块的可翻译短语。这是一种将所有可翻译短语聚合到适当的区域文件中的好方法,例如在本例中的en_GB.csv。
翻译 CSV 文件也可以放置在单个主题下。例如,让我们想象一种情况,我们向app/design/frontend/Magento/blank/i18n/en_GB.csv添加内容,如下所示:
"Translate me","Theme_en_GB Translate me"
"Var1 %1, Var2 %2, Var %3", "Theme_en_GB Var1 %1, Var2 %2, Var %3"
"Copyright %1 <a href=""%2"">Magento</a>","Theme_en_GB Copyright %1 <a href=""%2"">Magento</a>"
现在,对于en_GB区域设置的店面,Translate me字符串的输出将解析为Theme_en_GB Translate me,而不是en_GB Translate me字符串。
小贴士
主题 CSV 翻译的优先级高于模块 CSV 翻译,因此允许开发者覆盖单个模块的翻译。
除了 CSV 翻译文件外,Magento还支持一个名为可以在`Magento`管理区域中激活行内翻译,通过导航到**商店** **| 设置 | 配置 | 高级 | 开发者 | 行内翻译。此功能可以分别针对管理员和店面单独打开,如下面的截图所示:

如图所示的前一个截图,当某个功能被激活时,HTML 元素周围会出现红色的虚线边框。将鼠标悬停在单个元素上时,在左下角会显示一个小书图标。点击书图标会弹出一个窗口,如图下所示:

需要注意的是,这些红色的虚线边框和书图标只会出现在我们通过__()翻译函数传递的字符串上。
在这里,我们可以看到有关字符串的各种信息,例如显示、翻译和原文字符串。还有一个名为自定义的输入字段,我们可以在此添加新的翻译。内联翻译字符串存储在数据库中的translation表中。
提示
内联翻译比主题 CSV 翻译文件具有更高的优先级。
第八章:索引器
索引是通过对数据进行简化以减少数据库表数来转换数据的过程。这个过程用于产品、类别等,以提高网店性能。由于数据不断变化,这不仅仅是一个一次性过程,而是一个周期性的过程。Magento_Indexer模块是Magento索引功能的基础。
Magento控制台工具支持以下索引器命令。
indexer
indexer:info Shows allowed Indexers
indexer:reindex Reindexes Data
indexer:set-mode Sets index mode type
indexer:show-mode Shows Index Mode
indexer:status Shows status of Indexer
当运行php bin/magento indexer:info时,你会得到所有 Magento 索引器的列表;默认的索引器如下:
catalog_category_product Category Products
catalog_product_category Product Categories
catalog_product_price Product Price
catalog_product_attribute Product EAV
foggyline_office_employee Employee Flat Data
cataloginventory_stock Stock
catalogrule_rule Catalog Rule Product
catalogrule_product Catalog Product Rule
catalogsearch_fulltext Catalog Search
你将在系统 | 工具 | 索引管理菜单中的Magento管理后台看到所有索引器。
在管理区域内部,我们只能更改索引器模式。索引器有两种模式:
-
保存时更新:索引表在字典数据更改后立即更新
-
按计划更新:索引表根据配置的计划由
cron作业更新
由于无法从管理后台手动运行索引器,我们只能依赖它们的手动执行或cron执行。
手动执行是通过以下控制台命令完成的:
php bin/magento indexer:reindex
前面的命令会一次性运行所有索引器。我们可以进一步微调,通过运行一个类似于以下代码的命令来执行单个索引器:
php bin/magento indexer:reindex catalogsearch_fulltext
通过Magento_Indexer模块定义了 Cron 执行的索引器,如下所示:
-
indexer_reindex_all_invalid:这将每天每小时每分钟执行一次。它会在Magento\Indexer\Model\Processor类的一个实例上运行reindexAllInvalid方法。 -
indexer_update_all_views:这将每天每小时每分钟执行一次。它会在Magento\Indexer\Model\Processor类的一个实例上运行updateMview方法。 -
indexer_clean_all_changelogs:这将每天每小时的第 0 分钟执行一次。它会在Magento\Indexer\Model\Processor类的一个实例上运行clearChangelog方法。
这些cron作业使用操作系统cron作业设置,使得Magento的cron作业每分钟被触发一次。
以下三个状态是索引器可能具有的状态:
-
valid:数据已同步,无需重新索引 -
invalid:原始数据已更改,索引应更新 -
working:索引过程正在运行
尽管我们不会在本章中详细介绍创建自定义索引器的细节,但值得注意的是,Magento 在 vendor/magento/module-*/etc/indexer.xml 文件中定义了其索引器。这可能在我们需要深入了解单个索引器内部工作原理的情况下派上用场。例如,catalog_product_flat 索引器是通过 Magento\Catalog\Model\Indexer\Product\Flat 类实现的,该类在 vendor/magento/module-catalog/etc/indexer.xml 文件中定义。通过深入研究 Flat 类的实现,你可以了解数据是如何从 EAV 表中提取并扁平化成简化结构的。
摘要
在本章中,我们涵盖了 Magento 的许多最相关方面,这些方面超出了模型和类,涉及后端开发。我们查看了一下 crontab.xml,它帮助我们安排 jobs(命令),以便它们可以定期运行。然后,我们处理了通知消息,这使得我们可以通过浏览器向用户推送样式化的消息。会话和 cookies 部分让我们了解了 Magento 如何从浏览器跟踪用户信息到会话。日志和性能分析展示了跟踪性能和潜在问题的简单而有效的方法。事件和观察者 部分介绍了一种强大的模式,Magento 在代码中实现,我们可以触发自定义代码执行,当某个事件被触发时。缓存部分引导我们了解可用的缓存类型,并研究了如何创建和使用我们自己的缓存类型。通过前端应用(小工具)部分,我们学习了如何创建自己的小型应用,这些应用可以被调用到 CMS 页面和块中。自定义变量让我们了解了一个简单而有趣的功能,我们可以通过管理界面定义一个变量,然后在 CMS 页面、块或电子邮件模板中使用它。国际化 部分展示了如何使用 Magento 翻译功能在三个不同级别上翻译任何字符串,即模块 CSV 文件、主题 CSV 文件和内联翻译。最后,我们查看了一下索引器和它们的模式和状态;我们学习了如何控制它们的执行。
下一章将探讨前端开发。我们将学习如何创建自己的主题,并使用块和布局来影响输出。
第八章. 前端开发
前端开发是一个与生产网站或 Web 应用程序的 HTML、CSS 和 JavaScript 紧密相关的术语。它可以互换地涉及可访问性、可用性和性能,以达到令人满意的用户体验。我们想要应用到我们的网店的各种定制级别需要不同的开发技能水平。我们可以仅使用 CSS 对我们的网店进行相对简单的更改。这些更改将是我们接受网店结构,仅关注视觉上的更改,如更改颜色和图像。这可能是一个对经验较少的开发者和新接触 Magento 平台的人来说的好起点。更复杂的方法是对由 Magento 模块生成的输出进行更改。这通常意味着需要一点 PHP 知识,主要是对现有代码片段的复制-粘贴-修改。高于这个技能水平意味着我们需要了解如何对我们的网店进行结构性的更改。这通常意味着掌握 Magento 的相对复杂的布局引擎,通过 XML 定义进行更改。Magento 前端开发的最终和最高技能水平意味着对现有或新的自定义功能进行修改。
在本章中,我们将深入以下部分:
-
渲染流程
-
视图元素
-
块架构和生命周期
-
模板
-
XML 布局
-
主题
-
JavaScript
-
CSS
渲染流程
Magento 应用程序的入口点是它的index.php文件。所有的 HTTP 请求都通过它进行。
让我们按照以下方式分析index.php文件的(裁剪)版本:
//PART-1-1
require __DIR__ . '/app/bootstrap.php';
//PART-1-2
$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, $_SERVER);
//PART-1-3
$app = $bootstrap-> createApplication('Magento\Framework\App\Http');
//PART-1-4
$bootstrap->run($app);
前面的代码中的PART-1-1只是将/app/bootstrap.php包含到代码中。在引导过程中发生的事情是包含app/autoload.php和app/functions.php。函数文件包含一个__()函数,用于翻译目的,返回\Magento\Framework\Phrase对象的实例。不深入自动加载文件的细节,只需说它处理了所有我们的类文件在 Magento 中的自动加载。
PART-1-2只是一个静态的创建方法调用,用于获取\Magento\Framework\App\Bootstrap对象的实例,并将其存储到$bootstrap变量中。
PART-1-3在$bootstrap对象上调用createApplication方法。在createApplication内部发生的事情不过是使用对象管理器创建并返回我们传递给它的类的对象实例。由于我们将\Magento\Framework\App\Http类的名称传递给createApplication方法,我们的$app变量就变成了该类的实例。这意味着,实际上,我们的网店应用是Magento\Framework\App\Http类的实例。
PART-1-4 正在调用 $bootstrap 对象的 run 方法,并传递 Magento\Framework\App\Http 类的实例。尽管这看起来像一行简单的代码,但正如我们很快将看到的,事情变得复杂起来。
让我们按照以下方式分析 \Magento\Framework\App\Bootstrap 的 -> run 方法的(裁剪)版本:
public function run(\Magento\Framework\AppInterface $application)
{
//PART-2-1
$this->initErrorHandler();
$this->initObjectManager();
$this->assertMaintenance();
$this->assertInstalled();
//PART-2-2
$response = $application->launch();
//PART-2-3
$response->sendResponse();
}
在前面的代码中,PART-2-1 处理了一些家务事。它初始化了自定义错误处理器,初始化了对象管理器,检查我们的应用程序是否处于维护模式,并检查它是否已安装。
PART-2-2 看起来像一行简单的代码。在这里,我们正在调用 $application 的 launch 方法,而 $application 是 Magento\Framework\App\Http 实例。暂时不深入探讨 launch 方法的内部工作原理,我们只需说它返回了在 var/generation/Magento/Framework/App/Response/Http/Interceptor.php 下定义的 Magento\Framework\App\Response\Http\Interceptor 类的实例。请注意,这是一个自动生成的包装类,它扩展了 \Magento\Framework\App\Response\Http 类。实际上,忽略 Interceptor,我们可以这样说,$response 是 \Magento\Framework\App\Response.Http 类的实例。
最后,PART-2-3 调用了 $response 的 sendResponse 方法。尽管 $response 是 \Magento\Framework\App\Response.Http 类的实例,但实际的 sendResponse 方法是在父类树中的 \Magento\Framework\HTTP\PhpEnvironment\Response 类中找到的。sendResponse 方法调用另一个父类方法,即 send 方法。send 方法可以在 Zend\Http\PhpEnvironment\Response 类下找到。它触发了 sendHeaders 和 sendContent 方法。这是实际输出被发送到浏览器的地方,因为 sendHeaders 方法使用了 PHP 的 header 函数和 echo 构造来推送输出。
重申前面的内容,我们理解的执行流程归结如下:
-
index.php -
\Magento\Framework\App\Bootstrap->run -
\Magento\Framework\App\Http->launch -
\Magento\Framework\App\Response\Http->sendResponse
尽管我们已经到达了引导的 run 方法的末尾,但如果我们说我们已经涵盖了渲染流程,那是不公平的,因为我们几乎没触及它。
我们需要退后一步,详细查看 PART-2-2,即 launch 方法的内部工作原理。让我们看一下 \Magento\Framework\App\Http -> launch 方法的(裁剪)版本如下:
public function launch()
{
//PART-3-1
$frontController = $this->_objectManager->get ('Magento\Framework\App\FrontControllerInterface');
//PART-3-2
$result = $frontController->dispatch($this->_request);
if ($result instanceof \Magento\Framework\Controller \ResultInterface) {
//PART-3-3
$result->renderResult($this->_response);
} elseif ($result instanceof \Magento\Framework\App \Response\HttpInterface) {
$this->_response = $result;
} else {
throw new \InvalidArgumentException('Invalid return type');
}
//PART-3-4
return $this->_response;
}
PART-3-1 创建了一个符合 \Magento\Framework\App\FrontControllerInterface 类的对象实例。如果我们查看 app/etc/di.xml,我们可以看到对 FrontControllerInterface 的偏好设置是为了 \Magento\Framework\App\FrontController 类。然而,如果我们调试代码并检查实际的实例类,它将显示为 Magento\Framework\App\FrontController\Interceptor。这是 Magento 添加了一个拦截器包装器,然后扩展了 \Magento\Framework\App\FrontController,这是我们根据 di.xml 偏好条目所期望的。
现在我们知道了 $frontController 实例背后的真实类,我们知道在哪里查找 dispatch 方法。dispatch 方法是理解渲染流程过程中的另一个重要步骤。我们将在稍后更详细地探讨其内部工作原理。现在,让我们回到 PART-3-2 的 $result 变量。如果我们调试这个变量,直接在其背后的类将显示为 Magento\Framework\View\Result\Page\Interceptor,定义在动态创建的 var/generation/Magento/Framework/View/Result/Page/Interceptor.php 文件中。Interceptor 是 \Magento\Framework\View\Result\Page 类的包装器。因此,可以说我们的 $result 变量是 Page 类的实例。
Page 类扩展了 \Magento\Framework\View\Result\Layout,它进一步扩展了 \Magento\Framework\Controller\AbstractResult 并实现了 \Magento\Framework\Controller\ResultInterface。这里有一个相当长的链,但理解它很重要。
注意 PART-3-3。由于我们的 $result 是 \Magento\Framework\Controller\ResultInterface 的实例,我们进入了第一个 if 条件,调用了 renderResult 方法。renderResult 方法本身是在 \Magento\Framework\View\Result\Layout 类中声明的。不深入探讨 renderResult 的细节,只需说它添加了 HTTP 头和内容到传递给它的 $this->_response 对象。正如我们在 PART-2-2 中所描述的,相同的响应对象是 launch 方法返回的。
虽然 PART-3-3 没有描绘任何返回值,但表达式 $result->renderResult($this->_response) 本身并不进行任何输出。它修改了 $this->_response,这是我们最终从 launch 方法返回的,正如我们在 PART-3-4 中所展示的。
重申前面的内容,我们理解的执行流程归结如下:
-
index.php -
\Magento\Framework\App\Bootstrap->run -
\Magento\Framework\App\Http->launch -
\Magento\Framework\App\FrontController->dispatch -
\Magento\Framework\View\Result\Page->renderResult -
\Magento\Framework\App\Response\Http->sendResponse
正如我们在解释 PART-3-2 时提到的,dispatch 方法是渲染流程过程中的另一个重要步骤。让我们看一下 \Magento\Framework\App\FrontController 的 -> dispatch 方法的(裁剪)版本,如下所示:
public function dispatch(\Magento\Framework\App\RequestInterface $request)
{
//PART-4-1
while (!$request->isDispatched() && $routingCycleCounter++ < 100) {
//PART-4-2
foreach ($this->_routerList as $router) {
try {
//PART-4-3
$actionInstance = $router->match($request);
if ($actionInstance) {
$request->setDispatched(true);
//PART-4-4
$result = $actionInstance->dispatch($request);
break;
}
} catch (\Magento\Framework\Exception \NotFoundException $e) {}
}
}
//PART-4-4
return $result;
}
上一段代码中的 PART-4-1 和 PART-4-2 展示了(几乎)整个 dispatch 方法体包含在一个循环中。循环进行了 100 次迭代,进一步遍历所有可用的路由器类型,从而给每个路由器 100 次找到路由匹配的机会。
路由列表循环包括以下类类型的路由器:
-
Magento\Framework\App\Router\Base -
Magento\UrlRewrite\Controller\Router -
Magento\Cms\Controller\Router -
Magento\Framework\App\Router\DefaultRouter
所有的列表路由器都实现了 \Magento\Framework\App\RouterInterface,这使得它们都具有 match 方法的实现。
如果模块选择这样做,可以进一步定义新的路由器。例如,如果我们正在开发一个 Blog 模块,我们希望我们的模块捕获所有以 /blog/ 部分开始的 URL 请求。这可以通过指定自定义路由器来实现,然后它将出现在前面的列表中。
PART-4-3 展示了 $actionInstance 变量存储了路由器 match 方法调用的结果。根据 RouterInterface 的要求,match 方法必须返回一个实现了 \Magento\Framework\App\ActionInterface 的类的实例。让我们想象一下,我们现在正在从我们在 第四章 中编写的模块中访问 URL /foggyline_office/test/crud/,模型和集合。在这种情况下,我们的 $router 类将是 \Magento\Framework\App\Router\Base,我们的 $actionInstance 将是 \Foggyline\Office\Controller\Test\Crud\Interceptor 类。Magento 自动通过动态生成的 var/generation/Foggyline/Office/Controller/Test/Crud/Interceptor.php 文件添加 Interceptor。这个 Interceptor 类进一步扩展了我们的模块 \Foggyline\Office\Controller\Test\Crud 类文件。Crud 类扩展了 \Foggyline\Office\Controller\Test,它进一步扩展了 \Magento\Framework\App\Action\Action,它实现了 \Magento\Framework\App\ActionInterface。经过一段漫长的父子树,我们最终到达了 ActionInterface,这正是我们的 match 方法需要返回的。
PART-4-4 展示了在 $actionInstance 上调用 dispatch 方法。这个方法在 \Magento\Framework\App\Action\Action 中实现,并期望返回一个实现了 \Magento\Framework\App\ResponseInterface 的对象。在 dispatch 内部,调用 execute 方法,从而运行我们 Crud 控制器动作 execute 方法中的代码。
假设我们的Crud控制器操作执行方法没有返回任何内容,$result对象将变为Magento\Framework\App\Response\Http\Interceptor的实例,它围绕\Magento\Framework\App\Response\Http进行包装。
让我们假设我们的Crud类已经定义如下:
/**
* @var \Magento\Framework\View\Result\PageFactory
*/
protected $resultPageFactory;
public function __construct(
\Magento\Framework\App\Action\Context $context,
\Magento\Framework\View\Result\PageFactory $resultPageFactory
)
{
$this->resultPageFactory = $resultPageFactory;
return parent::__construct($context);
}
public function execute()
{
$resultPage = $this->resultPageFactory->create();
//...
return $resultPage;
}
现在调试$result变量显示它是一个\Magento\Framework\View\Result\Page\Interceptor的实例。这个Interceptor由 Magento 在var/generation/Magento/Framework/View/Result/Page/Interceptor.php下动态生成,并且仅仅是\Magento\Framework\View\Result\Page的包装器。这个Page类进一步扩展了\Magento\Framework\View\Result\Layout类,并实现了\Magento\Framework\App\ResponseInterface。
最后,PART-4-4展示了从FrontController的dispatch方法返回的$result对象,其类型为\Magento\Framework\View\Result\Page。
再次强调前面提到的,我们理解的执行流程如下:
-
index.php -
\Magento\Framework\App\Bootstrap->run -
\Magento\Framework\App\Http->launch -
\Magento\Framework\App\FrontController->dispatch -
\Magento\Framework\App\Router\Base->match -
\Magento\Framework\App\Action\Action->dispatch -
\Magento\Framework\View\Result\Page->renderResult -
\Magento\Framework\App\Response\Http->sendResponse
简而言之,作为前端开发者,我们应该知道,从我们的控制器操作中返回页面类型对象将自动调用该对象上的renderResult方法。Page和Layout是所有主题翻译、布局和模板加载触发的地方。
视图元素
Magento 的主要视图元素是其 UI 组件、容器和块。以下是对每个元素的简要概述。
Ui 组件
在vendor/magento/framework/View/Element/文件夹下,我们可以找到UiComponentInterface和UiComponentFactory。完整的Ui组件集合位于vendor/magento/framework/View/Element/目录下。Magento 通过一个名为Magento_Ui的独立模块实现了UiComponent。因此,组件本身位于vendor/magento/module-ui/Component/目录下。
组件实现UiComponentInterface,该接口定义在vendor/magento/framework/View/Element/UiComponentInterface.php文件中,如下所示:
namespace Magento\Framework\View\Element;
use Magento\Framework\View\Element\UiComponent\ContextInterface;
interface UiComponentInterface extends BlockInterface
{
public function getName();
public function getComponentName();
public function getConfiguration();
public function render();
public function addComponent($name, UiComponentInterface $component);
public function getComponent($name);
public function getChildComponents();
public function getTemplate();
public function getContext();
public function renderChildComponent($name);
public function setData($key, $value = null);
public function getData($key = '', $index = null);
public function prepare();
public function prepareDataSource(array & $dataSource);
public function getDataSourceData();
}
注意BlockInterface如何扩展BlockInterface,而BlockInterface只定义了一个方法要求,如下所示:
namespace Magento\Framework\View\Element;
interface BlockInterface
{
public function toHtml();
}
由于Block是界面元素的一部分,UiComponent可以被视为一个高级块。让我们快速查看\Magento\Framework\View\Layout类中的_renderUiComponent方法,部分定义如下:
protected function _renderUiComponent($name)
{
$uiComponent = $this->getUiComponent($name);
return $uiComponent ? $uiComponent->toHtml() : '';
}
这表明 UiComponent 与块以相同的方式渲染,通过在组件上调用 toHtml 方法。vendor/magento/module-ui/view/base/ui_component/etc/definition.xml 文件包含了一个广泛的 UiComponents 列表,如下所示:
-
dataSource:Magento\Ui\Component\DataSource -
listing:Magento\Ui\Component\Listing -
paging:Magento\Ui\Component\Paging -
filters:Magento\Ui\Component\Filters -
container:Magento\Ui\Component\Container -
form:Magento\Ui\Component\Form -
price:Magento\Ui\Component\Form\Element\DataType\Price -
image:Magento\Ui\Component\Form\Element\DataType\Media -
nav:Magento\Ui\Component\Layout\Tabs\Nav
…以及更多
这些组件主要用于在管理区域构建列表和过滤器。如果我们对整个 Magento 进行 uiComponent 的字符串搜索,我们通常会找到类似 vendor/magento/module-cms/view/adminhtml/layout/cms_block_index.xml 中的条目,内容如下:
<page xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout /etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<uiComponent name="cms_block_listing"/>
</referenceContainer>
</body>
</page>
uiComponent 的 name 属性的值 cms_block_listing 指的是 vendor/magento/module-cms/view/adminhtml/ui_component/cms_block_listing.xml 文件的名字。在 cms_block_listing.xml 文件中,我们定义了一个跨越数百行 XML 的列表组件。列表组件然后是 dataSource、container、bookmark、filterSearch、filters 等等。我们不会深入这些声明的细节,因为我们的重点是更通用的前端部分。
容器
容器没有与之相关的块类。容器会自动渲染其所有子元素。它们允许配置一些属性。只需将任何元素附加到容器上,它就会自动渲染。使用容器,我们可以定义包装标签、CSS 类等。
我们不能创建容器的实例,因为它们是一个抽象概念,而我们可以创建块实例。
容器是通过 Magento\Framework\View\Layout 类的 _renderContainer 方法渲染的,定义如下:
protected function _renderContainer($name)
{
$html = '';
$children = $this->getChildNames($name);
foreach ($children as $child) {
$html .= $this->renderElement($child);
}
if ($html == '' || !$this->structure->getAttribute($name, Element::CONTAINER_OPT_HTML_TAG)) {
return $html;
}
$htmlId = $this->structure->getAttribute($name, Element::CONTAINER_OPT_HTML_ID);
if ($htmlId) {
$htmlId = ' id="' . $htmlId . '"';
}
$htmlClass = $this->structure->getAttribute($name, Element::CONTAINER_OPT_HTML_CLASS);
if ($htmlClass) {
$htmlClass = ' class="' . $htmlClass . '"';
}
$htmlTag = $this->structure->getAttribute($name, Element::CONTAINER_OPT_HTML_TAG);
$html = sprintf('<%1$s%2$s%3$s>%4$s</%1$s>', $htmlTag, $htmlId, $htmlClass, $html);
return $html;
}
容器支持以下额外属性:htmlTag、htmlClass、htmlId 和 label。为了演示容器在实际操作中的效果,让我们确保我们有一个来自 第四章,模型和集合 的模块,并在模块根目录 app/code/Foggyline/Office/ 中创建 view/frontend/layout/foggyline_office_test_crud.xml 文件,内容如下:
<page layout="1column"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View /Layout/etc/page_configuration.xsd">
<head>
<title>Office CRUD #layout</title>
</head>
<body>
<container name="foobar" htmlTag="div" htmlClass="foo- bar">
<block class="Magento\Framework\View\Element\Text" name="foo">
<action method="setText">
<argument name="text" xsi:type="string"> <![CDATA[<p>The Foo</p>]]></argument>
</action>
</block>
<block class="Magento\Framework\View\Element\Text" name="bar">
<action method="setText">
<argument name="text" xsi:type="string"> <![CDATA[<p>The Bar</p>]]></argument>
</action>
</block>
</container>
</body>
</page>
上述 XML 定义了一个名为 foobar 的单个容器,容器内有两个名为 foo 和 bar 的块元素。当我们在浏览器中打开 http://{我们的商店网址}/index.php/foggyline_office/test/crud/ 时,它应该会启动。
注意容器本身并没有嵌套在任何其他元素中,而是直接嵌入到主体中。我们本可以轻松地嵌套到其他容器中,如下所示:
<body>
<referenceContainer name="content">
<container name="foobar" htmlTag="div" htmlClass="foo- bar">
无论哪种方式,我们都应该在浏览器中看到 The Foo 和 The Bar 这两个字符串,以及加载了全页布局,如下截图所示:

块
尽管容器决定了页面的布局,但它们并不直接包含实际内容。包含内容并嵌套在容器内的部分被称为 块。每个块可以包含任意数量的子内容块或子容器。因此,几乎每个 Magento 中的网页都是由块和容器的混合体构成的。布局定义了页面上块的顺序,而不是它们的位置。块的外观和感觉由 CSS 和页面的渲染方式决定。当我们提到块时,我们几乎总是隐含地指模板。模板是实际在页面内绘制元素的东西;块是包含数据的东西。换句话说,模板是 PHTML 或 HTML 文件,通过变量或方法从链接的 PHP 块类中提取数据。
Magento 在 app/etc/di.xml 中定义了 Magento\Framework\View\Result\Page 类型,如下所示:
<type name="Magento\Framework\View\Result\Page">
<arguments>
<argument name="layoutReaderPool" xsi:type="object">pageConfigRenderPool</argument>
<argument name="generatorPool" xsi:type="object">pageLayoutGeneratorPool</argument>
<argument name="template" xsi:type="string">Magento_Theme::root.phtml</argument>
</arguments>
</type>
注意模板参数设置为 Magento_Theme::root.phtml。当 Page 被初始化时,它会选择 vendor/magento/module-theme/view/base/templates/root.phtml 文件。root.phtml 定义如下:
<!doctype html>
<html <?php echo $htmlAttributes ?>>
<head <?php echo $headAttributes ?>>
<?php echo $requireJs ?>
<?php echo $headContent ?>
<?php echo $headAdditional ?>
</head>
<body data-container="body" data-mage-init='{"loaderAjax": {}, "loader": { "icon": "<?php echo $loaderIcon; ?>"}}' <?php echo $bodyAttributes ?>>
<?php echo $layoutContent ?>
</body>
</html>
在 root.phtml 中的变量在 Magento\Framework\View\Result\Page 渲染方法调用期间分配(部分)如下:
protected function render(ResponseInterface $response)
{
$this->pageConfig->publicBuild();
if ($this->getPageLayout()) {
$config = $this->getConfig();
$this->addDefaultBodyClasses();
$addBlock = $this->getLayout()->getBlock ('head.additional');
$requireJs = $this->getLayout()->getBlock('require.js');
$this->assign([
'requireJs' => $requireJs ? $requireJs->toHtml() : null,
'headContent' => $this->pageConfigRenderer-> renderHeadContent(),
'headAdditional' => $addBlock ? $addBlock->toHtml() : null,
'htmlAttributes' => $this->pageConfigRenderer-> renderElementAttributes($config::ELEMENT_TYPE_HTML),
'headAttributes' => $this->pageConfigRenderer-> renderElementAttributes($config::ELEMENT_TYPE_HEAD),
'bodyAttributes' => $this->pageConfigRenderer-> renderElementAttributes($config::ELEMENT_TYPE_BODY),
'loaderIcon' => $this->getViewFileUrl('images/loader- 2.gif'),
]);
$output = $this->getLayout()->getOutput();
$this->assign('layoutContent', $output);
$output = $this->renderPage();
$this->translateInline->processResponseBody($output);
$response->appendBody($output);
} else {
parent::render($response);
}
return $this;
}
表达式 $this->assign 用于将变量如 layoutContent 分配给 root.phtml 模板。layoutContent 是基于基本布局生成的,包括当前页面的所有布局更新。
基础布局包括以下 XML 文件在 vendor/magento/module-theme/view/ 目录下:
-
base/page_layout/empty.xml -
frontend/page_layout/1column.xml -
frontend/page_layout/2columns-left.xml -
frontend/page_layout/2columns-right.xml -
frontend/page_layout/3columns.xml
表达式 $this->getLayout()->getOutput() 用于获取所有标记为输出的块。它基本上在布局中查找元素,渲染它们,并返回带有输出的字符串。在这个过程中,会触发 core_layout_render_element 事件,这为我们影响输出结果提供了一种可能的方式。此时,页面上的大多数元素都已渲染。这很重要,因为块在这里扮演着重要角色。渲染系统会考虑 empty.xml,因为它也由一个容器列表组成,每个容器都通过其他布局更新附加了一些块。
注意
简而言之,每个容器都分配了相应的块。每个块通常(但不总是)渲染一个模板。模板本身可能调用也可能不调用其他块,依此类推。块在从模板调用时被渲染。
块架构和生命周期
块是 Magento 中主要的视图元素之一。在父树结构的根处,块从 Magento\Framework\View\Element\AbstractBlock 类扩展并实现 Magento\Framework\View\Element\BlockInterface。
BlockInterface 只设置了一个要求,即实现 toHtml 方法。此方法应返回块的 HTML 输出。
在查看 AbstractBlock 的内部结构时,我们可以看到它声明了许多方法。其中最重要的方法如下:
-
_prepareLayout:准备全局布局。我们可以在子类中重新定义此方法以更改布局。 -
addChild:创建一个新的块,将其设置为当前块的子块,并返回新创建的块。 -
_toHtml:返回一个空字符串。我们需要在子类中重写此方法以生成 HTML。 -
_beforeToHtml:返回$this。在渲染 HTML 之前执行,但在尝试加载缓存之后。 -
_afterToHtml:在渲染后处理块 HTML。返回一个 HTML 字符串。 -
toHtml:生成并返回一个块的 HTML 输出。此方法不应被重写。如果需要,我们可以在子类中重写_toHtml方法。
AbstractBlock 的执行流程可以描述如下:
-
_prepareLayout -
toHtml -
_beforeToHtml -
_toHtml -
_afterToHtml
它从 _prepareLayout 开始,通过一系列方法直到达到 _afterToHtml。本质上,这是我们关于块执行流程需要了解的内容。
最重要的块类型是:
-
Magento\Framework\View\Element\Text -
Magento\Framework\View\Element\Text\ListText -
Magento\Framework\View\Element\Messages -
Magento\Framework\View\Element\Template
所有这些块基本上都是抽象块的实现。由于 AbstractBlock 中的 _toHtml 方法仅返回一个空字符串,因此所有这些子类都实现了它们自己的 _toHtml 方法。
为了演示这些块的使用,我们可以使用之前创建的 app/code/Foggyline/Office/view/frontend/layout/foggyline_office_test_crud.xml 文件。
Text 块有一个 setText 方法,我们可以使用它来设置其内容。我们通过布局文件实例化 Text 块并设置其文本值的示例如下:
<block class="Magento\Framework\View\Element\Text" name="example_1">
<action method="setText">
<argument name="text" xsi:type="string"><![CDATA[<p>Text_1</p>]]></argument>
</action>
</block>
ListText 块从 Text 类扩展而来。然而,它实际上并不支持使用 setText 来设置其内容。这仅从其代码中就可以明显看出,在其 _toHtml 方法的实现中,$this->setText('') 表达式立即被调用。相反,实际上发生的情况是 _toHtml 方法遍历它可能拥有的任何子块,并在其上调用布局的 renderElement 方法。基本上,我们可以将 ListText 块与 container 进行比较,因为它们具有几乎相同的目的。然而,与容器不同,块是一个类,因此我们可以从 PHP 中操作它。以下是一个使用 ListText 的示例,其中包含几个子 Text 块:
<block class="Magento\Framework\View\Element\Text\ListText" name="example_2">
<block class="Magento\Framework\View\Element\Text" name="example_2a">
<action method="setText">
<argument name="text" xsi:type="string"> <![CDATA[<p>Text_2A</p>]]></argument>
</action>
</block>
<block class="Magento\Framework\View\Element\Text" name="example_2b">
<action method="setText">
<argument name="text" xsi:type="string"> <![CDATA[<p>Text_2B</p>]]></argument>
</action>
</block>
</block>
Messages 块支持四种我们可以用来向输出添加内容的方法:addSuccess、addNotice、addWarning 和 addError。以下是通过布局更新文件实例化 Messages 块的示例:
<block class="Magento\Framework\View\Element\Messages" name="example_3">
<action method="addSuccess">
<argument name="text" xsi:type="string"> <![CDATA[<p>Text_3A: Success</p>]]></argument>
</action>
<action method="addNotice">
<argument name="text" xsi:type="string"> <![CDATA[<p>Text_3B: Notice</p>]]></argument>
</action>
<action method="addWarning">
<argument name="text" xsi:type="string"> <![CDATA[<p>Text_3C: Warning</p>]]></argument>
</action>
<action method="addError">
<argument name="text" xsi:type="string"> <![CDATA[<p>Text_3D: Error</p>]]></argument>
</action>
</block>
上述示例应谨慎对待,因为在布局中调用这些设置方法不是正确的方式。默认的 Magento_Theme 模块已经定义了使用 vendor/magento/module-theme/view/frontend/templates/messages.phtml 进行消息渲染的 Messages 块。因此,在大多数情况下,没有必要定义我们自己的消息块。
最后,让我们看看以下 Template 块的示例:
<block class="Magento\Framework\View\Element\Template"
name="example_4" template="Foggyline_Office::office /no4/template.phtml"/>
上述 XML 将实例化 Template 类型的块,并在 app/code/Foggyline/Office/ 目录下渲染 view/frontend/templates/office/no4/template.phtml 文件的内容。
在 PHP 层面上,使用布局对象或直接通过对象管理器实例化一个新的块可以完成。布局方法是首选方法。关于之前在 XML 中的示例,让我们看看它们的 PHP 替代方案(假设 $resultPage 是 \Magento\Framework\View\Result\PageFactory 的一个实例)。
以下是一个实例化 Text 类型的块并将其添加到内容容器中的示例:
$block = $resultPage->getLayout()->createBlock(
'Magento\Framework\View\Element\Text',
'example_1'
)->setText(
'<p>Text_1</p>'
);
$resultPage->getLayout()->setChild(
'content',
$block->getNameInLayout(),
'example_1_alias'
);
ListText 版本在 PHP 中的实现如下:
$blockLT = $resultPage->getLayout()->createBlock(
'Magento\Framework\View\Element\Text\ListText',
'example_2'
);
$resultPage->getLayout()->setChild(
'content',
$blockLT->getNameInLayout(),
'example_2_alias'
);
$block2A = $resultPage->getLayout()->createBlock(
'Magento\Framework\View\Element\Text',
'example_2a'
)->setText(
'<p>Text_2A</p>'
);
$resultPage->getLayout()->setChild(
'example_2',
$block2A->getNameInLayout(),
'example_2a_alias'
);
$block2B = $resultPage->getLayout()->createBlock(
'Magento\Framework\View\Element\Text',
'example_2b'
)->setText(
'<p>Text_2B</p>'
);
$resultPage->getLayout()->setChild(
'example_2',
$block2B->getNameInLayout(),
'example_2b_alias'
);
注意我们首先创建了一个 ListText 块的实例,并将其分配给名为 content 的元素作为子元素。然后我们创建了两个单独的 Text 块,并将它们分配给名为 example_2 的元素作为子元素,即我们的 ListText。
接下来,让我们定义以下 Messages 块:
$messagesBlock = $resultPage->getLayout()->createBlock(
'Magento\Framework\View\Element\Messages',
'example_3'
);
$messagesBlock->addSuccess('Text_3A: Success');
$messagesBlock->addNotice('Text_3B: Notice');
$messagesBlock->addWarning('Text_3C: Warning');
$messagesBlock->addError('Text_3D: Error');
$resultPage->getLayout()->setChild(
'content',
$messagesBlock->getNameInLayout(),
'example_3_alias'
);
最后,让我们看看如何初始化 Template 块类型,如下所示:
$templateBlock = $resultPage->getLayout()->createBlock(
'Magento\Framework\View\Element\Template',
'example_3'
)->setTemplate(
'Foggyline_Office::office/no4/template.phtml'
);
$resultPage->getLayout()->setChild(
'content',
$templateBlock->getNameInLayout(),
'example_4_alias'
);
在可能的情况下,我们应该使用 XML 布局来设置我们的块。
现在我们知道了如何利用最常用的 Magento 块类型,让我们看看我们如何创建自己的块类型。
定义我们自己的 block 类就像创建一个扩展 Template 的自定义类文件一样简单。这个 block 类应该放在我们的模块 Block 目录下。使用我们的 Foggyline_Office 模块,让我们创建一个文件,Block/Hello.php,内容如下:
namespace Foggyline\Office\Block;
class Hello extends \Magento\Framework\View\Element\Template
{
public function helloPublic()
{
return 'Hello #1';
}
protected function helloProtected()
{
return 'Hello #2';
}
private function helloPrivate()
{
return 'Hello #3';
}
}
上述代码仅仅创建了一个新的自定义块类。然后我们可以通过布局文件调用这个 block 类,如下所示:
<block class="Foggyline\Office\Block\Hello"
name="office.hello" template="office/hello.phtml"/>
最后,在我们的模块 app/code/Foggyline/Office/ 目录中,我们创建一个模板文件,view/frontend/templates/office/hello.phtml,内容如下:
<?php /* @var $block Foggyline\Office\Block\Hello */ ?>
<h1>Hello</h1>
<p><?php echo $block->helloPublic() ?></p>
<p><?php //echo $block->helloProtected() ?></p>
<p><?php //echo $block->helloPrivate() ?></p>
为了进一步了解模板文件中的情况,让我们深入了解一下模板本身。
模板
模板是混合了 HTML 和 PHP 的代码片段。PHP 部分包括变量、表达式和 class 方法调用等元素。Magento 使用 PHTML 文件扩展名作为模板文件。模板位于单个模块的 view/{_area_}/templates/ 目录下。
在我们之前的例子中,我们使用类似于 Foggyline_Office::office/hello.phtml 的表达式来引用我们的模块模板文件。由于模板可能属于不同的模块,我们应该将模板名称作为前缀来使用,这是一个最佳实践。这将帮助我们定位模板文件并避免文件冲突。
一个简单的命名公式是这样的:我们输入模块的名称,然后是双冒号,接着是名称。这样,模板路径 office/hello.phtml 就等于 Foggyline_Office::office/hello.phtml。
在 PHTML 模板文件中,我们经常有各种 PHP 表达式,如 $block->helloPublic()。注意前面 XML 中的块类 Foggyline\Office\Block\Hello。这个块类的实例通过 $block 变量在 hello.phtml 中对我们可用。因此,像 $block->helloPublic() 这样的表达式实际上是调用 Hello 类的 helloPublic 方法。Hello 类不是 Magento 核心类之一,但它扩展了 \Magento\Framework\View\Element\Template。
我们的 hello.phtml 模板还有另外两个表达式:$block->helloProtected() 和 $block->helloPrivate()。然而,这些表达式不会被执行,因为模板只能看到它们 $block 实例的公共方法。
$this 变量在 PHTML 模板中也是可用的,它是 Magento\Framework\View\TemplateEngine\Php 类的一个实例。
在前面的模板代码示例中,我们可以轻松地将 $block->helloPublic() 替换为 $this->helloPublic() 表达式。这样做的原因在于模板引擎 Php 类,部分定义如下:
public function __call($method, $args)
{
return call_user_func_array([$this->_currentBlock, $method], $args);
}
public function __isset($name)
{
return isset($this->_currentBlock->{$name});
}
public function __get($name)
{
return $this->_currentBlock->{$name};
}
由于模板是在引擎的上下文中而不是在块的上下文中包含的,__call 将方法调用重定向到当前块。同样,__isset 将 isset 调用重定向到当前块,而 __get 允许读取当前块的属性。
虽然我们可以在模板文件中为同一目的使用 $block 和 $this,但我们实际上应该选择使用 $block。
模板的重要方面之一是它们的回退机制。回退是指仅给定相对路径时定义完整模板路径的过程。例如,office/hello.phtml 会回退到 app/code/Foggyline/Office/view/frontend/templates/office/hello.phtml 文件。
路径解析从定义在Magento\Framework\View\Element\Template类上的_toHtml方法开始。然后_toHtml方法在同一类中调用getTemplateFile,它反过来在resolver上调用getTemplateFileName,resolver是\Magento\Framework\View\Element\Template\File\Resolver的一个实例。进一步查看,resolver的getTemplateFileName进一步在_viewFileSystem上调用getTemplateFileName,_viewFileSystem是\Magento\Framework\View\FileSystem的一个实例。在\Magento\Framework\View\Design\FileResolution\Fallback\TemplateFile的一个实例上进一步调用getFile方法。getFile进一步触发Magento\Framework\View\Design\FileResolution\Fallback\Resolver\Simple实例上的 resolve 方法,该实例进一步在Magento\Framework\View\Design\Fallback\RulePool实例上调用getRule方法。RulePoll类是链中的最后一个类。getRule最终调用createTemplateFileRule方法,该方法创建检测文件位置的规则。
在运行getRule方法时,Magento 会检查以下类型的回退规则:
-
file -
locale -
template -
static -
email
值得花些时间研究RulePool类的内部工作原理,因为它展示了所列规则的详细回退。
布局
到目前为止,我们简要介绍了布局 XML。布局 XML 是一种工具,以模块化和灵活的方式构建 Magento 应用程序的页面。它使我们能够描述页面布局和内容放置。查看 XML 根节点,我们可以区分两种类型的布局:
-
layout: 包裹在<layout>中的 XML -
page: 包裹在<page>中的 XML
Page布局代表一个完整的 HTML 页面,而layout布局代表页面的一部分。layout类型是page布局类型的子集。这两种类型的布局 XML 文件都由位于vendor/magento/framework/View/Layout/etc/目录下的 XSD 模式进行验证:
-
layout–layout_generic.xsd -
page–page_configuration.xsd
根据提供<layout>和<page>元素的应用程序组件,我们可以进一步将它们分为基础布局和主题布局。
基础布局由模块提供,通常位于以下位置:
-
<module_dir>/view/frontend/layout: 页面配置和通用布局文件 -
<module_dir>/view/frontend/page_layout: 页面布局文件
主题布局由主题提供,通常位于以下位置:
-
<theme_dir>/<Namespace>_<Module>/layout: 页面配置和通用布局文件 -
<theme_dir>/<Namespace>_<Module>/page_layout: 页面布局文件
Magento 将在适当的页面上加载并合并所有模块和主题 XML 文件。一旦文件合并并处理了 XML 指令,结果将被渲染并发送到浏览器进行显示。如果存在两个不同的布局 XML 文件,且两者都引用了相同的块,则序列中具有相同名称的第二个文件将替换第一个文件。
当加载 XML 文件时,Magento 同时应用一个继承主题。我们可以应用一个主题,并且它将查找父主题,直到找到一个没有父主题的主题。
除了合并每个模块的文件外,模块目录内的布局文件也可以被主题扩展或覆盖。覆盖布局 XML 不是一种好的做法,但有时可能是必要的。
要覆盖在<module_dir>/view/frontend/layout/目录内由模块提供的基布局文件。
我们需要在app/design/frontend/<vendor>/<theme>/<Namespace_Module>/layout/override/base/目录中创建一个具有相同名称的 XML 文件。
要覆盖在<parent_theme_dir>/<Namespace>_<Module>/layout/目录内由父主题提供的主题布局文件。
我们需要在app/design/frontend/<vendor>/<theme>/<Namespace_Module>/layout/override/theme/<Parent_Vendor>/<parent_theme>/目录中创建一个具有相同名称的 XML 文件。
布局既可以被覆盖也可以被扩展。
定制布局的推荐方法是通过对自定义主题进行扩展。我们可以通过在app/design/frontend/{vendorName}/{theme}/{vendorName}_{moduleName}/layout/目录中添加一个具有相同名称的自定义 XML 布局文件来实现这一点。
如前例所示,布局支持大量指令:页面、头部、块等。这些指令的实际用途以及它们如何混合在一起本身就是一个挑战。详细说明每个指令超出了本书的范围。然而,我们可以展示如何确定某个特定指令的使用,这可能在我们某个特定时刻需要。为此,强烈建议使用像NetBeans PHP或PhpStorm这样的 IDE 环境,这些环境为包含 XSD 的 XML 提供自动完成功能。
以下是一个为 PhpStorm 定义外部架构的示例,我们只是简单地说urn:magento:framework:View/Layout/etc/page_configuration.xsd别名属于vendor/magento/framework/View/Layout/etc/page_configuration.xsd文件:

这样,当我们在 XML 文件周围键入时,PhpStorm 将知道如何提供自动完成功能。
例如,让我们看看我们如何使用css指令将外部 CSS 文件添加到我们的页面中。使用支持自动完成的 IDE,当我们开始在page | head元素中键入css指令时,自动完成可能会抛出以下内容:

显示了可用的属性列表,例如 src、sizes、ie_condition、src_type 等。像 PhpStorm 这样的 IDE 将允许我们右键单击一个元素或其属性,并 转到定义。查看 src 属性的定义将带我们进入 vendor/magento/framework/View/Layout/etc/head.xsd 文件,该文件定义了 css 元素如下:
<xs:complexType name="linkType">
<xs:attribute name="src" type="xs:string" use="required"/>
<xs:attribute name="defer" type="xs:string"/>
<xs:attribute name="ie_condition" type="xs:string"/>
<xs:attribute name="charset" type="xs:string"/>
<xs:attribute name="hreflang" type="xs:string"/>
<xs:attribute name="media" type="xs:string"/>
<xs:attribute name="rel" type="xs:string"/>
<xs:attribute name="rev" type="xs:string"/>
<xs:attribute name="sizes" type="xs:string"/>
<xs:attribute name="target" type="xs:string"/>
<xs:attribute name="type" type="xs:string"/>
<xs:attribute name="src_type" type="xs:string"/>
</xs:complexType>
所有这些都是我们可以设置在 css 元素上的属性,因此它们会显示自动完成,如下所示:

虽然使用强大的 IDE 不是使用 Magento 的必需条件,但拥有一个能够理解 XML 和 XSD 文件并提供自动完成和验证功能的 IDE 当然是有帮助的。
主题
默认情况下,Magento 附带两个主题,分别命名为 Blank 和 Luma。如果我们登录到 Magento 管理区域,我们可以在 内容 | 设计 | 主题 菜单下看到可用的主题列表,如下截图所示:

Magento 主题支持父子关系,这是我们之前提到的,在上一张图片的 父主题 列表中可以看到。
创建新主题
以下步骤概述了创建我们自己的主题的过程:
-
在
{Magento根目录}/app/design/frontend下,创建一个以我们供应商名称命名的新的目录,名为Foggyline。 -
在
vendor目录下,创建一个以主题名称命名的新的目录,名为jupiter。 -
在
jupiter目录下,创建registration.php文件,内容如下:<?php \Magento\Framework\Component\ComponentRegistrar::register( \Magento\Framework\Component\ComponentRegistrar::THEME, 'frontend/Foggyline/jupiter', __DIR__ ); -
将
vendor/magento/theme-frontend-blank/theme.xml复制到我们的主题中,app/design/frontend/Foggyline/jupiter/theme.xml,并按以下内容修改:<theme xsi:noNamespaceSchemaLocation="urn:magento: framework:Config/etc/theme.xsd"> <title>Foggyline Jupiter</title> <parent>Magento/blank</parent> <media> <preview_image>media/preview.jpg</preview_image> </media> </theme> -
创建
app/design/frontend/Foggyline/jupiter/media/preview.jpg图像文件作为主题预览图像(在管理区域中使用的图像)。 -
可选地,为静态文件如样式、字体、JavaScript 和图像创建单独的目录。这些文件存储在我们的主题
app/design/frontend/Foggyline/jupiter/文件夹的web子目录中,如下所示:-
web/css/ -
web/css/source/ -
web/css/source/ -
web/images/ -
web/js/
在主题
web目录下,我们存储通用的主题静态文件。如果我们的主题包含特定模块的静态文件,这些文件将存储在相应的vendor模块子目录下,例如app/design/frontend/Foggyline/jupiter/{vendorName_moduleName}/web/。 -
-
可选地,我们可以在主题
web/images/文件夹下创建主题logo.svg图像。
完成前面的步骤后,回顾到管理区域下的 内容 | 设计 | 主题 菜单,我们现在应该可以看到我们的主题列在以下截图所示:

而点击表格中我们主题名称旁边的行将打开如下屏幕:

注意前两个屏幕没有显示任何应用主题的选项。它们只是列出可用的主题以及每个主题旁边的一些基本信息。我们的自定义主题显示了有趣的父子关系,其中父主题和子主题可以属于不同的供应商。
应用主题需要以下额外步骤:
-
确保我们的主题出现在 内容 | 设计 | 主题 菜单下的主题列表中。
-
前往 商店 | 设置 | 配置 | 常规 | 设计。
-
在 商店视图 下拉字段中,我们选择要应用主题的商店视图,如以下图片的左上角所示:
![创建新主题]()
-
在 设计主题 选项卡中,我们在 设计主题 下拉列表中选择我们新创建的主题,如前一张图片的右侧所示。点击 保存配置。
-
在 系统 | 工具 | 缓存管理 下,选择并刷新无效的缓存类型,然后点击 清除目录图片缓存、清除 JavaScript/CSS 缓存 和 清除静态文件缓存 按钮。
-
最后,为了看到我们的更改已应用,请在浏览器中重新加载店面页面。
关于主题还有很多可以说的,可以写成一本书。然而,我们将继续讨论其他重要部分。
JavaScript
Magento 使用了相当多的 JavaScript 库,例如:
-
Knockout:
knockoutjs.com -
Ext JS:
www.sencha.com/products/extjs/ -
jQuery:
jquery.com/ -
jQuery UI:
jqueryui.com/ -
modernizr:
www.modernizr.com/ -
Prototype:
www.prototypejs.org/ -
RequireJS:
requirejs.org/ -
script.aculo.us:
script.aculo.us/ -
moment.js:
momentjs.com/ -
Underscore.js:
underscorejs.org/ -
gruntjs:
gruntjs.com/ -
AngularJS:
angularjs.org/ -
jasmine:
jasmine.github.io/
…以及一些其他库
虽然前端开发者不需要了解每个库的细节,但至少对其中大多数有一个基本的了解是推荐的。
提示
值得在控制台运行 find {MAGENTO-DIR}/ -name \*.js > js-list.txt 来获取 Magento 中每个 JavaScript 文件的全列表。花几分钟浏览这个列表,可能会在将来处理 Magento 中的 JavaScript 时作为一个不错的备忘录。
RequireJS 和 jQuery 库可能是最有趣的,因为它们经常在前端开发中成为焦点。RequireJS 在 Magento 中扮演着重要角色,因为它加载其他 JavaScript 文件。使用像 RequireJS 这样的模块化脚本加载器可以提高代码的运行速度。速度的提升来自于从头部移除 JavaScript,并在后台异步或延迟加载 JavaScript 资源。
可以如下指定 JavaScript 资源:
-
Magento 代码库中所有库的库级别(
lib/web)。 -
模块级别的一个模块中所有库的级别(
app/code/{vendorName}/{moduleName}/view/{area}/web)。 -
主题级别的一个主题中所有库的级别(
app/design/{area}/{vendorName}/{theme}/{vendorName}_{moduleName}/web)。 -
主题中所有库(
app/design/{area}/{vendorName}/{theme}/web)。尽管可能,但建议不要使用此级别来指定 JavaScript 资源。
建议在模板中而不是在布局更新中指定 JavaScript 资源。这样,我们确保通过 RequireJS 处理资源。
要与 RequireJS 库一起工作,指定 JavaScript 资源的映射;即分配别名给资源。使用requires-config.js创建映射。
为了使我们的配置更加精确和针对不同的模块/主题,我们可以根据需要,在requires-config.js文件中识别多个级别的映射。配置按照以下顺序收集和执行:
-
库配置
-
模块级别的配置
-
主题模块级别的祖先主题配置
-
当前主题的主题模块级别的配置
-
主题级别的祖先主题配置
-
当前主题的主题级别的配置
当我们谈论 Magento 中的 JavaScript 时,我们可以听到各种术语,如组件和小部件。我们可以通过以下列表描述 Magento 中 JavaScript 的类型来轻松区分这些术语:
-
JavaScript 组件(JS 组件):这可以是任何装饰为AMD(异步模块定义的缩写)模块的单个 JavaScript 文件
-
UI 组件:位于
Magento_Ui模块中的 JavaScript 组件 -
jQuery UI 小部件:由 jQuery UI 库提供的,在 Magento 中使用的 JavaScript 组件/小部件
-
jQuery 小部件:使用 jQuery UI Widget Factory 创建的自定义小部件,并装饰为 AMD 模块
我们有两种方法可以在模板文件中初始化 JavaScript 组件:
-
使用
data-mage-init属性 -
使用
<script>标签
data-mage-init属性在 DOM 就绪事件上解析。由于它是在某个特定元素上初始化的,因此脚本只为该特定元素调用,不会自动初始化页面上相同类型的其他元素。data-mage-init使用的一个例子可能如下所示:
<div data-mage-init='{ "<componentName>": {...} }'></div>
<script>标签的初始化与任何特定元素无关,或者与特定元素相关但无法直接访问该元素。脚本标签必须有一个属性,type="text/x-magento-init"。一个<script>标签初始化的例子可能如下所示:
<script type="text/x-magento-init">
// specific element but no direct access to the element
"<element_selector>": {
"<jsComponent1>": ...,
"<jsComponent2>": ...
},
// without relation to any specific element
"*": {
"<jsComponent3">: ...
}
</script>
根据具体情况和所需的表达程度,我们可以选择使用data-mage-init属性或<script>标签。
创建自定义 JS 组件
让我们通过一个实际例子来创建Foggyline_Office模块中的 JS 组件,形式如下 jQuery 小部件:
首先,我们将条目添加到app/code/Foggyline/Office/view/frontend/requirejs-config.js中,如下所示:
var config = {
map: {
'*': {
foggylineHello: 'Foggyline_Office/js/foggyline-hello'
}
}
};
然后我们添加实际的 JavaScript 文件app/code/Foggyline/Office/view/frontend/web/js/foggyline-hello.js,内容如下:
define([
"jquery",
"jquery/ui"
], function($){
"use strict";
$.widget('mage.foggylineHello', {
options: {
},
_create: function () {
alert(this.options);
//my code here
}
});
return $.mage.foggylineHello;
});
最后,我们在某个 PHTML 模板中调用我们的 JavaScript 组件,例如app/code/Foggyline/Office/view/frontend/templates/office/hello.phtml,如下所示:
<div data-mage-init='{"foggylineHello":{"myVar1": "myValue1", "myVar2": "myValue2"}}'>Foggyline</div>
刷新前端后,我们应该在浏览器中看到alert(this.options)的结果,显示myVar1和myVar2。
data-mage-init部分基本上在页面加载时触发。它不是通过在div元素上的一些点击或类似事件触发的;它是在页面加载时触发的。
如果我们在浏览器中没有看到期望的结果,我们可能需要在管理区域完全清除缓存。
CSS
Magento 使用官方 LESS 处理器的 PHP 端口将.less文件解析为.css文件。LESS 是一个 CSS 预处理器,通过向 CSS 语言添加各种功能来扩展它,如变量、混合和函数。所有这些使得 CSS 更易于维护、扩展和主题化。因此,前端开发者应编写 LESS 文件,然后由 Magento 转换为适当的 CSS 变体。
小贴士
值得在控制台中运行find / -name *.less > less-list.txt`来获取 Magento 中每个 LESS 文件的全列表。花几分钟浏览这个列表可能会在处理 Magento 中的样式表片段时作为一个很好的未来备忘录。
我们可以通过以下方法之一来定制店面外观和感觉:
-
覆盖默认的 LESS 文件——只有当我们的主题继承自默认主题或任何其他主题时,我们才能覆盖实际的 LESS 文件
-
使用内置的 LESS 预处理器创建我们自己的 LESS 文件
-
创建我们自己的 CSS 文件,可选地使用第三方 CSS 预处理器编译它们
在单独的前端主题目录中,我们可以在以下位置找到样式表:
-
{vendorName}_{moduleName}/web/css/source/ -
{vendorName}_{moduleName}/web/css/source/module/ -
web/css/ -
web/css/source/
CSS 文件可以通过模板和布局文件包含在页面中。一种推荐的方法是通过布局文件包含它们。如果我们想让我们的样式表在前端的所有页面上都可用,我们可以通过添加 default_head_blocks.xml 文件来实现。如果我们查看 blank 主题,它使用 vendor/magento/theme-frontend-blank/Magento_Theme/layout/default_head_blocks.xml,如下定义:
<page xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout /etc/page_configuration.xsd">
<head>
<css src="img/styles-m.css"/>
<css src="img/styles-l.css" media="screen and (min-width: 768px)"/>
<css src="img/print.css" media="print"/>
</head>
</page>
所需的只是将此文件复制到我们自定义主题的相同位置;假设它是前面示例中的 jupiter 主题,那么就是 app/design/frontend/Foggyline/jupiter/Magento_Theme/layout/default_head_blocks.xml。然后我们只需修改文件以包含我们的 CSS。
当运行时,Magento 将尝试查找包含的 CSS 文件。如果找不到 CSS 文件,它将搜索具有 .less 扩展名的同名文件。这是内置的预处理机制的一部分。
摘要
在本章中,我们首先探讨了渲染流程过程的三个方面:视图、结果对象和页面。然后我们详细研究了三个主要视图元素:ui-components、containers 和 blocks。我们进一步深入研究了块,探讨了它们的架构和生命周期。然后我们转向模板,探讨了它们的定位、渲染和回退。接下来是 XML 布局,它是块和模板之间的粘合剂。所有这些都为我们进一步研究主题结构、JavaScript 组件和 CSS 奠定了基础。在这个过程中,我们还进行了一些关于自定义主题和 JavaScript 组件创建的动手实践。CSS 和 JavaScript 只是 Magento 前端的一部分。从技术角度来看,对 XML 的扎实理解,甚至一些 PHP,对于前端相关开发来说,更多的是一种要求而不是例外。
下一章将介绍 Magento 的 Web API,我们将学习如何进行身份验证、进行 API 调用,甚至构建我们自己的 API。
第九章. Web API
在前面的章节中,我们学习了如何使用一些后端组件,以便店主可以管理和操作数据,如客户、产品、类别、订单等。有时这还不够,比如当我们从第三方系统中拉取或推送数据时。在这些情况下,Magento Web API 框架使得通过 REST 或 SOAP 调用 Magento 服务变得容易。
在本章中,我们将涵盖以下主题:
-
用户类型
-
认证方法
-
REST 与 SOAP
-
基于令牌的认证实践
-
基于 OAuth 的认证实践
-
基于 OAuth 的 Web API 调用
-
基于会话的认证实践
-
创建自定义 Web API
-
列表过滤的搜索条件接口
在我们开始进行 Web API 调用之前,我们必须验证我们的身份并拥有访问 API 资源的必要权限(授权)。认证允许 Magento 识别调用者的用户类型。根据用户的(管理员、集成、客户或访客)访问权限,确定 API 调用资源的可访问性。
用户类型
我们可以访问的资源列表取决于我们的用户类型,并在我们的模块配置文件webapi.xml中定义。
API 已知有三种用户类型,如下所示:
-
管理员或集成者:管理员或集成者被授权的资源。例如,如果管理员被授权访问
Magento_Cms::page resource,他们可以发起POST /V1/cmsPage调用。 -
客户:客户被授权的资源。这些资源具有匿名或自我权限。
-
访客用户:访客被授权的资源。这些资源具有匿名权限。
两个文件在定义 API 方面起着至关重要的作用:我们的模块acl.xml和webapi.xml文件。
acl.xml是我们定义我们的模块访问控制列表(ACL)的地方。它定义了访问资源的可用权限集。所有 Magento 模块的acl.xml文件被合并以构建一个 ACL 树,该树用于选择允许的 admin 角色资源或第三方集成的访问(系统 | 扩展 | 集成 | 添加新集成 | 可用 API)。
webapi.xml是我们定义 Web API 资源和它们权限的地方。当我们创建webapi.xml时,acl.xml中定义的权限被引用以创建每个 API 资源的访问权限。
让我们看看以下(截断的)来自核心Magento_Cms模块的webapi.xml:
<routes xsi:noNamespaceSchemaLocation= "urn:magento:module:Magento_Webapi:etc/webapi.xsd">
...
<route url="/V1/cmsPage" method="POST">
<service class="Magento\Cms\Api\PageRepositoryInterface" method="save" />
<resources>
<resource ref="Magento_Cms::page" />
</resources>
</route>
...
<route url="/V1/cmsBlock/search" method="GET">
<service class="Magento\Cms\Api\BlockRepositoryInterface" method="getList" />
<resources>
<resource ref="Magento_Cms::block" />
</resources>
</route>
...
</routes>
在 CMS 页面 API 的前述 webapi.xml 文件中,只有拥有 Magento_Cms::page 授权的用户可以访问 POST /V1/cmsPage 或 GET /V1/cmsBlock/search。我们将在示例中稍后更详细地解释路由;目前,我们的重点是 resource。我们可以在资源下分配多个子 resource 元素。在这些情况下,用户只要有任何一个这些 ACL 被分配,就能进行 API 调用。
实际的授权是授予给管理员或集成商,他们在 Magento 管理员中定义,可以选择完整的组或 ACL 树中选定的特定资源,如下面的截图所示:

由于 webapi.xml 和 acl.xml 相互关联,让我们看看核心模块 Magento_Cms 的(截断的)acl.xml 文件:
<resources>
<resource id="Magento_Backend::admin">
<resource id="Magento_Backend::content">
<resource id="Magento_Backend::content_elements">
<resource id="Magento_Cms::page" ...>
...
</resource>
</resource>
</resource>
</resource>
</resources>
注意到 Magento_Cms::page 资源的位置嵌套在 Magento_Backend::content_elements 之下,而 Magento_Backend::content_elements 又嵌套在 Magento_Backend::content 之下,Magento_Backend::content 又嵌套在 Magento_Backend::admin 之下。这告诉 Magento 在显示 角色资源 树时,在 Magento 管理员下渲染 ACL 的位置,如前一个截图所示。这并不意味着如果用户被授权访问所有这些父 Magento_Backend 资源,他仍然无法访问 Magento_Cms::page 资源所对应的 API。
对资源进行授权是一种相对简单的事情。在授权时没有树形检查。因此,当在 acl.xml 下定义时,每个资源都需要在 resource 元素上有一个唯一的 id 属性值。
正如之前所列出的,这些定义的资源是管理员或集成商被授权访问的资源。
相反,客户被分配了一个名为 anonymous 或 self 的资源。如果我们对所有 Magento 核心模块进行完整的 <resource ref="anonymous" /> 字符串搜索,会出现几个匹配项。
让我们来看看核心模块 vendor/magento/module-catalog/etc/webapi.xml 文件(以下为截断内容):
<route url="/V1/products" method="GET">
<service class= "Magento\Catalog\Api\ProductRepositoryInterface" method="getList"/>
<resources>
<resource ref="anonymous" />
</resources>
</route>
上述 XML 定义了一个 API 端点路径,其值为 /V1/products,可以通过 HTTP GET 方法访问。它进一步定义了一个名为 anonymous 的资源,这意味着当前登录的客户或访客用户可以调用此 API 端点。
anonymous 是一种特殊权限,在 acl.xml 中不需要定义。因此,它不会在 Magento 管理员下的权限树中显示。这仅仅意味着当前在 webapi.xml 中的资源可以无需认证即可访问。
最后,我们来看看 self 资源,其示例可以在(截断的)vendor/magento/module-customer/etc/webapi.xml 文件中找到如下:
<route url="/V1/customers/me" method="PUT">
<service class= "Magento\Customer\Api\CustomerRepositoryInterface" method="save"/>
<resources>
<resource ref="self"/>
</resources>
<data>
<parameter name="customer.id" force="true">%customer_id%</parameter>
</data>
</route>
self是一种特殊的访问方式,允许用户访问他们拥有的资源,前提是我们已经与系统建立了认证会话。例如,GET /V1/customers/me获取已登录客户的详细信息。这对于基于 JavaScript 的组件/小部件通常很有用。
认证方法
根据 Magento 的视角,移动应用、第三方应用和 JavaScript 组件/小部件(店面或管理员)是三种主要的客户端类型。尽管客户端基本上是与我们的 API 进行通信的一切,但每种类型的客户端都有一个首选的认证方法。
Magento 支持三种类型的认证方法,如下列出:
-
基于令牌的认证
-
基于 OAuth 的认证
-
基于会话的认证
基于令牌的认证最适合移动应用,其中令牌就像一个电子钥匙,提供对 Web API 的访问。基于令牌的认证系统背后的概念相对简单。用户在初始认证期间提供用户名和密码,以便从系统中获取一个时间有限的令牌。如果成功获取令牌,所有后续的 API 调用都使用该令牌进行。
基于 OAuth 的认证适用于与 Magento 集成的第三方应用。一旦应用通过OAuth 1.0a 握手过程获得授权,它就能访问 Magento Web API。在此我们必须了解三个关键术语:用户(资源所有者)、客户端(消费者)和服务器(服务提供商)。用户或资源所有者是请求允许访问其受保护资源的人。想象一下,一个客户作为用户(资源所有者)允许第三方应用访问其订单。在这种情况下,这个第三方应用就是客户端(消费者),而 Magento 及其 Web API 则是服务器(服务提供商)。
基于会话的认证可能是最容易理解的一种。作为客户,您使用客户凭证登录到 Magento 店面。作为管理员,您使用管理员凭证登录到 Magento 管理员界面。Magento Web API 框架使用您的登录会话信息来验证您的身份并授权访问请求的资源。
REST 与 SOAP 的比较
Magento 支持 Web API 的两种通信类型:SOAP(即简单对象访问协议)和REST(即表征状态转移)。认证方法本身并不绑定于任何一种。我们可以使用相同的认证方法和 Web API 方法调用,无论是 SOAP 还是 REST。
我们可能如下概述一些 REST 的特定内容:
-
我们通过 cURL 命令或 REST 客户端运行 REST Web API 调用。
-
请求支持
HTTP动词:GET、POST、PUT或DELETE。 -
一个
HTTP标头需要一个授权参数,指定使用 Bearer HTTP 授权方案 的认证令牌,Authorization: Bearer <TOKEN>。<TOKEN>是 Magento 令牌服务返回的认证令牌。 -
我们可以使用
HTTP标头Accept: application/<FORMAT>,其中<FORMAT>是 JSON 或 XML 之一。
我们可能如下概述一些 SOAP 特性:
-
我们通过 cURL 命令或 SOAP 客户端运行 SOAP Web API 调用。
-
只有为我们请求的服务才会生成 Web 服务定义语言(WSDL)文件。没有为所有服务合并的一个大 WSDL 文件。
-
Magento Web API 使用 WSDL 1.2,符合 WS-I 2.0 基本配置文件。
-
每个作为服务合同一部分的 Magento 服务接口在 WSDL 中都表示为一个独立的服务。
-
消费多个服务意味着在 WSDL 端点 URL 中以逗号分隔的方式指定它们,例如
http://<magento.host>/soap/<optional_store_code>?wsdl&services=<service_name_1>,<service_name_2>。 -
我们可以通过在浏览器中访问类似
http://<SHOP-URL>/soap/default?wsdl_list的 URL 来获取所有可用服务的列表。
以下 REST 和 SOAP 示例将广泛使用 cURL,它本质上是一个允许您从命令行或不同语言实现(如 PHP)中发出 HTTP 请求的程序。我们可以进一步将 cURL 描述为控制台浏览器,或我们的网络 view source 工具。我们可以用各种花哨的 REST 和 SOAP 库做的任何事情,我们也可以用 cURL 做到;它只是被认为是一个更底层的做法。
使用 cURL 或其他没有内部实现 WSDL/XML 解析的任何东西进行 SOAP 请求是繁琐的。因此,使用 PHP SoapClient 或更健壮的工具是必须的。SoapClient 是 PHP 的一个集成、积极维护的部分,因此通常是可用的。
即使有负分,我们仍将使用控制台 cURL、PHP cURL 和 PHP SoapClient 示例来展示所有我们的 API 调用。鉴于库抽象了如此多的功能,开发人员对 cURL 有一个坚实的理解是绝对必要的,即使是为了进行 SOAP 调用。
基于令牌的认证实践
基于令牌的认证的核心如下:
-
客户端使用用户名和密码请求访问
-
应用程序验证凭证
-
应用程序向客户端提供一个签名令牌
以下代码示例演示了针对客户用户的控制台 cURL REST 请求:
curl -X POST "http://magento2.ce/rest/V1/integration/customer/token"\
-H "Content-Type:application/json"\
-d '{"username":"john@change.me", "password":"abc123"}'
以下代码示例演示了针对客户用户的 PHP cURL REST 请求:
$data = array('username' => 'john@change.me', 'password' => 'abc123');
$data_string = json_encode($data);
$ch = curl_init('http://magento2.ce/rest/V1/integration /customer/token');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Content-Length: ' . strlen($data_string))
);
$result = curl_exec($ch);
以下代码示例演示了针对客户用户的控制台 cURL SOAP 请求:
curl -X POST -H 'Content-Type: application/soap+xml;
charset=utf-8; action= "integrationCustomerTokenServiceV1CreateCustomerAccessToken"'
-d @request.xml http://magento2.ce/index.php/soap/default?services= integrationCustomerTokenServiceV1
注意到 -d @request.xml 部分。在这里,我们告诉 curl 命令取 request.xml 文件的内容,并将其作为 POST 主体数据传递,其中 request.xml 文件的内容由前面的 curl 命令定义如下:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:integrationCustomerTokenServiceV1CreateCustomer AccessTokenRequest>
<username>john@change.me</username>
<password>abc123</password>
</ns1:integrationCustomerTokenServiceV1CreateCustomer AccessTokenRequest>
</env:Body>
</env:Envelope>
以下代码示例演示了针对客户用户的 PHP cURL SOAP-like 请求:
$data_string = file_get_contents('request.xml');
$ch = curl_init('http://magento2.ce/index.php/soap/default?services= integrationCustomerTokenServiceV1');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/soap+xml; charset=utf-8; action="integrationCustomerTokenServiceV1 CreateCustomerAccessToken"',
'Content-Length: ' . strlen($data_string))
);
$result = curl_exec($ch);
以下代码示例演示了使用 PHP SoapClient 进行 Web API 调用的用法:
$request = new SoapClient(
'http://magento2.ce/index.php/soap/default?wsdl&services= integrationCustomerTokenServiceV1',
array('soap_version' => SOAP_1_2, 'trace' => 1)
);
$token = $request->integrationCustomerTokenServiceV1Create CustomerAccessToken(array('username' => 'john@change.me', 'password' => 'abc123'));
管理员用户认证的 API 调用几乎相同,并且取决于我们采取的三种方法中的哪一种。区别仅在于在 REST 的情况下,使用https://magento2.ce/rest/V1/integration/admin/token作为端点 URL,以及在使用http://magento2.ce/index.php/soap/default?services=integrationCustomerTokenServiceV1的情况下。此外,对于 SOAP 调用,我们在$request对象上调用integrationAdminTokenServiceV1CreateAdminAccessToken。
在认证成功的情况下,无论是客户还是管理员 API 调用,响应都会是一个看起来随机的 32 个字符长字符串,我们称之为令牌。此令牌随后被保存在数据库中的oauth_token表中的令牌列下。
这可能对oauth_token表与令牌认证有什么关系有些困惑。
注意
如果我们仔细思考,基于令牌的认证可以看作是 OAuth 的简化版本,其中用户会使用用户名和密码进行认证,然后将获得的时效性令牌提供给第三方应用程序使用。
在认证失败的情况下,服务器会返回HTTP 401 未授权,其体包含一个 JSON 消息:
{"message":"Invalid login or password."}
注意我们如何能够调用 API 方法,尽管我们尚未进行认证?这意味着我们必须调用由匿名资源类型定义的 API。快速查看 API 端点可以给我们一些关于其定义位置的提示。在vendor/magento/module-integration/etc/webapi.xml文件下查看,我们可以看到以下(截断的)XML:
<route url="/V1/integration/admin/token" method="POST">
<service class="Magento\Integration\Api\AdminTokenServiceInterface" method="createAdminAccessToken"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/integration/customer/token" method="POST">
<service class="Magento\Integration\Api\ CustomerTokenServiceInterface" method="createCustomerAccessToken"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
我们可以清楚地看到,即使是基于令牌的认证本身也被定义为 API,使用匿名资源以便每个人都可以访问它。简而言之,基于令牌的认证是Magento\Integration模块的一个特性。
现在我们已经有了认证令牌,我们可以开始进行其他 API 调用。记住,令牌仅仅意味着我们已经对给定的用户名和密码进行了认证。它并不意味着我们能够访问所有 Web API 方法。这进一步取决于我们的客户或用户是否有适当的访问角色。
基于 OAuth 的认证实践
基于 OAuth 的认证是 Magento 支持的最复杂但最灵活的一种。在我们使用它之前,商家必须将我们的外部应用程序注册为与 Magento 实例的集成。以商家的身份,我们在系统 | 扩展 | 集成下的 Magento 管理区域进行操作。点击添加新集成按钮会打开如下截图所示的界面:

External Book App的值是我们外部应用的自由命名。如果我们将其与 Twitter 连接,我们就可以轻松地将它的名字放在这里。在名称旁边,我们有电子邮件、回调 URL和身份链接 URL字段。电子邮件的值并不是特别重要。回调 URL 和身份链接 URL 定义了接收 OAuth 凭证的外部应用端点。这些链接的值指向作为 OAuth 客户端的外部应用。我们稍后会回到这一点。
在可用 API面板下的API选项卡中,我们将资源访问设置为全部或自定义。如果设置为自定义,我们可以在资源选项中进一步微调我们希望允许访问此集成的资源,如下截图所示:

我们应该始终给外部应用我们正在使用的最小必要资源。这样,我们最小化了可能的安全风险。前面的截图显示我们只定义了Sales、Products、Customer和Marketing资源到集成中。这意味着 API 用户将无法使用内容资源,例如保存或删除页面。
如果我们现在点击保存按钮,我们应该被重定向回系统 | 扩展 | 集成屏幕,如下截图所示:

在这里,我们需要关注三件事情。首先,我们看到一个集成不安全的消息。这是因为当我们定义回调 URL 和身份链接 URL 时,我们使用了 HTTP 协议而不是 HTTPS 协议。在现实世界的连接中,出于安全考虑,我们需要确保使用 HTTPS。此外,我们注意到状态列仍然显示为不活跃。
位于状态列右侧的激活链接是启动双因素 OAuth 握手之前的步骤。只有能够访问后端集成列表的管理员才能启动此步骤。
在这一点上,我们需要从以下位置拉取External Book App OAuth 客户端背后的全部 PHP 代码,github.com/ajzele/B05032-BookAppOauthClient,并将其放置在我们 Magento 安装根目录下的pub/external-book-app/文件夹中,如下截图所示:

这些文件的功能是模拟我们自己的迷你 OAuth 客户端。我们不会深入探讨这些文件的内容,更重要的是将其视为一个外部 OAuth 客户端应用。当 Magento 触发回调和配置在上一页输出图像下的身份链接 URL 时,callback-url.php和identity-link-url.php文件将会执行。
一旦 OAuth 客户端文件就绪,我们回到我们的集成列表。在这里,我们点击 激活 链接。这打开了一个模态框,要求我们批准访问 API 资源,如下面的截图所示:

注意这里列出的 API 资源与我们在创建集成时在 API 选项卡下设置的那些相匹配。我们在这里实际上只能做两件事:要么点击 取消,要么点击 允许 来开始双因素 OAuth 握手。点击 允许 按钮并行执行两件事。
首先,它立即将凭据发送到创建 外部图书应用 集成时指定的端点(回调 URL)。从 Magento 到回调 URL 的 HTTP POST 包含与以下类似的参数值:
Array
(
[oauth_consumer_key] => cn5anfyvkg7sgm2lrv8cxvq0dxcrj7xm
[oauth_consumer_secret] => wvmgy0dmlkos2vok04k3h94r40jvi5ye
[store_base_url] => http://magento2-merchant.loc/index.php/
[oauth_verifier] => hlnsftola6c7b6wjbtb6wwfx4tow2x6x
)
基本上,一个 HTTP POST 请求正在击中 callback-url.php 文件,其内容(部分)如下:
session_id('BookAppOAuth');
session_start();
$_SESSION['oauth_consumer_key'] = $_POST['oauth_consumer_key'];
$_SESSION['oauth_consumer_secret'] = $_POST['oauth_consumer_secret'];
$_SESSION['store_base_url'] = $_POST['store_base_url'];
$_SESSION['oauth_verifier'] = $_POST['oauth_verifier'];
session_write_close();
header('HTTP/1.0 200 OK');
echo 'Response';
我们可以看到,通过 Magento 传递的参数被存储在一个名为 BookAppOAuth 的外部应用会话中。稍后,在 check-login.php 文件中,这些参数将被用来实例化 BookAppOauthClient,这将进一步被用来获取一个请求令牌,这是一个预先授权的令牌。
与 回调 URL HTTP POST 并行,我们打开了一个弹出窗口,如下面的截图所示:

我们在弹出窗口中看到的登录表单只是我们放在 identity-link-url.php 文件下的某些虚拟内容。Magento 通过 HTTP GET 向此文件传递两个值。这些是 consumer_id 和 success_call_back。consumer_id 的值是我们创建在管理区域中的集成 ID。OAuth 客户端应用决定是否要使用此值。success_call_back URL 指向我们的 Magento admin integration/loginSuccessCallback 路径。如果我们查看 identity-link-url.php 文件的代码,我们可以看到表单被设置为在 URL 上执行 POST 动作,例如 check-login.php?consumer_id={$consumerId}&callback_url={$callbackUrl}。
如果我们现在点击 登录 按钮,表单将 POST 数据到 check-login.php 文件,并在 URL 中作为 GET 参数传递 consumer_id 和 callback_url。
check-login.php 的内容(部分)定义如下:
require '../../vendor/autoload.php';
$consumer = $_REQUEST['consumer_id'];
$callback = $_REQUEST['callback_url'];
session_id('BookAppOAuth');
session_start();
$consumerKey = $_SESSION['oauth_consumer_key'];
$consumerSecret = $_SESSION['oauth_consumer_secret'];
$magentoBaseUrl = rtrim($_SESSION['store_base_url'], '/');
$oauthVerifier = $_SESSION['oauth_verifier'];
define('MAGENTO_BASE_URL', $magentoBaseUrl);
$credentials = new \OAuth\Common\Consumer\Credentials($consumerKey, $consumerSecret, $magentoBaseUrl);
$oAuthClient = new BookAppOauthClient($credentials);
$requestToken = $oAuthClient->requestRequestToken();
$accessToken = $oAuthClient->requestAccessToken(
$requestToken->getRequestToken(),
$oauthVerifier,
$requestToken->getRequestTokenSecret()
);
header('Location: '. $callback);
为了简化,我们在这里没有进行真正的用户登录检查。我们可能在 OAuth 相关调用之上添加了一个,然后在允许使用 OAuth 之前对用户进行用户名和密码的验证。然而,出于简化原因,我们从我们的示例 OAuth 客户端应用中省略了这部分。
在 check-login.php 文件中,我们可以看到,基于之前存储的会话参数,我们执行以下操作:
-
通过传递存储在会话中的
oauth_consumer_key、oauth_consumer_secret和store_base_url来实例化\OAuth\Common\Consumer\Credentials对象 -
实例化
BookAppOauthClient对象,将其构造函数传递整个凭据对象 -
使用
OauthClient对象获取请求令牌 -
使用请求令牌获取长期访问令牌
如果一切执行成功,弹出窗口将关闭,我们将被重定向回集成列表。现在的不同之处在于,查看网格时,我们有一个活动状态,旁边有一个重新授权链接,如下面的截图所示:

我们真正想要在这个阶段的是访问令牌和访问令牌密钥。如果我们编辑 External Book App 集成,我们可以看到这些值。这些值现在应该出现在以下截图所示的集成详细信息选项卡上:

访问令牌是我们所有后续 API 调键的密钥,并且我们成功地完成了基于 OAuth 的身份验证部分。
基于 OAuth 的 Web API 调用
一旦我们获得了 OAuth 访问令牌,从前面的步骤中,我们可以开始对其他方法进行 Web API 调用。尽管 Web API 覆盖范围对 REST 和 SOAP 都相同,但在进行方法调用时存在显著差异。
为了提供一个更健壮的示例,我们将针对客户组 save 方法,部分定义在 vendor/magento/module-customer/etc/webapi.xml 文件中,如下所示:
<route url="/V1/customerGroups" method="POST">
<service class="Magento\Customer\Api\GroupRepositoryInterface" method="save"/>
<resources>
<resource ref="Magento_Customer::group"/>
</resources>
</route>
要使用访问令牌进行 Web API 调用,例如 POST /V1/customerGroups,我们需要在调用中包含这些请求参数在授权请求头中:
-
oauth_consumer_key,从 Magento 管理区域获取,在集成编辑屏幕下。 -
oauth_nonce,随机值,应用程序为每个请求唯一生成。 -
oauth_signature_method,用于签名请求的签名方法名称。有效值有:HMAC-SHA1、RSA-SHA1和PLAINTEXT。 -
尽管 OAuth 协议支持
PLAINTEXT,但 Magento 不支持。我们将使用HMAC-SHA1。 -
oauth_timestamp,整数值,Unix-like 时间戳。 -
oauth_token,从 Magento 管理区域获取,在集成编辑屏幕下。 -
oauth_version,Magento 支持 Oauth 1.0a,因此我们使用1.0。 -
oauth_signature,生成的签名值,在签名生成过程中省略。
要为 HTTP 请求生成 OAuth 1.0a HMAC-SHA1 签名需要集中精力,如果手动完成。
我们需要确定请求的 HTTP 方法和 URL,它等于 POST http://magento2-merchant.loc/rest/V1/customerGroups。在这里使用正确的协议非常重要,所以请确保 URL 的 https:// 或 http:// 部分与实际发送到 API 的请求相匹配。
我们然后收集请求中包含的所有参数。这些附加参数有两个位置:URL(作为查询字符串的一部分)和请求体。
在 HTTP 请求中,参数被 URL 编码,但我们需要收集原始值。除了请求参数外,每个 oauth_* 参数都需要包含在签名中,除了 oauth_signature 本身之外。
参数按照以下方式归一化为单个字符串:
-
参数按名称排序,使用字典序字节值排序。如果两个或多个参数具有相同的名称,则按其值排序。
-
参数按排序顺序连接成一个字符串。对于每个参数,名称与相应的值由一个
=字符(ASCII 码 61)分隔,即使值是空的。每个名称-值对由一个&字符(ASCII 码 38)分隔。
此外,我们将签名密钥定义为 {消费者密钥}+{&}+{访问令牌密钥} 的值。
一旦我们将字符串归一化规则应用于参数并确定签名密钥,我们就调用 hash_hmac('sha1', $data, {签名密钥}, true) 来获取最终的 oauth_signature 值。
这应该会得到一个随机的 28 个字符长的字符串作为 oauth_signature,类似于这个 – Pi/mGfA0SOlIxO9W30sEch6bjGE=。
理解如何生成签名字符串很重要,但每次都正确地获取它既繁琐又耗时。我们可以通过实例化内置的 \OAuth\Common\Consumer\Credentials 和 \OAuth\OAuth1\Signature\Signature 类的对象来帮助自己,如下(部分)所示:
$credentials = new \OAuth\Common\Consumer\Credentials($consumerKey, $consumerSecret, $magentoBaseUrl);
$signature = new \OAuth\OAuth1\Signature\Signature($credentials);
$signature->setTokenSecret($accessTokenSecret);
$signature->setHashingAlgorithm('HMAC-SHA1');
echo $signature->getSignature($uri, array(
'oauth_consumer_key' => $consumerKey,
'oauth_nonce' => 'per-request-unique-token',
'oauth_signature_method' => 'HMAC-SHA1',
'oauth_timestamp' => '1437319569',
'oauth_token' => $accessToken,
'oauth_version' => '1.0',
), 'POST');
现在我们有了 oauth_signature 值,我们就准备好在我们的控制台 curl REST 示例中操作了。这归结为在控制台上运行以下命令:
curl -X POST http://magento2.ce/rest/V1/customerGroups
-H 'Content-Type: application/json'
-H 'Authorization: OAuth
oauth_consumer_key="vw2xi6kaq0o3f7ay60owdpg2f8nt66g6",
oauth_nonce="per-request-token-by-app-1",
oauth_signature_method="HMAC-SHA1",
oauth_timestamp="1437319569",
oauth_token="cney3fmk9p5282bm1khb83q846l7dner",
oauth_version="1.0",
oauth_signature="Pi/mGfA0SOlIxO9W30sEch6bjGE="'
-d '{"group": {"code": "The Book Writer", "tax_class_id": "3"}}'
注意,前面的命令只是从视觉上换行。它应该在控制台上是一行。一旦执行,API 调用将创建一个新的客户组,名为 The Book Writer。观察 curl 命令时,可能会有人问,为什么我们没有对通过 -d 标志开关传递的 JSON POST 数据进行归一化。这是因为如果内容类型为 application/x-www-form-urlencoded,则只有 HTTP POST 请求体中的参数才被考虑用于签名生成。
控制台 cURL SOAP 请求不需要使用 OAuth 签名。我们可以通过将 Authorization: Bearer {访问令牌值} 传递到请求头中执行 SOAP 请求,如下所示:
curl -X POST http://magento2.ce/index.php/soap/default?services= customerGroupRepositoryV1 -H 'Content-Type: application/soap+xml; charset=utf-8; action="customerGroupRepositoryV1Save"' -H 'Authorization: Bearer cney3fmk9p5282bm1khb83q846l7dner' -d @request.xml
其中 request.xml 包含以下内容:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:customerGroupRepositoryV1SaveRequest>
<group>
<code>The Book Writer</code>
<taxClassId>3</taxClassId>
</group>
</ns1:customerGroupRepositoryV1SaveRequest>
</env:Body>
</env:Envelope>
以下代码示例演示了针对客户组 save 方法调用的 PHP cURL SOAP 类似请求:
$request = new SoapClient(
'http://magento2.ce/index.php/soap/?wsdl&services= customerGroupRepositoryV1',
array(
'soap_version' => SOAP_1_2,
'stream_context' => stream_context_create(array(
'http' => array(
'header' => 'Authorization: Bearer cney3fmk9p5282bm1khb83q846l7dner')
)
)
)
);
$response = $request->customerGroupRepositoryV1Save(array(
'group' => array(
'code' => 'The Book Writer',
'taxClassId' => 3
)
));
注意方法名 customerGroupRepositoryV1Save 实际上由服务名 customerGroupRepositoryV1 和服务内部实际方法的 Save 名称组成。
我们可以通过在浏览器中打开类似 http://magento2.ce/soap/default?wsdl_list 的 URL 来获取所有定义的服务列表(取决于我们的 Magento 安装)。
基于会话认证的实践操作
基于会话的认证是 Magento 中第三种也是最简单的一种认证方式。在这里我们没有 token 传递的复杂性。作为客户,我们使用客户凭证登录到 Magento 店面。作为管理员,我们使用管理员凭证登录到 Magento 后台。Magento 使用名为PHPSESSID的 cookie 来跟踪存储我们的登录状态的会话。Web API 框架使用我们登录的会话信息来验证我们的身份并授权访问请求的资源。
客户可以访问在webapi.xml配置文件中配置了匿名或自授权的资源,如GET /rest/V1/customers/me。
如果我们在浏览器中尝试打开http://magento2.ce/rest/V1/customers/me URL,但没有以客户身份登录,我们会得到以下响应:
<response>
<message>Consumer is not authorized to access %resources</message>
<parameters>
<resources>self</resources>
</parameters>
</response>
如果我们以客户身份登录然后尝试打开相同的 URL,我们会得到以下响应:
<response>
<id>2</id>
<group_id>1</group_id>
<created_at>2015-11-22 14:15:33</created_at>
<created_in>Default Store View</created_in>
<email>john@change.me</email>
<firstname>John</firstname>
<lastname>Doe</lastname>
<store_id>1</store_id>
<website_id>1</website_id>
<addresses/>
<disable_auto_group_change>0</disable_auto_group_change>
</response>
管理员用户可以访问分配给他们的 Magento 管理员配置文件的资源。
创建自定义 Web API
Magento 附带了一系列我们可以调用的 API 方法。然而,有时这还不够,因为我们的业务需求需要额外的逻辑,我们需要能够向 Web API 添加我们自己的方法。
创建我们自己的 API 的最佳部分是,我们不必担心它们是 REST 还是 SOAP。Magento 抽象化这一点,使得我们的 API 方法自动对 REST 和 SOAP 调用可用。
在概念上,添加新的 API 涉及到两个方面:通过各种类定义业务逻辑,并通过webapi.xml文件公开它。然而,正如我们很快就会看到的,这有很多样板代码。
让我们创建一个名为Foggyline_Slider的微型模块,我们将演示创建(POST)、更新(PUT)、删除(DELETE)和列表(GET)方法调用。
创建一个模块注册文件,app/code/Foggyline/Slider/registration.php,内容(部分)如下:
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Foggyline_Slider',
__DIR__
);
创建一个模块配置文件,app/code/Foggyline/Slider/etc/module.xml,内容如下:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Module /etc/module.xsd">
<module name="Foggyline_Slider" setup_version="1.0.0"/>
</config>
创建一个安装脚本,我们的未来模型将持久化模块数据。我们通过创建app/code/Foggyline/Slider/Setup/InstallSchema.php文件来实现,内容(部分)如下:
namespace Foggyline\Slider\Setup;
use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
class InstallSchema implements InstallSchemaInterface
{
public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
{
$installer = $setup;
$installer->startSetup();
/**
* Create table 'foggyline_slider_slide'
*/
$table = $installer->getConnection()
->newTable($installer- >getTable('foggyline_slider_slide'))
->addColumn(
'slide_id',
\Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
null,
['identity' => true, 'unsigned' => true, 'nullable' => false, 'primary' => true],
'Slide Id'
)
->addColumn(
'title',
\Magento\Framework\DB\Ddl\Table::TYPE_TEXT,
200,
[],
'Title'
)
->setComment('Foggyline Slider Slide');
$installer->getConnection()->createTable($table);
...
$installer->endSetup();
}
}
现在我们指定我们资源的 ACL。我们的资源是我们对我们模块实体执行的 CRUD 操作。我们将以slide和image作为独立实体来结构化我们的模块,其中一张幻灯片可以与多个图像实体相关联。因此,我们希望能够分别控制每个实体的保存和删除操作的访问权限。我们通过定义app/code/Foggyline/Slider/etc/acl.xml文件如下来实现:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/ acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="Magento_Backend::content">
<resource id= "Magento_Backend::content_elements">
<resource id="Foggyline_Slider::slider" title="Slider" sortOrder="10">
<resource id="Foggyline_Slider::slide" title="Slider Slide" sortOrder="10">
<resource id= "Foggyline_Slider::slide_save" title="Save Slide" sortOrder="10" />
<resource id="Foggyline_Slider:: slide_delete" title="Delete Slide" sortOrder="20" />
</resource>
<resource id="Foggyline_Slider::image" title="Slider Image" sortOrder="10">
<resource id= "Foggyline_Slider::image_save" title="Save Image" sortOrder="10" />
<resource id= "Foggyline_Slider::image_delete" title="Delete Image" sortOrder="20" />
</resource>
</resource>
</resource>
</resource>
</resource>
</resources>
</acl>
</config>
现在 ACL 已经设置好了,我们在app/code/Foggyline/Slider/etc/webapi.xml文件(部分)中定义我们的 Web API 资源,如下所示:
<routes xsi:noNamespaceSchemaLocation= "urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/foggylineSliderSlide/:slideId" method="GET">
<service class="Foggyline\Slider\Api\ SlideRepositoryInterface" method="getById" />
<resources>
<resource ref="Foggyline_Slider::slide" />
</resources>
</route>
<route url="/V1/foggylineSliderSlide/search" method="GET">
<service class="Foggyline\Slider\Api\ SlideRepositoryInterface" method="getList" />
<resources>
<resource ref="anonymous" />
</resources>
</route>
<route url="/V1/foggylineSliderSlide" method="POST">
<service class="Foggyline\Slider\Api\ SlideRepositoryInterface" method="save" />
<resources>
<resource ref="Foggyline_Slider::slide_save" />
</resources>
</route>
<route url="/V1/foggylineSliderSlide/:id" method="PUT">
<service class="Foggyline\Slider\Api\ SlideRepositoryInterface" method="save" />
<resources>
<resource ref="Foggyline_Slider::slide_save" />
</resources>
</route>
<route url="/V1/foggylineSliderSlide/:slideId" method="DELETE">
<service class="Foggyline\Slider\Api\ SlideRepositoryInterface" method="deleteById" />
<resources>
<resource ref="Foggyline_Slider::slide_delete" />
</resources>
</route>
<route url="/V1/foggylineSliderImage/:imageId" method="GET">
<service class="Foggyline\Slider\Api\ ImageRepositoryInterface" method="getById" />
<resources>
<resource ref="Foggyline_Slider::image" />
</resources>
</route>
<route url="/V1/foggylineSliderImage/search" method="GET">
<service class="Foggyline\Slider\Api\ ImageRepositoryInterface" method="getList" />
<resources>
<resource ref="Foggyline_Slider::image" />
</resources>
</route>
<route url="/V1/foggylineSliderImage" method="POST">
<service class="Foggyline\Slider\Api\ ImageRepositoryInterface" method="save" />
<resources>
<resource ref="Foggyline_Slider::image_save" />
</resources>
</route>
<route url="/V1/foggylineSliderImage/:id" method="PUT">
<service class="Foggyline\Slider\Api\ ImageRepositoryInterface" method="save" />
<resources>
<resource ref="Foggyline_Slider::image_save" />
</resources>
</route>
<route url="/V1/foggylineSliderImage/:imageId" method="DELETE">
<service class="Foggyline\Slider\Api\ ImageRepositoryInterface" method="deleteById" />
<resources>
<resource ref="Foggyline_Slider::image_delete" />
</resources>
</route>
</routes>
注意到每个服务类属性都指向接口,而不是类。这是我们构建可公开服务的方式,总是有一个接口定义在它们后面。正如我们很快就会看到的,使用di.xml,这并不意味着 Magento 会直接从这些接口创建对象。
现在我们创建app/code/Foggyline/Slider/etc/di.xml文件,内容(部分)如下:
<config xsi:noNamespaceSchemaLocation= "urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="Foggyline\Slider\Api\Data\SlideInterface" type="Foggyline\Slider\Model\Slide"/>
<preference for="Foggyline\Slider\Api\ SlideRepositoryInterface" type= "Foggyline\Slider\Model\SlideRepository"/>
...
</config>
这里发生的事情是我们告诉 Magento 类似于,“嘿,每当你需要传递一个符合Foggyline\Slider\Api\Data\SlideInterface接口的实例时,最好使用Foggyline\Slider\Model\Slide类。”
到目前为止,我们还没有实际创建任何这些接口或模型类。在创建 API 时,我们应该首先从定义接口开始,然后我们的模型应该从这些接口扩展。
接口Foggyline\Slider\Api\Data\SlideInterface定义在app/code/Foggyline/Slider/Api/Data/SlideInterface.php文件中(部分)如下:
namespace Foggyline\Slider\Api\Data;
/**
* @api
*/
interface SlideInterface
{
const PROPERTY_ID = 'slide_id';
const PROPERTY_SLIDE_ID = 'slide_id';
const PROPERTY_TITLE = 'title';
/**
* Get Slide entity 'slide_id' property value
* @return int|null
*/
public function getId();
/**
* Set Slide entity 'slide_id' property value
* @param int $id
* @return $this
*/
public function setId($id);
/**
* Get Slide entity 'slide_id' property value
* @return int|null
*/
public function getSlideId();
/**
* Set Slide entity 'slide_id' property value
* @param int $slideId
* @return $this
*/
public function setSlideId($slideId);
/**
* Get Slide entity 'title' property value
* @return string|null
*/
public function getTitle();
/**
* Set Slide entity 'title' property value
* @param string $title
* @return $this
*/
public function setTitle($title);
}
我们正在追求极致的简化。我们的Slide实体实际上只有 ID 和标题值。id和slide_id指向数据库中的同一字段,它们的 getter 和 setter 的实现应该产生相同的结果。
虽然API/Data/*.php接口成为我们的数据模型的设计蓝图要求,但我们也有Api/*RepositoryInterface.php文件。这里的想法是将创建、更新、删除、搜索和类似的数据处理逻辑从数据模型类提取出来,放入它自己的类中。这样,我们的模型类就变成了更纯粹的数据和业务逻辑类,而其余的持久化和搜索相关逻辑则移动到这些存储库类中。
我们的幻灯片存储接口定义在app/code/Foggyline/Slider/Api/SlideRepositoryInterface.php文件中,如下所示:
namespace Foggyline\Slider\Api;
/**
* @api
*/
interface SlideRepositoryInterface
{
/**
* Retrieve slide entity.
* @param int $slideId
* @return \Foggyline\Slider\Api\Data\SlideInterface
* @throws \Magento\Framework\Exception\NoSuchEntityException If slide with the specified ID does not exist.
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function getById($slideId);
/**
* Save slide.
* @param \Foggyline\Slider\Api\Data\SlideInterface $slide
* @return \Foggyline\Slider\Api\Data\SlideInterface
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function save(\Foggyline\Slider\Api\Data\SlideInterface $slide);
/**
* Retrieve slides matching the specified criteria.
* @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria
* @return \Magento\Framework\Api\SearchResultsInterface
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria);
/**
* Delete slide by ID.
* @param int $slideId
* @return bool true on success
* @throws \Magento\Framework\Exception\NoSuchEntityException
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function deleteById($slideId);
}
在接口就位后,我们可以继续到模型类。为了在数据库中持久化和获取数据,我们的Slide实体在Model目录下确实需要三个文件。这些被称为数据模型、资源类和集合类。
数据模型类定义在app/code/Foggyline/Slider/Model/Slide.php文件中(部分)如下:
namespace Foggyline\Slider\Model;
class Slide extends \Magento\Framework\Model\AbstractModel
implements \Foggyline\Slider\Api\Data\SlideInterface
{
/**
* Initialize Foggyline Slide Model
*
* @return void
*/
protected function _construct()
{
/* _init($resourceModel) */
$this->_init ('Foggyline\Slider\Model\ResourceModel\Slide');
}
/**
* Get Slide entity 'slide_id' property value
*
* @api
* @return int|null
*/
public function getId()
{
return $this->getData(self::PROPERTY_ID);
}
/**
* Set Slide entity 'slide_id' property value
*
* @api
* @param int $id
* @return $this
*/
public function setId($id)
{
$this->setData(self::PROPERTY_ID, $id);
return $this;
}
/**
* Get Slide entity 'slide_id' property value
*
* @api
* @return int|null
*/
public function getSlideId()
{
return $this->getData(self::PROPERTY_SLIDE_ID);
}
/**
* Set Slide entity 'slide_id' property value
*
* @api
* @param int $slideId
* @return $this
*/
public function setSlideId($slideId)
{
$this->setData(self::PROPERTY_SLIDE_ID, $slideId);
return $this;
}
/**
* Get Slide entity 'title' property value
*
* @api
* @return string|null
*/
public function getTitle()
{
return $this->getData(self::PROPERTY_TITLE);
}
/**
* Set Slide entity 'title' property value
*
* @api
* @param string $title
* @return $this
*/
public function setTitle($title)
{
$this->setData(self::PROPERTY_TITLE, $title);
}
}
接着是模型资源类,定义在app/code/Foggyline/Slider/Model/ResourceModel/Slide.php文件中(部分)如下:
namespace Foggyline\Slider\Model\ResourceModel;
/**
* Foggyline Slide resource
*/
class Slide extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb
{
/**
* Define main table
*
* @return void
*/
protected function _construct()
{
/* _init($mainTable, $idFieldName) */
$this->_init('foggyline_slider_slide', 'slide_id');
}
}
最后,第三部分是模型集合类,定义在app/code/Foggyline/Slider/Model/ResourceModel/Slide/Collection.php文件中,如下所示:
namespace Foggyline\Slider\Model\ResourceModel\Slide;
/**
* Foggyline slides collection
*/
class Collection extends \Magento\Framework\Model\ResourceModel\Db\Collection\ AbstractCollection
{
/**
* Define resource model and model
*
* @return void
*/
protected function _construct()
{
/* _init($model, $resourceModel) */
$this->_init('Foggyline\Slider\Model\Slide', 'Foggyline\Slider\Model\ResourceModel\Slide');
}
}
如果我们现在手动实例化模型数据类,我们就能在数据库中持久化数据。为了完成di.xml的要求,我们仍然缺少一个最后的成分——Model/SlideRepository类文件。
让我们创建app/code/Foggyline/Slider/Model/SlideRepository.php文件,内容(部分)如下:
namespace Foggyline\Slider\Model;
use Magento\Framework\Api\DataObjectHelper;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Reflection\DataObjectProcessor;
class SlideRepository implements \Foggyline\Slider\Api\SlideRepositoryInterface
{
/**
* @var \Foggyline\Slider\Model\ResourceModel\Slide
*/
protected $resource;
/**
* @var \Foggyline\Slider\Model\SlideFactory
*/
protected $slideFactory;
/**
* @var \Foggyline\Slider\Model\ResourceModel\Slide\ CollectionFactory
*/
protected $slideCollectionFactory;
/**
* @var \Magento\Framework\Api\SearchResultsInterface
*/
protected $searchResultsFactory;
/**
* @var \Magento\Framework\Api\DataObjectHelper
*/
protected $dataObjectHelper;
/**
* @var \Magento\Framework\Reflection\DataObjectProcessor
*/
protected $dataObjectProcessor;
/**
* @var \Foggyline\Slider\Api\Data\SlideInterfaceFactory
*/
protected $dataSlideFactory;
/**
* @param ResourceModel\Slide $resource
* @param SlideFactory $slideFactory
* @param ResourceModel\Slide\CollectionFactory $slideCollectionFactory
* @param \Magento\Framework\Api\SearchResultsInterface $searchResultsFactory
* @param DataObjectHelper $dataObjectHelper
* @param DataObjectProcessor $dataObjectProcessor
* @param \Foggyline\Slider\Api\Data\SlideInterfaceFactory $dataSlideFactory
*/
public function __construct(
\Foggyline\Slider\Model\ResourceModel\Slide $resource,
\Foggyline\Slider\Model\SlideFactory $slideFactory,
\Foggyline\Slider\Model\ResourceModel\Slide\ CollectionFactory $slideCollectionFactory,
\Magento\Framework\Api\SearchResultsInterface $searchResultsFactory,
\Magento\Framework\Api\DataObjectHelper $dataObjectHelper,
\Magento\Framework\Reflection\DataObjectProcessor $dataObjectProcessor,
\Foggyline\Slider\Api\Data\SlideInterfaceFactory $dataSlideFactory
)
{
$this->resource = $resource;
$this->slideFactory = $slideFactory;
$this->slideCollectionFactory = $slideCollectionFactory;
$this->searchResultsFactory = $searchResultsFactory;
$this->dataObjectHelper = $dataObjectHelper;
$this->dataObjectProcessor = $dataObjectProcessor;
$this->dataSlideFactory = $dataSlideFactory;
}
...
}
可能看起来这里有很多事情要做,但实际上我们只是在构造函数中传递一些类和接口名称,以便实例化我们将用于 webapi.xml 文件中定义的各个服务方法的对象。
我们列表中的第一个服务方法是 getById,在 SlideRepository.php 中定义如下:
/**
* Retrieve slide entity.
*
* @api
* @param int $slideId
* @return \Foggyline\Slider\Api\Data\SlideInterface
* @throws \Magento\Framework\Exception\NoSuchEntityException If slide with the specified ID does not exist.
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function getById($slideId)
{
$slide = $this->slideFactory->create();
$this->resource->load($slide, $slideId);
if (!$slide->getId()) {
throw new NoSuchEntityException(__('Slide with id %1 does not exist.', $slideId));
}
return $slide;
}
然后我们有 save 方法,在 SlideRepository.php 中定义如下:
/**
* Save slide.
*
* @param \Foggyline\Slider\Api\Data\SlideInterface $slide
* @return \Foggyline\Slider\Api\Data\SlideInterface
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function save(\Foggyline\Slider\Api\Data\SlideInterface $slide)
{
try {
$this->resource->save($slide);
} catch (\Exception $exception) {
throw new CouldNotSaveException(__($exception- >getMessage()));
}
return $slide;
}
save 方法处理了 webapi.xml 中定义的 POST 和 PUT 请求,因此有效地处理了新幻灯片的创建或现有幻灯片的更新。
进一步来说,我们有 getList 方法,在 SlideRepository.php 中定义如下:
/**
* Retrieve slides matching the specified criteria.
*
* @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria
* @return \Magento\Framework\Api\SearchResultsInterface
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria)
{
$this->searchResultsFactory->setSearchCriteria ($searchCriteria);
$collection = $this->slideCollectionFactory->create();
foreach ($searchCriteria->getFilterGroups() as $filterGroup) {
foreach ($filterGroup->getFilters() as $filter) {
$condition = $filter->getConditionType() ?: 'eq';
$collection->addFieldToFilter($filter->getField(), [$condition => $filter->getValue()]);
}
}
$this->searchResultsFactory->setTotalCount($collection-> getSize());
$sortOrders = $searchCriteria->getSortOrders();
if ($sortOrders) {
foreach ($sortOrders as $sortOrder) {
$collection->addOrder(
$sortOrder->getField(),
(strtoupper($sortOrder->getDirection()) === 'ASC') ? 'ASC' : 'DESC'
);
}
}
$collection->setCurPage($searchCriteria->getCurrentPage());
$collection->setPageSize($searchCriteria->getPageSize());
$slides = [];
/** @var \Foggyline\Slider\Model\Slide $slideModel */
foreach ($collection as $slideModel) {
$slideData = $this->dataSlideFactory->create();
$this->dataObjectHelper->populateWithArray(
$slideData,
$slideModel->getData(),
'\Foggyline\Slider\Api\Data\SlideInterface'
);
$slides[] = $this->dataObjectProcessor-> buildOutputDataArray(
$slideData,
'\Foggyline\Slider\Api\Data\SlideInterface'
);
}
$this->searchResultsFactory->setItems($slides);
return $this->searchResultsFactory;
}
最后,我们有 deleteById 方法,在 SlideRepository.php 中定义如下:
/**
* Delete Slide
*
* @param \Foggyline\Slider\Api\Data\SlideInterface $slide
* @return bool
* @throws CouldNotDeleteException
*/
public function delete(\Foggyline\Slider\Api\Data\SlideInterface $slide)
{
try {
$this->resource->delete($slide);
} catch (\Exception $exception) {
throw new CouldNotDeleteException(__($exception-> getMessage()));
}
return true;
}
/**
* Delete slide by ID.
*
* @param int $slideId
* @return bool true on success
* @throws \Magento\Framework\Exception\NoSuchEntityException
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function deleteById($slideId)
{
return $this->delete($this->getById($slideId));
}
请记住,我们只在前面的部分代码示例中涵盖了 Slide 实体,这对于进一步进行 API 调用示例已经足够。
API 调用示例
由于我们定义的所有 API 都受资源保护,我们首先需要以管理员用户身份进行身份验证,假设管理员用户可以访问我们定义的所有自定义资源,包括我们定义的资源。为了简化,我们将使用基于令牌的认证方法,例如,本章前面给出的示例。一旦认证成功,我们应该有一个 32 个随机字符长的令牌,例如 pk8h93nq9cevaw55bohkjbp0o7kpl4d3。
一旦获取了令牌密钥,我们将使用控制台 cURL、PHP cURL、PHP SoapClient 以及控制台 SOAP 风格 cURL 示例来测试以下 API 调用:
-
GET /V1/foggylineSliderSlide/:slideId, 调用getById服务方法,需要Foggyline_Slider::slide资源 -
GET /V1/foggylineSliderSlide/search, 调用getList服务方法,需要Foggyline_Slider::slide资源 -
POST /V1/foggylineSliderSlide, 调用save服务方法,需要Foggyline_Slider::slide_save资源 -
PUT /V1/foggylineSliderSlide/:id, 调用save服务方法,需要Foggyline_Slider::slide_save资源 -
DELETE /V1/foggylineSliderSlide/:slideId, 调用deleteById服务方法,需要Foggyline_Slider::slide_delete资源
getById 服务方法调用示例
执行 GET /V1/foggylineSliderSlide/:slideId 的控制台 cURL 风格如下:
curl -X GET -H 'Content-type: application/json' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
http://magento2.ce/rest/V1/foggylineSliderSlide/1
执行 GET /V1/foggylineSliderSlide/:slideId 的 PHP cURL 风格如下:
$ch = curl_init('http://magento2.ce/rest/V1/foggylineSliderSlide/1');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3'
));
$result = curl_exec($ch);
控制台和 PHP cURL 风格的响应应该是一个类似于以下 JSON 字符串:
{"slide_id":1,"title":"Awesome stuff #1"}
执行 GET /V1/foggylineSliderSlide/:slideId 的 PHP SoapClient 风格如下:
$request = new SoapClient(
'http://magento2.ce/index.php/soap/? wsdl&services=foggylineSliderSlideRepositoryV1',
array(
'soap_version' => SOAP_1_2,
'stream_context' => stream_context_create(array(
'http' => array(
'header' => 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3')
)
)
)
);
$response = $request-> foggylineSliderSlideRepositoryV1GetById(array('slideId'=>1));
PHP SoapClient 风格的响应应该是如下 stdClass PHP 对象:
object(stdClass)#2 (1) {
["result"]=>
object(stdClass)#3 (2) {
["slideId"]=>
int(1)
["title"]=>
string(16) "Awesome stuff #1"
}
}
执行 GET /V1/foggylineSliderSlide/:slideId 的控制台 SOAP 风格 cURL 如下:
curl -X POST \
-H 'Content-Type: application/soap+xml; charset=utf-8; action="foggylineSliderSlideRepositoryV1GetById"' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
-d @request.xml \
http://magento2.ce/index.php/soap/default?services=foggyline SliderSlideRepositoryV1
其中 request.xml 的内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1GetByIdRequest>
<slideId>1</slideId>
</ns1:foggylineSliderSlideRepositoryV1GetByIdRequest>
</env:Body>
</env:Envelope>
注意我们实际上并没有做 GET 操作,而是做了 POST 类型的请求。此外,我们指向的 POST 请求的 URL 并非与之前的请求相同。这是因为 Magento SOAP 请求始终是 POST(或 PUT)类型,因为数据是以 XML 格式提交的。返回的 XML 格式指定了服务,而请求头中的操作指定了要在服务上调用的方法。
控制台 SOAP 风格 cURL 的响应应该是一个如下所示的 XML:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1GetByIdResponse>
<result>
<slideId>1</slideId>
<title>Awesome stuff #1</title>
</result>
</ns1:foggylineSliderSlideRepositoryV1GetByIdResponse>
</env:Body>
</env:Envelope>
获取列表服务方法调用示例
执行 GET /V1/foggylineSliderSlide/search 的控制台 cURL 风格如下:
curl -X GET -H 'Content-type: application/json' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
"http://magento2.ce/rest/V1/foggylineSliderSlide/search?search_criteria%5Bfilter_groups%5D%5B0%5D%5Bfilters%5D%5B0%5D%5Bfield%5D=title&search_criteria%5Bfilter_groups%5D%5B0%5D%5Bfilters%5D%5B0%5D%5Bvalue%5D=%25some%25&search_criteria%5Bfilter_groups%5D%5B0%5D%5Bfilters%5D%5B0%5D%5Bcondition_type%5D=like&search_criteria%5Bcurrent_page%5D=1&search_criteria%5Bpage_size%5D=10&search_criteria%5Bsort_orders%5D%5B0%5D%5Bfield%5D=slide_id&search_criteria%5Bsort_orders%5D%5B0%5D%5Bdirection%5D=ASC"
执行 GET /V1/foggylineSliderSlide/search 的 PHP cURL 风格如下:
$searchCriteriaJSON = '{
"search_criteria": {
"filter_groups": [
{
"filters": [
{
"field": "title",
"value": "%some%",
"condition_type": "like"
}
]
}
],
"current_page": 1,
"page_size": 10,
"sort_orders": [
{
"field": "slide_id",
"direction": "ASC"
}
]
}
}';
$searchCriteriaQueryString = http_build_query(json_decode($searchCriteriaJSON));
$ch = curl_init('http://magento2.ce/rest/V1/foggylineSliderSlide/ search?' . $searchCriteriaQueryString);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3'
));
$result = curl_exec($ch);
控制台和 PHP cURL 风格的响应应该是一个类似于以下 JSON 字符串:
{"items":[{"slide_id":2,"title":"Just some other slider"},{"slide_id":1,"title":"Awesome stuff #1"}], "search_criteria":{"filter_groups":[{"filters": [{"field":"title","value":"%some%","condition_type":"like"}]}], "sort_orders":[{"field":"slide_id","direction":"- 1"}],"page_size":10,"current_page":1},"total_count":2}
执行 GET /V1/foggylineSliderSlide/search 的 PHP SoapClient 风格如下:
$searchCriteria = [
'searchCriteria' =>
[
'filterGroups' =>
[
[
'filters' =>
[
[
'field' => 'title',
'value' => '%some%',
'condition_type' => 'like',
],
],
],
],
'currentPage' => 1,
'pageSize' => 10,
'sort_orders' =>
[
[
'field' => 'slide_id',
'direction' =>'ASC',
],
],
],
];
$request = new SoapClient(
'http://magento2.ce/index.php/soap/?wsdl&services= foggylineSliderSlideRepositoryV1',
array(
'soap_version' => SOAP_1_2,
'trace'=>1,
'stream_context' => stream_context_create(array(
'http' => array(
'header' => 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3')
)
)
)
);
$response = $request-> foggylineSliderSlideRepositoryV1GetList($searchCriteria);
PHP SoapClient 风格的响应应该是如下所示的 stdClass PHP 对象:
object(stdClass)#2 (1) {
["result"]=>
object(stdClass)#3 (3) {
["items"]=>
object(stdClass)#4 (0) {
}
["searchCriteria"]=>
object(stdClass)#5 (3) {
["filterGroups"]=>
object(stdClass)#6 (1) {
["item"]=>
object(stdClass)#7 (1) {
["filters"]=>
object(stdClass)#8 (1) {
["item"]=>
object(stdClass)#9 (2) {
["field"]=>
string(5) "title"
["value"]=>
string(6) "%some%"
}
}
}
}
["pageSize"]=>
int(10)
["currentPage"]=>
int(1)
}
["totalCount"]=>
int(0)
}
}
执行 GET /V1/foggylineSliderSlide/search 的控制台 SOAP 风格 cURL 如下:
curl -X POST \
-H 'Content-Type: application/soap+xml; charset=utf-8; action="foggylineSliderSlideRepositoryV1GetList"' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
-d @request.xml \
http://magento2.ce/index.php/soap/default?services=foggyline SliderSlideRepositoryV1
其中 request.xml 的内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1GetListRequest>
<searchCriteria>
<filterGroups>
<item>
<filters>
<item>
<field>title</field>
<value>%some%</value>
</item>
</filters>
</item>
</filterGroups>
<pageSize>10</pageSize>
<currentPage>1</currentPage>
</searchCriteria>
</ns1:foggylineSliderSlideRepositoryV1GetListRequest>
</env:Body>
</env:Envelope>
注意我们实际上并没有做 GET 操作,而是做了 POST。此外,我们指向的 POST 请求的 URL 并非与之前的请求相同。这是因为 Magento SOAP 请求始终是 POST 类型,因为数据是以 XML 格式提交的。返回的 XML 格式指定了服务,而请求头中的操作指定了要在服务上调用的方法。
控制台 SOAP 风格 cURL 的响应应该是一个如下所示的 XML:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1GetListResponse>
<result>
<items/>
<searchCriteria>
<filterGroups>
<item>
<filters>
<item>
<field>title</field>
<value>%some%</value>
</item>
</filters>
</item>
</filterGroups>
<pageSize>10</pageSize>
<currentPage>1</currentPage>
</searchCriteria>
<totalCount>0</totalCount>
</result>
</ns1:foggylineSliderSlideRepositoryV1GetListResponse>
</env:Body>
</env:Envelope>
保存(作为新文件)服务方法调用示例
执行 POST /V1/foggylineSliderSlide 的控制台 cURL 风格如下:
curl -X POST -H 'Content-type: application/json' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
-d '{"slide": {"title": "API test"}}' \
http://magento2.ce/rest/V1/foggylineSliderSlide/
执行 POST /V1/foggylineSliderSlide 的 PHP cURL 风格如下:
$slide = json_encode(['slide'=>['title'=> 'API test']]);
$ch = curl_init('http://magento2.ce/rest/V1/foggylineSliderSlide');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $slide);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Content-Length: ' . strlen($slide),
'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3'
));
$result = curl_exec($ch);
控制台和 PHP cURL 风格的响应应该是一个类似于以下 JSON 字符串:
{"slide_id":4,"title":"API test"}
执行 POST /V1/foggylineSliderSlide 的 PHP SoapClient 风格如下:
$slide = ['slide'=>['title'=> 'API test']];
$request = new SoapClient(
'http://magento2.ce/index.php/soap/?wsdl&services= foggylineSliderSlideRepositoryV1',
array(
'soap_version' => SOAP_1_2,
'trace'=>1,
'stream_context' => stream_context_create(array(
'http' => array(
'header' => 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3')
)
)
)
);
$response = $request-> foggylineSliderSlideRepositoryV1Save($slide);
PHP SoapClient 风格的响应应该是如下所示的 stdClass PHP 对象:
object(stdClass)#2 (1) {
["result"]=>
object(stdClass)#3 (2) {
["slideId"]=>
int(6)
["title"]=>
string(8) "API test"
}
}
执行 POST /V1/foggylineSliderSlide 的控制台 SOAP 风格 cURL 如下:
curl -X POST \
-H 'Content-Type: application/soap+xml; charset=utf-8; action="foggylineSliderSlideRepositoryV1Save"' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
-d @request.xml \
http://magento2.ce/index.php/soap/default?services=foggyline SliderSlideRepositoryV1
其中 request.xml 的内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1SaveRequest>
<slide>
<title>API test</title>
</slide>
</ns1:foggylineSliderSlideRepositoryV1SaveRequest>
</env:Body>
</env:Envelope>
控制台 SOAP 风格 cURL 的响应应该是一个如下所示的 XML:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1SaveResponse>
<result>
<slideId>8</slideId>
<title>API test</title>
</result>
</ns1:foggylineSliderSlideRepositoryV1SaveResponse>
</env:Body>
</env:Envelope>
保存(作为更新)服务方法调用示例
执行 PUT /V1/foggylineSliderSlide/:id 的控制台 cURL 风格如下:
curl -X PUT -H 'Content-type: application/json' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
-d '{"slide": {"slide_id": 2, "title": "API update test"}}' \
http://magento2.ce/rest/V1/foggylineSliderSlide/2
执行 PUT /V1/foggylineSliderSlide/:id 的 PHP cURL 风格如下:
$slideId = 2;
$slide = json_encode(['slide'=>['slide_id'=> $slideId, 'title'=> 'API update test']]);
$ch = curl_init('http://magento2.ce/rest/V1/foggylineSliderSlide/' . $slideId);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $slide);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Content-Length: ' . strlen($slide),
'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3'
));
$result = curl_exec($ch);
控制台和 PHP cURL 风格的响应应该是一个类似于以下 JSON 字符串:
{"id":2,"slide_id":2,"title":"API update test"}
执行 PUT /V1/foggylineSliderSlide/:id 的 PHP SoapClient 风格如下:
$slideId = 2;
$slide = ['slide'=>['slideId'=> $slideId, 'title'=> 'API update test']];
$request = new SoapClient(
'http://magento2.ce/index.php/soap/?wsdl&services= foggylineSliderSlideRepositoryV1',
array(
'soap_version' => SOAP_1_2,
'trace'=>1,
'stream_context' => stream_context_create(array(
'http' => array(
'header' => 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3')
)
)
)
);
$response = $request-> foggylineSliderSlideRepositoryV1Save($slide);
PHP SoapClient 风格的响应应该是如下所示的 stdClass PHP 对象:
object(stdClass)#2 (1) {
["result"]=>
object(stdClass)#3 (2) {
["slideId"]=>
int(2)
["title"]=>
string(15) "API update test"
}
}
执行 PUT /V1/foggylineSliderSlide/:id 的控制台 SOAP 风格 cURL 如下:
curl -X PUT \
-H 'Content-Type: application/soap+xml; charset=utf-8; action="foggylineSliderSlideRepositoryV1Save"' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
-d @request.xml \
http://magento2.ce/index.php/soap/default?services= foggylineSliderSlideRepositoryV1
其中 request.xml 的内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1SaveRequest>
<slide>
<slideId>2</slideId>
<title>API update test</title>
</slide>
</ns1:foggylineSliderSlideRepositoryV1SaveRequest>
</env:Body>
</env:Envelope>
控制台 SOAP 风格 cURL 的响应应该是一个如下所示的 XML:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1SaveResponse>
<result>
<slideId>2</slideId>
<title>API update test</title>
</result>
</ns1:foggylineSliderSlideRepositoryV1SaveResponse>
</env:Body>
</env:Envelope>
deleteById 服务方法调用示例
执行 DELETE /V1/foggylineSliderSlide/:slideId 的控制台 cURL 风格如下:
curl -X DELETE -H 'Content-type: application/json' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
http://magento2.ce/rest/V1/foggylineSliderSlide/3
执行 DELETE /V1/foggylineSliderSlide/:slideId 的 PHP cURL 风格如下:
$slideId = 4;
$ch = curl_init('http://magento2.ce/rest/V1/foggylineSliderSlide/' . $slideId);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3'
));
$result = curl_exec($ch);
控制台和 PHP cURL 风格的响应应该是一个类似于以下 JSON 字符串的字符串:
true
执行 DELETE /V1/foggylineSliderSlide/:slideId 的 PHP SoapClient 风格如下:
$slideId = 2;
$request = new SoapClient(
'http://magento2.ce/index.php/soap/?wsdl&services= foggylineSliderSlideRepositoryV1',
array(
'soap_version' => SOAP_1_2,
'trace'=>1,
'stream_context' => stream_context_create(array(
'http' => array(
'header' => 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3')
)
)
)
);
$response = $request-> foggylineSliderSlideRepositoryV1DeleteById(array('slideId'=> $slideId));
PHP SoapClient 风格的响应应该是类似于以下 stdClass PHP 对象:
object(stdClass)#2 (1) {
["result"]=>
bool(true)
}
执行 DELETE /V1/foggylineSliderSlide/:slideId 的控制台 SOAP 风格 cURL 如下:
curl -X POST \
-H 'Content-Type: application/soap+xml; charset=utf-8; action="foggylineSliderSlideRepositoryV1DeleteById"' \
-H 'Authorization: Bearer pk8h93nq9cevaw55bohkjbp0o7kpl4d3' \
-d @request.xml \
http://magento2.ce/index.php/soap/default?services= foggylineSliderSlideRepositoryV1
其中 request.xml 的内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1DeleteByIdRequest>
<slideId>5</slideId>
</ns1:foggylineSliderSlideRepositoryV1DeleteByIdRequest>
</env:Body>
</env:Envelope>
控制台 SOAP 风格 cURL 的响应应该是一个类似于以下 XML 的字符串:
<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope >
<env:Body>
<ns1:foggylineSliderSlideRepositoryV1DeleteByIdResponse>
<result>true</result>
</ns1:foggylineSliderSlideRepositoryV1DeleteByIdResponse>
</env:Body>
</env:Envelope>
上述 API 调用示例涵盖了我们对 Slide 实体自定义定义的所有 API。
回顾一下 $searchCriteria 变量,我们使用了 GET 类型的 HTTP 方法,将整个变量作为查询字符串传递。如果我们这样考虑,我们可以在 Web API 资源定义期间指定 POST,并将 $searchCriteria 变量的内容打包到请求体中。尽管 GET 方法的方法可能看起来有点脏,但想象一下如果我们为资源分配了匿名或自角色:我们就可以简单地打开一个长 URL 并获取搜索结果。考虑一个可能的小部件用途,其中小部件会简单地向 URL 发送 AJAX 请求并获取访客或客户的搜索结果。
完整的模块源代码可以在以下位置找到:github.com/ajzele/B05032-Foggyline_Slider。除了 Slide 实体外,完整的模块代码还包括 Image 实体。由于每个幻灯片可以包含多个图片,我们可以进一步测试类似于前面调用的 Image API 调用。
列表过滤的搜索条件接口
了解如何进行适当的列表过滤以获取匹配特定查找条件的实体对于有效使用核心 Magento 的 getList 服务以及可能的自定义编码 API 是至关重要的。例如,获取过去 24 小时内注册的客户列表以获取最新添加的产品。
让我们回顾一下 app/code/Foggyline/Slider/etc/webapi.xml 文件,其中我们定义了服务 method="getList"。服务类定义为 Foggyline\Slider\Api\SlideRepositoryInterface,它被定义为 Foggyline\Slider\Model\SlideRepository 类的偏好。最后,在 SlideRepository 类中,我们有实际的 getList 方法。getList 方法定义如下:
getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria);
我们可以看到,getList 方法只接受一个参数,即符合 SearchCriteriaInterface 的对象实例,称为 $searchCriteria。
这意味着我们已经有以下类型的(不完整)JSON 对象来传递给 getList 方法:
{
"search_criteria": {
}
}
为了进一步理解search_criteria的内部工作原理,我们需要了解SearchCriteriaInterface,它部分定义如下:
interface SearchCriteriaInterface
{
/* @param \Magento\Framework\Api\Search\FilterGroup[] $filterGroups */
public function setFilterGroups(array $filterGroups = null);
/* @param \Magento\Framework\Api\SortOrder[] $sortOrders */
public function setSortOrders(array $sortOrders = null);
/* @param int $pageSize */
public function setPageSize($pageSize);
/* @param int $currentPage */
public function setCurrentPage($currentPage);
}
每个接口的获取器和设置器方法都期望在传递的 API 参数中找到值。这意味着getPageSize()和setPageSize()方法会期望search_criteria有一个整型page_size属性。同样,getFilterGroups()和setFilterGroups()方法会期望传递给它们的search_criteria有一个\Magento\Framework\Api\Search\FilterGroup数组。这些见解使我们达到了传递给getList方法的以下(不完整)JSON 对象类型:
{
"search_criteria": {
"filter_groups": [
],
"current_page": 1,
"page_size": 10,
"sort_orders": [
]
}
}
现在我们已经到了需要确定filter_groups和sort_orders包含什么内容的时候了,因为这些不是简单类型,而是复合值。
进一步查看\Magento\Framework\Api\Search\FilterGroup,我们看到getFilters()和setFilters()方法的定义,这些方法与\Magento\Framework\Api\Filter对象数组一起工作。这意味着filter_groups有一个属性过滤器,它是一个由\Magento\Framework\Api\Filter定义的单独过滤器对象的数组。考虑到这一点,我们现在来看search_criteria JSON 对象的以下形式:
{
"search_criteria": {
"filter_groups": [
{
"filters": [
]
}
],
"current_page": 1,
"page_size": 10,
"sort_orders": [
]
}
}
进一步查看单个\Magento\Framework\Api\Filter,通过其获取器和设置器定义,我们可以得出field、value和condition_type等属性。这使我们进一步接近最终确定我们的search_criteria JSON 对象,现在它的结构如下:
{
"search_criteria": {
"filter_groups": [
{
"filters": [
{
"field": "title",
"value": "%some%",
"condition_type": "like"
}
]
}
],
"current_page": 1,
"page_size": 10,
"sort_orders": [
]
}
}
让我们来看看最后的sort_orders。sort_orders是\Magento\Framework\Api\SortOrder类型,它为字段和方向属性提供了获取器和设置器。了解这一点后,我们能够完全构建我们的search_criteria JSON 对象(或数组),这是我们传递给getList()服务方法调用的,如下所示:
{
"search_criteria": {
"filter_groups": [
{
"filters": [
{
"field": "title",
"value": "%some%",
"condition_type": "like"
}
]
}
],
"current_page": 1,
"page_size": 10,
"sort_orders": [
{
"field": "slide_id",
"direction": -1
}
]
}
}
当我们在filter_groups、filters或sort_orders下定义多个条目时会发生什么?逻辑预期是,当它们到达数据库时,这些会分解成AND和OR操作符。令人惊讶的是,这并不总是情况,至少在我们的先前列表中不是这样。由于getList方法的实际实现留给我们处理,我们可以决定我们想要如何处理过滤器组和过滤器。
回顾我们的getList方法,如以下部分所示,我们没有做任何暗示使用OR操作符的事情,所以所有内容最终都在数据库上以AND条件结束:
foreach ($searchCriteria->getFilterGroups() as $filterGroup) {
foreach ($filterGroup->getFilters() as $filter) {
$condition = $filter->getConditionType() ?: 'eq';
$collection->addFieldToFilter($filter->getField(), [$condition => $filter->getValue()]);
}
}
之前的代码只是简单地遍历所有过滤器组,将组内的所有过滤器拉入,并对所有内容调用相同的 addFieldToFilter 方法。类似的行为在核心 Magento 模块中得到了实现。尽管过滤本身遵循 \Magento\Framework\Api\SearchCriteriaInterface 接口,但在 Magento 范围内没有统一的处理方法来强制执行过滤中的 AND 和 OR 操作符。
然而,像 GET 产品这样的 Magento 核心 API 确实实现了 AND 和 OR 条件。在这些情况下,过滤器组导致 OR 条件,而组内的过滤器导致 AND 条件。
小贴士
遵循最佳实践,我们应该确保我们的模块在实现搜索条件时,尊重 filter_groups/filters 和 OR/AND 的关系。
摘要
在本章中,我们涵盖了与 Magento API 相关的许多内容。还有很多话要说,但这里概述的步骤应该足以让我们开始使用更高级的 API。我们以了解用户类型和支持的认证方法开始本章。我们特别强调了进行多种类型的 API 调用,如控制台 cURL、PHP cURL、PHP SoapClient 和控制台 cURL SOAP。这是为了鼓励开发者更深入地理解 API 调用的内部工作原理,而不仅仅是使用高级库。
在下一章中,我们将探讨 Magento 的一些主要部分。
第十章。主要功能区域
Magento 平台包含各种模块,提供各种功能。开发者通常更熟悉某一组功能而不是其他功能。一些最常用的功能示例包括与 CMS 块和页面、分类、产品、客户、导入、自定义产品类型、自定义支付和运输模块相关的功能。这并不是说其他功能就不重要。在本章中,我们将快速查看 Magento 管理区域、PHP 代码和 API 调用中的功能。本章分为以下部分:
-
CMS 管理
-
目录管理
-
客户管理
-
产品和客户导入
-
自定义产品类型
-
自定义离线运输方法
-
自定义离线支付方法
目的不是深入了解每个功能区域,而是展示管理界面以及相应的程序性和 API 方法,以实现基本管理。
CMS 管理
内容是帮助区分一个商店与另一个商店的因素。优质内容可以提高商店在搜索引擎中的可见性,为购买产品的客户提供信息洞察,并提供信誉和信任。Magento 提供了一个强大的内容管理系统,可用于为商店创建丰富内容。我们还可以用它来管理块和页面。
手动管理块
一个 CMS 块是内容的一个小型模块化单元,可以在页面的几乎任何位置定位。它们甚至可以被调用到另一个块中。块支持 HTML 和 JavaScript 作为其内容。因此,它们能够显示静态信息,如文本、图像和嵌入的视频,以及动态信息。
块可以通过管理界面、API 或代码创建。
以下步骤概述了从管理界面内创建块的过程:
-
登录到 Magento 管理区域。
-
在内容 | 元素 | 块菜单中,点击添加新块。这将打开一个类似于以下截图的屏幕:
![手动管理块]()
-
填写所需字段的值(块标题、标识符、商店视图、状态和内容)并点击保存块按钮。
保存块后,您将在浏览器中看到您已保存块的成功消息。CMS 块存储在数据库中的cms_block和cms_block_store表中。
标识符值可能是这里最有趣的部分。我们可以在 CMS 页面、另一个 CMS 块或某些代码中使用它来获取我们刚刚创建的块。
假设我们已经创建了一个具有标识符值为foggyline_hello的块,我们可以通过以下表达式在 CMS 页面或另一个块中调用它:
{{widget type="Magento\\Cms\\Block\\Widget\\Block" template="widget/static_block/default.phtml" block_id="foggyline_hello"}}
我们也可以将块的实际整数 ID 值传递给前面的表达式,如下所示:
{{widget type="Magento\\Cms\\Block\\Widget\\Block" template="widget/static_block/default.phtml" block_id="2"}}
然而,这种方法要求我们知道块的实际整数 ID。
前面的表达式表明,块通过小部件(也称为前端应用)包含在页面或另一个块中。Magento\Cms\Block\Widget\Block类类型的小部件正在使用widget/static_block/default.phtml模板文件来渲染实际的 CMS 块。
通过代码管理块
除了通过管理界面手动创建块之外,我们还可以使用代码创建 CMS 块,如下面的代码片段所示:
$model = $this->_objectManager->create('Magento\Cms\Model\Block');
$model->setTitle('Test block');
$model->setIdentifier('test_block');
$model->setContent('Test block!');
$model->setIsActive(true);
$model->save();
在这里,我们使用了实例管理器来创建Magento\Cms\Model\Block类的新模型实例。然后,我们通过定义的方法设置了一些属性,最后调用了save方法。
我们可以使用类似于以下代码的代码片段加载和更新现有块:
$model = $this->_objectManager->create('Magento\Cms\Model\Block');
//$model->load(3);
$model->load('test_block');
$model->setTitle('Updated Test block');
$model->setStores([0]);
$model->save();
块的load方法接受一个整数块 ID 或一个字符串块标识符。
最后,我们可以通过可用的 API 方法来管理块创建和更新的操作。以下代码片段显示了如何通过控制台 cURL REST API 调用创建 CMS 块:
curl -X POST "http://magento2.ce/index.php/rest/V1/cmsBlock" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8" \
-d '{"block": {"identifier": "test_api_block", "title": "Test API Block", "content": "API Block Content"}}'
携带者字符串只是一个登录令牌,我们通过首先运行前面章节中描述的认证 API 调用来获取。一旦我们有了认证令牌,我们就可以发送一个V1/cmsBlock POST请求,传递一个 JSON 对象作为数据。
通过 API 管理块
我们可以通过执行类似于以下代码的代码片段通过 API 获取新创建的 CMS 块:
curl -X GET "http://magento2.ce/index.php/rest/V1/cmsBlock/4" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8"
我们可以通过使用 API 并执行类似于以下代码的代码片段来更新现有的 CMS 块:
curl -X PUT "http://magento2.ce/index.php/rest/V1/cmsBlock/4" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8" \
-d '{"block": {"title": "Updated Test API Block"}}'
在这里,我们使用了 HTTP PUT 方法,并将整数4作为V1/cmsBlock/4 URL 的一部分传递。数字 4 代表数据库中块的 ID 值。
手动管理页面
CMS 页面是健壮的内容单元,与简单地嵌入到某些页面中的 CMS 块不同。CMS 页面可以有自己的 URL。CMS 页面的例子包括404 未找到、主页、启用 Cookies和隐私和 Cookies 政策。在处理 CMS 页面时,我们的想法是我们可以控制页面内容区域,而不会影响网站范围的元素,如页眉、页脚或侧边栏。除了前面列出的之外,Magento 并没有提供很多开箱即用的 CMS 页面。
与块一样,页面也可以通过管理界面、API 或代码创建。
以下步骤概述了从管理界面内部创建页面的过程:
-
登录到 Magento 管理区域。
-
在内容 | 元素 | 页面菜单中,点击添加新页面。这将打开一个与以下截图类似的屏幕:
![手动管理页面]()
-
填写所需字段的值(页面标题、商店视图、状态和内容)并点击保存块按钮。
页面保存后,你将在浏览器中看到你已保存此页面的成功消息。CMS 页面存储在数据库中的 cms_page 和 cms_page_store 表中。
假设我们已经创建了一个页面标题值为信息的页面,我们可以通过类似 http://magento2.ce/info 的 URL 在浏览器中访问此页面。尽管我们可能需要在新页面编辑屏幕中指定URL 键值,但 Magento 会自动分配与页面标题匹配的URL 键。
通过代码管理页面
除了通过管理界面手动创建之外,我们还可以通过以下代码片段创建 CMS 页面:
$model = $this->_objectManager->create('Magento\Cms\Model\Page');
$model->setTitle('Test page');
$model->setIdentifier('test-page');
$model->setPageLayout('1column');
$model->setContent('Test page!');
$model->setIsActive(true);
$model->setStores([0]);
$model->save();
在这里,我们使用了实例管理器来创建 Magento\Cms\Model\Page 类的新模型实例。然后,我们通过定义的方法设置了一些属性,并最终调用了 save 方法。通过管理界面设置的URL 键实际上是通过 setIdentifier 方法调用设置的标识符。
通过 API 管理页面
我们可以使用类似于以下代码段的代码片段来加载和更新现有的页面:
$model = $this->_objectManager->create('Magento\Cms\Model\Page');
//$model->load(6);
$model->load('test-page');
$model->setContent('Updated Test page!');
$model->save();
页面模型 load 方法接受页面标识符(URL 键)的整数 ID 值。
最后,我们可以通过可用的 API 方法来管理页面的创建和更新。以下代码片段显示了如何通过控制台 cURL REST API 调用来创建 CMS 页面:
curl -X POST "http://magento2.ce/index.php/rest/V1/cmsPage" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8" \
-d '{"page": {"identifier": "test-api-page", "title": "Test API Page", "content": "API Block Content"}}'
similar to the following one:
curl -X GET "http://magento2.ce/index.php/rest/V1/cmsPage/7" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8"
我们可以通过执行类似于以下代码段的代码来通过 API 更新现有的 CMS 页面:
curl -X PUT "http://magento2.ce/index.php/rest/V1/cmsPage/7" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8" \
-d '{"page": {"content": "Updated Test API Page", "identifier":"updated-page"}}'
在这里,我们使用了 HTTP PUT 方法,将整数 7 作为 V1/cmsPage/7 URL 的一部分传递。数字 7 代表数据库中页面的 ID 值。
目录管理
Magento_Catalog 模块是整个 Magento 平台的骨架之一。它为各种产品类型的库存管理提供强大的支持。该模块负责管理产品、类别及其属性、前端显示以及许多其他事情。
手动管理类别
我们可以通过导航到产品 | 库存 | 目录或产品 | 库存 | 类别来访问 Magento 管理区域内的目录功能。
如果我们从空白的 Magento 安装开始,我们可能会首先创建类别作为要创建的第一个实体之一。我们可以通过以下步骤手动创建类别:
-
登录到 Magento 管理区域。
-
前往产品 | 库存 | 类别菜单。这将打开一个类似于以下截图的屏幕:
![手动管理类别]()
-
在屏幕的左侧点击默认类别。然后,当页面重新加载时,点击添加子类别按钮。
-
虽然看起来好像没有发生任何变化,因为屏幕内容没有改变,但现在我们应该在一般信息选项卡中填写所需的选项,将名称设置为某个字符串值,并将是否激活设置为是。
-
最后,点击保存类别按钮。
新类别现在应该已创建。在左侧屏幕区域,如果您点击新创建的类别的名称,您将在一般信息选项卡上方看到其 ID 值,如图所示:

注意
知道类别 ID 后,您可以直接在浏览器中打开一个类似于http://magento2.ce/index.php/catalog/category/view/id/3的 URL 来测试它,其中数字3是类别的 ID。您将看到一个加载的类别页面,可能显示找不到与选择匹配的产品的消息,这是好的,因为我们还没有将产品分配给类别。
虽然我们不会深入探讨其细节,但值得注意的是,我们在这里只是触及了表面,因为类别使我们能够通过显示设置、自定义设计选项卡提供许多额外的选项。
由于类别是 EAV 实体,它们的数据存储在数据库的多个表中,如下所示:
-
catalog_category_entity -
catalog_category_entity_datetime -
catalog_category_entity_decimal -
catalog_category_entity_int -
catalog_category_entity_text -
catalog_category_entity_varchar
有几个额外的表将类别链接到产品:
-
catalog_category_product -
catalog_category_product_index -
catalog_category_product_index_tmp -
catalog_url_rewrite_product_category
通过代码管理类别
除了通过管理界面手动创建之外,我们还可以通过以下代码片段所示的方式通过代码创建类别:
$parentId = \Magento\Catalog\Model\Category::TREE_ROOT_ID;
$parentCategory = $this->_objectManager
->create('Magento\Catalog\Model\Category')
->load($parentId);
$category = $this->_objectManager
->create('Magento\Catalog\Model\Category');
$category->setPath($parentCategory->getPath());
$category->setParentId($parentId);
$category->setName('Test');
$category->setIsActive(true);
$category->save();
这里特别的是,在创建新类别时,我们首先创建了一个$parentCategory实例,它代表根类别对象。我们使用Category模型的TREE_ROOT_ID常量作为父类别 ID 的 ID 值。然后,我们创建了一个类别实例,设置了其path、parent_id、name和is_active值。
通过 API 管理类别
我们可以通过可用的 API 方法进一步管理类别创建。以下代码片段显示了通过控制台 cURL REST API 调用创建类别:
curl -X POST "http://magento2.ce/index.php/rest/V1/categories" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8" \
-d '{"category": {"parent_id": "1", "name": "Test API Category", "is_active": true}}'
承载字符串只是一个登录令牌,我们通过首先运行上一章中描述的认证 API 调用来获取它。一旦我们有了认证令牌,我们就可以发出一个/V1/categories POST请求,传递一个 JSON 对象作为数据。
我们可以通过执行以下类似代码片段的代码片段,通过 API 获取新创建的类别作为一个 JSON 对象:
curl -X GET "http://magento2.ce/index.php/rest/V1/categories/9" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8"
手动管理产品
现在,让我们看看如何创建一个新的产品。我们可以通过以下步骤手动创建产品:
-
登录到 Magento 管理区域。
-
在产品 | 库存 | 目录菜单中,点击添加产品按钮。这将打开一个类似于以下截图的屏幕:
![手动管理产品]()
-
现在,在产品详情选项卡中填写所需的选项。
-
最后,点击保存按钮。
如果保存成功,页面将重新加载并显示您已保存产品的消息。
与类别一样,我们在这里只是触及了产品的表面。查看其他可用的选项卡,有许多其他选项可以分配给产品。只需分配所需的选项就足以让我们在商店的前端 URL(如http://magento2.ce/index.php/catalog/product/view/id/4)上看到产品,其中数字4是产品的 ID 值。
产品也是 EAV 实体,其数据存储在数据库的多个表中,如下所示:
-
catalog_product_entity -
catalog_product_entity_datetime -
catalog_product_entity_decimal -
catalog_product_entity_gallery -
catalog_product_entity_group_price -
catalog_product_entity_int -
catalog_product_entity_media_gallery -
catalog_product_entity_media_gallery_value -
catalog_product_entity_text -
catalog_product_entity_tier_price -
catalog_product_entity_varchar
还有大量其他引用产品的表,例如catalog_product_bundle_selection,但这些主要用于链接功能片段。
通过代码管理产品
除了通过管理界面手动创建外,我们还可以通过代码创建产品,如下面的代码片段所示:
$catalogConfig = $this->_objectManager
->create('Magento\Catalog\Model\Config');
$attributeSetId = $catalogConfig->getAttributeSetId(4, 'Default');
$product = $this->_objectManager
->create('Magento\Catalog\Model\Product');
$product
->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE)
->setAttributeSetId($attributeSetId)
->setWebsiteIds([$this->storeManager->getWebsite()->getId()])
->setStatus(\Magento\Catalog\Model\Product\Attribute \Source\Status::STATUS_ENABLED)
->setStockData(['is_in_stock' => 1, 'manage_stock' => 0])
->setStoreId(\Magento\Store\Model\Store::DEFAULT_STORE_ID)
->setVisibility(\Magento\Catalog\Model\Product \Visibility::VISIBILITY_BOTH);
$product
->setName('Test API')
->setSku('tets-api')
->setPrice(19.99);
$product->save();
通过 API 管理产品
以下示例使用 REST API 创建一个新的简单产品:
curl -X POST "http://magento2.ce/index.php/rest/V1/products" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8" \
-d '{"product":{"sku":"test_api_1","name":"Test API #1","attribute_set_id":4,"price":19.99,"status":1, "visibility":4,"type_id":"simple","weight":1}}'
应该通过使用身份验证请求预先获取Bearer令牌。响应应该是一个包含所有公开产品数据的 JSON 对象。
我们可以通过执行以下代码片段的 API 获取现有产品信息:
curl -X GET "http://magento2.ce/index.php/rest/V1/products /product_dynamic_125" \
-H "Content-Type:application/json"
在前面的 URL 中,product_dynamic_125部分代表这个特定的产品 SKU 值。响应是一个包含所有公开产品数据的 JSON 对象。
可用的整个目录 API 列表可以在vendor/magento/module-catalog/etc/webapi.xml文件中查看。
客户管理
管理客户是 Magento 平台的重要方面之一。大多数情况下,客户创建是由新客户自己完成的。访问商店的新客户启动注册过程,并最终创建客户账户。一旦注册,客户就可以在我的账户页面上进一步编辑他们的账户详情,该页面通常在类似http://magento2.ce/index.php/customer/account/index/的链接上可用。
作为本节的一部分,我们感兴趣的是通过使用管理区域、代码和 API 来管理客户账户的可能性。
手动管理客户
以下步骤概述了从管理界面内部创建客户账户的过程:
-
登录到 Magento 管理区域。
-
在客户 | 所有客户菜单中,点击添加新客户按钮。这将打开一个类似于以下截图的屏幕:
![手动管理客户]()
-
填写所需字段的值(关联到网站、组、名、姓和电子邮件)并点击保存客户按钮。
一旦客户被保存,你将在浏览器中看到您已保存客户的成功消息。
对于此类情况,关联到网站值可能是最重要的值,在这种情况下,客户账户是由非客户用户间接创建的。
注意
由于 Magento 支持设置多个网站,客户账户可以根据商店 | 设置 | 配置 | 客户 | 客户配置 | 账户共享选项 | 共享客户账户选项设置为全局或按网站。因此,如果共享客户账户选项已设置为按网站,将关联到网站值指向正确的网站至关重要。否则,将创建客户账户,但客户将无法在店面登录。
Magento_Customer模块使用 EAV 结构来存储客户数据。因此,没有单个表存储客户信息。相反,根据客户属性及其数据类型存在多个表。
以下列表包含存储客户实体的表:
-
customer_entity -
customer_entity_datetime -
customer_entity_decimal -
customer_entity_int -
customer_entity_text -
customer_entity_varchar
没有客户地址的客户账户将不会真正完整。地址可以通过在管理区域客户编辑屏幕下的地址选项卡添加,如下面的截图所示:

注意,Magento 允许我们设置其中一个地址为默认发货地址和默认账单地址。
与客户实体类似,客户地址实体也使用 EAV 结构来存储其数据。
以下列表包含存储客户地址实体的表:
-
customer_address_entity -
customer_address_entity_datetime -
customer_address_entity_decimal -
customer_address_entity_int -
customer_address_entity_text -
customer_address_entity_varchar
通过代码管理客户
除了通过管理界面手动创建之外,我们还可以通过以下代码片段创建客户:
$model = $this->_objectManager-> create('Magento\Customer\Model\Customer');
$model->setWebsiteId(1);
$model->setGroupId(1);
$model->setFirstname('John');
$model->setLastname('Doe');
$model->setEmail('john.doe@mail.com');
$model->save();
在这里,我们使用实例管理器来创建Magento\Customer\Model\Customer类的新模型实例。然后我们可以通过定义的方法设置一些属性,并最终调用save方法。
我们可以使用类似于以下代码片段的代码片段来加载和更新现有客户:
$model = $this->_objectManager-> create('Magento\Customer\Model\Customer');
$model->setWebsiteId(1);
//$model->loadByEmail('john.doe@mail.com');
$model->load(1);
$model->setFirstname('Updated John');
$model->save();
我们可以使用load或loadByEmail方法调用。load方法接受现有客户实体的整数 ID 值,而loadByEmail接受一个字符串电子邮件地址。值得注意的是,必须在任何加载方法之前调用setWebsiteId。否则,我们将收到一个错误消息,指出在使用网站范围时必须指定客户网站 ID。
通过 API 管理客户
最后,我们可以使用可用的 API 方法来管理客户信息的创建和更新。以下代码片段显示了如何通过控制台 cURL REST API 调用创建客户:
curl -X POST "http://magento2.ce/index.php/rest/V1/customers" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer r9ok12c3wsusrxqomyxiwo0v7etujw9h" \
-d '{"customer": {"website_id": 1, "group_id": 1, "firstname": "John", "lastname": "Doe", "email": "john.doe@mail.com"}, "password":"abc123"}'
一旦我们有了认证令牌,我们就可以发送一个V1/customers POST请求,传递一个 JSON 对象作为数据。
我们可以通过执行一个类似于以下代码片段的代码,通过 API 获取新创建的客户:
curl -X GET "http://magento2.ce/index.php/rest/V1/customers/24" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8"
我们可以通过执行类似于以下代码片段的代码片段通过 API 更新现有客户:
curl -X PUT "http://magento2.ce/index.php/rest/V1/customers/24" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer r9ok12c3wsusrxqomyxiwo0v7etujw9h" \
-d '{"customer": {"id":24, "website_id": 1, "firstname": "John Updated", "lastname": "Doe", "email": "john2@mail.com"}, "password_hash":"cda57c7995e5f03fe07ad52d99686ba130e0d3e fe0d84dd5ee9fe7f6ea632650:cEf8i1f1ZXT1L2NwawTRNEqDWGyru6h3:1"}'
在这里,我们使用了 HTTP PUT 方法,将整数24作为V1/customers/24的一部分以及作为 URL 的主体部分。数字 24 代表数据库中客户的 ID 值。此外,请注意password_hash值;如果没有它,更新将失败。
通过代码管理客户地址
与客户类似,我们可以使用代码创建客户地址,如下面的代码片段所示:
$model = $this->_objectManager-> create('Magento\Customer\Model\Address');
//$model->setCustomer($customer);
$model->setCustomerId(24);
$model->setFirstname('John');
$model->setLastname('Doe');
$model->setCompany('Foggyline');
$model->setStreet('Test street');
$model->setCity('London');
$model->setCountryId('GB');
$model->setPostcode('GU22 7PY');
$model->setTelephone('112233445566');
$model->setIsDefaultBilling(true);
$model->setIsDefaultShipping(true);
$model->save();
在这里,我们使用了实例管理器创建Magento\Customer\Model\Address类的新模型实例。然后,我们通过定义的方法设置一些属性,并最终调用save方法。
我们可以通过类似于以下代码片段的代码片段加载和更新现有的客户地址:
$model = $this->_objectManager-> create('Magento\Customer\Model\Address');
$model->load(22);
$model->setCity('Update London');
$model->save();
在这里,我们使用了load方法通过其 ID 值加载现有的地址。然后,我们调用setCity方法并传递更新的字符串。在执行save方法后,地址应该反映这一变化。
通过 API 管理客户地址
令人惊讶的是,客户地址不能通过 API 调用直接创建或更新,因为没有定义POST或PUT REST API。然而,我们仍然可以通过以下方式使用 API 获取现有的客户地址信息:
curl -X GET "http://magento2.ce/index.php/rest/V1/customers /addresses/22" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer lcpnsrk4t6al83lymhfs86jabbi9mmt8"
可用的所有客户 API 列表可以在vendor/magento/module-customer/etc/webapi.xml文件中看到。
产品和客户导入
Magento 通过以下模块提供开箱即用的批量导入和导出功能:
-
AdvancedPricingImportExport -
BundleImportExport -
CatalogImportExport -
ConfigurableImportExport -
CustomerImportExport -
GroupedImportExport -
ImportExport -
TaxImportExport
导入功能的核心实际上在于ImportExport模块,而其他模块通过vendor/magento/module-{partialModuleName}-import-export/etc/import.xml和vendor/magento/module-{partialModuleName}-import-export/etc/export.xml文件提供单独的导入和导出实体。
这些功能可以从 Magento 管理区域中的系统 | 数据传输菜单访问。它们使我们能够导出和导入多个实体类型,例如高级定价、产品、主客户文件和客户地址。
以下截图显示了导入设置屏幕的实体类型选项:

在导入设置旁边,当我们为导入选择实体类型时,会出现导入行为部分,如下面的截图所示:

大多数实体类型都有类似的导入行为选项。大多数时候,我们将对添加/更新行为感兴趣。
由于导入过程比导出过程复杂一些,我们将重点关注导入和 CSV 文件格式。更具体地说,我们的重点是产品、主客户文件和客户地址的导入。
当与干净的 Magento 安装一起工作时,以下列在产品导入期间是必需的,以便在之后使产品在店面可见:
-
sku(例如,“test-sku”):这可以具有几乎任何值,只要它在 Magento 中是唯一的。 -
attribute_set_code(例如,“默认”):这可以具有在执行SELECT DISTINCT attribute_set_name FROM eav_attribute_set;查询时在数据库中找到的任何值。 -
product_type(例如,“简单”):这可以具有simple、configurable、grouped、virtual、bundle或downloadable的值。此外,如果我们创建或安装了一个添加新产品类型的第三方模块,我们也可以使用该模块。 -
categories(例如,“根/鞋子”):使用“根类别名称/子类别名称/子子类别名称”语法创建完整的类别路径。如果有多个类别,则使用竖线(“|”)分隔它们。例如,“根类别名称/子类别名称/子子类别名称| 根类别名称/子 _2 类别名称”。 -
product_websites(例如,“基础”):这可以具有在执行SELECT DISTINCT code FROM store_website;查询时在数据库中找到的值。 -
name(例如,“测试”):这可以具有几乎任何值。 -
product_online(例如,1):这可以是1表示“可见”或0表示“不可见”。 -
visibility(例如,“目录,搜索”):这可以具有“单独不可见”、“目录”、“搜索”或“目录,搜索”的值。 -
price(例如,“9.99”):这可以是一个整数或小数值。 -
qty(例如,“100”):这可以是一个整数或小数值。
虽然产品将仅通过包含一组列的前列列表导入,但我们通常希望为它们分配额外的信息,例如描述和图片。我们可以通过以下列来实现:
-
description(例如,“描述”):这可以包含任何字符串值。支持 HTML 和 JavaScript。 -
short_description(例如,“简短描述”):这可以包含任何字符串值。支持 HTML 和 JavaScript。 -
base_image(例如,butterfly.jpg):这是最终的导入图像名称。 -
small_image(例如,galaxy.jpg) -
thumbnail_image(例如,serenity.jpg)
关于图像的导入,只要在导入期间设置了图像文件目录路径,我们只需要提供最终的图像名称。我们可以使用相对路径,例如 Magento 安装的var/export、var/import、var/export/some/dir。
导入完成后,建议通过控制台运行php bin/magento indexer:reindex命令。否则,直到运行索引器之前,产品在店面上将不可见。
重新索引完成后,我们可以尝试打开店面 URL,它看起来像http://magento2.ce/index.php/catalog/product/view/id/1。在这种情况下,数字1是新导入的产品 ID。
当与干净的 Magento 安装一起工作时,在客户主要文件导入期间需要以下列出的列,以便我们的客户之后能够成功登录到店面:
-
email(例如,<john.doe@fake.mail>):作为字符串值的电子邮件地址 -
_website(例如,基础):这可以包含在执行SELECT DISTINCT code FROM store_website;查询时在数据库中找到的任何值 -
firstname(例如,约翰):一个字符串值 -
lastname(例如,多):一个字符串值 -
group_id(例如,1):这可以包含在执行SELECT customer_group_id code FROM customer_group WHERE customer_group_id != 0;查询时在数据库中找到的任何值
尽管客户只需使用之前列出的列集就能登录到店面,但我们通常希望分配其他相关的信息。我们可以通过以下列来实现:
-
gender(例如,男性):这可以是男性或女性 -
taxvat(例如,HR33311122299):任何有效的增值税号,尽管导入将接受无效的增值税号 -
dob(例如,1983-01-16):出生日期 -
prefix(例如,先生):任何字符串值 -
middlename(例如,开发人员):任何字符串值 -
suffix(例如,工程师):任何字符串值 -
password(例如,123abc):任何长度至少为 6 个字符的字符串值,如通过\Magento\CustomerImportExport\Model\Import\Customer::MIN_PASSWORD_LENGTH定义。
我们需要特别注意password列。这是一个明文密码。因此,我们需要小心不要以非安全的方式分发 CSV 文件。理想情况下,我们可以提供password_hash列而不是password。然而,password_hash列下的条目需要通过Magento\Customer\Model\Customer类中的hashPassword方法调用的相同算法进行哈希处理。这进一步在Magento\Framework\Encryption\Encryptor类的一个实例上调用getHash方法,最终解析为md5或sha256算法。
当与干净的 Magento 安装一起工作时,在客户地址导入期间需要以下列,以便我们的客户之后能够成功地在店面使用地址:
-
_website(例如,base):这可以具有在执行SELECT DISTINCT code FROM store_website;查询时在数据库中找到的任何值 -
_email(例如,<john@change.me>):作为字符串值的电子邮件地址 -
_entity_id -
firstname(例如,约翰):任何字符串值 -
lastname(例如,多伊):任何字符串值 -
street(例如,阿什顿巷):任何字符串值 -
city(例如,奥斯汀):任何字符串值 -
telephone(例如,00 385 91 111 000):任何字符串值 -
country_id(例如,GB):ISO-2 格式的国家代码 -
postcode(例如,TX 78753):任何字符串值
尽管客户只需列出的一组列就可以在店面使用地址,但我们通常希望分配其他相关的信息。我们可以通过以下列来实现:
-
region(例如,加利福尼亚):这可以是空白、自由形式的字符串,或者与在执行SELECT DISTINCT default_name FROM directory_country_region;查询时在数据库中找到的任何值匹配的特定字符串。当运行SELECT DISTINCT country_id FROM directory_country_region;时,显示 13 个不同的国家代码,这些代码在directory_country_region表中都有条目——AT、BR、CA、CH、DE、EE、ES、FI、FR、LT、LV、RO、US。这意味着具有该代码的国家需要分配一个适当的地区名称。 -
company(例如,雾线):这可以是任何字符串值。 -
fax(例如,00 385 91 111 000):这可以是任何字符串值。 -
middlename(例如,开发者):这可以是任何字符串值。 -
prefix(例如,先生):这可以是任何字符串值。 -
suffix(例如,工程师):这可以是任何字符串值。 -
vat_id(例如,HR33311122299):这可以是任何有效的增值税号,尽管导入将接受甚至非有效的那些。 -
_address_default_billing_(例如,"1"):这可以是"1"作为是或"0"作为否,以标记地址为默认账单地址。 -
_address_default_shipping_(例如,“1”):这可以是“1”表示是,或者“0”表示否,以标记该地址为默认的配送地址。
虽然 CSV 导入是一种非常好且相对快速的大规模导入产品、客户及其地址的方法,但它也有一些局限性。CSV 只是平面数据。我们无法对其应用任何逻辑。根据数据有多干净和有效,CSV 导入可能做得很好。否则,我们可能需要选择 API。我们需要记住,CSV 导入比产品和服务器的 API 创建要快得多,因为 CSV 导入直接在数据库上批量插入,而 API 则实例化完整的模型,尊重事件观察者等。
定制产品类型
Magento 提供以下六种开箱即用的产品类型:
-
简单产品
-
可配置产品
-
组合产品
-
虚拟产品
-
组合产品
-
可下载的产品
每个产品都有其特定属性。例如,虚拟和可下载的产品没有 weight 属性。因此,它们被排除在标准配送计算之外。通过围绕内置产品类型进行自定义编码,使用观察器和插件,我们可以实现几乎任何功能。然而,有时这还不够,或者没有解决方案来满足需求。在这种情况下,我们可能需要创建自己的产品类型,以便以更简洁的方式满足项目需求。
让我们创建一个名为 Foggyline_DailyDeal 的小型模块,它将为 Magento 添加一个新的产品类型。
首先,创建一个名为 app/code/Foggyline/DailyDeal/registration.php 的模块注册文件,其中包含以下部分内容:
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Foggyline_DailyDeal',
__DIR__
);
然后,创建一个包含以下内容的 app/code/Foggyline/DailyDeal/etc/module.xml 文件:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Module /etc/module.xsd">
<module name="Foggyline_DailyDeal" setup_version="1.0.0">
<sequence>
<module name="Magento_Catalog"/>
</sequence>
</module>
</config>
现在,创建一个包含以下内容的 app/code/Foggyline/DailyDeal/etc/product_types.xml 文件:
<config xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Catalog:etc/product_types.xsd">
<type name="foggylinedailydeal"
label="Daily Deal"
modelInstance="Foggyline\DailyDeal\Model\Product\Type \DailyDeal"
composite="false"
isQty="true"
canUseQtyDecimals="false">
<priceModel instance="Foggyline\DailyDeal\Model \Product\Price"/>
<indexerModel instance="Foggyline\DailyDeal\Model \ResourceModel\Indexer\Price"/>
<stockIndexerModel instance="Foggyline\DailyDeal\Model \ResourceModel\Indexer\Stock"/>
<!-- customAttributes parsed by Magento\Catalog\Model\ProductTypes\Config -->
<customAttributes>
<attribute name="is_real_product" value="true"/>
<attribute name="refundable" value="false"/>
<attribute name="taxable" value="true"/>
</customAttributes>
</type>
</config>
customAttributes 元素由 vendor/magento/module-catalog/Model/ProductTypes/Config.php 解析。
创建一个包含部分内容的 app/code/Foggyline/DailyDeal/Model/Product/Type/DailyDeal.php 文件,如下所示:
namespace Foggyline\DailyDeal\Model\Product\Type;
class DailyDeal extends \Magento\Catalog\Model\Product\Type\AbstractType
{
const TYPE_DAILY_DEAL = 'foggylinedailydeal';
public function deleteTypeSpecificData (\Magento\Catalog\Model\Product $product)
{
// TODO: Implement deleteTypeSpecificData() method.
}
}
现在,创建一个包含部分内容的 app/code/Foggyline/DailyDeal/Model/Product/Price.php 文件,如下所示:
namespace Foggyline\DailyDeal\Model\Product;
class Price extends \Magento\Catalog\Model\Product\Type\Price
{
}
完成此操作后,创建一个包含部分内容的 app/code/Foggyline/DailyDeal/Model/ResourceModel/Indexer/Price.php 文件,如下所示:
namespace Foggyline\DailyDeal\Model\ResourceModel\Indexer;
class Price extends \Magento\Catalog\Model\ResourceModel\Product \Indexer\Price\DefaultPrice
{
}
然后,创建一个包含部分内容的 app/code/Foggyline/DailyDeal/Model/ResourceModel/Indexer/Stock.php 文件,如下所示:
namespace Foggyline\DailyDeal\Model\ResourceModel\Indexer;
class Stock extends \Magento\CatalogInventory\Model\ResourceModel \Indexer\Stock\DefaultStock
{
}
最后,创建一个包含部分内容的 app/code/Foggyline/DailyDeal/Setup/InstallData.php 文件,如下所示:
namespace Foggyline\DailyDeal\Setup;
class InstallData implements \Magento\Framework\Setup\InstallDataInterface
{
private $eavSetupFactory;
public function __construct(\Magento\Eav\Setup\EavSetupFactory $eavSetupFactory)
{
$this->eavSetupFactory = $eavSetupFactory;
}
public function install(
\Magento\Framework\Setup\ModuleDataSetupInterface $setup,
\Magento\Framework\Setup\ModuleContextInterface $context
)
{
// the "foggylinedailydeal" type specifics
}
}
通过在 InstallData 类中添加以下 foggylinedailydeal 类型特定内容来扩展 install 方法:
$eavSetup = $this->eavSetupFactory->create(['setup' => $setup]);
$type = \Foggyline\DailyDeal\Model\Product\Type\ DailyDeal::TYPE_DAILY_DEAL;
$fieldList = [
'price',
'special_price',
'special_from_date',
'special_to_date',
'minimal_price',
'cost',
'tier_price',
'weight',
];
// make these attributes applicable to foggylinedailydeal products
foreach ($fieldList as $field) {
$applyTo = explode(
',',
$eavSetup->getAttribute (\Magento\Catalog\Model\Product::ENTITY, $field, 'apply_to')
);
if (!in_array($type, $applyTo)) {
$applyTo[] = $type;
$eavSetup->updateAttribute(
\Magento\Catalog\Model\Product::ENTITY,
$field,
'apply_to',
implode(',', $applyTo)
);
}
}
现在,从控制台运行 php bin/magento setup:upgrade。
如果你现在在管理区域打开产品 | 库存 | 目录菜单,并单击添加产品按钮旁边的下拉图标,你将在列表中看到Daily Deal产品类型,如下面的截图所示:

在下拉列表中单击Daily Deal产品类型应该会打开产品编辑页面,如下面的截图所示:

自定义产品类型编辑屏幕与内置产品类型之一之间没有明显的区别。
假设我们已经将产品命名为Daily Deal Test Product并保存,我们应该能够在店面中看到它,如下面的截图所示:

如果我们将产品添加到购物车并执行结账,应该会创建一个订单,就像任何其他产品类型一样。在管理区域中,在订单查看页面上,在已订购项目下,我们应该能够在列表中看到该产品,如下面的截图所示:

再次,自定义产品类型与在已订购项目部分下渲染的内置产品类型之间没有明显的区别。
最后,我们应该在控制台上运行php bin/magento indexer:reindex命令。即使我们实际上没有在索引器中实现任何代码,这也只是为了确保现有的索引器没有损坏。
整个模块代码可以从github.com/ajzele/B05032-Foggyline_DailyDeal下载。
自定义离线配送方法
Magento 提供了几种开箱即用的离线配送方式,例如Flatrate、Freeshipping、Pickup和Tablerate。我们可以在vendor/magento/module-offline-shipping/Model/Carrier目录中看到这些。
然而,项目需求通常是这样的,我们需要一个自定义编码的配送方法,其中应用了特殊业务逻辑。因此,我们可以控制配送价格的计算。在这种情况下,了解如何编写我们自己的离线配送方法可能会很有用。
让我们继续创建一个名为Foggyline_Shipbox的小模块,为 Magento 提供额外的离线配送方法。
首先,创建一个名为app/code/Foggyline/Shipbox/registration.php的模块注册文件,内容如下所示:
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Foggyline_Shipbox',
__DIR__
);
然后,创建一个包含以下内容的app/code/Foggyline/Shipbox/etc/module.xml文件:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Module /etc/module.xsd">
<module name="Foggyline_Shipbox" setup_version="1.0.0">
<sequence>
<module name="Magento_OfflineShipping"/>
</sequence>
</module>
</config>
现在,创建一个包含以下内容的app/code/Foggyline/Shipbox/etc/config.xml文件:
<config xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store :etc/config.xsd">
<default>
<carriers>
<shipbox>
<active>0</active>
<sallowspecific>0</sallowspecific>
<model> Foggyline\Shipbox\Model\Carrier\Shipbox</model>
<name>Shipbox</name>
<price>4.99</price>
<title>Foggyline Shipbox</title>
<specificerrmsg>This shipping method is not available. To use this shipping method, please contact us.</specificerrmsg>
</shipbox>
</carriers>
</default>
</config>
完成此操作后,创建一个包含以下内容的app/code/Foggyline/Shipbox/etc/adminhtml/system.xml文件,如下所示:
<config xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Config:etc/system_file.xsd">
<system>
<section id="carriers">
<group id="shipbox" translate="label" type="text" sortOrder="99" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Foggyline Shipbox</label>
<field id="active" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Enabled</label>
<source_model> Magento\Config\Model\Config\Source\Yesno </source_model>
</field>
<field id="name" translate="label" type="text" sortOrder="3" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Method Name</label>
</field>
<field id="price" translate="label" type="text" sortOrder="5" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Price</label>
<validate>validate-number validate-zero-or-greater</validate>
</field>
<field id="title" translate="label" type="text" sortOrder="2" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Title</label>
</field>
<field id="sallowspecific" translate="label" type="select" sortOrder="90" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Ship to Applicable Countries</label>
<frontend_class>shipping-applicable-country </frontend_class>
<source_model> Magento\Shipping\Model\Config\Source \Allspecificcountries </source_model>
</field>
<field id="specificcountry" translate="label" type="multiselect" sortOrder="91" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Ship to Specific Countries</label>
<source_model> Magento\Directory\Model \Config\Source\Country </source_model>
<can_be_empty>1</can_be_empty>
</field>
</group>
</section>
</system>
</config>
现在,创建一个包含以下内容的app/code/Foggyline/Shipbox/Model/Carrier/Shipbox.php文件:
namespace Foggyline\Shipbox\Model\Carrier;
use Magento\Quote\Model\Quote\Address\RateRequest;
class Shipbox extends \Magento\Shipping\Model\Carrier\AbstractCarrier
implements \Magento\Shipping\Model\Carrier\CarrierInterface
{
protected $_code = 'shipbox';
protected $_isFixed = true;
protected $_rateResultFactory;
protected $_rateMethodFactory;
public function __construct(
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
\Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory $rateErrorFactory,
\Psr\Log\LoggerInterface $logger,
\Magento\Shipping\Model\Rate\ResultFactory $rateResultFactory,
\Magento\Quote\Model\Quote\Address\RateResult \MethodFactory $rateMethodFactory,
array $data = []
)
{
$this->_rateResultFactory = $rateResultFactory;
$this->_rateMethodFactory = $rateMethodFactory;
parent::__construct($scopeConfig, $rateErrorFactory, $logger, $data);
}
public function collectRates(RateRequest $request)
{
//implement business logic
}
public function getAllowedMethods()
{
return ['shipbox' => $this->getConfigData('name')];
}
}
在Carrier\Shipbox类中扩展collectRates方法,如下所示:
public function collectRates(RateRequest $request)
{
if (!$this->getConfigFlag('active')) {
return false;
}
//Do some filtering of items in cart
if ($request->getAllItems()) {
foreach ($request->getAllItems() as $item) {
//$item->getQty();
//$item->getFreeShipping()
//$item->isShipSeparately()
//$item->getHasChildren()
//$item->getProduct()->isVirtual()
//...
}
}
//After filtering, start forming final price
//Final price does not have to be fixed like below
$shippingPrice = $this->getConfigData('price');
$result = $this->_rateResultFactory->create();
$method = $this->_rateMethodFactory->create();
$method->setCarrier('shipbox');
$method->setCarrierTitle($this->getConfigData('title'));
$method->setMethod('shipbox');
$method->setMethodTitle($this->getConfigData('name'));
$method->setPrice($shippingPrice);
$method->setCost($shippingPrice);
$result->append($method);
return $result;
}
在 Magento 管理区域,如果你现在查看商店 | 设置 | 配置 | 销售 | 运费方法,你将在列表中看到Foggyline Shipbox,如下截图所示:

将启用选项设置为是,然后点击保存配置按钮。
如果你现在在 MySQL 服务器上运行SELECT * FROM core_config_data WHERE path LIKE "%shipbox%";查询,你将看到如下截图所示的类似结果:

注意,在前面的截图中的代码片段中没有直接与适用于的国家和特定国家选项相关的代码,因为这些选项的处理已经内置在父AbstractCarrier类中。因此,只需在config.xml和system.xml中添加sallowspecific选项,我们就可以启用一个功能,即运费方法可以显示或隐藏在某些国家。
实现的核心在于collectRates方法。这是我们实现自己的业务逻辑的地方,该逻辑应该根据购物车中的商品计算运费。我们可以在collectRates方法中使用$request->getAllItems()来获取所有购物车商品的集合,遍历它们,根据各种条件形成最终的运费,等等。
现在,让我们继续跳转到店面以测试结账流程。我们应该能在结账页面上看到我们的方法,如下截图所示:

如果我们完成一个订单,我们还应该在订单本身上看到运费方法详情。在管理区域中,在销售 | 操作 | 订单下,如果我们查看支付与运费方法部分的查看订单,我们应该能看到运费方法,如下截图所示:

类似地,在订单总额部分,我们应该看到运费及处理费中的运费金额,如下截图所示:

通过这种方式,我们完成了自定义离线运费方法模块。完整的模块可以在github.com/ajzele/B05032-Foggyline_Shipbox找到。
自定义离线支付方法
Magento 提供了一些现成的离线支付方法,例如Banktransfer、Cashondelivery、Checkmo和Purchaseorder。你可以在vendor/magento/module-offline-payments/Model目录中看到它们。
当涉及到支付方式时,更常见的是使用在线支付服务提供商(网关),例如 PayPal 或 Braintree。有时,项目需求可能要求我们可能需要一个自定义编码的支付方式。你需要考虑程序化产品导入和订单创建脚本,这些脚本可能专门针对某些特别标记的支付方式。因此,支付过程将由我们控制。
在这种情况下,了解如何编写我们自己的离线支付方法可能很有用。值得注意的是,虽然我们可以创建一个离线支付,它会抓取用户的信用卡信息,但除非我们的基础设施符合 PCI 标准,否则这样做并不真正建议。
让我们继续创建一个名为 Foggyline_Paybox 的小模块,为 Magento 提供额外的离线支付方式。
首先,创建一个名为 app/code/Foggyline/Paybox/registration.php 的模块注册文件,部分内容如下:
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Foggyline_Paybox',
__DIR__
);
然后,创建一个名为 app/code/Foggyline/Paybox/etc/module.xml 的文件,内容如下:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Module /etc/module.xsd">
<module name="Foggyline_Paybox" setup_version="1.0.0">
<sequence>
<module name="Magento_OfflinePayments"/>
</sequence>
</module>
</config>
完成此操作后,创建一个名为 app/code/Foggyline/Paybox/etc/config.xml 的文件,内容如下:
<config xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Store:etc/config.xsd">
<default>
<payment>
<paybox>
<active>0</active>
<model>Foggyline\Paybox\Model\Paybox</model>
<order_status>pending</order_status>
<title>Foggyline Paybox</title>
<allowspecific>0</allowspecific>
<group>offline</group>
</paybox>
</payment>
</default>
</config>
然后,创建一个名为 app/code/Foggyline/Paybox/etc/payment.xml 的文件,内容如下:
<payment xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Payment:etc/payment.xsd">
<methods>
<method name="paybox">
<allow_multiple_address>1</allow_multiple_address>
</method>
</methods>
</payment>
现在,创建一个名为 app/code/Foggyline/Paybox/etc/adminhtml/system.xml 的文件,内容如下:
<config xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Config:etc/system_file.xsd">
<system>
<section id="payment">
<group id="paybox" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Paybox</label>
<field id="active" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Enabled</label>
<source_model> Magento\Config\Model\Config\Source\Yesno </source_model>
</field>
<field id="order_status" translate="label" type="select" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="0">
<label>New Order Status</label>
<source_model> Magento\Sales\Model\Config \Source\Order\Status\NewStatus </source_model>
</field>
<field id="sort_order" translate="label" type="text" sortOrder="100" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Sort Order</label>
<frontend_class> validate-number</frontend_class>
</field>
<field id="title" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Title</label>
</field>
<field id="allowspecific" translate="label" type="allowspecific" sortOrder="50" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Payment from Applicable Countries </label>
<source_model> Magento\Payment\Model\ Config\Source\Allspecificcountries </source_model>
</field>
<field id="specificcountry" translate="label" type="multiselect" sortOrder="51" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Payment from Specific Countries</label>
<source_model> Magento\Directory\Model \Config\Source\Country </source_model>
<can_be_empty>1</can_be_empty>
</field>
<field id="payable_to" translate="label" sortOrder="61" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Make Check Payable to</label>
</field>
<field id="mailing_address" translate="label" type="textarea" sortOrder="62" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Send Check to</label>
</field>
<field id="min_order_total" translate="label" type="text" sortOrder="98" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Minimum Order Total</label>
</field>
<field id="max_order_total" translate="label" type="text" sortOrder="99" showInDefault="1" showInWebsite="1" showInStore="0">
<label>Maximum Order Total</label>
</field>
<field id="model"></field>
</group>
</section>
</system>
</config>
创建一个名为 app/code/Foggyline/Paybox/etc/frontend/di.xml 的文件,内容如下:
<config xsi:noNamespaceSchemaLocation="urn:magento:framework: ObjectManager/etc/config.xsd">
<type name="Magento\Checkout\Model\CompositeConfigProvider">
<arguments>
<argument name="configProviders" xsi:type="array">
<item name= "offline_payment_paybox_config_provider" xsi:type="object">
Foggyline\Paybox\Model\PayboxConfigProvider
</item>
</argument>
</arguments>
</type>
</config>
完成此操作后,创建一个名为 app/code/Foggyline/Paybox/Model/Paybox.php 的文件,内容如下:
namespace Foggyline\Paybox\Model;
class Paybox extends \Magento\Payment\Model\Method\AbstractMethod
{
const PAYMENT_METHOD_PAYBOX_CODE = 'paybox';
protected $_code = self::PAYMENT_METHOD_PAYBOX_CODE;
protected $_isOffline = true;
public function getPayableTo()
{
return $this->getConfigData('payable_to');
}
public function getMailingAddress()
{
return $this->getConfigData('mailing_address');
}
}
现在,创建一个名为 app/code/Foggyline/Paybox/Model/PayboxConfigProvider.php 的文件,内容如下:
namespace Foggyline\Paybox\Model;
class PayboxConfigProvider implements \Magento\Checkout\Model\ConfigProviderInterface
{
protected $methodCode = \Foggyline\Paybox\Model\Paybox::PAYMENT_METHOD_PAYBOX_CODE;
protected $method;
protected $escaper;
public function __construct(
\Magento\Payment\Helper\Data $paymentHelper
)
{
$this->method = $paymentHelper->getMethodInstance($this-> methodCode);
}
public function getConfig()
{
return $this->method->isAvailable() ? [
'payment' => [
'paybox' => [
'mailingAddress' => $this-> getMailingAddress(),
'payableTo' => $this->getPayableTo(),
],
],
] : [];
}
protected function getMailingAddress()
{
$this->method->getMailingAddress();
}
protected function getPayableTo()
{
return $this->method->getPayableTo();
}
}
将整个 vendor/magento/module-offline-payments/view/frontend/layout/checkout_index_index.xml Magento 核心文件复制到 app/code/Foggyline/Paybox/view/frontend/layout/checkout_index_index.xml 模块中。然后,通过替换整个 <item name="offline-payments" xsi:type="array"> 元素及其子元素,编辑模块的 checkout_index_index.xml 文件,如下所示:
<item name="foggline-offline-payments" xsi:type="array">
<item name="component" xsi:type="string"> Foggyline_Paybox/js/view/payment/foggline-offline-payments </item>
<item name="methods" xsi:type="array">
<item name="paybox" xsi:type="array">
<item name="isBillingAddressRequired" xsi:type="boolean">true</item>
</item>
</item>
</item>
然后,创建一个名为 app/code/Foggyline/Paybox/view/frontend/web/js/view/payment/offline-payments.js 的文件,内容如下:
/*browser:true*/
/*global define*/
define(
[
'uiComponent',
'Magento_Checkout/js/model/payment/renderer-list'
],
function (
Component,
rendererList
) {
'use strict';
rendererList.push(
{
type: 'paybox',
component: 'Foggyline_Paybox/js/view/payment/method- renderer/paybox'
}
);
return Component.extend({});
}
);
完成此操作后,创建一个名为 app/code/Foggyline/Paybox/view/frontend/web/js/view/payment/method-renderer/paybox.js 的文件,内容如下:
/*browser:true*/
/*global define*/
define(
[
'Magento_Checkout/js/view/payment/default'
],
function (Component) {
'use strict';
return Component.extend({
defaults: {
template: 'Foggyline_Paybox/payment/paybox'
},
getMailingAddress: function () {
return window.checkoutConfig.payment. paybox.mailingAddress;
},
getPayableTo: function () {
return window.checkoutConfig.payment. paybox.payableTo;
}
});
}
);
现在,创建一个名为 app/code/Foggyline/Paybox/view/frontend/web/template/payment/paybox.html 的文件,内容如下:
<div class="payment-method" data-bind="css: {'_active': (getCode() == isChecked())}">
<div class="payment-method-title field choice">
<input type="radio"
name="payment[method]"
class="radio"
data-bind="attr: {'id': getCode()}, value: getCode(), checked: isChecked, click: selectPaymentMethod, visible: isRadioButtonVisible()"/>
<label data-bind="attr: {'for': getCode()}" class="label"><span data-bind="text: getTitle()"></span></label>
</div>
<div class="payment-method-content">
<div class="payment-method-billing-address">
<!-- ko foreach: $parent.getRegion(getBillingAddressFormName()) -->
<!-- ko template: getTemplate() --><!-- /ko -->
<!--/ko-->
</div>
<!-- ko if: getMailingAddress() || getPayableTo() -->
<dl class="items check payable">
<!-- ko if: getPayableTo() -->
<dt class="title"><!-- ko i18n: 'Make Check payable toooooo:' --><!-- /ko --></dt>
<dd class="content"><!-- ko i18n: getPayableTo() --> <!-- /ko --></dd>
<!-- /ko -->
<!-- ko if: getMailingAddress() -->
<dt class="title"><!-- ko i18n: 'Send Check toxyz:' -- ><!-- /ko --></dt>
<dd class="content">
<address class="paybox mailing address" data-bind ="html: $t(getMailingAddress())"></address>
</dd>
<!-- /ko -->
</dl>
<!-- /ko -->
<div class="checkout-agreements-block">
<!-- ko foreach: $parent.getRegion('before-place- order') -->
<!-- ko template: getTemplate() --><!-- /ko -->
<!--/ko-->
</div>
<div class="actions-toolbar">
<div class="primary">
<button class="action primary checkout"
type="submit"
data-bind="
click: placeOrder,
attr: {title: $t('Place Order')},
css: {disabled: !isPlaceOrderActionAllowed()},
enable: (getCode() == isChecked())
"
disabled>
<span data-bind="i18n: 'Place Order'"></span>
</button>
</div>
</div>
</div>
</div>
通过这种方式,我们完成了自定义离线支付方法模块。整个模块可以在 github.com/ajzele/B05032-Foggyline_Paybox 找到。
摘要
在本章中,我们讨论了一些开发者最常接触到的功能点。我们学习了在管理区域中查找信息的位置,以及如何编程管理这些功能背后的实体。因此,我们能够有效地手动和编程创建和获取 CMS 页面、块、分类和产品。我们还学习了如何创建产品和客户导入脚本。最后,我们研究了如何创建我们自己的自定义产品类型、简单支付和运输模块。
以下章节将引导我们了解 Magento 内置测试的使用方法,以及如何利用它们有效地进行应用程序的质量保证,以保持其健康状态。
第十一章:测试
软件测试可以定义为开发生命周期中的一个关键步骤。这一步骤常常被许多开发者无声地忽视,因为需要投入一定的时间来为代码库编写一个体面的测试套件。编写测试不仅是一个单一的一次性活动,而是一个随着代码的增长和变化而持续的过程。在任何时候,测试结果都应该验证和确认我们的软件按预期工作,从而满足业务和技术要求。在生命周期的早期阶段,编写测试应该早于编写实际的应用程序代码。这有助于防止在代码中引入缺陷。
在高层次上,我们可以将测试分为以下类别:
-
静态:在测试期间不执行应用程序代码。通过检查应用程序代码文件而不是它们的执行来查找可能的错误。
-
动态:在测试期间执行应用程序代码。在检查应用程序的功能行为时发现可能的错误。
在本章中,我们将探讨 Magento 提供的测试选项。在这个过程中,我们将构建一个包含一些测试功能的基本模块。
测试类型
Magento 提供多种类型的测试。我们可以在运行以下命令时在 Magento 根目录下的控制台看到这些测试的列表:
php bin/magento dev:tests:run –help
命令的结果是一个看起来像这样的输出:
Usage:
dev:tests:run [type]
Arguments:
type Type of test to run. Available types: all, unit, integration, integration-all, static, static-all, integrity, legacy, default (default: "default")
这个输出源自核心Magento_Developer模块中的Console/Command/DevTestsRunCommand.php文件。查看输出,我们可能会说实际上有九种测试类型,如下所示:
-
all -
unit -
integration -
integration-all -
static -
static-all -
integrity -
legacy -
default
然而,这些并非独特的测试类型;这些是组合,正如我们很快就会看到的。
让我们更仔细地查看DevTestsRunCommand类中的代码及其setupTestInfo方法。
setupTestInfo方法定义了内部的commands属性,如下所示:
$this->commands = [
'unit' => ['../tests/unit', ''],
'unit-performance' => ['../tests/performance/ framework/tests/unit', ''],
'unit-static' => ['../tests/static/ framework/tests/unit', ''],
'unit-integration' => ['../tests/integration/ framework/tests/unit', ''],
'integration' => ['../tests/integration', ''],
'integration-integrity' => ['../tests/integration', ' testsuite/Magento/ Test/Integrity'],
'static-default' => ['../tests/static', ''],
'static-legacy' => ['../tests/static', ' testsuite/Magento/Test/Legacy'],
'static-integration-js' => ['../tests/static', ' testsuite/Magento/Test/ Js/Exemplar'],
];
此外,我们还可以在以下方式定义的setupTestInfo方法中看到types属性:
$this->types = [
'all' => array_keys($this->commands),
'unit' => ['unit', 'unit-performance', 'unit- static', 'unit-integration'],
'integration' => ['integration'],
'integration-all' => ['integration', 'integration-integrity'],
'static' => ['static-default'],
'static-all' => ['static-default', 'static-legacy', 'static-integration-js'],
'integrity' => ['static-default', 'static-legacy', 'integration-integrity'],
'legacy' => ['static-legacy'],
'default' => [
'unit',
'unit-performance',
'unit-static',
'unit-integration',
'integration',
'static-default',
],
];
types属性逻辑上将一个或多个测试组合成一个在commands属性下找到的单个名称。我们可以看到unit单一类型如何包含unit、unit-performance、unit-static和unit-integration测试。commands属性指向实际测试库的磁盘位置。相对于 Magento 根安装文件夹,测试可以在dev/tests/目录中找到。
单元测试
单元测试旨在单独测试单个类方法,断言所有可能的组合,并关注应用程序中最小的可测试部分。Magento 使用PHPUnit测试框架进行单元测试。由于高度专注,单元测试在某个测试失败时可以轻松地识别问题的根本原因。
我们可以通过使用以下命令从 Magento 安装根目录特定地触发单元测试:
php bin/magento dev:tests:run unit
一旦触发,Magento 将在 vendor/magento/module-developer/Console/Command/DevTestsRunCommand.php 文件中运行 execute 命令。由于单元类型映射到多个命令,内部发生的情况是 Magento 将目录从一个目录切换到另一个目录,如下所示:
-
dev/tests/unit -
dev/tests/performance/framework/tests/unit -
dev/tests/static/framework/tests/unit -
dev/tests/integration/framework/tests/unit
我们可以说所有这些目录都被认为是单元测试目录。
在这些目录中的每一个,Magento 内部运行 passthru($command, $returnVal) 方法,其中 $command 参数解析为一个类似于以下字符串的字符串:
php /www/magento2/./vendor/phpunit/phpunit/phpunit
然后 PHPUnit 将相应地在每个这些目录中查找 phpunit.xml 配置文件。如果不存在 phpunit.xml,我们需要将 phpunit.xml.dist 的内容复制到 phpunit.xml。
让我们更详细地查看 dev/tests/unit/phpunit.xml 文件中的 testsuite、filter、whitelist 和其他配置元素。
在 dev/tests/unit/phpunit.xml 文件中找到了以下默认的 testsuite 目录列表,它列出了你需要查找以 Test.php 为前缀的 tests 文件所在的目录:
../../../app/code/*/*/Test/Unit
../../../dev/tools/*/*/Test/Unit
../../../dev/tools/*/*/*/Test/Unit
../../../lib/internal/*/*/Test/Unit
../../../lib/internal/*/*/*/Test/Unit
../../../setup/src/*/*/Test/Unit
../../../update/app/code/*/*/Test/Unit
../../../vendor/*/module-*/Test/Unit
../../../vendor/*/framework/Test/Unit
../../../vendor/*/framework/*/Test/Unit
列表相对于 dev/tests/unit/ 目录。例如,如果我们查看前面代码中的第一行,然后查看 Magento_Catalog 模块,很明显 Test 文件位于 app/code/<vendorName>/<moduleName>/Test/ 目录及其子目录下。这些文件夹中所有以 Test.php 结尾的内容都将作为单元测试的一部分执行。
小贴士
如果我们正在构建自己的模块,我们可以轻松地复制 dev/tests/unit/phpunit.xml.dist,适当编辑 testsuite 和 filter > whitelist,以便快速执行仅我们模块的单元测试,从而节省避免频繁执行整个 Magento 单元测试的时间。
集成测试
集成测试 测试单个组件、层和环境之间的交互。它们可以在 dev/tests/integration 目录中找到。像单元测试一样,Magento 也使用 PHPUnit 进行集成测试。因此,单元测试和集成测试之间的区别不是技术性质上的,而是逻辑性质上的。
要特定地触发集成测试,我们可以在控制台执行以下命令:
php bin/magento dev:tests:run integration
当执行时,Magento 会内部更改目录到 dev/tests/integration 并执行一个类似于以下命令的命令:
php /Users/branko/www/magento2/./vendor/phpunit/phpunit/phpunit
集成目录有其自己的 phpunit.xml.dist 文件。查看其 testsuite 定义,我们可以看到它指向 dev/tests/integration/testsuite 目录中找到的所有以 Test.php 结尾的文件。
静态测试
静态测试实际上并不运行代码;它们分析代码。它们用于验证代码是否符合某些编码标准,例如 PSR-1。我们可以在 dev/tests/static 目录下找到它们。
要专门触发静态测试,我们可以在控制台执行以下命令:
php bin/magento dev:tests:run static
当执行时,Magento 在内部将目录更改为 dev/tests/static 并执行一个类似于以下命令的操作:
php /Users/branko/www/magento2/./vendor/phpunit/phpunit/phpunit
静态目录有其自己的 phpunit.xml.dist 文件。查看其 testsuite 定义,你会看到以下四个测试套件被定义:
-
JavaScript 静态代码分析
-
PHP 编码标准验证
-
代码完整性测试
-
XSS 不安全输出测试
JSHint 是一个用于 JavaScript 代码质量检查的工具,用于 JavaScript 静态代码分析。对于 PHP 代码标准验证,使用 PHP_CodeSniffer 库的元素。PHP_CodeSniffer 将 PHP、JavaScript 和 CSS 文件进行标记化,并检测违反定义的编码标准。
完整性测试
完整性测试检查应用程序的链接方式。它们检查诸如合并配置验证等问题。基本上,它们告诉我们应用程序是否应该能够运行。
我们可以通过使用以下命令从 Magento 安装的根目录中专门触发完整性测试:
php bin/magento dev:tests:run integrity
当执行此操作时,Magento 首先在内部将目录更改为 dev/tests/static,然后执行两个类似于以下命令的操作:
php /Users/branko/www/magento2/./vendor/phpunit/phpunit/phpunit
php /Users/branko/www/magento2/./vendor/phpunit/phpunit/phpunit testsuite/Magento/Test/Legacy
然后,Magento 在内部将目录更改为 dev/tests/integration 并执行一个类似于以下命令的操作:
php /Users/branko/www/magento2/./vendor/phpunit/phpunit/phpunit testsuite/Magento/Test/Integrity
集成测试也使用 PHPUnit 编写实际测试。
旧版测试
旧版测试包括帮助开发者将模块移植到 Magento 新版本的库片段。
我们可以通过使用以下命令从 Magento 安装的根目录中专门触发旧版测试:
php bin/magento dev:tests:run legacy
当执行此操作时,Magento 首先在内部将目录更改为 /dev/tests/static,然后执行一个类似于以下命令的操作:
php /Users/branko/www/magento2/./vendor/phpunit/phpunit/phpunit testsuite/Magento/Test/Legacy
一旦触发,代码将运行检查过时的访问列表、连接、菜单、响应、系统配置以及一些其他事项。
性能测试
性能测试可以在 setup/performance-toolkit/ 目录下找到。这些测试需要安装 Apache JMeter,并且可以通过控制台上的 jmeter 命令访问。可以通过遵循 jmeter.apache.org 上的说明来下载和安装 Apache JMeter。
性能测试的核心定义在 benchmark.jmx 文件中,该文件可以在 JMeter GUI 工具中打开,如下截图所示:

如前一张截图所示,默认的benchmark.jmx测试被分为三个线程组,分别命名为setUp Thread Group、Customer Checkout和tearDown Thread Group。我们可能还想点击每个组并使用一些额外的参数进行配置,从而可能改变线程数(用户数),如下一张截图所示。然后我们可以简单地保存更改,作为对benchmark.jmx文件或新文件的修改:

我们可以通过运行以下命令从控制台手动触发性能测试,而不使用 GUI 界面:
jmeter -n \
-t /Users/branko/www/magento2/setup/performance-toolkit/benchmark.jmx \
-l /Users/branko/Desktop/jmeter-tmp/results.jtl \
-Jhost="magento2.ce" \
-Jbase_path="/" \
-Jreport_save_path="/Users/branko/report" \
-Jloops=2 \
-Jurl_suffix=".html" \
-Jcustomer_email="john.doe@email.loc" \
-Jcustomer_password="abc123" \
-Jadmin_path="/admin_nwb0bx" \
-Jadmin-user="john" \
-Jadmin-password="abc123" \
-Jresponse_time_file_name="/Users/branko/report/AggregateGraph.csv" \
-Jsimple_product_url_key="simple-product-1" \
-Jsimple_product_name="Simple Product 1" \
-Jconfigurable_product_url_key="configurable-product-1" \
-Jconfigurable_product_name="Configurable Product 1" \
-Jcategory_url_key="category-1" \
-Jcategory_name="Category 1" \
-Jsleep_between_steps=50
这里列出的以-J开头的控制台参数也匹配用户定义变量测试工具包的名称,如前一张截图所示。我们需要小心设置它们,以符合 Magento 的安装。-n参数指示jmeter以nongui模式运行。-t参数是我们设置要运行的测试(.jmx)文件路径的地方。-l参数设置我们需要记录样本的文件。
功能测试
功能测试模拟用户与我们的应用程序的交互。字面上讲,这意味着以浏览器交互的形式进行测试,包括点击页面、将产品添加到购物车等。为此,Magento 使用Magento 测试框架(MTF)。它是一个围绕Selenium的 PHP 包装器,Selenium 是一个适用于 Web 应用程序的可移植软件测试框架。MTF 不是通过控制台直接可用的。它可以在github.com/magento/mtf上下载。
在安装 MTF 之前,需要满足以下要求:
-
必须安装 Git。
-
必须安装 Firefox 浏览器。
-
必须安装并启用 PHP openssl 扩展。
-
需要 Java 版本 1.6 或更高版本,并且它的 JAR 可执行文件必须在系统 PATH 中。
-
可在
www.seleniumhq.org/找到的 Selenium 独立服务器需要下载。下载应提供一个我们将后来需要引用的 JAR 文件。 -
Magento 必须安装并配置为不使用秘密 URL 密钥。我们可以通过导航到商店 | 配置 | 高级 | 管理员 | 安全 | 将秘密密钥添加到 URL [是/否]并将它设置为否。
一旦满足最小要求,我们可以按照以下步骤安装 MTF:
-
从
dev/tests/functional/目录运行composer install命令。这会创建一个名为vendor的新目录;MTF 是从github.com/magento/mtf的 Git 仓库中拉取的。我们应该看到一个名为vendor的新目录,其中包含以下截图所示的内容:![功能测试]()
-
从
dev/tests/functional/utils/目录下运行generate.php文件。这应该会给我们一个类似于以下的控制台输出:|| Item || Count || Time || || Page Classes || 152 || 0 || || Fixture Classes || 46 || 0 || || Repository Classes || 67 || 0 || || Block || 475 || 0 || || Fixture || 100 || 0 || || Handler || 3 || 0 || || Page || 165 || 0 || || Repository || 67 || 0 ||注意
生成工具为固定值、处理器、存储库、页面对象和块对象创建工厂。当 MTF 初始化时,工厂预先生成,以方便测试的创建和运行。
在我们实际运行测试之前,还有一些其他的事情需要配置,如下所示:
-
编辑
dev/tests/functional/phpunit.xml文件。在php元素下,对于name="app_frontend_url",设置测试的 Magento 店面实际 URL 的值。对于name="app_backend_url",设置测试的 Magento 管理 URL 实际 URL 的值。对于name="credentials_file_path",设置./credentials.xml的值。小贴士
如果
phpunit.xml不存在,我们需要创建它,并将dev/tests/functional/phpunit.xml.dist的内容复制到其中,然后进行编辑。 -
编辑
dev/tests/functional/etc/config.xml文件。在application元素下,找到并编辑backendLogin、backendPassword和appBackendUrl的信息,使其与我们的商店匹配。小贴士
如果
config.xml不存在,我们需要创建它,并将dev/tests/functional/etc/config.xml.dist的内容复制到其中,然后进行编辑。 -
编辑
dev/tests/functional/credentials.xml文件。在空白 Magento 安装中,我们可能不需要这个文件,因为我们默认可以看到fedex、ups、dhl U.S.和dhl EU承运人的条目,这些在全新安装的 Magento 中尚未设置。小贴士
如果
credentials.xml不存在,我们需要创建它,并将dev/tests/functional/credentials.xml.dist的内容复制到其中,然后进行编辑。 -
通过控制台运行
java -jar {selenium_directory}/selenium-server.jar命令。这是为了确保 Selenium 服务器正在运行。 -
打开一个新的控制台或控制台标签,并在
dev/tests/functional/目录下执行phpunit命令。这个命令应该会打开 Firefox 浏览器并开始在其中运行测试用例,模拟用户点击浏览器窗口并填写表单输入。
当测试运行时,Magento 会将所有失败的测试记录在dev/tests/functional/var/log目录下,其结构类似于以下截图所示:

在dev/tests/functional/phpunit.xml文件下的php元素中,可以通过name="basedir"配置log路径。
如果我们想要在整个测试套件中针对特定的测试,我们可以在dev/tests/functional/目录中简单地触发以下命令:
phpunit tests/app/Magento/Customer/Test/TestCase /RegisterCustomerFrontendEntityTest.php
上述命令将运行一个名为RegisterCustomerFrontendEntityTest.php的单个测试。我们也可以使用更简短的表达形式,如下所示:
phpunit --filter RegisterCustomerFrontendEntityTest
一旦执行,浏览器应该打开并模拟在店面上的客户注册过程。
编写一个简单的单元测试
现在我们快速浏览了 Magento 提供的所有测试类型,让我们退一步再次看看单元测试。在实践中,单元测试可能是我们将要编写的大部分内容。考虑到这一点,让我们从 github.com/ajzele/B05032-Foggyline_Unitly 中获取 Foggyline_Unitly 模块,并开始为其编写单元测试。
如果你还没有在代码库中包含前几章的 Foggyline_Unitly 模块,那么你需要将其内容放置在 app/code/Foggyline/Unitly 下,并在 Magento 目录的根目录下从控制台执行以下命令:
php bin/magento module:enable Foggyline_Unitly
php bin/magento setup:upgrade
我们将要编写的测试位于模块的 Test/Unit 目录中。这使得测试目录的完整路径看起来像 app/code/Foggyline/Unitly/Test/Unit/。Magento 知道它需要查看这个文件夹,仅仅是因为在 dev/tests/unit/phpunit.xml 文件中找到了测试套件目录定义,如下所示:
<directory suffix="Test.php">
../../../app/code/*/*/Test/Unit
</directory>
单个模块 Test/Unit 目录中的文件和文件夹的结构也遵循该模块的文件和文件夹结构。以下截图显示了 Magento_Catalog 模块的 Test/Unit 目录结构:

这表明几乎任何 PHP 类都可以进行单元测试,无论它是控制器、块、辅助工具、模块、观察者还是其他东西。为了保持简单,我们将专注于与 Foggyline_Unitly 模块相关的控制器和块单元测试,该模块的结构如下:

让我们先为 Foggyline\Unitly\Controller\Hello\Shout 控制器类编写一个测试。忽略 __construct,Shout 类只有一个名为 execute 的方法。
我们将在与模块的 Test\Unit 目录相同的目录结构下为其编写一个测试,将测试放在 app/code/Foggyline/Unitly/Test/Unit/Controller/Hello/ShoutTest.php 文件中(部分),如下所示:
namespace Foggyline\Unitly\Test\Unit\Controller\Hello;
class ShoutTest extends \PHPUnit_Framework_TestCase
{
protected $resultPageFactory;
protected $controller;
public function setUp()
{
/* setUp() code here */
}
public function testExecute()
{
/* testExecute() code here */
}
}
Magento 模块目录中的每个单元测试都扩展自 \PHPUnit_Framework_TestCase 类。setUp 方法在测试执行之前被调用;我们可以将其视为 PHP 的 __construct。在这里,我们通常会设置固定值、打开网络连接或执行类似操作。
testExecute 方法的名称实际上是由测试 + 我们要测试的类的名称组成的。由于 Shout 类有一个执行方法,因此形成的测试方法变为测试 + 执行。通过将类方法名称的首字母大写,最终的名称为 testExecute。
现在,让我们继续将 /* setUp() 代码这里 */ 替换为以下内容:
$request = $this->getMock(
'Magento\Framework\App\Request\Http',
[],
[],
'',
false
);
$context = $this->getMock(
'\Magento\Framework\App\Action\Context',
['getRequest'],
[],
'',
false
);
$context->expects($this->once())
->method('getRequest')
->willReturn($request);
$this->resultPageFactory = $this-> getMockBuilder ('Magento\Framework\View\Result\PageFactory')
->disableOriginalConstructor()
->setMethods(['create'])
->getMock();
$this->controller = new \Foggyline\Unitly\Controller\Hello\Shout(
$context,
$this->resultPageFactory
);
测试的整体概念是基于模拟我们需要与之交互的对象。我们使用 getMock 方法,该方法为指定的类返回一个模拟对象。除了类名之外,getMock 方法还接受相当多的其他参数。第二个 $methods 参数标记了将被测试替身替换的方法名称。为 $methods 参数提供 null 意味着不会替换任何方法。getMock 方法的第三个参数代表 $arguments,它们是传递给原始类构造函数的参数。
从前面的代码中我们可以看到,$request 模拟对象没有向其 getMock 方法提供任何 $methods 或 $arguments 参数。另一方面,$context 对象传递了一个包含单个 getRequest 元素的数组。一旦 $context 对象初始化,它就调用 expects 方法,该方法在模拟对象中注册一个新的期望,并返回 InvocationMocker,我们可以在其上调用方法和 willReturn。在这种情况下,之前启动的 $request 对象的实例被传递给 willReturn。我们使用了 getMockBuilder 来创建一个 Result\PageFactory 模拟对象,并实例化了 Shout 控制器动作类,将上下文和结果页面模拟对象传递给它。
这个 setUp 方法中的所有代码都是为了获取控制器实例,该实例将在 testExecute 方法中使用。
小贴士
final、private 和 static 方法不能被模拟。由于它们保留了原始行为,因此 PHPUnit 的测试功能会忽略这些方法。
让我们继续替换这里的 /* testExecute() */ 代码,如下所示:
$title = $this-> getMockBuilder('Magento\Framework\View\Page\Title')
->disableOriginalConstructor()
->getMock();
$title->expects($this->once())
->method('set')
->with('Unitly');
$config = $this-> getMockBuilder('Magento\Framework\View\Page\Config')
->disableOriginalConstructor()
->getMock();
$config->expects($this->once())
->method('getTitle')
->willReturn($title);
$page = $this-> getMockBuilder('Magento\Framework\View\Result\Page')
->disableOriginalConstructor()
->getMock();
$page->expects($this->once())
->method('getConfig')
->willReturn($config);
$this->resultPageFactory->expects($this->once())
->method('create')
->willReturn($page);
$result = $this->controller->execute();
$this->assertInstanceOf('Magento\Framework\View\Result\Page', $result);
在前面的代码中,我们检查了页面标题、页面和结果页面对象。要从控制器代码内部获取页面标题,我们通常会使用一个表达式,例如 $resultPage->getConfig()->getTitle()。这个表达式涉及三个对象。$resultPage 对象调用 getConfig() 方法,该方法返回 Page\Config 对象的实例。该对象调用 getTitle 方法,该方法返回 Page\Title 对象的实例。因此,我们正在模拟和测试所有三个对象。
现在我们已经查看了一下控制器测试用例,让我们看看如何为块类创建一个测试用例。创建一个名为 app/code/Foggyline/Unitly/Test/Unit/Block/Hello/ShoutTest.php 的文件,并包含以下部分内容:
namespace Foggyline\Unitly\Test\Unit\Block\Hello;
class ShoutTest extends \PHPUnit_Framework_TestCase
{
/**
* @var \Foggyline\Unitly\Block\Hello\Shout
*/
protected $block;
protected function setUp()
{
$objectManager = new \Magento\Framework\TestFramework\Unit \Helper\ObjectManager($this);
$this->block = $objectManager-> getObject('Foggyline\Unitly\Block\Hello\Shout');
}
public function testGreeting()
{
$name = 'Foggyline';
$this->assertEquals(
'Hello '.$this->block->escapeHtml($name),
$this->block->greeting($name)
);
}
}
在这里,我们还定义了 setUp 方法和 testGreeting。testGreeting 方法被用作 Shout 块类上的问候方法的测试。
从概念上讲,对控制器、块或模型类进行单元测试之间没有区别。因此,在这个例子中我们将省略模型单元测试。你需要意识到的是,测试是我们自己定义的。从技术角度来说,我们可以针对各种情况测试单个方法,或者只测试最明显的一个。然而,为了更好地服务于测试的目的,我们应该测试所有可能的结果组合。
让我们继续创建一个包含以下内容的dev/tests/unit/foggyline-unitly-phpunit.xml文件:
<phpunit xsi:noNamespaceSchemaLocation="http://schema.phpunit.de /4.1/phpunit.xsd"
colors="true"
bootstrap="./framework/bootstrap.php"
>
<testsuite name="Foggyline_Unitly - Unit Tests">
<directory suffix="Test.php">
../../../app/code/Foggyline/Unitly/Test/Unit
</directory>
</testsuite>
<php>
<ini name="date.timezone" value="Europe/Zagreb"/>
<ini name="xdebug.max_nesting_level" value="200"/>
</php>
<filter>
<whitelist addUncoveredFilesFromWhiteList="true">
<directory suffix=".php">
../../../app/code/Foggyline/Unitly/*
</directory>
</whitelist>
</filter>
<logging>
<log type="coverage-html" target="coverage_dir/Foggyline_Unitly/test- reports/coverage" charset="UTF-8" yui="true" highlight="true"/>
</logging>
</phpunit>
最后,我们可以通过运行如phpunit -c foggyline-unitly-phpunit.xml之类的命令来仅执行我们自己的模块单元测试。
一旦测试执行完毕,我们应该能够在dev/tests/unit/coverage_dir/Foggyline_Unitly/test-reports/coverage/index.html文件中看到完整的代码覆盖率报告,如下面的截图所示:

前面的截图展示了代码覆盖率有多么详细,它甚至显示了被测试覆盖的百分比和代码行数。
摘要
在本章中,我们通过根目录下的dev/tests/目录和Magento_Developer模块中的库,了解了如何使用嵌入在 Magento 中的测试功能。我们学习了如何运行所有测试类型,并研究了一个编写我们自己的单元测试的简单示例。这里给出的示例并不能完全体现 PHPUnit 的强大功能。更多关于 PHPUnit 的信息可以在phpunit.de/找到。
现在我们将进入这本书的最后一章,我们将回顾到目前为止所学的内容,并开发一个包含一些基本测试的功能性迷你模块。
第十二章. 从零开始构建模块
基于前几章获得的知识,我们现在将构建一个微型Helpdesk模块。虽然这个模块是微型的,但我们将通过以下部分展示几个重要的 Magento 平台功能的用法:
-
注册模块(
registration.php和module.xml) -
创建配置文件(
config.xml) -
创建电子邮件模板(
email_templates.xml) -
创建系统配置文件(
system.xml) -
创建访问控制列表(
acl.xml) -
创建安装脚本(
InstallSchema.php) -
管理实体持久性(模型、资源、集合)
-
构建前端界面
-
构建后端界面
-
创建单元测试
模块要求
模块要求定义如下:
-
使用的名称,
Foggyline/Helpdesk -
要存储在表中的数据称为
foggyline_helpdesk_ticket -
工单实体将包含
ticket_id、customer_id、title、severity、created_at和status属性 -
customer_id属性将在customer_entity表上作为外键 -
将有三个可用的工单严重性值:
低、中和高 -
如果未指定,新工单的默认严重性值为
低 -
将有两个可用的工单状态:
已打开和已关闭 -
如果未指定,新工单的默认状态值为
已打开 -
需要定义两个电子邮件模板:
store_owner_to_customer_email_template和customer_to_store_owner_email_template,以便在创建工单和状态更改时推送电子邮件更新 -
客户将能够通过他们的我的账户部分提交工单
-
客户将能够在他们的我的账户部分查看他们之前提交的所有工单
-
客户将无法编辑任何现有的工单
-
一旦客户提交了新的工单,就会发送事务性电子邮件(我们可以称之为Foggyline – 帮助台 – 客户 | 店主)给店主
-
需要一个可配置的选项,以可能覆盖Foggyline – 帮助台 – 客户 | 店主电子邮件
-
管理员用户将能够访问客户 | 帮助台工单下的所有工单列表
-
管理员用户将能够将工单状态从已打开更改为已关闭,反之亦然
-
一旦管理员用户更改了工单状态,就会发送事务性电子邮件(我们可以称之为Foggyline – 帮助台 – 店主 | 客户)给客户
-
需要一个可配置的选项,以可能覆盖Foggyline – 帮助台 – 店主 | 客户电子邮件
根据概述的要求,我们已准备好开始我们的模块开发。
注册模块
我们首先开始定义app/code/Foggyline/Helpdesk/registration.php文件,内容如下:
<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Foggyline_Helpdesk',
__DIR__
);
我们随后定义了app/code/Foggyline/Helpdesk/etc/module.xml文件,内容如下:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Module /etc/module.xsd">
<module name="Foggyline_Helpdesk" setup_version="1.0.0">
<sequence>
<module name="Magento_Store"/>
<module name="Magento_Customer"/>
</sequence>
</module>
</config>
观察前面的文件,如果我们移除所有模块中重复的样板内容,我们在这里剩下三个重要的事情:
-
模块名称属性,定义为
Foggyline_Helpdesk。我们需要确保在命名我们的模块时遵循一定的模式,例如Vendor+_+Module名称。模块名称属性只能包含字母和数字 [A-Z, a-z, 0-9, _]。 -
定义我们的模块版本的
setup_version属性。其值只能包含数字 [0-9]。我们的示例将setup_version属性的值设置为1.0.0。 -
定义模块依赖关系的模块名称属性。我们的模块基本上表示它需要启用
Magento_Store和Magento_Customer模块。
一旦这个文件就绪,我们需要转到命令行,将目录更改为 Magento 安装目录,然后简单地执行以下命令:
php bin/magento module:enable Foggyline_Helpdesk
然而,如果我们现在在浏览器中打开前端区域的任何管理页面,我们可能会得到一个错误页面,该页面在var/reports/文件夹下生成以下错误:
Please upgrade your database: Run "bin/magento setup:upgrade" from the Magento root directory.
幸运的是,错误信息相当自描述,所以我们只需简单地回到控制台,将目录更改为 Magento 根目录,并执行以下命令:
php bin/magento setup:upgrade
执行的命令将激活我们的模块。
我们可以通过查看以下截图中的app/etc/config.php文件来确认这一点(在第 33 行):

进一步地,如果我们登录到管理区域,并转到商店 | 配置 | 高级 | 高级,我们应该在那里看到我们的模块列表,如下面的截图所示:

创建配置文件(config.xml)
现在我们将创建一个包含以下内容的app/code/Foggyline/Helpdesk/etc/config.xml文件:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store: etc/config.xsd">
<default>
<foggyline_helpdesk>
<email_template>
<customer>
foggyline_helpdesk_email_template_customer
</customer>
<store_owner>
foggyline_helpdesk_email_template_store_owner
</store_owner>
</email_template>
</foggyline_helpdesk>
</default>
</config>
这可能一开始看起来很困惑,不知道default | foggyline_helpdesk | email_template结构从何而来。这个结构本身表示我们将映射到浏览器中商店 | 配置部分可见的配置值的位臵。鉴于与商店 | 配置部分相关的所有视觉元素都源自system.xml文件,我们现在在config.xml中的这个结构将映射到我们即将定义的另一个system.xml文件。
目前,只需记住customer和store_owner属性中的结构和值。这些值将进一步映射到我们即将创建的另一个email_templates.xml文件。
关于config.xml文件,还有一件更重要的事情。我们需要非常小心地处理xsi:noNamespaceSchemaLocation属性值。此值需要设置为urn:magento:module:Magento_Store:etc/config.xsd。这是一个别名,实际上指向vendor/magento/module-store/etc/config.xsd文件。
创建电子邮件模板(email_templates.xml)
我们模块的要求指定需要定义两个电子邮件模板。关于这一点,在之前定义的 app/code/Foggyline/Helpdesk/etc/config.xml 文件中已经给出了提示。我们模块可用的电子邮件模板的实际定义是通过 app/code/Foggyline/Helpdesk/etc/email_templates.xml 文件完成的,内容如下:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Email:etc/email_templates.xsd">
<template id="foggyline_helpdesk_email_template_customer" label="Foggyline Helpdesk - Customer Email"
file="store_owner_to_customer.html" type="html" module="Foggyline_Helpdesk" area="frontend"/>
<template id="foggyline_helpdesk_email_template_store_owner" label="Foggyline Helpdesk - Store Owner Email"
file="customer_to_store_owner.html" type="html" module="Foggyline_Helpdesk" area="frontend"/>
</config>
查看 email_templates.xsd,我们可以得出结论,id、label、file、type 和 module 的值都是必需的。id 应该定义为我们模块的唯一值,给我们的电子邮件模板起一个合理且合理的代码名称,因为这个代码名称将在其他 XML 文件或代码中进一步使用。
我们在这里定义的 ID 值可以在 app/code/Foggyline/Helpdesk/etc/config.xml 中找到,作为 default | foggyline_helpdesk | email_template | customer 和 default | foggyline_helpdesk | email_template | store_owner 元素的值。
如果两个之间的联系还不完全清楚;当我们开始构建我们的 system.xml 文件时,我们将很快了解到这一点。
label 属性的值是稍后在 Magento 管理区域 营销 | 通讯 | 电子邮件模板 中可见的内容,所以请确保在这里放置一些用户友好且易于识别的内容。
此外,file 属性的值指向以下文件的位置:
-
app/code/Foggyline/Helpdesk/view/frontend/email/customer_to_store_owner.html -
app/code/Foggyline/Helpdesk/view/frontend/email/store_owner_to_customer.html
文件的内容将被设置为,稍后,在代码中,我们需要传递某些变量以填充变量占位符。
customer_to_store_owner.html 电子邮件模板,内容如下,将在代码中稍后触发,当客户创建新的票据时:
<!--@subject New Ticket Created @-->
<h1>Ticket #{{var ticket.ticket_id}} created</h1>
<ul>
<li>Id: {{var ticket.ticket_id}}</li>
<li>Title: {{var ticket.title}}</li>
<li>Created_at: {{var ticket.created_at}}</li>
<li>Severity: {{var ticket.severity}}</li>
</ul>
然后,我们将看到如何将 ticket 对象作为变量传递到模板中,以便在 HTML 模板中启用 {{var ticket.title}} 这样的调用。
store_owner_to_customer.html 电子邮件模板,内容如下,将在代码中稍后触发,当店主更改票据状态时:
<!--@subject Ticket Updated @-->
<h1>Ticket #{{var ticket.ticket_id}} updated</h1>
<p>Hi {{var customer_name}}.</p>
<p>Status of your ticket #{{var ticket.ticket_id}} has been updated</p>
<ul>
<li>Title: {{var ticket.title}}</li>
<li>Created_at: {{var ticket.created_at}}</li>
<li>Severity: {{var ticket.severity}}</li>
</ul>
如果我们现在登录到 Magento 管理区域,进入 营销 | 通讯 | 电子邮件模板,点击 添加新模板 按钮,我们应该能够在 模板 下拉菜单中看到我们的两个电子邮件模板,如图所示:

如果我们回顾一下我们的 config.xml 和 email_templates.xml,仍然没有明确的联系,不清楚 default | foggyline_helpdesk | email_template | customer 和 default | foggyline_helpdesk | email_template | store_owner 在 config.xml 中实际上代表什么。这是因为我们仍然缺少两个将它们联系起来的关键成分:app/code/Foggyline/Helpdesk/etc/adminhtml/system.xml 和 app/code/Foggyline/Helpdesk/etc/acl.xml 文件。
创建系统配置文件(system.xml)
system.xml 文件本质上是一个 商店 | 配置 接口构建器。我们在模块的 system.xml 文件中定义的条目将在 Magento 管理区域下的 商店 | 配置 接口中渲染某些部分。
与前两个 XML 文件不同,此配置文件位于一个额外的子文件夹中,因此其完整路径为 app/code/Foggyline/Helpdesk/etc/adminhtml/system.xml,内容如下:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Config:etc/system_file.xsd">
<system>
<tab id="foggyline" translate="label" sortOrder="200">
<label>Foggyline</label>
</tab>
<section id="foggyline_helpdesk" translate="label" type="text" sortOrder="110" showInDefault="1"
showInWebsite="1" showInStore="1">
<label>Helpdesk</label>
<tab>foggyline</tab>
<resource>Foggyline_Helpdesk::helpdesk</resource>
<group id="email_template" translate="label" type="text" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Email Template Options</label>
<field id="customer" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1">
<label>
Store Owner to Customer Email Template
</label>
<source_model>
Magento\Config\Model\Config\Source\ Email\Template
</source_model>
</field>
<field id="store_owner" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1">
<label>
Customer to Store Owner Email Template
</label>
<source_model>
Magento\Config\Model\Config\Source\ Email\Template
</source_model>
</field>
</group>
</section>
</system>
</config>
尽管这个文件中有很多事情,但都可以总结为几个重要点。
注意
确定我们想要显示我们的模块配置选项的位置是一个选择问题。要么我们定义并使用自己的标签,要么我们使用核心模块中的一个现有标签。这实际上取决于我们决定将配置选项放在哪里。
system.xml 定义了一个标签,正如分配了 id 属性值为 foggyline 的标签元素所示。我们可以在单个 system.xml 文件下定义多个标签。标签元素属性 id 需要在所有标签中都是唯一的,而不仅仅是我们在模块中定义的标签。在 tab 元素内,我们有一个 label 元素,其值为 Foggyline。这个值是显示在 Magento 管理界面 商店 | 配置 区域的内容。
最终结果应如图所示:

小贴士
Magento 在其核心模块中定义了六个预存在的标签(通用、服务、高级、目录、客户、销售)。我们只需通过搜索 tab 字符串,仅过滤名为 system.xml 的文件,就可以轻松地获取 Magento 中所有定义的标签列表。
在 tab 元素旁边,我们有 config | system | section 元素。这个元素是我们进一步定义将成为接受配置选项的 HTML 输入字段的元素,如前一张图所示。
我们可以在单个 system.xml 文件中定义多个部分。实际的 section 元素属性要求我们指定 id 属性值,在我们的例子中设置为 foggyline_helpdesk。其他重要的 section 元素属性是 showInWebsite 和 showInStore。这些可以取 0 或 1 作为值。根据我们的模块业务逻辑,我们可能会找到一个很好的理由来选择其中一个值而不是另一个。
进一步观察,我们 section 元素中包含的元素有:
-
label: 这指定了我们在 Magento 管理员 Store | Configuration 区域下将看到的标签。 -
tab: 这指定了我们希望此部分出现的标签页的 ID 值,在我们的例子中等于foggyline。 -
resource: 这指定了 ACL 资源 ID 值。 -
group: 这指定了字段的组。类似于section元素,它也有id、sortOrder、showInWebsite和showInStore属性。此外,组元素有子字段元素,这对应于在 Magento 管理员 Store | Configuration 区域下的 HTML 输入字段。
我们定义了两个字段,customer 和 store_owner。类似于 section 和 group,field 元素也有 id、sortOrder、showInWebsite 和 showInStore 属性。
注意 field 元素如何进一步包含定义其选项的子元素。鉴于我们的 field 元素类型属性被设置为 select,并且两个字段都如此,我们需要在每个 field 中定义 source_model 元素。两个字段都有相同的 source_model 值,它指向 Magento 核心类,Magento\Config\Model\Config\Source\Email\Template。查看该类,我们可以看到它实现了 \Magento\Framework\Option\ArrayInterface 并定义了 toOptionArray 方法。在渲染管理员 ** Stores** | Configuration 区域时,Magento 将调用此方法以填充选择 HTML 元素的值。
小贴士
理解我们可以使用 system.xml 做什么,归结于理解在 vendor/magento/module-config/etc/system_file.xsd 下定义了什么,并研究现有的 Magento 核心模块 system.xml 文件以获取一些示例。
如前所述,我们的 system.xml 有一个指向 app/code/Foggyline/Helpdesk/etc/acl.xml 文件的资源元素,我们现在将查看它。
创建访问控制列表(acl.xml)
app/code/Foggyline/Helpdesk/etc/acl.xml 文件是我们定义模块访问控制列表资源的地方。当点击 Add New Role 按钮时,访问控制列表资源在 Magento 管理员 System | Permissions | User Roles 区域可见,如以下截图所示:

查看前面的截图,我们可以看到在 Stores | Settings | Configuration 下的 Helpdesk Section。我们是如何将其放置在那里的?我们已经在 app/code/Foggyline/Helpdesk/etc/acl.xml 文件中定义了它,内容如下:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/ etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="Magento_Customer::customer">
<resource id="Foggyline_Helpdesk:: ticket_manage" title="Manage Helpdesk Tickets" />
</resource>
<resource id="Magento_Backend::stores">
<resource id="Magento_Backend:: stores_settings">
<resource id="Magento_Config::config">
<resource id= "Foggyline_Helpdesk::helpdesk" title="Helpdesk Section" />
</resource>
</resource>
</resource>
</resource>
</resources>
</acl>
</config>
通过查看提供的代码,可以立即得出结论,资源可以嵌套在彼此之中。不清楚我们应该如何知道如何将具有Foggyline_Helpdesk::helpdesk ID 值的自定义定义资源嵌套在哪里。简单的答案是,我们遵循了 Magento 的结构。通过查看几个 Magento 核心模块的system.xml文件及其acl.xml文件,出现了一种模式,其中模块将资源嵌套在Magento_Backend::admin | Magento_Backend::stores | Magento_Backend::stores_settings | Magento_Config::config之下。这些都是核心 Magento 中定义的现有资源,所以我们只是引用它们,而不是定义它们。我们在acl.xml文件中定义的唯一资源是我们自己的,然后我们从system.xml文件中引用它。我们可以在acl.xml中定义其他资源,并不一定所有资源都会嵌套在Foggyline_Helpdesk::helpdesk相同的结构中。
我们分配给资源元素的title属性值在管理区域中显示,如前一个截图所示。
小贴士
一定要使用描述性的标签,以便我们的模块资源易于识别。
创建安装脚本(InstallSchema.php)
InstallSchema,或安装脚本,是我们设置数据库中用于后续持久化我们模型所需表的一种方式。
如果我们回顾模块需求,以下字段需要在foggyline_helpdesk_ticket表中创建:
-
ticket_id -
customer_id -
title -
severity -
created_at -
status
我们的InstallSchema定义在app/code/Foggyline/Helpdesk/Setup/InstallSchema.php文件中,其(部分)内容如下:
<?php
namespace Foggyline\Helpdesk\Setup;
use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
/**
* @codeCoverageIgnore
*/
class InstallSchema implements InstallSchemaInterface
{
public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
{
$installer = $setup;
$installer->startSetup();
$table = $installer->getConnection()
->newTable($installer-> getTable('foggyline_helpdesk_ticket'))
/* ->addColumn ... */
/* ->addIndex ... */
/* ->addForeignKey ... */
->setComment('Foggyline Helpdesk Ticket');
$installer->getConnection()->createTable($table);
$installer->endSetup();
}
}
InstallSchema类通过实现单个install方法符合InstallSchemaInterface。在这个方法中,我们启动安装程序,创建新表,创建新字段,向表中添加索引和外键,最后结束安装程序,如下面的(部分)代码所示:
->addColumn(
'ticket_id',
\Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
null,
['identity' => true, 'unsigned' => true, 'nullable' => false, 'primary' => true],
'Ticket Id'
)
->addColumn(
'customer_id',
\Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
null,
['unsigned' => true],
'Customer Id'
)
->addColumn(
'title',
\Magento\Framework\DB\Ddl\Table::TYPE_TEXT,
null,
['nullable' => false],
'Title'
)
->addColumn(
'severity',
\Magento\Framework\DB\Ddl\Table::TYPE_SMALLINT,
null,
['nullable' => false],
'Severity'
)
->addColumn(
'created_at',
\Magento\Framework\DB\Ddl\Table::TYPE_TIMESTAMP,
null,
['nullable' => false],
'Created At'
)
->addColumn(
'status',
\Magento\Framework\DB\Ddl\Table::TYPE_SMALLINT,
null,
['nullable' => false],
'Status'
)
->addIndex(
$installer->getIdxName('foggyline_helpdesk_ticket', ['customer_id']),
['customer_id']
)
->addForeignKey(
$installer->getFkName('foggyline_helpdesk_ticket', 'customer_id', 'customer_entity', 'entity_id'),
'customer_id',
$installer->getTable('customer_entity'),
'entity_id',
\Magento\Framework\DB\Ddl\Table::ACTION_SET_NULL
)
提供的代码显示了如何使用addColumn方法调用将模块需求中的每个字段添加到数据库中,并传递某些参数,例如字段类型和可空状态。熟悉addColumn、addIndex和addForeignKey方法是有价值的,因为这些方法在指定我们模块的新表时最常用。
小贴士
我们可以通过研究其他核心模块如何处理InstallSchema.php文件来进一步深化对安装脚本的理解。遵循良好的数据库设计实践,我们应该始终在我们的表上创建索引和外键,当我们从其他表引用数据时。
管理实体持久性(模型、资源、集合)
在InstallSchema到位后,我们现在有实体持久性的条件。我们的下一步是定义Ticket实体的模型、资源和集合类。
Ticket实体模型类定义在app/code/Foggyline/Helpdesk/Model/Ticket.php文件中,其内容如下:
<?php
namespace Foggyline\Helpdesk\Model;
class Ticket extends \Magento\Framework\Model\AbstractModel
{
const STATUS_OPENED = 1;
const STATUS_CLOSED = 2;
const SEVERITY_LOW = 1;
const SEVERITY_MEDIUM = 2;
const SEVERITY_HIGH = 3;
protected static $statusesOptions = [
self::STATUS_OPENED => 'Opened',
self::STATUS_CLOSED => 'Closed',
];
protected static $severitiesOptions = [
self::SEVERITY_LOW => 'Low',
self::SEVERITY_MEDIUM => 'Medium',
self::SEVERITY_HIGH => 'High',
];
/**
* Initialize resource model
* @return void
*/
protected function _construct()
{
$this->_init('Foggyline\Helpdesk\Model\ ResourceModel\Ticket');
}
public static function getSeveritiesOptionArray()
{
return self::$severitiesOptions;
}
public function getStatusAsLabel()
{
return self::$statusesOptions[$this->getStatus()];
}
public function getSeverityAsLabel()
{
return self::$severitiesOptions[$this->getSeverity()];
}
}
阅读前面的代码,我们看到它扩展了\Magento\Framework\Model\AbstractModel类,该类进一步扩展了\Magento\Framework\Object类。这为我们Ticket模型类带来了许多额外的方法,例如load、delete、save、toArray、toJson、toString、toXml等等。
对于我们来说,唯一实际的要求是定义_construct方法,通过调用_init函数,指定模型在持久化数据时将使用的资源类。我们已将该值设置为Foggyline\Helpdesk\Model\ResourceModel\Ticket,这将是我们将要定义的下一个类,所谓的资源类。
我们还进一步定义了几个常量,STATUS_*和SEVERITY_*,作为良好的编程实践标志,并且不是将我们将在代码中使用的值硬编码,我们可以将这些值集中到类常量中。这些常量在某种程度上映射到我们的模块需求。
此外,我们还有三个额外的方法(getSeveritiesOptionArray、getStatusAsLabel和getSeverityAsLabel),我们将在后续的块类和模板文件中使用。
Ticket实体资源类在app/code/Foggyline/Helpdesk/Model/ResourceModel/Ticket.php下定义,内容如下:
<?php
namespace Foggyline\Helpdesk\Model\ResourceModel;
class Ticket extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb
{
/**
* Initialize resource model
* Get table name from config
*
* @return void
*/
protected function _construct()
{
$this->_init('foggyline_helpdesk_ticket', 'ticket_id');
}
}
我们可以看到代码扩展了\Magento\Framework\Model\ResourceModel\Db\AbstractDb类,该类进一步扩展了\Magento\Framework\Model\ResourceModel\AbstractResource类。这为我们Ticket资源类带来了许多额外的方法,例如load、delete、save、commit、rollback等等。
对于我们来说,唯一实际的要求是定义_construct方法,通过这个方法我们调用接受两个参数的_init函数。_init函数的第一个参数指定表名foggyline_helpdesk_ticket,第二个参数指定在该表中识别ticket_id列,我们将在此列中持久化数据。
最后,我们在app/code/Foggyline/Helpdesk/Model/ResourceModel/Ticket/Collection.php下定义了Ticket实体集合类,内容如下:
<?php
namespace Foggyline\Helpdesk\Model\ResourceModel\Ticket;
class Collection extends \Magento\Framework\Model\ ResourceModel\Db\Collection\AbstractCollection
{
/**
* Constructor
* Configures collection
*
* @return void
*/
protected function _construct()
{
$this->_init('Foggyline\Helpdesk\Model\Ticket', 'Foggyline\Helpdesk\Model\ResourceModel\Ticket');
}
}
集合类代码扩展了\Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection类,该类进一步扩展了\Magento\Framework\Data\Collection\AbstractDb类,该类进一步扩展了\Magento\Framework\Data\Collection。最终的父集合类实现了以下接口:\IteratorAggregate、\Countable、Magento\Framework\Option\ArrayInterface和Magento\Framework\Data\CollectionDataSourceInterface。通过这种深层次的继承,大量方法变得可用给我们的集合类,例如count、getAllIds、getColumnValues、getFirstItem、getLastItem等等。
关于我们新定义的集合类,我们唯一实际的要求是定义_construct方法。在_construct方法中,我们调用_init函数,并向其传递两个参数。第一个参数指定Ticket模型类Foggyline\Helpdesk\Model\Ticket,第二个参数指定Ticket资源类Foggyline\Helpdesk\Model\ResourceModel\Ticket。
我们刚刚定义的三个类(model、resource、collection)作为一个整体的单一实体持久机制。根据当前定义的代码,我们能够保存、删除、更新、带有过滤的查找以及列出我们的Ticket实体,我们将在接下来的部分中演示。
构建前端界面
现在我们已经定义了数据持久性功能所需的最小必要条件,我们可以继续构建前端界面。模块要求表明,客户应该能够通过他们的我的账户部分提交工单。因此,我们将在客户的我的账户部分下添加一个名为帮助台工单的链接。
为了实现一个完全功能的前端,以下内容是必需的:
-
一个将映射到我们控制器的路由
-
一个将捕获来自映射路由的请求的控制器
-
一个控制器动作,将加载布局
-
将更新视图以使其看起来像我们处于我的账户部分,同时提供我们自己的内容
-
一个用于驱动我们的模板文件的块类
-
一个我们将渲染到页面内容区域的模板文件
-
一个控制器动作,当表单提交时将保存新工单表单
创建路由、控制器和布局处理程序
我们首先在app/code/Foggyline/Helpdesk/etc/frontend/routes.xml文件中定义一条路由,其内容如下:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:App/ etc/routes.xsd">
<router id="standard">
<route id="foggyline_helpdesk" frontName="foggyline_helpdesk">
<module name="Foggyline_Helpdesk"/>
</route>
</router>
</config>
注意,路由元素id和frontName属性具有相同的值,但它们并不服务于相同的目的,正如我们很快将看到的。
现在我们定义我们的控制器app/code/Foggyline/Helpdesk/Controller/Ticket.php文件,其内容如下:
<?php
namespace Foggyline\Helpdesk\Controller;
abstract class Ticket extends \Magento\Framework\App\Action\Action
{
protected $customerSession;
public function __construct(
\Magento\Framework\App\Action\Context $context,
\Magento\Customer\Model\Session $customerSession
)
{
$this->customerSession = $customerSession;
parent::__construct($context);
}
public function dispatch(\Magento\Framework\App \RequestInterface $request)
{
if (!$this->customerSession->authenticate()) {
$this->_actionFlag->set('', 'no-dispatch', true);
if (!$this->customerSession->getBeforeUrl()) {
$this->customerSession->setBeforeUrl($this-> _redirect->getRefererUrl());
}
}
return parent::dispatch($request);
}
}
我们的控制器通过其构造函数加载客户会话对象。然后,在 dispatch 方法中使用客户会话对象来检查客户是否已认证。如果客户未认证,所有导致此控制器的 Internet 浏览器中的前端操作都将导致客户被重定向到登录屏幕。
一旦控制器就位,我们就可以定义从它扩展的动作。每个动作都是一个独立的类文件,从父类扩展而来。现在,我们将在app/code/Foggyline/Helpdesk/Controller/Ticket/Index.php文件中定义我们的 index 动作,即渲染我的账户 | 帮助台工单下的视图的动作,其内容如下:
<?php
namespace Foggyline\Helpdesk\Controller\Ticket;
class Index extends \Foggyline\Helpdesk\Controller\Ticket
{
public function execute()
{
$resultPage = $this->resultFactory->create(\Magento \Framework\Controller\ResultFactory::TYPE_PAGE);
return $resultPage;
}
}
控制器操作代码位于其类中的 execute 方法内。我们简单地扩展了 \Foggyline\Helpdesk\Controller\Ticket 控制器类,并在 execute 方法中定义必要的逻辑。简单地调用 loadLayout 和 renderLayout 就足以在前端渲染页面。
前端 XML 布局位于 app/code/Foggyline/Helpdesk/view/frontend/layout 文件夹下。拥有路由 ID、控制器和控制器操作就足以让我们确定处理名称,该名称遵循公式 {route id}{controller name}.xml。因此,我们在 app/code/Foggyline/Helpdesk/view/frontend/layout/foggyline_helpdesk_ticket_index.xml 文件中定义了一个索引操作布局,内容如下:
<?xml version="1.0"?>
<page xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout /etc/page_configuration.xsd">
<update handle="customer_account"/>
<body>
<referenceContainer name="content">
<block class="Foggyline\Helpdesk\Block\Ticket\Index" name="foggyline.helpdesk.ticket.index" template= "Foggyline_Helpdesk::ticket/index.phtml" cacheable="false"/>
</referenceContainer>
</body>
</page>
注意我们如何立即调用更新指令,并传递 customer_account 处理程序属性值。这就像说,“将 customer_account 处理程序中的所有内容包含到我们的处理程序中。”我们进一步引用内容块,在其中我们定义了自己的自定义块类型 Foggyline\Helpdesk\Block\Ticket\Index。尽管块类可以指定自己的模板,但我们使用模板属性和模块特定的路径 Foggyline_Helpdesk::ticket/index.phtml 来分配一个模板给块。
仅包含 customer_acount 处理程序是不够的;我们需要一些额外的东西来定义在 我的账户 部分下的链接。我们在 app/code/Foggyline/Helpdesk/view/frontend/layout/customer_account.xml 文件中定义了这个额外的东西,内容如下:
<?xml version="1.0"?>
<page xsi:noNamespaceSchemaLocation="urn:magento:framework:View/ Layout/etc/page_configuration.xsd">
<head>
<title>Helpdesk Tickets</title>
</head>
<body>
<referenceBlock name="customer_account_navigation">
<block class="Magento\Framework\View\Element\Html \Link\Current" name="foggyline-helpdesk-ticket">
<arguments>
<argument name="path" xsi:type="string"> foggyline_helpdesk/ticket/index </argument>
<argument name="label" xsi:type="string"> Helpdesk Tickets </argument>
</arguments>
</block>
</referenceBlock>
</body>
</page>
这里发生的情况是我们正在引用一个名为 customer_account_navigation 的现有块,并在其中定义了一个新的块,其类为 Magento\Framework\View\Element\Html\Link\Current。这个块接受两个参数:设置为我们的控制器操作的路径和设置为 帮助台票据 的标签。
创建块和模板
从 foggyline_helpdesk_ticket_index.xml 中引用的 Foggyline\Helpdesk\Block\Ticket\Index 块类定义在 app/code/Foggyline/Helpdesk/Block/Ticket/Index.php 文件中,内容如下:
<?php
namespace Foggyline\Helpdesk\Block\Ticket;
class Index extends \Magento\Framework\View\Element\Template
{
/**
* @var \Magento\Framework\Stdlib\DateTime
*/
protected $dateTime;
/**
* @var \Magento\Customer\Model\Session
*/
protected $customerSession;
/**
* @var \Foggyline\Helpdesk\Model\TicketFactory
*/
protected $ticketFactory;
/**
* @param \Magento\Framework\View\Element\Template\Context $context
* @param array $data
*/
public function __construct(
\Magento\Framework\View\Element\Template\Context $context,
\Magento\Framework\Stdlib\DateTime $dateTime,
\Magento\Customer\Model\Session $customerSession,
\Foggyline\Helpdesk\Model\TicketFactory $ticketFactory,
array $data = []
)
{
$this->dateTime = $dateTime;
$this->customerSession = $customerSession;
$this->ticketFactory = $ticketFactory;
parent::__construct($context, $data);
}
/**
* @return \Foggyline\Helpdesk\Model\ResourceModel \Ticket\Collection
*/
public function getTickets()
{
return $this->ticketFactory
->create()
->getCollection()
->addFieldToFilter('customer_id', $this-> customerSession->getCustomerId());
}
public function getSeverities()
{
return \Foggyline\Helpdesk\Model\ Ticket::getSeveritiesOptionArray();
}
}
我们定义 Foggyline\Helpdesk\Block\Ticket 块类而不是仅仅使用 \Magento\Framework\View\Element\Template 的原因是我们想定义一些可以在我们的 index.phtml 模板中使用的辅助方法。这些方法是 getTickets(我们将用于列出所有客户票据)和 getSeverities(我们将用于在创建新票据时创建一个可供选择的严重性下拉列表)。
模板进一步在 app/code/Foggyline/Helpdesk/view/frontend/templates/ticket/index.phtml 文件中定义,内容如下:
<?php $tickets = $block->getTickets() ?>
<form
id="form-validate"
action="<?php echo $block-> getUrl('foggyline_helpdesk/ticket/save') ?>"
method="post">
<?php echo $block->getBlockHtml('formkey') ?>
<div class="field title required">
<label class="label" for="title"><span> <?php echo __('Title') ?></span></label>
<div class="control">
<input
id="title"
type="text"
name="title"
data-validate="{required:true}"
value=""
placeholder="<?php echo __('Something descriptive') ?>"/>
</div>
</div>
<div class="field severity">
<label class="label" for="severity"><span><?php echo __('Severity') ?></span></label>
<div class="control">
<select name="severity">
<?php foreach ($block->getSeverities() as $value => $name): ?>
<option value="<?php echo $value ?>"><?php echo $this->escapeHtml($name) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<button type="submit" class="action save primary">
<span><?php echo __('Submit Ticket') ?></span>
</button>
</form>
<script>
require([
'jquery',
'mage/mage'
], function ($) {
var dataForm = $('#form-validate');
dataForm.mage('validation', {});
});
</script>
<?php if ($tickets->count()): ?>
<table class="data-grid">
<?php foreach ($tickets as $ticket): ?>
<tr>
<td><?php echo $ticket->getId() ?></td>
<td><?php echo $block->escapeHtml($ticket-> getTitle()) ?></td>
<td><?php echo $ticket->getCreatedAt() ?></td>
<td><?php echo $ticket->getSeverityAsLabel() ?> </td>
<td><?php echo $ticket->getStatusAsLabel() ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
尽管这是一大块代码,但由于它被分成了几个非常不同的角色扮演块,因此很容易阅读。
$block变量实际上等同于我们写的$this,它是对Foggyline\Helpdesk\Block\Ticket类实例的引用,我们在其中定义了实际的getTickets方法。因此,$tickets变量首先被定义为属于当前登录客户的工单集合。
我们指定了一个具有POST方法类型和指向我们的Save控制器动作的 action URL 的表单。在表单中,我们有一个$block->getBlockHtml('formkey')调用,它基本上返回一个名为form_key的隐藏输入字段,其值是一个随机字符串。在 Magento 中,表单密钥是一种防止跨站请求伪造(CSRF)的手段,因此我们确保在定义的任何表单上使用它们。作为表单的一部分,我们还定义了一个标题输入字段、严重性选择字段和提交按钮。注意那些被抛来抛去的 CSS 类,它们保证了我们的表单外观将与其他 Magento 表单相匹配。
在表单标签关闭后,我们有一个 RequireJS 类型的 JavaScript 包含,用于验证。鉴于我们的表单 ID 值设置为form-validate,JavaScript dataForm变量绑定到它,并在我们按下提交按钮时触发验证检查。
然后,我们有一个计数检查和一个foreach循环,用于渲染所有可能存在的客户工单。
模板代码的最终结果可以在以下图像中看到:

处理表单提交
为了完成我们的前端功能,我们还缺少一个组件——一个控制器动作,用于在表单提交后保存新工单。我们在这个app/code/Foggyline/Helpdesk/Controller/Ticket/Save.php文件中定义了这个动作,内容如下:
<?php
namespace Foggyline\Helpdesk\Controller\Ticket;
class Save extends \Foggyline\Helpdesk\Controller\Ticket
{
protected $transportBuilder;
protected $inlineTranslation;
protected $scopeConfig;
protected $storeManager;
protected $formKeyValidator;
protected $dateTime;
protected $ticketFactory;
public function __construct(
\Magento\Framework\App\Action\Context $context,
\Magento\Customer\Model\Session $customerSession,
\Magento\Framework\Mail\Template\TransportBuilder $transportBuilder,
\Magento\Framework\Translate\Inline\StateInterface $inlineTranslation,
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
\Magento\Store\Model\StoreManagerInterface $storeManager,
\Magento\Framework\Data\Form\FormKey\Validator $formKeyValidator,
\Magento\Framework\Stdlib\DateTime $dateTime,
\Foggyline\Helpdesk\Model\TicketFactory $ticketFactory
)
{
$this->transportBuilder = $transportBuilder;
$this->inlineTranslation = $inlineTranslation;
$this->scopeConfig = $scopeConfig;
$this->storeManager = $storeManager;
$this->formKeyValidator = $formKeyValidator;
$this->dateTime = $dateTime;
$this->ticketFactory = $ticketFactory;
$this->messageManager = $context->getMessageManager();
parent::__construct($context, $customerSession);
}
public function execute()
{
$resultRedirect = $this->resultRedirectFactory->create();
if (!$this->formKeyValidator->validate($this-> getRequest())) {
return $resultRedirect->setRefererUrl();
}
$title = $this->getRequest()->getParam('title');
$severity = $this->getRequest()->getParam('severity');
try {
/* Save ticket */
$ticket = $this->ticketFactory->create();
$ticket->setCustomerId($this->customerSession-> getCustomerId());
$ticket->setTitle($title);
$ticket->setSeverity($severity);
$ticket->setCreatedAt($this->dateTime-> formatDate(true));
$ticket->setStatus(\Foggyline\Helpdesk\Model\ Ticket::STATUS_OPENED);
$ticket->save();
$customer = $this->customerSession->getCustomerData();
/* Send email to store owner */
$storeScope = \Magento\Store\Model\ScopeInterface::SCOPE_STORE;
$transport = $this->transportBuilder
->setTemplateIdentifier($this->scopeConfig-> getValue('foggyline_helpdesk/email_template/ store_owner', $storeScope))
->setTemplateOptions(
[
'area' => \Magento\Framework\App\ Area::AREA_FRONTEND,
'store' => $this->storeManager-> getStore()->getId(),
]
)
->setTemplateVars(['ticket' => $ticket])
->setFrom([
'name' => $customer->getFirstname() . ' ' . $customer->getLastname(),
'email' => $customer->getEmail()
])
->addTo($this->scopeConfig->getValue( 'trans_email/ident_general/email', $storeScope))
->getTransport();
$transport->sendMessage();
$this->inlineTranslation->resume();
$this->messageManager->addSuccess(__('Ticket successfully created.'));
} catch (Exception $e) {
$this->messageManager->addError(__('Error occurred during ticket creation.'));
}
return $resultRedirect->setRefererUrl();
}
}
首先,我们查看__construct方法,看看传递给它了哪些参数。鉴于我们在execute方法中运行的代码需要检查表单密钥是否有效,在数据库中创建一个工单,将工单和一些客户信息传递给发送给商店所有者的电子邮件;然后,我们可以了解正在传递的哪些类型的对象。
execute方法首先检查表单密钥的有效性。如果表单密钥无效,我们将通过重定向到引用 URL 返回。
通过表单密钥检查后,我们获取表单传递的标题和严重性变量。然后,我们通过工单工厂的创建方法实例化工单实体,并逐个设置ticket实体的值。请注意,Ticket实体模型Foggyline\Helpdesk\Model\Ticket本身并没有像setSeverity这样的方法。这是其\Magento\Framework\Object父类继承的属性。
一旦保存了票务实体,我们就初始化运输构建器对象,传递所有成功发送电子邮件所需的参数。注意setTemplateIdentifier是如何使用我们的system.xml配置选项foggyline_helpdesk/email_template/store_owner的。如果没有在管理员商店 | 配置 | Foggyline | 帮助台区域中特别设置,它将在config.xml中定义一个默认值,该值指向email_templates.xml文件中的电子邮件模板 ID。
setTemplateVars期望传递给它的是一个数组或\Magento\Framework\Object的实例。我们传递整个$ticket对象给它,只是将其嵌套在票务键下,这样Ticket实体的属性,如标题,就可以在电子邮件 HTML 模板中作为{{var ticket.title}}可用。
当客户现在从我的账户 | 帮助台票务提交新票务表单时,HTTP POST请求将击中保存控制器动作类。如果前面的代码成功执行,票务将被保存到数据库,并且将重定向回我的账户 | 帮助台票务,在浏览器中显示票务成功创建的消息。
构建后端界面
到目前为止,我们一直在处理设置通用模块配置、电子邮件模板、前端路由、前端布局、块和模板。要完成模块要求,剩下的就是管理员界面,店主可以在那里查看提交的票务并将状态从开放更改为关闭。
以下是需要满足完全功能的管理员界面要求:
-
用于允许或拒绝访问票务列表的 ACL 资源
-
链接到列出票务的控制器动作的菜单项
-
映射到我们的管理员控制器的路由
-
映射到票务列表控制器动作的布局 XML
-
列出票务的控制器动作
-
在布局 XML 中定义的完整 XML 布局网格定义,包括网格、自定义列渲染器和自定义下拉筛选值
-
关闭票务并发送电子邮件给客户的控制器动作
链接访问控制列表和菜单
我们首先向先前定义的app/code/Foggyline/Helpdesk/etc/acl.xml文件添加一个新的 ACL 资源条目,作为Magento_Backend::admin资源的子项,如下所示:
<resource id="Magento_Customer::customer">
<resource id="Foggyline_Helpdesk::ticket_manage" title="Manage Helpdesk Tickets"/>
</resource>
单独来看,定义的资源条目不会做任何事情。这个资源将稍后用于菜单和控制器中。
链接到列出票务的控制器动作的菜单项在app/code/Foggyline/Helpdesk/etc/adminhtml/menu.xml文件中定义,如下所示:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation="urn:magento:module: Magento_Backend:etc/menu.xsd">
<menu>
<add id="Foggyline_Helpdesk::ticket_manage" title="Helpdesk Tickets" module="Foggyline_Helpdesk"
parent="Magento_Customer::customer" action="foggyline_helpdesk/ticket/index"
resource="Foggyline_Helpdesk::ticket_manage"/>
</menu>
</config>
我们使用 menu | add 元素在 Magento 管理区域下添加一个新的菜单项。一个项目在管理区域中的位置由属性 parent 定义,在我们的例子中意味着在现有的 客户 菜单下。如果省略了 parent,我们的项目将作为一个新项目出现在菜单上。title 属性值是我们将在菜单中看到的标签。id 属性必须唯一区分我们的菜单项与其他项。resource 属性引用 app/code/Foggyline/Helpdesk/etc/acl.xml 文件中定义的 ACL 资源。如果登录用户的角色不允许他使用 Foggyline_Helpdesk::ticket_manage 资源,则用户将无法看到菜单项。
创建路由、控制器和布局处理程序
现在我们添加一个路由,将其映射到我们的管理员控制器,通过定义 app/code/Foggyline/Helpdesk/etc/adminhtml/routes.xml 文件如下:
<?xml version="1.0"?>
<config xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc /routes.xsd">
<router id="admin">
<route id="foggyline_helpdesk" frontName="foggyline_helpdesk">
<module name="Foggyline_Helpdesk"/>
</route>
</router>
</config>
管理员路由定义几乎与前端路由定义相同,区别主要在于路由 ID 值,在这里等于管理员。
路由定义就绪后,我们现在可以定义我们的三个布局 XML 文件,位于 app/code/Foggyline/Helpdesk/view/adminhtml/layout 目录下,它们映射到票务列表控制器动作:
-
foggyline_helpdesk_ticket_grid.xml -
foggyline_helpdesk_ticket_grid_block.xml -
foggyline_helpdesk_ticket_index.xml
我们定义三个布局文件而不是一个单一的动作控制器的原因是因为我们在 Magento 管理区域中使用列表控制的方式。
foggyline_helpdesk_ticket_index.xml 文件的内容定义如下:
<?xml version="1.0" encoding="UTF-8"?>
<page xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout /etc/page_configuration.xsd">
<update handle="formkey"/>
<update handle="foggyline_helpdesk_ticket_grid_block"/>
<body>
<referenceContainer name="content">
<block class="Foggyline\Helpdesk\Block \Adminhtml\Ticket" name="admin.block.helpdesk.ticket.grid.container">
</block>
</referenceContainer>
</body>
</page>
指定了两个更新处理程序,一个用于获取 formkey,另一个用于获取 foggyline_helpdesk_ticket_grid_block。然后我们引用内容容器,并使用它定义一个新的 Foggyline\Helpdesk\Block\Adminhtml\Ticket 类块。
利用网格小部件
我们本可以使用 Magento\Backend\Block\Widget\Grid\Container 作为块类名。然而,鉴于我们需要一些额外的逻辑,比如移除 添加新项 按钮,我们选择了一个自定义类,然后扩展 \Magento\Backend\Block\Widget\Grid\Container 并添加所需的逻辑。
Foggyline\Helpdesk\Block\Adminhtml\Ticket 类定义在 app/code/Foggyline/Helpdesk/Block/Adminhtml/Ticket.php 文件中,如下所示:
<?php
namespace Foggyline\Helpdesk\Block\Adminhtml;
class Ticket extends \Magento\Backend\Block\Widget\Grid\Container
{
protected function _construct()
{
$this->_controller = 'adminhtml';
$this->_blockGroup = 'Foggyline_Helpdesk';
$this->_headerText = __('Tickets');
parent::_construct();
$this->removeButton('add');
}
}
在这里的 Ticket 块类中并没有发生太多的事情。最重要的是,我们扩展了 \Magento\Backend\Block\Widget\Grid\Container 并定义了 _controller 和 _blockGroup,因为它们充当了一种胶水,告诉我们的网格在哪里可以找到其他可能的块类。由于我们不会在管理员中提供 添加新票 功能,所以我们调用 removeButton 方法从网格容器中移除默认的 添加新项 按钮。
返回到我们的第二个 XML 布局文件,即 foggyline_helpdesk_ticket_grid.xml 文件,我们定义如下:
<?xml version="1.0"?>
<layout xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout /etc/layout_generic.xsd">
<update handle="formkey"/>
<update handle="foggyline_helpdesk_ticket_grid_block"/>
<container name="root">
<block class="Magento\Backend\Block\Widget\Grid\Container" name="admin.block.helpdesk.ticket.grid.container" template="Magento_Backend::widget/grid/container /empty.phtml"/>
</container>
</layout>
注意foggyline_helpdesk_ticket_grid.xml的内容几乎与foggyline_helpdesk_ticket_index.xml相同。两者之间的唯一区别是block类和模板属性的值。block类定义为Magento\Backend\Block\Widget\Grid\Container,而我们之前将其定义为Foggyline\Helpdesk\Block\Adminhtml\Ticket。
如果我们查看\Magento\Backend\Block\Widget\Grid\Container类的代码,我们可以看到以下属性被定义:
protected $_template = 'Magento_Backend::widget/grid/container.phtml';
如果我们查看vendor/magento/module-backend/view/adminhtml/templates/widget/grid/container.phtml和vendor/magento/module-backend/view/adminhtml/templates/widget/grid/container/empty.phtml文件的内容,差异可以很容易地被发现。container/empty.phtml只返回网格 HTML,而container.phtml返回按钮和网格 HTML。
由于foggyline_helpdesk_ticket_grid.xml将是排序和过滤期间 AJAX 加载网格列表的句柄,我们需要它在重新加载时只返回网格 HTML。
我们现在继续到 XML 布局文件中的第三个也是最大的文件,即app/code/Foggyline/Helpdesk/view/adminhtml/layout/foggyline_helpdesk_ticket_grid_block.xml文件。鉴于其大小,我们将将其分成两个代码块,在解释它们时逐一说明。
foggyline_helpdesk_ticket_grid_block.xml文件的第一部分,或初始内容,定义如下:
<?xml version="1.0" encoding="UTF-8"?>
<page xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout /etc/page_configuration.xsd">
<body>
<referenceBlock name= "admin.block.helpdesk.ticket.grid.container">
<block class="Magento\Backend\Block\Widget\Grid" name="admin.block.helpdesk.ticket.grid" as="grid">
<arguments>
<argument name="id" xsi:type="string"> ticketGrid</argument>
<argument name="dataSource" xsi:type="object"> Foggyline\Helpdesk\Model\ResourceModel \Ticket\Collection
</argument>
<argument name="default_sort" xsi:type="string">ticket_id</argument>
<argument name="default_dir" xsi:type="string">desc</argument>
<argument name="save_parameters_in_session" xsi:type="boolean">true</argument>
<argument name="use_ajax" xsi:type="boolean">true</argument>
</arguments>
<block class="Magento\Backend\Block \Widget\Grid\ColumnSet" name= "admin.block.helpdesk.ticket.grid.columnSet" as="grid.columnSet">
<!-- Column definitions here -->
</block>
</block>
</referenceBlock>
</body>
</page>
注意<!-- Column definitions here -->;我们很快就会回到那里。现在,让我们分析这里发生的事情。在主体元素之后,我们有一个对admin.block.helpdesk.ticket.grid.container的引用,这是一个在foggyline_helpdesk_ticket_grid.xml和foggyline_helpdesk_ticket_index.xml文件下定义的内容块子元素。在这个引用中,我们定义了另一个类为Magento\Backend\Block\Widget\Grid的块,传递了我们选择的名称和一个别名。此外,这个块有一个参数列表,并且有一个类为Magento\Backend\Block\Widget\Grid\ColumnSet的块作为子元素。
通过参数列表,我们指定了:
-
id: 设置为ticketGrid的值,我们可以在这里设置任何我们想要的值,理想情况下坚持使用公式{实体名称}。 -
dataSource: 设置为Foggyline\Helpdesk\Model\ResourceModel\Ticket\Collection的值,这是我们的Ticket实体资源类的名称。 -
default_sort: 设置为ticket_id的值,这是我们想要按其排序的Ticket实体属性。 -
default_dir: 设置为desc的值,表示排序的降序。此值与default_sort一起作为一个单一单元使用。 -
save_parameters_in_session: 设置为true,这可以通过以下示例最容易解释:如果我们对Ticket网格进行一些排序和过滤,然后转到管理区域的另一个部分,然后回到Ticket网格,如果此值设置为是,我们看到的网格将具有那些过滤和排序设置。 -
use_ajax: 设置为true时,当网格过滤和排序被触发时,一个 AJAX 加载器会启动并仅重新加载网格区域而不是整个页面。
在网格块参数列表之后,我们有网格列设置。这使我们到达了foggyline_helpdesk_ticket_grid_block.xml内容的第二部分。我们只需将<!-- Columns here -->注释替换为以下内容:
<block class="Magento\Backend\Block\Widget\Grid\Column" as="ticket_id">
<arguments>
<argument name="header" xsi:type="string" translate="true">ID</argument>
<argument name="type" xsi:type="string">number</argument>
<argument name="id" xsi:type="string">ticket_id</argument>
<argument name="index" xsi:type="string">ticket_id</argument>
</arguments>
</block>
<block class="Magento\Backend\Block\Widget\Grid\Column" as="title">
<arguments>
<argument name="header" xsi:type="string" translate="true">Title</argument>
<argument name="type" xsi:type="string">string</argument>
<argument name="id" xsi:type="string">title</argument>
<argument name="index" xsi:type="string">title</argument>
</arguments>
</block>
<block class="Magento\Backend\Block\Widget\Grid\Column" | as="severity">
<arguments>
<argument name="header" xsi:type="string" translate="true">Severity</argument>
<argument name="index" xsi:type="string">severity</argument>
<argument name="type" xsi:type="string">options</argument>
<argument name="options" xsi:type="options" model="Foggyline\Helpdesk\Model\Ticket\Grid\Severity"/>
<argument name="renderer" xsi:type="string"> Foggyline\Helpdesk\Block\Adminhtml\Ticket\Grid\Renderer \Severity
</argument>
<argument name="header_css_class" xsi:type="string"> col-form_id</argument>
<argument name="column_css_class" xsi:type="string"> col-form_id</argument>
</arguments>
</block>
<block class="Magento\Backend\Block\Widget\Grid\Column" as="status">
<arguments>
<argument name="header" xsi:type="string" translate="true">Status</argument>
<argument name="index" xsi:type="string">status</argument>
<argument name="type" xsi:type="string">options</argument>
<argument name="options" xsi:type="options"
model="Foggyline\Helpdesk\Model\Ticket \Grid\Status"/>
<argument name="renderer" xsi:type="string"> Foggyline\Helpdesk\Block\Adminhtml\Ticket\Grid \Renderer\Status
</argument>
<argument name="header_css_class" xsi:type="string"> col-form_id</argument>
<argument name="column_css_class" xsi:type="string"> col-form_id</argument>
</arguments>
</block>
<block class="Magento\Backend\Block\Widget\Grid\Column" as="action">
<arguments>
<argument name="id" xsi:type="string">action</argument>
<argument name="header" xsi:type="string" translate="true">Action</argument>
<argument name="type" xsi:type="string">action</argument>
<argument name="getter" xsi:type="string">getId</argument>
<argument name="filter" xsi:type="boolean">false</argument>
<argument name="sortable" xsi:type="boolean">false</argument>
<argument name="actions" xsi:type="array">
<item name="view_action" xsi:type="array">
<item name="caption" xsi:type="string" translate="true">Close</item>
<item name="url" xsi:type="array">
<item name="base" xsi:type="string">*/*/close</item>
</item>
<item name="field" xsi:type="string">id</item>
</item>
</argument>
<argument name="header_css_class" xsi:type="string"> col-actions</argument>
<argument name="column_css_class" xsi:type="string"> col-actions</argument>
</arguments>
</block>
与网格类似,列定义也有定义其外观和行为的参数:
-
header: 必需的,我们希望在列顶部看到的标签值。 -
type: 必需的,可以是以下任何一种:date、datetime、text、longtext、options、store、number、currency、skip-list、wrapline和country。 -
id: 必需的,一个唯一值,用于标识我们的列,最好与实体属性名称匹配。 -
index: 必需的,数据库列名。 -
options: 可选的,如果我们使用像options这样的类型,那么对于options参数,我们需要指定一个类,例如Foggyline\Helpdesk\Model\Ticket\Grid\Severity,该类实现了\Magento\Framework\Option\ArrayInterface,这意味着它提供了toOptionArray方法,然后在网格渲染期间填充选项值。 -
renderer: 可选的,因为我们的Ticket实体将严重性和状态作为整数值存储在数据库中,列会渲染这些整数值,这实际上并不太有用。我们希望将这些整数值转换为标签。为了做到这一点,我们需要重写单个表格单元的渲染部分,我们通过renderer参数来完成。传递给它的值,Foggyline\Helpdesk\Block\Adminhtml\Ticket\Grid\Renderer\Severity,需要是一个扩展\Magento\Backend\Block\Widget\Grid\Column\Renderer\AbstractRenderer并对其render方法进行自己实现的类。 -
header_css_class: 可选的,如果我们希望指定一个自定义标题类。 -
column_css_class: 可选的,如果我们希望指定一个自定义列类。
创建网格列渲染器
定义在app/code/Foggyline/Helpdesk/Block/Adminhtml/Ticket/Grid/Renderer/Severity.php文件中的Foggyline\Helpdesk\Block\Adminhtml\Ticket\Grid\Renderer\Severity类如下:
<?php
namespace Foggyline\Helpdesk\Block\Adminhtml\Ticket\Grid\Renderer;
class Severity extends \Magento\Backend\Block\Widget\Grid \Column\Renderer\AbstractRenderer
{
protected $ticketFactory;
public function __construct(
\Magento\Backend\Block\Context $context,
\Foggyline\Helpdesk\Model\TicketFactory $ticketFactory,
array $data = []
)
{
parent::__construct($context, $data);
$this->ticketFactory = $ticketFactory;
}
public function render(\Magento\Framework\DataObject $row)
{
$ticket = $this->ticketFactory->create()->load($row-> getId());
if ($ticket && $ticket->getId()) {
return $ticket->getSeverityAsLabel();
}
return '';
}
}
在这里,我们将票据工厂的实例传递给构造函数,然后在render方法中使用该实例根据从当前行获取的 ID 值加载票据。鉴于$row->getId()返回票据的 ID,这是一种很好的方式来重新加载整个票据实体,然后通过使用$ticket->getSeverityAsLabel()从票据模型中获取完整的标签。从这个方法返回的任何字符串都将在网格行下方显示。
在foggyline_helpdesk_ticket_grid_block.xml文件中引用的另一个渲染器类是Foggyline\Helpdesk\Block\Adminhtml\Ticket\Grid\Renderer\Status,我们将其内容定义在app/code/Foggyline/Helpdesk/Block/Adminhtml/Ticket/Grid/Renderer/Status.php文件中如下:
<?php
namespace Foggyline\Helpdesk\Block\Adminhtml\Ticket\Grid\Renderer;
class Status extends \Magento\Backend\Block\Widget\Grid\Column \Renderer\AbstractRenderer
{
protected $ticketFactory;
public function __construct(
\Magento\Backend\Block\Context $context,
\Foggyline\Helpdesk\Model\TicketFactory $ticketFactory,
array $data = []
)
{
parent::__construct($context, $data);
$this->ticketFactory = $ticketFactory;
}
public function render(\Magento\Framework\DataObject $row)
{
$ticket = $this->ticketFactory->create()->load($row-> getId());
if ($ticket && $ticket->getId()) {
return $ticket->getStatusAsLabel();
}
return '';
}
}
由于它也用于渲染器,Status类的内文几乎与Severity类的内文相同。我们通过构造函数传递票务工厂对象,以便在render方法内部使用。然后我们使用票务工厂和从$row对象获取的 ID 值加载Ticket实体。因此,该列将包含状态标签值而不是其整数值。
创建网格列选项
除了引用渲染器类之外,我们的foggyline_helpdesk_ticket_grid_block.xml文件还引用了Severity字段的options类。
我们在app/code/Foggyline/Helpdesk/Model/Ticket/Grid/Severity.php文件下定义了Foggyline\Helpdesk\Model\Ticket\Grid\Severity选项类如下:
<?php
namespace Foggyline\Helpdesk\Model\Ticket\Grid;
class Severity implements \Magento\Framework\Option\ArrayInterface
{
public function toOptionArray()
{
return \Foggyline\Helpdesk\Model \Ticket::getSeveritiesOptionArray();
}
}
来自 XML 布局的options值指的是一个必须实现toOptionArray方法的类,它返回一个数组,例如以下示例:
return [
['value'=>'theValue1', 'theLabel1'],
['value'=>'theValue2', 'theLabel2'],
];
我们的Severity类简单地调用我们在Ticket类上定义的静态方法getSeveritiesOptionArray,并传递这些值。
创建控制器动作
到目前为止,我们已经定义了菜单项、ACL 资源、XML 布局、块、options类和renderer类。要连接所有这些,我们需要控制器。我们需要三个控制器动作(Index、Grid和Close),它们都扩展自同一个管理Ticket控制器。
我们在app/code/Foggyline/Helpdesk/Controller/Adminhtml/Ticket.php文件下定义了管理Ticket控制器如下:
<?php
namespace Foggyline\Helpdesk\Controller\Adminhtml;
class Ticket extends \Magento\Backend\App\Action
{
protected $resultPageFactory;
protected $resultForwardFactory;
protected $resultRedirectFactory;
public function __construct(
\Magento\Backend\App\Action\Context $context,
\Magento\Framework\View\Result\PageFactory $resultPageFactory,
\Magento\Backend\Model\View\Result\ForwardFactory $resultForwardFactory
)
{
$this->resultPageFactory = $resultPageFactory;
$this->resultForwardFactory = $resultForwardFactory;
$this->resultRedirectFactory = $context-> getResultRedirectFactory();
parent::__construct($context);
}
protected function _isAllowed()
{
return $this->_authorization-> isAllowed('Foggyline_Helpdesk::ticket_manage');
}
protected function _initAction()
{
$this->_view->loadLayout();
$this->_setActiveMenu(
'Foggyline_Helpdesk::ticket_manage'
)->_addBreadcrumb(
__('Helpdesk'),
__('Tickets')
);
return $this;
}
}
这里有几个需要注意的事项。$this->resultPageFactory、$this->resultForwardFactory和$this->resultRedirectFactory是用于子类(Index、Grid和Close)的对象,因此我们不需要在每个子类中单独初始化它们。
_isAllowed()方法在每次我们有一个自定义定义的控制器或控制器动作,并希望对其进行自定义 ACL 资源检查时非常重要。在这里,我们是在\Magento\Framework\AuthorizationInterface类型的对象($this->_authorization)上调用isAllowed方法。传递给isAllowed方法调用的参数应该是我们自定义 ACL 资源的 ID 值。
然后我们有_initAction方法,这个方法用于设置子类之间共享的逻辑,通常包括加载整个布局、设置活动菜单标志和添加面包屑。
在前进的过程中,我们在app/code/Foggyline/Helpdesk/Controller/Adminhtml/Ticket/Index.php文件中定义了Index控制器动作如下:
<?php
namespace Foggyline\Helpdesk\Controller\Adminhtml\Ticket;
class Index extends \Foggyline\Helpdesk\Controller\Adminhtml\Ticket
{
public function execute()
{
if ($this->getRequest()->getQuery('ajax')) {
$resultForward = $this->resultForwardFactory-> create();
$resultForward->forward('grid');
return $resultForward;
}
$resultPage = $this->resultPageFactory->create();
$resultPage-> setActiveMenu('Foggyline_Helpdesk::ticket_manage');
$resultPage->getConfig()->getTitle()-> prepend(__('Tickets'));
$resultPage->addBreadcrumb(__('Tickets'), __('Tickets'));
$resultPage->addBreadcrumb(__('Manage Tickets'), __('Manage Tickets'));
return $resultPage;
}
}
控制器动作在其自己的类中执行,在 execute 方法中。我们的 execute 方法首先检查传入的请求是否包含 AJAX 参数。如果有 AJAX 参数,请求将被转发到同一控制器的 Grid 动作。
如果没有 AJAX 控制器,我们只需创建 \Magento\Framework\View\Result\PageFactory 对象的实例,并在其中设置标题、活动菜单项和面包屑。
在这个阶段,一个逻辑问题可能是这一切是如何工作的,我们能在哪里看到它。如果我们登录到 Magento 管理区域,在 客户 菜单下,我们应该能够看到 帮助台工单 菜单项。这个项目,在 app/code/Foggyline/Helpdesk/etc/adminhtml/menu.xml 中预先定义,表示菜单 action 属性等于 foggyline_helpdesk/ticket/index,这基本上等同于我们 Ticket 控制器的 Index 动作。
一旦我们点击 帮助台工单 链接,Magento 将击中其 Ticket 控制器中的 Index 动作,并尝试找到具有匹配路由 {id}+{controller name }+{controller action name }+{xml file extension } 的 XML 文件,在我们的例子中这等同于 {foggyline_helpdesk}+{ticket}+{index}+{.xml}。
在这一点上,我们应该能够看到屏幕,如下面的截图所示:

然而,如果我们现在尝试使用排序或过滤,我们会得到一个损坏的布局。这是因为基于在 foggyline_helpdesk_ticket_grid_block.xml 文件下定义的参数,我们缺少 Grid 控制器动作。当我们使用排序或过滤时,AJAX 请求击中 Index 控制器并请求转发到我们尚未定义的 Grid 动作。
我们现在在 app/code/Foggyline/Helpdesk/Controller/Adminhtml/Ticket/Grid.php 文件中定义 Grid 动作如下:
<?php
namespace Foggyline\Helpdesk\Controller\Adminhtml\Ticket;
class Grid extends \Foggyline\Helpdesk\Controller\Adminhtml\Ticket
{
public function execute()
{
$this->_view->loadLayout(false);
$this->_view->renderLayout();
}
}
Grid 控制器动作类中只有一个 execute 方法,这是预期的。execute 方法中的代码简单地调用 loadLayout(false) 方法以防止整个布局加载,使其只加载在 foggyline_helpdesk_ticket_grid.xml 文件下定义的部分。这有效地将网格 HTML 返回给 AJAX,从而刷新页面上的网格。
最后,我们需要处理我们在网格中看到的 关闭 动作链接。这个链接在 foggyline_helpdesk_ticket_grid_block.xml 文件中的列定义部分定义,指向 */*/close,这表示“从当前 URL 相对的路由器 / 从当前 URL 相对的控制器 / 关闭动作”,这进一步等同于我们的 Ticket 控制器的 Close 动作。
我们在 app/code/Foggyline/Helpdesk/Controller/Adminhtml/Ticket/Close.php 文件下定义 Close 控制器动作如下:
<?php
namespace Foggyline\Helpdesk\Controller\Adminhtml\Ticket;
class Close extends \Foggyline\Helpdesk\Controller\Adminhtml\Ticket
{
protected $ticketFactory;
protected $customerRepository;
protected $transportBuilder;
protected $inlineTranslation;
protected $scopeConfig;
protected $storeManager;
public function __construct(
\Magento\Backend\App\Action\Context $context,
\Magento\Framework\View\Result\PageFactory $resultPageFactory,
\Magento\Backend\Model\View\Result\ForwardFactory $resultForwardFactory,
\Foggyline\Helpdesk\Model\TicketFactory $ticketFactory,
\Magento\Customer\Api\CustomerRepositoryInterface $customerRepository,
\Magento\Framework\Mail\Template\TransportBuilder $transportBuilder,
\Magento\Framework\Translate\Inline\StateInterface $inlineTranslation,
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
\Magento\Store\Model\StoreManagerInterface $storeManager
)
{
$this->ticketFactory = $ticketFactory;
$this->customerRepository = $customerRepository;
$this->transportBuilder = $transportBuilder;
$this->inlineTranslation = $inlineTranslation;
$this->scopeConfig = $scopeConfig;
$this->storeManager = $storeManager;
parent::__construct($context, $resultPageFactory, $resultForwardFactory);
}
public function execute()
{
$ticketId = $this->getRequest()->getParam('id');
$ticket = $this->ticketFactory->create()->load($ticketId);
if ($ticket && $ticket->getId()) {
try {
$ticket->setStatus(\Foggyline \Helpdesk\Model\Ticket::STATUS_CLOSED);
$ticket->save();
$this->messageManager->addSuccess(__('Ticket successfully closed.'));
/* Send email to customer */
$customer = $this->customerRepository-> getById($ticket->getCustomerId());
$storeScope = \Magento\Store\Model\ ScopeInterface::SCOPE_STORE;
$transport = $this->transportBuilder
->setTemplateIdentifier($this->scopeConfig-> getValue('foggyline_helpdesk/email_template /customer', $storeScope))
->setTemplateOptions(
[
'area' => \Magento\Framework\App\Area::AREA_ADMINHTML,
'store' => $this->storeManager- >getStore()->getId(),
]
)
->setTemplateVars([
'ticket' => $ticket,
'customer_name' => $customer-> getFirstname()
])
->setFrom([
'name' => $this->scopeConfig-> getValue('trans_email/ident_general /name', $storeScope),
'email' => $this->scopeConfig-> getValue('trans_email/ident_general /email', $storeScope)
])
->addTo($customer->getEmail())
->getTransport();
$transport->sendMessage();
$this->inlineTranslation->resume();
$this->messageManager->addSuccess(__('Customer notified via email.'));
} catch (Exception $e) {
$this->messageManager->addError(__('Error with closing ticket action.'));
}
}
$resultRedirect = $this->resultRedirectFactory->create();
$resultRedirect->setPath('*/*/index');
return $resultRedirect;
}
}
Close动作控制器有两个不同的角色要履行。一个是更改票据状态;另一个是使用适当的电子邮件模板向客户发送电子邮件。类构造函数传递了许多参数,它们都是我们将要处理的对象的实例。
在执行动作中,我们首先检查id参数的存在,然后根据提供的 ID 值尝试通过票据工厂加载一个Ticket实体。如果票据存在,我们将它的状态标签设置为\Foggyline\Helpdesk\Model\Ticket::STATUS_CLOSED并保存它。
在保存票据之后是发送电子邮件的代码,这与我们在客户新票据保存操作中已经看到的是非常相似的。不同之处在于这次电子邮件是从管理员用户发送给客户的。我们将模板 ID 设置为路径foggyline_helpdesk/email_template/customer上的配置值。这次setTemplateVars方法传递给成员数组的是ticket和customer_name,因为它们都在电子邮件模板中使用。setFrom方法传递了通用商店用户名和电子邮件,然后调用transport对象的sendMessage方法。
最后,使用resultRedirectFactory对象,用户被重定向回票据网格。
通过这样,我们最终完成了模块的功能需求。
虽然我们已经完成了模块的功能需求,但对于我们开发者来说,剩下的工作就是编写测试。有几种测试类型,例如单元测试、功能测试、集成测试等等。为了保持简单,在本章中,我们只将涵盖单个模型类的单元测试。
创建单元测试
本章假设我们已经配置了 PHPUnit 并在命令行上可用。如果不是这种情况,可以使用phpunit.de/网站上的说明安装 PHPUnit。
要使用 PHPUnit 测试框架构建和运行测试,我们需要通过 XML 文件定义测试位置和其他配置选项。Magento 在dev/tests/unit/phpunit.xml.dist下定义了这个 XML 配置文件。让我们在dev/tests/unit/phpunit-foggyline-helpdesk.xml下创建一个该文件的副本,并进行以下调整:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1 /phpunit.xsd"
colors="true"
bootstrap="./framework/bootstrap.php"
>
<testsuite name="Foggyline_Helpdesk - Unit Tests">
<directory suffix="Test.php"> ../../../app/code/Foggyline/Helpdesk/Test/Unit </directory>
</testsuite>
<php>
<ini name="date.timezone" value="Europe/Zagreb"/>
<ini name="xdebug.max_nesting_level" value="200"/>
</php>
<filter>
<whitelist addUncoveredFilesFromWhiteList="true">
<directory suffix=".php"> ../../../app/code/Foggyline/Helpdesk/*</directory>
<exclude>
<directory> ../../../app/code/Foggyline/Form/Helpdesk </directory>
</exclude>
</whitelist>
</filter>
<logging>
<log type="coverage-html" target="coverage_dir/Foggyline_Helpdesk/test- reports/coverage" charset="UTF-8" yui="true" highlight="true"/>
</logging>
</phpunit>
小贴士
我们为我们的模块单独创建了一个特殊的 XML 配置文件,因为我们想快速运行模块中包含的一些测试,而不是整个 Magento 的app/code文件夹中的所有测试。
由于编写单元测试的实际艺术超出了本书的范围,并且为这个简单的模块编写 100%代码覆盖率的完整单元测试至少需要额外十几页,我们将只编写一个测试,一个覆盖Ticket实体模型类的测试。
我们在app/code/Foggyline/Helpdesk/Test/Unit/Model/TicketTest.php文件下定义了我们的Ticket实体模型类测试,如下所示:
<?php
namespace Foggyline\Helpdesk\Test\Unit\Model;
class TicketTest extends \PHPUnit_Framework_TestCase
{
protected $objectManager;
protected $ticket;
public function setUp()
{
$this->objectManager = new \Magento\Framework\TestFramework\Unit\Helper \ObjectManager($this);
$this->ticket = $this->objectManager-> getObject('Foggyline\Helpdesk\Model\Ticket');
}
public function testGetSeveritiesOptionArray()
{
$this-> assertNotEmpty(\Foggyline \Helpdesk\Model\Ticket::getSeveritiesOptionArray());
}
public function testGetStatusesOptionArray()
{
$this->assertNotEmpty(\Foggyline \Helpdesk\Model\Ticket::getStatusesOptionArray());
}
public function testGetStatusAsLabel()
{
$this->ticket->setStatus(\Foggyline\Helpdesk \Model\Ticket::STATUS_CLOSED);
$this->assertEquals(
\Foggyline\Helpdesk\Model\Ticket::$statusesOptions [\Foggyline\Helpdesk\Model\Ticket::STATUS_CLOSED],
$this->ticket->getStatusAsLabel()
);
}
public function testGetSeverityAsLabel()
{
$this->ticket->setSeverity(\Foggyline \Helpdesk\Model\Ticket::SEVERITY_MEDIUM);
$this->assertEquals(
\Foggyline\Helpdesk\Model\Ticket::$severitiesOptions [\Foggyline\Helpdesk\Model\Ticket::SEVERITY_MEDIUM],
$this->ticket->getSeverityAsLabel()
);
}
}
测试文件的定位应该映射被测试文件的定位。测试文件的命名也应该遵循被测试文件的命名,并在其后面附加 Test 后缀。这意味着如果我们的 Ticket 模型位于模块 Model/Ticket.php 文件下,那么我们的测试应该位于 Test/Unit/TicketTest.php。
我们的 Foggyline\Helpdesk\Test\Unit\Model\TicketTest 扩展了 \PHPUnit_Framework_TestCase 类。我们需要定义一个 setUp 方法,它类似于构造函数,其中我们设置变量和所有需要初始化的内容。
使用 Magento ObjectManager,我们实例化 Ticket 模型,然后在测试方法中使用它。实际的测试方法遵循一个简单的命名模式,其中 Ticket 模型的方法名称与 TicketTest 类中的 {*test}+{方法名称}* 相匹配。
我们定义了四个测试方法:testGetSeveritiesOptionArray、testGetStatusesOptionArray、testGetStatusAsLabel 和 testGetSeverityAsLabel。在测试方法中,我们只使用了 PHPUnit 测试框架库中的 assertEquals 和 assertNotEmpty 方法来进行基本检查。
现在,我们可以打开一个控制台,将目录更改为我们的 Magento 安装目录,并执行以下命令:
phpunit -c dev/tests/unit/phpunit-foggyline-helpdesk.xml
命令执行后,控制台应该显示如下输出:
PHPUnit 4.7.6 by Sebastian Bergmann and contributors.
....
Time: 528 ms, Memory: 11.50Mb
OK (4 tests, 4 assertions)
Generating code coverage report in HTML format ... done
回顾我们的 dev/tests/unit/phpunit-foggyline-helpdesk.xml 文件,在 phpunit > logging > log 元素的 target 属性下,我们可以看到测试报告被输出到相对于 XML 文件的 coverage_dir/Foggyline_Helpdesk/test-reports/coverage 文件夹中。
如果我们打开 dev/tests/unit/coverage_dir/Foggyline_Helpdesk/test-reports/coverage/ 文件夹,我们应该会看到那里生成了很多文件,如下面的截图所示:

在浏览器中打开 index.html 文件应该会给我们一个如下截图所示的页面:

我们可以看到代码覆盖率报告显示我们的 Model 文件夹的行和方法为 60%,其余部分为 0%。这是因为我们只为 Ticket 实体模型类编写了测试,而其余部分尚未经过测试。
摘要
本章提供了一个完整的、逐步的指南,用于编写一个简单但功能齐全的 Magento 模块。从功能的角度来看似乎很简单,但我们可以看到模块代码在多个 PHP、XML 和 PHMTL 文件中分布得相当分散。
通过这个简单的模块,我们涵盖了 Magento 平台的各种部分,包括路由、ACL、控制器、块、XML 布局、网格、控制器操作、模型、资源、集合、安装脚本、与会话的交互、电子邮件模板、电子邮件传输和布局对象。
最后,我们为我们的模型编写了一些简单的单元测试。尽管通常的做法是为所有 PHP 代码编写单元测试,但我们选择了简短版本,否则我们需要更多页面来涵盖所有内容。
完整的模块代码可在以下链接获取:github.com/ajzele/B05032-Foggyline_Helpdesk.
作为最后一章,让我们简要回顾一下全书所学的内容。我们的旅程从掌握 Magento 平台架构开始,我们对它背后的技术栈有了深刻的洞察。然后我们转向环境管理。尽管这可能看起来像是事物的错误顺序,但我们选择这一步是为了快速为我们开发做好准备。然后我们探讨了编程概念和约定,这为实际动手开发奠定了基础。通过模型、资源、集合类和索引器展示了实体持久化的细节。我们还进一步介绍了依赖注入和拦截的重要性及其实际细节。后端和前端相关开发各自在两章中进行了介绍,概述了为我们的 Magento 平台进行定制时最常见的一些内容。然后我们深入研究了 Web API 的细节,展示了如何进行认证的 API 调用甚至定义我们自己的 API。在这个过程中,我们还覆盖了一些主要的功能区域,如客户、报告、导入导出、购物车等。测试和 QA 占据了相当大的篇幅,因为我们简要地介绍了所有可用的测试形式。最后,我们利用所学知识构建了一个功能齐全的模块。
虽然我们在旅途中已经走过了相当长的路,但这仅仅是一个第一步。鉴于其庞大的代码库、多样的技术栈和功能列表,Magento 并不是一个容易掌握的平台。希望这本书能提供足够的动力,让我们进一步深入,成为真正的 Magento 专家。


































浙公网安备 33010602011771号