十、提取表示逻辑来查看文件

当谈到遗留应用程序中的页面脚本时,通常会看到业务逻辑与表示逻辑交织在一起。例如,页面脚本执行一些设置工作,然后包括页眉模板、调用数据库、输出结果、计算某些值、打印计算值、将值写回数据库,并包括页脚模板。

通过为遗留应用程序提取一个域层,我们已经采取了一些措施来消除这些顾虑。但是,页面脚本中对域层和其他业务逻辑的调用仍然与表示逻辑混合在一起。除其他事项外,这些问题的混合使得测试遗留应用程序的不同方面变得困难。

在本章中,我们将把所有的表示逻辑分离到它自己的层,这样我们就可以将它与业务逻辑分开进行测试。

嵌入式表示逻辑

以嵌入式表示逻辑为例,在收集之前,我们可以先看附录 E代码。

表示逻辑。代码显示了一个页面脚本,该脚本已被重构以使用域事务,但在代码的其余部分中仍然存在一些表示逻辑。

表示和业务逻辑有什么区别?

出于我们的目的,表示逻辑包括生成发送给用户(如浏览器或移动客户端)的输出的任何和所有代码。这不仅包括echoprint,还包括header()setcookie()。每种方法都会产生某种形式的输出。另一方面,“业务逻辑”是其他一切。

将表示逻辑与业务逻辑分离的关键是将它们的代码放在不同的作用域中。脚本应该首先执行所有业务逻辑,然后将结果传递给表示逻辑。完成后,我们将能够分别测试表示逻辑和业务逻辑。

为了实现范围的分离,我们将在页面脚本中使用Response对象。我们所有的表示逻辑都将在Response实例中执行,而不是直接在页面脚本中执行。这样做将提供范围分离,我们需要将所有输出生成(包括 HTTP 头和 cookie)与页面脚本的其余部分解耦。

为什么是响应对象?

通常,当我们想到表示时,我们想到的是一个视图或一个为我们呈现内容的模板系统。然而,这类系统通常不会封装发送给用户的完整输出集。我们不仅需要输出 HTTP 主体,还需要输出 HTTP 头。此外,我们需要能够测试是否设置了正确的标题,以及是否正确生成了内容。因此,Response对象在这一点上比视图或模板系统更适合。对于我们的Response对象,我们将使用中提供的类 http://mlaphp.com/code 。请注意,我们将在响应上下文中包含文件,这意味着该对象上的方法将可用于在该对象内运行的include文件。

提取过程

提取表示逻辑不像提取领域逻辑那么困难。然而,它确实需要仔细的关注和大量的测试。

一般来说,流程如下:

  1. 查找包含与其余代码混合的表示逻辑的页面脚本。
  2. 在该脚本中,在文件中的所有其他逻辑之后,重新排列代码,将所有表示逻辑收集到单个块,然后抽查重新排列的代码。
  3. 通过Response将表示逻辑块提取到要交付的视图文件中,并再次抽查脚本,确保脚本与新的Response正常工作。
  4. 将适当的转义添加到演示逻辑中,并再次抽查。
  5. 提交新代码,推送到公共存储库,并通知 QA。
  6. 再次从包含表示逻辑的下一页脚本开始,该脚本与其他非表示代码混合在一起。

搜索嵌入式表示逻辑

一般来说,我们应该很容易在遗留应用程序中找到表示逻辑。在这一点上,我们应该对代码库足够熟悉,以便更好地了解页面脚本在哪里生成输出。

如果我们需要一个跳转开始,我们可以使用我们的项目范围搜索工具来查找所有出现的echoprintprintfheadersetcookiesetrawcookie。其中一些可能发生在类方法中;我们将在稍后讨论这个问题。现在,我们将集中讨论发生这些调用的页面脚本。

重新整理页面脚本,抽查

现在我们有了一个候选页面脚本,我们需要重新排列代码,以便在表示逻辑和其他所有内容之间有一个清晰的界限。对于我们这里的示例,我们将在收集之前使用附录 E中的代码代码。

