All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.firebase.database.connection.WebsocketConnection Maven / Gradle / Ivy

/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.firebase.database.connection;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;

import com.google.common.collect.ImmutableList;
import com.google.firebase.database.util.JsonMapper;

import java.io.EOFException;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Represents a WebSocket connection to the Firebase Realtime Database. This abstraction acts as
 * the mediator between low-level IO ({@link WSClient}), and high-level connection management
 * ({@link Connection}). It handles frame buffering, most of the low-level errors, and notifies the
 * higher layer when necessary. Higher layer signals this implementation when it needs to send a
 * message out, or when a graceful connection tear down should be initiated.
 */
class WebsocketConnection {

  private static final long KEEP_ALIVE_TIMEOUT_MS = 45 * 1000; // 45 seconds
  private static final long CONNECT_TIMEOUT_MS = 30 * 1000; // 30 seconds
  private static final int MAX_FRAME_SIZE = 16384;
  private static final AtomicLong CONN_ID = new AtomicLong(0);
  private static final Logger logger = LoggerFactory.getLogger(WebsocketConnection.class);

  private final ScheduledExecutorService executorService;
  private final WSClient conn;
  private final Delegate delegate;
  private final String label;

  private StringList buffer;
  private boolean everConnected = false;
  private boolean isClosed = false;
  private ScheduledFuture keepAlive;
  private ScheduledFuture connectTimeout;

  WebsocketConnection(
      ConnectionContext connectionContext,
      HostInfo hostInfo,
      String optCachedHost,
      Delegate delegate,
      String optLastSessionId) {
    this(connectionContext, delegate,
        new DefaultWSClientFactory(connectionContext, hostInfo, optCachedHost, optLastSessionId));
  }

  WebsocketConnection(
      ConnectionContext connectionContext,
      Delegate delegate,
      WSClientFactory clientFactory) {
    this.executorService = connectionContext.getExecutorService();
    this.delegate = delegate;
    this.label = "[ws_" + CONN_ID.getAndIncrement() + "]";
    this.conn = clientFactory.newClient(new WSClientHandlerImpl());
  }

  void open() {
    conn.connect();
    connectTimeout =
        executorService.schedule(
            new Runnable() {
              @Override
              public void run() {
                closeIfNeverConnected();
              }
            },
            CONNECT_TIMEOUT_MS,
            TimeUnit.MILLISECONDS);
  }

  void start() {
    // No-op in java
  }

  void close() {
    logger.debug("{} Websocket is being closed", label);
    isClosed = true;
    conn.close();

    // Although true is passed for both of these, they each run on the same event loop, so
    // they will never be running.
    if (connectTimeout != null) {
      connectTimeout.cancel(true);
      connectTimeout = null;
    }
    if (keepAlive != null) {
      keepAlive.cancel(true);
      keepAlive = null;
    }
  }

  void send(Map message) {
    resetKeepAlive();
    try {
      String toSend = JsonMapper.serializeJson(message);
      List frames = splitIntoFrames(toSend, MAX_FRAME_SIZE);
      if (frames.size() > 1) {
        conn.send("" + frames.size());
      }

      for (String seg : frames) {
        conn.send(seg);
      }
    } catch (IOException e) {
      logger.error("{} Failed to serialize message: {}", label, message, e);
      closeAndNotify();
    }
  }

  private List splitIntoFrames(String src, int maxFrameSize) {
    if (src.length() <= maxFrameSize) {
      return ImmutableList.of(src);
    } else {
      ImmutableList.Builder frames = ImmutableList.builder();
      for (int i = 0; i < src.length(); i += maxFrameSize) {
        int end = Math.min(i + maxFrameSize, src.length());
        String seg = src.substring(i, end);
        frames.add(seg);
      }
      return frames.build();
    }
  }

  private void handleNewFrameCount(int numFrames) {
    logger.debug("{} Handle new frame count: {}", label, numFrames);
    buffer = new StringList(numFrames);
  }

  private void appendFrame(String message) {
    int framesRemaining = buffer.append(message);
    if (framesRemaining > 0) {
      return;
    }
    // Decode JSON
    String combined = buffer.combine();
    try {
      Map decoded = JsonMapper.parseJson(combined);
      logger.debug("{} Parsed complete frame: {}", label, decoded);
      delegate.onMessage(decoded);
    } catch (IOException e) {
      logger.error("{} Error parsing frame: {}", label, combined, e);
      closeAndNotify();
    } catch (ClassCastException e) {
      logger.error("{} Error parsing frame (cast error): {}", label, combined, e);
      closeAndNotify();
    }
  }

  private String extractFrameCount(String message) {
    // TODO: The server is only supposed to send up to 9999 frames (i.e. length <= 4), but that
    // isn't being enforced currently.  So allowing larger frame counts (length <= 6).
    // See https://app.asana.com/0/search/8688598998380/8237608042508
    if (message.length() <= 6) {
      try {
        int frameCount = Integer.parseInt(message);
        if (frameCount > 0) {
          handleNewFrameCount(frameCount);
        }
        return null;
      } catch (NumberFormatException e) {
        // not a number, default to frame count 1
      }
    }
    handleNewFrameCount(1);
    return message;
  }

  private void handleIncomingFrame(String message) {
    if (isClosed) {
      return;
    }
    resetKeepAlive();
    if (buffer != null && buffer.hasRemaining()) {
      appendFrame(message);
    } else {
      String remaining = extractFrameCount(message);
      if (remaining != null) {
        appendFrame(remaining);
      }
    }
  }

