com.google.firebase.database.connection.WebsocketConnection Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of firebase-admin Show documentation
Show all versions of firebase-admin Show documentation
This is the official Firebase Admin Java SDK. Build extraordinary native JVM apps in
minutes with Firebase. The Firebase platform can power your app’s backend, user
authentication, static hosting, and more.
/*
* 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);
}
}