アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

protobuf-grpcのJVMコード生成がいつまでもNullアノテーションに対応しないので無理矢理対応させる

protobuf, gRPCを使っているサーバーと通信するために公式が用意しているコード生成を使用しています。
https://github.com/protocolbuffers/protobuf/tree/master/java
https://github.com/grpc/grpc-java
https://github.com/grpc/grpc-kotlin (バックエンドはgrpc-javaで、追加でcoroutine対応があるくらいのやつ。今回はあんまり関係ない。)

現状の問題点

java7で java.annotation.Nonnullが使えないのでNullアノテーションを追加する気は無いらしい。
https://github.com/protocolbuffers/protobuf/issues/4351#issuecomment-390767828

grpc側も特に話は進んでいない。
https://github.com/grpc/grpc-java/issues/4724

non-nullアノテーションが無いとどうなるかというと。

public class JavaClass {
    String getString() {
        return "";
    }
}

String! と表示されています。javaの情報だけではどっちかわからないので、どっちにもなれる状態を表しています。

class Kotlin {
    init {
        JavaClass().value
    }
}

f:id:matsudamper:20220121025825p:plain

なので以下のようにnullableがありえる事を明示的に指定すれば特に問題は起きません。

val value: String? = JavaClass().value
println("value=$value") // value=null

しかし、non-nullを明示的に指定したり、non-nullな関数の引数に入れた時点でNullPointerExceptionが起きます。

val value: String = JavaClass().value
println("value=$value")

しかし、protobufのJavaではnullが返されることは無いそうです。

Protobuf Java API never returns null.

https://github.com/protocolbuffers/protobuf/issues/4351#issuecomment-374803044

non-nullかnullableかわからない状態の場合、特にnullチェックをしなくてもいいのですが、そのような事を覚えておかなければなりませんし、一緒に開発している人にも知らせておかないとnullチェックを行う冗長なコードが上がってくるかもしれません。
しかし、 package-info.java を使えばパッケージ下のデフォルトのアノテーションを設定できるそうです。
https://github.com/protocolbuffers/protobuf/issues/4351#issuecomment-390841619

package-info.java を差し込む

既に .proto からJava及びKotlinの拡張のコードを生成できているものとして進めます。

packageごとに以下の package-info.java を入れれば強制的にnon-nullになります。
詳細はKotlinの仕様を読むと良いです。

以下は自分が読んだ時点のURLなので、最新版を読んでください。
https://github.com/Kotlin/KEEP/blob/5e2d3dd1f81b237c3010461793c34bdf3857be9f/proposals/jsr-305-custom-nullability-qualifiers.md

@NonNullApi
package com.exsample;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import javax.annotation.Nonnull;
import javax.annotation.meta.TypeQualifierDefault;

@Nonnull
@TypeQualifierDefault({ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@interface NonNullApi {
}

子のpackageには適用されないので、全てのpackageに入れる必要があります。

Gradle で package-info.java を差し込む。

Protobufを自動で生成しているのだから、 package-info.java も自動で生成されるようにしましょう。

タスクの順番を決める

protobufプラグインでクラスが生成された後、クラスファイルが生成される前にタスクを差し込みたいわけです。なので以下でタスクの順番を指定します。このあたりはややこしくて触っていると循環参照になったりするのでよくわからないです。
build.gradle.kts

// まずはタスクを定義しましょう
val insertJavaInfoTask = tasks.create("insertJavaInfo")

// ProtoからJavaコードを生成した後に `insertJavaInfoTask` を実行したいので指定します。
tasks.withType<GenerateProtoTask> {
    finalizedBy(insertJavaInfoTask)
}

// `compileKotlin` の前に `insertJavaInfoTask` を実行するように指定します。  
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    dependsOn(insertJavaInfoTask)
}
insertJavaInfoTask.apply {
    doLast {
        // 後はここにコードを差し込む処理を入れるだけ
    }
}

コードを出力する

今回は簡単な決まったものを入れるだけなのでPoetなどのコード生成ツールは使用しません。

doLastの部分に以下のコードを差し込みます。このあたりはとりあえず動くコードという感じです。

val mainSourceSet = project.extensions.getByType(JavaPluginExtension::class.java)
    .sourceSets
    .getByName(SourceSet.MAIN_SOURCE_SET_NAME)

val loader = URLClassLoader(
    mainSourceSet.runtimeClasspath.map { it.toURI().toURL() }.toTypedArray(),
    Thread.currentThread().contextClassLoader
)
val classes = ClassPath.from(loader).getTopLevelClassesRecursive("com.exsample.packagename").map {
    it.load().kotlin
}.filterNot { it.jvmName.endsWith("Kt") }

val outRootFile = File(project.rootDir, "build/generated/source/proto/main/info")
classes.map {
    it.java.packageName
}.toSet().forEach { packageName ->
    val packagePath = packageName.split(".").joinToString("/")
    val file = File(File(outRootFile, packagePath), "package-info.java")

    val source = """
@ApiNonNull
package $packageName;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import javax.annotation.Nonnull;
import javax.annotation.meta.TypeQualifierDefault;

@Nonnull
@TypeQualifierDefault({ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@interface ApiNonNull {
}

    """.trimIndent()
    file.parentFile.mkdirs()
    file.writeText(source)
}

ソースの指定を忘れずに

// 後はソースの指定を忘れなければ大丈夫です。

sourceSets {
    main {
        java {
            srcDirs(
                "build/generated/source/proto/main/grpckt",
                "build/generated/source/proto/main/grpc",
                "build/generated/source/proto/main/info"
            )
        }
    }
}

これで取得できるものは non-null 扱いになりました。
f:id:matsudamper:20220121034929p:plain

その他

後は上記プロジェクトそのまま使ったり、mavenに上げたり、publishToMavenLocalしたりしてください。

引数は警告が出るものの、コンパイルエラーにはなりません。
f:id:matsudamper:20220121034659p:plain

以下のオプションを設定するとエラーになってくれます。 (jarとしてライブラリとして配布する場合は、使用する側で設定します)

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
    }
}

おわりに

早くちゃんと対応されると良いんですけどね。

Kotlinサポートは力入れていくみたいなので。
https://github.com/protocolbuffers/protobuf/issues/3742#issuecomment-843591924