使用-PHP7-构建-REST-Web-服务(全)

使用 PHP7 构建 REST Web 服务(全)

原文:zh.annas-archive.org/md5/0741f77c4686cccb7feaca7feda46f8b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Web 服务一直是一个重要的话题。有了 REST,事情变得更简单更好。如今,RESTful web 服务被广泛使用。十年前它很重要,但是单页应用(SPAs)和移动应用程序大大增加了它的使用。本书的目的是教育 PHP 开发人员有关 RESTful web 服务架构、有效创建 RESTful web 服务的当前工具,如一个名为 Lumen 的微框架、自动化 API 测试、API 测试框架、安全性和微服务架构。

尽管这本书是针对 PHP 的,因为我们将在 PHP7 中构建 RESTful web 服务,但它既不仅仅是关于 PHP7,也不仅仅是关于 REST。RESTful web 服务和在 PHP 中的实现是我们在这本书中要做的。然而,你将学到比这更多。你将了解一些在 PHP7 中是新的 PHP 特性。我们将讨论我们应该如何构建我们的应用程序以及与 web 和 web 服务相关的一些常见威胁。你将学习如何改进基本的 RESTful web 服务,并了解测试的重要性和不同类型的测试。因此,这不仅仅是关于 REST 或 PHP,还涉及一些次要但重要的与编程相关的东西,这些东西简单但在现实世界中能够让事情变得更好。在本书的结尾,你将了解到一个名为微服务的架构。

换句话说,尽管这本书是为 PHP 开发人员而写的,但它将使他们受益不仅仅是在 PHP 方面。因此,这本书不是一本食谱,而是一个旅程,在这个旅程中,你将开始学习关于 RESTful web 服务和 PHP7,然后开始构建 RESTful web 服务。然后,你可以通过了解其中的问题并加以修复来不断改进你的 RESTful web 服务。在这样的改进过程中,你将学到 PHP 中的不同东西,甚至超越 PHP。

本书涵盖的内容

第一章《RESTful Web 服务,介绍和动机》向你介绍了 web 服务、REST 架构、RESTful web 服务,以及它与其他 web 服务的比较,如 HTTP 动词和 RESTful 端点。它还通过博客的例子解释了 web 服务,然后讨论了响应格式和响应代码。

第二章《PHP7,更好地编码》包括 PHP7 中的新特性和变化,我们将在本书中使用或者非常重要并值得讨论。

第三章《创建 RESTful 端点》是关于在 Vanilla PHP 中为博客文章的 CRUD 操作创建 REST API 端点。它还解释了通过名为 Postman 的 REST 客户端手动测试 API 端点的方法。

第四章《审查设计缺陷和安全威胁》审查了我们在前一章中构建的内容,并强调其中的问题和缺陷,以便我们以后可以改进。

第五章《使用 Composer 进行加载和解析》,一个进化,是关于 PHP 生态系统中的一个进化工具:composer。这不仅仅是一个自动加载程序或包安装程序,而是一个依赖管理器。因此,你将在本章中了解 composer。

第六章《用 Lumen 照亮 RESTful Web 服务》向你介绍了一个名为 Lumen 的微框架,在这个框架中,我们将重写我们的 RESTful web 服务端点,并审查这个工具将如何显著改进我们的速度和应用程序结构。

第七章《改进 RESTful Web 服务》使我们能够改进前一章中所做的事情;你将学习如何改进 RESTful web 服务。我们将创建身份验证并制作一个转换器来分离 JSON 结构应该如何看起来。此外,我们将在安全性方面进行改进,并了解 SSL。

第八章,API 测试-门上的守卫,介绍了自动化测试的需求。将介绍不同类型的测试,然后专注于 API 测试。然后我们将介绍一个名为 CodeCeption 的自动化测试框架,并在其中编写 API 测试。

第九章,微服务,是关于微服务架构的。我们将了解微服务的好处和挑战,并研究一些可能的解决方案和权衡。

你需要为这本书做好准备

尽管我使用了 Ubuntu,但任何安装了 PHP7 的操作系统都可以正常工作。除了 PHP7 之外,唯一需要的是关系型数据库管理系统。本书在连接数据库时使用了与 MySQL 相关的设置,因此 MySQL 是理想的选择,但 MariaDB 或 PostgreSQL 也可以。

这本书适合谁

这本书是为以下受众编写的:

  • 任何有一些基本 PHP 知识并且想要构建 RESTful 网络服务的人。

  • 懂得基本 PHP 并且已经开发了一个基本的动态网站,想要构建 RESTful 网络服务的开发人员。

  • 学习了 PHP 并且大部分时间在开源 CMS 中工作,比如 WordPress,并且希望转向开发需要构建网络服务的自定义应用程序的开发人员。

  • 被困在 Code Igniter 中的传统系统中,并希望探索 PHP 现代生态系统的开发人员。

  • 使用过现代框架如 Yii 或 Laravel,但不确定构建 REST API 所需的关键部分,这些 API 不仅能够实现目的,而且在长期运行中表现良好,不总是需要手动测试,并且易于维护和扩展的开发人员。

  • 有经验的 PHP 开发人员,已经创建了一个返回数据的基本 API,但希望熟悉 REST 标准下的 API 构建方式,以及在身份验证出现时的工作方式,以及如何为其编写测试。

约定

在这本书中,你会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的例子以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“randGen()方法接受两个参数,定义返回值的范围。”

代码块设置如下:

<?php
function add($num1, $num2):int{
    return ($num1+$num2);
}

echo add(2,4); //6
echo add(2.5,4); //6

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

<?php
function add($num1, $num2):int{
    return ($num1+$num2);
}

echo add(2,4); //6
echo add(2.5,4); //6

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

sudo add-apt-repository ppa:ondrej/php

新术语和重要单词以粗体显示。例如,屏幕上看到的单词,比如菜单或对话框中的单词,会出现在文本中。

警告或重要提示会出现在这样的地方。提示和技巧会出现在这样的地方。

第一章:RESTful 网络服务,介绍和动机

RESTful 网络服务现在被广泛使用。RESTful 是简单的,也是其他网络服务中最广泛使用的。事实上,它的简单性也是它出名的原因。如果你正在阅读这本书,那么你很可能对 RESTful 网络服务有所了解。你可能已经使用过它,或者只是听说过。但即使你对 RESTful 网络服务不太了解,也不用担心,因为我们首先在这里对它进行了定义。所以首先,让我们列出本章将涵盖的高层主题:

  • 网络服务,什么是网络服务?

  • REST 架构(REST 的约束)

  • RESTful 网络服务

  • RESTful 网络服务的约定

  • HTTP 动词(方法)

  • 为什么要使用 RESTful 网络服务?

  • 响应类型和响应代码

  • 案例研究-博客的 RESTful 网络服务端点

然而,关于 RESTful 网络服务有很多误解。例如,有些人认为任何返回 JSON 的网络上的东西都是 RESTful 网络服务,而 RESTful 网络服务只返回 JSON。这是不正确的。

事实上,RESTful 网络服务支持多种格式,并不是所有返回 JSON 的东西都是 RESTful 网络服务。为了避免混淆,让我们了解一下什么是 RESTful 网络服务。

基于 REST 架构的网络服务是 RESTful 网络服务。那么,到底什么是网络服务和 REST 架构呢?让我们先了解网络服务,然后再了解 REST 架构。

网络服务

网络服务在不同的地方有不同的定义。逐字翻译的定义是,包括网页在内的任何在网上提供的服务都是网络服务,但是如果指的是技术术语网络服务,这并不正确。

为了定义网络服务,我们将从 W3C 词汇表中查看网络服务的定义:

“网络服务是一种旨在支持网络上可互操作的机器对机器交互的软件系统。它具有用机器可处理的格式(特别是 WSDL)描述的接口。其他系统按照其描述的方式使用 SOAP 消息与网络服务进行交互,通常使用 HTTP 与其他与 Web 相关的标准一起进行 XML 序列化。”-W3C,网络服务词汇表。

这个定义同样并不完全正确,因为它更具体地适用于基于 SOAP 和 WSDL 的网络服务。事实上,在 2004 年 2 月 11 日的 W3C 工作组说明中,它指出:

“我们可以确定两类主要的网络服务:

-符合 REST 的网络服务,其中服务的主要目的是使用一组统一的“无状态”操作来操作 Web 资源的 XML 表示;

-和任意网络服务,其中服务可能公开一组任意操作。”

因此,对于网络服务的一个更一般和更好的定义是,来自前面提到的 W3C 网络服务词汇表的定义:

“网络服务是一种旨在支持网络上可互操作的机器对机器交互的软件系统。”

为什么要使用网络服务?

现在,我们知道了什么是网络服务。所以在继续讨论 REST 之前,了解网络服务的需求是很重要的。网络服务可以在哪里使用?

正如刚刚定义的,Web 服务是支持网络上机器对机器的可互操作通信的系统。它对于不同系统或设备之间的通信非常有用。在我们的情况下,我们将使用 Web 服务来提供一个接口,通过这个接口,移动应用程序或 Web 应用程序将能够与服务器通信以获取和存储数据。这将使客户端应用程序与服务器端逻辑分离。如今,单页应用程序(SPA)和移动应用程序需要独立,与服务器端逻辑分离,并且只通过 Web 服务与服务器端逻辑交互。因此,Web 服务如今非常重要。然而,Web 服务的使用不仅限于客户端应用程序的使用,而且在服务器之间的通信中也很有用,其中一个服务器充当客户端。

REST 架构

REST 代表表述性状态转移。这是由 Roy Fielding 在 2000 年创立的架构风格,并在他的博士论文中阐述。他指出 REST “提供了一组架构约束,当作为一个整体应用时,强调组件交互的可扩展性,接口的通用性,组件的独立部署,以及中间组件来减少交互延迟,强制安全性,并封装遗留系统。”

REST 是基于网络应用的架构风格,而 HTTP 1.1 就是基于它开发的。

一个符合 RESTful 或 REST 的网络服务必须遵守以下六个约束;否则,它将不被视为 RESTful 或 REST 兼容。在阅读和理解以下提到的约束时,可以将现代网络视为 REST 架构的一个例子。

客户端服务器

REST 是关于分离客户端和服务器的。这个约束是关于“关注点分离”。这意味着服务器和客户端有各自的责任,因此一个不负责另一个的职责。例如,客户端不负责服务器上的数据存储,因为这是服务器的责任。同样,服务器不需要了解用户界面。因此,服务器和客户端都执行自己的任务并履行自己的责任,这使得他们的工作更容易。因此,服务器可以更具可扩展性,客户端上的用户界面可以是独立的和更具交互性。

无状态

客户端服务器通信是无状态的。来自客户端的每个请求都将包含提供请求所需的所有信息。这意味着在这种通信中除了请求中的信息之外,没有其他状态。客户端将收到的响应将基于请求而不查看除请求中的信息之外的任何状态。

如果需要维护会话,会话将基于请求中的令牌或标识符进行存储。因此,如果我们看一个 Web 请求的例子,那么 HTTP 的流程不过是一个请求由客户端发送到服务器,然后服务器发送一个响应回到客户端,如下图所示:

如果需要维护会话,会话数据将存储在服务器上,而会话标识符将发送回客户端。在随后的请求中,客户端将在每个请求中包含该会话标识符,服务器将通过此标识符识别客户端并加载相关会话数据,如下图所示:

在随后的请求中:

因此,REST 是无状态的。为了维护状态,需要传递标识符或任何其他信息,以逻辑上分组不同的请求以在请求中维护会话。如果在请求中没有传递这样的标识符,服务器将永远不知道这两个请求是否来自同一个客户端。

无状态性的优势在于简单性。相同的请求不会产生不同的响应,除非请求参数发生了变化。它将根据不同的请求参数返回不同的结果,而不是由于某种状态。即使状态取决于请求,如前面的例子所示。因此,会话标识符在请求中,这可能导致不同的状态,因此导致不同的响应。

可缓存

这个约束规定 RESTful Web 服务的响应必须定义自身是否可缓存,以便客户端知道是否应该缓存。如果正确定义,它可以减少开销并提高性能,因为如果能够使用缓存版本,客户端就不会去服务器。

统一接口

统一接口是最具区别性的约束。它基本上解耦了架构,使接口与实现分离,就像任何良好的系统一样。

这类似于面向对象编程中的情况:接口分离了实现和声明。这类似于操作系统将用户界面与复杂的实现逻辑分离开来,以保持软件的运行。

统一接口有四个约束。为了理解统一接口,我们需要理解这些约束。

资源标识

资源将在请求中被标识。例如,在基于 Web 的 REST 系统中,资源将由 URI 标识。无论资源在服务器上如何存储,它都将与响应中返回给客户端的内容分开。

实际上,服务器上的资源存储是一种实现,但请求和响应是客户端与之交互的方式,因此它就像是对客户端的接口。客户端可以通过这个接口识别资源。客户端所知道的就是它请求和得到的响应。

例如,客户端通常会向 URI 发送请求,并以 HTML、JSON 或 XML 的形式获得响应。这些格式都不是服务器在数据库内部或其他地方存储数据的方式。但对于客户端来说,重要的是它将要访问的 URI 以及它得到的 HTML、JSON 和 XML。

这是客户端的资源,无论它在服务器上如何存储。这就是好处,因为无论服务器的内部逻辑或表示如何更改,对于客户端来说它都将保持不变,因为客户端将请求发送到 URI 并以 HTML、JSON 或 XML 的形式获得响应,而不是它在服务器上的存储方式。这个约束导致资源标识和表示的松散耦合。

通过表示来操作资源

这个约束规定客户端应该持有足够的信息来修改或删除资源的表示。例如,在基于 Web 的 REST 系统中,可以使用 HTTP 方法和 URI 对资源执行任何操作。这使得事情变得容易跟踪,因为 API 开发人员不需要为与资源相关的每个端点提供文档。

自描述消息

请注意,根据需要向客户端发送代码是可选的,如果不想扩展客户端的功能,则不需要。

超媒体作为应用状态的引擎(HATEOAS)

这个约束规定,基于服务器向 REST 客户端提供的内容,REST 客户端应该能够发现所有可用的操作和资源。换句话说,它指出,如果客户端知道一个入口点,那么从第一个端点开始,它应该能够发现与该资源相关的其他相关端点。例如,如果客户端转到资源的列表端点,那应该包括该列表中资源的链接。如果应用了分页或限制,它应该有链接以转到列表中其余的项目。

如果客户端创建了一个新资源,新资源的链接也应该包含在响应中,这个链接可以用于使用不同的 HTTP 动词进行对该资源的读取、更新和删除操作。对于除了典型的 CRUD 之外的操作,显然会有更多的 URL,因此这些操作的 URL 也应该在响应中,以便从一个入口点发现与资源相关的所有端点。

由于 HATEOAS,一个端点会暴露出与其相关的链接。这减少了对全面 API 文档的需求,尽管不是完全减少,但不需要查看已经暴露的链接的 API 文档。

按需发送代码(可选)

这表明服务器可以通过发送可由客户端执行的代码,为 REST 客户端添加更多功能。在网络环境中,一个这样的例子是服务器发送给浏览器的 JavaScript 代码。

让我们举个例子来更好地理解这一点。

例如,Web 浏览器就像一个 REST 客户端,服务器传递 HTML 内容,浏览器呈现。在服务器端,有一些服务器端语言在服务器端执行一些逻辑工作。但是,如果我们想要在浏览器中添加一些逻辑,那么我们(作为服务器端开发人员)将不得不向客户端发送一些 JavaScript 代码,然后执行该 JavaScript 代码。因此,JavaScript 代码是服务器发送给客户端的按需代码,它扩展了 REST 客户端的功能。

由于我们已经定义了 REST 和网络服务,现在我们可以说 RESTful 网络服务是符合 REST 的任何网络服务。

分层系统

REST 系统的多层约束

REST 系统可以有多个层,并且如果客户端请求响应并获得响应,无法区分它是从服务器返回的还是从另一个中间件服务器返回的。因此,如果一个服务器层被另一个替换,除非提供了预期的内容,否则不会影响客户端。简而言之,一个层与其直接交互的下一层之外没有知识。

RESTful 网络服务

现在,我们已经定义了 RESTful 网络服务,我们需要了解 RESTful 网络服务是如何工作的,以及 RESTful 网络服务基于什么,以及为什么它们比其他网络服务(如 SOAP)更受欢迎。

RESTful 网络服务的约定

RESTful web 服务是基于 RESTful 资源的。RESTful 资源是一个实体/资源,通常存储在服务器上,并且客户端使用 RESTful web 服务请求它。以下是关于 RESTful web 服务中资源的一些特征:

  • 它通常被称为 URL 中的名词实体

  • 它是唯一的

  • 它与数据相关联

  • 它至少有一个 URI

如果你还在疑惑什么是资源,可以考虑博客的例子。在博客系统中,帖子用户类别评论都可以是资源。在购物车中,产品类别订单可以是资源。事实上,任何客户从服务器请求的实体都是资源。

最常见的是,可以对资源执行五种典型操作:

  • 列表

  • 创建

  • 读取

  • 更新

  • 删除

对于每个操作,我们需要两样东西:URI 和 HTTP 方法或动词。

URI 包含一个名词资源和一个动词 HTTP 方法。要对实体执行某些操作,我们需要一个名词,告诉我们需要执行某些操作的实体是什么。我们还需要指定一个动词,告诉我们要执行什么操作。

对于前面提到的操作,我们使用 HTTP 动词和资源名称的 URL 约定。在下一节中,我们将审查每个操作的 URL 结构和 HTTP 动词。

HTTP 动词和 URL 结构

以下是如何使用 URI 和 HTTP 动词在资源上执行这些操作的。请注意,在下面提到的操作的 URI 中,您需要用资源名称替换{resource}

列表操作

  • HTTP 方法:GET

  • URI:/{resource}

  • 结果:它返回所提到资源类型的列表。在该列表中,它将为资源提供唯一标识符,这些标识符可以用于对特定资源执行其他操作。

创建操作

  • HTTP 方法:POST

  • URI:/{resource}

  • 参数:可以在POST主体中有多个参数

  • 结果:这应该在主体中使用参数创建一个新的资源。

  • 正如你所看到的,创建和列表的 URI 没有区别,但这两个操作是通过 HTTP 方法区分的,这导致了不同的操作。事实上,HTTP 方法和 URI 的组合告诉我们应该执行哪种操作。

读取操作

HTTP 方法:GET

URI:/{resource}/{resource_id}

结果:这应该根据资源的 ID 返回记录。

这里,resource_id将是可以从列表操作结果中找到的资源的 ID。

更新操作

可以有两种类型的更新操作:

  • 更新特定记录的一些属性

  • 用新的完全替换特定记录

执行这两个操作的唯一变化是 HTTP 方法。

使用更新操作,更新资源的一些属性:

HTTP 方法:PATCH

当要替换整个资源时使用:

HTTP 方法:PUT

URI 和参数将保持不变:

URI:/{resource}/{resource_id}

参数:可以在查询字符串中有多个参数。最初,人们尝试在主体中传递这些参数,但实际上,使用查询字符串传递PATCHPUT参数。

结果:这应该根据 HTTP 方法更新或替换资源。

在这里,resource_id将是可以从列表操作结果中找到的资源的 ID。实际上,使用PATCHPUT不会有任何区别,但基于 REST 标准,应该使用PATCH来更新记录的不同属性,而应该使用PUT来替换整个资源。

删除操作

  • HTTP 方法:DELETE

  • URI:/{resource}/{resource_id}

  • 结果:这应该根据 URI 中的资源 ID 删除资源

如果你现在感到不知所措,不要担心,因为我们刚刚看到了哪种 HTTP 方法和 URI 的组合用于哪些操作。很快,我们将讨论一个案例研究,并看到一些不同资源上的操作以及示例。

在任何其他事情之前,既然我们现在了解了 RESTful 网络服务以及它们的工作原理,现在是了解为什么我们更喜欢使用 RESTful 网络服务而不是其他网络服务的好时机。

为什么要使用 RESTful 网络服务?

事实上,RESTful 网络服务并不是我们唯一可以编写的网络服务类型。我们也有其他编写网络服务的方式。还有一些更早的编写网络服务的方式以及一些更近期的方式。我们不会详细讨论其他网络服务,因为这超出了本书的范围,重点在于 RESTful 网络服务以及如何构建它们。

REST 与 SOAP

REST 的一个旧的替代方案是 SOAP。事实上,当 REST 作为一种替代方案出现时,SOAP 已经存在。一个关键的区别是 SOAP 没有告诉消费者如何访问的特定约定。SOAP 使用 WSDL 来公开其服务。将 WSDL 视为 SOAP 提供的服务的定义。这就是消费者知道 SOAP 基于网络服务提供了什么以及如何消费它们的方式。

另一方面,REST 强调“约定优于配置”。如果你像我们之前所做的那样看 RESTful 网络服务的 URL 结构和 HTTP 动词,你会发现有一个固定的约定。例如,如果你在客户端并且想要创建一个产品,如果你知道它将需要哪些参数,那么你可以通过向example.com/product发送POST请求来创建它,资源将被创建。如果你想列出所有产品,你可以使用相同的 URL 和GET请求。如果你从列表操作中获取产品 ID,你可以简单地使用它们来通过example.com/product/{product_id}分别使用PATCHPUTDELETE来更新或删除产品。要知道使用哪种 URL 和 HTTP 方法来执行某种操作是如此简单,因为这些是 RESTful 网络服务遵循的一些约定。因此,客户端端只需遵循这些约定,对于简单的任务就不需要大量的文档。

除此之外,无状态性的简单性、关注点的分离和可缓存性是我们已经详细了解的 RESTful 网络服务的其他优点之一。

HTTP 方法的性质

由于我们将主要处理 HTTP 上的 URL 和使用 HTTP 方法,最好花一些时间了解 HTTP 方法的性质。

我们还应该了解,HTTP 方法实际上并不是通过自身进行任何类型的列举、创建或修改。这只是一种约定,使用特定的 HTTP 方法和 URL 模式进行特定的操作。这些方法本身并不执行任何操作,而是取决于服务器端开发人员。这些方法可以根据开发人员编写的代码进行任何操作。

当我们谈论 HTTP 方法的性质时,这是关于遵循的约定和标准。毕竟,RESTful 网络服务是关于优先选择约定而不是配置。今天的 HTTP 和 REST 的基础就在于这些约定,而在编写 RESTful 网络服务时,我们将遵循这些约定。

安全/不安全的 HTTP 方法

HTTP 方法可以是安全的或不安全的。安全的意思是这些方法不会改变服务器上的任何资源,而不安全的意思是这些方法预期会改变服务器上的一些资源。因此,我们有GET作为唯一的安全方法,因为它不预期在服务器上做任何改变,而其他方法如PUTPOSTPATCHDELETE被认为是不安全的方法,因为它们预期在服务器上做一些改变。

幂等和非幂等方法

有些方法可以实现相同的结果,无论我们重复相同的操作多少次。我们认为GETPUTPATCHDELETE是幂等方法,因为无论我们重复调用这些方法多少次,结果总是相同的。例如,如果您使用GET example.com/books,它将始终返回相同的书籍列表,无论您用GET方法调用这个 URL 多少次。然而,如果用户在数据库中放入其他东西,那么在列出中可能会有不同的结果,但为了声明某些方法是否幂等,我们不考虑由于外部因素而导致结果的变化,而是考虑方法调用本身。同样,如果您使用PUTPATCH,比如PATCH example.com/books/2?author=Ali,无论您用相同的参数多少次调用这个方法,结果始终相同。

对于DELETE也是一样的。无论您多少次在相同的资源上调用DELETE,它只会被删除一次。然而,对于DELETE,它也可能基于实现而有所不同。这取决于您作为程序员想要如何实现。您可能希望第一次DELETE并给出成功的响应,而在后续调用中,您可以简单地给出 404,因为资源已经不存在。

POST是非幂等的,因为它在服务器上创建一个新资源,响应至少有一个唯一的属性(通常是资源的 ID),即使在相同的请求参数的情况下,所有其他属性都相同。

到目前为止,我们已经了解了 RESTful web 服务的约定、URL 模式、HTTP 方法和 HTTP 方法的性质。然而,这主要是关于请求。URL 和 HTTP 方法都是与请求相关的点。我们还没有看过响应,所以现在让我们来看一下。

HTTP 响应

请求的目的是获得响应,否则就没有用处,考虑到我们还需要了解对请求期望的响应类型。在这个上下文中,有两件事情我们将讨论:

  • 响应类型

  • 响应代码

响应类型

在当前世界中,许多人认为 RESTful web 服务的响应必须是 JSON 或包含 JSON 字符串的文本。然而,我们也可以在 RESTful web 服务请求的响应中使用 XML。许多人使用 JSON 作为响应,因为它轻量且易于解析。但与此同时,这只是一种偏好,取决于需求,与 REST 标准无关。

XML 和 JSON 都是格式化数据的方式。XML 代表可扩展标记语言,具有标记语法。而 JSON 代表 JavaScript 对象表示法,具有类似 JavaScript 对象的语法。要更好地理解 JSON,请查看www.json.org/

我们将很快看一个博客的案例研究,并看到请求和响应的例子。在这本书中,我们将使用 JSON 作为响应类型,因为 JSON 比 XML 更简单。在开发新应用程序时,我们大多使用 JSON,因为它轻量且易于理解。正如您在以下示例中所看到的,JSON 中的相同数据比 XML 简单得多,只包含重要的内容。

在这里,我们试图展示具有一个或多个作者的书籍的数据:

XML:

<books>
  <book>
    <name>Learning Neo4J</name>
    <authors>
      <author>Rik Van Bruggen</author>
    </authors>
  </book>
  <book>
    <name>
     Kali Linux – Assuring Security by Penetration Testing
    </name>
    <authors> 
      <author>Lee Allen</author>
      <author>Tedi Heriyanto</author>
      <author>Shakeel Ali</author>
    </authors>
 </book>
</books>

现在,让我们在 JSON 中看同样的例子:

{
books: [
  {
    name:"Learning Neo4J",
    authors:["Rik Van Bruggen"]
  },
  {
    name:"Kali Linux – Assuring Security by Penetration Testing",
    authors:["Lee Allen", "Tedi Heriyanto", "Shakeel Ali"]
  }
 ]
}

您可以清楚地从前面的例子中看到,XML 和 JSON 都传达相同的信息。然而,在 JSON 中,这更容易,而且需要更少的词来显示相同的信息。

因此,在本书的其余部分,我们将使用 JSON 作为我们的 RESTful web 服务的响应类型。

响应代码

响应代码,更为人所知的是 HTTP 状态代码,告诉我们请求的状态。如果 HTTP 请求成功,HTTP 状态代码是 200,表示 OK。如果有服务器错误,它返回 500 状态代码,表示内部服务器错误。如果请求中有任何问题,HTTP 状态代码是 400 及以上,其中 400 状态代码表示错误的请求。在重定向的情况下,响应代码是 300 及以上。

要查看完整的响应代码列表及其用法,请参见en.wikipedia.org/wiki/List_of_HTTP_status_codes

我不会详细介绍,因为这将是多余的,因为所有这些信息已经在前面提到的维基百科链接中都可以找到。然而,我们将在后续讨论不同的状态代码。

案例研究 - 博客的 RESTful web 服务端点

为了理解 RESTful web 服务,让我们以博客为案例研究,在博客中讨论资源/实体。我们将开始定义博客资源的要求和端点 URL,然后定义我们应该对这些请求做出怎样的响应。因此,这些端点和响应定义将帮助我们理解 RESTful web 服务端点应该是什么样子,以及响应应该是什么样子。在后面的章节中,我们将更多地讨论这些端点的实现,因此这些定义将作为下一章的 API 文档。然而,为了简单起见,我们现在将保持最小限度,并在以后添加更多属性。

尽管基于 HATEOAS,RESTful web 服务应该返回到下一个端点的链接,并且有一些约定告诉我们其他端点的信息,但 API 文档仍然很重要。API 消费者(客户端开发人员)和 API 提供者(服务器端开发人员)应该就此达成一致,以便两者可以并行工作,而不必等待对方。然而,在现实世界中,我们不必为基本的 CRUD 操作编写 API 文档。

在典型的博客中,最常见的资源是文章和评论。还有其他资源,但现在我们将讨论这两个资源,以便理解 RESTful web 服务。请注意,我们不考虑与身份验证相关的内容,但将在后面的章节中进行讨论。

如果客户端和服务器端团队属于同一组织,共同开发一个应用程序,那么由客户端团队创建这样的文档是个好主意,因为服务器端团队只是为客户端提供服务。

博客文章

在这里,我们列出了博客文章及其端点的要求。对于这些端点,我们将编写一个请求和一个响应。

要求

可以创建、修改、访问和删除博客文章。还应该有一种方法列出所有博客文章。因此,我们将列出博客文章的端点。

端点

以下是博客文章的端点:

创建博客文章

  • 请求POST /posts HTTP/1.1

  • 主体参数

内容:这是一篇很棒的文章

标题:很棒的文章

  • 回应
{id:1, title:"Awesome Post", content:"This is an awesome post", link: "/posts/1" }
  • 响应代码:201 Created

这里POST是方法,/posts是 URL(服务器名称后的路径),HTTP 1.1 是协议。我们将继续以相同的方式提及后续示例中的请求。因此,请求的第一部分是 HTTP 方法,第二部分是 URL,第三部分是协议。

响应代码告诉客户端资源已成功创建。如果请求参数被错误地省略,响应代码应为400,表示错误的请求。

阅读博客文章

  • 请求GET /posts/1 HTTP/1.1

  • 回应

{id:1, title:"Awesome Post", content:"This is an awesome post", link: "/posts/1" }
  • 响应代码:200 OK

注意,如果提供的 ID 对应的博客文章不存在(在当前情况下为 1),它应该返回404,表示资源未找到。

更新博客文章

  • 请求PATCH /posts/1?title=Modified%20Post HTTP/1.1

  • 回应

{id:1, title:"Modified Post", content:"This is an awesome post", link:"posts/1" }
  • 响应代码:200 OK

请注意,如果提供的帖子 ID(在本例中为 1)不存在,应返回响应代码404,表示资源未找到。

此外,由于 PATCH 用于修改记录的所有或一些属性,而 PUT 用于修改整个记录,就像用新记录替换旧记录一样。因此,如果我们使用 PUT 并且只传递一个属性,其他属性将变为空。在 PATCH 的情况下,它只会更新传递的属性,其他属性保持不变。

删除博客帖子

  • 请求DELETE /posts/1 HTTP/1.1

  • 响应

{success:"True", deleted_id:1 }
  • 响应代码200 OK

请注意,如果提供的博客帖子 ID(在当前情况下为 1)不存在,应返回404,表示资源未找到。

列出所有博客帖子

  • 请求GET /posts HTTP/1.1

  • 响应

{
data:[
  {
   id:1, title:"Awesome Post", content:"This is an awesome post", link: "/posts/1" 
  },  
  {
   id:2, title:"Amazing one", content:"This is an amazing post", link: "/posts/2"
  }
 ],
total_count: 2,
limit:10,
pagination: {
    first_page: "/posts?page=1",
    last_page: "/posts?page=1",
    page=1
 }
}
  • 响应代码200 OK

在这里,数据是对象数组,因为有多个记录返回。除了total_count之外,还有一个分页对象,目前显示第一页和最后一页,因为记录的total_count只有 2。因此,没有下一页或上一页。否则,我们还应该在分页中显示下一页和上一页。

正如您所看到的,分页中也有链接,以及帖子对象中的帖子链接。我们在响应中包含了这些链接,以符合 HATEOAS 约束,该约束规定如果客户端知道入口点,那么应该足以发现相关的端点。

在这里,我们探讨了博客帖子的要求,并定义了它们的端点的请求和响应。在下一个实体/资源中,我们将定义评论的端点和响应。

博客帖子评论

在这里,我们列出了博客帖子评论的要求,然后是它们的端点。对于这些端点,我们将编写请求响应

要求

帖子上可能有一个、多个或没有评论。因此,可以在博客帖子上创建评论。可以列出博客帖子的评论。可以阅读、修改或删除评论。

