/******************************************************************************* * Copyright (c) 2012-2015 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 org.eclipse.che.ide.collections.Array; import org.eclipse.che.ide.collections.Collections; import org.eclipse.che.ide.collections.StringMap; 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 com.google.gwt.core.client.JavaScriptException; import com.google.gwt.http.client.RequestBuilder; import com.google.gwt.user.client.Timer; import com.google.inject.Inject; import com.google.inject.Singleton; import com.google.inject.name.Named; /** * The implementation of {@link MessageBus}. * * @author Artem Zatsarynnyy */ @Singleton public class MessageBusImpl implements MessageBus { /** Period (in milliseconds) to send heartbeat pings. */ private static final int HEARTBEAT_PERIOD = 50 * 1000; /** Period (in milliseconds) between reconnection attempts after connection has been closed. */ private final static int FREQUENTLY_RECONNECTION_PERIOD = 2 * 1000; /** * Period (in milliseconds) between reconnection attempts after all previous * <code>MAX_FREQUENTLY_RECONNECTION_ATTEMPTS</code> attempts is failed. */ private final static int SELDOM_RECONNECTION_PERIOD = 60 * 1000; /** Max. number of attempts to reconnect for every <code>FREQUENTLY_RECONNECTION_PERIOD</code> ms. */ private final static int MAX_FREQUENTLY_RECONNECTION_ATTEMPTS = 5; /** Max. number of attempts to reconnect for every <code>SELDOM_RECONNECTION_PERIOD</code> ms. */ private final static int MAX_SELDOM_RECONNECTION_ATTEMPTS = 5; private static final 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 = 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(MessageBus.class, e); } } } }; private final Message heartbeatMessage; /** Counter of attempts to reconnect. */ private int frequentlyReconnectionAttemptsCounter; /** Timer for reconnecting WebSocket. */ private Timer frequentlyReconnectionTimer = new Timer() { @Override public void run() { if (frequentlyReconnectionAttemptsCounter == MAX_FREQUENTLY_RECONNECTION_ATTEMPTS) { cancel(); seldomReconnectionTimer.scheduleRepeating(SELDOM_RECONNECTION_PERIOD); return; } frequentlyReconnectionAttemptsCounter++; initialize(); } }; /** Counter of attempts to reconnect. */ private int seldomReconnectionAttemptsCounter; /** Timer for reconnecting WebSocket. */ private Timer seldomReconnectionTimer = new Timer() { @Override public void run() { if (seldomReconnectionAttemptsCounter == MAX_SELDOM_RECONNECTION_ATTEMPTS) { cancel(); return; } seldomReconnectionAttemptsCounter++; initialize(); } }; /** Internal {@link WebSocket} instance. */ private WebSocket ws; /** WebSocket server URL. */ private String url; /** Map of the message identifier to the {@link org.eclipse.che.ide.websocket.events.ReplyHandler}. */ private StringMap<RequestCallback> requestCallbackMap = Collections.createStringMap(); private StringMap<ReplyHandler> replyCallbackMap = Collections.createStringMap(); /** Map of the channel to the subscribers. */ private StringMap<Array<MessageHandler>> channelToSubscribersMap = Collections.createStringMap(); private ListenerManager<ConnectionOpenedHandler> connectionOpenedHandlers = ListenerManager.create(); private ListenerManager<ConnectionClosedHandler> connectionClosedHandlers = ListenerManager.create(); private ListenerManager<ConnectionErrorHandler> connectionErrorHandlers = ListenerManager.create(); private WsListener wsListener; private Array<String> messages2send = Collections.createArray(); /** * Creates new {@link MessageBus} instance. * * @param url * WebSocket server URL */ @Inject public MessageBusImpl(@Named("websocketUrl") String url) { this.url = url; MessageBuilder builder = new MessageBuilder(RequestBuilder.POST, null); builder.header("x-everrest-websocket-message-type", "ping"); heartbeatMessage = builder.build(); if (isSupported()) { initialize(); } } /** Initialize the message bus. */ private void initialize() { ws = WebSocket.create(url); wsListener = new WsListener(); ws.setOnMessageHandler(this); ws.setOnOpenHandler(wsListener); ws.setOnCloseHandler(wsListener); ws.setOnErrorHandler(wsListener); // callbackMap.clear(); // channelToSubscribersMap.clear(); } /** * 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 Array<Pair> headers = message.getHeaders(); if (headers != null) { for (int i = 0; i < headers.size(); i++) { Pair header = headers.get(i); 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); Array<MessageHandler> subscribersSet = channelToSubscribersMap.get(channel); if (subscribersSet != null) { for (int i = 0; i < subscribersSet.size(); i++) { MessageHandler handler = subscribersSet.get(i); //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) { Array<Pair> headers = message.getHeaders(); if (headers != null) { for (int i = 0; i < headers.size(); i++) { Pair header = headers.get(i); 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) { 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 { // checkWebSocketConnectionState(); 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); } /** {@inheritDoc} */ @Override public void addOnCloseHandler(ConnectionClosedHandler handler) { connectionClosedHandlers.add(handler); } /** {@inheritDoc} */ @Override public void addOnErrorHandler(ConnectionErrorHandler handler) { connectionErrorHandlers.add(handler); } /** {@inheritDoc} */ @Override public void subscribe(String channel, MessageHandler handler) throws WebSocketException { checkWebSocketConnectionState(); Array<MessageHandler> subscribersSet = channelToSubscribersMap.get(channel); if (subscribersSet != null) { subscribersSet.add(handler); return; } subscribersSet = Collections.createArray(); subscribersSet.add(handler); channelToSubscribersMap.put(channel, subscribersSet); sendSubscribeMessage(channel); } /** {@inheritDoc} */ @Override public void unsubscribe(String channel, MessageHandler handler) throws WebSocketException { checkWebSocketConnectionState(); Array<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); } } /** {@inheritDoc} */ @Override public boolean isHandlerSubscribed(MessageHandler handler, String channel) { Array<MessageHandler> set = channelToSubscribersMap.get(channel); if (set == null) { return false; } return set.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(); frequentlyReconnectionTimer.scheduleRepeating(FREQUENTLY_RECONNECTION_PERIOD); connectionClosedHandlers.dispatch(new ListenerManager.Dispatcher<ConnectionClosedHandler>() { @Override public void dispatch(ConnectionClosedHandler listener) { listener.onClose(event); } }); } @Override public void onError() { connectionErrorHandlers.dispatch(new ListenerManager.Dispatcher<ConnectionErrorHandler>() { @Override public void dispatch(ConnectionErrorHandler listener) { listener.onError(); } }); } @Override public void onOpen() { // If the any timer has been started then stop it. if (frequentlyReconnectionAttemptsCounter > 0) frequentlyReconnectionTimer.cancel(); if (seldomReconnectionAttemptsCounter > 0) seldomReconnectionTimer.cancel(); frequentlyReconnectionAttemptsCounter = 0; seldomReconnectionAttemptsCounter = 0; heartbeatTimer.scheduleRepeating(HEARTBEAT_PERIOD); connectionOpenedHandlers.dispatch(new ListenerManager.Dispatcher<ConnectionOpenedHandler>() { @Override public void dispatch(ConnectionOpenedHandler listener) { listener.onOpen(); } }); try { for (String message : messages2send.asIterable()) { send(message); } messages2send.clear(); } catch (WebSocketException e) { Log.error(MessageBusImpl.class, e); } } } }