使用 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 等图数据库。一旦导入,用户就可以对调用树执行图查询,从而更容易提取相关信息并回答上述问题。

在这篇博文中,你将学习如何:

  1. 指示 Quarkus 应用程序生成调用树 CSV 文件。

  2. 在容器中运行 Neo4j 图数据库

  3. 将这些 CSV 文件导入 Neo4j 图数据库。

  4. 针对图数据库运行 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 浏览器,您将能够观察到图中数据的简要摘要。

data loaded side

上面的数据显示约有 100000 个方法,以及它们之间超过 300000 个边。

接下来,让我们尝试一些 cypher 查询来探索调用图。我对 Quarkus 应用程序本身一无所知,但考虑到它是一个 Hibernate ORM 应用程序,我可以假设会调用某种 persist 方法。进入浏览器并输入查询:

match (m:Method) where m.name = "persist" return *
persist query

我们获得了一些命中,但节点显示的默认样式不是很易读。但是,我们可以像 此指南 中所示的那样调整样式表。对于这种情况,有两个有用的修改是,将默认 node diameter 值增加到例如 150px。另一个修改是将 node.Method caption 值更改为 "{display}"

display 是每个方法中的一个字段,它显示方法的简短 ID,包括包名和类名(仅首字母),以及方法名的驼峰式写法(单字母)。例如,j.p.EM.persist 将是 javax.persistence.EntityManagerpersist 方法的 display

在修改了浏览器样式并将节点移动以便清晰查看之后,让我们重复查询:

persist query big nodes

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

match (m:Method) where m.name = "persist" and m.type =~ ".*EntityManager" return *
entitymanager persist query

请注意,如果我们悬停在节点上,我们会获得关于该方法本身的信息。

回到最初的问题,我们想弄清楚给定的代码路径为什么会被包含。一种方法是從方法本身開始,然後向後追溯,找出在一定深度內與該方法相關的鏈接(例如,直接調用、虛調用、覆蓋等)。例如,讓我們嘗試找出還有哪些方法直接鏈接到 persist 方法:

match (m:Method) <- [*1..1] - (o) where m.name = "persist" and m.type =~ ".*EntityManager" return *
entitymanager persist depth 1 query

啊哈,所以只有一個路徑,這是一個虛調用(即接口調用),來自 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 *
entitymanager persist depth 2 query
subclass
reflection access holder

隨著我們進一步深入,我們開始看到一些生成類調用了 org.acme.hibernate.orm.FruitResource 中的 create 方法。org.acme.hibernate.orm.FruitResource_ClientProxyorg.acme.hibernate.orm.FruitResource_Subclass 都直接調用了該方法。還有一個有趣的調用來自 com.oracle.svm.core.reflect.ReflectionAccessorHolder 中的 FruitResource_create_d0…​ 方法。這基本上意味著 create 方法已在 GraalVM 中註冊,以便通過反射訪問。

如果我們查詢深度為 3,我們將發現反射訪問是一個入口點。因此,我們找到了通往 persist 方法的最短路徑,但這不一定是唯一的路徑。

entitymanager persist depth 3 query

您可以繼續向上探索層級,但遺憾的是,如果達到具有過多節點的深度,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 *"

完成探索後,請不要忘記關閉(killtestneo4j 容器。

$ podman kill testneo4j

請注意,這也將刪除容器(因為我們創建它時使用了 --rm)。

我們才剛剛開始探索 Neo4j 在此用例中的可能性,因此我們仍然需要學習所有技巧和竅門來充分利用它。隨著我們學習更多,我們將與社區分享任何技巧或查詢模板。