让我们为这些要求定义端点。

端点

以下是帖子评论的端点:

创建帖子的评论

  • 请求POST /posts/1/comments HTTP/1.1

  • 主体参数comment: An Awesome Post

  • 响应

{id:1, post_id:1, comment:"An Awesome Post", link: "/comments/1"}
  • 响应代码201 Created

在评论的情况下,评论是针对某篇博客文章创建的。因此,请求 URL 也包括post_id

阅读评论

  • 请求GET /posts/1/comment/1 HTTP/1.1GET /comment/1 HTTP/1.1

第二个看起来更合理,因为只需要有一个评论的 ID,而不用担心评论的帖子 ID。而且由于评论的 ID 是唯一的,我们不需要有帖子的 ID 来获取评论。因此,我们将继续使用第二个 URL,即GET /comment/1 HTTP/1.1

  • 响应
{id:1, post_id:1, comment:"An Awesome Post", link: "/comments/1"}
  • 响应代码200 OK

由于任何评论只能存在于某个帖子中,因此响应也包括post_id

更新评论

  • 请求PATCH /comment/1?comment="Modified%20Awesome%20Comment' HTTP/1.1

  • 响应

{id:1, post_id:1, comment:"Modified Awesome Comment", link: "/comments/1"}
  • 响应代码200 OK

在这里,我们使用 PATCH,因为我们想要更新评论的单个属性。此外,您可以看到在新评论中传递了%20。因此,%20只是空格的替换,因为 URL 不能包含空格。因此,在 URL 编码中,空格应始终被%20替换。

删除帖子评论

  • 请求DELETE /comments/1 HTTP/1.1

  • 响应

{success:"True", deleted_id:1 }
  • 响应代码200 OK

请注意,如果提供的帖子评论 ID(在当前情况下为 1)不存在,应返回404 Not Found

列出特定帖子的所有评论

  • 请求GET /posts/1/comments HTTP/1.1

  • 响应

{
data:[
  {
   id:1, comment:"Awesome Post", post_id:1, link: "/comments/1" 
  }, {
   id:2, comment:"Another post comment", post_id:1, link: "/comments/2"
  } 
 ], 
 total_count: 2,
 limit: 10,
 pagination: {
 first_page: "/posts/1/comments?page=1",
 last_page: "/posts/1/comments?page=1",
 page=1 
 } 
}
  • 响应代码200 OK

正如你所看到的,帖子的评论列表与博客帖子的列表非常相似。它以相同的方式显示total_count和分页。它现在显示第一页和最后一页,因为记录的total_count只有 2。所以没有下一页或上一页。否则,我们还应该在分页中显示下一页和上一页的链接。

通常,在博客上看不到评论的分页,但最好保持一致,在列表中加入分页。因为一篇帖子可能有很多评论,我们应该对其进行一些限制,所以我们需要分页。

更多资源

虽然我们已经尝试以实例的方式了解 RESTful 网络服务,但这里还有一些其他有趣的资源。

Roy Fielding 介绍 REST 的论文:

www.ics.uci.edu/~fielding/pubs/dissertation/top.htm

Roy Fielding 的回应的小组讨论:

groups.yahoo.com/neo/groups/rest-discuss/conversations/topics/6735

这是一个关于 REST 与 SOAP 的有趣讨论:stackoverflow.com

stackoverflow.com/questions/19884295/soap-vs-rest-differences

Roy Fielding 谈论 REST:

www.youtube.com/watch?v=w5j2KwzzB-0

REST 的另一种看法:

www.youtube.com/watch?v=RY_kMXEJZfk

总结

在这一章中,我们了解了什么是 RESTful 网络服务。我们看了应该满足的约束条件,才能称为 RESTful 网络服务。然后我们了解到 REST 是一种架构,是一种构建东西的方式,它更青睐约定而不是配置。我们看了 HTTP 动词(方法)并看了 URL 约定。我们了解到这些只是约定。HTTP 动词和 URL 在 RESTful 网络服务中使用;否则,开发人员始终可以提供预期的行为,因为 REST 有约定,这些约定只是被视为标准,但它不提供任何实现。

在这一章中,我们没有讨论 RESTful 网络服务的实现。我们只考虑了一个典型博客的案例研究,并且以博客的两个资源为例,定义了它们的端点和预期的响应。我们还看了 HTTP 响应代码,但我们没有编写实际的代码来实现这些 RESTful 网络服务。我们定义了这些端点,所以我们将在下一章看到它们的实现。

由于这本书是关于在 PHP7 中构建 RESTful 网络服务,下一章我们将看一下 PHP7 中的新功能。PHP7 并没有提供任何特定于 RESTful 网络服务的功能,但我们将利用 PHP7 中的一些新功能来编写更好、更干净的代码来构建 RESTful 网络服务。

如果你已经很了解 PHP7,并且不想此刻深入研究,你可以跳过第二章,PHP7,编写更好的代码,并开始第三章,创建 RESTful 端点,在那里我们将构建 RESTful 网络服务。

第二章:PHP7,编写更好的代码

PHP7 带来了许多新功能和变化。然而,它们中没有一个是专门针对 REST 或 Web 服务的。事实上,REST 与语言结构没有直接关系。这是因为 REST 是一种架构,而语言是为了提供实现而存在的构造。那么,这是否意味着 PHP7 有一些构造或功能可以使这种实现更好?是和否。这取决于我们对实现的理解。

如果我们的意思只是获取一个请求并返回一个响应,那么不,没有这样的特定功能。但是,任何 RESTful Web 服务都与一个实体相关联,而一个实体可以有自己的逻辑。因此,为了为该实体提供 RESTful Web 服务,我们还需要编写该逻辑。为此,我们需要编写更多的 PHP 代码,而不仅仅是获取请求并返回响应。因此,为了保持代码简单和清晰,是的,PHP7 为我们提供了许多东西。

我假设你已经掌握了 PHP 的基础知识,因为了解 PHP 基础知识是本书的先决条件。因此,我们不会看 PHP5。在本章中,我们将看一下许多 PHP7 的功能和变化,这些功能和变化要么非常重要,要么我们将在我们的代码中使用。我们将直接进入这些功能。我们不会详细介绍安装或升级到 PHP7,因为互联网上有数十个教程可供参考。以下是我们将讨论的功能和变化的列表:

  • 标量类型声明

  • 返回类型声明

  • 空合并运算符

  • 太空船运算符

  • 组使用语句

  • 与生成器相关的新功能

  • 生成器返回表达式

  • 生成器委托

  • 匿名类

  • Closure::call()函数

  • 错误和异常

  • PHP7.1 功能

  • 可空类型

  • 对称数组解构

  • list()中支持键

  • 多捕获异常处理

标量类型声明

在 PHP7 中,我们现在可以声明传递给函数的参数的类型。在以前的版本中,它们只能是用户定义的类,但现在它们也可以是标量类型。通过标量类型,我们指的是基本的原始类型,比如intstringfloat

以前,要验证传递给函数的参数,我们需要使用某种if-else。因此,我们过去会这样做:

<?php
function add($num1, $num2){
    if (!is_int($num1)){
        throw new Exception("$num1 is not an integer");
    }
    if (!is_int($num2)){
        throw new Exception("$num2 is not an integer");
    }

    return ($num1+$num2);
}

echo add(2,4);  // 6
echo add(1.5,4); //Fatal error:  Uncaught Exception: 1.5 is not an integer

在这里,我们使用if来确保变量$num1$num2的类型是int,否则我们会抛出异常。如果你是一个喜欢尽可能少写代码的早期 PHP 开发人员,那么你甚至可能根本不检查参数的类型。然而,如果你不检查参数类型,这可能导致运行时错误。因此,为了避免这种情况,应该检查参数类型,这就是 PHP7 所做的事情。

这是我们现在在 PHP7 中验证参数类型的方式:

<?php
function add(int $num1,int $num2){
    return ($num1+$num2);
}
echo add(2,4); //6
echo add("2",4); //6
echo add("something",4); 
//Fatal error:  Uncaught TypeError: Argument 1 passed to add() must be of the type integer, string given

正如你现在所看到的,我们只需将int作为类型提示,而不需要单独验证每个参数。如果参数不是整数,它应该抛出异常。然而,你可以看到,当2作为字符串传递时,它并没有显示TypeError,而是进行了隐式转换,并将其视为int 2。这是因为,默认情况下,PHP 代码是在强制模式下运行的。如果启用了严格模式,写"2"而不是 2 将导致TypeError而不是隐式转换。要启用严格模式,我们需要在 PHP 代码的开头使用declare函数。

这是我们可以这样做的方式:

<?php
declare(strict_types=1); 

function add(int $num1,int $num2){
    return ($num1+$num2);
}

echo add(2,4); //6
echo add("2",4); //Fatal error:  Uncaught TypeError: Argument 1 passed to add() must be of the type integer, string given,

echo add("something",4); // Fatal error:  Uncaught TypeError: Argument 1 passed to add() must be of the type integer, string given

返回类型声明

就像参数类型一样,还有一个返回类型;它也是可选的,但指定返回类型是一种安全的做法。

这是我们可以声明返回类型的方式:

<?php
function add($num1, $num2):int{
    return ($num1+$num2);
}

echo add(2,4); //6
echo add(2.5,4); //6

正如你在2.54的情况下所看到的,它应该是6.5,但由于我们指定了int作为返回类型,它执行了隐式类型转换。为了避免这种情况,以及获得隐式转换而不是错误,我们可以简单地启用严格类型,如下所示:

<?php
declare(strict_types=1);
function add($num1, $num2):int{
    return ($num1+$num2);
}

echo add(2,4); //6
echo add(2.5,4); //Fatal error:  Uncaught TypeError: Return value of add() must be of the type integer, float returned

空合并运算符

Null合并操作符(??)是一种语法糖,但非常重要。在 PHP5 中,当我们有一些可能未定义的变量时,我们使用三元运算符如下:

$username = isset($_GET['username']) ? $_GET['username'] : '';

然而,现在在 PHP7 中,我们可以简单地写:

$username = $_GET['username'] ?? '';

尽管这只是一种语法糖,但它可以节省时间,使代码更清晰。

太空船操作符

太空船操作符也是比较的快捷方式,在用户定义的排序函数中非常有用。我不会详细介绍这个,因为它在文档中已经有足够的解释:php.net/manual/en/migration70.new-features.php#migration70.new-features.spaceship-op

组合使用声明

现在可以在单个use语句中导入相同命名空间中的类、函数和常量。以前需要多个use语句。以下是一个例子,以便更好地理解它:

<?php
// use statement in Pre-PHP7 code
use abc\namespace\ClassA;
use abc\namespace\ClassB;
use abc\namespace\ClassC as C;

use function abc\namespace\funcA;
use function abc\namespace\funcB;
use function abc\namespace\funcC;

use const abc\namespace\ConstA;
use const abc\namespace\ConstB;
use const abc\namespace\ConstC;

// PHP 7+ code
use abc\namespace\{ClassA, ClassB, ClassC as C};
use function abc\namespace\{funcA, funcB, funcC};
use const abc\namespace\{ConstA, ConstB, ConstC};

从这个例子中可以看出,组合使用语句有多么方便,这是显而易见的。使用大括号和逗号分隔的值来组合值,比如{classA, classB, classC as C},这样就可以得到组合的use语句,而不是分别为这三个类使用use语句,每个类使用三次。

与生成器相关的功能

尽管生成器在 PHP5.5 中出现,但大多数 PHP 开发人员不使用它们,很可能也不了解生成器。因此,让我们首先讨论生成器。

生成器是什么?

如 PHP 手册所述:

生成器提供了一种简单的方法来实现简单的迭代器,而不需要实现实现迭代器接口的类的开销或复杂性。

好的,这里有一个更详细和易于理解的定义,来自同一来源,php.net

生成器允许您编写使用 foreach 来迭代一组数据的代码,而无需在内存中构建数组,这可能会导致超出内存限制,或需要大量的处理时间来生成。相反,您可以编写一个生成器函数,它与普通函数相同,只是它不是一次返回,而是生成器可以 yield 多次,以便提供要迭代的值。

例如,您可以简单地编写一个返回许多不同数字或值的函数。但问题是,如果许多不同的值意味着数百万个值,那么制作并返回一个包含这些值的数组是不高效的,因为它将消耗大量内存。因此,在这种情况下,使用generator更有意义。

要了解,请参阅此示例:

/* function to return generator */
function getValues($max){
    for($i=0; $i<$max; $i++ ){
        yield $i*2;
    }
}

// Using generator
foreach(getValues(99999) as $value){
    echo "Values: $value <br>";
}

正如你所看到的,代码中有一个yield语句。它就像 return 语句一样,但在生成器中,yield不会一次返回所有的值。它只会在每次 yield 执行时返回一个值,并且只有在调用generator函数时才会调用 yield。此外,每次 yield 执行时,它都会从上次停止的地方恢复代码执行。

现在我们了解了生成器,让我们看看与生成器相关的 PHP7 功能。

生成器返回表达式

正如我们之前所看到的,在调用生成器函数时,它返回的是由 yield 表达式返回的值。在 PHP7 之前,它没有return关键字返回一个值。但自 PHP7.0 以来,也可以使用 return 表达式。在这里,我使用了 PHP 文档中的一个例子,因为它解释得很好:

<?php

$gen = (function() {
    yield "First Yield";
    yield "Second Yield";

    return "return Value";
})();

foreach ($gen as $val) {
    echo $val, PHP_EOL;
}

echo $gen->getReturn(), PHP_EOL;

它将输出为:

First Yield
Second Yield
return Value

因此,它清楚地显示,在foreach中调用生成器函数不会返回return语句。相反,它只会在每次 yield 时返回。要获取return Value,可以使用这个语法:$gen->getReturn()

生成器委托

函数可以互相调用,同样,生成器也可以委托给另一个生成器。以下是生成器的委托方式:

<?php
function gen()
{
    yield "yield 1 from gen1";
    yield "yield 2 from gen1";
    yield from gen2();
}

function gen2()
{
    yield "yield 1 from gen2";
    yield "yield 2 from gen2";
}

foreach (gen() as $val)
{
    echo $val, PHP_EOL;
}

/* above will result in output:
yield 1 from gen1
yield 2 from gen1
yield 1 from gen2
yield 2 from gen2 
*/

在这里,gen2()是在gen()中调用的另一个生成器,因此在gen()中的第三个 yield,即yield from gen2();,将控制转移到gen2()。因此,它将开始使用gen2()的 yield。

请注意,yield from只能与数组、可遍历对象或生成器一起使用。在yield from中使用另一个函数(而不是生成器)将导致致命错误。

您可以将其视为在另一个函数中调用函数的方式。

匿名类

就像匿名函数一样,现在 PHP 中也有匿名类。请注意,如果需要对象,则很可能我们需要某种特定类型的对象,而不仅仅是随机的,例如:

<?php
class App
{
    public function __construct()
    {
        //some code here
    }
}

function useApp(App $app)
{
    //use app somewhere
}

$app = new App();
useApp($app);

请注意,在useApp()函数中需要一个特定类型的对象,并且如果它不是一个类,那么这个类型App就无法定义。那么我们在哪里以及为什么要使用一个具有特定功能的匿名类?我们可能需要它,以防我们需要传递一个实现某个特定接口或扩展某个父类的类,但只想在一个地方使用这个类。在这种情况下,我们可以使用匿名类。

这是 PHP7 文档中给出的相同示例,这样您就可以更容易地跟进:

<?php
interface Logger {
    public function log(string $msg);
}

class Application {
    private $logger;

    public function getLogger(): Logger {
         return $this->logger;
    }

    public function setLogger(Logger $logger) {
         $this->logger = $logger;
    }
}

$app = new Application;
$app->setLogger(new class implements Logger {
    public function log(string $msg) {
        echo $msg;
    }
});

var_dump($app->getLogger()); //object(class@anonymous)#2 (0) {}

正如您所看到的,尽管在$app->setLogger()中传递了一个匿名类对象,但它也可以是一个命名类对象。因此,匿名类对象可以被命名类对象替换。但是,当我们不想再次使用同一类的对象时,最好使用匿名类对象。

Closure::call()

将对象范围与闭包绑定是使用不同对象的闭包的有效方法。同时,它也是在不同位置为对象使用具有不同行为的不同闭包的简单方法。这是因为它在运行时绑定了对象范围与闭包,而不需要继承、组合等。

然而,以前我们没有Closure::call()方法;我们有类似于这样的东西:

<?php
// Pre PHP 7 code
class Point{
    private $x = 1; 
    private $y = 2;
}

$getXFn = function() {return $this->x;};
$getX = $getXFn->bindTo(new Point, 'Point');//intermediate closure
echo $getX(); // will output 1

但现在有了Closure::call(),可以将相同的代码编写如下:

<?php
//  PHP 7+ code
class Point{
    private $x = 1; 
    private $y = 2;
}

// PHP 7+ code
$getX = function() {return $this->x;};
echo $getX->call(new Point); // outputs 1 as doing same thing

这两个代码片段执行相同的操作。但是,PHP7+代码是简写的。如果需要将一些参数传递给闭包函数,可以在对象之后传递它们,如下所示:

<?php
// PHP 7+ closure call with parameter and binding

class Point{
 private $x = 1; 
 private $y = 2;
}

$getX = function($margin) {return $this->x + $margin;};
echo $getX->call(new Point, 2); //outputs 3 by ($margin + $this->x)

错误和异常

在 PHP7 中,大多数错误现在被报告为错误异常。只有少数致命错误会停止脚本执行;否则,如果进行错误或异常处理,它不会停止脚本。这是因为现在Errors类实现了Throwable接口,就像Exception类一样,它也实现了Throwable。因此,现在在大多数情况下,通过异常处理可以避免致命错误。

以下是错误类的一些子类:

  • TypeError

  • ParseError

  • ArithmeticError

  • DivisionByZeroError

  • AssertionError

这是您可以简单捕获错误并处理它的方式:

try {
    fn();
} catch(Throwable $error){
    echo $error->getMessage(); //Call to undefined function fn()
}

在这里,$error->getMessage()是一个实际返回此消息作为字符串的方法。在我们之前的示例中,消息将类似于:Call to undefined function fn().

这不是您可以使用的唯一方法。以下是在Throwable接口中定义的方法列表;您可以在错误/异常处理期间相应地使用它们。毕竟,ExceptionError类都实现了相同的Throwable接口:

interface Throwable
{
    public function getMessage(): string;
    public function getCode(): int;
    public function getFile(): string;
    public function getLine(): int;
    public function getTrace(): array;
    public function getTraceAsString(): string;
    public function getPrevious(): Throwable;
    public function __toString(): string;
}

PHP7.1

到目前为止,我们讨论的前面的功能都是与 PHP7.0 相关的。然而,PHP7 的最新版本是 PHP7.1,因此讨论 PHP7.1 的重要功能也是值得的,至少是我们将在工作中使用的功能,或者是值得知道并在某个地方使用的功能。

要运行以下代码,您需要安装 PHP7.1,因此,您可以使用以下命令:

sudo add-apt-repository ppa:ondrej/php
sudo apt-get update
(optional) sudo apt-get remove php7.0
sudo apt-get install php7.1 (from comments)

请记住,这不是官方的升级路径。PPA 是众所周知的,并且相对安全。

可空类型

如果我们对参数的数据类型或函数的返回类型进行类型提示,那么重要的是应该有一种方法来传递或返回NULL数据类型,而不是作为参数或返回类型进行类型提示。

可能会有不同的情况需要这样做,但我们总是需要在数据类型之前放置一个?。假设我们想要对string进行类型提示;如果我们想要使其可为空,也就是允许NULL,我们只需将其类型提示为? string。

例如:

<?php

function testReturn(): ?string
{
    return 'testing';
}

var_dump(testReturn());
// string(10) "testing"

function testReturn2(): ?string
{
    return null;
}

var_dump(testReturn2());
//NULL

function test(?string $name)
{
    var_dump($name);
}

test('testing');
//string(10) "testing"

test(null);
//NULL

test();
// Fatal error:  Uncaught ArgumentCountError: Too few arguments // to function test(),

对称数组解构

这不是一个重大的功能,但它是list()的方便缩写。因此,可以在以下示例中快速看到:

<?php
$records = [
    [7, 'Haafiz'],
    [8, 'Ali'],
];

// list() style
list($firstId, $firstName) = $records[0];

// [] in PHP7.1 is having same result
[$firstId, $firstName] = $records[0];

list()中的键的支持

正如您在前面的例子中所看到的,list()与数组一起工作,并按相同的顺序分配给变量。但是,根据 PHP7.1,list()现在支持键。由于[]list()的缩写,[]也支持键。

以下是前述描述的一个示例:

<?php
$records = [
    ["id" => 7, "name" => 'Haafiz'],
    ["id" => 8, "name" => 'Ali'],
];

// list() style
list("id" => $firstId, "name" => $firstName) = $records[0];

// [] style
["id" => $firstId, "name" => $firstName] = $records[0];

在这里,ID$firstId在前面的代码执行后将具有7,而$firstName将具有Haafiz,无论是使用list()样式还是[]样式。

多异常捕获处理

这是 PHP7.1 中一个有趣的功能。以前是可能的,但是需要多个步骤来执行。现在,不仅仅是捕获一个异常并处理它,还可以使用多异常捕获处理功能。语法可以在这里看到:

<?php
try {
    // some code
} catch (FirstException | SecondException $e) {
    // handle first and second exceptions
}

正如您在这里所看到的,有一个管道符号分隔这两个异常。因此,这个管道符号|分隔多个异常。在这个例子中只有两个异常,但可能会有更多。

更多资源

我们讨论了 PHP7 和 PHP 7.1(PHP7 的最新版本)的新功能,这些功能我们要么认为很重要要讨论,要么我们将在本书的其余部分中使用。但是,我们没有完全讨论 PHP7 的功能。您可以在php.net上找到 PHP7 功能列表:php.net/manual/en/migration70.new-features.php

在这里,您可以找到 PHP 7.1 的所有新功能:php.net/manual/en/migration71.new-features.php

总结

在本章中,我们讨论了重要的 PHP7 功能。此外,我们还介绍了新的 PHP7.1 功能。本章涵盖了本书其余部分将使用的基础知识。请注意,使用 PHP7 功能并不是必需的,但它可以帮助我们高效地编写简化的代码。

在下一章中,我们将开始在 PHP 中创建一个 RESTful API,就像我们在第一章中讨论的那样,RESTful Web Services, Introduction and Motivation,同时利用一些 PHP7 的功能。

第三章:创建 RESTful 端点

到目前为止,我们已经了解了什么是 RESTful 网络服务。我们还看到了 PHP7 中的新功能,这将使我们的代码更好,更清晰。现在,是时候在 PHP 中实现 RESTful 网络服务了。因此,本章就是关于实现的。

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

  • 在 PHP 中为博客创建 REST API

  • 创建数据库模式

  • 博客用户/作者表模式

  • 博客帖子表模式

  • 博客帖子评论模式

  • 创建 REST API 的端点

  • 代码结构

  • 常见组件

  • 创建博客文章端点

  • 要做

  • 可见的缺陷

  • 验证

  • 认证

  • 正确的 404 页面

  • 摘要

在 PHP 中为博客创建 REST API

要为博客创建 REST API 或 RESTful 网络服务,我们首先需要有博客实体。由于我们将在数据库中存储博客实体并从数据库中获取数据,因此我们首先需要为这些实体创建数据库模式。

创建数据库模式

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

  • 博客帖子

  • 帖子评论

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

这是我们为具有帖子和评论的博客设计数据库模式的方式。一个帖子可以有多个评论,评论始终属于帖子。在这里,我们有数据库模式的 SQL。您首先需要创建一个数据库,并且需要运行以下 SQL 来拥有帖子和评论表。如果您还没有创建数据库,请立即创建。您可以通过一些 DB UI 工具创建它,或者您可以运行以下 SQL 查询:

create DATABASE blog;

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

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

博客用户/作者表模式

用户表可以具有以下字段:

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

  • name:它将具有VARCHAR类型,长度为 100 个字符。在VARCHAR 100 的情况下,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;

此查询将创建一个如上所述的帖子表。我们尚未讨论的唯一事情是数据库引擎。此查询的最后一行ENGINE = InnoDB将数据库引擎设置为InnoDB。此外,在第 1 行,blog表示数据库的名称。如果您将数据库命名为除 blog 之外的任何其他名称,请将其替换为您的数据库名称。

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

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

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字符串。第二个参数是加密算法。而第三个参数是一个选项数组,我们在其中设置一个随机字符串作为盐。您也可以添加不同的盐。

然而,这个盐需要固定以加密密码,因为这种加密是单向加密。这意味着密码无法解密。因此,每次您需要匹配密码时,您都必须加密用户提供的密码,并将其与数据库中的密码进行匹配。为了匹配用户提供的密码和数据库中的密码,我们需要使用相同的密码函数和相同的参数。

我们现在不会制作用户登录功能,但是以后我们会做。

博客文章表模式

博客文章可以有以下字段:

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

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

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

  • content:内容将是帖子的正文。我们将使用text数据类型来存储内容。

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

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

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

以下是用于帖子表的 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;

此查询将创建一个如前所述的帖子表。

现在,我们添加外键来限制user_id只能有用户表中存在的值。以下是我们将添加该约束的方式:

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

博客文章评论模式

博客文章评论可以有以下字段:

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

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

  • post_idpost_id将是整数类型。它将是与帖子表中的id相关联的外键。

  • user_iduser_id 将是整数类型,它将是外键,并将与用户表中的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 访问它们;例如,用户可以输入:localhost:8000/posts.php,这将执行posts.php中的代码。在comments.php中也可以做同样的事情。

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

  • 第一个问题是posts.phpcomments.php将有不同的代码。这意味着,如果我们必须在这些不同的文件中使用相同的代码,我们将需要在这两个文件中写入或包含所有共同的东西。实际上,如果将会有更多的资源,那么我们将需要为每个资源创建一个不同的文件,并且在每个新文件中,我们将需要包含所有共同的代码。尽管现在只有两个资源,但我们也需要考虑可扩展性。因此,在这种方法中,我们将需要在所有文件中具有相同的代码。即使我们只是在所有文件中进行包含或需要,我们也需要这样做。但是,通过最小化要包含或需要的文件,可以解决或减轻这个问题。

  • 第二个问题与它在 URL 中的显示方式有关。在 URL 中,提到要使用的事实文件,所以如果在完成我们的端点并且将 API 提供给前端开发人员后,我们需要在服务器上更改文件名怎么办?前端应用程序的网络服务将无法正常工作,除非我们在前端应用程序中更改 URL 中的文件名。这指向了关于我们的请求以及服务器上存储的东西的一个重要问题。这意味着我们的代码将紧密耦合。这不应该发生,因为我们在第一章中所述的 REST 的约束中已经说明了。这个.php扩展名不仅暴露了我们在服务器端使用 PHP,而且我们的文件结构也暴露给了所有知道端点 URL 的人。

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

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

那么这个问题的解决方案是什么呢?如果我们可以有一个单一的入口点怎么办?如果所有请求都通过同一个入口点,然后路由到适当的代码呢?那将是一个更好的方法。请求将与帖子或评论相关联,它必须通过同一个单一入口点,而在该入口点,我们可以包含任何我们想要的代码。然后,该入口点将路由请求到适当的代码。这将解决两个问题。此外,事情将按照一种模式进行,因为每个资源的代码将遵循相同的模式。我们刚讨论的这种模式也被称为前端控制器。您可以在 wiki 上阅读有关前端控制器的更多信息: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 内置服务器。请注意,您不需要 Apache 或 NGINX 来运行 PHP 代码。PHP 有一个内置服务器,尽管这对测试和开发环境很好,但不建议用于生产。因为我们在本地机器上的开发环境中,让我们运行它:

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

这将使您能够通过 PHP 内置服务器访问http://localhost:8000,并输出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目录中创建。在博客目录中,我们将创建一个核心目录,因为我们将保留与核心目录中代码执行流程和模式相关的代码部分。这将是与 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是我们的应用程序运行的结构。所以让我们深入了解一下。

第一条语句从同一目录(即 core 目录)中需要一个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中到底发生了什么。

DB 类

这是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;
 } } } }

Router有两个方法,Router::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 -S localhost:8000来启动 PHP 服务器,然后在浏览器中输入:http://localhost:8000/posts,你应该会看到:帖子将在这里显示

如果你无法运行它,我建议你回去检查你漏掉了什么。你也可以使用本书提供给你的代码。无论如何,现在有必要在这一点上成功地编写和运行这段代码,因为仅仅阅读是不够的,实践会让你变得更好。

创建博客文章端点

到目前为止,我们已经完成了大部分通用代码。所以让我们来看看博客文章端点。在博客文章端点中,第一个是博客文章列表。

博客文章列表端点:

  • URI:/api/posts

  • 方法:GET

因此,让我们用适当的代码替换posts.php中的先前代码来提供帖子。为了提供这个,将以下代码放入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 格式获得一个空数组,这是可以的。由于目前在帖子表中没有记录,因此显示为空数组。让我们创建并使用添加帖子端点。

博客帖子创建端点:

  • URI:/api/posts

  • 方法:POST

  • 参数:titlestatuscontentuser_id

现在,我们只是让这些端点在没有用户身份验证的情况下工作,所以我们自己传递user_id。因此,它应该是来自用户表的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,然后使用绑定语句将变量与这些静态字符串绑定。那么这样做的目的是什么呢?原因是我们不能信任用户输入。直接将用户输入添加到 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; }

由于我们将其编写为一个单独的通用函数,因此我们可以在多个地方使用它。此外,无论在帖子表中有多少字段,我们都不需要在代码中反复调用相同的PDOStatement::bindValue()方法。我们只需在$allowedFields数组中添加更多字段,bindValue()方法将自动调用。

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

REST 客户端

非常流行的 REST 客户端之一是 Postman。Postman 是一个谷歌 Chrome 应用程序。如果您使用 Chrome,那么您可以从这里安装此应用程序:chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop/related?hl=en

一旦您打开 Postman,您就可以选择方法为 POST 或任何其他方法,然后在选择 Body 选项卡时,您可以设置字段名称和值,然后点击发送。检查 Postman 的以下屏幕截图,其中设置了字段和响应。这将让您了解 Postman 如何用于发送请求:

您可以看到通过 Postman 发送了 POST 请求,并且结果成功,正如我们所期望的那样。对于所有端点测试,可以使用 Postman。

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

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

获取单个帖子端点:

  • URI:/api/posts/{id}

  • 方法:GET

这个带有GET方法的 URL 应该根据提供的 ID 返回单个帖子。

为了实现这一点,我们需要做两件事:

  • 在这种模式的情况下,添加一个条件和代码,其中方法是GET,URL 是这种模式。

  • 我们需要编写并调用getPost()方法,从数据库中获取单个帖子。

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

首先,我们将添加一个条件和代码来返回单个帖子:

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(),它将从数据库中获取帖子记录。因此,这是我们将在同一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查询及其执行都足够简单。

更新帖子端点:

  • URI:/api/posts/{id}

  • 方法:PATCH

  • 参数:titlestatuscontentuser_id

这里的{id}将被实际帖子的 ID 替换。请注意,由于我们使用了PATCH方法,因此只应更新输入方法中存在的属性。

在这里,我们将user_id作为参数传递,但这只是因为我们没有进行身份验证,否则严格禁止将user_id作为参数传递。user_id应该是经过身份验证的用户的 ID,并且应该在参数中使用而不是获取user_id。因为它可以让任何用户通过在参数中传递另一个user_id来假装成其他人。

请注意,在使用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 postsSET $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 客户端进行测试时,我们需要在查询字符串中传递参数,而不是在正文中传递。

删除帖子端点:

  • 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(); }

在查看插入、获取和更新帖子端点的代码之后,这段代码非常简单。在这里,主要工作在于deletePost()方法,但它也与其他方法非常相似。

