Moshi: Thư viện xử lý JSON cho Android, Java, và Kotlin thế hệ mới

Nếu bạn là một lập trình viên Android, Java, hay đặc biệt là Kotlin và đang tìm một công cụ thay thế GSON giúp xử lí serialize/deserialize hiệu quả, thì Moshi có thể sẽ là ứng cử viên bạn đang tìm kiếm.

Upload image

Trong bài viết này, ta sẽ cùng tìm hiểu về cách sử dụng thư viện Java Moshi, đôi chút về những tính năng mà thư viện này hỗ trợ, cũng như so sánh giữa Moshi và GSON.

Moshi là gì?

Moshi là thư viện được tạo ra năm 2015 dựa trên Okio, phục vụ cho việc chuyển đổi JSON trong các dự án Java, Kotlin, hay Android.

Moshi kế thừa từ GSON – một thư viện Java ra đời năm 2008 và vẫn đang được sử dụng rộng rãi nhất hiện tại. Vậy điều gì khiến Moshi dần được chú ý hơn, đặc biệt là trong cộng đồng người sử dụng Kotlin? Để trả lời câu hỏi này, ta cùng xem qua cách Moshi hoạt động và so sánh nó với GSON.

Làm quen với Moshi Kotlin

Trong ví dụ này, ta sẽ sử dụng Moshi Kotlin để serialize và deserialize các đối tượng JSON bằng hai cách: sử dụng phương pháp reflection truyền thống kế thừa từ GSON và sử dụng phương pháp codegen của Moshi.

1. Cài đặt Moshi

Để sử dụng Moshi trong Kotlin, ta cần thêm dependency moshi:

implementation("com.squareup.moshi:moshi:1.14.0")

2. Áp dụng phương pháp reflection

Muốn dùng phương pháp reflection để parse, ta thêm dependency moshi-kotlin:

implementation("com.squareup.moshi:moshi-kotlin:1.14.0")

Thực hiện Serialization và Deserialization cơ bản

Tạo hai data class BookAuthor gồm các field như sau:

Class Author:

data class Author(var name: String, var email: String)

Class Book:

data class Book(
    var title: String,
    var author: Author,
    @Json(name = "publish_year") var publishYear: Int,
    var price: Double,
    var categories: List<String>,
    @Json(name = "updated_at") var updatedAt: LocalDateTime = LocalDateTime.now()
)

Như ví dụ trên, ta có thể dùng @Json annotation để thay đổi tên field khi parse sang object JSON (đổi tên cho publishYearupdatedAt).

Ta còn có thể gán các giá trị mặc định để sử dụng trong trường hợp field tương ứng bị thiếu trong JSON (giá trị mặc định cho field updatedAt).

Tiếp theo, ở function main(), ta tạo các đối tượng chuẩn bị cho việc parse dữ liệu:

val categories: List<String> = listOf("Comic", "Adventure")
val book = Book("One Piece", Author("Oda Eiichirou", "oda_email"), 1997, 10.8, categories)
val book1 = Book("One Piece 1", Author("Oda Eiichirou", "oda_email"), 1997, 10.8, categories)
val book2 = Book("One Piece 2", Author("Oda Eiichirou", "oda_email"), 1997, 10.8, categories)
val books: List<Book> = listOf(book, book1, book2)

Các bước để thực hiện việc parse dữ liệu theo phương pháp reflection cơ bản như sau:

  1. Tạo đối tượng Moshi
val moshi: Moshi = Moshi.Builder()
    .addLast(KotlinJsonAdapterFactory())
    .build()
  1. Tạo đối tượng JsonAdapter từ đối tượng Moshi cho kiểu dữ liệu tương ứng
val bookAdapter: JsonAdapter<Book> = moshi.adapter(Book::class.java)
  1. Thực hiện chuyển hoá dữ liệu
val bookJson: String = bookAdapter.toJson(book)
val generatedBook: Book? = bookAdapter.fromJson(bookJson)

Đối với những đối tượng chỉ chứa field thuộc các kiểu dữ liệu primitive, thuộc data class của Kotlin hay POJO của Java, chỉ ba bước trên là đủ. Tuy nhiên trong ví dụ này, nếu ta chỉ dừng ở đây thì khi chạy sẽ bị lỗi IllegalArgumentException như sau:

Upload image

Vì field updatedAt có kiểu dữ liệu là LocalDateTime không thuộc các trường hợp trên, ta cần tạo riêng adapter để Moshi biết cách serialize dữ liệu thuộc kiểu này:

Adapter cho class LocalDateTime:

class LocalDateTimeAdapter(var FORMATTER: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME):
    JsonAdapter<LocalDateTime>() {
    @FromJson
    override fun fromJson(dateTimeString: JsonReader): LocalDateTime? {
        return LocalDateTime.parse(dateTimeString.readJsonValue().toString(), FORMATTER)
    }

    @ToJson
    override fun toJson(writer: JsonWriter, dateTime: LocalDateTime?) {
        writer.jsonValue(dateTime?.format(FORMATTER))
    }
}

Sau đó, thêm adapter vừa tạo vào object moshi:

