📜 ⬆️ ⬇️

Organizing web-based interaction with the Spring application

I will say right away that the standard implementation of such an interaction exists .

However, since this article is a continuation of the topic “Simple Calling of Remote Service Methods in One-Page Applications” , here will be an alternative interaction scheme needed to replace ajax with web-based strategies in the context of the approach (jrspc) described in the above topic.

In the first article, the mechanism for invoking service methods was described using ajax.
')
This article describes how you can implement this mechanism, with the replacement of ajax by web sockets, without changing the code of the business logic of the application.

Such a replacement gives a faster connection (tests at the end), saving server memory, and adds the ability to call client methods from the server.

For the demonstration, a small chat application is written, with source code on a githaba .
for example, which analysis, I will try to explain how the client and server parts of this interaction are implemented.
The application runs on a tomcat 7.042 server.
It supports https and wss (certificate unconfirmed), and does not keep logs on the server.

Server part


The main question that arose when organizing a web-based interaction with the Spring application was the question of how to call Spring components, from its visibility areas that are bound to the http session, from a StreamInbound object returned by the createWebSocketInbound method of the WebSocketServlet class, which is not bound to the session?

To provide the required functionality for invoking the server component methods, we need to somehow access the worker ApplicationContext from the StreamInbound descendant.

If we try to install ApplicationContext in order to use it to get the components we need, in the successor of WebSocketServlet or StreamInbound - we will be disappointed, because it does not initialize, which is absolutely legal.

In order to get access to components from Spring contexts that are related to the http session from the web socket handler, we need to create an object that would be a session spring bean, and which would be stored in a static object of the storage class, which could be accessed would be StreamInbound heir.

