解决Quarkus扩展的问题(1/n)

306 阅读6分钟

解决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)
    }
1invokevirtual 的 ,返回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的构建过程,但它的原理是非常简单的。

  • 它是由构建步骤(注有@BuildStep 的方法)组成的。

  • 一个构建步骤可以消耗构建项目。

  • 一个构建步骤产生构建项目。

  • 然后就是解决构建步骤的依赖关系,以达到最终的结果:你的应用程序。

你可以在编写扩展指南中了解更多信息。

删除方法

现在我们有了方便的方法列表,下一步就是删除它们。

为了在构建过程中操作字节码,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的低内存占用和快速启动的关键。

但它也是一个非常强大的工具来定制你的应用程序的构建。