  private void resetKeepAlive() {
    if (isClosed) {
      return;
    }
    if (keepAlive != null) {
      keepAlive.cancel(false);
      logger.debug("{} Reset keepAlive. Remaining: {}", label,
          keepAlive.getDelay(TimeUnit.MILLISECONDS));
    } else {
      logger.debug("{} Reset keepAlive", label);
    }
    keepAlive = executorService.schedule(nop(), KEEP_ALIVE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
  }

  private Runnable nop() {
    return new Runnable() {
      @Override
      public void run() {
        if (conn != null) {
          conn.send("0");
          resetKeepAlive();
        }
      }
    };
  }

  /**
   * Closes the low-level connection, and notifies the higher layer ({@link Connection}.
   */
  private void closeAndNotify() {
    if (!isClosed) {
      close();
      delegate.onDisconnect(everConnected);
    }
  }

  private void onClosed() {
    if (!isClosed) {
      logger.debug("{} Closing itself", label);
      closeAndNotify();
    }
  }

  private void closeIfNeverConnected() {
    if (!everConnected && !isClosed) {
      logger.debug("{} Timed out on connect", label);
      closeAndNotify();
    }
  }

  /**
   * A client handler implementation that gets notified by the low-level WebSocket client. These
   * events fire on the same thread as the WebSocket client. We log the events on the same thread,
   * and hand them off to the RunLoop for further processing.
   */
  private class WSClientHandlerImpl implements WSClientEventHandler {

    @Override
    public void onOpen() {
      logger.debug("{} Websocket opened", label);
      executorService.execute(new Runnable() {
        @Override
        public void run() {
          connectTimeout.cancel(false);
          everConnected = true;
          resetKeepAlive();
        }
      });
    }

    @Override
    public void onMessage(final String message) {
      logger.debug("{} WS message: {}", label, message);
      executorService.execute(new Runnable() {
        @Override
        public void run() {
          handleIncomingFrame(message);
        }
      });
    }

    @Override
    public void onClose() {
      logger.debug("{} Closed", label);
      if (!isClosed) {
        // If the connection tear down was initiated by the higher-layer, isClosed will already
        // be true. Nothing more to do in that case.
        executorService.execute(
            new Runnable() {
              @Override
              public void run() {
                onClosed();
              }
            });
      }
    }

    @Override
    public void onError(final Throwable e) {
      if (e instanceof EOFException || e.getCause() instanceof EOFException) {
        logger.debug("{} WebSocket reached EOF", label, e);
      } else {
        logger.error("{} WebSocket error", label, e);
      }
      executorService.execute(
          new Runnable() {
            @Override
            public void run() {
              onClosed();
            }
          });
    }
  }

  /**
   * Handles buffering of WebSocket frames. The database server breaks large messages into smaller
   * frames. This class accumulates them in memory, and reconstructs the original message
   * before passing it to the higher layers of the client for processing.
   */
  private static class StringList {

    private final List buffer;
    private int remaining;

    StringList(int capacity) {
      checkArgument(capacity > 0);
      this.buffer = new ArrayList<>(capacity);
      this.remaining = capacity;
    }

    int append(String frame) {
      checkState(hasRemaining());
      buffer.add(frame);
      return --remaining;
    }

    boolean hasRemaining() {
      return remaining > 0;
    }

    /**
     * Combines frames (message fragments) received so far into a single text message. It is an
     * error to call this before receiving all the frames needed to reconstruct the message.
     */
    String combine() {
      checkState(!hasRemaining());
      try {
        StringBuilder sb = new StringBuilder();
        for (String frame : buffer) {
          sb.append(frame);
        }
        return sb.toString();
      } finally {
        buffer.clear();
      }
    }
  }

  /**
   * Higher-level event handler ({@link Connection})
   */
  interface Delegate {

    void onMessage(Map message);

    void onDisconnect(boolean wasEverConnected);
  }

  /**
   * Low-level WebSocket client. Implementations handle low-level network IO.
   */
  interface WSClient {

    void connect();

    void close();

    void send(String msg);
  }

  interface WSClientFactory {

    WSClient newClient(
        WSClientEventHandler delegate);

  }

  private static class DefaultWSClientFactory implements WSClientFactory {

    final ConnectionContext context;
    final HostInfo hostInfo;
    final String optCachedHost;
    final String optLastSessionId;

    DefaultWSClientFactory(ConnectionContext context, HostInfo hostInfo, String
        optCachedHost, String optLastSessionId) {
      this.context = context;
      this.hostInfo = hostInfo;
      this.optCachedHost = optCachedHost;
      this.optLastSessionId = optLastSessionId;
    }

    public WSClient newClient(WSClientEventHandler delegate) {
      String host = (optCachedHost != null) ? optCachedHost : hostInfo.getHost();
      URI uri = HostInfo.getConnectionUrl(
          host, hostInfo.isSecure(), hostInfo.getNamespace(), optLastSessionId);
      return new NettyWebSocketClient(uri, hostInfo.isSecure(), context.getUserAgent(),
          context.getThreadFactory(), delegate);
    }
  }

  /**
   * Event handler that handles the events generated by a low-level {@link WSClient}.
   */
  interface WSClientEventHandler {

    void onOpen();

    void onMessage(String message);

    void onClose();

    void onError(Throwable t);
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy