解决Quarkus扩展的问题(1/n)
这是第一篇文章,我希望这将是一系列的文章,展示你如何利用独特的Quarkus构建基础设施和扩展框架来解决复杂问题。
首先,启动一个Quarkus扩展是很容易的:在一个命令中,你可以得到它的脚手架并开始实际执行。但这并不是这篇文章的主题!
一个扩展,除了为你的应用程序提供一些运行时的代码外,还可以调整你的应用程序的构建,并在构建层面做各种事情。这就是我们在这个系列中要关注的。
今天的**问题:**为了确保二进制兼容,Hub4j GitHub API引入了一些桥接方法,这些方法会混淆Mockito,更确切地说,是ByteBuddy,最终使我们的测试不可靠。我们怎样才能解决这个问题呢?
一些背景
你可能听说过我的Quarkus GitHub应用扩展,它允许你以光速开发基于Quarkus的GitHub应用,只需很少的模板(无耻的广告:它非常棒!)。
我亲爱的同事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 | invokevirtual 的 ,返回getMyself() GHMyself |
而如果你现有的编译代码调用GHUser getMyself() ,在返回类型改变后,它仍然可以工作。
这种桥接方法的方法解决了一个真正的问题,而且这并不是什么大问题,因为它对开发者来说是完全透明的......除了当你由于ByteBuddy的问题而开始使用Mockito时。如果有几个签名相同但返回类型不同的方法,ByteBuddy会感到困惑。
ByteBuddy是一个了不起的库,这篇博文不应该被看作是对ByteBuddy的批评。这是一个极端的角落案例,在标准字节码中不会发生。 |
这个问题导致我们的测试不可靠,因为有时ByteBuddy会选择错误的方法来应用Mockito的魔法。
我们怎样才能解决这个问题呢?
就Quarkus GitHub App而言,我们并不真正关心二进制兼容性:当升级到GitHub API的新版本时,用户会重建他们的应用程序。
所以考虑到这些桥接方法是有问题的,一个解决方案是摆脱它们。
很明显,我们可以分叉GitHub API,避免生成桥接方法。
但如果我们能避免的话,永远分叉和维护分叉绝对不是我们应该考虑的事情。尤其是我们还想继续受益于 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 。 调整一个类的字节码只是为给定的类产生一个问题。
例如,为了从我们的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 | 我们要产生BytecodeTransformerBuildItems。 |
| 2 | 我们消耗之前产生的GitHubApiClassWithBridgeMethodsBuildItems。 |
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来说永远是不可见的。
总结
在会议上,我们经常说Quarkus的做法与其他框架不同,其神奇之处在于其创新的构建过程。
这个构建过程是Quarkus的低内存占用和快速启动的关键。
但它也是一个非常强大的工具来定制你的应用程序的构建。