编写更具有描述性的 RESTful API

写这篇文章也快过去快一年啦,这一年积累了很多新的想法,后续的项目应该不会使用 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 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 7年前 加精
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 6
ThinkQ

很好

6年前 评论

好文,很仔细的读完了。
不过好像文中写到的使用了 ParamBag 的话就无法 Eagerload 了。

6年前 评论

深度好文,mark 一波

6年前 评论
Leonz3n

预加载的问题 Dingo/Api 在2.2.3 之后加入了解决方案

<?php
namespace Dingo\Api\Transformer\Adapter;

use Dingo\Api\Http\Request;\
use Dingo\Api\Transformer\Binding;\
use League\Fractal\TransformerAbstract;\
use Dingo\Api\Contract\Transformer\Adapter;\
use League\Fractal\Manager as FractalManager;\
use League\Fractal\Resource\Item as FractalItem;\
use League\Fractal\Pagination\IlluminatePaginatorAdapter;\
use Illuminate\Support\Collection as IlluminateCollection;\
use League\Fractal\Resource\Collection as FractalCollection;\
use Illuminate\Database\Eloquent\Collection as EloquentCollection;\
use Illuminate\Contracts\Pagination\Paginator as IlluminatePaginator;

class Fractal implements Adapter
{
    protected function mergeEagerLoads($transformer, $requestedIncludes)
    {
        $includes = array_merge($requestedIncludes, 
            $transformer->getDefaultIncludes());

        $eagerLoads = [];

        foreach ($includes as $key => $value) {
            $eagerLoads[] = is_string($key) ? $key : $value;
        }

        // 如果定义了 lazyLoadedIncludes,则忽略掉这些加载
        if (property_exists($transformer, 'lazyLoadedIncludes')) {
            $eagerLoads = array_diff($eagerLoads, $transformer->lazyLoadedIncludes);
        }

        return $eagerLoads;
    }
}

然后在你的 Transformer 中定义即可

<?php
use League\Fractal\TransformerAbstract;
class Transformer extends TransformerAbstract
{
    /**
     * 懒加载的属性
     *
     * @var array
     */
    public $lazyLoadedIncludes = ['some'];
}
5年前 评论

@thons93 我已经没有用 dingo/api 啦,自己写了一个工具 weiwenhao/tree-ql

5年前 评论

mark, 学习一波

5年前 评论