Websocket com RabbitMQ e com Gateway Zuul

Neste tutorial, apresentaremos como configurar Spring websocket com RabbitMQ rodando em um container docker e com o Gateway Zuul. Nós iremos configurar o servidor websocket junto com o gateway zuul, porque ele suporta somente o protocolo HTTP na sua versão 1, e não conseguiria fazer o forward para um microservice específico para websocket.

Adicione as dependências no maven

Estamos utilizando a versão 2.1.3.RELEASE por isso iremos precisar do netty para utilizar o websocket

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-amqp</artifactId>
  <version>2.3.1.RELEASE</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>

Criando configuração do Websocket

Um detalhe importante sobre a configuração é definir o atributor setSystemLogin e setSystemPassword, caso não seja configurado seu servidor websocket não irá conseguir se conectar com o rabbitmq, porque o usuário padrão de conexão é ‘guest’, você pode conferir a descrição e solução do erro aqui.

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {

    private final FilterHandshakeHandler filterHandshakeHandler;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/socket")
                .setHandshakeHandler(filterHandshakeHandler)
                .setAllowedOrigins("*");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.setApplicationDestinationPrefixes("/send")
                .enableStompBrokerRelay("/topic")
                .setRelayHost("127.0.0.1")
                .setRelayPort(61613)
                .setSystemLogin("admin") // Utilizado para sobreescrever o usuário guest padrão
                .setSystemPasscode("123456")
                .setClientLogin("admin")
                .setClientPasscode("123456");
    }
}

Criando Handler/Filtro para Registrar na Sessão

Nessa configuração estamos definindo um name da sessão de conexão do cliente com o servidor websocket, esse name é gerado de forma randômica com o UUID. 

@Component
public class FilterHandshakeHandler extends DefaultHandshakeHandler {


    // Custom class for storing principal
    @Override
    protected Principal determineUser(ServerHttpRequest request,
                                      WebSocketHandler wsHandler,
                                      Map<String, Object> attributes) {
        return new StompPrincipal(UUID.randomUUID().toString());
    }

    public class StompPrincipal implements Principal {
        private String name;

        public StompPrincipal(String name) {
            this.name = name;
        }

        @Override
        public String getName() {
            return name;
        }
    }
}

Criando Controladores

Vamos criar uma classe chamada Message Controller, nela temos dois exemplos, um no qual iremos enviar e receber mensagens em “grupo” ou seja todos que tiverem inscritos (subscribe) nesse recurso chamado “/chat”, para enviar mensagens a url ficará “/send/chat” e para se inscrever nessa url “/topic/chat”.

Temos também outro exemplo que é o envio de uma mensagem específica para um usuário de acordo com sua sessão, neste caso o usuário irá se inscrever na url “/user/topic/notify”, esse /user é inserido automaticamente pelo spring para a identificação que é um envio específico.

@Controller
public class MessageController {

    // Example sending and receiving message in group
    @MessageMapping("/chat")
    @SendTo("/topic/chat")
    public String send(@RequestBody String message) {
        return message;
    }

    // Example sending message to specific user
    @SendToUser("/topic/notify")
    public String sendSpecific(@Payload String message, Principal principal) {
        System.out.println(String.format("principal %s", principal.getName()));
        return message;
    }
}

Para testarmos esse fluxo de envio de uma mensagem específica para um cliente conectado vamos criar um controlador Rest para enviar internamente essa mensagem. Você pode perceber que utilizamos o SimpMessagingTemplate que permite que enviamos através do método convertAndSendToUser para aquela url definida em @SendToUser.

@RestController
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class MessageRestController {

    private final SimpMessagingTemplate simpMessagingTemplate;

    @RequestMapping("/new")
    public HttpStatus send(@RequestParam String id, @RequestParam String msg) {
        simpMessagingTemplate.convertAndSendToUser(id, "/topic/notify", msg);
        return HttpStatus.OK;
    }
}

Configurando RabbitMQ com docker compose

