INTS-137: Add STOMP Chat Application

JIRA: https://jira.spring.io/browse/INTSAMPLES-137

* Upgrade to Boot 1.1.7
* Fix several Boot apps to get deal with `args`, e.g. `--debug` option can be useful for Boot
* Provide a loca copy for JavaScript files (`sock.js` and `stomp.js`) to avoid extra Internet connection on testing

INTS-137-2: Make JavaScript cross-browser

Some additional polishing to the server config, like `logging-channel-adapter` for server message flow
This commit is contained in:
Artem Bilan
2014-10-13 17:31:01 +03:00
committed by Gary Russell
parent a5c12343c6
commit 63232c2974
14 changed files with 580 additions and 6 deletions

View File

@@ -0,0 +1,50 @@
WebSockets Stomp Chat Sample
==============
This application demonstrates the Web chat based on STOMP WebSocket sub-protocol with Spring Integration Adapters.
## Server
The server is presented only with a single `org.springframework.integration.samples.websocket.standard.server.Application`
class, which is based on the Spring Boot AutoConfiguration and Spring Integration xml configuration `@ImportResource`.
It is a `main` and starts an embedded Tomcat server on the default `8080` port.
The WebSocket endpoint is mapped to the `/chat` path.
The server also can be run from Gradle `gradlew :stomp-chat:run`
The server application demonstrates how Spring Integration can be used as a STOMP Broker.
1. `webSocketSessionStore` - the `SimpleMetadataStore` to keep track of `WebSocketSession` and its `user`.
2. `chatMessagesStore` - the `SimpleMessageStore` to store messages for chat rooms.
3. `chatRoomSessions` - the `Map<String, Tuple2<String, String>>` to keep track of `WebSocketSession` `subscriptions`
to the concrete chat room - STOMP `destination`.
4. `<int-event:inbound-channel-adapter channel="routeStompEvents">` is subscribed to the `AbstractSubProtocolEvent`
type to handle STOMP sub-protocol events.
5. `<int:payload-type-router input-channel="routeStompEvents">` is mapped to the appropriate `AbstractSubProtocolEvent`
type to provide the specific integration flow for each event type.
6. `<int-websocket:inbound-channel-adapter>` receives STOMP messages, store them to the appropriate `messageGroup`
(according to the STOMP `destination`) and forward to the `<int-websocket:outbound-channel-adapter>` to send to
each `WebSocketSession` subscribed to that STOMP `destination` - chat room.
## Client
The `index.html` in the `src/main/resources/static` directory of this project demonstrates a JavaScript `STOMP` client
over `SockJS` client.
This application covers classical STOMP scenario:
- `connect` - requirement to enter the `user name` - chat member;
- `subscribe` - the `Join` operation on the one of chat rooms and receiving messages to that subscription for the
destination;
- `send` and `receive` - just chat messages;
- `unsubscribe` - the `Leave` operation on the chat room: the current web socket session stops receiving messages for
the destination;
- `disconnect` - close current web socket session and unsubscribe from all its subscriptions.
To get real chat interaction it's just enough to open several tabs in browser.
When the user joins to the chat room, his subscription receives all messages, sent by other users to that room,
immediately.
## Test Case
The `org.springframework.integration.samples.chat.stomp.server.ApplicationTests` demonstrates the Spring Boot test
framework and just starts Server on the random port to be sure that this application is run correctly.

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.integration.samples.chat.stomp.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
/**
* @author Artem Bilan
* @since 3.0
*/
@Configuration
@EnableAutoConfiguration
@ImportResource("classpath:org/springframework/integration/samples/chat/stomp/server/stomp-server.xml")
public class Application {
public static void main(String[] args) throws Exception {
ConfigurableApplicationContext ctx = SpringApplication.run(Application.class, args);
System.out.println("Hit 'Enter' to terminate");
System.in.read();
ctx.close();
}
}

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:int="http://www.springframework.org/schema/integration"
xmlns:int-websocket="http://www.springframework.org/schema/integration/websocket"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:int-event="http://www.springframework.org/schema/integration/event"
xmlns:task="http://www.springframework.org/schema/task" xmlns:util="http://www.springframework.org/schema/util"
xmlns:int-groovy="http://www.springframework.org/schema/integration/groovy"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/integration
http://www.springframework.org/schema/integration/spring-integration.xsd
http://www.springframework.org/schema/integration/event
http://www.springframework.org/schema/integration/event/spring-integration-event.xsd
http://www.springframework.org/schema/integration/websocket
http://www.springframework.org/schema/integration/websocket/spring-integration-websocket.xsd
http://www.springframework.org/schema/task
http://www.springframework.org/schema/task/spring-task.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util.xsd
http://www.springframework.org/schema/integration/groovy
http://www.springframework.org/schema/integration/groovy/spring-integration-groovy.xsd">
<int:wire-tap channel="logger"/>
<int:logging-channel-adapter id="logger" level="INFO" log-full-message="true"/>
<task:executor id="executor"/>
<bean id="webSocketSessionStore" class="org.springframework.integration.metadata.SimpleMetadataStore"/>
<bean id="chatMessagesStore" class="org.springframework.integration.store.SimpleMessageStore"/>
<util:map id="chatRoomSessions" value-type="java.util.List">
<entry key="room1" value="#{new java.util.ArrayList()}"/>
<entry key="room2" value="#{new java.util.ArrayList()}"/>
</util:map>
<bean id="stompSubProtocolHandler" class="org.springframework.web.socket.messaging.StompSubProtocolHandler"/>
<int-websocket:server-container id="serverWebSocketContainer" path="/chat">
<int-websocket:sockjs/>
</int-websocket:server-container>
<int-event:inbound-channel-adapter event-types="org.springframework.web.socket.messaging.AbstractSubProtocolEvent"
payload-expression="message"
channel="routeStompEvents"/>
<int:header-value-router input-channel="routeStompEvents"
header-name="simpMessageType"
resolution-required="false"
default-output-channel="nullChannel">
<int:mapping value="#{T(org.springframework.messaging.simp.SimpMessageType).CONNECT.name()}"
channel="connectAck"/>
<int:mapping value="#{T(org.springframework.messaging.simp.SimpMessageType).SUBSCRIBE.name()}"
channel="subscribe"/>
<int:mapping value="#{T(org.springframework.messaging.simp.SimpMessageType).UNSUBSCRIBE.name()}"
channel="unsubscribe"/>
<int:mapping value="#{T(org.springframework.messaging.simp.SimpMessageType).DISCONNECT.name()}"
channel="disconnect"/>
</int:header-value-router>
<int:outbound-channel-adapter id="connectAck"
expression="@webSocketSessionStore.put(headers.simpSessionId, headers.nativeHeaders.login)"/>
<int:publish-subscribe-channel id="subscribe"/>
<int:service-activator input-channel="subscribe" output-channel="nullChannel"
expression="@chatRoomSessions[headers.simpDestination]
.add(T(reactor.tuple.Tuple).of(headers.simpSessionId, headers.simpSubscriptionId))"/>
<int:chain input-channel="subscribe" output-channel="sendMessage">
<int:header-enricher>
<int:header name="sessionToSend" expression="headers.simpSessionId"/>
<int:header name="subscriptionToSend" expression="headers.simpSubscriptionId"/>
</int:header-enricher>
<int:service-activator
expression="@chatMessagesStore.getMessageGroup(headers.simpDestination).messages"/>
<int:filter expression="!payload.empty"/>
<int:header-enricher default-overwrite="true">
<int:header name="#{T(org.springframework.messaging.simp.stomp.StompHeaderAccessor).SESSION_ID_HEADER}"
expression="headers.sessionToSend"/>
<int:header
name="#{T(org.springframework.messaging.simp.stomp.StompHeaderAccessor).STOMP_SUBSCRIPTION_HEADER}"
expression="headers.subscriptionToSend"/>
</int:header-enricher>
<int:splitter apply-sequence="false"/>
</int:chain>
<int:outbound-channel-adapter id="unsubscribe">
<int-groovy:script>
chatRoomSessions.each { k, v ->
v.remove(reactor.tuple.Tuple.of(headers.simpSessionId, headers.simpSubscriptionId))
}
null
</int-groovy:script>
</int:outbound-channel-adapter>
<int:channel id="receiveMessage"/>
<int-websocket:inbound-channel-adapter channel="receiveMessage" container="serverWebSocketContainer"
default-protocol-handler="stompSubProtocolHandler"/>
<int:transformer input-channel="receiveMessage" output-channel="storeMessageAndPublish"
expression="{user: @webSocketSessionStore.get(headers.simpSessionId), message: payload, date: new java.util.Date()}"/>
<int:publish-subscribe-channel id="storeMessageAndPublish"/>
<int:service-activator input-channel="storeMessageAndPublish" output-channel="nullChannel"
expression="@chatMessagesStore.addMessageToGroup(headers.simpDestination, #root)"/>
<int:splitter input-channel="storeMessageAndPublish" output-channel="sendMessage" apply-sequence="false">
<int-groovy:script>
chatRoomSessions[headers.simpDestination].collect {
org.springframework.integration.support.MessageBuilder.withPayload(payload)
.copyHeaders(headers)
.setHeader('simpSessionId', it.t1)
.setHeader('simpSubscriptionId', it.t2)
.build()
}
</int-groovy:script>
</int:splitter>
<int:channel id="sendMessage">
<int:dispatcher task-executor="executor"/>
</int:channel>
<int-websocket:outbound-channel-adapter channel="sendMessage" container="serverWebSocketContainer"
default-protocol-handler="stompSubProtocolHandler"/>
<int:outbound-channel-adapter id="disconnect">
<int-groovy:script>
webSocketSessionStore.remove(headers.simpSessionId)
chatRoomSessions.each { k, v -> v.removeAll { it.t1 == headers.simpSessionId } }
null
</int-groovy:script>
</int:outbound-channel-adapter>
</beans>

