JSON en Kotlin: Comparando Opciones

Compartir esta publicación

Introducción JSON en Kotlin

En cualquier servicio web que reciba y transmita datos hacia y desde un servidor, el primer y último evento serán normalmente la transformación de los datos desde el formato utilizado por la petición web al formato que manejará el servidor web, y viceversa; estas operaciones se denominan deserialización y serialización, respectivamente. Para algunos servicios web, la reflexión sobre esta parte del flujo de datos se centra únicamente en cómo configurar el mecanismo de serialización para que funcione correctamente. Sin embargo, hay algunos escenarios para los que cada ciclo de CPU cuenta, y cuanto más rápido pueda funcionar el mecanismo de serialización, mejor. Este artículo explorará las características de desarrollo y rendimiento de cuatro opciones diferentes para trabajar con la serialización de mensajes JSON – GSON, Jackson, JSON-B, y Kotlinx Serialization, utilizando tanto el lenguaje de programación Kotlin como algunas de las características únicas que ofrece Kotlin en comparación con su lenguaje homólogo, Java.

Setup

Desde su primer lanzamiento en 2017, Kotlin ha crecido a pasos agigantados dentro de la comunidad JVM, convirtiéndose en el lenguaje de programación de referencia para el desarrollo de Android, así como en un ciudadano de primera clase en las principales herramientas JVM como Spring, JUnit, Gradle y más. Entre las innovaciones que aportó a la comunidad JVM con respecto a Java se encuentra la data class, un tipo especial de clase que se va a utilizar principalmente como soporte de datos (en otras palabras, un Objeto de Transferencia de Datos, o DTO) y genera automáticamente funciones de utilidad base para la clase como equals(), hashcode(), copy(), y más. Esto constituirá la base de las clases que se utilizarán para las pruebas de rendimiento, la primera de las cuales es PojoFoo – “Pojo” sus siglas en inglés “Plain Old Java Object”, lo que significa que sólo se utilizan los tipos de clase básicos del lenguaje de programación Java.

data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>) 
{
   constructor() : this("", 0, emptyList())
}

Para los que no estén familiarizados con el lenguaje de programación Kotlin: la clase tiene tres atributos – fizz, bizz, y bazz - que contienen funciones getter y setter. Hay dos constructores para la clase: uno que requiere argumentos para cada uno de los atributos, y otro que no requiere argumentos y rellena los atributos con valores por defecto. Este segundo constructor es el «constructor sin argumentos» que suelen requerir los mecanismos de serialización JSON.

En el ejemplo anterior, los tres atributos de clase están marcados con la palabra clave var; significa que los atributos son mutables y pueden modificarse en cualquier momento durante la vida de una instancia de la clase. Para que los atributos sean inmutables, basta con cambiar el designador a val, en el que los atributos se convertirán en el equivalente de final en Java, y Kotlin ya no generará una función getter para los atributos. Además, esto elimina el requisito de un constructor sin carga, por lo que se puede eliminar del código.

data class ImmutableFoo(val fizz: String, val bizz: Int, val bazz: List<String>)

La siguiente clase de ejemplo – DefaultValueFoo – usa un valor por defecto para el atributofizz. Esto significa que, si se invoca el constructor de DefaultValueFoo y no se proporciona ningún argumento para fizz, entonces al argumento se le asignará el valor por defecto.

data class DefaultValueFoo(var fizz: String = "FUZZ", var bizz: Int, var bazz: List<String>) {
   constructor() : this(bizz = 0, bazz = emptyList())
}

Por último, la clase de ejemplo ValueClassFoo cambia el tipo de atributo bizz de un entero simple a un clase inline. Las clases inline funcionan como envoltorios alrededor de un único valor de «carga útil»; mientras que el código Kotlin tratará la clase inline como una clase «genuina», el compilador traducirá el código para que sólo esté presente el valor de carga útil. Esto proporciona varias ventajas en comparación con el simple uso del valor de la carga útil directamente, como la aplicación de una seguridad de tipo para diferentes variables, por ejemplo, especificar un nombre de usuario y un tipo de contraseña – dos tipos que normalmente serían cadenas – para una función de inicio de sesión. En este caso, permite utilizar UInt: una clase exclusiva de Kotlin que simula el comportamiento de una función sin signo, algo que no está soportado por defecto por la JVM.