有了这个,我们现在已经完成了与端点相关的帖子。然而,现在我们返回的所有数据都不是真正的 JSON,对于客户端(浏览器或 Postman)来说,它仍然被视为字符串,并被视为 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 中做到这一点,但这并不是最佳方法。

要做的事情

由于我们已经完成了 Post CRUD 端点,你需要创建 Comments CRUD 端点。这不应该很困难,因为我们已经在路由中放置了评论,你知道我们将添加comments.php类似于posts.php。你也可以在posts.php文件中查看逻辑,因为comments.php将具有相同的操作和类似的代码。所以现在,是你编写comments.php CRUD 相关端点的时候了。

可见的缺陷

尽管我们在前面的章节中讨论的代码将起作用,但其中存在许多漏洞。我们将在接下来的章节中探讨不同的问题,然而在这里让我们看看其中的三个问题,以及如何解决它们:

  • 验证

  • 认证

  • 404 的情况下没有响应

验证

现在在我们的代码中,虽然我们使用了PDO准备和bindValue()方法,它只是保存了我们免受 SQL 注入的影响。然而,在插入和更新的情况下,我们没有验证所有字段。我们需要验证标题应该是特定限制的,状态应该是草稿或已发布,user_id应该始终是用户表中的 ID 之一。

解决方案

第一个简单的解决方案是放置手动检查来验证来自用户端的数据。这很简单,但是工作量很大。这意味着它会起作用,但我们可能会漏掉一些东西,如果我们没有漏掉任何检查,那将是很多低级别的细节要处理。

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

事实上,这不仅仅是关于验证的问题,而且在本章中我们仍然在做很多低级别的工作。因此,我们将看看如何通过使用 PHP 社区中可用的不同工具来最小化我们在低级别工作上的努力。

认证

现在,我们让任何人都可以添加、读取、更新和删除任何记录。这是因为没有经过身份验证的用户。一旦有了经过身份验证的用户,我们可以放置不同的约束,比如用户不应该能够删除或更新不同用户的内容等等。

那么为什么我们不简单地使用基于会话的身份验证,将Session ID放在 HTTP Only cookie 中呢?这在传统网站上是这样做的。我们启动会话,将用户数据放入会话变量中,会话 ID 存储在 HTTPOnly cookie 中。服务器总是读取那个 HTTP Only cookie,并获取会话 ID 来知道这个用户的会话数据属于哪个用户。这是在 PHP 开发的典型网站中发生的情况。那么为什么在 RESTful web 服务的情况下,我们不简单地使用相同的方法进行身份验证呢?

因为 RESTful web 服务并不仅仅是通过 web 浏览器调用。它可以是任何东西,比如移动设备、另一个服务器,或者可以是 SPA(单页应用)。因此,我们需要一种可以与任何这些东西一起工作的方式。

解决方案

一个解决方案是,我们将使用一个简单的令牌,而不是会话 ID。而且,这个令牌将只被发送给客户端,而不是存储在 cookies 中,客户端将始终在每个请求中携带该令牌以识别客户端。一旦客户端在每个请求中携带令牌,无论客户端是移动应用程序、SPA 还是其他任何东西,都不重要。我们将根据令牌简单地识别用户。

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

适当的 404 页面

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

总结

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

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

为了采用标准的编码风格和实践,你可以阅读 PHP 编码标准和风格:www.php-fig.org/

我建议你花一些时间在这两个 URL 上,这样你就可以写出更好的代码。

在下一章中,我们将详细研究这个问题,并识别这段代码中的不同缺陷,包括安全和设计缺陷。同时,我们也会看不同的解决方案。

第四章:审查设计缺陷和安全威胁

在本章中,我们将回顾我们的工作,我们实现的端点,并将探讨我们当前工作可以改进和应该改进的两个不同方面。我们还将看到:

  • 我们的代码结构和设计缺陷

  • 安全威胁以及我们如何减轻它们

然后,我们将探讨如何继续实现一个 RESTful API,并对前两节讨论的改进进行深入研究。

找出当前代码中的问题

到目前为止,我们已经编写了与博客文章端点相关的代码,并且我让你自己来处理评论相关的端点。如果你还没有做到这一点,那么我坚持你首先这样做,或者至少尝试这样做,因为没有实践,知识不会持续很长时间,所以至少在提供一些代码示例或有一些任务要做时保持练习。

无论如何,在上一章中,我们已经编写了实现 RESTful Web 服务端点的代码,我们将深入研究并确定缺少什么以及需要哪些改进。

结构和设计缺陷

现在在我们的代码中,有一些明显的缺陷我们可以确定。

缺少查询构建器层

尽管我们使用 PDO,但我们仍然需要编写查询并需要执行许多低级操作,比如意识到 SQL 注入(因此我们必须使用准备语句,然后绑定值)来执行与数据库相关的操作。我们应该使用某种查询构建器层,它可以为我们生成查询。因此,一旦我们有了这个层,我们就不需要一遍又一遍地编写 SQL 查询。

尽管 PDO 使得很容易将一个数据库连接换成另一个,但仍然有一些 SQL 查询需要针对不同的数据库进行更改。事实上,不仅对于更改 DBMS 有好处,而且,拥有一种查询构建器也是节省时间的,因为使用查询构建器,我们不总是处理字符串来构建查询,而是可以使用数组或关联数组来构建查询。

不完整的路由器

我们实现的路由器只是路由不同的文件,比如/posts是通过posts.php来提供的。我们的路由器没有指定posts.php的哪个函数将处理该请求。我们是根据 URL 模式从posts.php内部指定的。提醒一下,这是posts.php中的条件部分:

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

这并不难。我们可以简单地在router.php中设置这样的条件,并在posts.php中调用适当的函数。然而,如果你还记得我们的routes.php文件,它是一个非常简单的文件,包含键值对。为了方便起见,我再次放在这里:

<?php

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

正如你所看到的,我们没有在routes.php中指定请求方法,所以我们也需要在routes.php中指定。除此之外,我们还需要在routes.php中使用正则表达式而不是普通的 URL。在routes.php中做到这一点很容易,但我们需要添加实现的实际位置将是core/router.php。这是可以做到的,但我们不会这样做。我们不会从头开始制作诸如路由器之类的组件,因为这不是世界上第一次做的事情。那我们该怎么办呢?我们可以使用已经可用的开源组件或包中的路由器。稍后,我们将看到如何重用已经存在的开源包或组件。

面向对象编程的使用

我们应该使用面向对象的范式,因为这不仅有助于使代码更好、更清晰,而且随着时间的推移,它还可以使开发更快,因为干净的代码减少了我们编写更多功能或修改代码的摩擦。

将配置与实现分开

配置应该更好。我们有一个包含数据库连接信息的config文件,这很好,但还有许多其他东西应该在配置中,例如,是否显示错误应该通过config文件来控制。

因此,经验法则是我们应该将配置与实现分开。这很重要,这样我们就可以随时更改配置,而不必担心与逻辑等代码实现相关的问题。

应该写测试

无论您是编写 RESTful Web 服务还是制作网站,编写测试用例都是非常重要的。为此,代码也必须是可测试的。因此,测试(单元测试)不仅测试代码是否符合要求,还检查代码是否足够灵活和松散耦合。与松散耦合的代码相比,紧密耦合的代码不能那么容易地进行测试。

在代码中编写测试用例也使代码更清晰、更灵活,可以轻松修改。在 Web 服务的情况下,API 测试也很方便。

输入验证

如上一章所述,尽管我们使用 PDO 的prepare()bindValue()方法避免了 SQL 注入,但我们并没有验证来自输入源的数据。这是因为我们在上一章只是为了理解和学习而编写代码。否则,没有输入验证不仅不方便,而且对应用程序也不安全。

要应用验证,我们可以使用手动检查,或者编写一个验证器,我们可以简单地传递输入参数并根据特定规则进行检查。这种类型的验证器非常方便,但编写一个好的验证器也需要时间,因此最好使用已经存在的开源验证器。正如您所看到的,我们试图编写一个路由器,然后发现了问题。这些问题可以解决,但我们需要编写更多的代码,编写更多的代码需要时间。

在后面的章节中,我们将看到如何使用别人编写的验证器,并将其用于创建 RESTful Web 服务端点。我们不仅试图节省编写代码的时间,而且还试图避免有更多的代码,其维护将成为我们的责任。

处理 404 和其他错误

现在,如果博客文章或评论的 URL 或 ID 错误,我们还没有处理 404,所以我们需要处理这个问题,不仅发送未找到的错误,还要发送 HTTP 状态码 404。因此,对于不同的响应,我们需要发送不同的 HTTP 状态码。

元信息缺失

现在,没有记录计数,也没有分页。所有记录都显示出来了。所以如果有很多记录,比如说几百万条记录,那么返回所有记录就没有意义了。在这种情况下,我们应该应用分页,并且在响应中应该有一个适当的位置显示元信息。

数据库字段抽象

目前,从数据库中获取的所有数据都原样显示给用户。如果字段名称发生变化,客户端开发人员正在使用该数据库字段会怎么样?这也会在客户端出现错误。

如果您记得的话,REST 的一个重要约束是服务器返回给客户端的内容与服务器实际存储数据的方式之间的抽象。因此,我们需要保持这种抽象。在接下来的章节中,我们将看到如何保持这种抽象。

安全性

正如你所看到的,我们根本没有应用任何类型的安全性。事实上,我们还没有使所有端点都受到登录保护。但是,在现实世界中,不可能没有登录或身份验证。因此,我们需要使一些端点受到登录保护。

在本章中,我们只会看到如何为我们的端点实现安全性,但我们还不会实现,将在后面的章节中实现。现在我们正在研究如何使一些资源受到登录保护,因为基于此,我们将能够识别其他安全风险。因此,在本章的下一节中,我们将看到身份验证的工作原理。

保护 API 端点

首先,我们需要了解身份验证和登录的工作原理。客户端应用程序首次发送登录凭据(通常是电子邮件地址和密码)。基于这些凭据,服务器端的登录端点进行用户登录并返回针对经过身份验证的用户的令牌。该令牌存储在客户端。在每个请求中,客户端都会在请求体或请求头中携带该令牌。可以在以下图表中更清楚地看到。

首先,客户端将使用登录凭据访问服务器上的登录端点:

客户端获得令牌后,将其存储以备后用。然后,每次请求时,客户端都会发送相同的令牌,以便服务器可以将客户端视为经过身份验证的:

当服务器发现客户端经过身份验证时,它将返回基于经过身份验证的用户的数据。

如果请求中没有发送令牌,而只允许经过身份验证的用户,则服务器应返回 401 HTTP 状态码,表示未经身份验证或未经授权。

例如,考虑POST端点。有一些端点,如创建帖子、修改帖子和删除帖子;这些需要受到保护,因此应该有Auth 中间件来保护这些端点,而其他一些GET端点,如显示帖子和列出帖子等端点不需要登录保护,因此应该有Auth 中间件来保护受保护的端点。具体如下:

正如您在此图表中所看到的,服务器将根据提供的身份验证令牌做出响应,Auth 中间件只是为了从身份验证令牌中解析用户。但是,如果Auth 中间件无法从身份验证令牌中解析用户,它将简单地返回 401 未经授权的错误。

什么是身份验证中间件?

Auth 中间件将不过是一小段代码,用于验证身份验证令牌,并尝试解析该身份验证令牌的用户。它只是一小段代码,将附加到路由中的某些端点或返回端点数据的位置。在任何情况下,它都会在端点的实际代码之前执行,并验证并解析来自请求的auth令牌的用户。

在第六章中,用 Lumen 照亮 RESTful Web 服务,我们将研究中间件,在第七章中,改进 RESTful Web 服务,我们将编写身份验证中间件的代码。

RESTful Web 服务中的常见安全威胁

既然我们已经看到了当前代码中的问题以及我们将如何在一些端点中实现安全性并使用身份验证中间件,现在是时候看看在构建 RESTful Web 服务时需要考虑的常见安全威胁了。

使用 HTTPS

HTTPS 是带有 SSL 的 HTTP。由于我们的数据正在互联网上传输,我们需要确保我们的连接是安全的;因此,我们应该使用 HTTPS。HTTPS 的目的是确保服务器是其所声称的,并且数据以加密形式在客户端和服务器之间的安全连接中传输。

如果您不想购买 SSL 证书,因为对您来说成本太高,那么您可以简单地选择letsencrypt.org/. Let's Encrypt 是一个免费的证书颁发机构。因此,您可以在不支付 SSL 证书费用的情况下使用它。

保护 API 密钥/令牌

由于我们的会话将基于令牌,因此我们需要保护认证令牌。有一些需要做的不同的事情:

  1. 不要在 URL 中传递访问令牌。

  2. 访问令牌过期。

不要在 URL 中传递访问令牌

应该将需要发送到服务器的 API 密钥、令牌或其他敏感信息传递到 POST 主体或请求头中,而不是在 URL 中传递,因为这样可以被 Web 服务器日志捕获。

访问令牌过期

访问令牌应该在两种情况下过期。首先,注销时应该过期。其次,访问令牌应该在固定的时间后过期,且这段时间不应该太长。令牌过期的原因是,让访问令牌的有效时间更短更安全。如果我们有许多未使用的访问令牌,那么这些令牌被滥用的可能性就更大。

过期时间大约为两个小时或更短。虽然这取决于您想要如何实现,但较短的过期时间更安全。过期并不意味着用户需要重新登录,而是会有一个令牌刷新端点。将使用最后一个过期的令牌针对特定用户来获取新的令牌。请注意,最后一个令牌应该在有限的时间内可用于刷新令牌端点,之后最后一个令牌不应该可用于刷新令牌。否则,令牌过期有何意义。请记住,两种方式之间存在权衡。每次请求都刷新令牌更安全,但会给服务器带来更多开销。因此,您可以根据自己的情况选择哪种方式。

过期令牌的另一种方法是不通过时间来过期,而是在每次请求时刷新令牌。例如,如果使用一个令牌发送请求,服务器将验证该令牌,刷新令牌,并在响应中发送一个新的令牌。因此,旧令牌将不可用。令牌将在每次请求时刷新。可以用两种方式来实现;这取决于您如何偏好。

有限范围的访问令牌

限制访问令牌的范围也是一个好主意,以避免未经授权的人获得令牌时出现问题。此外,如果向客户端应用程序提供的服务不针对特定用户或访问权限,那么它仍应该具有某种 API 密钥,以便我们可以确定谁在请求信息。因此,如果有可疑的尝试使用某个 API 密钥访问 API 端点,我们可以简单地撤销特定的 API 密钥,这样它将不再对未来的请求有效。只有当有多个 API 密钥具有有限的访问级别时才可能实现这一点。

公共和私有端点

就像公共网页一样,我们也可以为 RESTful 网络服务设置公共端点。在身份验证之前对用户可用的所有端点都不是公共的。有时,我们创建的端点在登录之前或者无需登录即可使用,但它们只能通过我们的应用程序访问。这些端点不是公共的,因此我们不希望其他应用程序可以访问这些端点。为此,我们将使用一些 API 密钥,如前面讨论的那样。

我们可以使用基于oauth2的访问令牌。使用oauth2访问令牌的一个重要优势是,如果我们正在创建不同的应用程序来访问相似的端点,那么我们可以为不同的应用程序使用不同的访问令牌。

示例:我们可以将在线书店 API 公开为 RESTful 网络服务,并且我们可以有两个应用程序:

  • 图书销售app。给顾客。

  • 图书选择app.给老师。

现在通过客户端的应用程序,用户可以浏览不同的书籍,加入购物车并购买。而在教师的应用程序中,用户可以浏览和选择不同的书籍,以便稍后购买书籍的人。这两个不同的应用程序将有一些共同的端点和一些不同的端点。但是,我们不希望任何端点对每个人都是公开的。因此,我们可以有两个不同的访问级别,并制作两个不同的移动应用程序,每个应用程序都有不同的 API 密钥,每个应用程序都有不同的访问级别。当用户登录时,我们将返回一个具有有限访问权限的访问令牌。不同的令牌可以根据用户角色具有不同的访问级别。

假设在教师的应用程序中,有些教师只能选择书籍,而另一些教师,比如HOD(系主任)也可以购买书籍。因此,在登录后,这两个用户可以具有不同的访问令牌,转换为不同的访问级别。这个访问级别将基于访问令牌,将被转换为已登录的用户,并且我们将根据用户的角色决定访问级别。

公共 API 端点

因此,即使在登录之前,这些端点也是私有的。如果我们有一些公共的 API 端点,比如天气预报向每个人提供预报数据。最好还是有一个 API 密钥来跟踪谁在向服务器获取数据,但如果不是这种情况,我们只是在没有任何 API 密钥的情况下提供数据呢?这是否意味着我们是公开提供这些数据,所以我们不需要担心任何事情?实际上,不是。

如果客户端向服务器传递任何信息,最好使用 TLS 来加密数据。除此之外,我们也不能允许任何人不断地命中一个端点;为了公平使用,我们需要应用节流,这意味着 API 端点只能在特定时间段内被一个客户端命中有限次数。

不安全的直接对象引用

不安全的直接对象引用是指根据来自请求的数据获取或提供敏感信息。这不仅是 RESTful Web 服务的问题,也是网站的问题。为了理解这一点,让我们举个例子:

假设我们要更改用户的名字或账单地址。最好是将其引用到一个端点,比如:“PATCH /api/users/me?fist_name=Ali(在标头中具有令牌)”,而不是“PATCH /api/users/2?fist_name=Ali(在标头中具有令牌)”。

为了让用户修改自己的数据,它将在标头中具有一个令牌,服务器将确保该用户可以修改记录。但是,哪条记录?在具有“我”的端点中,它将根据令牌获取用户,并修改其first_name

在第二种情况下,我们有用户的id=2,因此可以根据用户id=2获取或更新用户,这是不安全的,因为用户可以在 URL 中传递任何用户 ID。因此,问题不在于这种类型的 URL,问题在于根据用户输入或客户端请求直接获取或更新记录。无论提供什么用户 ID,如果我们打算修改已登录用户的名字,那么它应该根据令牌获取或更新用户,而不是 URL 中的用户 ID。

限制允许的动词

我们需要限制允许的动词。例如,如果 Web 服务端点仅用于读取目的而不用于修改,则在 URL/api/post/3上,我们应该只允许“GET 方法/动词”,而不应该允许“PATCH、PUT”、DELETEPOST。如果有人使用PATCHPUTDELETEPOST命中/api/post/3,它不应该提供服务,而应该返回“405 方法不允许”错误。

然而,如果他们的客户端有访问令牌,并且基于此,用户只被允许使用GET方法(尽管还有其他方法可用),而不是其他方法,那么具有该用户的客户端使用其他方法访问相同的 URL,那么应该出现“403 禁止”错误,因为虽然有允许的方法,但基于其角色或权限,当前用户只是不被允许使用。

输入验证

似乎输入验证可能与技术无关,但验证输入非常重要,因为在数据库中拥有干净的数据不仅有益,而且还有助于防范诸如 XSS 和 SQL 注入等不同威胁。

实际上,XSS 预防和不同的输入验证是输入验证的重要部分,而 SQL 注入主要是在向数据库输入数据时防止的。另一种需要防范的威胁是 CSRF,但这将通过 API 密钥或身份验证令牌的使用已经得到防范。然而,也可以使用单独的 CSRF 令牌。

可重用的代码

我们没有讨论每一个安全威胁,但我们使用了一些需要注意的东西,以避免与安全相关的问题。我们已经讨论了如何保护我们的端点以及如何为 RESTful Web 服务实施身份验证。我们还讨论了我们在上一章中编写的当前代码中的缺陷。

然而,我们还没有编写代码来使我们的代码更好、更安全。我们可以做到这一点,但我们应该明白,已经有很多东西可以利用,而不是从头开始做一切。因此,我们将使用可用的代码,而不是自己用纯 PHP 编写一切。这不仅是为了节省时间,而且是为了使用社区中可用的东西,经过社区的时间测试。

因此,如果我们已经决定使用第三方代码片段、包或类,那么我们应该明白,在 PHP 中没有一个开发人员组在一个框架中编写代码。有许多 PHP 类作为独立类可用。有些是为某些框架编写的。有些是为 WordPress 等开源 CMS 编写的。还有一些包可在 PEAR(PHP 扩展和应用程序存储库)中找到。因此,一个地方可用的代码可能对其他代码无用或不兼容。

事实上,加载不同的代码片段在一起也可能是一个问题,特别是当有很多依赖关系时。

因此,PHP 社区迎来了革命。这不是一个框架、CMS 或开源类或扩展,而是 PHP 的依赖管理器,称为 Composer。我们可以以标准方式安装 Composer 包,Composer 已成为大多数 PHP 流行框架的标准。我们在这里不会更多地讨论 Composer,因为 Composer 是下一章的主题,所以我们将详细讨论它,因为我们将大量使用 Composer 进行包安装、依赖管理、自动加载等。不仅在本书中,而且如果您要在 PHP 中制作任何适当的应用程序,您都需要一个 composer。因此,我们将主要通过 Composer 包使用可重用的代码。

总结

我们已经讨论了当前代码中的问题和缺失部分以及安全威胁,并讨论了我们将如何实施身份验证。我们还讨论了我们将使用可重用组件或代码来节省时间和精力。此外,因为代码将由我们自己编写,我们将负责其维护和测试,因此使用开源的东西,这些东西不仅可用,而且在许多情况下经过测试,以及由社区维护,更有意义。出于这个目的,我们将主要使用 Composer,因为它已成为 PHP 中打包和使用可重用包的标准工具。

在下一章中,您将了解更多关于 Composer 的信息。它是什么,它是如何工作的,以及我们如何将其用于不同的目的。

在本章中,我们已经讨论了安全威胁,但我们并没有详细地涵盖它们,因为我们只有一个章节来讨论。但是,Web 应用程序和 RESTful Web 服务的安全性是一个广泛的话题。关于这个话题还有很多东西需要学习。我建议你去查看www.owasp.org/index.php/Category:OWASP_Top_Ten_Project作为一个起点。那里有很多东西可以学到,你也会从不同的角度学到东西。

第五章:使用 Composer 进行加载和解析,一个进化

Composer 不仅是一个包管理器,也是 PHP 中的依赖管理器。在 PHP 中,如果你想要重用一个开源组件,标准的做法是通过 Composer 使用开源包,因为 Composer 已经成为了制作包、安装包和自动加载的标准。在这里,我们讨论了一些新术语,比如包管理器、依赖管理器和自动加载。在本章中,我们将详细讨论它们是什么,以及 Composer 为它们提供了什么。

前面的段落解释了 Composer 主要的功能,但 Composer 不仅仅是这样。

在本章中,我们将看到以下内容:

  • Composer 简介

  • 安装

  • 使用 Composer

  • Composer 作为包和依赖管理器

  • 安装包

  • Composer 的工作原理

  • Composer 命令

  • composer.json文件

  • composer.lock文件

  • Composer 作为自动加载程序

Composer 简介

PHP 社区有点分裂,有很多不同的框架和库。由于有不同的框架可用,为一个框架编写的插件或包不能在另一个框架中使用。因此,应该有一种标准的方法来编写和安装包。这就是 Composer 的作用。Composer 是编写、分发和安装包的标准方法。Composer 受到了node.js生态系统中的npmNode Package Manager)的启发。

事实上,大多数开发人员使用 Composer 来安装他们使用的不同包。这也是因为使用 Composer 安装包很方便,因为通过 Composer 安装的包也可以很容易地通过 Composer 进行自动加载。我们将在本章后面讨论自动加载。

如前所述,Composer 不仅是一个包管理器,也是一个依赖管理器。这意味着如果一个包需要某些东西,Composer 将为其安装这些依赖项,然后相应地进行自动加载。

安装

Composer 需要 PHP 5.3.2+才能运行。以下是你可以在不同平台上安装 Composer 的方法。

在 Windows 上安装

在 Windows 上,安装 Composer 非常容易,所以我们不会详细介绍。你只需要从getcomposer.org下载并执行 Composer 安装程序。这是链接:getcomposer.org/Composer-Setup.exe

在 Linux/Unix/OS X 上安装

安装 Composer 有两种方式,分别是本地安装和全局安装。你可以通过以下命令简单地安装 Composer:

$ php -r "copy('https://getcomposer.org/installer', 'Composer-setup.php');"
$ php -r "if (hash_file('SHA384', 'composer-setup.php') === '669656bab3166a7aff8a7506b8cb2d1c292f042046c5a994c43155c0be6190fa0355160742ab2e1c88d40d5be660b410') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
$ php composer-setup.php
$ php -r "unlink('composer-setup.php');"

前面的四个命令分别执行以下任务:

  1. 下载 Composer 安装 PHP 文件

  2. 通过检查 SHA-384 验证安装程序

  3. 运行 Composer 安装程序安装 Composer

  4. 删除已下载的 Composer 安装文件

如果你对这个 Composer 安装做了什么很好奇,那就留下第四个命令。你可以看到,安装文件是一个名为composer-setup.php的 PHP 文件;你可以简单地打开这个文件并阅读代码。它主要是检查几个 PHP 扩展和设置,并创建composer.phar文件。这个composer.phar将负责执行 Composer 的任务。我们将很快在本章中看一下 Composer 的功能以及它执行的操作或任务。

使用上述命令,我们已经在本地安装了 Composer。默认情况下,它会在当前目录安装 Composer,这意味着通过安装 Composer 来放置composer.phar文件,因为这个composer.phar文件执行 Composer 功能。

如果你希望在特定目录中安装 Composer(放置composer.phar)或将composer.phar名称更改为其他名称,你可以简单地使用不同的参数运行安装,比如:

php composer-setup.php --install-dir=bin --filename=composer

还有许多其他参数,你可以在getcomposer.org/download/的“安装程序选项”下看到。

如果您已经在本地安装了 Composer,并且文件名为composer.phar,您可以通过以下方式简单地通过 PHP 运行它:

php composer.phar

这将运行 Composer。如果composer.phar不在之前composer.phar的相同目录中,则需要附加composer.phar文件所在目录的路径。这是因为我们已经在本地安装了 Composer。因此,让我们看看如何全局安装它。

全局安装

要在任何地方安装和访问 Composer,我们需要将其放在系统的PATH目录中添加的目录中。

为此,我们可以运行以下命令将composer.phar移动到一个我们可以全局访问的位置:

sudo mv composer.phar /usr/local/bin/Composer

现在,您只需通过运行命令composer就可以访问 Composer,并且它将正常工作;不需要其他任何东西。所以,如果您说:

composer -V

它将返回类似以下的内容:

composer version 1.4.2 2017-05-17 08:17:52

您可以从任何地方运行此命令,因为我们已经全局安装了 Composer。

Composer 的用法

Composer 是一个依赖管理器,还有其他不同的用途。Composer 用于在解决依赖关系的同时安装软件包。Composer 在自动加载方面也非常出色。Composer 还有更多的用途。在这里,我们将讨论 Composer 的不同用途。

Composer 作为依赖管理器

Composer 是一个依赖管理器。现在,您可以以一种不需要将第三方依赖项与其一起提供的方式打包您的代码。您只需要告诉它的依赖关系。实际上,您的软件包依赖关系可能有更多的依赖关系,而这些依赖关系也可能有更多的依赖关系。因此,在制作软件包或捆绑包时解决所有这些依赖关系可能会非常繁琐。但是,由于 Composer 的存在,这并不是问题。

由于 Composer 也是一个依赖管理器,因此依赖关系不再是问题。我们只需在一个 JSON 文件中指定依赖关系,Composer 就会解决这些依赖关系。我们将很快看一下该 JSON 文件。

安装软件包

如果我们在名为composer.json的 JSON 文件中有依赖关系(我们的工作依赖或将要依赖的其他软件包),那么我们可以通过 Composer 安装它们。

PHP 中有很多好的软件包,我们日常工作相关的大部分内容都是可用的,那么谁会想要重新发明轮子并重新创建所有东西呢?因此,要启动一个项目,我们可以通过 Composer 简单地安装不同的软件包,并重用已经存在的大量代码。

现在,问题是,Composer 可以从哪里安装?它可以是互联网上的任何地方吗?还是有一些固定的地方可以安装 Composer 软件包?实际上,可以有多个来源。Composer 安装软件包的一个默认位置是 Packagist packagist.org/

因此,我们将从 Packagist 安装一个软件包。假设我们想要从 Packagist 安装一个 PHP 单元测试框架软件包。它在这里可用:packagist.org/packages/phpunit/phpunit

因此,让我们使用以下命令进行安装:

composer require phpunit/phpunit

您将看到此软件包安装还将导致许多依赖项的安装。在这里,require是 Composer 命令,而phpunit/phpunit是此软件包在 Packagist 上注册的名称。请注意,我们刚刚讨论了composer.json文件,但我们不需要composer.json文件来安装此 PHP 单元软件包。实际上,如果我们已经有一些依赖关系,composer.json文件是有用的。如果我们现在只需要安装一些软件包,那么我们可以简单地使用composer require命令。而且这个composer require还会创建一个composer.json文件,并将其更新为phpunit/phpunit软件包。

这是在运行上述命令后将创建的composer.json文件的内容:

{
    "require": {
        "phpunit/phpunit": "⁶.2"
    }
}

您可以在 require 对象中看到,它具有包名称的键,然后在冒号后面是 "⁶.2",表示包版本。在这里,包版本以正则表达式给出,表示包版本从 6.2 开始,但这并不是实际安装的版本。安装包及其依赖项后,它们的确切版本将写入 composer.lock 文件中。这个文件 composer.lock 具有重要意义,所以我们很快将详细了解它。

运行此命令后,您将能够在运行 Composer require 命令的目录中看到另一个目录。这个目录是 vendor 目录。在 vendor 目录中,安装了所有的包。如果您查看它,您会发现不仅 PHP 单元存在于 vendor 目录中,而且所有的依赖项和依赖项的依赖项都安装在 vendor 目录中。

使用 composer.json 进行安装

除了使用 composer require,如果有一个 composer.json 文件,我们还可以通过另一个命令安装包。为此,进入另一个目录。我们可以简单地创建一个包含以下内容的 composer.json 文件:

{
    "require": {
        "phpunit/phpunit": "⁶.2",
        "phpspec/phpspec": "³.2"

    }
}

因此,一旦您有一个名为 composer.json 的文件,并且其中包含这些内容,您可以通过运行此命令根据这些版本信息安装这两个包及其依赖项:

composer install

这将执行与 Composer require 相同的操作。但是,如果同时存在 composer.jsoncomposer.lock 文件,它将从 composer.lock 文件中读取信息,并安装该确切版本,忽略 composer.json

如果要忽略 composer.lock 文件并根据 composer.json 文件中的信息进行安装,可以删除 composer.lock 文件并使用 composer install,或者运行:

composer update

注意,composer update 命令也会更新 composer.lock 文件。

如果一个包或库在 Packagist 上不可用,您仍然可以通过其他来源安装该包,为此,您需要在 composer.json 文件中输入不同的信息。您可以在这里阅读有关其他来源的详细信息 getcomposer.org/doc/05-repositories.md。但是,请注意,由于其便利性,Packagist 是推荐的来源。

详细了解 composer.json

我们看到的 composer.json 文件是最小的。要了解典型的 composer.json 文件是什么样的,这里是我最喜欢的 PHP MVC 框架 Laravel 的 composer.json 文件:

