This repository hosts a complete workshop on Spring Boot + Spring WebFlux. Just follow this README and create your first WebFlux applications! Each step of this workshop has its companion commit in the git history with a detailed commit message.
We’ll create two applications:
-
stock-quotes
is a functional WebFlux app which streams stock quotes -
trading-service
is an annotation-based WebFlux app using a datastore, HTML views, and several browser-related technologies
Reference documentations can be useful while working on those apps:
-
Spring WebFlux reference documentation and javadoc
Stock Quotes application
Create this application
Go to https://start.spring.io
and create a Maven project with Spring Boot 2.0.2.RELEASE,
with groupId io.spring.workshop
and artifactId stock-quotes
. Select the Reactive Web
Boot starter.
Unzip the given file into a directory and import that application into your IDE.
If generated right, you should have a main Application
class that looks like this:
package io.spring.workshop.stockquotes;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class StockQuotesApplication {
public static void main(String[] args) {
SpringApplication.run(StockQuotesApplication.class, args);
}
}
Edit your application.properties
file to start the server on a specific port.
server.port=8081
Launching it from your IDE or with mvn spring-boot:run
should start a Netty server on port 8081.
You should see in the logs something like:
INFO 2208 --- [ restartedMain] o.s.b.web.embedded.netty.NettyWebServer : Netty started on port(s): 8081
INFO 2208 --- [ restartedMain] i.s.w.s.StockQuotesApplication : Started StockQuotesApplication in 1.905 seconds (JVM running for 3.075)
Create a Quote Generator
To simulate real stock values, we’ll create a generator that emits such values at a specific interval. Copy the following classes to your project.
package io.spring.workshop.stockquotes;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.Instant;
public class Quote {
private static final MathContext MATH_CONTEXT = new MathContext(2);
private String ticker;
private BigDecimal price;
private Instant instant;
public Quote() {
}
public Quote(String ticker, BigDecimal price) {
this.ticker = ticker;
this.price = price;
}
public Quote(String ticker, Double price) {
this(ticker, new BigDecimal(price, MATH_CONTEXT));
}
public String getTicker() {
return ticker;
}
public void setTicker(String ticker) {
this.ticker = ticker;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public Instant getInstant() {
return instant;
}
public void setInstant(Instant instant) {
this.instant = instant;
}
@Override
public String toString() {
return "Quote{" +
"ticker='" + ticker + '\'' +
", price=" + price +
", instant=" + instant +
'}';
}
}
package io.spring.workshop.stockquotes;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import reactor.core.publisher.Flux;
import org.springframework.stereotype.Component;
@Component
public class QuoteGenerator {
private final MathContext mathContext = new MathContext(2);
private final Random random = new Random();
private final List<Quote> prices = new ArrayList<>();
/**
* Bootstraps the generator with tickers and initial prices
*/
public QuoteGenerator() {
this.prices.add(new Quote("CTXS", 82.26));
this.prices.add(new Quote("DELL", 63.74));
this.prices.add(new Quote("GOOG", 847.24));
this.prices.add(new Quote("MSFT", 65.11));
this.prices.add(new Quote("ORCL", 45.71));
this.prices.add(new Quote("RHT", 84.29));
this.prices.add(new Quote("VMW", 92.21));
}
public Flux<Quote> fetchQuoteStream(Duration period) {
// We want to emit quotes with a specific period;
// to do so, we create a Flux.interval
return Flux.interval(period)
// In case of back-pressure, drop events
.onBackpressureDrop()
// For each tick, generate a list of quotes
.map(this::generateQuotes)
// "flatten" that List<Quote> into a Flux<Quote>
.flatMapIterable(quotes -> quotes)
.log("io.spring.workshop.stockquotes");
}
/*
* Create quotes for all tickers at a single instant.
*/
private List<Quote> generateQuotes(long interval) {
final Instant instant = Instant.now();
return prices.stream()
.map(baseQuote -> {
BigDecimal priceChange = baseQuote.getPrice()
.multiply(new BigDecimal(0.05 * this.random.nextDouble()), this.mathContext);
Quote result = new Quote(baseQuote.getTicker(), baseQuote.getPrice().add(priceChange));
result.setInstant(instant);
return result;
})
.collect(Collectors.toList());
}
}
Functional web applications with "WebFlux.fn"
Spring WebFlux comes in two flavors of web applications: annotation based and functional. For this first application, we’ll use the functional variant.
Incoming HTTP requests are handled by a HandlerFunction
, which is essentially a function
that takes a ServerRequest and returns a Mono<ServerResponse>
. The annotation counterpart
to a handler function would be a Controller method.
But how those incoming requests are routed to the right handler?
We’re using a RouterFunction
, which is a function that takes a ServerRequest
, and returns
a Mono<HandlerFunction>
. If a request matches a particular route, a handler function is returned;
otherwise it returns an empty Mono
. The RouterFunction
has a similar purpose as the @RequestMapping
annotation in @Controller
classes.
Take a look at the code samples in the Spring WebFlux.fn reference documentation
Create your first HandlerFunction + RouterFunction
First, create a QuoteHandler
class and mark is as a @Component
;this class will have all our handler functions as methods.
Now create a hello
handler function in that class that always returns "text/plain" HTTP responses with "Hello Spring!" as body.
To route requests to that handler, you need to expose a RouterFunction
to Spring Boot.
Create a QuoteRouter
configuration class (i.e. annotated with @Configuration
)
that creates a bean of type RouterFunction<ServerResponse>
.
Modify that class so that GET requests to "/hello"
are routed to the handler you just implemented.
Since QuoteHandler is a component, you can inject it in @Bean methods as a method parameter.
|
Your application should now behave like this:
$ curl http://localhost:8081/hello -i
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: text/plain;charset=UTF-8
Hello Spring!%
Once done, add another endpoint:
-
with a HandlerFunction
echo
that echoes the request body in the response, as "text/plain" -
and an additional route in our existing
RouterFunction
that accepts POST requests on"/echo"
with a "text/plain" body and returns responses with the same content type.
You can also use this new endpoint with:
$ curl http://localhost:8081/echo -i -d "WebFlux workshop" -H "Content-Type: text/plain"
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: text/plain
WebFlux workshop%
Expose the Flux<Quotes> as a web service
First, let’s inject our QuoteGenerator
instance in our QuoteHandler
, instantiate
a Flux<Quote>
from it that emits a Quote
every 200 msec and can be shared between
multiple subscribers (look at the Flux
operators for that). This instance should be kept
as an attribute for reusability.
Now create a streamQuotes
handler that streams those generated quotes
with the "application/stream+json"
content type. Add the corresponding part in the RouterFunction
,
on the "/quotes"
endpoint.
$ curl http://localhost:8081/quotes -i -H "Accept: application/stream+json"
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: application/stream+json
{"ticker":"CTXS","price":84.0,"instant":1494841666.633000000}
{"ticker":"DELL","price":67.1,"instant":1494841666.834000000}
{"ticker":"GOOG","price":869,"instant":1494841667.034000000}
{"ticker":"MSFT","price":66.5,"instant":1494841667.231000000}
{"ticker":"ORCL","price":46.13,"instant":1494841667.433000000}
{"ticker":"RHT","price":86.9,"instant":1494841667.634000000}
{"ticker":"VMW","price":93.7,"instant":1494841667.833000000}
Let’s now create a variant of that — instead of streaming all values (with an infinite stream), we can
now take the last "n" elements of that Flux
and return those as a collection of Quotes with
the content type "application/json"
. Note that you should take the requested number of Quotes
from the request itself, with the query parameter named "size"
(or pick 10
as the default size
if none was provided).
curl http://localhost:8081/quotes -i -H "Accept: application/json"
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: application/json
[{"ticker":"CTXS","price":85.8,"instant":1494842241.716000000},{"ticker":"DELL","price":64.69,"instant":1494842241.913000000},{"ticker":"GOOG","price":856.5,"instant":1494842242.112000000},{"ticker":"MSFT","price":68.2,"instant":1494842242.317000000},{"ticker":"ORCL","price":47.4,"instant":1494842242.513000000},{"ticker":"RHT","price":85.6,"instant":1494842242.716000000},{"ticker":"VMW","price":96.1,"instant":1494842242.914000000},{"ticker":"CTXS","price":85.5,"instant":1494842243.116000000},{"ticker":"DELL","price":64.88,"instant":1494842243.316000000},{"ticker":"GOOG","price":889,"instant":1494842243.517000000}]%
Integration tests with WebTestClient
Spring WebFlux (actually the spring-test
module) includes a WebTestClient
that can be used to test WebFlux server endpoints with or without a running server.
Tests without a running server are comparable to MockMvc from Spring MVC where mock request
and response are used instead of connecting over the network using a socket.
The WebTestClient however can also perform tests against a running server.
You can check that your last endpoint is working properly with the following integration test:
package io.spring.workshop.stockquotes;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
// We create a `@SpringBootTest`, starting an actual server on a `RANDOM_PORT`
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class StockQuotesApplicationTests {
// Spring Boot will create a `WebTestClient` for you,
// already configure and ready to issue requests against "localhost:RANDOM_PORT"
@Autowired
private WebTestClient webTestClient;
@Test
public void fetchQuotes() {
webTestClient
// We then create a GET request to test an endpoint
.get().uri("/quotes?size=20")
.accept(MediaType.APPLICATION_JSON)
.exchange()
// and use the dedicated DSL to test assertions against the response
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBodyList(Quote.class)
.hasSize(20)
// Here we check that all Quotes have a positive price value
.consumeWith(allQuotes ->
assertThat(allQuotes.getResponseBody())
.allSatisfy(quote -> assertThat(quote.getPrice()).isPositive()));
}
@Test
public void fetchQuotesAsStream() {
List<Quote> result = webTestClient
// We then create a GET request to test an endpoint
.get().uri("/quotes")
// this time, accepting "application/stream+json"
.accept(MediaType.APPLICATION_STREAM_JSON)
.exchange()
// and use the dedicated DSL to test assertions against the response
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_STREAM_JSON)
.returnResult(Quote.class)
.getResponseBody()
.take(30)
.collectList()
.block();
assertThat(result).allSatisfy(quote -> assertThat(quote.getPrice()).isPositive());
}
}
Trading Service application
Create this application
Go to https://start.spring.io
and create a Maven project with Spring Boot 2.0.2.RELEASE,
with groupId io.spring.workshop
and artifactId trading-service
. Select the Reactive Web
, Devtools
, Thymeleaf
and Reactive Mongo
Boot starters.
Unzip the given file into a directory and import that application into your IDE.
Use Tomcat as a web engine
By default, spring-boot-starter-webflux
transitively brings spring-boot-starter-reactor-netty
and Spring Boot auto-configures Reactor Netty as a web server. For this application, we’ll use
Tomcat as an alternative.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
Note that Spring Boot supports as well Undertow and Jetty.
Use a reactive datastore
In this application, we’ll use a MongoDB datastore with its reactive driver; for this workshop, we’ll use an in-memory instance of MongoDB. So add the following:
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
</dependency>
We’d like to manage TradingUser
with our datastore.
package io.spring.workshop.tradingservice;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@Document
public class TradingUser {
@Id
private String id;
private String userName;
private String fullName;
public TradingUser() {
}
public TradingUser(String id, String userName, String fullName) {
this.id = id;
this.userName = userName;
this.fullName = fullName;
}
public TradingUser(String userName, String fullName) {
this.userName = userName;
this.fullName = fullName;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TradingUser that = (TradingUser) o;
if (!id.equals(that.id)) return false;
return userName.equals(that.userName);
}
@Override
public int hashCode() {
int result = id.hashCode();
result = 31 * result + userName.hashCode();
return result;
}
}
Now create a TradingUserRepository
interface that extends ReactiveMongoRepository
.
Add a findByUserName(String userName)
method that returns a single TradingUser
in a reactive fashion.
We’d like to insert users in our datastore when the application starts up. For that, create a UsersCommandLineRunner
component that implements Spring Boot’s CommandLineRunner
. In the run
method, use the reactive repository
to insert TradingUser
instances in the datastore.
Since the run method returns void, it expects a blocking implementation. This is why you should use the
blockLast(Duration) operator on the Flux returned by the repository when inserting data.
You can also then().block(Duration) to turn that Flux into a Mono<Void> that waits for completion.
|
Create a JSON web service
We’re now going to expose TradingUser
through a Controller.
First, create a UserController
annotated with @RestController
.
Then add two new Controller methods in order to handle:
-
GET requests to
"/users"
, returning allTradingUser
instances, serializing them with content-type"application/json"
-
GET requests to
"/users/{username}"
, returning a singleTradingUser
instance, serializing it with content-type"application/json"
You can now validate your implementation with the following test:
package io.spring.workshop.tradingservice;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.BDDMockito;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.reactive.server.WebTestClient;
@RunWith(SpringRunner.class)
@WebFluxTest(UserController.class)
public class UserControllerTests {
@Autowired
private WebTestClient webTestClient;
@MockBean
private TradingUserRepository repository;
@Test
public void listUsers() {
TradingUser juergen = new TradingUser("1", "jhoeller", "Juergen Hoeller");
TradingUser andy = new TradingUser("2", "wilkinsona", "Andy Wilkinson");
BDDMockito.given(this.repository.findAll())
.willReturn(Flux.just(juergen, andy));
this.webTestClient.get().uri("/users").accept(MediaType.APPLICATION_JSON)
.exchange()
.expectBodyList(TradingUser.class)
.hasSize(2)
.contains(juergen, andy);
}
@Test
public void showUser() {
TradingUser juergen = new TradingUser("1", "jhoeller", "Juergen Hoeller");
BDDMockito.given(this.repository.findByUserName("jhoeller"))
.willReturn(Mono.just(juergen));
this.webTestClient.get().uri("/users/jhoeller").accept(MediaType.APPLICATION_JSON)
.exchange()
.expectBody(TradingUser.class)
.isEqualTo(juergen);
}
}
Use Thymeleaf to render HTML views
We already added the Thymeleaf Boot starter when we created our trading application.
First, let’s add a couple of WebJar dependencies to get static resources for our application:
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>3.3.7</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>highcharts</artifactId>
<version>5.0.8</version>
</dependency>
We can now create HTML templates in our src/main/resources/templates
folder and map them using controllers.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="description" content="Spring WebFlux Workshop"/>
<meta name="author" content="Violeta Georgieva and Brian Clozel"/>
<title>Spring Trading application</title>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css"/>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css"/>
</head>
<body>
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="/">Spring Trading application</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li class="active"><a href="/">Home</a></li>
<li><a href="/quotes">Quotes</a></li>
<li><a href="/websocket">Websocket</a></li>
</ul>
</div>
</div>
</nav>
<div class="container wrapper">
<h2>Trading users</h2>
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>User name</th>
<th>Full name</th>
</tr>
</thead>
<tbody>
<tr th:each="user: ${users}">
<th scope="row" th:text="${user.id}">42</th>
<td th:text="${user.userName}">janedoe</td>
<td th:text="${user.fullName}">Jane Doe</td>
</tr>
</tbody>
</table>
</div>
<script type="text/javascript" src="/webjars/jquery/1.11.1/jquery.min.js"></script>
<script type="text/javascript" src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
</body>
</html>
As you can see in that template, we loop over the "users"
attribute and write a row in our HTML table for each.
Let’s display those users in our application:
-
Create a
HomeController
Controller -
Add a Controller method that handles GET requests to
"/"
-
Inject the Spring
Model
on that method and add a"users"
attribute to it
Spring WebFlux will resolve automatically Publisher instances before rendering the view,
there’s no need to involve blocking code at all!
|
Use the WebClient to stream JSON to the browser
In this section, we’ll call our remote stock-quotes
service to get Quotes from it, so we first need to:
-
copy over the
Quote
class to this application -
add the following template file to your application:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="description" content="Spring WebFlux Workshop"/>
<meta name="author" content="Violeta Georgieva and Brian Clozel"/>
<title>Spring Trading application</title>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css"/>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css"/>
<link rel="stylesheet" href="/webjars/highcharts/5.0.8/css/highcharts.css"/>
</head>
<body>
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="/">Spring Trading application</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a href="/">Home</a></li>
<li class="active"><a href="/quotes">Quotes</a></li>
<li><a href="/websocket">Websocket</a></li>
</ul>
</div>
</div>
</nav>
<div class="container wrapper">
<div id="chart" style="height: 400px; min-width: 310px"></div>
</div>
<script type="text/javascript" src="/webjars/jquery/1.11.1/jquery.min.js"></script>
<script type="text/javascript" src="/webjars/highcharts/5.0.8/highcharts.js"></script>
<script type="text/javascript" src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script type="text/javascript">
// Setting up the chart
var chart = new Highcharts.Chart('chart', {
title: {
text: 'My Stock Portfolio'
},
yAxis: {
title: {
text: 'Stock Price'
}
},
legend: {
layout: 'vertical',
align: 'right',
verticalAlign: 'middle'
},
xAxis: {
type: 'datetime'
},
series: [{
name: 'CTXS',
data: []
}, {
name: 'MSFT',
data: []
}, {
name: 'ORCL',
data: []
}, {
name: 'RHT',
data: []
}, {
name: 'VMW',
data: []
}, {
name: 'DELL',
data: []
}]
});
// This function adds the given data point to the chart
var appendStockData = function (quote) {
chart.series
.filter(function (serie) {
return serie.name === quote.ticker
})
.forEach(function (serie) {
var shift = serie.data.length > 40;
serie.addPoint([Date.parse(quote.instant), quote.price], true, shift);
});
};
// The browser connects to the server and receives quotes using ServerSentEvents
// those quotes are appended to the chart as they're received
var stockEventSource = new EventSource("/quotes/feed");
stockEventSource.onmessage = function (e) {
appendStockData(JSON.parse(e.data));
};
</script>
</body>
</html>
As you can see in this template file, loading that HTML page will cause the browser to send a request
the server for Quotes
using the Server Sent Event transport.
Now create a QuotesController
annotated with @Controller
and add two methods.
One that renders the quotes.html
template for incoming "GET /quotes"
requests.
The other should response to "GET /quotes/feed"
requests with the "text/event-stream"
content-type,
with a Flux<Quote>
as the response body. This data is already server by the stock-quotes
application
, so you can use a WebClient
to request the remote web service to retrieve that Flux
.
You should avoid making a request to the stock-quotes service for every browser connecting to
that page — for that, you can use the Flux.share() operator.
|
Create and Configure a WebSocketHandler
WebFlux includes functional reactive WebSocket client and server support.
On the server side there are two main components: WebSocketHandlerAdapter
will handle the incoming
requests by delegating to the configured WebSocketService
and WebSocketHandler
will be responsible
to handle WebSocket session.
Take a look at the code samples in Reactive WebSocket Support documentation
First, create an EchoWebSocketHandler
class; it has to implement WebSocketHandler
.
Now implement handle(WebSocketSession session)
method. The handler echoes the incoming messages with a delay of 1s.
To route requests to that handler, you need to map the above WebSocket handler to a specific URL: here, "/websocket/echo"
.
Create a WebSocketRouter
configuration class (i.e. annotated with @Configuration
) that creates a bean of type HandlerMapping
.
Create one additional bean of type WebSocketHandlerAdapter
which will delegate the processing of
the incoming request to the default WebSocketService
which is HandshakeWebSocketService
.
Now create a WebSocketController
annotated with @Controller and add a method that renders the
websocket.html
template for incoming "GET /websocket"
requests.
Add the following template file to your application:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="description" content="Spring WebFlux Workshop"/>
<meta name="author" content="Violeta Georgieva and Brian Clozel"/>
<title>Spring Trading application</title>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css"/>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css"/>
</head>
<body>
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="/">Spring Trading application</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a href="/">Home</a></li>
<li><a href="/quotes">Quotes</a></li>
<li class="active"><a href="/websocket">Websocket</a></li>
</ul>
</div>
</div>
</nav>
<div class="container wrapper">
<h2>Websocket Echo</h2>
<form class="form-inline">
<div class="form-group">
<input class="form-control" type="text" id="input" value="type something">
<input class="btn btn-default" type="submit" id="button" value="Send"/>
</div>
</form>
<div id="output"></div>
</div>
<script type="text/javascript" src="/webjars/jquery/1.11.1/jquery.min.js"></script>
<script type="text/javascript" src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
<script type="text/javascript">
$(document).ready(function () {
if (!("WebSocket" in window)) WebSocket = MozWebSocket;
var socket = new WebSocket("ws://localhost:8080/websocket/echo");
socket.onopen = function (event) {
var newMessage = document.createElement('p');
newMessage.textContent = "-- CONNECTED";
document.getElementById('output').appendChild(newMessage);
socket.onmessage = function (e) {
var newMessage = document.createElement('p');
newMessage.textContent = "<< SERVER: " + e.data;
document.getElementById('output').appendChild(newMessage);
}
$("#button").click(function (e) {
e.preventDefault();
var message = $("#input").val();
socket.send(message);
var newMessage = document.createElement('p');
newMessage.textContent = ">> CLIENT: " + message;
document.getElementById('output').appendChild(newMessage);
});
}
});
</script>
</body>
</html>
Integration tests with WebSocketClient
WebSocketClient
included in Spring WebFlux can be used to test your WebSocket endpoints.
You can check that your WebSocket endpoint, created in the previous section, is working properly with the following integration test:
package io.spring.workshop.tradingservice.websocket;
import static org.junit.Assert.assertEquals;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.reactive.socket.WebSocketMessage;
import org.springframework.web.reactive.socket.client.StandardWebSocketClient;
import org.springframework.web.reactive.socket.client.WebSocketClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.ReplayProcessor;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class EchoWebSocketHandlerTests {
@LocalServerPort
private String port;
@Test
public void echo() throws Exception {
int count = 4;
Flux<String> input = Flux.range(1, count).map(index -> "msg-" + index);
ReplayProcessor<Object> output = ReplayProcessor.create(count);
WebSocketClient client = new StandardWebSocketClient();
client.execute(getUrl("/websocket/echo"),
session -> session
.send(input.map(session::textMessage))
.thenMany(session.receive().take(count).map(WebSocketMessage::getPayloadAsText))
.subscribeWith(output)
.then())
.block(Duration.ofMillis(5000));
assertEquals(input.collectList().block(Duration.ofMillis(5000)), output.collectList().block(Duration.ofMillis(5000)));
}
protected URI getUrl(String path) throws URISyntaxException {
return new URI("ws://localhost:" + this.port + path);
}
}