Moshi: Modernizando el análisis JSON en Android

Para el desarrollo de cualquier aplicación es necesario utilizar JSON para comunicarnos con el backend y, hasta ahora, lo habitual era ayudarse de la librería Gson. Aunque esta herramienta funciona adecuadamente en Java, no es la mejor opción para Kotlin, pues pueden aparecer errores no controlados. En los últimos tiempos se han desarrollado otras herramientas que permiten hacer lo mismo, pero son más seguras ante este tipo de fallos. Estamos hablando de JacksonKotlinx.Serialization y Moshi. Aunque estas tres opciones presentan las mismas ventajas frente a Gson, nos centraremos en Moshi por ser la más popular y la más estable actualmente.

¿Qué es Moshi?

Moshi es una librería para parsear JSON en objetos Java o Kotlin. Está desarrollada por Square por casi las mismas personas que Gson. Los principales desarrolladores, Jesse Wilson y Jake Wharton, han dicho que Moshi podría considerarse Gson v3.

Las principales ventajas frente a Gson son:

  • Moshi entiende y funciona correctamente con los tipos no nulos de Kotlin.
  • Ya no es necesario indicarle a Proguard que no ofusque el package donde tengáis ubicados vuestros modelos, ya que con la configuración básica es suficiente.
  • Moshi sigue en desarrollo y mejorando, mientras que Gson es prácticamente una librería terminada.
  • Ocupa menos tamaño en el apk.

**(Jesse Wilson expone más ventajas aquí).

Si estáis acostumbrados a usar Gson no tendréis problemas en usar Moshi, ya que se parecen mucho. Sin embargo, es necesario tener en cuenta algunas diferencias y tomar ciertas precauciones a la hora de realizar una migración.

Uso de la librería

Se recomienda usar Moshi junto codegen: https://github.com/square/moshi#codegen

implementation "com.squareup.moshi:moshi:$moshi_version"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version"

Y nunca olvidar añadir las rules de Proguard: https://github.com/square/moshi/blob/master/moshi/src/main/resources/ME…

Para usarlo junto con Retrofit usar este converter: https://github.com/square/retrofit/tree/master/retrofit-converters/moshi

implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version"

Y añadir a vuestra instancia de Retrofit de la siguiente manera:

Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(MoshiConverterFactory.create())
        .build()

Con esto ya tenéis vuestro proyecto listo para usar Moshi para parsear vuestros JSON.

Diferencias con Gson: modelos

Donde con Gson teníamos:

data class FooDTO(
    val id: Int,
    @SerializedName("nombre") val name: String
)

Con Moshi tenemos:

@JsonClass(generateAdapter = true)
data class FooDTO(
    val id: Int,
    @Json(name = "nombre") val name: String
)

La anotación @SerializedName cambia por @Json y debido a que usamos codegen, es necesario anotar todas las clases con @JsonClass(generateAdapter = true.

Diferencias con Gson: Deserializer/Serializer

Con Gson se pondría de esta manera:

class DateAdapter : JsonDeserializer, JsonSerializer {

    private val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())

    override fun serialize(
        src: Date,
        typeOfSrc: Type,
        context: JsonSerializationContext
    ): JsonElement {
        return JsonPrimitive(df.format(src))
    }

    override fun deserialize(
        json: JsonElement,
        typeOfT: Type,
        context: JsonDeserializationContext
    ): Date {
        return df.parse(json.asString)!!
    }
}

Y con Moshi de esta otra:

class DateAdapter {

    private val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())

    @ToJson
    fun toJson(value: Date): String {
        return df.format(value)
    }

    @FromJson
    fun fromJson(source: String): Date {
        return df.parse(source)!!
    }
}

Diferencias con Gson: Kotlin null safety

Gson no entiende los tipos no nulos de Kotlin. Esto significa que si intentamos deserializar un valor nulo en un tipo no nullable, Gson lo hará sin errores, lo cual puede causar excepciones inesperadas fácilmente. Por ejemplo:

Dado el anterior modelo FooDTO, y un json {“id”:0, “name”:null}:

  • Gson creará el objeto FooDTO(0, null), aunque esto no debería de ser posible ya que name se ha definido como no nullable. No lanzará ninguna excepción pero si que obtendréis errores a la hora de usar name:
    • name.isEmpty() producirá un NullPointerException
    • name.trim() lanzará un TypeCastException: null cannot be cast to non-null type kotlin.CharSequence
  • Moshi lanzará la siguiente excepción al deserializar:
com.squareup.moshi.JsonDataException: Required value 'name' (JSON name 'nombre') missing

Con Moshi sabremos desde el primer momento que es lo que falla en la definición de nuestro modelo, sin embargo con Gson no seremos conscientes hasta el momento de usar la variable, pudiendo ocurrir en cualquier parte del código y pudiendo lanzar varios tipos de error según el uso de la variable y todo esto pasará mientras os preguntáis cómo ha sido posible que una variable no nula sea nula.

Migración desde Gson

Debido a que Moshi es más riguroso NO se recomienda hacer una migración masiva de todos los objetos que tengáis en Java o Kotlin ya que es posible que vuestros modelos no estén bien definidos.

Por ejemplo, si vuestros modelos tienen definidas variables no nullables que en algún momento son nulas pero nunca se han usado, con Gson nunca os habrá saltado el error y este habrá qo oculto pero si ese mismo modelo lo migráis a Moshi el error saltará al deserializar. Aún así hay varias opciones:

  • Si vuestro modelo está en Java: convertirlo a Kotlin con todos sus atributos nullables.
  • Vuestros modelos están en Kotlin y el código que los usa también está en Kotlin: eliminar los atributos que no se usen o hacerlos todos nullables.
  • Realizar una migración progresiva: empezar a usar Moshi en el parseo de nuevas peticiones y dejar las antiguas con Gson, pero teniendo en cuenta que no deben mezclarse dentro de un mismo modelo.
  • Realizar tests a vuestros modelos para así estar 100% seguros de que están bien definidos y así migrar sin miedo a romper nada.

