Quantcast
Channel: Adictos al trabajo
Viewing all 996 articles
Browse latest View live

i18n en componentes StencilJS

$
0
0

Índice de contenidos

1. Entorno

Este tutorial está escrito usando el siguiente entorno:

  • Hardware: Slimbook Pro 2 13.3″ (Intel Core i7, 32GB RAM)
  • Sistema Operativo: LUbuntu 18.04
  • StencilJS 0.15.2

2. Introducción

Ya llevo tiempo diciendo que StencilJS me parece la mejor tecnología para la creación de librerías de Web Components 100% nativos y reutilizables en cualquier aplicación web con Angular, Vue, React o ninguno de ellos.

Además, en las últimas versiones han añadido mejoras en el desarrollo como un starter mucho más simple y comprensible que el antiguo clone de su proyecto starter y la generación automática de la documentación de los componentes en función del uso de los decoradores y las anotaciones en JSDoc, entre otras.

Lo que no tienen de serie es una solución estándar para la internacionalización de los componentes desarrollados con esta tecnología; pero como veremos en este tutorial no es complicado hacer la implementación necesaria para el soporte de internacionalización.

3. Vamos al lío

Partimos de que ya tenemos una librería de componentes implementada con StencilJS y queremos que alguno de ellos se pueda internacionalizar. Cada componente será responsable de sus propios ficheros de internaciolización que tendrán extensión .json y el siguiente formato: nombre-componente.i18n.locale.json, por ejemplo, tnt-hello.i18n.es.json, y este fichero contendrá las claves con sus valores de esta forma:

{
    "hello": "Hola, mi nombre es "
}

Y en Inglés:

{
    "hello": "Hello, my name is "
}

A fin de que estos ficheros se distribuyan dentro de una carpeta global “i18n” vamos a modificar el fichero stencil.config.js para copiar todos los ficheros de idiomas de todos los componentes en la carpeta “i18n”, el fichero quedaría de esta forma:

import { Config } from '@stencil/core';

export const config: Config = {
  namespace: 'my-lib',
  outputTargets:[
    { type: 'dist' },
    { type: 'docs' },
    {
      type: 'www',
      serviceWorker: null // disable service workers
    }
  ],
  copy: [{
    src: "**/*.i18n.*.json",
    dest: "assets/i18n"
  }]
};

Una vez preparados los ficheros de idiomas, ahora necesitamos una forma de cargarlos de forma dinámica en función del lenguaje seleccionado. Para ello vamos a crear el fichero ./src/utils/locale.ts donde vamos a establecer una serie de funciones.

La primera de ellas nos va a calcular el lenguaje seleccionado en función de la propiedad lang que esté más cerca de nuestro componente. Es decir, si la propiedad lang está definida a nivel global de la página, cogerá ese locale y si esta definido a nivel de componente cogerá el locale del componente.

function getComponentClosestLanguage(element: HTMLElement): string {
    let closestElement = element.closest('[lang]') as HTMLElement;
    return closestElement ? closestElement.lang : 'en';
}

Ahora vamos a añadir la función encargada de cargar el json adecuado en función del nombre del componente y del locale. Para ello no hacemos uso de ninguna librería, simplemente creamos una promesa con el resultado de la función fetch.

function fetchLocaleStringsForComponent(componentName: string, locale: string): Promise {
    return new Promise((resolve, reject): void => {
        fetch(`/assets/i18n/{componentName}.i18n.${locale}.json`)
            .then((result) => {
                if (result.ok) resolve(result.json());
                else reject();
            }, () => reject());
    });
}

Por último, vamos a exportar una función que se va a encargar de devolver las cadenas del json seleccionado por componente y locale, y en caso de que no exista el locale seleccionado devolver el de Inglés por defecto y mostrar un mensaje de warning en el consola del navegador.

export async function getLocaleComponentStrings(element: HTMLElement): Promise {
    let componentName = element.tagName.toLowerCase();
    let componentLanguage = getComponentClosestLanguage(element);
    let strings;
    try {
        strings = await fetchLocaleStringsForComponent(componentName, componentLanguage);
    } catch (e) {
        console.warn(`no locale for {componentName} (${componentLanguage}) loading default locale en.`);
        strings = await fetchLocaleStringsForComponent(componentName, 'en');
    }
    return strings;
}

Ahora dentro de los componentes de los que queramos hacer uso de la internacionalización, tenemos que cargar las cadenas específicas dentro de la función componentWillLoad() definida en el ciclo de vida del componente. De esta forma nos aseguramos de que el componente no se carga hasta que no se resuelva la promesa de recuperación de los strings a mostrar.

También hacemos uso de @Element para pasar la referencia del componente a la función y definimos un atributo strings donde vamos a almacenar las cadenas a poder utilizar.

import { getLocaleComponentStrings } from '../../utils/locale';
...
@Element() element: HTMLElement;
strings: any;
async componentWillLoad(): Promise {
  this.strings = await getLocaleComponentStrings(this.element);
}

Ahora para utilizar estas cadenas simplemente tenemos que hacer uso del atributo “strings” de esta forma:

render() {
    return (<p>{this.strings.hello} {this.name}</p>);
}

Ahora cuando hagamos uso de nuestro componente, el idioma se ajustará al idioma definido en el HTML donde se incruste de forma automática, y si queremos que tenga un idioma distinto al marcado para la página, podemos especificárselo gracias al atributo lang que existe en todos los elementos HTML de esta forma:

<tnt-hello name="Ruben" lang="es"></tnt-hello>

En caso de no definir ningún atributo lang, ni en el componente ni en la página, se pondrá el idioma por defecto definido en la función “getLocaleComponentStrings”, en nuestro caso, Inglés.

Ahora cuando queramos utilizar esta solución en una aplicación de Angular debemos mapear los ficheros de idiomas para que Angular los pueda tener en cuenta. Para ello editamos el fichero angular.json y añadimos la siguiente línea en la sección de “assets”:

...
"assets": [
  "assets",
  "favicon.ico",
  { "glob": "**/*", "input": "./node_modules/nombre-libreria", "output": "/nombre-libreria/" },
  {"glob": "**/*", "input": "./node_modules/nombre-libreria/dist/collection/assets/i18n", "output": "/i18n/" }
],
...

Haciéndolo de esta forma nuestro componente adoptará por defecto los ficheros de idiomas definidos en la librería de StencilJS, pero si queremos cambiar el texto de un idioma en la aplicación de Angular, solo tenemos que crear la carpeta “i18n” dentro de la carpeta “assets” y copiar el fichero que queremos cambiar respetando el formato nombre-componente.i18n.locale.json

4. Conclusiones

Hemos visto cómo creando unas pocas funciones y sin hacer uso de pesadas librerías podemos añadirle fácilmente el soporte de i18n a nuestros componentes con StencilJS que pueden ser utilizados en cualquier sitio.

Cualquier duda o sugerencia en la zona de comentarios.

Saludos

La entrada i18n en componentes StencilJS se publicó primero en Adictos al trabajo.


Piano Android con Kotlin, MVVM y LiveData

$
0
0

Índice de contenidos

1. Introducción

Con este tutorial podrás construir una aplicación para tablets o móviles que utilicen Android, consistente en un piano de doce teclas que reaccionen al ser pulsadas cambiando visualmente y reproduciendo su sonido. Como lenguaje de desarrollo utilizaremos Kotlin y emplearemos una arquitectura MVVM con LiveData.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: MacBook Pro 17’ (2,66 GHz Intel Core i7, 8GB DDR3)
  • Sistema operativo: macOS Sierra 10.13.6
  • Entorno de desarrollo: Android Studio 3.2
  • Versión mínima Android SDK: 16

3. Arquitectura VMMV y LiveData

La arquitectura que utilizaremos para este tutorial será MVVM. Como MVC y MVP, consta de tres capas, pero guarda diferencias respecto a éstas, por lo que vamos a definirlas:

  • Modelo: representa la capa de datos y la lógica de negocio sin haber diferencias importantes con los otros patrones mencionados.
  • Vista: se encarga de mostrar la información directamente al usuario y es la capa con la que éste interacciona directamente a través de eventos, permitiendo la manipulación de la aplicación y sus datos.
  • View Model o Modelo de Vista: sirve de intermediario entre el modelo y la vista, proporcionando datos a esta última permitiéndole modificarlos. Es ajena a la vista, es decir, no contiene referencias a ésta y, por lo tanto, puede ser reutilizable por varias de ellas. Por tanto, la comunicación con ella se realiza por medio de enlaces de datos o bindings, que, en nuestro caso, será LiveData. Además, una vista puede necesitar de la información proporcionada por más de un View Model.

 

Como vemos, la Vista y el View Model tienen un bajo acoplamiento, lo cual ayuda mucho en la testabilidad al trasladar parte de la lógica al View Model, pero preservando la independencia de esta capa de los elementos de la interfaz de Android.

LiveData es un contenedor de datos asociado a un ciclo de vida de la aplicación. Permite ser observado, por lo que tendremos la seguridad de que al actualizarse las vistas serán notificadas y podrán tratar los cambios como necesiten. Además, al estar asociado al ciclo de vida de un fragmento o actividad, cuando éstos se destruyen, el objeto LiveData también lo hará, previniendo pérdidas de memoria y no siendo necesario el tratamiento manual de su ciclo de vida.

4. Instalación de Android Studio y creación del proyecto

Para desarrollar en Android recomiendo utilizar Android Studio que, como su nombre indica, está pensado para esta tarea. Es gratuito, tiene versiones para macOS, Windows y Linux, y está desarrollado por JetBrains, que son los mismos que desarrollan IntellIJ, así que si has utilizado este entorno te será fácil la adaptación.

Si nunca lo has utilizado, puedes descargarlo de su página oficial e instalarlo como otra aplicación para tu sistema operativo.

Una vez abierto, para crear un proyecto tan sólo tenemos que pulsar en la primera opción de la lista que se nos muestra y configurar nuestro proyecto, incluyendo datos como el nombre de la aplicación. Este tutorial está pensado para Kotlin, por lo que es importante marcar la opción para darle soporte.

Las últimas pantallas nos dejan seleccionar la versión de Android y la plantilla que vamos a utilizar. En nuestro caso indicaremos que nuestra aplicación será para teléfono y tablet, con un SDK mínimo de 16 (API 16: Android 4.1) y utilizaremos la plantilla de actividad vacía.

5. Desarrollando las capas

5.1. El Modelo

Una vez tenemos el proyecto creado, podemos comenzar a desarrollar. Para esto, vamos a crear la capa del modelo, que consiste en una clase que represente las teclas del piano (Key) y otra el piano en general (Keyboard).

Las teclas del piano tendrán dos atributos: pulsed, que indica si la tecla está pulsada o no, y pitch el cual indica los posibles valores de las notas en notación de enteros. Además, la clase Key contará con un método isWhite() que, en función del pitch, indicará si la tecla es blanca o negra.

La notación utilizada asigna a las teclas un valor del 0 al 11, siendo el do un 0, el do sostenido un 1 y el si un 11. Esto elimina la ambigüedad que existe con la denominación más corriente de las notas, donde la tecla del do sostenido también es re bemol. Para el tutorial esto es suficiente, ya que nuestro piano corresponde a una octava, pero si quisiéramos crear uno más grande, deberíamos añadir un atributo que se refiera a la octava de las teclas para poder diferenciarlas.

class Key(val pitch: Int, var pulsed : Boolean = false) {
    fun isWhite(): Boolean = pitch in arrayOf(0, 2, 4, 5, 7, 9, 11)
}

Por otro lado, la clase Keyboard es bastante sencilla, ya que contará con un único atributo: un array de teclas.

class Keyboard(val keys: Array<Key>)

Por último, vamos a crear un objeto Factory para la creación del teclado por defecto, definiendo una clase que se encargue de esta responsabilidad.

class KeyboardFactory {
    fun createDefaultKeyboard(): Keyboard {
        val mutableList = mutableListOf<Key>()
        for (i in 0..11) mutableList.add(Key(i))
        return Keyboard(mutableList.toTypedArray())
    }
}

5.2 El View Model

La responsabilidad de nuestro ViewModel será decidir cuándo hay que actualizar el modelo al pulsar o soltar teclas. Además, será quien contenga el objeto LiveData, el cual en Kotlin deberemos utilizar instanciando la clase MutableLiveData, y el encargado de actualizarlo para proporcionar el teclado a la vista.

Pero antes de implementar el ViewModel, necesitamos importar dos dependencias de livecycle al fichero de build.gradle, el relativo al módulo.

La sección de dependencias deberá contener las dos líneas que se marcan a continuación y después deberemos sincronizar nuestro proyecto:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    implementation "android.arch.lifecycle:extensions:1.1.0"
    implementation "android.arch.lifecycle:viewmodel:1.1.0"
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

Es posible que se encargue al ViewModel pulsar una tecla ya pulsada o soltar una no pulsada. Para evitar actualizar el LiveData cuando no sea necesario, comprobaremos si realmente hay que cambiar algo. Además, vamos a añadir un método estático para crear una instancia asociada al fragmento que se le pase por parámetro.

class KeyboardVM(
   private val keyboard: Keyboard = KeyboardFactory().createDefaultKeyboard(),
   val liveDataKeys: MutableLiveData<Keyboard> = MutableLiveData()
) : ViewModel() {

   init {
       liveDataKeys.postValue(keyboard)
   }

   fun updatePulsedKeys(keys: Array<Key>) {
       val keysToRelease = keyboard.keys.filter { it.pulsed && !keys.contains(it) }
       val keysToPulse = keyboard.keys.filter { !it.pulsed && keys.contains(it) }

       for (key in keysToRelease) keyboard.keys.firstOrNull { it == key }?.pulsed = false
       for (key in keysToPulse) keyboard.keys.firstOrNull { it == key }?.pulsed = true

       liveDataKeys.postValue(keyboard)
   }

   fun releaseKey(key: Key) {
       keyboard.keys.firstOrNull { it == key }?.pulsed = false
       liveDataKeys.postValue(keyboard)
   }


   companion object {
       fun create(fragment: Fragment) = ViewModelProviders.of(fragment).get(KeyboardVM::class.java)
   }
}

5.3. La Vista

Nuestra vista consistirá, en un primer nivel, en un fragmento, ya que la idea es que este piano pueda ser incorporado a proyectos más grandes en caso de ser necesario. Un fragmento de Android representa una parte de una actividad, ya sea un comportamiento o un trozo de la interfaz.

5.3.1. Las vistas de las teclas

Antes de programar nuestro fragmento, vamos a crear las clases necesarias para convertir los objetos de tipo Key en sus representaciones gráficas. Para ello implementaremos una clase denominada KeyView y una KeyViewFactory encargada de dicha conversión. Además, vamos a crear cuatro drawables para pintar los fondos de nuestras teclas y un BackgroundViewManager que se encargue de administrarlos.

La clase KeyView debe heredar de View para que Android pueda tratarla como tal, pero también deberá tener los atributos que tiene Key. Como Kotlin no soporta la herencia múltiple, añadiremos directamente Key como un atributo suyo.

class KeyView(context: Context?, val key: Key) : View(context)

Los drawables los tenemos que definir en unos archivos xml guardados en la carpeta drawable, dentro de res. Para su creación podemos hacer click derecho en esta carpeta y dar a New>Drawable resource file.

Los códigos de los cuatro fondos son los siguientes, teniendo que poner el nombre al crearlo y pegar el código en la pestaña Text (por defecto se puede abrir Design):

key_white_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:shape="rectangle"
   tools:ignore="MissingDefaultResource">
   <solid android:color="@android:color/white" />
   <stroke
       android:width="1dp"
       android:color="#000" />
</shape>

key_white_pulsed_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:shape="rectangle"
   tools:ignore="MissingDefaultResource">
   <gradient
       android:type="linear"
       android:centerX="20%"
       android:startColor="#FFbbbbbb"
       android:centerColor="#FFeeeeee"
       android:endColor="#FFffffff"
       android:angle="90"/>
   <stroke
       android:width="1dp"
       android:color="#000" />
</shape>

key_black_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
   <gradient
       android:type="linear"
       android:centerX="25%"
       android:startColor="#FF777777"
       android:centerColor="#FF333333"
       android:endColor="#FF000000"
       android:angle="90"/>
   <stroke
       android:width="1dp"
       android:color="#000" />
</shape>

key_black_pulsed_background.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
   <gradient
       android:type="linear"
       android:centerX="25%"
       android:startColor="#FF333333"
       android:centerColor="#FF222222"
       android:endColor="#FF000000"
       android:angle="90"/>
   <stroke
       android:width="1dp"
       android:color="#000" />
</shape>

Una vez ya tenemos los drawables, podemos implementar su manager y el KeyViewFactory:

class BackgroundManager(private val resources: Resources?) {
   private fun getDrawable(id: Int) =
       if (resources != null) ResourcesCompat.getDrawable(resources, id, null) else null

   fun setViewBackground(keyView: KeyView) {
       keyView.background = when {
           keyView.key.isWhite() && keyView.key.pulsed -> getDrawable(R.drawable.key_white_pulsed_background)
           keyView.key.isWhite() -> getDrawable(R.drawable.key_white_background)
           keyView.key.pulsed -> getDrawable(R.drawable.key_black_pulsed_background)
           else -> getDrawable(R.drawable.key_black_background)
       }
   }
}

class KeyViewFactory(
   private val context: Context?,
   private val backgroundManager: BackgroundManager = BackgroundManager(context?.resources)
) {

   fun createViews(keys: Array<Key>): Array<KeyView> {
       val views = mutableListOf<KeyView>()

       for (key in keys) {
           val view = KeyView(context, key)
           backgroundManager.setViewBackground(view)
           views.add(view)
       }

       return views.toTypedArray()
   }
}

5.3.2. El layout del piano

El siguiente paso es crear un layout intermedio que sirva para agrupar estas KeyView y controlar sus dimensiones. Es importante que a la hora de añadir las teclas en él añadamos primero las blancas para que las teclas negras queden superpuestas, lo cual hacemos en el método addKeyboard.

class KeyboardLayout(
   context: Context?,
   private val keyViews: MutableList<KeyView> = mutableListOf(),
   private val keyViewFactory: KeyViewFactory = KeyViewFactory(context)
) :
   LinearLayout(context) {

   private val relativeBlackWidth = 0.6 //relative width to white keys width
   private val relativeBlackHeight = 0.6 //relative height to white keys height
   private var keyWidth = 0

   fun clearKeyViews() {
       keyViews.clear()
       this.removeAllViews()
   }

fun addKeyboard(keyboard: Keyboard) {
   val views = keyViewFactory.createViews(keyboard.keys)

   keyViews.addAll(views)
   for (view in views.filter { it.key.isWhite() }) this.addView(view)
   for (view in views.filter { !it.key.isWhite() }) this.addView(view)

   if (keyViews.size > 0) sizeKeys()
}

   override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
       super.onSizeChanged(w, h, oldw, oldh)
       if (keyViews.size > 0) sizeKeys()
   }

   override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
       super.onLayout(changed, l, t, r, b)
       if (keyViews.size > 0) sizeKeys()
   }

   private fun sizeKeys() {
       val whiteKeys = keyViews.filter { it.key.isWhite() }
       keyWidth = if (whiteKeys.isNotEmpty()) width / whiteKeys.size else 0
       var initPosition = 0

       for (view in keyViews) {
           if (view.key.isWhite()) {
               sizeWhiteKey(view, initPosition)
               initPosition++
           } else {
               sizeBlackKey(view, initPosition)
           }
       }
   }

   private fun sizeBlackKey(view: KeyView, initPosition: Int) {
       val left = ((initPosition - relativeBlackWidth / 2) * keyWidth).toInt()
       val right = (left + keyWidth * relativeBlackWidth).toInt()
       val bottom = (height * relativeBlackHeight).toInt()
       view.layout(left, 0, right, bottom)
   }

   private fun sizeWhiteKey(view: KeyView, initPosition: Int) {
       var initPosition1 = initPosition
       val right = if (view == keyViews.last()) width else (initPosition1 + 1) * keyWidth
       view.layout(initPosition1 * keyWidth, 0, right, height)
   }

}

5.3.3. Creación del fragmento

Y ahora vamos a crear el fragmento, sobrescribiendo los métodos necesarios, entre ellos onActivityCreated para observar el LiveData del ViewModel, estableciendo que, cada vez que éste se modifique, se deberá llamar al KeyboardLayout para redibujar las teclas.

class KeyboardFragment : Fragment() {

   private lateinit var keyboardLayout: KeyboardLayout

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
   ): View? {
       keyboardLayout = KeyboardLayout(context)
       return keyboardLayout
   }

   override fun onActivityCreated(savedInstanceState: Bundle?) {
       super.onActivityCreated(savedInstanceState)
       subscribeToVM(KeyboardVM.create(this))
   }

private fun subscribeToVM(viewModel: KeyboardVM) {
   val keyboardObserver = Observer<Keyboard> { keyboard ->
       if (keyboard != null) {
           addKeyboardToLayout(keyboard)
       }
   }
   viewModel.liveDataKeys.observe(this, keyboardObserver)
}

private fun addKeyboardToLayout(keyboard: Keyboard) {
   keyboardLayout.clearKeyViews()
   keyboardLayout.addKeyboard(keyboard)
}

}

Por último, vamos a añadir el fragmento a la actividad, modificando el fichero activity_main.xml que está en res>layout. Pero para hacerlo vamos a necesitar un id único que identifique este fragmento. ¿Y cómo lo conseguimos? Tan sólo necesitamos definirlo en un fichero ids.xml, que crearemos dentro de la carpeta res>values, de forma similar a cuando creamos los drawables.

<?xml version="1.0" encoding="utf-8"?>
<resources>
   <item type="id" name="keyboardFragment"/>
</resources>

Al editar el código de activity_main.xml, que es el código de a continuación, revisa el paquete del fragmento, ya que igual no coincide con el de tu proyecto.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
       xmlns:tools="http://schemas.android.com/tools"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       tools:context=".FragmentExampleActivity">

       <fragment
   android:id="@id/keyboardFragment"
           android:name="com.autentia.keyboardtutorial.view.KeyboardFragment"
           android:layout_width="match_parent"
           android:layout_height="match_parent" />
   </LinearLayout>
</android.support.constraint.ConstraintLayout>

Si ejecutamos nuestra aplicación, veremos cómo se muestran las teclas, pero que no se fuerzan a mostrarse en horizontal. Para solucionar esto, tan sólo tenemos que añadir una línea en el método onCreate de la clase MainActivity para establecer requestedOrientation como landscape:

class MainActivity : AppCompatActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
   }
}

En este punto ya tenemos la representación gráfica del piano, pero todavía nos queda controlar los eventos necesarios para que exista una interacción real con el usuario y que se reproduzcan los sonidos.

6. Creación del Event Handler

Para el tratamiento de los eventos del piano, vamos a crear una nueva clase que guarde las referencias a las KeyView para poder buscar cual está recibiendo un evento y notificárselo al ViewModel.

Los eventos que trataremos serán:

  • Eventos para pulsar las teclas:
    • ACTION_MOVE
    • ACTION_DOWN
    • ACTION_POINTER_DOWN
  • Eventos para soltar las teclas:
    • ACTION_CANCEL
    • ACTION_UP
    • ACTION_POINTER_UP

class KeyboardEventHandler(private val keyboardVM: KeyboardVM) {

    private val keyViews = mutableListOf<KeyView>()

    fun clearKeyViews() {
        keyViews.clear()
    }

    fun setKeyViews(views: Array<KeyView>) {
        keyViews.addAll(views)
    }

    fun handleEvent(event: MotionEvent) {
        when (event.actionMasked) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_MOVE ->
                updatePulsedKeys(event)
            MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL ->
                releaseKey(event)
        }
    }

    private fun releaseKey(event: MotionEvent) {
        val keyView = findKey(event.getX(event.actionIndex), event.getY(event.actionIndex))
        if (keyView != null) keyboardVM.releaseKey(keyView.key)
    }

    private fun updatePulsedKeys(event: MotionEvent) {
        val keys = mutableListOf<Key>()

        val keysWithEvents = findKeysInEvents(event)
        for (view in keysWithEvents) keys.add(view.key)

        if (keys.size > 0) keyboardVM.updatePulsedKeys(keys.toTypedArray())
    }

    private fun findKey(x: Float, y: Float): KeyView? {
        val keysWithBlackFirst = keyViews.sortedBy { it.key.isWhite() }
        return keysWithBlackFirst.firstOrNull { it.left < x && it.right > x && it.top < y && it.bottom > y }
    }

    private fun findKeysInEvents(event: MotionEvent): MutableList<KeyView> {
        val keysWithEvents = mutableListOf<KeyView>()
        for (i in 0 until event.pointerCount) {
            val keyView = findKey(event.getX(i), event.getY(i))
            if (keyView != null) keysWithEvents.add(keyView)
        }
        return keysWithEvents
    }
}

Para acoplar esta clase, debemos cambiar el KeyboardLayout para crearla, sobrescribir el método onTouchEvent y añadir las llamadas al KeyboardEventHandler para que actualice sus KeyView. Además, tenemos que modificar el KeyboardFragment para pasar la referencia del ViewModel al Layout y que éste pueda crear el EventHandler con ella. Ten en cuenta que las clases que se muestran a continuación no se muestran enteras para evitar alargar más el tutorial, por lo que hay que sustituir o añadir los métodos.

class KeyboardLayout(
   context: Context?,
   keyboardVM: KeyboardVM,
   private val keyboardEventHandler: KeyboardEventHandler = KeyboardEventHandler(keyboardVM),
   private val keyViews: MutableList<KeyView> = mutableListOf(),
   private val keyViewFactory: KeyViewFactory = KeyViewFactory(context)
) : LinearLayout(context) {

fun clearKeyViews() {
   keyViews.clear()
   this.removeAllViews()
   this.keyboardEventHandler.clearKeyViews()
}


fun addKeyboard(keyboard: Keyboard) {
   val views = keyViewFactory.createViews(keyboard.keys)

   keyViews.addAll(views)
keyboardEventHandler.setKeyViews(views)
   for (view in views.filter { it.key.isWhite() }) this.addView(view)
   for (view in views.filter { !it.key.isWhite() }) this.addView(view)

   if (keyViews.size > 0) sizeKeys()
}


override fun onTouchEvent(event: MotionEvent?): Boolean {
   if (event != null) keyboardEventHandler.handleEvent(event)
   return true
}

class KeyboardFragment : Fragment() {

   private lateinit var keyboardLayout: KeyboardLayout

   override fun onCreateView(
      inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
   ): View? {
      keyboardLayout = KeyboardLayout(context, KeyboardVM.create(this))
      return keyboardLayout
   }
}

En este punto, si probamos la aplicación podremos ver cómo responde a los eventos correspondientes, actualizándose el teclado.

7. Creación del Sound Manager

El último paso de este tutorial es la creación de las clases necesarias para el tratamiento de los sonidos. Para ello haremos uso de la clase SoundPool que utiliza archivos de sonidos de pequeño tamaño para reproducirlos. Por lo tanto, necesitaremos los doce sonidos del teclado para poder implementar esta parte.

7.1. Los archivos de sonido

Hay varios sitios en internet donde puedes descargarte ficheros de música de forma gratuita, por ejemplo en la página de la Universidad de Iowa. Si eliges esta opción, los ficheros están en formato aiff, por lo que tendrás que convertirlos a wav. Para ello existen herramientas como la proporcionada por esta página.

Cuando tengamos los ficheros, tenemos que agregarlos a nuestro proyecto. Para ello tenemos que crear un directorio de recursos dentro de res, tal y como se indica en las imágenes a continuación. Como nombre y como tipo de recurso selecciona raw y da a OK.

Esto creará una carpeta a la que tendrás que añadir los ficheros wav, por ejemplo utilizando el gestor de archivos de tu sistema. Después, es posible que tengas que actualizar los recursos del proyecto, para lo que tan sólo haz click derecho en res y sincronízala (puede que incluso sea necesario cerrar y volver a abrir el proyecto para que el SoundResManager que vamos a hacer a continuación reconozca el acceso a los archivos).

Respecto al SoundResManager que hemos mencionado a continuación, será la clase que controlará todos estos ficheros, devolviendo un mapa donde las claves son los pitch y el valor el id de los ficheros. A la hora de copiar el código, ten en cuenta que tus ficheros puede que tengan un nombre diferente al que está indicado y que de ser así tendrás que cambiarlo en el fichero o en el código.

class SoundResManager {
   companion object {
       fun getSoundFilesIds() = mapOf (
           0 to R.raw.piano_4c,
           1 to R.raw.piano_4db,
           2 to R.raw.piano_4d,
           3 to R.raw.piano_4eb,
           4 to R.raw.piano_4e,
           5 to R.raw.piano_4f,
           6 to R.raw.piano_4gb,
           7 to R.raw.piano_4g,
           8 to R.raw.piano_4ab,
           9 to R.raw.piano_4a,
           10 to R.raw.piano_4bb,
           11 to R.raw.piano_4b
       )
   }
}

7.2. Administrar los sonidos

Nuestra clase KeyboardSoundPool, contará con los doce sonidos indicados que cargaremos al inicializar la clase y será la encargada de reproducirlos o pararlos. Al cargarlos, se crea un id que guardaremos en una clase que llamaremos Sound junto con el pitch correspondiente. Además, cuando mandemos al SoundPool que reproduzca el sonido, éste devolverá otro id del stream que se crea, devolviendo un 0 si no se logra iniciar. Dicho valor también lo guardaremos en Sound para poder pararlo, junto con un método que comprobará si el sonido está sonando.

class Sound(val id: Int, val pitch: Int, var streamId: Int = 0) {
   fun isPlaying() = streamId != 0
}

Como SoundPool se debe crear utilizando un builder a partir de la API 21, pero nuestra versión mínima es anterior, guardaremos una instancia de esta como atributo del KeyboardSoundPool en vez de aplicar una herencia, inicializando el SoundPool dependiendo de la versión que se esté utilizando.

Cada vez que se actualice el piano en el layout, llamaremos a esta clase para que identifique qué sonidos hay que parar y cuáles iniciar, comparándolos con el array de Sound que tenemos.

class KeyboardSoundPool(context: Context?) {
   private val soundPool: SoundPool
   private val volume = 1.0f
   private val sounds: Array<Sound>

   init {
       val soundsList = mutableListOf<Sound>()
       val soundFilesIds = SoundResManager.getSoundFilesIds()
       soundPool = if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
            val audioAttributes = AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).setUsage(AudioAttributes.USAGE_MEDIA).build()
            SoundPool.Builder().setMaxStreams(soundFilesIds.size).setAudioAttributes(audioAttributes).build()
        } else {
            SoundPool(soundFilesIds.size, AudioManager.STREAM_MUSIC, 0)
        }

       for (fileID in soundFilesIds) {
           val soundId = soundPool.load(context, fileID.value, 1)
           soundsList.add(Sound(soundId, fileID.key))
       }
       sounds = soundsList.toTypedArray()
   }

   fun updateSounds(soundingPitches: Array<Int>) {
       val soundsToStop = sounds.filter { it.isPlaying() && !soundingPitches.contains(it.pitch) }
       val soundsToPlay = sounds.filter { !it.isPlaying() && soundingPitches.contains(it.pitch) }

       for (sound in soundsToStop) stopSound(sound)
       for (sound in soundsToPlay) startSound(sound)
   }

   private fun startSound(sound: Sound) {
       val streamId = soundPool.play(sound.id, volume, volume, 1, 0, 1f)
       sound.streamId = streamId

   }

   private fun stopSound(sound: Sound) {
       soundPool.stop(sound.streamId)
       sound.streamId = 0
   }
}

Por último, tan sólo nos queda modificar el KeyboardLayout para inicializar el KeyboardSoundPool y para llamar a updateSounds cuando es necesario:

class KeyboardLayout(
   context: Context?,
   keyboardVM: KeyboardVM,
   private val keyboardEventHandler: KeyboardEventHandler = KeyboardEventHandler(keyboardVM),
   private val keyViews: MutableList<KeyView> = mutableListOf(),
   private val keyViewFactory: KeyViewFactory = KeyViewFactory(context),
   private val keyboardSoundPool: KeyboardSoundPool = KeyboardSoundPool(context)
) : LinearLayout(context) {


fun addKeyboard(keyboard: Keyboard) {
   val views = keyViewFactory.createViews(keyboard.keys)

   keyViews.addAll(views)
   keyboardSoundPool.updateSounds(keyboard.keys.filter { it.pulsed }.map { it.pitch }.toTypedArray())
   keyboardEventHandler.setKeyViews(views)
   for (view in views.filter { it.key.isWhite() }) this.addView(view)
   for (view in views.filter { !it.key.isWhite() }) this.addView(view)

   if (keyViews.size > 0) sizeKeys()
}

Y, ahora sí, ya tenemos un piano para Android utilizando una arquitectura MVVM que reacciona a los eventos de pulsar y soltar las teclas, actualizando tanto su representación gráfica como los sonidos que se reproducen.

¡Muchas gracias por haber seguido este tutorial!

8. Referencias

 

La entrada Piano Android con Kotlin, MVVM y LiveData se publicó primero en Adictos al trabajo.

Reutilizando web components generados por Stencil

$
0
0

 

Índice de contenidos

 

1. Introducción

En este tutorial vamos a ver con un ejemplo práctico cómo implementar un componente con Stencil y posteriormente su integración  en una aplicación sin framework y en otra con ReactJS.

Recordemos que Stencil es un compilador (no pretende ser un framework ni una librería web) que nos va a permitir obtener web components a partir de componentes implementados con las APIs que nos ofrece. Estas APIs, Stencil las ha diseñado siguiendo los mejores conceptos de otros frameworks JavaScript (sobre todo de React Fiber la forma de renderizar y de Angular la estructura y la sintaxis con decoradores). Para una introducción a Stencil recomiendo el tutorial Introducción a StencilJS y demo de integración con Angular

El ejemplo que vamos a realizar es muy sencillo pero nos va a servir para ver algunas características de la herramienta y como hace la “magia” de generarnos componentes 100% reutilizables.  

El ejemplo consiste en implementar un componente, le he llamado color-picker, que nos permita seleccionar un color. Sus características son:

  • Permite que se le asigne un valor de un color inicial (sino el propio componente tiene su valor por defecto).
  • Es resetteable. Una vez seleccionado un color podemos restaurar, a través de un botón, su valor inicial.
  • Muestra el código del color seleccionado.

 

Posteriormente integraremos el color-picker, generado como web component gracias a Stencil, en una app sin framework y en otra con React. En las dos apps se utilizará el componente para cambiarle el color a un texto. El resultado final sería este:

 

​2. Entorno

Este tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil Macbook Pro 15″ (2.5 GHz Intel Core i7, 16GB DDR3)
  • Sistema Operativo: Mac OS Mojave
  • IDE: Visual Studio Code Versión 1.29.1
  • Nodejs 10.12.0
  • React 16.6

 

​3. Implementando el color-picker con Stencil

Lo primero que vamos a hacer es crear el proyecto:

npm init stencil

Nos salen tres opciones de tipos de proyecto. Vamos a seleccionar la opción ‘component’. Como nombre del proyecto pondremos my-components. Después ejecutamos:

cd my-components
npm install

 

La estructura del proyecto que nos ha creado Stencil es la siguiente:

 

Dentro de la carpeta components es donde vamos a crear todos nuestros componentes. Como vemos ya se ha creado uno por defecto. Vamos a eliminarlo y comenzar a crear nuestro color-picker desde cero.

Dentro de components creamos la carpeta color-picker y dentro de ésta creamos los ficheros color-picker.css y color-picker.tsx.

color-picker.css

.color-picker {
  border: 2px solid #afafaf;
  display: inline-flex;
  max-width: 175px;
  align-items: center;
  padding: 0;
  margin: 0;
}

.color-picker input {
  cursor: pointer;
  border-width: 0;
  width: 50px;
  height: 30px;
}

.color-picker span {
  padding: 0 10px 0 10px;
  width: 70px;
  text-transform: uppercase;
  color: #afafaf;
}

.color-picker button {
  border: none;
  border-left: 1px solid #afafaf;
  font-size: 0.9em;
  width: 30px;
  height: 25px;
}

 

color-picker.tsx

import { Component, Prop, State, Event, EventEmitter } from '@stencil/core';

@Component({
  tag: 'color-picker',
  styleUrl: 'color-picker.css'
})
export class ColorPicker {

  @Prop() defaultValue: string = "#ff0000";
  @Prop() resettable: boolean = false;

  @State() value: string;

  @Event() colorChanged: EventEmitter;

  componentWillLoad() {
    this.setValue(this.defaultValue);
  }

  handleChange(event) {
    this.setValue(event.target.value);
  }

  setValue(value) {
    this.value = value;
    this.colorChanged.emit(this.value);
  }

  reset() {
    this.setValue(this.defaultValue);
  }

  renderResetButton() {
    if (this.resettable) {
      return <button onClick={() => this.reset()}>X</button>;
    }
  }

  render() { 
    return (
      <div class="color-picker">
        <input type="color" value={this.value} onChange={(ev) => this.handleChange(ev)} />
        <span>{this.value}</span>
        { this.renderResetButton() }
     </div>
    );
  }
}

 

Básicamente el color-picker es un wrapper de un input nativo html de tipo ‘color’, que además le añade las características comentadas en la introducción.

Las partes del componente son:

  • Decorador @Component, donde especificamos el nombre del componente y su fichero de estilos.
  • Las propiedades que se van a exponer hacia fuera, defaultValue (color por defecto) y resettable (Indica si el componente puede ser reiniciado). Estas propiedades por defecto son inmutables desde dentro del componente. Para hacerlas mutables hay que anotarlas con @Prop({ mutable: true })
  • Decoramos con @State los atributos internos del componente que van a ser modificados en su lógica, como en nuestro caso el atributo value.
  • El event emmitter, es el encargado de, una vez seleccionado el color, se emita un evento de tipo ‘colorChanged’ hacia fuera. Esto va a permitir que clientes del componente, suscritos al evento, sean notificados cada vez que cambie el color.
  • El método render, que devuelve la descripción necesaria para pintar el componente. Como se puede ver esta escrito con la sintaxis JSX. Es un concepto heredado de React que permite escribir la parte visual del componente declarativamente.

 

Para probar lo que hemos hecho vamos a usar el componente en la misma aplicación de Stencil. En el fichero index.html eliminamos el componente <my-component> y añadimos el color-picker:

<color-picker default-value = "#0000FF" resettable></color-picker>

 

La pagina index.html quedaría así:

<!DOCTYPE html>
<html dir="ltr" lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0">
    <title>Stencil Component Starter</title>
    <script src="/build/mycomponent.js"></script>
  </head>
  <body>
    <color-picker default-value = "#0000FF" resettable></color-picker>
  </body>
</html>

 

Ejecutamos npm start y si todo ha ido bien veremos el color-picker en ejecución!

Generando la distribución

Ahora que ya tenemos el componente listo, vamos a compilarlo y generar el empaquetado final que usarán las aplicaciones clientes.

El proyecto que hemos creado ya viene con el package.json y el stencil.config.ts preparado para generarnos la compilación en la carpeta ‘dist’. Solo vamos a cambiar el stencil.config.ts el namespace por my-components. Ejecutamos:

npm run build

Esto nos genera una carpeta dist con el siguiente contenido:

 

Para reutilizar el componente en otra app lo correcto sería publicar la librería generada en un repositorio npm. Para el ejemplo actual lo que haremos es copiar directamente la carpeta dist y pegarla en las apps clientes que vamos a ver a continuación.

 

​4. Integración en un proyecto sin framework

Como lo generado por Stencil es un web component estándar, éste puede ser usado en cualquier tipo de aplicación, use framework JavaScript o no.

En este caso vamos a ver su integración en una app sin framework.

Vamos a crear una carpeta color-picker-demo, dentro nos creamos un fichero index.html donde vamos a poner la referencia a la librería que nos generó Stencil que incluye al color-picker. Copiamos la carpeta dist en la carpeta color-picker-demo.

El fichero index.html quedaría así:

<!DOCTYPE html>
<html lang="en">
  <head> 
    <script src="./dist/my-components.js"></script>
    <style type="text/css">
      body {
        font-family: "Trebuchet MS", Helvetica, sans-serif;
     }
   
     div {
       position: relative;
       margin: auto;
       width: 50%;
       padding: 40px;
     }

     p {
       font-size: 3.6em;
     }

    </style>
  </head>
  <body>

    <div>
      <color-picker default-value = "#0000FF" resettable></color-picker>
      <p>Stencil is a great <b>Tool</b></p>
    </div>

    <script>
      const colorPicker = document.querySelector('color-picker');

      colorPicker.addEventListener("colorChanged", function(e) {
        document.querySelector("p").style.color = e.detail;
      }); 
    </script>

  </body>
</html>

 

Lo que hemos hecho es usar el color-picker para darle color a un texto que tenemos en un elemento <p>.

Le hemos asignado un valor (#0000FF) inicial y le hemos dicho que lo queremos resetteable. La suscripción al evento ‘colorChanged la hemos hecho desde JavaScript.

5. Integración en un proyecto con ReactJS

Veamos ahora la integración en un proyecto con React.

Creamos el proyecto con create-react-app

npx create-react-app color-picker-demo

Como no tenemos subido la librería de componentes en ningún repositorio npm lo que haremos es copiar la carpeta dist generada, directamente en una carpeta my-components dentro de node-modules.

Para incluir la librería de componentes en el proyecto React editamos el fichero index.js de nuestro proyecto React creado con create-react-app para añadir el import y la llamada a defineCustomElements(window). El fichero quedaría así:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

import { defineCustomElements } from 'my-components/dist/loader';

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();
defineCustomElements(window);

 

Modificamos el fichero App.js para utilizar nuestro color-picker.

App.js

import ReactDOM from 'react-dom';
import Greeting from './Greeting';
import Clock from './Clock';

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {color: ''};
    this.handleColorChanged = this.handleColorChanged.bind(this);
  }

  handleColorChanged(e) {
    this.setState({
      color: e.detail
    });
  }

  componentDidMount() {
    this.refs['colorPicker'].addEventListener('colorChanged', this.handleColorChanged);
  }

  render() {
    return (
      <div className="App">
        <color-picker ref="colorPicker" default-value="#0000ff" resetable />
        <p style={{ color: this.state.color }}>Stencil is a great <b>Tool</b></p>
      </div>
   );
  }
}

export default App;

Solo aclarar que para suscribir el listener  al evento del color picker es necesario hacerlo directamente a través de la referencia react al web component:

this.refs['colorPicker'].addEventListener('colorChanged', this.handleColorChanged);

Añadimos algo de estilo para darle más tamaño al texto:

App.css

.App {
  position: relative;
  margin: auto;
  width: 50%;
  padding: 40px;
}

p {
  font-family: "Trebuchet MS", Helvetica, sans-serif;
  font-size: 3.6em;
}

Ejecutamos el proyecto:

npm start

Y tenemos nuestro color-picker funcionando en una app con React:

6. Integración en un proyecto con Angular

Veamos ahora la integración en un proyecto con Angular.

Creamos el proyecto con @angular/cli

ng new ng-color-picker-demo

Como no tenemos subido la librería de componentes en ningún repositorio npm podemos copiar la carpeta dist generada, directamente en una carpeta my-components dentro de node-modules o también podemos instalar la dependencia de forma local, ejecutando

npm install --save path/my-components

Ahora editamos el fichero angular.json para incluir nuestra librería en la sección de assets y que pueda estar accesible desde el index.html.

...
polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": [
    "src/favicon.ico",
    "src/assets",
    { "glob": "**/*", "input": "./node_modules/my-components", "output": "/my-components/" }
],
"styles": [
     "src/styles.css"
],
...

Editamos el fichero index.html para añadir la referencia a nuestra librería del “head”:

De esta forma podemos utilizar nuestra librería tanto dentro como fuera del ámbito de Angular. Pero si la queremos utilizarla dentro del ámbito de Angular, tenemos que indicarle al framwork que estamos utilizando etiquetas que el no conoce y que, por favor, lo admita y no muestre errores. Para hacer esto dentro del AppModule de la aplicación añadimos el siguiente schema:

import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent],
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule { }

Ahora podemos editar el fichero app.component.html para añadir nuestro color-picker el cuál puede ser tratado como un componente de Angular más donde los inputs se bindean con [] y los eventos se manejan con ().

Siendo “defaultColor” un atributo del componente AppComponent y la función “handleColorChanged” un método de dicho componente que se encargará de gestionar el evento colorChanged. Es imprescindible que la información se pase con $event y en el detail de ese objeto viaja lo que se emite desde el componente de StencilJS.

7. Conclusiones

Con este sencillo ejemplo has podido ver como haciendo uso de Stencil podemos crear componentes rápidamente utilizando una serie de conceptos y técnicas que ya implementan otros frameworks JavaScript y posteriormente generar web components completamente reutilizables en cualquier tipo de aplicación.

 

8. Referencias

 

La entrada Reutilizando web components generados por Stencil se publicó primero en Adictos al trabajo.

Creando mi primera aplicación con Micronaut

$
0
0

Índice de contenidos

  1. Entorno
  2. Introducción
  3. Instalación de Micronaut
  4. Creando un servicio con Micronaut
  5. Conclusiones

1. Entorno

  • Hardware: Portátil MacBook Pro 15 pulgadas (2.5 GHz Intel i7, 16GB 1600 Mhz DDR3, 500GB Flash Storage).
  • Sistema Operativo: MacOs Mojave 10.14.2
  • SDKMAN! para la instalación de Micronaut
  • Micronaut 1.0.2

2. Introducción

Micronaut es un framework JVM que nos permite crear nuestras aplicaciones basadas en microservicios de una manera sencilla y rápida. Además de Java, Micronaut permite el uso de otros lenguajes como Groovy y Kotlin.

Las principales características de Micronaut con las siguientes:

  • Inyección de dependencias en tiempo de compilación en vez de en ejecución. Esto consigue que el despliegue de las aplicaciones sea muy rápido y un uso bajo de memoria.
  • Desarrollo rápido y sencillo de microservicios, basándose en anotaciones al igual que Spring MVC.
  • Programación reactiva a través de RxJava y ProjectReactor.
  • Framework ideal para el desarrollo de aplicaciones cloud nativas, ya que soporta el uso de herramientas para el descubrimiento de servicios, como Eureka y Consul, y sistemas distribuidos como Zipkin y Jaeger.
  • Crear tests para los servicios y clientes implementados de manera rápida y sencilla.

3. Instalación de Micronaut

Nosotros vamos a realizar la instalación a través de SDKMAN!, un manager que nos permite instalar distintas herramientas de desarrollo, ya que la guía oficial de Micronaut recomienda su instalación a través de él. Su instalación puedes seguirla en su web. Notar que en el caso de que no se quiera usar SDKMAN! también puedes descargarte los binarios de Micronaut desde la web oficial.

Una vez instalado SDKMAN!, ejecutamos el siguiente comando para instalar Micronaut:

sdk install micronaut

Este comando nos instala todas las dependencias de micronaut, incluido su CLI que nos permitirá crear aplicaciones base o ejecutar comandos:

4. Creando un servicio con Micronaut

Para crear un proyecto base con Micronaut hay que ejecutar el siguiente comando:

mn create-app hello-micronaut -build maven

Notar que hay que especificar con build que la gestión de dependencias sea con Maven (por defecto es Gradle).

Ya tenemos nuestro proyecto creado dentro del directorio hello-micronaut:

La clase Application es la que se utiliza para el despliegue de la aplicación desde el IDE o por línea de comandos desplegando por defecto en el puerto 8080:

package hello.micronaut;
import io.micronaut.runtime.Micronaut;
public class Application {
    public static void main(String[] args) {
        Micronaut.run(Application.class);
    }
}

A continuación, crearemos un controlador, dentro del paquete hello.micronaut, para un nuevo punto de API que responda con un mensaje de saludo personalizado:

package hello.micronaut;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;

@Controller("/hello")
public class HelloController {
    @Get("/{name}")
    @Produces(MediaType.TEXT_PLAIN)
    public String helloWorld(String name) {
        return "Hello " + name;
    }
}

Como se puede ver, las anotaciones utilizadas son similares a las de Spring MVC. Si redesplegamos el servicio y hacemos una llamada a http://localhost:8080/hello/Jon recibiremos la siguiente respuesta:

Hello Jon

Y así de sencillo y rápido ya tenemos creada nuestra primera aplicación en Micronaut 🙂

5. Conclusiones

Las arquitecturas basadas en microservicios son el futuro y los frameworks para su desarrollo cada vez son más. Micronaut es una alternativa a Spring Boot que nos permite crear nuestros servicios de manera rápida y sencilla también basándonos en anotaciones. Este tutorial son unos primeros pasos, en los próximos ahondaremos en la gestión de dependencias, programación reactiva y en cómo migrar un servicio creado con Spring Boot a Micronaut.

La entrada Creando mi primera aplicación con Micronaut se publicó primero en Adictos al trabajo.

Kanban from the Inside, el libro

$
0
0

Introducción

En esta ocasión toca hacer la review de Kanban from the Inside: Understand the Kanban Method, connect it to what you already know, introduce it with impact de Mike Burrows; es un libro en el que se hace un repaso exhaustivo al Método Kanban a través de sus 9 valores.

En la actualidad el libro se puede encontrar en versión impresa así como en digital en las diferentes plataformas de venta y sólo se encuentra disponible en inglés.

Inicialmente se introduce el concepto del Método Kanban, te pone en contexto y lo relaciona con otro modelos. De hecho incluso se indican unos pasos para la implantación del Método Kanban en la organización. El libro va más allá del tablero kanban y sus post it a modo de pinta y colorea y profundiza en la implementación paso a paso a aplicar para que sea un éxito.

La lectura se divide en 3 partes:

  1. Kanban a través de sus valores
  2. Modelos
  3. Implementación, que conforman 23 capítulos en total.

Cabe destacar las referencias a herramientas y comunidades con las que mantener el contacto y poder conocer más casos prácticos del Método Kanban que los que se incluyen en el libro.

Público

La audiencia a la que está orientada el libro es para CIOs y CEOs de empresas, Managers de Negocio, Jefes de proyecto, así como cualquier otro tipo de individuo que no esté familiarizado con el Método Kanban pero que esté buscando un nuevo método o enfoque para gestionar proyectos y equipos, sobre todo permite un profundo entendimiento del Método para aplicarlo en diferentes situaciones.

Aquellos usuarios con un trasfondo en el desarrollo de software o que estén familiarizados con los conceptos Lean o Agile pueden tener una lectura mucho más fácil debido a que la terminología y que la comprensión de los conceptos se hará más sencilla, aunque no es un prerrequisito.

Contenido

Como se ha mencionado anteriormente en la introducción, Kanban from the Inside comprende 3 partes que en total abarca 23 capítulos. Un lector ávido de conocimiento puede encontrar en esta lectura respuestas a preguntas como:

  • ¿Qué es Kanban?
  • ¿Cuándo debería plantearme trabajar con Kanban?
  • ¿Cómo empezar con el Método Kanban?
  • ¿Dónde se puede utilizar Kanban?

 

Desglosando el libro tenemos la siguiente división:

Parte I Kanban a través de sus valores

Se nos presenta Kanban a través de sus 9 valores: Transparencia, Equilibrio, Colaboración, Enfoque al Cliente, Flujo, Liderazgo, Entendimiento, Acuerdo y Respeto. Además, en este Bloque I se describen los conceptos de Patrones y las Agendas.

Parte II Modelos

En esta segunda parte se nos presenta de forma más detallada el método Kanban, los Sistemas de Pensamiento (Systems Thinking), la Teoría de Restricciones (TOC), otros modelos de la Agilidad como son Feature Driven Development (FDD), Extreme Programming (XP) y Scrum y cómo se pueden integrar con Kanban, el Toyota Production System (TPS), se dan algunos enfoques económicos y una serie de herramientas para Coach.

Parte III Implementación

La última parte se centra en complementar las 2 partes anteriores dando detalles para la implementación del Método Kanban: entendiendo las fuentes de insatisfacción, el análisis de la demanda y la capacidad, modelando el flujo de trabajo, descubriendo las Clases de Servicio, diseñando sistemas kanban y por último desplegándolo.

Todo ello se comunica a través de ejemplos prácticos de situaciones reales, llevando a cabo discusiones, planteando preguntas al lector para su razonamiento y análisis de forma que permita reforzar el aprendizaje y entendimiento de los conceptos explicados.

La lectura se hace amena debido al gran trabajo realizado en la redacción abreviada del contenido y la utilización de referencias a lecturas externas para complementar la información aportada, de forma que sin salirse del guión se explican los conceptos de forma concisa y deja a opción del lector el cumplimentar más en profundidad cada uno de ellos.

Es de agradecer todas las referencias y lecturas que se ofrecen para cada uno de los temas que se tratan, si bien profundizar en ellos hace pensar que el libro podría abarcar perfectamente el doble de su contenido actual y no es algo que lo penalice sino que se valora la capacidad de síntesis realizada.

Cómpralo en Amazon:

Conclusión

En mi opinión es uno de los mejores libros en la actualidad que explican el Método Kanban de una forma sencilla y directa, orientado para todos los públicos y muy accesible para que los principiantes puedan entender, de una forma en cierta medida práctica, el significado del Método Kanban a partir de sus principios y prácticas.

Sin embargo, los lectores más experimentados pueden tenerlo como referencia ya que puede aportar nuevas perspectivas a los 9 valores del Método Kanban y probablemente te dejará con ganas de ampliar más conocimientos sobre los temas expuestos. Además, las herramientas propuestas pueden ser novedosas y aportar utilidad para la práctica del mismo.

En cualquier caso si has intentado implantar el Método Kanban en tu empresa y te has visto en apuros, sin duda alguna, el libro te ayudará a realizar dicho trabajo de una forma más clarificada.

La entrada Kanban from the Inside, el libro se publicó primero en Adictos al trabajo.

Crear anotación con validación basada en JSR-380 y serialización personalizada

$
0
0

Índice de contenidos

1. Introducción

Como se define en el propio proyecto, Jackson es un conjunto de herramientas de procesamiento de datos para Java, entre las que incluye su conocido parseador a JSON, esta librería serializa/deserializa entre POJO y JSON. Su uso está tan extendido que lo incorporan multitud de proyectos como dependencia, entre ellos el conocido framework spring.

Por otro lado haremos uso de la JSR (Java Specification Requests), que nos permite mediante anotaciones validar cómodamente nuestros bean en Java.

En este tutorial vamos a suponer que se domina el uso básico de ambas tecnologías, así que nos centraremos en el caso en el cual necesitamos validar un objeto de nuestro dominio siguiendo una lógica particular, es decir, no nos sirven los validadores incluidos por defecto (notNull, notEmpty, ..) y además deseamos aplicar una serialización personalizada. Este caso es bastante común si por ejemplo queremos ofuscar datos sensibles o aplicar cualquier tipo de máscara en la serialización a JSON de un bean.

A pesar de que en el tutorial tratamos la creación de un serializador personalizado si se quiere crear un deserializador sería similar solo que para este último deberíamos extender a JsonDeserialize.

2. Entorno

  • El tutorial está escrito usando el siguiente entorno:
  • Hardware: Portátil MacBook Pro 15′ (2.5 GHz Intel Core i7, 16GB DDR3).
  • Sistema Operativo: Mac OS High Sierra 10.13.6
  • Oracle Java: 1.8.0_172

3. Creamos el validador

En este caso nuestro validador simplemente hace uso de un método de utilidad que nos comprueba que el campo dni es válido.

public class DniValidator implements ConstraintValidator {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        DniCheck dniCheck = new DniCheck();
        return dniCheck.isValid(value);
    }
}

4. Creamos el serializador

Por motivos de seguridad deseamos que por defecto cuando se serialize un dni se transforme todo a asteriscos.

public class DniJsonSerializer extends JsonSerializer {

    private static final String PATTERN_DNI = "w";
    private static final String ASTERISK = "*";

    @Override
    public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (value != null) {
            final String valueString = String.valueOf(value);
            gen.writeString(valueString.replaceAll(PATTERN_DNI, ASTERISK));
        }
    }
}

5. Formas de aplicar nuestro serializador

A la hora de emplear un serializador tenemos dos opciones, una básica creando nuestro serializador y la anotación asociada la cual se podrá aplicar a nivel de clase o atributo y una opción mas personalizable extendiendo a la clase SimpleModule, esta última se emplea para casos en los que queremos algo más completo como por ejemplo pasar parámetros a nuestra anotación.

5.1. Básica

Si optamos por esta opción solo añadimos la siguiente clase, donde se puede ver que empleamos la anotación @JacksonAnnotationsInside la cual nos permite el uso de meta-anotaciones con Jackson. En nuestro caso para ganar en legibilidad hemos creado una anotación @Dni la cual aplica nuestro serializador custom.

Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = DniSerializer.class)
public @interface Dni {
}

5.2. Personalizable

Si queremos ir un paso más allá y no solo aplicar un serializador personalizado a dni sino que deseamos tener una anotación propia que reciba parámetros, como en nuestro caso con el parámetro obfuscate debemos usar esta otra forma.

En primer lugar nos crearemos una clase en la que extendemos BeanSerializerModifier y sobreescribimos el método changeProperties que nos permitirá cambiar en tiempo de ejecución el serializador que se emplea.

public class DniBeanSerializationModifier extends BeanSerializerModifier {

    @Override
    public List changeProperties(SerializationConfig config, BeanDescription beanDesc,
                                                     List beanProperties) {
        final int size = beanProperties.size();
        for (int i = 0; i &lt; size; i++) {
            final BeanPropertyWriter writer = beanProperties.get(i);
            if (writer.getAnnotation(Dni.class) != null &amp;&amp; writer.getAnnotation(Dni.class).obfuscated()) {
                writer.assignSerializer(new DniJsonSerializer());
            }
        }
        return beanProperties;
    }
}

En el código anterior se recorren los campos de la clase que se está serializando, si el campo lleva la notación @Dni y además está activada la opción de obfuscated de la misma, se le aplica el DniJsonSerializer creado anteriormente.

Seguidamente nos creamos un módulo el cual extienda a SimpleModule para así registrar nuestro nuevo componente de serialización.

@Component
public class DniModule extends SimpleModule {

    @Autowired
    public DniModule(DniBeanSerializationModifier dniBeanSerializationModifier) {
        setSerializerModifier(dniBeanSerializationModifier);
    }
}

Por último crearemos la anotación la cual aplica nuestro validador DniValidator y además cuenta con la opción activar o desactivar nuestro serializador de ofuscado.

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DniValidator.class)
@Inherited
@Documented
public @interface Dni {
  boolean obfuscated() default true;
  String message() default &quot;{code.error}&quot;;
  Class[] groups() default {};
  Class[] payload() default {};
}

Los otros tres parámetros son comunes a la hora de crear anotaciones de validación:

Mesagge : atributo que devuelve la clave por defecto para crear mensajes de error.

Groups : grupo de atributos que permiten especificar a qué grupos de validación pertenece nuestra restricción.

Payload : este atributo puede ser usado por los clientes de la API para añadir su propio payload personalizado a la restricción.

6. Aplicando nuestra anotación

Si deseamos aplicar nuestra anotación simplemente debemos marcar el campo en nuestro pojo a serializar de la siguiente manera.

public class Person {
   private String name;
   private Integer age;
   @Dni(obfuscated = true)
   private String dni;
}

7. JSON resultante

Así sería nuestro json obtenido al serializar un objeto persona el cual cuenta entre sus atributos con un dni.

{
   "name":"John Doe",
   "age":30,
   "dni":"*********"
}

8. Conclusiones

El uso de jackson para serializar/deserializar a json está tan extendido que se podría considerar el estándar de facto en Java por lo que es una librería conocida pero existen usos no básicos como el anterior que necesitan darle una vuelta. Con este tutorial pretendemos dar una solución más o menos organizada a este tipo de casos.

En resumen, hemos visto cómo crear nuestro propio serializador y dos formas de emplearlo, directamente aplicado a un tipo o basado en una anotación que nos da la posibilidad de añadir opciones a la misma.

9. Referencias

Jackson Annotation Examples

La entrada Crear anotación con validación basada en JSR-380 y serialización personalizada se publicó primero en Adictos al trabajo.

Scrum PLoP

$
0
0

Scrum PLoP

PLoP es el acrónimo de Pattern Languages of Programs que podría traducirse como Plan de Acción. Pero, ¿qué es Scrum PLoP?.

Pattern Languages of Programs es el nombre de un grupo de conferencias anuales patrocinadas por The Hillside Group. Estas conferencias reúnen a las comunidades de escritores de Patrones durante un corto periodo de tiempo. Cabe destacar que son eventos sin ánimo de lucro.

El propósito de estas conferencias es desarrollar y refinar el arte de los patrones de diseño de software, o lo que es lo mismo revisar y refinar la bibliografía que generan las comunidades de prácticas.

La mayor parte del esfuerzo se centra en desarrollar una presentación textual de un patrón que sea fácil de entender y aplicar. Esto normalmente se hace a través de un taller (workshop).

Por lo tanto Scrum PLoP se define como una comunidad que con el paso del tiempo ha llegado a ser estable reuniendo a una serie de escritores de patrones comprometidos a largo plazo.

¿Por qué surge?

PLoP surge en parte porque los programadores del día a día se toparon con que las conferencias creadas para ellos no les proporcionaban un foro en el que compartir las grandes ideas que se les ocurrían y que hacen que programar sea una diversión. Además, de que estas mismas conferencias empezaron a publicar ideas que eran cada vez más irrelevantes.

Scrum PLoP les facilita un foro donde compartir ideas a través del diálogo y una abundante bibliografía en la que contribuye la comunidad en su construcción y evolución.

¿Quiénes son?

Scrum PLoP no fue fundada por un organismo de expertos o está patrocinada por ninguna gran marca, sino que en su base fue un movimiento que surgió por la comunidad de desarrolladores de software.

¿Dónde se realiza?

Alrededor del mundo. Desde 1994 que empezó el movimiento se ha desarrollado a lo largo y ancho del globo terráqueo. La siguiente cita será del 12 al 16 de Mayo en Corral da Pacheca (Portugal).

¿Cómo son?

Al igual que un Open Space, se basa en el respeto mutuo y la confianza, disponiendo de buena comida, así como sesiones de animación con los que distraerse entre los momentos de más carga de trabajo. Algunas personas incluso se quedan hasta la noche realizando alguna dinámica divertida.

¿Qué se obtiene?

Se obtiene un documento elaborado por los desarrolladores de software, que están al pie del cañón, en el que se detalla todo aquello que las prácticas Scrum no han mencionado.

Este documento tiene un código de colores que representan:

  • Verde: Patrón publicado
  • Amarillo: Patrón en progreso
  • Rojo: Patrón que necesita que se le preste atención
  • Turquesa: Referencia externa a un patrón fuera de la publicación

 

El formato de la presentación es: Nombre del Patrón, explicación de una situación adecuada a su uso, Autor y Fecha.

Al hacer click sobre uno de ellos nos da una explicación más detallada de cada patrón, veamos un ejemplo haciendo click sobre Estimation Points:

Sin más, se suministra el documento oficial actualizado a fecha de hoy con los Patrones proporcionados por la comunidad. Además, si conoces alguna práctica que funcione y no esté incluida en ella puedes animarte a cederla a la comunidad de Scrum PLoP.

Pattern Spread Sheet

A continuación, se menciona un listado con algunos de los Patrones que se incluyeron en el documento en 2013 cuyo autor es Jeff Sutherland y que son ampliamente conocidos por la comunidad, ya que permiten tener un buen arranque del proyecto además de conseguir equipos hiperproductivos:

Antes de finalizar me gustaría indicar la enorme utilidad y valor que aporta este documento, ya que recoge decenas de patrones con ideas reflexionadas basadas en Scrum y que la comunidad ha demostrado que funcionan de forma empírica.

Por último, indicar que en la actualidad desde Scrum PLoP están trabajando por sacar adelante un libro que reúna todos estos Patrones de gran utilidad para la comunidad de desarrolladores. Esperemos verlo pronto finalizado, mientras tanto nosotros utilizamos la plantilla de Patrones.

La entrada Scrum PLoP se publicó primero en Adictos al trabajo.

Creando una API reactiva con RxJava y Micronaut

$
0
0

Índice de contenidos

  1. Introducción
  2. Entorno
  3. Programación reactiva con Micronaut
  4. Implementación de API reactiva con RxJava

1. Introducción

En el tutorial anterior, “Creando mi primera aplicación con Micronaut”, vimos las características principales de este nuevo framework y lo sencillo que era crear nuestras aplicaciones basadas en microservicios.

En este tutorial nos vamos a centrar en cómo Micronaut permite crear nuestras APIs de manera reactiva consiguiendo así reducir los tiempos de espera, funcionamiento óptimo ante cargas elevadas en el sistema o  más robustez ante fallos o caídas en nuestras aplicaciones.

2. Entorno

  • Hardware: Portátil MacBook Pro 15 pulgadas (2.5 GHz Intel i7, 16GB 1600 Mhz DDR3, 500GB Flash Storage).
  • Sistema Operativo: MacOs Mojave 10.14.2
  • Micronaut 1.0.2

3. Programación reactiva con Micronaut

Micronaut está implementado sobre Netty, que es un framework basado en eventos y un modelo de entrada/salida no bloqueante. Gracias a ello, implementar nuestras APIs reactivas es muy sencillo, dejando el control al framework para decidir si una petición debe tratarse de manera síncrona o asíncrona.

A pesar de estar construído sobre Netty, Micronaut da la opción de diseñar APIs bloqueantes, ya que existen escenarios donde las respuestas deben servirse de manera síncrona. Es responsabilidad de nosotros decidir qué tipo de API diseñar para cada escenario.

¿Cómo sabe Micronaut si la petición es bloqueante o no? En función del tipo de respuesta del servicio:

  • Si el método del controlador devuelve un tipo no bloqueante como por ejemplo CompletableFuture , Observable de RxJava o Mono de Reactor, la petición se servirá de manera asíncrona, siendo tratada por el event loop de Netty.
  • Si el método del controlador devuelve otro tipo de dato (p.e. String) la petición será tratada por el pool de hilos creado al iniciarse la aplicación y que es bloqueante. Este pool de hilos puede configurarse al igual que con otros frameworks como Spring Boot.

En el siguiente apartado veremos una implementación de dos puntos de API uno bloqueante y otro no bloqueante.

4. Implementación de una API reactiva con RxJava

Creamos un nuevo controlador, EchoController, que contiene dos puntos de API que devuelven como respuesta el contenido del body de la petición:

@Controller("/echo")
public class EchoController {

    @Post(value = "/sync", consumes = MediaType.TEXT_PLAIN)
    public String echoSync(@Size(max = 1024) @Body String text) {
        return text;
    }

    @Post(value = "/async", consumes = MediaType.TEXT_PLAIN)
    public Single<MutableHttpResponse<String>> echoAsync(@Body Flowable<String> text) {
        return text.collect(StringBuffer::new, StringBuffer::append)
                   .map(buffer ->
                           HttpResponse.ok(buffer.toString())
                   );
    }
}

  1. El método echoSync define un tamaño máximo de 1MB para la petición, almacenando en memoria el contenido de la petición. Para peticiones con muchos datos, esto puede provocar fallos de memoria.
  2. El método echoAsync soluciona este problema, ya que va almacenando cada fragmento del body en el buffer y respondiendo cuando termina de procesar el contenido de text sin ser una petición bloqueante gracias al tipo de dato Single de RxJava.

Resumiendo, en función de los tipos de la petición y respuesta de nuestros métodos, Micronaut sabe quién gestionará la llamada haciendo la petición bloqueante o no de manera muy sencilla, permitiendo configurar también el pool de hilos. Ahora es competencia de cada uno el saber cuándo utilizar una implementación u otra 😉

La entrada Creando una API reactiva con RxJava y Micronaut se publicó primero en Adictos al trabajo.


Patrones de diseño en el frontend

$
0
0

El mundo frontend es conocido por su gran volatilidad, sin embargo poco hacemos para que esta volatilidad no afecte a nuestros desarrollos. Nos importa últimamente estar más a la última del framework del momento que aprender a hacer nuestro código más mantenible. Así que este tutorial irá en pos de hacer una aplicación lo más “Frameworkless” posible.


Índice

El código podrás verlo en mi Github: https://github.com/cesalberca/frontend-patterns.

¡No olvides seguirme en Twitter!


Problema

Nuestro usuario tiene el siguiente problema: dado que su aplicación Web es altamente interactiva y hace uso de técnicas como carga de datos en diferido es necesario mostrar al usuario los distintos estados de la aplicación.

Inicialmente, al no haber cargado nada se mostrará una luz en gris: Sin cargar

En el momento en que comienza una petición se mostrará una luz en azul y se bloqueará el botón:

Cargando

Si la petición actual ha ido bien mostrar una luz en verde, los usuarios resueltos y desbloquear el botón:

Éxito

Y si no, una luz en rojo y desbloquear el botón:

Error

Si se vuelve a peticionar algo se volverá a mostrar la luz azul.

El usuario prevé que querrá añadir algún aviso sobre algunas peticiones que sean destructivas, como el borrado de una entidad, y además querría notificar al usuario de éstas de alguna forma.

Por supuesto nuestro usuario necesita que todas las peticiones por defecto se comporten así, pudiendo en alguno lugares añadir gestiones más especiales para capturar errores más específicos.


Solución

