為遺留 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 服務器? 在下面的評論部分與我們分享您的經驗。