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$foo` 和 `user$bar` 类的 invoke 方法指向第1行和第4行。如果您查看 repro.clj,会发现这些正是定义函数 foobar 的行——这些信息并不有用,通常Java代码的堆栈跟踪不会指向定义方法的行,只有指向方法体内部调用更高层栈方法行的。虽然可以像 IFn 中定义的那样直接使用interop来调用 .invoke,但 invokeStatic 是由编译器生成的方法,可以说是实现细节。

可能的解决方案?

不采取任何行动

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

合并栈元素

这种方法合并了同一函数的栈元素,只保留对其可见的公共 invoke 方法,并报告实际调用下一个栈方法的行。这是一种相当简单的过滤,将导致repro异常的堆栈跟踪看起来像这样:

[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 或其他什么?
- 如何展示 deftype/reifies 的方法调用?这既是Clojure也是方法...
- Var/AFn/RestFn 栈元素真的无关紧要吗?

其他解决方案

可能还有其他选项...

您认为呢?

2个回答

+1
by
selected by
 
最佳回答

根据Kevin的评论,我真的想看到实际的堆栈跟踪,所以我认为它不应该消失。但是,思考可以应用于堆栈跟踪以提高洞察力的工具也是非常有用的。也就是说,很多人投入了时间,但结果(在我看来)并没有比原始的更好。我这样说并不是要泼冷水,只是注意到它看似简单,但细节很复杂。

我应该提到,你恰好选中了一个场景,这个场景在加载 https://clojure.atlassian.net/browse/CLJ-2529 已知漏洞时被激发了(这里通过顶层形式评估达到此阶段)。虽然这是显然可能的,但您通常会通过没有顶级调用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)

由于这是一种“打印”方法,我们可能可以做更多的事情来帮助,比如合并调用框架。检测这些是相同的框架可能具有挑战性,我不知道。用于在字节码中发出行/列调试符号的工具很奇怪,我认为这些内容已经很久没有接触过了,甚至可能存在我们可以使用的、原来不存在的选项。(在编译器中查找visitSource和visitLineNumber,以及在源调试扩展等内容中,例如 https://github.com/clojure/clojure/blob/master/src/jvm/clojure/lang/Compiler.java#L4326-L4340 - 关于JSR 45查看该内容)。

我相信JRuby在该问题上投入了大量的时间(显示来自jruby jvm堆栈跟踪的Ruby堆栈跟踪),这可能是一个值得我们深入了解的地方(我没有深入研究)。

+5
by

我使用过篡改堆栈跟踪的工具的经验是,它们似乎很好,直到有一天你遇到了一个奇怪的、毫无意义的错误,你花了几天时间尝试调试它,然后发现篡改隐藏了一些关键信息,然后你发誓永远不再相信这些事情。

即使是配对的调用和 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 的错误和错误报告,因为这始终是一个常见的抱怨,但据我所知,他并没有对堆栈跟踪的内容做出任何干扰。Cljure现在开始做这看起来不太可能。

从 `invokeStatic` 的存在或不存在中提取的所有信息都是很好的论点,我学到了。

我看了 stacktrace.raw 的 "为什么存在这个?" 部分,这似乎主要关注现有的异常美化器中的错误,而不是我试图确定的问题。
这些确实是很好的论点,而且我们一般认为,能够访问真正的堆栈跟踪很重要。然而,也没有理由不也让其他工具来解释跟踪。
...