如何创建LaravelEloquentAPI资源以将模型转换为JSON

来自菜鸟教程
跳转至:导航、​搜索

介绍

创建 API 时,我们经常需要使用数据库结果来过滤、解释或格式化将在 API 响应中返回的值。 API 资源类 允许您将模型和模型集合转换为 JSON,作为数据库和控制器之间的数据转换层。

API 资源提供了一个统一的接口,可以在应用程序的任何地方使用。 口才的关系也得到照顾。

Laravel 提供了两个 artisan 命令用于生成 resourcescollections - 我们稍后会了解这两者之间的区别。 但是对于资源和集合,我们将响应包装在数据属性中:JSON 响应标准。

在下一节中,我们将通过一个演示项目来了解如何使用 API 资源。

先决条件

要遵循本指南,您需要满足以下先决条件:

  • 一个工作的 Laravel 开发环境。 要进行设置,您可以按照我们关于 如何在 Ubuntu 18.04 上安装和配置 Laravel 应用程序的指南进行操作。

本教程使用 PHP v7.1.3 和 Laravel v5.6.35 编写。

本教程已使用 PHP v7.3.11、Composer v.1.10.7、MySQL 5.7.0 和 Laravel v.5.6.35 进行了验证。

第 1 步 — 克隆 Starter

克隆 this repo 并按照 README.md 中的说明启动并运行。

首先,克隆 repo:

git clone `git@github.com:do-community/songs-demo.git`

然后,导航到项目文件夹:

cd songs-demo

通过运行以下命令创建一个 .env 文件:

cp .env.example .env

在此 .env 文件中更新您的数据库凭据。

安装包和依赖项:

composer install

注意: 你必须在你的 Laravel 开发环境中才能工作。 对于使用 Vagrant 的用户,请确保在运行 composer install 之前将 ssh 导入 Vagrant。


然后,为应用程序生成一个加密密钥:

php artisan key:generate

使用一些示例数据运行迁移和种子数据库:

php artisan migrate:refresh --seed

第 2 步 — 设置项目

通过项目设置,我们现在可以开始动手了。 此外,由于这是一个小项目,我们不会创建任何控制器,而是会测试路由闭包内的响应。

让我们从生成一个 SongResource 类开始:

php artisan make:resource SongResource

资源文件通常放在 App\Http\Resources 文件夹中。

让我们看看新创建的资源文件——SongResource:

app/Http/Resources/SongResource.php

[...]
class SongResource extends JsonResource
{
    /**
    * Transform the resource into an array.
    *
    *  @param  \Illuminate\Http\Request  $request
    *  @return array
    **/
    public function toArray($request)
    {
        return parent::toArray($request);
    }
}

默认情况下,我们在 toArray() 方法中有 parent::toArray($request)。 如果我们把事情留在这里,所有可见的模型属性都将成为我们响应的一部分。 为了定制响应,我们在此 toArray() 方法中指定要转换为 JSON 的属性。

让我们更新 toArray() 方法以匹配以下代码段:

app/Http/Resources/SongResource.php

[...]
public function toArray($request)
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'rating' => $this->rating,
    ];
}

如您所见,我们可以直接从 $this 变量访问模型属性,因为资源类自动允许方法访问底层模型。

现在让我们使用以下代码段更新 routes/api.php

路线/api.php

[...]
use App\Http\Resources\SongResource;
use App\Song;
[...]

Route::get('/songs/{song}', function(Song $song) {
    return new SongResource($song);
});

Route::get('/songs', function() {
    return new SongResource(Song::all());
});

如果我们访问 URL /api/songs/1,我们将看到一个 JSON 响应,其中包含我们在 SongResource 类中为 id 为 1 的歌曲指定的键值对:

{
  data: {
    id: 1,
    title: "Mouse.",
    rating: 3
  }
}

但是,如果我们尝试访问 URL /api/songs,则会抛出异常:

OutputProperty [id] does not exist on this collection instance.

这是因为实例化 SongResource 类需要将资源实例传递给构造函数,而不是集合。 这就是抛出异常的原因。

如果我们想要返回一个集合而不是单个资源,则可以在 Resource 类上调用一个静态 collection() 方法,并将集合作为参数传入。 让我们将 /songs 路由闭包更新为:

Route::get('/songs', function() {
    return SongResource::collection(Song::all());
});

再次访问 /api/songs URL 会给我们一个包含所有歌曲的 JSON 响应。

{
  "data": [
    {
      "id": 1,
      "title": "Mouse.",
      "rating": 3
    },
    {
      "id": 2,
      "title": "I'll.",
      "rating": 0
    }
  ]
}

资源在返回单个资源甚至集合时工作得很好,但如果我们想在响应中包含元数据,则会受到限制。 这就是 Collections 来拯救我们的地方。

要生成一个集合类,我们运行:

php artisan make:resource SongsCollection

JSON 资源和 JSON 集合之间的主要区别在于,资源扩展 JsonResource 类并期望在实例化时传递单个资源,而集合扩展 ResourceCollection 类并期望集合作为实例化时的参数。

回到元数据位。 假设我们希望将一些元数据(例如总歌曲数)作为响应的一部分,以下是使用 ResourceCollection 类时的处理方法:

app/Http/Resources/SongsCollection.php

[...]
class SongsCollection extends ResourceCollection
{
    public function toArray($request)
    {
        return [
            'data' => $this->collection,
            'meta' => ['song_count' => $this->collection->count()],
        ];
    }
}

如果我们将 /api/songs 路由闭包更新为:

路线/api.php

[...]
use App\Http\Resources\SongsCollection;
[...]
Route::get('/songs', function() {
    return new SongsCollection(Song::all());
});

访问 URL /api/songs,我们现在可以看到数据属性中的所有歌曲以及元位中的总数:

{
  "data": [
    {
      "id": 1,
      "title": "Mouse.",
      "artist": "Carlos Streich",
      "rating": 3,
      "created_at": "2018-09-13 15:43:42",
      "updated_at": "2018-09-13 15:43:42"
    },
    {
      "id": 2,
      "title": "I'll.",
      "artist": "Kelton Nikolaus",
      "rating": 0,
      "created_at": "2018-09-13 15:43:42",
      "updated_at": "2018-09-13 15:43:42"
    },
    {
      "id": 3,
      "title": "Gryphon.",
      "artist": "Tristin Veum",
      "rating": 3,
      "created_at": "2018-09-13 15:43:42",
      "updated_at": "2018-09-13 15:43:42"
    }
  ],
  "meta": {
    "song_count": 3
  }
}

但是我们有一个问题,data 属性中的每首歌曲都没有按照我们之前在 SongResource 中定义的规范格式化,而是具有所有属性。

为了解决这个问题,在 toArray() 方法中,将 data 的值设置为 SongResource::collection($this->collection) 而不是 $this->collection

我们的 toArray() 方法现在看起来像这样:

app/Http/Resources/SongsCollection.php

[...]
public function toArray($request)
{
    return [
        'data' => SongResource::collection($this->collection),
        'meta' => ['song_count' => $this->collection->count()]
    ];
}

您可以通过再次访问 /api/songs URL 来验证我们在响应中获得了正确的数据。

如果想将元数据添加到单个资源而不是集合中怎么办? 幸运的是,JsonResource 类带有一个 additional() 方法,可让您在使用资源时指定您希望作为响应一部分的任何其他数据:

路线/api.php

[...]
Route::get('/songs/{song}', function(Song $song) {
    return (new SongResource(Song::find(1)))->additional([
        'meta' => [
            'anything' => 'Some Value'
        ]
    ]);
});

在这种情况下,响应看起来有点像这样:

{
  "data": {
    "id": 1,
    "title": "Mouse.",
    "rating": 3
  },
  "meta": {
    "anything": "Some Value"
  }
}

第 3 步 - 创建模型关系

在这个项目中,我们只有两个模型,AlbumSong。 当前关系是one-to-many关系,意思是一张专辑有很多歌曲,一首歌属于一张专辑。

我们现在将更新 SongResource 类中的 toArray() 方法,以便它引用专辑:

app/Http/Resources/SongResource.php

[...]
class SongResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            [...]
            // other attributes
            'album' => $this->album
        ];
    }
}

如果我们想更具体地说明响应中将出现哪些专辑属性,我们可以创建一个 AlbumResource 类似于我们对歌曲所做的那样。

要创建 AlbumResource,请运行:

php artisan make:resource AlbumResource

一旦创建了资源类,我们就可以指定我们希望包含在响应中的属性。

应用程序/Http/Resources/AlbumResource.php

[...]
class AlbumResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'title' => $this->title
        ];
    }
}

现在在 SongResource 类中,我们可以使用刚刚创建的 AlbumResource 类,而不是 'album' => $this->album

app/Http/Resources/SongResource.php

[...]
class SongResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            [...]
            // other attributes
            'album' => new AlbumResource($this->album)
        ];
    }
}

如果我们再次访问 /api/songs URL,您会注意到专辑将成为响应的一部分。 这种方法的唯一问题是它提出了 N + 1 查询问题。

出于演示目的,在 routes/api.php 文件中添加以下代码段:

路线/api.php

[...]
DB::listen(function($query) {
    var_dump($query->sql);
});

再次访问 /api/songs URL。 请注意,对于每首歌曲,我们都会进行额外的查询来检索专辑的详细信息? 这可以通过急切加载关系来避免。 在我们的例子中,将 /api/songs 路由闭包内的代码更新为:

路线/api.php