{
    "name": "laravel/laravel",
    "description": "The Laravel Framework.",
    "keywords": ["framework", "laravel"],
    "license": "MIT",
    "type": "project",
    "require": {
        "php": ">=5.6.4",
        "laravel/framework": "5.4.*",
        "laravel/tinker": "~1.0"
    },
    "require-dev": {
        "fzaninotto/faker": "~1.4",
        "mockery/mockery": "0.9.*",
        "phpunit/phpunit": "~5.7"
    },
    "autoload": {
        "classmap": [
            "database"
        ],
        "psr-4": {
            "App\\": "app/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    },
    "scripts": {
        "post-root-package-install": [
            "php -r \"file_exists('.env') || copy('.env.example', '.env');\""
        ],
        "post-create-project-cmd": [
            "php artisan key:generate"
        ],
        "post-install-cmd": [
            "Illuminate\\Foundation\\ComposerScripts::postInstall",
            "php artisan optimize"
        ],
        "post-update-cmd": [
            "Illuminate\\Foundation\\ComposerScripts::postUpdate",
            "php artisan optimize"
        ]
    },
    "config": {
        "preferred-install": "dist",
        "sort-packages": true,
        "optimize-autoloader": true
    }
}

我们不会深入研究此文件的明显部分,比如名称、描述等。我们将研究复杂和更重要的属性。

require 对象

您已经看到 require 具有依赖项和版本信息,这些信息由 composer install 命令安装。

require-dev 对象

require-dev 中,只列出了在开发阶段需要的那些包。在 Composer install 示例中,我们使用了 phpunit/phpunit 的示例,但实际上,像 phpunitphpspec 这样的包只在开发阶段需要,而不是在生产中需要。此外,如果有任何与调试相关的包需要,也可以包含在 require-dev 对象中。composer install 命令将安装所有在 require-dev 中以及 require 对象下的包。

但是,如果我们只想安装生产环境中需要的包,可以使用以下命令进行安装:

composer install --no-dev

在上述示例中,composer.json 中的 laravel/tinkerlaravel/laravelrequire 对象中,但 phpunitmockeryfaker 是在 require-dev 对象中提到的包,因此它们不会被安装。

autoload 和 autoload-dev

这个 autoload 选项是用来自动加载一个命名空间或一组类到一个目录下,或者简单地加载一个类。这是 Composer 提供的 PHP 自动加载程序的替代方案。这告诉 Composer 在自动加载类时要查找哪个目录。

自动加载属性还有两个属性,即classmappsr-4。PSR-4 是一种描述从文件路径自动加载类的规范。您可以在www.php-fig.org/psr/psr-4/.上阅读更多信息。

在这里,PSR-4 指定了一个命名空间,以及这个命名空间应该从哪里加载。在前面的示例中,App命名空间应该从app目录获取内容。

另一个属性是classmap。这用于自动加载不支持 PSR-4 或 PSR-0 的库。PSR-0 是另一种自动加载的标准,但是 PSR-4 是更新的,是推荐的。PSR-0 已经被弃用。

就像require-dev类似于require一样,autoload-dev类似于autoload

脚本

脚本基本上在不同事件的数组中具有脚本。脚本对象的所有这些属性都是事件,以及在特定事件上执行的值指定的脚本。不同的属性代表不同的事件,例如post-install-cmd表示在安装包后,它将执行post-install-cmd属性下的数组中的脚本。其他事件也是一样。在 Composer 文档的此 URL 中,您可以找到所有这些事件的详细信息:getcomposer.org/doc/articles/scripts.md#command-events

composer.lock

composer.lock的主要目的是锁定依赖项。

正如讨论的那样,composer.lock文件非常重要。这是因为当composer.json中没有指定特定的确切版本,或者通过composer require安装包时没有版本信息时,Composer 会安装该包,并在安装后添加关于该包安装的信息,包括确切的版本。

如果包已经在composer.lock中,那么很可能您也在composer.json中列出了该包。在这种情况下,您通常通过composer install安装该包,Composer 将从composer.lock中读取包详细信息和版本信息,并安装确切的版本,因为这就是 Composer 锁定依赖项的方式。

如果您的代码库中没有composer.lock文件,composer installcomposer require将安装包,这些包将创建composer.lock文件。

如果composer.lock文件已经存在,那么 Composer 安装将确保安装composer.lock文件中写入的确切版本,并且它将忽略composer.json。然而,如前所述,如果您想要更新依赖项并想要在composer.lock文件中更新它,那么您可以运行composer update。这是不推荐的,因为一旦您的应用程序在特定的依赖项上运行,并且您不想更新,那么composer.lock文件就很有用。因此,如果您想锁定依赖项,请不要运行composer update命令。

如果您在团队中工作,您必须提交composer.lock文件,以便您的团队中的其他成员可以拥有完全相同的包和版本。因此,强烈建议提交composer.lock文件,这不是讨论的问题。

我们不会详细讨论composer.lock,因为这就是我们需要了解的大部分内容。但是,我建议您打开并阅读一次composer.lock。不必理解所有内容,但这会给您一些想法。

它基本上包含已安装的包信息及其依赖项的确切版本。

Composer 作为自动加载程序

正如您所见,composer.json文件中有与自动加载相关的信息,因为 Composer 也负责自动加载。实际上,即使没有指定自动加载属性,Composer 也可以用于自动加载文件。

以前,我们使用requireinclude来单独加载每个文件。您不需要单独requireinclude每个文件。您只需要要求或包含一个文件,即./vendor/autoload.php。这个vendor目录是 Composer 的供应商目录,所有的包都放在那里。因此,这个autoload.php文件将自动加载所有内容,而不必担心按顺序包含所有文件及其依赖关系。

例子

假设我们有一个像这样的composer.json文件:

{
  "name": "laravel/laravel",
  "description": "The Laravel Framework.",
  "keywords": [
    "framework",
    "laravel"
  ],
  "license": "MIT",
  "type": "project",
  "require": {
    "php": ">=5.6.4",
    "twilio/sdk": "5.9.1",
    "barryvdh/laravel-debugbar": "².3",
    "barryvdh/laravel-ide-helper": "².3",
    "cartalyst/sentinel": "2.0.*",
    "gocardless/gocardless-pro": "¹.0",
    "intervention/image": "².3",
    "laravel/framework": "5.3.*",
    "laravelcollective/html": "⁵.3.0",
    "lodge/postcode-lookup": "dev-master",
    "nahid/talk": "².0",
    "predis/predis": "¹.1",
    "pusher/pusher-php-server": "².6",
    "thujohn/twitter": "².2",
    "vinkla/pusher": "².4"
  },
  "require-dev": {
    "fzaninotto/faker": "~1.4",
    "mockery/mockery": "0.9.*",
    "phpunit/phpunit": "~5.0",
    "symfony/css-selector": "3.1.*",
    "symfony/dom-crawler": "3.1.*"
  },
  "autoload": {
    "classmap": [
      "app/Models",
      "app/Traits"
    ],
    "psr-4": {
      "App\\": "app/"
    }
  },
  "autoload-dev": {
    "classmap": [
      "tests/TestCase.php"
    ]
  }
}

有了那个composer.json文件,如果我们运行composer install,它将安装所有这些包,然后加载所有这些包和所有类:

      "app/Models",
      "app/Traits"

我们只需要包含一个文件,就像这样:

require __DIR__.'/vendor/autoload.php';

这将使所有这些包在您的代码中可用。因此,所有这些包,以及我们自己在app/Modelsapp/Traits中的classes/traits,即使我们没有单独包含所有这些包,也将可用。因此,Composer 也可以作为自动加载程序。

用于创建项目的 Composer

我们还可以使用 Composer 从现有包创建一个新项目。这相当于执行两个步骤:

  • 克隆存储库

  • 在那里运行composer install

这意味着它将克隆一个项目并安装它的依赖项。可以使用以下命令完成:

composer create-project <package name> <path on file system> <version info>

如果我们想要从一个代码库开始一个项目,这是非常有用的。请注意,文件系统上的路径和版本号不是必需的,但是可选的。

例子

要安装一个名为 Laravel 的框架,您可以简单地运行:

composer create-project laravel/laravel

在这里,laravel/laravel是包。从这可以看出,文件系统上的路径或版本在这里没有提到。这是因为这些参数是可选的。

使用这些参数,该命令将如下所示:

composer create-project laravel/laravel ./exampleproject 5.3

摘要

Composer 是一种制作和使用可重用组件的标准方法。如今,已经做了很多事情,可以被重用。因此,在 PHP 中,Composer 是一种标准方法。在本章中,我们已经看到了 Composer 的工作原理,它的用途是什么,如何通过它安装包等等。然而,在本章中我们没有涉及的一件事是如何为 Composer 制作包。这是因为我们的重点是如何重用已经可用的 Composer 包。如果您想学习如何创建 Composer 包,那么就从这里开始:getcomposer.org/doc/02-libraries.md

如果您想了解更多关于 Composer 的信息,您可以:

  1. 去阅读 Composer 文档getcomposer.org/doc/

  2. 打开并开始阅读重要文件。您可以打开并阅读不同的 Composer 文件,比如composer.jsoncomposer.lock,来自不同的包。

到目前为止,我们已经看到了如何重用 Composer 组件,以避免自己编写所有内容。在下一章中,我们将开始使用这些组件或项目来使我们的 RESTful web 服务更好。

第六章:使用 Lumen 照亮 RESTful Web 服务

到目前为止,我们已经在核心 PHP 中创建了一个非常基本的 RESTful Web 服务,并发现了设计和安全方面的缺陷。我们还看到,为了改进,我们不需要从头开始创建所有东西。事实上,使用经过时间考验的开源代码更有意义,以基于更干净的代码构建更好的 Web 服务。

在上一章中,我们已经看到 Composer 是 PHP 项目的依赖管理器。在本章中,我们将使用一个开源的微框架来编写 RESTful Web 服务。我们将在一个活跃开发、经过时间考验并在 PHP 社区中广为人知的开源微框架中完成相同的工作。使用框架而不是几个组件的原因是,一个合适的框架可以为我们的代码提供良好的结构,并且它带有一些基本所需的组件。我们选择的微框架是 Lumen,它是全栈框架 Laravel 的微框架版本。

这是我们打算在本章中涵盖的内容:

  • 介绍 Lumen

  • Lumen 提供了什么

  • Lumen 与 Laravel 有什么共同之处

  • Lumen 与 Laravel 有何不同

  • 安装和配置

  • 数据库迁移

  • 在 Lumen 中编写 REST API

  • 路由

  • 控制器

  • REST 资源

  • Eloquent(模型)

  • 关系

  • 用户访问和基于令牌的身份验证和会话

  • API 版本控制

  • 速率限制

  • 用户的数据库种子

  • 使用 Lumen 包进行 REST API

  • 审查基于 Lumen 的 REST API

  • 加密的需求

  • 不同的 SSL 选项

  • 总结和更多资源

介绍 Lumen

Lumen 是全栈框架 Laravel 的微框架版本。在 PHP 社区中,Laravel 是一个非常知名的框架。因此,通过使用 Lumen,我们可以随时将我们的项目转换为 Laravel,并开始使用其全栈功能。

为什么使用微框架?

一切都有代价。我们选择了微框架而不是全栈框架,因为尽管全栈框架提供了更多的功能,但为了拥有这些功能,它必须加载更多的东西。因此,为了提供更多功能的奢侈,与微框架相比,全栈框架在性能上必须做出一些妥协。另一方面,微框架放弃了一些构建 Web 服务所不需要的功能,比如视图等,这使得它更快。

为什么选择 Lumen?

Lumen 并不是 PHP 社区中唯一的微框架。那么为什么选择 Lumen?有三个主要原因:

  • Lumen 是 Laravel 的微框架,因此我们可以通过一点努力将其转换为 Laravel,并利用其全栈功能。

  • 由于 Lumen 是 Laravel 的微框架,它像 Laravel 一样拥有出色的社区支持。一个良好的社区总是一个非常重要的因素。同时,Lumen 能够使用许多与 Laravel 相同的包。

  • 除了与 Laravel 的关系,Lumen 在性能方面也非常出色。基于性能,其他替代的微框架可能是 Slim 和 Selex。

Lumen 提供了什么

正如我们所知,Lumen 是 Laravel 的微框架版本,它提供了许多 Laravel 提供的功能。例如,它是一个 MVC 框架。然而,了解 Lumen 和 Laravel 的共同之处以及 Lumen 没有或具有不同的地方是很重要的。这将让我们对 Lumen 为我们提供了什么有一个很好的了解。

Lumen 与 Laravel 有什么共同之处

在这里,我没有说 Laravel 和 Lumen 之间的相似之处,因为 Lumen 并不是一个完全不同的框架。我说的是它们之间的共同之处,因为它们有共同的包和组件:这意味着它们在许多情况下共享相同的代码库。

实际上,Lumen 是一种小型、精简的 Laravel。它只是放弃了一些组件,并对一些任务使用不同的组件,比如路由。然而,你总是可以在同一个安装中打开很多组件。有时,甚至不需要在配置中编写一些代码。相反,你只需转到配置文件,取消注释一些代码行,它就开始使用这些组件。

事实上,Lumen 具有相同的版本。例如,如果有 Laravel 的 5.4 版本,Lumen 将具有相同的版本。因此,这些不是两个不同的东西。它们彼此之间有很多相似之处。Lumen 只是为了性能而放弃了一些不必要的东西。然而,如果你只想将为 Lumen 编写的应用程序代码转换为 Laravel,你只需将该代码放入 Laravel 安装中,它应该大部分工作。不需要对应用程序代码进行重大更改。

Lumen 与 Laravel 有何不同

由于 Lumen 是为微服务和 API 构建的,与前端相关的组件,如 elixir、身份验证 bootstrap、会话和视图等,不是 Lumen 的默认组件,但如果需要的话,可以稍后包含:它在这方面非常灵活。

Lumen 中的路由不同。事实上,它不使用 Symfony 路由器;而是使用一个速度更快但功能较少的不同路由器。这是因为 Lumen 为了速度而牺牲了功能。同样,像 Laravel 一样,没有单独的配置文件。相反,一些配置在.env文件中完成,而其他与注册提供程序或别名等相关的配置在bootstrap/app.php文件中完成,可能是为了避免为了速度而加载不同的文件。

Lumen 和 Laravel 都有很多包,其中很多都适用于两者。但仍然有一些包主要是为 Laravel 构建的,如果没有一些更改,就无法在 Lumen 中使用。因此,如果你打算安装一个包,请确保它支持 Lumen。对于 Laravel,大多数包都适用于 Laravel,因为 Laravel 更受欢迎,大多数包都是为 Laravel 构建的。

Lumen 到底提供了什么

你可能认为这就是 Lumen 和 Laravel 之间的区别,但 Lumen 到底提供了什么,让我们能够构建 API?我们将研究一下,但不会详细讨论,因为 Lumen 的文档lumen.laravel.com/docs/5.4已经涵盖了这一目的。文档中没有涵盖的是我们如何使用 Lumen 制作 RESTful Web 服务,以及我们可以利用哪些包来使我们的工作和生活更轻松。我们将深入研究这一点。

首先,我们将讨论 Lumen 提供了什么,以便我们了解其不同的组件和工作方式。

良好的结构

Lumen 具有良好的结构。由于它源自 Laravel,后者遵循 MVC(模型视图控制器)模式,Lumen 也有模型和控制器层。它没有视图层,因为它不需要视图:它是用于 Web 服务的。如果你不知道什么是 MCV,可以将其视为一种架构模式,其中责任分布在三个层中。模型是一个数据库层,有时也用作业务逻辑层(我们将在后面的章节中讨论模型中应该包含什么)。视图层用于模板相关的内容。控制器可以被认为是一个处理请求的层,同时从模型获取数据并渲染视图。在 Lumen 的情况下,只有模型和控制器层。

Lumen 为我们提供了一个良好的结构,因此我们不需要自己制作。事实上,Laravel 不仅提供 MVC 结构,还提供了服务容器,可以很好地解决依赖关系。Lumen 和 Laravel 的结构不仅仅是一个设计模式,而是很好地利用了不同的设计模式。因此,让我们看看 Lumen 还提供了什么,并深入了解服务容器和许多其他主题。

单独的配置

在第四章中,审查设计缺陷和安全威胁,我们看到配置应该与实现分开,所以 Lumen 为我们做到了这一点。它有单独的配置文件。事实上,它有一个单独的.env文件,可以在不同的环境中不同。除了.env文件,还有一个配置文件,其中存储了与不同包相关的配置,比如包注册或别名等。

请注意,你可能一开始在 Mac 或 Linux 上看不到.env文件,因为它以点开头,所以它会被隐藏。你需要显示隐藏文件,然后你就会看到.env文件。

路由器

Lumen 具有更好的路由能力。它不仅可以让你告诉哪些 URL 应该由哪个控制器提供,还可以让你告诉哪些 URL 以及使用哪种 HTTP 方法应该由哪个控制器的哪个方法提供。事实上,Lumen 可以指定 RESTful 约定中大多数我们使用的 HTTP 方法。

在为我们的博客示例创建 RESTful web 服务时,我们将看到代码示例。

中间件

中间件是在控制器提供请求之前或之后执行的一些操作。中间件可以执行许多任务,比如身份验证中间件、验证中间件等。

Lumen 自带一些中间件,同时我们也可以编写自己的中间件来实现我们的目的。

服务容器和依赖注入

服务容器是一个用于依赖注入和依赖解析的工具。开发人员只需告诉哪个类应该在哪里注入,服务容器就会解析并注入该依赖。

如果通过应用程序服务容器创建对象,而不是通过应用程序中的new关键字,依赖注入可以用于解析类的任何依赖关系。

例如,Lumen 服务容器用于解析所有 Lumen 控制器。因此,如果它们需要任何依赖项,服务容器负责解析它们。为了更好地理解,考虑以下示例:

class PostController extends Post
{
    public function __construct(Post $post){
        //do something with $post
    }
}

在前面的示例中,我只是提到了简单的Controller类,在PostController构造函数中注入了Post类。如果我们已经有另一个对象,我们希望注入而不是实际的Post对象,我们也可以这样做。

你可以在解析依赖项之前的任何地方使用以下代码来简单地做到这一点:

$ourCustomerPost = new OurCustomPost();
$this->app->instance("\Post", $ourCustomerPost);

现在,如果在类的构造函数或方法中对Post进行类型提示,那么OurCustomPost类的对象将被注入其中。这是因为$this->app->instance("\Post", $ourCustomerPost)告诉服务容器,如果有人要求一个\Post的实例,就给他们$ourCustomerPost

请注意,除了控制器解析,如果我们希望服务容器注入依赖项,我们还可以以以下方式创建对象:

$postController = $this->app->make('PostController');

因此,在这里,PostController将以与 Lumen 本身解析控制器相同的方式解析。请注意,我们使用术语Lumen,因为我们正在谈论 Lumen,但大部分内容在 Lumen 和 Laravel 中都是相同的。

如果这听起来有点令人不知所措,不要担心,一旦你开始使用 Lumen 或 Laravel 并在其中进行实际工作,你就会开始理解这一点。

HTTP 响应

Lumen 内置支持发送不同类型的响应、HTTP 状态码和响应头。这是我们之前讨论过的重要内容。对于 Web 服务来说,这更加重要,因为 Web 服务是由机器使用的,而不是人类。机器应该能够知道响应类型和状态码是什么。这不仅有助于判断是否出现错误或成功,还有助于判断发生了什么类型的错误。您可以在lumen.laravel.com/docs/5.4/responses中更详细地了解这一点。

验证

Lumen 还提供了对验证的支持;不仅是验证支持,还有内置的验证规则可以开始使用。但是,如果您需要为某个字段编写一些自定义验证逻辑,也可以随时进行编写。在创建我们的 RESTful Web 服务时,我们将深入研究这一点。

Eloquent ORM

Lumen 带有一个名为 Eloquent 的 ORM 工具。为了便于理解,您可以将其视为与数据库相关的高级库,通过它可以在不涉及关系的大量细节的情况下获取数据。在我们使用它时,我们将很快详细了解它。

数据库迁移和填充

如今,开发人员并不总是应该使用 SQL 或数据库工具来创建数据库。代码中应该有一些东西可以在版本控制系统下运行,并且团队中的每个开发人员都可以在自己的系统或服务器上运行。这个东西现在被称为迁移。编写迁移的另一个好处是它不是针对一个特定的数据库。相同的迁移可以在 MySQL 和 PostgreSQL 上工作。迁移涉及数据库的结构变化。

迁移是用于创建或修改数据库表,或创建不同的约束或索引。同样,种子数据用于向数据库中插入数据。

单元测试

单元测试也是确保代码质量的非常重要的部分,Lumen 也提供了对此的支持。我们不会在本章中编写测试,但我们将在以后的章节中编写测试。

请注意,我们还没有看到 Lumen 提供的每一项功能,我们只看到了一些我们可能需要了解的组件,以便在 Lumen 中创建 RESTful Web 服务。有关 Lumen 的更多详细信息,您可以简单地查阅其文档:lumen.laravel.com/docs/5.4

Lumen 的安装

要安装 Lumen,如果您已经安装了 composer,只需运行以下命令:

composer create-project --prefer-dist laravel/lumen blog

这将创建一个名为blog的目录,其中包含 Lumen 的安装。如果您遇到任何困难,请参阅此处的 Lumen 安装文档:lumen.laravel.com/docs/5.4

我建议在安装后,您去查看名为 blog 的这个 Lumen 项目的目录结构,因为当我们执行不同的任务时,这将更有意义。

配置

如果您查看我们安装 Lumen 的安装目录,在我们的案例中是blog,您会看到一个.env文件。Lumen 将配置保存在.env文件中。您可以看到有一个选项APP_KEY=,如果在.env文件中尚未设置,请设置它。这只需要设置为一个具有 32 个字符长度的随机字符串。

由于.env文件以点开头,在 Linux 或 Mac 中,此文件可能是隐藏的。为了查看此文件,您需要查看隐藏文件。

然后,要运行 Lumen,只需使用以下命令:

php -S localhost:8000 -t public

如您所见,我们正在使用 PHP 内置服务器,并在项目中给出public目录的路径。这是因为入口点是public/index.php。然后,在localhost:8000/上,您应该看到Lumen (5.4.6) (Laravel Components 5.4.*)

如果您看到错误Class 'Memcached' not found,这意味着您没有安装 Memcached,而 Lumen 正在尝试在某个地方使用它。如果您不需要 Memcached,您可以简单地转到.env文件并更改CACHE_DRIVER=file

现在我们已经安装和配置了 Lumen,我们将在 Lumen 中为博客示例创建相同的 RESTful Web 服务。

您还需要在bootstrap/app.php中取消注释以下内容。

//$app->withFacades();   //$app->withEloquent();

正如先前所述,Lumen 在这些功能不可用时可能会更快。但我们取消了注释,因为我们还需要利用 Lumen 的一些功能。那么,这两行代码到底是做什么的呢?第一行代码启用了 Facades 的使用。我们启用它是因为我们将需要一些需要 Facade 的包。第二行代码启用了 Laravel 和 Lumen 附带的 Eloquent ORM 的使用。出于性能考虑,默认情况下不启用 Eloquent。但是,Eloquent 是一个非常重要的组件,我们不应该忽视它,即使是出于性能考虑,除非性能对我们非常重要并且由于 Eloquent 而变慢。在我看来,除非情况紧急,否则我们不应该为了性能而牺牲清晰度。

设置数据库

我们需要为博客设置数据库。实际上,我们在第三章中已经设置了这一点,创建 RESTful 端点。我们可以在这里使用该数据库。实际上,我们将拥有相同的数据库结构,因此我们可以轻松地使用相同的数据库,但这并不推荐。在 Lumen 中,我们使用迁移来创建数据库结构。这不是强制性的,但很有用,因此您可以编写一次迁移并在任何地方使用它来创建数据库结构。SQL 文件也可以实现这个目的,但是迁移的美妙之处在于它也可以跨不同的关系型数据库管理系统工作。因此,手动创建一个名为blog的数据库。现在,我们将为结构编写迁移。

编写迁移

要在 Lumen 中创建迁移文件,我们可以在blog目录中使用以下命令创建迁移文件:

php artisan make:migration create_users_table

您将看到类似于这样的内容:

Created Migration: 2017_06_23_180043_create_users_table

并且将在/blog/database/migrations目录中创建一个具有此名称的文件。在此文件中,我们可以为用户表编写迁移代码。如果您打开文件并查看其中,您会发现其中有两个方法:up()down()up()方法在运行迁移时执行,而down()在回滚迁移时执行。

这是用户表创建迁移文件的内容:

<?php   use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint;   class CreateUsersTable extends Migration {    /**
 * Run the migrations. * * @return void
 */  public function up()
 { Schema::create('users', function(Blueprint $table)
 {  $table->integer('id', true);
  $table->string('name', 100);
  $table->string('email', 50)->unique('email_unique');
  $table->string('password', 100);
 $table->timestamps();  }); }      /**
 * Reverse the migrations. * * @return void
 */  public function down()
 { Schema::drop('users');
 }   } 

up()方法中,我们调用了 create 方法,并传递了一个函数。该函数包含添加字段的代码。如果您想了解有关通过迁移创建字段和表的更多信息,可以查看laravel.com/docs/5.4/migrations#tables

但是,在运行从数据库生成迁移的命令之前,您应该转到.env文件并添加您的数据库名称和凭据。为了运行迁移,请运行以下命令:

php artisan migrate

这将运行迁移,并创建两个表:迁移表和用户表。用户表是由先前提到的代码创建的,而迁移表是由 Laravel/Lumen 创建的,用于记录运行的迁移。这个表是第一次创建的,每次运行迁移时都会有更多的数据。

请注意,在运行迁移之前,您应该在.env文件中安装和配置 MySQL 或其他数据库。否则,如果没有安装或设置数据库,则迁移将无法工作。

现在,您可以以相同的方式创建帖子和评论表创建迁移文件。以下是帖子和评论表创建迁移文件的内容。

帖子迁移文件内容:

<?php   use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint;   class CreatePostsTable extends Migration {    /**
 * Run the migrations. * * @return void
 */  public function up()
 { Schema::create('posts', function(Blueprint $table)
 {  $table->integer('id', true);
  $table->string('title', 100);
  $table->enum('status', array('draft','published'))->default('draft');
  $table->text('content', 65535);
  $table->integer('user_id')->index('user_id_foreign');

         $table->timestamps();
 }); }      /**
 * Reverse the migrations. * * @return void
 */  public function down()
 { Schema::drop('posts');
 }   } 

这是评论表创建迁移文件:

<?php   use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint;   class CreateCommentsTable extends Migration {    /**
 * Run the migrations. * * @return void
 */  public function up()
 { Schema::create('comments', function(Blueprint $table)
 {  $table->integer('id', true);
  $table->string('comment', 250);
  $table->integer('post_id')->index('post_id');
  $table->integer('user_id')->index('user_id');

         $table->timestamps();
 }); }      /**
 * Reverse the migrations. * * @return void
 */  public function down()
 { Schema::drop('comments');
 }   } 

在拥有上述两个文件之后,再次运行以下命令:

php artisan migrate

上述命令将只执行尚未执行的新迁移文件。有了这样,你将在数据库中拥有这三个表。而且,由于我们也将有这些迁移,所以只需再次运行迁移,就可以在数据库中拥有这个模式。你可能会想,“编写迁移有什么好处呢?”。好处在于,迁移使得在任何 RDBMS 上部署变得更容易,因为代码是 Laravel 迁移代码,而不是 SQL 代码。此外,将这样的东西放在代码中总是更容易的,这样多个开发人员就可以获取彼此的迁移并立即运行它们。

如果你还记得,我们还做了一些索引和外键约束。所以,这就是我们如何在迁移中做到的。

使用与之前相同的命令创建一个新的迁移文件:

php artisan make:migration add_foreign_keys_to_comments_table

这将为评论表索引创建一个迁移文件。让我们给这个文件添加内容:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class AddForeignKeysToCommentsTable extends Migration {

    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('comments', function(Blueprint $table)
        {
            $table->foreign('post_id', 'post_id_comment_foreign')->references('id')->on('posts')->onUpdate('RESTRICT')->onDelete('RESTRICT');
            $table->foreign('post_id', 'post_id_foreign')->references('id')->on('posts')->onUpdate('RESTRICT')->onDelete('RESTRICT');
            $table->foreign('user_id', 'user_id_comment_foreign')->references('id')->on('users')->onUpdate('RESTRICT')->onDelete('RESTRICT');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('comments', function(Blueprint $table)
        {
            $table->dropForeign('post_id_comment_foreign');
            $table->dropForeign('post_id_foreign');
            $table->dropForeign('user_id_comment_foreign');
        });
    }

}

同样地,为帖子索引创建一个迁移文件。以下是文件的内容:

<?php   use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint;   class AddForeignKeysToPostsTable extends Migration {    /**
 * Run the migrations. * * @return void
 */  public function up()
 { Schema::table('posts', function(Blueprint $table)
 {  $table->foreign('user_id', 'user_id_foreign')->references('id')->on('users')->onUpdate('RESTRICT')->onDelete('RESTRICT');
 }); }      /**
 * Reverse the migrations. * * @return void
 */  public function down()
 { Schema::table('posts', function(Blueprint $table)
 {  $table->dropForeign('user_id_foreign');
 }); }   } 

在上述索引文件代码中,有一些代码有点复杂,需要我们的注意:

  $table->foreign('user_id', 'user_id_foreign')->references('id')->on('users')->onUpdate('RESTRICT')->onDelete('RESTRICT');

在这里,foreign() 方法接受字段名和索引的名称。然后,references() 方法接受父表中外键字段的名称,on() 方法的参数是被引用的表名(在我们的例子中,是用户表)。然后,另外两个方法 onUpdate()onDelete() 告诉用户在更新和删除时该做什么。如果你对迁移语法不太熟悉,没关系;你只需要查看 Lumen/Laravel 迁移文档。事实上,我建议你停下来,看一下与迁移相关的文档:laravel.com/docs/5.4/migrations

现在,为了使这些迁移在数据库中生效,我们需要再次运行迁移,以便新的迁移可以执行,并且我们可以在数据库中看到变化。所以运行:

php artisan migrate

有了这个,我们就完成了迁移。现在我们可以通过种子在这些表中插入一些数据,但现在我们不需要,所以暂时跳过编写种子。

编写 RESTful web service 端点

现在,是时候真正开始编写我们在第一章“RESTful Web Services, Introduction and Motivation”中讨论过的端点,并在第三章中用纯粹的 Vanilla PHP 编写的端点了。所以让我们开始吧。

由于它有控制器和模型层,我们将从控制器层开始编写 API,该层将为不同的端点提供服务。对于第一个控制器,我们要编写的是 PostController

编写第一个控制器

从技术上讲,这不是第一个控制器,因为 Lumen 自带了 2 个控制器,你可以在 /<our blog project path>/app/Http/Controllers/ 目录中找到。但这是我们要编写的第一个控制器。在 Laravel(Lumen 的大哥)中,我们不需要去创建控制器,因为有相应的命令,但是在 Lumen 中这些命令是不可用的。由于这些命令不是强制性的,但非常方便,最好是让这些命令可用。

为了使用我们在 Lumen 中没有的额外功能(其中一些已经包含在 Laravel 中),我们需要安装一个包。现在,我们需要安装的包是 flipbox/lumen-generator。关于这个包的更多信息可以在 packagist.org/packages/flipbox/lumen-generator 找到。

正如我们在前一章中所看到的,我们通过 composer 安装包,所以让我们安装它:

composer require --dev flipbox/lumen-generator

你可以看到我在那里添加了一个 --dev 标志。我这样做是为了避免在生产环境中使用它,因为这样它将被添加到 composer.json 中的 require --dev 部分。

无论如何,一旦安装了这个,你可以在 bootstrap/app.php 中注册它的 ServiceProvider

if ($app->environment() !== 'production') {
  $app->register(Flipbox\LumenGenerator\LumenGeneratorServiceProvider::class); }

现在,你可以看到我们有更多的命令可用。你可以通过运行以下命令来查看:

php artisan migrate

因此,让我们使用命令创建一个控制器。请注意,我们不仅仅是为了创建控制器而安装它,但当你使用它时,它将非常方便。无论如何,让我们使用以下命令创建一个控制器:

php artisan make:controller PostController --resource

它将在app/Http/Controllers/PostController.php创建一个控制器。这个命令不仅会创建PostController,还会添加与 REST 资源相关的方法。打开一个文件并查看它。

这是它生成的内容:

<?php   namespace App\Http\Controllers;   use Illuminate\Http\Request;   class PostController extends Controller {
  /**
 * Display a listing of the resource. * * @return \Illuminate\Http\Response
 */  public function index()
 {  //
  }    /**
 * Store a newly created resource in storage. * * @param \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */  public function store(Request $request)
 {  //
  }    /**
 * Display the specified resource. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function show($id)
 {  //
  }    /**
 * Update the specified resource in storage. * * @param \Illuminate\Http\Request  $request
 * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function update(Request $request, $id)
 {  //
  }    /**
 * Remove the specified resource from storage. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function destroy($id)
 {  //
  } } 

这些方法是因为我们添加了--resource标志而生成的。如果你想知道我是从哪里获取到这个标志的知识,因为它没有列在包页面上,我是从 Laravel 的控制器文档中获取的laravel.com/docs/5.4/controllers#resource-controllers。然而,由于这些命令是由第三方包工作的,所以 Laravel 文档和这些命令的实际行为可能会有所不同,但由于这些是为了复制 Laravel 命令而在 Lumen 中完成的,它们很可能会非常相似。

无论如何,我们有PostController及其方法。让我们逐个实现这些方法。

但是,请注意,在 Lumen 和 Laravel 中,与其他 PHP MVC 框架不同,每个 URL 都应该在路由中告知,否则它将无法访问。路由是一种唯一的入口点,不像其他框架如CodeIgniter,路由是可选的。在 Lumen 中,路由是必需的。换句话说,控制器的每个方法只能通过路由访问。

因此,在继续使用PostController之前,让我们为帖子端点添加路由,否则PostController将毫无用处。

Lumen 路由

在 Lumen 中,默认情况下,路由位于/routes/web.php。我说默认是因为这个路径可以更改。无论如何,进入routes/web.php并查看它。你会看到它自己返回一个响应,而不是指向任何控制器。所以,你应该知道它是由路由决定是否返回响应或使用控制器。但是,请注意,只有在路由闭包中返回响应才有意义,如果涉及的逻辑不多。在我们的情况下,我们将主要使用控制器。

当我们添加第一个路由时,我们的路由将如下所示:

<?php   /* |---------------------------------------------------------------- | Application Routes |---------------------------------------------------------------- | | Here is where you can register all of the routes for an application. | It is a breeze. Simply tell Lumen the URIs it should respond to | and give it the Closure to call when that URI is requested. | */   $app->get('/', function () use ($app) {
  return $app->version(); });  $app->get('/api/posts', [
    'uses' => 'PostController@index',
    'as' => 'list_posts'
]);

粗体字中的代码是我们编写的。在这里,$app->get()中的get用于指定 HTTP 方法。它可以是$app->post(),但我们使用了$app->get()来指定接受GET方法。然后,在这个方法中有 2 个参数,你可以在前面的代码中看到。第一个是路由模式,而第二个参数是一个关联数组,其中uses键中有控制器和方法,as键中有路由名称:意味着在域或项目 URL 之后,如果api/posts/是一个 URL,它应该由PostControllerindex()方法提供服务。而路由名称只是在那里,如果你想在代码中按名称指定路由 URL,那么它是有用的。

现在,为了检查我们的路由是否正确,并从控制器的index方法获取响应,让我们向PostControllerindex方法添加一些内容。这是我们现在添加的内容,只是为了测试我们的路由:

public function index() {
 return ['response' =>
        [
            'id' => 1,
            'title' => 'Some Post',
            'body' => 'Here is post body'
        ] **];** }

现在,尝试运行这段代码。在任何其他操作之前,你需要使用 PHP 内置服务器。进入整个代码所在的blog目录并运行:

php -S localhost:8000 -t public

然后,从浏览器中输入:http://localhost:8000/api/posts,你将看到以下响应:

{"response":{"id":1,"title":"Some Post","body":"Here is post body"}}

正如你所看到的,我们的路由起作用并从PostControllerindex()方法提供服务,如果你返回一个数组,Lumen 会将其转换为 JSON 并作为 JSON 返回。

要进一步查看特定 URL 映射到特定控制器特定方法的路由列表,只需运行:

php artisan route:list

你将看到路由的详细信息,告诉你哪个 URL 模式与哪段代码相关联。

REST 资源

这是一个非常基本的路由示例,由 PostController 方法提供。然而,如果你查看 PostController,它还有 4 个方法,我们需要为第一章中讨论的 4 个端点提供服务,并在第三章中实现创建 RESTful 端点。因此,我们需要在 Lumen 中为其他 4 个方法做同样的事情。为了将这 4 个方法映射到 4 个端点,我们不需要再添加 4 个路由。我们可以简单地添加一个基于资源的路由,它将将 REST 基础的 URL 模式映射到PostController的所有方法。

通过命令行创建PostController时,它创建了一个资源控制器,这意味着它具有提供 RESTful 端点所需的方法。因此,在routes/web.php文件中,我们应该用资源路由替换之前编写的代码。现在,我们应该能够通过在路由文件中添加这个语句来将所有 RESTful 端点映射到PostController的方法:

$app->resource('api/posts', 'PostController');

不幸的是,这个资源路由在 Laravel 中可用,但在 Lumen 中不可用。Lumen 使用不同的路由器以获得更好的性能。然而,这个资源方法也非常方便,如果我们有 4-5 个以上的 RESTful 资源,我们可以只用 4-5 个语句来映射它们的所有端点,而不是 16-20 个语句。因此,这里有一个小技巧,可以在 Lumen 中使用这种资源路由的方法。你可以将这个自定义方法添加到同一个路由文件中。

function resource($uri, $controller) {
  //$verbs = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];
  global $app;

  $app->get($uri, $controller.'@index');
  $app->post($uri, $controller.'@store');

  $app->get($uri.'/{id}', $controller.'@show');
  $app->put($uri.'/{id}', $controller.'@update');
  $app->patch($uri.'/{id}', $controller.'@update');

  $app->delete($uri.'/{id}', $controller.'@destroy'); }  

因此,我们的路由文件将如下所示:

<?php   /* |---------------------------------------------------------------- | Application Routes |---------------------------------------------------------------- | | Here is where you can register all of the routes for an application. | It is a breeze. Simply tell Lumen the URIs it should respond to | and give it the Closure to call when that URI is requested. | */    function resource($uri, $controller)
{
    //$verbs = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];

    global $app;

    $app->get($uri, $controller.'@index');
    $app->post($uri, $controller.'@store');

    $app->get($uri.'/{id}', $controller.'@show');
    $app->put($uri.'/{id}', $controller.'@update');
    $app->patch($uri.'/{id}', $controller.'@update');

    $app->delete($uri.'/{id}', $controller.'@destroy'); **}**     $app->get('/', function () use ($app) {
  return $app->version(); });   resource('api/posts', 'PostController'**);** 

粗体字中的代码是我们添加的。因此,你可以看到我们在路由文件中一次定义了resource()函数,并且我们可以将其用于所有 REST 资源路由。然后在最后一行,我们使用资源函数将所有api/posts端点映射到PostController的相应方法。

现在你可以通过访问 http://localhost:8000/api/posts 来测试它。我们现在无法测试其他端点,因为我们还没有在 PostController 的其他方法中编写任何代码。但是,你可以使用以下命令来查看存在的路由:

php artisan route:list

在我们的情况下,这个命令将在命令行上产生类似这样的结果:

在这里,我们可以看到这是根据我们在第一章中讨论的 RESTful 资源约定将路径映射到PostController方法。因此,对于帖子端点,我们已经完成了路由。现在我们需要在控制器中添加代码,以便它可以向数据库添加数据并从数据库中获取数据。下一步是创建一个模型层,并在控制器中使用它并返回适当的响应。

Eloquent ORM(模型层)

Eloquent 是 Laravel 和 Lumen 附带的 ORM。它负责数据库相关操作以及数据库关系。ORM对象关系映射)基本上将对象与数据库中的关系(表)进行映射。不仅如此,基于关系,你可以在不涉及底层细节的情况下获取另一个表的数据。这不仅节省了我们的时间,还使我们的代码更加清晰。

创建模型

我们现在要创建模型。模型层与数据库相关,因此我们也会在其中提及数据库关系。让我们为我们拥有的三个表创建模型。模型名称将是UserPostComment,分别对应userspostscomments表。

我们不需要创建一个用户模型,因为它已经包含在 Lumen 中。要创建帖子和评论模型,让我们运行以下命令,这些命令是通过使用flipbox/lumen-generator包而变得可用的。运行以下命令来创建模型:

这将在app目录中创建一个Post模型:

php artisan make:model Post

这将在app目录中创建一个Comment模型:

php artisan make:model Comment

如果你查看这些模型文件,你会发现这些都是继承自 Eloquent Model 的类;因此,这些模型都是基于 Eloquent Model 的模型,并具有 Eloquent Model 的特性。

注意,根据 Eloquent 的约定,如果模型的名称是 Post,表的名称将是 posts,即模型名称的复数形式。同样,对于 Comment 模型,它将是 comments 表。如果我们的表名不同,我们可以覆盖这一点,但我们没有这样做,因为在我们的情况下,我们的表和模型名称都符合相同的约定。

Eloquent 是一个大的讨论话题,但我们只是用它来制作我们的 API,所以我将限制讨论 Eloquent 在服务我们目的方面的使用。我认为这是有道理的,因为 Eloquent 的文档中已经有很多细节,所以有关 Eloquent 的更多细节,请参阅这里的 Eloquent 文档:laravel.com/docs/5.4/eloquent

Eloquent 关系

在模型层,特别是在从 ORM 继承时,有两个重要的事情:

  • 我们应该有模型,这样我们可以通过它们访问数据

  • 我们应该指定关系,这样我们可以利用 ORM 的全部功能

只需访问数据而不编写查询,我们也可以使用查询构建器。但是,关系的优势在于它仅与 ORM 使用一起出现。因此,让我们指定所有模型的关系。

首先,让我们指定用户的关系。由于用户可以有多篇帖子和多条评论,用户模型将与帖子和评论模型都有hasMany关系。在指定关系后,用户模型将如下所示:

<?php   namespace App;   use Illuminate\Auth\Authenticatable; use Laravel\Lumen\Auth\Authorizable; use Illuminate\Database\Eloquent\Model; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;   class User extends Model implements AuthenticatableContract, AuthorizableContract {
  use Authenticatable, Authorizable;    /**
 * The attributes that are mass assignable. * * @var array
 */  protected $fillable = [
  'name', 'email',
 ];    /**
 * The attributes excluded from the model's JSON form. * * @var array
 */  protected $hidden = [
  'password',
 ]; public function posts(){
        return $this->hasMany('App\Post');
    }

    public function comments(){
        return $this->hasMany('App\Comment'); **}** } 

我们在 User Model 中添加的唯一的东西是这两个用粗体标出的方法,它们是posts()comments(),指定了关系。基于这些方法,我们可以访问用户的帖子和评论数据。这两个方法告诉我们,用户与PostComment模型都有多对多的关系。

现在,让我们在Post模型中添加一个关系。由于帖子可以有多条评论,Post模型与Comment模型有多个关系。同时,Post模型与用户模型有多个反向关系,该反向关系是belongsTo关系。在添加关系信息后,Post模型代码如下。

<?php   namespace App;   use Illuminate\Database\Eloquent\Model;   class Post extends Model {
 public function comments(){
        return $this->hasMany('App\Comment');
    }

    public function user(){
        return $this->belongsTo('App\User'); **}** } 

正如你所看到的,我们已经指定了帖子与UserComment模型的关系。现在,这是带有关系的Comment模型。

<?php   namespace App;   use Illuminate\Database\Eloquent\Model;   class Comment extends Model {
 public function post(){
        return $this->belongsTo("App\Post");
    }

    public function user(){
        return $this->belongsTo("App\User"); **}** } 

正如你所看到的,对于PostUser模型,评论都有一个belongsTo关系,这是hasMany()的反向关系。

所以,现在我们已经指定了关系。是时候实现PostController的方法了。

控制器实现

让我们首先在PostControllerindex()方法中添加适当的代码,以返回实际数据。但是为了查看响应中的数据,最好在用户、帖子和评论表中插入一些虚拟数据。更好的方法是为此编写种子。但是,如果你不想了解如何编写种子,那么现在可以手动插入。

以下是index()方法的实现:

public function index(\App\Post $post) {
  return $post->paginate(20); }

在这里,paginate(20)表示它将返回一个带有 20 个限制的分页结果。正如你所看到的,我们使用了依赖注入来获取Post对象。这是我们在本章中已经讨论过的内容。

同样,我们将在这里实现PostController的其他方法。PostController代码将如下所示:

<?php   namespace App\Http\Controllers;   use Illuminate\Http\Request;   class PostController extends Controller {   public function __construct(\App\Post $post)
    {
        $this->post = $post; **}**    /**
 * Display a listing of the resource. * * @return \Illuminate\Http\Response
 */  public function index()
 { return $this->post->paginate(20**);**
 }    /**
 * Store a newly created resource in storage. * * @param \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */  public function store(Request $request)
 { $input = $request->all();
        $this->post->create($input);

        return [
            'data' => $input **];**
 }    /**
 * Display the specified resource. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function show($id)
 {  return $this->post->find($id**);**
 }    /**
 * Update the specified resource in storage. * * @param \Illuminate\Http\Request  $request
 * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function update(Request $request, $id)
 { $input = $request->all();
        $this->post->where('id', $id)->update($input);

        return $this->post->find($id**);**
 }    /**
 * Remove the specified resource from storage. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function destroy($id)
 { $post = $this->post->destroy($id);

        return ['message' => 'deleted successfully', 'post_id' => $post**];**
 } } 

正如你所看到的,我们正在使用 Post 模型并使用它的方法执行不同的操作。Lumen 的变量和函数名称使我们更容易理解发生了什么,但如果你想知道可以使用哪些 Eloquent 方法,请查看 Eloquent:laravel.com/docs/5.4/eloquent

如果您在那里找不到 Eloquent 方法的文档,请注意,我们使用的许多函数都是查询构建器的函数。因此,还要查看查询构建器的文档,网址为laravel.com/docs/5.4/queries

由于CommentController的实现将类似,我建议您自己实现CommentController,因为您只有自己动手才能真正学会。

我们错过了什么?

我们是否已经创建了为 RESTful 资源端点提供服务的控制器?实际上没有,我们错过了很多东西。我们只是创建了基本的 RESTful 网络服务,可以让您了解如何使用 Lumen 制作它,但我们错过了很多东西。因此,让我们看看它们,逐个完成它们。

验证和负面情况?

首先,我们只处理积极的情况:这意味着我们不考虑如果请求不符合我们的假设会发生什么。如果用户使用错误的方法发送数据会发生什么?如果用户传递的 ID 不存在记录会发生什么?

简而言之,我们还没有处理所有这些,但是 Lumen 已经为我们处理了一些事情。

如果您尝试使用POST方法命中端点 URL,http://localhost:8000/api/posts/1,那么这是一种无效的方法。在这些 URL 上,我们只能使用GETPUTPATCH发送请求。使用GET将触发PostControllershow()方法,而PUTPATCH将触发update()方法。但是不应该允许使用POST方法。实际上,如果您尝试使用POST方法在这些 URL 上发送请求,它将不起作用,您还将收到Method Not Allowed错误,就像应该的那样。因此,通过一次定义我们的路由,Lumen 将自行处理此类错误。

同样,Lumen 将使错误的 URL 或错误的 HTTP 方法和 URL 组合无效。

除此之外,我们不打算处理每一种情况,但让我们看看我们必须处理的重要事情;如果没有这些东西,我们的工作就无法完成。

因此,让我们看看PostController每个方法中我们错过了什么关于验证或缺失的用例。

使用 GET 方法的/api/posts

以下是/api/posts端点的响应(在我的情况下,数据库中只有一条记录):

{
   "current_page":1,
   "data":[
      {
         "id":1,
         "title":"test",
         "status":"draft",
         "content":"test post",
         "user_id":2
      }
   ],
   "from":1,
   "last_page":1,
   "next_page_url":null,
   "path":"http:\/\/localhost:8000\/api\/posts",
   "per_page":20,
   "prev_page_url":null,
   "to":1,
   "total":1
}

如果您回忆一下我们在第一章中看到的响应,RESTful Web Services, Introduction and Motivation,您会发现我们得到了我们讨论的大部分信息,但是以不同的格式得到了。尽管这完全没问题,因为它提供了足够的信息,但是这是如何使其与我们决定的内容相似。

以下是index()方法将变成什么样子:

public function index() {
 $posts = $this->post->paginate(20);
    $data = $posts['data'];

    $response = [
        'data' => $data,
        'total_count' => $posts['total'],
        'limit' => $posts['per_page'],
        'pagination' => [
            'next_page' => $posts['next_page_url'],
            'current_page' => $posts['current_page']
        ]
    ];

    return $response**;** }

最重要的是,响应的根级别的所有信息都不清晰。我们应该消除损害清晰度的内容,因为如果程序员需要花更多时间才能理解某些内容,程序员的生产力可能会受到影响。我们应该将分页相关的信息放在一个单独的属性下,可以是 pagination 或 meta,这样程序员就可以轻松地查看数据和其他属性。

我们做到了,但是我们是手动做的。现在,让我们暂时告一段落。在下一章中,我们将看看这其中有什么问题,为什么我们要手动调用它,以及我们可以做些什么。

使用 POST 方法的/api/posts

这将触发PostController::store()方法。我们错过的是验证。实际上,Lumen 还为我们提供了验证支持以及一些内置的验证规则。Lumen 的验证与 Laravel 非常相似,但也有一些不同之处。我建议您查看 Laravel 的验证文档,网址为laravel.com/docs/5.4/validation,以及 Lumen 与 Laravel 的验证差异:lumen.laravel.com/docs/5.4/validation

在这里,我们在store()中添加了验证,因此在添加验证后查看代码,然后我们将讨论它:

public function store(Request $request) {
  $input = $request->all();   $validationRules = [
        'content' => 'required|min:1',
        'title' => 'required|min:1',
        'status' => 'required|in:draft,published',
        'user_id' => 'required|exists:users,id'
    ];

    $validator = \Validator::make($input, $validationRules);
    if ($validator->fails()) {
        return new \Illuminate\Http\JsonResponse(
            [
                'errors' => $validator->errors()
            ], \Illuminate\**Http\**Response::HTTP_BAD_REQUEST
        );
 **}**   $post = $this->post->create($input);    return [
  'data' => $post
  ]; }

在这里,我们正在做 3 件事:

  1. 首先,我们设置了以下验证规则:

  2. 对于contenttitle,这些字段将是必需的,并且至少为 1 个字符长。

  3. 对于status,它是必需的,其值可以是已发布或草稿,因为它在数据库中设置为 ENUM。

  4. user_id是必需的,并且应存在于users表的id字段中。

  5. 然后,我们根据验证规则和输入创建了一个验证器对象,并检查验证器是否失败。否则,我们将继续进行。

  6. 如果验证器失败,它将手动返回错误。它返回与验证器获得的相同错误描述,同时手动返回适当的响应代码。这就是为什么我们使用了\Illuminate\Http\JsonResponse。第一个参数是响应主体,而第二个参数是响应代码。而不是编写 400 错误代码,我们可以在\Illuminate\Http\Response中使用一个常量。因此,我们将不需要记住响应代码,阅读我们的代码的人也不需要知道 400 状态代码是什么。

请注意,错误代码、响应代码和 HTTP 代码表示相同的事情。因此,如果您看到它们,不要感到困惑,它们在本书中是可以互换使用的。

使用 GET 方法的/api/posts/1

这将由show($id)方法提供。在我们的 show 方法中,我们只是获取记录并返回,但是如果传递给 URL 中的show()方法的 ID 不正确,或者记录表明该 ID 不存在,该怎么办?因此,我们只需要放置一个检查以确保它返回 404 错误,如果找不到具有该 ID 的帖子。我们的show()方法的代码将如下所示:

public function show($id) {
  $post = $this->post->find($id);
 if(!$post) {
        abort(404);
    }

    return $post**;** }

abort()方法将使用传递给它的错误代码停止执行。在这种情况下,它将简单地给出 404 Not Found 错误。

使用 PATCH/PUT 方法的/api/posts/1

它将由update()方法提供。同样,它是基于提供的 ID,因此我们需要检查该 ID 是否有效。因此,这就是我们将要做的事情:

public function update(Request $request, $id) {
  $input = $request->all();    $post = $this->post->find($id);    if(!$post) {
 abort(404);
 }   $post->fill($input);
  $post->save();    return $post; }

在这里,我们使用了模型的fill()方法,它将使用$input 中的字段和值分配给 Post 模型,然后使用save()方法保存它。在 Laravel 文档中,您可以看到使用 Eloquent 以不同方式插入和更新的方法,这在不同的地方可能很方便:laravel.com/docs/5.4/eloquent#inserting-and-updating-models

有时,您会看到 Laravel 文档链接,而不是 Lumen。这是因为 Lumen 大多使用与 Laravel 相同的代码。所有这些组件的文档大多是在 Laravel 的文档中编写的,并且没有在 Lumen 文档中复制,因此 Lumen 文档在 Lumen 与 Laravel 不同的地方是很好的。

这就是我们在update()方法中要做的事情。

使用 DELETE 方法的/api/posts/1

删除操作将由destroy($id)提供。同样,它取决于来自 API 用户的 ID,因此我们需要放置与我们为update()show()放置的类似检查。它将如下所示:

public function destroy($id) {
 $post = $this->post->find($id);

    if(!$post) {
        abort(404);
    }

    $post**->delete();**    return ['message' => 'deleted successfully', 'post_id' => $id]; }

有了这个,我们的PostController将如下所示:

<?php   namespace App\Http\Controllers;   use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse;   class PostController extends Controller {    public function __construct(\App\Post $post)
 {  $this->post = $post;
 }    /**
 * Display a listing of the resource. * * @return \Illuminate\Http\Response
 */  public function index()
 {  $posts = $this->post->paginate(20);
  $data = $posts['data'];    $response = [
  'data' => $data,
  'total_count' => $posts['total'],
  'limit' => $posts['per_page'],
  'pagination' => [
  'next_page' => $posts['next_page_url'],
  'current_page' => $posts['current_page']
 ] ];    return $response;
 }    /**
 * Store a newly created resource in storage. * * @param \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */  public function store(Request $request)
 {  $input = $request->all();    $validationRules = [
  'content' => 'required|min:1',
  'title' => 'required|min:1',
  'status' => 'required|in:draft,published',
  'user_id' => 'required|exists:users,id'
  ];    $validator = \Validator::make($input, $validationRules);
  if ($validator->fails()) {
  return new JsonResponse(
 [  'errors' => $validator->errors()
 ], Response::HTTP_BAD_REQUEST
  );
 }   $post = $this->post->create($input);    return [
  'data' => $post
  ];
 }    /**
 * Display the specified resource. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function show($id)
 {  $post = $this->post->find($id);    if(!$post) {
 abort(404);
 }    return $post;
 }    /**
 * Update the specified resource in storage. * * @param \Illuminate\Http\Request  $request
 * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function update(Request $request, $id)
 {  $input = $request->all();    $post = $this->post->find($id);    if(!$post) {
 abort(404);
 }    $post->fill($input);
  $post->save();    return $post;
 }    /**
 * Remove the specified resource from storage. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function destroy($id)
 {  $post = $this->post->find($id);    if(!$post) {
 abort(404);
 }    $post->delete();    return ['message' => 'deleted successfully', 'post_id' => $id];
 } } 

我们现在已经完成了返回适当的响应代码和验证等工作。

用户身份验证

到目前为止,我们还缺少用户身份验证。我们在输入中传递了user_id,这是错误的。我们之所以这样做是因为我们没有用户身份验证。因此,我们需要有一个身份验证机制。但是,除了身份验证之外,我们还需要有一个令牌生成机制。实际上,我们还需要刷新令牌。虽然我们可以自己做到这一点,但我们将安装另一个外部包。

在本章末尾开始用户身份验证并没有太多意义,因此我们将在下一章中处理用户身份验证,因为与之相关的事情有很多。

其他缺失的元素

我们现在缺少的其他东西如下:

  • API 版本控制

  • 速率限制或节流

  • 加密的需求

  • 转换器或序列化

(这是为了避免在控制器内部进行硬编码手动返回格式)

在下一章中,我们将处理用户认证和前面提到的元素,并进行一些其他改进。

评论资源实现

我把评论端点的实现留给了你,因为它与帖子端点的实现非常相似。但是,由于评论的两个路由与其他路由不同,为了让你了解你需要实现什么,我将告诉你在routes文件中添加什么,以便你可以相应地实现CommentController。这是routes/web.php文件:

<?php   /* |---------------------------------------------------------------- | Application Routes |---------------------------------------------------------------- | | Here is where you can register all of the routes for an application. | It is a breeze. Simply tell Lumen the URIs it should respond to | and give it the Closure to call when that URI is requested. | */     function resource($uri, $controller, $except **= []**) {
  //$verbs = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];    global $app;     if(!in_array('index', $except**)){**
  $app->get($uri, $controller.'@index');
 **}**   if(!in_array('store', $except)) {        $app->post($uri, $controller . '@store'); }

    if(!in_array('show', $except)) {        $app->get($uri . '/{id}', $controller . '@show'); };

    if(!in_array('udpate', $except)) {
        $app->put($uri . '/{id}', $controller . '@update');
        $app->patch($uri . '/{id}', $controller . '@update'); }

    if(!in_array('destroy', $except)) {        $app->delete($uri . '/{id}', $controller . '@destroy'); **}** }     $app->get('/', function () use ($app) {
  return $app->version(); });     resource('api/posts', 'PostController');  resource('api/comments', 'CommentController', ['store','index'**]);**   $app->post('api/posts/{id}/comments', $controller . '@store');
$app->get('api/posts/{id}/comments', $controller . '@index'**);**  

正如你所看到的,我们在resource()中添加了$except数组作为可选的第三个参数,这样如果我们不想为某个资源生成特定的路由,我们可以将其传递给$except数组。

CommentController中,代码将与PostController非常相似,只是对于store()index()post_id将是第一个参数并将被使用。

总结

到目前为止,我们在一个名为 Lumen 的微框架中创建了 RESTful web 服务端点。我们创建了迁移、模型和路由。我实现了PostController,但留下了CommentController的实现给你。因此,我们可以看到,我们在第三章中讨论的许多与实现相关的问题,创建 RESTful 端点,已经得到解决,因为使用了一个框架。我们能够很容易地解决许多其他问题。因此,使用正确的框架和包,我们能够工作得更快。

我们还确定了一些缺失的元素,包括用户认证。我们将在下一章中解决它们。在下一章中,我们还将从代码方面改进我们的工作。

在本章中,我们主要使用 Lumen。我们看了它,但我们试图继续制作我们的 API,所以我们无法详细查看 Lumen 及其代码的每一个部分。因此,查看 Lumen 的文档是一个好主意:lumen.laravel.com/docs/5.4/validation

为了更好地理解,您应该查看 Laravel 的文档,因为一些常见组件在 Laravel 的文档中有详细解释:laravel.com/docs/5.4。除了 Laravel 和 Lumen 的文档之外,建议去laracasts.com/观看关于 Laravel 的视频。如果在 Lumen 上找不到太多内容,不要担心,它与 Laravel 非常相似。除了一些变化,它们基本上是一样的。要了解 Laravel 和/或 Lumen,Lara casts 是一个非常好的资源,在 Laravel 社区非常受欢迎。Lara casts 主要由 Jeffrey Way 制作。我从他那里学到了很多东西,希望你也能学到。它不仅会教你 Laravel,还会教你如何开发,以及你应该如何进行开发。

第七章:改进 RESTful Web 服务

在上一章中,我们在 Lumen 中创建了 RESTful web 服务,并且确定了一些缺失的元素或需要改进的地方。在本章中,我们将致力于改进并完成一些缺失的元素。我们将改进某些元素,以满足漏洞和代码质量的要求。

我们将在本章中涵盖以下主题,以改进我们的 RESTful web 服务:

  • Dingo,简化 RESTful API 开发:

  • 安装和配置 Dingo API 软件包

  • 简化路由

  • API 版本控制

  • 速率限制

  • 内部请求

  • 身份验证和中间件

  • 转换器

  • 加密的需求:

  • SSL,不同的选项

  • 总结

Dingo,简化 RESTful API 开发

是的,你没听错。我没说 bingo。是 Dingo。实际上,Dingo API 是 Laravel 和 Lumen 的一个软件包,它使开发 RESTful web 服务变得更简单。它提供了许多开箱即用的功能,其中许多是我们在上一章中看到的。这些功能中的许多将使我们现有的代码更好,更容易理解和维护。您可以在github.com/dingo/api上查看 Dingo API 软件包。

让我们首先安装它,然后在使用它的同时,我们将继续查看其好处和特性。

安装和配置

只需通过 composer 安装它:

composer require dingo/api:1.0.x@dev

也许您想知道这个@dev是什么。因此,这是 Dingo 文档中的内容:

目前,该软件包仍处于开发阶段,因此没有稳定版本。您可能需要将最低稳定性设置为 dev。

如果您仍然不确定为什么需要设置最低稳定性,那是因为每个软件包的默认最低稳定性设置为stable。因此,如果您依赖于dev软件包,则应明确指定,否则它可能不会安装,因为最低稳定性与软件包的实际稳定性状态不匹配。

安装后,您需要注册它。转到bootstrap/app.php并在return $app;之前的某个地方放入此语句:

$app->register(Dingo\Api\Provider\LumenServiceProvider::class);

完成后,您需要在.env文件的末尾设置一些变量。在.env文件的末尾添加它们:

API_PREFIX=api
API_VERSION=v1
API_DEBUG=true
API_NAME="Blog API"
API_DEFAULT_FORMAT=json

配置是不言自明的。现在,让我们继续。

简化路由

如果您查看我们放置路由的routes/web.php文件,您会发现我们为帖子和评论端点编写了 54 行代码。使用 Dingo API,我们可以用只有 10 行代码来替换这 54 行代码,而且它会更加简洁。所以让我们这样做。您的routes/web.php文件应该如下所示:

<?php   /* |---------------------------------------------------------------- | Application Routes |---------------------------------------------------------------- | | Here is where you can register all of the routes for an application. | It is a breeze. Simply tell Lumen the URIs it should respond to | and give it the Closure to call when that URI is requested. | */ $api = app('Dingo\Api\Routing\Router');

$api->version('v1', function ($api) {

    $api->resource('posts', "App\Http\Controllers\PostController");
    $api->resource('comments', "App\Http\Controllers\CommentController", [
        'except' => ['store', 'index']
    ]); 
 $api->post('posts/{id}/comments', 'App\Http\Controllers\CommentController@store');

 $api->get('posts/{id}/comments', 'App\Http\Controllers\CommentController@index'); **});** $app->get('/', function () use ($app) {
  return $app->version(); });  

如您所见,我们刚刚在$api中得到了路由的对象。但是,这是 Dingo API 路由,而不是 Lumen 的默认路由。正如您所见,它具有我们想要的resource()方法,并且我们可以在except数组中提及不需要的方法。因此,总的来说,我们的路由现在变得非常简化。

要查看应用程序的确切路由,请运行以下命令:

php artisan route:list

API 版本控制

也许您已经注意到,在我们的路由文件中的上一个代码示例中,我们已经将 API 版本指定为v1。这是因为 API 版本控制很重要,而 Dingo 为我们提供了这样的功能。它有助于从不同版本提供不同的端点。您可以有另一个版本组,并且可以提供相同端点的不同内容。如果在不同版本下有相同的端点,则它将选择在您的.env文件中提到的版本。

但是,在 URI 中包含版本号会更好。为此,我们可以简单地使用以下前缀:

$api->version('v1', ['prefix' => 'api/v1'], function ($api) {

     $api->resource('posts', "App\Http\Controllers\PostController");
     $api->resource('comments', "App\Http\Controllers\CommentController", [
 'except' => ['store', 'index']
 ]);

     $api->post('posts/{id}/comments', 'App\Http\Controllers\CommentController@store');

     $api->get('posts/{id}/comments', 'App\Http\Controllers\CommentController@index');
});

现在,我们的路由将在 URI 中包含版本信息。这是一种推荐的方法。因为如果有人正在使用版本 1,并且我们将在版本 2 中进行更改,那么使用版本 1 的客户端在其请求中明确指定版本号时将不受影响。因此,我们的端点 URL 将如下所示:

http://localhost:8000/api/v1/posts
http://localhost:8000/api/v1/posts/1
http://localhost:8000/api/v1/posts/1/comments

但是,请注意,如果我们的 URI 和路由中有版本,那么最好在我们的控制器中实际应用该版本。如果没有,版本实现将非常有限。为此,我们应该为控制器设置基于版本的命名空间。在我们的控制器(PostControllerCommentController)中,命名空间将更改为以下代码行:

namespace App\Http\Controllers**\V1**;

现在,控制器目录结构也应该与命名空间匹配。因此,让我们在Controllers目录中创建一个名为V1的目录,并将我们的控制器移动到app\Http\Controllers**\V1**目录中。当我们有下一个版本时,我们将在app\Http\Controllers中创建另一个名为V2的目录,并在其中添加新的控制器。这也将导致一个新的命名空间App\Http\Controllers**\V2**。随着命名空间和目录的更改,routes/web.php中的控制器路径也需要相应地进行更改。

通过这个改变,你很可能会看到以下错误:

Class 'App\Http\Controllers\V1\Controller' not found

因此,要么将Controller.php从 controllers 目录移动到V1目录,要么像这样使用完整的命名空间访问它:\App\Http\Controllers\Controller

class PostController extends **\App\Http\Controllers\Controller**  {..

这取决于你如何做。

速率限制

速率限制也被称为节流。这意味着应该限制特定客户端在特定时间间隔内能够访问 API 端点的次数。要启用它,我们必须启用api.throttling中间件。您可以在所有路由或特定路由上应用节流。您只需在特定路由上应用中间件,如下所示。在我们的情况下,我们希望为所有端点启用它,所以让我们将其放在一个版本组中:

$api->version('v1', ['middleware' => 'api.throttle','prefix' => 'api/v1']**,** function ($api) {   $api->resource('posts', "App\Http\Controllers\V1\PostController");
  $api->resource('comments', "App\Http\Controllers\V1\CommentController", [
  'except' => ['store', 'index']
 ]);  $api->post('posts/{id}/comments', 'App\Http\V1\Controllers\CommentController@store'); $api->get('posts/{id}/comments', 'App\Http\V1\Controllers\CommentController@index');  });

为了简单起见,让我们在路由中进行一些更改。我们可以在版本组中使用命名空间,而不是在每个控制器的名称中指定命名空间,如下所示:

$api->version('v1', ['middleware' => 'api.throttle', 'prefix' => 'api/v1', **namespace => "App\HttpControllers\V1"** **]**

现在,我们可以简单地从控制器路径中删除它。

您还可以在分钟内提及限制和时间间隔:

$api->version('v1', [
  'middleware' => 'api.throttle',
 'limit' => 100,
    'expires' => 5**,**
  'prefix' => 'api/v1',
  'namespace' => 'App\Http\Controllers\V1'
  ], function ($api) {
  $api->resource('posts', "PostController");
  $api->resource('comments', "CommentController", [
  'except' => ['store', 'index']
 ]);    $api->post('posts/{id}/comments', 'CommentController@store');
  $api->get('posts/{id}/comments', 'CommentController@index');   });

在这里,expires是时间间隔,而limit是路由可以被访问的次数。

内部请求

我们大多数情况下是制作一个 API,由外部客户端作为 Web 服务访问,而不是从同一应用程序访问。但是,有时我们处于需要在同一应用程序内进行内部请求并希望以与返回给外部客户端相同的格式返回数据的情况。

假设现在您希望从 API 中的PostController获取评论数据,因为它返回一个响应而不是内部函数调用。我们希望在 Postman 或其他客户端访问时,获得与/api/posts/{postId}/comments端点返回的相同数据。在这种情况下,Dingo API 包可以帮助我们。以下是它的简单用法:

use  Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse; use **Dingo\Api\Routing\Helpers;**   class PostController extends Controller {
 use **Helpers;**    public function __construct(\App\Post $post)
 {  $this->post = $post;
 }
.... /**
 * Display the specified resource. * * @param int  $id
 * @return \Illuminate\Http\Response
 */ public function show($id) {
 $comments = $this->api->get("api/posts/$id/comments"**);**    $post = $this->post->find($id); ....
 }
....
} 

在前面的代码片段中,加粗的语句是不同的,它有助于进行内部请求。正如您所看到的,我们已经对端点进行了GET请求:

 $comments = $this->api->get("api/posts/$id/comments");

我们也可以通过使用不同的方法使其成为基于POST的请求,如下所示:

$this->api->post("api/v1/posts/$id/comments", ['comment' => 'a nice post']);

响应

Dingo API 包为不同类型的响应提供了更多的支持。由于我们不会详细介绍 Dingo API 提供的每一件事情,您可以在其文档中查看:github.com/dingo/api/wiki/Responses

然而,在本章的后面,我们将详细了解响应和格式。

我们将在其他方面使用 Dingo API 包,但现在让我们转向其他概念,我们将继续同时使用 Dingo API 包。

身份验证和中间件

我们已经多次讨论过,对于 RESTful Web 服务,会话是通过存储在客户端的身份验证令牌来维护的。因此,服务器可以查找身份验证令牌,并且可以在服务器上找到用户的会话。

有几种生成令牌的方式。在我们的情况下,我们将使用JWTJSON Web Tokens)。如在jwt.io上所述:

JSON Web Tokens 是一种开放的、行业标准的 RFC 7519 方法,用于在两个方之间安全地表示声明。

我们不会详细讨论 JWT,因为 JWT 是在两个方之间传输信息的一种方式(在我们的情况下是客户端和服务器),因为 JWT 可以用于许多目的。相反,我们将使用它来进行访问/身份验证令牌以维护无状态会话。因此,我们将坚持从 JWT 中获取我们需要的内容。我们需要它来维护身份验证目的的会话,并且这也是 Dingo API 包将帮助我们解决的问题。

事实上,Dingo API 在撰写本书时支持三种身份验证提供者。默认情况下启用了 HTTP 基本身份验证。另外两种是 JWT Auth 和 OAuth 2.0。我们将使用 JWT Auth。

JWT Auth 设置

Dingo API 用于集成 JWT 身份验证的包可以在github.com/tymondesigns/jwt-auth找到。



有两种设置 JWT Auth 的方式:

  1. 我们可以简单地按照 JWT Auth 包的说明进行配置,并手动修复一个接一个的问题。

  2. 我们可以简单地安装另一个包,帮助我们安装和设置 Dingo API 和 JWT Auth,并进行一些基本配置。

在这里,我们将看到两种方式。然而,使用手动方式可能会因不同包的不同版本和 Lumen 本身而变得模糊。因此,尽管我将展示手动方式,但我建议您使用集成包,这样您就不需要手动处理每个低级别的事情。我将向您展示手动方式,只是为了让您对底层包含的内容有一些了解。

手动方式

让我们按照github.com/tymondesigns/jwt-auth/wiki/Installation安装页面上提到的包进行安装。

首先,我们需要安装 JWT Auth 包:

 composer require tymon/jwt-auth 1.0.0-beta.3

请注意,此版本适用于 Laravel 5.3。对于旧版本,您可能需要使用不同版本的 JWT Auth 包,很可能是版本 0.5。

要在bootstrap/app.php文件中注册服务提供者,请添加以下代码行:

$app->register(Tymon\JWTAuth\Providers\JWTAuthServiceProvider::class);

然后,在同一个bootstrap/app.php文件中添加这两个类别名:

class_alias('Tymon\JWTAuth\Facades\JWTAuth', 'JWTAuth'); class_alias('Tymon\JWTAuth\Facades\JWTFactory', 'JWTFactory');

然后,您需要生成一个用于签署我们令牌的随机密钥。要这样做,请运行此命令:

php artisan jwt:generate

这将生成一个随机密钥。

您可能会看到一些错误,如下所示:

[Illuminate\Contracts\Container\BindingResolutionException] Unresolvable dependency resolving [Parameter #0 [ <required> $app ]] in class Illuminate\Cache\CacheManager

在这种情况下,在bootstrap/app.php中的$app->withEloquent();之后添加以下行。这样就可以解决问题,您可以尝试生成一个随机密钥:

$app->alias('cache', 'Illuminate\Cache\CacheManager'); $app->alias('auth', 'Illuminate\Auth\AuthManager');

然而,您可能想知道这个随机密钥将在哪里设置。实际上,有些包并不是为 Lumen 构建的,而是需要更像 Laravel 的结构。tymondesigns/jwt-auth包就是其中之一。它需要一种发布配置的方式。虽然 Lumen 没有为不同的包单独的配置文件,但我们需要它,我们可以让 Lumen 为这个包拥有这样一个config文件。要这样做,如果您在app/目录下没有helpers.php,那么创建它并添加以下内容:

<?php if ( ! function_exists('config_path')) {
  /**
 * Get the configuration path. * * @param string $path
 * @return string
 */  function config_path($path = '')
 {  return app()->basePath() . '/config' . ($path ? '/' . $path : $path);
 } }

然后,在自动加载数组的composer.json中添加helpers.php

"autoload": {
  "psr-4": {
    "App\\": "app/"
  },
  "files": [
    "app/helpers.php"
  ]
},

运行以下命令:

composer dump-autoload

一旦你拥有了它,运行以下命令:

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\JWTAuthServiceProvider"

在这一点上,您将收到另一个错误消息:

[Symfony\Component\Console\Exception\CommandNotFoundException]
 There are no commands defined in the "vendor" namespace.

不用担心,这是正常的。这是因为 Lumen 没有vendor:publish命令。所以,我们需要安装一个小包来执行这个命令:

composer require laravelista/lumen-vendor-publish

由于这个命令将有一个新的命令,为了使用该命令,我们需要在app/Console/Kernel.php$commands数组中放入以下内容。

现在,尝试再次运行相同的命令,如下所示:

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\JWTAuthServiceProvider"

这一次,你会看到类似这样的东西:

Copied File [/vendor/tymon/jwt-auth/src/config/config.php] To [/config/jwt.php]
Publishing complete for tag []!

现在,我们有blog/config/jwt.php,我们可以在这个文件中存储与jwt-auth包相关的配置。

我们需要做的第一件事是重新运行这个命令来设置随机密钥签名:

php artisan jwt:generate

这一次,你可以在config/jwt.php文件的返回数组中看到这个密钥设置:

'secret' => env('JWT_SECRET', 'RusJ3fiLAp4DmUNNzqGpC7IcQI8bfar7'),

接下来,你需要按照github.com/tymondesigns/jwt-auth/wiki/Configuration中所示的进行配置。

然而,你也可以将config/jwt.php中的其他设置保持为默认。

接下来要做的事情是告诉 Dingo API 使用 JWT 作为认证方法。所以在bootstrap/app.php中添加这个:

app('Dingo\Api\Auth\Auth')->extend('jwt', function ($app) {
 return new Dingo\Api\Auth\Provider\JWT($app['Tymon\JWTAuth\JWTAuth']);
});

根据 JWT Auth 文档,我们大部分配置都已经完成了,但请注意,你可能会遇到与版本相关的小问题。如果你使用的版本早于 Lumen 5.3,那么请注意,基于不同的 Laravel 版本,需要使用不同版本的 JWT Auth。对于版本 5.2,你应该使用 JWT Auth 版本 0.5。所以,如果你在低于 Laravel 5.2 的版本中仍然遇到任何错误,那么请注意,可能是因为版本差异导致的错误,所以你需要在互联网上搜索。

正如你所看到的,为了同时使用两个包来实现一些功能,我们必须花费一些时间进行配置,就像最近的几个步骤建议的那样。即使这样,由于版本差异,仍然有可能出现错误。因此,一个简单的方法是不要手动安装 Dingo API 包和 JWT Auth 包。还有另一个包,安装它将安装 Dingo API 包、Lumen 生成器、CORS(跨域资源共享)支持、JWTAuth,并使其可用,而不需要那么多的配置。现在,让我们来看看。

通过 Lumen JWT 认证集成包的更简单方式

更简单的方法是自己安装 Dingo API 包和 JWT Auth,只需安装packagist.org/packages/krisanalfa/lumen-dingo-adapter.

它将在你的基于 Lumen 的应用程序中添加 Dingo 和 JWT。只需安装这个包:

composer require krisanalfa/lumen-dingo-adapter

然后,在bootstrap/app.php中,添加以下代码行:

$app->register(Zeek\LumenDingoAdapter\Providers\LumenDingoAdapterServiceProvider::class);

现在,我们正在使用LumenDingoAdapter包,所以这是我们将使用的bootstrap/app.php文件,这样你就可以与你的文件进行比较:

<?php   require_once __DIR__.'/../vendor/autoload.php';   try {
 (new Dotenv\Dotenv(__DIR__.'/../'))->load(); } catch (Dotenv\Exception\InvalidPathException $e) {
  // }   /* |-------------------------------------------------------------------------- | Create The Application |-------------------------------------------------------------------------- | | Here we will load the environment and create the application instance | that serves as the central piece of this framework. We'll use this | application as an "IoC" container and router for this framework. | */   $app = new Laravel\Lumen\Application(
  realpath(__DIR__.'/../') );     $app->withFacades();

 $app**->withEloquent();**   /* |-------------------------------------------------------------------------- | Register Container Bindings |-------------------------------------------------------------------------- | | Now we will register a few bindings in the service container. We will | register the exception handler and the console kernel. You may add | your own bindings here if you like or you can make another file. | */   $app->singleton(
 Illuminate\Contracts\Debug\ExceptionHandler::class,
 App\Exceptions\Handler::class );   $app->singleton(
 Illuminate\Contracts\Console\Kernel::class,
 App\Console\Kernel::class );   $app->register(Zeek\LumenDingoAdapter\Providers\LumenDingoAdapterServiceProvider::class);     /* |-------------------------------------------------------------------------- | Register Middleware |-------------------------------------------------------------------------- | | Next, we will register the middleware with the application. These can | be global middleware that run before and after each request into a | route or middleware that'll be assigned to some specific routes. | */   // $app->middleware([ //    App\Http\Middleware\ExampleMiddleware::class // ]);   // $app->routeMiddleware([ //     'auth' => App\Http\Middleware\Authenticate::class, // ]);   /* |-------------------------------------------------------------------------- | Register Service Providers |-------------------------------------------------------------------------- | | Here we will register all of the application's service providers which | are used to bind services into the container. Service providers are | totally optional, so you are not required to uncomment this line. | */   // $app->register(App\Providers\AppServiceProvider::class); // $app->register(App\Providers\AuthServiceProvider::class); // $app->register(App\Providers\EventServiceProvider::class);   /* |-------------------------------------------------------------------------- | Load The Application Routes |-------------------------------------------------------------------------- | | Next we will include the routes file so that they can all be added to | the application. This will provide all of the URLs the application | can respond to, as well as the controllers that may handle them. | */   $app->group(['namespace' => 'App\Http\Controllers'], function ($app) {
  require __DIR__.'/../routes/web.php'; });   return $app; 

如果你想知道$app->withFacades()到底是做什么的,那么请注意,这将在应用程序中启用门面。门面是一种设计模式,用于将复杂的事物抽象化,同时提供简化的接口进行交互。在 Lumen 中,正如 Laravel 文档所述:门面为应用程序服务容器中可用的类提供了一个“静态”接口。

使用门面的好处是它提供了易记的语法。我们不会经常使用门面,并且会尽量避免使用它,因为我们更倾向于使用依赖注入。然而,一些包可能会使用门面,所以为了让它们工作,我们已经启用了门面。

认证

现在,我们可以使用api.auth中间件来保护我们的端点。这个中间件检查用户认证并从 JWT 中获取用户。然而,首先要做的是让用户登录,根据用户信息创建一个令牌,并将签名令牌返回给客户端。

为了使认证工作,我们首先需要创建一个与认证相关的控制器。这个控制器不仅会根据用户登录创建令牌,还会使用户令牌过期并刷新令牌。为了做到这一点,我们可以将这个开源的AuthController放在app/Http/Controllers/Auth/目录下

github.com/Haafiz/REST-API-for-basic-RPG/blob/master/app/Http/Controllers/Auth/AuthController.php.

为了给予信用,我想告诉你,我们使用的AuthController版本是github.com/krisanalfa/lumen-jwt/blob/develop/app/Http/Controllers/Auth/AuthController.php的修改版本。

无论如何,如果你在阅读本书时没有看到AuthController在线上可用,这里是AuthController的内容:

<?php   namespace App\Http\Controllers\Auth;   use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse; use Tymon\JWTAuth\Facades\JWTAuth; use App\Http\Controllers\Controller; use Tymon\JWTAuth\Exceptions\JWTException; use Illuminate\Http\Exception\HttpResponseException;   class AuthController extends Controller {
  /**
 * Handle a login request to the application. * * @param \Illuminate\Http\Request $request
 * * @return \Illuminate\Http\Response;
 */  public function login(Request $request)
 {  try {
  $this->validateLoginRequest($request);
 } catch (HttpResponseException $e) {
  return $this->onBadRequest();
 }    try {
  // Attempt to verify the credentials and create a token for the user
  if (!$token = JWTAuth::attempt(
  $this->getCredentials($request)
 )) {  return $this->onUnauthorized();
 } } catch (JWTException $e) {
  // Something went wrong whilst attempting to encode the token
  return $this->onJwtGenerationError();
 }    // All good so return the token
  return $this->onAuthorized($token);
 }    /**
 * Validate authentication request. * * @param Request $request
 * @return void
 * @throws HttpResponseException
 */  protected function validateLoginRequest(Request $request)
 {  $this->validate(
  $request, [
  'email' => 'required|email|max:255',
  'password' => 'required',
 ] ); }    /**
 * What response should be returned on bad request. * * @return JsonResponse
 */  protected function onBadRequest()
 {  return new JsonResponse(
 [  'message' => 'invalid_credentials'
  ], Response::HTTP_BAD_REQUEST
  );
 }    /**
 * What response should be returned on invalid credentials. * * @return JsonResponse
 */  protected function onUnauthorized()
 {  return new JsonResponse(
 [  'message' => 'invalid_credentials'
  ], Response::HTTP_UNAUTHORIZED
  );
 }    /**
 * What response should be returned on error while generate JWT. * * @return JsonResponse
 */  protected function onJwtGenerationError()
 {  return new JsonResponse(
 [  'message' => 'could_not_create_token'
  ], Response::HTTP_INTERNAL_SERVER_ERROR
  );
 }    /**
 * What response should be returned on authorized. * * @return JsonResponse
 */  protected function onAuthorized($token)
 {  return new JsonResponse(
 [  'message' => 'token_generated',
  'data' => [
  'token' => $token,
 ] ] ); }    /**
 * Get the needed authorization credentials from the request. * * @param \Illuminate\Http\Request $request
 * * @return array
 */  protected function getCredentials(Request $request)
 {  return $request->only('email', 'password');
 }    /**
 * Invalidate a token. * * @return \Illuminate\Http\Response
 */  public function invalidateToken()
 {  $token = JWTAuth::parseToken();    $token->invalidate();    return new JsonResponse(['message' => 'token_invalidated']);
 }    /**
 * Refresh a token. * * @return \Illuminate\Http\Response
 */  public function refreshToken()
 {  $token = JWTAuth::parseToken();    $newToken = $token->refresh();    return new JsonResponse(
 [  'message' => 'token_refreshed',
  'data' => [
  'token' => $newToken
  ]
 ] ); }    /**
 * Get authenticated user. * * @return \Illuminate\Http\Response
 */  public function getUser()
 {  return new JsonResponse(
 [  'message' => 'authenticated_user',
  'data' => JWTAuth::parseToken()->authenticate()
 ] ); } } 

这个控制器有三个主要任务:

  • 登录login()方法

  • 使令牌失效

  • 刷新令牌

登录

登录是在login()方法中完成的,并且它尝试使用JWTAuth::attempt($this->getCredentials($request))进行登录。如果凭据无效或者出现其他问题,它将返回一个错误。然而,要访问这个login()方法,我们需要为它添加一个路由。这是我们将在routes/web.php中添加的内容:

$api->post(
  '/auth/login', [
  'as' => 'api.auth.login',
  'uses' => 'Auth\AuthController@login',
 ] );

使令牌失效

为了使令牌失效,换句话说,注销用户,将使用invalidateToken()方法。这个方法将通过一个路由来调用。我们将添加以下路由,使用删除请求方法,它将从路由文件中调用AuthController::invalidateToken()

$api->delete(
  '/', [
  'uses' => 'Auth/AuthController@invalidateToken',
  'as' => 'api.auth.invalidate'
  ] );

刷新令牌

当令牌根据到期时间过期时,将调用刷新令牌。为了刷新令牌,我们还需要添加以下路由:

$api->patch(
  '/', [
  'uses' => 'Auth/AuthController@refreshToken',
  'as' => 'api.auth.refresh'
  ] );

请注意,所有这些端点都将添加在版本 v1 下。

一旦我们有了AuthController并且路由设置好了,用户可以使用以下端点进行登录:

POST /api/v1/auth/login
Params: email, passsword

尝试一下,你将获得基于 JWT 的访问令牌。

Lumen、Dingo、JWT Auth 和 CORS 样板

如果你在配置 Lumen 与 Dingo 和 JWT 时遇到困难,那么你可以简单地使用github.com/krisanalfa/lumen-jwt.这个存储库将为你提供使用 Dingo API 和 JWT 设置你的 Lumen 进行 API 开发的样板代码。你可以克隆它并开始使用。这只是一个 Lumen 与 JWT、Dingo API 和 CORS 支持的集成。因此,如果你要开始一个新的 RESTful web 服务项目,你可以直接使用这个样板代码开始。

在继续之前,让我们看一下我们的路由文件,确保我们在同一个页面上:

<?php   /* |-------------------------------------------------------------------------- | Application Routes |-------------------------------------------------------------------------- | | Here is where you can register all of the routes for an application. | It is a breeze. Simply tell Lumen the URIs it should respond to | and give it the Closure to call when that URI is requested. | */ $api = app('Dingo\Api\Routing\Router');     $api->version('v1', [
  'middleware' => ['api.throttle'],
  'limit' => 100,
  'expires' => 5,
  'prefix' => 'api/v1',
  'namespace' => 'App\Http\Controllers\V1' ],
  function ($api) {
 $api->group(['middleware' => 'api.auth'], function ($api) {
            //Posts protected routes
            $api->resource('posts', "PostController", [
                'except' => ['show', 'index']
            ]);

            //Comments protected routes
            $api->resource('comments', "CommentController", [
                'except' => ['show', 'index']
            ]);

            $api->post('posts/{id}/comments', 'CommentController@store'**);**      // Logout user by removing token
  $api->delete(
  '/', [
  'uses' => 'Auth/AuthController@invalidateToken',
  'as' => 'api.Auth.invalidate'
  ]
 );      // Refresh token
  $api->patch(
  '/', [
  'uses' => 'Auth/AuthController@refreshToken',
  'as' => 'api.Auth.refresh'
  ]
 ); **});**  $api->get('posts', 'PostController@index');
 $api->get('posts/{id}', 'PostController@show'**);**

  $api->get('posts/{id}/comments', 'CommentController@index');
 $api->get('comments/{id}', 'CommentController@show'**);**    $api->post(
  '/auth/login', [
  'as' => 'api.Auth.login',
  'uses' => 'Auth\AuthController@login',
 ] );  });    $app->get('/', function () use ($app) {
  return $app->version(); });   

正如你所看到的,我们已经创建了一个路由组。路由组只是一种将相似的路由分组的方式,我们可以在其中应用相同的中间件、命名空间或前缀等,就像我们在v1组中所做的那样。

在这里,我们创建了另一个路由组,以便我们可以在其中添加api.auth中间件。还要注意的一点是,我们已经将一些帖子路由从帖子资源路由中拆分出来,以便在没有登录的情况下有一些可用的路由。我们也对评论路由做了同样的处理。

请注意,如果你不想将一些路由从资源路由中拆分出来,那么你也可以这样做。你只需在控制器中添加api.auth中间件,而不是在路由文件中。两种方式都是正确的;这只是一个偏好问题。我之所以这样做,是因为我发现从相同的路由文件而不是不同控制器的构造函数中知道哪些路由受保护更容易。但再次强调,这取决于你。

我们只允许已登录的用户创建、更新和删除帖子。但是,我们需要确保已登录的用户只能更新或删除自己的帖子。虽然这也可以通过创建另一个中间件来实现,但在控制器中实现会更简单。

这就是我们在 PostController 中的做法:

<?php   namespace App\Http\Controllers\V1;   use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse; use Tymon\JWTAuth\Facades\JWTAuth; use Dingo\Api\Routing\Helpers;   class PostController extends Controller {
  use Helpers;    public function __construct(\App\Post $post)
 {     $this->post = $post;   }    /**
 * Display a listing of the resource. * * @return \Illuminate\Http\Response
 */  public function index(Request $request)
 {  $posts = $this->post->paginate(20);    return $posts;
 }    /**
 * Store a newly created resource in storage. * * @param \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */  public function store(Request $request)
 {    $input = $request->all();
 $input['user_id'] = $this->user->id**;**    $validationRules = [
  'content' => 'required|min:1',
  'title' => 'required|min:1',
  'status' => 'required|in:draft,published',
  'user_id' => 'required|exists:users,id'
  ];    $validator = \Validator::make($input, $validationRules);
  if ($validator->fails()) {
  return new JsonResponse(
 [  'errors' => $validator->errors()
 ], Response::HTTP_BAD_REQUEST
  );
 }    $this->post->create($input);    return [
  'data' => $input
  ];
 }    /**
 * Display the specified resource. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function show($id)
 {  $post = $this->post->find($id);    if(!$post) {
 abort(404);
 }    return $post;
 }    /**
 * Update the specified resource in storage. * * @param \Illuminate\Http\Request  $request
 * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function update(Request $request, $id)
 {  $input = $request->all();    $post = $this->post->find($id);    if(!$post) {
 abort(404);
 } if($this->user->id != $post->user_id){
            return new JsonResponse(
                [
                    'errors' => 'Only Post Owner can update it'
                ], Response::HTTP_FORBIDDEN
            ); **}**    $post->fill($input);
  $post->save();    return $post;
 }    /**
 * Remove the specified resource from storage. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function destroy($id)
 {  $post = $this->post->find($id);    if(!$post) {
 abort(404);
 } if($this->user->id != $post->user_id){
            return new JsonResponse(
                [
                    'errors' => 'Only Post Owner can delete it'
                ], Response::HTTP_FORBIDDEN
            ); **}**    $post->delete();    return ['message' => 'deleted successfully', 'post_id' => $id];
 } } 

正如您所看到的,我在三个地方都突出显示了代码。在 store() 方法中,我们从中获取了用户 ID,并将其放入输入数组中,以便帖子的 user_id 基于令牌。同样,在 update()delete() 中,我们使用了该用户的 ID,并放置了一个检查,以确保帖子所有者正在删除或更新帖子记录。您可能会想知道,当我们没有在任何地方定义 $this->user 属性时,我们是如何访问它的?实际上,我们正在使用 Helpers trait,所以 $this->user 来自该 trait。

注意,为了访问受保护的资源,您应该从登录端点获取令牌,并将其放在标头中,如下所示:Authentication: bearer <token grabbed from login>

同样,CommentController 将进行检查,以确保评论修改将仅限于评论所有者,并且删除将仅限于评论或帖子所有者。它将具有类似的检查和用户 ID 通过令牌的方式。因此,我将留给您实现评论控制器以进行这些检查。

转换器

在全栈 MVC 框架中,我们有模型视图控制器。在 Lumen 或 API 中,我们没有视图,因为我们只返回一些数据。但是,我们可能希望以与通常不同的方式显示数据。我们可能希望使用不同的格式,或者我们可能希望限制具有特定格式的对象。在所有这些情况下,我们需要一个处理格式相关任务的地方,一个可以包含不同格式相关内容的地方。我们可以在控制器中实现。但是,我们需要在不同的地方定义相同的格式。在这种情况下,我们可以在模型中添加一个方法。例如,帖子模型可以有一种特定的方式来格式化帖子对象。因此,我们可以在帖子模型中定义另一个方法。

它会很好地工作,但是如果你仔细看,它与格式有关,而不是模型。因此,我们有另一层叫做序列化或转换器。有时,我们需要嵌套对象,所以我们不希望一遍又一遍地做同样的嵌套。

Lumen 提供了一种将数据序列化为 JSON 的方法。在 Eloquent 对象中,有一个名为 toJson() 的方法;这个方法可以被重写以达到目的。但是,最好有一个单独的层用于格式化和序列化数据,而不是在同一个类中只有一个方法来实现。然后就是转换器;转换器只是另一层。您可以将转换器视为 API 或 web 服务的视图层。

理解和设置转换器

实际上,我们使用的包名为 Dingo API 包含了创建 RESTful web 服务所需的许多内容。同样,Dingo API 包还提供了对转换器的支持。

在做任何事情之前,我们需要了解转换器层由转换器组成。转换器是负责数据呈现的类。Dingo API 转换器支持转换器,对于转换器,API 依赖于另一个负责转换器功能的库。由我们决定使用哪个转换层或库。默认情况下,它使用 Fractal,一个默认的转换层。

我们不需要做任何其他与设置相关的事情。让我们开始使用转换器来处理我们的对象。但是,在那之前,让自己熟悉 Fractal。我们至少需要知道 Fractal 是什么以及它提供了什么。Fractal 的文档可以在 fractal.thephpleague.com/ 找到。

使用转换器

有两种方法可以告诉 Lumen 要使用哪个转换器类。为此,我们需要创建一个转换器类。让我们首先为我们的Post对象创建一个转换器,并将其命名为PostTransformer。首先,创建一个名为app/Transformers的目录,在该目录中,创建一个名为PostTransformer的类,内容如下:

<?php   namespace App\Transformers;   use League\Fractal;   class PostTransformer extends Fractal\TransformerAbstract {   public function transform(\App\Post $post)
 {   return $post->toArray();
 } }

您可以在transform()方法中对 Post 响应进行任何想要的操作。请注意,我们在这里不是可选地重写transform()方法,而是提供了transform()的实现。您始终需要在转换器类中添加该方法。但是,如果没有从任何地方使用该类,则该类将毫无用处。因此,让我们在我们的PostController中使用它。让我们在index()方法中使用它:

public function index(**\App\Transformers\PostTransformer** $postTransformer) {
  $posts = $this->post->paginate(20);   return $this->response->paginator($posts, $postTransformer**);** } 

正如您所看到的,我们已经将PostTransformer对象注入到$this->response->paginator()方法中。这里我们需要注意的第一件事是$this->response->paginator()方法和$this->response对象。我们现在需要知道$this->response对象最初是从哪里来的。我们得到它是因为我们在PostController中使用了Helpers trait。无论如何,现在让我们看看它是如何工作的。使用以下端点击中PostControllerindex()方法:

http://localhost:8000/api/v1/posts

它会返回类似这样的东西:

{ "data": [
 { "id": 1,
 "title": "test",
 "status": "draft",
 "content": "test post",
 "user_id": 2,
 "created_at": null,
 "updated_at": "2017-06-28 00:47:50"
 }, {  "id": 3,
  "title": "test",
  "status": "published",
  "content": "test post",
  "user_id": 2,
 "created_at": "2017-06-28 00:00:44",
  "updated_at": "2017-06-28 00:00:44"
  },
 {  "id": 4,
  "title": "test",
  "status": "published",
  "content": "test post",
  "user_id": 2,
  "created_at": "2017-06-28 03:21:36",
  "updated_at": "2017-06-28 03:21:36"
  },
 {  "id": 5,
  "title": "test post",
  "status": "draft",
  "content": "This is yet another post for testing purpose",
  "user_id": 8,
  "created_at": "2017-07-15 00:45:29",
  "updated_at": "2017-07-15 00:45:29"
  },
 {  "id": 6,
  "title": "test post",
  "status": "draft",
  "content": "This is yet another post for testing purpose",
  "user_id": 8,
  "created_at": "2017-07-15 23:53:23",
  "updated_at": "2017-07-15 23:53:23"
  } ], "meta": {
    "pagination": {
        "total": 5,
            "count": 5,
            "per_page": 20,
            "current_page": 1,
            "total_pages": 1,
            "links": []
        }
    }
}

如果您仔细观察,您会看到一个单独的元数据部分,其中包含与分页相关的内容。这是 Fractal 转换器本身提供的一个小功能。实际上,Fractal 可以为我们提供更多。

我们可以包含嵌套对象。例如,如果我们在Post中有user_id,并且我们希望User对象嵌套在同一个Post对象中,那么它也可以提供更简单的方法来实现。尽管转换器层就像 API 响应数据的视图层一样,但它提供的远不止于此。现在,我将向您展示当我们从show()和其他方法中返回PostTransformer后,我们的PostController方法将会是什么样子。有关 Fractal 的详细信息,我建议您查看 Fractal 文档,以便充分利用它,网址为fractal.thephpleague.com/

以下是我们的PostController方法的样子:

<?php   namespace App\Http\Controllers\V1;   use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse;  use **Dingo\Api\Routing\Helpers;** use App\Transformers\PostTransformer;   class PostController extends Controller {
 use **Helpers;**    public function __construct(\App\Post $post, \App\Transformers\PostTransformer $postTransformer)
 {   $this->post = $post;    $this->transformer = $postTransformer;   }    /**
 * Display a listing of the resource. * * @return \Illuminate\Http\Response
 */  public function index()
 {  $posts = $this->post->paginate(20);   return $this->response->paginator($posts, $this->transformer**);**
 }    /**
 * Store a newly created resource in storage. * * @param \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */  public function store(Request $request)
 {    $input = $request->all();
  $input['user_id'] = $this->user->id;    $validationRules = [
  'content' => 'required|min:1',
  'title' => 'required|min:1',
  'status' => 'required|in:draft,published',
  'user_id' => 'required|exists:users,id'
  ];    $validator = \Validator::make($input, $validationRules);
  if ($validator->fails()) {
  return new JsonResponse(
 [  'errors' => $validator->errors()
 ], Response::HTTP_BAD_REQUEST
  );
 }   $post = $this->post->create($input);   return $this->response->item($post, $this->transformer**);**
 }    /**
 * Display the specified resource. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function show($id)
 {  $post = $this->post->find($id);    if(!$post) {
 abort(404);
 }   return $this->response->item($post, $this->transformer**);**
 }    /**
 * Update the specified resource in storage. * * @param \Illuminate\Http\Request  $request
 * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function update(Request $request, $id)
 {  $input = $request->all();    $post = $this->post->find($id);    if(!$post) {
 abort(404);
 }    if($this->user->id != $post->user_id){
  return new JsonResponse(
 [  'errors' => 'Only Post Owner can update it'
  ], Response::HTTP_FORBIDDEN
  );
 }    $post->fill($input);
  $post->save();   return $this->response->item($post, $this->transformer**);**
 }    /**
 * Remove the specified resource from storage. * * @param int  $id
 * @return \Illuminate\Http\Response
 */  public function destroy($id)
 {  $post = $this->post->find($id);    if(!$post) {
 abort(404);
 }    if($this->user->id != $post->user_id){
  return new JsonResponse(
 [  'errors' => 'Only Post Owner can delete it'
  ], Response::HTTP_FORBIDDEN
  );
 }    $post->delete();    return ['message' => 'deleted successfully', 'post_id' => $id];
 } } 

从前面的代码片段中突出显示的行中,您可以看到我们在构造函数中添加了PostTransformer对象,并将其放置在$this->transformer中,我们在其他方法中使用了它。您还可以看到,在一个地方,我们在index()方法中使用了$this->response->paginator()方法,而在其他方法中我们使用了$this->response->item()。这是因为当有一个对象时,我们使用$this->response->item()方法,而在index()方法中有paginator对象时,我们使用paginator。请注意,如果您有一个集合并且没有paginator对象,则应该使用$this->response->collection()

如前所述,Fractal 具有更多功能,这些功能在其文档中有所介绍。因此,您需要暂停一下,并在fractal.thephpleague.com/上探索其文档。

加密

我们缺少的下一件事是加密客户端和服务器之间的通信,以便没有人可以在网络上嗅探和读取数据。为此,我们将使用SSL安全套接字层)。由于本书不涉及加密、密码学或服务器设置,我们不会详细介绍这些概念,但重要的是我们在这里谈论加密。如果有人能够在网络上嗅探数据,那么我们的网站或网络服务就不安全。

为了保护我们的 Web 服务,我们将使用 HTTPS 而不是 HTTP。HTTPS 中的“S”代表安全。现在,问题是我们如何使它安全。你可能会说我们将使用 SSL,就像我们之前说的那样。那么 SSL 是什么?SSL 是安全套接字层,是服务器和浏览器之间安全通信的标准方式。SSL 指的是安全协议。实际上 SSL 协议有三个版本,它们对一些攻击是不安全的。所以我们实际使用的是 TLS(传输层安全)。然而,当我们提到 TLS 时,我们仍然使用 SSL 术语。如果你想使用 SSL 证书和 SSL 来使 HTTP 安全,实际上底层使用的是比原始 SSL 协议更好的 TLS。

当建立连接时,服务器会将 SSL 证书的副本与公钥一起发送给浏览器,以便浏览器也可以对与服务器之间的通信进行编码或解码。我们不会深入讨论加密细节;然而,我们需要知道如何获得 SSL 证书。

SSL 证书,不同的选项

通常,SSL 证书是从证书提供商那里购买的。然而,你也可以从letsencrypt.org获得免费证书。所以,如果有免费证书可用,那为什么还要购买证书呢?实际上,有时从某些机构购买证书更多的是为了保险而不是安全。如果你正在建立一个电子商务网站或者接受付款或者非常关键的金融信息等非常重要的数据,那么你需要有人在你的网站用户面前承担责任。

也许从letsencrypt.org获得的证书与以较低价格出售的提供商的证书之间存在一些微小的差异(我不知道),但通常,购买证书更多的是为了保险而不是安全。

你将从你获得证书的地方得到安装说明。如果你选择使用letsencrypt.org,那么我建议你使用 certbot。请按照certbot.eff.org/上的说明进行操作。

总结

在本章中,我们讨论了在上一章中我们在 Lumen 中实现 RESTful Web 服务端点时所缺少的内容。我们讨论了限流(请求速率限制)以防止 DoS 或暴力破解。我们还使用了一些软件包实现了基于令牌的身份验证。请注意,我们只在这里保护了端点,我们不希望在用户未登录的情况下留下可访问的端点。如果有其他端点,你不希望公开访问,但它们不需要用户登录,那么你可以在这些端点上使用某种密钥或基本身份验证。

除此之外,我们讨论并使用了一些用于 Web 服务的视图层的转换器。然后,我们简要讨论了加密和 SSL 的重要性,然后讨论了 SSL 证书的可用选项。

在本章中,我不会给你更多资源的 URL 列表,因为我们在本章中讨论了很多不同的事情,所以我们无法深入了解每一件事的细节。要完全吸收它,你应该首先查看我们在这里讨论的每一件事的文档,然后进行实践。当你在实践中遇到问题并尝试解决它们时,你才会真正学到东西。

在下一章中,我们将讨论测试,并使用自动化测试工具为我们的端点和代码编写测试用例。

第八章:API 测试-守卫在大门上

在上一章中,我们解决了我们识别出的问题,并完成了 RESTful web 服务中剩下的事情。然而,为了确保质量,我们需要测试我们的端点,手动测试是不够的。在现实世界的项目中,我们无法重复测试每个端点,因为在现实世界中有更多的端点。因此,我们转向自动化测试。我们编写测试用例并以自动化的方式执行它们。事实上,首先编写测试用例,运行它们,然后编写代码来满足该测试的要求更有意义。这种开发方法称为 TDD(测试驱动开发)。

TDD 是好的,并确保我们按照我们的测试用例进行工作。然而,在这本书中,我们没有使用 TDD,因为有很多东西要理解,我们不想同时包括一件事。所以现在,当我们完成了概念、理解和在 Lumen 中编写 RESTful web 服务(对我们许多人来说也是新的)时,现在我们可以做这个缺失的事情,也就是测试。TDD 并非必不可少,但测试是。如果我们迄今为止没有为了理解其他东西而编写测试,那么现在我们应该这样做。以下是本章将涵盖的主题:

  • 自动化 API 测试的需求

  • 测试类型:

  • 单元测试

  • 集成测试

  • 功能测试

  • 验收测试

  • 我们将编写什么类型的测试?

  • 测试框架:

  • 介绍 CodeCeption

  • 设置和配置

  • 编写 API 测试

  • 总结和更多资源

自动化测试的需求

正如我们之前讨论过的,在现实世界中,我们无法在每个主要功能或更改后重复测试每个端点。我们可以尝试,但我们是人类,我们可能会错过。更大的问题是,我们有时可能会认为我们已经测试过了,但却错过了,因为没有记录我们测试过什么,我们无法知道。如果我们有一个单独的质量保证团队或人员,他们很可能会测试并记录下来。然而,在 RESTful web 服务的情况下,这将占用更多的时间,或者可能的情况是 QA 人员将作为一个整体测试最终产品,而不是 RESTful web 服务。

就像 RESTful web 服务作为产品的一个组件或一个方面一样,RESTful web 服务还有更多低级组件。不仅仅是端点,而是这些端点依赖于更低级的代码。因此,为了使我们的调试更容易,我们也为这些低级组件编写测试。此外,这样我们可以确保这些低级组件运行良好,并且按照其意图进行操作。在出现任何问题的情况下,我们可以运行测试,并确切地知道哪些地方出了问题。

尽管一开始编写测试需要时间,但从长远来看是有好处的。首先,它节省了在每次更改后重复测试相同端点的时间。然后,在重构某些东西时,它在很大程度上有所帮助。它让我们知道涟漪效应在哪里,以及由于我们的重构受到了什么影响。尽管一开始编写测试需要时间,但如果我们打算做一些长期存在的东西,那么它是值得的。事实上,软件开发成本比维护成本要低。这是因为它只会开发一次,但要维护和做更改,将会消耗更多的时间。因此,如果我们编写了自动化测试,它将确保一切都按照要求正常工作,因为维护代码的人很可能不是第一次编写代码的人。

没有一种测试可以提供所有这些好处,但有不同类型的测试,每种测试都有其自己的好处。既然我们知道了自动化测试和编写测试用例的重要性,让我们了解一下不同类型的测试。

测试类型

在不同的上下文中有不同类型的测试。在我们的情况下,我们将讨论四种主要类型的自动化测试。这些是不同类型的测试,基于我们测试的方式和内容:

  • 单元测试

  • 集成测试

  • 功能测试

  • 验收测试

单元测试

在单元测试中,我们分别测试不同的单元。所谓的单元,指的是非常小的独立组件。显然,组件彼此依赖,但我们考虑的是一个非常小的单元。在我们的情况下,这个小单元是类。这个类是一个应该具有单一职责的单元,它应该与其他类或组件抽象,并且依赖于最少数量的其他类或组件。无论如何,我们可以说在单元测试中,我们通过创建该类的对象来测试类,而不管它是否满足所需的行为。

一个重要的事情要理解的是,在单元测试期间,我们的测试不应该触及除了测试类/代码之外的代码。如果在单元测试期间,这段代码与其他对象或外部内容进行交互,我们应该模拟这些对象,而不是与实际对象进行交互,以便其他对象的方法的结果不会影响我们正在测试的单元/类的结果。你可能想知道我们所说的模拟是什么意思?模拟意味着提供一个虚假对象,并根据所需的行为设置它。

例如,如果我们正在使用User类进行测试,并且它依赖于Role类,那么Role类中的问题不应该导致User类的测试失败。为了实现这一点,我们将模拟Role对象并将其注入User类对象中,然后为User类使用的Role类的方法设置固定的返回值。因此,接下来,它将实际调用Role类,并且不会依赖于它。

单元测试的好处:

  • 它将让我们知道一个类是否没有达到其意图。一段时间后,当项目处于维护阶段时,另一个开发人员将能够理解这个类的意图。它的方法的意图是什么。因此,它将像是由知道为什么编写了该类的开发人员编写的类的手册。

  • 另外,正如我们刚刚讨论的,我们应该模拟对象,测试类依赖的对象,我们应该能够将模拟对象注入到测试类的对象中。如果我们能够做到这一点,并且能够在不调用外部对象的情况下进行管理,那么我们才能称我们的代码为可测试代码。如果我们的代码不可测试,那么我们就无法为其编写单元测试。因此,单元测试帮助我们使我们的代码可测试,这实际上是更松散耦合的代码。因此,具有可测试代码是一种优势(因为它是松散耦合的,更易于维护),即使我们不编写测试。

  • 如果我们遇到任何问题,它将让我们调试出问题所在。

  • 由于单元测试不与外部对象交互,因此它们比其他一些测试类型更快。

编写单元测试的开发人员被认为是更好的开发人员,因为带有测试的代码被认为是更干净的代码,因为开发人员已经确保了单元级组件不是紧密耦合的。单元测试可以作为类提供的手册以及如何使用它。

验收测试

验收测试是单元测试的完全相反。单元测试是在最低级别进行的,而验收测试是在最高级别进行的。验收测试是最终用户将如何看待产品以及最终用户将如何与产品进行交互的方式。在网站的情况下,在验收测试中,我们编写测试以从外部访问 URL。测试工具模拟浏览器或外部客户端来访问 URL。事实上,一些测试工具还提供使用 Web 浏览器(如 Chrome 或 Firefox)的选项。

大多数情况下,这些测试是由 QA 团队编写的。这是因为他们是确保系统对最终用户正常工作的人,正如预期的那样。此外,这些测试的执行速度很慢。对于用户界面,有时需要测试很多细节,因此需要一个单独的 QA 团队来进行此类测试。但这只是一个常见的做法,因此根据情况可能会有例外。

验收测试的好处:

  • 验收测试让您看到最终用户如何从外部看到和与您的软件交互

  • 它还可以让您捕捉将在任何特定的 Web 浏览器中发生的问题,因为它使用真实的 Web 浏览器来执行测试

  • 由于验收测试是为了从外部执行而编写的,所以无论您要测试哪个系统以及用于编写系统的技术或框架是什么都无关紧要

例如,如果您正在使用 PHP 编写测试用例的工具,那么您也可以将其用于其他语言编写的系统。因此,开发语言是 PHP、Python、Golang 还是.Net 都无关紧要。这是因为验收测试从外部击中系统,而不需要了解系统的任何内部知识。它是这四种测试中唯一一个在不考虑任何内部细节的情况下测试系统的测试类型。

验收测试非常有用,因为它们与您的系统使用真实浏览器进行交互。因此,如果某些内容在特定浏览器中无法正常工作,那么这些问题可以被识别出来。但请记住,使用真实浏览器,这些测试需要时间来执行。如果使用浏览器模拟,速度也会很慢,但仍然比真实浏览器快。请注意,验收测试被认为是这四种测试中最慢和最耗时的。

功能测试

功能测试与验收测试类似;但是,它是从不同的角度。功能测试是关于测试功能需求。它测试功能需求并从系统外部进行测试。但是,它具有内部可见性,并且可以在测试用例中执行系统的一些代码。

与验收测试类似,它击中 URL;然而,即使要击中 URL,它也会执行浏览器或外部客户端将在特定 URL 上执行的代码。但实际上并不是从外部击中 URL。测试实际上并没有击中 URL,它只是模拟了它。这是因为与验收测试不同,我们对最终用户如何与之交互并不感兴趣,而是如果代码从该 URL 执行,我们想要知道响应。

我们更感兴趣的是我们的功能需求是否得到满足,如果没有得到满足,问题出在哪里?

功能测试的好处:

  • 通过功能测试,测试工具可以访问系统,因此显示的错误细节比验收测试更好。

  • 功能测试实际上不会打开浏览器或外部客户端,因此速度更快。

  • 在功能测试中,我们还可以通过测试工具直接执行系统代码,因此在某些情况下,我们可以这样做来节省测试用例编写时间,或者使测试用例执行更快。有许多可用的测试工具。我们将很快使用其中一个名为 CodeCeption。

集成测试

集成测试在某种程度上与单元测试非常相似,因为在这两种测试中,我们通过使它们的对象调用它们的方法来测试类。但它们在测试类的方式上有所不同。在单元测试的情况下,我们不会触及我们要测试的类与之交互的其他对象。但在集成测试中,我们想要看到它们如何一起工作。我们让它们相互交互。

有时,一切都按照单元测试的要求正常运行,但在更高级别的测试(功能测试或验收测试)中却不正常,我们会根据需求通过访问 URL 进行测试。因此,在某些情况下,高级别测试失败,而单元测试通过,为了缩小问题的范围,集成测试非常有用。因此,可以认为集成测试处于功能测试和单元测试之间。功能测试是关于测试功能需求,而单元测试是关于测试单个单元。因此,集成测试处于两者之间,它测试这些单个单元如何一起工作;然而,它通过测试代码中的小组件进行测试,同时也让它们相互交互。

一些开发人员编写集成测试并将其称为单元测试。实际上,集成测试只是让测试中的代码与其他对象进行交互,因此它可以测试这些类在与系统组件交互时的工作方式。因此,如果测试中的代码非常简单且需要与系统交互进行测试,有些人会编写集成测试。然而,并不一定只编写一个单元测试或集成测试,如果有时间,可以同时编写两者。

集成测试的好处:

  • 当单元测试不足以捕捉错误,高级别测试不断告诉您有问题时,集成测试非常有用,可以帮助调试问题。

  • 由于集成测试的性质,在重构时非常有帮助,可以告诉您新更改受到了什么影响。

我们将进行哪种类型的测试?

每种类型的测试都有其重要性,尤其是单元测试。然而,我们主要进行 API 测试,将测试我们的 RESTful Web 服务端点。这并不意味着单元测试不重要,只是我们在本章主要关注 API 测试,因为本书侧重于 RESTful Web 服务。实际上,测试是一个大课题,你将能够看到关于测试和 TDD 的完整书籍。

如今,“BDD”(行为驱动开发)是一个更流行的术语。它与 TDD 并没有完全不同。它只是陈述测试用例的一种不同方式。实际上,在 BDD 中,没有测试用例,而是规范。它们具有相同的目的,但 BDD 以更友好的方式解决问题,即通过陈述规范并实现它们,这就是 TDD 的工作方式。因此,TDD 和 BDD 并没有不同,只是解决同一个问题的不同方式。

我们可以以功能测试和验收测试的方式进行 API 测试。然而,将 API 测试编写为功能测试更有意义。因为功能测试将更快,并且对我们的代码库有洞察力。这也更有意义,因为验收测试是为最终用户而设计的,而最终用户不使用 API。最终用户使用用户界面。

测试框架

就像我们有用于编写软件的框架一样,我们也有用于编写测试用例的框架。由于我们是 PHP 开发人员,我们将使用一个用 PHP 编写的测试框架,或者我们可以在其中用 PHP 编写测试用例,以便我们可以轻松使用它。

首先,无论我们用于应用开发的开发框架是什么,我们都可以在 PHP 中使用不同的测试框架。然而,Laravel 和 Lumen 也带有测试工具。我们也可以使用它们来编写测试用例。实际上,为 Lumen 编写测试用例会更容易,但它将是特定于 Lumen 和 Laravel 的。在这里,我们将使用一个框架,您将能够在 Lumen 和 Laravel 生态系统之外以及任何 PHP 项目中使用它,无论您使用哪个开发框架来编写代码。

PHP 中有许多不同的测试框架,那么我们如何决定使用哪一个?我们将使用一个不太低级的框架,因为我们不打算编写单元测试,而是功能测试,所以我们选择了一个稍微高级的框架。一个著名的单元测试框架是 PHPUnit:phpunit.de/

还有另一个以BDD(行为驱动开发)风格命名的单元测试框架,名为 PHPSpec:www.phpspec.net,如果您想学习或编写单元测试,PHPSpec 也很棒。然而,在这里,我们将使用一个既适用于功能测试又适用于单元测试的框架。尽管我们不写单元测试,但我们希望考虑一个稍后也可以用于单元测试的框架。我选择的框架是 CodeCeption:codeception.com/,因为它在 API 测试方面似乎非常出色。另一个 BDD 风格的选择可能是 Behat:behat.org/en/latest/。这是一个高级测试框架,但如果我们进行验收测试,甚至更好的是如果我们有一个专门的 QA 团队,他们将用 Gherkin 语法(github.com/cucumber/cucumber/wiki/Gherkin)编写许多测试用例,这非常接近自然语言。然而,对于 PHP 开发人员来说,Behat 和 Gherkin 可能有更多的学习曲线,而 CodeCeption 只是简单的 PHP(尽管如果需要,它也可以使用 Gherkin),因此许多读者将是新手编写测试用例,我将保持简单并贴近 PHP。然而,这是我两年前写的关于选择 API 测试框架的详细比较,虽然有些过时,但对于大部分内容仍然有效。如果您感兴趣,可以看一下haafiz.me/programming/api-testing-selecting-testing-framework

CodeCeption 简介

CodeCeption 是用 PHP 编写的,并由 PHPUnit 支持。CodeCeption 声称CodeCeption 使用 PHPUnit 作为运行其测试的后端。因此,任何 PHPUnit 测试都可以添加到 CodeCeption 测试套件中,然后执行

除了验收测试之外的其他测试需要一个具有对测试代码的洞察或连接的测试框架。如果我们使用的是开发框架,那么测试框架应该具有某种针对该框架的模块或插件。CodeCeption 在这方面做得很好。它为不同的框架和 CMS 提供了模块,例如 Symfony、Joomla、Laravel、Lumen、Yii2、WordPress 和 Zend 框架。只是让您知道,这些只是一些框架。CodeCeption 还支持许多其他模块,可以在不同情况下提供帮助。

设置和理解结构

安装 CodeCeption 有不同的方法,但我更喜欢 composer,这是安装不同 PHP 工具的标准方式。所以让我们安装它:

composer require "codeception/codeception" --dev

正如您所看到的,我们正在使用--dev标志,这样它就会将 CodeCeption 添加到composer.json文件中的require-dev块中。因此,在生产环境中,当您运行composer install --no-dev时,它将不会安装在require-dev块中的依赖项。如果有疑惑,请查看与 composer 相关的章节,即第五章,使用 Composer 加载和解决问题,一个进化

安装完成后,我们需要设置它以编写测试用例,并使其成为我们项目的一部分。安装只意味着它现在在vendors目录中,现在我们可以通过 composer 执行 CodeCeption 命令。

要设置,我们需要运行 CodeCeption 引导命令:

composer exec codecept bootstrap

codecept是 CodeCeption 在vendor/bin目录中的可执行文件,所以我们通过 composer 执行它,并给它一个参数来运行bootstrap命令。因此,在执行这个命令之后,一些文件和目录将被添加到你的项目中。

以下是它们的列表:

codeception.yml

tests/_data/
tests/_output/

tests/acceptance/
tests/acceptance.suite.yml
tests/_support/AcceptanceTester.php
tests/_support/Helper/Acceptance.php
tests/_support/_generated/AcceptanceTesterActions.php

tests/functional/
tests/functional.suite.yml
tests/_support/FunctionalTester.php
tests/_support/Helper/Functional.php
tests/_support/_generated/FunctionalTesterActions.php

tests/unit/
tests/unit.suite.yml
tests/_support/UnitTester.php
tests/_support/Helper/Unit.php
tests/_support/_generated/UnitTesterActions.php

如果你看一下提到的文件列表,那么你会注意到我们在根目录下有一个文件,即codeception.yml,其中包含了 CodeCeption 测试的基本配置。它告诉我们关于路径和基本设置。如果你阅读这个文件,你将能够很容易地理解它。如果你不理解某些东西,现在先忽略它。除了这个文件,其他的都在tests/目录下。这些对我们来说更重要。

首先,在tests/目录下有两个空目录。_output包含测试用例的输出,如果失败的话,_data包含数据库查询,如果我们想在运行测试之前和之后设置一个默认的数据库。

除此之外,你可以看到有三组文件,这些文件具有相似的文件,只是测试类型不同。在 CodeCeption 中,我们知道这些组是测试套件。所以,默认情况下,CodeCeption 带有三个测试套件。接受、功能和单元套件。所有这三个套件都包含四个文件和一个空目录。所以,让我们来看看每个文件和目录的目的。

tests/{suite-name}/

在这里,{suite-name}将被套件的实际名称替换;比如说单元套件,它将是tests/unit/

无论如何,这个目录将用于保存我们将要编写的测试用例。

tests/{suite-name}.suite.yml

这个文件是特定套件的配置文件。它包含了这个特定套件的ActorName。Actor 实际上就是具有特定设置和能力的人。根据 actor 的设置,它以不同的方式运行测试。设置包括模块的配置和启用模块。

tests/_support/_generated/{suite-name}TesterActions.php

这个文件是基于tests/{suite-name}.suite.yml中的设置自动生成的文件。

tests/_support/{suite-name}Tester.php

这个文件使用了_generated目录中生成的文件,开发人员可以根据需要进行更多的自定义。然而,通常情况下是不需要的。

tests/_support/Helper/{suite-name}.php

套件中的这个文件是辅助文件。在这里,你可以在类中添加更多的方法,并在你的测试用例中使用它。就像其他代码有库和辅助程序一样,你的测试用例代码也可以在套件的辅助类中有辅助方法。

请注意,如果你需要不同的辅助类,你可以添加更多的文件。

创建 API 套件

在我们的情况下,我们需要单元测试和 API 测试。虽然我们可以使用功能测试套件进行 API 测试,因为这些测试处于功能测试级别,但为了清晰和理解起见,我们可以通过这个命令创建一个单独的 API 套件:

composer exec codecept g:suite api

在这个命令中,ggenerate的缩写,它将生成一个 API 套件。api只是另一个套件的名称,这个命令已经创建了这些文件和目录:

tests/api/
tests/api.suite.yml
tests/_support/ApiTester.php
tests/_support/Helper/Api.php
tests/_support/_generated/ApiTesterActions.php

api.suite.yml文件将具有基本设置,但没有太多细节。这是因为api.suite.yml文件将具有基本设置,但没有太多细节。这是因为这里的api只是一个名称。你甚至可以说:

composer exec codecept g:suite abc

它应该已经创建了abc套件,具有相同的文件结构和设置。所以,我们的 API 套件只是另一个我们为了清晰和理解而单独创建的测试套件。

配置 API 套件

API 需要 REST 客户端来获取 RESTful 网络服务端点。除此之外,它还依赖于 Lumen。我们说 Lumen,因为它将与我们的代码集成,我们正在编写功能级别的测试而不是接受测试。因此,我们的测试框架应该对 Lumen 有洞察力和交互。我们的配置还需要什么?我们需要设置测试的.env文件。因此,这就是我们的配置文件的样子:

class_name: ApiTester
modules:
 enabled:  - REST:
 url: /api/v1
            depends: Lumen
        - \Helper\Api
    config:
  - Lumen:
 environment_file: .env.testing

在继续之前,请注意,我们在config/Lumen下指定了一个不同的环境文件选项,即environment_file: .env.testing。因此,如果我们在这里指定了.env.testing,那么我们应该有一个.env.testing。没什么大不了的,只需复制并粘贴您的.env文件。从命令行执行以下操作:

cp .env .env.testing

更改数据库凭据,使其指向不同的数据库,该数据库具有您当前数据库架构和数据的副本,基于这些数据,您想编写测试用例。尽管在 Laravel/Lumen 中进行测试时与数据库相关的内容将会回滚,并且不会影响我们的实际数据库,因此在开发中使用相同的数据库也是可以的。但是,在暂存环境中不建议,事实上是禁止使用相同的数据库进行测试;因此,最好从一开始就保持不同的数据库和配置。

我们不会在生产环境中运行测试。我们甚至不会在生产环境中安装与测试相关的工具,正如您所看到的,我们使用--dev标志安装了 CodeCeption。因此,当我们的代码在生产环境中,并且我们想要部署一个新功能时,我们的测试用例会在不同的服务器上运行,然后将代码部署到生产服务器上。有几种CI持续集成)工具可用于此。

编写测试用例

现在,是时候编写测试用例了。首先要了解的是,我们如何决定应该测试什么。我们应该先测试每个端点,然后再测试每个类吗?

首先要理解的是,我们应该只测试我们自己编写的代码。我所说的我们,是指我们团队的某个人。我们不打算测试第三方代码、框架代码或包代码。此外,我们也不想测试每个类和每个方法。在理想情况下,我们可以测试每个细微功能的细节,但这也有其缺点。首先,在现实世界中,我们没有时间这样做。我们打算测试大部分但不是全部部分。另一个原因是,我们编写的所有测试也是一种负担。随着时间的推移,我们还需要维护这些测试。因此,我们只对有意义的部分进行单元测试;只有在实际上执行一些复杂操作的地方才进行测试。如果您有一个函数,它的功能就是调用另一个函数并返回结果,那么我认为这样的代码片段不应该有自己的测试。

另一件事是,如果我们既要进行单元测试又要进行 API 测试,那么我们应该从哪里开始编写测试呢?我们应该先为所有端点编写测试,然后再为所有类编写测试,还是相反,先测试所有类,然后再测试所有端点?我们该如何做呢?我们显然打算测试我们的端点。我们也打算测试这些端点下的代码。这是不同的人可以以不同方式做的事情,但我和许多其他人,我见过的人,都是同时编写 API 测试和单元测试。我更喜欢编写 API 测试并继续为一个资源编写测试。之后,我们将转向控制器的单元测试。在我们的情况下,模型中除了从 Eloquent 或关系继承的内容之外,没有太多东西。在对资源进行 API 测试时,如果我们需要更多细节来修复错误,那么我们可以开始为该类编写单元测试。但这并没有硬性规定。这只是一种偏好问题。

针对 post 资源的 API 测试

我们可以以结构化方法编写测试用例,也可以以类的方式编写。两种方式都可以,我建议使用类,这样您可以在某个时候利用面向对象的概念。因此,让我们为此创建一个文件:

composer exec codecept generate:cest api CreatePost

这将在tests/api/CreatePostCest.php中创建一个类,内容类似于:

<?php   class CreatePostCest {
  public function _before(ApiTester $I)
 { }    public function _after(ApiTester $I)
 { }    // tests
  public function tryToTest(ApiTester $I)
 {  } } 

_before()方法是为了让您可以在测试用例之前编写任何代码,而_after()方法是为了在测试用例之后执行。接下来的方法只是一个示例,我们将对其进行修改。

在这里,我们将编写两种类型的测试。一种是在尝试未登录时创建一个帖子,这应该返回未经授权的错误,另一种是在登录后创建一个帖子,这应该是成功的。

在写之前,让我们设置我们的数据库工厂,以便为帖子获取随机内容,这样我们在测试期间可以使用它。让我们修改app/database/factories/ModelFactory.php,使其看起来像这样:

<?php   /* |-------------------------------------------------------------------------- | Model Factories |-------------------------------------------------------------------------- | | Here you may define all of your model factories. Model factories give | you a convenient way to create models for testing and seeding your | database. Just tell the factory how a default model should look. | */   $factory->define(App\User::class, function (Faker\Generator $faker) {
  return [
  'name' => $faker->name,
  'email' => $faker->email,
 ]; });   $factory->define(App\Post::class, function (Faker\Generator $faker) {
    return [
        'title' => $faker->name,
        'content' => $faker->text(),
        'status' => $faker->randomElement(['draft', 'published']),
    ]; **});** 

我刚刚添加了粗体标记的代码。所以,我们告诉它返回一个基于Faker\Generator类对象生成的参数数组,标题、内容和状态。所以,根据我们在这里定义的不同字段,我们可以通过ModelFactory为帖子用户生成随机内容,这样数据将是随机和动态的,而不是静态内容。在测试期间,最好在测试用例中使用随机数据来进行测试。

好的,现在让我们在CreatePostCest.php文件中编写我们的测试用例,这是我们将要编写的函数:

// tests if it let unauthorized user to create post public function tryToCreatePostWithoutLogin(ApiTester $I) {
  //This will be in console like a comment but effect nothing
  $I->wantTo("Send sending Post Data to create Post to test if it let it created without login?");    //get random data generated through ModelFactory
  $postData = factory(App\Post::class, 1)->make();    //Send Post data through Post method
  $I->sendPost("/posts", $postData);    //This one will also be like a comment in console
  $I->expect("To receive a unauthorized error resposne");    //Response code of unauthorized request should be 401
  $I->seeResponseCodeIs(401);  }