data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
   constructor() : this("", 0u, emptyList())
}

(Nota: la clase se llama así porque, aunque las inline clases se siguen llamando así en la documentación de Kotlin, se han renombrado como clases de valor en el código real; la palabra clave inline está obsoleta.)

  UX SOFTWARE: Ingeniería UX en un proyecto de desarrollo de software

Los participantes

GSON

Introducida en 2008 y desarrollada por Google, GSON es una de las principales opciones que los usuarios de Java emplean para llevar a cabo la serialización entre cadenas JSON y objetos Java y es la librería preferida para aprovechar en el desarrollo Android gracias al apoyo de Google.

Uso

El uso básico es crear una instancia de Gson e invocar las funciones Gson.toJson() yGson.fromJson() para serializar un objeto y deserializar una cadena JSON, respectivamente.

Trabajando con Kotlin

Sorprendentemente, no son necesarios pasos adicionales para trabajar con las cuatro clases de ejemplo; todos los fragmentos de código proporcionados anteriormente proceden del código de prueba de GSON.

Jackson

Presentada en 2009, Jackson es la otra biblioteca de serialización JSON ampliamente utilizada -junto con GSON- y se utiliza por defecto en los principales ecosistemas JVM como Spring Framework.

Uso

El uso básico es construir una instancia de ObjectMapper e invocar las funciones ObjectMapper.writeValueAsString() y ObjectMapper.readValue()para serializar un objeto y deserializar una cadena JSON, respectivamente.

Trabajando con Kotlin

A diferencia de GSON, hay bastante trabajo necesario para soportar las características de Kotlin en las clases de ejemplo.

  • Jackson no tiene un concepto nativo de deserialización de clases que no posean un constructor no-arg; si no puede encontrar un constructor no-arg, normalmente lanzará una excepción. Una solución para esto es marcar los parámetros en el constructor con @JsonProperty para que Jackson sepa qué argumento corresponde a qué atributo de clase.
data class ImmutableFoo(
   @param:JsonProperty("fizz") val fizz: String,
   @param:JsonProperty("bizz") val bizz: Int,
   @param:JsonProperty("bazz") val bazz: List<String>
)
  • Las clases inline no se procesan correctamente debido a una diferencia en la forma en que Jackson calcula cómo llevar a cabo la serialización y deserialización de una clase. Una ventaja de estas librerías de serialización es que normalmente no requieren la creación de clases especializadas para llevar a cabo las acciones de serialización y deserialización en una clase. En su lugar, calculan de qué campos extraer valores y cuáles establecer mediante reflexión; mientras que GSON ejecuta las acciones de reflexión en los campos de atributo reales dentro de la clase de destino, las acciones de reflexión de Jackson se dirigen a las funciones getter y setter de los atributos. Esto es un problema con las clases inline, ya que cualquier función que acepte o devuelva una clase inline es name-mangled para evitar colisiones con funciones que podrían aceptar el tipo «normal» equivalente en la JVM. Por lo tanto, tanto serializar como deserializar clases con atributos de clase inline resultará problemático.

org.opentest4j.AssertionFailedError: expected: <{"fizz":"FUZZ","bizz":5,"bazz":["BUZZ","BOZZ"]}> but was: <{"fizz":"FUZZ","bazz":["BUZZ","BOZZ"],"bizz-pVg5ArA":5}>

Unrecognized field "bizz" (class com.severett.serializationcomparison.jackson.model.ValueClassFoo), not marked as ignorable (3 known properties: "fizz", "bizz-WZ4Q5Ns", "bazz"])

Aunque existe un módulo especializado para Jackson – jackson-module-kotlin – que proporciona soporte para muchas partes de Kotlin que no se incluyen en las pruebas aquí (ejemplo Pair, Triple, IntRange, etc), no ofrece soporte para clases inline y no tiene previsto ofrecerlo en un futuro cercano. En su lugar, es necesario crear clases serializadoras y deserializadoras personalizadas para manejar ValueClassFoo y marca ValueClassFoo con @JsonSerialize y@JsonDeserialize, respectivamente.

class ValueClassFooSerializer : JsonSerializer<ValueClassFoo>() {
   override fun serialize(value: ValueClassFoo, gen: JsonGenerator, serializers: SerializerProvider?) {
       gen.writeStartObject()
       gen.writeStringField(ValueClassFoo.FIZZ_FIELD, value.fizz)
       gen.writeNumberField(ValueClassFoo.BIZZ_FIELD, value.bizz.toInt())
       gen.writeArrayFieldStart(ValueClassFoo.BAZZ_FIELD)
       value.bazz.forEach(gen::writeString)
       gen.writeEndArray()
       gen.writeEndObject()
   }
}
class ValueClassFooDeserializer : JsonDeserializer<ValueClassFoo>() {
   override fun deserialize(jsonParser: JsonParser, ctxt: DeserializationContext?): ValueClassFoo {
       val node = jsonParser.codec.readTree<JsonNode>(jsonParser)
       return ValueClassFoo(
           fizz = node[ValueClassFoo.FIZZ_FIELD].asText(),
           bizz = node[ValueClassFoo.BIZZ_FIELD].asInt().toUInt(),
           bazz = (node[ValueClassFoo.BAZZ_FIELD] as ArrayNode).map { it.textValue() }
       )
   }
}
@JsonSerialize(using = ValueClassFooSerializer::class)
@JsonDeserialize(using = ValueClassFooDeserializer::class)
data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
   constructor() : this("", 0u, emptyList())

   companion object {
       const val FIZZ_FIELD = "fizz"
       const val BIZZ_FIELD = "bizz"
       const val BAZZ_FIELD = "bazz"
   }
}

JSON-B

JSON-B es un estándar oficial para llevar a cabo la serialización y deserialización del formato de datos JSON, relativamente nuevo en el mundo de Java (se publicó por primera vez en 2017 junto con JEE 8). La API utiliza Eclipse Yasson o Apache Johnzon como la implementación subyacente, lo que significa que cualquiera de estas bibliotecas tendría que incluirse como dependencia en tiempo de ejecución; las pruebas para este artículo utilizaron Yasson como implementación.

Uso

El uso básico es construir una instancia de Jsonb via JsonbBuilder.create() e invocar las funciones Jsonb.toJson() yJsonb.fromJson() para serializar un objeto y deserializar una cadena JSON, respectivamente.

  Arquitectura Micro-frontend

Trabajando con Kotlin

JSON-B es la que requiere más trabajo de las cuatro bibliotecas evaluadas para funcionar correctamente con Kotlin.

  • JSON-B serializa los atributos de una clase por orden alfabético en lugar de por orden de declaración. Aunque esto no es un problema (los objetos JSON no requieren un orden para los campos clave), es necesario anotar una clase con @JsonbPropertyOrder si se desea un pedido específico.
@JsonbPropertyOrder("fizz", "bizz", "bazz")
data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>) {
   constructor() : this("", 0, emptyList())
}
  • Al igual que Jackson, JSON-B requiere un constructor no-arg y fallará si no encuentra uno al deserializar una cadena JSON en una clase. Por lo tanto, una clase sin un constructor no-arg necesitará marcar el constructor que JSON-B necesita usar con @JsonbCreator y marcar cada uno de los argumentos del constructor con @JsonbProperty para que correspondan a los atributos de la clase.
@JsonbPropertyOrder("fizz", "bizz", "bazz")
data class ImmutableFoo @JsonbCreator constructor(
   @JsonbProperty("fizz") val fizz: String,
   @JsonbProperty("bizz") val bizz: Int,
   @JsonbProperty("bazz") val bazz: List<String>
)
  • Por último, JSON-B también comparte el rasgo de Jackson de no ser capaz de manejar correctamente las clases en línea. Al intentar serializar ValueClassFoo producirá una salida incorrecta, y mientras que JSON-B no fallará al intentar deserializar una cadena a ValueClassFoo, no rellenará correctamente el atributo inline class.

expected: <{"fizz":"FUZZ","bizz":5,"bazz":["BUZZ","BOZZ"]}> but was: <{"bazz":["BUZZ","BOZZ"],"bizz-pVg5ArA":5,"fizz":"FUZZ"}>
expected: <ValueClassFoo(fizz=FUZZ, bizz=5, bazz=[BUZZ, BOZZ])> but was: <ValueClassFoo(fizz=FUZZ, bizz=0, bazz=[BUZZ, BOZZ])>

