Share
Embed
Transcript
53 slides
-
自己紹介 関根 純 せきね じゅん 経歴 いくつかのWeb系の事業会社でソフトウェア開発に従事したのち、 現在は株式会社コドモンで働いています。 Kotlin、Scala、Apache Pekkoが好き。
kotlin-csvのメンテナをやっています。 最近、Ktor Native on Cloudflareを試してうまくいって嬉しい。 : @jsoizo 2 -
kotlin-csvはKotlin/JVM向けに始まった • Pure KotlinのCSVリーダー・ライター • csvの文字列やファイルへの読み書きが出来る • 2019年にdoyaaaaakenさんが作り2024年に私が引き継いだ // v1.10.0
val rows = csvReader().readAll(File("in.csv")) csvWriter().writeAll(rows, "out.csv") 6 -
KMPで複数プラットフォームから使いたいという要望 需要の大きさはわからないが、あれば使う人はいそうだった → 2021年頃にJSのみサポートを追加 • #15 Nativeで使いたいというコメントが集まった • #151 multiplatformと書いてあるのにiOSに対応していない
• #160 JS版にwriteAllAsStringがない 一方で、設計の都合からターゲットを増やすことが難しかった 7 -
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 -
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 -
共通の設定の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 -
公開APIの作り直しは5つの段階で進める メジャーバージョンアップなので公開APIを一から決め直せる ※ 当時はここまで丁寧に計画していないが、振り返るとこの順で進めていた 1 公開APIを洗い出す 2 関心事で分類する 3 APIの設計の論点を出す
4 論点に照らしてAPIを設計する 5 定めた設計に基づいて実装する 15 -
v1の機能を関心事で分類する CSVの変換 IO 呼び出し方 文字列をパースする ファイルを読み書きする 非同期で読み書きする 行を書き出す ストリームを読み書きする 1行ずつ書く
区切り文字などを設定する 文字コードを選ぶ BOMを扱う 16 -
論点に照らすと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 -
コアは副作用を持たず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 -
IOの扱いを4パターンで検討する v1のファイルIOは残したいが、保守コストが上がる形は避けたい。 ファイルIOは「実装できるか」と「依存を増やすか」の両方に関わる 1 諦めてファイルIOを責務の外にする 2 共通で宣言し実装はexpect/actualで書く Nativeの実装が見えず早々に外した 3 ファイルIOができるターゲットだけに置く
4 全ターゲットのIOをまとめて共通に置く ソースセットの構造も見直す 理想 最悪でもパーサーは残る JVM固有の型だけこの形で足した 差を吸収するkotlinx-ioに任せた 20 -
ファイルIOはパターン4に行き着いた パターン2・3 ターゲットごとに書く形とファイルIOができるターゲットだけに置く形 全ターゲットに広げるにはしんどくて諦めた ↓ パターン4 だけ JVM kotlinx-ioで全ターゲットに置く issue
#15のコメントでkotlinx-ioを勧められ 理想の形を採れた java.ioの型とcharsetを受け取る拡張関数は残した Spring BootやAndroidなどJVMでの使い方を支えるため 21 -
kotlinx-ioはプラットフォームごとのIOの差を吸収する • KMP向けのIOライブラリで、okioがもとになっている • InputStreamやOutputStreamのような仕組みを共通で使える • ファイルを開く処理もプラットフォーム共通のコードで書ける の共通の // commonMain
kotlin-csv IO fun <T> CsvReader.readFromFile(path: Path, options: ..., block: ...): T = SystemFileSystem.source(path).buffered().use { source -> read(source, options, block) } 22 -
kotlinx-ioへの依存は受け入れられると判断した 受け入れられる理由 残るリスク • Kotlinチームが開発していて 長く保守される見込みがある • 安定度はまだAlphaなので 破壊的な変更が入りうる •
依存を許すのはIOの層だけで コアはstdlibだけで書ける • ファイルシステムの機能はexperimental • okioと違ってcommonから ファイルシステムをそのまま使える • 利用者のアプリにも実行時に入り バージョンがぶつかりうる※ 判断 • ファイルの読み書きの機能を全ターゲットに広げるためにAlphaのリスクを飲み込んだ • kotlinx-ioに触れるのはIOの層の2ファイルだけとして変わっても直す範囲は小さくした ※ 依存の公開範囲はAppendixに 23 -
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 -
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 -
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 -
ブラウザのJSでは引き続きファイルIOできない • Node.jsではファイルIOができるようになった • ブラウザではファイルのパスを受け取る関数が例外になる ◦ kotlinx-ioのJS版はNode.jsのfsを前提に実装されている ◦ ブラウザではfsを解決できない •
ブラウザで何もできないわけではない ◦ 文字列かSource・Sinkを渡せば読み書きできる • ブラウザはAPIの設計を決めたときに対象外として割り切った ※ ブラウザで確かめた結果はAppendixに 27 -
ソースコードを変えずにNativeの6ターゲットを足せた ターゲット v1 v2 JVM ◦ ◦ JS 文字列のパースだけ ◦
Node.jsではファイルIOも Native × macOS・iOS・Linux・Windows Wasm × wasmWasi・wasmJS • Nativeはbuild.gradle.ktsに8行足すだけで済んだ 28 -
安全で移行しやすいリリース 破壊的な変更なので、実装だけでなくテストや移行の手段も整えた 安全にリリースする 移行しやすくする リスク リスク 作り直したので挙動も性能も変わりうる 確かめるターゲットが増える 公開APIを壊すので利用者が移行で困る やったこと
• ブリッジバージョンで先にAPIだけ移せる • 全ターゲットをCIで確かめる • 警告から移行ガイドへたどれる やったこと • プロパティテストで不具合を探す • 性能をv1と比べて測る 31 -
全ターゲットでテストするためフレームワークを替えた v1 v2 テストの実行 kotest-runner-junit5 kotlin-test アサーション kotest-assertions kotest-assertions プロパティテスト
なし kotest-property 確かめたターゲット JVMだけ 全ターゲット • アサーションは表現力が豊かで好きなkotestを続けた • kotest-assertionsとkotest-propertyはKMPに対応している 32 -
ホストごとのCIで全ターゲットを確かめる ターゲット 動かす場所 確かめ方 JVM・Node.js・linuxX64 Ubuntu x64 テスト実行 linuxArm64・wasmWasi Ubuntu
x64 コンパイルだけ macosArm64・iOSシミュレーター macOS arm64 テスト実行 iosArm64 macOS arm64 コンパイルだけ mingwX64 Windows x64 テスト実行 Nativeのテストは同じOSのホストでしか動かせないので、 いくつかのターゲットはコンパイルのみに留めている 33 -
副作用を持たないコアは性質で確かめられる 規則に沿ってランダムに作った入力で、性質が崩れないかを確かめる 入力を作る カンマ 引用符 改行を 多めに混ぜて500通り コアロジックを通す → writeAllで書いて
readAllで読む → 性質を確かめる 書いて読むと 元に戻るか • 副作用がないのでファイルもモックも要らず、文字列だけで書ける • commonTestに書けばテストを実行するターゲットでそのまま動く ◦ 特定ターゲットでのエッジな挙動が起きるリスクを小さくする 34 -
プロパティテストが書き込みの不具合を見つけた バックスラッシュでエスケープする設定で、値 a\b を書いて読む 書いた結果 期待 読んだ結果 a\b に戻る 修正前
a\\b a\\b のまま 修正後 "a\\b" a\b に戻る • 読み込みは引用符の中のエスケープだけを解釈していた • エスケープ文字を含む値は引用符で囲むように直した 35 -
見つかった性能劣化を公開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 -
ブリッジバージョン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 -
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 -
ふりかえり 良かったこと • 公開APIの切り分けを悩み抜いて、良い設計に着地できた ◦ kotlinx.serializationなど既存の変換ライブラリに近い形に • 設計に集中し実装はエージェントに任せた ◦ PBTを利用することで品質を保ってリリースできた
• kotlinlangのSlackで、うまく移行できたという反応をもらえた 反省 • リリースまで約1年4ヶ月かかってしまった 42 -
まとめ プラットフォーム固有の仕様を共通の公開APIに入れると何が苦しいか • 実装を各ターゲットのactualに頼るので、同じ機能をそろえにくい • 破壊的変更を前提とした設計の見直しが必要な場合も 公開APIの設計をどのように決めるか • 副作用、全ターゲットで実装できるか、依存の3つで置き場所を決める •
価値はコアに置き、付随するものは外から足す • stdlibとkotlinxだけで書くほどKMPのターゲットを足しやすくなる 作り直しを安全で移行しやすいリリースにするにはどうするか • ホストごとCI、プロパティテスト、旧版との性能比較で安全にする • ブリッジバージョンと移行ガイドを作って移行しやすくする 43 -
同じ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 -
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 -
ブラウザでも文字列と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 -
プロパティテストを使って想定外の挙動を探る 書き込みと読み込みの食い違いやパーサーの挙動を確かめるため 書いて読む往復 読み込み 書き込みと読み込みが食い違わないか どんな入力でも決めたとおりに振る舞うか • 書いて読むと元に戻る • 決めた例外しか出ない
• 読んで書き直しても同じ文字列 • 列数の違う行を設定どおり扱う • 引用符なしのエスケープも読める • 空行だけを飛ばす • 重複した見出しを一意に改名する 49 -
書いて読むと元に戻るかを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 -
500通りの入力はCSVで意味を持つ文字に寄せて作る 文字を一様に選ぶと、区切りや引用符はほとんど出てこない 1文字ずつ選ぶ 値にする 7割 3割 CSVで意味を持つ文字 全部の文字 → 0〜16文字
設定を選ぶ → カンマ タブ 引用符 \ 改行など13文字 標準のCSV タブ区切り \でエスケープ 性質が崩れたら、kotestが入力を最小の例まで縮めて見せる わざと「カンマを含む値なら失敗」にすると、入力は「,」まで縮んだ 51 -
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