val moshi: Moshi = Moshi.Builder()
    .add(LocalDateTimeAdapter())
    .addLast(KotlinJsonAdapterFactory())
    .build()

Lưu ý, đối tượng thuộc kiểu Moshi có sẵn có thể được dùng để tạo ra đối tượng mới cùng kiểu bằng cách gọi hàm newBuilder(), nên bên cạnh cách trên, ta còn có thể làm như sau:

val moshi: Moshi = Moshi.Builder()
    .addLast(KotlinJsonAdapterFactory())
    .build()

moshi = moshi.newBuilder()
    .add(LocalDateTimeAdapter())
    .build()

Vậy là ta đã thực hiện xong các bước để serialize và deserialize cơ bản trong Moshi bằng phương pháp reflection. Khi in kết quả ra màn hình ta được output như sau:

Upload image

Ignore field

Trong phần này, ta dùng annotation @Json của Moshi để bỏ qua field bất kì khi thực hiện parse JSON. Ta còn có thể thấy được cách Moshi xử lí khi deserialize từ JSON bị thiếu field sẽ dùng giá trị mặc định như thế nào.

Để bỏ qua một field, ta thêm ignore = true vào annotation @Json như sau:

ags
@Json(name = "updated_at", ignore = true) var updatedAt: LocalDateTime = LocalDateTime.now()

Nếu field updatedAt có kiểu dữ liệu là LocalDateTime đã bị bỏ qua, ta có thể bỏ LocalDateTimeAdapter mà ta tạo từ bước trên ra khỏi object moshi mà vẫn không bị lỗi IllegalArgumentException như ở phần trên:

val moshi: Moshi = Moshi.Builder()
    //.add(LocalDateTimeAdapter())
    .addLast(KotlinJsonAdapterFactory())
    .build()

Chạy lại chương trình, ta có output như sau:

Upload image

Nhìn vào output, ta có thể thấy mặc dù đối tượng book mà ta tạo có field updatedAt, nhưng sau khi serialize thành JSON lưu trong đối tượng bookJson, field updatedAt đã bị bỏ qua nên không xuất hiện.

Khi deserialize đối tượng bookJson và lưu vào đối tượng generatedBook, Moshi không để giá trị của updatedAt bị thiếu là null như GSON mà dùng giá trị mặc định sẵn của field bị thiếu.

Lưu ý, khi muốn bỏ qua một field, Moshi yêu cầu ta khai báo sẵn một giá trị mặc định cho field đó để sử dụng sau khi deserialize. Nếu không khai báo sẵn, ta sẽ bị lỗi runtime như sau:

Upload image

Xử lí trên các kiểu generic lồng nhau

Để xử lí serialize/deserialize JSON cho các kiểu lồng nhau, trong bài này là kiểu List<Book>, ta thực hiện ba bước:

  1. Tạo đối tượng Type gồm tất cả các class lồng nhau:
val bookListType: Type = Types.newParameterizedType(List::class.java, Book::class.java)
  1. Tạo JsonAdapter từ đối tượng Type trên:
val bookListAdapter: JsonAdapter<List<Book>> = moshi.adapter(bookListType)
  1. Thực hiện serialize/deserialize bằng JsonAdapter vừa tạo:
val jsonBooks: String = bookListAdapter.toJson(books)
val generatedBooks: List<Book>? = bookListAdapter.fromJson(jsonBooks)

3. Áp dụng phương pháp codegen

Phương pháp codegen được Moshi hỗ trợ giúp giảm runtime khi thực hiện parse JSON. Mặc dù phương pháp này làm tăng thời gian compile, nhưng so với khoảng runtime tiết kiệm được, nhất là khi cần thao tác trên những JSON lớn, phương pháp này vẫn được cho là tối ưu hơn reflection.

Nói sơ qua về nguyên lí, khi áp dụng phương pháp codegen, Moshi sẽ tự động viết adapter cho đối tượng thuộc các class có gắn annotation @JsonClass(generateAdapter = true). Adapter được Moshi viết sẽ bỏ qua các field không cần thiết trong object, tránh việc decode chuỗi tên field (vốn xuất hiện nhiều lần), cũng như thực hiện một số biện pháp khác giúp tối ưu xử lí trên JSON. Để áp dụng phương pháp codegen, ta cần:

  1. Thêm plugin kotlin-kapt vào build.gradle
kotlin("kapt") version "1.8.0"
  1. Thêm dependency moshi-kotlin-codegen
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.14.0")

Lưu ý, nếu không dùng phương pháp reflection, ta có thể bỏ dependency moshi-kotlin ra khỏi project.

Theo phương pháp reflection, lúc khởi tạo đối tượng moshi, ta cần thêm KotlinJsonAdapterFactory để thực hiện reflection. Khi sử dụng phương pháp codegen, ta bỏ KotlinJsonAdapterFactory khỏi moshi:

val moshi: Moshi = Moshi.Builder()
    .add(LocalDateTimeAdapter())
    //.addLast(KotlinJsonAdapterFactory())
    .build()

