PHP7-编程秘籍(四)

73 阅读36分钟

PHP7 编程秘籍(四)

原文:zh.annas-archive.org/md5/2ddf943a2c311275def462dcde4895fb

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:处理日期/时间和国际化方面

在本章中,我们将涵盖以下主题:

  • 在视图脚本中使用表情符号或 emoji

  • 转换复杂字符

  • 从浏览器数据中获取 locale

  • 按区域设置数字格式

  • 按区域设置货币

  • 按区域设置日期/时间格式

  • 创建一个 HTML 国际日历生成器

  • 构建重复事件生成器

  • 处理翻译而无需 gettext

介绍

我们将从利用PHP 7引入的新Unicode转义语法开始本章的两个配方。之后,我们将介绍如何从浏览器数据中确定 Web 访问者的locale。接下来的几个配方将涵盖创建一个 locale 类,它将允许您以特定于 locale 的格式表示数字、货币、日期和时间。最后,我们将介绍一些演示如何生成国际化日历、处理重复事件和执行翻译的配方,而无需使用gettext

在视图脚本中使用表情符号或 emoji

单词emoticonsemotionicon的组合。Emoji源自日本,是另一个更大、更广泛使用的图标集。这些图标是小笑脸、小忍者和在地板上打滚大笑的图标,在任何具有社交网络方面的网站上都很受欢迎。然而,在 PHP 7 之前,制作这些小家伙是一种沮丧的练习。

如何做...

  1. 首先,您需要知道您希望呈现的图标的 Unicode。在互联网上快速搜索将指引您到几个优秀的图表之一。以下是三个hear-no-evilsee-no-evilspeak-no-evil猴子图标的代码:

U+1F648U+1F649U+1F64A

如何做...

  1. 向浏览器输出任何 Unicode 必须得到正确的标识。这通常是通过meta标签完成的。您应该将字符集设置为 UTF-8。以下是一个示例:
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
</head>
  1. 传统的方法是简单地使用 HTML 来显示图标。因此,您可以做如下操作:
<table>
  <tr>
    <td>&#x1F648;</td>
    <td>&#x1F649;</td>
    <td>&#x1F64A;</td>
  </tr>
</table>
  1. 从 PHP 7 开始,您现在可以使用此语法构造完整的 Unicode 字符:"\u{xxx}"。以下是与前述项目中相同的三个图标的示例:
<table>
  <tr>
    <td><?php echo "\u{1F648}"; ?></td>
    <td><?php echo "\u{1F649}"; ?></td>
    <td><?php echo "\u{1F64A}"; ?></td>
  </tr>
</table>

注意

您的操作系统和浏览器都必须支持 Unicode,并且还必须具有正确的字体集。例如,在 Ubuntu Linux 中,您需要安装ttf-ancient-fonts软件包才能在浏览器中看到表情符号。

工作原理...

在 PHP 7 中,引入了一种新的语法,允许您呈现任何 Unicode 字符。与其他语言不同,新的 PHP 语法允许变量数量的十六进制数字。基本格式如下:

\u{xxxx}

整个结构必须使用双引号引起来(或使用heredoc)。xxxx可以是任意组合的十六进制数字,2、4、6 及以上。

创建一个名为chap_08_emoji_using_html.php的文件。一定要包含meta标签,表示正在使用 UTF-8 字符编码的浏览器:

<!DOCTYPE html>
<html>
  <head>
    <title>PHP 7 Cookbook</title>
    <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  </head>

接下来,设置一个基本的 HTML 表格,并显示一行表情符号/emoji:

  <body>
    <table>
      <tr>
        <td>&#x1F648;</td>
        <td>&#x1F649;</td>
        <td>&#x1F64A;</td>
      </tr>
    </table>
  </body>
</html>

现在使用 PHP 添加一行以发出表情符号/emoji:

  <tr>
    <td><?php echo "\u{1F648}"; ?></td>
    <td><?php echo "\u{1F649}"; ?></td>
    <td><?php echo "\u{1F64A}"; ?></td>
  </tr>

以下是从 Firefox 中看到的输出:

工作原理...

另请参阅

转换复杂字符

访问整个 Unicode 字符集的能力为呈现复杂字符,特别是拉丁-1 字母表之外的字符,打开了许多新的可能性。

如何做...

  1. 有些语言是从右到左而不是从左到右阅读的。例如希伯来语和阿拉伯语。在这个例子中,我们向您展示如何使用U+202E Unicode 字符来呈现反向文本。以下代码行打印txet desreveR
echo "\u{202E}Reversed text";
echo "\u{202D}";    // returns output to left-to-right

注意

完成后不要忘记调用从左到右覆盖字符U+202D

  1. 另一个考虑因素是使用组合字符。一个例子是ñ(字母n上面漂浮着一个波浪符~)。这在词语中使用,比如mañana(西班牙语中的早晨或明天,取决于上下文)。有一个组合字符,用 Unicode 代码U+00F1表示。这是它的使用示例,回显mañana
echo "ma\u{00F1}ana"; // shows mañana
  1. 然而,这可能会影响搜索的可能性。想象一下,您的客户没有带有这个组合字符的键盘。如果他们开始输入man试图搜索mañana,他们将不成功。

  2. 访问完整的 Unicode 集合提供了其他可能性。您可以使用组合字符,而不是使用组合字符,它可以在字母上方放置一个浮动的波浪符。在这个echo命令中,输出与以前相同。只是形成单词的方式不同:

echo "man\u{0303}ana"; // also shows mañana
  1. 类似的应用可以用于重音符号。考虑法语单词élève(学生)。您可以使用组合字符来呈现它,也可以使用组合代码将重音符号浮动在字母上方。考虑以下两个例子。这两个例子产生相同的输出,但呈现方式不同:
echo "\u{00E9}l\u{00E8}ve";
echo "e\u{0301}le\u{0300}ve";

它是如何工作的...

创建一个名为chap_08_control_and_combining_unicode.php的文件。确保包含meta标签,表示正在使用 UTF-8 字符编码的浏览器:

<!DOCTYPE html>
<html>
  <head>
    <title>PHP 7 Cookbook</title>
    <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  </head>

接下来,设置基本的 PHP 和 HTML 来显示之前讨论的示例:

  <body>
    <pre>
      <?php
        echo "\u{202E}Reversed text"; // reversed
        //echo "\u{202D}"; // stops reverse
        echo "mañana";  // using pre-composed characters
        echo "ma\u{00F1}ana"; // pre-composed character
        echo "man\u{0303}ana"; // "n" with combining ~ character (U+0303)
        echo "élève";
        echo "\u{00E9}l\u{00E8}ve"; // pre-composed characters
        echo "e\u{0301}le\u{0300}ve"; // e + combining characters
      ?>
    </pre>
</body>
</html>

以下是浏览器的输出:

它是如何工作的...

从浏览器数据获取 locale

为了改善网站上的用户体验,重要的是以用户的区域设置可接受的格式显示信息。Locale 是一个通用术语,用来指示世界的某个地区。IT 社区已经努力使用由语言和国家代码组成的两部分指定来编码 locale。但是当一个人访问您的网站时,如何知道他们的区域设置呢?可能最有用的技术涉及检查 HTTP 语言标头。

如何做到...

  1. 为了封装 locale 功能,我们将假设一个类Application\I18n\Locale。我们将使这个类扩展一个现有的类Locale,这是 PHP 的 Intl 扩展的一部分。

注意

I18n 是 Internationalization 的常见缩写。(计算字母的数量!)

namespace Application\I18n;
use Locale as PhpLocale;
class Locale extends PhpLocale
{
  const FALLBACK_LOCALE = 'en';
  // some code
}
  1. 为了了解传入请求的样子,使用phpinfo(INFO_VARIABLES)。在测试后立即禁用此功能,因为它会向潜在攻击者透露太多信息:
<?php phpinfo(INFO_VARIABLES); ?>
  1. Locale 信息存储在$_SERVER['HTTP_ACCEPT_LANGUAGE']中。该值将采用这种一般形式:ll-CC,rl;q=0.n, ll-CC,rl;q=0.n,如表中所定义:
缩写意义
ll代表语言的两个小写字母代码。
-在语言和国家之间分隔区域代码ll-CC
CC代表国家的两个大写字母代码。
,将 locale 代码与回退根 locale代码(通常与语言代码相同)分隔开。
rl代表建议的根 locale 的两个小写字母代码。
;将 locale 信息与质量分隔开。如果质量丢失,默认为q=1(100%)概率;这是首选的。
q质量。
0.n0.00 到 1.0 之间的某个值。将此值乘以 100,以获得此访问者实际首选语言的概率百分比。
  1. 可能会列出多个 locale。例如,网站访问者可能在他们的计算机上安装了多种语言。PHP 的 Locale 类恰好有一个方法acceptFromHttp(),它读取Accept-language标头字符串并给我们所需的设置:
protected $localeCode;
public function setLocaleCode($acceptLangHeader)
{
  $this->localeCode = $this->acceptFromHttp($acceptLangHeader);
}
  1. 然后我们可以定义适当的 getter。get AcceptLanguage()方法返回$_SERVER['HTTP_ACCEPT_LANGUAGE']中的值。
public function getAcceptLanguage()
{
  return $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? self::FALLBACK_LOCALE;
}
public function getLocaleCode()
{
  return $this->localeCode;
}
  1. 接下来,我们定义一个构造函数,允许我们“手动”设置区域设置。否则,区域设置信息将从浏览器中获取:
public function __construct($localeString = NULL)
{
  if ($localeString) {
    $this->setLocaleCode($localeString);
  } else {
    $this->setLocaleCode($this->getAcceptLanguage());
  }
}
  1. 现在要做出重要的决定:如何处理这些信息!这将在接下来的几篇文章中介绍。

注意

即使访问者似乎接受一个或多种语言,该访问者并不一定希望以其浏览器指示的语言/区域设置显示内容。因此,尽管您可以根据这些信息设置区域设置,但您还应该为他们提供一个静态的备选语言列表。

它是如何工作的...

在这个例子中,让我们举三个例子:

  • 从浏览器获取的信息

  • 预设区域设置fr-FR

  • 从 RFC 2616 中获取的字符串:da, en-gb;q=0.8, en;q=0.7

将步骤 1 到 6 的代码放入一个名为Locale.php的文件中,该文件位于Application\I18n文件夹中。

接下来,创建一个名为chap_08_getting_locale_from_browser.php的文件,该文件设置自动加载并使用新的类:

<?php
  require __DIR__ . '/../Application/Autoload/Loader.php';
  Application\Autoload\Loader::init(__DIR__ . '/..');
  use Application\I18n\Locale;

现在,您可以定义一个包含三个测试区域设置字符串的数组:

$locale = [NULL, 'fr-FR', 'da, en-gb;q=0.8, en;q=0.7'];

最后,循环遍历三个区域设置字符串,创建新类的实例。回显从getLocaleCode()返回的值,以查看做出了什么选择:

echo '<table>';
foreach ($locale as $code) {
  $locale = new Locale($code); 
  echo '<tr>
    <td>' . htmlspecialchars($code) . '</td>
    <td>' . $locale->getLocaleCode() . '</td>
  </tr>';
}
echo '</table>';

这是结果(稍微加了一点样式):

它是如何工作的...

另请参阅

按区域设置格式化数字

数字表示可以根据区域设置而变化。举一个简单的例子,在英国,三百万八千五百一十二点九十二可以看作是:

3,080,512.92.

然而,在法国,同样的数字可能会显示如下:

3 080 512,92

如何做...

在表示特定区域的数字之前,您需要确定区域设置。这可以使用前面一篇文章中讨论的Application\I18n\Locale类来实现。区域设置可以手动设置或从标头信息中获取。

  1. 接下来,我们将使用NumberFormatter类的format()方法,以区域特定的格式输出和解析数字。首先,我们添加一个属性,该属性将包含NumberFormatter类的一个实例:
use NumberFormatter;
protected $numberFormatter;

注意

我们最初的想法是考虑使用 PHP 函数setlocale()根据区域设置生成格式化的数字。然而,这种传统方法的问题在于一切都将基于这个区域设置。这可能会引入处理根据数据库规范存储的数据的问题。setlocale()的另一个问题是它基于过时的标准,包括 RFC 1766 和 ISO 639。最后,setlocale()高度依赖于操作系统的区域支持,这将使我们的代码不可移植。

  1. 通常,下一步将是在构造函数中设置$numberFormatter。然而,对于我们的Application\I18n\Locale类,这种方法的问题在于,我们最终会得到一个过于庞大的类,因为我们还需要执行货币和日期格式化。因此,我们添加一个getter,首先检查是否已经创建了NumberFormatter的实例。如果没有,则创建并返回一个实例。新的NumberFormatter中的第一个参数是区域代码。第二个参数NumberFormatter::DECIMAL表示我们需要的格式化类型:
public function getNumberFormatter()
{
  if (!$this->numberFormatter) {
    $this->numberFormatter = new NumberFormatter($this->getLocaleCode(), NumberFormatter::DECIMAL);
  }
  return $this->numberFormatter;
}
  1. 然后我们添加一个方法,给定任何数字,将生成一个字符串,该字符串根据区域设置格式化该数字:
public function formatNumber($number)
{
  return $this->getNumberFormatter()->format($number);
}
  1. 接下来,我们添加一个方法,该方法可用于根据区域设置解析数字,生成本机 PHP 数值。请注意,根据服务器的 ICU 版本,结果可能在解析失败时不会返回FALSE
public function parseNumber($string)
{
  $result = $this->getNumberFormatter()->parse($string);
  return ($result) ? $result : self::ERROR_UNABLE_TO_PARSE;
}

它是如何工作的...

按照前面的要点对Application\I18n\Locale类进行添加。然后,您可以创建一个chap_08_formatting_numbers.php文件,其中设置自动加载并使用此类:

<?php
  require __DIR__ . '/../Application/Autoload/Loader.php';
  Application\Autoload\Loader::init(__DIR__ . '/..');
  use Application\I18n\Locale;

为此说明,创建两个Locale实例,一个用于英国,另一个用于法国。您还可以指定一个大数字用于测试:

  $localeFr = new Locale('fr_FR');
  $localeUk = new Locale('en_GB');
  $number   = 1234567.89;
?>

最后,您可以将formatNumber()parseNumber()方法包装在适当的 HTML 显示逻辑中,并查看结果:

<!DOCTYPE html>
<html>
  <head>
    <title>PHP 7 Cookbook</title>
    <meta http-equiv="content-type" content="text/html;charset=utf-8" />
    <link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
  </head>
  <body>
    <table>
      <tr>
        <th>Number</th>
        <td>1234567.89</td>
      </tr>
      <tr>
        <th>French Format</th>
        <td><?= $localeFr->formatNumber($number); ?></td>
      </tr>
      <tr>
        <th>UK Format</th>
        <td><?= $localeUk->formatNumber($number); ?></td>
      </tr>
      <tr>
        <th>UK Parse French Number: <?= $localeFr->formatNumber($number) ?></th>
        <td><?= $localeUk->parseNumber($localeFr->formatNumber($number)); ?></td>
      </tr>
      <tr>
        <th>UK Parse UK Number: <?= $localeUk->formatNumber($number) ?></th>
        <td><?= $localeUk->parseNumber($localeUk->formatNumber($number)); ?></td>
      </tr>
      <tr>
        <th>FR Parse FR Number: <?= $localeFr->formatNumber($number) ?></th>
        <td><?= $localeFr->parseNumber($localeFr->formatNumber($number)); ?></td>
      </tr>
      <tr>
        <th>FR Parse UK Number: <?= $localeUk->formatNumber($number) ?></th>
        <td><?= $localeFr->parseNumber($localeUk->formatNumber($number)); ?></td>
      </tr>
    </table>
  </body>
