使用 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 返回 GHMyselfgetMyself()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 构建过程,但它的原理非常简单

  • 它由构建步骤(使用 @BuildStep 注解的方法)组成。

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

  • 一个构建步骤生成构建项。

  • 然后,这只是解决构建步骤的依赖关系以获得最终结果:您的应用程序的问题。

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

删除方法

现在我们已经掌握了方法的列表,下一步就是删除它们。

为了在构建期间操作字节码,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 看到。

结论

在会议上,我们经常说 Quarkus 与其他框架的做法不同,并且魔力在于其创新的构建过程。

此构建过程是 Quarkus 低内存占用和快速启动时间的关键。

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