JSON dengan circe

Encoder dan decoder yang diderivasi otomatis untuk case class.

Daftar isi

JSON adalah format pertukaran data yang paling umum di antara layanan web, dan circe adalah pustaka JSON untuk Scala yang menempatkan kemampuan menyandikan dan membaca JSON pada type class Encoder dan Decoder. Topik ini membahas cara memperoleh kedua instance itu secara otomatis dari struktur case class melalui derivasi (derivation) milik circe-generic. Pembahasan juga mencakup penyandian nilai menjadi string JSON dengan asJson, pembacaan kembali dengan parser.decode yang menghasilkan Either, pencetakan JSON secara rapi dengan spaces2, serta pembacaan satu bidang tertentu melalui cursor. Setiap contoh dijalankan dengan scala-cli dan tiga artefak circe yang versinya dipatok pada 0.14.13.

Derivasi otomatis dan penyandian#

Derivasi otomatis menyusun instance Encoder dan Decoder dari struktur case class, sehingga tidak ada kode penyandian yang ditulis secara manual. Import io.circe.generic.auto.* menyediakan instance tersebut untuk setiap case class yang muncul pada ekspresi, termasuk case class yang berada di dalam List atau Option. Metode asJson, yang berasal dari io.circe.syntax.*, mengubah nilai menjadi objek Json, lalu noSpaces merendernya menjadi string tanpa spasi. Program berikut menyandikan sebuah Rak berisi dua Buku, mencetak JSON-nya, lalu membacanya kembali menjadi case class.

scala kirim.scala
//> using dep io.circe::circe-core:0.14.13
//> using dep io.circe::circe-generic:0.14.13
//> using dep io.circe::circe-parser:0.14.13

// Encoder dan Decoder untuk case class diderivasi otomatis oleh circe-generic
import io.circe.generic.auto.*
import io.circe.parser
import io.circe.syntax.*

case class Buku(judul: String, tahun: Int)
case class Rak(label: String, buku: List[Buku])

@main def kirim(): Unit =
  val rak = Rak("rak-utama", List(Buku("Pengantar Scala", 2023), Buku("Struktur Data", 2021)))

  // Menyandikan nilai case class menjadi string JSON tanpa spasi
  val json: String = rak.asJson.noSpaces
  println(json)

  // Membaca string JSON kembali menjadi case class, hasilnya Either
  val kembali: Either[io.circe.Error, Rak] = parser.decode[Rak](json)
  kembali match
    case Right(hasil) => println(s"Diterima: ${hasil.label}, ${hasil.buku.length} buku")
    case Left(error)  => println(s"Decode error: $error")
bash
# menjalankan program kirim.scala
scala-cli run kirim.scala
text Keluaran
{"label":"rak-utama","buku":[{"judul":"Pengantar Scala","tahun":2023},{"judul":"Struktur Data","tahun":2021}]}
Diterima: rak-utama, 2 buku

Keluaran pertama menunjukkan urutan bidang JSON mengikuti urutan deklarasi bidang pada case class, yaitu label lebih dahulu dan buku kemudian. Pemanggilan parser.decode[Rak](json) mengembalikan Either[io.circe.Error, Rak], di mana io.circe.Error adalah tipe induk untuk dua jenis kegagalan, yaitu ParsingFailure pada tingkat teks dan DecodingFailure pada tingkat struktur. Kedua sisi Either ditangani dengan pencocokan pola, sehingga keberhasilan pembacaan terlihat dari Right. Nilai hasil decode sama sekali tidak berbagi struktur dengan string JSON-nya, melainkan berupa objek case class yang siap digunakan seperti objek Scala biasa.

Pencetakan rapi dan kursor#

Pembacaan JSON dari teks dimulai dengan parser.parse, yang menghasilkan Either[ParsingFailure, Json]. Objek Json adalah representasi pohon dari data, dan printer spaces2 merendernya dengan indentasi dua spasi. Untuk membaca sebagian isi, json.hcursor menghasilkan cursor, yaitu penunjuk posisi pada pohon JSON, yang dapat berpindah dengan downField("nama") menuju satu bidang objek atau downArray menuju elemen pertama sebuah array. Metode as[T] pada cursor mengubah nilai pada posisi tersebut menjadi tipe T dan menghasilkan Either[DecodingFailure, T], sehingga pembacaan bidang tidak pernah melempar exception.

scala rapi.scala
//> using dep io.circe::circe-core:0.14.13
//> using dep io.circe::circe-parser:0.14.13

// spaces2 mencetak JSON rapi, hcursor membaca bidang tertentu
import io.circe.Json
import io.circe.parser

@main def rapi(): Unit =
  val teks = """{"sensor":"suhu-ruang","bacaan":[24,25,23]}"""

  parser.parse(teks) match
    case Right(json) =>
      println(json.spaces2)

      val kursor = json.hcursor
      val nama = kursor.downField("sensor").as[String]
      val bacaan = kursor.downField("bacaan").as[List[Int]]

      println(s"Sensor: ${nama.getOrElse("(tidak terbaca)")}")
      println(s"Jumlah bacaan: ${bacaan.getOrElse(Nil).length}")

    case Left(parsingError) =>
      println(s"Parse error: ${parsingError.message}")