Al igual que Jackson, la clase destino necesitará clases serializadoras y deserializadoras especiales para poder manejarlo y ser anotado como tal.

class ValueClassFooSerializer : JsonbSerializer<ValueClassFoo> {
   override fun serialize(valueClassFoo: ValueClassFoo, generator: JsonGenerator, ctx: SerializationContext?) {
       generator.writeStartObject()
       generator.write(ValueClassFoo.FIZZ_FIELD, valueClassFoo.fizz)
       generator.write(ValueClassFoo.BIZZ_FIELD, valueClassFoo.bizz.toInt())
       generator.writeStartArray(ValueClassFoo.BAZZ_FIELD)
       valueClassFoo.bazz.forEach(generator::write)
       generator.writeEnd()
       generator.writeEnd()
   }
}
class ValueClassFooDeserializer : JsonbDeserializer<ValueClassFoo> {
   override fun deserialize(jsonParser: JsonParser, ctx: DeserializationContext?, rtType: Type?): ValueClassFoo {
       var fizz: String? = null
       var bizz: UInt? = null
       var bazz: List<String>? = null
       while (jsonParser.hasNext()) {
           val event = jsonParser.next()
           if (event != JsonParser.Event.KEY_NAME) continue
           when (jsonParser.string) {
               ValueClassFoo.FIZZ_FIELD -> {
                   jsonParser.next()
                   fizz = jsonParser.string
               }
               ValueClassFoo.BIZZ_FIELD -> {
                   jsonParser.next()
                   bizz = jsonParser.int.toUInt()
               }
               ValueClassFoo.BAZZ_FIELD -> {
                   jsonParser.next()
                   bazz = jsonParser.array.getValuesAs(JsonString::class.java).map { it.string }
               }
           }
       }
       if (fizz != null && bizz != null && bazz != null) {
           return ValueClassFoo(fizz = fizz, bizz = bizz, bazz = bazz)
       } else {
           throw IllegalStateException("'fizz', 'bizz', and 'bazz' must be not null")
       }
   }
}
@JsonbTypeDeserializer(ValueClassFooDeserializer::class)
@JsonbTypeSerializer(ValueClassFooSerializer::class)
data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
   constructor() : this("", 0u, emptyList())

   companion object {
       const val FIZZ_FIELD = "fizz"
       const val BIZZ_FIELD = "bizz"
       const val BAZZ_FIELD = "bazz"
   }
}

Serialización Kotlinx

Por último, los autores de Kotlin han publicado su propia biblioteca de serialización para el lenguaje de programación Kotlin. Publicada por primera vez en 2020, la biblioteca Kotlinx Serialization está diseñada para acciones de serialización en general, no solo JSON; aunque la biblioteca solo contiene soporte oficial para JSON, tiene soporte experimental para otros formatos como Protobuf y CBOR, así como soporte de la comunidad para formatos como YAML.

Uso

A diferencia de otras librerías de serialización JSON, no es necesario crear un objeto instancia para llevar a cabo las acciones de serialización. En su lugar, las llamadas a las funciones de extensión encodeToString() y decodeFromString() se realizan para el objeto serializador en cuestión, en este caso el objeto Kotlin Json.

Trabajando con Kotlin

También a diferencia de otras librerías de serialización JSON, Kotlinx Serialization no funciona en clases personalizadas por defecto. Esto se debe a la forma en que funciona la librería: en lugar de utilizar la reflexión como las otras librerías, Kotlinx Serialization genera funciones específicas de serialización y deserialización para la(s) clase(s) objetivo en tiempo de compilación. Para reconocer qué clases necesitan que se les genere este código de serialización, cualquier clase objetivo necesita ser anotada con @Serializable (un método diferente está disponible para las clases de terceros).

@Serializable
data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>) {
   constructor() : this("", 0, emptyList())
}

Además, Kotlinx Serialization no funciona por defecto en atributos con un valor por defecto. Es necesario habilitarlo con la anotación @EncodeDefault.

@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class DefaultValueFoo(@EncodeDefault val fizz: String = "FUZZ", var bizz: Int, var bazz: List<String>) {
   constructor() : this(bizz = 0, bazz = emptyList())
}

Pruebas

Parámetros