正如你所看到的,注释已经解释了一切,所以除了我们使用sendPost()方法发送一个 Post 请求之外,没有必要明确说明任何事情,我们也可以说sendGet()sendPut()来使用不同的 HTTP 方法,等等。所以,现在我们需要运行这个测试。

我们可以通过以下方式运行它:

composer exec codecept run api

它不会在控制台上给我们清晰的输出。我们可以添加-v-vv-vvv来使输出更加详细,但在这个命令中,它会使 composer exec 相关的信息变得越来越详细。所以让我们这样执行:

vendor/bin/codecept run api

随意添加-v,最多三次,以获得更加详细的输出:

vendor/bin/codecept run api -vv

我们可以为路径vendor/bin/codecept创建一个别名,在控制台的会话中,我们可以使用这样的简写:

alias codecept=vendor/bin/codecept
codecept run api -vv

所以,执行它,你会在控制台中看到很多细节。根据你的需要使用-v-vv-vvv。现在让我们这样执行它:

codecept run api -v

在我们的情况下,我们的第一个测试应该已经通过了。现在,我们应该编写我们的第二个测试用例,也就是在登录后创建一个帖子。这涉及到更多我们需要理解的东西。所以让我们先看一下这个测试用例的代码,然后再进行审查:

// tests if it let unauthorized user to create post public function tryToCreatePostAfterLogin(ApiTester $I) {
  //This will be in console like a comment but effect nothing
 $I->wantTo("Sending Post Data to create Post after login"**);**
 $user = App\User::first();
    $token = JWTAuth::fromUser($user**);**    //get random data generated through ModelFactory
  $postData = factory(App\Post::class, 1)->make()->first()->toArray();    //Send Post data through Post method
 $I->amBearerAuthenticated($token);   $I->sendPost("/posts", $postData);    //This one will also be like a comment in console
  $I->expect("To receive a unauthorized error resposne");    //Response code of unauthorized request should be 401
 $I->seeResponseCodeIs(200**);**   }

