創(chuàng)建自定義數(shù)據(jù)傳輸對象(DTO)的全面指南,以增強(qiáng)Laravel API集成的可讀性、效率和可測試性
介紹
有效處理API響應(yīng)對于集成第三方API非常重要。在之前的文章中,我討論了如何使用Http facade設(shè)置簡單的客戶端和請求類。如果你還沒有閱讀過這篇文章,我建議你去看一下。
在此基礎(chǔ)上,本文將為您詳細(xì)介紹如何創(chuàng)建自定義數(shù)據(jù)傳輸對象(DTO),以將數(shù)據(jù)映射到API響應(yīng)中。我將使用正在進(jìn)行的Google Books API集成場景作為實際示例,使事情更容易理解。
將響應(yīng)數(shù)據(jù)映射到DTO
首先,讓我們來看一下從Google Books API獲取搜索結(jié)果時的示例響應(yīng)。為此,我調(diào)用了之前創(chuàng)建的QueryBooksByTitle動作,并搜索書籍"The Ferryman":
$response = app(QueryBooksByTitle::class)("The Ferryman"); dump($response->json());
這將輸出以下JSON數(shù)據(jù),我只選擇了我想要追蹤的字段:
{ "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": "..." }, ... }, ... ] }
現(xiàn)在我們知道了響應(yīng)的格式,讓我們創(chuàng)建必要的DTO來映射數(shù)據(jù)。讓我們從BookListData開始,它可以是一個簡單的PHP類。
<?php namespace App\DataTransferObjects; use Illuminate\Contracts\Support\Arrayable; /** * 存儲來自Google Books volumes API的頂層數(shù)據(jù)。 */ readonly class BooksListData implements Arrayable { public function __construct( public string $kind, public string $id, public int $totalItems, ) { } /** * 從數(shù)據(jù)數(shù)組創(chuàng)建類的新實例。 */ public static function fromArray(array $data): BooksListData { return new self( data_get($data, 'kind'), data_get($data, 'id'), data_get($data, 'totalItems'), ); } /** * 實現(xiàn)Laravel的Arrayable接口,允許將對象序列化為數(shù)組。 */ public function toArray(): array { return [ 'kind' => $this->kind, 'items' => $this->items, 'totalItems' => $this->totalItems, ]; } }
創(chuàng)建完DTO后,我們可以更新之前文章中創(chuàng)建的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方法,接受一個標(biāo)題并返回API的響應(yīng)。 */ class QueryBooksByTitle { /** * 根據(jù)標(biāo)題從Google Books API查詢書籍,并返回BookListData。 * 該方法創(chuàng)建一個GoogleBooksApiClient和一個ApiRequest, * 使用給定的標(biāo)題作為'q'查詢參數(shù)和'books'作為'printType'查詢參數(shù), * 并使用客戶端發(fā)送請求,然后返回書籍列表數(shù)據(jù)。 */ 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
我們可以創(chuàng)建一個測試來確保在調(diào)用該動作時返回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。這對于不經(jīng)常運(yùn)行的集成測試可能沒問題,但在我們的Laravel測試中,應(yīng)該修復(fù)這個問題。我們可以利用Http facade的功能來解決這個問題,因為我們的Client類是使用該facade構(gòu)建的。
防止測試中的HTTP請求
我喜歡做的第一步是確保我的測試沒有進(jìn)行我沒有預(yù)期的外部HTTP請求。我們可以將`Http::preventStrayRequests();`添加到Pest.php文件中。然后,在使用Http facade發(fā)出請求的任何測試中,除非我們模擬請求,否則會引發(fā)異常。
<?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');
如果再次運(yùn)行QueryBooksByTitle測試,現(xiàn)在會出現(xiàn)一個失敗的測試,顯示以下錯誤信息:
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.
現(xiàn)在,讓我們使用Http facade來偽造響應(yīng)。
<?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生成一個假響應(yīng)。 $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(), ], ], ], ], ]; // 當(dāng)客戶端向Google Books API發(fā)送請求時,返回假響應(yīng)。 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); });
現(xiàn)在運(yùn)行測試,不再出現(xiàn)RuntimeException,因為我們使用Http::fake()方法偽造了請求。Http::fake()方法非常靈活,可以接受一個包含不同URL的項目數(shù)組。根據(jù)您的應(yīng)用程序,您可以只使用'*'而不是完整的URL,甚至可以更具體地包括查詢參數(shù)或其他動態(tài)URL數(shù)據(jù)。如果需要,甚至可以偽造請求序列。有關(guān)更多信息,請參閱Laravel文檔。
這個測試效果很好,但仍然有一些改進(jìn)的空間。
擴(kuò)展數(shù)據(jù)傳輸對象(DTO)
首先,讓我們再次看一下響應(yīng)數(shù)據(jù)。將頂層的響應(yīng)映射到BooksListData對象中是不錯的,但使用items[0]['volumeInfo']['title']并不方便開發(fā)人員,并且IDE無法提供任何類型的自動完成。為了解決這個問題,我們需要創(chuàng)建更多的DTOs。通常最容易從需要映射的最低級別的項開始。在這種情況下,需要映射響應(yīng)中的imageLinks數(shù)據(jù)。查看來自Google Books的響應(yīng),似乎該數(shù)據(jù)可能包含縮略圖和小縮略圖屬性。我們將創(chuàng)建一個ImageLinksData對象來映射這部分?jǐn)?shù)據(jù)。
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, // 使用集合而不是數(shù)組是個人偏好。 // 這使得處理數(shù)據(jù)稍微更容易一些。 /** @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'), // 從數(shù)據(jù)數(shù)組創(chuàng)建集合。 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'), // 從數(shù)據(jù)數(shù)組創(chuàng)建集合。 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, // 將集合轉(zhuǎn)換為數(shù)組,因為它們實現(xiàn)了可數(shù)組化接口。 'authors' => $this->authors->toArray(), 'publisher' => $this->publisher, 'publishedDate' => $this->publishedDate, 'description' => $this->description, 'pageCount' => $this->pageCount, 'categories' => $this->categories->toArray(), // 由于我們使用了可數(shù)組化接口,我們可以直接調(diào)用imageLinks對象的toArray方法。 'imageLinks' => $this->imageLinks->toArray(), ]; } }
請注意,我使用了Laravel的集合而不是數(shù)組。我更喜歡使用集合,因此每當(dāng)響應(yīng)中有數(shù)組時,我都會映射到集合。另外,由于VolumeInfoData包含imageLinks屬性,我們可以使用ImageLinksData對象進(jìn)行映射。
再往上走一級,我們有一個項的列表,所以我們可以創(chuàng)建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對象,而不是映射數(shù)據(jù)數(shù)組,我們想要映射一個ItemData對象的集合。
namespace App\DataTransferObjects; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Collection; /** * 存儲來自Google Books volumes API的頂級數(shù)據(jù)。 */ readonly class BooksListData implements Arrayable { public function __construct( public string $kind, /** @var Collection<int, ItemData> */ public Collection $items, public int $totalItems, ) { } /** * 從數(shù)據(jù)數(shù)組創(chuàng)建類的新實例。 */ 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'), ); } /** * 實現(xiàn)Laravel的Arrayable接口,允許將對象序列化為數(shù)組。 */ public function toArray(): array { return [ 'kind' => $this->kind, 'items' => $this->items->toArray(), 'totalItems' => $this->totalItems, ]; } }
有了所有新創(chuàng)建的DTO,讓我們回到測試并進(jìn)行更新。
測試完整的數(shù)據(jù)傳輸對象(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('按標(biāo)題獲取圖書', function () { $title = fake()->sentence(); // 從Google Books API生成一個假響應(yīng)。 $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(), ], ], ], ], ]; // 當(dāng)客戶端向Google Books API發(fā)送請求時,返回假響應(yīng)。 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); });
現(xiàn)在我們的期望中可以看到,響應(yīng)正在映射所有不同的DTO,并正確設(shè)置標(biāo)題。
通過使操作返回DTO而不是默認(rèn)的Illuminate/Http/Client/Response,我們現(xiàn)在對API響應(yīng)具有類型安全性,并在編輯器中獲得更好的自動完成,這極大地提高了開發(fā)人員的體驗。
創(chuàng)建測試響應(yīng)輔助函數(shù)
另一個我喜歡做的測試技巧是創(chuàng)建類似于響應(yīng)工廠的東西。在每個可能需要查詢圖書的單個測試中模擬響應(yīng)是耗時的,因此我更喜歡創(chuàng)建一個簡單的trait來幫助我更快地模擬響應(yīng)。
<?php namespace Tests\Helpers; use Illuminate\Support\Facades\Http; trait GoogleBooksApiResponseHelpers { /** * 為按標(biāo)題查詢圖書生成一個假響應(yīng)。 */ private function fakeQueryBooksByTitleResponse(array $items = [], int $status = 200, bool $raw = false): void { // 如果raw為true,則直接返回items數(shù)組。否則,從Google Books API返回一個假響應(yīng)。 $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 )]); } // 創(chuàng)建一個假的項目數(shù)組。 private function createItem(array $data = []): array { return [ 'id' => data_get($data, 'id', '123'), 'volumeInfo' => $this->createVolumeInfo(data_get($data, 'volumeInfo', [])), ]; } // 創(chuàng)建一個假的卷信息數(shù)組。 private function createVolumeInfo(array $data = []): array { return [ 'title' => data_get($data, 'title', fake()->sentence), 'subtitle' => data_get($data, 'subtitle', '圖書副標(biāo)題'), '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,我們現(xiàn)在可以輕松地添加其他測試,而無需在每個測試中編寫所有的模擬數(shù)據(jù)。
<?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('按標(biāo)題獲取圖書', function () { $title = fake()->sentence(); // 從Google Books API生成一個假響應(yīng)。 $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('將標(biāo)題作為查詢參數(shù)傳遞', function () { $title = fake()->sentence(); // 從Google Books API生成一個假響應(yīng)。 $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生成一個假響應(yīng)。 $this->fakeQueryBooksByTitleResponse([ $this->createItem(), $this->createItem(), $this->createItem(), ]); $response = resolve(QueryBooksByTitle::class)('Fake Title'); expect($response->items)->toHaveCount(3); }); it('拋出異常', function () { // 從Google Books API生成一個假響應(yīng)。 $this->fakeQueryBooksByTitleResponse([ $this->createItem(), ], 400); resolve(QueryBooksByTitle::class)('Fake Title'); })->throws(RequestException::class);
通過這樣做,我們現(xiàn)在有了更干凈的測試,并且我們的API響應(yīng)被映射到DTO中。對于更多的優(yōu)化,您可以考慮使用Spatie提供的Laravel Data包來創(chuàng)建DTO,它可以幫助減少一些創(chuàng)建fromArray和toArray方法的模板代碼。
總結(jié)
在這篇文章中,你學(xué)習(xí)了如何通過使用數(shù)據(jù)傳輸對象(DTO)來簡化 Laravel 中開發(fā)和測試 API 集成的過程。
我們探討了使用 DTO 的好處,以及如何創(chuàng)建 DTO、將 API 響應(yīng)映射到 DTO,并開發(fā)測試響應(yīng)輔助函數(shù)。這不僅提高了代碼的可讀性,還促進(jìn)了更加類型安全、高效和可測試的開發(fā)流程。文章來源:http://www.zghlxwxcb.cn/article/694.html
這些技術(shù)不僅適用于 Laravel 的 API 集成,也適用于任何類型的 API 集成。然而,如果你希望了解更高級的解決方案,我推薦看一下 Saloon PHP 庫。文章來源地址http://www.zghlxwxcb.cn/article/694.html
到此這篇關(guān)于使用DTO在Laravel中簡化API響應(yīng)的文章就介紹到這了,更多相關(guān)內(nèi)容可以在右上角搜索或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!