使用DTO在Laravel中简化API响应

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

Laravel DTO

介绍

有效处理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,并开发测试响应辅助函数。这不仅提高了代码的可读性,还促进了更加类型安全、高效和可测试的开发流程。

这些技术不仅适用于 Laravel 的 API 集成,也适用于任何类型的 API 集成。然而,如果你希望了解更高级的解决方案,我推荐看一下 Saloon PHP 库。文章来源地址https://www.toymoban.com/diary/laravel/694.html

到此这篇关于使用DTO在Laravel中简化API响应的文章就介绍到这了,更多相关内容可以在右上角搜索或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

原文地址:https://www.toymoban.com/diary/laravel/694.html

如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请联系站长进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用
使用 Laravel Mock 提高您的工作效率
上一篇 2024年01月18日 10:51
解决 SVN 错误 "Could not open the requested SVN filesystem"
下一篇 2024年01月20日 01:06

相关文章

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包