创建自定义数据传输对象(DTO)的全面指南,以增强Laravel API集成的可读性、效率和可测试性

介绍
有效处理API响应对于集成第三方API非常重要。在之前的文章中,我讨论了如何使用Http facade设置简单的客户端和请求类。如果你还没有阅读过这篇文章,我建议你去看一下。
在此基础上,本文将为您详细介绍如何创建自定义数据传输对象(DTO),以将数据映射到API响应中。我将使用正在进行的Google Books API集成场景作为实际示例,使事情更容易理解。
将响应数据映射到DTO
首先,让我们来看一下从Google Books API获取搜索结果时的示例响应。为此,我调用了之前创建的QueryBooksByTitle动作,并搜索书籍"The Ferryman":
$response = app(QueryBooksByTitle::class)("The Ferryman");
dump($response->json());这将输出以下JSON数据,我只选择了我想要追踪的字段:
{
"kind": "books#volumes",
"totalItems": 367,
"items": [
{
...
},
{
...
},
{
"kind": "books#volume",
"id": "dO5-EAAAQBAJ",
"volumeInfo": {
"title": "The Ferryman",
"subtitle": "A Novel",
"authors": [
"Justin Cronin"
],
"publisher": "Doubleday Canada",
"publishedDate": "2023-05-02",
"description": "..."
},
...
},
...
]
}现在我们知道了响应的格式,让我们创建必要的DTO来映射数据。让我们从BookListData开始,它可以是一个简单的PHP类。
<?php
namespace App\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
/**
* 存储来自Google Books volumes API的顶层数据。
*/
readonly class BooksListData implements Arrayable
{
public function __construct(
public string $kind,
public string $id,
public int $totalItems,
) {
}
/**
* 从数据数组创建类的新实例。
*/
public static function fromArray(array $data): BooksListData
{
return new self(
data_get($data, 'kind'),
data_get($data, 'id'),
data_get($data, 'totalItems'),
);
}
/**
* 实现Laravel的Arrayable接口,允许将对象序列化为数组。
*/
public function toArray(): array
{
return [
'kind' => $this->kind,
'items' => $this->items,
'totalItems' => $this->totalItems,
];
}
}创建完DTO后,我们可以更新之前文章中创建的QueryBooksByTitle动作。
<?php
namespace App\Actions;
use App\DataTransferObjects\BooksListData;
use App\Support\ApiRequest;
use App\Support\GoogleBooksApiClient;
use Illuminate\Http\Client\Response;
/**
* QueryBooksByTitle类是一个用于从Google Books API查询书籍的动作。
* 它提供了一个__invoke方法,接受一个标题并返回API的响应。
*/
class QueryBooksByTitle
{
/**
* 根据标题从Google Books API查询书籍,并返回BookListData。
* 该方法创建一个GoogleBooksApiClient和一个ApiRequest,
* 使用给定的标题作为'q'查询参数和'books'作为'printType'查询参数,
* 并使用客户端发送请求,然后返回书籍列表数据。
*/
public function __invoke(string $title): BooksListData
{
$client = app(GoogleBooksApiClient::class);
$request = ApiRequest::get('volumes')
->setQuery('q', 'intitle:'.$title)
->setQuery('printType', 'books');
$response = $client->send($request);
return BooksListData::fromArray($response->json());
}
}Test the Response Data
我们可以创建一个测试来确保在调用该动作时返回BooksListData对象:
<?php
use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
it('fetches books by title', function () {
$title = 'The Lord of the Rings';
$response = resolve(QueryBooksByTitle::class)($title);
expect($response)->toBeInstanceOf(BooksListData::class);
});你可能没有注意到,但上面的测试存在一个问题。我们正在访问Google Books API。这对于不经常运行的集成测试可能没问题,但在我们的Laravel测试中,应该修复这个问题。我们可以利用Http facade的功能来解决这个问题,因为我们的Client类是使用该facade构建的。
防止测试中的HTTP请求
我喜欢做的第一步是确保我的测试没有进行我没有预期的外部HTTP请求。我们可以将`Http::preventStrayRequests();`添加到Pest.php文件中。然后,在使用Http facade发出请求的任何测试中,除非我们模拟请求,否则会引发异常。
<?php
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Support\Facades\Http;
use Tests\CreatesApplication;
uses(
TestCase::class,
CreatesApplication::class,
)
->beforeEach(function () {
Http::preventStrayRequests();
})
->in('Feature');如果再次运行QueryBooksByTitle测试,现在会出现一个失败的测试,显示以下错误信息:
RuntimeException: Attempted request to [https://www.googleapis.com/books/v1/volumes?key=XXXXXXXXXXXXX&q=intitle%3AThe%20Lord%20of%20the%20Rings&printType=books] without a matching fake.
现在,让我们使用Http facade来伪造响应。
<?php
use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use Illuminate\Support\Facades\Http;
it('fetches books by title', function () {
$title = fake()->sentence();
// 从Google Books API生成一个假响应。
$responseData = [
'kind' => 'books#volumes',
'totalItems' => 1,
'items' => [
[
'id' => fake()->uuid,
'volumeInfo' => [
'title' => $title,
'subtitle' => fake()->sentence(),
'authors' => [fake()->name],
'publisher' => fake()->company(),
'publishedDate' => fake()->date(),
'description' => fake()->paragraphs(asText: true),
'pageCount' => fake()->numberBetween(100, 500),
'categories' => [fake()->word],
'imageLinks' => [
'thumbnail' => fake()->url(),
],
],
],
],
];
// 当客户端向Google Books API发送请求时,返回假响应。
Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
body: $responseData,
status: 200
)]);
$response = resolve(QueryBooksByTitle::class)($title);
expect($response)->toBeInstanceOf(BooksListData::class);
expect($response->items[0]['volumeInfo']['title'])->toBe($title);
});现在运行测试,不再出现RuntimeException,因为我们使用Http::fake()方法伪造了请求。Http::fake()方法非常灵活,可以接受一个包含不同URL的项目数组。根据您的应用程序,您可以只使用'*'而不是完整的URL,甚至可以更具体地包括查询参数或其他动态URL数据。如果需要,甚至可以伪造请求序列。有关更多信息,请参阅Laravel文档。
这个测试效果很好,但仍然有一些改进的空间。
扩展数据传输对象(DTO)
首先,让我们再次看一下响应数据。将顶层的响应映射到BooksListData对象中是不错的,但使用items[0]['volumeInfo']['title']并不方便开发人员,并且IDE无法提供任何类型的自动完成。为了解决这个问题,我们需要创建更多的DTOs。通常最容易从需要映射的最低级别的项开始。在这种情况下,需要映射响应中的imageLinks数据。查看来自Google Books的响应,似乎该数据可能包含缩略图和小缩略图属性。我们将创建一个ImageLinksData对象来映射这部分数据。
namespace App\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
readonly class ImageLinksData implements Arrayable
{
public function __construct(
public ?string $thumbnail = null,
public ?string $smallThumbnail = null,
) {
}
public static function fromArray(array $data): self
{
return new self(
thumbnail: data_get($data, 'thumbnail'),
smallThumbnail: data_get($data, 'smallThumbnail'),
);
}
public function toArray(): array
{
return [
'thumbnail' => $this->thumbnail,
'smallThumbnail' => $this->smallThumbnail,
];
}
}#从那里,往上走一级,我们有VolumeInfoData对象。
namespace App\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
readonly class VolumeInfoData implements Arrayable
{
public function __construct(
public string $title,
public string $subtitle,
// 使用集合而不是数组是个人偏好。
// 这使得处理数据稍微更容易一些。
/** @var Collection<int, string> */
public Collection $authors,
public string $publisher,
public string $publishedDate,
public string $description,
public int $pageCount,
/** @var Collection<int, string> */
public Collection $categories,
// 图片链接由ImageLinksData对象映射。
public ImageLinksData $imageLinks,
) {
}
public static function fromArray(array $data): self
{
return new self(
title: data_get($data, 'title'),
subtitle: data_get($data, 'subtitle'),
// 从数据数组创建集合。
authors: collect(data_get($data, 'authors')),
publisher: data_get($data, 'publisher'),
publishedDate: data_get($data, 'publishedDate'),
description: data_get($data, 'description'),
pageCount: data_get($data, 'pageCount'),
// 从数据数组创建集合。
categories: collect(data_get($data, 'categories')),
// 将图片链接映射到ImageLinksData对象。
imageLinks: ImageLinksData::fromArray(data_get($data, 'imageLinks')),
);
}
public function toArray(): array
{
return [
'title' => $this->title,
'subtitle' => $this->subtitle,
// 将集合转换为数组,因为它们实现了可数组化接口。
'authors' => $this->authors->toArray(),
'publisher' => $this->publisher,
'publishedDate' => $this->publishedDate,
'description' => $this->description,
'pageCount' => $this->pageCount,
'categories' => $this->categories->toArray(),
// 由于我们使用了可数组化接口,我们可以直接调用imageLinks对象的toArray方法。
'imageLinks' => $this->imageLinks->toArray(),
];
}
}请注意,我使用了Laravel的集合而不是数组。我更喜欢使用集合,因此每当响应中有数组时,我都会映射到集合。另外,由于VolumeInfoData包含imageLinks属性,我们可以使用ImageLinksData对象进行映射。
再往上走一级,我们有一个项的列表,所以我们可以创建ItemData对象。
namespace App\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
readonly class ItemData implements Arrayable
{
public function __construct(
public string $id,
public VolumeInfoData $volumeInfo,
) {
}
public static function fromArray(array $data): self
{
return new self(
id: data_get($data, 'id'),
volumeInfo: VolumeInfoData::fromArray(data_get($data, 'volumeInfo')),
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'volumeInfo' => $this->volumeInfo->toArray(),
];
}
}最后,我们需要回到原始的BooksListData对象,而不是映射数据数组,我们想要映射一个ItemData对象的集合。
namespace App\DataTransferObjects;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
/**
* 存储来自Google Books volumes API的顶级数据。
*/
readonly class BooksListData implements Arrayable
{
public function __construct(
public string $kind,
/** @var Collection<int, ItemData> */
public Collection $items,
public int $totalItems,
) {
}
/**
* 从数据数组创建类的新实例。
*/
public static function fromArray(array $data): BooksListData
{
return new self(
data_get($data, 'kind'),
// 将项映射到ItemData对象的集合。
collect(data_get($data, 'items', []))->map(fn (array $item) => ItemData::fromArray($item)),
data_get($data, 'totalItems'),
);
}
/**
* 实现Laravel的Arrayable接口,允许将对象序列化为数组。
*/
public function toArray(): array
{
return [
'kind' => $this->kind,
'items' => $this->items->toArray(),
'totalItems' => $this->totalItems,
];
}
}有了所有新创建的DTO,让我们回到测试并进行更新。
测试完整的数据传输对象(DTO)
<?php
use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use App\DataTransferObjects\ImageLinksData;
use App\DataTransferObjects\ItemData;
use App\DataTransferObjects\VolumeInfoData;
use Illuminate\Support\Facades\Http;
it('按标题获取图书', function () {
$title = fake()->sentence();
// 从Google Books API生成一个假响应。
$responseData = [
'kind' => 'books#volumes',
'totalItems' => 1,
'items' => [
[
'id' => fake()->uuid,
'volumeInfo' => [
'title' => $title,
'subtitle' => fake()->sentence(),
'authors' => [fake()->name],
'publisher' => fake()->company(),
'publishedDate' => fake()->date(),
'description' => fake()->paragraphs(asText: true),
'pageCount' => fake()->numberBetween(100, 500),
'categories' => [fake()->word],
'imageLinks' => [
'thumbnail' => fake()->url(),
],
],
],
],
];
// 当客户端向Google Books API发送请求时,返回假响应。
Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
body: $responseData,
status: 200
)]);
$response = resolve(QueryBooksByTitle::class)($title);
expect($response)->toBeInstanceOf(BooksListData::class)
->and($response->items->first())->toBeInstanceOf(ItemData::class)
->and($response->items->first()->volumeInfo)->toBeInstanceOf(VolumeInfoData::class)
->imageLinks->toBeInstanceOf(ImageLinksData::class)
->title->toBe($title);
});现在我们的期望中可以看到,响应正在映射所有不同的DTO,并正确设置标题。
通过使操作返回DTO而不是默认的Illuminate/Http/Client/Response,我们现在对API响应具有类型安全性,并在编辑器中获得更好的自动完成,这极大地提高了开发人员的体验。
创建测试响应辅助函数
另一个我喜欢做的测试技巧是创建类似于响应工厂的东西。在每个可能需要查询图书的单个测试中模拟响应是耗时的,因此我更喜欢创建一个简单的trait来帮助我更快地模拟响应。
<?php
namespace Tests\Helpers;
use Illuminate\Support\Facades\Http;
trait GoogleBooksApiResponseHelpers
{
/**
* 为按标题查询图书生成一个假响应。
*/
private function fakeQueryBooksByTitleResponse(array $items = [], int $status = 200, bool $raw = false): void
{
// 如果raw为true,则直接返回items数组。否则,从Google Books API返回一个假响应。
$data = $raw ? $items : [
'kind' => 'books#volumes',
'totalItems' => count($items),
'items' => array_map(fn (array $item) => $this->createItem($item), $items),
];
Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
body: $data,
status: $status
)]);
}
// 创建一个假的项目数组。
private function createItem(array $data = []): array
{
return [
'id' => data_get($data, 'id', '123'),
'volumeInfo' => $this->createVolumeInfo(data_get($data, 'volumeInfo', [])),
];
}
// 创建一个假的卷信息数组。
private function createVolumeInfo(array $data = []): array
{
return [
'title' => data_get($data, 'title', fake()->sentence),
'subtitle' => data_get($data, 'subtitle', '图书副标题'),
'authors' => data_get($data, 'authors', ['作者1', '作者2']),
'publisher' => data_get($data, 'publisher', '出版商'),
'publishedDate' => data_get($data, 'publishedDate', '2021-01-01'),
'description' => data_get($data, 'description', '图书描述'),
'pageCount' => data_get($data, 'pageCount', 123),
'categories' => data_get($data, 'categories', ['类别1', '类别2']),
'imageLinks' => data_get($data, 'imageLinks', ['thumbnail' => 'https://example.com/image.jpg']),
];
}
}在Pest测试中使用该trait,我们只需要使用uses方法。
uses(GoogleBooksApiResponseHelpers::class);
有了这个trait,我们现在可以轻松地添加其他测试,而无需在每个测试中编写所有的模拟数据。
<?php
use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use App\DataTransferObjects\ImageLinksData;
use App\DataTransferObjects\ItemData;
use App\DataTransferObjects\VolumeInfoData;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\Helpers\GoogleBooksApiResponseHelpers;
uses(GoogleBooksApiResponseHelpers::class);
it('按标题获取图书', function () {
$title = fake()->sentence();
// 从Google Books API生成一个假响应。
$this->fakeQueryBooksByTitleResponse([['volumeInfo' => ['title' => $title]]]);
$response = resolve(QueryBooksByTitle::class)($title);
expect($response)->toBeInstanceOf(BooksListData::class)
->and($response->items->first())->toBeInstanceOf(ItemData::class)
->and($response->items->first()->volumeInfo)->toBeInstanceOf(VolumeInfoData::class)
->imageLinks->toBeInstanceOf(ImageLinksData::class)
->title->toBe($title);
});
it('将标题作为查询参数传递', function () {
$title = fake()->sentence();
// 从Google Books API生成一个假响应。
$this->fakeQueryBooksByTitleResponse([['volumeInfo' => ['title' => $title]]]);
resolve(QueryBooksByTitle::class)($title);
Http::assertSent(function (Illuminate\Http\Client\Request $request) use ($title) {
expect($request)
->method()->toBe('GET')
->data()->toHaveKey('q', 'intitle:'.$title);
return true;
});
});
it('获取多本图书的列表', function () {
// 从Google Books API生成一个假响应。
$this->fakeQueryBooksByTitleResponse([
$this->createItem(),
$this->createItem(),
$this->createItem(),
]);
$response = resolve(QueryBooksByTitle::class)('Fake Title');
expect($response->items)->toHaveCount(3);
});
it('抛出异常', function () {
// 从Google Books API生成一个假响应。
$this->fakeQueryBooksByTitleResponse([
$this->createItem(),
], 400);
resolve(QueryBooksByTitle::class)('Fake Title');
})->throws(RequestException::class);通过这样做,我们现在有了更干净的测试,并且我们的API响应被映射到DTO中。对于更多的优化,您可以考虑使用Spatie提供的Laravel Data包来创建DTO,它可以帮助减少一些创建fromArray和toArray方法的模板代码。
总结
在这篇文章中,你学习了如何通过使用数据传输对象(DTO)来简化 Laravel 中开发和测试 API 集成的过程。
我们探讨了使用 DTO 的好处,以及如何创建 DTO、将 API 响应映射到 DTO,并开发测试响应辅助函数。这不仅提高了代码的可读性,还促进了更加类型安全、高效和可测试的开发流程。文章来源:https://www.toymoban.com/article/694.html
这些技术不仅适用于 Laravel 的 API 集成,也适用于任何类型的 API 集成。然而,如果你希望了解更高级的解决方案,我推荐看一下 Saloon PHP 库。文章来源地址https://www.toymoban.com/article/694.html
到此这篇关于使用DTO在Laravel中简化API响应的文章就介绍到这了,更多相关内容可以在右上角搜索或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!