View File

@@ -0,0 +1,217 @@
<html>
<head>
<title>WebSocket Chat</title>
<script src="http://localhost:8080/sockjs.js"></script>
<script src="http://localhost:8080/stomp.js"></script>
<script type="text/javascript">
var sock, stompClient, currentUser, subscriptions = {};
function connect() {
var userValue = document.getElementById('user');
if (userValue.value != "") {
currentUser = userValue.value;
sock = new SockJS('http://localhost:8080/chat');
stompClient = Stomp.over(sock);
stompClient.connect({login: currentUser}, function (frame) {
document.getElementById("welcome").style.display = "none";
document.getElementById("chat").style.display = "";
document.getElementById("currentUser").innerHTML = currentUser;
userValue.value = "";
});
}
}
function subscribeToRoom(room) {
subscriptions[room] =
stompClient.subscribe('room' + room, function (message) {
var m = JSON.parse(message.body);
var tr = document.createElement('tr');
tr.innerHTML = "<td width='100'><div class='messageUser'>" +
m.user +
"</div><div class='messageDate'>" +
new Date(m.date).toLocaleTimeString(navigator.userLanguage,
{month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}) +
"</div></td><td>" +
m.message +
"</td>";
var messages = document.getElementById("messages" + room);
messages.appendChild(tr);
messages.scrollIntoView(false);
});
document.getElementById("join" + room).style.display = "none";
document.getElementById("leave" + room).style.display = "";
document.getElementById("message" + room).disabled = false;
document.getElementById("send" + room).disabled = false;
}
function sendMessage(room) {
var messageValue = document.getElementById("message" + room);
if (messageValue.value != "") {
stompClient.send('room' + room, {subscription: subscriptions[room].id}, messageValue.value);
messageValue.value = "";
}
}
function unsubscribeFromRoom(room) {
subscriptions[room].unsubscribe();
document.getElementById("join" + room).style.display = "";
document.getElementById("leave" + room).style.display = "none";
document.getElementById("message" + room).disabled = true;
document.getElementById("send" + room).disabled = true;
document.getElementById("messages" + room).innerHTML = "";
}
function disconnect() {
for (var key in subscriptions) {
if (subscriptions.hasOwnProperty(key)) {
unsubscribeFromRoom(key)
}
}
stompClient.disconnect(function (frame) {
document.getElementById("welcome").style.display = "";
document.getElementById("chat").style.display = "none";
});
}
</script>
<style>
.messageUser {
width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
.messageDate {
text-align: center;
margin-top: 5px;
}
</style>
</head>
<body style="margin: 0">
<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>
<div id="welcome"
style="position: absolute;
bottom: 0;
font-size: 200%;
height: 200px;
margin: auto;
text-align: center;
top: 0;
width: 100%;">
Welcome to the Simple WebSocket Stomp Chat!
<br/>
Enter your name to connect:
<br/>
<br/>
<div align="center">
<form onsubmit="connect();return false;">
<table>
<tr>
<td>
<input id="user" type="text"
style="font-size: 24pt; width: 200px; font-weight: bold; margin-top: 2px;"/>
</td>
<td>
<input type="submit" value="Connect"
style="height: 43px; width: 200px; font-size: 24pt; font-weight: bold;"/>
</td>
</tr>
</table>
</form>
</div>
</div>
<div id="chat" align="center" style="font-size: 200%; padding-top: 50px; display: none;">
Welcome, <span id="currentUser"></span>!
<br/>
Please, join to chat rooms to send and receive messages to/from other users:
<br/>
<br/>
<table>
<tr>
<td style="padding-right: 20px;">
<div align="center" style="font-size: 25pt;">
Room 1
</div>
<div align="center">
<input id="join1" type="button" value="Join"
onclick="subscribeToRoom(1)"
style="height: 43px; width: 150px; font-size: 20pt; font-weight: bold;"/>
&nbsp;&nbsp;&nbsp;
<input id="leave1" type="button" value="Leave"
onclick="unsubscribeFromRoom(1)"
style="height: 43px; width: 150px; font-size: 20pt; font-weight: bold;display: none"/>
</div>
<br/>
<div style="height: 300px; border: 5px groove; overflow: auto; width: 500px;">
<table width="100%" border="1" cellspacing="0" style="border: 0">
<tbody id="messages1" valign="top">
</tbody>
</table>
</div>
<br/>
<form onsubmit="sendMessage(1);return false;" style="margin-left: -4px;">
<table>
<tr>
<td>
<input id="message1" type="text" disabled
style="font-size: 20pt; width: 429px; margin-top: 2px;"/>
</td>
<td>
<input id="send1" type="submit" value="Send" disabled
style="height: 40px; width: 77px; font-size: 20pt; font-weight: bold;"/>
</td>
</tr>
</table>
</form>
</td>
<td style="padding-left: 20px;">
<div align="center" style="font-size: 25pt;">
Room 2
</div>
<div align="center">
<input id="join2" type="button" value="Join"
onclick="subscribeToRoom(2)"
style="height: 43px; width: 150px; font-size: 20pt; font-weight: bold;"/>
&nbsp;&nbsp;&nbsp;
<input id="leave2" type="button" value="Leave"
onclick="unsubscribeFromRoom(2)"
style="height: 43px; width: 150px; font-size: 20pt; font-weight: bold; display: none"/>
</div>
<br/>
<div style="height: 300px; border: 5px groove; overflow: auto; width: 500px;">
<table width="100%" border="1" cellspacing="0" style="border: 0">
<tbody id="messages2" valign="top">
</tbody>
</table>
</div>
<br/>
<form onsubmit="sendMessage(2);return false;" style="margin-left: -4px;">
<table>
<tr>
<td>
<input id="message2" type="text" disabled
style="font-size: 20pt; width: 429px; margin-top: 2px;"/>
</td>
<td>
<input id="send2" type="submit" value="Send" disabled
style="height: 40px; width: 77px; font-size: 20pt; font-weight: bold;"/>
</td>
</tr>
</table>
</form>
</td>
</tr>
</table>
<br/><br/>
<input type="button" value="Disconnect" onclick="disconnect()"
style="height: 43px; width: 220px; font-size: 24pt; font-weight: bold;"/>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.integration.samples.chat.stomp.server;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.IntegrationTest;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
/**
* @author Artem Bilan
* @since 3.0
*/
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
@IntegrationTest
public class ApplicationTests {
@Test
public void testWebSockets() throws InterruptedException {
}
}

View File

@@ -57,7 +57,7 @@ import org.springframework.messaging.support.GenericMessage;
public class Application {
public static void main(String[] args) throws Exception {
ConfigurableApplicationContext ctx = SpringApplication.run(Application.class);
ConfigurableApplicationContext ctx = SpringApplication.run(Application.class, args);
System.out.println("Hit 'Enter' to terminate");
System.in.read();
ctx.close();

View File

@@ -2,7 +2,7 @@
<html>
<head>
<title>Time over WebSocket</title>
<script src="http://cdn.sockjs.org/sockjs-0.3.min.js"></script>
<script src="http://localhost:8080/sockjs.js"></script>
<script type="text/javascript">
var sock = new SockJS('http://localhost:8080/time');

File diff suppressed because one or more lines are too long

View File

@@ -138,7 +138,6 @@ subprojects { subproject ->
}
repositories {
//TODO on release
// mavenLocal()
maven { url 'http://repo.spring.io/libs-snapshot' }
maven { url 'http://repo.spring.io/libs-milestone' }
@@ -1117,6 +1116,27 @@ project('web-sockets') {
}
}
project('stomp-chat') {
description = 'Web Sockets Stomp Chat Sample'
apply plugin: 'spring-boot'
dependencies {
compile 'org.springframework.boot:spring-boot-starter-websocket'
compile "org.springframework.integration:spring-integration-websocket:$springIntegration41Version"
compile "org.springframework.integration:spring-integration-event:$springIntegration41Version"
compile "org.springframework.integration:spring-integration-groovy:$springIntegration41Version"
testCompile 'org.springframework.boot:spring-boot-starter-test'
}
mainClassName = 'org.springframework.integration.samples.chat.stomp.server.Application'
tasks.withType(JavaExec) {
standardInput = System.in
}
}
task wrapper(type: Wrapper) {
description = 'Generates gradlew[.bat] scripts'
gradleVersion = '1.12'

View File

@@ -68,7 +68,7 @@ import org.springframework.social.twitter.api.impl.TwitterTemplate;
public class Application {
public static void main(String[] args) throws Exception {
ConfigurableApplicationContext ctx = SpringApplication.run(Application.class);
ConfigurableApplicationContext ctx = SpringApplication.run(Application.class, args);
System.in.read();
ctx.close();
}

View File

@@ -75,7 +75,7 @@ import org.springframework.social.twitter.api.impl.TwitterTemplate;
public class Application {
public static void main(String[] args) throws Exception {
ConfigurableApplicationContext ctx = SpringApplication.run(Application.class);
ConfigurableApplicationContext ctx = SpringApplication.run(Application.class, args);
Scanner scanner = new Scanner(System.in);
String hashTag = scanner.nextLine();
System.out.println(ctx.getBean(Gateway.class).sendReceive(hashTag));

View File

@@ -1,2 +1,2 @@
version=3.0.0.BUILD-SNAPSHOT
springBootVersion=1.1.6.RELEASE
springBootVersion=1.1.7.RELEASE