使用DTO在Laravel中简化API响应

8,269次阅读
没有评论

共计 1082 个字符,预计需要花费 3 分钟才能阅读完成。

创建自定义数据传输对象(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 类。

 $this->kind,
            'items' => $this->items,
            'totalItems' => $this->totalItems,
        ];
    }
}

创建完 DTO 后,我们可以更新之前文章中创建的 QueryBooksByTitle 动作。

setQuery('q', 'intitle:'.$title)
            ->setQuery('printType', 'books');
        $response = $client->send($request);
        return BooksListData::fromArray($response->json());
    }
}

Test the Response Data

我们可以创建一个测试来确保在调用该动作时返回 BooksListData 对象:

toBeInstanceOf(BooksListData::class);
});

你可能没有注意到,但上面的测试存在一个问题。我们正在访问 Google Books API。这对于不经常运行的集成测试可能没问题,但在我们的 Laravel 测试中,应该修复这个问题。我们可以利用 Http facade 的功能来解决这个问题,因为我们的 Client 类是使用该 facade 构建的。

防止测试中的 HTTP 请求

我喜欢做的第一步是确保我的测试没有进行我没有预期的外部 HTTP 请求。我们可以将 `Http::preventStrayRequests();` 添加到 Pest.php 文件中。然后,在使用 Http facade 发出请求的任何测试中,除非我们模拟请求,否则会引发异常。

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 来伪造响应。

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 AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
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 AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
use IlluminateSupportCollection;
readonly class VolumeInfoData implements Arrayable
{
    public function __construct(
        public string $title,
        public string $subtitle,
        // 使用集合而不是数组是个人偏好。// 这使得处理数据稍微更容易一些。/** @var Collection */
        public Collection $authors,
        public string $publisher,
        public string $publishedDate,
        public string $description,
        public int $pageCount,
        /** @var Collection */
        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 AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
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 AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
use IlluminateSupportCollection;
/**
 * 存储来自 Google Books volumes API 的顶级数据。*/
readonly class BooksListData implements Arrayable
{
    public function __construct(
        public string $kind,
        /** @var Collection */
        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)

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 来帮助我更快地模拟响应。

 '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,我们现在可以轻松地添加其他测试,而无需在每个测试中编写所有的模拟数据。

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 (IlluminateHttpClientRequest $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 模板网!

    正文完
     0
    Yojack
    版权声明:本篇文章由 Yojack 于1970-01-01发表,共计1082字。
    转载说明:
    1 本网站名称:优杰开发笔记
    2 本站永久网址:https://yojack.cn
    3 本网站的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系站长进行删除处理。
    4 本站一切资源不代表本站立场,并不代表本站赞同其观点和对其真实性负责。
    5 本站所有内容均可转载及分享, 但请注明出处
    6 我们始终尊重原创作者的版权,所有文章在发布时,均尽可能注明出处与作者。
    7 站长邮箱:laylwenl@gmail.com
    评论(没有评论)