La solución que he ideado parte de un enfoque más simple, sobre el que he ido iterando para poder extender fácilmente mi código para adaptarme a nuevas funcionalidades. Para ello he usado una serie de patrones de diseño que me ayudaran a gestionar de mejor forma el código. Usaremos TypeScript y React.


Chain of responsibility

Le gestión de una petición asíncrona tiene que ir pasando por una serie de estados: inicio de la petición, respuesta de la petición que a su vez se divide en: petición resuelta con éxito y petición fallida. Y además, este ciclo es lineal. Incluso se podría decir que es una cadena.

Para este tipo de estructuras existe un patrón de diseño llamado chain of responsability que lo que pretende es gestionar el procesamiento de objetos siendo cada objeto el que tenga la lógica de procesado. Es decir, este patrón nos puede ahorrar un montón de if y elses haciendo cumplir el principio de Open/Closed de SOLID (abierto a la extensión, cerrado a la modificación) como veremos más adelante.

¡Así que vamos a ello! Vamos a empezar por la interfaz Handler:

export interface Handler<T> {
  next: (context: T) => void
  setNext: (handler: Handler<T>) => void
}

La interfaz recibe un genérico, con lo cual esta interfaz nos valdría para otras cadenas.

Esta interfaz describe dos métodos. El primero es una función que invocará el siguiente handler de la cadena, pudiendo pasar un objeto context. Este context nos servirá para ir realizando las operaciones pertinentes sobre la petición o el estado de la aplicación.

El método setNext nos permite definir el siguiente objeto de la cadena, recibiendo a su vez un Handler.

Ahora bien, ¿cómo sería la implementación de un Handler? Pues sería algo tal que así:

import { Handler } from './Handler'
import { RequestEmptyHandler } from './RequestEmptyHandler'
import { RequestHandlerContext } from './RequestHandlerContext'

export class RequestStartHandler implements Handler<RequestHandlerContext> {
  // Aquí debemos definir el super tipo de RequestEmptyHandler que es Handler<RequestHandlerContext>
  private nextHandler: Handler<RequestHandlerContext> = new RequestEmptyHandler()

  public async next(context: RequestHandlerContext) {
    context.stateManager.state.isLoading = true

    // Comenzamos la petición y la guardamos en el objeto context
    context.request = context.callback()

    await this.nextHandler.next(context)
  }

  public setNext(handler: Handler<RequestHandlerContext>) {
    this.nextHandler = handler
  }
}

En el método next tendremos la gestión del comienzo de una petición, dado que tiene que pasar lo siguiente:

  • Poner el estado a cargando
  • Invocar la función que hará la petición (es una callback para conseguir una evaluación lazy)
  • Invocar al siguiente elemento de la cadena

El RequestHandlerContext es una interfaz con lo siguiente:

export interface RequestHandlerContext {
  stateManager: StateManager
  callback: () => Promise<unknown>
  request: Promise<unknown> | null
  response: Request.Payload<unknown>
}

Es aquí donde definimos lo que tendrá el objeto context.

También vemos que se da un valor por defecto al nextHandler que es el RequestEmptyHandler. Este handler vacío lo que hace es… nada. Este es el handler por si en algún momento se intenta llamar al next del último handler. ¿Su implementación? Muy sencilla:

import { Handler } from './Handler'
import { RequestHandlerContext } from './RequestHandlerContext'

export class RequestEmptyHandler implements Handler<RequestHandlerContext> {
  public async next() {}

  public setNext() {}
}

Como hemos dicho antes, después de la petición hay una respuesta, que a su vez sería un Handler. Aunque este Handler es a su vez un poco especial, dado que debe poder gestionar una respuesta con éxito o una respuesta fallida:

import { Handler } from './Handler'
import { RequestErrorHandler } from './RequestErrorHandler'
import { RequestSuccessHandler } from './RequestSuccessHandler'
import { RequestEmptyHandler } from './RequestEmptyHandler'
import { RequestHandlerContext } from './RequestHandlerContext'

export class RequestResponseHandler implements Handler<RequestHandlerContext> {
  private nextHandler: Handler<RequestHandlerContext> = new RequestEmptyHandler()

  private requestErrorHandler = new RequestErrorHandler()
  private requestSuccessHandler = new RequestSuccessHandler()

  constructor() {
    this.requestErrorHandler.setNext(new RequestEmptyHandler())
    this.requestSuccessHandler.setNext(new RequestEmptyHandler())
  }

  public async next(context: RequestHandlerContext) {
    try {
      context.response.value = await context.request
      this.setNext(this.requestSuccessHandler)
    } catch (e) {
      this.setNext(this.requestErrorHandler)
    } finally {
      await this.nextHandler.next(context)
      context.stateManager.state.isLoading = false
    }
  }

  public setNext(handler: Handler<RequestHandlerContext>) {
    this.nextHandler = handler
  }
}

Aquí vemos varias cosas, dentro de este Handler tenemos un RequestErrorHandler y un RequestSuccessHandler, y es en el next donde se determina qué camino ha de seguir la cadena. Una vez se ha decidido dicho camino se invoca al método next.

Cómo todos los Handlers implementan la misma interfaz aquí vemos la magia del [polimorfismo](https://en.wikipedia.org/wiki/Polymorphism_(computer_science)
, donde a esta clase poco le importa cuál sea el siguiente Handler, este se preocupa de elegir el camino correcto, ya serán el resto de Handlers quienes determinen qué tienen que hacer (esto hace que sigamos la S de SOLID – Single responsibility principle).

Y además vemos algo muy interesante, en el finally decimos que context.state.currentState.isLoading se ponga a false. Pero si vemos un poco más arriba, hacemos await de la llamada al siguiente handler, lo que quiere decir esto que estamos mutando el estado una vez se ha ejecuta el siguiente handler. Esto nos puede venir de perlas si no quisiéramos parar la ejecución del programa o si quisiéramos ejecutar algo a posteriori a modo de “limpieza”. En este caso una vez resuelto la petición con éxito o con error, queremos que se cambie el estado a cargado y no antes.

La clase de éxito de la petición es RequestSuccessHandler:

import { Handler } from './Handler'
import { RequestEmptyHandler } from './RequestEmptyHandler'
import { RequestHandlerContext } from './RequestHandlerContext'

export class RequestSuccessHandler implements Handler<RequestHandlerContext> {
  private nextHandler: Handler<RequestHandlerContext> = new RequestEmptyHandler()

  public async next(context: RequestHandlerContext) {
    context.stateManager.state.hasSuccess = true
    await this.nextHandler.next(context)
  }

  public setNext(handler: Handler<RequestHandlerContext>) {
    this.nextHandler = handler
  }
}

Y la de error es RequestErrorHandler:

import { Handler } from './Handler'
import { RequestEmptyHandler } from './RequestEmptyHandler'
import { RequestHandlerContext } from './RequestHandlerContext'

export class RequestErrorHandler implements Handler<RequestHandlerContext> {
  private nextHandler: Handler<RequestHandlerContext> = new RequestEmptyHandler()

  public async next(context: RequestHandlerContext) {
    context.stateManager.state.hasError = true
    context.response.hasError = true
    await this.nextHandler.next(context)
  }

  public setNext(handler: Handler<RequestHandlerContext>) {
    this.nextHandler = handler
  }
}

Aquí vemos dos hasError, la diferencia es que uno lo usamos en el estado de la aplicación en sí y otro lo usamos para gestionar la respuesta de la petición.

Ahora nos queda la última pieza… ¿Quién orquesta todo? Pues el RequestHandler:

import { RequestStartHandler } from './RequestStartHandler'
import { RequestEmptyHandler } from './RequestEmptyHandler'
import { StateManager } from '../application/state/StateManager'
import { RequestResponseHandler } from './RequestResponseHandler'
import { Request } from '../Request'
import { Handler } from './Handler'
import { RequestHandlerContext } from './RequestHandlerContext'

export class RequestHandler {
  private nextHandler: Handler<RequestHandlerContext> = new RequestEmptyHandler()

  constructor(private readonly state: StateManager) {}

  public async trigger<T>(
    callback: () => Promise<T>
  ): Promise<Request.Success<T> | Request.Fail> {
    const response: Request.Payload<Response> = {
      hasError: false,
      value: null
    }

    this.nextHandler = this.getNextHandler()
    const context: RequestHandlerContext = {
      stateManager: this.state,
      callback,
      request: null,
      response
    }

    context.stateManager.setEmptyState()
    await this.nextHandler.next(context)

    if (response.hasError) {
      return new Request.Fail()
    }

    return new Request.Success((response.value as unknown) as T)
  }

  private getNextHandler(): Handler<RequestHandlerContext> {
    const requestResponseHandler = new RequestResponseHandler()
    const requestEmptyHandler = new RequestEmptyHandler()

    const handler = new RequestStartHandler()
    handler.setNext(requestResponseHandler)
    requestResponseHandler.setNext(requestEmptyHandler)
    return handler
  }
}

Aquí básicamente creamos la cadena de Handlers, les decimos a cada uno cuál es su siguiente elemento de la cadena y exponemos a los clientes un método sobre el que pueden iniciar la cadena, que es el método trigger, el cual recibirá una función que retorna una promesa, que es la petición en sí. El trigger además retorna los valores o un error.

Lo que parece un objeto Request realmente es un namespace de TypeScript, donde agrupo las cosas que tienen que ver con el objeto petición (Request):

export namespace Request {
  export class Success<T> {
    constructor(public readonly value: T) {}
  }

  export class Fail extends Error {
    constructor() {
      super('Request failed')

      if (Error.captureStackTrace) {
        Error.captureStackTrace(this, Error)
      }
    }
  }

  export type Payload<T> = { hasError: boolean; value: null | T }
}


Proxy

Ahora tenemos un problema, el lector atento habrá visto que en el objeto context hemos empezado a cambiar unas propiedades del objeto state tal que así:

context.stateManager.state.isLoading = false

Esto es parte de la solución que implementaremos ante el problema de recargar la vista cuando un valor cambia. Porque según nuestra historia de usuario tenemos que representar varios estados del cargando de forma dinámica.

Esto podemos hacerlo con un Proxy de JavaScript, que curiosamente implementa el patrón Proxy por debajo, que será donde guardemos el estado. En este Proxy, podremos capturar todas las mutaciones de sus valores. Y teniendo esto, solamente nos hace falta conectar los componentes de nuestra aplicación con este estado, que es de la siguiente forma:

import { State } from './State'

export class StateManager  {
  public state: State

  public constructor() {
    this.state = new Proxy(this.getEmptyState(), {
      set: (
        target: State,
        p: PropertyKey,
        value: any,
        receiver: any
      ): boolean => {
        Reflect.set(target, p, value, receiver)
        return true
      }
    })
  }

  public getEmptyState(): State {
    return {
      isLoading: false,
      hasError: false,
      hasSuccess: false,
      users: []
    }
  }

  public setEmptyState(): void {
    this.state.isLoading = false
    this.state.hasError = false
    this.state.hasSuccess = false
  }
}

Ahora cada vez que mutemos el estado del StateManager podremos lanzar acciones, que serán observadas.


Observador

Ahora bien, necesitamos exponer al mundo una forma de poder observar estos cambios en el estado. Ahí entra el patrón observador. Empezamos por el sujeto:

import { Observer } from './Observer'

export interface Subject {
  register: (observer: Observer) => void
  notifyAll: () => void
}

El sujeto es aquella entidad que será observada. Tendrá un array de observadores y un método que permitirá registrar un nuevo observador y otro método para notificar a todos los observadores de que algo ha cambiado.

Y el observador:

export interface Observer {
  notify: () => void
}

El observador será notificado mediante el método notify.

Y si lo hilamos todo junto al StateManager:

import { Subject } from './Subject'
import { Observer } from './Observer'
import { State } from './State'

export class StateManager implements Subject {
  private readonly _observers: Observer[] = []

  public state: State

  public constructor() {
    this.state = new Proxy(this.getEmptyState(), {
      set: (
        target: State,
        p: PropertyKey,
        value: any,
        receiver: any
      ): boolean => {
        Reflect.set(target, p, value, receiver)
        // Es aquí donde notificamos a los observadores, ya que estando en el método `set` esto quiere decir que se ha mutado una propiedad del estado 
        this.notifyAll()
        return true
      }
    })
  }

  public getEmptyState(): State {
    return {
      isLoading: false,
      hasError: false,
      hasSuccess: false,
      users: []
    }
  }

  public setEmptyState(): void {
    this.state.isLoading = false
    this.state.hasError = false
    this.state.hasSuccess = false
  }

  public notifyAll() {
    this._observers.forEach(observer => observer.notify())
  }

  public register(observer: Observer) {
    this._observers.push(observer)
  }
}

Nos quedaría únicamente definir los observadores, pero eso lo veremos más adelante.

El tipo State no es más que una interfaz con el siguiente contenido:

import { FakeUser } from '../../fakeUser/FakeUser'

export interface State {
  isLoading: boolean
  hasError: boolean
  hasSuccess: boolean
  hasWarning: boolean
  userHasCanceledOperation: boolean
  users: FakeUser[]
}


Singleton

Si pensamos el caso de uso del estado, nunca tendrían sentido dos instancias o más de StateManager. Para evitar crear instancias de más tenemos el patrón Singleton. Para ello añadimos un campo a la clase llamado instance cuyo valor por defecto será null:

class StateManager implements Subject {
    private static _instance: StateManager | null = null
}

Añadimos un getter del campo privado _instance dónde gestionamos su creación una única vez:

export class StateManager implements Subject {
    private static _instance: StateManager | null = null

    public static get instance() {
      if (this._instance === null) {
        this._instance = new StateManager()
      }

      return this._instance
    }
}

Por último cambiamos la visibilidad del constructor de pública a privada, para que únicamente se pueda obtener la instancia de la clase por el getter instance.

El resultado sería el siguiente:

import { Subject } from './Subject'
import { Observer } from './Observer'
import { State } from './State'

export class StateManager implements Subject {
  private readonly _observers: Observer[] = []

  public state: State

  private static _instance: StateManager | null = null

  public static get instance() {
    if (this._instance === null) {
      this._instance = new StateManager()
    }

    return this._instance
  }

  private constructor() {
    this.state = new Proxy(this.getEmptyState(), {
      set: (
        target: State,
        p: PropertyKey,
        value: any,
        receiver: any
      ): boolean => {
        Reflect.set(target, p, value, receiver)
        this.notifyAll()
        return true
      }
    })
  }

  public getEmptyState(): State {
    return {
      isLoading: false,
      hasError: false,
      hasSuccess: false,
      users: []
    }
  }

  public setEmptyState(): void {
    this.state.isLoading = false
    this.state.hasError = false
    this.state.hasSuccess = false
  }

  public notifyAll() {
    this._observers.forEach(observer => observer.notify())
  }

  public register(observer: Observer) {
    this._observers.push(observer)
  }
}


React

Para la aplicación he optado por usar React, aunque hemos hecho el código de tal forma que la lógica de la aplicación no está acoplada con ningún framework.

Tenemos el componente Light que tendrá el siguiente contenido:

import React, { Component } from 'react'

export type LightStates = 'loading' | 'error' | 'success' | 'none'

interface Props {
  state: LightStates
}

export class Light extends Component<Props> {
  public render() {
    return (
      <div>
        <span className={`light light--${this.props.state}`} />
      </div>
    )
  }
}

Y tendremos por encima un LightContainer, este componente es un denominado “contenedor”. Los contenedores y componentes son un patrón de diseño que aplica a frameworks que se basan en componentes y la diferencia es que los contenedores son más listos que los componentes, ya que gestionan el estado y orquestan los componentes. Se empezó a usar en el front a raíz de este artículo de Dan Abramov.

Este patrón nos recuerda mucho al patrón Mediator del GoF, donde un objeto es el encargado de gestionar las dependencias entre muchos objetos. En este caso el mediador será el contenedor y los componentes serán las dependencias. La forma de comunicación será basada en props y callbacks.

El contenido del contenedor es el siguiente:

import React, { Component } from 'react'
import { StateManager } from './state/StateManager'
import { Observer } from './state/Observer'
import { Light, LightStates } from './Light'
import { Consumer } from './rootContainer'

interface Props {
  stateManager: StateManager
}

export class LightContainer extends Component<Props> {
  public getState(): LightStates {
    if (this.props.stateManager.state.isLoading) {
      return 'loading'
    }

    if (this.props.stateManager.state.hasError) {
      return 'error'
    }

    if (this.props.stateManager.state.hasSuccess) {
      return 'success'
    }

    return 'none'
  }

  public render(): React.ReactNode {
    return (
      <Consumer>
        {context => (
          <div className="light-controller">
            <Light state={this.getState()} />
            <button
              disabled={this.props.stateManager.state.isLoading}
              className={`button ${this.props.stateManager.state.isLoading ? 'button--disabled' : ''}`}
              onClick={async () => {
                // Si quisiéramos ir más allá delegaríamos esta funcionalidad en un servicio o en un use case, de momento nos vale aquí
                this.props.stateManager.state.users = []
                this.props.stateManager.state.users = await context.fakeUserRepository.findAll()
              }}
            >
              Get users
            </button>
            <h3>Users</h3>
            {this.props.stateManager.state.users.map(user => (
              <p key={user.name}>{user.name}</p>
            ))}
          </div>
        )}
      </Consumer>
    )
  }
}

Aquí vemos varias partes interesantes, estamos usando el API de Context de React para consumir un objeto context y parece que este nos provee de un fakeRepository que veremos más adelante. Vemos que estamos renderizando el componente Light y le pasamos directamente un state con getState() y este a su vez accede por props a un tal stateManager.


Context

El API de context nos va a hacer las veces de inyección de dependencias para poder cumplir uno de los principios SOLID, el de la D que es dependency inversion, que dictamina que no deberíamos depender en concreciones si no en abstracciones. ¿Cómo logramos esto? Pues resulta que fakeUserRepository es una interfaz y tiene la siguiente pinta:

import { Repository } from './Repository'
import { FakeUser } from './FakeUser'

export interface FakeUserRepository extends Repository<FakeUser> {}

Y Repository es otra interfaz del siguiente tipo:

export interface Repository<T> {
  findAll: () => Promise<T[]>
}

En esta interfaz podríamos definir métodos de acceso de entidades, por ejemplo: findOne, delete o update. Y luego cada interfaz de tipo repositorio ya definiría métodos más concretos como: findUserByName.

Y por tanto nos queda ver la implementación de esta interfaz:

import { FakeUserRepository } from './FakeUserRepository'
import { RequestHandler } from '../requestHandlers/RequestHandler'
import { Request } from '../Request'
import { wait } from '../utils/wait'
import { FakeUser } from './FakeUser'

export class FakeUserHttpRepository implements FakeUserRepository {
  private fakeUsers: FakeUser[] = [{ name: 'César' }, { name: 'Paco' }, { name: 'Alejandro' }]

  constructor(private readonly requestHandler: RequestHandler) {}

  public async findAll(): Promise<FakeUser[]> {
    const callback = () => this.getFakeUsers()

    const response = await this.requestHandler.trigger<FakeUser[]>(callback)

    if (response instanceof Request.Fail) {
      throw new Error('users could not be found.')
    }

    return response.value
  }

  private async getFakeUsers(): Promise<FakeUser[]> {
    await wait(1)
    const hasError = Math.random() >= 0.5

    if (hasError) {
      throw new Request.Fail()
    }

    return this.fakeUsers
  }
}

Si vemos que la lógica de muchos repositorios es idéntica, podríamos crearnos un GenericHttpRepository que nos diese esa funcionalidad común.

Y aquí ya empezamos a ver todo hilado, este FakeUserHttpRepository ya usa por debajo nuestro famoso RequestHandler, siendo este el que gestiona el ciclo de vida de la petición.

La utilidad wait no es más que un setTimeout promisificado:

export async function wait(seconds: number): Promise<void> {
  return new Promise(resolve => {
    window.setTimeout(() => {
      resolve()
    }, seconds * 1000)
  })
}

Ahora nos queda meter esto en el contexto de React, ahí entra el rootContainer:

import { createContext } from 'react'
import { RequestHandler } from '../requestHandlers/RequestHandler'
import { StateManager } from './state/StateManager'
import { FakeUserRepository } from '../fakeUser/FakeUserRepository'
import { FakeUserHttpRepository } from '../fakeUser/FakeUserHttpRepository'

export interface AppContext {
  fakeUserRepository: FakeUserRepository
}

const fakeUserRequestHandler = new RequestHandler(StateManager.instance)

export const contextValue: AppContext = {
  fakeUserRepository: new FakeUserHttpRepository(fakeUserRequestHandler)
}

const Context = createContext<AppContext>(contextValue)

export const Provider = Context.Provider
export const Consumer = Context.Consumer

Pasamos de una abstracción a una concreción, y esto es muy potente, porque imaginemos que queremos implementar un sistema de caché en local storage, lo único que tendríamos que crear es un repositorio FakeUserLocalStorageRepository y dinámicamente cambiar la implementación entre el FakeUserHttpRepository y el anterior, siendo completamente transparente para el consumidor.

El consumidor al final le da igual de dónde vengan los datos, él quiere los usuarios, ya será en otro sitio de dónde tiene que sacarlos. Además, si el día de mañana quisiéramos migrar a GraphQL lo único que tendríamos que hacer sería añadir otro repositorio, cumpliendo así otro de los principios de SOLID, el de la O, que es Open/Closed, lo que quiere decir que si añadimos funcionalidad no tenemos que tocar código antiguo si no añadir más código.

Y nos queda el punto inicial de la aplicación, el Aplication.tsx:

import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import { contextValue, Provider } from './rootContainer'
import { LightContainer } from './LightContainer'
import { StateManager } from './state/StateManager'

export class Application extends Component {
  public render(): React.ReactNode {
    return (
      <Provider value={contextValue}>
        <main className="application">
          <LightContainer stateManager={StateManager.instance} />
        </main>
      </Provider>
    )
  }
}

const root = document.getElementById('root')
ReactDOM.render(<Application />, root)

Aquí proveemos del contexto y la pasamos al stateManager el estado, que, como es un singleton pues será el mismo estado siempre.


Observadores

En un capítulo anterior hemos desarrollado un StateManager que era un sujeto, pero en ningún momento hemos definido quiénes se iban a suscribir a esa parte del estado. ¿Quiénes van a ser los suscriptores? Pues los componentes de React, para ello a nuestro componente LightContainer le diremos que implementa la interfaz Observer, que cuando se monte tiene que registrarse y que implementa un método notify que llama el forceUpdate de React.

import React, { Component } from 'react'
import { StateManager } from './state/StateManager'
import { Observer } from './state/Observer'
import { Light, LightStates } from './Light'
import { Consumer } from './rootContainer'

interface Props {
  stateManager: StateManager
}

export class LightContainer extends Component<Props> implements Observer {
  public componentDidMount(): void {
    this.props.stateManager.register(this)
  }

  public notify() {
    this.forceUpdate()
  }
}

Quedando al completo así:

import React, { Component } from 'react'
import { StateManager } from './state/StateManager'
import { Observer } from './state/Observer'
import { Light, LightStates } from './Light'
import { Consumer } from './rootContainer'

interface Props {
  stateManager: StateManager
}

export class LightContainer extends Component<Props> implements Observer {
  public componentDidMount(): void {
    this.props.stateManager.register(this)
  }

  private getState = (): LightStates => {
    if (this.props.stateManager.state.isLoading) {
      return 'loading'
    }

    if (this.props.stateManager.state.hasError) {
      return 'error'
    }

    if (this.props.stateManager.state.hasSuccess) {
      return 'success'
    }

    return 'none'
  }

  public render(): React.ReactNode {
    const { state } = this.props.stateManager

    return (
      <Consumer>
        {context => (
          <div className="light-controller">
            <Light state={this.getState()} />
            <button
              disabled={state.isLoading}
              className={`button ${state.isLoading ? 'button--disabled' : ''}`}
              onClick={async () => {
                state.users = await context.fakeUserRepository.findAll()
              }}
            >
              Get users
            </button>

            <h3>Users</h3>
            {state.users.map(user => (
              <p key={user.name}>{user.name}</p>
            ))}
          </div>
        )}
      </Consumer>
    )
  }

  public notify() {
    this.forceUpdate()
  }
}

El forceUpdate no hace que se renderice de nuevo todo el árbol, React sigue aplicando el diffing para renderizar solamente aquello que ha cambiado.


Nueva feature

Ahora veremos cuánto cuesta añadir nueva funcionalidad. El usuario ahora quiere que se muestre un botón que permite borrar a los usuarios. Pero antes de ello, se debe mostrar un mensaje advirtiendo al usuario que se va a proceder con una operación destructiva. El usuario va a poder cancelar la operación antes de 2.5 segundos. Si no hace nada se procederá con la petición destructiva.

Primero añadimos unos nuevos estados en State: hasWarning y userHasCanceledOperation. La interfaz quedaría así:

import { FakeUser } from '../../fakeUser/FakeUser'

export interface State {
  isLoading: boolean
  hasError: boolean
  hasSuccess: boolean
  hasWarning: boolean
  userHasCanceledOperation: boolean
  users: FakeUser[]
}

Dentro del StateManager le damos un valor vacío y le decimos que cuando hay un estado vacío debe poner el hasWarning y el userHasCanceledOperation a false:

import { Subject } from './Subject'
import { Observer } from './Observer'
import { State } from './State'

export class StateManager implements Subject {
  private readonly _observers: Observer[] = []

  public state: State

  private static _instance: StateManager | null = null

  public static get instance() {
    if (this._instance === null) {
      this._instance = new StateManager()
    }

    return this._instance
  }

  private constructor() {
    this.state = new Proxy(this.getEmptyState(), {
      set: (
        target: State,
        p: PropertyKey,
        value: any,
        receiver: any
      ): boolean => {
        Reflect.set(target, p, value, receiver)
        this.notifyAll()
        return true
      }
    })
  }

  public getEmptyState(): State {
    return {
      isLoading: false,
      hasError: false,
      hasSuccess: false,
      hasWarning: false,
      userHasCanceledOperation: false,
      users: []
    }
  }

  public setEmptyState(): void {
    this.state.isLoading = false
    this.state.hasError = false
    this.state.hasSuccess = false
    this.state.hasWarning = false
    this.state.userHasCanceledOperation = false
  }

  public notifyAll() {
    this._observers.forEach(observer => observer.notify())
  }

  public register(observer: Observer) {
    this._observers.push(observer)
  }
}

Creamos una clase RequestWarningHandler:

import { Handler } from './Handler'
import { RequestHandlerContext } from './RequestHandlerContext'
import { RequestEmptyHandler } from './RequestEmptyHandler'
import { waitUntilOr } from '../utils/wait'

export class RequestWarningHandler implements Handler<RequestHandlerContext> {
  private nextHandler: Handler<RequestHandlerContext> = new RequestEmptyHandler()

  public async next(context: RequestHandlerContext) {
    context.stateManager.state.hasWarning = true

    await waitUntilOr(2.5, () => context.stateManager.state.userHasCanceledOperation)

    if (context.stateManager.state.userHasCanceledOperation) {
      this.setNext(new RequestEmptyHandler())
    }

    context.stateManager.state.hasWarning = false
    await this.nextHandler.next(context)
  }

  public setNext(handler: Handler<RequestHandlerContext>) {
    this.nextHandler = handler
  }
}

Si el usuario no acepta el warning en un marco de 2 segundos y medio no comenzará la petición, ya que se irá al RequestEmptyHandler. Además, vemos una función waitUntilOr que tiene el siguiente contenido:

export async function waitUntilOr(seconds: number, value: () => boolean): Promise<void> {
  return new Promise(resolve => {
    let hasValueChanged = false
    let hasTimeoutRanOut = false

    window.setTimeout(() => {
      hasTimeoutRanOut = true
    }, seconds * 1000)

    const interval = window.setInterval(() => {
      hasValueChanged = value()

      if (hasValueChanged || hasTimeoutRanOut) {
        resolve()
        window.clearInterval(interval)
      }
    }, 100)

  })
}

Necesitamos esta función para evitar que al cancelar el borrado de usuarios varias veces dentro del marco de 2.5 segundos se cree otro intervalo.

Modificamos el método trigger de la clase RequestHandler y refactorizamos un poco para que quede más claro las distintas rutas que se pueden tomar:

import { RequestStartHandler } from './RequestStartHandler'
import { RequestEmptyHandler } from './RequestEmptyHandler'
import { StateManager } from '../application/state/StateManager'
import { RequestResponseHandler } from './RequestResponseHandler'
import { Request } from '../Request'
import { RequestWarningHandler } from './RequestWarningHandler'
import { RequestHandlerContext} from './RequestHandlerContext'
import { Handler } from './Handler'

export class RequestHandler {
  private nextHandler: Handler<RequestHandlerContext> = new RequestEmptyHandler()

  constructor(private readonly state: StateManager) {}

  public async trigger<T>(
    callback: () => Promise<T>,
    hasWarning: boolean = false
  ): Promise<Request.Success<T> | Request.Fail> {
    const response: Request.Payload<Response> = {
      hasError: false,
      value: null
    }

    this.nextHandler = this.getNextHandler(hasWarning)
    const context: RequestHandlerContext = {
      stateManager: this.state,
      callback,
      request: null,
      response
    }

    context.stateManager.setEmptyState()
    await this.nextHandler.next(context)

    if (response.hasError) {
      return new Request.Fail()
    }

    return new Request.Success((response.value as unknown) as T)
  }

  private getNextHandler(hasWarning: boolean): Handler<RequestHandlerContext> {
    if (hasWarning) {
      return this.getWarningHandlers()
    }

    return this.getDefaultHandlers()
  }

  private getWarningHandlers(): Handler<RequestHandlerContext> {
    const requestStartHandler = new RequestStartHandler()
    const requestResponseHandler = new RequestResponseHandler()
    const requestEmptyHandler = new RequestEmptyHandler()

    const handler = new RequestWarningHandler()
    handler.setNext(requestStartHandler)
    requestStartHandler.setNext(requestResponseHandler)
    requestResponseHandler.setNext(requestEmptyHandler)

    return handler
  }

  private getDefaultHandlers(): Handler<RequestHandlerContext> {
    const requestResponseHandler = new RequestResponseHandler()
    const requestEmptyHandler = new RequestEmptyHandler()

    const handler = new RequestStartHandler()
    handler.setNext(requestResponseHandler)
    requestResponseHandler.setNext(requestEmptyHandler)
    return handler
  }
}

Añadir al repositorio genérico Repository el método de deleteAll:

export interface Repository<T> {
  findAll: () => Promise<T[]>
  deleteAll: () => Promise<void>
}

Y lo implementamos en el FakeUserHttpRepository:

import { FakeUserRepository } from './FakeUserRepository'
import { RequestHandler } from '../requestHandlers/RequestHandler'
import { Request } from '../Request'
import { wait } from '../utils/wait'
import { FakeUser } from './FakeUser'

export class FakeUserHttpRepository implements FakeUserRepository {
  private fakeUsers: FakeUser[] = [{ name: 'César' }, { name: 'Paco' }, { name: 'Alejandro' }]

  constructor(private readonly requestHandler: RequestHandler) {}

  public async findAll(): Promise<FakeUser[]> {
    const callback = () => this.getFakeUsers()

    const response = await this.requestHandler.trigger<FakeUser[]>(callback)

    if (response instanceof Request.Fail) {
      throw new Error('users could not be found.')
    }

    return response.value
  }

  public async deleteAll() {
    const callback: () => Promise<void> = () =>
      new Promise(async resolve => {
        await wait(1)
        this.fakeUsers = []
        resolve()
      })

    await this.requestHandler.trigger<void>(callback, true)
  }

  private async getFakeUsers(): Promise<FakeUser[]> {
    await wait(1)
    const hasError = Math.random() >= 0.5

    if (hasError) {
      throw new Request.Fail()
    }

    return this.fakeUsers
  }
}

Por último, añadimos al contenedor el botón. Ahora nuestro contenedor tendrá un estado interno que nos dirá si se debe mostrar un warning:

import React, { Component } from 'react'
import { StateManager } from './state/StateManager'
import { Observer } from './state/Observer'
import { Light, LightStates } from './Light'
import { Consumer } from './rootContainer'

interface Props {
  stateManager: StateManager
}

interface State {
  isWarningVisible: boolean
}

export class LightContainer extends Component<Props, State> implements Observer {
  public constructor(props: Props) {
    super(props)
    this.state = {
      isWarningVisible: false
    }
  }

  public componentDidMount(): void {
    this.props.stateManager.register(this)
  }

  private getState = (): LightStates => {
    if (this.props.stateManager.state.isLoading) {
      return 'loading'
    }

    if (this.props.stateManager.state.hasError) {
      return 'error'
    }

    if (this.props.stateManager.state.hasSuccess) {
      return 'success'
    }

    return 'none'
  }

  private toggleWarning = () => {
    this.setState((previousState) => ({
      isWarningVisible: !previousState.isWarningVisible
    }))
  }

  public render(): React.ReactNode {
    const { state } = this.props.stateManager

    return (
      <Consumer>
        {context => (
          <div className="light-controller">
            <Light state={this.getState()} />
            <button
              disabled={state.isLoading}
              className={`button ${state.isLoading ? 'button--disabled' : ''}`}
              onClick={async () => {
                state.users = await context.fakeUserRepository.findAll()
              }}
            >
              Get users
            </button>

            {!(state.hasWarning && this.state.isWarningVisible) ? (
              <button
                className="button"
                onClick={async () => {
                  this.toggleWarning()
                  await context.fakeUserRepository.deleteAll()

                  if (!state.userHasCanceledOperation) {
                    state.users = await context.fakeUserRepository.findAll()
                  }
                }}
              >
                Delete users
              </button>
            ) : (
              <button
                className="button button--warning"
                onClick={() => {
                  state.userHasCanceledOperation = true
                  this.toggleWarning()
                }}
              >
                Cancel delete users
              </button>
            )}

            <h3>Users</h3>
            {state.users.map(user => (
              <p key={user.name}>{user.name}</p>
            ))}
          </div>
        )}
      </Consumer>
    )
  }

  public notify() {
    this.forceUpdate()
  }
}


Conclusión

Hemos visto un montón de patrones (Singleton, Observador, Chain of responsibility, Proxy, Mediator), junto con separación de capas con repositorios, estado y componentes. Hemos comprobado que si nuestro código cumple SOLID será más fácil lidiar con el, más mantenible y aceptará mejor el cambio.

El código podrás verlo en mi Github: https://github.com/cesalberca/frontend-patterns.

¡No olvides seguirme en Twitter!

La entrada Patrones de diseño en el frontend se publicó primero en Adictos al trabajo.

Introducción a ClickHouse

$
0
0

Índice de contenidos

1. Introducción

A lo largo de la historia, la información y saber utilizarla ha sido y será clave para tomar decisiones en cualquier ámbito. Todo hemos oído frases como “la información es poder”, el dato cada vez está tomando mayor relevancia de la que ya tenía. Grandes compañías y no tan grandes ofrecen de forma gratuita sus servicios a cambio de nuestros datos, ¿por qué?, porque con los datos que recopilan de nosotros, leyendo nuestros mails, conociendo nuestros hábitos, nuestra salud, preferencias deportivas, etc … generan un perfil que utilizan para ofrecernos publicidad orientada, productos en los que podemos estar interesados e incluso vender dicha información a terceros.

Business Intelligence es una de las áreas especializadas en la analítica del dato, se caracteriza en transformar información en conocimiento, con el objetivo de mejorar el proceso de toma de decisiones haciendo uso de estrategias y herramientas. El volumen de la información puede llegar a millones de registros, esto hace que el uso de soluciones tradicionales no sean las más adecuadas.

Una de las estrategias más habituales en este área es OLAP (OnLine Analytical Processing) que permite agilizar la consulta de grandes cantidades de datos usando estructuras multidimensionales que contienen información resumida de otras fuentes de datos del tipo OLTP (OnLine Transaction Processing). Su uso habitual es la generación de informes para los diferentes departamentos de una empresa como pueden ser ventas, marketing, etc.

Básicamente OLAP recopila un gran volumen de información de diversas fuentes (internas o externas), las agrupa de una forma determinada para poder después explotar la información accediendo a ella mediante el uso de un lenguaje más natural, como por ejemplo: “dime el número de billetes vendidos en el último cuatrimestre por país”, “rango de edades por países que acceden a la aplicación”, etc…

Las características principales cuando se trabaja con escenarios OLAP son:

  • La mayoría de las peticiones son de lectura.
  • Generalmente los datos no son modificados.
  • Tablas con un gran número de columnas.
  • Las consultas procesan un gran número de filas (billones de filas por segundo) pero sólo sobre un subconjunto de columnas.
  • No son necesarias las transacciones y desde el punto de vista de consistencia del dato no es un requisito esencial.

Teniendo en cuenta como se trabaja con los datos en escenarios OLAP, herramientas tradicionales como pueden ser base de datos como MySQL, Oracle o PostgreSQL no son las más adecuadas ya que el rendimiento es bastante pobre. Son base de datos orientadas a fila, es decir, los datos son almacenados en este orden:

Fila OrderID Pagada Nombre Enviada Fecha
#0 89354350662 1 Investor Relations 1 2016-05-18 05:19:20
#1 90329509958 0 Contact us 1 2016-05-18 08:10:20
#2 89953706054 1 Mission 1 2016-05-18 07:38:00
#N

Por tanto todos los valores relativos a una fila son físicamente almacenados uno al lado del otro.

Para que los rendimientos sean realmente óptimos, generalmente, las herramientas OLAP estructuran los datos en columnas almacenándolos de esta otra forma:

Fila #0 #1 #2 #N
OrderID 89354350662 90329509958 89953706054
Pagada 1 0 1
Nombre Investor Relations Contact us Mission
Enviada 1 1 1
Fecha 2016-05-18 05:19:20 2016-05-18 08:10:20 2016-05-18 07:38:00

En estos casos los valores de diferentes columnas se almacenan por separado y los datos de la misma columna se almacenan juntos. De esta forma la recuperación se puede realizar en bloque y se puede procesar muchos más datos que con las base de datos orientadas a fila.

2. Entorno

Este tutorial está escrito usando el siguiente entorno:

  • Hardware: MacBook Pro 15’ (3,1 GHz Intel Core i7, 16GB DDR3)
  • Sistema operativo: macOS Mojave 10.14.2
  • Versiones del software:
    • Docker: 18.09.1
    • ClickHouse Server: 18.14.18
    • ClickHouse Client: 18.14.18
    • Java: 8
    • Spring Boot: 2.1.1.RELEASE

3. ClickHouse

ClickHouse es una de las muchas herramientas para trabajar con este tipo de escenarios, es un producto desarrollado por Yandex (que podemos decir que es el Google Ruso) para utilizarlo como base de su herramienta de Yandex Metrica (Live Demo) que es una aplicación similar a Google Analitics . Siendo la tercera plataforma de análisis web más utilizada del mundo por detrás de Google y Facebook.

Si echamos un vistazo a los informes de rendimiento que lo comparan con otras herramientas, ClickHouse se posiciona en una buena posición estando por encima de muchas de ellas. Os dejo unos cuantos enlaces de dichos informes:

Las características base de este producto son:

  • Una base de datos realmente orientada a columna, sin utilizar datos extras como pueden ser la longitud de una cadena de caracteres, sólo almacena los datos.
  • Los datos se comprimen reduciendo el espacio y se almacenan en disco a diferencia de otras herramientas que sólo trabajan en memoria. Esto hace que el coste por GB sea bajo.
  • Capaz de procesar en paralelo los datos exprimiendo las capacidades multicores de los servidores.
  • Los datos son procesados utilizando vectores, mejorando la eficiencia de la CPU. ClickHouse hace uso del conjunto de instrucciones SSE 4.2 que permite trabajar con estas estructuras de datos y hace que este producto sea capaz de procesar billones de registros en muy poco tiempo. Esta característica hace que sólo pueda ser desplegado en procesadores que sean compatibles con este conjunto de instrucciones.
  • Utiliza el lenguaje SQL para ejecución de las sentencias, casi idéntico al estándar SQL.
  • Permite actualizar los datos en tiempo real sin bloqueo gracias a la forma de estructurar internamente los datos.
  • Proporciona indexación por clave primaria. ClickHouse ordena fisicamente los datos en base a su clave primaria y hace posible recuperar la información almacenada para un determinado valor o un rango de valores con muy baja latencia y pocos milisegundos.
  • Capaz de ejecutar consultas en tiempo real sin necesidad de procesar los datos previamente, es decir, ClickHouse puede trabajar con millones de registros en bruto (sin realizar preprocesamientoo previo de los datos) consiguiendo ejecutar sentencias sin ninguna latencia
  • Soporte de replicación e integridad de datos.

3.1 Tipos de tablas

Como hemos comentado anteriormente, ClickHouse es una base de datos y como cualquier base de datos la información se almacena en tablas para posteriormente realizar consultas sobre ellas. La peculiaridad de esta base de datos es que dispone de un gran abanico de tipología de tablas.

En este tutorial sólo las nombraremos sin entrar en detalle. El primer tipo que podemos destacar es la familia de tablas denominadas MergeTree, podemos decir que son el núcleo y las que más usaremos para explotar nuestros datos. Las características más importantes de esta familia de tablas son:

  • tienen clave primaria y los datos están ordenados físicamente en disco. Por ejemplo, si la clave primaria es (OrderId, Date) los datos estarán ordenados por OrderId y dentro de cada OrderId estarán ordenados por Date. Además ClickHouse mantiene un indice disperso que permite encontrar los datos más rápidamente y un formato bastante ingenioso y que os recomiendo que profundicéis leyendo la documentación.
  • los datos también se dividen en particiones mediante una clave de partición similar a la clave primaria. Ésta se utiliza para crear particiones, lo que permite optimizar las consultas sólo leyendo los datos de una determinada partición. Indicar que ClickHouse creará un fichero físico independiente por cada partición creada.

A la hora de diseñar una tabla para posteriormente realizar consultas sobre ellas deberemos prestar bastante atención y elegir correctamente estas dos claves de lo contrario podremos tener problemas de rendimiento y por tanto una experiencia de usuario pobre.

Partiendo de esta característica base de esta familia luego cada tipo de tabla tiene su peculiaridad. A continuación os muestro la lista de tablas de esta familia:

  • ReplacingMergeTree: su principal característica es que elimina los datos duplicados con la misma clave primaria.
  • SummingMergeTree: une todas las filas con la misma clave primaria en una sola fila realizando un resumen de las filas compactadas.
  • AggregatingMergeTree: remplaza todas las filas con la misma clave primaria con una única fila aplicando las funciones de agregación utilizadas (ejemplo: sum, avg).
  • CollapsingMergeTree: elimina asíncronamente todas las filas que contiene los mismos campos sin tener en cuenta un campo especial llamado Sign. ClickHouse mantendrá la fila donde el valor Sign sea 1 y eliminará aquellas cuyo valor sea -1.
  • VersionedCollapsingMergeTree: tipo de tabla similar a CollapsingMergeTree pero añadiendo un campo adicional llamado Version. Incluir este nuevo campo de Version permite realizar cambios continuamente del estado de los datos sin tener que estar cambiando el estado de los anteriores guardados previamente.
  • GraphiteMergeTree: tabla diseñada para almacenar datos para Graphite.

Indicar que los procesos de compactación de cualquiera de los tipos de tablas indicados anteriormente, ClickHouse, los realiza en background y en cualquier momento sin posibilidad de planificación.

Para entrar más en detalle sobre este tipo de tablas os recomiendo leer el apartado relativo a ellas en MergeTree Family.

Por otro lado tenemos otros tipos de tablas donde la característica especial es que no soportan índices. En este caso ClickHouse nos proporciona:

  • TinyLog: son las tablas más simples destinadas a almacenar pequeños volúmenes de datos no superiores al millón de filas. Se caracteriza por almacenar cada columna en diferentes ficheros comprimidos. Se utiliza para situaciones donde se escribe una vez y luego se realizan multitud de consultas, habitualmente se utiliza para almacenar datos temporalmente que son procesados en pequeños batches. Hay que tener cuidado con este tipo de tablas ya que si se escribe y se lee al mismo tiempo las consultas retornarán un error.
  • Log: similar a TinyLog pero añadiendo un pequeño fichero llamado “marks” que almacena información para ir saltando un determinado número de filas.
  • Memory: son tablas cuyos valores se almacenan en memoria y por tanto no se realiza ningún tipo de compresión de los datos. Los datos almacenados son volátiles, es decir, si se produce un reinicio se perderán. Este tipo de tablas se utiliza habitualmente para realizar pruebas.
  • Buffer: son tablas que almacenan los datos en memoria y periódicamente las depositan en otra tabla.
  • External Data: permite cargar recursos externos como si fueran tablas para su uso en consultas.

Y por último existen otros tipos de tablas especiales:

  • Distributed: realmente no es una tabla que contenga datos sino que permite distribuir las consultas sobre aquellos servidores donde se encuentran los datos y posteriormente unificarlos y retornar el resultado de la consulta.
  • Dictionary: son como si fueran tablas maestras que se definen en la configuración de ClickHouse. Son cargados en memoria en el arranque del servidor estando disponibles para utilizarlas en las consultas.
  • Merge: no confundir con la familia de tablas MergeTree, únicamente permite unir varias tablas leyéndolas de forma paralela. Estas tablas son sólo de lectura y no están permitidas las escrituras.
  • File: son tablas creadas a partir de los valores de un fichero.
  • Null: tabla como puede ser la tabla DUAL en Oracle. Las escrituras son ignoradas y las lecturas no retornan ninguna fila.
  • URL: son tablas creadas a partir de los datos retornados de una URL.
  • View: similar a las vistas de cualquier otra base de datos.
  • MaterializedView: similar a las tablas materializadas de Oracle.
  • Kafka: permite integrarnos con este sistema de mensajes consumiendo eventos que se produzcan en él.
  • MySQL: permite almacenar en una base de datos remota de MySQL una query ejecutada sobre ClickHouse.

3.2 Interfaces de comunicación

ClickHouse ofrece dos formas de comunicarnos: mediante HTTP o por TCP utilizando su propio protocolo. Utilizando estas dos interfaces existen multitud de herramientas o drivers para acceder de forma más amigable. Yandex ofrece un cliente por línea de comandos similar al comando mysql y nos permite ejecutar cualquier tipo de consulta sobre ClickHouse. También ofrece drivers JDBC y ODBC que permite usarse con cualquier librería de acceso a datos como puede ser Spring-JDBC, MyBatis, etc..

Indicar que estas dos implementaciones hacen uso del interfaz HTTP y el rendimiento puede ser un poco menor al nativo al tener mayor sobrecarga. En el caso de que quisiéramos utilizar un driver JDBC que utilice el interfaz nativo podríamos usar una librería de terceros ClickHouse-Native-JDBC pero hay que indicar que la última versión tiene leaks de memoria y fallos en la conexión por lo que en el momento del tutorial no aconsejamos utilizarlo.

Además de las librerías o herramientas que proporciona el propio Yandex existen implementaciones de terceros como: clientes para cualquiera de los lenguajes más usado hoy día, aplicaciones visuales como puede ser Tabix, HouseOps, LightHouse, DBeaver, DataGrip, etc..

3.3 Ejemplo de uso

Una vez que hemos visto un poco las características básicas de ClickHouse vamos a realizar un ejemplo utilizando el driver JDBC de Yandex. Para el ejemplo vamos a utilizar la base de datos que proporciona Transtats sobre los vuelos de las aerolíneas desde 1987. Para nuestro ejemplo sólo cargaremos los años que van desde 2015 al 2017 que corresponde con más de 17 millones de registros y usaremos las imágenes Docker del servidor y cliente de ClickHouse que proporciona Yandex.

Lo primero es arrancar un servidor de ClickHouse, evidentemente antes debemos tener instalado Docker en nuestro sistema. Abrimos un terminal y tecleamos:

docker run -d --name some-clickhouse-server -p 8123:8123 -p 9000:9000 -p 9009:9009 --ulimit nofile=262144:262144 yandex/clickhouse-serverA continuación, arrancamos el cliente de ClickHouse y accedemos al cliente abriendo una shell para instalar los comandos wget y unzip necesarios para descargar y cargar los datos en ClickHouse:

> docker run -it --name some-clickhouse-client --rm --link some-clickhouse-server:clickhouse-server yandex/clickhouse-client --host clickhouse-server
 > docker exec -it some-clickhouse-client bash
 root@adaf832bf64d:/> apt-get  update && apt-get install wget unzip

Ya tenemos preparado nuestro entorno para cargar los datos. Lo primero nos descargamos la información de cada mes desde el 2015 al 2017:

root@adaf832bf64d:/>  for s in `seq 2015 2017`
do
for m in `seq 1 12`
do
wget https://transtats.bts.gov/PREZIP/On_Time_Reporting_Carrier_On_Time_Performance_1987_present_${s}_${m}.zip

done
done

Ahora creamos la tabla en ClickHouse donde vamos a almacenar los datos. Arrancamos un cliente de ClickHouse:

root@adaf832bf64d:/> clickhouse-client --host clickhouse-server
ClickHouse client version 18.14.18.
Connecting to clickhouse-server:9000.
Connected to ClickHouse server version 18.14.18 revision 54409.

Y ejecutamos la siguiente sentencia:

CREATE TABLE ontime (
  Year UInt16,
  Quarter UInt8,
  Month UInt8,
  DayofMonth UInt8,
  DayOfWeek UInt8,
  FlightDate Date,
  UniqueCarrier FixedString(7),
  AirlineID Int32,
  Carrier FixedString(2),
  TailNum String,
  FlightNum String,
  OriginAirportID Int32,
  OriginAirportSeqID Int32,
  OriginCityMarketID Int32,
  Origin FixedString(5),
  OriginCityName String,
  OriginState FixedString(2),
  OriginStateFips String,
  OriginStateName String,
  OriginWac Int32,
  DestAirportID Int32,
  DestAirportSeqID Int32,
  DestCityMarketID Int32,
  Dest FixedString(5),
  DestCityName String,
  DestState FixedString(2),
  DestStateFips String,
  DestStateName String,
  DestWac Int32,
  CRSDepTime Int32,
  DepTime Int32,
  DepDelay Int32,
  DepDelayMinutes Int32,
  DepDel15 Int32,
  DepartureDelayGroups String,
  DepTimeBlk String,
  TaxiOut Int32,
  WheelsOff Int32,
  WheelsOn Int32,
  TaxiIn Int32,
  CRSArrTime Int32,
  ArrTime Int32,
  ArrDelay Int32,
  ArrDelayMinutes Int32,
  ArrDel15 Int32,
  ArrivalDelayGroups Int32,
  ArrTimeBlk String,
  Cancelled UInt8,
  CancellationCode FixedString(1),
  Diverted UInt8,
  CRSElapsedTime Int32,
  ActualElapsedTime Int32,
  AirTime Int32,
  Flights Int32,
  Distance Int32,
  DistanceGroup UInt8,
  CarrierDelay Int32,
  WeatherDelay Int32,
  NASDelay Int32,
  SecurityDelay Int32,
  LateAircraftDelay Int32,
  FirstDepTime String,
  TotalAddGTime String,
  LongestAddGTime String,
  DivAirportLandings String,
  DivReachedDest String,
  DivActualElapsedTime String,
  DivArrDelay String,
  DivDistance String,
  Div1Airport String,
  Div1AirportID Int32,
  Div1AirportSeqID Int32,
  Div1WheelsOn String,
  Div1TotalGTime String,
  Div1LongestGTime String,
  Div1WheelsOff String,
  Div1TailNum String,
  Div2Airport String,
  Div2AirportID Int32,
  Div2AirportSeqID Int32,
  Div2WheelsOn String,
  Div2TotalGTime String,
  Div2LongestGTime String,
  Div2WheelsOff String,
  Div2TailNum String,
  Div3Airport String,
  Div3AirportID Int32,
  Div3AirportSeqID Int32,
  Div3WheelsOn String,
  Div3TotalGTime String,
  Div3LongestGTime String,
  Div3WheelsOff String,
  Div3TailNum String,
  Div4Airport String,
  Div4AirportID Int32,
  Div4AirportSeqID Int32,
  Div4WheelsOn String,
  Div4TotalGTime String,
  Div4LongestGTime String,
  Div4WheelsOff String,
  Div4TailNum String,
  Div5Airport String,
  Div5AirportID Int32,
  Div5AirportSeqID Int32,
  Div5WheelsOn String,
  Div5TotalGTime String,
  Div5LongestGTime String,
  Div5WheelsOff String,
  Div5TailNum String
) ENGINE = MergeTree(FlightDate, (Year, FlightDate), 8192)

Salimos del cliente ejecutando el comando “exit” y a continuación, cargamos los datos utilizando el cliente de ClickHouse:

root@adaf832bf64d:/> for i in *.zip; do echo $i; unzip -cq $i '*.csv' | sed 's/.00//g' | clickhouse-client --host=clickhouse-server --query="INSERT INTO ontime FORMAT CSVWithNames"; done

Esto nos llevará un poco de tiempo. Una vez que el proceso de carga haya finalizado podemos realizar una prueba para ver si se han cargado los datos.

root@adaf832bf64d:/> clickhouse-client --host clickhouse-server
ClickHouse client version 18.14.18.
Connecting to clickhouse-server:9000.
Connected to ClickHouse server version 18.14.18 revision 54409.

4d42b6740871 :)  select count() from ontime