首先,我们转到文件的底部,添加一条/* PRESENTATION */注释作为最后一行。然后我们回到文件的顶部。逐行逐块地工作,我们在/* PRESENTATION */注释后将所有表示逻辑移到文件末尾。完成后,/* PRESENTATION */注释前面的部分应该只包含业务逻辑,后面的部分应该只包含表示逻辑。

考虑到我们在附录 E中的起始代码,在收集之前的代码,我们应该以更像附录 F中的代码,在收集之后的代码结束。特别要注意的是,我们有以下几点:

  • 将业务逻辑未使用的变量(如$current_page)向下移动到表示块
  • header.php包含向下移动到演示块
  • 将仅作用于表示变量的逻辑和条件移动到表示块,如设置$page_titleif
  • $_SERVER['PHP_SELF']替换为$action变量
  • $_GET['id']替换为$id变量

在创建表示块时,我们应该小心地遵循前面章节中关于依赖注入的所有经验教训。即使表示代码是文件中的一个块(不是类),我们也应该将该块视为类方法。除此之外,这意味着不使用 globals、superglobals 或new关键字。当我们稍后将表示块提取到视图文件时,这将使事情变得更容易。

现在我们已经重新安排了页面脚本,以便在最后收集了所有的表示逻辑,我们需要进行抽查,以确保页面脚本仍然正常工作。像往常一样,我们通过运行我们预先存在的特性测试(如果有的话)来实现这一点。如果没有,我们必须浏览或以其他方式调用更改的代码。

如果页面没有像以前一样生成相同的输出,那么我们的重新排列会以某种方式改变逻辑。我们需要撤销并重新排列,直到页面正常工作。

一旦我们的抽查成功,我们可能希望提交到目前为止的更改。如果我们的下一组更改失败,我们可以将代码恢复到已知的工作状态。

提取演示文稿查看文件和抽查

现在我们有了一个工作页面脚本,在单个块中包含所有表示逻辑,我们将把整个块提取到它自己的文件中,然后使用Response执行提取的逻辑。

创建视图/目录

首先,我们需要一个位置将视图文件放入遗留应用程序中。虽然我更喜欢将表示逻辑保持在业务逻辑附近,但这种安排会在以后的现代化步骤中给我们带来麻烦。因此,我们将在遗留应用程序中创建一个名为views/的新目录,并将视图文件放在那里。此目录应与我们的classes/tests/目录处于同一级别。例如:

/path/to/app/
1 classes/
2 tests/
3 views/

选择一个视图文件名

现在我们有了保存视图文件的地方,我们需要为即将提取的表示逻辑选择一个文件名。视图文件应以页面脚本命名,路径位于与页面脚本路径匹配的views/下。例如,如果我们从/foo/bar/baz.php处的页面脚本中提取演示文稿,则目标视图文件应保存在/views/foo/bar/baz.php处。

有时,在视图文件中使用扩展名而不仅仅是.php是很有用的。我发现使用一个表示视图格式的扩展名会很有帮助。例如,生成 HTML 的视图可能以.html.php结尾,而生成 JSON 的视图可能以.json.php结尾。

移动演示块查看文件

接下来,我们从页面脚本中剪切演示块,并按原样将其粘贴到新的视图文件中。

然后,我们在页面脚本中创建一个Response对象,并用setView()将其指向我们的视图文件,以取代页面脚本中的原始表示块。我们还为以后设置了一个对setVars()的空调用,最后调用send()方法。

在我们所有的页面脚本中,应该始终为响应对象使用相同的变量名。这里的所有示例都将使用名称$response。这并不是因为名称$response很特殊,而是因为在后面的章节中,这种一致性将非常重要。

例如:

foo/bar/baz.php
1 <?php
2 // ... business logic ...
3
4 /* PRESENTATION */
5 $response = new \Mlaphp\Response('/path/to/app/views');
6 $response->setView('foo/bar/baz.html.php');
7 $response->setVars(array());
8 $response->send();
9 ?>

此时,我们已经成功地将表示逻辑与页面脚本解耦。我们可以删除/* PRESENTATION */注释。它已经达到了目的,不再需要了。

