PHP-Laravel-设计模式-全-

PHP Laravel 设计模式(全)

原文:Design Patterns in PHP and Laravel

协议:CC BY-NC-SA 4.0

一、Laravel 基础

如果你打算做 PHP 开发,你应该下载 Laravel。Symfony 和 Laravel 是世界上最流行和最好的 PHP 框架。要在您的机器上安装 Laravel,您可以遵循快速启动页面 1 上的说明。你将需要 PHP7 和 OpenSSL、PDO、Mbstring、Tokenizer 和 XML PHP。它还要求启用 PHP 版本 5.6.4 或更高版本。你知道怎么安装,对吧?太棒了。但是为了以防万一,在 Ubuntu 上,使用下面的代码。

安装 PHP 和一些依赖项

> sudo add-apt-repository ppa:ondrej/php
> sudo apt-get update
> sudo apt-get install php7.0 php7.0-curl php7.0-mcrypt

现在,要创建一个新的 laravel 应用,我们只需按照名为designpatterns的文件夹中的 https://laravel.com/docs/5.3/installation#installing-laravel 上提供给我们的说明进行操作。当您构建本书中的各种应用时,请查阅每一章的 git 分支,以便 git Jedis 可以跟踪。

接下来你要做的是看看作曲家。Laravel 是基于大约 20 个 Composer 包构建的;作曲是猫的喵(重要信息见图 1-1 )。

A435115_1_En_1_Fig1_HTML.jpg

图 1-1。

Meow

什么是作曲家?

Composer 是 PHP 的依赖管理工具。它允许您列出应用正常运行所依赖的包。项目根目录中的一个名为composer.json的文件允许大量的配置选项,所以让我们先浏览一下其中的一些。

Composer 做了几件巧妙的事情:

  • 包的依赖性管理

  • PSR 和基于自定义文件的自动加载

  • 编译器优化有助于代码运行更快

  • 定制挂钩到生命周期事件,例如应用的安装、更新或首次创建。

  • 稳定性检查

使用您最喜欢的文本编辑器,打开项目根目录中的composer.json文件。请注意,在本书中,文件名都是相对于项目根的。这里要明确的是,当我说项目根时,那意味着直接在你创建的designpatterns文件夹中,所以app/MODELS/User.php实际上是我机器上的路径/home/kelt/book/designpatterns/app/models/User.php

元信息

在 Composer 清单的第一部分,您可以看到基本的元信息。

composer.json

"name": "laravel/laravel",
"description": "The Laravel Framework.",
"keywords": ["framework", "laravel"],
"license": "MIT",

所有这些信息都被一个名为包装商 2 的网站所使用,该网站对外面的包裹进行分类。作为一个标准实践,如果你创建包来托管在 Packagist 上,你可能希望name与那个包的 GitHub 库相同。

依赖性管理

接下来你会看到一个require块。这就是包依赖管理发挥作用的地方。目前你只需要 Laravel 框架,它由许多其他的包组成;然而,随着时间的推移,你会添加额外的软件包。

composer.json

"require": {
        "php": ">=5.6.4",
        "laravel/framework": "5.3.*"
},

看起来很简单,对吧?不过这里有一个问题,你可能会看到一个∾4.1>=1.0,<1.1 | >=1.2.访问 https://getcomposer.org/doc/01-basic-usage.md#package-versions 解释不同版本的规则,阅读表 1-1 也是如此。

表 1-1。

Version Rules

| 名字 | 例子 | 描述 | | --- | --- | --- | | 精确版本 | `1.0.2` | 您可以指定软件包的确切版本。 | | 范围 | `>=1.0` | 通过使用比较运算符,您可以指定有效版本的范围。 | |   | `>=1.0,<2.0` | 有效的运算符是`>, >=, <, <=, !=.` | |   | `>=1.0,<1.1 | >=1.2` | 您可以定义由逗号分隔的多个范围,这些范围将被视为逻辑 AND。管道符号|将被视为逻辑或。并且具有比 OR 更高的优先级。 | | 通配符 | `1.0.*` | 您可以使用*通配符指定模式。`1.0.*`相当于`>=1.0,<1.1.` | | 波浪号运算符 | `∼1.2` | 对于遵循语义版本化的项目非常有用。`∼1.2`相当于`>=1.2,<2.0.` |

虽然这里没有显示,但是您可以通过使用require-dev.为只开发包添加一个映射,behat、phpspec 和 clockwork 都是只开发包的很好的候选者。

半自动的

前面我提到 Composer 附带了一个自动加载器,甚至优化了 PHP 以使其运行得更快。因为有了autoload部分,它知道如何做到这一点。

composer.json

"autoload": {
        "classmap": [
           "database"
        ],
        "psr-4":{
           "App\\": "app/"
        }
},

您也可以使用 PSR 自动加载。如果你从未听说过 PSR,请花点时间去 http://petermoulding.com/php/psr 。基本上,它处理 PHP 的文件夹结构、名称空间和类名的标准化。

Laravel 5 使用 psr-4 自动加载,不像它的前身 Laravel 4。如果您查看 composer.json 的内部,您会注意到这几行

"psr-4": {
  "App" : "app/"
}

这允许我们在另一个 php 文件中使用完全限定的名称空间use App\Services\FooService;来引用一个文件,比如app/Services/FooService.php

现在,您的应用将查看app文件夹,并为您自动加载该目录中的任何文件。很漂亮,对吧?

生命周期挂钩/脚本

下面是运行composer installcomposer updatecomposer create-project后执行的脚本列表。

composer.json

"scripts": {
        "post-root-package-install": [
           "php -r \"file_exists('.env') || copy('.env.example', '.env');\""
        ],
        "post-create-project-cmd": [
           "php artisan key:generate"
        ],
        "post-install-cmd": [
           "illuminate\\Foundation\\ComposerScripts::postInstall",
           "php artisan optimze"
        ],
        "post-update-cmd": [
           "illuminate\\Foundation\\ComposerScripts::postUpdate",
            "php artisan optimize"
        ],
},

如果您想在这里运行自定义命令,可以进入 composer 的某些事件。我使用这些钩子在服务器上执行composer install的任何时候自动运行迁移之类的东西。当您部署到生产服务器时,您只需遵循这个简单的两步流程:

  1. git pull

  2. composer install

您不必记得运行迁移或清理资产或其他任何事情,因为您是在composer install完成运行后才这么做的。

composer installcomposer update?有什么区别

运行composer update会做两件事。

  1. 它会将所有必需的软件包更新到匹配的最新版本。

  2. 它将使用依赖项的确切版本更新composer.lock

运行composer install将安装composer.lock中列出的依赖项。如果锁文件不存在,这个命令变得和composer update一样,因为它会在下载完依赖项后为您创建composer.lock文件。

我们为什么要这样做?

假设您在机器 1 上运行 Composer,然后在机器 2 上运行。您需要确保两台机器上的包完全相同。如果你运行composer update,包的版本可能会因机器而异。这可能会导致问题。假设您作为依赖项需要的特定包改变了一个特性。突然,机器 2 抛出了一个大的 fat 500 内部服务器错误,而机器 1 仍然工作正常。你总是想避免那种行为。

理想情况下,你希望你的productionstaging,和各种local环境尽可能相似。

您可以通过从.gitignore中删除vendor文件夹并提交所有内容来解决这个问题,但是还有一个更好的方法。锁文件已经有了特定的 github 提交散列,这不应该改变,您可以通过遵循以下基本原则来利用这一点:

A435115_1_En_1_Figa_HTML.jpg仅在您的本地开发机器上运行composer update。从未投入生产。

稳定性

现在您已经到达了您的composer.json文件的末尾。

composer.json

"config": {
        "preferred-install": "dist"
},

Composer 可以通过源代码或分布式 zip 文件获取您的依赖项。这个配置选项告诉 Composer 使用优先于源代码的分发文件。你可以在 https://getcomposer.org/doc/04-schema.md#config 了解更多配置选项。

minimum-稳定性标志用于防止其他包将其他包的不稳定版本安装到您的应用中。

  • 你需要 a 包。

  • 包 A 需要包 B@dev。

  • 作曲家会抱怨的。

因为您的稳定性被设置为stable,但是一个子包依赖于一个不太稳定的版本,所以会发生这样的问题。在这种情况下,包 A 依赖于包 b 的开发版本。

你会如何解决这个问题?在这个场景中,为了安装包 A,您需要显式地将包 B@dev 添加到require数组中。另一种解决方法是,将minimum-stability改为下面的一种:dev, alpha, beta, RC,或者stable

运行作曲家

您可以通过 http://getcomposer.org/ 下载作曲家。安装后,通过运行以下命令来验证您的安装

> composer -v

您可以使用 Composer 运行许多命令。一个例子是不用文本编辑器编辑您的composer.json,您可以运行 Composer 命令来要求依赖关系:

> composer require

另一个有用的 Composer 命令是validate。尽管我接受了所有的 JavaScript 培训,但我仍然设法留下了尾随逗号,这是无效的 JSON,所以在更改后验证您的composer.json文件是一个好习惯。

设置环境变量

Laravel 为您的应用提供了简单的环境变量管理。在您新创建的 laravel 应用中查找一个名为. env 的文件。该文件将包含如下内容:

APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_LOG_LEVEL=debug
APP_URL=http://localhost
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret
BROADCAST_DRIVER=log
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_DRIVER=smtp
MAIL_HOST=mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
PUSHER_APP_ID=
PUSHER_KEY=
PUSHER_SECRET=


这个。env 文件允许您使用相同的应用设置不同的服务器。你可以想象我们可能有一个不同的临时服务器。env 设置文件比我们的生产服务器。

更多信息可以在 dot env 文件这里找到: https://laravel.com/docs/5.3/configuration#environment-configuration

在这里,您可以随意试验不同的配置文件和环境。在 Laravel 中,Die 和 dump 对于快速调试非常有用。但是,它不能替代好的测试或 Xdebug。既然你已经在 Laravel 中学到了一些基础知识,让我们继续学习坚实的原则。

Footnotes 1

http://laravel.com/docs/quick

2

http://packagist.org

二、让我们构建坚实的基础

$> git checkout solid_design

2009 年,一款名为 Farmville 的游戏在脸书爆红。我妈妈上瘾了;我可能也是,但我太骄傲了,不愿承认。我们不会创造另一个 Farmville,但让我们做一些类似的事情。

在这一章中,你将浏览罗伯特·c·马丁在他的著作《面向对象的原则》中首次介绍的五个坚实的原则。在这个展示区中,您将创建一个数字花园。有一个农民种了一个花园。咿呀咿呀咿呀。

  $garden = new App\EmptyGarden(20, 30); // this is a pretty good size\

20x30' garden
  $items = $garden->items();    // no plants here, just handfuls of dirt\

单一责任原则

第一个坚实的原则是单一责任原则。它规定一个类应该有一个单一的职责。 1 一个好的实践是在注释文档块中列出类的职责。这样你可以提醒自己和其他人这门课的目的,并尽量把责任降到最低。

app/EmptyGarden.php

namespace App;

<?php

/**

 * @purpose

 *

 * Provides empty garden space full of dirt which can

 * grow and produce items.

 *

 */

class EmptyGarden

{
        private $width;
        private $height;

        public function __construct($width, $height)
         {
                 $this->width = $width;
                 $this->height = $height;
         }

        public function items()
         {
                 $numberOfSpots = ceil($this->width * $this->height);
                return array_fill(0, $numberOfSpots, 'handful of dirt');
         }
}

你可以把items()方法叫做harvest(),但是这意味着一个花园可以自己收获。一般来说,农民或收获者从我们的花园里采摘甜美的花蜜。花园只是占用空间,收集生长的东西。收获自己,意味着又多了一份责任。

A435115_1_En_2_Figa_HTML.jpg不要把单一责任原则走得太远。一个阶级经历的变化越少,所有这些原则就越不重要。如果harvest()永远不会真正改变,那么将harvest()添加到EmptyGarden类中是没问题的。

记住,使用原则是有代价的。如果维护一个较大的类比维护五个解耦的较小的类更容易,就不要对抗潮流。封装变化的内容,不去管其他的。另外,也不要碰左边 4 个死去的女巫。

开/关原则

$garden = new App\MarijuanaGarden(10, 10);
$garden->items();     // about a  day's worth for Seth Rogan

等一下。你为什么要创造一个 MarijuanaGarden?是因为狗吗(见图 2-1 )?将字符串Marijuana传递给Garden类不是更简单吗?从表面上看,这似乎更容易;当然要跟踪的班级更少了。不过,最终,沿着字符串路径前进会导致编写一些条件逻辑(if、else、switch ),并且每次想要添加新类型的花园时,都需要修改Garden类。

A435115_1_En_2_Fig1_HTML.jpg

图 2-1。

You were promised drugs, remember?

实线中的 O 代表开放/封闭原则,表示类应该对扩展开放,但对修改关闭。 2

如果每次出现一个新类型的花园,你都必须修改这个Garden类,那么你就违反了开放/封闭原则。items()方法根据花园的类型返回不同的项目。请注意,下面的方法允许您轻松地添加许多花园,而无需修改原始的Garden类:

app/大麻

class MarijuanaGarden extends EmptyGarden
{
         public function items()
         {
                  return array_fill(0, $this->width * $this->height, 'weed');
         }
}

利斯科夫替代原理

下一个坚实的原则被称为 Liskov 替换原则,考虑到 PHP 是一种鸭子类型的语言,这很难做到。它没有严格的变量类型。然而,PHP 确实有类型提示,所以让我们使用它。

程序中的对象应该可以用其子类型的实例替换,而不会改变程序的正确性。 3

如果当你创建一个EmptyGarden you的时候为你的宽度和高度传递一个字符串或者一个类或者负数会发生什么?

> new EmptyGarden("foo", -1);

那可不好!那会导致东西破裂。你想冒这个险吗?也许你可以在这里做一点重构。与其让你的花园依赖于widthheight,不如让它依赖于PlotArea。这可以是占据空间的某个区域的界面。

src/EmptyGarden.php

public function __construct(use App\PlotArea; $plot)
{
        $this->plot = $plot;
}

一个PlotArea长什么样?当然是接口啦!

应用/绘图.php

namespace App;

interface PlotArea

{

         public function totalNumberOfPlots();

}

A PlotArea告诉你在这个地块区域你有多少块地可以种植。一个圆形花园可能有 20 块地,半径为 10 英尺。一个长方形的花园可能有 40 块地,面积为 10×10 英尺。注意,它是一个接口,而不是一个具体的类。

你现在需要改变你的EmptyGardenMarijuanaGarden类。

app/EmptyGarden.php

public function __construct(PlotArea $plot)
{
         $this->plot = $plot;
}

public function items()
{
         $numberOfPlots = $this->plot->totalNumberOfPlots();
         return array_fill(0, $numberOfPlots, 'handful of dirt');
}

但是你现在怎么称呼你的EmptyGardenPlotArea不是类,是接口。如果创建一个实现了PlotAreaRectangleArea类会怎么样?

app/RectangleArea.php

namespace App;

use App\PlotArea;

class RectangleArea implements PlotArea
{
        private $width;

        private $height;

        public function __construct($width, $height)
        {
                $this->width = $width;
                $this->height = $height;
        }

        public function totalNumberOfPlots()
        {
                return ceil($this->width * $this->height / 2);
        }
}

现在你可以修改你的课程了。

php 工匠修补匠

$garden = newApp\EmptyGarden(new App\RectangleArea(10, 10))
$garden->items();

你有 50 把土!这么多脏东西!查理·布朗的猪圈会很骄傲的。他为什么这么脏?他的父母在哪里?他是如何避免葡萄球菌感染的?

破坏 LSP 的另一种方法是从同一个方法返回不同的类型。在像 Java 这样的强类型语言中,这不是一个大问题;在 PHP 中,这可能是个问题。

想象一下,如果您调用$garden->items();,您希望返回的类型是什么?一个数组,对吗?如果MarijuanaGarden返回一个字符串呢?这会很快变得一团糟!这也打破了利斯科夫替代原理。

不幸的是,如果你使用 php 5,那么你将无法在方法 4 上设置返回类型提示。好消息是,返回类型提示是 php 7 中的一个新特性!因此,您可以使用以下语法(见下文):

public function items() : array { ... }

如果你仍然在使用 php 5,那么你仍然可以使用 doc-blocks 和@return 注释。这对编译器没有任何帮助,但可以帮助其他开发人员查看您的代码。如果你使用的是 php 7,那么我绝对推荐使用类型提示。如果不小心的话,很容易通过在子类方法中返回不同的类型来破坏 PHP 中的 Listov 替换原则。

界面分离原理

如果发现某样东西有用,那就一定要改变,对吧?你想给你的花园增加更多的功能。花园种植植物。你把种子种在地里,经过一点努力和好运,这些种子会生长并结出果实。

app/GardenInterface.php

namespace App;

{
        public function grow($advanceNumberOfDays);
}

这很好。然而,花园也要施肥、浇水、除草,容易受到虫子和兔子等害虫的攻击,并取决于阳光和雨水等天气因素。对于一个班级来说,这是很大的责任。让我们看看如果你添加更多的方法会是什么样子。

app/GardenInterface.php

namespace App;

{
        public function grow($advanceNumberOfDays);
        public function weed($pickOutPercentage);
        public function pestAttack($attackFactor);
        public function water($inGallons);
        public function sunshine($radiationLevel);
        public function fertilize($type, $amount);

        ...
}

神圣的方法,蝙蝠侠!注意,随着这个接口变得越来越大,任何实现GardenInterface的具体类的责任也变得越来越大。这意味着单一责任原则也可能被违反。当你违反了五条原则中的一条,其他原则也有可能被违反。

坚实的原则和谐地一起工作。这里的主要问题是,当理想情况下你可以封装不同的行为时,你却把许多功能塞进了一个花园。在单个类中放入的功能越多,管理该类就越困难。

人们常常很容易陷入领域建模和考虑实体,以至于我们忘记了面向对象编程不仅仅是为“事物”创建对象封装行为是面向对象设计的一个强大部分。我们可能有一个叫做Cat的物体,它是一个真实的东西(名词)。此外,我们可能还有一个名为CatMeows的对象,它更像是一个动作动词而不是名词。如果你还不够困惑,只要记住不是每个物体都会拉屎。

另外,如果您只想使用grow()方法呢?你会实现所有这些其他的方法来得到那一个方法吗?您可以创建一个只是子接口集合的花园。

src/GardenInterface.php

interface GardenInterface implements GrowableInterface, WeedableInterface, ...
{
}

拥有一个由小界面组成的主界面当然更加灵活,因为它允许你从那些小界面中挑选,但是它不能解决你的花园变得越来越复杂的问题。最终,您将添加更多的类来解决这个问题。现在,您可以为EmptyGarden类创建空方法。

保持你的接口小遵循接口分离原则:许多特定于客户端的接口比一个通用接口更好。 5

从属倒置原则

DIP(依赖倒置原则)声明一个人应该依赖抽象而不是具体化。 6 这是什么意思?要回答这个问题,回想一下之前你为你的花园定义了一个PlotArea;如果你这样做了会怎么样:

app/Garden.php

public function __construct(RectangleArea $plot)
{
        $this->plot = $plot;
}

这将迫使你为每个花园使用一个矩形区域。如果你需要各种形状的花园,这根本行不通。因此,为了避免不灵活的问题,您使用抽象(PlotArea接口)来代替具体化(RectangleArea类)。依赖性反转的这一方面被称为依赖性注入,它是通过注入实现特定接口的类来实现的。

另一个发现违反依赖反转的可靠方法是当你开始在代码中看到new关键字时。想象一下,如果您刚刚在items()方法中创建了一个新的矩形区域。

app/Garden.php

public function items()
{
        $numberOfPlots = new RectangleArea; // oh no's!

        return array_fill(0, $numberOfPlots, 'handful of dirt');
}

在这个例子中,EmptyGarden类是一个高级类,并且依赖于一个低级类RectangleArea。看到new这个词了吗?

在应用中的某个时候,你可能会使用单词new来创建一个对象。这很好,但是这确实意味着这个类与另一个类耦合,并且产生了一个硬依赖。用new,创建对象没有错,但是在错误的地方这样做,会导致脆弱的耦合代码,更难测试和维护。我试图将我的new语句保存在更高级别的代码和工厂中(稍后你会学到更多关于工厂的知识)。

在软件工程中,有一句话:低耦合,高内聚。耦合是一个类对另一个类的依赖程度。内聚性是一个类中的元素属于一起的程度。

假设你的班级是一座孤岛;你会希望它内部一切正常。经济好,犯罪率低等。如果你的岛屿确实依赖其他国家,那么你希望保持最小化。为什么呢?想象一下,如果这些国家中的一个与你在脸书断绝关系,导致你的经济崩溃。现在你的人民在挨饿。不酷。绝对不酷。因此,依赖很少的外部国家是低耦合和高内聚的一个例子。最好的岛是自己运行良好,不会让依赖压倒它的岛。同样,低耦合,高内聚。

为了减轻内聚性和耦合性问题,遵循高级类不应该依赖于低级类的实践,反之亦然。相反,依赖于接口、抽象类等抽象概念。

如何从低级班中分辨出高级?

想象一个指挥音乐会的大师在指挥一堂高级课。低级班是乐队里演奏乐器的人。指挥正在指挥低年级的班级,结果是美妙的音乐。

然而,想象一下大师依赖于一个具体的东西:大号手鲍勃。如果鲍勃因流感生病,大师必须关闭大型音乐会。如果他不依赖鲍勃,而是依赖抽象:大号手。在《德进行曲》中,一个不同类型的大号手,但是今晚的表演被拯救了!大师不必担心是鲍勃,弗雷德,还是萨利吹大号,只要他们有资格吹大号。这是最完美的依赖注入。

需要注意的一点是:依赖倒置和依赖注入不是一回事。另一种实现依赖反转的方法是使用控制容器的反转。

app/master . PHP

class Maestro

{
        public function conduct($song)
        {
                $tubaPlayer = app()->make('tuba.player');
                $clarinetPlayer = app()->make('clarinet.player');

                foreach([$tubaPlayer, $clarinetPlayer] as $player)
                {
                        $player->play($song);
                }
        }
}

请注意,这里您不依赖于任何类型的类。相反,您让 app()->make()为您提供所需的大号播放器和单簧管播放器。它们很容易在服务容器中被替换掉。

app/Providers/PlayerServiceProvider.php

如果说 Composer 是 Laravel 的脊梁,那么服务容器就是大脑。在后面的章节中,你会学到更多关于 Laravel 服务容器的知识。

结论

下面是校长总结的一些小技巧。

单一责任原则

不要把所有的工人鸡蛋放在一个篮子里。一个类应该有一个改变的理由。

开/关原则

不要一遍又一遍的换同一个班。如果你发现这种情况发生,抽象出是什么在改变。

李斯托夫替代原理

在重写的子类方法中返回与父类方法相同的类型。这同样适用于方法的参数。保持一致。

界面分离原理

不要用很多(超过五个)方法创建接口。这是你在一个地方做太多事情的迹象。

从属倒置原则

依赖接口和抽象类多于具体类。这样会更灵活。

封装变化的内容

只抽象出应用中不同的东西。例如,如果一个Mailer类永远不会改变,不要纠结于写一大堆抽象的东西:关注什么会改变。

Footnotes 1

http://en.wikipedia.org/wiki/SOLID_(object-oriented_design)#Overview

2

en。维基百科。org/ wiki/ SOLID_(面向对象 _ 设计)#概述

3

http://en.wikipedia.org/wiki/SOLID_(object-oriented_design)#Overview

4

https://wiki.php.net/rfc/returntypehint2

5

http://en.wikipedia.org/wiki/SOLID_(object-oriented_design)#Overview

6

http://en.wikipedia.org/wiki/SOLID_(object-oriented_design)#Overview

三、抽象工厂

$> git checkout abstract_factory

目的

提供创建相关或依赖对象系列的接口,而无需指定它们的具体类。 1

应用

当您拥有一系列多少有些关联的对象时,您可以使用工厂来创建产品。有时你想创造产品的变体。产品可以是任何类别。可能是User, Airplane,也可能是House。在处理房地产时,商业不同于住宅。在这种情况下,您可以创建两个工厂:一个用于商业,一个用于住宅。住宅工厂可以生产房屋和土地等产品。商业工厂可以生产像商店建筑和停车场这样的产品。房地产客户仍然可以进行诸如出售、购买和列出地段、土地、房屋和商店建筑等操作。当你改变你的工厂时,你不必改变你的客户。

抽象结构

  • 这个类使用了抽象工厂。见图 3-1 。具体工厂可以作为参数传递给客户端的构造函数,或者客户端可以足够聪明地知道使用哪个具体工厂,而无需依赖注入。例如,客户端可能决定使用基于运行的操作系统(Windows、Linux 或 Mac)的系列。

    A435115_1_En_3_Fig1_HTML.jpg

    图 3-1。

    This is an abstract uml document of abstract factory. It’s all abstract!

  • AbstractFactory:这可以是具体工厂实现的接口或者抽象类。如果你使用一个抽象类,那么默认的行为可以内置到基础抽象类中。

  • ConcreteFactory (1 和 2):这些类从AbstractFactory继承,并为其具体的系列类型生成产品。图 3-1 中只显示了两个ConcreteFactories,但是如果需要可以有更多。

  • Product (A 和 B):这些是抽象类或接口,将由具体的产品类 A1、B1、A2、B2 等等来实现。

  • (A1/B1 和 A2/B2):这些类都属于一个家族。A1 和 B1 与 A2 和 B2 属于不同的家族,所以它们是由不同的工厂制造的。这些是您关心的类,并且以某种方式被客户端使用。

例子

您正在创建一个可以在 PG-13 或 R 级模式下运行的模拟器。游戏的名字叫花园忍者。植物生长在花园里,商人出售或消费这些植物。你可以创造各种各样的水果和蔬菜。你可以用多种方式出售农产品。商家的基本步骤是

  1. 种植一个花园。

  2. 卖农产品。

游戏的目标从未改变。改变的是基于玩家的产品的基本系列,它可以根据玩家的成熟程度而改变。

您将创建两个不同的花园商人家族:

  1. 毒品贩子

  2. 稻农

一个稻农将种植一个稻田。稻农不会像毒贩那样种植和销售大米。毒品贩子正在生产非法大麻,所以他的行动很可能是秘密的。

示例结构

图 3-2 为示例结构。

A435115_1_En_3_Fig2_HTML.jpg

图 3-2。

Put it all together and make a family of related objects. Rice farmers and drug dealers are related by a family of products (gardens and stores). Who knew, right?

履行

如果你想看这个作品,下载资源库并查看 branch abstract_factory.

你的模拟器会从一个随机的成熟度等级中创建一个新的商家,然后从这个商家身上赚钱。由于存在风险,毒品贩子比种植水稻的农民赚得更多。不是说大米没有风险什么的。

app/simulator.php

require __DIR__ . '/../vendor/autoload.php';

$ratings = array(
        'PG-13' => new GardenNinja\RatedPG13\RiceFarmer,
        'R' => new GardenNinja\RatedR\DrugDealer
);

$merchant = $ratings[array_rand($ratings)];

$client = new App\Client($merchant);

$client->run();

这里有很多缺失的部分。让我们从检查Client类开始。

class Client

{
        public function __construct(Merchant $merchant)
        {
                $this->merchant = $merchant
        }

        public function run()
        {
                print "Your merchant made $" . $this->merchant->makeMoney() . PHP_EOL;
        }
}

接下来,Merchant类充当这个例子中Client类使用的抽象工厂。

app/Merchant.php

<?php namespace App;

abstract class Merchant

{
        abstract public function createStore();
        abstract public function createGarden();

您依赖于您的混凝土工厂,它们实现Merchant来创建您的两种产品:商店和花园。所有的商人都试图赚钱,所以你把这个方法放在这个抽象类中。

app/Merchant.php

public function makeMoney()
{
        $makeMoneyMoneymakeMoneyMoney = 0;
        $store = $this->createStore();
        $items = $this->createGarden()->items();
        foreach ($items as $item) {
                $makeMoneyMoneymakeMoneyMoney += $store->price($item);
        }
        return $makeMoneyMoneymakeMoneyMoney;
}

如果你理解了RiceFarmer类的工作原理,你就应该理解DrugDealer类的工作原理。它们是相关的,因为它们都属于商人抽象家族。所以让我们来看看RiceFarmer类。

app/rate DPG 13/recrear . PHP

<?php namespace App\RatedPG13;

class RiceFarmer extends Merchant
{
        public function createStore()
        {
                return new RiceStore;
        }

        public function createGarden()
        {
                return new RiceGarden;
        }
}

很简单,对吧?RiceStore只是负责产品定价,而RiceGarden创造新的Rice商品供我们销售。然而,你可以在这里得到相当复杂的定价和多少项目返回到你的花园从各种外部因素。

当你运行php app/Simulator.php时,你会看到有时你的商家赚 20 美元,有时赚 300 美元。这完全取决于模拟器运行的随机等级以及传递给客户端的具体商家类型。

结论

您使用抽象工厂创建了一系列与Merchant相关的产品。你为什么做这些工作?在这个模拟中,您可以做一些条件语句并获得相同的结果。为什么要创建所有这些类?这对你有什么好处?

在这个例子中,你的类非常简单(RiceStoreRiceGarden)。在现实世界的例子中,这些类可能要复杂得多。您使用抽象工厂模式的模块化设计允许随着您添加额外的商家而增长。

在前面的章节中,您学习了如何封装变化。在您的模拟器中,您可以为其他蔬菜、大豆、草药和香料添加更多的商人类型。你甚至可以支持更疯狂的想法,比如糖果花园。这样做,你不会被迫编辑现有的职业,只会增加更多的商人类型到游戏中。

抽象工厂的一个缺点是对抽象Merchant类的任何改变都会渗透到所有的具体类中。这意味着你必须对你的应用的结构以及产品系列如何组合在一起进行长时间的思考。在您的示例应用中,按照成熟度等级对产品系列进行分组可能没有意义。

另一个缺点是,您可能会在使用这种设计上花费很多精力,如果事情需要大幅改变,重构可能会更加困难。在您的示例场景中,无疑有一种比使用抽象工厂设计模式更简单、更好的方法来构建这个应用,因为这个人为的示例相当简单。你能想到其他的缺点吗?

Footnotes 1

设计模式:可重用面向对象软件的元素,第 99 页

四、构建器

$> git checkout builder

目的

将复杂对象的构造与其表示分离,以便相同的构造过程可以创建不同的表示。 1

应用

构建器适合于创建复杂的产品。正如上一章所讨论的,产品可以是任何东西。所有的创造模式都集中在生产产品上。不过,有些产品天生就很复杂。因此,在这种情况下,您可以将构建过程委托给主管和构建者。稍后会有更多的介绍。生成器模式的一个真实例子是汽车的构造。装配线和工程师按照建造者模式生产成品:一辆汽车。当您想要对创建产品的许多步骤进行微调控制时,这是您的首选模式。

抽象结构

  • 这个类包含一组指令(一种算法)来控制构建器的动作。构建器的特定实例可以作为构造函数或参数传递给 director 类上的公共方法。见图 4-1 。在您的示例中,您将使用后一种方法。

    A435115_1_En_4_Fig1_HTML.jpg

    图 4-1。

    Bob the Builder says, “YES WE CAN!”

  • 这是一个抽象的基类或接口,它列出了可以用来构建产品的所有步骤。

  • ConcreteBuilder:这个类继承自抽象的Builder类,并保存创建产品的实际方法。根据需要,可以有许多不同的构建器。这个类将产生一个特别创建的Product

  • 这是一个复杂的物体,通常有许多螺母和螺栓或活动部件,不容易建造。它可能由许多不同的属性组成。

例子

有时候人们会制造复杂的东西。作为一个承包商的儿子,我是第一手见证人,盖房子不是一件小事。建设过程中要做大量的工作。幸运的是,有建筑师的蓝图来指导整个过程。这些蓝图是建筑师关于如何建造房子的说明清单。然而,两个不同的木匠阅读相同的蓝图会产生不同的结果。

几年前,我住在郊区的一栋三室两卫的房子里。在这种情况下,您将重建我的房子。这个Architect将扮演导演的角色,你将有一个NoviceCarpenterExpertCarpenter按照同一个建筑师的指示建造我的老房子。

示例结构

图 4-2 为结构示意图。

A435115_1_En_4_Fig2_HTML.jpg

图 4-2。

Building a house

履行

在这个场景中,Architect将是导演,指导不同的Carpenter(builder如何建造房子。你真正关心的产品是房子,但为了得到房子,你必须使用木匠,为了使它更容易,你也使用建筑师。

app/Architect.php

namespace App;

{
        public function createMyOldHouse(Carpenter $builder)
        {
                // house foundation

                $builder->outside(25, 13);

                // master bedroom

                $builder->sidewall(5, range(1, 9));
                $builder->wall(range(1, 5), 10);
                $builder->wall(range(2, 5), 5);
                $builder->door(5, 4, 'left bottom');
                $builder->door(1, 5, 'left bottom');
                $builder->door(5, 9, 'left bottom');

                // bathrooms

                $builder->sidewall(2, range(6, 9));

                // bedroom 2

                $builder->wall(range(8, 11), 10);
                $builder->wall(8, 7);
                $builder->wall(range(8, 11), 5);

                //

                //

                // lots of code omitted here for brevity

                //

                //

                $builder->label(8, 21, ' K');
                $builder->label(11, 22, ' U');
        }
}

Architect只是向构建器调用一些执行。builder接受这些执行并调整最终产品:??。你可以让Carpenter对此负责,但他已经有足够的精力去盖房子,而不用担心我在郊区的老房子。

让我们来看看Carpenter的方法。

app/Carpenter.php

namespace App;

{
        protected $house;

        public function __construct(House $house = null)
        {
                $this->house = $house ?: new House;
        }

        public function getHouse()
        {
                return $this->house;
        }

        public function outside($width, $height)
        {
                $this->house->layout = array_fill(0, $height, array_fill(0, $width, "   "));
                $this->topOutsideWall($width, $height);
                $this->leftOutsideWall($width, $height);
                $this->rightOutsideWall($width, $height);
                $this->bottomOutsideWall($width, $height);
        }
        abstract public function wall($rows, $columns, $wallType = 'left side');
        abstract public function sidewall($rows, $columns);
        abstract public function door($rows, $columns, $doorType = 'left entry');
        abstract public function blank($rows, $columns);
        abstract public function label($rows, $columns, $label);
        abstract public function topOutsideWall($width, $height);  
        abstract public function leftOutsideWall($width, $height);
        abstract public function rightOutsideWall($width, $height);
        abstract public function bottomOutsideWall($width, $height);

        protected function items($rows, $columns, $item)
        {
                // put the item where it needs to go inside the house

        }

        protected function assertInBounds($row, $column)
        {
                // make sure the requested row/column is inside of the house

        }
}

Carpenter正在扮演builder的角色。在这个应用中,有两种类型的Carpenters,它们的行为不同,即NoviceExpert。这些木匠会用导演给的同一套指令(你的Architect)造出不同的房子。如果你需要的话,在你的应用中添加更多的构建器(例如DrunkenCarpenter)会非常容易。再来看NoviceCarpenter

app/NoviceCarpenter.php

namespace App;

{
        public function wall($rows, $columns, $wallType = 'left side')
        {
                $this->items($rows, $columns, $this->wallChar($wallType));
        }

        public function sidewall($rows, $columns)
        {
                $this->items($rows, $columns, '--');
        }

        public function door($rows, $columns, $doorType = 'left entry')
        {
                $this->items($rows, $columns, $this->doorChar($doorType));
        }

        public function blank($rows, $columns)
        {
                $this->items($rows, $columns, '  ');
        }

        public function label($rows, $columns, $label)
        {
                $this->items($rows, $columns, $label);
        }

        public function topOutsideWall($width, $height)
        {
                $this->items(0, range(0, $width - 1), '--');
        }

        public function leftOutsideWall($width, $height)
        {
                $this->items(range(1, $height - 1), 0, '| ');
        }

        public function rightOutsideWall($width, $height)
        {
                $this->items(range(1, $height - 1), $width - 1, ' |');
        }

        public function bottomOutsideWall($width, $height)
        {
                $this->items($height - 1, range(0, $width - 1), '--');
                $this->items($height - 1, 0, '|-');
        }

        protected function wallChar($wallType)
        {
                // returns the correct wall character for this type

        }

        protected function doorChar($doorType)
        {
                // returns the correct door character for this type

        }
}

NoviceCarpenter实现了您的抽象方法,并使用了特定类型的材料,即|和–字符。ExpertCarpenter用更强壮的=和)角色建造东西,因为他更有经验。

最后,如果你运行你的模拟器,你会得到一些漂亮的我的旧家的布局的 ASCII 艺术。

app/simulator.php

require __DIR__ . '/../vendor/autoload.php';

$director = new App\Architect
$builder1 = new App\Architect
$builder2 = new App\Architect

$director->createMyOldHouse($builder1);
$director->createMyOldHouse($builder2);

print '-- Novice Carpenter --' . PHP_EOL;

print $builder1->getHouse();

print PHP_EOL . '-- Expert Carpenter --' . PHP_EOL;

print $builder2->getHouse();

$ php app/simulator.php
-- Novice Carpenter --
------------------------------       -------------------
|               Ba   |         \                       |
|           |--------|                                 |
|   MB      |        |                                 |
|           |   Ba   |                                 |
|   -----   | ------ |      LR              --      -- |
| --|                |                      | |        |
| --\  ---  --  -----|                      | |        |
|       \ | |   /|   |                      | |   K |  |
|           |        |     ----\     ----   | |     |  |
|   Br      |   Br   |      |    |          | |  -- |  |
|           |        |   \  |         |     | |  / U|  |
|------------------- |-\------------        | | ----| --
-- Expert Carpenter --
============================== ==================
=              Ba    )        \                 =
=          ) ________)                          =
=    MB    )         )                          =
=          )   Ba    )                          =
=    ____  ) ______  )        LR      __     __ =
=  __)                                ) )    )  =
=  __\___  __  ______                 ) )    )  =
=       (\ )  /)     )                ) )  K )  =
=         )          )      ____\ ____) )    )  =
=    Br   )    Br    )      ) )   )   ) )  __)  =
=         )          )      \ )   )   ) ) / U)  =
======================\   ============) ) ====)==

结论

这种模式对于可以为同一组指令创建不同的复杂对象的情况很有用。给猫剥皮的方法有很多,尽管我很想在这里放一张猫的照片,但我认为这对我的 Photoshop 技能来说太难了(见图 4-3 )。思考一下如何将算法与实际的猫剥皮部分分开。您可以使用构建器模式,最终得到不同的指令集(指导器)和不同的 cat 皮肤方法(构建器)。

A435115_1_En_4_Fig3_HTML.jpg

图 4-3。

Cat skinner blues

这种模式的一个缺点是,您的 director 与您的抽象构建器耦合在一起。如果建造者改变了,那么你的导演也必须改变。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 110 页

五、工厂方法

$> git checkout factory_method

目的

定义一个创建对象的接口,但是让子类决定实例化哪个类。工厂方法让类将实例化推迟到子类。 1

应用

当你在创造某种事物的变体时,你可以将这些变体分解成不同的产品类别。然而,这些类可能很难构造,所以您为每个产品创建附带的工厂。工厂可以用来替换或重构类构造函数,这样产品类构造函数中就不存在逻辑了。这种模式不同于抽象工厂,因为您不是在创建产品系列。事实上,抽象工厂可以由许多不同的工厂方法组成。