</html>

以下是从浏览器中看到的结果:

它是如何工作的...

注意

请注意,如果区域设置为fr_FR,则解析时,以英国格式化的数字不会返回正确的值。同样,当区域设置为en_GB时,以法国格式化的数字在解析时也不会返回正确的值。因此,在尝试解析数字之前,您可能需要考虑添加验证检查。

另请参阅

按区域设置处理货币

处理货币的技术与处理数字的技术类似。我们甚至会使用相同的NumberFormatter类!然而,有一个主要区别,这是一个停滞不前的问题:为了正确格式化货币,您需要掌握货币代码。

如何做...

  1. 首要任务是以某种格式使货币代码可用。一种可能性是将货币代码简单地添加为Application\I18n\Locale类的构造函数参数:
const FALLBACK_CURRENCY = 'GBP';
protected $currencyCode;
public function __construct($localeString = NULL, $currencyCode = NULL)
{
  // add this to the existing code:
  $this->currencyCode = $currencyCode ?? self::FALLBACK_CURRENCY;
}

注意

尽管这种方法显然是可靠且可行的,但往往会属于半途而废走捷径的范畴!这种方法也往往会消除完全自动化,因为货币代码无法从 HTTP 标头中获取。正如您可能从本书的其他示例中了解到的,我们不会回避更复杂的解决方案,所以,俗话说得好,系好安全带

  1. 我们首先需要建立某种查找机制,即给定一个国家代码,我们可以获取其主要货币代码。为此说明,我们将使用适配器软件设计模式。根据此模式,我们应该能够创建不同的类,这些类可能以完全不同的方式运行,但产生相同的结果。因此,我们需要定义所需的结果。为此目的,我们引入一个类,Application\I18n\IsoCodes。正如您所看到的,这个类具有所有相关的属性,以及一种类似通用的构造函数:
namespace Application\I18n;
class IsoCodes
{
  public $name;
  public $iso2;
  public $iso3;
  public $iso_numeric;
  public $iso_3166;
  public $currency_name;
  public $currency_code;
  public $currency_number;
  public function __construct(array $data)
  {
    $vars = get_object_vars($this);
    foreach ($vars as $key => $value) {
      $this->$key = $data[$key] ?? NULL;
    }
  }
}
  1. 接下来,我们定义一个接口,其中包含我们需要执行国家代码到货币代码查找的方法。在这种情况下,我们引入Application\I18n\IsoCodesInterface
namespace Application\I18n;

interface IsoCodesInterface
{
  public function getCurrencyCodeFromIso2CountryCode($iso2) : IsoCodes;
}
  1. 现在我们准备构建一个查找适配器类,我们将其称为Application\I18n\IsoCodesDb。它实现了上述接口,并接受一个Application\Database\Connection实例(参见第一章,“建立基础”),用于执行查找。构造函数设置所需的信息,包括连接、查找表名称和表示 ISO2 代码的列。接口所需的查找方法然后发出一个 SQL 语句并返回一个数组,然后用于构建一个IsoCodes实例:
namespace Application\I18n;

use PDO;
use Application\Database\Connection;

class IsoCodesDb implements IsoCodesInterface
{
  protected $isoTableName;
  protected $iso2FieldName;
  protected $connection;
  public function __construct(Connection $connection, $isoTableName, $iso2FieldName)
  {
    $this->connection = $connection;
    $this->isoTableName = $isoTableName;
    $this->iso2FieldName = $iso2FieldName;
  }
  public function getCurrencyCodeFromIso2CountryCode($iso2) : IsoCodes
  {
    $sql = sprintf('SELECT * FROM %s WHERE %s = ?', $this->isoTableName, $this->iso2FieldName);
    $stmt = $this->connection->pdo->prepare($sql);
    $stmt->execute([$iso2]);
    return new IsoCodes($stmt->fetch(PDO::FETCH_ASSOC);
  }
}
  1. 现在我们将注意力转回到Application\I18n\Locale类。我们首先添加了一些新的属性和类常量:
const ERROR_UNABLE_TO_PARSE = 'ERROR: Unable to parse';
const FALLBACK_CURRENCY = 'GBP';

protected $currencyFormatter;
protected $currencyLookup;
protected $currencyCode;
  1. 我们添加了一个新的方法,从区域设置字符串中检索国家代码。我们可以利用来自 PHPLocale类(我们扩展的类)的“getRegion()”方法。以防需要,我们还添加了一个“getCurrencyCode()”方法:
public function getCountryCode()
{
  return $this->getRegion($this->getLocaleCode());
}
public function getCurrencyCode()
{
  return $this->currencyCode;
}
  1. 与格式化数字一样,我们定义了一个“getCurrencyFormatter(I)”,就像我们之前所做的“getNumberFormatter()”一样。请注意,使用NumberFormatter定义了$currencyFormatter,但第二个参数不同:
public function getCurrencyFormatter()
{
  if (!$this->currencyFormatter) {
    $this->currencyFormatter = new NumberFormatter($this->getLocaleCode(), NumberFormatter::CURRENCY);
  }
  return $this->currencyFormatter;
}
  1. 然后,如果已定义查找类,我们将在类构造函数中添加货币代码查找:
public function __construct($localeString = NULL, IsoCodesInterface $currencyLookup = NULL)
{
  // add this to the existing code:
  $this->currencyLookup = $currencyLookup;
  if ($this->currencyLookup) {
    $this->currencyCode = $this->currencyLookup->getCurrencyCodeFromIso2CountryCode($this->getCountryCode())->currency_code;
  } else {
    $this->currencyCode = self::FALLBACK_CURRENCY;
  }
}
  1. 然后添加适当的货币格式和解析方法。请注意,与解析数字不同,如果解析操作不成功,解析货币将返回FALSE
public function formatCurrency($currency)
{
  return $this->getCurrencyFormatter()->formatCurrency($currency, $this->currencyCode);
}
public function parseCurrency($string)
{
  $result = $this->getCurrencyFormatter()->parseCurrency($string, $this->currencyCode);
  return ($result) ? $result : self::ERROR_UNABLE_TO_PARSE;
}

工作原理...

创建以下类,如前面几个要点中所述:

讨论的要点
Application\I18n\IsoCodes3
Application\I18n\IsoCodesInterface4
Application\I18n\IsoCodesDb5

为了说明的目的,我们假设有一个填充了数据的 MySQL 数据库表iso_country_codes,其结构如下:

CREATE TABLE `iso_country_codes` (
  `name` varchar(128) NOT NULL,
  `iso2` varchar(2) NOT NULL,
  `iso3` varchar(3) NOT NULL,
  `iso_numeric` int(11) NOT NULL AUTO_INCREMENT,
  `iso_3166` varchar(32) NOT NULL,
  `currency_name` varchar(32) DEFAULT NULL,
  `currency_code` char(3) DEFAULT NULL,
  `currency_number` int(4) DEFAULT NULL,
  PRIMARY KEY (`iso_numeric`)
) ENGINE=InnoDB AUTO_INCREMENT=895 DEFAULT CHARSET=utf8;

按照之前讨论的要点 6 到 9,对Application\I18n\Locale类进行添加。然后可以创建一个chap_08_formatting_currency.php文件,其中设置自动加载并使用适当的类:

<?php
define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\Locale;
use Application\I18n\IsoCodesDb;
use Application\Database\Connection;
use Application\I18n\Locale;

接下来,我们创建ConnectionIsoCodesDb类的实例:

$connection = new Connection(include DB_CONFIG_FILE);
$isoLookup = new IsoCodesDb($connection, 'iso_country_codes', 'iso2');

为此示例,创建两个Locale实例,一个用于英国,另一个用于法国。您还可以指定一个大数字用于测试:

$localeFr = new Locale('fr-FR', $isoLookup);
$localeUk = new Locale('en_GB', $isoLookup);
$number   = 1234567.89;
?>

最后,您可以将“formatCurrency()”和“parseCurrency()”方法包装在适当的 HTML 显示逻辑中,并查看结果。根据前一个配方中呈现的*工作原理...*部分(此处未重复以节省树木!)制定您的视图逻辑。这是最终输出:

工作原理...

参见

按区域设置格式化日期/时间

日期和时间的格式因地区而异。作为一个经典的例子,考虑 2016 年,4 月,15 日和晚上的时间。美国人民偏好的格式可能是下午 7:23,2016 年 4 月 15 日,而在中国,您很可能会看到 2016-04-15 19:23。与数字和货币格式化一样,以一种对您的网站访问者可接受的格式显示(和解析)日期也很重要。

操作步骤...

  1. 首先,我们需要修改Application\I18n\Locale,添加语句以使用日期格式化类:
use IntlCalendar;
use IntlDateFormatter;
  1. 接下来,我们添加一个属性来表示IntlDateFormatter实例,以及一系列预定义的常量:
const DATE_TYPE_FULL   = IntlDateFormatter::FULL;
const DATE_TYPE_LONG   = IntlDateFormatter::LONG;
const DATE_TYPE_MEDIUM = IntlDateFormatter::MEDIUM;
const DATE_TYPE_SHORT  = IntlDateFormatter::SHORT;

const ERROR_UNABLE_TO_PARSE = 'ERROR: Unable to parse';
const ERROR_UNABLE_TO_FORMAT = 'ERROR: Unable to format date';
const ERROR_ARGS_STRING_ARRAY = 'ERROR: Date must be string YYYY-mm-dd HH:ii:ss or array(y,m,d,h,i,s)';
const ERROR_CREATE_INTL_DATE_FMT = 'ERROR: Unable to create international date formatter';

protected $dateFormatter;
  1. 之后,我们可以定义一个方法getDateFormatter(),它返回一个IntlDateFormatter实例。$type的值与之前定义的DATE_TYPE_*常量之一相匹配:
public function getDateFormatter($type)
{
  switch ($type) {
    case self::DATE_TYPE_SHORT :
      $formatter = new IntlDateFormatter($this->getLocaleCode(),
        IntlDateFormatter::SHORT, IntlDateFormatter::SHORT);
      break;
    case self::DATE_TYPE_MEDIUM :
      $formatter = new IntlDateFormatter($this->getLocaleCode(), IntlDateFormatter::MEDIUM, IntlDateFormatter::MEDIUM);
      break;
    case self::DATE_TYPE_LONG :
      $formatter = new IntlDateFormatter($this->getLocaleCode(), IntlDateFormatter::LONG, IntlDateFormatter::LONG);
      break;
    case self::DATE_TYPE_FULL :
      $formatter = new IntlDateFormatter($this->getLocaleCode(), IntlDateFormatter::FULL, IntlDateFormatter::FULL);
      break;
    default :
      throw new InvalidArgumentException(self::ERROR_CREATE_INTL_DATE_FMT);
  }
  $this->dateFormatter = $formatter;
  return $this->dateFormatter;
}
  1. 接下来,我们定义一个方法,生成一个区域设置格式的日期。定义传入的$date的格式有点棘手。它不能是特定于区域设置的,否则我们将需要根据区域设置规则解析它,结果难以预测。更好的策略是接受一个代表年、月、日等值的整数数组。作为备用方案,我们将接受一个字符串,但只能是这种格式:YYYY-mm-dd HH:ii:ss。时区是可选的,可以单独设置。首先我们初始化变量:
public function formatDate($date, $type, $timeZone = NULL)
{
  $result   = NULL;
  $year     = date('Y');
  $month    = date('m');
  $day      = date('d');
  $hour     = 0;
  $minutes  = 0;
  $seconds  = 0;
  1. 之后,我们生成代表年、月、日等值的值的分解:
if (is_string($date)) {
  list($dateParts, $timeParts) = explode(' ', $date);
  list($year,$month,$day) = explode('-',$dateParts);
  list($hour,$minutes,$seconds) = explode(':',$timeParts);
} elseif (is_array($date)) {
  list($year,$month,$day,$hour,$minutes,$seconds) = $date;
} else {
  throw new InvalidArgumentException(self::ERROR_ARGS_STRING_ARRAY);
}
  1. 接下来,我们创建一个IntlCalendar实例,它将作为运行format()时的参数。我们使用离散的整数值设置日期:
$intlDate = IntlCalendar::createInstance($timeZone, $this->getLocaleCode());
$intlDate->set($year,$month,$day,$hour,$minutes,$seconds);
  1. 最后,我们获得日期格式化程序实例,并生成结果:
  $formatter = $this->getDateFormatter($type);
  if ($timeZone) {
    $formatter->setTimeZone($timeZone);
  }
  $result = $formatter->format($intlDate);
  return $result ?? self::ERROR_UNABLE_TO_FORMAT;
}
  1. parseDate()方法实际上比格式化更简单。唯一的复杂之处在于如果未指定类型要做什么(这可能是最常见的情况)。我们需要做的就是循环遍历所有可能的类型(只有四种),直到产生结果为止:
public function parseDate($string, $type = NULL)
{
 if ($type) {
  $result = $this->getDateFormatter($type)->parse($string);
 } else {
  $tryThese = [self::DATE_TYPE_FULL,
    self::DATE_TYPE_LONG,
    self::DATE_TYPE_MEDIUM,
    self::DATE_TYPE_SHORT];
  foreach ($tryThese as $type) {
  $result = $this->getDateFormatter($type)->parse($string);
    if ($result) {
      break;
    }
  }
 }
 return ($result) ? $result : self::ERROR_UNABLE_TO_PARSE;
}

它是如何工作的...

对之前讨论过的Application\I18n\Locale进行更改。然后,您可以创建一个测试文件chap_08_formatting_date.php,设置自动加载,并创建Locale类的两个实例,一个用于美国,另一个用于法国:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\Locale;

$localeFr = new Locale('fr-FR');
$localeUs = new Locale('en_US');
$date     = '2016-02-29 17:23:58';
?>

接下来,通过合适的样式,运行formatDate()parseDate()的测试:

echo $localeFr->formatDate($date, Locale::DATE_TYPE_FULL);
echo $localeUs->formatDate($date, Locale::DATE_TYPE_MEDIUM);
$localeUs->parseDate($localeFr->formatDate($date, Locale::DATE_TYPE_MEDIUM));
// etc.

这里显示了输出的一个示例:

它是如何工作的...

另请参阅

创建 HTML 国际日历生成器

创建一个显示日历的程序是你在中学时最有可能做的事情。一个嵌套的for()循环,内部循环生成一个七天的列表,通常就足够了。甚至每个月有多少天这个问题也很容易通过一个简单的数组解决。当你需要弄清楚,在任何给定的年份,1 月 1 日是星期几时,情况就会变得棘手起来。还有,如果你想用特定语言和格式表示月份和星期几,符合特定区域设置的话,会怎么样?正如你可能已经猜到的那样,我们将使用之前讨论过的Application\I18n\Locale类构建一个解决方案。

操作步骤...

