使用 Neo4j 检查 Quarkus 原生调用路径的宇宙
这篇博文是我和 Sanne (Grinovero) 在一次午餐时萌生的一个想法的结晶。那时,我们这些远程工程师偶尔会面对面见面,有机会自发地分享想法。我不确定午餐是在 Neuchâtel 还是巴塞罗那,但 Sanne 在诊断一个问题时,被 GraalVM 的原生镜像分析调用树文本输出所困扰。他想知道是否可以将这些数据格式化成不同的形式,并导入图数据库以便于检查。我很高兴地宣布,GraalVM 和 Mandrel 21.3.0 的发布版本包含了一些改进,以解决这个特定问题。
本质上,它们为分析包含在原生二进制文件中的代码路径带来了急需的改进。调试这些代码路径旨在回答诸如以下问题:
为什么这段代码路径会被包含在原生二进制文件中?
在启用调用树打印时,这些代码路径可以被可选地报告。在 Quarkus 中,这可以通过传递 -Dquarkus.native.enable-reports
选项来实现。
在 21.3.0 版本之前,当 Quarkus 指示 GraalVM 发行版打印调用树时,生成的输出将是一个表示调用树的单个文本文件,采用 自定义树格式。这个文本文件会包含大量重复信息,并且大小可能高达数 GB。
GraalVM 21.3.0 引入了将调用树表示为 CSV 文件的可能性,而不是单个文本文件。这些 CSV 文件包含方法信息以及它们之间的不同连接(例如,直接调用、虚调用、覆盖等)。一个直接的好处是,没有信息重复,因此 CSV 文件的大小可以比相应的文本文件小几倍。在某些情况下,它们的大小可以小几千倍。然而,实现此功能的主要原因是为了方便地将调用树馈送到其他工具,例如 Neo4j 等图数据库。一旦导入,用户就可以对调用树执行图查询,从而更容易提取相关信息并回答上述问题。
在这篇博文中,你将学习如何:
-
指示 Quarkus 应用程序生成调用树 CSV 文件。
-
在容器中运行 Neo4j 图数据库
-
将这些 CSV 文件导入 Neo4j 图数据库。
-
针对图数据库运行 Neo4j cypher 查询,以了解 Quarkus 应用程序中的调用路径。
这篇博文使用 Quarkus Hibernate ORM 快速入门示例 作为 Quarkus 应用程序的示例。下载应用程序并执行
./mvnw package -DskipTests -Pnative \ -Dquarkus.native.container-build=true \ -Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel:21.3-java11 \ -Dquarkus.native.enable-reports
上面的命令将生成一个原生二进制文件和前面提到的 CSV 文件。
接下来,在容器中启动 Neo4j
$ export NEO_PASS=... $ podman run \ --detach \ --rm \ --name testneo4j \ -p7474:7474 -p7687:7687 \ --env NEO4J_AUTH=neo4j/${NEO_PASS} \ neo4j:latest
容器运行后,您可以通过 https://:7474 访问 Neo4j 浏览器。使用 neo4j
作为用户名,并使用 NEO_PASS
的值作为密码登录。
要导入 CSV 文件,我们需要以下 cypher 脚本,它将导入 CSV 文件中的数据并创建图数据库节点和边。
CREATE CONSTRAINT unique_vm_id ON (v:VM) ASSERT v.vmId IS UNIQUE;
CREATE CONSTRAINT unique_method_id ON (m:Method) ASSERT m.methodId IS UNIQUE;
LOAD CSV WITH HEADERS FROM 'file:///reports/csv_call_tree_vm.csv' AS row
MERGE (v:VM {vmId: row.Id, name: row.Name})
RETURN count(v);
LOAD CSV WITH HEADERS FROM 'file:///reports/csv_call_tree_methods.csv' AS row
MERGE (m:Method {methodId: row.Id, name: row.Name, type: row.Type, parameters: row.Parameters, return: row.Return, display: row.Display})
RETURN count(m);
LOAD CSV WITH HEADERS FROM 'file:///reports/csv_call_tree_virtual_methods.csv' AS row
MERGE (m:Method {methodId: row.Id, name: row.Name, type: row.Type, parameters: row.Parameters, return: row.Return, display: row.Display})
RETURN count(m);
LOAD CSV WITH HEADERS FROM 'file:///reports/csv_call_tree_entry_points.csv' AS row
MATCH (m:Method {methodId: row.Id})
MATCH (v:VM {vmId: '0'})
MERGE (v)-[:ENTRY]->(m)
RETURN count(*);
LOAD CSV WITH HEADERS FROM 'file:///reports/csv_call_tree_direct_edges.csv' AS row
MATCH (m1:Method {methodId: row.StartId})
MATCH (m2:Method {methodId: row.EndId})
MERGE (m1)-[:DIRECT {bci: row.BytecodeIndexes}]->(m2)
RETURN count(*);
LOAD CSV WITH HEADERS FROM 'file:///reports/csv_call_tree_override_by_edges.csv' AS row
MATCH (m1:Method {methodId: row.StartId})
MATCH (m2:Method {methodId: row.EndId})
MERGE (m1)-[:OVERRIDEN_BY]->(m2)
RETURN count(*);
LOAD CSV WITH HEADERS FROM 'file:///reports/csv_call_tree_virtual_edges.csv' AS row
MATCH (m1:Method {methodId: row.StartId})
MATCH (m2:Method {methodId: row.EndId})
MERGE (m1)-[:VIRTUAL {bci: row.BytecodeIndexes}]->(m2)
RETURN count(*);
您可以从 此链接 下载 cypher 脚本,或将其复制并粘贴到一个名为 import.cypher
的文件中。
上面的脚本足够通用,可以与任何 Quarkus 应用程序一起使用,但它只能与 Mandrel 21.3.0.Final 一起使用。GraalVM CE 21.3.0.Final 缺少符号链接来使 csv 文件引用生效,因此如果您使用的是此 GraalVM CE,则必须将 CSV 文件名修改为项目特定的、带时间戳的文件名。 |
接下来,将 import cypher 脚本和 CSV 文件复制到 Neo4j 的 import 文件夹中
$ podman cp target/*-native-image-source-jar/reports testneo4j:/var/lib/neo4j/import $ podman cp import.cypher testneo4j:/var/lib/neo4j
复制所有文件后,调用导入脚本
$ podman exec testneo4j bin/cypher-shell -u neo4j -p ${NEO_PASS} -f import.cypher
如果您需要重新导入数据,您将需要清除先前导入的数据,否则会出错。您可以通过执行以下命令来清除先前导入的数据 |
$ podman exec testneo4j bin/cypher-shell -u neo4j -p ${NEO_PASS} "MATCH(n) DETACH DELETE n" $ podman exec testneo4j bin/cypher-shell -u neo4j -p ${NEO_PASS} "DROP CONSTRAINT unique_vm_id" $ podman exec testneo4j bin/cypher-shell -u neo4j -p ${NEO_PASS} "DROP CONSTRAINT unique_method_id"
导入完成后(应该只需要几分钟),请转到 Neo4j 浏览器,您将能够观察到图中数据的简要摘要。

上面的数据显示约有 100000 个方法,以及它们之间超过 300000 个边。
接下来,让我们尝试一些 cypher 查询来探索调用图。我对 Quarkus 应用程序本身一无所知,但考虑到它是一个 Hibernate ORM 应用程序,我可以假设会调用某种 persist
方法。进入浏览器并输入查询:
match (m:Method) where m.name = "persist" return *

我们获得了一些命中,但节点显示的默认样式不是很易读。但是,我们可以像 此指南 中所示的那样调整样式表。对于这种情况,有两个有用的修改是,将默认 node
diameter
值增加到例如 150px
。另一个修改是将 node.Method
caption
值更改为 "{display}"
。
display 是每个方法中的一个字段,它显示方法的简短 ID,包括包名和类名(仅首字母),以及方法名的驼峰式写法(单字母)。例如,j.p.EM.persist 将是 javax.persistence.EntityManager 中 persist 方法的 display 。 |
在修改了浏览器样式并将节点移动以便清晰查看之后,让我们重复查询:

我们在上面可以看到,其中一个 persist
调用指向 javax.persistence.EntityManager
。这是 JPA 持久化实体的 API 方法,也是我们将进一步探索的方法。让我们将查询范围缩小到这一点,以便更清晰地查看:
match (m:Method) where m.name = "persist" and m.type =~ ".*EntityManager" return *

请注意,如果我们悬停在节点上,我们会获得关于该方法本身的信息。
回到最初的问题,我们想弄清楚给定的代码路径为什么会被包含。一种方法是從方法本身開始,然後向後追溯,找出在一定深度內與該方法相關的鏈接(例如,直接調用、虛調用、覆蓋等)。例如,讓我們嘗試找出還有哪些方法直接鏈接到 persist
方法:
match (m:Method) <- [*1..1] - (o) where m.name = "persist" and m.type =~ ".*EntityManager" return *

啊哈,所以只有一個路徑,這是一個虛調用(即接口調用),來自 org.acme.hibernate.orm.FruitResource
類中的 create
方法,該方法接受一個 org.acme.hibernate.orm.Fruit
參數並返回一個 javax.ws.rs.core.Response
。
接下來,讓我們進一步擴展查詢,並嘗試找到所有與 persist
方法深度為 2 的鏈接:
match (m:Method) <- [*1..2] - (o) where m.name = "persist" and m.type =~ ".*EntityManager" return *



隨著我們進一步深入,我們開始看到一些生成類調用了 org.acme.hibernate.orm.FruitResource
中的 create
方法。org.acme.hibernate.orm.FruitResource_ClientProxy
和 org.acme.hibernate.orm.FruitResource_Subclass
都直接調用了該方法。還有一個有趣的調用來自 com.oracle.svm.core.reflect.ReflectionAccessorHolder
中的 FruitResource_create_d0…
方法。這基本上意味著 create
方法已在 GraalVM 中註冊,以便通過反射訪問。
如果我們查詢深度為 3,我們將發現反射訪問是一個入口點。因此,我們找到了通往 persist
方法的最短路徑,但這不一定是唯一的路徑。

您可以繼續向上探索層級,但遺憾的是,如果達到具有過多節點的深度,Neo4j 瀏覽器將無法全部可視化。發生這種情況時,您可以改為直接在 cypher shell 中運行查詢。例如:
$ podman exec testneo4j bin/cypher-shell -u neo4j -p ${NEO_PASS} \ "match (m:Method) <- [*1..10] - (o) where m.name = 'persist' and m.type =~ '.*EntityManager' return *"
完成探索後,請不要忘記關閉(kill
)testneo4j
容器。
$ podman kill testneo4j
請注意,這也將刪除容器(因為我們創建它時使用了 --rm
)。
我們才剛剛開始探索 Neo4j 在此用例中的可能性,因此我們仍然需要學習所有技巧和竅門來充分利用它。隨著我們學習更多,我們將與社區分享任何技巧或查詢模板。