为遗留 PHP 项目构建 REST API
已发表: 2022-03-11构建或构建 REST API 并非易事,尤其是当您必须为遗留 PHP 项目执行此操作时。 现在有很多 3rd 方库可以轻松实现 REST API,但是将它们集成到现有的遗留代码库中可能相当令人生畏。 而且,您并不总是有幸使用现代框架,例如 Laravel 和 Symfony。 对于遗留的 PHP 项目,您经常会发现自己处于已弃用的内部框架的中间,运行在旧版本的 PHP 之上。
在本文中,我们将了解尝试从头开始实现 REST API 的一些常见挑战、解决这些问题的几种方法以及为遗留项目构建基于 PHP 的自定义 API 服务器的总体策略。
文章虽然基于 PHP 5.3 及以上版本,但核心概念对 PHP 5.0 以后的所有版本都有效,甚至可以应用于非 PHP 项目。 在这里,我们不会介绍一般的 REST API 是什么,所以如果您不熟悉它,请务必先阅读它。
为了让您更容易理解,这里列出了本文中使用的一些术语及其含义:
- API 服务器:服务于 API 的主要 REST 应用程序,在本例中,是用 PHP 编写的。
- API端点:客户端与之通信以执行操作并产生结果的后端“方法”。
- API 端点 URL:世界可通过该 URL 访问后端系统。
- API 令牌:通过 HTTP 标头或 cookie 传递的唯一标识符,从中可以识别用户。
- 应用程序:将通过 API 端点与 REST 应用程序通信的客户端应用程序。 在本文中,我们将假设它是基于 Web 的(桌面或移动),因此它是用 JavaScript 编写的。
初始步骤
路径模式
我们需要决定的第一件事是 API 端点可用的 URL 路径。 有2种流行的方式:
- 创建一个新的子域,例如 api.example.com。
- 创建一个路径,例如 example.com/api。
乍一看,似乎第一个变体更受欢迎和有吸引力。 然而,实际上,如果您正在构建特定于项目的 API,则选择第二个变体可能更合适。
采用第二种方法的最重要原因之一是这允许将 cookie 用作传输凭据的一种方式。 基于浏览器的客户端将在 XHR 请求中自动发送适当的 cookie,无需额外的授权标头。
另一个重要的原因是您不需要对某些代理服务器可能会剥离自定义标头的子域配置或管理问题做任何事情。 这在遗留项目中可能是一个乏味的考验。
使用 cookie 可以被认为是一种“非 RESTful”实践,因为 REST 请求应该是无状态的。 在这种情况下,我们可以做出妥协并在 cookie 中传递令牌值,而不是通过自定义标头传递它。 实际上,我们使用 cookie 作为直接传递令牌值而不是 session_id 的一种方式。 这种方法可以被认为是无状态的,但我们可以根据您的喜好来决定。
API 端点 URL 也可以进行版本控制。 此外,它们可以在路径名中包含预期的响应格式作为扩展名。 尽管这些并不重要,尤其是在 API 开发的早期阶段,但从长远来看,这些细节肯定会得到回报。 特别是当您需要实现新功能时。 通过检查客户端期望的版本并提供向后兼容所需的格式可能是最佳解决方案。
API 端点 URL 结构如下所示:
example.com/api/${version_code}/${actual_request_path}.${format}
而且,一个真实的例子:
example.com/api/v1.0/records.json
路由
在为 API 端点选择一个基本 URL 之后,我们需要做的下一件事是考虑我们的路由系统。 它可以集成到现有框架中,但如果这太麻烦,一个潜在的解决方法是在文档根目录中创建一个名为“api”的文件夹。 这样 API 就可以拥有完全独立的逻辑。 您可以通过将 API 逻辑放入其自己的文件来扩展此方法,例如:
您可以将“www/api/Apis/Users.php”视为特定 API 端点的单独“控制器”。 重用现有代码库中的实现会很棒,例如重用项目中已经实现的模型来与数据库通信。
最后,确保将来自“/api/*”的所有传入请求指向“/api/index.php”。 这可以通过更改您的 Web 服务器配置来完成。
API 类
版本和格式
您应该始终清楚地定义您的 API 端点接受哪些版本和格式以及默认的版本和格式。 这将允许您在将来构建新功能的同时保留旧功能。 API 版本基本上可以是一个字符串,但您可以使用数值来更好地理解和比较。 为次要版本保留备用数字是件好事,因为它清楚地表明只有少数不同之处:
- v1.0 意味着第一个版本。
- v1.1 第一个版本有一些小的改动。
- v2.0 将是一个全新的版本。
格式可以是您的客户需要的任何内容,包括但不限于 JSON、XML 甚至 CSV。 通过通过 URL 作为文件扩展名提供它,API 端点 url 确保了可读性,并且它成为 API 使用者知道他们可以期望的格式的一个明智之举:
- “/api/v1.0/records.json” 将返回 JSON 记录数组
- “/api/v1.0/records.xml”将返回记录的XML文件
值得指出的是,您还需要在响应中为每种格式发送正确的 Content-Type 标头。
收到传入请求后,您应该做的第一件事是检查 API 服务器是否支持请求的版本和格式。 在处理传入请求的 main 方法中,解析$_SERVER['PATH_INFO']或$_SERVER['REQUEST_URI']以确定是否支持请求的格式和版本。 然后,继续或返回 4xx 响应(例如 406“不可接受”)。 这里最关键的部分是总是返回客户期望的东西。 另一种方法是检查请求标头“Accept”而不是 URL 路径扩展名。
允许的路线
您可以将所有内容透明地转发到您的 API 控制器,但最好使用一组列入白名单的允许路由。 这会稍微降低灵活性,但可以非常清楚地了解下次返回代码时 API 端点 URL 的样子。
private $public_routes = array( 'system' => array( 'regex' => 'system', ), 'records' => array( 'regex' => 'records(?:/?([0-9]+)?)', ), );
您还可以将它们移动到单独的文件中以使事情更清洁。 上面的配置将用于启用对这些 URL 的请求:
/api/v1.0/system.json /api/v1.0/records.json /api/v1.0/records/7.json
处理 PUT 数据
PHP 自动处理传入的 POST 数据并将其放在$_POST超全局下。 但是,PUT 请求并非如此。 所有数据都“埋”到php://input中。 在调用实际的 API 方法之前,不要忘记将其解析为单独的结构或数组。 一个简单的parse_str就足够了,但如果客户端发送多部分请求,则可能需要额外的解析来处理表单边界。 多部分请求的典型用例包括文件上传。 检测和处理多部分请求可以如下完成:
self::$input = file_get_contents('php://input'); // For PUT/DELETE there is input data instead of request variables if (!empty(self::$input)) { preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches); if (isset($matches[1]) && strpos(self::$input, $matches[1]) !== false) { $this->parse_raw_request(self::$input, self::$input_data); } else { parse_str(self::$input, self::$input_data); } }
在这里, parse_raw_request可以实现为:
/** * Helper method to parse raw requests */ private function parse_raw_request($input, &$a_data) { // grab multipart boundary from content type header preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches); $boundary = $matches[1]; // split content by boundary and get rid of last -- element $a_blocks = preg_split("/-+$boundary/", $input); array_pop($a_blocks); // loop data blocks foreach ($a_blocks as $id => $block) { if (empty($block)) { continue; } // parse uploaded files if (strpos($block, 'application/octet-stream') !== false) { // match "name", then everything after "stream" (optional) except for prepending newlines preg_match("/name=\"([^\"]*)\".*stream[\n|\r]+([^\n\r].*)?$/s", $block, $matches); // parse all other fields } else { // match "name" and optional value in between newline sequences preg_match('/name=\"([^\"]*)\"[\n|\r]+([^\n\r].*)?\r$/s', $block, $matches); } $a_data[$matches[1]] = $matches[2]; } }
有了这个,我们可以在Api::$input作为原始输入和Api::$input_data作为关联数组的必要请求有效负载。

伪造 PUT/DELETE
有时您会发现自己处于服务器不支持除了标准 GET/POST HTTP 方法之外的任何东西的情况下。 此问题的常见解决方案是“伪造” PUT/DELETE 或任何其他自定义请求方法。 为此,您可以使用“魔术”参数,例如“_method”。 如果您在$_REQUEST数组中看到它,只需假设请求是指定类型的。 像 Laravel 这样的现代框架内置了这样的功能。 它提供了很好的兼容性,以防您的服务器或客户端有限制(例如,某人在不允许 PUT 请求的公司代理后面使用他的工作的 Wi-Fi 网络。)
转发到特定 API
如果您没有重用现有项目自动加载器的奢侈,您可以在spl_autoload_register函数的帮助下创建自己的。 在“api/index.php”页面中定义它并调用位于“api/Api.php”中的 API 类。 API 类充当中间件并调用实际方法。 例如,对“/api/v1.0/records/7.json”的请求最终应该调用带有参数 7 的“Apis/Records.php”GET 方法。这将确保关注点分离并提供一种方法来保持逻辑清洁剂。 当然,如果可以将其更深入地集成到您正在使用的框架中并重用其特定的控制器或路由,您也应该考虑这种可能性。
带有原始自动加载器的示例“api/index.php”:
<?php // Let's define very primitive autoloader spl_autoload_register(function($classname){ $classname = str_replace('Api_', 'Apis/', $classname); if (file_exists(__DIR__.'/'.$classname.'.php')) { require __DIR__.'/'.$classname.'.php'; } }); // Our main method to handle request Api::serve();
这将加载我们的Api类并开始独立于主项目提供服务。
选项请求
当客户端使用自定义标头转发其唯一令牌时,浏览器首先需要检查服务器是否支持该标头。 这就是 OPTIONS 请求的来源。其目的是确保客户端和 API 服务器的一切正常且安全。 因此,每次客户端尝试执行任何操作时,都可能会触发 OPTIONS 请求。 但是,当客户端使用 cookie 作为凭据时,它使浏览器不必发送这个额外的 OPTIONS 请求。
如果客户端请求带有 cookie 的 POST /users/8.json,它的请求将是非常标准的:
- 应用程序对 /users/8.json 执行 POST 请求。
- 浏览器执行请求并接收响应。
但是使用自定义授权或令牌标头:
- 应用程序对 /users/8.json 执行 POST 请求。
- 浏览器停止处理请求并改为启动 OPTIONS 请求。
- OPTIONS 请求被发送到 /users/8.json。
- 浏览器接收响应,其中包含 API 定义的所有可用方法和标头的列表。
- 仅当自定义标头出现在可用标头列表中时,浏览器才会继续执行原始 POST 请求。
但是,请记住,即使使用 cookie,使用 PUT/DELETE 您仍可能会收到额外的 OPTIONS 请求。 所以准备好回应它。
记录 API
基本结构
我们的示例 Records API 非常简单。 它将包含所有请求方法并将输出返回到同一个主 API 类。 例如:
<?php class Api_Records { public function __construct() { // In here you could initialize some shared logic between this API and rest of the project } /** * Get individual record or records list */ public function get($id = null) { if ($id) { return $this->getRecord(intval($id)); } else { return $this->getRecords(); } } /** * Update record */ public function put($record_id = null) { // In real world there would be call to model with validation and probably token checking // Use Api::$input_data to update return Api::responseOk('OK', array()); } // ...
因此,定义每个 HTTP 方法将使我们能够更轻松地以 REST 样式构建 API。
格式化输出
天真地响应从数据库接收到的所有内容返回给客户端可能会产生灾难性的后果。 为了避免任何意外暴露数据,请创建特定的格式方法,该方法将仅返回列入白名单的键。
白名单键的另一个好处是,您可以基于这些编写文档并进行所有类型检查,以确保例如 user_id 始终为整数,标志 is_banned 始终为布尔值 true 或 false,日期时间将有一个标准响应格式。
输出结果
标头
标头输出的单独方法将确保发送到浏览器的所有内容都是正确的。 此方法可以利用使 API 可通过同一域访问的好处,同时仍保持接收自定义授权标头的可能性。 在 HTTP_ORIGIN 和 HTTP_REFERER 服务器标头的帮助下,可以在相同或第 3 方域之间进行选择。 如果应用程序检测到客户端正在使用 x-authorization(或任何其他自定义标头),它应该允许来自所有来源的访问,请允许自定义标头。 所以它可能看起来像这样:
header('Access-Control-Allow-Origin: *'); header('Access-Control-Expose-Headers: x-authorization'); header('Access-Control-Allow-Headers: origin, content-type, accept, x-authorization'); header('X-Authorization: '.YOUR_TOKEN_HERE);
但是,如果客户端使用基于 cookie 的凭据,则标头可能会有所不同,仅允许请求的主机和 cookie 相关的凭据标头:
header('Access-Control-Allow-Origin: '.$origin); header('Access-Control-Expose-Headers: set-cookie, cookie'); header('Access-Control-Allow-Headers: origin, content-type, accept, set-cookie, cookie'); // Allow cookie credentials because we're on the same domain header('Access-Control-Allow-Credentials: true'); if (strtolower($_SERVER['REQUEST_METHOD']) != 'options') { setcookie(TOKEN_COOKIE_NAME, YOUR_TOKEN_HERE, time()+86400*30, '/', '.'.$_SERVER['HTTP_HOST']); }
请记住,OPTIONS 请求不支持 cookie,因此应用程序不会将其发送出去。 最后,这允许我们所有想要的 HTTP 方法具有访问控制到期:
header('Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE'); header('Access-Control-Max-Age: 86400');
身体
正文本身应包含客户端请求的格式的响应,成功时为 2xx HTTP 状态,由于客户端失败时为 4xx 状态,由于服务器失败时为 5xx 状态。 响应的结构可能会有所不同,但指定“状态”和“响应”字段也可能是有益的。 例如,如果客户端试图注册一个新用户并且用户名已经被使用,你可以发送一个 HTTP 状态为 200 的响应,但正文中的 JSON 看起来像这样:
{“status”: “ERROR”, “response”: ”username already taken”}
… 而不是直接出现 HTTP 4xx 错误。
结论
没有两个项目是完全相同的。 本文中概述的策略可能适合也可能不适合您的情况,但核心概念应该是相似的。 值得注意的是,并非每个页面背后都有最新的趋势或最新的框架,有时对“为什么我的 REST Symfony 包在这里不起作用”的愤怒可以转化为构建有用的东西的动力,有用的东西。 最终结果可能不会那么闪亮,因为它总是一些自定义和特定于项目的实现,但最终解决方案将是真正有效的; 在这样的场景中,这应该是每个 API 开发人员的目标。
为方便起见,此处讨论的概念的示例实现已上传到 GitHub 存储库。 您可能不想直接在生产环境中使用这些示例代码,但这可以很容易地作为您下一个遗留 PHP API 集成项目的起点。
最近必须为一些遗留项目实现一个 REST API 服务器? 在下面的评论部分与我们分享您的经验。