Migración progresiva

Mediante esta solución tendremos una anotación con la cual le diremos a Retrofit cuales son las llamadas que queremos parsear con Moshi.

@Target(AnnotationTarget.FUNCTION)
@Retention(RUNTIME)
annotation class Moshi

interface GdaxApi {
  @GET("products") fun products(): List
  @Moshi @GET("products") fun productsMoshi(): List
}

Para que esto funcione tenemos que crear nuestra propia Converter.Factory para que compruebe si un servicio está anotado con @Moshi:

class MoshiMigrationConverter(private val moshiConverterFactory: MoshiConverterFactory)
  : Converter.Factory() {
    
  override fun responseBodyConverter(
      type: Type,
      annotations: Array,
      retrofit: Retrofit): Converter? {
    for (annotation in annotations) {
      if (annotation.annotationClass == Moshi::class) {
        return moshiConverterFactory.responseBodyConverter(type, annotations, retrofit)
      }
    }
    return null
  }

  override fun requestBodyConverter(
      type: Type,
      parameterAnnotations: Array,
      methodAnnotations: Array,
      retrofit: Retrofit): Converter<*, RequestBody>? {
    for (annotation in methodAnnotations) {
      if (annotation.annotationClass == Moshi::class) {
        return moshiConverterFactory.requestBodyConverter(
            type,
            parameterAnnotations,
            methodAnnotations,
            retrofit)
      }
    }
    return null
  }
}

Finalmente lo insertamos a nuestra instancia de Retrofit:

val retrofit = Retrofit.Builder()
    .baseUrl(BASE_URL)
    .addConverterFactory(MoshiMigrationConverter(MoshiConverterFactory.create()))
    .addConverterFactory(GsonConverterFactory.create())
    .build()

De esta manera los nuevos servicios que vayáis implementando en vuestras apps pueden usar Moshi y los antiguos seguir usando Gson y también permite ir migrando uno a uno vuestros servicios.

Migración con tests

El primer paso sería hacer un test para comprobar que la definición de nuestro modelo se corresponde a lo que devuelve el servidor. Para ello únicamente necesitaremos JUnit y un archivo .json que sea la copia de lo que nos devuelve el servicio. Pongamos nuestro anterior modelo FooDTO y añadimos el siguiente “foo.json” dentro la carpeta de resources de los test, quedaría en “test/resources/foo.json”

{  
  "id": 0,  
  "name": "SDOS"  
}

Nuestra clase de test quedaría así:

class FooDTOTest {  
  
  private val loader = javaClass.classLoader!!  
  private val gson = GsonBuilder().create()  
  
  @Test  
  fun parse() {  
        val jsonString = String(loader.getResourceAsStream("foo.json").readBytes())  
        val actual = gson.fromJson(jsonString, FooDTO::class.java)  
  
        val expected = FooDTO(10, "SDOS")
  
        assertEquals(expected, actual)  
    }
}

La variable gson debe de ser igual a que lo uséis en Retrofit, en el caso de no tengáis ninguna GsonBuilder().create() es la que se crea por defecto.

Lo que estamos haciendo es leer el fichero “foo.json”, le decimos a Gson que lo deserialize en FooDTO y el resultado lo guardamos en la variable actual. En expected creamos un objecto que esperamos que sea el resultado de deserializar el json y finalmente comprobamos con JUnit que ambos modelos son iguales.

Una vez tengamos nuestros tests ya podemos migrar FooDTO para usarlo con Moshi y nuestra clase de test quedaría así:

class FooDTOTest {  
  
  private val loader = javaClass.classLoader!!  
  private val moshi = Moshi.Builder().build()
  
  @Test  
  fun parse() {  
        val jsonString = String(loader.getResourceAsStream("foo.json").readBytes())  
        val actual = moshi.adapter(FooDTO::class.java).fromJson(jsonString)
  
        val expected = FooDTO(10, "SDOS")
  
        assertEquals(expected, actual)  
    }
}

Al igual que antes, moshi debe de ser igual a la que uséis en Retrofit y Moshi.Builder().build() es la que se crea por defecto. La idea del test sigue siendo la misma, pero ahora usamos Moshi para deserializar el json.

Por tanto, cuantos más tests con distintos tipos de respuestas de vuestro servidor tengáis mejor y muy probablemente podáis descubrir errores en vuestros modelos antes de migrar a Moshi.

Conclusiones

La llegada de Kotlin ha dejado en evidencia algunas carencias de Gson, que pueden ser solventadas utilizando nuevas librerías, como Moshi. Moshi es null safety por lo que obliga a tener modelos bien definidos, evitando así la aparición de errores inesperados. Se utiliza de forma similar a Gson, pero evita añadir excepciones a Proguard. Además, puede coexistir con Gson y hacerse una migración progresiva.

Gson ha sido un buen compañero de viaje pero es hora de usar otras alternativas. Ahora tenéis las herramientas básicas para probar Moshi y comprobar sus ventajas por vosotros mismos.

Esperamos que os haya resultado interesante nuestro post sobre Moshi. ¿Lo vais a utilizar o preferís otras librerías? ¡Déjanos un comentario con tu experiencia u opinión al respecto!

Android Team
ALTEN


Fuentes: