17-调试 - Some-soda

17-调试

第 17 章 调试

LazyVim 支持直接在编辑器中调试各种编程语言。老实说,我的整个职业生涯主要都是用日志语句来调试。我总是觉得设置和集成调试器的麻烦不值得花费时间。它们在玩具项目中效果很好,但一旦你试图让调试器与例如 Docker 协作(或者让任何东西与 Docker 协作),或者与异步第三方库协作,总感觉付出的不值得。

所以我想说的是:我从未使用过 LazyVim 的调试系统,所以写这一章对我来说也是一次速成课。我总是通过教学学得最好!

17.1. 调试适配器协议 (Debug Adapter Protocol)

除了语言服务器提供者,VS Code 还带给我们调试适配器协议,通常缩写为 DAP。像 LSP 一样,DAP 是一种抽象,允许编辑器与各种调试器集成,而无需为每种调试器重新实现具体的语言细节。

Neovim 没有像 LSP 那样内置对 DAP 的支持,但可以配置 LazyVim 来启用一系列必要的插件来使用它。为此,只需安装 dap.core LazyExtra。

如果你也为你偏好的编程语言启用了 lazy extras,它很可能已经配置为与 DAP 一起工作。要再次检查,请查看 lang.<你偏好的语言> extra 是否包含 nvim-dap 配置部分。如果有,你大概率不需要配置任何东西。

17.2. 基本示例:Python

如果你已经安装了 dap.corelang.python Lazy Extras,并且你的系统上安装了 Python,你就准备好开始调试了。

我写了一个简单的 Python 脚本来演示这个:

列表 58. 一个 Python 脚本

def main():
    world_descriptions = ["Hello", "Cruel", "Beautiful", "Tired", "My"]

    for description in world_descriptions:
        print(description + " world") # <- 在这里设置断点
        if description == "Tired":
            print("")


if __name__ == "__main__":
    main()

(我们在第 14 章注意到的 ifmain 片段派上用场了!)

如果你在 Neovim 中打开这个文件并按下 <Space>d,你会看到一个全新的调试命令菜单:

menu dark

图 91. 调试菜单

其中许多只有在调试器已经在运行时才有意义。

让我们将光标移动到 print(description + " world") 行,然后按 <Space>db 设置一个断点 (breakpoint)。然后我们可以使用 <Space>da 命令打开运行菜单 (run args),其中包含以下选项(对于 Python Extra)来开始调试:

run args dark

图 92. 运行参数菜单

我不知道为什么有这么多选项,因为选项 1、2 和 5 似乎都做同样的事情。即使你选择选项 1 按回车,它也会提示输入参数。这个例子不需要参数,所以我只是再次按了回车。程序将运行到断点处,并在当前窗口周围打开五个新窗口(再次证明投资大显示器是值得的):

debug ui dark

图 93. 调试 UI

让我们先讨论编辑器下方的两个窗口:

  • 左边的那个包含一个带有常用调试操作的工具栏(其中大部分也可以从 <Space>d 迷你模式访问)。根据语言和环境的不同,这个窗口有时会包含程序输出,或者关于你自调试会话开始以来所采取操作的消息。在我的 Python 设置中,它保持空白。
  • 右边的小窗口是迄今为止控制台输出显示的地方,至少对于 Python 是这样。由于这是循环的第一次运行,控制台部分还没有任何内容。

我已经缩小了窗口以适应本书,但如果我处于活动的调试会话中,我通常会在我最大的显示器上将其最大化。

