在 Laravel 中构建 API
发布时间 作者 Steve McDougall
在 Laravel 中构建 API 是一门艺术。您必须超越数据访问,将 Eloquent 模型封装在 API 端点中。
您需要做的第一件事是设计您的 API;最好的方法是考虑您的 API 的目的。您为什么要构建这个 API,目标用例是什么?确定好这些之后,您就可以根据 API 的集成方式有效地设计 API。
通过专注于 API 的集成方式,您可以在 API 发布之前消除 API 中的任何潜在痛点。这就是为什么我总是测试我构建的任何 API 的集成,以确保涵盖所有预期用例的顺利集成。
让我们通过一个例子来描述一下。我正在构建一家新的银行,Laracoin。我需要我的用户能够创建帐户并为这些帐户创建交易。我有一个 Account
模型,一个 Transaction
模型,还有一个 Vendor
模型,每个交易都属于它。例如:
Account -> Has Many -> Transaction -> Belongs To -> Vendor Spending Account -> Lunch 11.50 -> Some Restaurant
因此,我们有三个主要模型需要关注我们的 API。如果我们在没有设计引导思维的情况下进行处理,那么我们将创建以下路由
GET /accountsPOST /accountsGET /accounts/{account}PUT|PATCH /accounts/{account}DELETE /accounts/{account} GET /transactionsPOST /transactionsGET /transactions/{transaction}PUT|PATCH /transactions/{transaction}DELETE /transactions/{transaction} GET /vendorsPOST /vendorsGET /vendors/{vendor}PUT|PATCH /vendors/{vendor}DELETE /vendors/{vendor}
但是,这些路由的优势是什么?我们只是为 eloquent 模型创建 JSON 访问,这确实有效 - 但没有增加任何价值,而且从集成的角度来看,它让事情感觉很机械。
相反,让我们考虑一下我们 API 的设计和目的。我们的 API 很可能主要由内部移动和 Web 应用程序访问。我们首先会专注于这些用例。知道这一点意味着我们可以微调我们的 API 以适应我们应用程序中的用户旅程。因此,通常情况下,在这些应用程序中,我们会看到一个帐户列表,因为我们可以管理我们的帐户。我们还需要点击进入一个帐户以查看交易列表。然后,我们需要点击交易以查看更多详细信息。我们实际上永远不需要直接查看供应商,因为它们更多地用于分类而不是其他用途。考虑到这一点,我们可以围绕这些用例和原则设计我们的 API
GET /accountsPOST /accountsGET /accounts/{account}PUT|PATCH /accounts/{account}DELETE /accounts/{account} GET /accounts/{account}/transactionsGET /accounts/{account}/transactions/{transaction} POST /transactions
这将使我们能够有效地管理我们的帐户,并且只能通过其所属的帐户直接获取交易。我们现在不希望交易被编辑或管理。这些应该只创建 - 并且从那里,一个内部流程应该在需要时更新这些。
既然我们知道 API 的设计方式,我们就可以专注于如何构建这个 API,以确保它能够快速响应并在复杂性方面扩展。
首先,我们假设我们正在构建一个只包含 API 的 Laravel 应用程序 - 所以我们不需要任何 api
前缀。让我们考虑一下我们如何注册这些路由,因为这通常是应用程序中第一个出现问题的部分。一个繁忙的路由文件在心理上很难解析,认知负荷是任何应用程序中的第一个挑战。
如果这个 API 要面向公众,我会考虑支持版本化的 API,在这种情况下,我会创建一个版本目录,并将每个主要组放在一个专用文件中。但是,我们在这里没有使用版本控制,因此我们将以不同的方式组织它们。
我们要创建的第一个路由文件是 routes/api/accounts.php
,我们可以将其添加到我们的 routes/api.php
中。
Route::prefix('accounts')->as('accounts:')->middleware(['auth:sanctum', 'verified'])->group( base_path('routes/api/accounts.php),);
每个组都将加载其路由,设置默认的中间件前缀和路由命名模式。我们的 accounts
路由文件将是扁平的,分组最少,除非我们想查看子资源。这使我们能够在尝试理解路由本身时只查看一个区域,但这也意味着任何与帐户有关的内容都将属于此文件。
Route::get( '/', App\Http\Controllers\Accounts\IndexController::class,)->name('index');
我们的第一个路由是帐户索引路由,它将显示经过身份验证用户的全部帐户。除了身份验证路由之外,这可能是通过 API 调用的第一个内容,因此它通常是我首先关注的。关注最关键的路由首先可以解除其他团队的阻塞,而且这还使您可以完善您希望在应用程序中遵循的标准。
现在我们了解了如何路由请求,我们可以考虑如何处理这些请求。逻辑在哪里,如何确保代码重复量最小化?
我最近写了一篇关于如何使用 Eloquent 的有效方法 的教程,其中深入探讨了查询类。这是我偏好的方法,因为它确保代码重复量最少。我不会详细说明为什么我会使用这种方法,因为我在之前的教程中已经详细说明了。但是,我将介绍如何在应用程序中使用它。如果适合您的需求,您可以遵循这种方法。
关键要记住的是,充分利用 API 的最佳方法是按照适合您和您的团队的方式构建它。花几个小时尝试调整不自然的方法只会减慢您的速度,而不会带来您想要实现的效益。
创建查询类时,需要将相应的接口绑定到控制器。这不是一个必需的步骤。但是,我正在写教程 - 所以您期望什么呢?
interface FilterForUserContract{ public function handle(Builder $query, string $user): Builder;}
然后是我们想要使用的实现
final class FilterAccountsForUser implements FilterForUserContract{ public function handle(Builder $query, string $user): Builder { return QueryBuilder::for( subject: $query, )->allowedIncludes( include: ['transactions'], )->where('user_id', $user)->getEloquentBuilder(); }}
这个查询类将获取所有为传递的用户提供的帐户,允许您选择性地包含每个帐户的交易 - 然后将 eloquent 构建器传递回来,以便在需要时添加其他范围。
然后,我们可以在控制器中使用它来查询经过身份验证用户的帐户,然后在响应中返回它们。让我们看看如何使用这个查询来了解可用的选项。
final class IndexController{ public function __construct( private readonly Authenticatable $user, private readonly FilterForUserContract $query, ) {} public function __invoke(Request $request): Responsable { $accounts = $this->query->handle( query: Account::query()->latest(), user: $this->user->getAuthIdentifier(), ); // return response here. }}
此时,我们的控制器有一个 eloquent 构建器,它将传递给响应,因此在传递数据时,请确保调用 get
或 paginate
以正确地传递数据。这将我们引入了我的意见性旅程中的下一个阶段。
响应是 API 的主要职责。我们应该快速高效地响应,以便为我们的用户提供快速响应的 API 体验。我们作为 API 的响应方式可以分为两个方面,响应类以及数据如何转换为响应。
这两个方面是响应和 API 资源。我将从 API 资源开始,因为我非常关心它们。API 资源用于屏蔽数据库结构,以及将存储在 API 中的信息转换为最适合在客户端消耗的方式的方法。
我在我的 Laravel API 中使用 JSON:API 标准,因为它是一个非常棒的标准,在 API 社区中得到很好的记录和使用。幸运的是,Tim MacDonald 创建了一个很棒的包,用于在 Laravel 中创建 JSON:API 资源,我在所有 Laravel 应用程序中都依赖它。我最近 写了一篇教程,介绍如何使用这个包,所以这里只介绍一些细节。
让我们从帐户资源开始,它将被设置为具有相关的关系和属性。自从我上次的教程以来,这个包最近更新了,使设置关系变得更加容易。
final class AccountResource extends JsonApiResource{ public $relationships = [ 'transactions' => TransactionResource::class, ]; public function toAttributes(Request $request): array { return [ 'name' => $this->name, 'balance' => $this->balance->getAmount(), ]; }}
现在,我们将保持简单。我们希望返回帐户名称和余额,并可以选择加载交易关系。
使用这些资源意味着要访问名称,我们必须使用:data.attributes.name
,这可能需要一段时间才能在您的 Web 或移动应用程序中习惯,但您很快就会上手。我喜欢这种方法,因为我们可以分离关系和属性,并在需要时扩展它们。
一旦我们的资源填充完毕,我们就可以专注于其他领域,例如授权。这是我们 API 的一个重要部分,不容忽视。我们大多数人之前都使用过 Laravel 的 Gate,使用过 Gate Facade。然而,我喜欢从框架本身注入 Gate 契约。这主要是因为在有机会的时候,我更喜欢依赖注入而不是 Facade。让我们来看看这在 StoreController
中对于账户来说会是什么样子。
final class StoreController{ public function __construct( private readonly Gate $access, ) {} public function __invoke(StoreRequest $request): Responsable { if (! $this->access->allows('store')) { // respond with an error. } // the rest of the controller goes here. }}
这里我们只是像使用 Facade 一样使用 Gate 功能,因为它们是同一个东西。我在此使用 allows
,但你可以使用 can
或其他方法。你应该关注授权,而不是它的实现方式,因为这对于你应用程序来说只是一个次要细节。
所以我们知道我们希望数据在 API 中如何表示,以及我们希望在应用程序中如何授权用户。接下来,我们可以看看如何处理写入操作。
对于我们的 API 来说,写入操作至关重要。我们需要确保它们速度快,因为它们可以使我们的 API 感觉敏捷。
你可以通过多种方式在 API 中写入数据,但我更喜欢使用后台作业并快速返回。这意味着你可以不用担心客户端,在自己的时间内处理创建事物的方式的逻辑。好处是你的后台作业仍然可以通过 WebSockets 发布更新,以获得实时效果。
让我们看看更新后的 StoreController
,当我们使用这种方法时,它适用于账户。
final class StoreController{ public function __construct( private readonly Gate $access, private readonly Authenticatable $user, ) {} public function __invoke(StoreRequest $request): Responsable { if (! $this->access->allows('store')) { // respond with an error. } dispatch(new CreateAccount( payload: NewAccount::from($request->validated()), user: $this->user->getAuthIdentifier(), )); // the rest of the controller goes here. }}
我们向后台作业发送一个数据传输对象的有效负载,该对象将在队列上进行序列化。我们使用验证后的数据创建了这个 DTO,并希望通过用户 ID 发送它,因为我们需要知道为谁创建它。
遵循这种方法,我们有有效的、类型安全的数据被传递以创建模型。在我们的测试中,我们这里只需要确保作业被分派。
it('dispatches a background job for creation', function (string $string): void { Bus::fake(); actingAs(User::factory()->create())->postJson( uri: action(StoreController::class), data: [ 'name' => $string, ], )->assertStatus( status: Http::ACCEPTED->value, ); Bus::assertDispatched(CreateAccount::class);})->with('strings');
我们在这里进行测试,以确保我们通过了验证,从我们的 API 中获得了正确的状态码,然后确认正确的后台作业被分派。
在此之后,我们可以隔离地测试作业,因为它不需要包含在我们的端点测试中。现在,它将如何写入数据库?我们使用一个 Command
类来写入我们的数据。我使用这种方法是因为仅仅使用 Action 类会很乱。最终我们会得到数百个 Action 类,在我们的目录中寻找一个特定的类时,很难解析它们。
与往常一样,因为我喜欢使用依赖注入,我们需要创建用于解析实现的接口。
interface CreateNewAccountContract{ public function handle(NewAccount $payload, string $user): Model;}
我们使用 New Account DTO 作为有效负载,并将用户 ID 作为字符串传递。通常,我把它作为字符串;我会在我的应用程序中为 ID 字段使用 UUID 或 ULID。
final class CreateNewAccount implements CreateNewAccountContract{ public function handle(NewAccount $payload, string $user): Model { return DB::transaction( callback: fn (): Model => Account::query()->create( attributes: [ ...$payload->toArray(), 'user_id' => $user, ], ), ); }}
我们将写入操作封装在数据库事务中,这样只有在写入成功的情况下才会提交到数据库。如果写入不成功,它允许我们回滚并抛出异常。
我们已经涵盖了如何为我们的响应转换模型数据、如何查询和写入数据,以及我们希望如何在应用程序中授权用户。在 Laravel 中构建可靠 API 的最后阶段是看看我们如何作为 API 进行响应。
大多数 API 在响应方面都很糟糕。这很讽刺,因为它可能是 API 最重要的部分。在 Laravel 中,你可以通过多种方式进行响应,从使用辅助函数到返回 JsonResponse
的新实例。但是,我喜欢构建专门的响应类。它们类似于 Query 和 Command 类,旨在减少代码重复,但也是返回响应最可预测的方式。
我创建的第一个响应是集合响应,我将在返回经过身份验证的用户拥有的帐户列表时使用它。我还将创建其他响应的集合,从单个模型响应到空响应和错误响应。
class Response implements Responsable{ public function toResponse(): JsonResponse { return new JsonResponse( data: $this->data, status: $this->status->value, ); }}
首先,我们必须创建我们的响应类将扩展的初始响应。这是因为它们都将以相同的方式响应。它们都需要以相同的方式返回数据和状态码。所以现在,让我们看看集合响应类本身。
final class CollectionResponse extends Response{ public function __construct( private readonly JsonApiResourceCollection $data, private readonly Http $status = Http::OK, ) {}}
这非常简洁易于实施,你可以将 data
属性转换为联合类型,使其更灵活。
final class CollectionResponse extends Response{ public function __construct( private readonly Collection|JsonResource|JsonApiResourceCollection $data, private readonly Http $status = Http::OK, ) {}}
这些简洁易懂,所以让我们看看 IndexController
的最终实现,它适用于账户。
final class IndexController{ public function __construct( private readonly Authenticatable $user, private readonly FilterForUserContract $query, ) {} public function __invoke(Request $request): Responsable { $accounts = $this->query->handle( query: Account::query()->latest(), user: $this->user->getAuthIdentifier(), ); return new CollectionResponse( data: $accounts->paginate(), ); }}
关注这些关键领域,可以让你扩展 API 的复杂性,而不必担心代码重复。当试图找出导致 Laravel API 速度慢的原因时,这些是我始终关注的关键领域。
这绝不是一个详尽的教程或你需要关注的清单,但是遵循这个简短的指南,你可以为未来的成功做好准备。