Crie um arquivo chamado rabbitmq_enabled_plugins com o conteúdo abaixo na raiz do projeto junto com seu arquivo stack.yml no passo seguinte.

[rabbitmq_federation_management,rabbitmq_management,rabbitmq_stomp,rabbitmq_web_stomp,rabbitmq_web_stomp_examples].

Crie um arquivo stack.yml com o conteúdo abaixo e execute com o comando “docker-compose -f stack.yml up -d”.

version: '2.2'

services:
  rabbitmq:
    image: rabbitmq:3.8.5-management-alpine
    hostname: rabbitmq
    ports:
      - 15672:15672
      - 5671:5671
      - 5672:5672
      - 61613:61613
    #    restart: always
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
      - ./rabbitmq_enabled_plugins:/etc/rabbitmq/enabled_plugins
    environment:
      - "RABBITMQ_HIPE_COMPILE=1"
      - RABBITMQ_DEFAULT_VHOST=/
      - RABBITMQ_DEFAULT_USER=admin
      - RABBITMQ_DEFAULT_PASS=123456
      - RABBITMQ_ERLANG_COOKIE=7184f46085f541589ec6bdd03b45e452
    healthcheck:
      timeout: 5s
      interval: 30s
      retries: 120
      test: "nc -z localhost 5672 || exit 1"
    networks:
      - app_network
networks:
  app_network:

volumes:
  rabbitmq_data:

Configurando application.yml

Insira as configurações abaixo no seu application.yml ou application.properties

spring:
  application:
    name: gateway
  rabbitmq:
    username: admin
    password: 123456
    addresses: 127.0.0.1:5672
    requested-heartbeat: 40
    host: ms-gateway
    listener:
      default:
        default-requeue-rejected: false