bash
# menjalankan program rapi.scala
scala-cli run rapi.scala
text Keluaran
{
  "sensor" : "suhu-ruang",
  "bacaan" : [
    24,
    25,
    23
  ]
}
Sensor: suhu-ruang
Jumlah bacaan: 3

Keluaran menunjukkan gaya printer spaces2, yaitu titik dua ditulis dengan spasi di kedua sisinya dan array dibuka pada baris tersendiri. Pada cursor, downField("sensor").as[String] menghasilkan Right("suhu-ruang"), sedangkan downField("bacaan").as[List[Int]] berhasil karena setiap elemen array memang angka. Metode getOrElse pada Either menyediakan nilai pengganti apabila hasilnya Left, sehingga program tetap berjalan dengan nilai default alih-alih berhenti.

Ketika JSON tidak cocok#

Kegagalan decode tidak berbentuk exception, melainkan Left yang berisi DecodingFailure beserta lokasi bidangnya. Dua kegagalan yang paling sering terjadi adalah tipe yang tidak cocok dan bidang yang hilang. String JSON berikut memiliki umur bertipe teks padahal case class meminta Int, dan string kedua sama sekali tidak memiliki bidang umur. Program berikut mencoba keduanya dan mencetak hasil decode mentahnya.

scala tidak-cocok.scala
//> using dep io.circe::circe-core:0.14.13
//> using dep io.circe::circe-generic:0.14.13
//> using dep io.circe::circe-parser:0.14.13

// JSON yang tidak cocok dengan struktur case class menghasilkan Left
import io.circe.generic.auto.*
import io.circe.parser

case class Siswa(nama: String, umur: Int)

@main def tidakCocok(): Unit =
  val salahTipe = """{"nama":"Budi","umur":"tujuh belas"}"""
  val kurangBidang = """{"nama":"Budi"}"""

  List(salahTipe, kurangBidang).foreach { teks =>
    parser.decode[Siswa](teks) match
      case Right(siswa) => println(s"Diterima: ${siswa.nama}, umur ${siswa.umur}")
      case Left(error)  => println(s"Decode error: $error")
  }
bash
# menjalankan program tidak-cocok.scala
scala-cli run tidak-cocok.scala
text Keluaran
Decode error: DecodingFailure at .umur: Int
Decode error: DecodingFailure at .umur: Missing required field

Kedua pesan menunjuk jalur yang sama, yaitu .umur, karena kegagalannya berada pada bidang itu. Pesan Int berarti nilai pada bidang tersebut tidak dapat dibaca sebagai angka bertipe Int, sedangkan pesan Missing required field berarti bidang itu tidak ditemukan sama sekali. Bentuk Either memungkinkan pemanggil menentukan sendiri reaksinya, misalnya memberi nilai bawaan, mengumpulkan semua kegagalan, atau meneruskan pesan error ke pemanggil layanannya.

Jalur pada pesan error mengikuti kedalaman struktur data. Ketika case class memuat case class lain, kegagalan pada bidang yang bersarang dilaporkan dengan jalur penuh, misalnya .siswa.umur untuk bidang umur yang berada di dalam objek siswa. Bentuk jalur ini membuat pesan error tetap dapat ditindaklanjuti pada dokumen JSON yang panjang, karena lokasi bidang yang bermasalah disebut dari akar dokumen sampai titik kegagalannya, bukan hanya nama bidangnya.

Kesalahan umum#

  • Artefak circe-generic tidak disertakan pada direktif //> using dep. Kompilasi gagal karena instance Encoder dan Decoder untuk case class tidak dapat disusun. Perbaikannya adalah menambahkan io.circe::circe-generic:0.14.13 di samping circe-core dan circe-parser.
  • Import io.circe.syntax.* tidak disertakan. Metode asJson tidak ditemukan pada case class karena metode itu merupakan metode ekstensi pada paket syntax. Perbaikannya adalah menambahkan import tersebut di bagian atas berkas.
  • Decode diasumsikan melempar exception apabila data salah. Hasil parser.decode memang berupa Either, sehingga cabang Left yang tidak ditangani tidak menyebabkan program berhenti, tetapi membuat nilai hasil hilang tanpa sinyal. Perbaikannya adalah selalu menangani kedua cabang pada pemanggil.

Ringkasan#

  • Derivasi io.circe.generic.auto.* menyusun instance Encoder dan Decoder dari struktur case class, sehingga asJson dan parser.decode dapat digunakan tanpa menulis kode penyandian manual.
  • Penyandian menghasilkan string JSON dengan urutan bidang sesuai deklarasi bidang, sedangkan pembacaan menghasilkan Either[io.circe.Error, T] yang menampung ParsingFailure maupun DecodingFailure.
  • spaces2 mencetak JSON dengan indentasi untuk dibaca manusia, dan hcursor dengan downField membaca bidang tertentu yang hasilnya juga Either.
  • Kegagalan decode berupa Left berisi DecodingFailure yang menunjuk lokasi bidangnya, bukan exception.

Referensi#