让我们看看左侧边栏,它被分成了几个窗口:

  • Locals and Globals (局部变量和全局变量)

    提供程序中已知变量及其当前值的列表。这些会在你单步执行程序时更新。旁边有小三角形的任何内容都可以通过将光标移动到该行(s 模式在这里非常方便,因为你可以用它直接从源代码跳转到窗口)并按 Enter 来展开。

  • <文件名>.py (例如 main.py)

    显示当前设置的所有断点的列表。在这种情况下,只有一个,但我们可以看到它在第 5 行,并能预览该行代码。这个信息在代码窗口本身也用一个指向当前断点行的箭头或一个大圆点(如果不是当前断点)高亮显示。即使在我用 <Space>du (debug ui toggle) 隐藏 DAP UI 后,这个图标仍然可见。

  • MainThread (主线程)

    向我显示当前的调用堆栈 (call stack),在这个特定程序中它相当简单。与局部和全局变量一样,如果旁边有小三角形,你可能可以展开某些行。这个程序没有任何嵌套函数调用,所以没什么可看的。

  • No Expression (无表达式)

    我目前没有任何监视表达式 (watch expressions)。这个窗口功能超强,因为你可以在其中进入插入模式。例如,我可以通过输入 i 后跟 description 然后按 enter 来添加对 description 变量的监视:

watch dark

图 94. 监视表达式输入

我现在可以按几次 <Space>dc 来“继续”(continue) 调试会话,这意味着它将运行到下一个断点。由于我们唯一的断点在一个循环中,它将在相同的位置中断,但具有新的值:

continues dark

图 95. 在 Beautiful 处中断

注意几个不同地方的单词 Beautiful

  • 局部变量部分的 description 变量
  • 我们刚刚在监视部分的 description 监视
  • 在编辑器窗口中,我们在循环中的 description 旁边看到 = Beautiful。是的,编辑器能够在我们调试时向我们显示变量的值,即使在我们隐藏 DAP UI 后,这个反馈仍然可见!

还要注意,我们现在已经循环了两次,所以右下角的控制台输出部分现在有两行输出了。

你甚至可以在 locals 窗口中实时编辑变量的值,方法是将光标导航到该行并按 e,然后会提示你输入新值。将其更改为“Foo”,用 Enter 确认,然后用 <Space>dc 继续,我们看到这一行输出了与原始列表不同的内容:

edited output dark

图 96. 实时编辑的变量输出

如果你想单步执行函数的行,请使用 <Space>dO 代表“步过”(Over)。这将表现得好像你在函数的每一行都设置了断点一样。如果你想“跳出”(out) 到调用当前函数的函数,请使用 <Space>do(这次是小写 o)。<Space>di 将“步入”(into) 光标下的函数调用,这样你就可以单步执行被调用函数内部的行。

译者注: 调试术语:

  • Step Over (<Space>dO): 执行当前行,如果当前行包含函数调用,则执行整个函数而不进入其内部。
  • Step Into (<Space>di): 执行当前行,如果当前行包含函数调用,则进入该函数内部的第一行。
  • Step Out (<Space>do): 继续执行当前函数的剩余部分,然后在调用当前函数的地方之后的第一行暂停。
  • Continue (<Space>dc): 继续执行直到遇到下一个断点或程序结束。

对于条件断点 (conditional breakpoint),请使用 <Space>dB(大写 B)而不是 <Space>db。系统会提示你提供一个条件,就像我在这里做的一样:

conditional breakpoint dark

图 97. 条件断点输入

现在如果我运行 <Space>dl(运行“上一个”last 调试命令,这样我就不必像用 <Space>da 那样选择调试环境并输入参数了),它将循环两次,输出 Hello World,然后在 description == "Cruel" 时才中断。

这就是 LazyVim 调试器的俗称之旅。在这个例子中它完美地工作了,但正如我开头所说,玩具示例对于调试器来说很容易。

一个真实的 Python 项目需要在虚拟环境中运行。如果你在启动 Neovim 之前激活了虚拟环境,调试器(和 LSP 工具)应该就能与 venv 一起正常工作。LazyVim 的 Python extra 确实支持选择虚拟环境,但我发现在打开编辑器之前激活它是管理它最不易出意外的方式。

17.3. 远程调试(一个 Go 的例子)

你也可以在远程位置(通常是 ssh 服务器或 Docker 容器)运行调试服务,并从你的本地 Neovim 连接到它。就我个人而言,我宁愿在远程位置安装 Neovim 并直接从那里运行它,但那是另一回事了。

在远程位置实际设置和运行调试适配器的说明完全取决于语言。对于这个例子,我将使用 Go。我已经启用了 lang.go LazyExtra,并使用类似于为 Python 描述的步骤在本地文件上进行了测试。