但是,这种解耦从根本上打破了表示逻辑,因为视图文件依赖于页面脚本中的变量。考虑到这一点,我们开始抽查和修改周期。我们浏览页面脚本或以其他方式调用页面脚本,发现特定变量对演示文稿不可用。我们将其添加到setVars()数组中,并再次抽查。我们继续向setVars()数组添加变量,直到视图文件拥有它所需要的一切,并且我们的抽查运行完全成功。

对于流程的这一部分,最好设置error_reporting(E_ALL)。这样,对于表示逻辑中的每个未初始化变量,我们将得到一个 PHP 通知。

考虑到我们之前在附录 E中的示例、收集前的代码附录 F、收集后的代码,我们最终得到了附录 G、响应视图文件后的代码。我们可以看到,articles.html.php视图文件需要四个变量:$id, $failure$input$action

1 <?php
2 // ...
3 $response->setVars(array(
4 'id' => $id,
5 'failure' => $article_transactions->getFailure(),
6 'input' => $article_transactions->getInput(),
7 'action' => $_SERVER['PHP_SELF'],
8 ));
9 // ...
10 ?>

一旦我们有了一个工作页面脚本,我们可能希望再次提交我们的工作,以便我们有一个已知的正确状态,如果需要的话,我们可以稍后恢复到该状态。

添加适当的逃逸

不幸的是,大多数遗留应用程序很少或根本不关注输出安全性。最常见的漏洞之一是跨站点脚本XSS)。

什么是 XSS?

