三、创建 RESTful 端点

到目前为止,我们已经了解了什么是 RESTful web 服务。我们还看到了 PHP7 中的新特性,这些特性将使我们的代码更好、更干净。现在,是在 PHP 中实现 RESTful web 服务的时候了。所以,这一章是关于实现的。

我们已经看到了一个博客的例子,其中包含博客帖子和评论端点。在本章中,我们将实现这些端点。以下是我们将讨论的主题:

  • 在 PHP 中为博客创建 RESTAPI
    • 创建数据库架构
      • 博客用户/作者表模式
      • 博客文章表模式
      • 博客帖子评论模式
    • 正在创建 REST API 的端点

      • 代码结构
      • 公共组件
      • 创建博客文章端点
      • 明显的缺陷
        • 验证
        • 认证
        • 404 页
      • 总结

用 PHP 为博客创建 RESTAPI

要为博客创建 RESTAPI 或 RESTfulWeb 服务,我们首先需要有博客实体。当我们将博客实体存储在数据库中并从数据库中获取数据时,我们首先需要为这些实体创建一个数据库模式。

创建数据库架构

我们将为两个资源/实体创建端点,它们是:

  • 博文
  • 发表评论

因此,我们将为这两个资源创建一个数据库模式。

下面是我们将如何为包含帖子和评论的博客设计一个数据库模式。一篇帖子可以有多条评论,而一条评论总是属于帖子。这里,我们有数据库模式的 SQL。您首先需要创建一个数据库,并且需要运行以下 SQL 来创建 posts 和 comments 表。如果尚未创建数据库,请立即创建。您可以通过一些 DB UI 工具创建它,也可以运行以下 SQL 查询:

create DATABASE blog;

这将创建一个名为blog的数据库。

在创建博客帖子表和博客帖子评论表之前,我们需要创建一个用户表,用于存储帖子或评论作者的信息。首先,让我们创建一个 users 表。

博客用户/作者表模式

用户表可以包含以下字段:

  • id:它将具有 integer 类型,该类型将是唯一的,并且将具有自动递增的值。id将是用户表的主键。

  • name:类型为VARCHAR,长度为 100 个字符。在VARCHAR100 的情况下,限制为 100 个字符。如果一个条目中的标题少于 100 个字符,比如说只有 13 个字符,那么它将占用 14 个字符的空间。这就是VARCHAR的工作原理。它比值中的实际字符多占用一个字符。

  • email:电子邮件地址的类型为VARCHAR,长度为 50。电子邮件字段将是唯一的。

  • password:密码类型为VARCHAR,长度为 50。我们将有password字段,因为稍后,在某个阶段,我们将使用emailpassword让用户登录。

可以有更多的字段,但为了简单起见,我们现在只保留这些字段。

用户 SQL 表

以下是users表的 SQL。注意,在我们的示例中,我们使用 MySQL 作为 RDBMS。对其他数据库的查询可能会有轻微更改:

CREATE TABLE `blog`.`users` (
 `id` INT NOT NULL AUTO_INCREMENT ,
 `name` VARCHAR(100) NOT NULL ,
 `email` VARCHAR(50) NOT NULL ,
 `password` VARCHAR(50) NOT NULL ,
 PRIMARY KEY (`id`), 
 UNIQUE `email_unique` (`email`))
ENGINE = InnoDB;

此查询将创建如上所述的 posts 表。我们唯一没有讨论的是数据库引擎。此查询的最后一行ENGINE = InnoDB将此表的数据库引擎设置为InnoDB。另外,在第 1 行,blog表示数据库的名称。如果您将数据库命名为 blog 以外的其他名称,请将其替换为您的数据库名称。

我们只为帖子和评论编写 API 的端点,不为用户编写端点,因此我们将使用 SQL Insert 查询手动向 users 表添加数据。

以下是用于填充users表的 SQL Insert 查询:

INSERT INTO `users` (`id`, `name`, `email`, `password`)
 VALUES 
(NULL, 'Haafiz', 'kaasib@gmail.com', '$2y$10$ZGZkZmVyZXJlM2ZkZjM0Z.rUgJrCXgyCgUfAG1ds6ziWC8pgLiZ0m'), 
(NULL, 'Ali', 'abc@email.com', '$2y$10$ZGZkZmVyZXJlM2ZkZjM0Z.rUgJrCXgyCgUfAG1ds6ziWC8pgLiZ0m');

当我们插入两条记录时,分别有nameemailpassword,我们将id设置为null。因为它是自动递增的,所以会自动设置。此外,您可以在两个记录中看到一个长的随机字符串。此随机字符串是密码。我们为两个用户设置了相同的密码。但是,用户将不会输入此随机字符串作为密码。此随机字符串根据用户的实际密码版本进行加密。用户密码为qwerty。此密码使用以下 PHP 代码加密:

password_hash("qwerty", PASSWORD_DEFAULT, ['salt'=>'dfdferere3fdf34dfdfdsfdnuJ$er']);
/* returns $2y$10$ZGZkZmVyZXJlM2ZkZjM0Z.rUgJrCXgyCgUfAG1ds6ziWC8pgLiZ0m
*/

password_hash()函数是 PHP 推荐的密码加密函数。第一个参数是一个password字符串。第二个参数是加密算法。而第三个参数是一个 options 数组,其中我们将一个随机字符串设置为 salt。你也可以加一种不同的盐。

