編寫具有描述性的 RESTful API (三): 使用者行為
使用者行為泛指使用者在網站上的互動,如點贊/關注/收藏/評論/發帖等等. 這些行為具有共同的特徵 ,因此可以用一致的編碼 來描述使用者的行為
使用者行為分析
我們先來分析一下簡書中的使用者行為, user_follow_user (使用者關注使用者) / user_like_comment / user_like_post / user_give_post (使用者讚賞帖子) / user_follow_collection (使用者關注專題)/ user_comment_post 等等
按照 RESTful 規範將上面的行為抽象為資源, 可以得到 user_follower , comment_liker , post_liker, post_giver , collection_follower 等資源.
這裡我忽略了一個可能的資源 post_commenters , 因此它已經存在了,就是 comments 表, 因此不做考慮
接下來以 post_liker 為例來看一下實際的編碼
建表
Schema::create('post_likers', function (Blueprint $table) { $table->increments('id'); $table->unsignedInteger('post_id'); $table->unsignedInteger('user_id'); $table->timestamps(); $table->index('post_id'); $table->index('user_id'); $table->unique(['post_id', 'user_id']); }); 複製程式碼
建模
這裡 post_liker 已經被提取成了資源的形式,雖然其依舊充當著中間表的作用,但形式上需要按照資源的形式來處理.
即表名需要複數形式,且需要相應的模型與控制器. 概言之
> php artisan make:model Models/PostLiker -a
<?php namespace App\Models; class PostLiker extends Model { public function post() { return $this->belongsTo(Post::class); } public function user() { return $this->belongsTo(User::class); } } 複製程式碼
路由
post 請求表示建立 liker 資源 即使用者喜歡帖子時需要呼叫該 API ,反之 delete 則表示刪除 liker 資源.
# api.php Route::post('posts/{post}/liker', 'PostLikerController@store'); Route::delete('posts/{post}/liker', 'PostLikerController@destroy'); 複製程式碼
控制器
<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Post; use App\Models\PostLiker; use Illuminate\Http\Response; class PostLikerController extends Controller { /** * @param Post $post * @return \Illuminate\Contracts\Routing\ResponseFactory|Response */ public function store(Post $post) { $postLiker = new PostLiker(); $postLiker->user_id = \Auth::id(); $postLiker->post()->associate($post); $postLiker->save(); return $this->created(); } /** * @param Post $post * @return \Illuminate\Contracts\Routing\ResponseFactory|Response */ public function destroy(Post $post) { $postLiker = PostLiker::where('user_id', \Auth::id()) ->where('post_id', $post->id) ->firstOrFail(); // Observer 中需要用到該關聯,防止重複查詢 $postLiker->setRelation('post', $post); $postLiker->delete(); return $this->noContent(); } } 複製程式碼
至此我們就成功把喜歡帖子的行為轉換成了 RESTful API.
destroy 方法為了觸發 observer 顯得有些繁瑣,還有待優化.
Observer
在 Observer 中,我們處理了與主邏輯不想關的附加邏輯, 即增減 post.like_count
<?php namespace App\Observers; use App\Models\PostLiker; class PostLikerObserver { /** * @param PostLiker $postLiker */ public function created(PostLiker $postLiker) { $postLiker->post()->increment('like_count'); } public function deleted(PostLiker $postLiker) { $postLiker->post()->decrement('like_count'); } } 複製程式碼
還沒完,還有一步重要的實現,便是判斷當前使用者是否已經喜歡了該帖子 ,或者說在一個帖子列表中當前使用者喜歡了哪些帖子
Is like
為了進一步增加描述性和前端繫結資料的便利性. 我們需要藉助 tree-ql 來實現該功能
# PostResource.php <?php namespace App\Resources; use Illuminate\Support\Facades\Redis; use Weiwenhao\TreeQL\Resource; class PostResource extends Resource { // 單次請求簡單快取 private $likedPostIds; // ... protected $custom = [ 'liked' ]; /** * @param $post * @return mixed */ public function liked($post) { if (!$this->likedPostIds) { $this->likedPostIds = $user->likedPosts()->pluck('posts.id')->toArray(); } return in_array($post->id, $this->likedPostIds); } } 複製程式碼
定義好之後,就可以愉快的 include 啦
文末會附上 Postman 文件.其餘使用者行為,同理實現即可.
統一使用者行為
使用者行為既然具有一致性,那麼其實可以進一步抽象來避免大量的使用者行為控制器和路由.
完全可以使用一個 UserActionController , 前端配套 userAction.js來統一實現使用者行為,
更進一步的想法則是,前端在 localStorate 中維護一份使用者行為的資源資料,可以直接通過 localStorate 來判斷當前使用者是否喜歡過某個使用者/是否喜歡過某篇文章等等,從而減輕後端的計算壓力.
localStorage 中的資源示例 post_likers: [12, 234, 827, 125] comment_likers: [222, 352, 122]
下面的示例是我曾經在專案中按照該想法實現的 userAction.js
import Vue from 'vue' import userAction from '~/api/userAction' /** *使用者行為統一介面 * *由於該類使用了瀏覽器端的 localStorage 所以只能在客戶端呼叫 *actionName 和對應的 primaryKey 分別為 *follow_user | 被關注的使用者的 id - 關注使用者行為 *join_community | 加入的社群的 id - 加入社群行為 *collect_product | 收藏的產品的 id - 收藏產品行為 * * +---------------------------------------------------------------------------- * *推薦在元件的 mounted 中呼叫 action 中的方法.已保證呼叫棧在瀏覽器端 *is() 方法屬於非同步方法,返回一個 Promise 物件,因此需要使用 await承接其結果 * * +---------------------------------------------------------------------------- *mounted 中的呼叫示例. 判斷當前使用者是否關注了使用者 id 為 1 的使用者. *async mounted () { *this.isAction = await this.$action.is('follow_user', 1, this) *} */ Vue.prototype.$action = { /** * 是否對某個物件產生過某種行為 * @param actionName * @param primaryKey * @param vue * @returns {Promise<boolean>} */ async is (actionName, primaryKey, vue) { if (!vue.$store.state.auth.user) { throw new Error('使用者未登入') } if (!primaryKey) { throw new Error('primaryKey必須傳遞') } primaryKey = Number(primaryKey) // 如果本地沒有在 localStorage 中發現相應的 actionName 則去請求後端進行同步 if (!window.localStorage.getItem(actionName)) { let {data} = await vue.$axios.get(userAction.fetch(actionName)) Object.keys(data).forEach(function (item) { localStorage.setItem(item, JSON.stringify(data[item])) }) } let item = localStorage.getItem(actionName) item = JSON.parse(item) if (item.indexOf(primaryKey) !== -1) { return true } return false }, /** * 建立一條使用者行為 * @param actionName * @param primaryKey * @param vue * @returns {boolean} */ create (actionName, primaryKey, vue) { if (!vue.$store.state.auth.user) { throw new Error('使用者未登入') } if (!primaryKey) { throw new Error('primaryKey必須傳遞') } primaryKey = Number(primaryKey) // 傳送 http 請求 vue.$axios.post(userAction.store(actionName, primaryKey)) // localStorage let item = window.localStorage.getItem(actionName) if (!item) { return false } item = JSON.parse(item) if (item.indexOf(primaryKey) === -1) { item.push(primaryKey) localStorage.setItem(actionName, JSON.stringify(item)) } }, /** * 刪除一條使用者行為 * @param actionName 使用者行為名稱 * @param primaryKey 該行為對應的primaryKey * @param vue * @returns {boolean} */ delete (actionName, primaryKey, vue) { if (!vue.$store.state.auth.user) { throw new Error('使用者未登入') } if (!primaryKey) { throw new Error('primaryKey必須傳遞') } primaryKey = Number(primaryKey) // 傳送 http 請求 vue.$axios.delete(userAction.destroy(actionName, primaryKey)) let item = window.localStorage.getItem(actionName) if (!item) { return false } item = JSON.parse(item) let index = item.indexOf(primaryKey) if (index !== -1) { item.splice(index, 1) localStorage.setItem(actionName, JSON.stringify(item)) } } } 複製程式碼
理論上按照該想法來實現,可以極大前後端的編碼壓力.
相應的後端程式碼就類似上面的 PostLikerController, 只是前端請求中增加了action_name 做資料導向.
補充
Redis快取
使用者行為的儲存結構非常符合 Redis 的 SET 資料結構, 因此來嘗試使用 set 對使用者行為進行快取,依舊使用 post_likers 作為例子.
Resource
<?php namespace App\Resources; use App\Models\Post; use Illuminate\Support\Facades\Redis; use Weiwenhao\TreeQL\Resource; class PostResource extends Resource { // ... protected $custom = [ 'liked' ]; /** * @param Post $post * @return boolean */ public function liked($post) { $user = \Auth::user(); $key = "user_likers:{$user->id}"; if (!Redis::exists($key)) { $likedPostIds = $user->likedPosts()->pluck('posts.id')->toArray(); Redis::sadd($key, $likedPostIds); } return (boolean)Redis::sismember($key, $post->id); } } 複製程式碼
相關的更新和刪除快取在 Observe中進行,控制器編碼無需變動
# PostLikerObserver.php <?php namespace App\Observers; use App\Models\PostLiker; use Illuminate\Support\Facades\Redis; class PostLikerObserver { /** * @param PostLiker $postLiker */ public function created(PostLiker $postLiker) { $postLiker->post()->increment('like_count'); // cache add $user = \Auth::user(); $key = "user_likers:{$user->id}"; if (Redis::exists($key)) { Redis::sadd($key, [$postLiker->post_id]); } } public function deleted(PostLiker $postLiker) { $postLiker->post()->decrement('like_count'); // cache remove $userId = \Auth::id(); $key = "user_likers:{$userId}"; if (Redis::exists($key)) { Redis::srem($key, $postLiker->post_id); } } } 複製程式碼
更進一步
上面的做法是我過去在專案中使用的方法,但是在編寫該篇時,我的腦海中冒出了另外一個想法
稍微去原始碼看看 PostResource 便明白了. 使用該方法能夠優化上文提到的 destroy 方法過於繁瑣的問題.
相關
- Api documentdocumenter.getpostman.com/view/150062…
- 線上除錯工具telescope
- 本節原始碼weiwenhao/community-api
- Api Toolweiwenhao/tree-ql
- 上一節編寫具有描述性的 RESTful API (二): 推薦與 Observer