STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Javaで書かれたライブラリをKotlinに書き換えるときに考慮したいこと

はじめに

こんにちは、STORES 決済 の Androidアプリ・SDKの開発をしている id:n-seki です。

......この一文はよく使う紹介文なのですが、気がついたことはありませんか? そうです、アプリだけではなくSDKの開発もしています!

STORES ではこのSDKを「決済 SDK」と呼んでいます。モノとしてはAndroidライブラリ(.aar)になっており、Androidアプリに組み込んでいただくことでクレジットカードなどのキャッシュレス決済をかんたんに実装できます。

coiney.com

この決済 SDK ですが、もともと公開インターフェースも含めてJavaで実装されていました。

アプリもJavaで実装されていましたが、段階的にKotlinに置き換えている状況でして、決済 SDKについてもKotlin移行に踏み切ることにしました。

アプリとは異なり、ライブラリには開発者というユーザーがいます。ライブラリのインターフェースに破壊的な変更が入ると、開発者としてはコード修正が必要になるなど、ライブラリ更新に一定のコストがかかってしまいます。

インターフェースの変更を最小限にし、かつ将来的な互換性を考慮したライブラリとするには、Kotlin化にあたってどのようなポイントに気をつけると良いでしょうか?

可視性

まずは可視性です。ライブラリに含まれる実装は「開発者に公開したいインターフェース」「公開しない内部的な実装」の2つに大分できると思いますが、それぞれ適切な可視性を設定する必要があります。

Kotlinでは internalprotectedprivate、そしてデフォルトの可視性が設定できます。KotlinではデフォルトがJavaで言うところのpublicです。

公開したいクラスやメソッドをデフォルトの可視性にして、公開したくないクラスやメソッドについては必要に応じてinternalprotectedprivateを使い分けるのが良いですね。

// このクラスは public なので公開される
class PaymentService {

    // こちらも public なので公開
    fun startPayment() {}
    
    // これらのメソッドは非公開(開発者は利用できない)
    internal fun startPaymentInternal() {}
    protected fun validatePaymentAmount() {}
    private fun handlePaymentError() {}
}

もちろんリフレクションを使えばアクセスできちゃいますが、今回は考えないことにしましょう。

なお、本記事で登場するサンプルコードはいずれも実際の決済 SDK のプロダクションコードではなく、説明のために生み出したそれっぽいコードなので細かい点はご容赦ください。

Javaからの呼び出し

上記のコードは一見すると何も問題ないように思えます。

開発者にはメソッドとして startPayment だけが公開されており、それ以外のメソッドへのアクセスはできないはず......ですよね?

class PaymentService {
   internal fun startPaymentInternal() {}
}

先ほどのコード例から一部を抜粋しました。 startPaymentInternalinternalなので非公開メソッドという扱いです。

当然ながらKotlinからの呼び出しではinternalなメソッドを利用することはできません。

val service = PaymentService()
service.startPaymentInternal() // これはビルドエラー

しかし、ライブラリがKotlin以外の言語から利用されることもあるかもしれません。とくに、長期に渡って運用しているライブラリの場合、Javaを使っている開発者がいるかもしれません。

そんなわけでJavaからライブラリを使ってみましょう。

PaymentService service = new PaymentService();
service.startPaymentInternal(); // ビルドできる!

おや? internalなはずの startPaymentInternal を参照できて、ビルドも通ってしまいました!

実際にはこのような感じで見えます。 Usage of Kotlin internal declaration from different module と表示されますがビルドは成功しちゃいます。

internalなメンバーでもJavaからは見える

何が起こっているのでしょうか? Android Studioの機能を使って、ビルド後に生成されるBytecodeを見てみます。

  // access flags 0x11
  public final startPaymentInternal$lib_debug()V
   L0
    LINENUMBER 6 L0
    RETURN
   L1
    LOCALVARIABLE this Lcom/example/lib/PaymentService; L0 L1 0
    MAXSTACK = 0
    MAXLOCALS = 1

startPaymentInternal の部分を抜粋してみましたが、publicで定義されています。

この挙動についてはKotlinドキュメントに記載がありました。

internal declarations become public in Java. Members of internal classes go through name mangling, to make it harder to accidentally use them from Java and to allow overloading for members with the same signature that don't see each other according to Kotlin rules

https://kotlinlang.org/docs/java-to-kotlin-interop.html#visibility

internalなメンバーは Java からはpublicとなるが、名前はmanglingされるため問題にはならない、というところでしょうか。 なるほど対処しなくても大きな問題にはならなそうですが、できれば internal なメンバーは確実に非公開としたいところです。

回避策

JvmSyntheticアノテーションを使うことでこの問題を回避できます。

class PaymentService {
   @JvmSynthetic
   internal fun startPaymentInternal() {}
}

