Thao tác với JSON trong Scala với spray-json

Giới Thiệu

  • JSON là định dạng khá quen thuộc với bất cứ developer nào khi phải lập trình với API. Đây là một định dạng khá phổ biến, cấu trúc dễ đọc, gọn nhẹ. Các ngôn ngữ lập trình đều có ít nhiều các thư viện hỗ trợ việc Parse hoặc Generate ra JSON. Trong bài viết này, mình xin giới thiệu một thư viện khá gọn nhẹ để xử lý JSON trong Scala, đó là thư viện spray-json
  • spray-json là một thư viện thuộc Spray Project - bộ toolkit dành cho việc build REST/HTTP integration layer trên nền tảng ScalaAkka (Xem thêm về Spray tại đây)
  • Trong một bài viết trước, chúng ta có phần nào khái niệm về implicit trong Scala (Xem thêm bài viết đó tại đây). Các bạn có thể đọc tham khảo để hiểu sơ về khái niệm implicit trong Scala.

Tính Năng

Ưu điểm

  • spray-json hỗ trợ các chức năng:
    • Kiểm soát các element trong JSON model.
    • Hỗ trợ JSON parser hiệu quả.
    • Có thể chọn cách in định dạng JSON ra string dưới dạng pretty hoặc compact.
    • Có thể serialization - deserialization một object thành kiểu Class.
    • Không cần thêm bất kì dependencies nào.
  • spray-json cho phép convert giữa các định dạng
    • Chuỗi JSON.
    • JSON Abstract Syntax Tree (ASTs) dựa trên JsValue.
    • Một instance theo kiểu dữ liệu trong Scala.

Cài đặt

  • spray-json có thể cài đặt bằng maven.
  • Ngoài ra, nếu dùng SBT để cài đặt bằng cách thêm vào file build.sbt:
libraryDependencies += "io.spray" %%  "spray-json" % "1.3.4"

Sử Dụng

Cơ bản

Import thư viện
  • Để sử dụng thư viện, chúng ta cần import thư viện vào theo cú pháp
import spray.json._
import DefaultJsonProtocol._ #Trường hợp chúng ta không sử dụng Protocol tự định nghĩa
Parse JSON String
  • Chúng ta có thể parse JSON string thành định dạng AST như sau
val source = """{ "some": "JSON source" }"""
val jsonAst = source.parseJson // or JsonParser(source)
Convert 1 Scala Object thành JSON AST
  • Chúng ta sử dụng extension method toJson để convert Scala Object thành JSON AST
val jsonAst = List(1, 2, 3).toJson

Nâng cao

  • Ngoài ra, ta có thể convert 1 Scala Object có type T thành định dạng JSON AST và ngược lại, convert JSON AST thành Scala Object. Để làm điều này, chúng ta cần phải đem giá trị implicit vào trong scope mà nó cung cấp JsonFormat[T] instance cho type T. Ta sẽ làm việc đó thông qua JsonProtocol
  • spray-json sử dụng SJSON để kết nối kiểu dữ liệu type T với các logic để serialize - deserialize một instance thành JSON.
  • DefaultJsonProtocol của spray-json bao gồm các implicit values của JsonFormat[T], mỗi JsonFormat[T] chứa các logic để convert một instance của kiểu dữ liệu type T thành JSON hoặc ngược lại. DefaultJsonProtocol đã cover toàn bộ các kiểu dữ liệu có trong Scala, cho nên đối với các kiểu dữ liệu thông thường, ta có thể dễ dàng convert dữ liệu thành JSON AST với câu lệnh toJson. Các kiểu dữ liệu đã được cover bao gồm:
    • Byte, Short, Int, Long, Float, Double, Char, Unit, Boolean
    • String, Symbol
    • BigInt, BigDecimal
    • Option, Either, Tuple1 - Tuple7
    • List, Array
    • immutable.{Map, Iterable, Seq, IndexedSeq, LinearSeq, Set, Vector}
    • collection.{Iterable, Seq, IndexedSeq, LinearSeq, Set}
    • JsValue
Sử dụng DefaultJsonProtocol
  • Trong trường hợp chúng ta muốn custom lại type T, chúng ta có thể sử dụng DefaultJsonProtocol với JsonFormat[T] bằng cách sau
case class Student(name: String, age: Int, address: String)

object MyJsonProtocol extends DefaultJsonProtocol {
  implicit val studentList = jsonFormat4(Student)
}

import MyJsonProtocol._
import spray.json._

val json = Student("An", 95,"HCM City").toJson
val student = json.convertTo[Student]

Cải Tiến

Tìm hiểu thêm về implicit trong spray-json

Tìm hiểu thêm về JsonWriters
  • Với việc hàm DefaultJsonProtocol (Có ở dạng traitobject) chứa khá nhiều các predefined writers, readers, formats, cũng như là các implicit methods. Check đoạn code dưới đây.
