简介

Laravel 的“上下文(context)”功能使你能够在应用程序内执行的请求、任务(job)和命令中捕获、检索和共享信息。这些被捕获的信息也会被包含在应用程序写入的日志中,使你能够更深入地了解写入日志条目之前发生的周围代码执行历史,并且允许你在整个分布式系统中追踪执行流程。

工作原理

理解 Laravel 上下文功能的最佳方式是使用内置的日志功能来观察其实际效果。开始使用时,你可以使用 Context 门面将信息添加到上下文中。在本示例中,我们将使用中间件(middleware),在每一个传入请求时,将请求 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);
    }
}

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

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

写入的日志将包含传递给该日志条目的 auth_id,同时也会将上下文中的 url 和 trace_id 作为元数据包含在内:

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

添加到上下文中的信息也会提供给分发到队列的任务(job)。例如,假设我们在向上下文中添加一些信息后,将一个 ProcessPodcast 任务分发到队列:

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

// In our controller...
ProcessPodcast::dispatch($podcast);

当任务被分发时,当前存储在上下文中的所有信息都会被捕获,并与该任务共享。随后,在任务执行期间,这些被捕获的信息会被重新填充(hydrated)回当前上下文中。因此,如果该任务的 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 门面的 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 方法可用于在满足指定条件时,将数据添加到上下文中。传递给 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,
// ]

如果在作用域闭包内修改了上下文中的某个对象,这种变更会在作用域外反映出来。

堆栈

上下文提供了创建“堆栈(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;

// 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')) {
    //
}

stackContains 和 hiddenStackContains 方法也支持将闭包作为第二个参数,以便更灵活地控制值的比较操作:

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

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

检索上下文

你可以使用 Context 门面的 get 方法,从上下文中检索信息:

use Illuminate\Support\Facades\Context;

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

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 值的键也会被视为存在:

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(/* ... */);

事件

上下文会触发两个事件,让你能够在上下文的序列化(脱水)和反序列化(水合)过程中挂钩。

为说明这些事件的用法,设想在应用的中间件中,根据传入 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 门面,而应确保仅对传入回调的仓库进行修改。