SELECT count()
FROM ontime 

┌──count()─┐
│ 17597523 │
└──────────┘

1 rows in set. Elapsed: 0.031 sec. Processed 17.60 million rows, 17.60 MB (569.78 million rows/s., 569.78 MB/s.) 

4d42b6740871 :)

Ahora vamos a crear una pequeña aplicación REST con Spring-Boot usando el driver JDBC de Yandex con Spring-JDBC. Creamos un proyecto Maven con el siguiente pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.autenita.training</groupId>
    <artifactId>spring-boot-training</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.1.RELEASE</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>ru.yandex.clickhouse</groupId>
            <artifactId>clickhouse-jdbc</artifactId>
            <version>0.1.48</version>
        </dependency>
    </dependencies>
</project>

En el directorio de resources creamos el fichero de configuración de Spring-Boot (application.yaml) bajo el directorio config:

debug: true
logging:
   path: /tmp
   file:
      max-size: 10MB
      max-history: 2
   group:
      tomcat: org.apache.catalina, org.apache.coyote, org.apache.tomcat            
   level:
      root: WARN
      tomcat: ERROR
      web: DEBUG
      org.hibernate: ERROR
      org.springframework.data: DEBUG
spring:
   datasource:
      driver-class-name: ru.yandex.clickhouse.ClickHouseDriver
      username: default
      password: 
      url: jdbc:clickhouse://127.0.0.1:8123
      connectionTimeout: 67000
      hikari:
         connectionTimeout: 67000
         idleTimeout: 600000
         maxLifetime: 1800000

Como podemos ver hemos configurado la conexión con ClickHouse que utilizará Spring-JDBC utilizando la librería de Hikari.

Ahora creamos la clase de arranque de Spring-Boot:

package com.autentia.training.springboot;

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;

@SpringBootApplication
public class ApplicationMain {

    public static void main(String[] args) throws Exception {
        new SpringApplicationBuilder(ApplicationMain.class)
            .run(args);
    }
}

En el ejemplo vamos a crear un endpoint que nos retorne el número de vuelos de cada aerolínea filtrando por año. Para ello nos creamos el controlador que reciba la petición:

package com.autentia.training.springboot.statistics.airlines.rest;

import java.util.List;

import javax.validation.constraints.Size;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.autentia.training.springboot.statistics.airlines.model.AirlineFlightsNumber;
import com.autentia.training.springboot.statistics.airlines.service.StatisticsAirlinesService;

@RestController
@RequestMapping("/statistics/airlines")
public class StatisticsAirlinesController {

    private StatisticsAirlinesService statisticsService;

    public StatisticsAirlinesController(StatisticsAirlinesService statisticsService) {
        this.statisticsService = statisticsService;

    }

    @GetMapping("/flights")
    public List<AirlineFlightsNumber> getFlightsByYear(@Size(min=4, max= 4) @RequestParam("year") int year) {
        return statisticsService.getFlightsByYear(year);
    }

}

Ahora creamos el servicio:

package com.autentia.training.springboot.statistics.airlines.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.autentia.training.springboot.statistics.airlines.model.AirlineFlightsNumber;
import com.autentia.training.springboot.statistics.airlines.repository.StatisticsAirlinesRepository;

@Service
public class StatisticsAirlinesService {

    private StatisticsAirlinesRepository statisticsRepository;

    public StatisticsAirlinesService (StatisticsAirlinesRepository statisticsRepository) {
        this.statisticsRepository = statisticsRepository;

    }

    public List<AirlineFlightsNumber> getFlightsByYear(int year) {
        return statisticsRepository.getFlightsByYear(year);
    }

}

Ahora el repositorio:

package com.autentia.training.springboot.statistics.airlines.repository;

import java.util.List;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import com.autentia.training.springboot.statistics.airlines.model.AirlineFlightsNumber;

@Repository
public class StatisticsAirlinesRepository {

    private JdbcTemplate jdbcTemplate;

    public StatisticsAirlinesRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;

    }

    public List<AirlineFlightsNumber> getFlightsByYear(int year) {
        return jdbcTemplate.query(
                " select Carrier, sum(AirlineID) as flights from ontime where Year = ? group by Carrier order by flights desc",
                new Object[] { year }, (rs, rowNum) -> {
                    return new AirlineFlightsNumber(rs.getString("Carrier"), rs.getLong("flights"));
                });
    }

}

Y por último el modelo:

package com.autentia.training.springboot.statistics.airlines.model;

public class AirlineFlightsNumber {

    private final String airline;
    private final long flights;

    public AirlineFlightsNumber(String airline, long flights) {
        this.airline = airline;
        this.flights = flights;
    }

    public String getAirline() {
        return airline;
    }

    public long getFlights() {
        return flights;
    }

}

Ahora arrancamos la aplicación y con un client ejecutamos por ejemplo que nos retorne los vuelos por aerolínea realizados en 2016:

GET http://localhost:8080/statistics/airlines/flights?year=2016

[
    {
        "airline": "WN",
        "flights": 25200117492
    },
    {
        "airline": "DL",
        "flights": 18261143340
    },
    {
        "airline": "AA",
        "flights": 18111573475
    },
    {
        "airline": "OO",
        "flights": 12302863632
    },
    {
        "airline": "UA",
        "flights": 10888803459
    },
    {
        "airline": "EV",
        "flights": 9999502340
    },
    {
        "airline": "B6",
        "flights": 5764991457
    },
    {
        "airline": "AS",
        "flights": 3533190400
    },
    {
        "airline": "NK",
        "flights": 2821470784
    },
    {
        "airline": "F9",
        "flights": 1943892756
    },
    {
        "airline": "HA",
        "flights": 1511975410
    },
    {
        "airline": "VX",
        "flights": 1463360691
    }
]

Como podéis ver los tiempos de respuesta de la petición están por debajo de 50 ms recorriendo los más de 17 millones de registros.

Se puede descargar el código completo desde:

git clone https://github.com/angelusGJ/clickhouse_base_example

4. Conclusión

Como habéis visto trabajar con ClickHouse para realizar analítica del dato es bastante sencillo ya que se trabajaría como cualquier otra base de datos. Lo realmente importante a la hora de trabajar con este tipo de base de datos es estructurar correctamente la información en base a las consultas que se vayan a realizar y elegir correctamente el tipo de tabla.

Lo único que hecho en falta es más documentación ya que la única documentación que existe es la documentación oficial. Aunque también hay que decir que es bastante buena y es suficiente para exprimir al máximo.

5. Referencias

La entrada Introducción a ClickHouse se publicó primero en Adictos al trabajo.

Cliente java REST de alto nivel de elasticsearch RestHighLevelClient

$
0
0

 

  1. Introducción
  2. Entorno
  3. Inicialización de elasticsearch
  4. Creación del cliente
  5. Creación de índices
  6. Inserción de datos
  7. Búsqueda de datos
  8. Conclusiones
  9. Bibliografía

Introducción

En la versión 6 de elasticsearch se puede leer en sus cambios “This Java High Level REST Client is designed to replace the TransportClient in a near future.” y en los cambios de la versión 7 se puede leer “the transport client will be removed in the future”, por lo tanto, es importante empezar a migrar el cliente de transporte al nuevo cliente oficial de elasticsearch y no tener problemas cuando queramos subir la versión de nuestro cluster.

Hay que saber que con el nuevo cliente podemos hacer todas las peticiones disponibles en la API de elasticsearch y, por lo tanto, nos abstrae de todo el tedioso trabajo de hacer peticiones usando un cliente REST java hecho por nosotros.

Entorno

  • Hardware: Portátil MacBook Pro 15′ (2.5 GHz Intel Core i7, 16GB DDR3)
  • Sistema Operativo: MacOS Mojave 10.14.2
  • Docker Desktop 2.0.0.2 para generar un nodo de elasticsearch con la versión 6.5.4
  • Jdk 11.0.1

Inicialización de elasticsearch

Es necesario tener levantado un elasticsearch al que hacer peticiones para probar el cliente, en mi caso, lo levantaré con docker-compose, aunque podremos tenerlo instalado de cualquier otra manera.

Si queremos usar docker-compose, tendremos que tener instalado docker en nuestra máquina y ejecutar el comando:

docker-compose -p=tutorial-client-elasticsearch up

Para que funcione este comando debemos tener un archivo con el nombre docker-compose.yml en el directorio que lo lancemos o usar la opción -f para especificar el nombre del fichero. La opción -p se usa para elegir el nombre del proyecto y así identificar fácilmente las redes, volumenes y contenedores que genera docker-compose.

Nuestro docker-compose.yml tendrá la siguiente forma:

version: '3'

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:6.5.4
    environment:
      - cluster.name=tutorial-elasticsearch-cluster
      - node.name=tutorial-elasticsearch-nodo
      - xpack.security.enabled=false
    ports:
      - '9200:9200/tcp'
    container_name: tutorial-client-elasticsearch

Para comprobar que el cluster está levantado podemos lanzar una petición con cualquier navegador o postman para ver las estadísticas del cluster. Teniendo en cuenta que tenemos elasticsearch levantado el localhost exponiendo el puerto 9200.

GET http://localhost:9200/_cluster/stats

La respuesta tendrá que ser del tipo:

{
    "_nodes": {
        "total": 1,
        "successful": 1,
        "failed": 0
    },
    "cluster_name": "tutorial-elasticsearch-cluster",
    "cluster_uuid": "EE4PFmBDRpiEhFZrUZrB6A",
    "timestamp": 1548171423308,
    "status": "green",
    "indices": {
        "count": 0,
        "shards": {},
        "docs": {
            "count": 0,
            "deleted": 0
        },
        "store": {
            "size_in_bytes": 0
        },
        "fielddata": {
            "memory_size_in_bytes": 0,
            "evictions": 0
        },
        "query_cache": {
            "memory_size_in_bytes": 0,
            "total_count": 0,
            "hit_count": 0,
            "miss_count": 0,
            "cache_size": 0,
            "cache_count": 0,
            "evictions": 0
        },
        "completion": {
            "size_in_bytes": 0
        },
        "segments": {
            "count": 0,
            "memory_in_bytes": 0,
            "terms_memory_in_bytes": 0,
            "stored_fields_memory_in_bytes": 0,
            "term_vectors_memory_in_bytes": 0,
            "norms_memory_in_bytes": 0,
            "points_memory_in_bytes": 0,
            "doc_values_memory_in_bytes": 0,
            "index_writer_memory_in_bytes": 0,
            "version_map_memory_in_bytes": 0,
            "fixed_bit_set_memory_in_bytes": 0,
            "max_unsafe_auto_id_timestamp": -9223372036854775808,
            "file_sizes": {}
        }
    },
    "nodes": {
        "count": {
            "total": 1,
            "data": 1,
            "coordinating_only": 0,
            "master": 1,
            "ingest": 1
        },
        "versions": [
            "6.5.4"
        ],
        "os": {
            "available_processors": 8,
            "allocated_processors": 8,
            "names": [
                {
                    "name": "Linux",
                    "count": 1
                }
            ],
            "mem": {
                "total_in_bytes": 16803217408,
                "free_in_bytes": 13421002752,
                "used_in_bytes": 3382214656,
                "free_percent": 80,
                "used_percent": 20
            }
        },
        "process": {
            "cpu": {
                "percent": 1
            },
            "open_file_descriptors": {
                "min": 312,
                "max": 312,
                "avg": 312
            }
        },
        "jvm": {
            "max_uptime_in_millis": 107908,
            "versions": [
                {
                    "version": "11.0.1",
                    "vm_name": "OpenJDK 64-Bit Server VM",
                    "vm_version": "11.0.1+13",
                    "vm_vendor": "Oracle Corporation",
                    "count": 1
                }
            ],
            "mem": {
                "heap_used_in_bytes": 171236144,
                "heap_max_in_bytes": 1037959168
            },
            "threads": 39
        },
        "fs": {
            "total_in_bytes": 94221574144,
            "free_in_bytes": 62299418624,
            "available_in_bytes": 57482735616
        },
        "plugins": [
            {
                "name": "ingest-user-agent",
                "version": "6.5.4",
                "elasticsearch_version": "6.5.4",
                "java_version": "1.8",
                "description": "Ingest processor that extracts information from a user agent",
                "classname": "org.elasticsearch.ingest.useragent.IngestUserAgentPlugin",
                "extended_plugins": [],
                "has_native_controller": false
            },
            {
                "name": "ingest-geoip",
                "version": "6.5.4",
                "elasticsearch_version": "6.5.4",
                "java_version": "1.8",
                "description": "Ingest processor that uses looksup geo data based on ip adresses using the Maxmind geo database",
                "classname": "org.elasticsearch.ingest.geoip.IngestGeoIpPlugin",
                "extended_plugins": [],
                "has_native_controller": false
            }
        ],
        "network_types": {
            "transport_types": {
                "netty4": 1
            },
            "http_types": {
                "netty4": 1
            }
        }
    }
}

Llegado a este punto, empezamos con el objetivo de este tutorial, ver cómo funciona el cliente de elasticsearch.

Creación del cliente

En primer lugar, necesitamos importar la dependencia de maven o gradle al proyecto.

Para maven, necesitamos importar la dependencia en el pom.xml

<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    <version>6.5.4</version>
</dependency>

En caso de gradle, tendremos que añadir nuestra dependencia en el fichero build.gradle

dependencies {
    compile 'org.elasticsearch.client:elasticsearch-rest-high-level-client:6.5.4'
}

Una vez importada la dependencia, ya podemos usar el cliente dentro de nuestro proyecto y podremos inicializarlo. Con el cluster que tengo inicializado en docker, podría crearlo con la siguiente línea:

RestHighLevelClient client = new RestHighLevelClient(
                RestClient.builder(
                        new HttpHost("localhost", 9200, "http")));

De esta forma ya podemos empezar a hacer peticiones con este cliente. Siempre hay que tener en cuenta que el cliente de elasticsearch va a tener una respuesta diferente para cada petición y, por lo tanto, la petición como la respuesta, van a estar definidas por una clase java concreta.

Vamos con nuestro primer código java, consultar la información básica del cluster y obtener una respuesta del tipo “MainResponse”.

RestHighLevelClient client = new RestHighLevelClient(
        RestClient.builder(
                new HttpHost("localhost", 9200, "http")));
LOGGER.info("Cliente conectado. ");

MainResponse response = client.info(RequestOptions.DEFAULT);
ClusterName clusterName = response.getClusterName();
String clusterUuid = response.getClusterUuid();
String nodeName = response.getNodeName();
Version version = response.getVersion();

LOGGER.info("Información del cluster: ");

LOGGER.info("Nombre del cluster: {}", clusterName.value());
LOGGER.info("Identificador del cluster: {}", clusterUuid);
LOGGER.info("Nombre de los nodos del cluster: {}", nodeName);
LOGGER.info("Versión de elasticsearch del cluster: {}", version.toString());

client.close();
LOGGER.info("Cliente desconectado.");

si ejecutamos el código anterior y vemos el resultado por pantalla, obtendremos:

Cliente conectado. 
Información del cluster: 
Nombre del cluster: tutorial-elasticsearch-cluster
Identificador del cluster: EE4PFmBDRpiEhFZrUZrB6A
Nombre de los nodos del cluster: tutorial-elasticsearch-nodo
Versión de elasticsearch del cluster: 6.5.4
Cliente desconectado.

En esta sección hemos visto cómo crear el cliente de elasticsearch y una petición para consultar la información básica del cluster. A continuación, veremos una serie de ejemplos sencillos que nos puede ilustrar la facilidad de uso de este cliente.

Creación de índices

Para crear un índice, usaremos una petición “CreateIndexRequest” y obtendremos una respuesta del tipo “CreateIndexResponse”, he elegido crear un índice con un “mapping” sencillo y sin “settings” para que quede un ejemplo más fácil y claro, pero se puede ver en la documentación de elasticsearch cómo añadir estas opciones a la petición.

Vamos a crear el índice “temas” con el tipo “tema”. Este tipo tendrá un solo campo “message” de tipo texto.

Para este ejemplo usaremos el siguiente código:

RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(new HttpHost("localhost", 9200, "http")));
LOGGER.info("Cliente conectado. ");

CreateIndexRequest request = new CreateIndexRequest("temas");
request.mapping("tema", "message", "type=text");
CreateIndexResponse createIndexResponse = client.indices().create(request, RequestOptions.DEFAULT);

boolean acknowledged = createIndexResponse.isAcknowledged();
LOGGER.info("Indice creado: {}", acknowledged);

