创建自定义数据传输对象(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/diary/laravel/694.html
这些技术不仅适用于 Laravel 的 API 集成,也适用于任何类型的 API 集成。然而,如果你希望了解更高级的解决方案,我推荐看一下 Saloon PHP 库。文章来源地址https://www.toymoban.com/diary/laravel/694.html
到此这篇关于使用DTO在Laravel中简化API响应的文章就介绍到这了,更多相关内容可以在右上角搜索或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!