zuul:
  sensitive-headers: Cookie
  routes:
    gateway:
      path: /socket/**
      url: forward:/socket

Testando configurações com Cliente

Agora vamos criar um arquivo chamado index.html, e insira o conteúdo abaixo. Se quisermos testar o envio individual de mensagens precisamos chamar o endpoint “/new” enviando como request param o id e mensagem, onde o id é o ID da sessão que poderá ser obtida na interface html depois que o cliente se conectar ao servidor websocket.

<!DOCTYPE html>
<html>
<head>
<title>Cliente Websocket</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">

<style>
	body {
	background: #E5DDD5 url("https://www.toptal.com/designers/subtlepatterns/patterns/sports.png") fixed;
}
.page-header {
	background: #1f1f1f;
	margin: 0;
  padding: 20px 0 10px;
	color: #FFFFFF;
	position: fixed;
	width: 100%;
  z-index: 1
}
.main {
	height: 100vh;
	padding-top: 70px;
}

.chat-log {
	padding: 40px 0 114px;
	height: auto;
	overflow: auto;
}
.chat-log__item {
	background: #fafafa;
	padding: 10px;
	margin: 0 auto 20px;
	max-width: 80%;
	float: left;
	border-radius: 4px;
	box-shadow: 0 1px 2px rgba(0,0,0,.1);
	clear: both;
}

.chat-log__item.chat-log__item--own {
	float: right;
	background: #DCF8C6;
	text-align: right;
}

.chat-form {
	background: #DDDDDD;
	padding: 40px 0;
	position: fixed;
	bottom: 0;
	width: 100%;
}

.chat-log__author {
	margin: 0 auto .5em;
	font-size: 14px;
	font-weight: bold;
}
</style>

<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script type="text/javascript">
	var stompClient = null;

	function setConnected(connected, sessionId = "") {
		document.getElementById('connect').disabled = connected;
		document.getElementById('disconnect').disabled = !connected;
		//document.getElementById('sendMessage').style.visibility = connected ? 'visible' : 'hidden';
		
		
		document.getElementById('sendMessage').disabled = !connected;
		document.getElementById('message').disabled = !connected;

		document.getElementById('response-value').innerHTML = '<b>Anote sua sessão: </b>'+sessionId;
		document.getElementById('date').innerText = new Date().toLocaleString('pt-BR', {timeZone: 'UTC'});;

		document.getElementById('response-item').style.visibility = connected ? 'visible' : 'hidden'

		//document.getElementById('session-content').removeAttribute("hidden"); 
		
		
	}

	function connect() {
		stompClient = Stomp.client("ws://localhost:8080/gateway/socket");
		stompClient.debug = () => {};
		stompClient.connect({}, function(frame) {
			setConnected(true, frame.headers['user-name']);
			console.log('Connected: ' + frame);
			
			stompClient.subscribe('/topic/chat', function(response) {
				showNewMessage(JSON.parse(response.body)['message']);
			});
			stompClient.subscribe('/user/topic/notify', function(response) {
				showNewMessage(response.body);
			});
		});
	}

	function disconnect() {
		stompClient.disconnect();
		setConnected(false);
		console.log("Disconnected");
		var chat = document.querySelector('.chat-log');
		while (chat.children.length > 1) {
			chat.removeChild(chat.lastChild);
		}
	}

	function sendMessage() {
		var input = document.getElementById('message');
		var message = input.value;
		stompClient.send("/send/chat", {}, JSON.stringify({
			'message' : message
		}));
		input.value = "";
	}

	function showNewMessage(message) {
		var elem = document.querySelector('#response-item');
		var clone = elem.cloneNode(true);
		var id = 'response-item'+'_' + Math.random().toString(36).substr(2, 9);
		clone.id = id;
		clone.querySelector("#response-value").innerText = message;

		document.querySelector('.chat-log').appendChild(clone);
		location.hash = "#" + id;
		bot(message);
	}

	function copy() {
		/* Get the text field */
		var copyText = document.getElementById("session");

		/* Select the text field */
		copyText.select();
		copyText.setSelectionRange(0, 99999); /* For mobile devices */

		/* Copy the text inside the text field */
		document.execCommand("copy");

		/* Alert the copied text */
		alert("Texto copiado: " + copyText.value);
	}
	
	function bot(message) {
		var link = message.match(/\bhttps?:\/\/\S+/gi)
		if(link && link[0].match(/\.(jpeg|jpg|gif|png)$/) && message.includes("bot") && message.includes("background")) {
			console.log(link[0]);
			document.body.style.background = '#E5DDD5 url('+link[0]+') fixed';
		}
	}
</script>
</head>
<body>
	<noscript>
		<h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2>
	</noscript>

	<header class="page-header">
		<div class="container ">
				<div class="d-flex justify-content-between">
						<div>
							<h2>Chat socket</h2>
						</div>
						<div class="d-flex align-items-center justify-content-end">
							<button class="btn btn-success" id="connect" onclick="connect();">Connect</button>
							<button class="btn btn-danger ml-2" id="disconnect" disabled="disabled" onclick="disconnect();">Disconnect</button>
						</div>
				</div>
		</div>
	</header>
	<div class="main">
		<div class="container ">
		<div class="chat-log">
			<div class="chat-log__item chat-log__item--own" id="response-item" style="visibility: hidden;">
				<!--<h3 class="chat-log__author">Anote sua sessão: </h3>-->
				<small id="date">14:30</small>
				<div class="chat-log__message" id="response-value">BRB</div>
			</div>
		</div>
		</div>
		<div class="chat-form">
		<div class="container ">
				<div class="row">
					<div class="col-sm-10 col-xs-8">
					<input type="text" class="form-control" id="message" placeholder="Message" disabled/>
					</div>
					<div class="col-sm-2 col-xs-4">
					<button type="button"  id="sendMessage" onclick="sendMessage();" class="btn btn-success btn-block" disabled>Enviar</button>
					</div>
				</div>
		</div>
		</div>
	</div>

	<!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
</body>
</html>

Dúvidas?

Você tem outras dúvidas? Deixe seu feedback nos comentários abaixo. Bom, espero que essa dica tenha sido útil.