Aller au contenu

Guide pratique sur l’utilisation d’IBM MQ avec Java Messaging Service

3 août 2023

Soham Patak

Ce billet de blogue aide les développeurs à se familiariser avec les files d’attente de messages IBM MQ. Après en avoir fini la lecture, vous devriez :

  1. comprendre le fonctionnement de base des files d’attente de messages;
  2. savoir configurer votre application pour IBM MQ;
  3. créer un conteneur sur Docker pour héberger une file d’attente de messages;
  4. et changer la file d’attente utilisée par votre application émettrice sans redéployer celle-ci.

Une connaissance basique de Java et de Spring est recommandée.

Pourquoi MQ?

Avant de rentrer dans les détails d’IBM MQ et de la configuration requise, discutons des problèmes que résolvent les file d’attente de messages, et des types d’application qui bénéficient le plus de cette architecture.

L’architecture de microservice est de plus en plus utilisée, car elle permet à plusieurs services de communiquer de manière rapide et asynchrone. Grâce aux files d’attente de messages, deux applications peuvent échanger de l’information de manière asynchrone : les messages envoyés par l’application émettrice sont stockés dans une file d’attente puis récupérés par l’application destinataire au besoin. Ainsi, les deux applications peuvent communiquer de manière découplée.

D’autres méthodes, comme REST API, reposent sur un processus qui bloque les messages, ce qui complique la communication asynchrone.

L’avantage des files, en regard de la communication asynchrone, c’est qu’elles sont toujours prêtes à traiter les messages en attente. Les autres méthodes, notamment REST API et GraphQL, ne permettent malheureusement pas de résoudre le problème de blocage des messages, faisant de MQ la meilleure solution. En plus, avec MQ, une application qui tombe en panne peut reprendre les requêtes sans interruption une fois redéployée. Autrement dit, les applications peuvent fonctionner de manière indépendante.

Qu’est-ce que MQ?

Une file d’attente de messages (MQ, pour message queue) est une des composantes des intergiciels de messagerie facilitant la communication asynchrone entre différentes applications. Les files d’attente stockent temporairement les messages à la manière d’une plateforme intermédiaire, à laquelle les autres logiciels ont accès. Dans ce tutoriel, nous utiliserons la plateforme IBM MQ.

Voici quelques avantages et caractéristiques d’IBM MQ :

  • Facile d’accès
  • Déploiement flexible
  • Compatible avec une multitude de langues et de cadres d’application
  • Grande banque d’aide et de documentation

Configuration

La configuration de MQ se fait en deux grandes étapes :

  1. Créer un conteneur Docker pour IBM MQ
  2. Arrimer Java Messaging Service (JMS) à MQ

Partie 1 : Créer un conteneur Docker pour IBM MQ

D’abord, il faut créer un fichier Docker Compose et un script en langage naturel (shell). Le fichier Docker Compose sera utilisé par le script pour créer le conteneur Docker permettant aux applications d’utiliser les files d’attente de messages en local. Le code Docker Compose suivant permet de définir l’image Docker.

version: '2.1'

services:
  ibmmq:
    image: 'docker.io/ibmcom/mq'
    environment:
      - LICENSE=accept
      - MQ_QMGR_NAME=QM1
    ports:
      - '1414:1414'
      - '9443:9443'
    volumes:
      - ibmmq:/data/ibmmq
    container_name: ibmmq

volumes:
  ibmmq:
    driver: local

Le script suivant prépare le conteneur et crée les files d’attente qui seront utilisées par notre application. Il ne crée que deux files d’attente, mais vous pouvez répéter la commande en fonction du nombre de files auxquelles votre application a besoin d’accéder pour envoyer ou recevoir des données.

#!/bin/bash

echo "Let's clean up the environment by taking the container down..."
docker-compose --log-level WARNING down --remove-orphans
# spins up the docker container for the mq
echo "Let's spin up the container..."
docker-compose -f docker-compose.yml up --detach
# The queues that are created here are also available within application.yaml.
# If you add one here, make sure it aligns with application.yaml
echo "INFO Creating the MQ queues..."
cat << EOF | docker exec --interactive ibmmq sh
runmqsc QM1
define qlocal (MQCLIENT_MQ1.RESPONSE.FROM.MQSERVER_MQ1)
define qlocal (MQCLIENT_MQ1.RESPONSE.FROM.MQSERVER_MQ2)
end
EOF

C’était la première partie.

Partie 2 : Arrimer Java Messaging Service (JMS) à MQ

La première étape de la deuxième partie consiste a créer une application Spring. Pour ce faire, nous allons utiliser un Spring Initializr. Dans ce tutoriel, nous travaillerons avec Java Messaging Service (JMS). JMS permet aux applications Java d’utiliser les systèmes de messages, comme IBM MQ, pour communiquer. Nous devrons également installer les dépendances nécessaires au fonctionnement de JMS.

<dependency>
			<groupId>com.ibm.mq</groupId>
			<artifactId>mq-jms-spring-boot-starter</artifactId>
			<version>2.7.4</version>
		</dependency>

		<dependency>
			<groupId>javax.jms</groupId>
			<artifactId>javax.jms-api</artifactId>
			<version>2.0.1</version>
		</dependency>

		<dependency>
			<groupId>commons-beanutils</groupId>
			<artifactId>commons-beanutils</artifactId>
			<version>1.9.4</version>
		</dependency>

Ensuite, nous créerons une classe nommée JmsConfig.java, dans laquelle nous injecterons les propriétés nécessaires à la connexion entre l’application Java et les files d’attente à l’aide de l’annotation @Value de Spring. Ces propriétés peuvent être ajoutées au fichier application.yaml, que vous trouverez dans le dossier ressources (ou resources) de votre application.

server:
  url: "http://localhost"
  port: 80

ibm:
  mq:
    queueManager: QM1
    channel: DEV.ADMIN.SVRCONN
    connName: localhost(1414)
    host: localhost
    port: 1414
    user: admin
  queues:
    sampleQueues: MQCLIENT_MQ1.RESPONSE.FROM.MQSERVER_MQ2,MQCLIENT_MQ1.RESPONSE.FROM.MQSERVER_MQ1

demo:
  concurrency:
    size:
      low: 1
      high: 6

 

@Configuration
public class JmsConfig {
	@Value( "${ibm.mq.host}" )
	private String host;

	@Value( "${ibm.mq.port}" )
	private Integer port;

	@Value( "${ibm.mq.queueManager}" )
	private String queueManager;

	@Value( "${ibm.mq.channel}" )
	private String channel;

	@Value( "${ibm.mq.user}" )
	private String user;

	@Value( "${ibm.mq.password}" )
	private String password;

	@Value( "${ibm.mq.connName}" )
	private String connName;

	@Value( "${ibm.queues.sampleQueues}" )
	private List<String> sampleQueues;

	@Value( "${demo.concurrency.size.low}" )
	private String concurrencyMin;

	@Value( "${demo.concurrency.size.high}" )
	private String concurrencyMax;

	@Bean
	public JmsTemplate jmsTemplate() throws JMSException{
		JmsTemplate jmsTemplate = new JmsTemplate();
		jmsTemplate.setConnectionFactory( cachingConnectionFactory() );
		return jmsTemplate;
	}

	@Bean
	public CachingConnectionFactory cachingConnectionFactory() throws JMSException{
		CachingConnectionFactory factory = new CachingConnectionFactory();
		factory.setSessionCacheSize( 1 );
		factory.setTargetConnectionFactory( createConnectionFactory() );
		factory.setReconnectOnException( true );
		factory.afterPropertiesSet();
		return factory;
	}

	@Bean
	public JmsConnectionFactory createConnectionFactory() throws JMSException{
		JmsFactoryFactory ff = JmsFactoryFactory.getInstance( JmsConstants.WMQ_PROVIDER );
		JmsConnectionFactory factory = ff.createConnectionFactory();
		factory.setObjectProperty( WMQConstants.WMQ_CONNECTION_MODE, Integer.valueOf( WMQConstants.WMQ_CM_CLIENT ) );
		factory.setStringProperty( WMQConstants.WMQ_HOST_NAME, host );
		factory.setObjectProperty( WMQConstants.WMQ_PORT, port );
		factory.setStringProperty( WMQConstants.WMQ_QUEUE_MANAGER, queueManager );
		factory.setStringProperty( WMQConstants.WMQ_CHANNEL, channel );
		factory.setStringProperty( WMQConstants.USERID, user );
		factory.setStringProperty( WMQConstants.PASSWORD, password );
		return factory;
	}

