/******************************************************************************* * Copyright (c) 2012-2017 Codenvy, S.A. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Codenvy, S.A. - initial API and implementation *******************************************************************************/ package org.eclipse.che.ide.websocket; import com.google.gwt.core.client.JavaScriptException; import com.google.gwt.http.client.RequestBuilder; import com.google.gwt.user.client.Timer; import com.google.gwt.user.client.rpc.AsyncCallback; import org.eclipse.che.ide.rest.HTTPHeader; import org.eclipse.che.ide.util.ListenerManager; import org.eclipse.che.ide.util.loging.Log; import org.eclipse.che.ide.websocket.events.ConnectionClosedHandler; import org.eclipse.che.ide.websocket.events.ConnectionErrorHandler; import org.eclipse.che.ide.websocket.events.ConnectionOpenedHandler; import org.eclipse.che.ide.websocket.events.MessageHandler; import org.eclipse.che.ide.websocket.events.MessageReceivedEvent; import org.eclipse.che.ide.websocket.events.ReplyHandler; import org.eclipse.che.ide.websocket.events.WebSocketClosedEvent; import org.eclipse.che.ide.websocket.rest.Pair; import org.eclipse.che.ide.websocket.rest.RequestCallback; import org.eclipse.che.ide.websocket.rest.SubscriptionHandler; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * @author Dmitry Shnurenko */ abstract class AbstractMessageBus implements MessageBus { /** Period (in milliseconds) to send heartbeat pings. */ private final static int HEARTBEAT_PERIOD = 50 * 1000; /** Period (in milliseconds) between reconnection attempts after connection has been closed. */ private final static int RECONNECTION_PERIOD = 2 * 1000; /** Max. number of attempts to reconnect for every <code>RECONNECTION_PERIOD</code> ms. */ private final static int MAX_RECONNECTION_ATTEMPTS = 5; private final static String MESSAGE_TYPE_HEADER_NAME = "x-everrest-websocket-message-type"; /** Timer for sending heartbeat pings to prevent autoclosing an idle WebSocket connection. */ private final Timer heartbeatTimer; /** Timer for reconnecting WebSocket. */ private final Timer reconnectionTimer; private final Message heartbeatMessage; private final String wsConnectionUrl; private final List<String> messages2send; /** Map of the message identifier to the {@link org.eclipse.che.ide.websocket.events.ReplyHandler}. */ private final Map<String, RequestCallback> requestCallbackMap; private final Map<String, ReplyHandler> replyCallbackMap; /** Map of the channel to the subscribers. */ private final Map<String, List<MessageHandler>> channelToSubscribersMap; private final ListenerManager<ConnectionOpenedHandler> connectionOpenedHandlers; private final ListenerManager<ConnectionClosedHandler> connectionClosedHandlers; private final ListenerManager<ConnectionErrorHandler> connectionErrorHandlers; private AsyncCallback reconnectionCallback; /** Counter of attempts to reconnect. */ private int reconnectionAttemptsCounter; private WebSocket ws; private WsListener wsListener; public AbstractMessageBus(String wsConnectionUrl) { this.wsConnectionUrl = wsConnectionUrl; this.requestCallbackMap = new HashMap<>(); this.replyCallbackMap = new HashMap<>(); this.channelToSubscribersMap = new HashMap<>(); this.connectionOpenedHandlers = ListenerManager.create(); this.connectionClosedHandlers = ListenerManager.create(); this.connectionErrorHandlers = ListenerManager.create(); this.messages2send = new ArrayList<>(); MessageBuilder builder = new MessageBuilder(RequestBuilder.POST, null); builder.header("x-everrest-websocket-message-type", "ping"); heartbeatMessage = builder.build(); if (isSupported()) { initialize(); } this.heartbeatTimer = new Timer() { @Override public void run() { Message message = getHeartbeatMessage(); try { send(message, null); } catch (WebSocketException e) { if (getReadyState() == ReadyState.CLOSED) { wsListener.onClose(new WebSocketClosedEvent()); } else { Log.error(AbstractMessageBus.class, e); } } } }; this.reconnectionTimer = new Timer() { @Override public void run() { if (reconnectionAttemptsCounter == MAX_RECONNECTION_ATTEMPTS) { cancel(); reconnectionCallback.onFailure(new Exception("The maximum number of reconnection attempts has been reached")); return; } reconnectionAttemptsCounter++; initialize(); } }; } public void cancelReconnection() { reconnectionAttemptsCounter = MAX_RECONNECTION_ATTEMPTS; reconnectionTimer.cancel(); } private void initialize() { ws = WebSocket.create(wsConnectionUrl); wsListener = new WsListener(); ws.setOnMessageHandler(this); ws.setOnOpenHandler(wsListener); ws.setOnCloseHandler(wsListener); ws.setOnErrorHandler(wsListener); } private void handleConnectionClosure(final WebSocketClosedEvent event) { connectionClosedHandlers.dispatch(new ListenerManager.Dispatcher<ConnectionClosedHandler>() { @Override public void dispatch(ConnectionClosedHandler listener) { listener.onClose(event); } }); } private void handleErrorConnection() { connectionErrorHandlers.dispatch(new ListenerManager.Dispatcher<ConnectionErrorHandler>() { @Override public void dispatch(ConnectionErrorHandler listener) { listener.onError(); } }); } /** * Checks if the browser has support for WebSockets. * * @return <code>true</code> if WebSocket is supported; * <code>false</code> if it's not */ private boolean isSupported() { return WebSocket.isSupported(); } /** {@inheritDoc} */ @Override public ReadyState getReadyState() { if (ws == null) { return ReadyState.CLOSED; } switch (ws.getReadyState()) { case 0: return ReadyState.CONNECTING; case 1: return ReadyState.OPEN; case 2: return ReadyState.CLOSING; case 3: return ReadyState.CLOSED; default: return ReadyState.CLOSED; } } /** {@inheritDoc} */ @Override public void onMessageReceived(MessageReceivedEvent event) { Message message = parseMessage(event.getMessage()); // http code 202 is "Accepted": The request has been accepted for processing, // but the processing has not been completed. // At this point, we ignore this code, since the request might or might not eventually be acted upon, // as it might be disallowed when processing actually takes place. if (message.getResponseCode() == 202) { return; } //TODO Should be revised to remove List<Pair> headers = message.getHeaders().toList(); if (headers != null) { for (Pair header : headers) { if (HTTPHeader.LOCATION.equals(header.getName()) && header.getValue().contains("async/")) { return; } } } if (getChannel(message) != null) { // this is a message received by subscription processSubscriptionMessage(message); } else { String uuid = message.getStringField(MessageBuilder.UUID_FIELD); ReplyHandler replyCallback = replyCallbackMap.remove(uuid); if (replyCallback != null) { replyCallback.onReply(message.getBody()); } else { RequestCallback requestCallback = requestCallbackMap.remove(uuid); if (requestCallback != null) { requestCallback.onReply(message); } } } } /** * Process the {@link Message} that received by subscription. * * @param message * {@link Message} */ private void processSubscriptionMessage(Message message) { String channel = getChannel(message); List<MessageHandler> subscribersSet = channelToSubscribersMap.get(channel); if (subscribersSet != null) { for (MessageHandler handler : subscribersSet) { //TODO this is nasty, need refactor this if (handler instanceof SubscriptionHandler) { ((SubscriptionHandler)handler).onMessage(message); } else { handler.onMessage(message.getBody()); } } } } /** * Parse text message to {@link Message} object. * * @param message * text message * @return {@link Message} */ private Message parseMessage(String message) { return Message.deserialize(message); } /** * Get message for heartbeat request * * @return {@link Message} */ private Message getHeartbeatMessage() { return heartbeatMessage; } /** * Get channel from which {@link Message} was received. * * @param message * {@link Message} * @return channel identifier or <code>null</code> if message is invalid. */ private String getChannel(Message message) { List<Pair> headers = message.getHeaders().toList(); if (headers != null) { for (Pair header : headers) { if ("x-everrest-websocket-channel".equals(header.getName())) { return header.getValue(); } } } return null; } /** {@inheritDoc} */ @Override public void send(Message message, RequestCallback callback) throws WebSocketException { checkWebSocketConnectionState(); final String uuid = message.getStringField(MessageBuilder.UUID_FIELD); internalSend(uuid, message.serialize(), callback); if (callback != null) { if (callback.getLoader() != null) { callback.getLoader().show(); } if (callback.getStatusHandler() != null) { callback.getStatusHandler().requestInProgress(uuid); } } } /** * Send text message. * * @param uuid * a message identifier * @param message * message to send * @param callback * callback for receiving reply to message * @throws WebSocketException * throws if an any error has occurred while sending data */ private void internalSend(String uuid, String message, RequestCallback callback) throws WebSocketException { checkWebSocketConnectionState(); if (callback != null) { requestCallbackMap.put(uuid, callback); } send(message); } /** * Transmit text data over WebSocket. * * @param message * text message * @throws WebSocketException * throws if an any error has occurred while sending data, * e.g.: WebSocket is not supported by browser, WebSocket connection is not opened */ private void send(String message) throws WebSocketException { if (getReadyState() != ReadyState.OPEN) { messages2send.add(message); return; } try { ws.send(message); } catch (JavaScriptException e) { throw new WebSocketException(e.getMessage(), e); } } /** {@inheritDoc} */ @Override public void send(String address, String message) throws WebSocketException { send(address, message, null); } /** {@inheritDoc} */ @Override public void send(String address, String message, ReplyHandler replyHandler) throws WebSocketException { checkWebSocketConnectionState(); MessageBuilder builder = new MessageBuilder(RequestBuilder.POST, address); builder.header("content-type", "application/json") .data(message); Message requestMessage = builder.build(); String textMessage = requestMessage.serialize(); String uuid = requestMessage.getStringField(MessageBuilder.UUID_FIELD); internalSend(uuid, textMessage, replyHandler); } /** * Send text message. * * @param uuid * a message identifier * @param message * message to send * @param callback * callback for receiving reply to message * @throws WebSocketException * throws if an any error has occurred while sending data */ private void internalSend(String uuid, String message, ReplyHandler callback) throws WebSocketException { checkWebSocketConnectionState(); if (callback != null) { replyCallbackMap.put(uuid, callback); } send(message); } /** * Send message with subscription info. * * @param channel * channel identifier * @throws WebSocketException * throws if an any error has occurred while sending data */ private void sendSubscribeMessage(String channel) throws WebSocketException { MessageBuilder builder = new MessageBuilder(RequestBuilder.POST, null); builder.header(MESSAGE_TYPE_HEADER_NAME, "subscribe-channel") .data("{\"channel\":\"" + channel + "\"}"); Message message = builder.build(); send(message, null); } /** * Send message with unsubscription info. * * @param channel * channel identifier * @throws WebSocketException * throws if an any error has occurred while sending data */ private void sendUnsubscribeMessage(String channel) throws WebSocketException { MessageBuilder builder = new MessageBuilder(RequestBuilder.POST, null); builder.header(MESSAGE_TYPE_HEADER_NAME, "unsubscribe-channel") .data("{\"channel\":\"" + channel + "\"}"); Message message = builder.build(); send(message, null); } /** {@inheritDoc} */ @Override public void addOnOpenHandler(ConnectionOpenedHandler handler) { connectionOpenedHandlers.add(handler); } @Override public void removeOnOpenHandler(ConnectionOpenedHandler handler) { connectionOpenedHandlers.remove(handler); } /** {@inheritDoc} */ @Override public void addOnCloseHandler(ConnectionClosedHandler handler) { connectionClosedHandlers.add(handler); } @Override public void removeOnCloseHandler(ConnectionClosedHandler handler) { connectionClosedHandlers.remove(handler); } /** {@inheritDoc} */ @Override public void addOnErrorHandler(ConnectionErrorHandler handler) { connectionErrorHandlers.add(handler); } /** {@inheritDoc} */ @Override public void subscribe(String channel, MessageHandler handler) throws WebSocketException { checkWebSocketConnectionState(); List<MessageHandler> subscribersSet = channelToSubscribersMap.get(channel); if (subscribersSet != null) { subscribersSet.add(handler); return; } subscribersSet = new ArrayList<>(); subscribersSet.add(handler); channelToSubscribersMap.put(channel, subscribersSet); sendSubscribeMessage(channel); } /** {@inheritDoc} */ @Override public void unsubscribe(String channel, MessageHandler handler) throws WebSocketException { checkWebSocketConnectionState(); List<MessageHandler> subscribersSet = channelToSubscribersMap.get(channel); if (subscribersSet == null) { throw new IllegalArgumentException("Handler not subscribed to any channel."); } if (subscribersSet.remove(handler) && subscribersSet.isEmpty()) { channelToSubscribersMap.remove(channel); sendUnsubscribeMessage(channel); } } @Override public void unsubscribeSilently(String channel, MessageHandler handler) { try { unsubscribe(channel, handler); } catch (WebSocketException e) { Log.error(AbstractMessageBus.class, e); } } /** {@inheritDoc} */ @Override public boolean isHandlerSubscribed(MessageHandler handler, String channel) { List<MessageHandler> messageHandlers = channelToSubscribersMap.get(channel); return messageHandlers != null && messageHandlers.contains(handler); } /** * Check WebSocket connection and throws {@link WebSocketException} if WebSocket connection is not ready to use. * * @throws WebSocketException * throws if WebSocket connection is not ready to use */ private void checkWebSocketConnectionState() throws WebSocketException { if (!isSupported()) { throw new WebSocketException("WebSocket is not supported."); } if (getReadyState() != ReadyState.OPEN) { throw new WebSocketException("WebSocket is not opened."); } } private class WsListener implements ConnectionOpenedHandler, ConnectionClosedHandler, ConnectionErrorHandler { @Override public void onClose(final WebSocketClosedEvent event) { heartbeatTimer.cancel(); reconnectionCallback = new AsyncCallback() { @Override public void onFailure(Throwable caught) { handleConnectionClosure(event); } @Override public void onSuccess(Object result) { } }; reconnectionTimer.schedule(RECONNECTION_PERIOD); } @Override public void onError() { ReadyState state = getReadyState(); if (state != ReadyState.CLOSING && state != ReadyState.CLOSED) { handleErrorConnection(); return; } reconnectionCallback = new AsyncCallback() { @Override public void onFailure(Throwable caught) { handleErrorConnection(); } @Override public void onSuccess(Object result) { } }; reconnectionTimer.schedule(RECONNECTION_PERIOD); } @Override public void onOpen() { // If the any timer has been started then stop it. reconnectionTimer.cancel(); reconnectionAttemptsCounter = 0; heartbeatTimer.scheduleRepeating(HEARTBEAT_PERIOD); connectionOpenedHandlers.dispatch(new ListenerManager.Dispatcher<ConnectionOpenedHandler>() { @Override public void dispatch(ConnectionOpenedHandler listener) { listener.onOpen(); } }); try { for (String message : messages2send) { send(message); } messages2send.clear(); } catch (WebSocketException e) { Log.error(AbstractMessageBus.class, e); } } } }