Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Kotlin JVM用に作られたライブラリのKMPを前提とした再設計

jsoizo
November 14, 2026

Kotlin JVM用に作られたライブラリのKMPを前提とした再設計

jsoizo

November 14, 2026

Transcript

53 slides
  1. v1の構造 : expect classでCsvReaderを宣言 commonMain expect class CsvReader(ctx) readAll(String) ctx.charset:

    String jvmMain jsMain actual class CsvReader readAll(String) + readAll(File / InputStream) + open(fileName / File / InputStream) { } + openAsync(...) { } actual class CsvReader readAll(String) 8
  2. v1の構造 : CsvWriterはexpectにファイルIOだけを宣言 commonMain expect class CsvWriter(ctx) open(fileName) { }

    writeAll(rows, fileName) jvmMain jsMain actual class CsvWriter open(fileName) { } writeAll(rows, fileName) + open(File / OutputStream) { } + writeAll(rows, File / OutputStream) + writeAllAsString(rows) actual class CsvWriter open(fileName) { TODO() } writeAll(rows, fileName) { TODO() } 9
  3. 共通の設定のcharsetはJVMのactualでしか使われない の // v1.10.0 commonMain interface ICsvReaderContext { /** The

    name must be supported by [java.nio.charset.Charset]. */ val charset: String } の // jvmMain actual class CsvReader private val charsetCode = Charset.forName(charset) val br = file.inputStream().bufferedReader(charsetCode) KotlinにはUTF-8以外の文字コードを共通で扱う仕組みがない 変換結果がプラットフォームごとに少しずつ違い、共通化しきれない ※ Shift_JISでの違いはAppendixに 11
  4. 論点に照らすとAPIは4つに分かれる 副作用を 持つか 全ターゲットで 実装できるか 分類 機能 CSVの変換 パース 書き出し

    持たない できる 区切り文字などの設定 ファイル IO ストリーム 文字コード BOM 呼び出し方 持つ 非同期 1行ずつ書く 設計の結果 増やさない コア ブラウザ以外できる※1 kotlinx-io※2 共通のIO できる kotlinx-io※2 共通のIO 共通化が難しい 増やさない JVMのIO 増やさない 共通のIO 持たない できる 持つ 依存を 増やすか できる kotlinx-coroutines 提供しない できる 増やさない 提供しない※3 ※1 ブラウザは対象外と割り切った。コンパイルは通るが実行時にエラーになる。 ※2 kotlinx-ioへの依存は受け入れた ※3 Sequenceで行を渡せば足りると判断した 18
  5. コアは副作用を持たずSequenceで受け渡す // commonMain class CsvReader(val config: CsvReaderConfig = CsvReaderConfig()) {

    fun read(chars: Sequence<Char>): Sequence<List<String>> fun readAll(text: String): List<List<String>> } class CsvWriter(val config: CsvWriterConfig = CsvWriterConfig()) { fun write(rows: Sequence<List<String>>): Sequence<Char> fun writeAll(rows: List<List<String>>): String } • コアはこの4つの関数だけで、ファイルも文字コードも知らない • どんなIOからでもSequenceにすれば呼び出せる ※ Sequenceはstdlibだけで使えて、遅延評価で1行ずつ取り出せる 19
  6. ファイルIOはパターン4に行き着いた パターン2・3 ターゲットごとに書く形とファイルIOができるターゲットだけに置く形 全ターゲットに広げるにはしんどくて諦めた ↓ パターン4 だけ JVM kotlinx-ioで全ターゲットに置く issue

    #15のコメントでkotlinx-ioを勧められ 理想の形を採れた java.ioの型とcharsetを受け取る拡張関数は残した Spring BootやAndroidなどJVMでの使い方を支えるため 21
  7. kotlinx-ioへの依存は受け入れられると判断した 受け入れられる理由 残るリスク • Kotlinチームが開発していて 長く保守される見込みがある • 安定度はまだAlphaなので 破壊的な変更が入りうる •

    依存を許すのはIOの層だけで コアはstdlibだけで書ける • ファイルシステムの機能はexperimental • okioと違ってcommonから ファイルシステムをそのまま使える • 利用者のアプリにも実行時に入り バージョンがぶつかりうる※ 判断 • ファイルの読み書きの機能を全ターゲットに広げるためにAlphaのリスクを飲み込んだ • kotlinx-ioに触れるのはIOの層の2ファイルだけとして変わっても直す範囲は小さくした ※ 依存の公開範囲はAppendixに 23
  8. v2の構造 : expect/actualをなくせた commonMain class CsvReader(config) read(Sequence<Char>) readAll(String) kotlinx-ioを使う拡張関数 コア

    共通のIO read(Source) { } readFromFile(Path / String) { } jvmMain java.ioを使う拡張関数 jsMain ほか JVMのIO readFromFile(File, charset) { } read(InputStream, charset) { } 固有のコードなし 24
  9. v2の構造 : WriterはReaderの完全な裏返し commonMain class CsvWriter(config) コア write(Sequence<List<String>>) writeAll(List<List<String>>) kotlinx-ioを使う拡張関数

    共通のIO write(rows, Sink) writeToFile(rows, Path / String) jvmMain java.ioを使う拡張関数 jsMain ほか JVMのIO writeToFile(rows, File, charset) write(rows, OutputStream, charset) 固有のコードなし 25
  10. IO周りは拡張関数で実装したため同じクラスから呼べる だけ 文字コードは共通の設定で指定 // v1 JVM val reader = csvReader

    { charset = "Shift_JIS" } val rows = reader.readAll(File("in.csv")) reader.open("in.csv") { readAllAsSequence().forEach { println(it) } } 共通の拡張関数 文字コードは だけ // v2 UTF-8 val reader = csvReader() val rows = reader.readAllFromFile("in.csv") reader.readFromFile("in.csv") { rows -> rows.forEach { println(it) } } の拡張関数 文字コードは引数で指定 // v2 JVM val sjis = reader.readAllFromFile(File("in.csv"), charset = "Shift_JIS") 26
  11. ブラウザのJSでは引き続きファイルIOできない • Node.jsではファイルIOができるようになった • ブラウザではファイルのパスを受け取る関数が例外になる ◦ kotlinx-ioのJS版はNode.jsのfsを前提に実装されている ◦ ブラウザではfsを解決できない •

    ブラウザで何もできないわけではない ◦ 文字列かSource・Sinkを渡せば読み書きできる • ブラウザはAPIの設計を決めたときに対象外として割り切った ※ ブラウザで確かめた結果はAppendixに 27
  12. ソースコードを変えずにNativeの6ターゲットを足せた ターゲット v1 v2 JVM ◦ ◦ JS 文字列のパースだけ ◦

    Node.jsではファイルIOも Native × macOS・iOS・Linux・Windows Wasm × wasmWasi・wasmJS • Nativeはbuild.gradle.ktsに8行足すだけで済んだ 28
  13. 安全で移行しやすいリリース 破壊的な変更なので、実装だけでなくテストや移行の手段も整えた 安全にリリースする 移行しやすくする リスク リスク 作り直したので挙動も性能も変わりうる 確かめるターゲットが増える 公開APIを壊すので利用者が移行で困る やったこと

    • ブリッジバージョンで先にAPIだけ移せる • 全ターゲットをCIで確かめる • 警告から移行ガイドへたどれる やったこと • プロパティテストで不具合を探す • 性能をv1と比べて測る 31
  14. 全ターゲットでテストするためフレームワークを替えた v1 v2 テストの実行 kotest-runner-junit5 kotlin-test アサーション kotest-assertions kotest-assertions プロパティテスト

    なし kotest-property 確かめたターゲット JVMだけ 全ターゲット • アサーションは表現力が豊かで好きなkotestを続けた • kotest-assertionsとkotest-propertyはKMPに対応している 32
  15. ホストごとのCIで全ターゲットを確かめる ターゲット 動かす場所 確かめ方 JVM・Node.js・linuxX64 Ubuntu x64 テスト実行 linuxArm64・wasmWasi Ubuntu

    x64 コンパイルだけ macosArm64・iOSシミュレーター macOS arm64 テスト実行 iosArm64 macOS arm64 コンパイルだけ mingwX64 Windows x64 テスト実行 Nativeのテストは同じOSのホストでしか動かせないので、 いくつかのターゲットはコンパイルのみに留めている 33
  16. 副作用を持たないコアは性質で確かめられる 規則に沿ってランダムに作った入力で、性質が崩れないかを確かめる 入力を作る カンマ 引用符 改行を 多めに混ぜて500通り コアロジックを通す → writeAllで書いて

    readAllで読む → 性質を確かめる 書いて読むと 元に戻るか • 副作用がないのでファイルもモックも要らず、文字列だけで書ける • commonTestに書けばテストを実行するターゲットでそのまま動く ◦ 特定ターゲットでのエッジな挙動が起きるリスクを小さくする 34
  17. プロパティテストが書き込みの不具合を見つけた バックスラッシュでエスケープする設定で、値 a\b を書いて読む 書いた結果 期待 読んだ結果 a\b に戻る 修正前

    a\\b a\\b のまま 修正後 "a\\b" a\b に戻る • 読み込みは引用符の中のエスケープだけを解釈していた • エスケープ文字を含む値は引用符で囲むように直した 35
  18. 見つかった性能劣化を公開APIを変えずに直した 10万行×20列のデータで、v1.10.0を1としたときの速さ 処理 v2 修正前 v2 修正後 ストリームに書く 0.43倍 1.81倍

    ファイルに書く 0.57倍 2.51倍 ストリームから読む 0.96倍 1.82倍 ファイルから読む 0.94倍 1.85倍 • 内部で1文字ずつyieldしていたのをまとめて処理する形に変えた 37
  19. ブリッジバージョン1.11.0で先にAPIだけ移せる 1.10.0 1.11.0 2.0.0 v1のAPIだけ 非推奨のv1のAPI + v2のコアとJVMのIO v2のAPIだけ →

    → coroutinesは据え置き kotlinx-ioなし Kotlin 2.3でビルド kotlinx-ioに依存 • 1.11.0で移行を済ませれば、2.0.0へはほぼ上げるだけ • 非推奨の警告に移行ガイドのURLを載せた • @ReplaceWith アノテーションはパッケージ変更のため使えず 38
  20. KMP前提のライブラリ設計を一般化するならば ライブラリが提供したい価値をコアに置き、付随するものは外から足す コア commonMain 公開APIのクラスと副作用のないロジック stdlibだけで書く CsvReader / CsvWriter Sequence<Char>

    ⇄ 例: と IOなどの副作用 例:kotlinx-io の commonMain 拡張関数で外から足す 差を吸収するライブラリに任せる Source / Sink / Path プラットフォーム固有 例:java.io の 行の変換 jvmMain File / InputStream など プラットフォームの型が要るものだけ 拡張関数で足す と charset 全ターゲットのうちできるだけ多くでCIを動かして確かめる ※ データを変換するライブラリならこの形になる。kotlinx.serializationのJSONも同じ形。 ※ 性能のために内部ではIOと変換を分けずに直接つなぐこともある 41
  21. まとめ プラットフォーム固有の仕様を共通の公開APIに入れると何が苦しいか • 実装を各ターゲットのactualに頼るので、同じ機能をそろえにくい • 破壊的変更を前提とした設計の見直しが必要な場合も 公開APIの設計をどのように決めるか • 副作用、全ターゲットで実装できるか、依存の3つで置き場所を決める •

    価値はコアに置き、付随するものは外から足す • stdlibとkotlinxだけで書くほどKMPのターゲットを足しやすくなる 作り直しを安全で移行しやすいリリースにするにはどうするか • ホストごとCI、プロパティテスト、旧版との性能比較で安全にする • ブリッジバージョンと移行ガイドを作って移行しやすくする 43
  22. 同じShift_JISでもプラットフォームごとに結果が違う ターゲット 使う仕組み 0x81 0x60 を読む 0x87 0x40 を読む 書く

    JVM Charset Shift_JIS 〜 U+301C U+FFFD U+0040 できる※1 JVM Charset windows-31j ~ U+FF5E ① U+2460 できる※1 JS TextDecoder shift_ jis ~ U+FF5E ① U+2460 できない※2 Native Apple Foundationの.shiftJIS ~ U+FF5E ① U+2460 できる ※1 Shift_JISはU+FF5Eと①を、windows-31jはU+301Cを書くと「?」になる ※2 TextEncoderはUTF-8しか書けない 46
  23. JVMやJSの利用者にはkotlinx-ioが実行時にだけ入る kotlin-csvはcommonMainでkotlinx-io-coreをimplementationで宣言している 利用者のターゲット コンパイル時 実行時 JVMやJSを含む 見えない 入る Nativeだけ 見える

    入る • String、File、InputStreamを受ける関数だけなら、kotlinx-ioを足さずに使える • SourceやPathを受ける関数を使うなら、kotlinx-ioを自分で依存に足す • 利用者側と1つのバージョンにまとまり、破壊的な変更でどちらかが壊れうる 47
  24. ブラウザでも文字列とSourceとSinkなら読み書きできる kotlin-csv 2.0.0をKotlin/JSでブラウザ向けにコンパイルし、Chrome 157で動かした 渡したもの 呼んだ関数 結果 文字列 readAll(text) 読めた

    Source readAll(buffer) 読めた Sink write(rows, buffer) 書けた ファイルのパス readAllFromFile(“in.csv”) 例外になった • SourceはブラウザのArrayBufferをkotlinx-ioのBufferに積んで渡した • Sinkに書いたBufferはBlobにできたが、ダウンロードの操作は試していない • ファイルのパスは「Module ‘fs’ could not be imported」で失敗した 48
  25. 書いて読むと元に戻るかを500通りの入力で確かめる @Test fun singleFieldRoundTrip() = runTest { val config =

    PropTestConfig(seed = 0L, iterations = 500) checkAll(config, dialectArb, fieldArb) { dialect, field -> val text = csvWriter { this.dialect = dialect this.quoteMode = WriteQuoteMode.CANONICAL this.outputLastLineTerminator = true }.writeAll(listOf(listOf(field))) val parsed = csvReader { this.dialect = dialect }.readAll(text) parsed shouldBe listOf(listOf(field)) } } 50
  26. 500通りの入力はCSVで意味を持つ文字に寄せて作る 文字を一様に選ぶと、区切りや引用符はほとんど出てこない 1文字ずつ選ぶ 値にする 7割 3割 CSVで意味を持つ文字 全部の文字 → 0〜16文字

    設定を選ぶ → カンマ タブ 引用符 \ 改行など13文字 標準のCSV タブ区切り \でエスケープ 性質が崩れたら、kotestが入力を最小の例まで縮めて見せる わざと「カンマを含む値なら失敗」にすると、入力は「,」まで縮んだ 51
  27. 1文字ごとにyieldして中断と再開を繰り返していた の 共通の も同じ作り // JVM IO IO private fun

    BufferedReader.toCharSequence(): Sequence<Char> = sequence { while (true) { val ch = read() if (ch == -1) break yield(ch.toChar()) } } // write private fun encodeField(field: String, ...): Sequence<Char> = sequence { if (shouldQuote) yield(quoteChar) for (ch in field) { if (ch == quoteChar) yield(quoteChar); yield(ch) } if (shouldQuote) yield(quoteChar) } コアの 52

More Decks by jsoizo

Other Decks in Programming