import spray.json._
import spray.json.DefaultJsonProtocol._

case class Person(firstName: String, lastName: String)

implicit val personJsonFormat = new RootJsonFormat[Person] {
  def write(person: Person): JsValue = {
    JsObject(
      "first_name" -> person.firstName.toJson,
      "last_name" -> person.lastName.toJson
    )
  }
  
  def read(value: JsValue): Person = ???
}

val nguyenQuang = Person(firstName = "Nguyen", lastName = "Quang")
val tranTin = Person(firstName = "Tran", lastName = "Tin")
List(nguyenQuang, tranTin).toJson
  • Ở đây, hàm toJson tìm kiếm một implicit JsonWriter[List[Person]] được provide bởi hàm trong spray-json
implicit def listFormat[T :JsonFormat]: RootJsonFormat[List[T]]
  • Ở đây, type T được hiểu là Person, và nội dung của JsonFormat bị ràng buộc bởi implicit JsonFormat[Person] mà ta đã định nghĩa ở trên. Nói cách khác, câu lệnh xuất ra Json cho List[Person] có thể viết phức tạp là
#Phức tạp
List(nguyenQuang, tranTin).toJson(listFormat(personJsonFormat))

#Đơn giản
List(nguyenQuang, tranTin).toJson
  • Thêm nữa, ở đây chúng ta dùng JsonFormat thay cho JsonWriter. Nếu như chúng ta chỉ có nhu cầu xài hàm write (Xuất object ra JSON) thì chúng ta chỉ nên sử dụng JsonWriter, thay vì không implement hàm read hoặc throw exception mặc định. Trong DefaultJsonProtocol của spray-json, phương pháp này được sử dụng khá là phổ biến (Sử dụng JsonFormat, không implement hàm read mà chỉ throw exception) mà bỏ qua việc cung cấp JsonWriter cho list hoặc Collection.

Mở rộng DefaultJsonProtocol

  • Như đã trình bày ở trên, chúng ta có thể chỉ cần dùng JsonWriter cho việc xuất JSON. Chúng ta có thể làm theo sau
import spray.json._

object ExtendedJsonProtocol extends DefaultJsonProtocol {
  implicit def listJsonWriter[T : JsonWriter]: RootJsonWriter[List[T]] = new  RootJsonWriter[List[T]] {
   def write(list: List[T]): JsArray = JsArray(list.map(_.toJson).toVector)
  }
}
import ExtendedJsonProtocol._

case class Person(firstName: String, lastName: String)

implicit val personJsonWriter = new RootJsonWriter[Person] {
  def write(person: Person): JsValue = {
    JsObject(
      "first_name" -> person.firstName.toJson,
      "last_name" -> person.lastName.toJson
    )
  }
}

val nguyenQuang = Person(firstName = "Nguyen", lastName = "Quang")
val tranTin = Person(firstName = "Tran", lastName = "Tin")
List(nguyenQuang, tranTin).toJson
  • Đoạn code trên chạy sẽ cho kết quả giống như kết quả của ví dụ trên sử dụng JsonFormat.
  • Tuy nhiên, trong trường hợp chúng ta muốn mở rộng Protocol của riêng mình có thể hỗ trợ cả việc readwrite cho lớp Person thì phải làm thế nào? Ở đây ta thử thay thế implicit JsonWriter[Person] bằng implicit JsonFormat[Person] theo như dưới đây
import spray.json._

object ExtendedJsonProtocol extends DefaultJsonProtocol {
  implicit def listJsonWriter[T : JsonWriter]: RootJsonWriter[List[T]] = new  RootJsonWriter[List[T]] {
   def write(list: List[T]): JsArray = JsArray(list.map(_.toJson).toVector)
  }
}
import ExtendedJsonProtocol._

case class Person(firstName: String, lastName: String)

implicit val personJsonFormat = jsonFormat2(Person)

val nguyenQuang = Person(firstName = "Nguyen", lastName = "Quang")
val tranTin = Person(firstName = "Tran", lastName = "Tin")
List(nguyenQuang, tranTin).toJson
  • Lúc này, compiler sẽ không chấp nhận code mà báo lỗi
Error:(17, 46) ambiguous implicit values:
 both method listFormat in trait CollectionFormats of type [T](implicit evidence$1: spray.json.JsonFormat[T])spray.json.RootJsonFormat[List[T]]{def write(list: List[T]): spray.json.JsArray}
 and method listJsonWriter in object ExtendedJsonProtocol of type [T](implicit evidence$1: spray.json.JsonWriter[T])spray.json.RootJsonWriter[List[T]]
 match expected type spray.json.JsonWriter[List[A$A10.this.Person]]