[...]
return new SongsCollection(Song::with('album')->get());

再次重新加载页面,您会发现查询数量减少了。

注释掉 DB::listen 片段,因为我们不再需要它了。

第 4 步 — 使用资源时使用条件

时不时地,我们可能有一个条件来确定将返回的响应类型。

我们可以采取的一种方法是在我们的 toArray() 方法中引入 if 语句。 好消息是我们不必这样做,因为 JsonResource 类内部需要一个 ConditionallyLoadsAttributes 特征,该特征具有一些处理条件的方法。

我们只会讨论 whenLoadedmergeWhen 方法,但文档很全面。

whenLoaded 方法

该方法可以防止在检索相关模型时加载尚未加载的数据,从而防止 (N+1) 查询问题。

仍然使用专辑资源作为参考点(一张专辑有很多歌曲):

应用程序/Http/Resources/AlbumResource.php

public function toArray($request)
{
    return [
        [...]
        // other attributes
        'songs' => SongResource::collection($this->whenLoaded($this->songs))
    ];
}

如果我们在检索专辑时没有急切地加载歌曲,我们最终会得到一个空的歌曲集合。

mergeWhen 方法

我们可以使用 mergeWhen() 方法来代替 if 语句来指示某些属性及其值是否将成为响应的一部分,该方法将要评估的条件作为第一个参数和一个包含键值的数组如果条件评估为真,则该对将成为响应的一部分:

应用程序/Http/Resources/AlbumResource.php

public function toArray($request)
{
    return [
        [...]
        // other attributes
        'songs' => SongResource::collection($this->whenLoaded($this->songs)),
        $this->mergeWhen($this->songs->count > 10, ['new_attribute' => 'attribute value'])
    ];
}

这看起来更干净、更优雅,而不是让 if 语句包装整个返回块。

第 5 步 — 单元测试 API 资源

现在我们已经学会了如何转换我们的响应,我们如何验证我们得到的响应是我们在资源类中指定的?

我们现在将编写测试来验证响应是否包含正确的数据,并确保仍然保持雄辩的关系。

让我们创建测试:

php artisan make:test SongResourceTest --unit

注意生成测试时的 --unit 标志:这将告诉 Laravel 这将是一个单元测试。

注意: 在验证过程中,运行make:test命令时出现错误Test already exists!SongResourceTest.php 的内容似乎包括一些较旧的测试。 将此文件的内容替换为本教程提供的代码。


让我们从编写测试开始,以确保我们来自 SongResource 类的响应包含正确的数据:

测试/单元/SongResourceTest.php

[...]
use App\Http\Resources\SongResource;
use App\Http\Resources\AlbumResource;
[...]
class SongResourceTest extends TestCase
{
    use RefreshDatabase;
    public function testCorrectDataIsReturnedInResponse()
    {
        $resource = (new SongResource($song = factory('App\Song')->create()))->jsonSerialize();
    }
}

在这里,我们首先创建一个歌曲资源,然后在 SongResource 上调用 jsonSerialize() 将资源转换为 JSON 格式,因为这将被发送到我们的前端。

由于我们已经知道将成为响应一部分的歌曲属性,我们现在可以做出断言:

测试/单元/SongResourceTest.php

[...]
$this->assertArraySubset([
    'title' => $song->title,
    'rating' => $song->rating
], $resource);

在此示例中,我们匹配了两个属性:titlerating。 您可以列出多个属性。

如果您想确保即使在将模型转换为资源之后仍保留模型关系,您可以使用:

测试/单元/SongResourceTest.php

[...]
public function testSongHasAlbumRelationship()
{
    $resource = (new SongResource($song = factory('App\Song')->create(["album_id" => factory('App\Album')->create(['id' => 1])])))->jsonSerialize();
}

在这里,我们使用 1album_id 创建一首歌曲,然后将歌曲传递给 SongResource 类,最后将资源转换为 JSON 格式。

为了验证歌曲-专辑关系是否仍然保持,我们对刚刚创建的 $resource 的专辑属性进行断言。 像这样:

测试/单元/SongResourceTest.php

[...]
$this->assertInstanceOf(AlbumResource::class, $resource["album"]);

但是请注意,如果我们执行 $this->assertInstanceOf(Album::class, $resource["album"]),我们的测试将失败,因为我们正在将专辑实例转换为 SongResource 类中的资源。

注意: 在验证过程中确定我们可以使用以下命令运行这些测试:

vendor/bin/phpunit

回顾一下,我们首先创建一个模型实例,将实例传递给资源类,将资源转换为 JSON 格式,然后最后进行断言。

结论

我们已经了解了 Laravel API 资源是什么,如何创建它们以及如何测试 JSON 响应。 随意探索 JsonResource 类并查看所有可用的方法。

如果您想了解更多关于 Laravel API 资源的信息,请查看 官方文档