使用 Quarkus 扩展解决问题 (1/n)
这是我希望成为一系列文章的第一篇,展示如何通过利用独特的 Quarkus 构建基础设施和扩展框架来解决复杂问题。
首先,引导一个 Quarkus 扩展很容易:只需一个命令,您就可以搭建它并开始实际的实现。但这并不是这篇文章的主题!
一个扩展,除了为您的应用程序提供一些运行时代码外,还允许调整应用程序的构建并在构建级别执行各种操作。这将是我们在本系列中关注的重点。
今日问题:为了确保二进制兼容性,Hub4j GitHub API 引入了一些桥接方法,这些方法混淆了 Mockito,更具体地说,是 ByteBuddy,并最终使我们的测试变得不可靠。我们如何解决这个问题?
一些背景信息
您可能听说过 我的 Quarkus GitHub App 扩展,它允许您以极快的速度开发基于 Quarkus 的 GitHub App,并且只需很少的样板代码(无耻的广告:它非常棒!)。
我的亲爱的同事 Yoann Rodière(他也很棒!)基于 Mockito(它在底层使用 ByteBuddy)为其编写了一些测试基础设施。一切都很好,直到我们开始注意到测试中令人困惑且不可重现的失败,Mockito 有时实际上并没有调用我们期望的方法。
问题的根源在于,为了确保二进制兼容性,我们在 Quarkus GitHub App 中使用的 Hub4j GitHub API 在字节码中引入了桥接方法。
例如,让我们以 GitHub API 的 GitHub
类的这个方法为例
@WithBridgeMethods(value = GHUser.class)
public GHMyself getMyself() throws IOException {
client.requireCredential();
return setMyself();
}
历史上,它曾经返回一个 GHUser
,但在较新的版本中,它返回一个 GHMyself
,这破坏了二进制兼容性。
为了恢复它,并在 @WithBridgeMethods
注解的帮助下,GitHub API 构建将在字节码中创建两个方法:一个返回 GHMyself
,一个返回 GHUser
。如果您使用旧版本的 GitHub API 编译了您的应用程序,并且只想使用新版本而不重新编译您的应用程序,这将非常有用。通常,在 Jenkins 的情况下,您可以切换到新版本的 GitHub API,而无需重新编译所有使用 GitHub API 的 Jenkins 插件。
在字节码级别,您最终会得到类似于以下的内容
public GHMyself getMyself() throws IOException {
client.requireCredential();
return setMyself();
}
public GHUser getMyself() throws IOException {
return getMyself(); (1)
}
1 | 返回 GHMyself 的 getMyself() 的 invokevirtual |
如果您现有的编译代码调用 GHUser getMyself()
,则在更改返回类型后它仍然可以工作。
这种桥接方法解决了实际问题,而且它并不是什么大问题,因为它对于开发人员来说是完全透明的……除非您由于 ByteBuddy 问题 而开始使用 Mockito:如果存在具有相同签名但返回类型不同的多个方法,ByteBuddy 可能会感到困惑。
ByteBuddy 是一个了不起的库,这篇博文不应被视为对 ByteBuddy 的批评。这是一个极端的角落案例,不会发生在标准字节码中。 |
这个问题导致我们的测试变得不可靠,因为有时 ByteBuddy 会选择错误的方法来应用 Mockito 的魔力。
我们如何解决这个问题?
在 Quarkus GitHub App 的情况下,我们并不真正关心二进制兼容性:当升级到新版本的 GitHub API 时,用户将重建他们的应用程序。
因此,鉴于这些桥接方法存在问题,一种解决方案是摆脱它们。
显然,我们可以 fork GitHub API 并避免生成桥接方法。
但是,如果我们可以避免,那么永远 fork 和维护一个 fork 绝对不是我们应该考虑的事情。特别是由于我们希望继续从 GitHub API 的所有未来改进中受益。
那么,我们是否可以在某种程度上保持库的标准,但让 Quarkus 在构建应用程序时调整字节码?
如果您赶时间,简短的答案是肯定的。现在让我们来解答(不太长的)答案。
让我们识别这些方法
在 Quarkus 中,我们可以使用 Jandex 索引注解,因此,在理想情况下,我们将使用 Jandex 索引 GitHub API jar(我们已经出于其他目的这样做),并询问 Jandex 以获取所有使用 @WithBridgeMethods
注解的方法
Collection<AnnotationInstance> withBridgeMethodsAnnotations =
index.getAnnotations(DotName.createSimple(WithBridgeMethods.class.getName));
不幸的是,@WithBridgeMethods
具有 CLASS
保留策略 - 这对于它的用法来说是完全合理的 - 并且 Jandex 只考虑具有 RUNTIME
保留策略的注解。
这个限制将在 Jandex 3 中得到缓解,但目前,我们无法使用 Jandex。
不幸的是,在那之前,我们没有太多选择:我们必须手动列出这些方法。
为了获得更大的灵活性,我们引入了一个 BuildItem
public final class GitHubApiClassWithBridgeMethodsBuildItem extends MultiBuildItem {
private final String className;
private final Set<String> methodNames;
GitHubApiClassWithBridgeMethodsBuildItem(String className, String... methodsWithBridges) {
this.className = className;
this.methodNames = new HashSet<>(Arrays.asList(methodsWithBridges));
}
public String getClassName() {
return className;
}
public Set<String> getMethodsWithBridges() {
return methodNames;
}
}
我们将为每个类生成一个 GitHubApiClassWithBridgeMethodsBuildItem
// ...
classesWithBridgeMethods.produce(new GitHubApiClassWithBridgeMethodsBuildItem(
"org.kohsuke.github.GHPullRequestCommitDetail$Commit", "getAuthor", "getCommitter"));
// ...
完成此操作后,我们能够从任何 Quarkus @BuildStep
消费 GitHubApiClassWithBridgeMethodsBuildItem
,因此此列表通常可用于 Quarkus 构建。
我不会详细介绍 Quarkus 构建过程,但它的原理非常简单
您可以在 编写扩展指南 中了解更多相关信息。 |
删除方法
现在我们已经掌握了方法的列表,下一步就是删除它们。
为了在构建期间操作字节码,Quarkus 提供了 BytecodeTransformerBuildItem
。调整类的字节码只是为给定类生成一个 BytecodeTransformerBuildItem
的问题。
例如,要从我们的 GitHub API 方法中删除桥接方法,我们将以下构建步骤添加到我们的扩展中
@BuildStep
void removeCompatibilityBridgeMethodsFromGitHubApi(
BuildProducer<BytecodeTransformerBuildItem> bytecodeTransformers, (1)
List<GitHubApiClassWithBridgeMethodsBuildItem> gitHubApiClassesWithBridgeMethods) { (2)
for (GitHubApiClassWithBridgeMethodsBuildItem gitHubApiClassWithBridgeMethods : gitHubApiClassesWithBridgeMethods) {
bytecodeTransformers.produce(new BytecodeTransformerBuildItem.Builder()
.setClassToTransform(gitHubApiClassWithBridgeMethods.getClassName())
.setVisitorFunction((ignored, visitor) -> new RemoveBridgeMethodsClassVisitor(visitor,
gitHubApiClassWithBridgeMethods.getClassName(),
gitHubApiClassWithBridgeMethods.getMethodsWithBridges()))
.build());
}
}
1 | 我们将生成 BytecodeTransformerBuildItem 。 |
2 | 我们消费先前生成的 GitHubApiClassWithBridgeMethodsBuildItem 。 |
RemoveBridgeMethodsClassVisitor
是一个经典的 ASM ClassVisitor
,它将修改字节码
class RemoveBridgeMethodsClassVisitor extends ClassVisitor {
private final String className;
private final Set<String> methodsWithBridges;
public RemoveBridgeMethodsClassVisitor(ClassVisitor visitor, String className, Set<String> methodsWithBridges) {
super(Gizmo.ASM_API_VERSION, visitor);
this.className = className;
this.methodsWithBridges = methodsWithBridges;
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
if (methodsWithBridges.contains(name) && ((access & Opcodes.ACC_BRIDGE) != 0)
&& ((access & Opcodes.ACC_SYNTHETIC) != 0)) { (1)
return null; (2)
}
return super.visitMethod(access, name, descriptor, signature, exceptions); (3)
}
}
1 | 如果方法名称匹配,并且该方法是桥接和合成方法…… |
2 | ……我们通过返回 null 从字节码中删除它。 |
3 | 如果不是,我们只是委托给超类方法,该方法会将该方法合并到字节码中。 |
就是这样!
在构建过程中,Quarkus 将创建一个包含修改后的字节码的类文件,并使用它来代替来自 GitHub API jar 的类。因此,我们想要删除的桥接方法将永远不会被 ByteBuddy 看到。