但是,每次加密密码时都需要修复此 salt,因为此加密是单向加密。这意味着密码无法解密。因此,每次需要匹配密码时,都必须加密用户提供的密码并将其与数据库中的密码匹配。为了匹配用户提供的密码和数据库中的密码,我们需要使用具有相同参数的相同密码函数。

我们现在不提供用户登录功能,但是,我们稍后会这样做。

博客文章表模式

博客文章可以包含以下字段:

  • id:类型为整数。它将是唯一的,并且具有自动递增的值。id将是博客帖子的主键。

  • title:类型为varchar,长度为 100 个字符。在varchar100 的情况下,限制为 100 个字符。如果一篇文章的标题少于 100 个字符,假设一篇文章的标题只需要 13 个字符,那么它将占用 14 个字符的空间。这就是varchar的工作原理。它比字段中的实际字符多占用一个字符。

  • status:状态为发布或草稿。我们将使用enum来完成它。它有两个可能的值,publisheddraft

  • content:内容将作为帖子的主体。我们将使用text数据类型作为内容。

  • user_iduser_id将为整型。它将是一个外键,并与用户表中的id相关。此用户将是博客文章的作者。

为了简单起见,我们只有这五个字段。user_id将包含文章作者用户的信息。

以下是用于创建 posts 表的 SQL 查询:

以下是 posts 表的 SQL。注意,在我们的示例中,我们使用 MySQL 作为 RDBMS。对其他数据库的查询可能会有轻微更改:

CREATE TABLE `blog`.`posts` ( 
 `id` INT NOT NULL AUTO_INCREMENT ,
 `title` VARCHAR(100) NOT NULL , 
 `status` ENUM('draft', 'published') NOT NULL DEFAULT 'draft' ,
 `content` TEXT NOT NULL ,
 `user_id` INT NOT NULL ,
 PRIMARY KEY (`id`), INDEX('user_id')
) 
ENGINE = InnoDB;

此查询将创建一个 posts 表,如前所述。

现在,我们添加外键来限制user_id只包含用户表中存在的值。下面是我们将如何添加该约束:

ALTER TABLE `posts` 
ADD CONSTRAINT `user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE RESTRICT;

博客帖子评论模式

博客帖子评论可以包含以下字段:

  • id:将具有integer类型。它将是唯一的,并具有自动递增的值。id将是博客帖子的主键。

  • comment:类型为varchar,长度为250个字符。

  • post_idpost_id将为整型。它将是 posts 表中与id相关的外键。

  • user_iduser_idinteger类型,为外键,与用户表中的id相关。

这里,user_id是评论作者的 ID,post_id是发表评论的帖子的 ID。

下面是一个创建comments表的 SQL 查询:

CREATE TABLE `blog`.`comments` ( 
 `id` INT NOT NULL AUTO_INCREMENT ,
 `comment` VARCHAR(250) NOT NULL ,
 `post_id` INT NOT NULL ,
 `user_id` INT NOT NULL ,
 PRIMARY KEY (`id`), INDEX(`post_id`), INDEX(`user_id`)
) ENGINE = InnoDB;

user_idpost_id添加外键约束:

ALTER TABLE `comments` ADD CONSTRAINT `post_id_comment_foreign` FOREIGN KEY (`post_id`) REFERENCES `posts`(`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; 

ALTER TABLE `comments` ADD CONSTRAINT `user_id_comment_foreign` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE RESTRICT;

通过运行所有这些 SQL 查询,您将设置大部分 DB 结构,以继续在 PHP 中创建 RESTful API 的端点。

创建 RESTful API 的端点

在创建特定于资源的 RESTful API 端点之前,让我们先创建目录,将代码放在其中。在某处创建一个blog目录,即您的home目录,以防 Linux 更好。然后,在blog目录中创建一个api目录。我们将把所有代码放在api目录中。如果您是命令行爱好者或经验丰富的 Ubuntu 用户,只需运行以下命令即可创建这些目录:

$ mkdir ~/blog //create blog directory
$ cd ~/blog //chang directory to blog directory
$ mkdir api //create api directory inside blog directory ~/blog
$ cd api //change directory to api directory

所以,api是我们放置代码的目录。如您所知,我们将为与两个资源相关的端点编写代码:博客帖子和帖子评论。在继续编写特定于博客文章的代码之前,让我们先看看如何构造代码。

代码结构

代码可以用多种方式编写。我们可以为帖子和评论创建不同的文件,比如posts.phpcomments.php,让用户通过 URL 访问它们;例如,用户可以写:http://localhost:8000/posts.php 可以执行posts.php中的代码。在comments.php中也可以这样做。

