Sprint implementasi mengeksekusi rancangan pengelola catatan harian dalam empat langkah yang masing-masing menghasilkan program utuh dan dapat dijalankan. Langkah pertama membangun model data beserta konversi CSV, langkah kedua menyimpan datanya ke berkas, langkah ketiga menambahkan pencarian dan laporan bulanan, sedangkan langkah keempat memindahkan seluruh operasi berkas ke atas coroutine. Seluruh keluaran pada topik ini berasal dari eksekusi nyata di sandbox jalur ini dengan data tetap yang sama, sehingga setiap hasil dapat direproduksi.
Langkah pertama: model data dan konversi CSV#
Model data dinyatakan sebagai data class Catatan dengan empat properti, yaitu id, tanggal, judul, dan isi. Tanggal bertipe LocalDate dari pustaka Java, sehingga penguraian dan pemformatannya tidak ditulis sendiri. Konversi ke CSV berbentuk fungsi extension keBarisCsv(), sedangkan konversi sebaliknya berupa fungsi dariBarisCsv() yang mengembalikan Catatan?, karena baris masukan belum tentu sah. Penguraian menggunakan split dengan argumen limit = 4, toIntOrNull, dan runCatching di atas LocalDate.parse, sehingga baris rusak menghasilkan null dan bukan error yang menghentikan program.
import java.time.LocalDate
data class Catatan(
val id: Int,
val tanggal: LocalDate,
val judul: String,
val isi: String,
)
// mengubah satu objek Catatan menjadi satu baris CSV
fun Catatan.keBarisCsv(): String = "$id,$tanggal,$judul,$isi"
// mengubah satu baris CSV menjadi objek Catatan, null bila formatnya rusak
fun dariBarisCsv(baris: String): Catatan? {
val bagian = baris.split(",", limit = 4)
if (bagian.size != 4) return null
val id = bagian[0].toIntOrNull() ?: return null
val tanggal = runCatching { LocalDate.parse(bagian[1]) }.getOrNull() ?: return null
return Catatan(id, tanggal, bagian[2], bagian[3])
}
fun main() {
val catatan = Catatan(1, LocalDate.parse("2026-01-05"), "Mulai proyek", "Merancang struktur pengelola catatan")
println(catatan.keBarisCsv())
println(dariBarisCsv("2,2026-01-20,Selesai perancangan,Model data dan format CSV disepakati"))
println(dariBarisCsv("baris-rusak"))
}# compiler Kotlin yang dipakai jalur ini
export PATH=/home/kal/my-project/tools/kotlinc/bin:/home/kal/my-project/tools/jdk-merged/bin:$PATH
export STDLIB=/home/kal/my-project/tools/kotlinc/lib/kotlin-stdlib.jar
# kompilasi model.kt, lalu jalankan kelas ModelKt
kotlinc model.kt -d out.jar && java -cp "out.jar:$STDLIB" ModelKt1,2026-01-05,Mulai proyek,Merancang struktur pengelola catatan
Catatan(id=2, tanggal=2026-01-20, judul=Selesai perancangan, isi=Model data dan format CSV disepakati)
nullBaris pertama adalah hasil keBarisCsv() yang menyusun keempat properti dengan pemisah koma. Baris kedua menampilkan toString() bawaan data class yang memuat seluruh propertinya, dan baris ketiga membuktikan baris masukan tanpa empat kolom dikembalikan sebagai null tanpa pesan error. Konversi dua arah ini menjadi satu-satunya tempat format CSV dikenal, sehingga modul lain tidak perlu mengetahui detail pemisah kolomnya.
Langkah kedua: penyimpanan ke berkas#
Kelas PenyimpananCatatan membungkus java.io.File dan menyediakan dua operasi, yaitu simpan() untuk menulis seluruh collection catatan dan baca() untuk memuatnya kembali. Penulisan menggunakan writeText() dengan joinToString() sehingga satu objek menjadi satu baris, sedangkan pembacaan menggunakan readLines() dan mapNotNull() sehingga baris yang gagal diuraikan terabaikan. Pemanggilan parentFile?.mkdirs() memastikan direktori induk sudah ada sebelum berkas ditulis.
Berkas catatan.csv dibuat oleh program di folder kerja /tmp/kerja-sprint-implementasi, bukan di folder topik ini. Folder tersebut adalah folder kerja sandbox jalur ini, dan jalurnya disesuaikan bila program dijalankan di lingkungan lain.
import java.io.File
import java.time.LocalDate
data class Catatan(
val id: Int,
val tanggal: LocalDate,
val judul: String,
val isi: String,
)
fun Catatan.keBarisCsv(): String = "$id,$tanggal,$judul,$isi"
fun dariBarisCsv(baris: String): Catatan? {
val bagian = baris.split(",", limit = 4)
if (bagian.size != 4) return null
val id = bagian[0].toIntOrNull() ?: return null
val tanggal = runCatching { LocalDate.parse(bagian[1]) }.getOrNull() ?: return null
return Catatan(id, tanggal, bagian[2], bagian[3])
}
// penyimpanan catatan pada satu berkas CSV di folder kerja
class PenyimpananCatatan(private val berkas: File) {
fun simpan(catatan: List<Catatan>) {
berkas.parentFile?.mkdirs()
berkas.writeText(catatan.joinToString("\n") { it.keBarisCsv() })
}
fun baca(): List<Catatan> =
if (berkas.exists()) berkas.readLines().mapNotNull { dariBarisCsv(it) } else emptyList()
}
fun main() {
val penyimpanan = PenyimpananCatatan(File("/tmp/kerja-sprint-implementasi/catatan.csv"))
penyimpanan.simpan(
listOf(
Catatan(1, LocalDate.parse("2026-01-05"), "Mulai proyek", "Merancang struktur pengelola catatan"),
Catatan(2, LocalDate.parse("2026-01-20"), "Selesai perancangan", "Model data dan format CSV disepakati"),
Catatan(3, LocalDate.parse("2026-02-11"), "Laporan pertama", "Laporan bulanan berhasil dibuat"),
),
)
println("Berkas tersimpan: ${penyimpanan.baca().size} catatan")
penyimpanan.baca().forEach { baris -> println(baris.keBarisCsv()) }
}# kompilasi penyimpanan.kt, lalu jalankan kelas PenyimpananKt
kotlinc penyimpanan.kt -d out.jar && java -cp "out.jar:$STDLIB" PenyimpananKtBerkas tersimpan: 3 catatan
1,2026-01-05,Mulai proyek,Merancang struktur pengelola catatan
2,2026-01-20,Selesai perancangan,Model data dan format CSV disepakati
3,2026-02-11,Laporan pertama,Laporan bulanan berhasil dibuatIsi berkas di folder kerja kini persis sama dengan contoh berikut, sehingga data yang sama dapat dipakai oleh langkah berikutnya. Keluaran Berkas tersimpan: 3 catatan membuktikan putaran penuh tulis dan baca berhasil dengan jumlah yang konsisten. Apabila berkas belum ada saat baca() dipanggil, fungsi tersebut mengembalikan collection kosong alih-alih melempar error, sehingga program tetap berjalan pada eksekusi pertama tanpa penanganan khusus.
1,2026-01-05,Mulai proyek,Merancang struktur pengelola catatan
2,2026-01-20,Selesai perancangan,Model data dan format CSV disepakati
3,2026-02-11,Laporan pertama,Laporan bulanan berhasil dibuatLangkah ketiga: pencarian dan laporan bulanan#
Dua fungsi murni menambahkan kemampuan analisis. Fungsi cari() menyaring collection dengan filter dan contains berargumen ignoreCase = true pada judul dan isi, sehingga huruf besar-kecil tidak memengaruhi hasil. Fungsi laporanBulanan() mengelompokkan catatan dengan groupBy atas hasil format yyyy-MM, menghitung jumlah tiap kelompok, lalu menata kuncinya dengan toSortedMap() agar urutannya kronologis. Pada struktur Gradle, keduanya menempati PencarianCatatan.kt dan LaporanBulanan.kt sesuai rancangan, sedangkan demo sandbox menyatukannya dalam satu berkas laporan.kt.
Data pada langkah ini diuraikan dari baris CSV yang ditulis langsung di dalam program, karena lapisan penyimpanan sudah dibuktikan pada langkah kedua. Dengan pemilihan semacam ini, keluaran langkah ini murni menunjukkan perilaku kedua fungsi analisisnya tanpa tergantung pada keberadaan berkas.
import java.time.LocalDate
import java.time.format.DateTimeFormatter
data class Catatan(
val id: Int,
val tanggal: LocalDate,
val judul: String,
val isi: String,
)
fun dariBarisCsv(baris: String): Catatan? {
val bagian = baris.split(",", limit = 4)
if (bagian.size != 4) return null
val id = bagian[0].toIntOrNull() ?: return null
val tanggal = runCatching { LocalDate.parse(bagian[1]) }.getOrNull() ?: return null
return Catatan(id, tanggal, bagian[2], bagian[3])
}
// pencarian kata kunci pada judul dan isi, tanpa membedakan huruf besar-kecil
fun cari(catatan: List<Catatan>, kataKunci: String): List<Catatan> =
catatan.filter { kandidat ->
kandidat.judul.contains(kataKunci, ignoreCase = true) ||
kandidat.isi.contains(kataKunci, ignoreCase = true)
}
// jumlah catatan per bulan, diurutkan berdasarkan tahun dan bulannya
val formatBulan = DateTimeFormatter.ofPattern("yyyy-MM")
fun laporanBulanan(catatan: List<Catatan>): Map<String, Int> =
catatan.groupBy { it.tanggal.format(formatBulan) }
.mapValues { (_, grup) -> grup.size }
.toSortedMap()
fun main() {
val barisCsv = listOf(
"1,2026-01-05,Mulai proyek,Merancang struktur pengelola catatan",
"2,2026-01-20,Selesai perancangan,Model data dan format CSV disepakati",
"3,2026-02-11,Laporan pertama,Laporan bulanan berhasil dibuat",
)
val semua = barisCsv.mapNotNull { dariBarisCsv(it) }
println("Pencarian \"perancangan\":")
cari(semua, "perancangan").forEach { catatan -> println("- ${catatan.id}: ${catatan.judul}") }
println("Laporan bulanan:")
laporanBulanan(semua).forEach { (bulan, jumlah) -> println("$bulan: $jumlah catatan") }
}# kompilasi laporan.kt, lalu jalankan kelas LaporanKt
kotlinc laporan.kt -d out.jar && java -cp "out.jar:$STDLIB" LaporanKtPencarian "perancangan":
- 2: Selesai perancangan
Laporan bulanan:
2026-01: 2 catatan
2026-02: 1 catatanPencarian menemukan satu catatan yang judulnya memuat kata yang dicari, sedangkan laporan menampilkan dua catatan untuk Januari 2026 dan satu catatan untuk Februari 2026. Kedua fungsi ini tidak menyentuh berkas sama sekali, sehingga keduanya dapat diuji hanya dengan collection di memori. Susunan ini menegaskan batas rancangan, yaitu analisis berlangsung di atas collection yang sudah dimuat, dan berkas hanya disentuh pada lapisan penyimpanan.
Langkah keempat: operasi berkas di atas coroutine#
Langkah terakhir menuntaskan desain coroutines-first dari rancangan. Tiga operasi berkas dinyatakan sebagai fungsi suspend yang membungkus pekerjaannya dengan withContext(Dispatchers.IO), yaitu simpanCatatan(), muatCatatan(), dan tulisArsip(). Nama fungsi dan parameternya disusun agar pemanggil tidak perlu mengetahui bahwa pekerjaannya berpindah thread. Fungsi main() menggunakan runBlocking sebagai jembatan antara dunia sinkron dan coroutine. Penulisan arsip dimulai dengan async sebelum laporan dicetak, dan arsip.await() mengembalikan hasilnya ketika berkas selesai ditulis.
import kotlinx.coroutines.*
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
data class Catatan(
val id: Int,
val tanggal: LocalDate,
val judul: String,
val isi: String,
)
fun Catatan.keBarisCsv(): String = "$id,$tanggal,$judul,$isi"
fun dariBarisCsv(baris: String): Catatan? {
val bagian = baris.split(",", limit = 4)
if (bagian.size != 4) return null
val id = bagian[0].toIntOrNull() ?: return null
val tanggal = runCatching { LocalDate.parse(bagian[1]) }.getOrNull() ?: return null
return Catatan(id, tanggal, bagian[2], bagian[3])
}
val formatBulan = DateTimeFormatter.ofPattern("yyyy-MM")
fun dataContoh(): List<Catatan> = listOf(
Catatan(1, LocalDate.parse("2026-01-05"), "Mulai proyek", "Merancang struktur pengelola catatan"),
Catatan(2, LocalDate.parse("2026-01-20"), "Selesai perancangan", "Model data dan format CSV disepakati"),
Catatan(3, LocalDate.parse("2026-02-11"), "Laporan pertama", "Laporan bulanan berhasil dibuat"),
)
fun laporanBulanan(catatan: List<Catatan>): Map<String, Int> =
catatan.groupBy { it.tanggal.format(formatBulan) }
.mapValues { (_, grup) -> grup.size }
.toSortedMap()
// seluruh operasi berkas berada di balik fungsi suspend dengan Dispatchers.IO
suspend fun simpanCatatan(berkas: File, catatan: List<Catatan>) = withContext(Dispatchers.IO) {
berkas.parentFile?.mkdirs()
berkas.writeText(catatan.joinToString("\n") { it.keBarisCsv() })
}
suspend fun muatCatatan(berkas: File): List<Catatan> = withContext(Dispatchers.IO) {
if (berkas.exists()) berkas.readLines().mapNotNull { dariBarisCsv(it) } else emptyList()
}
suspend fun tulisArsip(berkas: File, isi: String): String = withContext(Dispatchers.IO) {
berkas.parentFile?.mkdirs()
berkas.writeText(isi)
"arsip ${berkas.name} tersimpan, ${isi.length} karakter"
}
fun main() = runBlocking {
val berkasCatatan = File("/tmp/kerja-sprint-implementasi/catatan.csv")
if (!berkasCatatan.exists()) {
simpanCatatan(berkasCatatan, dataContoh())
}
val catatan = muatCatatan(berkasCatatan)
val ringkasan = laporanBulanan(catatan).map { (bulan, jumlah) -> "$bulan: $jumlah catatan" }
val arsip = async {
tulisArsip(File("/tmp/kerja-sprint-implementasi/arsip-laporan.txt"), ringkasan.joinToString("\n"))
}
println("Laporan bulanan:")
ringkasan.forEach { baris -> println(baris) }
println(arsip.await())
println("Total catatan dimuat: ${catatan.size}")
}# compiler Kotlin dan library coroutines yang dipakai jalur ini
export PATH=/home/kal/my-project/tools/kotlinc/bin:/home/kal/my-project/tools/jdk-merged/bin:$PATH
export LIBS=/home/kal/my-project/tools/libs
export STDLIB=/home/kal/my-project/tools/kotlinc/lib/kotlin-stdlib.jar
# kompilasi coroutine.kt dengan library coroutines pada classpath, lalu jalankan kelas CoroutineKt
kotlinc coroutine.kt -cp "$LIBS/kotlinx-coroutines-core-jvm-1.11.0.jar" -d out.jar && java -cp "out.jar:$LIBS/kotlinx-coroutines-core-jvm-1.11.0.jar:$STDLIB" CoroutineKtLaporan bulanan:
2026-01: 2 catatan
2026-02: 1 catatan
arsip arsip-laporan.txt tersimpan, 37 karakter
Total catatan dimuat: 3Keluaran menunjukkan laporan tercetak sebelum pesan arsip, padahal penulisan arsip sudah dimulai lebih dahulu oleh async. Urutan ini deterministik karena main() menentukan kapan setiap hasil dicetak, bukan waktu selesainya pekerjaan berkas. Berkas arsip hasil eksekusi berada di /tmp/kerja-sprint-implementasi/arsip-laporan.txt dengan isi yang sama dengan dua baris laporan pada keluaran, dan penulisannya berlangsung pada pool IO sehingga pencetakan laporan tidak perlu menunggu. Model data dan laporan bulanan pada langkah ini sama persis dengan langkah ketiga, hanya lapisan berkasnya yang menjadi fungsi suspend, sesuai batas yang dirancang.
Kesalahan umum#
- Baris CSV dipecah dengan
split(",")tanpa argumenlimit. Penyebabnya, isi catatan yang memuat tanda koma ikut terpecah sehingga jumlah kolom bergeser. Perbaikannya, gunakansplit(",", limit = 4)dan pertahankan kesepakatan bahwa tiga kolom pertama bebas tanda koma. - Bulan disusun dari
monthValuetanpa pengisi nol. Penyebabnya, teks2026-1dan2026-10tidak terurut dengan benar sebagai string. Perbaikannya, format tanggal denganDateTimeFormatter.ofPattern("yyyy-MM")sebelum dikelompokkan, karena nilai yang dihasilkannya selalu dua digit untuk bagian bulan. - Operasi berkas ditulis langsung di dalam fungsi utama. Penyebabnya, panggilan I/O tersebar di banyak tempat sehingga tidak ada satu batas yang jelas untuk konkurensi. Perbaikannya, kumpulkan seluruh akses berkas pada fungsi suspend satu lapisan, sebagaimana pola pada langkah keempat.
- Tanggal diuraikan dengan
LocalDate.parse()tanpa pengaman. Penyebabnya, teks tanggal yang tidak sesuai format melemparDateTimeParseExceptiondan menghentikan program. Perbaikannya, bungkus penguraian denganrunCatchingdan kembalikan null, sebagaimana pola padadariBarisCsv(). Pengaman serupa tidak diperlukan untuk kolom id karenatoIntOrNullsudah mengembalikan null pada teks yang bukan angka.
Ringkasan#
- Model data berupa data class
Catatandengan konversi CSV dua arah yang mengembalikan null untuk baris rusak. PenyimpananCatatanmenyatukan penulisan dan pembacaan berkas CSV di folder kerja melalui kelas kecil dengan dua fungsi.- Pencarian menyaring collection dengan kata kunci tanpa membedakan huruf besar-kecil, sedangkan laporan bulanan menghitung catatan per bulan dan menjaga urutan kronologisnya.
- Versi coroutine membungkus seluruh operasi berkas dengan
withContext(Dispatchers.IO)tanpa mengubah logika datanya.
Referensi#
- Coroutines guide — JetBrains — Panduan resmi coroutines yang mendasari langkah keempat, dari runBlocking sampai Dispatchers.IO.
- Data classes — Kotlin Documentation — Dokumentasi data class yang dipakai sebagai model data proyek.
- Class LocalDate — Oracle — API tanggal yang dipakai untuk penguraian, pemformatan, dan pengelompokan bulan.