	@Bean
	@Primary
	public JmsListenerEndpointRegistry createRegistry(){
		JmsListenerEndpointRegistry registry = new JmsListenerEndpointRegistry();
		return registry;
	}

	@Bean
	public JmsListenerEndpointRegistrar createRegistrar() throws JMSException{
		JmsListenerEndpointRegistrar registrar = new JmsListenerEndpointRegistrar();
		registrar.setEndpointRegistry( createRegistry() );
		registrar.setContainerFactory( createDefaultJmsListenerContainerFactory() );
		return registrar;
	}

	public DefaultJmsListenerContainerFactory createDefaultJmsListenerContainerFactory() throws JMSException{
		DefaultJmsListenerContainerFactory factory = new DefaultJmsListenerContainerFactory();
		factory.setConnectionFactory( createConnectionFactory() );
		return factory;
	}
}

Remarques sur le script ci-dessus :

  • Les propriétés que nous injectons ici sont utilisées par le bean JmsConnectionFactory pour établir la connexion aux files d’attente de messages.

Ensuite, nous voulons nous assurer que notre application est également capable de recevoir des messages. Il faut donc ajouter une autre classe nommée QueueConfig.java, qui lui permettra de le faire. Nous utiliserons le point de terminaison SimpleJmsListenerEndpoint. Nous configurons chaque file d’attente créée en tant que destination vers le point de terminaison SimpleJmsListenerEndpoint, auquel pourra accéder JMS pour lire les messages.

@Configuration
public class MqConfig{

	@Autowired
	JmsListenerEndpointRegistrar registrar;

	@Autowired
	private MessageHandler queueController;

	@Value( "${ibm.queues.sampleQueues}" )
	String[] sampleQueues;

	@Value( "${demo.concurrency.size.low}" )
	Integer messageConcurrencyLow;

	@Value( "${demo.concurrency.size.high}" )
	Integer messageConcurrencyHigh;

	String jmsMessageConcurrency = "";

	@PostConstruct
	public void init(){
		jmsMessageConcurrency = String.format( "%s-%s", messageConcurrencyLow, messageConcurrencyHigh );
		configureJmsListeners( registrar );
	}

	public void configureJmsListeners( JmsListenerEndpointRegistrar registrar ){
		int i = 0;
		for( final String queueName : sampleQueues ){
			SimpleJmsListenerEndpoint endpoint = new SimpleJmsListenerEndpoint();
			endpoint.setId( "demo-" + i++ );
			endpoint.setDestination( queueName );
			endpoint.setConcurrency( jmsMessageConcurrency );

			endpoint.setMessageListener( message -> {
				queueController.recv( queueName, message );
			} );
			registrar.registerEndpoint( endpoint );
		}
	}
}

Maintenant, nous devons ajouter une classe nommée MessageHandler.java, que nous utiliserons pour traiter les messages entrants et sortants.

@Component
public class MessageHandler{

	@Autowired
	private JmsTemplate jmsTemplate;

	protected final static Logger L = LoggerFactory.getLogger( MessageHandler.class );

	public void recv( String destination, Message message ){
		try{
			MQMessage mqMessage = new MQMessage();
			mqMessage.writeString( ( (TextMessage) message ).getText() );
			mqMessage.setStringProperty( "IncomingDestination", destination );
			processMessage( mqMessage );
		}
		catch( Exception e ){
			L.error("Error while reading message", e);
		}
	}

	public void processMessage( MQMessage mqMessage ){
		// TODO add application specific message processing code here
	}

	public void send(String destinationQueue,String messageBody){
		// TODO Create a messageCreator using the  messageBody here and use JmsTemplate to send the message
		// The message body depends on what the application needs
		//jmsTemplate.send(destinationQueue, messageCreator);
	}
}

Cette étape conclut la configuration de base nécessaire à l’application pour lire les messages de la file d’attente et lui en envoyer. Il ne s’agit que d’une configuration de base. La prochaine étape consisterait à créer une classe qui traite et stocke les données du message selon l’objectif de votre application.

Changement de contexte

