十八、解释器
$> 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 。
图 18-1。
Abstract structure
-
GlobalContext
是一个保存所有表达式可用的全局数据的类。你可以在这个类中分配和查找变量。如果不需要共享数据,那么可以忽略这个类。 -
AbstractExpression
是所有终端和非终端表达式使用的基类/接口。它包含了由所有子类专门定义的interpret
方法。 -
TerminalExpression
实现interpret
方法,不依赖任何其他表达式。这些表达式规则只有“字符串”,不依赖于其他表达式。 -
NonterminalExpression
实现interpret
方法并依赖其他表达式。如果一个非终结表达式依赖于自身或者依赖于另一个循环的表达式,那么它很容易递归。你必须努力防止你的语法中出现非终结符,这些非终结符会陷入无限递归循环,永远不会结束。一个无限循环、永远不会终止和提供用户反馈的应用是没有用的。
为什么我们要区分非终结符和终结符?如果您检查非终结符和终结符表达式类的代码,它们看起来非常相似。两者都继承自基本表达式接口,所以在代码方面没有真正的区别。唯一真正的区别是,非终结符表达式有一个额外的属性来存储其他表达式。可能值得在 php-doc 注释中为每个表达式类声明非终结符或终结符。这让你可以快速浏览注释,甚至是 grep 出所有非终结符表达式。非终结表达式比没有进一步执行的终结表达式更难排除故障。
例子
我在互联网上找到了一些这种模式的例子。
-
罗马数字翻译器
-
反向波兰计算器
-
创建您自己的 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
表达式是终结的还是非终结的?它由几个字符串和表达式组成。这意味着它是非终结性的。你看到哪些表情了?unit
和measurement
表达式可能是gauge
表达式的一部分。请注意,direction
表达式是终结性的,因为它没有链接到其他表达式。
那么为什么不使用正则表达式或字符串替换呢?举个小例子,这可能更容易。然而,随着复杂性的增加,字符串匹配可能会令人不安。用一个类来处理每种类型的表达式可以更容易地理解发生了什么。每个表情都有自己的工作要做。完成整个翻译可能需要几个表达式,但是你已经将每个表达式分解成(希望)可维护的部分。
示例结构
图 18-2 为结构示意图。
图 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
硬编码到客户端代码中。你只需要用一个字符串和上下文对象调用解析器的解释器。那么这个解析器是如何工作的呢?每个解析器都可以不同。每个解析器可能有很大的不同。我遵循的基本思路是这样的。
-
将句子分成一组记号。
-
将光标移动到第一个标记。
-
用语法规则匹配/识别单词。
-
继续将光标向前移动到下一个标记。
-
继续下去,直到你处理完所有的代币。
那么什么是代币呢?在您的例子中,您将把句子分成一个单词数组。然而,令牌也可以是单个字符。但是,在解析器中不需要这样的粒度。
什么是光标?游标是一个整数,它指向令牌数组的当前索引。这个指针帮助你跟上你已经处理的令牌。对于某些解析器来说,光标允许您向前甚至向后移动。一旦识别出标记,就向前移动光标。当光标到达 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 。图 19-1。
Abstract structure
-
迭代器是抽象 PHP 内部接口 4 ,包含遍历列表所需的方法。
-
current()
返回当前活动元素。 -
key()
返回当前活动元素的索引。 -
next()
向前移动到下一个元素。 -
将迭代器移动到第一个元素。
-
检查你是否在迭代器的末尾。
-
-
Traversable
是一个抽象的空接口,被 PHP 5 特殊处理。它的目的是允许您在一个foreach
循环中灵活地使用迭代器。在本章的后面你会看到更多的内容。默认情况下,Iterator
接口继承自这个抽象接口,所以你不必太担心。值得一提的是,这样你就能理解 PHP 迭代器是如何工作的。现在,只需知道如果没有这个接口,您将无法方便地在foreach
中使用迭代器。 -
ConcreteAggregate
保存了getIterator
方法的实际实现。它还包含对数组、列表或项目集合的引用。它需要将其项目传递给生成的Iterator
对象的构造函数。 -
ConcreteIterator
是Iterator
接口的实际实现。还有一些内置的 PHP 具体迭代器 6 。你将在你的例子中使用ArrayIterator
7。
例子
在本例中,您将创建一个电影列表。您将使用不同的迭代器迭代这个电影集合。在这个场景中,迭代器由聚合对象Movies
创建。在您进一步理解迭代器模式之后,您将通过查看 Laravel 如何以自己简洁的方式将迭代器模式用于雄辩的集合来结束这一章。
示例结构
Note
图 19-2 中列出了两个具体的迭代器。我只是想向你展示在 PHP 中创建一个向后遍历数组的迭代器是多么容易。
图 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, key
、next, rewind, valid
;然而,更容易的是反转数组,然后搭载掉ArrayIterator
。ArrayIterator
再一次来救你。
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'));
现在是关键时刻了。你想用三种不同的方式来循环播放这些电影。第一种方式是正常方式,使用getIterator
和foreach
。这将使用ArrayIterator
,并吐出所有添加到你的movies
聚集对象的电影。
app/simulator.php
print 'MOVIE LISTING' . PHP_EOL;
foreach ($movies as $movie) {
print ' - ' . $movie->title() . PHP_EOL;
}
对于那些有鹰眼的人来说,你可能想知道打给getIterator
的电话是从哪里打来的。我要对你们中的那些人说,恭喜你们,你们得到了一张贴纸 8 !如果你没有得到一个贴纸,那么也许你已经知道这个窍门了?这有点 PHP 的魔力。前面我提到了特殊的Traversable
接口。该接口仅用于IteratorAggregate
或Iterator
。在你的例子中,Movies
从IteratorAggregate
开始延伸,所以它也在延伸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 使用集合。数据库检索工作流是这样的:
-
使用雄辩的查询生成器(也称为 Fluent)构建数据库查询。
-
执行查询。对于选择,检索表行。
-
每个表行中的字段被合并到模型中。
-
每一个模型都被添加到一个雄辩的集合。
-
集合被返回。
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
11 和AppendIterator
12。我鼓励你去调查这些。到目前为止,您已经介绍了足够多的迭代器。我累了,朋友。
结论
在每一章的结尾,我都试图列出每种模式的缺点。迭代器模式的一个缺点是,为了创建您自己的自定义迭代器,您必须定义五个方法。这可能看起来势不可挡。为什么需要有一个hasNext()
方法?key()
应该返回什么?如果您最终创建了一些自定义迭代器,而不是依赖于原生 PHP SPL 迭代器,这些是您必须回答的问题。
迭代器模式的好处是隐藏了如何遍历对象的细节。这就是意图。你不再被迫对整数索引使用for
循环。您可以循环复杂的列表和树。同样,就像您在Movies
类中对评级过滤器所做的那样,您可以轻松地创建返回特殊过滤迭代器的方法。这减轻了客户端的工作负担。
你学到了很多关于 PHP 中迭代器的知识。尽可能多地使用现成的 SPL 产品。一个缺点是缺少文档。然而,这不应该阻止您在处理对象集合时使用迭代器模式。在 Laravel 中,您会经常使用集合,Taylor 已经做了大量工作来处理集合上的迭代和类似数组的访问。好好逛逛Collection
班;它有一些简洁的方法,也没有文档记录,但是非常有用!
Footnotes 1
设计模式:可重用面向对象软件的元素,第 289 页
2
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 。图 20-1。
Abstract structure
-
Mediator
包含具体同事使用的方法。这些方法可以通知其他同事,或者做他们需要的任何事情。 -
AbstractColleague
是一个接口或抽象类。这个抽象类可以保存所有同事对象使用的方法,也可以保存对中介对象的引用。 -
Colleague1
和Colleague2
是AbstractColleague
的实现。每个同事都不知道对方的存在。如果这些同事之间直接交流,这就违背了中介模式的目的。
例子
在本例中,您将构建一个电子商务结账系统的开端。如果你曾经建立或管理过电子商务,你就会知道这有多痛苦。钱总是让事情变得复杂。在这个平台上,你将依靠一个价格调节中介。调整器保存用于计算价格调整的业务逻辑。你有产品、顾客利益和优惠券。价格调整器根据您添加到系统中的客户优惠和优惠券重新计算产品价格。因此,当您添加块状奶酪的优惠券时,您应该会自动看到块状奶酪产品的价格变化,这都要感谢处理价格调整的中介。就像一般的工作流程一样,这就是你想要的:
-
客户增加了一些产品。
-
产品价格会随着客户添加(或删除)优惠券和优惠而自动变化。
我将在这里定义两个抽象的概念:价格调整和价格调整者。价格调节员(调解员)在计算价格时会考虑所有的价格调整(同事)。
作为一个警告,我并不推荐电子商务使用中介模式,但这似乎是展示这种模式的一个很好的例子。定价的业务逻辑可能很难处理,尤其是当跨越许多许多文件时。许多因素都会影响定价,比如你订购了多少产品、优惠券、你住在哪个国家、你是否是一名退伍军人或超过 65 岁或者你是否喜欢一个好苹果。好吧,最后一个可能永远不会被使用,但我确实喜欢一个好苹果。
在本例中,您还有一个任意的业务规则,即如果使用三张或更多优惠券,客户将失去利益。为什么是三个或更多?我不知道。去问客户。这很重要,你知道吗?
示例结构
图 20-2 为结构示意图。
图 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 岁了。比较oldPrice
和newPrice
的条件很重要。除了在产品价格没有变化的情况下不需要更新价格之外,这种情况还可以防止你一遍又一遍地递归调用自己。请记住,当您修改产品价格时,它会调用价格调整器。这将不断调用自己,你会得到一个恼人的最大递归深度超过堆栈跟踪。继续,您仍然需要实现另外两个方法来添加和删除调整。
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
类,就像用控制台命令创建用户一样。这种模式的缺点是,现在需要检查两个地方来创建用户:UserController
和UserCreator
。它使您的代码稍微复杂一点,但是提供了分离传输层和业务逻辑层的灵活性。
我可以写一整章关于六边形图案的内容,但我在这里只是简单地介绍了一下,主要是为了说明一个观点。关键是你的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()
的公共方法来解决这个问题。此时,您正在为保存私有值而创建方法。这暴露了类的内部工作方式,打破了封装。
为什么这会破坏封装?想象你有一个盒子。当你按下盒子上的按钮时,它就会吐出一块巧克力蛋糕。那就是封装。现在想象一下,蛋糕盒上有一百个按钮。你可以做不同大小和不同口味的蛋糕。一台有上百个按钮的机器听起来很复杂。创造一个是痛苦的。维持它是一种痛苦。用起来也是一种痛苦。我的意思是,如果我只是想要巧克力蛋糕,我不应该弄乱 99 个按钮。在软件设计中,你努力创造一百个不同的只有一个按钮的蛋糕机,而不是一个有一百个按钮的蛋糕机。
你不是在造蛋糕机。你在建造物品。不过,同样的原则也适用。当你设计你的对象时,你应该努力使它们尽可能容易使用。为了做到这一点,你的班级应该承担尽可能少的责任。您为您的类创建的公共方法越多,您添加的责任就越多。这就是为什么您使用 memento 模式而不是公开私有变量。
避免暴露私有变量并保存它们以备后用的一个好方法是使用 memento 模式。所以让我们来学习如何。
抽象结构
-
Originator
是包含您想要保存的public, protected,
和private
变量的类。当您在Originator
上调用snapshot
方法时,它将创建一个新的Memento
对象。restore
方法允许你在这个类中设置private
和protected
变量。见图 21-1 。图 21-1。
Abstract structure
-
Caretaker
处理快照。Originator
不需要额外负责保存自己的快照。由管理员决定以后如何恢复快照。这可以通过一种undo
方法来完成。这可以通过从$mementos
数组中选取一个数字索引来完成。可能完全是随机的。由管理员决定如何将备忘录放回Originator
。 -
Memento
是普通的老式 PHP 对象(POPO ),它存储了从Originator
中保存的变量。请不要更改这些变量,这一点很重要。这在某种程度上违反了纪念模式。快照应该被视为不可变的。一旦你开始篡改一个Memento
对象,你就在Memento
和Originator
之间创建了一个紧密耦合。到那时,它就不再是备忘录了。它变成了完全不同的东西。
这个模式没有抽象接口。很奇怪吧。Singleton 和 facade 是唯一没有抽象接口的模式。实际上,我将在下面的例子中使用一个接口,因为我发现接口很好使用。我提到这一点是为了说明一点。你不必总是为了使用一个模式而使用接口。不管您是否使用接口,您可能仍然在使用设计模式。我知道在这一点上我可能听起来像一张破唱片,但是我要再说一遍:**真正决定设计模式的是意图,而不是代码结构。
例子
你的目标是给你雄辩的模特拍快照。快照应该包含将模型恢复到原始状态所需的所有信息。这意味着,如果您更改模型的属性、表名和其他属性,您总是可以恢复到以前的快照。
在这个例子中,你会学到更多关于口才的内部知识。您也将稍微偏离 UML,并利用特征来实现您的 memento 模式。这些特征不是必需的,但是我发现在这种情况下,它给整个代码增加了一点语法上的好处。
利用你的快照特质,你可以给你雄辩的模特拍快照。这样做有很多好处。一个是你可以在任何时候创建一个模型的快照,然后将它恢复到某个原始状态。也许您正在更改一个人对象的电子邮件地址,并将它保存在数据库中。如果事情出错了,也许你想把人对象恢复到原来的样子。更新一个人的信息后,您需要更新一些远程服务上的信息,如 Stripe、CRM 或搜索索引。您将创建自己的快照。如果更新远程服务出现问题,您将从快照中恢复您的个人。
但是数据库事务或审计历史表不是更合适吗?是的,这是真的。事实上,数据库事务是 memento 模式的一个真实例子。此外,没有什么可以阻止您将审计历史作为 memento 模式的一部分来实现。在本例中您不会这样做,但是您可以在以后拍摄快照时轻松地添加审计。
示例结构
图 22-2 为结构示意图。
图 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
的通用数组,不如显式地列出要拍摄的实际属性。当然也欢迎你这样做。这个通用快照类足够抽象,可以处理许多不同类型的Originators
。Originator
是唯一应该使用快照的人。你的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 模式的替代方案。
-
使用雄辩的方法
-
对象序列化
使用雄辩的方法
不出意外的话,口才已经有了一个叫syncOriginal
的方法。Taylor 使用这个数组来跟踪自从您上次保存以来在您的雄辩模型中发生了什么变化。有一些叫做isDirty
和getDirty
的方法可以检查你雄辩模型的变化。他们比较了$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 模式时,您不必处理sleep
和wakeup
或可序列化 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
2 和SplObserver
3。
抽象结构
-
SplSubject
是一个抽象类或接口。当使用一个抽象类时,你可以在这里放置一个数组来跟踪附加的观察器。SplSubject
包含三种方法。attach
方法给这个主题增加了观察者。detach
方法带走观察者。notify
方法通常包含一个循环,该循环遍历所有附加的观察器并调用它们的update
方法。 -
SplObserver
是一个抽象类或接口。SplObserver
接口包含一个名为update
的方法,每当主题更新时由SplSubject
触发。 -
RealSubject
是SplSubject
的一个实现。它将包含存储对象(数组/集合/等等。)需要挂在附加的观察器上。一些实现实际上创建了一个抽象类来代替SplSubject
,并将存储对象放在抽象类中。既然SplSubject
是接口,就不能这么做。但是,没有什么可以阻止你创建自己的抽象类,而不是使用接口。 -
RealObserver
是SplObserver
接口的一个实现。它的update
方法将被传递给SplSubject
的一个实例。这个类处理每当主题更新时需要执行的特定逻辑。
图 22-1。
Abstract structure
例子
在本例中,您将使用 PHP 内置的SplObserver
和SplSubject
展示一个相当通用的观察者模式。解决这个问题后,你将进入一个更复杂的场景,泰勒切奶酪,而附近的observing
闻到了奶酪的味道。最后,你将在雄辩的模型上使用观察者来结束这一章。您还将探索 Laravel 雄辩模型的一些内部内容,以及它如何使用事件来处理附加的观察者。
示例结构
图 22-2 为结构示意图。
图 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 。
这两个类,RealSubject
和RealObserver
,是最通用的。很难观察这两个类别并从中得出任何商业意义。这是使用SplSubject
和SplObserver
的缺点之一。您被泛型方法卡住了。除了对update
方法输入SplSubject
之外,你也不能输入任何提示。几乎不值得使用它们。我认为SplSubject
和SplObserver
有助于引导。然而,让我们运行您的模拟,看看一些测试输出。
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
模型。一辆车有几个属性:manufacturer
、vin
、description
、year
。随意查看播种机 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
方法时,它会遍历可能的事件,然后用事件和类名调用registerModelEvent
。getObservableEvents
方法返回一个字符串数组。弦是您之前看到的事件(updating
、updated
、creating
、created
等等)。它还包括您使用$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
事件。每当在调度程序上触发该事件时,就会触发回调。这就是观察者如何依附于一个雄辩的模型。请注意,每个模型都没有自己的观察器数组。观察器都在一个调度程序中共享。这比在汽车模型的每个实例上附加一个观察器数组使用的内存要少。也意味着所有的车模都有相同的观察者,这是意料之中的。如果您想专门为汽车的一个实例而不是另一个实例创建一个观察者,那么您需要做一些不同的事情。我把这个留给你去思考。接下来,现在您已经了解了观察者是如何在雄辩模型中被附加和触发的,让我们制作您自己的客户观察者事件。
添加自定义观察者
对于finding
和found
,没有现成的可观察事件。您将创建这些。每当您试图使用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";
}
}
雄辩模型上没有finding
或found
事件。因此,为了让上面的代码工作,您还需要做更多的工作。剩下的工作在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
7
http://en.wikipedia.org/wiki/Event-driven_architecture
8