编程

从 dd() 到 Ray:一种不打断工作流的调试方案

9 2026-04-09 05:10:00

理解 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
}

看看我做了什么?每次我想确认新的节点,我不得不:

  1. 移动或者添加 dd()
  2. 刷新页面
  3. 丢失掉该节点之前的上下文
  4. 再重复

真正的问题是: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 --dev

ray() 辅助函数现在可以全局使用。现在你可以打开 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 并重新刷新,这对于重复运行命令并且不想要被旧的数据干扰非常有用。

我当前的工作流程

这是我近来的实际调试方式:

  1. 识别生命周期阶段 — 这将能知道到哪里去查看
  2. 在该阶段的关键点中添加 ray() 调用 — 这样才能理解发生了什么
  3. 如果我觉得是数据库问题,则调用 ray()->showQueries()
  4. 运行代码并查看 Ray
  5. 调整 — 根据需要添加 ray 调用,完成后移除这些调用

除了快速的一次性检查外,我很少再使用 dd()。而且我几乎从不读取原始日志文件进行调试——这是为 Ray 不可用的生产问题保留的。

什么时候使用哪种工具

这是我的准则:

情形工具
快速确认值dd()
需要查看整个请求流ray() 或者 Log::info()
开发环境调试ray()
调试 Job,命令或者事件ray()
调试查询或者性能ray()->showQueries()
生产环境调试Log::info() (Ray 不用于生产环境)

清理

我喜欢 Ray 的其中一点是:容易清理。我可以在代码库中搜索ray(在提交之前,确保没有留下任何调试语句)。

或者,我也可以不管这些调试代码。在生产环境中,如果没有安装 Ray 或者没有配置发送 Ray,调用会被静默忽视。不会造成任何破坏。

尽管如此,我仍然倾向于删除这些代码。调试代码是噪音,我希望代码整洁。

全景图

当然,本文的重点不是 Ray 是魔法,也不是说 dd() 不好。重点在于调试是一个工作流,正确的工具可以使该工作流更快。

dd() 适用于快速检查。Log 语句对生产至关重要。但对于严肃的开发调试——我需要了解复杂功能的完整流程——Ray 已成为我的首选。

它让我看到一切,而不会破坏任何东西。这改变了我处理问题的方式。我看到了整个展开的画面。

试试看。我想你会发现很难回去。

 

相关推荐: