2024 Clojure 状态调查!中分享您的想法。

欢迎!请参阅关于页面以获取更多关于此操作的信息。

+5
错误

背景

我看到了有关错误消息的 reddit 讨论,其中一位用户的关于信号与噪声比率的评论引起了我的共鸣,并促使我思考REPL如何向用户展示堆栈跟踪。

上下文

我查阅了该领域的各种发展,包括:
- Stuart Holloway的观点 认为堆栈跟踪总是正确的长度
- clj-stacktrace
- pretty
- clojure存档页面,其中包含关于堆栈跟踪的设计迭代,包括此片段

我认为现有的库要么试图隐藏太多(pretty),要么主要关注于展示/格式化而忽略了实际内容(clj-stacktrace)。我通常同意Stuart的观点,即堆栈跟踪的长度是正确的,因为每个堆栈元素都是真实的信息,是对异常发生时执行的方法堆栈的不模糊描述。

范围

本次讨论关于在REPL中展示给程序员的堆栈跟踪,考虑Throwable->mapclojure.core/print-throwable,而不是重新封装所有抛出的异常。

问题

堆栈跟踪的目的是展示关于已执行方法的有用信息,这些信息为异常提供上下文。我认为使这些信息有用的一个方面是能够查看/导航到导致异常的代码。在Clojure代码中定义的函数的情况下,因为没有方法/封装的类可以查看/导航到,因为这些函数的字节码是从函数定义动态生成的。此外,看似单一函数调用的堆栈跟踪通常含有多个元素,其中一些指向了函数定义的行,这也不太有用。

示例

给定一个文件repro.clj(我包含行号)

1 (defn foo []
2   (/ 1 0))
3 
4 (defn bar []
5   (foo))
6 
7 (bar)

在REPL中加载此文件并显示错误将显示以下堆栈跟踪

clj
Clojure 1.10.1
user=> (load-file "repro.clj")
Syntax error (ArithmeticException) compiling at (C:\Users\Vlaaad\Projects\vlaaad.github.io\repro.clj:7:1).
Divide by zero
user=> *e
#error {
 :cause "Divide by zero"
 :via
 [{:type clojure.lang.Compiler$CompilerException
   :message "Syntax error compiling at (C:\\Users\\Vlaaad\\Projects\\vlaaad.github.io\\repro.clj:7:1)."
   :data #:clojure.error{:phase :compile-syntax-check, :line 7, :column 1, :source "C:\\Users\\Vlaaad\\Projects\\vlaaad.github.io\\repro.clj"}
   :at [clojure.lang.Compiler load "Compiler.java" 7648]}
  {:type java.lang.ArithmeticException
   :message "Divide by zero"
   :at [clojure.lang.Numbers divide "Numbers.java" 188]}]
 :trace
 [[clojure.lang.Numbers divide "Numbers.java" 188]
  [clojure.lang.Numbers divide "Numbers.java" 3901]
  [user$foo invokeStatic "repro.clj" 2]
  [user$foo invoke "repro.clj" 1]
  [user$bar invokeStatic "repro.clj" 5]
  [user$bar invoke "repro.clj" 4]
  [user$eval140 invokeStatic "repro.clj" 7]
  [user$eval140 invoke "repro.clj" 7]
  [clojure.lang.Compiler eval "Compiler.java" 7177]
  [clojure.lang.Compiler load "Compiler.java" 7636]
  [clojure.lang.Compiler loadFile "Compiler.java" 7574]
  [clojure.lang.RT$3 invoke "RT.java" 327]
  [user$eval1 invokeStatic "NO_SOURCE_FILE" 1]
  [user$eval1 invoke "NO_SOURCE_FILE" 1]
  [clojure.lang.Compiler eval "Compiler.java" 7177]
  [clojure.lang.Compiler eval "Compiler.java" 7132]
  [clojure.core$eval invokeStatic "core.clj" 3214]
  [clojure.core$eval invoke "core.clj" 3210]
  [clojure.main$repl$read_eval_print__9086$fn__9089 invoke "main.clj" 437]
  [clojure.main$repl$read_eval_print__9086 invoke "main.clj" 437]
  [clojure.main$repl$fn__9095 invoke "main.clj" 458]
  [clojure.main$repl invokeStatic "main.clj" 458]
  [clojure.main$repl_opt invokeStatic "main.clj" 522]
  [clojure.main$main invokeStatic "main.clj" 667]
  [clojure.main$main doInvoke "main.clj" 616]
  [clojure.lang.RestFn invoke "RestFn.java" 397]
  [clojure.lang.AFn applyToHelper "AFn.java" 152]
  [clojure.lang.RestFn applyTo "RestFn.java" 132]
  [clojure.lang.Var applyTo "Var.java" 705]
  [clojure.main main "main.java" 40]]}

让我们专注于属于repro.clj的堆栈跟踪元素

[clojure.lang.Numbers divide "Numbers.java" 188]
[clojure.lang.Numbers divide "Numbers.java" 3901]
[user$foo invokeStatic "repro.clj" 2]
[user$foo invoke "repro.clj" 1]
[user$bar invokeStatic "repro.clj" 5]
[user$bar invoke "repro.clj" 4]
[user$eval140 invokeStatic "repro.clj" 7]
[user$eval140 invoke "repro.clj" 7]

代码中每个Clojure函数调用都对应两个栈元素:invokeinvokeStatic。对于类user$foouser$barinvoke方法,它们指向第1和第4行。如果您看看repro.clj,您会注意到这些是函数foobar定义所在的行——这些信息并不有用,一般来说,Java代码的堆栈跟踪不会指向方法定义所在的行,而只有指向方法体内调用更高层栈上方法的行。虽然可以使用interop直接调用.invoke,因为这在IFn中有定义,但invokeStatic是编译器生成的方法,可以认为是一个实现细节。

可能的解决方案?

不采取任何行动

Stuart可能是对的——如果我在调试与字节码相关的问题,我当然会对这些细节感兴趣。此外,Clojure的某些未来版本可能会以不同的方式生成函数字节码,从而导致更少的、更易读的堆栈跟踪。

合并堆栈元素

这种方法将同一函数的堆栈元素合并,只保留可见的公共invoke方法,并报告实际调用下一方法所在行的行号。这是一种相当微小的过滤,使得堆栈跟踪对异常复制 looks like like

[clojure.lang.Numbers divide "Numbers.java" 188]
[clojure.lang.Numbers divide "Numbers.java" 3901]
[user$foo invoke "repro.clj" 2]
[user$bar invoke "repro.clj" 5]
[user$eval140 invoke "repro.clj" 7]
[clojure.lang.Compiler eval "Compiler.java" 7177]
[clojure.lang.Compiler load "Compiler.java" 7636]
[clojure.lang.Compiler loadFile "Compiler.java" 7574]
[clojure.lang.RT$3 invoke "RT.java" 327]
[user$eval1 invoke "NO_SOURCE_FILE" 1]
[clojure.lang.Compiler eval "Compiler.java" 7177]
[clojure.lang.Compiler eval "Compiler.java" 7132]
[clojure.core$eval invoke "core.clj" 3214]
[clojure.main$repl$read_eval_print__9086$fn__9089 invoke "main.clj" 437]
[clojure.main$repl$fn__9095 invoke "main.clj" 458]
[clojure.main$repl invoke "main.clj" 458]
[clojure.main$repl_opt invoke "main.clj" 522]
[clojure.main$main invoke "main.clj" 667]
[clojure.lang.RestFn invoke "RestFn.java" 397]
[clojure.lang.AFn applyToHelper "AFn.java" 152]
[clojure.lang.RestFn applyTo "RestFn.java" 132]
[clojure.lang.Var applyTo "Var.java" 705]
[clojure.main main "main.java" 40]

我认为这已经很好地减少了信号与噪声比例,如果您调试与字节码相关的问题,您仍然可以通过在异常对象上使用.getStackTrace来获取完整的堆栈跟踪。

使Clojure堆栈元素像代码一样

Clojure哲学的一部分是偏好函数而不是方法,也许堆栈元素指向Clojure函数时应该看起来像函数而不是方法?大多数时候代码中没有.invoke interop。我更喜欢处理user/foo而不是user$foo invoke,因为这在我看得见的代码中。有人可能会争辩说,像clojure.lang.AFn applyToHelperclojure.lang.RestFn invoke这样的堆栈元素也是不必要的,因为它们是实现细节,使得Clojure函数可以使用可变参数进行调用(已经有一些过滤了这些细节!)。在这个方向上有许多未知的事物,我没有尝试回答,因为我不确定是否值得追求,但下面是堆栈跟踪可能看起来是什么样的示意图

[clojure.lang.Numbers.divide "Numbers.java" 188]
[clojure.lang.Numbers.divide "Numbers.java" 3901]
[user/foo "repro.clj" 2]
[user/bar "repro.clj" 5]
[user/eval140 "repro.clj" 7]
[clojure.lang.Compiler.eval "Compiler.java" 7177]
[clojure.lang.Compiler.load "Compiler.java" 7636]
[clojure.lang.Compiler.loadFile "Compiler.java" 7574]
[clojure.lang.RT$3.invoke "RT.java" 327]
[user/eval1 "NO_SOURCE_FILE" 1]
[clojure.lang.Compiler.eval "Compiler.java" 7177]
[clojure.lang.Compiler.eval "Compiler.java" 7132]
[clojure.core/eval "core.clj" 3214]
[clojure.main/repl/read-eval-print--9086/fn--9089 "main.clj" 437]
[clojure.main/repl/fn--9095 "main.clj" 458]
[clojure.main/repl "main.clj" 458]
[clojure.main/repl-opt "main.clj" 522]
[clojure.main/main "main.clj" 667]
[clojure.main.main "main.java" 40]