Cada una de las cuatro bibliotecas de serialización JSON lleva a cabo la serialización y deserialización de las cuatro clases de ejemplo, y la biblioteca Java Microbenchmark Harness (JMH) miden el rendimiento de cuántas operaciones se ejecutan por segundo de media. Por ejemplo:

@State(Scope.Benchmark)
open class SerializationComparison {
   private val gson = Gson()

   @Benchmark
   fun serializePojoFoo(): String = gson.toJson(pojoFoo)

   @Benchmark
   fun serializeImmutableFoo(): String = gson.toJson(immutableFoo)

   @Benchmark
   fun serializeDefaultValueFoo(): String = gson.toJson(defaultValueFoo)

   @Benchmark
   fun serializeValueClassFoo(): String = gson.toJson(valueClassFoo)

   @Benchmark
   fun deserializePojoFoo(): PojoFoo = gson.fromJson(pojoFooStr, PojoFoo::class.java)

   @Benchmark
   fun deserializeImmutableFoo(): ImmutableFoo = gson.fromJson(immutableFooStr, ImmutableFoo::class.java)

   @Benchmark
   fun deserializeDefaultValueFoo(): DefaultValueFoo = gson.fromJson(defaultValueFooStr, DefaultValueFoo::class.java)

   @Benchmark
   fun deserializeValueClassFoo(): ValueClassFoo = gson.fromJson(valueClassFooStr, ValueClassFoo::class.java)
}

Estas pruebas utilizan los valores por defecto de JMH de:

  • 5 rondas de calentamiento de 10 segundos
  • 5 rondas de mediciones
  • 5 procesos bifurcados para llevar a cabo ambas cosas
  TDD: Obsesión con las primitivas( parte 3 )

Las pruebas se ejecutan en un macOS con un Intel Core i7 de 6 núcleos a 2,6 GHz y 16 GB de RAM; la JVM ejecutora es Temurin 19+36.

Resultados

Serialización

R6vihdTKTV5oqr2ztMpGB4qeaUiWVEpmmD8H5R64sOLTOdFopqwUgkN 8JNARdgbb hGMyCIY1ZPyrDsEcbTnJ73p d1idZAjh04TaQGvuBOGk GzapSuny6CgyPcgo2LcIlPJi6YfQupcI2uu6WOuOj9T lllUeBbfRTfoklg0YBJqf9bSEOrpLFQ8laQ

La clara ganadora entre las cuatro bibliotecas es Kotlinx Serialization, ya que promedia más de 5 millones de operaciones por segundo, mucho más rápida que la biblioteca Jackson, que ocupa el segundo lugar. Sería imposible identificar las razones exactas de por qué el rendimiento de Kotlinx Serialization es mucho mayor en comparación con la competencia sin bucear demasiado en el código fuente de cada librería, pero una pista puede estar en cómo las otras librerías rinden mucho mejor durante la serialización de ValueClassFoo en comparación con las otras clases de ejemplo (la excepción es Kotlinx Serialization – que parece hacerlo peor, pero dados los rangos de error para cada resultado, no es estadísticamente significativo). Por ejemplo, al ejecutar el perfilador Java Flight Recorder en Jackson se obtiene el siguiente resultado en el árbol de llamadas para la serialización PojoFoo:

cWhOTs6MRZf3o2Vnp6RBkWW9HGSmMUvVO pDxnxVCNBvqBBQGJifl3q5jwXCIi9PMYfoSwg6pp7AMZUvbrl1Zs GxfavY3FVF5TQSq4CfvSaGrp7l m7sq35Xo KYJUikp1RLSEhFKXkdnmQPnEuzj2Z8wgTzxZHWtVJm4ph

En cambio, éste es el árbol de llamadas para serializar ValueClassFoo:

n35ET8eM7u ZISwbcmBRneFrmlfn0PMCi3pvgilNMYGN4vLVnFUWGfyURTSesas M8N1kwXV5c9qL SMscvd1TrmVSXcPTadlpbo4Eu5rbvDmuZLyIvAPxxyRbkqXz7V3noX1yt9a2uGuB9mGoM rGcDzGSrZ9PUvjFCXPjyN30aINjsHUK1MDg0dZkJwA

