編寫更具有描述性的 RESTful API

Max發表於2018-03-26

寫這篇文章也快過去快一年啦,這一年積累了很多新的想法,後續的專案應該不會使用 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

排序

排序欄位的選擇在網上有很多種

  1. ?sort_field=created_at&sort_order=asc
  2. ?order=+created
  3. ?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/limitpage/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 協議》,轉載必須註明作者和本文連結

相關文章