使用 request
您可能还记得前面几章的内容,网络应用程序的主要目的是处理来自客户端的 HTTP 请求并返回响应。如果这是应用程序的主要目的,那么管理请求和响应就应该是代码的重要组成部分。
PHP 是一种可用于脚本的语言,但其主要用途是在网络应用程序中。因此,该语言提供了大量用于管理请求和响应的助手。尽管如此,本地方式并不理想,作为优秀的 OOP 开发人员,我们应该想出一套类来帮助解决这个问题。这个小项目的主要元素是请求和路由器,它们仍在应用程序中。让我们开始吧!
request 对象
在启动迷你框架时,我们需要稍微改变一下目录结构。我们将为所有与框架相关的类创建 src/Core 目录。由于前几章中的配置读取器也是框架的一部分(而不是为用户提供功能),我们也应该将 Config.php 文件移到该目录下。
首先要考虑的是请求的外观。如果你还记得第 2 章 "使用 PHP 的 Web 应用程序",那么请求基本上就是一条指向 URL 的信息,它有一个方法—GET 或 POST。URL 同时由两部分组成:Web 应用程序的域,即服务器的名称,以及请求在服务器中的路径。例如,如果尝试访问 http://bookstore.com/my-books ,第一部分 http://bookstore.com 就是域名,/mybooks 就是路径。事实上,http 并不是域的一部分,但我们的应用程序并不需要这种粒度。您可以从 PHP 为每个请求填充的全局数组 $_SERVER 中获得这些信息。
我们的请求类应该为这三个元素分别设置一个属性,然后是一组获取器和其他一些对用户有用的帮助器。此外,我们还应在构造函数中初始化来自 $_SERVER 的所有属性。让我们看看会是什么样子:
<?php
namespace Bookstore\Core;
class Request {
const GET = 'GET';
const POST = 'POST';
private $domain;
private $path;
private $method;
public function __construct() {
$this->domain = $_SERVER['HTTP_HOST'];
$this->path = $_SERVER['REQUEST_URI'];
$this->method = $_SERVER['REQUEST_METHOD'];
}
public function getUrl(): string {
return $this->domain . $this->path;
}
public function getDomain(): string {
return $this->domain;
}
public function getPath(): string {
return $this->path;
}
public function getMethod(): string {
return $this->method;
}
public function isPost(): bool {
return $this->method === self::POST;
}
public function isGet(): bool {
return $this->method === self::GET;
}
}
我们可以在前面的代码中看到,除了每个属性的 getter 之外,我们还添加了 getUrl
、isPost
和 isGet
方法。用户可以使用已经存在的 getter 找到相同的信息,但由于会经常需要它们,因此让用户更轻松总是好的。 另请注意,这些属性来自 $_SERVER
数组的值:HTTP_HOST
、REQUEST_URI
和 REQUEST_METHOD
。
来自请求的过滤参数
请求的另一个重要部分是来自用户的信息,即 GET 和 POST 参数以及 Cookie。与 $_SERVER
全局数组一样,这些信息来自 $_POST
、$_GET
和 $COOKIE
,但最好避免不经过滤直接使用它们,因为用户可能会发送恶意代码。
现在,我们将实现一个类,它将表示一个可以过滤的 map-key-value 对。我们将把它称为 FilteredMap
,并将它包含在我们的命名空间 Bookstore\Core
中。我们将用它来包含 GET 和 POST 参数,以及作为请求类中两个新属性的 Cookie。该映射将只包含一个属性,即数据数组,并将包含一些从中获取信息的方法。要构造对象,我们需要将数据数组作为参数发送给构造函数:
<?php
namespace Bookstore\Core;
class FilteredMap {
private $map;
public function __construct(array $baseMap) {
$this->map = $baseMap;
}
public function has(string $name): bool {
return isset($this->map[$name]);
}
public function get(string $name) {
return $this->map[$name] ?? null;
}
}
到目前为止,该类的功能并不多。我们可以用普通数组实现同样的功能。当我们在获取数据时添加过滤器时,该类的作用就显现出来了。我们将实现三个过滤器,但你可以根据需要添加任意多个:
public function getInt(string $name) {
return (int) $this->get($name);
}
public function getNumber(string $name) {
return (float) $this->get($name);
}
public function getString(string $name, bool $filter = true) {
$value = (string) $this->get($name);
return $filter ? addslashes($value) : $value;
}
前面代码中的这三个方法允许用户获取特定类型的参数。比方说,开发人员需要从请求中获取图书的 ID。最好的选择是使用 getInt
方法,以确保返回值是一个有效的整数,而不是一些会扰乱数据库的恶意代码。还要注意函数 getString
,我们在这里使用了 addSlashed
方法。该方法会在一些可疑字符(如斜线或引号)上添加斜线,以防止出现恶意代码。
现在,我们可以使用 FilteredMap 从请求类中获取 GET 和 POST 参数以及 Cookie。新代码如下:
<?php
namespace Bookstore\Core;
class Request {
// ...
private $params;
private $cookies;
public function __construct() {
$this->domain = $_SERVER['HTTP_HOST'];
$this->path = explode('?', $_SERVER['REQUEST_URI'])[0];
$this->method = $_SERVER['REQUEST_METHOD'];
$this->params = new FilteredMap(
array_merge($_POST, $_GET)
);
$this->cookies = new FilteredMap($_COOKIE);
}
// ...
public function getParams(): FilteredMap {
return $this->params;
}
public function getCookies(): FilteredMap {
return $this->cookies;
}
}
通过这个新增功能,开发人员可以使用以下代码行获取 POST
参数 price
:
$price = $request->getParams()->getNumber('price');
这比通常调用全局数组更安全:
$price = $_POST['price'];
映射路由到控制器
如果你能回忆起你日常使用的任何 URL,你可能不会在路径中看到任何 PHP 文件,就像我们在 http://localhost:8000/init.php 中看到的那样。网站会尽量将 URL 格式化,使其更容易记忆,而不是依赖于处理该请求的文件。此外,正如我们已经提到的,无论路径如何,我们的所有请求都会通过同一个文件 index.php。因此,我们需要保存 URL 路径的地图,以及谁应该处理这些路径。
有时,我们的 URL 路径中会包含参数,这与 GET 或 POST 参数不同。例如,要获取显示特定图书的页面,我们可能会在 URL 中包含图书的 ID,如 /book/12 或 /book/3。每本不同的书的 ID 都会发生变化,但应由同一个控制器来处理所有这些请求。为此,我们可以说 URL 包含一个参数,并用 /book/:id 表示,其中 id 是标识图书 ID 的参数。我们还可以指定该参数的取值类型,例如数字、字符串等。
负责处理请求的控制器由方法类定义。该方法将 URL 路径定义的所有参数(如图书 ID)作为参数。我们根据控制器的功能对其进行分组,也就是说,BookController 类将包含与图书请求相关的方法。
在定义了路由的所有元素—URL-控制器之间的关系之后,我们就可以创建路由.json 文件了,这是一个用于保存地图的配置文件。该文件的每个条目都应包含一个路由,键是 URL,值是控制器的信息映射。我们来看一个例子:
{
"books/:page": {
"controller": "Book",
"method": "getAllWithPage",
"params": {
"page": "number"
}
}
}
上例中的路由指的是遵循 /books/:page 模式的所有 URL,page 可以是任何数字。因此,此路由将匹配 /books/23 或 /books/2 等 URL,但不应匹配 /books/one 或 /books。处理该请求的控制器应该是 BookController 中的 getAllWithPage 方法;我们将在所有类名后添加 Controller。根据我们定义的参数,该方法的定义应如下所示:
public function getAllWithPage(int $page): string {
//...
}
在定义路由时,我们还应考虑最后一件事。对于某些端点,我们应该强制用户通过身份验证,例如当用户试图访问自己的销售时。我们可以通过多种方式定义这一规则,但我们选择将其作为路由的一部分,在控制器信息中添加 "login": true
条目。考虑到这一点,让我们添加其余的路由,以定义我们期望拥有的所有视图:
{
//...
"books": {
"controller": "Book",
"method": "getAll"
},
"book/:id": {
"controller": "Book",
"method": "get",
"params": {
"id": "number"
}
},
"books/search": {
"controller": "Book",
"method": "search"
},
"login": {
"controller": "Customer",
"method": "login"
},
"sales": {
"controller": "Sales",
"method": "getByUser" ,
"login": true
},
"sales/:id": {
"controller": "Sales",
"method": "get",
"login": true,
"params": {
"id": "number"
}
},
"my-books": {
"controller": "Book",
"method": "getByUser",
"login": true
}
}
这些路由定义了我们需要的所有页面;我们可以以分页的方式获取所有图书,也可以根据 ID 获取特定图书;我们可以搜索图书,列出用户的销售情况,根据 ID 显示特定销售情况,以及列出某个用户借阅过的所有图书。但是,我们仍然缺少一些应用程序应该能够处理的端点。对于所有试图修改数据而非请求数据的操作,即借书或买书,我们也需要添加端点。在 routes.json
文件中添加以下内容:
{
// ...
"book/:id/buy": {
"controller": "Sales",
"method": "add",
"login": true
"params": {
"id": "number"
}
},
"book/:id/borrow": {
"controller": "Book",
"method": "borrow",
"login": true
"params": {
"id": "number"
}
},
"book/:id/return": {
"controller": "Book",
"method": "returnBook",
"login": true
"params": {
"id": "number"
}
}
}
路由器
路由器是应用程序中迄今为止最复杂的代码。其主要目标是接收 Request
对象,决定由哪个控制器来处理它,使用必要的参数调用它,并从该控制器返回响应。本节的主要目的是了解路由器的重要性,而不是其详细实现,但我们会尽量描述其每个部分。复制以下内容作为 src/Core/Router.php
文件:
<?php
namespace Bookstore\Core;
use Bookstore\Controllers\ErrorController;
use Bookstore\Controllers\CustomerController;
class Router {
private $routeMap;
private static $regexPatters = [
'number' => '\d+',
'string' => '\w'
];
public function __construct() {
$json = file_get_contents(
__DIR__ . '/../../config/routes.json'
);
$this->routeMap = json_decode($json, true);
}
public function route(Request $request): string {
$path = $request->getPath();
foreach ($this->routeMap as $route => $info) {
$regexRoute = $this->getRegexRoute($route, $info);
if (preg_match("@^/$regexRoute$@", $path)) {
return $this->executeController(
$route, $path, $info, $request
);
}
}
$errorController = new ErrorController($request);
return $errorController->notFound();
}
}
该类的构造函数读取 routes.json 文件,并将内容存储为一个数组。它的主方法 route 接收一个请求对象并返回一个字符串,我们将把字符串作为输出发送到客户端。该方法会遍历数组中的所有路由,尝试将每个路由与给定请求的路径相匹配。一旦找到,它就会尝试执行与该路由相关的控制器。如果没有一个路由与请求完全匹配,路由器将执行 ErrorController 的 notFound 方法,然后返回一个错误页面。
URL匹配使用正则表达式
在将 URL 与路由匹配时,我们需要注意动态 URL 的参数,因为它们不允许我们执行简单的字符串比较。PHP 和其他语言有一个非常强大的工具,可以对动态内容进行字符串比较:正则表达式。要成为正则表达式专家需要时间,而且这也不在本书的讨论范围之内,但我们还是要简单介绍一下正则表达式。
正则表达式是一个字符串,其中包含一些可以匹配动态内容的通配符。其中一些最重要的字符如下:
-
^
: 用于指定匹配部分应为整个字符串的开头 -
$
:用于指定匹配部分应该是整个字符串的结尾 -
\d
:用于匹配数字 -
\w
:用于匹配单词 -
+
: 用于跟随字符或表达式,让该字符或表达式至少出现一次或多次 -
*
:用于跟在字符或表达式后面,让该字符或表达式出现零次或多次 -
.
:用于匹配任何单个字符
让我们看一些例子:
-
模式
.*
将匹配任何内容,甚至是空字符串 -
模式
.+
将匹配包含至少一个字符的任何内容 -
模式
^\d+$
将匹配任何至少包含一位数字的数字
在 PHP 中,我们有不同的函数来处理正则表达式。其中最简单的,也是我们要使用的是 pregmatch
。该函数的第一个参数是一个模式(用两个字符分隔,通常是 @
或 /
),第二个参数是我们要匹配的字符串,还可以选择用一个数组来存储 PHP 找到的出现次数。该函数返回一个布尔值,如果有匹配则为 true
,否则为 false
。我们可以在 Route
类中如下使用该函数:
preg_match("@^/$regexRoute$@", $path)
$path
变量包含请求的路径,例如 /books/2
。我们使用一个以 @
分隔、带有 ^
和 $
通配符以强制匹配整个字符串的模式,并包含 /
和变量 $regexRoute
的连接。该变量的内容由以下方法提供;请将其添加到 Router
类中:
private function getRegexRoute(
string $route,
array $info
): string {
if (isset($info['params'])) {
foreach ($info['params'] as $name => $type) {
$route = str_replace(
':' . $name, self::$regexPatters[$type], $route
);
}
}
return $route;
}
前述方法会遍历来自路由信息的参数列表。对于每个参数,函数会用与参数类型相对应的通配符替换路由中的参数名称—请查看静态数组 $regexPatterns
。为了说明该函数的用法,我们来看几个示例:
-
由于路由
/books
不包含任何参数,因此无需更改即可返回 -
由于 URL 参数
id
是一个数字,因此路由books/:id/borrow
将更改为books/\d+/borrow
抽取URL中的参数
为了执行控制器,我们需要三项数据:要实例化的类的名称、要执行的方法的名称以及该方法需要接收的参数。前两个数据我们已经在路由 $info 数组中找到了,所以我们现在要集中精力找到第三个数据。在 Router 类中添加以下方法:
private function extractParams(
string $route,
string $path
): array {
$params = [];
$pathParts = explode('/', $path);
$routeParts = explode('/', $route);
foreach ($routeParts as $key => $routePart) {
if (strpos($routePart, ':') === 0) {
$name = substr($routePart, 1);
$params[$name] = $pathParts[$key+1];
}
}
return $params;
}
最后一种方法希望请求的路径和路由的 URL 都遵循相同的模式。通过 explode
方法,我们可以得到两个数组,每个数组的每个条目都应与之匹配。我们对它们进行遍历,对于路由数组中看起来像参数的每个条目,我们都会在 URL 中获取其值。例如,如果我们有路由 /books/:id/borrow
和路径 /books/12/borrow
,此方法的结果将是数组 ['id' => 12]
。
执行控制器
在本节的最后,我们将实现执行指定路由的控制器的方法。我们已经有了类名、方法和方法所需的参数,因此可以使用 call_user_func_array
本地函数,该函数在给定对象、方法名和方法参数后,会调用传递参数的对象的方法。由于参数的数量不固定,我们无法执行正常的调用,因此必须使用该函数。
但我们仍然缺少创建 routes.json
文件时引入的一个行为。有些路由会强制用户登录,在我们的例子中,这意味着用户拥有一个包含用户 ID 的 cookie。在路由强制授权的情况下,我们将检查请求是否包含 cookie,在这种情况下,我们将通过 setCustomerId
将 cookie 设置为控制器类。如果用户没有 cookie,我们将不执行当前路由的控制器,而是执行 CustomerController
类的 showLogin
方法,该方法将呈现登录表单的模板。让我们看看添加 Router
类的最后一个方法后的效果:
private function executeController(
string $route,
string $path,
array $info,
Request $request
): string {
$controllerName = '\Bookstore\Controllers\\' . $info['controller'] . 'Controller';
$controller = new $controllerName($request);
if (isset($info['login']) && $info['login']) {
if ($request->getCookies()->has('user')) {
$customerId = $request->getCookies()->get('user');
$controller->setCustomerId($customerId);
} else {
$errorController = new CustomerController($request);
return $errorController->login();
}
}
$params = $this->extractParams($route, $path);
return call_user_func_array(
[$controller, $info['method']], $params
);
}
我们已经警告过您,我们的应用程序缺乏安全性,因为这只是一个以教学为目的的项目。因此,请避免复制这里的授权系统。