Layanan web bertukar data dalam bentuk JSON, dan bentuk pertukaran itu perlu disepakati oleh dua sisi yang saling berkomunikasi. Kontrak serialisasi adalah kesepakatan atas nama properti dan tipe nilai yang boleh muncul dalam pertukaran tersebut. Topik ini membahas case class sebagai model data bersama, derivasi codec circe pada waktu kompilasi, akibat perubahan field terhadap JSON yang sudah ada, serta prinsip berbagi definisi antara server dan klien. Sebuah program kecil mendemonstrasikan keberhasilan dan kegagalan pembacaan JSON secara nyata.
Model data yang sama di dua sisi#
Server mengubah nilai menjadi JSON sebelum mengirimnya, sedangkan klien mengubah JSON kembali menjadi nilai agar dapat diproses. Kedua arah itu andal apabila kedua sisi mengikuti model yang sama. Pada layanan Scala, model tersebut lazim ditulis sebagai case class, misalnya Catatan(id: Int, isi: String) untuk data catatan pendek. Parameter id bertipe Int dan parameter isi bertipe String menjadi bentuk tunggal yang dirujuk oleh penyusun dan pembaca data.
Model yang terpusat mengurangi salah tafsir. Apabila definisi Catatan hanya ada satu, pertanyaan tentang nama properti atau tipe nilai tidak perlu dijawab dua kali dengan versi yang berbeda. Sisi mana pun yang menyusun JSON mengikuti case class yang sama, dan sisi mana pun yang membaca JSON menuntut bentuk yang sama pula. Kesepakatan semacam ini disebut kontrak, dan pada layanan Scala kontrak tersebut tertulis sebagai kode, bukan sebagai dokumen terpisah.
Codec circe dan derivasi#
circe adalah pustaka JSON untuk Scala. Tipe Encoder[A] menulis nilai bertipe A menjadi JSON, Decoder[A] membaca JSON menjadi nilai bertipe A, dan pasangan keduanya lazim disebut codec. circe dapat mendenivasi codec dari struktur case class pada waktu kompilasi, sehingga codec tidak perlu ditulis secara manual dan tidak mungkin menyimpang dari modelnya.
import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto.*
object Catatan:
given Encoder[Catatan] = deriveEncoder
given Decoder[Catatan] = deriveDecoderDerivasi eksplisit seperti di atas cocok untuk kontrak lintas proyek karena codec hanya ada satu dan terletak di dekat modelnya. deriveEncoder mengambil nama parameter case class dan menjadikannya nama properti JSON, sedangkan deriveDecoder menuntut agar setiap properti itu hadir pada JSON yang dibaca. circe juga menyediakan derivasi otomatis melalui io.circe.generic.auto.*, yaitu bentuk ringkas yang lazim digunakan pada program kecil dan contoh topik ini.
Ketika field berubah, kontrak berubah#
Program berikut memerankan dua sisi dengan satu model. Sisi server menyusun JSON dari nilai Catatan, sisi klien membacanya kembali, kemudian dua JSON lain diuji, yaitu JSON dengan nama properti lama dan JSON dengan properti tambahan.
//> using dep io.circe::circe-core:0.14.17
//> using dep io.circe::circe-generic:0.14.17
//> using dep io.circe::circe-parser:0.14.17
import io.circe.generic.auto.*
import io.circe.parser.decode
import io.circe.syntax.*
// model data yang dipakai bersama oleh server dan klien
case class Catatan(id: Int, isi: String)
@main def kontrakCatatan(): Unit =
// sisi server: mengubah nilai menjadi JSON
val json = Catatan(2, "jadwal piket kelas").asJson.noSpaces
println(json)
// sisi klien: membaca JSON dengan model yang sama
println(decode[Catatan](json))
// JSON dengan nama properti lama tidak dapat dibaca
val jsonLama = """{"id":3,"teks":"belajar circe"}"""
println(decode[Catatan](jsonLama))
// properti tambahan yang tidak dikenal diabaikan
val jsonTambahan = """{"id":4,"isi":"catatan baru","urgensi":"rendah"}"""
println(decode[Catatan](jsonTambahan))scala-cli run kontrak-catatan.scala{"id":2,"isi":"jadwal piket kelas"}
Right(Catatan(2,jadwal piket kelas))
Left(DecodingFailure at .isi: Missing required field)
Right(Catatan(4,catatan baru))Baris pertama adalah keluaran sisi server, dan nama propertinya, yaitu id serta isi, berasal dari parameter case class. Baris kedua memperlihatkan sisi klien yang membaca JSON itu dengan model yang sama, sehingga hasilnya Right berisi nilai utuh. Baris ketiga menggunakan JSON dengan properti bernama teks, sisa dari versi lama, dan decoder menolaknya dengan DecodingFailure at .isi: Missing required field karena properti isi tidak ditemukan. Baris keempat memuat properti tambahan urgensi yang tidak dikenal model, dan decoder mengabaikannya, sehingga pembacaan tetap berhasil.
Pesan DecodingFailure pada contoh di atas bersifat deterministik, yaitu menyebut lokasi properti yang hilang beserta sebabnya. Kegagalan semacam ini terjadi pada waktu berjalan, bukan pada waktu kompilasi, karena JSON datang dari luar program. Kontrak yang tertulis pada codec hanya melindungi sisi yang ikut dikompilasi bersama modelnya.
Berbagi definisi antara server dan klien#
Model dan codec berupa kode Scala biasa, sehingga keduanya dapat diletakkan dalam satu berkas atau satu modul yang dikompilasi oleh proyek server dan proyek klien sekaligus. Perubahan tipe pada model langsung terdeteksi compiler di kedua sisi, dan kedua sisi selalu menuruti satu versi kontrak yang sama. Cara berbagi ini menggantikan kesepakatan yang hanya ditulis pada dokumen, yang mudah tertinggal pada saat model berubah.
Kontrak yang sama juga berlaku untuk definisi endpoint. Pada tapir, kombinator jsonBody[Catatan] menggunakan codec circe yang sudah dibahas, sehingga pembacaan permintaan, penyusunan respons, dan pembacaan respons di sisi klien berpijak pada satu definisi. Dengan demikian, urutan perubahan yang aman adalah mengubah model beserta codecnya terlebih dahulu, mengompilasi kedua sisi, lalu baru mengganti bentuk data yang benar-benar dikirim.
Perubahan kontrak yang sudah digunakan banyak pihak memerlukan masa transisi. Cara yang lazim dilakukan adalah meneruskan bidang lama untuk sementara waktu, yaitu decoder tetap membaca nama properti lama dan mengisinya ke bidang baru, sedangkan encoder mulai menulis nama yang baru. Selama masa itu, klien lama tetap dapat membaca respons baru, dan klien yang sudah diperbarui menggunakan bentuk yang terkini. Setelah semua pihak berpindah, penanganan nama lama dicabut dan kontrak resmi kembali menjadi satu.
Ringkasan#
- Kontrak serialisasi dinyatakan oleh case class beserta codec circe-nya, yaitu
EncoderdanDecoderyang diderivasi compiler dari struktur model. - Nama properti JSON mengikuti nama parameter case class, sehingga mengganti nama properti berarti mengganti kontrak.
- JSON dengan nama properti lama gagal dibaca dengan
DecodingFailureyang deterministik, sedangkan properti tambahan yang tidak dikenal diabaikan oleh decoder. - Model dan codec yang sama dapat digunakan oleh server dan klien, termasuk oleh tapir melalui
jsonBody.
Referensi#
- circe — GitHub — Repositori resmi circe, pustaka JSON untuk Scala yang digunakan pada topik ini.
- Codecs — circe
— Dokumentasi
Encoder,Decoder, dan derivasi codec pada circe.