client.close();
LOGGER.info(" Cliente desconectado.");

Y veremos que por consola se imprime:

Cliente conectado.
WARNING: request [PUT http://localhost:9200/temas?master_timeout=30s&timeout=30s] returned 1 warnings: [299 Elasticsearch-6.5.4-d2ef93d "the default number of shards will change from [5] to [1] in 7.0.0; if you wish to continue using the default of [5] shards, you must manage this on the create index request or with an index template" "xxx, xx xxx xxxx xx:xx:xx GMT"]
Indice creado: true
Cliente desconectado.

Es posible que en este punto veamos un WARNING del cliente, es algo normal, nos avisa de los futuros cambios en nuevas versiones. Una funcionalidad que puede ayudar a los programadores a anticiparse a futuros fallos cuando subamos de versión.

Pero una vez creado el índice, nos interesaría rellenarlo con una serie de datos, para ello usaremos la Bulk API de elasticsearch con la que podemos crear, editar o eliminar documentos de cualquier índice de forma masiva.

Inserción de datos

Aunque se pueden añadir documentos uno a uno a elasticsearch con peticiones PUT sobre un índice usando el cliente, para este ejemplo, usaremos la Bulk API. Usando la Bulk API con una sola petición http podemos añadir miles de documentos a un índice.

En el tutorial añadiremos 8 temas con diferentes mensajes.

Para atacar la Bulk API usaremos el siguiente código:

RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(new HttpHost("localhost", 9200, "http")));
LOGGER.info("Cliente conectado. ");

BulkRequest request = new BulkRequest();
request.add(new IndexRequest("temas", "tema", "1").source(XContentType.JSON,"message", "Introducción"));
request.add(new IndexRequest("temas", "tema", "2").source(XContentType.JSON,"message", "Entorno"));
request.add(new IndexRequest("temas", "tema", "3").source(XContentType.JSON,"message", "Inicialización de elasticsearch"));
request.add(new IndexRequest("temas", "tema", "4").source(XContentType.JSON,"message", "Creación del cliente"));
request.add(new IndexRequest("temas", "tema", "5").source(XContentType.JSON,"message", "Creación de índices"));
request.add(new IndexRequest("temas", "tema", "6").source(XContentType.JSON,"message", "Inserción de datos"));
request.add(new IndexRequest("temas", "tema", "7").source(XContentType.JSON,"message", "Búsqueda de datos"));
request.add(new IndexRequest("temas", "tema", "8").source(XContentType.JSON,"message", "Conclusiones"));

BulkResponse bulkResponse = client.bulk(request, RequestOptions.DEFAULT);
LOGGER.info("Bulk con errores: {}", bulkResponse.hasFailures());

client.close();
LOGGER.info("Cliente desconectado.");

el resultado obtenido de esta parte es:

Cliente conectado. 
Bulk con errores: false
Cliente desconectado.

Vemos que el bulk no ha fallado y que todos los documentos deberían estar insertados en nuestro índice “temas”. Hay que recordar que aunque la Bulk API de fallos, esto no significa que no se ha insertado ningún documento, con que solo un documento de fallo, la Bulk API contiene errores, pero el resto de documentos se insertarían. En el siguiente punto vamos a ver cómo comprobar esto.

Búsqueda de datos

Como ocurre en elasticsearch cada cosa la podemos hacer de varias maneras, en este tutorial, usaremos la Search API y haremos una query para encontrar todos los documentos del índice “temas”, aunque, de forma parecida, usando QueryBuilders del cliente, se pueden realizar todo tipo de búsquedas.

RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(new HttpHost("localhost", 9200, "http")));
LOGGER.info("Cliente conectado.");

SearchRequest searchRequest = new SearchRequest("temas");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.matchAllQuery());
searchRequest.source(searchSourceBuilder);

SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);

for (SearchHit hit: searchResponse.getHits().getHits()){
    LOGGER.info("Documento con id {}: {}", hit.getId(), hit.getSourceAsString());
}

client.close();
LOGGER.info("Cliente desconectado.");

ejecutando este código anterior veremos por pantalla:

Cliente conectado.
Documento con id 5: {"message":"Creación de índices"}
Documento con id 8: {"message":"Conclusiones"}
Documento con id 2: {"message":"Entorno"}
Documento con id 4: {"message":"Creación del cliente"}
Documento con id 6: {"message":"Inserción de datos"}
Documento con id 1: {"message":"Introducción"}
Documento con id 7: {"message":"Búsqueda de datos"}
Documento con id 3: {"message":"Inicialización de elasticsearch"}
Cliente desconectado.

Con esta llamada hemos podido comprobar que el código de la Bulk API funcionaba como esperábamos y además hemos aprendido una de las formas más sencillas de realizar una petición a elasticsearch.

En realidad, las líneas:

SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.matchAllQuery());
searchRequest.source(searchSourceBuilder);

no son necesarias, ya que por defecto haremos una búsqueda que encuentre todos los documentos de un índice, pero con este pequeño ejemplo hemos visto la facilidad de añadir una query a la petición de búsqueda.

Conclusiones

En este tutorial hemos visto la facilidad de usar el cliente de elasticsearch. Con estos ejemplos también vemos que es muy fácil leer el código y entender lo que hace el cliente, pero esto no sucede al desarrollar.

Cuando estamos desarrollando deberíamos apoyarnos constantemente de la documentación oficial del cliente ya que tiene muchísimas clases y cada una sirve para hacer una petición concreta.

Además de esto, hay que estar atentos a los WARNING que saca el cliente de elasticsearch ya que puede hacer que nos anticipemos a los futuros problemas que puedan surgir cuando subamos la versión de elasticsearch. Puede darnos información sobre funcionalidad deprecada y cambios de configuración de futuras versiones.

Por último, hay que tener en cuenta que si estamos usando el cliente Java de Transporte hay que prever que debemos cambiarlo por el cliente REST de alto nivel porque éste lo sustituirá y en un futuro no podremos usar el cliente de Transporte para comunicarnos con futuras versiones de elasticsearch.

Bibliografía

Cliente Java High REST Client versión 6.5.4

La entrada Cliente java REST de alto nivel de elasticsearch RestHighLevelClient se publicó primero en Adictos al trabajo.

Animaciones en Android

$
0
0

Índice de contenidos

1. Introducción

El objetivo de este tutorial es servir de introducción a las animaciones en Android. Para alguno de los tipos de animaciones se mostrarán ejemplos sencillos mientras que otros tan sólo se describirán brevemente.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: MacBook Pro 17’ (2,66 GHz Intel Core i7, 8GB DDR3)
  • Sistema operativo: macOS Sierra 10.13.6
  • Entorno de desarrollo: Android Studio 3.3
  • Versión SDK mínima: 21

3. El paquete Animation

Android proporciona varias formas para animar las vistas. El primer paquete que vamos a evaluar es Animation, el cual se utiliza para animaciones simples. En él podemos encontrar las siguientes subclases:

  • AlphaAnimation: permite modificar el valor alpha de un objeto y cambiar su opacidad.
  • RotateAnimation: permite rotar un objeto.
  • ScaleAnimation: permite alterar el tamaño de un objeto.
  • TranslateAnimation: permite modificar la posición de un objeto.

 

Para utilizarlo, tan sólo tenemos que definir una animación en un fichero XML en la carpeta res>anim y aplicarlo a alguna vista. Si lo preferimos, podemos crear un objeto programáticamente en vez de con el XML.

Algunas de las propiedades que podemos definir son las siguientes:

  • duration: el tiempo que dura la animación en milisegundos.
  • fillAfter: si es true, los cambios de la animación permanecerán cuando termine, si no el objeto volverá a su estado inicial.
  • fillBefore y fillEnabled: si fillEnabled es false o si fillEnabled y fillBefore son true se aplican los cambios de la animación antes de iniciarse.
  • interpolator: los interpolators controlan la velocidad y la aceleración de los cambios de la animación.
  • repeatCount: cuántas veces se repite la animación.
  • repeatMode: define el comportamiento de la animación.
  • startOffset: retraso antes de iniciar la animación en milisegundos.

 

Además, podemos establecer un AnimationListener para controlar qué sucede cuando la animación se inicia, se repite y termina.

3.1. Ejemplo de Animation

Como ejemplo, vamos a animar esta pelota moviéndola desde la izquierda de la pantalla a la derecha.

pelota
Icono creado por Freepik de www.flaticon.com con licencia CC 3.0 BY

La imagen original es una imagen vectorial que hemos transformado para ser usado en Android. Para poder usarlo, crea un nuevo drawable resource file y pega el código que viene a continuación.

Creación de un drawable resource file

ball.xml

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
   android:viewportWidth="512"
   android:viewportHeight="512"
   android:width="640dp"
   android:height="640dp">
   <path
       android:pathData="M323.660156 263.214844l-67.660156 -16.839844 -59.238281 14.996094 -63.101563 135.546875 61.570313 100.085937c19.449219 4.886719 39.804687 7.496094 60.769531 7.496094 64.582031 0 123.402344 -24.640625 167.59375 -65.027344v-72.554687zm0 0"
       android:fillColor="#91DAFF" />
   <path
       android:pathData="M310.519531 13.515625l-141.859375 63.402344 54.433594 76.804687 53.03125 46.34375 90.535156 46.050782 125.980469 -66.125c-26.679687 -83.132813 -95.941406 -147.183594 -182.121094 -166.476563zm0 0"
       android:fillColor="#FF6881" />
   <path
       android:pathData="M276.125 200.066406s106.402344 -35.152344 216.515625 -20.074218c7.691406 23.960937 11.859375 49.496093 11.859375 76.007812 0 72.660156 -31.191406 138.035156 -80.90625 183.472656 -91.242188 -74.796875 -167.59375 -193.097656 -167.59375 -193.097656zm0 0"
       android:fillColor="#4CB4A4" />
   <path
       android:pathData="M176.660156 114.683594l-38 -40.765625 -61.019531 9.070312c-43.40625 44.742188 -70.140625 105.75 -70.140625 173.011719 0 27.160156 4.371094 53.292969 12.425781 77.757812l88.417969 -53.019531 64.367188 -72.191406 -12.183594 -44.300781zm0 0"
       android:fillColor="#F9DA60" />
   <path
       android:pathData="M296 474.5c-20.964844 0 -41.320312 -2.609375 -60.769531 -7.496094 -29.550781 -7.429687 -57 -20.152344 -81.277344 -37.097656l41.277344 67.097656c19.449219 4.886719 39.800781 7.496094 60.769531 7.496094 64.578125 0 123.402344 -24.640625 167.589844 -65.027344v-0.210937c-37.296875 22.367187 -80.9375 35.238281 -127.589844 35.238281zm0 0"
       android:fillColor="#4D9AE8" />
   <path
       android:pathData="M172.710938 208.550781l24.050781 52.820313s-25.886719 96.160156 -1.53125 235.632812c-82.667969 -20.78125 -148.878907 -82.976562 -175.304688 -163.246094 67.277344 -86.894531 152.785157 -125.207031 152.785157 -125.207031zm0 0"
       android:fillColor="#FF9EC0" />
   <path
       android:pathData="M256 7.5c18.730469 0 36.972656 2.085938 54.519531 6.015625 -61.339843 66.816406 -87.425781 140.207031 -87.425781 140.207031l-62.566406 10.523438s-33.414063 -41.433594 -82.886719 -81.253906c45.164063 -46.550782 108.378906 -75.492188 178.359375 -75.492188zm0 0"
       android:fillColor="#6DD1E0" />
   <path
       android:pathData="M276.128906 200.066406c0 34.988282 -28.363281 63.351563 -63.351562 63.351563 -34.984375 0 -63.347656 -28.363281 -63.347656 -63.351563 0 -34.984375 28.363281 -63.347656 63.347656 -63.347656 34.988281 0 63.351562 28.363281 63.351562 63.347656zm0 0"
       android:fillColor="#ECECEE" />
   <path
       android:pathData="M59.925781 303.757812c-8.054687 -24.464843 -12.425781 -50.597656 -12.425781 -77.757812 0 -54.355469 17.464844 -104.625 47.074219 -145.527344l-16.933594 2.519532c-43.40625 44.738281 -70.140625 105.746093 -70.140625 173.007812 0 27.160156 4.371094 53.292969 12.425781 77.757812l41.710938 -25.011718c-0.585938 -1.65625 -1.160157 -3.316406 -1.710938 -4.988282zm0 0"
       android:fillColor="#FFB258" />
   <path
       android:pathData="M87.363281 91.007812c8.851563 -13.652343 18.996094 -26.390624 30.277344 -38.015624 3.292969 -3.394532 6.695313 -6.683594 10.167969 -9.882813 -16.265625 9.816406 -31.398438 21.503906 -45.023438 34.746094 -1.738281 1.691406 -3.457031 3.398437 -5.144531 5.136719 3.316406 2.667968 6.554687 5.34375 9.722656 8.015624zm0 0"
       android:fillColor="#34BED2" />
   <path
       android:pathData="M92.691406 443.285156c29.34375 25.617188 64.757813 44.21875 102.539063 53.71875 -2.835938 -16.234375 -4.980469 -31.867187 -6.574219 -46.84375 -60.539062 -29.042968 -107.492188 -81.878906 -128.730469 -146.402344 -1.230469 -3.742187 -2.367187 -7.523437 -3.421875 -11.34375 -12.382812 12.324219 -24.761718 26.078126 -36.578125 41.34375 3.300781 10.03125 7.226563 19.785157 11.726563 29.207032 14.585937 30.539062 35.542968 58.0625 61.039062 80.320312zm0 0"
       android:fillColor="#FF66A8" />
   <path
       android:pathData="M312.160156 6.195312c-18.367187 -4.109374 -37.261718 -6.195312 -56.160156 -6.195312 -35.238281 0 -69.375 7.03125 -101.464844 20.90625 -30.980468 13.394531 -58.664062 32.523438 -82.277344 56.863281 -46.597656 48.027344 -72.257812 111.324219 -72.257812 178.230469 0 27.347656 4.308594 54.300781 12.804688 80.105469 13.386718 40.667969 37.097656 77.691406 68.5625 107.066406 31.597656 29.492187 70.339843 50.625 112.035156 61.101563 20.390625 5.128906 41.449218 7.726562 62.597656 7.726562 64.070312 0 125.386719 -23.789062 172.652344 -66.988281 52.757812 -48.222657 83.347656 -117.539063 83.347656 -189.011719 0 -26.699219 -4.109375 -53.042969 -12.214844 -78.300781 -27.621094 -86.0625 -99.515625 -151.777344 -187.625 -171.503907zm-151.671875 28.476563c30.199219 -13.054687 62.332031 -19.671875 95.511719 -19.671875 13.472656 0 26.9375 1.125 40.1875 3.351562 -3.894531 4.519532 -7.679688 9.136719 -11.398438 13.804688 -3.847656 4.859375 -7.605468 9.796875 -11.253906 14.808594 -2.4375 3.347656 -1.703125 8.042968 1.644532 10.480468 3.347656 2.4375 8.042968 1.699219 10.480468 -1.648437 8.570313 -11.773437 17.730469 -23.136719 27.457032 -33.972656 77.171874 18.765625 140.558593 75.125 168.484374 149.277343 -0.652343 -0.070312 -2.085937 -0.210937 -2.148437 -0.21875 -87.75 -8.820312 -169.578125 11.554688 -196.523437 19.285157 -0.019532 -0.132813 -0.042969 -0.265625 -0.0625 -0.398438 -1.394532 -9.417969 -4.703126 -18.5625 -9.683594 -26.675781 -7.621094 -12.441406 -19.015625 -22.457031 -32.839844 -28.289062 -0.007812 -0.003907 -0.019531 -0.011719 -0.03125 -0.015626 -0.308594 -0.128906 -0.613281 -0.265624 -0.925781 -0.390624 0.109375 -0.242188 0.21875 -0.472657 0.328125 -0.714844 8.5 -18.289063 18.253906 -35.996094 29.046875 -53.03125 2.21875 -3.5 1.179687 -8.132813 -2.320313 -10.347656 -3.5 -2.21875 -8.132812 -1.179688 -10.347656 2.320312 -11.699219 18.460938 -22.203125 37.640625 -31.269531 57.53125 -0.011719 0.03125 -0.027344 0.058594 -0.039063 0.085938 -0.167968 -0.027344 -0.339844 -0.046876 -0.507812 -0.074219 -17.554688 -2.855469 -36.019532 1.105469 -50.816406 10.996093 -4.792969 3.203126 -9.21875 6.980469 -13.097657 11.246094 -0.019531 -0.027344 -0.046875 -0.058594 -0.070312 -0.085937 -20.390625 -23.121094 -42.199219 -44.789063 -65.5625 -64.902344 -1.960938 -1.671875 -3.9375 -3.328125 -5.914063 -4.984375 20.925782 -20.183594 45 -36.234375 71.671875 -47.765625zm87.59375 208.171875c-9.730469 8.109375 -22.101562 13.066406 -35.304687 13.070312 -29.003906 0.015626 -54.65625 -24.867187 -55.8125 -53.800781 -1.210938 -30.335937 25.527344 -57.902343 55.839844 -57.894531 30.292968 0.011719 55.820312 25.566406 55.820312 55.847656 0 16.902344 -8.109375 32.414063 -20.542969 42.777344zm-169.777343 -149.628906c16.527343 13.644531 32.25 28.261718 47.21875 43.597656 8.871093 9.085938 17.515624 18.40625 25.757812 28.070312 -3.742188 6.515626 -6.390625 13.542969 -7.894531 20.851563 -0.007813 0.035156 -0.015625 0.070313 -0.023438 0.109375 -2.003906 9.566406 -1.894531 19.621094 0.167969 29.171875 0.039062 0.191406 0.070312 0.386719 0.113281 0.578125 -11.929687 6.996094 -23.488281 14.621094 -34.71875 22.6875 -31.152343 22.371094 -59.738281 48.472656 -84.414062 77.855469 -0.371094 0.441406 -0.738281 0.882812 -1.105469 1.324219 -0.117188 0.136718 -0.234375 0.273437 -0.371094 0.433593 -5.335937 -20.121093 -8.035156 -40.882812 -8.035156 -61.894531 0 -60.6875 22.429688 -118.222656 63.304688 -162.785156zm177.695312 403.785156c-18.277344 0 -36.484375 -2.0625 -54.195312 -6.128906 -0.109376 -0.660156 -0.222657 -1.316406 -0.332032 -1.976563 -3.25 -19.804687 -5.699218 -39.9375 -7.234375 -60.023437 -0.355469 -4.675782 -2.71875 -9.183594 -8.167969 -8.78125 -4.132812 0.300781 -7.234374 3.894531 -6.929687 8.027344 0.964844 11.949218 1.777344 23.902343 3.554687 35.765624 0.953126 7.601563 2.019532 15.183594 3.210938 22.75 -34.988281 -10.636718 -67.394531 -29.308593 -94.304688 -54.425781 -28.648437 -26.742187 -50.464843 -60.210937 -63.265624 -97.003906 5.917968 -7.480469 12.082031 -14.765625 18.503906 -21.820313 29.597656 -32.507812 64.097656 -60.640624 101.769531 -83.289062 0.054687 0.121094 0.121094 0.238281 0.179687 0.355469 6.339844 13.28125 16.863282 24.464843 29.730469 31.605469 2.585938 1.433593 8.886719 4.167968 9.339844 4.339843 -0.011719 0.0625 -0.027344 0.128907 -0.039063 0.191407 -2.269531 10.3125 -3.96875 20.757812 -5.386718 31.21875 -4.480469 33.09375 -5.828125 66.617187 -4.855469 99.980468 0.121094 4.082032 3.636719 7.402344 7.71875 7.28125 4.140625 -0.121094 7.398437 -3.578125 7.277344 -7.71875 -0.085938 -2.164062 -0.113281 -4.332031 -0.15625 -6.5 -0.777344 -40.21875 1.582031 -81.136718 9.976562 -120.5625 0.007813 -0.039062 0.015625 -0.082031 0.027344 -0.125 0.949219 0.140625 1.90625 0.257813 2.867187 0.359375 17.285157 1.824219 34.992188 -2.882812 49.0625 -13.085937 20.113282 29.8125 41.929688 58.5 64.863282 86.191406 28.402344 34.304688 58.859375 67.15625 92.859375 96.007812 -43.550781 37.058594 -98.636719 57.367188 -156.074219 57.367188zm219.851562 -142.136719c-12.667968 28.121094 -30.332031 53.191407 -52.554687 74.605469 -14.023437 -11.816406 -27.386719 -24.394531 -40.289063 -37.417969 -32.324218 -32.667969 -61.792968 -68.171875 -89.195312 -105.039062 -9.695312 -13.035157 -19.171875 -26.25 -28.222656 -39.746094 2.410156 -2.699219 4.605468 -5.589844 6.570312 -8.628906 6.363282 -9.824219 10.304688 -21.28125 11.242188 -32.957031 18.847656 -5.5625 89.335937 -24.457032 168.277344 -21.636719 0.050781 0.003906 0.101562 0.003906 0.152343 0.007812 11.679688 0.457031 23.34375 1.308594 34.945313 2.742188 0.042968 0.003906 0.085937 0.011719 0.128906 0.015625 6.699219 22.390625 10.09375 45.640625 10.09375 69.191406 0 34.449219 -7.113281 67.714844 -21.148438 98.863281zm0 0"
       android:fillColor="#000000" />
</vector>

Como vamos a usar un vector, también tenemos que añadir un atributo en la configuración por defecto de android. Para ello modificamos el fichero de configuración de gradle añadiendo la fila destacada en el código y sincronizamos el proyecto.

Localización de build.gradle
defaultConfig {
   applicationId "com.autentia.animationstutorial"
   minSdkVersion 21
   targetSdkVersion 28
   versionCode 1
   versionName "1.0"
   testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
   vectorDrawables.useSupportLibrary = true
}

A continuación vamos a añadir un ImageDrawable con el vector en el activity_main.xml que podemos encontrar dentro de res>layout.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/activityMain_constraintLayout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <ImageView
       android:id="@+id/ball_imageView"
       android:layout_width="0dp"
       android:layout_height="0dp"
       app:srcCompat="@drawable/ball"
       app:layout_constraintHeight_percent="0.2"
       app:layout_constraintWidth_percent="0.2"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintLeft_toLeftOf="parent"/>

</android.support.constraint.ConstraintLayout>

Una vez ya tenemos la imagen, vamos a definir la animación. Para ello, crearemos un directorio de recursos en res llamado anim. Después añadiremos un fichero de animación en el que pegaremos el código que tenemos a continuación.

Creación de un directorio de recursos Android
Creación de un fichero XML de animación

move_right.xml

<set xmlns:android="http://schemas.android.com/apk/res/android"
   android:fillAfter="true"
   android:interpolator="@android:anim/linear_interpolator">
   <translate
       android:fromXDelta="0%p"
       android:toXDelta="80%p"
       android:duration="2000" />
</set>

El código definido indica que se moverá horizontalmente desde la posición 0% de la anchura del contenedor hasta la 80%. Además la animación dura 2000 milisegundos y el linear_interpolator indica que la velocidad será constante.

Lo siguiente es aplicar la animación al ImageView. Para ello, vamos a modificar el MainActivity para que el método onCreate quede como el siguiente:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        ball_imageView.setOnClickListener {
            val animation = AnimationUtils.loadAnimation(applicationContext, R.anim.move_right)
            ball_imageView.startAnimation(animation)
        }
    }

Hemos añadido un listener al ImageView para que cuando lo pulsemos cree la animación y la ejecute. Si lanzamos la aplicación y presionamos la pelota, veremos la animación.

3.2. Mirando más de cerca

Si debuggeamos la aplicación para ver dónde está la imagen antes y después de la animación, podremos ver cómo sus coordenadas no varían. Esto lo podemos hacer añadiendo un AnimationListener y mirando en los métodos onAnimationStart y onAnimationEnd.

Este comportamiento es debido a que la clase Animation sólo cambia la representación gráfica de los objetos y no sus propiedades reales. En un principio puede resultar banal, pero en la práctica esto puede llevar a problemas. Por ello es posible que sea necesario tener que actualizar manualmente las propiedades del objeto animado.

3.3. AnimationSet

Animation proporciona una clase llamada AnimationSet. Su finalidad es agrupar varias animaciones para ejecutarlas juntas. Además de simplificar el código también combina las transformaciones en una sola, por lo que es más eficiente. Por otro lado, hay que tener en cuenta que si definimos algunas propiedades pueden sobrescribir las de los hijos, como su duración o el modo de repetición.

Como ejemplo, vamos a modificar la animación anterior para darle un poco de efecto al movimiento de la pelota. Edita el fichero move_right.xml y sustituye el contenido por éste:

<set xmlns:android="http://schemas.android.com/apk/res/android"
   android:fillAfter="true"
   android:interpolator="@android:anim/linear_interpolator"
   android:duration="2000">
   <translate
       android:fromXDelta="0%p"
       android:toXDelta="90%p"/>

   <rotate
       android:fromDegrees="0"
       android:toDegrees="-45"
       android:pivotX="50%p"
       android:pivotY="50%p"/>
</set>

Si iniciamos la animación, veremos cómo la pelota ahora hace un giro, ya que estaremos ejecutando las dos animaciones a la vez.

4. El paquete Animator

A partir del API 11 (Android 3.0) contamos con el paquete Animator, que soluciona varias de las limitaciones de Animation. Con este tipo de animaciones modificamos tanto la representación como las propiedades del objeto.

Vamos a describir dos clases que heredan de Animator: ValueAnimator y ObjectAnimator. Algunas de las propiedades que podemos definir para ellas son las siguientes:

  • duration: duración de la animación en milisegundos.
  • interpolator: objeto que controla la velocidad y la aceleración de la animación. Se pueden usar los interpolators de Animations.
  • repeatCount: veces que se repite la animación. Se puede indicar que es infinito (constante definida en ValueAnimator).
  • frameDelay: la frecuencia teórica con la que se actualizan los frames , por defecto 10 milisegundos. El valor es teórico, ya que depende de la potencia del dispositivo. Ten en cuenta que una frecuencia menor requiere mayor número de cálculos por unidad de tiempo.

 

Al igual que con las animaciones, podemos definir un listener. En este caso tenemos varios tipos

  • AnimationListener: cuenta con métodos a los que se llama cuándo se inicia la animación, cuándo termina, cuándo se repite o cuándo se cancela.
  • AnimationUpdateListener: cuenta con un método al que se llama cuando se debe actualizar el valor en cuestión.
  • AnimationListenerAdapter: se trata de una interfaz que proporciona implementaciones vacías de los métodos de arriba. Así sólo es necesario implementar los que se necesiten.

4.1. ValueAnimator

Podemos usar ValueAnimator para animar un int, un float o un color dentro de un rango durante un tiempo determinado. La animación no se aplica directamente sobre ningún elemento de nuestra aplicación, por lo que tendremos que añadir un AnimatorUpdateListener para aplicarlo.

Como ejemplo, vamos a mover la pelota de izquierda a derecha de la pantalla de nuevo. Para ello, vamos a modificar el método onCreate de la clase MainActivity indicando el tiempo. Además vamos a añadir un pequeño delay y a indicar que la animación se ejecutará con un AccelerateInterpolator:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)

   ball_imageView.setOnClickListener {
       val start = activityMain_constraintLayout.left.toFloat()
       val end = activityMain_constraintLayout.right * 0.8f
       val animator = ValueAnimator.ofFloat(start, end)
       animator.duration = 2000L
       animator.startDelay = 500
       animator.interpolator = AccelerateInterpolator()
       animator.addUpdateListener {
           ball_imageView.x = animator.animatedValue as Float
       }
       animator.start()
   }
}

4.2. ObjectAnimator

ObjectAnimator hereda de ValueAnimator y su concepto es muy similar. Ofrece la ventaja de indicar directamente el atributo del objeto que se va a animar sin necesidad de establecer un listener.

Los atributos de las vistas que se pueden animar son los siguientes:

  • translationX, translationY: indican dónde está la vista como una delta desde sus coordenadas izquierda y superior.
  • rotation, rotationX, rotationY: rotación 2D y 3D
  • scaleX, scaleY: escala 2D.
  • pivotX, pitotY: definen el pivote sobre el que la rotación y la escala se calcula. Por defecto está en el centro de la vista.
  • x, y: localización de la vista como suma de su valor traslationX o traslationY y sus coordenadas izquierda o superior.
  • alpha: define la transparencia de la vista en un rango desde 0 (invisible) hasta 1 (opaco).

 

Como ejemplo, vamos a repetir el ejemplo de ValueAnimator utilizando ObjectAnimator:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)

   ball_imageView.setOnClickListener {
       val end = activityMain_constraintLayout.right * 0.8f
       val animator = ObjectAnimator.ofFloat(ball_imageView, "translationX", end)
       animator.duration = 2000L
       animator.startDelay = 500
       animator.interpolator = AccelerateInterpolator()
       animator.start()
   }
}

Cuando vayamos a modificar varias propiedades de un objeto, podemos utilizar varios ObjectAnimator como hemos descrito arriba o utilizarlo con el método ofPropertyValuesHolder. Este método devuelve un objeto de tipo ObjectAnimator, tal y como sucedía antes, pero permite definir varias modificaciones que se darán a la vez.

A continuación, además de mover la pelota, vamos a hacer que su tamaño se duplique tanto horizontal como verticalmente:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)

   ball_imageView.setOnClickListener {
       val end = activityMain_constraintLayout.right * 0.8f
       val propertiesAnimator = ObjectAnimator.ofPropertyValuesHolder(
           ball_imageView,
           PropertyValuesHolder.ofFloat("translationX", end),
           PropertyValuesHolder.ofFloat("scaleX", 2f),
           PropertyValuesHolder.ofFloat("scaleY", 2f)
       )
       propertiesAnimator.duration = 2000L
       propertiesAnimator.start()
   }
}

4.3. AnimatorSet

Al igual que Animation, el paquete Animator también permite definir un set de animaciones. Sin embargo, a diferencia que Animation, también permite definir si se ejecutarán a la vez o secuencialmente.

Como ejemplo, vamos a añadir una segunda pelota y vamos a hacer que cuando se pulse una de ellas las dos se muevan a la vez. Además la inferior se moverá tras un pequeño delay.

Para empezar, modificaremos activity_main.xml para tener las dos pelotas:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
   android:id="@+id/activityMain_constraintLayout"
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <ImageView
       android:id="@+id/upperBall_imageView"
       android:layout_width="0dp"
       android:layout_height="0dp"
       app:srcCompat="@drawable/ball"
       app:layout_constraintHeight_percent="0.2"
       app:layout_constraintWidth_percent="0.2"
       app:layout_constraintTop_toTopOf="parent"
       app:layout_constraintLeft_toLeftOf="parent"/>

   <ImageView
       android:id="@+id/lowerBall_imageView"
       android:layout_width="0dp"
       android:layout_height="0dp"
       app:srcCompat="@drawable/ball"
       app:layout_constraintHeight_percent="0.2"
       app:layout_constraintWidth_percent="0.2"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintLeft_toLeftOf="parent"/>

</android.support.constraint.ConstraintLayout>

Para terminar modificamos la clase MainActivity:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)

   upperBall_imageView.setOnClickListener { startAnimation() }
   lowerBall_imageView.setOnClickListener { startAnimation() }
}

private fun startAnimation(){
   val end = activityMain_constraintLayout.right * 0.8f
   val upperAnimator = ObjectAnimator.ofFloat(upperBall_imageView, "translationX", end)
   val lowerAnimation = ObjectAnimator.ofFloat(lowerBall_imageView, "translationX", end)
   upperAnimator.duration = 1000
   lowerAnimation.duration = 1000
   lowerAnimation.startDelay = 200
   val animatorSet = AnimatorSet()
   animatorSet.playSequentially(upperAnimator, lowerAnimation)
   animatorSet.start()
}

5. ViewPropertyAnimator

Si tenemos que modificar más de dos propiedades de un objeto es recomendable usar ViewPropertyAnimator. Cuando hay varias animaciones simultáneas, esta clase las combina para mejorar el rendimiento.

Como ejemplo, vamos a animar la pelota superior para que desaparezca, se redimensione al doble de su tamaño y, además, gire sobre sí misma. Nuevamente modificamos el método onCreate de la clase MainActivity:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)

   upperBall_imageView.setOnClickListener {
       val animation = upperBall_imageView.animate() as ViewPropertyAnimator
       animation.alpha(0f).scaleX(2f).scaleY(2f).rotation(100f)
       animation.duration = 2000
       animation.start()
   }
}

Como se puede apreciar, el código se reduce, pudiendo indicar todas las animaciones a realizar en una única línea de código.

6. Movimientos con Physics

Para realizar animaciones que utilicen movimientos más naturales o que apliquen las leyes del mundo real utilizamos movimientos con físicas. A diferencia del resto de animaciones, las basadas en physics no muestran un cambio brusco al modificar la animación. Esto se debe a que el cambio se aplica como una fuerza sobre la velocidad actual, provocando una transición suave.

Android proporciona dos tipos de animaciones: SpringAnimation y FlingAnimation. La primera pretende simular una fuerza similar a la de un muelle y la segunda simula la fuerza de fricción (como cuando hacemos scroll y la animación va perdiendo velocidad).

7. Layouts y Actividades

Aunque en este tutorial no nos centraremos en ellas, android permite animar la transición entre layouts y entre actividades. Éstas se encuentran disponibles a partir del API 19 (Android 4.4) y permiten especificar el tipo de transición que se quiere, tanto para cambiar toda la interfaz como sólo alguna vista.

Además, a partir del API 21 (Android 5.0), contamos con transiciones entre actividades, es decir, entre dos layouts que pertenezcan a actividades diferentes.

8. Animar Drawables

Para animar un gráfico bitmap, podemos usar dos tipos de animación: AnimationDrawable y AnimatedVectorDrawable.

8.1. AnimationDrawable

AnimationDrawable se basa en la sucesión de varios drawables, funcionando de forma similar a un gif. Dependiendo del número de drawables que definamos y su duración se puede conseguir una transición más suave o más brusca. En XML aplicamos esta técnica con la etiqueta animation-list que funciona como un drawable.

Como ejemplo simple vamos a definir una lista de dos drawables que se alternen: un círculo y un cuadrado. Para empezar creamos tres nuevos drawables en res>drawable: las dos shapes y la animation-list.

circle.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
   android:shape="oval">
   <solid android:color="#99f" />
</shape>

square.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
   android:shape="rectangle">
   <solid android:color="#99f" />
</shape>

animated_shape.xml

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
   android:oneshot="false">
   <item
       android:drawable="@drawable/square"
       android:duration="200" />
   <item
       android:drawable="@drawable/circle"
       android:duration="200" />
</animation-list>

Como podemos ver, hemos añadido el círculo y el cuadrado a la lista con una duración de 200 milisegundos cada uno. Además, hemos definido oneshot como false para que la animación no se detenga.

