Operaciones asincrónicas sin dolores de cabeza
Como desarrolladores de código somos bastante capaces de lidiar con eventos que ocurren en serie. Sin embargo se nos dificulta tratar con eventos paralelos, retrazados o diferidos. Afortunadamente existen técnicas que pueden ayudar a lidiar con resultados retrazados o diferidos. Las principales entre estas técnicas son las promesas y futuros, que son el enfoque de este artículo junto con una biblioteca, JDeferred, que simplifica enormemente el uso de los mismos.
Wikipedia define el concepto de promesas y futuros como un objeto que actúa como un proxy para un resultado que es desconocido inicialmente. Un futuro es una vista de sólo lectura que apunta a una variable particular; es decir, su función es contener un valor y nada más.
Una promesa es un contenedor de asignación única y escribible que establece el valor del futuro. Las promesas pueden definir un API que se puede utilizar para reaccionar a los cambios de estado los enfuturos, como el valor resuelto, el valor rechazado debido a un error (esperado o inesperado) o la cancelación de la tarea que calcula el valor esperado. Veamos esto con más detalle.
Promesas en Java
La librería estándar de Java incluye varias implementaciones del concepto futuro basado en java.util.concurrent.future<V>, con una adición reciente realizada en Java 8 llamada CompletableFuture. Esta clase ofrece las siguientes habilidades:
- Obtener un valor que puede ser calculado de forma asíncrona.
- Registrar funciones de mutación que afecten al resultado calculado, cuando esté se encuentre disponible.
- Establecer una cadena de funciones que acepten el resultado, combinándolo con otros resultados.
- Inicializar una tarea de fondo que calcule el resultado esperado.
Puede comenzar rápidamente a utilizar CompletableFuture (me refieré a este tipo como una promesa de ahora en adelante) haciendo uso de un par de métodos de fábrica que se encuentran en dicho tipo. Puedes crear una promesa que no devuelva ningún valor invocando el siguiente código:
CompletableFuture.runAsync(new Runnable() { ... });
Esta versión te permite definir una tarea que realiza algún cálculo, pero el resultado no es importante. Lo importante en sí es si la tarea completó con éxito o no. Puedes adjuntar una reacción a este evento, como la siguiente:
CompletableFuture promise = CompletableFuture.runAsync(new Runnable() { ... }); promise.thenApply(result -> { System.out.println("Task is finished!"); });
Si estás interesado en el resultado calculado, debes invocar un método de fábrica diferente, uno que acepte a un proveedor como argumento, como éste:
CompletableFuture promise = CompletableFuture.runAsync(() -> "hello"); promise.thenApply(result -> { System.out.println("Task result was " + result); });
Una vez que obtienes una referencia a una promesa, puedes decorarla con otras operaciones que pueden reaccionar al resultado calculado, a una excepción que se lanze durante el cálculo, o transformaciones adicionales al valor calculado.
Supongamos el siguiente caso de uso: mostrar una lista de repositorios usando el nombre de una organización que se encuentra en GitHub. Esto requiere invocar una operación de un API de tipo REST, procesar los resultados y visualizarlos. Supongamos además que el código debe ser ensamblado como una aplicación JavaFX. Este último requisito obliga a pensar en utilizar el concepto de promesas, porque el cálculo de la lista de repositorios debe ejecutarse en un hilo que no sea el hilo de aplicación de JavaFX, pero el resultado debe publicarse dentro del hilo de aplicacioón de JavafaFX, que es la regla general cuando se crean aplicaciones JavaFX interactivas. Dicho de otro modo, cualquier operación que no esté relacionada con la interfaz de usuario (como una llamada de red, en nuestro caso) debe ocurrir en un hilo que no sea el hilo de apliación JavaFX; por el contrario, cualquier operación relacionada con la interfaz de usuario (como actualizar las propiedades de un widget) debe ocurrir dentro del hilo de aplicación de JavaFX. No entraré en los detalles de cómo se produce la llamada de red real; sin embargo, el código de trabajo completo se puede encontrar en GitHub. El siguiente fragmento muestra cómo ejecutar el cálculo en en hilo de fondo utilizando una promesa; notarás que inyecto algunos de los recursos relacionados:
public class GithubImpl implements Github { @Inject private GithubAPI api; @Inject private ExecutorService executorService; @Override public CompletableFuture<List> repositories(final String organization) { Supplier<List> supplier = () -> { Response<List> r = null; try { r = api.repositories(organization).execute(); } catch (IOException e) { throw new IllegalStateException(e); } if (r.isSuccessful()) { return r.body(); } throw new IllegalStateException(r.message()); }; return CompletableFuture.supplyAsync(supplier, executorService); } }
El código muestra la llamada de red que se ejecuta al invocar el método execute(). Si se produciere un problema de comunicación o un error de análisis, se lanzará una excepción de tipo IOException. Si la llamada tiene éxito se devuelve el contenido de la respuesta, automáticamente convertiod a objectos Java; si no tuvo éxito se lanza una excepciónl. Finalmente, la promesa se crea especificando un instancia de tipo Executor. Podrías notar en los fragmentos anterior dicha instancia fué omitida; esto se debe a que el Executor por omisión es el perteneciente al ForkJoin Common Pool.
Ahora, consumamos el resultado calculato por el futuro y la promesa. Se asume que existe otro componente (un controlador) cuya responsabilidad es invocar el servicio que se acaba de definir y popular una lista con los resultados obtenidos. Este controlador también tiene la responsabilidad de mostrar un error si se produciere una excepción durante la invocación del servicio.
public class AppController { @Inject private AppModel model; @Inject private Github github; @Inject private ApplicationEventBus eventBus; public void loadRepositories() { model.setState(RUNNING); github.repositories(model.getOrganization()) .thenAccept(model.getRepositories()::addAll) .exceptionally(t -> { eventBus.publishAsync(new ThrowableEvent(t)); return null; }) .thenAccept(result -> model.setState(READY)); } }
En breve explicaré cómo se ejecuta la última línea. Desconstruyamos el fragmento de código línea por línea. En primer lugar, el controlador establece un estado específico, utilizado por la interfaz de usuario para deshabilitar otras acciones hasta que el cálculo sea finalizado. A continuación, invoca el servicio y obtiene una promesa, repositories, cuya definición fue descrita en fragmentos de código anteriores. La promesa permite al controlador establecer otras acciones, como el procesamiento del resultado, en este caso, añadir la lista de repositorios a un modelo que probablemente se utilice por la interfaz de usuario para visualización. También es responsable de gestionar cualquier posible excepción que pudiere haber ocurrido durante la ejecución del servicio, utilizando una expresión lambda como argumento para exceptionally(). Finalmente, vuelve a establecer el estado, independientemente del éxito o fracaso, con la expresi' on lambda definida como argumento en thenAccept().
Posibles Problemas
Presta mucha atención al orden de los pasos utilizados para procesar el resultado de la promesa. Si los pasos se invocarén con una secuencia diferente, terminaremos con un comportamiento completamente diferente, y quizás inesperado. Marquemos los pasos como EXITO, FALLO, SIEMPRE. La configuración actual con funcionamiento correcto es: EXITO, FALLO, SIEMPRE.
Si utilizaremos una secuencia diferente, producirá diferentes resultados:
- SIEMPRE, EXITO, FALLO no compilará, dado que SIEMPRE cambia el tipo de resultado a Void como un tipo de retorno de la expresi'ôn lambda puesto que la misma no devuelve un valor.
- EXITO, SIEMPRE, FALLO causa que la interfaz de usuario permanezca deshabilitada si se produce un error, porque el modelo de estado que la interfaz de usuario está esperando nunca se actualiza.
- FALLO, EXITO, SIEMPRE hace que la interfaz de usuario permanezca deshabilitada si se produce un error de nuevo, porque el estado no se actualiza.
Por lo tanto, hay que permanecer vigilantes con respecto al orden de las acciones ligadas a este tipo de promesa. Existe otro problema inherente en CompletableFuture: el hecho de que es a la vez un futuro y una promesa. Las promesas te permiten reaccionar de forma asincrónica y sin bloqueos. Sin embargo, elfFuturo tiene un método específico que bloquea: get(). Esto significa que es posible convertir un escenario de no bloqueo en uno de bloqueo en cualquier momento, incluso inadvertidamente, porque es muy común llamar get() a tipos que exponen tal método (por ejemplo, Opctional).
Podrías preguntarte: ¿Cuál es el problema? Mientras no llame al método get, todo debería funcionar de manera adecuado, no es así? Pero dado que este tipo de promesa es un futuro, no hay garantía que el método get no será invocado más adelante por otra API que pueda manejar futuros. Sería mucho mejor si esta promesa no fuera un futuro en primer lugar. La siguiente pregunta podría ser: ¿Y si envuelvo CompletableFuture con una API de sólo promesa?. Sí, eso funcionaría, pero ¿qué tal cambiar a una biblioteca que otorgue este comportamiento? Me refiero a JDeferred.
Introduciendo JDeferred
JDeferred es una biblioteca que permite el uso de promesas. Se inspira en JQuery y Android Deferred Object. Está diseñada para ser compatible con JDK 1.6 y posteriores versiones de Java. Su API es muy simple, pero no te dejes engañar por esta simplicidad: puedes crear un código estable, de buen comportamiento y legible. Volvamos al ejemplo anterior usando JDeferred en esta ocasión. El código completo está disponible en GitHub, si quieres estudiarlo en detalle.
JDeferred puede ser añadido a tu proyecto con las siguientes coordenadas de Maven:
<dependency> <groupId>org.jdeferred</groupId> <artifactId>jdeferred-core</artifactId> <version>1.2.5</version> </dependency>
O si prefieres usar Gradle entonces
compile órg.jdeferred:jdeferred-core:1.2.5'
JDeferred ofrece un tipo básico, org.jdeferred.Promise, el cual puede ser utilizado para registrar acciones o funciones gestoras. El tipo Promise puede devolver un valor una vez completada, lanzar un Objeto (cualquier Objeto, no sólo Throwable) si se produciere un error, y devolver resultados intermedios durante el cálculo. Las dos últimas opciones no son posibles con CompletableFuture. JDeferred te permite agrupar las funciones gestoras por responsabilidad, eliminando así el problema de orden de registro como fue discutido anteriormente con CompletableFuture. Las promesas suelen ser creadas por otro componente llamado el DeferredManager. De esta manera, la biblioteca desvincula el mecanismo de creación de promesas respecto a la instancia de promesa particular, porque estos son dos conceptos distintos. Veamos cómo se ve el código del servicio anterior haciendo uso de JDeferred.
public class GithubImpl implements Github { @Inject private GithubAPI api; @Inject private DeferredManager deferredManager; @Override public Promise<List, Throwable, Void> repositories(final String organization) { return deferredManager.when(() -> { Response<List> r = api.repositories(organization).execute(); if (r.isSuccessful()) { return r.body(); } throw new IllegalStateException(r.message()); }); } }
Este código es funcionalmente equivalente al código examinado anteriormente, pero es considerablemente más limpio. Las tareas ejecutadas de esta manera se benefician del manejo automático de errores realizado. por DeferredManager. Esta es la razón por la que no se requiere manejar explícitamente los errores de comunicación y análisis como lo hicimos antes. Cuando alguno de estos errores ocurran enviarán una señal a la promesa para que ésta cambien de esta y se registren de tal manera que las funciones gestoras de error se invoquen.
Este ejemplo no produce ningún resultado intermedio, por lo que el tercer argumento de Promise se define como Void. Ahora bien, consumir la promesa puede hacerse de la siguiente manera:
public class AppController { @Inject private AppModel model; @Inject private Github github; @Inject private ApplicationEventBus eventBus; public void loadRepositories() { model.setState(RUNNING); github.repositories(model.getOrganization()) .done(model.getRepositories()::addAll) .fail(t -> eventBus.publishAsync(new ThrowableEvent(t))) .always((state, resolved, rejected) -> model.setState(READY)); } } }
El controlador realiza las mismas funciones que antes, pero el código es considerablemente más limpio. Puedes definir las funciones gestoras de EXITO, FALLO, SIEMPRE en el orden que consideres adecuado para este caso en particular. Finalmente, no hay manera de forzar la promesa de esperar en una forma bloqueante para que el resultado sea entregado; el API simplemente no lo permite.
Si lo deseas, también puedes cambiar a una implementación manual para producir la promesa, utilizando DeferredObject. Este tipo te permite establecer el valor calculado o rechazado, así como publicar resultados intermedios si es necesario. Si alguna vez haz utilizado el API de SwingWorker entonces ya sabes cómo se lleva a cabo este comportamiento: la diferencia clave es que las notificaciones emitidas por DeferredObject se envían en el hilo de fondo mientras que SwingWorker los envía dentro del hilo de UI. La siguiente manera es como el ejemplo anterior puede ser resescrito usando DeferredObject:
public class GithubImpl implements Github { @Inject private GithubAPI api; @Inject private ExecutorService executorService; @Override public Promise<List, Throwable, Void> repositories(final String organization) { Deferred<List, Throwable, Void> d = new DeferredObject<>(); executorService.submit(() -> { Response<List> r = null; try { r = api.repositories(organization).execute(); } catch (IOException e) { d.reject(e); return; } if (r.isSuccessful()) { d.resolve(r.body()); } d.reject(new IllegalStateException(r.message())); }); return d.promise(); } }
Esta vez, es necesario tratar todos los errores de comunicación y análisis de manera manual, así como programar explícitamente la tarea de fondo utilizando un Executor o medios similares. Este uso particular de DeferredObject también resulta útil cuando se escriben pruebas, asi podrás resolver o rechazar una promesa en cualquier momento. El siguiente caso de prueba muestra exactamente este escenario puede ser implementado usando una combinación de JDeferred, Mockito e inyección de dependencias:
@RunWith(JukitoRunner.class) public class AppControllerTest { @Inject private AppController controller; @Inject private AppModel model; @Test public void happyPath(Github github) { // given: Collection rs = TestHelper.createSampleRepositories(); Promise<List, Throwable, Void> p = new DeferredObject<List, Throwable, Void>().resolve(rs); when(github.repositories("foo")).thenReturn(p); // when: model.setOrganization("foo"); controller.loadRepositories(); // then: assertThat(model.getRepositories(), hasSize(3)); assertThat(model.getRepositories(), equalTo(rs)); verify(github, only()).repositories("foo"); } }
Aquí podemos ver cómo se usa DeferredObject para configurar un resultado esperado junto con una instancia de la clase Github que es ta registrada como un Mock de Mockito. Esta prueba en particular comprueba el camino felíz en el que todo funciona según lo esperado. Puede configurar una ruta de falla invocando a reject() en lugar de resolve(), comprobando que se ha producido la excepción esperada.
Conclusión
Las promesas te permiten manejar resultados computados de manera diferida o asíncrona. Java 8 proporciona un tipo llamado CompletableFuture que se puede utilizar como promesa. Este tipo permite manejar los resultados; transformar los resultados en otros valores; combinar un resultado con otros resultados; y manejar casos excepcionales cuando se producen errores. Sin embargo, debemos prestar atención al orden en el que las acciones se registran en la promesa. Además, es posible bloquear la promesa en cualquier momento simplemente invocando el método get(). JDeferred implementa una API más sencilla que ofrece las mismas capacidades sin los inconvenientes antes mencionados. JDeferred te permite publicar resultados intermedios en cualquier momento durante el cálculo de en la tarea de fondo. Ejemplos de este último comportamiento se pueden ver en GitHub.
La versión original de este artículo fue publicada en Oracle Java Magazine, Mayo 2017.