上下文

简介

你有没有遇到过这种情况:用户在网页上点了个按钮,触发了一个队列任务,任务在后台跑着,突然出错了。你去看日志,日志里只有任务自己的信息——播客 ID、处理状态——但根本不知道是哪个用户触发的、从哪个页面来的、请求的 URL 是什么。你想:要是队列任务的日志也能带上原始请求的信息就好了。

再比如:你在中间件里根据用户的 Accept-Language 头设置了 app.locale,邮件通知也按这个语言发。但邮件是队列任务发的,队列任务跑在另一个 PHP 进程里,根本读不到你在 HTTP 请求中设置的语言——邮件发出去了,语言却不对。

Laravel 的上下文(Context)就是来解决这个问题的。简单说,它是一个贯穿请求、队列任务、命令整个生命周期的「数据背包」——你在请求阶段往里面塞数据(追踪 ID、用户信息、语言设置等),框架会自动把这些数据带到队列任务里,任务执行时背包还在,数据直接就能用。而且,背包里的数据还会自动附加到日志中,让你在排查问题时能看到完整的上下文信息。

工作原理

上下文的核心机制很简单:你在请求中用 Context 门面存数据,Laravel 会自动帮你做两件事:

  1. 写入日志时自动附加——每条日志后面都会带上上下文里的数据,作为元数据
  2. 派发队列任务时自动传递——任务被派发到队列时,当前上下文会被序列化并随任务一起传输;任务在队列进程执行时,上下文会被还原回来

下面用日志来直观感受一下。首先,我们用一个中间件在每个进入的请求里把请求 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);
    }
}

有了这个中间件后,整个请求过程中写入的任何日志条目都会自动带上上下文里的数据。比如我们写了这样一条日志:

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

实际写入的日志长这样——你传给 Log::info() 的 auth_id 在前面,上下文里的 url 和 trace_id 作为元数据跟在后面:

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

再看队列传递的效果。假设我们在中间件里塞了数据后,在控制器里派发了一个 ProcessPodcast 任务到队列:

// In our middleware...
Context::add('url', $request->url());
Context::add('trace_id', Str::uuid()->toString());

// In our controller...
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,
        ]);

        // ...
    }
}

你会看到,任务的日志里也带上了最初那个 HTTP 请求中塞进上下文的数据——尽管任务跑在一个完全不同的 PHP 进程里:

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

以上就是上下文最核心的能力。下面我们逐个看它提供的 API——怎么存数据、怎么取数据、怎么删数据,以及一种不写入日志的「隐藏」数据模式。

捕获上下文

往上下文里存数据,用的是 Context 门面的 add() 方法:

use Illuminate\Support\Facades\Context;

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

要一次添加多个条目,可以传一个关联数组:

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"  —— 因为 key 已经存在,addIf 不会覆盖

上下文还提供了 increment() 和 decrement() 方法来递增或递减给定键的值。第一个参数是要跟踪的键,第二个参数可选,用来指定递增或递减的幅度:

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

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

条件上下文

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

作用域上下文

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,
// ]

注意

如果上下文中的对象在作用域闭包内被修改了(比如修改了对象的属性),这个修改会反映到作用域之外——scope 只恢复键值映射关系,不会深拷贝对象。

栈

除了存键值对,上下文还能存「栈」——一种按添加顺序排列的数据列表。用 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 查询,可以在 AppServiceProvider 的 boot() 方法里注册一个查询监听器,每次执行查询时把 SQL 和耗时推入栈:

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

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

这样,请求结束时日志里就会带上整个请求执行过的所有查询记录。

你可以用 stackContains() 和 hiddenStackContains() 方法判断某个值是否在栈中:

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

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

这两个方法也接受闭包作为第二个参数,让你自定义比较逻辑:

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

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

检索上下文

用 get() 方法从上下文中取数据:

use Illuminate\Support\Facades\Context;

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

only() 和 except() 方法可以取一部分数据——only() 只取指定的键,except() 排除指定的键:

$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']

remember() 和 rememberHidden() 方法相当于「取不到就设默认值」——如果键存在就返回已有值,不存在就执行闭包,把闭包返回值存入上下文并返回:

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

要获取上下文中的所有数据,调用 all() 方法:

$data = Context::all();

判断元素是否存在

用 has() 和 missing() 方法判断上下文中是否存储了给定键的值:

use Illuminate\Support\Facades\Context;

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

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

注意,has() 方法不管值是什么都返回 true——即使值是 null,只要键存在就返回 true:

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]

传数组可以一次移除多个键:

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

隐藏上下文

有时候你想在上下文里存一些数据,但不想让它们出现在日志里——比如密码、令牌、用户 ID 等敏感信息。上下文提供了「隐藏」数据模式:隐藏数据不会附加到日志中,也不能通过上面提到的普通方法(get、all 等)访问,必须用专门的 Hidden 系列方法:

use Illuminate\Support\Facades\Context;

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

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

Context::get('key');
// null  —— 普通方法取不到隐藏数据

「隐藏」方法与普通方法一一对应,用法完全相同,只是方法名多了 Hidden 后缀:

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

事件

上下文在队列任务的「脱水」和「注水」两个时机各派发一个事件,让你可以挂钩进去做自定义处理。

举个实际场景:你在中间件里根据 HTTP 请求的 Accept-Language 头设置了 app.locale。但队列任务跑在另一个进程里,这个配置值不会自动带过去。你可以用上下文事件来解决这个问题——在任务派发时(脱水)把 app.locale 存到隐藏上下文里,在任务执行时(注水)再把它恢复到配置中。

脱水

每当任务被派发到队列时,上下文中的数据会被「脱水」(序列化)并随任务载荷一起传输。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 门面,因为那会改变当前进程的上下文。请确保只对传给回调的仓库实例进行修改。

注水

每当队列任务开始执行时,与任务共享的上下文会被「注水」(反序列化)还原回当前上下文。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 门面,请确保只对传给回调的仓库实例进行修改。