Pero aún nos queda añadir la animación a la aplicación, por lo que vamos a modificar el layout activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
   android:id="@+id/activityMain_constraintLayout"
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <ImageView
       android:id="@+id/animatedShape_imageView"
       android:layout_width="0dp"
       android:layout_height="0dp"
       app:srcCompat="@drawable/animated_shape"
       app:layout_constraintHeight_percent="0.2"
       app:layout_constraintWidth_percent="0.2"
       app:layout_constraintTop_toTopOf="parent"
       app:layout_constraintLeft_toLeftOf="parent"/>

</android.support.constraint.ConstraintLayout>

Por último, volvemos a editar la clase MainActivity para crear un onClickListener que inicie la animación:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        animatedShape_imageView.setOnClickListener {
            (animatedShape_imageView.drawable as AnimationDrawable).start()
        }
    }

8.2. AnimatedVectorDrawable

El otro tipo de animación es AnimatedVectorDrawable, el cual permite modificar los atributos de un vector definido como una animación. Además contamos con AnimatedVectorDrawableCompat, la cual se usa para compatibilidad con versiones anteriores.

Normalmente se usan estas clases definiendo dos archivos XML. En uno se define la animación a realizar y contendrá la etiqueta object-animator. El otro contiene la etiqueta animated-vector y es donde uniremos la animación con uno de los elementos del vector. Estos elementos pueden ser tanto un group como un path, ambos con la etiqueta name definida.

A continuación vamos a animar este icono para que el color del círculo que lo rodea cambie.

icono de cerrar
Icono creado por Freepik de www.flaticon.com con licencia CC 3.0 BY

Primeramente vamos a crear un drawable siguiendo con el mismo procedimiento con el que creamos la pelota y pegaremos este código.

close_icon.xml

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
   android:id="@+id/close_button"
   android:viewportWidth="294.843"
   android:viewportHeight="294.843"
   android:width="294.843dp"
   android:height="294.843dp">
   <path
       android:name="circle"
       android:pathData="M147.421 0C66.133 0 0 66.133 0 147.421s66.133 147.421 147.421 147.421c38.287 0 74.567 -14.609 102.159 -41.136c2.389 -2.296 2.464 -6.095 0.167 -8.483c-2.295 -2.388 -6.093 -2.464 -8.483 -0.167c-25.345 24.367 -58.672 37.786 -93.842 37.786C72.75 282.843 12 222.093 12 147.421S72.75 12 147.421 12s135.421 60.75 135.421 135.421c0 16.842 -3.052 33.273 -9.071 48.835c-1.195 3.091 0.341 6.565 3.432 7.761c3.092 1.193 6.565 -0.341 7.761 -3.432c6.555 -16.949 9.879 -34.836 9.879 -53.165C294.843 66.133 228.71 0 147.421 0z"
       android:fillColor="#000000" />
   <path
       android:pathData="M167.619 160.134c-2.37 -2.319 -6.168 -2.277 -8.485 0.09c-2.318 2.368 -2.277 6.167 0.09 8.485l47.236 46.236c1.168 1.143 2.683 1.712 4.197 1.712c1.557 0 3.113 -0.603 4.288 -1.803c2.318 -2.368 2.277 -6.167 -0.09 -8.485L167.619 160.134z"
       android:fillColor="#000000" />
   <path
       android:pathData="M125.178 133.663c1.171 1.171 2.707 1.757 4.243 1.757s3.071 -0.586 4.243 -1.757c2.343 -2.343 2.343 -6.142 0 -8.485L88.428 79.942c-2.343 -2.343 -6.143 -2.343 -8.485 0c-2.343 2.343 -2.343 6.142 0 8.485L125.178 133.663z"
       android:fillColor="#000000" />
   <path
       android:pathData="M214.9 79.942c-2.343 -2.343 -6.143 -2.343 -8.485 0L79.942 206.415c-2.343 2.343 -2.343 6.142 0 8.485c1.171 1.171 2.707 1.757 4.243 1.757s3.071 -0.586 4.243 -1.757L214.9 88.428C217.243 86.084 217.243 82.286 214.9 79.942z"
       android:fillColor="#000000" />
</vector>

A continuación vamos con el objectAnimator. Para ello tenemos que crear una carpeta llamada animator dentro de res siguiendo el mismo procedimiento que cuando creamos la carpeta anim. Después, añade un nuevo fichero de animación en ella y pega este código:

black_to_white.xml

<?xml version="1.0" encoding="utf-8"?>
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
   android:propertyName="fillColor"
   android:valueFrom="@android:color/black"
   android:valueTo="@android:color/white"
   android:duration="1000"
   android:repeatCount="infinite"
   android:repeatMode="reverse"/>

Lo siguiente es añadir un animated-vector, que será un drawable dentro de la carpeta res>drawable. En esta etiqueta tenemos que definir el atributo drawable que indica cuál es el vector a animar. Por otro lado, en target también hay que definir dos atributos: animation para la animación que hemos definido y name para el group o path sobre el que se va a aplicar.

animated_close_icon.xml

<?xml version="1.0" encoding="utf-8"?>
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/close_icon">
<target
   android:animation="@animator/black_to_white"
   android:name="circle"/>
</animated-vector>

Ya tenemos el vector animado, pero aún nos queda añadirlo al layout activity_main.xml y editar la clase MainActivity para iniciar la animación.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    android:id="@+id/activityMain_constraintLayout"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <ImageView
        android:id="@+id/animated_vector"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:srcCompat="@drawable/animated_close_icon"
        android:scaleType="fitCenter"
        app:layout_constraintHeight_percent="0.2"
        app:layout_constraintWidth_percent="0.2"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"/>

</android.support.constraint.ConstraintLayout>

MainActivity

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)

   (animated_vector.drawable as AnimatedVectorDrawableCompat).start()
}

Y hasta aquí este tutorial. Si quieres más información puedes acceder a los enlaces que tienes en referencias.

¡Muchas gracias por haber leído hasta aquí!

9. Referencias

https://developer.android.com/training/animation/overview
https://developer.android.com/guide/topics/graphics/view-animation
https://developer.android.com/guide/topics/graphics/prop-animation
https://developer.android.com/reference/android/animation/Animator
https://developer.android.com/reference/android/view/animation/Animation
https://developer.android.com/reference/android/view/ViewPropertyAnimator
https://developer.android.com/guide/topics/graphics/drawable-animation
https://developer.android.com/reference/android/support/graphics/drawable/AnimatedVectorDrawableCompat

La entrada Animaciones en Android se publicó primero en Adictos al trabajo.

Seguridad en Elasticsearch con el plugin ReadonlyREST

$
0
0

Índice de contenidos

1. Introducción

Cuando estamos trabajando con Elasticsearch nos puede resultar útil securizar
las peticiones realizadas a los diferentes índices que tengamos creados y
tener algún control sobre el acceso a los mismos.

Elastic dispone de la extensión de pago X-Pack que proporciona funciones de
seguridad, alertas, monitoreo, informes y gráficos en un paquete fácil de
instalar.

Aunque puede resultar conveniente el pago de esta licencia, puede haber
proyectos que no requieran tantas funciones y en los que resulte más viable el
uso de alguna tecnología open source como el plugin para Elasticsearch
ReadonlyREST.

Concretamente, lo que nos ofrece ReadonlyREST en su versión open source es:

  • Cifrado: acceso a la API de Elasticsearch a través de HTTPS.
  • Autenticación: solicitud de credenciales de acceso.
  • Autorización: declarar grupos de usuarios, permisos y acceso parcial a índices.
  • Control de acceso: lógica de acceso compleja a través de listas de control de acceso (ACL).
  • Registro de auditoría: trazas de las solicitudes de acceso se pueden guardar en ficheros o en índices.
Flujo de una solicitud de búsqueda. Fuente: https://github.com/beshu-tech/readonlyrest-docs/blob/master/elasticsearch.md

Vamos a verlo en acción.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15′ (2,6 Ghz Intel Core i7, 32GB DDR4).
  • Sistema Operativo: Mac OS Mojave 10.14.2
  • Docker 18.09.1
  • Postman 6.7.1

3. Caso de prueba

Para nuestro caso, vamos a suponer que tenemos que controlar el acceso a
Elasticsearch para distintos usuarios que se autenticarán con su contraseña de
LDAP. Para ello, vamos a montar un entorno con docker que contenga OpenLDAP,
Elasticsearch y el plugin ReadonlyREST adecuado a nuestra versión de Elastic.
Para hacer las peticiones HTTP utilizaremos Postman.

3.1. Instalación

Lo primero, es descargar el código de aquí:
https://github.com/abarriosautentia/example-readonlyrest-elasticsearch

Una vez que tenemos el código descargado, hay que descomprimir el fichero (si os bajásteis un ZIP) y ejecutar en un terminal (dentro de la carpeta docker):

docker-compose up -d

Si todo ha ido bien, al final de nuestra consola veremos algo así:

Creating readonlyrest-ldap ... done
Creating readonlyrest-prepare-ldap ... done
Creating readonlyrest-elasticsearch ... done

3.2. Explicación

Vamos a pasar a explicar un poco lo que acabamos de hacer repasando el fichero docker-compose.yml que tenemos en la carpeta docker:

LDAP

Hemos montado un LDAP cuya raíz es dc=adictosaltrabajo,dc=com en el que hemos creado los usuarios ‘usuario1’, ‘usuario2’ y ‘usuario3’ que pertenecen a los grupos ‘grupo1’, ‘grupo2’ y ‘grupo3’, respectivamente. La contraseña es la misma que el nombre de usuario.
Para el usuario admin la contraseña es ‘autentia’.

Nuestro LDAP tendrá las siguientes entradas:

# adictosaltrabajo.com
      dn: dc=adictosaltrabajo,dc=com
      objectClass: top
      objectClass: dcObject
      objectClass: organization
      o: autentia
      dc: adictosaltrabajo

      # admin, adictosaltrabajo.com
      dn: cn=admin,dc=adictosaltrabajo,dc=com
      objectClass: simpleSecurityObject
      objectClass: organizationalRole
      cn: admin
      description: LDAP administrator
      userPassword:: e1NTSEF9SU9tUHI1OTNDMG1NdmV2bmNoRkhEUWQ5clk0U3Y3eWc=

      # Usuarios, adictosaltrabajo.com
      dn: ou=Usuarios,dc=adictosaltrabajo,dc=com
      objectClass: organizationalUnit
      ou: Usuarios

      # usuario1, Usuarios, adictosaltrabajo.com
      dn: uid=usuario1,ou=Usuarios,dc=adictosaltrabajo,dc=com
      objectClass: inetOrgPerson
      uid: usuario1
      sn: Usuario1
      cn: Mi Usuario1
      userPassword:: e1NTSEF9OEc4SkhrTTM2aDVkYUNTRUZNSGVTZ3BoM3lVcUpZRXo=

      # usuario2, Usuarios, adictosaltrabajo.com
      dn: uid=usuario2,ou=Usuarios,dc=adictosaltrabajo,dc=com
      objectClass: inetOrgPerson
      uid: usuario2
      sn: Usuario2
      cn: Mi Usuario2
      userPassword:: e1NTSEF9RzBKK3JXLzN2Ny83VXJ5MWhVdTdqS0dIUjJqbU9VaVo=

      # usuario3, Usuarios, adictosaltrabajo.com
      dn: uid=usuario3,ou=Usuarios,dc=adictosaltrabajo,dc=com
      objectClass: inetOrgPerson
      uid: usuario3
      sn: Usuario3
      cn: Mi Usuario3
      userPassword:: e1NTSEF9MjRObm42aXhleTJOUlZSM1IrblV2WDl4bDJVM2tiRUg=

      # Grupos, adictosaltrabajo.com
      dn: ou=Grupos,dc=adictosaltrabajo,dc=com
      objectClass: organizationalUnit
      ou: Grupos
      description: generic groups branch

      # grupo1, Grupos, adictosaltrabajo.com
      dn: cn=grupo1,ou=Grupos,dc=adictosaltrabajo,dc=com
      objectClass: groupOfUniqueNames
      cn: grupo1
      uniqueMember: uid=usuario1,ou=Usuarios,dc=adictosaltrabajo,dc=com

      # grupo2, Grupos, adictosaltrabajo.com
      dn: cn=grupo2,ou=Grupos,dc=adictosaltrabajo,dc=com
      objectClass: groupOfUniqueNames
      cn: grupo2
      uniqueMember: uid=usuario2,ou=Usuarios,dc=adictosaltrabajo,dc=com

      # grupo3, Grupos, adictosaltrabajo.com
      dn: cn=grupo3,ou=Grupos,dc=adictosaltrabajo,dc=com
      objectClass: groupOfUniqueNames
      cn: grupo3
      uniqueMember: uid=usuario3,ou=Usuarios,dc=adictosaltrabajo,dc=com

Elasticsearch

Tenemos montado un Elasticsearch (versión 6.5.4) con el plugin ReadonlyREST instalado (versión 1.16.33_es6.5.4), en el que hemos deshabilitado las características de seguridad de Elasticsearch (para evitar conflictos con el plugin) y hemos habilitado SSL para disponer de conexión HTTPS. Para ello, hemos tenido que crear un almacen de claves de prueba (keystore.jks).

ReadonlyREST

En el fichero readonlyrest.yml está la parte más interesante ya que es donde hemos configurado el control de acceso a nuestros índices y el acceso a LDAP.

Lo primero que hemos hecho es activar la propiedad audit_collector para que los errores de acceso se guarden en un índice de Elasticsearch. El nombre por defecto del índice es readonlyrest_audit-YYYY-MM-DD.

Habilitamos el cifrado SSL para poder utilizar el protocolo HTTPS y configuramos el keystore de pruebas que tenemos generado con un certificado autofirmado. Esto es sólo para pruebas, la generación de certificados para producción debe estar validada por una Autoridad certificada (CA). Podéis leer más información en este tutorial.

Las reglas para el control de acceso (access_control_rules) se dividen en bloques que contienen un listado de reglas. Estos bloques se aplican en orden secuencial, es decir, se va comprobando cada bloque de reglas de arriba a abajo hasta que se cumple un bloque completo de reglas. Si no se encuentra ningún bloque en el que se cumpla todo el listado de reglas, la petición se rechaza.

En nuestro caso, hemos creado 4 bloques de reglas:

  1. Los usuarios del grupo1, que se autentiquen correctamente contra LDAP, pueden crear índices e introducir datos (con bulk) en los índices que comiencen con el nombre “my_index”.
  2. Los usuarios del grupo2, que se autentiquen correctamente contra LDAP, pueden consultar datos de los índices que comiencen con el nombre “my_index”.
  3. Los usuarios del grupo3, que se autentiquen correctamente contra LDAP, pueden gestionar el cluster de Elasticsearch.
  4. Cualquier usuario, que se autentique correctamente contra LDAP, puede consultar los índices de auditoría (readonlyrest_audit-*).

access_control_rules:
        - name: 'Create and Bulk index access to all indices my_index* from users belong to grupo1'
          ldap_authentication: 'my_ldap'
          ldap_authorization:
            name: 'my_ldap' # ldap name from 'ldaps' section
            groups: ['grupo1'] # group within 'ou=Grupos,dc=adictosaltrabajo,dc=com'
          indices: ['my_index*']
          actions: ['indices:admin/create', 'indices:data/write/bulk']

        - name: 'Read access to all indices my_index* from users belong to grupo2'
          ldap_authentication: 'my_ldap'
          ldap_authorization:
            name: 'my_ldap' # ldap name from 'ldaps' section
            groups: ['grupo2'] # group within 'ou=Grupos,dc=adictosaltrabajo,dc=com'
          indices: ['my_index*']
          actions: ['indices:data/read/*']

        - name: 'Cluster access from users belong to grupo3'
          ldap_authentication: 'my_ldap'
          ldap_authorization:
            name: 'my_ldap' # ldap name from 'ldaps' section
            groups: ['grupo3'] # group within 'ou=Grupos,dc=adictosaltrabajo,dc=com'
          actions: ['cluster:*']

        - name: 'Read access to audit logs indices readonlyrest_audit-*'
          ldap_authentication: 'my_ldap'
          indices: ['readonlyrest_audit-*']
          actions: ['indices:data/read/*']

3.3. Probando el acceso

Abrimos Postman y vamos a comprobar que nuestras reglas de acceso se cumplen lanzando varias peticiones.

Antes de comenzar, debemos deshabilitar la verificación de certificados SSL para que no nos dé problemas el certificado autofirmado que estamos usando para nuestras pruebas. Lo hacemos desde:

Preferences -> General -> SSL certificate verification -> OFF

Para todas las peticiones, en la pestaña Auth seleccionaremos Basic Auth, donde introduciremos los usuarios de pruebas:

y en la pestaña Headers añadiremos Content-Type -> application/json:

Vamos con nuestra primera prueba donde crearemos el índice ‘my_index1’ con el usuario1, que tiene permisos para crear un índice con este nombre. Para ello lanzamos el siguiente PUT:

https://localhost:9200/my_index1

y en la respuesta recibimos el ACK correctamente:

Ahora vamos a insertar documentos en el índice con este mismo usuario. Lanzamos el siguiente PUT:

https://localhost:9200/_bulk

y en la respuesta recibimos la confirmación con el código 201 (Created):

Para consultar los datos del índice, debemos utilizar el usuario2 que tiene permisos de lectura sobre este índice. Lanzamos el siguiente GET:

https://localhost:9200/my_index1/_search

y recibimos la respuesta con el documento que acabamos de crear:

Si lo intentamos con el usuario1, que no tiene permiso de lectura, o metemos mal el usuario o la contraseña, nos devolverá un error 401 (Unauthorized):

Ahora vamos a consultar información del cluster con el usuario3. Lanzamos el siguiente GET:

https://localhost:9200/_cluster/state

y recibimos correctamente la información del cluster:

Por último, con cualquiera de los usuarios que tenemos podemos consultar los logs que se han cargado en el índice readonlyrest_audit-YYYY-MM-DD. Lanzamos el siguiente GET:

https://localhost:9200/readonlyrest_audit-*/_search

y vemos la auditoría que se ha guardado:

Podemos ver que se ha almacenado un documento en el índice para cada petición realizada, con mucha información que podemos explotar. Podemos destacar el campo acl_history donde se guardan los bloques de reglas que se han procesado para esa petición y el resultado de evaluar (en orden) cada una de ellas.

4. Conclusiones

En este tutorial hemos visto que tenemos alternativas open source para proteger el acceso a nuestro índices, más allá de la herramienta oficial X-Pack y sin necesidad de configurar ningún proxy.

Por medio del plugin ReadonlyREST para Elasticsearch hemos sido capaces de securizar nuestro Elasticsearch conectándolo a un LDAP de manera sencilla, pero ofrece más opciones como autenticación interna básica, delegar en un proxy, autenticación externa básica, JWT. También existen versiones de pago de ReadonlyREST que ofrecen más características.

La posibilidad de explotar los logs de acceso es un complemento muy útil a la hora de conocer cómo se están aplicando las reglas que hemos definido.

Espero que os haya resultado útil.

5. Referencias

La entrada Seguridad en Elasticsearch con el plugin ReadonlyREST se publicó primero en Adictos al trabajo.

De Java 8 a Java 11, ¿aún no te has migrado?

$
0
0
  1. Introducción
  2. Entorno
  3. Filosofía
  4. Modularización
  5. Inferencia de tipos
  6. Nuevos métodos en las colecciones
  7. Nuevos métodos en las Streams
  8. Nuevos métodos en los Optional
  9. Métodos privados en interfaces
  10. Comando jshell del terminal
  11. Comando java del terminal
  12. Nuevos recolectores de basura
  13. Paquetes eliminados
  14. Docker
  15. Otras funcionalidades
  16. Conclusiones

Introducción

Hemos visto el importante cambio de filosofía en la liberación de versiones de Java, desde la versión 9 de java liberada en septiembre de 2017 se van a liberar versiones por calendario y no por funcionalidades, de esta forma cada 6 meses se liberará una versión de java, y esto se ha cumplido hasta la fecha. En marzo de 2018 se publicó Java 10 y en septiembre del mismo año Java 11.

También se ha llegado al acuerdo de que cada año y medio saldrá una versión LTS de Java que incluirá un soporte a largo plazo, es el caso de Java 11 y está previsto que el soporte sea de 8 años, hasta 2026.

Pero esto implica que ya no hay soporte de Java 9 y Java 10, por lo tanto, intentaremos ver todos los cambios juntos de Java 8 a Java 11. A esto se une que también se acababa el soporte de java 8 en enero de este año 2019.

Con esta nueva filosofía tanto los desarrolladores que quieran usar la nueva funcionalidad cuanto antes, como las empresas que necesitan tener un largo tiempo de soporte, pueden ir usando las versiones que les interese y siempre pueden anticiparse a los futuros cambios de versión sabiendo cuando va a salir la siguiente.

Entorno

  • Hardware: Portátil MacBook Pro 15′ (2.5 GHz Intel Core i7, 16GB DDR3)
  • Sistema Operativo: MacOS Mojave 10.14.2
  • OpenJDK version “11.0.1” 2018-10-16

Filosofía

Como ya hemos hablado en la introducción el cambio más importante ha sido el cambio de filosofía, en vez de sacar las siguientes versiones por funcionalidad, va a ser por calendario. Cada 6 meses tendremos una versión que solo tendrá soporte los siguientes 6 meses y cada año y medio Oracle sacará una versión LTS con soporte durante 8 años.

También hay que saber otro cambio de filosofía importante, la JDK de java va a ser de pago y por lo tanto si queremos el soporte tendremos que pagar a Oracle por ello. Aunque también vamos a tener una versión libre, la OpenJDK, mantenida por Oracle y que tiene gran apoyo de la comunidad, estas versiones tendrán soporte de 6 meses, por lo tanto, si optamos por esta solución tendremos que ir migrando nuestra versión de java cada 6 meses.

Vemos que la recomendación es pasar a Java 11 directamente desde Java 8 sin pasar por Java 9 ni por Java 10, pero hay que tener en cuenta alguna cosa, si cambiamos la versión de Java a la 11, puede que todo siga compilando, pero hay que cambiar todas nuestras frameworks o librerias como Hibernate a versiones que soporten Java 11 o podremos tener errores en runtime que antes no teníamos.

Este tutorial se ha hecho con la OpenJDK11 que se puede bajar precompilada de https://adoptopenjdk.net/?variant=openjdk11&jvmVariant=hotspot

Modularización

Los módulos permiten la encapsulación en tiempo de compilación, de esta forma podemos restringir el acceso a una serie de paquetes. Esta funcionalidad está disponible desde Java 9.

Para definir los módulos es necesario definir el fichero module-info.java en la raiz del código fuente. Este fichero puede tener una estructura parecida al siguiente:

module com.autentia.tutoriales.java11 {
    exports com.autentia.tutoriales.java11.exports;
    requires com.autentia.tutoriales.optional;
    requires com.autentia.tutoriales.interfaces;
}

Si alguien intenta usar otra clase de com.autentia.tutoriales.java11 que no estén dentro de com.autentia.tutoriales.java11.exports daría error de compilación. Además indico que el código de com.autentia.tutoriales.java11 requiere el código de los paquetes com.autentia.tutoriales.optional y com.autentia.tutoriales.interfaces para poder compilar.

Pero hay más funcionalidades que podemos usar:

open module, para tener el módulo accesible por reflexión.

open module com.autentia.tutoriales.java11 {
    exports com.autentia.tutoriales.java11.exports;
    requires com.autentia.tutoriales.optional;
    requires com.autentia.tutoriales.interfaces;
}

exports (package) to (package), para poder usar solo las clases de un paquete desde otro paquete concreto. Si además queremos exportar un paquete pero que solo se pueda usar dentro de otro paquete concreto, podríamos poner:

module com.autentia.tutoriales.java11 {
    exports com.autentia.tutoriales.java11.exports;
            to com.autentia.tutoriales.java12;
    requires com.autentia.tutoriales.optional;
    requires com.autentia.tutoriales.interfaces;
}

requires transitive equivale a hacer require de los módulos que requiere otro módulo. Si el módulo com.autentia.tutoriales.optional requiere com.autentia.tutoriales.interfaces podríamos haber puesto:

module com.autentia.tutoriales.java11 {
    exports com.autentia.tutoriales.java11.exports;
    requires transitive com.autentia.tutoriales.optional;
}

Inferencia de tipos

Este es el principal cambio de Java 10, a partir de este momento podremos utilizar var para crear objetos sin tener que definir el tipo. Aunque como sabemos, tampoco tenemos que volvernos locos poniendo var en todos los objetos y perdiendo la comprensión del código.

A partir de Java 10 podremos ver código como:

var list = List.of(1, 2, 3);
var example = "example";
var team = new Team();

Además, con Java 11, se ha añadido el uso de var en las lambdas permitiendo las anotaciones en estos parámetros, aunque no se puede mezclar el uso de var con tipos, ni de var y un tipo vacío.

Map<Integer, Integer> map = Map.of(1, 2, 3, 4, 5, 6);
map.forEach((x, y) -> LOGGER.info(x + y));
map.forEach((Integer x, Integer y) -> LOGGER.info(x + y));
map.forEach((var x, var y) -> LOGGER.info(x + y));
map.forEach((@NotNull var x, @NotNull var y) -> LOGGER.info(x + y));

map.forEach((x, var y) -> LOGGER.info(x + y)); // No compila
map.forEach((int x, var y) -> LOGGER.info(x + y)); // No compila

Nuevos métodos en las colecciones

Con la JDK de java 9, vamos a tener más facilidades para crear colecciones ya inicializadas. Podremos usar el método estático of() de List, Set, Stream o Map. Hay que recordar que estas colecciones son inmutables y si intentamos hacer un add tendremos una UnsupportedOperationException. Un ejemplo de cada una puede ser:

List<Integer> list = List.of(1, 2, 3);
Set<String> set = Set.of("a", "b", "c");
Stream<String> stream = Stream.of("a", "b", "c");
Map<String, String> map = Map.of("clave 1", "valor 1", "clave 2",  "valor 2");

LOGGER.info(list);
LOGGER.info(set);
LOGGER.info(stream.collect(Collectors.toList()));
LOGGER.info(map);

La salida por consola es:

[1, 2, 3]
[a, b, c]
[a, b, c]
{clave 2=valor 2, clave 1=valor 1}

Por otro lado, con Java 10, se han introducido los métodos copyOf() para crear copias inmutables de List, Set y Map. Al igual que con el método of(), tendremos una UnsupportedOperationException si intentamos añadir elementos. Añadiendo el código al ejemplo anterior vemos cómo hacer copias.

List<Integer> listCopyOf = List.copyOf(list);
Set<String> setCopyOf = Set.copyOf(set);
Map<String, String> mapCopyOf = Map.copyOf(map)

LOGGER.info(listCopyOf);
LOGGER.info(setCopyOf);
LOGGER.info(mapCopyOf);

La salida es:

[1, 2, 3]
[a, b, c]
{clave 2=valor 2, clave 1=valor 1}

Además, se han añadido los métodos toUnmodificableList(), toUnmodificableSet() y toUnmodificableMap() a la clase Collectors para hacer las colecciones inmutables. Los casos de uso serían:

List toUnmodifiableList = Stream.of("a", "b", "c").collect(toUnmodifiableList());
Set toUnmodifiableSet = Stream.of("g", "h", "i").collect(Collectors.toUnmodifiableSet());
Map<Integer, Integer> toUnmodifiableMap = Stream.of(1, 2, 3).collect(toUnmodifiableMap(
        num -> num,
        num -> num * 4));

LOGGER.info(toUnmodifiableList);
LOGGER.info(toUnmodifiableSet);
LOGGER.info(toUnmodifiableMap);

Esto imprimiria por consola:

[a, b, c]
[g, h, i]
{1=4, 2=8, 3=12}

Nuevos métodos en los Streams

Con Java 9, podemos usar los métodos takeWhile(), dropWhile(), iterate() y ofNullable() en los streams. Los dos primeros se usan para eliminar o escoger los primeros elementos mientras se cumple una condición, el método iterate() genera una iteración de valores y el método ofNullable() genera un Stream con un elemento si el elemento no es null o vacío. Veamos un ejemplo de uso:

Si ejecutamos:

LOGGER.info("Ejemplo takeWhile():");
List takeWhileResult = Stream.of(1, 2, 3, 4, 5).takeWhile(value -> value < 3).collect(Collectors.toList());
LOGGER.info(takeWhileResult);

LOGGER.info("Ejemplo dropWhile():");
List dropWhileResult = Stream.of(1, 2, 3, 4, 5).dropWhile(value -> value < 3).collect(Collectors.toList());
LOGGER.info(dropWhileResult);

LOGGER.info("Ejemplo iterate():");
List iterateResult = Stream.iterate(1L, n  ->  n  + 1).limit(10).collect(Collectors.toList());
LOGGER.info(iterateResult);

LOGGER.info("Ejemplo ofNullable():");
String example = "example";
List ofNullableResult = Stream.ofNullable(example).collect(Collectors.toList());
LOGGER.info(ofNullableResult);
String nullExample = null;
List ofNullableNullResult = Stream.ofNullable(nullExample).collect(Collectors.toList());
LOGGER.info(ofNullableNullResult);

Obtendremos:

Ejemplo takeWhile():
[1, 2]
Ejemplo dropWhile():
[3, 4, 5]
Ejemplo iterate():
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Ejemplo ofNullable():
[example]
[]

Nuevos métodos en los Optional

En java 9 se han añadido los métodos ifPresentOrElse(), or() y stream() para aportar funcionalidad extra a los ya conocidos métodos de Java 8. También vemos cómo los Optional avanzan con Java 10 y se añade el método orElseThrow().

Resulta bastante importante el método stream(), con este método podemos obtener un Stream con todos los Optional de una lista que tenían isPresent() a true. En la documentación pone que con stream() convertimos un Optional en un Stream de cero o un valor, pero vamos a hacer un test para ver cómo podemos usarlo. En este test convertimos un Stream de 3 Optional en un Stream de 2 Optional debido a que uno es vacío y después lo convertimos en una lista, por lo tanto, la lista tendrá tan solo 2 elementos.

@Test
    public void streamTest() {
        Optional<String> optional1 = Optional.of("Texto 1");
        Optional<String> optional2 = Optional.empty();
        Optional<String> optional3 = Optional.of("Texto 3");

        List<String> textos = Stream.of(optional1, optional2, optional3)
                .flatMap(optional -> optional.stream()).collect(toList());

        assertEquals(2, textos.size());
        assertEquals("Texto 1", textos.get(0));
        assertEquals("Texto 3", textos.get(1));

    }

Con el método ifPresentOrElse() podemos añadir funcionalidad al ya existente ifPresent para ejecutar una función en caso de que el optional esté vacío. Lo vemos con el siguiente test.

@Test
    public void ifPresentOrElseTest() {
        Optional<String> optionalConUnTexto = Optional.of("Un texto");
        Optional<String> optionalVacio = Optional.empty();

        optionalConUnTexto.ifPresentOrElse(
                texto ->{
                    System.out.printf("El optional con texto debería pasar por el present.n");
                    assertEquals("Un texto", texto);
                },
                () -> {
                    throw new RuntimeException("El optional con texto nunca debería pasar por el else");
                }
        );

        optionalVacio.ifPresentOrElse(
                texto -> {
                    throw new RuntimeException("El optional vacío nunca debería pasar por el present");
                },
                () -> System.out.printf("El optional vacío debería pasar por el else")
        );

    }

El primer ifPresentOrElse entra por el present al tener texto y el segundo ifPresentOrElse entra por el else al no tener texto. Si hubiera sido de otra forma nos hubiera saltado una excepción, pero lo que hace es imprimir por pantalla el siguiente texto y terminar satisfactoriamente.

El optional con texto debería pasar por el present.
El optional vacío debería pasar por el else.

Como último método añadido en Java 9 tenemos el método or(), con este método podemos devolver un Optional en caso de que no esté presente. Con un test vemos su uso.

@Test
    public void orTest() {
        Optional<String> optionalConUnTexto = Optional.of("Un texto");
        Optional<String> optionalVacio = Optional.empty();

        Optional orOptionalConUnTexto = optionalConUnTexto.or(()->Optional.of("Optional vacío"));
        Optional orOptionalVacio = optionalVacio.or(()->Optional.of("Optional vacío"));

        assertEquals("Un texto", orOptionalConUnTexto.get());
        assertEquals("Optional vacío", orOptionalVacio.get());
    }

Para ver el último método, orElseThrow(), probamos cómo se lanza una excepción en caso de que el Optional sea vacío.

@Test(expected = Exception.class)
    public void orElseThrowTest() throws Exception{
        Optional<String> optionalVacio = Optional.empty();

        optionalVacio.orElseThrow(()-> new Exception("El optional era nulo"));
    }

En el uso de Optional como todo en Java, hay que hacerlo con cabeza, no hay que usarlo de forma que cambiemos el antiguo