  1. 首先,我们需要创建一个通用类,用于保存单日的信息。最初,它只会保存一个整数值$dayOfMonth。稍后,在下一个示例中,我们将扩展它以包括事件。由于这个类的主要目的是产生$dayOfMonth,我们将把这个值纳入它的构造函数,并定义__invoke()来返回这个值:
namespace Application\I18n;

class Day
{
  public $dayOfMonth;
  public function __construct($dayOfMonth)
  {
    $this->dayOfMonth = $dayOfMonth;
  }
  public function __invoke()
  {
    return $this->dayOfMonth ?? '';
  }
}
  1. 创建一个新的类,它将保存适当的日历生成方法。它将接受一个Application\I18n\Locale的实例,并定义一些类常量和属性。格式代码,如EEEEEMMMM,是从 ICU 日期格式中提取的:
namespace Application\I18n;

use IntlCalendar;

class Calendar
{

  const DAY_1 = 'EEEEE';  // T
  const DAY_2 = 'EEEEEE'; // Tu
  const DAY_3 = 'EEE';   // Tue
  const DAY_FULL = 'EEEE'; // Tuesday
  const MONTH_1 = 'MMMMM'; // M
  const MONTH_3 = 'MMM';  // Mar
  const MONTH_FULL = 'MMMM';  // March
  const DEFAULT_ACROSS = 3;
  const HEIGHT_FULL = '150px';
  const HEIGHT_SMALL = '60px';

  protected $locale;
  protected $dateFormatter;
  protected $yearArray;
  protected $height;

  public function __construct(Locale $locale)
  {
    $this->locale = $locale;
  }

     // other methods are discussed in the following bullets

}
  1. 然后我们定义一个方法,从我们的locale类中返回一个IntlDateFormatter实例。这将存储在一个类属性中,因为它将经常被使用:
protected function getDateFormatter()
{
 if (!$this->dateFormatter) {
  $this->dateFormatter = $this->locale->getDateFormatter(Locale::DATE_TYPE_FULL);
 }
 return $this->dateFormatter;
}
  1. 接下来,我们定义一个核心方法buildMonthArray(),它创建一个多维数组,其中外部键是一年中的周数,内部数组是表示一周的七个元素的天。我们接受年份、月份和可选的时区作为参数。请注意,在变量初始化的一部分中,我们从月份中减去 1。这是因为IntlCalendar::set()方法期望月份的基于 0 的值,其中 0 代表一月,1 代表二月,依此类推:
public function buildMonthArray($year, $month, $timeZone = NULL)
{
$month -= 1; 
//IntlCalendar months are 0 based; Jan==0, Feb==1 and so on
  $day = 1;
  $first = TRUE;
  $value = 0;
  $monthArray = array();
  1. 然后,我们创建一个IntlCalendar实例,并使用它来确定这个月有多少天:
$cal = IntlCalendar::createInstance($timeZone, $this->locale->getLocaleCode());
$cal->set($year, $month, $day);
$maxDaysInMonth = $cal->getActualMaximum(IntlCalendar::FIELD_DAY_OF_MONTH);
  1. 之后,我们使用我们的IntlDateFormatter实例来确定这个月的第一天是星期几。之后,我们将模式设置为w,随后将给出周数:
$formatter = $this->getDateFormatter();
$formatter->setPattern('e');
$firstDayIsWhatDow = $formatter->format($cal);
  1. 现在我们准备通过嵌套循环遍历该月的所有天。外部的while()循环确保我们不会超过月份的末尾。内部循环表示一周中的天。您会注意到我们利用IntlCalendar::get(),它允许我们从各种预定义字段中检索值。如果一年中的周数超过 52,我们还会将周数值调整为 0:
while ($day <= $maxDaysInMonth) {
  for ($dow = 1; $dow <= 7; $dow++) {
    $cal->set($year, $month, $day);
    $weekOfYear = $cal->get(IntlCalendar::FIELD_WEEK_OF_YEAR);
    if ($weekOfYear > 52) $weekOfYear = 0;
  1. 然后,我们检查$first是否仍然设置为TRUE。如果是,我们开始向数组添加日期。否则,数组值设置为NULL。然后,我们关闭所有打开的语句并返回数组。请注意,我们还需要确保内部循环不会超过月份的天数,因此在外部else子句中有额外的if()语句。

注意

请注意,我们不仅存储月份的值,还使用新定义的Application\I18n\Day类。

      if ($first) {
        if ($dow == $firstDayIsWhatDow) {
          $first = FALSE;
          $value = $day++;
        } else {
          $value = NULL;
        }
      } else {
        if ($day <= $maxDaysInMonth) {
          $value = $day++;
        } else {
          $value = NULL;
        }
      }
      $monthArray[$weekOfYear][$dow] = new Day($value);
    }
  }
  return $monthArray;
}

完善国际化输出

  1. 首先,一系列小方法,从提取基于类型的国际格式化日期开始。类型决定我们是否提供星期几的全名、缩写,或者只是一个字母,都适合该区域设置:
protected function getDay($type, $cal)
{
  $formatter = $this->getDateFormatter();
  $formatter->setPattern($type);
  return $formatter->format($cal);
}
  1. 接下来,我们需要一个方法来返回一个星期几的 HTML 行,调用新定义的getDay()方法。如前所述,类型决定了日期的外观:
protected function getWeekHeaderRow($type, $cal, $year, $month, $week)
{
  $output = '<tr>';
  $width  = (int) (100/7);
  foreach ($week as $day) {
    $cal->set($year, $month, $day());
    $output .= '<th style="vertical-align:top;" width="' . $width . '%">' . $this->getDay($type, $cal) . '</th>';
  }
  $output .= '</tr>' . PHP_EOL;
  return $output;
}
  1. 之后,我们定义一个非常简单的方法来返回一行星期日期。请注意,我们利用Day::__invoke()使用:$day()
protected function getWeekDaysRow($week)
{
  $output = '<tr style="height:' . $this->height . ';">';
  $width  = (int) (100/7);
  foreach ($week as $day) {
    $output .= '<td style="vertical-align:top;" width="' . $width . '%">' . $day() .  '</td>';
  }
  $output .= '</tr>' . PHP_EOL;
  return $output;
}
  1. 最后,一个将较小方法组合在一起生成单个月份日历的方法。首先我们构建月份数组,但只有在$yearArray尚不可用时才这样做:
public function calendarForMonth($year, 
    $month, 
    $timeZone = NULL, 
    $dayType = self::DAY_3, 
    $monthType = self::MONTH_FULL, 
    $monthArray = NULL)
{
  $first = 0;
  if (!$monthArray) 
    $monthArray = $this->yearArray[$year][$month]
    ?? $this->buildMonthArray($year, $month, $timeZone);
  1. 月份需要减去1,因为IntlCalendar的月份是基于 0 的:1 月= 0,2 月= 1,依此类推。然后,我们使用时区(如果有的话)和区域设置构建一个IntlCalendar实例。接下来,我们创建一个IntlDateFormatter实例,根据区域设置检索月份名称和其他信息:
  $month--;
  $cal = IntlCalendar::createInstance($timeZone, $this->locale->getLocaleCode());
  $cal->set($year, $month, 1);
  $formatter = $this->getDateFormatter();
  $formatter->setPattern($monthType);
  1. 然后,我们循环遍历月份数组,并调用刚才提到的较小方法来构建最终的输出:
  $this->height = ($dayType == self::DAY_FULL) 
     ? self::HEIGHT_FULL : self::HEIGHT_SMALL;
  $html = '<h1>' . $formatter->format($cal) . '</h1>';
  $header = '';
  $body   = '';
  foreach ($monthArray as $weekNum => $week) {
    if ($first++ == 1) {
      $header .= $this->getWeekHeaderRow($dayType, $cal, $year, $month, $week);
    }
    $body .= $this->getWeekDaysRow($dayType, $week);
  }
  $html .= '<table>' . $header . $body . '</table>' . PHP_EOL;
  return $html;
}
  1. 为了生成整年的日历,只需循环遍历 1 到 12 月。为了方便外部访问,我们首先定义一个构建年份数组的方法:
public function buildYearArray($year, $timeZone = NULL)
{
  $this->yearArray = array();
  for ($month = 1; $month <= 12; $month++) {
    $this->yearArray[$year][$month] = $this->buildMonthArray($year, $month, $timeZone);
  }
  return $this->yearArray;
}

public function getYearArray()
{
  return $this->yearArray;
}
  1. 要为一年生成日历,我们定义一个方法calendarForYear()。如果年份数组尚未构建,我们调用buildYearArray()。我们考虑要显示多少个月份的日历,然后调用calendarForMonth()
public function calendarForYear($year, 
  $timeZone = NULL, 
  $dayType = self::DAY_1, 
  $monthType = self::MONTH_3, 
  $across = self::DEFAULT_ACROSS)
{
  if (!$this->yearArray) $this->buildYearArray($year, $timeZone);
  $yMax = (int) (12 / $across);
  $width = (int) (100 / $across);
  $output = '<table>' . PHP_EOL;
  $month = 1;
  for ($y = 1; $y <= $yMax; $y++) {
    $output .= '<tr>';
    for ($x = 1; $x <= $across; $x++) {
      $output .= '<td style="vertical-align:top;" width="' . $width . '%">' . $this->calendarForMonth($year, $month, $timeZone, $dayType, $monthType, $this->yearArray[$year][$month++]) . '</td>';
    }
    $output .= '</tr>' . PHP_EOL;
  }
  $output .= '</table>';
  return $output;
}

它是如何工作的...

首先,确保按照前面的示例构建Application\I18n\Locale类。之后,在Application\I18n文件夹中创建一个名为Calendar.php的新文件,其中包含本示例中描述的所有方法。

接下来,定义一个调用程序chap_08_html_calendar.php,设置自动加载并创建LocaleCalendar实例。还要确保定义年份和月份:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\Locale;
use Application\I18n\Calendar;

$localeFr = new Locale('fr-FR');
$localeUs = new Locale('en_US');
$localeTh = new Locale('th_TH');
$calendarFr = new Calendar($localeFr);
$calendarUs = new Calendar($localeUs);
$calendarTh = new Calendar($localeTh);
$year = 2016;
$month = 1;
?>

然后,您可以开发适当的视图逻辑来显示不同的日历。例如,您可以包括参数来显示完整的月份和日期名称:

<!DOCTYPE html>
<html>
  <head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  <link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
  </head>
  <body>
    <h3>Year: <?= $year ?></h3>
    <?= $calendarFr->calendarForMonth($year, $month, NULL, Calendar::DAY_FULL); ?>
    <?= $calendarUs->calendarForMonth($year, $month, NULL, Calendar::DAY_FULL); ?>
    <?= $calendarTh->calendarForMonth($year, $month, NULL, Calendar::DAY_FULL); ?>
  </body>
</html>

它是如何工作的...

通过进行一些修改,您还可以显示整年的日历:

$localeTh = new Locale('th_TH');
$localeEs = new Locale('es_ES');
$calendarTh = new Calendar($localeTh);
$calendarEs = new Calendar($localeEs);
$year = 2016;
echo $calendarTh->calendarForYear($year);
echo $calendarEs->calendarForYear($year);

这是浏览器输出,显示了一个完整的西班牙语年历:

它是如何工作的...

另请参阅

构建一个重复事件生成器

与生成日历相关的一个非常普遍的需求是安排事件。事件可以是一次性事件,发生在一天,或者在周末。然而,更需要跟踪重复事件。我们需要考虑开始日期、重复间隔(每天、每周、每月)以及发生次数或特定的结束日期。

如何做...

  1. 在任何其他事情之前,创建一个表示事件的类将是一个绝妙的主意。最终,您可能会将数据存储在数据库中的这样一个类中。然而,在本示例中,我们将简单地定义类,并将数据库方面留给您的想象力。您会注意到我们将使用DateTime扩展中包含的许多类,这些类非常适合事件生成:
namespace Application\I18n;

use DateTime;
use DatePeriod;
use DateInterval;
use InvalidArgumentException;

class Event
{
  // code
}
  1. 接下来,我们定义一系列有用的类常量和属性。您会注意到,我们将大多数属性定义为public,以节省所需的 getter 和 setter 的数量。间隔被定义为sprintf()格式字符串;%d将被替换为一个值:
const INTERVAL_DAY = 'P%dD';
const INTERVAL_WEEK = 'P%dW';
const INTERVAL_MONTH = 'P%dM';
const FLAG_FIRST = 'FIRST';    // 1st of the month
const ERROR_INVALID_END  = 'Need to supply either # occurrences or an end date';
const ERROR_INVALID_DATE = 'String i.e. YYYY-mm-dd or DateTime instance only';
const ERROR_INVALID_INTERVAL = 'Interval must take the form "P\d+(D | W | M)"';

public $id;
public $flag;
public $value;
public $title;
public $locale;
public $interval;
public $description;
public $occurrences;
public $nextDate;
protected $endDate;
protected $startDate;
  1. 接下来,我们将注意力转向构造函数。我们需要收集和设置与事件相关的所有信息。变量名不言自明。

注意

$value并不是那么清晰。这个参数最终将被替换为间隔格式字符串中的值。因此,例如,如果用户选择$intervalINTERVAL_DAY,并且$value2,则生成的间隔字符串将是P2D,这意味着每隔一天(或每隔 2 天)。

public function __construct($title, 
    $description,
    $startDate,
    $interval,
    $value,
    $occurrences = NULL,
    $endDate = NULL,
    $flag = NULL)
{
  1. 然后我们初始化变量。请注意,ID 是伪随机生成的,但最终可能成为数据库events表中的主键。在这里,我们使用md5()不是出于安全目的,而是为了快速生成哈希,以便 ID 具有一致的外观:
$this->id = md5($title . $interval . $value) . sprintf('%04d', rand(0,9999));
$this->flag = $flag;
$this->value = $value;
$this->title = $title;
$this->description = $description;
$this->occurrences = $occurrences;
  1. 如前所述,间隔参数是一个sprintf()模式,用于构造适当的DateInterval实例:
try {
  $this->interval = new DateInterval(sprintf($interval, $value));
  } catch (Exception $e) {
  error_log($e->getMessage());
  throw new InvalidArgumentException(self::ERROR_INVALID_INTERVAL);
}
  1. 要初始化$startDate,我们调用stringOrDate()。然后,我们尝试通过调用stringOrDate()calcEndDateFromOccurrences()来生成$endDate的值。如果我们既没有结束日期也没有发生次数,就会抛出异常:
  $this->startDate = $this->stringOrDate($startDate);
  if ($endDate) {
    $this->endDate = $this->stringOrDate($endDate);
  } elseif ($occurrences) {
    $this->endDate = $this->calcEndDateFromOccurrences();
  } else {
  throw new InvalidArgumentException(self::ERROR_INVALID_END);
  }
  $this->nextDate = $this->startDate;
}
  1. stringOrDate()方法由几行代码组成,用于检查日期变量的数据类型,并返回DateTime实例或NULL
protected function stringOrDate($date)
{
  if ($date === NULL) { 
    $newDate = NULL;
  } elseif ($date instanceof DateTime) {
    $newDate = $date;
  } elseif (is_string($date)) {
    $newDate = new DateTime($date);
  } else {
    throw new InvalidArgumentException(self::ERROR_INVALID_END);
  }
  return $newDate;
}
  1. 如果设置了$occurrences,我们将从构造函数中调用calcEndDateFromOccurrences()方法,以便我们知道此事件的结束日期。我们利用DatePeriod类,它提供了基于开始日期、DateInterval和发生次数的迭代:
protected function calcEndDateFromOccurrences()
{
  $endDate = new DateTime('now');
  $period = new DatePeriod(
$this->startDate, $this->interval, $this->occurrences);
  foreach ($period as $date) {
    $endDate = $date;
  }
  return $endDate;
}
  1. 接下来,我们加入一个__toString()魔术方法,它简单地回显事件的标题:
public function __toString()
{
  return $this->title;
}
  1. 我们需要为我们的Event类定义的最后一个方法是getNextDate(),在生成日历时使用:
public function  getNextDate(DateTime $today)
{
  if ($today > $this->endDate) {
    return FALSE;
  }
  $next = clone $today;
  $next->add($this->interval);
  return $next;
}
  1. 接下来,我们将注意力转向上一篇食谱中描述的Application\I18n\Calendar类。通过进行一些小的修改,我们准备好将我们新定义的Event类与日历联系起来。首先,我们添加一个新属性$events,以及一个用于以数组形式添加事件的方法。我们使用Event::$id属性来确保事件被合并而不是被覆盖:
protected $events = array();
public function addEvent(Event $event)
{
  $this->events[$event->id] = $event;
}
  1. 接下来,我们添加一个名为processEvents()的方法,该方法在构建年历时将Event实例添加到Day对象中。首先,我们检查是否有任何事件,以及Day对象是否为NULL。您可能还记得,月初可能不是星期的第一天,因此需要将Day对象的值设置为NULL。我们当然不希望将事件添加到一个无效的日期!然后,我们调用Event::getNextDate()并查看日期是否匹配。如果匹配,我们将Event存储到Day::$events[]中,并在Event对象上设置下一个日期:
protected function processEvents($dayObj, $cal)
{
  if ($this->events && $dayObj()) {
    $calDateTime = $cal->toDateTime();
    foreach ($this->events as $id => $eventObj) {
      $next = $eventObj->getNextDate($eventObj->nextDate);
      if ($next) {
        if ($calDateTime->format('Y-m-d') == 
            $eventObj->nextDate->format('Y-m-d')) {
          $dayObj->events[$eventObj->id] = $eventObj;
          $eventObj->nextDate = $next;
        }
      }
    }
  }
  return $dayObj;
}

注意

请注意,我们不直接比较两个对象。这样做的两个原因:首先,一个是DateTime实例,另一个是IntlCalendar实例。另一个更有说服力的原因是,当获取DateTime实例时可能包括小时:分钟:秒,导致两个对象之间的实际值差异。

  1. 现在我们需要在buildMonthArray()方法中添加对processEvents()的调用,使其如下所示:
  while ($day <= $maxDaysInMonth) {
    for ($dow = 1; $dow <= 7; $dow++) {
      // add this to the existing code:
      $dayObj = $this->processEvents(new Day($value), $cal);
      $monthArray[$weekOfYear][$dow] = $dayObj;
    }
  }
  1. 最后,我们需要修改getWeekDaysRow(),添加必要的代码以在框内输出事件信息以及日期:
protected function getWeekDaysRow($type, $week)
{
  $output = '<tr style="height:' . $this->height . ';">';
  $width  = (int) (100/7);
  foreach ($week as $day) {
    $events = '';
    if ($day->events) {
      foreach ($day->events as $single) {
        $events .= '<br>' . $single->title;
        if ($type == self::DAY_FULL) {
          $events .= '<br><i>' . $single->description . '</i>';
        }
      }
    }
    $output .= '<td style="vertical-align:top;" width="' . $width . '%">' 
  . $day() . $events . '</td>';
  }
  $output .= '</tr>' . PHP_EOL;
  return $output;
}

它是如何工作的...

要将事件与日历关联,首先编写步骤 1 到 10 中描述的Application\I18n\Event类。接下来,修改Application\I18n\Calendar,如步骤 11 到 14 中所述。然后,您可以创建一个测试脚本chap_08_recurring_events.php,设置自动加载并创建LocaleCalendar实例。为了说明,继续使用'es_ES'作为区域设置:

<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\ { Locale, Calendar, Event };

try {
  $year = 2016;
  $localeEs = new Locale('es_ES');
  $calendarEs = new Calendar($localeEs);

现在我们可以开始定义并向日历添加事件。第一个示例添加了一个持续 3 天并从 2016 年 1 月 8 日开始的事件:

  // add event: 3 days
  $title = 'Conf';
  $description = 'Special 3 day symposium on eco-waste';
  $startDate = '2016-01-08';
  $event = new Event($title, $description, $startDate, 
                     Event::INTERVAL_DAY, 1, 2);
  $calendarEs->addEvent($event);

以下是另一个示例,即每月 1 日直到 2017 年 9 月发生的事件:

  $title = 'Pay Rent';
  $description = 'Sent rent check to landlord';
  $startDate = new DateTime('2016-02-01');
  $event = new Event($title, $description, $startDate, 
    Event::INTERVAL_MONTH, 1, '2017-09-01', NULL, Event::FLAG_FIRST);
  $calendarEs->addEvent($event);

然后,您可以根据需要添加每周、每两周、每月等样本事件。然后关闭try...catch块,并生成适当的显示逻辑:

} catch (Throwable $e) {
  $message = $e->getMessage();
}
?>
<!DOCTYPE html>
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  <link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
</head>
<body>
<h3>Year: <?= $year ?></h3>
<?= $calendarEs->calendarForYear($year, 'Europe/Berlin', 
    Calendar::DAY_3, Calendar::MONTH_FULL, 2); ?>
<?= $calendarEs->calendarForMonth($year, 1  , 'Europe/Berlin', 
    Calendar::DAY_FULL); ?>
</body>
</html>

以下是显示年初几个月的输出:

它是如何工作的...

另请参阅

处理翻译而不使用 gettext

翻译是使您的网站对国际客户群体可访问的重要部分。实现这一目标的一种方法是使用基于本地服务器上安装的GNU gettext操作系统工具的 PHP gettext函数。gettext有很好的文档和支持,但使用了传统的方法并具有明显的缺点。因此,在本教程中,我们提出了一种替代翻译方法,您可以构建自己的适配器

需要认识到的一点重要的是,PHP 可用的编程翻译工具主要设计为提供单词或短语的有限翻译,称为msgid消息 ID)。翻译的等效物称为msgstr消息字符串)。因此,通常只涉及相对不变的项目,如菜单、表单、错误或成功消息等。在本教程中,我们将假设您已将实际网页翻译存储为文本块。

注意

如果您需要翻译整个页面的内容,您可以考虑使用Google 翻译 API。但这是一个付费服务。或者,您可以使用Amazon Mechanical Turk以廉价的方式将翻译外包给具有多语言技能的个人。有关 URL,请参阅本教程末尾的另请参阅部分。

如何做...

  1. 我们将再次使用适配器软件设计模式,这次是为了提供翻译源的替代方案。在这个示例中,我们将演示.ini文件、.csv文件和数据库的适配器。

  2. 首先,我们将定义一个接口,稍后将用于标识翻译适配器。翻译适配器的要求非常简单,我们只需要为给定的消息 ID 返回一个消息字符串:

namespace Application\I18n\Translate\Adapter;
interface TranslateAdapterInterface
{
  public function translate($msgid);
}
  1. 接下来,我们定义一个与接口匹配的特质。特质将包含实际所需的代码。请注意,如果我们未能找到消息字符串,我们只需返回消息 ID:
namespace Application\I18n\Translate\Adapter;

trait TranslateAdapterTrait
{
  protected $translation;
  public function translate($msgid)
  {
    return $this->translation[$msgid] ?? $msgid;
  }
}
  1. 现在我们准备定义我们的第一个适配器。在这个示例中,我们将从使用.ini文件作为翻译源的适配器开始。您会注意到的第一件事是,我们使用了之前定义的特质。构造方法将在适配器之间有所不同。在这种情况下,我们使用parse_ini_file()来生成一个键/值对数组,其中键是消息 ID。请注意,我们使用$filePattern参数来替换区域设置,然后可以加载适当的翻译文件:
namespace Application\I18n\Translate\Adapter;

use Exception;
use Application\I18n\Locale;

class Ini implements TranslateAdapterInterface
{
  use TranslateAdapterTrait;
  const ERROR_NOT_FOUND = 'Translation file not found';
  public function __construct(Locale $locale, $filePattern)
  {
    $translateFileName = sprintf($filePattern, $locale->getLocaleCode());
    if (!file_exists($translateFileName)) {
      error_log(self::ERROR_NOT_FOUND . ':' . $translateFileName);
      throw new Exception(self::ERROR_NOT_FOUND);
    } else {
      $this->translation = parse_ini_file($translateFileName);
    }
  }
}
  1. 下一个适配器,Application\I18n\Translate\Adapter\Csv,除了打开翻译文件并使用fgetcsv()循环检索消息 ID / 消息字符串键值对外,其他都相同。这里我们只展示构造函数中的区别:
public function __construct(Locale $locale, $filePattern)
{
  $translateFileName = sprintf($filePattern, $locale->getLocaleCode());
  if (!file_exists($translateFileName)) {
    error_log(self::ERROR_NOT_FOUND . ':' . $translateFileName);
    throw new Exception(self::ERROR_NOT_FOUND);
  } else {
    $fileObj = new SplFileObject($translateFileName, 'r');
    while ($row = $fileObj->fgetcsv()) {
      $this->translation[$row[0]] = $row[1];
    }
  }
}

注意

这两个适配器的一个很大的缺点是,我们需要预加载整个翻译集,如果有大量的翻译,这会对内存造成压力。此外,需要打开和解析翻译文件,这会拖慢性能。

  1. 现在我们介绍第三个适配器,它执行数据库查找,避免了其他两个适配器的问题。我们使用一个PDO准备语句,它在开始时发送到数据库,只发送一次。然后我们根据需要执行多次,提供消息 ID 作为参数。您还会注意到,我们需要覆盖特质中定义的translate()方法。最后,您可能已经注意到我们使用了PDOStatement::fetchColumn(),因为我们只需要一个值:
namespace Application\I18n\Translate\Adapter;

use Exception;
use Application\Database\Connection;
use Application\I18n\Locale;

class Database implements TranslateAdapterInterface
{
  use TranslateAdapterTrait;
  protected $connection;
  protected $statement;
  protected $defaultLocaleCode;
  public function __construct(Locale $locale, 
                              Connection $connection, 
                              $tableName)
  {
    $this->defaultLocaleCode = $locale->getLocaleCode();
    $this->connection = $connection;
    $sql = 'SELECT msgstr FROM ' . $tableName 
       . ' WHERE localeCode = ? AND msgid = ?';
    $this->statement = $this->connection->pdo->prepare($sql);
  }
  public function translate($msgid, $localeCode = NULL)
  {
    if (!$localeCode) $localeCode = $this->defaultLocaleCode;
    $this->statement->execute([$localeCode, $msgid]);
    return $this->statement->fetchColumn();
  }
}
  1. 现在我们准备定义核心的Translation类,它与一个(或多个)适配器相关联。我们分配一个类常量来表示默认的区域设置,并为区域设置、适配器和文本文件模式(稍后解释)设置属性:
namespace Application\I18n\Translate;

use Application\I18n\Locale;
use Application\I18n\Translate\Adapter\TranslateAdapterInterface;

class Translation
{
  const DEFAULT_LOCALE_CODE = 'en_GB';
  protected $defaultLocaleCode;
  protected $adapter = array();
  protected $textFilePattern = array();
  1. 在构造函数中,我们确定区域设置,并将初始适配器设置为此区域设置。通过这种方式,我们能够托管多个适配器:
public function __construct(TranslateAdapterInterface $adapter, 
              $defaultLocaleCode = NULL, 
              $textFilePattern = NULL)
{
  if (!$defaultLocaleCode) {
    $this->defaultLocaleCode = self::DEFAULT_LOCALE_CODE;
  } else {
    $this->defaultLocaleCode = $defaultLocaleCode;
  }
  $this->adapter[$this->defaultLocaleCode] = $adapter;
  $this->textFilePattern[$this->defaultLocaleCode] = $textFilePattern;
}
  1. 接下来,我们定义一系列的 setter,这给了我们更多的灵活性:
public function setAdapter($localeCode, TranslateAdapterInterface $adapter)
{
  $this->adapter[$localeCode] = $adapter;
}
public function setDefaultLocaleCode($localeCode)
{
  $this->defaultLocaleCode = $localeCode;
}
public function setTextFilePattern($localeCode, $pattern)
{
  $this->textFilePattern[$localeCode] = $pattern;
}
  1. 然后,我们定义了 PHP 魔术方法__invoke(),它让我们可以直接调用翻译实例,返回给定消息 ID 的消息字符串:
public function __invoke($msgid, $locale = NULL)
{
  if ($locale === NULL) $locale = $this->defaultLocaleCode;
  return $this->adapter[$locale]->translate($msgid);
}
  1. 最后,我们还添加了一个方法,可以从文本文件中返回翻译的文本块。请记住,这可以修改为使用数据库。我们没有在适配器中包含这个功能,因为它的目的完全不同;我们只想根据一个键返回大块代码,这个键可能是翻译文本文件的文件名:
public function text($key, $localeCode = NULL)
{
  if ($localeCode === NULL) $localeCode = $this->defaultLocaleCode;
  $contents = $key;
  if (isset($this->textFilePattern[$localeCode])) {
    $fn = sprintf($this->textFilePattern[$localeCode], $localeCode, $key);
    if (file_exists($fn)) {
      $contents = file_get_contents($fn);
    }
  }
  return $contents;
}

它是如何工作的...

首先,您需要定义一个目录结构来存放翻译文件。为了说明的目的,您可以创建一个目录,/path/to/project/files/data/languages。在这个目录结构下,创建代表不同区域设置的子目录。对于这个示例,您可以使用这些:de_DEfr_FRen_GBes_ES,分别代表德语、法语、英语和西班牙语。

接下来,您需要创建不同的翻译文件。例如,这是一个代表西班牙语的data/languages/es_ES/translation.ini文件:

Welcome=Bienvenido
About Us=Sobre Nosotros
Contact Us=Contáctenos
Find Us=Encontrarnos
click=clic para más información

同样,为了演示 CSV 适配器,创建一个相同的 CSV 文件,data/languages/es_ES/translation.csv

"Welcome","Bienvenido"
"About Us","Sobre Nosotros"
"Contact Us","Contáctenos"
"Find Us","Encontrarnos"
"click","clic para más información"

最后,创建一个名为translation的数据库表,并用相同的数据填充它。主要区别在于数据库表将具有三个字段:msgidmsgstrlocale_code

CREATE TABLE `translation` (
  `msgid` varchar(255) NOT NULL,
  `msgstr` varchar(255) NOT NULL,
  `locale_code` char(6) NOT NULL DEFAULT '',
  PRIMARY KEY (`msgid`,`locale_code`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

接下来,使用本教程中显示的代码定义先前提到的类:

  • Application\I18n\Translate\Adapter\TranslateAdapterInterface

  • Application\I18n\Translate\Adapter\TranslateAdapterTrait

  • Application\I18n\Translate\Adapter\Ini

  • Application\I18n\Translate\Adapter\Csv

  • Application\I18n\Translate\Adapter\Database

  • Application\I18n\Translate\Translation

现在,您可以创建一个名为chap_08_translation_database.php的测试文件,以测试数据库翻译适配器。它应该实现自动加载,使用适当的类,并创建LocaleConnection实例。请注意,TEXT_FILE_PATTERN常量是一个sprintf()模式,其中区域代码和文件名被替换:

<?php
define('DB_CONFIG_FILE', '/../config/db.config.php');
define('TEXT_FILE_PATTERN', __DIR__ . '/../data/languages/%s/%s.txt');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\Locale;
use Application\I18n\Translate\ { Translation, Adapter\Database };
use Application\Database\Connection;

$conn = new Connection(include __DIR__ . DB_CONFIG_FILE);
$locale = new Locale('fr_FR');

接下来,创建一个翻译适配器实例,并使用它来创建一个Translation实例:

$adapter = new Database($locale, $conn, 'translation');
$translate = new Translation($adapter, $locale->getLocaleCode(), TEXT_FILE_PATTERN);
?>

最后,创建使用$translate实例的显示逻辑:

<!DOCTYPE html>
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
  <link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
</head>
<body>
<table>
<tr>
  <th><h1 style="color:white;"><?= $translate('Welcome') ?></h1></th>
  <td>
    <div style="float:left;width:50%;vertical-align:middle;">
    <h3 style="font-size:24pt;"><i>Some Company, Inc.</i></h3>
    </div>
    <div style="float:right;width:50%;">
    <img src="jcartier-city.png" width="300px"/>
    </div>
  </td>
</tr>
<tr>
  <th>
    <ul>
      <li><?= $translate('About Us') ?></li>
      <li><?= $translate('Contact Us') ?></li>
      <li><?= $translate('Find Us') ?></li>
    </ul>
  </th>
  <td>
    <p>
    <?= $translate->text('main_page'); ?>
    </p>
    <p>
    <a href="#"><?= $translate('click') ?></a>
    </p>
  </td>
</tr>
</table>
</body>
</html>

然后,您可以执行其他类似的测试,替换新的区域设置以获得不同的语言,或者使用另一个适配器来测试不同的数据源。以下是使用fr_FR区域设置和数据库翻译适配器的输出示例:

工作原理...

另请参阅

第九章:开发中间件

在本章中,我们将涵盖以下主题:

  • 使用中间件进行身份验证

  • 使用中间件实现访问控制

  • 使用缓存来提高性能

  • 实现路由

  • 进行跨框架系统调用

  • 使用中间件跨语言

介绍

在 IT 行业中经常发生的情况是,术语被创造出来,然后被使用和滥用。术语中间件也不例外。可以说,这个术语最早是在 2000 年由互联网工程任务组IETF)提出的。最初,这个术语是用于指代在传输层(即 TCP/IP)和应用层之间运行的任何软件。最近,特别是随着PHP 标准推荐编号 7PSR-7)的接受,中间件,特别是在 PHP 世界中,已经被应用到了 Web 客户端-服务器环境中。

注意

本节中的配方将使用附录中定义的具体类,定义 PSR-7 类

使用中间件进行身份验证

中间件的一个非常重要的用途是提供身份验证。大多数基于 Web 的应用程序都需要通过用户名和密码验证访问者的能力。通过将 PSR-7 标准纳入身份验证类,您将使其在各个方面都具有通用性,可以说是足够安全,可以在提供 PSR-7 兼容请求和响应对象的任何框架中使用。

操作步骤...

  1. 我们首先定义一个 Application\Acl\AuthenticateInterface 类。我们使用这个接口来支持适配器软件设计模式,通过允许各种适配器,使我们的 Authenticate 类更具通用性,每个适配器都可以从不同的来源(例如,从文件中,使用 OAuth2 等)获取身份验证。请注意使用 PHP 7 定义返回值数据类型的能力:
namespace Application\Acl;
use Psr\Http\Message\ { RequestInterface, ResponseInterface };
interface AuthenticateInterface
{
  public function login(RequestInterface $request) : 
    ResponseInterface;
}

注意

请注意,通过定义一个需要符合 PSR-7 的请求并生成符合 PSR-7 的响应的方法,我们使得此接口具有普遍适用性。

  1. 接下来,我们定义实现接口所需的 login() 方法的适配器。我们确保使用适当的类,并定义适合的常量和属性。构造函数使用在第五章中定义的 Application\Database\Connection
namespace Application\Acl;
use PDO;
use Application\Database\Connection;
use Psr\Http\Message\ { RequestInterface, ResponseInterface };
use Application\MiddleWare\ { Response, TextStream };
class DbTable  implements AuthenticateInterface
{
  const ERROR_AUTH = 'ERROR: authentication error';
  protected $conn;
  protected $table;
  public function __construct(Connection $conn, $tableName)
  {
    $this->conn = $conn;
    $this->table = $tableName;
  }
  1. 核心 login() 方法从请求对象中提取用户名和密码。然后我们进行直接的数据库查找。如果匹配成功,我们将用户信息存储在响应主体中,以 JSON 编码:
public function login(RequestInterface $request) : 
  ResponseInterface
{
  $code = 401;
  $info = FALSE;
  $body = new TextStream(self::ERROR_AUTH);
  $params = json_decode($request->getBody()->getContents());
  $response = new Response();
  $username = $params->username ?? FALSE;
  if ($username) {
      $sql = 'SELECT * FROM ' . $this->table 
        . ' WHERE email = ?';
      $stmt = $this->conn->pdo->prepare($sql);
      $stmt->execute([$username]);
      $row = $stmt->fetch(PDO::FETCH_ASSOC);
      if ($row) {
          if (password_verify($params->password, 
              $row['password'])) {
                unset($row['password']);
                $body = 
                new TextStream(json_encode($row));
                $response->withBody($body);
                $code = 202;
                $info = $row;
              }
            }
          }
          return $response->withBody($body)->withStatus($code);
        }
      }

提示

最佳实践

永远不要以明文形式存储密码。当您需要进行密码匹配时,请使用 password_verify(),这样就不需要再生成密码哈希。

  1. Authenticate 类是一个实现 AuthenticationInterface 的适配器类的包装器。因此,构造函数接受一个适配器类作为参数,以及一个字符串作为密钥,在其中身份验证信息存储在 $_SESSION 中:
namespace Application\Acl;
use Application\MiddleWare\ { Response, TextStream };
use Psr\Http\Message\ { RequestInterface, ResponseInterface };
class Authenticate
{
  const ERROR_AUTH = 'ERROR: invalid token';
  const DEFAULT_KEY = 'auth';
  protected $adapter;
  protected $token;
  public function __construct(
  AuthenticateInterface $adapter, $key)
  {
    $this->key = $key;
    $this->adapter = $adapter;
  }
  1. 此外,我们提供了一个带有安全令牌的登录表单,可以帮助防止跨站点请求伪造CSRF)攻击:
public function getToken()
{
  $this->token = bin2hex(random_bytes(16));
  $_SESSION['token'] = $this->token;
  return $this->token;
}
public function matchToken($token)
{
  $sessToken = $_SESSION['token'] ?? date('Ymd');
  return ($token == $sessToken);
}
public function getLoginForm($action = NULL)
{
  $action = ($action) ? 'action="' . $action . '" ' : '';
  $output = '<form method="post" ' . $action . '>';
  $output .= '<table><tr><th>Username</th><td>';
  $output .= '<input type="text" name="username" /></td>';
  $output .= '</tr><tr><th>Password</th><td>';
  $output .= '<input type="password" name="password" />';
  $output .= '</td></tr><tr><th>&nbsp;</th>';
  $output .= '<td><input type="submit" /></td>';
  $output .= '</tr></table>';
  $output .= '<input type="hidden" name="token" value="';
  $output .= $this->getToken() . '" />';
  $output .= '</form>';
  return $output;
}
  1. 最后,此类中的 login() 方法将检查令牌是否有效。如果无效,则返回 400 响应。否则,调用适配器的 login() 方法:
public function login(
RequestInterface $request) : ResponseInterface
{
  $params = json_decode($request->getBody()->getContents());
  $token = $params->token ?? FALSE;
  if (!($token && $this->matchToken($token))) {
      $code = 400;
      $body = new TextStream(self::ERROR_AUTH);
      $response = new Response($code, $body);
  } else {
      $response = $this->adapter->login($request);
  }
  if ($response->getStatusCode() >= 200
      && $response->getStatusCode() < 300) {
      $_SESSION[$this->key] = 
        json_decode($response->getBody()->getContents());
  } else {
      $_SESSION[$this->key] = NULL;
  }
  return $response;
}

}

工作原理...

首先,请确保遵循附录中定义的配方。接下来,继续定义本配方中介绍的类,总结如下表所示:

在这些步骤中讨论
Application\Acl\AuthenticateInterface1
Application\Acl\DbTable2 - 3
Application\Acl\Authenticate4 - 6

然后,您可以定义一个 chap_09_middleware_authenticate.php 调用程序,设置自动加载并使用适当的类:

<?php
session_start();
define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
define('DB_TABLE', 'customer_09');
define('SESSION_KEY', 'auth');
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');

use Application\Database\Connection;
use Application\Acl\ { DbTable, Authenticate };
use Application\MiddleWare\ { ServerRequest, Request, Constants, TextStream };

现在您可以设置身份验证适配器和核心类了:

$conn   = new Connection(include DB_CONFIG_FILE);
$dbAuth = new DbTable($conn, DB_TABLE);
$auth   = new Authenticate($dbAuth, SESSION_KEY);

确保初始化传入请求,并设置要发送到身份验证类的请求:

$incoming = new ServerRequest();
$incoming->initialize();
$outbound = new Request();

检查传入的类方法是否为POST。如果是,将请求传递给身份验证类:

if ($incoming->getMethod() == Constants::METHOD_POST) {
  $body = new TextStream(json_encode(
  $incoming->getParsedBody()));
  $response = $auth->login($outbound->withBody($body));
}
$action = $incoming->getServerParams()['PHP_SELF'];
?>

显示逻辑如下:

<?= $auth->getLoginForm($action) ?>

这是一个无效身份验证尝试的输出。请注意右侧的401状态代码。在这个示例中,您可以添加对响应对象的var_dump()

工作原理...

这是一个成功的身份验证:

工作原理...

另请参阅

有关如何避免 CSRF 和其他攻击的指导,请参阅第十二章 提高 Web 安全性

使用中间件实现访问控制

顾名思义,中间件位于一系列函数或方法调用的中间。因此,中间件非常适合“门卫”的任务。您可以使用一个中间件类轻松实现访问控制列表ACL)机制,该类读取 ACL 并允许或拒绝对序列中下一个函数或方法调用的访问。

如何做...

  1. 这个过程中可能最困难的部分是确定 ACL 中要包括哪些因素。为了说明,假设我们的用户都被分配了一个level和一个status。在这个示例中,level 的定义如下:
  'levels' => [0, 'BEG', 'INT', 'ADV']
  1. 状态可能表示他们在会员注册过程中的进展。例如,状态为0可能表示他们已启动会员注册过程,但尚未确认。状态为1可能表示他们的电子邮件地址已确认,但他们尚未支付月费,依此类推。

  2. 接下来,我们需要定义我们计划控制的资源。在这种情况下,我们将假设有必要控制对站点上一系列网页的访问。因此,我们需要定义一个这样的资源数组。在 ACL 中,我们可以引用键:

'pages'  => [0 => 'sorry', 'logout' => 'logout', 'login'  => 'auth',
             1 => 'page1', 2 => 'page2', 3 => 'page3',
             4 => 'page4', 5 => 'page5', 6 => 'page6',
             7 => 'page7', 8 => 'page8', 9 => 'page9']
  1. 最后,最重要的配置部分是根据levelstatus对页面进行分配。配置数组中使用的通用模板可能如下所示:
status => ['inherits' => <key>, 'pages' => [level => [pages allowed], etc.]]
  1. 现在我们可以定义Acl类了。与以前一样,我们使用了一些类,并定义了适用于访问控制的常量和属性:
namespace Application\Acl;

use InvalidArgumentException;
use Psr\Http\Message\RequestInterface;
use Application\MiddleWare\ { Constants, Response, TextStream };

class Acl
{
  const DEFAULT_STATUS = '';
  const DEFAULT_LEVEL  = 0;
  const DEFAULT_PAGE   = 0;
  const ERROR_ACL = 'ERROR: authorization error';
  const ERROR_APP = 'ERROR: requested page not listed';
  const ERROR_DEF = 
    'ERROR: must assign keys "levels", "pages" and "allowed"';
  protected $default;
  protected $levels;
  protected $pages;
  protected $allowed; 
  1. __construct()方法中,我们将分配数组分解为$pages(要控制的资源)、$levels$allowed(实际分配)。如果数组不包括这三个子组件中的一个,就会抛出异常:
public function __construct(array $assignments)
{
  $this->default = $assignments['default'] 
    ?? self::DEFAULT_PAGE;
  $this->pages   = $assignments['pages'] ?? FALSE;
  $this->levels  = $assignments['levels'] ?? FALSE;
  $this->allowed = $assignments['allowed'] ?? FALSE;
  if (!($this->pages && $this->levels && $this->allowed)) {
      throw new InvalidArgumentException(self::ERROR_DEF);
  }
}
  1. 您可能已经注意到我们允许继承。在$allowed中,inherits键可以设置为数组中的另一个键。如果是这样,我们需要将其值与当前正在检查的值合并。我们通过反向迭代$allowed,每次循环都合并任何继承的值。顺便说一句,这种方法也只隔离适用于特定statuslevel的规则:
protected function mergeInherited($status, $level)
{
  $allowed = $this->allowed[$status]['pages'][$level] 
    ?? array();
  for ($x = $status; $x > 0; $x--) {
    $inherits = $this->allowed[$x]['inherits'];
    if ($inherits) {
        $subArray = 
          $this->allowed[$inherits]['pages'][$level] 
          ?? array();
        $allowed = array_merge($allowed, $subArray);
    }
  }
  return $allowed;
}
  1. 在处理授权时,我们初始化了一些变量,然后从原始请求 URI 中提取了请求的页面。如果页面参数不存在,我们设置了400代码:
public function isAuthorized(RequestInterface $request)
{
  $code = 401;    // unauthorized
  $text['page'] = $this->pages[$this->default];
  $text['authorized'] = FALSE;
  $page = $request->getUri()->getQueryParams()['page'] 
    ?? FALSE;
  if ($page === FALSE) {
      $code = 400;    // bad request
  1. 否则,我们解码请求体内容,并获取statuslevel。然后我们可以调用mergeInherited(),它返回一个对此statuslevel可访问的页面数组:
} else {
    $params = json_decode(
      $request->getBody()->getContents());
    $status = $params->status ?? self::DEFAULT_LEVEL;
    $level  = $params->level  ?? '*';
    $allowed = $this->mergeInherited($status, $level);
  1. 如果请求的页面在$allowed数组中,我们将状态代码设置为200,并返回一个授权设置,以及与请求的页面代码对应的网页:
if (in_array($page, $allowed)) {
    $code = 200;    // OK
    $text['authorized'] = TRUE;
    $text['page'] = $this->pages[$page];
} else {
    $code = 401;            }
}
  1. 然后我们返回响应,以 JSON 编码,完成:
$body = new TextStream(json_encode($text));
return (new Response())->withStatus($code)
->withBody($body);
}

}

工作原理...

之后,您需要定义Application\Acl\Acl,这在本示例中进行了讨论。现在转到/path/to/source/for/this/chapter文件夹并创建两个目录:publicpages。在pages中,创建一系列 PHP 文件,例如page1.phppage2.php等。以下是其中一个页面的示例:

<?php // page 1 ?>
<h1>Page 1</h1>
<hr>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. etc.</p>

您还可以定义一个menu.php页面,该页面可以包含在输出中:

<?php // menu ?>
<a href="?page=1">Page 1</a>
<a href="?page=2">Page 2</a>
<a href="?page=3">Page 3</a>
// etc.

logout.php页面应销毁会话:

<?php
  $_SESSION['info'] = FALSE;
  session_destroy();
?>
<a href="/">BACK</a>

auth.php页面将显示登录屏幕(如前一示例中所述):

<?= $auth->getLoginForm($action) ?>

然后,您可以创建一个配置文件,根据级别和状态允许访问网页。为了举例说明,将其命名为chap_09_middleware_acl_config.php并返回一个类似于以下内容的数组:

<?php
$min = [0, 'logout'];
return [
  'default' => 0,     // default page
  'levels' => [0, 'BEG', 'INT', 'ADV'],
  'pages'  => [0 => 'sorry', 
  'logout' => 'logout', 
  'login' => 'auth',
               1 => 'page1', 2 => 'page2', 3 => 'page3',
               4 => 'page4', 5 => 'page5', 6 => 'page6',
               7 => 'page7', 8 => 'page8', 9 => 'page9'],
  'allowed' => [
               0 => ['inherits' => FALSE,
                     'pages' => [ '*' => $min, 'BEG' => $min,
                     'INT' => $min,'ADV' => $min]],
               1 => ['inherits' => FALSE,
                     'pages' => ['*' => ['logout'],
                    'BEG' => [1, 'logout'],
                    'INT' => [1,2, 'logout'],
                    'ADV' => [1,2,3, 'logout']]],
               2 => ['inherits' => 1,
                     'pages' => ['BEG' => [4],
                     'INT' => [4,5],
                     'ADV' => [4,5,6]]],
               3 => ['inherits' => 2,
                     'pages' => ['BEG' => [7],
                     'INT' => [7,8],
                     'ADV' => [7,8,9]]]
    ]
];

最后,在public文件夹中,定义index.php,该文件设置自动加载,并最终调用AuthenticateAcl类。与其他示例一样,定义配置文件,设置自动加载,并使用某些类。还要记得启动会话:

<?php
session_start();
session_regenerate_id();
define('DB_CONFIG_FILE', __DIR__ . '/../../config/db.config.php');
define('DB_TABLE', 'customer_09');
define('PAGE_DIR', __DIR__ . '/../pages');
define('SESSION_KEY', 'auth');
require __DIR__ . '/../../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/../..');

use Application\Database\Connection;
use Application\Acl\ { Authenticate, Acl };
use Application\MiddleWare\ { ServerRequest, Request, Constants, TextStream };

提示

最佳实践

保护会话是最佳实践。帮助保护会话的一种简单方法是使用session_regenerate_id(),它使现有的 PHP 会话标识无效并生成一个新的标识。因此,如果攻击者通过非法手段获得会话标识符,任何给定会话标识符有效的时间窗口将被最小化。

现在您可以拉取 ACL 配置,并为AuthenticateAcl创建实例:

$config = require __DIR__ . '/../chap_09_middleware_acl_config.php';
$acl    = new Acl($config);
$conn   = new Connection(include DB_CONFIG_FILE);
$dbAuth = new DbTable($conn, DB_TABLE);
$auth   = new Authenticate($dbAuth, SESSION_KEY);

接下来,定义传入和传出请求实例:

$incoming = new ServerRequest();
$incoming->initialize();
$outbound = new Request();

如果传入的请求方法是post,则调用login()方法处理身份验证:

if (strtolower($incoming->getMethod()) == Constants::METHOD_POST) {
    $body = new TextStream(json_encode(
    $incoming->getParsedBody()));
    $response = $auth->login($outbound->withBody($body));
}

如果为身份验证定义的会话密钥已填充,则表示用户已成功验证。如果没有,我们将编写一个名为later的匿名函数,其中包含身份验证登录页面:

$info = $_SESSION[SESSION_KEY] ?? FALSE;
if (!$info) {
    $execute = function () use ($auth) {
      include PAGE_DIR . '/auth.php';
    };

否则,您可以继续进行 ACL 检查。您首先需要从原始查询中找到用户想要访问的网页,但是:

} else {
    $query = $incoming->getServerParams()['QUERY_STRING'] ?? '';

然后,您可以重新编程$outbound请求以包含此信息:

$outbound->withBody(new TextStream(json_encode($info)));
$outbound->getUri()->withQuery($query);

接下来,您将能够检查授权,提供传出请求作为参数:

$response = $acl->isAuthorized($outbound);

然后,您可以检查authorized参数的返回响应,并编写匿名函数以包含返回的page参数(如果 OK),以及否则包含sorry页面:

$params   = json_decode($response->getBody()->getContents());
$isAllowed = $params->authorized ?? FALSE;
if ($isAllowed) {
    $execute = function () use ($response, $params) {
      include PAGE_DIR .'/' . $params->page . '.php';
      echo '<pre>', var_dump($response), '</pre>';
      echo '<pre>', var_dump($_SESSION[SESSION_KEY]);
      echo '</pre>';
    };
} else {
    $execute = function () use ($response) {
      include PAGE_DIR .'/sorry.php';
      echo '<pre>', var_dump($response), '</pre>';
      echo '<pre>', var_dump($_SESSION[SESSION_KEY]);
      echo '</pre>';
    };
}
}

现在,您只需要设置表单操作并在 HTML 中包装匿名函数:

$action = $incoming->getServerParams()['PHP_SELF'];
?>
<!DOCTYPE html>
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" content="text/html;charset=utf-8" />
</head>
<body>
  <?php $execute(); ?>
</body>
</html>

要测试它,您可以使用内置的 PHP Web 服务器,但是您需要使用-t标志指示文档根目录为public

**cd /path/to/source/for/this/chapter**
**php -S localhost:8080 -t public**

从浏览器中,您可以访问http://localhost:8080/ URL。

如果您尝试访问任何页面,您将被重定向回登录页面。根据配置,具有状态=1和级别=BEG的用户只能访问页面1并注销。如果以此用户身份登录,尝试访问页面 2,则输出如下:

它是如何工作的...

另请参阅

一旦用户登录,此示例依赖于$_SESSION作为用户身份验证的唯一手段。有关如何保护 PHP 会话的良好示例,请参见第十二章提高 Web 安全性,特别是名为保护 PHP 会话的示例。

使用缓存提高性能

缓存软件设计模式是存储需要很长时间才能生成的结果的地方。这可以采用漫长的视图脚本或复杂的数据库查询的形式。当然,存储目的地需要具有高性能,如果您希望提高网站访问者的用户体验。由于不同的安装将具有不同的潜在存储目标,因此缓存机制也适用于适配器模式。潜在存储目标的示例包括内存、数据库和文件系统。

如何做...

  1. 与本章中的其他一些配方一样,由于有共享的常量,我们定义了一个独立的Application\Cache\Constants类:
<?php
namespace Application\Cache;

class Constants
{
  const DEFAULT_GROUP  = 'default';
  const DEFAULT_PREFIX = 'CACHE_';
  const DEFAULT_SUFFIX = '.cache';
  const ERROR_GET      = 'ERROR: unable to retrieve from cache';
  // not all constants are shown to conserve space
}
  1. 由于我们遵循适配器设计模式,接下来我们定义一个接口:
namespace Application\Cache;
interface  CacheAdapterInterface
{
  public function hasKey($key);
  public function getFromCache($key, $group);
  public function saveToCache($key, $data, $group);
  public function removeByKey($key);
  public function removeByGroup($group);
}
  1. 现在我们准备定义我们的第一个缓存适配器,在这个示例中,我们使用 MySQL 数据库。我们需要定义将保存列名和准备语句的属性:
namespace Application\Cache;
use PDO;
use Application\Database\Connection;
class Database implements CacheAdapterInterface
{
  protected $sql;
  protected $connection;
  protected $table;
  protected $dataColumnName;
  protected $keyColumnName;
  protected $groupColumnName;
  protected $statementHasKey       = NULL;
  protected $statementGetFromCache = NULL;
  protected $statementSaveToCache  = NULL;
  protected $statementRemoveByKey  = NULL;
  protected $statementRemoveByGroup= NULL;
  1. 构造函数允许我们提供键列名以及Application\Database\Connection实例和用于缓存的表的名称:
public function __construct(Connection $connection,
  $table,
  $idColumnName,
  $keyColumnName,
  $dataColumnName,
  $groupColumnName = Constants::DEFAULT_GROUP)
  {
    $this->connection  = $connection;
    $this->setTable($table);
    $this->setIdColumnName($idColumnName);
    $this->setDataColumnName($dataColumnName);
    $this->setKeyColumnName($keyColumnName);
    $this->setGroupColumnName($groupColumnName);
  }
  1. 接下来的几个方法准备语句,并在访问数据库时调用。我们没有展示所有的方法,但呈现足够的内容来给你一个想法:
public function prepareHasKey()
{
  $sql = 'SELECT `' . $this->idColumnName . '` '
  . 'FROM `'   . $this->table . '` '
  . 'WHERE `'  . $this->keyColumnName . '` = :key ';
  $this->sql[__METHOD__] = $sql;
  $this->statementHasKey = 
  $this->connection->pdo->prepare($sql);
}
public function prepareGetFromCache()
{
  $sql = 'SELECT `' . $this->dataColumnName . '` '
  . 'FROM `'   . $this->table . '` '
  . 'WHERE `'  . $this->keyColumnName . '` = :key '
  . 'AND `'    . $this->groupColumnName . '` = :group';
  $this->sql[__METHOD__] = $sql;
  $this->statementGetFromCache = 
  $this->connection->pdo->prepare($sql);
}
  1. 现在我们定义一个确定给定键的数据是否存在的方法:
public function hasKey($key)
{
  $result = 0;
  try {
      if (!$this->statementHasKey) $this->prepareHasKey();
          $this->statementHasKey->execute(['key' => $key]);
  } catch (Throwable $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      throw new Exception(Constants::ERROR_REMOVE_KEY);
  }
  return (int) $this->statementHasKey
  ->fetch(PDO::FETCH_ASSOC)[$this->idColumnName];
}
  1. 核心方法是从缓存中读取和写入的方法。这是从缓存中检索的方法。我们只需要执行准备好的语句,执行SELECT,带有WHERE子句,其中包括键和组:
public function getFromCache(
$key, $group = Constants::DEFAULT_GROUP)
{
  try {
      if (!$this->statementGetFromCache) 
          $this->prepareGetFromCache();
          $this->statementGetFromCache->execute(
            ['key' => $key, 'group' => $group]);
          while ($row = $this->statementGetFromCache
            ->fetch(PDO::FETCH_ASSOC)) {
            if ($row && count($row)) {
                yield unserialize($row[$this->dataColumnName]);
            }
          }
  } catch (Throwable $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      throw new Exception(Constants::ERROR_GET);
  }
}
  1. 写入缓存时,我们首先确定是否存在该缓存键的条目。如果是,我们执行UPDATE;否则,我们执行INSERT
public function saveToCache($key, $data, $group = Constants::DEFAULT_GROUP)
{
  $id = $this->hasKey($key);
  $result = 0;
  try {
      if ($id) {
          if (!$this->statementUpdateCache) 
              $this->prepareUpdateCache();
              $result = $this->statementUpdateCache
              ->execute(['key' => $key, 
              'data' => serialize($data), 
              'group' => $group, 
              'id' => $id]);
          } else {
              if (!$this->statementSaveToCache) 
              $this->prepareSaveToCache();
              $result = $this->statementSaveToCache
              ->execute(['key' => $key, 
              'data' => serialize($data), 
              'group' => $group]);
          }
      } catch (Throwable $e) {
          error_log(__METHOD__ . ':' . $e->getMessage());
          throw new Exception(Constants::ERROR_SAVE);
      }
      return $result;
   }
  1. 然后我们定义了两种方法,通过键或组来删除缓存。通过组删除提供了一个方便的机制,如果有大量需要删除的项目:
public function removeByKey($key)
{
  $result = 0;
  try {
      if (!$this->statementRemoveByKey) 
      $this->prepareRemoveByKey();
      $result = $this->statementRemoveByKey->execute(
        ['key' => $key]);
  } catch (Throwable $e) {
      error_log(__METHOD__ . ':' . $e->getMessage());
      throw new Exception(Constants::ERROR_REMOVE_KEY);
  }
  return $result;
}

public function removeByGroup($group)
{
  $result = 0;
  try {
      if (!$this->statementRemoveByGroup) 
          $this->prepareRemoveByGroup();
          $result = $this->statementRemoveByGroup->execute(
            ['group' => $group]);
      } catch (Throwable $e) {
          error_log(__METHOD__ . ':' . $e->getMessage());
          throw new Exception(Constants::ERROR_REMOVE_GROUP);
      }
      return $result;
  }
  1. 最后,我们为每个属性定义获取器和设置器。这里没有展示所有的内容以节省空间:
public function setTable($name)
{
  $this->table = $name;
}
public function getTable()
{
  return $this->table;
}
// etc.
}
  1. 文件系统缓存适配器定义了与之前定义的相同的方法。请注意使用md5(),不是为了安全,而是作为一种快速从键生成文本字符串的方法:
namespace Application\Cache;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
class File implements CacheAdapterInterface
{
  protected $dir;
  protected $prefix;
  protected $suffix;
  public function __construct(
    $dir, $prefix = NULL, $suffix = NULL)
  {
    if (!file_exists($dir)) {
        error_log(__METHOD__ . ':' . Constants::ERROR_DIR_NOT);
        throw new Exception(Constants::ERROR_DIR_NOT);
    }
    $this->dir = $dir;
    $this->prefix = $prefix ?? Constants::DEFAULT_PREFIX;
    $this->suffix = $suffix ?? Constants::DEFAULT_SUFFIX;
  }

  public function hasKey($key)
  {
    $action = function ($name, $md5Key, &$item) {
      if (strpos($name, $md5Key) !== FALSE) {
        $item ++;
      }
    };

    return $this->findKey($key, $action);
  }

  public function getFromCache($key, $group = Constants::DEFAULT_GROUP)
  {
    $fn = $this->dir . '/' . $group . '/' 
    . $this->prefix . md5($key) . $this->suffix;
    if (file_exists($fn)) {
        foreach (file($fn) as $line) { yield $line; }
    } else {
        return array();
    }
  }

  public function saveToCache(
    $key, $data, $group = Constants::DEFAULT_GROUP)
  {
    $baseDir = $this->dir . '/' . $group;
    if (!file_exists($baseDir)) mkdir($baseDir);
    $fn = $baseDir . '/' . $this->prefix . md5($key) 
    . $this->suffix;
    return file_put_contents($fn, json_encode($data));
  }

  protected function findKey($key, callable $action)
  {
    $md5Key = md5($key);
    $iterator = new RecursiveIteratorIterator(
      new RecursiveDirectoryIterator($this->dir),
      RecursiveIteratorIterator::SELF_FIRST);
      $item = 0;
    foreach ($iterator as $name => $obj) {
      $action($name, $md5Key, $item);
    }
    return $item;
  }

  public function removeByKey($key)
  {
    $action = function ($name, $md5Key, &$item) {
      if (strpos($name, $md5Key) !== FALSE) {
        unlink($name);
        $item++;
      }
    };
    return $this->findKey($key, $action);
  }

