上下文
简介
你有没有遇到过这种情况:用户在网页上点了个按钮,触发了一个队列任务,任务在后台跑着,突然出错了。你去看日志,日志里只有任务自己的信息——播客 ID、处理状态——但根本不知道是哪个用户触发的、从哪个页面来的、请求的 URL 是什么。你想:要是队列任务的日志也能带上原始请求的信息就好了。
再比如:你在中间件里根据用户的 Accept-Language 头设置了 app.locale,邮件通知也按这个语言发。但邮件是队列任务发的,队列任务跑在另一个 PHP 进程里,根本读不到你在 HTTP 请求中设置的语言——邮件发出去了,语言却不对。
Laravel 的上下文(Context)就是来解决这个问题的。简单说,它是一个贯穿请求、队列任务、命令整个生命周期的「数据背包」——你在请求阶段往里面塞数据(追踪 ID、用户信息、语言设置等),框架会自动把这些数据带到队列任务里,任务执行时背包还在,数据直接就能用。而且,背包里的数据还会自动附加到日志中,让你在排查问题时能看到完整的上下文信息。
工作原理
上下文的核心机制很简单:你在请求中用 Context 门面存数据,Laravel 会自动帮你做两件事:
- 写入日志时自动附加——每条日志后面都会带上上下文里的数据,作为元数据
- 派发队列任务时自动传递——任务被派发到队列时,当前上下文会被序列化并随任务一起传输;任务在队列进程执行时,上下文会被还原回来
下面用日志来直观感受一下。首先,我们用一个中间件在每个进入的请求里把请求 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 门面,请确保只对传给回调的仓库实例进行修改。