如果你看这个测试用例,你会发现它和之前的测试用例代码非常相似,除了一些语句。所以,我已经用粗体标出了这些语句。第一件事是,由于测试用例不同,所以我们的wantTo()参数也不同。

然后,我们从数据库中获取第一个用户,并基于用户对象生成一个令牌。在这里,我们调用我们的应用程序代码,因为我们使用了在api.suite.yml文件中配置的 Lumen 模块。然后,我们使用 CodeCeption 的$I->amBearerAuthenticated($token)方法与我们生成的$token一起。这意味着我们发送了一个有效的令牌,所以服务器将把它视为已登录用户。这次响应代码将是 200,所以通过$I->seeResponseCodeIs(200),我们告诉它应该有 200 的响应代码,否则测试应该失败。这段代码就是这样做的。

实际上,还可以有很多类似的测试用例,比如测试如果在请求不完整的情况下返回400 Bad Request响应。

运行测试后,你会在控制台的最后看到这个:

OK (2 tests, 2 assertions)

这表明我们断言了两件事。断言意味着陈述我们希望为真的期望或事实。简单来说,这是我们在响应中检查的内容。就像现在我们只测试响应代码一样。但在现实世界中,我们会用更多的测试用例来测试整个响应。CodeCeption 也为我们提供了测试这些内容的方法。所以,让我们用更多的断言修改我们当前的两个测试用例。以下是我们将要测试的两个内容:

  • 将断言我们得到了响应中的 JSON。

  • 将断言我们根据我们的输入得到了正确的响应数据。

