Lzh on GitHub

简介

Laravel 的“上下文”功能使您能够捕获、检索和共享在应用程序中执行的请求、作业和命令中的信息。捕获的信息也会包含在您的应用程序编写的日志中,让您更深入地了解日志条目写入之前发生的周围代码执行历史,并允许您在分布式系统中跟踪执行流。

工作原理

理解 Laravel 上下文功能的最佳方式是结合其内置的日志功能来实际操作。首先,您可以使用 Context Facade 的 add 方法 将信息添加到上下文中。在这个例子中,我们将使用一个 中间件 来为每个传入的请求添加请求 URL 和一个唯一的追踪 ID 到上下文中:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class AddContext
{
    /**
     * Handle an incoming request.
     */
    public function handle(Request $request, Closure $next): Response
    {
        Context::add('url', $request->url());
        Context::add('trace_id', Str::uuid()->toString());

        return $next($request);
    }
}

添加到上下文中的信息会自动作为元数据附加到在整个请求过程中写入的任何 日志条目 中。将上下文作为元数据附加允许传递给单个日志条目的信息与通过 Context 共享的信息区分开来。例如,假设我们写入以下日志条目:

Log::info('User authenticated.', ['auth_id' => Auth::id()]);

写入的日志将包含传递给日志条目的 auth_id,但它也将包含上下文的 urltrace_id 作为元数据:

User authenticated. {"auth_id":27} {"url":"https://example.com/login","trace_id":"e04e1a11-e75c-4db3-b5b5-cfef4ef56697"}

添加到上下文中的信息也对分派到队列的作业可用。例如,假设我们在将一些信息添加到上下文后,将 ProcessPodcast 作业分派到队列:

// 在我们的中间件中...
Context::add('url', $request->url());
Context::add('trace_id', Str::uuid()->toString());

// 在我们的控制器中...
ProcessPodcast::dispatch($podcast);

当作业被分派时,当前存储在上下文中的任何信息都会被捕获并与作业共享。然后,在作业执行时,捕获的信息会被恢复到当前上下文中。因此,如果我们的作业的 handle 方法要写入日志:

class ProcessPodcast implements ShouldQueue
{
    use Queueable;

    // ...

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        Log::info('Processing podcast.', [
            'podcast_id' => $this->podcast->id,
        ]);

        // ...
    }
}

生成的日志条目将包含最初分派作业的请求期间添加到上下文中的信息:

Processing podcast. {"podcast_id":95} {"url":"https://example.com/login","trace_id":"e04e1a11-e75c-4db3-b5b5-cfef4ef56697"}

尽管我们主要关注 Laravel 上下文的内置日志相关功能,但以下文档将说明上下文如何让您在 HTTP 请求/排队作业边界之间共享信息,甚至如何添加 隐藏的上下文数据,这些数据不会随日志条目一起写入。

捕获上下文

您可以使用 Context Facade 的 add 方法在当前上下文中存储信息:

use Illuminate\Support\Facades\Context;

Context::add('key', 'value');

要一次添加多个项目,您可以向 add 方法传递一个关联数组:

Context::add([
    'first_key' => 'value',
    'second_key' => 'value',
]);

add 方法将覆盖共享相同键的任何现有值。如果您只想在键尚不存在时才将信息添加到上下文中,可以使用 addIf 方法:

Context::add('key', 'first');

Context::get('key');
// "first"

Context::addIf('key', 'second');

Context::get('key');
// "first"

上下文还提供了方便的方法来递增或递减给定的键。这两个方法都至少接受一个参数:要跟踪的键。可以提供第二个参数来指定键应该递增或递减的数量:

Context::increment('records_added');
Context::increment('records_added', 5);

Context::decrement('records_added');
Context::decrement('records_added', 5);

条件上下文

when 方法可用于根据给定条件向上下文中添加数据。如果给定条件求值为 true,则将调用传递给 when 方法的第一个闭包,如果条件求值为 false,则将调用第二个闭包:

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Context;

Context::when(
    Auth::user()->isAdmin(),
    fn ($context) => $context->add('permissions', Auth::user()->permissions),
    fn ($context) => $context->add('permissions', []),
);

作用域上下文

scope 方法提供了一种在给定回调的执行期间临时修改上下文并在回调执行完成后将上下文恢复到其原始状态的方法。此外,您可以在闭包执行期间传递应合并到上下文中的额外数据(作为第二个和第三个参数)。

use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Log;

Context::add('trace_id', 'abc-999');
Context::addHidden('user_id', 123);

Context::scope(
    function () {
        Context::add('action', 'adding_friend');

        $userId = Context::getHidden('user_id');

        Log::debug("Adding user [{$userId}] to friends list.");
        // Adding user [987] to friends list.  {"trace_id":"abc-999","user_name":"taylor_otwell","action":"adding_friend"}
    },
    data: ['user_name' => 'taylor_otwell'],
    hidden: ['user_id' => 987],
);

Context::all();
// [
//     'trace_id' => 'abc-999',
// ]

Context::allHidden();
// [
//     'user_id' => 123,
// ]
如果在作用域闭包内修改了上下文中的对象,该变动将反映在作用域之外。

堆栈(Stacks)

上下文提供了创建“堆栈”的能力,这些是按照添加顺序存储的数据列表。您可以通过调用 push 方法向堆栈中添加信息:

use Illuminate\Support\Facades\Context;

Context::push('breadcrumbs', 'first_value');

Context::push('breadcrumbs', 'second_value', 'third_value');

Context::get('breadcrumbs');
// [
//     'first_value',
//     'second_value',
//     'third_value',
// ]