这是一种非常简单的方法,但有两个问题:

  • 第一个问题是posts.phpcomments.php将有不同的代码。这意味着,如果我们必须在这些不同的文件中使用相同的代码,我们将需要在这两个文件中编写或包含所有公共内容。事实上,如果有更多的资源,那么我们需要为每个资源创建一个不同的文件,并且在每个新文件中,我们需要包含所有公共代码。尽管目前只有两种资源,但我们还需要考虑可扩展性。因此,在这种方法中,我们需要在所有文件中使用相同的代码。即使我们只是在做 include 或 require,我们也需要在所有文件中这样做。然而,这可以通过包含或要求的最小文件来解决或最小化。
  • 第二个问题与它在 URL 中的外观有关。URL 中提到了要使用的事实文件,因此,如果在完成端点并将 API 提供给前端开发人员之后,我们需要更改服务器上的文件名,该怎么办?来自前端应用程序的 web 服务将无法工作,除非我们在前端应用程序的 URL 中更改文件名。这就指向了一个重要的问题,即关于我们的请求以及如何在服务器上存储内容。这意味着我们的代码将紧密耦合。正如我们在第 1 章RESTful Web 服务、介绍和动机中的 REST 约束中所述,这不应该发生。这个.php扩展不仅暴露了我们在服务器端使用 PHP,而且我们的文件结构也暴露给了所有知道端点 URL 的人。

问题 1 的解决方案可以是 include 和 require 语句。尽管,所有文件中仍然需要 require 或 include 语句,如果需要在一个文件中更改一个 include 语句,我们将需要在所有文件中进行更改。因此,这不是一个好办法,但第一个问题是可以解决的。然而,第二个问题更为关键。一些使用 Apache 的.htaccess文件进行 URL 重写的人可能会认为 URL 重写可以解决问题。是的,它可以解决请求 URL 和文件系统上的文件之间的紧密耦合问题,但只有当我们使用 Apache 作为服务器时,它才能工作。

然而,随着时间的推移,您将看到越来越多的用例,并且您将意识到这种方式不是非常可伸缩的。在本文中,除了在所有资源文件中包含相同的代码之外,我们并没有遵循某种模式。此外,使用.htaccess重写 URL 可能可行,但不建议将其用作完整的路由器,因为它有其自身的局限性。

那么这个问题的解决方案是什么呢?如果我们能有一个单一的入口呢?如果所有请求都通过同一个入口点,然后路由到相应的代码,该怎么办?这将是一个更好的办法。请求将与帖子或评论相关,它必须通过同一个入口点,在该入口点,我们可以包含我们想要的任何代码。然后,该入口点将请求路由到相应的代码。这将解决这两个问题。此外,事物将处于一种模式中,因为每个资源的代码将遵循相同的模式。我们刚才讨论的这个模式也称为前端控制器。您可以在 wiki 上阅读有关前端控制器的更多信息:https://en.wikipedia.org/wiki/Front_controller

现在我们知道我们将使用前端控制器模式,因此,我们的入口点将是index.php文件。那么让我们在api目录中创建index.php。现在,让我们放一个 echo 语句,这样我们就可以使用 PHP 内置服务器测试和运行并查看至少hello world。稍后,我们将在index.php文件中添加适当的内容。所以,现在把这个放在index.php中:

<?php

echo "hello World through PHP built-in server";

要测试它,您需要运行 PHP 内置服务器。注意,运行 PHP 代码不需要 Apache 或 NGINX。PHP 有一个内置的服务器,虽然这对测试和开发环境有好处,但不建议在生产环境中使用。由于我们在本地计算机上的开发环境中,让我们运行它:

~/blog/api$ php -S localhost:8000

这将让您点击http://localhost:8000并通过 PHP 内置服务器输出hello World。现在,我们已经准备好开始编写实际的代码,让 RESTful 端点工作。

公共组件

在进入端点之前,让我们首先确定并解决为所有端点提供服务所需的问题。以下是这些东西:

  • 错误报告设置
  • 数据库连接
  • 路由

打开index.php,删除旧的 hello world 代码并将此代码放入index.php文件:

<?php

ini_set('display_errors', 1);
error_reporting(E_ALL);

require __DIR__."/../core/bootstrap.php";

在前两行中,我们基本上确保如果代码中有错误,我们可以看到错误。实际的魔法发生在最后一句话中,我们需要bootstrap.php

这只是我们将在~/blog/core目录中创建的另一个文件。在 blog 目录中,我们将创建一个核心目录,因为我们将在核心目录中保留与代码执行流程和模式相关的部分代码。它将是与我们的 API 的端点或逻辑无关的代码。这个核心代码将被创建一次,我们可以在不同的应用程序中使用相同的核心。

那么,让我们在blog/core目录中创建bootstrap.php。以下是我们将在bootstrap.php中写的内容:

<?php

require __DIR__.'/DB.php';
require __DIR__.'/Router.php';
require __DIR__.'/../routes.php';
require __DIR__ .'/../config.php';

$router = new Router;
$router->setRoutes($routes);

$url = $_SERVER['REQUEST_URI'];
require __DIR__."/../api/".$router->direct($url);

基本上,这将加载所有内容并执行。bootstrap.php是我们的应用程序将如何运行的结构。所以让我们深入研究一下。

第一条语句需要来自同一目录的DB类,即核心目录。DB类也是一个核心类,它将负责 DB 相关的东西。第二条语句需要一个路由器,将 URL 定向到正确的文件。而第三种方法需要路由来告诉在哪个 URL 的情况下服务哪个文件。

我们将逐一研究DBRouter类,但让我们先看看routes.php是否指定了路由。请注意,routes.php是特定于应用程序的,因此其内容将根据我们的应用程序 URL 而有所不同。

以下是blog/routes.php的内容:

<?php