所以这是我们的代码:

<?php   use Tymon\JWTAuth\Facades\JWTAuth;   class CreatePostCest {
  public function _before(ApiTester $I)
 { }    public function _after(ApiTester $I)
 { }    // tests if it let unauthorized user to create post
  public function tryToCreatePostWithoutLogin(ApiTester $I)
 {  //This will be in console like a comment but effect nothing
  $I->wantTo("Send sending Post Data to create Post to test if it let it created without login?");    //get random data generated through ModelFactory
  $postData = factory(App\Post::class, 1)->make()->first()->toArray();    //Send Post data through Post method
  $I->sendPost("/posts", $postData);    //This one will also be like a comment in console
  $I->expect("To receive a unauthorized error resposne");    //Response code of unauthorized request should be 401
  $I->seeResponseCodeIs(401);
     // Response should be in JSON format
  $I->seeResponseIsJson();
 }    // tests if it let unauthorized user to create post
  public function tryToCreatePostAfterLogin(ApiTester $I)
 {  //This will be in console like a comment but effect nothing
  $I->wantTo("Sending Post Data to create Post after login");    $user = App\User::first();
  $token = JWTAuth::fromUser($user);    //get random data generated through ModelFactory
  $postData = factory(App\Post::class, 1)->make()->first()->toArray();    //Send Post data through Post method
  $I->amBearerAuthenticated($token);
  $I->sendPost("/posts", $postData);    //This one will also be like a comment in console
  $I->expect("To receive a 200 response");    //Response code of unauthorized request should be 200
  $I->seeResponseCodeIs(200);
        // Response should be in JSON format
 $I->seeResponseIsJson();        //Response should contain data that matches with request $I->seeResponseContainsJson($postData**);**  } } 

正如你在上述代码片段中看到的,我们添加了三个额外的断言,你可以看到它是多么简单。实际上,在我们不知道对象可能具有的值时,检查响应中的内容可能会有些棘手。例如,如果我们想要请求并查看帖子列表,那么当我们不知道值时,我们该如何断言呢?在这种情况下,你可以使用基于 JSON 路径的断言,文档在这里:codeception.com/docs/modules/REST#seeResponseJsonMatchesJsonPath

你会像这样使用它:

$I->seeResponseJsonMatchesJsonPath('$.data[*].title');

这也是你在响应中看到的,但甚至有一个方法可以测试该记录是否现在也存在于数据库中。你应该自己尝试一下。你可以在这里找到它的文档:codeception.com/docs/modules/Db#seeInDatabase

其他测试用例

还有很多与其他帖子操作(端点)相关的测试用例。然而,编写测试用例的方式将保持不变。所以,我会跳过这部分,这样你就可以自己编写这些测试用例。不过,作为提示,以下是一些你应该练习编写的测试用例:

tryToDeletePostWithWrongId()  and it should return 404 response.

tryToDeletePostWithCorrectId() and it should return 200 with JSON we set there in PostController delete() method.

tryToDeletePostWithIdBelongsToOtherUserPost() it should return 403 Forbidden response because a user is only allowed to delete his/her own Post.

tryToDeletePostWithoutLogin() it should return 401 Unauthenticated because only a logged in user is allowed to delete his/her Post.

然后关于更新帖子:

tryToUpdatePostWithWrongId()  and it should return 404 response.

tryToUpdatePostWithCorrectId() and it should return 200 with JSON having that Post data.

tryToUpdatePostWithIdBelongsToOtherUserPost() it should return 403 Forbidden response because a user is only allowed to update his/her own Post.
tryToUpdatePostWithoutLogin() and it should return 401 unauthorized.

然后关于帖子列表:

tryToListPosts() and it should return 200 response code with Post list having data and meta indices in JSON.

然后关于获取单个帖子:

tryToSeePostWithId() and it should return 200 response code with Post data in JSON.

tryToSeePostWithInvalidId() and it should return 404 Not Found error.

因此,我强烈建议你编写这些测试用例。如果你需要更多示例,或者想要查看与身份验证相关的端点测试示例,那么你可以在这里找到一些示例,以便更好地理解:github.com/Haafiz/REST-API-for-basic-RPG/tree/master/tests/api

有关 CodeCeption 的更多信息,请参阅 CodeCeption 文档:codeception.com/

总结

在本章中,我们学习了测试类型,自动化测试的重要性,并为我们的 RESTful Web 服务端点编写了 API 测试。我再次想说的一件事是,我们只编写了 API 测试,以保持专注在我们的主题上,但单元测试同样重要。然而,测试是一个庞大的主题,单元测试有其自身的复杂性,所以无法在这一章中讨论。

更多资源

如果你想了解更多关于 PHP 自动化测试的信息,那么这里有一些重要的资源。

《Test Driven Laravel》(Adam Wathan 的视频课程)adamwathan.me/test-driven-Laravel/,然而这主要是关注于 Laravel 的。但是,这也会教给你一些重要的东西。

同样地,Jeffrey Way 的旧书《Laravel Testing Decoded》可以在leanpub.com/Laravel-testing-decoded找到。

再次强调,这是一本专门针对 Laravel 的书,但在一般情况下也会教给你很多东西。Jeffrey Way 即将推出的新书是关于 PHP 测试的,名为《Testing PHP》:leanpub.com/testingphp

前面提到的关于 PHP 的书还没有完成,所以你可以从 Jeffrey Way 的精彩的视频测试中学习:laracasts.com/skills/testing。事实上,Laracasts 不仅适用于测试,还适用于全面学习 PHP 和 Laravel。

无论你选择哪个来源,重要的是你要练习。这对于开发和测试都是如此。事实上,如果你以前没有进行过测试,那么练习测试就更加重要。起初,你可能会感到有些不知所措,但这是值得的。

第九章:微服务

尽管我们在本书中讨论了不同的方面,但我们所做的是为一个简单的博客创建了一个 RESTful web 服务。我们选择了这个例子,以便在业务逻辑方面保持简单,这样我们可以更详细地专注于我们的实际主题。这是有帮助的,但在现实世界中,事情并不那么简单和小。有着不同部分的大型系统很难维护。这些部分也很难调试和扩展。扩展性与仅仅维护和优化以获得更好的性能是不同的。在扩展性方面,代码和部署环境的优化都很重要。可扩展性、可维护性和性能一直是我们面临的挑战。

为了解决这个问题,我们有一种被称为微服务的架构风格。因此,在本章中,我们将讨论这个问题。微服务并不是必须使用的东西。然而,它们解决了我们在为更大的系统创建 RESTful web 服务时经常面临的一些挑战。因此,我们将看到微服务如何解决这些问题,以及微服务架构带来的挑战。

以下是本章将讨论的主题:

  • 介绍微服务

  • 基于微服务架构的动机

  • 它与 SOA(面向服务的架构)有何不同

  • 团队结构

  • 微服务的挑战

  • 微服务实现

介绍微服务

首先让我们定义微服务架构,然后深入了解微服务的细节。微服务架构成为一个热门术语,但并没有任何正式的定义。事实上,迄今为止,关于其属性或定义,还没有官方共识。然而,不同的人尝试过定义它。我在 Martin Fowler 的博客上找到了一个定义,非常令人信服。他和 James Lewis 这样定义它:

微服务架构风格是一种将单个应用程序开发为一组小服务的方法,每个服务在自己的进程中运行,并使用轻量级机制进行通信,通常是 HTTP 资源 API。这些服务围绕业务能力构建,并且可以通过完全自动化的部署机制独立部署。这些服务的集中管理最少,可能使用不同的编程语言和不同的数据存储技术。-- James Lewis 和 Martin Fowler

这似乎很正式,所以让我们深入了解这个定义,并尝试理解微服务架构。

首先,你应该知道,在我们为博客创建的 RESTful web 服务的例子中,是一个单体式的 web 服务。这意味着一切都在同一个 web 服务中。一切都在一起,因此需要一起部署为一个代码库。我们也可以对更大的应用程序使用相同的单体式方法,但该应用程序将变得越来越复杂,可扩展性将会减弱。

与此相反,微服务由许多小服务组成。每个小服务被称为微服务,或者我们可以简单地称之为服务。这些服务实现了一个应用程序的目的,但它们是独立的,非常松散耦合的。因此,每个微服务都有一个单独的代码库和一个单独的数据库或存储。由于每个微服务都是独立的,即使我们想要在同一台服务器上或不同的服务器上部署,它也可以独立部署。这意味着所有服务可能是相同的语言或框架,也可能不是。如果一个服务是 PHP,另一个可能是 Node.js,另一个可能是 Python。

如何将应用程序划分为微服务?

因此,问题是,“如果我们有一个庞大的应用程序,那么我们如何决定如何将其分成不同的微服务?”在理解如何将一个大系统分成微服务时,我们将考虑不同的因素。这些因素基于马丁·福勒所谓的“微服务的特征”。您可以在martinfowler.com/articles/microservices.html上查看马丁·福勒关于微服务特征的完整文章。

因此,在将一个大系统分成小的微服务时,需要考虑以下因素:

  • 每个微服务应该独立于其他微服务。如果不是完全独立的(因为这些服务是一个应用程序的一部分,所以它们可能会相互交互),那么依赖关系应该是最小的。

  • 我们将应用程序分成不同的组件。所谓组件,是指一个可以独立替换和升级的软件单元。这意味着替换或升级一个组件不应该对应用程序产生任何(或者最小的)影响。一个微服务将基于这样一个单一组件。

  • 一个服务应该有一个单一的责任。

  • 将应用程序或系统分成几个微系统,可以从业务需求入手。根据业务能力制作组件是一个好主意。事实上,我们的团队应该根据业务能力而不是技术来划分。

  • 同时,确保服务不要过于细粒度也很重要。过于细粒度的服务可能会导致开发工作量增加,同时由于相互交互的事物太多而导致性能不佳,因为它们实际上是相互依赖的。

在理想情况下,这些服务总是彼此独立的。然而,这并不总是可能的。有时,一个服务需要另一个服务的某些东西,有时,两个或更多服务有一些共同的逻辑。因此,依赖服务主要通过 HTTP 调用相互交互,共同的逻辑可以在不同服务之间的共享代码库中。然而,这仅在这些服务中使用相同技术时才可能。实际上,这意味着两个或更多服务依赖于共同的代码库。因此,根据前述定义,从理论上讲,这违反了微服务架构,但由于没有正式的理论或官方规范,所以我们考虑任何在现实世界中发生的事情。

对微服务的动机

有几个动机支持微服务。然而,我想要开始的是,当我们将其分成具有单一责任的组件时,我们遵守SRP单一责任原则)。单一责任实际上是面向对象原则中的前五个之一,也被称为 SOLID(en.wikipedia.org/wiki/SOLID_(object-oriented_design))。这个单一责任原则,无论是在架构层面还是低层面,都使事情变得简单和容易。在微服务的情况下,它将不同的组件分离开来。因此,修改一个组件的原因将与一个单一功能相关。系统的其他组件和功能将像以前一样工作。这就是微服务作为独立的组件和功能使它们更容易修改而不影响其他组件的方式。

以下是必须分开微服务的其他原因。

维护和调试

告诉大家模块化的代码总是更容易维护并且可以轻松调试,这并不是什么新鲜事。您可以轻松调试它,而且还有什么比不仅是模块化而且还部署为独立模块的组件更模块化的呢?因此,我们从微服务中获得了许多优势,这些优势是我们从模块化代码中获得的。

然而,有一点需要理解。如果我们从一开始就使用微服务架构,应用程序将是模块化的,因为我们正在分开开发服务。然而,如果我们没有从一开始就使用微服务,而是后来想要将其转换为微服务,那么首先,我们需要有模块化的代码,然后我们才能使用微服务架构,因为如果我们没有模块和松散耦合的代码,我们就无法将它们拆分成独立的组件。

总之,微服务的动机很简单,我们可以轻松地调试模块化的代码和组件。在维护的情况下,如果代码在独立的组件中,并且其他服务得到了它们所需的东西,而不用担心修改组件的内部逻辑,那么就不会出现连锁反应。

实际上,这还不是全部;在维护阶段一个非常重要的因素是生产力。在更大的代码库中,随着时间的推移,生产力可能会降低,因为开发人员需要担心整个应用程序。然而,在微服务中,一个团队中的开发人员进行的特定更改不需要担心整个应用程序,而只需要关注那个特定服务内的代码,因为对于那个特定的更改和正在处理它的开发人员来说,这一个微服务就是整个应用程序,其责任远远小于整个应用程序。因此,在维护期间,微服务的生产力可能比单片应用程序要好得多。

可扩展性

当系统扩展并且您想要为更多客户提供良好的性能时,经过一段时间,当您也进行了优化后,您需要更好、更强大的服务器。您可以通过向服务器添加更多资源来使服务器更强大。这被称为垂直扩展。垂直扩展有其局限性。毕竟,这是一个服务器。如果我们想要更多的可扩展性呢?实际上,还有另一种扩展方式,即水平扩展。在水平扩展中,您添加更多的小型服务器或服务器实例,而不是将所有资源添加到一个服务器中。在这种情况下,一个单片应用程序将如何部署在多个服务器上?我们可能需要在多个服务器上部署完整的应用程序,然后通过负载均衡器来管理通过多个服务器的流量。

然而,将整个应用程序部署在多个服务器上并不划算。如果我们可以让应用程序的一部分从一个服务器提供,另一部分从另一个服务器提供呢?这怎么可能?我们只有一个应用程序。这就是微服务架构的优势所在。它的好处不仅仅是可扩展性。其关键好处之一是系统中松散耦合的组件。

技术多样性

正如我们所见,在微服务中,每个代码库都与其他代码库分开。因此,不同的团队可以使用不同的技术和不同的存储来开发不同的服务。事实上,这些团队完全不需要在不同的服务之间使用相同的技术,除非它们提供的其他服务需要相互交互。然而,如果我们想要使用共享代码的选项来避免在不同技术中重复编写相同的逻辑,那么为了拥有共享的代码库,我们可能需要使用相同的技术。

弹性

在微服务中,弹性也是其中一个关键的好处。由于每个服务都是一个独立的组件,如果系统的一个组件因某种原因失败,那么问题可以与系统的其余部分隔离开来。

然而,我们需要确保系统在发生故障时能够正确降级。如果一个服务出现故障,我们可以尝试将其最小化,但可能会再次出现故障。然而,为了最小化其影响,我们应该小心处理,以便最小化其对其他服务和我们应用程序用户的影响。

可替换性

如果要替换系统的一部分,那么在单片架构中并不那么简单,因为一切都在同一个代码库中。然而,在微服务中,更容易替换系统的一个组件,因为你所需要做的就是有另一个服务并用它替换现有的服务。显然,你仍然需要有一个替代服务,但不像在同一个代码库中用其他代码替换整个组件那样。

并行化

通常,客户希望他们的软件能够早期开发并尽快上市,以便他们可以测试他们的想法或占领更多市场。因此,他们希望有更多的开发人员并行工作在他们的应用程序上。不幸的是,在单片应用程序中,我们可以进行有限的并行工作。实际上,如果我们有非常模块化的代码,我们也可以在单片应用程序中进行并行工作。然而,它仍然不能像基于微服务的应用程序那样独立和模块化。

每个服务都是独立开发和部署的。虽然这些服务彼此通信,但开发可以独立进行,在大多数情况下,我们可以保持几个服务的独立开发。因此,许多开发人员,实际上是开发团队,可以并行工作,这意味着软件可以早期开发。如果多个模块需要解决问题或需要另一个功能,则可以并行进行。

与 SOA 的不同之处

SOA 代表面向服务的架构。从名称上看,这种架构依赖于服务,就像微服务一样。服务定位是计算机软件中的一种服务设计范式。其原则强调关注点的分离(与 SRP 相同)。到目前为止,它似乎与微服务相似。在了解差异之前,我们需要知道什么是 SOA。尽管没有一个清晰的官方定义 SOA。所以让我们从维基百科中获取这个基本定义:

面向服务的架构(SOA)是一种软件设计风格,应用组件通过网络上的通信协议向其他组件提供服务。服务导向架构的基本原则与供应商、产品和技术无关。

如果你看这个定义,你会发现 SOA 与微服务非常相似,但它的定义并不那么简洁和清晰。一个原因可能是 SOA 本身是一个广义的架构。或者我们可以更好地说,SOA 是微服务的广义形式。

如 Oracle 的帖子所述:

“过去十年我们一直在谈论的就是微服务的 SOA。”-- Torsten Winterberg, Oracle ACE Director.

因此,微服务遵循相同的原则,但它更加专业化,专注于拥有多个独立的服务,其中一个服务是完全不同的组件,独立于其他服务存在。

团队结构

根据康威定律:

“设计系统的组织...受限于产生与这些组织的通信结构相同的设计。”

因此,为了基于微服务架构制定设计并获得其好处,我们还需要相应地组织工作的结构化团队。

通常,在单片应用程序中,我们有以下团队:

  • Dev-ops 团队

  • 后端开发团队

  • 数据库管理员团队

  • 移动应用程序开发团队

然而,在分布式架构的情况下,例如微服务(如果我们正在开发电子商务应用程序),我们将有以下团队:

  • 产品目录

  • 库存

  • 订单

  • 优惠券

  • 愿望清单

所有这些团队都将有成员,包括 Dev-ops、后端开发人员、数据库管理员和移动应用开发人员。因此,在微服务的情况下,我们将为每个服务设立一个团队。

团队规模:

没有硬性规定,但建议团队规模应符合杰夫·贝佐斯的“2 披萨规则”:如果一个团队不能靠两块披萨养活,那就太大了。原因是,如果团队变得更大,那么沟通可能会变得糟糕。

微服务的挑战

没有免费的午餐。一切都有其不利之处,或者至少有一些需要应对的挑战。如果我们选择微服务,它也有自己的挑战。因此,让我们来看看它们,并讨论如果有权衡的话,如何将它们最小化。

基础设施维护

尽管你不必每天更新你的基础设施,但它仍然需要维护,需要更多的努力。微服务带来了技术自由,但并非没有任何代价。你必须使用不同的技术来维护不同的服务器实例。这将需要更好的基础设施和有更多技术经验的人。

实际上,你并不总是需要更好的基础设施和对所有这些不同技术都有了解的人。通常,每个负责不同服务的团队都会有自己的基础设施或与 Dev-ops 相关的人员。然而,在这种情况下,你需要更多的人,因为现在,你不再在不同团队之间共享 Dev-ops 或基础设施相关的人员。事实上,这就是微服务团队的组成方式。团队至少不应该有共享资源。否则,你就无法因为独立服务而获得并行工作的优势。

然而,基础设施不仅意味着服务器设置,还包括部署、监控和日志记录。因此,为了达到这个目的,你不能只使用一种技术来解决问题,而牺牲了你的技术选择。然而,限制你的技术选择也可以让 Dev-ops 变得更容易一些。

另一件事是你需要在持续集成服务器上进行自动部署。它运行你的测试用例,然后,如果一切顺利,就会部署到你的实际服务器上。为此,你需要有 Dev-ops 人员编写脚本来自动化你的部署。有几种方法可以做到这一点。

性能

实际上,微服务可以更快地运行的原因是,客户端使用了一个完全独立的微服务。一个明显的原因是,一个请求在一个小的微服务中需要经过的步骤比在一个大型单体应用中要少。

然而,这是一个理想情况,不是所有的微服务都完全独立于彼此。它们相互作用并且相互依赖。因此,如果一个服务需要从另一个服务获取某些东西,它很可能需要进行网络调用,而网络调用是昂贵的。这会导致性能问题。然而,如果服务以最小的依赖方式创建,这种情况可以最小化。如果依赖不是最小的,那就意味着服务不是独立的,在这种情况下,我们可以合并这样的服务并创建一个独立的服务。

另一个选择可以是共享代码;这段代码将被用于不同的服务之间。如果两个或更多服务使用相同的功能,那么我们可以将其作为不同服务依赖的另一个服务,而是将其作为不同服务代码库的一部分共享代码。我们不会重复自己,会尝试将其制作成不同服务可以使用的模块或包。然而,有些人认为这是不好的做法,因为我们会在不同服务之间共享一些代码,这意味着它不会松散耦合。

调试和故障排除

正如你所看到的,我们说在微服务中调试和维护会更容易。然而,当这些服务之间进行通信并且一个服务的输出影响另一个服务时,这也会成为一个挑战。

当我们有不同的服务时,我们需要一种服务之间相互通信的方式。服务之间的通信有两种方式:通过 HTTP 调用或通过消息。这里,通过消息我们指的是使用某种消息队列,比如 RabbitMQ 等。在消息传递的情况下,如果出现错误或者发生了一些意外情况,那么这可能会非常困难。因为不只有一个服务,每个服务都是基于前一个服务的输出工作的,所以很难知道问题出在哪里。

因此,解决这个问题的一种方法是彻底编写测试。因为如果确保每个服务的测试用例都被编写并测试它们是否正常工作,那么在部署之前就可以发现问题。

然而,情况并非总是如此。这是因为不只有一个服务。许多服务正在交互,有时,实时环境中会出现问题,你希望进行调试和修复。出于这个目的,日志非常重要。然而,再次强调,这是一个分布式环境。那么,我们能做些什么呢?以下是你需要确保在日志中做的一些事情。

日志应该是集中的

你需要在某个集中的地方收集日志。如果你的日志在一个集中的地方,那么查看它们就会更容易,而不是检查每个服务器实例的日志。

这也很重要,因为你应该在实例之外的地方有日志备份。原因是,如果你替换了一个实例,那么你可能希望保留日志的副本以便在调试时使用。这可以是任何地方,包括亚马逊 S3、你的数据库或者磁盘,但你希望它是持久的和可用的。如果你在 AWS 上,你也可以使用他们的监控服务 CloudWatch。

日志应该是可搜索的。

拥有日志是好的。但就像互联网上的很多信息一样,如果你不知道哪个链接对你来说有用,那它实际上并不有用。由于搜索引擎告诉我们哪些页面有更相关的内容,这变得更容易。同样,活动应用程序的日志,特别是当有很多服务的日志在一起时,将不会那么有用。会有很多日志。因此,为了使它们可用,你应该以一种可以搜索和在查看时容易理解的方式存储你的日志。

跟踪请求链

就像用户在网站上从一个页面转到另一个页面一样,用户的客户端发送请求来执行不同的任务。因此,知道用户在这之前发送了哪些请求是一个好主意,因为在某些情况下,之前的请求可能会影响其他请求。因此,为了跟踪这一点,你可以简单地传递一个标识符,第一次期望在所有其他请求中都能找到相同的标识符。

另一个优点是,它不仅会显示流程,而且如果有人要求你解释为什么出现了某个特定的问题,那么对你来说也会更容易。如果标识符在客户端,相关人员可以在错误报告中给你该标识符作为参考,这样你就可以理解要跟踪哪个请求流程。

动态日志级别

通常,对于日志记录,你会使用某种日志框架,典型的日志级别有警告、信息、调试和详细。通常,在生产环境中,会使用信息级别或其他信息,但如果你想要解决一些问题并进行调试,你应该能够动态地更改日志级别。

因此,如果需要的话,你应该能够动态地在运行时设置日志级别。这很重要,因为如果在生产环境中出现问题,你不希望它持续很长时间。

实施

由于本章只是微服务的简介,我们不会深入讨论实现的细节。然而,我们将概述如何在微服务中实现不同的事物。我们已经在本书中讨论了 RESTful Web 服务的实现。然而,微服务还有其他一些部分。因此,我们只会了解在实现这些部分时涉及了什么。

部署

我们将自动化部署。我们将使用持续交付工具。持续交付是一个过程,其中有短周期的频繁交付,并确保软件可以随时可靠地发布。它旨在更快地发布软件,并通过构建、测试和频繁发布软件的方法来最小化风险。

持续交付从源代码控制一直到生产的自动化。有各种工具或流程可以帮助实现持续交付过程。然而,在其中有两个重要的事情:

  • 测试

  • CI(持续集成)

首先,在提交代码之前,开发人员应该在提交 CI 服务器上运行他们的测试(最重要的是单元测试),运行集成测试,并在通过测试时在 CI 服务器上集成。Travis CI 和 Jenkins CI 是流行的 CI 工具。除此之外,Circle CI 也很受欢迎。

在持续集成之后,构建会自动进行并自动部署。由于一图胜千言,为了进一步阐述,我在这里添加了维基百科的这张图片(这张图片来自维基媒体):

通过这个图表,我们将对 CI 有一些了解。有关持续交付的详细信息,您可以阅读维基百科文章en.wikipedia.org/wiki/Continuous_delivery

服务间通信

我们看到服务器之间的通信很重要。服务彼此依赖,有时一个服务的输入是另一个服务的输出,有时一个服务正在使用另一个服务。其中一个重要的事情是这些服务之间的通信。

因此,我们可以将服务间通信分为两种类型:

  1. 同步通信

  2. 异步通信

同步通信

在同步通信中,一个服务与另一个服务通信并等待结果。通常通过简单的 HTTP 调用来完成,使用与最终客户端相同的方法。因此,这些是简单的 HTTP 调用,得到一个响应(通常是 JSON)。一个服务向另一个服务发送 HTTP 请求,等待其响应,并在收到响应后继续。同步通信具有网络开销,并且必须等待响应,但实现简单,有时延迟不是问题。因此,在这种情况下,为了简单起见,我们可以使用同步通信。

异步通信

在异步通信中,一个服务不会等待另一个的响应。它基于发布-订阅模型。它使用消息代理向其他消费者/订阅者的服务发送消息。它使用轻量级消息传递工具,通过这些工具,一个服务向另一个服务发送消息。这样的消息传递工具包括但不限于 RabbitMQ、Apache Kafka 和 Akka。

如果您对了解更多关于微服务之间通信的内容感兴趣,那么howtocookmicroservices.com/communication/上的文章可能会很有趣。

共享库或公共代码

正如我们讨论的,可能有一些代码在不同的服务之间是共通的。它既可以是第三方代码,也可以是由团队为同一个应用程序编写的代码。无论哪种情况,我们显然都希望使用这些共通的代码。为了做到这一点,我们不会在我们的应用程序中复制这些代码,因为这违反了 DRY(不要重复自己)原则。但是,请注意,如果我们使用不同的编程语言/技术,我们就无法使用共通的代码。

所以我们做的是,我们打包那些通用代码或共享库,然后上传到某个地方,在部署时可以从那里获取这个包。在 PHP 的情况下,我们会创建 composer 包并上传到 packagist。然后,在服务中,当我们需要那些通用代码时,我们只需安装 composer 的包并从 vendor 目录中使用那些通用代码。

像 composer 这样的包和包管理器不仅仅存在于 PHP 中。在 Node.js 中,有 NPM(Node Package Manager),你可以使用它来创建一个 Node 包来实现相同的目的。因此,在不同的技术中,有不同的方法来创建和使用这样的包。

总结

在这一章中,作为本书的最后一章,我试图介绍微服务,这是一种现在备受关注的架构风格。我们研究它是因为我们需要一种架构,可以使用 RESTful Web 服务在复杂和更大的系统中实现更好的性能和可伸缩性。

本书的重点是 PHP7 中的 RESTful Web 服务,我们还研究了与构建 RESTful Web 服务或 PHP7 相关的其他主题。我们详细研究了其中一些主题,而其他一些只是触及了一下。许多这些主题太广泛,无法包含在一个章节中。其中一些主题可以有一本专门的书来专门讨论。这就是为什么我提供了不同的 URL 以供学习材料或建议阅读,如果你感兴趣的话可以参考。

接下来

有两件重要的事情:

实践:

真正的学习是当你开始实践某些东西时才开始的。当你实践时,有时会遇到问题并学到更多,这是你在没有解决这些问题的情况下无法学到的。

查阅建议的材料:

无论我提供了什么建议阅读材料,都请停下来至少看一下建议的材料。如果你觉得有帮助,可以深入了解。谁知道,那些建议的材料可能会教会你比整本书更有价值的东西。毕竟,那些材料提供了比我们在本书中讨论的更多细节。

posted @ 2024-05-05 12:12  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报