PHP7-模块化编程(全)

PHP7 模块化编程(全)

原文:zh.annas-archive.org/md5/ff0acc039cf922de0886cd9283ec3d9f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

构建模块化应用程序是一项具有挑战性的任务。它涉及广泛的知识领域,从设计模式和原则到所选择技术栈的方方面面。PHP 生态系统有相当多的工具、库、框架和平台,可以帮助我们实现模块化应用程序开发的目标。

PHP 7 带来了许多改进,可以进一步帮助实现这一目标。我们将从这些改进中开始我们的旅程。在本书结束时,我们的最终交付物将是一个由 Symfony 框架构建的模块化网络商店应用程序。

本书内容

第一章,生态系统概述,对 PHP 生态系统的当前状态进行了简要介绍。它探讨了 PHP 7 的最新功能,其中一些功能为模块化开发中的新概念打开了大门。此外,本章还概述了流行的 PHP 框架。

第二章,GoF 设计模式,描述了软件设计中常见问题的重复解决方案。为以下每种模式提供了实际的 PHP 示例:创建模式类型、结构模式和行为模式。

第三章,SOLID 设计原则,深入探讨了面向对象编程和设计的五个基本原则,这些原则使用 SOLID(单一责任、开闭原则、里氏替换、接口隔离和依赖反转)的首字母缩写。它提供了实际示例,并解释了这些原则在模块化开发中的重要性。

第四章,模块化网络商店应用的需求规范,指导读者定义整体应用程序需求的过程。它从定义实际应用程序功能需求开始,并逐步进行技术栈选择。

第五章,Symfony 概述,对 Symfony 作为框架、一组工具和开发方法论进行了高层次的概述。它侧重于我们构建模块化应用程序所需的构建模块。

第六章,构建核心模块,指导您通过基于 Symfony 捆绑包设置核心模块。然后,核心模块用于为其他模块设置结构和依赖关系。

第七章,构建目录模块,指导我们通过构建一个与网络商店仅目录功能集相匹配的自给模块。它向我们展示了如何设置与模块功能相关的实体,以及如何使用现有框架管理这些实体及其交互。

第八章,构建客户模块,指导我们通过构建一个与网络商店客户相关的功能集相匹配的自给模块。它向我们展示了如何设置与模块功能相关的实体,以及如何使用现有框架管理这些实体及其交互。它进一步向我们展示了如何创建注册和登录系统。

第九章,构建支付模块,指导我们通过构建一个与网络商店支付相关的功能集相匹配的自给模块。它向我们展示了如何与第三方支付提供商集成。它进一步向我们展示了如何将支付提供商作为服务提供给其他模块使用。

第十章,“构建发货模块”,指导我们构建一个自给自足的模块,与网店的发货相关功能相匹配。它向我们展示了如何定义几个扁平方法,根据不同的购物车产品属性产生不同的发货定价。它进一步向我们展示了如何将发货方法公开为其他模块使用的服务。

第十一章,“构建销售模块”,指导我们构建一个自给自足的模块,与网店仅销售相关的功能集相匹配。它向我们展示了如何设置与模块功能相关的购物车、购物车项目、订单和订单项目实体,以及如何使用现有框架管理这些实体及其交互。

第十二章,“集成和分发模块”,将前几章中构建的所有模块集成到一个单一的功能应用程序中。接下来,它指导我们通过现代 PHP 模块分发技术。这些技术包括 Git 和 Composer,间接包括 GitHub 和 Packagist。

您需要什么

为了成功运行本书提供的所有示例,您需要自己的 Web 服务器或第三方 Web 托管解决方案。高级技术栈包括 PHP 7.0 或更高版本,Apache/Nginx 和 MySQL。

Symfony 框架本身带有详细的系统要求列表,可以在symfony.com/doc/current/reference/requirements.html找到。

本书假设读者熟悉设置完整的开发环境。

本书的受众

本书主要面向中级 PHP 开发人员,他们对模块化编程几乎没有了解,希望了解设计模式和原则,以更好地利用现有框架进行模块化应用程序开发。

本书开发的模块化网店应用程序使用 Symfony 框架。但是,不需要假设或要求对 Symfony 框架有任何先前的了解。

约定

在本书中,您会发现一些区分不同类型信息的文本样式。以下是一些这些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们可以通过使用include指令包含其他上下文。”

代码块设置如下:

function hint (int $A, float $B, string $C, bool $D)
{
    var_dump($A, $B, $C, $D);
}

任何命令行输入或输出都以以下形式编写:

**sudo curl -LsS https://symfony.com/installer -o /usr/local/bin/symfony**
**sudo chmod a+x /usr/local/bin/symfony**

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会出现在文本中,如:“单击下一步按钮将您移至下一个屏幕。”

注意

警告或重要说明会出现在这样的框中。

提示

技巧和窍门会以这种方式出现。

第一章:生态系统概述

自 PHP 诞生以来已经过去了二十多年。最初由 Rasmus Lerdorf 于 1994 年创建,PHP 首字母缩略词最初代表个人主页。当时,PHP 只是用于支持简单网页的几个公共网关接口CGI)程序。

尽管 PHP 并不打算成为一种新的编程语言,但这个想法却得到了认可。在 90 年代末,Zend Technologies 的联合创始人 Zeev Suraski 和 Andi Gutmans 通过重写整个解析器继续了 PHP 的工作,从而诞生了 PHP 3。PHP 语言名称首字母缩略词现在代表PHP:超文本预处理器

PHP 将自己定位在世界前十大编程语言之中。根据软件质量公司 TIOBE 的数据,它目前排名第六。特别是自 2004 年 7 月发布 PHP 5 以来的最后十年中,PHP 一直被认为是构建 Web 应用程序的热门解决方案。

尽管 PHP 仍然表现为一种脚本语言,但可以肯定的是,自 PHP 5 以来,它已经远远超出了这一范畴。像 WordPress、Drupal、Magento 和 PrestaShop 等世界上最受欢迎的平台都是用 PHP 构建的。正是这些项目进一步提高了 PHP 的受欢迎程度。其中一些项目通过实现其他编程语言(如 Java、C#)和它们的框架中找到的复杂 OOP(面向对象编程)设计模式来拓展 PHP 的边界。

尽管 PHP 5 具有不错的面向对象编程(OOP)支持,但仍有许多事情有待实现。PHP 6 的工作计划是为 PHP Unicode 字符串提供更多支持。遗憾的是,它的开发停滞不前,PHP 6 在 2010 年被取消了。

同年,Facebook 宣布了他们的 HipHop 编译器。他们的编译器将 PHP 代码转换为 C++代码。然后通过 C++编译器将 C++代码进一步编译成本机代码。这个概念为 PHP 带来了重大的性能改进。然而,这种方法并不是很实用,因为将 PHP 脚本编译成本机代码太耗时。

不久之后,Zend Technologies 首席性能工程师 Dmitry Stogov 宣布了一个名为PHPNG的项目,这成为了下一个 PHP 版本 PHP 7 的基础。

2015 年 12 月,PHP 7 发布,带来了许多改进和新功能:

  • Zend Engine 的新版本

  • 性能提升(比 PHP 5.6 快两倍)

  • 显著减少的内存使用

  • 抽象语法树

  • 一致的 64 位支持

  • 改进的异常层次结构

  • 许多致命错误转换为异常

  • 安全随机数生成器

  • 删除了旧的和不受支持的 SAPI 和扩展

  • 空合并运算符

  • 返回和标量类型声明

  • 匿名类

  • 零成本断言

在本章中,我们将讨论以下主题:

  • 为 PHP 7 做好准备

  • 框架

为 PHP 7 做好准备

PHP 7 带来了一系列重大变化。这些变化影响了 PHP 解释器以及各种扩展和库。尽管大多数 PHP 5 代码在 PHP 7 解释器上仍将继续正常运行,但了解新提供的功能是值得的。

接下来,我们将研究其中一些功能及其提供的好处。

标量类型提示

标量类型提示在 PHP 中并不是一个全新的功能。随着 PHP 5.0 的引入,我们获得了对类和接口的类型提示的能力。PHP 5.1 通过引入数组类型提示来扩展了这一功能。随后,PHP 5.4 还额外增加了对可调用类型的提示。最后,PHP 7 引入了标量类型提示。将类型提示扩展到标量使得这可能是 PHP 7 中添加的最令人兴奋的功能之一。

现在可用的标量类型提示如下:

  • string:字符串(例如,hellofoobar

  • int:整数(例如,123

  • float:浮点数(例如,1.22.45.6

  • bool:布尔值(例如,truefalse

默认情况下,PHP 7 以弱类型检查模式工作,并将尝试转换为指定类型而不投诉。我们可以使用strict_typesdeclare()指令来控制这种模式。

declare(strict_types=1);指令必须是文件中的第一条语句,否则会生成编译器错误。它只影响它所在的特定文件,并不影响其他包含的文件。该指令完全是编译时的,不能在运行时控制。

declare(strict_types=0); //weak type-checking
declare(strict_types=1); // strict type-checking

假设以下是一个接受标量类型提示的简单函数。

function hint (int $A, float $B, string $C, bool $D)
{
    var_dump($A, $B, $C, $D);
}

新标量类型声明的弱类型检查规则大多与扩展和内置 PHP 函数的规则相同。由于这种自动转换,当将数据传递给函数时,我们可能会不知不觉地丢失数据。一个简单的例子是将浮点数传递给需要整数的函数;在这种情况下,转换将简单地去掉小数部分。

假设弱类型检查是打开的,默认情况下,可以观察到以下情况:

hint(2, 4.6, 'false', true); 
/* int(2) float(4.6) string(5) "false" bool(true) */

hint(2.4, 4, true, 8);
/* int(2) float(4) string(1) "1" bool(true) */

我们可以看到第一个函数调用按照提示传递参数。第二个函数调用并没有传递确切类型的参数,但函数仍然能够执行,因为参数经过了转换。

假设弱类型检查关闭,通过使用declare(strict_types=1);指令,可以观察到以下情况:

hint(2.4, 4, true, 8);

Fatal error: Uncaught TypeError: Argument 1 passed to hint() must be of the type integer, float given, called in php7.php on line 16 and defined in php7.php:8 Stack trace: #0 php7.php(16): hint(2.4, 4, true, 8) #1 {main} thrown in php7.php on line 8

函数调用在第一个参数上中断,导致\TypeError异常。strict_types=1指令不允许任何类型转换。参数必须与函数定义提示的类型相同。

返回类型提示

除了类型提示,我们还可以对返回进行类型提示。所有可以应用于函数参数的类型提示都可以应用于函数返回值。这也适用于弱类型检查规则。

要添加返回类型提示,只需在参数列表后面加上冒号和返回类型,如下例所示:

function divide(int $A, int $B) : int
{
    return $A / $B;
}

前面的函数定义表示divide函数期望两个int类型的参数,并且应该返回一个int类型的参数。

假设弱类型检查是打开的,默认情况下,可以观察到以下情况:

var_dump(divide(10, 2)); // int(5)
var_dump(divide(10, 3)); // int(3)

虽然divide(10, 3)的实际结果应该是一个浮点数,但返回类型提示会触发转换为整数。

假设弱类型检查关闭,通过使用declare(strict_types=1);指令,可以观察到以下情况:

int(5) 
Fatal error: Uncaught TypeError: Return value of divide() must be of the type integer, float returned in php7.php:10 Stack trace: #0php7.php(14): divide(10, 3) #1 {main} thrown in php7.php on line 10

在放置strict_types=1指令的情况下,divide(10, 3)会失败并抛出\TypeError异常。

提示

使用标量类型提示和返回类型提示可以提高我们的代码可读性,以及像 NetBeans 和 PhpStorm 这样的 IDE 编辑器的自动完成功能。

匿名类

随着匿名类的添加,PHP 对象获得了类似闭包的能力。我们现在可以通过无名类实例化对象,这使我们更接近其他语言中的对象文字语法。让我们看一个简单的例子:

$object = new class {
    public function hello($message) {
        return "Hello $message";
    }
};

echo$object->hello('PHP');

前面的例子显示了一个$object变量存储了一个匿名类实例的引用。更可能的用法是直接将新类传递给函数参数,而不将其存储为变量,如下所示:

$helper->sayHello(new class {
    public function hello($message) {
        return "Hello $message";
    }
});

与任何普通类一样,匿名类可以将参数传递给它们的构造函数,扩展其他类,实现接口,并使用特征:

class TheClass {}
interface TheInterface {}
trait TheTrait {}

$object = new class('A', 'B', 'C') extends TheClass implements TheInterface {

    use TheTrait;

    public $A;
    private $B;
    protected $C;

    public function __construct($A, $B, $C)
    {
        $this->A = $A;
        $this->B = $B;
        $this->C = $C;
    }
};

var_dump($object);

上面的例子将输出:

object(class@anonymous)#1 (3) { ["A"]=> string(1) "A"["B":"class@anonymous":private]=> string(1) "B"["C":protected]=> string(1) "C" }

匿名类的内部名称是根据其地址生成的唯一引用。

关于何时使用匿名类并没有明确的答案。这几乎完全取决于我们正在构建的应用程序,以及对象,根据它们的视角和用法。

使用匿名类的一些好处如下:

  • 模拟应用程序测试变得微不足道。我们可以为接口创建临时实现,避免使用复杂的模拟 API。

  • 避免为了更简单的实现而经常调用自动加载程序。

  • 清楚地告诉任何阅读代码的人,这个类在这里使用,而不是其他地方。

匿名类,或者说从匿名类实例化的对象,不能被序列化。尝试对它们进行序列化会导致致命错误,如下所示:

Fatal error: Uncaught Exception: Serialization of 'class@anonymous' is not allowed in php7.php:29 Stack trace: #0 php7.php(29): serialize(Object(class@anonymous)) #1 {main} thrown in php7.php on line 29

嵌套的匿名类不能访问外部类的私有或受保护的方法和属性。为了使用外部类的受保护方法和属性,匿名类可以扩展外部类。忽略方法,外部类的私有或受保护属性可以在匿名类中使用,如果通过其构造函数传递:

class Outer
{
    private $prop = 1;
    protected $prop2 = 2;

    protected function outerFunc1()
    {
        return 3;
    }

    public function outerFunc2()
    {
        return new class($this->prop) extends Outer
        {
            private $prop3;

            public function __construct($prop)
            {
                $this->prop3 = $prop;
            }

            public function innerFunc1()
            {
                return $this->prop2 + $this->prop3 + $this->outerFunc1();
            }
        };
    }
}

echo (new Outer)->outerFunc2()->innerFunc1(); //6

尽管我们将它们标记为匿名类,但从这些类实例化的对象的内部名称实际上并不是匿名的。匿名类的内部名称是根据其地址生成的唯一引用。

语句get_class(new class{});将导致类似class@anonymous/php7.php0x7f33c22381c8的结果,其中0x7f33c22381c8是内部地址。如果我们在代码的其他地方定义完全相同的匿名类,它的类名将不同,因为它将分配不同的内存地址。在这种情况下,结果对象可能具有相同的属性值,这意味着它们将相等(==)但不是相同的(===)。

Closure::call()方法

PHP 在 5.3 版本中引入了 Closure 类。Closure 类用于表示匿名函数。在 PHP 5.3 中实现的匿名函数产生了这种类型的对象。从 PHP 5.4 开始,Closure 类获得了几种方法(bindbindTo),允许在创建匿名函数后进一步控制匿名函数。这些方法基本上是使用特定绑定对象和类范围复制闭包。PHP 7 在 Closure 类上引入了call方法。call方法不会复制闭包,它会临时将闭包绑定到新的 this($newThis),并使用任何给定的参数调用它。然后返回闭包的返回值。

call函数签名如下:

function call ($newThis, ...$parameters) {}

\(newThis 是绑定闭包的对象,在`call`期间持续绑定。将作为\)parameters 给闭包的参数是可选的,意味着可以是零个或多个。

让我们看一个简单的Customer类和一个$greeting闭包的以下示例:

class Customer {
    private $firstname;
    private $lastname;

    public function __construct($firstname, $lastname)
    {
        $this->firstname = $firstname;
        $this->lastname = $lastname;
    }
}

$customer = new Customer('John', 'Doe');

$greeting = function ($message) {
    return "$message $this->firstname $this->lastname!";
};

echo **$greeting->call($customer, 'Hello');**

在实际的$greeting闭包中,没有$this,直到实际绑定发生之前它都不存在。我们可以通过直接调用像$greeting('Hello');这样的闭包来轻松确认这一点。但是,我们假设当我们通过其call函数将闭包绑定到给定对象实例时,$this将出现。在这种情况下,闭包中的$this变成了customer对象实例的$this。前面的示例显示了使用call方法调用将$customer绑定到闭包的绑定。生成的输出显示Hello John Doe!

生成器委托

生成器提供了一种简单的方法来实现迭代器,而无需实现实现Iterator接口的类的开销。它们允许我们编写使用foreach来迭代一组数据的代码,而无需在内存中构建数组。这消除了超出内存限制的错误。它们对于 PHP 并不是新的,因为它们是在 PHP 5.5 中添加的。

然而,PHP 7 为生成器带来了几项新的改进,其中之一是生成器委托。

生成器委托允许生成器产生其他生成器、数组或实现Traversable接口的对象。换句话说,我们可以说生成器委托是产生子生成器

让我们看一个带有三个生成器类型函数的以下示例:

function gen1() {
    yield '1';
    yield '2';
    yield '3';
}

function gen2() {
    yield '4';
    yield '5';
    yield '6';
}

function gen3() {
    yield '7';
    yield '8';
 **yield from gen1();**
    yield '9';
 **yield from gen2();**
    yield '10';
}

// output of the below code: 123
foreach (gen1() as $number) {
echo $number;
}

//output of the below code: 78123945610
foreach (gen3() as $number) {
    echo $number;
}

产生其他生成器需要使用yield from <expression>语法。

生成器返回表达式

在 PHP 7 之前,生成器函数无法返回表达式。生成器函数无法指定返回值的能力限制了它们在协程上下文中的实用性。

PHP 7 使生成器能够返回表达式。现在我们可以调用 $generator->getReturn() 来检索 return 表达式。当生成器尚未返回或抛出未捕获的异常时调用 $generator->getReturn() 将抛出异常。

如果生成器没有定义返回表达式并且已经完成了产出,将返回 null。

让我们看下面的例子:

function gen() {
    yield 'A';
    yield 'B';
    yield 'C';

    return 'gen-return';
}

$generator = gen();

//output of the below code: object(Generator)#1 (0) { }
var_dump($generator);

// output of the below code: Fatal error
// var_dump($generator->getReturn());

// output of the below code: ABC
foreach ($generator as $letter) {
    echo $letter;
}

// string(10) "gen-return"
var_dump($generator->getReturn());

看看 gen() 函数定义及其 return 表达式,人们可能期望 $generator 变量的值等于 gen-return 字符串。然而,情况并非如此,因为 $generator 变量变成了 \Generator 类的实例。在生成器仍然打开(未迭代)时调用生成器上的 getReturn() 方法将导致致命错误。

如果代码的结构使得不明显生成器是否已关闭,我们可以使用 valid 方法在获取返回值之前进行检查:

if ($generator->valid() === false) {
    var_dump($generator->getReturn());
}

空合并运算符

在 PHP 5 中,我们有三元运算符,它测试一个值,然后如果该值为 true,则返回第二个元素,如果该值为 false,则返回第三个元素,如下面的代码块所示:

$check = (5 > 3) ? 'Correct!' : 'Faulty!'; // Correct!
$check = (5 < 3) ? 'Correct!' : 'Faulty!'; // Faulty!

在处理 PHP 等网络中心语言中的用户提供的数据时,通常会检查变量是否存在。如果变量不存在,则将其设置为某个默认值。三元运算符为我们提供了这种便利,如下所示:

$role = isset($_GET['role']) ? $_GET['role'] : 'guest';

然而,简单并不总是快速或优雅。考虑到这一点,PHP 7 旨在解决最常见的用法模式之一,引入了空合并运算符(??)。

空合并运算符使我们能够编写更短的表达式,如下面的代码块中所示:

$role = $_GET['role'] **??**'guest';

合并运算符(??)被添加到 $_GET['role'] 变量之后,如果第一个操作数存在且不为 NULL,则返回第一个操作数的结果,否则返回第二个操作数的结果。这意味着 $_GET['role'] ?? 'guest' 是完全安全的,不会引发 E_NOTICE

我们还可以嵌套使用合并运算符:

$A = null; // or not set
$B = 10;

echo $A ?? 20; // 20
echo $A ?? $B ?? 30; // 10

从左到右阅读,存在且不为 null 的第一个值将被返回。这种构造的好处在于它能够以一种清晰有效的方式实现对所需值的安全回退。

提示

该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Modular-Programming-with-PHP7。我们还有其他丰富的书籍和视频代码包可供查阅,网址为 github.com/PacktPublishing/。欢迎查看!

太空船运算符

三向比较运算符,也称为太空船运算符,是在 PHP 7 中引入的。其语法如下:

(expr) <=> (expr)

如果两个操作数相等,则运算符返回 0,如果左边大,则返回 1,如果右边大,则返回 -1

它使用与其他现有比较运算符相同的比较规则:<<===>=>

operator<=> equivalent
$a < $b($a <=> $b) === -1
$a <= $b($a <=> $b) === -1 || ($a <=> $b) === 0
$a == $b($a <=> $b) === 0
$a != $b($a <=> $b) !== 0
$a >= $b($a <=> $b) === 1 || ($a <=> $b) === 0
$a > $b($a <=> $b) === 1

以下是一些太空船运算符行为的示例:

// Floats
echo 1.5 <=> 1.5; // 0
echo 1.5 <=> 2.5; // -1
echo 2.5 <=> 1.5; // 1

// Strings
echo "a"<=>"a"; // 0
echo "a"<=>"b"; // -1
echo "b"<=>"a"; // 1

echo "a"<=>"aa"; // -1
echo "zz"<=>"aa"; // 1

// Arrays
echo [] <=> []; // 0
echo [1, 2, 3] <=> [1, 2, 3]; // 0
echo [1, 2, 3] <=> []; // 1
echo [1, 2, 3] <=> [1, 2, 1]; // 1
echo [1, 2, 3] <=> [1, 2, 4]; // -1

// Objects
$a = (object) ["a" =>"b"]; 
$b = (object) ["a" =>"b"]; 
echo $a <=> $b; // 0

$a = (object) ["a" =>"b"]; 
$b = (object) ["a" =>"c"]; 
echo $a <=> $b; // -1

$a = (object) ["a" =>"c"]; 
$b = (object) ["a" =>"b"]; 
echo $a <=> $b; // 1

// only values are compared
$a = (object) ["a" =>"b"]; 
$b = (object) ["b" =>"b"]; 
echo $a <=> $b; // 0

这个运算符的一个实际用例是编写在排序函数中使用的回调,比如 usortuasortuksort

$letters = ['D', 'B', 'A', 'C', 'E'];

usort($letters, function($a, $b) {
return $a <=> $b;
});

var_dump($letters);

// array(5) { [0]=> string(1) "A" [1]=> string(1) "B" [2]=>string(1) "C" [3]=> string(1) "D" [4]=> string(1) "E" }

可抛出对象

尽管 PHP 5 引入了异常模型,但整体错误和错误处理仍然有些粗糙。基本上,PHP 有两种错误处理系统。传统错误仍然会弹出,并且不会被 try…catch 块处理。

E_RECOVERABLE_ERROR 为例:

class Address
{
    private $customer;
    public function __construct(Customer $customer)
    {
        $this->customer = $customer;
    }
}

$customer = new stdClass();

try {
    $address = new Address($customer);
} catch (\Exception $e) {
    echo 'handling';
} finally {
echo 'cleanup';
}

在这里,try…catch 块没有效果,因为错误不被解释为异常,而是可捕获的致命错误:

Catchable fatal error: Argument 1 passed to Address::__construct() must be an instance of Customer, instance of stdClass given, called in script.php on line 15 and defined in script.php on line 6.

一种可能的解决方法是使用 set_error_handler 函数设置用户定义的错误处理程序,如下所示:

set_error_handler(function($code, $message) {
    throw new \Exception($message, $code);
});

如上所述,错误处理程序现在会将每个错误转换为异常,因此可以通过try…catch块捕获。

PHP 7 将致命错误和可捕获的致命错误作为引擎异常的一部分,因此可以通过try…catch块捕获。这不包括警告和通知,它们仍然不通过异常系统,这对于向后兼容性是有意义的。

它还通过\Throwable接口引入了一个新的异常层次结构。\Exception\Error实现了\Throwable接口。

标准的 PHP 致命错误和可捕获的致命错误现在作为\Error异常抛出,尽管如果它们未被捕获,它们仍将继续触发传统的致命错误。

在整个应用程序中,我们必须使用\Exception\Error,因为我们不能直接实现\Throwable接口。但是,我们可以使用以下块来捕获所有错误,无论是\Exception还是\Error类型:

try {
// statements
} catch (**\Throwable $t**) {
    // handling
} finally {
// cleanup
}

\ParseError

ParseError是 PHP 7 对错误处理的一个很好的补充。我们现在可以处理由eval()includerequire语句触发的解析错误,以及由\ParseError异常抛出的解析错误。它扩展了\Error,而\Error又实现了\Throwable接口。

以下是一个破损的 PHP 文件的示例,因为数组项之间缺少“,”:

<?php

$config = [
'host' =>'localhost'
'user' =>'john'
];

return $config;

以下是包括config.php的文件的示例:

<?php 

try {
include 'config.php';
} catch (\ParseError $e) {
// handle broken file case
}

我们现在可以安全地捕获可能的解析错误。

dirname()函数的级别支持

dirname函数自 PHP 4 以来一直存在。这可能是 PHP 中最常用的函数之一。直到 PHP 7,此函数只接受path参数。在 PHP 7 中,添加了新的 levels 参数。

让我们看下面的例子:

// would echo '/var/www/html/app/etc'
echo dirname('/var/www/html/app/etc/config/');

// would echo '/var/www/html/app/etc'
echo dirname('/var/www/html/app/etc/config.php');

// would echo '/var/www/html/app'
echo dirname('/var/www/html/app/etc/config.php', 2);

// would echo '/var/www/html'
echo dirname('/var/www/html/app/etc/config.php', 3);

通过分配levels值,我们指示从分配的路径值向上移动多少级。虽然很小,但levels参数的添加肯定会使处理路径的某些代码更容易编写。

整数除法函数

intdiv是 PHP 7 引入的新的整数除法函数。该函数接受被除数和除数作为参数,并返回它们的商的整数部分,如下面的函数描述所示:

int intdiv(int $dividend, int $divisor)

让我们看下面的几个例子:

intdiv(5, 3); // int(1)
intdiv(-5, 3); // int(-1)
intdiv(5, -2); // int(-2)
intdiv(-5, -2); // int(2)
intdiv(PHP_INT_MAX, PHP_INT_MAX); // int(1)
intdiv(PHP_INT_MIN, PHP_INT_MIN); // int(1)

// following two throw error
intdiv(PHP_INT_MIN, -1); // ArithmeticError
intdiv(1, 0); // DivisionByZeroError

如果dividendPHP_INT_MIN,而除数是-1,那么会抛出ArithmeticError异常。如果除数是0,那么会抛出DivisionByZeroError异常。

常量数组

在 PHP 7 之前,使用define()定义的常量只能包含标量表达式,而不能包含数组。从 PHP 5.6 开始,可以使用const关键字定义数组常量,从 PHP 7 开始,也可以使用define()定义数组常量:

// the define() example
define('FRAMEWORK', [
'version' => 1.2,
'licence' =>'enterprise'
]);

echo FRAMEWORK['version']; // 1.2
echo FRAMEWORK['licence']; // enterprise

// the class const example
class App {
    const FRAMEWORK = [
'version' => 1.2,
'licence' =>'enterprise'
    ];
}

echo App::FRAMEWORK['version']; // 1.2
echo App::FRAMEWORK['licence']; // enterprise

常量一旦设置后就不能重新定义或取消定义。

统一的变量语法

为了使 PHP 的解析器更完整,PHP 7 引入了统一的变量语法。使用统一的变量语法,所有变量都是从左到右进行评估的。

与删除各种函数、关键字或设置不同,像这样的语义变化对现有代码库的影响可能相当大。以下代码演示了语法、其旧含义和新含义:

// Syntax
$$foo['bar']['baz']
// PHP 5.x:
// Using a multidimensional array value as variable name
${$foo['bar']['baz']}
// PHP 7:
// Accessing a multidimensional array within a variable-variable
($$foo)['bar']['baz']

// Syntax
$foo->$bar['baz']
// PHP 5.x:
// Using an array value as a property name
$foo->{$bar['baz']}
// PHP 7:
// Accessing an array within a variable-property
($foo->$bar)['baz']

// Syntax
$foo->$bar['baz']()
// PHP 5.x:
// Using an array value as a method name
$foo->{$bar['baz']}()
// PHP 7:
// Calling a closure within an array in a variable-property
($foo->$bar)['baz']()

// Syntax
Foo::$bar['baz']()
// PHP 5.x:
// Using an array value as a static method name
Foo::{$bar['baz']}()
// PHP 7:
// Calling a closure within an array in a static variable
(Foo::$bar)['baz']()

除了以前重写的旧到新语法示例之外,现在还支持一些新的语法组合。

PHP 7 现在支持嵌套双冒号::,以下是一个示例:

// Access a static property on a string class name
// or object inside an array
$foo['bar']::$baz;
// Access a static property on a string class name or object
// returned by a static method call on a string class name
// or object
$foo::bar()::$baz;
// Call a static method on a string class or object returned by
// an instance method call
$foo->bar()::baz();

我们还可以通过在括号中加倍来嵌套方法和函数调用,或者任何可调用的内容,如下面的代码示例所示:

// Call a callable returned by a function
foo()();
// Call a callable returned by an instance method
$foo->bar()();
// Call a callable returned by a static method
Foo::bar()();
// Call a callable return another callable
$foo()();

此外,我们现在可以对任何用括号括起来的有效表达式进行解引用:

// Access an array key
(expression)['foo'];
// Access a property
(expression)->foo;
// Call a method
(expression)->foo();
// Access a static property
(expression)::$foo;
// Call a static method
(expression)::foo();
// Call a callable
(expression)();
// Access a character
(expression){0};

安全的随机数生成器

PHP 7 引入了两个新的CSPRNG函数。CSPRNG 是密码学安全伪随机数生成器的缩写。

第一个random_bytes生成一个任意长度的加密随机字节字符串,适用于加密用途,比如生成密钥初始化向量。该函数只接受一个(length)参数,表示应以字节返回的随机字符串的长度。它返回一个包含请求的数量的密码安全随机字节的字符串,或者在找不到适当的随机源时,它会抛出一个异常。

以下是random_bytes的使用示例:

$bytes = random_bytes(5);

第二个random_int生成适用于需要无偏结果的密码随机整数,比如在为扑克游戏洗牌时。该函数接受两个(minmax)参数,表示要返回的最小值(必须是PHP_INT_MIN或更高)和要返回的最大值(必须小于或等于PHP_INT_MAX)。它返回范围在 min 到 max(包括 min 和 max)之间的密码安全随机整数。

以下是random_int的使用示例:

$int = random_int(1, 10);
$int = random_int(PHP_INT_MIN, 500);
$int = random_int(20, PHP_INT_MAX);
$int = random_int(PHP_INT_MIN, PHP_INT_MAX);

过滤反序列化()

序列化数据可以包括对象。这些对象还可以包括析构函数、__toString__call等函数。为了在对非结构化数据上反序列化对象时提高安全性,PHP 7 引入了现有unserialize函数的可选options参数。

options参数是一个数组类型,目前只接受allowed_classes键。

allowed_classes可以有三个值之一:

  • true:这是默认值,和以前一样允许所有对象

  • false:这里不允许对象

  • 允许的类名数组,列出了未序列化对象的允许类

以下是使用allowed_classes选项的示例:

class Customer{
    public function __construct(){
        echo '__construct';
    }

    public function __destruct(){
        echo '__destruct';
    }

    public function __toString(){
        echo '__toString';
        return '__toString';
    }

    public function __call($name, $arguments) {
        echo '__call';
    }
}

$customer = new Customer();

$s = serialize($customer); // triggers: __construct, __destruct

$u = unserialize($s); // triggers: __destruct
echo get_class($u); // Customer

$u = unserialize($s, ['allowed_classes'=>false]); // does not trigger anything
echo get_class($u); // __PHP_Incomplete_Class

我们可以看到,该类的对象如果不被接受,则被实例化为__PHP_Incomplete_Class

上下文敏感的词法分析器

根据php.net/manual/en/reserved.keywords.php列表,PHP 有 60 多个保留关键字。这些构成了语言结构,比如类、接口和特征中的属性、方法、常量的名称。

有时这些保留字最终会与用户定义的 API 声明发生冲突。

为了解决这个问题,PHP 7.0 引入了上下文敏感的词法分析器。有了上下文敏感的词法分析器,我们现在可以在我们的代码中使用关键字来表示属性、函数和常量的名称。

以下是与上下文敏感的词法分析器的影响相关的一些实际示例:

class ReportPool {
    public function include(Report $report) {
//
    }
}

$reportPool = new ReportPool();
$reportPool->include(new Report());

class Collection extends \ArrayAccess, \Countable, \IteratorAggregate {

    public function forEach(callable $callback) {
//
    }

    public function list() {
//
    }

    public static function new(array $items) {
        return new self($items);
    }
}

Collection::new(['var1', 'var2'])
->forEach(function($index, $item){ /* ... */ })
->list();

唯一的例外是class关键字,在类常量上下文中仍然保留,如下所示:

class Customer {
  const class = 'Retail'; // Fatal error
}

组使用声明

组使用声明在 PHP 7 中引入,用于从公共命名空间导入多个类时减少冗长。它们启用了如下的简写语法:

use Library\Group1\Group2\{ ClassA, ClassB, ClassC as Classy };

让我们看一下下面的例子,其中相同命名空间内的类名被组合使用:

// Current use syntax
use Doctrine\Common\Collections\Expr\Comparison;
use Doctrine\Common\Collections\Expr\Value;
use Doctrine\Common\Collections\Expr\CompositeExpression;

// Group use syntax
use Doctrine\Common\Collections\Expr\{ Comparison, Value, CompositeExpression };

我们还可以在部分命名空间上使用组使用声明,如下面的示例所示:

// Current use syntax
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion as Choice;
use Symfony\Component\Console\Question\ConfirmationQuestion;

// Group use syntax
use Symfony\Component\Console\{
  Helper\Table,
  Input\ArrayInput,
  Input\InputInterface,
  Output\NullOutput,
  Output\OutputInterface,
  Question\Question,
  Question\ChoiceQuestion as Choice,
  Question\ConfirmationQuestion,
};

我们还可以像下面的代码行一样进一步使用group use来导入函数和常量:

use Framework\Component\{
SubComponent\ClassA,
function OtherComponent\someFunction,
const OtherComponent\SOME_CONSTANT
};

Unicode 增强

Unicode,特别是 UTF-8,在 PHP 应用程序中越来越受欢迎。

PHP 7 为双引号字符串heredocs添加了新的转义序列,语法如下:

\u{code-point}

它产生了一个 Unicode 代码点的 UTF-8 编码,用十六进制数字指定。值得注意的是,花括号中的代码点长度是任意的。这意味着我们可以使用\u{FF}或更传统的\u{00FF}

以下是四种最常用货币、它们的符号和它们的 UTF-8 代码点的简单列表:

Euro€U+20AC
Japanese Yen¥U+00A5
Pound sterling£U+00A3
Australian dollar$U+0024

其中一些符号通常直接存在于键盘上,所以很容易像这里显示的那样写下来:

echo "the € currency";
echo "the ¥ currency";
echo "the £ currency";
echo "the $ currency";

然而,大多数其他符号不像单个按键那样容易访问,因此需要以代码点的形式编写,如下所示:

echo "the \u{1F632} face";
echo "the \u{1F609} face";
echo "the \u{1F60F} face";

在较早版本的 PHP 中,前面语句的输出将如下所示:

the \u{1F632} face
the \u{1F609} face
the \u{1F60F} face

显然,这并没有解析代码点,因为它会直接输出它们。

PHP 7 引入了 Unicode 代码点转义序列语法到字符串文字中,使以前的语句产生以下输出:

the 😉 face
the 😉 face
the 😉 face
```php

## 断言

断言是一种调试功能,用于检查给定的断言,并在其结果为`false`时采取适当的操作。它们一直是 PHP 的一部分,自 PHP 4 以来就一直存在。

断言与错误处理的不同之处在于,断言涵盖了不可能的情况,而错误是可能的并且需要被处理。

应避免将断言用作通用错误处理机制。断言不允许从错误中恢复。断言失败通常会停止程序的执行。

使用现代调试工具如 Xdebug,不多的开发人员会使用断言进行调试。

断言可以通过`assert_options`函数或`assert.active INI`设置轻松启用和禁用。

要使用断言,我们可以传入一个表达式或字符串,如下面的函数签名所示:

// PHP 5
bool assert ( mixed $assertion [, string $description ] )

// PHP 7
bool assert ( mixed $assertion [, Throwable $exception ] )


这两个签名在第二个参数上有所不同。PHP 7 可以接受字符串`$description`或`$exception`。

如果表达式的结果或字符串的求值结果为`false`,则会发出警告。如果第二个参数传递为`$exception`,则会抛出异常而不是失败。

关于`php.ini`配置选项,`assert`函数已扩展以允许所谓的*零成本断言*:

zend.assertions = 1 // Enable
zend.assertions = 0 // Disable
zend.assertions = -1 // Zero-cost


使用零成本设置,断言对性能和执行没有任何影响,因为它们不会被编译。

最后,**INI**设置中添加了`Boolean assert.exception`选项。将其设置为`true`,会导致失败的断言引发`AssertionError`异常。

## 对`list()`构造的更改

在 PHP 5 中,`list()`从最右边的参数开始分配值。在 PHP 7 中,`list()`从最左边的参数开始。基本上,现在的值按照它们被定义的顺序分配给变量。

然而,这只影响了`list()`与`array []`操作符一起使用的情况,如下面的代码块中所讨论的:

string(5) "blue" [1]=> string(6) "yellow" [2]=> string(4) "green" } ```php 在 PHP 7 中,前面代码的输出将如下所示: ``` string(5) "green" string(6) "yellow" string(4) "blue" array(3) { [0]=> string(5) "green" [1]=> string(6) "yellow" [2]=> string(4) "blue" } ```php 分配顺序可能会在将来再次改变,因此我们不应过分依赖它。 ## 会话选项 在 PHP 7 之前,`session_start()`函数并不直接接受任何配置选项。我们想要在会话中设置的任何配置选项都需要来自`php.ini`: ``` // PHP 5 ini_set('session.name', 'THEAPP'); ini_set('session.cookie_lifetime', 3600); ini_set('session.cookie_httponly', 1); session_start(); // PHP 7 session_start([ 'name' =>'THEAPP', 'cookie_lifetime' => 3600, 'cookie_httponly' => 1 ]); ```php 受性能优化目标的驱动,PHP 7 中添加了一个新的`lazy_write`运行时配置。当`lazy_write`设置为`1`时,只有在会话数据发生变化时才会重新写入。这是默认行为: ``` session_start([ 'name' =>'THEAPP', 'cookie_lifetime' => 3600, 'cookie_httponly' => 1, 'lazy_write' => 1 ]); ```php 尽管这里列出的更改一开始可能看起来并不令人印象深刻,但通过`session_start`函数直接覆盖会话选项的能力为我们的代码提供了一定的灵活性。 ## 弃用的功能 全球通用的软件主要版本有打破向后兼容性的奢侈。理想情况下,不会有太多,但为了使软件向前发展,一些旧的想法需要被抛弃。这些变化不是一夜之间发生的。某些功能首先被标记为弃用,以警告开发人员它将在未来版本的语言中被移除。有时,这种弃用期可能需要数年。 在整个 PHP 5.x 中,许多功能已被标记为弃用,在 PHP 7.0 中,它们都已被移除。 **POSIX 兼容**的正则表达式在 PHP 5.3 中已被弃用,现在在 PHP 7 中完全移除。 以下函数不再可用: + ereg_replace + ereg + eregi_replace + eregi + `split` + spliti + sql_regcase 我们应该使用**Perl 兼容正则表达式**(**PCRE**)。[`php.net/manual/en/book.pcre.php`](http://php.net/manual/en/book.pcre.php)是这些函数的一个很好的文档来源。 在 PHP 5.5 中已经弃用的`mysql`扩展现在已经被移除。不再有任何`mysql_*`函数可用。我们应该使用`mysqli`扩展。好消息是,从`mysql`到`mysqli`函数的转换大多是简单的,因为在我们的代码中添加`i`时,`mysql_*`函数调用并将数据库句柄(由`mysqli_connect`返回)作为第一个参数传递。[`php.net/manual/en/book.mysqli.php`](http://php.net/manual/en/book.mysqli.php)是这些函数的一个很好的文档来源。 PHP 脚本和 ASP 标签已不再可用: ``` <% // Code here %> <%=$varToEcho; %> ``` ## 框架 应用框架是一组函数、类、配置和约定,旨在支持 Web 应用程序、服务和 API 的开发。一些应用程序正在采用 API 优先的方法,而服务器端的 REST 和 SOAP API 是通过 PHP 构建的,客户端使用其他技术如 JavaScript。 构建 Web 应用程序时,通常有三个明显的选择: + 我们可以从头开始构建所有东西。这种方式可能会使我们的开发过程变慢,但我们可以实现完全符合我们标准的架构。不用说,这是一种非常低效的方法。 + 我们可以使用现有的框架。这样,我们的开发过程会很快,但我们需要满意我们的应用是建立在其他东西之上的。 + 我们可以使用现有的框架,但也可以尝试将其抽象到应用程序看起来独立于它的级别。这是一个痛苦而缓慢的方法,至少可以这么说。它涉及编写大量的适配器、包装器、接口等。 简而言之,框架的存在是为了让我们更容易更快地构建软件。许多编程语言都有流行的框架,PHP 也不例外。 鉴于 PHP 作为首选的 Web 编程语言的普及度,数十个框架在多年来已经涌现出来,这并不奇怪。选择“正确”的框架是一项艰巨的任务,尤其是对于新手来说更是如此。对于一个项目或团队来说合适的框架可能对另一个项目或团队来说并不合适。 然而,每个现代框架应该包括一些一般的高级部分。这些部分包括: + **模块化**:支持模块化应用程序开发,允许我们将代码整齐地分成功能性的构建块,而它是以模块化的方式构建的。 + **安全**:提供现代 Web 应用程序所期望的各种加密和其他安全工具。提供对身份验证、授权和数据加密等功能的无缝支持。 + **可扩展**:能够轻松地满足我们的应用程序需求,使我们能够根据我们的应用程序需求进行扩展。 + **社区**:它由充满活力和积极的社区积极开发和支持。 + **高性能**:以性能为重点构建。许多框架都吹嘘性能,但其中有许多变量。我们需要明确我们在这里评估什么。对缓存性能与原始性能进行测量通常是误导性的评估,因为缓存代理可以放在许多框架的前面。 + **企业就绪**:根据手头项目的类型,我们很可能希望选择一个标志自己为企业就绪的框架。这让我们足够自信地在其上运行关键和高使用率的业务应用程序。 虽然完全可以使用纯 PHP 编写整个 Web 应用程序而不使用任何框架,但今天的大多数项目确实使用了框架。 使用框架的好处超过了从头开始做所有事情的纯度。框架通常得到很好的支持和文档,这使得团队更容易掌握库、项目结构、约定和其他事项。 在谈到 PHP 框架时,值得指出一些流行的框架: + **Laravel**:[`laravel.com`](https://laravel.com) + **Symfony**:[`symfony.com`](http://symfony.com) + **Zend Framework**:[`framework.zend.com`](http://framework.zend.com) + **CodeIgniter**:[`www.codeigniter.com`](https://www.codeigniter.com) + **CakePHP**:[`cakephp.org`](http://cakephp.org) + **Slim**:[`www.slimframework.com`](http://www.slimframework.com) + **Yii**:[`www.yiiframework.com`](http://www.yiiframework.com) + **Phalcon**:[`phalconphp.com`](https://phalconphp.com) 这绝不是一个完整或甚至是按流行程度排序的列表。 ### Laravel 框架 Laravel 是根据 MIT 许可发布的,可以从[`laravel.com/`](https://laravel.com/)下载。 除了常规的路由、控制器、请求、响应、视图和(blade)模板之外,Laravel 还提供了大量额外的服务,如身份验证、缓存、事件、本地化等。 Laravel 的另一个很棒的功能是**Artisan**,这是一个命令行工具,提供了许多在开发过程中可以使用的有用命令。Artisan 还可以通过编写自己的控制台命令进行扩展。 Laravel 拥有一个非常活跃和充满活力的社区。它的文档简单清晰,使得新手很容易上手。此外,还有[`laracasts.com`](https://laracasts.com),它在文档和其他内容方面超越了 Laravel。Laracasts 是一个提供一系列专家录屏的网络服务,其中一些是免费的。 所有这些特性使得 Laravel 成为在选择框架时值得评估的选择。 ### Symfony Symfony 是根据 MIT 许可发布的,可以从[`symfony.com`](http://symfony.com)下载。 随着时间的推移,Symfony 引入了**长期支持**(LTS)版本的概念。这个发布过程从 Symfony 2.2 开始被采用,并严格遵循从 Symfony 2.4 开始。标准版本的 Symfony 维护八个月。长期支持版本支持三年。 关于新版本的另一个有趣的事情是基于时间的发布模型。所有新版本的 Symfony 发布都是每六个月一次:五月和十一月各一个。 Symfony 通过邮件列表、IRC 和 StackOverflow 拥有很好的社区支持。此外,SensioLabs 专业支持提供了从咨询、培训、辅导到认证的全方位解决方案。 许多 Symfony 组件被用于其他 Web 应用程序和框架,如 Laravel、Silex、Drupal 8、Sylius 等。 Symfony 之所以成为如此受欢迎的框架,是因为它的互操作性。"不要将自己锁在 Symfony 中!"的理念使其受到开发人员的欢迎,因为它允许构建精确满足我们需求的应用程序。 通过拥抱"不要重复造轮子"的理念,Symfony 本身大量使用现有的 PHP 开源项目作为框架的一部分,包括: + Doctrine(或 Propel):对象关系映射层 + PDO 数据库抽象层(Doctrine 或 Propel) + PHPUnit:一个单元测试框架 + Twig:一个模板引擎 + Swift Mailer:一个电子邮件库 根据我们的项目需求,我们可以选择使用全栈 Symfony 框架,Silex 微框架,或者只是一些单独的组件。 Symfony 开箱即用为新的 Web 应用程序提供了大量的结构基础。它通过其 bundle 系统实现。Bundle 类似于主应用程序中的微应用程序。在其中,整个应用程序被很好地结构化为模型、控制器、模板、配置文件和其他构建块。能够完全将不同领域的逻辑分离开有助于我们保持关注点的清晰分离,并独立开发我们领域的每个功能。 Symfony 是 PHP 在采用依赖注入方面的先驱之一,这使得它能够实现解耦的组件,并保持代码的高灵活性。 文档化、模块化、高度灵活、高性能、受支持,这些属性使 Symfony 成为值得评估的选择。 ### Zend Framework Zend Framework 是根据新的 BSD 许可证发布的,可以从[`framework.zend.com`](http://framework.zend.com)下载。 Zend Framework 的特点包括: + 完全面向对象的 PHP 组件 + 松散耦合的组件 + 可扩展的 MVC 支持布局和模板 + 支持多个数据库系统 MySQL、Oracle、MS SQL 等 + 通过 mbox、Maildir、POP3 和 IMAP4 处理电子邮件 + 灵活的缓存系统 除了免费的 Zend Framework 外,Zend Technologies Ltd 还提供了自己的商业版本的 PHP 堆栈,称为 Zend Server,以及包含专门与 Zend Framework 集成的功能的 Zend Studio IDE。虽然 Zend Framework 可以在任何 PHP 堆栈上运行,但 Zend Server 被宣传为运行 Zend Framework 应用程序的优化解决方案。 根据其架构设计,Zend Framework 仅仅是一组类。我们的应用程序不需要遵循严格的结构。这是使其对某一范围的开发人员如此吸引人的特点之一。我们可以利用 Zend MVC 组件创建一个完全功能的 Zend Framework 项目,或者只需加载我们需要的组件。 所谓的全栈框架会将结构、ORM 实现、代码生成等固定内容强加到项目中。另一方面,Zend Framework 以其解耦的特性,被归类为一种粘合型框架。我们可以轻松地将其粘合到现有应用程序中,或者用它来构建一个新的应用程序。 最新版本的 Zend Framework 遵循**SOLID 面向对象设计**原则。所谓的“随意使用”设计允许开发人员使用他们想要的任何组件。 尽管 Zend Framework 的主要推动力是 Zend Technologies,但许多其他公司也为该框架贡献了重要特性。 此外,Zend Technologies 提供了出色的 Zend Certified PHP Engineer 认证。优质的社区、官方公司支持、教育、托管和开发工具使 Zend Framework 成为值得评估的选择。 ### CodeIgniter CodeIgniter 是根据 MIT 许可证发布的,可以从[`www.codeigniter.com`](https://www.codeigniter.com)下载。 CodeIgniter 以其轻量级而自豪。核心系统只需要少量的小型库,这在其他框架中并不总是如此。 该框架采用简单的**模型-视图-控制**方法,允许在逻辑和呈现之间进行清晰分离。视图层不会强加任何特殊的模板语言,因此可以直接使用原生 PHP。 以下是 CodeIgniter 的一些突出特点: + 基于模型-视图-控制的系统 + 极其轻量级 + 具有对多个平台的支持的全功能数据库类 + 查询构建器数据库支持 + 表单和数据验证 + 安全和 XSS 过滤 + 本地化 + 数据加密 + 完整页面缓存 + 单元测试类 + 搜索引擎友好的 URL + 灵活的 URI 路由 + 支持钩子和类扩展 + 大量的辅助函数库 CodeIgniter 拥有一个活跃的社区,聚集在[`forum.codeigniter.com`](http://forum.codeigniter.com)。 小的占用空间、灵活性、出色的性能、接近零的配置和详尽的文档是使这个框架值得评估的选择。 ### CakePHP CakePHP 是根据 MIT 许可发布的,可以从[`cakephp.org`](http://cakephp.org)下载。 CakePHP 框架受到**Ruby on Rails**的极大启发,使用了许多它的概念。它重视约定胜过配置。 它是“一应俱全”的。对于现代 Web 应用程序,我们大多数需要的东西都已经内置了。翻译、数据库访问、缓存、验证、身份验证等等都已经内置了。 安全性是 CakePHP 哲学的另一个重要部分。CakePHP 带有用于输入验证、CSRF 保护、表单篡改保护、SQL 注入预防和 XSS 预防的内置工具,帮助我们保护我们的应用程序。 CakePHP 支持各种数据库存储引擎,如 MySQL、PostgreSQL、Microsoft SQL Server 和 SQLite。内置的 CRUD 功能对数据库交互非常方便。 它依靠一个庞大的社区支持。它还有一个大型的插件列表,可在[`plugins.cakephp.org`](http://plugins.cakephp.org)上找到。 CakePHP 提供了认证考试,开发人员在 CakePHP 框架、MVC 原则和 CakePHP 内部使用的标准方面接受考验。认证面向真实场景和 CakePHP 特定内容。 Cake Development Corporation 提供商业支持、咨询、代码审查、性能分析、安全审计,甚至开发服务,网址为[`www.cakedc.com`](http://www.cakedc.com)。Cake Development Corporation 是该框架背后的商业实体,由 CakePHP 的创始人之一 Larry Masters 于 2007 年成立。 ### Slim Slim 是根据 MIT 许可发布的,可以从[`www.slimframework.com`](http://www.slimframework.com)下载。 虽然“一应俱全”思维的框架提供了强大的库、目录结构和配置,微框架只需几行代码就能让我们开始。 微框架通常甚至缺乏基本的框架功能,如: + 身份验证和授权 + ORM 数据库抽象 + 输入验证和净化 + 模板引擎 这限制了它们的使用,但也使它们成为快速原型设计的强大工具。 Slim 支持任何 PSR-7 HTTP 消息实现。HTTP 消息可以是客户端到服务器的请求,也可以是服务器到客户端的响应。Slim 的功能类似于一个分发器,接收 HTTP 请求,调用适当的回调例程,并返回 HTTP 响应。 Slim 的好处在于它与中间件很好地配合。中间件基本上是一个可调用的函数,接受三个参数: + `\Psr\Http\Message\ServerRequestInterface`: PSR7 请求对象 + `\Psr\Http\Message\ResponseInterface`: PSR7 响应对象 + `callable`: 下一个中间件可调用 中间件可以自由地操作请求和响应对象,只要它们返回`\Psr\Http\Message\ResponseInterface`的实例。此外,每个中间件都需要调用下一个中间件,并将请求和响应对象作为参数传递给它。 这个简单的概念赋予了 Slim 可扩展性的能力,通过各种可能的第三方中间件。 尽管 Slim 提供了良好的文档、活跃的社区,并且项目目前正在积极开发,但它的使用是有限的。微框架几乎不是健壮企业应用的选择。不过,它们在开发中有它们的位置。 ### Yii Yii 是根据 BSD 许可发布的,可以从[`www.yiiframework.com`](http://www.yiiframework.com)下载。 Yii 对性能优化的关注使其成为几乎任何类型项目的完美选择,包括企业类型的应用程序。 一些杰出的 Yii 特性包括: + MVC 设计模式 + 自动生成复杂服务 WSDL + 日期、时间和数字的翻译、本地化、区域敏感格式化 + 数据缓存、片段缓存、页面缓存和 HTTP 缓存 + 基于错误的性质和应用程序运行模式显示错误的错误处理程序 + 安全措施,以帮助防止 SQL 注入、跨站脚本(XSS)、跨站请求伪造(CSRF)和 Cookie 篡改 + 基于 PHPUnit 和 Selenium 的单元和功能测试 Yii 的一个很棒的功能是一个名为 Gii 的工具。它是一个提供基于 Web 的代码生成器的扩展。我们可以使用 Gii 的图形界面快速设置生成模型、表单、模块、CRUD 等。还有一个 Gii 的命令行版本,适合喜欢控制台的人使用。 Yii 的架构使其能够与 PEAR 库、Zend Framework 等第三方代码很好地配合。它采用了 MVC 架构,允许清晰地分离关注点。 Yii 提供了一个令人印象深刻的扩展库,可在[`www.yiiframework.com/extensions`](http://www.yiiframework.com/extensions)找到。大多数扩展都是作为 composer 包分发的。它们为我们提供了加速开发的能力。我们可以轻松地将我们的代码打包为扩展并与他人分享。这使得 Yii 对于模块化应用程序开发更加有趣。 官方文档非常全面。还有几本书可供参考。 丰富的文档、充满活力的社区、活跃的发布、性能优化、安全强调、功能丰富和灵活性使 Yii 成为值得评估的选择。 ### Phalcon Phalcon 是根据 BSD 许可发布的,可以从[`phalconphp.com`](https://phalconphp.com)下载。 Phalcon 最初是由 Andres Gutierrez 和合作者于 2012 年发布的。该项目的目标是找到一种新的方法来编写 PHP 的传统 Web 应用程序框架。这种新方法以 C 语言扩展的形式出现。整个 Phalcon 框架都是作为 C 扩展开发的。 基于 C 的框架的好处在于在运行时加载整个 PHP 扩展。这极大地减少了 I/O 操作,因为不再需要加载`.php`文件。此外,编译的 C 语言代码比 PHP 字节码执行速度更快。由于 C 扩展与 PHP 一起在 Web 服务器守护进程启动过程中加载一次,它们的内存占用量很小。基于 C 的框架的缺点是代码是编译的,因此我们不能像使用 PHP 类一样轻松地调试和修补它。 低级架构和优化使 Phalcon 成为基于 MVC 的应用程序中开销最低的之一。 Phalcon 是一个全栈、松散耦合的框架。虽然它为我们的应用程序提供了完整的 MVC 结构,但它也允许我们根据应用程序的需求将其对象用作粘合组件。我们可以选择是创建一个完整的 MVC 应用程序,还是最小化的微型应用程序。微型应用程序适合以实际方式实现小型应用程序、API 和原型。 到目前为止,我们提到的所有框架都支持某种形式的扩展,我们可以向框架添加新的库或整个包。由于 Phalcon 是一个 C 代码框架,对框架的贡献不是以 PHP 代码的形式出现。另一方面,编写和编译 C 语言代码对于普通的 PHP 开发人员来说可能有些具有挑战性。 **Zephir**项目[`zephir-lang.com`](http://zephir-lang.com)通过引入高级 Zephir 语言来解决这些挑战。Zephir 旨在简化为 PHP 创建和维护 C 扩展,重点放在类型和内存安全上。 在与数据库通信时,Phalcon 使用**Phalcon 查询语言**,**PhalconQL**,或简称**PHQL**。PHQL 是一种高级的、面向对象的 SQL 方言,允许我们使用类似 SQL 的语言编写查询,该语言与对象而不是表一起使用。 视图模板由 Volt 处理,这是 Phalcon 自己的模板引擎。它与其他组件高度集成,可以在我们的应用程序中独立使用。 Phalcon 相当容易上手。它的文档涵盖了使用框架的 MVC 和微型应用程序样式,还有实际的例子。框架本身足够丰富,可以支持我们大多数今天的应用程序所需的结构和库。此外,还有一个名为**Phalconist** [`phalconist.com`](https://phalconist.com)的官方 Phalcon 网站,提供了框架的额外资源。 尽管没有官方公司支持,也没有认证、商业支持等类似的企业外观,Phalcon 在定位自己作为一个值得评估的选择方面做得很好,即使是在健壮的企业应用程序开发中也是如此。 # 总结 回顾一下 PHP 5 的发布及其对面向对象编程的支持,我们可以看到它对 PHP 生态系统产生的巨大积极影响。大量的框架和库已经涌现出来,为 Web 应用程序开发提供了企业级解决方案。 PHP 7 的发布很可能是 PHP 生态系统的又一个飞跃。虽然新功能中没有一项是革命性的,因为它们可以在其他编程语言中找到,但它们对 PHP 的影响很大。我们还没有看到它的新功能将如何重塑现有和未来的框架以及我们编写应用程序的方式。 引入更高级的*错误到异常*处理、标量类型提示和函数返回类型提示,肯定会为使用它们的应用程序和框架带来期待已久的稳定性。与 PHP 5.6 相比的速度改进足以显著降低高负载站点的托管成本。值得庆幸的是,PHP 开发团队最小化了向后不兼容的更改,因此它们不应该妨碍 PHP 7 的迅速采用。 选择合适的框架绝非易事。将框架分类为企业级框架的标准不仅仅是一堆类的集合。它有一个完整的生态系统。 在评估项目的框架时,不应受到炒作的影响。应该考虑以下问题: + 它是由公司还是社区驱动的? + 它提供质量的文档吗? + 它有稳定且频繁的发布周期吗? + 它提供某种官方形式的认证吗? + 它提供免费和商业支持吗? + 它有我们可以参加的偶尔研讨会吗? + 它对社区参与开放吗,这样我们就可以提交功能和补丁? + 它是一个全栈还是粘合类型的框架? + 它是按照惯例还是配置驱动的? + 它提供足够的库来让您开始(安全性、验证、模板化、数据库抽象、ORM、路由、国际化等)吗? + 核心框架是否可以进行足够的扩展和重写,以使其更具未来性,以适应可能的变化? 有许多成熟的 PHP 框架和库,因此选择并不容易。这些框架和库中的大多数仍然需要完全跟上 PHP 7 中添加的最新功能。 在接下来的章节中,我们将探讨常见的设计模式以及如何在 PHP 中集成它们。 # 第二章:GoF 设计模式 有一些因素使得一个优秀的软件开发者。设计模式的知识和使用就是其中之一。设计模式使开发者能够使用众所周知的名称来进行各种软件交互。无论是 PHP、Python、C#、Ruby 还是其他任何语言的开发者,设计模式都为经常发生的软件问题提供了与语言无关的解决方案。 设计模式的概念于 1994 年出现,作为《可重用面向对象软件的元素》一书的一部分。该书详细介绍了 23 种不同的设计模式,由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 四位作者撰写。这些作者通常被称为**四人帮**(**GoF**),所提出的设计模式有时被称为 GoF 设计模式。如今,两十多年后,设计可扩展、可重用、可维护和可适应的软件几乎不可能不将设计模式作为实现的一部分。 本章将介绍三种设计模式: + 创造性 + 结构性 + 行为 在本章中,我们不会深入研究每一个模式的理论,因为单独讨论这些内容就是一本完整的书。接下来,我们将更多地关注每种模式的简单 PHP 实现示例,以便更直观地了解事物。 # 创建型模式 创建型模式,顾名思义,为我们创建*对象*,因此我们不必直接实例化它们。实现创建模式为我们的应用程序提供了一定程度的灵活性,应用程序本身可以决定在特定时间实例化哪些对象。以下是我们归类为创建型模式的模式列表: + 抽象工厂模式 + 建造者模式 + 工厂方法模式 + 原型模式 + 单例模式 ### 注意 有关创建型设计模式的更多信息,请参见[`en.wikipedia.org/wiki/Creational_pattern`](https://en.wikipedia.org/wiki/Creational_pattern)。 ## 抽象工厂模式 构建可移植应用程序需要很高的依赖封装级别。抽象工厂通过*抽象化相关或依赖对象的创建*来实现这一点。客户端永远不会直接创建这些平台对象,工厂会为他们创建,使得可以在不改变使用它们的代码的情况下交换具体实现,甚至在运行时。 以下是可能的抽象工厂模式实现示例: ```php interface Button { public function render(); } interface GUIFactory { public function createButton(); } class SubmitButton implements Button { public function render() { echo 'Render Submit Button'; } } class ResetButton implements Button { public function render() { echo 'Render Reset Button'; } } class SubmitFactory implements GUIFactory { public function createButton() { return new SubmitButton(); } } class ResetFactory implements GUIFactory { public function createButton() { return new ResetButton(); } } // Client $submitFactory = new SubmitFactory(); $button = $submitFactory->createButton(); $button->render(); $resetFactory = new ResetFactory(); $button = $resetFactory->createButton(); $button->render(); ``` 我们首先创建了一个接口`Button`,然后由我们的`SubmitButton`和`ResetButton`具体类来实现。`GUIFactory`和`ResetFactory`实现了`GUIFactory`接口,该接口指定了`createButton`方法。然后客户端简单地实例化工厂并调用`createButton`,返回一个适当的按钮实例,我们称之为`render`方法。 ## 建造者模式 建造者模式将复杂对象的构建与其表示分离,使得相同的构建过程可以创建不同的表示。虽然一些创造模式在一次调用中构造产品,但建造者模式在主管的控制下逐步进行。 以下是建造者模式实现的示例: ```php class Car { public function getWheels() { /* implementation... */ } public function setWheels($wheels) { /* implementation... */ } public function getColour($colour) { /* implementation... */ } public function setColour() { /* implementation... */ } } interface CarBuilderInterface { public function setColour($colour); public function setWheels($wheels); public function getResult(); } class CarBuilder implements CarBuilderInterface { private $car; public function __construct() { $this->car = new Car(); } public function setColour($colour) { $this->car->setColour($colour); return $this; } public function setWheels($wheels) { $this->car->setWheels($wheels); return $this; } public function getResult() { return $this->car; } } class CarBuildDirector { private $builder; public function __construct(CarBuilder $builder) { $this->builder = $builder; } public function build() { $this->builder->setColour('Red'); $this->builder->setWheels(4); return $this; } public function getCar() { return $this->builder->getResult(); } } // Client $carBuilder = new CarBuilder(); $carBuildDirector = new CarBuildDirector($carBuilder); $car = $carBuildDirector->build()->getCar(); ``` 我们首先创建了一个具体的`Car`类,其中包含定义汽车一些基本特征的几种方法。然后我们创建了一个`CarBuilderInterface`,它将控制其中一些特征并获得最终结果(`car`)。具体类`CarBuilder`然后实现了`CarBuilderInterface`,接着是具体的`CarBuildDirector`类,它定义了构建和`getCar`方法。客户端只需实例化一个新的`CarBuilder`实例,并将其作为构造函数参数传递给一个新的`CarBuildDirector`实例。最后,我们调用`CarBuildDirector`的`build`和`getCar`方法来获得实际的汽车`Car`实例。 ## 工厂方法模式 `工厂`方法模式处理创建对象的问题,而无需指定将要创建的对象的确切类。 以下是工厂方法模式实现的示例: ```php interface Product { public function getType(); } interface ProductFactory { public function makeProduct(); } class SimpleProduct implements Product { public function getType() { return 'SimpleProduct'; } } class SimpleProductFactory implements ProductFactory { public function makeProduct() { return new SimpleProduct(); } } /* Client */ $factory = new SimpleProductFactory(); $product = $factory->makeProduct(); echo $product->getType(); //outputs: SimpleProduct ``` 我们首先创建了一个`ProductFactory`和`Product`接口。`SimpleProductFactory`实现了`ProductFactory`并通过其`makeProduct`方法返回新的`product`实例。`SimpleProduct`类实现了`Product`,并返回产品类型。最后,客户端创建了`SimpleProductFactory`的实例,并在其上调用`makeProduct`方法。`makeProduct`返回`Product`的实例,其`getType`方法返回`SimpleProduct`字符串。 ## 原型模式 原型模式通过克隆来复制其他对象。这意味着我们不是使用`new`关键字来实例化新对象。PHP 提供了一个`clone`关键字,它可以对对象进行浅复制,从而提供了非常直接的原型模式实现。浅复制不会复制引用,只会将值复制到新对象。我们还可以利用我们的类上的魔术`__clone`方法来实现更健壮的克隆行为。 以下是原型模式实现的示例: ```php class User { public $name; public $email; } class Employee extends User { public function __construct() { $this->name = 'Johhn Doe'; $this->email = 'john.doe@fake.mail'; } public function info() { return sprintf('%s, %s', $this->name, $this->email); } public function __clone() { /* additional changes for (after)clone behavior? */ } } $employee = new Employee(); echo $employee->info(); $director = clone $employee; $director->name = 'Jane Doe'; $director->email = 'jane.doe@fake.mail'; echo $director->info(); //outputs: Jane Doe, jane.doe@fake.mail ``` 我们首先创建了一个简单的`User`类。然后`Employee`类扩展了`User`类,并在其构造函数中设置了`name`和`email`。客户端通过`new`关键字实例化了`Employee`,并将其克隆到`director`变量中。`$director`变量现在是一个新实例,不是通过`new`关键字创建的,而是通过克隆使用`clone`关键字创建的。在`$director`上更改`name`和`email`不会影响`$employee`。 ## 单例模式 单例模式的目的是限制类的实例化为*单个*对象。它通过在类中创建一个方法来实现,如果不存在对象实例,则创建该类的新实例。如果对象实例已经存在,则该方法简单地返回对现有对象的引用。 以下是单例模式实现的示例: ```php class Logger { private static $instance; public static function getInstance() { if (!isset(self::$instance)) { self::$instance = new self; } return self::$instance; } public function logNotice($msg) { return 'logNotice: ' . $msg; } public function logWarning($msg) { return 'logWarning: ' . $msg; } public function logError($msg) { return 'logError: ' . $msg; } } // Client echo Logger::getInstance()->logNotice('test-notice'); echo Logger::getInstance()->logWarning('test-warning'); echo Logger::getInstance()->logError('test-error'); // Outputs: // logNotice: test-notice // logWarning: test-warning // logError: test-error ``` 我们首先创建了一个带有静态`$instance`成员和`getInstance`方法的`Logger`类,该方法始终返回类的单个实例。然后我们添加了一些示例方法,以演示客户端在单个实例上执行各种方法。 # 结构模式 结构模式处理类和对象的组合。使用接口或抽象类和方法,它们定义了组合对象的方式,从而获得新功能。以下是我们将作为结构模式进行分类的模式列表: + 适配器模式 + 桥接模式 + 组合模式 + 装饰器 + 外观模式 + 享元模式 + 代理 ### 注意 有关结构设计模式的更多信息,请参阅[`en.wikipedia.org/wiki/Structural_pattern`](https://en.wikipedia.org/wiki/Structural_pattern)。 ## 适配器模式 适配器模式允许使用现有类的接口来自另一个接口,基本上通过将一个类的接口转换为另一个类期望的接口,帮助两个不兼容的接口一起工作。 以下是适配器模式实现的示例: ```php class Stripe { public function capturePayment($amount) { /* Implementation... */ } public function authorizeOnlyPayment($amount) { /* Implementation... */ } public function cancelAmount($amount) { /* Implementation... */ } } interface PaymentService { public function capture($amount); public function authorize($amount); public function cancel($amount); } class StripePaymentServiceAdapter implements PaymentService { private $stripe; public function __construct(Stripe $stripe) { $this->stripe = $stripe; } public function capture($amount) { $this->stripe->capturePayment($amount); } public function authorize($amount) { $this->stripe->authorizeOnlyPayment($amount); } public function cancel($amount) { $this->stripe->cancelAmount($amount); } } // Client $stripe = new StripePaymentServiceAdapter(new Stripe()); $stripe->authorize(49.99); $stripe->capture(19.99); $stripe->cancel(9.99); ``` 我们首先创建了一个具体的`Stripe`类。然后定义了`PaymentService`接口,其中包含一些基本的支付处理方法。`StripePaymentServiceAdapter`实现了`PaymentService`接口,提供了支付处理方法的具体实现。最后,客户端实例化了`StripePaymentServiceAdapter`并执行了支付处理方法。 ## 桥接模式 桥接模式用于当我们想要将类或抽象与其实现解耦时,允许它们独立变化。当类和其实现经常变化时,这是很有用的。 以下是桥接模式实现的示例: ```php interface MailerInterface { public function setSender(MessagingInterface $sender); public function send($body); } abstract class Mailer implements MailerInterface { protected $sender; public function setSender(MessagingInterface $sender) { $this->sender = $sender; } } class PHPMailer extends Mailer { public function send($body) { $body .= "\n\n Sent from a phpmailer."; return $this->sender->send($body); } } class SwiftMailer extends Mailer { public function send($body) { $body .= "\n\n Sent from a SwiftMailer."; return $this->sender->send($body); } } interface MessagingInterface { public function send($body); } class TextMessage implements MessagingInterface { public function send($body) { echo 'TextMessage > send > $body: ' . $body; } } class HtmlMessage implements MessagingInterface { public function send($body) { echo 'HtmlMessage > send > $body: ' . $body; } } // Client $phpmailer = new PHPMailer(); $phpmailer->setSender(new TextMessage()); $phpmailer->send('Hi!'); $swiftMailer = new SwiftMailer(); $swiftMailer->setSender(new HtmlMessage()); $swiftMailer->send('Hello!'); ``` 我们首先创建了一个`MailerInterface`。具体的`Mailer`类然后实现了`MailerInterface`,为`PHPMailer`和`SwiftMailer`提供了一个基类。然后我们定义了`MessagingInterface`,它由`TextMessage`和`HtmlMessage`类实现。最后,客户端实例化`PHPMailer`和`SwiftMailer`,在调用`send`方法之前传递`TextMessage`和`HtmlMessage`的实例。 ## 组合模式 组合模式是关于将对象的层次结构视为单个对象,通过一个公共接口。对象被组合成三个结构,客户端对底层结构的更改毫不知情,因为它只消耗公共接口。 以下是组合模式实现的示例: ```php interface Graphic { public function draw(); } class CompositeGraphic implements Graphic { private $graphics = array(); public function add($graphic) { $objId = spl_object_hash($graphic); $this->graphics[$objId] = $graphic; } public function remove($graphic) { $objId = spl_object_hash($graphic); unset($this->graphics[$objId]); } public function draw() { foreach ($this->graphics as $graphic) { $graphic->draw(); } } } class Circle implements Graphic { public function draw() { echo 'draw-circle'; } } class Square implements Graphic { public function draw() { echo 'draw-square'; } } class Triangle implements Graphic { public function draw() { echo 'draw-triangle'; } } $circle = new Circle(); $square = new Square(); $triangle = new Triangle(); $compositeObj1 = new CompositeGraphic(); $compositeObj1->add($circle); $compositeObj1->add($triangle); $compositeObj1->draw(); $compositeObj2 = new CompositeGraphic(); $compositeObj2->add($circle); $compositeObj2->add($square); $compositeObj2->add($triangle); $compositeObj2->remove($circle); $compositeObj2->draw(); ``` 我们首先创建了一个`Graphic`接口。然后创建了`CompositeGraphic`、`Circle`、`Square`和`Triangle`,它们都实现了`Graphic`接口。除了实现`Graphic`接口的`draw`方法之外,`CompositeGraphic`还添加了另外两个方法,用于跟踪添加到其中的图形的内部集合。然后客户端实例化所有这些`Graphic`类,将它们全部添加到`CompositeGraphic`中,然后调用`draw`方法。 ## 装饰器模式 装饰器模式允许向单个对象实例添加行为,而不影响同一类的其他实例的行为。我们可以定义多个装饰器,每个装饰器都添加新功能。 以下是装饰器模式实现的示例: ```php interface LoggerInterface { public function log($message); } class Logger implements LoggerInterface { public function log($message) { file_put_contents('app.log', $message, FILE_APPEND); } } abstract class LoggerDecorator implements LoggerInterface { protected $logger; public function __construct(Logger $logger) { $this->logger = $logger; } abstract public function log($message); } class ErrorLoggerDecorator extends LoggerDecorator { public function log($message) { $this->logger->log('ERROR: ' . $message); } } class WarningLoggerDecorator extends LoggerDecorator { public function log($message) { $this->logger->log('WARNING: ' . $message); } } class NoticeLoggerDecorator extends LoggerDecorator { public function log($message) { $this->logger->log('NOTICE: ' . $message); } } $logger = new Logger(); $logger->log('Resource not found.'); $logger = new Logger(); $logger = new ErrorLoggerDecorator($logger); $logger->log('Invalid user role.'); $logger = new Logger(); $logger = new WarningLoggerDecorator($logger); $logger->log('Missing address parameters.'); $logger = new Logger(); $logger = new NoticeLoggerDecorator($logger); $logger->log('Incorrect type provided.'); ``` 我们首先创建了一个`LoggerInterface`,其中包含一个简单的`log`方法。然后定义了`Logger`和`LoggerDecorator`,它们都实现了`LoggerInterface`。然后是`ErrorLoggerDecorator`、`WarningLoggerDecorator`和`NoticeLoggerDecorator`,它们实现了`LoggerDecorator`。最后,客户端部分实例化了`logger`三次,并传递了不同的装饰器。 ## 外观模式 外观模式用于通过一个更简单的接口简化大型系统的复杂性。它通过为客户端提供方便的方法来执行大多数常见任务,通过一个单一的包装类来实现。 以下是外观模式实现的示例: ```php class Product { public function getQty() { // Implementation } } class QuickOrderFacade { private $product = null; private $orderQty = null; public function __construct($product, $orderQty) { $this->product = $product; $this->orderQty = $orderQty; } public function generateOrder() { if ($this->qtyCheck()) { $this->addToCart(); $this->calculateShipping(); $this->applyDiscount(); $this->placeOrder(); } } private function addToCart() { // Implementation... } private function qtyCheck() { if ($this->product->getQty() > $this->orderQty) { return true; } else { return true; } } private function calculateShipping() { // Implementation... } private function applyDiscount() { // Implementation... } private function placeOrder() { // Implementation... } } // Client $order = new QuickOrderFacade(new Product(), $qty); $order->generateOrder(); ``` 我们首先创建了一个`Product`类,其中包含一个`getQty`方法。然后创建了一个`QuickOrderFacade`类,它通过`constructor`接受`product`实例和数量,并进一步提供了`generateOrder`方法,该方法汇总了所有生成订单的操作。最后,客户端实例化了`product`,将其传递给`QuickOrderFacade`的实例,并调用了其上的`generateOrder`。 ## 享元模式 享元模式关乎性能和资源的减少,在相似对象之间尽可能共享数据。这意味着相同的类实例在实现中是共享的。当预计会创建大量相同类的实例时,这种方法效果最佳。 以下是享元模式实现的示例: ```php interface Shape { public function draw(); } class Circle implements Shape { private $colour; private $radius; public function __construct($colour) { $this->colour = $colour; } public function draw() { echo sprintf('Colour %s, radius %s.', $this->colour, $this->radius); } public function setRadius($radius) { $this->radius = $radius; } } class ShapeFactory { private $circleMap; public function getCircle($colour) { if (!isset($this->circleMap[$colour])) { $circle = new Circle($colour); $this->circleMap[$colour] = $circle; } return $this->circleMap[$colour]; } } // Client $shapeFactory = new ShapeFactory(); $circle = $shapeFactory->getCircle('yellow'); $circle->setRadius(10); $circle->draw(); $shapeFactory = new ShapeFactory(); $circle = $shapeFactory->getCircle('orange'); $circle->setRadius(15); $circle->draw(); $shapeFactory = new ShapeFactory(); $circle = $shapeFactory->getCircle('yellow'); $circle->setRadius(20); $circle->draw(); ``` 我们首先创建了一个`Shape`接口,其中包含一个`draw`方法。然后我们定义了实现`Shape`接口的`Circle`类,接着是`ShapeFactory`类。在`ShapeFactory`中,`getCircle`方法根据`color`选项返回一个新的`Circle`实例。最后,客户端实例化了几个`ShapeFactory`对象,并传入不同的颜色到`getCircle`方法中。 ## 代理模式 代理设计模式作为原始对象的接口在后台运行。它可以充当简单的转发包装器,甚至在包装的对象周围提供额外的功能。额外添加的功能示例可能是懒加载或缓存,可以弥补原始对象的资源密集操作。 以下是代理模式实现的示例: ```php interface ImageInterface { public function draw(); } class Image implements ImageInterface { private $file; public function __construct($file) { $this->file = $file; sleep(5); // Imagine resource intensive image load } public function draw() { echo 'image: ' . $this->file; } } class ProxyImage implements ImageInterface { private $image = null; private $file; public function __construct($file) { $this->file = $file; } public function draw() { if (is_null($this->image)) { $this->image = new Image($this->file); } $this->image->draw(); } } // Client $image = new Image('image.png'); // 5 seconds $image->draw(); $image = new ProxyImage('image.png'); // 0 seconds $image->draw(); ``` 我们首先创建了一个`ImageInterface`,其中包含一个`draw`方法。然后我们定义了`Image`和`ProxyImage`类,它们都扩展了`ImageInterface`。在`Image`类的`__construct`中,我们使用`sleep`方法模拟了**资源密集**的操作。最后,客户端实例化了`Image`和`ProxyImage`,展示了两者之间的执行时间差异。 # 行为模式 行为模式解决了各种对象之间通信的挑战。它们描述了不同对象和类如何相互发送消息以实现事情发生。以下是我们归类为行为模式的模式列表: + 责任链 + 命令 + 解释器 + 迭代器 + 中介者 + 备忘录 + 观察者 + 状态 + 策略 + 模板方法 + 访问者 ## 责任链模式 责任链模式通过以链式方式启用多个对象处理请求,将请求的发送者与接收者解耦。各种类型的处理对象可以动态添加到链中。使用递归组合链允许无限数量的处理对象。 以下是责任链模式实现的示例: ```php abstract class SocialNotifier { private $notifyNext = null; public function notifyNext(SocialNotifier $notifier) { $this->notifyNext = $notifier; return $this->notifyNext; } final public function push($message) { $this->publish($message); if ($this->notifyNext !== null) { $this->notifyNext->push($message); } } abstract protected function publish($message); } class TwitterSocialNotifier extends SocialNotifier { public function publish($message) { // Implementation... } } class FacebookSocialNotifier extends SocialNotifier { protected function publish($message) { // Implementation... } } class PinterestSocialNotifier extends SocialNotifier { protected function publish($message) { // Implementation... } } // Client $notifier = new TwitterSocialNotifier(); $notifier->notifyNext(new FacebookSocialNotifier()) ->notifyNext(new PinterestSocialNotifier()); $notifier->push('Awesome new product available!'); ``` 我们首先创建了一个抽象的`SocialNotifier`类,其中包含抽象方法`publish`,`notifyNext`和`push`方法的实现。然后我们定义了`TwitterSocialNotifier`,`FacebookSocialNotifier`和`PinterestSocialNotifier`,它们都扩展了抽象的`SocialNotifier`。客户端首先实例化了`TwitterSocialNotifier`,然后进行了两次`notifyNext`调用,传递了两种其他`notifier`类型的实例,然后调用了最终的`push`方法。 ## 命令模式 命令模式将执行特定操作的对象与知道如何使用它的对象解耦。它通过封装后续执行某个动作所需的所有相关信息来实现。这意味着关于对象、方法名称和方法参数的信息。 以下是命令模式的实现示例: ```php interface LightBulbCommand { public function execute(); } class LightBulbControl { public function turnOn() { echo 'LightBulb turnOn'; } public function turnOff() { echo 'LightBulb turnOff'; } } class TurnOnLightBulb implements LightBulbCommand { private $lightBulbControl; public function __construct(LightBulbControl $lightBulbControl) { $this->lightBulbControl = $lightBulbControl; } public function execute() { $this->lightBulbControl->turnOn(); } } class TurnOffLightBulb implements LightBulbCommand { private $lightBulbControl; public function __construct(LightBulbControl $lightBulbControl) { $this->lightBulbControl = $lightBulbControl; } public function execute() { $this->lightBulbControl->turnOff(); } } // Client $command = new TurnOffLightBulb(new LightBulbControl()); $command->execute(); ``` 我们首先创建了一个`LightBulbCommand`接口。然后我们定义了`LightBulbControl`类,提供了两个简单的`turnOn` / `turnOff`方法。然后我们定义了实现`LightBulbCommand`接口的`TurnOnLightBulb`和`TurnOffLightBulb`类。最后,客户端实例化了`TurnOffLightBulb`对象,并在其上调用了`execute`方法。 ## 解释器模式 解释器模式指定了如何评估语言语法或表达式。我们定义了语言语法的表示以及解释器。语言语法的表示使用复合类层次结构,其中规则映射到类。然后解释器使用表示来解释语言中的表达式。 以下是解释器模式实现的示例: ```php interface MathExpression { public function interpret(array $values); } class Variable implements MathExpression { private $char; public function __construct($char) { $this->char = $char; } public function interpret(array $values) { return $values[$this->char]; } } class Literal implements MathExpression { private $value; public function __construct($value) { $this->value = $value; } public function interpret(array $values) { return $this->value; } } class Sum implements MathExpression { private $x; private $y; public function __construct(MathExpression $x, MathExpression $y) { $this->x = $x; $this->y = $y; } public function interpret(array $values) { return $this->x->interpret($values) + $this->y->interpret($values); } } class Product implements MathExpression { private $x; private $y; public function __construct(MathExpression $x, MathExpression $y) { $this->x = $x; $this->y = $y; } public function interpret(array $values) { return $this->x->interpret($values) * $this->y->interpret($values); } } // Client $expression = new Product( new Literal(5), new Sum( new Variable('c'), new Literal(2) ) ); echo $expression->interpret(array('c' => 3)); // 25 ``` 我们首先创建了一个`MathExpression`接口,具有一个`interpret`方法。然后添加了`Variable`、`Literal`、`Sum`和`Product`类,它们都实现了`MathExpression`接口。然后客户端从`Product`类实例化,将`Literal`和`Sum`的实例传递给它,并最后调用`interpret`方法。 ## 迭代器模式 迭代器模式用于遍历容器并访问其元素。换句话说,一个类变得能够遍历另一个类的元素。PHP 原生支持迭代器,作为内置的`\Iterator`和`\IteratorAggregate`接口的一部分。 以下是迭代器模式实现的示例: ```php class ProductIterator implements \Iterator { private $position = 0; private $productsCollection; public function __construct(ProductCollection $productsCollection) { $this->productsCollection = $productsCollection; } public function current() { return $this->productsCollection->getProduct($this->position); } public function key() { return $this->position; } public function next() { $this->position++; } public function rewind() { $this->position = 0; } public function valid() { return !is_null($this->productsCollection->getProduct($this->position)); } } class ProductCollection implements \IteratorAggregate { private $products = array(); public function getIterator() { return new ProductIterator($this); } public function addProduct($string) { $this->products[] = $string; } public function getProduct($key) { if (isset($this->products[$key])) { return $this->products[$key]; } return null; } public function isEmpty() { return empty($products); } } $products = new ProductCollection(); $products->addProduct('T-Shirt Red'); $products->addProduct('T-Shirt Blue'); $products->addProduct('T-Shirt Green'); $products->addProduct('T-Shirt Yellow'); foreach ($products as $product) { var_dump($product); } ``` 我们首先创建了一个实现标准 PHP`\Iterator`接口的`ProductIterator`。然后添加了实现标准 PHP`\IteratorAggregate`接口的`ProductCollection`。客户端创建了一个`ProductCollection`的实例,通过`addProduct`方法调用将值堆叠到其中,并循环遍历整个集合。 ## 中介者模式 我们的软件中有更多的类,它们的通信变得更加复杂。中介者模式通过将复杂性封装到中介者对象中来解决这个问题。对象不再直接通信,而是通过中介者对象,从而降低了整体耦合度。 以下是中介者模式实现的示例: ```php interface MediatorInterface { public function fight(); public function talk(); public function registerA(ColleagueA $a); public function registerB(ColleagueB $b); } class ConcreteMediator implements MediatorInterface { protected $talk; // ColleagueA protected $fight; // ColleagueB public function registerA(ColleagueA $a) { $this->talk = $a; } public function registerB(ColleagueB $b) { $this->fight = $b; } public function fight() { echo 'fighting...'; } public function talk() { echo 'talking...'; } } abstract class Colleague { protected $mediator; // MediatorInterface public abstract function doSomething(); } class ColleagueA extends Colleague { public function __construct(MediatorInterface $mediator) { $this->mediator = $mediator; $this->mediator->registerA($this); } public function doSomething() { $this->mediator->talk(); } } class ColleagueB extends Colleague { public function __construct(MediatorInterface $mediator) { $this->mediator = $mediator; $this->mediator->registerB($this); } public function doSomething() { $this->mediator->fight(); } } // Client $mediator = new ConcreteMediator(); $talkColleague = new ColleagueA($mediator); $fightColleague = new ColleagueB($mediator); $talkColleague->doSomething(); $fightColleague->doSomething(); ``` 我们首先创建了一个具有多个方法的`MediatorInterface`,由`ConcreteMediator`类实现。然后我们定义了抽象类`Colleague`,强制在以下`ColleagueA`和`ColleagueB`类上实现`doSomething`方法。客户端首先实例化`ConcreteMediator`,然后将其实例传递给`ColleagueA`和`ColleagueB`的实例,然后调用`doSomething`方法。 ## 备忘录模式 备忘录模式提供了对象恢复功能。实现是通过三个不同的对象完成的;原始者、caretaker 和 memento,其中原始者是保留内部状态以便以后恢复的对象。 以下是备忘录模式实现的示例: ```php class Memento { private $state; public function __construct($state) { $this->state = $state; } public function getState() { return $this->state; } } class Originator { private $state; public function setState($state) { return $this->state = $state; } public function getState() { return $this->state; } public function saveToMemento() { return new Memento($this->state); } public function restoreFromMemento(Memento $memento) { $this->state = $memento->getState(); } } // Client - Caretaker $savedStates = array(); $originator = new Originator(); $originator->setState('new'); $originator->setState('pending'); $savedStates[] = $originator->saveToMemento(); $originator->setState('processing'); $savedStates[] = $originator->saveToMemento(); $originator->setState('complete'); $originator->restoreFromMemento($savedStates[1]); echo $originator->getState(); // processing ``` 我们首先创建了一个`Memento`类,它通过`getState`方法提供对象的当前状态。然后我们定义了`Originator`类,将状态推送到`Memento`。最后,客户端通过实例化`Originator`来扮演`caretaker`的角色,在其少数状态之间进行切换,保存并从`memento`中恢复它们。 ## 观察者模式 观察者模式实现了对象之间的一对多依赖关系。持有依赖项列表的对象称为**subject**,而依赖项称为**observers**。当主题对象改变状态时,所有依赖项都会被通知并自动更新。 以下是观察者模式实现的示例: ```php class Customer implements \SplSubject { protected $data = array(); protected $observers = array(); public function attach(\SplObserver $observer) { $this->observers[] = $observer; } public function detach(\SplObserver $observer) { $index = array_search($observer, $this->observers); if ($index !== false) { unset($this->observers[$index]); } } public function notify() { foreach ($this->observers as $observer) { $observer->update($this); echo 'observer updated'; } } public function __set($name, $value) { $this->data[$name] = $value; // notify the observers, that user has been updated $this->notify(); } } class CustomerObserver implements \SplObserver { public function update(\SplSubject $subject) { /* Implementation... */ } } // Client $user = new Customer(); $customerObserver = new CustomerObserver(); $user->attach($customerObserver); $user->name = 'John Doe'; $user->email = 'john.doe@fake.mail'; ``` 我们首先创建了一个实现标准 PHP`\SplSubject`接口的`Customer`类。然后定义了一个实现标准 PHP`\SplObserver`接口的`CustomerObserver`类。最后,客户端实例化了`Customer`和`CustomerObserver`对象,并将`CustomerObserver`对象附加到`Customer`上。然后`observer`捕捉到`name`和`email`属性的任何更改。 ## 状态模式 状态模式封装了基于其内部状态的相同对象的不同行为,使对象看起来好像已经改变了它的类。 以下是状态模式实现的示例: ```php interface Statelike { public function writeName(StateContext $context, $name); } class StateLowerCase implements Statelike { public function writeName(StateContext $context, $name) { echo strtolower($name); $context->setState(new StateMultipleUpperCase()); } } class StateMultipleUpperCase implements Statelike { private $count = 0; public function writeName(StateContext $context, $name) { $this->count++; echo strtoupper($name); /* Change state after two invocations */ if ($this->count > 1) { $context->setState(new StateLowerCase()); } } } class StateContext { private $state; public function setState(Statelike $state) { $this->state = $state; } public function writeName($name) { $this->state->writeName($this, $name); } } // Client $stateContext = new StateContext(); $stateContext->setState(new StateLowerCase()); $stateContext->writeName('Monday'); $stateContext->writeName('Tuesday'); $stateContext->writeName('Wednesday'); $stateContext->writeName('Thursday'); $stateContext->writeName('Friday'); $stateContext->writeName('Saturday'); $stateContext->writeName('Sunday'); ``` 我们首先创建了一个`Statelike`接口,然后是实现该接口的`StateLowerCase`和`StateMultipleUpperCase`。`StateMultipleUpperCase`在其`writeName`中添加了一些计数逻辑,因此在两次调用后会启动新状态。然后我们定义了`StateContext`类,我们将使用它来切换上下文。最后,客户端实例化了`StateContext`,并通过`setState`方法将`StateLowerCase`的实例传递给它,然后调用了几次`writeName`方法。 ## 策略模式 策略模式定义了一组算法,每个算法都被封装并与该组内的其他成员交换使用。 以下是策略模式实现的示例: ```php interface PaymentStrategy { public function pay($amount); } class StripePayment implements PaymentStrategy { public function pay($amount) { echo 'StripePayment...'; } } class PayPalPayment implements PaymentStrategy { public function pay($amount) { echo 'PayPalPayment...'; } } class Checkout { private $amount = 0; public function __construct($amount = 0) { $this->amount = $amount; } public function capturePayment() { if ($this->amount > 99.99) { $payment = new PayPalPayment(); } else { $payment = new StripePayment(); } $payment->pay($this->amount); } } $checkout = new Checkout(49.99); $checkout->capturePayment(); // StripePayment... $checkout = new Checkout(199.99); $checkout->capturePayment(); // PayPalPayment... ``` 我们首先创建了一个`PaymentStrategy`接口,然后是实现它的具体类`StripePayment`和`PayPalPayment`。然后我们定义了`Checkout`类,在`capturePayment`方法中加入了一些决策逻辑。最后,客户端通过构造函数传递一定金额来实例化`Checkout`。根据金额,`Checkout`在调用`capturePayment`时内部触发一个或另一个`payment`。 ## 模板模式 模板设计模式定义了算法在方法中的程序骨架。它让我们通过类覆盖的方式重新定义算法的某些步骤,而不真正改变算法的结构。 以下是模板模式实现的示例: ```php abstract class Game { private $playersCount; abstract function initializeGame(); abstract function makePlay($player); abstract function endOfGame(); abstract function printWinner(); public function playOneGame($playersCount) { $this->playersCount = $playersCount; $this->initializeGame(); $j = 0; while (!$this->endOfGame()) { $this->makePlay($j); $j = ($j + 1) % $playersCount; } $this->printWinner(); } } class Monopoly extends Game { public function initializeGame() { // Implementation... } public function makePlay($player) { // Implementation... } public function endOfGame() { // Implementation... } public function printWinner() { // Implementation... } } class Chess extends Game { public function initializeGame() { // Implementation... } public function makePlay($player) { // Implementation... } public function endOfGame() { // Implementation... } public function printWinner() { // Implementation... } } $game = new Chess(); $game->playOneGame(2); $game = new Monopoly(); $game->playOneGame(4); ``` 我们首先创建了一个提供了封装游戏玩法的所有抽象方法的抽象`Game`类。然后我们定义了`Monopoly`和`Chess`类,它们都是从`Game`类继承的,为每个游戏实现了特定的游戏玩法方法。客户端只需实例化`Monopoly`和`Chess`对象,然后在每个对象上调用`playOneGame`方法。 ## 访问者模式 访问者设计模式是一种将算法与其操作的对象结构分离的方法。因此,我们能够向现有的对象结构添加新的操作,而不实际修改这些结构。 以下是访问者模式实现的示例: ```php interface RoleVisitorInterface { public function visitUser(User $role); public function visitGroup(Group $role); } class RolePrintVisitor implements RoleVisitorInterface { public function visitGroup(Group $role) { echo 'Role: ' . $role->getName(); } public function visitUser(User $role) { echo 'Role: ' . $role->getName(); } } abstract class Role { public function accept(RoleVisitorInterface $visitor) { $klass = get_called_class(); preg_match('#([^\\\\]+)$#', $klass, $extract); $visitingMethod = 'visit' . $extract[1]; if (!method_exists(__NAMESPACE__ . '\RoleVisitorInterface', $visitingMethod)) { throw new \InvalidArgumentException("The visitor you provide cannot visit a $klass instance"); } call_user_func(array($visitor, $visitingMethod), $this); } } class User extends Role { protected $name; public function __construct($name) { $this->name = (string)$name; } public function getName() { return 'User ' . $this->name; } } class Group extends Role { protected $name; public function __construct($name) { $this->name = (string)$name; } public function getName() { return 'Group: ' . $this->name; } } $group = new Group('my group'); $user = new User('my user'); $visitor = new RolePrintVisitor; $group->accept($visitor); $user->accept($visitor); ``` 我们首先创建了一个`RoleVisitorInterface`,然后是实现`RoleVisitorInterface`的`RolePrintVisitor`。然后我们定义了抽象类`Role`,其中包含一个接受`RoleVisitorInterface`参数类型的方法。我们进一步定义了具体的`User`和`Group`类,它们都是从`Role`继承的。客户端实例化了`User`、`Group`和`RolePrintVisitor`,并将`visitor`传递给`User`和`Group`实例的`accept`方法调用。 # 摘要 设计模式是开发人员的一种常见的高级语言。它们使团队成员之间能够以简化的方式交流应用程序设计。了解如何识别和实现设计模式将我们的重点转移到解决业务需求,而不是在代码层面上如何将解决方案粘合在一起。 编码,就像大多数手工制作的学科一样,是你得到你所付出的。虽然实现一些设计模式需要一定的时间,但在较大的项目中不这样做可能会在未来以某种方式追上我们。与“使用框架还是不使用框架”辩论类似,实现正确的设计模式会影响我们代码的*可扩展性*、*可重用性*、*适应性*和*可维护性*。因此,使其更具未来性。 在接下来的章节中,我们将深入研究 SOLID 设计原则及其在软件开发过程中的作用。 # 第三章:SOLID 设计原则 构建模块化软件需要对类设计有很强的了解。有许多指南,涉及我们如何命名我们的类,它们应该有多少变量,方法的大小应该是多少等等。PHP 生态系统成功地将这些打包成官方的 PSR 标准,更确切地说是 PSR-1:基本编码标准和 PSR-2:编码风格指南。这些都是保持我们的代码可读、可理解和可维护的一般编程指南。 除了编程指南,我们还可以在类设计过程中应用更具体的设计原则。这些原则涉及低耦合、高内聚和强封装的概念。我们称之为 SOLID 设计原则,这是罗伯特·塞西尔·马丁在 21 世纪初提出的一个术语。 SOLID 是以下五个原则的首字母缩写: + S:单一职责原则(SRP) + O:开放/封闭原则(OCP) + L:里氏替换原则(LSP) + I:接口隔离原则(ISP) + D:依赖倒置原则(DIP) 十多年前,SOLID 原则的概念远未过时,因为它们是良好类设计的核心。在本章中,我们将深入研究这些原则,通过观察一些明显违反原则的违规行为来了解它们。 在本章中,我们将涵盖以下主题: + 单一职责原则 + 开放/封闭原则 + 里氏替换原则 + 接口隔离原则 + 依赖倒置原则 # 单一职责原则 单一职责原则处理试图做太多事情的类。这里的责任指的是改变的原因。根据罗伯特·C·马丁的定义: > “一个类应该只有一个改变的原因。” 以下是一个违反 SRP 的类的示例: ```php class Ticket { const SEVERITY_LOW = 'low'; const SEVERITY_HIGH = 'high'; // ... protected $title; protected $severity; protected $status; protected $conn; public function __construct(\PDO $conn) { $this->conn = $conn; } public function setTitle($title) { $this->title = $title; } public function setSeverity($severity) { $this->severity = $severity; } public function setStatus($status) { $this->status = $status; } private function validate() { // Implementation... } public function save() { if ($this->validate()) { // Implementation... } } } // Client $conn = new PDO(/* ... */); $ticket = new Ticket($conn); $ticket->setTitle('Checkout not working!'); $ticket->setStatus(Ticket::STATUS_OPEN); $ticket->setSeverity(Ticket::SEVERITY_HIGH); $ticket->save(); ``` `Ticket`类处理`ticket`实体的验证和保存。这两个责任是它改变的原因。每当关于票证验证或保存的要求发生变化时,`Ticket`类都必须进行修改。为了解决这里的 SRP 违规问题,我们可以使用辅助类和接口来分割责任。 以下是符合 SRP 的重构实现的示例: ```php interface KeyValuePersistentMembers { public function toArray(); } class Ticket implements KeyValuePersistentMembers { const STATUS_OPEN = 'open'; const SEVERITY_HIGH = 'high'; //... protected $title; protected $severity; protected $status; public function setTitle($title) { $this->title = $title; } public function setSeverity($severity) { $this->severity = $severity; } public function setStatus($status) { $this->status = $status; } public function toArray() { // Implementation... } } class EntityManager { protected $conn; public function __construct(\PDO $conn) { $this->conn = $conn; } public function save(KeyValuePersistentMembers $entity) { // Implementation... } } class Validator { public function validate(KeyValuePersistentMembers $entity) { // Implementation... } } // Client $conn = new PDO(/* ... */); $ticket = new Ticket(); $ticket->setTitle('Payment not working!'); $ticket->setStatus(Ticket::STATUS_OPEN); $ticket->setSeverity(Ticket::SEVERITY_HIGH); $validator = new Validator(); if ($validator->validate($ticket)) { $entityManager = new EntityManager($conn); $entityManager->save($ticket); } ``` 在这里,我们引入了一个简单的`KeyValuePersistentMembers`接口,其中有一个`toArray`方法,然后将其用于`EntityManager`和`Validator`类,这两个类现在都承担了单一职责。`Ticket`类变成了一个简单的数据持有模型,而客户端现在控制*实例化*、*验证*和*保存*作为三个不同的步骤。虽然这当然不是如何分离责任的通用公式,但它提供了一个简单明了的例子来解决这个问题。 在考虑单一职责原则的情况下进行设计会产生更小、更易读和更易测试的代码。 # 开放/封闭原则 开放/封闭原则规定一个类应该对扩展开放,但对修改封闭,根据维基百科上的定义: > “软件实体(类、模块、函数等)应该对扩展开放,但对修改封闭” 对于扩展开放的部分意味着我们应该设计我们的类,以便在需要时可以添加新功能。对于修改封闭的部分意味着这个新功能应该适合而不修改原始类。类只应在修复错误时进行修改,而不是添加新功能。 以下是一个违反开放/封闭原则的类的示例: ```php class CsvExporter { public function export($data) { // Implementation... } } class XmlExporter { public function export($data) { // Implementation... } } class GenericExporter { public function exportToFormat($data, $format) { if ('csv' === $format) { $exporter = new CsvExporter(); } elseif ('xml' === $format) { $exporter = new XmlExporter(); } else { throw new \Exception('Unknown export format!'); } return $exporter->export($data); } } ``` 在这里,我们有两个具体类,`CsvExporter`和`XmlExporter`,每个都有一个单一的职责。然后我们有一个`GenericExporter`,其`exportToFormat`方法实际上触发了适当实例类型上的`export`函数。问题在于我们无法在不修改`GenericExporter`类的情况下添加新类型的导出器。换句话说,`GenericExporter`对扩展不开放,对修改封闭。 以下是符合 OCP 的重构实现的一个例子: ```php interface ExporterFactoryInterface { public function buildForFormat($format); } interface ExporterInterface { public function export($data); } class CsvExporter implements ExporterInterface { public function export($data) { // Implementation... } } class XmlExporter implements ExporterInterface { public function export($data) { // Implementation... } } class ExporterFactory implements ExporterFactoryInterface { private $factories = array(); public function addExporterFactory($format, callable $factory) { $this->factories[$format] = $factory; } public function buildForFormat($format) { $factory = $this->factories[$format]; $exporter = $factory(); // the factory is a callable return $exporter; } } class GenericExporter { private $exporterFactory; public function __construct(ExporterFactoryInterface $exporterFactory) { $this->exporterFactory = $exporterFactory; } public function exportToFormat($data, $format) { $exporter = $this->exporterFactory->buildForFormat($format); return $exporter->export($data); } } // Client $exporterFactory = new ExporterFactory(); $exporterFactory->addExporterFactory( 'xml', function () { return new XmlExporter(); } ); $exporterFactory->addExporterFactory( 'csv', function () { return new CsvExporter(); } ); $data = array(/* ... some export data ... */); $genericExporter = new GenericExporter($exporterFactory); $csvEncodedData = $genericExporter->exportToFormat($data, 'csv'); ``` 在这里,我们添加了两个接口,`ExporterFactoryInterface`和`ExporterInterface`。然后修改了`CsvExporter`和`XmlExporter`以实现该接口。添加了`ExporterFactory`,实现了`ExporterFactoryInterface`。它的主要作用由`buildForFormat`方法定义,该方法返回导出器作为回调函数。最后,`GenericExporter`被重写以通过其构造函数接受`ExporterFactoryInterface`,其`exportToFormat`方法现在通过导出器工厂构建导出器并调用其`execute`方法。 客户端本身现在扮演了更加强大的角色,首先实例化了`ExporterFactory`并向其中添加了两个导出器,然后将其传递给`GenericExporter`。现在向`GenericExporter`添加新的导出格式不再需要修改它,因此使其对扩展开放,对修改封闭。这绝不是一个通用的公式,而是一种可能的满足 OCP 的方法概念。 # 里氏替换原则 **里氏替换原则**讨论了继承。它指定了我们应该如何设计我们的类,以便客户端依赖项可以被子类替换而客户端看不到差异,根据维基百科上的定义: > “程序中的对象应该能够被其子类型的实例替换,而不会改变程序的正确性” 虽然子类可能添加了一些特定的功能,但它必须符合与其基类相同的行为。否则,违反了里氏原则。 在涉及 PHP 和子类化时,我们必须超越简单的具体类,并区分:具体类、抽象类和接口。这三者都可以放在基类的上下文中,而扩展或实现它的所有内容都可以被视为派生类。 以下是 LSP 违规的一个例子,派生类没有实现所有方法: ```php interface User { public function getEmail(); public function getName(); public function getAge(); } class Employee implements User { public function getEmail() { // Implementation... } public function getAge() { // Implementation... } } ``` 在这里,我们看到一个`employee`类,它没有实现接口强制执行的`getName`方法。我们本可以使用抽象类而不是接口和抽象方法类型来代替`getName`方法,效果将是相同的。幸运的是,在这种情况下,PHP 会抛出错误,警告我们并没有完全实现接口。 以下是违反里氏原则的一个例子,不同的派生类返回不同类型的东西: ```php class UsersCollection implements \Iterator { // Implementation... } interface UserList { public function getUsers(); } class Emloyees implements UserList { public function getUsers() { $users = new UsersCollection(); //... return $users; } } class Directors implements UserList { public function getUsers() { $users = array(); //... return $users; } } ``` 在这里,我们看到一个边缘案例的简单例子。在两个派生类上调用`getUsers`将返回一个我们可以循环遍历的结果。然而,PHP 开发人员倾向于经常在数组结构上使用`count`方法,并且在`Employees`实例上使用`getUsers`结果将不起作用。这是因为`Employees`类返回实现了`Iterator`的`UsersCollection`,而不是实际的数组结构。由于`UsersCollection`没有实现`Countable`,我们无法在其上使用`count`,这可能会导致潜在的错误。 我们还可以在派生类对方法参数的处理上发现 LSP 违规的情况。这些通常可以通过使用`type`运算符的实例来发现,如下例所示: ```php interface LoggerProcessor { public function log(LoggerInterface $logger); } class XmlLogger implements LoggerInterface { // Implementation... } class JsonLogger implements LoggerInterface { // Implementation... } class FileLogger implements LoggerInterface { // Implementation... } class Processor implements LoggerProcessor { public function log(LoggerInterface $logger) { if ($logger instanceof XmlLogger) { throw new \Exception('This processor does not work with XmlLogger'); } else { // Implementation... } } } ``` 在这里,派生类`Processor`对方法参数施加了限制,而它应该接受符合`LoggerInterface`的一切。通过变得不那么宽容,它改变了基类 implied 的行为,在这种情况下是`LoggerInterface`。 所述示例仅仅是构成 LSP 违规的一部分。为了满足这一原则,我们需要确保派生类不以任何方式改变基类所施加的行为。 # 接口隔离原则 **接口隔离原则**规定客户端只应实现它们实际使用的接口。它们不应被强制实现它们不使用的接口。根据维基百科上的定义: > *"许多特定于客户端的接口比一个通用接口更好"* 这意味着我们应该将大而臃肿的接口分割成几个小而轻的接口,将其分离,使得较小的接口基于一组方法,每个方法提供一种特定的功能。 让我们来看一个违反 ISP 的漏洞抽象: ```php interface Appliance { public function powerOn(); public function powerOff(); public function bake(); public function mix(); public function wash(); } class Oven implements Appliance { public function powerOn() { /* Implement ... */ } public function powerOff() { /* Implement ... */ } public function bake() { /* Implement... */ } public function mix() { /* Nothing to implement ... */ } public function wash() { /* Cannot implement... */ } } class Mixer implements Appliance { public function powerOn() { /* Implement... */ } public function powerOff() { /* Implement... */ } public function bake() { /* Cannot implement... */ } public function mix() { /* Implement... */ } public function wash() { /* Cannot implement... */ } } class WashingMachine implements Appliance { public function powerOn() { /* Implement... */ } public function powerOff() { /* Implement... */ } public function bake() { /* Cannot implement... */ } public function mix() { /* Cannot implement... */ } public function wash() { /* Implement... */ } } ``` 在这里,我们有一个接口为几个与电器相关的方法设置要求。然后我们有几个实现该接口的类。问题是非常明显的;并非所有的电器都可以被挤进同一个接口。强迫洗衣机实现烘烤和混合方法是没有意义的。这些方法需要分别分成自己的接口。这样具体的电器类只需要实现实际有意义的方法。 # 依赖反转原则 **依赖反转原则**规定实体应该依赖于抽象而不是具体实现。也就是说,高级模块不应该依赖于低级模块,而应该依赖于抽象。根据维基百科上的定义: > *"一个应该依赖于抽象。不要依赖于具体实现。"* 这个原则很重要,因为它在解耦我们的软件中起着重要作用。 以下是一个违反 DIP 的类的示例: ```php class Mailer { // Implementation... } class NotifySubscriber { public function notify($emailTo) { $mailer = new Mailer(); $mailer->send('Thank you for...', $emailTo); } } ``` 在这里,我们可以看到`NotifySubscriber`类中的`notify`方法编写了对`Mailer`类的依赖。这导致了紧密耦合的代码,这正是我们试图避免的。为了纠正问题,我们可以通过类构造函数传递依赖,或者可能通过其他方法。此外,我们应该远离具体类依赖,转向抽象类依赖,就像在这里所示的纠正示例中所示的那样: ```php interface MailerInterface { // Implementation... } class Mailer implements MailerInterface { // Implementation... } class NotifySubscriber { private $mailer; public function __construct(MailerInterface $mailer) { $this->mailer = $mailer; } public function notify($emailTo) { $this->mailer->send('Thank you for...', $emailTo); } } ``` 在这里,我们看到一个依赖通过构造函数注入。注入是通过类型提示接口和实际的具体类来抽象的。这使得我们的代码耦合度较低。DIP 可以在任何时候使用,当一个类需要调用另一个类的方法,或者我们应该说向其发送消息时。 # 总结 在模块化开发方面,可扩展性是需要不断考虑的事情。编写一个将自己锁定的代码很可能会导致将来无法将其与其他项目或库集成。虽然 SOLID 设计原则可能看起来有些过分,但积极应用这些原则很可能会导致组件易于在时间上进行维护和扩展。 采用 SOLID 原则进行类设计,可以使我们的代码为未来的变化做好准备。它通过将这些变化局部化和最小化在我们的类中,使得使用它的任何集成都不会感受到变化的重大影响。 在接下来的章节中,我们将研究定义我们的应用程序规范,我们将在所有其他章节中构建它。 # 第四章:模块化网店应用的需求规范 从头开始构建软件应用程序需要多种技能,因为它不仅涉及编写代码。写下功能要求和勾画线框图通常是过程中的第一步,尤其是在我们处理客户项目时。这些步骤通常由开发人员以外的人员完成,因为它们需要对客户业务案例、用户行为等方面有一定的了解。作为一个更大的开发团队的一部分,我们作为开发人员通常会得到需求、设计和线框图,然后开始编码。独自完成项目,很容易忽略这些步骤,直接开始编码。然而,这种做法往往是低效的。制定功能要求和一些线框图是值得知道和遵循的技能,即使只是一个开发人员。 在本章后期,我们将讨论高级应用程序要求,以及一个粗略的线框图。 在本章中,我们将涵盖以下主题: + 定义应用程序要求 + 线框图 + 定义技术栈: + Symfony 框架 + 基础框架 # 定义应用程序要求 我们需要构建一个简单但响应迅速的网店应用程序。为了做到这一点,我们需要列出一些基本要求。我们目前感兴趣的要求类型是那些涉及用户与系统之间互动的要求。在用户使用方面,最常见的两种规定要求的技术是用例和用户故事。用户故事是一种不太正式但足够描述要求的方式。使用用户故事,我们封装了客户和商店经理的行为,如下所述。 客户应该能够做到以下事情: + 浏览静态信息页面(关于我们,客户服务) + 通过联系表格联系店主 + 浏览商店分类 + 查看产品详情(价格,描述) + 查看产品图片并放大查看(缩放) + 查看特价商品 + 查看畅销产品 + 将产品添加到购物车 + 创建客户账户 + 更新客户账户信息 + 找回丢失的密码 + 结账 + 查看订单总成本 + 在几种付款方式中选择 + 在几种运输方式中选择 + 在下订单后收到电子邮件通知 + 检查订单状态 + 取消订单 + 查看订单历史 商店经理应该能够做到以下事情: + 创建产品(至少包括以下属性:`标题`,`价格`,`sku`,`url-key`,`描述`,`数量`,`类别`和`图片`) + 上传产品图片 + 更新和删除产品 + 创建分类(至少包括以下属性:`标题`,`url-key`,`描述`和`图片`) + 上传图片到分类 + 更新和删除分类 + 在新的销售订单被创建时收到通知 + 在新的销售订单被取消时收到通知 + 按其状态查看现有销售订单 + 更新订单状态 + 禁用客户账户 + 删除客户账户 用户故事是一种方便的高级方式来记录应用程序要求。作为敏捷开发的一种特别有用的方式。 # 线框图 有了用户故事,让我们把重点转向实际的线框图。出于我们稍后会讨论的原因,我们的线框图工作将集中在客户的角度。 有许多线框工具,免费和商业化的都有。一些商业化的工具,比如[`ninjamock.com`](https://ninjamock.com),我们将用于我们的示例,仍然提供免费计划。这对个人项目非常方便,因为它节省了我们很多时间。 每个网站应用程序的起点是它的主页。以下线框图说明了我们网店应用程序的主页: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_01.jpg) 在这里,我们可以看到一些部分确定页面结构。页眉由标志、类别菜单和用户菜单组成。要求没有提到类别结构的任何内容,我们正在构建一个简单的网店应用,因此我们将坚持扁平的类别结构,没有任何子类别。用户菜单最初将显示**注册**和**登录**链接,直到用户实际登录,此时菜单将如下线框图所示更改。内容区域填充有畅销商品和特价商品,每个商品都有图像、标题、价格和定义的**添加到购物车**按钮。页脚区域包含链接到大多是静态内容页面和**联系我们**页面。 以下线框图展示了我们网店应用的分类页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_02.jpg) 页眉和页脚区域在整个网站中概念上保持不变。内容区域现在已更改为列出任何给定类别内的产品。单个产品区域的呈现方式与主页上的方式相同。类别名称和图像呈现在产品列表上方。类别图像的宽度给出了我们应该准备和上传到我们类别的图像类型的一些提示。 以下线框图展示了我们网店应用的产品页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_03.jpg) 这里的内容区域现在更改为列出单个产品信息。我们可以看到一个大的图像占位符、标题、sku、库存状态、价格、数量字段、**添加到购物车**按钮和产品描述。当商品可供购买时,将显示**有货**消息,当商品不再可用时将显示**缺货**。这与产品数量属性相关。我们还需要记住“查看具有大视图(放大)的产品图像”要求,点击图像将放大显示。 以下线框图展示了我们网店应用的注册页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_04.jpg) 这里的内容区域现在更改为呈现注册表单。我们可以以许多方式实现注册系统。通常情况下,在注册屏幕上询问的信息量最少,因为我们希望尽快让用户进入。然而,让我们假设我们正在尝试在注册屏幕上获取更完整的用户信息。我们不仅要求电子邮件和密码,还要求整个地址信息。 以下线框图展示了我们网店应用的登录页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_05.jpg) 这里的内容区域现在更改为呈现客户登录和忘记密码表单。我们为用户提供**电子邮件**和**密码**字段以进行登录,或者在重置密码操作时只提供**电子邮件**字段。 以下线框图展示了我们网店应用的客户账户页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_06.jpg) 这里的内容区域现在更改为呈现仅对已登录客户可见的客户账户区域。在这里,我们看到了两个主要信息。一个是客户信息,另一个是订单历史。客户可以从此屏幕更改其电子邮件、密码和其他地址信息。此外,客户可以查看、取消和打印其以前的所有订单。**我的订单**表按从新到旧的顺序列出订单。尽管用户故事没有指定,但订单取消应仅适用于待处理订单。这是我们稍后将更详细地讨论的内容。 这也是第一个显示用户菜单状态的屏幕,当用户登录时。我们可以看到一个下拉菜单显示用户的全名,**我的账户**和**退出**链接。紧挨着它,我们有**购物车(%s)**链接,用于列出购物车中的确切数量。 以下线框图展示了我们网店应用的结账购物车页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_07.jpg) 这里的内容区现在改变为呈现购物车的当前状态。如果客户已经向购物车中添加了任何商品,它们将在这里列出。每个商品应列出产品标题、单价、添加的数量和小计。客户应该能够更改数量并点击**更新购物车**按钮来更新购物车的状态。如果数量为`0`,点击**更新购物车**按钮将从购物车中移除该商品。购物车数量应始终反映页眉菜单中**购物车(%s)**链接的状态。屏幕右侧显示了当前订单总价的快速摘要,以及一个清晰的**去结账**按钮。 以下线框图展示了我们网店应用的结账购物车运输页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_08.jpg) 这里的内容区现在改变为呈现结账流程的第一步,即收集运输信息。这个页面对未登录客户不可见。客户可以在这里提供他们的地址详细信息,以及选择运输方式。运输方式区列出了几种运输方式。在右侧,显示了可折叠的订单摘要部分,列出购物车中当前商品。在其下方,有购物车小计值和一个清晰的**下一步**按钮。只有在提供了所有必要信息时,**下一步**按钮才会触发,此时它应该将我们带到结账购物车付款页面上的付款信息。 以下线框图展示了我们网店应用的结账购物车付款页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_09.jpg) 这里的内容区现在改变为呈现结账流程的第二步,即收集付款信息。这个页面对未登录客户不可见。客户将看到可用付款方式的列表。为了简化应用程序,我们将只关注固定付款,不会使用像 PayPal 或 Stripe 这样复杂的付款方式。在屏幕右侧,我们可以看到一个可折叠的**订单摘要**部分,列出购物车中当前商品。在其下方,有订单总额部分,分别列出**购物车小计**、**标准运费**、**订单总额**和一个清晰的**下订单**按钮。只有在提供了所有必要信息时,**下订单**按钮才会触发,此时它应该将我们带到结账成功页面。 以下线框图展示了我们网店应用的结账成功页面: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_10.jpg) 这里的内容区现在改变为输出结账成功的消息。显然,这个页面只对刚刚完成结账流程的已登录客户可见。订单号可点击并链接到**我的账户**区域,重点关注具体订单。到达这个页面时,客户和商店经理都应该收到通知邮件,根据*下订单后收到邮件通知*和*新销售订单创建后收到通知*的要求。 通过这些,我们结束了面向客户的线框图。 关于商店经理用户故事需求,我们现在将简单定义一个管理界面,如下截图所示: ![线框图](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_11.jpg) 稍后使用框架,我们将获得一个完整的自动生成的 CRUD 界面,用于多个**添加新**和**列表和管理**链接。对这个界面和其链接的访问将由框架的安全组件控制,因为这个用户不会是客户或数据库中的任何用户。 Symfony 框架 # 创建新记录 此外,在接下来的章节中,我们将把我们的应用程序分成几个模块。在这样的设置中,每个模块将负责各自的功能,处理客户、目录、结账和其他需求。 ## Symfony 框架对我们的应用程序来说是一个不错的选择。它是一个企业级框架,已经存在多年,文档和支持非常完善。可以从官方网站[`symfony.com`](http://symfony.com)下载,如下所示: 编辑现有记录 ![Symfony 框架](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_12.jpg) 将技术栈定义为 + 控制器 + 路由 + ORM(通过 Doctrine) + 表单 + 验证 + 安全 这些是我们应用程序所需的基本功能。ORM 特别在快速应用程序开发中起着重要作用。不用太担心编码,CRUD 的每个方面都可以将开发速度提高一倍或两倍。Symfony 在这方面的伟大之处在于,它允许通过执行两个简单的命令自动生成实体和围绕它们的 CRUD 操作: ```php php bin/console doctrine:generate:entity php app/console generate:doctrine:crud ``` 通过这样做,Symfony 生成实体模型和必要的控制器,使我们能够执行以下操作: + 列出所有记录 + 显示由其主键标识的给定记录 + 一旦需求和线框图确定,我们就可以将注意力集中在选择技术栈上。在第一章中,*生态系统概述*,我们简要介绍了几种最流行的 PHP 框架,并指出了它们的优势。在这种情况下,选择合适的框架更多地是一种偏好,因为大部分应用需求可以很容易地满足任何一个框架。然而,我们的选择落在了 Symfony 上。除了 PHP 框架,我们仍然需要一个 CSS 框架,在客户端浏览器中提供一些结构、样式和响应能力。由于本书的重点是 PHP 技术,我们选择了 Foundation CSS 框架来完成这项任务。 + Foundation 框架 + 删除现有记录 基本上,我们免费获得了一个最小的商店经理界面。这本身就涵盖了商店经理角色的大部分 CRUD 相关需求。然后,我们可以轻松修改生成的模板,进一步整合剩余的功能。 此外,安全组件提供了身份验证和授权,我们可以用来满足客户和商店经理的登录需求。因此,商店经理将是 Symfony 防火墙固定的、预先创建的用户,是唯一可以访问 CRUD 控制器操作的用户。 ## 基础框架 Foundation 框架由 Zurb 公司支持,是现代响应式 Web 应用程序的一个很好的选择。我们可以说它是一个企业级框架,提供了一套 HTML、CSS 和 JavaScript,我们可以构建在其上。可以从官方网站[`foundation.zurb.com`](http://foundation.zurb.com)下载,如下所示: ![Foundation 框架](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_04_13.jpg) Foundation 有三种风格: + Foundation for sites + 电子邮件基础 + Foundation for apps 我们对网站版本感兴趣。除了一般的样式外,Foundation for sites 还提供了大量的控件、导航元素、容器、媒体元素和插件。这些在我们的应用程序中将特别有用,比如标题菜单、类别产品列表、响应式购物车表格等。 Foundation 是一个以移动设备为先的框架,我们首先为小屏幕编码,然后大屏幕继承这些样式。它的默认 12 列网格系统使我们能够快速轻松地创建强大的多设备布局。 我们将使用 Foundation 来提供结构、一些基本样式和响应性,而不需要自己编写一行 CSS。这样就足以使我们的应用在移动和桌面屏幕上看起来足够美观,同时仍然将我们大部分的编码技能集中在后端事务上。 除了提供强大的功能外,Foundation 背后的公司还提供优质的技术支持。虽然我们在本书中不需要它,但选择应用程序框架时,这些事情建立了信心。 # 摘要 创建 Web 应用程序可能是一项乏味且耗时的任务,Web 商店可能是最健壮和最密集的应用程序类型之一,因为它们涵盖了大量的功能。在交付最终产品时涉及许多组件;从数据库、服务器端(PHP)代码到客户端(HTML、CSS 和 JavaScript)代码。在本章中,我们首先通过定义一些基本用户故事来定义我们小型网店的高级应用程序要求。将线框图加入其中有助于我们可视化客户界面,而商店管理界面将由框架提供。 我们进一步概述了支持模块化应用程序设计的两个最流行的框架。我们将注意力转向 Symfony 作为服务器端技术和 Foundation 作为客户端响应式框架。 在接下来的章节中,我们将更深入地了解 Symfony。Symfony 不仅是一组可重用的组件,还是最健壮和最流行的全栈 PHP 框架之一。因此,它是快速 Web 应用程序开发的一个有趣选择。 # 第五章:一览 Symfony 像 Symfony 这样的全栈框架有助于通过提供所有必要的组件,从用户界面到数据存储,来简化构建模块化应用程序的过程。这使得在应用程序增长时能够更快地交付各个部分。我们将通过将应用程序分割为几个较小的模块或 Symfony 术语中的 bundle 来体验到这一点。 接下来,我们将安装 Symfony,创建一个空项目,并开始研究构建模块化应用程序所必需的各个框架特性: + 控制器 + 路由 + 模板 + 表单 + Bundle 系统 + 数据库和 Doctrine + 测试 + 验证 # 安装 Symfony 安装 Symfony 非常简单。我们可以使用以下命令在 Linux 或 Mac OS X 上安装 Symfony: ```php **sudo curl -LsS https://symfony.com/installer -o /usr/local/bin/symfony** **sudo chmod a+x /usr/local/bin/symfony** ``` 我们可以使用以下命令在 Windows 上安装 Symfony: ```php **c:\> php -r "file_put_contents('symfony', file_get_contents('https://symfony.com/installer'));"** ``` 执行该命令后,我们可以简单地将新创建的`symfony`文件移动到我们的项目目录,并在 Windows 中进一步执行它作为`symfony`或`php symfony`。 这应该触发以下输出: ![安装 Symfony](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_01.jpg) 前面的响应表明我们已经成功设置了 Symfony,现在准备开始创建新项目。 # 创建一个空项目 既然我们已经设置好了 Symfony 安装程序,让我们继续创建一个新的空项目。我们只需执行`symfony new test-app`命令,如下面的命令行示例所示: ![创建一个空项目](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_02.jpg) 在这里,我们正在创建一个名为`test-app`的新项目。我们可以看到 Symfony 安装程序正在从互联网下载最新的 Symfony 框架,并输出一个简要的指令,说明如何通过 Symfony 控制台应用程序运行内置的 PHP 服务器。整个过程可能需要几分钟。 新创建的`test-app`目录的结构与以下类似: ![创建一个空项目](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_03.jpg) 这里为我们创建了许多文件和目录。然而,我们感兴趣的是`app`和`src`目录。`app`目录是整个站点应用程序配置的所在地。在这里,我们可以找到数据库、路由、安全和其他服务的配置。此外,这也是默认布局和模板文件所在的地方,如下面的截图所示: ![创建一个空项目](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_04.jpg) 另一方面,`src`目录包含了已经模块化的代码,以`AppBundle`模块的形式,如下面的截图所示: ![创建一个空项目](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_05.jpg) 随着我们的进展,我们将更详细地讨论这些文件的作用。目前,值得注意的是,将我们的浏览器指向这个项目会使`DefaultController.php`实际上渲染输出。 # 使用 Symfony 控制台 Symfony 框架自带一个内置的控制台工具,我们可以通过在项目根目录中执行以下命令来触发它: ```php **php bin/console** ``` 这样做会在屏幕上显示一个可用命令的广泛列表,分为以下几组: + `资产` + `缓存` + `配置` + `调试` + `doctrine` + `生成` + `lint` + `orm` + `路由` + `安全` + `服务器` + `swiftmailer` + `翻译` 这些命令赋予我们各种功能。我们未来特别感兴趣的是`doctrine`和`generate`命令。`doctrine`命令,特别是`doctrine:generate:crud`,基于现有的 Doctrine 实体生成一个 CRUD。此外,`doctrine:generate:entity`命令在现有 bundle 中生成一个新的 Doctrine 实体。在我们需要快速轻松地创建实体以及围绕它的整个 CRUD 时,这些命令非常有用。同样,`generate:doctrine:entity`和`generate:doctrine:crud`也是如此。 在继续测试这些命令之前,我们需要确保我们的数据库配置参数已经设置好,以便 Symfony 可以看到并与我们的数据库进行通信。为此,我们需要在`app/config/parameters.yml`文件中设置适当的值。 为了本节的目的,让我们继续在默认的`AppBundle`包中创建一个简单的 Customer 实体,围绕它创建整个 CRUD,假设 Customer 实体具有以下属性:`firstname`、`lastname`和`e-mail`。我们首先在项目根目录中运行`php bin/console generate:doctrine:entity`命令,结果如下输出: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_12.jpg) 在这里,我们首先提供了`AppBundle:Customer`作为实体名称,并确认了注释作为配置格式的使用。 最后,我们被要求开始向我们的实体添加字段。输入名字并按回车键,将我们移动到一系列关于字段类型、长度、可空和唯一状态的简短问题,如下屏幕截图所示: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_11.jpg) 现在我们应该已经为我们的 Customer 实体生成了两个类。通过 Symfony 和 Doctrine 的帮助,这些类被放置在**对象关系映射器**(**ORM**)的上下文中,因为它们将 Customer 实体与适当的数据库表进行了关联。但是,我们还没有指示 Symfony 实际上为我们的实体创建表。为此,我们执行以下命令: ```php **php bin/console doctrine:schema:update --force** ``` 这应该会产生如下屏幕截图所示的输出: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_13.jpg) 如果我们现在查看数据库,应该会看到一个`customer`表,其中包含使用 SQL 创建 dsyntax 创建的所有正确列,如下所示: ```php **CREATE TABLE `customer` (** **`id` int(11) NOT NULL AUTO_INCREMENT,** **`firstname` varchar(255) COLLATE utf8_unicode_ci NOT NULL,** **`lastname` varchar(255) COLLATE utf8_unicode_ci NOT NULL,** **`email`** **varchar(255) COLLATE utf8_unicode_ci NOT NULL,** **PRIMARY KEY (`id`),** **UNIQUE KEY `UNIQ_81398E09E7927C74` (`email`)** **) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;** ``` 此时,我们仍然没有实际的 CRUD 功能。我们只是有一个经过 ORM 授权的 Customer 实体类和适当的数据库表。以下命令将为我们生成实际的 CRUD 控制器和模板: ```php **php bin/console generate:doctrine:crud** ``` 这应该产生以下交互式输出: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_14.jpg) 通过提供完全分类的实体名称`AppBundle:Customer`,生成器将继续一系列附加输入,从生成写操作、读取的配置类型到路由前缀,如下屏幕截图所示: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_15.jpg) 完成后,我们应该能够通过简单打开类似`http://test.app/customer/`的 URL(假设`test.app`是我们设置的主机)来访问我们的 Customer CRUD 操作,如下所示: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_06.jpg) 如果我们单击**创建新条目**链接,我们将被重定向到`/customer/new/` URL,如下屏幕截图所示: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_07.jpg) 在这里,我们可以输入我们的 Customer 实体的实际值,并单击**Create**按钮,以将其持久化到数据库的`customer`表中。添加了一些实体后,初始的`/customer/` URL 现在能够列出它们所有,如下屏幕截图所示: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_08.jpg) 在这里,我们看到了**显示**和**编辑**操作的链接。**显示**操作是我们可能考虑的面向客户的操作,而**编辑**操作是面向管理员的操作。单击**编辑**操作,将我们带到表单的 URL`/customer/1/edit/`,而在这种情况下的数字`1`是数据库中客户实体的 ID: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_09.jpg) 在这里,我们可以更改属性值并单击**编辑**以将它们持久化到数据库中,或者我们可以单击**删除**按钮以从数据库中删除实体。 如果我们要创建一个具有已存在电子邮件的新实体,该电子邮件被标记为唯一字段,系统将抛出一个通用错误,如下所示: ![使用 Symfony 控制台](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_10.jpg) 这只是默认的系统行为,随着我们的进展,我们将探讨如何使其更加用户友好。到目前为止,我们已经看到了 Symfony 控制台的强大之处。通过几个简单的命令,我们能够创建实体及其整个 CRUD 操作。控制台还有很多功能。我们甚至可以创建自己的控制台命令,因为我们可以实现任何类型的逻辑。然而,就我们的需求而言,当前的实现暂时足够了。 # 控制器 控制器在 Web 应用程序中扮演着重要的角色,是任何应用程序输出的前沿。它们是端点,是在每个 URL 后面执行的代码。从技术上讲,我们可以说控制器是任何可调用的东西(函数、对象上的方法或闭包),它接受 HTTP 请求并返回 HTTP 响应。响应不限于单一格式,可以是 XML、JSON、CSV、图像、重定向、错误等任何东西。 让我们来看一下之前创建的(部分)`src/AppBundle/Controller/CustomerController.php`文件,更确切地说是它的`newAction`方法: ```php /** * Creates a new Customer entity. * * @Route("/new", name="customer_new") * @Method({"GET", "POST"}) */ public function newAction(Request $request) { //... return $this->render('customer/new.html.twig', array( 'customer' => $customer, 'form' => $form->createView(), )); } ``` 如果我们忽略实际的数据检索部分(`//…`),在这个小例子中有三个重要的事情需要注意: + `@Route`:这是 Symfony 的注释方式来指定 HTTP 端点,我们将用它来访问。第一个`"/new"`参数表示实际的端点,第二个`name="customer_new"`参数设置了这个路由的名称,我们可以在模板中的 URL 生成函数中使用它作为别名。值得注意的是,这是建立在实际`CustomerController`类上的`@Route("/customer")`注释之上的,因此完整的 URL 可能是`http://test.app/customer/new`。 + `@Method`:这里接受一个或多个 HTTP 方法的名称。这意味着`newAction`方法只会在 HTTP 请求与先前定义的`@Route`匹配并且是在`@Method`中定义的一个或多个 HTTP 方法类型时触发。 + `$this->render`:这返回`Response`对象。`$this->render`调用`Symfony\Bundle\FrameworkBundle\Controller\Controller`类的`render`函数,它实例化新的`Response()`,设置其内容,并返回该对象的整个实例。 现在让我们来看一下我们控制器中的`editAction`方法,如下面的代码块所示: ```php /** * Displays a form to edit an existing Customer entity. * * @Route("/{id}/edit", name="customer_edit") * @Method({"GET", "POST"}) */ public function editAction(Request $request, Customer $customer) { //... } ``` 在这里,我们看到一个路由接受一个单一的 ID,标记为第一个`@Route`注释参数中的`{id}`。方法的主体(在此处排除)不包含对获取`id`参数的直接引用。我们可以看到`editAction`函数接受两个参数,一个是`Request`,另一个是`Customer`。但是方法如何知道要接受`Customer`对象呢?这就是 Symfony 的`@ParamConverter`注释发挥作用的地方。它调用转换器将请求参数转换为对象。 `@ParamConverter`注释的好处在于我们可以明确或隐式地使用它。也就是说,如果我们不添加`@ParamConverter`注释,但在方法参数中添加类型提示,Symfony 将尝试为我们加载对象。这正是我们在上面的例子中的情况,因为我们没有明确地添加`@ParamConverter`注释。 术语上,控制器经常被用来交换路由。然而,它们并不是同一回事。 # 路由 简而言之,路由是将控制器与浏览器中输入的 URL 链接起来。现代的 Web 应用程序需要友好的 URL。这意味着从像`/index.php?product_id=23`这样的 URL 迁移到像`/catalog/product/t-shirt`这样的 URL。这就是路由发挥作用的地方。 Symfony 有一个强大的路由机制,使我们能够做到以下几点: + 创建映射到控制器的复杂路由 + 在模板中生成 URL + 在控制器内生成 URL + 从各种位置加载路由资源 Symfony 中路由的工作方式是所有请求都通过`app.php`。然后,Symfony 核心要求路由器检查请求。路由器然后将传入的 URL 与特定路由匹配,并返回有关路由的信息。这些信息,除其他事项外,包括应执行的控制器。最后,Symfony 内核执行控制器,返回一个响应对象。 所有应用程序路由都从单个路由配置文件加载,通常是`app/config/routing.yml`文件,如我们的测试应用程序所示: ```php app: resource: "@AppBundle/Controller/" type: annotation ``` 该应用程序只是许多可能输入之一。它的资源值指向`AppBundle`控制器目录,类型设置为注释,这意味着类注释将被读取以指定确切的路由。 我们可以定义具有多种变化的路由。其中一种如下所示: ```php // Basic Route Configuration /** * @Route("/") */ public function homeAction() { // ... } // Routing with Placeholders /** * @Route("/catalog/product/{sku}") */ public function showAction($sku) { // ... } // >>Required<< and Optional Placeholders /** * @Route("/catalog/product/{id}") */ public function indexAction($id) { // ... } // Required and >>Optional<< Placeholders /** * @Route("/catalog/product/{id}", defaults={"id" = 1}) */ public function indexAction($id) { // ... } ``` 前面的例子展示了我们可以定义路由的几种方式。有趣的是带有必需和可选参数的情况。如果我们考虑一下,从最新的例子中删除 ID 将匹配带有 sku 的前一个例子。Symfony 路由器总是选择它找到的第一个匹配路由。我们可以通过在`@Route`注释上添加正则表达式要求来解决这个问题,如下所示: ```php @Route( "/catalog/product/{id}", defaults={"id": 1}, requirements={"id": "\d+"} ) ``` 关于控制器和路由还有更多要说的,一旦我们开始构建我们的应用程序,我们将会看到。 # 模板 之前我们说过控制器接受请求并返回响应。然而,响应往往可以是任何内容类型。实际内容的生成是控制器委托给模板引擎的。然后模板引擎有能力将响应转换为 HTML、JSON、XML、CSV、LaTeX 或任何其他基于文本的内容类型。 在过去,程序员将 PHP 与 HTML 混合到所谓的 PHP 模板(`.php`和`.phtml`)中。尽管在某些平台上仍在使用,但这种方法被认为是不安全的,并且在许多方面缺乏。其中之一是将业务逻辑塞入模板文件中。 为了解决这些缺点,Symfony 打包了自己的模板语言 Twig。与 PHP 不同,Twig 旨在严格表达演示文稿,而不是思考程序逻辑。我们不能在 Twig 中执行任何 PHP 代码。而 Twig 代码只不过是带有一些特殊语法类型的 HTML。 Twig 定义了三种特殊语法类型: + `{{ ... }}`:这将把变量或表达式的结果输出到模板中。 + `{% ... %}`:这个标签控制模板的逻辑(`if`和`for`循环等)。 + `{# ... #}`:它相当于 PHP 的`/* comment */`语法。注释内容不包括在渲染页面中。 过滤器是 Twig 的另一个很好的功能。它们就像对变量值进行链式方法调用一样,修改输出之前的内容,如下所示: ```php

{{ title|upper }}

{{ filter upper }}

{{ title }}

{% endfilter %}

{{ title|lower|escape }}

{% filter lower|escape %}

{{ title }}

{% endfilter %} ``` 它还支持以下列出的函数: ```php {{ random(['phone', 'tablet', 'laptop']) }} ``` 前面的随机函数调用将从数组中返回一个随机值。除了内置的过滤器和函数列表外,Twig 还允许根据需要编写自己的过滤器和函数。 与 PHP 类继承类似,Twig 也支持模板和布局继承。让我们快速回顾一下`app/Resources/views/customer/index.html.twig`文件,如下所示: ```php {% extends 'base.html.twig' %} {% block body %}

Customer list

… {% endblock %} ``` 在这里,我们看到一个客户`index.html.twig`模板,使用`extends`标签来扩展另一个模板,这种情况下是在`app/Resources/views/`目录中找到的`base.html.twig`,内容如下: ```php {% block title %}Welcome!{% endblock %} {% block stylesheets%}{% endblock %} {% block body %}{% endblock %} {% block javascripts%}{% endblock %} ``` 在这里,我们看到几个块标签:`title`,`stylesheets`,`body`和`javascripts`。我们可以在这里声明任意数量的块,并以任何我们喜欢的方式命名它们。这使得`extend`标签成为模板继承的关键。它告诉 Twig 首先评估基础模板,设置布局并定义块,然后子模板如`customer/index.html.twig`填充这些块的内容。 模板存在于两个位置: + `app/Resources/views/` + `bundle-directory/Resources/views/` 这意味着为了`render/extend app/Resources/views/base.html.twig`,我们将在我们的模板文件中使用`base.html.twig`,而为了`render/extend app/Resources/views/customer/index.html.twig`,我们将使用`customer/index.html.twig`路径。 当与存储在 bundles 中的模板一起使用时,我们必须稍微不同地引用它们。在这种情况下,使用`bundle:directory:filename`字符串语法。以`FoggylineCatalogBundle:Product:index.html.twig`路径为例。这将是使用 bundles 模板文件的完整路径。这里`FoggylineCatalogBundle`是一个 bundle 名称,`Product`是该 bundle`Resources/views`目录中的一个目录名称,`index.html.twig`是`Product`目录中实际模板的名称。 每个模板文件名都有两个扩展名,首先指定格式,然后指定该模板的引擎;例如`*.html.twig`,`*.html.php`和`*.css.twig`。 一旦我们开始构建我们的应用程序,我们将更详细地了解这些模板。 # 表单 注册、登录、添加到购物车、结账,所有这些以及更多操作都在网店应用程序和其他地方使用 HTML 表单。构建表单是开发人员最常见的任务之一。通常需要时间来正确完成。 Symfony 有一个`form`组件,通过它我们可以以面向对象的方式构建 HTML 表单。这个组件本身也是一个独立的库,可以独立于 Symfony 使用。 让我们来看看`src/AppBundle/Entity/Customer.php`文件的内容,这是为我们自动生成的`Customer`实体类,当我们通过控制台定义它时: ```php class Customer { private $id; private $firstname; private $lastname; private $email; public function getId() { return $this->id; } public function setFirstname($firstname) { $this->firstname = $firstname; return $this; } public function getFirstname() { return $this->firstname; } public function setLastname($lastname) { $this->lastname = $lastname; return $this; } public function getLastname() { return $this->lastname; } public function setEmail($email) { $this->email = $email; return $this; } public function getEmail() { return $this->email; } } ``` 在这里,我们有一个普通的 PHP 类,它既不继承任何东西,也不以任何其他方式与 Symfony 相关联。它代表一个单一的客户实体,为其设置和获取数据。有了实体类,我们想要渲染一个表单,该表单将获取我们类使用的所有相关数据。这就是`Form`组件的作用所在。 当我们之前通过控制台使用 CRUD 生成器时,它为我们的 Customer 实体创建了`Form`类,位于`src/AppBundle/Form/CustomerType.php`文件中,内容如下: ```php namespace AppBundle\Form; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class CustomerType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('firstname') ->add('lastname') ->add('email') ; } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults(array( 'data_class' =>'AppBundle\Entity\Customer' )); } } ``` 我们可以看到表单组件背后的简单性归结为以下几点: + **扩展表单类型**:我们从`Symfony\Component\Form\AbstractType`类继承 + **实现 buildForm 方法**:这是我们添加要在表单上显示的实际字段的地方 + **实现 configureOptions**:这至少指定了`data_class`配置,指向我们的 Customer 实体。 表单构建器对象在这里承担了大部分工作。它不需要太多的工作就可以创建一个表单。有了`form`类,让我们来看看负责向模板提供表单的`controller`动作。在这种情况下,我们将专注于`src/AppBundle/Controller/CustomerController.php`文件中的`newAction`,内容如下: ```php $customer = new Customer(); $form = $this->createForm('AppBundle\Form\CustomerType', $customer); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $em = $this->getDoctrine()->getManager(); $em->persist($customer); $em->flush(); return $this->redirectToRoute('customer_show', array('id' =>$customer->getId())); } return $this->render('customer/new.html.twig', array( 'customer' => $customer, 'form' => $form->createView(), )); ``` 上述代码首先实例化了`Customer`实体类。`$this->createForm(…)`实际上是调用了`$this->container->get('form.factory')->create(…)`,将我们的`form`类名和`customer`对象的实例传递给它。然后我们有`isSubmitted`和`isValid`检查,以查看这是 GET 请求还是有效的 POST 请求。根据这个检查,代码要么返回到客户列表,要么设置`form`和`customer`实例,以便与模板`customer/new.html.twig`一起使用。我们稍后会更详细地讨论实际的验证。 最后,让我们来看看`app/Resources/views/customer/new.html.twig`文件中的实际模板: ```php {% extends 'base.html.twig' %} {% block body %}

Customer creation

{{ form_start(form) }} {{ form_widget(form) }} {{ form_end(form) }} {% endblock %} ``` 在这里我们看到了`extends`和`block`标签,以及一些相关的函数。Symfony 向 Twig 添加了几个表单渲染函数,如下所示: + `form(view, variables)` + `form_start(view, variables)` + `form_end(view, variables)` + `form_label(view, label, variables)` + `form_errors(view)` + `form_widget(view, variables)` + `form_row(view, variables)` + `form_rest(view, variables)` 我们的大多数应用程序表单将会像这样自动生成,因此我们能够获得一个完全功能的 CRUD,而不需要深入了解其他表单功能。 # 配置 Symfony 为了跟上现代需求,今天的框架和应用程序需要一个灵活的配置系统。Symfony 通过其强大的配置文件和环境概念很好地实现了这一角色。 默认的 Symfony 配置文件`config.yml`位于`app/config/`目录下,(部分)内容如下分段: ```php imports: - { resource: parameters.yml } - { resource: security.yml } - { resource: services.yml } framework: … # Twig Configuration twig: … # Doctrine Configuration doctrine: … # Swiftmailer Configuration swiftmailer: … ``` 像`framework`、`twig`、`doctrine`和`swiftmailer`这样的顶级条目定义了单个 bundle 的配置。 可选地,配置文件可以是 XML 或 PHP 格式(`config.xml`或`config.php`)。虽然 YAML 简单易读,XML 更强大,而 PHP 更强大但不太易读。 我们可以使用控制台工具来转储整个配置,如下所示: ```php **php bin/console config:dump-reference FrameworkBundle** ``` 前面的示例列出了核心`FrameworkBundle`的配置文件。我们可以使用相同的命令来显示任何实现容器扩展的 bundle 的可能配置,这是我们稍后将要研究的内容。 Symfony 对环境概念有一个很好的实现。查看`app/config`目录,我们可以看到默认的 Symfony 项目实际上从三种不同的环境开始: + `config_dev.yml` + `config_prod.yml` + `config_test.yml` 每个应用程序可以在各种环境中运行。每个环境共享相同的代码,但不同的配置。开发环境可能会使用大量的日志记录,而生产环境可能会使用大量的缓存。 这些环境被触发的方式是通过前端控制器文件,如下面的部分示例所示: ```php # web/app.php … $kernel = new AppKernel('prod', false); … # web/app_dev.php … $kernel = new AppKernel('dev', true); … ``` 测试环境在这里是缺失的,因为它只在运行自动化测试时使用,不能直接通过浏览器访问。 `app/AppKernel.php`文件实际上加载配置,无论是 YAML、XML 还是 PHP,如下面的代码片段所示: ```php public function registerContainerConfiguration(LoaderInterface $loader) { $loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml'); } ``` 环境遵循相同的概念,每个环境导入基本配置文件,然后修改其值以满足特定环境的需求。 # bundle 系统 大多数流行的框架和平台都支持某种形式的模块、插件、扩展或 bundle。大多数情况下,区别实际上只是在命名上,而可扩展性和模块化的概念是相同的。在 Symfony 中,这些模块化块被称为 bundles。 bundles 在 Symfony 中是一等公民,因为它们支持其他组件可用的所有操作。在 Symfony 中,一切都是一个 bundle,甚至核心框架也是。bundles 使我们能够构建模块化的应用程序,其中给定功能的整个代码都包含在一个单独的目录中。 一个单一的 bundle 包含所有的 PHP 文件、模板、样式表、JavaScript 文件、测试以及其他任何内容在一个根目录中。 当我们首次设置我们的测试应用程序时,它为我们创建了一个`AppBundle`,位于`src`目录下。随着我们继续使用自动生成的 CRUD,我们看到我们的 bundle 获得了各种目录和文件。 要让 Symfony 注意到一个 bundle,需要将其添加到`app/AppKernel.php`文件中的`registerBundles`方法中,如下所示: ```php public function registerBundles() { $bundles = [ new Symfony\Bundle\FrameworkBundle\FrameworkBundle(), new Symfony\Bundle\SecurityBundle\SecurityBundle(), new Symfony\Bundle\TwigBundle\TwigBundle(), new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(), new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(), //… new AppBundle\AppBundle(), ]; //… return $bundles; } ``` 创建一个新的 bundle 就像创建一个单个的 PHP 文件一样简单。让我们继续创建一个`src/TestBundle/TestBundle.php`文件,内容看起来像这样: ```php namespace TestBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; class TestBundle extends Bundle { … } ``` 一旦文件就位,我们只需要通过`app/AppKernel.php`文件的`registerBundles`方法进行注册,如下所示: ```php class AppKernel extends Kernel { //… public function registerBundles() { $bundles = [ // … new TestBundle\TestBundle(), // … ]; return $bundles; } //… } ``` 创建 bundle 的更简单的方法是只需运行一个控制台命令,如下所示: ```php **php bin/console generate:bundle --namespace=Foggyline/TestBundle** ``` 这将触发一系列关于 bundle 的问题,最终导致 bundle 创建,看起来像下面的截图: ![bundle 系统](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_16.jpg) 一旦过程完成,将创建一个新的 bundle,其中包含几个目录和文件,如下面的截图所示: ![bundle 系统](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_17.jpg) Bundle 生成器很友好地创建了控制器、依赖注入扩展、路由、准备服务配置、模板,甚至测试。由于我们选择共享我们的 bundle,Symfony 选择 XML 作为默认配置格式。依赖扩展简单地意味着我们可以通过在 Symfony 的主`config.yml`中使用`foggyline_test`作为根元素来访问我们的 bundle 配置。实际的`foggyline_test`元素在`DependencyInjection/Configuration.php`文件中定义。 # 数据库和 Doctrine 数据库几乎是每个 Web 应用程序的支柱。每当我们需要存储或检索数据时,我们都是通过数据库来实现的。在现代面向对象编程世界中的挑战是将数据库抽象化,以便我们的 PHP 代码与数据库无关。MySQL 可能是 PHP 世界中最知名的数据库。PHP 本身对与 MySQL 的工作有很好的支持,无论是通过`mysqli_*`扩展还是通过 PDO。然而,这两种方法都是针对 MySQL 特定的,离数据库太近。Doctrine 通过引入一层抽象解决了这个问题,使我们能够使用代表 MySQL 中的表、行及其关系的 PHP 对象进行工作。 Doctrine 完全与 Symfony 解耦,因此使用它完全是可选的。然而,它的一个很棒的地方是 Symfony 控制台提供了基于 Doctrine ORM 的自动生成 CRUD,就像我们在之前的示例中创建 Customer 实体时看到的那样。 一旦我们创建了项目,Symfony 就会为我们提供一个自动生成的`app/config/parameters.yml`文件。这个文件中,我们提供数据库访问信息,就像下面的示例中所示的那样。 ```php parameters: database_host: 127.0.0.1 database_port: null database_name: symfony database_user: root database_password: mysql ``` 一旦我们配置了适当的参数,我们就可以使用控制台生成功能。 值得注意的是,该文件中的参数仅仅是一种约定,因为`app/config/config.yml`将它们拉入`doctrine dbal`配置,就像这里所示的那样。 ```php doctrine: dbal: driver: pdo_mysql host: "%database_host%" port: "%database_port%" dbname: "%database_name%" user: "%database_user%" password: "%database_password%" charset: UTF8 ``` Symfony 控制台工具允许我们根据这个配置来删除和创建数据库,在开发过程中非常方便,就像下面的代码块所示的那样。 ```php php bin/console doctrine:database:drop --force php bin/console doctrine:database:create ``` 我们之前看到控制台工具如何使我们能够创建实体并将它们映射到数据库表中。这将足够满足我们在本书中的需求。一旦我们创建了它们,我们需要能够对它们执行 CRUD 操作。如果我们忽略自动生成的 CRUD 控制器`src/AppBundle/Controller/CustomerController.php`文件,我们可以看到以下与 CRUD 相关的代码: ```php // Fetch all entities $customers = $em->getRepository('AppBundle:Customer')->findAll(); // Persist single entity (existing or new) $em = $this->getDoctrine()->getManager(); $em->persist($customer); $em->flush(); // Delete single entity $em = $this->getDoctrine()->getManager(); $em->remove($customer); $em->flush(); ``` 关于 Doctrine 还有很多要说的,这已经超出了本书的范围。更多信息可以在官方页面找到([`www.doctrine-project.org`](http://www.doctrine-project.org))。 # 测试 现在,测试已经成为每个现代 Web 应用程序的一个组成部分。通常,测试这个术语意味着单元测试和功能测试。单元测试是关于测试我们的 PHP 类。每个单独的 PHP 类被认为是一个单元,因此称为单元测试。另一方面,功能测试测试我们应用程序的各个层面,通常集中在测试整体功能,比如登录或注册过程。 PHP 生态系统有一个很棒的单元测试框架叫做**PHPUnit**,可以在[`phpunit.de`](https://phpunit.de)下载。它使我们能够编写主要是单元测试,但也包括功能类型测试。Symfony 的一个很棒的地方是它内置了对 PHPUnit 的支持。 在我们开始运行 Symfony 的测试之前,我们需要确保已安装 PHPUnit 并且可以作为控制台命令使用。当执行时,PHPUnit 会自动尝试从当前工作目录中的`phpunit.xml`或`phpunit.xml.dist`中读取测试配置,如果可用的话。默认情况下,Symfony 在其根文件夹中带有一个`phpunit.xml.dist`文件,因此`phpunit`命令可以获取其测试配置。 以下是默认`phpunit.xml.dist`文件的部分示例: ```php tests src src/*Bundle/Resources src/*/*Bundle/Resources src/*/Bundle/*Bundle/Resources ``` `testsuites`元素定义了包含所有测试的目录 tests。`filter`元素及其子元素用于配置代码覆盖报告的白名单。`php`元素及其子元素用于配置 PHP 设置、常量和全局变量。 对于像我们这样的默认项目运行`phpunit`命令将产生以下输出: ![测试](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_05_18.jpg) 请注意,bundle 测试不会自动被捡起。我们自动创建的`src/AppBundle/Tests/Controller/CustomerControllerTest.php`文件在我们使用自动生成的 CRUD 时自动创建,但没有被执行。这不是因为它的内容默认被注释掉,而是因为`bundle`测试目录对`phpunit`不可见。为了使其执行,我们需要通过以下方式扩展`phpunit.xml.dist`文件,将目录添加到`testsuite`: ```php tests src/AppBundle/Tests ``` 根据我们构建应用程序的方式,我们可能希望将所有 bundle 添加到`testsuite`列表中,即使我们计划独立分发 bundle。 关于测试还有很多要说的。随着我们进一步学习并覆盖各个 bundle 的需求,我们将逐步进行。目前,了解如何触发测试以及如何向测试配置添加新位置就足够了。 # 验证 验证在现代应用程序中起着至关重要的作用。谈到 Web 应用程序时,我们可以说我们区分两种主要类型的验证;表单数据和持久化数据验证。通过 Web 表单从用户那里获取输入应该进行验证,与进入数据库的任何持久化数据一样。 Symfony 通过提供基于 JSR 303 Bean Validation 的验证组件在这方面表现出色,该组件起草并可在[`beanvalidation.org/1.0/spec/`](http://beanvalidation.org/1.0/spec/)上找到。如果我们回顾一下我们的`app/config/config.yml`,在`framework`根元素下,我们可以看到`validation`服务默认已启用: ```php framework: validation:{ enable_annotations: true } ``` 我们可以通过简单地通过`$this->get('validator')`表达式调用任何控制器类中的验证服务,如下例所示: ```php $customer = new Customer(); $validator = $this->get('validator'); $errors = $validator->validate($customer); if (count($errors) > 0) { // Handle error state } // Handle valid state ``` 上面示例的问题在于验证永远不会返回任何错误。原因是我们的类上没有设置任何断言。控制台自动生成的 CRUD 实际上没有在我们的`Customer`类上定义任何约束。我们可以通过尝试添加新客户并在电子邮件字段中输入任何文本来确认这一点,因为我们可以看到电子邮件不会被验证。 让我们继续编辑`src/AppBundle/Entity/Customer.php`文件,通过向`$email`属性添加`@Assert\Email`函数,就像这里所示的那样: ```php //… use Symfony\Component\Validator\Constraints as Assert; //… class Customer { //… /** * @var string * * @ORM\Column(name="email", type="string", length=255, unique=true) * @Assert\Email( * checkMX = true, * message = "Email '{{ value }}' is invalid.", * ) */ private $email; //… } ``` 断言约束的好处是它们像函数一样接受参数。因此,我们可以根据特定需求对单个约束进行微调。如果我们现在尝试跳过或添加一个错误的电子邮件地址,我们将收到类似**Email "john@gmail.test" is invalid**的消息。 有许多可用的约束,我们可以在[`symfony.com/doc/current/book/validation.html`](http://symfony.com/doc/current/book/validation.html)页面上查阅完整列表。 约束可以应用于类属性或公共 getter 方法。虽然属性约束最常见且易于使用,但 getter 方法约束允许我们指定更复杂的验证规则。 让我们来看一下`src/AppBundle/Controller/CustomerController.php`文件中的`newAction`方法: ```php $customer = new Customer(); $form = $this->createForm('AppBundle\Form\CustomerType', $customer); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // … ``` 在这里,我们看到一个`CustomerType`表单实例被绑定到`Customer`实例。实际的 GET 或 POST 请求数据通过`handleRequest`方法传递给表单的一个实例。现在,表单能够理解实体验证约束,并通过其`isValid`方法调用做出适当的响应。这意味着我们不必手动使用验证服务进行验证,表单可以为我们完成这项工作。 在我们逐个捆绑包进展的过程中,我们将继续扩展验证功能。 # 总结 在本章中,我们涉及了一些使 Symfony 如此出色的重要功能。控制器、模板、Doctrine、ORM、表单和验证构成了完整的数据呈现和持久化解决方案。我们已经看到了每个组件背后的灵活性和强大功能。捆绑包系统通过将这些组件封装成单独的小应用程序或模块,进一步提升了功能。现在,我们能够完全控制传入的 HTTP 请求,操作数据存储,并向用户呈现数据,所有这些都在一个捆绑包内完成。 在接下来的章节中,我们将利用前几章获得的见解和知识,最终根据要求开始构建我们的模块化应用程序。 # 第六章:构建核心模块 到目前为止,我们已经熟悉了 PHP 7 的最新变化,设计模式,设计原则和流行的 PHP 框架。我们还更详细地了解了 Symfony 作为我们未来的框架选择。现在我们终于达到了一个可以开始构建我们的模块化应用程序的地步。使用 Symfony 构建模块化应用程序是通过 bundles 机制完成的。从术语上讲,从这一点开始,我们将考虑 bundle 和模块是相同的东西。 在本章中,我们将涵盖以下与核心模块相关的主题: + 要求 + 依赖关系 + 实施 + 单元测试 + 功能测试 # 要求 回顾第四章, *模块化网络商店应用的需求规范*,以及那里提出的线框图,我们可以概述这个模块将具有的一些要求。核心模块将用于设置通用的、应用程序范围的功能,如下: + 将 Foundation CSS for sites 包含到项目中 + 构建主页 + 构建其他静态页面 + 构建一个联系我们页面 + 设置基本防火墙,其中管理员用户可以管理稍后其他模块生成的 CRUD # 依赖关系 核心模块本身并不依赖于我们将作为本书一部分编写的其他模块,或者 Symfony 标准安装之外的任何第三方模块。 # 实施 我们首先创建一个全新的 Symfony 项目,运行以下控制台命令: ```php **symfony new shop** ``` 这将创建一个新的`shop`目录,其中包含在浏览器中运行我们的应用程序所需的所有文件。在这些文件和目录中,有一个`src/AppBundle`目录,实际上就是我们的核心模块。在我们可以在浏览器中运行应用程序之前,我们需要将新创建的`shop`目录映射到一个主机名,比如说`shop.app`,这样我们就可以通过`http://shop.app` URL 在浏览器中访问它。完成这一步后,如果我们打开`http://shop.app`,我们应该看到**欢迎来到 Symfony 3.1.0**的屏幕,如下所示: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_06_05.jpg) 虽然我们目前还没有数据库的需求,但我们稍后将开发的其他模块将假定数据库连接,因此从一开始就进行设置是值得的。我们通过配置`app/config/parameters.yml`文件来配置正确的数据库连接参数。 然后我们从[`foundation.zurb.com/sites.html`](http://foundation.zurb.com/sites.html)下载 Foundation for Sites。下载完成后,我们需要解压并将`/js`和`/css`目录复制到`Symfony /web`目录中,如下面的屏幕截图所示: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_06_06.jpg) ### 注意 值得注意的是,我们在模块中使用的 Foundation 是一个简化的设置,我们只是使用 CSS 和 JavaScript 文件,而没有设置任何与 Sass 相关的内容。 在 Foundation CSS 和 JavaScript 文件就位后,我们编辑`app/Resources/views/base.html.twig`文件如下: ```php {% block title %}Welcome!{% endblock %} {% block stylesheets%}{% endblock %} {% block javascripts%}{% endblock %} ``` 在这里,我们设置整个头部和 body 结束区域,以及所有必要的 CSS 和 JavaScript 加载。Twig 的`asset`标签帮助我们构建 URL 路径,我们只需传递 URL 路径本身,它就会为我们构建一个完整的 URL。关于页面的实际内容,这里有几件事情需要考虑。我们将如何构建类别、客户和结账菜单?在这一点上,我们还没有这些模块,我们也不想让它们成为核心模块的必需品。那么我们如何解决还不存在的东西的挑战呢? 对于类别、客户和结账菜单,我们可以为每个菜单项定义全局 Twig 变量,然后用这些变量来渲染菜单。这些变量将通过适当的服务进行填充。由于核心包不知道未来的目录、客户和结账模块,我们将首先创建一些虚拟服务,并将它们连接到全局 Twig 变量。稍后,当我们开发目录、客户和结账模块时,这些模块将覆盖适当的服务,从而为菜单提供正确的值。 这种方法可能不完全符合模块化应用程序的概念,但对我们的需求来说已经足够了,因为我们并没有硬编码任何依赖关系。 我们首先在`app/config/config.yml`文件中添加以下条目: ```php twig: # ... globals: category_menu: '@category_menu' customer_menu: '@customer_menu' checkout_menu: '@checkout_menu' products_bestsellers: '@bestsellers' products_onsale: '@onsale' ``` `category_menu_items`、`customer_menu_items`、`checkout_menu_items`、`products_bestsellers`和`products_onsale`变量成为全局 Twig 变量,我们可以在任何 Twig 模板中使用,如下例所示: ```php
    {% for category in category_menu.getItems() %}
  • {{ category.name }}
  • {% endfor %}
``` Twig 全局变量`config`中的`@`字符用于表示服务名称的开始。这是将为我们的 Twig 变量提供值对象的服务。接下来,我们继续修改`app/config/services.yml`,创建`category_menu`、`customer_menu`、`checkout_menu`、`bestsellers`和`onsale`服务: ```php services: category_menu: class: AppBundle\Service\Menu\Category customer_menu: class: AppBundle\Service\Menu\Customer checkout_menu: class: AppBundle\Service\Menu\Checkout bestsellers: class: AppBundle\Service\Menu\BestSellers onsale: class: AppBundle\Service\Menu\OnSale ``` 此外,我们在`src/AppBundle/Service/Menu/`目录下创建列出的每个服务类。我们首先从`src/AppBundle/Service/Menu/Bestsellers.php`文件开始,内容如下: ```php namespace AppBundle\Service\Menu; class BestSellers { public function getItems() { // Note, this can be arranged as per some "Product"interface, so to know what dummy data to return return array( ay('path' =>'iphone', 'name' =>'iPhone', 'img' =>'/img/missing-image.png', 'price' => 49.99, 'add_to_cart_url' =>'#'), array('path' =>'lg', 'name' =>'LG', 'img' => '/img/missing-image.png', 'price' => 19.99, 'add_to_cart_url' =>'#'), array('path' =>'samsung', 'name' =>'Samsung', 'img'=>'/img/missing-image.png', 'price' => 29.99, 'add_to_cart_url' =>'#'), array('path' =>'lumia', 'name' =>'Lumia', 'img' =>'/img/missing-image.png', 'price' => 19.99, 'add_to_cart_url' =>'#'), array('path' =>'edge', 'name' =>'Edge', 'img' =>'/img/missing-image.png', 'price' => 39.99, 'add_to_cart_url' =>'#'), ); } } ``` 然后,我们添加`src/AppBundle/Service/Menu/Category.php`文件,内容如下: ```php class Category { public function getItems() { return array( array('path' =>'women', 'label' =>'Women'), array('path' =>'men', 'label' =>'Men'), array('path' =>'sport', 'label' =>'Sport'), ); } } ``` 接下来,我们添加`src/AppBundle/Service/Menu/Checkout.php`文件,内容如下所示: ```php class Checkout { public function getItems() { // Initial dummy menu return array( array('path' =>'cart', 'label' =>'Cart (3)'), array('path' =>'checkout', 'label' =>'Checkout'), ); } } ``` 完成后,我们将继续向`src/AppBundle/Service/Menu/Customer.php`文件添加以下内容: ```php class Customer { public function getItems() { // Initial dummy menu return array( array('path' =>'account', 'label' =>'John Doe'), array('path' =>'logout', 'label' =>'Logout'), ); } } ``` 然后我们添加`src/AppBundle/Service/Menu/OnSale.php`文件,内容如下: ```php class OnSale { public function getItems() { // Note, this can be arranged as per some "Product" interface, so to know what dummy data to return return array( array('path' =>'iphone', 'name' =>'iPhone', 'img' =>'/img/missing-image.png', 'price' => 19.99, 'add_to_cart_url' =>'#'), array('path' =>'lg', 'name' =>'LG', 'img' =>'/img/missing-image.png', 'price' => 29.99, 'add_to_cart_url' =>'#'), array('path' =>'samsung', 'name' =>'Samsung', 'img'=>'/img/missing-image.png', 'price' => 39.99, 'add_to_cart_url' =>'#'), array('path' =>'lumia', 'name' =>'Lumia', 'img' =>'/img/missing-image.png', 'price' => 49.99, 'add_to_cart_url' =>'#'), array('path' =>'edge', 'name' =>'Edge', 'img' =>'/img/missing-image.png', 'price' => 69.99, 'add_to_cart_url' =>'#'), ; } } ``` 我们现在已经定义了五个全局 Twig 变量,将用于构建我们的应用程序菜单。尽管变量现在连接到一个返回的虚拟数组的虚拟服务,但我们已经有效地将菜单项解耦到其他即将构建的模块中。当我们稍后开始构建我们的目录、客户和结账模块时,我们只需编写一个服务覆盖,并使用真实的项目填充菜单项数组。这将是理想的情况。 ### 注意 理想情况下,我们希望我们的服务按照某种接口返回数据,以确保谁覆盖或扩展它都是通过接口来实现的。由于我们试图保持我们的应用程序最小化,我们将继续使用简单的数组。 现在我们可以回到我们的`app/Resources/views/base.html.twig`文件,用以下内容替换前面代码中的``: ```php
Menu
{# category_menu is global twig var filled from service, and later overriden by another module service #}
``` 然后我们用以下内容替换``: ```php
{% for flash_message in app.session.flashBag.get('alert') %}
{{ flash_message }}
{% endfor %} {% for flash_message in app.session.flashBag.get('warning') %}
{{ flash_message }}
{% endfor %} {% for flash_message in app.session.flashBag.get('success') %}
{{ flash_message }}
{% endfor %}
``` 我们用以下内容替换``: ```php
{% block body %}{% endblock %}
``` 我们用以下内容替换``: ```php ``` 现在我们可以继续编辑`src/AppBundle/Controller/DefaultController.php`文件,并添加以下代码: ```php /** * @Route("/", name="homepage") */ public function indexAction(Request $request) { return $this->render('AppBundle:default:index.html.twig'); } /** * @Route("/about", name="about") */ public function aboutAction() { return $this->render('AppBundle:default:about.html.twig'); } /** * @Route("/customer-service", name="customer_service") */ public function customerServiceAction() { return $this->render('AppBundle:default:customer-service.html.twig'); } /** * @Route("/orders-and-returns", name="orders_returns") */ public function ordersAndReturnsAction() { return $this->render('AppBundle:default:orders-returns.html.twig'); } /** * @Route("/privacy-and-cookie-policy", name="privacy_cookie") */ public function privacyAndCookiePolicyAction() { return $this->render('AppBundle:default:privacy-cookie.html.twig'); } ``` 位于`src/AppBundle/Resources/views/default`目录中的所有使用的模板文件(`about.html.twig`、`customer-service.html.twig`、`orders-returns.html.twig`、`privacy-cookie.html.twig`)可以类似地定义如下: ```php {% extends 'base.html.twig' %} {% block body %}

About Us

Loremipsum dolor sit amet, consecteturadipiscingelit...

{% endblock %} ``` 在这里,我们只是将标题和内容包装到带有`row`类的`div`元素中,以便给它一些结构。结果应该是类似于这里显示的页面: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_06_04.jpg) **联系我们**页面需要不同的方法,因为它将包含一个表单。为了构建一个表单,我们使用 Symfony 的`Form`组件,通过向`src/AppBundle/Controller/DefaultController.php`文件添加以下内容: ```php /** * @Route("/contact", name="contact") */ public function contactAction(Request $request) { // Build a form, with validation rules in place $form = $this->createFormBuilder() ->add('name', TextType::class, array( 'constraints' => new NotBlank() )) ->add('email', EmailType::class, array( 'constraints' => new Email() )) ->add('message', TextareaType::class, array( 'constraints' => new Length(array('min' => 3)) )) ->add('save', SubmitType::class, array( 'label' =>'Reach Out!', 'attr' => array('class' =>'button'), )) ->getForm(); // Check if this is a POST type request and if so, handle form if ($request->isMethod('POST')) { $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->addFlash( 'success', 'Your form has been submitted. Thank you.' ); // todo: Send an email out... return $this->redirect($this->generateUrl('contact')); } } // Render "contact us" page return $this->render('AppBundle:default:contact.html.twig', array( 'form' => $form->createView() )); } ``` 我们首先通过表单构建器构建了一个表单。`add`方法接受字段定义和字段约束,验证可以基于它们进行。然后我们添加了对 HTTP POST 方法的检查,如果是这种情况,我们将使用请求参数填充表单并对其进行验证。 通过`contactAction`方法,我们仍然需要一个模板文件来实际渲染表单。我们通过添加`src/AppBundle/Resources/views/default/contact.html.twig`文件并添加以下内容来实现: ```php {% extends 'base.html.twig' %} {% block body %}

Contact Us

{{ form_start(form) }} {{ form_widget(form) }} {{ form_end(form) }}
{% endblock %} ``` 根据这些标签,Twig 为我们处理了表单渲染。结果的浏览器输出如下所示: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_06_03.jpg) 我们几乎已经准备好所有的页面了。但是还有一件事缺失,就是我们主页的正文区域。与其他具有静态内容的页面不同,这个页面实际上是动态的,因为它列出了畅销书和特价商品。这些数据预计来自其他模块,目前还不可用。但是,这并不意味着我们不能为它们准备虚拟占位符。让我们继续编辑`app/Resources/views/default/index.html.twig`文件如下: ```php {% extends 'base.html.twig' %} {% block body %} {% endblock %} ``` 现在我们需要用以下内容替换``: ```php {% if products_bestsellers %}

Best Sellers

{% for product in products_bestsellers.getItems() %}
missing image {{ product.name }}
${{ product.price }}
{% endfor %}
{% endif %} ``` 现在我们需要用以下内容替换``: ```php {% if products_onsale %}

On Sale

{% for product in products_onsale.getItems() %}
missing image {{ product.name }}
${{ product.price }}
{% endfor %}
{% endif %} ``` ### 提示 [`dummyimage.com`](http://dummyimage.com)使我们能够为我们的应用程序创建占位图像。 此时,我们应该看到如下所示的主页: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_06_02.jpg) ## 配置应用程序范围的安全性 作为应用程序范围安全的一部分,我们试图设置一些基本保护,防止未来的客户或任何其他用户能够访问和使用未来自动生成的 CRUD 控制器。我们通过修改`app/config/security.yml`文件来实现这一点。`security.yml`文件有几个组件需要处理:防火墙、访问控制、提供程序和编码器。如果我们观察先前测试应用程序中自动生成的 CRUD,就会清楚地看到我们需要保护以下内容,以防止客户访问: + `GET|POST /new` + `GET|POST /{id}/edit` + `DELETE /{id}` 换句话说,所有在 URL 中包含`/new`和`/edit`,以及所有使用`DELETE`方法的内容,都需要受到客户的保护。考虑到这一点,我们将使用 Symfony 安全功能创建一个具有`ROLE_ADMIN`角色的内存用户。然后,我们将创建一个访问控制列表,只允许`ROLE_ADMIN`访问我们刚刚提到的资源,并创建一个防火墙,当我们尝试访问这些资源时触发 HTTP 基本身份验证登录表单。 使用内存提供程序意味着在我们的`security.yml`文件中硬编码用户。对于我们应用程序的目的,我们将为管理员类型的用户这样做。然而,实际密码不需要硬编码。假设我们将使用`1L6lllW9zXg0`作为密码,让我们跳转到控制台并输入以下命令: ```php **php bin/console security:encode-password** ``` 这将产生以下输出。 ![配置应用程序范围的安全性](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_06_01.jpg) 我们现在可以通过添加内存提供程序并将生成的编码密码复制粘贴到其中来编辑`security.yml`,如下所示: ```php security: providers: in_memory: memory: users: john: password: $2y$12$DFozWehwPkp14sVXr7.IbusW8ugvmZs9dQMExlggtyEa/TxZUStnO roles: 'ROLE_ADMIN' ``` 在这里,我们定义了一个具有`ROLE_ADMIN`角色和编码`1L6lllW9zXg0`密码的用户`john`。 一旦我们有了提供程序,我们就可以继续在`security.yml`文件中添加编码器。否则 Symfony 将不知道如何处理分配给`john`用户的当前密码: ```php security: encoders: Symfony\Component\Security\Core\User\User: algorithm: bcrypt cost: 12 ``` 然后我们添加防火墙如下: ```php security: firewalls: guard_new_edit: pattern: /(new)|(edit) methods: [GET, POST] anonymous: ~ http_basic: ~ guard_delete: pattern: / methods: [DELETE] anonymous: ~ http_basic: ~ ``` `guard_new_edit`和`guard_delete`是我们两个应用程序防火墙的自由名称。`guard_new_edit`防火墙将拦截包含`/new`或`/edit`字符串的任何路由的所有 GET 和 POST 请求。`guard_delete`防火墙将拦截任何 URL 上的任何 HTTP `DELETE`方法。一旦这些防火墙启动,它们将显示一个 HTTP 基本身份验证表单,并且只有在用户登录后才允许访问。 然后我们按以下方式添加访问控制列表: ```php security: access_control: # protect any possible auto-generated CRUD actions from everyone's access - { path: /new, roles: ROLE_ADMIN } - { path: /edit, roles: ROLE_ADMIN } - { path: /, roles: ROLE_ADMIN, methods: [DELETE] } ``` 有了这些条目,任何试图访问任何 URL 的人,只要符合`access_control`下定义的任何模式,都将看到浏览器登录,如下所示: ![配置应用程序范围的安全性](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_06_07.jpg) 唯一可以登录的用户是`john`,密码是`1L6lllW9zXg0`。一旦认证,用户可以访问所有的 CRUD 链接。这对于我们简单的应用程序应该足够了。 # 单元测试 我们当前的模块除了控制器类和虚拟服务类之外没有特定的类。因此,我们不会在这里费心进行单元测试。 # 功能测试 在我们开始编写功能测试之前,我们需要通过将我们的 bundle `Tests`目录添加到`testsuite`路径中来编辑`phpunit.xml.dist`文件,如下所示: ```php <-- ... other elements ... --> src/AppBundle/Tests <-- ... other elements ... --> ``` 我们的功能测试将只覆盖一个控制器,因为我们没有其他控制器。我们首先创建一个`src/AppBundle/Tests/Controller/DefaultControllerTest.php`文件,内容如下: ```php namespace AppBundle\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class DefaultControllerTest extends WebTestCase { //… } ``` 下一步是测试我们的每一个控制器动作。至少我们应该测试页面内容是否被正确输出。 ### 提示 为了在我们的 IDE 中获得自动完成,我们可以从官方网站[`phpunit.de`](https://phpunit.de)下载`PHPUnitphar`文件。下载后,我们可以简单地将其添加到项目的根目录,这样 IDE(如**PHPStorm**)就可以识别它。这样就可以轻松地跟踪所有`$this->assert`方法调用及其参数。 我们想要测试的第一件事是我们的主页。我们通过向`DefaultControllerTest`类的主体添加以下内容来实现这一点。 ```php public function testHomepage() { // @var \Symfony\Bundle\FrameworkBundle\Client $client = static::createClient(); /** @var \Symfony\Component\DomCrawler\Crawler */ $crawler = $client->request('GET', '/'); // Check if homepage loads OK $this->assertEquals(200, $client->getResponse()->getStatusCode()); // Check if top bar left menu is present $this->assertNotEmpty($crawler->filter('.top-bar-left li')->count()); // Check if top bar right menu is present $this->assertNotEmpty($crawler->filter('.top-bar-right li')->count()); // Check if footer is present $this->assertNotEmpty($crawler->filter('.footer li')->children()->count()); } ``` 在这里,我们一次检查了几件事。我们检查页面是否正常加载,HTTP 200 状态。然后我们抓取左右菜单并计算它们的项目数,以查看它们是否有任何项目。如果所有单独的检查都通过了,`testHomepage`测试就被认为是通过的。 我们通过向`DefaultControllerTest`类添加以下内容来进一步测试所有静态页面: ```php public function testStaticPages() { // @var \Symfony\Bundle\FrameworkBundle\Client $client = static::createClient(); /** @var \Symfony\Component\DomCrawler\Crawler */ // Test About Us page $crawler = $client->request('GET', '/about'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertContains('About Us', $crawler->filter('h1')->text()); // Test Customer Service page $crawler = $client->request('GET', '/customer-service'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertContains('Customer Service', $crawler->filter('h1')->text()); // Test Privacy and Cookie Policy page $crawler = $client->request('GET', '/privacy-and-cookie-policy'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertContains('Privacy and Cookie Policy', $crawler->filter('h1')->text()); // Test Orders and Returns page $crawler = $client->request('GET', '/orders-and-returns'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertContains('Orders and Returns', $crawler->filter('h1')->text()); // Test Contact Us page $crawler = $client->request('GET', '/contact'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertContains('Contact Us', $crawler->filter('h1')->text()); } ``` 在这里,我们对所有页面运行相同的`assertEquals`和`assertContains`函数。我们只是试图确认每个页面是否以 HTTP 200 加载,并且页面标题的正确值是否返回,也就是`h1`元素。 最后,我们需要在`DefaultControllerTest`类中添加以下内容来处理表单提交测试: ```php public function testContactFormSubmit() { // @var \Symfony\Bundle\FrameworkBundle\Client $client = static::createClient(); /** @var \Symfony\Component\DomCrawler\Crawler */ $crawler = $client->request('GET', '/contact'); // Find a button labeled as "Reach Out!" $form = $crawler->selectButton('Reach Out!')->form(); // Note this does not validate form, it merely tests against submission and response page $crawler = $client->submit($form); $this->assertEquals(200, $client->getResponse()->getStatusCode()); } ``` 在这里,我们通过其**Reach Out!**提交按钮抓取表单元素。一旦获取表单,我们就在客户端上触发`submit`方法,将元素实例传递给它。值得注意的是,这里并没有测试实际的表单验证。即使如此,提交的表单应该会导致 HTTP 200 状态。 这些测试是有说服力的。如果我们愿意,我们可以编写更加健壮的测试,因为有许多元素可以进行测试。 # 总结 在本章中,我们构建了我们的第一个模块,或者在 Symfony 术语中称为 bundle。该模块本身并不是真正松散耦合的,因为它依赖于`app`目录中的一些内容,比如`app/Resources/views/base.html.twig`布局模板。当涉及核心模块时,我们可以这样做,因为它们只是我们为其余模块设置的基础。 在接下来的章节中,我们将构建一个目录模块。这将是我们网店应用程序的基础。 # 第七章:构建目录模块 目录模块是每个网店应用程序的基本组成部分。在最基本的级别上,它负责管理和显示类别和产品。这是以后模块的基础,例如结账,它为我们的网店应用程序添加了实际的销售功能。 更强大的目录功能可能包括大规模产品导入、产品导出、多仓库库存管理、私人会员类别等。然而,这些超出了本章的范围。 在本章中,我们将涵盖以下主题: + 要求 + 依赖关系 + 实现 + 单元测试 + 功能测试 # 要求 根据第四章中定义的高级应用程序要求,*模块化网店应用的需求规范*,我们的模块将实现多个实体和其他特定功能。 以下是所需模块实体的列表: + 类别 + 产品 类别实体包括以下属性及其数据类型: + `id`:整数,自增 + `title`:字符串 + `url_key`:字符串,唯一 + `description`:文本 + `image`:字符串 产品实体包括以下属性: + `id`:整数,自增 + `category_id`:整数,引用类别表 ID 列的外键 + `title`:字符串 + `price`:十进制 + `sku`:字符串,唯一 + `url_key`:字符串,唯一 + `description`:文本 + `qty`:整数 + `image`:字符串 + `onsale`:布尔值 除了添加这些实体及其 CRUD 页面之外,我们还需要覆盖负责构建类别菜单和特价商品的核心模块服务。 # 依赖关系 该模块对任何其他模块没有明确的依赖关系。Symfony 框架服务层使我们能够以这样的方式编写模块,大多数情况下它们之间不需要依赖关系。虽然该模块确实覆盖了核心模块中定义的一个服务,但该模块本身并不依赖于它,如果覆盖的服务丢失,也不会出现任何问题。 # 实现 我们首先创建一个名为`Foggyline\CatalogBundle`的新模块。我们通过控制台运行以下命令来完成: ```php **php bin/console generate:bundle --namespace=Foggyline/CatalogBundle** ``` 该命令触发一个交互过程,在这个过程中,会向我们询问几个问题,如下截图所示: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_01.jpg) 完成后,我们生成了以下结构: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_03.jpg) 如果我们现在查看`app/AppKernel.php`文件,我们会在`registerBundles`方法下看到以下行: ```php new Foggyline\CatalogBundle\FoggylineCatalogBundle() ``` 同样,`app/config/routing.yml`中添加了以下路由定义: ```php foggyline_catalog: resource: "@FoggylineCatalogBundle/Resources/config/routing.xml" prefix: / ``` 在这里,我们需要将`prefix: /`更改为`prefix: /catalog/`,以便不与核心模块路由冲突。保持`prefix: /`将简单地覆盖我们的核心`AppBundle`,并从`src/Foggyline/CatalogBundle/Resources/views/Default/index.html.twig`模板向浏览器输出`Hello World!`。我们希望保持事情的清晰分离。这意味着该模块不为自身定义根路由。 ## 创建实体 让我们继续创建一个`Category`实体。我们通过控制台来完成,如下所示: ```php **php bin/console generate:doctrine:entity** ``` ![创建实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_04.jpg) 这将在`src/Foggyline/CatalogBundle/`目录中创建`Entity/Category.php`和`Repository/CategoryRepository.php`文件。之后,我们需要更新数据库,以便引入`Category`实体,如下命令行示例所示: ```php **php bin/console doctrine:schema:update --force** ``` 这将产生一个类似于以下截图的屏幕: ![创建实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_05.jpg) 有了实体,我们就可以生成其 CRUD。我们通过以下命令来完成: ```php **php bin/console generate:doctrine:crud** ``` 这将产生如下交互式输出: ![创建实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_06.jpg) 这导致创建了`src/Foggyline/CatalogBundle/Controller/CategoryController.php`。它还在我们的`app/config/routing.yml`文件中添加了一个条目,如下所示: ```php foggyline_catalog_category: resource: "@FoggylineCatalogBundle/Controller/CategoryController.php" type: annotation ``` 此外,视图文件创建在`app/Resources/views/category/`目录下,这不是我们所期望的。我们希望它们在我们的模块`src/Foggyline/CatalogBundle/Resources/views/Default/category/`目录下,因此我们需要将它们复制过去。此外,我们需要修改`CategoryController`中的所有`$this->render`调用,通过在每个模板路径后附加`FoggylineCatalogBundle:default: string`来修改它们。 接下来,我们继续使用之前讨论过的交互式生成器创建`Product`实体: ```php **php bin/console generate:doctrine:entity** ``` 我们遵循交互式生成器,尊重以下属性的最小值:`title`、`price`、`sku`、`url_key`、`description`、`qty`、`category`和`image`。除了`price`和`qty`是十进制和整数类型之外,所有其他属性都是字符串类型。此外,`sku`和`url_key`被标记为唯一。这将在`src/Foggyline/CatalogBundle/`目录中创建`Entity/Product.php`和`Repository/ProductRepository.php`文件。 与我们为`Category view`模板所做的类似,我们需要为`Product view`模板做同样的事情。也就是说,将它们从`app/Resources/views/product/`目录复制到`src/Foggyline/CatalogBundle/Resources/views/Default/product/`,并通过在每个模板路径后附加`FoggylineCatalogBundle:default: string`来更新`ProductController`中的所有`$this->render`调用。 此时,我们不会急于更新模式,因为我们想要为我们的代码添加适当的关系。每个产品应该能够与单个`Category`实体建立关系。为了实现这一点,我们需要编辑`src/Foggyline/CatalogBundle/Entity/`目录中的`Category.php`和`Product.php`,如下所示: ```php // src/Foggyline/CatalogBundle/Entity/Category.php /** * @ORM\OneToMany(targetEntity="Product", mappedBy="category") */ private $products; public function __construct() { $this->products = new \Doctrine\Common\Collections\ArrayCollection(); } // src/Foggyline/CatalogBundle/Entity/Product.php /** * @ORM\ManyToOne(targetEntity="Category", inversedBy="products") * @ORM\JoinColumn(name="category_id", referencedColumnName="id") */ private $category; ``` 我们还需要编辑`Category.php`文件,添加`__toString`方法的实现,如下所示: ```php public function __toString() { return $this->getTitle(); } ``` 我们这样做的原因是,稍后,我们的产品编辑表单将知道在类别选择下列出什么标签,否则系统会抛出以下错误: ```php Catchable Fatal Error: Object of class Foggyline\CatalogBundle\Entity\Category could not be converted to string ``` 有了以上更改,我们现在可以运行模式更新,如下所示: ```php **php bin/console doctrine:schema:update --force** ``` 如果我们现在查看我们的数据库,`product`表的`CREATE`命令语法如下所示: ```php CREATE TABLE `product` ( `id` int(11) NOT NULL AUTO_INCREMENT, `category_id` int(11) DEFAULT NULL, `title` varchar(255) COLLATE utf8_unicode_ci NOT NULL, `price` decimal(10,2) NOT NULL, `sku` varchar(255) COLLATE utf8_unicode_ci NOT NULL, `url_key` varchar(255) COLLATE utf8_unicode_ci NOT NULL, `description` longtext COLLATE utf8_unicode_ci, `qty` int(11) NOT NULL, `image` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `UNIQ_D34A04ADF9038C4` (`sku`), UNIQUE KEY `UNIQ_D34A04ADDFAB7B3B` (`url_key`), KEY `IDX_D34A04AD12469DE2` (`category_id`), CONSTRAINT `FK_D34A04AD12469DE2` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; ``` 我们可以看到定义了两个唯一键和一个外键约束,根据我们交互式实体生成器提供的条目。现在我们准备为我们的`Product`实体生成 CRUD。为此,我们运行`generate:doctrine:crud`命令,并按照交互式生成器的指示进行操作,如下所示: ![创建实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_07.jpg) ## 管理图像上传 此时,如果我们访问`/category/new/`或`/product/new/`URL,图像字段只是一个简单的文本输入字段,而不是我们想要的实际图像上传。为了将其变成图像上传字段,我们需要编辑`Category.php`和`Product.php`中的`$image`属性,如下所示: ```php //… use Symfony\Component\Validator\Constraints as Assert; //… class [Category|Product] { //… /** * @var string * * @ORM\Column(name="image", type="string", length=255, nullable=true) * @Assert\File(mimeTypes={ "image/png", "image/jpeg" }, mimeTypesMessage="Please upload the PNG or JPEG image file.") */ private $image; //… } ``` 一旦我们这样做,输入字段就变成了文件上传字段,如下所示: ![管理图像上传](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_09.jpg) 接下来,我们将继续将上传功能实现到表单中。 我们首先通过在`src/Foggyline/CatalogBundle/Resources/config/services.xml`文件的`services`元素下添加以下条目来定义处理实际上传的服务: ```php %foggyline_catalog_images_directory% ``` `%foggyline_catalog_images_directory%`参数值是我们即将定义的一个参数的名称。 然后,我们创建`src/Foggyline/CatalogBundle/Service/ImageUploader.php`文件,内容如下: ```php namespace Foggyline\CatalogBundle\Service; use Symfony\Component\HttpFoundation\File\UploadedFile; class ImageUploader { private $targetDir; public function __construct($targetDir) { $this->targetDir = $targetDir; } public function upload(UploadedFile $file) { $fileName = md5(uniqid()) . '.' . $file->guessExtension(); $file->move($this->targetDir, $fileName); return $fileName; } } ``` 然后,我们在`src/Foggyline/CatalogBundle/Resources/config`目录中创建自己的`parameters.yml`文件,内容如下: ```php parameters: foggyline_catalog_images_directory: "%kernel.root_dir%/../web/uploads/foggyline_catalog_images" ``` 这是我们的服务期望找到的参数。如果需要,可以在`app/config/parameters.yml`下用相同的条目轻松覆盖它。 为了使我们的 bundle 能够看到`parameters.yml`文件,我们仍然需要编辑`src/Foggyline/CatalogBundle/DependencyInjection/ directory`中的`FoggylineCatalogExtension.php`文件,通过在`load`方法的末尾添加以下`loader`来实现: ```php $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('parameters.yml'); ``` 此时,我们的 Symfony 模块能够读取其`parameters.yml`,从而使其定义的服务能够获取其参数的正确值。现在只需要调整我们的`new`和`edit`表单的代码,将上传功能附加到它们上。由于这两个表单是相同的,以下是一个同样适用于`Product`表单的`Category`示例: ```php public function newAction(Request $request) { // ... if ($form->isSubmitted() && $form->isValid()) { /* @var $image \Symfony\Component\HttpFoundation\File\UploadedFile */ if ($image = $category->getImage()) { $name = $this->get('foggyline_catalog.image_uploader')->upload($image); $category->setImage($name); } $em = $this->getDoctrine()->getManager(); // ... } // ... } public function editAction(Request $request, Category $category) { $existingImage = $category->getImage(); if ($existingImage) { $category->setImage( new File($this->getParameter('foggyline_catalog_images_directory') . '/' . $existingImage) ); } $deleteForm = $this->createDeleteForm($category); // ... if ($editForm->isSubmitted() && $editForm->isValid()) { /* @var $image \Symfony\Component\HttpFoundation\File\UploadedFile */ if ($image = $category->getImage()) { $name = $this->get('foggyline_catalog.image_uploader')->upload($image); $category->setImage($name); } elseif ($existingImage) { $category->setImage($existingImage); } $em = $this->getDoctrine()->getManager(); // ... } // ... } ``` 现在`new`和`edit`表单都应该能够处理文件上传。 ## 覆盖核心模块服务 现在让我们继续处理类别菜单和特价商品。在构建核心模块时,我们在`app/config/config.yml`文件的`twig:global`部分定义了全局变量。这些变量指向了在`app/config/services.yml`文件中定义的服务。为了改变类别菜单和特价商品的内容,我们需要覆盖这些服务。 我们首先在`src/Foggyline/CatalogBundle/Resources/config/services.xml`文件中添加以下两个服务定义: ```php ``` 这两个服务都接受 Doctrine ORM 实体管理器和路由器服务参数,因为我们需要在内部使用它们。 然后我们在`src/Foggyline/CatalogBundle/Service/Menu/`目录中创建了实际的`Category`和`OnSale`服务类,如下所示: ```php //Category.php namespace Foggyline\CatalogBundle\Service\Menu; class Category { private $em; private $router; public function __construct( \Doctrine\ORM\EntityManager $entityManager, \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->em = $entityManager; $this->router = $router; } public function getItems() { $categories = array(); $_categories = $this->em->getRepository('FoggylineCatalogBundle:Category')->findAll(); foreach ($_categories as $_category) { /* @var $_category \Foggyline\CatalogBundle\Entity\Category */ $categories[] = array( 'path' => $this->router->generate('category_show', array('id' => $_category->getId())), 'label' => $_category->getTitle(), ); } return $categories; } } //OnSale.php namespace Foggyline\CatalogBundle\Service\Menu; class OnSale { private $em; private $router; public function __construct(\Doctrine\ORM\EntityManager $entityManager, $router) { $this->em = $entityManager; $this->router = $router; } public function getItems() { $products = array(); $_products = $this->em->getRepository('FoggylineCatalogBundle:Product')->findBy( array('onsale' => true), null, 5 ); foreach ($_products as $_product) { /* @var $_product \Foggyline\CatalogBundle\Entity\Product */ $products[] = array( 'path' => $this->router->generate('product_show', array('id' => $_product->getId())), 'name' => $_product->getTitle(), 'image' => $_product->getImage(), 'price' => $_product->getPrice(), 'id' => $_product->getId(), ); } return $products; } } ``` 这样单独做不会触发核心模块服务的覆盖。在`src/Foggyline/CatalogBundle/DependencyInjection/Compiler/`目录中,我们需要创建一个实现`CompilerPassInterface`的`OverrideServiceCompilerPass`类。在其`process`方法中,我们可以改变服务的定义,如下所示: ```php namespace Foggyline\CatalogBundle\DependencyInjection\Compiler; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; class OverrideServiceCompilerPass implements CompilerPassInterface { public function process(ContainerBuilder $container) { // Override the core module 'category_menu' service $container->removeDefinition('category_menu'); $container->setDefinition('category_menu', $container->getDefinition('foggyline_catalog.category_menu')); // Override the core module 'onsale' service $container->removeDefinition('onsale'); $container->setDefinition('onsale', $container->getDefinition('foggyline_catalog.onsale')); } } ``` 最后,我们需要编辑`src/Foggyline/CatalogBundle/FoggylineCatalogBundle.php`文件的`build`方法,以添加这个编译器通行证,如下所示: ```php public function build(ContainerBuilder $container) { parent::build($container); $container->addCompilerPass(new \Foggyline\CatalogBundle\DependencyInjection\Compiler\OverrideServiceCompilerPass()); } ``` 现在我们的`Category`和`OnSale`服务应该覆盖核心模块中定义的服务,从而为主页的标题**类别**菜单和**特价**部分提供正确的值。 ## 设置类别页面 自动生成的 CRUD 为我们创建了一个类别页面,布局如下: ![设置类别页面](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_10.jpg) 这与第四章中定义的类别页面有很大不同,因此我们需要修改`src/Foggyline/CatalogBundle/Resources/views/Default/category/`目录中的`show.html.twig`文件来修改我们的类别展示页面。我们通过用以下代码替换`body`块的整个内容来实现: ```php

{{ category.title }}

{{ category.description }}

{% set products = category.getProducts() %} {% if products %}
{% for product in products %}
missing image {{ product.title }}
${{ product.price }}
{% endfor %}
{% else %}

There are no products assigned to this category.

{% endif %} {% if is_granted('ROLE_ADMIN') %}
  • Edit
  • {{ form_start(delete_form) }} form_end(delete_form) }}
{% endif %} ``` 现在主体分为三个区域。首先,我们处理类别标题和描述输出。然后,我们获取并循环遍历分配给类别的产品列表,渲染每个单独的产品。最后,我们使用`is_granted` Twig 扩展来检查当前用户角色是否为`ROLE_ADMIN`,在这种情况下,我们显示类别的`编辑`和`删除`链接。 ## 设置产品页面 自动生成的 CRUD 为我们创建了一个产品页面,布局如下: ![设置产品页面](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_07_11.jpg) 这与第四章中定义的产品页面有所不同,*模块化网店应用的需求规格*。为了纠正问题,我们需要修改`src/Foggyline/CatalogBundle/Resources/views/Default/product/`目录中的`show.html.twig`文件,通过替换`body`块的整个内容来实现。 ```php

{{ product.title }}

SKU: {{ product.sku }}
{% if product.qty %}
IN STOCK
{% else %}
OUT OF STOCK
{% endif %}
$ {{ product.price }}
Qty

{{ product.description }}

{% if is_granted('ROLE_ADMIN') %}
  • Edit
  • {{ form_start(delete_form) }} {{ form_end(delete_form) }}
{% endif %} ``` 现在,主体分为两个主要部分。首先,我们处理产品图片、标题、库存状态和添加到购物车输出。添加到购物车表单使用`add_to_cart_url`服务来提供正确的链接。这个服务在核心模块中定义,并且目前只提供一个虚拟链接。稍后,当我们到达结账模块时,我们将为这个服务实现一个覆盖,并注入正确的添加到购物车链接。然后我们输出描述部分。最后,我们使用`is_granted` Twig 扩展,就像我们在 Category 示例中所做的那样,来确定用户是否可以访问产品的`编辑`和`删除`链接。 # 单元测试 现在我们有几个与控制器无关的类文件,这意味着我们可以对它们进行单元测试。但是,作为本书的一部分,我们不会追求完整的代码覆盖率,而是专注于一些小而重要的事情,比如在我们的测试类中使用容器。 我们首先在`phpunit.xml.dist`文件的`testsuites`元素下添加以下行: ```php src/Foggyline/CatalogBundle/Tests ``` 有了这个设置,从我们商店的根目录运行`phpunit`命令应该会捡起我们在`src/Foggyline/CatalogBundle/Tests/`目录下定义的任何测试。 现在让我们为我们的 Category 服务菜单创建一个测试。我们通过创建一个`src/Foggyline/CatalogBundle/Tests/Service/Menu/CategoryTest.php`文件来实现: ```php namespace Foggyline\CatalogBundle\Tests\Service\Menu; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Foggyline\CatalogBundle\Service\Menu\Category; class CategoryTest extends KernelTestCase { private $container; private $em; private $router; public function setUp() { static::bootKernel(); $this->container = static::$kernel->getContainer(); $this->em = $this->container->get('doctrine.orm.entity_manager'); $this->router = $this->container->get('router'); } public function testGetItems() { $service = new Category($this->em, $this->router); $this->assertNotEmpty($service->getItems()); } protected function tearDown() { $this->em->close(); unset($this->em, $this->router); } } ``` 前面的例子展示了`setUp`和`tearDown`方法的使用,它们的行为类似于 PHP 的`__construct`和`__destruct`方法。我们使用`setUp`方法来设置实体管理器和路由器服务,以便在类的其余部分中使用。`tearDown`方法只是一个清理工作。现在如果我们运行`phpunit`命令,我们应该能看到我们的测试被捡起并在其他测试之后执行。 我们甚至可以通过执行带有完整类路径的`phpunit`命令来专门针对这个类,如下所示: ```php **phpunit src/Foggyline/CatalogBundle/Tests/Service/Menu/CategoryTest.php** ``` 类似于我们为`CategoryTest`所做的,我们可以继续创建`OnSaleTest`;两者之间唯一的区别是类名。 # 功能测试 自动生成 CRUD 工具的好处在于它甚至为我们生成了功能测试。具体来说,在这种情况下,它在`src/Foggyline/CatalogBundle/Tests/Controller/`目录下生成了`CategoryControllerTest.php`和`ProductControllerTest.php`文件。 ### 提示 自动生成的功能测试在类体内有注释掉的方法。这在`phpunit`运行时会报错。我们至少需要在其中定义一个虚拟的`test`方法,以便让`phpunit`忽略它们。 如果我们查看这两个文件,我们会发现它们都定义了一个`testCompleteScenario`方法,但是这个方法完全被注释掉了。让我们继续并修改`CategoryControllerTest.php`的内容如下: ```php // Create a new client to browse the application $client = static::createClient( array(), array( 'PHP_AUTH_USER' => 'john', 'PHP_AUTH_PW' => '1L6lllW9zXg0', ) ); // Create a new entry in the database $crawler = $client->request('GET', '/category/'); $this->assertEquals(200, $client->getResponse()->getStatusCode(), "Unexpected HTTP status code for GET /product/"); $crawler = $client->click($crawler->selectLink('Create a new entry')->link()); // Fill in the form and submit it $form = $crawler->selectButton('Create')->form(array( 'category[title]' => 'Test', 'category[urlKey]' => 'Test urlKey', 'category[description]' => 'Test description', )); $client->submit($form); $crawler = $client->followRedirect(); // Check data in the show view $this->assertGreaterThan(0, $crawler->filter('h1:contains("Test")')->count(), 'Missing element h1:contains("Test")'); // Edit the entity $crawler = $client->click($crawler->selectLink('Edit')->link()); $form = $crawler->selectButton('Edit')->form(array( 'category[title]' => 'Foo', 'category[urlKey]' => 'Foo urlKey', 'category[description]' => 'Foo description', )); $client->submit($form); $crawler = $client->followRedirect(); // Check the element contains an attribute with value equals "Foo" $this->assertGreaterThan(0, $crawler->filter('[value="Foo"]')->count(), 'Missing element [value="Foo"]'); // Delete the entity $client->submit($crawler->selectButton('Delete')->form()); $crawler = $client->followRedirect(); // Check the entity has been delete on the list $this->assertNotRegExp('/Foo title/', $client->getResponse()->getContent()); ``` 我们首先将`PHP_AUTH_USER`和`PHP_AUTH_PW`设置为`createClient`方法的参数。这是因为我们的`/new`和`/edit`路由受核心模块安全保护。这些设置允许我们在请求中传递基本的 HTTP 身份验证。然后我们测试了类别列表页面是否可以访问以及它的`创建新条目`链接是否可以被点击。此外,我们还测试了`create`和`edit`表单以及它们的结果。 现在剩下的就是重复我们刚才在`CategoryControllerTest.php`中使用的方法,在`ProductControllerTest.php`中进行。我们只需要在`ProductControllerTest`类文件中更改一些标签,以匹配`product`路由和预期结果。 现在运行`phpunit`命令应该能成功执行我们的测试。 # 总结 在本章中,我们构建了一个微型但功能齐全的目录模块。它允许我们创建、编辑和删除类别和产品。通过在自动生成的 CRUD 之上添加几行自定义代码,我们能够为类别和产品实现图像上传功能。我们还看到了如何覆盖核心模块服务,只需删除现有的服务定义并提供一个新的定义。在测试方面,我们看到了如何在我们的请求中传递身份验证以测试受保护的路由。 在接下来的章节中,我们将构建一个客户模块。 # 第八章:构建客户模块 客户模块为我们的网店提供了进一步销售功能的基础。在非常基本的层面上,它负责注册、登录、管理和显示相关客户信息。这是后续销售模块的要求,它为我们的网店应用程序添加了实际的销售功能。 在本章中,我们将涵盖以下主题: + 要求 + 依赖关系 + 实现 + 单元测试 + 功能测试 # 要求 根据第四章中定义的高级应用程序要求,*模块化网店应用的需求规范*,我们的模块将定义一个名为`Customer`的实体。 `Customer`实体包括以下属性: + `id`: integer, auto-increment + `email`: string, unique + `username`: string, unique, needed for login system + `password`: string + `first_name`: string + `last_name`: string + `company`: string + `phone_number`: string + `country`: string + `state`: string + `city`: string + `postcode`: string + `street`: string 在本章中,除了添加`Customer`实体及其 CRUD 页面之外,我们还需要处理登录、注册、忘记密码页面的创建,以及覆盖一个负责构建客户菜单的核心模块服务。 # 依赖关系 该模块不依赖于任何其他模块。虽然它覆盖了核心模块中定义的一个服务,但模块本身并不依赖于它。此外,一些安全配置将作为核心应用程序的一部分提供,我们稍后会看到。 # 实现 我们首先创建一个名为`Foggyline\CustomerBundle`的新模块。我们可以通过控制台运行以下命令来实现: ```php **php bin/console generate:bundle --namespace=Foggyline/CustomerBundle** ``` 该命令触发了一个交互式过程,在这个过程中会问我们一些问题,如下面的截图所示: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_08_01.jpg) 完成后,我们得到了以下结构: ![实现](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_08_02.jpg) 如果我们现在查看`app/AppKernel.php`文件,我们会在`registerBundles`方法下看到以下行: ```php new Foggyline\CustomerBundle\FoggylineCustomerBundle() ``` 同样,`app/config/routing.yml`目录中添加了以下路由定义: ```php foggyline_customer: resource: "@FoggylineCustomerBundle/Resources/config/routing.xml" prefix: / ``` 在这里,我们需要将`prefix: /`更改为`prefix: /customer/`,这样我们就不会与核心模块的路由冲突。保持`prefix: /`不变将简单地覆盖我们的核心`AppBundle`,并从`src/Foggyline/CustomerBundle/Resources/views/Default/index.html.twig`模板向浏览器输出**Hello World!**。我们希望保持事情的清晰和分离。这意味着该模块不为自己定义`root`路由。 ## 创建客户实体 让我们继续创建一个`Customer`实体。我们可以通过控制台来实现: ```php **php bin/console generate:doctrine:entity** ``` 这个命令触发了交互式生成器,我们需要提供实体属性。完成后,生成器将在`src/Foggyline/CustomerBundle/`目录中创建`Entity/Customer.php`和`Repository/CustomerRepository.php`文件。之后,我们需要更新数据库,以便通过运行以下命令引入`Customer`实体: ```php **php bin/console doctrine:schema:update --force** ``` 这导致了一个屏幕,如下面的截图所示: ![创建客户实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_08_07.jpg) 有了实体,我们就可以生成它的 CRUD。我们可以通过以下命令来实现: ```php **php bin/console generate:doctrine:crud** ``` 这导致了一个交互式输出,如下所示: ![创建客户实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_08_03.jpg) 这导致了`src/Foggyline/CustomerBundle/Controller/CustomerController.php`目录的创建。它还在我们的`app/config/routing.yml`文件中添加了一个条目,如下所示: ```php foggyline_customer_customer: resource: "@FoggylineCustomerBundle/Controller/CustomerController.php" type: annotation ``` 同样,视图文件是在`app/Resources/views/customer/`目录下创建的,这不是我们所期望的。我们希望它们在我们的模块`src/Foggyline/CustomerBundle/Resources/views/Default/customer/`目录下,所以我们需要将它们复制过去。此外,我们需要修改`CustomerController`中的所有`$this->render`调用,通过在每个模板路径后附加`FoggylineCustomerBundle:default: string`来实现。 ## 修改安全配置 在我们继续进行模块内的实际更改之前,让我们想象一下我们的模块要求规定了某种安全配置以使其工作。这些要求规定我们需要对`app/config/security.yml`文件进行几处更改。我们首先编辑`providers`元素,添加以下条目: ```php foggyline_customer: entity: class: FoggylineCustomerBundle:Customer property: username ``` 这有效地将我们的`Customer`类定义为安全提供者,而`username`元素是存储用户身份的属性。 然后,在`encoders`元素下定义编码器类型,如下所示: ```php Foggyline\CustomerBundle\Entity\Customer: algorithm: bcrypt cost: 12 ``` 这告诉 Symfony 在加密密码时使用`bcrypt`算法,算法成本为`12`。这样,我们的密码在保存到数据库中时就不会以明文形式出现。 然后,我们继续在`firewalls`元素下定义一个新的防火墙条目,如下所示: ```php foggyline_customer: anonymous: ~ provider: foggyline_customer form_login: login_path: foggyline_customer_login check_path: foggyline_customer_login default_target_path: customer_account logout: path: /customer/logout target: / ``` 这里发生了很多事情。我们的防火墙使用`anonymous: ~`定义来表示它实际上不需要用户登录即可查看某些页面。默认情况下,所有 Symfony 用户都被验证为匿名用户,如下图所示,在**Developer**工具栏上: ![修改安全配置](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_08_04.jpg) `form_login`定义有三个属性。`login_path`和`check_path`指向我们的自定义路由`foggyline_customer_login`。当安全系统启动认证过程时,它将重定向用户到`foggyline_customer_login`路由,我们将很快实现所需的控制器逻辑和视图模板,以处理登录表单。一旦登录,`default_target_path`确定用户将被重定向到哪里。 最后,我们重用 Symfony 匿名用户功能,以排除某些页面被禁止。我们希望我们的非认证客户能够访问登录、注册和忘记密码页面。为了实现这一点,我们在`access_control`元素下添加以下条目: ```php - { path: customer/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: customer/register, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: customer/forgotten_password, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: customer/account, roles: ROLE_USER } - { path: customer/logout, roles: ROLE_USER } - { path: customer/, roles: ROLE_ADMIN } ``` 值得注意的是,这种处理模块和基本应用程序之间安全性的方法远非理想。这只是一个可能的例子,说明了我们如何实现这个模块所需的功能。 ## 扩展客户实体 有了前面的`security.yml`添加,我们现在准备开始实际实现注册流程。首先,我们编辑`src/Foggyline/CustomerBundle/Entity/`目录中的`Customer`实体,使其实现`Symfony\Component\Security\Core\User\UserInterface`、`\Serializable`。这意味着需要实现以下方法: ```php public function getSalt() { return null; } public function getRoles() { return array('ROLE_USER'); } public function eraseCredentials() { } public function serialize() { return serialize(array( $this->id, $this->username, $this->password )); } public function unserialize($serialized) { list ( $this->id, $this->username, $this->password, ) = unserialize($serialized); } ``` 尽管所有密码都需要使用盐进行哈希处理,但在这种情况下`getSalt`函数是无关紧要的,因为`bcrypt`在内部已经处理了这个问题。`getRoles`函数是重要的部分。我们可以返回一个或多个个体客户将拥有的角色。为了简化,我们将为每个客户分配一个`ROLE_USER`角色。但是这可以很容易地更加健壮,以便将角色存储在数据库中。`eraseCredentials`函数只是一个清理方法,我们将其留空。 由于用户对象首先被反序列化、序列化并保存到每个请求的会话中,我们实现了`\Serializable`接口。序列化和反序列化的实际实现可以只包括一小部分客户属性,因为我们不需要将所有东西都存储在会话中。 在我们继续并开始实现注册、登录、忘记密码和其他部分之前,让我们先定义我们稍后要使用的所需服务。 ## 创建订单服务 我们将创建一个`orders`服务,用于填充**我的账户**页面下可用的数据。稍后,其他模块可以覆盖此服务并注入真实的客户订单。要定义一个`orders`服务,我们通过在`src/Foggyline/CustomerBundle/Resources/config/services.xml`文件中在`services`元素下添加以下内容来进行编辑: ```php ``` 然后,我们继续创建`src/Foggyline/CustomerBundle/Service/CustomerOrders.php`目录,内容如下: ```php namespace Foggyline\CustomerBundle\Service; class CustomerOrders { public function getOrders() { return array( array( 'id' => '0000000001', 'date' => '23/06/2016 18:45', 'ship_to' => 'John Doe', 'order_total' => 49.99, 'status' => 'Processing', 'actions' => array( array( 'label' => 'Cancel', 'path' => '#' ), array( 'label' => 'Print', 'path' => '#' ) ) ), ); } } ``` `getOrders`方法在这里只是返回一些虚拟数据。我们可以很容易地使其返回一个空数组。理想情况下,我们希望它返回符合某些特定接口的某些类型元素的集合。 ## 创建客户菜单服务 在上一个模块中,我们定义了一个填充客户菜单的`customer`服务,并填充了一些虚拟数据。现在我们将创建一个覆盖服务,根据客户登录状态填充菜单的实际客户数据。要定义一个`customer menu`服务,我们通过在`src/Foggyline/CustomerBundle/Resources/config/services.xml`文件中在`services`元素下添加以下内容来进行编辑: ```php ``` 在这里,我们将`token_storage`和`router`对象注入到我们的服务中,因为我们需要它们根据客户的登录状态构建菜单。 然后,我们继续创建`src/Foggyline/CustomerBundle/Service/Menu/CustomerMenu.php`目录,内容如下: ```php namespace Foggyline\CustomerBundle\Service\Menu; class CustomerMenu { private $token; private $router; public function __construct( $tokenStorage, \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->token = $tokenStorage->getToken(); $this->router = $router; } public function getItems() { $items = array(); $user = $this->token->getUser(); if ($user instanceof \Foggyline\CustomerBundle\Entity\Customer) { // customer authentication $items[] = array( 'path' => $this->router->generate('customer_account'), 'label' => $user->getFirstName() . ' ' . $user->getLastName(), ); $items[] = array( 'path' => $this->router->generate('customer_logout'), 'label' => 'Logout', ); } else { $items[] = array( 'path' => $this->router->generate('foggyline_customer_login'), 'label' => 'Login', ); $items[] = array( 'path' => $this->router->generate('foggyline_customer_register'), 'label' => 'Register', ); } return $items; } } ``` 在这里,我们看到一个基于用户登录状态构建菜单。这样,客户在登录时可以看到**注销**链接,未登录时可以看到**登录**链接。 然后,我们添加`src/Foggyline/CustomerBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php`目录,内容如下: ```php namespace Foggyline\CustomerBundle\DependencyInjection\Compiler; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; class OverrideServiceCompilerPass implements CompilerPassInterface { public function process(ContainerBuilder $container) { // Override the core module 'onsale' service $container->removeDefinition('customer_menu'); $container->setDefinition('customer_menu', $container->getDefinition('foggyline_customer.customer_menu')); } } ``` 在这里,我们正在实际进行`customer_menu`服务覆盖。但是,这不会生效,直到我们通过添加以下内容来编辑`src/Foggyline/CustomerBundle/FoggylineCustomerBundle.php`目录的`build`方法: ```php namespace Foggyline\CustomerBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Foggyline\CustomerBundle\DependencyInjection\Compiler\OverrideServiceCompilerPass; class FoggylineCustomerBundle extends Bundle { public function build(ContainerBuilder $container) { parent::build($container);; $container->addCompilerPass(new OverrideServiceCompilerPass()); } } ``` `addCompilerPass`方法调用接受我们的`OverrideServiceCompilerPass`实例,确保我们的服务覆盖将生效。 ## 实现注册流程 要实现注册页面,我们首先修改`src/Foggyline/CustomerBundle/Controller/CustomerController.php`文件如下: ```php /** * @Route("/register", name="foggyline_customer_register") */ public function registerAction(Request $request) { // 1) build the form $user = new Customer(); $form = $this->createForm(CustomerType::class, $user); // 2) handle the submit (will only happen on POST) $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // 3) Encode the password (you could also do this via Doctrine listener) $password = $this->get('security.password_encoder') ->encodePassword($user, $user->getPlainPassword()); $user->setPassword($password); // 4) save the User! $em = $this->getDoctrine()->getManager(); $em->persist($user); $em->flush(); // ... do any other work - like sending them an email, etc // maybe set a "flash" success message for the user return $this->redirectToRoute('customer_account'); } return $this->render( 'FoggylineCustomerBundle:default:customer/register.html.twig', array('form' => $form->createView()) ); } ``` 注册页面使用标准的自动生成的客户 CRUD 表单,只需将其指向`src/Foggyline/CustomerBundle/Resources/views/Default/customer/register.html.twig`模板文件,内容如下: ```php {% extends 'base.html.twig' %} {% block body %} {{ form_start(form) }} {{ form_widget(form) }} {{ form_end(form) }} {% endblock %} ``` 一旦这两个文件就位,我们的注册功能应该就能正常工作了。 ## 实现登录流程 我们将在独立的`/customer/login` URL 上实现登录页面,因此我们通过添加以下`loginAction`函数来编辑`CustomerController.php`文件: ```php /** * Creates a new Customer entity. * * @Route("/login", name="foggyline_customer_login") */ public function loginAction(Request $request) { $authenticationUtils = $this->get('security.authentication_utils'); // get the login error if there is one $error = $authenticationUtils->getLastAuthenticationError(); // last username entered by the user $lastUsername = $authenticationUtils->getLastUsername(); return $this->render( 'FoggylineCustomerBundle:default:customer/login.html.twig', array( // last username entered by the user 'last_username' => $lastUsername, 'error' => $error, ) ); } ``` 在这里,我们只是检查用户是否已经尝试登录,如果是,我们将将该信息传递给模板,以及潜在的错误。然后我们编辑`src/Foggyline/CustomerBundle/Resources/views/Default/customer/login.html.twig`文件,内容如下: ```php {% extends 'base.html.twig' %} {% block body %} {% if error %}
{{ error.messageKey|trans(error.messageData, 'security') }}
{% endif %}
{% endblock %} ``` 一旦登录,用户将被重定向到`/customer/account`页面。我们通过在`CustomerController.php`文件中添加`accountAction`方法来创建此页面,如下所示: ```php /** * Finds and displays a Customer entity. * * @Route("/account", name="customer_account") * @Method({"GET", "POST"}) */ public function accountAction(Request $request) { if (!$this->get('security.authorization_checker')->isGranted('ROLE_USER')) { throw $this->createAccessDeniedException(); } if ($customer = $this->getUser()) { $editForm = $this->createForm('Foggyline\CustomerBundle\Form\CustomerType', $customer, array( 'action' => $this->generateUrl('customer_account'))); $editForm->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { $em = $this->getDoctrine()->getManager(); $em->persist($customer); $em->flush(); $this->addFlash('success', 'Account updated.'); return $this->redirectToRoute('customer_account'); } return $this->render('FoggylineCustomerBundle:default:customer/account.html.twig', array( 'customer' => $customer, 'form' => $editForm->createView(), 'customer_orders' => $this->get('foggyline_customer.customer_orders')->getOrders() )); } else { $this->addFlash('notice', 'Only logged in customers can access account page.'); return $this->redirectToRoute('foggyline_customer_login'); } } ``` 使用`$this->getUser()`我们正在检查已登录用户是否已设置,如果是,则将其信息传递给模板。然后我们编辑`src/Foggyline/CustomerBundle/Resources/views/Default/customer/account.html.twig`文件,内容如下: ```php {% extends 'base.html.twig' %} {% block body %}

My Account

{{ form_start(form) }}
{{ form_row(form.email) }} {{ form_row(form.username) }} {{ form_row(form.plainPassword.first) }} {{ form_row(form.plainPassword.second) }} {{ form_row(form.firstName) }} {{ form_row(form.lastName) }} {{ form_row(form.company) }} {{ form_row(form.phoneNumber) }}
{{ form_row(form.country) }} {{ form_row(form.state) }} {{ form_row(form.city) }} {{ form_row(form.postcode) }} {{ form_row(form.street) }}
{{ form_end(form) }} {% endblock %} ``` 通过这样做,我们解决了**我的账户**页面的实际客户信息部分。在当前状态下,该页面应该呈现一个编辑表单,如下截图所示,使我们能够编辑所有客户信息: ![实现登录过程](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_08_05.jpg) 然后,我们通过以下方式替换``: ```php {% block customer_orders %}

My Orders

{% for order in customer_orders %} {% endfor %} /tbody>
Order Id Date Ship To Order Total Status Actions
{{ order.id }} {{ order.date }} {{ order.ship_to }} {{ order.order_total }} {{ order.status }}
{% for action in order.actions %} {{ action.label }} {% endfor %}
{% endblock %} ``` 现在应该呈现**My Account**页面的**My Orders**部分,如下所示: 实现登录流程 这只是来自`src/Foggyline/CustomerBundle/Resources/config/services.xml`中定义的服务的虚拟数据。在后面的章节中,当我们到达销售模块时,我们将确保它覆盖`foggyline_customer.customer_orders`服务,以便在这里插入真实的客户数据。 ## 实现注销流程 在定义防火墙时,我们对`security.yml`所做的更改之一是配置注销路径,我们将其指向`/customer/logout`。该路径的实现在`CustomerController.php`文件中如下: ```php /** * @Route("/logout", name="customer_logout") */ public function logoutAction() { } ``` 注意,`logoutAction`方法实际上是空的。没有实际的实现。不需要实现,因为 Symfony 拦截请求并为我们处理注销。但是,我们需要定义这个路由,因为我们在`system.xml`文件中引用了它。 ## 管理忘记密码 忘记密码功能将作为一个单独的页面实现。我们通过向`CustomerController.php`文件添加`forgottenPasswordAction`函数来编辑它,如下所示: ```php /** * @Route("/forgotten_password", name="customer_forgotten_password") * @Method({"GET", "POST"}) */ public function forgottenPasswordAction(Request $request) { // Build a form, with validation rules in place $form = $this->createFormBuilder() ->add('email', EmailType::class, array( 'constraints' => new Email() )) ->add('save', SubmitType::class, array( 'label' => 'Reset!', 'attr' => array('class' => 'button'), )) ->getForm(); // Check if this is a POST type request and if so, handle form if ($request->isMethod('POST')) { $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->addFlash('success', 'Please check your email for reset password.'); // todo: Send an email out to website admin or something... return $this->redirect($this->generateUrl('foggyline_customer_login')); } } // Render "contact us" page return $this->render('FoggylineCustomerBundle:default:customer/forgotten_password.html.twig', array( 'form' => $form->createView() )); } ``` 在这里,我们仅检查 HTTP 请求是 GET 还是 POST,然后发送电子邮件或加载模板。为了简单起见,我们实际上没有实现实际的电子邮件发送。这是需要在本书之外解决的问题。渲染的模板指向`src/Foggyline/CustomerBundle/Resources/views/Default/customer/forgotten_password.html.twig`文件,内容如下: ```php {% extends 'base.html.twig' %} {% block body %}

Forgotten Password

{{ form_start(form) }} {{ form_widget(form) }} {{ form_end(form) }}
{% endblock %} ``` # 单元测试 除了自动生成的`Customer`实体及其 CRUD 控制器之外,我们创建了两个自定义服务类作为这个模块的一部分。由于我们不追求完整的代码覆盖率,我们将仅在单元测试中涵盖`CustomerOrders`和`CustomerMenu`服务类。 我们首先在`phpunit.xml.dist`文件的`testsuites`元素下添加以下行: ```php src/Foggyline/CustomerBundle/Tests ``` 有了这个,从我们商店的根目录运行`phpunit`命令应该能够捕捉到我们在`src/Foggyline/CustomerBundle/Tests/`目录下定义的任何测试。 现在让我们继续为我们的`CustomerOrders`服务创建一个测试。我们通过创建一个`src/Foggyline/CustomerBundle/Tests/Service/CustomerOrders.php`文件来实现: ```php namespace Foggyline\CustomerBundle\Tests\Service; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class CustomerOrders extends KernelTestCase { private $container; public function setUp() { static::bootKernel(); $this->container = static::$kernel->getContainer(); } public function testGetItemsViaService() { $orders = $this->container->get('foggyline_customer.customer_orders'); $this->assertNotEmpty($orders->getOrders()); } public function testGetItemsViaClass() { $orders = new \Foggyline\CustomerBundle\Service\CustomerOrders(); $this->assertNotEmpty($orders->getOrders()); } } ``` 这里我们总共有两个测试,一个是通过服务实例化类,另一个是直接实例化。我们仅使用`setUp`方法来设置`container`属性,然后在`testGetItemsViaService`方法中重用它。 接下来,我们在目录中创建`CustomerMenu`测试如下: ```php namespace Foggyline\CustomerBundle\Tests\Service\Menu; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class CustomerMenu extends KernelTestCase { private $container; private $tokenStorage; private $router; public function setUp() { static::bootKernel(); $this->container = static::$kernel->getContainer(); $this->tokenStorage = $this->container->get('security.token_storage'); $this->router = $this->container->get('router'); } public function testGetItemsViaService() { $menu = $this->container->get('foggyline_customer.customer_menu'); $this->assertNotEmpty($menu->getItems()); } public function testGetItemsViaClass() { $menu = new \Foggyline\CustomerBundle\Service\Menu\CustomerMenu( $this->tokenStorage, $this->router ); $this->assertNotEmpty($menu->getItems()); } } ``` 现在,如果我们运行`phpunit`命令,我们应该能够看到我们的测试被捕捉并与其他测试一起执行。我们甚至可以通过执行带有完整类路径的`phpunit`命令来专门针对这两个测试,如下所示: ```php **phpunit src/Foggyline/CustomerBundle/Tests/Service/CustomerOrders.php** **phpunit src/Foggyline/CustomerBundle/Tests/Service/Menu/CustomerMenu.php** ``` # 功能测试 自动生成的 CRUD 工具在`src/Foggyline/CustomerBundle/Tests/Controller/`目录中为我们生成了`CustomerControllerTest.php`文件。在上一章中,我们展示了如何向`static::createClient`传递身份验证参数,以便模拟用户登录。然而,这不同于我们的客户将使用的登录。我们不再使用基本的 HTTP 身份验证,而是一个完整的登录表单。 为了解决登录表单测试问题,让我们继续编辑`src/Foggyline/CustomerBundle/Tests/Controller/CustomerControllerTest.php`文件如下: ```php namespace Foggyline\CustomerBundle\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; class CustomerControllerTest extends WebTestCase { private $client = null; public function setUp() { $this->client = static::createClient(); } public function testMyAccountAccess() { $this->logIn(); $crawler = $this->client->request('GET', '/customer/account'); $this->assertTrue($this->client->getResponse()-> isSuccessful()); $this->assertGreaterThan(0, $crawler->filter('html:contains("My Account")')->count()); } private function logIn() { $session = $this->client->getContainer()->get('session'); $firewall = 'foggyline_customer'; // firewall name $em = $this->client->getContainer()->get('doctrine')->getManager(); $user = $em->getRepository('FoggylineCustomerBundle:Customer')->findOneByUsername('john@test.loc'); $token = new UsernamePasswordToken($user, null, $firewall, array('ROLE_USER')); $session->set('_security_' . $firewall, serialize($token)); $session->save(); $cookie = new Cookie($session->getName(), $session->getId()); $this->client->getCookieJar()->set($cookie); } } ``` 在这里,我们首先创建了`logIn`方法,其目的是通过将正确的令牌值设置到会话中,并通过 cookie 将该会话 ID 传递给客户端来模拟登录。然后我们创建了`testMyAccountAccess`方法,该方法首先调用`logIn`方法,然后检查爬虫是否能够访问“我的账户”页面。这种方法的好处在于,我们不必编写用户密码,只需编写用户名。 现在,让我们继续处理客户注册表单,通过向`CustomerControllerTest`添加以下内容: ```php public function testRegisterForm() { $crawler = $this->client->request('GET', '/customer/register'); $uniqid = uniqid(); $form = $crawler->selectButton('Register!')->form(array( 'customer[email]' => 'john_' . $uniqid . '@test.loc', 'customer[username]' => 'john_' . $uniqid, 'customer[plainPassword][first]' => 'pass123', 'customer[plainPassword][second]' => 'pass123', 'customer[firstName]' => 'John', 'customer[lastName]' => 'Doe', 'customer[company]' => 'Foggyline', 'customer[phoneNumber]' => '00 385 111 222 333', 'customer[country]' => 'HR', 'customer[state]' => 'Osijek', 'customer[city]' => 'Osijek', 'customer[postcode]' => '31000', 'customer[street]' => 'The Yellow Street', )); $this->client->submit($form); $crawler = $this->client->followRedirect(); //var_dump($this->client->getResponse()->getContent()); $this->assertGreaterThan(0, $crawler->filter('html:contains("customer/login")')->count()); } ``` 在上一章中,我们已经看到了类似于这个的测试。在这里,我们只是打开了一个客户/注册页面,然后找到一个带有“注册!”标签的按钮,以便我们可以通过它获取整个表单。然后我们设置所有必需的表单数据,并模拟表单提交。如果成功,我们观察重定向主体,并断言其中的预期值。 现在运行`phpunit`命令应该成功执行我们的测试。 # 总结 在本章中,我们构建了一个微型但功能齐全的客户模块。该模块假定我们在`security.yml`文件上进行了一定程度的设置,如果我们要重新分发它,可以将其作为模块文档的一部分进行覆盖。这些更改包括定义我们自己的自定义防火墙和自定义安全提供程序。安全提供程序指向我们的`customer`类,而该类又是按照 Symfony`UserInterface`构建的。然后我们构建了注册、登录和忘记密码表单。尽管每个表单都带有一组最小的功能,但我们看到构建完全自定义的注册和登录系统是多么简单。 此外,我们通过使用专门定义的服务在“我的账户”页面下设置“我的订单”部分,采取了一些前瞻性的做法。这绝对是理想的做法,它有其作用,因为我们稍后将从“销售”模块中清晰地覆盖此服务。 在接下来的章节中,我们将构建一个“支付”模块。 # 第九章:构建支付模块 支付模块为我们的网店提供了进一步销售功能的基础。当我们到达即将推出的销售模块的结账流程时,它将使我们能够实际选择支付方式。支付方式通常可以是各种类型。有些可以是静态的,如支票和货到付款,而其他一些可以是常规信用卡,如 Visa、MasterCard、American Express、Discover 和 Switch/Solo。在本章中,我们将讨论这两种类型。 在本章中,我们将研究以下主题: + 要求 + 依赖 + 实施 + 单元测试 + 功能测试 # 要求 我们的应用要求在第四章下定义,*模块化网店应用的需求规范*,实际上并没有说明我们需要实现的支付方式类型。因此,在本章中,我们将开发两种支付方式:卡支付和支票支付。关于信用卡支付,我们不会连接到真实的支付处理器,但其他所有操作都将按照与信用卡一起工作的方式进行。 理想情况下,我们希望通过接口完成以下操作: ```php namespace Foggyline\SalesBundle\Interface; interface Payment { function authorize(); function capture(); function cancel(); } ``` 这将需要`SalesBundle`模块,但我们还没有开发。因此,我们将使用一个简单的 Symfony`controller`类来进行支付方法,该类提供了自己的方式来处理以下功能: + 函数`authorize();` + 函数`capture();` + 函数`cancel();` `authorize`方法用于仅授权交易而不实际执行交易的情况。结果是一个交易 ID,我们未来的`SalesBundle`模块可以存储并重复使用以进行进一步的`capture`和`cancel`操作。`capture`方法首先执行授权操作,然后捕获资金。`cancel`方法基于先前存储的授权令牌执行取消操作。 我们将通过标记的 Symfony 服务公开我们的支付方式。服务的标记是一个很好的功能,它使我们能够查看容器和所有标记为相同标记的服务,这是我们可以用来获取所有`paymentmethod`服务的东西。标记命名必须遵循一定的模式,这是我们作为应用程序创建者所强加给自己的。考虑到这一点,我们将使用`name`,`payment_method`标记每个支付服务。 稍后,`SalesBundle`模块将获取并使用所有标记为`payment_method`的服务,然后在内部使用它们生成可用支付方式的列表。 # 依赖 该模块不依赖于任何其他模块。但是,首先构建`SalesBundle`模块,然后公开一些`payment`模块可能使用的接口可能更方便。 # 实施 我们首先创建一个名为`Foggyline\PaymentBundle`的新模块。我们通过运行以下命令来完成这个操作: ```php **php bin/console generate:bundle --namespace=Foggyline/PaymentBundle** ``` 该命令触发一个交互式过程,沿途询问我们几个问题,如下所示: ![实施](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_09_01.jpg) 完成后,文件`app/AppKernel.php`和`app/config/routing.yml`将自动修改。`AppKernel`类的`registerBundles`方法已添加到`$bundles`数组下的以下行: ```php new Foggyline\PaymentBundle\FoggylinePaymentBundle(), ``` `routing.yml`已更新为以下条目: ```php foggyline_payment: resource: "@FoggylinePaymentBundle/Resources/config/routing.xml" prefix: / ``` 为了避免与核心应用程序代码冲突,我们需要将`prefix: /`更改为`prefix: /payment/`。 ## 创建卡实体 尽管在本章中我们不会在数据库中存储任何信用卡,但我们希望重用 Symfony 自动生成的 CRUD 功能,以便为我们提供信用卡模型和表单。让我们继续创建一个`Card`实体。我们将使用控制台来实现,如下所示: ```php php bin/console generate:doctrine:entity ``` 该命令触发交互式生成器,为实体快捷方式提供`FoggylinePaymentBundle:Card`,我们还需要提供实体属性。我们想要用以下字段对`Card`实体建模: + `card_type`: string + `card_number`: string + `expiry_date`: date + `security_code`: string 完成后,生成器将在`src/Foggyline/PaymentBundle/`目录中创建`Entity/Card.php`和`Repository/CardRepository.php`。我们现在可以更新数据库,以便引入`Card`实体,如下所示: ```php php bin/console doctrine:schema:update --force ``` 有了实体,我们准备生成其 CRUD。我们将使用以下命令来实现: ```php php bin/console generate:doctrine:crud ``` 这将导致创建`src/Foggyline/PaymentBundle/Controller/CardController.php`文件。它还会向我们的`app/config/routing.yml`文件添加一个条目,如下所示: ```php foggyline_payment_card: resource: "@FoggylinePaymentBundle/Controller/CardController.php" type: annotation ``` 同样,视图文件是在`app/Resources/views/card/`目录下创建的。由于我们实际上不会围绕卡片执行任何与 CRUD 相关的操作,因此我们可以继续删除所有生成的视图文件,以及`CardController`类的整个主体。此时,我们应该有`Card`实体,`CardType`表单和空的`CardController`类。 ### 创建卡支付服务 卡支付服务将为我们未来的销售模块提供其结账流程所需的相关信息。它的作用是提供订单的支付方法标签、代码和处理 URL,如`authorize`、`capture`和`cancel`。 我们将首先在`src/Foggyline/PaymentBundle/Resources/config/services.xml`文件的 services 元素下定义以下服务: ```php ``` 该服务接受两个参数:一个是`form.factory`,另一个是`router`。`form.factory`将在服务内部用于为`CardType`表单创建表单视图。标签在这里是一个关键元素,因为我们的`SalesBundle`模块将根据分配给服务的`payment_method`标签来寻找支付方法。 现在我们需要在`src/Foggyline/PaymentBundle/Service/CardPayment.php`文件中创建实际的服务类,如下所示: ```php namespace Foggyline\PaymentBundle\Service; use Foggyline\PaymentBundle\Entity\Card; class CardPayment { private $formFactory; private $router; public function __construct( $formFactory, \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->formFactory = $formFactory; $this->router = $router; } public function getInfo() { $card = new Card(); $form = $this->formFactory->create('Foggyline\PaymentBundle\Form\CardType', $card); return array( 'payment' => array( 'title' =>'Foggyline Card Payment', 'code' =>'card_payment', 'url_authorize' => $this->router->generate('foggyline_payment_card_authorize'), 'url_capture' => $this->router->generate('foggyline_payment_card_capture'), 'url_cancel' => $this->router->generate('foggyline_payment_card_cancel'), 'form' => $form->createView() ) ); } } ``` `getInfo`方法将为我们未来的`SalesBundle`模块提供必要的信息,以便它构建结账流程的支付步骤。我们在这里传递了三种不同类型的 URL:`authorize`,`capture`和`cancel`。这些路由目前还不存在,我们将很快创建它们。我们的想法是将支付操作和流程转移到实际的`payment`方法。我们未来的`SalesBundle`模块只会对这些支付 URL 进行**AJAX POST**,并期望获得成功或错误的 JSON 响应。成功的响应应该产生某种交易 ID,错误的响应应该产生一个标签消息显示给用户。 ## 创建卡支付控制器和路由 我们将通过向`src/Foggyline/PaymentBundle/Resources/config/routing.xml`文件添加以下路由定义来编辑它: ```php FoggylinePaymentBundle:Card:authorize FoggylinePaymentBundle:Card:capture FoggylinePaymentBundle:Card:cancel ``` 然后,我们将通过添加以下内容来编辑`CardController`类的主体: ```php public function authorizeAction(Request $request) { $transaction = md5(time() . uniqid()); // Just a dummy string, simulating some transaction id, if any if ($transaction) { return new JsonResponse(array( 'success' => $transaction )); } return new JsonResponse(array( 'error' =>'Error occurred while processing Card payment.' )); } public function captureAction(Request $request) { $transaction = md5(time() . uniqid()); // Just a dummy string, simulating some transaction id, if any if ($transaction) { return new JsonResponse(array( 'success' => $transaction )); } return new JsonResponse(array( 'error' =>'Error occurred while processing Card payment.' )); } public function cancelAction(Request $request) { $transaction = md5(time() . uniqid()); // Just a dummy string, simulating some transaction id, if any if ($transaction) { return new JsonResponse(array( 'success' => $transaction )); } return new JsonResponse(array( 'error' =>'Error occurred while processing Card payment.' )); } ``` 现在,我们应该能够访问像`/app_dev.php/payment/card/authorize`这样的 URL,并看到`authorizeAction`的输出。这里给出的实现是虚拟的。在本章中,我们不打算连接到真实的支付处理 API。对我们来说重要的是,`sales`模块在结账过程中,会通过`payment_method`标记的服务的`getInfo`方法中的`['payment']['form']`键来渲染任何可能的表单视图。这意味着结账过程应该在信用卡付款下显示一个信用卡表单。结账的行为将被编码,以便如果选择了带有表单的付款,并且点击了**下订单**按钮,那么付款表单将阻止结账过程继续进行,直到付款表单被提交到支付本身定义的授权或捕获 URL。当我们到达`SalesBundle`模块时,我们将更详细地讨论这一点。 ## 创建支票付款服务 除了信用卡付款方式,让我们继续定义另一种静态付款,称为**支票**。 我们将从`src/Foggyline/PaymentBundle/Resources/config/services.xml`文件的 services 元素下定义以下服务开始: ```php ``` 这里定义的`service`只接受一个`router`参数。标签名称与信用卡付款服务相同。 然后,我们将创建`src/Foggyline/PaymentBundle/Service/CheckMoneyPayment.php`文件,内容如下: ```php namespace Foggyline\PaymentBundle\Service; class CheckMoneyPayment { private $router; public function __construct( \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->router = $router; } public function getInfo() { return array( 'payment' => array( 'title' =>'Foggyline Check Money Payment', 'code' =>'check_money', 'url_authorize' => $this->router->generate('foggyline_payment_check_money_authorize'), 'url_capture' => $this->router->generate('foggyline_payment_check_money_capture'), 'url_cancel' => $this->router->generate('foggyline_payment_check_money_cancel'), //'form' =>'' ) ); } } ``` 与信用卡付款不同,支票付款在`getInfo`方法下没有定义表单键。这是因为没有信用卡条目需要定义。它只是一个静态付款方式。但是,我们仍然需要定义`authorize`、`capture`和`cancel`的 URL,即使它们的实现可能只是一个简单的 JSON 响应,带有成功或错误键。 ## 创建支票付款控制器和路由 一旦支票付款服务就位,我们就可以继续为其创建必要的路由。我们将首先在`src/Foggyline/PaymentBundle/Resources/config/routing.xml`文件中添加以下路由定义: ```php FoggylinePaymentBundle:CheckMoney:authorize FoggylinePaymentBundle:CheckMoney:capture FoggylinePaymentBundle:CheckMoney:cancel ``` 然后,我们将创建`src/Foggyline/PaymentBundle/Controller/CheckMoneyController.php`文件,内容如下: ```php namespace Foggyline\PaymentBundle\Controller; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Bundle\FrameworkBundle\Controller\Controller; class CheckMoneyController extends Controller { public function authorizeAction(Request $request) { $transaction = md5(time() . uniqid()); return new JsonResponse(array( 'success' => $transaction )); } public function captureAction(Request $request) { $transaction = md5(time() . uniqid()); return new JsonResponse(array( 'success' => $transaction )); } public function cancelAction(Request $request) { $transaction = md5(time() . uniqid()); return new JsonResponse(array( 'success' => $transaction )); } } ``` 与信用卡付款类似,这里我们添加了`authorize`、`capture`和`cancel`方法的简单虚拟实现。这些方法的响应将在后面的`SalesBundle`模块中使用。我们可以很容易地从这些方法中实现更健壮的功能,但这超出了本章的范围。 # 单元测试 我们的`FoggylinePaymentBundle`模块非常简单。它只提供两种付款方式:信用卡和支票。它通过两个简单的`service`类来实现。由于我们不追求完整的代码覆盖率测试,我们将只在单元测试中覆盖`CardPayment`和`CheckMoneyPayment`服务类。 我们将首先在`phpunit.xml.dist`文件的`testsuites`元素下添加以下行: ```php src/Foggyline/PaymentBundle/Tests ``` 有了这个设置,从商店的根目录运行`phpunit`命令应该会捕捉到我们在`src/Foggyline/PaymentBundle/Tests/`目录下定义的任何测试。 现在,让我们继续为我们的`CardPayment`服务创建一个测试。我们将创建一个`src/Foggyline/PaymentBundle/Tests/Service/CardPaymentTest.php`文件,内容如下: ```php namespace Foggyline\PaymentBundle\Tests\Service; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class CardPaymentTest extends KernelTestCase { private $container; private $formFactory; private $router; public function setUp() { static::bootKernel(); $this->container = static::$kernel->getContainer(); $this->formFactory = $this->container->get('form.factory'); $this->router = $this->container->get('router'); } public function testGetInfoViaService() { $payment = $this->container->get('foggyline_payment.card_payment'); $info = $payment->getInfo(); $this->assertNotEmpty($info); $this->assertNotEmpty($info['payment']['form']); } public function testGetInfoViaClass() { $payment = new \Foggyline\PaymentBundle\Service\CardPayment( $this->formFactory, $this->router ); $info = $payment->getInfo(); $this->assertNotEmpty($info); $this->assertNotEmpty($info['payment']['form']); } } ``` 在这里,我们运行了两个简单的测试,以查看我们是否可以通过容器或直接实例化一个服务,并简单地调用它的`getInfo`方法。预期该方法将返回一个包含`['payment']['form']`键的响应。 现在,让我们继续为我们的`CheckMoneyPayment`服务创建一个测试。我们将创建一个`src/Foggyline/PaymentBundle/Tests/Service/CheckMoneyPaymentTest.php`文件,内容如下: ```php namespace Foggyline\PaymentBundle\Tests\Service; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class CheckMoneyPaymentTest extends KernelTestCase { private $container; private $router; public function setUp() { static::bootKernel(); $this->container = static::$kernel->getContainer(); $this->router = $this->container->get('router'); } public function testGetInfoViaService() { $payment = $this->container->get('foggyline_payment.check_money'); $info = $payment->getInfo(); $this->assertNotEmpty($info); } public function testGetInfoViaClass() { $payment = new \Foggyline\PaymentBundle\Service\CheckMoneyPayment( $this->router ); $info = $payment->getInfo(); $this->assertNotEmpty($info); } } ``` 同样,在这里我们也有两个简单的测试:一个通过容器获取`payment`方法,另一个直接通过一个类获取。不同之处在于我们没有检查`getInfo`方法响应中是否存在表单键。 # 功能测试 我们的模块有两个控制器类,我们希望测试它们的响应。我们要确保`CardController`和`CheckMoneyController`类的`authorize`、`capture`和`cancel`方法是有效的。 我们首先创建了一个`src/Foggyline/PaymentBundle/Tests/Controller/CardControllerTest.php`文件,内容如下: ```php namespace Foggyline\PaymentBundle\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class CardControllerTest extends WebTestCase { private $client; private $router; public function setUp() { $this->client = static::createClient(); $this->router = $this->client->getContainer()->get('router'); } public function testAuthorizeAction() { $this->client->request('GET', $this->router->generate('foggyline_payment_card_authorize')); $this->assertTests(); } public function testCaptureAction() { $this->client->request('GET', $this->router->generate('foggyline_payment_card_capture')); $this->assertTests(); } public function testCancelAction() { $this->client->request('GET', $this->router->generate('foggyline_payment_card_cancel')); $this->assertTests(); } private function assertTests() { $this->assertSame(200, $this->client->getResponse()->getStatusCode()); $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); $this->assertContains('success', $this->client->getResponse()->getContent()); $this->assertNotEmpty($this->client->getResponse()->getContent()); } } ``` 然后我们创建了`src/Foggyline/PaymentBundle/Tests/Controller/CheckMoneyControllerTest.php`,内容如下: ```php namespace Foggyline\PaymentBundle\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class CheckMoneyControllerTest extends WebTestCase { private $client; private $router; public function setUp() { $this->client = static::createClient(); $this->router = $this->client->getContainer()->get('router'); } public function testAuthorizeAction() { $this->client->request('GET', $this->router->generate('foggyline_payment_check_money_authorize')); $this->assertTests(); } public function testCaptureAction() { $this->client->request('GET', $this->router->generate('foggyline_payment_check_money_capture')); $this->assertTests(); } public function testCancelAction() { $this->client->request('GET', $this->router->generate('foggyline_payment_check_money_cancel')); $this->assertTests(); } private function assertTests() { $this->assertSame(200, $this->client->getResponse()->getStatusCode()); $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); $this->assertContains('success', $this->client->getResponse()->getContent()); $this->assertNotEmpty($this->client->getResponse()->getContent()); } } ``` 这两个测试几乎是相同的。它们包含了对`authorize`、`capture`和`cancel`方法的测试。由于我们的方法是使用固定的成功 JSON 响应实现的,所以这里没有什么意外。然而,我们可以通过将我们的付款方法扩展为更强大的东西来轻松地进行调试。 # 总结 在本章中,我们构建了一个具有两种付款方法的付款模块。信用卡付款方法是为了模拟涉及信用卡的付款。因此,它包括一个表单作为其`getInfo`方法的一部分。另一方面,支票付款是模拟一个静态的付款方法 - 不包括任何形式的信用卡。这两种方法都是作为虚拟方法实现的,这意味着它们实际上并没有与任何外部付款处理器进行通信。 我们的想法是创建一个最小的结构,展示如何开发一个简单的付款模块以进行进一步的定制。我们通过将每种付款方法公开为一个标记服务来实现这一点。使用`payment_method`标记是一种共识,因为我们是构建完整应用程序的人,所以我们可以选择如何在`sales`模块中实现这一点。通过为每种付款方法使用相同的标记名称,我们有效地为未来的`sales`模块创建了条件,以便选择所有的付款方法并在其结账流程下呈现它们。 在接下来的章节中,我们将构建一个**shipment**模块。 # 第十章:构建运输模块 运输模块,以及“支付”模块,为我们的网店提供了进一步的销售功能的基础。当我们到达即将到来的“销售”模块的结账过程时,它将使我们能够选择运输方式。与“支付”类似,`shipment`可能是静态的和动态的。静态可能意味着固定的定价值,甚至是通过一些简单条件计算出来的值,动态通常意味着与外部 API 服务的连接。 在本章中,我们将接触到两种类型,并看看如何为实施`shipment`模块设置基本结构。 在本章中,我们将涵盖`shipment`模块的以下主题: + 要求 + 依赖关系 + 实施 + 单元测试 + 功能测试 # 要求 应用要求,在第四章中定义,*模块化网店应用的需求规范*,并没有给出我们需要实施的运输方式的具体信息。因此,在本章中,我们将开发两种运输方式:动态费率运输和固定费率运输。动态费率运输用作将运输方式连接到真实运输处理器(如 UPS、FedEx 等)的方式。但是,它实际上不会连接到任何外部 API。 理想情况下,我们希望通过类似以下的接口来实现: ```php namespace Foggyline\SalesBundle\Interface; interface Shipment { function getInfo($street, $city, $country, $postcode, $amount, $qty); function process($street, $city, $country, $postcode, $amount, $qty); } ``` 然后,`getInfo`方法可以用于获取给定订单信息的可用交付选项,而处理方法将处理所选的交付选项。例如,我们可能会有一个 API 返回“当天送货($9.99)”和“标准送货($4.99)”作为动态费率运输方式的交付选项。 拥有这样一个运输接口将要求拥有我们尚未开发的`SalesBundle`模块。因此,我们将继续使用 Symfony 控制器处理过程方法和处理`getInfo`方法的服务来处理我们的运输方式。 与上一章中的支付方式一样,我们将通过标记的 Symfony 服务公开我们的`getInfo`方法。我们将用于运输方式的标记是`shipment_method`。在结账过程中,`SalesBundle`模块将获取所有标记为`shipment_method`的服务,并在内部使用它们来获取可用的运输方式列表。 # 依赖关系 我们正在以另一种方式构建模块。也就是说,我们在了解`SalesBundle`模块的任何信息之前就构建它,这是唯一会使用它的模块。考虑到这一点,`shipment`模块不依赖于任何其他模块。但是,构建`SalesBundle`模块然后公开一些`shipment`模块可能使用的接口可能更方便。 # 实施 我们将首先创建一个名为`Foggyline\ShipmentBundle`的新模块。我们将通过运行以下命令来使用控制台完成: ```php **php bin/console generate:bundle --namespace=Foggyline/ShipmentBundle** ``` 该命令触发一个交互式过程,沿途询问我们几个问题,如下所示: ![实施](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_10_01.jpg) 完成后,文件`app/AppKernel.php`和`app/config/routing.yml`将自动修改。在`$bundles`数组下添加了`AppKernel`类的`registerBundles`方法: ```php new Foggyline\PaymentBundle\FoggylineShipmentBundle(), ``` `routing.yml`文件已更新为以下条目: ```php foggyline_payment: resource: "@FoggylineShipmentBundle/Resources/config/routing.xml" prefix: / ``` 为了避免与核心应用程序代码冲突,我们需要将`prefix: /`更改为`prefix: /shipment/`。 ## 创建固定费率运输服务 定价运输服务将提供固定的运输方式,我们的“销售”模块将在结账过程中使用。它的作用是提供运输方式标签、代码、交付选项和处理 URL。 我们将首先在`src/Foggyline/ShipmentBundle/Resources/config/services.xml`文件的`services`元素下定义以下服务: ```php ``` 这个`service`只接受一个参数:`router`。`tagname`的值设置为`shipment_method`,因为我们的`SalesBundle`模块将根据分配给服务的`shipment_method`标签来寻找运输方法。 我们现在将在`src/Foggyline/ShipmentBundle/Service/FlatRateShipment.php`文件中创建实际的`service`类,如下所示: ```php namespace Foggyline\ShipmentBundle\Service; class FlatRateShipment { private $router; public function __construct( \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->router = $router; } public function getInfo($street, $city, $country, $postcode, $amount, $qty) { return array( 'shipment' => array( 'title' =>'Foggyline FlatRate Shipment', 'code' =>'flat_rate', 'delivery_options' => array( 'title' =>'Fixed', 'code' =>'fixed', 'price' => 9.99 ), 'url_process' => $this->router->generate('foggyline_shipment_flat_rate_process'), ) ; } } ``` `getInfo`方法将为我们未来的`SalesBundle`模块提供必要的信息,以便它构建结账过程的`shipment`步骤。它接受一系列参数:`$street`、`$city`、`$country`、`$postcode`、`$amount`和`$qty`。我们可以将这些视为统一的运输接口的一部分。在这种情况下,`delivery_options`返回一个固定值。`url_process`是我们将插入所选运输方法的 URL。我们未来的`SalesBundle`模块将仅仅对这个 URL 进行一个 AJAX POST,期望得到一个成功或错误的 JSON 响应,这与我们想象中使用支付方法的方式非常相似。 ## 创建固定费率运输控制器和路由 我们通过向`src/Foggyline/ShipmentBundle/Resources/config/routing.xml`文件添加以下路由定义来编辑它: ```php FoggylineShipmentBundle:FlatRate:process ``` 然后我们创建一个`src/Foggyline/ShipmentBundle/Controller/FlatRateController.php`文件,内容如下: ```php namespace Foggyline\ShipmentBundle\Controller; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Bundle\FrameworkBundle\Controller\Controller; class FlatRateController extends Controller { public function processAction(Request $request) { // Simulating some transaction id, if any $transaction = md5(time() . uniqid()); return new JsonResponse(array( 'success' => $transaction )); } } ``` 我们现在应该能够访问一个 URL,比如`/app_dev.php/shipment/flat_rate/process`,并看到`processAction`的输出。这里给出的实现是虚拟的。对我们来说重要的是,`sales`模块将在其结账过程中,通过`shipment_method`标记的服务的`getInfo`方法推送任何可能的`delivery_options`。这意味着结账过程应该显示固定费用运输作为一个选项。结账的行为将被编码,如果没有选择`shipment`方法,它将阻止结账过程继续进行。当我们到达`SalesBundle`模块时,我们将更详细地讨论这一点。 ## 创建动态费率支付服务 除了固定费用运输方法,让我们继续定义另一种动态运输,称为动态费率。 我们将首先在`src/Foggyline/ShipmentBundle/Resources/config/services.xml`文件的`services`元素下定义以下服务: ```php ``` 在这里定义的`service`只接受一个`router`参数。`tag name`属性与固定费用运输服务相同。 然后我们将创建`src/Foggyline/ShipmentBundle/Service/DynamicRateShipment.php`文件,内容如下: ```php namespace Foggyline\ShipmentBundle\Service; class DynamicRateShipment { private $router; public function __construct( \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->router = $router; } public function getInfo($street, $city, $country, $postcode, $amount, $qty) { return array( 'shipment' => array( 'title' =>'Foggyline DynamicRate Shipment', 'code' =>'dynamic_rate_shipment', 'delivery_options' => $this->getDeliveryOptions($street, $city, $country, $postcode, $amount, $qty), 'url_process' => $this->router->generate('foggyline_shipment_dynamic_rate_process'), ) ); } public function getDeliveryOptions($street, $city, $country, $postcode, $amount, $qty) { // Imagine we are hitting the API with: $street, $city, $country, $postcode, $amount, $qty return array( array( 'title' =>'Same day delivery', 'code' =>'dynamic_rate_sdd', 'price' => 9.99 ), array( 'title' =>'Standard delivery', 'code' =>'dynamic_rate_sd', 'price' => 4.99 ), ); } } ``` 与固定费用运输不同,这里`getInfo`方法的`delivery_options`键是由`getDeliveryOptions`方法的响应构建的。该方法是服务内部的,不被想象为公开或作为接口的一部分。我们可以很容易地想象在其中进行一些 API 调用,以便为我们的动态运输方法获取计算费率。 ## 创建动态费率运输控制器和路由 一旦动态费率运输服务就位,我们就可以为其创建必要的路由。我们将首先在`src/Foggyline/ShipmentBundle/Resources/config/routing.xml`文件中添加以下路由定义: ```php FoggylineShipmentBundle:DynamicRate:process ``` 然后我们创建`src/Foggyline/ShipmentBundle/Controller/DynamicRateController.php`文件,内容如下: ```php namespace Foggyline\ShipmentBundle\Controller; use Foggyline\ShipmentBundle\Entity\DynamicRate; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; class DynamicRateController extends Controller { public function processAction(Request $request) { // Just a dummy string, simulating some transaction id $transaction = md5(time() . uniqid()); if ($transaction) { return new JsonResponse(array( 'success' => $transaction )); } return new JsonResponse(array( 'error' =>'Error occurred while processing DynamicRate shipment.' )); } } ``` 与固定费率运输类似,这里我们添加了一个简单的虚拟实现过程和方法。传入的`$request`应该包含与服务`getInfo`方法相同的信息,也就是说,它应该有以下参数可用:`$street`、`$city`、`$country`、`$postcode`、`$amount`和`$qty`。方法的响应将在后面的`SalesBundle`模块中使用。我们可以很容易地从这些方法中实现更健壮的功能,但这超出了本章的范围。 # 单元测试 `FoggylineShipmentBundle`模块非常简单。通过提供两个简单的服务和两个简单的控制器,很容易进行测试。 我们将首先在`phpunit.xml.dist`文件的`testsuites`元素下添加以下行: ```php src/Foggyline/ShipmentBundle/Tests ``` 有了这个文件,从商店的根目录运行`phpunit`命令应该会检测到我们在`src/Foggyline/ShipmentBundle/Tests/`目录下定义的任何测试。 现在,让我们继续为我们的`FlatRateShipment`服务创建一个测试。我们将创建一个`src/Foggyline/ShipmentBundle/Tests/Service/FlatRateShipmentTest.php`文件,内容如下: ```php namespace Foggyline\ShipmentBundle\Tests\Service; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class FlatRateShipmentTest extends KernelTestCase { private $container; private $router; private $street = 'Masonic Hill Road'; private $city = 'Little Rock'; private $country = 'US'; private $postcode = 'AR 72201'; private $amount = 199.99; private $qty = 7; public function setUp() { static::bootKernel(); $this->container = static::$kernel->getContainer(); $this->router = $this->container->get('router'); } public function testGetInfoViaService() { $shipment = $this->container->get('foggyline_shipment.flat_rate'); $info = $shipment->getInfo( $this->street, $this->city, $this->country, $this->postcode, $this->amount, $this->qty ); $this->validateGetInfoResponse($info); } public function testGetInfoViaClass() { $shipment = new \Foggyline\ShipmentBundle\Service\FlatRateShipment($this->router); $info = $shipment->getInfo( $this->street, $this->city, $this->country, $this->postcode, $this->amount, $this->qty ); $this->validateGetInfoResponse($info); } public function validateGetInfoResponse($info) { $this->assertNotEmpty($info); $this->assertNotEmpty($info['shipment']['title']); $this->assertNotEmpty($info['shipment']['code']); $this->assertNotEmpty($info['shipment']['delivery_options']); $this->assertNotEmpty($info['shipment']['url_process']); } } ``` 这里运行了两个简单的测试。一个是检查我们是否可以通过容器实例化一个服务,另一个是检查我们是否可以直接这样做。一旦实例化,我们只需调用服务的`getInfo`方法,向其传递一个虚拟地址和订单信息。虽然我们实际上并没有在`getInfo`方法中使用这些数据,但我们需要传递一些东西,否则测试将失败。该方法预期返回一个包含在 shipment 键下的几个关键字的响应,最重要的是`title`、`code`、`delivery_options`和`url_process`。 现在,让我们继续为我们的`DynamicRateShipment`服务创建一个测试。我们将创建一个`src/Foggyline/ShipmentBundle/Tests/Service/DynamicRateShipmentTest.php`文件,内容如下: ```php namespace Foggyline\ShipmentBundle\Tests\Service; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class DynamicRateShipmentTest extends KernelTestCase { private $container; private $router; private $street = 'Masonic Hill Road'; private $city = 'Little Rock'; private $country = 'US'; private $postcode = 'AR 72201'; private $amount = 199.99; private $qty = 7; public function setUp() { static::bootKernel(); $this->container = static::$kernel->getContainer(); $this->router = $this->container->get('router'); } public function testGetInfoViaService() { $shipment = $this->container->get('foggyline_shipment.dynamicrate_shipment'); $info = $shipment->getInfo( $this->street, $this->city, $this->country, $this->postcode, $this->amount, $this->qty ); $this->validateGetInfoResponse($info); } public function testGetInfoViaClass() { $shipment = new \Foggyline\ShipmentBundle\Service\DynamicRateShipment($this->router); $info = $shipment->getInfo( $this->street, $this->city, $this->country, $this->postcode, $this->amount, $this->qty ); $this->validateGetInfoResponse($info); } public function validateGetInfoResponse($info) { $this->assertNotEmpty($info); $this->assertNotEmpty($info['shipment']['title']); $this->assertNotEmpty($info['shipment']['code']); // Could happen that dynamic rate has none?! //$this->assertNotEmpty($info['shipment']['delivery_options']); $this->assertNotEmpty($info['shipment']['url_process']); } } ``` 这个测试几乎与`FlatRateShipment`服务的测试相同。在这里,我们也有两个简单的测试:一个通过容器获取支付方法,另一个通过类直接获取。不同之处在于我们不再断言`delivery_options`的存在。这是因为真实的 API 请求可能不会根据给定的地址和订单信息返回任何交付选项。 # 功能测试 我们整个模块只有两个控制器类,我们要测试它们的响应。我们要确保`FlatRateController`和`DynamicRateController`类的`process`方法是可访问且有效的。 首先,我们将创建一个`src/Foggyline/ShipmentBundle/Tests/Controller/FlatRateControllerTest.php`文件,内容如下: ```php namespace Foggyline\ShipmentBundle\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class FlatRateControllerTest extends WebTestCase { private $client; private $router; public function setUp() { $this->client = static::createClient(); $this->router = $this->client->getContainer()->get('router'); } public function testProcessAction() { $this->client->request('GET', $this->router->generate('foggyline_shipment_flat_rate_process')); $this->assertSame(200, $this->client->getResponse()->getStatusCode()); $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); $this->assertContains('success', $this->client->getResponse()->getContent()); $this->assertNotEmpty($this->client->getResponse()->getContent()); } } ``` 然后,我们将创建一个`src/Foggyline/ShipmentBundle/Tests/Controller/DynamicRateControllerTest.php`文件,内容如下: ```php namespace Foggyline\ShipmentBundle\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class DynamicRateControllerTest extends WebTestCase { private $client; private $router; public function setUp() { $this->client = static::createClient(); $this->router = $this->client->getContainer()->get('router'); } public function testProcessAction() { $this->client->request('GET', $this->router->generate('foggyline_shipment_dynamic_rate_process')); $this->assertSame(200, $this->client->getResponse()->getStatusCode()); $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); $this->assertContains('success', $this->client->getResponse()->getContent()); $this->assertNotEmpty($this->client->getResponse()->getContent()); } } ``` 这两个测试几乎是相同的。它们包含一个单一过程操作方法的测试。目前,控制器过程操作方法只是返回一个固定的成功 JSON 响应。我们可以很容易地扩展它以返回不仅仅是一个固定的响应,并且可以伴随着更健壮的功能测试。 # 总结 在本章中,我们构建了一个具有两种运输方法的`shipment`模块。每种运输方法都提供了可用的交付选项。固定费率运输方法在其交付选项下只有一个固定值,而动态费率方法从`getDeliveryOptions`方法中获取其值。我们可以很容易地在`getDeliveryOptions`中嵌入一个真实的运输 API,以提供真正动态的运输选项。 显然,我们在这里缺少官方接口,就像我们在支付方法中一样。然而,这是我们可以随时回来并在我们最终的`final`模块中重构的内容。 与支付方式类似,这里的想法是创建一个最小的结构,展示如何开发一个简单的装运模块以供进一步定制。通过使用`shipment_methodservice`标签,我们有效地为未来的“销售”模块暴露了装运方法。 在接下来的章节中,我们将构建一个“销售”模块,最终将利用我们的“支付”和“装运”模块。 # 第十一章:构建销售模块 销售模块是我们将构建的一系列模块中的最后一个,以便提供一个简单但功能齐全的网络商店应用程序。我们将在目录的基础上添加购物车和结账功能来实现这一点。结账本身最终将利用在前几章中定义的运输和付款服务。这里的整体重点将放在绝对基础上,因为真正的购物车应用程序会采用更加健壮的方法。然而,了解如何以简单的方式将所有内容联系在一起是打开以后实现更加健壮的网络商店应用程序的第一步。 在本章中,我们将介绍销售模块的以下主题: + 要求 + 依赖关系 + 实施 + 单元测试 + 功能测试 # 要求 应用要求,在第四章中定义,*模块化网络商店应用的需求规范*,为我们提供了一些关于购物车和结账的线框图。基于这些线框图,我们可以推测出我们需要创建哪些类型的实体来实现功能。 以下是所需模块实体的列表: + 购物车 + 购物车项目 + 订单 + 订单项目 `Cart`实体包括以下属性及其数据类型: + `id`:整数,自动递增 + `customer_id`:字符串 + `created_at`:日期时间 + `modified_at`:日期时间 `Cart Item`实体包括以下属性: + `id`:整数,自动递增 + `cart_id`:整数,外键,引用类别`表 id`列 + `product_id`:整数,外键,引用产品`表 id`列 + `qty`:字符串 + `unit_price`:十进制 + `created_at`:日期时间 + `modified_at`:日期时间 `Order`实体包括以下属性: + `id`:整数,自动递增 + `customer_id`:整数,外键,引用客户`表 id`列 + `items_price`:十进制 + `shipment_price`:十进制 + `total_price`:十进制 + `状态`:字符串 + `customer_email`:字符串 + `customer_first_name`:字符串 + `customer_last_name`:字符串 + `address_first_name`:字符串 + `address_last_name`:字符串 + `address_country`:字符串 + `address_state`:字符串 + `address_city`:字符串 + `address_postcode`:字符串 + `address_street`:字符串 + `address_telephone`:字符串 + `payment_method`:字符串 + `shipment_method`:字符串 + `created_at`:日期时间 + `modified_at`:日期时间 `Order Item`实体包括以下属性: + `id`:整数,自动递增 + `sales_order_id`:整数,外键,引用订单`表 id`列 + `product_id`:整数,外键,引用产品`表 id`列 + `title`:字符串 + `qty`:整数 + `unit_price`:十进制 + `total_price`:十进制 + `created_at`:日期时间 + `modified_at`:日期时间 除了添加这些实体和它们的 CRUD 页面之外,我们还需要覆盖一个负责构建类别菜单和特价商品的核心模块服务。 # 依赖关系 销售模块将在代码中有几个依赖项。这些依赖项指向客户和目录模块。 # 实施 我们首先创建一个名为`Foggyline\SalesBundle`的新模块。我们可以通过控制台运行以下命令来实现: ```php **php bin/console generate:bundle --namespace=Foggyline/SalesBundle** ``` 该命令触发一个交互式过程,在此过程中向我们提出了几个问题,如下所示: ![实施](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_11_01.jpg) 完成后,`app/AppKernel.php`和`app/config/routing.yml`文件将自动修改。`AppKernel`类的`registerBundles`方法已添加到`$bundles`数组下的以下行: ```php new Foggyline\PaymentBundle\FoggylineSalesBundle(), ``` `routing.yml`文件已更新,添加了以下条目: ```php foggyline_payment: resource: "@FoggylineSalesBundle/Resources/config/routing.xml" prefix: / ``` 为了避免与核心应用程序代码发生冲突,我们需要将`prefix: /`更改为`prefix: /sales/`。 ## 创建购物车实体 让我们继续创建一个`Cart`实体。我们可以通过控制台来实现,如下所示: ```php **php bin/console generate:doctrine:entity** ``` 触发交互式生成器,如下所示的屏幕截图: ![创建购物车实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_11_02.jpg) 这将在`src/Foggyline/SalesBundle/`目录中创建`Entity/Cart.php`和`Repository/CartRepository.php`文件。之后,我们需要更新数据库,以便通过运行以下命令引入`Cart`实体: ```php **php bin/console doctrine:schema:update --force** ``` 有了`Cart`实体,我们可以继续生成`CartItem`实体。 ## 创建购物车项目实体 让我们继续创建`CartItem`实体。我们通过使用现在众所周知的`console`命令来完成: ```php **php bin/console generate:doctrine:entity** ``` 触发交互式生成器,如下所示的屏幕截图: ![创建购物车项目实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_11_03.jpg) 这将在`src/Foggyline/SalesBundle/`目录中创建`Entity/CartItem.php`和`Repository/CartItemRepository.php`。自动生成完成后,我们需要返回并编辑`CartItem`实体,以更新`cart`字段关系如下: ```php /** * @ORM\ManyToOne(targetEntity="Cart", inversedBy="items") * @ORM\JoinColumn(name="cart_id", referencedColumnName="id") */ private $cart; ``` 在这里,我们定义了所谓的*双向一对多*关联。一对多关联中的外键在这种情况下是在多方上定义的,也就是`CartItem`实体。双向映射需要在`OneToMany`关联上使用`mappedBy`属性,在`ManyToOne`关联上使用`inversedBy`属性。在这种情况下,`OneToMany`方是`Cart`实体,因此我们返回`src/Foggyline/SalesBundle/Entity/Cart.php`文件,并添加以下内容: ```php /** * @ORM\OneToMany(targetEntity="CartItem", mappedBy="cart") */ private $items; public function __construct() { $this->items = new \Doctrine\Common\Collections\ArrayCollection(); } ``` 然后,我们需要更新数据库,以便通过运行以下命令引入`CartItem`实体: ```php **php bin/console doctrine:schema:update --force** ``` 有了`CartItem`实体,我们可以继续生成`Order`实体。 ## 创建订单实体 让我们继续创建`Order`实体。我们通过控制台这样做: ```php **php bin/console generate:doctrine:entity** ``` 如果我们尝试提供`FoggylineSalesBundle:Order`作为实体快捷方式名称,生成的输出将会抛出错误,如下所示的屏幕截图: ![创建订单实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_11_04.jpg) 相反,我们将使用`SensioGeneratorBundle:SalesOrder`作为实体快捷方式名称,并按照以下方式跟随生成器: ![创建订单实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_11_05.jpg) 接下来是与客户信息相关的其余字段。要更好地了解,请查看以下屏幕截图: ![创建订单实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_11_06.jpg) 接下来是与订单地址相关的其余字段,如下所示: ![创建订单实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_11_07.jpg) 值得注意的是,通常我们希望将地址信息提取到自己的表中,即将其作为自己的实体。但是,为了保持简单,我们将继续将其作为`SalesOrder`实体的一部分。 完成后,在`src/Foggyline/SalesBundle/`目录中创建`Entity/SalesOrder.php`和`Repository/SalesOrderRepository.php`文件。之后,我们需要更新数据库,以便通过运行以下命令引入`SalesOrder`实体: ```php **php bin/console doctrine:schema:update --force** ``` 有了`SalesOrder`实体,我们可以继续生成`SalesOrderItem`实体。 ## 创建 SalesOrderItem 实体 让我们继续创建`SalesOrderItem`实体。我们通过使用以下`console`命令启动代码生成器: ```php **php bin/console generate:doctrine:entity** ``` 当要求实体快捷方式名称时,我们提供`FoggylineSalesBundle:SalesOrderItem`,然后按照以下屏幕截图中显示的生成器字段定义: ![创建 SalesOrderItem 实体](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_11_08.jpg) 这将在`src/Foggyline/SalesBundle/`目录中创建`Entity/SalesOrderItem.php`和`Repository/SalesOrderItemRepository.php`文件。自动生成完成后,我们需要返回并编辑`SalesOrderItem`实体,以更新`SalesOrder`字段关系如下: ```php /** * @ORM\ManyToOne(targetEntity="SalesOrder", inversedBy="items") * @ORM\JoinColumn(name="sales_order_id", referencedColumnName="id") */ private $salesOrder; /** * @ORM\OneToOne(targetEntity="Foggyline\CatalogBundle\Entity\Product") * @ORM\JoinColumn(name="product_id", referencedColumnName="id") */ private $product; ``` 在这里,我们定义了两种类型的关系。第一种是与`$salesOrder`相关的双向一对多关联,这是我们在`Cart`和`CartItem`实体中看到的。第二种是与`$product`相关的单向一对一关联。引用被称为单向,因为`CartItem`引用`Product`,而`Product`不会引用`CartItem`,因为我们不想改变属于另一个模块的东西。 我们仍然需要回到`src/Foggyline/SalesBundle/Entity/SalesOrder.php`文件,并添加以下内容: ```php /** * @ORM\OneToMany(targetEntity="SalesOrderItem", mappedBy="salesOrder") */ private $items; public function __construct() { $this->items = new \Doctrine\Common\Collections\ArrayCollection(); } ``` 然后我们需要更新数据库,以便通过运行以下命令引入`SalesOrderItem`实体: ```php **php bin/console doctrine:schema:update --force** ``` 有了`SalesOrderItem`实体,我们现在可以开始构建购物车和结账页面。 ## 覆盖`add_to_cart_url`服务 `add_to_cart_url`服务最初是在`FoggylineCustomerBundle`中声明的,带有虚拟数据。这是因为在销售功能可用之前,我们需要一种构建产品的添加到购物车 URL 的方法。虽然肯定不是理想的方式,但这是一种可能的方式。 现在我们将使用我们在销售模块中声明的服务来覆盖该服务,以提供正确的添加到购物车 URL。我们首先通过在`src/Foggyline/SalesBundle/Resources/config/services.xml`中定义服务来开始,如下所示: ```php ``` 然后创建`src/Foggyline/SalesBundle/Service/AddToCartUrl.php`,内容如下: ```php namespace Foggyline\SalesBundle\Service; class AddToCartUrl { private $em; private $router; public function __construct( \Doctrine\ORM\EntityManager $entityManager, \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->em = $entityManager; $this->router = $router; } public function getAddToCartUrl($productId) { return $this->router->generate('foggyline_sales_cart_add', array('id' => $productId)); } } ``` 这里的`router`服务期望名为`foggyline_sales_cart_add`的路由,但这个路由还不存在。我们通过在`src/Foggyline/SalesBundle/Resources/config/routing.xml`文件的`routes`元素下添加以下条目来创建该路由,如下所示: ```php FoggylineSalesBundle:Cart:add ``` 路由定义期望在`src/Foggyline/SalesBundle/Controller/CartController.php`文件中的购物车控制器中找到`addAction`函数,我们定义如下: ```php namespace Foggyline\SalesBundle\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Bundle\FrameworkBundle\Controller\Controller; class CartController extends Controller { public function addAction($id) { if ($customer = $this->getUser()) { $em = $this->getDoctrine()->getManager(); $now = new \DateTime(); $product = $em->getRepository('FoggylineCatalogBundle:Product')->find($id); // Grab the cart for current user $cart = $em->getRepository('FoggylineSalesBundle:Cart')->findOneBy(array('customer' => $customer)); // If there is no cart, create one if (!$cart) { $cart = new \Foggyline\SalesBundle\Entity\Cart(); $cart->setCustomer($customer); $cart->setCreatedAt($now); $cart->setModifiedAt($now); } else { $cart->setModifiedAt($now); } $em->persist($cart); $em->flush(); // Grab the possibly existing cart item // But, lets find it directly $cartItem = $em->getRepository('FoggylineSalesBundle:CartItem')->findOneBy(array('cart' => $cart, 'product' => $product)); if ($cartItem) { // Cart item exists, update it $cartItem->setQty($cartItem->getQty() + 1); $cartItem->setModifiedAt($now); } else { // Cart item does not exist, add new one $cartItem = new \Foggyline\SalesBundle\Entity\CartItem(); $cartItem->setCart($cart); $cartItem->setProduct($product); $cartItem->setQty(1); $cartItem->setUnitPrice($product->getPrice()); $cartItem->setCreatedAt($now); $cartItem->setModifiedAt($now); } $em->persist($cartItem); $em->flush(); $this->addFlash('success', sprintf('%s successfully added to cart', $product->getTitle())); return $this->redirectToRoute('foggyline_sales_cart'); } else { $this->addFlash('warning', 'Only logged in users can add to cart.'); return $this->redirect('/'); } } } ``` 在`addAction`方法中有相当多的逻辑。我们首先检查当前用户是否已经在数据库中有购物车条目;如果没有,我们就创建一个新的。然后添加或更新现有的购物车条目。 为了使我们的新`add_to_cart`服务实际上覆盖`Customer`模块中的服务,我们仍然需要添加一个编译器。我们通过定义`src/Foggyline/SalesBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php`文件来实现这一点,内容如下: ```php namespace Foggyline\SalesBundle\DependencyInjection\Compiler; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; class OverrideServiceCompilerPass implements CompilerPassInterface { public function process(ContainerBuilder $container) { // Override 'add_to_cart_url' service $container->removeDefinition('add_to_cart_url'); $container->setDefinition('add_to_cart_url', $container->getDefinition('foggyline_sales.add_to_cart_url')); // Override 'checkout_menu' service // Override 'foggyline_customer.customer_orders' service // Override 'bestsellers' service // Pickup/parse 'shipment_method' services // Pickup/parse 'payment_method' services } } ``` 稍后,我们将在此文件中添加其余的覆盖。为了暂时解决问题,并使`add_to_cart`服务覆盖生效,我们需要在`src/Foggyline/SalesBundle/FoggylineSalesBundle.php`文件的`build`方法中注册*编译器*,如下所示: ```php public function build(ContainerBuilder $container) { parent::build($container);; $container->addCompilerPass(new OverrideServiceCompilerPass()); } ``` 覆盖现在应该生效了,我们的`Sales`模块现在应该提供有效的添加到购物车链接。 ## 覆盖`checkout_menu`服务 `Customer`模块中定义的结账菜单服务有一个简单的目的,即提供到购物车和结账流程的第一步的链接。由于当时还不知道销售模块,`Customer`模块提供了一个虚拟链接,现在我们将覆盖它。 首先,在`src/Foggyline/SalesBundle/Resources/config/services.xml`文件的`services`元素下添加以下服务条目: ```php ``` 然后添加`src/Foggyline/SalesBundle/Service/CheckoutMenu.php`文件,内容如下: ```php namespace Foggyline\SalesBundle\Service; class CheckoutMenu { private $em; private $token; private $router; public function __construct( \Doctrine\ORM\EntityManager $entityManager, $tokenStorage, \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->em = $entityManager; $this->token = $tokenStorage->getToken(); $this->router = $router; } public function getItems() { if ($this->token && $this->token->getUser() instanceof \Foggyline\CustomerBundle\Entity\Customer ) { $customer = $this->token->getUser(); $cart = $this->em->getRepository('FoggylineSalesBundle:Cart')->findOneBy(array('customer' => $customer)); if ($cart) { return array( array('path' => $this->router->generate('foggyline_sales_cart'), 'label' =>sprintf('Cart (%s)', count($cart->getItems()))), array('path' => $this->router->generate('foggyline_sales_checkout'), 'label' =>'Checkout'), ); } } return array(); } } ``` 该服务期望两个路由,`foggyline_sales_cart`和`foggyline_sales_checkout`,因此我们需要通过向`src/Foggyline/SalesBundle/Resources/config/routing.xml`文件添加以下路由定义来修改它: ```php FoggylineSalesBundle:Cart:index FoggylineSalesBundle:Checkout:index ``` 新添加的路由期望`cart`和`checkout`控制器。`cart`控制器已经就位,所以我们只需要添加`indexAction`。此时,让我们添加一个空的如下: ```php public function indexAction(Request $request) { } ``` 类似地,让我们创建一个`src/Foggyline/SalesBundle/Controller/CheckoutController.php`文件,内容如下: ```php namespace Foggyline\SalesBundle\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\CountryType; class CheckoutController extends Controller { public function indexAction() { } } ``` 稍后,我们将回到这两个`indexAction`方法,并添加适当的方法体实现。 为了完成服务覆盖,我们现在通过用以下内容替换`src/Foggyline/SalesBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php`文件中先前创建的`// Override 'checkout_menu'`服务注释来修改它: ```php $container->removeDefinition('checkout_menu'); $container->setDefinition('checkout_menu', $container->getDefinition('foggyline_sales.checkout_menu')); ``` 我们新定义的服务现在应该覆盖`Customer`模块中定义的服务,从而提供正确的结账和购物车(带有购物车中的商品数量)URL。 ## 覆盖客户订单服务 `foggyline_customer.customer_orders`服务是为当前登录的客户提供以前创建的订单集合。`Customer`模块为此目的定义了一个虚拟服务,这样我们就可以继续构建**我的账户**页面下的**我的订单**部分。现在我们需要覆盖此服务,使其返回正确的订单。 我们首先在`src/Foggyline/SalesBundle/Resources/config/services.xml`文件的服务下添加以下`service`元素: ```php ``` 然后,我们添加`src/Foggyline/SalesBundle/Service/CustomerOrders.php`文件,内容如下: ```php namespace Foggyline\SalesBundle\Service; class CustomerOrders { private $em; private $token; private $router; public function __construct( \Doctrine\ORM\EntityManager $entityManager, $tokenStorage, \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->em = $entityManager; $this->token = $tokenStorage->getToken(); $this->router = $router; } public function getOrders() { $orders = array(); if ($this->token && $this->token->getUser() instanceof \Foggyline\CustomerBundle\Entity\Customer ) { $salesOrders = $this->em->getRepository('FoggylineSalesBundle:SalesOrder') ->findBy(array('customer' => $this->token->getUser())); foreach ($salesOrders as $salesOrder) { $orders[] = array( 'id' => $salesOrder->getId(), 'date' => $salesOrder->getCreatedAt()->format('d/m/Y H:i:s'), 'ship_to' => $salesOrder->getAddressFirstName() . '' . $salesOrder->getAddressLastName(), ' 'order_total' => $salesOrder->getTotalPrice(), 'status' => $salesOrder->getStatus(), 'actions' => array( array( 'label' =>'Cancel', 'path' => $this->router->generate('foggyline_sales_order_cancel', array('id' => $salesOrder->getId())) ), array( 'label' =>'Print', 'path' => $this->router->generate('foggyline_sales_order_print', array('id' => $salesOrder->getId())) ) ) ); } } return $orders; } } ``` `route generate`方法期望找到两个路由,`foggyline_sales_order_cancel`和`foggyline_sales_order_print`,这两个路由尚未创建。 让我们继续通过在`src/Foggyline/SalesBundle/Resources/config/routing.xml`文件的`route`元素下添加以下内容来创建它们: ```php FoggylineSalesBundle:SalesOrder:cancel FoggylineSalesBundle:SalesOrder:print ``` 路由定义又期望`SalesOrderController`被定义。由于我们的应用程序需要管理员用户能够列出和编辑订单,我们将使用以下 Symfony 命令自动生成我们的`Sales Order`实体的 CRUD: ```php **php bin/console generate:doctrine:crud** ``` 当要求实体快捷名称时,我们只需提供`FoggylineSalesBundle:SalesOrder`并继续,允许创建写操作。此时,已经为我们创建了几个文件,以及`Sales`包之外的一些条目。其中一个条目是`app/config/routing.yml`文件中的路由定义,如下所示: ```php foggyline_sales_sales_order: resource: "@FoggylineSalesBundle/Controller/SalesOrderController.php" type: annotation ``` 我们应该已经在那里有一个`foggyline_sales`条目。不同之处在于`foggyline_sales`指向我们的`router.xml`文件,而新创建的`foggyline_sales_sales_order`指向刚创建的`SalesOrderController`。为了简单起见,我们可以保留它们两者。 自动生成器还在`app/Resources/views/`目录下创建了一个`salesorder`目录,我们需要将其移动到我们的包中,作为`src/Foggyline/SalesBundle/Resources/views/Default/salesorder/`目录。 现在我们可以通过将以下内容添加到`src/Foggyline/SalesBundle/Controller/SalesOrderController.php`文件中来处理我们的打印和取消操作: ```php public function cancelAction($id) { if ($customer = $this->getUser()) { $em = $this->getDoctrine()->getManager(); $salesOrder = $em->getRepository('FoggylineSalesBundle:SalesOrder') ->findOneBy(array('customer' => $customer, 'id' => $id)); if ($salesOrder->getStatus() != \Foggyline\SalesBundle\Entity\SalesOrder::STATUS_COMPLETE) { $salesOrder->setStatus(\Foggyline\SalesBundle\Entity\SalesOrder::STATUS_CANCELED); $em->persist($salesOrder); $em->flush(); } } return $this->redirectToRoute('customer_account'); } public function printAction($id) { if ($customer = $this->getUser()) { $em = $this->getDoctrine()->getManager(); $salesOrder = $em->getRepository('FoggylineSalesBundle:SalesOrder') ->findOneBy(array('customer' => $customer, 'id' =>$id)); return $this->render('FoggylineSalesBundle:default:salesorder/print.html.twig', array( 'salesOrder' => $salesOrder, 'customer' => $customer )); } return $this->redirectToRoute('customer_account'); } ``` `cancelAction`方法仅仅检查所涉及的订单是否属于当前登录的客户;如果是,允许更改订单状态。`printAction`方法仅仅加载订单,如果它属于当前登录的客户,并将其传递给`print.html.twig`模板。 然后,我们创建了`src/Foggyline/SalesBundle/Resources/views/Default/salesorder/print.html.twig`模板,内容如下: ```php {% block body %}

Printing Order #{{ salesOrder.id }}

{#

Just a dummy Twig dump of entire variable

#} {{ dump(salesOrder) }} {% endblock %} ``` 显然,这只是一个简化的输出,我们可以根据需要进一步自定义。重要的是,我们已经将`order`对象传递给我们的模板,并且现在可以从中提取所需的任何信息。 最后,我们将`src/Foggyline/SalesBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php`文件中的`// Override 'foggyline_customer.customer_orders'`服务注释替换为以下代码: ```php $container->removeDefinition('foggyline_customer.customer_orders'); $container->setDefinition('foggyline_customer.customer_orders', $container->getDefinition('foggyline_sales.customer_orders')); ``` 这将使服务覆盖生效,并引入我们刚刚进行的所有更改。 ## 覆盖畅销服务 在`Customer`模块中定义的`bestsellers`服务应该为首页显示的畅销产品提供虚拟数据。其目的是展示商店中五个畅销产品。`Sales`模块现在需要覆盖此服务,以便提供正确的实现,实际销售的产品数量将影响所显示的畅销产品的内容。 我们首先在`src/Foggyline/SalesBundle/Resources/config/services.xml`文件的`service`元素下添加以下定义: ```php ``` 然后我们按以下内容定义`src/Foggyline/SalesBundle/Service/BestSellers.php`文件: ```php namespace Foggyline\SalesBundle\Service; class BestSellers { private $em; private $router; public function __construct( \Doctrine\ORM\EntityManager $entityManager, \Symfony\Bundle\FrameworkBundle\Routing\Router $router ) { $this->em = $entityManager; $this->router = $router; } public function getItems() { $products = array(); $salesOrderItem = $this->em->getRepository('FoggylineSalesBundle:SalesOrderItem'); $_products = $salesOrderItem->getBestsellers(); foreach ($_products as $_product) { $products[] = array( 'path' => $this->router->generate('product_show', array('id' => $_product->getId())), 'name' => $_product->getTitle(), 'img' => $_product->getImage(), 'price' => $_product->getPrice(), 'id' => $_product->getId(), ); } return $products; } } ``` 在这里,我们获取`SalesOrderItemRepository`类的实例,并在其上调用`getBestsellers`方法。这个方法还没有被定义。我们通过将其添加到`src/Foggyline/SalesBundle/Repository/SalesOrderItemRepository.php`文件中来定义它: ```php public function getBestsellers() { $products = array(); $query = $this->_em->createQuery('SELECT IDENTITY(t.product), SUM(t.qty) AS HIDDEN q FROM Foggyline\SalesBundle\Entity\SalesOrderItem t GROUP BY t.product ORDER BY q DESC') ->setMaxResults(5); $_products = $query->getResult(); foreach ($_products as $_product) { $products[] = $this->_em->getRepository('FoggylineCatalogBundle:Product') ->find(current($_product)); } return $products; } ``` 在这里,我们使用**Doctrine 查询语言**(**DQL**)来构建五个畅销产品的列表。最后,我们需要用以下代码替换`src/Foggyline/SalesBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php`文件中的`// Override 'bestsellers'`服务注释: ```php $container->removeDefinition('bestsellers'); $container->setDefinition('bestsellers', $container->getDefinition('foggyline_sales.bestsellers')); ``` 通过覆盖`bestsellers`服务,我们公开了基于实际销售的畅销产品列表,供其他模块获取。 ## 创建购物车页面 购物车页面是顾客可以通过首页、分类页面或产品页面的**加入购物车**按钮看到已添加到购物车的产品列表的地方。我们之前创建了`CartController`和一个空的`indexAction`函数。现在让我们继续编辑`indexAction`函数如下: ```php public function indexAction() { if ($customer = $this->getUser()) { $em = $this->getDoctrine()->getManager(); $cart = $em->getRepository('FoggylineSalesBundle:Cart')->findOneBy(array('customer' => $customer)); $items = $cart->getItems(); $total = null; foreach ($items as $item) { $total += floatval($item->getQty() * $item->getUnitPrice()); } return $this->render('FoggylineSalesBundle:default:cart/index.html.twig', array( 'customer' => $customer, 'items' => $items, 'total' => $total, )); } else { $this->addFlash('warning', 'Only logged in customers can access cart page.'); return $this->redirectToRoute('foggyline_customer_login'); } } ``` 在这里,我们正在检查用户是否已登录;如果是,我们会向他们显示带有所有项目的购物车。未登录用户将被重定向到客户登录 URL。`indexAction`函数期望`src/Foggyline/SalesBundle/Resources/views/Default/cart/index.html.twig`文件,我们定义其内容如下: ```php {% extends 'base.html.twig' %} {% block body %}

Shopping Cart

{% for item in items %} {% endfor %}
Item Price Qty Subtotal
{{ item.product.title }} {{ item.unitPrice }} {{ item.qty * item.unitPrice }}
Order Total: {{ total }}
{% endblock %} ``` 模板渲染时,将在每个添加的产品下显示数量输入元素,以及**更新购物车**按钮。**更新购物车**按钮提交表单,其操作指向`foggyline_sales_cart_update`路由。 让我们继续创建`foggyline_sales_cart_update`,通过在`src/Foggyline/SalesBundle/Resources/config/routing.xml`文件的`route`元素下添加以下条目: ```php FoggylineSalesBundle:Cart:update ``` 新定义的路由期望在`src/Foggyline/SalesBundle/Controller/CartController.php`文件中找到一个`updateAction`函数,我们添加如下: ```php public function updateAction(Request $request) { $items = $request->get('item'); $em = $this->getDoctrine()->getManager(); foreach ($items as $_id => $_qty) { $cartItem = $em->getRepository('FoggylineSalesBundle:CartItem')->find($_id); if (intval($_qty) > 0) { $cartItem->setQty($_qty); $em->persist($cartItem); } else { $em->remove($cartItem); } } // Persist to database $em->flush(); $this->addFlash('success', 'Cart updated.'); return $this->redirectToRoute('foggyline_sales_cart'); } ``` 要从购物车中删除产品,我们只需将数量值插入为`0`,然后点击**更新购物车**按钮。这完成了我们简单的购物车页面。 ## 创建支付服务 为了从购物车到结账的过程中,我们需要解决支付和运输服务的问题。先前的`Payment`和`Shipment`模块公开了它们的一些`Payment`和`Shipment`服务,现在我们需要将它们聚合成一个单一的`Payment`和`Shipment`服务,供我们的结账流程使用。 我们开始用以下代码替换`src/Foggyline/SalesBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php`文件中先前添加的`// Pickup/parse 'payment_method'`服务注释: ```php $container->getDefinition('foggyline_sales.payment') ->addArgument( array_keys($container->findTaggedServiceIds('payment_method')) ); ``` `findTaggedServiceIds`方法返回一个带有`payment_method`标签的所有服务的键值列表,然后我们将其作为参数传递给我们的`foggyline_sales.payment`服务。这是在 Symfony 编译时获取服务列表的唯一方法。 然后我们通过在`service`元素下添加以下内容来编辑`src/Foggyline/SalesBundle/Resources/config/services.xml`文件: ```php ``` 最后,我们按以下方式在`src/Foggyline/SalesBundle/Service/Payment.php`文件中创建`Payment`类: ```php namespace Foggyline\SalesBundle\Service; class Payment { private $container; private $methods; public function __construct($container, $methods) { $this->container = $container; $this->methods = $methods; } public function getAvailableMethods() { $methods = array(); foreach ($this->methods as $_method) { $methods[] = $this->container->get($_method); } return $methods; } } ``` 根据`services.xml`文件中的服务定义,我们的服务接受两个参数,一个是`$container`,另一个是`$methods`。`$methods`参数在编译时传递,我们能够获取所有`payment_method`标记的服务列表。这有效地意味着我们的`getAvailableMethods`现在能够返回任何模块中标记为`payment_method`的服务。 ## 创建装运服务 `Shipment`服务的实现方式与`Payment`服务类似。总体思路是相似的,只是在途中有一些不同。我们首先用以下代码替换之前添加的`// Pickup/parse shipment_method'`服务注释,放在`src/Foggyline/SalesBundle/DependencyInjection/Compiler/OverrideServiceCompilerPass.php`文件中: ```php $container->getDefinition('foggyline_sales.shipment') ->addArgument( array_keys($container->findTaggedServiceIds('shipment_method')) ); ``` 然后,我们通过在`src/Foggyline/SalesBundle/Resources/config/services.xml`文件的`service`元素下添加以下内容来编辑: ```php ``` 最后,我们按照以下方式在`src/Foggyline/SalesBundle/Service/Shipment.php`文件中创建`Shipment`类: ```php namespace Foggyline\SalesBundle\Service; class Shipment { private $container; private $methods; public function __construct($container, $methods) { $this->container = $container; $this->methods = $methods; } public function getAvailableMethods() { $methods = array(); foreach ($this->methods as $_method) { $methods[] = $this->container->get($_method); } return $methods; } } ``` 现在,我们能够通过我们统一的`Payment`和`Shipment`服务获取所有的`Payment`和`Shipment`服务,从而使结账流程变得简单。 ## 创建结账页面 结账页面将由两个结账步骤构成,第一个是收集装运信息,第二个是收集支付信息。 我们从装运步骤开始,通过更改`src/Foggyline/SalesBundle/Controller/CheckoutController.php`文件及其`indexAction`如下: ```php public function indexAction() { if ($customer = $this->getUser()) { $form = $this->getAddressForm(); $em = $this->getDoctrine()->getManager(); $cart = $em->getRepository('FoggylineSalesBundle:Cart')->findOneBy(array('customer' => $customer)); $items = $cart->getItems(); $total = null; foreach ($items as $item) { $total += floatval($item->getQty() * $item->getUnitPrice()); } return $this->render('FoggylineSalesBundle:default:checkout/index.html.twig', array( 'customer' => $customer, 'items' => $items, 'cart_subtotal' => $total, 'shipping_address_form' => $form->createView(), 'shipping_methods' => $this->get('foggyline_sales.shipment')->getAvailableMethods() )); } else { $this->addFlash('warning', 'Only logged in customers can access checkout page.'); return $this->redirectToRoute('foggyline_customer_login'); } } private function getAddressForm() { return $this->createFormBuilder() ->add('address_first_name', TextType::class) ->add('address_last_name', TextType::class) ->add('company', TextType::class) ->add('address_telephone', TextType::class) ->add('address_country', CountryType::class) ->add('address_state', TextType::class) ->add('address_city', TextType::class) ->add('address_postcode', TextType::class) ->add('address_street', TextType::class) ->getForm(); } ``` 在这里,我们获取当前登录的客户购物车,并将其传递到`checkout/index.html.twig`模板,还有其他几个在装运步骤中需要的变量。`getAddressForm`方法简单地为我们构建了一个地址表单。还有一个调用我们新创建的`foggyline_sales.shipment`服务,它使我们能够获取所有可用的装运方式列表。 然后,我们创建`src/Foggyline/SalesBundle/Resources/views/Default/checkout/index.html.twig`,内容如下: ```php {% extends 'base.html.twig' %} {% block body %}

Checkout

Shipping Address {{ form_widget(shipping_address_form) }}
Shipping Methods
    {% for method in shipping_methods %} {% set shipment = method.getInfo('street', 'city', 'country', 'postcode', 'amount', 'qty')['shipment'] %}
    • {% for delivery_option in shipment.delivery_options %}
    • {{ delivery_option.title }} ({{ delivery_option.price }})
    • {% endfor %}
  • {% endfor %}
{% include 'FoggylineSalesBundle:default:checkout/order_sumarry.html.twig' %}
Cart Subtotal: {{ cart_subtotal }}
{% endblock %} ``` 模板列出了所有与地址相关的表单字段,以及可用的装运方式。JavaScript 部分处理了**下一步**按钮的点击,基本上是将表单提交到`foggyline_sales_checkout_payment`路由。 然后,我们通过在`src/Foggyline/SalesBundle/Resources/config/routing.xml`文件的`routes`元素下添加以下条目来定义`foggyline_sales_checkout_payment`路由: ```php FoggylineSalesBundle:Checkout:payment ``` 路由条目期望在`CheckoutController`中找到`paymentAction`,我们定义如下: ```php public function paymentAction(Request $request) { $addressForm = $this->getAddressForm(); $addressForm->handleRequest($request); if ($addressForm->isSubmitted() && $addressForm->isValid() && $customer = $this->getUser()) { $em = $this->getDoctrine()->getManager(); $cart = $em->getRepository('FoggylineSalesBundle:Cart')->findOneBy(array('customer' => $customer)); $items = $cart->getItems(); $cartSubtotal = null; foreach ($items as $item) { $cartSubtotal += floatval($item->getQty() * $item->getUnitPrice()); } $shipmentMethod = $_POST['shipment_method']; $shipmentMethod = explode('____', $shipmentMethod); $shipmentMethodCode = $shipmentMethod[0]; $shipmentMethodDeliveryCode = $shipmentMethod[1]; $shipmentMethodDeliveryPrice = $shipmentMethod[2]; // Store relevant info into session $checkoutInfo = $addressForm->getData(); $checkoutInfo['shipment_method'] = $shipmentMethodCode . '____' . $shipmentMethodDeliveryCode; $checkoutInfo['shipment_price'] = $shipmentMethodDeliveryPrice; $checkoutInfo['items_price'] = $cartSubtotal; $checkoutInfo['total_price'] = $cartSubtotal + $shipmentMethodDeliveryPrice; $this->get('session')->set('checkoutInfo', $checkoutInfo); return $this->render('FoggylineSalesBundle:default:checkout/payment.html.twig', array( 'customer' => $customer, 'items' => $items, 'cart_subtotal' => $cartSubtotal, 'delivery_subtotal' => $shipmentMethodDeliveryPrice, 'delivery_label' =>'Delivery Label Here', 'order_total' => $cartSubtotal + $shipmentMethodDeliveryPrice, 'payment_methods' => $this->get('foggyline_sales.payment')->getAvailableMethods() )); } else { $this->addFlash('warning', 'Only logged in customers can access checkout page.'); return $this->redirectToRoute('foggyline_customer_login'); } } ``` 前面的代码从结账流程的装运步骤中获取提交的内容,将相关数值存储到会话中,获取支付步骤所需的变量,并渲染`checkout/payment.html.twig`模板。 我们定义了`src/Foggyline/SalesBundle/Resources/views/Default/checkout/payment.html.twig`文件的内容如下: ```php {% extends 'base.html.twig' %} {% block body %}

Checkout

Payment Methods
    {% for method in payment_methods %} {% set payment = method.getInfo()['payment'] %}
  • {{ payment.title }} {% if payment['form'] is defined %}
    {{ form_widget(payment['form']) }}
    {% endif %}
  • {% endfor %}
{% include 'FoggylineSalesBundle:default:checkout/order_sumarry.html.twig' %}
Cart Subtotal: {{ cart_subtotal }}
{{ delivery_label }}: {{ delivery_subtotal }}
Order Total: {{ order_total }}
{% endblock %} ``` 与装运步骤类似,这里还有可用支付方式的渲染,以及一个由 JavaScript 处理的**下订单**按钮,因为按钮位于提交表单之外。下订单后,提交将发送到`foggyline_sales_checkout_process`路由,我们在`src/Foggyline/SalesBundle/Resources/config/routing.xml`文件的`routes`元素下定义如下: ```php FoggylineSalesBundle:Checkout:process ``` 路由指向`CheckoutController`中的`processAction`函数,我们定义如下: ```php public function processAction() { if ($customer = $this->getUser()) { $em = $this->getDoctrine()->getManager(); // Merge all the checkout info, for SalesOrder $checkoutInfo = $this->get('session')->get('checkoutInfo'); $now = new \DateTime(); // Create Sales Order $salesOrder = new \Foggyline\SalesBundle\Entity\SalesOrder(); $salesOrder->setCustomer($customer); $salesOrder->setItemsPrice($checkoutInfo['items_price']); $salesOrder->setShipmentPrice ($checkoutInfo['shipment_price']); $salesOrder->setTotalPrice($checkoutInfo['total_price']); $salesOrder->setPaymentMethod($_POST['payment_method']); $salesOrder->setShipmentMethod($checkoutInfo['shipment_method']); $salesOrder->setCreatedAt($now); $salesOrder->setModifiedAt($now); $salesOrder->setCustomerEmail($customer->getEmail()); $salesOrder->setCustomerFirstName($customer->getFirstName()); $salesOrder->setCustomerLastName($customer->getLastName()); $salesOrder->setAddressFirstName($checkoutInfo['address_first_name']); $salesOrder->setAddressLastName($checkoutInfo['address_last_name']); $salesOrder->setAddressCountry($checkoutInfo['address_country']); $salesOrder->setAddressState($checkoutInfo['address_state']); $salesOrder->setAddressCity($checkoutInfo['address_city']); $salesOrder->setAddressPostcode($checkoutInfo['address_postcode']); $salesOrder->setAddressStreet($checkoutInfo['address_street']); $salesOrder->setAddressTelephone($checkoutInfo['address_telephone']); $salesOrder->setStatus(\Foggyline\SalesBundle\Entity\SalesOrder::STATUS_PROCESSING); $em->persist($salesOrder); $em->flush(); // Foreach cart item, create order item, and delete cart item $cart = $em->getRepository('FoggylineSalesBundle:Cart')->findOneBy(array('customer' => $customer)); $items = $cart->getItems(); foreach ($items as $item) { $orderItem = new \Foggyline\SalesBundle\Entity\SalesOrderItem(); $orderItem->setSalesOrder($salesOrder); $orderItem->setTitle($item->getProduct()->getTitle()); $orderItem->setQty($item->getQty()); $orderItem->setUnitPrice($item->getUnitPrice()); $orderItem->setTotalPrice($item->getQty() * $item->getUnitPrice()); $orderItem->setModifiedAt($now); $orderItem->setCreatedAt($now); $orderItem->setProduct($item->getProduct()); $em->persist($orderItem); $em->remove($item); } $em->remove($cart); $em->flush(); $this->get('session')->set('last_order', $salesOrder->getId()); return $this->redirectToRoute('foggyline_sales_checkout_success'); } else { $this->addFlash('warning', 'Only logged in customers can access checkout page.'); return $this->redirectToRoute('foggyline_customer_login'); } } ``` 一旦提交到控制器,就会创建一个新订单以及所有相关的项目。同时,购物车和购物车项目将被清除。最后,客户将被重定向到订单成功页面。 ## 创建订单成功页面 订单成功页面在完整的网络商店应用程序中起着重要作用。这是我们感谢客户购买并可能提供一些更多相关或交叉相关的购物选项,以及一些可选的折扣的地方。虽然我们的应用程序很简单,但构建一个简单的订单成功页面是值得的。 我们首先在`src/Foggyline/SalesBundle/Resources/config/routing.xml`文件的`routes`元素下添加以下路由定义: ```php FoggylineSalesBundle:Checkout:success ``` 路由指向`CheckoutController`中的`successAction`函数,我们定义如下: ```php public function successAction() { return $this->render('FoggylineSalesBundle:default:checkout/success.html.twig', array( 'last_order' => $this->get('session')->get('last_order') )); } ``` 在这里,我们只是简单地获取当前登录客户的最后创建的订单 ID,并将完整的订单对象传递给`src/Foggyline/SalesBundle/Resources/views/Default/checkout/success.html.twig`模板,内容如下: ```php {% extends 'base.html.twig' %} {% block body %}

Checkout Success

Thank you for placing your order #{{ last_order }}.

You can see order details here.

{% endblock %} ``` 通过这一步,我们为我们的网络商店完成了整个结账流程。虽然它非常简单,但它为更强大的实现奠定了基础。 ## 创建商店管理仪表板 现在我们已经完成了结账`Sales`模块,让我们快速回到我们的核心模块`AppBundle`。根据我们的应用程序要求,让我们继续创建一个简单的商店管理仪表板。 我们首先添加`src/AppBundle/Controller/StoreManagerController.php`文件,内容如下: ```php namespace AppBundle\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Bundle\FrameworkBundle\Controller\Controller; class StoreManagerController extends Controller { /** * @Route("/store_manager", name="store_manager") */ public function indexAction() { return $this->render('AppBundle:default:store_manager.html.twig'); } } ``` `indexAction`函数简单地返回`src/AppBundle/Resources/views/default/store_manager.html.twig`文件,我们定义其内容如下: ```php {% extends 'base.html.twig' %} {% block body %}

Store Manager

{% endblock %} ``` 模板仅仅渲染类别、产品、客户和订单管理链接。对这些链接的实际访问由防火墙控制,如前几章所述。 # 单元测试 `Sales`模块比以前的任何模块都更强大。有几件事情我们可以进行单元测试。但是,作为本章的一部分,我们不会涵盖完整的单元测试。我们只会把注意力转向单个单元测试,即`CustomerOrders`服务的单元测试。 我们首先在`phpunit.xml.dist`文件的`testsuites`元素下添加以下行: ```php src/Foggyline/SalesBundle/Tests ``` 有了这些,从商店的根目录运行`phpunit`命令应该会执行我们在`src/Foggyline/SalesBundle/Tests/`目录下定义的任何测试。 现在,让我们继续为我们的`CustomerOrders`服务创建一个测试。我们通过定义`src/Foggyline/SalesBundle/Tests/Service/CustomerOrdersTest.php`文件并填写以下内容来实现这一点: ```php namespace Foggyline\SalesBundle\Test\Service; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; class CustomerOrdersTest extends KernelTestCase { private $container; public function setUp() { static::bootKernel(); $this->container = static::$kernel->getContainer(); } public function testGetOrders() { $firewall = 'foggyline_customer'; $em = $this->container->get('doctrine.orm.entity_manager'); $user = $em->getRepository('FoggylineCustomerBundle:Customer')->findOneByUsername ('ajzele@gmail.com'); $token = new UsernamePasswordToken($user, null, $firewall, array('ROLE_USER')); $tokenStorage = $this->container->get('security.token_storage'); $tokenStorage->setToken($token); $orders = new \Foggyline\SalesBundle\Service\CustomerOrders( $em, $tokenStorage, $this->container->get('router') ); $this->assertNotEmpty($orders->getOrders()); } } ``` 在这里,我们使用`UsernamePasswordToken`函数来模拟客户登录。然后将密码令牌传递给`CustomerOrders`服务。`CustomerOrders`服务然后在内部检查令牌存储是否分配了令牌,将其标记为已登录用户并返回其订单列表。能够模拟客户登录对于我们可能为销售模块编写的任何其他测试都是必不可少的。 # 功能测试 与单元测试类似,我们只关注单个功能测试,因为做任何更强大的测试都超出了本章的范围。我们将编写一个简单的代码,将产品添加到购物车并访问结账页面。为了将商品添加到购物车,我们还需要模拟用户登录。 我们按照以下方式编写`src/Foggyline/SalesBundle/Tests/Controller/CartControllerTest.php`测试: ```php namespace Foggyline\SalesBundle\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; class CartControllerTest extends WebTestCase { private $client = null; public function setUp() { $this->client = static::createClient(); } public function testAddToCartAndAccessCheckout() { $this->logIn(); $crawler = $this->client->request('GET', '/'); $crawler = $this->client->click($crawler->selectLink('Add to Cart')->link()); $crawler = $this->client->followRedirect(); $this->assertTrue($this->client->getResponse()->isSuccessful()); $this->assertGreaterThan(0, $crawler->filter('html:contains("added to cart")')->count()); $crawler = $this->client->request('GET', '/sales/cart/'); $crawler = $this->client->click($crawler->selectLink('Go to Checkout')->link()); $this->assertTrue($this->client->getResponse()->isSuccessful()); $this->assertGreaterThan(0, $crawler->filter('html:contains("Checkout")')->count()); } private function logIn() { $session = $this->client->getContainer()->get('session'); $firewall = 'foggyline_customer'; // firewall name $em = $this->client->getContainer()->get('doctrine')->getManager(); $user = $em->getRepository('FoggylineCustomerBundle:Customer')->findOneByUsername('ajzele@gmail.com'); $token = new UsernamePasswordToken($user, null, $firewall, array('ROLE_USER')); $session->set('_security_' . $firewall, serialize($token)); $session->save(); $cookie = new Cookie($session->getName(), $session->getId()); $this->client->getCookieJar()->set($cookie); } } ``` 一旦运行,测试将模拟客户登录,将商品添加到购物车,并尝试访问结账页面。根据我们数据库中实际的客户,我们可能需要更改前面测试中提供的客户电子邮件。 现在运行`phpunit`命令应该成功执行我们的测试。 # 总结 在本章中,我们构建了一个简单但功能齐全的“销售”模块。仅使用四个简单的实体(`Cart`、`CartItem`、`SalesOrder`和`SalesOrderItem`),我们成功实现了简单的购物车和结账功能。通过这样做,我们赋予了客户实际购买产品的能力,而不仅仅是浏览产品目录。销售模块利用了前几章定义的付款和发货服务。虽然付款和发货服务是作为虚构的、虚拟的实现的,但它们提供了一个基本的框架,我们可以用于真正的付款和发货 API 实现。 此外,在本章中,我们通过创建一个简单的界面来处理管理员仪表板,该界面仅仅聚合了一些现有的 CRUD 界面。对仪表板和管理链接的访问受到`app/config/security.yml`中的条目的保护,只允许`ROLE_ADMIN`访问。 到目前为止,我们编写的模块构成了一个简化的应用程序。编写健壮的网络商店应用程序通常会包括现代电子商务平台中发现的数十种其他功能,例如 Magento。这些功能包括多语言、货币和网站支持;健壮的类别、产品和产品库存管理;购物车和目录销售规则;以及许多其他功能。模块化我们的应用程序使开发和维护过程更加简单。 在最后一章中,我们将探讨如何分发我们的模块。 # 第十二章:集成和分发模块 在前几章中,我们以模块化的方式构建了一个简单的网络商店应用程序。每个模块在处理各自的部分时都起着特殊的作用,这有助于整体应用程序。尽管应用程序本身是模块化编写的,但它被保存在一个 Git 单一版本控制存储库中。如果每个模块都在自己的存储库中提供,那将是一个更清晰的分离。这样,我们将能够将不同的模块开发作为完全不同的项目进行保留,同时仍然能够将它们一起使用。随着我们的前进,我们将看到如何通过 GIT 和 Composer 以两种不同的方式实现这一点。 在本章中,我们将涵盖以下工具和服务: + 理解 Git + 理解 GitHub + 理解 Composer + 理解 Packagist # 理解 Git 最初由 Linus Torvalds 开始,Git 版本控制目前是最受欢迎的版本控制系统之一。与大型项目的整体速度和效率以及出色的分支系统一起,使其在开发人员中广受欢迎。 学习 Git 版本控制本身超出了本书的范围,建议阅读*Pro Git*书籍。 ### 提示 由 Scott Chacon 和 Ben Straub 编写,由 Apress 出版的*Pro Git*书籍可以免费获取,网址为[`git-scm.com/book/en/v2`](https://git-scm.com/book/en/v2)。 Git 的一个很棒的功能是其子模块,这是我们在本章中感兴趣的部分。它们使我们能够将较大的模块化项目(例如我们的网络商店应用程序)切分为一系列较小的子模块,其中每个子模块都是一个独立的 Git 存储库。 # 理解 GitHub Git 出现三年后,GitHub 出现了。GitHub 基本上是建立在 Git 版本控制系统之上的网络服务。它使开发人员能够轻松地将他们的代码发布到在线,其他人可以简单地克隆他们的存储库并使用他们的代码。在 GitHub 上创建帐户是免费的,可以通过按照官方主页上的说明来完成([`github.com`](https://github.com))。 目前,我们的应用程序结构如下图所示: ![理解 GitHub](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_12_02.jpg) 我们想要将其分成六个不同的 Git 存储库,如下所示: + 核心 + 目录 + 客户 + 支付 + 销售 + 装运 `core`存储库应包含除`src/Foggyline`目录内容之外的所有内容。 假设我们在 GitHub 上创建了一个空的`core`存储库,并且我们的本地*all-in-one*应用程序当前保存在`shop`目录中,我们在计算机上初始化以下命令: ```php **cp -R shop core-repository** **rm -Rfcore-repository/.git/** **rm -Rfcore-repository/src/Foggyline/*** **touch core-repository/src/Foggyline/.gitkeep** **cd core-repository** **git init** **git remote add origin git@github.com:/.git** **git add --all** **git commit -m "Initial commit of core application"** **git push origin master** ``` 此时,我们仅将我们的全能网络商店应用程序的核心部分推送到 GitHub 上的`core`存储库。`src/Foggyline/`目录中不包含任何模块。 现在,让我们回到 GitHub,并为五个模块(即`catalog`,`customer`,`payment`,`sales`和`shipment`)创建一个适当的空存储库。现在,我们可以为每个模块执行一组控制台命令,如下面的`CatalogBundle`示例所示: ```php **cp -R shop/src/Foggyline/CatalogBundle catalog-repository** **cd catalog-repository** **git init** **git remote add origin git@github.com:/.git** **git add --all** **git commit -m "Initial commit of catalog module"** **git push origin master** ``` 一旦所有五个模块都被推送到存储库,我们最终可以将它们视为子模块,如下所示: ```php **cd core-repository** **git submodule add git@github.com:/.git src/Foggyline/CatalogBundle** **git submodule add git@github.com:/.git src/Foggyline/CustomerBundle** **git submodule add git@github.com:/.git src/Foggyline/PaymentBundle** **git submodule add git@github.com:/.git src/Foggyline/SalesBundle** **git submodule add git@github.com:/.git src/Foggyline/ShipmentBundle** ``` 如果我们现在在`core`存储库目录中运行`ls-al`命令,我们应该能够看到一个`.gitmodules`文件,其中包含以下内容: ```php **[submodule "src/Foggyline/CatalogBundle"]** **path = src/Foggyline/CatalogBundle** **url = git@github.com:/.git** **[submodule "src/Foggyline/CustomerBundle"]** **path = src/Foggyline/CustomerBundle** **url = git@github.com:/.git** **[submodule "src/Foggyline/PaymentBundle"]** **path = src/Foggyline/PaymentBundle** **url = git@github.com:/.git** **[submodule "src/Foggyline/SalesBundle"]** **path = src/Foggyline/SalesBundle** **url = git@github.com:/.git** **[submodule "src/Foggyline/ShipmentBundle"]** **path = src/Foggyline/ShipmentBundle** **url = git@github.com:/.git** ``` `.gitmodules`文件基本上包含了添加到我们核心项目(即核心应用程序)的所有子模块的列表。我们现在应该提交并推送这个文件到`core`存储库。假设`.gitmodules`文件被推送到`core`存储库,我们可以轻松地删除到目前为止创建的所有目录,并使用一个简单的命令初始化项目,如下所示: ```php **git clone --recursive git@github.com:/.git** ``` `git clone`命令的`--recursive`参数会根据`.gitmodules`文件自动初始化和更新存储库中的每个子模块。 # 理解 Composer Composer 是 PHP 的一个依赖管理工具。默认情况下,它不会全局安装任何东西,而是基于每个项目进行安装。我们可以使用它来重新分发我们的项目,以定义它成功执行所需的库和包。使用 Composer 非常简单。它只需要在项目的根目录中创建一个`composer.json`文件,内容类似如下: ```php { "require": { "twig/twig": "~1.0" } } ``` 如果我们在某个空目录中创建了前面的`composer.json`文件,并在该目录中执行`composer install`命令,Composer 将会读取`composer.json`文件并为我们的项目安装定义的依赖项。实际的`install`操作意味着从远程存储库下载所需的代码到我们的计算机上。在这样做的过程中,`install`命令会创建`composer.lock`文件,其中写入了已安装依赖项的确切版本列表。 我们还可以简单地执行`twig/twig:~1.0`命令,这是 Composer 所需的,它做的事情是一样的,但采用了不同的方法。它不需要我们编写`composer.json`文件,如果存在一个,它将对其进行更新。 了解 Composer 本身超出了本书的范围,建议的官方文档可在[`getcomposer.org/doc`](https://getcomposer.org/doc)找到。 Composer 允许打包和正式的依赖管理,使其成为将我们的一体化模块化应用程序切分为一系列 Composer 软件包的绝佳选择。这些软件包需要一个存储库。 # 了解 Packagist 在涉及 Composer 软件包时,主要的存储库是**Packagist**([`packagist.org`](https://packagist.org))。这是一个我们可以通过浏览器访问的网络服务,可以免费开设帐户,并开始向存储库提交我们的软件包。我们还可以使用它来搜索已经存在的软件包。 Packagist 通常用于免费开源软件包,尽管我们可以以相同的方式将**privateGitHub**和**BitBucket**存储库附加到其中,唯一的区别是私有存储库需要 SSH 密钥才能工作。 还有更方便的商业安装 Composer 包管理器的方法,比如**Toran Proxy**([`toranproxy.com`](https://toranproxy.com))。这允许更容易地托管私有软件包,提供更高的带宽以加快软件包安装速度,并提供商业支持。 到目前为止,我们将我们的应用程序切分为了六个不同的 Git 存储库,一个用于核心应用程序,其余五个分别用于每个模块(`catalog`、`customer`、`payment`、`sales`和`shipment`)。现在,让我们迈出最后一步,看看我们如何从 Git 子模块转移到 Composer 软件包。 假设我们在[`packagist.org`](https://packagist.org)上创建了一个帐户并成功登录,我们将首先点击**提交**按钮,这将使我们进入一个类似以下截图的屏幕: ![理解 Packagist](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_12_01.jpg) 在这里,我们需要提供我们现有的 Git、SVN 或 Mercurial(HG)存储库的链接。前面的示例提供了一个到 Git 存储库的链接([`github.com/ajzele/B05460_CatalogBundle`](https://github.com/ajzele/B05460_CatalogBundle))。在按下**检查**按钮之前,我们需要确保我们的存储库在其根目录中定义了一个`composer.json`文件,否则将会抛出类似以下截图中显示的错误。 ![理解 Packagist](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_12_03.jpg) 然后,我们将为我们的`CatalogBundle`创建`composer.json`文件,内容如下: ```php { "name": "foggyline/catalogbundle", "version" : "1.0.0", "type": "library", "description": "Just a test module for web shop application.", "keywords": [ "catalog" ], "homepage": "https://github.com/ajzele/B05460_CatalogBundle", "license": "MIT", "authors": [ { "name": "Branko Ajzele", "email": "ajzele@gmail.com", "homepage": "http://foggyline.net", "role": "Developer" } ], "minimum-stability": "dev", "prefer-stable": true, "autoload": { "psr-0": { "Foggyline\\CatalogBundle\\": "" } }, "target-dir": "Foggyline/CatalogBundle" } ``` 这里有很多属性,所有这些属性都在[`getcomposer.org/doc/04-schema.md`](https://getcomposer.org/doc/04-schema.md)页面上得到了充分的记录。 有了上述的`composer.json`文件,通过在控制台上运行`composer install`命令,将会在`vendor/foggyline/catalogbundle`目录下拉取代码,使得我们的 bundle 文件的完整路径为`vendor/foggyline/catalogbundle/Foggyline/CatalogBundle/FoggylineCatalogBundle.php`。 一旦我们将上述的`composer.json`文件添加到我们的 Git 存储库中,我们可以回到 Packagist,然后点击**Check**按钮,这将会出现一个类似下面截图的屏幕: ![理解 Packagist](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_12_04.jpg) 最后,当我们点击**Submit**按钮时,将会出现一个类似下面截图的屏幕: ![理解 Packagist](https://gitee.com/OpenDocCN/freelearn-php-zh/raw/master/docs/mdl-prog-php7/img/B05460_12_05.jpg) 我们的包现在已经添加到 Packagist 中,通过在控制台上运行以下命令,可以将其安装到项目中: ```php **composer require foggyline/catalogbundle:dev-master** ``` 同样,我们可以像下面的代码块中所示,向现有项目的`composer.json`文件中添加适当的条目: ```php { "require": { "foggyline/catalogbundle": "dev-master" }, } ``` 现在我们知道如何将应用程序分割成几个 Git 存储库和 Composer 包,我们需要对`src/Foggyline/`目录中剩余的模块做同样的操作,因为只有这些模块才会被注册为 Composer 包。 在`sales`模块开发过程中,我们注意到它依赖于其他几个模块,比如`catalog`和`customer`。我们可以使用`composer.json`文件的 require 属性来概述这种依赖关系。 一旦`src/Foggyline/`模块的所有 Git 存储库都更新了适当的`composer.json`定义,我们可以回到我们的核心应用程序存储库,并按照以下方式更新其`composer.json`文件中的`require`属性: ```php { "require": { // ... "foggyline/catalogbundle": "dev-master" "foggyline/customerbundle": "dev-master" "foggyline/paymentbundle": "dev-master" "foggyline/salesbundle": "dev-master" "foggyline/shipmentbundle": "dev-master" // ... }, } ``` 在这一点上,使用子模块和包之间的区别可能并不那么明显。然而,与子模块不同,包允许版本控制。尽管我们的所有包都是从`dev-master`中拉取的,但如果有的话,我们也可以轻松地针对特定版本的包进行定位。 # 总结 在本章中,我们快速了解了 Git 和 Composer 以及如何通过 GitHub 和 Packagist 集成和分发我们的模块。在 Packagist 下发布包已经被证明是一个相当简单和容易的过程。所需的只是一个指向版本控制系统存储库的公共链接和项目根目录下的`composer.json`文件定义。 从头开始编写我们自己的应用程序并不一定意味着我们需要使用 Git 子模块或 Composer 包,就像本章所介绍的那样。Symfony 应用程序本身通过 bundle 结构化地模块化。当在 Symfony 项目上使用版本控制系统时,应该只保存我们的代码,这意味着所有的 Symfony 库和其他依赖项都应该在项目设置时通过 Composer 拉取。本章中展示的例子仅仅展示了如果我们想要编写可与他人共享的模块化组件,我们可以实现什么。例如,如果我们真的在开发一个健壮的`catalog`模块,其他有兴趣编写自己的网店的人可能会发现有兴趣要求并在他们的项目中使用它。 这本书从研究当前的 PHP 生态系统开始。然后我们涉及设计模式和原则,作为专业编程的基础。然后我们开始为我们的网店应用编写一个简短、更加直观的规范。最后,我们将我们的应用程序分成核心部分和几个其他较小的模块,然后根据规范进行编码。在这个过程中,我们熟悉了一些最常用的 Symfony 功能。我们编写的整体应用程序远非健壮。它只是一个最简单形式的网店,在功能方面还有很多不足之处。然而,应用的概念展示了在 PHP 中编写模块化应用程序可以是多么简单和快速。
posted @ 2024-05-05 00:11  绝不原创的飞龙  阅读(20)  评论(0编辑  收藏  举报