Laravel 之道特別篇 3: 模型式 Web API 介面開發流程
轉載https://laravel-china.org/articles/20183
導語
這篇文章是我使用 Laravel 開發 Web Api 總結的自己一套開發流程。
記錄在此,方便回顧。
同時,將自己的經驗分享給大家。
這套流程,可能稍微複雜,對軟體設計方面支援還有待提高。
歡迎童鞋們,提出指正意見。
模型式 Web Api 介面開發。
先理一下概念,模型式的意思是指:將控制器邏輯做高度封裝,放到模型父類中,通過控制器鏈式呼叫,簡化程式碼。
關於 Api 介面的資料流程
呼叫我們自己開發的 Web Api。無非就兩個概念:Request(請求) 和 Response(響應)
結合請求引數,請求型別(操作類–增、刪、改,獲取類–查)等問題進行詳細分析,所有介面基本分為以下流程
第一步,對請求進行 身份驗證,這步操作基本由 JWT 或其他身份驗證方式負責,我們基本不用對其進行開發,儘管用就行
第二步,身份通過後,我們首先應該做的工作就是 表單驗證
第三步,表單資料驗證通過後,有些情況不能直接對錶單資料進行使用,還需要對其 進行加工
第四步,拿到加工好的表單資料後,我們才能利用這些資料進行 資料庫儲存、資料庫查詢、第三方請求等操作
第五步,如果是獲取類請求,我們還需要對資料庫進行查詢操作,資料庫查詢可能涉及模型關聯、分頁等複雜操作,有的還需要請求第三方
第六步,如果是操作類請求,我們要 生成資料模型,儲存資料,更改資料,刪除資料等
第七步,生成響應資料,在我們剛拿到響應資料時,可能資料不是前端需要的格式,我們還需要對其進行最後的 加工
第八步,傳送響應資料給前端
承上啟下
上面是我對一個請求週期的一些見解,不足之處,請多多指正。下面我們具體看一下如何進行 模型式開發
模型父類 Model.php
首先,我們需要一個模型父類,而這個父類繼承 Laravel 的模型基類。具體模型子類則繼承自此模型父類。說白一點,就是在模型繼承中插入一個模型類,能夠做到修改 Laravel 模型基類,而不影響其它開發者使用模型基類。
控制器大部分涉及到的程式碼邏輯,全部封裝到這個模型父類中,通過 控制器鏈式呼叫,模型具體配置邏輯程式碼所需引數。從而簡化了程式碼,規範了控制器程式碼邏輯流程,同時,實現邏輯方法的高度複用。
具體 模型父類 的程式碼內容如下(稍微有些複雜):
app/Model.php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model as BaseModel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Http\JsonResponse;
use App\Utils\Helper;
use App\Utils\Response;
class Model extends BaseModel
{
// ------------------------ 定義基礎屬性 --------------------------
/**
* 載入輔助 Trait 類:可自行修改定義,根據專案情況可多載入幾個
*/
use Helper, Response;
/**
* 資料容器:存放請求資料、模型生成臨時資料、響應資料等
*
* @var array
*/
protected $data = [];
/**
* 應該被轉換成原生型別的屬性。
*
* @var array
*/
protected $casts = [
'created_at' => 'string',
'updated_at' => 'string'
];
// ------------------------ 定義基礎資料容器操作方法 --------------------------
/**
* 儲存資料:向資料容器中合併資料,一般在方法結尾處使用
*
* @param array $data 經過系統 compact 方法處理好的陣列
* @return Model
*/
protected function compact(array $data) :Model
{
$this->data = $data + $this->data;
return $this;
}
/**
* 獲取資料:從資料容器中獲取資料,一般在方法開始,或方法中使用
*
* @param string $keys 支援點語法,訪問多維陣列
* @param null $default
* @return array|mixed|null
*/
protected function extract(string $keys = '', $default = null)
{
$result = $this->data;
if ($keys == '') {
return $result;
}
$keys = explode('.', $keys);
foreach ($keys as $key) {
if (!array_key_exists($key, $result)) {
return $default;
}
$result = $result[$key];
}
return $result;
}
/**
* 資料容器對外介面
*
* @param string $keys
* @param null $default
* @return array|mixed|null
*/
public function data(string $keys = '', $default = null)
{
return $this->extract($keys, $default);
}
// ------------------------ 請求開始:驗證資料,加工資料 --------------------------
/**
* 請求:使用模型的入口
*
* @param string $method 控制器方法名稱
* @param array $request 請求的原始資料
* @param BaseModel|null $auth 核心入口模型,一般由授權模型獲得。
* @return Model
*/
protected function request(string $method, array $request = [], BaseModel $auth = null) :Model
{
return $this->compact(compact('method', 'request', 'auth'))
->validate()
->process();
}
/**
* 驗證資料:根據 rule 方法返回的規則,進行資料驗證
*
* @return Model
*/
protected function validate() :Model
{
$rules = $this->rule();
if (!$rules) return $this;
$validator = Validator::make($this->extract('request'), $rules['rules'], config('message'), $rules['attrs']);
if (isset($rules['sometimes']) && count($rules['sometimes'])) {
foreach ($rules['sometimes'] as $v) {
$validator->sometimes(...$v);
}
}
$validator->validate();
return $this;
}
/**
* 資料驗證規則:此方法將在 validate 方法中被呼叫,用以獲取驗證規則
*
* 注:此方法需根據情況,在子模型中重寫
*
* @return array
*/
protected function rule() :array
{
return [];
}
/**
* 加工請求:請求資料驗證通過後,用此方法進行資料加工與方法派遣操作
*
* 注:此方法需根據情況,在子模型中重寫
*
* @return Model
*/
protected function process() :Model
{
return $this;
}
// ------------------------ 操作類請求:對映欄位、生成模型、儲存資料 --------------------------
/**
* 生成資料模型:此方法定義一個統一名為 model 的對外介面,建議在控制器中呼叫
*
* @return Model
*/
public function model() :Model
{
return $this->createDataModel();
}
/**
* 生成資料模型(內建方法)
*
* @return Model
*/
protected function createDataModel() :Model
{
$request = $this->extract('request');
$maps = $this->map();
if (!$maps) return $this;
foreach (array_keys($request) as $v) {
if (array_key_exists($v, $maps)) {
$k = $maps[$v];
$this->$k = $request[$v];
}
}
return $this;
}
/**
* 資料對映:請求欄位 => 資料庫欄位 的對映,用以生成含有資料的資料表模型
*
* 注:此方法需根據情況,在子模型中重寫
*
* @return array
*/
protected function map() :array
{
return [];
}
/**
* 儲存模型:同 save 方法,可重寫 save 邏輯,而不影響原 save,保證其它模組正常工作
*
* @param array $options
* @return Model
*/
public function reserve(array $options = []) :Model
{
if ($this->save($options)) {
return $this;
} else {
DB::rollBack();
abort(422, '儲存失敗');
}
}
// ------------------------ 獲取類請求:根據規則和對映獲取資料,加工資料,返回資料 --------------------------
/**
* 取出資料:從資料庫獲取資料,建議在控制器中呼叫
*
* @return Model
*/
public function fetch() :Model
{
$response = [];
$map = $this->fetchMap();
if (!$map) return $this;
$gathers = $this->isShow()->getChainRule($this, $map['chain']);
foreach ($gathers as $k => $gather) {
foreach ($map['data'] as $_k => $v) {
if (is_array($v)) {
foreach ($gather->$_k as $n => $relevancy) {
foreach ($v as $m => $_v) {
$response[$k][$_k][$n][$m] = $this->getDataRule($relevancy, explode('.', $_v));
}
}
} else {
$response[$k][$_k] = $this->getDataRule($gather, explode('.', $v));
}
}
}
return $this->compact(compact('response'))->epilogue();
}
/**
* 區分展示詳情或展示列表
*
* @return Model
*/
protected function isShow() :Model
{
$isShow = $this->id ? true : false;
return $this->compact(compact('isShow'));
}
/**
* 取資料的對映規則
*
* 注:此方法需根據情況,在子模型中重寫
*
* @return array
*/
protected function fetchMap() :array
{
return [];
}
/**
* 遞迴鏈式操作:封裝查詢構造器,根據陣列引數呼叫查詢構造順序。
*
* @param array $chains
* @return object
*/
protected function getChainRule($model, array $chains)
{
if (!$chains) {
if ($this->extract('isShow')) {
return Collection::make([$model]);
}
return $model->get();
}
$chain = array_shift($chains);
foreach ($chain as $k => $v) {
$model = $model->$k(...$v);
}
if ($k == 'paginate') {
$page = [
'total' => $model->total(),
'lastPage' => $model->lastPage(),
];
$this->compact(compact('page'));
return $model;
} else if ($chains) {
return $this->getChainRule($model, $chains);
} else if ($this->extract('isShow')) {
return Collection::make([$model]);
} else {
return $model->get();
}
}
/**
* 遞迴取值:取關聯模型的資料
*
* @return mixed
*/
protected function getDataRule($gather, array $rules)
{
$rule = array_shift($rules);
$gather = $gather->$rule;
if ($rules) {
return $this->getDataRule($gather, $rules);
} else {
return $gather;
}
}
// ------------------------ 響應資料 --------------------------
/**
* 傳送響應:請在控制器呼叫,操作類請求傳 message,獲取類請求不要傳 message
*
* @param null $message
* @return JsonResponse
*/
public function response($message = null) :JsonResponse
{
if ($message !== null) {
$this->setMessage($message);
}
return $this->send();
}
/**
* 操作類請求設定操作成功的 message
*
* @return Model
*/
protected function setMessage($message = null) :Model
{
$response = [
'code' => 200,
'message' => $message !== null ? $message : '操作成功',
];
return $this->compact(compact('response'))->epilogue();
}
/**
* 收尾:對獲取的資料進行最後加工
*
* @return Model
*/
protected function epilogue() :Model
{
return $this;
}
/**
* 傳送資料
*
* @return JsonResponse
*/
protected function send() :JsonResponse
{
return response()->json($this->extract('response'));
}
/**
* Handle dynamic method calls into the model.
*
* @param string $method
* @param array $parameters
* @return mixed
*/
public function __call($method, $parameters)
{
if (in_array($method, ['increment', 'decrement', 'request'])) {
return $this->$method(...$parameters);
}
return $this->newQuery()->$method(...$parameters);
}
}
來張圖,加深一下認識
需要的前期準備
增加 message.php 配置檔案
相信仔細看的童鞋,validate 方法需要一箇中文配置檔案 message.php
具體程式碼如下:
config/message.php
<?php
return [
'accepted' => ':attribute必須為yes、on、 1、或 true',
'after' => ':attribute必須為:date之後',
'alpha' => ':attribute必須完全是字母的字元',
'alpha_dash' => ':attribute應為字母、數字、破折號( - )以及下劃線( _ )',
'alpha_num' => ':attribute必須完全是字母、數字',
'array' => ':attribute必須為陣列',
'before' => ':attribute必須為:date之後',
'between' => ':attribute大小必須在:min與:max之間',
'confirmed' => '兩次:attribute不一致',
'date' => ':attribute不是日期格式',
'date_format' => ':attribute必須是:format格式',
'different:field' => ':attribute不能與:field相等',
'email' => ':attribute不是郵箱格式',
'integer' => ':attribute必須為整數',
'max' => ':attribute最大為:max',
'min' => ':attribute最小為:min',
'numeric' => ':attribute必須為數字',
'regex' => ':attribute格式錯誤',
'required' => ':attribute不能為空',
'required_if' => ':attribute不能為空',
'required_with' => ':attribute不能為空',
'required_with_all' => ':attribute不能為空',
'required_without' => ':attribute不能為空',
'required_without_all' => ':attribute不能為空 ',
'size' => ':attribute必須等於:value',
'string' => ':attribute必須為字串',
'unique' => ':attribute已存在',
'exists' => ':attribute不存在',
'json' => ':attribute必須是JSON字串',
'image' => ':attribute必須是一個影象',
'url' => ':attribute必須是合法的URL',
];
如圖:
修改 App\Exceptions\Handler 類。
主要攔截 表單驗證 異常,通過 Api 返回的方式,傳送給前端
app/Exceptions/Handler.php
<?php
namespace App\Exceptions;
use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that are not reported.
*
* @var array
*/
protected $dontReport = [
//
];
/**
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array
*/
protected $dontFlash = [
'password',
'password_confirmation',
];
/**
* Report or log an exception.
*
* @param \Exception $exception
* @return void
*/
public function report(Exception $exception)
{
parent::report($exception);
}
/**
* 公用返回格式
* @param string $message
* @param int $code
* @return JsonResponse
*/
protected function response(string $message, int $code) :JsonResponse
{
$response = [
'code' => $code,
'message' => $message,
];
return response()->json($response, 200);
}
/**
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @param \Exception $exception
* @return Object
*/
public function render($request, Exception $exception)
{
// 攔截表單驗證異常,修改返回方式
if ($exception instanceof ValidationException) {
$message = array_values($exception->errors())[0][0];
return $this->response($message, 422);
}
return parent::render($request, $exception);
}
}
如圖
具體使用,我們來舉個例子
資料表結構如下圖
這是一個關於文章的管理資料結構,其中文章基本資訊和文章內容是分離兩個表,做一對一關係;而文章與評論是一對多關係。這樣做的好處是,獲取文章列表時,無需查文章內容,有助於提高查詢速度,因為內容一般是很大的。
先看一下模型類和控制器的位置
Api 路由
routes/api.php
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
// Laravel 自帶路由,不用管
Route::middleware('auth:api')->get('/user', function (Request $request) {
return $request->user();
});
// 文章管理 REST API。
Route::apiResource('article', 'ArticleController');
控制器程式碼
app/Http/Controllers/ArticleController.php
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ArticleController extends Controller
{
/**
* Display a listing of the resource.
*
* @return JsonResponse
*/
public function index(Request $request) :JsonResponse
{
return Article::request('index', $request->all())
->fetch()
->response();
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return JsonResponse
*/
public function store(Request $request) :JsonResponse
{
return Article::request('store', $request->all())
->model()
->reserve()
->response('儲存成功');
}
/**
* Display the specified resource.
*
* @param Article $article
* @return JsonResponse
*/
public function show(Request $request, Article $article) :JsonResponse
{
return $article->request('show', $request->all())
->fetch()
->response();
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param Article $article
* @return JsonResponse
*/
public function update(Request $request, Article $article) :JsonResponse
{
return $article->request('update', $request->all())
->model()
->reserve()
->response('修改成功');
}
/**
* Remove the specified resource from storage.
*
* @param Article $article
* @throws
* @return JsonResponse
*/
public function destroy(Article $article) :JsonResponse
{
return $article->request('destroy', [])
->delete()
->response('刪除成功');
}
}
Article 模型程式碼
app/Models/Article.php
<?php
namespace App\Models;
use App\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\DB;
class Article extends Model
{
/**
* 與 Content 一對一
*
* @return Relation
*/
public function content() :Relation
{
return $this->hasOne(Content::class)->withDefault();
}
/**
* 與 Comment 一對多
*
* @return Relation
*/
public function comments() :Relation
{
return $this->hasMany(Comment::class);
}
/**
* 資料驗證規則:此方法將在 validate 方法中被呼叫,用以獲取驗證規則
*
* @return array
*/
protected function rule() :array
{
switch ($this->extract('method')) {
case 'store':
return [
'rules' => [
'title' => 'required|string|max:140',
'content' => 'required|string',
],
'attrs' => [
'title' => '文章標題',
'content' => '文章內容',
]
];
break;
case 'update':
return [
'rules' => [
'title' => 'required_without:content|string|max:140',
'content' => 'required_without:title|string',
],
'attrs' => [
'title' => '文章標題',
'content' => '文章內容',
]
];
break;
case 'index':
case 'show':
return [
'rules' => [
'page' => 'required|integer|min:1',
'num' => 'sometimes|integer|min:1',
],
'attrs' => [
'page' => '頁碼',
'num' => '每頁數量',
]
];
break;
}
return [];
}
/**
* 加工請求:請求資料驗證通過後,用此方法進行資料加工與方法派遣操作
*
* @return Model
*/
protected function process() :Model
{
switch ($this->extract('method')) {
case 'store':
case 'update':
$request = array_map(function ($item) {
return trim($item);
}, $this->extract('request'));
return $this->compact(compact('request'));
break;
}
return $this;
}
/**
* 資料對映:請求欄位 => 資料庫欄位 的對映,用以生成含有資料的資料表模型
*
* @return array
*/
protected function map() :array
{
return [
'title' => 'title',
];
}
/**
* 儲存模型:同 save 方法,可重寫 save 邏輯,而不影響原 save,保證其它模組正常工作
*
* @param array $options
* @return Model
*/
public function reserve(array $options = []) :Model
{
DB::beginTransaction();
if (
$this->save($options)
&&
$this->content->request('store', $this->extract('request'))
->model()
->save()
) {
DB::commit();
return $this;
} else {
DB::rollBack();
abort(422, '儲存失敗');
}
}
/**
* 刪除
*
* @return $this|bool|null
* @throws \Exception
*/
public function delete()
{
DB::beginTransaction();
if (
$this->content->delete()
&&
parent::delete()
) {
DB::commit();
return $this;
} else {
DB::rollBack();
abort(422, '刪除失敗');
}
}
/**
* 取資料的對映規則
*
* @return array
*/
protected function fetchMap() :array
{
switch ($this->extract('method')) {
case 'index':
return [
'chain' => [
['paginate' => [$this->extract('request.num', 10)]]
],
'data' => [
'id' => 'id',
'title' => 'title',
'c_time' => 'created_at',
'u_time' => 'updated_at',
]
];
break;
case 'show':
return [
'chain' => [
['load' => ['content']],
['load' => [['comments' => function ($query) {
$paginate = $query->paginate($this->extract('request.num', 10));
$page = [
'total' => $paginate->total(),
];
$this->compact(compact('page'));
}]]],
],
'data' => [
'id' => 'id',
'title' => 'title',
'content' => 'content.content',
'comments' => [
'id' => 'id',
'comment' => 'comment',
],
'c_time' => 'created_at',
'u_time' => 'updated_at',
]
];
break;
}
return [];
}
/**
* 收尾:對獲取的資料進行最後加工
*
* @return Model
*/
protected function epilogue() :Model
{
switch ($this->extract('method')) {
case 'index':
$response = [
'code' => 200,
'message' => '獲取成功',
'data' => $this->extract('response'),
'total' => $this->extract('page.total'),
'lastPage' => $this->extract('page.lastPage'),
];
return $this->compact(compact('response'));
break;
case 'show':
$response = ['comments' => [
'total' => $this->extract('page.total'),
'data' => $this->extract('response.0.comments')
]] + $this->extract('response.0');
return $this->compact(compact('response'));
break;
}
return $this;
}
}
Content 模型
app/Models/Content.php
<?php
namespace App\Models;
use App\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
class Content extends Model
{
/**
* @return Relation
*/
public function article() :Relation
{
return $this->belongsTo(Article::class);
}
/**
* 資料對映:請求欄位 => 資料庫欄位 的對映,用以生成含有資料的資料表模型
*
* @return array
*/
protected function map() :array
{
return [
'content' => 'content',
];
}
}
Comment 模型
app/Models/Comment.php
<?php
namespace App\Models;
use App\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
class Comment extends Model
{
/**
* 與 Article 多對一
*
* @return Relation
*/
public function article() :Relation
{
return $this->belongsTo(Article::class);
}
}
說一點
上面程式碼,我沒有對 評論 寫控制器,測試時,是在資料庫手動新增的評論
最後,我們看一下 postman 檢測結果