抽象结构

  • 抽象类,作为所有具体创建者的接口。这将可能包含所有/大多数具体创作者使用的共享功能。如果工厂对每种类型的产品都没有区别,那么这可以成为一个具体的创造者本身,而不再是抽象的。见图 5-1 。

    A435115_1_En_5_Fig1_HTML.jpg

    图 5-1。

    The factory method uses a concrete subclass to create a concrete product.

  • ConcreteCreator:当一个具体产品的创建有不同的创建逻辑时,那么你就用一个专门针对ConcreteProduct.的创建者来覆盖Creator基类

  • Product:所有ConcreteProducts使用的抽象类或接口。

  • ConcreteProduct:这是Product的变种。它包含特定于其变体的逻辑。这个物体是由一个ConcreteCreator创造的。

例子

你将种植植物。花园的类型产生不同类型的植物。如果你的工厂是一个大麻园,那么它创造了大麻植物。你的花园工厂生产的产品是植物,但具体的植物类型是大麻。一个菜园可能生产玉米、南瓜和土豆作为产品。菜园是工厂接口的另一种具体类型。玉米、南瓜和土豆是你的产品界面的具体类型。希望这有意义。

示例结构

图 5-2 为结构示意图。

A435115_1_En_5_Fig2_HTML.jpg

图 5-2。

Please don’t send the FBI to my house.

履行

这是你的模拟器,可以让你的花园成长。一旦一个花园被种植,你可以迭代返回的植物并消费它们。请注意,您可以用不同的花园工厂替换掉大麻花园,模拟器的其余部分可以不加修改地运行。现在,你会坚持吸毒。

app/simulator.php

$garden = new App\MarijuanaGarden
$plants = $garden->grow();

foreach ($plants as $plant) {
        $plant->consume();
}

如果你想知道大麻花园是什么样子,我不会在这里给你看一张照片。不如去上课吧?

app/MarijuanaGarden.php

namespace App;

{
        public function harvest()
        {
                return [new MarijuanaPlant, new MarijuanaPlant]

        }
}

注意你正在扩展Garden类。这可能是一个接口,但你将从抽象 garden 类继承一些基本功能,如下所示。植物也会死,就像人一样。这真的很令人难过,但它总是发生。在你的花园里,总有一株植物死去。请不要问我为什么。

app/Garden.php

namespace App;

{
        abstract public function harvest();

        public function grow()
        {
                $items = $this->harvest();

                // one plant died, oh noes!!!

                $died = array_shift($items);

                return $items;
        }
}

接下来,你应该看看大麻植物长什么样。同样,这里没有你的照片;无法让该死的摄像机工作。

app/MarijuanaPlant.php

namespace App;

{
        public function consume()
        {
                print "you now have a strong hunger for a bag of Bugles" . PHP_EOL;
        }
}

结论

工厂方法模式使得以后引入其他类型的植物变得容易。植物的不同变化和组合很容易用你的工厂来建造。主要的要点是抽象出一个类的困难的构造过程。

您可能想知道工厂方法模式与抽象工厂模式有何不同。抽象工厂用于创建产品系列(有时非常不同),工厂方法实际上关注的是创建单一的不同产品。抽象工厂经常使用工厂方法。

这种模式的一个缺点是,有时对于您想要做的事情来说,它可能是多余的。工厂方法的一个更简化/淡化的版本叫做简单工厂,我将在后面的章节中讨论它。工厂非常有用,尤其是在与领域驱动设计结合使用时。在重逻辑的应用中,工厂将是你的盟友。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 121 页

六、原型

$> git checkout prototype

目的

使用原型实例指定要创建的对象种类,并通过复制该原型来创建新对象。 1

应用

当您想要派生和修改现有对象时,请使用原型模式。这种模式的一个很好的用途是,当您希望避免构建一个需要花费大量时间或者创建起来很复杂的类时。创建成本高的对象的一个例子是使用 web 服务获取数据的对象。但是,一旦有了数据,就不再需要从 web 服务获取数据;你只需克隆数据。还有另一种称为代理的模式,它也是这里描述的 web 服务示例的一个很好的候选。很快您就会看到,您也可以使用这种模式来克隆复杂的对象。

抽象结构

图 6-1 为结构示意图。

  • Client:使用Prototype类。这可以是一个类或脚本本身。在这个例子中,您甚至不用担心客户端。

  • 这是一个抽象类,其他类可以扩展。但是,如果只有一个ConcretePrototype,它不一定是抽象的。clone方法用于复制类的内部结构,这样你就可以创建一个具有相同内部结构的新对象。

  • ConcretePrototype (1/2):这些类从Prototype类扩展而来,可以为原型的每个变体提供额外的方法。如果原型没有变化,你可以把它合并成Prototype

A435115_1_En_6_Fig1_HTML.jpg

图 6-1。

Clone, clone, everywhere a clone

例子

1996 年 7 月 5 日,世界永远地改变了,那一天,第一只由成年体细胞克隆而成的哺乳动物绵羊多利诞生了。新生的多莉之所以被命名为多莉,是因为她是用乳腺克隆的,而歌手多莉·帕顿在这方面尤其臭名昭著。多莉死于攻击其储存系统的逆转录病毒。肺部问题实际上在克隆体中很常见。这就是为什么你要在模拟器中跟踪每只羊的肺部。

示例结构

注意在图 6-2 中,你没有在这个例子中创建绵羊的变体;所以ConcretePrototype1变成了Prototype

A435115_1_En_6_Fig2_HTML.jpg

图 6-2。

Example prototype structure

履行

您将使用 PHP 的本机内置克隆机制来应用原型模式。这不是实现原型模式的必要条件,因为您可以创建自己的克隆方法;然而,以我的非专业观点来看,了解当地人要容易得多,也更酷。

假装你有羊…

app/Sheep.php

namespace App;

{
        public $name = "Big Momma";
}

现在你已经证实了羊的存在,我感到非常兴奋,因为我有一些非常糟糕的关于羊的笑话要告诉你。 2

  • 你怎么称呼一只裹着巧克力的羊?糖果咩。

  • 如果你把一只愤怒的绵羊和一头喜怒无常的母牛杂交,你会得到什么?一只哞哞叫的动物。

  • 墨西哥的羊怎么说圣诞快乐?抓绒纳维达!

  • 织一件毛衣需要多少只羊?别傻了。羊不会织!

  • 绵羊在哪里剪羊毛?在咩咩商店!

好吧,我希望我们喜欢这些笑话。现在我已经从我的系统中获得了这些,下一步是构建一个模拟器,为您创建和管理绵羊。

src/模拟器

$sheep = new App\Sheep;
$dolly = $sheep;
$dolly->name = "Dolly Parton";
var_dump($sheep, $dolly);

你的模拟器应该会给你吐出$sheep$dolly的名字。你知道Dolly Parton$dolly;的名字,但是,在这种情况下,你认为出发$sheep的名字是什么?

模拟器输出

class Sheep
#2 (2) {

  public $name =>
  string(5) "Dolly Parton"
 }

class Sheep
#2 (2) {

  public $name =>
  string(5) "Dolly Parton"
}

哦哦。看起来$sheep->name不再是Big Momma.了,如果你习惯于面向对象编程,并且知道内存指针是如何工作的,那么这可能对你来说并不奇怪。不过,在本例中,您不需要担心 sheep 对象中的数据是 baaaaaaa-ad。你可能已经注意到这两个对象都指向绵羊#2。这告诉你$sheep$dolly都指向内存中完全相同的地址。在 PHP 中,当一个对象被设置为等于另一个对象时,那么两个对象都引用内存中的同一个地址空间。见图 6-3 。

A435115_1_En_6_Fig3_HTML.jpg

图 6-3。

We are the same object, boss!

如果你想让它们有不同的内存地址,你应该克隆绵羊,这正是你要做的。

src/模拟器

$dolly = clone $sheep;

这利用了另一个内存槽,并将$name变量复制到新的内存地址。现在当你更新$dolly的名字时,它不会影响$sheep$name,因为它在内存中使用了一个完全不同的地址。见图 6-4 。

A435115_1_En_6_Fig4_HTML.jpg

图 6-4。

Cloning at its finest

如果您再次运行模拟器,您可以看到输出正是您想要的。

模拟器输出

class Sheep
#2 (2) {

  public $name =>
  string(5) "Big Momma"
 }

class Sheep
#4 (2) {

  public $name =>
  string(5) "Dolly Parton"
}

暂时一切都好;然而,这里您还没有真正实现原型模式。下一件要做的事情是修改 sheep 类,至少添加一个复合类。

app/simulator.php

$sheep = new App\Sheep(new App\Lungs);

$dolly = clone $sheep;
$dolly->name = "Dolly Parton";
$dolly->applyVirus('JaagsiekteVirus');
var_dump($sheep, $dolly);

在你运行你的模拟器之前,你应该给 sheep Lungs(一个新的类),并且给Sheep类添加applyVirus方法。这种方法会损害羊的肺部。就像真人快打角色的健康指示器一样,肺也有一个健康指示器。健康范围从 0 到 100 %;当一只新绵羊出生时,肺部是 100%健康的。使用JaagsiekteVirus后,肺部健康达到 20%。

app/Sheep.php

namespace App;

{
        public function __construct(Lungs $lungs)
        {
                $this->name = "Big Momma";
                $this->lungs = $lungs;
        }

        public function appyVirus($virusType)
        {
                $this->lungs->health(20);
        }
}

现在,当您运行模拟器时,您会得到以下输出:

模拟器输出

class Sheep
#2 (2) {

  public $name =>
  string(9) "Big Momma"
  public $lungs =>
  class Lungs
#4 (1) {

    protected  $health =>
    int(20)
  }
}

class Sheep
#3 (2) {

  public  $name =>
  string(12) "Dolly Parton"
  public  $lungs =>
  class Lungs
#4 (1) {

    protected  $health =>
    int(20)
  }
}

哦,又来了。你克隆了绵羊,但克隆的只是原始内部变量的肤浅拷贝。这意味着在您的模拟中,两只不同名称的绵羊共享同一套肺。这是不应该发生的,所以你需要强制进行深度克隆,并在你的羊被克隆的任何时候创造不同的肺。要解决这个问题,您可以使用神奇的clone方法。

app/Sheep.php

public function __clone()
{
        $this->lungs = clone $this->lungs;
}

现在,只要一只羊被克隆,它们的肺也会被克隆。这使得羊和肺对象不能共享内存地址。克隆的缺点是使用了更多的内存地址空间,所以除非确实需要,否则不要克隆对象。再次运行模拟器会显示正确的输出。

模拟器输出

class Sheep
#2 (2) {

  public $name =>
  string(9) "Big Momma"
  public $lungs =>
  class Lungs
#4 (1) {

    protected  $health =>
    int(100)
  }
}

class Sheep
#3 (2) {

  public  $name =>
  string(12) "Dolly Parton"
  public  $lungs =>
  class Lungs
#5 (1) {

    protected  $health =>
    int(20)
  }
}

这就完成了原型模式。该模式的重点是将任何对象引用复制到它们的外部内存地址空间,这样您就可以彼此独立地使用这些对象。在您的示例中,Sheep并不复杂,但是您可以添加许多变量来测量模拟器中动物的稳定性和健康状况。

这里为什么不用简单的工厂呢?这个原型模式看起来需要做很多额外的工作,不是吗?你可以用一个简单的工厂来创造第一只原羊。然而,如果你通过应用一些病毒对绵羊进行内部改造,你会得到一只看起来不同的绵羊。如果你想开始克隆那只经过改造的羊呢?这就是原型模式优于简单工厂的地方。

app/simulator.php

$sickSheep1 = clone $sheep;
$sickSheep1->applyVirus();
$sickSheep2 = clone $sickSheep1;

$sickSheep1->applyMedicine('Medicine 1');
$sickSheep2->applyMedicine('Medicine 1');

// compare the health of two sick sheep...

结论

在本例中,您使用克隆技术轻松地复制了绵羊。在这个例子中,Sheep对象非常简单,但是在现实生活的模拟器中,Sheep对象可能有许多变量和数据与之相关联。创造新的绵羊不应该是乏味的,你实际上很少关心这一部分。你更关心改造一只羊,看看它抵抗病毒的能力有多强。你当然不希望 new sheep 的构造方面压倒了你模拟器中的逻辑。

原型模式的一个缺点是您很容易违反单一责任原则。一个已经存在的类有一个责任,克隆只是给那个类增加了另一个责任。这是次要的,考虑到你现在可以非常容易地克隆对象。

这里没有看到的另一个缺点是,当使用原型模式时,您可能会以不同的方法结束不同的ConcretePrototypes。这使得管理新克隆的对象变得困难。例如,想象一下,如果你有两只具体类型的羊,分别叫做WoolySheepMilkingSheep。它们有不同的方法:MilkingSheep有一个方法叫gotMilk()WoolySheep有一个方法叫gotWool()。现在客户必须知道你在和哪种类型的羊打交道。如果客户端没有跟上,那么方法gotMilk()可能会在WoolySheep上被调用,这将抛出一个错误。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 133 页

2

http://jokes4us.com/animaljokes/sheepjokes.html

七、单例

$> git checkout singleton

目的

确保一个类只有一个实例,并提供对它的全局访问点。 1

应用

简而言之:没有。老实说,我还没有发现 PHP 中单例模式的任何实际应用。最初的单例设计模式是为了确保一个类只能创建一个实例。它被用在后台打印程序的上下文中,在那里您只需要创建一个实例。开箱即用,PHP 是一个单线程环境,通常在 apache/nginx 线程中的生命周期很短。即使你使用了类似于React PHP2的东西,你也不能使用阻塞操作(sleep, file_get_contents, global),所以在你的应用中,你不需要担心限制对某个类的单个实例的全局访问,因为即使在 React 中也会导致死锁。

因此,你将会学到一个变体,我称之为简化的单例。这些是您在 Laravel 服务容器中注册的单例,或者如果您没有使用 Laravel,您可以在某个 IoC 容器中注册它们。然而,如果你对如何实现单例模式感兴趣,让我们先看一下,然后通过展示用于单例模式的 Laravel 服务容器来结束这一章。

抽象结构

图 7-1 为结构示意图。

  • Singleton:注意对构造函数的protected final限制。构造函数只能从类本身内部调用。如果你还不存在,怎么可能调用一个方法来创造你自己?这是一个鸡和蛋的问题。不过,在这种情况下,通过使用静态方法instance(),您可以创建一个新的 singleton 实例,它存储在protected $instance变量中。

A435115_1_En_7_Fig1_HTML.jpg

图 7-1。

The singleton pattern

例子

在本例中,您将制作一个计数器,它会在每次发出请求时递增。您将尝试两种方式:使用单例模式和简单单例模式。作为额外的奖励,您将创建具有 PHP 特性的 singleton 模式,称为 trait 3 。为什么呢?因为你可以。

履行

首先:您需要创建一个对请求进行计数的类。

app/RequestCounter.php

namespace App;

class RequestCounter

{
        private $numberOfRequestsMade = 0;

        public function numberOfRequestsMade()
        {
                return $this->numberOfRequestsMade;
        }
        public function makeRequest()
        {
                $this->numberOfRequestsMade++;
        }
}

接下来,我们来谈谈特质。什么是特质?如果你已经知道这一点,你可以跳过这一部分。当我第一次了解特质时,我非常努力,到处都在使用它们,不管是左还是右。我很疯狂。我花了几个星期才意识到我在滥用特质。在 PHP 中,我们使用特征作为混合功能的方式。在 PHP 中,我们不能扩展多个类(也称为多重继承)。特质是绕过这种限制的一种方式。然而,没有多重继承并不是一件坏事。如果不增加从多个类继承的能力,常规的经典继承已经足够困难了。有时我们有一小部分不属于任何特定类的功能,但是我们想添加到许多不同的类中;特征在这方面非常有用。不要像我一样对特质着迷。不要沉醉于特质力量,否则几周后你会发现自己患有严重的特质宿醉头痛。

您将创建一个名为SingletonPattern的特征,它可以被添加到任何类中,以便将它变成单例。我之前说过,你可能永远不会使用单例模式,所以你可以把它作为一个练习,看看特征的酷因素,并学习一个新的模式。

app/SingletonPattern.php

namespace App;

trait SingletonPattern
{
        static protected $instance;

        final protected function __construct()
        {
                // no one but ourselves can create ourselves

        }

        static public function instance()
        {
                if (! static::$instance) {

                    static::$instance = new static;
                }
                return static::$instance;
        }
}

现在让我们利用这个特性。您将创建一个新类,它从RequestCounter扩展而来,并使用您的SingletonPattern特征。

app/requestcountersingleton . PHP

namespace App;

class RequestCounterSingleton extends RequestCounter
{
        use SingletonPattern;
}

在这里等一下。为什么要创建这样一个新的类?原因很简单,而且是双重的。

  1. 您希望能够对您的RequestCounter类进行单元测试。测试一个单体比一个普通的 ol' PHP 类更困难。

  2. 稍后您将在 Laravel 服务容器中使用RequestCounter。服务容器会为你处理单例的东西,在这种情况下,你不需要这个SingletonPattern特征。

至此,您已经准备好使用您的 singleton 了。同样,这只是一个如何通过 traits 实现单例模式的例子。这样做实际上没有任何可行性;这纯粹是一种学习特质的教育尝试。

app/simulator.php

App\RequestCounterSingleton::instance()->makeRequest();
App\RequestCounterSingleton::instance()->makeRequest();
App\RequestCounterSingleton::instance()->makeRequest();

// Singleton request hits: 3

print 'Singleton request hits: ' . RequestCounterSingleton::instance\
()->numberOfRequestsMade() . PHP_EOL;

现在让我们看看如何使用 Laravel 服务容器实现一个简单的 singleton。

app/simulator.php

app()->instance('request.counter', new App\RequestCounter);
app()->make('request.counter')->makeRequest();
app()->make('request.counter')->makeRequest();
app()->make('request.counter')->makeRequest();
app()->make('request.counter')->makeRequest();
app()->make('request.counter')->makeRequest();

// Simple singleton request hits: 5

print 'Simple singleton request hits: ' . app('request.counter')
->numberOfRequestsMade() . PHP_EOL;

每次调用app()->make(),都是重用同一个RequestCounter类。它这样做只是因为您使用了app()->instance(),这使得 Laravel 将'request.counter'作为单例处理。您也可以使用服务容器,这样每次调用app()->make()时,它都会创建一个新的RequestCounter。如果你不想让'request.counter'成为单例,你可以创建一个绑定而不是一个实例,如下面的代码所示。

绑定示例(非单例)

app()->bind('request.counter', function ($app) {
  return new RequestCounter;
});

在这一点上,你可能想知道为什么你要为一个单例使用服务容器而不是仅仅使用一个全局变量。事实上,服务容器是在bootstrap/app.php的第 14 行创建的一个全局变量。那么为什么不使用全局变量呢?为什么要做额外的工作?

引导程序. app

$app = new Illuminate\Foundation\Application(
    realpath(__DIR__.'/../')
);

答案是,通过使用服务容器,您已经将所有客户端从RequestCounter类中分离出来。您可以在任何时候用具有相同接口的其他类替换掉RequestCounter,理论上您的应用的其余部分将继续运行。你可以通过替换掉app()->instance('request.counter', new SomeOtherRequestCounter).来做到这一点,这是你的框架库中一个非常灵活和强大的东西。

结论

您了解了单例模式和修改后的简单单例模式。您在 Laravel 和 traits 中介绍了服务容器。虽然您可能不会使用 singleton 模式,但您可能会在某个时候发现自己出于各种原因在 Laravel 服务容器中使用 singleton,包括性能增强或跨系统共享数据。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 144 页

2

http://reactphp.org

3

http://php.net/manual/en/language.oop5.traits.php

八、简单工厂方法

$> git checkout simple_factory_method

简单工厂不是你在最初的 90 年代四人组设计模式书中找到的设计模式。然而,这是一种非常有用的创建对象的方法——非常有用,以至于我专门为它写了一章。

目的

简单工厂方法简化了创建新的具体对象的过程。

应用

使用一个简单的工厂可以使代码更干净,更容易处理。当您想要创建一个具有依赖关系的对象时,可以应用这种模式。这种模式可以用来重构代码中创建产品的地方。有人可能会说这就是我们之前讨论过的工厂方法模式。在某种程度上,这可能是真的。不同之处在于,我们不会为多种产品创建多个工厂。我们只创建一个可以生产各种产品的工厂。

抽象结构

  • Factory:这个类为你创建了Product(见图 8-1 )。有时由于依赖关系,一个产品需要几行代码来创建;如果您在整个应用中都这样做,这就为 bug 潜入您的代码打开了大门。这也是一种将Product的创建与所有使用它的客户端分离的方法。你可以稍后在一个地方替换掉Product:??。

    A435115_1_En_8_Fig1_HTML.jpg

    图 8-1。

    Factory creates a shiny new product for you

  • Product:这个类可以由几个依赖项组成:Subclass1Subclass2Subclass3等…

示例场景

您将重构下面的代码以使用一个简单的工厂。

一些人为的例子

$bar = new Bar('test', 123);
$baz = new Baz;
$foo = new Foo($bar, $baz);

履行

如果你发现自己一遍又一遍地做着同样的代码,那么把它抽象成一个工厂会使事情变得更容易。

另一个人为的例子

$foo = Foo::factory();
  // or perhaps using Laravel's service container

$foo = app()->make('foo');

让我们看看如何用简单的 PHP 创建这个工厂方法。然后,您将把它工作到 Laravel 服务容器中。

app/Foo.php

namespace App;

class Foo

{
        public function __construct(Bar $bar, Baz $baz)
        {
                $this->bar = $bar;
                $this->baz = $baz;
        }
        static public function factory(Bar $bar = null, Baz $baz = null)
        {
                $bar = $bar ?: new Bar('test', 123);
                $baz = $baz ?: new Baz;
                return new Foo($bar, $baz);
        }
}

这里发生了什么事?基本上,您已经将创建一个新的Foo对象的代码移动到这个工厂方法中。现在,如果您选择这样做,您可以覆盖依赖项,但是依赖项是默认硬连接的。

在工匠作坊里修修补补

> Foo::factory()

// object(Foo)(

//   'bar' => object(Bar)(

//     'var1' => 'test',

//     'var2' => 123

//   ),

//   'baz' => object(Baz)(

//

//   )

// )

也许你不喜欢在你的Foo课堂上无所事事的factory方法。这是一个好迹象,表明您应该在其他地方重构代码。让我们专门为此创建一个Factory类。

app/Factory.php

namespace App;

class Factory

{
        static public function foo(Bar $bar = null, Baz $baz = null)
        {
                $bar = $bar ?: new Bar('test', 123);
                $baz = $baz ?: new Baz;

                return new Foo($bar, $baz);
         }
}

App\Factory::foo();运行 tinker 将产生与之前类似的结果。

您可能会注意到您一直在使用静态方法。许多工厂方法是静态的,因为这样调用它们更容易。你不会想先创建一个新工厂,然后调用foo方法。如果一步就能完成,为什么要分两步做呢?测试在这里也不是问题。你不会嘲笑或单元测试这个工厂方法;那样做你不会有太大收获。您想要测试的真正代码在工厂正在创建的类内部。

结论

简单的工厂方法清理了类,并将具有多个依赖关系的复杂对象的创建放在一个地方。

值得注意的是,Laravel 服务容器足够智能,可以自动为您注入依赖项,所以尽可能利用这一点。

服务容器自动依赖解析

class First

{
}

class Second

{
  protected $first;
  public function __construct(First $first)
  {
    $this->first = $first;
  }
}

class Third

{
  protected $second;
  public function __construct(Second $second)
  {
    $this->second = $second;
  }
}

$third = app()->make('Third'); // this works!

在这个例子中,Laravel 服务容器会自动将一个Second对象注入到Third对象中,同样,它会将一个First对象解析为Second对象。这允许您创建干净且可测试的代码,因为您的所有依赖项都被注入到构造函数中。当你测试Third类时,如果你愿意,你可以注入一个模拟的Second类,这使得测试变得更加容易,因为你可以专注于Third的公共方法(它的接口)。

此时,您应该知道简单工厂模式和工厂方法模式之间的区别。不过,为了确保万无一失,我还是要再说一遍:工厂方法模式使用子类来创建产品类的不同变体。简单工厂模式是一个简单的助手函数,它取代了关键字new,并清理了具有多个依赖关系的更复杂对象的创建。

九、适配器

$> git checkout adapter

目的

将一个类的接口转换成客户端期望的另一个接口。适配器允许类一起工作,否则由于不兼容的接口而无法工作。 1

应用

有时你想使用现有的代码,但接口并不符合你的需要。例如,您希望利用供应商/外部代码,而无需重写所有现有代码。这类似于在圆孔中安装一个方栓。只要钉子足够小,方形的钉子可以放入任何圆孔中。你应该注意去适应什么是需要的,因为你添加到你的适配器接口的方法越多,你的方钉在你的圆孔里就变得越大。

你可能已经注意到我在最后一段强调了存在。这是因为我想强调适配器模式的主要目的/意图:处理现有代码。想象一下,把欧洲所有的电源插座从 220 伏换成美国的 110 伏标准是多么的不可能。当然,如果你是从零开始给房子布线,你可能会用 110 伏的电压,但我们不是从零开始。欧洲有数百万套公寓、酒店和住宅,多得无法重构。这是适配器的亮点。使用适配器可以让我们将现有的(和经过验证的)系统保持在适当的位置。我们不改变系统;我们只需要担心适配器,这要容易得多,尤其是当两个系统相互兼容的时候。

您已经知道了适配器模式,因为您在现实生活中使用过适配器。你的智能手机插入一个 USB 适配器,该适配器插入一个 110 伏的墙上插座。您的电脑显示器插入 HDMI-to-DVI 适配器或 Thunderbolt for Mac 用户。电源适配器将汽车的点烟器转换成可以给手机充电的东西。

重申一下,在开始新代码时,您可能不会使用适配器模式。它的真正好处来自于修改已经确定的现有代码。好了,说说打聋马(图 9-1 )。我现在就不说什么时候使用适配器模式了。

A435115_1_En_9_Fig1_HTML.jpg

图 9-1。

Mr. Ed would be proud to call you his son… HORSE PRIDE!

抽象结构

  • Client:这些是期望一个Target类的类(图 9-2 )。因为您在这里处理的是一个现有的系统,Client实际上可能不仅仅是一个类。

    A435115_1_En_9_Fig2_HTML.jpg

    图 9-2。

    Adapter pattern

  • Target:这是客户端希望看到的界面。理想情况下,这是一个抽象类或接口。但是,如果它是一个常规类,那么Adapter仍然可以扩展这个类并覆盖所有的公共方法。

  • Adapter:这个类将扩展或实现Target。它的方法与Target的方法相匹配,通常是Adaptee方法的包装。例如,在图 9-2 ,Adapter::someMethod()调用Adaptee::differentMethod.

  • 这是你试图用一个Adapter包装的类。这个类通常是您希望引入到现有应用中的供应商、包或遗留代码。也可能是你写的代码,但是你害怕接触它,因为它很旧,没有单元测试,但是已经被证明是有效的,因为它正在应用中使用。无论如何,我们的目标是把这个Adaptee代码和

示例场景

你有现有的系统,邮寄信件到一个地址。这个系统已经被证明是有用的,现在楼上的头面人物想把它集成到你公司的客户关系管理(CRM)数据库中,这个数据库里有很多很多的客户地址。您的工作是添加从公司 CRM 数据库向客户发送邮件的功能。

示例结构

图 9-3 为结构示意图。

A435115_1_En_9_Fig3_HTML.jpg

图 9-3。

Mail system using the adapter pattern

履行

你可以跑进去,拿着枪,尝试重构你的MailClient或者甚至创建一个新的MailClient,但是MailClient只是这个难题的一小部分,让我们假设(虽然这个例子没有显示出来)许多事情都依赖于MailClient来工作,所以创建一个新的需要一段时间来构建。此外,您不希望接触 CRM 代码,在本例中,它应该被视为非常类似于您从软件包中获得的供应商代码。

让我们看看 CRM 代码,它已经由另一个团队为您提供了。

app/CRM/Address.php

<?php namespace CRM;

class Address

{
        private $primaryAddress, $secondaryAddress, $city, $state, $zipCode;
        public function __construct($primaryAddress, $secondaryAddress, $ci\ ty, $state, $zipCode)
        {
                $this->primaryAddress = $primaryAddress;
                $this->secondaryAddress = $secondaryAddress;
                $this->city = $city;
                $this->state = $state;
                $this->zipCode = $zipCode;
        }
        public function getFullAddress()
        {
                return $this->primaryAddress . PHP_EOL
                        . ($this->secondaryAddress ? $this->secondaryAddress . PHP_'')
                        . $this->city . ', ' . $this->state . ' '
                        . $this->zipCode . PHP_EOL;
        }
        public function getPrimaryAddress()
        {
                return $this->primaryAddress;
        }
        public function getSecondaryAddress()
        {
                return $this->secondaryAddress;
        }
        public function getCity()
        {
                return $this->city;
        }
        public function getState()
        {
                return $this->state;
        }
        public function getZipCode()
        {
                return $this->zipCode;
        }
        public function setPrimaryAddress($primaryAddress)
        {
                $this->primaryAddress = $primaryAddress;
        }
        public function setSecondaryAddress($secondaryAddress)
        {
                $this->secondaryAddress = $secondaryAddress;
        }
        public function setCity($city)
        {
                $this->city = $city;
        }
        public function setState($state)
        {
                $this->state = $state;
        }
        public function setZipCode($zipCode)
        {
                $this->zipCode = $zipCode;
        }
}

这是一个非常简单的类。我把它说得很简单,但实际上你可能会有一些奇怪的代码被塞进一个巨大的类中,这个类有一些名为doStuff的超级大方法,没有注释。您甚至可能有多个需要适应的类。我们将在后面的Facade章节中讨论这个问题,但是现在让我们只关注这里的一个简单的Address。接下来,让我们看看你的Address类,并将其与CRMAddress进行比较。

app/Address.php

namespace App;

interface Address
{
        public function to();
        public function address1();
        public function address2();
        public function city();
        public function region();
        public function postalCode();
        public function __toString();
}

所以你可能注意到的第一件事是你甚至没有一个Address类;它是一个接口。公共方法(接口)肯定和CRM\Address里面的方法不匹配。我可以给你看另一个类,它实际上实现了。然而,这无关紧要,因为您只打算使用这个接口。同样,您的适配器将实现Address接口。

app/CRMAddressAdapter.php

namespace App;

class CRMAddressAdapter implements Address
{
        protected $to, $Address;
        public function __construct($name, App\CRM\Address)
        {
                $this->address = $address;
                $this->to = $name;
        }
        public function to()
        {
                return $this->to;
        }
        public function address1()
        {
                return $this->address->getPrimaryAddress();
        }
        public function address2()
        {
                return $this->address->getSecondaryAddress();
        }
        public function city()
        {
                return $this->address->getCity();
        }
        public function region()
        {
                return $this->address->getState();
        }
        public function postalCode()
        {
                return $this->address->getZipCode();
        }
        public function __toString()
        {
                return $this->to . PHP_EOL . $this->address->getFullAddress();
        }
}

下面是模拟器代码,它将所有这些拼图拼在了一起。这不是你在上面的 UML 模式中看到的Client类;这里的Client其实就是MailClient。模拟器只是运行所有不同的代码。请注意MailClient是如何依赖Address的。

app/simulator.php

$crmAddress = with(new App\CRM\AddressLookup)->findByTelephone('555 867-\
5309');
$address = new App\CRMAddressAdapter('Jenny Call', $crmAddress);
$mailClient = new App\MailClient;
$mailClient->sendLetter($address, 'Hello there, this is the body of \
the letter');

我在这里不介绍MailClient,但基本上它向一个Address发送消息。我没有展示MailClient类,因为它确实与适配器模式没有任何关系。你可以在这个模拟器中看到,你将你的CRMAddress改编成了Address,并将其传递给了MailClient。如果您在这个例子中从头开始编写MailClient,那么跳过适配器模式,只编写依赖于CRMAddressMailClient会更有意义。希望您现在理解了如何使用适配器模式。

结论

适配器模式也称为包装器模式,因为它将现有的接口包装在客户端期望的接口内。当您没有现有代码时,您可能会发现适配器模式的用途,但最有可能的是,这种模式将用于现有代码的情况。

一个缺点是,两个类可能真的很难适应这么多的方法。这可能会导致适配器部分损坏,如果客户端希望使用目标接口公开的所有方法,那么当客户端调用与适配器完全不兼容的方法时,这可能会导致问题。尽管目标和适配者有不兼容的接口,但这两者很可能是相关的。事实上,如果客户的适配器和目标没有任何共同之处,为什么还要为它们进行调整呢?

使用适配器模式,通过将Ambulance类包装在垃圾车适配器类中,可以使救护车看起来像垃圾车(图 9-4 )。这是有用的还是最好的方法取决于具体情况。救护车是用来在紧急情况下把人送到医院的。垃圾车被用来运送垃圾到荒地。他们有完全不同的目的和目标。然而,适配器不必是完美的;他们只需要在客户期望的目标接口上实现所有公共方法调用。因此,如果在这种情况下,你的Garbage卡车等级的唯一方法是pickupTrash,那么尽管救护车将成为一辆可怕的垃圾车,但没有什么可以阻止你将垃圾倾倒在这辆医疗车内,并将其运往垃圾填埋场。

A435115_1_En_9_Fig4_HTML.jpg

图 9-4。

Adapter pattern

将两个不相关的类用于不同的目的可能会很困难,但这并不是不可能的。当简单的重构可以工作,或者当您可以创建与解决您的问题更相关的新类时,请谨慎创建适配器。例如,在您的场景示例中,如果MailClientAddress只在整个应用中的一两个地方使用,那么重写一个新的MailClient来使用CRMAddress并丢弃Address会更容易。长话短说,编写适配器来避免大规模重构。

有些人混淆了适配器模式和策略模式。我还没有谈到策略模式,但是总的来说,这两种模式都使用了复合。事实上,很多模式都使用组合,因为当一个变化,比如一个新的特性请求,稍后出现时,组合更灵活,更容易处理。我在本书的前几章谈到了组合,使用组合比使用继承更好。策略模式是关于使用组合来切换算法,而适配器模式使用组合来变形/修改现有的接口。与适配器模式相混淆的还有桥接模式。我将在下一章介绍桥的模式。这两种模式看起来相似,但意图不同。一些模式在代码方面看起来非常相似,但是意图是不同的。我将在下一章的结尾介绍这些不同之处。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 157 页

十、桥接

$> git checkout bridge

目的

将抽象与其实现解耦,这样两者可以独立变化。 1

应用

从实现中分离抽象意味着什么?为了展示一个这样的例子,让我们假设你想发送一条消息。A message是一个抽象概念。然而,有许多不同的方式可以发送信息。你可以用电子邮件发送;把它作为一封信邮寄出去;在屋顶上大声说出来;把它贴到脸书、推特或其他网站上;把它放在瓶子里,扔进大海;或者在飞机后面挂横幅。见鬼,甚至还有一个向 外太空 2 提交短信的网站。这些都是Message抽象的可能实现。

抽象结构

  • Abstraction是使用来自ImplementermethodA的接口或类。见图 10-1 。

    A435115_1_En_10_Fig1_HTML.jpg

    图 10-1。

    Bridge pattern

  • RefinedAbstractionmethodB的一个实际实现。有时当使用不同的实现者时,你需要稍微改变你的抽象。稍后您将在 MySQL 和 PostgreSQL 连接的例子中看到这一点。

  • Implementer是真正的实现者 A 和 b 的接口。

  • ImplementerA / B满足实现者接口。他们都做methodA,但方式略有不同。这个方法是在抽象层的保护下使用的。

例子

在这个例子中,您将看到 Laravel 的模式构建器的幕后,并演示它对桥模式的使用。Laravel 的 schema builder 允许您在数据库中创建新的表和列,并在以后回滚它们。我将在命令模式一章中详细介绍这一点,但现在让我们问这个问题:如何在 MySQL 中构建一个表?PostgreSQL 呢?微软 SQL 或者 SQLite 怎么样?这些都是您想要支持的数据库,但是如果您仔细想想,您会发现有以下几个部分:

  1. 抽象:构建一个表

  2. 实现:用于在 MySQL、SQLite、PostgreSQL 等中构建表的语法。

您可以为 MySQL、SQLite 和 PostgreSQL 创建一个构建器,但是您可能会以这种方式编写大量重复的代码。构建者可以关注如何创建表模式、删除表或检查表是否存在。从一个数据库到另一个数据库的解耦语法允许您获得细粒度的数据库 SQL 语句,并且还可以全面重用 builder 类。此外,您还将涵盖上述消息示例。

示例结构

图 10-2 为结构示意图。

A435115_1_En_10_Fig2_HTML.jpg

图 10-2。

Concrete example

履行

这些都是 Laravel 中相当大的类,所以您将只关注每个类中的一小部分代码,这样您就可以看到运行中的构建器模式。我们来看一下hasTable方法。这个方法告诉您$table是否已经存在于您的数据库中。构建器抽象依赖于语法来编译 SQL,以确定表是否存在。

vendor/laravel/framework/src/Illuminate/Database/Schema/builder . PHP

49   public function hasTable($table)
50   {
51           $sql = $this->grammar->compileTableExists();
52
53           $table = $this->connection->getTablePrefix().$table;
54
55           return count($this->connection->select($sql, [$table])) > 0;
56   }

方法hasTable首先编译特定于数据库的 SQL 文本,确定一个表是否存在,接下来它查找带有可选全局配置前缀的表名,最后它返回对 SQL 代码运行select的结果。让我们来看看compileTableExists的语法。您将使用 MySQL 版本,但是每个数据库都有自己的方法实现。

vendor/laravel/framework/src/Illuminate/Database/Schema/Grammars/MySQL grammar . PHP

33   public function compileTableExists()
34   {
35           return 'select * from information_schema.tables where table_schema \
36   = ? and table_name = ?';

37   }

这个简单的 SQL 字符串就是如何确定一个表在 MySQL 中是否存在。如果构建器是使用 MySQL 作为连接驱动程序配置的,那么在检查表是否存在时会使用这个语法。顺便说一下,如果你想知道 Laravel 是如何知道使用 MySQL 作为驱动程序的,这可以在config/database.php中看到。我可以在构建器中检查其他方法,但它们或多或少是相同的。事实上,构建器还使用一个名为Blueprint的类来帮助一些在表上创建列的常规方法和其他各种操作。如果你愿意的话,可以随意钻研那些代码,但是现在我将把注意力转移到另一个类上。

您可能没有注意到这一点,但是请注意在compileTableExists方法中实际上有两个?查询参数。然而,在hasTable方法中,只有一个查询参数被传递给$this->connection->select($sql, [$table])。这是如何工作的?不应该;它会出错。因此,MySQL 的Builder抽象需要改进。输入MysqlBuilder

vendor/laravel/framework/src/Illuminate/Database/Schema/MySQL builder . PHP

13   public function hasTable($table)
14   {
15           $sql = $this->grammar->compileTableExists();
16
17           $database = $this->connection->getDatabaseName();
18
19           $table = $this->connection->getTablePrefix().$table;
20
21           return count($this->connection->select($sql, [$database, $table])) > 0;
22   }

显然,要检查 MySQL 中是否存在表,您需要数据库名和表名;因此需要对Builder中的hasTable方法进行一些改进,以处理这种微妙的差异。事实上,MysqlBuilder只覆盖了 Builder 中的两个方法;它没有触及其他方法。

第二个示例(发送消息)

