Laravel 上下文属性实用指南


# Laravel 上下文属性实用指南

Laravel 充满了各种特性,有些特性一旦被你发现,会让你既惊叹又略感尴尬——为什么之前从未知道它们的存在?对我来说,ContextualAttribute(上下文属性)就是其中之一。它是一个强大的工具,通过整理依赖项的解析和注入方式,帮助你编写更简洁、更具表达力且高度可维护的代码。

本文将简要讨论什么是上下文属性、它们为何如此有用,并通过一个实际示例,展示如何使用它们来简化 API 开发中的一个常见问题。

#什么是上下文属性?

本质上,上下文属性是一种特殊的 PHP 8 属性,它允许你控制服务容器如何为特定变量解析依赖项。

考虑一个典型的控制器方法:

public function update(Request $request, string $id)
{
    // ...
}

当你类型提示 Request 时,Laravel 的服务容器知道要注入当前的 HTTP 请求实例。但如果你需要注入更具体的内容,比如需要复杂逻辑才能解析的东西呢?例如,注入一个由请求头中的 API 密钥(而非 URL 参数)确定的 Workspace 模型。这就是上下文属性大显身手的地方。

它们允许你将一个属性附加到参数上,例如 #[CurrentWorkspace],并告诉 Laravel:“嘿,当你在某个参数(比如 $workspace 或任何参数)上看到这个属性时,请使用我的自定义逻辑来解析它。”

上下文属性在 Laravel 11 中引入。Laravel 本身已经内置了几个,如 #[Storage], #[Auth], #[Cache], #[Config], #[DB], #[Log]#[CurrentUser]。这些属性都挂钩到容器的解析过程中,根据请求的上下文注入特定值。

# 何时应该使用上下文属性?

上下文属性并非用于日常的依赖注入。在大多数情况下,简单的类型提示通常就足够了(例如路由模型绑定)。它们擅长解决依赖项解析不直接、无法通过 Laravel 标准机制处理的情况。

以下是它们非常适合的几个场景:

  1. API 密钥认证:当你的 API 需要根据 API 密钥(而非 URL 参数)来确定工作区/租户时——非常适合像 Stripe、Paystack 或 Flutterwave 这样的服务,其中资源与身份验证令牌绑定。
  2. 基于请求头的上下文:从自定义请求头、用户代理或其他不属于 URL 的请求元数据中解析资源。
  3. 复杂的授权模式:当你需要解析一个资源,该资源需要多重授权检查或超出简单路由模型绑定的业务逻辑时。
  4. 横切依赖项:注入那些在多个控制器中都需要、但由复杂的应用状态(而非简单参数)决定的对象。

以及更多其他场景。

# 实战示例:使用 Laravel Passport 解析工作区

让我们构建一个真实示例。假设我们正在开发一个类似于 Google Analytics 或 Mixpanel 的 SaaS 分析平台。我们可以使用 Laravel Passport 进行 API 身份验证,其中每个 API 令牌都限定在特定的工作区(Workspace)范围内。当客户端进行 API 调用时(如 POST /api/eventsGET /api/analytics/pageviews),他们会在 Authorization 请求头中发送其 Passport 令牌。

Laravel Passport 处理身份验证并给我们提供 $request->user(),但我们仍然需要:

  1. 确定当前令牌限定在哪个工作区
  2. 验证该工作区是否处于活跃状态且用户有权访问
  3. 将解析出的工作区注入到我们的控制器中

这里的关键在于:虽然 Passport 处理了身份验证,但工作区解析仍然需要复杂的业务逻辑,否则这些逻辑将在每个控制器方法中重复出现。

# 旧方法:在每个控制器中手动解析工作区

即使 Passport 处理了身份验证,传统方法仍然涉及重复的工作区解析逻辑:

app/Http/Controllers/AnalyticsController.php

