/*
* Copyright 2017 OmniFaces
*
* 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 org.omnifaces.cdi.push;
import static java.lang.String.format;
import static java.util.Collections.emptyMap;
import static org.omnifaces.util.Beans.getInstance;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.PreDestroy;
import javax.enterprise.context.SessionScoped;
import javax.faces.view.ViewScoped;
import javax.inject.Inject;
/**
* <p>
* This web socket channel manager holds all application and session scoped web socket channel identifiers registered by
* <code><o:socket></code>.
*
* @author Bauke Scholtz
* @see Socket
* @since 2.3
*/
@SessionScoped
public class SocketChannelManager implements Serializable {
// Constants ------------------------------------------------------------------------------------------------------
private static final long serialVersionUID = 1L;
private static final String ERROR_INVALID_SCOPE =
"o:socket 'scope' attribute '%s' does not represent a valid scope. It may not be an EL expression and allowed"
+ " values are 'application', 'session' and 'view', case insensitive. The default is 'application'. When"
+ " 'user' attribute is specified, then scope defaults to 'session' and may not be 'application'.";
private static final String ERROR_DUPLICATE_CHANNEL =
"o:socket channel '%s' is already registered on a different scope. Choose an unique channel name for a"
+ " different channel (or shutdown all browsers and restart the server if you were just testing).";
/** A good developer will unlikely declare multiple application scoped push channels in same application (a global JS listener is more efficient). */
private static final int ESTIMATED_CHANNELS_PER_APPLICATION = 1;
/** A good developer will unlikely declare multiple session scoped push channels in same session (a global JS listener is more efficient). */
private static final int ESTIMATED_CHANNELS_PER_SESSION = 1;
/** A good developer will unlikely declare multiple view scoped channels in same view (a global JS listener is more efficient). */
private static final int ESTIMATED_CHANNELS_PER_VIEW = 1;
/** A good developer will unlikely allow the session to have more than one user (bad security practice, but technically not impossible). */
private static final int ESTIMATED_USERS_PER_SESSION = 1;
/** A good developer will unlikely declare more than three push channels in same application (one for each scope with each a global JS listener). */
static final int ESTIMATED_TOTAL_CHANNELS = ESTIMATED_CHANNELS_PER_APPLICATION + ESTIMATED_CHANNELS_PER_SESSION + ESTIMATED_CHANNELS_PER_VIEW;
static final Map<String, String> EMPTY_SCOPE = emptyMap();
private enum Scope {
APPLICATION, SESSION, VIEW;
static Scope of(String value, Serializable user) {
if (value == null) {
return (user == null) ? APPLICATION : SESSION;
}
for (Scope scope : values()) {
if (scope.name().equalsIgnoreCase(value) && (user == null || scope != APPLICATION)) {
return scope;
}
}
throw new IllegalArgumentException(format(ERROR_INVALID_SCOPE, value));
}
}
// Properties -----------------------------------------------------------------------------------------------------
private static final ConcurrentHashMap<String, String> APPLICATION_SCOPE = new ConcurrentHashMap<>(ESTIMATED_CHANNELS_PER_APPLICATION);
private final ConcurrentHashMap<String, String> sessionScopedChannels = new ConcurrentHashMap<>(ESTIMATED_CHANNELS_PER_SESSION);
private final ConcurrentHashMap<Serializable, String> sessionUsers = new ConcurrentHashMap<>(ESTIMATED_USERS_PER_SESSION);
@Inject
private SocketSessionManager socketSessions;
@Inject
private SocketUserManager socketUsers;
// Actions --------------------------------------------------------------------------------------------------------
/**
* Register given channel on given scope and returns the web socket channel identifier.
* @param channel The web socket channel.
* @param scope The web socket scope. Supported values are <code>application</code>, <code>session</code> and
* <code>view</code>, case insensitive. If <code>null</code>, the default is <code>application</code>.
* @param user The user object representing the owner of the given channel. If not <code>null</code>, then scope
* may not be <code>application</code>.
* @return The web socket channel identifier. This can be used as web socket URI.
* @throws IllegalArgumentException When the scope is invalid or when channel already exists on a different scope.
*/
@SuppressWarnings("unchecked")
protected String register(String channel, String scope, Serializable user) {
switch (Scope.of(scope, user)) {
case APPLICATION: return register(null, channel, APPLICATION_SCOPE, sessionScopedChannels, getViewScopedChannels(false));
case SESSION: return register(user, channel, sessionScopedChannels, APPLICATION_SCOPE, getViewScopedChannels(false));
case VIEW: return register(user, channel, getViewScopedChannels(true), APPLICATION_SCOPE, sessionScopedChannels);
default: throw new UnsupportedOperationException();
}
}
@SuppressWarnings("unchecked")
private String register(Serializable user, String channel, Map<String, String> targetScope, Map<String, String>... otherScopes) {
if (!targetScope.containsKey(channel)) {
for (Map<String, String> otherScope : otherScopes) {
if (otherScope.containsKey(channel)) {
throw new IllegalArgumentException(format(ERROR_DUPLICATE_CHANNEL, channel));
}
}
((ConcurrentHashMap<String, String>) targetScope).putIfAbsent(channel, channel + "?" + UUID.randomUUID().toString());
}
String channelId = targetScope.get(channel);
if (user != null) {
if (!sessionUsers.containsKey(user)) {
sessionUsers.putIfAbsent(user, UUID.randomUUID().toString());
socketUsers.register(user, sessionUsers.get(user));
}
socketUsers.addChannelId(sessionUsers.get(user), channel, channelId);
}
socketSessions.register(channelId);
return channelId;
}
/**
* When current session scope is about to be destroyed, deregister all session scope channels and explicitly close
* any open web sockets associated with it to avoid stale websockets. If any, also deregister session users.
*/
@PreDestroy
protected void deregisterSessionScope() {
for (Entry<Serializable, String> sessionUser : sessionUsers.entrySet()) {
socketUsers.deregister(sessionUser.getKey(), sessionUser.getValue());
}
socketSessions.deregister(sessionScopedChannels.values());
}
// Nested classes -------------------------------------------------------------------------------------------------
/**
* This helps the web socket channel manager to hold view scoped web socket channel identifiers registered by
* <code><o:socket></code>.
* @author Bauke Scholtz
* @see SocketChannelManager
* @since 2.3
*/
@ViewScoped
protected static class ViewScope implements Serializable {
private static final long serialVersionUID = 1L;
private ConcurrentHashMap<String, String> channels = new ConcurrentHashMap<>(ESTIMATED_CHANNELS_PER_VIEW);
/**
* Returns the view scoped channels.
* @return The view scoped channels.
*/
protected Map<String, String> getChannels() {
return channels;
}
/**
* When current view scope is about to be destroyed, deregister all view scoped channels and explicitly close
* any open web sockets associated with it to avoid stale websockets.
*/
@PreDestroy
protected void deregisterViewScope() {
SocketSessionManager.getInstance().deregister(channels.values());
}
}
// Internal -------------------------------------------------------------------------------------------------------
/**
* For internal usage only. This makes it possible to reference session scope channel IDs during injection time of
* {@link SocketPushContext} (the CDI session scope is not necessarily active during push send time).
* This should actually be package private, but package private methods in CDI beans are subject to memory leaks.
* @return Session scope channel IDs.
*/
protected Map<String, String> getSessionScopedChannels() {
return sessionScopedChannels;
}
/**
* For internal usage only. This makes it possible to reference view scope channel IDs during injection time of
* {@link SocketPushContext} (the JSF view scope is not necessarily active during push send time).
* This should actually be package private, but package private methods in CDI beans are subject to memory leaks.
* @param create Whether or not to auto-create the entry in JSF view scope.
* @return View scope channel IDs.
*/
protected Map<String, String> getViewScopedChannels(boolean create) {
ViewScope bean = getInstance(ViewScope.class, create);
return (bean == null) ? EMPTY_SCOPE : bean.getChannels();
}
/**
* For internal usage only. This makes it possible to resolve the session and view scoped channel ID during push
* send time in {@link SocketPushContext}.
*/
static String getChannelId(String channel, Map<String, String> sessionScope, Map<String, String> viewScope) {
String channelId = viewScope.get(channel);
if (channelId == null) {
channelId = sessionScope.get(channel);
if (channelId == null) {
channelId = APPLICATION_SCOPE.get(channel);
}
}
return channelId;
}
// Serialization --------------------------------------------------------------------------------------------------
private void writeObject(ObjectOutputStream output) throws IOException {
output.defaultWriteObject();
// All of below is just in case server restarts with session persistence or failovers/synchronizes to another server.
output.writeObject(APPLICATION_SCOPE);
HashMap<String, ConcurrentHashMap<String, Set<String>>> sessionUserChannels = new HashMap<>(sessionUsers.size());
for (String userId : sessionUsers.values()) {
sessionUserChannels.put(userId, socketUsers.getUserChannels().get(userId));
}
output.writeObject(sessionUserChannels);
}
@SuppressWarnings("unchecked")
private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
input.defaultReadObject();
// Below is just in case server restarts with session persistence or failovers/synchronizes from another server.
APPLICATION_SCOPE.putAll((Map<String, String>) input.readObject());
Map<String, ConcurrentHashMap<String, Set<String>>> sessionUserChannels = (Map<String, ConcurrentHashMap<String, Set<String>>>) input.readObject();
for (Entry<Serializable, String> sessionUser : sessionUsers.entrySet()) {
String userId = sessionUser.getValue();
socketUsers.register(sessionUser.getKey(), userId);
socketUsers.getUserChannels().put(userId, sessionUserChannels.get(userId));
}
// Below awkwardness is because SocketChannelManager can't be injected in SocketSessionManager (CDI session scope
// is not necessarily active during WS session). So it can't just ask us for channel IDs and we have to tell it.
// And, for application scope IDs we make sure they're re-registered after server restart/failover.
socketSessions.register(sessionScopedChannels.values());
socketSessions.register(APPLICATION_SCOPE.values());
}
}