משחקים עם JSON בסקאלה

23/11/2023

יש משהו חמוד כשספריית תוכנה מתאמצת כדי שדברים יעבדו בצורה אינטואיטיבית אבל יש בזה גם סכנה - והיא שהצעד האינטואיטיבי הבא כבר לא יעבוד. זה הסיפור עם ספריית circe של סקאלה, ספריה לעבודה עם JSON-ים.

1. איך כותבים JSON בסקאלה עם circe

נתחיל בתוכנית פשוטה שמגדירה case class עם שדה name ושדה price, וכותבת אותו למסך בתור JSON. עם circe הקוד נראה ככה:

import io.circe.*
import io.circe.generic.auto.*
import io.circe.parser.*
import io.circe.syntax.*

case class Game(name: String, price: Int)

def makeItem(): Game =
  Game(name = "Super Mario", price = 20)


@main
def main(): Unit = {
  val item = makeItem()
  println(item.asJson.toString)
}

2. איך קוראים JSON בסקאלה עם circe

גם הקריאה מ JSON בחזרה ל case class עובדת בלי בעיה ובצורה מובנית בעזרת אותו קסם שעבד לנו בכתיבה. הקוד הזה ב main גם יקרא את ה json חזרה וידפיס את האוביקט שפוענח ממנו:

def main(): Unit = {
  val item = makeItem()
  val stringified = item.asJson.toString

  val restored = decode[Game](stringified)
  println(restored)
}

3. מה קורה כשצריך לכתוב משהו מטיפוס לא ידוע

היכולת המלהיבה האחרונה של circe וזו שהייתי שמח לוותר עליה קשורה לעבודה עם case class-ים מסוגים שונים. בואו נרחיב את התוכנית כדי לתמוך בעוד כמה מוצרים:

trait Item {
  val name: String
  val price: Int
}
case class Game(name: String, price: Int) extends Item
case class Book(name: String, ISBN: String, price: Int) extends Item

def makeItem(): Item =
  if (Random.nextInt(10) >= 5) {
    Game(name = "Super Mario", price = 20)
  } else {
    Book(name = "Pragmatic Scala: Create Expressive, Concise, and Scalable Applications",
      price = 50,
      ISBN = "1680500546")
  }

ניסיון לקמפל את אותה תוכנית main ייכשל עם הודעת השגיאה:

No given instance of type io.circe.Encoder[Item] was found for parameter encoder of method asJson in class EncoderOps.

הסיפור הוא ש circe לא הצליח לייצר אוטומטית את ה Encoder בגלל ש Item הוא trait פתוח, ואולי אין לנו עדיין את הגישה לכל המחלקות שיורשות אותו. התיקון הקל הוא להפוך את Item ל sealed trait ואז התוכנית חוזרת להתקמפל.

sealed trait Item {
  val name: String
  val price: Int
}

בשביל שהקוד גם יעבוד עלינו לסדר עוד פרט קטן ב main - זיכרו שהפעלנו decode עם העברה מפורשת של שם המחלקה כדי לקרוא את ה JSON חזרה ל case class. בגלל שאנחנו עובדים עם sealed trait צריך לשנות את שורת הפיענוח וקוד ה main אחרי התיקון הוא:

@main
def main(): Unit = {
  val item = makeItem()
  val stringified = item.asJson.toString

  val restored = decode[Item](stringified)
  println(restored)
}

4. איפה זה נשבר

התיעוד של circe עוצר פה כי באמת עבודת הזיהוי האוטומטי של שדות ב case class-ים היתה מרשימה. אבל בואו נעצור רגע כדי לשים לב מה בדיוק קרה כאן, איך ה decode הצליח לזהות לאיזה case class ה JSON צריך להפוך? לפי מה הוא יודע להחליט אם זה Game או Book ?

התשובה מתחבאת במבנה ה JSON. אני מוסיף הודעת הדפסה ל stringified ומקבל שה JSON הוא:

{
  "Book" : {
    "name" : "Pragmatic Scala: Create Expressive, Concise, and Scalable Applications",
    "ISBN" : "1680500546",
    "price" : 50
  }
}

ובעצם במקום לכתוב רק את השדות circe הוסיף עוד רמה לאוביקט שהמפתח שלה הוא שם ה case class ממנו יצרנו את ה JSON. בצורה כזאת הוא יכול גם לקרוא את האוביקט ולדעת באיזה קלאס להשתמש.

זה די מדליק עד שמנסים להוסיף עוד רמה בירושה. למשל מה אם כל Item בעצם מרחיב trait אחר? משהו כזה:

import io.circe.*
import io.circe.generic.auto.*
import io.circe.parser.*
import io.circe.syntax.*
import scala.util.Random

sealed trait Serializable

sealed trait Item extends Serializable {
  val name: String
  val price: Int
}

case class Game(name: String, price: Int) extends Item
case class Book(name: String, ISBN: String, price: Int) extends Item

def makeItem(): Serializable =
  if (Random.nextInt(10) >= 5) {
    Game(name = "Super Mario", price = 20)
  } else {
    Book(name = "Pragmatic Scala: Create Expressive, Concise, and Scalable Applications",
      price = 50,
      ISBN = "1680500546")
  }



@main
def main(): Unit = {
  val item = makeItem()
  val stringified = item.asJson.toString
  println(stringified)
  val restored = decode[Serializable](stringified)
  println(restored)
}

הפעם ה JSON שאנחנו מקבלים מקבל עוד רמה ונראה כך:

{
  "Item" : {
    "Game" : {
      "name" : "Super Mario",
      "price" : 20
    }
  }
}

אבל הניסיון לפענח אותו נכשל עם ההודעה:

Left(DecodingFailure at .Item: type Serializable has no class/object/case named 'Item'.)

ונכון זה לא סוף העולם, מי בכלל צריך להרחיב trait עם trait אחר ואפשר תמיד לכתוב לבד Decoder-ים ו Encoder-ים. אבל זה לא הסיפור פה. אולי צריך להגיד לכותבי ספריות - הקסמים שלכם הם אחלה כשהם עובדים עד הסוף. חצי קסם רק עושה לי יותר עבודה.