请分享您的想法,参与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函数调用都有2个对应的栈元素:《invoke》和《invokeStatic》。类《user$foo》和《user$bar》的方法《invoke》指向第1行和第4行。如果你查看《repro.clj》,你会注意到这些是定义函数《foo》和《bar》的行——这些信息是不有用的,并且通常Java代码的调用堆栈不会指向方法定义的行,而只会指向调用更高层栈中方法的代码行内部。虽然可以使用互操作直接调用《.invoke》,因为这已经在IFn中定义了,但《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》互操作。我更喜欢操作《user/foo》,而不是《user$foo invoke》,因为这在我可以看到的代码中。有人可能会说,像《clojure.lang.AFn applyToHelper》和《clojure.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/eval》与《clojure.lang.Compiler.eval》还是其他什么?
- 如何展示针对定义类型/reifies的方法调用?这些既是Clojure也是方法...
- Var/AFn/RestFn的栈元素是否确实无关紧要?

其他选项

可能还有其他选项...

你怎么看?

2 答案

+1

入选
 
最佳解答

根据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)

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

我相信JRuby在这方面投入了大量的时间(显示您从Ruby JVM堆栈跟踪中看到的Ruby堆栈跟踪),这可能是一个值得了解更多的事情(我尚未研究过)。

+5

我使用对堆栈跟踪进行混淆的工具的经验是,它们在某个时候看起来很好,直到有一天你遇到了一个奇怪的、不合理的错误,然后花费几天时间尝试调试它,然后发现混淆隐藏了一些关键信息,然后发誓永远不再相信 这些东西。

甚至成对调用的调用和调用的静态帧也告诉你一些东西。存在调用静态帧说明了这一点。

  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
的确是很好的观点,并且一般而言,我们认为访问真实的堆栈跟踪是很重要的。然而,也没有理由不也有其他的工具可以解析跟踪。
...