Article original : How to Use RxStomp with React: Build A Chat App

STOMP est un protocole incroyablement simple mais puissant pour envoyer des messages, implémenté par des serveurs populaires comme RabbitMQ, ActiveMQ et Apollo. L'utilisation de STOMP sur WebSocket est un protocole simple, ce qui en fait un choix populaire pour envoyer des messages depuis un navigateur web, car des protocoles comme AMQP sont limités par le blocage des connexions TCP par les principaux navigateurs.

Pour utiliser STOMP sur WebSocket, vous pouvez utiliser @stomp/stompjs, mais cela implique des callbacks compliqués et une API complexe qui répond à des cas d'utilisation plus spécialisés. Heureusement, il existe également le moins connu @stomp/rx-stomp qui offre une interface agréable via les observables RxJS. Les observables ne sont pas exclusifs à Angular, et ils s'intègrent très bien avec le fonctionnement de React. C'est une interface pratique pour composer des flux de travail et des pipelines complexes avec de nombreuses sources de messages différentes.

Ce tutoriel suit un chemin quelque peu similaire à la version initiale en Angular, mais la structure des composants et le style de code sont adaptés au style fonctionnel de React.

Note : Ce tutoriel est écrit avec TypeScript en mode strict, mais le code JavaScript est presque identique puisque nous n'avons que 5 déclarations de types. Pour la version JS, vous pouvez ignorer les imports et les définitions de types.

Table des matières

Objectifs

Ici, nous allons construire une application de chat simplifiée qui montre divers aspects de RxStomp à travers différents composants. Globalement, nous voulons avoir :

  • Un frontend React connecté avec RxStomp à un serveur STOMP.

  • Un affichage en direct de l'état de la connexion basé sur notre connexion au serveur STOMP.

  • Une logique Pub/Sub pour tout sujet configurable.

  • La répartition de la logique RxStomp sur plusieurs composants pour montrer comment séparer la logique et les responsabilités.

  • L'alignement des cycles de vie de la connexion/abonnements RxStomp avec les cycles de vie des composants React pour garantir qu'il n'y a pas de fuites ou d'observateurs non fermés.

Prérequis

  • Vous devez avoir un serveur STOMP en cours d'exécution afin que l'application React puisse s'y connecter. Ici, nous utiliserons RabbitMQ avec l'extension rabbitmq_web_stomp.

  • La dernière version de React. Ce tutoriel utilisera la v18, bien que les versions plus anciennes devraient également fonctionner.

  • Une certaine familiarité avec les observables sera également utile.

Serveur STOMP de démarrage avec RabbitMQ