瓶子里的信息不仅仅是一首警察歌曲,它也是一种有效但低效的传递信息的方式。前面我谈到了将消息抽象从其实现中分离出来。所以让我们设计一个系统来发送消息。请注意下面的类名列表。看看本章开头的抽象 UML,看看你是否能把它们放在正确的地方。

  • Carrier

  • Email

  • OceanBottle

  • Messenger

  • PlainMessenger

现在看看下面的类。你会如何使用桥接模式?抽象是什么类,实现者是谁?桥在哪里?

  • Carrier (Implementer)

  • Email (ImplementerA)

  • OceanBottle (ImplementerB)

  • Messenger (Abstraction)

  • PlainMessenger (RefinedAbstraction)

你试图用各种载体抽象出信使。由于Carrier是实现者,它将保存如何发送特定类型消息的逻辑。

app/Carriers/Carrier.php

namespace App\Carriers;

interface Carrier
{
        public function sendMessage($message);
}

EmailOceanBottle都是Carrier的具体实现。让我们来看看他们两个。请注意,他们只是吐出了消息。在现实世界的例子中,电子邮件运营商将连接到MailChimp或一些服务来发送您的消息。OceanBottle号航母会触发某种机器把你的信息打印在纸上,然后把折叠好的纸放进瓶子里,扔进太平洋。

app/Carriers/Email.php

namespace App\Carriers;

class Email implements Carrier
{
        public function sendMessage($message)
        {
                echo 'EMAIL: '. $message . PHP_EOL;
        }
}

app/Carriers/OceanBottle.php

namespace App\Carriers;

class OceanBottle implements Carrier 

{
        public function sendMessage($message)
        {
                echo 'OCEAN BOTTLE: ' . $message . PHP_EOL;
        }
}

现在您已经知道了消息是如何发送的,让我们来看看Messenger类。这种抽象是为了什么?记住,桥模式的目标是将抽象和实现分开。发送信息的想法与实际发送信息的细节是不同的。仔细想想,发送电子邮件的步骤与发送短信或邮寄信件的步骤非常相似。

  1. 把信息放到媒体上。

  2. 为媒介提供载体。

这并不是说媒介和载体不会改变,因为它们肯定会改变。电子邮件使用数字媒介和互联网作为载体。信件使用纸张作为媒介和邮件载体。当你说你的信息时,你的媒介是空气,你的载体是麦克风或者无线电广播。无论如何,这些步骤保持不变,因此当你抽象出Messenger类时,它使用载体和媒介来处理实际细节的两步过程。在本例中,为简单起见,您将介质和载体结合在一起。

app/Messengers/Messenger.php

namespace App\Messengers;

class Messenger

{
        protected $carrier;

        public function __construct(use App\Carriers\Carrier; $carrier)
        {
                $this->carrier = $carrier;
        }

        public function send($message)
        {
                $message = $this->correctMisspellings($message);
                $this->carrier->sendMessage($message);
        }

        // pretend like you are correcting mispellings

        protected function correctMispellings($message)
        {
                return str_replace('Helo', 'Hello', $message);

        }
}

请注意,messenger 会尝试纠正您的拼写错误。纠正拼写错误不是运营商的工作,但你可以看到,如果你没有将信使与运营商分开,这可能会被破坏在一起。但是,如果您不想担心拼写检查呢?很像前面的MysqlBuilderBuilder的精炼版本,您将精炼您的Messenger抽象并创建一个PlainMessenger

{title="src/Messengers/PlainMessenger.php", lang=php}

PlainMessenger扩展了Messenger { public function send($message) { return $this->Carrier->sendMessage($message); } } ∼∼∼∼∼∼∼∼

当你处理文本消息类型时,你可能想使用PlainMessenger。人们喜欢发表情符号和短词,所以拼写检查不太重要,实际上可能会让发短信的用户感到困惑。在模拟器中,让我们看看在处理短信载体时如何使用不同的信使。

app/Messengers/plain messenger . PHP

$message = "Helo world!";