一些开放性问题
将Clojure函数调用表示为单个符号而不是类+方法对意味着将Java方法调用表示为单个符号,它们应该如何体现?clojure.lang.Compiler/evalclojure.lang.Compiler.eval还是其他什么形式?
如何展示deftypes/reifies的方法调用?这两个都是Clojure和方法的...
Var/AFn/RestFn堆栈元素真的无关紧要吗?

其他方案

可能还有其他选择...

您觉得呢?

2 个回答

+1
by
选定 by
 
最佳答案

按照Kevin的评论,我确实想看到实际的堆栈跟踪,所以我认为它不应该消失。但也是很有意义去思考能够应用于堆栈跟踪的工具,为您提供更好的洞察。但这样说,许多人都投入了时间,但结果(在我看来)并不比原来的好。我并不是想泼冷水,只是想指出,这听起来很简单,但细节很复杂。

我应该提一下,你恰好选择了一个场景,正在触动CLJ-2529中已知的bug(在这个过程中,阶段的报告被错误地报告了),在这里是从顶层表单eval到达的。虽然这是可能的,但您更有可能得到这个bug,是通过没有任何顶层调用bar并把这个代码放在一个命名空间中实现的。

(ns repro)

(defn foo []
  (/ 1 0))

(defn bar []
  (foo))

然后,把可复现的部分放在你的源代码中,并调用它

% clj
Clojure 1.10.3
user=> (require 'repro)
nil
user=> (repro/bar)
Execution error (ArithmeticException) at repro/foo (repro.clj:4).
Divide by zero

我认为你应该也注意到,Clojure提供了一个打印堆栈跟踪函数(pst),它会为你做一定程度的清理(在某些情况下,它甚至还会省略参与错误报告的顶层帧 - 这在1.10.0中进行了调整以改进许多情况)

user=> (pst *e)
ArithmeticException Divide by zero
	clojure.lang.Numbers.divide (Numbers.java:188)
	clojure.lang.Numbers.divide (Numbers.java:3901)
	repro/foo (repro.clj:4)
	repro/foo (repro.clj:3)
	repro/bar (repro.clj:7)
	repro/bar (repro.clj:6)
	user/eval150 (NO_SOURCE_FILE:1)
	user/eval150 (NO_SOURCE_FILE:1)
	clojure.lang.Compiler.eval (Compiler.java:7181)
	clojure.lang.Compiler.eval (Compiler.java:7136)
	clojure.core/eval (core.clj:3202)
	clojure.core/eval (core.clj:3198)

由于这是一个“打印”方法,这可能是一个我们可以做更多帮助的地方,比如合并调用帧。检测这些是否是相同的帧可能具有挑战性,我不知道。字节码中的行/列调试符号的工具很奇怪,我也不认为这些内容已经有很长时间没有接触过了,甚至可能存在我们最初没有的选项。(在编译器以及源调试扩展等类似https://github.com/clojure/clojure/blob/master/src/jvm/clojure/lang/Compiler.java#L4326-L4340中查找visitSource和visitLineNumber - 请查看JSR 45)。

我相信JRuby也在这个问题上花费了很多时间(显示由jruby jvm堆栈跟踪生成的ruby堆栈跟踪),这可能是一个可以了解更多内容的好东西(我还没有研究过它)。

+5
by

我对篡改堆栈跟踪的工具的经验是,它们看起来似乎很正常,直到有一天你遇到了一个奇怪的bug,这个bug说不通,然后花了几天时间试图调试它,最后发现篡改隐藏了一些关键信息,然后发誓再也不信任这些工具。

即使是配对的 invoke 和 invokeStatic 数组也能告诉你一些信息。invokeStatic 的存在可以告诉你一些信息。

  1. 如果你看到同一个函数的 invoke 和 invokeStatic,这意味着该函数没有关闭任何东西,并且调用代码不是用直接链接编译的。
  2. 如果你只看到 invokeStatic,这意味着该函数没有关闭任何东西,并且调用代码是用直接链接编译的。
  3. 如果你只看到 invoke,这告诉你该函数关闭了某些内容,但没有告诉你是否启用了直接链接。

如果你还没有见过 https://github.com/stuartsierra/stacktrace.raw,它非常有价值。这是一段试图撤销所有现有的异常“美化器”效果的代码。它的说明文档中有关于“为什么存在?”的精彩部分(《https://github.com/stuartsierra/stacktrace.raw#why-this-exists》)。

Alex 做了很多工作来尝试改进 Clojure 的错误和错误报告,因为这是一个经久不衰的抱怨,但据我所知,他没有做任何事情来干预堆栈跟踪的内容。似乎 Clojure 开始的时候不太可能这样做。

by
关于你可以从 invokeStatic 的存在或不存在中提取的所有信息,是很好的观点,我意识到这一点。

我阅读了 stacktrace.raw 的“为什么存在?”部分,它似乎主要关注现有的异常美化器中的bug,而不是我试图确定的问题。
by
这些确实是很好的观点,总的来说,我们认为访问真实的堆栈跟踪非常重要。然而,也没有必要不也有其他工具可以解释跟踪。
...