$routes = [
    'posts' => 'posts.php',
    'comments' => 'comments.php'
];

你可以看到它只是填充了一个$routes数组。在这里,帖子和评论是我们期望的 URL 的一部分,如果 URL 中有帖子,它将服务于posts.php文件,如果 URL 中有评论,它将服务于comments.php

bootstrap.php中的第四个要求是具有DB设置等应用配置。以下是blog/config.php的样本内容:

<?php
/**
 * Config File
 */
$db = [
    'host' => 'localhost',
    'username' => 'root',
    'password' => '786'
];

现在,让我们逐一看看DBRouter类,这样我们就可以了解blog/core/bootstrap.php中到底发生了什么。

数据库类

以下是blog/core/DB.phpDB类的代码:

<?php

class DB {

    function connect($db)
    {
        try {
            $conn = new PDO("mysql:host={$db['host']};dbname=blog", $db['username'], $db['password']);

            // set the PDO error mode to exception
            $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

            return $conn;
        } catch (PDOException $exception) {
            exit($exception->getMessage());
        }
    }
}

此类与数据库相关。现在,我们有一个构造函数,它实际上使用blog/config.php中定义的PDO$db数组连接到数据库。但是,稍后我们将在这个类中添加更多内容。

您可以看到我们在这里使用了一个PDO对象:PDOPHP 数据对象。它用于与数据库交互,是推荐的,因为我们想使用哪个数据库并不重要,我们只需要更改连接字符串,其余的就可以正常工作。此字符串:"mysql:host=$host;dbname=blog"是连接字符串。DB.php中的代码将创建与数据库的连接,该连接将在脚本结束时关闭。我们在这里使用了try catch,因为当我们的代码之外的任何东西被触发时,使用异常处理是很好的。

到目前为止,我们已经研究了DB类、routes.php(路由关联数组)和config.php(设置关联数组)。现在我们需要了解一下Router课程的内容。

路由器类

下面是位于blog/core/Router.phpRouter类的实现:

<?php

class Router {

    private $routes = [];

    function setRoutes(Array $routes) {
        $this->routes = $routes;
    }

    function getFilename(string $url) {
        foreach($this->routes as $route => $file) {
            if(strpos($url, $route) !== false){
                return $file;
            }
        }
    }
}

RouterRouter::setRoutes(Array $routes)Router::getFilename()两种方式。setRoutes()正在采取一系列路线并将其存储。然后,getFilename()方法负责决定针对哪个 URL 服务哪个文件。我们不是在比较整个 URL,而是使用strpos()检查$route中的字符串是否存在于$url中,如果存在,则返回相应的文件名。

代码同步

为了确保我们在同一页上,以下是您的blog目录中应该包含的内容:

  • blog

    • blog/config.php
    • blog/routes.php
    • blog/core
      • blog/core/DB.php
      • blog/core/Router.php
      • blog/core/bootstrap.php
    • blog/api
      • blog/api/index.php
      • blog/api/posts.php

注意,blog/api/posts.php到目前为止没有任何合适的内容,因此您可以保留任何可以在浏览器中查看的内容,以便您知道此内容来自posts.php。除此之外,如果您缺少任何内容,请将其与本book.boostrap.php评论中提供给您的代码进行比较。

无论如何,您已经看到了bootstrap.php中包含的所有文件的内容,因此现在您可以回顾bootstrap.php代码来更好地理解事情。将再次放置该内容,以便您可以看到它:

<?php

require __DIR__ . '/DB.php';
require __DIR__.'/Router.php';
require __DIR__.'/../routes.php';

$router = new Router;
$router->setRoutes($routes);

$url = $_SERVER['REQUEST_URI'];
require __DIR__."/../api/".$router->getFilename($url);

正如您所看到的,这只是包括configroutes文件,还包括RouterDB类。这里是设置$routes中的路线,如routes.php中所述。然后,基于 URL,它将获得文件名,该文件名将服务于该 URL 并需要该文件。我们正在使用$_SERVER['REQUEST_URI'];它是一个超级全局变量,其 URL 路径位于主机名之后。

到目前为止,我们已经完成了通用的代码制作应用程序结构。现在,如果您的blog/api/posts.php包含像我的posts.php这样的代码:

<?php

echo "Posts will come here";

在启动 PHP 服务器时说:php -S localhost:8000,然后在浏览器中点击:http://localhost:8000/posts,您应该会看到:帖子会出现在这里

如果您无法运行它,我建议您回去检查您遗漏的内容。您也可以使用本书中提供的代码。在任何情况下,此时编写并成功运行此代码都很重要,因为仅仅阅读是不够的,实践会让您变得更好。

创建博客文章端点

到目前为止,我们已经完成了大多数通用代码。让我们看看博客帖子端点。在博客文章端点中,第一个是博客文章列表。

列出端点的博客帖子:

  • URI:/api/posts
  • 方法:GET

因此,让我们用合适的代码来替换posts.php中先前的代码,以便为 POST 服务。为了实现这一点,在posts.php文件中输入以下代码:

<?php

$url = $_SERVER['REQUEST_URI'];

// checking if slash is first character in route otherwise add it
if(strpos($url,"/") !== 0){
    $url = "/$url";
}

