Grails + JMS + Email
La situación es la siguiente: un usuario origina la ejecución de una acción en el servidor, la cual debe realizar una o varias tareas que pueden requerir una gran cantidad de tiempo y cuyo estado final no influye en la respuesta que se envíe al usuario. Nuestro caso concreto: envío de emails como consecuencia de una acción solicitada por un usuario.
En Thuest esta situación se repite bastantes veces. Por ejemplo, cuando un usuario se hace fan de otro se le envía un correo a este último informando. Un primer desarrollo de la acción convertirseEnFan podría ser:
1º Recuperar de la BBDD el nuevo ídolo 2º Crear y guardar una nueva instancia Fan, que relacione al usuario de sesión y al ídolo recuperado 3º Mandar un email al ídolo 4º Renderizar la respuesta al usuario
El paso que nos interesa es el tercero. Si ese envío se realiza de forma síncrona la acción completa sufrirá una penalización bastante grande respecto al tiempo necesario. La comunicación con el servidor SMTP puede retrasarse y si a esto le sumamos que podemos tener acciones que conlleven el envío de muchos emails, realizar esta operación de forma asíncrona resulta muy interesante.
Podríamos tirar por el camio de en medio y crearnos threads para que hicieran el trabajo, incluso podríamos usar un pool de threads si queremos ser más elegantes. Pero en Java tenemos JMS, que a mi es lo primero que se me viene a la cabeza en estos casos. Además, una vez configurado JMS verás como se te ocurren muchas aplicaciones (el tema los topics da mucho juego y puedes montar un sistema de notificaciones bastante chulo).
Plugin JMS para Grails
Vamos a centrarnos. Puesto que estamos trabajando con Grails, nuestro objetivo es hacer funcionar JMS en este framework y poder enviar emails de forma asíncrona. Para lo primero comenzamos por instalar el plugin correspondiente:
$> grails install-plugin jms
En la página del plugin hay información más detallada de lo que yo pueda explicar aquí, échale un ojo. Particularmente yo tuve que modificar el código del plugin (versión 0.5-RC2), incluyendo returns que el programador entendió opcionales pero que no lo eran tanto. Los ficheros afectados fueron JmsService.groovy, ListenerConfig.groovy y ServiceInspector.groovy.
Además, en el fichero ListenerConfig.groovy se definen los listener que realmente estarán esperando los nuevos mensajes. El método registerListenerContainer tiene el builder que construye estos objetos. En ese builder es posible que te interese deshabilitar el uso de un gestor de transacciones; para ello comenta la linea donde se asigna valor al transactionManager.
El último paso es proporcionar al contexto de ejecución de la aplicación un bean capaz de crear conexiones con nuestro broker de JMS. Este bean será usado por el plugin para establecer las conexiones. Podemos usar el fichero grails-app/conf/spring/resources.groovy para realizar la definición de este bean. Un ejemplo vale más que mil palabras, así es que aquí está el contenido de mi resources.groovy
import grails.util.GrailsUtil
if(GrailsUtil.environment == "production") {
beans = {
jmsConnectionFactory(org.springframework.jndi.JndiObjectFactoryBean) {
jndiName = 'jms/connectionFactory/thuest'
}
}
} else if(GrailsUtil.environment == "development") {
beans = {
jmsConnectionFactory(com.sun.messaging.ConnectionFactory) {
// Default values
}
}
}
Por defecto, el plugin de JMS espera un bean con nombre jmsConnectionFactory, y así vamos a dejarlo. En función del entorno de ejecución en el que estemos definiremos el bean jmsConnectionFactory de una forma o de otra. Si estamos en producción le indicamos que recupere el bean mediante jndi y la clase porporcionada por Spring JndiObjectFactoryBean (esto acarrea configuración en el servidor, más adelante la veremos). Si estamos en desarrollo indicamos que la clase del bean es com.sun.messaging.ConnectionFactory (para el caso particular de OpenMQ, para ActiveMQ ver la página del plugin JMS) y aceptamos los valores por defecto. Esto valores por defecto implican tener corriendo una instancia del imqbrokerd en el puerto 7676. imqbrokerd viene con Glassfish (mira en el directorio imq/bin de la instalación de Glassfish).
El plugin está listo, siguiente paso.
Receptores de mensajes
Vamos a aislar la funcionalidad que queremos ejecutar asícronamente en un servicio, en mi caso MessageService. En ese servicio hay que incluir el siguiente atributo:
class MessageService {
...
static exposes = ['jms']
...
}
Este atributo es el que busca el plugin internamente para saber si un servicio tiene métodos asíncronos.
Para definir los métodos asíncronos en estre caso creo que la forma más precisa es usar las anotaciones @Queue o @Topic, en función de lo que queramos. Yo por ejemplo tengo lo siguiente:
import grails.jms.Queue
class MessageService {
...
static exposes = ['jms']
@Queue
def sendEmailMessage(EmailMessagePack msg) {...}
}
Serializando que es gerundio
El plugin de JMS inyecta en todos los servicios y controladores el método sendJMSMessage (entre otros), que en su forma más básica acepta un destinatario y un mensaje. El destinatario se expresa como un mapa, dos ejemplos:
sendJMSMessage([queue:'thuest.message.sendEmailMessage'], mensaje) sendJMSMessage([service:'message', method:'sendEmailMessage'], mensaje)
Todo objeto que se envía como mensaje debe ser serializable y sus atributo también para que la recursividad que se aplica por defecto no dé errores. Si tienes que enviar varios contenidos créate una clase para la ocasión (recuerda que debe implementar Serializable) y define los distintos contenidos como atributos de la nueva clase. La alternativa podría ser crearte un converter de mensajes, pero yo he optado por la primera opción, lo veía más sencillo.
Si se envía como mensaje un mapa los valores sólo pueden ser tipos básicos de Java y String.
Configuración en glassfish
Lo primero es definir el objeto ConnectionFactory accesible por jndi. Para ello, empleando la interfaz web de administración de Glassfish, vamos a Resources->JMS Resources->Connection Factories->New. El nombre jndi será el mismo que especificamos en el resources.groovy (en nuestro caso jms/connectionFactory/thuest) y como tipo de recurso yo elegí javax.jms.QueueConnectionFactory al trabajar con queues, si trabajas con topics elige la otra opción.
Llega el turno de definir los destinos de los mensajes, en nuestro caso las queues. Por defecto para el plugin de JMS los nombres jndi de las colas asociadas a mensajes tienen el formato nombreAplicacion.nombreServicio.nombreMetodo, por ejemplo thuest.message.sendEmailMessage. Por lo tanto la configuración en Glassfish supondrá crear tantas colas como métodos asíncronos hayamos definido con el plugin de JMS, usando los nombres correspondientes. Esto lo podemos hacer desde la interfaz web de administración de Glassfish: Resources->JMS Resources->Destination Resources->New.
Los physical destinations solicitados para crear las queues se crean en distintos lugares en función de si estamos trabajando con clusters o no. Si estamos trabajando con clusters, en la interfaz web de administración de Glassfish, ve a Clusters->Mi Cluster->Physical Destinations->New. Si no trabajas con clusters ve a Stand-Alone instances->Mi server->Physical Destinations->New.
Envío de emails
El primer paso es instalar el plugin de mail de Grails:
$> grails install-plugin mail
Me saltaré el paso de la configuación y uso porque en la página del plugin viene muy bien explicado. En relación al tema que estamos tratando, aconsejaría que el envío de emails se aisle en un método único, así se podría aislar y hacer asíncrono más fácilmente.
Si vas a usar templates gsp como cuerpos de los correos (algo bastante potente y recomendable) ten en cuenta que el envío se realizará fuera de un contexto request. ¿Y qué?. Pues que internamente el objeto implícito request (que no existirá en este caso) puede ser usado en distintos sitios, por ejemplo por el tag g:applyLayout, así es que si pretendes hacer layouts en tus emails vete olvidando.
Y fin
Creo haber hablado de lo más importante, si tienes alguna duda o piensas que hay algo erróneo en lo que he escrito, usa los comentarios, que aquí estamos para aprender.