从 dd() 到 Ray:一种不打断工作流的调试方案
理解 Laravel 的请求生命周期来定位 bug 而非盲目猜测的方法。但即便确定了阶段,仍需实际查看代码中的运行情况。
对大多数 Laravel 开发者来说,这意味着一件事:dd()。
dd() 确实很棒。多年来它一直是我的首选方式。但不知从何时起,我意识到它也在拖慢我的进度。倒不是说它是个烂工具——而是因为它的运作方式。
让我来展示一下我的意思,以及我是如何将调试流程优化为能提供更多可见性,同时又不会频繁导致应用崩溃的方法
dd() 工作流 (以及为何它让人崩溃)
假设我正在调试结账流程。正在创建订单,但总额出错了。我想看看数据在系统中变动时发生了什么。
我的第一直觉:
public function store(CheckoutRequest $request)
{
$cart = Cart::findOrFail($request->cart_id);
dd($cart->items);
$order = Order::create([
'user_id' => auth()->id(),
'total' => $cart->calculateTotal(),
]);
// ... rest of the logic
}我到达端点,看到购物车上的商品。很好,但是我想同时看看 calculateTotal() 返回什么。所以我将 dd() 后移:
public function store(CheckoutRequest $request)
{
$cart = Cart::findOrFail($request->cart_id);
$total = $cart->calculateTotal();
dd($total);
$order = Order::create([
'user_id' => auth()->id(),
'total' => $total,
]);
// ... rest of the logic
}此处的总额没有出错,但是数据库中的总额错了。因此,现在我想看看订单创建后的情况:
public function store(CheckoutRequest $request)
{
$cart = Cart::findOrFail($request->cart_id);
$order = Order::create([
'user_id' => auth()->id(),
'total' => $cart->calculateTotal(),
]);
dd($order);
// ... rest of the logic
}看看我做了什么?每次我想确认新的节点,我不得不:
- 移动或者添加
dd() - 刷新页面
- 丢失掉该节点之前的上下文
- 再重复
真正的问题是:dd() 中断了请求:我无法看到整个流程。一次只能看到一个快照,随后请求即终止。如果问题出在 dd() 之后,我看不到,除非将 dd() 往后移动。
如果是简单的问题,这样做并不复杂。但是如果涉及多个步骤、事件、任务或者中间件,就不那么“好玩”了。
进阶:使用日志语句
在某一刻,我 意识到我可以不必终止请求,使用 Log::info() 来替代 dd():
public function store(CheckoutRequest $request)
{
$cart = Cart::findOrFail($request->cart_id);
Log::info('Checkout: Cart loaded', ['items' => $cart->items->toArray()]);
$total = $cart->calculateTotal();
Log::info('Checkout: Total calculated', ['total' => $total]);
$order = Order::create([
'user_id' => auth()->id(),
'total' => $total,
]);
Log::info('Checkout: Order created', ['order' => $order->toArray()]);
event(new OrderCreated($order));
Log::info('Checkout: Event dispatched');
return redirect()->route('orders.show', $order);
}现在我可以看到一切了。请求完整了,所有逻辑都运行了,我可以在日志文件中跟踪整个流程。如果在创建订单后出现问题——比如在事件监听器中——我会看到的。
游戏规则改变了。我从看到一个冻结的时刻到看到整部电影。
但后来我遇到了一个新问题:读取日志文件很痛苦。
[2024-01-15 10:23:45] local.INFO: Checkout: Cart loaded {"items":[{"id":1,"product_id":5,"quantity":2,"price":"29.99"},{"id":2,"product_id":12,"quantity":1,"price":"49.99"}]}
[2024-01-15 10:23:45] local.INFO: Checkout: Total calculated {"total":109.97}
[2024-01-15 10:23:45] local.INFO: Checkout: Order created {"order":{"id":42,"user_id":1,"total":"109.97","created_at":"2024-01-15T10:23:45.000000Z","updated_at":"2024-01-15T10:23:45.000000Z"}}
[2024-01-15 10:23:45] local.INFO: Checkout: Event dispatched
[2024-01-15 10:23:46] local.INFO: Some other unrelated log
[2024-01-15 10:23:46] local.INFO: Another log from somewhere else
[2024-01-15 10:23:46] local.INFO: Checkout: Cart loaded {"items":[{"id":3,"product_id":8,"quantity":1,"price":"19.99"}]}这很有效,但并不完全令人愉快。JSON 的空间很小。多个请求交织在一起。我必须翻越一堵文字墙才能找到我要找的东西。如果我想检查一个嵌套对象,我会眯着眼睛看一行 JSON,试图理解它。
我想要更好的方式。
Ray:全新的调试体验
Ray 是 Spatie 制作的一个桌面应用,它从你应用接收调试输出。你无需将数据转储到浏览器或写入日志文件,而是将数据发送到 Ray,它将以美观、可搜索、有组织的界面显示数据。
以下是使用 Ray 进行的类似调试流程:
public function store(CheckoutRequest $request)
{
$cart = Cart::findOrFail($request->cart_id);
ray('Cart loaded', $cart->items);
$total = $cart->calculateTotal();
ray('Total calculated', $total);
$order = Order::create([
'user_id' => auth()->id(),
'total' => $total,
]);
ray('Order created', $order);
event(new OrderCreated($order));
ray('Event dispatched');
return redirect()->route('orders.show', $order);
}当我现在到达调试点时,Ray 会亮起每一条数据——以干净、可扩展的格式显示。我可以点击嵌套对象,查看数据类型,所有内容都按照请求进行组织。
请求是完整的。没破坏任何东西。我有一个清晰、直观的时间表,可以准确地看到发生了什么。
安装 Ray
使用 Composer 安装:
composer require spatie/laravel-ray --devray() 辅助函数现在可以全局使用。现在你可以打开 Ray 应用,运行你的代码,并观察输出流。
Ray 改变了我的调试方式
查看多个值而无需多个 DUMP
使用 dd() 时,我经常这样:
dd($user, $order, $items, $total);虽然可以这么使用,但是输出可能会有点混乱——没有标签来区分各个项目:
而使用 Ray:
ray($user)->label('User');
ray($order)->label('Order');
ray($items)->label('Items');
ray($total)->label('Total');每一项单独显示,用标签来区分。查看时可以根据需要折叠或者展开:
也可以简化为:
ray([
'user' => $user,
'order' => $order,
'items' => $items,
'total' => $total,
]);使用颜色跟踪工作流
当调试的东西比较复杂时,可以让各个阶段可视化:
ray('Starting checkout process')->blue();
// ... validation logic
ray('Validation passed')->green();
// ... payment processing
ray('Payment processed')->green();
// ... if something fails
ray('Inventory check failed', $unavailableItems)->red();在 Ray 应用中,这些以颜色标志显示。我可以浏览整个列表,查看哪一项出错了——红色的条目跳了出来。
根据条件调试
有时候我只想当特定条件为真时查看输出:
ray($order)->showWhen($order->total > 1000);上述代码只有在订单总额超过 1000 时会发送到 Ray。当调试特定的边缘案例,并且不想被正常请求的噪音淹没时,这非常有用。
与之相反:
ray($cart)->removeWhen($cart->items->isEmpty());测试性能
当我猜想某些代码较慢时,我可以这样测试:
ray()->measure();
$results = $this->heavyDatabaseQuery();
ray()->measure();
$processed = $this->processResults($results);
ray()->measure();Ray 显示每次 measures() 调用之间的时间差。无需手动计算时间戳.
暂停执行(确实需要时)
有时候,我确实想要以一种比之 dd() 可控的方式暂停执行。Ray 有暂停功能:
ray('About to do something important', $data)->pause();
$this->doSomethingImportant();此处请求暂停了,Ray 中显示了一个 “Continue" 按钮。我可以暂停检查数据,当确认好后,点击 Continue 继续请求处理。它就像是断点调试,不同的是,不需要使用 Xdebug。
调试请求:Ray 的一大亮点
这就是 Ray 对我来说不可或缺的地方。与其手动记录查询或通过 Laravel Telescope 挖掘,我可以告诉 Ray 观测:
ray()->showQueries();
$users = User::with('posts.comments')
->where('active', true)
->get();
ray()->stopShowingQueries();Ray 现在向我展示了所有运行的查询,其中包括:
- 完整的 SQL(插入绑定,以便我可以将其直接复制粘贴到数据库客户端)
- 每个查询所花费的时间
- 查询来自哪些代码
这对于 N+1 调试来说是非常宝贵的。我可以立即看到一个循环是否触发数百个查询,而它本应该只触发一个查询。
查看查询计数
有时候我不需要查看所有查询,只需要知道运行了几个查询:
ray()->countQueries(function () {
$this->loadDashboardData();
});Ray 显示 “23 queries executed.”。那么我就能知道我的优化是否生效了。
HTTP 请求之外进行调试
dd() 完全崩溃的一个地方是调试排队的作业或事件监听器。该作业在一个单独的进程中运行——没有浏览器可以打印。
使用 Log 语句,我可以看到发生了什么,但我又回到了读取日志文件的困境。
而使用 Ray,它也有效:
// app/Jobs/ProcessOrder.php
public function handle()
{
ray('ProcessOrder job started', $this->order)->blue();
// ... processing logic
ray()->showQueries();
$this->order->items->each(function ($item) {
ray('Processing item', $item)->gray();
$this->updateInventory($item);
});
ray()->stopShowingQueries();
ray('ProcessOrder job completed')->green();
}我将作业排队运行,Ray 向我展示了其中发生的一切——包括查询。不再需要分析日志文件。
调试 Artisan 命令:
同样地:
public function handle()
{
ray()->newScreen('Import Users Command');
$rows = $this->parseCSV();
ray(count($rows) . ' rows to import');
foreach ($rows as $index => $row) {
ray("Processing row {$index}")->gray();
// ... import logic
}
ray('Import complete')->green();
}newScreen() 清空了 Ray 并重新刷新,这对于重复运行命令并且不想要被旧的数据干扰非常有用。
我当前的工作流程
这是我近来的实际调试方式:
- 识别生命周期阶段 — 这将能知道到哪里去查看
- 在该阶段的关键点中添加
ray()调用 — 这样才能理解发生了什么 - 如果我觉得是数据库问题,则调用
ray()->showQueries() - 运行代码并查看 Ray
- 调整 — 根据需要添加 ray 调用,完成后移除这些调用
除了快速的一次性检查外,我很少再使用 dd()。而且我几乎从不读取原始日志文件进行调试——这是为 Ray 不可用的生产问题保留的。
什么时候使用哪种工具
这是我的准则:
| 情形 | 工具 |
|---|---|
| 快速确认值 | dd() |
| 需要查看整个请求流 | ray() 或者 Log::info() |
| 开发环境调试 | ray() |
| 调试 Job,命令或者事件 | ray() |
| 调试查询或者性能 | ray()->showQueries() |
| 生产环境调试 | Log::info() (Ray 不用于生产环境) |
清理
我喜欢 Ray 的其中一点是:容易清理。我可以在代码库中搜索ray(在提交之前,确保没有留下任何调试语句)。
或者,我也可以不管这些调试代码。在生产环境中,如果没有安装 Ray 或者没有配置发送 Ray,调用会被静默忽视。不会造成任何破坏。
尽管如此,我仍然倾向于删除这些代码。调试代码是噪音,我希望代码整洁。
全景图
当然,本文的重点不是 Ray 是魔法,也不是说 dd() 不好。重点在于调试是一个工作流,正确的工具可以使该工作流更快。
dd() 适用于快速检查。Log 语句对生产至关重要。但对于严肃的开发调试——我需要了解复杂功能的完整流程——Ray 已成为我的首选。
它让我看到一切,而不会破坏任何东西。这改变了我处理问题的方式。我看到了整个展开的画面。
试试看。我想你会发现很难回去。