if($url == '/posts' && $_SERVER['REQUEST_METHOD'] == 'GET') {
    $posts = getAllPosts();
    echo json_encode($posts);
}

function getAllPosts() {
    return [
        [
            'id' => 1,
            'title' => 'First Post',
            'content' => 'It is all about PHP'
        ],
        [
            'id' => 2,
            'title' => 'Second Post',
            'content' => 'RESTful web services'
        ],
    ];
}

这里,我们正在检查方法是否为GET,URL 是否为/posts,并且我们正在从名为getAllPosts()的函数获取数据。为了简单起见,我们从硬编码数组而不是数据库中获取数据。然而,我们实际上需要从数据库中获取数据。让我们添加从数据库获取数据的代码。下面是它的外观:

<?php

$url = $_SERVER['REQUEST_URI'];
// checking if slash is first character in route otherwise add it
if(strpos($url,"/") !== 0){
 $url = "/$url";
}
 $dbInstance = new DB();
$dbConn = $dbInstance->connect($db);

if($url == '/posts' && $_SERVER['REQUEST_METHOD'] == 'GET') {
 $posts = getAllPosts($dbConn);
 echo json_encode($posts);
}

;;
function getAllPosts($db) {
 $statement = $db->prepare("SELECT * FROM posts");
 $statement->execute();
 $result = $statement->setFetchMode(PDO::FETCH_ASSOC);
 return $statement->fetchAll();
}

如果执行此代码,将得到 JSON 格式的空数组,这很好。此时将显示一个空数组,因为 posts 表中没有记录。让我们创建并使用 addpost 端点。

博客帖子创建端点:

  • URI:/api/posts
  • 方法:POST
  • 参数:titlestatuscontentuser_id

现在,我们只是让这些端点在没有用户身份验证的情况下工作,所以我们自己通过user_id。所以,它应该是 users 表中的id

要使其正常工作,我们需要添加posts.php。然后以粗体显示新代码:

<?php

$url = $_SERVER['REQUEST_URI'];

// checking if slash is first character in route otherwise add it
if(strpos($url,"/") !== 0){
 $url = "/$url";
}

$dbInstance = new DB();
$dbConn = $dbInstance->connect($db);

if($url == '/posts' && $_SERVER['REQUEST_METHOD'] == 'GET') {
 $posts = getAllPosts($dbConn);
 echo json_encode($posts);
}

if($url == '/posts' && $_SERVER['REQUEST_METHOD'] == 'POST') {
 $input = $_POST;
 $postId = addPost($input, $dbConn);
 if($postId){
     $input['id'] = $postId;
     $input['link'] = "/posts/$postId";
 }

 echo json_encode($input); }

function getAllPosts($db) {
 $statement = $db->prepare("SELECT * FROM posts");
 $statement->execute();
 $result = $statement->setFetchMode(PDO::FETCH_ASSOC);
 return $statement->fetchAll();
}
 function addPost($input, $db){
 $sql = "INSERT INTO posts 
 (title, status, content, user_id) 
 VALUES 
 (:title, :status, :content, :user_id)";

 $statement = $db->prepare($sql);

 $statement->bindValue(':title', $input['title']);
 $statement->bindValue(':status', $input['status']);
 $statement->bindValue(':content', $input['content']);
 $statement->bindValue(':user_id', $input['user_id']);

 $statement->execute();

 return $db->lastInsertId();
}

如您所见,我们已经放置了另一个检查,因此如果该方法将是POST,那么它将运行addPost()方法。在addPost()方法中,增加了POST。我们使用了相同的PDO准备和执行语句。

不过,这次我们也使用了bindValue()。首先,我们在带有冒号的INSERT语句中添加一个静态字符串,如:title, :status,然后使用 bind 语句将变量与这些静态字符串绑定。那么这样做的目的是什么呢?原因是我们不能相信用户的输入。在 SQL 查询中直接添加用户输入可能导致 SQL 注入。所以为了避免 SQL 注入,我们可以将PDO::prepare()函数与PDOStatement::bindValue()一起使用。在prepare()函数中,我们提供了一个字符串,而bindValue()将用户输入与该字符串绑定。因此,这个PDOStatement::bindValue()不仅用输入参数替换这些字符串,而且确保不会发生 SQL 注入。

我们也使用了PDO::lastInsertId()。这是为了返回刚刚创建的记录的自动递增的id

addPost()方法中,我们针对不同领域重复使用bindValue()方法。如果会有更多的字段,那么我们可能需要重复写更多次。为了避免这种情况,我们将addPost()方法代码更改为:

function addPost($input, $db){

    $sql = "INSERT INTO posts 
          (title, status, content, user_id) 
          VALUES 
          (:title, :status, :content, :user_id)";

    $statement = $db->prepare($sql);

 bindAllValues($statement, $input);

    $statement->execute();

    return $db->lastInsertId();
}

您可以看到PDOStatement::bindValue()调用被替换为一个bindAllValues()函数调用,该函数调用以PDOStatement作为第一个参数,用户输入作为第二个参数。bindAllValues()是我们编写的一个自定义函数,下面是我们将在同一posts.php文件中编写的bindAllValues()方法的一个实现:

function bindAllValues($statement, $params){
    $allowedFields = ['title', 'status', 'content', 'user_id'];

    foreach($params as $param => $value){
        if(in_array($param, $allowedFields)){
            $statement->bindValue(':'.$param, $value);
        }
    }

    return $statement;
}

