寫這篇文章也快過去快一年啦,這一年積累了很多新的想法,後續的專案應該不會使用 dingo/api 而是使用自己寫的擴充套件 (有興趣的話點我) 並結合 laravel 自身的能力來進行 api 的開發了。
我時常覺得後端應該關心的是資料,而不是業務。
因此我希望能夠在資料的基礎上編寫一套介面 能夠滿足h5端、pc端、ios/android端、包括小程式端等等80%的需求
laravel + dingo/api 對於api開發來說已經足夠友好了,因此選擇在它的基礎上構建。
預備的知識
- laravel
- RESTful
- dingo/api
這是根據下面的想法總結出的一份demo,持續更新中
https://github.com/weiwenhao/restful-demo
查詢Filter
排序
排序欄位的選擇在網上有很多種
?sort_field=created_at&sort_order=asc
?order=+created
-
?sort_by=created_at&order=asc
...這裡選擇在第三種方式,在可讀性和資料處理上更加方便
url: http://api.test/api/posts?sort_by=created_at&order=desc
laravel: $query->orderBy(request()->get('sort_by', 'id'), request()->get('order', 'desc'))
分頁
對於分頁來說 offset/limit
和page/per_page
又是兩個糾結的選擇
常見的分頁需求有兩種,一種時普通的ajax分頁,另外一種是下拉載入更多分頁
ajax分頁相比於載入更多 通常需要一個total
因此選擇能同時適應兩種分頁需求的 page/per_page
,laravel和dingo/api對該方式的支援也足夠好
url: http://maxwei.me/api/posts?per_page=3&page=2
laravel: $query->paginate(request()->get('per_page', 15))->appends(request()->except('page'))
返回結果中的links示例:
"links": {
"next": "http://api.test/api/posts?order=asc&page=3",
"previous": "http://mp.test/api/diaries?order=asc&page=1"
},
欄位篩選
api: api.test/users?fields=id,nickname,avatar
對於user表中的phone,password欄位推薦使用Model的hidden屬性隱藏。
laravel: !is_null(request()->get('fields')) && $query->addSelect(explode(',', request()->get('fields')));
transform()的常用寫法和fields有一定的衝突,還沒有找到比較優雅的解決方案。
where篩選
當我們只想要狀態為1的文章時 我希望可以這麼做
url: http://api.test/api/posts?status=1
當我想要標籤id為1, 2的文章時則這樣
url: http://api.test/api/posts?tag_id=1,2
當我... 夠了,簡單點是我所追求的,我不希望去建立一些規則滿足模糊查詢、notIn、orWhere、巢狀where等等。這不具有通用性,如果需要可以建立一些特定的路由去滿足這些條件即可。
laravel:
$where = ['status', 'tag_id'] //這是我希望能夠被篩選的欄位
foreach ($where as $item) {
if (is_null($value = request()->get($item))) {
continue;
}
if (str_contains($value, ',')) {
$query->whereIn($item, explode(',', $value));
} else {
$query->where($item, '=', $value);
}
}
資源巢狀
有如下兩種需求場景
- 獲取某個使用者/或標籤下的所有文章
- 獲取首頁的精選文章
我希望這兩種情況都能透過一個index()方法得到解決,因此我這樣做
#api.php
// 主資源路由
$api->resource('posts', 'PostController')
// 多對多關係巢狀路由
$api->get('tags/{tag}/posts', function ($id) {
$tags = \App\Models\Tag::findOrFail($id);
return app()->call('App\Http\Controllers\Api\PostController@index', ['query' => $tags->posts()]);
});
// 一對多關係巢狀路由
$api->get('users/{user}/posts', function ($id) {
$user = \App\Models\User::findOrFail($id);
return app()->call('App\Http\Controllers\Api\PostController@index', ['query' => $user->posts()]);
})
// 上面的程式碼非常的有規律,可以進行一次封裝,而不是這樣不行的重複解析。laravel5.6支援的路由模型注入是個不錯的注意,但是dingo/api目前還不支援
// 別忘了在你模型中定義相應的關聯關係
# PostController.php
public function index($query = null)
{
// parseFilter是我封裝的一個用來解析通用引數的方法
$paginator = $this->parseFilter($query ?? Post::query());
return $this->response->paginator($paginator, new PostTransformer());
}
資源關聯
dingo/api + Fractal 對資源關聯處理非常優雅,並且很好的解決了n+1 問題。
假設兩個需求
- 當我取出多個文章資源時我希望能夠關聯它們的作者。
url: http://api.test/posts?include=user:field(id|name|avatar)
- 取出一個社群資源並附帶幾名活躍的使用者資源,以及這些活躍使用者最近發表過的3篇文章時
url:http://api.test/hubs/1?include=hot_users:limit(3).posts:fields(id|title):limit(3)
這大概就是我非常喜歡fractal而遲遲不肯使用laravel5.5的resources的原因, 因為它制定出了一套include的規則和相應的程式碼處理,使得程式碼的偶合性非常低。
include引數的詳細使用方式 請參考dingo/api文件 和 fractal文件 https://fractal.thephpleague.com/
對於上面的需求我們可以這麼做
# PostTransformer.php
public function includeUser(Post $post, ParamBag $params)
{
// fractal會幫我們解析include中的引數,並注入到 $params中。因此我們直接使用
$user = $post->user()->select($params[fields] ?? '*')->firstOrFail();
return $this->item($user, new UserTransformer());
}
# HubTransformer.php
public function includeHotUsers(Hub $hub, ParamBag $params)
{
$users = $hub->hot_users()
->limit($params['limit'][0] ?? 5)
->get();
return $this->collection($users, new UserTransformer());
}
# UserTransformer.php
public function includePosts(User $user, ParamBag $params)
{
$post = $user->posts()
->select($params[fields] ?? ['id', 'title', 'description', 'like_count'])
->limit($params['limit'][0] ?? 5)
->get();
return $this->collection($posts, new PostTransformer());
}
補充一下, 對於使用了 $this->response()->collection()
和$this->response->paginator()
方法的資源。 dingo/api 會去解析url中的include引數,然後去呼叫模型的相應的關聯方法來進行預載入,從而解決查詢的n+1問題
上面的第二個需求,要求Hub模型中必須定義 hot_users和posts 這兩個關聯方法,否則就會丟擲異常
這裡模型定義的關聯方法的名稱必須與url一致 既 hot_users()。非常難受呀,因為url推薦小寫,方法名推薦小駝峰!!
關聯資源的引數過濾規則
:引數名稱(值1|值2|值N)
':' 冒號標誌著一個引數的開始
緊跟著是引數名稱
然後接上引數值 其中引數的值需要被括號括起
多個引數值時使用 '|' 分隔
關聯資源我並不推薦提供分頁引數,因為其會造成資料的重複讀取,如果需要取出的關聯資源資料量很多。推薦透過單獨的api請求獲取該資源,而不是透過include方式載入進來。
資源中的動作
我們對資源存在一些動作行為,如對帖子的點贊收藏等,這裡我選擇模仿github的做法,將動作轉換為資源。
建立與刪除動作資源
- 點贊文章
url: http://mp.test/posts/1/likes
method: POST
- 取消點贊文章
url: http://mp.test/posts/1/likes
method: DELETE
// PostLikeCOntroller.php
public function store(Request $request, $id)
{
DB::table('user_like_post')->insert([
'user_id' => \Auth::id(),
'post_id' => $id
]);
return $this->response->created();
}
public function destroy($id)
{
DB::table('user_like_post')->where('user_id', \Auth::id())->where('post', $id)->delete();
return $this->response->noContent();
}
驗證動作資源
這是一個我研究/糾結了很久的問題,嘗試過很多種寫法,這裡決定模仿知乎的api寫法
驗證使用者是否點讚了某一篇帖子
url: http://api.test/posts/1?include=is_like
對於上面的url,dingo/api 會自動呼叫PostTransformer的includeIsLike方法。我們只需要在該方法中進行驗證即可
# PostTransformer.php
public function includeIsLike(Post $post)
{
// 這行程式碼可以根據Auth::id Cache一下
$likePostIds = DB::table('user_like_post')->where('user_id', Auth::id())->pluck('post_id')->toArray();
return $this->primitive(in_array($diary->id, $likePostIds));
}
吐槽一下 includeIsLike如何返回標量資源,文件上沒有任何描述。
看了原始碼才發現primitive這個關鍵詞。?
對於單個資源可以很容易的完成上面的需求,但對於資源集合我遇到了很大的問題
url: http://api.test/posts?include=is_like
集合我統一使用了$this->response->paginator()
, 前面提到 paginator和collection方法,會去檢測include引數並呼叫模型的相應的關聯方法來進行預載入。 所以會去posts模型去找is_like方法,可是我真的定義不出一個is_like關聯關係呢。
而且這個行為是沒法優雅的禁止掉的,想要禁止?ok啊,那就全關了,別想我再給你解決n+1問題了
這明明是一個很容易解決的問題,在dingo/api的issue中也提到了多次。但是都沒有得到解決。
於是我fork下了dingo/api的程式碼準備解決一下這個問題時,我終於明白是為什麼了~
dingo/api和Fractal是不同作者的專案。dingo/api是為laravel量身打造的。其依賴的transform使用的是Fractal。 而Fractal並不專屬於laravel.
在dingo/api中做很容易,但是在Fractal中新增一個為laravel服務的擴充套件就有些不切實際了
既然如此就在我們的專案中稍微解決一下這個問題
# 定義一個根Transformers.php 所有的Transformer都繼承自該Transformer
<?php
namespace App\Http\Transformers;
use League\Fractal\TransformerAbstract;
class Transformer extends TransformerAbstract
{
protected $disableEagerLoadedIncludes = [];
public function getDisableEagerLoadedIncludes()
{
return $this->disableEagerLoadedIncludes;
}
}
# 定義一個 Fractal.php 並繼承於原有Fractal
<?php
namespace App\Services;
class Fractal extends \Dingo\Api\Transformer\Adapter\Fractal
{
protected function mergeEagerLoads($transformer, $requestedIncludes)
{
$includes = array_merge($requestedIncludes, $transformer->getDefaultIncludes());
$includes = array_diff($includes, $transformer->getDisableEagerLoadedIncludes());
$eagerLoads = [];
foreach ($includes as $key => $value) {
$eagerLoads[] = is_string($key) ? $key : $value;
}
return $eagerLoads;
}
}
# 修改 dingo/api的配置檔案api.php 將原有的Fractal更改為我們自定義的
'transformer' => env('API_TRANSFORMER', \App\Services\Fractal::class),
大功告成~ 接下來我們只需要在PostTransformer中定義一個 disableEagerLoadedIncludes屬性來新增不需要急切載入的屬性了。
protected $disableEagerLoadedIncludes = ['is_like'];
終於可以 ?include=is_like,like_count,is_author,balala...
面向include的程式設計了
補充
上面說的都是已查詢為主,但增刪改也有一些小技巧 如建立資源除了使用 dingo/api封裝的$this->response->created()
外, 使用$this->response->item($post, new PostTransformer())->setStatusCode(201);
也是一種不錯的選擇。
使用 request進行表單驗證、使用 Policiy進行許可權驗證、使用Observer進行副作用的處理等等,從而保證增刪改的程式碼更具有可讀性和解耦性。
另外還有很多待解決的問題
-
如巢狀資源中,直接在路由檔案中處理邏輯並不優雅
-
fields 篩選欄位對 Transform的傳統寫法並不友好 有待改進
-
fields方法在laravel中顯得和include有些衝突,是否可以直接在include中編寫需要獲取的fields呢
- 這裡我覺得可能需要拋棄原有transform()的寫法,資料處理應該透過orm提供的一些修改器來進行。更多的資料處理則交給客戶端,服務端提供一些更加raw的資料。
-
當一個介面過於複雜時,需要請求多次api,超過3次以上我就有些難以接受了,get請求url過長問題,接下來會嘗試進行路由的對映 組裝操作等操作解決這個問題
-
資源暴露是否會帶來安全問題?這一點我覺不會。如何認證和限流參考dingo/api文件即可
-
對於一些不符合RESTful資源的需求如何處理,如搜尋需求。可以嘗試建立些額外的路由來處理這些額外的需求。這些需求可以佔到一個專案的20%左右
-
資源控制器的index方法使用的page每次都會多查詢一條總記錄數的sql。
-
資源控制器的index方法如果不存在篩選條件時如何做資源限制,總不能一次取出所有的資源
-
突發奇想採用了一個sql黑名單的機制
private function isBlacklist($query) { $limit = 100; if (request('per_page') && request('per_page') < $limit) { return false; } $key = 'sql:'. $query->toSql(); if (Cache::has($key)) { return true; } if ($query->count() > $limit) { Cache::forever($key, date('Y-m-d H:i:s')); return true; } return false; }
-
...
結語
再次說明一下我在做什麼,我希望能夠在資料的基礎上編寫一套介面能夠滿足h5端、pc端、ios/android端、包括小程式端等等80%的需求。
我不希望我的介面要跟著每一次的業務變動而去修改,我希望自己關心的是資料,而不是業務。
我希望只要知道產品的原型,就能完成後端80%的開發, 而不是等設計定稿/等前端開發等等
現在前端的開發是模組化的,在我看來就是面向import的開發。
傳統的RESTful介面沒法適應於前端的模組化開發。存在著大量的欄位冗餘和http請求,這是我在學習graphQL的時看到的一句話。
但既然前端的開發是模組化的、面向import的,後端的api介面為什麼不能是面向include的呢 ?
持續關注中 - 希望你能分享在RESTful API、dingo/api、laravel api等開發時的經驗、想法和技巧~
本作品採用《CC 協議》,轉載必須註明作者和本文連結