跨站点脚本是一种攻击,通过将用户输入发送回浏览器而不受攻击。例如,攻击者可以在表单输入或 HTTP 头中输入恶意编制的 JavaScript 代码。如果该值随后被传递回浏览器而没有转义,则浏览器将执行该 JavaScript 代码。这可能会打开客户端浏览器,使其遭受进一步的攻击。更多信息请参见 XSS(上的OWASP 条目 https://www.owasp.org/index.php/Cross-site_Scripting_%28XSS%29 )。

针对 XSS 的防御措施是在使用变量的上下文中始终转义所有变量。如果变量用作 HTML 内容,则需要将其转义为 HTML 内容;如果在 HTML 属性中使用变量,则需要对其进行转义,以此类推。

防御 XSS 需要开发人员的努力。如果我们还记得关于转义输出的一件事,那就是htmlspecialchars()函数。适当地使用此函数将使我们免受大多数(但不是所有)XSS 攻击。

当使用htmlspecialchars()时,我们必须确保每次都传递一个引号常量和一个字符集。因此,仅调用htmlspecialchars($unescaped_text)是不够的。我们必须打电话给htmlspecialchars($unescaped_text, ENT_QUOTES, 'UTF-8')。因此,输出如下所示:

unescaped.html.php
1 <form action="<?php
2 echo $request->server['PHP_SELF'];
3 ?>" method="POST">

这需要像这样转义:

escaped.html.php
1 <form action="<?php
2 echo htmlspecialchars(
3 $request->server['PHP_SELF'],
4 ENT_QUOTES,
5 'UTF-8'
6 );
7 ?>" method="POST">

每当我们发送未经扫描的输出时,我们都需要意识到我们可能会打开一个安全漏洞。因此,我们必须对用于输出的每个变量应用转义。

这样反复调用htmlspecialchars()可能会比较麻烦,Response类提供了一个esc()方法作为htmlspecialchars()的别名,并进行了合理的设置:

escaped.php
1 <form action="<?php
2 echo $this->esc($request->server['PHP_SELF']);
3 ?>" method="POST">

请注意,通过htmlspecialchars()逃离只是一个起点。虽然转义本身很简单,但很难知道针对特定上下文的合适转义技术。

不幸的是,提供逃逸和其他安全技术的全面概述超出了本书的范围。有关更多信息,以及一个好的独立转义工具,请参见的Zend\Escaperhttps://framework.zend.com/manual/2.2/en/modules/zend.escaper 图书馆。

在我们转义Response视图文件中的所有输出之后,我们可以继续进行测试。

写视图文件测试

为视图文件编写测试提出了一些独特的挑战。在本章之前,我们所有的测试都是针对类和类方法的。因为我们的视图文件是文件,所以我们需要将它们放入稍微不同的测试结构中。

测试/视图/目录

首先,我们需要在tests/目录中创建一个views/子目录。之后,我们的tests/目录应该是这样的:

/path/to/app/tests/
1 bootstrap.php
2 classes/
3 phpunit.xml
4 views/

接下来,我们需要修改phpunit.xml文件,以便它知道如何扫描新的views/子目录进行测试:

tests/phpunit.xml
1 <phpunit bootstrap="./bootstrap.php">
2 <testsuites>
3 <testsuite>
4 <directory>./classes</directory>
5 <directory>./views</directory>
6 </testsuite>
7 </testsuites>
8 </phpunit>

编写视图文件测试

现在我们有了视图文件测试的位置,我们需要编写一个。

虽然我们正在测试一个文件,但 PHPUnit 要求每个测试都是一个类。因此,我们将为正在测试的视图文件命名我们的测试,并将其放置在模仿原始视图文件位置的tests/views/下的子目录中。例如,如果我们在views/foo/bar/baz.html.php处有一个视图文件,我们将在tests/views/foo/bar/BazHtmlTest.php处创建一个测试文件。是的,这有点难看,但它将帮助我们跟踪哪些测试映射到哪些视图。

在我们的测试类中,我们将创建一个Response实例,就像页面脚本末尾的那个实例一样。我们将把视图文件路径和所需的变量传递给它。最后,我们将需要该视图,然后检查输出和标题以查看该视图是否正常工作。

根据我们的articles.html.php文件,我们的初始测试可能如下所示:

tests/views/ArticlesHtmlTest.php
1 <?php
2 class ArticlesHtmlTest extends \PHPUnit_Framework_TestCase
3 {
4 protected $response;
5 protected $output;
6
7 public function setUp()
8 {
9 $this->response = new \Mlaphp\Response('/path/to/app/views');
10 $this->response->setView('articles.html.php');
11 $this->response->setVars(
12 'id' => '123',
13 'failure' => array(),
14 'action' => '/articles.php',
15 'input' => array(
16 'title' => 'Article Title',
17 'body' => 'The body text of the article.',
18 'max_ratings' => 5,
19 'credits_per_rating' => 1,
20 'notes' => '...',
21 'ready' => 0,
22 ),
23 );
24 $this->output = $this->response->requireView();
25 }
26
27 public function testBasicView()
28 {
29 $expect = '';
30 $this->assertSame($expect, $this->output);
31 }
32 }
33 ?>

为什么使用 RequireReview()而不是 send()?

如果我们使用send(),则Response将输出视图文件结果,而不是将它们留在缓冲区中供我们检查。调用requireView()调用视图文件,但返回结果,而不是生成输出。

当我们运行这个测试时,它将失败。我们很高兴,因为$expect值是空的,但是输出应该包含很多内容。这是正确的行为。(如果测试通过,则可能有问题。)

断言内容的正确性

现在我们需要我们的测试来查看输出是否正确。

最简单的方法是转储实际的$this->output字符串并将其值复制到$expect变量。如果输出字符串相对较短,则确保它们完全相同的assertSame($expect, $this->output)就足以满足我们的目的。

但是,如果主视图文件包含的任何其他文件发生任何更改,则测试将失败。发生故障的原因不是主视图已更改,而是相关视图已更改。这不是帮助我们的那种失败。

对于较大的输出字符串,我们可以查找期望的子字符串,并确保它存在于实际输出中。然后,当测试失败时,它将与我们正在测试的特定子字符串相关,而不是与整个输出字符串相关。

例如,我们可以使用strpos()来查看输出中是否有特定的字符串。如果$this->output的草堆不包含$expect指针,strpos()将返回布尔值false。任何其他值表示存在$needle。(如果我们编写自己的自定义断言方法,则此逻辑更易于阅读。)

1 <?php
2 public function assertOutputHas($expect)
3 {
4 if (strpos($this->output, $expect) === false) {
5 $this->fail("Did not find expected output: $expect");
6 }
7 }
8
9 public function testFormTag()
10 {
11 $expect = '<form method="POST" action="/articles.php">';
12 $this->assertOutputHas($expect);
13 }
14 ?>

这种方法的优点是非常简单,但可能不适合复杂的断言。我们可能希望计算元素出现的次数,或者断言 HTML 具有特定的结构而不引用该结构的内容,或者检查元素是否出现在输出中的正确位置。

对于这些更复杂的内容断言,PHPUnit 有一个assertSelectEquals()断言,以及其他相关的assertSelect*()方法。它们通过使用 CSS 选择器来检查输出的不同部分,但可能很难阅读和理解。

或者,我们可能更愿意安装Zend\Dom\Query,以便更好地操纵 DOM 树。该库还通过使用 CSS 选择器来分离内容。它返回DOM节点和节点列表,这对于以细粒度方式测试内容非常有用。

不幸的是,我无法给出具体的建议,说明哪种方法最适合您。我建议从一个类似于上述assertOutputHas()方法的方法开始,当您明显需要一个更强大的系统时,再转到Zend\Dom\Query方法。

在我们编写了测试以确认演示文稿正常工作后,我们继续进行流程的最后一部分。

提交、推送、通知 QA

此时,我们应该已经通过了页面脚本和提取的表示逻辑的测试。我们现在提交所有代码和测试,将它们推送到公共存储库,并通知 QA 我们已准备好让他们查看新工作。

做。。。虽然

我们继续在页面脚本中搜索混合了业务逻辑的表示逻辑。当我们通过Response对象提取所有表示逻辑以查看文件时,我们就完成了。

常见问题

标题和 cookie 呢?

在上面的示例中,我们只关注echoprint的输出。然而,页面脚本通常也会通过header()setcookie()setrawcookie()设置 HTTP 头。这些也会产生输出。

处理这些输出方法可能会有问题。尽管Response类使用output bufferingechoprint捕获为返回值,但没有类似的选项用于缓冲对header()和相关函数的调用。因为这些函数的输出没有缓冲,所以我们无法轻松地测试以查看发生了什么。

在这里,拥有一个Response物体真的能帮助我们。该类附带了缓冲header()和相关本机 PHP 函数的方法,但在send()之前不会调用这些函数。这使我们能够捕获这些调用的输入,并在它们实际激活之前对它们进行测试。

例如,假设我们在人造视图文件中有一些类似的代码:

foo.json.php
1 <?php
2 header('Content-Type: application/json');
3 setcookie('baz', 'dib');
4 setrawcookie('zim', 'gir');
5 echo json_encode($data);
6 ?>

除此之外,我们无法测试头文件是否符合我们的预期。PHP 已经将它们发送到客户端。

当使用带有响应对象的视图文件时,我们可以在本机函数调用之前加上$this->前缀,以调用响应方法,而不是本机 PHP 函数。Response方法将参数缓冲到本机调用中,而不是直接进行调用。这允许我们在参数作为输出交付之前检查它们。

foo.json.php
1 <?php
2 $this->header('Content-Type: application/json');
3 $this->setcookie('baz', 'dib');
4 $this->setrawcookie('zim', 'gir');
5 echo json_encode($data);
6 ?>

因为视图文件正在响应实例中执行,所以它可以访问$this中的Response属性和方法。Response对象上的header()setcookie()setrawcookie()方法与本机 PHP 方法具有完全相同的签名,但将输入捕获到属性中以便稍后输出,而不是立即生成输出。

我们现在可以测试Response对象来检查 HTTP 主体和 HTTP 头。

tests/views/FooJsonTest.php
1 <?php
2 public function test()
3 {
4 // set up the response object
5 $response = new \Mlaphp\Response('/path/to/app/views');
6 $response->setView('foo.json.php');
7 $response->setVars('data', array('foo' => 'bar'));
8
9 // invoke the view file and test its output
10 $expect_body = '{"foo":"bar"}';
11 $actual_body = $response->requireView();
12 $this->assertSame($expect_output, $actual_output);
13
14 // test the buffered HTTP header calls
15 $expect_headers = array(
16 array('header', 'Content-Type: application/json'),
17 array('setcookie', 'baz', 'dib'),
18 array('setrawcookie', 'zim', 'gir'),
19 );
20 $actual_headers = $response->getHeaders();
21 $this->assertSame($expect_output, $actual_output);
22 }
23 ?>

响应getHeaders()方法返回子数组数组。每个子数组都有一个元素 0,表示要调用的本机 PHP 函数名,其余元素是函数的参数。这些是将在send()时间进行的函数调用。

如果我们已经有了模板系统怎么办?

很多时候,遗留应用程序已经有了视图或模板系统。如果是这样,继续使用现有的模板系统而不是引入新的Response类就足够了。

如果我们决定保留现有的模板系统,本章中的其他步骤仍然适用。我们需要将所有模板调用移动到页面脚本末尾的单个位置,将所有模板交互与其余业务逻辑分离。然后我们可以在页面脚本的末尾显示模板。例如:

foo.php
1 <?php
2 // ... business logic ...
3
4 /* PRESENTATION */
5 $template = new Template;
6 $template->assign($this->getVars());
7 $template->display('foo.tpl.php');
8 ?>

如果我们不发送 HTTP 头,这种方法就像使用Response对象一样可测试。然而,如果我们混合调用header()和相关函数,我们的可测试性将受到更大的限制。

为了将来验证遗留代码,我们可以将模板逻辑移动到视图文件中,并与页面脚本中的Response对象交互。例如:

foo.php
1 <?php
2 // ... business logic ...
3
4 /* PRESENTATION */
5 $response = new Response('/path/to/app/views');
6 $response->setView('foo.html.php');
7 $response->setVars(array('foo' => $foo));
8 $response->send();
9 ?>
foo.html.php
1 <?php
2 // buffer calls to HTTP headers
3 $this->setcookie('foo', 'bar');
4 $this->setrawcookie('baz', 'dib');
5
6 // set up the template object with Response vars
7 $template = new Template;
8 $template->assign($this->getVars());
9
10 // display the template
11 $template->display('foo.tpl.php');
12 ?>

这允许我们继续使用现有的模板逻辑和文件,同时通过Response对象为 HTTP 头添加可测试性。

为了一致性,我们应该使用现有的模板系统,或者通过Response对象将所有模板逻辑包装到视图文件中。我们不应该在某些页面脚本中使用模板系统,而在其他页面脚本中使用Response对象。在后面的章节中,重要的是我们在页面脚本中有一种与表示层交互的单一方式。

流媒体内容呢?

大多数情况下,我们的演示文稿足够小,可以通过 PHP 缓冲到内存中,直到准备好发送为止。但是,有时我们的遗留应用程序可能需要发送大量数据,例如数十兆或数百兆字节的文件。

将一个大文件读入内存以便我们可以将它输出给用户通常不是一个好方法。相反,我们对文件进行流式处理:我们读取文件的一小段并将其发送给用户,然后读取下一小段并将其发送给用户,依此类推,直到整个文件交付为止。这样,我们就不必将整个文件保存在内存中。

到目前为止,这些示例只涉及将视图缓冲到内存中,然后一次将其全部输出,而不涉及流。对于我们的视图文件来说,将整个资源读入内存然后输出是一种糟糕的方法。同时,我们需要确保在任何流式内容之前交付标题。

Response对象有一个方法来处理这种情况。Response方法setLastCall()允许我们设置一个用户定义的函数(可调用函数),在需要查看文件并发送头文件后调用。有了这个,我们可以传递一个类方法,它将为我们流式输出资源。

例如,假设我们需要输出一个大的图像文件。我们可以编写如下类来处理流逻辑:

classes/FileStreamer.php
1 <?php
2 class FileStreamer
3 {
4 public function send($file, $dest = STDOUT)
5 {
6 $fh = fopen($file, 'rb');
7 while (! feof($fh)) {
8 $data = fread($fh, 8192);
9 fwrite($dest, $data);
10 }
11 fclose($fh);
12 }
13 }
14 ?>

这里还有很多需要改进的地方,比如错误检查和更好的资源处理,但是它实现了我们示例的目的。

然后,我们可以在页面脚本中创建一个FileStreamer实例,视图文件可以将其用作setLastCall()的可调用参数:

foo.php
1 <?php
2 // ... business logic ...
3 $file_streamer = new FileStreamer;
4 $image_file = '/path/to/picture.tiff';
5 $content_type = 'img/tiff';
6
7 /* PRESENTATION */
8 $response = new Response('/path/to/app/views');
9 $response->setView('foo.stream.php');
10 $response->setVars(array(
11 'streamer' => $file_streamer,
12 'file' => $image_file,
13 'type' => $content_type,
14 ));
15 ?>
views/foo.stream.php
1 <?php
2 $this->header("Content-Type: {$type}");
3 $this->setLastCall(array($streamer, 'send'), $file);
4 ?>

send()时间,Response将需要视图文件,该文件将设置头和最后一个带有参数的调用。然后,Response发送标题和捕获的视图输出(在本案例中为 nothing)。最后,它调用setLastCall()中的可调用参数和参数,从而导出文件。

如果我们有很多表现变量呢?

在本章的代码示例中,我们只有少量变量可以传递到表示逻辑。不幸的是,很可能会有 10 个或 20 个或更多的变量要传递。这通常是因为演示文稿由几个include文件组成,每个文件都需要自己的变量。

这些额外的变量通常用于站点标题、导航和页脚部分。因为我们已经将业务逻辑与表示逻辑解耦,并在单独的范围内执行表示逻辑,所以我们必须传入所有include文件所需的所有变量。

假设我们有一个包含header.php文件的视图文件,如下所示:

header.php
1 <html>
2 <head>
3 <title><?php
4 echo $this->esc($page_title);
5 ?></title>
6 <link rel="stylesheet" href="<?php
7 echo $this->esc($page_style);
8 ?>"></link>
9 </head>
10 <body>
11 <h1><?php echo $this->esc($page_title); ?></h1>
12 <div id="navigation">
13 <ul>
14 <?php foreach ($site_nav as $nav_item) {
Extract Presentation Logic To View Files 117
15 $href = $this->esc($nav_item['href']);
16 $name = $this->esc($nav_item['name']);
17 echo '<li><a href="' . $href
18 . '"/a>' . $name
19 . '</li>' . PHP_EOL;
20 }?>
21 </ul>
22 </div>
23 <!-- end of header.php -->

我们的页面脚本必须传递$page_title$page_style$site_nav变量才能正确显示标题。这是一个相对温和的案例;可能还有更多的变量。

一种解决方案是将常用变量收集到它们自己的一个或多个对象中。然后,我们可以将这些常用对象传递到_Response_中,供视图文件使用。例如,特定于标题的显示变量可以放置在HeaderDisplay类中,然后可以将该类传递给_Response_

classes/HeaderDisplay.php
1 <?php
2 class HeaderDisplay
3 {
4 public $page_title;
5 public $page_style;
6 public $site_nav;
7 }
8 ?>

然后我们可以修改header.php文件以使用HeaderDisplay对象,页面脚本可以传递HeaderDisplay的实例,而不是所有单独的头相关变量。

提示

一旦我们开始将相关变量收集到类中,我们将开始了解如何将表示逻辑收集到这些类上的方法中,从而减少视图文件中的逻辑量。例如,我们不难想象在HeaderDisplay类上有一个getNav()方法为导航小部件返回正确的 HTML。

生成输出的类方法呢?

在本章的示例代码中,我们主要关注页面脚本中的表示逻辑。但是,可能是域类或其他支持类使用echoheader()生成输出。因为输出生成必须限制在表示层,所以我们需要找到一种在不破坏遗留应用程序的情况下删除这些调用的方法。即使是用于表示目的的类也不应该自行生成输出。

这里的解决方案是将echoprint等的每次使用转换为return。然后,我们可以立即输出结果,或者将结果捕获到一个变量中,稍后再输出。

例如,假设我们有一个如下所示的类方法:

1 <?php
2 public function namesAndRoles($list)
3 {
4 echo "<p>Names and roles:</p>";
5 foreach ($list as $item) {
6 echo "<dl>";
7 echo "<dt>Name</dt><dd>{$item['name']}</dd>";
8 echo "<dt>Role</dt><dd>{$item['role']}</dd>";
9 echo "</dl>";
10 }
11 }
12 ?>

我们可以将其转换为类似的内容(请记住添加转义!):

1 <?php
2 public function namesAndRoles($list)
3 {
4 $html = "<p>Names and roles:</p>";
5 foreach ($list as $item) {
6 $name = htmlspecialchars($item['name'], ENT_QUOTES, 'UTF-8');
7 $role = htmlspecialchars($item['role'], ENT_QUOTES, 'UTF-8');
8 $html .= "<dl>";
9 $html .= "<dt>Name</dt><dd>{$name}</dd>";
10 $html .= "<dt>Role</dt><dd>{$role}</dd>";
11 $html .= "</dl>";
12 }
13 return $html;
14 }
15 ?>

演示文稿中混合了哪些业务逻辑?

当重新排列页面脚本以将业务逻辑与表示逻辑分离时,我们可能会发现表示代码调用事务或其他类或资源。这是一种有害的混合关注点形式,因为演示取决于这些调用的结果。

如果调用的代码专门用于输出,那么就没有问题;我们可以把电话留在原地。但是,如果被调用的代码与外部资源(如数据库或网络连接)交互,则需要将各种关注点分开。

解决方案是从表示逻辑中提取一组等效的业务逻辑调用,将结果捕获到一个变量,然后将该变量传递给表示。

对于一个虚构的示例,以下混合代码进行数据库调用,然后在单个循环中显示它们:

1 <?php
2 /* PRESENTATION */
3 foreach ($post_transactions->fetchTopTenPosts() as $post) {
4 echo "{$post['title']} has "
5 . $comment_transactions->fetchCountForPost($post['id'])
6 . " comments.";
7 }
8 ?>

暂时忽略一下,我们需要解决示例中呈现的 N+1 查询问题,最好在事务级别解决。我们怎样才能将演示与数据检索分离开来?

在这种情况下,我们构建一组等效的代码来捕获所需的数据,然后将该数据传递给表示逻辑,并应用适当的转义:

1 <?php
2 // ...
3 $posts = $post_transactions->fetchTopTenPosts();
4 foreach ($posts as &$post) {
5 $count = $comment_transactions->fetchCountForPost($post['id']);
6 $post['comment_count'] = $count;
7 }
8 // ...
9
10 /* PRESENTATION */
11 foreach ($posts as $post) {
12 $title = $this->esc($post['title']);
13 $comment_count = $this->esc($post['comment_count']);
14 echo "{$title} has {$comment_count} comments."
15 }
16 ?>

是的,我们最终在同一数据上循环了两次——一次在业务逻辑中,一次在表示逻辑中。虽然这在某些方面可能被合理地称为低效,但效率并不是我们的主要目标。分离关注点是我们的主要目标,这种方法很好地实现了这一点。

如果页面只包含表示逻辑怎么办?

遗留应用程序中的某些页面可能主要或全部由表示代码组成。在这些情况下,我们似乎不需要响应对象。

但是,即使这些页面脚本也应该转换为使用响应和视图文件。现代化过程中的下一步将需要页面脚本结果的一致接口,而我们的响应对象是确保一致性的方法。

回顾和下一步

我们现在已经浏览了所有页面脚本,并将表示逻辑提取到一系列单独的文件中。表示代码现在在完全独立于页面脚本的范围内执行。这使得我们很容易看到脚本的剩余逻辑,以及独立地测试表示逻辑。

随着表示逻辑被提取到它自己的层,我们的页面脚本的大小正在缩小。剩下的只是一些设置工作和准备响应所需的操作逻辑。

然后,我们的下一步是将页面脚本中剩余的操作逻辑提取到一系列控制器类中。