因为我们已经将它作为一个单独的泛型函数编写,所以我们可以在多个地方使用它。此外,无论 posts 表中有多少字段,我们都不需要在代码中重复调用相同的PDOStatement::bindValue()方法。我们只需在$allowedFields数组中添加更多字段,bindValue()方法将被自动调用。

为了测试POST请求,我们不能简单地从浏览器中点击 URL。为了测试一个POST请求,我们需要使用某种 REST 客户端,或者使用POST创建并提交一个表单。REST 客户端是一种更好、更简单、更简单的方法。

REST 客户端

一个非常受欢迎的 REST 客户是邮递员。Postman 是一款谷歌 Chrome 应用程序。如果您使用的是 Chrome,那么您可以从此处安装此应用程序:https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop/related?hl=en

打开 Postman 后,您将能够选择方法作为 POST 或任何其他方法,并且在选择 Body 选项卡时,您将能够设置字段名称和值,然后单击 Send。检查以下设置了字段和响应的邮递员屏幕截图。这将让您了解如何使用邮递员处理邮件请求:

您可以看到,POST 请求已通过此邮递员发送,其结果如我们预期的那样成功。对于所有端点测试,可以使用 Postman。

在运行这个基于 POST 的 POST 创建端点之后,我们可以再次测试 POST 端点的列表,这次它将返回数据,因为现在有一个 POST。

让我们研究获取单个帖子、更新帖子和删除帖子的端点。

获取单个 post 端点:

  • URI:/api/posts/{id}
  • 方法:GET

这个带有GET方法的 URL 应该根据提供的 ID 返回一篇文章。

要实现这一目标,我们需要做两件事:

  • Add方法为GET且 URL 为该模式的条件。
  • 我们需要编写并调用从数据库中获取单个帖子的getPost()方法。

我们需要在posts.php中添加以下代码。

首先,我们将添加一个条件和代码以返回单个 post:

