PHP7-编程蓝图(全)

PHP7 编程蓝图(全)

原文:zh.annas-archive.org/md5/27faa03af47783c6370aa5ff8894925f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

PHP 是开发 Web 应用程序的优秀语言。它本质上是一种服务器端脚本语言,也用于通用编程。PHP 7 是最新版本,它提供了主要的向后兼容性断裂,并专注于提供改进的性能和速度。随着对高性能的需求增加,这个最新版本包含了构建高效应用程序所需的一切。PHP 7 提供了改进的引擎执行、更好的内存使用以及一套更好的工具,使您能够通过多线程 Web 服务器在低成本硬件和服务器上维护网站的高流量。

本书涵盖内容

第一章,创建用户配置文件系统并使用 Null Coalesce 运算符,我们将发现新的 PHP 7 功能,并构建用于存储用户配置文件的应用程序。

第二章,构建数据库类和简单购物车,我们将创建一个简单的数据库层库,帮助我们访问我们的数据库。我们将介绍一些使我们的查询安全的技巧,以及如何使用 PHP 7 使我们的编码更简单、更简洁。

第三章,构建社交通讯服务,我们将构建一个社交通讯服务,用户可以使用其社交登录进行注册,并允许他们注册到通讯服务。我们还将为管理通讯服务创建一个简单的管理员系统。

第四章,使用 Elasticsearch 构建具有搜索功能的简单博客,您将学习如何创建一个博客系统,尝试使用 Elasticsearch 以及如何在您的代码中应用它。此外,您还将学习如何创建一个简单的博客应用程序并将数据存储到 MySQL 中。

第五章,创建 RESTful Web 服务,向您展示如何创建一个可用于管理用户配置文件的 RESTful Web 服务。该服务将使用 Slim 微框架实现,并使用 MongoDB 数据库进行持久化。本章还涵盖了 RESTful Web 服务的基础知识,最重要的是常见的 HTTP 请求和响应方法、PSR-7 标准以及 PHP 7 的新 mongodb 扩展。

第六章,构建聊天应用程序,描述了使用 WebSockets 实现实时聊天应用程序。您将学习如何使用 Ratchet 框架构建独立的 WebSocket 和 HTTP 服务器,并如何在 JavaScript 客户端应用程序中连接到 WebSocket 服务器。我们还将讨论如何为 WebSocket 应用程序实现身份验证以及如何在生产环境中部署它们。

第七章,构建异步微服务架构,涵盖了(小型)微服务架构的实现。在本章中,您将使用 ZeroMQ 而不是 RESTful Web 服务进行网络通信,这是一种专注于异步性、松散耦合和高性能的替代通信协议。

第八章,为自定义语言构建解析器和解释器,描述了如何使用 PHP-PEG 库定义语法并实现自定义表达式语言的解析器,该语言可用于向企业应用程序添加最终用户开发功能。

第九章,PHP 中的 Reactive 扩展,我们将研究 PHP 的 Reactive 扩展库,并尝试构建一个简单的定时应用程序。

本书所需内容

您需要从官方 PHP 网站下载并安装 PHP 7。您还需要安装一个 Web 服务器,如 Apache 或 Nginx,并配置为默认运行 PHP 7。

如果您对虚拟机有经验,还可以使用 Docker 容器和/或 Vagrant 来构建一个安装了 PHP 7 的环境。

这本书适合谁

这本书是为网页开发人员、PHP 顾问以及任何正在使用 PHP 进行多个项目的人准备的。假定具有 PHP 编程的基本知识。

约定

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名会显示如下:“让我们创建一个简单的UserProfile类。”

一段代码被设置如下:

function fetch_one($id) { 
  $link = mysqli_connect(''); 
  $query = "SELECT * from ". $this->table . " WHERE `id` =' " .  $id "'"; 
  $results = mysqli_query($link, $query); 
}

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

'credit_card' => $credit_card, 
**'items' => //<all the items and prices>//,** 
'total' => $total,

任何命令行输入或输出都以以下方式书写:

 **mysql> source insert_profiles.sql**

新术语重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中出现,就像这样:“只需点击允许访问,然后点击确定。”

注意

警告或重要提示会以这样的框出现。

提示

提示和技巧会以这种方式出现。

第一章:创建用户配置文件系统并使用空合并运算符

为了开始这一章,让我们来看看 PHP 7 中的新空合并。我们还将学习如何构建一个简单的配置文件页面,其中列出了可以单击的用户,并创建一个简单的类似 CRUD 的系统,这将使我们能够注册新用户到系统中,并删除用户以进行封禁。

我们将学习使用 PHP 7 空合并运算符,以便我们可以在有数据时显示数据,或者如果没有数据,只显示一个简单的消息。

让我们创建一个简单的UserProfile类。自 PHP 5 以来,创建类的能力就已经可用了。

PHP 中的一个类以class开头,后面是类的名称:

class UserProfile { 

  private $table = 'user_profiles'; 

} 

} 

我们已经将表格设为私有,并添加了一个private变量,我们在其中定义它将与哪个表相关联。

让我们添加两个函数,也称为类中的方法,来简单地从数据库中获取数据:

function fetch_one($id) { 
  $link = mysqli_connect(''); 
  $query = "SELECT * from ". $this->table . " WHERE `id` =' " .  $id "'"; 
  $results = mysqli_query($link, $query); 
} 

function fetch_all() { 
  $link = mysqli_connect('127.0.0.1', 'root','apassword','my_dataabase' ); 
  $query = "SELECT * from ". $this->table . "; 
 $results = mysqli_query($link, $query); 
} 

空合并运算符

我们可以使用 PHP 7 的空合并运算符来允许我们检查我们的结果是否包含任何内容,或者返回一个我们可以在视图上检查的定义文本,这将负责显示任何数据。

让我们把这个放在一个文件中,其中包含所有的定义语句,并称之为:

//definitions.php 
define('NO_RESULTS_MESSAGE', 'No results found'); 

require('definitions.php'); 
function fetch_all() { 
   ...same lines ... 

   $results = $results ??  NO_RESULTS_MESSAGE; 
   return $message;    
} 

在客户端,我们需要设计一个模板来显示用户配置文件的列表。

让我们创建一个基本的 HTML 块,以显示每个配置文件可以是一个div元素,其中包含几个列表项元素来输出每个表。

在下面的函数中,我们需要确保所有的值都至少填写了姓名和年龄。然后当函数被调用时,我们只需返回整个字符串:

function profile_template( $name, $age, $country ) { 
 $name = $name ?? null; 
  $age = $age ?? null; 
  if($name == null || $age === null) { 
    return 'Name or Age need to be set';  
   } else { 

    return '<div> 

         <li>Name: ' . $name . ' </li> 

         <li>Age: ' . $age . '</li> 

         <li>Country:  ' .  $country . ' </li> 

    </div>'; 
  } 
} 

关注点分离

在一个适当的 MVC 架构中,我们需要将视图与获取数据的模型分开,控制器将负责处理业务逻辑。

在我们的简单应用程序中,我们将跳过控制器层,因为我们只想在一个公共页面中显示用户配置文件。前面的函数也被称为 MVC 架构中的模板渲染部分。

虽然有一些可用于 PHP 的框架可以直接使用 MVC 架构,但现在我们可以坚持我们已经拥有的东西并使其工作。

PHP 框架可以从空合并运算符中受益很多。在我曾经使用的一些代码中,我们经常使用三元运算符,但仍然需要添加更多的检查来确保值不是虚假的。

此外,三元运算符可能会令人困惑,并需要一些时间来适应。另一种选择是使用isSet函数。然而,由于isSet函数的性质,一些虚假的值将被 PHP 解释为已设置。

创建视图

现在我们的模型已经完成,有一个模板渲染函数,我们只需要创建一个视图,通过它我们可以查看每个配置文件。

我们的视图将放在一个foreach块中,并且我们将使用我们编写的模板来渲染正确的值:

//listprofiles.php 

<html> 
<!doctype html> 
<head> 
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css"> 
</head> 
<body> 

<?php 
foreach($results as $item) { 
  echo profile_template($item->name, $item->age, $item->country; 
} 
?> 
</body> 
</html> 

让我们把上面的代码放到index.php中。

虽然我们可以安装 Apache 服务器,配置它来运行 PHP,安装新的虚拟主机和其他必要的功能,并将我们的 PHP 代码放入 Apache 文件夹中,但这需要时间。因此,为了测试这一点,我们可以只运行 PHP 的服务器进行开发。

要运行内置的 PHP 服务器(在php.net/manual/en/features.commandline.webserver.php上阅读更多信息),我们将使用我们正在运行的文件夹,在终端内:

**php -S localhost:8000**

如果我们打开浏览器,我们应该还看不到任何东西,没有找到结果。这意味着我们需要填充我们的数据库。

如果您的数据库连接出现错误,请确保将我们提供的正确数据库凭据替换到我们所做的每个mysql_connect调用中。

  1. 为了向我们的数据库提供数据,我们可以创建一个简单的 SQL 脚本,就像这样:
INSERT INTO user_profiles ('Chin Wu', 30, 'Mongolia'); 
INSERT INTO user_profiles ('Erik Schmidt', 22, 'Germany'); 
INSERT INTO user_profiles ('Rashma Naru', 33, 'India'); 

  1. 让我们把它保存在一个名为insert_profiles.sql的文件中。在与 SQL 文件相同的目录中,通过以下命令登录 MySQL 客户端:
 **mysql -u root -p**

  1. 然后输入使用<数据库名称>:
 **mysql>  use <database>;**

  1. 通过运行 source 命令导入脚本:
 **mysql> source insert_profiles.sql**

现在我们的用户资料页面应该显示如下:

创建视图

创建个人资料输入表单

现在让我们为用户创建 HTML 表单来输入他们的个人资料。

如果我们没有一个简单的方法让用户输入他们的用户资料细节,我们的个人资料应用就没有用处。

我们将创建个人资料输入表单如下:

//create_profile.php 

<html> 
<body> 
<form action="post_profile.php" method="POST"> 

  <label>Name</label><input name="name"> 
  <label>Age</label><input name="age"> 
  <label>Country</label><input name="country"> 

</form> 
</body> 
</html> 

在这个个人资料帖子中,我们需要创建一个 PHP 脚本来处理用户发布的任何内容。它将从输入值创建一个 SQL 语句,并输出它们是否被插入。

我们可以再次使用空合并运算符来验证用户是否输入了所有值,并且没有留下未定义或空的值:

$name = $_POST['name'] ?? ""; 

$age = $_POST['country'] ?? ""; 

$country = $_POST['country'] ?? ""; 

这可以防止我们在向数据库插入数据时累积错误。

首先,让我们创建一个变量来保存每个输入的数组:

$input_values =  [ 
 'name' => $name, 
 'age' => $age, 
 'country' => $country 
]; 

上面的代码是一种新的 PHP 5.4+写数组的方式。在 PHP 5.4+中,不再需要使用实际的array();作者个人更喜欢新的语法。

我们应该在我们的UserProfile类中创建一个新的方法来接受这些值:

Class UserProfile { 

 public function insert_profile($values)  { 

 $link =  mysqli_connect('127.0.0.1', 'username','password', 'databasename'); 

 $q = " INSERT INTO " . $this->table . " VALUES ( '".$values['name']."', '".$values['age'] . "' ,'".$values['country']. "')"; 
   return mysqli_query($q); 

 } 
} 

与我们的个人资料模板渲染函数一样,我们不需要在函数中创建一个参数来保存每个参数,我们可以简单地使用一个数组来保存我们的值。

这样,如果需要向我们的数据库插入一个新字段,我们只需在 SQL insert语句中添加另一个字段。

趁热打铁,让我们创建编辑个人资料部分。

目前,我们假设使用此编辑个人资料的人是网站的管理员。

我们需要创建一个页面,假设$_GET['id']已经设置,那么我们将从数据库中获取用户并在表单上显示。代码如下:

<?php 
require('class/userprofile.php');//contains the class UserProfile into 

$id = $_GET['id'] ?? 'No ID'; 
//if id was a string, i.e. "No ID", this would go into the if block 
if(is_numeric($id)) { 
  $profile =  new UserProfile(); 
  //get data from our database 
  $results =   $user->fetch_id($id); 
  if($results && $results->num_rows > 0  ) { 
     while($obj = $results->fetch_object()) 
   { 
          $name = $obj->name; 
          $age = $obj->age; 
       $country = $obj->country; 
      } 
        //display form with a hidden field containing the value of the ID 
?> 

  <form action="post_update_profile.php" method="post"> 

  <label>Name</label><input name="name" value="<?=$name?>"> 
  <label>Age</label><input name="age" value="<?=$age?>"> 
  <label>Country</label><input name="country" value="<?=country?>"> 

</form> 

  <?php 

  } else { 
         exit('No such user'); 
  } 

} else { 
  echo $id; //this  should be No ID'; 
 exit; 
}   

请注意,我们在表单中使用了所谓的快捷echo语句。这使我们的代码更简单,更易读。由于我们使用的是 PHP 7,这个功能应该是开箱即用的。

一旦有人提交表单,它就会进入我们的$_POST变量,我们将在我们的UserProfile类中创建一个新的Update函数。

管理员系统

最后,让我们创建一个简单的网格,用于与我们的用户资料数据库一起使用的管理员仪表板门户。我们对此的要求很简单:我们只需设置一个基于表格的布局,以行显示每个用户资料。

从网格中,我们将添加链接以便能够编辑个人资料,或者删除它,如果我们想要的话。在我们的 HTML 视图中显示表格的代码如下:

<table> 
 <tr> 
  <td>John Doe</td> 
  <td>21</td> 
  <td>USA</td> 
  <td><a href="edit_profile.php?id=1">Edit</a></td> 
  <td><a href="profileview.php?id=1">View</a> 
  <td><a href="delete_profile.php?id=1">Delete</a> 
 </tr> 
</table> 
This script to this is the following: 
//listprofiles.php 
$sql = "SELECT * FROM userprofiles LIMIT $start, $limit ";  
$rs_result = mysqli_query ($sql); //run the query 

while($row = mysqli_fetch_assoc($rs_result) { 
?> 
    <tr> 
           <td><?=$row['name'];?></td> 
           <td><?=$row['age'];?></td>  
        <td><?=$row['country'];?></td>       

         <td><a href="edit_profile.php?id=<?=$id?>">Edit</a></td> 
          <td><a href="profileview.php?id=<?=$id?>">View</a> 
          <td><a href="delete_profile.php?id=<?=$id?>">Delete</a> 
           </tr> 

<?php 
} 

有一件事我们还没有创建:delete_profile.php页面。查看和编辑页面已经讨论过了。

delete_profile.php页面将如下所示:

<?php 

//delete_profile.php 
$connection = mysqli_connect('localhost','<username>','<password>', '<databasename>'); 

$id = $_GET['id'] ?? 'No ID'; 

if(is_numeric($id)) { 
mysqli_query( $connection, "DELETE FROM userprofiles WHERE id = '" .$id . "'"); 
} else { 
 echo $id; 
} 
i(!is_numeric($id)) {  
exit('Error: non numeric \$id');  
 } else { 
echo "Profile #" . $id . " has been deleted"; 

?> 

当然,由于我们的数据库中可能有很多用户资料,我们必须创建一个简单的分页。在任何分页系统中,你只需要找出总行数和每页要显示的行数。我们可以创建一个函数,它将能够返回一个包含页码和每页要查看的数量的 URL。

从我们的查询数据库中,我们首先创建一个新的函数,让我们只选择数据库中的总项目数:

class UserProfile{ 
 // .... Etc ... 
function count_rows($table) { 
      $dbconn = new mysqli('localhost', 'root', 'somepass', 'databasename');  
  $query = $dbconn->query("select COUNT(*) as num from '". $table . "'"); 

   $total_pages = mysqli_fetch_array($query); 

   return $total_pages['num']; //fetching by array, so element 'num' = count 
} 

对于我们的分页,我们可以创建一个简单的paginate函数,它接受页面的base_url,每页的行数(也称为每页要显示的记录数),以及找到的记录的总数:

require('definitions.php'); 
require('db.php'); //our database class 

Function paginate ($base_url, $rows_per_page, $total_rows) { 
  $pagination_links = array(); //instantiate an array to hold our html page links 

   //we can use null coalesce to check if the inputs are  null   
  ( $total_rows || $rows_per_page) ?? exit('Error: no rows per page and total rows);  
     //we exit with an error message if this function is called incorrectly  

    $pages =  $total_rows % $rows_per_page; 
    $i= 0; 
       $pagination_links[$i] =  "<a href="http://". $base_url  . "?pagenum=". $pagenum."&rpp=".$rows_per_page. ">"  . $pagenum . "</a>"; 
      } 
    return $pagination_links; 

} 

这个函数将帮助在表格中显示上面的页面链接:

function display_pagination($links) {
      $display = '<div class="pagination">
                  <table><tr>';
      foreach ($links as $link) {
               echo "<td>" . $link . "</td>";
      }

       $display .= '</tr></table></div>';

       return $display;
    }

请注意,我们遵循的原则是函数内部应该很少有echo语句。这是因为我们希望确保这些函数的其他用户在调试页面上出现神秘输出时不会感到困惑。

通过要求程序员回显函数返回的内容,可以更容易地调试我们的程序。此外,我们遵循关注点分离,我们的代码不输出显示,它只是格式化显示。

因此,任何未来的程序员都可以更新函数的内部代码并返回其他内容。这也使我们的函数可重用;想象一下,将来有人使用我们的函数,这样,他们就不必再次检查我们的函数中是否有一些错放的echo语句。

提示

关于替代短标签的说明

如你所知,另一种echo的方法是使用<?= 标签。你可以这样使用:<?="helloworld"?>。这些被称为短标签。在 PHP 7 中,替代 PHP 标签已被移除。RFC 声明<%<%=%><script language=php>已被弃用。RFC 在wiki.php.net/rfc/remove_alternative_php_tags中表示,RFC 并未移除短开标签(<?)或带有echo的短开标签(<?= )。

由于我们已经铺好了创建分页链接的基础,现在我们只需要调用我们的函数。以下脚本就是使用前面的函数创建分页页面所需的全部内容:

$mysqli = mysqli_connect('localhost','<username>','<password>', '<dbname>'); 

   $limit = $_GET['rpp'] ?? 10;    //how many items to show per page default 10; 

   $pagenum = $_GET['pagenum'];  //what page we are on 

   if($pagenum) 
     $start = ($pagenum - 1) * $limit; //first item to display on this page 
   else 
     $start = 0;                       //if no page var is given, set start to 0 
/*Display records here*/ 
$sql = "SELECT * FROM userprofiles LIMIT $start, $limit ";  
$rs_result = mysqli_query ($sql); //run the query 

while($row = mysqli_fetch_assoc($rs_result) { 
?> 
    <tr> 
           <td><?php echo $row['name']; ?></td> 
           <td><?php echo $row['age']; ?></td>  
        <td><?php echo $row['country']; ?></td>            
           </tr> 

<?php 
} 

/* Let's show our page */ 
/* get number of records through  */ 
   $record_count = $db->count_rows('userprofiles');  

$pagination_links =  paginate('listprofiles.php' , $limit, $rec_count); 
 echo display_pagination($paginaiton_links); 

我们页面链接的 HTML 输出在listprofiles.php中会看起来像这样:

<div class="pagination"><table> 
 <tr> 
        <td> <a href="listprofiles.php?pagenum=1&rpp=10">1</a> </td> 
         <td><a href="listprofiles.php?pagenum=2&rpp=10">2</a>  </td> 
        <td><a href="listprofiles.php?pagenum=3&rpp=10">2</a>  </td> 
    </tr> 
</table></div> 

总结

正如你所看到的,我们对 null 合并有很多用例。

我们学习了如何创建一个简单的用户配置文件系统,以及如何在从数据库获取数据时使用 PHP 7 的 null 合并功能,如果没有记录,则返回 null。我们还了解到,null 合并运算符类似于三元运算符,只是如果没有数据,默认返回 null。

在下一章中,我们将有更多用例来使用其他 PHP 7 功能,特别是在为我们的项目创建数据库抽象层时。

第二章:构建一个数据库类和简单的购物车

对于我们以前的应用程序,只是用户配置文件,我们只创建了一个简单的创建-读取-更新-删除(CRUD)数据库抽象层 - 基本的东西。在本章中,我们将创建一个更好的数据库抽象层,它将允许我们做的不仅仅是基本的数据库功能。

除了简单的 CRUD 功能之外,我们将在数据库抽象类中添加结果操作。我们将在我们的数据库抽象类中构建以下功能:

  • 将整数转换为其他更准确的数字类型

  • 数组转对象

  • firstOf()方法:允许我们选择数据库查询结果的第一个结果

  • lastOf()方法:允许我们选择数据库查询结果的最后一个结果

  • iterate()方法:允许我们迭代结果并以我们将发送到此函数的格式返回它

  • searchString()方法:在结果列表中查找字符串

我们可能会根据需要添加更多的功能。在本章的末尾,我们将应用数据库抽象层来构建一个简单的购物车系统。

购物车很简单:已经登录的用户应该能够点击一些出售的物品,点击添加到购物车,并获取用户的详细信息。用户验证了他们的物品后,然后点击购买按钮,我们将把他们的购物车物品转移到购买订单中,他们将填写交货地址,然后保存到数据库中。

构建数据库抽象类

在 PHP 中,创建一个类时,有一种方法可以在每次初始化该类时调用某个方法。这称为类的构造函数。大多数类都有构造函数,所以我们将有自己的构造函数。构造函数的函数名是用两个下划线和construct()关键字命名的,就像这样:function __construct()。两个下划线的函数也被称为魔术方法。

在我们的数据库抽象类中,我们需要创建一个构造函数,以便能够返回mysqli生成的link对象:

 Class DB { 

  public $db; 

  //constructor 
  function __construct($server, $dbname,$user,$pass) { 
    //returns mysqli $link $link = mysqli_connect(''); 
    return $this->db = mysqli_connect($server, $dbname, $user, $pass); 
  } 
} 

原始查询方法

query方法将执行传递给它的任何查询。我们将在query方法中调用 MySQLi 的db->query方法。

它是什么样子的:

public function query($sql) { 
 $results =   $this->db->query($sql); 
 return $results; 
} 

创建方法

对于我们的数据库层,让我们创建create方法。通过这个方法,我们将使用 SQL 语法将项目插入到数据库中。在 MySQL 中,语法如下:

INSERT INTO [TABLE] VALUES ([val1], [val2], [val3]); 

我们需要一种方法将数组值转换为以逗号分隔的字符串:

 function create ($table, $arrayValues) { 
  $query = "INSERT INTO  `" . $table . " ($arrayVal);  //TODO: setup arrayVal 
  $results = $this->db->query($link, $query); 
} 

读取方法

对于我们的db层,让我们创建read方法。通过这个方法,我们将只使用 SQL 语法查询我们的数据库。

MySQL 中的语法如下:

SELECT * FROM [table] WHERE [key] = [value] 

我们需要创建一个能够接受括号中的前置参数的函数:

public function read($table, $key, $value){ 
         $query  = SELECT * FROM $table WHERE `". $key . "` =  " . $value; 
     return $this->db->query($query); 
} 

选择所有方法

我们的read方法接受一个keyvalue对。然而,可能有些情况下我们只需要选择表中的所有内容。在这种情况下,我们应该创建一个简单的方法来选择表中的所有行,它只接受要选择的table作为参数。

在 MySQL 中,您只需使用以下命令选择所有行:

SELECT * FROM [table]; 

我们需要创建一个能够接受括号中的前置参数的函数:

public function select_all($table){ 
         $query  = "SELECT * FROM " . $table; 
     return $this ->query($query); 
} 

删除方法

对于我们的db层,让我们创建delete方法。通过这个方法,我们将使用 SQL 语法删除数据库中的一些项目。

MySQL 语法很简单:

DELETE FROM [table] WHERE [key] = [val]; 

我们还需要创建一个能够接受括号中的前置参数的函数:

public function delete($table, $key, $value){ 
         $query  = DELETE FROM $table WHERE `". $key . "` =  " . $value; 
     return $this->query($query); 
} 

更新方法

对于我们的数据库层,让我们创建一个update方法。通过这个方法,我们将能够使用 SQL 语法更新数据库中的项目。

MySQL 语法如下:

UPDATE [table] SET [key1] = [val1], [key2] => [val2]  WHERE [key] = [value] 

注意

请注意,WHERE子句可以比一个键值对更长,这意味着您可以向语句添加ANDOR。这意味着,除了使第一个键动态化之外,WHERE子句需要能够接受AND/OR作为其参数。

例如,您可以为$where参数编写以下内容,以选择firstnameJohnlastnameDoe的人:

firstname='John' AND lastname='Doe' 

这就是为什么我们在函数中将条件作为一个字符串参数的原因。我们数据库类中的update方法最终将如下所示:

public function update($table, $updateSetArray, $where){ 
     Foreach($updateSetArray as $key => $value) { 
         $update_fields .= $key . "=" . $value . ","; 
     } 
      //remove last comma from the foreach loop above 
     $update_fields = substr($update_fields,0, str_len($update_fields)-1); 
    $query  = "UPDATE " . $table. " SET " . $updateFields . " WHERE " $where; //the where 
    return $this->query($query); 
} 

first_of 方法

在我们的数据库中,我们将创建一个first_of方法,它将过滤掉其余的结果,只获取第一个结果。我们将使用 PHP 的reset函数,它只获取数组中的第一个元素:

//inside DB class  
public function first_of($results) { 
  return reset($results); 
} 

last_of 方法

last_of方法类似;我们可以使用 PHP 的end函数:

//inside DB class  
public function last_of($results) { 
  Return end($results); 
} 

iterate_over 方法

iterate_over方法将是一个简单添加格式的函数 - 在 HTML 代码之前和之后 - 例如,对于我们从数据库中获得的每个结果:

public function iterate_over($prefix, $postfix, $items) { 
    $ret_val = ''; 
    foreach($items as $item) { 
        $ret_val .= $prefix. $item . $postfix; 
    } 
    return $ret_val; 
} 

searchString 方法

给定一组结果,我们将查找某个字段中的内容。这样做的方法是生成类似于以下的 SQL 代码:

    SELECT * FROM {table} WHERE {field} LIKE '%{searchString}%';

该函数将接受表和字段,以检查表中的搜索字符串needle

public function search_string($table, $column, $needle) { 
 $results = $this->query("SELECT * FROM `".$table."` WHERE " .    $column . " LIKE '%" . $needle. "%'"); 
   return $results; 
} 

使用 convert_to_json 方法实现一个简单的 API

有时我们希望数据库的结果以特定格式呈现。一个例子是当我们将结果作为 JSON 对象而不是数组处理时。这在您构建一个简单的 API 以供移动应用程序使用时非常有用。

这可能是可能的,例如,在另一个需要以特定格式(例如 JSON 格式)的系统中,我们可以将对象转换为 JSON 并发送它。

在 PHP 中,有一个json_encode方法,它将任何数组或对象转换为 JSON 表示。我们类的方法将只是将传递给它的值返回为json

function convertToJSON($object) { 
   return json_encode($object); 
   } 

购物车

现在我们将构建一个简化的购物车模块,它将利用我们新建的数据库抽象类。

让我们来规划一下购物车的功能:

  • 购物清单页面

  • 购物者应该看到几个带有名称和价格的物品

  • 购物者应该能够点击每个物品旁边的复选框,将其添加到购物车中

  • 结账页面

  • 物品清单及其价格

  • 总计

  • 确认页面

  • 输入详细信息,如账单地址、账单信用卡号,当然还有名字

  • 购物者还应该能够指定将商品发送到哪个地址

构建购物清单

在这个页面中,我们将创建基本的 HTML 块,以显示购物者可能想要购买的物品清单。

我们将使用与之前相同的模板系统,但是不再将整个代码放在一个页面中,而是将页眉和页脚分开,并简单地在我们的文件中包含它们使用include()。我们还将使用相同的 Bootstrap 框架来使我们的前端看起来漂亮。

物品模板渲染函数

我们将创建一个物品渲染函数,它将在div中渲染所有我们的购物物品。该函数将简单地返回一个带有物品价格、名称和图片的 HTML 标记:

//accepts the database results as an array and calls db functions render_shopping_items($items) 
{ 
$db->iterate_over("<td>", "</td>", $item_name); 
    foreach($items as $item) { 
     $item->name.  ' ' .$item->price . ' ' . $item->pic; 

   } 
$resultTable .= "</table>"; 
} 

在上面的代码中,我们使用了我们新创建的iterate_over函数,该函数格式化数据库的每个值。最终结果是我们有了一个我们想要购买的物品的表格。

让我们创建一个简单的布局结构,每个页面都会得到页眉和页脚,并且从现在开始,只需包含它们:

header.php中:

<html> 
<!doctype html> 
<head> 
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css"> 
</head> 
<body> 

footer.php中:

<div class="footer">Copyright 2016</div></body> 
</html> 

index.php中:

<?php 
require('header.php'); 
//render item_list code goes here 
require('itemslist.php'); //to be coded  
require('footer.php'); 
?> 

现在让我们创建itemslist.php页面,该页面将包含在index.php中:

<?php  
include('DB.php'); 
$db = new DB(); 
$table = 'shopping_items'; 
$results = $db->select_all($table); 
//calling the render function created earlier: 
foreach(as $item) { 
  echo render_shopping_items($results);  
} 

?> 
//shopping items list goes here. 

我们的函数已经准备好了,但是我们的数据库还不存在。我们还需要填充我们的数据库。

通过在我们的 MySQL 数据库中创建shopping_items表来创建一些购物物品:

CREATE TABLE shopping_items ( 
    id INT(11) NOT NULL AUTO_INCREMENT, 
    name VARCHAR(255) NOT NULL, 
    price DECIMAL(6,2) NOT NULL, 
   image VARCHAR(255) NOT NULL, 
PRIMARY KEY  (id)  
); 

让我们运行 MySQL,并将以下物品插入到我们的数据库中:

INSERT INTO `shopping_items` VALUES (NULL,'Tablet', '199.99', 'tablet.png'); 
INSERT INTO `shopping_items` VALUES (NULL, 'Cellphone', '199.99', 'cellphone.png'); 
INSERT INTO `shopping_items` (NULL,'Laptop', '599.99', 'Laptop.png'); 
INSERT INTO `shopping_items` (NULL,'Cable', '14.99', 'Cable.png'); 
INSERT INTO `shopping_items` (NULL, 'Watch', '100.99', 'Watch.png'); 

将其保存在一个名为insert_shopping_items.sql的文件中。然后,在与insert_shopping_items.sql文件相同的目录中:

  1. 登录到 MySQL 客户端并按照以下步骤进行:
**mysql -u root -p**

  1. 然后键入use <数据库名称>
**mysql>  use <database>;**

  1. 使用source命令导入脚本:
**mysql> source insert_shopping_items.sql**

当我们运行SELECT * FROM shopping_items时,我们应该看到以下内容:

项目模板渲染函数

向购物清单页面添加复选框

现在让我们为用户创建 HTML 复选框,以便能够选择购物物品。我们将创建以下形式来插入数据:

//items.php 

<html> 
<body> 

<form action="post_profile.php" method="POST"> 
<table> 
  <input type="checkbox" value="<item_id>"> <td><item_image></td> 
  <td><item_name></td><td> 
 </table> 
</form> 
</body> 
</html> 

为此,我们需要修改我们的render_items方法以添加复选框:

public function render_items($itemsArray)  { 

foreach($itemsArray as $item) { 
  return '<tr> 
           <td><input type="checkbox" name="item[]" value="' . $item->id. '">' .  . '</td><td>' . $item->image .'</td> 
<td>'. $item->name . '</td> 
'<td>'.$item->price . '</td> 
'</tr>'; 
} 
} 

在下一页上,当用户单击提交时,我们将需要获取所有 ID 并存储到一个数组中。

由于我们将复选框命名为item[],我们应该能够通过$_POST['item']作为数组获取值。基本上,所有被选中的物品将作为数组存储在 PHP 的$_POST变量中,这将允许我们获取所有值以保存数据到我们的数据库中。让我们循环遍历结果的 ID,并在我们的数据库中获取每个物品的价格并将每个物品保存在名为itemsArray的数组中,其中键是物品的名称,值是物品的价格。

$db = new DB(); 
$itemsArray= []; //to contain our items - since PHP 5.4, an array can be defined with []; 
foreach($_POST['item'] as $itemId) { 

   $item = $db->read('shopping_items', 'id', $itemId); 
   //this produces the equivalent SQL code: SELECT * FROM shopping_items WHERE id = '$itemId'; 
   $itemsArray[$item->name] = $item-price;  

} 

我们将首先与用户确认购买的物品。现在我们只会将物品和总金额保存到 cookie 中。我们将在结账页面上访问 cookie 的值,该页面将接受用户的详细信息,并在提交结账页面时将其保存到我们的数据库中。

提示

PHP 会话与 cookie:对于不太敏感的数据,例如用户购买的物品清单,我们可以使用 cookie,它实际上将数据(以纯文本形式!)存储在浏览器中。如果您正在构建此应用程序并在生产中使用它,建议使用会话。要了解有关会话的更多信息,请访问php.net/manual/en/features.sessions.php

PHP 中的 Cookies

在 PHP 中,要启动一个 cookie,只需调用setcookie函数。为了将我们购买的物品保存到 cookie 中,我们必须对数组进行序列化,原因是 cookie 只能将值存储为字符串。

在这里,我们将物品保存到 cookie 中:

setcookie('purchased_items', serialize($itemsArray), time() + 900); 

前面的 cookie 将在purchased_items cookie 中将物品存储为数组。它将在 15 分钟后过期(900 秒)。但是,请注意time()函数的调用,它返回当前时间的 Unix 时间戳。在 PHP 中,当达到最后一个参数中设置的时间时,cookie 将会过期。

注意

调试基于 cookie 的应用程序有时会令人沮丧。确保time()生成的时间戳确实显示当前时间。

例如,可能您最近重新格式化了您的计算机,由于某种原因无法正确设置时间。要测试time(),只需运行一个带有time()调用的 PHP 脚本,并检查www.unixtimestamp.com/是否几乎相同。

构建结账页面

最后,我们将创建一个表单,用户可以在结账后输入他们的详细信息。

首先,我们需要为客户建立数据库表。让我们称这个表为purchases。我们需要存储客户的姓名、地址、电子邮件、信用卡、购买的物品和总额。我们还应该存储购买交易的时间,并使用唯一的主键来索引每一行。

以下是要导入到我们的 MySQL 数据库中的表的架构:

CREATE TABLE purchases ( 
    id INT(11) NOT NULL AUTO_INCREMENT, 
    customer_name VARCHAR(255) NOT NULL, 
    address DECIMAL(6,2) NOT NULL, 
    email DECIMAL(6,2) NOT NULL, 
    credit_card VARCHAR(255) NOT NULL, 
    items TEXT NOT NULL, 
    total DECIMAL(6,2) NOT NULL, 
    created DATETIME NOT NULL, 
    PRIMARY KEY (id) 
); 

导入的一种方法是创建一个名为purchases.sql的文件,然后登录到您的 MySQL 命令行工具。

然后,您可以选择要使用的数据库:

**USE <databasename>**

最后,假设您在与purchases.sql相同的目录中,您可以运行:

**SOURCE purchases.sql** 

最后,通过创建一个简单的表单,包括地址、信用卡和买家姓名等详细信息的输入字段来完成:

<form action="save_checkout.php" method="post"> 
<table> 
  <tr> 
   <td>Name</td><td><input type="text" name="fullname"></td>  
  </tr>  

 <tr> 
<td>Address</td><td><input type="text" name="address"></td> 
</tr> 
<tr> 
<td>Email</td><td><input type="text" name="email"></td> 
</tr> 

<tr>  
  <td>Credit Card</td><td><input type="text" name="credit_card"></td> 
 </tr> 
<tr>  
  <td colspan="2"><input type="submit" name="submit" value="Purchase"></td> 
 </tr> 

</table> 
</form> 

这是它的样子:

构建结账页面

最后,我们将像往常一样将所有内容保存到我们的数据库中的另一个表中,使用我们的DB类。为了计算总金额,我们将查询数据库的价格,并使用 PHP 的array_sum来获得总金额:

$db = new DB($server,$dbname,$name,$password); 

//let's get the other details of the customer 
$customer_name = $_POST['fullname']; 
$address = $_POST['address']; 
$email = $_POST['email']; 
$credit_card = $_POST['credit_card]; 
$time_now = date('Y-m-d H:i:s'); 

foreach($purchased_items as $item) { 
  $prices[] = $item->price; 
} 

//get total using array_sum 
$total = array_sum($prices); 

$db->insert('purchases', [ 
   'address' => $address, 
'email' => $email, 
'credit_card' => $credit_card, 
 **'items' => //<all the items and prices>//,** 
   'total' => $total, 
    'purchase_date' => $timenow 
  ]);  
?> 

为了保持简单,正如您在突出显示的代码中所看到的,我们需要将所有购买的物品收集到一个长字符串中,以保存在我们的数据库中。以下是如何连接每个物品和它们的价格:

foreach($purchased_items as $item) { 
   $items_text .= $item->name ":" . $item->price .  "," 
} 

然后我们可以将这些数据保存到变量$items_text中。我们将更新前面突出显示的代码,并将文本<所有物品和价格>更改为$items_text

... 
  'items' => $items_text 
 ... 

在我们的代码中,foreach循环应该放在调用$db->insert方法之前。

感谢页面

最后,我们已经将数据保存到我们的purchased_items表中。现在是时候向我们的客户说声谢谢并发送一封电子邮件了。在我们的thankyou.php的 HTML 代码中,我们将只写一张感谢便条,并让用户知道他们的购买情况即将收到一封电子邮件。

这是一个屏幕截图:

感谢页面

我们将文件命名为thankyou.php,它的 HTML 代码非常简单:

<!DOCTYPE html> 
<html> 
<head> 
   <!-- Latest compiled and minified CSS --> 
   <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous"> 
   <title>Thank you for shopping at example.info</title> 
</head> 
<body> 
 <div class="container"> 
        <div class="row"> 
            <div class="col-lg-12 text-center"> 
                <h1>Thank you for shopping at example.info</h1> 
                     <p>Yey! We're really happy for choosing us to shop online. We've sent you an email of your purchases. </p> 
                     <p>Let us know right away if you need anything</p> 
            </div> 
        </div> 
    </div> 
</body> 
</html> 

使用 PHP 发送电子邮件是使用mail()函数完成的:

mail("<to address>", "Your purchase at example.com","Thank you for purchasing...", "From: <from address>"); 

第三个参数是我们电子邮件的消息。在代码中,我们仍然需要添加购买的细节。我们将循环遍历我们之前制作的 cookie 和价格,然后输出总金额,并发送消息:

$mail_message = 'Thank you for purchasing the following items'; 
$prices = []; 
$purchased_items = unserialize($_COOKIE['purchased_items']); 
foreach($purchased_items as $itemName => $itemPrice) { 
  $mail_message .=  $itemName . ": " .$itemPrice . "\r\n \r\n"; 
  //since this is a plain text email, we will use \r\n - which are escape strings for us to add a new line after each price. 
  $prices[] = $itemPrice; 
} 

$mail_message .= "The billing total of your purchases is " . array_sum($prices); 

mail($_POST['email'], "Thank you for shopping at example.info here is your bill", $mail_message, "From: billing@example.info"); 

我们可以将前面的代码添加到我们的thankyou.php文件的最后。

安装 TCPDF

您可以从 sourceforge 下载 TCPDF 库,sourceforge.net/projects/tcpdf/

TCPDF 是用于编写 PDF 文档的 PHP 类。

一个带有 TCPDF 示例的 PHP 示例代码如下:

//Taken from http://www.tcpdf.org/examples/example_001.phps 

// Include the main TCPDF library (search for installation path). 
require_once('tcpdf_include.php'); 

// create new PDF document 
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false); 

// set document information 
$pdf->SetCreator(PDF_CREATOR); 
$pdf->SetAuthor('Nicola Asuni'); 
$pdf->SetTitle('TCPDF Example 001'); 
$pdf->SetSubject('TCPDF Tutorial'); 
$pdf->SetKeywords('TCPDF, PDF, example, test, guide'); 

// set default header data 
$pdf->SetHeaderData(PDF_HEADER_LOGO, PDF_HEADER_LOGO_WIDTH, PDF_HEADER_TITLE.' 001', PDF_HEADER_STRING, array(0,64,255), array(0,64,128)); 
$pdf->setFooterData(array(0,64,0), array(0,64,128)); 

// set header and footer fonts 
$pdf->setHeaderFont(Array(PDF_FONT_NAME_MAIN, '', PDF_FONT_SIZE_MAIN)); 
$pdf->setFooterFont(Array(PDF_FONT_NAME_DATA, '', PDF_FONT_SIZE_DATA)); 

// set default monospaced font 
$pdf->SetDefaultMonospacedFont(PDF_FONT_MONOSPACED); 

// set margins 
$pdf->SetMargins(PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT); 
$pdf->SetHeaderMargin(PDF_MARGIN_HEADER); 
$pdf->SetFooterMargin(PDF_MARGIN_FOOTER); 

// set auto page breaks 
$pdf->SetAutoPageBreak(TRUE, PDF_MARGIN_BOTTOM); 

// set image scale factor 
$pdf->setImageScale(PDF_IMAGE_SCALE_RATIO); 

// set some language-dependent strings (optional) 
if (@file_exists(dirname(__FILE__).'/lang/eng.php')) { 
    require_once(dirname(__FILE__).'/lang/eng.php'); 
    $pdf->setLanguageArray($l); 
} 

// --------------------------------------------------------- 

// set default font subsetting mode 
$pdf->setFontSubsetting(true); 

// Set font 
// dejavusans is a UTF-8 Unicode font, if you only need to 
// print standard ASCII chars, you can use core fonts like 
// helvetica or times to reduce file size. 
$pdf->SetFont('dejavusans', '', 14, '', true); 

// Add a page 
// This method has several options, check the source code documentation for more information. 
$pdf->AddPage(); 

// set text shadow effect 
$pdf->setTextShadow(array('enabled'=>true, 'depth_w'=>0.2, 'depth_h'=>0.2, 'color'=>array(196,196,196), 'opacity'=>1, 'blend_mode'=>'Normal')); 

// Set some content to print 
$html = <<<EOD 
<h1>Welcome to <a href="http://www.tcpdf.org" style="text-decoration:none;background-color:#CC0000;color:black;">&nbsp;<span style="color:black;">TC</span><span style="color:white;">PDF</span>&nbsp;</a>!</h1> 
<i>This is the first example of TCPDF library.</i> 
<p>This text is printed using the <i>writeHTMLCell()</i> method but you can also use: <i>Multicell(), writeHTML(), Write(), Cell() and Text()</i>.</p> 
<p>Please check the source code documentation and other examples for further information.</p> 
<p style="color:#CC0000;">TO IMPROVE AND EXPAND TCPDF I NEED YOUR SUPPORT, PLEASE <a href="http://sourceforge.net/donate/index.php?group_id=128076">MAKE A DONATION!</a></p> 
EOD; 

// Print text using writeHTMLCell() 
$pdf->writeHTMLCell(0, 0, '', '', $html, 0, 1, 0, true, '', true); 

// --------------------------------------------------------- 

// Close and output PDF document 
// This method has several options, check the source code documentation for more information. 
$pdf->Output('example_001.pdf', 'I'); 

有了这个例子,我们现在可以使用前面的代码并稍微修改一下,以便创建我们自己的发票。我们只需要相同的 HTML 样式和我们总计生成的值。让我们使用相同的代码并更新值到我们需要的值。

在这种情况下,我们将设置作者为网站的名称example.info。并将我们的主题设置为发票

首先,我们需要获取主要的 TCPDF 库。如果您将其安装在不同的文件夹中,我们可能需要提供一个相对路径,指向tcpdf_include.php文件:

require_once('tcpdf_include.php'); 

这将使用类的默认方向和默认页面格式实例化一个新的 TCPDF 对象:

$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false); 

$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false); 

// set document information 
$pdf->SetCreator(PDF_CREATOR); 
$pdf->SetAuthor('Example.Info'); 
$pdf->SetTitle('Invoice Purchases'); 
$pdf->SetSubject('Invoice'); 
$pdf->SetKeywords('Purchases, Invoice, Shopping'); 
s 

$html = <<<EOD 
<h1>Example.info Invoice </h1> 
<i>Invoice #0001.</i> 
EOD; 

现在,让我们使用 HTML 来创建一个客户购买的 HTML 表格:

$html .= <<<EOD 
<table> 
  <tr> 
    <td>Item Purchases</td> 
    <td>Price</td> 
  </tr> 
EOD; 

注意

这种多行字符串的写法被称为 heredoc 语法。

让我们通过实例化我们的DB类来创建与数据库的连接:

$db = new DBClass('localhost','root','password', 'databasename'); 
We shall now query our database with our database class: 

$table = 'purchases'; 
$column = 'id';  
$findVal = $_GET['purchase_id']; 

   $result = $db->read ($table, $column, $findVal); 

foreach($item = $result->fetch_assoc()) { 
$html .=   "<tr> 
         <td>". $item['customer_name']. "</td> 
         <td>" . $item['items'] . " 
</tr>"; 

$total = $items['total']; //let's save the total in a variable for printing in a new row 

} 

$html .= '<tr><td colspan="2" align="right">TOTAL: ' ".$total. " ' </td></tr>'; 

$html .= <<<EOD 
</table> 
EOD; 

$pdf->writeHTML($html, true, false, true, false, ''); 

$pdf->Output('customer_invoice.pdf', 'I'); 

在创建 PDF 时,重要的是要注意,大多数 HTML 到 PDF 转换器都是简单创建的,并且可以解释简单的内联 CSS 布局。我们使用表格打印出每个项目,这对于表格数据来说是可以的。它为布局提供了结构,并确保事物被正确对齐。

管理购买的管理员

我们将建立管理系统来处理所有的购买。这是为了跟踪每个从我们网站购买东西的客户。它将包括两个页面:

  • 所有购买了东西的客户的概述

  • 能够查看客户购买的物品

我们还将在这些页面上添加一些功能,以便管理员更容易地更改客户的信息。

我们还将创建一个简单的htaccess apache 规则,以阻止其他人访问我们的管理站点,因为它包含非常敏感的数据。

让我们首先开始选择我们purchases表中的所有数据:

<?php 
//create an html variable for printing the html of our page: 
$html = '<!DOCTYPE html><html><body>'; 

$table = 'purchases'; 
$results = $db->select_all($table); 

//start a new table structure and set the headings of our table: 
$html .= '<table><tr> 
    <th>Customer name</th> 
    <th>Email</th> 
    <th>Address</th> 
    <th>Total Purchase</th> 
</tr>'; 

//loop through the results of our query: 
while($row = $results->fetch_assoc()){ 
    $html .= '<tr><td>'$row['customer_name'] . '</td>'; 
    $html .= '<td>'$row['email'] . '</td>'; 
    $html .= '<td>'$row['address'] . '</td>'; 
    $html .= '<td>'$row['purchase_date'] . '</td>'; 
    $html .= '</tr>'; 
} 

$html .= '</table>'; 
$html .= '</body></html>; 

//print out the html 
echo $html; 

现在,我们将添加一个链接到我们客户数据的另一个视图。这个视图将使管理员能够查看他们所有的购买。我们可以通过在客户姓名上添加一个链接,将第一个页面链接到客户购买的详细视图,方法是将我们添加客户姓名到$html变量的行更改为:

    $html .= '<tr><td><a href="view_purchases.php?pid='.$row['id'] .'">'$row['customer_name'] . '</a></td>'; 

请注意,我们已经将$row['id']作为 URL 的一部分。现在我们可以通过$_GET['pid']值访问我们将要获取的数据的 ID 号。

让我们在一个新文件view_purchases.php中创建查看客户购买物品的代码:

<?php 
//create an html variable for printing the html of our page: 
$html = '<!DOCTYPE html><html><body>'; 

$table = 'purchases; 
$column = 'id'; 
**$purchase_id = $_GET['pid'];** 
$results = $db->read($table, $column, $purchase_id);  
//outputs: 
// SELECT * FROM purchases WHERE id = '$purchase_id'; 

//start a new table structure and set the headings of our table: 
$html .= '<table><tr><th>Customer name</thth>Total Purchased</th></tr>'; 
//loop through the results of our query: 
while($row = $results->fetch_assoc()){ 
    $html .= '<tr><td>'$row['customer_name'] . '</td>'; 
    $html .= '<tr><td>'$row['email'] . '</td>'; 
    $html .= '<tr><td>'$row['address'] . '</td>'; 
    $html .= '<tr><td>'$row['purchase_date'] . '</td>'; 
    $html .= '</tr>'; 
} 
$html .= '</table>'; 
echo $html; 

在上面的代码中,我们使用了$_GET['id']变量来查找客户的确切购买记录。虽然我们可以只使用客户姓名来查找表purchases中客户的购买记录,但这将假定客户只通过我们的系统购买了一次。此外,我们没有使用客户姓名来确定我们是否有时有相同姓名的客户。

通过使用表purchases的主要 ID,在我们的情况下,通过选择id字段来确保我们选择了特定的唯一购买。请注意,由于我们的数据库很简单,我们可以只查询数据库中的一个表 - 在我们的情况下是purchases表。

也许一个更好的实现方法是将“购买”表分成两个表 - 一个包含客户的详细信息,另一个包含已购买物品的详细信息。这样,如果同一个客户返回,他们的详细信息可以在下次自动填写,我们只需要将新购买的物品链接到他们的账户上。

在这种情况下,“购买”表将简单地称为“已购买物品”表,每个物品将与客户 ID 相关联。客户的详细信息将存储在一个包含其唯一地址、电子邮件和信用卡详细信息的“客户”表中。

然后,您将能够向客户展示他们的购买历史。每次客户从商店购买物品时,交易日期将被记录下来,您需要按照每笔交易的日期和时间对历史记录进行排序。

总结

太好了,我们完成了!

我们刚刚学会了如何构建一个简单的数据库抽象层,以及如何将其用于购物车。我们还学习了关于 cookies 和使用 TCPDF 库构建发票。

在下一章中,我们将构建一个完全不同的东西,并使用会话来保存用户的当前信息,以构建基于 PHP 的聊天系统。

第三章:构建社交通讯服务

根据可靠的词典,通讯是定期发布给社会、企业或组织成员的公告。

在本章中,我们将构建一个电子邮件通讯,允许会员订阅和取消订阅,接收特定类别的更新,并允许营销人员检查有多少人访问了某个链接。

我们将为用户构建一个身份验证系统,以便登录和退出通讯管理系统,这是一个社交登录系统,供订阅会员轻松检查其订阅以及为订阅者和管理员提供简单的仪表板。

身份验证系统

在本章中,我们将实现一个新的身份验证系统,以允许通讯通讯的管理员进行身份验证。自 PHP5 以来,PHP 已经改进并添加了一个功能,面向对象的开发人员已经用来分隔命名空间。

让我们首先定义一个名为Newsletter的命名空间,如下所示:

<?php 
namespace Newsletter;  
//this must always be in every class that will use namespaces 
class Authentication { 
} 
?> 

在上面的示例中,我们的Newsletter命名空间将有一个Authentication类。当其他类或 PHP 脚本需要使用NewsletterAuthentication类时,他们可以简单地使用以下代码声明它:

Use Newsletter\Authentication; 

在我们的Newsletter类中,让我们使用bcrypt创建一个简单的用户检查,这是一种流行且安全的创建和存储散列密码的方法。

注意

自 PHP 5.5 以来,bcrypt 已内置到password_hash() PHP 函数中。PHP 的password_hash()函数允许密码成为散列。相反,当您需要验证散列是否与原始密码匹配时,可以使用password_verify()函数。

我们的类将非常简单-它将有一个用于验证输入的电子邮件地址和散列密码是否与数据库中的相同的函数。我们必须创建一个只有一个方法verify()的简单类,该方法接受用户的电子邮件和密码。我们将使用bcrypt来验证散列密码是否与我们数据库中的相同:

Class Authorization { 
     public function verify($email, $password) { 
         //check for the $email and password encrypted with bcrypt 
         $bcrypt_options = [ 
            'cost' => 12, 
            'salt' => 'secret' 
         ]; 
         $password_hash = password_hash($password, PASSWORD_BCRYPT, $bcrypt_options); 
         $q= "SELECT * FROM users WHERE email = '". $email. "' AND password = '".$password_hash. "'"; 
         if($result = $this->db->query($q)) { 
                     while ($obj = results->fetch_object()) { 
                           $user_id = $obj->id; 
} 

         } else { 
   $user_id = null; 
} 
         $result->close(); 
         $this->db->close(); 
         return $user_id; 

    } 
} 

然而,我们需要让DB类能够在我们的数据库中执行简单的查询。对于这个简单的一次性项目,我们可以在我们的Authentication类中简单使用依赖注入的概念。

我们应该创建一个相当简单的 IOC 容器类,它允许我们实例化数据库。

让我们称之为DbContainer,它允许我们将类(例如Authentication)连接到DB类:

Namespace Newsletter; 
use DB; 
Class DbContainer { 
   Public function getDBConnection($dbConnDetails) {  
   //connect to database here: 
    $DB = new \DB($server, $username, $password, $dbname); 
       return $DB; 
  } 
} 

但是,如果您立即使用此函数,将会出现一个错误,指出找不到文件并将加载DB类。

以前,我们使用了use系统来要求类。为了使其工作,我们需要创建一个自动加载程序函数来加载我们的DB类,而无需使用require语句。

在 PHP 中,我们可以创建spl_autoload_register函数,它将自动处理所需的文件。

以下是基于 PHP 手册中的示例的示例实现:

<?php 
/** 
 * After registering this autoload function with SPL, the following line 
 * would cause the function to attempt to load the \Newsletter\Qux class 
 * from /path/to/project/src/Newsletter/Qux.php: 
 *  
 *      new \Newsletter\Qux; 
 *       
 * @param string $class The fully-qualified class name. 
 * @return void 
 */ 
spl_autoload_register(function ($class) { 
    // project-specific namespace prefix 
    $prefix = 'Newsletter'; 
    // base directory for the namespace prefix 
    $base_dir = __DIR__ . '/src/'; 
    // does the class use the namespace prefix? 
    $len = strlen($prefix); 
    if (strncmp($prefix, $class, $len) !== 0) { 
        // no, move to the next registered autoloader 
        return; 
    } 
    // get the relative class name 
    $relative_class = substr($class, $len); 
    // replace the namespace prefix with the base directory,               //replace namespace 
    // separators with directory separators in the relative class      //name, append 
    // with .php 
    $file = $base_dir . str_replace('', '/', $relative_class) . '.php'; 
    // if the file exists, require it 
    if (file_exists($file)) { 
        require $file; 
    } 
}); 

使用上述代码,我们现在需要创建一个src目录,并在应用程序中使用此分隔符\\约定来分隔文件夹结构。

使用此示例意味着我们需要将数据库类文件DB.class.php放在src文件夹中,并将文件名重命名为DB.php

这样做是为了当您在另一个 PHP 脚本中指定要使用DB类时,PHP 将在后台自动执行require src/DB.php

继续使用我们的示例DbContainer,我们需要以某种方式将所有配置信息(即数据库名称、用户名和密码)传递到DbContainer中。

让我们简单地创建一个名为dbconfig.php的文件,其中包含数据库详细信息并将其作为对象返回,并要求它:

//sample dbconfig.php 
return array('server' => 'localhost', 
  'username' => 'root', 
  'password => '', 
  'dbname' => 'newsletterdb' 
); 

在我们的DbContainer类中,让我们创建一个loadConfig()函数,从dbconfig.php文件中读取,并实例化一个数据库连接:

Class DbContainer { 
public function  loadConfig ($filePath) { 

   if($filePath) { 
     $config = require($filePath); 
     return $config; //contains the array  
   } 

} 

现在我们需要创建一个connect()方法,这将使我们能够简单地连接到 MySQL 数据库并仅返回连接:

Class DB { 
 //... 
public function connect($server, $username, $password, $dbname) { 
   $this->connection = new MySQLI($server, $username, $password, $dbname); 
     return $this->connection; 
} 
} 

通过不将文件名硬编码到我们的函数中,我们使我们的函数更加灵活。在调用loadConfig()时,我们需要将config文件的路径放入。

我们还使用了$this关键字,这样每当我们需要引用DB类中的其他函数时,我们只需在自动加载程序加载并实例化DB类后调用$DB->nameOfMethod(someParams)

有了这个,我们现在可以轻松地更改config文件的路径,以防我们将config文件移动到其他路径,例如,到一个通过 Web 直接访问的文件夹。

然后,我们可以轻松地使用这个函数,并在一个单独的类中生成一个数据库实例,例如,在我们的Newsletter类中,我们现在可以引用DB类连接的一个实例,并在Newsletter类中实例化它。

现在我们完成了这一步,我们应该简单地创建一个 Bootstrap 文件,加载spl_autoload_register函数和使用dbContainer连接到数据库。让我们将文件命名为bootstrap.php,它应该包含以下内容:

require('spl_autoloader_function.php'); 

$dbContainer = new \DBContainer; //loads our DB from src folder, using the spl_autoload_functionabove. 

$dbConfig = $db->getConfig('dbconfig.php'); 

$dbContainer = getDB($dbConfig); //now contains the array of database configuration details 

下一步是使用以下代码连接到数据库:

$DB = new \DB;  
$DBConn = $DB->connect($dbContainer['server'],$dbContainer['username'],$dbContainer['password'],$dbContainer['dbname']); 

当我们都连接到数据库之后,我们需要重写我们的授权查询,以使用新初始化的类。

让我们在我们的DB类中创建一个简单的select_where方法,然后从Authorization类中调用它:

public function select_where($table, $where_clause) { 
   return $this->db->query("SELECT * FROM ". $table." WHERE " . $where_clause); 
} 

Authorization类现在如下所示:

Class Authorization { 
    //this is used to get the database class into Authorization  
    Public function instantiateDB($dbInstance){ 
       $this->db = $dbInstance; 
    } 

    public function verify($email, $password) { 
         //check for the $email and password encrypted with bcrypt 
         $bcrypt_options = [ 
            'cost' => 12, 
            'salt' => 'secret' 
         ]; 
         $password_hash = password_hash($password, PASSWORD_BCRYPT, $bcrypt_options); 
         //select with condition 
         $this->db->select_where('users', "email = '$email' AND password = '$password_hash'"); 
         if($result = $this->db->query($q)) { 
                     while ($obj = results->fetch_object()) { 
                           $user_id = $obj->id; 
} 

         } else { 
   $user_id = null; 
} 
         $result->close(); 
         $this->db->close(); 
         return $user_id; 

    } 
} 

为会员创建社交登录

为了让更多人轻松订阅,我们将实现一种方式,让 Facebook 用户可以简单地登录并订阅我们的通讯,而无需输入他们的电子邮件地址。

通过Oauth登录 Facebook 通过生成应用程序认证令牌开始。第一步是转到developers.facebook.com/

您应该看到您的应用程序列表,或者点击应用程序进行创建。您应该看到类似以下截图的内容:

为会员创建社交登录

您应该首先创建一个应用程序,并且可以通过访问应用程序创建页面来获取您的应用程序 ID 和应用程序密钥,类似于以下截图:

为会员创建社交登录

在创建新应用程序时,Facebook 现在包括了一种测试应用程序 ID 的方法。

它看起来像这样:

为会员创建社交登录

这是为了测试应用程序 ID 是否有效。这是可选的,您可以跳过这一步,只需将应用程序 ID 和应用程序密钥的值插入到前面截图中显示的代码中。

现在让我们创建fbconfig.php文件,其中将包含一种使用 Facebook SDK 库启用会话的方法。

fbconfig.php脚本将包含以下内容:

<?php 
session_start(); 
$domain = 'http://www.socialexample.info'; 
require_once 'autoload.php'; 

use FacebookFacebookSession; 
use FacebookFacebookRedirectLoginHelper; 
use FacebookFacebookRequest; 
use FacebookFacebookResponse; 
use FacebookFacebookSDKException; 
use FacebookFacebookRequestException; 
use FacebookFacebookAuthorizationException; 
use FacebookGraphObject; 
use FacebookEntitiesAccessToken; 
use FacebookHttpClientsFacebookCurlHttpClient; 
use FacebookHttpClientsFacebookHttpable; 

// init app with app id and secret (get from creating an app) 
$fbAppId = '123456382121312313'; //change this. 
$fbAppSecret = '8563798aasdasdasdweqwe84'; 
FacebookSession::setDefaultApplication($fbAppId, $fbAppSecret); 
// login helper with redirect_uri 
    $helper = new FacebookRedirectLoginHelper($domain . '/fbconfig.php' ); 
try { 
  $session = $helper->getSessionFromRedirect(); 
} catch( FacebookRequestException $ex ) { 
echo "Hello, sorry but we've encountered an exception and could not log you in right now"; 
} catch( Exception $ex ) { 
  // Tell user something has happened 
  echo "Hello, sorry but we could not log you in right now";       
} 
// see if we have a session 
if ( isset( $session ) ) { 
  // graph api request for user data 
  $request = new FacebookRequest( $session, 'GET', '/me' ); 
  $response = $request->execute(); 
  // get response 
//start a graph object with the user email 
  $graphObject = $response->getGraphObject(); 
  $id = $graphObject->getProperty('id');  
  $fullname = $graphObject->getProperty('name');  
  $email = $graphObject->getProperty('email'); 

     $_SESSION['FB_id'] = $id;            
     $_SESSION['FB_fullname'] = $fullname; 
     $_SESSION['FB_email'] =  $email; 

//save user to session 
     $_SESSION['UserName'] = $email; //just for demonstration purposes 
//redirect user to index page        
    header("Location: index.php"); 
} else { 
  $loginUrl = $helper->getLoginUrl(); 
 header("Location: ".$loginUrl); 
} 
?> 

在这里,我们基本上通过session_start()开始一个会话,并通过将其保存到一个变量中设置我们网站的域。然后自动加载 FB SDK,这将需要 Facebook 访问其 API 所需的文件和类来访问。

然后,我们使用use关键字在其他 Facebook SDK 类上设置了几个依赖项。我们使用我们的应用程序 ID 和应用程序密钥设置了facebookSession类,然后尝试通过调用getSessionfromRedirect()方法启动会话。

如果有任何错误被捕获尝试启动会话,我们只需让用户知道我们无法登录他,但如果一切顺利进行,我们将以用户的电子邮件开始一个图形对象。

为了演示目的,我们保存一个用户名,实际上是用户的电子邮件地址,一旦我们通过 Facebook 图表获取了电子邮件。

无论如何,我们将通过检查他们的电子邮件地址对每个人进行身份验证,并且为了让用户更容易登录,让我们只将他们的电子邮件存储为用户名。

我们需要用index.php完成我们的网站,向用户展示我们网站内部的内容。我们在从 Facebook 页面登录后,将用户重定向到index.php页面。

现在我们将保持简单,并从登录的用户的 Facebook 个人资料中显示全名。我们将添加一个注销链接,以便用户有注销的选项:

<?php 
session_start();  
?> 
<!doctype html> 
<html > 
  <head> 
    <title>Login to SocialNewsletter.com</title> 
<link href=" https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet">  
 </head> 
  <body> 
  <?php if ($_SESSION['FB_id']): ?>      <!--  After user login  --> 
<div class="container"> 
<div class="hero-unit"> 
  <h1>Hello <?php echo $_SESSION['UserName']; ?></h1> 
  <p>How to login with PHP</p> 
  </div> 
<div class="span4"> 
 <ul class="nav nav-list"> 
<li class="nav-header">FB ID: <?php echo $_SESSION['FB_id']; ?></li> 
<li> Welcome <?php echo $_SESSION['FB_fullName']; ?></li> 
<div><a href="logout.php">Logout</a></div> 
</ul></div></div> 
    <?php else: ?>     <!-- Before login -->  
<div class="container"> 
<h1>Login with Facebook</h1> 
           Not Connected with Facebook. 
<div> 
      <a href="fbconfig.php">Login with Facebook</a></div> 
      </div> 
    <?php endif ?> 
  </body> 
</html> 

登录后,我们只需为用户显示仪表板。我们将在下一节讨论如何为用户创建基本仪表板。

会员仪表板

最后,当会员登录我们的应用程序时,他们现在可以使用会员订阅页面订阅通讯。让我们首先构建用于存储会员详细信息和他们订阅的数据库。member_details表将包括以下内容:

  • firstnamelastname:用户的真实姓名

  • email:能够给用户发送电子邮件

  • canNotify:布尔值(true 或 false),如果他们同意通过电子邮件接收有关其他优惠的通知

提示

关于 MySQL 中的布尔类型有趣的是,当您创建使用布尔值(true 或 false)的字段时,MySQL 实际上只将其别名为TINYINT(1)。布尔基本上是 0 表示 false,1 表示 true。有关更多信息,请参阅dev.mysql.com/doc/refman/5.7/en/numeric-type-overview.html

member_details表将处理此事,并将使用以下 SQL 代码创建:

CREATE TABLE member_details(  
  id INT(11) PRIMARY KEY AUTO_INCREMENT, 
  firstname VARCHAR(255), 
  lastname VARCHAR(255), 
  email VARCHAR(255), 
  canNotify TINYINT(1), 
  member_id INT(11) 
); 

登录时,我们的会员将存储在users表中。让我们使用以下 SQL 代码创建它:

CREATE TABLE users ( 
   id INT(11) PRIMARY KEY AUTO_INCREMENT 
   username VARCHAR(255), 
   password VARCHAR(255), 
); 

现在,构建一个视图,向我们的会员展示我们拥有的所有不同订阅。我们通过检查subscriptions表来实现这一点。subscriptions表模式定义如下:

  • id Int(11):这是subscriptions表的主键,并设置为AUTO_INCREMENT

  • newsletter_id Int(11):这是他们订阅的newsletter_id

  • active BOOLEAN:这表示用户当前是否订阅(默认为 1)

使用 SQL,它将如下所示:

CREATE TABLE subscriptions ( 
  `id` INT(11) PRIMARY KEY AUTO_INCREMENT, 
  `newsletter_id` INT(11) NOT NULL, 
  `member_id` INT(11) NOT NULL, 
  `active` BOOLEAN DEFAULT true 
); 

我们还需要创建newsletters表,其中将以 JSON 格式保存所有通讯、它们的模板和内容。通过在我们的数据库中使用 JSON 作为存储格式,现在应该很容易从数据库中获取数据并将 JSON 解析为适当的值插入到我们的模板中。

由于我们的通讯将存储在数据库中,我们需要为其创建适当的 SQL 模式。设计如下:

  • Id INT(11):在数据库中索引我们的通讯

  • newsletter_name(文本):我们通讯的标题

  • newsletter_count INT(11):记录我们特定通讯的版本

  • Status(字符串):记录我们的通讯的状态,是否已发布、未发布或待发布

  • Slug(字符串):能够在我们的社交通讯网站上使用浏览器查看通讯

  • Template(文本):存储 HTML 模板

  • Content(文本):存储将进入我们的 HTML 模板的数据

  • 发布日期(日期):记录发布日期

  • Created_at(日期):记录通讯首次创建的时间

  • Updated_at(日期):记录上次有人更新通讯的时间

其 SQL 如下:

CREATE TABLE newsletters ( 
id INT(11) PRIMARY KEY AUTO_INCREMENT, 
newsletter_name (TEXT), 
newsletter_count INT(11) NOT NULL DEFAULT '0', 
marketer_id INT(11) NOT NULL, 
is_active TINYINT(1), 
created_at DATETIME, 

); 

当用户取消订阅时,这将帮助指示他们先前订阅了此通讯。这就是为什么我们将存储一个active字段,以便当他们取消订阅时,而不是删除记录,我们只需将其设置为 0。

marketer_id将在未来的管理部分中使用,其中我们提到将负责管理新闻通讯订阅的人员。

新闻通讯也可能有许多出版物,这些出版物将是实际发送给每个订阅的新闻通讯。以下 SQL 代码是用来创建出版物的:

CREATE TABLE publications ( 
  newsleterId INT(11) PRIMARY KEY AUTO_INCREMENT, 
  status VARCHAR(25), 
  content TEXT, 
  template TEXT, 
  sent_at DATETIME, 
  created_at DATETIME, 
); 

现在让我们在我们的Newsletter类中构建方法,以选择已登录会员的订阅以显示到我们的仪表板:

Class Dashboard { 
  public function getSubscriptions($member_id) { 
  $query = $db->query("SELECT * FROM subscriptions, newsletters WHERE subscriptions.member_id ='". $member_id."'"); 
  if($query->num_rows() > 0) { 
      while ($row = $result->fetch_assoc()) { 
          $data  = array(  
            'name' => $row->newsletter_name,  
            'count' => $row->newsletter_count, 
            'mem_id' => $row->member_id,  
            'active' => $row->active 
         ); 
      } 
      return $data; 
  }  
} 
} 

从上述代码中,我们只是创建了一个函数,用于获取给定会员 ID 的订阅。首先,我们创建了"SELECT * FROM subscriptions, newsletters WHERE subscriptions.member_id ='". $member_id."查询。之后,我们使用 MySQLi 结果对象的fetch_assoc()方法循环遍历查询结果。现在我们已经将其存储在$data变量中,我们返回该变量,并在以下代码中通过调用以下函数在表中显示数据:

 $member_id = $_SESSION['member_id']; 
 $dashboard = new Dashboard; 
 $member_subscriptions = $dashboard->getSubscriptions($member_id); 
 ?> 
  <table> 
    <tr> 
      <td>Member Id</td><td>Newsletter Name</td><td>Newsletter count</td><td>Active</td> 
     </tr> 
<?php 
 foreach($member_subscriptions as $subs) { 
    echo '<tr> 
     <td>'. $subs['mem_id'] . '</td>' .  
     '<td>' . $subs['name'].'</td>' .  
     '<td>' . $subs['count'] . '</td>'. 
     '<td>' . $subs['active'] . '</td> 
     </tr>'; 
 } 
 echo '</table>'; 

营销人员仪表盘

我们的营销人员,他们管理自己拥有的每个新闻通讯,将能够登录到我们的系统,并能够看到有多少会员订阅了他们的电子邮件地址。

这将是一个管理员系统,使营销人员能够更新会员记录,查看最近的订阅,并允许营销人员向其新闻通讯的任何会员发送自定义电子邮件。

我们将有一个名为marketers的表,其中将包含以下字段:

  • id:用于存储索引

  • 营销人员的姓名:用于存储营销人员的姓名

  • 营销人员的电子邮件:用于存储营销人员的电子邮件地址

  • 营销人员的密码:用于存储营销人员的登录密码

我们用于创建上述字段的 SQL 很简单:

CREATE TABLE marketers ( 
id INT(11) AUTO_INCREMENT, 
marketer_name VARCHAR(255) NOT NULL, 
marketer_email VARCHAR(255) NOT NULL, 
marketer_password VARCHAR(255) NOT NULL, 

PRIMARY KEY `id`  
); 

在另一个表中,我们将定义营销人员及其管理的新闻通讯的多对多关系。

我们需要一个id作为索引,拥有新闻通讯的营销人员的 ID,以及营销人员拥有的新闻通讯的 ID。

创建此表的 SQL 如下:

CREATE TABLE newsletter_admins ( 
  Id INT(11) AUTO_INCREMENT, 
  marketer_id INT(11) , 
  newsletter_id INT(11), 
  PRIMARY KEY `id`, 
); 

现在让我们构建一个查询,以获取他们拥有的新闻通讯的管理员。这将是一个简单的类,我们将引用所有我们的数据库函数:

<?php  
class NewsletterDb { 
public $db; 

function __construct($dbinstance) { 
$this->db = $dbinstance; 
} 

//get admins = marketers 
public function get_admins ($newsletter_id) { 
$query = "SELECT * FROM newsletter_admins LEFT JOIN marketers ON marketers.id = newsletter_admins.admin_id.WHERE newsletters_admins.newsletter_id = '".$newsletter_id."'"; 
  $this->db->query($query); 
} 
} 

管理营销人员的管理系统

我们需要一种方法让营销人员登录并通过密码进行身份验证。我们需要一种方法让管理员创建帐户并注册营销人员及其新闻通讯。

让我们首先构建这部分。

在我们的管理视图中,我们将需要设置一个默认值,并要求对执行的每个操作进行身份验证密码。这是我们不需要存储在数据库中的东西,因为只会有一个管理员。

在我们的config/admin.php文件中,我们将定义用户名和密码如下:


<?php 
$admin_username = 'admin'; 
$password = 'test1234'; 
?> 

然后我们只需在我们的登录页面login.php中包含文件。我们将简单地检查它。登录页面的代码如下:

<html> 
<?php  
if(isset($_POST['username']) && isset($_POST['password'])) { 
  //check if they match then login 
  if($_POST['username'] == $admin_username  
    && $_POST['password'] == $password) { 
   //create session and login 
   $_SESSION['logged_in'] = true; 
   $_SESSION['logged_in_user'] = $admin_username; 
      header('http://ourwebsite.com/admin/welcome_dashboard.php'); 
 } 
 ?> 
} 
</html> 

请注意,我们必须根据我们正在开发的位置正确设置我们的网站 URL。在上面的示例中,页面将在登录后重定向到ourwebsite.com/admin/welcome_dashboard.php。我们可以创建变量来存储域和要重定向到的 URL 片段,以便这可以是动态的;请参阅以下示例:

$domain = 'http://ourwebsite.com'; 
$redirect_url = '/admin/welcome_dashboard.php'; 
header($domain . $redirect_url); 

一旦登录,我们将需要构建一个简单的 CRUD(创建、读取、更新、删除)系统来管理将管理他们的新闻通讯的营销人员。

以下是能够获取营销人员和他们管理的新闻通讯列表的代码:

Function get_neewsletter_marketers() { 
  $q = "SELECT * FROM marketers LEFT JOIN newsletters '; 
  $q .= "WHERE marketers.id = newsletters.marketer_id"; 

  $res = $db->query($q); 

  while ($row = $res->fetch_assoc()) { 
   $marketers = array( 
     'name' => $row['marketer_name'], 
     'email' => $row['marketer_email'], 
     'id' => $row['marketer_id'] 
    ); 
  } 
  return $marketers; 
} 

我们需要添加一种方法来编辑、创建和删除营销人员。让我们创建一个dashboard/table_header.php来包含在我们脚本的顶部。

以下是table_header.php代码的样子:

<table> 
<tr> 
 <th>Marketer Email</th> 
  <th>Edit</th> 
 <th>Delete</th> 
</tr> 

现在我们将创建一个for()循环来循环遍历每个营销人员。让我们创建一种方法来选择我们数据库中的所有营销人员。首先,让我们调用我们的函数来获取数据:

$marketrs = get_newsletter_marketers(); 

然后让我们使用foreach()循环来循环遍历所有营销人员:

foreach($marketers as $marketer) { 
  echo '<tr><td>'. $marketer['email'] .'</td> 
   <td><a href="edit_marketer.php?id='. $marketer['id'].'">Edit</a></td> 
  <td><a href="delete_marketer.php">delete</td> 
  </tr>'; 
} 
echo '</table>'; 

然后我们用</table>为表结束代码。

让我们创建delete_marketer.php脚本和edit_marketer.php脚本。以下将是删除脚本:

function delete_marketer($marketer_id) { 
  $q = "DELETE FROM marketers WHERE marketers.id = '" .   $marketer_id . "'"; 
   $this->db->query($q); 
} 
$marketer_id = $_GET['id']; 
delete_marketer($marketer_id); 

这是由一个表单组成的编辑脚本,一旦提交将更新数据:

if(empty($_POST['submit'])) { 
  $marketer_id = $_GET['id']; 
  $q = "SELECT * FROM marketers WHERE id = '" . $marketer_id."'"; 

 $res = $db->query($q); 

  while ($row = $res->fetch_assoc()) { 
   $marketer = array( 
     'name' => $row['marketer_name'], 
     'email' => $row['marketer_email'], 
     'id' => $row['id'] 
    ); 
  } 

  ?> 
  <form action="update_marketer.php" method="post"> 
   <input type="hidden" name="marketer_id" value="<?php echo $marketer['id'] ?>"> 
   <input type="text" name="marketer_name" value="<?php echo $marketer['name'] ?>"> 
   <input type="text" name="marketer_email" value="<?php echo $marketer['email'] ?>"> 
  <input type="submit" name="submit" /> 
</form> 
  <?php 

  } else { 
     $q = "UPDATE marketers SET marketer_name='" . $_POST['marketer_name'] . ", marketer_email = '". $_POST['marketer_email']."' WHERE id = '".$_POST['marketer_id']."'"; 
   $this->db->query($q); 
   echo "Marketer's details has been updated"; 
  } 
?> 

我们的通讯的自定义模板

每个营销人员都需要制定他们的通讯。在我们的情况下,我们可以允许他们创建一个简单的侧边栏通讯和一个简单的自上而下的通讯。为了构建一个简单的侧边栏,我们可以创建一个 HTML 模板,看起来像下面这样:

<html> 
<!doctype html> 

<sidebar style="text-align:left"> 
{{MENU}} 
</sidebar> 

<main style="text-align:right"> 
   {{CONTENT}} 
</main> 
</html> 

在之前的代码中,我们使用内联标签样式化 HTML 电子邮件,因为一些电子邮件客户端不会渲染从我们 HTML 外部引用的样式表。

我们可以使用正则表达式来替换{{MENU}}{{CONTENT}}模式,以填充数据。

我们的数据库将以 JSON 格式存储内容,一旦解析 JSON,我们将得到内容和菜单数据,然后插入到它们各自的位置。

在我们的数据库中,我们需要添加newsletter_templates表。以下是我们将如何创建它:

CREATE TABLE newsletter_templates ( 
 Id INT(11) PRIMARY KEY AUTO_INCREMENT, 
Newsletter_id INT(11) NOT NULL, 
   Template TEXT NOT NULL, 
   Created_by INT(11) NOT NULL   
) ENGINE=InnoDB; 

有了模板之后,我们需要一种方法让营销人员更新模板。

从仪表板上,我们显示通讯的模板列表。

让我们按照以下方式创建表单:

$cleanhtml = htmlentities('<html> 
<!doctype html> 

<sidebar style="text-align:left"> 
{{MENU}} 
</sidebar> 

<main style="text-align:right"> 
   {{CONTENT}} 
</main> 
</html> 
'); 
<form> 
   <h2>Newsletter Custom Template</h2> 
  <textarea name="customtemplate"> 
<?php echo $cleanhtml; ?> 
</textarea> 
  <input type="submit" value="Save Template" name="submit"> 
  </form> 

我们还通过向textarea添加值来填充它。请注意,在之前的代码中,我们需要首先使用htmlentities清理模板的 HTML 代码。这是因为我们的 HTML 可能被解释为网页的一部分,并在浏览器渲染时引起问题。

我们现在已经准备好发送实际的通讯了。为了发送通讯,我们需要创建一个脚本,循环遍历通讯中的所有成员,然后简单地使用 PHP 邮件功能发送给他们。

使用 PHP 邮件功能,我们只需要循环遍历我们数据库中的所有通讯成员。

这就是那个脚本的样子:

$template = require('template.class.php'); 
$q = "SELECT * FROM newsletter_members WHERE newsletter_id = 1"; //if we're going to mail newsletter #1  
$results = $db->query($q); 
While ($rows =$results->fetch_assoc() ) { 
  //gather data  
  $newsletter_title = $row['title']; 
  $member_email = $row['template']; 
  $menu = $row['menu']; //this is a new field to contain any menu html 
  $content = $row['content']; 
  $content_with_menu = $template->replace_menu($menu, $content); 
  $emailcontent = $template->         replace_contents($content,$content_with_menu); 
  //mail away! 
  mail($member_email, 'info@maillist.com', $newsletter_title ,$email_content); 
} 

我们需要完成replace_menureplace_contents函数。让我们简单地构建文本替换函数,用于替换我们在之前的代码中已经获取的内容。数据来自数据库中的通讯表:

class Template { 
   public function replace_menu($menu, $content) { 
     return  str_replace('{{MENU}}', $menu, $content); 
   } 
   public function replace_contents ($actualcontent, $content) { 
    return str_replace('{{CONTENT}}', $actualcontent,  $content); 
   }  
} 

请注意,我们修改了我们的表,为通讯中添加了菜单。这个菜单必须由用户创建,并使用 HTML 标记。它基本上是一个 HTML 链接列表。菜单的正确标记应该如下所示:

<ul> 
  <li><a href="http://someUrl.com">some URL</a></li> 
<li><a href="http://someNewUrl.com">some new URL</a></li> 
<li><a href="http://someOtherUrl.com">some other URL</a></li> 
</ul> 

链接跟踪

对于我们的链接跟踪系统,我们需要允许营销人员嵌入链接,实际上通过我们的系统传递,以便我们跟踪链接的点击次数。

我们将创建一个服务,自动将我们输入的链接缩短为随机哈希。URL 看起来像http://example.com/link/xyz123,哈希xyz123将存储在我们的数据库中。当用户访问链接时,我们将匹配链接。

让我们创建链接表,并创建一个函数来帮助我们生成缩短链接。至少,我们需要能够存储链接的标题、实际链接、缩短链接,以及创建链接的人,以便我们可以将其放在营销人员的仪表板上。

链接表的 SQL 如下所示:

CREATE TABLE links ( 
   id INT(11) PRIMARY KEY AUTO_INCREMENT, 
   link_title TEXT NOT NULL, 
   actual_link TEXT, 
   shortened_link VARCHAR(255), 
   created DATETIME, 
   created_by INT(11) 
); 

现在让我们创建以下函数,它将生成一个随机哈希:

public function createShortLink($site_url,$title, $actual_url,$created_by) { 
    $created_date = date('Y-m-d H:i:s'); 
  $new_url = $site_url . "h?=" . md5($actual_url); 
  $res = $this->db->query("INSERT INTO links VALUES (null, $title ,'". $actual_url. "', '". $new_url.", '". $created_date."','".$created_by."'"),; 
  )); 
   return $res; 
} 

我们还需要存储链接的点击次数。我们将使用另一个表,将link_id链接到点击次数,每当有人使用缩短链接时,我们将更新该表:

CREATE TABLE link_hits ( 
   link_id INT(11), 
   num_hits INT(11) 
); 

我们不需要对之前的 SQL 表进行索引,因为我们不需要在其上进行快速搜索。每次生成新的 URL 时,我们应该将表填充为num默认为 0:

createShortLink函数中添加以下函数:

$res = $this->db->query("INSERT INTO links VALUES (null, '$actual_url',$title, '$new_url', '$created_date', '$created_by'"); 

$new_insert_id = $this->db->insert_id; 

$dbquery = INSERT INTO link_hits VALUES($new_insert_id,0); 

$this->db->query($dbquery); 

insert_id是 MySQL 最后插入记录的 ID。它是一个函数,每次添加新行时都会返回新生成的 ID。

让我们生成包含两个函数的链接点击类,一个用于初始化数据库,另一个用于在用户点击链接时更新link_hits表:

Class LinkHit {       

     Public function __construct($mysqli) { 
          $this->db = $mysqli; 
      } 

   public function  hitUpdate ($link_id) { 

  $query = "UPDATE link_hits SET num_hits++ WHERE link_id='".    $link_id. "'"; 

   //able to update 
     $this->db->query($query)       
   } 

   Public function checkHit ($shorturl) { 
   $arrayUrl = parse_url($shortUrl); 
parse_str($parts['query'],$query); 
$hash = $query['h'];  

   $testQuery = $this->db->query("SELECT id FROM links WHERE shortened_link LIKE '%$hash%'"); 
   if($this->db->num_rows > 0) { 
         while($row = $testQuery->fetch_array() ) { 
   return $row['id']; 
          } 
   } else { 
     echo "Could not find shorted link"; 
     return null; 
  } 
} 

//instantiating the function: 
$mysqli = new mysqli('localhost','test_user','test_password','your_database'); 
$Link = new LinkHit($mysqli); 
$short_link_id = $Link->checkHit("http://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); 

if($short_link_id !== null) { 
  $link->hitUpdate($isShort); 
} 

为了让我们的营销人员查看链接,我们需要在我们的门户网站上的links页面上显示他们的链接。

我们创建用于检查链接及其点击次数的功能,这是归因于已登录的管理员用户:

$user_id = $_SESSION['user_id']; 
$sql = "SELECT * FROM links LEFT JOIN link_hits ON links.id = link_hits.link_id WHERE links.created_by='" . $user_id. "'"; 
$query = $mysqli->query($sql); 
?> 
<table> 
<tr> 
<td>Link id</td><td>Link hits</td></tr> 
<?php 
while($obj = $query->fetch_object()) { 
  echo '<tr><td>'.$obj->link.'</td> 
<td>' . $obj->link_hits.'</td></tr></tr>'; 
} 
?> 
</table> 

在上述代码中,我们只是通过检查变量$_SESSION['user_id']获取了已登录用户的 ID。然后我们通过执行字符串变量$SQL执行了一个 SQL 查询。之后,我们循环遍历结果,并将结果显示在 HTML 表中。请注意,当我们显示永久的 HTML 标记时,例如表的开头、标题和</table>标记的结束时,我们退出 PHP 代码。

PHP 在不使用 echo 语句时性能略有提高,这就是 PHP 脚本的美妙之处,您真的可以进入 PHP 部分,然后进入代码中的 HTML 部分。您对这个想法的美感可能有所不同,但我们只是想展示 PHP 在这个练习中可以做什么。

支持的 AJAX 套接字聊天

该系统允许订阅者联系特定通讯组的管理员。它将只包含一个联系表单。此外,我们需要实现一种实时向管理员发送通知的方式。

我们将基本上为管理员添加一个套接字连接,以便每当有人发送查询时,它将在营销人员的仪表板上闪烁通知。

这对于socket.io和一个名为 WebSockets 的浏览器技术来说非常简单。

socket.io 简介

使用 socket.io,我们不需要创建用于定期检查服务器是否有事件的代码。我们只需通过 AJAX 传递用户输入的数据,并通过发出事件来触发套接字的监听器。它提供了长轮询和通过 WebSockets 进行通信,并得到了现代 Web 浏览器的支持。

注意

WebSockets 扩展了通过浏览器建立套接字连接的概念。要了解有关 WebSockets 的更多信息,请访问www.html5rocks.com/en/tutorials/websockets/basics/

socket.io 网站上的示例代码只包括socket.io.js脚本:

<script src="socket.io/socket.io.js"></script> 

我们的 PHP Web 服务器将使用一个名为Ratchet的东西,它在socketo.me上有一个网站。它基本上允许我们为 PHP 使用 WebSockets。

这是他们的网站:

socket.io 简介

Ratchet 只是一个工具,允许 PHP 开发人员“在 WebSockets 上创建实时的、双向的应用程序”。通过创建双向数据流,它允许开发人员创建实时聊天和其他实时应用程序等东西。

让我们通过按照他们在socketo.me/docs/hello-world上的教程开始。

使用 Ratchet,我们需要安装Composer并将以下内容添加到我们项目目录中的composer.json文件中:

{ 
    "autoload": { 
        "psr-0": { 
            "MyApp": "src" 
        } 
    }, 
    "require": { 
        "cboden/ratchet": "0.3.*" 
    } 
} 

如果您之前有使用 Composer 的经验,基本上它所做的就是在编写需要自动加载的脚本的路径时使用psr-0标准。然后我们在同一目录中运行composer install。在设置好 Ratchet 之后,我们需要设置处理某些事件的适当组件。

我们需要创建一个名为SupportChat的文件夹,并将Chat.php放在其中。这是因为在之前的composer.json文件中使用 psr-0 时,它期望src目录内有一个目录结构。

让我们创建一个包含我们需要实现的存根函数的类:

namespace SupportChat; 
use Ratchet\MessageComponentInterface; 
use Ratchet\ConnectionInterface; 

class SupportChat implements MessageComponentInterface { 
  Protected $clients; 
  Public function __construct() { 
    $this->clients = new \SplObjectStorage; 
  } 
} 

我们需要声明$clients变量来存储将连接到我们聊天应用程序的客户端。

让我们实现客户端打开连接时的接口:

Public function onOpen(ConnectionInterface $conn) { 
  $this->clients->attach($conn); 
  echo "A connection has been established"; 
} 

现在让我们创建onMessageonClose方法如下:

Public function onMessage (ConnectionInterface $from, $msg) { 
 foreach ($this->clients as $client) { 
        if ($from !== $client) { 
            $client->send($msg); 
        } 
    } 
} 

public function onClose(ConnectionInterface $conn) { 
$this->clients->detach($conn); 
} 

让我们也创建一个用于处理错误的onError方法如下:

public function onError (ConnectionInterface $conn) { 
$this->clients->detach($conn); 
} 

现在我们需要实现应用程序的客户端(浏览器)部分。

在您的htdocspublic文件夹中创建一个名为app.js的文件,其中包含以下代码:

var messages = []; 

// connect to the socket server 
var conn = new WebSocket('ws://localhost:8088'); 
conn.onopen = function(e) { 
   console.log('Connected to server:', conn); 
} 

conn.onerror = function(e) { 
   console.log('Error: Could not connect to server.'); 
} 

conn.onclose = function(e) { 
   console.log('Connection closed'); 
} 

// handle new message received from the socket server 
conn.onmessage = function(e) { 
   // message is data property of event object 
   var message = JSON.parse(e.data); 
   console.log('message', message); 

   // add to message list 
   var li = '<li>' + message.text + '</li>'; 
   $('.message-list').append(li); 
} 

// attach onSubmit handler to the form 
$(function() { 
   $('.message-form').on('submit', function(e) { 
         // prevent form submission which causes page reload 
         e.preventDefault(); 

         // get the input 
         var input = $(this).find('input'); 

         // get message text from the input 
         var message = { 
               type: 'message', 
               text: input.val() 
         }; 

         // clear the input 
         input.val(''); 

         // send message to server 
         conn.send(JSON.stringify(message)); 
   }); 
}); 

我们需要创建用于上述代码的 HTML。我们应该将文件命名为app.js。现在,让我们实现一个简单的输入文本,让用户输入他们的消息:

<!DOCTYPE html> 
<html> 
<head> 
   <title>Chat with Support</title> 
   <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.2.3/jquery.js"></script> 
   <script src="app.js"></script> 
</head> 
<body> 

   <h1>Chat with Support</h1> 

   <h2>Messages</h2> 
   <ul class="message-list"></ul> 
   <form class="message-form"> 
         <input type="text" size="40" placeholder="Type your message here" /> 
         <button>Send message</button> 
   </form> 
</body> 
</html> 

App.js是我们之前编写的 JavaScript 代码应该放置的地方。我们还需要创建一个 WebSocket 服务器来处理端口8088上的 WebSocket:


<?php 
// import namespaces 
use Ratchet\Server\IoServer; 
use Ratchet\WebSocket\WsServer; 
use SupportChat\Chat; 

// use the autoloader provided by Composer 
require dirname(__DIR__) . '/vendor/autoload.php'; 

// create a websocket server 
$server = IoServer::factory( 
    new WsServer( 
        new Chat() 
    ) 
    , 8088 
); 

$server->run(); 

我们的聊天应用现在已经准备好供公众使用。但是,我们需要启动我们的聊天服务器,通过php bin/server.php启动它来处理 WebSockets。

请注意,在 Windows 上,它会提示有关正在使用的网络:

socket.io 简介

只需单击允许访问,然后单击确定

现在当我们访问http://localhost/client.html时,我们应该看到以下内容:

socket.io 简介

但是,我们需要通过添加用户名和电子邮件来改进联系表单,以便支持人员在没有支持人员回复用户的情况下通过电子邮件回复他。

我们的表单现在如下所示:

<form class="message-form" id="chatform"> 
         <input type="text" name="firstname" size="25" placeholder="Your Name"> 
         <input type="text" name="email" size="25" placeholder="Email"> 

         <input type="text" name="message" size="40" placeholder="Type your message here" /> 
         <button>Send message</button> 
   </form> 

由于我们添加了这些细节,我们需要将它们存储在我们的数据库中。我们可以通过将所有数据转发到另一个 PHP 脚本来执行发送。在 JavaScript 中,代码将向处理程序添加一种从表单发送到sendsupportmessage.php的方式。

以下是使用 jQuery 编写的 JavaScript 代码的样子:

<script> 
$(document).ready(function() { 
   $('submit').on('click', function() { 
     $.post('sendsupportmessage.php', $("#chatform").serialize()) 
       .done(function(data) { 
         alert('Your message has been sent'); 
      }); 
   }); 
}); 
</script> 

在将接收消息的脚本sendsupportmessage.php中,我们需要解析信息并创建一封发送到支持电子邮件contact@yoursite.com的电子邮件;请参考以下示例:

<?php 
  if( !empty($_POST['message'])) { 
    $message = htmlentities($_POST['message']); 
  } 

  if( !empty($_POST['email'])) { 
    $email = htmlentities($_POST['email']); 
  } 

  if( !empty($_POST['firstname']) ) { 
    $firstname = htmlentities($_POST['firstname']); 
  }  

  $emailmessage = 'A support message from ' . $firstname . '; 
  $emailmessage .=  ' with email address: ' . $email . '; 
  $emailmessage .= ' has been received. The message is '. $message; 

   mail('contact@yoursite.com', 'Support message', $emailmessage);  

  echo "success!"; 
?> 

该脚本只是检查提交的值是否不为空。根据经验,使用!empty()而不是使用isset()函数检查设置的值更好,因为 PHP 可能会将空字符串('')评估为已设置:

$foo = ''; 
if(isset($foo)) { print 'But no its empty'; } 
else { print 'PHP7 rocks!'; } 

现在我们需要向用户显示,因为我们使用 AJAX 将消息发送到服务器,并更新 AJAX 框。在 JavaScript 代码中,我们应该将.done()回调代码更改为以下内容:

.done(function(data) { 
   if(data === 'succcess!') { 
     var successHtml = '<li>Your message was sent</li>'; 
     $('.message-list').append(successHtml); 

   } 
      } 

太棒了!请注意,我们更改了警报框的调用,而是将消息您的消息已发送附加回消息列表中。我们的支持表单现在发送了消息的发送者,并且我们的支持团队可以在他们的电子邮件中收到消息。

总结

在本章中,您学到了很多。总之,我们建立了一个简单的管理系统来管理我们的营销人员。此外,我们还为新闻通讯的成员创建了一个登录方式,这将引导用户到主页。

然后我们回顾了如何使用简单的模板系统发送电子邮件,这允许用户添加自己的菜单和内容到布局中。我们还能够使用 Facebook PHP SDK 和其认证过程添加 Facebook 社交登录。

在本章的后半部分,我们建立了一个简单的聊天系统,它将立即发送电子邮件到我们网站的支持电子邮件地址。我们查看了 Ratchet,这是一个 PHP 库,可以帮助我们在 PHP 中处理实时消息,并使用 AJAX 异步发送数据到另一个将发送电子邮件到支持电子邮件的脚本。

我们现在已经创建了一个令人印象深刻的新闻通讯应用程序,它不仅具有社交登录功能和支持聊天框,还允许其他新闻通讯营销人员通过网站管理其内容。

第四章:使用 Elasticsearch 构建具有搜索功能的简单博客

在本章中,我们将创建一个简单的博客,可以创建和删除帖子。然后我们将致力于为我们的博客添加一些功能,例如以下内容:

  • 实现一个非常简单的带有 CRUD 和管理员功能的博客

  • 工作和安装 Elasticsearch 和 Logstash

  • 尝试使用 Elasticsearch 的 PHP 客户端

  • 学习构建与 Elasticsearch 一起工作的工具

  • 为我们的数据库构建搜索缓存

  • 基于我们的 Elasticsearch 信息构建图表

创建 CRUD 和管理员系统

首先,让我们构建我们的帖子的 SQL。数据库表至少应该包含帖子标题、帖子内容、帖子日期以及修改和发布日期。

这是 SQL 应该看起来的样子:

CREATE TABLE posts( 
id INT(11) PRIMARY KEY AUTO INCREMENT, 
post_title TEXT, 
post_content TEXT, 
post_date DATETIME, 
modified DATETIME, 
published DATETIME 
); 

现在让我们创建一个函数来读取数据。一个典型的博客网站有评论和与博客文章相关的一些额外的 SEO 元数据。但在本章中,我们不会创建这部分。无论如何,向评论数据添加表格并在另一个表格中添加有关每篇文章的 SEO 元数据应该是相当简单的。

让我们从创建管理员系统开始。我们需要登录,所以我们将创建一个简单的登录-注销脚本:

//admin.php 
<form action="admin.php" method="post"> 
Username: <input type="text" name="username"><br /> 
Password: <input type="text" name="username"><br /> 
<input type="submit" name="submit"> 
</form> 
<?php 
$db = new mysqli(); //etc 

Function checkPassword($username, $password) { 
//generate hash 
    $bpassword = password_hash($password); 

//clean up username for sanitization 
$username = $db->real_escape_string($username); 

    $query = mysqli_query("SELECT * FROM users WHERE password='".$bpassword."' AND username = '". $username. "'"); 
if($query->num_rows() > 0) { 
return true; 
     } 
return false; 
} 

if(isset$_POST[' assword']) && isset ($_POST['username']) ) { 
If(checkPassword($_POST['username'], $_POST['password'])) { 
$_SESSION['admin'] = true; 
$_SESSION['logged_in'] = true; 
$_SESSION['expires'] = 3600; //1 hour 
      $_SESSION['signin_time'] = time(); //unix time 
      header('Location: admin_crud_posts.php'); 
} 
else { 
       //lead the user out 
header('Location: logout.php'); 
    } 
   } 
} 

当您登录到admin.php时,您设置了会话,然后被重定向到 CRUD 页面。

管理员 CRUD 页面的脚本如下:

<?php 
$db = new mysqli(); //etc 
function delete($post_id) { 
   $sql_query = "DELETE FROM posts WHERE id= '". $post_id."'"; 
  $db->query($sql_query); 

} 

function update($postTitle, $postContent, $postAuthor, $postId) { 
$sql_query = "UPDATE posts  
   SET  title = '".$postTitle. "',  
   post_content = '". $postContent. "',  
   post_author='". $postAuthor."'   
   WHERE id = '".$postId."'"; 
   $db->query($sql_query); 
} 

function create($postTitle, $postContent, $postAuthor) { 

$insert_query = "INSERT INTO posts (null , 
    '" . $postTitle."', 
    '". $postContent."', 
   '" $postAuthor."')";  

$db->query($insert_query); 

} 

$query = "SELECT * FROM posts"; 
$result = $db->query($query); 

//display 
?> 
<table> 
<tr> 
<td>Title</td> 
<td>Content</td> 
<td>Author</td> 
<td>Administer</td> 
</tr> 
while($row = $db->fetch_array($query,MYSQLI_ASSOC)) { 
  $id = $row['id']; 
echo '<tr>'; 

echo '<td>' .$row['title'] . '</td>'; 

echo '<td>' . $row['content'] . '</td>';   

echo '<td>' . $row['author'] . '</td>'; 

echo '<td><a href="edit.php?postid='.$id.'">Edit</a>'; 
echo '<a href="delete.php?postid='.$id.'">Delete</a>'.</td>';' 
echo '</tr>'; 
} 
echo "</table>"; 

?> 

在上面的脚本中,我们只是简单地定义了一些函数来处理 CRUD 操作。要显示数据,我们只需简单地循环遍历数据库并在表格中输出它。

编辑和删除页面,这些是用户界面和用于编辑或删除帖子的功能所需的脚本,如下所示:

edit.php

<?php 
function redirect($home) { 
header('Location: '. $home); 
} 
if(!empty($_POST)) { 
   $query = 'UPDATE posts SET title='" .  $_POST['title']. "', content='". $_POST['content']."' WHERE id = ".$_POST['id']; 
   $db->query($query); 
   redirect('index.php'); 
} else { 
  $id = $_GET['id']; 
  $q = "SELECT * FROM posts WHERE id= '".$_GET['id'] . "'" 
?> 
<form action="edit.php" method="post"> 

<input name="post_title type="text" value=" ="<?php echo  $_POST[ 
title'] ?>"> 

<input type="text" value="<?php echo $_POST['content'] ?>"> 

<input type="hidden" value="<?php echo $_GET['id'] ?>"> 

</form> 
<?php 
} 
?> 

让我们创建实际的删除帖子功能。以下是delete.php的样子:

<?php 

function redirect($home) { 
    header('Location: '. $home); 
} 
if(isset ($_GET['postid'])) { 
    $query = "DELETE FROM  posts WHERE id = '".$_GET['post_id']."'"; 
$db->query($query); 
redirect('index.php'); 
} 

我们的 PHP 记录器 Monolog 将使用 Logstash 插件将帖子添加到 Elasticsearch 中。

我们将设置一个 Logstash 插件,首先检查文档是否存在,如果不存在,则插入。

要更新 Elasticsearch,我们需要执行upsert,如果记录存在,则更新相同的记录,如果不存在,则创建一个新的记录。

此外,我们已经实现了一种方法,可以从我们的 CRUD 中删除帖子,但实际上并没有从数据库中删除它,因为我们需要它来进行检索。

对于需要执行的每个操作,我们只需使用$_GET['id']来确定在单击时我们要执行什么操作。

像任何博客一样,我们需要一个首页供用户显示可阅读的帖子:

index.php

<html> 
<?php 
$res = $db->query("SELECT * FROM posts LIMIT 10"); 
foreach$posts as $post { 
<h1><?phpecho $post[]?> 
?> 
} 
?> 

在上面的代码中,我们广泛使用了简写的php标记,这样我们就可以专注于页面布局。请注意它是如何在 PHP 模式中来回穿梭的,但看起来就像我们只是在使用模板,这意味着我们可以看到 HTML 标记的一般轮廓,而不会过多涉及 PHP 代码的细节。

填充帖子表

没有任何数据,我们的博客就是无用的。因此,为了演示目的,我们将使用一个种子脚本来自动填充我们的表格数据。

让我们使用一个用于生成虚假内容的流行库Faker,它可以在github.com/fzaninotto/Faker上找到。

使用 Faker,您只需通过提供其autoload.php文件的所需路径来加载它,并使用 composer 进行加载(composer require fzaninotto/faker)。

生成虚假内容的完整脚本如下:

<?php 
require "vendor/autoload"; 
$faker = FakerFactory::create(); 
for($i=0; $i < 10; $i++) { 
  $id = $i; 
  $post = $faker->paragraph(3, true); 
  $title  = $faker->text(150);  
  $query = "INSERT INTO posts VALUES (".$id.",'".$title."','".$post . "','1')" 
} 

?> 

现在让我们开始熟悉 Elasticsearch,这是我们博客文章的数据库搜索引擎。

什么是 Elasticsearch?

Elasticsearch是一个搜索服务器。它是一个带有 HTTP Web 界面和无模式 JSON 文档的全文搜索引擎。这意味着我们使用 JSON 存储新的可搜索数据。输入这些文档的 API 使用 HTTP 协议。在本章中,我们将学习如何使用 PHP 并构建一个功能丰富的搜索引擎,可以执行以下操作:

  • 设置 Elasticsearch PHP 客户端

  • 将搜索数据添加到 Elasticsearch 进行索引

  • 学习如何使用关键字进行相关性

  • 缓存我们的搜索结果

  • 使用 Elasticsearch 与 Logstash 存储 apache 日志

  • 解析 XML 以存储到 Elasticsearch

安装 Elasticsearch 和 PHP 客户端

创建用于消费 Elasticsearch 的 Web 界面。

就您需要知道的而言,Elasticsearch 只需要通过使用最新的 Elasticsearch 源代码进行安装。

安装说明如下:

  1. 转到www.elastic.co/并下载与您的计算机系统相关的源文件,无论是 Mac OSX、Linux 还是 Windows 机器。

  2. 下载文件到计算机后,应运行设置安装说明。

  3. 例如,对于 Mac OSX 和 Linux 操作系统,您可以执行以下操作:

  • 安装 Java 1.8。

  • 通过 curl(在命令行中)下载 Elasticsearch:

**curl -L -O 
      https://download.elastic.co/elasticsearch/release/org/elasticsearch
      /distribution/tar/elasticsearch/2.1.0/elasticsearch-2.1.0.tar.gz**

  • 解压缩存档并切换到其中:
**tar -zxvf elasticsearch-2.1.0.tar.gz**
**cd /path/to/elasticsearch/archive**

  • 启动它:
**cd bin**
**./elasticsearch**

在 Mac OSX 上安装 Elasticsearch 的另一种方法是使用 homebrew,它可以在brew.sh/上找到。然后,使用以下命令使用 brew 进行安装:

**brew install elasticsearch**

  1. 对于 Windows 操作系统,您只需要按照向导安装程序进行点击,如下截图所示:安装 Elasticsearch 和 PHP 客户端

  2. 安装完成后,您还需要安装Logstash 代理。Logstash 代理负责从各种输入源向 Elasticsearch 发送数据。

  3. 您可以从 Elasticsearch 网站下载它,并按照计算机系统的安装说明进行安装。

  4. 对于 Linux,您可以下载一个tar文件,然后您只需要使用 Linux 的另一种方式,即使用软件包管理器,即apt-getyum,具体取决于您的 Linux 版本。

您可以通过安装Postman并进行GET 请求http://localhost:9200来测试 Elasticsearch:

  1. 通过打开 Google Chrome 并访问www.getpostman.com/来安装 Postman。您可以通过转到附加组件并搜索 Postman 来在 Chrome 上安装它。

  2. 安装 Postman 后,您可以注册或跳过注册:安装 Elasticsearch 和 PHP 客户端

  3. 现在尝试进行GET 请求http://localhost:9200安装 Elasticsearch 和 PHP 客户端

  4. 下一步是在您的 composer 中尝试使用 Elasticsearch 的 PHP 客户端库。以下是如何做到这一点:

  5. 首先,在您的composer.json文件中包含 Elasticsearch:

      { 
      "require":{ 
      "elasticsearch/elasticsearch":"~2.0" 
      } 
      } 

  1. 获取 composer:
      curl-s http://getcomposer.org/installer | php 
      phpcomposer.phar install --no-dev 

  1. 通过将其包含在项目中来实例化一个新的客户端:
      require'vendor/autoload.php'; 

      $client =Elasticsearch\ClientBuilder::create()->build(); 

现在让我们尝试索引一个文档。为此,让我们创建一个使用 PHP 客户端的 PHP 文件,如下所示:

$params=[ 
    'index'=> 'my_index', 
    'type'=> 'my_type', 
    'id'=> 'my_id', 
    'body'=>['testField'=> 'abc'] 
]; 

$response = $client->index($params); 
print_r($response); 

我们还可以通过创建以下代码的脚本来检索该文档:

$params=[ 
    'index'=> 'my_index', 
    'type'=> 'my_type', 
    'id'=> 'my_id' 
]; 

$response = $client->get($params); 
print_r($response); 

如果我们正在执行搜索,代码如下:

$params=[ 
    'index'=> 'my_index', 
    'type'=> 'my_type', 
    'body'=>[ 
        'query'=>[ 
            'match'=>[ 
                'testField'=> 'abc' 
] 
] 
] 
]; 

$response = $client->search($params); 
print_r($response); 

简而言之,Elasticsearch PHP 客户端使得更容易将文档插入、搜索和从 Elasticsearch 获取文档。

构建一个 PHP Elasticsearch 工具

上述功能可用于使用 Elasticsearch PHP 客户端创建基于 PHP 的用户界面,以插入、查询和搜索文档。

这是一个简单的引导(HTML CSS 框架)表单:

<div class="col-md-6"> 
<div class="panel panel-info"> 
<div class="panel-heading">Create Document for indexing</div> 
<div class="panel-body"> 
<form method="post" action="new_document" role="form"> 
<div class="form-group"> 
<label class="control-label" for="Title">Title</label> 
<input type="text" class="form-control" id="newTitle" placeholder="Title"> 
</div> 
<div class="form-group"> 
<label class="control-label" for="exampleInputFile">Post Content</label> 
<textarea class="form-control" rows="5" name="post_body"></textarea> 
<p class="help-block">Add some Content</p> 
</div> 
<div class="form-group"> 
<label class="control-label">Keywords/Tags</label> 
<div class="col-sm-10"> 
<input type="text" class="form-control" placeholder="keywords, tags, more keywords" name="keywords"> 
</div> 
<p class="help-block">You know, #tags</p> 
</div> 
<button type="submit" class="btnbtn-default">Create New Document</button> 
</form> 
</div> 
</div> 
</div> 

这是表单应该看起来的样子:

构建 PHP Elasticsearch 工具

当用户提交内容的详细信息时,我们需要捕捉用户输入的内容、关键词或标签。PHP 脚本将输入输入到 MySQL,然后输入到我们的脚本,然后将其推送到我们的 Elasticsearch 中:

public function insertData($data) { 
  $sql = "INSERT INTO posts ('title', 'tags', 'content') VALUES('" . $data['title] . "','" . $data['tags'] . "','" .$data['content'] . ")"; 
mysql_query($sql); 
} 

insertData($_POST); 

现在让我们也尝试将此文档发布到 Elasticsearch:

$params=[ 
    'index'=> 'my_posts', 
    'type'=>'posts', 
    'id'=>'posts', 
    'body'=>[ 
       'title'=>$_POST['title'], 
       'tags' => $_POST['tags'], 
       'content' => $_POST['content'] 
] 
]; 

$response = $client->index($params); 
print_r($response); 

将文档添加到我们的 Elasticsearch

Elasticsearch 使用索引将每个数据点存储到其数据库中。从我们的 MySQL 数据库中,我们需要将数据发布到 Elasticsearch。

让我们讨论 Elasticsearch 中索引实际上是如何工作的。它比 MySQL 的传统搜索更快的原因在于它搜索索引而不是搜索每个条目。

Elasticsearch 中的索引工作原理是什么?它使用Apache Lucene创建一种称为倒排索引的东西。倒排索引意味着它查找搜索项而无需扫描每个条目。基本上意味着它有一个查找表,列出了系统中输入的所有单词。

ELK 堆栈的架构概述如下:

将文档添加到我们的 Elasticsearch

在上图中,我们可以看到输入源,通常是日志或其他数据源,进入Logstash。然后从Logstash进入Elasticsearch

一旦数据到达Elasticsearch,它会经过一些标记和过滤。标记是将字符串分解为不同部分的过程。过滤是当一些术语被分类到单独的索引中时。例如,我们可能有一个 Apache 日志索引,然后还有另一个输入源,比如Redis,推送到另一个可搜索的索引中。

可搜索的索引是我们之前提到的反向索引。可搜索的索引基本上是通过将每个术语存储并引用其原始内容到索引中来实现的。这类似于索引数据库中所做的操作。当我们创建主键并将其用作搜索整个记录的索引时,这是相同的过程。

您可以在集群中有许多节点执行此索引操作,所有这些都由 Elasticsearch 引擎处理。在上图中,节点标记为N1N4

查询 Elasticsearch

现在我们了解了每个部分,那么我们如何查询 Elasticsearch 呢?首先,让我们介绍 Elasticsearch。当您开始运行 Elasticsearch 时,您应该向http://localhost:9200发送 HTTP 请求。

我们可以使用 Elasticsearch web API 来实现这一点,它允许我们使用 RESTful HTTP 请求将记录插入到 Elasticsearch 服务器中。这个 RESTful API 是将记录插入到 Elasticsearch 的唯一方法。

安装 Logstash

Logstash 只是所有传递到 Elasticsearch 的消息经过的中央日志系统。

要设置 Logstash,请按照 Elasticsearch 网站上提供的指南进行操作:

www.elastic.co/guide/en/logstash/current/getting-started-with-logstash.html

Elasticsearch 和 Logstash 一起工作,将不同类型的索引日志输入 Elasticsearch。

我们需要在两个数据点之间创建一种称为传输或中间件。为此,我们需要设置 Logstash。它被称为 Elasticsearch 的摄入工作马,还有更多。它是一个数据收集引擎,将数据从数据源管道传输到目的地,即 Elasticsearch。Logstash 基本上就像一个简单的数据管道。

我们将创建一个 cron 作业,这基本上是一个后台任务,它将从我们的帖子表中添加新条目并将它们放入 Elasticsearch 中。

熟悉管道概念的 Unix 和 Linux 用户,|,将熟悉管道的工作原理。

Logstash 只是将我们的原始日志消息转换为一种称为JSON的格式。

提示

JSON,也称为JavaScript 对象表示法,是在 Web 服务之间传输数据的流行格式。它很轻量,许多编程语言,包括 PHP,都有一种方法来编码和解码 JSON 格式的消息。

设置 Logstash 配置

Logstash 配置的输入部分涉及正确读取和解析日志数据。它由输入数据源和要使用的解析器组成。这是一个示例配置,我们将从redis输入源中读取:

input { 
redis { 
key =>phplogs 
data_type => ['list'] 
  } 
} 

但首先,为了能够推送到redis,我们应该安装并使用phpredis,这是一个允许 PHP 将数据插入redis的扩展库。

安装 PHP Redis

安装 PHP Redis 应该很简单。它在大多数 Linux 平台的软件包存储库中都有。您可以阅读有关如何安装它的文档github.com/phpredis/phpredis

安装完成后,您可以通过创建以下脚本并运行它来测试您的 PHP Redis 安装是否正常工作:

<?php 
$redis = new Redis() or die("Cannot load Redis module."); 
$redis->connect('localhost'); 
$redis->set('random', rand(5000,6000)); 
echo $redis->get('random'); 

在上面的例子中,我们能够启动一个新的 Redis 连接,然后设置一个名为random的键,其值在50006000之间。最后,我们通过调用echo $redis->get('random')来输出我们刚刚输入的数据。

有了这个,让我们使用名为Monolog的 PHP 日志库来创建真正的 PHP 代码,将日志存储在 Redis 中。

让我们创建一个composer.json,供日志项目使用。

在终端中,让我们运行初始化 composer:

composer init 

它将在之后交互式地询问一些问题,然后应该创建一个composer.json文件。

现在通过输入以下内容来安装 Monolog:

composer require monolog/monolog

让我们设置从我们的 MySQL 数据库中读取数据,然后将其推送到 Elasticsearch 的 PHP 代码:

<?php 
require'vendor/autoload.php' 

useMonolog\Logger; 
useMonolog\Handler\RedisHandler; 
useMonolog\Formatter\LogstashFormatter; 
usePredis\Client; 

$redisHandler=newRedisHandler(newClient(),'phplogs'); 
$formatter =newLogstashFormatter('my_app'); 
$redisHandler->setFormatter($formatter); 

// Create a Logger instance  
$logger =newLogger('logstash_test', array($redisHandler)); 
$logger->info('Logging some infos to logstash.'); 

在上面的代码中,我们创建了一个名为phplogsredisHandler。然后,我们设置LogstashFormatter实例以使用应用程序名称my_app

在脚本的末尾,我们创建一个新的logger实例,将其连接到redisHandler,并调用loggerinfo()方法来记录数据。

Monolog 将格式化程序的职责与实际日志记录分开。logger负责创建消息,而格式化程序将消息格式化为适当的格式,以便 Logstash 能够理解。Logstash 将其传输到 Elasticsearch,Logstash 将其索引的日志数据存储在 Elasticsearch 索引中,以便以后进行查询。

这就是 Elasticsearch 的美妙之处。只要有 Logstash,您可以选择不同的输入源供 Logstash 处理,Elasticsearch 将在 Logstash 推送数据时保存数据。

编码和解码 JSON 消息

现在我们知道如何使用 Monolog 库,我们需要将其集成到我们的博客应用程序中。我们将通过创建一个 cronjob 来实现这一点,该 cronjob 将检查当天是否有新的博客文章,并通过使用 PHP 脚本将它们存储在 Elasticsearch 中。

首先,让我们创建一个名为server_scripts的文件夹,将所有 cronjobs 放在其中:

$ mkdir ~/server_scripts 
$ cd ~/server_scripts 

现在,这是我们的代码:

<?php 
$db_name = 'test'; 
$db_pass = 'test123'; 
$db_username = 'testuser' 
$host = 'localhost'; 
$dbconn = mysqli_connect(); 
$date_now = date('Y-m-d 00:00:00'); 
$date_now_end = date('Y-m-d 00:00:00',mktime() + 86400); 
$res = $dbcon->query("SELECT * FROM posts WHERE created >= '". $date_now."' AND created < '". $date_now_end. "'"); 

while($row = $dbconn->fetch_object($res)) { 
  /* do redis queries here */ 

} 

使用 Logstash,我们可以从我们的redis数据中读取并让它完成工作,然后使用以下 Logstash 的输出插件代码输出它:

output{ 
elasticsearch_http{ 
host=> localhost 
} 
} 

在 Elasticsearch 中存储 Apache 日志

监控日志是任何 Web 应用程序的重要方面。大多数关键系统都有一个称为仪表板的东西,这正是我们将在本节中使用 PHP 构建的东西。

作为本章的额外内容,让我们谈谈另一个日志主题,服务器日志。有时,我们希望能够确定服务器在某个特定时间的性能。

Elasticsearch 的另一项功能是存储 Apache 日志。对于我们的应用程序,我们可以添加这个功能,以便更多了解我们的用户。

例如,如果我们对监视用户使用的浏览器以及用户访问我们网站时来自何处感兴趣,这可能会很有用。

为了做到这一点,我们只需使用 Apache 输入插件设置一些配置,如下所示:

input { 
file { 
path => "/var/log/apache/access.log" 
start_position => beginning  
ignore_older => 0  
    } 
} 

filter { 
grok { 
match => { "message" => "%{COMBINEDAPACHELOG}"} 
    } 
geoip { 
source => "clientip" 
    } 
} 

output { 
elasticsearch {} 
stdout {} 
} 

当您从 Elasticsearch 安装 Kibana 时,可以创建Kibana仪表板;但是,这需要最终用户已经知道如何使用该工具来创建各种查询。

然而,有必要使高层管理人员能够更简单地查看数据,而无需知道如何创建 Kibana 仪表板。

为了使我们的最终用户不必学习如何使用 Kibana 和创建仪表板,我们将在请求仪表板页面时简单地查询ILog信息。对于图表库,我们将使用一个名为Highcharts的流行库。但是,为了获取信息,我们需要创建一个简单的查询,以 JSON 格式返回一些信息给我们。

处理 Apache 日志,我们可以使用 PHP Elasticsearch 客户端库来创建。这是一个简单的客户端库,允许我们查询 Elasticsearch 以获取我们需要的信息,包括命中次数。

我们将为我们的网站创建一个简单的直方图,以显示在我们的数据库中记录的访问次数。

例如,我们将使用 PHP Elasticsearch SDK 来查询 Elasticsearch 并显示 Elasticsearch 结果。

我们还必须使直方图动态化。基本上,当用户想要在某些日期之间进行选择时,我们应该能够设置 Highcharts 来获取数据点并创建图表。如果您还没有查看过 Highcharts,请参考www.highcharts.com/

将 Apache 日志存储在 Elasticsearch 中

获取过滤数据以显示在 Highcharts 中

像任何图表用户一样,我们有时需要过滤我们在图表中看到的内容的能力。我们不应该依赖 Highcharts 为我们提供控件来过滤我们的数据,而是应该能够通过改变 Highcharts 将呈现的数据来进行过滤。

在以下 Highcharts 代码中,我们为我们的页面添加了以下容器分隔符;首先,我们使用 JavaScript 从我们的 Elasticsearch 引擎获取数据:

<script> 

$(function () {  
client.search({ 
index: 'apachelogs', 
type: 'logs', 
body: { 
query: { 
       "match_all": { 

       }, 
       {  
         "range": { 
             "epoch_date": { 
               "lt": <?php echo mktime(0,0,0, date('n'), date('j'), date('Y') ) ?>, 

               "gte": <?php echo mktime(0,0,0, date('n'), date('j'), date('Y')+1 ) ?> 
          } 
         } 
       }  
          } 
       } 
}).then(function (resp) { 
var hits = resp.hits.hits; 
varlogCounts = new Array(); 
    _.map(resp.hits.hits, function(count) 
    {logCounts.push(count.count)}); 

  $('#container').highcharts({ 
chart: { 
type: 'bar' 
        }, 
title: { 
text: 'Apache Logs' 
        }, 
xAxis: { 
categories: logDates 
        }, 
yAxis: { 
title: { 
text: 'Log Volume' 
            } 
        }, 
   plotLines: [{ 
         value: 0, 
         width: 1, 
         color: '#87D82F' 
         }] 
   }, 
   tooltip: { 
   valueSuffix: ' logs' 
    }, 
   plotOptions: { 
   series: { 
         cursor: 'pointer', 
         point: { 
   }, 
   marker: { 
   lineWidth: 1 
       } 
     } 
   }, 
   legend: { 
         layout: 'vertical', 
         align: 'right', 
         verticalAlign: 'middle', 
         borderWidth: 0 
      }, 
   series: [{ 
   name: 'Volumes', 
   data: logCounts 
       }] 
      });  

}, function (err) { 
console.trace(err.message); 
    $('#container').html('We did not get any data'); 
}); 

}); 
   </script> 

   <div id="container" style="width:100%; height:400px;"></div> 

这是使用 JavaScript 的 filter 命令进行的,然后将数据解析到我们的 Highcharts 图表中。您还需要使用 underscore 进行过滤功能,这将有助于确定我们要向用户呈现哪些数据。

让我们首先构建过滤我们的 Highcharts 直方图的表单。

这是 CRUD 视图中搜索过滤器的 HTML 代码将是这样的:

<form> 
<select name="date_start" id="dateStart"> 
<?php 
$aWeekAgo = date('Y-m-d H:i:s', mktime( 7 days)) 
    $aMonthAgo = date(Y-m-d H:i:s', mktime( -30));    
//a month to a week 
<option value="time">Time start</option> 
</select> 
<select name="date_end" id="dateEnd"> 
<?php 
    $currentDate= date('Y-m-d H:i:s');        
$nextWeek = date('', mktime(+7 d)); 
    $nextMonth = date( ,mktime (+30)); 
?> 
<option value=""><?php echo substr($currentData,10);?> 
</option> 
<button id="filter" name="Filter">Filter</button> 
</form> 

为了实现图表的快速重新渲染,我们必须在每次单击过滤按钮时使用普通的 JavaScript 附加一个监听器,然后简单地擦除包含我们的 Highcharts 图表的div元素的信息。

以下 JavaScript 代码将使用 jQuery 和 underscore 更新过滤器,并在第一个条形图中使用相同的代码:

<script src="https://code.jquery.com/jquery-2.2.4.min.js" integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script> 

<script src="txet/javascript"> 
$("button#filter").click {  
dateStart = $('input#dateStart').val().split("/"); 
dateEnd = $('input#dateEnd').val().split("/"); 
epochDateStart = Math.round(new Date(parseInt(dateStart[])]), parseInt(dateStart[1]), parseInt(dateStart[2])).getTime()/1000); 
epochDateEnd = Math.round(new Date(parseInt(dateEnd [])]), parseInt(dateEnd [1]), parseInt(dateEnd[2])).getTime()/1000); 

       }; 

client.search({ 
index: 'apachelogs', 
type: 'logs', 
body: { 
query: { 
       "match_all": { 

       }, 
       {  
         "range": { 
             "epoch_date": { 
               "lt": epochDateStart, 

               "gte": epochDateEnd 
          } 
         } 
       }  
          } 
       } 
}).then(function (resp) { 
var hits = resp.hits.hits; //look for hits per day fromelasticsearch apache logs 
varlogCounts = new Array(); 
    _.map(resp.hits.hits, function(count) 
    {logCounts.push(count.count)}); 

$('#container').highcharts({ 
chart: { 
type: 'bar' 
        }, 
title: { 
text: 'Apache Logs' 
        }, 
xAxis: { 
categories: logDates 
        }, 
yAxis: { 
title: { 
text: 'Log Volume' 
            } 
        } 

   }); 
}); 
</script> 

在上述代码中,我们已经包含了jquery和 underscore 库。当单击按钮以聚焦某些日期时,我们通过表单设置$_GET['date'],然后 PHP 使用一个简单的技巧获取信息,即通过简单地刷新包含图表的div,然后要求 Highcharts 重新渲染数据。

为了使这更酷一些,我们可以使用 CSS 动画效果,使其看起来像我们正在聚焦相机。

这可以通过 jQuery CSS 变换技术来实现,然后将其调整回正常大小并重新加载新的图表:

$("button#filter").click( function() { 
   //..other code 
  $("#container").animate ({ 
width: [ "toggle", "swing" ], 
height: [ "toggle", "swing" ] 
}); 

}); 

现在我们已经学会了如何使用 JavaScript 进行过滤,并允许使用过滤样式过滤 JSON 数据。请注意,过滤是一个相对较新的 JavaScript 函数;它是在ECMAScript 6中引入的。我们已经使用它来创建高层管理人员需要能够为其自己的目的生成报告的仪表板。

我们可以使用 underscore 库,它具有过滤功能。

我们将只加载 Elasticsearch 中的最新日志,然后,如果我们想要执行搜索,我们将创建一种过滤和指定要在日志中搜索的数据的方法。

让我们创建 Apache 日志的 Logstash 配置,以便 Elasticsearch 进行处理。

我们只需要将输入 Logstash 配置指向我们的 Apache 日志位置(通常是/var/log/apache2目录中的文件)。

这是 Apache 的基本 Logstash 配置,它读取位于/var/log/apache2/access.log的 Apache 访问日志文件:

input {    file { 
path => '/var/log/apache2/access.log' 
        } 
} 

filter { 
grok { 
    match =>{ "message" => "%{COMBINEDAPACHELOG}" } 
  } 
date { 
match => [ "timestamp" , "dd/MMM/yyyy:HH:mm:ss Z" ] 
  } 
} 

它使用称为 grok 过滤器的东西,它匹配任何类似于 Apache 日志格式的内容,并将时间戳匹配到dd/MMM/yyyy:HH:mm:ss Z日期格式。

如果您将 Elasticsearch 视为彩虹的终点,将 Apache 日志视为彩虹的起点,那么 Logstash 就像是将日志从两端传输到 Elasticsearch 可以理解的格式的彩虹。

Grokking是用来描述将消息格式重新格式化为 Elasticsearch 可以解释的内容的术语。这意味着它将搜索一个模式,并过滤匹配该模式的内容,特别是它将在 JSON 中查找日志的时间戳和消息以及其他属性,这是 Elasticsearch 存储在其数据库中的内容。

用于查看 Elasticsearch 日志的仪表板应用

现在让我们为我们的博客创建一个仪表板,以便我们可以查看我们在 Elasticsearch 中的数据,包括帖子和 Apache 日志。我们将使用 PHP Elasticsearch SDK 来查询 Elasticsearch 并显示 Elasticsearch 结果。

我们将只加载 Elasticsearch 中的最新日志,然后,如果我们想要执行搜索,我们将创建一种过滤和指定要在日志中搜索的数据的方法。

这是搜索过滤表单的样子:

用于查看 Elasticsearch 日志的仪表板应用

search.php中,我们将创建一个简单的表单来搜索 Elasticsearch 中的值:

<form action="search_elasticsearch.php" method="post"> 
<table> 
   <tr> 
<td>Select time or query search term 
<tr><td>Time or search</td> 
<td><select> 
    <option value="time">Time</option> 
     <option value="query">Query Term</option> 
<select> 
</td>  
</tr> 
<tr> 
<td>Time Start/End</td> 
  <td><input type="text" name="searchTimestart" placeholder="YYYY-MM-DD HH:MM:SS" > /  
  <input type="text" name="searchTimeEnd" placeholder="YYYY-MM-DD HH:MM:SS" > 
</td> 
</tr> 
<tr> 
<td>Search Term:</td><td><input name="searchTerm"></td> 
</tr> 
<tr><td colspan="2"> 
<input type="submit" name="search"> 
</td></tr> 
</table> 
</form> 

当用户点击提交时,我们将向用户显示结果。

我们的表单应该简单地显示出我们当天对于 Apache 日志和博客文章的记录。

这是我们在命令行中使用 curl 查询ElasticSearch信息的方式:

**$ curl http://localhost:9200/_search?q=post_date>2016-11-15**

现在我们将从 Elasticsearch 获得一个 JSON 响应:

{"took":403,"timed_out":false,"_shards":{"total":5,"successful":5,"failed":0},"hits":{"total":1,"max_score":0.01989093,"hits":[{"_index":"posts","_type":"post","_id":"1","_score":0.01989093,"_source":{ 
  body: { 
    "user" : "kimchy", 
    "post_date" : "2016-11-15T14:12:12", 
    "post_body" : "trying out Elasticsearch" 
  }  
}}]}} 

我们也可以使用 REST 客户端(一种在 Firefox 中查询 RESTful API 的方式)来查询数据库,只需指定GET方法和路径,并在 URL 中设置q变量为您想要搜索的参数:

用于查看 Elasticsearch 日志的仪表板应用

带有结果缓存的简单搜索引擎

要安装 PHP Redis,请访问github.com/phpredis/phpredis

每次用户搜索时,我们可以将他们最近的搜索保存在 Redis 中,如果已经存在,就直接呈现这些结果。实现可能如下所示:

<?php 
$db = new mysqli(HOST, DB_USER, DB_PASSWORD, DB_NAME); //define the connection details 

if(isset($_POST['search'])) {  

$hashSearchTerm = md5($_POST['search']); 
    //get from redis and check if key exist,  
    //if it does, return search result    
    $rKeys = $redis->keys(*); 

   if(in_array($rKeys, $hashSearchTerm){  
         $searchResults =  $redis->get($hashSearchTerm); 
         echo "<ul>"; 
         foreach($searchResults as $result) { 
                 echo "<li> 
     <a href="readpost.php?id=" . $result ['postId']. "">".$result['postTitle'] . "</a> 
        </li>" ; 
         echo "</ul>"; 
        } 
   } else { 
     $query = "SELECT * from posts WHERE post_title LIKE '%".$_POST['search']."%' OR post_content LIKE '%".$_POST['search']."%'"; 

     $result = $db->query($query); 
     if($result->num_rows() > 0) { 
     echo "<ul>;" 
     while ($row = $result->fetch_array(MYSQL_BOTH))  
       { 
       $queryResults = [ 
       'postId' => $row['id'], 
       'postTitle' => $row['post_title']; 
        ]; 

        echo "<li> 
     <a href="readpost.php?id=" . $row['id']. "">".$row['post_title'] . "</a> 
        </li>" ; 
       } 
     echo "</ul>"; 

     $redis->setEx($hashSearchTerm, 3600, $queryResults); 

     } 
   } 
} //end if $_POST 
else { 
  echo "No search term in input"; 
} 
?> 

Redis 是一个简单的字典。它在数据库中存储一个键和该键的值。在前面的代码中,我们使用它来存储用户搜索结果的引用,这样下次执行相同的搜索时,我们可以直接从 Redis 数据中获取。

在前面的代码中,我们将搜索词转换为哈希,以便可以轻松地识别为通过的相同查询,并且可以轻松地存储为键(应该只有一个字符串,不允许空格)。如果在哈希后在 Redis 中找到键,那么我们将从 Redis 中获取它,而不是从数据库中获取它。

Redis 可以通过使用$redis->setEx方法保存键并在X秒后使其过期。在这种情况下,我们将其存储 3,600 秒,相当于一个小时。

缓存基础

缓存的概念是将已经搜索过的项目返回给用户,这样对于其他正在搜索相同搜索结果的用户,应用程序就不再需要从 MySQL 数据库中进行完整的数据库提取。

拥有缓存的坏处是您必须执行缓存失效。

Redis 数据的缓存失效

缓存失效是指当您需要过期和删除缓存数据时。这是因为您的缓存在一段时间后可能不再是实时的。当然,在失效后,您需要更新缓存中的数据,这发生在有新的数据请求时。缓存失效过程可以采用以下三种方法之一:

  • 清除是指立即从缓存数据中删除内容。

  • 刷新意味着获取新数据并覆盖已有数据。这意味着即使缓存中有匹配项,我们也将使用新信息刷新该匹配项。

  • 封禁基本上是将先前缓存的内容添加到封禁列表中。当另一个客户端获取相同信息并在检查黑名单时,如果已存在,缓存的内容将被更新。

我们可以在后台连续运行 cron 作业,以更新该搜索的每个缓存结果为新的结果。

这是在 crontab 中每 15 分钟运行的后台 PHP 脚本可能的样子:

0,15,30,45 * * * * php /path/to/phpfile 

要让 Logstash 将数据放入 Redis 中,我们只需要执行以下操作:

# shipper from apache logs to redis data 
output { 
redis { host => "127.0.0.1" data_type => "channel" key => "logstash-%{@type}-%{+yyyy.MM.dd.HH}" } 
} 

这是删除缓存数据的 PHP 脚本的工作原理:

functiongetPreviousSearches() { 
return  $redis->get('searches'); //an array of previously searched searchDates 
} 

$prevSearches = getPreviousSearches(); 

$prevResults = $redis->get('prev_results');  

if($_POST['search']) { 

  if(in_array($prevSEarches)&&in_array($prevResults[$_POST['search']])) { 
if($prevSEarches[$_POST['search'])] { 
            $redis->expire($prevSearches($_POST['searchDate'])) { 
         Return $prevResults[$_POST['search']]; 
} else { 
         $values =$redis->get('logstash-'.$_POST['search']); 
             $previousResults[] = $values; 
         $redis->set('prev_results', $previousResults); 

          } 

} 
     }   
  } 

在前面的脚本中,我们基本上检查了之前搜索的searchDate,如果有,我们将其设置为过期。

如果它也出现在previousResults数组中,我们将把它给用户;否则,我们将执行一个新的redis->get命令来获取搜索日期的结果。

使用浏览器的 localStorage 作为缓存

缓存存储的另一个选项是将其保存在客户端浏览器中。这项技术被称为localStorage

我们可以将其用作用户的简单缓存,并存储搜索结果,如果用户想搜索相同的内容,我们只需检查 localStorage 缓存。

提示

localStorage只能存储 5MB 的数据。但考虑到常规文本文件只有几千字节,这已经相当多了。

我们可以利用elasticsearch.js客户端而不是 PHP 客户端来向 Elasticsearch 发出请求。浏览器兼容版本可以从www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/browser-builds.html下载。

我们还可以使用 Bower 来安装elasticsearch.js客户端:

bower install elasticsearch 

为了达到我们的目的,我们可以利用 jQuery Build 通过创建一个使用 jQuery 的客户端:

var client = new $.es.Client({ 
hosts: 'localhost:9200' 
}); 

现在我们应该能够使用 JavaScript 来填充localStorage

由于我们只是在客户端进行查询和显示,这是完美的匹配!

请注意,我们可能无法使用客户端脚本记录搜索的数据。但是,我们可以将搜索查询历史保存为包含先前搜索的项目键的模型。

基本的 JavaScript searchQuery对象将如下所示:

varsearchQuery = { 
search: {queryItems: [ { 
'title: 'someName',  
  'author': 'Joe',  
   'tags': 'some tags'}  
] }; 
}; 

我们可以通过运行以下 JavaScript 文件来测试客户端是否工作:

client.ping({ 
requestTimeout: 30000, 

  // undocumented params are appended to the query string 
hello: "elasticsearch" 
}, function (error) { 
if (error) { 
console.error('elasticsearch cluster is down!'); 
  } else { 
console.log('All is well'); 
  } 
}); 

通过以下方式,搜索结果可以缓存在localStorage中:

localStorage.setItem('results',JSON.stringify(results)); 

我们将使用从elasticsearch中找到的数据填充结果,然后只需检查之前是否进行了相同的查询。

我们还需要保持数据的新鲜。假设大约需要 15 分钟,用户会感到无聊并刷新页面以查看新信息。

同样,我们检查过去是否显示过搜索结果:

var searches = localStorage.get('searches'); 
if(searches != mktime( date('H'), date('i')-15) ) { 
  //fetch again 
varsearchParams = { 
index: 'logdates', 
body:  
query: { 
match: { 
date: $('#search_date').value; 

} 
client.search(); 
} else { 
  //output results from previous search; 
prevResults[$("#search_date").val()]; 
} 

现在,每当我们过期搜索条件,比如大约 15 分钟后,我们将简单地清除缓存并放入 Elasticsearch 找到的新搜索结果。

使用流处理

在这里,我们将利用 PHP 的 Monolog 库,然后流式传输数据而不是推送完整的字符串。使用流的好处是它们可以轻松地管道到 Logstash 中,然后将其作为索引数据存储到 Elasticsearch 中。Logstash 还具有创建数据流和流式传输数据的功能。

我们甚至可以在不使用 Logstash 的情况下直接输入我们的数据,使用一种称为流的东西。有关流的更多信息,请参阅php.net/manual/en/book.stream.php

例如,以下是将一些数据推送到 Elasticsearch 的方法:http://localhost/dev/streams/php_input.php

curl -d "Hello World" -d "foo=bar&name=John" http://localhost/dev/streams/php_input.php 

php_input中,我们可以放入以下代码:

readfile('php://input') 

我们将获得Hello World&foo=bar&name=John,这意味着 PHP 能够使用 PHP 输入流获取第一个字符串作为流。

要玩转 PHP 流,让我们手动使用 PHP 创建一个流。PHP 开发人员通常在处理输出缓冲时已经有一些处理流数据的经验。

输出缓冲的想法是收集流直到完整,然后将其显示给用户。

当流尚未完成并且我们需要等待数据的结束字符以完全传输数据时,这是特别有用的。

我们可以将流推送到 Elasticsearch!这可以通过使用 Logstash 输入插件来处理流来实现。这就是 PHP 如何输出到流的方式:

<?php 
require 'vendor/autoload.php'; 
$client = new Elasticsearch\Client(); 
ob_start(); 
$log['body'] = array('hello' => 'world', 'message' => 'some test'); 
$log['index'] = 'test'; 
$log['type'] = 'log'; 
echo json_encode($log);  
//flush output of echo into $data 
$data = ob_get_flush(); 
$newData = json_decode($data); //turn back to array 
$client->index($newData); 

使用 PHP 存储和搜索 XML 文档

我们还可以处理 XML 文档并将其插入到 Elasticsearch 中。为此,我们可以将数据转换为 JSON,然后将 JSON 推送到 Elasticsearch 中。

首先,您可以查看以下 XML 转 JSON 转换器:

如果您想检查 XML 是否已正确转换为 JSON,请查看codebeautify.org/xmltojson上的XML TO JSON Converter工具;从那里,您可以轻松查看如何将 XML 导出为 JSON:

使用 PHP 存储和搜索 XML 文档

使用 Elasticsearch 搜索社交网络数据库

在本节中,我们将简单地使用我们的知识将其应用于使用 PHP 构建的现有社交网络。

假设我们有用户希望能够搜索他们的社交动态。这就是我们构建完整的自动下拉搜索系统的地方。

每当用户发布帖子时,我们需要能够将所有数据存储在 Elasticsearch 中。

然而,在我们的搜索查询中,我们将匹配搜索结果与用户提取的实际单词。如果它不与每个查询逐字匹配,我们将不显示它。

我们首先需要构建动态信息流。SQL 模式如下所示:

CREATE TABLE feed ( 
Id INT(11) PRIMARY KEY, 
Post_title TEXT, 
post_content TEXT, 
post_topics TEXT, 
post_time DATETIME, 
post_type VARCHAR(255), 
posted_by INT (11) DEFAULT '1'  
) ; 

Post_type将处理帖子的类型 - 照片、视频、链接或纯文本。

因此,如果用户添加了一种图片类型,它将被保存为图像类型。当某人搜索帖子时,他们可以按类型进行筛选。

每当用户保存新照片或新帖子时,我们还将数据存储到 Elasticsearch 中,如下所示:

INSERT INTO feed (`post_title`, `post_content`, `post_time`, `post_type`) VALUES ('some title', 'some content', '2015-03-20 00:00:00', 'image', 1); 

现在我们需要在用户插入前面的新帖子时制作一个输入表单。我们将构建一个可以上传带标题的照片或只添加文本的表单:

<h2>Post something</h2> 

<form type="post" action="submit_status.php" enctype="multipart/form-data"> 
Title:<input name="title" type="text" /> 
Details: <input name="content" type="text"> 
Select photo:  
<input type="file" name="fileToUpload" id="fileToUpload"> 
<input type="hidden" value="<?php echo $_SESSION['user_id'] ?>" name="user_id"> 
<input name="submit" type="submit"> 

</form> 

submit_status.php脚本将包含以下代码以保存到数据库中:

<?php 
use Elasticsearch\ClientBuilder; 

   require 'vendor/autoload.php'; 

$db = new mysqli(HOST, DB_USER, DB_PASSWORD, DATABASE); 

 $client = ClientBuilder::create()->build(); 
if(isset($_POST['submit'])) { 
  $contentType = (!empty($_FILES['fileToUpload'])) ? 'image' : ' 

$db->query("INSERT INTO feed (`post_title`, `post_content`, `post_time`, `post_type`, `posted_by`)  
VALUES ('". $_POST['title'] ."','" . $_POST['content'] . "','" . date('Y-m-d H:i:s'). "','" . $contentType . "','" . $_POST['user_id']); 

//save into elasticsearch 
$params = [ 
    'index' => 'my_feed', 
    'type' => 'posts', 
    'body' => [  
      'contenttype' => $contentType, 
      'title'  => $_POST['title'], 
      'content' => $_POST['content'],         
      'author' => $_POST['user_id'] 
    ] 
]; 
       $client->index($params); 
  } 

 ?> 

显示随机搜索引擎结果

前面的动态信息流数据库表是每个人都会发布的表。我们需要启用随机显示信息流中的内容。我们可以将帖子插入到信息流中而不是存储。

通过从 Elasticsearch 搜索并随机重新排列数据,我们可以使我们的搜索更有趣。在某种程度上,这确保了使用我们的社交网络的人能够在其信息流中看到随机的帖子。

要从帖子中搜索,我们将不直接查询 SQL,而是搜索 Elasticsearch 数据库中的数据。

首先,让我们弄清楚如何将数据插入名为posts的 Elasticsearch 索引中。打开 Elasticsearch 后,我们只需执行以下操作:

$ curl-XPUT 'http://localhost:9200/friends/'-d '{ 
"settings":{ 
"number_of_shards":3, 
"number_of_replicas":2 
} 
}' 

我们可能也想搜索我们的朋友,如果我们有很多朋友,他们不会全部出现在动态中。所以,我们只需要另一个用于搜索的索引,称为friends索引。

在 Linux 命令行中运行以下代码将允许我们创建一个新的friends索引:

$ curl-XPUT 'http://localhost:9200/friends/'-d '{ 
"settings":{ 
"number_of_shards":3, 
"number_of_replicas":2 
} 
}' 

因此,我们现在可以使用friends索引存储关于我们朋友的数据。

$ curl-XPUT 'http://localhost:9200/friends/posts/1'-d '{ 
"user":"kimchy", 
"post_date":"2016-06-15T14:12:12", 
"message":"fred the friend" 
}' 

通常我们会寻找朋友的朋友,当然,如果有任何朋友符合搜索条件,我们会向用户展示。

总结

在本章中,我们讨论了如何创建一个博客系统,尝试了 Elasticsearch,并能够做到以下几点:

  • 创建一个简单的博客应用程序并将数据存储在 MySQL 中

  • 安装 Logstash 和 Elasticsearch

  • 使用 curl 练习与 Elasticsearch 一起工作

  • 使用 PHP 客户端将数据导入 Elasticsearch

  • 使用 Highcharts 从 Elasticsearch 中的图表信息(点击数)

  • 使用elasticsearch.js客户端查询 Elasticsearch 获取信息

  • 在浏览器中使用 Redis 和 localStorage 进行缓存处理

第五章:创建 RESTful Web 服务

本章的目标是实现一个 RESTful Web 服务,用于管理用户配置文件。每个用户将具有一些基本的联系信息(例如用户名、名字和姓氏)、用于认证的密码和个人资料图片。

这项服务将使用 Slim 微框架实现,这是一个小巧轻便的框架,作为 PHP 5.5 及更新版本的开源库(MIT 许可)提供(当然我们将使用 PHP 7)。为了持久性,将使用 MongoDB 数据库。这提供了一个完美的机会来探索 PHP 的 MongoDB 扩展,它取代了旧的(同名,但完全不同)在 PHP 7 中被移除的 Mongo 扩展。

在本章中,我们将涵盖以下内容:

  • RESTful Web 服务的基础知识,最重要的是常见的 HTTP 请求和响应方法

  • 安装和使用 Slim 框架,以及 PSR-7 标准的基础知识

  • 使用 Slim 框架和 MongoDB 存储设计和实现实际示例 RESTful Web 服务

  • 如何使用 PSR-7 流和在 MongoDB 数据库中使用 GridFS 存储大文件

RESTful 基础知识

在本节中,我们将重述 RESTful Web 服务的基础知识。您将了解 REST Web 服务的基本架构目标以及超文本传输协议HTTP)的最常见协议语义,通常用于实现此类服务。

REST 架构

表现状态转移这个术语是由 Roy Fielding 在 2000 年创造的,描述了一种分布式系统的架构风格,原则上独立于任何具体的通信协议。实际上,大多数 REST 架构都是使用超文本传输协议来实现的,简称 HTTP。

每个 RESTful Web 服务的关键组件是资源。每个资源应满足以下要求:

  • 可寻址性:每个资源必须由统一资源标识符URI)进行标识,这在 RFC 3986 中得到了标准化。例如,具有用户名johndoe的用户可能具有 URIhttp://example.com/api/users/johndoe

  • 无状态性:参与者之间的通信是无状态的;这意味着 REST 应用程序通常不使用用户会话。相反,每个请求都需要包含服务器需要满足请求的所有信息。

  • 统一接口:每个资源必须可通过一组标准方法访问。当使用 HTTP 作为传输协议时,您通常会使用 HTTP 方法来查询或修改资源的状态。本章的下一节包含对最常见的 HTTP 标准方法和响应代码的简要概述。

  • 资源和表示的解耦:每个资源可以有多个表示。例如,REST 服务可能同时提供用户配置文件的 JSON 和 XML 表示。通常,客户端会指定服务器应该以哪种格式响应,服务器将选择最符合客户端指定要求的表示。这个过程称为内容协商

在本章中,您将学习如何在一个小型的 RESTful Web 服务中实现所有这些架构原则。您将实现几种不同类型的资源,具有不同的表示,并学习如何使用不同的 HTTP 方法和响应代码来查询和修改这些资源。此外,您还将学习如何利用高级的 HTTP 功能(例如丰富的缓存控制头集)。

常见的 HTTP 方法和响应代码

HTTP 定义了一组标准方法(或动词),客户端可以在请求中使用,以及服务器可以在响应中使用的状态代码。在 REST 架构中,不同的请求方法用于查询或修改由请求 URI 标识的资源的服务器端状态。这些请求方法和响应状态代码在 RFC 7231 中标准化。表 1表 2显示了最常见的请求方法和状态代码的概述。

请求方法GETHEADOPTIONS被定义为安全。服务器在处理这些类型的请求时不应修改自己的状态。此外,安全方法和PUTDELETE方法都被定义为幂等。幂等性意味着重复的相同请求应具有与单个请求相同的效果-例如,对/api/users/12345 URI 的多个DELETE请求仍应导致删除该资源。

表 1,常见的 HTTP 请求方法:

HTTP 方法 描述
GET 用于查询由 URI 标识的资源的状态。服务器将以查询的资源表示形式做出响应。
HEAD 就像GET一样,只是服务器返回响应头,而不是实际的资源表示。
POST POST请求可以在其请求体中包含资源表示。服务器应将此对象存储为请求 URI 标识的资源的新子资源。
PUT 就像POST一样,PUT请求也在其请求体中包含资源表示。服务器应确保具有给定 URI 和表示的资源存在,并且如果需要应创建一个资源。
DELETE 删除指定 URI 的资源。
OPTIONS 客户端可以使用它来查询给定资源允许哪些操作。

表 2:常见的 HTTP 响应状态代码:

状态代码 描述
200 OK 请求已成功处理;响应消息通常包含所请求资源的表示。
201 Created 200 OK一样,但另外明确指出请求创建了一个新资源。
202 Accepted 请求已被接受处理,但尚未被处理。当服务器异步处理耗时请求时,这是有用的。
400 Bad Request 服务器无法解释客户端的请求。当请求包含无效的 JSON 或 XML 数据时可能会出现这种情况。
401 Unauthorized 客户端需要在访问此资源之前进行身份验证。响应可以包含有关所需身份验证的更多信息,并且请求可以使用适当的凭据重复。
403 Forbidden 当客户端经过身份验证,但未被授权访问特定资源时可以使用。
404 Not Found 当 URI 指定的资源不存在时使用。
405 Method Not Allowed 请求方法不允许指定的资源。
500 Internal Server Error 服务器在处理请求时发生错误。

使用 Slim 框架的第一步

在本节中,您将首先使用 Composer 安装框架,然后构建一个小型示例应用程序,该应用程序将向您展示框架的基本原则。

安装 Slim

Slim 框架可以很容易地使用 Composer 安装。它需要至少版本 5.5 的 PHP,但也可以很好地与 PHP 7 一起使用。首先通过 Composer 初始化一个新项目:

**$ composer init .**

这将为我们的项目创建一个新的项目级composer.json文件。现在,您可以将 slim/slim 包添加为依赖项:

**$ composer require slim/slim**

一个小样本应用程序

现在,您可以在您的 PHP 应用程序中开始使用 Slim 框架。为此,在您的 Web 服务器文档根目录中创建一个index.php文件,并包含以下内容:

<?php 
use \Slim\App; 
use \Slim\Http\Request; 
use \Slim\Http\Response; 

require "vendor/autoload.php"; 

$app = new App(); 
$app->get("/", function(Request $req, Response $res): Response { 
    return $res->withJson(["message" => "Hello World!"]); 
}); 
$app->run(); 

让我们来看看 Slim 框架在这里是如何工作的。这里的中心对象是$app变量,它是Slim\App类的一个实例。然后可以使用这个应用实例来注册路由。每个路由都是一个将 HTTP 请求路径映射到处理 HTTP 请求的简单回调函数。这些处理函数需要接受一个请求和一个响应对象,并需要返回一个新的响应对象。

在测试这个应用程序之前,你可能需要配置你的 Web 服务器将所有请求重写到你的index.php文件。如果你正在使用 Apache 作为 Web 服务器,可以在你的文档根目录中使用一个简单的.htaccess文件来完成这个操作:

RewriteEngine on 
RewriteCond %{REQUEST_FILENAME} !-f 
RewriteCond %{REQUEST_FILENAME} !-d 
RewriteRule ^([^?]*)$ /index.php [NC,L,QSA] 

这个配置将重写所有 URL 的请求到你的index.php文件。

你可以使用浏览器测试你的(尽管仍然非常简单的)API。如果你更喜欢命令行,我可以推荐使用 HTTPie 命令行工具。HTTPie 是基于 Python 的,你可以使用操作系统的软件包管理器或 Python 自己的软件包管理器 pip 轻松安装它:

**apt-get install httpie**
**# Alternatively:**
**pip install --upgrade httpie**

然后你可以在命令行上使用HTTPie轻松执行 RESTful HTTP 请求,并获得语法高亮的输出。查看以下图例,了解在与示例应用程序一起使用 HTTPie 时的示例输出:

一个小样例应用

使用 Slim 示例应用程序的 HTTPie 的示例输出

接受 URL 参数

Slim 路由也可以包含路径中的参数。在你的index.php中,在最后一个$app->run()语句之前添加以下路由:

$app->get( 
    '/users/{username}', 
    function(Request $req, Response $res, array $args): Response { 
        return $res->withJson([ 
          'message' => 'Hello ' . $args['username' 
        ]); 
    } 
); 

正如你所看到的,任何路由规范都可以包含花括号中的任意参数。然后路由处理函数可以接受一个包含 URL 中所有路径参数的关联数组的第三个参数(例如前面示例中的用户名参数)。

接受带有消息体的 HTTP 请求

到目前为止,你只使用了 HTTP GET请求。当然,Slim 框架也支持 HTTP 协议定义的任何其他类型的请求方法。然而,GET和例如POST请求之间的一个有趣的区别是,一些请求(如POSTPUT等)可以包含请求体。

请求体由结构化数据组成,按照预定义的编码序列化为字符串。当向服务器发送请求时,客户端使用 Content-Type HTTP 头告诉服务器请求体使用的编码。常见的编码包括以下内容:

  • application/x-www-form-urlencoded 通常由浏览器在提交 HTML 表单时使用

  • application/json 用于 JSON 编码

  • application/xmltext/xml 用于 XML 编码

幸运的是,Slim 框架支持所有这些编码,并自动确定解析请求体的正确方法。你可以使用以下简单的路由处理程序进行测试:

$app->post('/users', function(Request $req, Response $res): Response { 
    $body = $req->getParsedBody(); 
    return $response->withJson([ 
        'message' => 'creating user ' . $body['username'] 
    ]); 
}); 

注意使用Request类提供的getParsedBody()方法。这个方法将使用请求体,并根据请求中存在的 Content-Type 头自动使用正确的解码方法。

现在你可以使用之前介绍的任何内容编码来将数据POST到这个路由。可以使用以下 curl 命令进行简单测试:

**$ curl -d '&username=martin&firstname=Martin&lastname=Helmich' http://localhost/users** 
**$ curl -d '{"username":"martin","firstname":"Martin","lastname":"Helmich"}' -H'Content-Type: application/json' http://localhost/users**
**$ curl -d '<user><username>martin</username><firstname>Martin</firstname><lastname>Helmich</lastname></user>' -H'Content-Type: application/xml'**

所有这些请求将从你的 Slim 应用程序中产生相同的响应,因为它们包含完全相同的数据,只是使用了不同的内容编码。

PSR-7 标准

Slim 框架的主要特性之一是 PSR-7 兼容性。PSR-7 是由 PHP 框架互操作性组(FIG)定义的 PHP 标准推荐(PSR),描述了一组标准接口,可以由 PHP 编写的 HTTP 服务器和客户端库实现,以增加这些产品之间的可操作性(或者用简单的英语来说,使这些库可以相互使用)。

PSR-7 定义了框架可以实现的一组 PHP 接口。下图说明了 PSR-7 标准定义的接口。您甚至可以通过使用 Composer 获取psr/http-messages包在您的项目中安装这些接口:

PSR-7 标准

PSR-7 标准定义的接口

您在之前的示例中使用的Slim\Http\RequestSlim\Http\Response类已经实现了这些 PSR-7 接口(Slim\Http\Request类实现了ServerRequestInterfaceSlim\Http\Response实现了ResponseInterface)。

当您想要将两个不同的 HTTP 库一起使用时,这些标准化的接口变得特别有用。作为一个有趣的例子,考虑一个 PSR-7 兼容的 HTTP 服务器框架,比如与一个 PSR-7 兼容的客户端库一起使用,例如Guzzle(如果要使用 Composer 安装,请使用包键guzzlehttp/guzzle)。您可以使用这两个库,并轻松地将它们连接在一起,实现一个非常简单的反向代理:

$httpClient = new \GuzzleHttp\Client(); 

$app = new \Slim\App(); 
$app->any('{path:.*}', 
    function( 
        ServerRequestInterface $req, 
        ResponseInterface $response 
    ) use ($client): ResponseInterface { 
        return $client->send( 
            $request->withUri( 
                $request->getUrl()->withHost('your-upstream-server.local') 
            ) 
        ); 
    } 
); 

这里到底发生了什么?Slim 请求处理程序将ServerRequestInterface的实现作为第一个参数传递(记住;这个接口继承了常规的RequestInterface),并且需要返回一个ResponseInterface的实现。方便的是,GuzzleHttp\Clientsend()方法也接受RequestInterface并返回ResponseInterface。因此,您可以简单地重用您在处理程序中收到的请求对象,并将其传递到 Guzzle 客户端中,并且还可以重用 Guzzle 客户端返回的响应对象。Guzzle 的send()方法实际上返回GuzzleHttp\Psr7\Response类的实例(而不是Slim\Http\Response)。这是完全可以接受的,因为这两个类都实现了相同的接口。此外,前面的示例使用了 PSR-7 接口定义的方法来修改请求 URI 的主机部分。

提示

不可变对象 您可能会对前面示例中的withUriwithHost方法感到好奇。为什么 PSR-7 接口没有声明setUrisetHost等方法?答案是所有 PSR-7 实现都设计为不可变。这意味着对象在创建后不打算被修改。所有以with开头的方法(实际上 PSR-7 定义了很多)都旨在返回原始对象的副本,其中一个属性被修改。因此,基本上,您将传递原始对象的克隆,而不是使用 setter 方法修改对象:

// 使用可变对象(不受 PSR-7 支持)

$uri->setHost('foobar.com');

// 使用不可变对象

$uri = $uri->withHOst('foobar.com');

中间件

中间件是 Slim 框架和类似库中最重要的功能之一。它允许您在将 HTTP 请求传递给实际请求处理程序之前修改 HTTP 请求,在从请求处理程序返回后修改 HTTP 响应,或者完全绕过请求处理程序。这有很多可能的用例:

  • 您可以在中间件中处理身份验证和授权。身份验证包括从给定的请求参数中识别用户(也许 HTTP 请求包含授权头或包含会话 ID 的 cookie),授权涉及检查经过身份验证的用户是否实际被允许访问特定资源。

  • 您可以通过计算特定用户的请求次数并在实际请求处理程序之前返回错误响应代码来为您的 API 实现速率限制。

  • 总的来说,所有在请求被请求处理程序处理之前丰富请求的各种操作。

中间件也是可链接的。框架可以管理任意数量的中间件组件,并且传入的请求将通过所有注册的中间件。每个中间件项必须作为函数可调用,并接受 RequestInterfaceResponseInterface 和表示下一个中间件实例(或请求处理程序本身)的函数。

以下代码示例显示了一个向应用程序添加(诚然非常简单的)HTTP 身份验证的中间件:

**$app->add(function (Request $req, Response $res, callable $next): Response {**
**    $auth = $req->getHeader('Authorization');**
**    if (!$auth) {**
**        return $res->withStatus(401);**
**    }**
**    if (substr($auth, 0, 6) !== 'Basic ' ||**
**        base64_decode(substr($auth, 6)) !== 'admin:secret') {**
**        return $res->withStatus(401);**
**    }**
**    return $next($req, $res);**
**}**

$app->get('/users/{username}', function(Request $req, Response $res): Response {
    // Handle the request
});

$app->get('/users/{username}', function(Request $req, Response $res): Response { 
    // Handle the request 
}); 

$app->add() 函数可用于注册中间件,该中间件将在任何请求上被调用。正如你所看到的,中间件函数本身看起来类似于常规请求处理程序,唯一的区别是第三个参数 $next。每个请求可以通过潜在的不确定数量的中间件。$next 函数使得中间件组件可以控制请求是否应该传递给链中的下一个中间件组件(或注册的请求处理程序本身)。然而,需要注意的是,中间件不必在任何时候调用 $next 函数。在上面的例子中,未经授权的 HTTP 请求甚至不会通过实际的请求处理程序,因为处理身份验证的中间件在没有有效身份验证时根本不调用 $next

这就是 PSR-7 起作用的地方。由于 PSR-7,您可以开发和分发中间件,并且它们将与实现 PSR-7 的所有框架和库一起工作。这保证了库之间的互操作性,并确保存在可以广泛重用的库的共享生态系统。简单的互联网搜索 PSR-7 中间件 将产生大量几乎可以立即使用的库。

实现 REST 服务

在本章中,您将开始实现实际的用户配置文件服务。作为第一步,我们将设计服务的 RESTful API,然后继续实现设计的 API 端点。

设计服务

现在是时候开始实现本章中要实现的实际任务了。在本章中,您将使用 Slim 框架和 MongoDB 开发一个 RESTful Web 服务,以访问和读取用户配置文件。简而言之,在设计 REST Web 服务时,您应该考虑要向用户提供的资源的第一步。

提示

保持 RESTful 请确保围绕使用 POSTPUTDELETE 等 HTTP 动词修改状态的资源进行设计。我经常看到围绕过程而不是资源开发的 HTTP API,最终导致 URL,例如 POST /users/createPOST /users/update,更像是基于 RPC 的 API 设计。

以下表格显示了本章中将使用的资源和操作。有一些中心资源:

  • /profiles 是所有已知配置文件的集合。它是只读的 - 意味着只允许 GET(和 HEAD)操作 - 并包含所有用户配置文件的集合。您的 API 的用户应该能够通过一组约束来过滤集合或将返回的集合限制为给定长度。过滤和限制都可以作为可选查询参数实现:
      GET /profiles?firstName=Martin&limit=10 

  • /profiles/{username} 是代表单个用户的资源。对此资源的 GET 请求将返回该用户的配置文件,而 PUT 请求将创建配置文件或更新已存在的配置文件,DELETE 请求将删除配置文件。

  • /profiles/{username}/image 代表用户的配置文件图像。可以使用 PUT 操作设置它,使用 GET 操作读取它,并使用 DELETE 操作删除它。

路由 目的
GET /profiles 列出所有用户,可选择按搜索参数过滤
GET /profiles/{username} 返回单个用户
PUT /profiles/{username} 创建具有给定用户名的新用户,或更新已存在的具有该用户名的用户
DELETE /profiles/{username} 删除用户
PUT /profiles/{username}/image 为用户存储新的个人资料图片
GET /profiles/{username}/image 检索用户的个人资料图片
DELETE /profiles/{username}/image 删除个人资料图片

可能会出现的一个问题是,为什么这个例子使用PUT请求来创建新的个人资料,而不是POST。我经常看到POST创建对象相关联,PUT更新对象相关联 - 这是对 HTTP 标准的错误解释。请注意,我们将用户名作为个人资料的 URI 的一部分。这意味着当为具有给定用户名的新用户创建个人资料时,您已经知道资源在创建后将具有哪个 URI。

这正是PUT资源的作用 - 确保具有给定表示的资源存在于给定的 URI 中。优点是您可以依赖PUT请求是幂等的。这意味着对/profiles/martin-helmich的十几个相同的PUT请求不会造成任何伤害,而对/profiles/的十几个相同的POST请求很可能会创建十几个不同的用户资料。

启动项目

在开始实现 REST 服务之前,您可能需要处理一些系统要求。为了简单起见,我们将在这个例子中使用一组链接的 Docker 容器。首先创建一个新的容器,使用官方的 MongoDB 镜像运行一个 MongoDB 实例:

 ****$ docker run --name profiles-db -d mongodb**** 

对于应用程序容器,您可以使用官方的 PHP 镜像。但是,由于 MongoDB PHP 驱动程序不是标准 PHP 分发的一部分,您需要通过PECL安装它。为此,您可以创建一个自定义的Dockerfile来构建您的应用程序容器:

FROM php:7-apache 

RUN apt-get update && \ 
    apt-get install -y libssl-dev && \ 
    pecl install mongodb && \ 
    docker-php-ext-enable mongodb 
RUN a2enmod rewrite 

接下来,构建您的容器并运行它。将其链接到已经运行的 MongoDB 容器:

**$ docker build -t packt-chp5 .**
**$ docker run --name profiles-web --link profiles-db:db \**
**-v $PWD:/var/www/html -p 80:80 packt-chp5**

这将创建一个新的 Apache 容器,其中运行 PHP 7,并将当前工作目录映射到 Web 服务器的文档根目录。-p 80:80标志允许通过浏览器或命令行客户端使用http://localhost访问 Apache 容器。

就像本章的第一个例子一样,我们将使用 Composer 来管理项目的依赖关系和自动类加载。您可以从以下composer.json文件开始:

{ 
    "name": "packt-php7/chp5-rest-example", 
    "type": "project", 
    "authors": [{ 
        "name": "Martin Helmich", 
        "email": "php7-book@martin-helmich.de" 
    }], 
    "require": { 
        "php": ">=7.0", 
        "slim/slim": "³.1", 
        "mongodb/mongodb": "¹.0", 
        "phpunit/phpunit": "⁵.1", 
        "ext-mongodb": "*" 
    }, 
    "autoload": { 
        "psr-4": { 
            "Packt\\Chp5": "src/" 
        } 
    } 
} 

创建composer.json文件后,使用composer install安装项目的依赖项。如果您没有在符合所有指定约束的环境中运行 Composer,可以在 Composer 命令中添加--ignore-platform-reqs标志。

在这个例子中,我们将使用 Composer 的 PSR-4 自动加载器,以Packt\Chp5作为基本命名空间,所有类都位于src/目录中。这意味着类如Packt\Chp5\Foo\Bar需要在文件src/Foo/Bar.php中定义。

使用 MongoDB 构建持久层

在这个例子中,我们将采取的第一步是构建应用程序域的面向对象模型 - 用户个人资料。在第一步中,这将不会过于复杂。让我们从定义一个Profile类开始,具有以下属性:

  • 唯一标识用户并可用作登录用户名的用户名

  • 给定的名字和姓氏

  • 用户关心的兴趣和爱好列表

  • 用户的生日

  • 用户密码的哈希值,在以后用户在编辑自己的个人资料之前进行身份验证时会很有用(并防止他们编辑其他人的个人资料)

这可以作为一个简单的 PHP 类来实现。请注意,该类目前完全是不可变的,因为它的属性只能使用构造函数设置。此外,此类不包含任何持久性逻辑(意味着从数据库获取数据或将其放回)。遵循关注点分离,建模数据并将其持久化到数据库中是两个不同的关注点,应该在不同的类中处理。

declare(strict_types = 1); 
namespace Packt\Chp5\Model; 

class Profile 
{ 
    private $username; 
    private $givenName; 
    private $familyName; 
    private $passwordHash; 
    private $interests; 
    private $birthday; 

    public function __construct( 
        string $username, 
        string $givenName, 
        string $familyName, 
        string $passwordHash, 
        array $interests = [], 
        DateTime $birthday = null 
    ) { 
        $this->username     = $username; 
        $this->givenName    = $givenName; 
        $this->familyName   = $familyName; 
        $this->passwordHash = $passwordHash; 
        $this->interests    = $interests; 
        $this->birthday     = $birthday; 
    } 

    // getter methods omitted for brevity 
} 

现在,您可以在应用程序中建模用户配置文件-但是您还不能对其进行任何操作。我们的第一个目标将是将Profile类的实例存储在 MongoDB 数据库后端。这将在Packt\Chp5\Service\ProfileService类中完成:

declare(strict_types = 1); 
namespace Packt\Chp5\Service; 

use MongoDB\Collection; 
use Packt\Chp5\Model\Profile; 

class ProfileService 
{ 
    private $profileCollection; 

    public function __construct(Collection $profileCollection) 
    { 
        $this->profileCollection = $profileCollection; 
    } 
} 

ProfileServiceMongoDB\Collection类的实例作为依赖项传递到其构造函数中。这个类由mongodb/mongodb Composer 包提供,并且模型一个单一的 MongoDB 集合(虽然不完全正确,但集合是 MongoDB 等同于 MySQL 表)。同样,我们遵循关注点分离:建立与数据库的连接不是ProfileService的关注点,将在不同的地方处理。

让我们首先在此服务中实现一个方法,该方法可以将新用户配置文件添加到数据库中。这样的方法的合适名称是insertProfile

 **   public function insertProfile(Profile $profile): Profile**
**    {**
**        $record = $this->profileToRecord($profile);**
**        $this->profileCollection->insertOne($profile);**
**        return $profile;**
**    }**

    private function profileToRecord(Profile $profile): array 
    { 
        return [ 
            'username'     => $profile->getUsername(), 
            'passwordHash' => $profile->getPasswordHash(), 
            'familyName'   => $profile->getFamilyName(), 
            'givenName'    => $profile->getGivenName(), 
            'interests'    => $profile->getInterests(), 
            'birthday'     => $profile->getBirthDay()->format('Y-m-d') 
        ]; 
    } 
} 

请注意,此代码示例包含一个私有方法profileToRecord(),它将Profile类的实例转换为一个普通的 PHP 数组,该数组将作为文档存储在集合中。这段代码被提取到自己的方法中,因为以后将有用作可重用的函数。实际的插入是由集合的insertOne方法执行的,该方法将一个简单的 PHP 数组作为参数。

作为下一步,让我们通过使用另一个方法updateProfile来扩展配置文件服务,该方法可以-您猜对了-更新现有配置文件:

    public function updateProfile(Profile $profile): Profile 
    { 
        $record = $this->profileToRecord($profile); 
        $this->profileCollection->findOneAndUpdate( 
            ['username' => $profile->getUsername()], 
            ['$set' => $record] 
        ); 
        return $profile; 
    } 

传递给findOneAndUpdate方法的第一个参数是 MongoDB 查询。它包含一组约束,文档应该匹配(在本例中,文档的username属性等于$profile->getUsername()返回的任何值)。

就像 SQL 查询一样,这些查询可以变得任意复杂。例如,以下查询将匹配所有名为Martin且出生于 1980 年 1 月 1 日后的用户,并且喜欢开源软件或科幻文学。您可以在docs.mongodb.com/manual/reference/operator/query/找到 MongoDB 查询选择运算符的完整参考。

[ 
  'givenName' => 'Martin', 
  'birthday' => [ 
    '$gte' => '1980-01-01' 
  ], 
  'interests' => [ 
    '$elemMatch' => [ 
      'Open Source', 
      'Science Fiction' 
    ] 
] 

findOneAndUpdate()的第二个参数包含一组更新操作,这些操作将应用于与给定查询匹配的第一个找到的文档。在此示例中,$set运算符包含一个属性值数组,该数组将在匹配的文档上进行更新。就像查询一样,这些更新语句可能会变得更加复杂。以下内容将更新所有匹配的用户的名字为Max,并将音乐添加到他们的兴趣列表中:

[ 
  '$set' => [ 
    'givenName' => 'Max', 
  ], 
  '$addToSet' => [ 
    'interests' => ['Music'] 
  ] 
] 

使用一个简单的测试脚本,您现在可以测试此配置文件服务。为此,您需要建立与 MongoDB 数据库的连接。如果您之前使用了 Docker 命令,则您的 MongoDB 服务器的主机名将简单地是db

declare(strict_types = 1); 
$manager = new \MongoDB\Driver\Manager('mongodb://db:27017'); 
$collection = new \MongoDB\Collection($manager, 'database-name', 'profiles'); 

$profileService = new \Packt\Chp5\Service\ProfileService($collection); 
$profileService->insertProfile(new \Packt\Chp5\Model\Profile( 
    'jdoe', 
    'John', 
    'Doe', 
    password_hash('secret', PASSWORD_BCRYPT), 
    ['Open Source', 'Science Fiction', 'Death Metal'], 
    new \DateTime('1970-01-01') 
)); 

添加和更新用户配置文件很好,但配置文件服务还不支持从数据库加载这些配置文件。为此,您可以使用更多方法扩展您的ProfileService。从一个简单检查给定用户名的配置文件是否存在的hasProfile方法开始:

public function hasProfile(string $username): bool 
{ 
    return $this->profileCollection->count(['username' => $username]) > 0; 
} 

hasProfile方法简单地检查数据库中是否存储了给定用户名的配置文件。为此,使用了集合的count方法。该方法接受一个 MongoDB 查询对象,并将返回匹配此约束的所有文档的计数(在本例中,具有给定用户名的所有文档的数量)。当具有给定用户名的配置文件已经存在时,hasProfile方法将返回 true。

继续实现getProfile方法,该方法从数据库加载用户配置文件并返回相应的Profile类的实例:

public function getProfile(string $username): Profile 
{ 
    $record = $this->profileCollection->findOne(['username' => $username]); 
    if ($record) { 
        return $this->recordToProfile($record); 
    } 
    throw new UserNotFoundException($username); 
} 

private function recordToProfile(BSONDocument $record): Profile 
{ 
    return new Profile( 
        $record['username'], 
        $record['givenName'], 
        $record['familyName'], 
        $record['passwordHash'], 
        $record['interests']->getArrayCopy(), 
        new \DateTime($record['birthday']); 
    ); 
} 

getProfile方法使用集合的findOne方法(偶然接受相同的查询对象),该方法返回与约束匹配的第一个文档(或 null,当找不到文档时)。当找不到具有给定用户名的配置文件时,将抛出Packt\Chp5\Exception\UserNotFoundException。这个类的实现留给读者作为练习。然后将找到的文档传递给私有的recordToProfile方法,该方法反转了您之前已经实现的profileToRecord方法。请注意,所有 MongoDB 查询方法都不会返回普通数组作为文档,而总是返回MongoDB\Model\BSONDocument类的实例。您可以像使用常规数组一样使用它们,但在类型提示函数参数或返回值时可能会遇到问题。

添加和检索用户

现在您已成功实现了配置文件 REST 服务的持久性逻辑,您现在可以开始实现实际的 REST Web 服务。

在之前的示例中,我们已经使用简单的回调函数作为 Slim 框架的请求处理程序:

$app->get('/users', function(Request $req, Response $res): Response { 
    return $response->withJson(['foo' => 'bar']); 
}); 

这对于快速入门是完全可以的,但随着应用程序的增长,将会变得难以维护。为了以更可扩展的方式构建应用程序,您可以利用 Slim 请求处理程序不必是匿名函数的事实,而实际上可以是任何可调用的东西。在 PHP 中,您还可以通过实现__invoke方法使对象可调用。您可以使用这个来实现一个请求处理程序,它可以是一个具有自己属性的有状态类。

然而,在实现请求处理程序之前,让我们先看一下 Web 服务的响应。由于我们选择了 JSON 作为我们的主要表示格式,您经常需要将Profile类的实例转换为 JSON 对象 - 当然也需要反过来。为了保持这种转换逻辑的可重用性,建议将此功能实现为一个单独的单元。为此,您可以实现一个ProfileJsonMapping trait,如下例所示:

namespace Packt\Chp5\Mapper; 

trait ProfileJsonMapping 
{ 
    private function profileToJson(Profile $profile): array 
    { 
        return [ 
            'username'   => $profile->getUsername(), 
            'givenName'  => $profile->getGivenName(), 
            'familyName' => $profile->getFamilyName(), 
            'interests'  => $profile->getInterests(), 
            'birthday'   => $profile->getBirthday()->format('Y-m-d') 
        ]; 
    } 

    private function profileFromJson(string $username, array $json): Profile 
    { 
        return new Profile( 
            $username, 
            $json['givenName'], 
            $json['familyName'], 
            $json['passwordHash'] ?? password_hash($json['password']), 
            $json['interests'] ?? [], 
            new \DateTime($json['birthday']) 
        ); 
    } 
} 

表示逻辑已经处理好了,现在您可以继续实现获取单个用户配置文件的路由。在这个示例中,我们将在Packt\Chp5\Route\ShowUserRoute类中实现这个路由,并使用之前显示的ProfileJsonMapping trait:

namespace Packt\Chp5\Route; 
// imports omitted for brevity 

class ShowProfileRoute 
{ 
    use ProfileJsonMapping; 
    private $profileService; 

    public function __construct(ProfileService $profileService) 
    { 
        $this->profileService = $profileService; 
    } 

    public function __invoke(Request $req, Response $res, array $args): Response 
    { 
        $username = $args['username']; 
        if ($this->profileService->hasProfile($username)) { 
            $profile = $this->profileService->getProfile($username); 
            return $res->withJson($this->profileToJson($profile)); 
        } else { 
            return $res 
                ->withStatus(404) 
                ->withJson(['msg' => 'the user ' . $username . ' does not exist']); 
        } 
    } 
} 

正如您所看到的,这个类中的__invoke方法与您在之前的示例中看到的回调请求处理程序具有相同的签名。此外,这个路由类使用了您在上一节中实现的ProfileService。实际处理程序首先检查是否存在具有给定用户名的配置文件,并在请求的配置文件不存在时返回404 Not Found状态码。否则,Profile实例将被转换为普通数组,并作为 JSON 字符串返回。

您现在可以在您的index.php中初始化您的 Slim 应用程序,如下所示:

use MongoDB\Driver\Manager; 
use MongoDB\Collection; 
use Packt\Chp5\Service\ProfileService; 
use Packt\Chp5\Route\ShowProfileRoute; 
use Slim\App; 

$manager        = new Manager('mongodb://db:27017'); 
$collection     = new Collection($manager, 'database-name', 'profiles'); 
**$profileService = new ProfileService($collection);** 

$app = new App(); 
**$app->get('/profiles/{username}', new 
ShowProfileRoute($profileService));** 
$app->run(); 

如果您的数据库仍然包含来自上一节的一些测试数据,您现在可以通过使用 HTTPie 等工具来测试此 API。

添加和检索用户

使用 REST API 访问用户配置文件

对于创建新用户配置文件(以及更新现有配置文件),您现在可以创建一个新的请求处理程序类。由于对/profiles/{username}PUT请求将创建一个新配置文件或更新已经存在的配置文件,新的请求处理程序将需要同时做这两件事:

namespace Packt\Chp5\Route; 
// Imports omitted for brevity 

class PutProfileRoute 
{ 
    use ProfileJsonMapping; 
    private $profileService; 

    public function __construct(ProfileService $profileService) 
    { 
        $this->profileService = $profileService; 
    } 

    public function __invoke(Request $req, Response $res, array $args): Response 
    { 
        $username      = $args['username']; 
        $profileJson   = $req->getParsedBody(); 
        $alreadyExists = $this->profileService->hasProfile($username); 

        $profile = $this->profileFromJson($username, $profileJson); 
        if ($alreadyExists) { 
            $profile = $this->profileService->updateProfile($profile); 
            return $res->withJson($this->profileToJson($profile)); 
        } else { 
            $profile = $this->profileService->insertProfile($profile); 
            return $res->withJson($this->profileToJson($profile))->withStatus(201); 
        } 
    } 
} 

在这个例子中,我们使用Request类的getParsedBody方法来检索解析后的消息体。幸运的是,这个方法足够智能,可以查看请求的Content-Type头,并自动选择适当的解析方法(在application/json请求的情况下,将使用json_decode方法来解析请求体)。

在检索解析后的消息体之后,使用ProfileJsonMapping特性中定义的profileFromJson方法来从这个主体创建Profile类的实际实例。根据这个用户名是否已经存在配置文件,然后我们可以使用ProfileService类中实现的方法插入或更新用户配置文件。请注意,根据是创建新配置文件还是更新现有配置文件,将返回不同的 HTTP 状态代码(当创建新配置文件时为201 Created,否则为200 OK)。

提示

验证呢? 您会注意到目前,您可以将任何东西作为主体参数传递,请求处理程序将尝试将其保存为用户配置文件,即使缺少必要的属性或主体不包含有效的 JSON。PHP 7 的新类型安全功能将为您提供一些安全性,因为 - 由于启用了declare(strict_types=1)的严格类型,当输入主体中缺少某些字段时,它们将简单地抛出TypeError。输入验证的更彻底的实现将在验证输入部分进行讨论:

// As both parameters have a "string" type hint, strict typing will 
// cause PHP to throw a TypeError when one of the two parameters should 
// be null 
$profile = new Profile( 
    $jsonObject['familyName'], 
    $jsonObject['givenName'] 
); 

您现在可以在您的index.php中的新路由中连接这个类:

$app = new App(); 
$app->get('/profiles/{username}', new 
ShowProfileRoute($profileService)); 
**$app->put('/profiles/{username}', new 
PutProfileRoute($profileService));** 
$app->run(); 

之后,您可以尝试使用 HTTPie 创建一个新的用户配置文件:

$ http PUT http://localhost/profiles/jdoe givenName=John familyName=Doe \ 
password=secret birthday=1970-01-01 

您还可以尝试通过简单重复相同的 PUT 请求并使用不同的参数来更新创建的配置文件。HTTP 响应代码(201 Created200 OK)允许您确定是创建了新配置文件还是更新了现有配置文件。

列出和搜索用户

您的 API 的当前状态允许用户读取、创建和更新特定用户配置文件。但是,网络服务仍然缺少搜索配置文件集合或列出所有已知用户配置文件的功能。对于列出配置文件,您可以使用一个新函数getProfiles来扩展ProfileService类:

namespace Packt\Chp5\Service\ProfileService; 
// ... 

class ProfileService 
{ 
    // ... 

 **public function getProfiles(array $filter = []): Traversable**
 **{** 
 **$records = $this->profileCollection->find($filter);** 
 **foreach ($records as $record) {** 
 **yield $this->recordToProfile($record);** 
 **}** 
 **}** 
} 

如果您不熟悉这种语法:前一个函数是一个生成器函数。yield语句将导致函数返回Generator类的一个实例,它本身实现了Traversable接口(这意味着您可以使用foreach循环对其进行迭代)。当处理大型数据集时,这种构造特别方便。由于find函数本身也返回一个Traversable,您可以从数据库中流式传输匹配的配置文件文档,惰性地将它们映射到用户对象,并将数据流传递到请求处理程序中,而无需将整个对象集合放入内存中。

作为对比,考虑以下实现,它使用普通数组而不是生成器。您会注意到,由于使用了ArrayObject类,即使方法的接口保持不变(返回Traversable),这个实现在ArrayObject实例中存储了所有找到的配置文件实例的列表,而之前的实现一次只处理一个对象:

public function getProfiles(array $filter = []): Traversable 
{ 
    $records  = $this->profileCollection->find($filter); 
    $profiles = new ArrayObject(); 

    foreach ($records as $record) { 
        $profiles->append($this->recordToProfile($record)); 
    } 

    return $profiles; 
} 

由于 MongoDB API 直接接受结构良好的查询对象来匹配文档,而不是自定义的基于文本的语言(是的,我在看你,SQL),因此您不必担心传统基于 SQL 的系统(并非总是,但通常是)容易受到的注入攻击。这允许我们的getProfiles函数接受$filter参数中的查询对象,我们只需将其传递给find方法。

接下来,您可以通过添加新的参数来扩展getProfiles函数,以对结果集进行排序:

public function getProfiles( 
    array  $filter        = [], 
 **string $sorting       = 'username',** 
 **bool   $sortAscending = true** 
): Traversable { 
    $records = $this->profileCollection->find($filter, ['sort' => [ 
 **$sorting => $sortAscending ? 1 : -1** 
 **]]);** 

    // ... 
} 

使用这个新函数,很容易实现一个新的类Packt\Chp5\Route\ListProfileRoute,您可以使用它来查询整个用户集合:

namespace Packt\Chp5\Route; 

class ListProfileRoute 
{ 
    use ProfileJsonMapping; 

    private $profileService; 

    public function __construct(ProfileService $profileService) 
    { 
        $this->profileService = $profileService; 
    } 

    public function __invoke(Request $req, Response $res): Response 
    { 
        $params = $req->getQueryParams(); 

        $sort = $params['sort'] ?? 'username'; 
        $asc  = !($params['desc'] ?? false); 
        $profiles     = $this->profileService->getProfiles($params, $sort, $asc); 
        $profilesJson = []; 

        foreach ($profiles as $profile) { 
            $profilesJson[] = $this->profileToJson($profile); 
        } 

        return $response->withJson($profilesJson); 
    } 
} 

在那之后,您可以在index.php文件中为 Slim 应用程序注册新的请求处理程序:

$app = new App(); 
**$app->get('/profiles', new ListProfileRoute($profileService));** 
$app->get('/profiles/{username}', new ShowProfileRoute($profileService)); 
$app->put('/profiles/{username}', new PutProfileRoute($profileService)); 
$app->run(); 

删除配置文件

到目前为止,删除用户配置文件应该是一个简单的任务。首先,您需要在ProfileService类中添加一个新的方法:

class ProfileService 
{ 
    // ... 

 **public function deleteProfile(string $username)** 
 **{** 
 **$this->profileCollection->findOneAndDelete(['username' =>
 $username]);** 
 **}** 
} 

MongoDB 集合的findOneAndDelete方法确实实现了它承诺的功能。此函数的第一个参数是一个 MongoDB 查询对象,就像您在前几节中已经使用过的那样。由此查询对象匹配的第一个文档将从集合中删除。

在那之后,您可以实现一个新的请求处理程序类,该类使用配置文件服务来删除配置文件(如果存在)。当尝试删除一个不存在的用户时,请求处理程序将以正确的状态代码“404 未找到”做出响应:

namespace Packt\Chp5\Route; 
// Imports omitted... 

class DeleteProfileRoute 
{ 

    /** @var ProfileService */ 
    private $profileService; 

    public function __construct(ProfileService $profileService) 
    { 
        $this->profileService = $profileService; 
    } 

    public function __invoke(Request $req, Response $res, array $args): Response 
    { 
        $username = $args['username']; 
        if ($this->profileService->hasProfile($username)) { 
            $this->profileService->deleteProfile($username); 
            return $res->withStatus(204); 
        } else { 
            return $res 
                ->withStatus(404) 
                ->withJson(['msg' => 'user "' . $username . '" does not exist']); 
        } 
    } 
} 

您会注意到我们的示例代码库中现在有一些重复的代码。

ShowProfileRouteDeleteProfileRoute都需要检查给定用户名的用户配置文件是否存在,如果不存在,则返回“404 未找到”响应。

这是使用中间件的一个很好的用例。如前一节所述,中间件可以通过自身发送响应到 HTTP 请求,或将请求传递给下一个中间件组件或实际的请求处理程序。这使您可以实现中间件,从路由参数中获取用户名,检查该用户是否存在配置文件,并在该用户不存在时返回错误响应。如果该用户确实存在,则可以将请求传递给请求处理程序:

namespace Packt\Chp5\Middleware 

class ProfileMiddleware 
{ 
    private $profileService; 

    public function __construct(ProfileService $profileService) 
    { 
        $this->profileService = $profileService; 
    } 

    public function __invoke(Request $req, Response $res, callable $next): Response 
    { 
        $username = $request->getAttribute('route')->getArgument('username'); 
        if ($this->profileService->hasProfile($username)) { 
            $profile = $this->profileService->getProfile($username); 
            return $next($req->withAttribute('profile', $profile)); 
        } else { 
            return $res 
                ->withStatus(404) 
                ->withJson(['msg' => 'user "' . $username . '" does not exist'); 
        } 
    } 
} 

所有 PSR-7 请求都可以具有可以使用$req->withAttribute($name, $value)设置的任意属性,并且可以使用$req->getAttribute($name)检索。这允许中间件将任何类型的值传递给实际的请求处理程序 - 这正是ProfileMiddleware通过将profile属性附加到请求来实现的。然后,实际的请求处理程序可以通过简单调用$req->getAttribute('profile')来检索已加载的用户配置文件。

中间件的注册方式与常规请求处理程序类似。每次使用$app->get(...)$app->post(...)注册新的请求处理程序时,此方法将返回路由配置的实例,您可以为其分配不同的中间件。在您的index.php文件中,您可以像这样注册您的中间件:

**$profileMiddleware = new ProfileMiddleware($profileService);** 

$app = new App(); 
$app->get('/profiles', new ListProfileRoute($profileService)); 
$app->get('/profiles/{username}', new ShowProfileRoute($profileService)) 
 **->add($profileMiddleware);** 
$app->delete('/profiles/{username}', new DeleteProfileRoute($profileService)) 
 **->add($profileMiddleware);** 
$app->put('/profiles/{username}', new PutProfileRoute($profileService)); 
$app->run(); 

在为GET /profiles/{username}DELETE /profiles{username}路由注册中间件之后,您可以修改相应的路由处理程序,简单地使用配置文件请求属性并删除错误检查:

class ShowProfileRoute 
{ 
    // ... 

    public function __invoke(Request $req, Response $res): Response 
    { 
 **$profile = $req->getAttribute('profile');** 
        return $res->withJson($this->profileToJson($profile)); 
    } 
} 

DeleteProfileRoute类也是如此:

class DeleteProfileRoute 
{ 
    // ... 

    public function __invoke(Request $req, Response $res): Response 
    { 
 **$profile = $req->getAttribute('profile');** 
        $this->profileService->deleteProfile($profile->getUsername()); 
        return $res->withStatus(204); 
    } 
} 

验证输入

在实现PUT /profiles/{username}路由时,您可能已经注意到我们并没有那么关注用户输入的验证。在某种程度上,我们实际上可以使用 PHP 7 的新严格类型来验证用户输入。您可以通过在代码的第一行使用declare(strict_types = 1)语句来激活严格类型。考虑以下示例:

return new Profile( 
    $username, 
    $json['givenName'], 
    $json['familyName'], 
    $json['passwordHash'] ?? password_hash($json['password']), 
    $json['interests'] ?? [], 
    $json['birthday'] ? new \DateTime($json['birthday']) : NULL 
); 

例如,假设Profile类的$givenName参数被类型提示为string,当$json['givenName']未设置时,前面的语句将抛出TypeError。然后,您可以使用try/catch语句捕获此错误,并返回适当的400 Bad Request HTTP 响应:

try { 
    $this->jsonToProfile($req->getParsedBody()); 
} catch (\TypeError $err) { 
    return $response 
        ->withStatus(400) 
        ->withJson(['msg' => $err->getMessage()]); 
} 

然而,这只提供了基本的错误检查,因为您只能验证数据类型,无法断言逻辑约束。此外,这种方法会提供糟糕的用户体验,因为错误响应只会包含第一个触发的错误。

为了实现更复杂的验证,您可以向应用程序添加另一个中间件(在这里使用中间件是一个很好的选择,因为它允许您将验证逻辑的关注点封装在一个单独的类中)。让我们称这个类为Packt\Chp5\Middleware\ProfileValidationMiddleware

namespace Packt\Chp5\Middleware; 

class ProfileValidationMiddleware 
{ 
    private $profileService; 

    public function __construct(ProfileService $profileService) 
    { 
        $this->profileService = $profileService; 
    } 

    public function __invoke(Request $req, Response $res, callable $next): Response 
    { 
        $username      = $request->getAttribute('route')->getArgument('username'); 
        $profileJson   = $req->getParsedBody(); 
        $alreadyExists = $this->profileService->hasProfile($username); 

        $errors = []; 

        if (!isset($profileJson['familyName'])) { 
            $errors[] = 'missing property "familyName"'; 
        }  

        if (!isset($profileJson['givenName'])) { 
            $errors[] = 'missing property "givenName"'; 
        }  

        if (!$alreadyExists && 
            !isset($profileJson['password']) && 
            !isset($profileJson['passwordHash']) 
        ) { 
            $errors[] = 'missing property "password" or "passwordHash"; 
        } 

        if (count($errors) > 0) { 
            return $res 
                ->withStatus(400) 
                ->withJson([ 
                    'msg' => 'request body does not contain a valid user profile', 
                    'errors' => $errors 
                ]); 
        } else { 
            return $next($req, $res); 
        } 
    } 
} 

声明验证中间件类之后,您可以在您的index.php文件中注册它:

$profileMiddleware = new ProfileMiddleware($profileService); 
**$validationMiddleware = new ProfileValidationMiddleware($profileService);** 

$app = new App(); 
$app->get('/profiles', new ListProfileRoute($profileService)); 
$app->get('/profiles/{username}', new ShowProfileRoute($profileService)) 
    ->add($profileMiddleware); 
$app->delete('/profiles/{username}', new DeleteProfileRoute($profileService)) 
    ->add($profileMiddleware); 
$app->put('/profiles/{username}', new PutProfileRoute($profileService)) 
 **->add($validationMiddleware);** 
$app->run(); 

流和大文件

到目前为止,我们的 Web 服务可以对用户个人资料执行基本操作。在本章中,我们将扩展用户个人资料服务,以处理用户的个人资料图像。在本章的过程中,您将学习如何使用 PHP 流处理甚至非常大的文件。

配置图像上传

基本上,在 RESTful 应用程序中,您可以将图像视为任何其他资源。您可以使用POST和/或PUT操作创建和更新它,并使用GET检索它。唯一的区别是资源的选择表示。不再使用application/json作为 Content-Type 进行 JSON 编码,而是使用具有 JPEG 或 PNG 表示的资源,其相应的image/jpegimage/png内容类型。

在这一点上,了解 PSR-7 标准如何对 HTTP 请求和响应主体进行建模将是有用的。从技术上讲,每个消息(请求和响应)主体只是一个字符串,这些可以被建模为简单的 PHP 字符串。这对于您在过去几节中处理的消息来说是可以的,但在处理更大的消息时(比如图像),可能会出现问题。这就是为什么 PSR-7 将所有消息主体都建模为用户可以从中读取(对于请求主体)或写入(对于响应主体)的流。您可以将流中的数据传输到文件或另一个网络流中,而无需将整个内容适应 PHP 进程的内存中。

接下来,我们将实现用户的个人资料图像作为一个新的资源。用户的个人资料图像将具有 URI /profiles/{username}/image。加载用户的图像将是一个简单的GET请求(返回一个带有适当的Content-Type: image/jpegimage/png头和图像二进制内容的响应主体)。更新图像将使用PUT请求,带有 Content-Type 头和图像内容作为消息主体。

首先实现一个新的请求处理程序类,在其中从请求流中读取块并将其写入文件:

namespace Packt\Chp5\Route; 

class PutImageRoute 
{ 
    private $imageDir; 

    public function __construct(string $imageDir) 
    { 
        $this->imageDir = $imageDir; 
    } 

    public function __invoke(Request $req, Response $res): Response 
    { 
        if (!is_dir($this->imageDir)) { 
            mkdir($this->imageDir); 
        } 

        $profile    = $req->getAttribute('profile'); 
        $fileName   = $this->imageDir . '/' . $profile->getUsername(); 
 **$fileHandle = fopen($fileName, 'w');** 
 **while (!$req->getBody()->eof()) {** 
 **fwrite($fileHandle, $req->getBody()->read(4096));** 
 **}** 
 **fclose($fileHandle);** 
        return $res->withJson(['msg' => 'image was saved']); 
    } 
} 

这个请求处理程序使用fopen(...)打开一个文件句柄进行写入,然后以 4 KB 的块读取请求体,并将其写入打开的文件。这种解决方案的优势在于,无论您保存的文件是 4 KB 还是 400 MB,都不会真正有影响。因为您一次只读取输入的 4 KB 块,所以内存使用量会保持相对恒定,与输入大小无关。

提示

关于可扩展性 在本地文件系统中存储文件并不是非常可扩展的,应该只被视为一个示例。为了保持可扩展性,您可以将图像目录放在网络存储上(例如 NFS),或者使用其他分布式存储解决方案。在接下来的部分中,使用 GridFS 存储 您还将学习如何使用 GridFS 以可扩展的方式存储文件。

接下来,在您的 Slim 应用程序中注册请求处理程序:

$profileMiddleware = new ProfileMiddleware($profileService); 
$validationMiddleware = new ProfileValidationMiddleware($profileService); 

$app = new App(); 
// ... 
**$app->put('/profiles/{username}/image', new PutImageRoute(__DIR__ . '/images'))**
 **->add($profileMiddleware);** 
$app->run(); 

为了测试这个路由,在您的计算机上找到一个任意大小的图像文件,并在命令行上使用以下 curl 命令(记住;由于我们正在为新路由使用profileMiddleware,因此您需要为此指定实际存在于数据库中的用户个人资料):

**curl --data-binary @very-big-image.jpeg -H 'Content-Type: image/jpeg' -X PUT 
-v http://localhost/profiles/jdoe/image**

运行此命令后,您应该在项目文件夹的images/目录中找到一个jdoe文件,其内容与原始文件完全相同。

将用户的个人资料图片返回给用户的工作方式类似。为此,实现一个名为Packt\Chp5\Route\ShowImageRoute的新请求处理程序:

namespace Packt\Chp5\Route; 

class ShowImageRoute 
{ 
    /** @var string */ 
    private $imageDir; 

    public function __construct(string $imageDir) 
    { 
        $this->imageDir = $imageDir; 
    } 

    public function __invoke(Request $req, Response $res, array $args): Response 
    { 
        $profile     = $req->getAttribute('profile'); 
        $filename    = $this->imageDir . '/' . $profile->getUsername(); 
        $fileHandle  = fopen($filename, 'r'); 
        $contentType = mime_content_type($filename); 

        return $res 
            ->withStatus(200) 
            ->withHeader('Content-Type', $contentType) 
            ->withBody(new Body($fileHandle)); 
    } 
} 

在这里,我们使用mime_content_type方法来加载上传文件的实际内容类型。需要内容类型,因为 HTTP 响应需要包含 Content-Type 标头,浏览器才能正确显示图像。

此外,我们使用Slim\Http\Body类,这使得实现更加容易:这个类实现了 PSR-7 StreamInterface,并且可以使用打开的流(例如,可能是打开的文件处理程序)进行初始化。然后,Slim 框架将负责将此文件的内容传递给用户。

此请求处理程序也可以在index.php中注册:

$app = new \Slim\App(); 
// ... 
**$app->get('/profiles/{username}/image', new
ShowImageRoute(__DIR__ . '/images'))** 
 **->add($profileMiddleware);** 
$app->put('/profiles/{username}/image', new PutImageRoute(__DIR__ . '/images')) 
    ->add($profileMiddleware); 
$app->run(); 

如果在实现PUT路由后上传了测试图像,现在可以使用相同的用户个人资料测试GET路由。由于 curl 命令只会返回一个大的二进制数据块,因此最好在您选择的浏览器中访问http://localhost/profiles/jdoe/image

使用 GridFS 存储

将用户上传的文件存储在服务器的本地文件系统中对于小型站点是一个可行的解决方案。但是,一旦您感到需要对应用程序进行水平扩展,您就需要研究分布式文件系统。例如,您可以用 NFS 文件系统挂载的网络设备替换用户图像文件夹。由于在本章中您已经大量使用了 MongoDB,在本节中您将了解 GridFS。GridFS 是一种在 MongoDB 数据库中存储 - 可能非常大的 - 文件的规范。

GridFS 规范很简单。您将需要两个集合 - fs.filesfs.chunks。前者将用于存储文件元数据,而后者将存储文件的实际内容。由于 MongoDB 文档默认限制为 16 MB,每个存储的文件将被分割成几个(默认为)255 KB 的。文件文档将具有以下形式:

{ 
  "_id": <object ID> 
  "length": <file size in bytes>, 
  "chunkSize": <size of each chunk in bytes, default 261120>, 
  "uploadDate": <timestamp at which the file was saved>, 
  "md5": <MD5 checksum of the file, as hex string>, 
  "filename": <the file's name>, 
  "contentType": <MIME type of file contents>, 
  "aliases": <list of alternative file names>, 
  "metadata": <arbitrary metadata> 
} 

块文档将具有以下形式:

{ 
  "_id": <chunk ID>, 
  "files_id": <object ID of the file this chunk belongs to>, 
  "n": <index of the chunk within the file>, 
  "data": <binary data, of the file's chunk length> 
} 

请注意,GridFS 只是关于如何在 MongoDB 数据库中存储文件的建议,您可以自由地在 MongoDB 存储中实现任何其他类型的文件存储。但是,GridFS 是一个被广泛接受的标准,很可能您会发现几乎每种语言都有 GridFS 的实现。因此,如果您想使用 PHP 应用程序将文件写入 GridFS 存储,然后使用 Python 程序从那里读取文件,您会发现这两种运行时都有标准实现,可以直接使用,而无需重新发明轮子。

在 PHP 7 中,您可以使用helmich/gridfs库来访问 GridFS。您可以使用 Composer 获取它:

**composer require helmich/gridfs**

GridFS 围绕存储桶展开。每个存储桶可以包含任意数量的文件,并在两个 MongoDB 集合中内部存储它们,<bucket name>.files<bucket name>.chunks

首先,通过使用Helmich\GridFS\Bucket类在您的index.php中修改应用程序引导程序,为用户个人资料图片创建一个新的存储桶。每个存储桶可以使用BucketOptions实例进行初始化,在其中您可以配置几个存储桶选项,例如存储桶名称。

创建存储桶后,您可以将其作为依赖项传递给ShowImageRoutePutImageRoute类:

$manager = new \MongoDB\Driver\Manager('mongodb://db:27017'); 
$database = new \MongoDB\Database($manager, 'database-name'); 

**$bucketOptions = (new \Helmich\GridFS\Options\BucketOptions)**
 **->withBucketName('profileImages');**
**$bucket = new \Helmich\GridFS\Bucket($database, $bucketOptions);** 
$profiles = $database->selectCollection('profiles'); 

// ... 

**$app->get('/profiles/{username}/image', new 
ShowImageRoute($bucket))** 
    ->add($profileMiddleware); 
**$app->put('/profiles/{username}/image', new 
PutImageRoute($bucket))** 
    ->add($profileMiddleware); 
$app->run(); 

PutImageRouteShowImageRoute现在作为依赖项传递了一个 GridFS 桶。现在,您可以调整这些类,将上传的文件写入该桶。让我们从调整PutImageRoute类开始:

**use Helmich\GridFS\BucketInterface;** 

class PutImageRoute 
{ 
 **private $bucket;** 

    public function __construct(BucketInterface $bucket) 
    { 
 **$this->bucket = $bucket** 
    } 

    // ... 
} 

GridFS 桶的接口在BucketInterface中描述,我们在这个例子中使用。现在,您可以修改PutImageRoute__invoke方法,将上传的个人资料图片存储在桶中:

public function __invoke(Request $req, Response $res, array $args): Response 
{ 
    $profile       = $req->getAttribute('profile'); 
    $contentType   = $req->getHeader('content-type')[0]; 
    $uploadOptions = (new \Helmich\GridFS\Options\UploadOptions) 
      ->withMetadata(['content-type' => $contentType]); 

    $stream = $req->getBody()->detach(); 
    $fileId = $this->bucket->uploadFromStream( 
        $profile->getUsername(), 
        $stream, 
        $uploadOptions 
    ); 
    fclose($stream); 
    return $res->withJson(['msg' => 'image was saved']); 
} 

在这个例子中,我们使用了$req->getBody()->detach()方法来从请求体中获取实际的底层输入流。然后将该流传递到桶的uploadFromStream方法中,以及文件名(在这种情况下,简单地是用户名)和一个UploadOptions对象。UploadOptions对象定义了文件上传的配置选项;其中,您可以指定将存储在<bucketname>.files集合中的 GridFS 自身元数据旁边存储的任意元数据。

现在,剩下的就是调整ShowProfileRoute以使用 GridFS 桶。首先,修改类的构造函数以接受BucketInterface作为参数,就像我们在PutProfileRoute中所做的那样。然后,您可以调整__invoke方法,从 GridFS 桶中下载请求的个人资料图片:

public function __invoke(Request $req, Response $res, array $args): Response 
{ 
    $profile = $req->getAttribute('profile'); 
    $stream = $this->bucket->openDownloadStreamByName($profile->getUsername()); 
    $file = $stream->file(); 

    return $res 
        ->withStatus(200) 
        ->withHeader('content-type', $file['metadata']['content-type']) 
        ->withBody(new \Helmich\GridFS\Stream\Psr7\DownloadStreamAdapter($stream)); 
} 

在这个例子中,我们使用了 Bucket 的openDownloadStreamByName方法来通过文件名在桶中查找文件,并返回一个流对象,从中我们可以下载文件。

打开的下载流是Helmich\GridFS\Stream\DownloadStream接口的实现。不幸的是,您不能直接在 HTTP 响应中使用此接口。但是,您可以使用Helmich\GridFS\Stream\Psr7\DownloadStreamAdapter接口从 GridFS 流创建一个符合 PSR-7 标准的流,您可以在 HTTP 响应中使用。

总结

在本章中,您已经了解了 RESTful Web 服务的基本架构原则,以及如何使用 Slim 框架自己构建一个。我们还看了一下 PSR-7 标准,它允许您在 PHP 中编写可在框架之间移植并且高度可重用的 HTTP 组件。最后,您还学会了如何使用 PHP 的新 MongoDB 扩展来直接访问存储的集合,以及与其他高级抽象(如 GridFS 标准)结合使用。

您新学到的 Slim 知识和对 PSR-7 标准的理解将使您受益于接下来的章节,您将在其中使用 Ratchet 框架构建一个实时聊天应用程序,然后使用 PSR-7 将 Ratchet 与 Slim 框架集成。

第六章:构建聊天应用程序

在本章中,我们将使用WebSocket构建一个实时聊天应用程序。您将学习如何使用Ratchet框架使用 PHP 构建独立的 WebSocket 和 HTTP 服务器,以及如何在 JavaScript 客户端应用程序中连接到 WebSocket 服务器。我们还将讨论如何为 WebSocket 应用程序实现身份验证以及如何在生产环境中部署它们。

WebSocket 协议

在本章中,我们将广泛使用 WebSocket。为了充分理解我们将要构建的聊天应用程序的工作原理,让我们首先看一下 WebSocket 的工作原理。

WebSocket 协议在RFC 6455中指定,并使用 HTTP 作为底层传输协议。与传统的请求/响应范式相比,在该范式中,客户端向服务器发送请求,服务器然后回复响应消息,WebSocket 连接可以保持打开很长时间,服务器和客户端都可以在 WebSocket 上发送和接收消息(或数据帧)。

WebSocket 连接始终由客户端(通常是用户的浏览器)发起。下面的清单显示了浏览器可能发送给支持 WebSocket 的服务器的示例请求:

GET /chat HTTP/1.1 
Host: localhost 
**Upgrade: websocketConnection: upgrade** 
Origin: http://localhost 
**Sec-WebSocket-Key: de7PkO6qMKuGvUA3OQNYiw==** 
**Sec-WebSocket-Protocol: chat** 
**Sec-WebSocket-Version: 13**

就像常规的 HTTP 请求一样,请求包含一个请求方法(GET)和一个路径(/chat)。UpgradeConnection头告诉服务器,客户端希望将常规 HTTP 连接升级为 WebSocket 连接。

Sec-WebSocket-Key头包含一个随机的、base64 编码的字符串,唯一标识这个单个 WebSocket 连接。Sec-WebSocket-Protocol头可以用来指定客户端想要使用的子协议。子协议可以用来进一步定义服务器和客户端之间的通信应该是什么样子的,并且通常是特定于应用程序的(在我们的情况下,是chat协议)。

当服务器接受升级请求时,它将以101 Switching Protocols响应作为响应,如下面的清单所示:

HTTP/1.1 101 Switching Protocols 
Upgrade: websocket 
Connection: Upgrade 
Sec-WebSocket-Accept: BKb5cchTfWayrC7SKtvK5yW413s= 
Sec-WebSocket-Protocol: chat 

Sec-WebSocket-Accept头包含了来自请求的Sec-WebSocket-Key的哈希值(确切的哈希值在 RFC 6455 中指定)。响应中的Sec-WebSocket-Protocol头确认了服务器理解客户端在请求中指定的协议。

完成这个握手之后,连接将保持打开状态,服务器和客户端都可以从套接字发送和接收消息。

使用 Ratchet 的第一步

在本节中,您将学习如何安装和使用 Ratchet 框架。需要注意的是,Ratchet 应用程序的工作方式与部署在 Web 服务器上并且基于每个请求工作的常规 PHP 应用程序不同。这将要求您采用一种新的思考方式来运行和部署 PHP 应用程序。

架构考虑

使用 PHP 实现 WebSocket 服务器并不是一件简单的事情。传统上,PHP 的架构围绕着经典的请求/响应范式:Web 服务器接收请求,将其传递给 PHP 解释器(通常内置于 Web 服务器中或由进程管理器(如 PHP-FPM)管理),解析请求并将响应返回给 Web 服务器,然后 Web 服务器再响应客户端。PHP 脚本中数据的生命周期仅限于单个请求(这一原则称为共享无状态)。

这对于传统的 Web 应用程序非常有效;特别是共享无状态原则,因为这是 PHP 应用程序通常很好扩展的原因之一。然而,对于 WebSocket 支持,我们需要一种不同的范式。客户端连接需要保持打开状态很长时间(可能是几个小时,甚至几天),服务器需要在连接的整个生命周期内随时对客户端消息做出反应。

实现这种新范式的一个库是我们在本章中将要使用的Ratchet库。与常规的 PHP 运行时不同,它们存在于 Web 服务器中,Ratchet 将启动自己的 Web 服务器,可以为长时间运行的 WebSocket 连接提供服务。由于您将处理具有极长运行时间的 PHP 进程(服务器进程可能运行数天、数周或数月),因此您需要特别注意诸如内存消耗之类的事项。

入门

使用Composer可以轻松安装 Ratchet。它需要至少版本为 5.3.9 的 PHP,并且也与 PHP 7 兼容。首先,在项目目录的命令行上使用composer init命令初始化一个新项目:

**$ composer init .**

接下来,将 Ratchet 添加为项目的依赖项:

**$ composer require cboden/ratchet**

此外,通过向生成的composer.json文件添加以下部分来配置 Composer 的自动加载器:

'autoload': { 
  'PSR-4': { 
    'Packt\Chp6\Example': 'src/' 
  } 
} 

像往常一样,PSR-4 自动加载意味着 Composer 类加载器将在项目目录的src/文件夹中查找Packt\Chp6\Example命名空间的类。一个(假设的)Packt\Chp6\Example\Foo\Bar类需要在src/Foo/Bar.php文件中定义。

由于 Ratchet 实现了自己的 Web 服务器,您将不需要专用的 Web 服务器,如ApacheNginx(目前)。首先创建一个名为server.php的文件,在其中初始化和运行 Ratchet Web 服务器:

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0'); 
$app->run() 

然后,您可以启动您的 Web 服务器(它将侦听您在Ratchet\App构造函数的第二个参数中指定的端口)使用以下命令:

**$ php server.php**

如果您的计算机上没有准备好 PHP 7 安装,您可以使用以下命令快速开始使用Docker

**$ docker run --rm -v $PWD:/opt/app -p 8080:8080 php:7 php /opt/app/server.php**

这两个命令都将启动一个长时间运行的 PHP 进程,可以直接在命令行上处理 HTTP 请求。在后面的部分中,您将学习如何将应用程序部署到生产服务器上。当然,这个服务器实际上并没有做太多事情。但是,您仍然可以使用 CLI 命令或浏览器进行测试,如下面的屏幕截图所示:

入门

使用 HTTPie 测试示例应用程序

让我们继续向我们的服务器添加一些业务逻辑。由 Ratchet 提供服务的 WebSocket 应用程序需要是实现Ratchet\MessageComponentInterface的 PHP 类。此接口定义了以下四种方法:

  • onOpen(\Ratchet\ConnectionInterface $c)将在新客户端连接到 WebSocket 服务器时调用

  • onClose(\Ratchet\ConnectionInterface $c)将在客户端从服务器断开连接时调用

  • onMessage(\Ratchet\ConnectionInterface $sender, $msg)将在客户端向服务器发送消息时调用

  • onError(\Ratchet\ConnectionInterface $c, \Exception $e)将在处理消息时发生异常时调用

让我们从一个简单的例子开始:一个 WebSocket 服务,客户端可以向其发送消息,它将以相同的消息但是反向的方式回复给同一个客户端。让我们称这个类为Packt\Chp6\Example\ReverseEchoComponent;代码如下:

namespace Packt\Chp6\Example; 

use Ratchet\ConnectionInterface; 
use Ratchet\MessageComponentInterface; 

class ReverseEchoComponent implements MessageComponentInterface 
{ 
    public function onOpen(ConnectionInterface $conn) 
    {} 

    public function onClose(ConnectionInterface $conn) 
    {} 

    public function onMessage(ConnectionInterface $sender, $msg) 
    {} 

    public function onError(ConnectionInterface $conn, 
                            Exception $e) 
    {} 
} 

请注意,尽管我们不需要MessageComponentInterface指定的所有方法,但我们仍然需要实现所有这些方法,以满足接口。例如,如果在客户端连接或断开连接时不需要发生任何特殊的事情,则实现onOpenonClose方法,但只需将它们留空即可。

为了更好地理解此应用程序中发生的情况,请向onOpenonClose方法添加一些简单的调试消息,如下所示:

public function onOpen(ConnectionInterface $conn) 
{ 
    echo "new connection from " . $conn->remoteAddress . "\n"; 
} 

public function onClose(ConnectionInterface $conn) 
{ 
    echo "connection closed by " . $conn->remoteAddress . "\n"; 
} 

接下来,实现onMessage方法。$msg参数将包含客户端发送的消息作为字符串,并且您可以使用ConnectionInterface类的send()方法将消息发送回客户端,如下面的代码片段所示:

public function onMessage(ConnectionInterface $sender, $msg) 
{ 
    echo "received message '$msg' from {$conn->remoteAddress}\n"; 
    $response = strrev($msg); 
    $sender->send($response); 
} 

提示

您可能倾向于使用 PHP 7 的新类型提示功能来提示$msg参数为string。在这种情况下,这是行不通的,因为它会改变由Ratchet\MessageComponentInterface规定的方法接口,并导致致命错误。

然后,您可以使用以下代码在server.php文件中将您的 WebSocket 应用程序注册到Ratchet\App实例中:

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0'); 
**$app->route('/reverse', new Packt\Chp6\Example\ReverseEchoComponent);** 
$app->run(); 

测试 WebSocket 应用程序

为了测试 WebSocket 应用程序,我可以推荐wscat工具。它是一个用 JavaScript 编写的命令行工具(因此需要在您的计算机上运行 Node.js),可以使用npm进行安装,如下所示:

**$ npm install -g wscat**

使用 WebSocket 服务器监听端口8080,您可以使用以下 CLI 命令使用wscat打开新的 WebSocket 连接:

**$ wscat -o localhost --connect localhost:8080/reverse**

这将打开一个命令行提示符,您可以在其中输入要发送到 WebSocket 服务器的消息。还将显示从服务器接收到的消息。请参见以下屏幕截图,了解 WebSocket 服务器和 wscat 的示例输出:

测试 WebSocket 应用程序

使用 wscat 测试 WebSocket 应用程序

玩转事件循环

在前面的示例中,您只在收到来自同一客户端的消息后才向客户端发送消息。这是在大多数情况下都能很好地工作的传统请求/回复通信模式。但是,重要的是要理解,当使用 WebSocket 时,您并不被强制遵循这种模式,而是可以随时向连接的客户端发送消息。

为了更好地了解您在 Ratchet 应用程序中拥有的可能性,让我们来看看 Ratchet 的架构。Ratchet 是建立在 ReactPHP 之上的;一个用于网络应用程序的事件驱动框架。React 应用程序的核心组件是事件循环。应用程序中触发的每个事件(例如,当新用户连接或向服务器发送消息时)都存储在队列中,事件循环处理存储在此队列中的所有事件。

ReactPHP 提供了不同的事件循环实现。其中一些需要安装额外的 PHP 扩展,如libeventev(通常,基于libeventev或类似扩展的事件循环提供最佳性能)。通常,像 Ratchet 这样的应用程序会自动选择要使用的事件循环实现,因此如果您不想要关心 ReactPHP 的内部工作,通常不需要担心。

默认情况下,Ratchet 应用程序会创建自己的事件循环;但是,您也可以将自己创建的事件循环注入到Ratchet\App类中。

所有 ReactPHP 事件循环都必须实现接口React\EventLoop\LoopInterface。您可以使用类React\EventLoop\Factory自动创建一个在您的环境中受支持的此接口的实现:

$loop = \React\EventLoop\Factory::create(); 

然后,您可以将这个$loop变量传递到您的 Ratchet 应用程序中:

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0', $loop) 
$app->run(); 

直接访问事件循环允许您实现一些有趣的功能。例如,您可以使用事件循环的addPeriodicTimer函数注册一个回调,该回调将在周期性间隔内由事件循环执行。让我们在一个简短的示例中使用这个特性,通过构建一个名为Packt\Chp6\Example\PingComponent的新 WebSocket 组件:

namespace Packt\Chp6\Example; 

use Ratchet\MessageComponentInterface; 
use React\EventLoop\LoopInterface; 

class PingCompoment extends MessageComponentInterface 
{ 
    private $loop; 
    private $users; 

    public function __construct(LoopInterface $loop) 
    { 
        $this->loop  = $loop; 
        $this->users = new \SplObjectStorage(); 
    } 

    // ... 
} 

在这个例子中,$users属性将帮助我们跟踪连接的用户。每当新客户端连接时,我们可以使用onOpen事件将连接存储在$users属性中,并使用onClose事件来移除连接:

public function onOpen(ConnectionInterface $conn) 
{ 
 **$this->users->attach($conn);** 
} 

public function onClose(ConnectionInterface $conn) 
{ 
 **$this->users->detach($conn);** 
} 

由于我们的 WebSocket 组件现在知道了连接的用户,我们可以使用事件循环来注册一个定时器,定期向所有连接的用户广播消息。这可以很容易地在构造函数中完成:

public function __construct(LoopInterface $loop) 
{ 
    $this->loop  = $loop; 
    $this->users = new \SplObjectStorage(); 

 **$i = 0;** 
 **$this->loop->addPeriodicTimer(5, function() use (&$i) {** 
 **foreach ($this->users as $user) {** 
 **$user->send('Ping ' . $i);** 
 **}** 
 **$i ++;** 
 **});** 
} 

传递给 addPeriodicTimer 的函数将每五秒钟被调用一次,并向每个连接的用户发送一个带有递增计数器的消息。修改您的 server.php 文件,将这个新组件添加到您的 Ratchet 应用程序中:

$loop = \React\EventLoop\Factory::create(); 
$app = new \Ratchet\App('localhost', 8080, '0.0.0.0', $loop) 
**$app->route('/ping', new PingCompoment($loop));** 
$app->run(); 

您可以再次使用 wscat 测试这个 WebSocket 处理程序,如下截图所示:

Playing with the event loop

定期事件循环计时器发送的周期性消息

这是一个很好的例子,说明了 WebSocket 客户端在没有明确请求的情况下从服务器接收更新。这提供了有效的方式,以几乎实时地向连接的客户端推送新数据,而无需重复轮询信息。

实现聊天应用程序

在这个关于使用 WebSocket 进行开发的简短介绍之后,让我们现在开始实现实际的聊天应用程序。聊天应用程序将由使用 Ratchet 构建的 PHP 服务器端应用程序和在用户浏览器中运行的基于 HTML 和 JavaScript 的客户端组成。

启动项目服务器端

如前一节所述,基于 ReactPHP 的应用程序在与事件循环扩展(如 libeventev)一起使用时将获得最佳性能。不幸的是,libevent 扩展与 PHP 7 不兼容。幸运的是,ReactPHP 也可以与 ev 扩展一起使用,其最新版本已经支持 PHP 7。就像在上一章中一样,我们将使用 Docker 来创建一个干净的软件堆栈。首先为您的应用程序容器创建一个 Dockerfile

FROM php:7 
RUN pecl install ev-beta && \ 
    docker-php-ext-enable ev 
WORKDIR /opt/app 
CMD ["/usr/local/bin/php", "server.php"] 

然后,您将能够从该文件构建一个镜像,并使用以下 CLI 命令从项目目录内启动容器:

**$ docker build -t packt-chp6**
**$ docker run -d --name chat-app -v $PWD:/opt/app -p 8080:8080 
      packt-chp6**

请注意,只要您的项目目录中没有 server.php 文件,这个命令实际上是不会起作用的。

就像在前面的示例中一样,我们也将使用 Composer 进行依赖管理和自动加载。为您的项目创建一个新的文件夹,并创建一个 composer.json 文件,其中包含以下内容:

{ 
    "name": "packt-php7/chp6-chat", 
    "type": "project", 
    "authors": [{ 
        "name": "Martin Helmich", 
        "email": "php7-book@martin-helmich.de" 
    }], 
    "require": { 
        "php": ">= 7.0.0", 
        "cboden/ratchet": "⁰.3.4" 
    }, 
    "autoload": { 
        "psr-4": { 
            "Packt\\Chp6": "src/" 
        } 
    } 
} 

通过在项目目录中运行 composer install 安装所有必需的软件包,并创建一个临时的 server.php 文件,其中包含以下内容:

<?php 
require_once 'vendor/autoload.php'; 

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0'); 
$app->run(); 

您已经在介绍示例中使用了 Ratchet\App 构造函数。关于这个类的构造函数参数有几点需要注意:

  • 第一个参数 $httpHost 是您的应用程序将可用的 HTTP 主机名。这个值将被用作允许的来源主机。这意味着当您的服务器监听 localhost 时,只有在 localhost 域上运行的 JavaScript 才能连接到您的 WebSocket 服务器。

  • $port 参数指定了您的 WebSocket 服务器将监听的端口。端口 8080 现在足够了;在后面的部分,您将学习如何安全地配置您的应用程序以在 HTTP 标准端口 80 上可用。

  • $address 参数描述了 WebSocket 服务器将监听的 IP 地址。这个参数的默认值是 '127.0.0.1',这将允许在同一台机器上运行的客户端连接到您的 WebSocket 服务器。当您在 Docker 容器中运行应用程序时,这是行不通的。字符串 '0.0.0.0' 将指示应用程序监听所有可用的 IP 地址。

  • 第四个参数 $loop 允许您将自定义事件循环注入 Ratchet 应用程序。如果不传递此参数,Ratchet 将构造自己的事件循环。

您现在应该能够使用以下命令启动您的应用程序容器:

**$ docker run --rm -v $PWD:/opt/app -p 8080:8080 packt-chp6**

提示

由于您的应用程序现在是一个单一的、长时间运行的 PHP 进程,对 PHP 代码库的更改在重新启动服务器之前不会生效。请记住,当您对应用程序的 PHP 代码进行更改时,使用 Ctrl + C 停止服务器,并使用相同的命令重新启动服务器(或使用 docker restart chat-app 命令)。

引导 HTML 用户界面

我们的聊天应用程序的用户界面将基于 HTML、CSS 和 JavaScript。为了管理前端依赖关系,在本例中我们将使用Bower。您可以使用以下命令(作为 root 用户或使用sudo)安装 Bower:

**$ npm install -g bower**

继续创建一个新的public/目录,您可以在其中放置所有前端文件。在该目录中,放置一个带有以下内容的bower.json文件:

{ 
    "name": "packt-php7/chp6-chat", 
    "authors": [ 
        "Martin Helmich <php7-book@martin-helmich.de>" 
    ], 
    "private": true, 
    "dependencies": { 
        "bootstrap": "~3.3.6" 
    } 
} 

创建bower.json文件后,您可以使用以下命令安装声明的依赖项(在本例中是Twitter Bootstrap框架):

**$ bower install**

这将下载 Bootstrap 框架及其所有依赖项(实际上只有 jQuery 库)到bower_components/目录中,然后您将能够在稍后的 HTML 前端文件中包含它们。

还有一个有用的方法是运行一个能够提供 HTML 前端文件的 Web 服务器。当您的 WebSocket 应用程序受限于localhost来源时,这一点尤为重要,它将只允许来自localhost域的 JavaScript 的请求(这不包括在浏览器中打开的本地文件)。一个快速简单的方法是使用nginx Docker 镜像。确保从public/目录中运行以下命令:

**$ docker run -d --name chat-web -v $PWD:/var/www -p 80:80 nginx**

之后,您将能够在浏览器中打开http://localhost并查看来自public/目录的静态文件。如果您在该目录中放置一个空的index.html,Nginx 将使用该页面作为索引页面,无需显式请求其路径(这意味着http://localhost将向用户提供文件index.html的内容)。

构建一个简单的聊天应用程序

现在您可以开始实现实际的聊天应用程序。如前面的示例所示,您需要为此实现Ratchet\MessageComponentInterface。首先创建一个Packt\Chp6\Chat\ChatComponent类,并实现接口所需的所有方法:

namespace Packt\Chp6\Chat; 

use Ratchet\MessageComponentInterface; 
use Ratchet\ConnectionInterface; 

class ChatComponent implements MessageComponentInterface 
{ 
    public function onOpen(ConnectionInterface $conn) {} 
    public function onClose(ConnectionInterface $conn) {} 
    public function onMessage(ConnectionInterface $from, $msg) {} 
    public function onError(ConnectionInterface $conn, \Exception $err) {} 
} 

聊天应用程序需要做的第一件事是跟踪连接的用户。为此,您需要维护所有打开连接的集合,在新用户连接时添加新连接,并在用户断开连接时将其移除。为此,在构造函数中初始化SplObjectStorage类的一个实例:

**private $users;** 

public function __construct() 
{ 
 **$this->users = new \SplObjectStorage();** 
} 

然后在onOpen事件中将新连接附加到此存储中,并在onClose事件中将其移除:

public function onOpen(ConnectionInterface $conn) 
{ 
 **echo "user {$conn->remoteAddress} connected.\n";** 
 **$this->users->attach($conn);** 
} 

public function onClose(ConnectionInterface $conn) 
{ 
 **echo "user {$conn->remoteAddress} disconnected.\n";** 
 **$this->users->detach($conn);**} 

现在每个连接的用户都可以向服务器发送消息。对于每条接收到的消息,组件的onMessage方法将被调用。为了实现一个真正的聊天应用程序,每条接收到的消息都需要被传递给其他用户,方便的是,您已经有了一个包含所有连接用户的$this->users集合,可以向他们发送接收到的消息:

public function onMessage(ConnectionInterface $from, $msg) 
{ 
 **echo "received message '$msg' from user {$from->remoteAddress}\n";**
 **foreach($this->users as $user) {** 
 **if ($user != $from) {** 
 **$user->send($msg);** 
 **}** 
 **}**} 

然后在您的server.php文件中注册您的聊天组件:

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0'); 
**$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent);** 
$app->run(); 

重新启动应用程序后,通过在两个单独的终端中使用 wscat 打开两个 WebSocket 连接来测试聊天功能。您在一个连接中发送的每条消息都应该在另一个连接中弹出。

构建一个简单的聊天应用程序

使用两个 wscat 连接测试简陋的聊天应用程序

现在您已经有一个(诚然,仍然很简陋的)聊天服务器在运行,我们可以开始为聊天应用程序构建 HTML 前端。首先,一个静态的 HTML 文件对此来说完全足够了。首先在public/目录中创建一个空的index.html文件:

<!DOCTYPE html>
<html> 
  <head> 
    <title>Chat application</title> 
 **<script src="bower_components/jquery/dist/jquery.min.js"></script>** 
 **<script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script>**
 **<link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css"/>** 
  </head> 
  <body> 
  </body> 
</html> 

在这个文件中,我们已经包含了我们将在本例中使用的前端库;Bootstrap 框架(一个 JavaScript 和一个 CSS 文件)和 jQuery 库(另一个 JavaScript 文件)。

由于你将为这个应用程序编写大量的 JavaScript 代码,因此在 HTML 页面的<head>部分中添加另一个js/app.js文件实例也是很有用的:

<head> 
  <title>Chat application</title> 
  <script src="bower_components/jquery/dist/jquery.min.js"></script> 
  <script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script> 
 **<script src="js/app.js"></script>** 
  <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css"/> 
</head> 

然后,你可以在index.html文件的<body>部分构建一个极简的聊天窗口。你只需要一个用于编写消息的输入字段,一个用于发送消息的按钮,以及一个用于显示其他用户消息的区域:

<div class="container"> 
  <div class="row"> 
    <div class="col-md-12"> 
      <div class="input-group"> 
        <input class="form-control" type="text" id="message"  placeholder="Your message..." /> 
        <span class="input-group-btn"> 
          <button id="submit" class="btn btn-primary">Send</button> 
        </span> 
      </div> 
    </div> 
  </div> 
  <div class="row"> 
    <div id="messages"></div> 
  </div> 
</div> 

HTML 文件中包含一个输入字段(id="message"),用户可以在其中输入新的聊天消息,一个按钮(id="submit")用于提交消息,以及一个(目前还是空的)部分(id="messages"),用于显示从其他用户接收到的消息。以下截图显示了这个页面在浏览器中的显示方式:

构建一个简单的聊天应用

当然,所有这些都不会有任何作用,如果没有适当的 JavaScript 来实际使聊天工作。在 JavaScript 中,你可以使用WebSocket类打开一个 WebSocket 连接。

注意

关于浏览器支持 WebSockets 在所有现代浏览器中都得到支持,而且已经有一段时间了。你可能会遇到需要支持较旧的 Internet Explorer 版本(9 及以下)的问题,这些版本不支持 WebSockets。在这种情况下,你可以使用web-socket-js库,它在内部使用 Flash 作为回退,而 Ratchet 也很好地支持 Flash。

在这个例子中,我们将把所有的 JavaScript 代码放在public/目录下的js/app.js文件中。你可以通过用 WebSocket 服务器的 URL 作为第一个参数来实例化WebSocket类来打开一个新的 WebSocket 连接:

var connection = new WebSocket('ws://localhost:8080/chat'); 

就像服务器端组件一样,客户端 WebSocket 也提供了几个你可以监听的事件。方便的是,这些事件的名称与 Ratchet 使用的方法类似,onopenoncloseonmessage,你都可以(也应该)在自己的代码中实现:

connection.onopen = function() { 
    console.log('connection established'); 
} 

connection.onclose = function() { 
    console.log('connection closed'); 
} 

connection.onmessage = function(event) { 
    console.log('message received: ' + event.data); 
} 

接收消息

每个客户端连接在 Ratchet 服务器应用程序中都会有一个对应的ConnectionInterface实例。当你在服务器上调用连接的send()方法时,这将触发客户端的onmessage事件。

每次收到新消息时,这条消息应该显示在聊天窗口中。为此,你可以实现一个新的 JavaScript 方法appendMessage,它将在之前创建的消息容器中显示新消息:

var appendMessage = function(message, sentByMe) { 
    var text = sentByMe ? 'Sent at' : 'Received at'; 
     var html = $('<div class="msg">' + text + ' <span class="date"></span>: <span 
    class="text"></span></div>'); 

    html.find('.date').text(new Date().toLocaleTimeString()); 
    html.find('.text').text(message); 

    $('#messages').prepend(html); 
} 

在这个例子中,我们使用了一个简单的 jQuery 构造来创建一个新的 HTML 元素,并用当前的日期和时间以及实际接收到的消息文本填充它。请注意,单个消息目前只包含原始消息文本,还不包含任何形式的元数据,比如作者或其他信息。我们稍后会解决这个问题。

提示

在这种情况下,使用 jQuery 创建 HTML 元素已经足够了,但在实际情况下,你可能会考虑使用专门的模板引擎,比如MustacheHandlebars。由于这不是一本 JavaScript 书,我们将在这里坚持基础知识。

当收到消息时,你可以调用appendMessage方法:

connection.onmessage = function(event) { 
    console.log('message received: ' + event.data); 
 **appendMessage(event.data, false);** 
} 

事件的数据属性包含整个接收到的消息作为一个字符串,你可以根据需要使用它。目前,我们的聊天应用程序只能处理纯文本聊天消息;每当你需要传输更多或结构化的数据时,使用 JSON 编码可能是一个不错的选择。

发送消息

要发送消息,你可以(不出所料地)使用连接的send()方法。由于你已经在 HTML 文件中有了相应的用户输入字段,现在只需要更多的 jQuery 就可以让我们的聊天的第一个版本运行起来:

$(document).ready(function() { 
    $('#submit').click(function() { 
        var message = $('#message').val(); 

        if (message) { 
            console.log('sending message: "' + message + '"'); 
            connection.send(message); 

            appendMessage(message, true); 
        } 
    }); 
}); 

一旦 HTML 页面完全加载,我们就开始监听提交按钮的click事件。当按钮被点击时,输入字段中的消息将使用连接的send()方法发送到服务器。每次发送消息时,Ratchet 都会调用服务器端组件的onMessage事件,允许服务器对该消息做出反应并将其分发给其他连接的用户。

通常,用户希望在聊天窗口中看到他们自己发送的消息。这就是为什么我们调用之前实现的appendMessage,它将把发送的消息插入到消息容器中,就好像它是从远程用户接收的一样。

测试应用程序

当两个容器(Web 服务器和 WebSocket 应用程序)都在运行时,您现在可以通过在浏览器中打开 URL http://localhost 来测试您的聊天的第一个版本(最好是在两个不同的窗口中打开页面,这样您实际上可以使用应用程序与自己聊天)。

以下截图显示了测试应用程序时应该获得的结果示例:

测试应用程序

使用两个浏览器窗口测试聊天应用程序的第一个版本

防止连接超时

当您将测试站点保持打开超过几分钟时,您可能会注意到最终 WebSocket 连接将被关闭。这是因为大多数浏览器在一定时间内没有发送或接收消息时(通常为五分钟)会关闭 WebSocket 连接。由于您正在处理长时间运行的连接,您还需要考虑连接问题-如果您的用户之一使用移动连接并在使用您的应用程序时暂时断开连接会怎么样?

最简单的缓解方法是实现一个简单的重新连接机制-每当连接关闭时,等待几秒然后再次尝试。为此,您可以在onclose事件中启动一个超时,在其中打开一个新连接:

connection.onclose = function(event) { 
    console.error(e); 
    setTimeout(function() { 
        connection = new WebSocket('ws://localhost:8080/chat'); 
    }, 5000); 
} 

这样,每当连接关闭时(由于超时、网络连接问题或任何其他原因);应用程序将在五秒的宽限时间后尝试重新建立连接。

如果您希望主动防止断开连接,您还可以定期通过连接发送消息以保持连接活动。这可以通过注册一个间隔函数来完成,该函数定期(在超时时间内的间隔内)向服务器发送消息:

**var interval;** 

connection.onopen = function() { 
    console.log('connection established'); 
 **interval = setInterval(function() {** 
 **connection.send('ping');** 
 **}, 120000);** 
} 

connection.onclose = function() { 
    console.error(e); 
 **clearInterval(interval);** 
    setTimeout(function() { 
        connection = new WebSocket('ws://localhost:8080/chat'); 
    }, 5000); 
} 

这里有一些需要考虑的注意事项:首先,您应该在连接实际建立之后才开始发送保持活动的消息(这就是为什么我们在onopen事件中注册间隔),并且当连接关闭时也应该停止发送保持活动的消息(例如,当网络不可用时仍然可能发生),这就是为什么间隔需要在onclose事件中清除。

此外,您可能不希望保持活动的消息广播到其他连接的客户端;这意味着这些消息在服务器端组件中也需要特殊处理:

public function onMessage(ConnectionInterface $from, $msg) 
{ 
 **if ($msg == 'ping') {** 
 **return;** 
 **}** 

    echo "received message '$msg' from user {$from->remoteAddress}\n"; 
    foreach($this->users as $user) { 
        if ($user != $from) { 
            $user->send($msg); 
        } 
    } 
} 

部署选项

正如您已经注意到的,Ratchet 应用程序不像您典型的 PHP 应用程序那样部署,而是实际上运行自己的 HTTP 服务器,可以直接响应 HTTP 请求。此外,大多数应用程序不仅仅会提供 WebSocket 连接,还需要处理常规的 HTTP 请求。

提示

本节旨在为您概述如何在生产环境中部署 Ratchet 应用程序。在本章的其余部分,为了简单起见,我们将继续使用基于 Docker 的开发设置(不使用负载平衡和花哨的进程管理器)。

这将带来一整套新问题需要解决。其中之一是可伸缩性:默认情况下,PHP 是单线程运行的,因此即使使用libev提供的异步事件循环,您的应用程序也永远无法扩展到多个 CPU。虽然您可以考虑使用pthreads扩展在 PHP 中启用线程(并进入一个全新的痛苦世界),但通常更容易的方法是简单地多次启动 Ratchet 应用程序,让它们侦听不同的端口,并使用 Nginx 等负载均衡器将 HTTP 请求和 WebSocket 连接分发给它们。

对于处理常规(非 WebSocket)HTTP 请求,您仍然可以使用常规的 PHP 进程管理器,如 PHP-FPM 或 Apache 的 PHP 模块。然后,您可以配置 Nginx 将这些常规请求分派给 FPM,将所有 WebSocket 请求分派给您运行的 Ratchet 应用程序之一。

部署选项

使用 Nginx 负载均衡器部署和负载平衡 Ratchet 应用程序

为了实现这一点,您首先需要使应用程序侦听的端口可以为每个运行的进程单独配置。由于应用程序是通过命令行启动的,使端口可配置的最简单方法是使用命令行参数。您可以使用getopt函数轻松解析命令行参数。在此过程中,您还可以使侦听地址可配置。将以下代码插入到您的server.php文件中:

**$options = getopt('l:p:', ['listen:', 'port:']);** 
**$port = $options['port'] ?? $options['p'] ?? 8080;** 
**$addr = $options['listen'] ?? $options['l'] ?? '127.0.0.1';** 

$app = new \Ratchet\App('localhost', $port, $addr); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
$app->run(); 

接下来,您需要确保您的服务器实际上自动启动了足够数量的进程。在 Linux 环境中,Supervisor工具通常是一个不错的选择。在 Ubuntu 或 Debian Linux 系统上,您可以使用以下命令从系统的软件包存储库安装它:

**$ apt-get install supervisor**

然后,在/etc/supervisor/conf.d/中放置一个配置文件,内容如下:

[program:chat] 
numprocs=4 
command=php /path/to/application -port=80%(process_num)02d 
process_name=%(program_name)s-%(process_num)02d 
autostart=true 
autorestart=unexpected 

这将配置 Supervisor 在系统启动时启动四个聊天应用程序的实例。它们将侦听端口80008003,并在它们意外终止时由 Supervisor 自动重新启动-请记住:在 FPM 管理的环境中,PHP 致命错误可能相对无害,但在独立的 PHP 进程中,一个致命错误将使您的整个应用程序对所有用户不可用,直到有人重新启动该进程。因此,最好有一个像 Supervisor 这样的服务,可以自动重新启动崩溃的进程。

接下来,安装一个 Nginx web 服务器,用作四个运行的聊天应用程序的负载均衡器。在 Ubuntu 或 Debian 上,安装 Nginx 如下:

**$ apt-get install nginx**

安装 Nginx 后,在目录/etc/nginx/sites-enabled/中放置一个名为chat.conf的配置文件,内容如下:

upstream chat { 
    server localhost:8000; 
    server localhost:8001; 
    server localhost:8002; 
    server localhost:8003; 
} 
server { 
    listen 80; 
    server_name chat.example.com; 

    location /chat/ { 
        proxy_pass http://chat; 
        proxy_http_version 1.1; 
        proxy_set_header Upgrade $http_upgrade; 
        proxy_set_header Connection "upgrade"; 
    } 

    // Additional PHP-FPM configuration here 
    // ... 
} 

这个配置将配置所有四个应用程序进程作为 Nginx 负载均衡器的上游服务器。所有以/chat/路径开头的 HTTP 请求将被转发到服务器上运行的 Ratchet 应用程序之一。proxy_http_versionproxy_set_header指令是必要的,以便 Nginx 能够正确地在服务器和客户端之间转发 WebSocket 握手。

连接 Ratchet 和 PSR-7 应用程序

迟早,您的聊天应用程序还需要响应常规的 HTTP 请求(例如,一旦您想要添加具有登录表单和身份验证处理的身份验证层,这将变得必要)。

如前一节所述,PHP 中 WebSocket 应用程序的常见设置是让 Ratchet 应用程序处理所有 WebSocket 连接,并将所有常规 HTTP 请求定向到常规的 PHP-FPM 设置。但是,由于 Ratchet 应用程序实际上也包含自己的 HTTP 服务器,因此您也可以直接从 Ratchet 应用程序响应常规 HTTP 请求。

就像您使用Ratchet\MessageComponentInterface来实现 WebSocket 应用程序一样,您可以使用Ratchet\HttpServerInterface来实现常规 HTTP 服务器。例如,考虑以下类:

namespace Packt\Chp6\Http; 

use Guzzle\Http\Message\RequestInterface; 
use Ratchet\ConnectionInterface; 
use Ratchet\HttpServerInterface; 

class HelloWorldServer implements HttpServerInterface 
{ 
    public function onOpen(ConnectionInterface $conn, RequestInterface $request = null) 
    {} 

    public function onClose(ConnectionInterface $conn) 
    {} 

    public function onError(ConnectionInterface $conn, \Exception $e) 
    {} 

    public function onMessage(ConnectionInterface $from, $msg) 
    {} 
} 

如您所见,HttpServerInterface定义的方法与MessageCompomentInterface类似。唯一的区别是现在还将$request参数传递到onOpen方法中。这个类是Guzzle\Http\Message\RequestInterface的一个实例(不幸的是,它不实现 PSR-7 RequestInterface),您可以从中获取基本的 HTTP 请求属性。

现在,您可以使用onOpen方法来对收到的 HTTP 请求发送常规 HTTP 响应:

public function onOpen(ConnectionInterface $conn, RequestInterface $request = null) 
{ 
   $conn->send("HTTP/1.1 200 OK\r\n");
    $conn->send("Content-Type: text/plain\r\n"); 
    $conn->send("Content-Length: 13\r\n"); 
    $conn->send("\r\n"); 
    $conn->send("Hello World\n"); 
    $conn->close(); 
} 

如您所见,您需要在onOpen方法中发送整个 HTTP 响应(包括响应标头!)。这有点繁琐,但稍后我们会找到更好的方法,但目前这样就足够了。

接下来,在server.php中注册您的 HTTP 服务器,方式与注册新的 WebSocket 服务器相同:

$app = new \Ratchet\App('localhost', $port, $addr); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
**$app->route('/hello', new \Packt\Chp6\Http\HelloWorldServer, ['*']);** 
$app->run(); 

特别注意这里的第三个参数['*']:此参数将允许此路由的任何请求来源(不仅仅是localhost),因为大多数浏览器和命令行客户端甚至不会为常规 HTTP 请求发送来源标头。

重新启动应用程序后,您可以使用任何常规 HTTP 客户端(无论是命令行还是浏览器)测试新的 HTTP 路由。如下面的截图所示:

桥接 Ratchet 和 PSR-7 应用程序

使用 cURL 测试 Ratchet HTTP 服务器

手动构建包括标头的 HTTP 响应是一项非常繁琐的任务-特别是如果在某个时刻,您的应用程序包含多个 HTTP 端点。因此,最好有一个框架来为您处理所有这些事情。

在上一章中,您已经使用了Slim框架,您也可以将其与 Ratchet 很好地集成。不幸的是,Ratchet 目前还不符合 PSR-7,因此您需要做一些工作来将 Ratchet 的请求接口转换为 PSR-7 实例,并将 PSR-7 响应返回到ConnectionInterface

首先使用 Composer 将 Slim 框架安装到您的应用程序中:

**$ composer require slim/slim**

本节的其余部分的目标是构建HttpServerInterface的新实现,该实现将 Slim 应用程序作为依赖项,并将所有传入的请求转发到 Slim 应用程序。

首先定义实现HttpServerInterface并接受Slim\App作为依赖项的Packt\Chp6\Http\SlimAdapterServer类:

namespace Packt\Chp6\Http; 

use Guzzle\Http\Message\RequestInterface; 
use Ratchet\ConnectionInterface; 
use Ratchet\HttpServerInterface; 
use Slim\App; 

class SlimAdapterServer implements HttpServerInterface 
{ 
    private $app; 

    public function __construct(App $app) 
    { 
        $this->app = $app; 
    } 

    // onOpen, onClose, onError and onMessage omitted 
    // ... 
} 

您需要做的第一件事是将 Ratchet 传递到onOpen事件的$request参数映射到 PSR-7 请求对象(然后将其传递到 Slim 应用程序进行处理)。Slim 框架提供了其自己的实现:Slim\Http\Request类。首先将以下代码添加到您的onOpen方法中,将请求 URI 映射到Slim\Http\Uri类的实例:

$guzzleUri = $request->getUrl(true); 
$slimUri = new \Slim\Http\Uri( 
    $guzzleUri->getScheme() ?? 'http', 
    $guzzleUri->getHost() ?? 'localhost', 
    $guzzleUri->getPort(), 
    $guzzleUri->getPath(), 
    $guzzleUri->getQuery() . '', 
    $guzzleUri->getFragment(), 
    $guzzleUri->getUsername(), 
    $guzzleUri->getPassword() 
); 

这将在 Slim URI 对象中映射 Guzzle 请求的 URI 对象。它们在很大程度上是兼容的,允许您将大多数属性简单地复制到Slim\Http\Uri类的构造函数中。只有$guzzleUri->getQuery()返回值需要通过与空字符串连接来强制转换为字符串。

继续构建 HTTP 请求标头对象:

$headerValues = []; 
foreach ($request->getHeaders() as $name => $header) { 
    $headerValues[$name] = $header->toArray(); 
} 
$slimHeaders = new \Slim\Http\Headers($headerValues); 

构建请求 URI 和标头后,您可以创建SlimRequest类的实例:

$slimRequest = new \Slim\Http\Request( 
    $request->getMethod(), 
    $slimUri, 
    $slimHeaders, 
    $request->getCookies(), 
    [], 
    new \Slim\Http\Stream($request->getBody()->getStream()); 
); 

然后,您可以使用此请求对象来调用作为依赖项传递给SlimAdapterServer类的 Slim 应用程序:

$slimResponse = new \Slim\Http\Response(200); 
$slimResponse = $this->app->process($slimRequest, $slimResponse); 

$this->app->process()函数实际上会执行 Slim 应用程序。它类似于您在上一章中使用的$app->run()方法,但直接接受 PSR-7 请求对象,并返回一个用于进一步处理的 PSR-7 响应对象。

最后的挑战是现在使用$slimResponse对象,并将其中包含的所有数据返回给客户端。让我们从发送 HTTP 头部开始:

$statusLine = sprintf('HTTP/%s %d %s', 
    $slimResponse->getProtocolVersion(), 
    $slimResponse->getStatusCode(), 
    $slimResponse->getReasonPhrase() 
); 
$headerLines = [$statusLine]; 

foreach ($slimResponse->getHeaders() as $name => $values) { 
    foreach ($values as $value) { 
        $headerLines[] = $headerName . ': ' . $value; 
    } 
} 

$conn->send(implode("\r\n", $headerLines) . "\r\n\r\n"); 

$statusLine包含 HTTP 响应的第一行(通常是HTTP/1.1 200 OKHTTP/1.1 404 Not Found之类的内容)。嵌套的foreach循环用于从 PSR-7 响应对象中收集所有响应头,并将它们连接成一个字符串,该字符串可以用于 HTTP 响应(每个头部都有自己的一行,由回车CR)和换行LF)分隔)。双\r\n最终终止头部,并标记响应主体的开始,接下来您将输出它:

$body = $slimResponse->getBody(); 
$body->rewind(); 

while (!$body->eof()) { 
    $conn->send($body->read(4096)); 
} 
$conn->close(); 

在您的server.php文件中,您现在可以实例化一个新的 Slim 应用程序,将其传递给一个新的SlimAdapterServer类,并在 Ratchet 应用程序中注册此服务器:

**use Slim\App;** 
**use Slim\Http\Request;** 
**use Slim\Http\Response;** 
**$slim = new App();** 
**$slim->get('/hello', function(Request $req, Response $res): Response {** 
 **$res->getBody()->write("Hello World!");** 
 **return $res;** 
**});** 
**$adapter = new \Packt\Chp6\Http\SlimAdapterServer($slim);** 

$app = new \Ratchet\App('localhost', $port, $addr); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
**$app->route('/hello', $adapter, ['*']);** 
$app->run(); 

将 Slim 框架集成到 Ratchet 应用程序中,可以让您使用同一个应用程序为 WebSocket 请求和常规 HTTP 请求提供服务。从一个持续运行的 PHP 进程中提供 HTTP 请求会带来一些有趣的新机会,尽管您必须谨慎使用。您需要担心诸如内存消耗(PHP 确实有垃圾回收器,但如果不注意,仍可能造成内存泄漏,导致 PHP 进程超出内存限制而崩溃),但在有高性能要求时,构建这样的应用可能是一个有趣的选择。

通过 Web 服务器访问您的应用程序

在我们的开发设置中,我们当前运行两个容器,应用程序容器本身监听端口8080,而 Nginx 服务器监听端口80,提供静态文件,如index.html和各种 CSS 和 JavaScript 文件。在生产设置中,通常不建议为静态文件和应用程序本身公开两个不同的端口。

因此,我们现在将配置我们的 Web 服务器容器,以便在存在静态文件时提供静态文件(例如index.html或 CSS 和 JavaScript 文件),并在没有实际文件存在的情况下将 HTTP 请求委托给应用程序容器。为此,首先创建一个 Nginx 配置文件,您可以将其放在项目目录的任何位置,例如etc/nginx.conf

map $http_upgrade $connection_upgrade { 
    default upgrade; 
    '' close; 
} 

server { 
    location / { 
        root /var/www; 
        try_files $uri $uri/index.html @phpsite; 
    } 

    location @phpsite { 
        proxy_http_version 1.1; 
        proxy_set_header X-Real-IP  $remote_addr; 
        proxy_set_header Host $host; 
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 
        proxy_set_header Upgrade $http_upgrade; 
        proxy_set_header Connection $connection_upgrade; 
        proxy_pass http://app:8080; 
    } 
} 

这种配置将导致 Nginx 在/var/www目录中查找文件(当使用 Docker 启动 Nginx Web 服务器时,您可以将本地目录简单地挂载到容器的/var/www目录中)。在那里,它将首先查找直接的文件名匹配,然后查找目录中的index.html,最后将请求传递给上游 HTTP 服务器。

提示

这种配置也适用于部署选项部分中描述的生产设置。当您运行多个应用程序实例时,您将需要在proxy_pass语句中引用一个专用的上游配置,其中包含多个上游应用程序。

创建配置文件后,您可以按以下方式重新创建 Nginx 容器(特别注意docker run命令的--link标志):

**$ docker rm -f chat-web** 
**$ docker run -d --name chat-web **--link chat-app:app** -v $PWD/public:/var/www -p 80:80 nginx**

添加身份验证

目前,我们的应用程序缺少一个至关重要的功能:任何人都可以在聊天中发布消息,也没有办法确定哪个用户发送了哪条消息。因此,在下一步中,我们将为我们的聊天应用程序添加一个身份验证层。为此,我们将需要一个登录表单和某种身份验证处理程序。

在这个例子中,我们将使用典型的基于会话的身份验证。成功验证用户名和密码后,系统将为用户创建一个新的会话,并将(随机且不可猜测的)会话 ID 存储在用户浏览器的 cookie 中。在后续请求中,身份验证层可以使用来自 cookie 的会话 ID 来查找当前经过身份验证的用户。

创建登录表单

让我们开始实现一个简单的用于管理会话的类。这个类将被命名为Packt\Chp6\Authentication\SessionProvider

namespace Packt\Chp6\Authentication; 

class SessionProvider 
{ 
    private $users = []; 

    public function hasSession(string $sessionId): bool 
    { 
        return array_key_exists($sessionId, $this->users); 
    } 

    public function getUserBySession(string $sessionId): string 
    { 
        return $this->users[$sessionId]; 
    } 

    public function registerSession(string $user): string 
    { 
        $id = sha1(random_bytes(64)); 
        $this->users[$id] = $user; 
        return $id; 
    } 
} 

这个会话处理程序非常简单:它只是简单地存储哪个用户(按名称)正在使用哪个会话 ID;可以使用registerSession方法注册新会话。由于所有 HTTP 请求将由同一个 PHP 进程提供,因此您甚至不需要将这些会话持久化到数据库中,而是可以简单地将它们保存在内存中(但是,一旦在负载平衡环境中运行多个进程,您将需要基于数据库的会话存储,因为您不能简单地在不同的 PHP 进程之间共享内存)。

提示

关于真正随机的随机数

为了生成一个密码安全的会话 ID,我们使用了在 PHP 7 中添加的random_bytes函数,现在建议使用这种方式来获取密码安全的随机数据(永远不要使用randmt_rand等函数)。

在接下来的步骤中,我们将在新集成的 Slim 应用程序中实现一些额外的路由:

  1. GET /路由将提供实际的聊天 HTML 网站。直到现在,这是一个静态的 HTML 页面,直接由 Web 服务器提供。使用身份验证,我们将需要在这个网站上进行更多的登录(例如,当用户未登录时将其重定向到登录页面),这就是为什么我们将首页页面移到应用程序中。

  2. GET /login路由将提供一个登录表单,用户可以通过用户名和密码进行身份验证。提供的凭据将提交给...

  3. POST /authenticate路由。这个路由将验证用户提供的凭据,并在用户成功验证后启动一个新的会话(使用之前构建的SessionProvider类)。验证成功后,/authenticate路由将重定向用户回到/路由。

让我们开始在 Ratchet 应用程序中注册这三个路由,并将它们连接到之前创建的 Slim 适配器中的server.php文件中:

$app = new \Ratchet\App('localhost', $port, $addr); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
**$app->route('/', $adapter, ['*']);** 
**$app->route('/login', $adapter, ['*']);** 
**$app->route('/authenticate', $adapter, ['*']);** 
$app->run(); 

继续实现/路由。请记住,这个路由只是简单地提供您之前创建的index.html文件,但前提是存在有效的用户会话。为此,您需要检查 HTTP 请求中是否提供了带有会话 ID 的 HTTP cookie,然后验证是否存在具有此 ID 的有效用户会话。为此,请将以下代码添加到您的server.php中(如果仍然存在,请删除之前创建的GET /hello路由)。如下面的代码所示:

**$provider = new \Packt\Chp6\Authentication\SessionProvider();** 
$slim = new \Slim\App(); 
**$slim->get('/', function(Request $req, Response $res) use ($provider): Response {** 
 **$sessionId = $req->getCookieParams()['session'] ?? '';** 
 **if (!$provider->hasSession($sessionId)) {** 
 **return $res->withRedirect('/login');** 
 **}** 
 **$res->getBody()->write(file_get_contents('templates/index.html'));** 
 **return $res** 
 **->withHeader('Content-Type', 'text/html;charset=utf8');** 
**});**

这个路由为您的用户提供templates/index.html文件。目前,这个文件应该位于您的设置中的public/目录中。在项目文件夹中创建templates/目录,并将index.htmlpublic/目录移动到那里。这样,文件将不再由 Nginx Web 服务器提供,所有对/的请求将直接转发到 Ratchet 应用程序(然后要么提供索引视图,要么将用户重定向到登录页面)。

在下一步中,您可以实现/login路由。这个路由不需要特殊的逻辑:

$slim->get('/login', function(Request $req, Response $res): Response { 
    $res->getBody()->write(file_get_contents('templates/login.html')); 
    return $res 
        ->withHeader('Content-Type', 'text/html;charset=utf8'); 
}); 

当然,要使这个路由实际工作,您需要创建templates/login.html文件。首先创建一个简单的 HTML 文档作为新模板:

<!DOCTYPE html> 
<html lang="en"> 
<head> 
    <meta charset="UTF-8"> 
    <title>Chap application: Login</title> 
    <script src="bower_components/jquery/dist/jquery.min.js"></script> 
    <script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script> 
    <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css"/> 
</head> 
<body> 
</body> 
</html> 

这将加载所有必需的 JavaScript 库和 CSS 文件,以便登录表单正常工作。在<body>部分,您可以添加实际的登录表单:

<div class="row" id="login"> 
    <div class="col-md-4 col-md-offset-4"> 
        <div class="panel panel-default"> 
            <div class="panel-heading">Login</div> 
            <div class="panel-body"> 
                <form action="/authenticate" method="post"> 
                    <div class="form-group"> 
                        <label for="username">Username</label> 
                        <input type="text" name="username" id="username" placeholder="Username" class="form-control"> 
                    </div> 
                    <div class="form-group"> 
                        <label for="password">Password</label> 
                        <input type="password" name="password" id="password" placeholder="Password" class="form-control"> 
                    </div> 
                    <button type="submit" id="do-login" class="btn btn-primary btn-block"> 
                        Log in 
                    </button> 
                </form> 
            </div> 
        </div> 
    </div> 
</div> 

特别注意<form>标签:表单的 action 参数是/authenticate路由;这意味着所有输入到表单中的值将被传递到(尚未编写的)/authenticate路由处理程序中,您将能够验证输入的凭据并创建一个新的用户会话。

保存此模板文件并重新启动应用程序后,您可以通过简单地请求/ URL(无论是在浏览器中还是使用诸如HTTPiecurl之类的命令行工具)来测试新的登录表单。由于您尚未拥有登录会话,因此应立即被重定向到登录表单。如下截图所示:

创建登录表单

未经身份验证的用户现在将被重定向到登录表单

现在唯一缺少的是实际的/authenticate路由。为此,请在您的server.php文件中添加以下代码:

$slim->post('/authenticate', function(Request $req, Response $res) use ($provider): Response { 
    $username = $req->getParsedBodyParam('username'); 
    $password = $req->getParsedBodyParam('password'); 

    if (!$username || !$password) { 
        return $res->withStatus(403); 
    } 

    if (!$username == 'mhelmich' || !$password == 'secret') { 
        return $res->withStatus(403); 
    } 

    $session = $provider->registerSession($username); 
    return $res 
        ->withHeader('Set-Cookie', 'session=' . $session) 
        ->withRedirect('/'); 
}); 

当然,在这个例子中,实际的用户身份验证仍然非常基本-我们只检查一个硬编码的用户/密码组合。在生产设置中,您可以在此处实现任何类型的用户身份验证(通常包括在数据库集合中查找用户并比较提交的密码哈希与用户存储的密码哈希)。

检查授权

现在,唯一剩下的就是扩展聊天应用程序本身,以仅允许经过授权的用户连接。幸运的是,WebSocket 连接开始时作为常规 HTTP 连接(在升级为 WebSocket 连接之前)。这意味着浏览器将在Cookie HTTP 标头中传输所有 cookie,然后您可以在应用程序中访问这些 cookie。

为了将授权问题与实际的聊天业务逻辑分开,我们将在一个特殊的装饰器类中实现所有与授权相关的内容,该类还实现了Ratchet\MessageComponentInterface接口并包装了实际的聊天应用程序。我们将称这个类为Packt\Chp6\Authentication\AuthenticationComponent。首先,通过实现一个接受MessageComponentInterfaceSessionProvider作为依赖项的构造函数来实现这个类:

namespace Packt\Chp6\Authentication; 

use Ratchet\MessageComponentInterface; 
use Ratchet\ConnectionInterface; 

class AuthenticationComponent implements MessageComponentInterface 
{ 
    private $wrapped; 
    private $sessionProvider; 

    public function __construct(MessageComponentInterface $wrapped, SessionProvider $sessionProvider) 
    { 
        $this->wrapped         = $wrapped; 
        $this->sessionProvider = $sessionProvider; 
    } 
} 

接下来,通过实现MessageComponentInterface定义的方法。首先,将所有这些方法实现为简单地委托给$wrapped对象上的相应方法:

public function onOpen(ConnectionInterface $conn) 
{ 
    $this->wrapped->onOpen($conn); 
} 

public function onClose(ConnectionInterface $conn) 
{ 
    $this->wrapped->onClose($conn); 
} 

public function onError(ConnectionInterface $conn, \Exception $e) 
{ 
    $this->wrapped->onError($conn, $e); 
} 

public function onMessage(ConnectionInterface $from, $msg) 
{ 
    $this->wrapped->onMessage($from, $msg); 
} 

现在,您可以向以下新的onOpen方法添加身份验证检查。在这里,您可以检查是否设置了带有会话 ID 的 cookie,使用SessionProvider检查会话 ID 是否有效,并且仅在存在有效会话时接受连接(意思是:委托给包装组件):

public function onOpen(ConnectionInterface $conn) 
{ 
 **$sessionId = $conn->WebSocket->request->getCookie('session');** 
 **if (!$sessionId || !$this->sessionProvider->hasSession($sessionId)) {** 
 **$conn->send('Not authenticated');** 
 **$conn->close();** 
 **return;** 
 **}** 
 **$user = $this->sessionProvider->getUserBySession($sessionId);** 
 **$conn->user = $user;** 

    $this->wrapped->onOpen($conn); 
} 

如果未找到会话 ID 或给定的会话 ID 无效,则连接将立即关闭。否则,会话 ID 将用于从SessionProvider中查找关联的用户,并将其添加为连接对象的新属性。在包装组件中,您可以简单地再次访问$conn->user以获取对当前经过身份验证的用户的引用。

连接用户和消息

现在,您可以断言只有经过身份验证的用户才能在聊天中发送和接收消息。但是,消息本身尚未与任何特定用户关联,因此您仍然不知道实际发送消息的用户是谁。

到目前为止,我们一直使用简单的纯文本消息。由于每条消息现在需要包含比纯文本更多的信息,因此我们将切换到 JSON 编码的消息。每条聊天消息将包含一个从客户端发送到服务器的msg属性,服务器将添加一个填充有当前经过身份验证的用户名的author属性。这可以在您之前构建的ChatComponentonMessage方法中完成,如下所示:

public function onMessage(ConnectionInterface $from, $msg) 
{ 
    if ($msg == 'ping') { 
        return; 
    } 

 **$decoded = json_decode($msg);** 
 **$decoded->author = $from->user;** 
 **$msg = json_encode($decoded);** 

    foreach ($this->users as $user) { 
        if ($user != $from) { 
            $user->send($msg); 
        } 
    } 
} 

在这个例子中,我们首先对从客户端接收的消息进行 JSON 解码。然后,我们将向消息添加一个"author"属性,其中填写了经过身份验证的用户的用户名(请记住,$from->user属性是在您之前构建的AuthenticationComponent中设置的)。然后,将重新编码消息并发送给所有连接的用户。

当然,我们的 JavaScript 前端也必须支持这些新的 JSON 编码消息。首先,要更改app.js JavaScript 文件中的appendMessage函数,以接受结构化对象形式的消息,而不是简单的字符串:

var appendMessage = function(message, sentByMe) { 
    var text = sentByMe ? 'Sent at' : 'Received at'; 
 var html = $('<div class="msg">' + text + ' <span class="date"></span> by <span class="author"></span>: <span class="text"></span></div>'); 

    html.find('.date').text(new Date().toLocaleTimeString()); 
 **html.find('.author').text(message.author);** 
    html.find('.text').text(message.msg); 

    $('#messages').prepend(html); 
}; 

appendMessage函数被 WebSocket 连接的onmessage事件和您的提交按钮监听器所使用。onmessage事件需要修改为首先对传入的消息进行 JSON 解码:

connection.onmessage = function(event) { 
 **var msg = JSON.parse(event.data);** 
    appendMessage(**msg**, false); 
} 

此外,提交按钮监听器需要将 JSON 编码的数据发送到 WebSocket 服务器,并将结构化数据传递到修改后的appendMessage函数中:

$(document).ready(function () { 
    $('#submit').click(function () { 
        var text = $('#message').val(); 
 **var msg = JSON.stringify({** 
 **msg: text** 
 **});** 
        connection.send(msg); 

        appendMessage({ 
 **author: "me",** 
 **message: text** 
        }, true); 
    }) 
}); 

总结

在本章中,您已经了解了 WebSocket 应用程序的基本原则以及如何使用 Ratchet 框架构建它们。与大多数 PHP 应用程序相比,Ratchet 应用程序部署为单个长时间运行的 PHP 进程,不需要像 FPM 或 Web 服务器这样的进程管理器。这需要一种完全不同的部署方式,我们在本章中也进行了研究,无论是用于开发还是用于高规模的生产环境。

除了简单地使用 Ratchet 提供 WebSockets 之外,我们还研究了如何使用 PSR-7 标准将 Ratchet 应用程序与其他框架集成(例如,您在第五章中已经使用过的 Slim 框架,创建 RESTful Web 服务)。

在第七章中,构建异步微服务架构,您将了解另一种通信协议,可以用来集成应用程序。虽然 WebSockets 仍然建立在 HTTP 之上,但下一章将介绍ZeroMQ协议-这与 HTTP 完全不同,并带来了一整套新的挑战需要解决。

第七章:构建异步微服务架构

在本章中,我们将构建一个由一组小型独立组件组成的应用程序,这些组件通过网络协议进行通信。通常,这些所谓的微服务架构是使用基于 HTTP 的通信协议构建的,通常以 RESTful API 的形式,我们已经在第五章中实现了创建 RESTful Web 服务

在本章中,我们将探讨一种关注异步性、松散耦合和高性能的替代通信协议:ZeroMQ,而不是专注于 REST。我们将使用 ZeroMQ 为一个(完全虚构的)电子商务场景构建一个简单的结账服务,该服务将处理广泛的问题,从电子邮件消息、订单处理、库存管理等等。

目标架构

我们的微服务架构的中心服务将是结账服务。该服务将为许多电子商务系统共有的结账流程提供 API。对于每个结账流程,我们将需要以下输入数据:

  • 一个可以包含任意数量的文章的购物车

  • 客户的联系数据

然后,结账服务将负责执行实际的结账流程,其中涉及多个额外的服务,每个服务处理结账流程的单个步骤或关注点:

  1. 我们虚构的电子商务企业将处理实物商品(或更抽象的商品,我们只能有限数量地存货)。因此,对于购物车中的每件商品,结账服务将需要确保所需数量的商品实际上有库存,并且如果可能的话,减少相应数量的可用库存。这将是库存服务的责任。

  2. 成功完成结账流程后,用户需要通过电子邮件收到有关成功结账的通知。这将是邮件服务的责任。

  3. 此外,在完成结账流程后,订单必须转发给一个开始为该订单发货的运输服务。

以下图表显示了本章所需的目标架构的高层视图:

注意

在本章中,重点将放在使用 ZeroMQ 实现不同服务之间的通信模式上。我们不会实现实际的业务逻辑,这需要实际工作(因为您完全可以用另一本书来填补这部分)。相反,我们将实现实际服务作为提供我们希望它们实现的 API 的简单存根,但只包含实际业务逻辑的原型实现。

目标架构

我们应用的目标架构

图中所示接口旁边的标签(RESPUB)是您将在本章中了解的不同 ZeroMQ 套接字类型。

ZeroMQ 模式

在本章中,您将了解 ZeroMQ 支持的基本通信模式。如果这些听起来有点理论化,不要担心;您将在整个章节中自己实现所有这些模式。

请求/回复模式

ZeroMQ 库支持各种不同的通信模式。对于每种模式,您将需要不同的 ZeroMQ 套接字类型。最简单的通信模式是请求/回复模式,其中客户端打开一个 REQ 套接字并连接到监听 REP 套接字的服务器。客户端发送一个请求,然后服务器进行回复。

请求/回复模式

ZeroMQ 请求/回复套接字

重要的是要知道,REQ 和 REP 套接字始终是同步的。每个 REQ 套接字一次只能向单个 REP 套接字发送请求,更重要的是,每个 REP 套接字也只能连接到单个 REQ 套接字。ZeroMQ 库甚至在协议级别强制执行这一点,并在 REQ 套接字尝试在回复当前请求之前接收新请求时触发错误。我们将在以后使用高级通信模式来解决这个限制。

发布/订阅模式

发布/订阅模式由一个 PUB 套接字组成,可以在其上发布消息。可以连接任意数量的 SUB 套接字到此套接字。当在 PUB 套接字上发布新消息时,它将转发到所有连接的 SUB 套接字。

发布/订阅模式

发布/订阅套接字

PUB/SUB 架构中的每个订阅者都需要指定至少一个订阅 - 一个作为每条消息过滤器的字符串。发布者将根据订阅者过滤消息,以便每个订阅者只接收他们订阅的消息。

发布/订阅严格单向工作。发布者无法从订阅者那里接收消息,订阅者也无法将消息发送回发布者。然而,就像多个 SUB 套接字可以连接到单个 PUB 套接字一样,单个 SUB 套接字也可以连接到多个 PUB 套接字。

推/拉模式

推/拉模式与发布/订阅模式类似。PUSH 套接字用于向任意数量的 PULL 套接字发布消息(就像 PUB/SUB 一样,单个 PULL 套接字也可以连接到任意数量的 PUSH 套接字)。然而,与发布/订阅模式相反,发送到 PUSH 套接字的每条消息只会分发到连接的 PULL 套接字中的一个。这种行为使得 PUSH/PULL 模式非常适合实现工作池,例如,您可以使用它来将任务分发给任意数量的工作者以并行处理。同样,PULL 套接字也可以用于从任意数量的 PUSH 套接字收集结果(这可能是从工作池返回的结果)。

使用 PUSH/PULL 套接字将任务分发给工作池,然后使用第二个 PUSH/PULL 层从该池中收集结果到单个套接字中,也称为扇出/扇入

推/拉模式

使用 PUSH 和 PULL 套接字实现扇出/扇入架构

引导项目

像往常一样,我们将从为本章的项目进行引导开始。在 PHP 应用程序中使用 ZeroMQ 库,您将需要通过 PECL 安装的php-zmq 扩展。您还需要包含 ZeroMQ 库的 C 头文件的libzmq-dev软件包。您可以通过操作系统的软件包管理器安装它。以下命令将在 Ubuntu 和 Debian Linux 上都适用:

**$ apt-get install libmzq-dev**
**$ pecl install zmq-beta**

像往常一样,我们将使用 composer 来管理我们的 PHP 依赖项,并使用 Docker 来管理所需的系统库。由于我们的应用程序将由多个在多个进程中运行的服务组成,我们将使用多个 composer 项目和多个 Docker 镜像。

如果您正在使用 Windows,并希望在不使用 Docker 的情况下本地运行 ZeroMQ/PHP 应用程序,您可以从 PECL 网站(pecl.php.net/package/zmq/1.1.3/windows)下载 ZeroMQ 扩展。

我们所有的服务将使用相同的软件(安装了 ZeroMQ 扩展的 PHP)。我们将从实现库存服务开始,但您将能够在本示例中创建的所有服务中使用相同的 Docker 镜像(或至少相同的 Dockerfile)。首先,在项目目录中创建一个inventory/Dockerfile文件,内容如下:

FROM php:7 
RUN apt-get update && apt-get install -y libzmq-dev 
RUN docker-php-ext-configure pcntl && \ 
    docker-php-ext-install pcntl && \ 
    pecl install ev-beta && docker-php-ext-enable ev && \ 
    pecl install zmq-beta && docker-php-ext-enable zmq 
WORKDIR /opt/app 
ONBUILD ADD . /opt/app 
CMD ["/usr/local/bin/php", "server.php"] 

您会注意到我们还安装了pcntlev扩展。您已经在第六章中使用过ev扩展,构建聊天应用程序。它提供了一个与我们稍后在本章中将使用的react/zmq库很好配合的异步事件循环。pcntl扩展提供了一些功能,将帮助您控制后续长时间运行的 PHP 进程的进程状态。

为了使生活更轻松,您还可以在项目目录中创建一个docker-compose.yml文件,以便使用 Docker compose 来管理应用程序中的众多容器。一旦您有了可以在容器中运行的第一个服务,我们将介绍这一点。

构建库存服务

我们将从实现库存服务开始,因为它将使用简单的请求/回复模式进行通信,而且没有其他依赖关系。

开始使用 ZeroMQ REQ/REP 套接字

首先在inventory/目录中创建服务的composer.json文件:

{ 
  "name": "packt-php7/chp7-inventory", 
  "type": "project", 
  "authors": [{ 
    "name": "Martin Helmich", 
    "email": "php7-book@martin-helmich.de" 
  }], 
  "require": { 
    "php": ">= 7.0", 
    "ext-zmq": "*" 
  }, 
  "autoload": { 
    "psr-4": { 
      "Packt\\Chp7\\Inventory": "src/" 
    } 
  } 
} 

创建composer.json文件后,在inventory/目录中使用composer install命令来安装项目的依赖项。

让我们首先为库存创建一个server.php文件。就像第六章中的 Ratchet 应用程序一样,这个文件稍后将成为我们的主服务器进程 - 请记住,在这个例子中,我们甚至没有使用 HTTP 作为通信协议,因此没有 Web 服务器,也没有 FPM 涉及到任何地方。

每个 ZeroMQ 应用程序的起点是上下文。上下文存储了 ZeroMQ 库需要维护套接字和与其他套接字通信所需的各种状态。然后,您可以使用此上下文创建一个新的套接字,并将此上下文绑定到一个端口:

$args = getopt('p:', ['port=']); 
$ctx = new ZMQContext(); 

$port = $args['p'] ?? $args['port'] ?? 5557; 
$addr = 'tcp://*:' . $port; 

$sock = $ctx->getSocket(ZMQ::SOCKET_REP); 
$sock->bind($addr); 

这段代码创建了一个新的 ZeroMQ REP 套接字(可以回复请求的套接字),并将此套接字绑定到可配置的 TCP 端口(默认为 5557)。现在您可以在此套接字上接收消息并回复它们:

while($message = $sock->recv()) { 
    echo "received message '" . $message . "'\n"; 
    $sock->send("this is my response message"); 
} 

正如您所看到的,这个循环将无限期地轮询新消息,然后对其进行响应。套接字的recv()方法将阻塞脚本执行,直到接收到新消息(稍后您可以使用react/zmq库轻松实现非阻塞套接字,但现在这就足够了)。

为了测试您的 ZeroMQ 服务器,您可以在inventory/目录中创建第二个文件client.php,在其中可以使用 REQ 套接字向服务器发送请求:

$args = getopt('h', ['host=']); 
$ctx = new ZMQContext(); 

$addr = $args['h'] ?? $args['host'] ?? 'tcp://127.0.0.1:5557'; 

$sock = $ctx->getSocket(ZMQ::SOCKET_REQ); 
$sock->connect($addr); 

$sock->send("This is my request"); 
var_dump($sock->recv()); 

当您的服务器脚本正在运行时,您可以简单地运行client.php脚本来连接到服务器的 REP 套接字,发送请求,并等待服务器的回复。就像 REP 套接字一样,REQ 套接字的recv方法也会阻塞,直到从服务器接收到回复。

如果您正在使用 Docker compose 来管理开发环境中的众多容器(目前只有一个,但将会有更多),请将以下部分添加到您的docker-compose.yml文件中:

inventory: 
  build: inventory 
  ports: 
    - 5557 
  volumes: 
    - inventory:/usr/src/app 

docker-compose.yml配置文件中添加库存服务后,您可以通过在命令行上运行以下命令来启动容器:

**$ docker-compose up**

使用 JsonRPC 进行通信

现在我们有一个服务器,可以从客户端接收文本消息,然后将响应发送回该客户端。但是,为了构建一个可工作且易于维护的微服务架构,我们需要一种协议和格式,使这些消息可以遵循,并且所有服务都可以达成一致。在微服务架构中,这个共同点通常是 HTTP,其丰富的协议语义可用于轻松构建 REST Web 服务。但是,ZeroMQ 作为一种协议要低级得多,不涉及不同的请求方法、标头、缓存以及 HTTP 所附带的所有其他功能。

我们将库存服务实现为一个简单的远程过程调用RPC)服务,而不是一个 RESTful 服务。一个快速简单的格式是 JSON-RPC,它使用 JSON 消息实现 RPC。使用 JSON-RPC,客户端可以使用以下 JSON 格式发送方法调用:

{ 
  "jsonrpc": "2.0", 
  "method": "methodName", 
  "params": ["foo", "bar", "baz"], 
  "id": "some-random-id" 
} 

服务器随后可以使用以下格式响应此消息:

{ 
  "jsonrpc": "2.0", 
  "id": "id from request", 
  "result": "the result value" 
} 

或者,当处理过程中发生错误时:

{ 
  "jsonrpc": "2.0", 
  "id": "id from request", 
  "error": { 
    "message": "the error message", 
    "code": 1234 
  } 
} 

这个协议相对简单,我们可以很容易地在 ZeroMQ 之上实现它。为此,首先创建一个新的Packt\Chp7\Inventory\JsonRpcServer类。这个服务器将需要一个 ZeroMQ 套接字,还需要一个对象,该对象提供客户端应该能够使用 RPC 调用的方法:

namespace Packt\Chp7\Inventory; 

class JsonRpcServer 
{ 
    private $socket; 
    private $server; 

    public function __construct(\ZMQSocket $socket, $server) 
    { 
        $this->socket = $socket; 
        $this->server = $server; 
    } 
} 

我们现在可以实现一个方法,接收来自套接字的消息,尝试将它们解析为 JSON-RPC 消息,并调用$server对象上的相应方法,并返回该方法的结果值:

public function run() 
{ 
    while ($msg = $this->socket->recv()) { 
        $resp = $this->handleMessage($msg); 
        $this->socket->send($resp); 
    } 
} 

与前面的例子一样,这个方法将无限运行,并处理任意数量的请求。现在,让我们来看看handleMessage方法:

private function handleMessage(string $req): string { 
    $json   = json_decode($req); 
    $method = [$this->server, $json->method]; 

    if (is_callable($method)) { 
        $result = call_user_func_array($method, $json->params ?? []); 
        return json_encode([ 
            'jsonrpc' => '2.0, 
            'id'      => $json->id, 
            'result'  => $result 
        ]); 
    } else { 
        return json_encode([ 
            'jsonrpc' => '2.0', 
            'id'      => $json->id, 
            'error'   => [ 
                'message' => 'uncallable method ' . $json->method, 
                'code'    => -32601 
            ] 
        ]); 
    } 
} 

这个方法检查$this->server对象是否有一个与 JSON-RPC 请求的method属性相同的可调用方法。如果是,将使用请求的param属性作为参数调用此方法,并将返回值合并到 JSON-RPC 响应中。

目前,这个方法仍然缺少一些基本的异常处理。一个未处理的异常,一个致命错误可以终止整个服务器进程,所以我们在这里需要特别小心。首先,我们需要确保传入的消息确实是一个有效的 JSON 字符串:

private function handleMessage(string $req): string { 
    $json   = json_decode($req); 
 **if (json_last_error()) {** 
 **return json_encode([** 
 **'jsonrpc' => '2.0',** 
 **'id'      => null,** 
 **'error'   => [** 
 **'message' => 'invalid json: ' .
json_last_error_msg(),** 
 **'code'    => -32700** 
 **]** 
 **]);** 
 **}** 

    // ... 
} 

还要确保捕获可能从实际服务函数中抛出的任何异常。由于我们使用的是 PHP 7,记住常规的 PHP 错误现在也会被抛出,因此不仅要捕获异常,还要捕获错误。您可以通过在catch子句中使用Throwable接口来捕获异常和错误:

if (is_callable($method)) { 
 **try {** 
        $result = call_user_func_array($method, $json->params ?? []); 
        return json_encode(/* ... */); 
 **} catch (\Throwable $t) {** 
 **return json_encode([** 
 **'jsonrpc' => '2.0',** 
 **'id'      => $json->id,** 
 **'error'   => [** 
 **'message' => $t->getMessage(),** 
 **'code'    => $t->getCode()** 
 **]** 
 **]);** 
 **}** 
} else { // ... 

您现在可以继续实现包含库存服务业务逻辑的实际服务。由于我们到目前为止花了相当多的时间处理低级协议,让我们回顾一下这个服务的要求:库存服务管理库存中的文章。在结账过程中,库存服务需要检查所需文章的数量是否有库存,并在可能的情况下,减少给定数量的库存数量。

我们将在Packt\Chp7\Inventory\InventoryService类中实现这个逻辑。请注意,我们将尝试保持示例简单,并简单地在内存中管理我们的文章库存。在生产环境中,您可能会使用数据库管理系统来存储文章数据:

namespace Packt\Chp7\Inventory\InventoryService; 

class InventoryService 
{ 
    private $stock = [ 
        1000 => 123, 
        1001 => 4, 
        1002 => 12 
    ]; 

    public function checkArticle(int $articleNumber, int $amount = 1): bool 
    { 
        if (!array_key_exists($articleNumber, $this->stock)) { 
            return false; 
        } 
        return $this->stock[$articleNumber] >= $amount; 
    } 

    public function takeArticle(int $articleNumber, int $amount = 1): bool 
    { 
        if (!$this->checkArticle($articleNumber, $amount) { 
            return false; 
        } 

        $this->stock[$articleNumber] -= $amount; 
        return true; 
    } 
} 

在这个例子中,我们从文章编号10001002开始。checkArticle函数测试给定文章的所需数量是否有库存。takeArticle函数尝试减少所需数量的文章数量,如果可能的话。如果成功,函数返回true。如果所需数量不在库存中,或者根本不知道这篇文章,函数将返回false

现在我们有一个实现 JSON-RPC 服务器的类,另一个类包含我们库存服务的实际业务逻辑。我们现在可以将这两个类放在我们的server.php文件中一起使用:

$args = getopt('p:', ['port=']); 
$ctx = new ZMQContext(); 

$port = $args['p'] ?? $args['port'] ?? 5557; 
$addr = 'tcp://*:' . $port; 

$sock = $ctx->getSocket(ZMQ::SOCKET_REP); 
$sock->bind($addr); 

**$service = new \Packt\Chp7\Inventory\InventoryService();** 
**$server = new \Packt\Chp7\Inventory\JsonRpcServer($sock, $service);** 
**$server->run();**

为了测试这个服务,至少在您的结账服务的第一个版本运行起来之前,您可以调整在上一节中创建的client.php脚本,以便发送和接收 JSON-RPC 消息:

// ... 

$msg = [ 
    'jsonrpc' => '2.0', 
    'method'  => 'takeArticle', 
    'params'  => [1001, 2] 
]; 

$sock->send(json_encode($msg)); 
$response = json_decode($sock->recv()); 

if (isset($response->error)) { 
    // handle error... 
} else { 
    $success = $reponse->result; 
    var_dump($success); 
} 

每次调用此脚本都会从库存中删除两件编号为#1001 的物品。在我们的例子中,我们使用的是一个在本地管理的库存,始终初始化为此文章的四件物品,因此client.php脚本的前两次调用将返回 true 作为结果,而所有后续调用将返回 false。

使库存服务多线程化

目前,库存服务在单个线程中运行,并且使用阻塞套接字。这意味着它一次只能处理一个请求;如果在处理其他请求时收到新请求,客户端将不得不等待直到所有先前的请求都完成处理。显然,这不是很好的扩展。

为了实现一个可以并行处理多个请求的服务器,您可以使用 ZeroMQ 的ROUTER/DEALER模式。ROUTER 是一种特殊类型的 ZeroMQ 套接字,行为非常类似于常规的 REP 套接字,唯一的区别是可以并行连接多个 REQ 套接字。同样,DEALER 套接字是另一种类似于 REQ 套接字的套接字,唯一的区别是可以连接到多个 REP 套接字。这使您可以构建一个负载均衡器,它只包括一个 ROUTER 和一个 DEALER 套接字,将多个客户端的数据包传输到多个服务器。

使库存服务多线程化

ROUTER/DEALER 模式

由于 PHP 不支持多线程(至少不是很好),在这个例子中我们将采用多进程。我们的多线程服务器将由一个处理 ROUTER 和 DEALER 套接字的主进程以及多个每个使用一个 REP 套接字的 worker 进程组成。要实现这一点,您可以使用pcntl_fork函数分叉多个 worker 进程。

提示

为了使pcntl_fork函数工作,您需要启用pcntl扩展。在几乎所有的发行版中,这个扩展默认是启用的;在您之前构建的 Dockerfile 中,它也被明确安装了。如果您自己编译 PHP,那么在调用configure脚本时,您将需要--enable-pcntl标志。

在这个例子中,我们的库存服务将由多个 ZeroMQ 套接字组成:首先是大量的 worker 进程,每个进程都监听一个 RES 套接字以响应请求,以及一个主进程,每个 ROUTER 和 DEALER 套接字都接受和分发这些请求。只有 ROUTER 套接字对外部服务可见,并且可以通过 TCP 到达;对于所有其他套接字,我们将使用 UNIX 套接字进行通信 - 它们更快,且无法通过网络到达。

首先实现一个 worker 函数;为此创建一个名为server_multithreaded.php的新文件:

require 'vendor/autoload.php'; 

use Packt\Chp7\Inventory\InventoryService; 
use Packt\Chp7\Inventory\JsonRpcServer; 

function worker() 
{ 
    $ctx = new ZMQContext(); 

    $sock = $ctx->getSocket(ZMQ::SOCKET_REP); 
    $sock->connect('ipc://workers.ipc'); 

    $service = new InventoryService(); 

    $server = new JsonRpcServer($sock, $service); 
    $server->run(); 
} 

worker()函数创建一个新的 REP 套接字,并将此套接字连接到 UNIX 套接字ipc://workers.ipc(这将由主进程稍后创建)。然后运行您之前已经使用过的JsonRpcServer

现在,您可以使用pcntl_fork函数启动任意数量(在本例中为四个)的这些 worker 进程:

for ($i = 0; $i < 4; $i ++) { 
    $pid = pcntl_fork(); 
    if ($pid == 0) { 
        worker($i); 
        exit(); 
    } 
} 

如果您不熟悉fork函数:它会复制当前运行的进程。分叉的进程将继续在分叉时的相同代码位置运行。然而,在父进程中,pcntl_fork()的返回值将返回新创建进程的进程 ID。然而,在新进程中,这个值将是 0。在这种情况下,子进程现在成为我们的 worker 进程,而实际的主进程将在不退出的情况下通过循环。

在此之后,您可以通过创建一个 ROUTER 和一个 DEALER 套接字来启动实际的负载均衡器:

$args = getopt('p:', ['port=']); 
$ctx = new ZMQContext(); 

$port = $args['p'] ?? $args['port'] ?? 5557; 
$addr = 'tcp://*:' . $port; 

$ctx = new ZMQContext(); 

//  Socket to talk to clients 
$clients = $ctx->getSocket(ZMQ::SOCKET_ROUTER); 
$clients->bind($addr); 

//  Socket to talk to workers 
$workers = $ctx->getSocket(ZMQ::SOCKET_DEALER); 
$workers->bind("ipc://workers.ipc"); 

ROUTER 套接字绑定到服务预期可到达的实际网络地址(在本例中,允许通过网络到达服务的 TCP 套接字)。另一方面,DEALER 套接字绑定到一个本地 UNIX 套接字,不会暴露给外部世界。UNIX 套接字ipc://workers.ipc的唯一目的是工作进程可以将其 REP 套接字连接到它。

创建了 ROUTER 和 DEALER 套接字后,您可以使用ZMQDevice类将来自 ROUTER 套接字的传入数据包传输到 DEALER 套接字,然后平均分配到所有连接的 REP 套接字。从 REP 套接字发送回来的响应数据包也将被分发回原始客户端:

//  Connect work threads to client threads via a queue 
$device = new ZMQDevice($clients, $workers); 
$device->run(); 

以这种方式更改库存服务不需要修改客户端代码;负载均衡器正在监听的 ROUTER 套接字行为非常类似于 REP 套接字,并且任何 REQ 套接字都可以以完全相同的方式连接到它。

构建结账服务

现在我们有一个管理您小型虚构电子商务企业库存的服务。接下来,我们将实现实际结账服务的第一个版本。结账服务将提供一个用于完成结账流程的 API,使用由多个文章和基本客户联系数据组成的购物车。

使用 react/zmq

为此,结账服务将提供一个简单的 REP ZeroMQ 套接字(或在并发设置中的 ROUTER 套接字)。在接收结账订单后,结账服务将与库存服务通信,以检查所需物品是否可用,并通过购物车中的物品数量减少库存数量。如果成功,它将在 PUB 套接字上发布结账订单,其他服务可以监听。

如果购物车包含多个物品,结账服务将需要多次调用库存服务。在本例中,您将学习如何并行进行多个请求以加快执行速度。我们还将使用react/zmq库,该库为 ZeroMQ 库提供了异步接口,以及react/promise库,它将帮助您更好地处理异步应用程序。

首先在新的checkout/目录中创建一个新的composer.json文件,并使用composer install初始化项目:

{ 
 **"name": "packt-php7/chp7-checkout",** 
  "type": "project", 
  "authors": [{ 
    "name": "Martin Helmich", 
    "email": "php7-book@martin-helmich.de" 
  }], 
  "require": { 
    "php": ">= 7.0", 
 **"react/zmq": "⁰.3.0",** 
 **"react/promise": "².2",** 
    "ext-zmq": "*", 
 **"ext-ev": "*"** 
  }, 
  "autoload": { 
    "psr-4": { 
 **"Packt\\Chp7\\Checkout": "src/"** 
    } 
  } 

这个文件类似于库存服务的composer.json;唯一的区别是 PSR-4 命名空间和额外的要求react/zmqreact/promiseext-ev。如果您正在使用 Docker 进行开发设置,您可以直接从库存服务中复制现有的 Dockerfile。

继续在您的checkout/目录中创建一个server.json文件。与任何 React 应用程序一样(记得第六章中的 Ratchet 应用程序,构建聊天应用程序),您需要做的第一件事是创建一个事件循环,然后运行它:

<?php 
use \React\ZMQ\Factory; 
use \React\ZMQ\Context; 

require 'vendor/autoload.php'; 

$loop = Factory::create(); 
$ctx  = new Context($loop); 

$loop->run(); 

请注意,我们现在使用React\ZMQ\Context类而不是ZMQContext类。React 上下文类提供相同的接口,但通过一些功能扩展了其基类,以更好地支持异步编程。

您现在可以启动此程序,它将无限运行,但目前还不会执行任何操作。由于结账服务应该提供一个 REP 套接字,客户端应该发送请求到该套接字,因此在运行事件循环之前,您应该继续创建并绑定一个新的 REP 套接字:

// ... 
$ctx = new Context($loop); 

**$socket = $ctx->getSocket(ZMQ::SOCKET_REP);** 
**$socket->bind('tcp://0.0.0.0:5557');** 

$loop->run(); 

ReactPHP应用程序是异步的;现在,您可以在套接字上注册事件处理程序,而不是只调用recv()等待下一个传入消息,ReactPHP 的事件循环将在收到消息时立即调用它:

// ... 

$socket = $ctx->getSocket(ZMQ::SOCKET_REP); 
$socket->bind('tcp://0.0.0.0:5557'); 
**$socket->on('message', function(string $msg) use ($socket) {** 
 **echo "received message $msg.\n";** 
 **$socket->send('Response text');** 
**});** 

$loop->run(); 

这种回调解决方案类似于您在开发客户端 JavaScript 代码时最常遇到的其他异步库。基本原则是相同的:$socket->on(...)方法只是注册一个事件监听器,可以在以后的任何时间点调用,每当收到新消息时。代码的执行将立即继续(与此相反,比较常规的$socket->recv()函数会阻塞,直到收到新消息),然后调用$loop->run()方法。这个调用启动了实际的事件循环,负责在收到新消息时调用注册的事件监听器。事件循环将一直阻塞,直到被中断(例如,通过命令行上的Ctrl + C触发的 SIGINT 信号)。

使用承诺

在处理异步代码时,通常只是时间的问题,直到您发现自己陷入了“回调地狱”。想象一下,您想发送两个连续的 ZeroMQ 请求(例如,首先询问库存服务给定的文章是否可用,然后实际上指示库存服务减少所需数量的库存)。您可以使用多个套接字和您之前看到的“消息”事件来实现这一点。然而,这很快就会变成一个难以维护的混乱:

$socket->on('message', function(string $msg) use ($socket, $ctx) { 
    $check = $ctx->getSocket(ZMQ::SOCKET_REQ); 
    $check->connect('tcp://identity:5557'); 
    $check->send(/* checkArticle JSON-RPC here */); 
    $check->on('message', function(string $msg) use ($socket, $ctx) { 
        $take = $ctx->getSocket(ZMQ::SOCKET_REQ); 
        $take->connect('tcp://identity:5557'); 
        $take->send(/* takeArticle JSON-RPC here */); 
        $take->on('message', function(string $msg) use ($socket) { 
            $socket->send('success'); 
        }); 
    }); 
}); 

上述代码片段只是说明了这可能变得多么复杂的一个例子;在我们的情况下,您甚至需要考虑每个结账订单可能包含任意数量的文章,每篇文章都需要两个新请求到身份服务。

为了让生活更美好,您可以使用承诺来实现这个功能(有关该概念的详细解释,请参见下面的框)。react/promise库提供了良好的承诺实现,应该已经在您的composer.json文件中声明。

注意

什么是承诺? 承诺(有时也称为未来)是异步库中常见的概念。它们提供了一种替代常规基于回调的方法。

基本上,承诺是一个代表尚未可用的值的对象(例如,因为应该检索该值的 ZeroMQ 请求尚未收到回复)。在异步应用程序中,承诺可能随时变得可用(实现)。然后,您可以注册应该在承诺实现时调用的函数,以进一步处理承诺的已解析值:$promise = $someService->someFunction(); $promise->then(function($promisedValue) { echo "Promise resolved: $promisedValue\n"; });

then()函数的每次调用都会返回一个新的承诺,这次是由传递给then()的回调返回的值。这使您可以轻松地将多个承诺链接在一起:

$promise ->then(function($value) use ($someService) { $newPromise = $someService->someOtherFunc($value); return $newPromise; }) ->then(function ($newValue) { echo "Promise resolved: $newValue\n"; });

现在,我们可以通过编写一个用于与我们的库存服务通信的异步客户端类来利用这个原则。由于该服务使用 JSON-RPC 进行通信,我们现在将实现Packt\Chp7\Checkout\JsonRpcClient类。该类使用 ZeroMQ 上下文进行初始化,并且为了方便起见,还包括远程服务的 URL:

namespace Packt\Chp7\Checkout; 

use React\Promise\PromiseInterface; 
use React\ZMQ\Context; 

class JsonRpcClient 
{ 
    private $context; 
    private $url; 

    public function __construct(Context $context, string $url) 
    { 
        $this->context = $context; 
        $this->url     = $url; 
    } 

    public function request(string $method, array $params = []): PromiseInterface 
    { 
    } 
} 

在这个例子中,该类已经包含一个request方法,该方法接受一个方法名和一组参数,并应返回React\Promise\PromiseInterface的实现。

request()方法中,您现在可以打开一个新的 REQ 套接字并向其发送一个 JSON-RPC 请求:

public function request(string $method, array $params = []): PromiseInterface 
{ 
 **$body = json_encode([** 
 **'jsonrpc' => '2.0',** 
 **'method'  => $method,** 
 **'params'  => $params,** 
 **]);** 
 **$sock = $this->context->getSocket(\ZMQ::SOCKET_REQ);** 
 **$sock->connect($this->url);** 
 **$sock->send($body);** 
} 

由于request()方法应该是异步工作的,您不能简单地调用recv()方法并阻塞,直到收到结果。相反,我们需要返回一个对响应值的承诺,以便稍后可以解决,每当在 REQ 套接字上收到响应消息时。为此,您可以使用React\Promise\Deferred类:

$body = json_encode([ 
    'jsonrpc' => '2.0', 
    'method'  => $method, 
    'params'  => $params, 
]); 
**$deferred = new Deferred();** 

$sock = $this->context->getSocket(\ZMQ::SOCKET_REQ); 
$sock->connect($this->url); 
**$sock->on('message', function(string $response) use ($deferred) {** 
 **$deferred->resolve($response);** 
**});** 
$sock->send($body); 

**return $deferred->promise();**

这是承诺如何工作的一个典型例子:您可以使用Deferred类来创建并返回一个尚未可用的值的承诺。记住:传递给$sock->on(...)方法的函数不会立即被调用,而是在任何以后的时间点,当实际收到响应时。一旦发生这种事件,由请求函数返回的承诺将以实际的响应值解决。

由于响应消息包含 JSON-RPC 响应,您需要在满足对请求函数的调用者所做的承诺之前评估这个响应。由于 JSON-RPC 响应也可能包含错误,值得注意的是,您也可以拒绝一个承诺(例如,在等待响应时发生错误时):

$sock->on('message', function(string $response) use ($deferred) { 
 **$response = json_decode($response);** 
 **if (isset($response->result)) {** 
 **$deferred->resolve($response->result);** 
 **} elseif (isset($response->error)) {** 
 **$deferred->reject(new \Exception(** 
 **$response->error->message,** 
 **$response->error->code** 
 **);** 
 **} else {** 
 **$deferred->reject(new \Exception('invalid response'));** 
 **}** 
}); 

现在,您可以在您的server.php中使用这个 JSON-RPC 客户端类,以便在每个传入的结账请求上实际与库存服务进行通信。让我们从一个简单的例子开始,演示如何使用新类将两个连续的 JSON-RPC 调用链接在一起:

$client = new JsonRpcClient($ctx, 'tcp://inventory:5557'); 
$client->request('checkArticle', [1000]) 
    ->then(function(bool $ok) use ($client) { 
        if ($ok) { 
            return $client->request('takeArticle', [1000]); 
        } else { 
            throw new \Exception("Article is not available"); 
        } 
    }) 
    ->then(function(bool $ok) { 
        if ($ok) { 
            echo "Successfully took 1 item of article 1000"; 
        } 
    }, function(\Exception $error) { 
        echo "An error occurred: ${error->getMessage()}\n"; 
    }); 

正如您所看到的,PromiseInterfacethen函数接受两个参数(每个都是一个新函数):第一个函数将在承诺以实际值解决时被调用;第二个函数将在承诺被拒绝时被调用。

如果传递给then(...)的函数返回一个新值,那么 then 函数将返回一个新的承诺。这个规则的一个例外是当回调函数本身返回一个新的承诺(在我们的情况下,在then()回调中再次调用了$client->request)。在这种情况下,返回的承诺将替换原始承诺。这意味着对then()函数的链接调用实际上是在第二个承诺上监听。

让我们在server.php文件中使用这个。与前面的例子相比,您需要考虑每个结账订单可能包含多个文章。这意味着您需要对库存服务执行多个checkArticle请求:

**$client = new JsonRpcClient($ctx, 'tcp://inventory:5557');** 
$socket->on('message', function(string $msg) use ($socket, $client) { 
 **$request = json_decode($msg);** 
 **$promises = [];** 
 **foreach ($request->cart as $article) {** 
 **$promises[] = $client->request('checkArticle', [$article->articlenumber, $article->amount]);** 
    } 
}); 

在这个例子中,我们假设传入的结账订单是 JSON 编码的消息,看起来像下面的例子:

{ 
  "cart": [ 
    "articlenumber": 1000, 
    "amount": 2 
  ] 
} 

在我们的server.php的当前版本中,我们多次调用 JSON-RPC 客户端,并将返回的承诺收集到一个数组中。然而,我们实际上还没有对它们做任何事情。现在,您可以对这些承诺中的每一个调用then()函数,其中包含一个回调,该回调将对每个文章进行调用,并传递一个布尔参数,指示这篇文章是否可用。然而,为了正确处理订单,我们需要知道结账订单中的所有文章是否都可用。所以你需要做的不是等待每个承诺单独完成,而是等待它们全部完成。这就是React\Promise\all函数的作用:这个函数以承诺列表作为参数,并返回一个新的承诺,一旦所有提供的承诺都被实现,它就会被实现:

$request = json_decode($msg); 
$promises = []; 

foreach ($request->cart as $article) { 
    $promises[] = $client->request('checkArticle', [$article->articlenumber, $article->amount]); 
} 

**React\Promise\all($promises)->then(function(array $values) use ($socket) {** 
 **if (array_sum($values) == count($values)) {** 
 **echo "all required articles are available";** 
 **} else {** 
 **$socket->send(json_encode([** 
 **'error' => 'not all required articles are available'** 
 **]);** 
 **}**
**});**

如果库存服务中没有所有所需的文章,您可以提前用错误消息回答请求,因为没有必要继续下去。如果所有文章都可用,您将需要一系列后续请求来实际减少指定数量的库存。

提示

在这个例子中使用的array_sum($values) == count($values)构造是一个快速的解决方法,用来确保布尔值数组只包含 true 值。

接下来,您现在可以扩展您的服务器,以在所有checkArticle方法调用成功返回后运行第二组请求到库存服务。这可以通过使用React\Promise\all方法按照之前的方式完成:

React\Promise\all($promises)->then(function(array $values) use ($socket, $request) { 
 **$promises = [];** 
 **if (array_sum($values) == count($values)) {** 
 **foreach ($request->cart as $article) {** 
 **$promises[] = $client->request('takeArticle', [$article->articlenumber, $article->amount]);** 
 **}** 
 **React\Promise\all($promises)->then(function() use ($socket) {** 
 **$socket->send(json_encode([** 
 **'result' => true** 
 **]);** 
 **}** 
    } else { 
        $socket->send(json_encode([ 
            'error' => 'not all required articles are available' 
        ]); 
    } 
}); 

为了实际测试这个新的服务器,让我们编写一个简短的测试脚本,尝试执行一个示例结账订单。为此,在您的checkout/目录中创建一个新的client.php文件:

$ctx  = new ZMQContext(); 
$sock = $ctx->getSocket(ZMQ::SOCKET_REQ); 
$sock->connect('tcp://checkout:5557'); 
$sock->send(json_encode([ 
    'cart' => [ 
        ['articlenumber' => 1000, 'amount' => 3], 
        ['articlenumber' => 1001, 'amount' => 2] 
    ] 
])); 

$result = $sock->recv(); 
var_dump($result); 

要运行结账服务和测试脚本,可以在项目的根目录中使用新的结账服务扩展您的docker-compose.yml文件:

**checkout:** 
 **build: checkout** 
 **volumes:** 
 **- checkout:/usr/src/app** 
 **links:** 
 **- inventory:inventory** 
inventory: 
  build: inventory 
  ports: 
    - 5557 
  volumes: 
    - inventory:/usr/src/app 

对于测试脚本,添加第二个 Compose 配置文件docker-compose.testing.yml

test: 
  build: checkout 
  command: php client.php 
  volumes: 
    - checkout:/usr/src/app 
  links: 
    - checkout:checkout 

之后,您可以使用以下命令行命令测试您的结账服务:

**$ docker-compose up -d 
$ docker-compose -f docker-compose.testing.yml run --rm test**

以下屏幕截图显示了测试脚本和两个服务器脚本的示例输出(在此示例中,添加了一些额外的echo语句,使服务器更加详细):

使用承诺工作

结账和库存服务处理结账订单的示例输出

构建邮寄服务

接下来,我们将在我们的微服务架构中加入一个邮寄服务。在处理结账后,用户应该通过电子邮件收到有关结账状态的通知。

提示

如前所述,本章的重点是构建个别服务之间的通信模式。因此,在本节中,我们不会实现邮寄服务的实际邮寄功能,而是专注于该服务如何与其他服务通信。查看第三章构建社交通讯服务,了解如何使用 PHP 实际向其他收件人发送电子邮件。

理论上,您可以像实现库存服务一样实现邮寄服务-构建一个独立的 PHP 程序,监听 ZeroMQ REP 套接字,让结账服务打开一个 REQ 套接字,并向邮寄服务发送请求。但是,也可以使用发布/订阅模式来实现相同的功能。

使用发布/订阅模式,结账服务甚至不需要知道邮寄服务。相反,结账服务只需打开其他服务可以连接到的 PUB 套接字。在 PUB 套接字上发送的任何消息都会分发到所有连接的(订阅)服务。这允许您实现一个非常松散耦合的架构,也非常可扩展-您可以通过让更多和不同的服务订阅相同的 PUB 套接字来为您的结账流程添加新功能,而无需修改结账服务本身。

这是可能的,因为在邮寄服务的情况下,通信不需要是同步的-结账服务在继续流程之前不需要等待邮寄服务完成其操作,也不需要来自邮寄服务的任何数据。相反,消息可以严格单向流动-从结账服务到邮寄服务。

首先,您需要在结账服务中打开 PUB 套接字。为此,请修改结账服务的server.php,创建一个新的 PUB 套接字,并将其绑定到 TCP 地址:

$socket = $ctx->getSocket(ZMQ::SOCKET_REP); 
$socket->bind('tcp://0.0.0.0:5557'); 

**$pubSocket = $ctx->getSocket(ZMQ::SOCKET_PUB);**
**$pubSocket->bind('tcp://0.0.0.0:5558');** 

$client = new JsonRpcClient($ctx, 'tcp://inventory:5557'); 

成功从库存服务中取得所需物品后,您可以在此套接字上发布消息。在这种情况下,我们将简单地在 PUB 套接字上重新发送原始消息:

$socket->on('message', function(string $msg) use ($client, $pubSocket) { 
    // ... 
    React\Promise\all($promises)->then(function(array $values) use ($socket, $pubSocket, $request) { 
        $promises = []; 
        if (array_sum($values) == count($values)) { 
            // ... 
            React\Promise\all($promises)->then(function() use ($socket, $pubSocket, $request) { 
 **$pubSocket->send($request);** 
            $socket->send(json_encode([ 
                'result' => true 
            ]); 
        } else { 
            $socket->send(json_encode([ 
                'error' => 'not all required articles are available' 
            ]); 
        } 
    }); 
}); 

$loop->run(); 

现在,您已经在接受的结账订单上发布了一个 PUB 套接字,可以编写实际的邮寄服务,创建一个订阅此 PUB 套接字的 SUB 套接字。

为此,在项目目录中创建一个名为mailing/的新目录。从先前的示例中复制 Dockerfile,并创建一个新的composer.json文件,内容如下:

{ 
 **"name": "packt-php7/chp7-mailing",** 
    "type": "project", 
    "authors": [{ 
        "name": "Martin Helmich", 
        "email": "php7-book@martin-helmich.de" 
    }], 
    "require": { 
        "php": ">= 7.0", 
        "react/zmq": "⁰.3.0" 
    }, 
    "autoload": { 
        "psr-4": { 
 **"Packt\\Chp7\\Mailing": "src/"** 
        } 
    } 
} 

与以前的示例相比,唯一的区别是新的包名称和不同的 PSR-4 自动加载命名空间。此外,您不需要react/promise库来进行邮寄服务。像往常一样,在mailing/目录中的命令行上运行composer install来下载所需的依赖项。

您现在可以在mailing/目录中创建一个新的server.php文件,其中创建一个新的 SUB 套接字,然后可以连接到结帐服务:

require 'vendor/autoload.php'; 

$loop = \React\EventLoop\Factory::create(); 
$ctx  = new \React\ZMQ\Context($loop); 

$socket = $ctx->getSocket(ZMQ::SOCKET_SUB); 
$socket->subscribe(''); 
$socket->connect('tcp://checkout:5558'); 

$loop->run(); 

注意$socket->subscribe()调用。每个 SUB 套接字可以订阅给定的主题频道。频道由一个字符串前缀标识,可以作为每个发布的消息的一部分提交。然后客户端只会接收与他们订阅的频道匹配的消息。如果您不关心一个 PUB 套接字上的不同频道,您可以通过调用$socket->subscribe并传递一个空字符串来订阅空频道,从而接收在 PUB 套接字上发布的所有消息。但是,如果您不调用 subscribe 方法,您将根本不会收到任何消息。

套接字连接后,您可以为'message'事件提供一个监听函数,在其中解码 JSON 编码的消息并相应地处理它:

$socket->connect('tcp://checkout:5558'); 
**$socket->on('message', function(string $msg) {** 
 **$data = json_decode($msg);** 
 **if (isset($data->customer->email)) {** 
 **$email = $data->customer->email;** 
 **echo "sending confirmation email to $email.\n";** 
 **}** 
**});** 

$loop->run(); 

还要注意,PUB 和 SUB 套接字是严格单向的:您可以从 PUB 套接字向任意数量的订阅的 SUB 套接字发送消息,但您不能在同一个套接字上回复给发布者-至少不能。如果您真的需要某种反馈渠道,您可以让发布者在一个单独的 REP 或 SUB 套接字上监听,订阅者使用新的 REQ 或 PUB 套接字连接。以下图表说明了实现这样的反馈渠道的两种策略:

构建邮寄服务

在发布/订阅架构中实现反馈通道的不同策略

要测试新的邮寄服务,您可以重用上一节中的client.php脚本。由于邮寄服务要求结帐订单包含电子邮件地址,您需要将其添加到消息正文中:

$sock->send(json_encode([ 
    'cart' => [ 
        ['articlenumber' => 1000, 'amount' => 3], 
        ['articlenumber' => 1001, 'amount' => 2] 
    ], 
 **'customer' => [** 
 **'email' => 'john.doe@example.com'** 
    ] 
])); 

还要记得将新的邮寄服务添加到docker-compose.yml文件中:

# ... 
checkout: 
  build: checkout 
  volumes: 
    - checkout:/usr/src/app 
  links: 
    - inventory:inventory 
**mailing:** 
 **build: mailing** 
 **volumes:** 
 **- mailing:/usr/src/app** 
 **links:** 
 **- checkout:checkout** 
inventory: 
  build: inventory 
  ports: 
    - 5557 
  volumes: 
    - inventory:/usr/src/app 

docker-compose.yml中添加新服务后,启动所有服务并再次运行测试脚本:

**$ docker-compose up -d inventory checkout mailing**
**$ docker-compose run --rm test**

之后,检查单独的容器的输出,以检查结帐订单是否被正确处理:

**$ docker-compose logs**

构建邮寄服务

在我们的小型电子商务示例中,我们还缺少邮寄服务。在现实世界的场景中,这将是一个非常复杂的任务,您通常需要与外部方进行通信,也许需要与外部运输服务提供商的 API 集成。因此,我们现在将使用 PUSH 和 PULL 套接字以及任意数量的工作进程构建我们的邮寄服务作为工作池。

初学者的 PUSH/PULL

PUB 套接字将每条消息发布到所有连接的订阅者。ZeroMQ 还提供了 PUSH 和 PULL 套接字类型-它们的工作方式类似于 PUB/SUB,但在 PUSH 套接字上发布的每条消息只发送到潜在的多个连接的 PULL 套接字中的一个。您可以使用这个来实现一个工作池,将长时间运行的任务推送到其中,然后并行执行。

为此,我们需要一个使用 SUB 套接字订阅已完成结帐订单的主进程。同一进程需要提供一个 PUSH 套接字,以便各个工作进程可以连接到它。以下图表说明了这种架构:

初学者的 PUSH/PULL

PUB/SUB 和 PUSH/PULL 的组合

像往常一样,首先在项目文件夹中创建一个新的shipping/目录。从以前的服务中复制 Dockerfile,创建一个新的composer.json文件,并使用composer install初始化项目:

{ 
 **"name": "packt-php7/chp7-shipping",** 
    "type": "project", 
    "authors": [{ 
        "name": "Martin Helmich", 
        "email": "php7-book@martin-helmich.de" 
    }], 
    "require": { 
        "php": ">= 7.0.0", 
        "react/zmq": "⁰.3.0" 
    }, 
    "autoload": { 
        "psr-4": { 
 **"Packt\\Chp7\\Shipping": "src/"** 
        } 
    } 
} 

我们将从实现主进程开始。这个主进程需要做三件简单的事情:

  • 打开一个 SUB 套接字,并将此套接字连接到结帐服务的 PUB 套接字。这将允许运输服务接收结帐服务接受的所有结帐订单。

  • 打开一个 PUSH 套接字,并将此套接字绑定到一个新的 TCP 端口。这将允许工作进程连接并接收结帐订单。

  • 将在 SUB 套接字上接收的每条消息转发到 PUSH 套接字。

为此,在您的shipping/目录中创建一个新的master.php文件,您可以在其中创建一个新的事件循环并创建两个所需的套接字:

require 'vendor/autoload.php'; 

$loop = React\EventLoop\Factory::create(); 
$ctx  = new React\ZMQ\Context($loop); 

$subSocket = $ctx->getSocket(ZMQ::SOCKET_SUB); 
$subSocket->subscribe(''); 
$subSocket->connect('tcp://checkout:5558'); 

$pushSocket = $ctx->getSocket(ZMQ::SOCKET_PUSH); 
$pushSocket->bind('tcp://0.0.0.0:5557'); 

$loop->run(); 

为了实际处理在 SUB 套接字上接收的消息,注册一个监听器函数在$subSocket变量上,将每个接收到的消息发送到 PUSH 套接字:

$pushSocket->bind('tcp://0.0.0.0:5557'); 

**$subSocket->on('message', function(string $msg) use ($pushSocket) {** 
 **echo 'dispatching message to worker';** 
 **$pushSocket->send($msg);** 
**});** 

$loop->run(); 

接下来,在shipping/目录中创建一个名为worker.php的新文件。在这个文件中,您将创建一个 PULL 套接字,用于接收主进程中打开的 PUSH 套接字上的消息:

require 'vendor/autoload.php'; 

$loop = React\EventLoop\Factory::create(); 
$ctx  = new React\ZMQ\Context($loop); 

$pullSocket = $ctx->getSocket(ZMQ::SOCKET_PULL); 
$pullSocket->connect('tcp://shippingmaster:5557'); 

$loop->run(); 

再次,附加一个监听器函数到$pullSocket,以处理传入的消息:

$pullSocket->connect('tcp://shippingmaster:5557'); 
**$pullSocket->on('message', function(string $msg) {** 
 **echo "processing checkout order for shipping: $msg\n";** 
 **sleep(5);** 
**});** 

$loop->run(); 

sleep(5),在这个例子中,只是模拟执行可能需要更长时间的运输订单。与本章中一样,我们不会实现实际的业务逻辑,只需要演示各个服务之间的通信模式。

为了测试运输服务,现在将主进程和工作进程添加到您的docker-compose.yml文件中:

# ... 

inventory: 
  build: inventory 
  volumes: 
    - inventory:/usr/src/app 

**shippingmaster:** 
 **build: shipping** 
 **command: php master.php** 
 **volumes:** 
 **- shipping:/usr/src/app** 
 **links:** 
 **- checkout:checkout** 
**shippingworker:** 
 **build: shipping** 
 **command: php worker.php** 
 **volumes:** 
 **- shipping:/usr/src/app** 
 **links:** 
 **- shippingmaster:shippingmaster**

之后,您可以启动所有容器,然后使用以下命令跟踪它们的输出:

**$ docker-compose up -d**

默认情况下,Docker compose 将始终启动每个服务的一个实例。但是,您可以使用docker-compose scale命令启动每个服务的附加实例。这对于shippingworker服务来说是一个好主意,因为我们为该服务选择的 PUSH/PULL 架构实际上允许任意数量的该服务实例并行运行:

**$ docker-compose scale shippingworker=4**

在启动了一些更多的shippingworker服务实例之后,您可以使用docker-compose logs命令附加到所有容器的日志输出。然后,使用第二个终端启动您在上一节中创建的客户端测试脚本:

**$ docker-compose run --rm test**

当您多次运行此命令时,您将看到在后续调用的容器的不同实例中打印的运输工作进程中的调试输出。您可以在以下截图中看到一个示例输出:

初学者的 PUSH/PULL

演示具有多个工作进程的工作推/拉架构的示例输出

扇出/扇入

除了将耗时的任务分配给多个工作进程外,您还可以使用 PUSH 和 PULL 套接字让工作进程将结果推送回主进程。这种模式称为扇出/扇入。对于本例,让master.php文件中的主进程监听一个单独的 PULL 套接字:

$pushSocket = $ctx->getSocket(ZMQ::SOCKET_PUSH); 
$pushSocket->bind('tcp://0.0.0.0:5557'); 

**$pullSocket = $ctx->getSocket(ZMQ::SOCKET_PULL);** 
**$pullSocket->bind('tcp://0.0.0.0:5558');** 
**$pullSocket->on('message', function(string $msg) {** 
 **echo "order $msg successfully processed for shipping\n";** 
**});** 

$subSocket->on('message', function(string $msg) use ($pushSocket) { 
    // ... 
}); 

$loop->run(); 

worker.php文件中,您现在可以使用新的 PUSH 套接字连接到此 PULL 套接字,并在成功处理结帐订单时发送消息:

**$pushSocket = $ctx->getSocket(ZMQ::SOCKET_PUSH);**
**$pushSocket->connect('tcp://shippingmaster:5558');** 

$pullSocket = $ctx->getSocket(ZMQ::SOCKET_PULL); 
$pullSocket->connect('tcp://shippingmaster:5557'); 
$pullSocket->on('message', function(string $msg) use ($pushSocket) { 
    echo "processing checkout order for shipping: $msg\n"; 
    sleep(5); 
 **$pushSocket->send($msg);** 
}); 

$loop->run(); 

一旦处理完消息,这将立即将消息推送回主进程。请注意,PUSH/PULL 的使用方式与上一节中的方式相反-之前我们有一个 PUSH 套接字和多个 PULL 套接字;对于扇入,我们在主进程上有一个 PULL 套接字,而在工作进程上有多个 PUSH 套接字。

提示

使用 bind()和 connect()

在本节中,我们已经使用了 PUSH 和 PULL 套接字的bind()connect()方法。通常,bind()用于让套接字监听新的 TCP 端口(或 UNIX 套接字),而connect()用于让套接字连接到另一个已经存在的套接字。通常情况下,您可以在任何套接字类型上使用bind()connect()。在某些情况下,比如 REQ/REP,您会直觉地bind()REP 套接字,然后connect()REQ 套接字,但 PUSH/PULL 和 PUB/SUB 实际上都可以双向工作。您可以让 PULL 套接字连接到监听的 PUSH 套接字,但也可以让 PUSH 套接字连接到监听的 PULL 套接字。

以下截图显示了运输服务的主进程和工作进程并行处理多个结账订单的示例输出。请注意,实际处理是由不同的工作进程(在此示例中为shippingworker_1shippingworker_3)完成的,但之后被"扇入"回主进程:

扇出/扇入

扇出/扇入的实际操作

连接 ZeroMQ 和 HTTP

正如您在本章中所看到的,ZeroMQ 提供了许多不同的可能性,用于在不同的服务之间实现通信。特别是,发布/订阅和推/拉等模式在 PHP 的事实标准协议 HTTP 中并不容易实现。

另一方面,HTTP 更广泛地被采用,并提供了更丰富的协议语义集,处理诸如缓存或身份验证等问题已经在协议级别上。因此,特别是在提供外部 API 时,您可能更喜欢提供基于 HTTP 而不是基于 ZeroMQ 的 API。幸运的是,在两种协议之间进行桥接很容易。在我们的示例架构中,结账服务是唯一会被外部服务使用的服务。为了为结账服务提供更好的接口,我们现在将实现一个基于 HTTP 的结账服务包装器,可以以 RESTful 方式使用。

为此,您可以使用react/http包。该包提供了一个极简的 HTTP 服务器,就像react/zmq一样,它是异步工作的,并使用事件循环来处理请求。这意味着基于 react 的 HTTP 服务器甚至可以在同一个进程中,使用与结账服务已经提供的 REP ZeroMQ 套接字相同的事件循环来运行。首先,在项目目录中的checkout/文件夹中运行以下命令来安装react/http包:

**$ composer require react/http**

在扩展结账服务以使用 HTTP 服务器之前,server.php脚本需要进行一些重构。当前,server.php创建了一个带有事件监听函数的 REP ZeroMQ 套接字,其中处理请求。由于我们的目标现在是添加一个触发相同功能的 HTTP API,我们需要将此逻辑提取到一个单独的类中。首先创建Packt\Chp7\Checkout\CheckoutService类:

namespace Packt\Chp7\Checkout; 

use React\Promise\PromiseInterface; 

class CheckoutService 
{ 
    private $client; 

    public function __construct(JsonRpcClient $client) 
    { 
        $this->client = $client; 
    } 

    public function handleCheckoutOrder(string $msg): PromiseInterface 
    { 
    } 
} 

handleCheckoutOrder方法将保存之前直接在server.php文件中实现的逻辑。由于此方法稍后将被 ZeroMQ REP 套接字和 HTTP 服务器同时使用,因此此方法不能直接发送响应消息,而只能返回一个 promise,然后可以在server.php中使用:

public function handleCheckoutOrder(string $msg): PromiseInterface 
{ 
    $request = json_decode($msg); 
    $promises = []; 

    foreach ($request->cart as $article) { 
        $promises[] = $this->client->request('checkArticle', [$article->articlenumber, $article->amount]); 
    } 

    return \React\Promise\all($promises) 
        ->then(function(array $values):bool { 
            if (array_sum($values) != count($values)) { 
                throw new \Exception('not all articles are in stock'); 
            } 
            return true; 
        })->then(function() use ($request):PromiseInterface { 
            $promises = []; 

            foreach ($request->cart as $article) { 
                $promises[] = $this->client->request('takeArticle', [$article->articlenumber, $article->amount]); 
            } 

            return \React\Promise\all($promises); 
        })->then(function(array $values):bool { 
            if (array_sum($values) != count($values)) { 
                throw new \Exception('not all articles are in stock'); 
            } 
            return true; 
        }); 
} 

一致使用 promise 并不关心返回消息实际上允许一些简化;而不是直接发送错误消息,您可以简单地抛出异常,这将导致此函数返回的promise被自动拒绝。

现有的server.php文件现在可以简化为几行代码:

$client          = new JsonRpcClient($ctx, 'tcp://inventory:5557'); 
**$checkoutService = new CheckoutService($client);** 

$socket->on('message', function($msg) use ($ctx, $checkoutService, $pubSocket, $socket) { 
    echo "received checkout order $msg\n"; 

 **$checkoutService->handleCheckoutOrder($msg)->then(function() use ($pubSocket, $msg, $socket) {** 
 **$pubSocket->send($msg);** 
 **$socket->send(json_encode(['msg' => 'OK']));** 
 **}, function(\Exception $err) use ($socket) {** 
 **$socket->send(json_encode(['error' => $err->getMessage()]));** 
 **});** 
}); 

接下来,您可以开始处理 HTTP 服务器。为此,您首先需要一个简单的套接字服务器,然后将其传递到实际的 HTTP 服务器类中。这可以在运行事件循环之前的server.php中的任何时间点完成:

**$httpSocket = new \React\Socket\Server($loop);** 
**$httpSocket->listen(8080, '0.0.0.0');** 
**$httpServer = new \React\Http\Server($httpSocket);** 

$loop->run(); 

HTTP 服务器本身有一个'request'事件,您可以为其注册一个监听函数(类似于 ZeroMQ 套接字的'message'事件)。监听函数作为参数传递了一个请求和一个响应对象。这些都是React\Http\RequestReact\Http\Response类的实例:

$httpServer->on('request', function(React\Http\Request $req, React\Http\Response $res) { 
    $res->writeHead(200); 
    $res->end('Hello World'); 
}); 

不幸的是,React HTTP 的RequestResponse类与相应的 PSR-7 接口不兼容。但是,如果有需要,您可以相对容易地将它们转换,就像在第六章构建聊天应用程序中已经看到的那样,桥接 Ratchet 和 PSR-7 应用程序部分中。

在此监听函数中,您可以首先检查正确的请求方法和路径,并发送错误代码,否则:

$httpServer->on('request', function(React\Http\Request $req, React\Http\Response $res) { 
 **if ($request->getPath() != '/orders') {** 
 **$msg = json_encode(['msg' => 'this resource does not exist']);** 
 **$response->writeHead(404, [** 
 **'Content-Type' => 'application/json;charset=utf8',** 
 **'Content-Length' => strlen($msg)** 
 **]);** 
 **$response->end($msg);** 
 **return;** 
 **}** 
 **if ($request->getMethod() != 'POST') {** 
 **$msg = json_encode(['msg' => 'this method is not allowed']);** 
 **$response->writeHead(405, [** 
 **'Content-Type' => 'application/json;charset=utf8',** 
 **'Content-Length' => strlen($msg)** 
 **]);** 
 **$response->end($msg);** 
 **return;** 
 **}** 
}); 

这就是问题所在。ReactPHP HTTP 服务器是如此异步,以至于当触发request事件时,请求正文尚未从网络套接字中读取。要获取实际的请求正文,您需要监听请求的data事件。但是,请求正文以 4096 字节的块读取,因此对于大型请求正文,数据事件实际上可能会被多次调用。读取完整的请求正文的最简单方法是检查Content-Length标头,并在数据事件处理程序中检查是否已经读取了确切数量的字节:

$httpServer->on('request', function(React\Http\Request $req, React\Http\Response $res) { 
    // error checking omitted... 

 **$length = $req->getHeaders()['Content-Length'];** 
 **$body   = '';** 
 **$request->on('data', function(string $chunk) use (&$body) {** 
 **$body .= $chunk;** 
 **if (strlen($body) == $length) {** 
 **// body is complete!** 
 **}** 
 **});** 
}); 

当发送方在其请求中使用所谓的分块传输编码时,这是行不通的。但是,使用分块传输读取请求正文的工作方式类似;在这种情况下,退出条件不依赖于Content-Length标头,而是在读取第一个空块时。

在完整的请求正文被读取后,您可以将此正文传递给之前已经使用过的$checkoutService

$httpServer->on('request', function(React\Http\Request $req, React\Http\Response $res) use ($pubSocket, $checkoutService) { 
    // error checking omitted... 

    $length = $req->getHeaders()['Content-Length']; 
    $body   = ''; 

    $request->on('data', function(string $chunk) use (&$body, $pubSocket, $checkoutService) { 
        $body .= $chunk; 
        if (strlen($body) == $length) { 
 **$checkoutService->handleCheckoutOrder($body)** 
 **->then(function() use ($response, $body, $pubSocket) {**
 **$pubSocket->send($body);** 
 **$msg = json_encode(['msg' => 'OK']);** 
 **$response->writeHead(200, [** 
 **'Content-Type' => 'application/json',** 
 **'Content-Length' => strlen($msg)** 
 **]);** 
 **$response->end($msg);** 
 **}, function(\Exception $err) use ($response) {** 
 **$msg = json_encode(['msg' => $err->getMessage()]);** 
 **$response->writeHead(500, [** 
 **'Content-Type' => 'application/json',** 
 **'Content-Length' => strlen($msg)** 
 **]);** 
 **$response->end($msg);** 
 **});** 
        } 
    }); 
}); 

CheckoutService类的使用方式与以前完全相同。现在唯一的区别是如何将响应发送回客户端;如果原始请求是由 ZeroMQ REP 套接字接收的,则将相应的响应发送到发送请求的 REQ 套接字。现在,如果请求是由 HTTP 服务器接收的,则会发送具有相同内容的 HTTP 响应。

您可以使用 curl 或 HTTPie 等命令行工具测试新的 HTTP API:

**$ http -v localhost:8080/orders
cart:='[{"articlenumber":1000,"amount":3}]' customer:='{"email":"john.doe@example.com"}'**

以下截图显示了使用前面的 HTTPie 命令测试新 API 端点时的示例输出:

桥接 ZeroMQ 和 HTTP

测试新的 HTTP API

总结

在本章中,您已经了解了 ZeroMQ 作为一种新的通信协议以及如何在 PHP 中使用它。与 HTTP 相比,ZeroMQ 支持比简单的请求/响应模式更复杂的通信模式。特别是发布/订阅和推送/拉取模式,它们允许您构建松散耦合的架构,可以轻松扩展新功能并且可以很好地扩展。

您还学会了如何使用 ReactPHP 框架构建使用事件循环的异步服务,以及如何使用承诺使异步性可管理。我们还讨论了如何将基于 ZeroMQ 的应用程序与常规HTTP API 集成。

虽然以前的章节都集中在不同的网络通信模式上(第五章中的 RESTful HTTP,创建 RESTful Web 服务,第六章中的 WebSockets,构建聊天应用程序,以及现在的 ZeroMQ),我们将在接下来的章节中重新开始,并学习如何使用 PHP 构建用于自定义表达式语言的解析器。

第八章:为自定义语言构建解析器和解释器

可扩展性和适应性通常是企业应用程序中所需的功能。通常,动态更改应用程序的行为和业务规则是有用的、实际的,甚至是用户的实际功能要求。例如,想象一下,一个电子商务应用程序中,销售代表可以自行配置业务规则;例如,当系统应该为购买提供免费运输或者在满足某些特殊条件时应用一定的折扣(当购买金额超过 150 欧元时提供免费运输,并且客户已经在过去进行了两次或更多次购买,或者已经是客户超过一年)。

根据经验,这些规则往往变得非常复杂(当客户是男性且年龄超过 35 岁并且有两个孩子和一只名叫 Whiskers 先生的猫,并且在一个没有云的满月之夜下放置购买订单时提供折扣),并且可能经常发生变化。因此,作为开发人员,您可能会很高兴为用户提供配置此类规则的可能性,而不是每次这些规则之一发生变化时都必须更新、测试和重新部署应用程序。这样的功能称为最终用户开发,通常使用特定领域语言来实现。

特定领域语言是针对特定应用领域定制的语言(与通用语言相对,如 C、Java 或者您猜对了 PHP)。在本章中,我们将为企业应用程序中的业务规则构建自己的小型表达式语言的解析器。

为此,我们需要回顾解析器的工作原理以及如何使用形式语法描述形式语言。

解释器和编译器的工作原理

解释器和编译器读取用编程语言编写的程序。它们可以直接执行它们(解释器),或者首先将它们转换为机器语言或另一种编程语言(编译器)。解释器和编译器通常都有(除其他外)称为词法分析器解析器的两个组件。

解释器和编译器的工作原理

这是编译器或解释器的基本架构

解释器可能省略代码生成,并直接运行解析后的程序,而无需专门的编译步骤。

词法分析器(也称为扫描器标记器)将输入程序分解为可能的最小部分,即所谓的标记。每个标记由标记类(例如,数值或变量标识符)和实际的标记内容组成。例如,给定输入字符串2 + (3 * a)的计算器的词法分析器可能生成以下标记列表(每个标记都有一个标记类和值):

  1. 数字(“2”)

  2. 加法运算符(“+”)

  3. 开放括号(“”)

  4. 数字(“3”)

  5. 乘法运算符(“*”)

  6. 变量标识符(“a”)

  7. 闭合括号(“”)

在下一步中,解析器获取令牌流并尝试从该流中推导出实际的程序结构。为此,解析器需要使用一组描述输入语言的规则,即语法。在许多情况下,解析器生成表示结构化树中的输入程序的数据结构;所谓的语法树。例如,输入字符串2 + (3 * a)生成以下语法树:

解释器和编译器的工作原理

可以从表达式 2 +(3 * a)生成的抽象语法树(AST)

请注意,有些程序将通过词法分析,但在下一步中,它们被解析器识别为语法错误。例如,名为2 + ( 1的输入字符串将通过词法分析(并生成诸如{Number(2), Addition Operator, Opening bracket, Number(1)}的标记列表),但显然在语法上是错误的,因为没有匹配的闭括号(假设解析器使用了普遍认可的数学表达式语法;在其他语法中,2+(1实际上可能是一个语法上有效的表达式)

语言和语法

为了使解析器能够理解程序,它需要该语言的正式描述-语法。在本章中,我们将使用所谓的解析表达式语法PEG)。PEG(相对)容易定义,并且有一些库可以自动生成给定语法的解析器。

语法由终结符号非终结符号组成。非终结符号是一个可能由几个其他符号组成的符号,遵循某些规则(产生规则)。例如,语法可以包含一个数字作为非终结符号。每个数字可以被定义为任意长度的数字序列。然后,一个数字可以是 0 到 9 中的任何字符(实际数字中的每个数字都是终结符号)。

让我们试着正式描述数字的结构(然后在此基础上构建数学表达式)。让我们从描述数字的外观开始。每个数字由一个或多个数字组成,所以让我们从描述数字和数字开始:

Digit: '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9' 
Number: Digit+ 

在这个例子中,Digit 是我们的第一个非终结符号。我们语法的第一个规则规定 0 到 9 中的任何字符都是数字。在这个例子中,字符'0'到'9'是终结符号,是最小的可能的构建块。

提示

实际上,许多解析器生成器允许您使用正则表达式来匹配终结符号。在前面的例子中,您可以简单地陈述这一点,而不是列举所有可能的数字:Digit: /[0-9]/

我们语法的第二条规则规定Number(我们的第二个非终结符号)由一个或多个Digit符号组成(+表示重复一次或多次)。以同样的方式,我们也可以扩展语法以支持小数:

Digit: '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9' 
**Integer: Digit+Decimal: Digit* '.' Digit+Number: Decimal | Integer**

在这里,我们引入了两个新的非终结符号:IntegerDecimalInteger只是一个数字序列,而Decimal可以以任意数量的数字开头(或者根本不开头,这意味着像.12这样的值也是一个有效的数字),然后是一个点,然后是一个或多个数字。与上面已经使用的+运算符(“重复一次或更多次”)不同,*运算符表示“没有或一次或多次”。Number的产生规则现在说明一个数字可以是一个小数或一个整数。

提示

顺序在这里很重要;给定输入字符串3.14,整数规则将匹配此输入字符串的3,而小数规则将匹配整个字符串。因此,在这种情况下,最好首先尝试将数字解析为小数,当失败时再将数字解析为整数。

目前,这个语法只描述了正数。但是,它可以很容易地修改为支持负数。

Digit: '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9' 
Integer: '-'? Digit+ 
Decimal: '-'? Digit* '.' Digit+ 
Number: Decimal | Integer 

在这个例子中使用的?字符表示一个符号是可选的。这意味着整数和小数现在可以选择以-字符开头。

我们现在可以继续为我们的语法定义更多规则。例如,我们可以添加一个描述乘法的新规则:

Product: Number ('*' Number)* 

由于除法基本上是与乘法相同的操作(并且具有相同的运算符优先级),我们可以使用相同的规则处理这两种情况:

Product: Number (('*'|'/') Number)* 

一旦您在语法中添加了求和的规则,就重要考虑操作的顺序(先乘法,然后加法)。让我们定义一个名为Sum的新规则(再次使用一个规则来涵盖加法和减法):

Sum: Product (('+'|'-') Product)* 

这乍一看可能有些反直觉。毕竟,一个总和实际上不需要由两个乘积组成。但是,由于我们的Product规则使用*作为量词,它也将匹配单个数字,从而允许诸如5 + 4之类的表达式被解析为Product + Product

为了使我们的语法完整,我们仍然需要能够解析嵌套语句。目前,我们的语法可以解析诸如2 * 32 + 3之类的语句。甚至2 + 3 * 4也将被正确解析为2 + (3 * 4)(而不是(2 + 3) * 4)。但是,像(2 + 3) * 4这样的语句不匹配我们语法的任何规则。毕竟,Product规则规定了一个乘积是由*字符连接的任意数量的Number;由于括号括起来的求和不匹配Number规则,因此Product规则也不会匹配。为了解决这个问题,我们将引入两个新规则:

Expr: Sum 
Value: Number | '(' Expr ')' 

有了新的Value规则,我们可以调整Product规则以匹配常规数字或括号括起来的例外:

Product: Value ('*' Value)* 

在这里,您将找到描述数学表达式所需的完整语法。它目前还不支持任何类型的变量或逻辑语句,但它将是我们在本章剩余部分中构建的自己的解析器的合理起点:

Expr: Sum 
Sum: Product (('+' | '-') Product)* 
Product: Value (('*' | '/') Value)* 
Value: Number | '(' Expr ')' 
Number: (Decimal | Integer) 
Decimal: '-'? Digit* '.' Digit+ 
Integer: '-'? Digit+ 
Digit: '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9' 

您的第一个 PEG 解析器

从头开始构建标记生成器和解析器是一项非常繁琐的任务。幸运的是,存在许多库可以根据某种形式的形式语法定义自动生成解析器。

在 PHP 中,您可以使用hafriedlander/php-peg库根据解析表达式语法生成任何形式语言的解析器的 PHP 代码。为此,创建一个新的项目目录,并创建一个包含以下内容的新composer.json文件:

{ 
  "name": "packt-php7/chp8-calculator", 
  "authors": [{ 
    "name": "Martin Helmich", 
    "email": "php7-book@martin-helmich.de" 
  }], 

  "require": { 
    "hafriedlander/php-peg": "dev-master" 
  }, 
  "autoload": { 
    "psr-4": { 
      "Packt\\Chp8\\DSL": "src/" 
    }, 
    "files": [ 
      "vendor/hafriedlander/php-peg/autoloader.php" 
    ] 
  } 
} 

请注意,hafriedlander/php-peg库不使用 PSR-0 或 PSR-4 自动加载程序,而是使用自己的类加载程序。因此,您不能使用 composer 内置的 PSR-0/4 类加载程序,需要手动包含该软件包的自动加载程序。

与之前的章节类似,我们将使用Packt\Chp8\DSL作为基本命名空间,用于基于src/目录的 PSR-4 类加载器。这意味着名为Packt\Chp8\DSL\Foo\Bar的 PHP 类应该位于src/Foo/Bar.php文件中。

在使用 PHP PEG 时,您将解析器编写为一个包含特殊类型注释中的语法的常规 PHP 类。此类用作实际解析器生成器的输入文件,然后生成实际的解析器源代码。解析器输入文件的文件类型通常为.peg.inc。解析器类必须扩展hafriedlander\Peg\Parser\Basic类。

我们的解析器将使用Packt\Chp8\DSL\Parser\Parser类名。它将存储在src/Parser/Parser.peg.inc文件中:

namespace Packt\Chp8\DSL\Parser; 

use hafriedlander\Peg\Parser\Basic; 

class Parser extends Basic 
{ 
    /*!* ExpressionLanguage 

    <Insert grammar here> 

    */ 
} 

注意

注意类中以/*!*字符开头的注释。这个特殊的注释块将被解析器生成器捕获,并且需要包含解析器将被生成的语法。

然后,您可以使用 PHP-PEG CLI 脚本构建实际的解析器(它将存储在文件src/Parser/Parser.php中,并且可以被 composer 类加载器捕获):

**$ php -d pcre.jit=0 vendor/hafriedlander/php-peg/cli.php 
    src/Parser/Parser.peg.inc > src/Parser/Parser.php**

提示

需要使用-d pcre.jit=0标志来修复与 PHP 7 相关的 PEG 包中的错误。禁用pcre.jit标志可能会影响程序的性能;但是只有在生成解析器时才能禁用此标志。生成的解析器不会受到pcre.jit标志的影响。

目前,解析器生成将因为解析器类尚未包含有效的语法而失败。这很容易改变;在你的解析器输入文件的特殊注释(以/*!*开头)中添加以下行:

/*!* ExpressionLanguage 

**Digit: /[0-9]/** 
**Integer: '-'? Digit+** 
**Decimal: '-'? Digit* '.' Digit+** 
**Number: Decimal | Integer** 

*/ 

你会注意到这正是我们在前一节中用来匹配数字的示例语法。这意味着重新构建解析器后,你将拥有一个知道数字长什么样并能够识别它们的解析器。诚然,这还不够。但我们可以继续完善。

通过运行cli.php脚本重新构建你的解析器,然后在项目目录中创建一个名为test.php的测试脚本:

require_once 'vendor/autoload.php'; 

use \Packt\Chp8\DSL\Parser\Parser; 

$result1 = new (Parser('-143.25'))->match_Number(); 
$result2 = new (Parser('I am not a number'))->match_Number(); 

var_dump($result1); 
var_dump($result2); 

记住,Packt\Chp8\DSL\Parser\Parser类是从你的Parser.peg.inc输入文件自动生成的。该类继承了hafriedlander\Peg\Parser\Basic类,后者也提供了构造函数。构造函数接受一个表达式,解析器应该解析这个表达式。

对于你的语法中定义的每个非终结符号,解析器将包含一个名为match_[符号名称]()的函数(例如,match_Number),该函数将根据给定的规则匹配输入字符串。

在我们的示例中,$result1是与有效数字(或者一般来说,由解析器语法匹配的输入字符串)匹配的结果,而$result2的输入字符串显然不是一个数字,不应该被语法匹配。让我们来看看这个测试脚本的输出:

array(3) { 
  '_matchrule' => 
  string(6) "Number" 
  'name' => 
  string(6) "Number" 
  'text' => 
  string(7) "-143.25" 
} 
bool(false) 

正如你所看到的,解析第一个输入字符串返回一个包含匹配规则和被规则匹配的字符串的数组。如果规则没有匹配(例如在$result2中),match_*函数将始终返回false

让我们继续添加我们在前一节中已经看到的规则的其余部分。这将使我们的解析器不仅能够解析数字,还能够解析整个数学表达式:

/*!* ExpressionLanguage 

Digit: /[0-9]/ 
Integer: '-'? Digit+ 
Decimal: '-'? Digit* '.' Digit+ 
Number: Decimal | Integer 
**Value: Number | '(' > Expr > ')'** 
**Product: Value (> ('*'|'/') > Value)*** 
**Sum: Product (> ('+'|'-') > Product)*** 
**Expr: Sum** 

*/ 

在这个代码示例中,特别注意>字符。这些是由解析器生成器提供的特殊符号,可以匹配任意长度的空白序列。在一些语法中,空格可能很重要,但在解析数学表达式时,通常不在乎某人输入2+3还是2 + 3

重新构建你的解析器,并调整你的测试脚本来测试这些新规则:

var_dump((new Parser('-143.25'))->match_Expr()); 
var_dump((new Parser('12 + 3'))->match_Expr()); 
var_dump((new Parser('1 + 2 * 3'))->match_Expr()); 
var_dump((new Parser('(1 + 2) * 3'))->match_Expr()); 
var_dump((new Parser('(1 + 2)) * 3'))->match_Expr()); 

特别注意最后一行。显然,(1 + 2)) * 3表达式在语法上是错误的,因为它包含的右括号比左括号多。然而,对于这个输入语句,match_Expr函数的输出将是以下内容:

array(3) { 
  '_matchrule' => 
  string(4) "Expr" 
  'name' => 
  string(4) "Expr" 
  'text' => 
  string(7) "(1 + 2)" 
} 

正如你所看到的,输入字符串仍然匹配了Expr规则,只是没有匹配整个字符串。字符串的第一部分,(1 + 2),在语法上是正确的,并且符合Expr规则。这在使用 PEG 解析器时非常重要。如果一个规则不能匹配整个输入字符串,解析器仍然会尽可能匹配输入的尽可能多的部分。这取决于你作为解析器的用户来确定部分匹配是好事还是坏事(在我们的情况下,这可能会触发错误,因为部分匹配的表达式会导致非常奇怪的结果,无疑会让用户感到非常惊讶)。

评估表达式

到目前为止,我们只使用我们自己构建的 PEG 解析器来检查输入字符串是否符合给定的语法(也就是说,我们可以告诉输入字符串是否包含有效的数学表达式)。下一个逻辑步骤是实际评估这些表达式(例如,确定'(1 + 2) * 3'的值为'9')。

正如您已经看到的,每个match_*函数都返回一个包含有关匹配字符串的附加信息的数组。在解析器内,您可以注册自定义函数,当匹配给定符号时将调用这些函数。让我们从简单的事情开始,尝试将由我们的语法匹配的数字转换为实际的 PHP 整数或浮点值。为此,请首先修改您的语法中的IntegerDecimal规则如下:

Integer: **value:('-'? Digit+)** 
 **function value(array &$result, array $sub) {** 
 **$result['value'] = (int) $sub['text'];** 
 **}** 

Double: **value:('-'? Digit* '.' Digit+)**
 **function value(array &$result, array $sub) {** 
 **$result['value'] = (float) $sub['text'];** 
 **}**

让我们看看这里发生了什么。在每个规则中,您可以为规则内的子模式指定名称。例如,在Integer规则中的模式Digit+被分配了名为value的名称。一旦解析器找到与此模式匹配的字符串,它将调用在Integer规则下方提供的同名函数。该函数将被调用并传入两个参数:&$result参数将是稍后由实际的match_Number函数返回的数组。正如您所看到的,该参数被作为引用传递,您可以在值函数内对其进行修改。$sub参数包含子模式的结果数组(无论如何,它都包含一个text属性,您可以从中访问匹配的子模式的实际文本内容)。

在这种情况下,我们简单地使用 PHP 的内置函数将文本中的数字转换为实际的intfloat变量。然而,这仅仅是因为我们的自定义语法和 PHP 巧合地以相同的方式表示数字,从而使我们能够使用 PHP 解释器将这些值转换为实际的数值。

如果您在规则中使用非终结符号,则无需显式指定子模式名称;您可以简单地使用符号名称作为函数名称。这可以在Number规则中完成:

Number: Decimal | Integer 
 **function Decimal(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}** 
 **function Integer(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}**

同样,$sub参数包含匹配子模式的结果数组。在这种情况下,这意味着您之前修改过的match_Decimalmatch_Integer函数返回的结果数组。

这将在ProductSum规则中变得更加复杂。首先,通过为Product规则的各个部分添加标签来开始:

Product: left:Value (operand:(> operator:('*'|'/') > right:Value))* 

继续通过向规则添加相应的规则函数来进行:

Product: left:Value (operand:(> operator:('*'|'/') > right:Value))* 
 **function left(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}** 
 **function right(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}** 
 **function operator(array &$result, array $sub) {** 
 **$result['operator'] = $sub['text'];** 
 **}** 
 **function operand(array &$result, array $sub) {** 
 **if ($sub['operator'] == '*') {** 
 **$result['value'] *= $sub['value'];** 
 **} else {** 
 **$result['value'] /= $sub['value'];** 
 **}** 
 **}**

Sum规则可以相应地进行修改:

Sum: left:Product (operand:(> operator:('+'|'-') > right:Product))* 
 **function left(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}** 
 **function right(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}** 
 **function operator(array &$result, array $sub) {** 
 **$result['operator'] = $sub['text'];** 
 **}** 
 **function operand(array &$result, array $sub) {** 
 **if ($sub['operator'] == '+') {** 
 **$result['value'] += $sub['value'];** 
 **} else {** 
 **$result['value'] -= $sub['value'];** 
 **}** 
 **}**

最后,您仍然需要修改ValueExpr规则:

Value: Number | '(' > Expr > ')' 
 **function Number(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}** 
 **function Expr(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}** 
Expr: Sum 
 **function Sum(array &$result, array $sub) {** 
 **$result['value'] = $sub['value'];** 
 **}**

使用您的解析器中的这些新函数,现在它将能够在解析表达式的同时进行评估(请注意,我们在这里不遵循传统编译器架构,因为解析和执行不被视为分开的步骤,而是在同一步骤中完成)。使用cli.php脚本重新构建您的解析器类,并调整您的测试脚本以测试一些表达式:

var_dump((new Parser('-143.25'))->match_Expr()['value']); 
var_dump((new Parser('12 + 3'))->match_Expr()['value']); 
var_dump((new Parser('1 + 2 * 3'))->match_Expr()['value']); 
var_dump((new Parser('(1 + 2) * 3'))->match_Expr()['value']); 

运行您的测试脚本将提供以下输出:

double(-143.25) 
int(15) 
int(7) 
int(9) 

构建抽象语法树

目前,我们的解析器在同一步骤中解释输入代码并对其进行评估。然而,大多数编译器和解释器在实际运行程序之前会创建一个中间数据结构:抽象语法树AST)。使用 AST 提供了一些有趣的可能性;例如,它为您提供了程序的结构化表示,然后您可以对其进行分析。此外,您可以使用 AST 并将其转换回基于文本的程序(可能是另一种语言)。

AST 是表示程序结构的树。构建基于 AST 的解析器的第一步是设计树的对象模型:需要哪些类以及它们如何相互关联。以下图显示了用于描述数学表达式的对象模型的初步草案:

构建抽象语法树

(初步)抽象语法树的对象模型

在这个模型中,几乎所有的类都实现了Expression接口。这个接口规定了evaluate()方法,这个方法可以由接口的实现来执行实际的操作,模拟相应的树节点。让我们从实现Packt\Chp8\DSL\AST\Expression接口开始:

namespace Packt\Chp8\DSL\AST; 

interface Expression 
{ 
    public function evaluate() 
} 

接下来是Number类及其两个子类:IntegerDecimal。由于我们将使用 PHP 7 的类型提示功能,而IntegerDecimal类只能使用intfloat变量,我们无法充分利用继承,不得不将Number类留空:

namespace Packt\Chp8\DSL\AST; 

abstract class Number implements Expression 
{} 

Integer类可以用 PHP 整数值初始化。由于这个类模拟了一个字面整数值;在这个类中,evaluate()方法需要做的唯一一件事情就是再次返回这个值:

namespace Packt\Chp8\DSL\AST; 

class Integer extends Number 
{ 
    private $value; 

    public function __construct(int $value) 
    { 
        $this->value = $value; 
    } 

    public function evaluate(): int 
    { 
        return $this->value; 
    } 
} 

Decimal类可以以相同的方式实现;在这种情况下,只需使用float而不是int作为类型提示:

namespace Packt\Chp8\DSL\AST; 

class Decimal extends Number 
{ 
    private $value; 

    public function __construct(float $value) 
    { 
        $this->value = $value; 
    } 

    public function evaluate(): float 
    { 
        return $this->value; 
    } 
} 

对于AdditionSubtractionMultiplicationDivision类,我们将使用一个共同的基类Packt\Chp8\DSL\AST\BinaryOperation。这个类将包含构造函数,这样你就不必一遍又一遍地实现它了:

namespace Packt\Chp8\DSL\AST; 

abstract class BinaryOperation implements Expression 
{ 
    protected $left; 
    protected $right; 

    public function __construct(Expression $left, Expression $right) 
    { 
        $this->left  = $left; 
        $this->right = $right; 
    } 
} 

实现实际的操作类变得很容易。让我们以Addition类为例:

namespace Packt\Chp8\DSL\AST; 

class Addition extends BinaryOperation 
{ 
    public function evaluate() 
    { 
 **return $this->left->evaluate() + $this->right->evaluate();** 
    } 
} 

剩下的类SubtractionMultiplicationDivision可以以类似于Addition类的方式实现。为了简洁起见,这些类的实际实现留给你作为练习。

现在剩下的就是在解析器中实际构建 AST。这相对容易,因为我们现在可以简单地修改解析器调用的已经存在的挂钩函数,当匹配到单个规则时。

让我们从解析数字的规则开始:

Integer: value:('-'? Digit+) 
    function value(array &$result, array $sub) { 
 **$result['node'] = new Integer((int) $sub['text']);** 
    } 

Decimal: value:('-'? Digit* '.' Digit+) 
    function value(array &$result, array $sub) { 
 **$result['node']  = new Decimal((float) $sub['text']);** 
    } 

Number: Decimal | Integer 
    function Decimal(&$result, $sub) { 
 **$result['node']  = $sub['node'];** 
    } 
    function Integer(&$result, $sub) { 
 **$result['node']  = $sub['node'];** 
    } 

IntegerDecimal规则匹配时,我们创建一个IntegerDecimal类的新 AST 节点,并将其保存在返回数组的node属性中。当Number规则匹配时,我们只需接管已经创建的节点存储在匹配符号中。

我们可以以类似的方式调整Product规则:

Product: left:Value (operand:(> operator:('*'|'/') > right:Value))* 
    function left(array &$result, array $sub) { 
 **$result['node']  = $sub['node'];** 
    } 
    function right(array &$result, array $sub) { 
 **$result['node']  = $sub['node'];** 
    } 
    function operator(array &$result, array $sub) { 
 **$result['operator'] = $sub['text'];** 
    } 
    function operand(array &$result, array $sub) { 
        if ($sub['operator'] == '*') { 
 **$result['node'] = new Multiplication($result['node'], $sub['node']);** 
        } else { 
 **$result['node'] = new Division($result['node'], $sub['node']);** 
        } 
    } 

由于我们的 AST 模型严格将乘法等操作视为二进制操作,解析器将把输入表达式(如1 * 2 * 3 * 4)拆分成一系列二进制乘法(类似于1 * (2 * (3 * 4)),如下图所示):

构建抽象语法树

表达式 1 * 2 * 3 * 4 的语法树

继续以相同的方式调整你的Sum规则:

Sum: left:Product (operand:(> operator:('+'|'-') > right:Product))* 
    function left(&$result, $sub) { 
 **$result['node']  = $sub['node'];** 
    } 
    function right(&$result, $sub) { 
 **$result['node']  = $sub['node'];** 
    } 
    function operator(&$result, $sub) { $result['operator'] = $sub['text']; } 
    function operand(&$result, $sub) { 
        if ($sub['operator'] == '+') { 
 **$result['node'] = new Addition($result['node'], $sub['node']);** 
        } else { 
 **$result['node'] = new Subtraction($result['node'], $sub['node']);** 
        } 
    } 

现在,剩下的就是按照以下方式在ValueExpr规则中读取创建的 AST 节点:

Value: Number | '(' > Expr > ')' 
    function Number(array &$result, array $sub) { 
 **$result['node'] = $sub['node'];** 
    } 

Expr: Sum 
    function Sum(array &$result, array $sub) { 
 **$result['node'] = $sub['node'];** 
    } 

在你的测试脚本中,你现在可以通过从match_Expr()函数的返回值中提取node属性来测试 AST 是否正确构建。然后,你可以通过在 AST 的根节点上调用evaluate()方法来获得表达式的结果:

$astRoot = (new Parser('1 + 2 * 3'))->match_Expr()['node']; 
var_dump($astRoot, $astRoot->evaluate()); 

$astRoot = (new Parser('(1 + 2) * 3'))->match_Expr()['node']; 
var_dump($astRoot, $astRoot->evaluate()); 

请注意,这个测试脚本中的两个表达式应该产生两个不同的语法树(都显示在下图中),并分别求值为 7 和 9。

构建抽象语法树

解析 1+2'和(1+2)'表达式得到的两个语法树

构建一个更好的接口

目前,我们构建的解析器并不是真正易于使用的。为了正确使用解析器,用户(在这个上下文中,将“用户”解释为“使用你的解析器的另一个开发人员”)必须调用match_Expr()方法(这只是解析器提供的许多公共match_*函数之一,实际上不应该被外部用户调用),从返回的数组中提取node属性,然后在这个属性中包含的根节点上调用evaluate函数。此外,解析器还匹配部分字符串(记住我们的解析器认为(1 + 2)) * 3这个例子部分正确),这可能会让一些用户感到非常惊讶。

这个原因足以通过一个新的类来扩展我们的项目,这个类封装了这些怪癖,并为我们的解析器提供了一个更清晰的接口。让我们创建一个新的类,Packt\Chp8\DSL\ExpressionBuilder

namespace Packt\Chp8\DSL\ExpressionBuilder; 

use Packt\Chp8\DSL\AST\Expression; 
use Packt\Chp8\DSL\Exception\ParsingException; 
use Packt\Chp8\DSL\Parser\Parser; 

class ExpressionBuilder 
{ 
    public function parseExpression(string $expr): Expression 
    { 
        $parser = new Parser($expr); 
        $result = $parser->match_Expr(); 

        if ($result === false || $result['text'] !== $expr) { 
            throw new ParsingException(); 
        } 

        return $result['node']; 
    } 
} 

在这个例子中,我们正在检查整个字符串是否可以通过断言来解析,即匹配解析器返回的字符串实际上等于输入字符串(而不仅仅是子字符串)。如果是这种情况(或者如果表达式根本无法解析,结果只是 false),则会抛出Packt\Chp8\DSL\Exception\ParsingException的实例。这个异常类尚未定义;目前,它可以简单地继承基本异常类,不需要包含任何自定义逻辑:

namespace Packt\Chp8\DSL\Exception; 

class ParsingException extends \Exception 
{} 

新的ExpressionBuilder类现在为您提供了一种更简洁的方法来解析和评估表达式。例如,您现在可以在您的test.php脚本中使用以下结构:

$builder = new \Packt\Chp8\DSL\ExpressionBuilder; 

var_dump($builder->parseExpression('12 + 3')->evaluate()); 

评估变量

到目前为止,我们的解析器可以评估静态表达式,从简单的表达式开始,比如3(评估结果是 3),到任意复杂的表达式,比如(5 + 3.14) * (14 + (29 - 2 * 3.918))(顺便说一句,评估结果是 286.23496)。然而,所有这些表达式都是静态的;它们总是评估为相同的结果。

为了使这更加动态,我们现在将扩展我们的语法以允许变量。一个带有变量的表达式的例子是3 + a,然后可以使用不同的值多次评估a

这一次,让我们从修改语法树的对象模型开始。首先,我们需要一个新的节点类型,Packt\Chp8\DSL\AST\Variable,例如允许3 + a表达式生成以下语法树:

Evaluating variables

从表达式 3 + a 生成的语法树

还有第二个问题:与使用Number节点的Number节点或算术运算相反,我们不能简单地计算Variable节点的数值(毕竟,它可以有任何值 - 这就是变量的意义)。因此,在评估表达式时,我们还需要传递关于哪些变量存在以及它们具有什么值的信息。为此,我们将通过额外的参数简单地扩展Packt\Chp8\DSL\AST\Expression接口中定义的evaluate()函数:

namespace Packt\Chp8\DSL\AST; 

interface Expression 
{ 
 **public function evaluate(array $variables = []);** 
} 

更改接口定义需要更改所有实现此接口的类。在Number子类(IntegerDecimal)中,您可以添加新参数并简单地忽略它。静态数字的值根本不依赖于任何变量的值。下面的代码示例展示了Packt\Chp8\DSL\AST\Integer类中的这种变化,但请记住也要以同样的方式更改Decimal类:

class Integer 
{ 
    // ... 
 **public function evaluate(array $variables = []): int** 
    { 
        return $this->value; 
    } 
} 

BinaryOperation子类(AdditionSubtractionMultiplicationDivision)中,定义的变量的值也并不重要。但是我们需要将它们传递给这些节点的子节点。下面的例子展示了Packt\Chp8\DSL\AST\Addition类中的这种变化,但请记住也要相应地更改SubtractionMultiplicationDivision类:

class Addition 
{ 
 **public function evaluate(array $variables = [])** 
    { 
 **return $this->left->evaluate($variables)** 
 **+ $this->right->evaluate($variables);** 
    } 
} 

最后,我们现在可以声明我们的Packt\Chp8\DSL\AST\Variable类:

namespace Packt\Chp8\DSL\AST; 

use Packt\Chp8\DSL\Exception\UndefinedVariableException; 

class Variable implements Expression 
{ 
    private $name; 

    public function __construct(string $name) 
    { 
        $this->name = $name; 
    } 

    public function evaluate(array $variables = []) 
    { 
        if (isset($variables[$this->name])) { 
            return $variables[$this->name]; 
        } 
        throw new UndefinedVariableException($this->name); 
    } 
} 

在这个类的evaluate()方法中,您可以查找这个变量当前的实际值。如果一个变量没有定义(即:在$variables参数中不存在),我们将引发一个(尚未实现的)Packt\Chp8\DSL\Exception\UndefinedVariableException的实例,以让用户知道出了问题。

提示

你如何处理自定义语言中未定义的变量完全取决于你。你也可以改变Variable类的evaluate()方法,在评估未定义的变量时返回一个默认值,比如 0(或其他任何值)。然而,使用未定义的变量可能是无意的,简单地继续使用默认值可能会让你的用户感到非常惊讶。

UndefinedVariableException类可以简单地扩展常规的Exception类:

namespace Packt\Chp8\DSL\Exception; 

class UndefinedVariableException extends \Exception 
{ 
    private $name; 

    public function __construct(string $name) 
    { 
        parent::__construct('Undefined variable: ' . $name); 
        $this->name = $name; 
    } 
} 

最后,我们需要调整解析器的语法以实际识别表达式中的变量。为此,我们的语法需要两个额外的符号:

Name: /[a-zA-z]+/ 
Variable: Name 
    function Name(&$result, $sub) { 
        $result['node'] = new Variable($sub['name']); 
    } 

接下来,你需要扩展Value规则。目前,Value可以是Number符号,或者用括号括起来的Expr。现在,你还需要允许变量:

**Value: Number | Variable | '(' > Expr > ')'** 
    function Number(array &$result, $sub) { 
        $result['node'] = $sub['node']; 
    } 
 **function Variable(array &$result, $sub) {** 
 **$result['node'] = $sub['node'];** 
 **}** 
    function Expr(array &$result, $sub) { 
        $result['node'] = $sub['node']; 
    } 

使用 PHP-PEG 的cli.php脚本重新构建你的解析器,并在test.php脚本中添加一些调用来测试这个新功能:

$expr = $builder->parseExpression('1 + 2 * a'); 
var_dump($expr->evaluate(['a' => 1])); 
var_dump($expr->evaluate(['a' => 14])); 
var_dump($expr->evaluate(['a' => -1])); 

这些应该分别评估为 3、29 和-1。你也可以尝试在不传递任何变量的情况下评估表达式,这应该(理所当然地)导致抛出UndefinedVariableException

添加逻辑表达式

目前,我们的语言只支持数值表达式。另一个有用的补充是支持布尔表达式,这些表达式不会评估为数值,而是truefalse。可能的例子包括诸如3 = 4(这将始终评估为false)、2 < 4(这将始终评估为true)或a <= 5(这取决于变量a的值)的表达式。

比较

和之前一样,让我们从扩展语法树的对象模型开始。我们将从一个表示两个表达式之间相等检查的Equals节点开始。使用这个节点,1 + 2 = 4 - 1表达式将产生以下语法树(当然最终应该评估为true):

比较

应该从解析 1 + 2 = 4 - 1 表达式得到的语法树

为此,我们将实现Packt\Chp8\DSL\AST\Equals类。这个类可以继承我们之前实现的BinaryOperation类:

namespace Packt\Chp8\DSL\AST; 

class Equals extends BinaryOperation 
{ 
    public function evaluate(array $variables = []) 
    { 
        return $this->left->evaluate($variables) 
            == $this->right->evaluate($variables); 
    } 
} 

在这个过程中,我们也可以同时实现NotEquals节点:

namespace Packt\Chp8\DSL\AST; 

**class NotEquals extends BinaryOperation** 
{ 
    public function evaluate(array $variables = []) 
    { 
 **return $this->left->evaluate($variables)** 
 **!= $this->right->evaluate($variables);** 
    } 
} 

接下来,我们需要调整我们解析器的语法。首先,我们需要改变语法以区分数值和布尔表达式。为此,我们将在整个语法中将Expr符号重命名为NumExpr。这会影响Value符号:

Value: Number | Variable | '(' > **NumExpr** > ')' 
    function Number(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 
    function Variable(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 
 **function NumExpr(array &$result, array $sub) {** 
        $result['node'] = $sub['node']; 
    } 

当然,你还需要改变Expr规则本身:

**NumExpr**: Sum 
    function Sum(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

接下来,我们可以定义一个相等(以及不相等)的规则:

ComparisonOperator: '=' | '|=' 
Comparison: left:NumExpr (operand:(> op:ComparisonOperator > right:NumExpr)) 
    function left(&$result, $sub) { 
        $result['leftNode'] = $sub['node']; 
    } 
    function right(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 
    function op(array &$result, array $sub) { 
        $result['op'] = $sub['text']; 
    } 
    function operand(&$result, $sub) { 
        if ($sub['op'] == '=') { 
            $result['node'] = new Equals($result['leftNode'], $sub['node']); 
        } else { 
            $result['node'] = new NotEquals($result['leftNode'], $sub['node']); 
        } 
    } 

请注意,在这种情况下,这个规则变得有点复杂,因为它支持多个运算符。然而,这些规则现在相对容易通过更多的运算符进行扩展(当我们检查非相等的事物时,如"大于"或"小于"可能是下一个逻辑步骤)。首先定义的ComparisonOperator符号匹配所有类型的比较运算符,而使用这个符号来匹配实际表达式的Comparison规则。

最后,我们可以添加一个新的BoolExpr符号,并重新定义Expr符号:

BoolExpr: Comparison 
    function Comparison(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

Expr: BoolExpr | NumExpr 
    function BoolExpr(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 
    function NumExpr(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

在调用match_Expr()函数时,我们的解析器现在将匹配数值和布尔表达式。使用 PHP-PEG 的cli.php脚本重新构建你的解析器,并在test.php脚本中添加一些新的调用:

$expr = $builder->parseExpression('1 = 2'); 
var_dump($expr->evaluate()); 

$expr = $builder->parseExpression('a * 2 = 6'); 
var_dump($expr->evaluate(['a' => 3]); 
var_dump($expr->evaluate(['a' => 4]); 

这些表达式应该分别评估为falsetruefalse。你之前添加的数值表达式应该继续像以前一样工作。

类似于这样,你现在可以向你的语法中添加额外的比较运算符,比如>>=<<=。由于这些运算符的实现基本上与=|=操作相同,我们将把它作为一个练习留给你。

"and"和"or"运算符

为了完全支持逻辑表达式,另一个重要特性是能够通过andor运算符组合逻辑表达式。由于我们正在为最终用户开发我们的语言,我们将构建我们的语言以实际支持andor作为逻辑运算符(与许多通用编程语言中的&&||相反,这些语言都是从 C 语法派生而来)。

再次,让我们从实现语法树的相应节点类型开始。我们需要建模andor操作的节点类型,这样一个语句,比如a = 1b = 2,将被解析成以下语法树:

The "and" and "or" operators

解析 a=1 or b=2 得到的语法树

首先实现Packt\Chp8\DSL\AST\LogicalAnd类(我们不能使用And作为类名,因为在 PHP 中这是一个保留字):

namespace Packt\Chp8\DSL\AST; 

class LogicalAnd extends BinaryOperation 
{ 
    public function evaluate(array $variables=[]) 
    { 
        return $this->left->evaluate($variables) 
            && $this->right->evaluate($variables); 
    } 
} 

对于or运算符,你也可以用同样的方式实现Packt\Chp8\DSL\AST\LogicalOr类。

在使用andor运算符时,你需要考虑运算符优先级。虽然算术运算的运算符优先级已经定义得很好,但逻辑运算符却不是这样。例如,语句a and b or c and d可以被解释为(((a and b) or c) and d)(相同的优先级,从左到右),或者同样可以被解释为(a and b) or (c and d)and的优先级),或者(a and (b or c)) and dor的优先级)。然而,大多数编程语言将and运算符视为最高优先级,因此除非有其他约定,否则坚持这一传统是有道理的。

以下图显示了将这种优先级应用于a=1 and b=2 or b=3a=1 and (b=2 or b=3)语句得到的语法树:

The "and" and "or" operators

解析 a=1 and b=2 or b=3 and a=1 and (b=2 or b=3)得到的语法树

我们的语法需要一些新的规则。首先,我们需要一个表示布尔值的新符号。目前,这样的布尔值可以是一个比较,也可以是用括号括起来的任何布尔表达式。

BoolValue: Comparison | '(' > BoolExpr > ')' 
    function Comparison(array &$res, array $sub) { 
        $res['node'] = $sub['node']; 
    } 
    function BoolExpr(array &$res, array $sub) { 
        $res['node'] = $sub['node']; 
    } 

你还记得我们之前如何使用ProductSum规则实现了运算符优先级吗?我们可以用同样的方式实现AndOr规则:

And: left:BoolValue (> "and" > right:BoolValue)* 
    function left(array &$res, array $sub) { 
        $res['node'] = $sub['node']; 
    } 
    function right(array &$res, array $sub) { 
        $res['node'] = new LogicalAnd($res['node'], $sub['node']); 
    } 

Or: left:And (> "or" > right:And)* 
    function left(array &$res, array $sub) { 
        $res['node'] = $sub['node']; 
    } 
    function right(array &$res, array $sub) { 
        $res['node'] = new LogicalOr($res['node'], $sub['node']); 
    } 

之后,我们可以扩展BoolExpr规则,使其也匹配Or表达式(由于单个And符号也匹配Or规则,单个And符号也将是BoolExpr):

BoolExpr: Or | Comparison 
 **function Or(array &$result, array $sub) {** 
 **$result['node'] = $sub['node'];** 
 **}** 
    function Comparison(array &$result, array $sub) { 
        $result['node'] = $sub['node']; 
    } 

现在可以在你的test.php脚本中添加一些新的测试用例。尝试使用变量,并特别注意运算符优先级是如何解析的:

$expr = $builder->parseExpression('a=1 or b=2 and c=3'); 
var_dump($expr->evaluate([ 
    'a' => 0, 
    'b' => 2, 
    'c' => 3 
]); 

条件

现在,我们的语言支持(任意复杂的)逻辑表达式,我们可以使用这些来实现另一个重要的特性:条件语句。我们的语言目前只支持评估为单个数字或布尔值的表达式;我们现在将实现三元运算符的变体,这在 PHP 中也是众所周知的:

($b > 0) ? 1 : 2; 

由于我们的语言面向最终用户,我们将使用更易读的语法,这将允许诸如when <condition> then <value> else <value>的语句。在我们的语法树中,这些构造将由Packt\Chp8\DSL\AST\Condition类表示:

<?php 
namespace Packt\Chp8\DSL\AST; 

class Condition implements Expression 
{ 
    private $when; 
    private $then; 
    private $else; 

    public function __construct(Expression $when, Expression $then, Expression $else) 
    { 
        $this->when = $when; 
        $this->then = $then; 
        $this->else = $else; 
    } 

    public function evaluate(array $variables = []) 
    { 
        if ($this->when->evaluate($variables)) { 
            return $this->then->evaluate($variables); 
        } 
        return $this->else->evaluate($variables); 
    } 
} 

这意味着,例如when a > 2 then a * 1.5 else a * 2表达式应该被解析成以下语法树:

Conditions

理论上,我们的语言还应该支持条件或 then/else 部分中的复杂表达式,允许诸如when (a > 2 or b = 2) then (2 * a + 3 * b) else (3 * a - b)或甚至嵌套语句,比如when a=2 then (when b=2 then 1 else 2) else 3

Conditions

继续通过向解析器的语法中添加新的符号和规则:

Condition: "when" > when:BoolExpr > "then" > then:Expr > "else" > else:Expr 
    function when(array &$res, array $sub) { 
        $res['when'] = $sub['node']; 
    } 
    function then(array &$res, $sub) { 
        $res['then'] = $sub['node']; 
    } 
    function else(array &$res, array $sub) { 
        $res['node'] = new Condition($res['when'], $res['then'], $sub['node']); 
    } 

还要调整BoolExpr规则以匹配条件。在这种情况下,顺序很重要:如果您首先将OrComparison符号放在BoolExpr规则中,规则可能会将when解释为变量名,而不是条件表达式。

BoolExpr: **Condition |** Or | Comparison 
 **function Condition(array &$result, array $sub) {** 
 **$result['node'] = $sub['node'];** 
 **}** 
    function Or(&$result, $sub) { 
        $result['node'] = $sub['node']; 
    } 
    function Comparison(&$result, $sub) { 
        $result['node'] = $sub['node']; 
    } 

然后,使用 PHP-PEG 的cli.php脚本重新构建您的解析器,并在测试脚本中添加一些测试语句以测试新的语法规则:

$expr = $builder->parseExpression('when a=1 then 3.14 else a*2'); 
var_dump($expr->evaluate(['a' => 1]); 
var_dump($expr->evaluate(['a' => 2]); 
var_dump($expr->evaluate(['a' => 3]); 

这些测试案例应该分别评估为 3.14、4 和 6。

使用结构化数据

到目前为止,我们的自定义表达式语言只支持非常简单的变量-数字和布尔值。然而,在实际应用中,情况通常并不那么简单。当使用表达式语言提供可编程的业务规则时,您通常会使用结构化数据。例如,考虑一个电子商务系统,其中后台用户有可能定义在哪些条件下向用户提供折扣以及折扣的购买金额(以下图显示了这样一个功能在应用程序中实际上可能看起来的假设示例)。

通常,您事先不知道用户将如何使用此功能。仅使用数字变量,您将不得不在评估表达式时传递整套变量,以防用户可能使用其中一两个。或者,您可以将整个域对象(例如,表示购物车的 PHP 对象,也许还有另一个表示客户的对象)作为变量传递到表达式中,并给用户访问这些对象的属性或调用方法的选项。

这样的功能将允许用户在表达式中使用cart.value等表达式。在评估此表达式时,这可以被转换为直接属性访问(如果$cart变量确实具有公开访问的$value属性),或者调用getValue()方法:

使用结构化数据

结构化数据如何在企业电子商务应用程序中用作变量的示例

为此,我们需要稍微修改我们的 AST 对象模型。我们将引入一个新的节点类型,Packt\Chp8\DSL\AST\PropertyFetch,它模拟了从变量中获取的命名属性。但是,我们需要考虑这些属性获取需要被链接,例如,在表达式中,如cart.customer.contact.firstname。这个表达式应该被解析成以下语法树:

使用结构化数据

为此,我们将重新定义之前添加的Variable节点类型。将Variable类重命名为NamedVariable,并添加一个名为Variable的新接口。然后,这个接口可以被NamedVariable类和PropertyFetch类同时实现。PropertyFetch类可以接受Variable实例作为其左操作数。

首先将Packt\Chp8\DSL\AST\Variable类重命名为Packt\Chp8\DSL\AST\NamedVariable

namespace Packt\Chp8\DSL\AST; 

use Packt\Chp8\DSL\Exception\UnknownVariableException; 

**class NamedVariable implements Variable** 
{ 
    private $name; 

    public function __construct(string $name) 
    { 
        $this->name = $name; 
    } 

    public function evaluate(array $variables = []) 
    { 
        if (isset($variables[$this->name])) { 
            return $variables[$this->name]; 
        } 
        throw new UnknownVariableException(); 
    } 
} 

然后,添加名为Packt\Chp8\DSL\AST\Variable的新接口。它不需要包含任何代码;我们将仅用于类型提示:

namespace Packt\Chp8\DSL\AST; 

interface Variable extends Expression 
{ 
} 

继续添加Packt\Chp8\DSL\AST\PropertyFetch新类:

namespace Packt\Chp8\DSL\AST; 

class PropertyFetch implements Variable 
{ 
    private $left; 
    private $property; 

    public function __construct(Variable $left, string $property) 
    { 
        $this->left = $left; 
        $this->property = $property; 
    } 

    public function evaluate(array $variables = []) 
    { 
        $var = $this->left->evaluate($variables); 
        return $var[$this->property] ?? null; 
    } 
} 

最后,在解析器的语法中修改Variable规则:

Variable: Name **('.' property:Name)*** 
    function Name(array &$result, array $sub) { 
        $result['node'] = new NamedVariable($sub['text']); 
    } 
 **function property(&$result, $sub) {** 
 **$result['node'] = new PropertyFetch($result['node'], $sub['text']);** 
 **}**

使用此规则,Variable符号可以由多个用.字符链接在一起的属性名称组成。然后,规则函数将为第一个属性名称构建一个NamedVariable节点,然后将此节点作为PropertyFetch节点的链的一部分处理后续属性。

像往常一样,重新构建您的解析器,并在测试脚本中添加几行:

$e = $builder->parseExpression('foo.bar * 2'); 
var_dump($e->evaluate(['foo' => ['bar' => 2]])); 

使用对象

让最终用户理解数据结构的概念并不容易。虽然对象具有属性的概念(例如,客户具有名字和姓氏)通常很容易传达,但您可能不会让最终用户关注数据封装和对象方法之类的事情。

因此,隐藏数据访问的复杂性可能是有用的;如果用户想要访问客户的名字,他们应该能够编写customer.firstname,即使底层对象的实际属性是受保护的,并且通常需要调用getFirstname()方法来读取此属性。由于获取器函数通常遵循某些命名模式,我们的解析器可以自动将诸如customer.firstname之类的表达式转换为诸如$customer->getFirstname()之类的方法调用。

要实现此功能,我们需要通过一些特殊情况来扩展PropertyFetchevaluate方法:

public function evaluate(array $variables = []) 
{ 
    $var = $this->left->evaluate($variables); 
 **if (is_object($var)) {** 
 **$getterMethodName = 'get' . ucfirst($this->property);** 
 **if (is_callable([$var, $getterMethodName])) {** 
 **return $var->{$getterMethodName}();** 
 **}**
 **$isMethodName = 'is' . ucfirst($this->property);** 
 **if (is_callable([$var, $isMethodName])) {** 
 **return $var->{$isMethodName}();** 
 **}** 
 **return $var->{$this->property} ?? null;** 
 **}** return $var[$this->property] ?? null; 
} 

使用此实现,例如customer.firstname这样的表达式将首先检查客户对象是否实现了可以调用的getFirstname()方法。如果不是这种情况,解释器将检查是否存在isFirstname()方法(在这种情况下没有意义,但作为获取器函数可能很有用,因为布尔属性通常被命名为isSomething而不是getSomething)。如果也不存在isFirstname()方法,解释器将查找名为firstname的可访问属性,然后作为最后的手段简单地返回 null。

通过添加编译器来优化解释器

我们的解析器现在可以正常工作,并且您可以在任何类型的应用程序中使用它,为最终用户提供非常灵活的定制选项。但是,解析器的效率并不高。一般来说,解析表达式是计算密集型的,在大多数情况下,可以合理地假设您正在处理的实际表达式不会在每个请求中更改(或者至少比它们更改的频率更高)。

因此,我们可以通过向我们的解释器添加缓存层来优化解析器的性能。当然,我们无法缓存表达式的实际评估结果;毕竟,当使用不同的变量解释它们时,这些结果可能会发生变化。

在本节中,我们要做的是向我们的解析器添加一个编译器功能。对于每个解析的表达式,我们的解析器生成一个代表该表达式结构的 AST。您现在可以使用此语法树将表达式转换为任何其他编程语言,例如 PHP。

考虑2 + 3 * a表达式。此表达式生成以下语法树:

通过添加编译器来优化解释器

在我们的 AST 模型中,这对应于Packt\Chp8\DSL\AST\Addition类的一个实例,其中包含对Packt\Chp8\DSL\AST\Number类和Packt\Chp8\DSL\AST\Product类(等等)的引用。

我们无法实现编译器功能将这些表达式转换回 PHP 代码(毕竟,PHP 也支持简单的算术运算),可能看起来像这样:

use Packt\Chp8\DSL\AST\Expression; 

$cachedExpr = new class implements Expression 
{ 
    public function evaluate(array $variables=[]) 
    { 
        return 2 + (3 * $variables['a']); 
    } 
} 

以这种方式生成的 PHP 代码可以保存在文件中以供以后查找。如果解析器传递了一个已经缓存的表达式,它可以简单地加载保存的 PHP 文件,以便不实际解析表达式。

要实现此功能,我们需要有可能将语法树中的每个节点转换为相应的 PHP 表达式。为此,让我们通过一个新方法扩展我们的Packt\Chp8\DSL\AST\Expression接口:

namespace Packt\Chp8\DSL\AST; 

interface Expression 
{ 
    public function evaluate(array $variables = []); 

 **public function compile(): string;** 
} 

这种方法的缺点是,您现在需要为实现此接口的每个类实现此方法。让我们从简单的东西开始:Packt\Chp8\DSL\AST\Number类。由于每个Number实现始终会评估为相同的数字(3 始终会评估为 3 而不会评估为 4),我们可以简单地返回数字值:

namespace Packt\Chp8\DSL\AST; 

abstract class Number implements Expression 
{ 
 **public function compile(): string** 
 **{** 
 **return var_export($this->evaluate(), true);** 
 **}** 
} 

至于剩余的节点类型,我们将需要返回每种表达式类型在 PHP 中的实现的方法。例如,对于Packt\Chp8\DSL\AST\Addition类,我们可以添加以下compile()方法:

namespace Packt\Chp8\DSL\AST; 

class Addition extends BinaryOperation 
{ 
    // ... 

 **public function compile(): string** 
 **{** 
 **return '(' . $this->left->compile() . ') + (' . $this->right->compile() . ')';** 
 **}** 
} 

对于剩余的算术运算,如SubtractionMultiplicationDivision,以及EqualsNotEqualsAndOr等逻辑运算,也要采取类似的方法。

对于Condition类,您可以使用 PHP 的三元运算符:

namespace Packt\Chp8\DSL\AST; 

class Condition implements Expression 
{ 
    // ... 

 **public function compile(): string** 
 **{** 
 **return sprintf('%s ? (%s) : (%s)',
             $this->when->compile(),** 
 **$this->then->compile(),** 
 **$this->else->compile()** 
 **);** 
 **}** 
} 

NamedVariable类很难调整;类的evaluate()方法当前在引用不存在的变量时会抛出UnknownVariableException。但是,我们的compile()方法需要返回一个单一的 PHP 表达式。查找值并抛出异常不能在单个表达式中完成。幸运的是,您可以实例化类并在其上调用方法:

namespace Packt\Chp8\DSL\AST; 

use Packt\Chp8\DSL\Exception\UnknownVariableException; 

class NamedVariable implements Variable 
{ 
    // ... 

    public function evaluate(array $variables = []) 
    { 
        if (isset($variables[$this->name])) { 
            return $variables[$this->name]; 
        } 
        throw new UnknownVariableException(); 
    } 

    public function compile(): string 
    { 
        return sprintf('(new %s(%s))->evaluate($variables)', 
            __CLASS__, 
            var_export($this->name, true) 
        ); 
    } 
} 

使用这种解决方法,a * 3表达式将被编译为以下 PHP 代码:

(new \Packt\Chp8\DSL\AST\NamedVariable('a'))->evaluate($variables) * 3 

这只留下了PropertyFetch类。您可能还记得,这个类比其他节点类型更复杂,因为它实现了如何从对象中查找属性的许多不同情况。理论上,这个逻辑可以使用三元运算符在单个表达式中实现。这将导致foo.bar表达式被编译为以下怪物:

is_object((new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables)) ? ((is_callable([(new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables), 'getBar']) ? (new \Packt\Chp8\DSL\AST\NamedVariable('a'))->evaluate($variables)->getBar() : ((is_callable([(new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables), 'isBar']) ? (new \Packt\Chp8\DSL\AST\NamedVariable('a'))->evaluate($variables)->isBar() : (new \Packt\Chp8\DSL\AST\NamedVariable('a'))->evaluate($variables)['bar'] ?? null)) : (new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables)['bar'] 

为了防止编译后的代码变得过于复杂,最好稍微重构PropertyFetch类。您可以将实际的属性查找方法提取到一个静态方法中,该方法可以从evaluate()方法和compiled代码中调用:

<?php 
namespace Packt\Chp8\DSL\AST; 

class PropertyFetch implements Variable 
{ 
    private $left; 
    private $property; 

    public function __construct(Variable $left, string $property) 
    { 
        $this->left = $left; 
        $this->property = $property; 
    } 

    public function evaluate(array $variables = []) 
    { 
        $var = $this->left->evaluate($variables); 
 **return static::evaluateStatic($var, $this->property);** 
    } 

 **public static function evaluateStatic($var, string $property)** 
 **{** 
 **if (is_object($var)) {** 
 **$getterMethodName = 'get' . ucfirst($property);** 
 **if (is_callable([$var, $getterMethodName])) {** 
 **return $var->{$getterMethodName}();** 
 **}** 
 **$isMethodName = 'is' . ucfirst($property);** 
 **if (is_callable([$var, $isMethodName])) {** 
 **return $var->{$isMethodName}();** 
 **}** 
 **return $var->{$property} ?? null;** 
 **}** 
 **return $var[$property] ?? null;** 
 **}** 
 **public function compile(): string** 
 **{** 
 **return __CLASS__ . '::evaluateStatic(' . $this->left->compile() . ', ' . var_export($this->property, true) . ')';** 
 **}** 
} 

这样,foo.bar表达式将简单地评估为:

\Packt\Chp8\DSL\AST\PropertyFetch::evaluateStatic( 
    (new \Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables), 
    'bar' 
) 

在下一步中,我们可以为先前介绍的ExpressionBuilder类添加一个替代方案,该方案可以透明地编译表达式,将它们保存在缓存中,并在必要时重用已编译的版本。

我们将称这个类为Packt\Chp8\DSL\CompilingExpressionBuilder

<?php 
namespace Packt\Chp8\DSL; 

class CompilingExpressionBuilder 
{ 
    /** @var string */ 
    private $cacheDir; 
    /** 
     * @var ExpressionBuilder 
     */ 
    private $inner; 

    public function __construct(ExpressionBuilder $inner, string $cacheDir) 
    { 
        $this->cacheDir = $cacheDir; 
        $this->inner = $inner; 
    } 
} 

由于我们不希望重新实现ExpressionBuilder的解析逻辑,因此该类将ExpressionBuilder的实例作为依赖项。当解析尚未存在于缓存中的新表达式时,将使用内部表达式构建器来实际解析此表达式。

让我们继续向这个类添加一个parseExpression方法:

public function parseExpression(string $expr): Expression 
{ 
    $cacheKey = sha1($expr); 
    $cacheFile = $this->cacheDir . '/' . $cacheKey . '.php'; 
    if (file_exists($cacheFile)) { 
        return include($cacheFile); 
    } 

    $expr = $this->inner->parseExpression($expr); 

    if (!is_dir($this->cacheDir)) { 
        mkdir($this->cacheDir, 0755, true); 
    } 

    file_put_contents($cacheFile, '<?php return new class implements '.Expression::class.' { 
        public function evaluate(array $variables=[]) { 
            return ' . $expr->compile() . '; 
        } 

        public function compile(): string { 
            return ' . var_export($expr->compile(), true) . '; 
        } 
    };'); 
    return $expr; 
} 

让我们看看这个方法中发生了什么:首先,实际的输入字符串用于计算哈希值,唯一标识此表达式。如果缓存目录中存在具有此名称的文件,则将其包含为 PHP 文件,并将文件的返回值作为方法的返回值返回:

$cacheKey = sha1($expr); 
$cacheFile = $this->cacheDir . '/' . $cacheKey; 
if (file_exists($cacheFile)) { 
    return include($cacheFile); 
} 

由于方法的类型提示指定方法需要返回Packt\Chp8\DSL\AST\Expression接口的实例,生成的缓存文件也需要返回此接口的实例。

如果找不到表达式的编译版本,则该表达式将像内部表达式构建器一样通常解析。然后使用compile()方法将此表达式编译为 PHP 表达式。然后使用此 PHP 代码片段来编写实际的缓存文件。在此文件中,我们创建一个实现表达式接口的新匿名类,并且在其evaluate()方法中包含编译后的表达式。

提示

匿名类是 PHP 7 中添加的一个功能。此功能允许您创建实现接口或扩展现有类的对象,而无需明确定义此类的命名类。在语法上,此功能可以如下使用:

$a = new class implements SomeInterface {     public function test() {         echo 'Hello';     } }; $a->test();

这意味着foo.bar * 3表达式将创建一个缓存文件,其中包含以下 PHP 代码:

<?php 
return new class implements Packt\Chp8\DSL\AST\Expression 
{ 
    public function evaluate(array $variables = []) 
    { 
        return (Packt\Chp8\DSL\AST\PropertyFetch::evaluateStatic( 
            (new Packt\Chp8\DSL\AST\NamedVariable('foo'))->evaluate($variables), 
            'bar' 
        )) * (3); 
    } 

    public function compile(): string 
    { 
        return '(Packt\\Chp8\\DSL\\AST\\PropertyFetch::evaluateStatic((new Packt\\Chp8\\DSL\\AST\\NamedVariable('foo'))->evaluate($variables), 'bar'))*(3)'; 
    } 
}; 

有趣的是,PHP 解释器本身的工作方式与此类似。在实际执行 PHP 代码之前,PHP 解释器将代码编译为中间表示或字节码,然后由实际解释器解释。为了不一遍又一遍地解析 PHP 源代码,编译后的字节码被缓存;这就是 PHP 的操作码缓存的工作原理。

由于我们将编译的表达式保存为 PHP 代码,这些表达式也将被编译为 PHP 字节码,并缓存在操作码缓存中,以再次提高性能。例如,先前缓存的表达式的 evaluate 方法评估为以下 PHP 字节码:

通过添加编译器来优化解释器

PHP 解释器生成的 PHP 字节码

验证性能改进

实现将编译到 PHP 的动机是为了提高解析器的性能。作为最后一步,我们现在将尝试验证缓存层是否确实提高了解析器的性能。

为此,您可以使用PHPBench包,您可以使用 composer 安装:

**$ composer require phpbench/phpbench**

PHPBench 提供了一个框架,用于在隔离中对单个代码单元进行基准测试(在这方面类似于 PHPUnit,只是用于基准测试而不是测试)。每个基准测试都是一个包含场景的 PHP 类作为方法。每个场景方法的名称需要以bench开头。

首先,在根目录中创建一个bench.php文件,内容如下:

require 'vendor/autoload.php'; 

use Packt\Chp8\DSL\ExpressionBuilder; 
use Packt\Chp8\DSL\CompilingExpressionBuilder; 

class ParserBenchmark 
{ 
    public function benchSimpleExpressionWithBasicParser() 
    { 
        $builder = new ExpressionBuilder(); 
        $builder->parseExpression('a = 2')->evaluate(['a' => 1]); 
    } 
} 

然后,您可以使用以下命令运行此基准测试:

**vendor/bin/phpbench run bench.php --report default**

这将生成以下报告:

验证性能改进

目前,PHPBench 仅运行基准函数一次,并测量执行此函数所需的时间。在这种情况下,大约为 2 毫秒。这并不是非常精确,因为这样的微量测量可能会有很大的变化,取决于同时发生在计算机上的其他事情。因此,通常最好多次执行基准函数(比如说,几百次或几千次),然后计算平均执行时间。使用 PHPBench,您可以通过在基准类的 DOC 注释中添加@Revs(5000)注释来轻松实现这一点:

/** 
 * @Revs(5000) 
 */ 
class ParserBenchmark 
{ 
    // ... 
} 

此注释将导致 PHPBench 实际运行此基准函数 5000 次,然后计算平均运行时间。

让我们还添加第二个场景,其中我们使用相同的表达式使用新的CompilingExpressionBuilder

/** 
 * @Revs(5000) 
 */ 
class ParserBenchmark 
{ 
    public function benchSimpleExpressionWithBasicParser() 
    { 
        $builder = new ExpressionBuilder(); 
        $builder->parseExpression('a = 2')->evaluate(['a' => 1]); 
    } 

    public function benchSimpleExpressionWithCompilingParser() 
    { 
        $builder = new CompilingExpressionBuilder(); 
        $builder->parseExpression('a = 2')->evaluate(['a' => 1]); 
    } 
} 

再次运行基准测试;这次对两个解析器进行基准测试,并进行 5000 次迭代:

验证性能改进

如您所见,解析和评估a=2表达式需要我们的常规解析器平均约 349 微秒(大约 20 兆字节的 RAM)。使用编译解析器只需要约 33 微秒(运行时间减少约 90%),只需要 5 兆字节的 RAM(或约 71%)。

现在,a=2可能并不是最具代表性的基准,因为在实际使用情况中使用的实际表达式可能会变得更加复杂。

为了进行更真实的基准测试,让我们添加另外两种情景,这次使用更复杂的表达式:

public function benchComplexExpressionBasicParser() 
{ 
    $builder = new ExpressionBuilder(); 
    $builder 
        ->parseExpression('when (customer.age = 1 and cart.value = 200) then cart.value * 0.1 else cart.value * 0.2') 
        ->evaluate(['customer' => ['age' => 1], 'cart' => ['value' => 200]]); 
} 

public function benchComplexExpressionCompilingParser() 
{ 
    $builder = new CompilingExpressionBuilder(new ExpressionBuilder(), 'cache/auto'); 
    $builder 
        ->parseExpression('when (customer.age = 1 and cart.value = 200) then cart.value * 0.1 else cart.value * 0.2') 
        ->evaluate(['customer' => ['age' => 1], 'cart' => ['value' => 200]]); 
} 

再次运行基准测试,并查看结果:

验证性能改进

这甚至比以前更好!使用常规解析器解析when (customer.age = 1 and cart.value = 200) then cart.value * 0.1 else cart.value * 0.2表达式大约需要 2.5 毫秒(请记住我们上次基准测试中谈到的微秒),而使用优化解析器只需要 50 微秒!这是约 98%的改进。

摘要

在本章中,您学习了如何使用 PHP-PEG 库实现自定义表达式语言的解析器、解释器和编译器。您还学习了如何为这样的语言定义语法,以及如何使用它们来开发特定领域的语言。这些可以用于在大型软件系统中提供最终用户开发功能,允许用户在很大程度上自定义其软件的业务规则。

使用特定领域语言动态修改程序可以成为一个强有力的卖点,特别是在企业系统中。它们允许用户自行修改程序的行为,而无需等待开发人员更改业务规则并触发漫长的发布流程。这样,新的业务规则可以快速实施,使您的客户能够迅速应对不断变化的需求。

第九章:PHP 中的响应式扩展

在本章中,我们将讨论 PHP 中的响应式扩展,这是一个允许 PHP 程序员以响应式方式使用 PHP 的库,以及如何在事件中使用,也称为发布-订阅编程。我们还将讨论 PHP 中的函数式编程的概念以及如何以更简洁的方式进行编程。我们还将讨论以下主题:

  • 映射

  • 减少

  • 延迟

  • 以下是响应式扩展的使用案例:

  • 日志数据分析(解析 Apache 日志)

  • 排队系统(异步处理任务队列)

  • 事件

响应式扩展是使用 PHP 以函数式方式编码的一种方式。它们是一组库(在 GitHub 上可用,网址为github.com/ReactiveX/RxPHP),可以帮助您使用 PHP 中的可观察集合和 LINQ 风格的查询操作来组合基于事件的程序。

可观察对象的介绍

简而言之,您将进行事件驱动的编程,其中您将使用所谓的事件循环,并附加(连接)事件来执行您的命令。

安装只需要一个简单的 composer。

响应式 PHP 是如何工作的?在 PHP 中,除了运行代码php -S localhost:8000之外,没有其他方式来创建服务器。PHP 将当前目录视为公共目录的基础(在 Apache 中,通常是/var/www或使用XAMPP时为C:/xampp/htdocs)。顺便说一句,这仅在 PHP 5.4.0 之后才可用,也适用于 PHP 7.x。

没有可编程的方式来控制 PHP 命令行界面服务器的实际工作方式。

每次向该服务器发送请求时,PHP 服务器将负责处理它是否是有效请求,并自行处理事件。简而言之,每个请求都是一个新请求-没有涉及流或事件。

RxPHP通过在底层创建一个 PHP 流来创建事件循环,该流具有帮助使响应式编程成为可能的附加函数。该流基本上具有一个递归函数(一个不断调用自身并创建命令循环的函数)。事件循环基本上是一个编程构造,运行一个无限循环,简单地等待事件并能够对每个事件做出反应(换句话说,运行一些函数)。

事件循环和 ReactiveX 的介绍

熟悉事件循环的最佳方法是通过 JavaScript 世界中的一个流行库,即 jQuery。

如果您有使用 jQuery 的经验,您可以简单地创建(或链接)事件到一个简单的 DOM 选择器,然后编写代码来处理这些特定事件。例如,您可以通过将其附加到特定链接上创建一个onClick事件,然后编写当单击该链接时会发生什么的代码。

如果您熟悉 jQuery,控制具有 IDsomeLink的链接的代码将如下所示:

HTML:

< a href="some url" id="someLink"> 

JavaScript:

$("#someLink").on('click', function() { 
   //some code here 
}); 

在前面的代码片段中,每当 jQuery 找到一个 ID 为someLink的元素时,它将在每次单击事件上执行某些操作。

由于它在事件循环中,它将循环遍历事件循环的每个迭代并处理需要完成的工作。

然而,在响应式编程中有一点不同,它是函数式编程的一种形式。函数式编程是关于尽可能保持函数的纯净,不产生副作用。函数式编程的另一个方面是不可变性,但我们将在另一个部分讨论这一点。

在响应式编程中,我们基本上有可观察对象观察者的概念。

可观察对象以数据形式发出事件,观察者订阅可观察对象以接收其事件。

使用响应式扩展进行编程的重点是能够以更功能化的方式进行编程。我们不再编写whilefor循环,而是调用一个事件循环,它将跟踪观察者和它们的 Observable(订阅者)。以这种方式提供信息的好处是,现在可以制作基于事件或事件驱动的程序,其中您的代码将做出反应。

有了这个,你可以创建在后台永远运行的程序,只是响应式扩展。

让我们讨论一些响应式扩展的可用函数:

  • 延迟

  • 延迟

  • 调度器

  • 递归调度器

  • mapflatMap

  • 减少

  • 转换为数组

  • 合并

  • 扫描

  • 压缩

延迟

在 RxPHP 中,delay函数的使用如下:

<?php 
require_once __DIR__ . '/../bootstrap.php'; 

$loop = new \React\EventLoop\StreamSelectLoop(); 

$scheduler  = new \Rx\Scheduler\EventLoopScheduler($loop); 

\Rx\Observable::interval(1000, $scheduler) 
    ->doOnNext(function ($x) { 
        echo "Side effect: " . $x . "\n"; 
    }) 
    ->delay(500) 
    ->take(5) 
    ->subscribe($createStdoutObserver(), $scheduler); 

$loop->run(); 

在前面的代码中,我们创建了一个EventLoopScheduler,它将帮助我们按 1000 毫秒的间隔安排代码的执行。延迟函数被给予 500 毫秒来执行,take 函数将在最终订阅之前只花费 5 毫秒。

延迟

defer函数在执行之前等待X次迭代才会执行:

<?php 

require_once __DIR__.'/../bootstrap.php'; 

$source = \Rx\Observable::defer(function () { 
    return \Rx\Observable::just(42); 
}); 

$subscription = $source->subscribe($stdoutObserver); 
?> 

在前面的代码中,我们创建了一个 Observable 对象,当调用defer函数时,它将返回 42。defer函数是一种承诺类型,返回一个 Observable,其中的代码将以异步方式执行。当 Observable 被订阅时,函数以一种方式绑定在一起,然后被调用触发

你可能会问,什么是 Observable?在 ReactiveX 中,观察者订阅 Observable。然后观察者对 Observable 发出的任何项或序列做出反应。

这意味着当您的应用程序收到一堆事件,但以异步方式处理它们时,不一定按照它们可能到达的顺序处理它们。

在前面的代码中,stdoutObserver是一个观察者,它将事件循环或 Observable 中的任何内容输出到stdout或控制台日志中。

调度器

调度器与三个主要组件一起工作:执行上下文,即执行给定任务的能力;执行策略是如何它将被排序;还有时钟或计时器或测量时间的基础系统,这是需要安排何时执行的。

调度器代码的使用方式如下:

$loop    = \React\EventLoop\Factory::create(); 
$scheduler = new \Rx\Scheduler\EventLoopScheduler($loop); 

它基本上创建了一个eventScheduler,执行事件循环并对并发级别进行参数化。在前面的延迟中使用了 RxPHP 中的简单调度器。

递归调度器

这就是递归调度函数的使用方式:

<?php 

require_once __DIR__ . '/../bootstrap.php'; 

use Rx\Observable; 

class RecursiveReturnObservable extends Observable 
{ 
    private $value; 

    /** 
     * @param mixed $value Value to return. 
     */ 
    public function __construct($value) 
    { 
        $this->value = $value; 
    } 

    public function subscribe(\Rx\ObserverInterface $observer, $scheduler = null) 
    { 
        return $scheduler->scheduleRecursive(function ($reschedule) use ($observer) { 
            $observer->onNext($this->value); 
            $reschedule(); 
        }); 
    } 
} 

$loop      = React\EventLoop\Factory::create(); 
$scheduler = new Rx\Scheduler\EventLoopScheduler($loop); 

$observable = new RecursiveReturnObservable(42); 
$observable->subscribe($stdoutObserver, $scheduler); 

$observable = new RecursiveReturnObservable(21); 
$disposable = $observable->subscribe($stdoutObserver, $scheduler); 

$loop->addPeriodicTimer(0.01, function () { 
    $memory    = memory_get_usage() / 1024; 
    $formatted = number_format($memory, 3) . 'K'; 
    echo "Current memory usage: {$formatted}\n"; 
}); 

// after a second we'll dispose the 21 observable 
$loop->addTimer(1.0, function () use ($disposable) { 
    echo "Disposing 21 observable.\n"; 
    $disposable->dispose(); 
}); 

$loop->run(); 

前面的代码通过添加几个调度器定时器,然后递归或重复返回一个 Observable,然后订阅它。前面的代码将生成 21 个 Observables。

1 秒后发生了什么:

//Next value: 21 
//Next value: 42 
//Next value: 21 
//Next value: 42 
//Next value: 21 

之后,它将处理 Observables 并最终打印出内存使用情况:

//Disposing 21 observable. 
//Next value: 42 
//Next value: 42 
//Next value: 42 
//Next value: 42 
//Next value: 42 
//Current memory usage: 3,349.203K 

映射和扁平映射

map是一个简单的函数,它接受另一个函数并循环遍历一堆元素(一个数组),并对每个元素应用或调用传递给这些元素的函数。

另一方面,flatMap也订阅 Observable,这意味着您不再需要关心。

减少

reduce函数简单地将一个函数应用于传入的 Observables。简而言之,它接受一堆 Observables,并以顺序方式将函数应用于所有这些 Observables,将一个应用于下一个结果。

以下是如何使用reduce函数的示例:

<?php 

require_once __DIR__ . '/../bootstrap.php'; 

//Without a seed 
$source = \Rx\Observable::fromArray(range(1, 3)); 

$subscription = $source 
    ->reduce(function ($acc, $x) { 
        return $acc + $x; 
    }) 
    ->subscribe($createStdoutObserver()); 

转换为数组

toArray函数允许您操作 Observables 并从中创建数组。使用toArray的代码如下:

<?php 

use Rx\Observer\CallbackObserver; 

require_once __DIR__ . '/../bootstrap.php'; 

$source = \Rx\Observable::fromArray([1, 2, 3, 4]); 

$observer = $createStdoutObserver(); 

$subscription = $source->toArray() 
    ->subscribe(new CallbackObserver( 
        function ($array) use ($observer) { 
            $observer->onNext(json_encode($array)); 
        }, 
        [$observer, "onError"], 
        [$observer, "onCompleted"] 
    )); 

在前面的代码中,我们首先基于数组[1,2,3,4]创建了一个 Observable。

这使我们能够使用数组的值并使用观察者订阅它们。在 ReactiveX 编程中,每个观察者只能与 Observables 一起工作。简而言之,toArray函数允许我们创建订阅源数组的观察者。

合并

merge函数只是一个操作符,它通过合并它们的发射将多个 Observables 合并为一个。

任何源 Observable 的onError通知都将立即传递给观察者。这将终止合并的 Observable:

<?php 

require_once __DIR__ . '/../bootstrap.php'; 

$loop      = React\EventLoop\Factory::create(); 
$scheduler = new Rx\Scheduler\EventLoopScheduler($loop); 

$observable       = Rx\Observable::just(42)->repeat(); 
$otherObservable  = Rx\Observable::just(21)->repeat(); 
$mergedObservable = $observable 
    ->merge($otherObservable) 
    ->take(10); 

$disposable = $mergedObservable->subscribe($stdoutObserver, $scheduler); 

$loop->run(); 

do

do函数只是在各种 Observable 生命周期事件上注册一个操作。基本上,您将注册回调,ReactiveX 只会在 Observable 中发生某些事件时调用这些回调。这些回调将独立于正常的通知集合调用。RxPHP 设计了各种操作符来允许这样做:

<?php 

require_once __DIR__ . '/../bootstrap.php'; 

$source = \Rx\Observable::range(0, 3) 
    ->doOnEach(new \Rx\Observer\CallbackObserver( 
        function ($x) { 
            echo 'Do Next:', $x, PHP_EOL; 
        }, 
        function (Exception $err) { 
            echo 'Do Error:', $err->getMessage(), PHP_EOL; 
        }, 
        function () { 
            echo 'Do Completed', PHP_EOL; 
        } 
    )); 

$subscription = $source->subscribe($stdoutObserver); 

scan

scan操作符对 Observable 发出的每个项目应用一个函数。它按顺序应用这个函数并发出每个连续的值:

<?php 

require_once __DIR__ . '/../bootstrap.php'; 

//With a seed 
$source = Rx\Observable::range(1, 3); 

$subscription = $source 
    ->scan(function ($acc, $x) { 
        return $acc * $x; 
    }, 1) 
    ->subscribe($createStdoutObserver()); 

这是一个没有种子的scan的例子:

<?php 

require_once __DIR__ . '/../bootstrap.php'; 

//Without a seed 
$source = Rx\Observable::range(1, 3); 

$subscription = $source 
    ->scan(function ($acc, $x) { 
        return $acc + $x; 
    }) 
    ->subscribe($createStdoutObserver()); 

zip

zip方法返回一个 Observable,并对按顺序发出的项目的组合应用您选择的函数。这个函数的结果将成为返回的 Observable 发出的项目:

<?php 

require_once __DIR__ . '/../bootstrap.php'; 

//With a result selector 
$range = \Rx\Observable::fromArray(range(0, 4)); 

$source = $range 
    ->zip([ 
        $range->skip(1), 
        $range->skip(2) 
    ], function ($s1, $s2, $s3) { 
        return $s1 . ':' . $s2 . ':' . $s3; 
    }); 

$observer = $createStdoutObserver(); 

$subscription = $source->subscribe($createStdoutObserver()); 

在以下示例代码中,我们使用zip而没有结果选择器:

<?php 

use Rx\Observer\CallbackObserver; 

require_once __DIR__ . '/../bootstrap.php'; 

//Without a result selector 
$range = \Rx\Observable::fromArray(range(0, 4)); 

$source = $range 
    ->zip([ 
        $range->skip(1), 
        $range->skip(2) 
    ]); 

$observer = $createStdoutObserver(); 

$subscription = $source 
    ->subscribe(new CallbackObserver( 
        function ($array) use ($observer) { 
            $observer->onNext(json_encode($array)); 
        }, 
        [$observer, "onError"], 
        [$observer, "onCompleted"] 
    )); 

通过 Reactive 调度程序解析日志

仅仅拥有 Reactive 扩展和函数式编程技术的理论知识而不知道何时可以使用它是困难的。为了应用我们的知识,让我们看看以下的情景。

假设我们需要以异步方式读取 Apache 日志文件。

Apache 日志行看起来像这样:

111.222.333.123 HOME - [01/Feb/1998:01:08:39 -0800] "GET /bannerad/ad.htm HTTP/1.0" 
200 198 "http://www.referrer.com/bannerad/ba_intro.htm""Mozilla/4.01 (Macintosh; I; PPC)" 

111.222.333.123 HOME - [01/Feb/1998:01:08:46 -0800] "GET /bannerad/ad.htm HTTP/1.0" 
200 28083 "http://www.referrer.com/bannerad/ba_intro.htm""Mozilla/4.01 (Macintosh; I; PPC)" 

让我们分解每行的部分。

首先,我们有 IP 地址。它在一些数字之间有三个点。其次,我们有记录服务器域的字段。

第三,我们有日期和时间。然后我们得到一个字符串,它说访问了什么,使用了什么 HTTP 协议。状态码是 200,后面是进程 ID,最后是请求者的名称,也称为引用者。

在读取 Apache 日志时,我们只想要 IP 地址、URL 和访问的日期和时间,还想知道使用了什么浏览器。

我们知道我们可以将数据分解成它们之间的空格,所以让我们将日志改为由以下方法分割的数组:

<?php 
function readLogData($pathToLog) { 
$logs = []; 
$data = split('\n', read($pathToLog);) //log newlines 

foreach($data as line) { 
$logLine = split('',$line); 
  $ipAddr = $logLine[0]; 
  $time = $logLine[3]; 
$accessedUrl = $logLine[6]; 
  $referrer = $logLine[11]; 
  $logs[] = [ 
'IP' => $ipAddr, 
'Time' => $time, 
'URL' => $accessedUrl, 
'UserAgent' => $referrer 
  ]; 

} 
return $logs; 
} 

让我们添加一个 Observable,以便我们可以异步执行前面的函数,这意味着它将通过每小时读取日志文件来工作。

代码看起来像这样:

$loop      = React\EventLoop\StreamSelectLoop; 
$scheduler = new Rx\Scheduler\EventLoopScheduler($loop); 

$intervalScheduler = \Rx\Observable::interval(3600000, $scheduler); 

//execute function to read logFile: 
$intervalScheduler::defer(function() { 
readLogData('/var/log/apache2/access.log'); 
})->subscribe($createStdoutObserver()); 

ReactiveX 中的事件队列

事件队列只需确保以同步方式或先进先出方式完成的事情。让我们首先定义队列是什么。

队列基本上是一个要做的事情列表,它将一个接一个地执行,直到队列中的所有事情都完成了。

在 Laravel 中,例如,已经有了队列的概念,我们遍历队列的元素。您可以在laravel.com/docs/5.0/queues找到文档。

队列通常用于需要按顺序执行一些任务而不是在异步函数中执行的系统。在 PHP 中,已经有了SplQueue类,它使用双向链表实现了队列的主要功能。

一般来说,队列按照它们的顺序执行。在 ReactiveX 中,事情更多的是异步的。在这种情况下,我们将实现一个优先级队列,其中每个任务都有相应的优先级。

这是 ReactiveX 中一个简单的PriorityQueue代码:

use \Rx\Scheduler\PriorityQueue; 

Var $firstItem = new ScheduledItem(null, null, null, 1, null); 

var $secondtItem = new ScheduledItem(null, null, null, 2, null); 
$queue          = new PriorityQueue(); 
$queue->enqueue($firstItem); 
$queue->enqueue($secondItem); 
//remove firstItem if not needed in queue 
$queue->remove($firstItem); 

在前面的代码中,我们使用了 RxPHP 的PriorityQueue库。我们设置了一些调度程序,并将它们排入了PriorityQueue中。我们为每个调度的项目分配了优先级或执行时间,分别为 1 和 2。在前面的场景中,第一个项目将首先执行,因为它具有最高的优先级并且执行时间最短(1)。最后,我们移除了ScheduledItem,只是为了展示在 RxPHP 库中PriorityQueue的可能性。

总结

您学会了如何使用响应式扩展库 RxPHP。响应式编程主要是使用 Observables 和 Observers,这类似于使用订阅者和发布者进行工作。

您学会了如何使用delaydefermapflatMap等操作符,以及如何使用调度程序。

您还学会了如何读取 Apache 日志文件并安排在每小时后进行读取,以及如何使用 RxPHP 的PriorityQueue类。

posted @ 2024-05-05 00:11  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报