public void method(String value){
    if (value == null) return;
    ...

por un

public void method(Optional<String> value){
    if(!value.isPresent()) return;
    ...

En este apartado hemos visto cómo poco a poco Java nos va dando más funcionalidad de Optional para que nuestro código quede más legible y no este lleno de if por todos lados.

Métodos privados en interfaces

Como ya vimos con Java 8, es posible añadir métodos por defecto en interfaces, tenemos que intentar no hacerlo continuamente, pero es una posibilidad más que nos da el lenguaje. Después de permitir estos métodos, en Java 9 nos dan la posibilidad de tener métodos privados dentro de las interfaces para facilitar la legibilidad de código y que no se hagan métodos por defecto infinitos.

Comando jshell del terminal

En java 9 tenemos un nuevo comando disponible en nuestro terminal, el comando jshell. Con este comando podemos probar directamente por consola cualquier sentencia de java sin necesidad de un IDE. Como vemos en el siguiente ejemplo, podemos usar variables, realizar imports o cualquier sentencia que se nos ocurra. Además nos da detalles si encuentra algún error en el código.

Ejemplo de jshell

Comando java del terminal

Además del ya incluido jshell, con Java 11, podemos ejecutar ficheros java desde consola. Si creamos el fichero HolaMundo.java.

public class HolaMundo {
    public static void main(String[] args) {
        System.out.println("Hola mundo");
    }
}

Con el terminal entramos en la carpeta del fichero y ejecutamos:

java HolaMundo.java

Veremos en la salida por el terminal:

Hola mundo

Nuevos recolectores de basura

Aunque este cambio puede parecer transparente para los programadores, se ha cambiado el recolector de basura por defecto. A partir de Java 9, será el “G1 Garbage Collector”, este recolector está optimizado para ofrecer un balance adecuado entre baja latencia y alto rendimiento. Por otro lado, con Java 11, se añade un recolector de basura que no reclama memoria, el recolector de basura Epsilon y de forma experimental el recolector de basura ZGC.

Paquetes eliminados

Con la versión de Java 11 se han eliminado una serie de paquetes.

  • java.corba (CORBA)
  • java.se.ee (Aggregator module for the six modules above)
  • java.transaction (JTA)
  • java.xml.ws (JAX-WS, plus the related technologies SAAJ and Web Services Metadata)
  • java.activation (JAF)
  • java.xml.bind (JAXB)
  • java.xml.ws.annotation (Common Annotations)
  • jdk.xml.bind (Tools for JAXB)
  • jdk.xml.ws (Tools for JAX-WS)

Docker

Hay varias funcionalidades que pueden afectar a Docker, en primer lugar, usando jlink de Java 9, podemos ensamblar y optimizar un conjunto de módulos y sus dependencias en una imagen personalizada en tiempo de ejecución generando así imagenes más pequeñas.

Además, con java 10 instalado sobre docker, podemos poner el número de cores que usa el programa java para ejecutar. Antes de esto, docker usaba todos los cores disponibles en la máquina.

Otras funcionalidades

Además de las ya citadas hay una serie extra de funcionalidades como:

  • Nuevos métodos  repeat, strip, stripLeading, stripTrailing, isBlank y lines en la clase String. (Java 11)
  • Unicode añade nuevos carácteres, emojis y símbolos. (Java 11)
  • Existen nuevos versionados de Jars con la posibilidad de multiversionado. (Java 9)
  • Hay nuevos métodos para identificar procesos pid(), sus hijos children() y sus descendientes descendants(). (Java 9)
  • Las nuevas clases Flow.Processor, Flow.Subscriber, Flow.Publisher Flow que permiten la programación reactiva de publicación-subscripción. (Java 9)

Se pueden ver todas las funcionalidades en:

Conclusiones

Durante el tutorial hemos visto las funcionalidades más destacables que se han incluido desde Java 8 hasta Java 11, pero nos damos cuenta de que lo más importante es el cambio de filosofía y hay que estar atentos a las nuevas versiones, especialmente si usamos la OpenJDK ya que hay que ir migrando de versión cada 6 meses para poder tener soporte. 

Es importante pasar a Java 11 debido a que ya no hay soporte oficial de Java 8, pero para poder migrar con seguridad es necesario que nuestro código tenga una buena red de test para asegurar que todo funciona correctamente en tiempo de ejecución.

La entrada De Java 8 a Java 11, ¿aún no te has migrado? se publicó primero en Adictos al trabajo.

Empezando con Xcode UI testing

$
0
0
  1. Introducción
  2. Entorno
  3. Creación de una prueba
  4. Escribir UI test automaticamente
  5. Los frameworks de las pruebas UI
  6. Conclusiones
  7. Referencias

Introducción

Las pruebas de interfaz de usuario (UI tests) son una excelente manera de garantizar que sus interacciones de interfaz de usuario más críticas continúen funcionando a medida que agrega nuevas funciones o refactoriza la base de código de su aplicación.

También es una buena forma de automatizar tareas repetitivas cuando se trabaja con el código de la interfaz de usuario (cuando tienes que navegar profundamente en tu aplicación para probar algo en lo que estás trabajando, por ejemplo).

Escribir y ejecutar pruebas de UI es algo diferente de hacer pruebas unitarias, ya que en realidad estás interactuando con tu aplicación, en lugar de realizar pruebas programáticas contra una determinada API. Ambas tienen mucho valor y el mejor enfoque es utilizar las dos para diferentes tareas.

Xcode se entrega con pruebas de interfaz de usuario integradas en el framework XCTest, que probablemente ya hayas utilizado para pruebas de unidad. Estas funciones de las pruebas UI han existido durante algunos años, pero muchos desarrolladores las han considerado como inestables y difíciles de usar. Y eso solía ser cierto, ya que en las primeras iteraciones las cosas eran realmente muy inestables, pero actualmente creo que merece la pena darles una segunda oportunidad si aún no lo has hecho.

Entorno

Este tutorial está escrito usando el siguiente entorno:

  • Hardware: MacBook Pro 15’ (2,5 GHz Intel Core i7, 16GB DDR3, mediados de 2015)
  • Sistema operativo: macOS Mojave 10.14.2
  • Versiones del software:
    • Xcode: 7+
    • iOS SDK: 9.0+

Creación de una prueba

Si tu aplicación aún no tiene un objetivo (“target”) de UI test, todo lo que tienes que hacer para agregar uno es ir a File> New> Target … en Xcode y seleccionar “iOS UI Testing Bundle “. Luego, edita el esquema de tu aplicación: ve a “Product”> “Scheme” > “New scheme…” en Xcode y agrega el paquete de pruebas de IU en “Test”. Un ejemplo en donde una prueba de UI puede ser una gran solución, es cuando deseamos probar una interfaz de usuario bastante complicada. Digamos que el usuario tiene que pasar por diferentes pantallas y al final de la última pantalla, aparece un botón “Open another flow”, que tienes que pulsar para abrir otro flujo:

Entonces, vamos a escribir un test muy simple para comprobar este escenario:

func testExample() {
        //lanzar la aplicación
        app.launch()
        //comprobar que estamos en la primera página
        XCTAssert(app.otherElements["firstView"].exists)
        //obtener todas las paginas de la aplicación
        let tabBarsQuery = app.tabBars
        //pulsar el botón de la segunda barra para cambiar la página
        tabBarsQuery.buttons["Second"].tap()
        //pulsar el botón de la tercera barra para cambiar la página
        tabBarsQuery.buttons["Third"].tap()
        //deslizar a la parte inferior de la segunda página
        app.scrollViews.containing(.staticText, identifier: "Third View").element.swipeUp()
       //pulsar el botón para abrir otro flujo
       app.buttons["Open another flow"].tap()
       //comprobar que la vista de la primera pagina no se muestra
       XCTAssertFalse(app.otherElements["firstView"].exists)
    }

Como se puede ver arriba, realizamos nuestra prueba interactuando con nuestra interfaz de usuario, realizando toques, en lugar de llamar a nuestra propia API. De hecho, nuestras pruebas se ejecutarán en un proceso completamente diferente, por lo que no tenemos acceso a nuestro propio código. Esto nos “obliga” a probar realmente el uso de nuestra aplicación, en lugar de “falsificarla”.

Escribir UI test automáticamente

Para empezar a escribir UI tests no hace falta saberse un especial API de Xcode. Podemos crear nuevos tests sin escribir ninguna palabra gracias a una funcionalidad de Xcode. ¡Solo hay que lanzar nuestra aplicación y pulsar un botón! ¡Increíble! Una vez que tu aplicación esté lista para grabar una prueba, abre un fichero en el objetivo (“target”) de UI test e inserta el cursor en un método de UI test.

Se puede agregar a un método de prueba existente o crear uno nuevo. Haz clic en el botón Grabar (“Record”) y Xcode inicia su aplicación en Simulator. Cada vez que toca un elemento en la pantalla, Xcode agrega una línea de código a su método de prueba. Haz clic en el botón Grabar (“Record”) nuevamente para dejar de agregar acciones al método.

Una vez finalizada la prueba, agrega aserciones para verificar si la interfaz de usuario está en el estado correcto. Se puede usar aserciones para probar partes de la interfaz, botones de texto, el número de celdas de vista de tabla, la existencia de un botón en particular y mucho mas.

//comprobar que la vista de la primera pagina no se muestra
XCTAssertFalse(app.otherElements["firstView"].exists)
//comprobar que estamos en la tercera pagina
XCTAssert(app.otherElements["thirdView"].exists)
XCTAssert(app.otherElements["thirdViewLabel"].exists)
Para usar API “otherElements” es imprescindible poner el identificador de accesibilidad

Los frameworks de las pruebas UI

XCUITest

Hace unos años, Apple suspendió su framework de automatización de JavaScript y lo reemplazó con XCUITest. Ésta es una librería nativa compatible con Apple, lo que significa que puedes escribir tus pruebas de UI en Objective-C o Swift.
Una cosa que tengo que destacar es que no comprueba los elementos (etiquetas, botones, etc.) que contienen un valor. En su lugar, verifica el valor que existe en la pantalla.

XCUITest se ejecuta en su propio proceso. Esto tiene algunas ventajas pero también desventajas. Se puede ver todo en la pantalla, pero no puede comprobar cuál es el estado interno de la aplicación. Esto es bastante bueno ya que evita que los desarrolladores cometan errores relacionados con el conocimiento que un usuario normal no tiene. Por otro lado, estar en un proceso separado también significa que necesita algún tiempo para sincronizar el estado de la aplicación. Esto lleva tiempo y ralentiza la prueba. Además, al realizar algunas operaciones que requieren tiempo, puede provocar errores debido a que XCUITest no encuentra el elemento solicitado.

Desde su introducción, se están realizando mejoras constantes con cada lanzamiento de Xcode. La versión más reciente agrega opciones tales como:
iniciar múltiples aplicaciones al mismo tiempo (para mirar cómo interactúan), inicio en caliente (enviar la aplicación al fondo y recuperarla), tomando capturas de pantalla y etc.
Por lo tanto, incluso si decides que XCUITest no es el framework adecuado para tí, es posible que desees realizar un seguimiento del mismo.

EarlGrey

A principios de 2016 Google lanzó EarlGrey. La diferencia entre XCUITest y este framework de automatización de IU es que EarlGrey y la aplicación comparten el mismo proceso. La prueba llamada GreyBox puede influir en la memoria compartida, cambiando así el comportamiento en tiempo de ejecución de la aplicación.
En su repositorio Github, Google proporciona instrucciones detalladas para configurarlo.
En cuanto a la sincronización. Google afirma que debido a que EarlGrey comparte el mismo proceso, se encarga de la sincronización en sí. Aunque puedes tener acceso a él, pero probablemente no lo necesites.

Appium

Appium es diferente en comparación con XCUITest y EarlGrey. Es multiplataforma y no es necesario utilizar un idioma nativo. Entonces, la idea es que tú mismo escribes pruebas en tu idioma preferido y las usas en todas las plataformas compatibles. Esto puede reducir el tiempo de escritura y el mantenimiento de las pruebas a la mitad, ya que, en un mundo ideal, las pruebas funcionan en Android e iOS de la misma manera. Si alguna vez has trabajado en una aplicación que soporta ambas plataformas, ya conoces el resultado. No es realista. En su lugar, Appium escribe las mismas pruebas para ambas plataformas.

Lo que más me echa para atrás al usar Appium es la velocidad. Una simple prueba de inicio de sesión:
1. Iniciar aplicación
2. Introduce credenciales
3. Presione Entrar
4. Comprobar si está conectado
Esto puede tardar con Appium 2(!!!) minutos para completar. Lo mismo se hace en XCUITest o EarlGrey en menos de 10 segundos (lo cual es bastante tiempo).

Cucumberish y otras opciones

Hay más opciones que las anteriores. En caso de que quieras escribir solo en Swift u Objective-C, hay KIF y Frank. De lo contrario, si te gusta BDD y por ejemplo el framework Cucumber, existen frameworks que usan Cucumber, por ejemplo Cucumberish o Calabash. Entre los dos, mi elección preferida es Cucumberish, ya que es un semi-oficial framework para iOS. Está completamente integrado con Xcode Test Navigator y XCUITest, las pruebas están escritas en Swift/Objective-C y no hace falta usar Ruby u otras herramientas para escribir los pasos de las pruebas. Además, los informes de las pruebas aparecerán en Reports Navigator como cualquier prueba de Xcode, pero no tiene buena documentación y se necesita un conocimiento previo de Cucumber.

Lo principal de Cucumberish son sus ficheros “.feature” que contienen las descripciones de las funciones de nuestra aplicación que queremos comprobar con nuestras pruebas. Están escritos en Gherkin, que es el lenguaje que permite describir la funcionalidad de manera natural y sencilla.

Feature: As someone who plans to automate the iOS project test cases, I will use Cucumberish.

# primero en el escenario es su nombre, 
# que también aparecerá en el Xcode Test Navigator
Scenario: First scenario

# esto es el primer paso del escenario
# el contexto de nuestra prueba
Given user launch the application
# la condición de nuestra prueba
When user touch the buttons
# el resultado que queremos conseguir
Then user sees other flow

# por favor, ten en cuenta que cada paso tiene que empezar 
# con "Given", "When", "Then", "And", or "But".
When I run the application
Then I can see the first page

Luego, el desarrollador tiene que correlacionar los pasos del escenario con las pruebas de XCUITest ya escritas. Así, las personas no-técnicas y los desarrolladores pueden colaborar en el mismo proyecto con el mínimo esfuerzo.

import XCTest

//la interconexión entre las pruebas de desarrollador
//y las descripciones en el fichero .feature
@objc class SampleUITestSteps: SampleUITests {

    func SampleUITestsImplementation(){
        Given("user launch the application") { (args, userInfo) in
            SampleUITests().runApplication()
        }

        Then("verify that we are at the start") { (_, _) in
            SampleUITestSteps().verifyThatWeAreAtTheStart()
        }

        When("user touch the buttons") { (args, userInfo) in
            SampleUITests().openOtherFlow()
        }

        Then("user sees other flow") { (args, userInfo) -> Void in
            SampleUITests().verifyOtherFlowIsOpen();
        }

        When("I run the application") { (args, userInfo) in
            SampleUITests().runApplication()
        }

        Then("I can see the first page") { (args, userInfo) in
            SampleUITests().verifyThatWeAreAtTheStart()
        }
    }
}

De esta manera, el desarrollador puede adaptar sus pruebas ya escritas con los requisitos de cliente o los criterios de aceptación de la historia de usuario.

import XCTest

//las pruebas de XCUITest ya escritas por el desarrollador 
class SampleUITests: XCTestCase {
    let application = XCUIApplication()

    func runApplication(){
        //lanzar la aplicación
        application.launch()
    }

    func openOtherFlow() {
        //obtener todas paginas de la aplicación
        let tabBarsQuery = application.tabBars
        //pulsar el botón de la segunda barra para cambiar la página
        tabBarsQuery.buttons["Second"].tap()
        //pulsar el botón de la tercera barra para cambiar la página
        tabBarsQuery.buttons["Third"].tap()
        //deslizar a la parte inferior de la segunda página
        application.scrollViews.containing(.staticText, identifier:"thirdViewLabel").element.swipeUp()
        //pulsar el botón para abrir otro flujo
        application.buttons["Open another flow"].tap()
    }

    func verifyThatWeAreAtTheStart() {
        //comprobar que estamos en la primera página
        XCTAssert(application.otherElements["firstView"].exists)
    }

    func verifyOtherFlowIsOpen() {
        //comprobar que las vistas de las tabs no se muestran
        XCTAssertFalse(application.otherElements["firstView"].exists)
        XCTAssertFalse(application.otherElements["secondView"].exists)
        XCTAssertFalse(application.otherElements["thirdView"].exists)
    }

    func tapBarButtons() {
        let tabBarsQuery = XCUIApplication().tabBars
        let secondButton = tabBarsQuery.buttons["Second"]
        secondButton.tap()
        tabBarsQuery.buttons["First"].tap()
        secondButton.tap()
        tabBarsQuery.buttons["Third"].tap()
    }
}

Conclusiones

Con el fin de que tus pruebas de UI sean fáciles de mantener y rápidas de ejecutar, es recomendable hacerlas lo más simples posible y dejar la verificación lógica compleja a pruebas unitarias. Este tipo de pruebas ofrece la mejor “inversión”, ya que las pruebas de UI son más lentas, puesto que tienen que ejecutar tu aplicación y esperar animaciones, etc. Además, las pruebas UI hacen que la depuración sea mucho menos dolorosa y evitan los problemas que no son tan evidentes desde el punto de vista del código. Como cuando tienes los elementos invisibles o inaccesibles en tu interfaz.

En general, lo más importante de las pruebas y esto también se aplica a las pruebas de UI, es que me da mucha más confianza al cambiar mi aplicación o antes de enviarlo al cliente.

Como se puede ver en los ejemplos, las pruebas de interfaz de usuario también usan las funciones de accesibilidad de UIKit para encontrar los elementos de interfaz, lo que significa que si pasas algún tiempo implementando algunas pruebas de interfaz de usuario, puedes mejorar la accesibilidad de tu aplicación. ¡Bingo!

Referencias

 

La entrada Empezando con Xcode UI testing se publicó primero en Adictos al trabajo.


Persistencia de datos en Android con Room

$
0
0

Índice de contenidos

1. Introducción

Con este tutorial aprenderás a usar Room, una librería para manejar bases de datos SQLite en Android de una manera más segura. Además desarrollaremos un ejemplo utilizando Kotlin.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: MacBook Pro 17’ (2,66 GHz Intel Core i7, 8GB DDR3)
  • Sistema operativo: macOS Sierra 10.13.6
  • Entorno de desarrollo: Android Studio 3.3
  • Versión SDK mínima: 16

3. SQLite

3.1. SQLite

SQLite es un sistema de dominio público de gestión de bases de datos relacionales. La principal ventaja que presenta es que no funciona como un proceso independiente, sino que forma parte de la aplicación que lo utiliza. Por tanto, no necesita ser instalado independientemente, ejecutado o detenido, ni tiene fichero de configuración. Además sigue los principios ACID.

En Android es bastante útil, ya que permite la utilización de una base de datos local en el dispositivo que utilice nuestra aplicación de una manera relativamente ligera y sencilla.

3.2. Inconvenientes

SQLite es relativamente de bajo nivel, por lo que presenta ciertos riesgos. Su implementación requiere tiempo y esfuerzo, ya que se deben escribir las sentencias SQL de la base de datos. Por ello, si el modelo de datos sufre cambios, tendremos que modificar estas sentencias manualmente, con el riesgo que ello implica. Por si esto no fuera poco, estas sentencias no se comprueban durante la compilación, por lo que hay un importante riesgo de errores en tiempo de ejecución.

4. Room

4.1. Room

Room es una librería que abstrae el uso de SQLite al implementar una capa intermedia entre esta base de datos y el resto de la aplicación. De esta forma se evitan los problemas de SQLite sin perder las ventajas de su uso.

Room funciona con una arquitectura cuyas clases se marcan con anotaciones preestablecidas. Por otro lado, la mayoría de las consultas a la base de datos sí se comprueban en tiempo de compilación.

4.2. Arquitectura

Las partes de las que se compone Room son las siguientes:

  • Entity: son clases que definen las tablas de la base de datos y de las entidades a utilizar.
  • DAO: interfaces que definen los métodos utilizados para acceder a la base de datos.
  • RoomDatabase: sirve de acceso a la base de datos SQLite a través de los DAOs definidos.

 

Además, es recomendable utilizar una clase intermedia a la cual denominamos Repository cuya finalidad es administrar las diferentes fuentes de datos.

4.3. Anotaciones

Para que se detecten qué clases tendrán que ser tratadas por esta librería y para indicar ciertas configuraciones debemos utilizar anotaciones. Las principales son las siguientes:

  • @Database: para indicar que la clase será el Database. Además, dicha clase debería ser abstracta y heredar de RoomDatabase.
  • @Dao: se utiliza para las interfaces de los DAOs.
  • @Entity: indica que la clase es una entidad.
  • @PrimaryKey: indica que el atributo al que acompaña será la clave primaria de la tabla. También podemos establecer que se asigne automáticamente si la incluimos así: @PrimaryKey(autoGenerate = true)”.
  • @ColumnInfo: sirve para personalizar la columna de la base de datos del atributo asociado. Podemos indicar, entre otras cosas, un nombre para la columna diferente al del atributo.
  • @Ignore: previene que el atributo se almacene como campo en la base de datos.
  • @Index: para indicar el índice de la entidad.
  • @ForeingKey: indica que el atributo es una clave foránea relacionada con la clave primaria de otra entidad.
  • @Embedded: para incluir una entidad dentro de otra.
  • @Insert: anotación para los métodos de los DAOs que inserten en la base de datos.
  • @Delete: anotación para los métodos de los DAOs que borren en la base de datos.
  • @Update: anotación para los métodos de los DAOs que actualicen una entidad en la base de datos.
  • @Query: anotación para un método del DAO que realice una consulta en la base de datos, la cual deberemos especificar.

5. Ejemplo de utilización de Room

Vamos a desarrollar un ejemplo para ver cómo utilizar Room. Para ello vamos a crear una agenda sencilla con contactos y su teléfono.

5.1. Incorporando Room a nuestro proyecto

Para seguir este tutorial, crea un nuevo proyecto de Android con una actividad vacía. Una vez tengamos nuestro proyecto, tenemos que añadir las dependencias de Room para usarlo.

Para ello, abrimos el fichero de propiedades de gradle y lo editamos. Este tutorial se desarrolla con Kotlin, por lo que tendremos que añadir la dependencia de kapt. Además, nuestro ejemplo seguirá la arquitectura MVVM por lo que vamos a añadir las dependencias para el ViewModel y LiveData, aunque no es necesario para utilizar Room. El resultado es el siguiente:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

apply plugin: 'kotlin-kapt'

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.autentia.tutorialroom"
        minSdkVersion 16
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation "android.arch.persistence.room:runtime:1.1.1"
    kapt "android.arch.persistence.room:compiler:1.1.1"

    implementation "android.arch.lifecycle:extensions:1.1.1" //ViewModel and LiveData

    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

5.2. Creando la Entity

Nuestra base de datos tendrá una tabla para los contactos. Por tanto, tenemos que crear una clase de tipo Entity.

@Entity(tableName = Contact.TABLE_NAME)
data class Contact(
    @ColumnInfo(name = "phone_number") @NotNull val phoneNumber: String,
    @ColumnInfo(name = "first_name") @NotNull val firstName: String,
    @ColumnInfo(name = "last_name") val lastName: String? = null
) {
    companion object {
        const val TABLE_NAME = "contact"
    }

    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "contact_id")
    var contactId: Int = 0
}

Como puedes ver, la entidad tiene cuatro atributos y, como ninguno tiene la etiqueta @Ignore, todos serán columnas de la tabla. La clave primaria se autogenerará y sólo el apellido permitirá valores nulos. Además, todos los nombres de las columnas están especificados con la anotación @ColumnInfo.

5.3. Creando el DAO

Como ya se ha indicado, el DAO será una interfaz que especifica los métodos con los que accederemos a la entidad en la base de datos. Aunque en nuestro caso sólo vamos a insertar y listar todos los elementos, también tienes las funciones para borrar y modificar. También hay que fijarse en que el método getOrderedAgenda() devuelve un objeto de tipo LiveData. Esto no es obligatorio, pudiendo devolver un Array o una Lista, pero como nuestro ejemplo seguirá una arquitectura MVVM vamos a hacerlo así.

@Dao
interface ContactDao {
    @Insert
    fun insert(contact: Contact)

    @Update
    fun update(vararg contact: Contact)

    @Delete
    fun delete(vararg contact: Contact)

    @Query("SELECT * FROM " + Contact.TABLE_NAME + " ORDER BY last_name, first_name")
    fun getOrderedAgenda(): LiveData<List<Contact>>
}

5.4. La RoomDatabase

Nuestra database será abstracta y seguirá el patrón singleton para que sea compartida por cualquier objeto que la utilice. Definimos una función que devolverá el DAO que queremos y en el método getInstance ordenaremos a Room que inicialice la instancia de la database si es null y luego la devolveremos.

@Database(entities = [Contact::class], version = 1)
abstract class ContactsDatabase : RoomDatabase() {
    abstract fun contactDao(): ContactDao

    companion object {
        private const val DATABASE_NAME = "score_database"
        @Volatile
        private var INSTANCE: ContactsDatabase? = null

        fun getInstance(context: Context): ContactsDatabase? {
            INSTANCE ?: synchronized(this) {
                INSTANCE = Room.databaseBuilder(
                    context.applicationContext,
                    ContactsDatabase::class.java,
                    DATABASE_NAME
                ).build()
            }
            return INSTANCE
        }
    }

}

5.5. La clase Repository

Nuestro repositorio accederá a la base de datos para recuperar el DAO de los contactos y tendrá dos métodos, uno para insertar y otro para recuperar el LiveData. Además, implementaremos una clase privada para poder ejecutar la llamada de inserción en un hilo independiente, ya que no se permite realizarla en el hilo principal.

class ContactsRepository(application: Application) {
    private val contactDao: ContactDao? = ContactsDatabase.getInstance(application)?.contactDao()

    fun insert(contact: Contact) {
        if (contactDao != null) InsertAsyncTask(contactDao).execute(contact)
    }

    fun getContacts(): LiveData<List<Contact>> {
        return contactDao?.getOrderedAgenda() ?: MutableLiveData<List<Contact>>()
    }

    private class InsertAsyncTask(private val contactDao: ContactDao) :
        AsyncTask<Contact, Void, Void>() {
        override fun doInBackground(vararg contacts: Contact?): Void? {
            for (contact in contacts) {
                if (contact != null) contactDao.insert(contact)
            }
            return null
        }
    }
}

5.6. Accediendo a Room desde el resto de la app

Llegados a este punto, ya tenemos Room implementado, por lo que ahora vamos a desarrollar el resto de la aplicación para acceder a la base de datos y manipular su contenido. Para empezar, vamos a desarrollar el View Model, que instanciará la clase ContactsRepository para recuperar el LiveData e insertar contactos.

class ContactsViewModel(application: Application) : AndroidViewModel(application) {
    private val repository = ContactsRepository(application)
    val contacts = repository.getContacts()

    fun saveContact(contact: Contact) {
        repository.insert(contact)
    }
}

Para seguir, modificaremos el layout activity_main.xml que encontramos dentro de la carpeta res>layout, en el cual incluiremos tres campos de texto y un botón para añadir contactos. Por otro lado, tendremos un TextView para poder mostrar los contactos, aunque también podríamos hacerlo por ejemplo con un ListView. El contenido debe quedar así:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/fistName_editText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ems="10"
        android:inputType="textPersonName"
        android:hint="Nombre"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    <EditText
        android:id="@+id/lastName_editText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ems="10"
        android:inputType="textPersonName"
        android:hint="Apellido"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/fistName_editText" />

    <EditText
        android:id="@+id/phone_editText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ems="10"
        android:inputType="textPersonName"
        android:hint="Teléfono"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/lastName_editText" />

    <Button
        android:id="@+id/addContact_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Añadir"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/phone_editText" />

    <TextView
        android:id="@+id/contacts_textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/addContact_button"
        android:gravity="center"/>

</android.support.constraint.ConstraintLayout>

Por último, vamos con la clase MainActivity. Esta clase debe observar el LiveData del ViewModel para mostrar los cambios y añadir un listener al botón para añadir el contacto cuando se pulse.

class MainActivity : AppCompatActivity() {
    private lateinit var contactsViewModel: ContactsViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        contactsViewModel = run {
            ViewModelProviders.of(this).get(ContactsViewModel::class.java)
        }

        addContact_button.setOnClickListener { addContact() }
        addObserver()
    }

    private fun addObserver() {
        val observer = Observer<List<Contact>> { contacts ->
            if (contacts != null) {
                var text = ""
                for (contact in contacts) {
                    text += contact.lastName + " " + contact.firstName + " - " + contact.phoneNumber + "\n"
                }
                contacts_textView.text = text
            }
        }
        contactsViewModel.contacts.observe(this, observer)
    }

    private fun addContact() {
        val phone = phone_editText.text.toString()
        val name = fistName_editText.text.toString()
        val lastName =
            if (lastName_editText.text.toString() != "") lastName_editText.text.toString()
            else null

        if (name != "" && phone != "") contactsViewModel.saveContact(Contact(phone, name, lastName))
    }
}

Con esto hemos terminado el tutorial, por lo que podemos ejecutar nuestra aplicación y añadir nuevos contactos que se mostrarán debajo del botón.

 

¡Muchas gracias por haber leído hasta aquí!

6. Referencias

http://www.sqlitetutorial.net/what-is-sqlite//a>
https://developer.android.com/reference/androidx/room/Room
https://www.sqlite.org/index.html
https://developer.android.com/training/data-storage/room/

La entrada Persistencia de datos en Android con Room se publicó primero en Adictos al trabajo.

Escaneo de claves en un clúster Redis

$
0
0

Índice de contenidos

1. Introducción

Redis es una base de datos en memoria que almacena tipos de datos: strings, listas, hashes, sets ordenados, streams,… Puede ser utilizado como sistema de caché, para lo que hay que tener en cuenta una serie de consideraciones como por ejemplo deshabilitar la persistencia en disco o limitar el uso de memoria máximo disponible para el servicio Redis a un 60% de la RAM total disponible.

Las soluciones clusterizadas de Redis tienen como objetivo conseguir un alto rendimiento y facilitar el escalado, así como asegurar disponibilidad, teniendo al menos una réplica por cada nodo maestro.

Cuando montamos un clúster Redis podemos vernos en la necesidad de monitorizar el uso de memoria por cada uno de los datos almacenado en Redis.

En este tutorial veremos cómo realizar dicha monitorización sin poner en peligro el servicio, ya que existe la posibilidad de que bloqueemos el acceso a los nodos del clúster por estar realizando una operación muy costosa.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

    • Hardware: Portátil MacBook Pro 15’ (2,5 GHz Intel Core i7, 16 GB 1600 MHz DDR3)
    • Sistema operativo: Mac OS X High Sierra 10.13.6
    • Entorno de desarrollo: IntelliJ IDEA CE 2018.3.4
    • Java 11.0.2
    • Apache Maven 3.5.3
    • Redis 5.0.3
    • Jedis 3.0.1

En cuanto al clúster Redis, se ha creado un clúster mínimo, es decir, 3 nodos maestros y sus correspondientes 3 réplicas, cada uno configurado con una política de desalojo LRU. También se han configurado para que no persistan en memoria y con el máximo de memoria establecido a 2.4 GB.

3. Inicializando el clúster

Para inicializar el clúster y realizar el escaneado de claves he creado un proyecto Java gestionado por Maven, por tanto lo primero que tenemos que hacer es incluir en nuestro pom la dependencia con la librería Jedis, un cliente de Redis para Java:

pom.xml

...

	<dependencies>
		<dependency>
			<groupId>redis.clients</groupId>
			<artifactId>jedis</artifactId>
			<version>3.0.1</version>
		</dependency>
		<dependency>
			<groupId>com.fasterxml.jackson.core</groupId>
			<artifactId>jackson-databind</artifactId>
			<version>2.9.8</version>
		</dependency>
	</dependencies>

...

También se incluye la librería Jackson para devolver la respuesta del programa en formato Json.

Podemos crearnos un método main encargado de recoger los parámetros de entrada del programa, inicializar el clúster (de manera opcional) con datos de prueba, realizar el escaneo y mostrar el resultado por la salida estándar.

package com.autentia.redis;

import com.autentia.redis.initializer.ClusterInitializer;
import com.autentia.redis.response.ScannerResponse;
import com.autentia.redis.scanner.KeyScanner;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;

import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class ScanRedisCluster {

    private static final ObjectMapper mapper = new ObjectMapper();

    public static void main(String[] args) {
        if (args.length  4) {
            System.err.println("Provide all master nodes in ip:port format (Optional -i to initialize cluster)");
            return;
        }

        Set nodes = new HashSet();
        String[] splittedNode = args[0].split(":");
        HostAndPort hostAndPort = new HostAndPort(splittedNode[0], Integer.valueOf(splittedNode[1]));
        nodes.add(hostAndPort);


        try (JedisCluster cluster = new JedisCluster(nodes)) {

            if (args.length == 4 && "-i".equals(args[3])) {
                ClusterInitializer.initialize(cluster);
            }

            final List response = KeyScanner.scan(cluster, Arrays.asList(args[0], args[1], args[2]));
            System.out.println(mapper.writeValueAsString(response));
        } catch (JsonProcessingException e) {
            System.err.println("Error parsing response");
        }

    }
}

Como se observa en el snippet de código anterior, para realizar la conexión con Redis utilizamos el primer parámetro de entrada, que será el host y el puerto a través del que da servicio uno de los nodos maestros de nuestro clúster. Jedis solo necesita la información de uno de los nodos, y lo usará para obtener la información necesaria de la topología del clúster.

Si incluimos como último parámetro de entrada al programa la opción -i inicializaremos el clúster con la clase ClusterInitilizer. Dicha clase tiene un método encargado de crear datos en Redis, en particular crea 10 claves de tipo zset, que agrupan a su vez 50000 claves de tipo string. Además también incluimos 100000 claves de tipo hash con 20 campos cada uno.

package com.autentia.redis.initializer;

import redis.clients.jedis.JedisCluster;

public class ClusterInitializer {

    public static void initialize(JedisCluster cluster) {
        initialiceZsets(cluster);
        initializeHashes(cluster);
    }

    private static void initialiceZsets(JedisCluster cluster) {
        for (int zsetKey = 0; zsetKey < 10; zsetKey++) {
            for (int memberKey = 0; memberKey < 50000; memberKey++) {
                String member = "key" + zsetKey + memberKey;
                String value = "value" + zsetKey + memberKey;
                cluster.set(member, value);
                String zset = "zset" + zsetKey;
                cluster.zadd(zset, 0, member);
            }
        }
    }

    private static void initializeHashes(JedisCluster cluster) {
        for (int hashKey = 0; hashKey < 100000; hashKey++) {
            String hash = "hash" + hashKey;
            for (int fieldKey = 0; fieldKey < 20; fieldKey++) {
                String field = "field" + fieldKey;
                String value = "hashValue" + fieldKey;
                cluster.hset(hash, field, value);
            }
        }
    }

}

¿Por qué he inicializado el clúster con zsets y hashes? He querido enfocar la solución en situaciones en las que utilizamos Redis como sistema de caché distribuida. Si utilizamos Spring Data Redis en combinación con Jedis para la gestión de la caché, los métodos que hayamos marcado como @Cacheable crearán una nueva clave, en el nodo Redis que les corresponda, que será de tipo string. Esto se hará por cada una de las diferentes combinaciones de parámetros de entrada con las que se llame al método cacheado. Estas claves contendrán el valor cacheado, y a su vez dichas claves estarán agrupadas en un zset que identifican de manera genérica al método.

Por otro lado, también se almacena en el clúster Redis los hashes que hemos comentado anteriormente. Redis nos puede servir para almacenar las sesiones de nuestra aplicación web, de manera que cuando se necesite información asociada a una sesión, ésta se vaya a buscar al clúster Redis, evitando la necesidad de tener afinidad de sesión con los clientes de la aplicación. Para realizar dicha labor podemos usar Spring Session en particular la librería Spring Session Data Redis. Y como os podéis imaginar, la información asociadas de las sesiones se guarda en Redis como hashes.

4. Escaneado de claves

