はじめに
昨年は Kotlin/JS の現状を確かめる と題して、Kotlin/JS まわりを試してみました。
今回の題材はこちら。
Kotlin cross-platform / multi-format reflectionless serialization
マルチプラットフォーム(JVM, JS, Native)で使えるシリアライゼーションの仕組みです。
コンパイラプラグインとランタイムライブラリで構成されています。
JSON, CBOR, Protobuf 形式に変換できる様です。
色々と試したいところですが、JSON 変換を試してみたいと思います。
JSON 変換ライブラリと言えば、Android では GSON, Moshi、Spring では Jackson などと様々存在するわけですが、バックエンドとフロントエンドで異なるライブラリを使うとそれぞれに学習する必要があり、また各々カスタマイズが必要だったりするかと思います。
共通化するならば、Kotlin 標準はどうだろう?ということで今回の題材です。目指すは、バックエンドとフロントエンドでの JSON 変換の共通化。以下の図の構成を目指した一歩になります。
今回の記事のコードを読みたい方は kotlin-serialization-sample をどうぞ。
マルチプラットフォームライブラリを構成する
マルチプラットフォームで試すために、まずは環境を構築します。
Building Multiplatform Projects with Gradle
目指すは、Android, iOS, Web での共通化なのですが最初からそこを目指すと遭難しそうなので、まずは Kotlin/JVM, Kotlin/Native, Kotlin/JS を全て使うところから始めます。
ディレクトリ構成
/
|- gradlew
|- gradlew.bat
|- gradle
| |- wrapper
| |- gradle-wrapper.jar
| |- gradle-wrapper.properties
|- gradle.properties
|- settings.gradle
|- sample-library
|- build.gradle
|- src
|- commonMain
|- commonTest
|- jsMain
|- jsTest
|- jvmMain
|- jvmTest
|- macosMain
|- macosTest
Gradle バージョン
今回のサンプルでは Gradle Wrapper を使っています。Gradle のバージョンは gradle-wrapper.properties で指定しています。
distributionUrl=https\://services.gradle.org/distributions/gradle-4.7-all.zip
今回は Native の環境を含むため、バージョン 4.7 を使う必要があります。
You must have Gradle 4.7, because higher versions have unsupported format of metadata.
gradle.properties
Gradle plugin や依存ライブラリのバージョンの指定には gradle.properties を使うことにします。
kotlinVersion=1.3.11
kotlinSerializationVersion=0.9.1
後の話になりますが、Gradle plugin の version 指定に変数が使えない問題に対処するためです。詳しくは、Allow the plugin DSL to expand properties as part of the version を参照。
settings.gradle
pluginManagement {
resolutionStrategy {
eachPlugin {
if (requested.id.id == "kotlin-multiplatform") {
useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
if (requested.id.id == "kotlinx-serialization") {
useModule("org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion")
}
}
}
}
include ':sample-library'
rootProject.name = 'kotlin-serialization-sample'
// for native
enableFeaturePreview('GRADLE_METADATA')
multiplatform Gradle plugin と serialization Gradle plugin は Gradle plugin portal に公開されていないため、pluginManagement に設定を追加します。Gradle (with plugins block) に書かれています。バージョン指定の方法を変更していますが、gradle.properties で指定したバージョンを使用するための処置です。
また、Native で使う場合には、enableFeaturePreview('GRADLE_METADATA')
が必要です。シリアライゼーションの Native の説明 に書かれています。
build.gradle
sample-library の build.gradle です。今回のサンプルの肝になる部分です。
buildscript {
repositories {
mavenCentral()
}
}
plugins {
id 'kotlin-multiplatform'
id 'kotlinx-serialization'
}
repositories {
mavenCentral()
// for kotlinx-serialization-runtime
maven { url "https://kotlin.bintray.com/kotlinx" }
}
group 'com.example'
version '0.0.1'
apply plugin: 'maven-publish'
kotlin {
targets {
fromPreset(presets.jvm, 'jvm')
fromPreset(presets.js, 'js')
// For ARM, preset should be changed to presets.iosArm32 or presets.iosArm64
// For Linux, preset should be changed to e.g. presets.linuxX64
// For MacOS, preset should be changed to e.g. presets.macosX64
fromPreset(presets.macosX64, 'macos')
}
sourceSets {
commonMain {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib-common'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$kotlinSerializationVersion"
}
}
commonTest {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-test-common'
implementation 'org.jetbrains.kotlin:kotlin-test-annotations-common'
}
}
jvmMain {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$kotlinSerializationVersion"
}
}
jvmTest {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-test'
implementation 'org.jetbrains.kotlin:kotlin-test-junit'
}
}
jsMain {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib-js'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-js:$kotlinSerializationVersion"
}
}
jsTest {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-test-js'
}
}
macosMain {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-native:$kotlinSerializationVersion"
}
}
macosTest {
}
}
}
まず、plugins で multiplatform plugin と serialization plugin を指定しています。settings.gradle で設定した plugin を指定しています。ここで version を指定できるのですが、version の指定に変数を使うことができないため settings.gradle で version を指定しています。
targets では、presets から target platform を選択しています。presets の一覧は Supported platforms で確認できます。presets.jvm
に続く 'jvm'
は targetName です。任意の名前を付けられます。targetName は sourceSets におけるプレフィックスになり、ディレクトリ名と sourceSets の指定で使われます。例えば、targetName が 'jvm'
ならば jvmMain
, jvmTest
を src ディレクトリに加えます。そして、sourceSets に jvmMain
, jvmTest
に対する設定を書きます。
各 sourceSets では serialization runtime ライブラリを依存に追加します。JVM, JS, Native で異なるので注意が必要です。
Serialization サンプルコード
common コードでテスト
Quick example をアレンジして試してみます。
src
|- commonMain
| |- resources
| |- kotlin
| |- sample
| |- Data.kt
|- commonTest
|- resources
|- kotlin
|- sample
|- SerializationTests.kt
resources ディレクトリは不要ですが、ディレクトリ構成のイメージを補うために書いています。Main の kotlin ディレクトリに sample.Data
クラスのコード、Test の kotlin ディレクトリに sample.SerializationTests
クラスのコードを配置しています。
package sample
import kotlinx.serialization.Optional
import kotlinx.serialization.Serializable
@Serializable
data class Data(val a: Int, @Optional val b: String = "42")
package sample
import kotlinx.serialization.UpdateMode
import kotlinx.serialization.json.JSON
import kotlinx.serialization.list
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class SerializationTests {
private lateinit var json: JSON
@BeforeTest
fun setUp() {
json = JSON(
unquoted = false,
indented = false,
indent = " ",
strictMode = true,
updateMode = UpdateMode.OVERWRITE,
encodeDefaults = true)
}
@Test
fun testSerialize() {
val jsonData = json.stringify(Data.serializer(), Data(42))
assertEquals("""{"a":42,"b":"42"}""", jsonData)
val jsonList = json.stringify(Data.serializer().list, listOf(Data(42)))
assertEquals("""[{"a":42,"b":"42"}]""", jsonList)
}
@Test
fun testDeserialize() {
val obj = JSON.parse(Data.serializer(), """{"a":42}""")
assertEquals(Data(a=42, b="42"), obj)
}
}
まずは実行してみたいと思います。multiplatform plugin を使うと Gradle タスクに check, <targetName>Test が追加されます。つまり、今回のサンプルでは check, jvmTest, jsTest, macosTest タスクが追加されます。
A test task is created under the name <targetName>Test for each target that is suitable for testing. Run the check task to run the tests for all targets.
引用:https://kotlinlang.org/docs/reference/building-mpp-with-gradle.html#running-tests
check タスクを使えば JVM, JS, Native の全てのターゲットでテストが実行される様です。
$ ./gradlew check
... snip ...
[==========] Running 2 tests from 1 test cases.
[----------] Global test environment set-up.
[----------] 2 tests from sample.SerializationTests
[ RUN ] sample.SerializationTests.testSerialize
[ OK ] sample.SerializationTests.testSerialize (0 ms)
[ RUN ] sample.SerializationTests.testDeserialize
[ OK ] sample.SerializationTests.testDeserialize (1 ms)
[----------] 2 tests from sample.SerializationTests (1 ms total)
[----------] Global test environment tear-down
[==========] 2 tests from 1 test cases ran. (1 ms total)
[ PASSED ] 2 tests.
テストがパスした様子です。しかし、本当にテストされているのでしょうか?
ターゲット毎にテストされていることを検証
ターゲット毎に結果の異なる簡単なテストを用意します。作成するファイルは SampleTests.kt です。
src
|- commonTest
| |- kotlin
| |- sample
| |- SampleTests.kt
|- jvmTest
| |- kotlin
| |- sample
| |- SampleTests.kt
|- jsTest
| |- kotlin
| |- sample
| |- SampleTests.kt
|- macosTest
|- kotlin
|- sample
|- SampleTests.kt
commonTest の SampleTests.kt にテストを用意します。isWorking 関数の戻り値が true であれば成功するテストです。
package sample
import kotlin.test.Test
import kotlin.test.assertTrue
expect fun isWorking(): Boolean
class SampleTests {
@Test
fun testIsWorking() {
assertTrue(isWorking())
}
}
jvmTest, jsTest, macosTest では isWorking 関数を定義します。
package sample
actual fun isWorking(): Boolean = true
check タスクを実行して確かめます。
$ ./gradlew check
... snip ...
[----------] 1 tests from sample.SampleTests
[ RUN ] sample.SampleTests.testIsWorking
[ OK ] sample.SampleTests.testIsWorking (0 ms)
[----------] 1 tests from sample.SampleTests (0 ms total)
... snip ...
テストはパスします。次に、各ターゲットの isWorking 関数の戻り値を false にして check タスクを実行してみます。
ターゲット | テスト結果 |
---|---|
jvm | FAILED |
js | OK |
macos | FAILED |
おや?ターゲットが js の場合は OK になります。js ではテストされていないことがわかりました。実はテスト実行は JVM, Android, Linux, Windows, macOS のみのサポートになっています。JS はサポートされていません。
Running tests in a Gradle build is currently supported by default for JVM, Android, Linux, Windows and macOS; JS and other Kotlin/Native targets need to be manually configured to run the tests with an appropriate environment, an emulator or a test framework.
引用:https://kotlinlang.org/docs/reference/building-mpp-with-gradle.html#running-tests
jsTest タスクの実行環境を構築する
Multiplatform Kotlin application のサンプル を参考に、jsTest タスクを実行できる様にしてみます。
サンプルでは Gradle Plugin for Node と Mocha を組み合わせて構築しています。しかし、Gradle Plugin for Node の更新は止まっており、node のダウンロードで失敗してしまいます。他の方法を探してみたところ、Node Gradle Plugin を発見しました。
まずは、sample-library の build.gradle に Node Gradle Plugin の設定を追加します。
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("com.liferay:com.liferay.gradle.plugins.node:$nodePluginVersion")
}
}
apply plugin: "com.liferay.node"
node {
nodeVersion = project.nodeVersion
}
nodePluginVersion と nodeVersion は gradle.properties で設定しています。
nodePluginVersion=4.5.1
nodeVersion=10.13.0
今回、node の設定はバージョンだけを行っています。バージョンは 10.13.0 (LTS) を指定しています。他にも諸々の設定があり、デフォルト設定では node がダウンロードされる設定になっています。インストール済みの node を使いたい場合には Project Extension で指定できます。
プラグインの設定に続いて、node module に関する設定を package.json に記述します。ファイルの置き場所は sample-library ディレクトリ直下です。
{
"name": "sample-library",
"devDependencies": {
"mocha": "5.2.0"
},
"scripts": {
"test": "node_modules/mocha/bin/mocha node_modules"
}
}
Mocha をインストールさせるために devDependencies を記述し、テスト実行のために scripts を記述します。ひとまず、node_modules 以下にあるテストコードを実行させます。
node_modules 以下にテストコードを配置することにしたので、node_modules ディレクトリにテストコードをコピーする設定を build.gradle に追加します。サンプル でも同様の処理を行っています。但し、サンプルでは multiplatform Gradle plugin を使っておらず、細かい部分で変更を加える必要がありました。
task populateNodeModules(dependsOn: [npmInstall, compileTestKotlinJs]) {
doLast {
copy {
from compileKotlinJs.destinationDir
from compileTestKotlinJs.destinationDir
def jsCompilations = kotlin.targets.js.compilations
jsCompilations.test.runtimeDependencyFiles.each {
if (it.exists() && !it.isDirectory()) {
println("from ${it.toString().split('/').last()}")
from zipTree(it.absolutePath).matching { include '*.js' }
}
}
into "${projectDir}/node_modules"
}
}
}
npmInstall タスクは Node Gradle plugin に含まれるタスクです。npmInstall タスクが完了すると node_modules ディレクトリに Node モジュールがインストールされている状態になります。今回のサンプルでは Mocha のインストールが npmInstall タスクで行われます。
また、compileTestKotlinJs タスクは multiplatform Gradle plugin に含まれるタスクです。targetName に応じた compileTestKotlin<targetName> タスクが生成されます。今回のサンプルでは、compileTestKotlinJs, compileTestKotlinJvm, compileTestKotlinMacos が生成されています。これらのタスクの存在は println(tasks)
を実行して調べられます。
jsCompilations
あたりのコードは IDEA のコード を参考にしています。このコードによって、依存しているライブラリのアーカイブから JavaScript ファイルを抽出しています。例えば、Kotlin 標準ライブラリの JAR ファイルから kotlin.js が抽出されます。
設定の完了まであと一息です。最後に、jsTest タスク実行前に Mocha が実行される様にし、Mocha の実行前に populateNodeModules が実行される様にします。また、モジュールの種類は node に対応する種類 (commonjs または umd) を指定します。
[compileKotlinJs, compileTestKotlinJs]*.configure {
kotlinOptions {
sourceMap = true
sourceMapEmbedSources = "always"
moduleKind = 'commonjs'
}
}
jsTest.dependsOn npmRunTest
npmRunTest.dependsOn populateNodeModules
npmRun<scriptName> タスクで Mocha を実行しています。package.json の scriptes で設定した test スクリプトを、npmRunTest タスクで実行できます。
以上で、jsTest タスクの実行環境の構築は完了です。jsTest タスクを実行して試してみるとテストの実行を確認できます。
複雑なデータ構造で JSON 変換を試す
環境が整ったところで、複雑なデータ構造で JSON 変換をしてみます。試すデータ構造は以下のとおりです。ComplexData オブジェクトが複数の ChildData オブジェクトを List として持ち、ChildData オブジェクトが BigDecimal と DateTime を持つデータ構造です。
package sample
import kotlinx.serialization.Serializable
@Serializable
data class ComplexData(val children: List<ChildData>) {
constructor(vararg children: ChildData) : this(children.toList())
}
package sample
import com.soywiz.klock.DateTime
import kotlinx.serialization.Serializable
@Serializable
data class ChildData(
val decimal: BigDecimal,
val dateTime: DateTime
)
BigDecimal
BigDecimal は expect と actual を使って以下に示すとおりに実装します。雑な実装なので、四則演算すらできません。actual class で data class を使おうと思ったら、expect class でプライマリコンストラクタにパラメータを持たせることになります。expect class のプライマリコンストラクタにはプロパティパラメータ (val/var が付くパラメータ) を持たせられないため、value パラメータには val/var は付けません。
package sample
expect class BigDecimal(value: String) : Comparable<BigDecimal>
package sample
import java.math.BigDecimal as JvmBigDecimal
actual class BigDecimal actual constructor(value: String) : Comparable<BigDecimal> {
private val value = JvmBigDecimal(value)
override fun toString(): String = value.toString()
override fun compareTo(other: BigDecimal): Int = value.compareTo(other.value)
}
package sample
actual data class BigDecimal actual constructor(private val value: String) : Comparable<BigDecimal> {
override fun toString(): String = value
override fun compareTo(other: BigDecimal): Int = compareValues(value, other)
}
macosMain の BigDecimal.kt は jsMain の BigDecimal.kt と同じにします。実際にはそれぞれのプラットフォームで異なる実装になるでしょう。
マルチプラットフォーム対応のライブラリが無い場合には、各プラットフォームのライブラリをラッピングして使用したり、独自に実装する必要がでてくると思います。もっと良い方法があれば知りたいところです。
DateTime
DateTime にはマルチプラットフォーム対応の Klock というライブラリがあります。Klock がどれだけ使い物になるかは未知数です。今回は動かしてみることが目的なので使ってみました。
gradle.properties に Klock のバージョンを追加します。
klockVersion=1.0.0
次に build.gradle の commonMain に Klock への依存を追加します。リポジトリの指定も必要です。以下には追記部分のみを記述しています。
repositories {
// for DateTime
maven { url "https://dl.bintray.com/soywiz/soywiz" } // 追記
}
kotlin {
sourceSets {
commonMain {
dependencies {
implementation "com.soywiz:klock:$klockVersion" // 追記
}
}
}
}
テストコード
動作確認するためのテストコードでは以下を行っています。
- ComplexData オブジェクトを JSON に変換
- JSON を ComplexData オブジェクトに変換
- 前後の ComplexData オブジェクトを比較
package sample
import com.soywiz.klock.DateTime
import com.soywiz.klock.days
import kotlinx.serialization.UpdateMode
import kotlinx.serialization.json.JSON
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ComplexSerializationTests {
private lateinit var json: JSON
@BeforeTest
fun setUp() {
json = JSON(
unquoted = false,
indented = false,
indent = " ",
strictMode = true,
updateMode = UpdateMode.OVERWRITE,
encodeDefaults = true)
}
@Test
fun testComplexDate() {
val data = ComplexData(
ChildData(
BigDecimal("12345678901234567890123456789012345678901234567890"),
DateTime.now()
),
ChildData(
BigDecimal("12345678901234567890123456789012345678901234567890.09876543210987654321"),
DateTime.now() + 1.days
)
)
val json = JSON.stringify(ComplexData.serializer(), data)
println(json)
val deserializedData = JSON.parse(ComplexData.serializer(), json)
assertEquals(data, deserializedData)
}
}
テストの実行結果
テストを実行します。
$ ./gradlew check
...
SerializationException: Can't locate argument-less serializer for class BigDecimal. For generic classes, such as lists, please provide serializer explicitly.
...
BUILD FAILED in 1s
1 actionable task: 1 executed
残念ながら失敗します。serializer を明示する様に書かれています。それもそうです。BigDecimal と DateTime では Serializer を生成していません。
BigDecimal クラスに Serializable アノテーションを付けて再度テストを実行してみます。
$ ./gradlew check
...
TypeError: tmp$.serializer is not a function
at compiledSerializer (node_modules/kotlinx-serialization-runtime-js.js:8711:87)
...
BUILD FAILED in 2s
5 actionable tasks: 3 executed, 2 up-to-date
serializer が関数ではないと言われています。困りました。
Multiplatform Kotlin application のサンプル では expect, actual を使いつつ serialization を使っています。そこでは、Serializer をカスタマイズすることで実現しています。
Serializer のカスタマイズ
BigDecimal のカスタム Serializer を作ってみます。
package sample
import kotlinx.serialization.Decoder
import kotlinx.serialization.Encoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializer
@Serializer(forClass = BigDecimal::class)
object BigDecimalSerializer: KSerializer<BigDecimal> {
override fun serialize(output: Encoder, obj: BigDecimal) {
output.encodeString(obj.toString())
}
override fun deserialize(input: Decoder): BigDecimal {
return BigDecimal(input.decodeString())
}
}
DateTime はライブラリに含まれるクラスです。このようなクラスには Serializable アノテーションを付与できないため、こちらもカスタム Serializer を作成します。
package sample
import com.soywiz.klock.DateFormat
import com.soywiz.klock.DateTime
import com.soywiz.klock.parse
import kotlinx.serialization.Decoder
import kotlinx.serialization.Encoder
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializer
@Serializer(forClass = DateTime::class)
object DateTimeSerializer : KSerializer<DateTime> {
private val format = DateFormat("yyyy-MM-dd'T'hh:mm:ss")
override fun serialize(output: Encoder, obj: DateTime) {
output.encodeString(obj.format(format))
}
override fun deserialize(input: Decoder): DateTime {
return format.parse(input.decodeString()).utc
}
}
再度テストを実行
では、再びテストを実行します。
./gradlew check
...
Caused by: java.lang.IllegalStateException: Class DateTime is not externally serializable
at org.jetbrains.kotlinx.serialization.compiler.backend.common.SerializerCodegen.generate(SerializerCodegen.kt:42)
...
BUILD FAILED in 1s
1 actionable task: 1 executed
再び残念な結果になりました。
エラーは SerializerCodegen.kt で properties.isExternallySerializable のチェックに引っかかることで発生しています。SerializableProperties.kt に isExternallySerializable の実装があります。
val isExternallySerializable: Boolean =
primaryConstructorParameters.size == primaryConstructorProperties.size
プライマリコンストラクタのパラメータとプロパティの数が一致する必要があるとのことです。DateTime クラスの実装を見ると、プライマリコンストラクタにはプロパティが一つだけなので問題なさそうに見えますが inline class になっていることが問題なのかもしれません。Class is not externally serializable についての Issue が上がっており、v1.3.20-eap-25 の SerializerCodegen.kt を見ると実装が変更されているため、次のバージョンを待ってみることにします。
ということで DateTime は断念することにして、BigDecimal だけでも動かしてみたいと思います。ChildData から DateTime を削除し、関連するコードも全て削除した上でテストを再々実行すると、やはり失敗します。
Serializer の指定
エラーメッセージには please provide serializer explicitly. と書かれています。Serializer を明示的に指定しなければいけない様子なので、以下のとおりに指定します。
package sample
import kotlinx.serialization.Serializable
@Serializable
data class ChildData(
@Serializable(with = BigDecimalSerializer::class) val decimal: BigDecimal
)
以下の様に追加する方法でいけるかと思ったのですが駄目でした。
json.install(SimpleModule(BigDecimal::class, BigDecimalSerializer))
3度目の正直?
今回は JS でのテストには成功します!しかし、JVM では IllegalStateException: Class BigDecimal is not externally serializable が発生します。これは既に原因を追求したエラーです。次のバージョンまで待つ事案として保留します。
おわりに
今回は BigDecimal, DateTime を含むサンプルは動かせられず、かなり残念な結果になってしまいました。まだバージョン 1.0 になっていないので仕方がないかなとは思います。冒頭に掲げた以下の構成を実現する道のりは、まだまだ遠いのかなと思います。
上の構成を実現するには JSON 変換だけでなく HTTP クライアントライブラリと非同期処理ライブラリの共通化も必要です。
HTTP クライアントライブラリは、Ktor で Multiplatform Http Client の開発が進んでいます。非同期処理ライブラリとしてはコルーチンが既にマルチプラットフォーム対応しています。RxKotlin や Reactor でもマルチプラットフォーム対応の話が挙がってはいる様子です。
今回やってみて最も良さそうだなと思った点は、テストコードを共通化できる点です。複数のプラットフォーム毎に通信部分の実装とテストを行うのではなく、ひとつのテストで複数のプラットフォーム対応にできたら随分と楽なのではないかと思いました。
また来年も何かを試してみたいと思います。
おまけ
下記の記事なんかも読んでみては如何でしょうか?