前言
藉由 基礎篇 所提供的簡單範例,我們可以發現如果我們把每個路由都寫成一個閉包(Closure)
的形式,很容易導致程式碼不易閱讀
,並且把所有資料都寫在一個檔案非常難以去釐清 RESTful API 服務的類型
和相關的依賴類
。
所以本篇將要介紹如何將 SLIM 4 構建成一個屬於 ADR 模式
的一個框架,構建之前也會介紹傳統 MVC 框架
的架構,之後再來講解如何使用 JWT
如何用作登入後產生 Token
並且驗證其身份資訊
。
以下我構想這個框架要符合什麼條件:
基於 ADR 模式
要有一個基礎的 ORM(可以不用 Migration)
要有 Jwt 功能
能夠輕鬆寫單元測試、E2E 測試
要使用 Docker 一鍵啟動
使用 Github Actions CI / CD
本篇主要是參考 Fogless 的Slim 4 搭建 RESTful API 以及 Daniel Opitz 的Slim 4 - Tutorial ,並且結合自身所學,再根據個人習慣建構。進階篇的建構方向是藉由基礎篇的基礎,將 Slim 4 的 ADR 框架
建立完成,並且實現 表單驗證
、統一回應格式
、Jwt
、登入登出
功能。
安裝 本機最低需求
PHP 8.0 (目前沒有使用 php-7.4 實測過,所以希望至少可以 8.0 以上)
Composer
Docker、Docker-Compose
安裝相關依賴
我們先將基礎篇的範例程式碼 clone
下來命名成新專案 Slim-ADR
,然後將相關依賴先安裝。
1 2 3 4 5 6 7 8 git clone https://github.com/POABOB/Slim-Simple.git mv Slim-Simple Slim-ADR composer install mv .env.example .env
建構 RESTful API PSR-4
PSR-4 Autoloader
是用來告訴開發者如何架構專案的目錄結構
及命名空間
,遵循這個規範並搭配 Composer 提供的 autoload
的功能,就可以將 PHP 檔案進行自動加載
,而不用一直使用 require
。
我們要自動加載的目錄為 src
、tests
(請順便建立),src
目錄是用來存放我們 RESRful API的程式碼,tests
則是我們用來撰寫軟體測試時所用的目錄。
當我們把 autoload
的位置
和命名變數
設定好之後,完整檔案應該如下呈現:
composer.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 { "name" : "poabob/slim-adr" , "type" : "project" , "description" : "A Simple Restful PHP Microservice Framework!" , "homepage" : "https://github.com/POABOB/Slim-ADR" , "authors" : [ { "name" : "POABOB" , "email" : "zxc752166@gmail.com" } ], "keywords" : [ "slim-simple" , "slim" , "slim4" , "slim-4" , "nginx" , "microservice" , "restful" ], "license" : "MIT" , "require" : { "php" : ">=8.0" , "catfan/medoo" : "^2.1" , "php-di/php-di" : "^6.4" , "selective/basepath" : "^2.1" , "slim/http" : "^1.0" , "slim/psr7" : "^1.0" , "slim/slim" : "^4.10" , "symfony/dotenv" : "^6.0" }, "config" : { "process-timeout" : 0 , "sort-packages" : true }, "autoload" : { "psr-4" : { "App\\" : "src" } }, "autoload-dev" : { "psr-4" : { "App\\Test\\" : "tests" } } }
MVC 模式
什麼是 MVC
呢?我們可以先由以下流程圖來了解 MVC
的運作模式。
每當我們 Client
向 Server
傳遞請求的時候,首先會被先由路由(Route)
來去攔截,攔截這份請求解析他的 HTTP Method
和 URI
。
路由找到對應的控制器(Controller)
,一個控制器通常會有多個類型相同的 Action
操作。
去執行該控制器的 Action
處理我們想要做的事情,這裡通常會做一些業務邏輯
、表單驗證
的事情。
如果該 Action
會使用到資料庫的話,控制器不能執行資料庫操作,而是要讓 Model
來去做 DB 的 CRUD
。
Model
將會與資料庫建立連線,並且把 SQL 語法當作溝通工具與資料庫溝通。
從資料庫操作完我們要做的事情後,返回結果給 Model
。
Model
將結果返回給控制器
,這邊可以做資料相關的處理
。
控制器
將我們所拿到的資料或結果渲染在 View
當中。
最後把 View
的渲染結果返回給 Client
。
名稱
功能
Controller
檢查請求的資料是否符合規範
、業務處理
、資料庫返回資料的格式處理
、連接 Model
和 View
的主幹
Model
資料庫溝通
View
將結果渲染
到我們的視圖給 Client
ADR 模式
我們可以看到 MVC
雖然是一個非常完整且成熟的架構,但依然看得到幾樣缺點。
Controller
負載的工作量太大,寫久了容易導致 Controller
裡面的 Action
複雜且難以閱讀。
現行比較流行前後端分離的架構(SPA),導致 View
在 RESTful API
中沒有什麼存在的必要性。
因此,進而產生了 ADR
(Action - Domain - Responder)這個模式,Action
主要是將 Controller
中的 Action
獨立出來成一個單獨的檔案,一個 Action
就只負責一件事情
,並且他只處理請求
和回應
兩項操作。Domain
則包含了兩種操作,一個是 Service
,另外一個則是 Repository
。基本上一個 Action
會對應一個獨立的 Service
,而 Service
就是用來切割原本在 Controller
大量的工作。Repository
則是與原先 Model
非常類似並且裡面會有多種資料庫操作。
名稱
功能
Action
負責處理請求
和回應
Service
檢查請求的資料是否符合規範
、業務處理
、資料庫返回資料的格式處理
Repository
資料庫溝通
Action
我們先 src
目錄之中建立一個 Action
目錄。
並且在 src/Action
建立一個專門給 Users
的資料夾 src/Action/Users
。
然後建立四個檔案,分別是 src/Action/Users/GetAction.php
、src/Action/Users/InsertAction.php
、src/Action/Users/UpdateAction.php
、src/Action/Users/DeleteAction.php
。
目錄大概會如下圖:1 2 3 4 5 6 7 8 . ├── src/ | ├── Actions/ | | └── Users/ | | ├── GetAction.php | | ├── InsertAction.php | | ├── UpdateAction.php | | └── DeleteAction.php
src/Action/Users/GetAction.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <?php namespace App \Action \Users ; use Slim \Http \Response ;use Psr \Http \Message \ServerRequestInterface as Request ;use App \Domain \Users \Service \GetService ;final class GetAction { private GetService $service; public function __construct (GetService $service) { $this ->service = $service; } public function __invoke (Request $request, Response $response) : Response { $return = $this ->service->getUsers(); return $response->withJson($return, 200 , JSON_UNESCAPED_UNICODE); } }
src/Action/Users/InsertAction.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php namespace App \Action \Users ; use Slim \Http \Response ;use Psr \Http \Message \ServerRequestInterface as Request ;use App \Domain \Users \Service \InsertService ;final class InsertAction { private InsertService $service; public function __construct (InsertService $service) { $this ->service = $service; } public function __invoke (Request $request, Response $response) : Response { $data = (array )$request->getParsedBody(); $return = $this ->service->insertUser($data); return $response->withJson($return, 200 , JSON_UNESCAPED_UNICODE); } }
src/Action/Users/UpdateAction.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php namespace App \Action \Users ; use Slim \Http \Response ;use Psr \Http \Message \ServerRequestInterface as Request ;use App \Domain \Users \Service \UpdateService ;final class UpdateAction { private UpdateService $service; public function __construct (UpdateService $service) { $this ->service = $service; } public function __invoke (Request $request, Response $response) : Response { $data = (array )$request->getParsedBody(); $return = $this ->service->updateUser($data); return $response->withJson($return, 200 , JSON_UNESCAPED_UNICODE); } }
src/Action/Users/DeleteAction.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php namespace App \Action \Users ; use Slim \Http \Response ;use Psr \Http \Message \ServerRequestInterface as Request ;use App \Domain \Users \Service \DeleteService ;final class DeleteAction { private DeleteService $service; public function __construct (DeleteService $service) { $this ->service = $service; } public function __invoke (Request $request, Response $response) : Response { $data = (array )$request->getParsedBody(); $return = $this ->service->deleteUser($data); return $response->withJson($return, 200 , JSON_UNESCAPED_UNICODE); } }
Service
在我們上方的四個 Action
可以看到,每個 Action
都向一個獨立的 Service
請求結果,並且將結果返回。
那我們可以建立 src/Domain/Users/Service
和 src/Domain/Users/Repository
兩種路徑目錄當作我們 Users
的 Domain
。
然後在目錄中建立四個檔案,分別是 src/Domain/Users/Service/GetService.php
、src/Domain/Users/Service/InsertService.php
、src/Domain/Users/Service/UpdateService.php
、src/Domain/Users/Service/DeleteService.php
。
目錄大概會如下圖:1 2 3 4 5 6 7 8 9 10 . ├── src/ | ├── Domain/ | | └── Users/ | | ├── Repository/ | | └── Service/ | | ├── GetService.php | | ├── InsertService.php | | ├── UpdateService.php | | └── DeleteService.php
src/Domain/Users/Service/GetService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <?php namespace App \Domain \Users \Service ; use App \Domain \Users \Repository \UsersRepository ;final class GetService { private UsersRepository $db; public function __construct (UsersRepository $db) { $this ->db = $db; } public function getUsers () : array { $param = "*" ; $where = 1 ; $data = $this ->db->getUsers($param, $where); return $data; } }
src/Domain/Users/Service/InsertService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <?php namespace App \Domain \Users \Service ;use App \Domain \Users \Repository \UsersRepository ;final class InsertService { private UsersRepository $db; public function __construct (UsersRepository $db) { $this ->db = $db; } public function insertUser (array $data) : int { $return = $this ->db->insertUser($data); return $return; } }
src/Domain/Users/Service/UpdateService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php namespace App \Domain \Users \Service ;use App \Domain \Users \Repository \UsersRepository ;final class UpdateService { private UsersRepository $db; public function __construct (UsersRepository $db) { $this ->db = $db; } public function updateUser (array $data) : bool { $return = $this ->db->updateUser( ["name" => $data["name" ]], ["id" => $data["id" ]] ); return $return; } }
src/Domain/Users/Service/DeleteService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <?php namespace App \Domain \Users \Service ;use App \Domain \Users \Repository \UsersRepository ;final class DeleteService { private UsersRepository $db; public function __construct (UsersRepository $db) { $this ->db = $db; } public function deleteUser (array $data) : bool { $return = $this ->db->deleteUser(["id" => $data["id" ]]); return $return; } }
Repository
每個 Service
都會統一向 Repository
請求資料庫操作。
我們在目錄中建立 src/Domain/Users/Repository/UsersRepository.php
讓我們操作資料庫。
目錄大概會如下圖:1 2 3 4 5 6 . ├── src/ | ├── Domain/ | | └── Users/ | | └── Repository/ | | └── UsersRepository.php
src/Domain/Users/Repository/UsersRepository.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 <?php namespace App \Domain \Users \Repository ;use UnexpectedValueException ;use Medoo \Medoo ;class UsersRepository { private Medoo $db; public function __construct (Medoo $db) { $this ->db = $db; } public function getUsers (array | string $params = "*" , array | int $where = 1 , string $table = "Users" ) : array { try { return $this ->db->select($table, $params, $where); } catch (PDOException $e) { throw new UnexpectedValueException($e->getMessage()); } } public function insertUser (array $params = [], string $table = "Users" ) : int { try { $this ->db->insert($table, $params); return $this ->db->id(); } catch (PDOException $e) { throw new UnexpectedValueException($e->getMessage()); } } public function updateUser (array $params = [], array $where = [], string $table = "Users" ) : void { try { $this ->db->update($table, $params, $where); return ; } catch (PDOException $e) { throw new UnexpectedValueException($e->getMessage()); } } public function deleteUser (array $where = [], string $table = "Users" ) : void { try { $this ->db->delete($table, $where); return ; } catch (PDOException $e) { throw new UnexpectedValueException($e->getMessage()); } } }
修改配置
請修改我們 Slim-ADR
中的 config/routes.php
和 config/bootstrap.php
。
config/routes.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <?php declare (strict_types=1 );use Slim \Http \Response ;use Psr \Http \Message \ServerRequestInterface as Request ;use Slim \Routing \RouteCollectorProxy ;use Slim \App ;return function (App $app) { $app->options("[{routes.*}]" , function (Request $req, Response $res, array $args) :Response { return $res; }); $app->group("/api" , function (RouteCollectorProxy $app) { $app->get("/home[/]" , function (Request $req, Response $res, array $args) :Response { return $res->withJson("GET HOME" , 200 , JSON_UNESCAPED_UNICODE); }); $app->get("/home/{name}[/]" , function (Request $req, Response $res, array $args) :Response { return $res->withJson("GET Hello {$args['name']}!" , 200 , JSON_UNESCAPED_UNICODE); }); $app->group("/user" , function (RouteCollectorProxy $app) { $app->get("[/]" , \App\Action\Users\GetAction::class); $app->post("[/]" , \App\Action\Users\InsertAction::class); $app->patch("[/]" , \App\Action\Users\UpdateAction::class); $app->delete("[/]" , \App\Action\Users\DeleteAction::class); }); }); };
config/bootstrap.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <?php declare (strict_types=1 );use DI \ContainerBuilder ;use Symfony \Component \Dotenv \Dotenv ;use Slim \App ;require_once __DIR__ . '/../vendor/autoload.php' ;$containerBuilder = new ContainerBuilder(); $containerBuilder->addDefinitions(__DIR__ . '/container.php' ); $container = $containerBuilder->build(); $dotenv = $container->get(Dotenv::class); $dotenv->load(__DIR__ .'/../.env' ); $app = $container->get(App::class); (require __DIR__ . '/middleware.php' )($app); (require __DIR__ . '/routes.php' )($app); return $app;
表單驗證和統一回應格式 當我們設計好一個 ADR 模式的架構之後,肯定會有一些小問題,比如說:要怎麼檢驗 Client 傳遞過來的資料
,或是回應
的時候要怎麼統一格式
,讓前端可以判斷該項請求
是否為正確操作
。
表單驗證 那麼表單驗證
就誕生了,表單驗證顧名思義就是檢驗傳遞的資料型別是否符合後端需求,如果可以就放行操作,不行則返回哪些傳遞的資料是有問題的。以下是我自己封裝的表單驗證,此依賴存放在 src/Utils
之中。
src/Utils/Validation.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 <?php declare (strict_types=1 );namespace App \Utils ;class Validation { private array $_errors = []; public function validate (array $src = [], array $rules = []) { foreach ($src as $item => $item_value) { if (key_exists($item, $rules)) { foreach ($rules[$item] as $rule => $rule_value){ if (is_int($rule)) { $rule = $rule_value; } switch ($rule){ case 'required' : if (empty ($item_value) && $rule_value){ if ($item_value === 0 || $item_value === false || $item_value === '0' || $item_value === 0.00 ) { break ; } $this ->addError($item,ucwords($item). ' 無法為空' ); } break ; case 'minLen' : if (mb_strlen((string)$item_value) < $rule_value){ $this ->addError($item, ucwords($item). ' 最小長度應為 ' .$rule_value. ' 個字元' ); } break ; case 'maxLen' : if (mb_strlen((string)$item_value) > $rule_value){ $this ->addError($item, ucwords($item). ' 最大長度應為 ' .$rule_value. ' 個字元' ); } break ; case 'numeric' : if (!is_numeric($item_value) && $rule_value){ $this ->addError($item, ucwords($item). ' 應為數字' ); } break ; case 'float' : if (!is_float($item_value) && $rule_value){ $this ->addError($item, ucwords($item). ' 應為浮點數' ); } break ; case 'alpha' : if (!ctype_alpha($item_value) && $rule_value){ $this ->addError($item, ucwords($item). ' 應為字母' ); } break ; case 'space' : if (!ctype_space($item_value) && $rule_value){ $this ->addError($item, ucwords($item). ' 不應有空格' ); } break ; case 'email' : if (!filter_var($item_value, FILTER_VALIDATE_EMAIL) && $rule_value){ $this ->addError($item, ucwords($item). ' 不為Email格式' ); } break ; case 'same' : if ($item_value != $rule_value && $rule_value){ $this ->addError($item, ucwords($item). ' 輸入要一致' ); } break ; case 'id_number' : $map = [ 'A' =>10 ,'B' =>11 ,'C' =>12 ,'D' =>13 ,'E' =>14 ,'F' =>15 , 'G' =>16 ,'H' =>17 ,'I' =>34 ,'J' =>18 ,'K' =>19 ,'L' =>20 , 'M' =>21 ,'N' =>22 ,'O' =>35 ,'P' =>23 ,'Q' =>24 ,'R' =>25 , 'S' =>26 ,'T' =>27 ,'U' =>28 ,'V' =>29 ,'W' =>32 ,'X' =>30 , 'Y' =>31 ,'Z' =>33 ]; $strLen = strlen($item_value); $item_value = strtoupper($item_value); if (($strLen != 10 || preg_match("/^[A-Z][1-2][0-9]+$/" , $item_value) == 0 ) && $rule_value) { $this ->addError($item, ucwords($item). ' 不為身份證格式' ); break ; } $code = 0 ; for ($i = 0 ; $i < $strLen; $i++){ $symbol = substr($item_value,$i,1 ); if ($i == 0 ){ $tmp = $map[$symbol]; $code += intval($tmp/10 ) + ($tmp%10 )*9 ; }else if ($i == $strLen - 1 ){ $code += intval($symbol); }else { $code += intval($symbol) * (9 - $i); } } if ($code % 10 != 0 && $rule_value){ $this ->addError($item, ucwords($item). ' 不為身份證格式' ); } break ; } } } } } private function addError ($item, $error) { $this ->_errors[$item][] = $error; } public function error () { if (empty ($this ->_errors)) { return false ; } return $this ->_errors; } }
統一回應格式 我們通常設計 RESTful API 會給前端狀態碼
、資料
、訊息
當作他們請求是否成功的依據,並且可以透過狀態碼
去得知這是甚麼問題。通常我習慣將狀態碼寫在回應格式之中,而非改變http status code(單純只是習慣而已),如果有疑慮也會偏向與大家整合他們開發的習慣。以下是我封裝的 ResponseFormat.php
也是放在 src/Utils
目錄下。
src/Utils/ResponseFormat.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 <?php declare (strict_types=1 );namespace App \Utils ;class ResponseFormat { public int $code; public mixed $data; public mixed $message; public function format (int $status = 200 , mixed $data = null, mixed $message = null) : ResponseFormat { $this ->reset(); if (gettype($data) === 'string' ) { $this ->message = $data; $data = null ; $message = null ; } if ($data) { $this ->data = $data; } if ($message) { $this ->message = $message; } $this ->code = $status; return $this ; } private function reset () : void { $this ->code = 200 ; $this ->data = null ; $this ->message = null ; } }
使用方式 一開始我們通常會先在建構子上先傳遞這兩個依賴讓 php-di
幫助我們使用自動配裝。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private UsersRepository $repository;private ResponseFormat $res;private Validation $v;public function __construct (UsersRepository $repository, ResponseFormat $res, Validation $v) { $this ->repository = $repository; $this ->res = $res; $this ->v = $v; }
然後在 Service
的處理中,使用 $this->v->validate()
開啟驗證,並且查看 $this->v->error()
是否為 true,是的話就返回錯誤的資訊。$this->res->format()
就是我們封裝好的格式,第一個參數是狀態碼
、第二個是資料
、第三個是訊息
。當我們第二個參數是字串
的時候,格式會自動改成訊息
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public function deleteUser (array $data) : ResponseFormat { $this ->v->validate( ["ID" => (!empty ($data["id" ]) ? $data["id" ] : "" )], ["ID" => ["required" , "maxLen" => 11 ]] ); if ($this ->v->error()) { return $this ->res->format(401 , $this ->v->error(),"提交格式有誤!" ); } $this ->repository->deleteUser(["id" => $data["id" ]]); return $this ->res->format(200 , "Success" ); }
介紹完之後,我們可以將之前的四個 Service
套用上我們寫的依賴之中。
src/Domain/Users/Service/GetService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 <?php declare (strict_types=1 );namespace App \Domain \Users \Service ;use App \Domain \Users \Repository \UsersRepository ;use App \Utils \ResponseFormat ;final class GetService { private UsersRepository $repository; private ResponseFormat $res; public function __construct (UsersRepository $repository, ResponseFormat $res) { $this ->repository = $repository; $this ->res = $res; } public function getUsers () : ResponseFormat { $data = $this ->repository->getUsers(); return $this ->res->format(200 , $data); } }
src/Domain/Users/Service/InsertService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 <?php declare (strict_types=1 );namespace App \Domain \Users \Service ;use App \Domain \Users \Repository \UsersRepository ;use App \Utils \ResponseFormat ;use App \Utils \Validation ;final class InsertService { private UsersRepository $repository; private ResponseFormat $res; private Validation $v; public function __construct (UsersRepository $repository, ResponseFormat $res, Validation $v) { $this ->repository = $repository; $this ->res = $res; $this ->v = $v; } public function insertUser (array $data) : ResponseFormat { $this ->v->validate( [ "姓名" => (!empty ($data["name" ]) ? $data["name" ] : "" ), "密碼" => (!empty ($data["password" ]) ? $data["password" ] : "" ), ], [ "姓名" => ["required" , "maxLen" => 64 ], "密碼" => ["required" , "maxLen" => 64 ], ] ); if ($this ->v->error()) { return $this ->res->format(401 , $this ->v->error(),"提交格式有誤!" ); } $data["password" ] = md5($data["password" ]); $this ->repository->insertUser($data); return $this ->res->format(200 , "Success" ); } }
src/Domain/Users/Service/UpdateService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 <?php declare (strict_types=1 );namespace App \Domain \Users \Service ;use App \Domain \Users \Repository \UsersRepository ;use App \Utils \ResponseFormat ;use App \Utils \Validation ;final class UpdateService { private UsersRepository $repository; private ResponseFormat $res; private Validation $v; public function __construct (UsersRepository $repository, ResponseFormat $res, Validation $v) { $this ->repository = $repository; $this ->res = $res; $this ->v = $v; } public function updateUser (array $data) : ResponseFormat { $this ->v->validate( [ "ID" => (!empty ($data["id" ]) ? $data["id" ] : "" ), "姓名" => (!empty ($data["name" ]) ? $data["name" ] : "" ) ], [ "ID" => ["required" , "maxLen" => 11 ], "姓名" => ["required" , "maxLen" => 64 ] ] ); if ($this ->v->error()) { return $this ->res->format(401 , $this ->v->error(),"提交格式有誤!" ); } $this ->repository->updateUser( ["name" => $data["name" ]], ["id" => $data["id" ]] ); return $this ->res->format(200 , "Success" ); } }
src/Domain/Users/Service/DeleteService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 <?php declare (strict_types=1 );namespace App \Domain \Users \Service ;use App \Domain \Users \Repository \UsersRepository ;use App \Utils \ResponseFormat ;use App \Utils \Validation ;final class DeleteService { private UsersRepository $repository; private ResponseFormat $res; private Validation $v; public function __construct (UsersRepository $repository, ResponseFormat $res, Validation $v) { $this ->repository = $repository; $this ->res = $res; $this ->v = $v; } public function deleteUser (array $data) : ResponseFormat { $this ->v->validate( ["ID" => (!empty ($data["id" ]) ? $data["id" ] : "" )], ["ID" => ["required" , "maxLen" => 11 ]] ); if ($this ->v->error()) { return $this ->res->format(401 , $this ->v->error(),"提交格式有誤!" ); } $this ->repository->deleteUser(["id" => $data["id" ]]); return $this ->res->format(200 , "Success" ); } }
成果展示
用 postman 依序操作 插入(沒資料) -> 插入(有資料) -> 獲取
登入和Jwt 一般我們在使用 Http 這種 Stateless 的協議的時候,通常無法辨識我們的使用者的真實身分,除非藉由以下兩種方式。
使用 Cookie Session
,通常我們在登入的時候 Sever 會將使用者資料存放在 Session 的記憶體之中,並且分發 Cookie(夾帶SessionId) 給 Client,讓 Client 每次請求的時候將 Cookie 當作身分證連同傳送到 Server。
使用 Jwt
,我們在登入的時候 Server 會將使用者資料加密,並且存成特定格式返回給 Client(Server 不做儲存),並且每次我們在請求的時候都會從 Header 的 Authorization 欄位去查找有沒有這種 Token 的加密身分證。
由於 SPA、手機 APP 這種前後端分離的方式逐漸流行,Cookie Session 漸漸也開始被棄用了,其中不方便是一個原因,再來如果是分散式系統的話要儲存在記憶體只能依賴 Redis
這種記憶體型的資料庫
才能夠達成。
Jwt 介紹 Jwt的組成分成三部分。
Header
:通常存放加密的類型
和該Token的類型
。1 2 3 4 { "alg" : "SHA256" , "typ" : "JWT" }
Payload
:一般裡面會有我們使用者的訊息
、簽發者
、使用期限
…等資訊。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 { "iss" : "http://example.com" , "iat" : "120000000" , "exp" : "155555555" , "sub" : "1234567890" , "aud" : "all" , "nbf" : "130000000" , "jti" : "asd123" "user_info" : { "name" : "Bob" , "user_id" : 1 , "roles" : "admin" }, }
Signature
:base64UrlEncode(header)
、base64UrlEncode(payload)
、secret
三個部分加密而成。
安裝 安裝 lcobucci/jwt: ^4.0
的一個 Jwt 的依賴庫,本次實作使用 4.0
版本,因為不同版本所用的函數不同,所以安裝前請先確認版本是否正確。
1 composer require "lcobucci/jwt" : "^4.0"
產生公鑰和私鑰 我們使用 RSA 的加密方式來進行簽名。
1 2 3 4 openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -outform PEM -pubout -out public.pem
.env配置 原本的 .env.example
已經有配置好了,不過生產環境還是建議獨立生成一個公鑰私鑰以防萬一。
.env
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # JWT SETTINGS JWT_ISSUER=SLIM_4 JWT_LIFETIME=86400 JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- MIIBPQIBAAJBALSlDUfngNVzILQh0UDzg22Wd3NCvHrl1PMK+IxRoTQovLN3TQ8E oBgL7GqHTYSrnnADrV0JSgf8onbDzkvZoYcCAwEAAQJBALEY5w5JPZsFRViTlsww b/bt/qk3EgUCcWTcqpMWLA4vBBH7/guLZvyWG1U4Q63vgO1SSA7g+bwMvmDMCj6l fhECIQDc6/A9mYXirCwHL0iKb5o1R/ri4NqZYrcoGUrYhpntZQIhANFT7k4SffT0 3PSK1Pa2OcsnfuBMmqZcld3DP8+lCip7AiEAhRaZ9vIaxxBDwdxJTiSneLuxN6aP 6mGex0hdX43PA0UCIQDNZjD41LZZjYfeQPg1WZueF5QsnZ5GTaUUpIjRxF0UTwIh AMa/1Gkl/FUaiZaFm6KMysKHAeWg3YZudouHoDLDcDbl -----END RSA PRIVATE KEY-----" JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALSlDUfngNVzILQh0UDzg22Wd3NCvHrl 1PMK+IxRoTQovLN3TQ8EoBgL7GqHTYSrnnADrV0JSgf8onbDzkvZoYcCAwEAAQ== -----END PUBLIC KEY-----"
建立 Jwt 的函式庫 基本上就是實現我們的建立 Jwt
、驗證 Jwt
、解析 Jwt
的方法。
src/Utils/Jwt.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 <?php declare (strict_types=1 );namespace App \Utils ; use UnexpectedValueException ;use InvalidArgumentException ;use Lcobucci \JWT \Configuration ;use Lcobucci \JWT \Signer \Hmac \Sha256 ;use Lcobucci \JWT \Signer ;use Lcobucci \JWT \Signer \Key \InMemory ;use Lcobucci \JWT \Token \Plain ;use Lcobucci \JWT \Validation \Constraint \SignedWith ;use Lcobucci \JWT \Validation \Constraint \IssuedBy ;use Lcobucci \JWT \Validation \Constraint \IdentifiedBy ;use Lcobucci \JWT \Validation \Constraint \ValidAt ;use Lcobucci \Clock \SystemClock ;use DateTimeImmutable ;use DateTimeZone ;final class Jwt { private string $issuer; private int $lifetime; private Configuration $config; public function __construct ( string $issuer, int $lifetime, string $privateKey, string $publicKey ) { $this ->issuer = $issuer; $this ->lifetime = $lifetime; $this ->signer = new Signer\Rsa\Sha256(); $this ->publicKey = $publicKey; $this ->config = Configuration::forAsymmetricSigner( $this ->signer, InMemory::plainText($privateKey), InMemory::plainText($publicKey) ); } public function getLifetime () : int { return $this ->lifetime; } public function createJwt (array $info) : string { $issuedAt = new DateTimeImmutable(); return ($this ->config->builder() ->issuedBy($this ->issuer) ->permittedFor($this ->issuer) ->identifiedBy($this ->v5_UUID("0x752222" , "JWT_TOKEN" ), true ) ->issuedAt($issuedAt) ->canOnlyBeUsedAfter($issuedAt) ->expiresAt($issuedAt->modify("+{$this->lifetime} seconds" )) ->withClaim("info" , $info) ->getToken($this ->config->signer(), $this ->config->signingKey()) )->toString(); } public function createParsedToken (string $token) : Plain { return $this ->config->parser()->parse($token); } public function validateToken (string $accessToken) : bool { $token = $this ->createParsedToken($accessToken); $this ->config->setValidationConstraints(new SignedWith($this ->config->signer(), $this ->config->verificationKey())); $this ->config->setValidationConstraints(new IssuedBy($token->claims()->get("iss" ))); $this ->config->setValidationConstraints(new IdentifiedBy($token->claims()->get("jti" ))); $this ->config->setValidationConstraints(new ValidAt(new SystemClock(new DateTimeZone("Asia/Taipei" )))); $constraints = $this ->config->validationConstraints(); if (!$this ->config->validator()->validate($token, ...$constraints)) { return false ; } return true ; } public function v5_UUID (string $name_space, string $string) : string { $n_hex = preg_replace('/[^0-9A-Fa-f\-\(\)]/' , '' , $name_space); $binray_str = '' ; for ($i = 0 ; $i < strlen($n_hex); $i+=2 ) { if (!isset ($n_hex[$i+1 ])) { $binray_str .= chr(hexdec($n_hex[$i])); break ; } $binray_str .= chr(hexdec($n_hex[$i].$n_hex[$i+1 ])); } $hashing = sha1($binray_str . $string); return sprintf('%08s-%04s-%04x-%04x-%12s' , substr($hashing, 0 , 8 ), substr($hashing, 8 , 4 ), (hexdec(substr($hashing, 12 , 4 )) & 0x0fff ) | 0x5000 , (hexdec(substr($hashing, 16 , 4 )) & 0x3fff ) | 0x8000 , substr($hashing, 20 , 12 ) ); } }
容器 Jwt 配置 config/container.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?php use App \Utils \Jwt ;use Slim \Http \Factory \DecoratedResponseFactory ;return [ Jwt::class => function () { return new Jwt($_ENV["JWT_ISSUER" ], (int)$_ENV["JWT_LIFETIME" ], $_ENV["JWT_PRIVATE_KEY" ], $_ENV["JWT_PUBLIC_KEY" ]); }, DecoratedResponseFactory::class => function (ContainerInterface $container) { return $container->get(App::class)->getResponseFactory(); } ];
登入和登出 當我們完成了 Jwt 的函式庫之後,我們就可以來實現登入登出的功能。
Action 請建立 src/Action/Auth
目錄,並且新增 src/Action/Auth/LoginAction.php
和 src/Action/Auth/LogoutAction.php
。
src/Action/Auth/LoginAction.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <?php declare (strict_types=1 );namespace App \Action \Auth ;use Slim \Http \Response ;use Psr \Http \Message \ServerRequestInterface as Request ;use App \Domain \Auth \Service \LoginService ;final class LoginAction { private LoginService $service; public function __construct (LoginService $service) { $this ->service = $service; } public function __invoke (Request $req, Response $res) : Response { $data = (array )$req->getParsedBody(); $return = $this ->service->login($data); return $res->withJson($return, 200 , JSON_UNESCAPED_UNICODE); } }
src/Action/Auth/LogoutAction.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <?php declare (strict_types=1 );namespace App \Action \Auth ;use Slim \Http \Response ;use Psr \Http \Message \ServerRequestInterface as Request ;use App \Domain \Auth \Service \LogoutService ;final class LogoutAction { private LogoutService $service; public function __construct (LogoutService $service) { $this ->service = $service; } public function __invoke (Request $req, Response $res) : Response { $return = $this ->service->logout(); return $res->withJson($return, 200 , JSON_UNESCAPED_UNICODE); } }
Service 然後在 src/Domain/Auth
的目錄下新增相對應的 Service src/Domain/Auth/Service/LoginService.php
和 src/Domain/Auth/Service/LogoutService.php
。src/Domain/Auth/Service/LoginService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 <?php declare (strict_types=1 );namespace App \Domain \Auth \Service ;use App \Domain \Auth \Repository \AuthRepository ;use App \Utils \ResponseFormat ;use App \Utils \Validation ;use App \Utils \Jwt ;final class LoginService { private AuthRepository $repository; private ResponseFormat $res; private Validation $v; private Jwt $jwt; public function __construct (AuthRepository $repository, ResponseFormat $res, Validation $v, Jwt $jwt) { $this ->repository = $repository; $this ->res = $res; $this ->v = $v; $this ->jwt = $jwt; } public function login (array $data) : ResponseFormat { $this ->v->validate( [ "帳號" => (!empty ($data["name" ]) ? $data["name" ] : "" ), "密碼" => (!empty ($data["password" ]) ? $data["password" ] : "" ) ], [ "帳號" => ["required" , "maxLen" => 64 ], "密碼" => ["required" , "maxLen" => 64 ] ] ); if ($this ->v->error()) { return $this ->res->format(401 , $this ->v->error(),"提交格式有誤!" ); } $data["password" ] = md5($data["password" ]); $data = $this ->repository->login("*" , $data); if (!$data) { return $this ->res->format(400 , "帳號或密碼錯誤" ); } $token = $this ->jwt->createJwt(["name" => $data[0 ]["name" ], "role" => "admin" ]); $lifetime = $this ->jwt->getLifetime(); $result = [ "access_token" => $token, "token_type" => "Bearer" , "expires_in" => $lifetime, ]; return $this ->res->format(200 , $result); } }
src/Domain/Auth/Service/LogoutService.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <?php declare (strict_types=1 );namespace App \Domain \Auth \Service ;use App \Utils \ResponseFormat ;final class LogoutService { private ResponseFormat $res; public function __construct (ResponseFormat $res) { $this ->res = $res; } public function logout () : ResponseFormat { return $this ->res->format(200 , "Success" ); } }
Repository 最後在 src/Domain/Auth
的目錄下新增相對應的 Repository 的 src/Domain/Auth/Repository/AuthRepository.php
。
src/Domain/Auth/Repository/AuthRepository.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 <?php declare (strict_types=1 );namespace App \Domain \Auth \Repository ;use UnexpectedValueException ;use Medoo \Medoo ;class AuthRepository { private Medoo $db; public function __construct (Medoo $db) { $this ->db = $db; } public function login (array | string $params = "*" , array | int $where = 1 , string $table = "Users" ) : array { try { return $this ->db->select($table, $params, $where); } catch (PDOException $e) { throw new UnexpectedValueException($e->getMessage()); } } }
新增路由 config/routes.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?php return function (App $app) { $app->options("[{routes.*}]" , function (Request $req, Response $res, array $args) :Response { return $res; }); $app->group("/api" , function (RouteCollectorProxy $app) { $app->post("/login[/]" , \App\Action\Auth\LoginAction::class); $app->get("/logout[/]" , \App\Action\Auth\LogoutAction::class); }); };
Jwt驗證是否登入 由於我們完成了登入登出路由,但是我們必須在 Action
操作之前提前解析 Jwt Token
,於是我們就會使用到 Middleware
來預先處理Token
。
Middleware 實現 src/Middleware/JwtAuth.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 <?php declare (strict_types=1 ); namespace App \Middleware ; use App \Utils \Jwt ;use App \Utils \ResponseFormat ;use Slim \Http \Factory \DecoratedResponseFactory ;use Psr \Http \Message \ResponseInterface ;use Psr \Http \Message \ServerRequestInterface ;use Psr \Http \Server \MiddlewareInterface ;use Psr \Http \Server \RequestHandlerInterface ; final class JwtAuth implements MiddlewareInterface { private Jwt $jwt; private DecoratedResponseFactory $responseFactory; private ResponseFormat $response; public function __construct (Jwt $jwt, DecoratedResponseFactory $responseFactory, ResponseFormat $response) { $this ->jwt = $jwt; $this ->responseFactory = $responseFactory; $this ->response = $response; } public function process (ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface { $authorization = explode(" " , (string)$request->getHeaderLine("Authorization" )); $token = $authorization[1 ] ?? "" ; if (!$token || !$this ->jwt->validateToken($token)) { return $this ->responseFactory->createResponse() ->withJson($this ->response->format(403 , "請先登入" ), 200 , JSON_UNESCAPED_UNICODE); } $parsedToken = $this ->jwt->createParsedToken($token); $request = $request->withAttribute("token" , $parsedToken); $request = $request->withAttribute("info" , $parsedToken->claims()->get("info" )); return $handler->handle($request); } }
新增 Middleware 到路由 config/routes.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?php return function (App $app) { $app->options("[{routes.*}]" , function (Request $req, Response $res, array $args) :Response { return $res; }); $app->group("/api" , function (RouteCollectorProxy $app) { $app->get("/logout[/]" , \App\Action\Auth\LogoutAction::class)->add(\App\Middleware\JwtAuth::class); }); };
成果展示
使用 postman 進行請求操作,依序是 登出(無 Jwt) -> 登入 -> 登出(有 Jwt)
結語 當前目錄 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 . ├── config/ 應用配置目錄 | ├── bootstrap.php 啟動文件 | ├── container.php 依賴注入容器文件 | ├── middleware.php 中間件文件 | ├── routes.php 路由文件 | └── settings.php 配置設定文件 ├── docker/ Docker 相關目錄 | ├── nginx/ Nginx 目錄 │ | └── default.conf Nginx 配置文件 | └── php/ PHP 目錄 │ └── Dockerfile PHP 容器配置文件 ├── public/ 網站根目錄 │ └── index.php 入口文件 ├── src/ PHP 原始碼目錄(App Namespace) │ ├─Action/ │ │ ├─Auth/ │ │ │ ├─LoginAction.php │ │ │ └─LogoutAction.php │ │ └─Users/ │ │ ├─GetAction.php │ │ ├─InsertAction.php │ │ ├─UpdateAction.php │ │ └─DeleteAction.php │ ├─Domain/ │ │ ├─Auth/ │ │ │ ├─Repository/ │ │ │ │ └─AuthRepository.php │ │ │ └─Service/ │ │ │ ├─LoginService.php │ │ │ └─LogoutService.php │ │ └─Users/ │ │ ├─Repository/ │ │ │ └─UsersRepository.php │ │ └─Service/ │ │ ├─GetService.php │ │ ├─InsertService.php │ │ ├─UpdateService.php │ │ └─DeleteService.php │ ├─Middleware/ │ │ └─JwtAuth.php │ └─Utils/ │ ├─Jwt.php │ ├─Validation.php │ └─ResponseFormat.php ├── vendor/ Composer 目錄 ├── .env 系統變數 ├── .gitignore Git 忽略文件 ├── composer.json Composer 配置文件 ├── composer.lock Composer 鎖定文件 ├── docker-compose.yml Docker 容器配置文件 └── init.sql DB Schema
未來工作
目前我們已經將 Slim 4 的 ADR 框架建立完成,並且實現 表單驗證
、統一回應格式
、Jwt
、登入登出
功能。
下一篇(DevOps篇),我將會新增 Unit Testing
、E2E Testing
、Github Actions
等相關功能。
本文範例程式碼在 GitHub 上的 POABOB/Slim-ADR 。
參考資料
Model、View、Controller 三分天下
Slim 4 搭建 RESTful API
Slim 4 配置 JSON Web Token
Slim 4 - Tutorial
bxcodec/go-clean-arch