To summarize, so far we have seen how to broadcast STOMP messages to StockJS clients, how to stack messages in an external multi-protocol broker, and how to interact with this broker (RabbitMQ) in the Spring ecosystem.
This recipe is about implementing dedicated queues, no longer topics (broadcast), so that users can receive real-time updates related to the specific content they are viewing. It is also a demonstration of how SockJS clients can send data to their private queues.
For private queues, we had to secure messages and queue accesses. We have broken down our stateless rule of thumb for the API to make use of Spring Session. This extends the authentication performed by cloudstreetmarket-api
and reuses the Spring Security context within cloudstreetmarket-websocket
.
Because the v8.2.x
branch introduced the new cloudstreetmarket-websocket
web app, the Apache HTTP proxy configuration needs to be updated to fully support our WebSocket implementation. Our VirtualHost
definition is now:
<VirtualHost cloudstreetmarket.com:80> ProxyPass /portal http://localhost:8080/portal ProxyPassReverse /portal http://localhost:8080/portal ProxyPass /api http://localhost:8080/api ProxyPassReverse /api http://localhost:8080/api ProxyPass /ws http://localhost:8080/ws ProxyPassReverse /ws http://localhost:8080/ws RewriteEngine on RewriteCond %{HTTP:UPGRADE} ^WebSocket$ [NC] RewriteCond %{HTTP:CONNECTION} ^Upgrade$ [NC] RewriteRule .* ws://localhost:8080%{REQUEST_URI} [P] RedirectMatch ^/$ /portal/index </VirtualHost>
tar.gz
. Follow the instructions on the page to install it (unpackage it, uncompress it, and build it with the make command).Once installed, for a quick start, run Redis with:
$ src/redis-server
README.md
page. Running Microsoft's native port of Redis allows you to run Redis without any other third-party installations.To quickly start Redis server, run the following command:
$ redis-server.exe redis.windows.conf
Make sure the Use Tomcat installation radio button is checked.
You can download them respectively from http://central.maven.org/maven2/redis/clients/jedis/2.5.2/jedis-2.5.2.jar and http://central.maven.org/maven2/org/apache/commons/commons-pool2/2.2/commons-pool2-2.2.jar
You can also find these jars in the chapter_8/libs
directory.
chapter_8/libs
directory, you will also find the tomcat-redis-session-manager-2.0-tomcat-8.jar archive. Copy the three jars tomcat-redis-session-manager-2.0-tomcat-8.jar
, commons-pool2-2.2.jar
, and jedis-2.5.2.jar
into the lib
directory of your local Tomcat installation that Eclipse is referring to. This should be C: omcat8lib or /home/usr/{system.username}/tomcat8/lib
if our instructions have been followed in Chapter 1, Setup Routine for an Enterprise Spring Application.Valve
configuration:<Valve asyncSupported="true" className="edu.zipcloud.catalina.session.RedisSessionHandlerValve"/> <Manager className="edu.zipcloud.catalina.session.RedisSessionManager" host="localhost" port="6379" database="0" maxInactiveInterval="60"/>
While creating the new cloudstreetmarket-websocket
web app, we have also changed the database engine from HSQLDB to MySQL. Doing so has allowed us to share the database between the api
and websocket
modules.
We are now going to define a common configuration for schema users and a database name.
csm_tech
and needs to have the password csmDB1$55
:mysql.exe
in the MySQL servers installation directory:MySQL Server 5.6inmysql.exe
mysql
command from the terminalOn both platforms, the first step is then to provide the root password chosen earlier.
csm
database either with the MySQL workbench or with MySQL client:mysql> CREATE DATABASE csm;
csm
database as the current database:mysql> USE csm;
mysql> csm < <home-directory>cloudstreetmarket-parentcloudstreetmarket-coresrcmain esourcesMETA-INFdbcurrency_exchange.sql; mysql> csm < <home-directory>cloudstreetmarket-parentcloudstreetmarket-coresrcmain esourcesMETA-INFdbinit.sql; mysql> csm < <home-directory>cloudstreetmarket-parentcloudstreetmarket-coresrcmain esourcesMETA-INFdbstocks.sql; mysql> csm < <home-directory>cloudstreetmarket-parentcloudstreetmarket-coresrcmain esourcesMETA-INFdbindices.sql;
cloudstreetmarket-api
and cloudstreetmarket-websocket
, the following filter has been added to the web.xml
files. This filter has to be positioned before the Spring Security chain definition:<filter> <filter-name>springSessionRepositoryFilter</filter-name> <filter-class> org.springframework.web.filter.DelegatingFilterProxy </filter-class> <async-supported>true</async-supported> </filter> <filter-mapping> <filter-name>springSessionRepositoryFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
cloudstreetmarket-api
:<!-- Spring Session --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session</artifactId> <version>1.0.2.RELEASE</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.2</version> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> <version>1.0.2.RELEASE</version> </dependency> <!-- Spring Security --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-messaging</artifactId> <version>4.0.2.RELEASE</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.4</version> </dependency>
cloudstreetmarket-api
again, security-config.xml
has been updated to reflect the following changes in the Spring Security filter chain:<security:http create-session="ifRequired" authentication-manager-ref="authenticationManager" entry-point-ref="authenticationEntryPoint"> <security:custom-filter ref="basicAuthenticationFilter" after="BASIC_AUTH_FILTER" /> <security:csrf disabled="true"/> <security:intercept-url pattern="/oauth2/**" access="permitAll"/> <security:intercept-url pattern="/basic.html" access="hasRole('ROLE_BASIC')"/> <security:intercept-url pattern="/**" access="permitAll"/> <security:session-management session-authentication-strategy-ref="sas"/> </security:http> <bean id="sas" class="org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy" />
security-config.xml
file, as well as the security-config.xml
file in cloudstreetmarket-websocket
now define three extra beans:<bean class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory" p:port="6379"/> <bean class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration"/> <bean class="edu.zipcloud.cloudstreetmarket.core.util.RootPath CookieHttpSessionStrategy"/>
cloudstreetmarket-webapp
not to create sessions. We wanted sessions to be created only in the cloudstreetmarket-api
. We have achieved this with adding the following configuration to the web.xml
file in cloudstreetmarket-webapp
:<session-config> <session-timeout>1</session-timeout> <cookie-config> <max-age>0</max-age> </cookie-config> </session-config>
cloudstreetmarket-websocket
has the following configuration:<bean id="securityContextPersistenceFilter" class="org.springframework.security.web.context.SecurityContextPersistenceFilter"/> <security:http create-session="never" authentication-manager-ref="authenticationManager" entry-point-ref="authenticationEntryPoint"> <security:custom-filter ref="securityContextPersistenceFilter" before="FORM_LOGIN_FILTER" /> <security:csrf disabled="true"/> <security:intercept-url pattern="/channels/private/**" access="hasRole('OAUTH2')"/> <security:headers> <security:frame-options policy="SAMEORIGIN" /> </security:headers> </security:http> <security:global-method-security secured-annotations="enabled" pre-post-annotations="enabled" authentication-manager-ref="authenticationManager"/>
cloudstreetmarket-websocket
complete the XML configuration:The WebSocketConfig bean in edu.zipcloud.cloudstreetmarket.ws.config is defined as follows:
@EnableScheduling @EnableAsync @EnableRabbit @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig extends AbstractSessionWebSocketMessageBrokerConfigurer<Expiring Session> { @Override protected void configureStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/channels/users/broadcast") .setAllowedOrigins(protocol.concat(realmName)) .withSockJS() .setClientLibraryUrl( Constants.SOCKJS_CLIENT_LIB); registry.addEndpoint("/channels/private") .setAllowedOrigins(protocol.concat(realmName)) .withSockJS() .setClientLibraryUrl( Constants.SOCKJS_CLIENT_LIB); } @Override public void configureMessageBroker(final MessageBrokerRegistry registry) { registry.enableStompBrokerRelay("/topic", "/queue"); registry.setApplicationDestinationPrefixes("/app"); } @Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.taskExecutor() corePoolSize(Runtime.getRuntime().availableProcessors() *4); } @Override //Increase number of threads for slow clients public void configureClientOutboundChannel( ChannelRegistration registration) { registration.taskExecutor().corePoolSize( Runtime.getRuntime().availableProcessors() *4); } @Override public void configureWebSocketTransport( WebSocketTransportRegistration registration) { registration.setSendTimeLimit(15*1000) .setSendBufferSizeLimit(512*1024); } }
The WebSocketSecurityConfig
bean in edu.zipcloud.cloudstreetmarket.ws.config
is defined as follows:
@Configuration public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer { @Override protected void configureInbound( MessageSecurityMetadataSourceRegistry messages) { messages.simpMessageDestMatchers("/topic/actions", "/queue/*", "/app/queue/*").permitAll(); } @Override protected boolean sameOriginDisabled() { return true; } }
ActivityFeedWSController
class has been copied over to cloudstreetmarket-websocket
to broadcast user activities. It still doesn't require any specific role or authentication:@RestController public class ActivityFeedWSController extends CloudstreetWebSocketWCI{ @MessageMapping("/channels/users/broadcast") @SendTo("/topic/actions") public UserActivityDTO handle(UserActivityDTO message) throws Exception { return message; } @RequestMapping(value="/channels/users/broadcast/info", produces={"application/json"}) @ResponseBody public String info(HttpServletRequest request) { return "v0"; } }
@RestController public class StockProductWSController extends CloudstreetWebSocketWCI<StockProduct>{ @Autowired private StockProductServiceOffline stockProductService; @MessageMapping("/queue/CSM_QUEUE_{queueId}") @SendTo("/queue/CSM_QUEUE_{queueId}") @PreAuthorize("hasRole('OAUTH2')") public List<StockProduct> sendContent(@Payload List<String> tickers, @DestinationVariable("queueId") String queueId) throws Exception { String username = extractUserFromQueueId(queueId); if(!getPrincipal().getUsername().equals(username)){ throw new IllegalAccessError("/queue/CSM_QUEUE_"+queueId); } return stockProductService.gather(username, tickers.toArray(new String[tickers.size()])); } @RequestMapping(value=PRIVATE_STOCKS_ENDPOINT+"/info", produces={"application/xml", "application/json"}) @ResponseBody @PreAuthorize("hasRole('OAUTH2')") public String info(HttpServletRequest request) { return "v0"; } private static String extractUserFromQueueId(String token){ Pattern p = Pattern.compile("_[0-9]+$"); Matcher m = p.matcher(token); String sessionNumber = m.find() ? m.group() : ""; return token.replaceAll(sessionNumber, ""); } }
stock_search.js
and stock_search_by_market.js
, the following block has been added in order to regularly request data updates for the set of results that is displayed to the authenticated user:if(httpAuth.isUserAuthenticated()){ window.socket = new SockJS('/ws/channels/private'); window.stompClient = Stomp.over($scope.socket); var queueId = httpAuth.generatedQueueId(); window.socket.onclose = function() { window.stompClient.disconnect(); }; window.stompClient.connect({}, function(frame) { var intervalPromise = $interval(function() { window.stompClient.send( '/app/queue/CSM_QUEUE_'+queueId, {}, JSON.stringify($scope.tickers)); }, 5000); $scope.$on( "$destroy", function( event ) { $interval.cancel(intervalPromise); window.stompClient.disconnect(); } ); window.stompClient.subscribe('/queue/CSM_QUEUE_'+queueId, function(message){ var freshStocks = JSON.parse(message.body); $scope.stocks.forEach(function(existingStock) { //Here we update the currently displayed stocks }); $scope.$apply(); dynStockSearchService.fadeOutAnim(); //CSS animation //(green/red backgrounds…) }); }); };
The httpAuth.generatedQueueId()
function generates a random queue name based on the authenticated username (see http_authorized.js
for more details).
This policy (named PRIVATE
) applies to all auto-generated queues matching the pattern CSM_QUEUE_*
, with an auto-expiration of 24 hours.
Redis is an open source in-memory data-structure store. Day-after-day, it is becoming increasingly popular as a NoSQL database and as a key-value store.
Its ability to store keys with optional expiration times and with very high availability (over its remarkable cluster) makes it a very solid underlying technology for session manager implementations. This is precisely the use we make of it through Spring Session.
Spring Session is a relatively new Spring project, but it is meant to grow up and take a substantial space in the Spring ecosystem, especially with the recent Microservices and IoT trends. The project is managed by Rob Winch at Pivotal inc. As introduced previously, Spring Session provides an API to manage users' sessions from different Spring components.
The most interesting and notable feature of Spring Session is its ability to integrate with the container (Apache Tomcat) to supply a custom implementation of HttpSession
.
To make use of a custom HttpSession
implementation, Spring Session completely replaces the HttpServletRequest
with a custom wrapper (SessionRepositoryRequestWrapper
). This operation is performed inside SessionRepositoryFilter
, which is the servlet filter that needs to be configured in the web.xml
to intercept the request flow (before Spring MVC).
To do its job, the SessionRepositoryFilter
must have an HttpSession
implementation. At some point, we registered the RedisHttpSessionConfiguration
bean. This bean defines a couple of other beans, and among them is a sessionRepository
, which is a RedisOperationsSessionRepository
.
See how the SessionRepositoryFilter
is important for bridging across the application all the performed session operations to the actual engine implementation that will execute those operations.
A
RedisConnectionFactory
implementation is necessary in order to produce a suitable connection to Redis. Selecting a RedisConnectionFactory
implementation, we have been following the Spring team's choice which appeared to be the JedisConnectionFactory
. This RedisConnectionFactory
relies on Jedis (a lightweight Redis Java client). https://github.com/xetorthio/jedis.
We have registered an HttpSessionStrategy
implementation: RootPathCookieHttpSessionStrategy
. This class is a customized version (in our codebase) of the Spring CookieHttpSessionStrategy
.
Because we wanted to pass the cookie from cloudstreetmarket-api
to cloudstreetmarket-websocket
, the cookie path (which is a property of a cookie) needed to be set to the root path (and not the servlet context path). Spring Session 1.1+ should offer a configurable path feature.
https://github.com/spring-projects/spring-session/issues/155
For now, our RootPathCookieHttpSessionStrategy
(basically CookieHttpSessionStrategy
) produces and expects cookies with a SESSION name:
Currently, only cloudstreetmarket-api
produces such cookies (the two other web apps have been restricted in their cookie generation capabilities so they don't mess up our sessions).
Do you remember our good friend Spring Data JPA? Well now, Spring Data Redis follows a similar purpose but for the Redis NoSQL key-value store:
Spring Session Data Redis is the Spring module that specifically implements Spring Data Redis for the purpose of Spring Session management.
Apache Tomcat natively provides clustering and session-replication features. However, these features rely on load balancers sticky sessions. Sticky sessions have pros and cons for scalability. As cons, we can remember that sessions can be lost when servers go down. Also the stickiness of sessions can induce a slow loading time when we actually need to respond to a surge of traffic.
We have also been using an open source project from James Coleman that allows a Tomcat servers to store non-sticky sessions in Redis immediately on session creation for potential uses by other Tomcat instances. This open source project can be reached at the following address:
https://github.com/jcoleman/tomcat-redis-session-manager
However, this project doesn't officially support Tomcat 8. Thus, another fork went further in the Tomcat Release process and is closer from the Tomcat 8 requirements:
https://github.com/rmohr/tomcat-redis-session-manager
We forked this repository and provided an adaptation for Tomcat 8 in https://github.com/alex-bretet/tomcat-redis-session-manager.
The
tomcat-redis-session-manager-2.0-tomcat-8.jar
copied to tomcat/lib
comes from this repository.
In the main installation directory for Redis, an executable for a command line tool (Cli
) can be found. This executable can be launched from the command:
$ src/redis-cli
Or:
$ redis-cli.exe
This executable gives access to the Redis console. From there, for example, the KEY *
command lists all the active sessions:
127.0.0.1:6379> keys * 1) "spring:session:sessions:4fc39ce3-63b3-4e17-b1c4-5e1ed96fb021" 2) "spring:session:expirations:1418772300000"
The FLUSHALL
command clears all the active sessions:
redis 127.0.0.1:6379> FLUSHALL OK
Discover the Redis client language with their online tutorial accessible at http://try.redis.io.
We make use of this filter in the cloudstreetmarket-websocket
Spring Security filter chain. Its role consists of injecting an external Spring Security context into a SecurityContextHolder
from the configured SecurityContextRepository
:
<bean id="securityContextPersistenceFilter" class="org.sfw.security.web.context.SecurityContextPersistence Filter"> <constructor-arg name="repo" ref="httpSessionSecurityContextRepo" /> </bean> <bean id="httpSessionSecurityContextRepo" class='org.sfw.security.web.context.HttpSessionSecurityContext Repository'>operty name='allowSessionCreation' value='false' /> </bean>
This filter interacts with SecurityContextRepository
to persist the context once the filter chain has been completed. Combined with Spring Session, this filter is very useful when you need to reuse an authentication that has been performed in another component (another web app in our case).
After this point, we have also been able to declare a global-method-security
element (of the Spring Security namespace) that allows us to make use of @PreAuthorize
annotations in @MessageMapping
annotated methods (our message handling methods)::
<global-method-security secured-annotations="enabled" pre-post-annotations="enabled" />
Once again, we recommend the Spring reference document on Spring Session, which is very well done. Please check it out:
http://docs.spring.io/spring-session/docs/current/reference/html5
The few lines added to httpd.conf
serve the purpose of rewriting the WebSocket scheme to ws
during the WebSocket handshake. Not doing this causes SockJS to fall back to its XHR options (one WebSocket emulation).
Also, we recommend that you read more about the Spring Data Redis project (in its reference document):
http://docs.spring.io/spring-data/data-redis/docs/current/reference/html
http://www.slideshare.net/sergialmar/websockets-with-spring-4