This session object (let's call it ClientManager) is created during the installation of the http connection.

Accordingly, the client, before starting to interact with the server via the web socket, must make one http handshake request, as a result of which, he must receive his ClientManager ID.

The result of this query can be sent to the client code in two ways - to insert clientManagerId into the generated generated page, or via ajax request, from a static page (here - the option is implemented via ajax).

This request is processed in the session controller's initializeClientManager method:

@Controller @Scope("session") public class ClientManagerController { @Autowired private ClientManager clientManager; @RequestMapping(value = "/init", method = RequestMethod.POST) @ResponseBody private String initializeClientManager(HttpSession session) { JSONObject result = new JSONObject(); try{ boolean loged = ClientManagersStorage.checkClientManager(clientManager, session) ; result.put("loged", loged); result.put("clientManagerId", clientManager.getId()); }catch(Throwable th){ result.put("error", th.toString()); } return result.toString(); } 


ClientManagersStorage is a repository of our session managers of clients, which has methods for checking the manager for null, creating a new one, adding it to the repository, searching for and deleting.

 public class ClientManagersStorage { final static private Map<String, ClientManager> clientManagers = new ConcurrentHashMap <String, ClientManager>(); public static boolean checkClientManager(ClientManager clientManager, HttpSession session) { ClientManager registeredClientManager = clientManagers.get(clientManager.getId()); if (registeredClientManager == null) { clientManager.setSession(session); addClientManager(clientManager); registeredClientManager = clientManager; } return registeredClientManager.getUser() != null; } ... } 


(the question of managing the life cycle of the session will be discussed a little below)

As you can see, managers are stored in a static map, by the key that is its hashCode, and when the user reloads the page, the same manager is assigned to it.

The ID of this manager is passed to the client in the clientManagerId variable of the response.

After the client has received his manager's ID, he can open a web socket connection by passing his clientManagerId in a single parameter of the request to establish a connection.

The request to open this connection is handled in the createWebSocketInbound method of the WebSocketConnectorServlet class, an implementation of the abstract WebSocketServlet.

 @Override protected StreamInbound createWebSocketInbound(String paramString, HttpServletRequest request) { String clientManagerId = request.getParameter("clientManagerId"); ClientManager clientManager = ClientManagersStorage.findClientManager(clientManagerId); if(clientManager == null){ return new WebSocketConnection(null); } log.debug("new connection"); return new WebSocketConnection(clientManager); } 

in it, the clientManagerId is taken from the request, the ClientManager is located on it, and the WebSocketConnection object (which is StreamInbound) is created, to which the ClientManager is bound.

Since the ClientManager is session, and was created in a “normal” http request, all of the spring bins will be available from it, via the ApllicationContext that is automatically signed into it, which, here, will be initialized correctly).

When opening a new connection with the client, the container invokes the onOpen method of the WebSocketConnection class, in which its associated ClientManager adds this WebSocketConnection to its connection map, according to the object's (hashcode).

  @Override protected void onOpen(WsOutbound outbound) { if(clientManager != null){ clientManager.addConnection(this); } } 


(Support for multiple connections is necessary so that the user can open the application in several windows, each of which will create its own web-based connection.)

By opening a connection, the client can send requests for server-side calls that will be processed in the overridden onTextMessage method of the WebSocketConnection class.
  @Override protected void onTextMessage(CharBuffer message) throws IOException { try { String connectionId = String.valueOf(this.hashCode()); String request = message.toString(); clientManager.handleClientRequest(request, connectionId); } catch (Throwable th) { log.error("in onTextMessage: " + th); } } 


The ClientCanent class handleClientRequest method — processes the request and writes the result to the connection:

 @Autowired private RequestHandler requestHandler; public void handleClientRequest(String request, String connectionId) { log.debug("handleClientRequest request=" + request); log.debug("handleClientRequest user=" + getUser()); /** handleRequest - never throws exceptions ! */ JSONObject response = requestHandler.handleRequest(request, this); String responseJson = response.toString(); CharBuffer buffer = CharBuffer.wrap(responseJson); WebSocketConnection connection = connections.get(connectionId); try { connection.getWsOutbound().writeTextMessage(buffer); } catch (IOException ioe) { log.error("in handleClientRequest: in writeTextMessage: " + ioe); } } 


requestHandler - avtovaerenny component responsible for processing requests.
The ApllicationContext is built into it, with which it finds service objects.

Its handleRequest method, searches for a service component, and calls on it the methods the client needs, just like the processAjaxRequest method from the CommonServiceController class from the previous article.


This is the general pattern of interaction.

Now let's take a closer look at the moment of initialization of the ClientManager http session.

Session has the property to fall off on timeout, which by default is 30 minutes.
To avoid this, set its value to maximum, and invalidate the session when we need it - namely, in two cases: the first case is when someone made the request not from the application, and the second when the client closed the application page.

The first case is handled directly in the initialization method:

  public class ClientManager{ public void setSession(HttpSession session) { /** session will be invalidated at connection removing */ session.setMaxInactiveInterval(Integer.MAX_VALUE);//69.04204112011317 years this.session = session; new Thread(new Runnable() { @Override public void run() { /** Giving time to client, for establish websocket connection. */ try {Thread.sleep(60000);} catch (InterruptedException ignored) {} /** if client not connected via websocket until this time - it is bot */ if (connections.size() == 0) {removeMe();} } }).start(); } private void removeMe() {ClientManagersStorage.removeClientManager(this);} ... } 


and the second is in the onClose method of the WebSocketConnection class:

 public class WebSocketConnection{ @Override protected void onClose(int status) { if(clientManager != null){ clientManager.removeConnection(this); } } ... } public class ClientManager{ public void removeConnection(WebSocketConnection webSocketConnection) { String connectionId = getObjectHash(webSocketConnection); connections.remove(connectionId); if (connections.size() == 0) { log.debug("removeConnection before wait: connections.size()=" + connections.size()); /** may be client just reload page? */ try {Thread.sleep(waitForReloadTime);} catch (Throwable ignored) {} if (connections.size() == 0) { /** no, client leave us (page closed in browser)*/ ClientManagersStorage.removeClientManager(this); log.debug("client " + getId() + " disconnected"); } } } ... } 


The session is invalidated in the removeClientManager method of the ClientManagersStorage class:

 public static void removeClientManager(ClientManager clientManager) { ClientManager removed = clientManagers.remove(clientManager.getId()); if(removed == null){return;} User user = removed.getUser(); if(user != null){ Broadcaster.broadcastCommand("userPanel.setLogedCount", UserService.logedCount.decrementAndGet()); } Broadcaster.broadcastCommand("userPanel.setOnlineCount", ClientManagersStorage.getClientManagersCount()); try { clientManager.getSession().invalidate(); clientManager.setSession(null); } catch (Throwable th) { log.error("at removeClientManager: " + th); } } 

The same method is used to notify users that the number of visitors to the page has changed (the processing of these notifications on the client is described below).

To notify users about events on the server — the Broadcaster class is used, which has two methods: broadcastCommand and sendCommandToUser:

 public class Broadcaster{ public static void broadcastCommand(String method, Object params) { for (ClientManager clientManager : ClientManagersStorage.getClientManagers().values()) { clientManager.sendCommandToClient(method, params); } } public static void sendCommandToUser(Long userId, String method, Object params) { List<ClientManager> userClientManagers = ClientManagersStorage.findUserClientManagers(userId); for(ClientManager clientManager: userClientManagers){ clientManager.sendCommandToClient(method, params); } } } 


The sendCommandToClient method of the ClientManager class works like this:

 public void sendCommandToClient(String method, Object params) { for(WebSocketConnection connection: connections.values()){ sendCommandToClientConnection(connection, method, params); } } private void sendCommandToClientConnection(WebSocketConnection connection, String method, Object params) { JSONObject commandBody = new JSONObject(); if(params == null){params = new JSONObject();} commandBody.put("method", method); commandBody.put("params", params); CharBuffer buffer = CharBuffer.wrap(commandBody.toString()); try { connection.getWsOutbound().writeTextMessage(buffer); } catch (IOException ioe) { log.error("in sendCommandToClient: in writeTextMessage: " + ioe); } } 


On this, with the server part we finish, and move on to the client.

Client part


The client part must implement three functionalities:

the first is a handshake on ajax, to initialize the session client manager, the second is a web-based transport, to send jsrpc requests and receive answers to them, and the third is to call functions on the client, from the server.

The first part is the simplest:

Since we are using Angulyar, for initializing the http session of the ajax request, $ http is used:

  var appName = "jrspc-ws"; var secured = document.location.protocol == "https:" ? "s" : ""; var HttpSessionInitializer = {url: "http"+secured+"://"+ document.location.host +"/"+appName+"/init"}; /** called from root-controller.js after its initialization */ HttpSessionInitializer.init = function($http) { $http.post(this.url, "").success(function(response){ if (response.error) { error(response.error); } else { loged = response.loged; Server.initialize("ws"+secured+"://"+ document.location.host +"/"+appName+"/ws?clientManagerId="+response.clientManagerId); if(loged){Listeners.notify("onLogin");} } }).error(function() {error("network error!");}); } 


On the server, this request is processed in the initializeClientManager method of the ClientManagerController class, the code of which is given above, in the description of the server part.

Initialization of the socket connection occurs in the Server.initialize function:

  connector.initialize = function(url) { connector.url = url; try { connector.connect(url); return true; } catch (ex) { p("in connector.initialize: " + ex); return false; } } 


connector is an internal Server object that is responsible for the web socket connection (its full code is in the ws-connector.js file)

The code from ws-connector.js that is responsible for generating the jrspc request:

  Server.socketRequests = {}; var requestId = 0; function sendSocket(service, method, params, successCallback, errorCallback, control) { if (!checkSocket()) {return;} requestId++; if(!params){params = [];} if(!isArray(params)){params = [params];} var data = { service : service, method : method, params : params, requestId : requestId }; Server.socketRequests["request_" + requestId] = { successCallback : successCallback, errorCallback : errorCallback, control : control }; if (control) {control.disabled = true;} var message = JSON.stringify(data); log("sendSocket: "+message); connector.socket.send(message); } ... Server.call = sendSocket; 



Code from ws-connector.js, which is responsible for processing responses to requests, and processing server commands:

  connector.socket.onmessage = function(message) { var data = message.data; var response = JSON.parse(data); var requestId = response.requestId; if (requestId) {/** server return response */ var control = Server.socketRequests["request_" + requestId].control; if (control) {control.disabled = false;} if (response.error) { var errorCallback = Server.socketRequests["request_" + requestId].errorCallback; if (errorCallback) { try { errorCallback(response.error); } catch (ex) { error("in connector.socket.onmessage errorCallback: " + ex + ", data=" + data); } }else{ error(response.error); } } else { var successCallback = Server.socketRequests["request_" + requestId].successCallback; if (successCallback) { try { successCallback(response.result); } catch (ex) { error("in connector.socket.onmessage successCallback: " + ex + ", data=" + data); } } } delete Server.socketRequests["request_" + requestId]; } else { /** server call client or broadcast */ var method = eval(response.method); var params = response.params; try { method(params); } catch (ex) { error("in connector.socket.onmessage call method: " + ex + ", data=" + data); } } }; 


The application of the framework described above allows you to implement all the business logic responsible for the chat functionality - in two functions on the client ( chat-controller.js ):

  self.sendMessage = function(command){ var message = {to: (self.sendPrivate ? self.privateTo : "all"), from: userPanel.user.login, text: self.newMessage, clientTime: new Date().getTime()}; Server.call("chatService", "dispatchMessage", message, function(){ self.newMessage = ""; self.$digest(); }, function(error){self.onError(error);}, command); } /** called from server */ self.onChatMessage = function (message){ message.isPrivate = (message.to != "all"); self.messages.push(message); self.$digest(); chatConsole.scrollTop = chatConsole.clientHeight + chatConsole.scrollHeight; } 


and one server method:

  @Component public class ChatService extends AbstractService{ @Autowired private UserManager userManager; @Secured("User") @Remote public void dispatchMessage(ChatMessage message){ message.setServerTime(new Date().getTime()); String to = message.getTo(); if("ALL".equalsIgnoreCase(to)){ Broadcaster.broadcastCommand("chatPanel.onChatMessage", message); }else{ User fromUser = getUser(); message.setFrom(fromUser.getLogin()); User toUser = userManager.findByLogin(to); if(toUser == null){throw new RuntimeException("User "+to+" not found!");} Broadcaster.sendCommandToUser(toUser.getId(), "chatPanel.onChatMessage", message); Broadcaster.sendCommandToUser(fromUser.getId(), "chatPanel.onChatMessage", message); } } } 

Speed ​​tests, with serial and parallel sending of 1000 requests, for ajax and websockets:

consistently: ajax (3474, 3380, 3377) ws (1299, 1113, 1054)
in parallel: ajax (1502, 1515, 1469) ws (616, 637, 632)

test code


  function testController($scope){ var self = $scope; self.maxIterations = 1000; self.testIterations = self.maxIterations; self.testStart = 0; self.testEnd = 0; self.testForSpeedSerial = function(command){ if(self.testStart == 0){self.testStart = now();} if(--self.testIterations <= 0){ var duration = now() - self.testStart; alert("testForSpeedSerial duration="+duration); self.testStart = 0; self.testIterations = self.maxIterations; return; } Server.call("userService", "testForSpeed", "", function(){ self.testForSpeedSerial(command); }, error, command); } self.testForSpeedParallelResponses = 0; self.testForSpeedParallel = function(command){ self.testStart = now(); for(var i = 0; i < self.testIterations; i++){ Server.call("userService", "testForSpeed", "", function(){ self.testForSpeedParallelResponses++ ; if(self.testForSpeedParallelResponses >= self.maxIterations){ var duration = now() - self.testStart; alert("testForSpeedParallel duration="+duration); self.testForSpeedParallelResponses = 0; } }, error, command); } } } 

server testForSpeed ​​method:

  @Remote public void testForSpeed(){} 



All critical comments and indications of errors will be greatly appreciated.

Source: https://habr.com/ru/post/220015/


All Articles