するとBytecodeが以下のようになり、access flagが変わっていることがわかります。

  // access flags 0x1011
  public final synthetic startPaymentInternal$lib_debug()V
   L0
    LINENUMBER 8 L0
    RETURN
   L1
    LOCALVARIABLE this Lcom/example/lib/PaymentService; L0 L1 0
    MAXSTACK = 0
    MAXLOCALS = 1

この状態だとJavaからであっても startPaymentInternal は不可視となります。

見えなくなった

JvmSyntheticのドキュメントに記載されている通り、このアノテーションがつくとBytecode上で ACC_SYNTHETIC とマークされ、Javaからアクセス不可になります。これによって、KotlinからもJavaからも該当メソッドが利用できない状態を達成できます。

詳細についてはこれらの記事・ドキュメントも読んでみてください。

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.jvm/-jvm-synthetic/

Hide internal members of Kotlin Module from JVM 🔐 | by Shreyas Patil | ScaleReal | Medium

The @JvmSynthetic Annotation in Kotlin | Baeldung on Kotlin

データクラス

次にKotlinのdata classを考えてみましょう。 決済情報を表すデータを公開するために、以下のような data class を定義してみました。

data class Transaction(
   val amount: Int,
   val payedAt: LocalDateTime,
)

このクラスをライブラリの公開インターフェースとすれば良さそうですね。

......本当に?

自動生成されるコード

data classが便利なのは toString()copy() メソッドが自動で生成されるから、というところが大きいですよね。

またDestructuring declarationsという仕組みもあります。これは文字どおりdata classのプロパティを分解して宣言できます。

val transaction = Transaction(amount = 512, payedAt = LocalDateTime.now())
val (amount, payedAt) = transaction

こちらについてもcomponent1()のようなメソッドが生成されています。

https://kotlinlang.org/docs/destructuring-declarations.html

後方互換

これらの自動生成されるコードはライブラリの後方互換性に影響を及ぼす可能性があります。

data classではいくつかのメソッドが生成されますが、生成されたメソッドの可視性はデフォルト = public になっています。

つまりライブラリでdata classTransactionクラスを公開するということは、それらの生成されたメソッドも公開することになります。

というわけで、開発者はこのようなコードを書くことができます。

val transaction = Transaction(amount = 512, payedAt = LocalDateTime.now())
val (amount, payedAt) = transaction
val newTransaction = transaction.copy(100)  // ミスではありません

この時点ではとくに問題なさそうです。

ここで、仕様変更が発生してTransactionにプロパティが増える状況を考えてみましょう。

data class Transaction(
   val fee: Int = 0,
   val amount: Int,
   val payedAt: LocalDateTime,
)

手数料を表す fee が追加されました。互換性を考慮してデフォルト値 0 が設定されています。 (定義位置があからさまですが説明のためです、許してください!)

先程のコードはそのままビルドできるでしょうか?

val transaction = Transaction(amount = 512, payedAt = LocalDateTime.now())
val (amount, payedAt) = transaction // amount = 0, payedAt = 512
val newTransaction = transaction.copy(100) // fee = 100, amount = 512

なんと、ビルドできてしまいます! しかしコメントしたように、意図とは全く異なる挙動になってしまっています。

普段からKotlinを利用されているエンジニアの方であれば、この挙動は「それはそう」という感じかもしれませんが、ライブラリの後方互換性という観点から見ると対処しておきたい気持ちになります。

回避策

この(将来的な)問題を回避するため、決済 SDK では data class を一切公開しないことにしました。 代替として通常のクラスやinterfaceを公開し、Java時代との互換性維持などの必要に応じて toString などのメソッドを実装しています。

class Transaction(
    val fee: Int = 0,
    val amount: Int,
    val payedAt: LocalDateTime,
)

copyメソッドや分解宣言などの仕組みは利用できなくなるため、上述した問題は発生しなくなります。 しかし逆を言うと、それらの便利な仕組みが使えなくなるデメリットが出てくるため、要件と相談して方針を決めるのが良いと思います。

ちなみに Poko というライブラリがあって、通常のクラスでありながらも toStringequalsなどのメソッドを自動生成してくれるようです。

github.com

決済 SDK では導入しませんでしたが、このようなライブラリを使うのも良さそうですね。

このdata classの問題についてはJake Wharton氏のブログに詳しくまとまっているので、興味がある方は読んでみてください。

Public API challenges in Kotlin - Jake Wharton

さいごに

Javaで書かれたライブラリをKotlinに書き換えるときに考慮したいこと」というタイトルで「可視性」と「データクラス」を取り上げてみました。 ライブラリの内容や性質によってはこのような考慮・対処はやりすぎかもしれませんが、長期運用しているライブラリをKotlinに書き換えるようなシチュエーションでは一考の余地があるのではないでしょうか?

自分自身、今回のKotlin移行で初めて知ったこともあって、とても勉強になりました。アプリとライブラリでは考えるポイントが違うなと常々思います。

余裕があればAPIドキュメント生成をDokkaに移行した話も書きたかったのですが、ちょっと長くなってしまったので別の機会にしようと思います。

それではよいKotlinライフを!