Como muestran los dos árboles de llamadas, la creación de una clase especial para la serialización de instancias de ValueClassFoo significa que Jackson no tiene que utilizar la reflexión – un proceso muy caro, computacionalmente hablando – para determinar qué atributos deben ser serializados. Por supuesto, esto viene con la desventaja de tener más código para el desarrollador para mantener, y se romperá tan pronto como los atributos de la clase se modifican.

Deserialización

s45GB5gkTWpDaQeEJGjihZoO5BWG0QfrdqUMUajruHdnt9aeuQ4H6FCrXvreVwZ7DAjWwEzAM9qMDPtWNKmdOj9kDxEpvKapUiCgy5fFznwnISfMmmBVUvOnMn1VMMNp9Oyljk6lbGjtcTLwc5F3R5TSyAaZiO18W2NQGIpvja0DWZCSC 5AKj3oOpIhw

De nuevo, Kotlinx Serialization se comporta claramente mejor en la deserialización en comparación con las tres bibliotecas restantes. GSON, Jackson y Kotlinx Serialization obtuvieron resultados notablemente mejores al deserializar instancias de DefaultValueFoo, y eso se debe presumiblemente al hecho de que había menos datos que leer para la prueba de deserialización – para ese escenario, las bibliotecas tenían que deserializar {"bizz":5,"bazz":["BUZZ","BOZZ"]}, lo que significa un campo menos que analizar.Curiosamente, Jackson lo hizo peor en deserializar ValueClassFoo en comparación con las otras clases de ejemplo. De nuevo usando el perfilador Java Flight Recorder, aquí hay un gráfico de llama para la deserialización de Jackson PojoFoo:

VZWOWjXpXwHm2O1p7i1SYs3J9MK0ky6VNvcJXlx6jJrz66O5IEq8XjN53M z1mXo52P5EziuBjmXGSoOPPQoH4WcbSodQKNPkOOljZ1yHVhLQBJiHXhJEo1It0zlwT53bGnJkhzR6UNkTXNCBfJIqeZeKLdaluuPn4FrR 62fvpdFMPtc3DoSBbyrYK4Gw

Del mismo modo, he aquí un gráfico de llamas para la deserialización de Jackson ValueClassFoo:

gYwE4Kwjd4yWMRWQDlLP kqZJJ1 18rDAs9KUKgoSp2 7PFmKcDUfvLKFFp1Ev

Parece que, en contraste con las acciones de serialización, el deserializador por defecto de Jackson es más rápido que un deserializador manual. Por supuesto, no había opción para hacer esto en el caso de una clase inline: era crear el deserializador personalizado o hacer que el código fallara.

Reflexiones finales

Aunque las pruebas proporcionan resultados prometedores para la biblioteca de serialización Kotlinx, hay algunas advertencias que se deben proporcionar:

  • Las clases de ejemplo eran relativamente simples para reducir la cantidad de variables entre los escenarios de prueba. Llevar a cabo acciones de serialización y deserialización en estructuras de datos grandes y complejas podría proporcionar resultados totalmente diferentes a favor de una biblioteca de serialización diferente.
  • Debido a que el código de serialización de Kotlinx se ha desarrollado para el lenguaje de programación Kotlin, el código escrito en Java tendría que reescribirse en Kotlin para poder utilizar la biblioteca, algo que podría llevar mucho tiempo y ser difícil de vender para un proyecto que tiene una gran base de código escrito en Java. Las otras tres bibliotecas, en cambio, no tienen esa restricción y pueden utilizarse tanto con Java como con Kotlin.

En cualquier caso, los resultados sugieren que los desarrolladores de Kotlin deberían probar la librería Kotlinx Serialization en sus proyectos, ya que aparte de su alto rendimiento, también ofrece la oportunidad de ser una «ventanilla única» para la serialización no sólo de JSON, sino también de otros formatos como Protobuf, YAML, etc.

¿Te interesa leer más contenido sobre Kotlin? No pierdas de vista el blog de Apiumhub; cada semana se publica contenido nuevo de mucha utilidad.  

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Suscríbete a nuestro boletín de noticias

Recibe actualizaciones de los últimos descubrimientos tecnológicos

¿Tienes un proyecto desafiante?

Podemos trabajar juntos

apiumhub software development projects barcelona
Secured By miniOrange