现在是时候在容器中测试它了。我将使用 Podman,但你也可以用 Docker 或 ssh 来完成。

首先,我将上面的 Python 脚本移植到了 Go:

列表 59. 一个 Go 脚本

package main

import "fmt"

func main() {
  for _, description := range []string{
    "Hello",
    "Cruel",
    "Beautiful",
    "Tired",
    "My",
  } {
    fmt.Printf("%s world\n", description) // <- 在这里设置断点
    if description == "Tired" {
      fmt.Println()
    }
  }
}

然后我创建了以下简单的 ContainerFile

列表 60. Golang ContainerFile

FROM golang:1.22-alpine
WORKDIR /app
COPY main.go ./
RUN go build -o /hello main.go
CMD ["/hello"]

podman build -t somego .podman run --rm somego 表明这两个文件工作正常。

你的项目有一个 DockerfileContainerfile,他们不希望你为了安装像调试器(或 Neovim)而去修改它。

所以我总是非常抵触地创建一个单独的、被 gitignore 的 Containerfile.local 来扩展公司的 Containerfile

Go 的调试器叫做 delve。你可以通过将容器作为 shell 运行来临时安装它:

列表 61. 在容器中安装 Delve

$ podman run --rm -it somego /bin/sh
/app # go install github.com/go-delve/delve/cmd/dlv@latest

现在容器应该有了一个 dlv 命令,但只在该容器退出之前有效。为了让它更持久,我的偷偷摸摸的 Containerfile.local 看起来像这样:

列表 62. 偷偷摸摸的容器扩展

# 假设基础镜像是上面构建的 somego
FROM localhost/somego
# 安装 git (delve 可能需要) 和 delve 本身
RUN apk add --no-cache git
RUN go install github.com/go-delve/delve/cmd/dlv@latest

# 暴露 delve 监听的端口
EXPOSE 40000

# 启动 delve 调试器
# debug: 启动调试会话
# -l: 监听地址和端口
# --headless: 无头模式,不启动 delve 终端 UI
# --accept-multiclient: 允许多个客户端连接
# ./main.go: 要调试的程序路径 (相对于 WORKDIR)
CMD ["dlv", "debug", \
     "-l", "0.0.0.0:40000", \
     "--headless", \
     "--accept-multiclient", \
     "./main.go"]

你可以让 dlv 命令在任何端口上运行;只需确保你 EXPOSE 了相同的端口。

用以下命令构建这个容器:

$ podman build -f Containerfile.local -t my-some-go .

然后用以下命令运行它:

# -p 40000:40000 将宿主机的 40000 端口映射到容器的 40000 端口
$ podman run --rm -it -p 40000:40000 --name my-some-go my-some-go

我不喜欢容器的众多原因之一是命令必须如此冗长。现在它终于准备好连接调试器了。

下一步是配置 nvim-dap-go 来连接到这个端口。这确实相当冗长且有点脆弱。我在我的 plugins 目录下创建了一个新的 extend-dap-go.lua 文件,看起来像这样:

列表 63. Nvim-Dap 远程 Go 配置

-- lua/plugins/extend-dap-go.lua
return {
  "leoluz/nvim-dap-go",
  -- 依赖 nvim-dap
  dependencies = { "mfussenegger/nvim-dap" },
  opts = {
    -- 添加一个新的 dap 配置
    dap_configurations = {
      -- 附加到已在监听的 delve 实例
      {
        type = "go", -- 调试类型
        name = "Attach container", -- 配置名称
        mode = "remote", -- 模式为远程
        request = "attach", -- 请求类型为附加
        port = 40000, -- delve 监听的端口
        host = "127.0.0.1", -- delve 监听的主机 (通常是本地回环地址)
        -- 路径替换,将本地工作区路径映射到容器内的路径
        substitutePath = {
          { from = "${workspaceFolder}", to = "/app" },
          -- 可能需要添加更多路径映射
        },
      },
      -- 保留或添加其他配置...
      -- {
      --   type = "go",
      --   name = "Debug",
      --   request = "launch",
      --   program = "${file}"
      -- },
    },
    -- 如果只想为远程调试设置端口,可以只在上面的配置中设置
    -- 如果希望所有 delve 实例都用 40000,则取消注释下面部分
    -- delve = {
    --   port = 40000,
    -- },
  },
}

译者注: 对原始配置进行了一些调整,将 porthost 直接放在 attach 配置内部,这是更推荐的方式,避免全局覆盖本地调试的端口。添加了 substitutePath 的说明。

端口需要在单独的 delve 部分指定(译者注:如上,现在建议放在 attach 配置内),并且有一个不幸的副作用,即总是绑定到那个端口,即使你正在运行本地调试会话。substitutePath 部分配置为将你的本地目录映射到容器内的 /app 文件夹。如果你的 Containerfile 使用不同的 WORKDIR,请更改它。

重启 Neovim 以加载新配置并打开 main.go 文件(在本地文件系统上)。用通常的方式 <Space>db 设置断点。然后按 <Space>dc 弹出一个可能的调试配置菜单。你会在菜单中看到 Attach Container。选择它,祈祷今天任何在听的调试之神保佑你,然后等待断点命中。

译者注: 启动调试的命令应为 <Space>dc (debug continue) 或 <Space>dl (debug last) 或通过 <Space>da 选择配置。

密切关注 podman 容器的输出,因为 fmt.Print 语句将输出到那里。控制台输出不会显示在编辑器中。

这种开发体验不是很好(尽管你习惯于使用容器)。如果让它运行到完成(或者如果你更改了代码),你必须停止并重新启动容器。所以,我建议在容器外进行你的 go 编码和调试。毕竟,Go 被设计用来构建静态自包含的二进制文件。但如果你有理由使用容器,现在你知道该怎么做了!

17.4. 示例:连接到 Chrome

浏览器开发工具窗口中的 Chrome 调试器非常出色,所以如果你宁愿只使用它而不是 nvim-dap-ui,也是情有可原的。(嘿,我不会评判;我仍然用 console.log 来完成我大部分的 Chrome 调试)。

但事实证明,从 Neovim 连接它惊人地容易。首先,从终端启动 Chrome 浏览器,传递 --remote-debugging-port=9222 标志。Chrome 的确切路径取决于操作系统和包管理器;在 Linux 上,它可能在你的路径中为 google-chrome,在 MacOS 上,如果你安装了 .dmg,你可能需要访问类似这样的路径:

列表 64. Chrome 的路径

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

其次,在 Neovim 内部用 <Space>cm 打开 Mason。搜索 chrome-debug-adapter。用 i 安装它并重启 Neovim。

不需要额外的配置。这有点让我大吃一惊,因为我没看到 LazyVim 做了任何额外的事情来连接它。只需打开一个 jsxtsxjsts 文件。然后按 <Space>db 添加一个断点,<Space>dc 连接到 Chrome 并让它在该断点处中断。我甚至不必选择配置!

我在一个全新的 Vite-react 应用中测试了这一点,将断点放在一个按钮被点击时的回调函数内部。点击按钮,调试器暂停,所有局部变量都已设置好。我甚至可以使用 <Space>di 深入 React 源代码,LazyVim 会自动从 node_modules 打开文件。

再次强调,我不太明白这相对于只使用 Chrome devtools 调试器有什么实用性。通常,当我调试前端代码时,我更关心我与浏览器的交互如何影响调试工具的状态,所以切换到我的编辑器来继续到下一个断点实际上并不方便。

17.5. 总结

本章全部是关于直接从你的编辑器调试代码。对于作者来说,这是一个难以很好涵盖的棘手主题,因为调试器在工作时惊人地简单,但它们通常不能开箱即用。LazyVim 在开箱即用配置方面做得和我见过的一样好(不比 VS Code 差),但你可能仍然需要在它与你的系统流程工作之前捣鼓配置。这是无法避免的。

如果你能让它很好地工作,你可能会发现你比只使用 print 语句时更有效率。但这可能需要很长时间才能弥补最初的设置时间,而且这种技能是不可转移的;一旦你开始一个新项目,你可能需要再次经历一遍不同的配置咒语!

在下一章中,我们将讨论 Neotest,一个用于从编辑器内部运行测试的工具。