  public function removeByGroup($group)
  {
    $removed = 0;
    $baseDir = $this->dir . '/' . $group;
    $pattern = $baseDir . '/' . $this->prefix . '*' 
    . $this->suffix;
    foreach (glob($pattern) as $file) {
      unlink($file);
      $removed++;
    }
    return $removed;
  }
}
  1. 现在我们准备介绍核心缓存机制。在构造函数中,我们接受一个实现了CacheAdapterInterface的类作为参数:
namespace Application\Cache;
use Psr\Http\Message\RequestInterface;
use Application\MiddleWare\ { Request, Response, TextStream };
class Core
{
  public function __construct(CacheAdapterInterface $adapter)
  {
    $this->adapter = $adapter;
  }
  1. 接下来是一系列的包装方法,调用适配器中同名的方法,但接受Psr\Http\Message\RequestInterface类作为参数,并返回Psr\Http\Message\ResponseInterface作为响应。我们从一个简单的开始:hasKey()。注意我们如何从请求参数中提取key
public function hasKey(RequestInterface $request)
{
  $key = $request->getUri()->getQueryParams()['key'] ?? '';
  $result = $this->adapter->hasKey($key);
}
  1. 要从缓存中检索信息,我们需要从请求对象中提取键和组参数,然后调用适配器中的相同方法。如果没有获得结果,我们设置一个204代码,表示请求成功,但没有生成内容。否则,我们设置一个200(成功)代码,并遍历结果。然后将所有内容放入响应对象中,并返回:
public function getFromCache(RequestInterface $request)
{
  $text = array();
  $key = $request->getUri()->getQueryParams()['key'] ?? '';
  $group = $request->getUri()->getQueryParams()['group'] 
    ?? Constants::DEFAULT_GROUP;
  $results = $this->adapter->getFromCache($key, $group);
  if (!$results) { 
      $code = 204; 
  } else {
      $code = 200;
      foreach ($results as $line) $text[] = $line;
  }
  if (!$text || count($text) == 0) $code = 204;
  $body = new TextStream(json_encode($text));
  return (new Response())->withStatus($code)
                         ->withBody($body);
}
  1. 奇怪的是,写入缓存几乎与之前定义的方法相同,只是结果预期要么是一个数字(即受影响的行数),要么是一个布尔结果:
public function saveToCache(RequestInterface $request)
{
  $text = array();
  $key = $request->getUri()->getQueryParams()['key'] ?? '';
  $group = $request->getUri()->getQueryParams()['group'] 
    ?? Constants::DEFAULT_GROUP;
  $data = $request->getBody()->getContents();
  $results = $this->adapter->saveToCache($key, $data, $group);
  if (!$results) { 
      $code = 204;
  } else {
      $code = 200;
      $text[] = $results;
  }
      $body = new TextStream(json_encode($text));
      return (new Response())->withStatus($code)
                             ->withBody($body);
  }
  1. 删除方法与预期相似:
public function removeByKey(RequestInterface $request)
{
  $text = array();
  $key = $request->getUri()->getQueryParams()['key'] ?? '';
  $results = $this->adapter->removeByKey($key);
  if (!$results) {
      $code = 204;
  } else {
      $code = 200;
      $text[] = $results;
  }
  $body = new TextStream(json_encode($text));
  return (new Response())->withStatus($code)
                         ->withBody($body);
}

public function removeByGroup(RequestInterface $request)
{
  $text = array();
  $group = $request->getUri()->getQueryParams()['group'] 
    ?? Constants::DEFAULT_GROUP;
  $results = $this->adapter->removeByGroup($group);
  if (!$results) {
      $code = 204;
  } else {
      $code = 200;
      $text[] = $results;
  }
  $body = new TextStream(json_encode($text));
  return (new Response())->withStatus($code)
                         ->withBody($body);
  }
} // closing brace for class Core

它是如何工作的...

为了演示Acl类的使用,您需要定义本篇文章中描述的类,总结如下:

在这些步骤中讨论
Application\Cache\Constants1
Application\Cache\CacheAdapterInterface2
Application\Cache\Database3 - 10
Application\Cache\File11
Application\Cache\Core12 - 16

接下来,定义一个测试程序,你可以称之为chap_09_middleware_cache_db.php。在这个程序中,像往常一样,定义必要文件的常量,设置自动加载,使用适当的类,哦...并编写一个生成质数的函数(你可能在这一点上重新阅读最后一点。不用担心,我们可以帮你解决这个问题!):

<?php
define('DB_CONFIG_FILE', __DIR__ . '/../config/db.config.php');
define('DB_TABLE', 'cache');
define('CACHE_DIR', __DIR__ . '/cache');
define('MAX_NUM', 100000);
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\Database\Connection;
use Application\Cache\{ Constants, Core, Database, File };
use Application\MiddleWare\ { Request, TextStream };

好吧,需要一个运行时间很长的函数,所以质数生成器,我们来吧!数字 1、2 和 3 被给定为质数。我们使用 PHP 7 的yield from语法来生成这前三个。然后,我们直接跳到 5,并继续到请求的最大值:

function generatePrimes($max)
{
  yield from [1,2,3];
  for ($x = 5; $x < $max; $x++)
  {
    if($x & 1) {
        $prime = TRUE;
        for($i = 3; $i < $x; $i++) {
            if(($x % $i) === 0) {
                $prime = FALSE;
                break;
            }
        }
        if ($prime) yield $x;
    }
  }
}

然后,您可以设置一个数据库缓存适配器实例,作为核心的参数:

$conn    = new Connection(include DB_CONFIG_FILE);
$dbCache = new Database(
  $conn, DB_TABLE, 'id', 'key', 'data', 'group');
$core    = new Core($dbCache);

或者,如果您希望使用文件缓存适配器,这是适当的代码:

$fileCache = new File(CACHE_DIR);
$core    = new Core($fileCache);

如果您想要清除缓存,可以这样做:

$uriString = '/?group=' . Constants::DEFAULT_GROUP;
$cacheRequest = new Request($uriString, 'get');
$response = $core->removeByGroup($cacheRequest);

您可以使用time()microtime()来查看此脚本在有缓存和无缓存的情况下运行的时间:

$start = time() + microtime(TRUE);
echo "\nTime: " . $start;

接下来,生成一个缓存请求。状态码200表示您能够从缓存中获取素数列表:

$uriString = '/?key=Test1';
$cacheRequest = new Request($uriString, 'get');
$response = $core->getFromCache($cacheRequest);
$status   = $response->getStatusCode();
if ($status == 200) {
    $primes = json_decode($response->getBody()->getContents());

否则,您可以假设未从缓存中获取任何内容,这意味着您需要生成素数,并将结果保存到缓存中:

} else {
    $primes = array();
    foreach (generatePrimes(MAX_NUM) as $num) {
        $primes[] = $num;
    }
    $body = new TextStream(json_encode($primes));
    $response = $core->saveToCache(
    $cacheRequest->withBody($body));
}

然后,您可以检查停止时间,计算差异,并查看您的新素数列表:

$time = time() + microtime(TRUE);
$diff = $time - $start;
echo "\nTime: $time";
echo "\nDifference: $diff";
var_dump($primes);

这是在值存储在缓存之前的预期输出:

它是如何工作的...

现在,您可以再次运行相同的程序,这次是从缓存中检索:

它是如何工作的...

考虑到我们的小素数生成器不是世界上效率最高的,而且演示是在笔记本电脑上运行的,时间从 30 多秒降到了毫秒。

还有更多...

另一个可能的缓存适配器可以围绕Alternate PHP Cache (APC)扩展的命令构建。该扩展包括诸如apc_exists()apc_store()apc_fetch()apc_clear_cache()之类的函数。这些函数非常适合我们的hasKey()saveToCache()getFromCache()removeBy*()函数。

另请参阅

您可能考虑对先前描述的缓存适配器类进行轻微更改,遵循 PSR-6,这是一个针对缓存的标准建议。然而,对于这个标准的接受程度并不像 PSR-7 那样高,因此我们决定在这里提出的配方中不完全遵循这个标准。有关 PSR-6 的更多信息,请参阅www.php-fig.org/psr/psr-6/

实施路由

路由是指接受用户友好的 URL、解析 URL 为其组成部分,然后确定应该调度哪个类和方法的过程。这种实现的优势在于,不仅可以使您的 URL搜索引擎优化SEO)友好,还可以创建规则,包括正则表达式模式,可以提取参数的值。

如何做...

  1. 可能最受欢迎的方法是利用支持URL 重写的 Web 服务器。这样的一个例子是配置为使用mod_rewrite的 Apache Web 服务器。然后,您定义重写规则,允许图形文件请求以及对 CSS 和 JavaScript 的请求保持不变。否则,请求将通过路由方法进行处理。

  2. 另一种潜在的方法是简单地让您的 Web 服务器虚拟主机定义指向特定的路由脚本,然后调用路由类,做出路由决策,并适当地重定向。

  3. 要考虑的第一段代码是如何定义路由配置。显而易见的答案是构造一个数组,其中每个键都指向一个正则表达式,该正则表达式与 URI 路径匹配,并且有某种形式的操作。以下代码片段显示了这种配置的示例。在这个例子中,我们定义了三个路由:homepage和默认路由。默认路由应该放在最后,因为它将匹配之前未匹配的任何内容。操作以匿名函数的形式呈现,如果路由匹配发生,则将执行该函数:

$config = [
  'home' => [
    'uri' => '!^/$!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/page0.php'; }
  ],
  'page' => [
    'uri' => '!^/(page)/(\d+)$!',
      'exec' => function ($matches) {
        include PAGE_DIR . '/page' . $matches[2] . '.php'; }
  ],
  Router::DEFAULT_MATCH => [
    'uri' => '!.*!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/sorry.php'; }
  ],
];
  1. 接下来,我们定义我们的Router类。我们首先定义在检查和匹配路由过程中将有用的常量和属性:
namespace Application\Routing;
use InvalidArgumentException;
use Psr\Http\Message\ServerRequestInterface;
class Router
{
  const DEFAULT_MATCH = 'default';
  const ERROR_NO_DEF  = 'ERROR: must supply a default match';
  protected $request;
  protected $requestUri;
  protected $uriParts;
  protected $docRoot;
  protected $config;
  protected $routeMatch;
  1. 构造函数接受一个符合ServerRequestInterface的类、文档根目录的路径以及前面提到的配置文件。请注意,如果未提供默认配置,则会抛出异常:
public function __construct(ServerRequestInterface $request, $docRoot, $config)
{
  $this->config = $config;
  $this->docRoot = $docRoot;
  $this->request = $request;
  $this->requestUri = 
    $request->getServerParams()['REQUEST_URI'];
  $this->uriParts = explode('/', $this->requestUri);
  if (!isset($config[self::DEFAULT_MATCH])) {
      throw new InvalidArgumentException(
        self::ERROR_NO_DEF);
  }
}
  1. 接下来,我们有一系列的 getter,允许我们检索原始请求、文档根目录和最终路由匹配:
public function getRequest()
{
  return $this->request;
}
public function getDocRoot()
{
  return $this->docRoot;
}
public function getRouteMatch()
{
  return $this->routeMatch;
}
  1. isFileOrDir()方法用于确定我们是否试图匹配 CSS、JavaScript 或图形请求(以及其他可能性):
public function isFileOrDir()
{
  $fn = $this->docRoot . '/' . $this->requestUri;
  $fn = str_replace('//', '/', $fn);
  if (file_exists($fn)) {
      return $fn;
  } else {
      return '';
  }
}
  1. 最后,我们定义了match(),它遍历配置数组,并通过preg_match()运行uri参数。如果匹配成功,则将配置键和preg_match()填充的$matches数组存储在$routeMatch中,并返回回调。如果没有匹配,则返回默认回调:
public function match()
{
  foreach ($this->config as $key => $route) {
    if (preg_match($route['uri'], 
        $this->requestUri, $matches)) {
        $this->routeMatch['key'] = $key;
        $this->routeMatch['match'] = $matches;
        return $route['exec'];
    }
  }
  return $this->config[self::DEFAULT_MATCH]['exec'];
}
}

工作原理...

首先,切换到/path/to/source/for/this/chapter并创建一个名为routing的目录。接下来,定义一个文件index.php,设置自动加载并使用正确的类。您可以定义一个常量PAGE_DIR,指向上一篇文章中创建的pages目录:

<?php
define('DOC_ROOT', __DIR__);
define('PAGE_DIR', DOC_ROOT . '/../pages');

require_once __DIR__ . '/../../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/../..');
use Application\MiddleWare\ServerRequest;
use Application\Routing\Router;

接下来,添加在本教程第 3 步中讨论的配置数组。请注意,您可以在模式的末尾添加(/)?以考虑可选的尾随斜杠。另外,对于home路由,您可以提供两个选项://home

$config = [
  'home' => [
    'uri' => '!^(/|/home)$!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/page0.php'; }
  ],
  'page' => [
    'uri' => '!^/(page)/(\d+)(/)?$!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/page' . $matches[2] . '.php'; }
  ],
  Router::DEFAULT_MATCH => [
    'uri' => '!.*!',
    'exec' => function ($matches) {
      include PAGE_DIR . '/sorry.php'; }
  ],
];

然后,您可以定义一个路由器实例,将初始化的ServerRequest实例作为第一个参数提供:

$router = new Router((new ServerRequest())
  ->initialize(), DOC_ROOT, $config);
$execute = $router->match();
$params  = $router->getRouteMatch()['match'];

然后,您需要检查请求是文件还是目录,以及路由匹配是否为/

if ($fn = $router->isFileOrDir()
    && $router->getRequest()->getUri()->getPath() != '/') {
    return FALSE;
} else {
    include DOC_ROOT . '/main.php';
}

接下来,定义main.php,类似于这样:

<?php // demo using middleware for routing ?>
<!DOCTYPE html>
<head>
  <title>PHP 7 Cookbook</title>
  <meta http-equiv="content-type" 
  content="text/html;charset=utf-8" />
</head>
<body>
    <?php include PAGE_DIR . '/route_menu.php'; ?>
    <?php $execute($params); ?>
</body>
</html>

最后,需要一个使用用户友好路由的修订菜单:

<?php // menu for routing ?>
<a href="/home">Home</a>
<a href="/page/1">Page 1</a>
<a href="/page/2">Page 2</a>
<a href="/page/3">Page 3</a>
<!-- etc. -->

要使用 Apache 测试配置,请定义一个虚拟主机定义,指向/path/to/source/for/this/chapter/routing。此外,定义一个.htaccess文件,将任何不是文件、目录或链接的请求重定向到index.php。或者,您可以直接使用内置的 PHP Web 服务器。在终端窗口或命令提示符中,键入此命令:

**cd /path/to/source/for/this/chapter/routing**
**php -S localhost:8080**

在浏览器中,请求http://localhost:8080/home时的输出如下:

工作原理...

另请参阅

有关使用NGINX Web 服务器进行重写的信息,请参阅本文:nginx.org/en/docs/http/ngx_http_rewrite_module.html。有许多复杂的 PHP 路由库可用,介绍的功能远远超过了这里介绍的简单路由器。这些包括 Altorouter (altorouter.com/),TreeRoute (github.com/baryshev/TreeRoute),FastRoute (github.com/nikic/FastRoute)和 Aura.Router. (github.com/auraphp/Aura.Router)。此外,大多数框架(例如 Zend Framework 2 或 CodeIgniter)都具有自己的路由功能。

进行跨框架系统调用

PSR-7(和中间件)开发的主要原因之一是日益增长的需要在框架之间进行调用。值得注意的是,PSR-7 的主要文档由PHP Framework Interop Group (PHP-FIG)托管。

操作步骤...

  1. 在中间件跨框架调用中使用的主要机制是创建一个驱动程序,依次执行框架调用,维护一个公共的请求和响应对象。预期请求和响应对象分别代表Psr\Http\Message\ServerRequestInterfacePsr\Http\Message\ResponseInterface

  2. 为了说明这一点,我们定义了一个中间件会话验证器。常量和属性反映了会话thumbprint,这是一个我们用来包含网站访问者 IP 地址、浏览器和语言设置等因素的术语:

namespace Application\MiddleWare\Session;
use InvalidArgumentException;
use Psr\Http\Message\ { 
  ServerRequestInterface, ResponseInterface };
use Application\MiddleWare\ { Constants, Response, TextStream };
class Validator
{
  const KEY_TEXT = 'text';
  const KEY_SESSION = 'thumbprint';
  const KEY_STATUS_CODE = 'code';
  const KEY_STATUS_REASON = 'reason';
  const KEY_STOP_TIME = 'stop_time';
  const ERROR_TIME = 'ERROR: session has exceeded stop time';
  const ERROR_SESSION = 'ERROR: thumbprint does not match';
  const SUCCESS_SESSION = 'SUCCESS: session validates OK';
  protected $sessionKey;
  protected $currentPrint;
  protected $storedPrint;
  protected $currentTime;
  protected $storedTime;
  1. 构造函数接受ServerRequestInterface实例和会话作为参数。如果会话是一个数组(比如$_SESSION),我们将其包装在一个类中。我们这样做的原因是,以防我们传递了一个会话对象,比如 Joomla 中使用的JSession。然后,我们使用先前提到的因素创建指纹。如果存储的指纹不可用,我们假设这是第一次,并存储当前的指纹以及停止时间(如果设置了此参数)。我们使用md5()是因为它是一个快速的哈希,不会外部暴露,因此对这个应用程序很有用:
public function __construct(
  ServerRequestInterface $request, $stopTime = NULL)
{
  $this->currentTime  = time();
  $this->storedTime   = $_SESSION[self::KEY_STOP_TIME] ?? 0;
  $this->currentPrint = 
    md5($request->getServerParams()['REMOTE_ADDR']
      . $request->getServerParams()['HTTP_USER_AGENT']
      . $request->getServerParams()['HTTP_ACCEPT_LANGUAGE']);
        $this->storedPrint  = $_SESSION[self::KEY_SESSION] 
      ?? NULL;
  if (empty($this->storedPrint)) {
      $this->storedPrint = $this->currentPrint;
      $_SESSION[self::KEY_SESSION] = $this->storedPrint;
      if ($stopTime) {
          $this->storedTime = $stopTime;
          $_SESSION[self::KEY_STOP_TIME] = $stopTime;
      }
  }
}
  1. 并不需要定义__invoke(),但这个魔术方法对于独立的中间件类非常方便。按照惯例,我们接受ServerRequestInterfaceResponseInterface实例作为参数。在这个方法中,我们只是检查当前的指纹是否与存储的指纹匹配。第一次,当然,它们会匹配。但在后续请求中,有可能会捕获到试图劫持会话的攻击者。此外,如果会话时间超过了停止时间(如果设置了),同样会发送401代码:
public function __invoke(
  ServerRequestInterface $request, Response $response)
{
  $code = 401;  // unauthorized
  if ($this->currentPrint != $this->storedPrint) {
      $text[self::KEY_TEXT] = self::ERROR_SESSION;
      $text[self::KEY_STATUS_REASON] = 
        Constants::STATUS_CODES[401];
  } elseif ($this->storedTime) {
      if ($this->currentTime > $this->storedTime) {
          $text[self::KEY_TEXT] = self::ERROR_TIME;
          $text[self::KEY_STATUS_REASON] = 
            Constants::STATUS_CODES[401];
      } else {
          $code = 200; // success
      }
  }
  if ($code == 200) {
      $text[self::KEY_TEXT] = self::SUCCESS_SESSION;
      $text[self::KEY_STATUS_REASON] = 
        Constants::STATUS_CODES[200];
  }
  $text[self::KEY_STATUS_CODE] = $code;
  $body = new TextStream(json_encode($text));
  return $response->withStatus($code)->withBody($body);
}
  1. 现在我们可以使用我们的新中间件类。至少在这一点上,不同框架之间的调用存在的主要问题在这里总结。因此,我们如何实现中间件在很大程度上取决于最后一点:
  • 并非所有的 PHP 框架都符合 PSR-7

  • 现有的 PSR-7 实现并不完整

  • 所有框架都想成为“老大”

  1. 作为一个例子,让我们来看看Zend Expressive的配置文件,它是一个自称为PSR7 中间件微框架。这里有一个名为middleware-pipeline.global.php的文件,它位于标准 Expressive 应用程序中的config/autoload文件夹中。依赖项键用于标识将在管道中激活的中间件包装类:
<?php
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive\Helper;
return [  
  'dependencies' => [
     'factories' => [
        Helper\ServerUrlMiddleware::class => 
        Helper\ServerUrlMiddlewareFactory::class,
        Helper\UrlHelperMiddleware::class => 
        Helper\UrlHelperMiddlewareFactory::class,
        **// insert your own class here**
     ],
  ],
  1. middleware_pipline键下,您可以标识在路由过程发生之前或之后将被执行的类。可选参数包括patherrorpriority
'middleware_pipeline' => [
   'always' => [
      'middleware' => [
         Helper\ServerUrlMiddleware::class,
      ],
      'priority' => 10000,
   ],
   'routing' => [
      'middleware' => [
         ApplicationFactory::ROUTING_MIDDLEWARE,
         Helper\UrlHelperMiddleware::class,
         **// insert reference to middleware here**
         ApplicationFactory::DISPATCH_MIDDLEWARE,
      ],
      'priority' => 1,
   ],
   'error' => [
      'middleware' => [
         // Add error middleware here.
      ],
      'error'    => true,
      'priority' => -10000,
    ],
  ],
];
  1. 另一种技术是修改现有框架模块的源代码,并向符合 PSR-7 的中间件应用程序发出请求。以下是修改**Joomla!**安装以包含中间件会话验证器的示例。

  2. 接下来,将此代码添加到/path/to/joomla文件夹中的index.php文件的末尾。由于 Joomla!使用 Composer,我们可以利用 Composer 自动加载程序:

session_start();    // to support use of $_SESSION
$loader = include __DIR__ . '/libraries/vendor/autoload.php';
$loader->add('Application', __DIR__ . '/libraries/vendor');
$loader->add('Psr', __DIR__ . '/libraries/vendor');
  1. 然后,创建我们的中间件会话验证器的实例,并在$app = JFactory::getApplication('site');之前进行验证请求:
$session = JFactory::getSession();
$request = 
  (new Application\MiddleWare\ServerRequest())->initialize();
$response = new Application\MiddleWare\Response();
$validator = new Application\Security\Session\Validator(
  $request, $session);
$response = $validator($request, $response);
if ($response->getStatusCode() != 200) {
  // take some action
}

它是如何工作的...

首先,创建描述步骤 2-5 的Application\MiddleWare\Session\Validator测试中间件类。然后,您需要转到getcomposer.org/并按照说明获取 Composer。将其下载到/path/to/source/for/this/chapter文件夹中。接下来,构建一个基本的 Zend Expressive 应用程序,如下所示。在提示是否选择最小骨架时,请务必选择No

**cd /path/to/source/for/this/chapter**
**php composer.phar create-project zendframework/zend-expressive-skeleton expressive**

这将创建一个/path/to/source/for/this/chapter/expressive文件夹。切换到这个目录。修改public/index.php如下:

<?php
if (php_sapi_name() === 'cli-server'
    && is_file(__DIR__ . parse_url(
$_SERVER['REQUEST_URI'], PHP_URL_PATH))
) {
    return false;
}
chdir(dirname(__DIR__));
**session_start();**
**$_SESSION['time'] = time();**
**$appDir = realpath(__DIR__ . '/../../..');**
**$loader = require 'vendor/autoload.php';**
**$loader->add('Application', $appDir);**
$container = require 'config/container.php';
$app = $container->get(\Zend\Expressive\Application::class);
$app->run();

然后,您需要创建一个调用我们会话验证中间件的包装类。创建一个SessionValidateAction.php文件,需要放在/path/to/source/for/this/chapter/expressive/src/App/Action文件夹中。为了说明这一点,将停止时间参数设置为一个较短的持续时间。在这种情况下,time() + 10给您 10 秒:

namespace App\Action;
use Application\MiddleWare\Session\Validator;
use Zend\Diactoros\ { Request, Response };
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class SessionValidateAction
{
  public function __invoke(ServerRequestInterface $request, 
  ResponseInterface $response, callable $next = null)
  {
    $inbound   = new Response();
    $validator = new Validator($request, **time()+10**);
    $inbound   = $validator($request, $response);
    if ($inbound->getStatusCode() != 200) {
        session_destroy();
        setcookie('PHPSESSID', 0, time()-300);
        $params = json_decode(
          $inbound->getBody()->getContents(), TRUE);
        echo '<h1>',$params[Validator::KEY_TEXT],'</h1>';
        echo '<pre>',var_dump($inbound),'</pre>';
        exit;
    }
    return $next($request,$response);
  }
}

现在,您需要将新类添加到中间件管道中。修改config/autoload/middleware-pipeline.global.php如下。修改部分用粗体显示:

<?php
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive\Helper;
return [
  'dependencies' => [
 **'invokables' => [**
 **App\Action\SessionValidateAction::class =>** 
 **App\Action\SessionValidateAction::class,**
 **],**
   'factories' => [
      Helper\ServerUrlMiddleware::class => 
      Helper\ServerUrlMiddlewareFactory::class,
      Helper\UrlHelperMiddleware::class => 
      Helper\UrlHelperMiddlewareFactory::class,
    ],
  ],
  'middleware_pipeline' => [
      'always' => [
         'middleware' => [
            Helper\ServerUrlMiddleware::class,
         ],
         'priority' => 10000,
      ],
      'routing' => [
         'middleware' => [
            ApplicationFactory::ROUTING_MIDDLEWARE,
            Helper\UrlHelperMiddleware::class,
            **App\Action\SessionValidateAction::class,**
            ApplicationFactory::DISPATCH_MIDDLEWARE,
         ],
         'priority' => 1,
      ],
    'error' => [
       'middleware' => [
          // Add error middleware here.
       ],
       'error'    => true,
       'priority' => -10000,
    ],
  ],
];

您可能还考虑修改主页模板以显示$_SESSION的状态。相关文件是/path/to/source/for/this/chapter/expressive/templates/app/home-page.phtml。只需添加var_dump($_SESSION)即可。

最初,您应该看到类似以下的东西:

它是如何工作的...

10 秒后,刷新浏览器。现在您应该看到这个:

它是如何工作的...

使用中间件跨语言

除非您尝试在不同版本的 PHP 之间进行通信,否则 PSR-7 中间件将几乎没有用处。回想一下这个首字母缩略词的含义:PHP 标准建议。因此,如果您需要向另一种语言编写的应用程序发出请求,请将其视为任何其他 Web 服务 HTTP 请求。

如何做...

  1. 在 PHP 4 的情况下,实际上有机会进行面向对象编程的有限支持。因此,最好的方法是降级前三个食谱中描述的基本 PSR-7 类。没有足够的空间来涵盖所有的变化,但我们提供了Application\MiddleWare\ServerRequest的潜在 PHP 4 版本。首先要注意的是没有命名空间!因此,我们使用下划线 _ 来代替命名空间分隔符的类名:
class Application_MiddleWare_ServerRequest
extends Application_MiddleWare_Request
implements Psr_Http_Message_ServerRequestInterface
{
  1. 在 PHP 4 中,所有属性都使用关键字var进行标识:
var $serverParams;
var $cookies;
var $queryParams;
// not all properties are shown
  1. initialize()方法几乎相同,只是在 PHP 4 中不允许使用$this->getServerParams()['REQUEST_URI']这样的语法。因此,我们需要将其拆分为一个单独的变量:
function initialize()
{
  $params = $this->getServerParams();
  $this->getCookieParams();
  $this->getQueryParams();
  $this->getUploadedFiles;
  $this->getRequestMethod();
  $this->getContentType();
  $this->getParsedBody();
  return $this->withRequestTarget($params['REQUEST_URI']);
}
  1. 所有$_XXX超全局变量都出现在 PHP 4 的后续版本中:
function getServerParams()
{
  if (!$this->serverParams) {
      $this->serverParams = $_SERVER;
  }
  return $this->serverParams;
}
// not all getXXX() methods are shown to conserve space
  1. 空合并运算符是在 PHP 7 中引入的。我们需要使用isset(XXX) ? XXX : '';代替:
function getRequestMethod()
{
  $params = $this->getServerParams();
  $method = isset($params['REQUEST_METHOD']) 
    ? $params['REQUEST_METHOD'] : '';
  $this->method = strtolower($method);
  return $this->method;
}
  1. JSON 扩展是在 PHP 5 中引入的。因此,我们需要满足于原始输入。我们还可以在json_encode()json_decode()的位置使用serialize()unserialize()
function getParsedBody()
{
  if (!$this->parsedBody) {
      if (($this->getContentType() == 
           Constants::CONTENT_TYPE_FORM_ENCODED
           || $this->getContentType() == 
           Constants::CONTENT_TYPE_MULTI_FORM)
           && $this->getRequestMethod() == 
           Constants::METHOD_POST)
      {
          $this->parsedBody = $_POST;
      } elseif ($this->getContentType() == 
                Constants::CONTENT_TYPE_JSON
                || $this->getContentType() == 
                Constants::CONTENT_TYPE_HAL_JSON)
      {
          ini_set("allow_url_fopen", true);
          $this->parsedBody = 
            file_get_contents('php://stdin');
      } elseif (!empty($_REQUEST)) {
          $this->parsedBody = $_REQUEST;
      } else {
          ini_set("allow_url_fopen", true);
          $this->parsedBody = 
            file_get_contents('php://stdin');
      }
  }
  return $this->parsedBody;
}
  1. withXXX()方法在 PHP 4 中基本相同:
function withParsedBody($data)
{
  $this->parsedBody = $data;
  return $this;
}
  1. 同样,withoutXXX()方法也是一样的:
function withoutAttribute($name)
{
  if (isset($this->attributes[$name])) {
      unset($this->attributes[$name]);
  }
  return $this;
}

}
  1. 对于使用其他语言的网站,我们可以使用 PSR-7 类来制定请求和响应,但随后需要使用 HTTP 客户端与其他网站进行通信。例如,回想一下本章中讨论的“开发 PSR-7 请求类”食谱中的Request演示。以下是*它是如何工作的...*部分的示例:
$request = new Request(
  TARGET_WEBSITE_URL,
  Constants::METHOD_POST,
  new TextStream($contents),
  [Constants::HEADER_CONTENT_TYPE => 
  Constants::CONTENT_TYPE_FORM_ENCODED,
  Constants::HEADER_CONTENT_LENGTH => $body->getSize()]
);

$data = http_build_query(['data' => 
$request->getBody()->getContents()]);

$defaults = array(
  CURLOPT_URL => $request->getUri()->getUriString(),
  CURLOPT_POST => true,
  CURLOPT_POSTFIELDS => $data,
);
$ch = curl_init();
curl_setopt_array($ch, $defaults);
$response = curl_exec($ch);
curl_close($ch);