Si vous souhaitez également utiliser RabbitMQ (ce n'est pas strictement nécessaire), voici des guides d'installation pour différents systèmes d'exploitation. Pour ajouter l'extension, vous devrez exécuter :

$ rabbitmq-plugins enable rabbitmq_web_stomp

Si vous pouvez utiliser Docker, un fichier Docker similaire à celui-ci configurera tout ce qui est nécessaire pour le tutoriel :

FROM rabbitmq:3.8.8-alpine

run rabbitmq-plugins enable --offline rabbitmq_web_stomp

EXPOSE 15674

Modèle React de démarrage

Pour ce tutoriel, nous utiliserons le modèle react-ts de Vite. La partie centrale de notre application sera dans le composant App, et nous créerons des composants enfants pour d'autres fonctionnalités STOMP spécifiques.

Comment installer RxStomp

Nous utiliserons le package npm @stomp/rx-stomp :

$ npm i @stomp/rx-stomp rxjs

Cela installera la version 2.0.0.

Note : Ce tutoriel fonctionne toujours sans spécifier explicitement rxjs puisque c'est une dépendance sœur, mais il est bon de l'indiquer explicitement.

Comment gérer la connexion et la déconnexion avec le serveur STOMP

Maintenant, ouvrons App.tsx et initialisons notre client RxStomp. Puisque le client n'est pas un état qui changera pour le rendu, nous l'envelopperons dans le Hook useRef.

// src/App.tsx
import { useRef } from 'react'
import { RxStomp } from '@stomp/rx-stomp'

import './App.css'

function App() {
  const rxStompRef = useRef(new RxStomp())
  const rxStomp = rxStompRef.current

  return (
    <>
      <h1>Bonjour RxStomp !</h1>
    </>
  )
}

export default App

En supposant les ports et les détails d'authentification par défaut, nous définirons ensuite une configuration pour notre connexion.

// src/App.tsx

import { RxStomp } from '@stomp/rx-stomp'
import type { RxStompConfig } from '@stomp/rx-stomp'
...
const rxStompConfig: RxStompConfig = {
  brokerURL: 'ws://localhost:15674/ws',
  connectHeaders: {
    login: 'guest',
    passcode: 'guest',
  },
  debug: (msg) => {
    console.log(new Date(), msg)
  },
  heartbeatIncoming: 0,
  heartbeatOutgoing: 20000,
  reconnectDelay: 200,
}

function App() {
  ...

Pour une meilleure expérience de développement, nous avons journalisé tous les messages avec des horodatages dans une console locale et défini des fréquences de temporisation basses. Votre configuration devrait être assez différente pour votre application de production, alors consultez la documentation RxStompConfig pour toutes les options disponibles.

Ensuite, nous passerons la configuration à rxStomp à l'intérieur d'un Hook useEffect. Cela gère l'activation de la connexion parallèlement au cycle de vie du composant.

// src/App.tsx
...
function App() {
  const rxStompRef = useRef(new RxStomp())
  const rxStomp = rxStompRef.current

  useEffect(() => {
    rxStomp.configure(rxStompConfig)
    rxStomp.activate()

    return () => { 
      rxStomp.deactivate() 
    }
  })
  ...

Bien qu'il n'y ait aucun changement visuel dans notre application, la vérification des logs devrait montrer les logs de connexion et de ping. Voici un exemple de ce à quoi cela devrait ressembler :

Date ... >>> CONNECT
login:guest
passcode:guest
accept-version:1.2,1.1,1.0
heart-beat:20000,0

Date ... Received data 
Date ... <<< CONNECTED
version:1.2
heart-beat:0,20000
session:session-EJqaGQijDXqlfc0eZomOqQ
server:RabbitMQ/4.0.2
content-length:0

Date ... connected to server RabbitMQ/4.0.2 
Date ... send PING every 20000ms 
Date ... <<< PONG 
Date ... >>> PING

Note : Généralement, si vous voyez des logs en double, cela peut être un signe qu'une fonctionnalité de désactivation ou de désabonnement n'a pas été implémentée correctement. React rend chaque composant deux fois dans un environnement de développement pour aider les gens à attraper ces bugs via React.StrictMode.

Comment surveiller l'état de la connexion

RxStomp dispose d'une énumération RxStompState qui représente les états de connexion possibles avec notre courtier. Notre prochain objectif est d'afficher l'état de la connexion dans notre interface utilisateur.

Créons un nouveau composant pour cela appelé Status.tsx :

// src/Status.tsx
import { useState } from 'react'

export default function Status() {
  const [connectionStatus, setConnectionStatus] = useState('')

  return (
    <>
      <h2>État de la connexion : {connectionStatus}</h2>
    </>
  )
}

Nous pouvons utiliser l'observable rxStomp.connectionState$ pour lier à notre chaîne connectionStatus. De manière similaire à l'utilisation de useEffect, nous utiliserons l'action de démontage pour unsubscribe().

// src/Status.tsx
import { RxStompState } from '@stomp/rx-stomp'
import { useEffect, useState } from 'react'
import type { RxStomp } from '@stomp/rx-stomp'


export default function Status(props: { rxStomp: RxStomp }) {
  const [connectionStatus, setConnectionStatus] = useState('')

  useEffect(() => {
    const statusSubscription = props.rxStomp.connectionState$.subscribe((state) => {
      setConnectionStatus(RxStompState[state])
    })

    return () => {
      statusSubscription.unsubscribe()
    }
  }, [])

  return (
    <>
      <h2>État de la connexion : {connectionStatus}</h2>
    </>
  )
}

Pour le visualiser, nous l'incluons dans notre application :

// src/App.tsx
import Status from './Status'
...
  return (
    <>
      <h1>Bonjour RxStomp !</h1>

      <Status rxStomp={rxStomp}/>
    </>
  )

À ce stade, vous devriez avoir un indicateur visuel fonctionnel à l'écran. Essayez de manipuler en arrêtant le serveur STOMP et voyez si les logs fonctionnent comme prévu.

Comment envoyer des messages

Créons un simple salon de discussion pour montrer un flux de messagerie simplifié de bout en bout avec le courtier.

Nous pouvons placer la fonctionnalité dans un nouveau composant Chatroom. Tout d'abord, nous pouvons créer le composant avec un champ username et message personnalisé qui est lié aux entrées.

// src/Chatroom.tsx
import { useState } from 'react'
import type { RxStomp } from '@stomp/rx-stomp'

export default function Chatroom(props: {rxStomp: RxStomp}) {
  const [message, setMessage] = useState('')
  const [userName, setUserName] = useState(`user${Math.floor(Math.random() * 1000)}`)

  return (
    <>
      <h2>Salon de discussion</h2>

      <label htmlFor='username'>Nom d'utilisateur : </label>
      <input
        type='text'
        name='username'
        value={userName}
        onChange={(e) => setUserName(e.target.value)}
      />

      <label htmlFor='message'>Message : </label>

      <input
        type='text'
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        name='message'
      />
    </>
  )    
}

Incluons cela dans notre App avec un basculeur pour rejoindre le salon de discussion :

// src/App.tsx
import { useEffect, useState, useRef } from 'react'
import Chatroom from './Chatroom'
...
function App() {
  const [joinedChatroom, setJoinedChatroom] = useState(false)
  ...
  return (
    <>
      <h1>Bonjour RxStomp !</h1>

      <Status rxStomp={rxStomp}/>

      {!joinedChatroom && (
        <button onClick={() => setJoinedChatroom(true)}>
          Rejoindre le salon de discussion !
        </button>
      )}

      {joinedChatroom && (
        <>
          <button onClick={() => setJoinedChatroom(false)}>
            Quitter le salon de discussion !
          </button>

          <Chatroom rxStomp={rxStomp}/>
        </>
      )}

    </>
  )

Il est temps d'envoyer réellement des messages. STOMP est idéal pour envoyer des messages basés sur du texte (les données binaires sont également possibles). Nous définirons la structure des données que nous envoyons dans un nouveau fichier types :

// types.ts
interface ChatMessage {
  userName: string,
  message: string
}

Note : Si vous n'utilisez pas TypeScript, vous pouvez ignorer l'ajout de cette définition de type.

Ensuite, utilisons JSON pour séquentialiser le message et envoyer des messages à notre serveur STOMP en utilisant .publish avec un sujet de destination et notre body JSON.

// src/Chatroom.tsx
import type { ChatMessage } from './types'
...
const CHATROOM_NAME = '/topic/test'

export default function Chatroom(props: {rxStomp: RxStomp}) {
  ...
  function sendMessage(chatMessage: ChatMessage) {
    const body = JSON.stringify({ ...chatMessage })
    props.rxStomp.publish({ destination: CHATROOM_NAME, body })
    console.log(`Envoyé ${body}`)
    setMessage('')
  }

  return (
    <>
      <h2>Salon de discussion</h2>

      <label htmlFor="username">Nom d'utilisateur : </label>
      <input
        type="text"
        name="username"
        value={userName}
        onChange={(e) => setUserName(e.target.value)}
      />

      <label htmlFor="message">Message : </label>

      <input
        type="text"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        name="message"
      />

      <button onClick={() => sendMessage({userName, message})}>Envoyer le message</button>
    </>
  )
}

Pour le tester, essayez de cliquer sur le bouton Envoyer le message plusieurs fois et voyez si la séquentialisation fonctionne correctement. Bien que vous ne puissiez pas encore voir de changements visuels, les logs de la console devraient les montrer :

Date ... >>> SEND
destination:/topic/test
content-length:45

Sent {"userName":"user722","message":"1234567890"}

Comment recevoir des messages

Nous allons créer un nouveau composant pour afficher la liste des messages de tous les utilisateurs. Pour l'instant, nous utiliserons le même type, passerons le nom du sujet en tant que prop, et afficherons tout sous forme de liste. Tout cela va dans un nouveau composant appelé MessageList.

// src/MessageDisplay.tsx
import { useEffect, useState } from 'react'
import type { RxStomp } from '@stomp/rx-stomp'
import type { ChatMessage } from './types'

export default function MessageDisplay(props: {rxStomp: RxStomp, topic: string}) {
  const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
    {userName: 'admin', message: `Bienvenue dans la salle ${props.topic} !`}
  ])

  return(
  <>
  <h2>Messages de discussion</h2>
  <ul>
    {chatMessages.map((chatMessage, index) => 
      <li key={index}>
        <strong>{chatMessage.userName}</strong>: {chatMessage.message}
      </li>
    )}
  </ul>
  </>
  )
}

Il est temps de tout rassembler !

Nous pouvons afficher nos messages à afficher dans notre composant Chatroom en l'ajoutant en bas.

// src/Chatroom.tsx
import { useState } from 'react'
import type { ChatMessage } from './types'
import type { RxStomp } from '@stomp/rx-stomp'

import MessageDisplay from './MessageDisplay'

export const CHATROOM_NAME = '/topic/test'

export default function Chatroom(props: {rxStomp: RxStomp}) {
  const [message, setMessage] = useState('')
  const [userName, setUserName] = useState(`user${Math.floor(Math.random() * 1000)}`)

  function sendMessage(chatMessage: ChatMessage) {
    const body = JSON.stringify({ ...chatMessage })
    props.rxStomp.publish({ destination: CHATROOM_NAME, body })
    console.log(`Envoyé ${body}`)
    setMessage('')
  }

  return (
    <>
      <h2>Salon de discussion</h2>

      <label htmlFor='username'>Nom d'utilisateur : </label>
      <input
        type='text'
        name='username'
        value={userName}
        onChange={(e) => setUserName(e.target.value)}
      />

      <label htmlFor='message'>Message : </label>

      <input
        type='text'
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        name='message'
      />

      <button onClick={() => sendMessage({userName, message})}>Envoyer le message</button>

      <MessageDisplay rxStomp={props.rxStomp} topic={CHATROOM_NAME} />
    </>
  )
}

Et une fois que vous avez vérifié que l'affichage statique fonctionne localement, nous pouvons rendre cet affichage dynamique en utilisant un Observable RxJS pour recevoir nos messages de discussion.

De manière similaire à la gestion de l'abonnement avec le composant Status, nous configurons l'abonnement au montage et nous désabonnons au démontage.

En utilisant le pipe et map de RxJS, nous pouvons désérialiser notre JSON en notre ChatMessage. La conception modulaire peut vous permettre de configurer un pipeline plus compliqué si nécessaire en utilisant les opérateurs RxJS.

// src/MessageDisplay.tsx
...
import { map } from 'rxjs'

export default function MessageDisplay(props: {rxStomp: RxStomp, topic: string}) {
  const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
    {userName: 'admin', message: `Bienvenue dans la salle ${props.topic} !`}
  ])

  useEffect(() => {
    const subscription = props.rxStomp
      .watch(props.topic)
      .pipe(map((message) => JSON.parse(message.body)))
      .subscribe((message) => setChatMessages((chatMessages) => [...chatMessages, message]))

    return () => {
      subscription.unsubscribe()
    }
  }, [])

  ...

À ce stade, l'interface graphique du chat devrait afficher les messages correctement, et vous pouvez expérimenter en ouvrant plusieurs onglets en tant qu'utilisateurs différents.

Une autre chose à essayer ici est d'éteindre le serveur STOMP, d'envoyer quelques messages, puis de le rallumer. Les messages devraient être mis en file d'attente localement et envoyés une fois que le serveur est prêt. Sympa !

Résumé

Dans ce tutoriel, nous avons :

  • Installé @stomp/rx-stomp pour une bonne expérience de développement.

  • Configuré RxStompConfig pour configurer notre client avec les détails de connexion, la journalisation du débogueur et les paramètres du temporisateur.

  • Utilisé rxStomp.activate et rxStomp.deactivate pour gérer le cycle de vie principal du client.

  • Surveillé l'état de l'abonnement en utilisant l'observable rxStomp.connectionState$.

  • Publié des messages en utilisant rxStomp.publish avec des destinations configurables et des corps de messages.

  • Créé un observable pour un sujet donné en utilisant rxStomp.watch.

  • Utilisé à la fois les logs de la console et les composants React pour voir la bibliothèque en action, et vérifier la fonctionnalité et la tolérance aux pannes.

Vous pouvez trouver le code final sur Gitlab : https://gitlab.com/harsh183/rxstomp-react-tutorial. N'hésitez pas à l'utiliser comme modèle de départ et à signaler tout problème qui pourrait survenir.