はじめに
こんにちは、STORES 決済 の Androidアプリ・SDKの開発をしている id:n-seki です。
......この一文はよく使う紹介文なのですが、気がついたことはありませんか? そうです、アプリだけではなくSDKの開発もしています!
STORES ではこのSDKを「決済 SDK」と呼んでいます。モノとしてはAndroidライブラリ(.aar)になっており、Androidアプリに組み込んでいただくことでクレジットカードなどのキャッシュレス決済をかんたんに実装できます。
この決済 SDK ですが、もともと公開インターフェースも含めてJavaで実装されていました。
アプリもJavaで実装されていましたが、段階的にKotlinに置き換えている状況でして、決済 SDKについてもKotlin移行に踏み切ることにしました。
アプリとは異なり、ライブラリには開発者というユーザーがいます。ライブラリのインターフェースに破壊的な変更が入ると、開発者としてはコード修正が必要になるなど、ライブラリ更新に一定のコストがかかってしまいます。
インターフェースの変更を最小限にし、かつ将来的な互換性を考慮したライブラリとするには、Kotlin化にあたってどのようなポイントに気をつけると良いでしょうか?
可視性
まずは可視性です。ライブラリに含まれる実装は「開発者に公開したいインターフェース」「公開しない内部的な実装」の2つに大分できると思いますが、それぞれ適切な可視性を設定する必要があります。
Kotlinでは internal
、 protected
、private
、そしてデフォルトの可視性が設定できます。KotlinではデフォルトがJavaで言うところのpublic
です。
公開したいクラスやメソッドをデフォルトの可視性にして、公開したくないクラスやメソッドについては必要に応じてinternal
、 protected
、private
を使い分けるのが良いですね。
// このクラスは public なので公開される class PaymentService { // こちらも public なので公開 fun startPayment() {} // これらのメソッドは非公開(開発者は利用できない) internal fun startPaymentInternal() {} protected fun validatePaymentAmount() {} private fun handlePaymentError() {} }
もちろんリフレクションを使えばアクセスできちゃいますが、今回は考えないことにしましょう。
なお、本記事で登場するサンプルコードはいずれも実際の決済 SDK のプロダクションコードではなく、説明のために生み出したそれっぽいコードなので細かい点はご容赦ください。
Javaからの呼び出し
上記のコードは一見すると何も問題ないように思えます。
開発者にはメソッドとして startPayment
だけが公開されており、それ以外のメソッドへのアクセスはできないはず......ですよね?
class PaymentService { internal fun startPaymentInternal() {} }
先ほどのコード例から一部を抜粋しました。
startPaymentInternal
は internal
なので非公開メソッドという扱いです。
当然ながら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
と表示されますがビルドは成功しちゃいます。
何が起こっているのでしょうか? 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 class
なTransaction
クラスを公開するということは、それらの生成されたメソッドも公開することになります。
というわけで、開発者はこのようなコードを書くことができます。
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
というライブラリがあって、通常のクラスでありながらも toString
や equals
などのメソッドを自動生成してくれるようです。
決済 SDK では導入しませんでしたが、このようなライブラリを使うのも良さそうですね。
このdata class
の問題についてはJake Wharton氏のブログに詳しくまとまっているので、興味がある方は読んでみてください。
Public API challenges in Kotlin - Jake Wharton
さいごに
「Javaで書かれたライブラリをKotlinに書き換えるときに考慮したいこと」というタイトルで「可視性」と「データクラス」を取り上げてみました。 ライブラリの内容や性質によってはこのような考慮・対処はやりすぎかもしれませんが、長期運用しているライブラリをKotlinに書き換えるようなシチュエーションでは一考の余地があるのではないでしょうか?
自分自身、今回のKotlin移行で初めて知ったこともあって、とても勉強になりました。アプリとライブラリでは考えるポイントが違うなと常々思います。
余裕があればAPIドキュメント生成をDokka
に移行した話も書きたかったのですが、ちょっと長くなってしまったので別の機会にしようと思います。
それではよいKotlinライフを!