上下文
简介
Laravel 的「上下文」能力让你能够在应用内执行的请求、任务与命令中捕获、检索并共享信息。这些被捕获的信息也会包含在应用写入的日志中,让你更深入地了解日志条目写入之前的相关代码执行历史,并能在分布式系统中追踪执行流。
工作原理
理解 Laravel 上下文能力的最佳方式,是结合内置日志功能实际体验。开始时,可使用 Context facade 向上下文添加信息。本例中,我们将使用中间件,在每个传入请求上将请求 URL 与唯一的 trace 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,同时也会将上下文的 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,
]);
// ...
}
}最终的日志条目会包含最初派发该任务的请求期间添加到上下文的信息:
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 还提供了递增或递减给定键的便捷方法。这两个方法都至少接受一个参数:要跟踪的键。可提供第二个参数,指定递增或递减的量:
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,
// ]WARNING
若在作用域闭包内修改了上下文中的对象,该变更会反映到作用域之外。
栈
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;
// 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 facade 的 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']);隐藏上下文
Context 支持存储「隐藏」数据。这些隐藏信息不会追加到日志中,也无法通过上文文档中的数据检索方法访问。Context 提供了另一组方法来操作隐藏上下文信息:
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(/* ... */);事件
Context 会派发两个事件,让你能够挂接到上下文的水合与脱水过程。
为说明这些事件的用法,假设你在应用的中间件中根据传入 HTTP 请求的 Accept-Language 头设置了 app.locale 配置值。Context 的事件让你能在请求期间捕获该值,并在队列上恢复它,确保队列上发送的通知具有正确的 app.locale 值。我们可以使用 Context 的事件与隐藏数据来实现这一点,下文将予以说明。
脱水
每当任务被派发到队列时,上下文中的数据会被「脱水」,并与任务载荷一同捕获。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'));
});
}INFO
不应在 dehydrating 回调中使用 Context facade,因为那会更改当前进程的上下文。请确保只修改传给回调的 repository。
水合
每当队列任务开始在队列上执行时,与该任务共享的任何上下文都会被「水合」回当前上下文。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'));
}
});
}INFO
不应在 hydrated 回调中使用 Context facade,而应确保只修改传给回调的 repository。