Đối với các class ta muốn Moshi tạo adapter, ta gắn annotation @JsonClass(generateAdapter = true). Trong bài này, ta muốn Moshi tạo code cho adapter của class BookAuthor:

@JsonClass(generateAdapter = true)
data class Book()

@JsonClass(generateAdapter = true)
data class Author(internal var name: String, internal var email: String)

Để hiểu thêm về code của các adapter được Moshi tạo ra trong thời gian compile, ta có thể xem trong folder generated trong build.

So sánh GSON và Moshi

Về tính năng:

So với Moshi, GSON dễ sử dụng hơn, nhưng hỗ trợ ít tính năng hơn. GSON ra đời sớm và được sử dụng rộng rãi, song thư viện này đã lâu không được cập nhật version mới cũng như fix bug.

Trong khi đó, Moshi cung cấp nhiều tính năng mới giúp tối ưu việc parse dữ liệu JSON, một số ví dụ như:

  • Cung cấp annotation để custom việc parse dữ liệu của một field trong JSON (@ColorInt, @HexColor).
  • Cho phép custom các adapter hoặc Contextual Deserialization giúp các tính năng này gọn hơn, ít tạo ra những đoạn code dài dòng, trùng lặp.
  • Cung cấp phương thức newBuilder() dùng để tạo những moshi mới từ những moshi đã có sẵn. Việc này giúp tạo ra những adapter độc lập, mỗi adapter làm một việc, tránh việc tạo ra adapter “thần thánh” có thể parse hàng ngàn model.
  • Hỗ trợ kiểu dữ liệu đa hình, đồng thời có phương án để fallback trong trường hợp JSON đưa vào kiểu dữ liệu không biết.
  • Tối ưu tốc độ xử lí và giảm tiêu hao bộ nhớ nhờ hỗ trợ của Okio.

Về độ tin cậy và xử lí lỗi:

So với các kiểu dữ liệu có thể null trong Java, một trong số ưu điểm của Kotlin là cung cấp các kiểu dữ liệu không thể null giúp cho việc xử lí dữ liệu an toàn hơn. Tuy nhiên, bởi vì GSON là một thư viện của Java, việc sử dụng GSON để deserialize từ JSON có thể tạo ra các giá trị null cho các kiểu dữ liệu không null của Kotlin. Điều này có thể dẫn đến các lỗi runtime.

GSON không sử dụng các giá trị mặc định mà ta gán cho field, do đó, nếu có field bị thiếu trong JSON, giá trị field đó trong object mới tạo sẽ là giá trị mặc định của kiểu dữ liệu.

Về phần Moshi, thư viện này cung cấp nhiều giải pháp hơn để hỗ trợ Kotlin, phải kể đến là codegen adapter giúp quá trình chuyển hoá dữ liệu nhanh hơn so với phương pháp reflection cũ của GSON.

Moshi sử dụng các giá trị mặc định do lập trình viên gán cho field, giúp hạn chế lỗi xảy ra trong việc parse JSON, đặc biệt là lỗi về business.

Moshi còn có cách báo lỗi rõ ràng và dễ đoán hơn, như quăng IOException cho vấn đề về IO hoặc JsonDataException khi kiểu dữ liệu không phù hợp. Message báo lỗi dễ đọc và dễ truy ngược lỗi hơn, từ đó việc debug sẽ được nhẹ nhàng hơn.

Về hiệu năng:

Moshi hoạt động nhanh hơn và tiêu tốn ít bộ nhớ hơn GSON.

Khi nhìn vào các object JSON cùng kiểu, ta thường thấy tên field xuất hiện lặp đi lặp lại trong nhiều objects. Moshi dựa trên hỗ trợ của Okio sẽ tải trước tập hợp tên field của object cần parse bằng phương pháp codegen, từ đó tránh việc decode chuỗi UTF-8 mỗi lần tên field xuất hiện đồng thời bỏ qua những field không mong muốn, giúp việc deserialize JSON được nhanh hơn. Ta có thể đọc thêm code của các adapter do Moshi tự động tạo để hiểu thêm về quá trình này.

Hơn nữa, Retrofit (REST Client giúp xử lí REST request và response) cũng sử dụng Okio. Vì vậy, việc Moshi và Retrofit cùng chia sẻ buffer giúp giảm tiêu hao bộ nhớ khi thực hiện gọi network và serialize response JSON.

Lời kết

Ra đời từ năm 2015, Moshi đã và đang có sự phát triển không ngừng giúp cho việc xử lí JSON trong Android, Java, và Kotlin ngày càng hiệu quả và nhanh chóng. Đây cũng là một thư viện mạnh mẽ được cộng đồng người sử dụng Kotlin lựa chọn để thực hiện quá trình chuyển hoá JSON. Hy vọng bài viết giúp các bạn bắt đầu với Moshi dễ dàng hơn. Để có thể hiểu thêm về Moshi, các bạn có thể tìm hiểu thêm về Okio và Retrofit cũng như những ảnh hưởng của hai công nghệ này tới performance của Moshi.

Tham khảo Baeldung, Eric the Coder, Reza

Atekco - Home for Authentic Technical Consultants