lazy val result = List(nguyenQuang, tranTin).toJson
  • Từ lỗi "ambigious implicit values", ta có thể đoán là nguyên nhân do compiler đang không biết chọn cách nào để chọn đúng giá trị implicit cho JsonWriter[List], cụ thể ở đây là
      1. Sử dụng DefaultJsonProtocol để tạo RootJsonFormat[List]
    List(nguyenQuang, tranTin).toJson.toJson(listFormat(personJsonFormat))
    
      1. Sử dụng method listJsonWriter mà ta định nghĩa để chuyển đổi JsonFormat[Person] thành RootJsonWriter[List]
    List(nguyenQuang, tranTin).toJson(listJsonWriter(personJsonFormat))
    
Tìm hiểu về lỗi "ambigious implicit values"
  • Lỗi "ambigious implicit values" xảy ra là do Scala đang không biết tìm giá trị implicit phù hợp để thay thế vào vị trí. Bản thân Scala có một hệ thống để đánh rank cho các implicit hiện có và chọn ra cái phù hợp nhất. Theo như tài liệu của Scala, mục "7.2 Implicit parameters, ta có thể tìm thấy cụm từ implicit scope cùng với một chú ý.

If there are several eligible arguments which match the implicit parameter’s type, a most specific one will be chosen using the rules of static overloading resolution.

  • Tóm gọn lại 6.26.3 Overloading resolution, ý tưởng chính là Scala tìm kiếm một alternative phù hợp nhất với implicit. Và để xác định một alternative X có phù hợp hơn alternative Y hay không, ta có thể tưởng tượng là đó là một trận đấu gồm 2 vòng, kết quả cuối cùng là tổng điểm cộng lại.

    • Vòng 1: alternative được định nghĩa ở đâu?
      • X sẽ hơn Y 1 điểm nếu như X được định nghĩa trong class hoặc object có nguồn gốc (ví dụ là extend from) từ class hoặc object mà Y được định nghĩa (và ngược lại).
    • Vòng 2: alternative thuộc loại gì
      • X sẽ hơn Y 1 điểm nếu type của X comform (ví dụ như là subtype) của Y.
  • Kết quả cuối cùng sẽ là tổng điểm của 2 vòng. Tuy nhiên, trường hợp Hoà vẫn có thể xảy ra, ví dụ như

    • Cả 2 alternative hoà ở cả 2 ván. Ví dụ như cả alternative có cùng type và đều cùng được định nghĩa trong cùng class/object
    • Mỗi alternative thắng 1 vòng. Ví dụ X được định nghĩa trong 1 subclass mà Y được định nghĩa, nhưng type của Y lại là subtype của X.
  • Trường hợp 2 xảy ra cho ví dụ ở trên

    • listJsonWriter được định nghĩa trong object mà nó extend từ DefaultJsonProtocol nơi listFormat của spray-json được định nghĩa. Do đó 1-0 nghiêng về phía ta.
    • JsonFormat[List[T] - type được return về từ method listFormat lại là subtype của JsonWriter[List[T], type này được return về từ method listJsonWriter của ta định nghĩa. Do đó, tỉ số hiệp 2 là 0-1.
  • Tổng hợp kết quả trên, ta có 1 kết quả hoà, và gây ra lỗi "ambigious implicit values" như trên.

Giải pháp
  • Từ những kết quả tìm hiểu được như trên, ta có thể thấy có 2 cách giải quyết vấn đề này: hoặc là thay đổi nơi định nghĩa của listJsonWriter, hoặc là thay đổi type được trả về từ listJsonWriter.
  • Tuy nhiên, vì hàm listJsonWriter khá là khó thay đổi, nên cách khả dĩ nhất là thay đổi nơi định nghĩa của nó, làm nó bớt specific hơn hàm listFormat của spray-json. Một cách để thực hiện việc này là thay đổi extends DefaultJsonProtocol thành import DefaultJsonProtocol._ như dưới đây:
import spray.json._
import DefaultJsonProtocol._

object ExtendedJsonProtocol {
  implicit def listJsonWriter[T : JsonWriter]: RootJsonWriter[List[T]] = new RootJsonWriter[List[T]] {
    def write(list: List[T]): JsArray = JsArray(list.map(_.toJson).toVector)
  }
}
import ExtendedJsonProtocol._

case class Person(firstName: String, lastName: String)

implicit val personJsonFormat = jsonFormat2(Person)

val nguyenQuang = Person(firstName = "Nguyen", lastName = "Quang")
val tranTin = Person(firstName = "Tran", lastName = "Tin")
List(nguyenQuang, tranTin).toJson

Kết luận

  • spray-json là một thư viện khá tốt, gọn nhẹ, phục vụ tốt cho việc xử lý JSON. Ngoài ra, với các hiểu biết về implicit, ta cũng có thể dễ dàng custom lại Protocol theo ý bản thân để phù hợp hơn cho dự án.

Tham khảo