Lorsque l’application est déployée dans différents environnements, vous voudrez parfois utiliser plusieurs ensembles de files d’attente en fonction de ce que vous cherchez à faire. Pour des tests, par exemple, mieux vaut utiliser une file d’attente factice. Voici comment faire.

Injecter les propriétés JMS définies plus tôt :

@RestController
@RequestMapping( "/context_switch" )
public class ContextSwitchController{
	protected final static Logger L = LoggerFactory.getLogger( ContextSwitchController.class );
	@Autowired
	private JmsTemplate jmsTemplate;
	@Autowired
	private JmsConnectionFactory factory;
	@Autowired
	private JmsListenerEndpointRegistry registry;
	@Autowired
	private JmsListenerEndpointRegistrar registrar;
	@Autowired
	private MessageHandler messageHandler;
	@Autowired
	private JmsConfig jmsConfig;
  }

Créer un contrôleur de REST API nommé ContextController.java avec un point de terminaison POST permettant de changer de file d’attente à l’aide d’un programme.

@PostMapping
	public String switchContext( @RequestBody JmsConfig configRequest ) throws Exception{
		try{
			if( configRequest.getQueueManager() != null ){
				BeanUtils.copyProperties( jmsConfig, configRequest );
				setupConnection( configRequest );
				switchQueues( configRequest );
			}
			L.debug( "Context switched successfully" );
		}
		catch( Exception e ){
			throw new Exception( "Unable to switch context." );
		}
		return "Switched to QM: " + jmsConfig.getQueueManager() + " with host: " + jmsConfig.getHost() + " and port: " + jmsConfig.getPort();
	}

Le corps des requêtes à ce point de terminaison est du même type que le bean JmsConfig injecté plus tôt. Nous pouvons utiliser BeanUtils.copyProperties pour copier les nouvelles propriétés JMS vers le bean JmsConfig.

Ensuite, utiliser le script ci-dessous pour fermer les connexions en cours et mettre à jour connectionfactory avec les nouvelles propriétés JMS.

private void setupConnection( JmsConfig configRequest ) throws Exception{
		Set<String> listenerContainerIds = registry.getListenerContainerIds();
		for( String id : listenerContainerIds ){
			registry.getListenerContainer( id ).stop();
		}
		try{
			factory.setStringProperty( WMQConstants.WMQ_HOST_NAME, configRequest.getHost() );
			factory.setObjectProperty( WMQConstants.WMQ_PORT, Integer.valueOf( configRequest.getPort() ) );
			factory.setStringProperty( WMQConstants.WMQ_QUEUE_MANAGER, configRequest.getQueueManager() );
			factory.setStringProperty( WMQConstants.WMQ_CHANNEL, configRequest.getChannel() );
			factory.setStringProperty( WMQConstants.USERID, configRequest.getUser() );
			factory.setStringProperty( WMQConstants.PASSWORD, configRequest.getPassword() );

			factory.createConnection();
			jmsTemplate.setConnectionFactory( factory );
		}
		catch( JMSException e ){
			throw new Exception( "Invalid Connection Factory Parameters" );
		}
	}

Pour finir, nous devons nous assurer que les nouvelles files d’attente acceptent les messages. Pour ce faire, nous utiliserons SimpleJmsListenerEndpoint, comme lors de la configuration initiale.

private void switchQueues( JmsConfig configRequest ){
		for( String queueName : configRequest.getSampleQueues() ){
			SimpleJmsListenerEndpoint endpoint = new SimpleJmsListenerEndpoint();
			endpoint.setId( "demo-" + UUID.randomUUID() );
			endpoint.setDestination( queueName );
			endpoint.setConcurrency( configRequest.getConcurrencyMin() + "-" + configRequest.getConcurrencyMax() );
			endpoint.setMessageListener( message -> {	
				messageHandler.recv( queueName, message );
			} );
			registrar.registerEndpoint( endpoint );
		}
	}

C’était la partie sur les changements de contexte.

Conclusion

Voici les étapes que nous avons suivies :

  1. Créer un conteneur Docker en local pour héberger IBM MQ.
  2. Utiliser JMS pour se connecter à la file d’attente (envoi et réception).
  3. Ajouter un contrôleur pour programmer le changement de file d’attente dans une application déjà déployée.

Ce guide ne présente qu’une structure de base que vous pouvez adapter à vos besoins.

VOUS AVEZ UN PROJET ?