PHP Restful API
tags: Web
PHP
Restful API
category: Back-End
description: PHP Restful API
created_at: 2021/08/04 23:00:00
set: 技能競賽
事前準備
- 稍微熟悉
PHP
語法 - 稍微知道
Restful API
前言
今年的全國技能競賽因為疫情關係延期了幾天,這次的 Restful API
題目相較近幾年簡單很多,大概是3年前的水準(?),我不是很清楚說他們是打算叫選手當場寫一個 SPA
去串 API
,還是打算再次出現一次 。禁用框架
導致選手掛掉
然後我當初準備技能競賽的時候,第一年就是準備假設被禁用框架的情況下,我有沒有辦法寫出來,這個方向去準備的,後來我運氣不好,那年沒有禁用,所以我有準備過不使用框架的做法。
後來幾年題目的複雜度大增,就都沒有出現過禁用框架這件事了(不過這是比賽 斷網+提供套件並不多但是至少會讓你能做(?))
所以我就把我之前比賽時練習的寫法稍微整理一下,然後稍微把一些地方改成類別的寫法,也稍微想了可能哪些人適合看 (?)
- 你剛好是選手
- 學PHP一陣子想學一些框架但還看不懂文件(ex: laravel)
跟我一樣想自幹東西的人
注意
這篇文章參考就好,實際要寫請還是使用像是 Laravel
之類的框架的解決方案,比較穩定。
不過你是選手就另當別論了
另外這篇不會修太多細節,主要傳達概念,包含較多細節的可參考之前整理過比較完整的版本,基本上就是實作 Laravel
當中的一些功能(Ex: Template Engine, Migration...)
相對完整的版本: https://github.com/LaiJunBin/laiPHP
當初偷懶,文件還沒更新到最新
目錄結構
<your project>
<app>
<Controller>
MainController.php
<Models>
User.php
<core>
DB.php
Request.php
Response.php
Route.php
.htaccess
app.php
functions.php
routes.php
當初我在比賽是沒有切分 Request
與 Response
,因為比賽時間不多,主要講求功能的完成。
首先複寫 apache
設定,因為比賽還是使用 apache
,建立 .htaccess
設定說不管哪個 url
都會導向至 app.php
做處理。
RewriteEngine On
RewriteRule ^.*$ app.php [nc,qsa]
全域函數
先寫一些全域函數在 functions.php
當中,之後會在 app.php
引入
use Core\Response;
// 引入整個資料夾
function require_dir_once($dir)
{
$cores = glob($dir);
foreach ($cores as $core) {
require_once $core;
}
}
// 把 `url` 清理一下並同時傳回長度
function clean_url($url)
{
$url = array_filter(explode('/', $url));
return [implode('/', $url), count($url)];
}
// 如果今天題目要求有需要在實作
function get_mime_type($filename)
{
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$mime_types = array(
'png' => 'image/png',
'jpeg' => 'image/jpeg',
'jpg' => 'image/jpeg',
// ...
);
return $mime_types[$extension] ?? 'application/octet-stream';
}
// 這是一個 helper function,只是想少打一點字
function Response($content = null)
{
return new Response($content);
}
主程式 (不含 Route處理 )
<?php
use Core\Route;
use Core\Request;
require_once __DIR__ . "/functions.php";
require_dir_once(__DIR__ . '/core/*.php');
require_dir_once(__DIR__ . '/app/Models/*.php');
require_dir_once(__DIR__ . '/app/Controller/*.php');
require_once './routes.php';
const ALLOW_METHODS = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE'
];
// 取得請求的方法
$method = $_SERVER['REQUEST_METHOD'];
// 取得url
$url = explode('/', $_SERVER['REQUEST_URI']);
$url = array_filter($url);
$url = explode('?', implode('/', $url))[0];
[$url, $url_count] = clean_url($url);
// 若有表單欺騙且方法包含在允許的方法中就複寫
if (isset($_POST['_method'])) {
$method = strtoupper($_POST['_method']);
if (!in_array($method, ALLOW_METHODS)) {
$method = $_SERVER['REQUEST_METHOD'];
}
}
// 先預設沒找到api
$is_found = false;
/*
這裡要走訪所有的路由,若匹配就執行對應的行為,等後面在補上
*/
// 如果沒有符合的路由,當作一般行為處理
if (!$is_found) {
if (file_exists($url) && filetype($url) == 'file') {
header('Content-Type:' . get_mime_type($url));
echo file_get_contents($url);
} else {
Response()->code(404);
}
}
Core
路由: Route.php
<?php
namespace Core;
class Route
{
// 存放所有路由
static $routes = [];
static function get($url, $action)
{
self::process("GET", $url, $action);
}
static function post($url, $action)
{
self::process("POST", $url, $action);
}
static function put($url, $action)
{
self::process("PUT", $url, $action);
}
static function patch($url, $action)
{
self::process("PATCH", $url, $action);
}
static function delete($url, $action)
{
self::process("DELETE", $url, $action);
}
// 建立路由資料
static function process($method, $url, $action)
{
if (!array_key_exists($method, self::$routes)) {
self::$routes[$method] = [];
}
if (is_array($action)) {
[$script, $function] = $action;
} else {
[$script, $function] = explode('@', $action);
}
// clean_url() 在 functions.php
[$url, $url_count] = clean_url($url);
// 將 url 使用 regex 處理
preg_match_all("/{(.[^}]*)}/", $url, $params);
$pattern = preg_replace("/{.[^}]*}/", "(.*)", $url);
$pattern = str_replace('/', '\/', $pattern);
$pattern = str_replace('?', '\?', $pattern);
$pattern = "/(?={$pattern}\?)^{$pattern}\?.*|^{$pattern}$/";
array_push(self::$routes[$method], [
'method' => $method,
'script' => $script,
'function' => $function,
'pattern' => $pattern,
'len' => $url_count,
'params' => $params[1]
]);
}
// 檢查url是否存在routes對應method中
static function has($url, $method = 'get')
{
$url = explode('?', $url)[0];
[$url, $url_count] = clean_url($url);
foreach (static::$routes[$method] ?? [] as $route) {
if ($url_count == $route->len && preg_match($route->pattern, $url)) {
return true;
}
}
return false;
}
}
在 Laravel
裡面,路由是這麼定義的:(假設GET)
Route::get('urls/{param}', [XxxController, 'method']);
如果你寫的是比較舊版的 Laravel
開始,可能會看過另一種寫法
Route::get('urls/{param}', 'XxxController@method');
這兩種寫法出來的結果是一樣的,而上面那段 Route.php
的程式,就是在實作這邊,但我們不需要實作到 Laravel
上面所有功能 (ex: prefix, group, middleware...),只要實作比賽用的到的 基本功能
。
再來補上 app.php
路由處理,因為已經知道 route
的資料有哪些
// ...
foreach (Route::$routes[$method] ?? [] as $route) {
if ($url_count == $route['len'] && preg_match($route['pattern'], $url, $matches)) {
// 取得路由參數
$params = [];
if (count($route['params'])) {
$params = array_combine($route['params'], array_slice($matches, -count($route['params'])));
}
$values = [];
if (count($route['params']))
$values = array_slice($matches, -count($route['params']));
// 依照路由定義的行為建立類別(instance)
$class = new ReflectionClass($route['script']);
$instance = $class->newInstance();
$function = $class->getMethod($route['function']);
// 如果對應到的 method 參數比較多,則在要帶進去的參數前面加上 Request 物件
if (count($function->getParameters()) > count($values)) {
array_unshift($values, new Request($params));
}
// invoke method.
$function->invokeArgs($instance, $values);
$is_found = true;
break;
}
}
// ...
再來補上 Request
與 Respnose
Request.php
<?php
namespace Core;
class Request
{
private $__status;
private $__method;
private $__params;
private $__uri;
function __get($name)
{
if ($name === 'status')
return $this->__status;
else if ($name === 'method')
return $this->__method;
else if ($name === 'uri')
return $this->__uri;
else if (array_key_exists($name, $this->__params))
return $this->__params[$name];
return null;
}
function __set($name, $value)
{
if (array_key_exists($name, $this->__params)) {
user_error("Can't set property: " . __CLASS__ . "->$name");
} else {
$this->__params[$name] = $value;
}
}
public function __construct($params = [])
{
$this->__status = $_SERVER['REDIRECT_STATUS'] ?? 200;
$this->__method = $_SERVER['REQUEST_METHOD'];
$this->__uri = $_SERVER['REQUEST_URI'];
$this->__params = $params;
}
public function content()
{
return file_get_contents('PHP://input');
}
public function json()
{
$raw_data = file_get_contents('PHP://input');
$json_data = json_decode($raw_data, true);
if (json_last_error() !== JSON_ERROR_NONE)
return null;
return $json_data;
}
public function get($key = null, $default = null)
{
return $_GET[$key] ?? $default;
}
public function post($key = null, $default = null)
{
return $_POST[$key] ?? $default;
}
public function file($name = null)
{
if ($name === null) {
$files = [];
foreach ($_FILES as $key => $file) {
$files[$key] = $this->file($key);
}
return $files;
}
if (!array_key_exists($name, $_FILES))
return null;
if (is_array($_FILES[$name]['name'])) {
$files = [];
for ($i = 0; $i < count($_FILES[$name]['name']); $i++) {
$files[] = [
"name" => $_FILES[$name]['name'][$i],
"type" => $_FILES[$name]['type'][$i],
"tmp_name" => $_FILES[$name]['tmp_name'][$i],
"error" => $_FILES[$name]['error'][$i],
"size" => $_FILES[$name]['size'][$i],
];
}
return $files;
}
return $_FILES[$name];
}
public function headers($key = null)
{
if ($key !== null)
return apache_request_headers()[$key] ?? null;
return apache_request_headers();
}
}
基本上就是對 Request
稍微做一點封裝(ex: data、header),而 __get
與 __set
是 PHP 特有的魔術方法,會在對 instance
做取值與設定值的時候觸發。
再來是 Response
Response.php
<?php
namespace Core;
class Response
{
public function __construct($content = null)
{
echo $content;
return $this;
}
public function json($json_data = [])
{
header('Content-Type: application/json');
echo json_encode($json_data);
return $this;
}
public function code($code = 200)
{
http_response_code($code);
return $this;
}
}
Response 非常的單純,需要回應什麼不同的東西可以在額外加工。
值得一提的是 return $this
可以讓你一路接下去那個 instance
的方法,例如:
Response()->json()->code();
這樣寫起來非常的舒服
建立 Controller
在一般比較小的專案中,程式邏輯的部分會放在 Controller
,如果比較複雜可能會抽出一層 Service
,但這裡是針對比賽,全部放在 Controller
一大包也沒關係。
MainController.php
先填上幾個函數,再來寫 routes.php
定義路由去觸發。
<?php
namespace App\Controller;
use Core\Request;
class MainController
{
public function index($id)
{
return Response()->json([
'success' => true,
'data' => $id
]);
}
public function test(Request $request) {
var_dump($request);
}
}
然後在 routes.php
定義路由
<?php
use Core\Route;
Route::get('/{id}', [\App\Controller\MainController::class, 'index']);
Route::post('/test', [\App\Controller\MainController::class, 'test']);
然後打開你熟悉的API測試工具,例如 Postman
,發出 Request
應該就會有回應。
get /100
{
"success": true,
"data": "100"
}
post /test
object(Core\Request)#4 (4) {
["__status":"Core\Request":private]=>
int(200)
["__method":"Core\Request":private]=>
string(4) "POST"
["__params":"Core\Request":private]=>
array(0) {
}
["__uri":"Core\Request":private]=>
string(5) "/test"
}
既然確定可以正常觸發的話,就可以開始處理資料庫了,如果每個 API
都要從頭寫 SQL
, 這樣太沒效率比賽會做不完,而且會寫的很累,所以多包一層 Model
層
注意: 這裡的 Model 層只是實作使用物件的靜態函數產生SQL並執行(因為只是比賽用),實際上 Model、ORM 更多更彈性的用法可以參考 Laravel
官方。
或是在上面的github連結中也有實作基本的部分(ex: instance, relationship)
DB.php
<?php
namespace Core;
class DB
{
static protected $table = null;
static private $db = null;
static function get_table()
{
return static::$table ?? get_called_class();
}
static function create($params)
{
$table = self::get_table();
$key = implode(',', array_keys($params));
$bindKey = implode(',', array_map(function ($key) {
return ':' . $key;
}, array_keys($params)));
$sql = "insert {$table}({$key}) values({$bindKey})";
self::execute($sql, $params);
return self::$db->lastInsertId();
}
static function update($params, $conditions)
{
$table = self::get_table();
$key = implode(',', array_map(function ($key) {
return $key . '=:' . $key;
}, array_keys($params)));
$conditionKey = implode(' and ', array_map(function ($key) {
return $key . '=:c' . $key;
}, array_keys($conditions)));
$conditions = array_combine(array_map(function ($key) {
return 'c' . $key;
}, array_keys($conditions)), array_values($conditions));
$sql = "update {$table} set {$key} where {$conditionKey}";
self::execute($sql, array_merge($params, $conditions));
}
static function delete($conditions)
{
$table = self::get_table();
$conditionKey = implode(' and ', array_map(function ($key) {
return $key . '=:' . $key;
}, array_keys($conditions)));
$sql = "delete from {$table} where {$conditionKey}";
return self::execute($sql, $conditions);
}
static function all()
{
$table = self::get_table();
$sql = "select * from {$table}";
return self::execute($sql, [])->fetchAll(\PDO::FETCH_ASSOC);
}
static function find($conditions, $orderby = null)
{
return self::select($conditions, $orderby)->fetch(\PDO::FETCH_ASSOC);
}
static function where($conditions, $orderby = null)
{
return self::select($conditions, $orderby)->fetchAll(\PDO::FETCH_ASSOC);
}
static function exists($conditions)
{
return self::select($conditions, null)->fetch(\PDO::FETCH_ASSOC) !== false;
}
private static function select($conditions, $orderby = null)
{
$table = self::get_table();
$conditionKey = implode(' and ', array_map(function ($key) {
return $key . '=:' . $key;
}, array_keys($conditions)));
$sql = "select * from {$table} where {$conditionKey}";
if ($orderby != null) {
$by = implode(' ', array_map(function ($key) use ($orderby) {
return $key . ' ' . $orderby[$key];
}, array_keys($orderby)));
$sql .= ' order by ' . $by;
}
return self::execute($sql, $conditions);
}
static function execute($sql, $params)
{
if (self::$db == null) {
// 這裡要改成自己的資訊
self::$db = new \PDO('mysql:host=localhost;dbname=資料表名稱', 'username', 'password');
self::$db->exec('set names utf8');
}
$query = self::$db->prepare($sql);
foreach ($params as $key => $value) {
$query->bindValue($key, $value);
}
$query->execute();
if ($query->errorinfo()[2] != "") {
echo $query->errorinfo()[2] . "<br>" . $sql;
exit();
}
return $query;
}
}
這裡的話基本上就是使用 PDO
連接資料庫,其他就是字串的處理。
比較重要的是約在109行,需要改成自己環境的資料。
再來到 User.php
<?php
namespace App\Models;
use Core\DB;
class User extends DB
{
// 告訴DB說我的table是users
static protected $table = 'users';
}
所以這時候需要一個users
資料表,用最基本的就可以
CREATE TABLE `users` (
`id` int(10) NOT NULL,
`username` varchar(100) NOT NULL,
`name` varchar(100) NOT NULL,
`password` int(100) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `users` (`id`, `username`, `name`, `password`) VALUES
(1, 'user01', 'user', 1234);
ALTER TABLE `users`
ADD PRIMARY KEY (`id`);
ALTER TABLE `users`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
再來到 MainController.php
改一下 index()
,測試一下 Model 正不正常
use App\Models\User;
public function index()
{
return Response()->json([
'success' => true,
'data' => User::all()
]);
}
你應該會看到下面的回應
{
"success": true,
"data": [
{
"id": "1",
"username": "user01",
"name": "user",
"password": "1234"
}
]
}
再來就做個基本的範例吧,users 的 CRUD:
routes.php
<?php
use Core\Route;
Route::get('/users', [\App\Controller\MainController::class, 'index']);
Route::get('/users/{id}', [\App\Controller\MainController::class, 'get']);
Route::post('/users', [\App\Controller\MainController::class, 'store']);
Route::patch('/users/{id}', [\App\Controller\MainController::class, 'update']);
Route::delete('/users/{id}', [\App\Controller\MainController::class, 'delete']);
MainController.php
<?php
namespace App\Controller;
use App\Models\User;
use Core\Request;
class MainController
{
public function index()
{
return Response()->json([
'success' => true,
'data' => User::all()
]);
}
public function get($id)
{
$user = User::find(['id' => $id]);
if (!$user) {
return Response()->json([
'success' => false,
'message' => 'USER_NOT_FOUND.'
])->code(404);
}
return Response()->json([
'success' => true,
'data' => $user
]);
}
public function store(Request $request)
{
$data = $request->json();
if (User::exists(['username' => $data['username']])) {
return Response()->json([
'success' => false,
'message' => 'USER_ALREADY_EXISTS.'
])->code(400);
}
try {
$user_id = User::create($data);
return Response()->json([
'success' => true,
'data' => $user_id
]);
} catch (\Exception $e) {
return Response()->json([
'success' => false,
'message' => 'USER_CREATE_ERROR.'
])->code(400);
}
}
public function update(Request $request, $id)
{
$user = User::find(['id' => $id]);
if (!$user) {
return Response()->json([
'success' => false,
'message' => 'USER_NOT_FOUND.'
])->code(404);
}
$data = $request->json();
if ($user['username'] != $data['username'] && User::exists(['username' => $data['username']])) {
return Response()->json([
'success' => false,
'message' => 'USER_ALREADY_EXISTS.'
])->code(400);
}
try {
User::update($data, $user);
return Response()->json([
'success' => true,
'data' => ''
]);
} catch (\Exception $e) {
return Response()->json([
'success' => false,
'message' => 'USER_UPDATE_ERROR.'
])->code(400);
}
}
public function delete($id)
{
$user = User::find(['id' => $id]);
if (!$user) {
return Response()->json([
'success' => false,
'message' => 'USER_NOT_FOUND.'
])->code(404);
}
User::delete($user);
return Response()->json([
'success' => true,
'data' => ''
]);
}
}
總結
雖然說這樣寫起來非常冗長,但是在比賽的情況下如果真的被禁用框架,你會實作這些細微的部分,就是只有你有分數,其他人的這個模組就會是0分,整個差距就會浮現。(因為比賽考5~6個模組而總分是100)
就算比賽沒有禁用框架這件事,你也會更清楚那些框架在運作時的流程,也可以嘗試自己把更細節的東西實現出來。所以自幹也不是沒有優點
想當初練習的時候大概半小時把這些架構刻出來,正式比賽如果用這一套,等於讓了別人半小時