上下文

引言

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

它是如何工作的

理解 Laravel 的上下文功能最好的方法是通过内置的日志功能来实际查看它的作用。为了开始,您可以使用 Context facade 将信息添加到上下文中。在这个例子中,我们将使用一个【中间件】,在每个传入请求中将请求的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
{
    /**
     * 处理传入的请求。
     */
    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;

    // ...

    /**
     * 执行作业。
     */
    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"

条件上下文

when 方法可用于根据给定条件将数据添加到上下文中。传递给 when 方法的第一个闭包将在给定条件评估为 true 时调用,而第二个闭包将在条件评估为 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', []),
);

堆栈

上下文(Context)提供了创建 “堆栈” 的功能,这些堆栈是按添加顺序存储的数据列表。你可以通过调用 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;

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');

only 方法可以用来检索上下文中部分信息:

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

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

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

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

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

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

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

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

$data = Context::all();

这些方法提供了从上下文中获取、移除和管理数据的不同方式,帮助你更灵活地操作上下文数据。

确定项的存在

你可以使用 has 方法来判断上下文中是否为给定的键存储了任何值:

use Illuminate\Support\Facades\Context;

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

has 方法会返回 true,无论存储的值是什么。因此,举个例子,若某个键的值为 null,它仍然会被视为存在:

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

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

这个方法可以帮助你检查上下文中是否存储了某个键的值,无论该值是 null 还是其它任何值。

移除上下文

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']);

隐藏上下文

上下文(Context)提供了存储 “隐藏” 数据的功能。这些隐藏信息不会被添加到日志中,也无法通过上面文档中提到的数据检索方法访问。上下文提供了一套不同的方法来操作隐藏的上下文信息:

use Illuminate\Support\Facades\Context;

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

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

Context::get('key');
// null
[source, php]

这些 “隐藏” 方法与上述非隐藏方法的功能相似,具体包括:

  • Context::addHidden(/* …​ */);

  • Context::addHiddenIf(/* …​ */);

  • Context::pushHidden(/* …​ */);

  • Context::getHidden(/* …​ */);

  • Context::pullHidden(/* …​ */);

  • Context::popHidden(/* …​ */);

  • Context::onlyHidden(/* …​ */);

  • Context::allHidden(/* …​ */);

  • Context::hasHidden(/* …​ */);

  • Context::forgetHidden(/* …​ */);

这些方法允许你与隐藏的数据进行交互,而不暴露在日志或通过常规的上下文数据访问方法中。

事件

上下文(Context)会触发两个事件,允许你在上下文的水化(hydration)和脱水(dehydration)过程中进行钩子操作。

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

脱水(Dehydrating)

每当一个任务被分派到队列时,上下文中的数据会被 “脱水”(dehydrated),并与任务的有效载荷一起捕获。Context::dehydrating 方法允许你注册一个闭包,这个闭包会在脱水过程中被调用。在这个闭包中,你可以修改将与队列任务共享的数据。

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

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

/**
 * 启动任何应用服务。
 */
public function boot(): void
{
    Context::dehydrating(function (Repository $context) {
        $context->addHidden('locale', Config::get('app.locale'));
    });
}

在脱水回调中,不应该使用 Context facade,因为那样会改变当前进程的上下文。确保只修改传递给回调的仓库(repository)数据。

水合(Hydrated)

每当一个队列中的任务开始执行时,任何与任务共享的上下文数据将被 “水化”(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'));
        }
    });
}

你不应该在水化回调中使用 Context facade,而应确保只对传递给回调的存储库进行修改。