$emailMessenger = new App\Messengers\Messenger(new App\Carriers\Email;
$snailMessenger = new Messengers\Messenger(new App\Carriers\SnailMail('PO Box 123, Somewhere, NY, 12345'));
$textMessenger = new App\Messengers\PlainMessenger(new App\Carriers\TextMessage ('123.456.7890'));

$emailMessenger->send($message);
$snailMessenger->send($message);
$textMessenger->send($message);

结论

您使用桥接模式将模式构建器与底层数据库分离。作为一个简单的例子,您还使用了桥模式来构建消息传递应用的主干。

那么什么时候应该使用这种模式呢?您可能已经注意到,将抽象和实现分成两个不同的类会有一些开销。在简单的情况下,使用桥模式可能会矫枉过正,并且可能会给本来简单的问题增加巨大的混乱。然而,如果你正在计划一个可扩展的和灵活的生态系统(例如框架),并且桥模式看起来很适合,它可能是。在上面的例子中,您可以轻松地添加新的信使和承运人。挺灵活的,每节课都很专注。另一方面,您可以构建一个大型的Emailer类,它将发送消息和拼写检查合二为一。

因为您没有将抽象和实现分开,所以对于团队中的新成员来说,内聚且更大的Emailer类可能更容易理解。假如你从来没有实现一个不同的消息载体,这种方法是没有错的。在某种程度上,避免抽象和实现之间的永久绑定可以给你带来很大的灵活性。

您的Messenger抽象使用组合来调用来自Carrier实现者的方法。虽然你还没有学会策略模式,但它有相似的结构,也使用了复合。事实上,您学习的许多模式都使用了组合(因为它很糟糕),表面上看,这些模式可能类似。请记住,这都是关于意图。桥的目的是保持抽象和实现非常松散的耦合。适配器模式的目的是在两个不兼容的类之间充当中间人。策略模式的目的是封装算法。因此,如果你发现自己在思考两种模式之间的差异,写下它们的意图,你可能会回答你自己的问题。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 171 页

2

www.talk2ets.com

3

www.youtube.com/watch?v=MbXWrmQW-OE

十一、组合

$> git checkout composite

目的

将对象组成树结构来表示部分-整体层次结构。Composite 允许客户端统一处理单个对象和对象的组合。 1

应用

生活中的很多东西都有等级结构:家谱、关系、组织、语言、自然、军事、政府、地址、工作场所、游戏、文件结构等等。有时候,事情在层级中更容易思考。想象一下,试着只用名字来解释你的家谱。等级制度允许我们把一个复杂的系统分解成相关的部分。然后,我们可以内省整棵树,或者只内省部分树,这对我们有利。

等级制度自然被认为是树。这是因为树木从底部开始,分支,并在某一特定的叶子处结束。叶子的父节点是分枝,它的父节点可能是另一个分枝。最后,事物在树的底部结束。但是,我不希望您认为,仅仅因为我们按照层次结构对事物进行排序,就意味着我们有一个组合模式。注意,在 intent 中还有第二句话:Composite 让客户机一致地对待单个对象和对象的组合。这意味着我们可以调用$cat->methodName()$cats->methodName(),我们不必担心$cat是一个单独的叶子对象,而$cats是一个由许多对象的叶子组成的分支。

抽象结构

  • Component:这可以是一个接口,也可以是一个抽象类,任何叶子或者复合都可以扩展(图 11-1 )。如果存在所有子类都能受益的方法,则使用抽象类;否则,简单的接口就足够了。

    A435115_1_En_11_Fig1_HTML.jpg

    图 11-1。

    The composite pattern

  • Leaf:叶子是原始的Component对象。它没有孩子。

  • Composite:复合是作为孩子的Components的集合。在简单的情况下,你可以将孩子作为数组存储在Composite类中。像Leaf一样,它是一个Component,因此必须实现method

例子

如果你已经建立了一些网站,那么你可能已经处理过下拉菜单。如果你运气不好,你有这样一个客户,他想要一个带有很多链接的层次菜单的大菜单。如果不管菜单有多少级,你都能一视同仁,那会怎么样?你只是把它们打印出来,对吗?所以如果你能说下面的话呢?

把菜单打印出来

$megaMenu->print();

// or

$simpleMenu->print();

// or

$someLink->print();

示例结构

图 11-2 为结构示意图。

A435115_1_En_11_Fig2_HTML.jpg

图 11-2。

Composite pattern for menus

履行

您正在构建的是一种动态输出菜单的方式。不过,首先我要提醒您,将 HTML 放在 PHP 类中很难维护,有一种更好的方法:使用局部 Laravel 视图。然而,因为这个例子纯粹是为了说明组合模式,所以我在这些类中塞进了一些难看的 HTML。无论如何,这个示例的目标是构建一些菜单,以便您可以像这样轻松地输出它们:

app/simulator.php

$menulink1 = new App\MenuLink('google', 'http://google.com');
$menulink2 = new App\MenuLink('facebook', 'http://facebook.com');
$menulink3 = new App\MenuLink('kelt', 'http://keltdockins.com');
$menuitem1 = new App\MenuItem('some text');

$megaMenu = new App\MenuCollection;
$subMenu1 = new App\MenuCollection;
$subMenu2 = new App\MenuCollection;
$subMenu3 = new App\MenuCollection;

$megaMenu->add($subMenu1);
$megaMenu->add($subMenu2);

$subMenu1->add($menulink1);
$subMenu1->add($menulink2);
$subMenu2->add($menulink3);
$subMenu2->add($subMenu3);
$subMenu3->add($menuitem1);

print '<!-- printing entire mega menu -->' . PHP_EOL; $megaMenu->output();

print PHP_EOL . '<!-- printing submenu only -->' . PHP_EOL; $subMenu1->output();

print PHP_EOL . '<!-- printing menuitem1 only -->' . PHP_EOL; $menuitem1->output();

这个模拟的输出是什么?它输出 HTML,就像我之前说的。

模拟器输出

<!-- printing entire mega menu -->
<div class="sub-menu level0">
    <div class="sub-menu level1">
        <a title="google" href="http://google.com">google</a>
        <a title="facebook" href="http://facebook.com">facebook</a>
    </div>
    <div class="sub-menu level1">
        <a title="kelt" href="http://keltdockins.com">kelt</a>
        <div class="sub-menu level2">
            some text
        </div>
    </div>
</div>

<!-- printing submenu only -->
<div class="sub-menu level0">
    <a title="google" href="http://google.com">google</a>
    <a title="facebook" href="http://facebook.com">facebook</a>
</div>

<!-- printing menuitem1 only -->
some text

很漂亮,对吧?它让菜单的制作变得轻而易举。你也可以添加不同类型的菜单,比如一个MenuButton或者MenuLinkWithImage。不过,我有点言过其实了。你甚至还没有看到上面模拟中的类。它从Menu开始。

app/Menu.php

namespace App;

interface Menu
{
        public function output($level = 0);
}

其余的类扩展了Menu,并且必须实现output方法。接下来,让我们来考察一下叫做MenuLink的叶子。

app/MenuLink.php

namespace App;

class MenuLink implements Menu
{
        public function __construct($name, $url)
        {
                $this->name = $name;
                $this->url = $url;
        }
        public function output($level = 0)
        {
                print str_repeat(' ', $level * 4);
                print "<a title=\"{$this->name}\" href=\"{$this->url}\">{$this->name}</a>"   . PHP_EOL;
        }
}

正如您可能知道的,这个类只是处理打印带有 URL 和名称的锚 HTML 标签。很简单,对吧?但是,如果你没有这个MenuLink的网址呢?您可以在这里放入一个if语句,但这意味着您正在向一个方法添加逻辑。有没有另一种方法来处理这个问题,这样你就不必在你的output方法中添加一个if语句了?再来一片叶子怎么样?称它为MenuItem

app/MenuItem.php 类型

namespace App;

class MenuItem implements Menu
{
        public function __construct($name)
        {
                $this->name = $name;
        }

        public function output($level = 0)

        {
                print str_repeat(' ', $level * 4);
                print "{$this->name}" . PHP_EOL;
        }
}

不要急于创建if语句。记住:方法中的条件越多,方法的内部就越难理解。当然,在这里和那里添加一些if / else不会让你感到困惑,但是我把条件句当成友敌。你必须利用条件,但他们一有机会就会在背后捅你一刀,所以你周围的条件越少越好。除非你喜欢被人在背后捅刀子,在这种情况下,if该起来了!总之,您通过使用不同的叶类解决了上面的问题。现在让我们来看看你的组合,隐藏在阴影中,伪装成你的层次结构中的另一片叶子。

app/MenuCollection.php

namespace App;

class MenuCollection implements Menu
{
        protected $children = [];

        public function add(Menu $menu)
        {
                $this->children[] = $menu;
        }
        public function output($level = 0)
        {
                print str_repeat(' ', $level * 4);
                print "<div class=\"sub-menu level{$level}\">". PHP_EOL;

                foreach ($this->children as $child){
                   $child->output($level + 1);
                }

                print str_repeat(' ', $level * 4);
                print "</div>" . PHP_EOL;
        }
}

这个集合处理output方法的方式略有不同。它调用它的直接子节点,当这些子节点也是MenuCollections时,它们的子节点被调用。请注意,您可以向您的MenuCollection添加实现Menu的任何类型的类。最终,这意味着您可以在集合中嵌套链接、项目和集合。

在我结束这一章之前,考虑一下:如果你想向后(向上)遍历这棵树呢?所以如果你启动一个子菜单,你想知道它的父菜单,父菜单的父菜单,等等。,现在这是一条单行道,从顶级巨型菜单开始。您可以用跟踪子节点的类似方式来保存对每个子节点的父节点的引用。这完全取决于您的应用的业务需求。如果决定存储父引用,一定要决定是希望对象只有一个父引用,还是允许多个父引用。当你冒险进入这个领域时,你可能会遇到除了组合模式之外的其他模式,这完全没问题,勇敢的灵魂,继续冒险吧!

结论

在本章中,您使用了组合模式来输出菜单的层次结构。我想重申,简单地创建类的层次结构并不意味着它就是组合模式。当单个对象和对象集合以相同的方式处理时,可以看到组合模式。Composite 允许你命令一只猫去meow(),同样也可以命令一群猫去meow()。它让你output一个菜单集合,就像一个菜单链接一样。老实说,如果你没有一个叶子,并且你正在使用一个单一的复合类,你也可以称它为组合模式。我不会向警方告发你的,我保证。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 183 页

十二、装饰器

$> git checkout decorator

目的

动态地将附加责任附加到对象上。Decorators 为扩展功能提供了子类化的灵活替代方案。 1

应用

装饰器扩展功能。装饰器和单纯的继承有什么不同?经典继承在运行时之前扩展了类的功能。装饰器在运行时扩展实时对象的功能。装饰器包装了一个真实的对象,让我们能够在运行时改变对象的行为。谈论运行时和 PHP 可能看起来很奇怪,因为 PHP 是一种中断式语言,而不是传统的编译语言(如 C++,Java ),但这个原则仍然适用。随着代码的执行,你在用 decorators 动态地改变行为。

作为改变行为的一个例子,假设你有一个Duck类,它可以quack()Duck在放射性池塘中游泳并获得超能力。你可以用一个SuperPower装饰器把Duck包起来,这将使它能够superQuack(),击倒那些敢于挑战鸭子优势的坏蛋和火鸡。

抽象结构

  • Component:作为ConcreteComponent的父类的抽象类或接口。如果组件只有一种变体,它可能不需要抽象。见图 12-1 。

    A435115_1_En_12_Fig1_HTML.jpg

    图 12-1。

    Who knew decorating was so much fun?

  • ConcreteComponent:你可以有Component的多种具体变化。它充当子类,并将成为被Decorator包装的实际对象。

  • 这个抽象类充当所有具体装饰器的基类。这个类很可能与Component共享同一个公共接口,并使用一个构造函数,该构造函数使用一个Component来包装。被调用的Decorator公共方法通常只是为Component代理同一个公共方法。

  • ConcreteDecoratorA / ConcreteDecoratorB:这些具体的装饰器可以覆盖基类Decorator中的方法,并利用通过构造函数注入的包装好的ConcreteComponent对象。除了覆盖现有方法,还可以添加其他补充方法。

例子

你在构建一个有怪物的游戏。每个怪物都有固定的力量(STR),智力(INT),速度(SPE)。你将在怪物周围包裹装饰器,以在运行时修改它们的能力分数。虽然可能有更好的方法来处理大量的怪物(比如 flyweight 模式),但是您不必担心这里的内存使用;您希望严格关注在运行时添加职责。在战斗中,怪物可以变形并适应战斗,获得(和失去)能力。你不想为所有这些可能性构建不同的Monster类;相反,您使用装饰器。

我将通过讨论演讲者来结束这一章,我称之为装饰器模式的一个微小变化。

示例结构

图 12-2 为结构示意图。

A435115_1_En_12_Fig2_HTML.jpg

图 12-2。

Monster Mania@!#$#!

履行

怪物有智力,力量和速度分数。您构建了一个庞大的类来保存这些值。

app/Monster.php

namespace App;

abstract class Monster

{
        public function intelligence() { return 1; }
        public function strength() { return 1; }
        public function speed() { return 1; }
}

你需要一个真正的怪物。我知道没有怪物、圣诞老人或者怪物圣诞老人 2 这样的东西,但怪物存在于电子游戏中。让我们选一个最笨的怪物:僵尸。经典僵尸没那么可怕。你可以从僵尸身边走开,它永远也抓不到你。流行文化电影和游戏,如《Z 世界大战》和《死路四条》,已经将这些原本可笑、缓慢、愚蠢的怪物变成了快速、强大、可怕的泰坦 3 。我看不出我们有什么理由不加入僵尸的行列!

app/Zombie.php

namesapce App;

class Zombie extends Monster
{
        public function strength() { return 3; }
}

你所做的只是调整力量,让僵尸变得更强一点。接下来,您需要创建ModifiedMonster,它是所有 decorators 的基类。为什么不简单地使用Monster作为装饰器的基类呢?Monster类不像装饰器那样包装另一个Monster类。装饰器需要一个基类,将一个庞然大物传递给构造函数。你也可以在ModifiedMonster抽象类中添加/删除额外的功能。

app/ModifiedMonster.php

namespace App;

abstract class ModifiedMonster

{
        protected $monster;

        public function __construct(Monster $monster)
        {
                $this->monster = $monster;
        }

        public function intelligence() { return $this->monster->intelligence(); }
        public function strength() { return $this->monster->strength(); }
        public function speed() { return $this->monster->speed(); }
}

现在你终于可以开始装修了!你想让你修改后的怪物更聪明,所以使用ExtremelySmart装饰器来实现。你也可以用ExtremelyFast让你的怪物跑得更快。我只展示了ExtremelySmart类,因为它解释了装饰器的结构。请随意查看源代码中的其他装饰器。

app/ExtremelySmart.php

namespace App;

class ExtremelySmart extends ModifiedMonster
{
        public function intelligence()
        {
                return parent::intelligence() * 2;
        }

        public function castSpell($spell)
        {
                return "casts the {$spell} spell";
        }
}

最后,你需要一个客户来运行所有这些装饰。首先,你打印出僵尸的统计数据。

app/simulator.php

 5   print 'Running Zombie Thing' . PHP_EOL;
 6   
 7   $monster = new App\Zombie;
 8   
 9   print 'This zombie stats are'
10           . ' STR ' . $monster->strength()
11           . ' INT ' . $monster->intelligence()
12           . ' SPE ' . $monster->speed() . PHP_EOL;

接下来,你想给僵尸增加一些速度。所以你在运行时修改他,用ExtremelyFast来修饰。

app/simulator.php

18   $monster = new App\ExtremelyFast($monster);
19
20   print 'Decorated zombie stats are'
21           . ' STR ' . $monster->strength()
22           . ' INT ' . $monster->intelligence()
23           . ' SPE ' . $monster->speed()
24           . ' and it can now ' . $monster->jumpAttack() . PHP_EOL;

如果你愿意,现在你可以给僵尸增加更多的速度和智力。他现在是一个超级僵尸,我现在很害怕。我要去开灯了;这里太暗了。

app/simulator.php

30   $monster = new App\ExtremelyFast($monster);
31   $monster = new App\ExtremelyFast($monster);
32   $monster = new App\ExtremelySmart($monster);
33
34   print 'Decorated zombie stats are'
35           . ' STR ' . $monster->strength()
36           . ' INT ' . $monster->intelligence()
37           . ' SPE ' . $monster->speed()
38           . ' and ' . $monster->castSpell('fireball') . PHP_EOL;

现在你知道装饰模式是如何工作的了。它附加了额外的责任。但是,请注意,如果你要对你的怪物进行跳跃攻击,它会抛出一个错误。

app/simulator.php

41   $monster->jumpAttack(); // no such method - errors

这个错误是因为ExtremelyFast装饰器提供了jumpAttack,但是第 32 行的最后一个装饰是ExtremelySmart,这意味着这个方法不再可用。这是装饰模式的一个缺点。如果一个修饰提供了一些基类ModifiedMonster不知道的新职责,那么你只能得到最新的修饰的方法。令人高兴的是,一种叫做 presenters 的装饰模式可以解决这个问题。

提出者

主持人利用 PHP 中的魔法方法 4 ,比如 __call() 5 和 __get() 6 ,来绕过你在修改后的怪物装饰器中发现的消失责任问题。那么你什么时候使用演示者呢?如果满足以下任一条件,请使用它们:

  1. 您在模型中添加代码完全是为了视图逻辑:尽可能保持模型的整洁。模型在你的应用中随处可见。尽可能地保留逻辑,以避免到处制造恶魔。

  2. 您正在向自己的 Laravel 视图添加逻辑:如果您发现自己在做大量的条件语句,可以考虑将其抽象成一个 presenter 方法。

在本例中,您将使用一个名为robclanc y/presenter7的演示者。我已经冒昧地将它添加到了composer.json中,所以请确保您运行了composer update,然后您就可以使用php artisan serve运行 web 服务器了。

一旦完成,看一看http://localhost:8000 8http://localhost:8000/presenter 9 。这是你的路线。

routes/web.php

Route::get('/', function () {
        $user = new App\UserPresenter(new User);

        $user->favoriteColor = rand(0, 1) ? 'blue' : null;

        return view('hello', compact('user'));
});

注意,在这个例子中,我修改了 home 路由。您为该用户随机设置了一种喜爱的颜色。您还用一个UserPresenter来“装饰”这个$user对象。一会儿你会看到那堂课。首先,我们来看看 hello 视图。演示者的目标是将第一位代码转换成第二位代码。

以前

resources/views/hello . blade . PHP

23   @if ($user->favoriteColor)
24     <span style="background-color: {{ $user->favoriteColor }}">Hello
25   there!</span>
26   @else

27     <span>Hello there!</span>
28   @endif

在...之后

resources/views/hello . blade . PHP

17   <span {{ $user->favoriteColorStyle }}>Hello there!</span>

您可以看到第二段代码看起来更简单,并且不需要您思考太多。信不信由你,去除你的观点中的大部分逻辑实际上是可能的。无逻辑视图是你应该努力争取的,因为它们使你的代码工作更容易。阅读包含if/else/elseif/foreach语句的数千行 HTML 代码是一件非常麻烦的事情。如果您从不添加条件,那么您将永远不会发现自己在视图中调试逻辑,因为它将在您的 PHP presenter 类中。说到这里,我们来看看UserPresenter级。

app/user reseller . PHP

namespace App;

class UserPresenter extends Presenter
{
        public function presentFavoriteColorStyle()
        {
                return $this->favoriteColor
                        ? "style=\"background-color: {$this->favoriteColor};\""
                        : '';
        }
}

正如我在开始时所说的,演示者是神奇的,不同的包之间实现略有不同。还有其他一些软件包,但我随机选择了robclancy/presenter。你可以自己写,但我不想为这一章写。如果你愿意,你可以和我分享。这种特殊风格的 presenter 希望您在 Camel 封装的函数名前面加上前缀present,这将在您的类上创建一个动态属性。您也可以只创建一个函数,但是将favoriteColorStyle作为另一个属性而不是某个函数似乎更好。也许我只是懒?无论如何,这涵盖了称为 presenter 的装饰器模式的变体。

结论

装饰模式提供了一种灵活的方式来为对象添加职责;职责可以在运行时附加和分离。这也意味着您不必编写完美的类。如果你第一次没有做对,没关系。当价格合适的时候,你可以在以后增加新的职责。

使用 decorators 的一个缺点是,如果你使用类型提示,那么你必须记住一个Decorator不实现它的Component。它把它包起来。因此,如果你使用类型提示,比如someMethod(Component $obj),这将抛出一个错误,因为从技术上讲Decorator不是Component的类型。装饰模式的另一个缺点是,如果有很多装饰,调试或故障排除会变得越来越困难。但是,不要让这阻止你使用表示器或装饰器,特别是如果你需要扩展类的职责而不需要子类化或修改基类的时候。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 196 页

2

http://en.wikipedia.org/wiki/Santa's_Slay

3

http://en.wikipedia.org/wiki/Attack_on_Titan

4

http://php.net/manual/en/language.oop5.magic.php

5

http://php.net/manual/en/language.oop5.overloading.php#object.call

6

http://php.net/manual/en/language.oop5.overloading.php#object.get

7

https://github.com/robclancy/presenter

8

http://localhost:8000

9

http://localhost:8000/presenter

十三、外观

$> git checkout facade

目的

为子系统中的一组接口提供统一的接口。Facade 定义了一个更高级的接口,使得子系统更容易使用。 1

应用

生活是艰难的。有时候事情就是很复杂。也许你写的代码是为了解决一个非常棘手的问题。当然,你通常试图把事情分解成最简单的形式(模型),但是你的建模并不总是正确的。有时候代码并不需要复杂。当你第一次写东西时,你并不总是第一次就写对。有多少次你看着你的旧代码想要重构它?你学习新的东西。你可以尝试重构;然而,在某些情况下,这可能是非常昂贵的(时间和金钱)。重构的另一种方法是在现有代码的基础上创建另一层,使界面更容易理解和使用。在某种程度上,这种模式可以被认为是第二次机会模式。

然而,facade 模式并不完全是为了掩盖糟糕的代码。它可以用来创建一个简单的公共接口,该接口包含了以某种方式协同工作的各种类。也许您正在处理一个拥有数百个公共方法的大型子系统,但是您只需要其中的十个。您希望 facade 正确地构造子系统中的各种类,并向您提供这十个接口,以便您可以轻松地使用它们。使用这种模式可以让您分层考虑代码。facade 将包装一层较低级别的代码,这样当您想要使用该系统时,就不必处理子系统的复杂细节。

Laravel 的脸

A435115_1_En_13_Figa_HTML.jpg fa-cade (fah-saud)

建筑物的正面。一种行为或表现方式,让别人对你的真实感受或处境产生错误的想法。

Laravel 有个东西叫外观 2 ,不要和外观图案混淆。Laravel 使用 facades 作为在服务容器中包装类或绑定的方式,然后静态地调用它。因此,如果你有一个名为AwesomeImpl的类,它有一个名为someMethod的非静态方法,你可以为AwesomeImpl创建一个名为Awesome的门面,然后静态地调用Awesome::someMethod()。不过,真的是这样。它只是为类提供一个静态接口。您不希望在AwesomeImpl中使用静态方法,因为静态方法很难在 PHPUnit 中测试。

如果你关注拉勒韦尔的戏剧,那么你可能知道每隔几个月就会有人在 reddit 3 上抱怨拉勒韦尔的门面,声称它们不是真正的门面。每个人都喜欢好的戏剧。

  • 谁在乎他们是否不同?门面的定义是一种行为方式,给人一种错误的情况。Laravel facades 确实给人一种拥有静态方法的类的感觉。-引人注目的猫科设计

当我们谈到 Laravel facade 辩论的主题时,有人提到 Laravel 也许应该将 facade 的名称改为 proxies。您将很快了解代理模式。“四人帮”最初打算用代理来解决性能、保护和并发性问题。拉勒维尔外观的意图是什么?

早些时候,当我说 Laravel facades 只是用来将方法转化为静态方法时,我有点撒谎了。不好意思。实际上我忽略了一个重要的细节。Laravel facade 的真正意图是让客户不必知道如何构造一个类。静态调用方法只是一个副产品。您静态调用这些方法的真正原因是因为这样做更容易。所以在上面的例子中,客户端从不构造一个new AwesomeImpl;客户使用 Laravel 外观,为他们处理施工。对某些人来说,静态调用方法比非静态调用方法更性感。使用 Laravel facade 允许您绕过构造类的细节。说出做到这一点的模式!(我会说简单工厂模式。)

在这本书的开始,我承诺了一些戏剧,所以你有它!除了戏剧性之外,这里的主要收获是您应该意识到 Laravel 中的术语 facade 与 facade 模式有着不同的含义。

抽象结构

图 13-1 为结构示意图。

  • Facade通常是一个单独的类。它深入到一个复杂的子系统中,构造不同的类并调用方法。在这个抽象的例子中,method()正在创建ClassA1, ClassB1ClassC1,并从每个调用方法。这些类在同一个子系统中,所以它们可能以某种方式一起工作。facade 的创建者知道它们是如何协同工作的,并为您创建了一个名为method()的调用方法。谢谢,代笔! 4

  • 包含许多逻辑上属于一起的类。这些类可能在同一个域中。这些类可能有些耦合。它们可能彼此没有任何关系,但属于同一个名称空间,因为开发人员喜欢这样做。理解子系统对于创建外观抽象层以简化其他人的工作至关重要。

A435115_1_En_13_Fig1_HTML.jpg

图 13-1。

The facade pattern

例子

我没有一个类的子系统供你使用。我们需要一些现有的代码,我们不太喜欢的界面。为了发明一个例子,我可以从 WordPress 或者其他一些头脑装瓶商那里获取一些代码。唉,不过!我们在这个蓝色的星球上只有一百多岁。如果我跳过 WordPress 的想法,我真的希望你不会太失望。我们有更重要的事情要做。

因此,在本例中,您将采用所有现有的模式模拟,并将它们合并到一个单独的类中。这样做是为了有一个地方可以运行所有的模式。你将称这个地方为PatternExecutor。您正在创建一个新层,允许您触发到目前为止您已经覆盖的任何模式的模拟。作为额外的奖励,有一个random()方法,它的职责是随机执行一个模式模拟。结果应该类似于下面的代码:

app/simulator.php

$patternFacade = new App\PatternExectutor;
$patternFacade->random();

每次运行使用模式外观的模拟器时,您都会得到一个随机输出,因为一个模式是随机选择运行的。假设您运行它两次,那么您看到的输出可能(随机地)如下所示:

php 应用/模拟器

ABSTRACTFACTORY PATTERN
======================================
Your merchant made $20
======================================

php 应用/模拟器

BRIDGE PATTERN
=====================================================================
EMAIL: Hello world!
SNAIL MAIL: Hello world! sending to: PO Box 123, Somewhere, NY, 12345
TEXT: Helo world!
=====================================================================

示例结构

图 13-2 为结构示意图。

A435115_1_En_13_Fig2_HTML.jpg

图 13-2。

The concrete example

履行

目前,每个模式模拟都在它自己的分支中。这意味着您需要检查每个 git 分支,并将代码复制到/app目录中。我指出这一点只是为了清楚起见,在现实生活中你不会用你的 facade 模式这样做,因为你的代码不会分散在你的存储库中不同的 git 分支。现在,来自各个分支的所有代码都已经被复制了,假设这些代码是您的子系统。图 13-3 是目录截图。正如您所看到的,对于不知道这段代码背后的原因的人来说,文件名并不能提供太多的信息。

A435115_1_En_13_Fig3_HTML.jpg

图 13-3。

What is the purpose of the code in this directory?

你能通过查看目录中的文件名告诉我这个子系统的用途吗?结构和文件名可能会令人混淆。这里发生了很多事情,仅仅通过查看这些文件名并不清楚所有这些代码应该如何一起工作。因此,您将为新手开发人员创建一个名为PickerExecutor的门面,统一代码子系统。

创建代码的人通常知道代码背后的原因。他们知道为什么一个叫做doSomethin g的方法存在。他们创造了它。当然,总有时间和记忆丧失的因素,所以这些原因变得不那么清楚了。这就是为什么创建一个 facade 可能有助于减轻对整个子系统存在原因的任何混淆。

facade 模式用一个单一的目的统一了所有这些代码。那目的是什么?我碰巧知道所有这些类都是为了说明 GoF 模式,但那是因为我是整本书中创建这些类的工程师。外观是复杂子系统的简化,这里的复杂子系统有传播不同 GoF 模式的目的。因此,您的外观将为新用户提供一个有希望有明确目的和/或更易于使用的界面。你不希望人们仅仅为了利用子系统的目的而必须挖掘子系统的所有类。

app/PatternExecutor.php

 5   public function random($params = [])
 6   {
 7           $methods = ['abstractFactory', 'adapter', 'bridge', 'builder'];
 8           $method = $methods[array_rand($methods)];
 9
10           print PHP_EOL . strtoupper("$method pattern") . PHP_EOL;
11           print "======================================" . PHP_EOL;
12           $this->$method($params);
13           print "======================================" . PHP_EOL;
14   }

这个random方法选择一个随机的方法名并调用它。因此,选择一些方法来看看,其中任何一个都可以随机选择。

app/PatternExecutor.php

16   public function abstractFactory()
17   {
18           $ratings = array(
19                   'PG-13' => new App\RatedPG13\RiceFarmer,
20                   'R' => new App\RatedR\DrugDealer
21           );
22   
23           $merchant = $ratings[array_rand($ratings)];
24           $client = new App\Client($merchant);
25   
26           $client->run();
27   }

对于abstractFactory方法,我从抽象工厂模式分支复制了simulator.php代码。我没有将模拟器代码组织成类,所以我只是将所有代码复制到方法中。不过,这提出了一个很好的观点。为什么我复制了所有的代码?如果代码已经用simulator.php写好了,为什么我不需要那个文件?我可以将每个分支的simulator.php重命名为模式名,然后需要 PHP 脚本。我没有这样做,因为我认为在这种情况下,在 facade 的每个方法中复制代码会更干净。我说这些是为了把我们带到下一点。为了利用子系统,外观有时需要实现自己的代码。其他时候,它可能像调用现有方法的序列一样简单。在这种情况下,您必须在最终调用PatternExecutor之前设置一些东西。

app/PatternExecutor.php

30   public function adapter()
31   {
32           $crmAddress = with(new App\CRM\AddressLookup)->findByTelephone('555 867-5309');
33           $address = new App\CRM\AddressAdapter('Jenny Call', $crmAddress);
34           $mailClient = new App\MailClient;
35           $mailClient->sendLetter($address, 'Hello there, this is the body of
36           the letter');
37   }

这里没有列出其余的方法。我想你现在大概明白这个门面是怎么实现的了。请随意查看 git 存储库,了解 facade 中的其他方法。如果你真的感到勇敢,试着自己实现一些吧!

外观和适配器一样吗?

那么,外观和适配器有什么不同呢?它们的结构相似。根本区别在于意图。facade 接口不像适配器接口那样是预先确定的。在适配器中,您不能随意命名您的方法,因为您有一个需要特定接口的客户端。facade 可以命名最有意义的方法,也可以命名对 facade 的客户来说最简单的方法。

此外,适配器并不意味着采用复杂的层和简化。它们是关于把一层转化成客户已经期望的另一层。适配器模式类似于 HDMI 转 DVI 适配器,外观模式更像是将 HDMI 输出连接到主板及其子电路。你不需要知道电路是如何一起工作的;您只需将电缆插入 HDMI 接口,让工程师们去担心底层部件。简而言之,适配器转换层,外观隐藏层。

结论

在本章中,你看到了当你想简化某层代码时,你可以使用 facades。这种模式的缺点是 facade 依赖于各种各样的类。如果底层系统发生变化,可能会给你的门面带来麻烦。每当对子系统进行底层更改时,您可能都必须重构外观。这也意味着你需要理解底层的类是如何一起工作的。必须有人来创建和管理门面。拥有 facade 的好处在于,团队中的所有开发人员都可以利用更简单、更易于使用的 API,而不必处理底层子系统的复杂性。

一个 facade 必须是一个单独的类吗?一般来说是的,一个 facade 通常是一个单独的类。你希望你的外观尽可能简单易用。如果你创建了多个类,你需要知道这些类是如何一起工作的。一个 facade 已经与子系统中的许多模块耦合在一起,但是您对使用 facade 的客户端开发人员隐藏了所有这些。这并不意味着你必须创建一个巨大的超级门面类。如果有必要的话,你可以创建多个 facade 类,然后在这些 facade 之上创建另一个层。建在其他外观上的外观。请记住,您希望尽可能保持简单,外观是您必须管理的更多代码。因此,当建立一个门面时,要确保收益大于建立和管理门面所花费的时间。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 208 页

2

https://laravel.com/docs/5.3/facades

3

www.reddit.com/r/PHP/comments/1zpm0y/laravel_lets_talk_about_facades

4

www.youtube.com/watch?v=HZJSccRDKZc

十四、享元

$> git checkout flyweight

目的

使用共享来有效地支持大量细粒度的对象。 1

应用

flyweight 模式用于减少内存占用。当您处理大量共享相同属性的对象或共享相同大数据(例如图像)的少数对象时,您会发现这种模式非常有用。举例来说,假设您有一个具有类似于name, gender,phone number属性的Person对象。假设每个 Person 对象的平均内存开销是 1000 字节。当您分配 100,000 个Person对象时,您最终会使用 1 亿字节(95MB)的内存。现在让我们也假设所有的Person对象都有设置为"Female"gender属性,并假设字符串"Female"占用大约 300 字节的内存。这意味着大约有 300 x 100,000 字节(28MB)的内存被复制。(OMG,让它停下来!不再有数学 PLZ!)

一句话:利用共享数据可以减少总的内存消耗。更少的内存可能意味着更好的性能。

四人组把可以在对象之间共享的数据称为内在数据,把不能共享的数据称为外在数据。我发现自己不得不回去回忆哪个是哪个。我将省去您的麻烦,而是说数据要么是可共享的,要么是不可共享的。

你如何知道哪些数据是可共享的?对于Person类,你有三个属性:name, genderphone number。你可能会说gender是可共享的,而namephone number不是。电话号码和姓名通常有些独特;但是,如果在这个应用中,您处理的数据集有很多重复的名称,该怎么办呢?在这种情况下,您可以通过共享namegender来节省大量内存。由您决定在 flyweight 中共享哪些数据。

然而,如果你停下来想一想,在 PHP 的传统工作流程中,你可能不需要 flyweights。我说的 PHP 传统工作流程到底是什么意思?

  1. 某个最终用户在浏览器中输入一个 URL 来导航到某个路由。

  2. 该路由调用 web 服务器(Apache/Nginx ),而 web 服务器又调用 PHP。

  3. PHP 调用数据库调用/业务逻辑等操作。

  4. 最终,一些 HTML 会通过 HTTP 协议返回给最终用户。

所有这些都是在说:PHP 脚本的生命可能非常短暂。构建 100,000 个对象需要时间(在我的机器上大约 15 秒)。在这段时间里,你的终端用户不耐烦地敲着他的脚,等待网页的回应。更不用说其他用户也试图请求相同的页面。因此,flyweight 模式在无状态请求-响应情况下没有意义,因为每次请求路由时构造大量对象会花费很长时间。

在一个小的对象集合上使用 flyweight 可能有些过头了。那么你什么时候使用 flyweight 呢?并非所有 PHP 都属于请求-响应无状态 HTTP 范式。您可以在命令行上运行 PHP 脚本。您可以将 PHP 作为 web 套接字服务运行。你甚至可以利用 Laravel 的队列 2 与 Beanstalk 和亚马逊 SQS 的集成来运行 PHP 作为后台进程。不是每一个 PHP 脚本都必须在几毫秒内运行,因此有时您可能希望使用 flyweight 模式来节省内存。既然我已经展示了为什么你可能使用 flyweight 模式,让我们学习这个模式。

抽象结构

图 14-1 为结构示意图。

  • 是所有 flyweight 对象继承的基础抽象类。如果不需要基础方法,那么这可能只是具体 flyweights 实现的一个接口。

  • 减轻客户创建新 flyweight 对象的负担。它处理决定何时创建一个新的具体 flyweight 对象或指向一个已经创建的对象的逻辑。这个工厂包含一个关联数组,跟踪所有创建的 flyweights。

  • ConcreteFlyweight是一个Flyweight的实例。它的属性是共享的。重要的是要记住,如果这个 flyweight 的一个属性被改变,那么指向这个 flyweight 的每个人的属性都会被改变。共享的 flyweights 应该被视为不可变的,否则你会危及你的理智。

  • UnsharedConcreteFlyweight是非共享 flyweight 的实例。有时,您需要创建一个 flyweight 的唯一的、非共享的实例。使用 flyweight 工厂,您可以创建一个特殊的 flyweight 实例。这个对象可以随意更改。一种方法是在你的基类Flyweight中创建一个clone方法。这样,flyweight 工厂可以从现有对象中克隆对象,然后将它们转换成非共享的 flyweight。此时,当您更改 flyweight 的任何属性时,您并没有破坏共享数据。有时你可能不需要这种类型的轻量级。这里提到它只是为了让您意识到数据共享问题。我将在例子中说明这个问题。

A435115_1_En_14_Fig1_HTML.jpg

图 14-1。

The flyweight pattern

例子

经过几天对其他开发人员使用 flyweight 模式的不同例子的研究,我感到沮丧。我看到的很多例子都是针对游戏引擎的。在游戏中,你经常会处理共享数据的庞大对象。充满坦克和僵尸的世界。一片长满树木的森林。激光枪的粒子爆炸。在拉勒维尔你不会真的做那种事。

“四人帮”在文本编辑器中使用了 flyweight 模式。在文档中重复出现的图像节点共享相同的图像内存,但在页面中包含非共享的图像坐标。当只有位置不同时,不需要复制图像数据。网络浏览器也做同样的事情。再说一次,在拉勒维尔你也不会真的做这种事情。当然,你确实使用了 WYSIWYG 编辑器,但这更多的是与 JavaScript 有关,而不是 Laravel。

有什么好的例子来说明 flyweight 模式?这对我来说很难。想了一会这个模式还是被难住了,决定看一些动漫。我从风之谷的娜乌西卡 3 开始。还是什么都没有;接下来是4。做完那个,我又看了英姿飒爽 远去 5 。在这一点上我绝望了。爆发大炮的时间:Ponyo6。这些事情需要时间。我想,时间就是一个很好的例子。例如,在组织约会时,可以使用日历上的日期。你写下同一天的多个约会。你买五本日历并不是为了在同一天写下五个不同的约会。你只要在盒子里写小就行了。让我们进一步探讨这个概念。

在这个例子中,您将创建一个计时器 flyweight,它为给定的一天记录一个日期。您将在脚本中加载 100,000 个用户,每个用户都有一个最后登录的日期。更有趣的是,您的大多数用户都是在过去几个月内登录的。您可以创建 100,000 个日期时间对象,每个用户一个。然而,您实际上只需要大约 60 个用户共享的日期时间对象。为了保持这个例子的简洁,您实际上不会对大量的用户对象做任何事情。实际上,您实际上会对这 100,000 个用户对象做一些事情。您还应该假设这个脚本作为一些 cron 任务、队列作业或一次性命令行 PHP 脚本运行。如前所述,您不可能在一个网络请求中加载这么多对象。

示例结构

图 14-2 为结构示意图。

A435115_1_En_14_Fig2_HTML.jpg

图 14-2。

The concrete example

履行

咳咳…首先是关于 PHP 和内存的一课。

当我谈到原型模式(和克隆)时,我简要地谈到了内存和指针,但是让我们更详细地再讨论一下。你问为什么?如果您理解 PHP 如何为变量分配内存,您应该更好地理解如何实现 flyweight 模式。几个实验将更好地解释 PHP 如何为变量分配内存。首先,我想定义一些东西。指针是内存中的一个小空间,它只是指向另一个内存地址的开始。这类似于将一个电子邮件地址(pointer@stuff.com)转发到另一个电子邮件地址(real@stuff.com)。数据都保存在real@stuff.com的收件箱里,但你仍然可以使用两个电子邮件地址。比我聪明的人说过以下的话:

  • PHP 是一种动态的、松散类型的语言,使用写时复制和引用计数。 7

什么是参考计数 8 ?它是引用特定内存空间的变量的数量。当引用计数达到零时,PHP 内部知道他们可以丢弃这个内存空间中的数据,并将其重新用于其他用途。没有引用计数,你就不知道什么时候释放内存。最终你会耗尽内存。使用 Xdebug,您可以查看变量的引用计数。如果没有 Xdebug,可以使用 Ubuntu 的 apt-get,Mac 的 brew,或者在 Windows 上下载安装程序。

app/memory/experience 1 . PHP

$a = "hello there";
xdebug_debug_zval('a');

// a: (refcount=1, is_ref=0)='hello there'

$b = $a;
xdebug_debug_zval('a');

// a: (refcount=2, is_ref=0)='hello there'

$b = 'something else';
xdebug_debug_zval('a');

// a: (refcount=1, is_ref=0)='hello there'

注意当你把$b赋给变量$arefcount是如何增加的?在内部,PHP 并没有为$b创建新的内存空间。这样更快(比我聪明的人说)。稍后,当$b变为something else,时,你必须分配新的内存,在此期间$a的 refcount 返回到 1。

接下来要解决的是写时复制的确切含义。当两个或多个变量相互赋值时,它们共享同一个内存地址。只有当其中一个变量改变时,PHP 才会复制内存中的实际值。您可以在下一个实验中使用memory _ get _ usage9函数来查看这一点。

app/memory/experience 2 . PHP

function print_member($step) {
        print "Step #{$step} - " . memory_get_usage() . PHP_EOL;
}
print_memory(1);                       // Step #1 - 226536 bytes

$a = array_fill(0, 200000, 0); // new memory allocated in Address#1

print_memory(2);                       // Step #2 - 19924088 bytes

$b = $a;                                       // address of $b equ\

als address $a
print_memory(3);                       // Step #3 - 19924176 bytes

$b[4] = 4;                                     // new memory allocat\

ed Address#2

print_memory(4);                       // Step #4 - 39621528 bytes

当你第一次启动你的程序时,你使用了 226,536 字节。好的,很好。谁在乎…请下一步!在第 2 步中,分配一个巨大的数组,其中填充了 200,000 个零值元素。在这一步中,您现在使用了 19,924,088 字节的内存。涨幅很大啊!在步骤 3 中,当$b被分配给$a时,你可能期望看到内存使用翻倍,但这并没有发生。实际上只增加了 88 个字节。这是为$b创建一个新指针并更新地址#1 中的引用计数所需的空间量。最后,当您在步骤 4 中更改$b时,您会看到内存使用的巨大差异,因为此时当$b被更改时,您将内存从地址#1 复制到一个新的位置,我们称之为地址#2,然后您更改了$b的第四个元素。这就是写入时复制的工作原理。直到最后需要的时候,你才复制内存值。这就是为什么您可以在不耗尽内存的情况下执行类似以下代码的操作。

app/memory/experience 3 . PHP

$storage = [];
$a = array_fill(0, 200000, 'abc');

for ($i = 0; $i < 1000; $i++) {
        $storage[$i] = $a;
}

print $storage[999][3] . PHP_EOL; // abc

请注意,您在数组$a中分配了 200,000 个块。根据您上一次的实验,100,000 个数据块大约是 19,924,176 个字节,因此如果您将它翻倍,它将占用将近 38MB 的内存。如果你这样循环 1000 次,那将会有将近 4GB 的内存,这将会使大多数系统陷入瘫痪。这里不会崩溃和烧毁的原因是 PHP 的写时复制方法。

到目前为止,您只处理了字符串、数字和数组。对象是如何表现的?对象仍然有引用计数和写时复制,所以它们的行为方式基本相同。下面的代码说明了对象和基元之间的区别。

app/memory/experience 4 . PHP

class SomeObject { public $answer; }

function change1($obj) { $obj->answer = 42; }

function change2($obj) { $obj = 'Douglas'; }

$x = new SomeObject();
$x->answer = 0;

change1($x);
change2($x);

var_dump($x);          // what is the output?

你在这里的产量是多少?

  1. 你应该看到你的对象的回答是 0 吗?

  2. 你应该去看看道格拉斯吗?

  3. 你应该看到你的答案已被改为 42?

事实证明第三种选择是正确的,但是为什么呢?为什么在change1方法中answer属性变成了 42,而change2方法却没有任何效果?答案在于你的对象的属性是引用相同内存地址的指针。当你将$x传递给change1change2函数时,你为$x本身创建了一个新的内存指针,但是$x的所有属性仍然指向同一个内存位置。因此,在change1中更新$obj->answer = 42,也就是改变$x->answer的相同内存地址。在change2中,当您更新$obj = 'Douglas'时,您正在更改与$x不同的内存地址。你将在下一个实验中利用这一点。

app/memory/experience 5 . PHP

$faker = Faker\Factory::create();
$faker->seed(42);
$storage = [];

$checkOne = memory_get_usage();

for ($i = 0; $i < 100000; $i++) {
        $storage[] = new Person($faker->firstName, $faker->boolean() ? 'Male' : 'Female');
}

$checkTwo = memory_get_usage();

print round(abs($checkTwo - $checkOne) / (1024*1024)) . 'MB memory'
. PHP_EOL;

// 44MB memory

Faker 10 库为您提供了伪随机的名字以及随机的是或否布尔值来确定性别。您可能需要运行composer update来获取 Faker。在这个实验中,您将创建一组随机的Person对象,存储所有 100,000 个实例的总内存消耗是 44MB。接下来,你要利用你在实验 4 中的发现。通过在对象间共享gender属性,可以减少使用的内存量。

app/memory/experience 6 . PHP

$checkOne = memory_get_usage();

$male = new Gender('Male');
$female = new Gender('Female');

for ($i = 0; $i < 100000; $i++) {
        $storage[] = new Person($faker->firstName, $faker->boolean() ? $male : $female);
}

$checkTwo = memory_get_usage();

print round(abs($checkTwo - $checkOne) / (1024*1024)) . 'MB memory'
. PHP_EOL; // 39MB 
memory

所以你节省了 5MB 的内存。这看起来并不多。在这种情况下,您没有保存那么多,因为大部分内存使用的是这个人的名字。共享name属性可能会为您节省更多空间。我把它作为一个练习留给你去尝试。希望我已经用一种可以理解的方式解释了 PHP 中跨内存共享数据的概念。但是您还没有实现 flyweight 模式。到目前为止你所做的叫做串实习 11 。让我们继续前进,实现你的轻量级!

Flyweight 实现

当您使用 flyweight 模式时,您需要一种基准测试方法。这种模式的要点是节省内存和提高性能。如果你不知道你的程序使用了多少内存,那么你怎么知道你为做一个轻量级应用所做的工作是否有回报呢?你已经在这一章的内存基准上花了很多时间,所以你应该很熟悉memory_get_usage,这是你将用来确定内存使用的。这里是你将要运行的模拟器来演示轻量级。您将首先看到在没有 flyweight 的情况下使用了多少内存。接下来,您将看到在保持 flyweight 不变的情况下节省了多少内存。

app/simulator.php

$memory1 = memory_get_usage();
$time1 = microtime(true);

$people1 = [];

for ($i = 0; $i < 100000; $i++) {
        $person = new App\Person;
        $person->last_login = App\RandomDate::between('2014-11-01', '2014-12-01');
        $people1[] = $person;
}

$memory = round((memory_get_usage() - $memory1) / (1024 * 1024), 2);
$time = round(microtime(true) - $time1, 2);

print "Without flyweight, {$memory}MB of memory and {$time}s" . PHP_EOL;

下一步是使用一个名为DateKeeper的 flyweight 来存储日期。与前面的代码不同,来自DateKeeper的日期由创建的 100,000 个Person对象的last_login属性共享。本质上,你是在重复代码;唯一的主要区别是在给Person对象分配last_login日期时使用了 Flyweight 工厂。

src/simulator.php

$person->last_login = $dateKeeper->fetch(RandomDate::between('2014-11-01',
'2014-12-01'));

那么这个例子中的DateKeeper flyweight 工厂是什么样子的呢?让我们来看看。

app/datekeeper.php

class DateKeeper

{
        static private $dates = [];
        static public function fetch($dateAsString)
        {
                $datetime = is_a($dateAsString, 'DateTime') ? $dateAsString : new \DateTime($dateAsString);

                $index = $datetime->format('Y-m-d');

                if (! array_key_exists($index, static::$dates)) {
                        static::$dates[$index] = $datetime;
                }

                return static::$dates[$index];
        }
}

你可能会说,“哦,你在用静力学;太恶心了!Kelt!”是啊,你可能是对的。不过,在这种情况下,这给了您不必初始化新的DateKeeper的优势。你可以在任何地方使用这个DateKeeper作为简单的单例全局变量!如果您想在整个应用的不同地方使用DateKeeper,而不必依赖服务容器来创建单例,这可能会非常好。真的是这样。我想指出这一点,因为你也可以创建无静电的轻量级。当不使用静态时,只需要确保根据需要在应用中传递 flyweight 对象,否则就会失去所有缓存的对象和 flyweight 模式的好处。

正如你所看到的,这并不是让你的轻量级跑起来的全部工作。轻量级不应该有很多工作。对待它的本质:内存共享。

结论

总的来说,flyweight 模式不太可能在 Laravel 中使用。不过,不用担心;这一章并不完全是浪费时间。您学到了很多关于 PHP 内存管理的知识。了解内存在 PHP 中是如何分配的似乎太低级了,但是考虑一下这个。假设您使用 Laravel 的雄辩 ORM 从数据库中提取了数千条记录。您可以花一点时间来检查这个操作的内存使用和性能。接下来,您可以跳过雄辩的 ORM,直接使用DB::table('table_name')。这种方法将返回stdClass对象,而不是水合雄辩的模型。它可能会更快,使用更少的内存。我把这作为一个练习留给你去尝试。

flyweight 模式的一个缺点是它会导致更复杂的代码。这可能不值得增加额外的复杂性。你应该经常检查使用 flyweight 节省了多少内存。有时获得的内存自由根本不值得使用 flyweight 的额外复杂性。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 218 页

2

https://laravel.com/docs/master/queues

3

http://en.wikipedia.org/wiki/Nausica%C3%A4_of_the_Valley_of_the_Wind_%28film%29

4

http://en.wikipedia.org/wiki/My_Neighbor_Totoro

5

http://en.wikipedia.org/wiki/Spirited_Away

6

http://en.wikipedia.org/wiki/Ponyo

7

http://PHP.net/manual/en/internals2.variables.intro.PHP

8

http://PHP.net/manual/en/features.gc.refcounting-basics.PHP

9

http://PHP.net/manual/en/function.memory-get-usage.PHP

10

https://github.com/fzaninotto/Faker

11

http://en.wikipedia.org/wiki/String_interning

十五、代理

$> git checkout proxy

目的

为另一个对象提供代理或占位符,以控制对它的访问。 1

应用

代理是中间人模式。传入调用首先与代理通信,而不是客户端直接调用某个对象方法。类似于装饰器,代理将自己包装在对象周围。与装饰器不同,代理不会为包装的对象添加新功能。那么你为什么使用代理呢?为什么中间有个男人?有几个很好的理由可以解释为什么你会这样做,并且从这些理由中产生了不同类型的代理。我将在下面列出这些不同的类型,以及你为什么想要使用那种类型的代理的原因。

  • 虚拟代理用于延迟底层对象的构造或简化对象。当一个对象需要一段时间来加载时,您可以仅在绝对需要时使用这个代理来构造对象。它还可以用来降低复杂性。例如,通过使用虚拟代理,名为doStuff的方法可以更恰当地重命名为maximizeProfits

  • 当您希望将远程资源视为本地资源时,远程代理非常有用。使用肥皂 WSDL 就是一个例子,但是我已经很久没有使用了。许多 restful APIs,如 Stripe,都附带了一个库,它充当 web 服务的代理。

  • 保护代理阻止对方法访问。一个UnauthenticatedProxy可能不允许某些方法给它的底层委托。

  • 智能代理为包装的对象添加了额外的功能。调用对象方法时触发事件或写入日志文件不会干扰原始对象源代码。这类似于装饰器,但是您没有向对象添加可见的功能。添加的功能对客户端是透明的。

还有其他代理,但你必须自己去发现它们。现在你将把注意力集中在这些主要的问题上。

抽象结构

  • SubjectProxyRealSubject扩展而来的抽象类或接口。它定义了使用的基本共享方法。这是您在键入提示时使用的。见图 15-1 。

    A435115_1_En_15_Fig1_HTML.jpg

    图 15-1。

    The proxy pattern

  • Real SubjectSubject的具体实现。它将被Proxy使用。Proxy一般会通过构图来包裹Real Subject

  • Proxy用于代替Real Subject。它是一个Real Subject的替代品。如果你的词汇像我一样糟糕,那么你可能会听到“代理人”这个词,并想起布鲁斯·威利斯的那部电影。因此,请允许我定义代理人。代孕妈妈是替代品。类似于代课教师如何代替另一名教师,proxy将表现得更像一个真实的主题,只在保护、性能或简化Real Subject的功能方面进行替代。

例子

在本例中,您将创建四种不同类型的代理。

  • 智能代理:大多数矿工都不与人交往。他们不想分享任何关于他们珍贵战利品的信息。其他矿工都管不住自己的脏嘴。这些矿工不得不吹嘘和广播他们开采的黄金数量。您将使用智能代理来模拟这个场景。您将使用内置的 Laravel 事件在挖掘发生时进行广播。见图 15-5 。

    A435115_1_En_15_Fig5_HTML.jpg

    图 15-5。

    Smart proxy - loudmouth miners

  • 保护代理:你有一座你想要开采的金矿。有些矿你想开采多少就开采多少。你想要限制在任何给定时间可以开采多少的其他矿。您将为此使用保护代理。见图 15-4 。

    A435115_1_En_15_Fig4_HTML.jpg

    图 15-4。

    Protection proxy - lawful mining

  • 远程代理:您有一个想要使用的远程 restful API。您将使用 Guzzle 客户端来处理这个问题。您的代理会让它看起来好像根本没有 restful API。这个代理对您的客户机隐藏了使用 JSON 和 HTTP 的细节。见图 15-3 。

    A435115_1_En_15_Fig3_HTML.jpg

    图 15-3。

    Remote proxy - fetching people

  • 虚拟代理:您将为文件阅读器创建一个虚拟代理。文件读取器被设计成读取文件并从文件中提取信息。当它构造时,它将文件加载到内存中。这里的问题是,您可能同时使用许多这样的文件读取器,并且除非绝对需要,否则您不想将文件加载到内存中。见图 15-2 。

    A435115_1_En_15_Fig2_HTML.jpg

    图 15-2。

    Virtual proxy - file reader

履行

虚拟代理(实验 1 和 2)

虚拟代理仅在需要时用于构造对象。在这个例子中,您有一个名为FileReader的类。文件读取器在首次构造文件时将文件加载到内存中。如果你有许多不同的FileReader对象,你将消耗大量的内存。有时你不需要打开文件,直到后来。例如,如果您有 100 个文件读取器对象,您可能只能选择操作其中的 10 个。因此,在内存中保存其他 90 个文件是对资源的浪费。你的虚拟代理将推迟FileReader的建造。

在第一个实验中,您将看到内存猪FileReader对象使用了多少内存。这样,您可以比较在第二个实验中使用虚拟代理节省了多少内存。

app/experience 1 . PHP

$benchmark1 = memory_get_usage();
$baseDir = base_path();
$files = ['files/file1.txt', 'files/file2.txt', 'files/file3.txt'];

foreach ($files as $index => $file) {
        $files[$index] = new App\File\FileReader($baseDir . $file);
}

$benchmark2 = memory_get_usage();
$difference = $benchmark2 - $benchmark1;

print "Memory used: {$difference}" . PHP_EOL;

当你运行这个的时候,你可能会得到一个和我不同的数字。我的实验#1 输出Memory used: 4373840。这大约是 4.2MB。需要注意的是,此时您甚至没有使用FileReader对象。一个数组中只存储三个文件读取器。为了理解为什么会消耗这么多内存,让我们来看看实际的FileReader类。

app/文件/文件管理器. php

namespace App\File;

class FileReader implements ReaderInterface
{
        // somebody wrote this class so that it

        // loads a damn file when you construct... geesh

        public function __construct($path)
        {
                $this->file = file_get_contents($path);
                $this->path = $path;
        }
        public function countOccurancesOfWord($word)
        {
                return substr_count($this->file, $word);
        }
}

为什么不重构这个文件阅读器?为什么不简单地将file_get_contents移出构造函数?这将解决问题。这个类非常简单,所以很容易重构。然而,有两件事。一是不是所有的类都这么简单。第二,我想演示一个虚拟代理。所以发挥一下你的想象力,看看这个例子。求你了。谢谢你。我知道如果我礼貌地请求你,你会同意的。为了创建一个虚拟代理,你创建了一个被巧妙地称为FileReader Proxy的类。

app/文件/文件管理器 xy.php

namespace App\File;

class FileReaderProxy implements ReaderInterface
{
        public function __construct($path)
        {
                $this->path = $path;
        }
        public function countOccurancesOfWord($word)
        {
                return $this->fileReader()->countOccurancesOfWord();
        }
        protected function fileReader()
        {
                if (! $this->fileReader) {
                        $this->fileReader = new FileReader($this->path);
                }
                return $this->fileReader;
        }
}

这只是延迟了FileReader的创建,直到countOccurancesOfWord最终被调用。虽然没有在这里实践,但我可以初始化一个新的FileReader,只是为了在 count occurrences 方法完成后立即销毁它。一遍又一遍地从一个文件中读取可能没有将文件存储在内存中有效,直到您完成它。在这种情况下,您将新的文件读取器存储在类的实例变量中,以便以后可以再次使用它。我向你挑战,换个方式试试。不要存储文件阅读器,看看这样节省了多少内存。说到内存使用,我们来运行实验 2。除了一个关键的区别,大部分代码都是相同的。

app/experience 2 . PHP

foreach ($files as $index => $file) {
        $files[$index] = new \App\File\FileReaderProxy($baseDir . $file);
}

运行这个实验会产生输出Memory used: 10552 (10KB)。使用 10KB 的空间比使用 4000KB 的空间有效得多。当然,在所有三个文件上调用countOccurancesOfWord方法将使用相同数量的内存。为什么要经历这些麻烦?在这个实验中,假设不是每个文件都会被加载。您还保留在每次调用完countOccurancesOfWord方法时修改代理并从内存中删除文件的权利。这有助于保持空间空闲。不过,这不是这个练习的重点。真正的要点是理解虚拟代理如何允许你改变另一个类的性能。

远程代理(实验 3)

当您有远程运行的代码,但您希望透明地将其视为在本地运行时,远程代理非常有用。在本例中,您为一个 HTTP RESTful JSON 服务创建了一个代理。您可以直接调用 web 服务。您也可以直接处理 JSON。代理可以为您消除一些复杂性。您调用的这个 API 会找到一个人员列表。返回的人将有一个你需要的有偿或无偿标志。您将循环查找所有尚未付账的人。

app/experience 3 . PHP

$api = new \App\Api\ApiProxy;
$people = $api->findPeople();

foreach ($people as $person) {
        if (! $person->paid) {
                print "{$person->name} has not paid yet!" . PHP_EOL;
        }
}

实验 3 中没有任何地方提到 HTTP 协议或 JSON。查看这段代码,似乎这里的一切都是在本地运行的。ApiProxy 处理 HTTP 客户机的美味佳肴。对于您的 HTTP 客户端,您使用 Guzzle,因为它非常适合使用。你可以建立一个真正的服务器进行通信,但这需要大量的工作。相反,你利用 Guzzle 的嘲笑能力来返回嘲笑的响应。

app/Api/MockedWebCalls.php

use GuzzleHttp\Message\Response;

use GuzzleHttp\Stream\Stream;

$json = json_encode([
        ['id' => 1234, 'name' => 'John', 'paid' => false ],
        ['id' => 2345, 'name' => 'Joe', 'paid' => true ],
]);

$stream = Stream::factory($json);
$response = new Response(200);
$response->setBody($stream);

\App\Api\HttpClient::$mocks = [$response];

不过,这种嘲笑与您的远程代理模式没有任何关系。这只是在嘲笑人们的反应。当您第一次调用 HttpClient 时,它将返回编码的 JSON 字符串作为响应,状态代码为 200。对 HttpClient 的任何额外调用都将导致抛出异常。到目前为止,这是一个巨大的代码。让我们看看您实际的远程代理。

app/Api/ApiProxy.php

namespace App\Api;

class ApiProxy

{
        public function findPeople()
        {
                $client = new \App\Api\HttpClient;
                $response = $client->get('http://some.api.com/find/people');

                $peopleAsJson = $response->json();
                $people = [];

                foreach ($peopleAsJson as $personAsJson) {
                        $person = new App/Person;
                        $person->id = $personAsJson['id'];
                        $person->name = $personAsJson['name'];
                        $person->paid = $personAsJson['paid'];
                        $people[] = $person;
                }
                return $people;
        }
}

ApiProxy从 API 服务器获取 JSON 数据,并将其转换成一个由ApiPerson对象组成的数组。在只传递数据的情况下,创建Person对象可能不值得。现在,让我们假设您想要的不仅仅是 JSON。你应该选择那些可能有方法的类。在 PHP 中使用 JSON 对象确实给了你一些数据结构,但是它没有给你任何类方法。此外,您可以键入提示并扩展一个类;仅仅用 JSON 是无法做到的。这个例子的目的是展示如何通过为远程 API 创建一个代理来简化它的工作。

保护代理(实验 4)

有时候你需要保护一些东西。这是保护代理的工作。在本例中,您将模拟一个金矿和矿工。每个矿都有一定数量的黄金。矿工可以开采金矿,并继续这样做,直到金矿耗尽黄金。镇上来了一位新警长,他说没有一个矿工可以每天开采超过 500 盎司的黄金。让我们看看这个法则如何作为你的保护代理来应用。

app/experience 4 . PHP

$miner = new \App\Mining\Miner('Big Bad John');
$goldmine = new \App\Mining\MiningLaws(new \App\Mining\Goldmine(10000));

// it is okay to mine a little bit at a time

$amount1 = $miner->mine($goldmine, 10); // mined 10

print "{$miner->name} attempts to mine 10 ounces and got $amount1" .
 PHP_EOL;

$amount2 = $miner->mine($goldmine, 50); // mined 50

print "{$miner->name} attempts to mine 50 ounces and got $amount2" .
 PHP_EOL;

$amount3 = $miner->mine($goldmine, 500); // only 100 due to mining l

aws proxy

print "{$miner->name} attempts to mine 500 ounces and got $amount3"
. PHP_EOL;

在本例中,您有矿井和矿工。矿工可以采矿,从矿井中提取资源。

app/Mining/Miner.php

namespace App\Mining;

class Miner

{
        public function __construct($name)
        {
                $this->name = $name;
        }
        public function mine(Mine $mine, $amount)
        {
                return $mine->extract($amount);
        }
}

接下来,我们来考察金矿类。它记录了可供开采的黄金数量。金矿是用一定数量的可开采的黄金来初始化的。他们不能提取比现有的更多的黄金。

app/Mining/Goldmine.php

namespace App\Mining;

class Goldmine implements Mine
{
        const TYPE = 'gold mine';

        protected $amountAvailable;

        public function __construct($amountAvailable)
        {
                $this->amountAvailable = $amountAvailable;
        }
        public function extract($amount)
        {
                if ($amount > $this->amountAvailable) {
                      $amount = $this->amountAvailable;
                }
                $this->amountAvailable -= $amount;

                return $amount;
        }
}

这一类中没有任何东西阻止矿工一次开采超过 100 盎司的黄金。这就是你的保护代理的用武之地。在实验 4 中,一名矿工试图开采 10、50 和 500 盎司的黄金。你可以在你的Goldmine中放置条件逻辑,不接受超过 100 盎司的黄金,但是将采矿法与实际的矿分开会给你更多的灵活性。它是灵活的,因为它允许您保护其他类型的矿山,并将采矿法与实际的金矿脱钩。另一个好处是,挖掘规则可以在运行时修改。所以让我们看看这个采矿法保护代理是如何工作的。

app/Mining/MiningLaws.php

namespace App\Mining;

class MiningLaws implements Mine
{
        public function __construct(Mine $mine)
        {
                $this->Mine = $Mine;
        }

        public function extract($amount)
        {
                // limit to only 100 units at a time

                if ($amount > 100) {
                        $amount = 100;
                }

                return $this->mine->extract($amount);
        }
}

保护代理保护底层对象。你在利用组合而不是继承。你可以让MiningLaws延长Goldmine,但是在那种情况下,那些法律将会与金矿联系在一起。当你想使用其他类型的矿山,如铜矿或煤矿,你不能重复使用这些相同的法律。在某些情况下这可能没问题。然而,一般来说,当您耦合到接口而不是具体化时,更容易适应变化。

说到你本可以做得不同的事情,为什么不在这里抛出一个异常呢?简单的回答是因为我想引起你的注意。如果您抛出了一个CallThePoliceException?,而不是将数量更改为 100,这是处理权限和保护代理时的一个好习惯。抛出异常是一个很好的练习阶段。如果你愿意的话,我把它作为一个练习留给你去尝试。

智能代理

有时你想给一个对象增加额外的功能。这类似于装饰工。它们的不同之处在于,智能代理通常将其附加功能隐藏在公共接口下。装饰器可以添加额外的新公共方法,智能代理可能不会这样做。让我们向 miner 应用添加一个事件 fire/broadcast。这里唯一改变的是,当一个矿工开采一些东西时,你希望一个事件被触发。您的名为LoudMouthMiner的智能代理将为您处理此事。

app/Mining/loudhouthminer . PHP

namespace App\Mining;

class LoudMouthMining extends Miner
{
        public function __construct($name, Illuminate\Contracts\Events\Dispatcher = null)
        {
                parent::__construct($name);
                $this->event = $event ?: app('Illuminate\Contracts\Events\Dispatcher');
        }

        public function mine(Mine $mine, $amount)
        {
                $amount = parent::mine($mine, $amount);
                $this->event->fire('loud.mouth.mined', [$this, $mine, $amount]);
                return $amount;
        }
}

在关于保护代理的部分,我谈到了使用复合。这里你把它混合起来,使用继承。你把Miner当作一个抽象基类。虽然它没有被命名为 abstract,但它足够基本,所有其他的 miner 类型都可以从它扩展。如果您的父类Miner中有其他方法,您就不必覆盖它们。这个代理做的唯一一件事是添加一个事件处理程序对象,该对象在 mine 方法内部被触发。因此,你不需要使用全合成,因为你不需要那么多的灵活性。做最容易的事。如果你不确定Miner类以及它会带来多大的变化,你可能会坚持使用合成,因为就像我之前说的,它更容易适应未来的变化。

假设您不想将激发的事件添加到您可能创建的不同类型的Miner中。从代码的角度来看,这是很困难的。您最终将不得不扩展一个LoudMouthMiner或者编写重复的代码。更好的方法是重构并使用组合。你可以在构造函数中注入一个Miner类型,这样LoudMouthMiner将不再从基类Miner中继承。够了。我们来看实验 5。

app/experience 5 . PHP

Event::listen('loud.mouth.mined', function (\App\Mining\Miner $miner, \App\Mining\Mine $mine, $amount){
        print "{$miner->name} gone done mined {$amount} from the ol' " .
        $mine::TYPE . PHP_EOL;
});

$miner = new \App\Mining\LoudMouthMiner('Big Bad John');
$goldmine = new \App\Mining\Goldmine(10000);
$miner->mine($goldmine, 10);

在 Laravel 的应用上注册事件处理程序将在触发loud.mouth.mined事件的任何时候触发这个闭包。匿名功能打印出矿工的姓名、开采量和矿井类型。当您在$miner上调用mine方法时,事件将被触发并处理。除了在内部触发一个事件之外,大嘴矿工的行为与普通矿工非常相似。这是一个智能代理。

结论

代理模式有哪些缺点?一个缺点是有时重构会更容易。在您的FileReader的例子中,您创建了另一个完整的类,您必须管理它来解决内存使用问题。代理是您必须维护的另一个类。你应该确信这是值得的。

另一个缺点是,当你使用 composition 时,你可能会编写许多类似包装器的方法来调用底层的真实主题。代理为您提供了两个地方来维护真正的主题代码。你可以通过继承来解决这个问题,但是这意味着你的基类会变得更加复杂。

除了缺点之外,当您想要用一个对象替换另一个占位符时,代理是一个有用的模式。代理的好处包括性能的提高、更简单的界面和增加额外的功能。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 233 页

十六、责任链

$> git checkout chain_of_responsibility

目的

通过给多个对象一个处理请求的机会,避免将请求的发送方耦合到接收方。链接接收对象,并沿着链传递请求,直到有对象处理它。 1

应用

生活中的许多事情都遵循责任链模式:军队、企业,甚至赌场老丨虎丨机。举个例子,打电话给你手机公司的客户支持。通常从自动语音系统开始。如果这还不能让你满意,那么你会被转到基本的第 1 级支持,从那里你可以一步步往上爬,直到你发现自己在和第 4 级工程师争论。在一场关于冰棍的激烈争论后,你要求和他的经理通话。

希望在这个指挥链的某一点上,你的要求得到满足。如果在这个过程中的任何一点,你的电话断线了,你必须用自动语音系统从零开始。

抽象结构

  • Client会在某个Handler的具体实例上调用handleRequest。在某个时刻,客户端的请求被满足,客户端接收到某个请求。见图 16-1 。

    A435115_1_En_16_Fig1_HTML.jpg

    图 16-1。

    Chain of responsibility pattern

  • Handler是一个抽象类或接口。所有具体的处理程序都是从处理程序扩展而来的。所有的处理程序可能有也可能没有后继者。如果具体的处理程序不能处理这个请求,那么这个请求就被传递给它的继任者。

  • ConcreteHandler1/ConcreteHandler2实际实现handleRequest方法。但是请记住,处理程序不一定处理请求。请求可以交给实现Handler接口的后继者。

例子

跳街 37 号的生活很艰难。大坏鸟垄断了方圆 20 个街区的毒品市场,得分就像拜访坏脾气的奥斯卡一样简单。我相信你熟悉杂草的不同测量方法;然而,为了清楚起见,让我们在这里列出它们。

  • 克:基本单位

  • 第八:3.5 克

  • 四重:7 克

  • 盎司:28 克

  • 千克:1000 克

一个客户会要求一定数量的大麻。所有的请求都以不高兴的奥斯卡开始。如果客户的要求太高,爱发牢骚的奥斯卡会让他的老板帮你牵线搭桥。如果他的老板不能满足你的毒瘾,他会把你送上毒品交易链。这样一直持续到一个人都不剩。大坏鸟是头儿,所以如果你去见他,那么你要么是在开世界上最大的派对,要么你就有大麻烦了。为了到达大鸟,你必须通过一个关卡。这家伙是个直截了当的数字和商人,他也不怕开枪。所以要小心。

坏脾气的奥斯卡会和各种各样的客户打交道。一个客户甚至向他要一块饼干(猜猜是谁?).因为奥斯卡只是一个普通的街头流浪儿,他从来不给任何客户超过 3 克的毒品。为了得到 3 克以上,你必须对付奥斯卡的老板。他的老板嗤之以鼻,是个EighthDealer只会在你的要求小于 7 克的情况下为你服务;否则,你会见到斯尼菲的老板,他是一个QuadDealer。你的要求会不断上升,直到得到满足。

以下是您将为每个类定义的规则:

  • GramDealers:份量不超过 3 克

  • EighthDealers:份量不超过 7 克

  • QuadDealers:份量不超过 28 克

  • OunceDealers:不超过 1000 克,有麻醉保护

  • KiloDealers:缉毒保护

示例结构

图 16-2 为结构示意图。

A435115_1_En_16_Fig2_HTML.jpg

图 16-2。

Lots of dealing going on this street

履行

Dealer类中有一些基本的助手方法。它是你的基础抽象类,所有的具体处理程序都将继承它。帮手的方法包括为客户服务,让老板处理,把计量换算成克,甚至拍客户。

app/Dealer.php

namespace App;

abstract class Dealer;

{
        protected $boss;

        protected $name;

        abstract public function dealWith(Client $client);

        public function __construct($name)

        {
                $this->name = $name;
        }

        public function boss(Dealer $dealer)
        {
                $this->boss = $dealer;
        }

        protected function shoot(Client $client)
        {
                print "{$client->name} got shot" . PHP_EOL;
        }

        protected function serve(Client $client)
        {
                print "{$client->name} got {$client->request} from {$this->name}" . PHP_EOL;
        }
        protected function letTheBossDealWith(Client $client)
        {
                if ($this->boss) return $this->boss->dealWith($client);
        }
        protected function convertRequestToGrams(Client $client)
        {
                // returns requested amount in your basic grams unit

        }
}

当从Dealer类继承时,有一个抽象方法你必须实现,那就是dealWith。首先,你要弄清楚客户想要多少。接下来,你来处理。对于一个克经销商来说,当要求的数量在 1 到 3 克之间时,你就为客户服务了。你给客户的服务不能少于 1 克。任何超过 3 克的东西都需要你的老板来处理。

app/GramDealer.php

namespace App;

class GramDealer extends Dealer
{
        public function dealWith(Client $client)
        {
                $amount = $this->convertRequestToGrams($client);

                if ($amount < 1) return;

                if ($amount > 3) return $this->letTheBossDealWith($client);

                return $this->serve($client);
        }
}

GramDealer, EighthDealer, QuadDealer, OunceDealerKiloDealer都共享同一个接口dealWith,非常相似。当他们无法让客户满意时,他们也会依赖老板。在您的模拟中,红眼 Mos 将从 Oscar 获得 2 克,EarnEz 从 Kabby 获得 1 盎司,以此类推。

app/simulator.php

// create the dealers

$grouchyOscar = new \App\GramDealer('Grouchy Oscar');
$dealer2 = new \App\EighthDealer('Sniffy');
$dealer3 = new \App\QuadDealer('Kabby');
$dealer4 = new \App\OunceDealer('AC Countant');
$dealer5 = new \App\KiloDealer('The Big Bad Bird');

// setup the chain of responsibility

$grouchyOscar->boss($dealer2);
$dealer2->boss($dealer3);
$dealer3->boss($dealer4);
$dealer4->boss($dealer5);

// all deals start with Grouchy

$grouchyOscar->dealWith(new \App\Client('Red Eye Mos', '2 grams'));
$grouchyOscar->dealWith(new \App\Client('EarnEz', 'ounce'));
$grouchyOscar->dealWith(new \App\Client('Tellme Fatz', 'quad'));
$grouchyOscar->dealWith(new \App\Client('Cookie Hipster', 'cookie'));
$grouchyOscar->dealWith(new \App\Client('Zo 2 Easy', '99 grams'));
$grouchyOscar->dealWith(new \App\Client('Bertie', '4 eighths', $narc = tr\
ue));

$grouchyOscar->dealWith(new \App\Client('Seth Rogen', '2 kilos'));

// Sniffy and Kabby are taken out of play

// because Bertie busted them

$grouchyOscar->boss($dealer4);

// Bertie the Narc gets greedy

// and gets shot

$grouchyOscar->dealWith(new Client('Bertie', 'kilo', $narc = true));

看到这有多灵活了吗?格鲁希甚至在执行过程中把他的老板换成了会计师。在这个特定的模拟中,您以线性方式组织了每个经销商,但是责任链允许您非常灵活地更换继任者,而无需更改子类。这个链条让斯尼菲的老板卡比与格鲁希脱钩。这很重要,因为在第 37 跳街,毒贩经常被逮捕或枪杀。能够在运行时动态替换经销商可以让您的程序继续运行。假设您已经将每个类耦合在一起。你会有一些非常不灵活的东西。见图 16-3 。

A435115_1_En_16_Fig3_HTML.jpg

图 16-3。

Way too much inheritance, homie!

这种类型的链式继承将您锁定在一个固定的流程中。你不需要这种锁定,尤其是当你想让一个GramDealer跳过一个EighthDealer而一个QuadDealer直接到达一个OunceDealer的时候。

结论

我喜欢把责任链模式理解为“当你的客户令人讨厌时,给你的老板打电话”模式。你的老板比你赚得多是有原因的,所以让他去处理更大的问题。

使用这种模式时,应该警惕循环引用。除非您的应用处理它们,否则您可能会走向无限循环。基本上,在下面的代码中,你是在说奥斯卡的老板是 Sniffy,Sniffy 的老板是奥斯卡,这没有意义。下属不能当老板。这就是你陷入无限循环的原因:循环引用。这是这种模式的一个缺点。

危险的循环引用

$grouchyOscar->boss($dealer2);
$dealer2->boss($grouchyOscar); // WAT?

$grouchyOscar->dealWith(new Client('Infinite Loop Man!', '2 grams');
 // Oscar handles this

$grouchyOscar->dealWith(new Client('Infinite Loop Man!', 'kilo');
// loopty loop forever!

这种模式的好处是它允许您解耦请求链。在这方面,它非常灵活。对于那些有计算机科学头脑的人来说,你可能会看到责任链模式和面向对象的有限状态机的相似之处,其中每个状态都是终结的。然而,从技术上讲,一个有限状态机可以有多个后继。责任链模式只有一个继任者。有多个怎么知道选哪个接班人?只有一个继任者意味着你不用担心挑选一个。这个限制使得链式模式比有限状态机更容易使用。如果你发现自己需要一个有限状态机,那么就去看看关于状态模式的那一章。

您已经看到了将请求解耦到单独的类如何帮助您灵活地连接和完成请求。这是一个可以使用的强大模式。不过要记住,伴随着巨大力量而来的是邪恶的外星人 2大地精 3

Footnotes 1

设计模式:可重用面向对象软件的元素,第 251 页

2

http://en.wikipedia.org/wiki/Venom_%28comics%29

3

http://en.wikipedia.org/wiki/Hobgoblin_%28comics%29

十七、命令

$> git checkout command

目的

将请求封装为一个对象,从而允许您用不同的请求参数化客户端,对请求进行排队或记录,并支持可撤销的操作。 1

应用

当您需要将执行动作的对象与调用动作的对象分离时,这是一个很好的使用模式。然而,最后一部分到底是什么意思呢?假设您想要将一系列做各种不同事情的事件排队。稍后您将清除队列,这将实际调用所有这些操作。你为什么不立即调用/执行该操作?将一些动作排在后面有一些好处。一个是您可以保持调用动作的顺序。这允许您拥有撤消功能。另一个好处是您将请求具体化为一个对象。这使得定制新的请求就像创建新的命令类一样简单。

许多现实世界的例子都遵循命令模式。举个例子,一个顾客(也叫客户)点了他的食物。你可以把命令想象成命令。服务员是点菜的人。厨师收到订单,为顾客准备美味的晚餐。见图 17-1 。

A435115_1_En_17_Fig1_HTML.jpg

图 17-1。

Too many cooks (Clipart provided by openclipart.org.2 3 4 5)

抽象结构

  • Client创建具体的命令实例供Invoker使用。见图 17-2 。

  • Receiver是命令将要操作的对象。这可能是一个文档、数据库或任何数量的类,它们保存您正在执行命令的实际数据。这些命令都在这个 receiver 对象上操作。

  • Command是一个抽象类,定义了所有具体命令的结构。

  • ConcreteCommand是一个特定的命令类。虽然不是必需的,但它通常具有回滚操作的能力。命令作用于接收器。

  • Invoker是实际调用命令的内容。假设你是客户,电视是接收器;Invoker将是遥控器。一些可能被调用的命令是音量增大和减小。

A435115_1_En_17_Fig2_HTML.jpg

图 17-2。

Command pattern UML

例子

您将探索在 Laravel 6迁移 是如何工作的,而不是创建一个虚构的示例。Laravel 中的迁移受到了 Rails 框架的启发,并提供了一种一致的方式来为数据库创建表、列和索引。除了创建之外,迁移还提供回滚功能,以防您需要撤消数据库更改。

等一下。我以为这一章是关于命令模式的?是的。Laravel 迁移是在野外发现的命令模式的一个例子。我已经冒昧地将 Laravel 框架类映射到抽象 UML 图(图 17-2 )。因此,您将了解更多关于迁移和新模式的知识!谈两只死鸟。这样你就不会觉得被欺骗了,我将用一个使用电视的命令模式的简化例子来结束这一章。

示例结构

图 17-3 为结构示意图。

A435115_1_En_17_Fig3_HTML.jpg

图 17-3。

Laravel migrations and the command pattern

履行

您要做的第一件事是创建一个迁移,在您的数据库中创建一个users表。您没有设置数据库,所以对于这个例子,您将配置 Laravel 使用 SQLite。

DB_CONNECTION=sqlite

如果使用 SQLite,Laravel 将尝试使用默认名称为 database/database.sqlite 的数据库。用户需要手动创建该文件(即在命令行上使用 touch database/database.sqlite)。

确保为 PHP 启用了 SQLite 驱动程序。在 Ubuntu 上,这和sudo apt-get install php7.0-sqlite sqlite一样简单。如果不想用 SQLite,可以随便用 PgSQL 或者 MySQL。您使用 SQLite 是因为它设置起来很容易。

如果您查看database/migrations文件夹,您应该会注意到用户的迁移文件。它将附有时间戳,文件名的最后一部分将是create_users_table。作为一种常见的良好做法,您应该根据您正在执行的操作来命名您的迁移。create_users_table这个名字清楚地解释了它的目的和作用。如果你不创建一个表呢?如果要向现有的表中添加一个新字段,该怎么办?然后您可以将您的迁移命名为add_field1_to_users_table

  • 命名迁移时,尽可能具体。

这有什么关系?这很重要,因为您不希望有两个名为do_stuff的迁移;太令人困惑了,伙计。每次迁移都有一个目的。请尝试在文件名中清楚地说明这一目的。

这是生成的文件。

数据库/迁移/2014 _ 07 _ 11 _ 185334 _ create _ users _ table . PHP

class CreateUsersTable extends Migration
{
        public function up()
        {
                // do command action

        }

        public function down()
        {
                // undo command action

        }
}

CreateUsersTable是从抽象的Migration类扩展而来的具体命令。填充up()down()部分是你的工作,所以现在让我们使用 Laravel 令人敬畏的模式构建器 7 来完成这项工作。

数据库/迁移/2014 _ 07 _ 11 _ 185334 _ create _ users _ table . PHP

public function up()
{
        Schema::create('users', function ($table) {
                $table->increments('id');
                $table->string('first_name');
                $table->string('last_name');
                $table->string('email')->unique();
                $table->string('password');
                $table->timestamp('last_login_at')->nullable();
                $table->timestamps();                  // gives you created_at and updated_

        });
}

public function down()
{
        Schema::drop('users');
}

给你。您创建了一个新的users表,其中包含一些字段:first_namelast_nameemail等等。让我们抓住这个坏男孩!

php artisan migrate

如果幸运的话,您应该会看到一条如下所示的消息,让您知道您创建的迁移命令已成功运行。如果没有,请确保您的数据库设置和配置正确。

Migrated: 2014_07_11_185334_create_users_table

如果您决定撤销这个迁移,您可以运行php artisan migrate:rollback,它从您的CreateUsersTable类运行down()方法,然后从模式中删除users表。

注意,如果您连续多次运行php artisan migrate,脚本不会每次都尝试创建users表。您的模式的调用者称为Migrator,它将之前运行的迁移存储在一个名为migrations的数据库表中。一会儿你会看一看Migrator;现在让我们检查一下你的模式的client类:??。

vendor/laravel/framework/src/Illuminate/Database/Console/Migrations/migrate command . PHP

public function __construct(Migrator $migrator)
{
  parent::__construct();
  $this->migrator = $migrator;
}

public function fire()
{
  if (! $this->confirmToProceed()) {
      return;
  }
  $this->prepareDatabase();
  $this->migrator->run($this->getMigrationPaths(), [
     'pretend' => $this->option('pretend'),
     'step' => $this->option('step'),
  ]);
  foreach ($this->migrator->getNotes() as $note) {

     $this->output->writeln($note);
  }
  if ($this->option('seed')) {
      $this->call('db:seed', ['--force' => true);
  }
}

vendor/laravel/framework/src/Illuminate/Database/Migrations/Migrator . PHP

public function run($paths = [], array $options = [])
{
    $this->notes = [];
    $files = $this->getMigrationFiles($paths);
$ran = $this->repository->getRan();
    $migrations = Collection::make($files)
                    ->reject(function ($file) use ($ran) {
                        return in_array($this->getMigrationName($file), $ran);
                    })->values()->all();
    $this->requireFiles($migrations);
    $this->runMigrationList($migrations, $options);
    return $migrations;
}

public function runMigrationList($migrations, array $options = [])
{

if (count($migrations) == 0) {
        $this->note('<info>Nothing to migrate.</info>');
        return;
    }
    $batch = $this->repository->getNextBatchNumber();
    $pretend = Arr::get($options, 'pretend', false);
    $step = Arr::get($options, 'step', false);
    foreach ($migrations as $file) {
        $this->runUp($file, $batch, $pretend);
        if ($step) {
            $batch++;
        }
    }
}

protected function runUp($file, $batch, $pretend)
{
    $file = $this->getMigrationName($file);
    $migration = $this->resolve($file);
    if ($pretend) {
        return $this->pretendToRun($migration, 'up');
    }

    $this->runMigration($migration, 'up');
    $this->repository->log($file, $batch);
    $this->note("<info>Migrated:</info> {$file}");
}

客户机利用构造函数中传递的Migrator并最终运行迁移器。上面要注意的重点是$this->migrator->run($paths = [], array $options = [])法。这就是Invoker的开始。如果您想了解迁移是如何回滚的,您可以查看另一个名为RollbackCommand的命令。它像MigrateCommand一样使用Migrator来调用rollback方法,而不是run

Migrator作为Invoker运行每个迁移命令。但是等等。为什么Client知道Invoker?我以为客户端依赖于CommandReceiver ?上面的 UML 图有错吗?这真的不是命令模式吗?当你在野外发现模式时,它们并不总是与“我不知道泰勒·奥特威尔在想什么”相匹配。我怀疑当他写这些东西的时候,命令模式是否在他的脑海中。更有可能的是,他在做他觉得正确的事情,于是这种命令模式出现了。没关系。你不是警察。

事实上,泰勒写这个的方式,他几乎已经排除了一个Receiver类。不过,不要让这欺骗了你,因为在 Laravel 中,使用所谓的 facade 8 (不要与 facade 模式混淆)允许你从任何地方全局访问框架的许多不同部分。在您的CreateUsersTable中,Schema构建者承担Receiver的角色。

多棒啊。想象一下,在运行任何类型的迁移之前,您必须检查它以前是否运行过。那不是很糟糕吗?幸运的是,您所要担心的只是填写每次迁移的up()down()部分;剩下的在拉勒维尔手上。迁移通常用于改变模式,但不限于此。您可以使用 up()将照片从互联网下载到 public/awesome-cat-photos 目录中。down()命令可以简单地删除 public/awesome-cat-photos 目录。

需要指出的是,迁移是按顺序调用的。这就是为什么每次迁移的文件名中都有一个时间戳:以确保所有迁移都按照特定的时间顺序运行。命令模式也包含时间顺序。当您在文本编辑器中按 Ctrl+Z 时,它应该会撤消您刚刚做的最后一件事。其他任何事情都会令人沮丧。

命令模式的出现正是为了解决这个问题:文本编辑器的撤销按钮!作者不想将命令请求与文档本身联系起来。他们将文档用作一个Receiver,通过您刚才看到的命令模式,可以很容易地撤销在文档对象上调用的命令。

电视命令模式示例

在这个简短的例子中,我将使用电视、遥控器和手来说明命令模式是如何工作的。他们说一张照片胜过一千句话。不过,谁在数呢,阿米赖特?任何人,这里有一张图描述了你想要编码出来的情况(见图 17-4 )。

A435115_1_En_17_Fig4_HTML.jpg

图 17-4。

Using the command pattern (clip art provided by openclipart.org)

您将制作一个电视遥控器,它可以执行命令,但也可以存储可以撤消的命令的历史记录。大多数电视遥控器不存储命令的历史。不是很好的用户体验。如果你按下音量增大按钮,最好按下音量减小按钮,而不是撤销按钮。在本例中,您正在构建一个间谍遥控器。它将保存所有发送命令的历史记录。为什么呢?因为你是个间谍。这是你的遥控器,它充当调用者。

app/TV/remote control . PHP

namespace App\Television;

class RemoteControl

{
        private $history;

        public function __construct()
        {
                $this->history = new \SplStack;
        }

        public function invoke(Command $command)
        {
                $this->history->push($command);
                $command->fire();
        }

您在命令对象的堆栈中跟踪历史。但是,请注意调用者并不知道实际命令在做什么。调用程序只触发命令并保存历史记录。您可以添加更多功能。您将提供一种撤消命令的方法。

app/TV/remote control . PHP

public function undo($amount = 1)
{
        while ($amount-- > 0 && ! $this->history->isEmpty())
        {
                $command = $this->history->pop();

                $command->undo();
        }
}

这个undo函数从堆栈中弹出命令,并对命令调用undo方法。那么命令看起来像什么呢?这是一个处理不断变化的卷的命令。

app/TV/change volume . PHP

namespace App\Television;

class ChangeVolume implements Command
{
        protected $tv;

        public function __construct(Television $tv, $delta = 1)
        {
                $this->tv = $tv;
                $this->delta = $delta;
        }

        public function fire()
        {
                $volume = $this->tv->getVolume();
                $this->tv->setVolume($volume + $this->delta);
        }

        public function undo()
        {
                $volume = $this->tv->getVolume();
                $this->tv->setVolume($volume - $this->delta);
        }
}

该命令用一个电视接收机对象和一个增量初始化。Delta 是一个整数,用于知道每次执行该命令时电视音量应该改变多少。大多数命令都耦合到接收器。如果您希望能够与实现电视接口的其他具体类一起工作,您可以将接收器作为一个接口。不过,在这个例子中,您直接将命令耦合到接收器。你的接收器,电视,负责存储音量。它可以存储其他属性,如频道号、输入源、视频/音频设置。为了简化这个例子,电视只存储音量。

app/TV/Television . PHP

namespace App\Television;

class Television

{
       protected $volume;

       public function getVolume()
       {
               return $this->volume;
       }

       public function setVolume($volume)
       {
               if ($volume < 0) $volume = 0;
               if ($volume > 50) $volume = 50;
               $this->volume = $volume;
       }
}

除了充当模型和存储卷之外,Television类还有一些业务逻辑来确保没有卷是负数或超过 50。任何超过 50 的声音都会弄坏扬声器。你不想要坏了的扬声器。电视课对命令一无所知。最后,您将客户端代码付诸实施。客户端的第一步是创建invokerreceivercommand对象。

app/simulator.php

$tv = new \App\Television\Television;
$control = new \App\Television\RemoteControl;
$volumeUp = new \App\Television\ChangeVolume($tv, 1);
$volumeUpFour = new \App\Television\ChangeVolume($tv, 4);
$volumeDown = new \App\Television\ChangeVolume($tv, -1);

接下来,客户端调用命令。我在每个调用方法旁边都注释了音量的变化。这将在你撤销命令时处理。

app/simulator.php

$control->invoke($volumeUp);          // 1

$control->invoke($volumeUp);          // 2

$control->invoke($volumeDown);        // 1

$control->invoke($volumeUp);          // 2

$control->invoke($volumeUp);          // 3 <-- 6 more

$control->invoke($volumeDown);        // 2

$control->invoke($volumeUpFour);      // 6

$control->invoke($volumeUpFour);      // 10

$control->invoke($volumeUp);          // 11

$control->invoke($volumeUp);          // 12

$control->invoke($volumeUp);          // 13 <-- 4 ago

$control->invoke($volumeUp);          // 14

$control->invoke($volumeUp);          // 15

$control->invoke($volumeUp);          // 16

$control->invoke($volumeDown);        // 15 <-- current

您检查当前状态下的音量。应该是 15。接下来,回滚四个命令,然后再回滚六个,每次都确保输出音量。这些水平应该是 13 和 3。

app/simulator.php

print $tv->getVolume() . PHP_EOL;  // 15

$control->undo(4);

print $tv->getVolume() . PHP_EOL;  // 13

$control->undo(6);

print $tv->getVolume() . PHP_EOL;  // 3

所以你有它。命令模式如何工作的另一个例子。构建和处理调用者、接收者和命令的任务留给了客户端。建造工作可以委托给名为CreateRemoteControl的工厂。

结论

命令模式在处理请求时非常有用,如果将它们作为对象处理,会更容易处理。在这些例子中,你可以写一个大类来处理电视机的所有命令。您可以编写一个大型 SQL 文件来处理数据库。这样做使得处理代码的特定部分变得困难,因为您有一大块代表所有命令的代码。您将每个命令封装到它自己的对象中。这意味着重用命令和提供可撤销的动作要容易得多。

命令模式的主要缺点是增加了处理命令的类。班级越多,尤其是班级差异很大的时候,就越容易失去凝聚力。使用迁移时,假设您创建的所有类都与数据库结构的变化有关。然而,情况并不一定如此。你可以使用迁移将海蒂·克鲁姆的图片下载到/public目录中。这是对迁移的严重滥用,但在技术上仍然是可能的。只要保持命令的目的一致,就不需要太担心整体的内聚性。

接下来,你将学习解释器模式(见图 17-5 )。

A435115_1_En_17_Fig5_HTML.jpg

图 17-5。

You’ve been beckoned. Footnotes 1

Design Patterns: Elements of Reusable Object-Oriented Software,第 263 页

2

https://openclipart.org/detail/154837/people-cook-by-yyycatch

3

https://openclipart.org/detail/77077/waiter-by-shokunin

4

https://openclipart.org/detail/182377/notepadr-by-crisg-182377

5

https://openclipart.org/detail/77077/waiter-by-shokunin

6

http://laravel.com/docs/migrations

7

http://laravel.com/docs/schema

8

http://laravel.com/docs/facades

十八、解释器

$> git checkout interpreter

目的

给定一种语言,为它的语法定义一个表示,以及一个使用该表示来解释该语言中的句子的解释器。 1

应用

有些模式你可能永远不会用到:flyweight、singleton 和这个。无论如何我都要报道它。以防万一。我以前也错过。这一章比其他章节更理论化一点。为了理解解释器模式,我需要先谈谈语言和语法。了解如何运用语法会让你在别人看来更聪明。这也恰好是解释器模式的主要好处。

虽然这一章可能看起来非常理论化,但在现实世界中有大量的解释器。联合国使用数百名口译员。你可能已经用谷歌翻译把这首冰冻的歌从英语翻译成俄语了。那个名为 C3PO 的金色机器人已经被编程为翻译超过 600 万种语言。也许你见过美国手语翻译在舞台前挥舞手臂。

人类是天生的解释器。甚至那些只说英语的单语者也仍然把英语的声音和单词翻译成意思。这就是上下文的重要性。同一组词可能会衍生出不同的意思。为了说明上下文的价值,“我马上就去做”是什么意思这要看上下文。如果你的老板要一份 TPS 报告,这可能意味着“我将立即开始着手 TPS 报告。”然而,如果你把语境换成你的朋友建议你剃光全身的毛,在网上裸体摆姿势,你的语气可能是讽刺的,意思就变成了,“哈。哈。我不会那么做的。”同样的话。不同的语境等于不同的意义。语言很难,是的。

听起来更聪明并不是解释器模式的唯一应用。当您想要将特定于领域的语言翻译成行动时,可以使用解释器模式。如果您的应用处理汽车,那么您可能需要翻译某些机械术语。对机械师来说,售后服务意味着零件不是由原始制造商制造的。气斧是一种割炬。扳手是在引擎上工作的俚语。你想使用你的机械词汇,一个翻译可能会有用。

上下文无关语法

为什么要讨论上下文无关语法?我想告诉你什么是上下文无关语法,这样你就可以在本章的后面使用这个术语。我不会让你的生活变得更艰难,我保证。

上下文无关语法是一组生成字符串模式的规则。它由终端和非终端组成。一个非终结点由其他非终结点或终结点组成。终端只是一个符号。想象一下 a 的语言后面跟着 b,以 a 结尾。这种语言的一些有效示例如下

  • 阿伯

  • aaaaba

  • abaaaaaaa

  • aabbaa

为了表示这种语言的语法,您将使用 Backus-Naur 形式2 。你可以在维基百科上读到更多关于它的内容,但是你可能会通过阅读下一个例子来理解它是如何工作的。

<L> ::= <A><B><A>
<A> ::= 'a' | 'a' <A>
<B> ::= 'b'{<B>}

这乍一看可能很奇怪。非端子用brackets, like < >包裹。符号用引号括起来,像' '。烟斗(|)代表一种选择;你可以把它读成或,意思是你可以选择一边或另一边。所以在<A>的情况下,你可以选择符号‘a’或‘a’后跟另一个‘a’。非终结符<A>使用递归来构建一个“a”符号列表。还有一个修改过的 BNF 语法,我已经用在了<B>中。<B>规则的工作方式与<A>规则相同。{<B>}表示零个或多个实例。你可以用任何一种方式来写这些语法;这取决于你的口味。我给你看了两个,所以你可以选择。

用这种 BNF 结构写语法有什么收获?当你用这种结构写你的语法时,你可以很容易地分辨出终结符和非终结符在哪里。第二个好处是,当您有一个要遵守的规则列表时,为这种语法创建代码会更容易。函数式程序员会为每个非终结规则创建一个方法。每个方法都可以调用语法中的其他非终结方法。您将做一些类似的事情,除了您将使用类而不是方法。既然你对语法有所了解,你应该马上运用这些知识。

抽象结构

  • 使用全局上下文和解释器表达式来执行一些动作。执行的一组动作(通常在上下文中)取决于客户端调用的表达式。客户端可以使用表达式构建语法。然而,为了避免给客户端带来太多的工作,解析器类也可以处理语法的构建。见图 18-1 。

    A435115_1_En_18_Fig1_HTML.jpg

    图 18-1。

    Abstract structure

  • GlobalContext是一个保存所有表达式可用的全局数据的类。你可以在这个类中分配和查找变量。如果不需要共享数据,那么可以忽略这个类。

  • AbstractExpression是所有终端和非终端表达式使用的基类/接口。它包含了由所有子类专门定义的interpret方法。

  • TerminalExpression实现interpret方法,不依赖任何其他表达式。这些表达式规则只有“字符串”,不依赖于其他表达式。

  • NonterminalExpression实现interpret方法并依赖其他表达式。如果一个非终结表达式依赖于自身或者依赖于另一个循环的表达式,那么它很容易递归。你必须努力防止你的语法中出现非终结符,这些非终结符会陷入无限递归循环,永远不会结束。一个无限循环、永远不会终止和提供用户反馈的应用是没有用的。

为什么我们要区分非终结符和终结符?如果您检查非终结符和终结符表达式类的代码,它们看起来非常相似。两者都继承自基本表达式接口,所以在代码方面没有真正的区别。唯一真正的区别是,非终结符表达式有一个额外的属性来存储其他表达式。可能值得在 php-doc 注释中为每个表达式类声明非终结符或终结符。这让你可以快速浏览注释,甚至是 grep 出所有非终结符表达式。非终结表达式比没有进一步执行的终结表达式更难排除故障。

例子

我在互联网上找到了一些这种模式的例子。

  1. 罗马数字翻译器

  2. 反向波兰计算器

  3. 创建您自己的 SQL 语句

在构建 web 应用时,您可能不会做这些事情。很难想出一个类似于轻量级章节的例子,所以我再次依赖于时间的例子。在这个例子中,您将把某些短语解释成 PHP 日期时间。以下是一些你可能需要解释的候选短语:

  • “几天前”->“-3 天”

  • "未来很短的时间"-> "+ 10 分钟"

  • "不久的某个时候"-> "+ 1 天"

  • “很长一段时间过去了”->“1 年”

  • “五十六小时前”->“五十六小时”

通过检查这里的结构,我为你的解释器想出了一个语法。你从一个时间表达式开始,它由一些量规和方向组成。量规是时间的度量或距离。除了时间量,你还需要方向。方向要么是负的(过去),要么是正的(未来)。

<time> ::= <gauge> <direction>

<gauge> ::= 'a few' <unit> | 'a short time' | 'a long time' |
                       'sometime soon' | <measurement>

<direction> ::= 'ago' | 'in the past' | 'in the future' |
                             'goes by' | ''

<measurement> ::= <number> <unit>

<number> ::= '1' | '2' | ... | '23' | 'one' | 'two' | ... | 'twenty \
three'

<unit> ::= 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' |
                 'months' | 'years'

扪心自问,gauge表达式是终结的还是非终结的?它由几个字符串和表达式组成。这意味着它是非终结性的。你看到哪些表情了?unitmeasurement表达式可能是gauge表达式的一部分。请注意,direction表达式是终结性的,因为它没有链接到其他表达式。

那么为什么不使用正则表达式或字符串替换呢?举个小例子,这可能更容易。然而,随着复杂性的增加,字符串匹配可能会令人不安。用一个类来处理每种类型的表达式可以更容易地理解发生了什么。每个表情都有自己的工作要做。完成整个翻译可能需要几个表达式,但是你已经将每个表达式分解成(希望)可维护的部分。

示例结构

图 18-2 为结构示意图。

A435115_1_En_18_Fig2_HTML.jpg

图 18-2。

Example structure

履行

信不信由你,最困难的部分已经过去了。之前我想出了一套 BNF 语法规则。您为每个表达式规则创建一个类,并严格遵循语法。让我们为第一个表达式<time> ::= <gauge> <direction>制作一个类。

app/Time/Expressions/Time expression . PHP

namespace App\Time\Expressions;

class TimeExpression implements BaseExpression
{
        public function __construct(BaseExpression $gauge, BaseExpression
        $direction)
        {
                $this->gauge = $gauge;
                $this->direction = $direction;
        }

        public function interpret(\App\Time\TimeContext $context)
        {
                $gauge = $this->gauge->interpret($context);
                $direction = $this->direction->interpret($context);

                if ($direction != '') {
                        $time = $context->getTime();
                        $time->modify($direction . $gauge);
                        $context->setTime($time);
                }

                return $context->getTimeAsString();
        }
}

时间表达式有两个参数,一个用于度量,一个用于方向。这正是语法的读法。在量规和方向被解释后,您可以在$context中修改时间。背景很重要。如果没有上下文,你怎么知道时间是“现在”呢?你可以硬编码new DateTime或者time()。但是,这是不灵活的。当你能控制开始日期时,测试就更容易了。上下文允许你做其他的事情,比如获取和分配变量。我们来看看TimeContext级。

app/Time/TimeContext.php

namespace App\Time;

class TimeContext

{
        protected $time, $variables;
        public function __construct(\DateTime $time)
        {
                $this->time = $time;
                $this->variables = [];
        }

        public function getTime()
        {
                return $this->time;
        }
        public function setTime(\DateTime $time)
        {
                $this->time = $time;
        }

        public function getTimeAsString($format = 'Y-m-d H:i:s')
        {
                return $this->time->format($format);
        }

        public function getVariable($key, $default = null)
        {
                return $this->hasVariable($key)
                    ? $this->variables[$key] : $default;
        }

        public function setVariable($key, $value)
        {
                $this->variables[$key] = $value;
        }

        public function hasVariable($key)
        {
                return is_string($key)
                   && array_key_exists($key, $this->variables);
        }

        public function unsetVariable($key)
        {
                unset($this->variables[$key]);
        }
}

为什么可以在上下文中设置变量?我在 BNF 中没有提到任何变量,那么什么是变量呢?你很快就会明白。首先我们来看另一个表达式类,具体来说就是方向表达式<direction> ::= 'ago' | 'in the past' | 'in the future'。它是一个终结表达式,因为它不依赖于任何其他表达式。

app/Time/Expressions/direction expression . PHP

namespace App\Time\Expressions;

class DirectionExpress implements BaseExpression
{

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

        public function interpret(\App\Time\TimeContext $context)
        {
                switch ($this->literal) {
                        case 'ago': return '-';
                        case 'in the past': return '-';
                        case 'in the future': return '+';
                        case 'goes by': return '+';
                        case '': return '+';
                }

                throw new \Exception('Could not interpret literal '
                             .  $this->literal);
         }
}

方向表达式的唯一职责是返回一个“+”或“-”。加号表示将来,负号表示过去。该表达式不包含对其他表达式的外部调用。终结表达式比非终结表达式更好使用。接下来是gauge表达式,它应该返回实际的时间测量值。语法规则如下所示:

<gauge> ::= 'a few' <unit> | 'a short time' | 'a long time' | 'sometime soon' | <measurement>.

app/Time/Expressions/gauge expression . PHP

namespace App\Time\Expressions;

class GaugeExpression implements BaseExpression

{
        public function __construct($expr1, BaseExpression $expr2 = null)
        {
                $this->expr1 = $expr1;
                $this->expr2 = $expr2;
        }

        public function interpret(\App\Time\TimeContext $context)
        {
                if ($context->hasVariable($this->expr1)) {
                        return $context->getVariable($this->expr1);
                }
                switch ($this->expr1) {
                       case 'a few':
                         return '3 ' . $this->expr2->interpret
                         ($context);
                       case 'a short time':
                         return '10 minutes';

                       case 'a long time':
                         return '2 years';

                       case 'sometime soon':
                         return '1 day';
                }

                return $this->expr1->interpret($context);
        }
}

切换回TimeContext和变量。像“不久的某个时候”和“几个”这样的表达是相对的。您需要覆盖这些变量的灵活性。每个文字都有默认值,正如您在switch语句中看到的。但是,这些值可以被上下文覆盖。正如我前面提到的,这是拥有上下文的另一个好处。请注意,如果您没有找到一个文字表达式,您假设发生了<measurement>规则,并试图解释该表达式。

接下来,让我们跳到模拟器(客户端)部分。我可以给你展示更多的表达,但是我想现在你可能已经掌握了表达的概念。您可以随意浏览 git 存储库中的其他表达式。模拟器给你一个想法,如何把这些碎片放在一起。运行模拟器客户端会产生以下输出:

$> php app/simulator.php

time for now:            2015-01-31 12:34:56
a few hours in the past: 2015-01-31 09:34:56
thirty days ago:         2015-01-01 09:34:56
sometime soon:           2015-01-01 09:44:56
a long time goes by:     2017-01-01 09:44:56
a short time ago:        2017-01-01 09:34:56

让我们看看模拟器代码内部是如何生成这个输出的。在您的应用中,所有表达式都需要一个上下文,因此您需要初始化一个TimeContext。有趣的是,您将在整个客户端的执行过程中重用相同的上下文。

app/simulator.php

1   $context = new TimeContext(new DateTime('2015-01-31 12:34:56'));
2   print "time for now:" . $context->getTimeAsString() . PHP_EOL;

接下来,您将创建一个新的时间表达式。

app/simulator.php

$gauge = new GaugeExpression('a few', new UnitExpression('hours'));
$direction = new DirectionExpression('in the past');
$time = new TimeExpression($gauge, $direction);

print "a few hours in the past: " . $time->interpret($context) . PHP_EOL;

// ... look in the git repository for more examples ...

这就是了。你的第一个翻译。不太好看,是吧?即使你已经完成了所有这些工作,仍然有很多工作是客户被迫要做的。客户端也负责设置上下文和表达式。天啊,客户就不能休息一下吗?它们当然可以,这是进入下一节的一个很好的过渡,下一节将介绍一个可以这样做的小解析器。

伙计,我的解析器呢?

客户端并不总是负责连接表达式。有时您将这项工作委托给解析器。最好是解析一个字符串,而不是把不同的表达式类串在一起。解析器最初不是解释器模式的一部分。不要悲伤,我的朋友;无论如何,您都要创建一个解析器。这将使您了解如何将解释器表达式和解析器放在一起。让我们重写上面的模拟器代码,并添加一些语法糖。

app/simulator-with-parser.php

print "a few hours in the past: " . $parser->interpret('a few hours
in the past', $context) . PHP_EOL;

啊,很好,对吧?不再需要将new TimeExpression硬编码到客户端代码中。你只需要用一个字符串和上下文对象调用解析器的解释器。那么这个解析器是如何工作的呢?每个解析器都可以不同。每个解析器可能有很大的不同。我遵循的基本思路是这样的。

  1. 将句子分成一组记号。

  2. 将光标移动到第一个标记。

  3. 用语法规则匹配/识别单词。

  4. 继续将光标向前移动到下一个标记。

  5. 继续下去,直到你处理完所有的代币。

那么什么是代币呢?在您的例子中,您将把句子分成一个单词数组。然而,令牌也可以是单个字符。但是,在解析器中不需要这样的粒度。

什么是光标?游标是一个整数,它指向令牌数组的当前索引。这个指针帮助你跟上你已经处理的令牌。对于某些解析器来说,光标允许您向前甚至向后移动。一旦识别出标记,就向前移动光标。当光标到达 tokens 数组的末尾时,您应该完成了对字符串的解析。如果发生错误,您可以使用异常中的光标位置来广播问题,以便开发人员或客户端进行故障排除。当您在代码中省略分号并犯错误时,您会看到这种行为。有了行号和堆栈跟踪,就更容易跟踪错误。您不会采取包括堆栈跟踪这样过分的措施,但是如果您想扩展您的解析器以包括更多的调试选项,原则仍然在这里。

app/Time/TimeParser.php

class TimeExpressionParser

{
        protected $tokens, $cursor;

        public function __construct(NumberParser $numberParser)
        {
                $this->numberParser = $numberParser;
        }

        public function interpret($sentence, $context)
        {

                return $this->parse($sentence)->interpret($context);
        }

您已经保护了属性,以跟上令牌数组和当前处理的令牌光标在数组中的位置。解析器的构造依赖于数字解析器。NumberParser将类似“65”的字符串转换成数字“65”。您可以扩展这个解析器,但是没有必要,因为我刚刚说了它的作用。

这种方法似乎相当无害。它解析句子,然后对返回的解释器对象运行interpret方法。所以让我们更深入地研究一下parse方法。

app/Time/TimeParser.php

public function parse($sentence)
{
        $this->tokens = explode(' ', $sentence);

        $this->cursor = 0;

        $gauge = $this->gauge();

        $direction = $this->direction();

        return new Expressions\TimeExpression($gauge, $direction);
}

parse方法处理将句子分解成记号。您希望从令牌数组的开头开始,因此将光标位置设置为零。接下来,您有两个获取规格和方向表达式的辅助方法。最后,返回一个新的时间表达式。让我们来看看如何获取量规表达式。

app/Time/TimeParser.php

protected function gauge()
{
        $section = $this->tokens(2);

        if ($section == 'a few') {
                $this->cursor += 2;

                $unit = new Expressions\UnitExpression($this->tokens(1));

                $this->cursor += 1;

                return new Expressions\GaugeExpression($section, $unit);
        }
        if ($section == 'sometime soon') {

                $this->cursor += 2;

                return new Expressions\GaugeExpression($section);

        }

        $section = $this->tokens(3);

        if ($section == 'a short time' || $section == 'a long time') {
                $this->cursor += 3;

                return new Expressions\GaugeExpression($section);
        }

        $measurement = $this->measurement();

        return new Expressions\GaugeExpression($measurement);
}

咻,这种方法有很多优点。每个条件返回一个GaugeExpression,所以你几乎可以把这个方法看作一个巨大的switch语句。回忆一下<gauge>的规则:

<gauge> ::= 'a few' <unit> | 'a short time' | 'a long time' | 'some\
time soon' | <measurement>

您的gauge方法中的每个条件都检查您在表达式语法中找到的标记。让我们来看看第一个条件匹配“几个”。

app/Time/TimeParser.php

if ($section == 'a few') {
        $this->cursor += 2;

        $unit = new Expressions\UnitExpression($this->tokens(1));

        $this->cursor += 1;

        return new Expressions\GaugeExpression($section, $unit);
}

当您匹配标记“几个”时,您需要将光标前移两个位置,因为“几个”是两个单词。接下来,你利用了“单位”只是一个单词的事实,所以无论下一个单词是什么,都必须是你的单位。当然,您可能会遇到无效的语法。如果您提供的单元令牌无效,您假设UnitExpression将抛出异常。在将令牌向前推进一个点之后,返回新的规范表达式。我可以介绍一下direction方法,但是我想现在你可以看到如何使用标记和光标来删除组成表达式的单词。请注意,您仍然使用相同的表达式类和语法。之前你在simulator.php里面手动创建了表达式类。现在您的解析器正在为您创建新的表达式对象。但是,解析器与解释器模式无关。它只是一个表达式工厂,使用您之前已经构建好的语法和解释器模式来创建对象。

解析器通常是为语法和句法规则定制的。每个解析器都不同。有些语法会比其他语法更难解析。我在上面概述了一些指导原则,比如使用令牌数组和游标,来帮助您构建自己的解析器。这里的基本观点是,您可以在解析器中重用来自解释器模式的语法。

结论

您已经看到了解释器模式如何帮助将短语翻译成日期时间。您还构建了一个解析器来集成字符串和解释器表达式。将每个语法规则转换成类有助于保持代码的可维护性和易懂性。如果你想进一步扩展你的语法,你只需要添加更多的表达式类。

添加更多的表达式类也是不利的。这些表达式类一起工作。如果一个表达式不能正确完成它的工作,那么它可能会抛弃所有其他的表达式。尽管每个表达式可能都很简单,但是所有的表达式都可以一起工作。表达式之间可能存在嵌套依赖关系,因此很难跟踪错综复杂的关系。这就是为什么每个组件都需要彻底测试。

在您的例子中,您创建了一个解析器。随着语法的增长,创建解释器的解析器也会挑战你。您在两个地方管理语法:解析器和解释器。需要对解析器进行大量测试,以确保它创建了正确的解释表达式。它与表达式紧密耦合,如果表达式的基本规则在以后发生变化,这会导致令人头痛的问题。

解释器模式也被传言很慢。但是比什么慢呢?一个成熟的解析器?一个机器学习算法?我还没有看到这种说法的任何真正的基准或证据。我的直觉是,是的,这种模式会比一些手工制作的算法慢。您可能从未使用过这种设计模式,但至少现在您知道了它的基本工作原理。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 274 页

2

http://en.wikipedia.org/wiki/Backus%E2%80%93Naur_Form

十九、迭代器

$> git checkout iterator

目的

提供一种方法来顺序访问聚合对象的元素,而不暴露其底层表示。 1

应用

什么是聚合对象?聚合这个词的意思是由几个元素组合而成的一个整体。在编程术语中,它是一个集合、数组、列表等。但是,为什么要使用聚合对象呢?您已经可以使用数组顺序聚合一组数字、字符串或对象。为什么需要迭代器模式?这是一个无用的模式吗?答案是否定的。

使用迭代器对象有一些好处。第一个好处是迭代器用来从一个条目遍历到下一个条目的机制都是隐藏的。你不必公开如何从 A 点到达 z 点。客户端对所有迭代器使用相同的接口,并且不必担心在for循环中保持一些计数器索引。客户端只是不断地请求下一个条目,直到迭代器不再提供任何条目。这不同于传统的遍历数组的方式,它提供了更大的灵活性。

迭代器的另一个主要好处是,通过切换迭代器对象,可以很容易地改变集合对象中项的顺序。迭代器负责以特定的顺序遍历列表。也许你想打乱你的列表或者过滤掉符合搜索条件的条目,或者甚至在列表中向后遍历。这是迭代器模式真正闪光的地方。

想象一下,你的 iPod/iPhone/Droid 手机上有一堆歌曲。如果你和我一样,这些歌曲的播放顺序很重要。我不想听到马文·盖接着对机器大发雷霆。或许我知道。这取决于我的心情。有些日子我想听新的流行音乐。其他时候想听老歌。有时我想随机混合一些音乐。我永远不会让贾斯汀比伯和他的父亲鲍勃·西格 2 混在一起。您可以使用播放列表来整理您收藏的歌曲。

从这个意义上说,播放列表与迭代器同义。播放列表决定了歌曲的顺序。歌曲不知道它们的顺序。这就是播放列表的用途。所以如果播放列表是迭代器,那么集合对象是什么呢?在这个类比中,那可能是你,因为你正在创建播放列表并拥有所有的歌曲。聚集对象是构造迭代器的东西。您将很快了解更多这方面的内容。

抽象结构

Note

PHP 内部接口/类用红色标出。

  • IteratorAggregate是一个聚合对象的抽象 PHP 内部接口 3 。这个接口的getIterator方法是由ConcreteAggregate定义的。该方法应该生成新的Iterator对象。注意,如果除了getIterator方法之外还需要其他方法,那么可以创建自己的基本迭代器接口,从这个接口扩展而来。见图 19-1 。

    A435115_1_En_19_Fig1_HTML.jpg

    图 19-1。

    Abstract structure

  • 迭代器是抽象 PHP 内部接口 4 ,包含遍历列表所需的方法。

    • current()返回当前活动元素。

    • key()返回当前活动元素的索引。

    • next()向前移动到下一个元素。

    • 将迭代器移动到第一个元素。

    • 检查你是否在迭代器的末尾。

  • Traversable是一个抽象的空接口,被 PHP 5 特殊处理。它的目的是允许您在一个foreach循环中灵活地使用迭代器。在本章的后面你会看到更多的内容。默认情况下,Iterator接口继承自这个抽象接口,所以你不必太担心。值得一提的是,这样你就能理解 PHP 迭代器是如何工作的。现在,只需知道如果没有这个接口,您将无法方便地在foreach中使用迭代器。

  • ConcreteAggregate保存了getIterator方法的实际实现。它还包含对数组、列表或项目集合的引用。它需要将其项目传递给生成的Iterator对象的构造函数。

  • ConcreteIteratorIterator接口的实际实现。还有一些内置的 PHP 具体迭代器 6 。你将在你的例子中使用ArrayIterator7

例子

在本例中,您将创建一个电影列表。您将使用不同的迭代器迭代这个电影集合。在这个场景中,迭代器由聚合对象Movies创建。在您进一步理解迭代器模式之后,您将通过查看 Laravel 如何以自己简洁的方式将迭代器模式用于雄辩的集合来结束这一章。

示例结构

A435115_1_En_19_Figa_HTML.jpg Note

图 19-2 中列出了两个具体的迭代器。我只是想向你展示在 PHP 中创建一个向后遍历数组的迭代器是多么容易。

A435115_1_En_19_Fig2_HTML.jpg

图 19-2。

Example structure

履行

首先,您需要一个Movie对象来存储电影的标题和分级。这里没什么特别的,这甚至不是迭代器模式的一部分。

app/Movie.php

namespace App;

class Movie

{
        protected $title, $rating;

        public function __construct($title, $rating)
        {
                $this->title = $title;
                $this->rating = $rating;
        }

        public function title()
        {
                return $this->title;
        }

        public function rating()
        {
                return $this->rating;
        }
}

接下来是你的聚合迭代器。您需要一种添加电影的方法。

app/Movies.php

namespace App;

class Movies implements \IteratorAggregate
{
        protected $list = [];

        public function add(Movie $movie)
        {
                $this->list[] = $movie;
        }

记住这是生成迭代器对象的类。这个类中创建了三个迭代器,所以让我们更详细地看看它们。第一个是默认的getIterator方法,它使用你的电影列表生成一个新的ArrayIterator

app/Movies.php

12   public function getIterator()
13   {
14           return new \ArrayIterator($this->list);
15   }

接下来,创建一个迭代器,作为电影分级的过滤器。你使用ArrayIterator来完成这个。

app/Movies.php

17   public function rated($rating)
18   {
19           $filtered = array_filter($this->list, function ($item) use ($rating) {
20           
21                   return $item->rating() === $rating;
22           });
23
24           return new \ArrayIterator($filtered);
25   }

您现在可能已经猜到了ArrayIterator是一个非常有用的迭代器。接下来,您将生成这个迭代器的山寨版,名为ReverseArrayIterator。这个迭代器在 PHP 中不是现成的,所以你必须尽快创建它。

app/Movies.php

27   public function reverse()
28   {
29           return new ReverseArrayIterator($this->list);
30   }

按照约定,这里是ReverseArrayIterator。你可以自己实现current, keynext, rewind, valid;然而,更容易的是反转数组,然后搭载掉ArrayIteratorArrayIterator再一次来救你。

app/reversearrayiiterator . PHP

class ReverseArrayIterator extends \ArrayIterator
{
        public function __construct(array $array)
        {
                parent::__construct(array_reverse($array));
        }
}

你已经上过几节课了。现在是时候看看这个东西是如何工作的了。所有这些工作的要点是对客户机隐藏遍历条目列表的细节。让我们看看你是否做到了。首先,您的客户必须添加一个电影列表。我会在这里展示。尽量不要笑得太厉害。

app/simulator.php

$movies = new \App\Movies;
$movies->add(new \App\Movie('Ponyo', 'G'));
$movies->add(new \App\Movie('Kill Bill', 'R'));
$movies->add(new \App\Movie('The Santa Clause', 'PG'));
$movies->add(new \App\Movie('Guardians of the Galaxy', 'PG-13'));
$movies->add(new \App\Movie('Reservoir dogs', 'R'));
$movies->add(new \App\Movie('Sharknado', 'PG-13'));
$movies->add(new \App\Movie('Back to the Future', 'PG'));

现在是关键时刻了。你想用三种不同的方式来循环播放这些电影。第一种方式是正常方式,使用getIteratorforeach。这将使用ArrayIterator,并吐出所有添加到你的movies聚集对象的电影。

app/simulator.php

print 'MOVIE LISTING' . PHP_EOL;

foreach ($movies as $movie) {
        print ' - ' . $movie->title() . PHP_EOL;
}

对于那些有鹰眼的人来说,你可能想知道打给getIterator的电话是从哪里打来的。我要对你们中的那些人说,恭喜你们,你们得到了一张贴纸 8 !如果你没有得到一个贴纸,那么也许你已经知道这个窍门了?这有点 PHP 的魔力。前面我提到了特殊的Traversable接口。该接口仅用于IteratorAggregateIterator。在你的例子中,MoviesIteratorAggregate开始延伸,所以它也在延伸Traversable。这就是 PHP 如何知道在上面的代码中神奇地使用getIterator方法,而不需要您显式地调用它。试着把这看作是保持代码美观的一种便利,而不是某种神奇的独角兽特性。

在下一段代码中,你必须显式地调用迭代器方法的名字。你可以得到一场免费的魔术表演。现在是时候明确表态了。(我就知道我圣诞节得到的这本辞典会派上用场。)

app/simulator.php

print PHP_EOL . 'RATED R ONLY' . PHP_EOL;

foreach ($movies->rated('R') as $movie) {
        print ' - ' . $movie->title() . PHP_EOL;
}

您将使用反向迭代器做同样的事情。

app/simulator.php

print PHP_EOL . 'IN REVERSE ORDER' . PHP_EOL;

foreach ($movies->reverse() as $movie) {
        print ' - ' . $movie->title() . PHP_EOL;
}

请注意,在所有这些过程中,您不必跟踪索引、过滤或排序。这都是在幕后为你做的。客户端仍然负责调用迭代器。客户不负责如何到达下一个项目的细节。虽然您知道它是幕后驱动迭代器的数组,但是客户端不知道。没有什么可以阻止你用别的东西替换掉Movies中的数组,比如说一个列表,你的客户端代码应该仍然起同样的作用。

拉勒维尔收藏

现在您已经了解了迭代器的工作原理,让我们来研究一下 Laravel。拉勒维尔有一种东西叫做收藏品。它通过它的 ORM 使用集合。数据库检索工作流是这样的:

  1. 使用雄辩的查询生成器(也称为 Fluent)构建数据库查询。

  2. 执行查询。对于选择,检索表行。

  3. 每个表行中的字段被合并到模型中。

  4. 每一个模型都被添加到一个雄辩的集合。

  5. 集合被返回。

Laravel 的雄辩系列是模特的绝佳包装。如果你有兴趣,可以在vendor/laravel/framework/src/Illuminate/Database/Eloquent/Collection.php查看该文件。雄辩的集合扩展了基本的通用支持集合。这个漂亮的类可以在Illuminate\Support\Collection中找到,它有超过 1300 行帮助方法。它也与数据库无关,因此您可以将它用于任何类型的数据结构。您对这个类感兴趣,想看看它是如何使用迭代器模式的。

vendor/laravel/framework/src/Illuminate/Support/collection . PHP

12   class Collection implements ArrayAccess, Arrayable, Countable, IteratorAggregate,
13   Jsonable, JsonSerializable {
14           
15           use Macroable;
16           
17           /**

18            * The items contained in the collection.

19            *
20            * @var array
21            */
22           protected $items = [];
23           /**

24            * Create a new collection.

25            *

26            * @param array $items

27            * @return void

28            */

29           public function __construct(array $items = [])
30           {
31                   $this->items = $items;
32           }

这个类实现了很多其他的东西;尽管如此,它仍然实现了IteratorAggregate,这正是您创建的Movies类的工作方式。这里面应该有一个getIterator的方法。

vendor/laravel/framework/src/Illuminate/Support/collection . PHP

610   public function getIterator()
611   {
612           return new ArrayIterator($this->items);
613   }

不过,这有点无聊。您已经在前面的Movies aggregate 对象中看到了这一点。但是等等!在这条getIterator线下面有一些新的东西:

vendor/laravel/framework/src/Illuminate/Support/collection . PHP

620   public function getCachingIterator($flags = CachingIterator::CALL_TO
621   STRING)
622   {
623           return new CachingIterator($this->getIterator(), $flags);
624   }

还记得我之前给你看的清单吗 9 带有所有内置的原生 PHP 迭代器?caching iterator10是另一个你可以使用的内置迭代器。你为什么要用它?

它是做什么的?PHP 文档没有提供太多的见解,只是说,这个对象支持缓存迭代器覆盖另一个迭代器。缓存另一个迭代器有什么好处?我发现这个迭代器有一个很好的用例,就是当你在遍历过程中需要向前看的时候。假设您需要知道遍历中的下一项,并在此基础上进行一些逻辑运算。下面你就这么做吧。

app/cache-example.php

$numbers = new CachingIterator(new ArrayIterator([1, 2, 3, 1, 4, 6, 3, 9]));

foreach ($numbers as $currentNumber) {
        $sign = '';
        if ($numbers->hasNext()) {
                $nextNumber = $numbers->getInnerIterator()->current();
                $sign = $nextNumber > $currentNumber ? '>' : '<';
        }

        print $sign ? "$currentNumber $sign " : $currentNumber;
}

print PHP_EOL;

在此示例中,您应该会看到以下输出:

B> 1 > 2 > 3 < 1 > 4 > 6 < 3 > 9

这些都行得通,因为你可以向前看。如果没有缓存迭代器,您将无法预测下一个数字是大于还是小于当前数字。还有很多其他有用的迭代器,比如 RecursiveDirectoryIterator 11AppendIterator12。我鼓励你去调查这些。到目前为止,您已经介绍了足够多的迭代器。我累了,朋友。

结论

在每一章的结尾,我都试图列出每种模式的缺点。迭代器模式的一个缺点是,为了创建您自己的自定义迭代器,您必须定义五个方法。这可能看起来势不可挡。为什么需要有一个hasNext()方法?key()应该返回什么?如果您最终创建了一些自定义迭代器,而不是依赖于原生 PHP SPL 迭代器,这些是您必须回答的问题。

迭代器模式的好处是隐藏了如何遍历对象的细节。这就是意图。你不再被迫对整数索引使用for循环。您可以循环复杂的列表和树。同样,就像您在Movies类中对评级过滤器所做的那样,您可以轻松地创建返回特殊过滤迭代器的方法。这减轻了客户端的工作负担。

你学到了很多关于 PHP 中迭代器的知识。尽可能多地使用现成的 SPL 产品。一个缺点是缺少文档。然而,这不应该阻止您在处理对象集合时使用迭代器模式。在 Laravel 中,您会经常使用集合,Taylor 已经做了大量工作来处理集合上的迭代和类似数组的访问。好好逛逛Collection班;它有一些简洁的方法,也没有文档记录,但是非常有用!

Footnotes 1

设计模式:可重用面向对象软件的元素,第 289 页

2

www.bobseger.com/

3

http://php.net/manual/en/class.iteratoraggregate.php

4

http://php.net/manual/en/class.iterator.php

5

http://php.net/manual/en/class.traversable.php

6

http://php.net/manual/en/spl.iterators.php

7

http://php.net/manual/en/class.arrayiterator.php

8

贴纸不是我提供的;抱歉,我没有贴纸可发。你得向卖贴纸的人要你的贴纸。你认识贴纸男吗?

9

http://php.net/manual/en/spl.iterators.php

10

http://php.net/manual/en/class.cachingiterator.php

11

http://php.net/manual/en/class.recursivedirectoryiterator.php

12

http://php.net/manual/en/class.appenditerator.php

二十、中介

$> git checkout mediator
$> composer update

目的

定义一个封装一组对象如何交互的对象。Mediator 通过防止对象显式地相互引用来促进松散耦合,并允许您独立地改变它们的交互。 1

应用

每个人都有需要帮助的时候。有时候你已经够忙的了。中介是中间那个和你一起工作的人。律师、秘书和房地产经纪人都是现实世界中的中介。房地产经纪人与买方和卖方合作,完成改变房屋所有权的任务。通常,买方和卖方从不直接沟通;所有的交流都通过房地产经纪人进行。

《四人帮》一书将用户界面控件列为中介模式的用例示例。什么是用户界面控件?假设您正在查看一个下拉框和一个保存按钮。在您从下拉框中选择有效选项之前,“保存”按钮是禁用的。这两个小部件不需要相互了解。保存按钮只需要知道它是否被启用。中介可以帮助解决这个问题。

然而,您不是在用 Java 或 Smalltalk 构建用户界面。你用拉弗尔。您编写 HTML,作为客户端的视图。当然,可能有 JavaScript/jQuery 来处理 Save 按钮的禁用/启用特性,但是在这一点上,您是在 Laravel 的上下文之外。因此,在 Laravel 视图中使用 mediator 模式在用户界面控件之间进行通信是不可取的。

抽象结构

  • AbstractMediator是一个接口或抽象类。它的方法由具体的中介实现。这里列出的抽象方法将是同事通知中介者的公共 API。见图 20-1 。

    A435115_1_En_20_Fig1_HTML.jpg

    图 20-1。

    Abstract structure

  • Mediator包含具体同事使用的方法。这些方法可以通知其他同事,或者做他们需要的任何事情。

  • AbstractColleague是一个接口或抽象类。这个抽象类可以保存所有同事对象使用的方法,也可以保存对中介对象的引用。

  • Colleague1Colleague2AbstractColleague的实现。每个同事都不知道对方的存在。如果这些同事之间直接交流,这就违背了中介模式的目的。

例子

在本例中,您将构建一个电子商务结账系统的开端。如果你曾经建立或管理过电子商务,你就会知道这有多痛苦。钱总是让事情变得复杂。在这个平台上,你将依靠一个价格调节中介。调整器保存用于计算价格调整的业务逻辑。你有产品、顾客利益和优惠券。价格调整器根据您添加到系统中的客户优惠和优惠券重新计算产品价格。因此,当您添加块状奶酪的优惠券时,您应该会自动看到块状奶酪产品的价格变化,这都要感谢处理价格调整的中介。就像一般的工作流程一样,这就是你想要的:

  • 客户增加了一些产品。

  • 产品价格会随着客户添加(或删除)优惠券和优惠而自动变化。

我将在这里定义两个抽象的概念:价格调整和价格调整者。价格调节员(调解员)在计算价格时会考虑所有的价格调整(同事)。

作为一个警告,我并不推荐电子商务使用中介模式,但这似乎是展示这种模式的一个很好的例子。定价的业务逻辑可能很难处理,尤其是当跨越许多许多文件时。许多因素都会影响定价,比如你订购了多少产品、优惠券、你住在哪个国家、你是否是一名退伍军人或超过 65 岁或者你是否喜欢一个好苹果。好吧,最后一个可能永远不会被使用,但我确实喜欢一个好苹果。

在本例中,您还有一个任意的业务规则,即如果使用三张或更多优惠券,客户将失去利益。为什么是三个或更多?我不知道。去问客户。这很重要,你知道吗?

示例结构

图 20-2 为结构示意图。

A435115_1_En_20_Fig2_HTML.jpg

图 20-2。

Example structure

履行

抽象的价格调整类扮演您的抽象同事的角色。它的主要目标是设置价格调整器,然后使用价格调整器中介对象更新价格。这个类通过产品、优惠券和客户利益来扩展。

src/PriceAdjustment.php

abstract class PriceAdjustment

{
        private $priceAdjuster;

        protected function __construct(AbstractPriceAdjuster $priceAdjuster)
        {
                $this->priceAdjuster = $priceAdjuster;
                $this->priceAdjuster->addAdjustment($this);
        }
        protected function updatePrices()
        {
                $this->priceAdjuster->adjustPrices($this);
        }
}

你可以把它变成一个界面。你为什么没有呢?没有真正的原因,真的。将价格调整器对象抽象出来似乎不错。这可以防止同事类破坏它,除了构造函数。任何扩展调价类的同事都可以打updatePrices。然后updatePrices将信息反馈给中介。然后,中介做它想做的任何事情。接下来,让我们定义一个产品,然后继续讨论优惠券和客户福利。

app/Product.php

namespace App;

class Product extends PriceAdjustment

{
        protected $name, $price, $modifiedPrice;
        public function __construct($name, $price, AbstractPriceAdjuster $priceAdjuster)
        {
                $this->name = $name;
                $this->price = $this->modifiedPrice = $price;
                parent::__construct($priceAdjuster);
        }

请注意,您必须将依赖项传递给构造函数。最后一个依赖项包括中介对象。个人感觉把这个服务类传到一个产品里面是不对的。产品似乎很像模型。它应该只是用来存储数据的。在这里有这个调节器类看起来很奇怪,尤其是当你考虑处理雄辩的模型时。事实上,雄辩的模型有一个构造器,它需要一个数据属性列表。

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

public function __construct(array $attributes = []) {
...
}

如果你试图将这个额外的中介类注入到一个雄辩的模型中,那你就违背了初衷。更适合雄辩模型的模式是观察者模式。事实上,雄辩术已经将观察者模式融入其中。我将在几章中讨论这个问题。回到产品,您会看到其余的方法都是访问器和赋值器。

src/Product.php

15   public function name()
16   {
17           return $this->name;
18   }
19
20   public function original()
21   {
22           return $this->price;
23   }
24
25   public function price()
26   {
27           return $this->modifiedPrice;
28   }
29
30   public function modifyPrice($price)
31   {
32           $this->modifiedPrice = $price;
33           $this->updatePrices();
34   }

请注意,无论何时修改产品价格,您都需要调用updatePrices。这个方法调用价格调整器中介器,它为您处理价格。将价格计算与实际产品类别分开是一件好事。接下来,看看优惠券和客户福利。这些类和你的Product类很像。

app/Coupon.php

namespace App;

class Coupon extends PriceAdjustment
{
        protected $name, $amount;

        public function __construct($name, $amount, AbstractPriceAdjuster $priceAdjuster)
        {
                $this->name = $name;
                $this->amount = $amount;
                parent::__construct($priceAdjuster);
        }

        // name() and amount() accessors omitted

        public function modifyAmount($amount)
        {
                $this->amount = $amount;
                $this->updatePrices();
        }
}

同样,当您修改优惠券的金额时,您需要告诉价格调整器更新价格。每当修改客户优惠折扣时,您都将执行相同的操作。

app/CustomerBenefit.php

namespace App;

class CustomerBenefit extends PriceAdjustment
{
        protected $discount;

        public function construct($discount, AbstractPriceAdjuster $priceAdjuster)
        {
                if ($discount > 100) throw new Exception("cannot have a discount over 100%");

                $this->discount = $discount;

                parent:: construct($priceAdjuster);
       }

       // discount() accessor omitted

       public function modifyDiscount($discount)
       {
               $this->discount = $discount;
               $this->updatePrices();
       }
}

你可能会觉得奇怪,优惠券和顾客利益可以改变。在现实生活中,优惠券通常不会改变价格。如果是这样的话,它可能会被视为一种完全不同的优惠券。优惠券很像价值对象。值对象应该被视为不可变的。原因超出了本章的范围,但是如果你好奇的话,一个谷歌搜索 2 可以告诉你。然而,在本例中,您允许优惠券和优惠在创建后被更改,而不是将它们视为不可变的值对象。

到目前为止,您已经完成了价格调整课程。下一步是制造价格调整器。这里我不打算展示抽象调价器 3 ,因为它只是一个接口,在上面的 UML 图中你可以很容易地看到它需要实现哪些方法。让我们直接进入正题:价格调节者。

app/PriceAdjuster.php

namespace App;

use Illuminate\Support\Collection;

class PriceAdjuster implements AbstractPriceAdjuster
{
        protected $cid = 1;

        public function construct()
        {
                $this->products = new Collection;
                $this->coupons = new Collection;
                $this->customerBenefits = new Collection;
                $this->appliedCoupons = [];
        }

您用三个新的集合和一个数组来构造价格调整器。作为业务规则的一部分,每件产品只能使用一张优惠券。这就是为什么你有一个应用优惠券的数组。接下来,你进入这个中介的心脏:adjustPrices

app/PriceAdjuster.php

17   public function adjustPrices()
18   {
19           $customerDiscount = $this->getCustomerDiscount();
20   
21           foreach ($this->products as $product) {
22                   $oldPrice = $product->price();
23                   $newPrice = round($this->getCouponDiscountForProduct($product) *
24                   1 - $customerDiscount / 100), 2);
25                   if ($oldPrice !== $newPrice) $product->modifyPrice($newPrice);
26           }
27   }

该方法遍历所有产品,找到产品的优惠券折扣,然后减去客户折扣。如果数学让你困惑,不要担心;这只意味着你不再是 21 岁了。比较oldPricenewPrice的条件很重要。除了在产品价格没有变化的情况下不需要更新价格之外,这种情况还可以防止你一遍又一遍地递归调用自己。请记住,当您修改产品价格时,它会调用价格调整器。这将不断调用自己,你会得到一个恼人的最大递归深度超过堆栈跟踪。继续,您仍然需要实现另外两个方法来添加和删除调整。

app/PriceAdjuster.php

27   public function addAdjustment(PriceAdjustment $adjustment)
28   {
29           $this->{'add' . get_class($adjustment)}($adjustment);
30   }
31   
32   public function removeAdjustment(PriceAdjustment $adjustment)
33   {
34           $this->{'remove' . get_class($adjustment)}($adjustment);
35   }
36   
37   protected function addProduct(Product $product)
38   {
39           $this->addToCollection($this->products, $product);
40   }
41   
42   protected function addCustomerBenefit(CustomerBenefit $benefit)
43   {
44           $this->addToCollection($this->customerBenefits, $benefit);
45   }
46   
47   protected function addCoupon(Coupon $coupon)
48   {
49           $this->addToCollection($this->coupons, $coupon);
50   }
51   
52   protected function removeProduct(Product $product)
53   {
54           unset($this->appliedCoupons[$product->cid]);
55           $this->removeFromCollection($this->products, $product);
56   }
57   
58   protected function removeCoupon(Coupon $coupon)
59   {
60           $key = array_search($coupon->cid, $this->appliedCoupons);
61   
62           if ($key !== false) unset($this->appliedCoupons[$key]);
63   
64           $this->removeFromCollection($this->coupons, $coupon);
65   }
66   
67   protected function removeCustomerBenefit(CustomerBenefit $benefit)
68   {
69           $this->removeFromCollection($this->customerBenefits, $benefit);
70   }
71   
72   protected function addToCollection($collection, $object)
73   {
74           $object->cid = $this->cid++;
75   
76           $collection->push($object);
77   
78           $this->adjustPrices();
79   }
80   
81   protected function removeFromCollection($collection, $object)
82   {
83           $key = $collection->search($object);
84   
85           $collection->forget($key);
86   
87           $this->adjustPrices();
88   }

在价格调整器中有一些更受保护的方法,但是它们与中介模式没有任何关系。如果你对这个类的其余部分感到好奇,请查看 GitHub 4 上的代码

现在终于到了跑这个坏小子的时候了!在您的模拟中,您将添加一些产品和优惠券,甚至是客户折扣。然后你将打印出你的产品价格和总价。模拟器看起来像这样:

app/simulator.php

 6   $priceAdjuster = new \App\PriceAdjuster;
 7   
 8   $product1 = new \App\Product('Block Cheese', 3.99, $priceAdjuster);
 9   $product2 = new Product('Frozen Pizza', 6.69, $priceAdjuster);
10   $product3 = new Product('Popcorn', 2.34, $priceAdjuster);
11   price()('untouched prices', $product1, $product2, $product3);

在这里,您正在创建三种新产品,并设置名称和价格。price方法给出了以下输出:

--- untouched prices ---
Block Cheese: 3.99
Frozen Pizza: 6.69
Popcorn: 2.34
total: 13.02

你的理算员还没有调整这些原始价格。让我们通过添加一些优惠券来改变这种情况。

app/simulator.php

13   $coupon1 = new \App\Coupon('Block Cheese', 1.00, $priceAdjuster);
14   $coupon2 = new Coupon('Frozen Pizza', 2.00, $priceAdjuster);
15   price('adding 2 coupons', $product1, $product2, $product3);

既然已经添加了优惠券,价格调整器就开始工作了。你可以看到块状奶酪和冷冻披萨现在更便宜了。

--- adding 2 coupons ---
Block Cheese: 2.99
Frozen Pizza: 4.69
Popcorn: 2.34
total: 10.02

接下来,你得到了你的客户利益,它把你的价格打了 30%的折扣。同样,通过将价格调整器添加到类中,价格会自动调整。

app/simulator.php

17   $benefit1 = new \App\CustomerBenefit(30, $priceAdjuster);
18   price('added 30% customer benefit', $product1, $product2, $product3);

--- added 30% customer benefit ---
Block Cheese: 2.09
Frozen Pizza: 3.28
Popcorn: 1.64
total: 7.01

请记住,作为上述商业规则的一部分,如果顾客使用两张以上的优惠券,他或她将失去利益。

app/simulator.php

13   $coupon3 = new Coupon('Popcorn', 2.00, $priceAdjuster);
14   price('adding 3rd coupon, customer looses 30% benefit', $product1,
15   $product2, $product3);

--- adding 3rd coupon, customer looses 30% benefit ---
Block Cheese: 2.99
Frozen Pizza: 4.69
Popcorn: 0.34
total: 8.02

在这一部分,你决定取消最便宜的优惠券,最大限度地节省。我觉得我应该从周日的报纸上剪优惠券什么的。

app/simulator.php

13   $priceAdjuster->removeAdjustment($coupon1);
14   unset($coupon1);
15   price('removing coupon #1, now 30% benefit back', $product1, $product2, $product3);

--- removing coupon #1, now 30% benefit back ---
Block Cheese: 2.79
Frozen Pizza: 3.28
Popcorn: 0.24
total: 6.31

最后,您展示了您可以编辑一项福利,并且产品价格会通过 price adjuster mediator 自动更改。

app/simulator.php

13   $benefit1->modifyDiscount(45);
14   price('customer gets 45% discount now!', $product1, $product2, $product3);

--- customer gets 45% discount now! ---
Block Cheese: 2.19
Frozen Pizza: 2.58
Popcorn: 0.19
total: 4.96

这个例子差不多结束了。下面我给你留下一些对这种模式的思考。

别惹我的构造器,伙计!

如果你不喜欢中介模式破坏构造函数的方式,请注意,你可以改变这一点。你可以使用一个可空的对象或者简单的单例对象。

app/Product.php

public function construct($name, $price, AbstractPriceAdjuster $priceAdjuster = null)
{
        $this->name = $name;
        $this->price = $this->modifiedPrice = $price;
        parent:: construct($priceAdjuster ?: PriceAdjuster::instance());
}

这就是了。不再需要传递价格调整器。如果您没有传入任何东西,它使用价格调整器的静态实例,一个简单的单例。当进行单元测试时,请确保不要使用这种单例模式,方法是传入您自己的模拟调价器。

中介不适合我

你可能已经注意到,中介可以很快变得势不可挡。我个人不喜欢我在这个例子中采用的方法。这是一个试图使模式“适合”的例子在更新产品价格时,只需进行两次调用就可以避免中介模式:一次是更新价格,另一次是重新计算。请允许我演示一下。

$product = new Product("name", 3.45);
$product->modifyPrice(2.34);
$priceAdjuster->updatePrice();

现在不是产品调用中介,而是你自己调用它。我自己更喜欢这种程度的控制。目前,中介耦合到所有同事,反之亦然。它看起来太复杂了。希望您已经了解了一些关于中介模式的知识(并且足以知道什么时候应该远离它)。让我们来谈谈什么不是中介模式。

不是中介模式

让我花点时间指出,管理者和控制者与中介者模式不同。管理器或控制器初始化从属类。通常情况下,这些下属之间从不直接交流。那不是中介吗?不,有几个原因。首先,那些从属类通常没有办法与管理器通信。这种交流是单向的。经理告诉下属做什么,然后等待直接的回应。如果后来下属发生了什么事,经理会幸福地保持不知道,因为下属没有办法与经理取得联系。第二个原因是,下属很可能不是同事。他们彼此没有什么关系。下属很可能彼此不相结合。

六边形模式可用于控制器、控制器和管理器对象。人们可能倾向于认为六边形图案很像介体图案。它的结构类似。六边形模式的基本思想是将控制器对象传递给从属类。然后,从属对象可以回调传入的控制器对象上的方法。这类似于中介模式,但又不完全相同。中介处理多个同事之间的沟通。六边形模式只处理两个类之间的直接通信:控制器和服务。

如果您想知道六边形图案可能是什么样子,这里有一些示例代码。您定义一个供控制器使用的接口。

interface Created
{
        public function created($obj);
        public function notCreated($errors);
}

控制器实现了Created接口。它必须被您很快就会看到的UserCreator服务类使用。在下面的代码中,控制器不再负责实际验证和创建新用户。他的工作仅仅是充当传输层/路由层,并将任务委派给下属的UserCreator类。

class UserController extends Controller implements Created
{
        public function store(Request $request)
        {
            $userCreator = new UserCreator($this);
            return $userCreator->create($request->input('email'), $request->input('password'));
        }

        public function created($user)
        {
            auth()->login($user);
            return redirect('/');
        }

        public function notCreated($errors)
        {
            return redirect('users/create')->withErrors($errors)->withInpt();
        }
}

接下来,您需要定义您的UserCreator服务类。它将处理创建用户的工作。通过直接调用管理器的方法,UserCreator告诉管理器它是created还是notCreated。使用这种方法,请注意您的控制器是完全无逻辑的。它非常简单,甚至不需要测试。如果你愿意,你仍然可以对控制器进行单元测试,但是你需要重构,这样你就可以注入一个模拟UserCreator。测试您的UserCreator更加容易,因为您不必处理外观、HTTP 请求和重定向。

class UserCreator

{
        public function construct(Created $manager)
        {
                $this->manager = $manager;
        }

        public function create($email, $password)
        {
                $validator = Validator::make(...);

                if ($validator->fails()) {
                        return $this->manager->notCreated($errors);
                }

                $user = new User;
                $user->email = $email;
                $user->password = bcrypt($password);
                $user->save();

                // do other user creation stuff here

                return $this->manager->created($user);
        }
}

六边形模式的另一个好处是,您可以用不同的类重用您的UserCreator类,就像用控制台命令创建用户一样。这种模式的缺点是,现在需要检查两个地方来创建用户:UserControllerUserCreator。它使您的代码稍微复杂一点,但是提供了分离传输层和业务逻辑层的灵活性。

我可以写一整章关于六边形图案的内容,但我在这里只是简单地介绍了一下,主要是为了说明一个观点。关键是你的UserCreator可以反馈给他的经理,这是一个类似于中介模式的结构。六边形模式的目的是分离传输层和业务层。其目的不是解耦同事对象。我的最后一点是,在讨论模式时,你应该记住意图是非常重要的。尤其是在比较不同模式之间的差异时。

结论

中介模式用于处理同事对象之间的通信。这促进了原本紧密耦合的类之间的松散耦合。在研究这种模式时,我看到的最大缺点之一是创建了上帝对象。我们以前都创造过上帝的物品。如果有足够的时间,这些物品会让你的内心充满悔恨和遗憾。我和一个朋友对这种行为有一个术语。我们称之为前任混蛋。

前一个混蛋就是写了一堆代码然后走人的家伙。现在,您陷入了一大堆您必须弄清楚的怪异代码中。有时候你甚至是你自己以前的混蛋。你爬回到几个月前写的代码,只是因为客户抱怨一些奇怪的错误。奇怪的代码往往会产生奇怪的错误。中介往往会产生以前的混蛋,尤其是随着同事数量的增加。

这种模式的另一个缺点是,当中介模式不适合时,您最终会创建比所需更复杂的代码。中介体是一个很难适应 Laravel 的模式。前端 JavaScript 可能更适合这种模式。事件通常用于在整个代码中传播更改,因此在 JavaScript 领域中它可能做得更好。我的两个观点:如果你发现自己想要许多对象之间的松散耦合,那么就给中介一个机会。否则,随它去吧。

接下来,让我们花点时间来讨论一下纪念物模式。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 305 页

2

www.google.com/webhp#q=why%20should%20value%20objects%20be%20immutable

3

https://github.com/kdocki/larasign/blob/mediator/src/AbstractPriceAdjuster.php

4

https://github.com/kdocki/larasign/blob/mediator/src/PriceAdjuster.php

二十一、备忘录

$> git checkout memento

目的

在不违反封装的情况下,捕获并具体化一个对象的内部状态,以便该对象可以在以后恢复到这个状态。 1

应用

你看过那部叫纪念物 2 的电影吗?故事讲述了一个再也无法制造新记忆的调查员。他循着线索寻找杀害他妻子的第二个袭击者。这不是克里斯托弗·诺兰最好的电影之一,但仍然值得一看。这一章与此无关。我甚至不知道如何把电影和备忘录联系起来。不过,我确实喜欢一部好的诺兰电影。

使用 memento 模式的原因是为了保存对象的内部状态。这种模式也称为撤销模式。您创建对象的快照。并且您可以随时恢复到以前的状态。

拍摄物体的快照听起来可能很容易。你看着一个物体复制数值,对吗?嗯,事情没那么简单。看下面的对象,问问自己,你是怎么保存MyObject?的内部变量状态的

app/MyObject.php

namespace App;

class MyObject

{
        private $thing;
        public $anotherThing;
}

你可以访问$anotherThing,因为它是公共的。保存$thing的值很困难,因为它是私有变量。你可以创建一个名为getThing()的公共方法来解决这个问题。此时,您正在为保存私有值而创建方法。这暴露了类的内部工作方式,打破了封装。

A435115_1_En_21_Figa_HTML.jpg为什么这会破坏封装?想象你有一个盒子。当你按下盒子上的按钮时,它就会吐出一块巧克力蛋糕。那就是封装。现在想象一下,蛋糕盒上有一百个按钮。你可以做不同大小和不同口味的蛋糕。一台有上百个按钮的机器听起来很复杂。创造一个是痛苦的。维持它是一种痛苦。用起来也是一种痛苦。我的意思是,如果我只是想要巧克力蛋糕,我不应该弄乱 99 个按钮。在软件设计中,你努力创造一百个不同的只有一个按钮的蛋糕机,而不是一个有一百个按钮的蛋糕机。

你不是在造蛋糕机。你在建造物品。不过,同样的原则也适用。当你设计你的对象时,你应该努力使它们尽可能容易使用。为了做到这一点,你的班级应该承担尽可能少的责任。您为您的类创建的公共方法越多,您添加的责任就越多。这就是为什么您使用 memento 模式而不是公开私有变量。

避免暴露私有变量并保存它们以备后用的一个好方法是使用 memento 模式。所以让我们来学习如何。

抽象结构

  • Originator是包含您想要保存的public, protected,private变量的类。当您在Originator上调用snapshot方法时,它将创建一个新的Memento对象。restore方法允许你在这个类中设置privateprotected变量。见图 21-1 。

    A435115_1_En_21_Fig1_HTML.jpg

    图 21-1。

    Abstract structure

  • Caretaker处理快照。Originator不需要额外负责保存自己的快照。由管理员决定以后如何恢复快照。这可以通过一种undo方法来完成。这可以通过从$mementos数组中选取一个数字索引来完成。可能完全是随机的。由管理员决定如何将备忘录放回Originator

  • Memento是普通的老式 PHP 对象(POPO ),它存储了从Originator中保存的变量。请不要更改这些变量,这一点很重要。这在某种程度上违反了纪念模式。快照应该被视为不可变的。一旦你开始篡改一个Memento对象,你就在MementoOriginator之间创建了一个紧密耦合。到那时,它就不再是备忘录了。它变成了完全不同的东西。

这个模式没有抽象接口。很奇怪吧。Singleton 和 facade 是唯一没有抽象接口的模式。实际上,我将在下面的例子中使用一个接口,因为我发现接口很好使用。我提到这一点是为了说明一点。你不必总是为了使用一个模式而使用接口。不管您是否使用接口,您可能仍然在使用设计模式。我知道在这一点上我可能听起来像一张破唱片,但是我要再说一遍:**真正决定设计模式的是意图,而不是代码结构。

例子

你的目标是给你雄辩的模特拍快照。快照应该包含将模型恢复到原始状态所需的所有信息。这意味着,如果您更改模型的属性、表名和其他属性,您总是可以恢复到以前的快照。

在这个例子中,你会学到更多关于口才的内部知识。您也将稍微偏离 UML,并利用特征来实现您的 memento 模式。这些特征不是必需的,但是我发现在这种情况下,它给整个代码增加了一点语法上的好处。

利用你的快照特质,你可以给你雄辩的模特拍快照。这样做有很多好处。一个是你可以在任何时候创建一个模型的快照,然后将它恢复到某个原始状态。也许您正在更改一个人对象的电子邮件地址,并将它保存在数据库中。如果事情出错了,也许你想把人对象恢复到原来的样子。更新一个人的信息后,您需要更新一些远程服务上的信息,如 Stripe、CRM 或搜索索引。您将创建自己的快照。如果更新远程服务出现问题,您将从快照中恢复您的个人。

但是数据库事务或审计历史表不是更合适吗?是的,这是真的。事实上,数据库事务是 memento 模式的一个真实例子。此外,没有什么可以阻止您将审计历史作为 memento 模式的一部分来实现。在本例中您不会这样做,但是您可以在以后拍摄快照时轻松地添加审计。

示例结构

图 22-2 为结构示意图。

A435115_1_En_21_Fig2_HTML.jpg

图 21-2。

Example structure

履行

让我们来看看你的snapshot对象。这取代了 memento 对象。您用一组键/值对来构造快照。请注意,创建快照后,您不能更改项目。这样做是为了防止管理员破坏封装。

app/快照. php

namespace App;

class Snapshot

{
         protected $items;

         public function __construct(array $items)
         {
                 $this->items = $items;
         }

         public function items()
         {
                 return $this->items;
         }
}

与其传入一个名为$items的通用数组,不如显式地列出要拍摄的实际属性。当然也欢迎你这样做。这个通用快照类足够抽象,可以处理许多不同类型的OriginatorsOriginator是唯一应该使用快照的人。你的snapshot班只有一个责任。它充当键/值对的存储桶。snapshot类对Originator了解得越多,就越有可能危及Originator的封装。

上面的snapshot类对Originator一无所知。不明确Originator的属性有一个缺点。Originator在创建快照时必须格外小心。当Originator忘记设置一个键时,不会出现编译错误,如果您必须显式地向快照构造函数提供所有Originator的属性,就会出现编译错误。明确通常可以防止以后的错误。在这种情况下,您可以破例选择隐式路径。

接下来,看你的Originator对象。

app/Person.php

namespace App;

class Person extends Model implements Snapshots
{
        use EloquentSnapshots;
}

您在这里使用一个特征来实现您的Snapshots接口。这种做法在 Laravel 越来越普遍。Snapshots接口只需要您实现两个方法。

app/快照. php

namespace App;

interface Snapshots
{
        public function snapshot();
        public function restore(Snapshot $snapshot);
}

您使用 trait 来混合这种功能。您可以直接在您的Person类中编写方法。你选择做一个特质的原因有两个。一是为了可重用性。这个特性非常普遍,可以被其他雄辩的模型重用。第二个原因是为了避免继承除了雄辩之外的另一个基类。

app/EloquentSnapshots.php

namespace App;

trait EloquentSnapshots
{
        public function snapshot()
        {
                $items = [];

                $keys = [
                        'connection', 'table', 'primaryKey', 'perPage', 'incrementing', 'timestamps', 'attributes', 'original', 'relations', 'hidden', 'visible', 'appends', 'fillable', 'guarded', 'dates', 'touches', 'observables', 'with', 'morphClass', 'exists',
                ];

                foreach ($keys as $key) {
                        $items[$key] = $this->$key;
                }
                return new Snapshot($items);
        }

循环遍历所有的键,并将它们添加到名为$items的键/值对数组中。然后,这些项目用于创建新的快照对象。稍后,snapshot 对象将用于循环遍历您添加的项目,并将它们分配回您的雄辩类。

app/EloquentSnapshots.php

public function restore(Snapshot $snapshot)
{
        foreach ($snapshot->items() as $key => $value) {
                $this->$key = $value;
        }
}

现在只剩下看守人了。管理员管理从Originator创建的快照。在你的例子中,你不会成为那类人;相反,模拟器将为您管理快照。在某种程度上,模拟器承担了看管者的角色。

app/simulator.php

 9   $person = new \App\Person;
10   $person->name = "Kelt";
11   $snapshot1 = $person->snapshot();
12   
13   $person->setTable('persons');
14   $person->name = "test name";
15   $person->email = "testing@test.com";
16   $snapshot2 = $person->snapshot();

请注意,您创建了两个快照。第一个快照的名称中只有“Kelt”。第二张快照更改了此人的姓名和电子邮件地址。它还设置了表名。让我们检查以下语句的输出:

app/simulator.php

18   print personInfo("this is how person looks now", $person);

控制台输出

this is how person looks now
name: test name, table: persons, email: testing@test.com

app/simulator.php

20   $person->restore($snapshot1);
21   print personInfo("restoring snapshot 1", $person);

控制台输出

restoring snapshot 1
name: Kelt, table: people, email:

app/simulator.php

23   $person->restore($snapshot2);
24   print personInfo("restoring snapshot 2", $person);

控制台输出

restoring snapshot 2
name: test name, table: persons, email: testing@test.com

看看您是如何将对象恢复到以前的快照的?您打印出表名、人名和电子邮件,作为快照正在工作的证据。第一次和第三次打印的声明是一样的,正如所料。当然,你已经偷看了幕后,所以你知道魔术是如何在幕后工作的。不过,这还是很酷。您可以为您的雄辩模型创建快照,并且在任何时候,您都可以通过恢复快照来撤消更改。

备忘录的替代品

每当我遇到一个问题,我总是试图记住检查其他人是如何解决这个问题的。这通常可以节省我的时间。也就是说,让我们提出几个使用 memento 模式的替代方案。

  1. 使用雄辩的方法

  2. 对象序列化

使用雄辩的方法

不出意外的话,口才已经有了一个叫syncOriginal的方法。Taylor 使用这个数组来跟踪自从您上次保存以来在您的雄辩模型中发生了什么变化。有一些叫做isDirtygetDirty的方法可以检查你雄辩模型的变化。他们比较了$original数组和$attributes数组的内容。

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

3217   public function getDirty()
3218   {
3219           $dirty = [];
3220   
3221           foreach ($this->attributes as $key => $value) {
3222              if (! array_key_exists($key, $this->original)) {
3223                  $dirty[$key] = $value;
3224              } elseif ($value !== $this->original[$key] &&
3225                           ! $this->originalIsNumericallyEquivalent($key)) {
3226                $dirty[$key] = $value;
3227              }
3228           }
3229   }

此方法允许您查看脏字段。在一个雄辩的模型完成保存后,您同步原始数组。这意味着一旦您保存了模型,就不应该认为任何东西是脏的。

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

1502   protected function finishSave(array $options)
1503   {
1504            $this->fireModelEvent('saved', false);
1505   
1506            $this->syncOriginal();
1507   
1508            if (Arr::get($options, 'touch', true)) {
1509                $this->touchOwners();
1510            }
1511   }

在你保存完一个模型后,$attributes数组被syncOriginal方法复制到$original

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

3154   public function syncOriginal()
3155   {
3156          $this->original = $this->attributes;
3157   
3158          return $this;
3159   }

为什么我要报道这些?了解口才如何处理某些事情对你有好处。看看泰勒是怎么处理这个问题的,可以给你自己的问题一些思路。通过查看引擎盖下面,你可以确保泰勒没有在拍快照或做备忘录图案。你确实发现他在跟踪一个数组中的属性。这很方便。这意味着,如果你只想要你的属性快照,泰勒有你涵盖。可以用getAttributes()

app/test1.php

   $person1 = new \App\Person;
   $person1->name = 'Kelt';
   $snapshot1 = $person1->getAttributes();

   $person2 = new Person($snapshot1);
   print personInfo('New person from attributes of person1', $person2);

控制台输出

New person from attributes of person1
name: Kelt, table: people, email:

该代码不需要您进行特殊编码。都已经烤成雄辩了。如果你需要做的只是恢复属性,那么你就不需要备忘录模式。

对象序列化

您可以选择的另一个选项是对象序列化。您不是创建 memento 模式,而是序列化一个对象,从而将其保存为一个字符串。该字符串可以被存储以备后用。当需要还原时,将字符串反序列化回对象中。这是创建快照的好方法。

app/test2.php

$person = new \App\Person;
$person->setTable('persons');
$person->email = 'testing@test.com';
$snapshot1 = serialize($person);

$person->setTable('crm_people');
$person->email = "some-new@email.com";

print personInfo('examining person object', $person);

$person = unserialize($snapshot1);

print personInfo('restoring snapshot 1', $person);

控制台输出

examining person object
name: , table: crm_people, email: some-new@email.com
restoring snapshot 1
name: , table: persons, email: testing@test.com

为什么不只是序列化对象呢?为什么要使用备忘录图案呢?memento 模式比序列化有一些优势。memento 模式提供的第一个优势是灵活性和可控性。如果你只是简单地序列化你的对象,你就不能选择你想要保存的字段。如果您只想保存几个受保护的属性,该怎么办?memento 模式允许您只选择那些您想要保存的字段。

大多数对象都可以序列化。包含资源(如数据库连接或文件流)的对象可能会有问题。在某些情况下,序列化会失败。这是备忘录模式的第二个好处。使用 memento 模式时,您不必处理sleepwakeup可序列化 3 接口。但是,您不应该害怕处理序列化。对象序列化非常酷。下面的测试表明,即使你已经连接到数据库,口才似乎仍然为你处理序列化。快看。

app/test3.php

$person = new \App\Person;
$person->email = 'testing@test.com';
$person->save();

$snapshot1 = serialize($person);
$person->email = "something@else.com";
$person = unserialize($snapshot1);

print $person->isDirty() === false ? '' : 'isDirty' . PHP_EOL;

print personInfo('unserialized person', $person);

控制台输出

unserialized person
name: , table: people, email: testing@test.com

为了让上面的代码工作,你必须运行迁移。您还需要在您的机器上安装 SQLite PHP PDO 驱动程序。

序列化可以带你走很长的路。即使有 memento 模式的优势,序列化仍然是一个非常酷的选择。

结论

memento 模式用于避免违反封装,同时仍然捕获发起者类的内部变量。备忘录模式也有一些缺点。当发起者对象有大量数据要存储时,创建 memento 对象的成本可能是存储器密集型的。这种模式的第二个缺点是,您给发起者对象增加了更多的责任。

你也学会了备忘录模式的替代方案。总而言之,如果在创建快照时不需要任何额外的灵活性,这种模式可能会被序列化替代。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 315 页

2

http://en.wikipedia.org/wiki/Memento_%28film%29

3

http://php.net/manual/en/class.serializable.php

二十二、观察者

$> git checkout observer

目的

定义对象之间的一对多依赖关系,这样当一个对象改变状态时,它的所有依赖对象都会得到通知并自动更新。 1

应用

当您希望对象对 subject 对象上的特定事件做出反应时,可以使用 observer 模式。当主题改变时,它通知观察者。这是我将要介绍的最流行的模式之一。事实上,PHP 已经内置了观察者模式的接口: SplSubject 2SplObserver3

抽象结构

  • SplSubject是一个抽象类或接口。当使用一个抽象类时,你可以在这里放置一个数组来跟踪附加的观察器。SplSubject包含三种方法。attach方法给这个主题增加了观察者。detach方法带走观察者。notify方法通常包含一个循环,该循环遍历所有附加的观察器并调用它们的update方法。

  • SplObserver是一个抽象类或接口。SplObserver接口包含一个名为update的方法,每当主题更新时由SplSubject触发。

  • RealSubjectSplSubject的一个实现。它将包含存储对象(数组/集合/等等。)需要挂在附加的观察器上。一些实现实际上创建了一个抽象类来代替SplSubject,并将存储对象放在抽象类中。既然SplSubject是接口,就不能这么做。但是,没有什么可以阻止你创建自己的抽象类,而不是使用接口。

  • RealObserverSplObserver接口的一个实现。它的update方法将被传递给SplSubject的一个实例。这个类处理每当主题更新时需要执行的特定逻辑。

A435115_1_En_22_Fig1_HTML.jpg

图 22-1。

Abstract structure

例子

在本例中,您将使用 PHP 内置的SplObserverSplSubject展示一个相当通用的观察者模式。解决这个问题后,你将进入一个更复杂的场景,泰勒切奶酪,而附近的observing闻到了奶酪的味道。最后,你将在雄辩的模型上使用观察者来结束这一章。您还将探索 Laravel 雄辩模型的一些内部内容,以及它如何使用事件来处理附加的观察者。

示例结构

图 22-2 为结构示意图。

A435115_1_En_22_Fig2_HTML.jpg

图 22-2。

Example structure

履行

SPL 的一般观察员

您只需要创建两个类,因为您使用的是通用的 SPL 接口。

app/RealObserver.php

namespace App;

class RealObserver implements \SplObserver
{
        public function __construct($name)
        {
                $this->name = $name;
        }

        public function update(\SplSubject $subject)
        {
                print "{$this->name} was notified by {$subject->name}" . PHP_EOL;
        }
}

如果你想知道 SPL 代表什么,它是标准 Php 库的缩写。SPL 有许多漂亮的东西。你可以在 http://php.net/manual/en/book.spl.php 看到它们。

app/RealSubject.php

namespace App;

class RealSubject implements \SplSubject
{
    private $observers;

    public function __construct($name)
    {
        $this->name = $name;
        $this->observers = new \SplObjectStorage;
    }

    public function attach(\SplObserver $observer)
    {
        $this->observers->attach($observer);
    }

    public function detach(\SplObserver $observer)
{
        $this->observers->detach($observer);
}

    public function notify()
    {
        foreach ($this->observers as $observer) {
            $observer->update($this);
        }
    }
}

记下SplObjectStorage对象。注意,当你想分离一个观察者时,你只需要在SplObjectStorage对象上调用detach。如果你在这里使用数组会怎么样?您必须遍历 observers 数组并找到要删除的观察者。那你就必须取消设置。SplObjectStorage是一个整洁的助手类。我知道我已经说过了,但我还要再说一遍。我推荐你多看看标准 PHP 库 4

这两个类,RealSubjectRealObserver,是最通用的。很难观察这两个类别并从中得出任何商业意义。这是使用SplSubjectSplObserver的缺点之一。您被泛型方法卡住了。除了对update方法输入SplSubject之外,你也不能输入任何提示。几乎不值得使用它们。我认为SplSubjectSplObserver有助于引导。然而,让我们运行您的模拟,看看一些测试输出。

app/simulator.php

$subject1 = new \App\RealSubject('subject1');
$observer1 = new \App\RealObserver('observer1');
$observer2 = new \App\RealObserver('observer2');
$observer3 = new \App\RealObserver('observer3');

$subject1->attach($observer1);
$subject1->attach($observer2);
$subject1->attach($observer3);

$subject1->notify();

控制台输出

> php app/simulator.php
observer1 was notified by subject1
observer2 was notified by subject1
observer3 was notified by subject1

这就是了。最后一点,我想在这里指出,你可能并不总是希望你的notify方法是公共的。您可能只想在内部流程发生时通知观察者。不幸的是,由于使用通用 SPL 接口的限制,您不能在这里更改它。让我们在下一部分获得更多的自定义。

泰勒切奶酪

你们都知道人是复杂的。他们可以观察和被观察。换句话说:人既是主体又是观察者。在下一个例子中,你将讲述一个故事。故事是这样的。

这是平静的一天。泰勒在#laravel irc 上和他的 Laravel 粉丝一起玩。突然,泰勒切开了奶酪。没有人真正注意到。除了杰弗瑞,马楚加,戴尔。哦,亚当,马特和格雷厄姆·坎贝尔。这些家伙碰巧注意到了并且就在附近。泰勒不喜欢分享他的蒙特雷杰克奶酪。他决定离开频道,独自享用他的奶酪。翻译成代码,是这样的:

app/cheese.php

$taylor = new \App\Person("Taylor");
$dayle = new \App\Person("Dayle");
$jeffery = new \App\Person("Jeffery");
$machuga = new \App\Hipster("Machuga");
$campbell = new \App\Person('Graham');

$taylor->nearBy($dayle, $jeffery, $machuga, $campbell);
$taylor->cuts('cheedar');
$taylor->says('oops...');

$taylor->noLongerNearBy($dayle, $jeffery, $machuga);
$taylor->cuts('monterey jack');
$taylor->says('This monterey jack cheese is all mine! muhahaha!');

对于这段代码,您预期的输出如下:

控制台输出

> php app/cheese.php
--- Taylor cuts cheedar ---
Dayle says:         "i smell cheedar"
Jeffery says:         "i smell cheedar"
Machuga says:         "i smell cheedarz, that you Taylor?"
Graham says:         "i smell cheedar"
Taylor says:         "oops..."
--- Taylor cuts monterey jack ---
Taylor says:         "This monterey jack cheese is all mine! muhahaha!"

不过,上面的代码还不能工作。您仍然需要创建底层类。让我们从你的基本界面开始:奶酪 smeller 和奶酪切割机。

app/CheeseSmeller.php

namespace App;

interface CheeseSmeller
{
        public function smells(CheeseCutter $cutter, $cheese);
}

app/CheeseCutter.php

namespace App;

interface CheeseCutter
{
        public function nearBy(CheeseSmeller $smeller);
        public function noLongerNearBy(CheeseSmeller $smeller);
        public function cuts($cheese);
}

一个人在你的故事里有名字。您需要在构造函数中提供名称。一个人也可以说话,所以你也需要一个方法。

app/Person.php

class Person implements CheeseSmeller, CheeseCutter
{
        public function __construct($name)
        {
                $this->name = $name;
                $this->nearBy = new \SplObjectStorage;
        }

        public function says($phrase)
        {
                print "{$this->name} says: \t\"" . $phrase . "\"" . PHP_EOL;
        }

你需要实现nearBy。这种方法可以跟踪附近的嗅探器。

app/Person.php

16   public function nearBy(CheeseSmeller $smeller)
17   {
18           $smellers = func_get_args();
19   
20           foreach ($smellers as $smeller) {
21           
22                   $this->nearBy->attach($smeller);
23           }
24   }

接下来,您实现noLongerNearBy。这将移除可能在此人附近的任何气味。

app/Person.php

26   public function noLongerNearBy(CheeseSmeller $smeller)
27   {
28           $smellers = func_get_args();
29   
30           foreach ($smellers as $smeller) {
31           
32                   $this->nearBy->detach($smeller);
33           }
34   }

当一个人切奶酪时,附近的任何人都会闻到。

app/Person.php

36   public function cuts($cheese)
37   {
38           print "--- {$this->name} cuts {$cheese} ---" . PHP_EOL;
39   
40           foreach ($this->nearBy as $nearBy) {
41           
42                   $nearBy->smells($this, $cheese);
43           }
44   }

最后,作为您的CheeseSmeller接口的一部分,您需要实现smells。每当一个人在另一个切奶酪的人附近时,就调用这个方法。

app/Person.php

46   public function smells(CheeseCutter $cutter, $cheese)
47   {
48           $this->says("i smell {$cheese}");
49   }

这就是了。与您之前提到的通用 SPL 示例不同,这段代码散发着业务逻辑的味道。您可以查看这些方法名并发现发生了什么。SPL 的例子和切奶酪的例子都使用了观察者模式。接下来,您将检查嵌入在所有雄辩模型中的观察者模式。

雄辩的观察者:开箱即用的观察者

所有雄辩的模型都有观察者的模式。对于下一个例子,您将创建一个Car模型。一辆车有几个属性:manufacturervindescriptionyear。随意查看播种机 5迁移 6 。您可能不需要运行种子和迁移,因为我也将 database.sqlite 数据库提交给了 GitHub 存储库。

app/Car.php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Car extends Model
{

}

现在你有了一个雄辩的模型。你如何在这个模型上设置观察者?你用observe的方法。

app/test1.php

\App\Car::observe(new Observers\ObserveEverything);

下面我做了一个名为ObserverEverything的通用观察器,它包含了你可以在雄辩模型上观察到的所有开箱即用的方法。每个方法只是打印一条语句,这样您就可以知道它何时被调用。让我们来看看这个方法列表。

app/Observers/observe everything . PHP

namespace App\Observers;

class ObserveEverything

{
        public function creating($model)
        {
                print "creating model" . PHP_EOL;
        }

        public function created($model)
        {
                print "created model" . PHP_EOL;
        }

        public function updating($model)
        {
                print "updating model" . PHP_EOL;
        }

        public function updated($model)
        {
                print "updated model" . PHP_EOL;
        }

        public function saving($model)
        {
                print "saving model" . PHP_EOL;

        }
        public function saved($model)
        {
                print "saved model" . PHP_EOL;
        }

        public function deleting($model)
        {
                print "deleting model" . PHP_EOL;
        }

        public function deleted($model)
        {
                print "deleted model" . PHP_EOL;
        }

        public function restoring($model)
        {
                print "restoring model" . PHP_EOL;
        }

        public function restored($model)
        {
                print "restored model" . PHP_EOL;
        }
}

您可以根据每个方法名进行猜测,并确定何时会调用它。然而,我在这里列出了每一个更深入的见解。

  • 在数据库中首次创建模型之前调用creating。您可以通过在新构建的模型上调用save或者静态地使用create来触发它。请注意,当构造新模型或从数据库中检索新模型时,不会触发此方法。如果方法返回false,模型将不会被创建。

  • 在数据库中创建模型后,调用created

  • 在数据库中保存现有模型之前,调用updating。如果该方法返回false,则不更新模型。

  • 在数据库中保存现有模型后,调用updated

  • 在数据库中创建或更新模型之前调用saving。如果该方法返回false,则不保存模型。

  • 在数据库中创建或更新模型后,调用saved

  • 从数据库中删除模型之前调用deleting。如果方法返回false,模型不会被删除。

  • 从数据库中删除模型后,调用deleted

  • 在数据库中恢复模型之前调用restoring。恢复仅适用于使用 Laravel 软删除的型号。这将从数据库中该记录的deleted_at列中删除日期。如果方法返回false,模型不会被恢复。

  • 在数据库中恢复模型后,调用restored

下一步是将这个观察者附加到您的Car模型上。

app/test1.php

7   Car::observe(new Observers\ObserveEverything);

您将通过修改Car模型来触发事件。下面进一步展示了调用了ObserveEverything观察器中的哪些方法。

app/test1.php

10   $car1 = Car::find(1);
11   $car1->vin = str_random()(32);
12   print "\nSaving car #1 to database\n";
13   $car1->save();

Saving car #1 to database

saving model
updating model
updated model
saved model

app/test1.php

17   $car2 = new Car;
18   $car2->description = "cool car description";
19   $car2->vin = str_random(32);
20   $car2->manufacturer = 'Honda';
21   $car2->year = '2012';
22   print "\nCreating new car\n";
23   $car2->save();

Creating new car
saving model
creating model
created model
saved model

app/test1.php

26   print "\nDeleting that new car you just made\n";
27   $car2->delete();

Deleting that new car you just made
deleting model
deleted model

app/test1.php

30   print "\nRestoring that car you just deleted\n";
31   $car2->restore();

Restoring that car you just deleted
restoring model
saving model
updating model
updated model
saved model
restored model

阻止观察员更新

您可能已经注意到这些现成的观察器事件的重复出现模式。每一个都为观察者提供了在雄辩模型上捕捉事件前后的能力。提供的一个特性是,如果您对任何 before 类型的事件返回false,那么进一步的执行将停止。这意味着你可以阻止一个雄辩的模型使用观察者saving, creating, updating, deleting,或者restoring。让我们看一个例子。

假设一辆汽车的所有 VIN 号码都必须包含字母 h。使用观察器,当 VIN 不包含字母 h 时,您将阻止对数据库的更新。

app/test1.php

 7   Car::observe(new Observers\VinObserver);
 8   $car1 = Car::find(1);
 9   
10   // attempt #1 with no h

11   $car1->vin = "asdfasdfasdf";
12   $car1->save() && print "attempt #1 saved\n";
13   
14   // attempt #2 contains h

15   $car1->vin = "hasdfasdfasdf";
16   $car1->save() && print "attempt #2 saved\n";

model vin does not contain letter 'h', canceling update...
attempt #2  saved

第一次尝试更新失败。只有第二次尝试保存到数据库。这里是执行 h 规则的VinObserver

app/Observers/VinObserver.php

namespace App\Observers;

class VinObserver

{
        public function updating($model)
        {
                $original = $model->getOriginal('vin');

                if ($model->vin === $original) {
                        return true;   // ignore unchanged vin

                }

                if (! str_contains($model->vin, 'h')) {

                        print "model vin does not contain letter 'h', canceling updating vi \n";

                        return false;
                }
        }

}

您会忽略任何没有更改 VIN 号的车型。没有字母 h 的 VINs 返回 false。这将阻止更新的发生。那么 Laravel 是如何在引擎盖下为你处理这件事的呢?让我们来看一下更新。

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

1520   protected function performUpdate(Builder $query, array $options = [])
1521   {
1522           if ($this->fireModelEvent('updating') === false) {
1523               return false;
1524           }
1525           
1526           if ($this->timestamps && Arr::get($options, 'touch', true)) {
1527               $this->updateTimestamps();

1528           }
1529         
1530           $dirty = $this->getDirty();
1531         
1532           if (count($dirty) > 0) {
1533               $this->setKeysForSaveQuery($query)->update($dirty);
1534               $this->fireModelEvent('updated', false);
1535           }
1536         
1537           return true;

当您执行更新时,发生的一件事是检查脏字段。如果这个模型没有任何变化,那么你甚至不需要更新。接下来,你可以看到fireModelEvent。如果它返回 false,那么您不执行更新。让我们继续检查一下fireModelEvent方法。

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

1651   protected function fireModelEvent($event, $halt = true)
1652   {
1653           if (! isset(static::$dispatcher)) {
1654              return true;
1655           }
1656   
1657           // You will append the names of the class to the event to distinguish it from

1658           // other model events that are fired, allowing you to listen on each\model

1659           // event set individually instead of catching event for all the models.

1660           $event = "eloquent.{$event}: ".static::class;
1661   
1662           $method = $halt ? 'until' : 'fire';
1663   
1664           return static::$dispatcher->$method($event, $this);
1665   }

此方法在调度程序上调用 until 或 fire 并返回结果。为什么在这个模型上看不到任何对观察者的引用?您附加到模型的所有观察器都放在 dispatcher 中。这就是为什么你在这个fireModelEvent方法中看不到任何关于观察者的东西。

那么这个静态调度程序是什么呢?雄辩模型使用共享调度程序,特别是$app['events']单例。事件调度程序是一个消息总线。是Illuminate\Events\Dispatcher的一个实例。当应用启动数据库服务提供者时,事件调度程序被注入到雄辩模型中。

vendor/laravel/framework/src/Illuminate/Database/databaseserviceprovider . PHP

20   public function boot()
21   {
22           Model::setConnectionResolver($this->app['db']);
23   
24           Model::setEventDispatcher($this->app['events']);
25   }

您已经了解了模型事件是如何触发的。您已经看到了如何阻止对模型的更新。但是,你还是少了一块。您已经假设所有注册的观察者都被放置在事件调度器中,因为fireModelEvent正在使用调度器。不过,你不知道怎么做。因此,让我们追溯一下观察者是如何依附于一个雄辩的模型和潜在的调度员来揭开这个模糊的神秘面纱的。

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

407   public static function observe($class)
408   {
409           $instance = new static;
410   
411           $className = is_string($class) ? $class : get_class($class);
412   
413           // When registering a model observer, you ...

414           // ... do moose stuff ... (not really)

415           // ... making it convenient to watch these.

416          foreach ($instance->getObservableEvents() as $event) {
417             if (method_exists($class, $event)) {
418                 static::registerModelEvent($event, $className.'@'.$event, $priority);
419             }
420          }
421   }

当您在模型上调用observe方法时,它会遍历可能的事件,然后用事件和类名调用registerModelEventgetObservableEvents方法返回一个字符串数组。弦是您之前看到的事件(updatingupdatedcreatingcreated等等)。它还包括您使用$observables数组放在这个类上的任何额外的可观察事件。在下一个例子中,您将在您的汽车模型上附加更多可观察到的事件。使用替换,您可以在当前示例中推断出该方法具有以下参数:

static::registerModelEvent('updating', 'Observers\VinObserver@updating');

那么registerModelEvent具体做什么呢?让我们来看看。

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

1270   protected static function registerModelEvent($event, $callback)
1271   {
1272           if (isset(static::$dispatcher)) {
1273               $name = static::class;
1274               static::$dispatcher->listen("eloquent.{$event}: {$name}", $callback, $priority);
1275           }
1276   }

共享调度程序被告知监听一个eloquent.updating: App\Cars事件。每当在调度程序上触发该事件时,就会触发回调。这就是观察者如何依附于一个雄辩的模型。请注意,每个模型都没有自己的观察器数组。观察器都在一个调度程序中共享。这比在汽车模型的每个实例上附加一个观察器数组使用的内存要少。也意味着所有的车模都有相同的观察者,这是意料之中的。如果您想专门为汽车的一个实例而不是另一个实例创建一个观察者,那么您需要做一些不同的事情。我把这个留给你去思考。接下来,现在您已经了解了观察者是如何在雄辩模型中被附加和触发的,让我们制作您自己的客户观察者事件。

添加自定义观察者

对于findingfound,没有现成的可观察事件。您将创建这些。每当您试图使用Car::find($id)通过 id 查找特定型号时,这些事件都会被调用。就像之前的对应物一样,如果finding返回 false,那么您将停止执行,防止模型被发现。

app/test3.php

 7   \App\Car::observe(new \App\Observers\LookupObserver);
 8   
 9   $car0 = Car::find(0);
10   $car1 = Car::find(1);
finding id 1!
found model 1

没有 id 为零的车。您的查找观察器将防止获取任何无效的 id。你也可以在这里介绍其他东西。也许每次你查找一辆车,它就会更新一些分析数据库表。

app/Observers/lookup observer . PHP

namespace App\Observers;

class LookupObserver

{
        public function finding($id)
        {
                if ($id < 1) return false;

                print "finding id {$id}!\n";
        }
        public function found($model)
        {
                print "found model {$model->id}\n";
        }
}

雄辩模型上没有findingfound事件。因此,为了让上面的代码工作,您还需要做更多的工作。剩下的工作在Car模型本身中完成。

app/Car.php

class Car extends Model
{
        use SoftDeletes;

        protected $dates = ['deleted_at'];

        protected $observables = ['finding', 'found'];

您正在使用软删除,所以这就是前两行所做的。$observables数组允许您监听其他事件。它在雄辩的getObservableEvents方法中使用。该方法将自定义观察器事件与事实上的标准事件合并。

vendor/laravel/framework/src/Illuminate/Database/口才/模型. php

1284   public function getObservableEvents()
1285   {
1286           return array_merge(
1287                   [
1288                           'creating', 'created', 'updating', 'updated',
1289                           'deleting', 'deleted', 'saving', 'saved',
1290                           'restoring', 'restored',
1291                   ],
1292                   $this->observables // <-- merge in custom events

1293           );
1294   }

回到您的汽车模型,您将覆盖find方法。这个方法将为您触发模型事件。

app/Car.php

13   public static function find($id, $columns =  ['*'])
14   {
15           $shouldProceed = static::triggerModelEvent('finding', true, $id);
16   
17           if ($shouldProceed === false) return null;
18   
19           $results = parent::find($id, $columns);
20   
21           static::triggerModelEvent('found', $stop = false, $results);
22   
23           return $results;
24   }

这个方法内部是对父方法find的调用。结果被返回。不过,你已经用父find方法包装了triggerModelEvent。这允许您正确地执行事件。

请注意,您不能使用fireModelEvent,因为find方法是静态的。你甚至还没有你的模型的实例(因为你还没有找到它!).因此,您需要引入自己静态触发模型事件的方式。注意,如果您已经有了模型的实例,您将使用fireModelEvent而不是triggerModelEvent

app/Car.php

26   protected static function triggerModelEvent($event, $halt, $params =
27   null)
28   {
29           if (! isset(static::$dispatcher)) return true;
30   
31           $event = "eloquent.{$event}: ".get_called_class();
32   
33           $method = $halt ? 'until' : 'fire';
34   
35           return static::$dispatcher->$method($event, $params);
36   }

您镜像了fireModelEvent方法的功能。它调用调度程序的方式与fireModelEvent非常相似。然而,这更灵活一点。它允许您提供自定义参数,而不是假设您已经有一个雄辩模型的实例来处理。

结论

事件驱动架构(Event-driven architecture)7是一种软件架构模式,围绕着应用内的状态。观察者模式可以用在这种类型的软件架构中。还有其他类似的模式。我将在这里列出它们,因为它们都略有不同。

  • 观察者模式将观察者对象附加到主题上。当主体状态改变时,它通知观察者。观察者模式似乎比中介者模式更受欢迎。事实上,观察者模式赢得了最流行设计模式奖。

  • 中介模式使用一个对象来中介许多其他对象。与 observer 模式不同,中介对其聚集的从属对象了解得更多,因为它对每个从属对象调用特定的方法。

  • 命令总线模式与命令模式相关。这也可以是事件驱动的。当一个对象的状态改变时,它可以向命令总线发送命令。命令总线将立即处理命令,或者将作业排队以备后用。

  • sub/pub 模式(subscribe/publish)使用消息总线来传递状态变化。Laravel 有一个内置的消息总线,叫做事件 8 。你可以这样使用Event:

Event::listen('Illuminate\Auth\Events\Login', function($user) {
    $user->last_login = new DateTime;

    $user->save();
});

$response = Event::fire('Illuminate\Auth\Events\Login', [$user]);

观察者是本书中最流行的设计模式之一。和其他模式一样,它也有缺点。

缺点

第一个缺点是观察者与主体分离。这个主题有一系列的观察者。主体对其观察者知之甚少。当状态改变时它调用它们。虽然这提供了强大的灵活性,但这也是一个缺点。观察者必须在某个地方依附于主体。你在哪里注册是任意的。它可能存在于某个引导文件、服务提供者,甚至是您创建的某个自定义文件中。假设您有许多不同的观察者附加到一个主题上,这些附件都分散到许多不同的文件中。管理附属于主题的观察者变得很麻烦。

另一个缺点是状态变化变得更加复杂。当一个主题的状态改变时,观察者被调用。每个观察者都不知道对方。如果一个主题有 15 个观察者,那么每个观察者都不知道它的 14 个兄弟在做什么。这种类型的代码很难优化。例如,如果所有 15 个观察者都保存到数据库中会怎样?因此,任何时候对主题进行一次更改,都会导致 16 次数据库更改。如果有问题,这可能很难调试!你必须追踪每个观察者,找出哪个是罪魁祸首。

抛开缺点不谈,观察者模式仍然在许多架构和应用中使用。它是源中的一个强有力的盟友,并提供了监视主体内状态变化的灵活性。明智地使用它。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 326 页

2

http://php.net/manual/en/class.splsubject.php

3

http://php.net/manual/en/class.splobserver.php

4

http://php.net/manual/en/book.spl.php

5

https://github.com/kdocki/larasign/blob/observer/database/seeds/DatabaseSeeder.php

6

https://github.com/kdocki/larasign/blob/observer/database/migrations/2015_03_10_222303_create_cars_table.php

7

http://en.wikipedia.org/wiki/Event-driven_architecture

8

http://laravel.com/docs/4.2/events

二十三、状态

$> git checkout state
$> composer update

目的

允许对象在其内部状态改变时改变其行为。该对象看起来会改变它的类。 1

应用

状态模式是手套模式。当它合适的时候,它正好合适。换句话说,你会知道你什么时候需要它。您可以使用状态模式对在其生命周期中改变行为的事物进行建模。

有没有发现自己使用一些名为$type的字符串变量和一大串switch/case/if/else语句来决定调用哪些方法?国家模式将清理所有这些令人讨厌的情况。自动售货机和许多其他现实生活中的机器一样,使用状态模式。你看不到那些外在的状态。自动售货机的所有状态都发生在内部。外在,你体验不同的行为。例如,如果你在自动售货机上点击购买按钮而没有付款,会发生什么?你付款后呢?内部状态会改变该按钮的行为。按钮永远不会对您(客户端)改变。

抽象结构

  • Client是使用的主类。底层方法使用上下文和状态来改变运行时的行为。客户端通常不直接与状态对象交互。换句话说,用户永远不会知道一个new SomeState类。它只使用这个客户端上的公共方法。客户端的内部状态可以在用户完全不知道的情况下改变。当客户端调用event()时,它会转发给底层状态的处理程序。您不必将状态存储在上下文中。可以直接存储在这个类里面。如果您不需要存储状态之间的上下文数据,这一点尤其正确。为什么不把$state放在客户端对象里面?因为你将上下文传递给每个州的handle方法。因此,任何状态都能够使用上下文对象进行转换。如果你不这样做,你将不得不在你的客户端上公开暴露一个setState()。这意味着您可以在类之外控制状态,这可能不是一个好主意,因为它给这个客户端类增加了额外的复杂性(和责任)。

  • Context被传递到所有状态,这允许状态之间的通信。上下文被传递给每个状态的处理程序。一个上下文可以像数据存储(StdClass)一样简单,也可以包含帮助器方法(比如下图所示的method)。可以从任何状态类调用method。有时状态是“无状态”的,不需要与其他状态通信。在这种情况下,您不需要上下文对象。我在网上看到的一些 State 模式的实现实际上将ContextClient组合成了一个类。组合ClientContext的缺点是它暴露了你的内在。这给client类增加了额外的责任。你要把它们分开,避免公开上下文。

  • State是一个接口。所有具体的状态都将实现这个接口。它为一个状态定义了不同的处理方法。句柄方法应该以可能发生的事件命名。我很快会谈到这一点。现在,只要知道有一些术语会出现在你面前。

    • 状态:一些状态类

    • 事件:状态类上的可用方法

    • 转换:当状态和事件导致状态改变时

  • ConcreteState1 / ConcreteState2各自代表不同的状态。里面的方法向上下文对象提供指令。换句话说,上下文对象应该为这个状态和事件做什么?这些类也可以使用上下文对象的setState方法来改变状态。每当你改变状态,这就是所谓的转变。

A435115_1_En_23_Fig1_HTML.jpg

图 23-1。

Abstract structure

例子

让我们建立一个自动售货机的模型。你可以按自动售货机上的按钮。根据它的内部状态,它会做不同的事情。同样的按钮,不同的回应。您可以使用有限状态机的绘图来模拟自动售货机。见图 23-2 。

A435115_1_En_23_Fig2_HTML.jpg

图 23-2。

Vending machine example

您可以将此图表转换为事件表。顶部是您需要创建的状态。左侧是每个状态的事件。表格内容描述了每个转换应该发生的情况(状态和事件对)。

| 事件/状态 | 空闲状态 | HasMoneyState | | --- | --- | --- | | 插入 | 切换到有钱状态。 | 给机器多加点钱。 | | 退还 | 告诉用户“不退款。” | 返还用户投入机器的所有钱。将机器恢复到空闲状态。 | | 购买 | 告诉用户,他们需要插入钱,然后才能购买东西。 | 如果产品可用,并且用户已经输入足够的钱来购买,则调用机器上的存款并将状态设置为 IdleState。 |

示例结构

图 23-3 为结构示意图。

A435115_1_En_23_Fig3_HTML.jpg

图 23-3。

Example structure

实现:示例 1 -状态模式

你从你的VendingMachine课开始。通过使用上面的状态/事件表,您可以看到自动售货机有三个事件:insert, refundpurchase。您为每个事件创建一个方法。每种方法是做什么的?它是底层状态事件的通道。您还必须用产品列表初始化您的上下文。这些产品将在不同的州之间共享。这种类型的上下文信息对于每个状态都很重要。自动售货机上下文与它可购买的产品数量保持一致。例如,您不能从这台机器上购买任何胡椒博士,因为金额为零。

app/example 1/vending machine . PHP

namespace App\Example1;

class VendingMachine;

{
        protected $context;

        protected $products = [
                'Dr. Pepper' => ['amount' => 0, 'price' => 125],
                'Pepsi' => ['amount' => 1, 'price' => 125],
                'Mountain Dew' => ['amount' => 0, 'price' => 125],
        ];

        public function construct()
        {
                $this->context = new VendingMachineContext($this->products);
                $this->context->setState(new IdleState);
        }

        public function insert($money)
        {
                return $this->context->state()->insert($this->context, $money);
        }

        public function refund()
        {
                return $this->context->state()->refund($this->context);
        }

        public function purchase($product)
        {
                return $this->context->state()->purchase($this->context, $product
        }
}

现在我们来看看VendingMachineContext。这个类保存有价值的信息,比如当前状态、插入了多少钱、当前存入了多少钱,以及关于产品价格和库存的信息。

app/example 1/vending machine context . PHP

namespace App\Example1;

class VendingMachineContext

{
        protected $state;
        public $insertedMoney;
        public $totalMoney;
        public $products;

        public function __construct($products, $totalMoney = 0, $insertedMoney = 0)

        {
                $this->products = $products;
                $this->totalMoney = 0;
                $this->insertedMoney = 0;
        }

        public function state()
        {
                return $this->state;
        }

        public function setState(VendingMachineState $state)
        {
                $this->state = $state;
        }
}

这个上下文类应该被传递给每个自动售货机状态。这里是你的起始状态,IdleState

app/Example1/IdleState.php

namespace App\Example1;

class IdleState implements VendingMachineState
{
        public function insert($machine, $money)
        {
                $hasMoney = new HasMoneyState;
                $machine->setState($hasMoney);
                $hasMoney->insert($machine, $money);
        }

        public function refund($machine)
        {
                print "no refund available in idle state\n";
        }

        public function purchase($machine, $product)
        {
                print "you'll need to enter money to purchase $product\n";
        }
}

插入实际上显示了向有钱状态的转变。请注意,当您试图在空闲状态下发布退款或购买事件时会发生什么。你仍然需要定义在这些事件中发生了什么。有时一个事件什么也不做(即使你让它打印出一些文本)。

A435115_1_En_23_Figa_HTML.jpg如果你发现自己需要很多不同的事件,而这些事件对于一个特定的状态来说从来不会发生,那么你可以使用一个抽象类来代替VendingMachineState的接口。在VendingMachineState内部,您可以为每个事件方法实现空方法。然而,我喜欢指定每个事件,即使它是空的。即使更啰嗦,在我看来也更干净。

再来看看下一个状态,HasMoney

app/Example1/HasMoneyState.php

namespace App\Example1;

class HasMoneyState implements VendingMachineState
{
        public function insert($machine, $money)
        {

                if ($money < 0) throw new \Exception('You cannot insert negative money');

                print "you have inserted {$money} cents\n";

                $machine->insertedMoney += $money;
}

        public function refund($machine)
        {
                print "refunding {$machine->insertedMoney} cents\n";

                $machine->insertedMoney = 0;

                $machine->setState(new IdleState);
        }

当您处于“有钱”状态并插入更多的钱时,您只需继续添加到$machineinsertedMoney属性中。这就是为什么语境很重要。与其使用上下文,不如将$insertedMoney属性放在这个HasMoneyState中。那会有用的。然而,还有其他的属性,比如$totalMoney$products,即使你转换到另一个状态也是需要的。上下文跨状态传递属性。您将在下一个事件方法中使用这些属性:purchase

app/Example1/HasMoneyState.php

23   public function purchase($machine, $productName)
24   {
25           if ($machine->products[$productName]['amount'] < 1) {
26           
27                   print "sorry, you are out of $productName, please choose another
28   product\n";
29                   return;
30           }
31   
32           if ($machine->products[$productName]['price'] > $machine->insertedMoney) {
33   
34           
35                   print "sorry, you need at least {$machine->products[$productName]
36   'price']}   to buy $productName\n";
37                   return;
38           }
39   
40           $machine->totalMoney += $machine->insertedMoney;
41           $machine->insertedMoney = 0;
42   
43           print "[vending machine now has {$machine->totalMoney} cents]\n";
44           print "[vending machine spits out $productName]\n";
45   
46           $machine->setState(new IdleState);
47   }

首先,你要确保你选择的产品有库存。接下来,您检查以确保用户插入了足够的钱。现在,您可以通过添加insertedMoneytotalMoney来继续购买。最后,您需要将状态重置回空闲。让我们来看看你的自动售货机在运行!

app/example1.php

$machine = new \App\Example1\VendingMachine;

$machine->refund();

// no refund available in idle state

$machine->insert(50);

// you have inserted 50 cents

$machine->refund();

// refunding 50 cents

$machine->insert(100);

// you have inserted 100 cents

$machine->purchase('Mountain Dew');

// sorry, you are out of Mountain Dew, please choose another product

$machine->insert(25);

// you have inserted 25 cents

$machine->purchase('Dr. Pepper');

// sorry, you are out of Dr. Pepper, please choose another product

$machine->purchase('Pepsi');

// [vending machine now has 125 cents]

// [vending machine spits out Pepsi]

$machine->refund();     // because you  all hit

                        // that button after you

                        // buy a soda, right?

                        // no  refund  available  in  idle  state

示例 2 -输入状态机

在下一个例子中,您将看到一种不同类型的模式,它是从 state 模式发展而来的。你可以称之为状态机。状态机试图集成状态模式的一部分。你可以在 https://github.com/definitely246/state machine 看到这个状态机。这个想法是你创建一个可重用的架构来切换你的客户对象的状态。有许多不同的方法可以做到这一点。一种方法是将状态模式抽象出来。上下文不再知道状态。上下文只提供关于它自己的信息和帮助器方法。让我们来看看您的新自动售货机(上下文)类。

app/example 2/vending machine . PHP

namespace App\Example2;

class VendingMachine

{
        protected $insertedMoney;

        protected $totalMoney;

        protected $products = [
                'Dr. Pepper' => ['amount' => 0, 'price' => 125],
                'Pepsi'         => ['amount' => 1, 'price' => 125],
                'Mountain Dew' => ['amount' => 0, 'price' => 125],
        ];
        public function __construct($totalMoney = 0, $insertedMoney = 0)
        {
                $this->totalMoney = $totalMoney;
                $this->insertedMoney = $insertedMoney;
        }
        public function insertMoney($money)
        {
                $this->insertedMoney += $money;
        }
        public function insertedMoney()
        {
                return $this->insertedMoney;
        }

        public function refundMoney()
        {
                $refund = $this->insertedMoney;
                $this->insertedMoney = 0;

                return $refund;
        }

        public function products()
        {
                return $this->products;
        }

        public function numberOfRemaining($product)
        {
                return $this->products[$product]['amount'];
        }

        public function priceOf($product)
        {
                return $this->products[$product]['price'];
        }

        public function purchase($product)
        {
                $this->totalMoney = $this->insertedMoney;
                $this->insertedMoney = 0;
                print "[vending machine now has {$this->totalMoney} cents]\n";

                print "[vending machine spits out $product]\n";
        }
}

注意这里没有关于州的内容。您只提供方法来做非常具体的事情。如果您在这个类上调用 purchase 方法,它将购买一个产品,而不管这个自动售货机周围的条件如何。这为您的客户端提供了一个更简单、更干净的界面,因为您不再需要担心状态。这也意味着您可以在以后的任意时间向现有的类添加一个状态机。因此,如果这个VendingMachine类不再管理它的内部状态,那么在哪里做呢?目前,这段代码留在了example2.php中,但是它可以进一步封装在另一个(客户端)类中。

app/example2.php

$transitions = [
        [
                 'event' => 'insert',       // inserting money

                 'from'  => 'idle',         // changes idle state

                 'to'    => 'has money',    // to has money state

                 'start' => true,           // this is starting state

        ],
        [
                 'event' => 'insert',       // inserting more

                 'from'  => 'has money',    // money is okay

                 'to'    => 'has money',    // state does not change

        ],
        [
                 'event' => 'refund',       // allow idle to refund

                 'from'  => 'idle',         // transition prints msg

                 'to'    => 'idle',         // and state stays the

        ],
        [
                 'event' => 'refund',       // refunding when in

                 'from'  => 'has money',    // has money state

                 'to'    => 'idle',         // sets you back

        ],
        [
                 'event' => 'purchase',     // stops the fsm because

                 'from'  => 'has money',    // all items have been

                 'to'    => 'out of stock', // purchased and there is

                 'stop'  => true,           // no more idle state

        ],
        [
                 'event' => 'purchase',     // when  you  make  it to this

                 'from'  => 'has money',    // transition, you purchase item.

                 'to'    => 'idle',         // order matters, see true above?

        ],
];
$vendingMachine = new \App\Example2\VendingMachine;

$machine = new \StateMachine\FSM($transitions, $vendingMachine, '\App\Example2\Transitions');

这种方法的好处是,您可以通过这个数组轻松地管理事件转换。状态机只需要两样东西:转换数组和上下文。第三个参数是一个完全限定的名称空间,状态机可以在其中找到转换类。过渡类处理从一个事件到另一个事件的过渡。您需要为数组中的每个过渡定义一个过渡类。那么如何定义一个过渡类呢?现在我们来看一下数组中的第一个转换。

[
          'event' => 'insert',              // inserting money

          'from'  => 'idle',                // changes idle state

          'to'    => 'has money',           // to has money state

          'start' => true,                  // this is starting state

],

类名是根据该数组上的事件、from 和 to 属性自动生成的。因此,您需要创建的类名应该叫做InsertChangesIdleToHasMoney。读起来不错,对吧?这种命名约定可以改变。您可以访问状态机文档了解如何做到这一点。因此,让我们创建您的类。

app/example 2/insertchangesidletohasmoney . PHP

namespace App\Example2;

class InsertChangesIdleToHasMoney

{
        public function allow($vendingMachine)
        {
                // always allow the user to insert money

                // when sitting around in the idle state

                return true;
        }

        public function handle($vendingMachine, $money)
        {
                print "inserting {$money} coins\n";

                return $vendingMachine->insertMoney($money);
        }
}

每个过渡类需要两个方法。第一个方法让你知道你是否被允许处理这个方法。这给了你询问状态机是否转换的能力。

$machine->canInsert($money) // returns true because allow() returns

true

当您真正想要调用这个转换时,您调用事件名称。在这种情况下,事件名称为insert

$machine->insert($money) // invokes InsertChangesIdleToHasMoney::handle($vendingMachine,
$money);

在状态机调用了handle方法之后,状态机会自动从IdleState状态切换到HasMoney状态,因为您在$transitions数组中告诉它这样做。还定义了其他五个转换类。如果你想看得更详细,请查看源2。你可以在这里跳过这些。让我们再来看看example2.php的用法。

app/example2.php

$machine = new \StateMachine\FSM($transitions, $vendingMachine, '\App\Example2\Transitions');

print "machine state: [{$machine->state()}]\n";

$ourMoney = 300;

print "you have $ourMoney coins\n";

$ourMoney -= 125;

$machine->insert(125);

print "machine state: [{$machine->state()}]\n";

print "attempting to purchase Dr. Pepper\n";

// you can easily turn off exceptions

$machine->whiny = false;

if(! $machine->purchase('Dr.Pepper')) {
      print "asking machine for refund\n";

      $ourMoney += $machine->refund();
}

此代码产生以下输出:

machine state: [idle]
you have 300 coins
inserting 125 coins
machine state: [has money]
attempting to purchase Dr. Pepper
you are out of Dr. Pepper, sorry...
asking machine for refund

您可能已经注意到了状态机上的whiny属性。它打开和关闭异常。如果 whiny mode 为真,那么每当状态机被要求进行无效转换时,就会抛出一个异常。当状态机处于空闲状态时,没有购买事件处理程序。如果没有关闭 whiny 模式,状态机就会抛出一个异常。当 whiny 模式关闭时,它只为无效的过渡返回 false。接下来,你在抱怨模式下购物(推荐)。

app/example2.php

// put exception handling back on
$machine->whiny = true;

print "\nyou now have $ourMoney coins\n";

print "machine state: [{$machine->state()}]\n";

$ourMoney -= 100;

$machine->insert(100);

try {

    $machine->purchase('Pepsi');

} catch (\StateMachine\Exceptions\CannotTransitionForEvent $e) {
  ...
}
        print "---------------------------------------------\n";
        print "caught CannotTransitionForEvent exception\n";
        print "when whiny mode is active, you get exceptions\n";
        print "for invalid state transitions\n";
        print "---------------------------------------------\n";
}

if ($machine->canPurchase('Pepsi')) {
        $machine->purchase('Pepsi');
}

$ourMoney -= 25;

$machine->insert(25);

$machine->purchase('Pepsi');

产出 2

you now have 300 coins
machine state: [idle]
inserting 100 coins

not enough money for Pepsi. machine needs 25 more coins.
---------------------------------------------
caught CannotTransitionForEvent exception
when whiny mode is active, you get exceptions

for invalid state transitions
---------------------------------------------

not enough money for Pepsi. machine needs 25 more coins.
inserting 25 coins
[vending machine now has 125 cents]
[vending machine spits out Pepsi]

现在你已经看到了购买,让我们看看机器如何处理用完的产品。这将调用StateMachineIsStopped异常,因为在你的转换数组中有stop。一旦您的状态机停止,它就不再处理任何进一步的转换。

example2.php

print "\nyou now have $ourMoney coins\n";

print "machine state: [{$machine->state()}]\n";

print "inserting 25 coins\n";

try {

        $machine->insert(25);        // throws StateMachineIsStopped exception

                                     // probably should handle

                                     // though, since a user w

                                     // you should just spit the

                                     // out and message the you

                                     // you are out of stock br

} catch (\StateMachine\Exceptions\StateMachineIsStopped $e) {
        print "---------------------------------------------\n";
        print "Caught the StopMachineIsStopped exception...\n";
        print "This means that the insert you just tried failed...\n";
        print "---------------------------------------------\n";
}

产出 3

you now have 175 coins
machine state: [out of stock]
inserting 25 coins
---------------------------------------------
Caught the StopMachineIsStopped exception...

This means that the insert you just tried failed...
---------------------------------------------

您可能已经注意到,与上一个示例相比,您添加了更多的过渡。这只是为了说明在使用状态机时创建新的转换是多么简单。这个例子不像你在第一个例子中的更传统的状态模式类。尽管目的是一样的。请记住,本例中您的客户机分布在example2.php上。您可以轻松地添加如下所示的客户端类:

示例 2 客户端

class VendingMachineClient extends \StateMachine\FSM
{
        protected $transitions = [
                // transitions listed here ...

        ];

        public function __construct()
        {
                parent:: __construct($this->transitions, new VendingMachine, '\App\Example2\Transitions');
        }
}

这种状态机方法的问题在于它很神奇。在不知道有限状态机如何工作的情况下,你无法推断出VendingMachineClient上的公共方法。您知道您可以调用 transitions 数组中的事件。这是因为在FSM中有一个神奇的call方法让这一切发生。然而,对于任何新来者来说,这并不明显。第一次看到这个类的人可能会大吃一惊。这里有反射和神奇的方法调用。这并不意味着这是一个糟糕的设计;这只是意味着如果你喜欢一个不那么隐含的状态机,你可以用不同的方式来做。这就引出了下一个例子。

例 3

下一种方法利用特征。您将状态放回到VendingMachine类中。现在看起来是这样的:

app/example 3/vending machine . PHP

namespace App\Example3;

class VendingMachine extends \StateMachine\DefaultContext
{
        use \StateMachine\Stateful;

        protected $state = '\App\Example3\IdleState';

        protected $context = 'this';

        // the rest of this class looks the same as it did

        // in example 2 protected and is omitted to keep it short

您的自动售货机仍然以与示例 2 相同的方式处理购买和产品。这里的区别在于您使用了一个名为Stateful的特性,它允许您自动地调用状态的底层方法。它没有前面的例子那么神奇,因为您可以查看每个 state 类中可用的特征和方法。让我们看看IdleStateHasMoneyState类。

app/Example3/IdleState.php

namespace App\Example3;

class IdleState implements State
{
        public function __construct(VendingMachine $machine)
        {
                $this->machine = $machine;
        }

        public function insert($money)
        {
                $this->machine->insertMoney($money);
                $this->machine->setState('\App\Example3\HasMoneyState');
        }

        public function refund()
        {
                print "no refund available in idle state\n";
        }

        public function purchase($product)
        {
                print "you'll need to enter money to purchase $product\n";
        }

接下来是HasMoney状态。

app/Example3/HasMoneyState.php

class HasMoneyState implements State
{
        public function __construct($machine)
        {
                $this->machine = $machine;
        }

        public function insert($money)
        {
                if ($money < 0) throw new \Exception('You cannot insert negative money');

                $this->machine->insertMoney($money);
        }

        public function refund()
        {
                $this->machine->setState('\App\Example\IdleState');

                return $this->machine->refundMoney();
        }

        public function purchase($product)
        {
                if (! $this->machine->canPurchase($product)) {
                          return;
        }

                $this->machine->setState('Example3\IdleState');
                $this->machine->makePurchase($product);
        }

您可能会注意到这看起来更像传统的状态模式。这种方法让你的自动售货机设计更加清晰。在我看来它是最干净的。我不太喜欢特质,因为它们经常被滥用。我认为在这种情况下是可行的。不过,每个人都有自己的偏好。我为状态模式提供了三种不同的方法。选择你的毒药。微笑。

结论

在本章开始时,我提到这是一个手套图案。除非合身,否则不要用。状态模式的一个缺点是它增加了要维护的类的数量。如果您的设计中只有几个状态和事件,那么这可能不值得。在客户端类中包含几个条件语句可能比拥有多个状态类更容易。

四人帮的书提到,如果你的状态不包含内部变量,你就不需要一遍又一遍地构造状态类。您可以重用相同的状态类。有些人会将这种方法视为 flyweight 模式,因为您可以重用状态对象。重用状态对象可能会更快,因为您不必一次又一次地重新构建它们。这并不意味着它是轻量级的。flyweight 是关于减少内存占用的。你可能没有几百万个不同的状态对象。重用几个状态类并不意味着更少的内存(正如你在 flyweight 一章中看到的)。不过,我能反驳谁呢?

还有另一个相关的模式,看起来与 state 模式相同。这就是所谓的策略模式。两种模式都使用合成来修改内部行为。主要区别在于策略是针对算法的,而不是内部状态。您可能不会对自动售货机使用策略模式,因为已知自动售货机在现实生活中处于不同的状态。尽管状态和策略模式之间的代码结构看起来基本相同,但意图不同。我还没有谈到策略模式,但那是下一章。我们开始吧!

Footnotes 1

设计模式:可重用面向对象软件的元素,第 338 页

2

https://github.com/kdocki/larasign/tree/state/app/Example2/Transitions

二十四、策略

$> git checkout strategy

目的

定义一系列算法,封装每一个算法,并使它们可以互换。策略让算法独立于使用它的客户端而变化。 1

应用

当您的算法共享相同的公共接口,但在幕后以不同的方式工作时,策略模式是有用的。策略模式通常依靠组合来传递不同的算法。Laravel 加密 2 组件使用相同的方法加密和解密消息。不过,基本的密码算法是可以改变的。这是策略模式的松散实现:

$crypt = Illuminate\Encryption\Encrypter('secret key', $cipher)
$encrypted = $crypt->encrypt('secret');
$decrypted = $crypt->decrypt($encrypted);

注意Crypt如何保持了encryptdecrypt相同的接口方法,然而当你改变密码时,底层的行为也改变了。这个例子体现了策略模式的概念。缺少的部分和我说的松散实现的原因是在这个例子中$cipher是一个string,而不是一个class

抽象结构

  • Context是保存strategy对象的类。这是客户将与之交互的内容。当method()被调用时,它会调用$strategy->algorithm()。因为这个类使用了复合,你可以通过改变$strategy对象轻松地替换掉策略算法。见图 24-1 。

    A435115_1_En_24_Fig1_HTML.jpg

    图 24-1。

    Abstract structure

  • Strategy是一个抽象类或接口。它定义了所有算法的公共接口方法。

  • ConcreteStrategy1/2是一个策略的不同算法实现。

例子

在本例中,您将制作一只鸡。不是美味的烤鸡。会发出声音的鸡。鸡会发出不同的声音。公鸡打鸣。母鸡咯咯叫。一只小鸡唧唧喳喳。图 4-2 显示了鸡发出的不同声音。

A435115_1_En_24_Fig2_HTML.jpg

图 24-2。

Chicken noises

让我们看看这个Chicken类会是什么样子,如果你为每种类型的鸡使用一个巨大的 switch 语句。

app/ChickenBeforePattern.php

namespace App;

class Chicken

{
        public function construct($noisetype)
        {
                $this->noisetype = $noisetype;
        }

        public function speaks()
        {
                switch ($this->noisetype) {
                        case 'hen':     return 'cluck, cluck';
                        case 'chick':   return 'chirp, chirp';
                        case 'rubber':  return 'squeek!';
                        case 'muted':   return '';
                        case 'rooster': return 'cock-a-doodle-doo!';
                }

                return '';
        }

        public function scratch()
        {
                print 'scratches some dirt' . PHP_EOL;
        }

每只鸡说话的方式都不一样。这个简单的算法是为给定类型的鸡制造一个噪音。类似于 Laravel Encrypter类,您的噪声生成算法输出一个字符串。算法在产生的噪声方面有所不同,但是您的Chicken(上下文)类保持不变。注意所有的鸡都会抓痒。您将使用策略模式来删除在speaks方法中找到的 switch 语句。

示例结构

图 24-3 为结构示意图。

A435115_1_En_24_Fig3_HTML.jpg

图 24-3。

Example structure

履行

你要做的第一件事是改变你的Chicken类来接受一个噪声制造者类。

app/Chicken.php

namespace App;

class Chicken

{
        public function __construct(Noises\Noise $noise)
        {
                $this->noise = $noise;
        }

        public function speaks()

        {
                print $this->noise->make();
        }

        public function scratch()
        {
                print 'scratches some dirt' . PHP_EOL;
        }
}

现在,当鸡说话时,它依靠噪音策略发出声音。任何时候你需要创建一个新的噪音,就像添加一个新的类一样简单。你不再需要重新打开Chicken类。关门了。这就是我在书的开头讲的开/闭立体原理。

这将我们带回编程中的一个重要概念,一个你可能已经非常熟悉的概念。不要囊括所有的东西。您只封装应用中可能会发生变化的内容。假设您永远不会添加另一种类型的鸡噪声。在这种情况下,您之前看到的switch语句可能是更好的方法。不要努力去解决不存在的问题。

那么你怎么知道你是否应该抽象一些算法呢?这是一个提示。你有没有发现自己重新打开同一个文件来一遍又一遍地修改某些东西?该文件中的类很可能做得太多了。把文件分成几份。使用合成将文件分割成不同文件。如果你在这些文件中有一个算法,就使用策略模式。

如您所见,策略模式使用组合来拆分算法。然而,仅仅因为你在某个地方使用了组合并不意味着你在使用策略模式。很多模式使用构图。策略的目的是制造可互换的算法。在这个例子中,你的算法是关于如何制造噪音的。诚然,它并不复杂,但它仍然是一个算法。

app/Noises/BabyChickNoise.php

namespace App\Noises;

class BabyChickNoise implements Noise
{
        public function make()
        {
                return "chirp, chrip\n";
        }
}

想看看另一个噪声实现吗?这里有一个Hen噪音。

app/Noises/HenNoise.php

namespace App\Noises;

class HenNoise implements Noise
{
        public function make()
        {
                return "cluck, cluck, BA-cawk!\n";
        }
}

如果你想看其他的,请查看资源库 3 中的噪音。现在让我们看看如何使用这个类。

app/simulator.php

$chicken = new \App\Chicken(new \App\Noises\BabyChickNoise);
$chicken->speaks();                // chirp, chirp

$chicken = new Chicken(new \App\Noises\HenNoise);
$chicken->speaks();                // cluck, cluck

$chicken = new Chicken(new \App\Noises\RoosterNoise);
$chicken->speaks();             // cock-a-doodle-doo!!!

$chicken = new Chicken(new \App\Noises\RubberChickenNoise);
$chicken->speaks();             // squeeek!

$chicken = new Chicken(new \App\Noises\Muted);
$chicken->speaks();             //

$chicken->scratch();            // scratches some  dirt

之前你的鸡类有一个$noisetype字符串和switch语句。现在,通过一个Noise算法,你有了更多的控制权。这也允许你在以后添加新的算法。

结论

您已经看到了策略模式如何将您从条件语句中解放出来。条件中的每条语句都表达了自己的行为。这通常会使代码更容易理解。策略模式允许您做的另一件事是选择您想要使用的算法。一种算法在 80%的情况下可能有更好的性能。你有 80%的机会可以选择它。另外 20%的时间你可能会选择另一个不常用的算法。

这种策略模式有几个缺点。客户必须知道不同的策略对象。这种额外的复杂性是最小的,并且可以通过服务容器自动解析依赖性来消除。这个缺点不应该阻止您使用 Laravel 中的策略模式。

下一个缺点是增加了应用中的类的数量。不过,这真的是个问题吗?越多越好,对吧?之前我们讨论了如何保留switch声明。当算法足够简单时,有时这是一个不错的选择。添加更多的类会增加复杂性。复杂性在于理解如何设计多个类一起工作。然而,选择不使用策略模式的后果是一个巨大的单块类,它违反了开放/封闭原则。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 349 页

2

https://laravel.com/docs/master/encryption

3

https://github.com/kdocki/larasign/tree/strategy/app/Noises

二十五、模板方法

$> git checkout template_method

目的

在操作中定义算法的框架,将一些步骤推迟到子类。模板方法允许子类在不改变算法结构的情况下重新定义算法的某些步骤。 1

应用

当您有一个需要内部进一步指令才能正确运行的算法时,模板方法非常有用。当算法有步骤时,它非常适合,就像烹饪食谱一样。《头脑优先设计模式》一书举了一个制作不同含咖啡因饮料的例子。泡一杯茶很像泡一杯咖啡 2 。一些步骤有所不同,但在大多数情况下,您可以重用许多步骤。当同一算法的不同版本共享许多步骤时,模板模式很有帮助。算法的变化步骤在子类中定义。

抽象结构

  • AbstractAlgorithm包含算法变体之间的所有共享片段。可以重用的基本方法放在这里。见图 25-1 。

    A435115_1_En_25_Fig1_HTML.jpg

    图 25-1。

    Abstract structure

  • ConcreteAlgorithm 1/2包含对抽象算法缺失步骤的覆盖。算法的变化在这里。

例子

在此示例中,您将遵循一般编写器创建文档的步骤。这些通用的步骤为任何作者提供了一个配方(或算法)。

  • 打个草稿。

  • 在文档未通过审查流程后对其进行修订。

有不同类型的作家。你是哪种类型的作家取决于你写的文件类型。在本例中,您将创建软件作者和杂志作者。

示例结构

图 25-2 为结构示意图。

A435115_1_En_25_Fig2_HTML.jpg

图 25-2。

Example structure

履行

你从你的基础抽象算法Writer开始。所有作家都会写作。作家写作时通常遵循相同的食谱。一个作家会创作一个草稿,并不断修改草稿,直到它足够好。

app/Writer.php

namespace App;

abstract class Writer

{
        abstract protected function draft();
        abstract protected function failsReview($document);
        abstract protected function revise($document);

        public function write()
        {
                $document = $this->draft();

                while ($this->failsReview($document)) {
                        $document = $this->revise($document);
                }

                return $document;
        }

注意有三个抽象方法。要求所有具体算法都实现这三个抽象方法。根据作者的类型,作者可能会以不同的方式审阅他们的文档。软件作者将使用单元测试来审查。一个杂志作者会使用一个评论团队。

app/SoftwareWriter.php

namespace App;

class SoftwareWriter extends Writer
{
        public $testedCount = 0;

        protected function draft()
        {
                print "drafting software program\n";
                return "software";
        }
        protected function failsReview($document)
        {
                print "do unit tests pass for {$document}?\n";
                return $this->testedCount++ < 3;
        }

        protected function revise($document)
        {
                print "correcting mistakes for {$document} (revision #{$this->tesedCount})\n";

                return $document;
        }

这是一位杂志作家。同样,这种不同类型的作者可以有完全不同的起草,审查和修改步骤。

app/MagazineWriter.php

namespace App;

class MagazineWriter extends Writer
{
        protected function draft()
        {
                $document = "magazine";
                print "drafting {$document} document\n";
                return $document;
        }

        protected function failsReview($document)
        {
                print "reviewing {$document} document\n";
                return false;
        }

        protected function revise($document)
        {
                print "revising {$document} document\n";
                return $document;
        }

运行模拟器会产生以下输出。

app/simulator.php

$writer = new \App\MagazineWriter;
$writer->write();

// drafting magazine document

// reviewing magazine document

$writer = new \App\SoftwareWriter;
$writer->write();

// drafting software program

// do unit tests pass for software?

// correcting mistakes for software (revision #1)

// do unit tests pass for software?

// correcting mistakes for software (revision #2)

// do unit tests pass for software?

// correcting mistakes for software (revision #3)

// do unit tests pass for software?

结论

模板方法的一个主要缺点是随着时间的推移变得复杂。实际上,随着您添加更多具体的算法,该模式变得更加难以维护。如果您在上面添加了另一个版权发布步骤会怎么样?

public function publish()
{
        $document = $this->draft();

        while ($this->failsReview($document)) {
                $document = $this->revise($document);
        }

        $this->copyright($document);
        return $document;
}

为软件程序或杂志申请版权可能是有意义的。然而,如果你有另一个叫做HighschoolEssayWriter?的具体算法呢?一个高中生不需要为他的论文文档申请版权。不过,这个具体的算法仍然需要覆盖abstract copyright方法。这是拥有一个通用模板来管理你的算法的一个缺点。在你的子算法类中进行非常特殊的定制会很麻烦。添加copyright方法会影响每个子类。你必须改变从abstract Writer类继承的每一个类。这意味着你的抽象算法会变得混乱。这可能是一个棘手的问题,许多人认为这是一个交易破坏者。

有些人抱怨测试基础抽象类。这可以通过创建一个继承抽象类的模拟子类来解决。

请阅读 http://tech.puredanger.com/2007/07/03/pattern-hate template/ 的博客文章,了解模板模式的更多缺点。这个人给出了一些不使用模板方法模式的很好的理由。

  • 它没有很好地传达意图。

  • 很难组合功能。

  • 理解程序流程很难。

  • 很难维持。

当你看一看模板方法模式的结构时,它使用继承而不是组合。我已经谈到了写作如何帮助你避免违反坚实的原则。长话短说,使用这种模式要非常小心。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 360 页

2

头先设计图案,第章第 8 ,第 276 页

二十六、访问者

$> git checkout visitor

目的

表示要在对象结构的元素上执行的操作。Visitor 允许您定义一个新的操作,而不改变它所操作的元素的类。 1

应用

这可能是我见过的最复杂的 GoF 模式之一。第一次盯着 UML 图看了一会儿我说:“嗯, 2 ?”在其基本形式上,这种模式完全是关于从半相关或不相关的类中提取方法。当你这样做的时候,你不再需要改变或者重新访问(双关语)代码。在阅读了更多关于这种模式的内容后,我发现一些喜欢花言巧语的人喜欢称之为双重调度 3

什么是双重派遣?基本上,当你调用一个方法时,那个方法会为你调用另一个方法。因此该方法进行了双倍的调用,因此得名。像 C++或 Java 这样的语言允许你重载方法 4 。在 PHP 中不需要方法重载。在 PHP 中,您需要在运行时使用反射进行双重调度。但是,您稍后会发现,访问者模式不需要双重分派。我之所以在这里列出它,是因为其他语言经常使用带有方法重载的双重分派来实现访问者模式。

双重派遣在行动

$dispatch->method(new Car);  // calls $dispatch->carMethod($car)

$dispatch->method(new Dog); // calls $dispatch->dogMethod($dog)

访问者模式的一个类比是一个修理工访问一所房子。房子的主人可以选择接受或拒绝修理工。如果业主要求管道工,但电工出现了,他可以把电工赶走。假设修理工被主人接受了,他就能做好他的工作。房主不需要知道任何关于管道的细节。除了几个问题和账单之外,房主不再参与此事。修理工和房主的责任是分开的。与访客模式相反的是 DIY(自己动手)房主,他们可以修理自己的管道。问题是,没有其他人的帮助,一个自己动手的房主能做的事情就这么多了。

这个“什么都自己做”的问题也存在于软件中。你不想要一个什么都做的巨型类。然而,有时现实生活中的模型可以做很多事情。此外,现实生活中的模型将增加更多的功能和发展。通过使用 visitor 模式,您可以在以后添加到您的模型化类中,而不必一遍又一遍地重新打开该类,也不会违反打开/关闭原则。

抽象结构

图 26-1 为结构示意图。

A435115_1_En_26_Fig1_HTML.jpg

图 26-1。

Abstract structure

例子

女人和鸡有什么共同点?你可以把你的笑话发到@kdocki 5 上,这样我就可以告诉我的妻子了。在这个例子中,女人和鸡都可以被戳。您可以将poke方法放在每个类中。

class Chicken {
   public function poke() {   ...   }
   // ... other methods related to Chicken

}

class Woman

{
   public function poke() {   ...   }
   // ... other methods related to Wife

}

这有时行得通。但是,如果您现在想添加更多的方法,该怎么办呢?怎么样tickle, kisschase?最终你不断地在你的类中增加操作,给一个巨大的整体类增加越来越多的责任。还有,问问自己,女人真的需要知道怎么戳自己吗?那不是应该放在别处的责任吗?在这个例子中,您学习了如何使用访问者模式向ChickenWoman模型添加新的操作。

示例结构

图 26-2 为结构示意图。

A435115_1_En_26_Fig2_HTML.jpg

图 26-2。

Example structure

履行

如图 26-2 所示,你将操作抽象成Visitor类。任何可以运行这些Visitor操作的类都应该实现Visitable。一个Visitable可以选择接受或拒绝一个新的访客。

app/visit table . PHP

namespace App;

interface Visitable

{
        public function accept(Visitor $visitor);
}

本例中的Visitables是一个Woman和一个Chicken

app/Woman.php

namespace App;

class Woman implements Visitable
{
        public function __construct($name)
        {
                $this->name = $name;
        }

        public function accept(Visitor $visitor)
        {
                return $visitor->visitWoman($this);
        }
}

别忘了家禽!

app/Chicken.php

namespace App;

class Chicken implements Visitable
{
        public function construct($type)
        {
                $this->type = $type;
        }

        public function accept(Visitor $visitor)
        {
                return $visitor->visitChicken($this);
        }
}

注意,这两个类都调用了 visitor 上的一个方法。我见过一些使用通用的visit方法而不是visitWoman的例子。这些例子里有一个巨大的switch语句和is_a6里面的visit方法。双重调度将visitable类型中继到更具体的方法。我选择跳过这一步,因为没有必要。你已经知道了visitable的类型,可以直接调用。PHP 中没有方法重载,但是可以简单地命名每个方法visit<VisitableType>来绕过这个限制。

接下来看Visitor

app/Visitor.php

namespace App;

interface Visitor
{
        function visitWoman(Woman $woman);
        function visitChicken(Chicken $chicken);
}

每个Visitor都必须知道如何拜访一个WomanChickenpoke访客戳一个WomanChicken(图 26-3 )。

A435115_1_En_26_Fig3_HTML.jpg

图 26-3。

Poking a chicken

app/PokeVisitor.php

namespace App;

class PokeVisitor implements Visitor
{
        public function visitWoman(Woman $woman)
        {
                print "the woman named {$woman->name} was poked\n";
        }

        public function visitChicken(Chicken $chicken)
        {
                print "the {$chicken->type} chicken was poked\n";
        }
}

后来你决定要添加一个tickle操作。这就像创建一个新的访问者一样简单。

app/TickleVisitor.php

namespace App;

class TickleVisitor implements Visitor
{
        public function visitWoman(Woman $woman)
        {
                print "the woman named {$woman->name} was tickled\n";
        }

        public function visitChicken(Chicken $chicken)
        {
                print "the {$chicken->type} chicken was tickled\n";
        }
}

您可以用新的访问者类添加越来越多的操作。我将把chasekiss访问者操作留给您来实现。现在让我们看看你将如何使用你的访问者和可访问者。

app/simulator.php

$woman = new \App\Woman("Sally");
$woman->accept(new \App\PokeVisitor);
$woman->accept(new \App\TickleVisitor);

$chicken = new \App\Chicken('Dominecker');
$chicken->accept(new PokeVisitor);
$chicken->accept(new TickleVisitor);

运行此模拟输出

the woman named Sally was poked
the woman named Sally was tickled
the Dominecker chicken was poked
the Dominecker chicken was tickled

最后要指出的是。因为PokeVisitor是类,不是方法,所以可以保存属性。这些属性可用于保存有状态信息。这意味着PokeVisitor比仅仅放在Woman类中的poke方法具有更大的灵活性。

结论

访问者模式的主要缺点是,每次你添加一个新的Visitable类型时,你必须在每个可用的Visitor类中创建一个新的visit<NewType>操作。这可以通过使用Visitor的抽象基类而不是接口来解决。基础抽象类可以实现该方法的默认值,直到一个visitor类需要覆盖该方法。我也看到过一些例子也用这样的方式来解决这个问题。不建议这样做,但我会告诉你如何做:

public function visit(Visitable $visitable)
{
        $className = get_class($visitable);
        $methodName = "visit{$className}";

        if (method_exists($this, "visit{$className}")) {
                return call_user_func_array([$this, "visit{$className}"] [$visitable]);
        }
}

不推荐上述方法的原因是,当你并不真正需要它时,它会增加复杂性。因为它使用反射,所以排除故障也更加困难。使用反射的解决方案会使调试堆栈跟踪更具挑战性。

抛开缺点不谈,访问者模式使得添加新操作变得轻而易举。这是假设你没有添加任何新的Visitable类。然而,如果您频繁地添加新的Visitable类型,那么维护这种模式的成本会很高,并且会给您带来很多麻烦。

访问者模式的另一种选择是使用特征(混合)。将功能混合到现有的类中通常更容易。然而,特征仍然有缺点,会给类增加越来越多的功能。然而,对于一些开发人员来说,这种方法比实现访问者模式更容易混淆。拥有一个名为accept的通用双重分派方法,并向其传递一个 visitor 类,可能会让项目的新手望而生畏。这是因为accept方法可以根据它接收的访问者类型做许多不同的事情。accept的广泛性既是其最大的弱点,也是其最大的优势。

Footnotes 1

设计模式:可重用面向对象软件的元素,第 366 页

2

www.destroyallsoftware.com/talks/wat

3

http://en.wikipedia.org/wiki/Double_dispatch

4

http://en.wikipedia.org/wiki/Function_overloading

5

https://twitter.com/kdocki

6

http://php.net/manual/en/function.is-a.php

二十七、更多资源

学习模式是一场无休止的战斗。在研究过程中,我利用了几种不同的资源来帮助我。我想与你们分享这些资源。

posted @ 2024-08-03 11:26  绝不原创的飞龙  阅读(1)  评论(0编辑  收藏  举报