PHP Restful API

tags: Web PHP Restful API
category: Back-End
description: PHP Restful API
created_at: 2021/08/04 23:00:00
set: 技能競賽

cover image

事前準備

  • 稍微熟悉 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

當初我在比賽是沒有切分 RequestResponse,因為比賽時間不多,主要講求功能的完成。


首先複寫 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;
    }
}

// ...

再來補上 RequestRespnose

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)

就算比賽沒有禁用框架這件事,你也會更清楚那些框架在運作時的流程,也可以嘗試自己把更細節的東西實現出來。所以自幹也不是沒有優點

想當初練習的時候大概半小時把這些架構刻出來,正式比賽如果用這一套,等於讓了別人半小時




最後更新時間: 2021年08月04日.