堆栈可用于捕获有关请求的历史信息,例如应用程序中正在发生的事件。例如,您可以创建一个事件侦听器,每次执行查询时都将查询 SQL 和持续时间作为元组推送到堆栈:

use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\DB;

// 在 AppServiceProvider.php 中...
DB::listen(function ($event) {
    Context::push('queries', [$event->time, $event->sql]);
});

您可以使用 stackContainshiddenStackContains 方法来确定值是否在堆栈中:

if (Context::stackContains('breadcrumbs', 'first_value')) {
    //
}

if (Context::hiddenStackContains('secrets', 'first_value')) {
    //
}

stackContainshiddenStackContains 方法也接受一个闭包作为它们的第二个参数,允许对值比较操作进行更多控制:

use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;

return Context::stackContains('breadcrumbs', function ($value) {
    return Str::startsWith($value, 'query_');
});

检索上下文

您可以使用 Context Facade 的 get 方法从上下文中检索信息:

use Illuminate\Support\Facades\Context;

$value = Context::get('key');

onlyexcept 方法可用于检索上下文中的一个子集信息:

$data = Context::only(['first_key', 'second_key']);

$data = Context::except(['first_key']);

pull 方法可用于从上下文中检索信息并立即将其从上下文中移除:

$value = Context::pull('key');

如果上下文数据存储在 堆栈 中,您可以使用 pop 方法从堆栈中弹出项目:

Context::push('breadcrumbs', 'first_value', 'second_value');

Context::pop('breadcrumbs');
// second_value

Context::get('breadcrumbs');
// ['first_value']

rememberrememberHidden 方法可用于从上下文中检索信息,同时如果请求的信息不存在,则将上下文值设置为给定闭包返回的值:

$permissions = Context::remember(
    'user-permissions',
    fn () => $user->permissions,
);

如果您想检索上下文中存储的所有信息,可以调用 all 方法:

$data = Context::all();

确定项目是否存在

您可以使用 hasmissing 方法来确定上下文是否为给定键存储了任何值:

use Illuminate\Support\Facades\Context;

if (Context::has('key')) {
    // ...
}

if (Context::missing('key')) {
    // ...
}

has 方法将返回 true,无论存储的值是什么。因此,例如,一个具有 null 值的键将被视为存在:

Context::add('key', null);

Context::has('key');
// true

删除上下文

forget 方法可用于从当前上下文中删除一个键及其值:

use Illuminate\Support\Facades\Context;

Context::add(['first_key' => 1, 'second_key' => 2]);

Context::forget('first_key');

Context::all();

// ['second_key' => 2]

您可以通过向 forget 方法提供一个数组来一次忘记多个键:

Context::forget(['first_key', 'second_key']);

隐藏上下文

上下文提供了存储“隐藏”数据的功能。这些隐藏信息不会附加到日志中,并且无法通过上面文档中介绍的数据检索方法访问。上下文提供了一组不同的方法来与隐藏上下文信息进行交互:

use Illuminate\Support\Facades\Context;

Context::addHidden('key', 'value');

Context::getHidden('key');
// 'value'

Context::get('key');
// null

“隐藏”方法模仿了上面文档中非隐藏方法的功能:

Context::addHidden(/* ... */);
Context::addHiddenIf(/* ... */);
Context::pushHidden(/* ... */);
Context::getHidden(/* ... */);
Context::pullHidden(/* ... */);
Context::popHidden(/* ... */);
Context::onlyHidden(/* ... */);
Context::exceptHidden(/* ... */);
Context::allHidden(/* ... */);
Context::hasHidden(/* ... */);
Context::missingHidden(/* ... */);
Context::forgetHidden(/* ... */);

事件

上下文会分派两个事件,让您可以在上下文的“水合”(hydration)和“脱水”(dehydration)过程中进行挂钩。

为了说明如何使用这些事件,想象一下在您的应用程序的一个中间件中,您根据传入 HTTP 请求的 Accept-Language 标头设置 app.locale 配置值。上下文的事件允许您在请求期间捕获此值,并在队列上恢复它,确保在队列上发送的通知具有正确的 app.locale 值。我们可以使用上下文的事件和 隐藏 数据来实现这一点,以下文档将对此进行说明。

脱水(Dehydrating)

每当一个作业被分派到队列时,上下文中的数据都会“脱水”并与作业的有效负载一起捕获。Context::dehydrating 方法允许您注册一个闭包,该闭包将在脱水过程中被调用。在此闭包中,您可以更改将与排队作业共享的数据。

通常,您应该在应用程序 AppServiceProvider 类的 boot 方法中注册 dehydrating 回调:

use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Context;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Context::dehydrating(function (Repository $context) {
        $context->addHidden('locale', Config::get('app.locale'));
    });
}
您不应在 dehydrating 回调中使用 Context Facade,因为那会更改当前进程的上下文。请确保您只更改传递给回调的存储库。

水合(Hydrated)

每当一个排队作业开始在队列上执行时,与作业共享的任何上下文都将“水合”回当前上下文。Context::hydrated 方法允许您注册一个闭包,该闭包将在水合过程中被调用。

通常,您应该在应用程序 AppServiceProvider 类的 boot 方法中注册 hydrated 回调:

use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Context;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Context::hydrated(function (Repository $context) {
        if ($context->hasHidden('locale')) {
            Config::set('app.locale', $context->getHidden('locale'));
        }
    });
}
您不应在 hydrated 回调中使用 Context Facade,而应确保您只更改传递给回调的存储库。