为旧版PHP项目构建REST API

本文概述

  • 初始步骤
  • API类
  • 记录API
  • 输出结果
  • 总结
建立或设计REST API并非易事, 尤其是当你必须为旧版PHP项目执行时。如今, 有很多第三方库可以轻松实现REST API, 但是将它们集成到现有的旧版代码库中可能会令人生畏。而且, 你不一定总是拥有与Laravel和Symfony这样的现代框架一起工作的奢侈。使用旧版PHP项目, 你通常会发现自己在旧版内部PHP框架中的某个位置, 并在旧版本的PHP之上运行。
为旧版PHP项目构建REST API

文章图片
为旧版PHP项目构建REST API
鸣叫
在本文中, 我们将探讨尝试从头开始实现REST API的一些常见挑战, 解决这些问题的几种方法以及为遗留项目构建基于自定义PHP的API服务器的总体策略。
尽管本文基于PHP 5.3和更高版本, 但核心概念对5.0版之后的所有PHP版本均有效, 甚至可以应用于非PHP项目。在这里, 我们将不介绍REST API的一般含义, 因此, 如果你不熟悉REST API, 请务必先阅读它。
为了使你更容易理解, 以下列出了本文中使用的一些术语及其含义:
  • API服务器:在这种情况下, 服务于API的主要REST应用程序是用PHP编写的。
  • API端点:客户端与之通信以执行操作并产生结果的后端” 方法” 。
  • API端点URL:后端系统可通过其访问的URL。
  • API令牌:通过HTTP标头或cookie传递的唯一标识符, 可以从中识别用户。
  • 应用:客户端应用程序, 它将通过API端点与REST应用程序进行通信。在本文中, 我们将假定它是基于Web的(台式机或移动设备), 因此它是用JavaScript编写的。
初始步骤 路径模式
我们需要决定的第一件事就是API端点将在哪个URL路径上可用。有两种流行的方式:
  • 创建一个新的子域, 例如api.example.com。
  • 创建一个路径, 例如example.com/api。
乍一看, 第一个变种似乎更受欢迎和更具吸引力。但是, 实际上, 如果你要构建针对特定项目的API, 则选择第二种版本可能更合适。
采用第二种方法的最重要原因之一是, 这允许将cookie用作传输凭据的手段。基于浏览器的客户端将自动在XHR请求中发送适当的cookie, 从而无需其他授权标头。
另一个重要原因是, 你无需为子域配置或管理问题做任何事情, 因为某些代理服务器可能会剥夺自定义标头。这在遗留项目中可能是一个乏味的考验。
由于REST请求应该是无状态的, 因此使用cookie可以被认为是” 有害的” 做法。在这种情况下, 我们可以做出妥协, 并在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逻辑放入其自己的文件中来扩展此方法, 例如:
为旧版PHP项目构建REST 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作为关联数组获得必要的请求有效负载。
伪装/删除
有时, 你会发现自己处于服务器不支持标准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方法。这将确保关注点分离并提供一种保持逻辑清洁器。当然, 如果有可能将其更深入地集成到你正在使用的框架中并重用其特定的控制器或路由, 则你也应该考虑这种可能性。
【为旧版PHP项目构建REST API】带有原始自动加载器的示例” 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请求的麻烦。
为旧版PHP项目构建REST API

文章图片
如果客户端请求使用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类。例如:
< ?phpclass 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服务器头在同一个域或第三方域之间进行选择。如果应用程序检测到客户端正在使用x授权(或任何其他自定义标头), 则应允许来自所有来源的访问, 请允许自定义标头。所以它看起来像这样:
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服务器?在下面的评论部分与我们分享你的经验。

    推荐阅读