if(preg_match("/posts\/([0-9])+/", $url, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET'){
    $postId = $matches[1];
    $post = getPost($dbConn, $postId);

    echo json_encode($post);
}

这里,我们正在检查模式是否为/posts/{id},其中id可以是任何数字。然后我们调用自定义函数getPost(),从数据库中获取 post 记录。下面是我们将在同一个posts.php文件中添加的getPost()实现:

function getPost($db, $id) {
    $statement = $db->prepare("SELECT * FROM posts where id=:id");
    $statement->bindValue(':id', $id);
    $statement->execute();

    return $statement->fetch(PDO::FETCH_ASSOC);
}

这段代码只是从数据库中提取一条记录作为关联数组,从最后一行可以清楚地看到。除此之外,SELECT查询及其执行非常简单。

更新 post 端点:

  • URI:/api/posts/{id}
  • 方法:PATCH
  • 参数:titlestatuscontentuser_id

这里的{id}将被实际帖子的 ID 替换。注意,由于我们使用的是PATCH方法,因此只需要更新输入方法中出现的那些属性。

Here we are passing user_id as a parameter but it is just because we don't have authentication working otherwise it is strictly prohibited to pass user_id as parameter. user_id should be the id of authenticated user and that should be used instead of getting user_id in parameter. Because it can let any user pretend to be someone else by passing another user_id in parameter.

请注意,当使用PUTPATCH时,参数应该通过查询字符串传递,只有POST在主体中有参数。

让我们更新我们的代码posts.php以支持更新操作,然后我们将对此进行更多研究。

以下是posts.php中需要添加的代码:

//Code to update post, if /posts/{id} and method is PATCH

if(preg_match("/posts\/([0-9])+/", $url, $matches) && $_SERVER['REQUEST_METHOD'] == 'PATCH'){
    $input = $_GET;
    $postId = $matches[1];
    updatePost($input, $dbConn, $postId);

    $post = getPost($dbConn, $postId);
    echo json_encode($post);
}

/**
 * Get fields as parameters to set in record
 *
 * @param $input
 * @return string
 */
function getParams($input) {
    $allowedFields = ['title', 'status', 'content', 'user_id'];

    $filterParams = [];
    foreach($input as $param => $value){
        if(in_array($param, $allowedFields)){
            $filterParams[] = "$param=:$param";
        }
    }

    return implode(", ", $filterParams);
}

/**
 * Update Post
 *
 * @param $input
 * @param $db
 * @param $postId
 * @return integer
 */
function updatePost($input, $db, $postId){

    $fields = getParams($input);

    $sql = "
          UPDATE posts 
          SET $fields 
          WHERE id=':postId'
           ";

    $statement = $db->prepare($sql);
    $statement->bindValue(':id', $id);
    bindAllValues($statement, $input);

    $statement->execute();

    return $postId;
}

首先检查 URL 是否为/posts/{id}格式,然后检查Request方法是否为PATCH。在这种情况下,它调用updatePost()方法。updatePost()方法通过getParams()方法获取以逗号分隔的字符串形式的键值对。然后进行查询、绑定值和postId。这与INSERT方法非常相似。然后在条件块中,我们回显更新的记录的 JSON 编码形式。这与我们在创建帖子时所做的非常相似,只得到一篇帖子。

您应该注意的一件事是,我们从$_GET获取的参数是查询字符串。这是因为在PATCHPUT的情况下,参数是以查询字符串的形式传递的。因此,在通过 Postman 或任何其他 REST 客户端进行测试时,我们需要在查询字符串中传递参数,而不是在正文中传递参数。

删除 post 端点:

  • URI:/api/posts/{id}
  • 方法:DELETE

这与获取单个博客帖子端点非常相似,但这里的方法是DELETE,因此记录将被删除而不是查看。

以下是添加posts.php删除博客帖子记录的代码:

//if url is like /posts/{id} (id is integer) and method is DELETE

if(preg_match("/posts\/([0-9])+/", $url, $matches) && $_SERVER['REQUEST_METHOD'] == 'DELETE'){
    $postId = $matches[1];
    deletePost($dbConn, $postId);

    echo json_encode([
        'id'=> $postId,
        'deleted'=> 'true'
    ]);
}

/**
 * Delete Post record based on ID
 *
 * @param $db
 * @param $id
 */
function deletePost($db, $id) {    
    $statement = $db->prepare("DELETE FROM posts where id=':id'");
    $statement->bindValue(':id', $id);
    $statement->execute();
}

在查看了 insert、get 和 update post 的端点代码之后,这段代码非常简单。在这里,主要工作是在deletePost()方法中,但它也与其他方法非常相似。

至此,我们已经完成了与端点相关的帖子。然而,现在我们返回的所有数据都是 JSON,而不是用于客户端(浏览器或邮递员)的 JSON。它仍然将其视为字符串,并将其视为 HTML。这是因为我们返回的是 JSON,但它仍然是一个字符串。为了告诉客户机将其作为 JSON,我们需要在任何输出之前在头文件中指定Content-Type

header("Content-Type:application/json");

为了确保我们的posts.php文件相同,这里有一个完整的posts.php代码:

<?php

$url = $_SERVER['REQUEST_URI'];
if(strpos($url,"/") !== 0){
    $url = "/$url";
}
$urlArr = explode("/", $url);

$dbInstance = new DB();
$dbConn = $dbInstance->connect($db);

header("Content-Type:application/json");

if($url == '/posts' && $_SERVER['REQUEST_METHOD'] == 'GET') {
    $posts = getAllPosts($dbConn);
    echo json_encode($posts);
}

if($url == '/posts' && $_SERVER['REQUEST_METHOD'] == 'POST') {
    $input = $_POST;
    $postId = addPost($input, $dbConn);
    if($postId){
        $input['id'] = $postId;
        $input['link'] = "/posts/$postId";
    }

    echo json_encode($input);

}

if(preg_match("/posts\/([0-9])+/", $url, $matches) && $_SERVER['REQUEST_METHOD'] == 'PUT'){
    $input = $_GET;
    $postId = $matches[1];
    updatePost($input, $dbConn, $postId);

    $post = getPost($dbConn, $postId);
    echo json_encode($post);
}

if(preg_match("/posts\/([0-9])+/", $url, $matches) && $_SERVER['REQUEST_METHOD'] == 'GET'){
    $postId = $matches[1];
    $post = getPost($dbConn, $postId);

    echo json_encode($post);
}

if(preg_match("/posts\/([0-9])+/", $url, $matches) && $_SERVER['REQUEST_METHOD'] == 'DELETE'){
    $postId = $matches[1];
    deletePost($dbConn, $postId);

    echo json_encode([
        'id'=> $postId,
        'deleted'=> 'true'
    ]);
}

/**
 * Get Post based on ID
 *
 * @param $db
 * @param $id
 *
 * @return Associative Array
 */
function getPost($db, $id) {
    $statement = $db->prepare("SELECT * FROM posts where id=:id");
    $statement->bindValue(':id', $id);
    $statement->execute();

    return $statement->fetch(PDO::FETCH_ASSOC);
}

/**
 * Delete Post record based on ID
 *
 * @param $db
 * @param $id
 */
function deletePost($db, $id) {
    $statement = $db->prepare("DELETE FROM posts where id=':id'");
    $statement->bindValue(':id', $id);
    $statement->execute();
}

/**
 * Get all posts
 *
 * @param $db
 * @return mixed
 */
function getAllPosts($db) {
    $statement = $db->prepare("SELECT * FROM posts");
    $statement->execute();
    $statement->setFetchMode(PDO::FETCH_ASSOC);

    return $statement->fetchAll();
}

/**
 * Add post
 *
 * @param $input
 * @param $db
 * @return integer
 */
function addPost($input, $db){

    $sql = "INSERT INTO posts 
          (title, status, content, user_id) 
          VALUES 
          (:title, :status, :content, :user_id)";

    $statement = $db->prepare($sql);

    bindAllValues($statement, $input);

    $statement->execute();

    return $db->lastInsertId();
}

/**
 * @param $statement
 * @param $params
 * @return PDOStatement
 */
function bindAllValues($statement, $params){
    $allowedFields = ['title', 'status', 'content', 'user_id'];

    foreach($params as $param => $value){
        if(in_array($param, $allowedFields)){
            $statement->bindValue(':'.$param, $value);
        }
    }

    return $statement;
}

/**
 * Get fields as parameters to set in record
 *
 * @param $input
 * @return string
 */
function getParams($input) {
    $allowedFields = ['title', 'status', 'content', 'user_id'];

    $filterParams = [];
    foreach($input as $param => $value){
        if(in_array($param, $allowedFields)){
            $filterParams[] = "$param=:$param";
        }
    }

    return implode(", ", $filterParams);
}

/**
 * Update Post
 *
 * @param $input
 * @param $db
 * @param $postId
 * @return integer
 */
function updatePost($input, $db, $postId){

    $fields = getParams($input);
    $input['postId'] = $postId;

    $sql = "
          UPDATE posts 
          SET $fields 
          WHERE id=':postId'
           ";

    $statement = $db->prepare($sql);

    bindAllValues($statement, $input);

    $statement->execute();

    return $postId;

}

注意,这段代码非常基本,它有许多缺陷,我们将在下一章中看到。这只是给你一个如何在核心 PHP 中实现的方向,但这不是最好的方法。

正如我们对后 CRUD 端点所做的那样,您需要创建注释 CRUD 端点。这应该不难,因为我们已经在 routes 中添加了注释,您知道我们将添加类似于posts.phpcomments.php。您还可以查看posts.php文件中的逻辑,因为comments.php将有相同的操作,并且将有类似的代码。现在,是时候为comments.phpCRUD 相关端点编码了。

明显的缺陷

虽然我们在前几节中讨论的代码可以工作,但其中存在许多漏洞。在接下来的章节中,我们将研究不同的问题,但是,让我们看看其中的三个问题,并看看如何解决它们:

  • 验证
  • 认证
  • 404 情况下无响应

验证

现在在我们的代码中,虽然我们使用了PDOprepare 和bindValue()方法,但它只会从 SQL 注入中保存它。但是,在 insert 和 update 的情况下,我们并不验证所有字段。我们需要验证标题是否有特定的限制,状态是否为草稿或已发布,user_id应该始终是 users 表中的一个 ID。

解决方案

第一个简单的解决方案是进行手动检查,以验证来自用户端的数据。这很简单,但工作量很大。这意味着它会工作,但我们可能会错过一些东西,如果我们没有错过任何检查,这将是一个低层次的细节处理很多。

因此,更好的方法是利用社区中已有的一些开源软件包或工具。我们将在接下来的章节中查看并使用这些工具或包。在接下来的章节中,我们还将使用这些包来验证数据。

事实上,这不仅适用于验证,而且在本章中,我们自己仍在做许多低级工作。因此,我们将研究如何通过使用 PHP 社区中可用的不同工具来最小化我们在低级内容上的工作。

认证

现在,我们允许任何人添加、读取、更新和删除任何记录。这是因为没有经过身份验证的用户。一旦有一个经过身份验证的用户,我们可以设置不同的约束,例如用户不能删除或更新其他用户的内容等等。

那么,为什么我们不在一个仅 HTTP 的 cookie 中简单地设置基于会话的身份验证,并具有一个会话 ID?这是在传统网站上完成的。我们启动会话,将用户数据放入会话变量中,会话 ID 存储在 HTTPOnly cookie 中。服务器总是读取仅 HTTP 的 cookie 并获取会话 ID,以了解哪个会话数据属于该用户。这就是用 PHP 开发的典型网站中发生的情况。那么,我们为什么不在 RESTful web 服务的情况下简单地使用相同的东西进行身份验证呢?

因为 RESTful web 服务不打算仅通过 web 浏览器调用。它可以是任何东西,例如移动设备、另一台服务器,也可以是SPA单页应用程序)。因此,我们需要一种能够处理这些事情的方法。