class AnalyticsController extends Controller
{
    public function pageviews(Request $request)
    {
        // Passport 提供了已认证的用户
        $user = $request->user();

        // 但我们仍然需要弄清楚这个令牌对应哪个工作区
        $token = $user->token();

        // 从令牌作用域中提取工作区 (例如 'workspace:123')
        $workspaceScope = collect($token->scopes)->first(function ($scope) {
            return str_starts_with($scope, 'workspace:');
        });

        if (!$workspaceScope) {
            return response()->json([
                'error' => '访问被拒',
                'message' => '提供的 API 令牌无权访问任何工作区。请确保您的令牌包含所需的工作区作用域。',
                'code' => 'WORKSPACE_SCOPE_MISSING'
            ], 403);
        }

        $workspaceId = str_replace('workspace:', '', $workspaceScope);

        // 查找并验证工作区
        $workspace = Workspace::where('id', $workspaceId)
            ->where('is_active', true)
            ->whereHas('users', function ($query) use ($user) {
                $query->where('id', $user->id);
            })
            ->first();

        if (!$workspace) {
            return response()->json([
                'error' => '访问被拒',
                'message' => '找不到该工作区或您无权访问。请验证工作区 ID 和您的权限。',
                'code' => 'WORKSPACE_ACCESS_DENIED'
            ], 403);
        }

        // 最后,才是实际的业务逻辑
        $pageviews = $workspace->analytics()
            ->where('event_type', 'pageview')
            ->whereBetween('created_at', [$request->start_date, $request->end_date])
            ->count();

        return response()->json(['pageviews' => $pageviews]);
    }

    public function storeEvent(Request $request)
    {
        // 我们必须再次重复所有的工作区解析逻辑!
        $user = $request->user();
        $token = $user->token();

        $workspaceScope = collect($token->scopes)->first(function ($scope) {
            return str_starts_with($scope, 'workspace:');
        });

        if (!$workspaceScope) {
            return response()->json([
                'error' => '访问被拒',
                'message' => '提供的 API 令牌无权访问任何工作区。请确保您的令牌包含所需的工作区作用域。',
                'code' => 'WORKSPACE_SCOPE_MISSING'
            ], 403);
        }

        $workspaceId = str_replace('workspace:', '', $workspaceScope);

        $workspace = Workspace::where('id', $workspaceId)
            ->where('is_active', true)
            ->whereHas('users', function ($query) use ($user) {
                $query->where('id', $user->id);
            })
            ->first();

        if (!$workspace) {
            return response()->json([
                'error' => '访问被拒',
                'message' => '找不到该工作区或您无权访问。请验证工作区 ID 和您的权限。',
                'code' => 'WORKSPACE_ACCESS_DENIED'
            ], 403);
        }

        // 业务逻辑...
        $workspace->analytics()->create($request->validated());

        return response()->json(['success' => true]);
    }
}

这太痛苦了!即使 Passport 处理了身份验证,我们仍然必须在每个 API 方法中重复复杂的工作区解析逻辑。

# 更好的方法:中间件 + 上下文绑定

在深入探讨上下文属性之前,让我们看看一个早在 Laravel 6 就存在的替代方案:结合使用中间件和上下文绑定。这种方法可以解决我们的重复问题,但如你所见,它有一些缺点。

# 步骤 1:工作区解析中间件

首先,我们创建一个中间件,它从已认证用户的令牌作用域中解析工作区:

app/Http/Middleware/ResolveWorkspaceFromToken.php

