Quarkusのエクステンションで問題を解く (1/n)
この記事は、Quarkus独自のビルドインフラとエクステンションフレームワークを活用して複雑な問題を解決する方法を紹介する、数回にわたる連載の最初の記事です。
まず最初に、Quarkusエクステンションのブートストラップは簡単です。1つのコマンドで、雛形ができ、実際の実装に取り掛かることができます。しかし、それはこの記事の主題ではありません!
エクステンションは、アプリケーションにランタイムコードを提供するだけでなく、アプリケーションのビルドを調整し、ビルドレベルであらゆる種類のことを行うことができます。このシリーズでは、この点に焦点を当てます。
今日の問題: バイナリ互換性を確保するために、Hub4j GitHub APIは、Mockito、特にByteBuddyを混乱させ、最終的に我々のテストの信頼性を低下させるいくつかのブリッジ・メソッドを導入しています。どうすればそれを解決できるでしょうか?
いくつかのコンテキスト
私のQuarkus GitHub Appエクステンションは、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 ビルドはバイトコードに 2 つのメソッドを作成します。1 つは GHMyself
を返し、もう 1 つは GHUser
を返します。これは、GitHub APIの古いバージョンでアプリケーションをコンパイルしていて、アプリケーションを再コンパイルせずに新しいバージョンを使いたい場合に非常に便利です。一般的にJenkinsの場合、GitHub APIを使用するJenkinsプラグインをすべて再コンパイルすることなく、GitHub APIの新しいバージョンに切り替えることができます。
バイトコードレベルでは、以下のようなものになります。
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の新しいバージョンにアップグレードする場合、ユーザーはアプリケーションを再ビルドすることになります。
ですから、これらのブリッジ方式が問題であることを考えると、これを廃止することが一つの解決策になると思います。
もちろん、GitHub API をフォークしてブリッジメソッドを生成しないようにすることもできます。
しかし、フォークして永遠に維持することは、避けられるのであれば絶対に考えるべきことではありません。特に、GitHub API の将来の改良の恩恵を受け続けたいのですから。
そこで、ライブラリは標準のまま、アプリケーションのビルド時にQuarkusでバイトコードを調整することはできないでしょうか。
急いでいるのなら、短い答えは「イエス」です。では、(そうではない)長い答えに行きましょう。
メソッドを特定しよう
Quarkusでは、Jandexでアノテーションのインデックスを作成できます。完璧な世界では、JandexでGitHub API jarのインデックスを作成し(他の目的ですでに行っています)、Jandexに問い合わせて、 @WithBridgeMethods
でアノテーションされたすべてのメソッドを取得することができます。
Collection<AnnotationInstance> withBridgeMethodsAnnotations =
index.getAnnotations(DotName.createSimple(WithBridgeMethods.class.getName));
Unfortunately, @WithBridgeMethods
has a CLASS
retention policy
- which makes perfect sense for its usage -
and Jandex only considers annotations with a RUNTIME
retention policy.
この制限は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のビルドプロセスの詳細は省きますが、その原理は極めてシンプルです。
Writing extensionsガイドで詳しく解説しています。 |
メソッドの削除
これでメソッドのリストが手元に揃ったので、次のステップではメソッドを削除します。
ビルド中にバイトコードを操作するために、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 | `BytecodeTransformerBuildItem`sを作成します。 |
2 | 先に生産された `GitHubApiClassWithBridgeMethodsBuildItem`s を消費します。 |
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から見えることはありません。