A la hora de hacer un recorrido por todas las claves que tenemos en un nodo Redis hay que tomar precauciones. Quizás nuestro primer impulso sea lanzar el comando KEYS con el patrón *. Y sí, efectivamente obtendremos todas las claves. ¿Pero qué ocurre si lo lanzamos en entornos productivos donde se han podido generar cientos de miles de claves?, pues que ponemos en peligro el rendimiento, es más, podemos bloquear durante mucho tiempo el acceso a Redis con lo que estaremos bloqueando a las aplicaciones clientes del sistema.

Como alternativa, Redis nos proporciona el comando SCAN. Con él podremos obtener las claves de una manera iterativa, es decir, nos va proporcionando las claves por lotes. Con un cursor que inicializaremos a cero, y del que Redis nos devolverá un valor actualizado con el que realizar la siguiente llamada, podremos ir recuperando todas las claves. El proceso finalizará cuando el cursor vuelva a ser cero. El comando SCAN no te puede asegurar que te acabe devolviendo todas las claves ni que todas las que te devuelva realmente estén, ya que durante las iteraciones se han podido crear o eliminar claves.

El escaneador de claves que os propongo es el siguiente:

package com.autentia.redis.scanner;

import com.autentia.redis.model.RedisZset;
import com.autentia.redis.response.ScannerResponse;
import com.autentia.redis.response.ZsetResponseBuilder;
import redis.clients.jedis.*;

import java.util.*;
import java.util.stream.Collectors;

public class KeyScanner {

    public static List scan(JedisCluster cluster, List masterNodes) {
        List zsets = new ArrayList();
        Map stringKeysWithMemoryUsage = new HashMap();
        Map hashesKeysWithMemoryUsage = new HashMap();

        Map clusterNodes = cluster.getClusterNodes();
        Set clusterNodesHostAndPort = clusterNodes.keySet();

        clusterNodesHostAndPort.stream().forEach(node -> {
            if (isMaster(node, masterNodes)) {
                JedisPool jedisPool = clusterNodes.get(node);
                try (Jedis connection = jedisPool.getResource()) {
                    String cursor = "0";
                    do {
                        ScanResult scanResult = connection.scan(cursor);
                        List keys = scanResult.getResult();
                        keys.stream().forEach(key -> {
                            long memoryUsage = getMemoryUsage(connection, key);
                            String type = connection.type(key);
                            switch (type) {
                                case "zset":
                                    List zsetKeys = getZsetKeys(key, connection);
                                    RedisZset zset = new RedisZset(key, zsetKeys, memoryUsage);
                                    zsets.add(zset);
                                    break;
                                case "string":
                                    stringKeysWithMemoryUsage.put(key, memoryUsage);
                                    break;
                                case "hash":
                                    hashesKeysWithMemoryUsage.put(key, memoryUsage);
                                    break;
                            }
                        });
                        cursor = scanResult.getCursor();
                    } while (!"0".equals(cursor));
                }
            }
        });

        return getResponse(zsets, stringKeysWithMemoryUsage, hashesKeysWithMemoryUsage);
    }

    private static boolean isMaster(String node, List masterNodes) {
        return masterNodes.contains(node);
    }

    private static long getMemoryUsage(Jedis connection, String key) {
        long memoryUsage = 0;
        String script = "return redis.call('memory', 'usage', '" + key + "')";
        Object memoryUsageResult = connection.eval(script);
        try {
            memoryUsage = Long.valueOf(String.valueOf(memoryUsageResult));
        } catch (NumberFormatException e) {
            System.err.println("Invalid memory usage in key: " + key);
        }
        return memoryUsage;
    }

    private static List getZsetKeys(String key, Jedis connection) {
        List zsetKeys = new ArrayList();
        String cursor = "0";
        do {
            ScanResult zscanResult = connection.zscan(key, cursor);
            cursor = zscanResult.getCursor();
            List keysResult = zscanResult.getResult();
            List keys = keysResult.stream().map(Tuple::getElement).collect(Collectors.toList());
            zsetKeys.addAll(keys);
        } while(!"0".equals(cursor));
        return zsetKeys;
    }

    private static List getResponse(List zsets, Map stringKeysWithMemoryUsage, Map hashesKeysWithMemoryUsage) {
        List responses = new ArrayList();
        List zsetResponses = new ZsetResponseBuilder().build(zsets, stringKeysWithMemoryUsage);
        long hashesMemoryUsage = hashesKeysWithMemoryUsage.values().stream().mapToLong(Long::longValue).sum();
        ScannerResponse hashesResponses = new ScannerResponse("Hashes for sessions", "hash", hashesMemoryUsage);
        responses.addAll(zsetResponses);
        responses.add(hashesResponses);
        return responses;
    }

}

Lo primero que nos puede llamar la atención es que lanzamos el comando SCAN por cada uno de los nodos maestros, utilizando una conexión directa a ellos en lugar de utilizar la conexión general con el clúster de tipo JedisCluster. Esto se debe a que Jedis no nos permite hacer un scan con el patrón * contra el clúster en general.

El algortimo, a medida que va obteniendo los lotes de claves recuperados por el comando SCAN, se encarga de a través del comando MEMORY USAGE, incluido a partir de Redis 4.0.0, de obtener el uso de memoria en bytes de cada elemento. Esto incluye tanto el valor o valores que almacena, como lo que implica almacenar su clave. Jedis, al menos en la versión que estoy utilizando a día de hoy, no incluye la operación MEMORY USAGE, como alternativa, utilizo la operación EVAL, a la que le podemos pasar un script LUA para ser ejecutado en el nodo Redis. En este caso enviamos un script que devuelve el resultado de ejecutar el comando MEMORY USAGE sobre una clave concreta. He concatenado el string que forma el script con la operación +, pero podríamos usar un StringBuilder como mejor práctica.

Una vez que tenemos el uso de memoria, obtenemos el tipo de la clave con el comando TYPE. Si el TYPE nos indica que se trata de un zset, obtenemos todas las claves que contiene. Igual que ocurre con el comando KEYS, debemos procurar no usar el comando ZRANGE, ya que si tenemos muchas claves agrupadas en dicho zset podremos volver a tener problemas de bloqueo. Como alternativa disponemos del comando ZSCAN, cuyo funcionamiento es el mismo que el del SCAN pero sobre un zset concreto. No obstante, si vemos que se están generando un número de claves excesivo asociadas a un zset, puede que tengamos que analizar lo que estamos cacheando y cómo, porque es posible que no lo hayamos planteado correctamente, ya que si hay muchas combinaciones de parámetros de entrada también es posible que los hit de cachés sean bajos o casi inexistentes, con lo que realmente no nos aporta la manera en que se está cacheando.

Las claves de tipo string serán las claves agrupadas por los zsets. En el ejemplo, estas claves son muchas, y devolverlas todas como respuesta del programa puede ser un problema, además, usando Redis como sistema de caché con Jedis, estas claves se almacenan como array de bytes si usamos el serializador por defecto, por lo que mostrarlas no nos aporta mucha información. Lo mejor es obtener lo que ocupan y luego acumular todos esos valores de uso de memoria en el zset al que pertenecen.

Finalmente, de las claves de tipo hash obtenemos al igual que con los string solo lo que ocupan, para luego acumular todos estos valores e indicar que es el espacio total que está ocupando en un momento dado la gestión de sesiones. Las sesiones caducan, por lo que desaparecen y aparecen en Redis constantemente, a parte de que pueden ser muchas, por lo que tampoco aporta mucho devolver las claves concretas.

Como vemos, para montar la respuesta del programa, por un lado sumamos todo el uso de memoria que implican los hashes y lo agrupamos en un mismo elemento, sin embargo, como la lógica de la construcción de la respuesta de los elementos zset es un poco más compleja, se ha implementado en la siguiente clase:

package com.autentia.redis.response;

import com.autentia.redis.model.RedisZset;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class ZsetResponseBuilder {

    public static List build(List zsets, Map stringKeysWithMemoryUsage) {
        List zsetResponse = new ArrayList();
        zsets.stream().forEach(zset -> {
            long memoryUsage = zset.getMemoryUsage();
            memoryUsage += zset.getZsetKeys().stream().mapToLong(key -> {
                Long zsetKeyMemoryUsage = stringKeysWithMemoryUsage.get(key);
                return zsetKeyMemoryUsage != null ? zsetKeyMemoryUsage : 0;
            }).sum();
            ScannerResponse response = new ScannerResponse(zset.getKey(), "zset", memoryUsage);
            zsetResponse.add(response);
        });

        return zsetResponse;
    }
}

El builder se encarga de acumular el espacio de memoria utilizado por el propio zset con el utilizado por cada una de sus claves en un mismo valor.

De cara a construir la respuesta utilizamos una lista de la siguiente clase:

package com.autentia.redis.response;

public class ScannerResponse {

    private final String key;

    private final String type;

    private final long memoryUsage;

    public ScannerResponse(String key, String type, long memoryUsage) {
        this.key = key;
        this.type = type;
        this.memoryUsage = memoryUsage;
    }

    public String getKey() {
        return key;
    }

    public String getType() {
        return type;
    }

    public long getMemoryUsage() {
        return memoryUsage;
    }
}

La clase de modelo utilizada para guardar la información de los zset (su clave, su uso de memoria sin incluir el usado por sus claves, y las claves de tipo string que agrupa) es la siguiente:

package com.autentia.redis.model;

import java.util.List;

public class RedisZset {

    private final String key;

    private final List zsetKeys;

    private final long memoryUsage;

    public RedisZset(String key, List zsetKeys, long memoryUsage) {
        this.key = key;
        this.zsetKeys = zsetKeys;
        this.memoryUsage = memoryUsage;
    }

    public String getKey() {
        return key;
    }

    public List getZsetKeys() {
        return zsetKeys;
    }

    public long getMemoryUsage() {
        return memoryUsage;
    }
}

Aquí os dejo un ejemplo de la salida del programa:

[
   {
      "key":"zset4",
      "type":"zset",
      "memoryUsage":7312131
   },
   {
      "key":"zset5",
      "type":"zset",
      "memoryUsage":7872131
   },
   {
      "key":"zset1",
      "type":"zset",
      "memoryUsage":7312131
   },
   {
      "key":"zset9",
      "type":"zset",
      "memoryUsage":7472131
   },
   {
      "key":"zset0",
      "type":"zset",
      "memoryUsage":7632131
   },
   {
      "key":"zset8",
      "type":"zset",
      "memoryUsage":7472131
   },
   {
      "key":"zset3",
      "type":"zset",
      "memoryUsage":7712131
   },
   {
      "key":"zset7",
      "type":"zset",
      "memoryUsage":7632131
   },
   {
      "key":"zset6",
      "type":"zset",
      "memoryUsage":7472131
   },
   {
      "key":"zset2",
      "type":"zset",
      "memoryUsage":7632131
   },
   {
      "key":"Hashes for sessions",
      "type":"hash",
      "memoryUsage":48188890
   }
]

5. Alternativas

Algunas alternativas para hacernos una idea de lo que están ocupando nuestras claves en Redis y detectar problemas pueden ser por ejemplo, el acceder a cada uno de los nodos del clúster a través del cliente de consola de Redis redis-cli con la opción –bigkeys. Esta opción realiza un escaneo de claves, también de una manera iterativa con el comando SCAN. Y nos proporciona información del número total de claves entontradas organizadas por tipos, el porcentaje del total de claves y la media de espacio que ocupan. También nos dicen que claves son las que más ocupan por tipo. No nos ofrece información de todas las claves pero nos puede indicar que por ejemplo existe un zset, el mayor, que contiene 600000 miembros. Quizás será una caché que debemos analizar.

Ya he comentado la posibilidad de lanzar un script LUA contra cada uno de los nodos del clúster con el comando EVAL. Yo he hecho un pequeño script:

function string.tohex(str)
    return (str:gsub('.', function (c)
        return string.format('%02X', string.byte(c))
    end))
end

local keys = {}; 
local done = false; 
local cursor = "0"; 

repeat 
	local result = redis.call("SCAN", cursor); 
	cursor = result[1]; 
	for i, key in ipairs(result[2]) do
		table.insert(keys, key); 
	end
until cursor == "0"; 

local memoryUsages = {}
local types = {}
for i, key in ipairs(keys) do
	local memoryUsage = redis.call("MEMORY", "USAGE", key);
	table.insert(memoryUsages, memoryUsage);
	local type = redis.call("TYPE", key).ok;
	table.insert(types, type); 
end

local responses = {}
for i = 1, #keys do
	if types[i] == "zset" then
		local response = types[i] .. ", " .. keys[i] .. ", " .. memoryUsages[i]
		local zsetKeys = redis.call("ZRANGE", keys[i], 0, -1)
		response = response .. ","
		for i, key in ipairs(zsetKeys) do
			response = response .. " " .. string.tohex(key)
		end
		table.insert(responses, response);
	elseif string.match(keys[i], "spring:session") then
		local response = types[i] .. ", " .. keys[i] .. ", " .. memoryUsages[i]
		table.insert(responses, response);
	else
		local response = types[i] .. ", " .. string.tohex(keys[i]) .. ", " .. memoryUsages[i]
		table.insert(responses, response);
	end
	
end

return responses

Os pido por favor que no lo ejecutéis en entornos productivos por dos motivos principales, el primero, como véis se utiliza el comando ZRANGE para obtener las claves asociadas a un zset, que como ya hemos dicho no es recomendable, y el segundo que ya de por sí es un script pesado y mientras se esté ejecutando bloquearemos el clúster. Si esto os ocurriese os recomiendo que lancéis el comando SCRIPT KILL sobre el nodo bloqueado. Simplemente lo comparto para que tengaís una referencia de como se podría hacer un escaneo de claves en un nodo con LUA.

6. Conclusiones

Lo que creo que debe quedar claro con este tutorial, es que debemos tener cuidado al intentar obtener información de todas las claves que se almacenan en nodos Redis. Las primeras opciones siempre deben ser las propuestas por la herramienta, como el comando INFO, en particular en la sección MEMORY donde encontraremos el uso de memoria por parte de las claves, el uso de memoria por parte del servicio Redis en general, los picos de memoria, niveles de fragmentación, etc, o la opción –big keys vista anteriormente.

Elastic también proporciona beats con métricas a explotar y visualizar en Kibana, este beat hace uso del comando INFO.

Si necesitamos obtener información más completa de las claves hay que procurar consumirlas siempre de manera iterativa con el comando SCAN y similares, tal y como se ha visto en este tutorial. Si vamos a usar este tipo de programas para automatizar la monitorización debemos tener cuidado con la frecuencia con la que se lanzarán.

¡Espero que esta información os haya resultado útil!

7. Referencias

La entrada Escaneo de claves en un clúster Redis se publicó primero en Adictos al trabajo.

Securizando un API Rest con JWT y roles

$
0
0

Índice de contenidos

  1. Introducción
  2. Entorno
  3. Securizando con roles: diferencia entre @PreAuthorize y @Secured
  4. Vamos al lío
  5. La hora de la verdad
  6. Conclusiones

1. Introducción

Siguiendo la línea de lo que nos contó Álvaro en su tutorial sobre como securizar un API Rest con JWT, vamos a profundizar un poco más en lo que nos ofrece Spring Security para poder proteger nuestra API permitiendo el acceso según los roles asociados a los usuarios.

Como la base es el tutorial anterior, no volveremos a hacer hincapié en lo que Álvaro nos enseñó y lo tomaremos como punto de partida para este tutorial. El ejemplo completo puedes encontrarlo en GitHub.

2. Entorno

  • Hardware: Portátil MacBook Pro 15 pulgadas (2.5 GHz Intel i7, 16GB 1600 Mhz DDR3, 500GB Flash Storage).
  • Sistema Operativo: MacOs Mojave 10.14.2
  • Versiones del software:
    • Java 11
    • Spring Boot 2.1.0.RELEASE
    • Lombok 1.18.4

3. Securizando con roles: diferencia entre @PreAuthorize y @Secured

Los roles nos permiten tener un control más especifico del acceso a nuestra API, pudiendo acotar a un grupo más reducido de usuarios ciertos recursos de nuestro sistema o proteger el acceso a operaciones sensibles o críticas. Por ejemplo, si tenemos una intranet para dar de alta empleados, podríamos permitir el acceso a las altas únicamente a los responsables de RRHH y la consulta de empleados a un perfil más público (como los empleados de la empresa).

Spring Security nos permite de manera sencilla proteger nuestros puntos de API con las etiquetas @PreAuthorize y @Secured indicando los roles que tienen acceso. En función de cómo queramos securizar nuestros endpoints, utilizaremos una anotación u otra:

  • @PreAuthorize es una anotación más nueva que @Secured (disponible desde la versión 3 de Spring Security) y mucho más flexible.
  • @PreAuthorize soporta Spring Expression Language (SpEL) pudiendo acceder a todos métodos y propiedades de la clase SecurityExpressionRoot pudiendo por tanto utilizar expresiones como hasRole, permitAll…mientras que con @Secured indicamos sólo los roles permitidos.
  • Cuando securizamos nuestros endpoints con @Secured indicando varios roles se permite el acceso a cualquier usuario que tenga asociado al menos uno de los roles (equivalente a una expresión OR). Con @PreAuthorize, como admite expresiones (pudiendo usar AND, OR, NOT) podríamos implementar un acceso exclusivo a aquellos usuarios que tengan varios roles asociados:
    • @Secured({“ROLE_ADMIN”, “ROLE_USER”}) Acceso a todos usuarios con rol admin o user.
    • @PreAuthorize(“hasRole(‘ROLE_ADMIN’) AND hasRole(‘ROLE_USER’)”) Acceso a todos usuarios con rol admin y user.
    • @Secured({“ROLE_ADMIN”}) y @PreAuthorize(“hasRole(‘ROLE_ADMIN’)”) son equivalentes.

4. Vamos al lío

4.1 Implementando la API de usuarios

A continuación vamos a ver qué cambios tenemos que hacer en nuestra configuración de Spring Security para securizar nuestra aplicación con roles. La aplicación desarrollada inicializa la base de datos con una serie de roles (admin, user y operational), una serie de usuarios y la configuración de qué roles tienen asociados cada uno de estos usuarios:

  • user1/password1: rol admin y rol usuario
  • user2/password2: rol operational
  • user3/password3: rol usuario

Nuestra aplicación tiene disponible varios endpoints:

  • Accesible para rol usuario:
    • [GET] /users/{id}: Recuperar la información de un usuario por id.
  • Accesible para rol administrador:
    • [POST] /users: Dar de alta usuarios.
    • [GET] /users/{id}: Recuperar la información de un usuario por id.

4.2 Configurando @PreAuthorized

Lo primero que tenemos que hacer es configurar dentro de nuestro WebSecurityConfig que se pueda utilizar el método PreAuthorized para la seguridad:

@Configuration
@EnableWebSecurity
//@EnableGlobalMethodSecurity(securedEnabled = true) // @Secured
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
}

Ahora, protegemos nuestros endpoints para los roles indicados anteriormente, teniendo en cuenta que recuperar el detalle de un usuario estará disponible para para rol admin y user:

//@Secured({"ROLE_ADMIN","ROLE_USER"})
@PreAuthorize("hasRole('ROLE_USER') OR hasRole('ROLE_ADMIN')")
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable long id) {
        ...
}
//@Secured("ROLE_ADMIN")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping
public ResponseEntity<User> saveUser(@RequestBody AuthorizationRequest userRequest) {
	...
}

4.3 Añadiendo los roles al token

Para finalizar, nos falta añadir la información de los roles en el token generado para el usuario. En primer lugar, la instancia de UserDetails que contiene la información del usuario autenticado debe contener los authorities asociados:

@Service("userDetailsService")
public class UserServiceImpl implements UserService {
	...
	@Override
	public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
		final User retrievedUser = userRepository.findByName(userName);
		if (retrievedUser == null) {
			throw new UsernameNotFoundException("Invalid username or password");
		}
		return UserDetailsMapper.build(retrievedUser);
	}
        ...
}

Los roles asociados vendrán informados con un prefijo ROLE_ (es buena práctica hacerlo por legibilidad) seguido del rol definido en nuestra base de datos.

public class UserDetailsMapper {
	public static UserDetails build(User user) {
		return new org.springframework.security.core.userdetails.User(user.getName(), user.getPassword(), getAuthorities(user));
	}
	private static Set<? extends GrantedAuthority> getAuthorities(User retrievedUser) {
		Set<Role> roles = retrievedUser.getRoles();
		Set<SimpleGrantedAuthority> authorities = new HashSet<>();
		roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName())));
		return authorities;
	}
}

De este modo, la instancia de Authentication a la que está asociada el usuario y que maneja la seguridad ya tiene los roles configurados en nuestro sistema, pudiendo devolver el token con esta información dentro de la propiedad claim:

public class TokenProvider {
	...
	public static String generateToken(Authentication authentication) {
		// Genera el token con roles, issuer, fecha, expiración (8h)
		final String authorities = authentication.getAuthorities().stream()
				.map(GrantedAuthority::getAuthority)
				.collect(Collectors.joining(","));
		return Jwts.builder()
				.setSubject(authentication.getName())
				.claim(AUTHORITIES_KEY, authorities)
				.signWith(SignatureAlgorithm.HS256, SIGNING_KEY)
				.setIssuedAt(new Date(System.currentTimeMillis()))
				.setIssuer(ISSUER_TOKEN)
				.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY_SECONDS*1000))
				.compact();
	}
        ...
}

Ahora nuestro token ya tiene esta información y podemos recuperarla para construir nuestro UsernamePasswordAuthenticationToken que es el utilizado por Spring Security para saber si tiene acceso al endpoint del servicio:

public class JwtAuthorizationFilter extends OncePerRequestFilter {
	...
	@Override
	protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
			FilterChain filterChain) throws ServletException, IOException {
		String authorizationHeader = httpServletRequest.getHeader(Constants.HEADER_AUTHORIZATION_KEY);
		...
		UserDetails user = userService.loadUserByUsername(userName);

		UsernamePasswordAuthenticationToken authenticationToken = TokenProvider.getAuthentication(token, user);
		SecurityContextHolder.getContext().setAuthentication(authenticationToken);
		filterChain.doFilter(httpServletRequest, httpServletResponse);
	}
}

public class TokenProvider {
	...
	public static UsernamePasswordAuthenticationToken getAuthentication(final String token,
			final UserDetails userDetails) {
		final JwtParser jwtParser = Jwts.parser().setSigningKey(SIGNING_KEY);
		final Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
		final Claims claims = claimsJws.getBody();
		final Collection<SimpleGrantedAuthority> authorities =
				Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
						.map(SimpleGrantedAuthority::new)
						.collect(Collectors.toList());
		return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
	}
        ...
}

5. La hora de la verdad

Ahora podemos hacer pruebas con nuestros usuarios. Por ejemplo user1 y user3 , con rol de administrador y user respectivamente, pueden ver el detalle de un usuario. Primero obtendremos un token válido con la llamada a /login:

Usamos el token de la respuesta y hacemos una llamada al servicio para ver el detalle del usuario. Esta llamada nos devuelve una respuesta con código 200 (OK) y la información del usuario:

Si hacéis la prueba con user3, veréis que también tiene acceso al detalle de un usuario. Sin embargo con “user2” no podemos ver el detalle porque no tiene un rol válido. La respuesta será de tipo 403 (Forbidden):

Podéis hacer pruebas también con el endpoint de dar de alta un usuario, que es un poco más restrictivo porque sólo user1, al ser administrador, puede acceder a él.

6. Conclusiones

Es muy común el desarrollo de aplicaciones web que puedan ser gestionadas por usuarios con diferentes perfiles, por lo que la securización de nuestra API en función de roles es algo imprescindible si queremos restringir el acceso a ciertos recursos. Los puntos más relevantes en los que hemos profundizado han sido:

  • La securización por roles con @Secured y @PreAuthorize y la potencia de cada una de ellas, viendo cuándo se puede utilizar una u otra.
  • Informar en nuestro JWT token de las authorities/claims del usuario para autorizar el acceso a los recursos ofrecidos. También hemos visto cómo recuperar del token estos roles y cómo asociarlos a la instancia de tipo Authentication que maneja el acceso.

Podéis descargaros el ejemplo completo para jugar con la API que he implementado o implementar la vuestra propia jugando con los roles y los usuarios. Cualquier pregunta o duda que tengáis no dudéis en escribir en la sección de comentarios del tutorial. Intentaré responder lo antes posible.

La entrada Securizando un API Rest con JWT y roles se publicó primero en Adictos al trabajo.

Como mantener seguridad y privacidad mediante una VPN

$
0
0

En este artículo vamos a analizar cómo funciona una VPN y las razones por las que puede ser recomendable utilizar una VPN, comenzaremos con una pequeña perspectiva acerca de qué es una VPN.

Índice de contenidos

1. ¿Cómo funciona Internet?

Internet es inicialmente una sucesión de pequeñas redes que se interconectan entre sí, cada una de esas redes conoce lo que hay dentro de ella y, mediante protocolos de comunicación, contiene una serie de tablas que le permiten saber cuál es el siguiente salto que ha de dar para llegar a un recurso en una red lejana.

Esas redes se unen en redes mayores que aglutinan más destinos, en cada transición de red se conoce cuál será el siguiente salto que ha de dar el paquete para llegar a su destino. Cada uno de esos saltos se conocen como hops y se realizan en ubicaciones fuera de nuestra red. En cada uno de estos saltos nuestras comunicaciones pueden verse interceptadas por terceros.

2. ¿Quién querría interceptar nuestras comunicaciones?

Tradicionalmente la respuesta a esta pregunta eran los hackers, pero desde las revelaciones de Snowden ya sabemos que no tenemos que temer solo por nuestra seguridad, sino también por nuestra privacidad, programas como PRISM han hecho que muchos tomemos otra actitud con respecto a nuestra seguridad.

3. ¿Qué es una VPN?

Una VPN es una especie de carril lógico en el que nuestros datos viajan de forma aislada. Este aislamiento se consigue mediante la encriptación de los datos, de forma que, cualquier punto por el que pasan puede ver cuál es el destino y cuál es el punto anterior, pero no puede ver el contenido del paquete.  De esta manera conseguimos una serie de ventajas:

  • Seguridad: Al estar nuestras comunicaciones protegidas por una encriptación fuerte, los datos no pueden ser manipulados.
  • Disponibilidad geográfica: Al realizarse la conexión a través de un servidor situado en otra región geográfica, podemos realizar nuestras conexiones desde cualquier punto del planeta, permitiendo así cambiar con ello los servicios o contenidos a los que tenemos acceso.
  • Privacidad: Un servicio de VPN no debería guardar registros de nuestras conexiones, de tal forma que el registro de conexiones en nuestro operador solo mostrará conexiones a los servidores de VPN, no a los puntos finales a los que se realiza la conexión.

4. ¿Cómo funciona una VPN?

La conexión VPN se realiza entre dos puntos, la forma en la que se realiza la conexión depende del protocolo utilizado, de forma general, ambos extremos se identifican e intercambian los protocolos de encriptación/comunicación, una vez que han intercambiado la información se establece el túnel de comunicación, de manera que toda comunicación se encripta en un extremo del túnel y viaja encriptada hasta el otro extremo del mismo, una encriptación de 256-bit, por ejemplo, requería algo así como un decillón de años para ser violada por un ataque de fuerza bruta. De esta forma, podemos utilizar un extremo (un servidor) para acceder a internet sabiendo que a partir de ese punto toda la información se intercambia con nuestro equipo mediante un túnel seguro.

Pero esta encriptación no siempre es suficiente para garantizar nuestra seguridad y nuestra privacidad, sino que hay una serie de funcionalidades que es necesario que nos proporcione nuestra VPN si queremos garantizar la privacidad y la seguridad:

  • Kill Switch. – Es un mecanismo por el que la comunicación se interrumpe si se pierde la comunicación con el otro extremo de la VPN. Si no se incluye este mecanismo, nuestro sistema operativo continuará enviando paquetes por la conexión normal en lugar de enviarlos a través de la VPN, lo que pondría en peligro nuestra seguridad y nuestra privacidad.
  • Fugas de DNS (DNS leaks). –  Algunas VPN no proporcionan mecanismos de seguridad ante fugas de DNS, de esta manera, permiten que las peticiones de DNS se realicen por fuera de la VPN, poniendo en riesgo la seguridad y la privacidad.
  • Fugas de WebRTC. – Al igual que en el caso de los DNS, algunas VPNs no proporcionan mecanismos de prevención de fugas de WebRTC y nos podemos encontrar que, a pesar de contar con una buena VPN en nuestro sistema, nuestras videoconferencias se emiten por un canal no protegido.
  • Registros. – Las leyes de muchos de nuestros países obligan a operadores y a proveedores de servicios de VPN a recabar registros que estén disponibles bajo requerimiento, si realmente queremos tener una privacidad a prueba de balas, necesitaremos utilizar un servicio de VPN que resida en una región que no le obligue a recabar registros de usuario.

5. ¿Cuándo usar una VPN?

Si estamos preocupados por nuestra seguridad y privacidad, la respuesta sencilla sería: siempre. Pero al margen de esto, vamos a señalar una serie de casos en los que deberíamos obligarnos a utilizarlas:

  • Teletrabajo. – Cualquier empresa seria debe proporcionar un entorno seguro que incluya conexiones mediante VPN si sus trabajadores van a trabajar de forma remota, no solo para sus equipos informáticos, sino para sus dispositivos móviles.
  • Conexiones inalámbricas. –  En general, las conexiones inalámbricas no son seguras, a menos que hagamos que sean seguras, las redes inalámbricas domésticas proporcionadas por el rúter de nuestro proveedor de internet son en general poco seguras, lo mismo ocurre con dispositivos Mi-Fi y similares, incluso si tenemos nuestra red asegurada, si tenemos dispositivos como televisiones inteligentes, asistentes y similares son puntos de ruptura de seguridad bastante asequibles.
  • Itinerancia. – Hoy en día somos muchos los que viajamos y trabajamos, nos conectamos en hoteles, en cafeterías, en bibliotecas… en cualquiera de estos entornos es altamente recomendable utilizar una buena VPN para asegurar nuestra seguridad.

6. Conclusiones

En los últimos años se ha incrementado la preocupación por la seguridad y la privacidad mientras nos conectamos a internet, pero de forma paralela se ha visto como se incrementaban las fuentes de inseguridad, hoy en día es impensable no tener dispositivos conectados y son pocas las personas que solamente usan internet desde la relativa seguridad de sus hogares, si nos preocupa nuestra seguridad y la de nuestra información, es recomendable empezar a tomar medidas, hay muchas medidas que se pueden tomar: usar solo páginas seguras, reducir consentimientos, eliminar cookies, navegar en modo seguro, utilizar navegadores como Tor, o buscadores como DuckDuckGo… pero además de todo eso, es recomendable que utilicemos un buen proveedor de VPN si queremos dar un mayor nivel de privacidad y seguridad a todas nuestras comunicaciones.

7. Referencias

La entrada Como mantener seguridad y privacidad mediante una VPN se publicó primero en Adictos al trabajo.

LGTM: ver vulnerabilidades en el código

$
0
0

Índice de contenidos

1. Entorno

Este tutorial está escrito usando el siguiente entorno:

  • Hardware: Slimbook Pro 2 13.3″ (Intel Core i7, 32GB RAM)
  • Sistema Operativo: Linux Mint 19

2. Introducción

En el ámbito de la seguridad de nuestras aplicaciones nunca podemos estar 100% seguros de no tener vulnerabilidades en nuestro código que faciliten poder hacer maldades en nuestras aplicaciones.

Es por ello que en el mercado están surgiendo herramientas que tratan de automatizar el chequeo de ciertas vulnerabilidades conocidas por todos como: SQL Injection, CSRF, XSS, etc… tienes una lista amplia en este enlace.

En este tutorial vamos a hablar de una que se está posicionando como una buena alternativa gratuita para proyectos Open Source para el chequeo de vulnerabilidades de este tipo, favoreciendo el “code review” en los proyectos: LGTM. Actualmente esta herramienta soporta los siguientes lenguajes: Java, TypeScript/JavaScript, Python, C/C++ y C#.

3. Vamos al lío

LGTM es una herramienta que sigue el modelo de negocio y el funcionamiento de otras como Travis; es decir nos permite conectar nuestros repositorios públicos de GitHub para la ejecución del análisis de nuestro código. Mientras el repositorio sea público no tendremos que pagar ningún tipo de licencia.

Para acceder a la herramienta tenemos que ir a su sitio web y registrarnos. La forma más cómoda es hacerlo con alguna de las opciones sociales que te ofrece como la cuenta que tengamos en GitHub.

Una vez seleccionada esta opción, nos pedirá el usuario y contraseña de GitHub.

Y nuestra autorización para poder conectar:

Hecho esto nos solicitará un username y pulsando en “Let’s go!” estaremos preparados para comenzar a utilizar la herramienta:

Esto nos va a llevar a nuestro dashboard donde se muestra la lista de proyectos que tenemos analizados. Para añadir un nuevo proyecto solo tenemos que poner la URL de un repositorio público ya sea nuestro o no y pulsar en “Add”.

Al momento el proyecto quedará registrado y el sistema comenzará con su análisis, el cual puede tardar varios minutos.

Al finalizar el análisis, el sistema nos envía un correo advirtiendo de este hecho y podemos ver en el dashboard las alertas que haya detectado, pulsando en el detalle de cada proyecto analizado.

Dentro de la pestaña “Alerts” podemos ver el número de alertas y el detalle de cada una de ellas. En este caso la herramienta ha detectado algunos imports que no están utilizando y los marca como recomendaciones:

En otros casos puede encontrar errores de seguridad como la posibilidad de SQL Injections:

En la pestaña “Files” encontramos un diagrama de densidad que nos permite navegar por la estructura del proyecto y llegar a ver el código fuente y el número de líneas de cada fichero.

En la pestaña “Compare” nos da una comparación entre el proyecto analizados con respecto al estado de otros proyectos similares.

En la pestaña “Dependencies” nos da un listado de todas las dependencias de nuestro proyecto junto con las alertas de esas dependencias.

Y, por último, en la pestaña “Integrations” nos permite activar el Code Review automático en las pull request del repositorio (si somos nosotros los administradores) y nos ofrece fragmentos de código para mostrar como badget del resultado del análisis en el README del proyecto.

En caso de querer activar las pull request pulsamos en “Activate automated code review”, lo que va a hacer que nos solicite permiso para acceder a más información de nuestro proyecto en GitHub.

De esta forma cada vez que haya una pull request automáticamente LGTM analizará los cambios y nos informará de si presenta algún tipo de alerta.

4. Conclusiones

Todavía queda un largo camino para que este tipo de herramientas detecten todos los tipos de vulnerabilidades existentes de forma automática; pero si que es cierto, que LGTM de una manera sencilla y gratuita para proyectos en repositorios públicos, puede ser una buena opción a la hora de solventar este tipo de alertas.

Cualquier duda o sugerencia en la zona de comentarios.

Saludos

La entrada LGTM: ver vulnerabilidades en el código se publicó primero en Adictos al trabajo.

Viewing all 996 articles
Browse latest View live