<?php
namespace App\Http\Middleware;
use App\Models\Workspace;
use Closure;
use Illuminate\Http\Request;
class ResolveWorkspaceFromToken
{
    public function handle(Request $request, Closure $next)
    {
        // Passport 已经认证了用户
        $user = $request->user();
        if (!$user) {
            return response()->json(['error' => '未认证'], 401);
        }
        // 获取当前的 Passport 令牌
        $token = $user->token();
        $workspaceScope = collect($token->scopes)->first(function ($scope) {
            return str_starts_with($scope, 'workspace:');
        });
        // 从令牌中提取工作区作用域 (例如 'workspace:123')
         if (!$workspaceScope) {
            return response()->json([
                'error' => '访问被拒',
                'message' => '提供的 API 令牌无权访问任何工作区。请确保您的令牌包含所需的工作区作用域。',
                'code' => 'WORKSPACE_SCOPE_MISSING'
            ], 403);
        }
        // 从作用域中提取工作区 ID
        $workspaceId = str_replace('workspace:', '', $workspaceScope);
        // 查找并验证工作区
        $workspace = Workspace::where('id', $workspaceId)
            ->where('is_active', true)
            ->where('subscription_status', 'active') // 新增:检查订阅状态
            ->whereHas('users', function ($query) use ($user) {
                $query->where('id', $user->id)->where('role', '!=', 'banned'); // 新增:检查用户角色
            })
            ->first();
       if (!$workspace) {
            return response()->json([
                'error' => '访问被拒',
                'message' => '找不到该工作区或您无权访问。请验证工作区 ID 和您的权限。',
                'code' => 'WORKSPACE_ACCESS_DENIED'
            ], 403);
        }
        // 检查工作区是否超出 API 限制
        if ($workspace->hasExceededApiLimits()) { // 新增:API 限制检查
            return response()->json([
                'error' => '超出速率限制',
                'message' => '此工作区已超出其 API 速率限制。请升级您的套餐或稍后再试。',
                'code' => 'RATE_LIMIT_EXCEEDED'
            ], 429);
        }
        // 将解析出的工作区绑定到容器
        app()->instance('workspace.current', $workspace);
        return $next($request);
    }
}

# 步骤 2:注册中间件 (Laravel 11 之前)

对于 Laravel 11 之前的版本,我们在 app/Http/Kernel.php 中注册中间件:

app/Http/Kernel.php

protected $routeMiddleware = [
    // ... 其他中间件
    'workspace.resolve' => \App\Http\Middleware\ResolveWorkspaceFromToken::class,
];

# 步骤 3:设置上下文绑定

在你的 AppServiceProvider 中,我们设置上下文绑定。我们本质上是在告诉 Laravel:“当这些特定的控制器需要 Workspace 时,给它们这个已解析的实例。” 如下所示:

app/Providers/AppServiceProvider.php

use App\Http\Controllers\AnalyticsController;
use App\Http\Controllers\EventController;
use App\Http\Controllers\ReportsController;
use App\Models\Workspace;
public function register()
{
    $this->app->when([AnalyticsController::class, EventController::class, ReportsController::class])
        ->needs(Workspace::class)
        ->give(function () {
            return app('workspace.current'); // 返回中间件绑定的实例
        });
}

# 步骤 4:更新 API 路由

routes/api.php

Route::middleware(['auth:api', 'workspace.resolve'])->prefix('api')->group(function () {
    Route::post('/events', [AnalyticsController::class, 'storeEvent']);
    Route::get('/analytics/pageviews', [AnalyticsController::class, 'pageviews']);
    // ... 所有需要工作区作用域的 API 端点
});

# 步骤 5:清理控制器

现在我们的控制器可以变得很简洁:

app/Http/Controllers/AnalyticsController.php

class AnalyticsController extends Controller
{
    public function pageviews(Request $request, Workspace $workspace)
    {
        // 很简洁!但 $workspace 是从哪里来的?🤔
        $pageviews = $workspace->analytics()
            ->where('event_type', 'pageview')
            ->whereBetween('created_at', [$request->start_date, $request->end_date])
            ->count();
        return response()->json(['pageviews' => $pageviews]);
    }
}

虽然这种方法有效并消除了重复,但它有两个主要缺点:

  1. 它像“魔法”般令人困惑:当新成员加入你的项目并看到控制器中的 Workspace $workspace 时,他们不知道它从哪里来。他们可能假设它来自路由模型绑定,或者对 Laravel 如何解析它感到困惑。你需要深入研究服务提供者才能理解解析逻辑。
  2. 需要为每个控制器手动配置:每次创建一个需要工作区的新控制器时,你必须记得在服务提供者的 when()->needs()->give() 绑定中添加它。这很繁琐且容易出错——你可能会忘记添加,然后疑惑为什么你的控制器没有接收到工作区。

替代模式
在中间件中解析工作区并将其附加到请求对象上:

// 在中间件中
$request->merge(['current_workspace' => $workspace]);

// 在控制器中
$workspace = $request->current_workspace;

虽然这可行,但你失去了类型安全性,并且依赖关系变得不那么明确。

# 优雅之道:中间件 + 上下文属性 (Laravel 11+)

现在让我们看看上下文属性如何解决上下文绑定和请求合并方法中的两个问题。我们将使用相同的中间件,但不是为 AppServiceProvider 中的每个控制器注册上下文绑定,而是创建一个上下文属性。

# 步骤 1:注册中间件 (Laravel 11+)

如果使用 Laravel 11+,我们在 bootstrap/app.php 文件中注册这个中间件:

bootstrap/app.php

<?php
use App\Http\Middleware\ResolveWorkspaceFromToken;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // 注册我们的工作区解析中间件
        $middleware->alias([
            'workspace.resolve' => ResolveWorkspaceFromToken::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

# 步骤 2:自定义上下文属性

接下来,我们创建上下文属性,它从容器中检索工作区:

app/Http/Attributes/CurrentWorkspace.php

<?php
namespace App\Http\Attributes;
use App\Models\Workspace;
use Attribute;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute; // Laravel 11+ 接口
#[Attribute(Attribute::TARGET_PARAMETER)] // 指定此属性可用于参数
class CurrentWorkspace implements ContextualAttribute // 实现 Laravel 的接口
{
    /**
     * 从服务容器解析当前工作区。
     *
     * 当 Laravel 的服务容器在依赖注入期间遇到参数上的此属性时,会调用此方法。
     */
    public static function resolve(self $attribute, Container $container): Workspace
    {
        // 中间件已经完成了所有繁重的工作:
        // - 从 Passport 令牌中提取工作区作用域
        // - 验证工作区访问权限
        // - 检查订阅状态和 API 限制
        // - 将工作区绑定到容器
        //
        // 我们只需检索已解析的工作区实例
        return $container->make('workspace.current'); // 获取中间件绑定的实例
    }
}

# 步骤 3:更新 API 路由

更新你的 API 路由以同时使用 Passport 身份验证和工作区解析:

routes/api.php

Route::middleware(['auth:api', 'workspace.resolve'])->prefix('api')->group(function () {
    Route::post('/events', [AnalyticsController::class, 'storeEvent']);
    Route::get('/analytics/pageviews', [AnalyticsController::class, 'pageviews']);
    Route::get('/analytics/sessions', [AnalyticsController::class, 'sessions']);
    Route::get('/analytics/users', [AnalyticsController::class, 'users']);
    // ... 所有需要工作区作用域的 API 端点
});

# 步骤 4:简洁的控制器

现在是收获简洁的时刻!我们的控制器变得极其清晰且专注于业务:

app/Http/Controllers/AnalyticsController.php

<?php
namespace App\Http\Controllers;
use App\Http\Attributes\CurrentWorkspace;
use App\Http\Requests\StoreEventRequest;
use App\Models\Workspace;
use Illuminate\Http\Request;
class AnalyticsController extends Controller
{
    /**
     * 获取当前工作区的页面浏览分析数据。
     */
    public function pageviews(Request $request, #[CurrentWorkspace] Workspace $workspace)
    {
        $pageviews = $workspace->analytics()
            ->where('event_type', 'pageview')
            ->whereBetween('created_at', [$request->start_date, $request->end_date])
            ->selectRaw('DATE(created_at) as date, COUNT(*) as count')
            ->groupBy('date')
            ->orderBy('date')
            ->get();
        return response()->json(['data' => $pageviews]);
    }
    /**
     * 存储一个新的分析事件。
     */
    public function storeEvent(StoreEventRequest $request, #[CurrentWorkspace] Workspace $workspace)
    {
        $event = $workspace->analytics()->create([
            'event_type' => $request->event_type,
            'properties' => $request->properties,
            'user_id' => $request->user_id,
            'session_id' => $request->session_id,
            'ip_address' => $request->ip(),
            'user_agent' => $request->userAgent(),
        ]);
        return response()->json(['success' => true, 'event_id' => $event->id]);
    }
    /**
     * 获取用户分析数据。
     */
    public function users(Request $request, #[CurrentWorkspace] Workspace $workspace)
    {
        $users = $workspace->analytics()
            ->distinct('user_id')
            ->whereBetween('created_at', [$request->start_date, $request->end_date])
            ->count('user_id');
        return response()->json(['unique_users' => $users]);
    }
    /**
     * 注意这有多简洁 - 用户和工作区都被注入了!
     */
    public function profile(Request $request, #[CurrentWorkspace] Workspace $workspace)
    {
        $user = $request->user(); // 来自 Passport
        return response()->json([
            'user' => $user->only(['name', 'email']),
            'workspace' => $workspace->only(['name', 'plan']),
            'permissions' => $user->permissionsForWorkspace($workspace), // 假设有此方法
        ]);
    }
}

看看这有多简洁!每个方法都接收到了已认证的用户(来自 Passport)和已解析的工作区(来自我们的上下文属性),没有任何样板代码。

  1. 无需服务提供者配置:我们完全消除了在 AppServiceProvider 中为每个控制器注册上下文绑定的需要。新的控制器自动与 #[CurrentWorkspace] 配合使用,无需任何额外设置。
  2. 自文档化代码:当开发者看到 #[CurrentWorkspace] Workspace $workspace 时,他们立即明白这是一个自定义解析的依赖项。属性名称清晰地表明了它的目的。
  3. 零维护开销:添加任意多的控制器——它们都将使用相同的属性工作,无需修改任何服务提供者。

# 高级用法:多工作区上下文

你甚至可以针对不同的工作区访问模式使用不同的上下文属性:

class ApiController extends Controller
{
    // 对于限定在特定工作区的 API 令牌
    public function analytics(#[CurrentWorkspace] Workspace $workspace)
    {
        // $workspace 来自令牌作用域解析
    }
}
class WebController extends Controller
{
    // 对于可以在工作区之间切换的 Web 用户
    public function dashboard(#[SelectedWorkspace] Workspace $workspace)
    {
        // $workspace 可能来自会话或 URL 参数 (需另一个属性)
    }
}

# 测试你的上下文属性

测试变得很简单,因为你可以模拟容器绑定:

public function test_can_get_pageview_analytics()
{
    $user = User::factory()->create();
    $workspace = Workspace::factory()->create();
    // 创建一个带有工作区作用域的 Passport 令牌
    $token = $user->createToken('Test Token', ['workspace:' . $workspace->id]);
    // 模拟容器绑定(通常由中间件创建)
    $this->app->instance('workspace.current', $workspace); // 绑定模拟实例
    $response = $this->withHeaders([
        'Authorization' => 'Bearer ' . $token->accessToken,
    ])->get('/api/analytics/pageviews?start_date=2024-01-01&end_date=2024-01-31');
    $response->assertOk(); // 断言响应状态码为 200
}

# 结论

Laravel 的上下文属性提供了一种强大而优雅的方式来处理无法通过简单路由模型绑定解决的复杂依赖解析场景。它们非常适合以下情况:

  • 资源由请求头、API 密钥或其他非 URL 数据决定。
  • 你需要复杂的身份验证和授权逻辑。
  • 你希望获得类型安全的依赖注入,而不是从修改后的请求对象中提取数据。
  • 你存在影响多个控制器的横切关注点(Cross-cutting Concerns)。

这种模式将重复、易错的身份验证代码转变为简洁、声明式的控制器方法。随着 API 的增长和添加更复杂的身份验证要求,这种技术能优雅地扩展。

下次当你发现自己在多个 API 控制器中复制身份验证和资源解析逻辑时,考虑一下上下文属性是否可能提供更优雅的解决方案。


内容为 AI 翻译,旨在方便快速理解,有错误属于正常现象。
代码内容仅为功能性说明,不用在意合理性。

本作品采用《CC 协议》,转载必须注明作者和本文链接
? 我的导航网站已经可以公开使用啦:Cootab
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!