解决方案

一个解决方案是,我们将使用一个简单的令牌,而不是会话 ID。而不是 cookies,该令牌将被发送到客户端,客户端将始终在每个请求中使用该令牌来标识客户端。一旦客户机在每个请求中使用令牌,那么客户机是移动应用程序、SPA 还是其他任何东西都无关紧要。我们将简单地根据令牌识别用户。

现在的问题是如何创建并发回令牌?这可以手动完成,但同样,如果开源软件已经提供并经过社区测试,为什么还要创建它呢?事实上,在后面的章节中,我们将使用这样的包,并使用令牌进行身份验证。

404 页

现在,如果我们要查找的页面或记录不存在,我们就没有合适的 404 页面。这是因为我们没有在路由器中处理这个问题。路由器是非常基本的,但同样,这是低级的东西,我们可以在开源中找到这样的路由器。我们也将在后面的章节中使用它。

总结

我们创建了一个基本的 RESTful web 服务,并提供了基本的 CRUD 操作。然而,当前代码中存在许多问题,我们将在下一章中看到并解决这些问题。

在本章中,我们编写了 PHP 代码来创建一个基本的 RESTful web 服务,尽管这不是最好的方法——这只是给您一个方向。这里有一些资源,您可以从中学习编写更好的 PHP 代码。这是 PHP 最佳实践的快速参考:http://www.phptherightway.com/

要采用标准编码风格和实践,您可以在阅读 PHP 编码标准和风格 http://www.php-fig.org/

我建议您在这两个 URL 上花费一些时间,以便编写更好的代码。

在下一章中,我们将详细研究这一点,并将识别代码中的不同缺陷,包括安全性和设计缺陷。另外,看看不同的解决方案。